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
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;
|
|
}
|
|
}
|
|
|