Skip to main content

chessnut_move/transport/
tokio.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//! Tokio actor for concurrent access to a Chessnut Move board.
16//!
17//! [`spawn`] owns a [`TokioTransport`] in one task and returns a cloneable
18//! [`BoardHandle`]. Commands from multiple handles are serialized, request and
19//! response helpers are correlated one at a time, and every decoded
20//! [`BoardEvent`] is published to each [`EventStream`] subscriber.
21//!
22//! Transport failures stop the actor and are retained in [`ActorExit`].
23//! Malformed protocol notifications are reported through
24//! [`EventStreamError::Decode`] without stopping the actor.
25
26use core::future::Future;
27use core::pin::Pin;
28use core::task::{Context, Poll, ready};
29use std::collections::VecDeque;
30use std::time::Duration;
31
32use thiserror::Error;
33use tokio_stream::wrappers::BroadcastStream;
34use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
35use tokio_stream::{Stream, StreamExt};
36
37use crate::protocol::{BatteryStatus, BoardEvent, Command, PieceStatus};
38#[cfg(doc)]
39use crate::transport;
40use crate::transport::{
41  DecodeNotificationError, DecodedNotification, MAX_NOTIFICATION_LEN, Notification,
42  NotificationSource, decode_notification,
43};
44
45/// A transport whose operations can run inside a task spawned by Tokio.
46///
47/// Unlike [`AsyncTransport`][transport::AsyncTransport], every returned future is
48/// explicitly `Send`. This keeps the runtime-neutral trait usable by local and
49/// embedded executors.
50///
51/// # Examples
52///
53/// ```
54/// use core::convert::Infallible;
55/// use chessnut_move::protocol::Command;
56/// use chessnut_move::transport::tokio::TokioTransport;
57/// use chessnut_move::transport::{Notification, NotificationSource};
58///
59/// struct MyTransport;
60///
61/// impl TokioTransport for MyTransport {
62///     type Error = Infallible;
63///
64///     async fn subscribe(
65///         &mut self,
66///         _source: NotificationSource,
67///     ) -> Result<(), Self::Error> {
68///         Ok(())
69///     }
70///
71///     async fn write_command(
72///         &mut self,
73///         _command: &Command,
74///     ) -> Result<(), Self::Error> {
75///         Ok(())
76///     }
77///
78///     async fn next_notification<'a>(
79///         &'a mut self,
80///         buffer: &'a mut [u8],
81///     ) -> Result<Notification<'a>, Self::Error> {
82///         buffer[..34].fill(0);
83///         Ok(Notification::new(
84///             NotificationSource::Position,
85///             &buffer[..34],
86///         ))
87///     }
88/// }
89/// ```
90pub trait TokioTransport: Send + 'static {
91  /// Error returned by Bluetooth or adapter operations.
92  type Error: Send + 'static;
93
94  /// Enables notifications for one protocol source.
95  ///
96  /// # Errors
97  ///
98  /// Returns [`Self::Error`] when the adapter cannot enable the corresponding
99  /// [GATT characteristic][NotificationSource::characteristic].
100  fn subscribe(
101    &mut self,
102    source: NotificationSource,
103  ) -> impl Future<Output = Result<(), Self::Error>> + Send + '_;
104
105  /// Disables notifications for one protocol source.
106  ///
107  /// The default implementation performs no operation.
108  ///
109  /// # Errors
110  ///
111  /// Returns [`Self::Error`] when the adapter cannot disable the corresponding
112  /// [GATT characteristic][NotificationSource::characteristic].
113  fn unsubscribe(
114    &mut self,
115    _source: NotificationSource,
116  ) -> impl Future<Output = Result<(), Self::Error>> + Send + '_ {
117    async { Ok(()) }
118  }
119
120  /// Writes an encoded command using its
121  /// [required write kind][Command::write_kind].
122  ///
123  /// # Errors
124  ///
125  /// Returns [`Self::Error`] when the command cannot be written.
126  fn write_command<'a>(
127    &'a mut self,
128    command: &'a Command,
129  ) -> impl Future<Output = Result<(), Self::Error>> + Send + 'a;
130
131  /// Receives the next notification into the supplied buffer.
132  ///
133  /// The returned [`Notification`] must borrow its bytes from `buffer`.
134  /// Implementations must return an error instead of truncating a notification
135  /// that exceeds the buffer.
136  ///
137  /// # Errors
138  ///
139  /// Returns [`Self::Error`] when notification delivery ends, Bluetooth I/O
140  /// fails, the source is unknown, or the supplied buffer is too small.
141  fn next_notification<'a>(
142    &'a mut self,
143    buffer: &'a mut [u8],
144  ) -> impl Future<Output = Result<Notification<'a>, Self::Error>> + Send + 'a;
145
146  /// Closes transport-owned resources after subscriptions are disabled.
147  ///
148  /// The default implementation performs no operation.
149  ///
150  /// # Errors
151  ///
152  /// Returns [`Self::Error`] when transport cleanup fails.
153  fn close(&mut self) -> impl Future<Output = Result<(), Self::Error>> + Send + '_ {
154    async { Ok(()) }
155  }
156}
157
158/// Queue capacities and timeout behavior for a board actor.
159///
160/// # Examples
161///
162/// ```
163/// use std::time::Duration;
164/// use chessnut_move::transport::tokio::ActorConfig;
165///
166/// let config = ActorConfig {
167///     request_timeout: Duration::from_secs(10),
168///     ..ActorConfig::default()
169/// };
170/// assert_eq!(config.request_timeout, Duration::from_secs(10));
171/// ```
172#[derive(Clone, Copy, Debug, PartialEq, Eq)]
173pub struct ActorConfig {
174  /// Maximum number of handle messages waiting for the actor.
175  pub command_capacity: usize,
176
177  /// Number of events retained for each lag-aware subscriber.
178  pub event_capacity: usize,
179
180  /// Maximum number of queries waiting behind the active query.
181  pub query_capacity: usize,
182
183  /// Time allowed for a battery or piece-status response.
184  pub request_timeout: Duration,
185}
186
187impl Default for ActorConfig {
188  fn default() -> Self {
189    Self {
190      command_capacity: 32,
191      event_capacity: 64,
192      query_capacity: 32,
193      request_timeout: Duration::from_secs(5),
194    }
195  }
196}
197
198/// Reports invalid actor configuration supplied to [`spawn`].
199#[derive(Clone, Copy, Debug, PartialEq, Eq, Error)]
200pub enum SpawnError {
201  /// [`ActorConfig::command_capacity`] was zero.
202  #[error("command channel capacity must be greater than zero")]
203  ZeroCommandCapacity,
204
205  /// [`ActorConfig::event_capacity`] was zero.
206  #[error("event channel capacity must be greater than zero")]
207  ZeroEventCapacity,
208}
209
210/// Current lifecycle phase of the board actor.
211#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
212pub enum LifecycleState {
213  /// The actor is subscribing and enabling realtime updates.
214  Starting,
215
216  /// Initialization completed and handle requests are accepted.
217  Running,
218
219  /// The actor is rejecting new requests and cleaning up the transport.
220  ShuttingDown,
221
222  /// The actor completed without a fatal error.
223  Stopped,
224
225  /// Initialization, transport I/O, or cleanup failed.
226  Faulted,
227}
228
229/// Reports why a [`BoardHandle`] operation could not complete.
230#[derive(Clone, Copy, Debug, PartialEq, Eq, Error)]
231pub enum HandleError {
232  /// The actor task or its request channel has ended.
233  #[error("the board actor has stopped")]
234  ActorStopped,
235
236  /// Graceful shutdown has started.
237  #[error("the board actor is shutting down")]
238  ShuttingDown,
239
240  /// A transport operation failed.
241  ///
242  /// The underlying transport error is available from [`ActorExit::result`].
243  #[error("the transport operation failed; inspect BoardTask for the underlying error")]
244  TransportFailed,
245
246  /// A query did not receive its matching response before the configured timeout.
247  #[error("the board request timed out")]
248  RequestTimedOut,
249
250  /// The bounded queue for serialized queries is full.
251  #[error("the pending query queue is full")]
252  QueryQueueFull,
253}
254
255/// Reports a recoverable or terminal condition while consuming an [`EventStream`].
256#[derive(Clone, Copy, Debug, PartialEq, Eq, Error)]
257pub enum EventStreamError {
258  /// The subscriber fell behind and older events were discarded.
259  #[error("event subscriber lagged and missed {0} events")]
260  Lagged(u64),
261
262  /// A malformed notification was skipped while the actor remained running.
263  #[error(transparent)]
264  Decode(#[from] DecodeNotificationError),
265
266  /// The actor closed the event channel.
267  #[error("the board event stream is closed")]
268  Closed,
269}
270
271/// Fatal error retained when the board actor terminates.
272#[derive(Debug, Error)]
273pub enum ActorError<E> {
274  /// A transport operation failed during initialization, operation, or cleanup.
275  #[error("transport error: {0}")]
276  Transport(E),
277}
278
279/// A cloneable command and request handle for a running board actor.
280///
281/// Clones share one bounded request channel. Dropping the final handle closes
282/// that channel; the actor then performs normal transport cleanup. Keep the
283/// corresponding [`BoardTask`] when the final [`ActorExit`] result or transport
284/// is needed.
285///
286/// # Examples
287///
288/// ```no_run
289/// use chessnut_move::protocol::{Command, LedPattern};
290/// use chessnut_move::transport::tokio::{BoardHandle, HandleError};
291///
292/// # async fn turn_off_leds(board: &BoardHandle) -> Result<(), HandleError> {
293/// board.send(Command::set_leds(&LedPattern::default())).await
294/// # }
295/// ```
296#[derive(Clone)]
297pub struct BoardHandle {
298  request_tx: ::tokio::sync::mpsc::Sender<Message>,
299  lifecycle_rx: ::tokio::sync::watch::Receiver<LifecycleState>,
300}
301
302impl BoardHandle {
303  /// Returns the most recently observed actor lifecycle state.
304  ///
305  /// # Examples
306  ///
307  /// ```no_run
308  /// use chessnut_move::transport::tokio::{BoardHandle, LifecycleState};
309  ///
310  /// # fn inspect(board: &BoardHandle) {
311  /// if board.lifecycle() == LifecycleState::Running {
312  ///     println!("board actor is ready");
313  /// }
314  /// # }
315  /// ```
316  pub fn lifecycle(&self) -> LifecycleState {
317    *self.lifecycle_rx.borrow()
318  }
319
320  /// Subscribes to lifecycle changes from the actor.
321  ///
322  /// The returned
323  /// [`tokio::sync::watch::Receiver`](https://docs.rs/tokio/1/tokio/sync/watch/struct.Receiver.html)
324  /// immediately exposes the current state and retains only the latest state.
325  pub fn subscribe_lifecycle(&self) -> ::tokio::sync::watch::Receiver<LifecycleState> {
326    self.lifecycle_rx.clone()
327  }
328
329  /// Sends a command and waits for the transport write to finish.
330  ///
331  /// Protocol responses are published separately through [`EventStream`].
332  /// Prefer [`BoardHandle::battery_status`] and
333  /// [`BoardHandle::piece_status`] when sending the corresponding queries so
334  /// the actor can correlate the response.
335  ///
336  /// # Errors
337  ///
338  /// Returns [`HandleError::ShuttingDown`] after shutdown begins,
339  /// [`HandleError::ActorStopped`] when the actor is no longer reachable, or
340  /// [`HandleError::TransportFailed`] when the write fails.
341  pub async fn send(&self, command: Command) -> Result<(), HandleError> {
342    trace_event!(
343      command_len = command.bytes().len(),
344      write_kind = ?command.write_kind(),
345      "submitting command to board actor"
346    );
347    let (reply_tx, reply_rx) = ::tokio::sync::oneshot::channel();
348    self
349      .send_message(Message::Send {
350        command,
351        reply: reply_tx,
352      })
353      .await?;
354    receive_reply(reply_rx).await?
355  }
356
357  /// Queries and returns the board's battery status.
358  ///
359  /// This helper sends [`Command::read_battery_level`] and correlates the next
360  /// matching response.
361  ///
362  /// Queries are serialized because Move responses do not contain request IDs.
363  /// The decoded response is also published as
364  /// [`BoardEvent::BatteryStatus`].
365  ///
366  /// # Examples
367  ///
368  /// ```no_run
369  /// use chessnut_move::transport::tokio::{BoardHandle, HandleError};
370  ///
371  /// async fn report_battery(board: &BoardHandle) -> Result<(), HandleError> {
372  ///     let status = board.battery_status().await?;
373  ///     let state = if status.charging { "charging" } else { "on battery" };
374  ///     println!("{}% ({state})", status.percentage);
375  ///     Ok(())
376  /// }
377  /// ```
378  ///
379  /// # Errors
380  ///
381  /// Returns [`HandleError::QueryQueueFull`] when the query queue is full,
382  /// [`HandleError::RequestTimedOut`] when no matching response arrives,
383  /// [`HandleError::TransportFailed`] when the query write fails,
384  /// [`HandleError::ShuttingDown`] after shutdown begins, or
385  /// [`HandleError::ActorStopped`] when the actor is no longer reachable.
386  pub async fn battery_status(&self) -> Result<BatteryStatus, HandleError> {
387    debug_event!(query = "battery_status", "submitting board query");
388    let (reply_tx, reply_rx) = ::tokio::sync::oneshot::channel();
389    self
390      .send_message(Message::Query(QueryRequest::Battery(reply_tx)))
391      .await?;
392    receive_reply(reply_rx).await?
393  }
394
395  /// Queries and returns the status of all tracked physical pieces.
396  ///
397  /// This helper sends [`Command::read_piece_status`] and correlates the next
398  /// matching response.
399  ///
400  /// Queries are serialized because Move responses do not contain request IDs.
401  /// The decoded response is also published as
402  /// [`BoardEvent::PieceStatus`].
403  ///
404  /// # Examples
405  ///
406  /// ```no_run
407  /// use chessnut_move::transport::tokio::{BoardHandle, HandleError};
408  ///
409  /// async fn report_low_pieces(board: &BoardHandle) -> Result<(), HandleError> {
410  ///     let status = board.piece_status().await?;
411  ///
412  ///     for tracked in status
413  ///         .pieces
414  ///         .iter()
415  ///         .filter(|piece| piece.battery_percentage.is_some_and(|level| level < 20))
416  ///     {
417  ///         println!("{:?} is low", tracked.piece);
418  ///     }
419  ///
420  ///     Ok(())
421  /// }
422  /// ```
423  ///
424  /// # Errors
425  ///
426  /// Returns [`HandleError::QueryQueueFull`] when the query queue is full,
427  /// [`HandleError::RequestTimedOut`] when no matching response arrives,
428  /// [`HandleError::TransportFailed`] when the query write fails,
429  /// [`HandleError::ShuttingDown`] after shutdown begins, or
430  /// [`HandleError::ActorStopped`] when the actor is no longer reachable.
431  pub async fn piece_status(&self) -> Result<PieceStatus, HandleError> {
432    debug_event!(query = "piece_status", "submitting board query");
433    let (reply_tx, reply_rx) = ::tokio::sync::oneshot::channel();
434    self
435      .send_message(Message::Query(QueryRequest::Pieces(reply_tx)))
436      .await?;
437    receive_reply(reply_rx).await?
438  }
439
440  /// Creates a new lag-aware stream of subsequently published board events.
441  ///
442  /// Events published before this request is processed are not replayed.
443  ///
444  /// # Examples
445  ///
446  /// ```no_run
447  /// use std::error::Error;
448  /// use chessnut_move::protocol::BoardEvent;
449  /// use chessnut_move::transport::tokio::BoardHandle;
450  ///
451  /// async fn watch_moves(board: &BoardHandle) -> Result<(), Box<dyn Error>> {
452  ///     let mut events = board.subscribe_events().await?;
453  ///
454  ///     loop {
455  ///         if let BoardEvent::PositionChanged(position) = events.recv().await? {
456  ///             println!("position: {position:?}");
457  ///         }
458  ///     }
459  /// }
460  /// ```
461  ///
462  /// # Errors
463  ///
464  /// Returns [`HandleError::ShuttingDown`] after shutdown begins or
465  /// [`HandleError::ActorStopped`] when the actor is no longer reachable.
466  pub async fn subscribe_events(&self) -> Result<EventStream, HandleError> {
467    debug_event!("subscribing to board actor events");
468    let (reply_tx, reply_rx) = ::tokio::sync::oneshot::channel();
469    self
470      .send_message(Message::SubscribeEvents { reply: reply_tx })
471      .await?;
472    receive_reply(reply_rx).await?
473  }
474
475  /// Requests graceful actor shutdown and waits for transport cleanup.
476  ///
477  /// The actor unsubscribes from command responses, unsubscribes from position
478  /// notifications, and then calls [`TokioTransport::close`]. Other handle
479  /// clones stop accepting requests once shutdown begins.
480  ///
481  /// # Examples
482  ///
483  /// ```no_run
484  /// use chessnut_move::transport::tokio::{BoardHandle, HandleError};
485  ///
486  /// async fn stop(board: &BoardHandle) -> Result<(), HandleError> {
487  ///     board.shutdown().await
488  /// }
489  /// ```
490  ///
491  /// # Errors
492  ///
493  /// Returns [`HandleError::TransportFailed`] when cleanup fails,
494  /// [`HandleError::ShuttingDown`] when another shutdown is already in
495  /// progress, or [`HandleError::ActorStopped`] when the actor is no longer
496  /// reachable.
497  pub async fn shutdown(&self) -> Result<(), HandleError> {
498    info_event!("requesting graceful board actor shutdown");
499    let (reply_tx, reply_rx) = ::tokio::sync::oneshot::channel();
500    self
501      .send_message(Message::Shutdown { reply: reply_tx })
502      .await?;
503    receive_reply(reply_rx).await?
504  }
505
506  /// Checks lifecycle state and enqueues one handle message.
507  async fn send_message(&self, message: Message) -> Result<(), HandleError> {
508    let lifecycle = self.lifecycle();
509    match lifecycle {
510      LifecycleState::Starting | LifecycleState::Running => {}
511      LifecycleState::ShuttingDown => {
512        debug_event!(?lifecycle, "board actor rejected handle request");
513        return Err(HandleError::ShuttingDown);
514      }
515      LifecycleState::Stopped | LifecycleState::Faulted => {
516        debug_event!(?lifecycle, "board actor rejected handle request");
517        return Err(HandleError::ActorStopped);
518      }
519    }
520
521    self.request_tx.send(message).await.map_err(|_| {
522      debug_event!("board actor request channel is closed");
523      HandleError::ActorStopped
524    })
525  }
526}
527
528/// A lag-aware stream for events published by the board actor.
529///
530/// The stream implements
531/// [`tokio_stream::Stream`](https://docs.rs/tokio-stream/0.1/tokio_stream/trait.Stream.html)
532/// with
533/// `Item = Result<BoardEvent, EventStreamError>`. Decode and lag errors are
534/// recoverable; consumers can continue polling after receiving either one.
535/// The stream ends after the actor closes its event channel.
536pub struct EventStream {
537  inner: BroadcastStream<Result<BoardEvent, DecodeNotificationError>>,
538}
539
540impl EventStream {
541  /// Wraps one broadcast subscriber as a public lag-aware stream.
542  fn new(
543    receiver: ::tokio::sync::broadcast::Receiver<Result<BoardEvent, DecodeNotificationError>>,
544  ) -> Self {
545    Self {
546      inner: BroadcastStream::new(receiver),
547    }
548  }
549
550  /// Receives the next event or stream condition.
551  ///
552  /// # Examples
553  ///
554  /// ```no_run
555  /// use chessnut_move::protocol::BoardEvent;
556  /// use chessnut_move::transport::tokio::{
557  ///     EventStream, EventStreamError,
558  /// };
559  ///
560  /// async fn next_position(
561  ///     events: &mut EventStream,
562  /// ) -> Result<(), EventStreamError> {
563  ///     loop {
564  ///         let event = events.recv().await?;
565  ///         if let BoardEvent::PositionChanged(position) = event {
566  ///             println!("position: {position:?}");
567  ///             return Ok(());
568  ///         }
569  ///     }
570  /// }
571  /// ```
572  ///
573  /// # Errors
574  ///
575  /// Returns [`EventStreamError::Lagged`] when older events were discarded,
576  /// [`EventStreamError::Decode`] when a malformed notification was skipped,
577  /// or [`EventStreamError::Closed`] after the actor closes the channel.
578  pub async fn recv(&mut self) -> Result<BoardEvent, EventStreamError> {
579    self.next().await.unwrap_or(Err(EventStreamError::Closed))
580  }
581}
582
583impl Stream for EventStream {
584  type Item = Result<BoardEvent, EventStreamError>;
585
586  fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
587    let item = ready!(Pin::new(&mut self.inner).poll_next(cx));
588    Poll::Ready(match item {
589      Some(Ok(Ok(event))) => Some(Ok(event)),
590      Some(Ok(Err(error))) => Some(Err(EventStreamError::Decode(error))),
591      Some(Err(BroadcastStreamRecvError::Lagged(count))) => {
592        warn_event!(missed_events = count, "board event subscriber lagged");
593        Some(Err(EventStreamError::Lagged(count)))
594      }
595      None => {
596        debug_event!("board event stream closed");
597        None
598      }
599    })
600  }
601}
602
603/// The detailed result of a terminated actor, including its transport.
604///
605/// An exit always returns ownership of the transport, including after a
606/// transport failure. Use [`ActorExit::result`] to inspect the outcome without
607/// consuming the exit or [`ActorExit::into_parts`] to recover both values.
608pub struct ActorExit<T: TokioTransport> {
609  transport: T,
610  result: Result<(), ActorError<T::Error>>,
611}
612
613impl<T: TokioTransport> ActorExit<T> {
614  /// Returns a shared reference to the actor's transport.
615  pub const fn transport(&self) -> &T {
616    &self.transport
617  }
618
619  /// Consumes the exit and returns its transport, discarding the actor result.
620  pub fn into_transport(self) -> T {
621    self.transport
622  }
623
624  /// Consumes the exit and returns the transport and actor result.
625  pub fn into_parts(self) -> (T, Result<(), ActorError<T::Error>>) {
626    (self.transport, self.result)
627  }
628
629  /// Returns the actor result by reference.
630  ///
631  /// # Errors
632  ///
633  /// Returns the retained [`ActorError`] when initialization, operation, or
634  /// cleanup failed.
635  pub const fn result(&self) -> Result<(), &ActorError<T::Error>> {
636    match &self.result {
637      Ok(()) => Ok(()),
638      Err(error) => Err(error),
639    }
640  }
641
642  /// Consumes the exit and returns the owned actor result.
643  ///
644  /// # Errors
645  ///
646  /// Returns the retained [`ActorError`] when initialization, operation, or
647  /// cleanup failed.
648  pub fn into_result(self) -> Result<(), ActorError<T::Error>> {
649    self.result
650  }
651}
652
653/// A joinable Tokio task running the board actor.
654///
655/// Awaiting the task produces an [`ActorExit`] containing both the transport
656/// and final result. Dropping `BoardTask` detaches the Tokio task; it does not
657/// cancel the actor.
658pub struct BoardTask<T: TokioTransport> {
659  join: ::tokio::task::JoinHandle<ActorExit<T>>,
660}
661
662impl<T: TokioTransport> BoardTask<T> {
663  /// Requests immediate task cancellation.
664  ///
665  /// Prefer [`BoardHandle::shutdown`] so the actor can unsubscribe and close
666  /// the transport cleanly. Awaiting the task after cancellation returns a
667  /// cancelled
668  /// [`tokio::task::JoinError`](https://docs.rs/tokio/1/tokio/task/struct.JoinError.html).
669  pub fn abort(&self) {
670    self.join.abort();
671  }
672
673  /// Returns whether the actor task has completed.
674  pub fn is_finished(&self) -> bool {
675    self.join.is_finished()
676  }
677}
678
679impl<T: TokioTransport> Future for BoardTask<T> {
680  type Output = Result<ActorExit<T>, ::tokio::task::JoinError>;
681
682  fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
683    Pin::new(&mut self.join).poll(cx)
684  }
685}
686
687/// Spawns a concurrent board actor and returns its handle and joinable task.
688///
689/// Initialization runs inside the spawned task. Observe
690/// [`BoardHandle::subscribe_lifecycle`] or await the [`BoardTask`] to detect an
691/// initialization failure.
692///
693/// # Examples
694///
695/// ```no_run
696/// use chessnut_move::transport::tokio::{
697///     ActorConfig, BoardHandle, BoardTask, SpawnError, TokioTransport, spawn,
698/// };
699///
700/// # fn start<T: TokioTransport>(
701/// #     transport: T,
702/// # ) -> Result<(BoardHandle, BoardTask<T>), SpawnError> {
703/// spawn(transport, ActorConfig::default())
704/// # }
705/// ```
706///
707/// # Errors
708///
709/// Returns [`SpawnError::ZeroCommandCapacity`] or
710/// [`SpawnError::ZeroEventCapacity`] when the corresponding
711/// [`ActorConfig`] capacity is zero.
712///
713/// # Panics
714///
715/// Panics when called outside a Tokio runtime.
716pub fn spawn<T: TokioTransport>(
717  transport: T,
718  config: ActorConfig,
719) -> Result<(BoardHandle, BoardTask<T>), SpawnError> {
720  if config.command_capacity == 0 {
721    warn_event!(
722      field = "command_capacity",
723      "invalid board actor configuration"
724    );
725    return Err(SpawnError::ZeroCommandCapacity);
726  }
727  if config.event_capacity == 0 {
728    warn_event!(
729      field = "event_capacity",
730      "invalid board actor configuration"
731    );
732    return Err(SpawnError::ZeroEventCapacity);
733  }
734
735  info_event!(
736    command_capacity = config.command_capacity,
737    event_capacity = config.event_capacity,
738    query_capacity = config.query_capacity,
739    request_timeout = ?config.request_timeout,
740    "spawning board actor"
741  );
742  let (request_tx, request_rx) = ::tokio::sync::mpsc::channel(config.command_capacity);
743  let (event_tx, _) = ::tokio::sync::broadcast::channel(config.event_capacity);
744  let (lifecycle_tx, lifecycle_rx) = ::tokio::sync::watch::channel(LifecycleState::Starting);
745
746  let actor = run_actor(transport, config, request_rx, event_tx, lifecycle_tx);
747  #[cfg(feature = "tracing")]
748  let actor = {
749    use ::tracing::Instrument as _;
750
751    actor.instrument(::tracing::info_span!("board_actor"))
752  };
753  let join = ::tokio::spawn(actor);
754
755  Ok((
756    BoardHandle {
757      request_tx,
758      lifecycle_rx,
759    },
760    BoardTask { join },
761  ))
762}
763
764/// Request sent from a handle to the actor.
765enum Message {
766  Send {
767    command: Command,
768    reply: ::tokio::sync::oneshot::Sender<Result<(), HandleError>>,
769  },
770  Query(QueryRequest),
771  SubscribeEvents {
772    reply: ::tokio::sync::oneshot::Sender<Result<EventStream, HandleError>>,
773  },
774  Shutdown {
775    reply: ::tokio::sync::oneshot::Sender<Result<(), HandleError>>,
776  },
777}
778
779/// Query whose response must be correlated by notification type.
780enum QueryRequest {
781  Battery(::tokio::sync::oneshot::Sender<Result<BatteryStatus, HandleError>>),
782  Pieces(::tokio::sync::oneshot::Sender<Result<PieceStatus, HandleError>>),
783}
784
785/// Active serialized query and its response deadline.
786struct PendingQuery {
787  request: QueryRequest,
788  deadline: ::tokio::time::Instant,
789}
790
791impl QueryRequest {
792  /// Returns the stable tracing label for this query type.
793  #[cfg(feature = "tracing")]
794  const fn kind(&self) -> &'static str {
795    match self {
796      Self::Battery(_) => "battery_status",
797      Self::Pieces(_) => "piece_status",
798    }
799  }
800
801  /// Builds the command associated with this response type.
802  fn command(&self) -> Command {
803    match self {
804      Self::Battery(_) => Command::read_battery_level(),
805      Self::Pieces(_) => Command::read_piece_status(),
806    }
807  }
808
809  /// Completes this query with a handle-level error.
810  fn fail(self, error: HandleError) {
811    match self {
812      Self::Battery(reply) => {
813        let _ = reply.send(Err(error));
814      }
815      Self::Pieces(reply) => {
816        let _ = reply.send(Err(error));
817      }
818    }
819  }
820}
821
822impl PendingQuery {
823  /// Completes the active query with a handle-level error.
824  fn fail(self, error: HandleError) {
825    self.request.fail(error);
826  }
827}
828
829/// Receives an actor reply and maps a dropped sender to actor termination.
830async fn receive_reply<T>(
831  reply: ::tokio::sync::oneshot::Receiver<Result<T, HandleError>>,
832) -> Result<Result<T, HandleError>, HandleError> {
833  reply.await.map_err(|_| HandleError::ActorStopped)
834}
835
836/// Owns the transport and runs the complete actor lifecycle.
837///
838/// Only transport errors are fatal. Decode failures are broadcast to event
839/// subscribers and the receive loop continues. Query responses are published
840/// as events after satisfying the active query.
841async fn run_actor<T: TokioTransport>(
842  mut transport: T,
843  config: ActorConfig,
844  mut request_rx: ::tokio::sync::mpsc::Receiver<Message>,
845  event_tx: ::tokio::sync::broadcast::Sender<Result<BoardEvent, DecodeNotificationError>>,
846  lifecycle_tx: ::tokio::sync::watch::Sender<LifecycleState>,
847) -> ActorExit<T> {
848  info_event!("board actor is starting");
849  let mut notification_buffer = [0; MAX_NOTIFICATION_LEN];
850  let mut pending_query: Option<PendingQuery> = None;
851  let mut queued_queries: VecDeque<QueryRequest> = VecDeque::new();
852  let mut shutdown_reply = None;
853
854  let mut result = initialize_transport(&mut transport)
855    .await
856    .map_err(ActorError::Transport);
857
858  if result.is_ok() {
859    lifecycle_tx.send_replace(LifecycleState::Running);
860    info_event!("board actor is running");
861
862    result = loop {
863      if pending_query.is_none()
864        && let Some(query) = queued_queries.pop_front()
865      {
866        debug_event!(
867          query = query.kind(),
868          queued_queries = queued_queries.len(),
869          "starting queued board query"
870        );
871        match start_query(&mut transport, query, config.request_timeout).await {
872          Ok(query) => pending_query = Some(query),
873          Err(error) => break Err(error),
874        }
875      }
876
877      let deadline = pending_query
878        .as_ref()
879        .map(|query| query.deadline)
880        .unwrap_or_else(::tokio::time::Instant::now);
881
882      ::tokio::select! {
883        message = request_rx.recv() => {
884          match message {
885            Some(Message::Send { command, reply }) => {
886              trace_event!(
887                command_len = command.bytes().len(),
888                write_kind = ?command.write_kind(),
889                "board actor is writing a command"
890              );
891              match transport.write_command(&command).await {
892                Ok(()) => {
893                  let _ = reply.send(Ok(()));
894                }
895                Err(error) => {
896                  error_event!(operation = "write_command", "board transport failed");
897                  let _ = reply.send(Err(HandleError::TransportFailed));
898                  break Err(ActorError::Transport(error));
899                }
900              }
901            }
902            Some(Message::Query(query)) => {
903              debug_event!(
904                query = query.kind(),
905                queued_queries = queued_queries.len(),
906                has_active_query = pending_query.is_some(),
907                "board actor received a query"
908              );
909              if pending_query.is_none() && queued_queries.is_empty() {
910                match start_query(&mut transport, query, config.request_timeout).await {
911                  Ok(query) => pending_query = Some(query),
912                  Err(error) => break Err(error),
913                }
914              } else if queued_queries.len() < config.query_capacity {
915                queued_queries.push_back(query);
916              } else {
917                warn_event!(
918                  query = query.kind(),
919                  query_capacity = config.query_capacity,
920                  "board query queue is full"
921                );
922                query.fail(HandleError::QueryQueueFull);
923              }
924            }
925            Some(Message::SubscribeEvents { reply }) => {
926              debug_event!(
927                subscribers = event_tx.receiver_count() + 1,
928                "creating board event subscription"
929              );
930              let _ = reply.send(Ok(EventStream::new(event_tx.subscribe())));
931            }
932            Some(Message::Shutdown { reply }) => {
933              info_event!("board actor received shutdown request");
934              shutdown_reply = Some(reply);
935              break Ok(());
936            }
937            None => {
938              info_event!("all board handles were dropped");
939              break Ok(());
940            }
941          }
942        }
943        notification = transport.next_notification(&mut notification_buffer) => {
944          let notification = match notification {
945            Ok(notification) => notification,
946            Err(error) => {
947              error_event!(operation = "next_notification", "board transport failed");
948              break Err(ActorError::Transport(error));
949            }
950          };
951          trace_event!(
952            source = ?notification.source(),
953            notification_len = notification.bytes().len(),
954            "board actor received a notification"
955          );
956          let event = match decode_notification(notification) {
957            Ok(DecodedNotification::Event(event)) => event,
958            Ok(DecodedNotification::RealtimeUpdatesAcknowledged) => continue,
959            Err(error) => {
960              warn_event!(error = ?error, "board actor skipped a malformed notification");
961              let _ = event_tx.send(Err(error));
962              continue;
963            }
964          };
965
966          resolve_query(&mut pending_query, event);
967          let _ = event_tx.send(Ok(event));
968        }
969        _ = ::tokio::time::sleep_until(deadline), if pending_query.is_some() => {
970          if let Some(query) = pending_query.take() {
971            warn_event!(query = query.request.kind(), "board query timed out");
972            query.fail(HandleError::RequestTimedOut);
973          }
974        }
975      }
976    };
977  } else {
978    error_event!(
979      operation = "initialize",
980      "board actor initialization failed"
981    );
982  }
983
984  lifecycle_tx.send_replace(LifecycleState::ShuttingDown);
985  info_event!("board actor is shutting down");
986  request_rx.close();
987
988  if let Some(query) = pending_query.take() {
989    query.fail(HandleError::ShuttingDown);
990  }
991  for query in queued_queries {
992    query.fail(HandleError::ShuttingDown);
993  }
994  while let Ok(message) = request_rx.try_recv() {
995    fail_message(message, HandleError::ShuttingDown);
996  }
997
998  let cleanup_result = shutdown_transport(&mut transport)
999    .await
1000    .map_err(ActorError::Transport);
1001  if cleanup_result.is_err() {
1002    error_event!(operation = "shutdown", "board transport cleanup failed");
1003  }
1004  if result.is_ok() {
1005    result = cleanup_result;
1006  }
1007
1008  let final_state = if result.is_ok() {
1009    LifecycleState::Stopped
1010  } else {
1011    LifecycleState::Faulted
1012  };
1013  lifecycle_tx.send_replace(final_state);
1014  match final_state {
1015    LifecycleState::Stopped => info_event!("board actor stopped"),
1016    LifecycleState::Faulted => error_event!("board actor stopped after a fatal error"),
1017    _ => {}
1018  }
1019
1020  if let Some(reply) = shutdown_reply {
1021    let reply_result = if result.is_ok() {
1022      Ok(())
1023    } else {
1024      Err(HandleError::TransportFailed)
1025    };
1026    let _ = reply.send(reply_result);
1027  }
1028
1029  ActorExit { transport, result }
1030}
1031
1032/// Performs the protocol-required startup sequence.
1033///
1034/// Command responses are subscribed after the realtime-enable write because
1035/// the board may emit an undocumented `0x23` acknowledgement on that
1036/// characteristic. The decoder also recognizes delayed acknowledgements.
1037async fn initialize_transport<T: TokioTransport>(transport: &mut T) -> Result<(), T::Error> {
1038  trace_event!(
1039    operation = "subscribe",
1040    source = ?NotificationSource::Position,
1041    "initializing board transport"
1042  );
1043  transport.subscribe(NotificationSource::Position).await?;
1044  trace_event!(
1045    operation = "enable_realtime_updates",
1046    "initializing board transport"
1047  );
1048  transport
1049    .write_command(&Command::enable_realtime_updates())
1050    .await?;
1051  trace_event!(
1052    operation = "subscribe",
1053    source = ?NotificationSource::CommandResponse,
1054    "initializing board transport"
1055  );
1056  transport
1057    .subscribe(NotificationSource::CommandResponse)
1058    .await
1059}
1060
1061/// Performs ordered best-effort-compatible transport cleanup.
1062///
1063/// Cleanup returns at the first transport error. The transport is still
1064/// retained in [`ActorExit`] so callers can perform adapter-specific recovery.
1065async fn shutdown_transport<T: TokioTransport>(transport: &mut T) -> Result<(), T::Error> {
1066  trace_event!(
1067    operation = "unsubscribe",
1068    source = ?NotificationSource::CommandResponse,
1069    "shutting down board transport"
1070  );
1071  transport
1072    .unsubscribe(NotificationSource::CommandResponse)
1073    .await?;
1074  trace_event!(
1075    operation = "unsubscribe",
1076    source = ?NotificationSource::Position,
1077    "shutting down board transport"
1078  );
1079  transport.unsubscribe(NotificationSource::Position).await?;
1080  trace_event!(operation = "close", "shutting down board transport");
1081  transport.close().await
1082}
1083
1084/// Writes the next serialized query and records its response deadline.
1085async fn start_query<T: TokioTransport>(
1086  transport: &mut T,
1087  query: QueryRequest,
1088  timeout: Duration,
1089) -> Result<PendingQuery, ActorError<T::Error>> {
1090  debug_event!(
1091    query = query.kind(),
1092    timeout = ?timeout,
1093    "writing serialized board query"
1094  );
1095  if let Err(error) = transport.write_command(&query.command()).await {
1096    error_event!(
1097      operation = "write_query",
1098      query = query.kind(),
1099      "board transport failed"
1100    );
1101    query.fail(HandleError::TransportFailed);
1102    return Err(ActorError::Transport(error));
1103  }
1104
1105  Ok(PendingQuery {
1106    request: query,
1107    deadline: ::tokio::time::Instant::now() + timeout,
1108  })
1109}
1110
1111/// Completes the active query when an event has its expected response type.
1112///
1113/// Move query responses do not carry request IDs, so at most one query can be
1114/// active. Nonmatching events remain public events and leave the query pending.
1115fn resolve_query(pending: &mut Option<PendingQuery>, event: BoardEvent) {
1116  let matches = matches!(
1117    (pending.as_ref().map(|query| &query.request), event),
1118    (Some(QueryRequest::Battery(_)), BoardEvent::BatteryStatus(_))
1119      | (Some(QueryRequest::Pieces(_)), BoardEvent::PieceStatus(_))
1120  );
1121
1122  if !matches {
1123    return;
1124  }
1125
1126  let query = pending.take().expect("matching pending query exists");
1127  debug_event!(
1128    query = query.request.kind(),
1129    "board query received its response"
1130  );
1131  match (query.request, event) {
1132    (QueryRequest::Battery(reply), BoardEvent::BatteryStatus(status)) => {
1133      let _ = reply.send(Ok(status));
1134    }
1135    (QueryRequest::Pieces(reply), BoardEvent::PieceStatus(status)) => {
1136      let _ = reply.send(Ok(status));
1137    }
1138    _ => unreachable!("query and event variants were checked before taking the query"),
1139  }
1140}
1141
1142/// Rejects a queued handle message during shutdown or actor failure.
1143fn fail_message(message: Message, error: HandleError) {
1144  match message {
1145    Message::Send { reply, .. } | Message::Shutdown { reply } => {
1146      let _ = reply.send(Err(error));
1147    }
1148    Message::Query(query) => query.fail(error),
1149    Message::SubscribeEvents { reply } => {
1150      let _ = reply.send(Err(error));
1151    }
1152  }
1153}
1154
1155#[cfg(test)]
1156mod tests {
1157  use std::sync::{Arc, Mutex};
1158
1159  use super::*;
1160
1161  #[derive(Clone, Copy, Debug, PartialEq, Eq, Error)]
1162  #[error("mock notification channel closed")]
1163  struct MockError;
1164
1165  struct OwnedNotification {
1166    source: NotificationSource,
1167    bytes: Vec<u8>,
1168  }
1169
1170  #[derive(Debug, PartialEq, Eq)]
1171  enum Operation {
1172    Subscribe(NotificationSource),
1173    Write(Vec<u8>),
1174  }
1175
1176  #[derive(Default)]
1177  struct Record {
1178    subscriptions: Vec<NotificationSource>,
1179    unsubscriptions: Vec<NotificationSource>,
1180    writes: Vec<Vec<u8>>,
1181    operations: Vec<Operation>,
1182    closed: bool,
1183  }
1184
1185  struct MockTransport {
1186    record: Arc<Mutex<Record>>,
1187    notification_rx: ::tokio::sync::mpsc::Receiver<OwnedNotification>,
1188  }
1189
1190  impl MockTransport {
1191    fn new() -> (
1192      Self,
1193      Arc<Mutex<Record>>,
1194      ::tokio::sync::mpsc::Sender<OwnedNotification>,
1195    ) {
1196      let record = Arc::new(Mutex::new(Record::default()));
1197      let (notification_tx, notification_rx) = ::tokio::sync::mpsc::channel(8);
1198
1199      (
1200        Self {
1201          record: Arc::clone(&record),
1202          notification_rx,
1203        },
1204        record,
1205        notification_tx,
1206      )
1207    }
1208  }
1209
1210  impl TokioTransport for MockTransport {
1211    type Error = MockError;
1212
1213    async fn subscribe(&mut self, source: NotificationSource) -> Result<(), Self::Error> {
1214      let mut record = self.record.lock().unwrap();
1215      record.subscriptions.push(source);
1216      record.operations.push(Operation::Subscribe(source));
1217      Ok(())
1218    }
1219
1220    async fn unsubscribe(&mut self, source: NotificationSource) -> Result<(), Self::Error> {
1221      self.record.lock().unwrap().unsubscriptions.push(source);
1222      Ok(())
1223    }
1224
1225    async fn write_command(&mut self, command: &Command) -> Result<(), Self::Error> {
1226      let bytes = command.bytes().to_vec();
1227      let mut record = self.record.lock().unwrap();
1228      record.writes.push(bytes.clone());
1229      record.operations.push(Operation::Write(bytes));
1230      Ok(())
1231    }
1232
1233    async fn next_notification<'a>(
1234      &'a mut self,
1235      buffer: &'a mut [u8],
1236    ) -> Result<Notification<'a>, Self::Error> {
1237      let notification = self.notification_rx.recv().await.ok_or(MockError)?;
1238      buffer[..notification.bytes.len()].copy_from_slice(&notification.bytes);
1239      Ok(Notification::new(
1240        notification.source,
1241        &buffer[..notification.bytes.len()],
1242      ))
1243    }
1244
1245    async fn close(&mut self) -> Result<(), Self::Error> {
1246      self.record.lock().unwrap().closed = true;
1247      Ok(())
1248    }
1249  }
1250
1251  #[::tokio::test]
1252  async fn actor_correlates_queries_without_stealing_events() {
1253    let (transport, record, notification_tx) = MockTransport::new();
1254    let (handle, task) = spawn(transport, ActorConfig::default()).unwrap();
1255    let mut events = handle.subscribe_events().await.unwrap();
1256    wait_until_running(&handle).await;
1257
1258    assert_eq!(
1259      record.lock().unwrap().operations[..3],
1260      [
1261        Operation::Subscribe(NotificationSource::Position),
1262        Operation::Write(Command::enable_realtime_updates().bytes().to_vec()),
1263        Operation::Subscribe(NotificationSource::CommandResponse),
1264      ]
1265    );
1266
1267    notification_tx
1268      .send(OwnedNotification {
1269        source: NotificationSource::CommandResponse,
1270        bytes: vec![0x23, 0x01, 0x00],
1271      })
1272      .await
1273      .unwrap();
1274
1275    let query_handle = handle.clone();
1276    let query = ::tokio::spawn(async move { query_handle.battery_status().await });
1277    wait_for_write(&record, Command::read_battery_level().bytes()).await;
1278
1279    notification_tx
1280      .send(OwnedNotification {
1281        source: NotificationSource::Position,
1282        bytes: vec![0; 34],
1283      })
1284      .await
1285      .unwrap();
1286
1287    assert!(matches!(
1288      events.recv().await,
1289      Ok(BoardEvent::PositionChanged(_))
1290    ));
1291    assert!(!query.is_finished());
1292
1293    notification_tx
1294      .send(OwnedNotification {
1295        source: NotificationSource::CommandResponse,
1296        bytes: vec![0x41, 0x03, 0x0c, 0x01, 88],
1297      })
1298      .await
1299      .unwrap();
1300
1301    assert_eq!(
1302      query.await.unwrap(),
1303      Ok(BatteryStatus {
1304        charging: true,
1305        percentage: 88,
1306      })
1307    );
1308    assert_eq!(
1309      events.recv().await,
1310      Ok(BoardEvent::BatteryStatus(BatteryStatus {
1311        charging: true,
1312        percentage: 88,
1313      }))
1314    );
1315
1316    let query_handle = handle.clone();
1317    let query = ::tokio::spawn(async move { query_handle.piece_status().await });
1318    wait_for_write(&record, Command::read_piece_status().bytes()).await;
1319    notification_tx
1320      .send(OwnedNotification {
1321        source: NotificationSource::CommandResponse,
1322        bytes: piece_status_response(),
1323      })
1324      .await
1325      .unwrap();
1326
1327    let piece_status = query.await.unwrap().unwrap();
1328    assert_eq!(piece_status.pieces[0].battery_percentage, Some(50));
1329    assert_eq!(piece_status.pieces[33].battery_percentage, Some(83));
1330    assert!(matches!(
1331      events.recv().await,
1332      Ok(BoardEvent::PieceStatus(_))
1333    ));
1334
1335    handle.shutdown().await.unwrap();
1336    let exit = task.await.unwrap();
1337    assert!(exit.result().is_ok());
1338    assert_eq!(handle.lifecycle(), LifecycleState::Stopped);
1339    assert_eq!(events.recv().await, Err(EventStreamError::Closed));
1340
1341    let record = record.lock().unwrap();
1342    assert_eq!(
1343      record.subscriptions,
1344      [
1345        NotificationSource::Position,
1346        NotificationSource::CommandResponse,
1347      ]
1348    );
1349    assert_eq!(
1350      record.unsubscriptions,
1351      [
1352        NotificationSource::CommandResponse,
1353        NotificationSource::Position,
1354      ]
1355    );
1356    assert!(record.closed);
1357  }
1358
1359  #[::tokio::test(start_paused = true)]
1360  async fn request_timeout_does_not_stop_the_actor() {
1361    let (transport, record, _notification_tx) = MockTransport::new();
1362    let config = ActorConfig {
1363      request_timeout: Duration::from_secs(5),
1364      ..ActorConfig::default()
1365    };
1366    let (handle, task) = spawn(transport, config).unwrap();
1367    wait_until_running(&handle).await;
1368
1369    let query_handle = handle.clone();
1370    let query = ::tokio::spawn(async move { query_handle.battery_status().await });
1371    wait_for_write(&record, Command::read_battery_level().bytes()).await;
1372
1373    ::tokio::time::advance(Duration::from_secs(6)).await;
1374    assert_eq!(query.await.unwrap(), Err(HandleError::RequestTimedOut));
1375    assert_eq!(handle.lifecycle(), LifecycleState::Running);
1376
1377    handle.shutdown().await.unwrap();
1378    assert!(task.await.unwrap().result().is_ok());
1379  }
1380
1381  #[::tokio::test]
1382  async fn malformed_notification_is_reported_without_stopping_actor() {
1383    let (transport, _record, notification_tx) = MockTransport::new();
1384    let (handle, task) = spawn(transport, ActorConfig::default()).unwrap();
1385    let mut events = handle.subscribe_events().await.unwrap();
1386    wait_until_running(&handle).await;
1387
1388    notification_tx
1389      .send(OwnedNotification {
1390        source: NotificationSource::Position,
1391        bytes: vec![0; 29],
1392      })
1393      .await
1394      .unwrap();
1395
1396    assert!(matches!(
1397      events.recv().await,
1398      Err(EventStreamError::Decode(DecodeNotificationError::Position(
1399        crate::protocol::DecodePositionNotificationError::NotificationTooShort(
1400          crate::protocol::NotificationTooShortError {
1401            expected: 34,
1402            actual: 29,
1403          }
1404        )
1405      )))
1406    ));
1407    assert_eq!(handle.lifecycle(), LifecycleState::Running);
1408
1409    handle.shutdown().await.unwrap();
1410    assert!(task.await.unwrap().result().is_ok());
1411  }
1412
1413  #[::tokio::test]
1414  async fn event_stream_reports_lag() {
1415    let (sender, receiver) = ::tokio::sync::broadcast::channel(1);
1416    let mut events = EventStream::new(receiver);
1417
1418    sender
1419      .send(Ok(BoardEvent::BatteryStatus(BatteryStatus {
1420        charging: false,
1421        percentage: 10,
1422      })))
1423      .unwrap();
1424    sender
1425      .send(Ok(BoardEvent::BatteryStatus(BatteryStatus {
1426        charging: false,
1427        percentage: 11,
1428      })))
1429      .unwrap();
1430
1431    assert_eq!(events.recv().await, Err(EventStreamError::Lagged(1)));
1432    assert_eq!(
1433      events.recv().await,
1434      Ok(BoardEvent::BatteryStatus(BatteryStatus {
1435        charging: false,
1436        percentage: 11,
1437      }))
1438    );
1439  }
1440
1441  async fn wait_until_running(handle: &BoardHandle) {
1442    let mut lifecycle = handle.subscribe_lifecycle();
1443    while *lifecycle.borrow() != LifecycleState::Running {
1444      lifecycle.changed().await.unwrap();
1445    }
1446  }
1447
1448  async fn wait_for_write(record: &Arc<Mutex<Record>>, expected: &[u8]) {
1449    loop {
1450      if record
1451        .lock()
1452        .unwrap()
1453        .writes
1454        .iter()
1455        .any(|write| write == expected)
1456      {
1457        return;
1458      }
1459      ::tokio::task::yield_now().await;
1460    }
1461  }
1462
1463  fn piece_status_response() -> Vec<u8> {
1464    const IDENTITIES: [u8; 34] = [
1465      1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 7, 7, 7, 7, 7, 7, 7, 7, 8, 8, 9, 9, 10,
1466      10, 11, 11, 12,
1467    ];
1468
1469    let mut response = vec![0; MAX_NOTIFICATION_LEN];
1470    response[..3].copy_from_slice(&[0x41, 0x89, 0x0b]);
1471    for (index, identity) in IDENTITIES.iter().copied().enumerate() {
1472      let offset = 3 + index * 4;
1473      response[offset] = identity;
1474      response[offset + 1] = index as u8;
1475      response[offset + 2] = u8::MAX - index as u8;
1476      response[offset + 3] = 50 + index as u8;
1477    }
1478    response
1479  }
1480}