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
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)
|
|
}
|
|
}
|
|
|