diff --git a/apps/client/lib/core/utils/logger.dart b/apps/client/lib/core/utils/logger.dart index b001f285..43f1735b 100644 --- a/apps/client/lib/core/utils/logger.dart +++ b/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'; diff --git a/apps/client/lib/data/services/bes_bluetooth_service.dart b/apps/client/lib/data/services/bes_bluetooth_service.dart index b6ae8a6d..0ebe305d 100644 --- a/apps/client/lib/data/services/bes_bluetooth_service.dart +++ b/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(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 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'); } } diff --git a/apps/client/lib/modules/my_devices/controllers/my_devices_controller.dart b/apps/client/lib/modules/my_devices/controllers/my_devices_controller.dart index fb165b26..d7afca18 100644 --- a/apps/client/lib/modules/my_devices/controllers/my_devices_controller.dart +++ b/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 _workers = []; + @override void onInit() { super.onInit(); _bleManager = Get.find(); _refreshFromUser(); Future.microtask(loadDevices); + + // 排序把「在线的排最前」,而在线与否是随时会变的链路状态——耳机可能在 + // 用户停在本页时才连上、或者被摘下断开。列表本身在 Obx 里,但 Obx 只 + // 收集到 allDevices 这一个可观察对象,**排序结果不会自己重算**, + // 必须在连接状态变化时重新跑一遍 _refreshFromUser。 + if (Get.isRegistered()) { + 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 = >{}; - final flat = []; + final online = []; + final offline = []; if (User.isLoggedIn()) { for (final d in User.instance.devices) { grouped.putIfAbsent(d.devicetype, () => []).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([...online, ...offline]); } /// 当前是不是正连着这一台。**只看本地蓝牙状态,不问服务端**—— @@ -147,8 +178,14 @@ class MyDevicesController extends GetxController { final mac = device.devicemac.toUpperCase(); if (Get.isRegistered()) { 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; } }