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
38 changes: 19 additions & 19 deletions apps/helper/Sources/SimBLEHelperKit/RequestRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
}
Expand Down
9 changes: 7 additions & 2 deletions apps/helper/Sources/simble-helper/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions apps/helper/Tests/SimBLEHelperKitTests/AuthTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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 {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}

Expand Down
6 changes: 4 additions & 2 deletions packages/host-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
16 changes: 9 additions & 7 deletions packages/host-core/Sources/SimBLEHostCore/CentralService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading