diff --git a/tools/simblectl/README.md b/tools/simblectl/README.md index ab55809..765cdd0 100644 --- a/tools/simblectl/README.md +++ b/tools/simblectl/README.md @@ -14,3 +14,4 @@ stdout. - `sims`: list the booted simulators and their platforms. - `disarm`: clear the injection environment on every booted simulator. - `status`: report whether the bridge is running, over a HELLO round-trip to the recorded helper. +- `scan [seconds]`: scan on the running helper and print the discovered peripherals. diff --git a/tools/simblectl/Sources/SimBLECTLKit/Command.swift b/tools/simblectl/Sources/SimBLECTLKit/Command.swift index 321f953..f68dde3 100644 --- a/tools/simblectl/Sources/SimBLECTLKit/Command.swift +++ b/tools/simblectl/Sources/SimBLECTLKit/Command.swift @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: 2026 Nirapod Labs +import Foundation import SimBLEHelperKit import SimBLEProtocol @@ -24,9 +25,31 @@ public struct StatusProbe: Equatable, Sendable { } } +/// A peripheral a scan discovered: the peripheral id in lowercase hex, its last-seen RSSI, +/// and the advertised name and service UUIDs when present. +public struct DiscoveredDevice: Equatable, Sendable { + /// The peripheral id as lowercase hex. + public let peripheralId: String + /// The last-seen RSSI in dBm. + public let rssi: Int64 + /// The advertised local name, when present. + public let localName: String? + /// The advertised service UUIDs, when present. + public let serviceUUIDs: [String]? + + public init(peripheralId: String, rssi: Int64, localName: String? = nil, + serviceUUIDs: [String]? = nil) + { + self.peripheralId = peripheralId + self.rssi = rssi + self.localName = localName + self.serviceUUIDs = serviceUUIDs + } +} + public enum SimBLECTL { /// The verbs reported in the usage error. - static let commands = ["version", "sims", "disarm", "status"] + static let commands = ["version", "sims", "disarm", "status", "scan"] /// Probe the recorded bridge over a HELLO round-trip. Nil when the connection or the /// round-trip fails. The protocol version is the one HELLO negotiates. @@ -39,13 +62,37 @@ public enum SimBLECTL { return StatusProbe(protocolVersion: version) } + /// Run a central scan on the recorded helper for `duration` seconds. Returns the discovered + /// peripherals deduped by id with their last-seen fields, sorted by id, or empty on a connection + /// or round-trip failure. + public static func runScan(_ state: HelperState, _ duration: TimeInterval) -> [DiscoveredDevice] { + guard let token = CapabilityToken(hex: state.token), + let client = try? LoopbackClient(port: state.port), + (try? client.send(.scanStart(serviceUUIDs: nil), token: token)) != nil + else { return [] } + var latest: [Data: DiscoveredDevice] = [:] + let deadline = Date().addingTimeInterval(duration) + while Date() < deadline { + guard case let .discovered(peripheralId, advertisement, rssi) = try? client.receiveEvent() + else { continue } + latest[peripheralId] = DiscoveredDevice( + peripheralId: hex(peripheralId), rssi: rssi, + localName: advertisement.localName, serviceUUIDs: advertisement.serviceUUIDs + ) + } + _ = try? client.send(.scanStop, token: token) + return latest.values.sorted { $0.peripheralId < $1.peripheralId } + } + /// Dispatch on the verb (argv[1]). `arming` is the simulator-control seam the device verbs - /// use; `state` reads the helper's discovery record; `probe` runs the bridge round-trip. + /// use; `state` reads the helper's discovery record; `probe` runs the bridge round-trip; + /// `scan` runs a central scan on the recorded helper. public static func handle( arguments: [String], arming: SimulatorArming = SimulatorArming(), state: () -> HelperState? = HelperState.read, - probe: (HelperState) -> StatusProbe? = SimBLECTL.probeBridge + probe: (HelperState) -> StatusProbe? = SimBLECTL.probeBridge, + scan: (HelperState, TimeInterval) -> [DiscoveredDevice] = SimBLECTL.runScan ) -> CommandResult { switch arguments.dropFirst().first { case "version": @@ -56,6 +103,8 @@ public enum SimBLECTL { return disarm(arming) case "status": return status(state, probe) + case "scan": + return self.scan(arguments, state, scan) default: let list = commands.map { #""\#($0)""# }.joined(separator: ",") return CommandResult(exitCode: 1, output: #"{"error":"unknown command","commands":[\#(list)]}"#) @@ -98,6 +147,51 @@ public enum SimBLECTL { ) } + /// Default scan duration in seconds when `scan [seconds]` omits or mistypes the argument. + private static let defaultScanDuration: TimeInterval = 5 + + /// The discovered peripherals as `{"discovered":[…]}`. Exit 1 with `{"error":"no running + /// helper"}` when no record exists. `scan [seconds]` sets the duration; absent or unparseable + /// falls back to the default. + private static func scan( + _ arguments: [String], + _ state: () -> HelperState?, + _ scan: (HelperState, TimeInterval) -> [DiscoveredDevice] + ) -> CommandResult { + guard let record = state() else { + return CommandResult(exitCode: 1, output: #"{"error":"no running helper"}"#) + } + let seconds = arguments.dropFirst(2).first.flatMap(TimeInterval.init) ?? defaultScanDuration + let entries = scan(record, seconds).map(deviceJSON).joined(separator: ",") + return CommandResult(exitCode: 0, output: #"{"discovered":[\#(entries)]}"#) + } + + /// One discovered peripheral as a JSON object, omitting the absent optional fields. + private static func deviceJSON(_ device: DiscoveredDevice) -> String { + var fields = [#""peripheralId":"\#(device.peripheralId)""#, #""rssi":\#(device.rssi)"#] + if let localName = device.localName { + fields.append(#""localName":\#(jsonString(localName))"#) + } + if let serviceUUIDs = device.serviceUUIDs { + let list = serviceUUIDs.map(jsonString).joined(separator: ",") + fields.append(#""serviceUUIDs":[\#(list)]"#) + } + return "{\(fields.joined(separator: ","))}" + } + + /// A string as a JSON string literal, escaping per the JSON grammar. + private static func jsonString(_ value: String) -> String { + let data = try? JSONSerialization.data(withJSONObject: [value]) + let array = data.flatMap { String(data: $0, encoding: .utf8) } + guard let array, array.hasPrefix("["), array.hasSuffix("]") else { return "\"\"" } + return String(array.dropFirst().dropLast()) + } + + /// Lowercase hex of the bytes. + private static func hex(_ bytes: Data) -> String { + bytes.map { String(format: "%02x", $0) }.joined() + } + /// The JSON token for a platform. private static func platformName(_ platform: SimPlatform) -> String { switch platform { diff --git a/tools/simblectl/Tests/SimBLECTLKitTests/CommandTests.swift b/tools/simblectl/Tests/SimBLECTLKitTests/CommandTests.swift index f2aefae..3d5b8dc 100644 --- a/tools/simblectl/Tests/SimBLECTLKitTests/CommandTests.swift +++ b/tools/simblectl/Tests/SimBLECTLKitTests/CommandTests.swift @@ -99,12 +99,12 @@ final class CommandTests: XCTestCase { func testUnknownVerbReturnsUsageError() { let result = SimBLECTL.handle(arguments: ["simblectl", "bogus"]) XCTAssertEqual(result.exitCode, 1) - XCTAssertEqual(result.output, #"{"error":"unknown command","commands":["version","sims","disarm","status"]}"#) + XCTAssertEqual(result.output, #"{"error":"unknown command","commands":["version","sims","disarm","status","scan"]}"#) } func testNoVerbReturnsUsageError() { let result = SimBLECTL.handle(arguments: ["simblectl"]) XCTAssertEqual(result.exitCode, 1) - XCTAssertEqual(result.output, #"{"error":"unknown command","commands":["version","sims","disarm","status"]}"#) + XCTAssertEqual(result.output, #"{"error":"unknown command","commands":["version","sims","disarm","status","scan"]}"#) } } diff --git a/tools/simblectl/Tests/SimBLECTLKitTests/ScanTests.swift b/tools/simblectl/Tests/SimBLECTLKitTests/ScanTests.swift new file mode 100644 index 0000000..8f6a9ac --- /dev/null +++ b/tools/simblectl/Tests/SimBLECTLKitTests/ScanTests.swift @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Nirapod Labs + +import Foundation +import SimBLECTLKit +import SimBLEHelperKit +import SimBLEHostCore +import SimBLEProtocol +import XCTest + +/// The `scan` verb through its scanner seam, and the real `runScan` against a live in-process +/// listener with no radio. +final class ScanTests: XCTestCase { + private let record = HelperState(port: 51234, token: String(repeating: "ab", count: 32)) + + func testScanRendersDiscoveredDevicesInOrderWithOptionalFields() { + let devices = [ + DiscoveredDevice(peripheralId: "0a1b", rssi: -77), + DiscoveredDevice(peripheralId: "ff01", rssi: -42, localName: "Sensor", + serviceUUIDs: ["180D", "180F"]), + ] + let result = SimBLECTL.handle( + arguments: ["simblectl", "scan"], state: { self.record }, scan: { _, _ in devices } + ) + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual( + result.output, + #"{"discovered":[{"peripheralId":"0a1b","rssi":-77},"# + + #"{"peripheralId":"ff01","rssi":-42,"localName":"Sensor","serviceUUIDs":["180D","180F"]}]}"# + ) + } + + func testScanWithNoRecordReportsNoRunningHelper() { + let result = SimBLECTL.handle( + arguments: ["simblectl", "scan"], state: { nil }, scan: { _, _ in [] } + ) + XCTAssertEqual(result.exitCode, 1) + XCTAssertEqual(result.output, #"{"error":"no running helper"}"#) + } + + /// A radio-free `CentralBackend` that re-emits one discovery through its sink every 10ms + /// while scanning, dispatched on a background queue. + private final class DiscoveringCentralBackend: CentralBackend, @unchecked Sendable { + private let lock = NSLock() + private var sink: (@Sendable (CentralBackendEvent) -> Void)? + private var scanning = false + private let discovery: CentralBackendEvent + + init(discovery: CentralBackendEvent) { + self.discovery = discovery + } + + func setEventSink(_ sink: @escaping @Sendable (CentralBackendEvent) -> Void) { + lock.lock(); self.sink = sink; lock.unlock() + } + + func startScan(serviceUUIDs _: [String]?) throws { + lock.lock(); scanning = true; lock.unlock() + emitRepeatedly() + } + + private func emitRepeatedly() { + DispatchQueue.global().asyncAfter(deadline: .now() + 0.01) { [weak self] in + guard let self else { return } + lock.lock(); let active = scanning; let sink = self.sink; lock.unlock() + guard active else { return } + sink?(discovery) + emitRepeatedly() + } + } + + func managerState() -> UInt64 { Wire.managerStatePoweredOn } + func stopScan() throws { lock.lock(); scanning = false; lock.unlock() } + func connect(peripheralId _: Data) throws {} + func disconnect(peripheralId _: Data) throws {} + func discoverServices(peripheralId _: Data, serviceUUIDs _: [String]?) throws -> [String] { [] } + func discoverCharacteristics(peripheralId _: Data, serviceUUID _: String, + characteristicUUIDs _: [String]?) throws -> [String] { [] } + func readCharacteristic(peripheralId _: Data, serviceUUID _: String, + characteristicUUID _: String) throws -> Data { Data() } + func writeCharacteristic(peripheralId _: Data, serviceUUID _: String, + characteristicUUID _: String, value _: Data, + withResponse _: Bool) throws {} + func setNotify(peripheralId _: Data, serviceUUID _: String, characteristicUUID _: String, + enabled _: Bool) throws -> Bool { false } + func readRSSI(peripheralId _: Data) throws -> Int64 { 0 } + func peripheralState(peripheralId _: Data) throws -> UInt64 { 0 } + } + + /// A radio-free `PeripheralBackend`; the listener needs one to build the router. + private final class StubPeripheralBackend: PeripheralBackend, @unchecked Sendable { + func setEventSink(_: @escaping @Sendable (PeripheralBackendEvent) -> Void) {} + func managerState() -> UInt64 { Wire.managerStatePoweredOn } + func addService(serviceUUID _: String, isPrimary _: Bool, + characteristics _: [CharacteristicSpec]) throws {} + func removeService(serviceUUID _: String) throws {} + func startAdvertising(localName _: String?, serviceUUIDs _: [String]?) throws {} + func stopAdvertising() throws {} + func respondRead(requestId _: UInt64, value _: Data, attError _: UInt64) throws {} + func respondWrite(requestId _: UInt64, attError _: UInt64) throws {} + func updateValue(serviceUUID _: String, characteristicUUID _: String, value _: Data, + centralId _: Data?) throws {} + } + + func testRealRunScanCollectsADiscoveryFromALiveListener() throws { + let token = CapabilityToken() + let peripheralId = Data([0xDE, 0xAD, 0xBE, 0xEF]) + let backend = DiscoveringCentralBackend(discovery: .discovered( + peripheralId: peripheralId, localName: "Sensor", serviceUUIDs: ["180D"], + txPower: nil, manufacturerData: nil, rssi: -40 + )) + let listener = LoopbackListener( + router: RequestRouter(service: CentralService(backend: backend), + peripheralService: PeripheralService(backend: StubPeripheralBackend()), + gate: AuthGate(session: token)) + ) + try listener.start() + defer { listener.stop() } + + let devices = SimBLECTL.runScan(HelperState(port: listener.port, token: token.hex), 0.5) + XCTAssertEqual(devices, [DiscoveredDevice(peripheralId: "deadbeef", rssi: -40, + localName: "Sensor", serviceUUIDs: ["180D"])]) + } +}