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}