chessnut_move/transport/mod.rs
1// Copyright 2026 Daymon Littrell-Reyes
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Runtime-neutral sessions and notification routing for Chessnut Move boards.
16//!
17//! A transport implementation maps [`Command`][protocol::Command] values and
18//! [`NotificationSource`][transport::NotificationSource] values to a Bluetooth library. The
19//! session types own protocol buffers and perform command encoding,
20//! notification decoding, [initialization][protocol::Command::enable_realtime_updates],
21//! and shutdown.
22//!
23//! # Examples
24//!
25//! A custom adapter tags bytes with their originating characteristic before
26//! passing them to the shared decoder:
27//!
28//! ```
29//! use chessnut_move::protocol::{BatteryStatus, BoardEvent};
30//! use chessnut_move::transport::{
31//! DecodedNotification, Notification, NotificationSource,
32//! decode_notification,
33//! };
34//!
35//! let bytes = [0x41, 0x03, 0x0c, 0x00, 64];
36//! let notification =
37//! Notification::new(NotificationSource::CommandResponse, &bytes);
38//! let decoded = decode_notification(notification)?;
39//!
40//! assert_eq!(
41//! decoded,
42//! DecodedNotification::Event(BoardEvent::BatteryStatus(BatteryStatus {
43//! charging: false,
44//! percentage: 64,
45//! })),
46//! );
47//! # Ok::<(), chessnut_move::transport::DecodeNotificationError>(())
48//! ```
49#![doc = "# Session APIs"]
50#![cfg_attr(
51 feature = "async",
52 doc = "- [`Board`][transport::Board] is the default runtime-neutral async session."
53)]
54#![cfg_attr(
55 feature = "blocking",
56 doc = "- [`BlockingBoard`][transport::BlockingBoard] provides the synchronous session."
57)]
58#![cfg_attr(
59 feature = "tokio",
60 doc = "- The [`tokio`][transport::tokio] module provides a cloneable actor handle and event streams."
61)]
62
63use thiserror::Error;
64
65#[cfg(doc)]
66use crate::protocol;
67use crate::protocol::{
68 BoardEvent, DecodePositionNotificationError, DecodeResponseNotificationError,
69 decode_position_notification, decode_response_notification,
70};
71
72#[cfg(feature = "async")]
73mod asynchronous;
74#[cfg(feature = "blocking")]
75mod blocking;
76#[cfg(feature = "btleplug")]
77pub mod btleplug;
78pub mod gatt;
79#[cfg(feature = "tokio")]
80pub mod tokio;
81
82#[cfg(feature = "async")]
83pub use asynchronous::{AsyncBoard, AsyncTransport};
84#[cfg(feature = "blocking")]
85pub use blocking::{BlockingBoard, BlockingTransport};
86#[cfg(feature = "tokio")]
87pub use tokio::TokioTransport;
88
89/// Header observed on the board's undocumented realtime-enable acknowledgement.
90///
91/// The acknowledgement can arrive after command-response subscription even
92/// when that subscription follows the enable write, so every session decoder
93/// recognizes and consumes it.
94const REALTIME_UPDATES_ACKNOWLEDGEMENT: u8 = 0x23;
95
96/// Default runtime-neutral asynchronous board session.
97///
98/// This is an alias for [`AsyncBoard`].
99#[cfg(feature = "async")]
100pub type Board<T> = AsyncBoard<T>;
101
102/// Size of the largest notification currently defined by the Move protocol.
103///
104/// Keeping this bounded lets board sessions receive notifications without
105/// requiring `alloc`.
106pub const MAX_NOTIFICATION_LEN: usize = 3 + 34 * 4;
107
108/// Identifies which Chessnut Move characteristic produced a notification.
109#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
110pub enum NotificationSource {
111 /// Realtime packed position notifications.
112 ///
113 /// This maps to [`gatt::Characteristic::PositionNotification`].
114 Position,
115
116 /// Battery, tracked-piece, and session-control responses.
117 ///
118 /// This maps to [`gatt::Characteristic::CommandResponse`].
119 CommandResponse,
120}
121
122impl NotificationSource {
123 /// Returns the GATT characteristic associated with this notification source.
124 ///
125 /// # Examples
126 ///
127 /// ```
128 /// use chessnut_move::transport::gatt::Characteristic;
129 /// use chessnut_move::transport::NotificationSource;
130 ///
131 /// assert_eq!(
132 /// NotificationSource::Position.characteristic(),
133 /// Characteristic::PositionNotification,
134 /// );
135 /// ```
136 pub const fn characteristic(self) -> gatt::Characteristic {
137 match self {
138 Self::Position => gatt::Characteristic::PositionNotification,
139 Self::CommandResponse => gatt::Characteristic::CommandResponse,
140 }
141 }
142}
143
144/// A borrowed notification returned by a transport implementation.
145#[derive(Clone, Copy, Debug, PartialEq, Eq)]
146pub struct Notification<'a> {
147 source: NotificationSource,
148 bytes: &'a [u8],
149}
150
151impl<'a> Notification<'a> {
152 /// Creates a notification tagged with its originating characteristic.
153 ///
154 /// The returned value borrows `bytes`; transports normally borrow from the
155 /// receive buffer supplied to their notification method.
156 ///
157 /// # Examples
158 ///
159 /// ```
160 /// use chessnut_move::transport::{Notification, NotificationSource};
161 ///
162 /// let bytes = [0x23, 0x01, 0x00];
163 /// let notification =
164 /// Notification::new(NotificationSource::CommandResponse, &bytes);
165 /// assert_eq!(notification.bytes(), &bytes);
166 /// ```
167 pub const fn new(source: NotificationSource, bytes: &'a [u8]) -> Self {
168 Self { source, bytes }
169 }
170
171 /// Returns the characteristic category that produced this notification.
172 pub const fn source(&self) -> NotificationSource {
173 self.source
174 }
175
176 /// Returns the complete borrowed notification payload.
177 pub const fn bytes(&self) -> &'a [u8] {
178 self.bytes
179 }
180}
181
182/// Reports why a tagged transport notification could not be decoded.
183#[derive(Clone, Copy, Debug, PartialEq, Eq, Error)]
184pub enum DecodeNotificationError {
185 /// A position notification was malformed.
186 #[error(transparent)]
187 Position(#[from] DecodePositionNotificationError),
188
189 /// A command-response notification was malformed or unsupported.
190 #[error(transparent)]
191 CommandResponse(#[from] DecodeResponseNotificationError),
192}
193
194/// A protocol notification decoded by a board session.
195///
196/// Control acknowledgements are represented separately because they are part
197/// of session management rather than observable board state.
198///
199/// The event variant is intentionally stored inline to keep notification
200/// decoding available without `alloc`.
201#[allow(clippy::large_enum_variant)]
202#[derive(Clone, Copy, Debug, PartialEq, Eq)]
203pub enum DecodedNotification {
204 /// A notification that represents observable board data.
205 Event(BoardEvent),
206
207 /// The board acknowledged the realtime-update command.
208 ///
209 /// Sessions consume this acknowledgement after sending
210 /// [`Command::enable_realtime_updates`][protocol::Command::enable_realtime_updates].
211 RealtimeUpdatesAcknowledged,
212}
213
214/// An error produced while operating a board session.
215#[derive(Debug, Error)]
216pub enum BoardError<E> {
217 /// The transport failed while subscribing, writing, receiving, or closing.
218 #[error("transport error: {0}")]
219 Transport(E),
220
221 /// A received notification could not be decoded.
222 #[error(transparent)]
223 Decode(#[from] DecodeNotificationError),
224}
225
226/// Decodes a tagged transport notification into an event or session-control
227/// acknowledgement.
228///
229/// # Examples
230///
231/// ```
232/// use chessnut_move::protocol::{BatteryStatus, BoardEvent};
233/// use chessnut_move::transport::{
234/// DecodedNotification, Notification, NotificationSource,
235/// decode_notification,
236/// };
237///
238/// let bytes = [0x41, 0x03, 0x0c, 0x00, 64];
239/// let decoded = decode_notification(Notification::new(
240/// NotificationSource::CommandResponse,
241/// &bytes,
242/// ))?;
243/// assert_eq!(
244/// decoded,
245/// DecodedNotification::Event(BoardEvent::BatteryStatus(BatteryStatus {
246/// charging: false,
247/// percentage: 64,
248/// })),
249/// );
250/// # Ok::<(), chessnut_move::transport::DecodeNotificationError>(())
251/// ```
252///
253/// # Errors
254///
255/// Returns [`DecodeNotificationError::Position`] when position bytes are
256/// malformed, or [`DecodeNotificationError::CommandResponse`] when response
257/// bytes are malformed or unsupported.
258pub fn decode_notification(
259 notification: Notification<'_>,
260) -> Result<DecodedNotification, DecodeNotificationError> {
261 let _span = trace_span!(
262 "decode_notification",
263 source = ?notification.source,
264 notification_len = notification.bytes.len()
265 );
266 let decoded = match notification.source {
267 NotificationSource::Position => decode_position_notification(notification.bytes)
268 .map(BoardEvent::PositionChanged)
269 .map(DecodedNotification::Event)
270 .map_err(Into::into),
271 NotificationSource::CommandResponse
272 if notification.bytes.first().copied() == Some(REALTIME_UPDATES_ACKNOWLEDGEMENT) =>
273 {
274 Ok(DecodedNotification::RealtimeUpdatesAcknowledged)
275 }
276 NotificationSource::CommandResponse => decode_response_notification(notification.bytes)
277 .map(DecodedNotification::Event)
278 .map_err(Into::into),
279 };
280
281 match &decoded {
282 Ok(DecodedNotification::RealtimeUpdatesAcknowledged) => {
283 debug_event!("received realtime-update acknowledgement");
284 }
285 Ok(DecodedNotification::Event(BoardEvent::PositionChanged(_))) => {
286 trace_event!(event = "position_changed", "decoded board event");
287 }
288 Ok(DecodedNotification::Event(BoardEvent::BatteryStatus(_))) => {
289 trace_event!(event = "battery_status", "decoded board event");
290 }
291 Ok(DecodedNotification::Event(BoardEvent::PieceStatus(_))) => {
292 trace_event!(event = "piece_status", "decoded board event");
293 }
294 Err(_error) => {
295 trace_event!(error = ?_error, "notification decoding failed");
296 }
297 }
298
299 decoded
300}
301
302#[cfg(any(feature = "async", feature = "blocking"))]
303/// Shared storage for runtime-neutral owning sessions.
304///
305/// The fixed notification buffer keeps polling allocation-free and is reused
306/// for every call to the transport.
307pub(crate) struct BoardState<T> {
308 pub(crate) transport: T,
309 pub(crate) notification_buffer: [u8; MAX_NOTIFICATION_LEN],
310}
311
312#[cfg(any(feature = "async", feature = "blocking"))]
313impl<T> BoardState<T> {
314 /// Creates session storage around a transport.
315 pub(crate) const fn new(transport: T) -> Self {
316 Self {
317 transport,
318 notification_buffer: [0; MAX_NOTIFICATION_LEN],
319 }
320 }
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326 use crate::protocol::BatteryStatus;
327
328 #[test]
329 fn decodes_notifications_according_to_their_source() {
330 let response = [0x41, 0x03, 0x0c, 0x01, 74];
331 let event = decode_notification(Notification::new(
332 NotificationSource::CommandResponse,
333 &response,
334 ));
335
336 assert_eq!(
337 event,
338 Ok(DecodedNotification::Event(BoardEvent::BatteryStatus(
339 BatteryStatus {
340 charging: true,
341 percentage: 74,
342 },
343 )))
344 );
345 }
346
347 #[test]
348 fn recognizes_realtime_update_acknowledgement() {
349 let notification = Notification::new(NotificationSource::CommandResponse, &[0x23, 0x01, 0x00]);
350
351 assert_eq!(
352 decode_notification(notification),
353 Ok(DecodedNotification::RealtimeUpdatesAcknowledged)
354 );
355 }
356}