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). public 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 = [] public init(dispatcher: EventDispatcher) { self.dispatcher = dispatcher super.init() } public func attach(server: JieliHomeServer) { self.server = server if centralManager == nil { centralManager = CBCentralManager(delegate: self, queue: .main) } } public func detach() { stopScan() centralManager = nil } // MARK: - Public API (mirrors Android signatures) public 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) } public 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 = [] } public func isScanning() -> Bool { scanning } // MARK: - CBCentralManagerDelegate public func centralManagerDidUpdateState(_ central: CBCentralManager) { let on = central.state == .poweredOn dispatcher?.send([ "type": "adapterStatus", "enabled": on, "hasBle": true, ]) if !on && scanning { stopScan() } } public func centralManager( _ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber ) { let name = peripheral.name ?? (advertisementData[CBAdvertisementDataLocalNameKey] as? String) if currentSkipUnnamed && (name?.isEmpty ?? true) { return } if !currentNameList.isEmpty { guard let n = name, currentNameList.contains(where: { $0.caseInsensitiveCompare(n) == .orderedSame }) else { return } } let id = peripheral.identifier.uuidString if !publishedIds.insert(id).inserted { return } let manufacturerData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data let serviceUuids = (advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID])? .map { $0.uuidString.uppercased() } ?? [] var advFlags: Int? = nil var mfrCompanyId: Int? = nil var mfrPayloadHex: String? = nil var advRecords: [[String: String]] = [] if let m = manufacturerData, m.count >= 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) } }