import 'dart:async'; import 'dart:io'; import 'package:echomeet_device_sdk/echomeet_device_sdk.dart'; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; import '../../core/utils/logger.dart'; import '../../core/utils/permission_util.dart'; import '../../data/models/user_Info.dart'; import '../../data/services/background_launch_consent.dart'; import '../../data/services/network/api.dart'; /// EaiRec 录音支架(AI手机支架)的接入层。 /// /// 底层协议在 `recorder_holder` 插件里,本类只是把 [EchoMeetDeviceSdk] 的流 /// 封装成 GetX 可观测状态,不重写任何蓝牙/Opus 逻辑。设备的两条通道: /// - 通道 A:控制命令 + 实时 Opus 音频流(录音走这条) /// - 通道 B:TF 卡文件(列表/下载/删除),见本类下半部分 /// 协议规范见 local_plugins/recorder_holder/EaiRec_protocol.md。 /// /// 与项目原有的 [BleManager](杰理耳机链路)是两套独立的设备通路,互不影响: /// - 杰理耳机 → BleManager /// - EaiRec 支架 → 本服务 class HolderDeviceService extends GetxService { static const String _tag = 'HolderDeviceService'; static HolderDeviceService get to => Get.find(); final EchoMeetDeviceSdk _sdk = EchoMeetDeviceSdk.instance; /// 扫描到的设备 final RxList scanned = [].obs; /// 按 MAC 去重累积扫描结果。 /// /// 原生侧是「每扫到一个设备就单独上报一次」(notifyScanResult 传单元素列表), /// SDK 收到后会把整个结果流替换成这一条。如果直接 assignAll,目标设备刚进列表 /// 就会被下一个不匹配设备的空结果清掉,界面永远是空的。 /// echomeet 那边用的是 _byMac 累积(见 ble_scanner.dart),这里保持一致。 final Map _byAddress = {}; /// 连接状态 final Rx connection = EchoMeetConnectionState.disconnected.obs; /// 设备录音状态(SDK 上报) final Rx recording = const EchoMeetRecordingState().obs; /// 设备信息(电量 / 固件版本) final Rxn deviceInfo = Rxn(); final RxBool isScanning = false.obs; /// 当前连接设备的 MAC(连接时记下,SDK 未提供反查接口) final RxString connectedAddressRx = ''.obs; String get connectedAddress => connectedAddressRx.value; /// 当前连接设备的广播名(设备页据此选主图) final RxString connectedNameRx = ''.obs; String get connectedName => connectedNameRx.value; // ==================== 自动回连 ==================== static const String _lastDeviceKey = 'holder_last_device'; final GetStorage _storage = GetStorage(); Timer? _autoConnectTimer; int _autoAttempt = 0; /// 本地已绑定设备列表的存储键(服务端绑定失败时也保证设备管理页有记录) static const String _pairedListKey = 'holder_paired_devices'; /// 记住最近一次成功连接的设备,供开机/断连后自动回连, /// 同时写入本地已绑定列表(设备管理页会合并展示)。 void _rememberDevice(String name, String address) { if (address.isEmpty) return; _storage.write(_lastDeviceKey, {'name': name, 'address': address}); _savePaired(name, address); } /// 本地已绑定的支架列表 List get pairedDevices { final raw = _storage.read(_pairedListKey); if (raw == null) return const []; return raw.whereType().toList(growable: false); } void _savePaired(String name, String address) { final list = pairedDevices.toList(); final key = address.toUpperCase(); list.removeWhere( (e) => (e['address'] ?? '').toString().toUpperCase() == key); // 最近连接的排最前,供后续多设备回连按 MRU 顺序尝试 list.insert(0, {'name': name, 'address': address}); _storage.write(_pairedListKey, list); Logger.i(_tag, '本地已绑定列表更新,共 ${list.length} 台'); } /// 从本地已绑定列表移除(解绑用) void removePaired(String address) { final list = pairedDevices.toList(); final key = address.toUpperCase(); list.removeWhere( (e) => (e['address'] ?? '').toString().toUpperCase() == key); _storage.write(_pairedListKey, list); } Map? get _lastDevice => _storage.read(_lastDeviceKey); /// 是否有已绑定(记住)的支架 bool get hasPairedDevice => (_lastDevice?['address'] ?? '') != ''; /// 最近一次连接过的设备名。断连后设备页仍按它显示主图, /// 而不是退回默认图——用户看到的应该是"我的那台设备"。 String get lastDeviceName { final n = connectedNameRx.value; if (n.isNotEmpty) return n; return (_lastDevice?['name'] ?? '') as String; } /// 尝试自动回连。 /// /// Android 上 SDK 的 connect() 走的是 registerBackgroundScan(mac), /// 那本身就是系统级「后台扫到该 MAC 就连」的注册——设备开机进入广播范围时 /// 由原生层自动接上。所以自动回连的关键是:App 启动后 / 断连后重新注册一次。 /// 注册失败按退避重试,最多 [_maxAutoAttempts] 次。 static const int _maxAutoAttempts = 5; /// 本次运行里用户拒绝过系统蓝牙权限。拒绝后不再自动重试、不再反复弹框, /// 直到用户主动去连接页重试([resetPermissionDenied])。 bool _permissionDenied = false; void resetPermissionDenied() => _permissionDenied = false; Future tryAutoConnect({bool reset = true}) async { if (reset) _autoAttempt = 0; _autoConnectTimer?.cancel(); if (isConnected) return; // 自动回连 = 向系统注册蓝牙后台扫描 = 设备靠近时自启动本应用, // 未取得用户明示同意前不能做(应用商店合规检测项)。 // 这里只做静默检查,不弹窗——弹窗只在用户主动点连接时弹。 if (!BackgroundLaunchConsent.granted) { Logger.i(_tag, '用户未开启设备自动连接,跳过后台扫连注册'); return; } final last = _lastDevice; final address = (last?['address'] ?? '') as String; if (address.isEmpty) return; if (_permissionDenied) { Logger.i(_tag, '本次运行已被拒绝蓝牙权限,不再重复申请'); return; } // 合规(场景 1/2):后台自动回连是没有用户操作的流程,这里**只能查状态、 // 不能申请权限**——原来调 _sdk.requestPermission() 会让原生直接弹系统框, // 而且拒绝标记只存在内存里,重启 App 又会再弹一次,正好踩中场景 2。 // 没权限就安静地放弃自动回连,等用户自己去连接页点连接时再申请。 if (!await _hasBluetoothPermission()) { _permissionDenied = true; _autoConnectTimer?.cancel(); _autoAttempt = _maxAutoAttempts; Logger.w(_tag, '无蓝牙权限,自动回连中止(不弹框,等用户主动连接时再申请)'); return; } final name = (last?['name'] ?? 'Echomeet') as String; connectedNameRx.value = name; Logger.i(_tag, '自动回连注册: $name $address (第 ${_autoAttempt + 1} 次)'); connectedAddressRx.value = address; bool ok = false; try { ok = await _sdk.connect(name: name, address: address); } catch (e) { Logger.e(_tag, '自动回连注册异常: $e'); } // 注册成功后由原生后台扫连接管;这里只在注册失败时退避重试 if (!ok && ++_autoAttempt < _maxAutoAttempts) { final delay = Duration(seconds: 5 * _autoAttempt); _autoConnectTimer = Timer(delay, () => tryAutoConnect(reset: false)); } } StreamSubscription? _scanSub; StreamSubscription? _connSub; StreamSubscription? _recSub; StreamSubscription? _infoSub; /// 设备是否已连接 bool get isConnected => connection.value == EchoMeetConnectionState.connected; /// 设备当前是否正在录音(以 SDK 缓存的设备上报状态为准)。 /// /// 录音控制是 toggle 语义,下发前必须先看设备实际状态—— /// 设备已经在录时再发一次 toggle 会把它关掉。 bool get deviceIsRecording => recording.value.isRecording || _sdk.isRecording; /// 录音的 PCM 帧流(16kHz / 16bit / 单声道,Opus 已在原生侧解码) Stream get audioFrames => _sdk.audioFrames; // ==================== 通道 B:TF 卡文件 ==================== // // 协议见 local_plugins/recorder_holder/EaiRec_protocol.md §5。 // 这里只做「连上之后对时 + 拉一次状态」和几个供上层调用的入口; // 帧格式与状态机都在插件的 RecClient 里,本类不碰字节。 /// 设备 TF 卡与录音状态。设备主动上报(录音起停)也会刷新它。 final Rxn recInfo = Rxn(); /// 通道 B 是否可用。设备没有 TF 卡能力时为 false,此时通道 A 照常工作。 final RxBool recChannelReady = false.obs; StreamSubscription? _recInfoSub; StreamSubscription>? _recListSub; /// 设备端录音停止后会主动推一次完整文件列表,落在这里。 final RxList recFiles = [].obs; /// 同一份推送的事件流版本。RxList 只表达「当前是什么」, /// 而导入服务要的是「又推了一次」这个**事件**(内容可能一模一样)。 final StreamController> _recFilesPushes = StreamController>.broadcast(); Stream> get recFilesPushes => _recFilesPushes.stream; /// 连上之后的通道 B 握手。 /// /// 顺序按协议文档 §8.1:先对时再查状态 —— 设备是拿 RTC 生成录音文件的 FAT /// 时间的,对时晚了这一次录的文件时间就是错的。 /// 通道 B 握手最多试几次。原生的 `connected` 回调在**服务发现之前**就抛出来了 /// (真机实测差约 700ms),第一次探必然探到「没有 0xFDA5」。 /// ⚠️ 这里失败就直接 return 的老写法后果比看上去严重:不只是这次没握上手, /// 而是**对时没做(录音文件时间全错)、文件列表推送没订阅(录完不会自动同步)、 /// 设备状态没拉**,而 `ensureRecChannel()` 本身是每次重新探的, /// 于是「列表能拉、但录完不会自动出现」——一个非常难查的半死状态。 static const int _maxRecChannelTries = 6; Future _initRecChannel() async { for (var i = 1; i <= _maxRecChannelTries; i++) { if (!isConnected) return; // 中途断了就别再试 try { if (await _sdk.ensureRecChannel()) { // 订阅要在对时之前挂上:设备可能在握手过程中就推东西上来。 // `??=` 保证重试时不会重复订阅。 _recInfoSub ??= _sdk.recorder.onRecInfo.listen((info) { recInfo.value = info; Logger.i(_tag, '设备状态: $info'); }); _recListSub ??= _sdk.recorder.onFileListPushed.listen((list) { Logger.i(_tag, '设备主动推送文件列表,共 ${list.length} 个'); recFiles.assignAll(list); if (!_recFilesPushes.isClosed) _recFilesPushes.add(list); }); await _sdk.recorder.syncRtc(); recInfo.value = await _sdk.recorder.queryInfo(); recChannelReady.value = true; Logger.i(_tag, '通道 B 就绪(第 $i 次): ${recInfo.value}'); return; } } catch (e) { // 通道 B 失败不能影响连接本身,只记不抛 Logger.i(_tag, '通道 B 握手第 $i 次失败,重试: $e'); } await Future.delayed(const Duration(milliseconds: 800)); } recChannelReady.value = false; Logger.w(_tag, '通道 B 握手 $_maxRecChannelTries 次都没成,TF 卡录音无法同步'); } /// 拉取 TF 卡上的完整录音列表。 Future> listRecordings() async { if (!await _sdk.ensureRecChannel()) { throw RecException('通道 B 不可用'); } final list = await _sdk.recorder.listFiles(); recFiles.assignAll(list); return list; } /// 把 TF 卡上的一个录音下载到本地。 /// /// 落在 `/EaiRec/<文件名>`,**同名文件会被当作断点续传的半成品接着写** /// —— 这正是要的行为(下载中断后重进接着下)。要重下得先删掉本地文件。 Future downloadRecording( String name, { void Function(int received, int total)? onProgress, RecCancelToken? cancelToken, }) async { if (!await _sdk.ensureRecChannel()) { throw RecException('通道 B 不可用'); } final docs = await getApplicationDocumentsDirectory(); final sink = FileRecSink(File('${docs.path}/EaiRec/$name')); await sink.open(); return _sdk.recorder.download( name: name, sink: sink, onProgress: onProgress, cancelToken: cancelToken, ); } /// 把 TF 卡上的一个录音拉到调用方给的 sink 里。 /// /// 与 [downloadRecording] 的区别是**落点由调用方决定**——通用的 /// [DeviceFileStore] 端口要把文件放进自己的暂存目录,不能被这里写死。 Future downloadTo({ required String name, required RecFileSink sink, void Function(int received, int total)? onProgress, RecCancelToken? cancelToken, }) async { if (!await _sdk.ensureRecChannel()) { throw RecException('通道 B 不可用'); } return _sdk.recorder.download( name: name, sink: sink, onProgress: onProgress, cancelToken: cancelToken, ); } /// 删除设备上的一个录音。**调用前 UI 必须已二次确认。** Future deleteRecording(String name) async { if (!await _sdk.ensureRecChannel()) { throw RecException('通道 B 不可用'); } await _sdk.recorder.deleteFile(name); recFiles.removeWhere((e) => e.name == name); } @override void onInit() { super.onInit(); _sdk.ensureBound(); _scanSub = _sdk.scanResults.listen((list) { if (list.isEmpty) return; // 空批次不清空已发现列表 for (final d in list) { final key = d.address.isNotEmpty ? d.address : d.id; if (key.isEmpty) continue; _byAddress[key] = d; } scanned.assignAll(_byAddress.values.toList(growable: false)); }); _connSub = _sdk.connectionState.listen((s) { Logger.i(_tag, '连接状态: $s'); connection.value = s; if (s == EchoMeetConnectionState.connected) { _autoConnectTimer?.cancel(); _autoAttempt = 0; // 名字用本次实际连上的那台。原来这里写死 'Echomeet',换成 EaiRec 之后 // 记下来的名字会是错的,设备页据 lastDeviceName 选主图就会选错。 _rememberDevice( // 兜底名要跟当前固件的广播名一致,否则设备页按名字取产品图会落空。 // 切换设备名见 EchoMeetDeviceSdk.defaultNameFilters。 connectedNameRx.value.isNotEmpty ? connectedNameRx.value : 'Echomeet', connectedAddressRx.value, ); // 协议 §8.1 第 7 步:连上后主动查一次电量。 // ⚠️ 不能只靠 waitConnected() 里那次——**自动回连不走 waitConnected** // (`tryAutoConnect` 直接调 SDK.connect),于是设备页电量一直空着。 // 设备侧电量是「请求/响应 + 主动上报」双模式,不问就可能永远等不到。 _kickBatteryQuery(); // ⚠️ 绑定登记同样不能只挂在 connect():**自动回连不走 connect()**。 // 设备一旦记住了,之后每次都是原生回连上线,绑定接口一次都不会打, // 设备管理页就永远空着(换账号/后台补录 MAC 之后尤其明显)。 unawaited(bindConnectedToServer()); // 通道 B(TF 卡文件)握手,失败不影响录音等通道 A 的功能 unawaited(_initRecChannel()); } else if (s == EchoMeetConnectionState.disconnected && hasPairedDevice) { // 设备关机/走远导致的断连:重新挂上后台扫连,等它再开机 _batteryRetry?.cancel(); Logger.i(_tag, '连接断开,重新注册后台扫连等待设备上线'); Future.delayed(const Duration(seconds: 3), () => tryAutoConnect()); } }); _recSub = _sdk.recordingState.listen((s) => recording.value = s); _infoSub = _sdk.deviceInfo.listen((i) => deviceInfo.value = i); // App 启动后延迟一点再挂,等蓝牙栈和权限就绪 Future.delayed(const Duration(seconds: 3), () => tryAutoConnect()); } @override void onClose() { _scanSub?.cancel(); _connSub?.cancel(); _recSub?.cancel(); _infoSub?.cancel(); _recInfoSub?.cancel(); _recListSub?.cancel(); _recFilesPushes.close(); _autoConnectTimer?.cancel(); _batteryRetry?.cancel(); super.onClose(); } /// 支架 SDK 扫描/连接需要的蓝牙权限 static const List _btPermissions = [ Permission.bluetoothScan, Permission.bluetoothConnect, ]; /// 只查状态,不申请 Future _hasBluetoothPermission() async { if (!Platform.isAndroid) return true; for (final p in _btPermissions) { if (!await p.status.isGranted) return false; } return true; } /// 用户拒绝过且仍在 48h 冷却期 —— UI 据此把扫描/连接入口置灰 bool get isPermissionCoolingDown => Platform.isAndroid && PermissionUtil.instance.isAnyDenialCoolingDown(_btPermissions); /// 申请蓝牙权限(只允许在用户主动触发扫描/连接时调用) /// /// 合规(场景 1/2):SDK 的 requestPermission() 是原生直接弹系统框的, /// permission_handler 管不到,所以在这里手工加冷却闸:拒绝过就直接返回 false, /// 不再调用 SDK,重启 App 也一样——把拒绝状态落到 GetStorage 而不是内存变量。 Future requestPermission() async { if (await _hasBluetoothPermission()) return true; if (isPermissionCoolingDown) { Logger.i(_tag, '蓝牙权限已被拒绝,48h 冷却中,本次不再申请,请置灰入口'); return false; } final granted = await _sdk.requestPermission(); if (!granted) { await PermissionUtil.instance.markDenied(_btPermissions); } return granted; } Future startScan() async { if (!await requestPermission()) { Logger.w(_tag, '蓝牙权限被拒绝,无法扫描'); return; } _byAddress.clear(); scanned.clear(); isScanning.value = true; // ⚠️ 原生 `startScan` 会返回 false(蓝牙未就绪 / 权限 / 已在扫描)。 // 以前这里不看返回值,UI 就一直转「正在搜索」而其实根本没在扫。 // 现在失败了等蓝牙栈就绪再补一次;两次都不行才如实把 isScanning 放下来。 var ok = await _sdk.startScan(); if (!ok) { await Future.delayed(const Duration(milliseconds: 800)); ok = await _sdk.startScan(); } if (!ok) { Logger.w(_tag, '原生扫描未能启动(蓝牙未就绪/权限/已在扫描),UI 退回未扫描态'); isScanning.value = false; } } Future stopScan() async { await _sdk.stopScan(); isScanning.value = false; } Future connect(EchoMeetScannedDevice device) async { await stopScan(); Logger.i(_tag, '连接设备: ${device.name} ${device.address}'); connectedAddressRx.value = device.address; connectedNameRx.value = device.name; _rememberDevice(device.name, device.address); // 先向服务端登记绑定,设备管理页才有记录 await bindToServer(device); return _sdk.connectDevice(device); } /// 把支架登记到服务端的已绑定设备列表。 /// /// 走 `user_binddevice`,与恒玄耳机同一个接口、同一套规则:**只按 MAC 校验** /// (协议 §2.3;服务端 `api_binddevice.go` 拿 devicemac 在全局 `device_mac` /// 表里查,产品是从命中行的 productid 列读出来的)。 /// /// ⚠️ **不要传 pid**:服务端 `UserBindDeviceReq.pid` 是 `int32`,而这里唯一能填的 /// `advertisementHex` 是十六进制**字符串**(常常还是空串)。传上去必然 /// `readUint32: unexpected character` → `code:22`,整个请求被拒, /// 表现为「连上了但设备管理页永远空着」。恒玄那条链路就是不传 pid 的。 /// code 也留空:支架没有杰理那套广播授权码。 /// /// 接口失败不阻断连接和录音,只是设备管理页没有记录。 Future bindToServer(EchoMeetScannedDevice device) => _bindMac(device.name, device.address); /// 用**当前连着的**这台去登记。自动回连路径专用:那条路不经过 [connect], /// 所以拿不到 `EchoMeetScannedDevice`,只有连接时记下的名字和 MAC。 Future bindConnectedToServer() => _bindMac(connectedNameRx.value, connectedAddressRx.value); /// 本进程内已登记成功的 MAC,避免每次回连都打一次接口。 /// **只记成功的**:失败(如后台还没补录这台 MAC)下次连上要能重试。 final Set _bound = {}; Future _bindMac(String name, String mac) async { if (mac.isEmpty) return; if (!User.isLoggedIn()) { Logger.w(_tag, '未登录,跳过设备绑定登记'); return; } final key = mac.toUpperCase(); if (_bound.contains(key)) return; try { final resp = await Api.binddevice({ 'code': '', 'devicename': name, 'devicemac': mac, }); Logger.i(_tag, '绑定接口返回: $resp'); final deviceJson = resp?['device']; if (deviceJson is Map) { _bound.add(key); User.instance.addUserDevice(UserDevice.fromJson(deviceJson)); Logger.i(_tag, '支架已登记到已绑定设备列表'); } else { // resp == null 是服务端明确拒绝(AuthInterceptor 在业务码 != 0 时 // 把 data 置 null 后 resolve,不抛)——最常见的是这台 MAC 还没导入 // 后台的 device_mac 表,返回 `{"code":2005,"msg":"设备未登记"}`。 Logger.w(_tag, '绑定接口未返回 device 字段(resp=$resp),设备管理页看不到这台'); } } catch (e) { // 绑定失败不影响本次连接和录音,只是设备管理页没有记录 Logger.e(_tag, '支架绑定登记失败: $e'); } } /// 等待连接真正建立。 /// /// Android 上 [connect] 调的是 registerBackgroundScan,返回值只代表「后台扫连 /// 注册成功」,真正连上是异步的,要等 connectionState 流推 connected。 /// SDK 的 INTEGRATION.md 快速开始里也是这个流程。 Future waitConnected({ Duration timeout = const Duration(seconds: 25), }) async { if (isConnected) return true; final completer = Completer(); late final Worker worker; Timer? timer; void finish(bool ok) { if (completer.isCompleted) return; worker.dispose(); timer?.cancel(); completer.complete(ok); } worker = ever(connection, (s) { if (s == EchoMeetConnectionState.connected) { // 电量查询不在这里发:onInit 的 connected 分支已经用 _kickBatteryQuery() // 带重试地问了。在这儿再补一发只会在服务发现完成前多失败一次。 finish(true); } if (s == EchoMeetConnectionState.error) finish(false); }); timer = Timer(timeout, () => finish(isConnected)); return completer.future; } /// 主动断开:同时清掉记忆,避免用户手动断开后又被自动连回 Future disconnect() async { _autoConnectTimer?.cancel(); final addr = connectedAddressRx.value; await _storage.remove(_lastDeviceKey); if (addr.isNotEmpty) removePaired(addr); await _sdk.disconnect(); } /// 打开设备编码器,让它开始往手机推 Opus 流。 /// /// 协议层是 `0xAA 0x05 0x02 0xB1 0x02 [CRC]`。这是唯一真正下发到设备的 /// 录音相关命令——`toggleRecording` 只切换原生的本地录音状态,不发 BLE 包。 Future openEncoder() => _sdk.openEncoder(); /// 切换设备录音状态。 /// /// 注意:原生的 toggleRecording **不发任何 BLE 包**,只切换 BleRecord 的 /// 本地 timerStatus / 文件写入状态。真正让设备推流的是 [openEncoder]。 Future startRecording() => _sdk.toggleRecording(); /// 停止设备录音。[fileName] 会作为设备端保存的文件名。 /// /// SDK 的 stopRecording 内部是 setNewName(name) + saveRecording(), /// 与 echomeet 的停止序列一致,直接复用。 Future stopRecording({String? fileName}) => _sdk.stopRecording(fileName: fileName); /// 暂停 / 恢复(SDK 是 toggle 语义) Future toggleRecording() => _sdk.toggleRecording(); Future refreshBattery() => _sdk.refreshBattery(); // ---------------- 电量:连上后必须重试着问 ---------------- Timer? _batteryRetry; int _batteryTries = 0; /// 连上后把电量拿到手。**这里必须重试,问一次是不够的。** /// /// ⚠️ 原生的 `connected` 回调在**服务发现之前**就抛出来了(真机实测差约 /// 700ms),那一刻写特征还是 null,`sendCommand` 直接打 /// `发送命令失败: 设备未连接` 并返回 false —— 命令整条丢在地上,且没有任何 /// 上层能看见的错。设备侧电量虽然也有主动上报,但不保证会来, /// 于是表现就是**设备页右下角永远没有电量**(2026-09-10 真机复现)。 /// /// 所以:发不出去就隔 800ms 再发,发出去了给 2s 等回包,直到 deviceInfo /// 里真的有电量值、或者试满 [_maxBatteryTries] 次为止。 /// 4 次约 3 秒,够覆盖服务发现那 700ms 的窗口了。**不要调大**: /// 通道 A 缺失时这些重试一次都不会成功,只是白刷日志(见 [_attemptBattery] 的说明)。 static const int _maxBatteryTries = 4; void _kickBatteryQuery() { _batteryRetry?.cancel(); _batteryTries = 0; unawaited(_attemptBattery()); } Future _attemptBattery() async { if (!isConnected) return; // 断了就别再问 if (_batteryTries >= _maxBatteryTries) { // ⚠️ 走到这里最常见的**不是**时序问题,而是设备根本没有通道 A。 // 2026-09-10 真机(Echomeet 00:0D:01:00:00:41)服务发现只有一个服务: // `0xFDA5`(FDA6/FDA7) —— 通道 B(TF 卡)。协议 §4.3 的电量在通道 A // (`abc0`/`abc1`/`abc2`) 上,那个服务压根不存在,于是原生 checkConn() // 因 writeChar==null 恒 false,每一发都打「发送命令失败: 设备未连接」。 // **那条错误信息是误导**:链路好好的,缺的是服务。要电量得固件补通道 A。 Logger.w( _tag, '连上后问了 $_batteryTries 次都没拿到电量。' '若原生一直报「发送命令失败: 设备未连接」,看服务发现清单:' '这台固件很可能只暴露了 0xFDA5(通道B),没有 abc0(通道A),电量物理上问不到'); return; } _batteryTries++; final sent = await refreshBattery(); _batteryRetry = Timer( Duration(milliseconds: sent ? 2000 : 800), () { if (deviceInfo.value?.batteryLevel != null) { Logger.i(_tag, '电量已拿到: ${deviceInfo.value?.batteryLevel}%' '(第 $_batteryTries 次尝试)'); return; } unawaited(_attemptBattery()); }, ); } }