Living with it · Chapter 30 · Time: 3 hours · Level: Intermediate · Status: Done on this robot
The robot switches a Magicshine HORI 900 bike light on over Bluetooth Low Energy and reads its model and battery. You learn how BLE works, decode the lamp's frames, write light_hori.py - and see why the robot cannot switch this lamp off, and what that means for the next light.
The objective is a light on Matt's work. The light on hand is a Magicshine HORI 900 bike light, which takes commands
over Bluetooth Low Energy (BLE). The robot can switch it on, set its brightness and read its model and battery. It
cannot switch it off: no off command for this lamp exists in any source, and every candidate the record found was
tried on 2026-10-07.
This chapter builds the part that works, records exactly what was tried for off, and ends with the rule for buying the
next light.
Everything here was done on this robot on 2026-10-07 between about 17:58 and 19:00 UTC: the Bluetooth fix, the GATT
read-out, the frames, light_hori.py on and query. Not done: Bluetooth across a real reboot, and any way to switch
the lamp off.
Before you send anything to the lamp
- The owner's rule since 2026-10-07 ~18:50 UTC: the light must be off by default, so switching it on over BLE is
not usable until an off exists. No frames have been sent to the lamp since.- Every
onwrites a custom mode into the lamp's memory (slot 1) and switches the lamp to it. It changes what the
lamp's own button cycles through. Slot 1 already holds a test mode from 2026-10-07.- Parked at 50-99 % with no airflow, the lamp reached 59 °C after a few minutes.
queryonly reads. Runononly if you accept all three points above.
The robot's Bluetooth is the second half of the RTL8852CE Wi-Fi card, USB ID 0bda:0852. Chapter 7 made it work with
scripts/setup/robot_bt_rtk.sh.
New idea: why a radio chip needs firmware from the driver
Many Bluetooth chips start from a small program in their own ROM and need a patch loaded by the host driver before
they work properly. This chip reports its ROM version aslmp_subver 0x8852. The kernel's own driver on 5.15
(btusbwithbtrtl) does not know the chip, logs "unknown IC info, lmp subver 8852 ... assuming no firmware
upload needed", loads nothing, and a 20 s scan found zero devices in a house full of them. NVIDIA'srtk_btusb
(packagenvidia-l4t-kernel-oot-modules) loads the patch from/lib/firmware; after it the chip reports
0xa40a.
Check the three things that must be true: the chip's two USB interfaces belong to rtk_btusb, the adapter hci0 is up
with the patched version, and BlueZ has it powered. On this robot the Bluetooth device sits at USB path 1-3.
On the robot:
for i in /sys/bus/usb/devices/1-3/1-3:1.*; do echo "$(basename $i) -> $(basename "$(readlink -f $i/driver 2>/dev/null)")"; done
hciconfig -a | grep -E "hci|UP|Manufacturer|LMP" | head -4
bluetoothctl show | grep -E "Controller|Powered"
Check
Real output, 2026-10-07 17:58-17:59 UTC:1-3:1.0 -> rtk_btusb 1-3:1.1 -> rtk_btusb hci0: Type: Primary Bus: USB UP RUNNING LMP Version: (0xc) Subversion: 0xa40a Manufacturer: Realtek Semiconductor Corporation (93) Controller <robot-bluetooth-MAC> (public) Powered: yes
If the interfaces belong to btusb or the subversion reads 0x8852, run the fix from chapter 7 (the record copied it to
/tmp and ran it there):
On your laptop:
cd ~/CCode/rosorin-pro
scp -q scripts/setup/robot_bt_rtk.sh rosorin-wifi:/tmp/robot_bt_rtk.sh
ssh rosorin-wifi 'bash /tmp/robot_bt_rtk.sh 2>&1; echo "wifi still up: $(cat /sys/class/net/wlP1p1s0/operstate)"'
Check
The script prints the binding, the driver's firmware lines andhciconfig. On 2026-10-07 (three firmware lines
left out here):1-3:1.0 -> rtk_btusb 1-3:1.1 -> rtk_btusb rtk_btusb: fw: exists, config file: exists rtk_btusb: load_firmware done rtk_btusb: read_ver_rsp->lmp_subver = 0xa40a rtk_btusb: patch_entry->lmp_sub = 0x8852 rtk_btusb: Rtk patch end 0 hci0: Type: Primary Bus: USB UP RUNNING LMP Version: (0xc) Subversion: 0xa40a wifi still up: up
Not test-built
The fix has not been seen across a real reboot. A boot was simulated (btusb removed, the device re-enumerated, it
came up onrtk_btusb, patched), and/boot/initrdholds no Bluetooth modules, so the blacklist in
/etc/modprobe.d/rosorin-bt-rtk.confshould hold at boot. The robot last booted at about 17:51 UTC, before the fix
(17:58 UTC), and had not rebooted when this was written.
If it fails
The Wi-Fi hang of 2026-10-07 17:44 UTC came about 30 s after a Bluetooth scan with the old driver. The same Wi-Fi
firmware errors were logged at 17:22-17:27, before any Bluetooth use, so a link is not proven. The record printed
the Wi-Fi state after every Bluetooth command (cat /sys/class/net/wlP1p1s0/operstate); do the same. After the
fix, a 12 s scan saw 16 devices with the Wi-Fi up and 0 rtw89 errors.
New idea: advertising and connections
A BLE device that wants to be found broadcasts short packets called advertisements: its address, often a name,
sometimes small data. A scanner (the robot) listens for them. To exchange commands the scanner connects; while
connected, most devices stop advertising. The HORI 900 advertises asM1-B0 HORI_900from address
<light-BLE-address>, with no manufacturer data and no service list, only a short service-data field forFFE1
(16 01 00 xx 02 00 00 64). While anything is connected to it - the robot or a phone - it stops advertising.
Its radio stays on when its light is off: at 18:45 UTC on 2026-10-07 the robot connected to it and switched it on
while it was off at the button.
New idea: GATT - services, characteristics, descriptors
Once connected, a BLE device exposes a table called GATT. The table holds services; each service holds
characteristics, which are small values with properties:read,write,notify,indicate. A characteristic
may carry descriptors; descriptor2902switches its notifications on and off. Short 16-bit IDs such asFFE0are
shorthand for 128-bit UUIDs of the form0000ffe0-0000-1000-8000-00805f9b34fb, which is what the tools print.
New idea: write and notify
The robot sends a command by writing bytes to a characteristic. A "write request" waits for the device to confirm
the write at the protocol level. The device answers by notifying: it pushes new bytes on a characteristic the robot
has subscribed to. The HORI 900 uses one characteristic,FFE0, for both: the robot writes a frame, the lamp
notifies a reply frame.
The HORI 900's table, read on the robot on 2026-10-07 with busctl:
| Service | Characteristic | Properties | What it is |
|---|---|---|---|
1801 (Generic Attribute) |
2A05 |
indicate | standard |
FFE1 |
FFE0 |
read, write, notify | the Magicshine frame channel |
FFE1 |
FFE2 "feature" |
read | value 01 00 01 |
FFE1 |
FFE3 "status", FFE4 "control write", FFE5 "control rsp", FFE6 "network write", FFE7 "network rsp" |
not used | |
FEB3 |
FED4-FED8 |
firmware update (OTA) |
The device calls itself "Simple BLE MultiRole" once connected: the name of a Texas Instruments sample firmware. It has
no standard light-control service.
New idea: BlueZ on D-Bus
On Linux the Bluetooth daemonbluetoothd(BlueZ) does the radio work and publishes every adapter, device,
service and characteristic as an object on the system D-Bus, a message bus that programs use to call each other.
The lamp is/org/bluez/hci0/dev_<light-BLE-address-with-underscores>; itsFFE0characteristic is
/org/bluez/hci0/dev_<light-BLE-address-with-underscores>/service000c/char000d(the numbers are handles BlueZ read from the lamp).
A program calls methods on these objects (Connect,StartNotify,WriteValue) and receives replies as
PropertiesChangedsignals.bluetoothctlandbusctlare command-line clients of the same objects.
Put the lamp near the robot, make sure no phone is connected to it, then scan for 12 s and count what the robot
hears. This is the record's command:
On the robot:
bluetoothctl power on >/dev/null 2>&1
{ echo "scan on"; sleep 12; echo "scan off"; sleep 1; echo "devices"; sleep 1; echo quit; } | bluetoothctl 2>&1 | sed 's/\x1b\[[0-9;]*m//g' | grep -E "^Device|NEW\] Device" | sed 's/.*Device //' | sort -u > /tmp/bt_scan.txt
echo "devices seen: $(wc -l < /tmp/bt_scan.txt)"; grep HORI /tmp/bt_scan.txt
Check
devices seen: 16on 2026-10-07 (0 before the driver fix), and a line with<light-BLE-address>and the name
M1-B0 HORI_900.
Connect and list the characteristics with their properties (adapted from the record's command of 18:18 UTC, which also
read every value):
On the robot:
M=<light-BLE-address>; P=/org/bluez/hci0/dev_<light-BLE-address-with-underscores>
{ echo "scan on"; sleep 5; echo "scan off"; echo "connect $M"; sleep 7; echo quit; } | bluetoothctl >/dev/null 2>&1
echo "connected: $(busctl get-property org.bluez $P org.bluez.Device1 Connected)"
for o in $(busctl tree org.bluez 2>/dev/null | grep -oE "dev_<light-BLE-address-with-underscores>/service[0-9a-f]+/char[0-9a-f]+$" | sort -u); do
p=/org/bluez/hci0/$o
echo "$o uuid=$(busctl get-property org.bluez $p org.bluez.GattCharacteristic1 UUID | cut -d'"' -f2 | cut -c5-8) flags=[$(busctl get-property org.bluez $p org.bluez.GattCharacteristic1 Flags | cut -d' ' -f3-)]"
done
bluetoothctl disconnect $M
Check
Lines from the record:connected: b true dev_<light-BLE-address-with-underscores>/service0008/char0009 uuid=2a05 flags=["indicate"] dev_<light-BLE-address-with-underscores>/service000c/char000d uuid=ffe0 flags=["read" "write" "notify"]
If it fails
Failed to connect: org.bluez.Error.Failed le-connection-abort-by-local: BlueZ connects only to a device it has
seen in a recent scan. Scan first, as the command above does;light_hori.pyruns a 4 s discovery before every
connect for the same reason.- The lamp does not show up: a phone (the Magicshine app) or another program is connected to it, so it stopped
advertising.- Notifications vanish when you test with
busctl:StartNotifystays on only while the D-Bus client that asked
for it is connected.busctlexits after each call, so the subscription ends at once (the record read back
notifying: b false). A program that stays connected while it writes and listens is needed: that is
light_hori.py.
Every message, both ways, is one frame on FFE0:
Board bytes:
DE len cmd payload ... xor ED
DE starts the frame and ED ends it.len is the length of the whole frame in bytes, which is the payload length plus 5.xor is len XOR cmd XOR every payload byte.B: A1, A4 and AD are answered by B1, B4 and BD, but A6 is answered by B7 andA2 by B6.The format and the command numbers come from open-source captures of the official app
(lenne0815/karoo-magicshine-controls, TimFourcode/evo1300, m4jki/allty-1500s-control) and were checked on this lamp on
2026-10-07:
| Sent | Frame | Reply on this lamp | Meaning |
|---|---|---|---|
A1 |
DE06A100A7ED |
B1, for example DE0DB100001118071F0103AFED |
lamp information |
A4 |
DE06A400A2ED |
B4 DE13B40000000000640000000000000000C3ED |
battery: the byte at index 8 (counting DE as 0) is 0x64 = 100, coarse |
AD |
DE06AD00ABED |
BD carrying the text HORI_900 |
model name |
A6 01 EF |
DE07A601EF4FED |
B7 DE07B70001B1ED |
sent by the official app before an output frame; meaning not decoded |
A2 |
20 bytes, below | B6 DE07B60001B0ED |
add a custom mode to a slot; the lamp switches to it |
In the B1 replies in the command log, the byte at index 8 follows the lamp's temperature in °C: 0x1F = 31 at the
first query (18:21 UTC), 0x3B = 59 at 18:47 UTC with the lamp on at 50 %. (hardware.md calls it "byte 7"; it counts
differently.)
The A2 frame used for "on at full brightness" is copied byte for byte from a published capture of the official app:
Board bytes:
DE 14 A2 01 01 01 01 63 00 01 50 00 00 00 00 00 00 BB 3F ED
| Index | Value | Meaning |
|---|---|---|
| 0 | DE |
start |
| 1 | 14 |
length, 20 bytes |
| 2 | A2 |
command: add a custom mode |
| 3-6 | 01 01 01 01 |
copied from the capture; not decoded field by field on this lamp |
| 7 | 63 |
brightness, 99. light_hori.py puts 1-99 here |
| 8-16 | 00 01 50 00 00 00 00 00 00 |
copied from the capture |
| 17 | BB |
flag: BB = add (save) the mode. Required on the HORI 900 |
| 18 | 3F |
XOR |
| 19 | ED |
end |
What A2 does comes from the ALLTY 1500S project, where every frame was confirmed on the lamp: A2 adds a
custom mode to slot 1-20 (last data byte BB = add, AA = delete, brightness 1-100), and the lamp switches to the mode
it was given. That is why "on" works: the robot adds a bright mode and the lamp shows it. It is not a live "set output"
command.
If it fails
- No
B6at all: first check that the flag byte isBB. The EVO 1300 project's "apply only" flag00is silently
rejected by the HORI 900, in both published frame layouts.B6is not proof of success. Its byte at index 4 is a status:01for an accepted add
(DE07B60001B0ED),00for the two delete frames (AA) sent at 18:49 UTC (DE07B60000B1ED). What00means
is not established.light_hori.pycounts anyB6as acknowledged.
Compute the frames on the Mac before you write the program that sends them. Start python3 and paste:
On your laptop:
def frame(cmd, payload):
ln = len(payload) + 5
c = ln ^ cmd
for b in payload:
c ^= b
return bytes([0xDE, ln, cmd] + list(payload) + [c, 0xED])
print(frame(0xA1, [0]).hex().upper())
print(frame(0xA6, [0x01, 0xEF]).hex().upper())
print(frame(0xA2, [1, 1, 1, 1, 0x63, 0, 1, 0x50, 0, 0, 0, 0, 0, 0, 0xBB]).hex().upper())
Check
DE06A100A7ED DE07A601EF4FED DE14A20101010163000150000000000000BB3FEDThe third line is the published full-brightness capture, byte for byte, including its XOR
3F.
The program connects to the lamp through BlueZ, subscribes to FFE0, writes a list of frames 180 ms apart (the
official app's spacing), collects the replies and decides from them. It uses only python3-dbus (1.2.18) and
python3-gi (3.42.1), which are on the stock image: nothing to install. It runs with the system python3, not a
venv. You will write buddy_link/light_hori.py in the repo on the Mac and copy it to the robot.
The docstring is the program's manual and the record of the protocol. Create buddy_link/light_hori.py and start it:
On your laptop:
#!/usr/bin/env python3
"""The work light: Magicshine HORI 900 over BLE (BlueZ D-Bus; system python3-dbus + python3-gi, nothing installed).
Needs the robot's Bluetooth on rtk_btusb (scripts/setup/robot_bt_rtk.sh).
light_hori.py on [level 1-99] (default 99 = full)
light_hori.py query model, battery, temperature (read-only)
There is NO off: no confirmed BLE power-off exists for this lamp (2026-10-07). Off = the lamp's button.
Exit 0 = the light acknowledged the output frame (B6), 1 = no acknowledgement.
Protocol (open-source captures: lenne0815/karoo-magicshine-controls, TimFourcode/evo1300; checked on this light
2026-10-07): service FFE1, char FFE0 (write + notify), frames DE <len> <cmd> <payload> <xor(len..payload)> ED.
The official app's order: A1 (info), A6 01 EF (acknowledged B7), A2 output (acknowledged B6), A4 (battery), 180 ms
apart. On the HORI 900 the A2 frame is only accepted with byte 17 = 0xBB (FLAG_SAVE): with 0x00 ("apply only") it
is silently rejected, in both published layouts. A2 = ADD a custom mode to a slot (0xBB add / 0xAA delete, brightness
1..100; m4jki/allty-1500s-control, confirmed on an ALLTY 1500S = M1-B3); the lamp switches to the added mode. A
brightness-0 "off" is acknowledged but the light stays on (owner, 2026-10-07).
"""
import sys, time
import dbus, dbus.mainloop.glib
from gi.repository import GLib
The address is the lamp's own; the two paths are the BlueZ objects from the "BlueZ on D-Bus" box.
On your laptop:
ADDR = '<light-BLE-address>' # advertises "M1-B0 HORI_900"; AD query answers "HORI_900"
DEV = '/org/bluez/hci0/dev_' + ADDR.replace(':', '_')
FFE0 = DEV + '/service000c/char000d'
The same frame() you ran on the Mac, and the four fixed queries:
On your laptop:
def frame(cmd, payload):
ln = len(payload) + 5
c = ln ^ cmd
for b in payload:
c ^= b
return bytes([0xDE, ln, cmd] + list(payload) + [c, 0xED])
A1, A4, AD = frame(0xA1, [0]), frame(0xA4, [0]), frame(0xAD, [0])
A6 = frame(0xA6, [0x01, 0xEF])
output(level) builds the A2 frame with the brightness at index 7 of the frame (index 4 of the payload), capped at
0x63 = 99. The level <= 0 branch is the brightness-0 frame from the off attempts; main() never passes 0, so it is
not reachable from the command line.
On your laptop:
def output(level):
"""Module-1 layout (Karoo capture DE14A20101010163000150000000000000BB3FED = full); level 0 = off."""
if level <= 0:
return frame(0xA2, [1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xBB])
return frame(0xA2, [1, 1, 1, 1, min(level, 0x63), 0, 1, 0x50, 0, 0, 0, 0, 0, 0, 0xBB])
run(frames) does the BLE work. In order:
Connect with a 20 s timeout, then wait 1.5 s for the GATT table.PropertiesChanged on the FFE0 object: every notification's bytes go into rx.StartNotify on FFE0. It stays on because this process keeps its D-Bus connection open until it exits.settle_ms (1.5 s) for late replies,For on, the exchange looks like this (the replies arrived in this order on 2026-10-07 18:47 UTC):
On your laptop:
def run(frames, settle_ms=1500):
dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
bus = dbus.SystemBus()
dev = bus.get_object('org.bluez', DEV)
if not dev.Get('org.bluez.Device1', 'Connected', dbus_interface='org.freedesktop.DBus.Properties'):
adapter = dbus.Interface(bus.get_object('org.bluez', '/org/bluez/hci0'), 'org.bluez.Adapter1')
try:
adapter.StartDiscovery() # BlueZ only connects to a device it has seen recently
time.sleep(4)
adapter.StopDiscovery()
except dbus.DBusException:
pass
dbus.Interface(dev, 'org.bluez.Device1').Connect(timeout=20)
time.sleep(1.5)
rx = []
bus.add_signal_receiver(lambda iface, ch, inv: 'Value' in ch and rx.append(bytes(ch['Value'])),
'PropertiesChanged', 'org.freedesktop.DBus.Properties', 'org.bluez', path=FFE0)
ch = dbus.Interface(bus.get_object('org.bluez', FFE0), 'org.bluez.GattCharacteristic1')
ch.StartNotify() # stays on while this process holds the D-Bus connection
loop = GLib.MainLoop()
def send(i=0):
if i < len(frames):
ch.WriteValue(dbus.Array(frames[i], signature='y'), {'type': 'request'})
GLib.timeout_add(180, send, i + 1)
else:
GLib.timeout_add(settle_ms, loop.quit)
return False
GLib.timeout_add(300, send)
loop.run()
return rx
send() returns False so that GLib runs each timer once; the next timer is set inside it.
query sends A1, A4, AD and prints the model from the BD reply and the battery byte from the B4 reply; it
exits 0 if anything answered. off prints that there is no off and exits 1 without touching the lamp. on sends the
official app's sequence A1, A6, the A2 output, A4, and exits 0 if any B6 came back. The 'off' test inside
the final print is a leftover: by then a[0] can only be on. The docstring promises the temperature from query;
the code does not print it.
On your laptop:
def main():
a = sys.argv[1:] or ['query']
if a[0] == 'query':
rx = run([A1, A4, AD])
for r in rx:
if r[2] == 0xBD:
print('model', r[4:-2].rstrip(b'\0').decode(errors='replace'))
elif r[2] == 0xB4:
print('battery byte', r[8], '(coarse: 100/50/30 on the EVO 1700)')
return 0 if rx else 1
if a[0] == 'off':
print('no BLE off for the HORI 900 (brightness 0 is not a valid mode; tested 2026-10-07): use the lamp button')
return 1
if a[0] != 'on':
sys.exit(__doc__)
level = max(1, int(a[1]) if len(a) > 1 else 0x63)
rx = run([A1, A6, output(level), A4])
ok = any(r[2] == 0xB6 for r in rx)
print(f"light {a[0]}{'' if a[0] == 'off' else ' ' + str(level)}: {'acknowledged' if ok else 'NO acknowledgement'}")
return 0 if ok else 1
if __name__ == '__main__':
sys.exit(main())
buddy_link/light_hori.py, 102 lines, as in the repo:
On your laptop:
#!/usr/bin/env python3
"""The work light: Magicshine HORI 900 over BLE (BlueZ D-Bus; system python3-dbus + python3-gi, nothing installed).
Needs the robot's Bluetooth on rtk_btusb (scripts/setup/robot_bt_rtk.sh).
light_hori.py on [level 1-99] (default 99 = full)
light_hori.py query model, battery, temperature (read-only)
There is NO off: no confirmed BLE power-off exists for this lamp (2026-10-07). Off = the lamp's button.
Exit 0 = the light acknowledged the output frame (B6), 1 = no acknowledgement.
Protocol (open-source captures: lenne0815/karoo-magicshine-controls, TimFourcode/evo1300; checked on this light
2026-10-07): service FFE1, char FFE0 (write + notify), frames DE <len> <cmd> <payload> <xor(len..payload)> ED.
The official app's order: A1 (info), A6 01 EF (acknowledged B7), A2 output (acknowledged B6), A4 (battery), 180 ms
apart. On the HORI 900 the A2 frame is only accepted with byte 17 = 0xBB (FLAG_SAVE): with 0x00 ("apply only") it
is silently rejected, in both published layouts. A2 = ADD a custom mode to a slot (0xBB add / 0xAA delete, brightness
1..100; m4jki/allty-1500s-control, confirmed on an ALLTY 1500S = M1-B3); the lamp switches to the added mode. A
brightness-0 "off" is acknowledged but the light stays on (owner, 2026-10-07).
"""
import sys, time
import dbus, dbus.mainloop.glib
from gi.repository import GLib
ADDR = '<light-BLE-address>' # advertises "M1-B0 HORI_900"; AD query answers "HORI_900"
DEV = '/org/bluez/hci0/dev_' + ADDR.replace(':', '_')
FFE0 = DEV + '/service000c/char000d'
def frame(cmd, payload):
ln = len(payload) + 5
c = ln ^ cmd
for b in payload:
c ^= b
return bytes([0xDE, ln, cmd] + list(payload) + [c, 0xED])
A1, A4, AD = frame(0xA1, [0]), frame(0xA4, [0]), frame(0xAD, [0])
A6 = frame(0xA6, [0x01, 0xEF])
def output(level):
"""Module-1 layout (Karoo capture DE14A20101010163000150000000000000BB3FED = full); level 0 = off."""
if level <= 0:
return frame(0xA2, [1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xBB])
return frame(0xA2, [1, 1, 1, 1, min(level, 0x63), 0, 1, 0x50, 0, 0, 0, 0, 0, 0, 0xBB])
def run(frames, settle_ms=1500):
dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
bus = dbus.SystemBus()
dev = bus.get_object('org.bluez', DEV)
if not dev.Get('org.bluez.Device1', 'Connected', dbus_interface='org.freedesktop.DBus.Properties'):
adapter = dbus.Interface(bus.get_object('org.bluez', '/org/bluez/hci0'), 'org.bluez.Adapter1')
try:
adapter.StartDiscovery() # BlueZ only connects to a device it has seen recently
time.sleep(4)
adapter.StopDiscovery()
except dbus.DBusException:
pass
dbus.Interface(dev, 'org.bluez.Device1').Connect(timeout=20)
time.sleep(1.5)
rx = []
bus.add_signal_receiver(lambda iface, ch, inv: 'Value' in ch and rx.append(bytes(ch['Value'])),
'PropertiesChanged', 'org.freedesktop.DBus.Properties', 'org.bluez', path=FFE0)
ch = dbus.Interface(bus.get_object('org.bluez', FFE0), 'org.bluez.GattCharacteristic1')
ch.StartNotify() # stays on while this process holds the D-Bus connection
loop = GLib.MainLoop()
def send(i=0):
if i < len(frames):
ch.WriteValue(dbus.Array(frames[i], signature='y'), {'type': 'request'})
GLib.timeout_add(180, send, i + 1)
else:
GLib.timeout_add(settle_ms, loop.quit)
return False
GLib.timeout_add(300, send)
loop.run()
return rx
def main():
a = sys.argv[1:] or ['query']
if a[0] == 'query':
rx = run([A1, A4, AD])
for r in rx:
if r[2] == 0xBD:
print('model', r[4:-2].rstrip(b'\0').decode(errors='replace'))
elif r[2] == 0xB4:
print('battery byte', r[8], '(coarse: 100/50/30 on the EVO 1700)')
return 0 if rx else 1
if a[0] == 'off':
print('no BLE off for the HORI 900 (brightness 0 is not a valid mode; tested 2026-10-07): use the lamp button')
return 1
if a[0] != 'on':
sys.exit(__doc__)
level = max(1, int(a[1]) if len(a) > 1 else 0x63)
rx = run([A1, A6, output(level), A4])
ok = any(r[2] == 0xB6 for r in rx)
print(f"light {a[0]}{'' if a[0] == 'off' else ' ' + str(level)}: {'acknowledged' if ok else 'NO acknowledgement'}")
return 0 if ok else 1
if __name__ == '__main__':
sys.exit(main())
deploy.sh has no target for this file. Check that it parses, then copy it (the record's commands):
On your laptop:
cd ~/CCode/rosorin-pro
python3 -c "import ast;ast.parse(open('buddy_link/light_hori.py').read())" && scp -q buddy_link/light_hori.py rosorin-wifi:buddy_link/light_hori.py
query only reads. The lamp must be in range and not connected to a phone.
On the robot:
timeout 40 python3 ~/buddy_link/light_hori.py query; echo "exit $?"
Check
Real output, 2026-10-07 18:30 UTC:battery byte 100 (coarse: 100/50/30 on the EVO 1700) model HORI_900 exit 0
Read the warning at the top of this chapter first. on takes a level from 1 to 99 (default 99):
On the robot:
timeout 40 python3 ~/buddy_link/light_hori.py on 50; echo "exit $?"
Check
Real output ofonat the default level, 2026-10-07 18:30 UTC:light on 99: acknowledged exit 0
on 50at 18:45 UTC printedlight on 50: acknowledged. The lamp lights. On 2026-10-07 around 18:40 UTC the
owner saw it, and so did the robot's camera (frames from the robot API's/frame.jpgbefore and after).
If it fails
NO acknowledgement, exit 1: the lamp did not answerB6. Check that it is charged, in range and not connected
to a phone, then runquery.- A D-Bus error from
Connect: the discovery before it did not see the lamp. Run the scan from "See it yourself".- The record always ran the program under
timeout 40. Keep it: a radio call that never returns then ends the
run instead of holding your shell.
Every candidate the record could find was sent to the lamp on 2026-10-07. None switched it off (the owner watched).
| Tried | Frame | Source | Result |
|---|---|---|---|
Brightness 0 in the Karoo capture's layout: what light_hori.py off sent until the correction |
DE14A20101010100000000000000000000BB0DED |
output(0), commit d2b7e7b |
B6, light stays on (18:30-18:39 UTC) |
| The Karoo "OFF" | as published | karoo-magicshine-controls, captured on a two-group EVO 1700 | B6, light stays on, "maybe dimmer" (owner) |
| The karoofirefly M1 off (channel 1, brightness 0) | DE14A20101000000010100000000000000BB0DED |
karoofirefly, from the official app | B6, light stays on, "maybe dimmer" (owner) |
| Every channel disabled, slot 1 | DE14A20101000000000000000000000000BB0DED |
hardware.md | B6, light stays on |
Flag 00 ("apply only") |
evo1300 | rejected, no B6 |
|
| The info query on two other writable characteristics | DE06A100A7ED |
no reply at all |
Two more facts close the door. The lamp's button sends no notification on any notify or indicate characteristic, so the
robot cannot even see a manual off. And none of the four public projects (karoo-magicshine-controls, karoofirefly,
evo1300, allty-1500s-control) has a confirmed off for a single-group lamp. lessons.md (2026-10-07) adds: "The owner's
phone app could not find it either." Brightness 0 is not a valid mode, and A2 only adds modes.
Magicshine lists two controls for this lamp: the MAGICSHINE app (Bluetooth) and the MJ-6558 remote ("FTR LightSync")
(hardware.md, from retailer pages). The lamp's USB-C port only charges: plugged into the robot it does not enumerate.
Not test-built
- Whether the MJ-6558 remote can switch the HORI 900 off, and over which radio it talks to the lamp, has not been
checked on this lamp. The record only names the remote.- Slot 1 of the lamp still holds a test mode written on 2026-10-07. Two delete frames (
AA) were sent at 18:49
UTC and answeredB6 00; later add frames wrote slot 1 again. Deleting it needs the owner's OK
(hardware.md, status.md).
On 2026-10-06 the plan said a Magicshine Bluetooth bike light would be "decoded here when it arrives", at "~75-85 %
confidence". The lamp arrived, the robot switched it on about 35 minutes after its Bluetooth first worked, and hours
of trial frames on the owner's lamp - each one saved into its button modes - found no off. The project's rule since
then (AGENTS.md rule 10, lessons.md 2026-10-07):
Lesson: a part is "verified" only when every operation the robot needs (on AND off at minimum) is confirmed by a
published capture or a test on the same model. Never recommend at a confidence percentage; never "decode it when it
arrives". Before writing guessed frames to the owner's device: stop and say what is known.
For the next light, that means all of these hold for the exact model before it is bought:
docs/research/work_light_reference.md estimatesNo part has passed this test yet, and this guide names none.
hciconfig -a shows UP RUNNING and Subversion: 0xa40a, and both interfaces at 1-3 belong to rtk_btusb.light_hori.py query prints model HORI_900 and exits 0.A2 frame by hand and get the XOR 3F of the published capture.on to the lamp without accepting that it writes a mode into slot 1.Where this comes from
buddy_link/light_hori.py(commits d2b7e7b and 7767c8b, 2026-10-07);scripts/setup/robot_bt_rtk.sh(commit
e6d5716);docs/hardware.md2026-10-07 entries (Bluetooth, Magicshine HORI 900 over BLE, HORI 900 control,
USB-C charge only);docs/lessons.md2026-10-07 "The work light was bought on a guess";docs/status.mdend
(2026-10-07 18:05, 18:40, 18:50, 19:00 UTC);docs/runbook.md(work light row);AGENTS.mdrule 10;
docs/research/work_light_reference.md. Command log (cmdlog_robot.md), 2026-10-07: driver state 17:43, rebind
17:58, scan 17:59, GATT reads 18:16-18:18, queries 18:21,on/query18:30,on 5018:45, all-disabled 18:47,
delete frames 18:49, karoofirefly frames 18:50. Frames in the check box computed on the Mac.