
ioreg -p IOUSB -l -w 0 | grep -i -A 3 "LED\|DMX\|HID"system_profiler SPUSBDataType | grep -i -A 5 "LED\|DMX"ls /dev/cu.* /dev/tty.*
三条命令都搜不到这个设备 → 控制器没有 USB CDC/HID 接口,USB 只走 5V。剩下唯一通道是 BLE。
macOS 蓝牙权限是 TCC 卡点,没授权的脚本直接 SIGABRT,退出码 134。系统设置 → 隐私与安全 → 蓝牙 → 勾上跑脚本的 Terminal。注意是 跑脚本的那个进程:从 VS Code 集成终端跑就要授权 Code,从 iTerm 跑就要授权 iTerm。
# led_scan.pyimport asynciofrom bleak import BleakScannerasync def main(): for d in await BleakScanner.discover(timeout=8.0): print(f"{d.address} {d.rssi:4d} {d.name}")asyncio.run(main())
$ python3 led_scan.py4831826C-BDE4-2249-9D38-27438DDBCC41 -30 LEDDMX-03-C55A

RSSI -30 说明信号没问题。两个验证点:
# led_inspect.pyimport asynciofrom bleak import BleakClientUUID = "4831826C-BDE4-2249-9D38-27438DDBCC41"async def main(): async with BleakClient(UUID) as c: for s in c.services: print(f"SVC {s.uuid}") for ch in s.characteristics: print(f" CH {ch.uuid} {ch.properties}")asyncio.run(main())
输出:
SVC 0000ffe0-0000-1000-8000-00805f9b34fb CH 0000ffe1-0000-1000-8000-00805f9b34fb ['read','write-without-response','write','notify']
只有一个服务 FFE0 + 一个特征 FFE1。这是 TI CC254x 系列(HM-10、JDY-08 那一票)透传模块的指纹 —— 数据全从 FFE1 走,本身不带任何结构化协议,协议层在上面跑什么完全看灯条厂商的固件。
按 HM-10 灯条圈常见的几种(Triones、LEDnet、ELK-BLEDOM、MagicHome)一轮发过去,灯纹丝不动。这条路走不通。
macOS 自带 PacketLogger(Additional Tools for Xcode 里),能抓 BLE,但 GATT 写入的 payload 在系统日志里全是 <private>:
log stream --predicate 'subsystem == "com.apple.bluetooth"' --info
里面所有 ATT_WRITE 的 value 字段都被擦掉。log show --info --predicate ...一样。这是 iOS/macOS 隐私层做的脱敏,原生抓包路线走不通,要么 jailbreak 要么外接 BLE sniffer,都不划算。


二维码扫出来 App 叫 LED LAMP。Apple Silicon 的 Mac 可以直接跑 iOS App:App Store 装好之后,二进制落在:
/Applications/LED LAMP.app/Wrapper/LEDLAMP.app/LEDLAMP
file "/Applications/LED LAMP.app/Wrapper/LEDLAMP.app/LEDLAMP"# Mach-O 64-bit executable arm64otool -ov "/Applications/LED LAMP.app/Wrapper/LEDLAMP.app/LEDLAMP" > /tmp/ledov.txtotool -tV "/Applications/LED LAMP.app/Wrapper/LEDLAMP.app/LEDLAMP" > /tmp/leddis.txtwc -l /tmp/ledov.txt /tmp/leddis.txt# 346490 /tmp/ledov.txt# 1550110 /tmp/leddis.txt
-ov是 Objective-C 元数据(类、方法、protocol 等等),-tV是反汇编。
先 grep 所有名字带 send 的方法:
grep -n "sendDataRGB\|sendCMD\|sendMode\|sendBright" /tmp/ledov.txt | head
跳出来一个 sendCMDWithR:G:B:,名字最像。反编译出包格式 7B 07 R G B 05 P FF BF,发过去 —— 灯没反应。
问题在于:这个名字下面有 5 个不同地址的实现(不同类有同名方法)。光看名字会误判。写一个 Python 脚本把每个 imp 地址映射回 owning class:
# 解析 otool -ov 输出,给每个 imp 找它的 classimport relines = open("/tmp/ledov.txt").readlines()current_class = Nonetarget = "0x10055e4dc" # sendCMDWithR:G:B: 的地址for i, line in enumerate(lines): m = re.match(r'^ name\s+0x[0-9a-f]+\s+(\S+)\s*$', line) if m: current_class = m.group(1) if target in line and "imp" in line: print(f"{target} -> {current_class}")
输出:
0x10055e4dc -> ZJSetVCCAR02
CAR02—— 这是车灯控制器,不是我的灯条。教训:iOS App 反编译不能只看 selector 名字,一定要先确认 owning class。
把 5 个 sendDataRGBWithRed:green:blue:全跑一遍 owning class 映射:
0x1000be420 SUNSetViewController 太阳灯0x100367020 BLERhythmViewController 音乐律动0x100384d88 ZJSetViewController ★ 主设置控制器0x1003d3a0c ZJCustomVCCar01 车灯 Car010x10042bf54 ZJCustomViewController 通用 DIY
ZJ前缀对应制造商 szszjkj(深圳众基科技),跟灯条印刷的厂商一致。ZJSetViewController最有可能。
反编译这一个:
grep -n "^0000000100384d88" /tmp/leddis.txt# 918373:0000000100384d88 stp x20, x19, [sp, #-0x20]!
读它前 130 行汇编。函数读 [UserInfoPro shareInstance].serviceName(设备名前缀),跟一堆 cfstring 比较,按 model 分发:
918404 ldr x0, [x26, #0xd8] ; UserInfoPro 类918405 bl shareInstance918409 bl serviceName918415 ; cfstring 0x100709ee8 比较 → "LEDBLE"918420 ; cfstring 0x10070afa8 比较 → "LEDCAR-00"...
cfstring 是 __DATA,__cfstring段里的 CoreFoundation 字符串字面量结构(isa+info+ptr+length 共 32 字节)。手工解 Mach-O:
import struct as Sdata = open("/Applications/LED LAMP.app/Wrapper/LEDLAMP.app/LEDLAMP", "rb").read()def u32(o): return S.unpack_from("<I", data, o)[0]def u64(o): return S.unpack_from("<Q", data, o)[0]assert u32(0) == 0xfeedfacf # Mach-O 64ncmds = u32(0x10)# 遍历 LC_SEGMENT_64 收集所有 section 的 (vmaddr, fileoff, size)sections = {}o = 0x20for _ in range(ncmds): cmd, cmdsize = u32(o), u32(o+4) if cmd == 0x19: # LC_SEGMENT_64 nsects = u32(o + 64) for i in range(nsects): base = o + 0x48 + i*0x50 secname = data[base:base+16].rstrip(b"\x00").decode() segname = data[base+16:base+32].rstrip(b"\x00").decode() sections[(segname, secname)] = (u64(base+32), u32(base+48), u64(base+40)) o += cmdsizedef addr2off(a): for (sg, sn), (va, fo, sz) in sections.items(): if va <= a < va + sz: return fo + (a - va)def cfstring(addr): o = addr2off(addr) data_ptr = u64(o + 16) length = u64(o + 24) return data[addr2off(data_ptr):addr2off(data_ptr) + length].decode()for a in [0x100709ee8, 0x100709f08, 0x10070ae88, 0x10070aea8, 0x10070aec8, 0x10070af68, 0x10070afa8]: print(hex(a), repr(cfstring(a)))
0x100709ee8 'LEDBLE'0x100709f08 'LEDDMX-03' ← 我的设备0x10070ae88 'LEDDMX-00'0x10070aea8 'LEDDMX-01'0x10070aec8 'LEDDMX-02'0x10070af68 'LEDWIFI'0x10070afa8 'LEDCAR-00'
验证 Mach-O 解析对不对:随便挑几个已知字符串(比如 "HH"时间格式符),人工比对地址,确认和 otool 输出一致。再或者 strings -tx 二进制 | head看几个字符串的偏移,跟 addr2off反推的对不对得上。
汇编里三组对应:
LEDBLE / LEDCAR-00 → 7E FF 05 03 R G B FF EFLEDDMX-01 / LEDWIFI → 7B 04 07 R G B FF FF BFLEDDMX-00 / LEDDMX-02 / LEDDMX-03 → 7B FF 07 R G B FF FF BF ★
每条分支末尾都汇合到同一段:
918484 ldr x0, [..., NSData class]918486 bl _objc_alloc918487 add x2, sp, #0xc ; 9 字节缓冲区起点918488 mov w3, #0x9 ; length=9918489 bl initWithBytes:length:...918493 bl 0x10061fe00 ; objc msgSend stub
bl 0x10061fe00是个 objc_msgSend 桩(在 __TEXT,__objc_stubs段里),要解出它对应的 selector。每个桩 32 字节,前两条指令是 adrp x1, page+ ldr x1, [x1, #imm],加载选择器引用:
def adrp_imm(insn): immlo = (insn >> 29) & 3 immhi = (insn >> 5) & 0x7FFFF imm = (immhi << 2) | immlo if imm & (1 << 20): imm |= ~((1 << 21) - 1) return imm << 12def ldr_imm(insn): return ((insn >> 10) & 0xFFF) << 3def stub_selector(stub_addr): o = addr2off(stub_addr) i0, i1 = u32(o), u32(o+4) page = (stub_addr & ~0xFFF) + adrp_imm(i0) sel_ref = page + ldr_imm(i1) sel_str = u64(addr2off(sel_ref)) end = data.index(b"\x00", addr2off(sel_str)) return data[addr2off(sel_str):end].decode()print(stub_selector(0x10061fe00)) # → 'sendCMD:'print(stub_selector(0x1006309e0)) # → 'shareInstance'print(stub_selector(0x100620ba0)) # → 'serviceName'
完整的发包链条这下完整了:
[UserInfoPro shareInstance].serviceName # 拿到 "LEDDMX-03"按 model 分发,构造 9 字节包[NSData initWithBytes:packet length:9][self sendCMD:data] # 写到 FFE1
最直接的验证:让灯条按你指挥的顺序变色,肉眼看。
# led_verify.pyimport asynciofrom bleak import BleakClientUUID = "4831826C-BDE4-2249-9D38-27438DDBCC41"CH = "0000ffe1-0000-1000-8000-00805f9b34fb"def color(r, g, b): return bytes([0x7B, 0xFF, 0x07, r, g, b, 0xFF, 0xFF, 0xBF])async def main(): async with BleakClient(UUID) as c: seq = [("红",255,0,0), ("绿",0,255,0), ("蓝",0,0,255), ("黄",255,255,0), ("青",0,255,255), ("紫",255,0,255), ("白",255,255,255), ("灭",0,0,0)] for name, r, g, b in seq: pkt = color(r, g, b) print(f"{name} {pkt.hex(' ')}") await c.write_gatt_char(CH, pkt, response=False) await asyncio.sleep(2)asyncio.run(main())
灯条按顺序红绿蓝黄青紫白灭,每个 2 秒。对就是对,错就是错,没有半对的灰色地带。
几个非常重要的实现细节,都是踩过的坑:
反向验证:故意发错包,确认灯条不变。比如发 7E FF 05 03 R G B FF EF(这是 LEDBLE 的格式,不是我的灯条),确认灯没反应 → 说明包格式确实是 model 相关的,正向结果不是巧合。
颜色搞定后,要做沿着灯条流动的效果是另一组命令。同一个类里有 sendDataModelWithValue:modeName:,反编译出来是:
7B FF 03 <mode_id> FF FF FF FF BF
第三字节从 0x07(颜色)变成 0x03(模式),结构完全一样。模式 ID 一字节,没有列表,只能穷举:
# mode_explorer.py — 跑遍 mode 1..48,每个停 3 秒import asynciofrom bleak import BleakClientUUID = "4831826C-BDE4-2249-9D38-27438DDBCC41"CH = "0000ffe1-0000-1000-8000-00805f9b34fb"async def main(): async with BleakClient(UUID) as c: for m in range(1, 49): pkt = bytes([0x7B, 0xFF, 0x03, m, 0xFF, 0xFF, 0xFF, 0xFF, 0xBF]) print(f">>> mode {m}") await c.write_gatt_char(CH, pkt, response=False) await asyncio.sleep(3)asyncio.run(main())
边跑边记哪些编号是想要的效果。这个灯条 mode 16 是多色块流动,正好对上"五彩斑斓闪动"的需求。
led_scan.py 扫描确认设备set_color.py R G B 单色set_mode.py N 切到内置 mode N
set_mode.py连上 → 发一个 9 字节包 → 断开。控制器固件会一直跑这个模式,不需要 Python 进程挂着。
# set_mode.pyimport asyncio, sysfrom bleak import BleakClientUUID = "4831826C-BDE4-2249-9D38-27438DDBCC41"CH = "0000ffe1-0000-1000-8000-00805f9b34fb"async def main(mode): pkt = bytes([0x7B, 0xFF, 0x03, mode, 0xFF, 0xFF, 0xFF, 0xFF, 0xBF]) async with BleakClient(UUID) as c: await c.write_gatt_char(CH, pkt, response=False) await asyncio.sleep(0.1) await c.write_gatt_char(CH, pkt, response=False) # 防丢asyncio.run(main(int(sys.argv[1])))
走完整个流程后能确定的边界:
帧率 ~10 Hz(再高 BLE 就丢包)空间控制 没有逐 LED 接口,只能选预设颜色自由度 整条单色 OR 内置模式固定调色板自定义效果 DIY 模式只能写有限的几个颜色槽位
如果只想从内置花样里挑一个好看的,到这里就结束了。如果原本想做"真正的海浪从灯条一端涌到另一端、用我自己指定的海洋色",这条路结构性做不到 —— 要的是逐像素实时控制,那就得拆掉控制器,把灯条数据线接到 ESP32,刷 WLED,30 行 Python 通过 DDP 协议直接推像素帧。但那是另一个故事。
bleak | |
otool -ov-tV | |
Python + struct | |
grep | |
整个过程不需要越狱、不需要 IDA/Ghidra、不需要 BLE sniffer,全套 macOS 命令行工具就能搞定。