keydous-bridge 0.1.2

Linux bridge for configuring Keydous keyboards with the official web driver
use std::{collections::HashSet, sync::Arc, time::Duration};

use chrono::{Datelike, Local, Timelike};
use clap::{Parser, Subcommand, ValueEnum};
use keydous_bridge::{
    catalog::{HidCatalog, SimulatedCatalog},
    server::{ServerConfig, serve, serve_with_device_io},
    transport::{AccessPolicy, DeviceIo, ScopedHidTransport},
};
use tokio::net::TcpListener;

#[derive(Parser)]
#[command(version)]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    Serve {
        #[arg(long)]
        development_origin: Option<String>,
        #[arg(long)]
        simulate: bool,
        #[arg(long)]
        allow_settings_write: bool,
    },
    ProbeVersions,
    SetLight {
        #[arg(value_enum)]
        effect: LightEffect,
        #[arg(long, default_value = "ffffff", value_parser = parse_rgb)]
        color: [u8; 3],
        #[arg(long, default_value_t = 4, value_parser = clap::value_parser!(u8).range(0..=4))]
        brightness: u8,
        #[arg(long, default_value_t = 2, value_parser = clap::value_parser!(u8).range(0..=4))]
        speed: u8,
    },
}

#[derive(Clone, Copy, ValueEnum)]
enum LightEffect {
    Off,
    Static,
    Breath,
    Neon,
    Wave,
    Dazzle,
    Laser,
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let cli = Cli::parse();
    match cli.command {
        Command::Serve {
            development_origin,
            simulate,
            allow_settings_write,
        } => {
            let mut config = ServerConfig::official();
            if let Some(origin) = development_origin {
                config.allowed_origins.push(origin);
            }
            let listener = TcpListener::bind(config.address).await?;
            config.address = listener.local_addr()?;
            if simulate {
                serve(listener, config, Arc::new(SimulatedCatalog), async {
                    let _ = tokio::signal::ctrl_c().await;
                })
                .await?;
            } else {
                let catalog = HidCatalog::enumerate()?;
                if catalog.is_empty() {
                    return Err(std::io::Error::new(
                        std::io::ErrorKind::NotFound,
                        "no supported Keydous keyboard found",
                    )
                    .into());
                }
                let policy = if allow_settings_write {
                    AccessPolicy::Settings
                } else {
                    AccessPolicy::Scoped
                };
                let transport =
                    ScopedHidTransport::with_devices(catalog.transport_devices(), policy)?;
                tokio::spawn(sync_clocks_on_connect());
                serve_with_device_io(
                    listener,
                    config,
                    Arc::new(catalog),
                    Arc::new(transport),
                    async {
                        let _ = tokio::signal::ctrl_c().await;
                    },
                )
                .await?;
            }
        }
        Command::ProbeVersions => {
            let catalog = HidCatalog::enumerate()?;
            let paths = catalog.vendor_paths();
            let transport = ScopedHidTransport::with_devices(
                catalog.transport_devices(),
                AccessPolicy::Scoped,
            )?;
            for path in paths {
                println!("{path}");
                for (name, command) in [("keyboard", 0x8f), ("display", 0xad)] {
                    let mut request = [0_u8; 64];
                    request[0] = command;
                    transport.send(&path, &request, 0)?;
                    let response = transport.read(&path)?;
                    let hex = response
                        .iter()
                        .map(|byte| format!("{byte:02x}"))
                        .collect::<Vec<_>>()
                        .join(" ");
                    println!("{name}: {hex}");
                }
            }
        }
        Command::SetLight {
            effect,
            color,
            brightness,
            speed,
        } => {
            let catalog = HidCatalog::enumerate()?;
            let paths = catalog.vendor_paths();
            let transport = ScopedHidTransport::with_devices(
                catalog.transport_devices(),
                AccessPolicy::Settings,
            )?;
            let report = light_report(effect, color, brightness, speed);
            for path in paths {
                transport.send(&path, &report, 0)?;
                println!("{path}");
            }
        }
    }
    Ok(())
}

async fn sync_clocks_on_connect() {
    let mut synced_paths = HashSet::new();
    let mut interval = tokio::time::interval(Duration::from_secs(2));
    loop {
        interval.tick().await;
        let Ok(catalog) = HidCatalog::enumerate() else {
            continue;
        };
        let devices = catalog.transport_devices();
        let current_paths = devices
            .iter()
            .map(|(path, _)| path.clone())
            .collect::<HashSet<_>>();
        synced_paths.retain(|path| current_paths.contains(path));
        let Ok(transport) =
            ScopedHidTransport::with_devices(devices.clone(), AccessPolicy::Scoped)
        else {
            continue;
        };
        for (path, _) in devices {
            if synced_paths.contains(&path) {
                continue;
            }
            let now = Local::now();
            let report = clock_report(
                now.year() as u16,
                now.month() as u8,
                now.day() as u8,
                now.hour() as u8,
                now.minute() as u8,
                now.second() as u8,
            );
            match transport.send(&path, &report, 0) {
                Ok(()) => {
                    eprintln!(
                        "clock synchronized path={} timestamp={:04}-{:02}-{:02}T{:02}:{:02}:{:02}",
                        path,
                        now.year(),
                        now.month(),
                        now.day(),
                        now.hour(),
                        now.minute(),
                        now.second()
                    );
                    synced_paths.insert(path);
                }
                Err(error) => eprintln!("clock synchronization failed path={path} error={error}"),
            }
        }
    }
}

fn clock_report(
    year: u16,
    month: u8,
    day: u8,
    hour: u8,
    minute: u8,
    second: u8,
) -> [u8; 64] {
    let mut report = [0_u8; 64];
    report[0] = 0x28;
    report[8..10].copy_from_slice(&year.to_be_bytes());
    report[10] = month;
    report[11] = day;
    report[12] = hour;
    report[13] = minute;
    report[14] = second;
    report
}

fn parse_rgb(value: &str) -> Result<[u8; 3], String> {
    let value = value.strip_prefix('#').unwrap_or(value);
    if value.len() != 6 {
        return Err("color must contain exactly six hexadecimal digits".into());
    }
    let color =
        u32::from_str_radix(value, 16).map_err(|_| "color must be hexadecimal".to_string())?;
    Ok([
        ((color >> 16) & 0xff) as u8,
        ((color >> 8) & 0xff) as u8,
        (color & 0xff) as u8,
    ])
}

fn light_report(effect: LightEffect, color: [u8; 3], brightness: u8, speed: u8) -> [u8; 64] {
    let mut report = [0_u8; 64];
    report[0] = 0x07;
    report[1] = match effect {
        LightEffect::Off => 0,
        LightEffect::Static => 1,
        LightEffect::Breath => 2,
        LightEffect::Neon => 3,
        LightEffect::Wave => 4,
        LightEffect::Dazzle => 5,
        LightEffect::Laser => 6,
    };
    report[2] = 4 - speed;
    report[3] = brightness;
    report[4] = 7;
    report[5..8].copy_from_slice(&color);
    report
}

#[cfg(test)]
mod tests {
    use super::{LightEffect, clock_report, light_report, parse_rgb};

    #[test]
    fn clock_report_matches_the_recovered_layout() {
        let report = clock_report(2026, 7, 30, 1, 2, 3);
        assert_eq!(
            &report[..16],
            &[0x28, 0, 0, 0, 0, 0, 0, 0, 0x07, 0xea, 7, 30, 1, 2, 3, 0]
        );
    }

    #[test]
    fn static_light_report_matches_the_recovered_layout() {
        let report = light_report(LightEffect::Static, [0x12, 0x34, 0x56], 3, 1);
        assert_eq!(
            &report[..9],
            &[0x07, 0x01, 0x03, 0x03, 0x07, 0x12, 0x34, 0x56, 0x00]
        );
    }

    #[test]
    fn rgb_parser_accepts_web_colors() {
        assert_eq!(parse_rgb("#12abef").unwrap(), [0x12, 0xab, 0xef]);
    }
}