From bde962baa79dd3290604fc650674ef55ded99551 Mon Sep 17 00:00:00 2001 From: liwei1dao Date: Mon, 11 May 2026 18:45:06 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agent/controllers/agent_controller.dart | 6 +- .../ios/Classes/Core/JieliHomeServer.swift | 8 + .../ios/Classes/Feature/ConnectFeature.swift | 21 ++- .../Feature/JieliEventForwarders.swift | 165 ++++++++++++++++++ .../ios/Classes/Feature/ScanFeature.swift | 56 +++++- 5 files changed, 244 insertions(+), 12 deletions(-) create mode 100644 local_plugins/device_jieli/ios/Classes/Feature/JieliEventForwarders.swift diff --git a/lib/modules/agent/controllers/agent_controller.dart b/lib/modules/agent/controllers/agent_controller.dart index 1ffbdde13..5cc9b3937 100644 --- a/lib/modules/agent/controllers/agent_controller.dart +++ b/lib/modules/agent/controllers/agent_controller.dart @@ -1755,11 +1755,7 @@ class AgentController extends GetxController with WidgetsBindingObserver { return; } - // 必须在 Android 上且已连接杰理耳机 - if (!Platform.isAndroid) { - Get.snackbar('tips'.tr, '该功能仅安卓端支持'); - return; - } + // 必须已连接杰理耳机;iOS / Android 都走 device_jieli 的 AI 助理通路 final BleManager bleManager = Get.find(); if (!bleManager.isConnected) { Get.snackbar('tips'.tr, '请先连接耳机'); diff --git a/local_plugins/device_jieli/ios/Classes/Core/JieliHomeServer.swift b/local_plugins/device_jieli/ios/Classes/Core/JieliHomeServer.swift index 52e0ab912..62b23efab 100644 --- a/local_plugins/device_jieli/ios/Classes/Core/JieliHomeServer.swift +++ b/local_plugins/device_jieli/ios/Classes/Core/JieliHomeServer.swift @@ -66,6 +66,11 @@ class JieliHomeServer { private(set) lazy var assistantBridge: AssistantBridge = { AssistantBridge(server: self) }() + /// 设备主动推送事件转发器(battery / phoneCallStatus / expandFunction), + /// 对齐 Android 的 `DeviceInfoEventForwarder` + `CustomEventForwarder`。 + private(set) lazy var eventForwarders: JieliEventForwarders = { + JieliEventForwarders(server: self) + }() private init() {} @@ -94,6 +99,8 @@ class JieliHomeServer { // Hook scan into shared BLE central; scan publishes its own events. scanFeature.attach(server: self) connectFeature.attach() + // 把 SDK 的 NSNotification(电量 / 通话状态 / 自定义协议)转发到 Dart。 + eventForwarders.attach() initialized = true dispatcher.send([ @@ -110,6 +117,7 @@ class JieliHomeServer { translationFeature.stop() speechFeature.detach() assistantBridge.shutdown() + eventForwarders.detach() scanFeature.detach() initialized = false } diff --git a/local_plugins/device_jieli/ios/Classes/Feature/ConnectFeature.swift b/local_plugins/device_jieli/ios/Classes/Feature/ConnectFeature.swift index c5750ab4e..b8acbf8c1 100644 --- a/local_plugins/device_jieli/ios/Classes/Feature/ConnectFeature.swift +++ b/local_plugins/device_jieli/ios/Classes/Feature/ConnectFeature.swift @@ -48,6 +48,7 @@ class ConnectFeature: NSObject { deviceType: Int, connectWay: Int ) throws { + NSLog("%@", "[JieliConnect] connect() start ble=\(bleAddress) edr=\(edrAddress ?? "nil") type=\(deviceType) way=\(connectWay)") server?.dispatcher.send([ "type": "connectionState", "address": bleAddress, @@ -56,26 +57,33 @@ class ConnectFeature: NSObject { #if canImport(JL_BLEKit) guard let server = server else { throw PluginError("CONNECT_FAILED", "server not initialized") } let mult = server.bleMultiple + NSLog("%@", "[JieliConnect] authEnable=\(mult.authEnable) ble_TIMEOUT=\(mult.ble_TIMEOUT) filter=\(mult.ble_FILTER_ENABLE)") // Build / lookup entity. JL_BLEMultiple keeps a `blePeripheralArr` with // discovered entities — we resolve by UUID, matching the iOS demo. let entity: JL_EntityM if let cached = entityCache[bleAddress] as? JL_EntityM { entity = cached + NSLog("%@", "[JieliConnect] entity cache hit for \(bleAddress)") } else if let made = mult.makeEntity(withUUID: bleAddress) { entity = made entityCache[bleAddress] = entity + NSLog("%@", "[JieliConnect] entity makeEntity ok for \(bleAddress)") } else { + NSLog("%@", "[JieliConnect] entity makeEntity FAILED — peripheral not in JL_BLEMultiple cache (\(bleAddress))") throw PluginError("CONNECT_FAILED", "remote device not found (UUID=\(bleAddress))") } mult.connectEntity(entity) { [weak self] status in guard let self = self else { return } + NSLog("%@", "[JieliConnect] connectEntity callback status=\(status.rawValue) ble=\(bleAddress)") switch status { case .paired: + NSLog("%@", "[JieliConnect] PAIRED — issuing cmdTargetFeatureResult (RCSP init handshake) for \(bleAddress)") self.connectedId = bleAddress self.connectedName = entity.mPeripheral.name // RCSP target-feature query — same role as Android's connect-success // path that issues `cmdTargetFeatureResult` after handshake. - entity.mCmdManager.cmdTargetFeatureResult { _, _, _ in + entity.mCmdManager.cmdTargetFeatureResult { result, response, error in + NSLog("%@", "[JieliConnect] cmdTargetFeatureResult result=\(result) response=\(String(describing: response)) error=\(String(describing: error)) for \(bleAddress)") self.server?.dispatcher.send([ "type": "connectionState", "address": bleAddress, @@ -88,8 +96,9 @@ class ConnectFeature: NSObject { ]) } case .connecting: - break + NSLog("%@", "[JieliConnect] CONNECTING \(bleAddress)") case .disconnectOk: + NSLog("%@", "[JieliConnect] DISCONNECT_OK \(bleAddress)") self.server?.dispatcher.send([ "type": "connectionState", "address": bleAddress, @@ -97,6 +106,9 @@ class ConnectFeature: NSObject { ]) default: // Any other terminal failure → expose as 0/disconnect with a code. + // 注意:authEnable=true 时,未授权设备会在 RCSP 握手阶段被 SDK 主动断开, + // 通常会以 .disconnect 或 .timeout 等非 .paired 状态走到这里。 + NSLog("%@", "[JieliConnect] TERMINAL FAILURE status=\(status.rawValue) (authEnable=\(self.server?.bleMultiple.authEnable ?? false)) — surfacing as disconnect for \(bleAddress)") self.server?.dispatcher.send([ "type": "connectionState", "address": bleAddress, @@ -105,6 +117,7 @@ class ConnectFeature: NSObject { } } #else + NSLog("%@", "[JieliConnect] JL_BLEKit not linked — cannot connect \(bleAddress)") throw PluginError("CONNECT_FAILED", "JL_BLEKit framework not linked") #endif } @@ -150,6 +163,7 @@ class ConnectFeature: NSObject { // BleMultiple flow publishes via the `paired` callback above, so the only // job here is to ensure consistency for native subscribers. if let cbp = note.object as? CBPeripheral { + NSLog("%@", "[JieliConnect] notif kJL_BLE_M_ENTITY_CONNECTED id=\(cbp.identifier.uuidString) name=\(cbp.name ?? "")") connectedId = cbp.identifier.uuidString connectedName = cbp.name } @@ -158,6 +172,7 @@ class ConnectFeature: NSObject { @objc private func onDisconnected(_ note: Notification) { if let cbp = note.object as? CBPeripheral { let id = cbp.identifier.uuidString + NSLog("%@", "[JieliConnect] notif kJL_BLE_M_ENTITY_DISCONNECTED id=\(id) name=\(cbp.name ?? "")") server?.dispatcher.send([ "type": "connectionState", "address": id, @@ -168,9 +183,11 @@ class ConnectFeature: NSObject { } @objc private func onAdapterOff(_ note: Notification) { + NSLog("%@", "[JieliConnect] notif kJL_BLE_M_OFF (adapter off)") server?.dispatcher.send(["type": "adapterStatus", "enabled": false, "hasBle": true]) } @objc private func onAdapterOn(_ note: Notification) { + NSLog("%@", "[JieliConnect] notif kJL_BLE_M_ON (adapter on)") server?.dispatcher.send(["type": "adapterStatus", "enabled": true, "hasBle": true]) } } diff --git a/local_plugins/device_jieli/ios/Classes/Feature/JieliEventForwarders.swift b/local_plugins/device_jieli/ios/Classes/Feature/JieliEventForwarders.swift new file mode 100644 index 000000000..1850988e8 --- /dev/null +++ b/local_plugins/device_jieli/ios/Classes/Feature/JieliEventForwarders.swift @@ -0,0 +1,165 @@ +import Foundation + +#if canImport(JL_BLEKit) +import JL_BLEKit +#endif + +/// 把 JL_BLEKit 的 NSNotification(设备主动推送)翻译成 EventDispatcher +/// 事件。对齐 Android `event/DeviceInfoEventForwarder` + `CustomEventForwarder`: +/// +/// - `kJL_MANAGER_SYSTEM_INFO` / `kJL_MANAGER_TARGET_INFO` → `battery` +/// (耳机端电量变化、放回仓、设备主动同步系统信息时都会触发;这里只在 +/// `outputDeviceModel().battery` 真的变化时才往 Dart 抛,避免高频空包) +/// - `kJL_MANAGER_CALL_STATUS` → `phoneCallStatus` +/// (通话状态变化;BleManager._isCall 跟 InternalCleanupListener 都依赖它) +/// - `kJL_MANAGER_CUSTOM_DATA` → `expandFunction` +/// (耳机厂商私有协议主动上报,opCode=0x16/0x17 用来发起/停止通话翻译; +/// Android 的 `CustomEventForwarder.onExpandFunction` 等价路径) +/// +/// 通知 payload 格式来自 SDK 头注释: +/// `@{ kJL_MANAGER_KEY_UUID: uuid, kJL_MANAGER_KEY_OBJECT: object }` +/// 实际可能放在 `note.object` 也可能在 `note.userInfo`,所以读取做了兜底。 +final class JieliEventForwarders: NSObject { + + weak var server: JieliHomeServer? + + private var lastBatteryLevel: Int = -1 + private var lastCallStatus: Int = -1 + + init(server: JieliHomeServer) { + self.server = server + super.init() + } + + func attach() { + #if canImport(JL_BLEKit) + let nc = NotificationCenter.default + nc.addObserver(self, selector: #selector(onSystemInfo(_:)), + name: NSNotification.Name(kJL_MANAGER_SYSTEM_INFO), object: nil) + nc.addObserver(self, selector: #selector(onTargetInfo(_:)), + name: NSNotification.Name(kJL_MANAGER_TARGET_INFO), object: nil) + nc.addObserver(self, selector: #selector(onCallStatus(_:)), + name: NSNotification.Name(kJL_MANAGER_CALL_STATUS), object: nil) + nc.addObserver(self, selector: #selector(onCustomData(_:)), + name: NSNotification.Name(kJL_MANAGER_CUSTOM_DATA), object: nil) + // 设备断开时清空去重缓存,避免下一次重连首次电量/通话状态变化被 + // "和上次一样" 误判而吞掉。 + nc.addObserver(self, selector: #selector(onEntityDisconnected(_:)), + name: NSNotification.Name(kJL_BLE_M_ENTITY_DISCONNECTED), object: nil) + #endif + } + + func detach() { + NotificationCenter.default.removeObserver(self) + lastBatteryLevel = -1 + lastCallStatus = -1 + } + + // MARK: - Helpers + + /// SDK 把 `@{UUID, OBJECT}` 字典塞在 NSNotification 的哪个字段并不一致 + /// (`object` 或 `userInfo`),这里两个位置都查一遍。 + private func payloadDict(_ note: Notification) -> [AnyHashable: Any]? { + if let dict = note.object as? [AnyHashable: Any] { return dict } + if let info = note.userInfo, !info.isEmpty { return info } + return nil + } + + #if canImport(JL_BLEKit) + private func uuid(from note: Notification) -> String { + let dict = payloadDict(note) ?? [:] + if let s = dict[kJL_MANAGER_KEY_UUID] as? String { return s } + return "" + } + + private func obj(from note: Notification) -> Any? { + payloadDict(note)?[kJL_MANAGER_KEY_OBJECT] + } + #endif + + // MARK: - Battery + + @objc private func onSystemInfo(_ note: Notification) { + publishBatteryIfChanged(note: note) + } + + @objc private func onTargetInfo(_ note: Notification) { + publishBatteryIfChanged(note: note) + } + + private func publishBatteryIfChanged(note: Notification) { + #if canImport(JL_BLEKit) + guard let server = server, server.initialized else { return } + let address = uuid(from: note) + // 当前 session 只跟踪一个已连接设备;UUID 对不上就放弃,避免抢 + // 别的 entity 的状态。 + guard let entity = server.connectFeature.currentEntity() as? JL_EntityM, + !address.isEmpty, entity.mUUID == address else { return } + let battery = Int(entity.mCmdManager.outputDeviceModel().battery) + // 0 是 SDK 没拿到信息时的占位;忽略 0 → 非 0 之外的"覆盖性 0", + // 防止短暂的握手抖动把已经显示出来的电量重置回 0。 + if battery == 0 && lastBatteryLevel > 0 { return } + if battery == lastBatteryLevel { return } + lastBatteryLevel = battery + server.dispatcher.send([ + "type": "battery", + "address": address, + "level": battery, + ]) + #endif + } + + // MARK: - Phone call status + + @objc private func onCallStatus(_ note: Notification) { + guard let server = server, server.initialized else { return } + #if canImport(JL_BLEKit) + let address = uuid(from: note) + // 杰理 iOS SDK 的 call status 通过 NSNumber 抛上来;解不出来就当 0 + // (= idle),整段链路语义都是 "status != 0 表示通话中"。 + let status: Int + if let n = obj(from: note) as? NSNumber { + status = n.intValue + } else if let n = obj(from: note) as? Int { + status = n + } else { + status = 0 + } + if status == lastCallStatus { return } + lastCallStatus = status + server.dispatcher.send([ + "type": "phoneCallStatus", + "address": address, + "status": status, + ]) + #endif + } + + // MARK: - Disconnect reset + + @objc private func onEntityDisconnected(_ note: Notification) { + lastBatteryLevel = -1 + lastCallStatus = -1 + } + + // MARK: - Expand function (vendor custom command, device-initiated) + + @objc private func onCustomData(_ note: Notification) { + guard let server = server, server.initialized else { return } + #if canImport(JL_BLEKit) + let address = uuid(from: note) + guard let data = obj(from: note) as? Data, !data.isEmpty else { return } + // Android 的 onExpandFunction(opCode, payload) 把首字节当 opCode, + // 其余作为 payload。iOS `cmdCustomData` 出站方向也是这么拼的 + // (见 CustomCmdFeature.send),按对称把入站拆回去。 + let opCode = Int(data[0]) + let payload = data.count > 1 ? data.subdata(in: 1..")") + NSLog("%@", "[JieliScan] localName = \(localName ?? "")") + NSLog("%@", "[JieliScan] id = \(peripheral.identifier.uuidString)") + NSLog("%@", "[JieliScan] rssi = \(RSSI) txPower=\(txPowerStr) connectable=\(isConnectable)") + NSLog("%@", "[JieliScan] mfr(hex) = \(mfrHex)") + if let m = manufacturerData, m.count >= 2 { + let cid = Int(m[0]) | (Int(m[1]) << 8) + let payload = m.count > 2 + ? m[2..= 2 { mfrCompanyId = Int(m[0]) | (Int(m[1]) << 8) if m.count > 2 { @@ -198,12 +244,12 @@ class ScanFeature: NSObject, CBCentralManagerDelegate { "deviceType": NSNull(), // TODO: parse via JL_AdvParse / JLAdvParse if available "connectWay": NSNull(), // TODO: parse via JL_AdvParse / JLAdvParse if available "rssi": RSSI.intValue, - "rawAdv": NSNull(), // CoreBluetooth does not surface raw bytes + "rawAdv": (rawAdvHex as Any?) ?? NSNull(), "advRecords": advRecords, "advFlags": advFlags as Any, "manufacturerCompanyId": mfrCompanyId as Any, "manufacturerData": mfrPayloadHex as Any, - "serviceUuids": serviceUuids, + "serviceUuids": serviceUuidsUpper, ] dispatcher?.send(payload) }