You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

217 lines
7.4 KiB

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<String> = []
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..<m.count].map { String(format: "%02X", $0) }.joined()
}
}
// BLE Core Spec §11 ad-record reconstruction is best-effort on iOS:
// CoreBluetooth surfaces parsed fields only. We synthesize a minimal
// record list for parity with Android's structured payload.
if let m = manufacturerData {
// 0xFF — Manufacturer Specific Data
advRecords.append([
"len": String(format: "%02d", m.count + 1),
"type": "0xFF",
"data": "0x" + m.map { String(format: "%02X", $0) }.joined(),
])
}
if let n = name, !n.isEmpty {
let bytes = Array(n.utf8)
advRecords.append([
"len": String(format: "%02d", bytes.count + 1),
"type": "0x09", // Complete Local Name
"data": "0x" + bytes.map { String(format: "%02X", $0) }.joined(),
])
}
var payload: [String: Any?] = [
"type": "deviceFound",
"name": name ?? "",
// iOS does not expose the BLE MAC in CoreBluetooth — use the peripheral
// identifier UUID as a stable address. Downstream ConnectFeature looks
// it up by the same id.
"address": id,
"edrAddr": NSNull(), // TODO: parse via JL_AdvParse / JLAdvParse if available
"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
"advRecords": advRecords,
"advFlags": advFlags as Any,
"manufacturerCompanyId": mfrCompanyId as Any,
"manufacturerData": mfrPayloadHex as Any,
"serviceUuids": serviceUuids,
]
dispatcher?.send(payload)
}
// MARK: - UUID parsing (matches Android `parseUuid`)
static func parseUuid(_ raw: String) -> CBUUID? {
let s = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !s.isEmpty else { return nil }
return CBUUID(string: s)
}
}