chessnut 0.0.1

Rust driver for Chessnut electronic chess boards
Documentation
use std::{
    collections::BTreeMap as Map,
    ffi::CStr,
    thread::sleep,
    time::{Duration, Instant},
};

use hidapi::HidDevice as Device;

use crate::{Color, Error, Piece, Position, Response, Result, Role, Square};

pub const VID: u16 = 0x2d80;
pub const USAGE_PAGE: u16 = 0xFF00;

pub fn pids() -> Map<u16, &'static str> {
    pub const PIDS: &[u16] = &[0x8000, 0x8100, 0x8200, 0x8300, 0x8400, 0x8500, 0x8600];
    PIDS.iter().copied().map(|pid| (pid, "unknown")).collect()
}

#[derive(Debug)]
pub struct Connection {
    pub(crate) device: Device,
    earliest_write: Option<Instant>,
    read_timeout: Duration,
}

fn parse_nibble(nibble: u8) -> Result<Option<Piece>> {
    use {Color::*, Role::*};

    if nibble == 0 {
        return Ok(None);
    }
    let (color, role) = match nibble {
        // 0 => None,
        0x1 => (Black, Queen),
        0x2 => (Black, King),
        0x3 => (Black, Bishop),
        0x4 => (Black, Pawn),
        0x5 => (Black, Knight),
        0x6 => (White, Rook),
        0x7 => (White, Pawn),
        0x8 => (Black, Rook),
        0x9 => (White, Bishop),
        0xA => (White, Knight),
        0xB => (White, Queen),
        0xC => (White, King),
        _ => {
            error!("problem parsing {nibble} as piece");
            return Err(Error::InvalidReport("nibble not a piece"));
        }
    };
    Ok(Some(Piece { color, role }))
}

impl TryFrom<(u8, Vec<u8>)> for Response {
    type Error = Error;

    fn try_from((indicator, report): (u8, Vec<u8>)) -> Result<Response> {
        match indicator {
            0x01 => {
                if report.len() < 32 {
                    return Err(Error::InvalidReport("short position report"));
                }

                // A1 = 0, B1 = 1, ..., H8 = 63
                let mut square = 64u8;
                let mut position = Position::empty();
                for byte in report.iter().take(32) {
                    square -= 1;
                    if let Some(piece) = parse_nibble(byte & 0xF)? {
                        let square = Square::try_from(square).unwrap();
                        position.set_piece_at(square, piece);
                    }
                    square -= 1;
                    if let Some(piece) = parse_nibble((byte & 0xF0) >> 4)? {
                        let square = Square::try_from(square).unwrap();
                        position.set_piece_at(square, piece);
                    }
                }
                Ok(Response::Position(position))
            }
            0x28 => {
                let (kind, content) = report
                    .split_first()
                    .ok_or(Error::InvalidReport("version report missing sub-indicator"))?;
                let content = CStr::from_bytes_until_nul(content)
                    .map_err(|_| Error::InvalidReport("version report invalid C string"))?
                    .to_string_lossy()
                    .into_owned();
                match kind {
                    0x0 => Ok(Response::BleVersion(content)),
                    0x1 => Ok(Response::McuVersion(content)),
                    _ => Err(Error::InvalidReport("version report invalid version kind")),
                }
            }
            _ => Err(Error::UnknownReport(indicator)),
        }
    }
}

impl Connection {
    const READ_TIMEOUT: Duration = Duration::from_millis(100);
    const WRITE_INTERVAL: Duration = Duration::from_millis(200);

    pub fn new() -> Result<Self> {
        info!(":: HID connect");
        let mut api = hidapi::HidApi::new_without_enumerate().map_err(Error::Connect)?;
        api.add_devices(VID, 0).map_err(Error::Connect)?;

        // find device
        let pids = pids();
        let mut iter = api.device_list().filter(|info| {
            pids.contains_key(&(info.product_id() & 0xFF00)) && info.usage_page() == USAGE_PAGE
        });
        let info = iter.next().ok_or(Error::NoBoardFound)?;
        if iter.next().is_some() {
            // warn that we picked first of multiples
        }

        // open device
        let device = info.open_device(&api).map_err(Error::Connect)?;
        let info = device.get_device_info().map_err(Error::Connect)?;

        // NB: "serial" is just set to "Chessnut"
        info!("...ok {:x}:{:x}", info.vendor_id(), info.product_id());

        Ok(Self { device, earliest_write: None, read_timeout: Self::READ_TIMEOUT })
    }

    // - first byte is indicator of report type
    // - second byte is size of following payload (spurious bytes should be truncated)
    pub fn report(&mut self) -> Result<(u8, Vec<u8>)> {
        const MAX_SHORT_HID_REPORTS: usize = 5;
        let mut hid_report = vec![0u8; 256];
        let mut short_hid_reports = 0;
        loop {
            let size = self
                .device
                .read_timeout(&mut hid_report, self.read_timeout.as_millis() as _)
                .map_err(Error::Read)?;
            hid_report.resize(size, 0);
            debug!("<- {}", hex::encode(&hid_report));
            if hid_report.len() < 2 {
                warn!("too short HID report: {}", hex::encode(&hid_report));
                short_hid_reports += 1;
                if short_hid_reports >= MAX_SHORT_HID_REPORTS {
                    error!("{MAX_SHORT_HID_REPORTS} consecutive short HID reports");
                    return Err(Error::BoardNotOn);
                }
                continue;
            }
            let mut report = hid_report.split_off(2);
            let indicator = hid_report[0];
            // debug!("    indicator {indicator:02x}");
            let len = hid_report[1] as usize;
            if report.len() < len {
                warn!(
                    "too short report (indicator {}, len {}): {}",
                    indicator,
                    len,
                    hex::encode(&report)
                );
                continue;
            }
            report.resize(len, 0);
            return Ok((indicator, report));
        }
    }

    pub fn execute(&mut self, command: &[u8]) -> Result<()> {
        debug!("-> {}", hex::encode(command));

        // TODO: reconnect on errors
        self.earliest_write.into_iter().for_each(sleep_until);
        let count = self.device.write(command).map_err(Error::Write)?;
        assert_eq!(count, command.len());
        self.earliest_write = Some(Instant::now() + Self::WRITE_INTERVAL);
        Ok(())
    }

    pub fn set_mode<M: crate::Mode>(mut self) -> Result<Self> {
        use crate::Command;
        info!(":: mode {}", M::MODE);
        self.execute(&Command::switch_mode::<M>())?;
        debug!("...ok");
        Ok(self)
    }
}

// the nightly-only experimental std::thread::sleep_until
fn sleep_until(deadline: Instant) {
    let now = Instant::now();
    if let Some(delay) = deadline.checked_duration_since(now) {
        sleep(delay);
    }
}