deadboy18/Ocrustar-USB-IR

GitHub: deadboy18/Ocrustar-USB-IR

对Ocrustar/ElkSmart USB红外发射器的完整协议逆向工程与开源Python驱动,解决了三个关键编码bug,使设备可脱离官方App独立完成红外信号的学习与发射。

Stars: 0 | Forks: 0

# 逆向工程 Ocrustar USB 红外发射器 **关于 Ocrustar 智能 IR 红外发射器 (VID `045C` / PID `02AA`) 的完整协议分析与开源驱动**

Ocrustar USB IR Blaster
The Ocrustar USB IR Blaster — a $6 dongle that speaks a surprisingly complex protocol

## 目录 - [背景](#background) - [硬件](#the-hardware) - [逆向工程方法](#reverse-engineering-approach) - [第 1 步:USB 描述符分析](#step-1-usb-descriptor-analysis) - [第 2 步:APK 反编译](#step-2-apk-decompilation) - [第 3 步:Native 库分析](#step-3-native-library-analysis) - [第 4 步:流量捕获与关联](#step-4-traffic-capture--correlation) - [协议规范](#protocol-specification) - [USB 传输层](#usb-transport-layer) - [握手序列](#handshake-sequence) - [设备识别](#device-identification) - [IR 传输](#ir-transmission) - [IR 学习](#ir-learning) - [序列号查询](#serial-number-query) - [编码管道](#the-encoding-pipeline) - [阶段 1:脉冲压缩 (WAVZip)](#stage-1-pulse-compression-wavzip) - [阶段 2:带 ÷16 预缩放的 LEB128](#stage-2-leb128-with-16-prescaling) - [阶段 3:Huffman 编码 (仅限 D226)](#stage-3-huffman-coding-d226-only) - [阶段 4:组帧](#stage-4-framing) - [陷阱与难点](#the-gotchas) - [APK 反编译深入探究](#apk-decompilation-deep-dive) - [应用架构](#app-architecture) - [关键源文件](#key-source-files) - [VID/PID 白名单](#vidpid-whitelist) - [传输前验证](#pre-transmit-validation) - [USB Host Manager 配置](#usb-host-manager-configuration) - [云端 IR 数据库 (Kookong SDK)](#cloud-ir-database-kookong-sdk) - [Kookong API 端点](#kookong-api-endpoints) - [ElkSmart 后端端点](#elksmart-backend-endpoints) - [云端 IR 数据格式](#ir-data-format-in-cloud) - [Kookong SDK 加密](#kookong-sdk-encryption) - [BLE 协议](#ble-protocol) - [耗费了 15 个版本才修复的三个 Bug](#the-three-bugs-that-took-15-versions-to-fix) - [可用代码](#working-code) - [调试与故障排除](#debugging--troubleshooting) - [协议快速参考](#protocol-quick-reference) - [相关研究](#related-work) - [版本历史](#version-history) - [许可证](#license) ## 背景 官方应用名为 **Ocrustar**([Google Play](https://play.google.com/store/apps/details?id=com.payne.okux&hl=en) · [App Store](https://apps.apple.com/my/app/ocrustar/id1544017376) · [APKPure](https://apkpure.com/ocrustar-remote-control/com.payne.okux)),包名为 `com.payne.okux`,基于 ElkSmart IR SDK(`com.esmart.ir`)构建。 这些设备是一个设计方案的贴牌变种,以许多名称出现:ElkSmart、ZaZa Remote、Tiqiaa TView、ROCK IR 等等。它们都共享一个通用的芯片组和协议族,但不同硬件版本之间的具体编码细节有所不同。本文档涵盖的设备在握手期间标识为 **D226**,并使用了一个包含 Huffman 压缩的特别有趣的编码管道——这使得它比旧版 D552 变体更加复杂。 尽管销量很大,但该 USB 协议的**官方文档几乎为零**。在供应商的 Android 应用之外控制此设备的唯一方法是从头开始逆向工程该协议。这就是本项目所记录的内容。 ### 为什么要费这个心? - **家庭自动化**:从 Raspberry Pi、服务器或脚本发送 IR 命令 - **不依赖手机**:从任何电脑控制你的电视/空调/回音壁 - **批量操作**:编写脚本执行复杂的 IR 宏(例如,“午夜关闭所有设备”) - **可持续性**:Ocrustar 应用明天可能就会从 Play Store 消失——而且它依赖的中国云服务器也可能无法永远存在 ## 硬件 | 属性 | 值 | |---|---| | USB VID | `0x045C` (Renesas/NEC — 被设备借用) | | USB PID | `0x02AA` | | USB 类 | 厂商特定 (0xFF) | | 端点 | 1× 批量 IN (`0x81`),1× 批量 OUT (`0x01`) | | 最大包大小 | 64 字节 | | 载波频率 | 可配置,通常为 38 kHz | | 外形尺寸 | ~15 × 10 × 5 mm USB-A 插头 | | 芯片组 | 无标记 SoC,基于 ARM | | Windows 设备名 | `SMART` (制造商: `SMTCTL`) | 该设备呈现为一个带有厂商特定批量端点的标准 USB 设备。没有 HID 接口,没有标准 IR 类——一切都是专有的。 ``` InstanceId : USB\VID_045C&PID_02AA\5&33432EA&0&6 FriendlyName : SMART Manufacturer : SMTCTL Status : OK ``` ## 逆向工程方法 逆向工程遵循一种由外而内的分层策略。 ### 第 1 步:USB 描述符分析 第一步仅仅是插入设备,并使用 `lsusb -v` (Linux) 和 USBDeview (Windows) 读取其 USB 描述符。 ``` Bus 001 Device 007: ID 045c:02aa bDeviceClass 0 bDeviceSubClass 0 Endpoint Descriptor: bEndpointAddress 0x01 EP 1 OUT bmAttributes 2 Bulk wMaxPacketSize 64 Endpoint Descriptor: bEndpointAddress 0x81 EP 1 IN bmAttributes 2 Bulk wMaxPacketSize 64 ``` 主要观察结果:批量端点(而非中断/HID),64 字节最大包大小,以及 VID `045C`(实际上注册给 Renesas/NEC——该设备借用了他们的 VID)。PID `02AA` 在随后的握手中变得很重要——固件实际上将其自身的 PID 作为身份令牌回显。 ### 第 2 步:APK 反编译 使用 **JADX 1.5.1** 对 Ocrustar Android 应用(`com.payne.okux` v6.2.9)进行了反编译。该应用是围绕 ElkSmart IR SDK(`com.esmart.ir`)的 Java/Kotlin 包装器。主要发现如下: **Java 层** — `UsbHostManager` 类管理 USB 通信。该代码使用 `CommunicationRunnable` 处理认证状态机,为每个协议阶段带有清晰的标记。代码中保留的 Debug 日志语句对于在任何流量被捕获之前确认命令结构和参数顺序极其有帮助。 **IROTG 类** — 核心 IR 编码逻辑位于 `com.esmart.ir.IROTG` 中,它负责处理 WAVZip+Huffman 传输管道和 `~reverseBits(value)` 字节混淆。反编译出的 `a(byte)` 方法是理解该混淆函数的“罗塞塔石碑”。 **WAVZip 编码器** — 在 `com.esmart.ir.otg.b` 中找到,该 Kotlin 文件实现了脉冲对字典压缩。比较器类 `com.esmart.ir.otg.c` 揭示了关键的排序行为:脉冲对按总持续时间排序,而不是仅按频率。 **Huffman 编码器** — 分散在 HufMZip 包的 `b.a` 到 `b.f` 中。树构建器中对 `PriorityQueue` 的使用是关键的发现,它解释了为什么 Python 的 `heapq` 会生成错误的树。 ### 第 3 步:Native 库分析 使用 **Ghidra** 对 `libelksmart.so` / `libkksdk.so`(ARM,32 位)库进行静态分析。识别出的关键函数: 1. **脉冲压缩** — 接收原始 IR 时序数组,并提取两个最频繁的脉冲对作为字典条目进行压缩的函数 2. **LEB128 编码器** — 可变长度整数编码,但带有一个关键的转折点:所有值在编码前都除以 16(这是一个在任何地方都没有记录的预缩放步骤) 3. **Huffman 编码器** — 使用优先级队列构建完整的 Huffman 树,应用于压缩后的脉冲流 4. **字节混淆** — 每个协议字节在传输前都会进行位反转和取反操作 5. **校验和** — 按 62 字节帧应用的特定校验和算法 Kookong SDK 的 native 库(`libkksdk.so`)还包含用于 API 负载加密的 16 字节加密密钥 `Kf9j8Si15EKM9h4u`,尽管其加密算法本身是自定义的(非标准 AES)。 ### 第 4 步:流量捕获与关联 在通过静态分析部分了解协议后,使用了带有 USBPcap 插件(Windows)和 `usbmon`(Linux)的 **Wireshark** 来捕获 USB 流量。通过应用发送已知的 IR 信号(具有可预测位模式的 NEC 协议命令),并将生成的 USB 数据包与预期的编码输出进行关联。 这一步确认了每个编码阶段,并揭示了准确的帧格式,包括频率编码和负载长度字段。 **在 Windows 上设置 USBPcap:** ``` # 从 https://desowin.org/usbpcap/ 安装 USBPcap 并重启 # 识别您的 USB root hub: USBPcapCMD.exe # 开始 capture(选择带有 SMART 设备的 hub): USBPcapCMD.exe -d \\.\USBPcap1 -A -o capture.pcapng # 在另一个终端中,运行脚本: python ocrustar.py --send-test # 使用 Ctrl+C 停止 capture,在 Wireshark 中打开 # 有用的 filters:usb.transfer_type == 3 (bulk), usb.data_len > 0 ``` ## 协议规范 ### USB 传输层 所有通信均使用端点 `0x01`(OUT,主机→设备)和 `0x81`(IN,设备→主机)上的批量传输。最大传输大小为 64 字节。 大型负载被分割成 63 字节的帧:62 字节数据 + 1 字节校验和。最后一个帧可以更短,并且省略校验和。 ### 握手序列 每个会话都以三步握手开始: ``` Host → Device: FC FC FC FC (4 bytes: "hello") Device → Host: FC FC FC FC XX YY (6 bytes: "hello + device type") Host → Device: FA FA FA FA (4 bytes: "acknowledged") ``` 设备响应中的 `XX YY` 字节标识了硬件变体: | XX | YY | 设备类型 | 编码 | |---|---|---|---| | `0x02` | `0xAA` | D226 / D571 | 脉冲压缩 + Huffman | | `0x70` | `0x01` | D552 | 仅脉冲压缩 | D226 是较新、更常见的变体。D552 是较旧的版本,跳过了 Huffman 阶段。 **重要提示**:握手必须在约 1 秒内完成。如果设备在 200 毫秒内未响应 `FC FC FC FC`,请最多重试 3 次。在第一次尝试之前刷新 USB 端点可防止过期数据干扰握手。 `FA FA FA FA` 确认是**强制性的**。跳过它(正如某些第三方实现所做的那样)会使设备处于未就绪状态,在此状态下它会接受但忽略传输命令。 ### 设备识别 握手响应字节 `XX YY` 对应于 USB PID(`02 AA` → `0x02AA`)。这并非巧合——固件将其自身的 PID 作为身份令牌回显。 如果响应的前 4 个字节是 `FA FA FA FA` 而不是 `FC FC FC FC`,则字节 4–5 包含的是固件版本数据,而不是设备类型。 ### IR 传输 握手之后,IR 信号将作为带有固定头部的编码负载发送: ``` FF FF FF FF [freq_hi] [freq_mid] [freq_lo] [len_hi] [len_lo] [payload...] ``` - **`FF FF FF FF`** — 前导码(4 字节) - **频率** — 3 字节,经混淆处理,编码为 `(carrier_hz + 0x7FFFF)`,按 `[bits 15:8] [bits 23:16] [bits 7:0]` 拆分到各字节中 - **负载长度** — 2 字节,经混淆处理,大端序 - **负载** — 编码后的 IR 信号(见[编码管道](#the-encoding-pipeline)) 头部(频率和长度字段)中的每个字节都经过了**混淆**:先位反转,再按位取反。 设备成功时响应 `FF FF FF FF`(将前导码作为 ACK 回显)。 ### IR 学习 学习模式用于捕获来自物理遥控器的 IR 信号: ``` Host → Device: FE FE FE FE ("start learning") Device → Host: FE FE FE FE [len_hi] [len_lo] [raw_data...] Host → Device: FD FD FD FD ("stop learning") ``` 当检测到 IR 信号时,设备会做出响应。`len` 字段指示接下来要接收的原始数据的总字节数。数据可能在多次 USB 传输中到达——持续累积直到收集到 `len` 个字节。 原始数据的解码方式: 1. 对于每个满足 `b < 0xFF` 的字节 `b`:时序值 = `b × 16 + carry` 2. 对于每个满足 `b == 0xFF` 的字节 `b`:累加 `carry += 0xFF0`(4080µs 溢出标记) 这会产生一个以微秒为单位的时序值数组(交替的标记/间隔)。进位机制允许编码大于 4064µs(254 × 16)的值。 ### 序列号查询 可以通过以下方式获取设备序列号: ``` Host → Device: FB FB FB FB Device → Host: FB FB FB FB [serial_data...] ``` 如果响应恰好为 15 个字节,则序列号解码为:6 个 ASCII 字符(字节 4–9),随后是 3 个整数值(字节 10–12)、1 个 ASCII 字符(字节 13)和 1 个整数(字节 14),并拼接成一个字符串。 ## 编码管道 这是协议的核心,也是大部分逆向工程精力的投入之处。该管道将原始 IR 时序数组(以微秒为单位)转换为设备所期望的压缩负载。 ``` Raw timings (µs) │ ▼ ┌─────────────────────┐ │ Pulse Compression │ Dictionary encode top-2 pairs ("WAVZip") └──────────┬──────────┘ │ ▼ ┌─────────────────────┐ │ Huffman Encoding │ D226 only (skipped for D552) └──────────┬──────────┘ │ ▼ ┌─────────────────────┐ │ Framing + Checksum │ 62-byte chunks, mangled header └──────────┬──────────┘ │ ▼ USB bulk writes (2ms interval) ```

Encoding Pipeline Diagram

### 阶段 1:脉冲压缩 (WAVZip) “WAVZip”这个名称来自反编译的类名 `com.esmart.ir.otg.b` (WAVZip.kt)。IR 信号具有高度重复性——一个典型的 NEC 命令约有 34 个脉冲对,但只使用 2–3 个不同的(标记,间隔)持续时间。编码器利用了这一点: 1. **统计频率** — 计算每个 `(mark, space)` 对出现的频率 2. **提取前 2 个** — 获取两个最频繁的脉冲对 3. **按持续时间排序** — 较短的对成为索引 `0x00`,较长的成为 `0x01`(按 mark + space 之和排序) 4. **构建字典头部** — 发出这两个参考对(LEB128 编码) 5. **编码信号** — 用 `0x00` 或 `0x01` 替换匹配的脉冲对;对不匹配的脉冲对进行内联编码 输出格式: ``` [pair1_mark] [pair1_space] [pair0_mark] [pair0_space] FF FF FF [encoded_pulses...] ``` 注意**违反直觉的排序**:pair1(索引 `0x01`,即较长的脉冲对)在头部中首先被发出,随后是 pair0(索引 `0x00`,即较短的脉冲对)。`FF FF FF` 分隔符标志着字典的结束。 **示例** — 包含 560µs/560µs(短)和 560µs/1690µs(长)脉冲对的 NEC 信号: ``` Dictionary: pair1=(560,1690), pair0=(560,560) Header: LEB128(560) LEB128(1690) LEB128(560) LEB128(560) FF FF FF Body: 00 00 00 ... 01 01 ... (0x00 for short pair, 0x01 for long pair) ``` ### 阶段 2:带 ÷16 预缩放的 LEB128 各个时序值使用 [LEB128](https://en.wikipedia.org/wiki/LEB128)(小端基 128,Little-Endian Base 128)进行编码,这是一种可变长度整数编码。然而,存在一个**关键的预缩放步骤**,仅从代码上看并不明显: ``` encoded_value = LEB128( round(raw_microseconds / 16) ) ``` 每个时序值在 LEB128 编码之前都**除以 16**(带四舍五入)。这种预缩放将微秒级精度的时序映射到设备期望的字节范围内。设备硬件内部以 16µs 为节拍运行,因此预缩放将微秒转换为了设备节拍。如果没有这一步,编码值会大 16 倍,设备将默默地拒绝该负载。 LEB128 编码本身: ``` def leb128_encode(value): result = [] while True: byte = value & 0x7F value >>= 7 if value: byte |= 0x80 # set continuation bit if (byte & 0xFF) == 0xFF: byte = 0xFE # escape: 0xFF is reserved as separator result.append(byte) if not value: break return result ``` 特殊情况:值 ≤ 1 直接发出,不进行预缩放(这些是字典索引字节 `0x00`/`0x01`,而不是时序值)。 **预缩放示例:** | 原始值 (µs) | ÷16 (四舍五入) | LEB128 | |---|---|---| | 560 | 35 | `0x23` | | 1690 | 106 | `0x6A` | | 4500 | 281 | `0x99 0x02` | | 9000 | 563 | `0xB3 0x04` | | 40000 | 2500 | `0xC4 0x13` | ### 阶段 3:Huffman 编码 (仅限 D226) D226 变体在脉冲压缩数据之上增加了一个 Huffman 压缩层。这是协议中最复杂的部分。 **树构建:** 1. 统计脉冲压缩流中的字节频率 2. 使用**最小堆优先队列**构建 Huffman 树 3. 优先队列必须完全匹配 `java.util.PriorityQueue` 的平局决胜行为——固件是使用该类的 Java/JNI 代码编译的 **序列化格式:** ``` [symbol_count_hi] [symbol_count_lo] — number of unique symbols (2 bytes) [sym0] [weight0_hi] [weight0_lo] — symbol and its FREQUENCY (3 bytes each) [sym1] [weight1_hi] [weight1_lo] ... [pad_bits] — number of padding bits in last byte (1 byte) [huffman_bitstream...] — the compressed data ``` **关键细节:** 符号表存储的是**频率计数**(来自原始节点权重),而不是 Huffman 码长。设备固件使用这些频率来重建完全相同的 Huffman 树以进行解码。这很不寻常——大多数 Huffman 实现传输的是码长或规范代码。符号在序列化中按值(升序)排序。 **为什么这对兼容性很重要**:如果 Huffman 树与固件期望的树不完全匹配,解压将产生乱码。树的形状取决于优先队列中的平局决胜顺序,这就是为什么对 Java `PriorityQueue` 的模拟必不可少的原因。 ### 阶段 4:组帧 最终编码的负载(经过 Huffman 处理后)被封装进 USB 帧: 1. 前置 `FF FF FF FF` + 经混淆的频率(3 字节)+ 经混淆的长度(2 字节) 2. 分割成 62 字节的块 3. 对于每个完整的 62 字节块:附加 1 字节的校验和(总计:每帧 63 字节) 4. 最后一个块(如果小于 62 字节)按原样发送,不带校验和 **校验和算法:** ``` def checksum(frame_62_bytes): s = sum(frame_62_bytes) raw = (s & 0xF0) | ((s >> 8) & 0x0F) return mangle(raw) ``` **字节混淆**(用于频率、长度和校验和): ``` def mangle(byte): # Reverse bits, then invert reversed = 0 for i in range(8): reversed = (reversed << 1) | (byte & 1) byte >>= 1 return (~reversed) & 0xFF ``` 帧通过批量 USB 写入以 2ms 的间隔发送。APK 使用一个周期为 2ms 的 `Timer` 来调度每个块的写入。 ## 陷阱与难点 这些是耗费了大量调试时间的非显而易见的陷阱: ### 1. ÷16 预缩放 LEB128 编码在编码前将所有时序值除以 16。这不是 bug 也不是优化——这是固件解释输入数据的方式。设备硬件内部以 16µs 为节拍运行,因此预缩放将微秒转换为了设备节拍。遗漏这一点会导致 IR 输出慢 16 倍(并且完全无法工作)。设备不会报错——它会默默地接受数据且不产生任何效果。 ### 2. 字典脉冲对排序 频率最高的两个脉冲对按总持续时间(mark + space)排序,而不是按频率排序。较短的对获得索引 `0x00`,较长的对获得 `0x01`。在头部中,它们以**相反的顺序**发出:先是 pair1,然后是 pair0。这很容易弄反,一旦弄反,每个 `0` 都会解码为错误的脉冲宽度,每个 `1` 也会解码为另一种错误的脉冲宽度——从而产生一个反转的、无法识别的 IR 信号。 ### 3. Java PriorityQueue 平局决胜 当两个 Huffman 节点具有相同的权重时,`java.util.PriorityQueue` 的 `siftUp`/`siftDown` 方法决定哪个节点放在哪里。Python 的 `heapq` 与此行为不匹配。必须使用对 Java 实现的忠实移植来构建树,否则生成的 Huffman 编码将有所不同,并且设备将解码出乱码。 ### 4. LEB128 中的 0xFF 转义 字节 `0xFF` 在脉冲压缩格式中用作分隔符(`FF FF FF` 标志着字典的结束)。如果 LEB128 编码产生了 `0xFF` 字节,则必须将其替换为 `0xFE`。这是一个简单的冲突避免机制。 ### 5. 频率编码字节顺序 载波频率被编码为 `freq + 0x7FFFF` 并拆分为 3 个字节,但字节顺序**并不是**简单的大端序。其顺序为 `[bits 15:8]`、`[bits 23:16]`、`[bits 7:0]`——中间有效位优先。每个字节随后都会进行混淆。 ### 6. 强制性的 FA ACK 一些第三方实现(包括 [iodn/android-ir-blaster](https://github.com/iodn/android-ir-blaster))在握手响应后跳过了 `FA FA FA FA` 确认。Ocrustar APK 总是会发送它。省略此 ACK 会使设备处于一种状态:它会无误地接受 USB 写入,但从不点亮 IR LED。 ## APK 反编译深入探究 以下内容是使用 JADX 1.5.1 反编译 `com.payne.okux.apk`(Ocrustar v6.2.9)提取出来的。 ### 应用架构 Ocrustar 应用支持三种 IR 输出路径: - **USB OTG** — ElkSmart USB 加密狗(本文档的重点)。由 `com.esmart.ir.otg.UsbHostManager` 和 `com.esmart.ir.IROTG` 管理。 - **BLE** — 蓝牙低功耗 IR 发射器。由 `yc.bluetooth.androidble.ELKBLEManager` 管理。 - **内置 IR** — 带有 `ConsumerIrManager` 硬件 IR 发射器的手机(例如旧款三星、小米、华为)。使用 Android 的内置 API。 ### 关键源文件 | 反编译文件 | 用途 | |---|---| | `com.esmart.ir.otg.UsbHostManager` | USB 连接生命周期、批量 I/O、认证握手、学习数据接收 | | `com.esmart.ir.IROTG` | IR 发送/学习 API、WAVZip+Huffman 传输编码、字节混淆 | | `com.esmart.ir.otg.b` (WAVZip.kt) | WAVZip 压缩——脉冲对去重 + 可变长度编码 | | `b.a` 到 `b.f` (HufMZip.java) | Huffman 树构建器、编码器、字典、比较器 | | `a.a` 和 `a.b` (IROTG.kt inner) | 用于分块 USB 传输的定时器任务 | | `com.payne.okux.view.home.HomeActivityKotlin` | USB 设备验证(VID/PID 白名单) | | `com.payne.okux.view.newlearn.KeyLearningActivity` | 学习模式 UI + 测试播放 | | `com.payne.okux.utils.ArrayUtils` | 用于 IR 时序数据的 byte↔int 数组转换 | | `com.payne.okux.model.enu.Magic` | BLE 协议 magic 字节常量 | ### VID/PID 白名单 所有受支持的 ElkSmart 加密狗均使用 **VID = `0x045C`**。在 `HomeActivityKotlin.getCheckInterface()` 中白名单包含以下 PID: | PID (十六进制) | PID (十进制) | 标签 | 设备标记 | 备注 | |---|---|---|---|---| | `0x02AA` | 682 | "old" | `"old"` | 主要目标 — D226 (Huffman) | | `0x014A` | 330 | "old (229)" | `"old"` | 变体 | | `0x0134` | 308 | "5s" | `"308"` | | | `0x0195` | 405 | "4s" | `"405"` | | | `0x0184` | 388 | "foreign trade" | `"388"` | 出口/国际型号 | | `0x0130` | 304 | "304" | `"304"` | | | `0x0189` | 393 | "393" | `"393"` | | | `0x018F` | 399 | "399" | `"393"` | 与 393 共享标记 | | `0x0131` | 305 | "305" | `"305"` | | | `0x0132` | 306 | "306" | `"306"` | | | `0x0133` | 307 | "307" | `"307"` | | VID `0x4348` / PID `0x55E0` 被明确**拒绝**(显示提示:“PID {pid}, 非合适设备”——非法设备)。 ### 传输前验证 应用在编码前验证 IR 数据: - 数据数组不得为空 - 数据长度必须是偶数(mark/space 对) - 总信号持续时间必须小于 1,000,000µs(1 秒) - 不允许并发传输(`tempIndex` 必须为 0) 默认载波频率:USB OTG 为 `38000 Hz`,内置 IR 为 `68000 Hz`。 ### USB Host Manager 配置 USB 主机管理器使用以下参数进行初始化(来自 `HomeActivityKotlin.initOTG()`): ``` UsbHostManager.Builder(applicationContext) .setIndentify("quandoo", "Android2AndroidAccessory1", "showcasing android2android USB communication", "0.1", "http://quandoo.de", "42") .setReadWriteRate(5) // 190ms between read cycles .setNeedOrgReadData(true) .create() ``` “quandoo”标识符是演示/模板项目的遗留物——它们没有任何协议用途。读取缓冲区:16384 字节。批量传输超时:100ms。最大权限重试次数:2。 ## 云端 IR 数据库 (Kookong SDK) Ocrustar 应用使用 **Kookong (库控) SDK** 作为其云端 IR 码库。Kookong 是国内主要的 IR 数据库提供商,拥有涵盖电视、空调、机顶盒、风扇等数千种家电品牌和型号的代码。 **在 APK 中发现的硬编码凭据:** | 凭据 | 值 | 来源 | |---|---|---| | Kookong API 密钥 | `E5B72D808C79E3FB129D6C4EF3B22482` | `App.KooKongKey` | | Native 加密密钥 | `Kf9j8Si15EKM9h4u` `libkksdk.so` | **API 主机:** | 服务 | URL | 用途 | |---|---|---| | Kookong SDK | `https://sdk2.kookong.com` | IR 码数据库(品牌、型号、遥控器) | | ElkSmart API v4.1 | `https://api.elksmart.com/codeLibrary` | 用户账户、DIY 遥控器、OTA 更新 | | ElkSmart API v2 | `https://elkapi.elksmart.com/codeLibrary` | 广告、DIY 按键、UIR 上传 | | Legacy 控制台 | `http://console.elksmart.com` | 旧版后端 | | 论坛 | `https://bbs.elksmart.com` | 用户社区 | | 支持聊天机器人 | `https://www.chatbase.co/chatbot-iframe/VXIZRX-y6vE2sSB6kxwx2` | AI 支持 | | 支持邮箱 | `Alice@ELKsmart.com` | 直接联系 | ### Kookong API 端点 均位于 `https://sdk2.kookong.com` 之下: | 端点 | 用途 | |---|---| | `/m/brands` | 按类别列出家电品牌 | | `/m/models` | 列出某品牌的遥控器型号 | | `/m/remotes` | 获取遥控器布局 | | `/m/irs` | 获取多个按键的 IR 数据 | | `/m/ir` | 获取单个按键的 IR 数据 | | `/m/irsinglekey` | 获取单按键 IR 码 | | `/m/decodeir` | 将原始 IR 信号解码为协议 | | `/m/rctestkey` | 获取用于遥控器匹配的测试按键 | | `/m/samekeyremotes` | 查找具有匹配按键的遥控器 | | `/m/filterrc` | 过滤遥控器 | | `/m/tvboxir` | 机顶盒 IR 码 | | `/m/stb` | 机顶盒数据库 | | `/m/countrylist` | 支持的国家 | | `/m/programguide` | 电视节目指南 | | `/m/appver` | 应用版本检查 | ### ElkSmart 后端端点 位于 `https://api.elksmart.com/codeLibrary` 之下: | 端点 | 认证 | 用途 | |---|---|---| | `/Oauth/login` | 无 | 手机登录(短信验证码) | | `/Oauth/emailLogin` | 无 | 邮箱登录 | | `/Oauth/register` | 无 | 邮箱注册 | | `/Oauth/sendPhoneSms` | 无 | 请求短信验证 | | `/Oauth/sendEmailCode` | 无 | 请求邮件验证 | | `/Oauth/getAllKeys` | 无 | 获取所有 DIY 按键定义 | | `/Oauth/getAppPoster` | 无 | 获取宣传横幅 | | `/app/learn/get` | Token | 获取用户已学习的遥控器 | | `/app/learn/batchUpdate` | Token | 批量更新已学习的数据 | | `/app/learn/uirUpload` | 无 | 上传 UIR(已学习的 IR 数据) | | `/app/learn/deleteUserDiy` | Token | 删除用户的 DIY 遥控器 | | `/app/version/checkUpdate` | 无 | 检查应用更新 | | `/app/version/getAdData` | 无 | 获取广告数据 | | `/app/version/getModuleConfig` | 无 | 获取模块配置 | 经过认证的请求使用来自 `GlobalData.getInstance().getUserInfo().token` 的 bearer token。 ### 云端 IR 数据格式 由 `UirUploadParam` 使用的 UIR(用户 IR)上传格式: ``` { "frequency": 38000, "irkeys": [ { "keyId": 123, "keyName": "power", "irData": [9000, 4500, 560, 560, ...] } ] } ``` IR 数据存储为以微秒为单位的时序值数组——这与 `--send-raw` 使用的格式相同。 ### Kookong SDK 加密 SDK 使用 `libkksdk.so` 中的自定义 JNI 函数加密 API 负载。native `enc2` 函数使用 16 字节密钥 `Kf9j8Si15EKM9h4u`,但加密算法是自定义的(非标准 AES)。破解它需要使用 Ghidra 或 IDA Pro 反编译 ARM64 native 库。 ## BLE 协议 Ocrustar 应用还支持蓝牙低功耗 IR 发射器,使用的是该协议的简化版本。BLE 路径使用不同的命令令牌: | 命令 | 字节 | 用途 | |---|---|---| | BLE 学习 | `EC EC EC EC` | 通过 BLE 进入学习模式 | | BLE 停止学习 | `ED ED ED ED` | 通过 BLE 停止学习模式 | BLE 数据编码使用简化的 WAVZip,不带 Huffman 压缩。Mark 值偏置 +2056 后除以 16。Space 值直接除以 16。对于 marks,`0xFF` 字节变为 `0xFE`;对于 spaces,`0x7F` 变为 `0x7E`。 **BLE Magic 常量**(来自 `Magic` 枚举): | 枚举 | 十六进制 | 用途 | |---|---|---| | `IR_SINGLE_HEADER` | `0xFF` | 单按键 IR 头部 | | `IR_SINGLE_DATA` | `0xF0` | 单按键 IR 数据 | | `AIR_COND_WHOLE_HEADER` | `0xEF` | 空调整帧头部 | | `TV_WHOLE_HEADER` | `0xDF` | 电视整帧头部 | | `IPTV_WHOLE_HEADER` | `0xCF` | IPTV 整帧头部 | | `TEMP_HUMIDITY_CMD` | `0xAA` | 温湿度传感器 | | `DIY_LEARN` | `0x73` | DIY 学习模式 | | `VERSION` | `0x76` | 固件版本查询 | ## 耗费了 15 个版本才修复的三个 Bug 该脚本经历了 15 次迭代,直到学习和传输功能都能正常工作。从 v10 开始的每个版本都成功完成了握手,并且设备对每一次 USB 传输都回复了 ACK——但 IR LED 从未点亮。固件默默地拒绝了负载数据。 ### Bug 1 — LEB128 缩放 Bug(“烧毁扬声器”效应) **发生了什么:** 像 `4816` 微秒这样的 IR 时序值被直接编码成了 LEB128 格式。 **失败原因:** 微控制器固件在重建时序数据时,会自动将每个接收到的值乘以 16。因此它接收到 `4816`,乘以 16,得到 `77,056µs`——这是一个长得不可思议的脉冲,导致 PWM 定时器溢出。固件检测到溢出,认为该信号无效,于是默默中止。 **修复方法:** 在编码前将每个值除以 16(带四舍五入):`sv = int(v / 16.0 + 0.5)` ### Bug 2 — Java PriorityQueue 不匹配(“口音错误”) **发生了什么:** Huffman 压缩根据字节频率构建二叉树。当两个字节具有相同的频率时,树的结构取决于哪个节点在左、哪个在右。Python 的 `heapq` 和 Java 的 `PriorityQueue` 在打破这种平局时的方式不同。 **失败原因:** Ocrustar 应用是用 Java 编写的。其 Huffman 树是使用 `java.util.PriorityQueue` 构建的,它使用特定的 sift-up/sift-down 算法。Python 的 `heapq` 使用不同的算法。两者都能生成有效的 Huffman 树,但固件期望使用 Java 形状的树进行解码。 **修复方法:** 一个完美复制了 Java `PriorityQueue` 内部机制的自定义 `JavaPriorityQueue` 类——相同的 sift-up、sift-down 和平局决胜行为。 ### Bug 3 — WAVZip 字典交换(“反转模式”) **发生了什么:** WAVZip 压缩查找最常见的两个 mark/space 对,并为它们分配简写代码。早期版本按频率排序(最常见 = `0`)。 **失败原因:** 固件期望 `0` 始终是*较短*的对(按总持续时间),而 `1` 始终是*较长*的对。如果较长的对碰巧更频繁,分配就会互换——从而产生一个反转的、无法识别的 IR 信号。 **修复方法:** 找到前 2 个最频繁的脉冲对后,严格按总持续时间对它们进行排序:`top2.sort(key=lambda x: x[0] + x[1])` ## 可用代码 本仓库中的工具是一个独立的 Python 脚本,除了 `pyusb` 之外没有其他依赖: ``` # 安装 pip install pyusb libusb-package # Windows:首先通过 Zadig 安装 WinUSB 驱动(https://zadig.akeo.ie/) # 测试设备连接(仅握手) python ocrustar.py # 从物理遥控器学习信号 python ocrustar.py --learn # 发射已学习的信号 python ocrustar.py --send-raw "9000,4500,560,560,560,1690,..." --freq 38000 # 发送 NEC 测试模式 python ocrustar.py --send-test # 强制 D552 编码(适用于较旧的硬件版本) python ocrustar.py --send-raw "..." --force-d552 ``` 有关完整的实现,请参阅 [`ocrustar.py`](ocrustar.py);有关字节级别的协议参考,请参阅 [`docs/PROTOCOL.md`](docs/PROTOCOL.md)。 ### Windows 设置 (Zadig) 1. 插入 IR 发射器 2. 下载并运行 [Zadig](https://zadig.akeo.ie/) 3. 选项 → 列出所有设备 → 选择 **SMART** 4. 将目标驱动程序设置为 **WinUSB** → 点击 **替换驱动程序** 5. 在设备管理器中验证:“SMART” 应出现在通用串行总线设备下,且没有黄色三角形

Zadig WinUSB driver setup for SMART device
Zadig — select the SMART device and replace the driver with WinUSB

## 调试与故障排除 | 症状 | 原因 | 解决方法 | |---|---|---| | “找不到设备” | 驱动程序错误或未插入 | 通过 Zadig 安装 WinUSB | | 握手超时 | 设备处于死锁状态 | 拔出/重新插入加密狗(USB 重新上电) | | 握手成功但无 IR 输出 | 编码 bug | 使用本仓库的代码(三个 bug 均已修复) | | IR 发射但设备无响应 | 频率错误或学习数据有噪声 | 靠近遥控器重新学习信号 | | 间歇性握手失败 | 缺少 FA ACK | 此代码正确发送了 ACK | | 传输后返回 `FF FF FF FF` | 成功 | 这是设备确认它已执行命令的响应 | ### 验证 IR 输出 手机摄像头可以看到近红外光。打开你的相机应用,将其对准加密狗上的 IR LED,并发送一个信号。你应该能看到 LED 发出微弱的紫色/白色闪光。

IR LED flash visible through phone camera
IR LED firing — the purple glow is invisible to the naked eye but shows up on phone cameras

## 协议快速参考 | 命令 | 字节 | 方向 | |---|---|---| | 握手初始化 | `FC FC FC FC` | 主机 → 设备 | | 握手响应 | `FC FC FC FC XX YY` | 设备 → 主机 | | 握手 ACK | `FA FA FA FA` | 主机 → 设备 | | IR 传输 | `FF FF FF FF [freq×3] [len×2] [payload]` | 主机 → 设备 | | IR 传输 ACK | `FF FF FF FF` | 设备 → 主机 | | 开始学习 | `FE FE FE FE` | 主机 → 设备 | | 学习数据 | `FE FE FE FE [len×2] [raw]` | 设备 → 主机 | | 停止学习 | `FD FD FD FD` | 主机 → 设备 | | 获取序列号 | `FB FB FB FB` | 主机 → 设备 | ## 相关研究 - **[deadboy18/Tiqiaa-USB-IR-Windows](https://github.com/deadboy18/Tiqiaa-USB-IR-Windows)** — 我们的伴随项目:Tiqiaa USB IR 发射器(使用 ZaZa Remote 应用)的开源 Python 驱动。同一协议族(D552 变体),不同设备。如果你使用的是 Tiqiaa 设备而不是 Ocrustar,请使用那个仓库。 - **XenRE** — [逆向工程 Tiqiaa TView USB IR 收发器](https://habr.com/ru/articles/494800/) (Habr, 2020) + [GitLab](https://gitlab.com/XenRE/tiqiaa-usb-ir)。关于该设备系列的基础性工作。详细介绍了带有 Wireshark 捕获和 native 代码分析的 D552 变体协议。我们的 D226 分析基于相同的协议族,但记录了额外的 Huffman 压缩层以及 D226 引入的三个编码 bug。 - **Pawit Pornkitprasan** — [Tiqiaa USB IR Python](https://gitlab.com/pawitp/tiqiaa-usb-ir-py) + [Medium 文章](https://pawitp.medium.com/analyzing-remote-control-code-with-tiqiaa-zazaremote-adaptor-2ca17bed89fe)。Tiqiaa 变体的 Python 实现。 - **todormanev/cclairmont** — [tiqiaa_lirc](https://github.com/todormanev/tiqiaa_lirc)。用于 Linux 集成的 LIRC 用户空间驱动。 - **iodn/android-ir-blaster** — 由 NeroTeam Security Labs 开发的[开源 Android 应用](https://github.com/iodn/android-ir-blaster),具有部分 ElkSmart 支持 (Dart/Flutter + Kotlin)。支持 PID `0x0131`;PID `0x02AA` 检测有效,但传输失败(与本文档中记录的三个 bug 相同)。 ### 发现的协议差异 (n 与 Ocrustar APK 对比) 在开发过程中,我们将 iodn/android-ir-blaster 的实现与 Ocrustar APK 源代码进行了比较,发现了以下差异: | 方面 | Ocrustar APK | iodn 实现 | |---|---|---| | 认证 ACK | 身份识别后发送 `FA FA FA FA` | 不发送 ACK | | Huffman 填充字节 | 写入填充计数 (8 - 余数) | 写入余数 (length % 8) | | 脉冲对排序 | 按总持续时间对前 2 个脉冲对进行排序 | 不按持续时间排序,仅按频率顺序 | | 后台读取器 | 持续运行的读取线程 | 无后台读取器 | ## 版本历史 | 版本 | 变更内容 | 结果 | |---|---|---| | v1–v9 | VID 错误(`0x10C4` Silicon Labs),猜测的协议 | 找不到设备 | | v10 | 通过 APK 反编译获得正确的 VID,完整协议 | 握手有效,传输无响应 | | v11 | 修复了 Huffman 字典(使用频率而不是码长) | 仍然无响应 | | v12 | 移植了 iodn 实现,移除了 FA ACK | 仍然无响应,间歇性握手失败 | | v13 | 恢复了 FA ACK,测试了学习模式 | 学习有效,传输仍然无响应 | | v14 | 修复了 Huffman 填充字节 | 仍然无响应 | | v15 | ÷16 LEB128 缩放 + Java PQ 模拟 + 按持续时间排序的 WAVZip 对 | **学习和传输均可正常工作** | ## 仓库结构 ``` ocrustar-ir/ ├── README.md ← You are here (full reverse engineering writeup) ├── ocrustar.py ← Standalone Python driver ├── docs/ │ ├── PROTOCOL.md ← Byte-level protocol reference │ └── APK_ANALYSIS.md ← Full APK decompilation notes ├── images/ ← Photos and diagrams ├── LICENSE ← MIT └── requirements.txt ``` ## 许可证 MIT — 详见 [LICENSE](LICENSE)。 本项目是出于互操作性目的而进行的独立逆向工程。它不隶属于 Ocrustar、ElkSmart、Kookong、Tiqiaa 或任何设备制造商。
标签:APK反编译, BLE协议, ElkSmart, Huffman编码, Kookong SDK, Ocrustar, PID 02AA, Python驱动, USB协议, VID 045C, 云API文档, 云资产清单, 信号分析, 开源硬件, 智能家居, 物联网安全, 硬件分析, 红外遥控, 逆向工具, 逆向工程