Skip to main content

chessnut_move/protocol/
command.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//! Typed construction of every command currently defined by the Move API.
16
17use crate::protocol::wire::{PackedSquares, encode_piece};
18use crate::protocol::{BOARD_STATE_LENGTH, LedPattern, Position};
19#[cfg(doc)]
20use crate::{protocol, transport};
21
22const AUTO_MOVE_PREFIX: [u8; 2] = [0x42, 0x21];
23const SET_LED_PREFIX: [u8; 2] = [0x43, 0x20];
24const AUTO_MOVE_COMMAND_LENGTH: usize = 35;
25const SET_LED_COMMAND_LENGTH: usize = SET_LED_PREFIX.len() + BOARD_STATE_LENGTH;
26
27/// Maximum number of bytes in a command, defined by the Move's public API surface.
28///
29/// Transport implementations can use this value when allocating fixed command
30/// buffers.
31pub const MAX_COMMAND_LEN: usize = const_max(AUTO_MOVE_COMMAND_LENGTH, SET_LED_COMMAND_LENGTH);
32
33/// Returns the larger of two compile-time command lengths.
34const fn const_max(left: usize, right: usize) -> usize {
35  if left > right { left } else { right }
36}
37
38/// An encoded command ready to write to the board's command characteristic.
39///
40/// Construct commands with methods such as [`Command::set_leds`] and
41/// [`Command::read_battery_level`].
42///
43/// Transport implementations use [`Command::bytes`] and [`Command::write_kind`] to perform
44/// the write.
45///
46/// # Examples
47///
48/// ```
49/// use chessnut_move::protocol::{
50///     Command, File, LedColor, LedPattern, Rank, Square, WriteKind,
51/// };
52///
53/// let mut leds = LedPattern::default();
54/// leds.set_color(Square::new(File::E, Rank::Four), LedColor::Green);
55///
56/// let command = Command::set_leds(&leds);
57/// assert_eq!(command.write_kind(), WriteKind::WithoutResponse);
58/// ```
59#[derive(Clone, Debug, PartialEq, Eq)]
60pub struct Command {
61  bytes: [u8; MAX_COMMAND_LEN],
62  len: u8,
63  write_kind: WriteKind,
64}
65
66/// Selects the GATT write operation required by a [`Command`].
67#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
68pub enum WriteKind {
69  /// Requests a GATT-level response from the peripheral.
70  WithResponse,
71
72  /// Submits the value without requesting a GATT-level response.
73  WithoutResponse,
74}
75
76/// Selects how an [automatic movement] reacts to user interaction.
77///
78/// [automatic movement]: Command::auto_move
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum AutoMoveMode {
81  /// Continues toward the target position despite user piece movement.
82  Force,
83
84  /// Stops automatic movement when the user moves a piece.
85  Normal,
86}
87
88impl AutoMoveMode {
89  /// Returns the byte value used for this mode in an [auto-move command].
90  ///
91  /// # Examples
92  ///
93  /// ```
94  /// use chessnut_move::protocol::AutoMoveMode;
95  ///
96  /// assert_eq!(AutoMoveMode::Force.flag(), 0);
97  /// assert_eq!(AutoMoveMode::Normal.flag(), 1);
98  /// ```
99  ///
100  /// [auto-move command]: Command::auto_move
101  pub const fn flag(self) -> u8 {
102    match self {
103      Self::Force => 0x00,
104      Self::Normal => 0x01,
105    }
106  }
107}
108
109impl Command {
110  // All public constructors pass statically sized arrays through this helper.
111  // Its const assertions make adding a command larger than MAX_COMMAND_LEN a
112  // compile-time error instead of truncating the command.
113  pub(crate) const fn from_bytes<const N: usize>(input: &[u8; N], write_kind: WriteKind) -> Self {
114    const {
115      assert!(N <= MAX_COMMAND_LEN);
116      assert!(MAX_COMMAND_LEN <= u8::MAX as usize);
117    }
118
119    let mut bytes = [0; MAX_COMMAND_LEN];
120    let mut index = 0;
121    while index < N {
122      bytes[index] = input[index];
123      index += 1;
124    }
125
126    Self {
127      bytes,
128      len: N as u8,
129      write_kind,
130    }
131  }
132
133  /// Creates a command that moves the physical pieces to a target position.
134  ///
135  /// The target describes all 64 squares, not only the pieces that changed.
136  /// [Position notifications] are unavailable while the board is performing an
137  /// automatic movement.
138  ///
139  /// Use [`Command::stop_auto_move`] to cancel movement.
140  ///
141  /// # Examples
142  ///
143  /// ```
144  /// use chessnut_move::protocol::{
145  ///     AutoMoveMode, Color, Command, File, Piece, PieceKind, Position, Rank,
146  ///     SQUARE_COUNT, Square,
147  /// };
148  ///
149  /// let mut target = Position::new([None; SQUARE_COUNT]);
150  /// target.set_piece(
151  ///     Square::new(File::E, Rank::Four),
152  ///     Some(Piece {
153  ///         color: Color::White,
154  ///         kind: PieceKind::Pawn,
155  ///     }),
156  /// );
157  ///
158  /// let command = Command::auto_move(target, AutoMoveMode::Normal);
159  /// assert_eq!(command.bytes().len(), 35);
160  /// ```
161  ///
162  /// [Position notifications]: protocol::BoardEvent::PositionChanged
163  pub fn auto_move(position: Position, mode: AutoMoveMode) -> Self {
164    trace_event!(
165      mode = ?mode,
166      occupied_squares = position
167        .squares()
168        .iter()
169        .filter(|piece| piece.is_some())
170        .count(),
171      "encoding auto-move command"
172    );
173    let mut command = [0; AUTO_MOVE_COMMAND_LENGTH];
174    let bytes = PackedSquares::encode(position.squares(), encode_piece).into_bytes();
175
176    command[..AUTO_MOVE_PREFIX.len()].copy_from_slice(&AUTO_MOVE_PREFIX);
177    command[AUTO_MOVE_PREFIX.len()..AUTO_MOVE_PREFIX.len() + BOARD_STATE_LENGTH]
178      .copy_from_slice(&bytes);
179    command[AUTO_MOVE_COMMAND_LENGTH - 1] = mode.flag();
180
181    Self::from_bytes(&command, WriteKind::WithoutResponse)
182  }
183
184  /// Creates a command that stops the current [automatic movement].
185  ///
186  /// The command is safe to send when no automatic movement is active.
187  ///
188  /// [automatic movement]: Command::auto_move
189  #[cfg_attr(
190    feature = "async",
191    doc = r#"
192# Examples
193
194Send the stop command through an initialized async session:
195
196```no_run
197use chessnut_move::protocol::Command;
198use chessnut_move::transport::{AsyncBoard, AsyncTransport, BoardError};
199
200async fn stop<T: AsyncTransport>(
201    board: &mut AsyncBoard<T>,
202) -> Result<(), BoardError<T::Error>> {
203    board.send(&Command::stop_auto_move()).await
204}
205```
206"#
207  )]
208  pub const fn stop_auto_move() -> Self {
209    let mut command = [0; AUTO_MOVE_COMMAND_LENGTH];
210    command[0] = AUTO_MOVE_PREFIX[0];
211    command[1] = AUTO_MOVE_PREFIX[1];
212
213    Self::from_bytes(&command, WriteKind::WithoutResponse)
214  }
215
216  /// Creates a command that enables realtime position notifications.
217  ///
218  /// Low-level transport users must subscribe to
219  /// [`NotificationSource::Position`] before writing this command so the
220  /// initial position is not missed. Board sessions perform this sequence as
221  /// part of their initialization procedure.
222  ///
223  /// # Examples
224  ///
225  /// ```
226  /// use chessnut_move::protocol::{Command, WriteKind};
227  ///
228  /// let command = Command::enable_realtime_updates();
229  /// assert_eq!(command.bytes(), [0x21, 0x01, 0x00]);
230  /// assert_eq!(command.write_kind(), WriteKind::WithResponse);
231  /// ```
232  ///
233  /// [`NotificationSource::Position`]: transport::NotificationSource::Position
234  #[cfg_attr(
235    feature = "async",
236    doc = "See [`AsyncBoard::initialize`](transport::AsyncBoard::initialize) for the runtime-neutral async initialization procedure."
237  )]
238  #[cfg_attr(
239    feature = "blocking",
240    doc = "See [`BlockingBoard::initialize`](transport::BlockingBoard::initialize) for the blocking initialization procedure."
241  )]
242  #[cfg_attr(
243    feature = "tokio",
244    doc = "The [Tokio actor](transport::tokio::spawn) performs initialization in its spawned task."
245  )]
246  pub const fn enable_realtime_updates() -> Self {
247    Self::from_bytes(&[0x21, 0x01, 0x00], WriteKind::WithResponse)
248  }
249
250  /// Creates a query for the board's battery status.
251  ///
252  /// The corresponding command-response notification decodes to
253  /// [`BoardEvent::BatteryStatus`].
254  ///
255  /// [`BoardEvent::BatteryStatus`]: protocol::BoardEvent::BatteryStatus
256  #[cfg_attr(
257    feature = "async",
258    doc = r#"
259# Examples
260
261Send the query through an initialized async session and wait for its response:
262
263```no_run
264use chessnut_move::protocol::{BatteryStatus, BoardEvent, Command};
265use chessnut_move::transport::{AsyncBoard, AsyncTransport, BoardError};
266
267async fn read_battery<T: AsyncTransport>(
268    board: &mut AsyncBoard<T>,
269) -> Result<BatteryStatus, BoardError<T::Error>> {
270    board.send(&Command::read_battery_level()).await?;
271
272    loop {
273        if let BoardEvent::BatteryStatus(status) = board.next_event().await? {
274            return Ok(status);
275        }
276    }
277}
278```
279"#
280  )]
281  #[cfg_attr(
282    feature = "tokio",
283    doc = "Tokio actor consumers can use [`BoardHandle::battery_status`](transport::tokio::BoardHandle::battery_status) to send and correlate this query."
284  )]
285  pub const fn read_battery_level() -> Self {
286    query_command(0x0c)
287  }
288
289  /// Creates a query for the status of all tracked physical pieces.
290  ///
291  /// The corresponding command-response notification decodes to
292  /// [`BoardEvent::PieceStatus`].
293  ///
294  /// [`BoardEvent::PieceStatus`]: protocol::BoardEvent::PieceStatus
295  #[cfg_attr(
296    feature = "async",
297    doc = r#"
298# Examples
299
300Send the query through an initialized async session and wait for its response:
301
302```no_run
303use chessnut_move::protocol::{BoardEvent, Command, PieceStatus};
304use chessnut_move::transport::{AsyncBoard, AsyncTransport, BoardError};
305
306async fn read_pieces<T: AsyncTransport>(
307    board: &mut AsyncBoard<T>,
308) -> Result<PieceStatus, BoardError<T::Error>> {
309    board.send(&Command::read_piece_status()).await?;
310
311    loop {
312        if let BoardEvent::PieceStatus(status) = board.next_event().await? {
313            return Ok(status);
314        }
315    }
316}
317```
318"#
319  )]
320  #[cfg_attr(
321    feature = "tokio",
322    doc = "Tokio actor consumers can use [`BoardHandle::piece_status`](transport::tokio::BoardHandle::piece_status) to send and correlate this query."
323  )]
324  pub const fn read_piece_status() -> Self {
325    query_command(0x0b)
326  }
327
328  /// Creates a command that replaces the LED color of every square.
329  ///
330  /// Squares not selected in `pattern` are turned off. Use
331  /// [`LedPattern::default`] to turn off all square LEDs.
332  ///
333  /// # Examples
334  ///
335  /// ```
336  /// use chessnut_move::protocol::{
337  ///     Command, File, LedColor, LedPattern, Rank, Square,
338  /// };
339  ///
340  /// let mut pattern = LedPattern::default();
341  /// pattern.set_color(Square::new(File::E, Rank::Four), LedColor::Blue);
342  ///
343  /// let command = Command::set_leds(&pattern);
344  /// assert_eq!(command.bytes().len(), 34);
345  /// ```
346  pub fn set_leds(pattern: &LedPattern) -> Self {
347    trace_event!("encoding LED command");
348    let packed = pattern.encode();
349
350    let mut command = [0; SET_LED_COMMAND_LENGTH];
351
352    command[..SET_LED_PREFIX.len()].copy_from_slice(&SET_LED_PREFIX);
353    command[SET_LED_PREFIX.len()..].copy_from_slice(&packed);
354
355    Self::from_bytes(&command, WriteKind::WithoutResponse)
356  }
357
358  /// Returns the complete byte sequence to write to the board.
359  ///
360  /// # Examples
361  ///
362  /// ```
363  /// use chessnut_move::protocol::Command;
364  ///
365  /// assert_eq!(Command::enable_realtime_updates().bytes(), [0x21, 0x01, 0x00]);
366  /// ```
367  pub fn bytes(&self) -> &[u8] {
368    &self.bytes[..self.len as usize]
369  }
370
371  /// Returns the GATT write operation required by this command.
372  ///
373  /// # Examples
374  ///
375  /// ```
376  /// use chessnut_move::protocol::{Command, WriteKind};
377  ///
378  /// assert_eq!(
379  ///     Command::stop_auto_move().write_kind(),
380  ///     WriteKind::WithoutResponse,
381  /// );
382  /// ```
383  pub const fn write_kind(&self) -> WriteKind {
384    self.write_kind
385  }
386}
387
388/// Creates a three-byte register query using a GATT write with response.
389const fn query_command(register: u8) -> Command {
390  Command::from_bytes(&[0x41, 0x01, register], WriteKind::WithResponse)
391}
392
393#[cfg(test)]
394mod tests {
395  use super::*;
396  use crate::protocol::{Color, File, LedColor, Piece, PieceKind, Rank, SQUARE_COUNT, Square};
397
398  #[test]
399  fn fixed_commands_can_be_constructed_at_compile_time() {
400    const STOP: Command = Command::stop_auto_move();
401    const ENABLE_UPDATES: Command = Command::enable_realtime_updates();
402    const READ_BATTERY: Command = Command::read_battery_level();
403    const READ_PIECES: Command = Command::read_piece_status();
404
405    assert_eq!(&STOP.bytes()[..2], AUTO_MOVE_PREFIX);
406    assert_eq!(ENABLE_UPDATES.bytes(), [0x21, 0x01, 0x00]);
407    assert_eq!(READ_BATTERY.bytes(), [0x41, 0x01, 0x0c]);
408    assert_eq!(READ_PIECES.bytes(), [0x41, 0x01, 0x0b]);
409  }
410
411  #[test]
412  fn auto_move_command_encodes_target_position_and_mode() {
413    let mut position = Position::new([None; SQUARE_COUNT]);
414    position.set_piece(
415      Square::new(File::H, Rank::Eight),
416      Some(Piece {
417        color: Color::Black,
418        kind: PieceKind::Queen,
419      }),
420    );
421    position.set_piece(
422      Square::new(File::G, Rank::Eight),
423      Some(Piece {
424        color: Color::Black,
425        kind: PieceKind::King,
426      }),
427    );
428
429    let command = Command::auto_move(position, AutoMoveMode::Normal);
430
431    assert_eq!(&command.bytes()[..2], AUTO_MOVE_PREFIX);
432    assert_eq!(command.bytes()[2], 0x21);
433    assert!(command.bytes()[3..34].iter().all(|byte| *byte == 0));
434    assert_eq!(command.bytes()[34], AutoMoveMode::Normal.flag());
435    assert_eq!(command.write_kind(), WriteKind::WithoutResponse);
436  }
437
438  #[test]
439  fn stop_auto_move_command_zeroes_the_target_and_force_flag() {
440    let command = Command::stop_auto_move();
441
442    assert_eq!(&command.bytes()[..2], AUTO_MOVE_PREFIX);
443    assert!(command.bytes()[2..].iter().all(|byte| *byte == 0));
444    assert_eq!(command.write_kind(), WriteKind::WithoutResponse);
445  }
446
447  #[test]
448  fn battery_level_command_matches_move_api() {
449    let command = Command::read_battery_level();
450
451    assert_eq!(command.bytes(), [0x41, 0x01, 0x0c]);
452    assert_eq!(command.write_kind(), WriteKind::WithResponse);
453  }
454
455  #[test]
456  fn piece_status_command_matches_move_api() {
457    let command = Command::read_piece_status();
458
459    assert_eq!(command.bytes(), [0x41, 0x01, 0x0b]);
460    assert_eq!(command.write_kind(), WriteKind::WithResponse);
461  }
462
463  #[test]
464  fn led_command_uses_chessnut_square_and_nibble_order() {
465    let mut pattern = LedPattern::default();
466    pattern.set_color(Square::new(File::H, Rank::Eight), LedColor::Red);
467    pattern.set_color(Square::new(File::G, Rank::Eight), LedColor::Green);
468    pattern.set_color(Square::new(File::A, Rank::One), LedColor::Blue);
469
470    let command = Command::set_leds(&pattern);
471
472    assert_eq!(command.bytes().len(), SET_LED_COMMAND_LENGTH);
473    assert_eq!(&command.bytes()[..2], SET_LED_PREFIX);
474    assert_eq!(command.bytes()[2], 0x21);
475    assert!(command.bytes()[3..33].iter().all(|byte| *byte == 0));
476    assert_eq!(command.bytes()[33], 0x30);
477    assert_eq!(command.write_kind(), WriteKind::WithoutResponse);
478  }
479}