use std::{collections::HashSet, process::Command as ProcessCommand, sync::Arc, time::Duration};
use chrono::{Datelike, Local, Timelike};
use clap::{Parser, Subcommand, ValueEnum};
use keydous_bridge::{
catalog::{HidCatalog, SimulatedCatalog},
profiles::{CommandSet, SUPPORTED_PROFILES},
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 {
#[command(about = "Print read-only HID information for connected Keydous devices")]
Inspect,
Serve {
#[arg(long)]
development_origin: Option<String>,
#[arg(long)]
simulate: bool,
#[arg(long)]
allow_settings_write: bool,
},
ProbeVersions,
GetReportRate {
#[arg(long, default_value_t = 0)]
profile: u8,
},
SetReportRate {
#[arg(value_parser = parse_report_rate)]
rate: u16,
#[arg(long, default_value_t = 0)]
profile: u8,
},
CycleReportRate {
#[arg(long, default_value_t = 0)]
profile: u8,
},
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::Inspect => inspect_devices()?,
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::GetReportRate { profile } => {
let catalog = HidCatalog::enumerate()?;
let device = catalog
.active_transport_device()
.ok_or("no supported Keydous keyboard found")?;
let path = device.0.clone();
let transport = ScopedHidTransport::with_devices([device], AccessPolicy::Scoped)?;
let rate = get_report_rate(&transport, &path, profile)?;
println!("{path}: {rate} Hz");
}
Command::SetReportRate { rate, profile } => {
let catalog = HidCatalog::enumerate()?;
let device = catalog
.active_transport_device()
.ok_or("no supported Keydous keyboard found")?;
let connection = report_rate_connection(device.1);
let path = device.0.clone();
let transport = ScopedHidTransport::with_devices([device], AccessPolicy::Settings)?;
set_report_rate(&transport, &path, profile, rate)?;
println!("{path}: {rate} Hz");
notify_report_rate(rate, connection);
}
Command::CycleReportRate { profile } => {
let catalog = HidCatalog::enumerate()?;
let device = catalog
.active_transport_device()
.ok_or("no supported Keydous keyboard found")?;
let connection = report_rate_connection(device.1);
let path = device.0.clone();
let transport = ScopedHidTransport::with_devices([device], AccessPolicy::Settings)?;
let current = get_report_rate(&transport, &path, profile)?;
let rate = match current {
1000 => 2000,
2000 => 4000,
4000 => 8000,
_ => 1000,
};
set_report_rate(&transport, &path, profile, rate)?;
println!("{path}: {rate} Hz");
notify_report_rate(rate, connection);
}
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 inspect_devices() -> Result<(), hidapi::HidError> {
const KEYDOUS_VENDOR_ID: u16 = 0x3151;
let api = hidapi::HidApi::new()?;
let devices = api
.device_list()
.filter(|device| device.vendor_id() == KEYDOUS_VENDOR_ID)
.collect::<Vec<_>>();
if devices.is_empty() {
println!("No Keydous HID interfaces found.");
return Ok(());
}
println!("Keydous HID interfaces: {}", devices.len());
for (index, device) in devices.into_iter().enumerate() {
let profile = SUPPORTED_PROFILES.iter().find(|profile| {
device.vendor_id() == profile.vendor_id
&& device.product_id() == profile.product_id
&& device.usage_page() == profile.usage_page
&& device.usage() == profile.usage
&& device.interface_number() == profile.interface_number
});
let support = profile.map_or("unsupported", |profile| profile.name);
println!();
println!("Interface {}", index + 1);
println!(" path: {}", device.path().to_string_lossy());
println!(
" usb_id: {:04x}:{:04x}",
device.vendor_id(),
device.product_id()
);
println!(
" manufacturer: {}",
optional_text(device.manufacturer_string())
);
println!(" product: {}", optional_text(device.product_string()));
println!(" release: {:04x}", device.release_number());
println!(" interface_number: {}", device.interface_number());
println!(" usage_page: 0x{:04x}", device.usage_page());
println!(" usage: 0x{:04x}", device.usage());
println!(" bus: {:?}", device.bus_type());
println!(" profile: {support}");
}
Ok(())
}
fn optional_text(value: Option<&str>) -> &str {
value
.filter(|value| !value.trim().is_empty())
.unwrap_or("unknown")
}
fn parse_report_rate(value: &str) -> Result<u16, String> {
let rate = value
.parse::<u16>()
.map_err(|_| "polling rate must be a number".to_string())?;
report_rate_value(rate).map(|_| rate).ok_or_else(|| {
"polling rate must be one of 125, 250, 500, 1000, 2000, 4000, 8000".to_string()
})
}
fn report_rate_value(rate: u16) -> Option<u8> {
match rate {
8000 => Some(0),
4000 => Some(1),
2000 => Some(2),
1000 => Some(3),
500 => Some(4),
250 => Some(5),
125 => Some(6),
_ => None,
}
}
fn report_rate_from_value(value: u8) -> Option<u16> {
match value {
0 => Some(8000),
1 => Some(4000),
2 => Some(2000),
3 => Some(1000),
4 => Some(500),
5 => Some(250),
6 => Some(125),
_ => None,
}
}
fn get_report_rate(
transport: &ScopedHidTransport,
path: &str,
profile: u8,
) -> Result<u16, Box<dyn std::error::Error>> {
let mut report = [0_u8; 64];
report[0] = 0x83;
report[1] = profile;
transport.send(path, &report, 0)?;
let response = transport.read(path)?;
let value = response
.get(2)
.copied()
.ok_or("polling-rate response is too short")?;
report_rate_from_value(value)
.ok_or_else(|| format!("device returned unknown polling-rate value {value}").into())
}
fn set_report_rate(
transport: &ScopedHidTransport,
path: &str,
profile: u8,
rate: u16,
) -> Result<(), Box<dyn std::error::Error>> {
let value = report_rate_value(rate).ok_or("unsupported polling rate")?;
let mut report = [0_u8; 64];
report[0] = 0x03;
report[1] = profile;
report[2] = value;
transport.send(path, &report, 0)?;
std::thread::sleep(Duration::from_millis(100));
let applied = get_report_rate(transport, path, profile)?;
if applied != rate {
return Err(format!("device reported {applied} Hz after requesting {rate} Hz").into());
}
Ok(())
}
fn report_rate_connection(command_set: CommandSet) -> &'static str {
match command_set {
CommandSet::Nj98CpV4 => "USB",
CommandSet::Nj98CpV4Wireless => "2.4 GHz",
}
}
fn notify_report_rate(rate: u16, connection: &str) {
let message = format!("{connection} polling rate: {rate} Hz");
if let Err(error) = ProcessCommand::new("notify-send")
.args(["--app-name=Keydous", "Keydous NJ98-CP V4", &message])
.spawn()
{
eprintln!("notification failed: {error}");
}
}
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]);
}
}