diff --git a/packages/host-core/Sources/SimBLEHostCore/CentralBackend.swift b/packages/host-core/Sources/SimBLEHostCore/CentralBackend.swift index 19f67c3..f32d172 100644 --- a/packages/host-core/Sources/SimBLEHostCore/CentralBackend.swift +++ b/packages/host-core/Sources/SimBLEHostCore/CentralBackend.swift @@ -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) } @@ -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. @@ -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. diff --git a/packages/host-core/Sources/SimBLEHostCore/CentralService.swift b/packages/host-core/Sources/SimBLEHostCore/CentralService.swift index 22dc4cd..bdc4799 100644 --- a/packages/host-core/Sources/SimBLEHostCore/CentralService.swift +++ b/packages/host-core/Sources/SimBLEHostCore/CentralService.swift @@ -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) } diff --git a/packages/host-core/Sources/SimBLEHostCore/CoreBluetoothCentral.swift b/packages/host-core/Sources/SimBLEHostCore/CoreBluetoothCentral.swift index 67fabee..e0f68aa 100644 --- a/packages/host-core/Sources/SimBLEHostCore/CoreBluetoothCentral.swift +++ b/packages/host-core/Sources/SimBLEHostCore/CoreBluetoothCentral.swift @@ -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] = [:] private var serviceWaiters: [Data: Latch<[String]>] = [:] private var characteristicWaiters: [CharKey: Latch<[String]>] = [:] private var readWaiters: [CharKey: Latch] = [:] @@ -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() - 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 { @@ -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, 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() } @@ -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, @@ -434,11 +424,6 @@ private extension CoreBluetoothCentral { return CentralBackendError(code: Int64(error.code), message: error.localizedDescription) } - func fulfillConnect(_ id: Data, _ result: Result) { - 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) diff --git a/packages/host-core/Tests/SimBLEHostCoreTests/CentralServiceTests.swift b/packages/host-core/Tests/SimBLEHostCoreTests/CentralServiceTests.swift index 9167180..8ff749d 100644 --- a/packages/host-core/Tests/SimBLEHostCoreTests/CentralServiceTests.swift +++ b/packages/host-core/Tests/SimBLEHostCoreTests/CentralServiceTests.swift @@ -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") diff --git a/packages/interpose/src/hooks/central_hooks.m b/packages/interpose/src/hooks/central_hooks.m index a6a13e3..c94fe47 100644 --- a/packages/interpose/src/hooks/central_hooks.m +++ b/packages/interpose/src/hooks/central_hooks.m @@ -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 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 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 delegate = entry.delegate; @@ -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 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); }); } diff --git a/packages/protocol/c/include/simble_protocol.h b/packages/protocol/c/include/simble_protocol.h index eb40dee..132a5ca 100644 --- a/packages/protocol/c/include/simble_protocol.h +++ b/packages/protocol/c/include/simble_protocol.h @@ -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; /** diff --git a/packages/protocol/c/src/simble_protocol.c b/packages/protocol/c/src/simble_protocol.c index 52be30a..714cae3 100644 --- a/packages/protocol/c/src/simble_protocol.c +++ b/packages/protocol/c/src/simble_protocol.c @@ -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 }; @@ -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 diff --git a/packages/protocol/c/tests/protocol_test.c b/packages/protocol/c/tests/protocol_test.c index 8e0072c..ccaebaa 100644 --- a/packages/protocol/c/tests/protocol_test.c +++ b/packages/protocol/c/tests/protocol_test.c @@ -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, diff --git a/packages/protocol/swift/Sources/SimBLEProtocol/Messages.swift b/packages/protocol/swift/Sources/SimBLEProtocol/Messages.swift index fc01f48..0c30bd9 100644 --- a/packages/protocol/swift/Sources/SimBLEProtocol/Messages.swift +++ b/packages/protocol/swift/Sources/SimBLEProtocol/Messages.swift @@ -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. @@ -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 @@ -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) @@ -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: diff --git a/packages/protocol/swift/Tests/SimBLEProtocolTests/WireTests.swift b/packages/protocol/swift/Tests/SimBLEProtocolTests/WireTests.swift index 9e4eb93..778e6e8 100644 --- a/packages/protocol/swift/Tests/SimBLEProtocolTests/WireTests.swift +++ b/packages/protocol/swift/Tests/SimBLEProtocolTests/WireTests.swift @@ -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",