use std::sync::Arc;
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()?;
let policy = if allow_settings_write {
AccessPolicy::Settings
} else {
AccessPolicy::Scoped
};
let transport =
ScopedHidTransport::with_devices(catalog.transport_devices(), policy)?;
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(())
}
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, light_report, parse_rgb};
#[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]);
}
}