You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

278 lines
8.8 KiB

/// 恒玄(BES) 私有协议的**纯函数**编解码。
///
/// ⚠️ **协议冻结**:这里的每一个字节都对应已量产固件(Echo-one 0.0.2 / 0.0.3)
/// 的真实行为,由 `test/devices/bes_protocol_test.dart` 用真机抓的帧做金测试。
/// 改任何一个常量、任何一处偏移,先看测试为什么红。
///
/// 这个文件不依赖 Flutter / GetX / 原生插件,只做 `List<int>` ↔ 结构体。
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<int> frame(int cmd) => <int>[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<int>().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<int> b) => b
.map((e) => (e & 0xFF).toRadixString(16).padLeft(2, '0').toUpperCase())
.join(' ');
static String macOf(Iterable<int> b) => b
.map((e) => (e & 0xFF).toRadixString(16).padLeft(2, '0').toUpperCase())
.join(':');
/// 是不是一个 `BB` 开头、至少带命令号的帧。
static bool isResponse(List<int> 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<int> 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<int> 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<int> data) {
if (data.length < 7 || (data[1] & 0xFF) != 0x8E) return null;
return data[6] & 0xFF;
}
}