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.
267 lines
7.9 KiB
267 lines
7.9 KiB
/// 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 <ver> <cap> <token 4>`
|
|
static List<int> enterFrame(List<int> token) {
|
|
if (token.length != 4) {
|
|
throw ArgumentError('token must be 4 bytes');
|
|
}
|
|
return <int>[
|
|
0xAA, 0x6B, 0x06, protoVer, capG722,
|
|
token[0] & 0xFF, token[1] & 0xFF, token[2] & 0xFF, token[3] & 0xFF,
|
|
];
|
|
}
|
|
|
|
static List<int> exitFrame() => const <int>[0xAA, 0x6C];
|
|
|
|
/// 解析 `BB EB`。长度不够返回 null。
|
|
static BesF2fEnterAck? parseEnterAck(List<int> 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<int> 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<int> 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<int>.unmodifiable(afterCompanyId.sublist(4, 8)),
|
|
battery: afterCompanyId[8] & 0x7F,
|
|
charging: (afterCompanyId[8] & 0x80) != 0,
|
|
);
|
|
}
|
|
|
|
static List<int> hello(List<int> token) => <int>[
|
|
0xAA, opHello, 0x06, protoVer,
|
|
token[0] & 0xFF, token[1] & 0xFF, token[2] & 0xFF, token[3] & 0xFF,
|
|
roleApp,
|
|
];
|
|
|
|
static List<int> sessionCfg() => <int>[
|
|
0xAA, opSessionCfg, 0x03, vadAlways, defaultGain, triggerContinuous,
|
|
];
|
|
|
|
static List<int> startStream() => <int>[0xAA, opStartStream, 0x00];
|
|
|
|
static List<int> stopStream() => <int>[0xAA, opStopStream, 0x00];
|
|
|
|
static List<int> keepalive(int seq) =>
|
|
<int>[0xAA, opKeepalive, 0x01, seq & 0xFF];
|
|
|
|
static List<int> exitEar() => <int>[0xAA, opExit, 0x00];
|
|
|
|
/// `AA 91` 下行音频。payload = flags(含耳位) + seq16le + g722。
|
|
static List<int> audioDown({
|
|
required int ear,
|
|
required int flags,
|
|
required int seq,
|
|
required List<int> g722,
|
|
}) {
|
|
final head = (ear & 0x0F) | (flags & 0xF0);
|
|
final body = <int>[head, seq & 0xFF, (seq >> 8) & 0xFF, ...g722];
|
|
return <int>[0xAA, opAudioDown, body.length & 0xFF, ...body];
|
|
}
|
|
|
|
/// 解析一条 BLE 通知帧。不是本协议返回 null。
|
|
static BesF2fBleFrame? parseBle(List<int> 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<int>.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<int> token;
|
|
final int battery;
|
|
final bool charging;
|
|
|
|
bool tokenEquals(List<int> 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<int> 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<int> g722;
|
|
|
|
bool get ownVoice => (flags & BesF2f.flagOwnVoice) != 0;
|
|
}
|
|
|