Adapter & transport protocols¶
BoardKit separates two concerns into two protocols:
BoardAdapter— pure wire framing. A value type that accumulates raw bytes, parses frames, and emits[BoardEvent]. No I/O, no async, no concurrency primitives. Lives in the library target.BoardTransport— platform radio. A reference type that owns the BLE or USB-HID link, holds aBoardAdapter, and exposes anAsyncStream<BoardEvent>to the session layer. Lives in the app target whereCoreBluetoothorSkipFuseis available.
BoardAdapter¶
BoardAdapter is a Sendable protocol. Every concrete adapter is a value
type (struct) so the test harness can copy it cheaply without heap allocation.
public protocol BoardAdapter: Sendable {
var capabilities: BoardCapabilities { get }
var minimumWriteInterval: TimeInterval { get }
mutating func feed(bytes: Data) -> [BoardEvent]
func encode(_ command: BoardCommand) -> Data?
func handshakeCommands(isReconnect: Bool)
-> [(command: BoardCommand, delayBefore: TimeInterval)]
// Default: empty. Ack-based protocols like ChessUp override it —
// the transport must drain it after every feed(bytes:).
mutating func takePendingResponses() -> [Data]
}
capabilities¶
Queried once at connect time. The session reads this immediately after connecting and routes events through the appropriate kernel path (occupancy-only vs. identity-aware).
feed(bytes:)¶
The adapter accumulates partial frame bytes across calls (framing is
stateful). Emits events only when a complete frame boundary is reached.
Must be called serially — concurrent calls to a mutating function on a
value type are undefined behavior. An empty Data is safe (no-op).
encode(_:)¶
Returns the wire bytes for a BoardCommand, or nil when the command is
unsupported for this board. The transport silently skips nil results.
minimumWriteInterval¶
The transport serializes every outgoing adapter payload through one writer and
leaves at least this many seconds between physical writes. This includes normal
commands, handshake commands, and values returned by
takePendingResponses(). The default is zero. ChessnutAdapter declares
0.2 seconds because classic Chessnut firmware requires a 200 ms write floor;
stored-game import can queue three responses from one incoming frame, so those
responses must not be written as a synchronous burst.
handshakeCommands(isReconnect:)¶
Returns the ordered handshake sequence the transport executes after a link is established. Returning the sequence (rather than executing it) keeps the adapter pure and testable without a live transport or timer.
isReconnect: true for a restored link (BLE drop-and-reconnect),
false for the initial fresh connection.
takePendingResponses()¶
Mandatory wire-level responses the adapter queued while parsing the most
recent feed(bytes:) input. The default implementation returns [];
ack-based protocols override it. The ChessUp board retransmits every 0xA3
move frame until the host writes a 0x21 ack (and board-side promotions
until a 0x23 ack) — leaving them unacked floods the notify pipe until the
BLE link drops. The transport MUST call this immediately after every
feed(bytes:) and enqueue each returned payload on the same serialized, paced
writer used for normal commands. Draining is destructive: each queued response
is returned exactly once.
// Chessnut Air — first connect:
// [(command: .startSession, delayBefore: 0)]
// Square Off — first connect:
// [(command: .startSession, delayBefore: 0.25),
// (command: .requestState, delayBefore: 0.15)]
BoardTransport¶
BoardTransport is a @MainActor protocol (conformers are @Observable
classes observed by SwiftUI or SkipFuse). Its implementation lives in the app
target; BoardKit defines only the protocol.
@MainActor
public protocol BoardTransport: AnyObject {
var state: BoardTransportState { get }
var discovered: [DiscoveredBoardDevice] { get }
var connectedDeviceName: String? { get }
var lastError: String? { get }
var events: AsyncStream<BoardEvent> { get }
func startScan()
func stopScan()
func connect(_ device: DiscoveredBoardDevice)
func disconnect()
func send(_ command: BoardCommand)
}
The transport's responsibility is:
- On raw BLE data arrival: call
adapter.feed(bytes:)→ yield eachBoardEventintoevents. After each feed, calladapter.takePendingResponses()and write every returned payload to the board's write characteristic through the same serialized writer used bysend(_:). - On link established: yield
.connected, executeadapter.handshakeCommands(isReconnect:)with inter-command delays and the adapter'sminimumWriteIntervalfloor, then letadapter.feed()emit.readywhen the board ACKs. - On link drop: yield
.disconnected(error:).
┌─────────────┐ raw bytes ┌──────────────┐ BoardEvent ┌──────────────┐
│ BLE radio │ ──────────► │ BoardAdapter │ ────────────► │BoardTransport│
└─────────────┘ └──────────────┘ │ .events │
└──────────────┘
BoardTransportState¶
public enum BoardTransportState: Equatable, Sendable {
case poweredOff
case unauthorized
case idle
case scanning
case connecting
case connected
case disconnected
case reconnecting(attempt: Int)
}
.connected means the transport has a writable link and its notification
subscription is active. It deliberately precedes the adapter handshake; wait
for BoardEvent.ready before treating the board's sensor stream as ready.
The .reconnecting(attempt:) case is 1-indexed. Pair it with the
maxAttempts of the BoardReconnectPolicy driving the loop if you want to
report progress.
DiscoveredBoardDevice¶
public struct DiscoveredBoardDevice: Identifiable, Equatable, Sendable {
public let id: UUID // stable OS-assigned peripheral UUID
public let name: String // advertised device name ("Chessnut Air", …)
public let rssi: Int // signal strength in dBm — use for proximity sorting
public let token: String // opaque transport token; do not interpret
}
Discovered devices are listed in transport.discovered during a scan.
Pass one to transport.connect(_:) to initiate a connection.