Skip to main content

chessnut_move/protocol/
mod.rs

1// Copyright 2026 Daymon Littrell-Reyes
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//   http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Typed values and codecs for the Chessnut Move byte protocol.
16//!
17//! Applications normally construct a [`Command`][protocol::Command] and
18//! consume [`BoardEvent`][protocol::BoardEvent] values through a session in the
19//! [`transport`] module. Transport implementers can use
20//! [`decode_position_notification`][protocol::decode_position_notification] and
21//! [`decode_response_notification`][protocol::decode_response_notification]
22//! when they need direct access to the protocol codecs.
23//!
24//! Board positions use canonical A1-to-H8 indexing through
25//! [`Square`][protocol::Square], while encoding and decoding preserve the Move
26//! board's reversed wire order.
27//!
28//! # Examples
29//!
30//! Consumers can handle decoded events without depending on a particular
31//! Bluetooth library:
32//!
33//! ```
34//! use chessnut_move::protocol::{
35//!     BoardEvent, File, Rank, Square,
36//! };
37//!
38//! fn occupied(event: BoardEvent, square: Square) -> Option<bool> {
39//!     match event {
40//!         BoardEvent::PositionChanged(position) => {
41//!             Some(position.piece_at(square).is_some())
42//!         }
43//!         BoardEvent::BatteryStatus(_) | BoardEvent::PieceStatus(_) => None,
44//!     }
45//! }
46//!
47//! let e4 = Square::new(File::E, Rank::Four);
48//! # let _ = (occupied, e4);
49//! ```
50
51use crate::protocol::wire::{PackedSquares, decode_piece};
52#[cfg(doc)]
53use crate::transport;
54
55mod command;
56mod errors;
57mod event;
58mod led;
59mod position;
60mod square;
61mod wire;
62
63pub use command::*;
64pub use errors::*;
65pub use event::*;
66pub use led::*;
67pub use position::*;
68pub use square::*;
69
70const BOARD_STATE_OFFSET: usize = 2;
71const RESPONSE_HEADER: u8 = 0x41;
72const RESPONSE_HEADER_LENGTH: usize = 3;
73const BATTERY_RESPONSE_LENGTH: usize = 5;
74const BATTERY_RESPONSE_PAYLOAD_LENGTH: u8 = 0x03;
75const BATTERY_RESPONSE_TYPE: u8 = 0x0c;
76const PIECE_STATUS_RESPONSE_PAYLOAD_LENGTH: u8 = 0x89;
77const PIECE_STATUS_RESPONSE_TYPE: u8 = 0x0b;
78const TRACKED_PIECE_BYTES: usize = 4;
79const PIECE_STATUS_RESPONSE_LENGTH: usize =
80  RESPONSE_HEADER_LENGTH + TRACKED_PIECE_COUNT * TRACKED_PIECE_BYTES;
81
82/// Decodes a [position notification] emitted by the [position characteristic].
83///
84/// Bytes two through thirty-three contain the packed state of all 64 squares.
85/// Any trailing bytes are transport metadata and are ignored.
86///
87/// # Examples
88///
89/// ```
90/// use chessnut_move::protocol::{
91///     Color, File, Piece, PieceKind, Rank, Square,
92///     decode_position_notification,
93/// };
94///
95/// let mut notification = [0_u8; 34];
96/// notification[2] = 0x21; // h8 = black queen, g8 = black king
97/// let position = decode_position_notification(&notification)?;
98///
99/// assert_eq!(
100///     position.piece_at(Square::new(File::H, Rank::Eight)),
101///     Some(Piece {
102///         color: Color::Black,
103///         kind: PieceKind::Queen,
104///     }),
105/// );
106/// # Ok::<(), chessnut_move::protocol::DecodePositionNotificationError>(())
107/// ```
108///
109/// # Errors
110///
111/// Returns [`DecodePositionNotificationError::NotificationTooShort`] when
112/// fewer than 34 bytes are supplied.
113///
114/// Returns
115/// [`DecodePositionNotificationError::InvalidPiece`] when a square contains an
116/// unknown piece code.
117///
118/// [position notification]: BoardEvent::PositionChanged
119/// [position characteristic]: transport::gatt::Characteristic::PositionNotification
120pub fn decode_position_notification(
121  bytes: &[u8],
122) -> Result<Position, DecodePositionNotificationError> {
123  let _span = trace_span!(
124    "decode_position_notification",
125    notification_len = bytes.len()
126  );
127  let expected_minimum = BOARD_STATE_OFFSET + BOARD_STATE_LENGTH;
128  let payload = bytes
129    .get(BOARD_STATE_OFFSET..)
130    .and_then(|payload| payload.first_chunk::<BOARD_STATE_LENGTH>())
131    .ok_or(NotificationTooShortError {
132      expected: expected_minimum,
133      actual: bytes.len(),
134    })?;
135
136  let packed = PackedSquares::from_bytes(*payload);
137  let squares = packed.decode(decode_piece)?;
138  trace_event!("decoded position notification");
139
140  Ok(Position::new(squares))
141}
142
143/// Decodes a battery or tracked-piece response notification.
144///
145/// The notification type byte determines whether the returned event is
146/// [`BoardEvent::BatteryStatus`] or [`BoardEvent::PieceStatus`].
147///
148/// # Examples
149///
150/// ```
151/// use chessnut_move::protocol::{
152///     BatteryStatus, BoardEvent, decode_response_notification,
153/// };
154///
155/// let event = decode_response_notification(&[0x41, 0x03, 0x0c, 0x01, 72])?;
156/// assert_eq!(
157///     event,
158///     BoardEvent::BatteryStatus(BatteryStatus {
159///         charging: true,
160///         percentage: 72,
161///     }),
162/// );
163/// # Ok::<(), chessnut_move::protocol::DecodeResponseNotificationError>(())
164/// ```
165///
166/// # Errors
167///
168/// Returns [`DecodeResponseNotificationError::NotificationTooShort`] for a
169/// truncated frame, [`DecodeResponseNotificationError::InvalidNotificationHeader`]
170/// for invalid header bytes, or
171/// [`DecodeResponseNotificationError::UnexpectedNotification`] for an
172/// unsupported response type.
173///
174/// Battery and piece payload validation errors are
175/// returned through [`DecodeResponseNotificationError::BatteryStatus`] and
176/// [`DecodeResponseNotificationError::PieceStatus`].
177pub fn decode_response_notification(
178  bytes: &[u8],
179) -> Result<BoardEvent, DecodeResponseNotificationError> {
180  let _span = trace_span!(
181    "decode_response_notification",
182    notification_len = bytes.len()
183  );
184  require_length(bytes, RESPONSE_HEADER_LENGTH)?;
185  require_header_byte(bytes, 0, RESPONSE_HEADER)?;
186
187  let event = match bytes[2] {
188    BATTERY_RESPONSE_TYPE => decode_battery_status(bytes).map(BoardEvent::BatteryStatus),
189    PIECE_STATUS_RESPONSE_TYPE => decode_piece_status(bytes).map(BoardEvent::PieceStatus),
190    response_type => Err(DecodeResponseNotificationError::UnexpectedNotification(
191      response_type,
192    )),
193  }?;
194
195  match event {
196    BoardEvent::BatteryStatus(_status) => trace_event!(
197      response = "battery_status",
198      charging = _status.charging,
199      percentage = _status.percentage,
200      "decoded command response"
201    ),
202    BoardEvent::PieceStatus(_) => trace_event!(
203      response = "piece_status",
204      piece_count = TRACKED_PIECE_COUNT,
205      "decoded command response"
206    ),
207    BoardEvent::PositionChanged(_) => {}
208  }
209
210  Ok(event)
211}
212
213fn decode_battery_status(bytes: &[u8]) -> Result<BatteryStatus, DecodeResponseNotificationError> {
214  require_length(bytes, BATTERY_RESPONSE_LENGTH)?;
215  require_header_byte(bytes, 1, BATTERY_RESPONSE_PAYLOAD_LENGTH)?;
216
217  let charging = match bytes[3] {
218    0 => false,
219    1 => true,
220    value => return Err(BatteryStatusError::InvalidChargingFlag(value).into()),
221  };
222  let percentage = validate_battery_percentage(bytes[4])?;
223
224  Ok(BatteryStatus {
225    charging,
226    percentage,
227  })
228}
229
230fn decode_piece_status(bytes: &[u8]) -> Result<PieceStatus, DecodeResponseNotificationError> {
231  require_length(bytes, PIECE_STATUS_RESPONSE_LENGTH)?;
232  require_header_byte(bytes, 1, PIECE_STATUS_RESPONSE_PAYLOAD_LENGTH)?;
233
234  let mut pieces = [TrackedPieceStatus::default(); TRACKED_PIECE_COUNT];
235
236  for (index, status) in pieces.iter_mut().enumerate() {
237    let offset = RESPONSE_HEADER_LENGTH + index * TRACKED_PIECE_BYTES;
238    let identity = bytes[offset];
239    let battery_percentage = bytes[offset + 3];
240
241    *status = TrackedPieceStatus {
242      piece: decode_tracked_piece(index, identity)?,
243      x: bytes[offset + 1],
244      y: bytes[offset + 2],
245      battery_percentage: validate_tracked_piece_battery(index, battery_percentage)?,
246    };
247  }
248
249  Ok(PieceStatus { pieces })
250}
251
252// The response carries an identity byte for every physical tracked piece.
253// Index order is stable, but the identity is still validated so firmware
254// changes or corrupt frames cannot silently relabel a piece.
255fn decode_tracked_piece(index: usize, value: u8) -> Result<Piece, PieceStatusError> {
256  use Color::{Black, White};
257  use PieceKind::{Bishop, King, Knight, Pawn, Queen, Rook};
258
259  let piece = match value {
260    0x01 => Piece {
261      color: White,
262      kind: Pawn,
263    },
264    0x02 => Piece {
265      color: White,
266      kind: Rook,
267    },
268    0x03 => Piece {
269      color: White,
270      kind: Knight,
271    },
272    0x04 => Piece {
273      color: White,
274      kind: Bishop,
275    },
276    0x05 => Piece {
277      color: White,
278      kind: Queen,
279    },
280    0x06 => Piece {
281      color: White,
282      kind: King,
283    },
284    0x07 => Piece {
285      color: Black,
286      kind: Pawn,
287    },
288    0x08 => Piece {
289      color: Black,
290      kind: Rook,
291    },
292    0x09 => Piece {
293      color: Black,
294      kind: Knight,
295    },
296    0x0a => Piece {
297      color: Black,
298      kind: Bishop,
299    },
300    0x0b => Piece {
301      color: Black,
302      kind: Queen,
303    },
304    0x0c => Piece {
305      color: Black,
306      kind: King,
307    },
308    value => {
309      return Err(PieceStatusError::InvalidTrackedPiece { index, value });
310    }
311  };
312
313  Ok(piece)
314}
315
316fn validate_battery_percentage(value: u8) -> Result<u8, BatteryStatusError> {
317  if value <= 100 {
318    Ok(value)
319  } else {
320    Err(BatteryStatusError::InvalidBatteryPercentage(value))
321  }
322}
323
324fn validate_tracked_piece_battery(index: usize, value: u8) -> Result<Option<u8>, PieceStatusError> {
325  // Hardware reports 0xff when a physical piece's battery telemetry is
326  // unavailable. Values between 101 and 254 are not defined by the protocol.
327  match value {
328    0..=100 => Ok(Some(value)),
329    0xff => Ok(None),
330    _ => Err(PieceStatusError::InvalidTrackedPieceBattery { index, value }),
331  }
332}
333
334fn require_length(bytes: &[u8], expected: usize) -> Result<(), NotificationTooShortError> {
335  if bytes.len() < expected {
336    Err(NotificationTooShortError {
337      expected,
338      actual: bytes.len(),
339    })
340  } else {
341    Ok(())
342  }
343}
344
345fn require_header_byte(
346  bytes: &[u8],
347  offset: usize,
348  expected: u8,
349) -> Result<(), DecodeResponseNotificationError> {
350  let actual = bytes[offset];
351  if actual == expected {
352    Ok(())
353  } else {
354    Err(DecodeResponseNotificationError::InvalidNotificationHeader {
355      offset,
356      expected,
357      actual,
358    })
359  }
360}
361
362#[cfg(test)]
363mod tests {
364  use super::*;
365
366  const TRACKED_PIECE_IDENTITIES: [u8; TRACKED_PIECE_COUNT] = [
367    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, 10,
368    11, 11, 12,
369  ];
370
371  #[test]
372  fn decodes_battery_status_response() {
373    let event = decode_response_notification(&[0x41, 0x03, 0x0c, 0x01, 87]);
374
375    assert_eq!(
376      event,
377      Ok(BoardEvent::BatteryStatus(BatteryStatus {
378        charging: true,
379        percentage: 87,
380      }))
381    );
382  }
383
384  #[test]
385  fn rejects_invalid_battery_status_fields() {
386    assert_eq!(
387      decode_response_notification(&[0x41, 0x03, 0x0c, 0x02, 87]),
388      Err(BatteryStatusError::InvalidChargingFlag(0x02).into())
389    );
390    assert_eq!(
391      decode_response_notification(&[0x41, 0x03, 0x0c, 0x00, 101]),
392      Err(BatteryStatusError::InvalidBatteryPercentage(101).into())
393    );
394  }
395
396  #[test]
397  fn decodes_all_tracked_piece_statuses() {
398    let response = piece_status_response();
399    let BoardEvent::PieceStatus(status) = decode_response_notification(&response).unwrap() else {
400      panic!("expected a piece-status event");
401    };
402
403    assert_eq!(
404      status.pieces[0],
405      TrackedPieceStatus {
406        piece: Piece {
407          color: Color::White,
408          kind: PieceKind::Pawn,
409        },
410        x: 0,
411        y: 255,
412        battery_percentage: Some(50),
413      }
414    );
415    assert_eq!(
416      status.pieces[16].piece,
417      Piece {
418        color: Color::White,
419        kind: PieceKind::King,
420      }
421    );
422    assert_eq!(
423      status.pieces[17].piece,
424      Piece {
425        color: Color::Black,
426        kind: PieceKind::Pawn,
427      }
428    );
429    assert_eq!(
430      status.pieces[33].piece,
431      Piece {
432        color: Color::Black,
433        kind: PieceKind::King,
434      }
435    );
436    assert_eq!(status.pieces[33].x, 33);
437    assert_eq!(status.pieces[33].y, 222);
438    assert_eq!(status.pieces[33].battery_percentage, Some(83));
439  }
440
441  #[test]
442  fn decodes_unavailable_tracked_piece_battery() {
443    let mut response = piece_status_response();
444    response[RESPONSE_HEADER_LENGTH + 3] = 0xff;
445
446    let BoardEvent::PieceStatus(status) = decode_response_notification(&response).unwrap() else {
447      panic!("expected a piece-status event");
448    };
449
450    assert_eq!(status.pieces[0].battery_percentage, None);
451  }
452
453  #[test]
454  fn rejects_invalid_tracked_piece_fields() {
455    let mut invalid_identity = piece_status_response();
456    invalid_identity[RESPONSE_HEADER_LENGTH + 5 * TRACKED_PIECE_BYTES] = 0xff;
457    assert_eq!(
458      decode_response_notification(&invalid_identity),
459      Err(
460        PieceStatusError::InvalidTrackedPiece {
461          index: 5,
462          value: 0xff,
463        }
464        .into()
465      )
466    );
467
468    let mut invalid_battery = piece_status_response();
469    invalid_battery[RESPONSE_HEADER_LENGTH + 7 * TRACKED_PIECE_BYTES + 3] = 101;
470    assert_eq!(
471      decode_response_notification(&invalid_battery),
472      Err(
473        PieceStatusError::InvalidTrackedPieceBattery {
474          index: 7,
475          value: 101,
476        }
477        .into()
478      )
479    );
480  }
481
482  #[test]
483  fn validates_response_length_and_header() {
484    assert_eq!(
485      decode_response_notification(&[0x41, 0x03]),
486      Err(
487        NotificationTooShortError {
488          expected: RESPONSE_HEADER_LENGTH,
489          actual: 2,
490        }
491        .into()
492      )
493    );
494    assert_eq!(
495      decode_response_notification(&[0x40, 0x03, 0x0c, 0, 50]),
496      Err(DecodeResponseNotificationError::InvalidNotificationHeader {
497        offset: 0,
498        expected: 0x41,
499        actual: 0x40,
500      })
501    );
502    assert_eq!(
503      decode_response_notification(&[0x41, 0x04, 0x0c, 0, 50]),
504      Err(DecodeResponseNotificationError::InvalidNotificationHeader {
505        offset: 1,
506        expected: 0x03,
507        actual: 0x04,
508      })
509    );
510    assert_eq!(
511      decode_response_notification(&[0x41, 0x01, 0xff]),
512      Err(DecodeResponseNotificationError::UnexpectedNotification(
513        0xff
514      ))
515    );
516  }
517
518  #[test]
519  fn rejects_short_piece_status_response() {
520    let response = piece_status_response();
521
522    assert_eq!(
523      decode_response_notification(&response[..response.len() - 1]),
524      Err(
525        NotificationTooShortError {
526          expected: PIECE_STATUS_RESPONSE_LENGTH,
527          actual: PIECE_STATUS_RESPONSE_LENGTH - 1,
528        }
529        .into()
530      )
531    );
532  }
533
534  fn piece_status_response() -> [u8; PIECE_STATUS_RESPONSE_LENGTH] {
535    let mut response = [0; PIECE_STATUS_RESPONSE_LENGTH];
536    response[..RESPONSE_HEADER_LENGTH].copy_from_slice(&[
537      RESPONSE_HEADER,
538      PIECE_STATUS_RESPONSE_PAYLOAD_LENGTH,
539      PIECE_STATUS_RESPONSE_TYPE,
540    ]);
541
542    for (index, identity) in TRACKED_PIECE_IDENTITIES.iter().copied().enumerate() {
543      let offset = RESPONSE_HEADER_LENGTH + index * TRACKED_PIECE_BYTES;
544      response[offset] = identity;
545      response[offset + 1] = index as u8;
546      response[offset + 2] = u8::MAX - index as u8;
547      response[offset + 3] = 50 + index as u8;
548    }
549
550    response
551  }
552}