Skip to main content

chessnut_move/transport/
asynchronous.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//! Allocation-free asynchronous session built on a runtime-neutral transport.
16
17use crate::protocol::{BoardEvent, Command};
18#[cfg(doc)]
19use crate::transport;
20use crate::transport::{
21  BoardError, BoardState, DecodedNotification, Notification, NotificationSource,
22  decode_notification,
23};
24
25/// An asynchronous I/O adapter for a connected Chessnut Move board.
26///
27/// Implementations are responsible only for mapping commands and notification
28/// sources to their BLE library. Protocol encoding and decoding stay in this
29/// crate.
30///
31/// The returned futures are not required to implement [`Send`], allowing the
32/// transport to run on local and embedded executors.
33#[cfg_attr(
34  feature = "tokio",
35  doc = "Implement [`TokioTransport`][transport::TokioTransport] instead when the transport must run in a spawned Tokio task."
36)]
37///
38/// # Examples
39///
40/// ```
41/// use core::convert::Infallible;
42/// use chessnut_move::protocol::Command;
43/// use chessnut_move::transport::{
44///     AsyncTransport, Notification, NotificationSource,
45/// };
46///
47/// struct MyTransport;
48///
49/// impl AsyncTransport for MyTransport {
50///     type Error = Infallible;
51///
52///     async fn subscribe(
53///         &mut self,
54///         _source: NotificationSource,
55///     ) -> Result<(), Self::Error> {
56///         Ok(())
57///     }
58///
59///     async fn write_command(
60///         &mut self,
61///         _command: &Command,
62///     ) -> Result<(), Self::Error> {
63///         Ok(())
64///     }
65///
66///     async fn next_notification<'a>(
67///         &'a mut self,
68///         buffer: &'a mut [u8],
69///     ) -> Result<Notification<'a>, Self::Error> {
70///         buffer[..34].fill(0);
71///         Ok(Notification::new(
72///             NotificationSource::Position,
73///             &buffer[..34],
74///         ))
75///     }
76/// }
77/// ```
78#[allow(async_fn_in_trait)]
79pub trait AsyncTransport {
80  /// Error returned by Bluetooth or adapter operations.
81  type Error;
82
83  /// Enables notifications for one protocol source.
84  ///
85  /// # Errors
86  ///
87  /// Returns [`Self::Error`] when the adapter cannot enable the corresponding
88  /// [GATT characteristic][NotificationSource::characteristic].
89  async fn subscribe(&mut self, source: NotificationSource) -> Result<(), Self::Error>;
90
91  /// Disables notifications for one protocol source.
92  ///
93  /// The default implementation performs no operation.
94  ///
95  /// # Errors
96  ///
97  /// Returns [`Self::Error`] when the adapter cannot disable the corresponding
98  /// [GATT characteristic][NotificationSource::characteristic].
99  async fn unsubscribe(&mut self, _source: NotificationSource) -> Result<(), Self::Error> {
100    Ok(())
101  }
102
103  /// Writes an encoded command using its
104  /// [required write kind][Command::write_kind].
105  ///
106  /// # Errors
107  ///
108  /// Returns [`Self::Error`] when the command cannot be written.
109  async fn write_command(&mut self, command: &Command) -> Result<(), Self::Error>;
110
111  /// Receives the next notification into the supplied buffer.
112  ///
113  /// The returned [`Notification`] must borrow its bytes from `buffer`.
114  /// Implementations must return an error instead of truncating a notification
115  /// that exceeds the buffer.
116  ///
117  /// # Errors
118  ///
119  /// Returns [`Self::Error`] when notification delivery ends, Bluetooth I/O
120  /// fails, the source is unknown, or the supplied buffer is too small.
121  async fn next_notification<'a>(
122    &'a mut self,
123    buffer: &'a mut [u8],
124  ) -> Result<Notification<'a>, Self::Error>;
125
126  /// Closes transport-owned resources after subscriptions are disabled.
127  ///
128  /// The default implementation performs no operation.
129  ///
130  /// # Errors
131  ///
132  /// Returns [`Self::Error`] when transport cleanup fails.
133  async fn close(&mut self) -> Result<(), Self::Error> {
134    Ok(())
135  }
136}
137
138/// An owning, allocation-free asynchronous session with a Chessnut Move board.
139///
140/// The session reuses a fixed notification buffer and requires exclusive
141/// mutable access for commands and events. Dropping a session does not call
142/// [`AsyncTransport::unsubscribe`] or [`AsyncTransport::close`]; call
143/// [`AsyncBoard::shutdown`] when cleanup is required.
144///
145/// # Examples
146///
147/// The transport must already represent a connected board:
148///
149/// ```no_run
150/// use chessnut_move::protocol::{BoardEvent, Command, LedPattern};
151/// use chessnut_move::transport::{AsyncBoard, AsyncTransport, BoardError};
152///
153/// async fn run<T: AsyncTransport>(
154///     transport: T,
155/// ) -> Result<(), BoardError<T::Error>> {
156///     let mut board = AsyncBoard::new(transport);
157///     board.initialize().await?;
158///
159///     board.send(&Command::set_leds(&LedPattern::default())).await?;
160///     match board.next_event().await? {
161///         BoardEvent::PositionChanged(position) => {
162///             println!("position: {position:?}");
163///         }
164///         BoardEvent::BatteryStatus(_) | BoardEvent::PieceStatus(_) => {}
165///     }
166///
167///     board.shutdown().await
168/// }
169/// ```
170pub struct AsyncBoard<T> {
171  state: BoardState<T>,
172}
173
174impl<T> AsyncBoard<T> {
175  /// Creates a board session around a connected transport.
176  ///
177  /// This constructor performs no I/O. Call [`AsyncBoard::initialize`] before
178  /// receiving events.
179  ///
180  /// # Examples
181  ///
182  /// ```
183  /// use chessnut_move::transport::{AsyncBoard, AsyncTransport};
184  ///
185  /// fn open_session<T: AsyncTransport>(transport: T) -> AsyncBoard<T> {
186  ///     AsyncBoard::new(transport)
187  /// }
188  /// ```
189  pub const fn new(transport: T) -> Self {
190    Self {
191      state: BoardState::new(transport),
192    }
193  }
194
195  /// Returns a shared reference to the underlying transport.
196  pub const fn transport(&self) -> &T {
197    &self.state.transport
198  }
199
200  /// Returns a mutable reference to the underlying transport.
201  ///
202  /// Direct operations can change subscription or connection state expected by
203  /// the session.
204  pub fn transport_mut(&mut self) -> &mut T {
205    &mut self.state.transport
206  }
207
208  /// Consumes the session and returns its transport without shutting it down.
209  pub fn into_transport(self) -> T {
210    self.state.transport
211  }
212}
213
214impl<T: AsyncTransport> AsyncBoard<T> {
215  /// Initializes subscriptions and enables realtime position updates.
216  ///
217  /// Initialization subscribes to position notifications, writes
218  /// [`Command::enable_realtime_updates`], and then subscribes to command
219  /// responses. Completed steps are not rolled back when a later step fails.
220  ///
221  /// # Examples
222  ///
223  /// ```no_run
224  /// use chessnut_move::transport::{AsyncBoard, AsyncTransport, BoardError};
225  ///
226  /// # async fn initialize<T: AsyncTransport>(
227  /// #     board: &mut AsyncBoard<T>,
228  /// # ) -> Result<(), BoardError<T::Error>> {
229  /// board.initialize().await?;
230  /// # Ok(())
231  /// # }
232  /// ```
233  ///
234  /// # Errors
235  ///
236  /// Returns [`BoardError::Transport`] when subscribing or writing the
237  /// realtime-update command fails.
238  pub async fn initialize(&mut self) -> Result<(), BoardError<T::Error>> {
239    debug_event!(session = "async", "initializing board session");
240    self
241      .state
242      .transport
243      .subscribe(NotificationSource::Position)
244      .await
245      .map_err(|error| {
246        warn_event!(
247          session = "async",
248          stage = "subscribe_position",
249          "board initialization failed"
250        );
251        BoardError::Transport(error)
252      })?;
253    self.send(&Command::enable_realtime_updates()).await?;
254    self
255      .state
256      .transport
257      .subscribe(NotificationSource::CommandResponse)
258      .await
259      .map_err(|error| {
260        warn_event!(
261          session = "async",
262          stage = "subscribe_command_response",
263          "board initialization failed"
264        );
265        BoardError::Transport(error)
266      })?;
267    debug_event!(session = "async", "board session initialized");
268    Ok(())
269  }
270
271  /// Writes a command to the board.
272  ///
273  /// This method confirms only the transport write. Commands with protocol
274  /// responses are received separately through [`AsyncBoard::next_event`].
275  ///
276  /// # Errors
277  ///
278  /// Returns [`BoardError::Transport`] when the transport cannot write the
279  /// command.
280  pub async fn send(&mut self, command: &Command) -> Result<(), BoardError<T::Error>> {
281    trace_event!(
282      session = "async",
283      command_len = command.bytes().len(),
284      write_kind = ?command.write_kind(),
285      "writing board command"
286    );
287    self
288      .state
289      .transport
290      .write_command(command)
291      .await
292      .map_err(|error| {
293        warn_event!(session = "async", "board command write failed");
294        BoardError::Transport(error)
295      })
296  }
297
298  /// Waits for and decodes the next observable board event.
299  ///
300  /// Session-control acknowledgements are consumed internally and are not
301  /// returned as events.
302  ///
303  /// # Errors
304  ///
305  /// Returns [`BoardError::Transport`] when notification delivery fails, or
306  /// [`BoardError::Decode`] when a notification is malformed or unsupported.
307  pub async fn next_event(&mut self) -> Result<BoardEvent, BoardError<T::Error>> {
308    loop {
309      let notification = self
310        .state
311        .transport
312        .next_notification(&mut self.state.notification_buffer)
313        .await
314        .map_err(|error| {
315          warn_event!(session = "async", "notification receive failed");
316          BoardError::Transport(error)
317        })?;
318      trace_event!(
319        session = "async",
320        source = ?notification.source(),
321        notification_len = notification.bytes().len(),
322        "received board notification"
323      );
324
325      match decode_notification(notification)? {
326        DecodedNotification::Event(event) => return Ok(event),
327        DecodedNotification::RealtimeUpdatesAcknowledged => {}
328      }
329    }
330  }
331
332  /// Unsubscribes from board notifications and closes the transport.
333  ///
334  /// Cleanup stops at the first failed operation. Consuming the session with
335  /// [`AsyncBoard::into_transport`] remains available after an error.
336  ///
337  /// # Errors
338  ///
339  /// Returns [`BoardError::Transport`] when either unsubscribe operation or
340  /// [`AsyncTransport::close`] fails.
341  pub async fn shutdown(&mut self) -> Result<(), BoardError<T::Error>> {
342    debug_event!(session = "async", "shutting down board session");
343    self
344      .state
345      .transport
346      .unsubscribe(NotificationSource::CommandResponse)
347      .await
348      .map_err(|error| {
349        warn_event!(
350          session = "async",
351          stage = "unsubscribe_command_response",
352          "board shutdown failed"
353        );
354        BoardError::Transport(error)
355      })?;
356    self
357      .state
358      .transport
359      .unsubscribe(NotificationSource::Position)
360      .await
361      .map_err(|error| {
362        warn_event!(
363          session = "async",
364          stage = "unsubscribe_position",
365          "board shutdown failed"
366        );
367        BoardError::Transport(error)
368      })?;
369    self.state.transport.close().await.map_err(|error| {
370      warn_event!(session = "async", stage = "close", "board shutdown failed");
371      BoardError::Transport(error)
372    })?;
373    debug_event!(session = "async", "board session stopped");
374    Ok(())
375  }
376}
377
378#[cfg(test)]
379mod tests {
380  use core::convert::Infallible;
381
382  use futures_executor::block_on;
383
384  use super::*;
385  use crate::protocol::{BatteryStatus, WriteKind};
386
387  struct MockTransport {
388    subscriptions: [Option<NotificationSource>; 2],
389    subscription_count: usize,
390    written: [u8; 35],
391    written_len: usize,
392    write_kind: Option<WriteKind>,
393    notification_count: usize,
394  }
395
396  impl MockTransport {
397    const fn new() -> Self {
398      Self {
399        subscriptions: [None; 2],
400        subscription_count: 0,
401        written: [0; 35],
402        written_len: 0,
403        write_kind: None,
404        notification_count: 0,
405      }
406    }
407  }
408
409  impl AsyncTransport for MockTransport {
410    type Error = Infallible;
411
412    async fn subscribe(&mut self, source: NotificationSource) -> Result<(), Self::Error> {
413      self.subscriptions[self.subscription_count] = Some(source);
414      self.subscription_count += 1;
415      Ok(())
416    }
417
418    async fn write_command(&mut self, command: &Command) -> Result<(), Self::Error> {
419      self.written[..command.bytes().len()].copy_from_slice(command.bytes());
420      self.written_len = command.bytes().len();
421      self.write_kind = Some(command.write_kind());
422      Ok(())
423    }
424
425    async fn next_notification<'a>(
426      &'a mut self,
427      buffer: &'a mut [u8],
428    ) -> Result<Notification<'a>, Self::Error> {
429      let notification: &[u8] = if self.notification_count == 0 {
430        &[0x23, 0x01, 0x00]
431      } else {
432        &[0x41, 0x03, 0x0c, 0x00, 63]
433      };
434      self.notification_count += 1;
435      buffer[..notification.len()].copy_from_slice(notification);
436      Ok(Notification::new(
437        NotificationSource::CommandResponse,
438        &buffer[..notification.len()],
439      ))
440    }
441  }
442
443  #[test]
444  fn initializes_and_decodes_events_without_allocating() {
445    block_on(async {
446      let mut board = AsyncBoard::new(MockTransport::new());
447      board.initialize().await.unwrap();
448
449      assert_eq!(
450        board.transport().subscriptions,
451        [
452          Some(NotificationSource::Position),
453          Some(NotificationSource::CommandResponse),
454        ]
455      );
456      assert_eq!(
457        &board.transport().written[..board.transport().written_len],
458        Command::enable_realtime_updates().bytes()
459      );
460      assert_eq!(board.transport().write_kind, Some(WriteKind::WithResponse));
461
462      assert_eq!(
463        board.next_event().await.unwrap(),
464        BoardEvent::BatteryStatus(BatteryStatus {
465          charging: false,
466          percentage: 63,
467        })
468      );
469    });
470  }
471}