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
12 changes: 9 additions & 3 deletions packages/host-core/Sources/SimBLEHostCore/CentralBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ public enum CentralBackendEvent: Equatable, Sendable {
/// A peripheral disconnected unexpectedly; `errorCode` is the `CBError` raw value when one
/// applied.
case peripheralDisconnected(peripheralId: Data, errorCode: Int64?)
/// A connect succeeded; the connection is up.
case peripheralConnected(peripheralId: Data)
/// A connect failed; `errorCode` is the `CBError` raw value when one applied.
case peripheralConnectFailed(peripheralId: Data, errorCode: Int64?)
/// The central manager's `CBManagerState` changed.
case stateChanged(state: UInt64)
}
Expand All @@ -38,8 +42,9 @@ public struct CentralBackendError: Error, Equatable, Sendable {

/// The CoreBluetooth central 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.
/// on a Mac. Most commands block until their CoreBluetooth delegate callback fires
/// or the backend times out; connect returns once the request is issued, and its
/// outcome arrives through the event sink. Unsolicited results arrive there too.
///
/// The bridge moves GATT traffic only. No pairing secret, bonding record, or key
/// material crosses this surface.
Expand All @@ -57,7 +62,8 @@ public protocol CentralBackend: AnyObject, Sendable {
/// Stop scanning.
func stopScan() throws

/// Connect to the peripheral named by its host identifier.
/// Connect to the peripheral named by its host identifier. Returns once the request is issued;
/// the outcome arrives as a `peripheralConnected` or `peripheralConnectFailed` event.
func connect(peripheralId: Data) throws

/// Cancel the connection to the named peripheral.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ public final class CentralService: @unchecked Sendable {
characteristicUUID: characteristicUUID, value: value)
case let .peripheralDisconnected(peripheralId, errorCode):
return .peripheralDisconnected(peripheralId: peripheralId, errorCode: errorCode)
case let .peripheralConnected(peripheralId):
return .peripheralConnected(peripheralId: peripheralId)
case let .peripheralConnectFailed(peripheralId, errorCode):
return .peripheralConnectFailed(peripheralId: peripheralId, errorCode: errorCode)
case let .stateChanged(state):
return .centralStateChanged(state: state)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ public final class CoreBluetoothCentral: NSObject, CentralBackend, @unchecked Se
/// Peripherals the manager has surfaced, keyed by identifier bytes; populated by a scan hit.
private var peripherals: [Data: CBPeripheral] = [:]
// Pending command latches keyed by a discriminator, signaled by the delegate callback.
private var connectWaiters: [Data: Latch<Void>] = [:]
private var serviceWaiters: [Data: Latch<[String]>] = [:]
private var characteristicWaiters: [CharKey: Latch<[String]>] = [:]
private var readWaiters: [CharKey: Latch<Data>] = [:]
Expand Down Expand Up @@ -59,10 +58,9 @@ public final class CoreBluetoothCentral: NSObject, CentralBackend, @unchecked Se

public func connect(peripheralId: Data) throws {
let peripheral = try lookup(peripheralId)
let latch = Latch<Void>()
setConnectWaiter(latch, for: peripheralId)
// Real connect never blocks and has no timeout. This returns once the request is issued;
// didConnect or didFailToConnect emits the outcome as an event.
queue.async { self.manager.connect(peripheral, options: nil) }
_ = try wait(latch) { self.clearConnectWaiter(peripheralId) }
}

public func disconnect(peripheralId: Data) throws {
Expand Down Expand Up @@ -250,14 +248,6 @@ private extension CoreBluetoothCentral {
static let timeout: Int64 = 9 // CBError.connectionTimeout
static let opFailed: Int64 = 1 // CBError.unknown

func setConnectWaiter(_ latch: Latch<Void>, for id: Data) {
lock.lock(); connectWaiters[id] = latch; lock.unlock()
}

func clearConnectWaiter(_ id: Data) {
lock.lock(); connectWaiters[id] = nil; lock.unlock()
}

func setServiceWaiter(_ latch: Latch<[String]>, for id: Data) {
lock.lock(); serviceWaiters[id] = latch; lock.unlock()
}
Expand Down Expand Up @@ -328,14 +318,14 @@ extension CoreBluetoothCentral: CBCentralManagerDelegate {

public func centralManager(_: CBCentralManager, didConnect peripheral: CBPeripheral) {
peripheral.delegate = self
fulfillConnect(Self.identifier(of: peripheral), .success(()))
emit(.peripheralConnected(peripheralId: Self.identifier(of: peripheral)))
}

public func centralManager(_: CBCentralManager, didFailToConnect peripheral: CBPeripheral,
error: Error?)
{
fulfillConnect(Self.identifier(of: peripheral),
.failure(Self.backendError(error, fallback: "failed to connect")))
emit(.peripheralConnectFailed(peripheralId: Self.identifier(of: peripheral),
errorCode: error.map { Int64(($0 as NSError).code) }))
}

public func centralManager(_: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral,
Expand Down Expand Up @@ -434,11 +424,6 @@ private extension CoreBluetoothCentral {
return CentralBackendError(code: Int64(error.code), message: error.localizedDescription)
}

func fulfillConnect(_ id: Data, _ result: Result<Void, Error>) {
lock.lock(); let latch = connectWaiters[id]; lock.unlock()
latch?.fulfill(result)
}

func fulfillServices(_ id: Data, _ result: Result<[String], Error>) {
lock.lock(); let latch = serviceWaiters[id]; lock.unlock()
latch?.fulfill(result)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,19 @@ final class CentralServiceTests: XCTestCase {
])
}

func testConnectOutcomeEventsSurface() {
let backend = FakeCentralBackend()
let service = CentralService(backend: backend)
let collector = EventCollector()
service.onEvent { collector.append($0) }
backend.emit(.peripheralConnected(peripheralId: peripheralId))
backend.emit(.peripheralConnectFailed(peripheralId: peripheralId, errorCode: 7))
XCTAssertEqual(collector.all, [
.peripheralConnected(peripheralId: peripheralId),
.peripheralConnectFailed(peripheralId: peripheralId, errorCode: 7),
])
}

func testBackendFailureBecomesAProtocolFailureWithTheDeviceCode() {
let backend = FakeCentralBackend()
backend.failWith = CentralBackendError(code: 4, message: "characteristic not discovered")
Expand Down
50 changes: 34 additions & 16 deletions packages/interpose/src/hooks/central_hooks.m
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,33 @@ static void deliverEvent(CBCentralManager *manager, const simble_event *event) {
});
break;
}
case SIMBLE_EVT_CONNECTED: {
CBPeripheral *peripheral =
simble_shadow_peripheral(manager, event->peripheral, event->peripheral_len);
dispatchOnManagerQueue(entry, ^{
id<CBCentralManagerDelegate> delegate = entry.delegate;
if ([delegate respondsToSelector:@selector(centralManager:didConnectPeripheral:)]) {
[delegate centralManager:manager didConnectPeripheral:peripheral];
}
});
break;
}
case SIMBLE_EVT_CONNECT_FAILED: {
CBPeripheral *peripheral =
simble_shadow_peripheral(manager, event->peripheral, event->peripheral_len);
NSError *error = event->has_error_code ? [NSError errorWithDomain:CBErrorDomain
code:event->error_code
userInfo:nil]
: nil;
dispatchOnManagerQueue(entry, ^{
id<CBCentralManagerDelegate> delegate = entry.delegate;
if ([delegate
respondsToSelector:@selector(centralManager:didFailToConnectPeripheral:error:)]) {
[delegate centralManager:manager didFailToConnectPeripheral:peripheral error:error];
}
});
break;
}
case SIMBLE_EVT_CENTRAL_STATE_CHANGED: {
dispatchOnManagerQueue(entry, ^{
id<CBCentralManagerDelegate> delegate = entry.delegate;
Expand Down Expand Up @@ -298,23 +325,14 @@ - (void)simble_connectPeripheral:(CBPeripheral *)peripheral
size_t idLen = 0;
if (!simble_shadow_peripheral_id(peripheral, pid, sizeof(pid), &idLen))
return;
simble_response resp;
simble_status st = simble_client_connect(pid, idLen, &resp);
g_stats.connect++;
SimbleManagerEntry *entry = simble_shadow_manager_entry(self);
CBCentralManager *manager = self;
dispatchOnManagerQueue(entry, ^{
id<CBCentralManagerDelegate> delegate = entry.delegate;
if (st == SIMBLE_OK && resp.kind == SIMBLE_RESP_PERIPHERAL) {
if ([delegate respondsToSelector:@selector(centralManager:didConnectPeripheral:)]) {
[delegate centralManager:manager didConnectPeripheral:peripheral];
}
} else if ([delegate
respondsToSelector:@selector(
centralManager:didFailToConnectPeripheral:error:)]) {
NSError *error = [NSError errorWithDomain:CBErrorDomain code:resp.error_code userInfo:nil];
[delegate centralManager:manager didFailToConnectPeripheral:peripheral error:error];
}
// Real connect returns at once and never times out. The host acknowledges immediately and
// reports the outcome as a CONNECTED or CONNECT_FAILED event the reader turns into the
// delegate callback. The request goes off the caller thread, where a slow ack cannot block it.
NSData *pidData = [NSData dataWithBytes:pid length:idLen];
dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{
simble_response resp;
simble_client_connect(pidData.bytes, pidData.length, &resp);
});
}

Expand Down
2 changes: 2 additions & 0 deletions packages/protocol/c/include/simble_protocol.h
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,8 @@ typedef enum {
SIMBLE_EVT_SUBSCRIBED, ///< A central subscribed (peripheral role).
SIMBLE_EVT_UNSUBSCRIBED, ///< A central unsubscribed (peripheral role).
SIMBLE_EVT_READY_TO_UPDATE, ///< The transmit queue has room again (peripheral role).
SIMBLE_EVT_CONNECTED, ///< A connect succeeded (central role).
SIMBLE_EVT_CONNECT_FAILED, ///< A connect failed (central role).
} simble_evt_kind;

/**
Expand Down
16 changes: 16 additions & 0 deletions packages/protocol/c/src/simble_protocol.c
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ enum {
OP_EVT_SUBSCRIBED = 135,
OP_EVT_UNSUBSCRIBED = 136,
OP_EVT_READY_TO_UPDATE = 137,
OP_EVT_CONNECTED = 138,
OP_EVT_CONNECT_FAILED = 139,
ST_OK = 0,
ST_ERROR = 1
};
Expand Down Expand Up @@ -845,6 +847,20 @@ simble_status simble_decode_event(const uint8_t *payload, size_t len, simble_eve
out->error_code = read_int(find(entries, count, K_ERR_CODE), &out->has_error_code);
return SIMBLE_OK;
}
case OP_EVT_CONNECTED: {
out->kind = SIMBLE_EVT_CONNECTED;
return copy_bytes(find(entries, count, K_PERIPHERAL), out->peripheral, sizeof(out->peripheral),
&out->peripheral_len);
}
case OP_EVT_CONNECT_FAILED: {
out->kind = SIMBLE_EVT_CONNECT_FAILED;
st = copy_bytes(find(entries, count, K_PERIPHERAL), out->peripheral, sizeof(out->peripheral),
&out->peripheral_len);
if (st != SIMBLE_OK)
return st;
out->error_code = read_int(find(entries, count, K_ERR_CODE), &out->has_error_code);
return SIMBLE_OK;
}
case OP_EVT_CENTRAL_STATE_CHANGED:
case OP_EVT_PERIPHERAL_STATE_CHANGED: {
out->kind = op->uintval == OP_EVT_CENTRAL_STATE_CHANGED ? SIMBLE_EVT_CENTRAL_STATE_CHANGED
Expand Down
16 changes: 16 additions & 0 deletions packages/protocol/c/tests/protocol_test.c
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,22 @@ int main(void) {
evt.kind == SIMBLE_EVT_DISCONNECTED && !evt.has_error_code,
"decode disconnected event without error");

uint8_t evt_connected[] = {0xA2, 0x00, 0x18, 0x8A, 0x18, 0x1E, 0x44, 0x01, 0x02, 0x03, 0x04};
CHECK(simble_decode_event(evt_connected, sizeof(evt_connected), &evt) == SIMBLE_OK &&
evt.kind == SIMBLE_EVT_CONNECTED && evt.peripheral_len == 4,
"decode connected event");

uint8_t evt_conn_fail_err[] = {0xA3, 0x00, 0x18, 0x8B, 0x0A, 0x26, 0x18,
0x1E, 0x44, 0x01, 0x02, 0x03, 0x04};
CHECK(simble_decode_event(evt_conn_fail_err, sizeof(evt_conn_fail_err), &evt) == SIMBLE_OK &&
evt.kind == SIMBLE_EVT_CONNECT_FAILED && evt.has_error_code && evt.error_code == -7,
"decode connect-failed event with error");

uint8_t evt_conn_fail_noerr[] = {0xA2, 0x00, 0x18, 0x8B, 0x18, 0x1E, 0x44, 0x01, 0x02, 0x03, 0x04};
CHECK(simble_decode_event(evt_conn_fail_noerr, sizeof(evt_conn_fail_noerr), &evt) == SIMBLE_OK &&
evt.kind == SIMBLE_EVT_CONNECT_FAILED && !evt.has_error_code,
"decode connect-failed event without error");

uint8_t evt_central_changed[] = {0xA2, 0x00, 0x18, 0x83, 0x18, 0x29, 0x04};
CHECK(simble_decode_event(evt_central_changed, sizeof(evt_central_changed), &evt) == SIMBLE_OK &&
evt.kind == SIMBLE_EVT_CENTRAL_STATE_CHANGED && evt.manager_state == 4,
Expand Down
21 changes: 21 additions & 0 deletions packages/protocol/swift/Sources/SimBLEProtocol/Messages.swift
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,10 @@ public enum Event: Equatable {
value: Data)
/// A peripheral disconnected unexpectedly; `errorCode` is the `CBError` when one applied.
case peripheralDisconnected(peripheralId: Data, errorCode: Int64?)
/// A connect succeeded (central role); the connection is up.
case peripheralConnected(peripheralId: Data)
/// A connect failed (central role); `errorCode` is the `CBError` when one applied.
case peripheralConnectFailed(peripheralId: Data, errorCode: Int64?)
/// The host central manager's `CBManagerState` changed.
case centralStateChanged(state: UInt64)
/// The host peripheral manager's `CBManagerState` changed.
Expand Down Expand Up @@ -222,6 +226,8 @@ public enum Wire {
static let opSubscribed: UInt64 = 135
static let opUnsubscribed: UInt64 = 136
static let opReadyToUpdate: UInt64 = 137
static let opConnectedEvent: UInt64 = 138
static let opConnectFailedEvent: UInt64 = 139

static let statusOK: UInt64 = 0
static let statusError: UInt64 = 1
Expand Down Expand Up @@ -828,6 +834,16 @@ public enum Wire {
writer.uint(keyOp); writer.uint(opDisconnectedEvent)
if let errorCode { writer.uint(keyErrorCode); writer.int(errorCode) }
writer.uint(keyPeripheralId); writer.bytes(peripheralId)
case let .peripheralConnected(peripheralId):
writer.mapHeader(2)
writer.uint(keyOp); writer.uint(opConnectedEvent)
writer.uint(keyPeripheralId); writer.bytes(peripheralId)
case let .peripheralConnectFailed(peripheralId, errorCode):
// op, errorCode (10) when an error applied, peripheralId (30), keys ascending.
writer.mapHeader(errorCode != nil ? 3 : 2)
writer.uint(keyOp); writer.uint(opConnectFailedEvent)
if let errorCode { writer.uint(keyErrorCode); writer.int(errorCode) }
writer.uint(keyPeripheralId); writer.bytes(peripheralId)
case let .centralStateChanged(state):
writer.mapHeader(2)
writer.uint(keyOp); writer.uint(opCentralStateChanged)
Expand Down Expand Up @@ -897,6 +913,11 @@ public enum Wire {
case opDisconnectedEvent:
return try .peripheralDisconnected(peripheralId: map.bytes(keyPeripheralId),
errorCode: map.optionalInt(keyErrorCode))
case opConnectedEvent:
return try .peripheralConnected(peripheralId: map.bytes(keyPeripheralId))
case opConnectFailedEvent:
return try .peripheralConnectFailed(peripheralId: map.bytes(keyPeripheralId),
errorCode: map.optionalInt(keyErrorCode))
case opCentralStateChanged:
return try .centralStateChanged(state: map.uint(keyManagerState))
case opPeripheralStateChanged:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,9 @@ final class WireTests: XCTestCase {
characteristicUUID: "2A37", value: val),
.peripheralDisconnected(peripheralId: pid, errorCode: -10),
.peripheralDisconnected(peripheralId: pid, errorCode: nil),
.peripheralConnected(peripheralId: pid),
.peripheralConnectFailed(peripheralId: pid, errorCode: -7),
.peripheralConnectFailed(peripheralId: pid, errorCode: nil),
.centralStateChanged(state: 4),
.peripheralStateChanged(state: 5),
.readRequest(requestId: 3, serviceUUID: "180D", characteristicUUID: "2A37",
Expand Down
Loading