diff --git a/README.md b/README.md index 2d4bf0e..8407769 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ The package targets iOS 16 and macOS 13, uses CoreBluetooth directly, and expose - Receive realtime board positions as FEN placement strings through `AsyncStream`. - Query battery status. - Control LEDs for classic Chessnut boards and Chessnut Move. +- Import OTB games recorded by the board as FEN placement snapshots. - Use Chessnut Move auto-move, stop auto-move, and piece-status commands. - Inject custom transports for tests, simulators, replay tools, or alternative BLE stacks. - Generate DocC documentation for public API and usage guides. @@ -196,6 +197,50 @@ for piece in pieces { } ``` +### Import OTB Games + +Use `importOTBGames(timeout:)` to download games stored by the board during over-the-board play. Stored-game transfer can take longer than simple commands, so the default timeout is 120 seconds: + +```swift +let games = try await client.importOTBGames() + +for game in games { + for position in game.positions { + print(position) + } +} +``` + +Each `OTBGame` contains FEN placement strings. Import mode pauses live FEN updates while stored games are being transferred, so enable realtime updates again when the import finishes: + +```swift +try await client.enableRealtimeUpdates() +``` + +### OTB Upload Flow + +The expected stored-game upload flow is: + +```mermaid +graph TD + A([Start]) --> B[Switch to upload mode] + B --> C[Query the files count] + C --> D[/read the file count/] + D --> E{files count > 0} + + E --> F([END]) + E -- YES --> G[Send ready for import command] + + G --> H[Send start import command] + H --> I[/read input data/] + I --> J{input data = end flag} + + J -- NO --> I + J -- YES --> K(send file import done command) + + K --> C +``` + ## Custom Transports `EasyLinkClient` depends on `EasyLinkTransport`, so tests and simulators can replace CoreBluetooth: @@ -227,6 +272,11 @@ FEN notifications are decoded using the same nibble mapping as the C++ SDK's `Ch Common realtime command: - Enable realtime FEN: `[0x21, 0x01, 0x00]` +- Enable OTB upload mode: `[0x21, 0x01, 0x01]` +- Query OTB file count: `[0x31, 0x01, 0x00]` +- Ready for OTB import: `[0x33, 0x01, 0x00]` +- Start OTB import: `[0x34, 0x01, 0x01]` +- Mark OTB file import done: `[0x39, 0x01, 0x00]` Classic profile: @@ -258,10 +308,12 @@ Chessnut Move profile: - [x] Realtime FEN updates with `AsyncStream`. - [x] Classic and Chessnut Move LED commands. - [x] Battery status query for supported profiles. +- [x] OTB game import with stored FEN placement snapshots. - [x] Chessnut Move auto-move and stop auto-move commands. - [x] Chessnut Move piece-status parsing. - [x] Bounded response buffering in the internal response router. -- [x] Unit tests for codec, client flows, response routing, and strict concurrency builds. +- [x] Unit tests for codec, client flows, OTB import, response routing, and strict concurrency builds. +- [x] CoreBluetooth integration tests isolated behind an explicit environment flag. - [x] GitHub Actions for tests, coverage, release, and DocC publishing. - [x] DocC catalog with API documentation and usage guides. @@ -276,6 +328,9 @@ Chessnut Move profile: - [ ] Add coordinate helpers for chess notation such as `e4`. - [ ] Build full FEN helpers for side to move, castling rights, en passant, and counters. - [ ] Document the coordinate system used by FEN, LEDs, and piece status in more detail. +- [ ] Document OTB protocol behavior against more board firmware versions. +- [ ] Add PGN conversion helpers for imported OTB games. +- [ ] Add OTB import progress reporting for long stored-game transfers. - [ ] Add tests for timeout, disconnection, out-of-order responses, and simultaneous requests. - [ ] Add optional real-hardware integration tests behind a flag or separate scheme. - [ ] Add packet logging or tracing for BLE diagnostics. diff --git a/Sources/EasyLinkSwiftSDK/CoreBluetoothEasyLinkTransport.swift b/Sources/EasyLinkSwiftSDK/CoreBluetoothEasyLinkTransport.swift index 0848f7b..73b8e9d 100644 --- a/Sources/EasyLinkSwiftSDK/CoreBluetoothEasyLinkTransport.swift +++ b/Sources/EasyLinkSwiftSDK/CoreBluetoothEasyLinkTransport.swift @@ -1,8 +1,34 @@ import Foundation @preconcurrency import CoreBluetooth +#if DEBUG +import OSLog + +private let easyLinkTransportLogger = Logger(subsystem: "EasyLinkSwiftSDK", category: "CoreBluetoothTransport") + +private func transportDebugHex(_ bytes: [UInt8]) -> String { + bytes.map { String(format: "%02X", $0) }.joined(separator: " ") +} + +private func transportDebugProperties(_ properties: CBCharacteristicProperties) -> String { + var names: [String] = [] + if properties.contains(.broadcast) { names.append("broadcast") } + if properties.contains(.read) { names.append("read") } + if properties.contains(.writeWithoutResponse) { names.append("writeWithoutResponse") } + if properties.contains(.write) { names.append("write") } + if properties.contains(.notify) { names.append("notify") } + if properties.contains(.indicate) { names.append("indicate") } + if properties.contains(.authenticatedSignedWrites) { names.append("authenticatedSignedWrites") } + if properties.contains(.extendedProperties) { names.append("extendedProperties") } + if properties.contains(.notifyEncryptionRequired) { names.append("notifyEncryptionRequired") } + if properties.contains(.indicateEncryptionRequired) { names.append("indicateEncryptionRequired") } + return names.isEmpty ? "none" : names.joined(separator: "|") +} +#endif // CoreBluetooth delegate callbacks are delivered on `queue`; all mutable BLE state below is accessed by scheduling onto that queue. -public final class CoreBluetoothEasyLinkTransport: NSObject, EasyLinkTransport, @unchecked Sendable { +public final class CoreBluetoothEasyLinkTransport: NSObject, EasyLinkTransport, EasyLinkResponsePollingTransport, EasyLinkNotificationRearmingTransport, @unchecked Sendable { + private static let minimumWriteInterval: TimeInterval = 0.2 + public let notifications: AsyncStream private let profile: BoardProfile @@ -13,8 +39,14 @@ public final class CoreBluetoothEasyLinkTransport: NSObject, EasyLinkTransport, private var centralManager: CBCentralManager? private var peripheral: CBPeripheral? private var commandCharacteristic: CBCharacteristic? + private var fenNotificationCharacteristic: CBCharacteristic? + private var responseNotificationCharacteristic: CBCharacteristic? + private var fileNotificationCharacteristic: CBCharacteristic? private var connectContinuations: [UUID: CheckedContinuation] = [:] private var pendingWriteContinuations: [CheckedContinuation] = [] + private var nextWriteDate = Date.distantPast + private var responsePollCount = 0 + private var lastErroredResponseValue: [UInt8]? public init(profile: BoardProfile, deviceID: UUID? = nil) { self.profile = profile @@ -35,10 +67,16 @@ public final class CoreBluetoothEasyLinkTransport: NSObject, EasyLinkTransport, public func connect() async throws { let id = UUID() + #if DEBUG + easyLinkTransportLogger.debug("connect requested id=\(id.uuidString, privacy: .public)") + #endif try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { continuation in queue.async { - if self.peripheral?.state == .connected, self.commandCharacteristic != nil { + if self.isConnectionReady { + #if DEBUG + easyLinkTransportLogger.debug("connect completed immediately; connection already ready") + #endif continuation.resume() return } @@ -46,8 +84,14 @@ public final class CoreBluetoothEasyLinkTransport: NSObject, EasyLinkTransport, self.connectContinuations[id] = continuation if self.centralManager == nil { + #if DEBUG + easyLinkTransportLogger.debug("creating CBCentralManager") + #endif self.centralManager = CBCentralManager(delegate: self, queue: self.queue) } else { + #if DEBUG + easyLinkTransportLogger.debug("central manager exists; start scan if ready") + #endif self.startScanIfReady() } } @@ -60,12 +104,23 @@ public final class CoreBluetoothEasyLinkTransport: NSObject, EasyLinkTransport, } public func disconnect() async { + #if DEBUG + easyLinkTransportLogger.debug("disconnect requested") + #endif await withCheckedContinuation { continuation in queue.async { if let peripheral = self.peripheral { + #if DEBUG + easyLinkTransportLogger.debug("cancel peripheral connection id=\(peripheral.identifier.uuidString, privacy: .public)") + #endif self.centralManager?.cancelPeripheralConnection(peripheral) } self.commandCharacteristic = nil + self.fenNotificationCharacteristic = nil + self.responseNotificationCharacteristic = nil + self.responsePollCount = 0 + self.lastErroredResponseValue = nil + self.nextWriteDate = .distantPast self.peripheral = nil self.finishConnect(.failure(EasyLinkError.disconnected)) self.finishPendingWrites(.failure(EasyLinkError.disconnected)) @@ -78,28 +133,176 @@ public final class CoreBluetoothEasyLinkTransport: NSObject, EasyLinkTransport, public func write(_ command: [UInt8]) async throws { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in queue.async { + self.scheduleWrite(command, continuation: continuation) + } + } + } + + func pollResponseCharacteristic() async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + queue.async { + defer { continuation.resume() } guard let peripheral = self.peripheral, peripheral.state == .connected, - let commandCharacteristic = self.commandCharacteristic + let characteristic = self.responseNotificationCharacteristic else { - continuation.resume(throwing: EasyLinkError.disconnected) + #if DEBUG + easyLinkTransportLogger.debug("poll response skipped; response characteristic not ready") + #endif return } - self.pendingWriteContinuations.append(continuation) - peripheral.writeValue( - Data(command), - for: commandCharacteristic, - type: .withResponse - ) + self.responsePollCount += 1 + #if DEBUG + if self.responsePollCount == 1 || self.responsePollCount.isMultiple(of: 20) { + easyLinkTransportLogger.debug("poll response readValue count=\(self.responsePollCount, privacy: .public) properties=\(transportDebugProperties(characteristic.properties), privacy: .public)") + } + #endif + peripheral.readValue(for: characteristic) } } } + func rearmNotificationCharacteristics() async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + queue.async { + let characteristics = [ + self.fenNotificationCharacteristic, + self.responseNotificationCharacteristic, + ].compactMap { $0 } + + guard let peripheral = self.peripheral, + peripheral.state == .connected, + !characteristics.isEmpty + else { + #if DEBUG + easyLinkTransportLogger.debug("rearm notifications skipped; characteristics not ready") + #endif + continuation.resume() + return + } + + #if DEBUG + easyLinkTransportLogger.debug("rearm notifications disabling count=\(characteristics.count, privacy: .public)") + #endif + for characteristic in characteristics where characteristic.isNotifying { + peripheral.setNotifyValue(false, for: characteristic) + } + + self.queue.asyncAfter(deadline: .now() + 0.15) { + #if DEBUG + easyLinkTransportLogger.debug("rearm notifications enabling count=\(characteristics.count, privacy: .public)") + #endif + for characteristic in characteristics { + peripheral.setNotifyValue(true, for: characteristic) + } + + self.queue.asyncAfter(deadline: .now() + 0.25) { + continuation.resume() + } + } + } + } + } + + func rearmFENNotificationCharacteristic() async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + queue.async { + guard let peripheral = self.peripheral, + peripheral.state == .connected, + let characteristic = self.fenNotificationCharacteristic + else { + #if DEBUG + easyLinkTransportLogger.debug("rearm FEN notification skipped; characteristic not ready") + #endif + continuation.resume() + return + } + + #if DEBUG + easyLinkTransportLogger.debug("rearm FEN notification disabling") + #endif + if characteristic.isNotifying { + peripheral.setNotifyValue(false, for: characteristic) + } + + self.queue.asyncAfter(deadline: .now() + 0.15) { + #if DEBUG + easyLinkTransportLogger.debug("rearm FEN notification enabling") + #endif + peripheral.setNotifyValue(true, for: characteristic) + + self.queue.asyncAfter(deadline: .now() + 0.25) { + continuation.resume() + } + } + } + } + } + + private func scheduleWrite(_ command: [UInt8], continuation: CheckedContinuation) { + let now = Date() + let writeDate = max(now, nextWriteDate) + nextWriteDate = writeDate.addingTimeInterval(Self.minimumWriteInterval) + let delay = writeDate.timeIntervalSince(now) + + #if DEBUG + easyLinkTransportLogger.debug("schedule write delay=\(delay, privacy: .public) len=\(command.count, privacy: .public) bytes=\(transportDebugHex(command), privacy: .public)") + #endif + + queue.asyncAfter(deadline: .now() + delay) { + self.performWrite(command, continuation: continuation) + } + } + + private func performWrite(_ command: [UInt8], continuation: CheckedContinuation) { + guard let peripheral, + peripheral.state == .connected, + let commandCharacteristic + else { + #if DEBUG + easyLinkTransportLogger.error("write failed disconnected len=\(command.count, privacy: .public) bytes=\(transportDebugHex(command), privacy: .public)") + #endif + continuation.resume(throwing: EasyLinkError.disconnected) + return + } + + let writeType: CBCharacteristicWriteType = commandCharacteristic.properties.contains(.writeWithoutResponse) + ? .withoutResponse + : .withResponse + + #if DEBUG + let writeTypeDescription = writeType == .withoutResponse ? "withoutResponse" : "withResponse" + easyLinkTransportLogger.debug("perform write characteristic=\(commandCharacteristic.uuid.uuidString, privacy: .public) type=\(writeTypeDescription, privacy: .public) len=\(command.count, privacy: .public) bytes=\(transportDebugHex(command), privacy: .public)") + #endif + + if writeType == .withResponse { + pendingWriteContinuations.append(continuation) + peripheral.writeValue( + Data(command), + for: commandCharacteristic, + type: writeType + ) + } else { + peripheral.writeValue( + Data(command), + for: commandCharacteristic, + type: writeType + ) + continuation.resume() + } + } + private func startScanIfReady() { guard centralManager?.state == .poweredOn else { + #if DEBUG + easyLinkTransportLogger.debug("scan deferred centralState=\(String(describing: self.centralManager?.state.rawValue), privacy: .public)") + #endif return } + #if DEBUG + easyLinkTransportLogger.debug("start scan profile=\(String(describing: self.profile), privacy: .public)") + #endif centralManager?.scanForPeripherals( withServices: nil, options: [CBCentralManagerScanOptionAllowDuplicatesKey: false] @@ -107,6 +310,9 @@ public final class CoreBluetoothEasyLinkTransport: NSObject, EasyLinkTransport, } private func connect(_ peripheral: CBPeripheral, using central: CBCentralManager) { + #if DEBUG + easyLinkTransportLogger.debug("connecting peripheral id=\(peripheral.identifier.uuidString, privacy: .public) name=\((peripheral.name ?? ""), privacy: .public)") + #endif central.stopScan() self.peripheral = peripheral peripheral.delegate = self @@ -115,6 +321,9 @@ public final class CoreBluetoothEasyLinkTransport: NSObject, EasyLinkTransport, private func finishConnect(_ result: Result) { guard !connectContinuations.isEmpty else { + #if DEBUG + easyLinkTransportLogger.debug("finishConnect ignored; no pending continuations result=\(String(describing: result), privacy: .public)") + #endif return } @@ -123,8 +332,14 @@ public final class CoreBluetoothEasyLinkTransport: NSObject, EasyLinkTransport, switch result { case .success: + #if DEBUG + easyLinkTransportLogger.debug("finishConnect success continuations=\(continuations.count, privacy: .public)") + #endif continuations.forEach { $0.resume() } case let .failure(error): + #if DEBUG + easyLinkTransportLogger.error("finishConnect failure continuations=\(continuations.count, privacy: .public) error=\(String(describing: error), privacy: .public)") + #endif continuations.forEach { $0.resume(throwing: error) } } } @@ -153,30 +368,27 @@ public final class CoreBluetoothEasyLinkTransport: NSObject, EasyLinkTransport, } private func validateConnectionReadiness() { - guard let peripheral else { - return - } - - let requiredUUIDs = Set([ - CBUUID(nsuuid: ProtocolConstants.commandCharacteristic), - CBUUID(nsuuid: ProtocolConstants.fenNotificationCharacteristic), - CBUUID(nsuuid: ProtocolConstants.responseCharacteristic) - ]) - - let discoveredUUIDs = Set( - peripheral.services? - .flatMap { $0.characteristics ?? [] } - .map(\.uuid) ?? [] - ) - - if commandCharacteristic != nil && requiredUUIDs.isSubset(of: discoveredUUIDs) { + #if DEBUG + easyLinkTransportLogger.debug("validate readiness command=\(self.commandCharacteristic != nil, privacy: .public) fenNotify=\((self.fenNotificationCharacteristic?.isNotifying == true), privacy: .public) responseNotify=\((self.responseNotificationCharacteristic?.isNotifying == true), privacy: .public) peripheralState=\(String(describing: self.peripheral?.state.rawValue), privacy: .public)") + #endif + if isConnectionReady { finishConnect(.success(())) } } + + private var isConnectionReady: Bool { + commandCharacteristic != nil && + fenNotificationCharacteristic?.isNotifying == true && + responseNotificationCharacteristic?.isNotifying == true && + peripheral?.state == .connected + } } extension CoreBluetoothEasyLinkTransport: CBCentralManagerDelegate { public func centralManagerDidUpdateState(_ central: CBCentralManager) { + #if DEBUG + easyLinkTransportLogger.debug("central state updated raw=\(central.state.rawValue, privacy: .public)") + #endif switch central.state { case .poweredOn: startScanIfReady() @@ -198,6 +410,9 @@ extension CoreBluetoothEasyLinkTransport: CBCentralManagerDelegate { advertisementData: [String: Any], rssi RSSI: NSNumber ) { + #if DEBUG + easyLinkTransportLogger.debug("didDiscover peripheral id=\(peripheral.identifier.uuidString, privacy: .public) name=\((peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? ""), privacy: .public) rssi=\(RSSI.intValue, privacy: .public)") + #endif guard let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String, profile.matchesPeripheralName(name), deviceID == nil || peripheral.identifier == deviceID @@ -209,9 +424,13 @@ extension CoreBluetoothEasyLinkTransport: CBCentralManagerDelegate { } public func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + #if DEBUG + easyLinkTransportLogger.debug("didConnect peripheral id=\(peripheral.identifier.uuidString, privacy: .public)") + #endif peripheral.discoverServices([ CBUUID(nsuuid: ProtocolConstants.fenService), - CBUUID(nsuuid: ProtocolConstants.operationService) + CBUUID(nsuuid: ProtocolConstants.operationService), + CBUUID(nsuuid: ProtocolConstants.fileService) ]) } @@ -220,6 +439,9 @@ extension CoreBluetoothEasyLinkTransport: CBCentralManagerDelegate { didFailToConnect peripheral: CBPeripheral, error: Error? ) { + #if DEBUG + easyLinkTransportLogger.error("didFailToConnect peripheral id=\(peripheral.identifier.uuidString, privacy: .public) error=\(String(describing: error), privacy: .public)") + #endif self.peripheral = nil finishConnect(.failure(error ?? EasyLinkError.connectionFailed("CoreBluetooth failed to connect."))) } @@ -229,7 +451,16 @@ extension CoreBluetoothEasyLinkTransport: CBCentralManagerDelegate { didDisconnectPeripheral peripheral: CBPeripheral, error: Error? ) { + #if DEBUG + easyLinkTransportLogger.debug("didDisconnect peripheral id=\(peripheral.identifier.uuidString, privacy: .public) error=\(String(describing: error), privacy: .public)") + #endif commandCharacteristic = nil + fenNotificationCharacteristic = nil + responseNotificationCharacteristic = nil + fileNotificationCharacteristic = nil + responsePollCount = 0 + lastErroredResponseValue = nil + nextWriteDate = .distantPast self.peripheral = nil finishConnect(.failure(error ?? EasyLinkError.disconnected)) finishPendingWrites(.failure(error ?? EasyLinkError.disconnected)) @@ -240,10 +471,17 @@ extension CoreBluetoothEasyLinkTransport: CBCentralManagerDelegate { extension CoreBluetoothEasyLinkTransport: CBPeripheralDelegate { public func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { if let error { + #if DEBUG + easyLinkTransportLogger.error("didDiscoverServices error=\(String(describing: error), privacy: .public)") + #endif finishConnect(.failure(error)) return } + #if DEBUG + let serviceIDs = peripheral.services?.map { $0.uuid.uuidString }.joined(separator: ",") ?? "" + easyLinkTransportLogger.debug("didDiscoverServices services=\(serviceIDs, privacy: .public)") + #endif peripheral.services?.forEach { service in switch service.uuid { case CBUUID(nsuuid: ProtocolConstants.fenService): @@ -261,6 +499,12 @@ extension CoreBluetoothEasyLinkTransport: CBPeripheralDelegate { for: service ) + case CBUUID(nsuuid: ProtocolConstants.fileService): + peripheral.discoverCharacteristics( + [CBUUID(nsuuid: ProtocolConstants.fileNotificationCharacteristic)], + for: service + ) + default: break } @@ -273,17 +517,48 @@ extension CoreBluetoothEasyLinkTransport: CBPeripheralDelegate { error: Error? ) { if let error { + #if DEBUG + easyLinkTransportLogger.error("didDiscoverCharacteristics service=\(service.uuid.uuidString, privacy: .public) error=\(String(describing: error), privacy: .public)") + #endif finishConnect(.failure(error)) return } + #if DEBUG + let characteristicIDs = service.characteristics?.map { + "\($0.uuid.uuidString)[\(transportDebugProperties($0.properties))]" + }.joined(separator: ",") ?? "" + easyLinkTransportLogger.debug("didDiscoverCharacteristics service=\(service.uuid.uuidString, privacy: .public) characteristics=\(characteristicIDs, privacy: .public)") + #endif service.characteristics?.forEach { characteristic in switch characteristic.uuid { case CBUUID(nsuuid: ProtocolConstants.commandCharacteristic): + #if DEBUG + easyLinkTransportLogger.debug("command characteristic discovered") + #endif commandCharacteristic = characteristic - case CBUUID(nsuuid: ProtocolConstants.fenNotificationCharacteristic), - CBUUID(nsuuid: ProtocolConstants.responseCharacteristic): + case CBUUID(nsuuid: ProtocolConstants.fenNotificationCharacteristic): + #if DEBUG + easyLinkTransportLogger.debug("fen notification characteristic discovered; enabling notify") + #endif + fenNotificationCharacteristic = characteristic + peripheral.setNotifyValue(true, for: characteristic) + + case CBUUID(nsuuid: ProtocolConstants.responseCharacteristic): + #if DEBUG + easyLinkTransportLogger.debug("response notification characteristic discovered; enabling notify") + #endif + responseNotificationCharacteristic = characteristic + responsePollCount = 0 + lastErroredResponseValue = nil + peripheral.setNotifyValue(true, for: characteristic) + + case CBUUID(nsuuid: ProtocolConstants.fileNotificationCharacteristic): + #if DEBUG + easyLinkTransportLogger.debug("file notification characteristic discovered; enabling notify") + #endif + fileNotificationCharacteristic = characteristic peripheral.setNotifyValue(true, for: characteristic) default: @@ -294,24 +569,97 @@ extension CoreBluetoothEasyLinkTransport: CBPeripheralDelegate { validateConnectionReadiness() } + public func peripheral( + _ peripheral: CBPeripheral, + didUpdateNotificationStateFor characteristic: CBCharacteristic, + error: Error? + ) { + if let error { + #if DEBUG + easyLinkTransportLogger.error("didUpdateNotificationState characteristic=\(characteristic.uuid.uuidString, privacy: .public) error=\(String(describing: error), privacy: .public)") + #endif + finishConnect(.failure(error)) + return + } + + #if DEBUG + easyLinkTransportLogger.debug("didUpdateNotificationState characteristic=\(characteristic.uuid.uuidString, privacy: .public) isNotifying=\(characteristic.isNotifying, privacy: .public)") + #endif + switch characteristic.uuid { + case CBUUID(nsuuid: ProtocolConstants.fenNotificationCharacteristic): + fenNotificationCharacteristic = characteristic + + case CBUUID(nsuuid: ProtocolConstants.responseCharacteristic): + responseNotificationCharacteristic = characteristic + + case CBUUID(nsuuid: ProtocolConstants.fileNotificationCharacteristic): + fileNotificationCharacteristic = characteristic + + default: + break + } + + validateConnectionReadiness() + } + public func peripheral( _ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error? ) { - guard error == nil, let value = characteristic.value else { + if let error { + guard let value = characteristic.value, + characteristic.uuid == CBUUID(nsuuid: ProtocolConstants.responseCharacteristic) + else { + #if DEBUG + easyLinkTransportLogger.error("didUpdateValue ignored characteristic=\(characteristic.uuid.uuidString, privacy: .public) error=\(String(describing: error), privacy: .public) hasValue=\((characteristic.value != nil), privacy: .public)") + #endif + return + } + + let bytes = Array(value) + guard bytes != lastErroredResponseValue else { + return + } + lastErroredResponseValue = bytes + #if DEBUG + easyLinkTransportLogger.debug("didUpdateValue response with error len=\(bytes.count, privacy: .public) bytes=\(transportDebugHex(bytes), privacy: .public) error=\(String(describing: error), privacy: .public)") + #endif + notificationContinuation.yield(.response(bytes)) + return + } + + guard let value = characteristic.value else { return } let bytes = Array(value) + if characteristic.uuid == CBUUID(nsuuid: ProtocolConstants.responseCharacteristic) { + lastErroredResponseValue = nil + } switch characteristic.uuid { case CBUUID(nsuuid: ProtocolConstants.fenNotificationCharacteristic): + #if DEBUG + easyLinkTransportLogger.debug("didUpdateValue FEN len=\(bytes.count, privacy: .public) bytes=\(transportDebugHex(bytes), privacy: .public)") + #endif notificationContinuation.yield(.fen(bytes)) case CBUUID(nsuuid: ProtocolConstants.responseCharacteristic): + #if DEBUG + easyLinkTransportLogger.debug("didUpdateValue response len=\(bytes.count, privacy: .public) bytes=\(transportDebugHex(bytes), privacy: .public)") + #endif + notificationContinuation.yield(.response(bytes)) + + case CBUUID(nsuuid: ProtocolConstants.fileNotificationCharacteristic): + #if DEBUG + easyLinkTransportLogger.debug("didUpdateValue file len=\(bytes.count, privacy: .public) bytes=\(transportDebugHex(bytes), privacy: .public)") + #endif notificationContinuation.yield(.response(bytes)) default: + #if DEBUG + easyLinkTransportLogger.debug("didUpdateValue unknown characteristic=\(characteristic.uuid.uuidString, privacy: .public) len=\(bytes.count, privacy: .public) bytes=\(transportDebugHex(bytes), privacy: .public)") + #endif break } } @@ -322,13 +670,22 @@ extension CoreBluetoothEasyLinkTransport: CBPeripheralDelegate { error: Error? ) { guard !pendingWriteContinuations.isEmpty else { + #if DEBUG + easyLinkTransportLogger.error("didWriteValue with no pending continuation characteristic=\(characteristic.uuid.uuidString, privacy: .public) error=\(String(describing: error), privacy: .public)") + #endif return } let continuation = pendingWriteContinuations.removeFirst() if let error { + #if DEBUG + easyLinkTransportLogger.error("didWriteValue failed characteristic=\(characteristic.uuid.uuidString, privacy: .public) error=\(String(describing: error), privacy: .public)") + #endif continuation.resume(throwing: error) } else { + #if DEBUG + easyLinkTransportLogger.debug("didWriteValue success characteristic=\(characteristic.uuid.uuidString, privacy: .public) pendingRemaining=\(self.pendingWriteContinuations.count, privacy: .public)") + #endif continuation.resume() } } diff --git a/Sources/EasyLinkSwiftSDK/EasyLinkClient.swift b/Sources/EasyLinkSwiftSDK/EasyLinkClient.swift index 0a73f98..bb55e50 100644 --- a/Sources/EasyLinkSwiftSDK/EasyLinkClient.swift +++ b/Sources/EasyLinkSwiftSDK/EasyLinkClient.swift @@ -1,5 +1,28 @@ import Foundation +private actor CommandGate { + private var isLocked = false + private var waiters: [CheckedContinuation] = [] + + func acquire() async { + guard isLocked else { + isLocked = true + return + } + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + func release() { + guard !waiters.isEmpty else { + isLocked = false + return + } + waiters.removeFirst().resume() + } +} + /// High-level async client for a Chessnut board. public actor EasyLinkClient { /// The board profile used to encode commands and parse responses. @@ -10,6 +33,8 @@ public actor EasyLinkClient { private let transport: EasyLinkTransport private let responseRouter = ResponseRouter() + private let commandGate = CommandGate() + private var uploadChannel: OTBChannel? private nonisolated let fenContinuation: AsyncStream.Continuation private var notificationTask: Task? @@ -65,69 +90,231 @@ public actor EasyLinkClient { /// Enables realtime FEN notifications on the board. public func enableRealtimeUpdates() async throws { - try await transport.write(ProtocolConstants.enableRealtimeMode) + try await withCommandGate { + try await transport.write(ProtocolConstants.enableRealtimeMode) + } } /// Sets LEDs using the command format for the active profile. public func setLEDs(_ board: LEDBoard) async throws { - let command: [UInt8] - switch profile { - case .classic: - command = EasyLinkCodec.classicLEDCommand(board) - case .move: - command = EasyLinkCodec.moveLEDCommand(board) + try await withCommandGate { + let command: [UInt8] + switch profile { + case .classic: + command = EasyLinkCodec.classicLEDCommand(board) + case .move: + command = EasyLinkCodec.moveLEDCommand(board) + } + try await transport.write(command) } - try await transport.write(command) } /// Requests the board battery status. public func batteryStatus(timeout: Duration = .seconds(3)) async throws -> BatteryStatus { - try await transport.write(profile.batteryCommand) - let profile = self.profile - let response = try await responseRouter.wait( - matching: { response in - switch profile { - case .classic: - response.count >= 4 && response[0] == 0x2A && response[1] == 0x02 - case .move: - response.count >= 5 && response[0] == 0x41 && response[1] == 0x03 && response[2] == 0x0C - } - }, - timeout: timeout - ) - return try EasyLinkCodec.parseBatteryStatus(profile: profile, response: response) + try await withCommandGate { + try await transport.write(profile.batteryCommand) + let profile = self.profile + let response = try await responseRouter.wait( + matching: { response in + switch profile { + case .classic: + response.count >= 4 && response[0] == 0x2A && response[1] == 0x02 + case .move: + response.count >= 5 && response[0] == 0x41 && response[1] == 0x03 && response[2] == 0x0C + } + }, + timeout: timeout + ) + return try EasyLinkCodec.parseBatteryStatus(profile: profile, response: response) + } } /// Starts a Chessnut Move auto-move operation from a FEN placement. public func setAutoMove(fen: String, force: Bool = true) async throws { - guard profile == .move else { - throw EasyLinkError.unsupportedCommand(profile) + try await withCommandGate { + guard profile == .move else { + throw EasyLinkError.unsupportedCommand(profile) + } + try await transport.write(EasyLinkCodec.moveAutoMoveCommand(fen: fen, force: force)) } - try await transport.write(EasyLinkCodec.moveAutoMoveCommand(fen: fen, force: force)) } /// Stops the current Chessnut Move auto-move operation. public func stopAutoMove() async throws { - guard profile == .move else { - throw EasyLinkError.unsupportedCommand(profile) + try await withCommandGate { + guard profile == .move else { + throw EasyLinkError.unsupportedCommand(profile) + } + try await transport.write(EasyLinkCodec.moveStopAutoMoveCommand()) } - try await transport.write(EasyLinkCodec.moveStopAutoMoveCommand()) } /// Requests Chessnut Move piece status records. public func pieceStatus(timeout: Duration = .seconds(3)) async throws -> [PieceStatus] { - guard profile == .move else { - throw EasyLinkError.unsupportedCommand(profile) + try await withCommandGate { + guard profile == .move else { + throw EasyLinkError.unsupportedCommand(profile) + } + + try await transport.write([0x41, 0x01, 0x0B]) + let response = try await responseRouter.wait( + matching: { response in + response.count >= 3 && response[0] == 0x41 && response[1] == 0x89 && response[2] == 0x0B + }, + timeout: timeout + ) + return try EasyLinkCodec.parseMovePieceStatus(response: response) + } + } + + /// Retrieves games recorded by the board during OTB (over-the-board) play. + /// + /// Switches the board to upload mode, downloads all stored games, and returns them. + /// Upload mode stops realtime FEN notifications — call ``enableRealtimeUpdates()`` + /// afterwards to resume the FEN stream. + public func importOTBGames(timeout: Duration = .seconds(120)) async throws -> [OTBGame] { + try await withCommandGate { + try await importOTBGamesLocked(timeout: timeout) + } + } + + private func importOTBGamesLocked(timeout: Duration) async throws -> [OTBGame] { + defer { uploadChannel = nil } + + var games: [OTBGame] = [] + + while true { + try await transport.write(ProtocolConstants.queryFilesCount) + let fileCount = try await nextFileCount(timeout: timeout) + guard fileCount > 0 else { + return games + } + + let channel = OTBChannel() + uploadChannel = channel + try await transport.write(ProtocolConstants.enableUploadMode) + if let rearmingTransport = transport as? EasyLinkNotificationRearmingTransport { + await rearmingTransport.rearmNotificationCharacteristics() + } + let game = try await importNextOTBGame(from: channel, timeout: timeout) + games.append(game) + uploadChannel = nil + try await transport.write(ProtocolConstants.fileImportDone) + } + } + + private func withCommandGate( + operation: () async throws -> T + ) async throws -> T { + await commandGate.acquire() + do { + let value = try await operation() + await commandGate.release() + return value + } catch { + await commandGate.release() + throw error } + } - try await transport.write([0x41, 0x01, 0x0B]) + private func nextFileCount(timeout: Duration) async throws -> Int { let response = try await responseRouter.wait( - matching: { response in - response.count >= 3 && response[0] == 0x41 && response[1] == 0x89 && response[2] == 0x0B + matching: { bytes in + bytes.count >= 3 && bytes[0] == 0x32 && bytes[1] == 0x01 }, timeout: timeout ) - return try EasyLinkCodec.parseMovePieceStatus(response: response) + return Int(response[2]) + } + + private func importNextOTBGame(from channel: OTBChannel, timeout: Duration) async throws -> OTBGame { + try await transport.write(ProtocolConstants.readyForImport) + try await transport.write(ProtocolConstants.startImport) + + var positions: [String] = [] + var didReceiveStartFlag = false + collectLoop: while true { + let notification = try await nextOTBNotification( + from: channel, + timeout: timeout, + pollResponseCharacteristic: didReceiveStartFlag + ) + + switch notification { + case let .response(bytes) where isOTBFlag(bytes, marker: 0xBE): + if !didReceiveStartFlag { + positions.removeAll() + didReceiveStartFlag = true + if let rearmingTransport = transport as? EasyLinkNotificationRearmingTransport { + await rearmingTransport.rearmFENNotificationCharacteristic() + } + } + + case let .response(bytes) where isOTBFlag(bytes, marker: 0xED): + break collectLoop + + case .disconnected: + throw EasyLinkError.disconnected + + default: + if let placement = placement(from: notification) { + positions.append(placement) + } + } + } + + return OTBGame(positions: positions) + } + + private func nextOTBNotification( + from channel: OTBChannel, + timeout: Duration, + pollResponseCharacteristic: Bool + ) async throws -> EasyLinkNotification { + guard pollResponseCharacteristic else { + return try await channel.next(timeout: timeout) + } + + let transport = transport + let pollTask = Task { + guard let pollingTransport = transport as? EasyLinkResponsePollingTransport else { + return + } + + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(250)) + guard !Task.isCancelled else { return } + await pollingTransport.pollResponseCharacteristic() + } + } + defer { pollTask.cancel() } + + return try await channel.next(timeout: timeout) + } + + private nonisolated func isOTBFlag(_ bytes: [UInt8], marker: UInt8) -> Bool { + bytes.count >= 3 && bytes[0] == 0x37 && bytes[1] == 0x01 && bytes[2] == marker + } + + private nonisolated func isPlacementPacket(_ bytes: [UInt8]) -> Bool { + bytes.count >= 34 && bytes[0] == 0x01 + } + + private nonisolated func placement(from notification: EasyLinkNotification) -> String? { + switch notification { + case let .fen(packet): + try? EasyLinkCodec.decodePlacement(from: packet) + case let .response(bytes) where isPlacementPacket(bytes): + try? EasyLinkCodec.decodePlacement(from: bytes) + default: + nil + } + } + + private func tryForwardToUploadChannel(_ notification: EasyLinkNotification) async -> Bool { + guard let channel = uploadChannel else { return false } + await channel.receive(notification) + return true } private func startNotificationTask() { @@ -135,12 +322,17 @@ public actor EasyLinkClient { return } - notificationTask = Task { [transport, responseRouter, fenContinuation] in + notificationTask = Task { [weak self, transport, responseRouter, fenContinuation] in for await notification in transport.notifications { guard !Task.isCancelled else { return } + if let self, await self.tryForwardToUploadChannel(notification) { + if case .disconnected = notification { return } + continue + } + switch notification { case let .fen(packet): if let placement = try? EasyLinkCodec.decodePlacement(from: packet) { @@ -162,3 +354,52 @@ public actor EasyLinkClient { notificationTask = nil } } + +private actor OTBChannel { + private var buffer: [EasyLinkNotification] = [] + private var waiters: [UUID: CheckedContinuation] = [:] + + func receive(_ notification: EasyLinkNotification) { + if let (id, waiter) = waiters.first { + waiters.removeValue(forKey: id) + waiter.resume(returning: notification) + } else { + buffer.append(notification) + if buffer.count > 512 { buffer.removeFirst() } + } + } + + func next(timeout: Duration) async throws -> EasyLinkNotification { + try await withThrowingTaskGroup(of: EasyLinkNotification.self) { group in + group.addTask { try await self.nextWaiting() } + group.addTask { + try await Task.sleep(for: timeout) + throw EasyLinkError.timeout + } + guard let result = try await group.next() else { + throw EasyLinkError.timeout + } + group.cancelAll() + return result + } + } + + private func nextWaiting() async throws -> EasyLinkNotification { + if !buffer.isEmpty { + return buffer.removeFirst() + } + + let id = UUID() + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + waiters[id] = continuation + } + } onCancel: { + Task { await self.cancelWaiter(id: id) } + } + } + + private func cancelWaiter(id: UUID) { + waiters.removeValue(forKey: id)?.resume(throwing: CancellationError()) + } +} diff --git a/Sources/EasyLinkSwiftSDK/EasyLinkSwiftSDK.docc/EasyLinkSwiftSDK.md b/Sources/EasyLinkSwiftSDK/EasyLinkSwiftSDK.docc/EasyLinkSwiftSDK.md index dcf1411..f3d94b0 100644 --- a/Sources/EasyLinkSwiftSDK/EasyLinkSwiftSDK.docc/EasyLinkSwiftSDK.md +++ b/Sources/EasyLinkSwiftSDK/EasyLinkSwiftSDK.docc/EasyLinkSwiftSDK.md @@ -6,7 +6,7 @@ Discover and control Chessnut electronic chessboards over Bluetooth Low Energy. EasyLinkSwiftSDK is a native Swift package for communicating with Chessnut boards on iOS 16 and macOS 13 or later. It uses CoreBluetooth, exposes async APIs, and publishes realtime board positions through `AsyncStream`. -Use ``EasyLinkScanner`` when your app needs to show real Bluetooth devices in a picker. Use ``EasyLinkClient`` to connect, enable realtime updates, query battery state, set LEDs, and use Chessnut Move specific commands. +Use ``EasyLinkScanner`` when your app needs to show real Bluetooth devices in a picker. Use ``EasyLinkClient`` to connect, enable realtime updates, query battery state, set LEDs, import OTB games stored on the board, and use Chessnut Move specific commands. ```swift for await device in EasyLinkScanner.scan(profile: .move) { @@ -34,8 +34,10 @@ try await client.enableRealtimeUpdates() ### Client API - +- - - ``EasyLinkClient`` +- ``OTBGame`` - ``BatteryStatus`` - ``LEDBoard`` - ``LEDColor`` diff --git a/Sources/EasyLinkSwiftSDK/EasyLinkSwiftSDK.docc/OTBGameImport.md b/Sources/EasyLinkSwiftSDK/EasyLinkSwiftSDK.docc/OTBGameImport.md new file mode 100644 index 0000000..23a001b --- /dev/null +++ b/Sources/EasyLinkSwiftSDK/EasyLinkSwiftSDK.docc/OTBGameImport.md @@ -0,0 +1,51 @@ +# OTB Game Import + +Download games recorded by the board during over-the-board play. + +## Overview + +Use ``EasyLinkClient/importOTBGames(timeout:)`` to switch the board into upload mode, query the number of stored games, download one stored game, mark it imported, and repeat until the board reports that no games remain. + +```swift +let games = try await client.importOTBGames() + +for game in games { + for position in game.positions { + print(position) + } +} +``` + +Each ``OTBGame/positions`` value is a FEN placement string captured from the board. The SDK returns only the placement field, matching ``EasyLinkClient/fenUpdates``. + +## Realtime Updates + +Upload mode stops realtime FEN notifications while the board sends stored games. Call ``EasyLinkClient/enableRealtimeUpdates()`` after importing if your UI should resume live board updates. + +```swift +let games = try await client.importOTBGames() +try await client.enableRealtimeUpdates() +``` + +## Empty Storage + +When the board reports that no OTB games are stored, the method returns an empty array. + +```swift +let games = try await client.importOTBGames() + +if games.isEmpty { + print("No stored games") +} +``` + +## Timeout And Disconnects + +The timeout applies while waiting for upload responses and FEN packets. Stored-game transfer can take significantly longer than simple commands, so the default timeout is 120 seconds. If the board disconnects during import, the method throws ``EasyLinkError/disconnected``. + +## Topics + +### Importing + +- ``EasyLinkClient/importOTBGames(timeout:)`` +- ``OTBGame`` diff --git a/Sources/EasyLinkSwiftSDK/EasyLinkTransport.swift b/Sources/EasyLinkSwiftSDK/EasyLinkTransport.swift index 2ae1609..ddf9028 100644 --- a/Sources/EasyLinkSwiftSDK/EasyLinkTransport.swift +++ b/Sources/EasyLinkSwiftSDK/EasyLinkTransport.swift @@ -14,3 +14,21 @@ public protocol EasyLinkTransport: AnyObject, Sendable { /// Writes a raw command packet. func write(_ command: [UInt8]) async throws } + +protocol EasyLinkResponsePollingTransport: EasyLinkTransport { + /// Requests a best-effort read of the response characteristic. + /// + /// Some Chessnut Air firmware revisions appear to advance OTB upload data + /// after ATT read attempts even though the characteristic advertises notify + /// only. CoreBluetooth may report "read not permitted" while still exposing + /// the latest characteristic value. + func pollResponseCharacteristic() async +} + +protocol EasyLinkNotificationRearmingTransport: EasyLinkTransport { + /// Rewrites notification subscriptions after board mode changes. + func rearmNotificationCharacteristics() async + + /// Rewrites only the FEN notification subscription. + func rearmFENNotificationCharacteristic() async +} diff --git a/Sources/EasyLinkSwiftSDK/Models.swift b/Sources/EasyLinkSwiftSDK/Models.swift index fc3c288..0f6586f 100644 --- a/Sources/EasyLinkSwiftSDK/Models.swift +++ b/Sources/EasyLinkSwiftSDK/Models.swift @@ -68,6 +68,17 @@ public struct LEDBoard: Sendable, Equatable { } } +/// A game recorded by the board during OTB (over-the-board) play. +public struct OTBGame: Sendable, Equatable { + /// FEN placement strings captured by the board during the game. + public let positions: [String] + + /// Creates an OTB game value. + public init(positions: [String]) { + self.positions = positions + } +} + /// Battery state reported by a board. public struct BatteryStatus: Sendable, Equatable { /// Battery percentage reported by the device. diff --git a/Sources/EasyLinkSwiftSDK/ProtocolConstants.swift b/Sources/EasyLinkSwiftSDK/ProtocolConstants.swift index c56e178..f04afe2 100644 --- a/Sources/EasyLinkSwiftSDK/ProtocolConstants.swift +++ b/Sources/EasyLinkSwiftSDK/ProtocolConstants.swift @@ -6,8 +6,15 @@ enum ProtocolConstants { static let operationService = UUID(uuidString: "1b7e8271-2877-41c3-b46e-cf057c562023")! static let commandCharacteristic = UUID(uuidString: "1b7e8272-2877-41c3-b46e-cf057c562023")! static let responseCharacteristic = UUID(uuidString: "1b7e8273-2877-41c3-b46e-cf057c562023")! + static let fileService = UUID(uuidString: "1b7e8281-2877-41c3-b46e-cf057c562023")! + static let fileNotificationCharacteristic = UUID(uuidString: "1b7e8283-2877-41c3-b46e-cf057c562023")! static let enableRealtimeMode: [UInt8] = [0x21, 0x01, 0x00] + static let enableUploadMode: [UInt8] = [0x21, 0x01, 0x01] + static let queryFilesCount: [UInt8] = [0x31, 0x01, 0x00] + static let readyForImport: [UInt8] = [0x33, 0x01, 0x00] + static let startImport: [UInt8] = [0x34, 0x01, 0x01] + static let fileImportDone: [UInt8] = [0x39, 0x01, 0x00] } extension BoardProfile { diff --git a/Tests/EasyLinkSwiftSDKTests/CoreBluetoothEasyLinkTransportTests.swift b/Tests/EasyLinkSwiftSDKTests/CoreBluetoothEasyLinkTransportTests.swift index 1e720a0..71be1e7 100644 --- a/Tests/EasyLinkSwiftSDKTests/CoreBluetoothEasyLinkTransportTests.swift +++ b/Tests/EasyLinkSwiftSDKTests/CoreBluetoothEasyLinkTransportTests.swift @@ -1,4 +1,5 @@ import EasyLinkSwiftSDK +import Foundation import XCTest final class CoreBluetoothEasyLinkTransportTests: XCTestCase { @@ -53,7 +54,9 @@ final class CoreBluetoothEasyLinkTransportTests: XCTestCase { XCTAssertNotNil(transport) } - func testConnectCancellationDoesNotHang() async { + func testConnectCancellationDoesNotHang() async throws { + try skipUnlessCoreBluetoothIntegrationTestsAreEnabled() + let transport = CoreBluetoothEasyLinkTransport(profile: .classic) let task = Task { @@ -77,4 +80,10 @@ final class CoreBluetoothEasyLinkTransportTests: XCTestCase { await transport.disconnect() await transport.disconnect() } + + private func skipUnlessCoreBluetoothIntegrationTestsAreEnabled() throws { + guard ProcessInfo.processInfo.environment["EASYLINK_RUN_COREBLUETOOTH_TESTS"] == "1" else { + throw XCTSkip("Set EASYLINK_RUN_COREBLUETOOTH_TESTS=1 from an app/test host with NSBluetoothAlwaysUsageDescription to run CoreBluetooth integration tests.") + } + } } diff --git a/Tests/EasyLinkSwiftSDKTests/EasyLinkClientTests.swift b/Tests/EasyLinkSwiftSDKTests/EasyLinkClientTests.swift index 3a5103e..4fe8e65 100644 --- a/Tests/EasyLinkSwiftSDKTests/EasyLinkClientTests.swift +++ b/Tests/EasyLinkSwiftSDKTests/EasyLinkClientTests.swift @@ -78,10 +78,7 @@ final class EasyLinkClientTests: XCTestCase { } func testPieceStatusWritesMoveCommandAndParsesResponse() async throws { - var payload: [UInt8] = [] - for index in 0..<34 { - payload += [UInt8(index), 1, 2, 3] - } + let payload: [UInt8] = (0..<34).flatMap { index in [UInt8(index), 1, 2, 3] } let transport = FakeTransport { command in command == [0x41, 0x01, 0x0B] ? [0x41, 0x89, 0x0B] + payload : nil diff --git a/Tests/EasyLinkSwiftSDKTests/EasyLinkScannerTests.swift b/Tests/EasyLinkSwiftSDKTests/EasyLinkScannerTests.swift index 860f7b1..25d4f48 100644 --- a/Tests/EasyLinkSwiftSDKTests/EasyLinkScannerTests.swift +++ b/Tests/EasyLinkSwiftSDKTests/EasyLinkScannerTests.swift @@ -1,8 +1,11 @@ import EasyLinkSwiftSDK +import Foundation import XCTest final class EasyLinkScannerTests: XCTestCase { - func testScanImmediateCancellationDoesNotCrash() async { + func testScanImmediateCancellationDoesNotCrash() async throws { + try skipUnlessCoreBluetoothIntegrationTestsAreEnabled() + let task = Task { for await _ in EasyLinkScanner.scan(profile: .classic) {} } @@ -10,11 +13,19 @@ final class EasyLinkScannerTests: XCTestCase { await task.value } - func testScanCancellationForMoveProfileDoesNotCrash() async { + func testScanCancellationForMoveProfileDoesNotCrash() async throws { + try skipUnlessCoreBluetoothIntegrationTestsAreEnabled() + let task = Task { for await _ in EasyLinkScanner.scan(profile: .move) {} } task.cancel() await task.value } + + private func skipUnlessCoreBluetoothIntegrationTestsAreEnabled() throws { + guard ProcessInfo.processInfo.environment["EASYLINK_RUN_COREBLUETOOTH_TESTS"] == "1" else { + throw XCTSkip("Set EASYLINK_RUN_COREBLUETOOTH_TESTS=1 from an app/test host with NSBluetoothAlwaysUsageDescription to run CoreBluetooth integration tests.") + } + } } diff --git a/Tests/EasyLinkSwiftSDKTests/FakeTransport.swift b/Tests/EasyLinkSwiftSDKTests/FakeTransport.swift index f8c8473..64c716d 100644 --- a/Tests/EasyLinkSwiftSDKTests/FakeTransport.swift +++ b/Tests/EasyLinkSwiftSDKTests/FakeTransport.swift @@ -1,17 +1,26 @@ -import EasyLinkSwiftSDK +@testable import EasyLinkSwiftSDK import Foundation -actor FakeTransport: EasyLinkTransport { +actor FakeTransport: EasyLinkTransport, EasyLinkResponsePollingTransport { nonisolated let notifications: AsyncStream private nonisolated let continuation: AsyncStream.Continuation - private var responseHandler: (([UInt8]) -> [UInt8]?)? + private var responseHandler: (@Sendable ([UInt8]) -> [UInt8]?)? + private var queuedResponses: [[UInt8]: [[UInt8]]] + private var polledNotifications: [EasyLinkNotification] private(set) var writes: [[UInt8]] = [] + private(set) var pollCount = 0 private(set) var didConnect = false private(set) var didDisconnect = false - init(responseHandler: (([UInt8]) -> [UInt8]?)? = nil) { + init( + responseHandler: (@Sendable ([UInt8]) -> [UInt8]?)? = nil, + queuedResponses: [[UInt8]: [[UInt8]]] = [:], + polledNotifications: [EasyLinkNotification] = [] + ) { self.responseHandler = responseHandler + self.queuedResponses = queuedResponses + self.polledNotifications = polledNotifications var continuation: AsyncStream.Continuation! self.notifications = AsyncStream { streamContinuation in @@ -31,12 +40,31 @@ actor FakeTransport: EasyLinkTransport { func write(_ command: [UInt8]) async throws { writes.append(command) - let response = responseHandler?(command) + let response = responseHandler?(command) ?? nextQueuedResponse(for: command) if let response { continuation.yield(.response(response)) } } + func pollResponseCharacteristic() async { + pollCount += 1 + guard !polledNotifications.isEmpty else { + return + } + + continuation.yield(polledNotifications.removeFirst()) + } + + private func nextQueuedResponse(for command: [UInt8]) -> [UInt8]? { + guard var responses = queuedResponses[command], !responses.isEmpty else { + return nil + } + + let response = responses.removeFirst() + queuedResponses[command] = responses + return response + } + nonisolated func send(_ notification: EasyLinkNotification) { continuation.yield(notification) } diff --git a/Tests/EasyLinkSwiftSDKTests/OTBImportTests.swift b/Tests/EasyLinkSwiftSDKTests/OTBImportTests.swift new file mode 100644 index 0000000..b4b78f8 --- /dev/null +++ b/Tests/EasyLinkSwiftSDKTests/OTBImportTests.swift @@ -0,0 +1,334 @@ +@testable import EasyLinkSwiftSDK +import XCTest + +final class OTBImportTests: XCTestCase { + + // MARK: - Helpers + + private func fenPacket(placement: String) throws -> [UInt8] { + [0x01, 0x20] + (try EasyLinkCodec.encodePlacement(placement)) + } + + private func makeClient( + queuedResponses: [[UInt8]: [[UInt8]]] = [:], + responseHandler: (@Sendable ([UInt8]) -> [UInt8]?)? = nil, + polledNotifications: [EasyLinkNotification] = [] + ) async throws -> (EasyLinkClient, FakeTransport) { + let transport = FakeTransport( + responseHandler: responseHandler, + queuedResponses: queuedResponses, + polledNotifications: polledNotifications + ) + let client = EasyLinkClient(profile: .classic, transport: transport) + try await client.connect() + return (client, transport) + } + + private func queuedCountResponses(_ counts: UInt8...) -> [[UInt8]: [[UInt8]]] { + [ + ProtocolConstants.queryFilesCount: counts.map { [0x32, 0x01, $0] } + ] + } + + // MARK: - Tests + + func testImportOTBGamesReturnsEmptyWhenBoardHasNoGames() async throws { + let (client, _) = try await makeClient { command in + command == ProtocolConstants.queryFilesCount ? [0x32, 0x01, 0x00] : nil + } + let games = try await client.importOTBGames(timeout: .seconds(1)) + XCTAssertTrue(games.isEmpty) + } + + func testImportOTBGamesSendsCorrectCommandSequenceForZeroGames() async throws { + let (client, transport) = try await makeClient { command in + command == ProtocolConstants.queryFilesCount ? [0x32, 0x01, 0x00] : nil + } + _ = try await client.importOTBGames(timeout: .seconds(1)) + let writes = await transport.writes + XCTAssertEqual(writes, [ + ProtocolConstants.queryFilesCount, + ]) + } + + func testImportOTBGamesSendsCorrectCommandSequenceForOneGame() async throws { + let placement = "8/8/8/8/8/8/8/8" + let (client, transport) = try await makeClient(queuedResponses: queuedCountResponses(1, 0)) { command in + switch command { + case ProtocolConstants.startImport: [0x37, 0x01, 0xBE] + default: nil + } + } + let importTask = Task { try await client.importOTBGames(timeout: .seconds(2)) } + try await Task.sleep(for: .milliseconds(50)) + transport.send(.fen(try fenPacket(placement: placement))) + transport.send(.response([0x37, 0x01, 0xED])) + _ = try await importTask.value + let writes = await transport.writes + XCTAssertEqual(writes, [ + ProtocolConstants.queryFilesCount, + ProtocolConstants.enableUploadMode, + ProtocolConstants.readyForImport, + ProtocolConstants.startImport, + ProtocolConstants.fileImportDone, + ProtocolConstants.queryFilesCount, + ]) + } + + func testImportOTBGamesSingleGameWithOnePosition() async throws { + let placement = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR" + let (client, transport) = try await makeClient(queuedResponses: queuedCountResponses(1, 0)) { command in + switch command { + case ProtocolConstants.startImport: [0x37, 0x01, 0xBE] + default: nil + } + } + let importTask = Task { try await client.importOTBGames(timeout: .seconds(2)) } + try await Task.sleep(for: .milliseconds(50)) + transport.send(.fen(try fenPacket(placement: placement))) + transport.send(.response([0x37, 0x01, 0xED])) + let games = try await importTask.value + XCTAssertEqual(games.count, 1) + XCTAssertEqual(games[0].positions, [placement]) + } + + func testImportOTBGamesAcceptsPlacementPacketsOnResponseChannel() async throws { + let placement = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR" + let (client, transport) = try await makeClient(queuedResponses: queuedCountResponses(1, 0)) { command in + switch command { + case ProtocolConstants.startImport: [0x37, 0x01, 0xBE] + default: nil + } + } + let importTask = Task { try await client.importOTBGames(timeout: .seconds(2)) } + try await Task.sleep(for: .milliseconds(50)) + transport.send(.response(try fenPacket(placement: placement))) + transport.send(.response([0x37, 0x01, 0xED])) + let games = try await importTask.value + XCTAssertEqual(games.count, 1) + XCTAssertEqual(games[0].positions, [placement]) + } + + func testImportOTBGamesPollsResponseCharacteristicAfterStartFlag() async throws { + let placement = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR" + let (client, transport) = try await makeClient( + queuedResponses: queuedCountResponses(1, 0), + responseHandler: { command in + switch command { + case ProtocolConstants.startImport: [0x37, 0x01, 0xBE] + default: nil + } + }, + polledNotifications: [ + .response(try fenPacket(placement: placement)), + .response([0x37, 0x01, 0xED]), + ] + ) + + let games = try await client.importOTBGames(timeout: .seconds(2)) + + XCTAssertEqual(games.count, 1) + XCTAssertEqual(games[0].positions, [placement]) + let pollCount = await transport.pollCount + XCTAssertGreaterThan(pollCount, 0) + } + + func testImportOTBGamesIgnoresFileMetadataResponse() async throws { + let placement = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR" + let metadata: [UInt8] = [0x36, 0x08, 0x60, 0x02, 0x00, 0x00, 0x29, 0x85, 0x35, 0x13] + let (client, transport) = try await makeClient(queuedResponses: queuedCountResponses(1, 0)) { command in + switch command { + case ProtocolConstants.startImport: metadata + default: nil + } + } + let importTask = Task { try await client.importOTBGames(timeout: .seconds(2)) } + try await Task.sleep(for: .milliseconds(50)) + transport.send(.response([0x37, 0x01, 0xBE])) + transport.send(.response(try fenPacket(placement: placement))) + transport.send(.response([0x37, 0x01, 0xED])) + let games = try await importTask.value + XCTAssertEqual(games.count, 1) + XCTAssertEqual(games[0].positions, [placement]) + } + + func testImportOTBGamesIgnoresDuplicateStartFlagAfterCollectionStarted() async throws { + let placement = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR" + let (client, transport) = try await makeClient(queuedResponses: queuedCountResponses(1, 0)) { command in + switch command { + case ProtocolConstants.startImport: [0x37, 0x01, 0xBE] + default: nil + } + } + let importTask = Task { try await client.importOTBGames(timeout: .seconds(2)) } + try await Task.sleep(for: .milliseconds(50)) + transport.send(.response(try fenPacket(placement: placement))) + transport.send(.response([0x37, 0x01, 0xBE])) + transport.send(.response([0x37, 0x01, 0xED])) + let games = try await importTask.value + XCTAssertEqual(games.count, 1) + XCTAssertEqual(games[0].positions, [placement]) + } + + func testImportOTBGamesStartsCollectionWhenPlacementArrivesBeforeStartFlag() async throws { + let placement = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR" + let (client, transport) = try await makeClient(queuedResponses: queuedCountResponses(1, 0)) + let importTask = Task { try await client.importOTBGames(timeout: .seconds(2)) } + try await Task.sleep(for: .milliseconds(50)) + transport.send(.response(try fenPacket(placement: placement))) + transport.send(.response([0x37, 0x01, 0xED])) + let games = try await importTask.value + XCTAssertEqual(games.count, 1) + XCTAssertEqual(games[0].positions, [placement]) + } + + func testImportOTBGamesSingleGameWithMultiplePositions() async throws { + let positions = [ + "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR", + "rnbqkbnr/pppp1ppp/4p3/8/4P3/8/PPPP1PPP/RNBQKBNR", + ] + let (client, transport) = try await makeClient(queuedResponses: queuedCountResponses(1, 0)) { command in + switch command { + case ProtocolConstants.startImport: [0x37, 0x01, 0xBE] + default: nil + } + } + let importTask = Task { try await client.importOTBGames(timeout: .seconds(2)) } + try await Task.sleep(for: .milliseconds(50)) + for p in positions { transport.send(.fen(try fenPacket(placement: p))) } + transport.send(.response([0x37, 0x01, 0xED])) + let games = try await importTask.value + XCTAssertEqual(games.count, 1) + XCTAssertEqual(games[0].positions, positions) + } + + func testImportOTBGamesMultipleGames() async throws { + let placement1 = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR" + let placement2 = "8/8/8/3k4/4K3/8/8/8" + let (client, transport) = try await makeClient(queuedResponses: queuedCountResponses(2, 1, 0)) { command in + switch command { + case ProtocolConstants.startImport: [0x37, 0x01, 0xBE] + default: nil + } + } + let importTask = Task { try await client.importOTBGames(timeout: .seconds(2)) } + try await Task.sleep(for: .milliseconds(50)) + transport.send(.fen(try fenPacket(placement: placement1))) + transport.send(.response([0x37, 0x01, 0xED])) + try await Task.sleep(for: .milliseconds(50)) + transport.send(.fen(try fenPacket(placement: placement2))) + transport.send(.response([0x37, 0x01, 0xED])) + let games = try await importTask.value + XCTAssertEqual(games.count, 2) + XCTAssertEqual(games[0].positions, [placement1]) + XCTAssertEqual(games[1].positions, [placement2]) + } + + func testImportOTBGamesThrowsOnDisconnectDuringCollection() async throws { + let (client, transport) = try await makeClient(queuedResponses: queuedCountResponses(1)) { command in + switch command { + case ProtocolConstants.startImport: [0x37, 0x01, 0xBE] + default: nil + } + } + let importTask = Task { try await client.importOTBGames(timeout: .seconds(2)) } + try await Task.sleep(for: .milliseconds(50)) + transport.send(.disconnected) + do { + _ = try await importTask.value + XCTFail("Expected disconnected error") + } catch EasyLinkError.disconnected { + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func testImportOTBGamesThrowsTimeoutWhenNoResponse() async throws { + let (client, _) = try await makeClient() + do { + _ = try await client.importOTBGames(timeout: .milliseconds(100)) + XCTFail("Expected timeout error") + } catch EasyLinkError.timeout { + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func testImportOTBGamesStartFlagClearsPositionsCollectedBeforeIt() async throws { + let earlyPlacement = "8/8/8/8/8/8/8/8" + let placement = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR" + let (client, transport) = try await makeClient(queuedResponses: queuedCountResponses(1, 0)) + let importTask = Task { try await client.importOTBGames(timeout: .seconds(2)) } + try await Task.sleep(for: .milliseconds(50)) + transport.send(.response(try fenPacket(placement: earlyPlacement))) + transport.send(.response([0x37, 0x01, 0xBE])) + transport.send(.response(try fenPacket(placement: placement))) + transport.send(.response([0x37, 0x01, 0xED])) + let games = try await importTask.value + XCTAssertEqual(games.count, 1) + XCTAssertEqual(games[0].positions, [placement]) + } + + func testImportOTBGamesEmptyGame() async throws { + let (client, transport) = try await makeClient(queuedResponses: queuedCountResponses(1, 0)) { command in + command == ProtocolConstants.startImport ? [0x37, 0x01, 0xBE] : nil + } + let importTask = Task { try await client.importOTBGames(timeout: .seconds(2)) } + try await Task.sleep(for: .milliseconds(50)) + transport.send(.response([0x37, 0x01, 0xED])) + let games = try await importTask.value + XCTAssertEqual(games.count, 1) + XCTAssertEqual(games[0].positions, []) + } + + func testFenPacketsNotRoutedToFenStreamDuringImport() async throws { + let placement = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR" + let (client, transport) = try await makeClient(queuedResponses: queuedCountResponses(1, 0)) { command in + command == ProtocolConstants.startImport ? [0x37, 0x01, 0xBE] : nil + } + actor FENCollector { + var values: [String] = [] + func append(_ value: String) { values.append(value) } + } + let collector = FENCollector() + let captureTask = Task { + for await fen in client.fenUpdates { + await collector.append(fen) + } + } + let importTask = Task { try await client.importOTBGames(timeout: .seconds(2)) } + try await Task.sleep(for: .milliseconds(50)) + transport.send(.fen(try fenPacket(placement: placement))) + transport.send(.response([0x37, 0x01, 0xED])) + _ = try await importTask.value + try await Task.sleep(for: .milliseconds(50)) + captureTask.cancel() + let captured = await collector.values + XCTAssertTrue(captured.isEmpty, "FEN packets must not be emitted to fenUpdates during OTB import") + } + + func testImportOTBGamesSerializesWithConcurrentCall() async throws { + let (client, transport) = try await makeClient { command in + command == ProtocolConstants.queryFilesCount ? [0x32, 0x01, 0x00] : nil + } + async let first = client.importOTBGames(timeout: .seconds(2)) + async let second = client.importOTBGames(timeout: .seconds(2)) + let (games1, games2) = try await (first, second) + XCTAssertTrue(games1.isEmpty) + XCTAssertTrue(games2.isEmpty) + let writes = await transport.writes + XCTAssertEqual(writes.filter { $0 == ProtocolConstants.queryFilesCount }.count, 2) + } + + func testFenUpdatesResumeRoutingAfterOTBImport() async throws { + let placement = "8/8/8/3k4/4K3/8/8/8" + let (client, transport) = try await makeClient { command in + command == ProtocolConstants.queryFilesCount ? [0x32, 0x01, 0x00] : nil + } + var fenIterator = client.fenUpdates.makeAsyncIterator() + _ = try await client.importOTBGames(timeout: .seconds(1)) + transport.send(.fen(try fenPacket(placement: placement))) + let received = await fenIterator.next() + XCTAssertEqual(received, placement) + } +} diff --git a/Tests/EasyLinkSwiftSDKTests/ProtocolConstantsTests.swift b/Tests/EasyLinkSwiftSDKTests/ProtocolConstantsTests.swift index 2b9fe87..ff1ebbb 100644 --- a/Tests/EasyLinkSwiftSDKTests/ProtocolConstantsTests.swift +++ b/Tests/EasyLinkSwiftSDKTests/ProtocolConstantsTests.swift @@ -14,6 +14,26 @@ final class ProtocolConstantsTests: XCTestCase { XCTAssertEqual(ProtocolConstants.enableRealtimeMode, [0x21, 0x01, 0x00]) } + func testEnableUploadModeCommand() { + XCTAssertEqual(ProtocolConstants.enableUploadMode, [0x21, 0x01, 0x01]) + } + + func testQueryFilesCountCommand() { + XCTAssertEqual(ProtocolConstants.queryFilesCount, [0x31, 0x01, 0x00]) + } + + func testReadyForImportCommand() { + XCTAssertEqual(ProtocolConstants.readyForImport, [0x33, 0x01, 0x00]) + } + + func testStartImportCommand() { + XCTAssertEqual(ProtocolConstants.startImport, [0x34, 0x01, 0x01]) + } + + func testFileImportDoneCommand() { + XCTAssertEqual(ProtocolConstants.fileImportDone, [0x39, 0x01, 0x00]) + } + func testClassicMatchesChessnutBoardNames() { XCTAssertTrue(BoardProfile.classic.matchesPeripheralName("Chessnut Air")) XCTAssertTrue(BoardProfile.classic.matchesPeripheralName("Chessnut Air+")) diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..ebc90f6 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,3 @@ +coverage: + ignore: + - "Tests/**"