diff --git a/apps/helper/Sources/SimBLEHelperKit/RequestRouter.swift b/apps/helper/Sources/SimBLEHelperKit/RequestRouter.swift index 1a2e09a..8115c6e 100644 --- a/apps/helper/Sources/SimBLEHelperKit/RequestRouter.swift +++ b/apps/helper/Sources/SimBLEHelperKit/RequestRouter.swift @@ -12,35 +12,38 @@ enum BridgeErrorCode { static let unauthorized: Int64 = -1 /// A request whose bytes did not decode to a valid message. static let malformed: Int64 = -2 - /// A peripheral-role op the central bridge does not implement. - static let notImplemented: Int64 = -3 } -/// Turns a request into a response by driving the central service, behind the -/// capability-token gate, and streams the service's events to the connected client. -/// One `CentralService` is shared across connections; the listener attaches a -/// per-connection event sink for the duration of a connection. +/// Turns a request into a response by driving the central service or the peripheral +/// service, behind the capability-token gate, and streams both services' events to the +/// connected client. One service of each role is shared across connections; the listener +/// attaches a per-connection event sink for the duration of a connection. public final class RequestRouter: @unchecked Sendable { private let service: CentralService + private let peripheralService: PeripheralService private let gate: AuthGate private let sinkLock = NSLock() private var eventSink: (@Sendable (Event) -> Void)? - /// Build the router over the central service and the token gate. The router installs - /// the service's event sink once and forwards each event to the attached connection. - public init(service: CentralService, gate: AuthGate) { + /// Build the router over the central service, the peripheral service, and the token + /// gate. The router installs each service's event sink once and forwards every event + /// to the attached connection. + public init(service: CentralService, peripheralService: PeripheralService, gate: AuthGate) { self.service = service + self.peripheralService = peripheralService self.gate = gate - service.onEvent { [weak self] event in + let forward: @Sendable (Event) -> Void = { [weak self] event in guard let self else { return } - self.sinkLock.lock() - let sink = self.eventSink - self.sinkLock.unlock() + sinkLock.lock() + let sink = eventSink + sinkLock.unlock() sink?(event) } + service.onEvent(forward) + peripheralService.onEvent(forward) } - /// Route the central service's events to `sink` for the life of one connection. The + /// Route both services' events to `sink` for the life of one connection. The /// listener attaches before serving and detaches after. public func attachEventSink(_ sink: @escaping @Sendable (Event) -> Void) { sinkLock.lock() @@ -71,11 +74,8 @@ public final class RequestRouter: @unchecked Sendable { } catch { return .failure(op: 0, code: BridgeErrorCode.malformed, message: String(describing: error)) } - // A peripheral-role op is rejected at the router, not dispatched: the central bridge - // does not implement it in this version. - guard !Wire.isPeripheralRole(request) else { - return .failure(op: Wire.op(of: request), code: BridgeErrorCode.notImplemented, - message: "peripheral role not implemented in the central bridge") + if Wire.isPeripheralRole(request) { + return peripheralService.handle(request) } return service.handle(request) } diff --git a/apps/helper/Sources/simble-helper/main.swift b/apps/helper/Sources/simble-helper/main.swift index 626feff..06405b1 100644 --- a/apps/helper/Sources/simble-helper/main.swift +++ b/apps/helper/Sources/simble-helper/main.swift @@ -10,7 +10,7 @@ import SimBLEHostCore import Darwin #endif -// The central bridge helper: own the Mac's Bluetooth central and answer GATT +// The bridge helper: own the Mac's Bluetooth central and peripheral and answer GATT // operations over an authenticated loopback channel. It mints a per-session // capability token and gates every request on it, validated before the op is // interpreted. No key material crosses the wire. Constructing a CBCentralManager @@ -30,8 +30,13 @@ guard CBManager.authorization == .allowedAlways else { let token = CapabilityToken() let central = CoreBluetoothCentral() +let peripheral = CoreBluetoothPeripheral() let listener = LoopbackListener( - router: RequestRouter(service: CentralService(backend: central), gate: AuthGate(session: token)) + router: RequestRouter( + service: CentralService(backend: central, peripheralSupported: true), + peripheralService: PeripheralService(backend: peripheral), + gate: AuthGate(session: token) + ) ) do { diff --git a/apps/helper/Tests/SimBLEHelperKitTests/AuthTests.swift b/apps/helper/Tests/SimBLEHelperKitTests/AuthTests.swift index 5408201..240f271 100644 --- a/apps/helper/Tests/SimBLEHelperKitTests/AuthTests.swift +++ b/apps/helper/Tests/SimBLEHelperKitTests/AuthTests.swift @@ -54,6 +54,7 @@ final class AuthTests: XCTestCase { private func router(session: CapabilityToken) -> RequestRouter { RequestRouter(service: CentralService(backend: FakeCentralBackend()), + peripheralService: PeripheralService(backend: FakePeripheralBackend()), gate: AuthGate(session: session)) } diff --git a/apps/helper/Tests/SimBLEHelperKitTests/FakePeripheralBackend.swift b/apps/helper/Tests/SimBLEHelperKitTests/FakePeripheralBackend.swift new file mode 100644 index 0000000..6018e58 --- /dev/null +++ b/apps/helper/Tests/SimBLEHelperKitTests/FakePeripheralBackend.swift @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Nirapod Labs + +import Foundation +import SimBLEHostCore +import SimBLEProtocol + +/// A radio-free `PeripheralBackend` for the helper round-trip and router tests. It +/// accepts every command and exposes the event sink, so a loopback client drives a +/// full request-response and reads a streamed event without Bluetooth. +final class FakePeripheralBackend: PeripheralBackend, @unchecked Sendable { + private let lock = NSLock() + private var sink: (@Sendable (PeripheralBackendEvent) -> Void)? + + var state: UInt64 = Wire.managerStatePoweredOn + + func setEventSink(_ sink: @escaping @Sendable (PeripheralBackendEvent) -> Void) { + lock.lock(); self.sink = sink; lock.unlock() + } + + func emit(_ event: PeripheralBackendEvent) { + lock.lock(); let sink = self.sink; lock.unlock() + sink?(event) + } + + func managerState() -> UInt64 { + state + } + + 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 {} +} diff --git a/apps/helper/Tests/SimBLEHelperKitTests/LoopbackRoundTripTests.swift b/apps/helper/Tests/SimBLEHelperKitTests/LoopbackRoundTripTests.swift index 9b19f51..b27449a 100644 --- a/apps/helper/Tests/SimBLEHelperKitTests/LoopbackRoundTripTests.swift +++ b/apps/helper/Tests/SimBLEHelperKitTests/LoopbackRoundTripTests.swift @@ -15,11 +15,13 @@ final class LoopbackRoundTripTests: XCTestCase { private let serviceUUID = "180D" private let charUUID = "2A37" - private func startListener(_ backend: FakeCentralBackend, token: CapabilityToken) + private func startListener(_ backend: FakeCentralBackend, token: CapabilityToken, + peripheral: FakePeripheralBackend = FakePeripheralBackend()) throws -> LoopbackListener { let listener = LoopbackListener( router: RequestRouter(service: CentralService(backend: backend), + peripheralService: PeripheralService(backend: peripheral), gate: AuthGate(session: token)) ) try listener.start() @@ -142,14 +144,29 @@ final class LoopbackRoundTripTests: XCTestCase { XCTAssertEqual(code, BridgeErrorCode.unauthorized) } - func testPeripheralRoleOpIsRejectedOverLoopback() throws { + func testPeripheralRoleOpRoutesToThePeripheralService() throws { let token = CapabilityToken() let listener = try startListener(FakeCentralBackend(), token: token) defer { listener.stop() } let client = try LoopbackClient(port: listener.port) - guard case let .failure(_, code, _) = try client.send(.stopAdvertising, token: token) else { - return XCTFail("a peripheral-role op must come back not-implemented") - } - XCTAssertEqual(code, BridgeErrorCode.notImplemented) + XCTAssertEqual(try client.send(.stopAdvertising, token: token), .advertisingStopped) + } + + func testPeripheralEventStreamsToTheClient() throws { + let token = CapabilityToken() + let peripheral = FakePeripheralBackend() + let listener = try startListener(FakeCentralBackend(), token: token, peripheral: peripheral) + defer { listener.stop() } + let client = try LoopbackClient(port: listener.port) + + XCTAssertEqual(try client.send( + .startAdvertising(localName: "Sensor", serviceUUIDs: ["180D"]), token: token + ), .advertisingStarted) + peripheral.emit(.readRequest(requestId: 1, serviceUUID: serviceUUID, + characteristicUUID: charUUID, offset: 0, centralId: peripheralId)) + XCTAssertEqual(try client.receiveEvent(), .readRequest( + requestId: 1, serviceUUID: serviceUUID, characteristicUUID: charUUID, offset: 0, + centralId: peripheralId + )) } } diff --git a/apps/helper/Tests/SimBLEHelperKitTests/RouterRobustnessTests.swift b/apps/helper/Tests/SimBLEHelperKitTests/RouterRobustnessTests.swift index 9ae3c89..080e102 100644 --- a/apps/helper/Tests/SimBLEHelperKitTests/RouterRobustnessTests.swift +++ b/apps/helper/Tests/SimBLEHelperKitTests/RouterRobustnessTests.swift @@ -13,6 +13,7 @@ import XCTest final class RouterRobustnessTests: XCTestCase { private func router(session: CapabilityToken) -> RequestRouter { RequestRouter(service: CentralService(backend: FakeCentralBackend()), + peripheralService: PeripheralService(backend: FakePeripheralBackend()), gate: AuthGate(session: session)) } diff --git a/packages/host-core/README.md b/packages/host-core/README.md index 8ec09e0..b5972a2 100644 --- a/packages/host-core/README.md +++ b/packages/host-core/README.md @@ -7,5 +7,7 @@ SPDX-FileCopyrightText: 2026 Nirapod Labs The macOS CoreBluetooth service behind the helper. -The scaffold exposes status metadata only. Scan, connect, GATT, and -peripheral-server behavior are not implemented yet. +It drives the Mac's adapter in both roles. As a central it scans, connects, discovers services and +characteristics, reads, writes, sets notify, and reads RSSI. As a peripheral it publishes services, +advertises, answers read and write requests, and pushes characteristic updates. Each role sits +behind a backend protocol, run against a fake on a radio-less runner or the real radio on a Mac. diff --git a/packages/host-core/Sources/SimBLEHostCore/CentralService.swift b/packages/host-core/Sources/SimBLEHostCore/CentralService.swift index 5bb2d69..22dc4cd 100644 --- a/packages/host-core/Sources/SimBLEHostCore/CentralService.swift +++ b/packages/host-core/Sources/SimBLEHostCore/CentralService.swift @@ -9,16 +9,18 @@ import SimBLEProtocol /// backend's unsolicited events to a sink the router installs, mapped to protocol /// events. The backend is injected, so tests run it against a fake with no radio. /// -/// Only the central role is implemented here. A peripheral-role request reaches -/// the router, not this service; the router answers it with a not-implemented -/// failure. +/// This service handles the central role. The router routes a peripheral-role request +/// to the peripheral service instead. public final class CentralService: @unchecked Sendable { private let backend: CentralBackend + private let peripheralSupported: Bool /// Build the service over the backend it drives. The CLI helper injects the real - /// `CoreBluetoothCentral`; tests inject a fake. - public init(backend: CentralBackend) { + /// `CoreBluetoothCentral`; tests inject a fake. `peripheralSupported` reports whether + /// the peripheral bridge is wired, surfaced in `hostStatus`. + public init(backend: CentralBackend, peripheralSupported: Bool = false) { self.backend = backend + self.peripheralSupported = peripheralSupported } /// Forward backend events to `sink`, mapped to protocol events. The router sets this @@ -31,11 +33,11 @@ public final class CentralService: @unchecked Sendable { /// The current host status, read straight from the backend's central manager state. /// `centralSupported` is true once the backend reaches `poweredOn`; `peripheralSupported` - /// is false; the peripheral role is not implemented in the central bridge. + /// reports the value the service was built with. public func hostStatus() -> HostStatus { let state = backend.managerState() return HostStatus(centralSupported: state == Wire.managerStatePoweredOn, - peripheralSupported: false, centralState: state) + peripheralSupported: peripheralSupported, centralState: state) } /// Drive `request` against the backend and produce its response. A backend failure diff --git a/packages/host-core/Sources/SimBLEHostCore/CoreBluetoothPeripheral.swift b/packages/host-core/Sources/SimBLEHostCore/CoreBluetoothPeripheral.swift new file mode 100644 index 0000000..81984de --- /dev/null +++ b/packages/host-core/Sources/SimBLEHostCore/CoreBluetoothPeripheral.swift @@ -0,0 +1,288 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Nirapod Labs + +import CoreBluetooth +import Foundation +import SimBLEProtocol + +/// The real peripheral driver: a `CBPeripheralManager` behind the `PeripheralBackend` +/// surface. A command that has a delegate callback blocks the calling thread on a latch +/// until it fires or a deadline passes; unsolicited results (a read or write request, a +/// subscription change, a state change, the transmit queue draining) go to the event +/// sink. +/// +/// The bridge moves GATT traffic only. No pairing secret or key material is read, +/// stored, or relayed by this driver. +public final class CoreBluetoothPeripheral: NSObject, PeripheralBackend, @unchecked Sendable { + /// How long a command waits for its delegate callback before reporting a timeout. + private static let commandTimeout: TimeInterval = 10 + + private let queue = DispatchQueue(label: "simble.peripheral") + private var manager: CBPeripheralManager! + private let lock = NSLock() + + /// Published services keyed by UUID string, with their characteristics. + private var services: [String: CBMutableService] = [:] + /// Incoming ATT requests keyed by the id minted when raising the event, consumed by a respond. + private var pendingRequests: [UInt64: CBATTRequest] = [:] + private var nextRequestId: UInt64 = 1 + // Pending command latches, signaled by the delegate callback. + private var addServiceWaiters: [String: Latch] = [:] + private var advertisingWaiter: Latch? + private var eventSink: (@Sendable (PeripheralBackendEvent) -> Void)? + + /// Build the driver and start the manager on its own queue. The manager reaches + /// `poweredOn` asynchronously; `managerState()` observes it. + override public init() { + super.init() + manager = CBPeripheralManager(delegate: self, queue: queue) + } + + public func setEventSink(_ sink: @escaping @Sendable (PeripheralBackendEvent) -> Void) { + lock.lock() + eventSink = sink + lock.unlock() + } + + public func managerState() -> UInt64 { + UInt64(max(0, manager.state.rawValue)) + } + + public func addService(serviceUUID: String, isPrimary: Bool, + characteristics: [CharacteristicSpec]) throws + { + let service = CBMutableService(type: CBUUID(string: serviceUUID), primary: isPrimary) + service.characteristics = characteristics.map { + CBMutableCharacteristic( + type: CBUUID(string: $0.uuid), + properties: CBCharacteristicProperties(rawValue: UInt(truncatingIfNeeded: $0.properties)), + value: nil, + permissions: CBAttributePermissions(rawValue: UInt(truncatingIfNeeded: $0.permissions)) + ) + } + let latch = Latch() + setAddServiceWaiter(latch, for: serviceUUID) + lock.lock(); services[serviceUUID] = service; lock.unlock() + queue.async { self.manager.add(service) } + _ = try wait(latch) { self.clearAddServiceWaiter(serviceUUID) } + } + + public func removeService(serviceUUID: String) throws { + let service = try service(serviceUUID) + lock.lock(); services[serviceUUID] = nil; lock.unlock() + queue.async { self.manager.remove(service) } + } + + public func startAdvertising(localName: String?, serviceUUIDs: [String]?) throws { + var data: [String: Any] = [:] + if let localName { data[CBAdvertisementDataLocalNameKey] = localName } + if let serviceUUIDs { + data[CBAdvertisementDataServiceUUIDsKey] = serviceUUIDs.map { CBUUID(string: $0) } + } + let latch = Latch() + setAdvertisingWaiter(latch) + queue.async { self.manager.startAdvertising(data) } + _ = try wait(latch) { self.clearAdvertisingWaiter() } + } + + public func stopAdvertising() throws { + queue.async { self.manager.stopAdvertising() } + } + + public func respondRead(requestId: UInt64, value: Data, attError: UInt64) throws { + let request = try takeRequest(requestId) + request.value = value + queue.async { self.manager.respond(to: request, withResult: Self.result(attError)) } + } + + public func respondWrite(requestId: UInt64, attError: UInt64) throws { + let request = try takeRequest(requestId) + queue.async { self.manager.respond(to: request, withResult: Self.result(attError)) } + } + + public func updateValue(serviceUUID: String, characteristicUUID: String, value: Data, + centralId: Data?) throws + { + let characteristic = try characteristic(characteristicUUID, serviceUUID: serviceUUID) + let centrals = centralId.flatMap { id in subscribedCentrals(characteristic).filter { + Self.identifier(of: $0) == id + } } + queue.async { + _ = self.manager.updateValue(value, for: characteristic, onSubscribedCentrals: centrals) + } + } + + // MARK: lookup + + private func service(_ uuid: String) throws -> CBMutableService { + lock.lock(); let service = services[uuid]; lock.unlock() + guard let service else { + throw PeripheralBackendError(code: Self.unknownAttribute, message: "service not published") + } + return service + } + + private func characteristic(_ uuid: String, serviceUUID: String) throws -> CBMutableCharacteristic + { + let service = try service(serviceUUID) + let target = CBUUID(string: uuid) + guard let characteristic = (service.characteristics as? [CBMutableCharacteristic])? + .first(where: { $0.uuid == target }) + else { + throw PeripheralBackendError(code: Self.unknownAttribute, + message: "characteristic not published") + } + return characteristic + } + + private func subscribedCentrals(_ characteristic: CBMutableCharacteristic) -> [CBCentral] { + characteristic.subscribedCentrals ?? [] + } + + private static func identifier(of central: CBCentral) -> Data { + withUnsafeBytes(of: central.identifier.uuid) { Data($0) } + } + + private static func result(_ attError: UInt64) -> CBATTError.Code { + CBATTError.Code(rawValue: Int(truncatingIfNeeded: attError)) ?? .success + } + + // MARK: request bookkeeping + + /// Record an incoming ATT request under a freshly minted id and return that id. + private func record(_ request: CBATTRequest) -> UInt64 { + lock.lock() + let id = nextRequestId + nextRequestId &+= 1 + pendingRequests[id] = request + lock.unlock() + return id + } + + private func takeRequest(_ id: UInt64) throws -> CBATTRequest { + lock.lock(); let request = pendingRequests.removeValue(forKey: id); lock.unlock() + guard let request else { + throw PeripheralBackendError(code: Self.unknownAttribute, message: "unknown request") + } + return request + } + + private func wait(_ latch: Latch, cleanup: () -> Void) throws -> T { + defer { cleanup() } + guard let outcome = latch.wait(timeout: Self.commandTimeout) else { + throw PeripheralBackendError(code: Self.timeout, message: "command timed out") + } + return try outcome.get() + } + + private func emit(_ event: PeripheralBackendEvent) { + lock.lock() + let sink = eventSink + lock.unlock() + sink?(event) + } +} + +private extension CoreBluetoothPeripheral { + static let unknownAttribute: Int64 = 4 // CBATTError.attributeNotFound + static let timeout: Int64 = 9 // CBError.connectionTimeout + static let opFailed: Int64 = 1 // CBError.unknown + + func setAddServiceWaiter(_ latch: Latch, for uuid: String) { + lock.lock(); addServiceWaiters[uuid] = latch; lock.unlock() + } + + func clearAddServiceWaiter(_ uuid: String) { + lock.lock(); addServiceWaiters[uuid] = nil; lock.unlock() + } + + func setAdvertisingWaiter(_ latch: Latch) { + lock.lock(); advertisingWaiter = latch; lock.unlock() + } + + func clearAdvertisingWaiter() { + lock.lock(); advertisingWaiter = nil; lock.unlock() + } +} + +// MARK: CBPeripheralManagerDelegate + +extension CoreBluetoothPeripheral: CBPeripheralManagerDelegate { + public func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) { + emit(.stateChanged(state: UInt64(max(0, peripheral.state.rawValue)))) + } + + public func peripheralManager(_: CBPeripheralManager, didAdd service: CBService, error: Error?) { + let uuid = service.uuid.uuidString + lock.lock(); let latch = addServiceWaiters[uuid]; lock.unlock() + if let error { + latch?.fulfill(.failure(Self.backendError(error, fallback: "add service failed"))) + return + } + latch?.fulfill(.success(())) + } + + public func peripheralManagerDidStartAdvertising(_: CBPeripheralManager, error: Error?) { + lock.lock(); let latch = advertisingWaiter; lock.unlock() + if let error { + latch?.fulfill(.failure(Self.backendError(error, fallback: "start advertising failed"))) + return + } + latch?.fulfill(.success(())) + } + + public func peripheralManager(_: CBPeripheralManager, didReceiveRead request: CBATTRequest) { + let id = record(request) + let characteristic = request.characteristic + emit(.readRequest(requestId: id, + serviceUUID: characteristic.service?.uuid.uuidString ?? "", + characteristicUUID: characteristic.uuid.uuidString, + offset: UInt64(max(0, request.offset)), + centralId: Self.identifier(of: request.central))) + } + + public func peripheralManager(_: CBPeripheralManager, + didReceiveWrite requests: [CBATTRequest]) + { + for request in requests { + let id = record(request) + let characteristic = request.characteristic + emit(.writeRequest(requestId: id, + serviceUUID: characteristic.service?.uuid.uuidString ?? "", + characteristicUUID: characteristic.uuid.uuidString, + value: request.value ?? Data(), + offset: UInt64(max(0, request.offset)), + centralId: Self.identifier(of: request.central))) + } + } + + public func peripheralManager(_: CBPeripheralManager, central: CBCentral, + didSubscribeTo characteristic: CBCharacteristic) + { + emit(.subscribed(serviceUUID: characteristic.service?.uuid.uuidString ?? "", + characteristicUUID: characteristic.uuid.uuidString, + centralId: Self.identifier(of: central), + mtu: UInt64(central.maximumUpdateValueLength))) + } + + public func peripheralManager(_: CBPeripheralManager, central: CBCentral, + didUnsubscribeFrom characteristic: CBCharacteristic) + { + emit(.unsubscribed(serviceUUID: characteristic.service?.uuid.uuidString ?? "", + characteristicUUID: characteristic.uuid.uuidString, + centralId: Self.identifier(of: central))) + } + + public func peripheralManagerIsReady(toUpdateSubscribers _: CBPeripheralManager) { + emit(.readyToUpdate) + } +} + +private extension CoreBluetoothPeripheral { + static func backendError(_ error: Error?, fallback: String) -> PeripheralBackendError { + guard let error = error as NSError? else { + return PeripheralBackendError(code: opFailed, message: fallback) + } + return PeripheralBackendError(code: Int64(error.code), message: error.localizedDescription) + } +} diff --git a/packages/host-core/Sources/SimBLEHostCore/PeripheralBackend.swift b/packages/host-core/Sources/SimBLEHostCore/PeripheralBackend.swift new file mode 100644 index 0000000..372a9c6 --- /dev/null +++ b/packages/host-core/Sources/SimBLEHostCore/PeripheralBackend.swift @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Nirapod Labs + +import Foundation +import SimBLEProtocol + +/// An unsolicited event a peripheral learns without asking: a manager-state change, +/// an incoming read or write request, a central subscribing or unsubscribing, or the +/// transmit queue draining. The backend hands these to a sink the service installs; +/// the service turns each into a protocol event frame. +public enum PeripheralBackendEvent: Equatable, Sendable { + /// The peripheral manager's `CBManagerState` changed. + case stateChanged(state: UInt64) + /// A connected central asked to read a local characteristic; answer with `respondRead` + /// carrying the same `requestId`. + case readRequest(requestId: UInt64, serviceUUID: String, characteristicUUID: String, + offset: UInt64, centralId: Data) + /// A connected central asked to write a local characteristic; answer with `respondWrite` + /// carrying the same `requestId`. + case writeRequest(requestId: UInt64, serviceUUID: String, characteristicUUID: String, + value: Data, offset: UInt64, centralId: Data) + /// A central subscribed to a local characteristic; `mtu` is its maximum update value length. + case subscribed(serviceUUID: String, characteristicUUID: String, centralId: Data, mtu: UInt64) + /// A central unsubscribed from a local characteristic. + case unsubscribed(serviceUUID: String, characteristicUUID: String, centralId: Data) + /// The transmit queue has room again after a failed `updateValue`. + case readyToUpdate +} + +/// Why a backend command failed, carrying a device-shaped `CBError`/`CBATTError` +/// numeric code and a human-readable reason that is never load-bearing. +public struct PeripheralBackendError: Error, Equatable, Sendable { + /// A `CBError`/`CBATTError` raw value, for device parity. + public let code: Int64 + /// A human-readable reason for logs; the code is what callers branch on. + public let message: String + + /// Wrap a failure with its device code and reason. + public init(code: Int64, message: String) { + self.code = code + self.message = message + } +} + +/// The CoreBluetooth peripheral operations the bridge drives, abstracted so the +/// service runs against a fake on a radio-less runner and against the real radio +/// on a Mac. Each command blocks until its CoreBluetooth delegate callback fires +/// or the backend times out; unsolicited results arrive through the event sink. +/// +/// The bridge moves GATT traffic only. No pairing secret, bonding record, or key +/// material crosses this surface. +public protocol PeripheralBackend: AnyObject, Sendable { + /// Install the sink for unsolicited events. The service sets this once before + /// driving any command; the backend calls it from its own queue. + func setEventSink(_ sink: @escaping @Sendable (PeripheralBackendEvent) -> Void) + + /// The current `CBManagerState` raw value. + func managerState() -> UInt64 + + /// Publish a local GATT service with its characteristics. + func addService(serviceUUID: String, isPrimary: Bool, + characteristics: [CharacteristicSpec]) throws + + /// Remove a previously published local service. + func removeService(serviceUUID: String) throws + + /// Begin advertising, optionally with a local name and service UUIDs. + func startAdvertising(localName: String?, serviceUUIDs: [String]?) throws + + /// Stop advertising. + func stopAdvertising() throws + + /// Answer an incoming read request with a value and an ATT result. + func respondRead(requestId: UInt64, value: Data, attError: UInt64) throws + + /// Answer an incoming write request with an ATT result. + func respondWrite(requestId: UInt64, attError: UInt64) throws + + /// Push a new value for a local characteristic to the subscribed centrals, or to one + /// central when `centralId` is set. + func updateValue(serviceUUID: String, characteristicUUID: String, value: Data, + centralId: Data?) throws +} diff --git a/packages/host-core/Sources/SimBLEHostCore/PeripheralService.swift b/packages/host-core/Sources/SimBLEHostCore/PeripheralService.swift new file mode 100644 index 0000000..25dab37 --- /dev/null +++ b/packages/host-core/Sources/SimBLEHostCore/PeripheralService.swift @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Nirapod Labs + +import Foundation +import SimBLEProtocol + +/// The op the router calls for a peripheral-role request. It takes a decoded protocol +/// request, drives the injected backend, and produces the protocol response; it +/// forwards the backend's unsolicited events to a sink the router installs, mapped to +/// protocol events. The backend is injected, so tests run it against a fake with no +/// radio. +/// +/// The router routes a peripheral-role request here and every other request to the +/// central service. +public final class PeripheralService: @unchecked Sendable { + private let backend: PeripheralBackend + + /// Build the service over the backend it drives. The CLI helper injects the real + /// `CoreBluetoothPeripheral`; tests inject a fake. + public init(backend: PeripheralBackend) { + self.backend = backend + } + + /// Forward backend events to `sink`, mapped to protocol events. The router sets this + /// once and writes each event back to the connected client as an event frame. + public func onEvent(_ sink: @escaping @Sendable (Event) -> Void) { + backend.setEventSink { event in + sink(Self.event(from: event)) + } + } + + /// Drive `request` against the backend and produce its response. A backend failure + /// becomes a protocol failure carrying the device code; an unexpected error becomes a + /// generic failure. The op is echoed so the client correlates the reply. + public func handle(_ request: Request) -> Response { + do { + switch request { + case let .addService(serviceUUID, isPrimary, characteristics): + try backend.addService(serviceUUID: serviceUUID, isPrimary: isPrimary, + characteristics: characteristics) + return .serviceAdded(serviceUUID: serviceUUID) + case let .removeService(serviceUUID): + try backend.removeService(serviceUUID: serviceUUID) + return .serviceRemoved(serviceUUID: serviceUUID) + case let .startAdvertising(localName, serviceUUIDs): + try backend.startAdvertising(localName: localName, serviceUUIDs: serviceUUIDs) + return .advertisingStarted + case .stopAdvertising: + try backend.stopAdvertising() + return .advertisingStopped + case let .respondRead(requestId, value, attError): + try backend.respondRead(requestId: requestId, value: value, attError: attError) + return .readResponded + case let .respondWrite(requestId, attError): + try backend.respondWrite(requestId: requestId, attError: attError) + return .writeResponded + case let .updateValue(serviceUUID, characteristicUUID, value, centralId): + try backend.updateValue(serviceUUID: serviceUUID, characteristicUUID: characteristicUUID, + value: value, centralId: centralId) + return .valueUpdated + default: + // The router routes only a peripheral-role op here; this default is the + // unreachable fallback. + return .failure(op: Wire.op(of: request), code: Self.notImplemented, + message: "operation not implemented in the peripheral bridge") + } + } catch let failure as PeripheralBackendError { + return .failure(op: Wire.op(of: request), code: failure.code, message: failure.message) + } catch { + return .failure(op: Wire.op(of: request), code: Self.unsupported, + message: String(describing: error)) + } + } + + /// Map a backend event to its protocol event. + private static func event(from event: PeripheralBackendEvent) -> Event { + switch event { + case let .stateChanged(state): + .peripheralStateChanged(state: state) + case let .readRequest(requestId, serviceUUID, characteristicUUID, offset, centralId): + .readRequest(requestId: requestId, serviceUUID: serviceUUID, + characteristicUUID: characteristicUUID, offset: offset, + centralId: centralId) + case let .writeRequest(requestId, serviceUUID, characteristicUUID, value, offset, centralId): + .writeRequest(requestId: requestId, serviceUUID: serviceUUID, + characteristicUUID: characteristicUUID, value: value, offset: offset, + centralId: centralId) + case let .subscribed(serviceUUID, characteristicUUID, centralId, mtu): + .subscribed(serviceUUID: serviceUUID, characteristicUUID: characteristicUUID, + centralId: centralId, mtu: mtu) + case let .unsubscribed(serviceUUID, characteristicUUID, centralId): + .unsubscribed(serviceUUID: serviceUUID, characteristicUUID: characteristicUUID, + centralId: centralId) + case .readyToUpdate: + .readyToUpdate + } + } + + /// CBError.Code.unsupportedDevice (6) stands in for a request the bridge cannot serve. + private static let unsupported: Int64 = 6 + /// A non-peripheral-role op the peripheral bridge does not implement. + private static let notImplemented: Int64 = 6 +} diff --git a/packages/host-core/Tests/SimBLEHostCoreTests/CoreBluetoothPeripheralTests.swift b/packages/host-core/Tests/SimBLEHostCoreTests/CoreBluetoothPeripheralTests.swift new file mode 100644 index 0000000..6c4d2fe --- /dev/null +++ b/packages/host-core/Tests/SimBLEHostCoreTests/CoreBluetoothPeripheralTests.swift @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Nirapod Labs + +import CoreBluetooth +import Foundation +@testable import SimBLEHostCore +import SimBLEProtocol +import XCTest + +/// These exercise the real `CBPeripheralManager`. Instantiating one without a granted +/// Bluetooth authorization aborts the process (no usage-description in a bare test +/// bundle), so they skip unless authorization is already `allowedAlways`, then wait +/// for `poweredOn` and skip on timeout. They run only from a properly bundled host; +/// on a radio-less runner and on a Mac regardless of Bluetooth state they skip, so +/// the suite is green everywhere. The fake-backend tests carry the deterministic +/// dispatch coverage. +final class CoreBluetoothPeripheralTests: XCTestCase { + /// How long to wait for the manager to power on before skipping. + private static let powerOnTimeout: TimeInterval = 3 + private let serviceUUID = "180D" + private let charUUID = "2A37" + + /// Build the driver and wait for `poweredOn`, or skip. Authorization short of + /// `allowedAlways` skips before any manager is constructed, which is the only safe + /// path: constructing one without authorization aborts. + private func poweredOnPeripheral() throws -> CoreBluetoothPeripheral { + try XCTSkipUnless(CBManager.authorization == .allowedAlways, + "Bluetooth not authorized for this process (run from the bundled host)") + let peripheral = CoreBluetoothPeripheral() + let deadline = Date().addingTimeInterval(Self.powerOnTimeout) + while Date() < deadline { + if peripheral.managerState() == Wire.managerStatePoweredOn { return peripheral } + Thread.sleep(forTimeInterval: 0.05) + } + throw XCTSkip("Bluetooth peripheral not powered on (off, or unsupported)") + } + + func testManagerReachesPoweredOnOrSkips() throws { + let peripheral = try poweredOnPeripheral() + XCTAssertEqual(peripheral.managerState(), Wire.managerStatePoweredOn) + } + + func testAddServiceDoesNotThrowWhenPoweredOn() throws { + let peripheral = try poweredOnPeripheral() + let spec = CharacteristicSpec(uuid: charUUID, properties: 0x12, permissions: 0x01) + XCTAssertNoThrow(try peripheral.addService(serviceUUID: serviceUUID, isPrimary: true, + characteristics: [spec])) + } + + func testUpdateValueOnUnknownServiceFailsCleanly() throws { + let peripheral = try poweredOnPeripheral() + // An update to a service the manager never published fails with the device code, not a crash. + XCTAssertThrowsError(try peripheral.updateValue(serviceUUID: serviceUUID, + characteristicUUID: charUUID, + value: Data([0x01]), centralId: nil)) + { error in + guard error is PeripheralBackendError else { + return XCTFail("expected a PeripheralBackendError, got \(error)") + } + } + } + + func testRespondToUnknownRequestFailsCleanly() throws { + let peripheral = try poweredOnPeripheral() + XCTAssertThrowsError(try peripheral.respondWrite(requestId: 999, attError: 0)) { error in + guard error is PeripheralBackendError else { + return XCTFail("expected a PeripheralBackendError, got \(error)") + } + } + } +} diff --git a/packages/host-core/Tests/SimBLEHostCoreTests/FakePeripheralBackend.swift b/packages/host-core/Tests/SimBLEHostCoreTests/FakePeripheralBackend.swift new file mode 100644 index 0000000..14e78de --- /dev/null +++ b/packages/host-core/Tests/SimBLEHostCoreTests/FakePeripheralBackend.swift @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Nirapod Labs + +import Foundation +@testable import SimBLEHostCore +import SimBLEProtocol + +/// A radio-free `PeripheralBackend` for the dispatch tests. It records each command, +/// exposes the event sink, and can fail a command with a chosen device code. +/// Deterministic, so the suite runs without Bluetooth. +final class FakePeripheralBackend: PeripheralBackend, @unchecked Sendable { + private let lock = NSLock() + private var sink: (@Sendable (PeripheralBackendEvent) -> Void)? + + /// The `CBManagerState` the fake reports; defaults to `poweredOn`. + var state: UInt64 = Wire.managerStatePoweredOn + /// When set, the next command throws this error. + var failWith: PeripheralBackendError? + + /// The commands the fake received, in order. + private(set) var commands: [String] = [] + /// The last service the fake was asked to publish. + private(set) var lastService: ( + uuid: String, + isPrimary: Bool, + characteristics: [CharacteristicSpec] + )? + /// The last advertising request the fake received. + private(set) var lastAdvertising: (localName: String?, serviceUUIDs: [String]?)? + /// The last read response the fake received. + private(set) var lastReadResponse: (requestId: UInt64, value: Data, attError: UInt64)? + /// The last write response the fake received. + private(set) var lastWriteResponse: (requestId: UInt64, attError: UInt64)? + /// The last value update the fake received. + private(set) var lastUpdate: (serviceUUID: String, characteristicUUID: String, value: Data, + centralId: Data?)? + + func setEventSink(_ sink: @escaping @Sendable (PeripheralBackendEvent) -> Void) { + lock.lock(); self.sink = sink; lock.unlock() + } + + /// Push an event to the installed sink. + func emit(_ event: PeripheralBackendEvent) { + lock.lock(); let sink = self.sink; lock.unlock() + sink?(event) + } + + func managerState() -> UInt64 { + state + } + + func addService(serviceUUID: String, isPrimary: Bool, + characteristics: [CharacteristicSpec]) throws + { + try checkFailure(); record("addService") + lastService = (serviceUUID, isPrimary, characteristics) + } + + func removeService(serviceUUID _: String) throws { + try checkFailure(); record("removeService") + } + + func startAdvertising(localName: String?, serviceUUIDs: [String]?) throws { + try checkFailure(); record("startAdvertising") + lastAdvertising = (localName, serviceUUIDs) + } + + func stopAdvertising() throws { + try checkFailure(); record("stopAdvertising") + } + + func respondRead(requestId: UInt64, value: Data, attError: UInt64) throws { + try checkFailure(); record("respondRead") + lastReadResponse = (requestId, value, attError) + } + + func respondWrite(requestId: UInt64, attError: UInt64) throws { + try checkFailure(); record("respondWrite") + lastWriteResponse = (requestId, attError) + } + + func updateValue(serviceUUID: String, characteristicUUID: String, value: Data, + centralId: Data?) throws + { + try checkFailure(); record("updateValue") + lastUpdate = (serviceUUID, characteristicUUID, value, centralId) + } + + private func record(_ name: String) { + lock.lock(); commands.append(name); lock.unlock() + } + + private func checkFailure() throws { + if let failWith { throw failWith } + } +} diff --git a/packages/host-core/Tests/SimBLEHostCoreTests/PeripheralServiceTests.swift b/packages/host-core/Tests/SimBLEHostCoreTests/PeripheralServiceTests.swift new file mode 100644 index 0000000..ffeeba6 --- /dev/null +++ b/packages/host-core/Tests/SimBLEHostCoreTests/PeripheralServiceTests.swift @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Nirapod Labs + +import Foundation +@testable import SimBLEHostCore +import SimBLEProtocol +import XCTest + +/// Dispatch from a decoded peripheral-role request to a response, against a fake +/// backend so the suite runs with no radio. Each case proves one op drives the backend +/// and shapes the response, plus the event mapping and the failure path. +final class PeripheralServiceTests: XCTestCase { + private let serviceUUID = "180D" + private let charUUID = "2A37" + private let centralId = Data([0xDE, 0xAD, 0xBE, 0xEF]) + + func testAddServiceCarriesTheSpecAndConfirms() { + let backend = FakePeripheralBackend() + let service = PeripheralService(backend: backend) + let spec = CharacteristicSpec(uuid: charUUID, properties: 0x12, permissions: 0x01) + XCTAssertEqual( + service.handle(.addService(serviceUUID: serviceUUID, isPrimary: true, + characteristics: [spec])), + .serviceAdded(serviceUUID: serviceUUID) + ) + XCTAssertEqual(backend.commands, ["addService"]) + XCTAssertEqual(backend.lastService?.uuid, serviceUUID) + XCTAssertEqual(backend.lastService?.isPrimary, true) + XCTAssertEqual(backend.lastService?.characteristics, [spec]) + } + + func testRemoveServiceConfirms() { + let backend = FakePeripheralBackend() + let service = PeripheralService(backend: backend) + XCTAssertEqual(service.handle(.removeService(serviceUUID: serviceUUID)), + .serviceRemoved(serviceUUID: serviceUUID)) + XCTAssertEqual(backend.commands, ["removeService"]) + } + + func testStartAdvertisingCarriesTheFilterAndConfirms() { + let backend = FakePeripheralBackend() + let service = PeripheralService(backend: backend) + XCTAssertEqual( + service.handle(.startAdvertising(localName: "Sensor", serviceUUIDs: ["180D"])), + .advertisingStarted + ) + XCTAssertEqual(backend.commands, ["startAdvertising"]) + XCTAssertEqual(backend.lastAdvertising?.localName, "Sensor") + XCTAssertEqual(backend.lastAdvertising?.serviceUUIDs, ["180D"]) + } + + func testStopAdvertisingConfirms() { + let backend = FakePeripheralBackend() + let service = PeripheralService(backend: backend) + XCTAssertEqual(service.handle(.stopAdvertising), .advertisingStopped) + XCTAssertEqual(backend.commands, ["stopAdvertising"]) + } + + func testRespondReadCarriesTheValueAndConfirms() { + let backend = FakePeripheralBackend() + let service = PeripheralService(backend: backend) + XCTAssertEqual( + service.handle(.respondRead(requestId: 7, value: Data([0x48, 0x49]), attError: 0)), + .readResponded + ) + XCTAssertEqual(backend.lastReadResponse?.requestId, 7) + XCTAssertEqual(backend.lastReadResponse?.value, Data([0x48, 0x49])) + XCTAssertEqual(backend.lastReadResponse?.attError, 0) + } + + func testRespondWriteCarriesTheResultAndConfirms() { + let backend = FakePeripheralBackend() + let service = PeripheralService(backend: backend) + XCTAssertEqual(service.handle(.respondWrite(requestId: 9, attError: 0)), .writeResponded) + XCTAssertEqual(backend.lastWriteResponse?.requestId, 9) + XCTAssertEqual(backend.lastWriteResponse?.attError, 0) + } + + func testUpdateValueCarriesTheTargetAndConfirms() { + let backend = FakePeripheralBackend() + let service = PeripheralService(backend: backend) + XCTAssertEqual( + service.handle(.updateValue(serviceUUID: serviceUUID, characteristicUUID: charUUID, + value: Data([0x5A]), centralId: centralId)), + .valueUpdated + ) + XCTAssertEqual(backend.lastUpdate?.serviceUUID, serviceUUID) + XCTAssertEqual(backend.lastUpdate?.characteristicUUID, charUUID) + XCTAssertEqual(backend.lastUpdate?.value, Data([0x5A])) + XCTAssertEqual(backend.lastUpdate?.centralId, centralId) + } + + func testStateChangeSurfacesAsAPeripheralStateChangedEvent() { + let backend = FakePeripheralBackend() + let service = PeripheralService(backend: backend) + let collector = EventCollector() + service.onEvent { collector.append($0) } + backend.emit(.stateChanged(state: Wire.managerStatePoweredOn)) + XCTAssertEqual(collector.all, + [.peripheralStateChanged(state: Wire.managerStatePoweredOn)]) + } + + func testReadRequestSurfacesAsAReadRequestEvent() { + let backend = FakePeripheralBackend() + let service = PeripheralService(backend: backend) + let collector = EventCollector() + service.onEvent { collector.append($0) } + backend.emit(.readRequest(requestId: 3, serviceUUID: serviceUUID, + characteristicUUID: charUUID, offset: 0, centralId: centralId)) + XCTAssertEqual(collector.all, [.readRequest( + requestId: 3, serviceUUID: serviceUUID, characteristicUUID: charUUID, offset: 0, + centralId: centralId + )]) + } + + func testWriteRequestSurfacesAsAWriteRequestEvent() { + let backend = FakePeripheralBackend() + let service = PeripheralService(backend: backend) + let collector = EventCollector() + service.onEvent { collector.append($0) } + backend.emit(.writeRequest(requestId: 4, serviceUUID: serviceUUID, + characteristicUUID: charUUID, value: Data([0x01]), offset: 0, + centralId: centralId)) + XCTAssertEqual(collector.all, [.writeRequest( + requestId: 4, serviceUUID: serviceUUID, characteristicUUID: charUUID, value: Data([0x01]), + offset: 0, centralId: centralId + )]) + } + + func testSubscriptionEventsSurface() { + let backend = FakePeripheralBackend() + let service = PeripheralService(backend: backend) + let collector = EventCollector() + service.onEvent { collector.append($0) } + backend.emit(.subscribed(serviceUUID: serviceUUID, characteristicUUID: charUUID, + centralId: centralId, mtu: 185)) + backend.emit(.unsubscribed(serviceUUID: serviceUUID, characteristicUUID: charUUID, + centralId: centralId)) + backend.emit(.readyToUpdate) + XCTAssertEqual(collector.all, [ + .subscribed(serviceUUID: serviceUUID, characteristicUUID: charUUID, centralId: centralId, + mtu: 185), + .unsubscribed(serviceUUID: serviceUUID, characteristicUUID: charUUID, centralId: centralId), + .readyToUpdate, + ]) + } + + func testBackendFailureBecomesAProtocolFailureWithTheDeviceCode() { + let backend = FakePeripheralBackend() + backend.failWith = PeripheralBackendError(code: 4, message: "service not published") + let service = PeripheralService(backend: backend) + guard case let .failure(op, code, _) = service.handle(.removeService(serviceUUID: serviceUUID)) + else { return XCTFail("a backend failure must surface as a protocol failure") } + XCTAssertEqual(op, Wire.op(of: .removeService(serviceUUID: serviceUUID))) + XCTAssertEqual(code, 4) + } +} diff --git a/packages/protocol/swift/Sources/SimBLEProtocol/Messages.swift b/packages/protocol/swift/Sources/SimBLEProtocol/Messages.swift index 13d9d14..fc01f48 100644 --- a/packages/protocol/swift/Sources/SimBLEProtocol/Messages.swift +++ b/packages/protocol/swift/Sources/SimBLEProtocol/Messages.swift @@ -260,9 +260,8 @@ public enum Wire { } } - /// Whether `request` is a peripheral-role operation, the ops the central bridge - /// rejects with a not-implemented failure in this version (`addService` through - /// `updateValue`). + /// Whether `request` is a peripheral-role operation (`addService` through `updateValue`), + /// which the router sends to the peripheral service. public static func isPeripheralRole(_ request: Request) -> Bool { switch request { case .addService, .removeService, .startAdvertising, .stopAdvertising,