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]);
}
}