/// Echo-one 面对面 BLE 协议的纯函数编解码。 /// /// 字节布局以 `docs/face_to_face_ble_protocol.md` v0.2 为准, /// 由 `test/devices/bes_f2f_protocol_test.dart` 钉死。原生组包必须与这里一致。 library; class BesF2f { BesF2f._(); static const int protoVer = 0x02; static const int capG722 = 0x01; static const int codecG722 = 0x01; static const int earLeft = 0x01; static const int earRight = 0x02; static const int magic0 = 0xF2; static const int magic1 = 0xF2; /// 控制与音频分开的四个特征。UUID 按协议占位,固件已按此实现。 static const String serviceUuid = 'AEAF5F32-0001-4C4B-A100-000000000001'; static const String ctrlTxUuid = 'AEAF5F32-0002-4C4B-A100-000000000001'; static const String ctrlRxUuid = 'AEAF5F32-0003-4C4B-A100-000000000001'; static const String audioUpUuid = 'AEAF5F32-0004-4C4B-A100-000000000001'; static const String audioDownUuid = 'AEAF5F32-0005-4C4B-A100-000000000001'; static const int opHello = 0x01; static const int opSessionCfg = 0x02; static const int opStartStream = 0x03; static const int opStopStream = 0x04; static const int opKeepalive = 0x05; static const int opExit = 0x06; static const int opAudioUp = 0x90; static const int opAudioDown = 0x91; static const int opEvent = 0x87; static const int opPace = 0x88; static const int roleApp = 0x01; static const int vadAlways = 0x00; static const int triggerContinuous = 0x00; static const int defaultGain = 80; static const int flagVad = 0x10; static const int flagSegBegin = 0x20; static const int flagSegEnd = 0x40; static const int flagOwnVoice = 0x80; static const int flagPlayBegin = 0x10; static const int flagPlayEnd = 0x20; static const int flagFlush = 0x40; static const int evtButton = 0x01; static const int evtWear = 0x02; static const int evtBattery = 0x03; static const int evtStats = 0x04; static const int evtFault = 0x05; static const int evtExitRequest = 0x06; static const int exitUser = 0x01; static const int exitBattery = 0x02; static const int exitIncomingCall = 0x03; static const int exitPeerLost = 0x04; static const int ok = 0x00; static const int errUnsupported = 0x01; static const int errBusy = 0x02; static const int errEarUnavailable = 0x03; static const int errToken = 0x04; static const int errBleResource = 0x05; static const int errCodec = 0x06; static const int errTimeout = 0x07; static const int errEarMismatch = 0x08; static const int errOverflow = 0x09; static const int errInternal = 0x0A; static const int errTwsSplit = 0x0B; /// `AA 6B 06 ` static List enterFrame(List token) { if (token.length != 4) { throw ArgumentError('token must be 4 bytes'); } return [ 0xAA, 0x6B, 0x06, protoVer, capG722, token[0] & 0xFF, token[1] & 0xFF, token[2] & 0xFF, token[3] & 0xFF, ]; } static List exitFrame() => const [0xAA, 0x6C]; /// 解析 `BB EB`。长度不够返回 null。 static BesF2fEnterAck? parseEnterAck(List frame) { if (frame.length < 10) return null; if ((frame[0] & 0xFF) != 0xBB || (frame[1] & 0xFF) != 0xEB) return null; return BesF2fEnterAck( result: frame[3] & 0xFF, earMask: frame[4] & 0xFF, protoVer: frame[5] & 0xFF, codec: frame[6] & 0xFF, frameMs: frame[7] & 0xFF, fwMinor: frame[8] & 0xFF, classicKeep: (frame[9] & 0xFF) == 0x01, ); } static bool isExitAckOk(List frame) { if (frame.length < 4) return false; return (frame[0] & 0xFF) == 0xBB && (frame[1] & 0xFF) == 0xEC && (frame[3] & 0xFF) == ok; } /// 广播里 Manufacturer Data 去掉 Company ID 之后的 9 字节。 static BesF2fAdvert? parseAdvertPayload(List afterCompanyId) { if (afterCompanyId.length < 9) return null; if ((afterCompanyId[0] & 0xFF) != magic0 || (afterCompanyId[1] & 0xFF) != magic1) { return null; } final ear = afterCompanyId[3] & 0xFF; if (ear != earLeft && ear != earRight) return null; return BesF2fAdvert( version: afterCompanyId[2] & 0xFF, ear: ear, token: List.unmodifiable(afterCompanyId.sublist(4, 8)), battery: afterCompanyId[8] & 0x7F, charging: (afterCompanyId[8] & 0x80) != 0, ); } static List hello(List token) => [ 0xAA, opHello, 0x06, protoVer, token[0] & 0xFF, token[1] & 0xFF, token[2] & 0xFF, token[3] & 0xFF, roleApp, ]; static List sessionCfg() => [ 0xAA, opSessionCfg, 0x03, vadAlways, defaultGain, triggerContinuous, ]; static List startStream() => [0xAA, opStartStream, 0x00]; static List stopStream() => [0xAA, opStopStream, 0x00]; static List keepalive(int seq) => [0xAA, opKeepalive, 0x01, seq & 0xFF]; static List exitEar() => [0xAA, opExit, 0x00]; /// `AA 91` 下行音频。payload = flags(含耳位) + seq16le + g722。 static List audioDown({ required int ear, required int flags, required int seq, required List g722, }) { final head = (ear & 0x0F) | (flags & 0xF0); final body = [head, seq & 0xFF, (seq >> 8) & 0xFF, ...g722]; return [0xAA, opAudioDown, body.length & 0xFF, ...body]; } /// 解析一条 BLE 通知帧。不是本协议返回 null。 static BesF2fBleFrame? parseBle(List frame) { if (frame.length < 3) return null; final sof = frame[0] & 0xFF; if (sof != 0xAA && sof != 0xBB) return null; final op = frame[1] & 0xFF; final len = frame[2] & 0xFF; if (frame.length < 3 + len) return null; return BesF2fBleFrame(op: op, payload: frame.sublist(3, 3 + len)); } static BesF2fAudio? parseAudioUp(BesF2fBleFrame frame) { if (frame.op != opAudioUp || frame.payload.length < 3) return null; final b = frame.payload[0] & 0xFF; return BesF2fAudio( ear: b & 0x0F, flags: b & 0xF0, seq: (frame.payload[1] & 0xFF) | ((frame.payload[2] & 0xFF) << 8), g722: List.unmodifiable(frame.payload.sublist(3)), ); } /// 用户能看懂的失败文案键。未知码用超时。 static String messageKey(int code) { switch (code) { case errUnsupported: return 'f2fFirmwareUpgrade'; case errBusy: return 'f2fEndOtherFeature'; case errEarUnavailable: return 'f2fWearBothEars'; case errTwsSplit: return 'f2fPutBackInCase'; default: return 'f2fEarNotReady'; } } } class BesF2fEnterAck { const BesF2fEnterAck({ required this.result, required this.earMask, required this.protoVer, required this.codec, required this.frameMs, required this.fwMinor, required this.classicKeep, }); final int result; final int earMask; final int protoVer; final int codec; final int frameMs; final int fwMinor; final bool classicKeep; bool get bothEars => (earMask & 0x03) == 0x03; } class BesF2fAdvert { const BesF2fAdvert({ required this.version, required this.ear, required this.token, required this.battery, required this.charging, }); final int version; final int ear; final List token; final int battery; final bool charging; bool tokenEquals(List expected) { if (token.length != expected.length) return false; for (var i = 0; i < token.length; i++) { if ((token[i] & 0xFF) != (expected[i] & 0xFF)) return false; } return true; } } class BesF2fBleFrame { const BesF2fBleFrame({required this.op, required this.payload}); final int op; final List payload; int get result => payload.isEmpty ? 0xFF : payload[0] & 0xFF; } class BesF2fAudio { const BesF2fAudio({ required this.ear, required this.flags, required this.seq, required this.g722, }); final int ear; final int flags; final int seq; final List g722; bool get ownVoice => (flags & BesF2f.flagOwnVoice) != 0; }