import Foundation import CoreBluetooth /// BLE scan via CoreBluetooth. Mirrors Android `feature/ScanFeature.kt`. /// /// Like the Android implementation, scanning is **not** delegated to the JieLi /// SDK's built-in `scan()` (which has its own filterContent/strategy quirks); /// instead we drive `CBCentralManager` directly and parse the advertisement /// data ourselves. The published `deviceFound` event payload matches the /// Android schema 1:1 so the Dart parser is unchanged: /// /// ``` /// { /// type: "deviceFound", /// name, address, rssi, /// edrAddr, deviceType, connectWay, // null on iOS — see note below /// rawAdv, advFlags, manufacturerCompanyId, manufacturerData, /// serviceUuids: [String], advRecords: [{len, type, data}] /// } /// ``` /// /// `edrAddr` / `deviceType` / `connectWay` are populated on Android by feeding /// the raw scan record through `ParseDataUtil.isFilterBleDevice(...)`. On iOS /// the equivalent live in `JL_AdvParse`'s `JLAdvParse` Obj-C class; until the /// exact entry point is wired we leave them null and rely on the connection /// path's BLE-only fallback (matches the Android "fallback when SDK can't /// parse" behavior). class ScanFeature: NSObject, CBCentralManagerDelegate { private weak var server: JieliHomeServer? private weak var dispatcher: EventDispatcher? /// Dedicated central manager for scanning — separated from the SDK's own /// `JL_BLEMultiple` central so that filter knobs do not collide. private var centralManager: CBCentralManager? private var scanning: Bool = false private var stopWorkItem: DispatchWorkItem? private var currentNameList: [String] = [] private var currentSkipUnnamed: Bool = true private var serviceFilters: [CBUUID] = [] // Track which addresses have been published this scan to dedupe. private var publishedIds: Set = [] init(dispatcher: EventDispatcher) { self.dispatcher = dispatcher super.init() } func attach(server: JieliHomeServer) { self.server = server if centralManager == nil { centralManager = CBCentralManager(delegate: self, queue: .main) } } func detach() { stopScan() centralManager = nil } // MARK: - Public API (mirrors Android signatures) func startScan( timeoutMs: Int, nameList: [String], uuidList: [String], skipUnnamed: Bool ) throws { guard let central = centralManager else { throw PluginError("SCAN_FAILED", "BLE central unavailable") } if scanning { stopScan() } currentNameList = nameList.filter { !$0.isEmpty } currentSkipUnnamed = skipUnnamed serviceFilters = uuidList.compactMap { Self.parseUuid($0) } publishedIds.removeAll(keepingCapacity: true) if central.state != .poweredOn { // Match Android behavior — scan request from a powered-off adapter // surfaces the same shape of error. throw PluginError("SCAN_FAILED", "BLE adapter not powered on (state=\(central.state.rawValue))") } let services: [CBUUID]? = serviceFilters.isEmpty ? nil : serviceFilters let options: [String: Any] = [ // Allow duplicate adverts so we keep getting RSSI updates. CBCentralManagerScanOptionAllowDuplicatesKey: false ] central.scanForPeripherals(withServices: services, options: options) scanning = true dispatcher?.send([ "type": "scanStatus", "ble": true, "started": true, ]) let work = DispatchWorkItem { [weak self] in self?.stopScan() } stopWorkItem = work DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(timeoutMs), execute: work) } func stopScan() { if let work = stopWorkItem { work.cancel(); stopWorkItem = nil } if let central = centralManager, central.state == .poweredOn { central.stopScan() } if scanning { dispatcher?.send([ "type": "scanStatus", "ble": true, "started": false, ]) } scanning = false currentNameList = [] serviceFilters = [] } func isScanning() -> Bool { scanning } // MARK: - CBCentralManagerDelegate func centralManagerDidUpdateState(_ central: CBCentralManager) { let on = central.state == .poweredOn dispatcher?.send([ "type": "adapterStatus", "enabled": on, "hasBle": true, ]) if !on && scanning { stopScan() } } func centralManager( _ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber ) { let name = peripheral.name ?? (advertisementData[CBAdvertisementDataLocalNameKey] as? String) // === 调试用:打印每一条广播(过滤前),完整 dump 广播 hex === // iOS CoreBluetooth 不暴露原始广播字节,这里把 advertisementData // 里所有字段按 BLE AD type 拼出 hex 方便核对。 let manufacturerData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data let serviceUuids = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] let serviceData = advertisementData[CBAdvertisementDataServiceDataKey] as? [CBUUID: Data] let txPower = advertisementData[CBAdvertisementDataTxPowerLevelKey] as? NSNumber let localName = advertisementData[CBAdvertisementDataLocalNameKey] as? String let isConnectable = (advertisementData[CBAdvertisementDataIsConnectable] as? NSNumber)?.boolValue ?? false let solicited = advertisementData[CBAdvertisementDataSolicitedServiceUUIDsKey] as? [CBUUID] let overflow = advertisementData[CBAdvertisementDataOverflowServiceUUIDsKey] as? [CBUUID] let mfrHex = manufacturerData?.map { String(format: "%02X", $0) }.joined() ?? "nil" let svcUuids = serviceUuids?.map { $0.uuidString }.joined(separator: ",") ?? "" let svcDataHex = serviceData?.map { (k, v) in "\(k.uuidString)=" + v.map { String(format: "%02X", $0) }.joined() }.joined(separator: ";") ?? "nil" let txPowerStr = txPower.map { "\($0.intValue)" } ?? "nil" let solicitedStr = solicited?.map { $0.uuidString }.joined(separator: ",") ?? "" let overflowStr = overflow?.map { $0.uuidString }.joined(separator: ",") ?? "" NSLog("%@", "================ [JieliScan] didDiscover ================") NSLog("%@", "[JieliScan] name = \(name ?? "")") 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 { mfrPayloadHex = m[2.. CBUUID? { let s = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard !s.isEmpty else { return nil } return CBUUID(string: s) } }