Skip to main content

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}