Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions tools/simblectl/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
100 changes: 97 additions & 3 deletions tools/simblectl/Sources/SimBLECTLKit/Command.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2026 Nirapod Labs

import Foundation
import SimBLEHelperKit
import SimBLEProtocol

Expand All @@ -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.
Expand All @@ -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":
Expand All @@ -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)]}"#)
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions tools/simblectl/Tests/SimBLECTLKitTests/CommandTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"]}"#)
}
}
124 changes: 124 additions & 0 deletions tools/simblectl/Tests/SimBLECTLKitTests/ScanTests.swift
Original file line number Diff line number Diff line change
@@ -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"])])
}
}
Loading