From 4e5c9c535eed5cad1398326c4193b81ad0c07d04 Mon Sep 17 00:00:00 2001 From: NSStudent Date: Sat, 18 Apr 2026 16:13:51 +0200 Subject: [PATCH 1/7] test: skip CoreBluetooth integration tests by default Signed-off-by: NSStudent --- Sources/EasyLinkSwiftSDK/EasyLinkClient.swift | 127 ++++++++++++- Sources/EasyLinkSwiftSDK/Models.swift | 11 ++ .../EasyLinkSwiftSDK/ProtocolConstants.swift | 5 + .../CoreBluetoothEasyLinkTransportTests.swift | 11 +- .../EasyLinkClientTests.swift | 5 +- .../EasyLinkScannerTests.swift | 15 +- .../EasyLinkSwiftSDKTests/FakeTransport.swift | 4 +- .../OTBImportTests.swift | 171 ++++++++++++++++++ 8 files changed, 339 insertions(+), 10 deletions(-) create mode 100644 Tests/EasyLinkSwiftSDKTests/OTBImportTests.swift diff --git a/Sources/EasyLinkSwiftSDK/EasyLinkClient.swift b/Sources/EasyLinkSwiftSDK/EasyLinkClient.swift index 0a73f98..cb7a799 100644 --- a/Sources/EasyLinkSwiftSDK/EasyLinkClient.swift +++ b/Sources/EasyLinkSwiftSDK/EasyLinkClient.swift @@ -10,6 +10,7 @@ public actor EasyLinkClient { private let transport: EasyLinkTransport private let responseRouter = ResponseRouter() + private var uploadChannel: OTBChannel? private nonisolated let fenContinuation: AsyncStream.Continuation private var notificationTask: Task? @@ -130,17 +131,94 @@ public actor EasyLinkClient { 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(5)) async throws -> [OTBGame] { + let channel = OTBChannel() + uploadChannel = channel + defer { uploadChannel = nil } + + try await transport.write(ProtocolConstants.enableUploadMode) + try await transport.write(ProtocolConstants.queryFilesCount) + + var fileCount = 0 + fileCountLoop: while true { + switch try await channel.next(timeout: timeout) { + case let .response(bytes) where bytes.count >= 3 && bytes[0] == 0x32 && bytes[1] == 0x01: + fileCount = Int(bytes[2]) + break fileCountLoop + default: + break + } + } + + guard fileCount > 0 else { return [] } + + var games: [OTBGame] = [] + + for _ in 0.. Bool { + bytes.count >= 3 && bytes[0] == 0x37 && bytes[1] == 0x01 && bytes[2] == marker + } + + private func tryForwardToUploadChannel(_ notification: EasyLinkNotification) async -> Bool { + guard let channel = uploadChannel else { return false } + await channel.receive(notification) + return true + } + private func startNotificationTask() { guard notificationTask == nil else { 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 +240,50 @@ 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/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..836bca1 100644 --- a/Sources/EasyLinkSwiftSDK/ProtocolConstants.swift +++ b/Sources/EasyLinkSwiftSDK/ProtocolConstants.swift @@ -8,6 +8,11 @@ enum ProtocolConstants { static let responseCharacteristic = UUID(uuidString: "1b7e8273-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..f84c78b 100644 --- a/Tests/EasyLinkSwiftSDKTests/FakeTransport.swift +++ b/Tests/EasyLinkSwiftSDKTests/FakeTransport.swift @@ -5,12 +5,12 @@ actor FakeTransport: EasyLinkTransport { nonisolated let notifications: AsyncStream private nonisolated let continuation: AsyncStream.Continuation - private var responseHandler: (([UInt8]) -> [UInt8]?)? + private var responseHandler: (@Sendable ([UInt8]) -> [UInt8]?)? private(set) var writes: [[UInt8]] = [] private(set) var didConnect = false private(set) var didDisconnect = false - init(responseHandler: (([UInt8]) -> [UInt8]?)? = nil) { + init(responseHandler: (@Sendable ([UInt8]) -> [UInt8]?)? = nil) { self.responseHandler = responseHandler var continuation: AsyncStream.Continuation! diff --git a/Tests/EasyLinkSwiftSDKTests/OTBImportTests.swift b/Tests/EasyLinkSwiftSDKTests/OTBImportTests.swift new file mode 100644 index 0000000..96030dc --- /dev/null +++ b/Tests/EasyLinkSwiftSDKTests/OTBImportTests.swift @@ -0,0 +1,171 @@ +@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( + responseHandler: (@Sendable ([UInt8]) -> [UInt8]?)? = nil + ) async throws -> (EasyLinkClient, FakeTransport) { + let transport = FakeTransport(responseHandler: responseHandler) + let client = EasyLinkClient(profile: .classic, transport: transport) + try await client.connect() + return (client, transport) + } + + // 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.enableUploadMode, + ProtocolConstants.queryFilesCount, + ]) + } + + func testImportOTBGamesSendsCorrectCommandSequenceForOneGame() async throws { + let placement = "8/8/8/8/8/8/8/8" + let (client, transport) = try await makeClient { command in + switch command { + case ProtocolConstants.queryFilesCount: [0x32, 0x01, 0x01] + 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.enableUploadMode, + ProtocolConstants.queryFilesCount, + ProtocolConstants.readyForImport, + ProtocolConstants.startImport, + ProtocolConstants.fileImportDone, + ]) + } + + func testImportOTBGamesSingleGameWithOnePosition() async throws { + let placement = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR" + let (client, transport) = try await makeClient { command in + switch command { + case ProtocolConstants.queryFilesCount: [0x32, 0x01, 0x01] + 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 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 { command in + switch command { + case ProtocolConstants.queryFilesCount: [0x32, 0x01, 0x01] + 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 { command in + switch command { + case ProtocolConstants.queryFilesCount: [0x32, 0x01, 0x02] + 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 { command in + switch command { + case ProtocolConstants.queryFilesCount: [0x32, 0x01, 0x01] + 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 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) + } +} From e63979e71580974e9839272bc7dec3260af8b9d9 Mon Sep 17 00:00:00 2001 From: NSStudent Date: Sat, 18 Apr 2026 16:25:56 +0200 Subject: [PATCH 2/7] docs: document OTB game import --- README.md | 33 +++++++++++- .../EasyLinkSwiftSDK.docc/EasyLinkSwiftSDK.md | 4 +- .../EasyLinkSwiftSDK.docc/OTBGameImport.md | 51 +++++++++++++++++++ 3 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 Sources/EasyLinkSwiftSDK/EasyLinkSwiftSDK.docc/OTBGameImport.md diff --git a/README.md b/README.md index 2d4bf0e..18a35e8 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,26 @@ for piece in pieces { } ``` +### Import OTB Games + +Use `importOTBGames(timeout:)` to download games stored by the board during over-the-board play: + +```swift +let games = try await client.importOTBGames(timeout: .seconds(5)) + +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() +``` + ## Custom Transports `EasyLinkClient` depends on `EasyLinkTransport`, so tests and simulators can replace CoreBluetooth: @@ -227,6 +248,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 +284,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 +304,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/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..bc89385 --- /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, and collect each game as an ``OTBGame``. + +```swift +let games = try await client.importOTBGames(timeout: .seconds(5)) + +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. If the board disconnects during import, the method throws ``EasyLinkError/disconnected``. + +## Topics + +### Importing + +- ``EasyLinkClient/importOTBGames(timeout:)`` +- ``OTBGame`` From c256e22ada0aa59d4e8bab2bbec274cf52753208 Mon Sep 17 00:00:00 2001 From: NSStudent Date: Sat, 18 Apr 2026 18:46:49 +0200 Subject: [PATCH 3/7] WIP: otb games Signed-off-by: NSStudent --- README.md | 4 +- .../CoreBluetoothEasyLinkTransport.swift | 282 +++++++++-- Sources/EasyLinkSwiftSDK/EasyLinkClient.swift | 463 ++++++++++++++---- .../EasyLinkSwiftSDK.docc/OTBGameImport.md | 6 +- .../EasyLinkSwiftSDK/EasyLinkTransport.swift | 8 + .../EasyLinkSwiftSDKTests/FakeTransport.swift | 19 +- .../OTBImportTests.swift | 92 +++- 7 files changed, 737 insertions(+), 137 deletions(-) diff --git a/README.md b/README.md index 18a35e8..96636ce 100644 --- a/README.md +++ b/README.md @@ -199,10 +199,10 @@ for piece in pieces { ### Import OTB Games -Use `importOTBGames(timeout:)` to download games stored by the board during over-the-board play: +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(timeout: .seconds(5)) +let games = try await client.importOTBGames() for game in games { for position in game.positions { diff --git a/Sources/EasyLinkSwiftSDK/CoreBluetoothEasyLinkTransport.swift b/Sources/EasyLinkSwiftSDK/CoreBluetoothEasyLinkTransport.swift index 0848f7b..3fafc0b 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, @unchecked Sendable { + private static let minimumWriteInterval: TimeInterval = 0.2 + public let notifications: AsyncStream private let profile: BoardProfile @@ -13,8 +39,12 @@ 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 connectContinuations: [UUID: CheckedContinuation] = [:] private var pendingWriteContinuations: [CheckedContinuation] = [] + private var nextWriteDate = Date.distantPast + private var responsePollCount = 0 public init(profile: BoardProfile, deviceID: UUID? = nil) { self.profile = profile @@ -35,10 +65,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 +82,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 +102,22 @@ 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.nextWriteDate = .distantPast self.peripheral = nil self.finishConnect(.failure(EasyLinkError.disconnected)) self.finishPendingWrites(.failure(EasyLinkError.disconnected)) @@ -78,28 +130,94 @@ 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 - ) + guard characteristic.properties.contains(.read) else { + if self.responsePollCount == 0 { + #if DEBUG + easyLinkTransportLogger.debug("poll response unsupported; properties=\(transportDebugProperties(characteristic.properties), privacy: .public)") + #endif + } + self.responsePollCount += 1 + return + } + + self.responsePollCount += 1 + #if DEBUG + if self.responsePollCount == 1 || self.responsePollCount.isMultiple(of: 20) { + easyLinkTransportLogger.debug("poll response readValue count=\(self.responsePollCount, privacy: .public)") + } + #endif + peripheral.readValue(for: characteristic) } } } + 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 + } + + #if DEBUG + easyLinkTransportLogger.debug("perform write characteristic=\(commandCharacteristic.uuid.uuidString, privacy: .public) len=\(command.count, privacy: .public) bytes=\(transportDebugHex(command), privacy: .public)") + #endif + pendingWriteContinuations.append(continuation) + peripheral.writeValue( + Data(command), + for: commandCharacteristic, + type: .withResponse + ) + } + 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 +225,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 +236,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 +247,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 +283,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 +325,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,6 +339,9 @@ 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) @@ -220,6 +353,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 +365,14 @@ 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 + responsePollCount = 0 + nextWriteDate = .distantPast self.peripheral = nil finishConnect(.failure(error ?? EasyLinkError.disconnected)) finishPendingWrites(.failure(error ?? EasyLinkError.disconnected)) @@ -240,10 +383,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): @@ -273,17 +423,40 @@ 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 peripheral.setNotifyValue(true, for: characteristic) default: @@ -294,24 +467,66 @@ 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 + + default: + break + } + + validateConnectionReadiness() + } + public func peripheral( _ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error? ) { guard error == nil, let value = characteristic.value 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) 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)) 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 +537,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 cb7a799..acae966 100644 --- a/Sources/EasyLinkSwiftSDK/EasyLinkClient.swift +++ b/Sources/EasyLinkSwiftSDK/EasyLinkClient.swift @@ -1,4 +1,61 @@ import Foundation +#if DEBUG +import OSLog + +private let easyLinkClientLogger = Logger(subsystem: "EasyLinkSwiftSDK", category: "EasyLinkClient") + +private func debugHex(_ bytes: [UInt8]) -> String { + bytes.map { String(format: "%02X", $0) }.joined(separator: " ") +} + +private func debugDescription(_ notification: EasyLinkNotification) -> String { + switch notification { + case let .fen(packet): + "fen len=\(packet.count) bytes=\(debugHex(packet))" + case let .response(bytes): + "response len=\(bytes.count) bytes=\(debugHex(bytes))" + case .disconnected: + "disconnected" + } +} +#endif + +private actor CommandGate { + private var isLocked = false + private var waiters: [CheckedContinuation] = [] + + func acquire(operationName: String) async { + guard isLocked else { + isLocked = true + #if DEBUG + easyLinkClientLogger.debug("CommandGate immediate acquire operation=\(operationName, privacy: .public)") + #endif + return + } + + #if DEBUG + easyLinkClientLogger.debug("CommandGate queued operation=\(operationName, privacy: .public) waiters=\(self.waiters.count, privacy: .public)") + #endif + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + func release(operationName: String) { + guard !waiters.isEmpty else { + isLocked = false + #if DEBUG + easyLinkClientLogger.debug("CommandGate unlocked operation=\(operationName, privacy: .public)") + #endif + return + } + + #if DEBUG + easyLinkClientLogger.debug("CommandGate resuming next operation=\(operationName, privacy: .public) remainingAfterResume=\(self.waiters.count - 1, privacy: .public)") + #endif + waiters.removeFirst().resume() + } +} /// High-level async client for a Chessnut board. public actor EasyLinkClient { @@ -10,6 +67,7 @@ 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? @@ -66,69 +124,81 @@ public actor EasyLinkClient { /// Enables realtime FEN notifications on the board. public func enableRealtimeUpdates() async throws { - try await transport.write(ProtocolConstants.enableRealtimeMode) + try await withCommandGate("enableRealtimeUpdates") { + 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("setLEDs") { + 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("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) + } } /// 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("setAutoMove") { + 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("stopAutoMove") { + 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("pieceStatus") { + 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) + 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. @@ -136,69 +206,245 @@ public actor EasyLinkClient { /// 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(5)) async throws -> [OTBGame] { - let channel = OTBChannel() - uploadChannel = channel + public func importOTBGames(timeout: Duration = .seconds(120)) async throws -> [OTBGame] { + try await withCommandGate("importOTBGames") { + try await importOTBGamesLocked(timeout: timeout) + } + } + + private func importOTBGamesLocked(timeout: Duration) async throws -> [OTBGame] { defer { uploadChannel = nil } - try await transport.write(ProtocolConstants.enableUploadMode) - try await transport.write(ProtocolConstants.queryFilesCount) + #if DEBUG + easyLinkClientLogger.debug("OTB import started timeout=\(String(describing: timeout), privacy: .public)") + #endif - var fileCount = 0 - fileCountLoop: while true { - switch try await channel.next(timeout: timeout) { - case let .response(bytes) where bytes.count >= 3 && bytes[0] == 0x32 && bytes[1] == 0x01: - fileCount = Int(bytes[2]) - break fileCountLoop - default: - break + var games: [OTBGame] = [] + + while true { + #if DEBUG + easyLinkClientLogger.debug("OTB enable upload mode") + #endif + try await transport.write(ProtocolConstants.enableUploadMode) + #if DEBUG + easyLinkClientLogger.debug("OTB query file count command") + #endif + try await transport.write(ProtocolConstants.queryFilesCount) + let fileCount = try await nextFileCount(timeout: timeout) + #if DEBUG + easyLinkClientLogger.debug("OTB file count received count=\(fileCount, privacy: .public)") + #endif + guard fileCount > 0 else { + #if DEBUG + easyLinkClientLogger.debug("OTB import finished totalGames=\(games.count, privacy: .public)") + #endif + return games } - } - guard fileCount > 0 else { return [] } + let channel = OTBChannel() + uploadChannel = channel + #if DEBUG + easyLinkClientLogger.debug("OTB upload channel installed") + #endif + let game = try await importNextOTBGame(from: channel, timeout: timeout) + games.append(game) + #if DEBUG + easyLinkClientLogger.debug("OTB game imported index=\(games.count, privacy: .public) positions=\(game.positions.count, privacy: .public)") + #endif + uploadChannel = nil + #if DEBUG + easyLinkClientLogger.debug("OTB upload channel cleared; marking imported file done") + #endif + try await transport.write(ProtocolConstants.fileImportDone) + } + } - var games: [OTBGame] = [] + private func withCommandGate( + _ operationName: String, + operation: () async throws -> T + ) async throws -> T { + #if DEBUG + easyLinkClientLogger.debug("Command gate acquire requested operation=\(operationName, privacy: .public)") + #endif + await commandGate.acquire(operationName: operationName) + #if DEBUG + easyLinkClientLogger.debug("Command gate acquired operation=\(operationName, privacy: .public)") + #endif + do { + let value = try await operation() + await commandGate.release(operationName: operationName) + #if DEBUG + easyLinkClientLogger.debug("Command gate released operation=\(operationName, privacy: .public)") + #endif + return value + } catch { + await commandGate.release(operationName: operationName) + #if DEBUG + easyLinkClientLogger.debug("Command gate released after error operation=\(operationName, privacy: .public) error=\(String(describing: error), privacy: .public)") + #endif + throw error + } + } - for _ in 0.. Int { + #if DEBUG + easyLinkClientLogger.debug("OTB waiting for file count response") + #endif + let response = try await responseRouter.wait( + matching: { bytes in + bytes.count >= 3 && bytes[0] == 0x32 && bytes[1] == 0x01 + }, + timeout: timeout + ) + #if DEBUG + easyLinkClientLogger.debug("OTB raw file count response len=\(response.count, privacy: .public) bytes=\(debugHex(response), privacy: .public)") + #endif + return Int(response[2]) + } - startFlagLoop: while true { - let notification = try await channel.next(timeout: timeout) - if case let .response(bytes) = notification, isOTBFlag(bytes, marker: 0xBE) { - break startFlagLoop - } + private func importNextOTBGame(from channel: OTBChannel, timeout: Duration) async throws -> OTBGame { + #if DEBUG + easyLinkClientLogger.debug("OTB readyForImport command") + #endif + try await transport.write(ProtocolConstants.readyForImport) + #if DEBUG + easyLinkClientLogger.debug("OTB startImport command") + #endif + try await transport.write(ProtocolConstants.startImport) + + var positions: [String] = [] + var didReceiveStartFlag = false + var latestMetadataDescription: String? + collectLoop: while true { + let notification: EasyLinkNotification + do { + notification = try await nextOTBNotification( + from: channel, + timeout: timeout, + pollResponseCharacteristic: didReceiveStartFlag + ) + } catch { + #if DEBUG + easyLinkClientLogger.error("OTB wait failed started=\(didReceiveStartFlag, privacy: .public) positions=\(positions.count, privacy: .public) metadata=\((latestMetadataDescription ?? ""), privacy: .public) error=\(String(describing: error), privacy: .public)") + #endif + throw error } + #if DEBUG + easyLinkClientLogger.debug("OTB collect notification \(debugDescription(notification), privacy: .public)") + #endif + + switch notification { + case let .response(bytes) where isOTBFlag(bytes, marker: 0xBE): + #if DEBUG + easyLinkClientLogger.debug("OTB start flag received startedBefore=\(didReceiveStartFlag, privacy: .public) positionsBefore=\(positions.count, privacy: .public)") + #endif + if !didReceiveStartFlag { + positions.removeAll() + didReceiveStartFlag = true + } - var positions: [String] = [] - collectLoop: while true { - switch try await channel.next(timeout: timeout) { - case let .fen(packet): - if let placement = try? EasyLinkCodec.decodePlacement(from: packet) { - positions.append(placement) - } - case let .response(bytes) where isOTBFlag(bytes, marker: 0xED): - break collectLoop - case .disconnected: - throw EasyLinkError.disconnected - default: - break + case let .response(bytes) where isOTBFlag(bytes, marker: 0xED): + #if DEBUG + easyLinkClientLogger.debug("OTB end flag received positions=\(positions.count, privacy: .public)") + #endif + break collectLoop + + case let .response(bytes) where isOTBFileMetadata(bytes): + latestMetadataDescription = otbFileMetadataDescription(bytes) + #if DEBUG + easyLinkClientLogger.debug("OTB file metadata received \(latestMetadataDescription ?? "", privacy: .public)") + #endif + + case .disconnected: + #if DEBUG + easyLinkClientLogger.error("OTB disconnected while collecting game") + #endif + throw EasyLinkError.disconnected + + default: + if let placement = placement(from: notification) { + positions.append(placement) + #if DEBUG + easyLinkClientLogger.debug("OTB placement appended count=\(positions.count, privacy: .public) placement=\(placement, privacy: .public)") + #endif + } else { + #if DEBUG + easyLinkClientLogger.debug("OTB ignored notification \(debugDescription(notification), privacy: .public)") + #endif } } + } - games.append(OTBGame(positions: positions)) - try await transport.write(ProtocolConstants.fileImportDone) + 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 pollTask = Task { [transport] in + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(250)) + guard !Task.isCancelled else { return } + guard let pollingTransport = transport as? EasyLinkResponsePollingTransport else { return } + await pollingTransport.pollResponseCharacteristic() + } } + defer { pollTask.cancel() } - return games + 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 isOTBFileMetadata(_ bytes: [UInt8]) -> Bool { + bytes.count >= 10 && bytes[0] == 0x36 && bytes[1] == 0x08 + } + + private nonisolated func otbFileMetadataDescription(_ bytes: [UInt8]) -> String? { + guard isOTBFileMetadata(bytes) else { return nil } + let byteCount = UInt32(bytes[2]) | + (UInt32(bytes[3]) << 8) | + (UInt32(bytes[4]) << 16) | + (UInt32(bytes[5]) << 24) + let token = UInt32(bytes[6]) | + (UInt32(bytes[7]) << 8) | + (UInt32(bytes[8]) << 16) | + (UInt32(bytes[9]) << 24) + #if DEBUG + return "bytes=\(byteCount) token=0x\(String(format: "%08X", token)) raw=\(debugHex(bytes))" + #else + return "bytes=\(byteCount) token=\(token)" + #endif + } + + 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 } + #if DEBUG + easyLinkClientLogger.debug("OTB forwarding notification to upload channel \(debugDescription(notification), privacy: .public)") + #endif await channel.receive(notification) return true } @@ -221,14 +467,27 @@ public actor EasyLinkClient { switch notification { case let .fen(packet): + #if DEBUG + easyLinkClientLogger.debug("Realtime FEN notification outside OTB len=\(packet.count, privacy: .public) bytes=\(debugHex(packet), privacy: .public)") + #endif if let placement = try? EasyLinkCodec.decodePlacement(from: packet) { fenContinuation.yield(placement) + } else { + #if DEBUG + easyLinkClientLogger.debug("Realtime FEN decode failed outside OTB") + #endif } case let .response(response): + #if DEBUG + easyLinkClientLogger.debug("Routing response outside OTB len=\(response.count, privacy: .public) bytes=\(debugHex(response), privacy: .public)") + #endif await responseRouter.receive(response) case .disconnected: + #if DEBUG + easyLinkClientLogger.debug("Notification task received disconnect") + #endif return } } @@ -246,6 +505,9 @@ private actor OTBChannel { private var waiters: [UUID: CheckedContinuation] = [:] func receive(_ notification: EasyLinkNotification) { + #if DEBUG + easyLinkClientLogger.debug("OTBChannel receive waiters=\(self.waiters.count, privacy: .public) buffer=\(self.buffer.count, privacy: .public) notification=\(debugDescription(notification), privacy: .public)") + #endif if let (id, waiter) = waiters.first { waiters.removeValue(forKey: id) waiter.resume(returning: notification) @@ -256,24 +518,42 @@ private actor OTBChannel { } 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 + do { + return 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() + #if DEBUG + easyLinkClientLogger.debug("OTBChannel next returning \(debugDescription(result), privacy: .public)") + #endif + return result } - group.cancelAll() - return result + } catch { + #if DEBUG + easyLinkClientLogger.error("OTBChannel next failed error=\(String(describing: error), privacy: .public) waiters=\(self.waiters.count, privacy: .public) buffer=\(self.buffer.count, privacy: .public)") + #endif + throw error } } private func nextWaiting() async throws -> EasyLinkNotification { - if !buffer.isEmpty { return buffer.removeFirst() } + if !buffer.isEmpty { + #if DEBUG + easyLinkClientLogger.debug("OTBChannel nextWaiting using buffered notification bufferBefore=\(self.buffer.count, privacy: .public)") + #endif + return buffer.removeFirst() + } let id = UUID() + #if DEBUG + easyLinkClientLogger.debug("OTBChannel nextWaiting parking waiter id=\(id.uuidString, privacy: .public)") + #endif return try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { continuation in waiters[id] = continuation @@ -284,6 +564,9 @@ private actor OTBChannel { } private func cancelWaiter(id: UUID) { + #if DEBUG + easyLinkClientLogger.debug("OTBChannel cancel waiter id=\(id.uuidString, privacy: .public)") + #endif waiters.removeValue(forKey: id)?.resume(throwing: CancellationError()) } } diff --git a/Sources/EasyLinkSwiftSDK/EasyLinkSwiftSDK.docc/OTBGameImport.md b/Sources/EasyLinkSwiftSDK/EasyLinkSwiftSDK.docc/OTBGameImport.md index bc89385..23a001b 100644 --- a/Sources/EasyLinkSwiftSDK/EasyLinkSwiftSDK.docc/OTBGameImport.md +++ b/Sources/EasyLinkSwiftSDK/EasyLinkSwiftSDK.docc/OTBGameImport.md @@ -4,10 +4,10 @@ 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, and collect each game as an ``OTBGame``. +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(timeout: .seconds(5)) +let games = try await client.importOTBGames() for game in games { for position in game.positions { @@ -41,7 +41,7 @@ if games.isEmpty { ## Timeout And Disconnects -The timeout applies while waiting for upload responses and FEN packets. If the board disconnects during import, the method throws ``EasyLinkError/disconnected``. +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 diff --git a/Sources/EasyLinkSwiftSDK/EasyLinkTransport.swift b/Sources/EasyLinkSwiftSDK/EasyLinkTransport.swift index 2ae1609..369752f 100644 --- a/Sources/EasyLinkSwiftSDK/EasyLinkTransport.swift +++ b/Sources/EasyLinkSwiftSDK/EasyLinkTransport.swift @@ -14,3 +14,11 @@ 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. + /// + /// CoreBluetooth still reports the value through `notifications`, so callers + /// should keep consuming the normal notification stream. + func pollResponseCharacteristic() async +} diff --git a/Tests/EasyLinkSwiftSDKTests/FakeTransport.swift b/Tests/EasyLinkSwiftSDKTests/FakeTransport.swift index f84c78b..8a10c52 100644 --- a/Tests/EasyLinkSwiftSDKTests/FakeTransport.swift +++ b/Tests/EasyLinkSwiftSDKTests/FakeTransport.swift @@ -6,12 +6,17 @@ actor FakeTransport: EasyLinkTransport { private nonisolated let continuation: AsyncStream.Continuation private var responseHandler: (@Sendable ([UInt8]) -> [UInt8]?)? + private var queuedResponses: [[UInt8]: [[UInt8]]] private(set) var writes: [[UInt8]] = [] private(set) var didConnect = false private(set) var didDisconnect = false - init(responseHandler: (@Sendable ([UInt8]) -> [UInt8]?)? = nil) { + init( + responseHandler: (@Sendable ([UInt8]) -> [UInt8]?)? = nil, + queuedResponses: [[UInt8]: [[UInt8]]] = [:] + ) { self.responseHandler = responseHandler + self.queuedResponses = queuedResponses var continuation: AsyncStream.Continuation! self.notifications = AsyncStream { streamContinuation in @@ -31,12 +36,22 @@ 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)) } } + 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 index 96030dc..77b803a 100644 --- a/Tests/EasyLinkSwiftSDKTests/OTBImportTests.swift +++ b/Tests/EasyLinkSwiftSDKTests/OTBImportTests.swift @@ -10,14 +10,21 @@ final class OTBImportTests: XCTestCase { } private func makeClient( + queuedResponses: [[UInt8]: [[UInt8]]] = [:], responseHandler: (@Sendable ([UInt8]) -> [UInt8]?)? = nil ) async throws -> (EasyLinkClient, FakeTransport) { - let transport = FakeTransport(responseHandler: responseHandler) + let transport = FakeTransport(responseHandler: responseHandler, queuedResponses: queuedResponses) 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 { @@ -42,9 +49,8 @@ final class OTBImportTests: XCTestCase { func testImportOTBGamesSendsCorrectCommandSequenceForOneGame() async throws { let placement = "8/8/8/8/8/8/8/8" - let (client, transport) = try await makeClient { command in + let (client, transport) = try await makeClient(queuedResponses: queuedCountResponses(1, 0)) { command in switch command { - case ProtocolConstants.queryFilesCount: [0x32, 0x01, 0x01] case ProtocolConstants.startImport: [0x37, 0x01, 0xBE] default: nil } @@ -61,14 +67,15 @@ final class OTBImportTests: XCTestCase { ProtocolConstants.readyForImport, ProtocolConstants.startImport, ProtocolConstants.fileImportDone, + ProtocolConstants.enableUploadMode, + ProtocolConstants.queryFilesCount, ]) } func testImportOTBGamesSingleGameWithOnePosition() async throws { let placement = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR" - let (client, transport) = try await makeClient { command in + let (client, transport) = try await makeClient(queuedResponses: queuedCountResponses(1, 0)) { command in switch command { - case ProtocolConstants.queryFilesCount: [0x32, 0x01, 0x01] case ProtocolConstants.startImport: [0x37, 0x01, 0xBE] default: nil } @@ -82,14 +89,79 @@ final class OTBImportTests: XCTestCase { 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 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 { command in + let (client, transport) = try await makeClient(queuedResponses: queuedCountResponses(1, 0)) { command in switch command { - case ProtocolConstants.queryFilesCount: [0x32, 0x01, 0x01] case ProtocolConstants.startImport: [0x37, 0x01, 0xBE] default: nil } @@ -106,9 +178,8 @@ final class OTBImportTests: XCTestCase { 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 { command in + let (client, transport) = try await makeClient(queuedResponses: queuedCountResponses(2, 1, 0)) { command in switch command { - case ProtocolConstants.queryFilesCount: [0x32, 0x01, 0x02] case ProtocolConstants.startImport: [0x37, 0x01, 0xBE] default: nil } @@ -127,9 +198,8 @@ final class OTBImportTests: XCTestCase { } func testImportOTBGamesThrowsOnDisconnectDuringCollection() async throws { - let (client, transport) = try await makeClient { command in + let (client, transport) = try await makeClient(queuedResponses: queuedCountResponses(1)) { command in switch command { - case ProtocolConstants.queryFilesCount: [0x32, 0x01, 0x01] case ProtocolConstants.startImport: [0x37, 0x01, 0xBE] default: nil } From 03823edc5c5459f4ca2a88abc43b75be389452d1 Mon Sep 17 00:00:00 2001 From: NSStudent Date: Sun, 19 Apr 2026 17:04:15 +0200 Subject: [PATCH 4/7] WIP: OTB Signed-off-by: NSStudent --- README.md | 24 +++ .../CoreBluetoothEasyLinkTransport.swift | 150 +++++++++++++++--- Sources/EasyLinkSwiftSDK/EasyLinkClient.swift | 28 +++- .../EasyLinkSwiftSDK/EasyLinkTransport.swift | 14 +- .../EasyLinkSwiftSDKTests/FakeTransport.swift | 19 ++- .../OTBImportTests.swift | 37 ++++- 6 files changed, 235 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 96636ce..8407769 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,30 @@ Each `OTBGame` contains FEN placement strings. Import mode pauses live FEN updat 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: diff --git a/Sources/EasyLinkSwiftSDK/CoreBluetoothEasyLinkTransport.swift b/Sources/EasyLinkSwiftSDK/CoreBluetoothEasyLinkTransport.swift index 3fafc0b..d4e5895 100644 --- a/Sources/EasyLinkSwiftSDK/CoreBluetoothEasyLinkTransport.swift +++ b/Sources/EasyLinkSwiftSDK/CoreBluetoothEasyLinkTransport.swift @@ -26,7 +26,7 @@ private func transportDebugProperties(_ properties: CBCharacteristicProperties) #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, EasyLinkResponsePollingTransport, @unchecked Sendable { +public final class CoreBluetoothEasyLinkTransport: NSObject, EasyLinkTransport, EasyLinkResponsePollingTransport, EasyLinkNotificationRearmingTransport, @unchecked Sendable { private static let minimumWriteInterval: TimeInterval = 0.2 public let notifications: AsyncStream @@ -45,6 +45,7 @@ public final class CoreBluetoothEasyLinkTransport: NSObject, EasyLinkTransport, 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 @@ -117,6 +118,7 @@ public final class CoreBluetoothEasyLinkTransport: NSObject, EasyLinkTransport, self.fenNotificationCharacteristic = nil self.responseNotificationCharacteristic = nil self.responsePollCount = 0 + self.lastErroredResponseValue = nil self.nextWriteDate = .distantPast self.peripheral = nil self.finishConnect(.failure(EasyLinkError.disconnected)) @@ -149,20 +151,10 @@ public final class CoreBluetoothEasyLinkTransport: NSObject, EasyLinkTransport, return } - guard characteristic.properties.contains(.read) else { - if self.responsePollCount == 0 { - #if DEBUG - easyLinkTransportLogger.debug("poll response unsupported; properties=\(transportDebugProperties(characteristic.properties), privacy: .public)") - #endif - } - self.responsePollCount += 1 - return - } - self.responsePollCount += 1 #if DEBUG if self.responsePollCount == 1 || self.responsePollCount.isMultiple(of: 20) { - easyLinkTransportLogger.debug("poll response readValue count=\(self.responsePollCount, privacy: .public)") + easyLinkTransportLogger.debug("poll response readValue count=\(self.responsePollCount, privacy: .public) properties=\(transportDebugProperties(characteristic.properties), privacy: .public)") } #endif peripheral.readValue(for: characteristic) @@ -170,6 +162,83 @@ public final class CoreBluetoothEasyLinkTransport: NSObject, EasyLinkTransport, } } + 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) @@ -197,15 +266,30 @@ public final class CoreBluetoothEasyLinkTransport: NSObject, EasyLinkTransport, return } + let writeType: CBCharacteristicWriteType = commandCharacteristic.properties.contains(.writeWithoutResponse) + ? .withoutResponse + : .withResponse + #if DEBUG - easyLinkTransportLogger.debug("perform write characteristic=\(commandCharacteristic.uuid.uuidString, privacy: .public) len=\(command.count, privacy: .public) bytes=\(transportDebugHex(command), privacy: .public)") + 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 - pendingWriteContinuations.append(continuation) - peripheral.writeValue( - Data(command), - for: commandCharacteristic, - type: .withResponse - ) + + 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() { @@ -372,6 +456,7 @@ extension CoreBluetoothEasyLinkTransport: CBCentralManagerDelegate { fenNotificationCharacteristic = nil responseNotificationCharacteristic = nil responsePollCount = 0 + lastErroredResponseValue = nil nextWriteDate = .distantPast self.peripheral = nil finishConnect(.failure(error ?? EasyLinkError.disconnected)) @@ -457,6 +542,7 @@ extension CoreBluetoothEasyLinkTransport: CBPeripheralDelegate { #endif responseNotificationCharacteristic = characteristic responsePollCount = 0 + lastErroredResponseValue = nil peripheral.setNotifyValue(true, for: characteristic) default: @@ -502,14 +588,36 @@ extension CoreBluetoothEasyLinkTransport: CBPeripheralDelegate { 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.error("didUpdateValue ignored characteristic=\(characteristic.uuid.uuidString, privacy: .public) error=\(String(describing: error), privacy: .public) hasValue=\((characteristic.value != nil), privacy: .public)") + 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 diff --git a/Sources/EasyLinkSwiftSDK/EasyLinkClient.swift b/Sources/EasyLinkSwiftSDK/EasyLinkClient.swift index acae966..59376c9 100644 --- a/Sources/EasyLinkSwiftSDK/EasyLinkClient.swift +++ b/Sources/EasyLinkSwiftSDK/EasyLinkClient.swift @@ -222,10 +222,6 @@ public actor EasyLinkClient { var games: [OTBGame] = [] while true { - #if DEBUG - easyLinkClientLogger.debug("OTB enable upload mode") - #endif - try await transport.write(ProtocolConstants.enableUploadMode) #if DEBUG easyLinkClientLogger.debug("OTB query file count command") #endif @@ -246,6 +242,16 @@ public actor EasyLinkClient { #if DEBUG easyLinkClientLogger.debug("OTB upload channel installed") #endif + #if DEBUG + easyLinkClientLogger.debug("OTB enable upload mode") + #endif + try await transport.write(ProtocolConstants.enableUploadMode) + if let rearmingTransport = transport as? EasyLinkNotificationRearmingTransport { + #if DEBUG + easyLinkClientLogger.debug("OTB rearm notifications after upload mode") + #endif + await rearmingTransport.rearmNotificationCharacteristics() + } let game = try await importNextOTBGame(from: channel, timeout: timeout) games.append(game) #if DEBUG @@ -341,6 +347,12 @@ public actor EasyLinkClient { if !didReceiveStartFlag { positions.removeAll() didReceiveStartFlag = true + if let rearmingTransport = transport as? EasyLinkNotificationRearmingTransport { + #if DEBUG + easyLinkClientLogger.debug("OTB rearm FEN notification after start flag") + #endif + await rearmingTransport.rearmFENNotificationCharacteristic() + } } case let .response(bytes) where isOTBFlag(bytes, marker: 0xED): @@ -387,11 +399,15 @@ public actor EasyLinkClient { return try await channel.next(timeout: timeout) } - let pollTask = Task { [transport] in + 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 } - guard let pollingTransport = transport as? EasyLinkResponsePollingTransport else { return } await pollingTransport.pollResponseCharacteristic() } } diff --git a/Sources/EasyLinkSwiftSDK/EasyLinkTransport.swift b/Sources/EasyLinkSwiftSDK/EasyLinkTransport.swift index 369752f..ddf9028 100644 --- a/Sources/EasyLinkSwiftSDK/EasyLinkTransport.swift +++ b/Sources/EasyLinkSwiftSDK/EasyLinkTransport.swift @@ -18,7 +18,17 @@ public protocol EasyLinkTransport: AnyObject, Sendable { protocol EasyLinkResponsePollingTransport: EasyLinkTransport { /// Requests a best-effort read of the response characteristic. /// - /// CoreBluetooth still reports the value through `notifications`, so callers - /// should keep consuming the normal notification stream. + /// 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/Tests/EasyLinkSwiftSDKTests/FakeTransport.swift b/Tests/EasyLinkSwiftSDKTests/FakeTransport.swift index 8a10c52..64c716d 100644 --- a/Tests/EasyLinkSwiftSDKTests/FakeTransport.swift +++ b/Tests/EasyLinkSwiftSDKTests/FakeTransport.swift @@ -1,22 +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: (@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: (@Sendable ([UInt8]) -> [UInt8]?)? = nil, - queuedResponses: [[UInt8]: [[UInt8]]] = [:] + queuedResponses: [[UInt8]: [[UInt8]]] = [:], + polledNotifications: [EasyLinkNotification] = [] ) { self.responseHandler = responseHandler self.queuedResponses = queuedResponses + self.polledNotifications = polledNotifications var continuation: AsyncStream.Continuation! self.notifications = AsyncStream { streamContinuation in @@ -42,6 +46,15 @@ actor FakeTransport: EasyLinkTransport { } } + 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 diff --git a/Tests/EasyLinkSwiftSDKTests/OTBImportTests.swift b/Tests/EasyLinkSwiftSDKTests/OTBImportTests.swift index 77b803a..e69479c 100644 --- a/Tests/EasyLinkSwiftSDKTests/OTBImportTests.swift +++ b/Tests/EasyLinkSwiftSDKTests/OTBImportTests.swift @@ -11,9 +11,14 @@ final class OTBImportTests: XCTestCase { private func makeClient( queuedResponses: [[UInt8]: [[UInt8]]] = [:], - responseHandler: (@Sendable ([UInt8]) -> [UInt8]?)? = nil + responseHandler: (@Sendable ([UInt8]) -> [UInt8]?)? = nil, + polledNotifications: [EasyLinkNotification] = [] ) async throws -> (EasyLinkClient, FakeTransport) { - let transport = FakeTransport(responseHandler: responseHandler, queuedResponses: queuedResponses) + let transport = FakeTransport( + responseHandler: responseHandler, + queuedResponses: queuedResponses, + polledNotifications: polledNotifications + ) let client = EasyLinkClient(profile: .classic, transport: transport) try await client.connect() return (client, transport) @@ -42,7 +47,6 @@ final class OTBImportTests: XCTestCase { _ = try await client.importOTBGames(timeout: .seconds(1)) let writes = await transport.writes XCTAssertEqual(writes, [ - ProtocolConstants.enableUploadMode, ProtocolConstants.queryFilesCount, ]) } @@ -62,12 +66,11 @@ final class OTBImportTests: XCTestCase { _ = try await importTask.value let writes = await transport.writes XCTAssertEqual(writes, [ - ProtocolConstants.enableUploadMode, ProtocolConstants.queryFilesCount, + ProtocolConstants.enableUploadMode, ProtocolConstants.readyForImport, ProtocolConstants.startImport, ProtocolConstants.fileImportDone, - ProtocolConstants.enableUploadMode, ProtocolConstants.queryFilesCount, ]) } @@ -106,6 +109,30 @@ final class OTBImportTests: XCTestCase { 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] From 851e0853171b09bda95f0291963c0ab13350cdd6 Mon Sep 17 00:00:00 2001 From: NSStudent Date: Tue, 21 Apr 2026 13:32:05 +0200 Subject: [PATCH 5/7] Fix: Subscribe to file service characteristic for OTB game import Signed-off-by: NSStudent --- .../CoreBluetoothEasyLinkTransport.swift | 27 ++++++++++++++++++- .../EasyLinkSwiftSDK/ProtocolConstants.swift | 2 ++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/Sources/EasyLinkSwiftSDK/CoreBluetoothEasyLinkTransport.swift b/Sources/EasyLinkSwiftSDK/CoreBluetoothEasyLinkTransport.swift index d4e5895..73b8e9d 100644 --- a/Sources/EasyLinkSwiftSDK/CoreBluetoothEasyLinkTransport.swift +++ b/Sources/EasyLinkSwiftSDK/CoreBluetoothEasyLinkTransport.swift @@ -41,6 +41,7 @@ public final class CoreBluetoothEasyLinkTransport: NSObject, EasyLinkTransport, 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 @@ -428,7 +429,8 @@ extension CoreBluetoothEasyLinkTransport: CBCentralManagerDelegate { #endif peripheral.discoverServices([ CBUUID(nsuuid: ProtocolConstants.fenService), - CBUUID(nsuuid: ProtocolConstants.operationService) + CBUUID(nsuuid: ProtocolConstants.operationService), + CBUUID(nsuuid: ProtocolConstants.fileService) ]) } @@ -455,6 +457,7 @@ extension CoreBluetoothEasyLinkTransport: CBCentralManagerDelegate { commandCharacteristic = nil fenNotificationCharacteristic = nil responseNotificationCharacteristic = nil + fileNotificationCharacteristic = nil responsePollCount = 0 lastErroredResponseValue = nil nextWriteDate = .distantPast @@ -496,6 +499,12 @@ extension CoreBluetoothEasyLinkTransport: CBPeripheralDelegate { for: service ) + case CBUUID(nsuuid: ProtocolConstants.fileService): + peripheral.discoverCharacteristics( + [CBUUID(nsuuid: ProtocolConstants.fileNotificationCharacteristic)], + for: service + ) + default: break } @@ -545,6 +554,13 @@ extension CoreBluetoothEasyLinkTransport: CBPeripheralDelegate { 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: break } @@ -576,6 +592,9 @@ extension CoreBluetoothEasyLinkTransport: CBPeripheralDelegate { case CBUUID(nsuuid: ProtocolConstants.responseCharacteristic): responseNotificationCharacteristic = characteristic + case CBUUID(nsuuid: ProtocolConstants.fileNotificationCharacteristic): + fileNotificationCharacteristic = characteristic + default: break } @@ -631,6 +650,12 @@ extension CoreBluetoothEasyLinkTransport: CBPeripheralDelegate { #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)") diff --git a/Sources/EasyLinkSwiftSDK/ProtocolConstants.swift b/Sources/EasyLinkSwiftSDK/ProtocolConstants.swift index 836bca1..f04afe2 100644 --- a/Sources/EasyLinkSwiftSDK/ProtocolConstants.swift +++ b/Sources/EasyLinkSwiftSDK/ProtocolConstants.swift @@ -6,6 +6,8 @@ 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] From 1099c6e42244479b924986c42538540a6f44681d Mon Sep 17 00:00:00 2001 From: NSStudent Date: Wed, 22 Apr 2026 19:20:17 +0200 Subject: [PATCH 6/7] cleanup: remove OTB debug logging and expand test coverage Signed-off-by: NSStudent --- Sources/EasyLinkSwiftSDK/EasyLinkClient.swift | 237 ++---------------- .../OTBImportTests.swift | 66 +++++ .../ProtocolConstantsTests.swift | 20 ++ 3 files changed, 113 insertions(+), 210 deletions(-) diff --git a/Sources/EasyLinkSwiftSDK/EasyLinkClient.swift b/Sources/EasyLinkSwiftSDK/EasyLinkClient.swift index 59376c9..bb55e50 100644 --- a/Sources/EasyLinkSwiftSDK/EasyLinkClient.swift +++ b/Sources/EasyLinkSwiftSDK/EasyLinkClient.swift @@ -1,58 +1,24 @@ import Foundation -#if DEBUG -import OSLog - -private let easyLinkClientLogger = Logger(subsystem: "EasyLinkSwiftSDK", category: "EasyLinkClient") - -private func debugHex(_ bytes: [UInt8]) -> String { - bytes.map { String(format: "%02X", $0) }.joined(separator: " ") -} - -private func debugDescription(_ notification: EasyLinkNotification) -> String { - switch notification { - case let .fen(packet): - "fen len=\(packet.count) bytes=\(debugHex(packet))" - case let .response(bytes): - "response len=\(bytes.count) bytes=\(debugHex(bytes))" - case .disconnected: - "disconnected" - } -} -#endif private actor CommandGate { private var isLocked = false private var waiters: [CheckedContinuation] = [] - func acquire(operationName: String) async { + func acquire() async { guard isLocked else { isLocked = true - #if DEBUG - easyLinkClientLogger.debug("CommandGate immediate acquire operation=\(operationName, privacy: .public)") - #endif return } - - #if DEBUG - easyLinkClientLogger.debug("CommandGate queued operation=\(operationName, privacy: .public) waiters=\(self.waiters.count, privacy: .public)") - #endif await withCheckedContinuation { continuation in waiters.append(continuation) } } - func release(operationName: String) { + func release() { guard !waiters.isEmpty else { isLocked = false - #if DEBUG - easyLinkClientLogger.debug("CommandGate unlocked operation=\(operationName, privacy: .public)") - #endif return } - - #if DEBUG - easyLinkClientLogger.debug("CommandGate resuming next operation=\(operationName, privacy: .public) remainingAfterResume=\(self.waiters.count - 1, privacy: .public)") - #endif waiters.removeFirst().resume() } } @@ -124,14 +90,14 @@ public actor EasyLinkClient { /// Enables realtime FEN notifications on the board. public func enableRealtimeUpdates() async throws { - try await withCommandGate("enableRealtimeUpdates") { + 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 { - try await withCommandGate("setLEDs") { + try await withCommandGate { let command: [UInt8] switch profile { case .classic: @@ -145,7 +111,7 @@ public actor EasyLinkClient { /// Requests the board battery status. public func batteryStatus(timeout: Duration = .seconds(3)) async throws -> BatteryStatus { - try await withCommandGate("batteryStatus") { + try await withCommandGate { try await transport.write(profile.batteryCommand) let profile = self.profile let response = try await responseRouter.wait( @@ -165,7 +131,7 @@ public actor EasyLinkClient { /// Starts a Chessnut Move auto-move operation from a FEN placement. public func setAutoMove(fen: String, force: Bool = true) async throws { - try await withCommandGate("setAutoMove") { + try await withCommandGate { guard profile == .move else { throw EasyLinkError.unsupportedCommand(profile) } @@ -175,7 +141,7 @@ public actor EasyLinkClient { /// Stops the current Chessnut Move auto-move operation. public func stopAutoMove() async throws { - try await withCommandGate("stopAutoMove") { + try await withCommandGate { guard profile == .move else { throw EasyLinkError.unsupportedCommand(profile) } @@ -185,7 +151,7 @@ public actor EasyLinkClient { /// Requests Chessnut Move piece status records. public func pieceStatus(timeout: Duration = .seconds(3)) async throws -> [PieceStatus] { - try await withCommandGate("pieceStatus") { + try await withCommandGate { guard profile == .move else { throw EasyLinkError.unsupportedCommand(profile) } @@ -207,7 +173,7 @@ public actor EasyLinkClient { /// 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("importOTBGames") { + try await withCommandGate { try await importOTBGamesLocked(timeout: timeout) } } @@ -215,174 +181,84 @@ public actor EasyLinkClient { private func importOTBGamesLocked(timeout: Duration) async throws -> [OTBGame] { defer { uploadChannel = nil } - #if DEBUG - easyLinkClientLogger.debug("OTB import started timeout=\(String(describing: timeout), privacy: .public)") - #endif - var games: [OTBGame] = [] while true { - #if DEBUG - easyLinkClientLogger.debug("OTB query file count command") - #endif try await transport.write(ProtocolConstants.queryFilesCount) let fileCount = try await nextFileCount(timeout: timeout) - #if DEBUG - easyLinkClientLogger.debug("OTB file count received count=\(fileCount, privacy: .public)") - #endif guard fileCount > 0 else { - #if DEBUG - easyLinkClientLogger.debug("OTB import finished totalGames=\(games.count, privacy: .public)") - #endif return games } let channel = OTBChannel() uploadChannel = channel - #if DEBUG - easyLinkClientLogger.debug("OTB upload channel installed") - #endif - #if DEBUG - easyLinkClientLogger.debug("OTB enable upload mode") - #endif try await transport.write(ProtocolConstants.enableUploadMode) if let rearmingTransport = transport as? EasyLinkNotificationRearmingTransport { - #if DEBUG - easyLinkClientLogger.debug("OTB rearm notifications after upload mode") - #endif await rearmingTransport.rearmNotificationCharacteristics() } let game = try await importNextOTBGame(from: channel, timeout: timeout) games.append(game) - #if DEBUG - easyLinkClientLogger.debug("OTB game imported index=\(games.count, privacy: .public) positions=\(game.positions.count, privacy: .public)") - #endif uploadChannel = nil - #if DEBUG - easyLinkClientLogger.debug("OTB upload channel cleared; marking imported file done") - #endif try await transport.write(ProtocolConstants.fileImportDone) } } private func withCommandGate( - _ operationName: String, operation: () async throws -> T ) async throws -> T { - #if DEBUG - easyLinkClientLogger.debug("Command gate acquire requested operation=\(operationName, privacy: .public)") - #endif - await commandGate.acquire(operationName: operationName) - #if DEBUG - easyLinkClientLogger.debug("Command gate acquired operation=\(operationName, privacy: .public)") - #endif + await commandGate.acquire() do { let value = try await operation() - await commandGate.release(operationName: operationName) - #if DEBUG - easyLinkClientLogger.debug("Command gate released operation=\(operationName, privacy: .public)") - #endif + await commandGate.release() return value } catch { - await commandGate.release(operationName: operationName) - #if DEBUG - easyLinkClientLogger.debug("Command gate released after error operation=\(operationName, privacy: .public) error=\(String(describing: error), privacy: .public)") - #endif + await commandGate.release() throw error } } private func nextFileCount(timeout: Duration) async throws -> Int { - #if DEBUG - easyLinkClientLogger.debug("OTB waiting for file count response") - #endif let response = try await responseRouter.wait( matching: { bytes in bytes.count >= 3 && bytes[0] == 0x32 && bytes[1] == 0x01 }, timeout: timeout ) - #if DEBUG - easyLinkClientLogger.debug("OTB raw file count response len=\(response.count, privacy: .public) bytes=\(debugHex(response), privacy: .public)") - #endif return Int(response[2]) } private func importNextOTBGame(from channel: OTBChannel, timeout: Duration) async throws -> OTBGame { - #if DEBUG - easyLinkClientLogger.debug("OTB readyForImport command") - #endif try await transport.write(ProtocolConstants.readyForImport) - #if DEBUG - easyLinkClientLogger.debug("OTB startImport command") - #endif try await transport.write(ProtocolConstants.startImport) var positions: [String] = [] var didReceiveStartFlag = false - var latestMetadataDescription: String? collectLoop: while true { - let notification: EasyLinkNotification - do { - notification = try await nextOTBNotification( - from: channel, - timeout: timeout, - pollResponseCharacteristic: didReceiveStartFlag - ) - } catch { - #if DEBUG - easyLinkClientLogger.error("OTB wait failed started=\(didReceiveStartFlag, privacy: .public) positions=\(positions.count, privacy: .public) metadata=\((latestMetadataDescription ?? ""), privacy: .public) error=\(String(describing: error), privacy: .public)") - #endif - throw error - } - #if DEBUG - easyLinkClientLogger.debug("OTB collect notification \(debugDescription(notification), privacy: .public)") - #endif + let notification = try await nextOTBNotification( + from: channel, + timeout: timeout, + pollResponseCharacteristic: didReceiveStartFlag + ) switch notification { case let .response(bytes) where isOTBFlag(bytes, marker: 0xBE): - #if DEBUG - easyLinkClientLogger.debug("OTB start flag received startedBefore=\(didReceiveStartFlag, privacy: .public) positionsBefore=\(positions.count, privacy: .public)") - #endif if !didReceiveStartFlag { positions.removeAll() didReceiveStartFlag = true if let rearmingTransport = transport as? EasyLinkNotificationRearmingTransport { - #if DEBUG - easyLinkClientLogger.debug("OTB rearm FEN notification after start flag") - #endif await rearmingTransport.rearmFENNotificationCharacteristic() } } case let .response(bytes) where isOTBFlag(bytes, marker: 0xED): - #if DEBUG - easyLinkClientLogger.debug("OTB end flag received positions=\(positions.count, privacy: .public)") - #endif break collectLoop - case let .response(bytes) where isOTBFileMetadata(bytes): - latestMetadataDescription = otbFileMetadataDescription(bytes) - #if DEBUG - easyLinkClientLogger.debug("OTB file metadata received \(latestMetadataDescription ?? "", privacy: .public)") - #endif - case .disconnected: - #if DEBUG - easyLinkClientLogger.error("OTB disconnected while collecting game") - #endif throw EasyLinkError.disconnected default: if let placement = placement(from: notification) { positions.append(placement) - #if DEBUG - easyLinkClientLogger.debug("OTB placement appended count=\(positions.count, privacy: .public) placement=\(placement, privacy: .public)") - #endif - } else { - #if DEBUG - easyLinkClientLogger.debug("OTB ignored notification \(debugDescription(notification), privacy: .public)") - #endif } } } @@ -420,27 +296,6 @@ public actor EasyLinkClient { bytes.count >= 3 && bytes[0] == 0x37 && bytes[1] == 0x01 && bytes[2] == marker } - private nonisolated func isOTBFileMetadata(_ bytes: [UInt8]) -> Bool { - bytes.count >= 10 && bytes[0] == 0x36 && bytes[1] == 0x08 - } - - private nonisolated func otbFileMetadataDescription(_ bytes: [UInt8]) -> String? { - guard isOTBFileMetadata(bytes) else { return nil } - let byteCount = UInt32(bytes[2]) | - (UInt32(bytes[3]) << 8) | - (UInt32(bytes[4]) << 16) | - (UInt32(bytes[5]) << 24) - let token = UInt32(bytes[6]) | - (UInt32(bytes[7]) << 8) | - (UInt32(bytes[8]) << 16) | - (UInt32(bytes[9]) << 24) - #if DEBUG - return "bytes=\(byteCount) token=0x\(String(format: "%08X", token)) raw=\(debugHex(bytes))" - #else - return "bytes=\(byteCount) token=\(token)" - #endif - } - private nonisolated func isPlacementPacket(_ bytes: [UInt8]) -> Bool { bytes.count >= 34 && bytes[0] == 0x01 } @@ -458,9 +313,6 @@ public actor EasyLinkClient { private func tryForwardToUploadChannel(_ notification: EasyLinkNotification) async -> Bool { guard let channel = uploadChannel else { return false } - #if DEBUG - easyLinkClientLogger.debug("OTB forwarding notification to upload channel \(debugDescription(notification), privacy: .public)") - #endif await channel.receive(notification) return true } @@ -483,27 +335,14 @@ public actor EasyLinkClient { switch notification { case let .fen(packet): - #if DEBUG - easyLinkClientLogger.debug("Realtime FEN notification outside OTB len=\(packet.count, privacy: .public) bytes=\(debugHex(packet), privacy: .public)") - #endif if let placement = try? EasyLinkCodec.decodePlacement(from: packet) { fenContinuation.yield(placement) - } else { - #if DEBUG - easyLinkClientLogger.debug("Realtime FEN decode failed outside OTB") - #endif } case let .response(response): - #if DEBUG - easyLinkClientLogger.debug("Routing response outside OTB len=\(response.count, privacy: .public) bytes=\(debugHex(response), privacy: .public)") - #endif await responseRouter.receive(response) case .disconnected: - #if DEBUG - easyLinkClientLogger.debug("Notification task received disconnect") - #endif return } } @@ -521,9 +360,6 @@ private actor OTBChannel { private var waiters: [UUID: CheckedContinuation] = [:] func receive(_ notification: EasyLinkNotification) { - #if DEBUG - easyLinkClientLogger.debug("OTBChannel receive waiters=\(self.waiters.count, privacy: .public) buffer=\(self.buffer.count, privacy: .public) notification=\(debugDescription(notification), privacy: .public)") - #endif if let (id, waiter) = waiters.first { waiters.removeValue(forKey: id) waiter.resume(returning: notification) @@ -534,42 +370,26 @@ private actor OTBChannel { } func next(timeout: Duration) async throws -> EasyLinkNotification { - do { - return 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() - #if DEBUG - easyLinkClientLogger.debug("OTBChannel next returning \(debugDescription(result), privacy: .public)") - #endif - return result + 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 } - } catch { - #if DEBUG - easyLinkClientLogger.error("OTBChannel next failed error=\(String(describing: error), privacy: .public) waiters=\(self.waiters.count, privacy: .public) buffer=\(self.buffer.count, privacy: .public)") - #endif - throw error + guard let result = try await group.next() else { + throw EasyLinkError.timeout + } + group.cancelAll() + return result } } private func nextWaiting() async throws -> EasyLinkNotification { if !buffer.isEmpty { - #if DEBUG - easyLinkClientLogger.debug("OTBChannel nextWaiting using buffered notification bufferBefore=\(self.buffer.count, privacy: .public)") - #endif return buffer.removeFirst() } let id = UUID() - #if DEBUG - easyLinkClientLogger.debug("OTBChannel nextWaiting parking waiter id=\(id.uuidString, privacy: .public)") - #endif return try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { continuation in waiters[id] = continuation @@ -580,9 +400,6 @@ private actor OTBChannel { } private func cancelWaiter(id: UUID) { - #if DEBUG - easyLinkClientLogger.debug("OTBChannel cancel waiter id=\(id.uuidString, privacy: .public)") - #endif waiters.removeValue(forKey: id)?.resume(throwing: CancellationError()) } } diff --git a/Tests/EasyLinkSwiftSDKTests/OTBImportTests.swift b/Tests/EasyLinkSwiftSDKTests/OTBImportTests.swift index e69479c..b4b78f8 100644 --- a/Tests/EasyLinkSwiftSDKTests/OTBImportTests.swift +++ b/Tests/EasyLinkSwiftSDKTests/OTBImportTests.swift @@ -254,6 +254,72 @@ final class OTBImportTests: XCTestCase { } } + 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 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+")) From 36dd2afaef2958ab2f9c093d30e2b4e49367d7da Mon Sep 17 00:00:00 2001 From: NSStudent Date: Wed, 22 Apr 2026 19:26:57 +0200 Subject: [PATCH 7/7] chore: exclude test files from codecov coverage report Signed-off-by: NSStudent --- codecov.yml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 codecov.yml 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/**"