1use 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
82pub 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
143pub 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
252fn 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 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}