Browse Source

设备管理按在线排序;恒玄日志在 release 里恢复可见、电量补查重试

设备管理页:
- 在线设备排最前,其余保持服务端顺序。用「两个桶拼起来」而不是 List.sort,
  Dart 的 sort 不保证稳定,按 0/1 排会让离线设备的相对顺序每次刷新都变。
- 排序结果不会自己重算:列表的 Obx 只收集到 allDevices 一个可观察对象,
  所以挂 ever(deviceStatus/connectedDeviceId/deviceMac) 在连接变化时重排,
  onClose 里 dispose。
- isConnected 补上 deviceMac 比对,与 unbindDevice 里的判据对齐。
  只比 connectedDeviceId 的话 iOS 上恒为 false(那是 peripheral UUID
  不是 MAC),列表全显示未连接、排序也白排。

日志:
- Logger 的 console 开关原来是 !kReleaseMode。配上 release 级别 = warning,
  结果是 debug/info 被级别挡掉、warn/error 被开关挡掉,release 包里一条都
  到不了 logcat。为真机排查写的那套恒玄抓帧诊断,在真正拿去测的包里全是
  死代码。现在 release 也开,级别门槛仍是 warning。
- 恒玄的原始帧 / 未识别回包 / 电量 / 固件版本 / 命令探测一律改 warn 级,
  这样正式包里也留得住——定位新固件命令号只能靠这些第一手字节。

电量:
- 握手里只发一次 AA 09 不够(那一刻 SPP 刚建好、命令可能被丢;协议 3.1.10
  这帧也可能只在状态变化时主动推)。加 3s/8s/20s 三次补查,拿到就停,
  断开即取消。不做长期轮询:探测命令占着 SPP 会让录音/翻译排队。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main
Rodger-Wang 1 month ago
parent
commit
edc414bc61
  1. 14
      apps/client/lib/core/utils/logger.dart
  2. 65
      apps/client/lib/data/services/bes_bluetooth_service.dart
  3. 45
      apps/client/lib/modules/my_devices/controllers/my_devices_controller.dart

14
apps/client/lib/core/utils/logger.dart

@ -26,8 +26,18 @@ class Logger {
static LogLevel _currentLevel =
kReleaseMode ? LogLevel.warning : LogLevel.debug;
/// 控制台输出开关(Release 默认关闭以减少 developer.log 开销)
static bool _consoleEnabled = !kReleaseMode;
/// 控制台输出开关。
///
/// ⚠️ Release 下**也开**。原来是 `!kReleaseMode`(release 关闭),配上
/// `_currentLevel = warning` 的结果是:release 包里 debug/info 被级别挡掉、
/// warning/error 又被这个开关挡掉,**一条日志都到不了 logcat**。
/// 于是所有为真机排查写的抓帧/协议诊断(恒玄那套 `未识别回包`、`[抓帧]`)
/// 在真正拿去测的 release 包里全是死代码——只能靠重新打 debug 包才看得到,
/// 而 debug 包又不签正式名、装不上正在测的那台机器。
///
/// 开销可控:release 的级别门槛是 warning,只有 warn/error 会走到这里,
/// 本来就是低频事件。要临时看更细的,用 [setLevel]。
static bool _consoleEnabled = true;
/// 默认标签
static const String _defaultTag = 'App';

65
apps/client/lib/data/services/bes_bluetooth_service.dart

@ -31,6 +31,9 @@ class BesBluetoothService extends GetxService {
StreamSubscription? _statusSubscription;
StreamSubscription? _cmdEventSubscription;
/// 连接后补查电量的重试定时器,断开时取消(见 [_scheduleBatteryRetry])
Timer? _batteryRetryTimer;
/// 0=未连接 1=连接中 2=已连接(与原生侧约定一致)
final RxInt deviceStatus = 0.obs;
final RxString connectedDeviceName = ''.obs;
@ -167,6 +170,7 @@ class BesBluetoothService extends GetxService {
void onClose() {
_statusSubscription?.cancel();
_cmdEventSubscription?.cancel();
_batteryRetryTimer?.cancel();
super.onClose();
}
@ -229,6 +233,7 @@ class BesBluetoothService extends GetxService {
if (deviceStatus.value == 2) {
await sendData([0xAA, 0x06]);
await queryBattery();
_scheduleBatteryRetry();
// App 可能是在耳机已经通话中时才连上/重启回连的——0xD1/0xD2/0x02/0x03
// 都是推送事件,错过了就再也等不到,必须连上后主动查一次。
await queryCallState();
@ -255,6 +260,8 @@ class BesBluetoothService extends GetxService {
Logger.e(_tag, '连接后握手失败(不致命): $e');
}
} else if (event == 0) {
_batteryRetryTimer?.cancel();
_batteryRetryTimer = null;
connectedDeviceId.value = '';
connectedDeviceName.value = '';
deviceVersion.value = '';
@ -276,7 +283,10 @@ class BesBluetoothService extends GetxService {
.map((b) => (b as int).toRadixString(16).padLeft(2, '0'))
.join(' ')
: '$raw';
Logger.d(_tag, '恒玄设备命令事件: type=${event['type']} raw=[$hex]');
// ⚠️ 用 warn 级:release 包的级别门槛就是 warning,写 debug/info 等于
// 在真机上什么都记不下来。这是定位「新固件上电量命令是哪个」的唯一
// 第一手材料,频率也低(只在耳机推帧时触发),留在正式包里是划算的。
Logger.w(_tag, '恒玄设备命令事件: type=${event['type']} raw=[$hex]');
final type = event['type'];
if (type is String && type.isNotEmpty) lastCmdType.value = type;
switch (type) {
@ -315,19 +325,19 @@ class BesBluetoothService extends GetxService {
// 哪些命令还活着,跳过它们就失去了对照组。
const skip = {0x51, 0x52, 0x56, 0x57, 0x58, 0x59, 0x5A,
0x5E, 0x5F, 0x64, 0x65, 0x66, 0x67, 0x69, 0x6A};
Logger.i(_tag,
Logger.w(_tag,
'[探测] 开始 0x${from.toRadixString(16)}~0x${to.toRadixString(16)}');
for (var cmd = from; cmd <= to; cmd++) {
if (cmd >= 0x51 && cmd <= 0x6A) continue; // 会切工作模式,别碰
if (skip.contains(cmd)) continue;
if (deviceStatus.value != 2) return;
Logger.i(_tag, '[探测] 发送 AA ${cmd.toRadixString(16).padLeft(2, '0')}');
Logger.w(_tag, '[探测] 发送 AA ${cmd.toRadixString(16).padLeft(2, '0')}');
try {
await sendData([0xAA, cmd]);
} catch (_) {}
await Future.delayed(const Duration(milliseconds: 400));
}
Logger.i(_tag, '[探测] 结束');
Logger.w(_tag, '[探测] 结束');
}
/// 实测出来的命令号(2026-09-01,Echo-one 固件 0.0.2,`probeCommands` 扫 0x01~0x20 得到):
@ -448,6 +458,39 @@ class BesBluetoothService extends GetxService {
}
}
/// 连上之后电量迟迟没到就再补问几次。
///
/// 握手里只发一次 `AA 09` 是不够的,两个原因:
/// 1. 那一刻 SPP 通道刚建好(还排在 `AA 06` 后面),命令可能被固件丢掉;
/// 2. 协议 3.1.10 说这帧「响应查询**或者**状态变化时主动上报」——如果这版
/// 固件只走后者,连接那一瞬本来就不会有推送,等一等反而可能等到。
///
/// 间隔逐次拉长(3s / 8s / 20s),三次拿不到就彻底放弃:探测命令占着 SPP
/// 会让用户点录音/翻译排队(2026-09-04 真机踩过),不能无限期轮询下去。
/// 中途任何一路(查询回包或耳机主动推)把电量填上了就立即停。
void _scheduleBatteryRetry() {
_batteryRetryTimer?.cancel();
const delays = <Duration>[
Duration(seconds: 3),
Duration(seconds: 8),
Duration(seconds: 20),
];
var attempt = 0;
void next() {
if (attempt >= delays.length) return;
final d = delays[attempt++];
_batteryRetryTimer = Timer(d, () async {
if (deviceStatus.value != 2) return; // 已断开
if (batteryLevel.value >= 0) return; // 已经拿到了,不再打扰通道
Logger.w(_tag, '电量仍未上报,第 $attempt 次补查 (AA 09)');
await queryBattery();
next();
});
}
next();
}
/// 请求上报通话状态(`AA 0E`),耳机回 `0x8E`:
/// data[6] 0x01=来电 / 0x02=通话中 / 0x03=通话结束。
/// 原生侧已把 0x02/0x03 解析成 startMakeCall/stopMakeCall 事件,与
@ -495,13 +538,13 @@ class BesBluetoothService extends GetxService {
// 连接后周期性上报,结构是 [len,attr,value] × 2(attr 0x01左 / 0x02右)。
// 实测值恒为 1,不像百分比;可能是等级或入耳状态。
// 固件确认它是电量后,把下面这行换成 _parseBattery(data) 即可。
Logger.d(_tag, '设备状态上报(0x18) 原始=$data');
Logger.w(_tag, '设备状态上报(0x18) 原始=$data');
break;
default:
// 没识别的回包打出来,方便按真实固件补协议(电量 CMD 就靠这个定位)。
// 用 INFO 级 + 十六进制:DEBUG 在某些构建里被过滤掉,而十进制的
// `[187, 134, 34, ...]` 对着协议文档看要一个个换算,很容易看错。
Logger.i(
Logger.w(
_tag,
'未识别回包(设备主动上报) CMD=0x${data[1].toRadixString(16).padLeft(2, '0')} '
'原始=${data.map((e) => e.toRadixString(16).padLeft(2, '0').toUpperCase()).join(' ')}');
@ -586,7 +629,7 @@ class BesBluetoothService extends GetxService {
// 只有盒子上报时(两只耳机都在盒内)退而显示盒电量
batteryLevel.value = batteryCase.value;
}
Logger.i(
Logger.w(
_tag,
'恒玄耳机电量: ${batteryLevel.value}%'
' (L=${batteryLeft.value}${chargingLeft.value ? "⚡" : ""}'
@ -604,7 +647,7 @@ class BesBluetoothService extends GetxService {
String mac(Iterable<int> b) =>
b.map((e) => e.toRadixString(16).padLeft(2, '0').toUpperCase()).join(':');
Logger.i(_tag, '[抓帧] BB0A 原始 = ${hex(data)}');
Logger.w(_tag, '[抓帧] BB0A 原始 = ${hex(data)}');
var p = 3; // 与 _parseBattery 同一起点:SOF + CMD + 1 字节长度
while (p + 1 < data.length) {
final blockLen = data[p];
@ -617,7 +660,7 @@ class BesBluetoothService extends GetxService {
'[3..8]=${body.length >= 9 ? mac(body.sublist(3, 9)) : "-"} '
'[6..11]=${body.length >= 12 ? mac(body.sublist(6, 12)) : "-"}'
: (attr == 7 ? ' ← 蓝牙地址 = ${mac(body)}' : '');
Logger.i(
Logger.w(
_tag,
'[抓帧] attr=$attr len=$blockLen body(${body.length}B)='
'${hex(body)}$note');
@ -654,7 +697,9 @@ class BesBluetoothService extends GetxService {
final parsed = left ?? right ?? box;
if (parsed != null) {
deviceVersion.value = parsed;
Logger.i(_tag, '恒玄耳机固件版本: $parsed');
// 固件版本决定这台机器该用哪套命令号(0.0.2 与 0.0.3 的电量命令不同),
// 排查电量问题第一件事就是看它,所以要在 release 日志里留得住。
Logger.w(_tag, '恒玄耳机固件版本: $parsed');
}
}

45
apps/client/lib/modules/my_devices/controllers/my_devices_controller.dart

@ -26,12 +26,37 @@ class MyDevicesController extends GetxController {
final RxBool isLoading = false.obs;
/// 连接状态监听句柄,[onClose] 里要断掉
final List<Worker> _workers = <Worker>[];
@override
void onInit() {
super.onInit();
_bleManager = Get.find<BleManager>();
_refreshFromUser();
Future.microtask(loadDevices);
// 排序把「在线的排最前」,而在线与否是随时会变的链路状态——耳机可能在
// 用户停在本页时才连上、或者被摘下断开。列表本身在 Obx 里,但 Obx 只
// 收集到 allDevices 这一个可观察对象,**排序结果不会自己重算**,
// 必须在连接状态变化时重新跑一遍 _refreshFromUser。
if (Get.isRegistered<BesBluetoothService>()) {
final bes = BesBluetoothService.to;
_workers.add(ever(bes.deviceStatus, (_) => _refreshFromUser()));
// deviceStatus 只说「连上了」,连的是哪台要看这两个:Android 上是
// connectedDeviceId,iOS 上只能等耳机自报的 deviceMac 到达。
_workers.add(ever(bes.connectedDeviceId, (_) => _refreshFromUser()));
_workers.add(ever(bes.deviceMac, (_) => _refreshFromUser()));
}
}
@override
void onClose() {
for (final w in _workers) {
w.dispose();
}
_workers.clear();
super.onClose();
}
/// 从服务端拉取最新的设备列表,并刷新 [User.instance.devices]。
@ -125,16 +150,22 @@ class MyDevicesController extends GetxController {
void _refreshFromUser() {
final grouped = <int, List<UserDevice>>{};
final flat = <UserDevice>[];
final online = <UserDevice>[];
final offline = <UserDevice>[];
if (User.isLoggedIn()) {
for (final d in User.instance.devices) {
grouped.putIfAbsent(d.devicetype, () => <UserDevice>[]).add(d);
flat.add(d);
(isConnected(d) ? online : offline).add(d);
}
}
devicesByType.value = grouped;
devicesByType.refresh();
allDevices.assignAll(flat);
// 在线的排最前,其余保持服务端返回的顺序。
//
// ⚠️ 刻意用「两个桶拼起来」而不是 List.sort:Dart 的 sort **不保证稳定**
// (长度 <32 走插入排序碰巧稳定,再长就切到快排),拿它按 0/1 排会让
// 离线设备的相对顺序每次刷新都可能变,看着像列表在乱跳。
allDevices.assignAll(<UserDevice>[...online, ...offline]);
}
/// 当前是不是正连着这一台。**只看本地蓝牙状态,不问服务端**——
@ -147,8 +178,14 @@ class MyDevicesController extends GetxController {
final mac = device.devicemac.toUpperCase();
if (Get.isRegistered<BesBluetoothService>()) {
final bes = BesBluetoothService.to;
// ⚠️ 两个值都要比,判据与 unbindDevice 里那段保持一致:
// connectedDeviceId 在 Android 上就是 MAC,在 iOS 上却是 CoreBluetooth
// 的 peripheral UUID,跟服务端记的真 MAC 永远对不上;耳机在 `BB 0A`
// 属性 7 里自报的 deviceMac 才是两端都能对上的那个。
// 只比前者的话,iOS 上这里恒为 false —— 列表全显示未连接、排序也白排。
if (bes.isConnected &&
bes.connectedDeviceId.value.toUpperCase() == mac) {
(bes.connectedDeviceId.value.toUpperCase() == mac ||
bes.deviceMac.value.toUpperCase() == mac)) {
return true;
}
}

Loading…
Cancel
Save