/// 恒玄(BES) 私有协议的**纯函数**编解码。 /// /// ⚠️ **协议冻结**:这里的每一个字节都对应已量产固件(Echo-one 0.0.2 / 0.0.3) /// 的真实行为,由 `test/devices/bes_protocol_test.dart` 用真机抓的帧做金测试。 /// 改任何一个常量、任何一处偏移,先看测试为什么红。 /// /// 这个文件不依赖 Flutter / GetX / 原生插件,只做 `List` ↔ 结构体。 library; /// App → 耳机 的命令(`AA xx`)。 /// /// 来源:厂商协议 + 真机实测(见各条注释)。请求码与响应码的关系**没有** /// 统一规律:多数是 `响应 = 请求 | 0x80`(06→86、0E→8E),但电量是 09→0A。 class BesCmd { BesCmd._(); static const int sof = 0xAA; /// 固件版本查询,回 `BB 86`(或主动上报 `BB 07`) static const int firmwareVersion = 0x06; /// 耳机状态(电量/位置/连接/MAC),回 `BB 0A` + ack `BB 89`。 /// 实测扫出来的请求码,文档里那个「响应命令 20」两种进制都无回包。 /// ⚠️ 固件 0.0.3 上此命令已不回 `BB 0A`。 static const int status = 0x09; /// 按键配置查询,回 `BB 8C` static const int keyConfig = 0x0C; /// 通话状态查询,回 `BB 8E`(data[6] 0x01=来电 / 0x02=通话中 / 0x03=结束) static const int callState = 0x0E; /// EQ 配置查询,回 `BB 90` static const int eqConfig = 0x10; /// 进入通话模式:耳机开始 G.722 mode=0x03 双路交织上行 static const int callModeStart = 0x51; /// 退出通话模式 static const int callModeStop = 0x52; /// 通话翻译下行 TTS 包(84 字节,由原生 `CallTranslationDownlink` 组包) static const int translationTts = 0x56; /// 通知耳机 ASR 已开启 static const int asrStarted = 0x57; /// 通知耳机 ASR 已关闭 static const int asrStopped = 0x58; /// 开始设备端主麦录音(mSBC 上行),设备回 `BB D9` static const int micRecordStart = 0x59; /// 停止设备端主麦录音,设备回 `BB DA` static const int micRecordStop = 0x5A; /// 请求退出 AI 模式,耳机回 `BB E5` static const int aiExit = 0x65; /// 开始面对面翻译,耳机回 `BB E6` static const int faceToFaceStart = 0x66; /// 结束面对面翻译,耳机回 `BB E7` static const int faceToFaceStop = 0x67; /// 应答 AI 唤醒(收到 `BB 64` 后 **3 秒内**必须发) static const int aiAccept = 0x6A; /// 这一段会切换耳机工作模式(进通话/录音/AI),探测时一律不碰 static bool switchesMode(int cmd) => cmd >= 0x51 && cmd <= 0x6A; static List frame(int cmd) => [sof, cmd & 0xFF]; } /// 耳机 → App 的响应/上报(`BB xx`)。 class BesResp { BesResp._(); static const int sof = 0xBB; /// 主动上报固件版本 static const int firmwarePush = 0x07; /// 响应 `AA 06` static const int firmwareVersion = 0x86; /// 耳机状态帧(电量/位置/连接/MAC) static const int status = 0x0A; /// `AA 09` 的 ack(`BB 89 04 00`),无数据 static const int statusAck = 0x89; /// 周期上报的另一种状态帧,`[len,attr,value]×2`,值 0/1,含义未定 static const int unknown18 = 0x18; } /// `BB 0A` 状态帧里的属性号。 class BesStatusAttr { BesStatusAttr._(); static const int batteryLeft = 0x01; static const int batteryRight = 0x02; static const int batteryCase = 0x03; /// 耳机位置(是否在盒内),值常 >100,**不是电量** static const int position = 0x04; /// 连接状态,值常 >100 static const int connection = 0x05; /// 预留 12 字节,里面嵌着一个含义未知的 MAC,**不要拿去确权** static const int reserved = 0x06; /// 耳机蓝牙地址 6 字节——耳机自报的真 MAC,iOS 上唯一可靠来源 static const int macAddress = 0x07; } /// `BB 0A` 解析结果。没出现的字段为 null。 class BesStatus { const BesStatus({ this.batteryLeft, this.batteryRight, this.batteryCase, this.chargingLeft = false, this.chargingRight = false, this.chargingCase = false, this.mac, }); final int? batteryLeft; final int? batteryRight; final int? batteryCase; final bool chargingLeft; final bool chargingRight; final bool chargingCase; /// 大写、冒号分隔,如 `11:11:22:33:33:A4` final String? mac; bool get hasBattery => batteryLeft != null || batteryRight != null || batteryCase != null; /// 「只显示一个数」时用:左右取较低一侧;两只都在盒里只有盒电量时退回盒。 int? get summaryBattery { final lr = [batteryLeft, batteryRight].whereType().toList(); if (lr.isNotEmpty) return lr.reduce((a, b) => a < b ? a : b); return batteryCase; } @override String toString() => 'BesStatus(L=$batteryLeft${chargingLeft ? '⚡' : ''} ' 'R=$batteryRight${chargingRight ? '⚡' : ''} ' 'C=$batteryCase${chargingCase ? '⚡' : ''} mac=$mac)'; } /// 固件版本帧解析结果。 class BesFirmware { const BesFirmware({this.left, this.right, this.box}); final String? left; final String? right; final String? box; /// 优先左耳。 String? get preferred => left ?? right ?? box; } class BesProtocol { BesProtocol._(); static String hex(Iterable b) => b .map((e) => (e & 0xFF).toRadixString(16).padLeft(2, '0').toUpperCase()) .join(' '); static String macOf(Iterable b) => b .map((e) => (e & 0xFF).toRadixString(16).padLeft(2, '0').toUpperCase()) .join(':'); /// 是不是一个 `BB` 开头、至少带命令号的帧。 static bool isResponse(List data) => data.length >= 2 && (data[0] & 0xFF) == BesResp.sof; /// 解析 `BB 0A` 状态帧。 /// /// 帧格式 `BB | 0A | CMD Length(1) | 块… | checksum`, /// 块是 `[len, 属性, 数据…]`,**len 计的是「属性 + 数据」的字节数**。 /// /// 三个踩过的坑(都会让电量永远显示不出来、且不报错): /// 1. 起点是 **3** 不是 4:长度字段只有 1 字节,照抄固件版本帧的 `p = 4` /// 会整体错位一格,第一个块长度读成 0x01 < 2 直接 break; /// 2. 充电盒是属性 **0x03** 不是 0x04(0x04 是耳机位置,值常 >100); /// 3. 电量字节最高位是充电指示位,必须 `& 0x7F`。 /// /// 属性 3(盒电量)**可选**:耳机不在盒内时这帧没有它。 static BesStatus parseStatus(List data) { int? l, r, c; var cl = false, cr = false, cc = false; String? mac; var p = 3; while (p + 2 < data.length) { final blockLen = data[p] & 0xFF; if (blockLen < 2 || p + blockLen >= data.length) break; final attr = data[p + 1] & 0xFF; final raw = data[p + 2] & 0xFF; final level = raw & 0x7F; final charging = (raw & 0x80) != 0; if (level <= 100) { switch (attr) { case BesStatusAttr.batteryLeft: l = level; cl = charging; break; case BesStatusAttr.batteryRight: r = level; cr = charging; break; case BesStatusAttr.batteryCase: c = level; cc = charging; break; } } if (attr == BesStatusAttr.macAddress && blockLen >= 7 && p + 7 < data.length) { mac = macOf(data.sublist(p + 2, p + 8)); } p += blockLen + 1; } return BesStatus( batteryLeft: l, batteryRight: r, batteryCase: c, chargingLeft: cl, chargingRight: cr, chargingCase: cc, mac: mac, ); } /// 解析固件版本帧(`BB 86` / `BB 07`): /// `BB CMD len(2) + 若干版本块 [len, 属性, 8字节版本]`, /// 属性 0x01左 / 0x02右 / 0x04盒;版本取版本块后 3 字节。 static BesFirmware parseFirmware(List data) { String? left, right, box; var p = 4; while (p + 1 < data.length) { final blockLen = data[p] & 0xFF; if (blockLen < 2 || p + blockLen >= data.length) break; final attr = data[p + 1] & 0xFF; final version = data.sublist(p + 2, p + 1 + blockLen); if (version.length >= 8) { final v = '${version[5]}.${version[6]}.${version[7]}'; switch (attr) { case 0x01: left = v; break; case 0x02: right = v; break; case 0x04: box = v; break; } } p += blockLen + 1; } return BesFirmware(left: left, right: right, box: box); } /// 通话状态查询 `BB 8E` 的 data[6]:0x01=来电 0x02=通话中 0x03=结束。 /// 原生已经把 0x02/0x03 翻成 startMakeCall/stopMakeCall 事件,这里只作备查。 static int? parseCallState(List data) { if (data.length < 7 || (data[1] & 0xFF) != 0x8E) return null; return data[6] & 0xFF; } }