diff --git a/Cargo.lock b/Cargo.lock index 11c00f9..d2ad868 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -105,7 +105,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -120,12 +120,30 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "heck" version = "0.5.0" @@ -145,18 +163,40 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "libc" version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + [[package]] name = "nuphyctl" version = "0.5.1" @@ -164,6 +204,8 @@ dependencies = [ "anyhow", "clap", "hidapi", + "serde_json", + "toml", ] [[package]] @@ -196,6 +238,59 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "foldhash", + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + [[package]] name = "shlex" version = "2.0.1" @@ -219,6 +314,56 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -245,3 +390,15 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index a050119..6925872 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,3 +13,5 @@ clap = { version = "4.6.0", features = ["derive"] } hidapi = { version = "2.6.5", default-features = false, features = [ "linux-static-hidraw", ] } +serde_json = { version = "1.0.154", features = ["preserve_order"] } +toml = "1.1.8" diff --git a/README.md b/README.md index 6c3a10a..f04ee66 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ It currently supports: - setting main/backlight RGB effects - setting side-light effects - setting decorative/strip-light effects +- reading, remapping, backing up and restoring the keymap (declaratively from a TOML file) - sending raw 64-byte HID reports for reverse engineering ## Build @@ -106,6 +107,119 @@ Side/decorative effects: `time`, `neon`, `static`, `breathe`, `rhythm` +## Keymap + +The `keymap` commands read and write the keymap stored on the keyboard (the same data +NuPhy IO edits). Every command that changes the keyboard first saves a binary backup of +the whole keymap, writes only keys whose value differs, then reads the keymap back and +fails unless it matches exactly. Changes persist across replugs; there is no separate +save step. + +Named positions follow the Air75 V3 layout (Windows legends). Any board also accepts +`row,col` and `#index` positions. + +Show firmware version, current mode and keymap geometry: + +```bash +nuphyctl keymap info +nuphyctl keymap info --json +``` + +Layers: `0-3` are Mac mode, `4-7` Windows mode. Within a mode the layers are `base`, +`fn`, `fn2` and `fn3`, so `windows.fn` is layer `5`. Commands accept either form. + +Print the keymap, a single layer, or one mode (`--defaults` reads the factory keymap, +`--changed` shows only keys that differ from it): + +```bash +nuphyctl keymap dump --layer windows.base +nuphyctl keymap dump --mode windows --json +nuphyctl keymap dump --changed +``` + +Save a full binary backup (all layers) and write one back later. Only differing keys are +written, and the result is verified: + +```bash +nuphyctl keymap dump --raw-out air75.bin +nuphyctl keymap restore-backup air75.bin --dry-run +nuphyctl keymap restore-backup air75.bin +``` + +Set one key. Positions are names (`F1`, `ESC`, `YELLOW`, `KNOB`, `KNOB_CCW`, ...), `row,col` +or `#index`; keycodes are names (`BRID`, `F20`, `MISSION_CONTROL`, `DND`, `MO(5)`, `TRNS`, ...) +or hex (`0x00be`): + +```bash +nuphyctl keymap set --layer windows.base F1 BRID +nuphyctl keymap set --layer 4 3,12 MUTE +nuphyctl keymap set --layer 4 KNOB_CW 0x00a9 --json +``` + +List the names: + +```bash +nuphyctl keymap keycodes +nuphyctl keymap positions --json +``` + +### Declarative keymap files + +Keep the keymap in a TOML file (for example in your dotfiles) and apply it. Only keys +listed in the file are touched. `apply` prints the diff (current -> desired), writes only +the changes, verifies them, and is idempotent: a second run reports `no changes`. + +```toml +[windows.base] # layer 4 +F1 = "BRID" +F2 = "BRIU" +F3 = "MISSION_CONTROL" +F6 = "DND" +YELLOW = "DEL" +"3,12" = "MUTE" # row,col and "#index" work too + +[windows.base.knob] # same as KNOB_CCW / KNOB_CW / KNOB +ccw = "VOLD" +cw = "VOLU" + +[windows.fn] # layer 5 +F1 = "F1" +F2 = 0x3b # integers are raw keycodes + +[layers.6] # absolute layer number +ESC = "TRNS" +``` + +```bash +nuphyctl keymap apply keymap.toml --dry-run +nuphyctl keymap apply keymap.toml +nuphyctl keymap apply keymap.toml --json +``` + +Sections are `[mac.]`, `[windows.]` (`base`, `fn`, `fn2`, `fn3`) and +`[layers.]`. Unknown sections, positions or keycodes, and two entries for the same key, +are errors; nothing is written when the file does not parse. + +To start a file from the keyboard's current state, export the keys that differ from the +factory keymap: + +```bash +nuphyctl keymap export > keymap.toml +``` + +### Backups and reset + +Before any write, `set`, `apply`, `restore-backup` and `reset` save the current keymap to +`$XDG_STATE_HOME/nuphyctl/keymap-.bin` (default +`~/.local/state/nuphyctl/`) and print the path. Restore one with +`nuphyctl keymap restore-backup `. + +Reset a mode's layers (or all layers) to the factory keymap. `--yes` is required: + +```bash +nuphyctl keymap reset --mode windows --yes +``` + ## Device selection If more than one matching HID interface is present, narrow the target with one or more selector flags. @@ -158,6 +272,7 @@ cargo run -- raw send --report-id 0 --hex "...128 hex chars..." - `rgb set`, `rgb side`, and `rgb decorative` automatically retry a few common transient HID failures - `rgb side` and `rgb decorative` require `--hex` only for effects that use custom RGB color - `raw send` expects exactly 64 payload bytes +- `keymap` commands negotiate a new session key, so a NuPhy IO tab that is open at the same time may need a reload afterwards ## Reverse engineering diff --git a/docs/reverse-engineering.md b/docs/reverse-engineering.md index d639b70..3f28189 100644 --- a/docs/reverse-engineering.md +++ b/docs/reverse-engineering.md @@ -77,6 +77,71 @@ Device targeting notes: - If no selector is passed, the CLI tries to auto-pick the most likely control interface. - When multiple candidates remain, prefer `--path` for exact reproduction during packet experiments. +## Keymap protocol (implemented) + +Recovered from the NuPhy IO web app (drive.nuphy.io) and verified on an Air75 V3 running +firmware 1.0.17.6. + +Data commands use the same 64-byte framing as the lighting packets, with the length in +byte 4. `K` is the session key negotiated with `0xee` (with `K = 0` nothing is XORed): + +```text +request: 55 cmd 00 sum len^K addr_lo^K addr_hi^K 00^K data^K ... +reply: aa cmd 00 sum len^K addr_lo^K addr_hi^K 00^K data^K ... +``` + +- `sum` is the wrapping sum of bytes `4..63`. +- `addr` is a byte offset; `len` is at most 56 (64 minus the 8 header bytes). +- Replies echo `len`/`addr`; `nuphyctl` matches replies on command, `len` and `addr`. + +Commands: + +| cmd | name | request | reply | +| --- | --- | --- | --- | +| `0xa0` | GetBase | `len 8 addr 0` | `mode, layerCount, layersPerMode, rows, cols, extra` | +| `0xa1` | GetFirmwareInfo | `len 8 addr 0` | `b0 b1 b2 b3 b4 b5`: version `b2.b1.b0`, plus `.(b4 + 256*b5)` when `b3 == 0xaa` | +| `0xb1` | GetDefaultKeys | `len n addr a` | `n` bytes of the factory keymap | +| `0xb2` | GetUseKeys | `len n addr a` | `n` bytes of the active keymap | +| `0xb3` | SetUseKeys | `len n addr a data` | ack | +| `0xb4` | RestoreUseLayers | `len count addr first data = count zero bytes` | ack | + +There is no save/commit command: `SetUseKeys` writes persist across replugs. +`RestoreUseLayers` copies the factory keymap over `count` layers starting at layer +`first` (for example `len 4 addr 4` resets the Windows layers 4-7). + +`GetBase` on the Air75 V3 returns `01 08 04 06 10 82`: current mode Windows, 8 layers, +4 per mode, a 6x16 matrix and `0x82`. When bit 7 of the last byte is set, its low bits +give the number of extra (non-matrix) entries per layer (here 2); otherwise there are 2. + +Keymap layout: + +- Each layer is `2 * (rows * cols + extra)` bytes: 196 on the Air75 V3, 1568 for all 8 layers. +- Entry `i` of layer `L` is at byte `L * 196 + 2 * i`, a big-endian 16-bit keycode. +- Matrix keys come first (`i = row * 16 + col`), then the extras: `i = 96` knob + counter-clockwise (factory `0x00aa` Volume Down), `i = 97` knob clockwise (factory + `0x00a9` Volume Up). +- Layers `0-3` are Mac mode, `4-7` Windows mode; in each, base, Fn (`MO(1)`/`MO(5)` on + the Fn key), and two further layers (factory: side-light controls on Fn+M and a Fn+X layer). + +Air75 V3 positions: row 0 is Esc `(0,0)`, F1-F12 `(0,1..12)`, PrtSc `(0,13)`, the yellow +key `(0,14)` (factory Insert); `(0,15)` is empty. The red top-right key (the swappable knob +module) is `(3,12)` (factory Mute). `nuphyctl keymap positions` lists the rest. + +Keycodes are QMK-style: + +- `0x0000` no key, `0x0001` transparent. +- Basic HID usages `0x04-0xa4` and modifiers `0xe0-0xe7` as-is. +- QMK consumer/system block: `0xa8` Mute, `0xa9`/`0xaa` Volume Up/Down, `0xab-0xaf` media, + `0xb0` Eject, `0xb1` Mail, `0xb2` Calculator, `0xb3` My Computer, `0xb4-0xba` browser keys, + `0xbd`/`0xbe` screen brightness up/down, `0xd1-0xdc` mouse. +- `MO(n)` = `0x5220 | n`; QMK RGB codes `0x7820-0x782a`. +- NuPhy codes `0x7e00 + n`, e.g. `0x7e01-0x7e05` USB/2.4G/BT1-3, `0x7e06` Mission Control + (Linux sees `KEY_SCALE`), `0x7e07` macOS search, `0x7e08` Siri (`KEY_VOICECOMMAND`), + `0x7e09` Launchpad, `0x7e0a`/`0x7e0b` full/area screenshot, `0x7e16` Do Not Disturb + (`KEY_DO_NOT_DISTURB`), `0x7e40`/`0x7e41` lock screen, `0x7e44` emoji. + +`nuphyctl keymap keycodes` prints the full catalogue. + ## 1) Capture packets in Chrome Open in Chrome, then open DevTools Console and paste: diff --git a/src/app.rs b/src/app.rs index 1513fb0..ccc950b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,11 +1,13 @@ use crate::cli::{ - Cli, Command, DeviceSelector, RawSubcommand, RgbColorMode, RgbDecorativeSetArgs, RgbDirection, - RgbEffect, RgbSetArgs, RgbSideEffect, RgbSideSetArgs, RgbSubcommand, + Cli, Command, DeviceSelector, KeymapSubcommand, RawSubcommand, RgbColorMode, + RgbDecorativeSetArgs, RgbDirection, RgbEffect, RgbSetArgs, RgbSideEffect, RgbSideSetArgs, + RgbSubcommand, }; use crate::color::{RgbColor, parse_hex_bytes}; use crate::hid_transport::{ REPORT_LEN, clear_input_reports, list_devices, open_selected_device, send_report, }; +use crate::keymap_cmd; use crate::nuphy_protocol::{KeyboardProtocol, MainLightSettings, SessionKey, SideLightSettings}; use anyhow::{Result, bail}; use hidapi::HidApi; @@ -59,6 +61,17 @@ pub fn run(cli: Cli, api: &HidApi) -> Result<()> { RgbSubcommand::Side(args) => run_rgb_side(api, &args), RgbSubcommand::Decorative(args) => run_rgb_decorative(api, &args), }, + Command::Keymap(keymap) => match keymap.action { + KeymapSubcommand::Info(args) => keymap_cmd::run_info(api, &args), + KeymapSubcommand::Dump(args) => keymap_cmd::run_dump(api, &args), + KeymapSubcommand::Export(args) => keymap_cmd::run_export(api, &args), + KeymapSubcommand::Set(args) => keymap_cmd::run_set(api, &args), + KeymapSubcommand::Apply(args) => keymap_cmd::run_apply(api, &args), + KeymapSubcommand::Reset(args) => keymap_cmd::run_reset(api, &args), + KeymapSubcommand::RestoreBackup(args) => keymap_cmd::run_restore_backup(api, &args), + KeymapSubcommand::Keycodes(args) => keymap_cmd::run_keycodes(&args), + KeymapSubcommand::Positions(args) => keymap_cmd::run_positions(&args), + }, Command::Raw(raw) => match raw.action { RawSubcommand::Send(args) => run_raw_send(api, &args.device, args.report_id, &args.hex), }, @@ -332,7 +345,7 @@ fn run_raw_send(api: &HidApi, selector: &DeviceSelector, report_id: u8, hex: &st Ok(()) } -fn is_retryable_rgb_error(err: &anyhow::Error) -> bool { +pub(crate) fn is_retryable_rgb_error(err: &anyhow::Error) -> bool { err.chain().any(|cause| { let msg = cause.to_string().to_ascii_lowercase(); RETRYABLE_RGB_ERROR_PATTERNS @@ -360,7 +373,22 @@ fn parse_effect_color( } fn print_commands() { - const COMMANDS: &[&str] = &["list", "rgb set", "rgb side", "rgb decorative", "raw send"]; + const COMMANDS: &[&str] = &[ + "list", + "rgb set", + "rgb side", + "rgb decorative", + "keymap info", + "keymap dump", + "keymap export", + "keymap set", + "keymap apply", + "keymap reset", + "keymap restore-backup", + "keymap keycodes", + "keymap positions", + "raw send", + ]; for command in COMMANDS { println!("{command}"); diff --git a/src/cli.rs b/src/cli.rs index 3e8f597..9ad3805 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,4 +1,5 @@ use clap::{Args, Parser, Subcommand, ValueEnum}; +use std::path::PathBuf; #[derive(Parser, Debug)] #[command(name = "nuphyctl", about = "NuPhy keyboard control CLI")] @@ -15,6 +16,8 @@ pub enum Command { List, /// RGB-related commands Rgb(RgbCommand), + /// Keymap commands (read, remap, back up and restore keys) + Keymap(KeymapCommand), /// Send a raw HID output report Raw(RawCommand), } @@ -263,6 +266,187 @@ pub struct RgbDecorativeSetArgs { pub device: DeviceSelector, } +#[derive(Args, Debug)] +pub struct KeymapCommand { + #[command(subcommand)] + pub action: KeymapSubcommand, +} + +#[derive(Subcommand, Debug)] +pub enum KeymapSubcommand { + /// Show firmware version, current mode and keymap geometry + Info(KeymapInfoArgs), + /// Print the keymap (named keycodes and positions) + Dump(KeymapDumpArgs), + /// Print keys that differ from the factory keymap as an `apply` TOML file + Export(KeymapExportArgs), + /// Set one key, then read it back and verify + Set(KeymapSetArgs), + /// Apply a declarative TOML keymap file (only listed keys are touched) + Apply(KeymapApplyArgs), + /// Reset a mode's layers to the factory keymap + Reset(KeymapResetArgs), + /// Write a binary backup back to the keyboard (only differing keys) + RestoreBackup(KeymapRestoreBackupArgs), + /// List known keycode names + Keycodes(KeymapListArgs), + /// List named key positions (Air75 V3 layout) + Positions(KeymapListArgs), +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +pub enum KeymapMode { + Mac, + #[value(alias = "win")] + Windows, +} + +impl KeymapMode { + pub const fn index(self) -> u8 { + match self { + Self::Mac => 0, + Self::Windows => 1, + } + } +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +pub enum KeymapResetMode { + Mac, + #[value(alias = "win")] + Windows, + All, +} + +#[derive(Args, Debug)] +pub struct KeymapInfoArgs { + /// Print JSON + #[arg(long)] + pub json: bool, + + #[command(flatten)] + pub device: DeviceSelector, +} + +#[derive(Args, Debug)] +pub struct KeymapDumpArgs { + /// Only this layer (number like 5, or mode.layer like windows.fn) + #[arg(long, conflicts_with = "mode")] + pub layer: Option, + + /// Only the layers of this mode + #[arg(long, value_enum)] + pub mode: Option, + + /// Read the factory keymap (GetDefaultKeys) instead of the active one + #[arg(long)] + pub defaults: bool, + + /// Only show keys that differ from the factory keymap + #[arg(long, conflicts_with = "defaults")] + pub changed: bool, + + /// Also save the full keymap (all layers) as a binary backup to this file + #[arg(long, value_name = "FILE")] + pub raw_out: Option, + + /// Print JSON + #[arg(long)] + pub json: bool, + + #[command(flatten)] + pub device: DeviceSelector, +} + +#[derive(Args, Debug)] +pub struct KeymapExportArgs { + #[command(flatten)] + pub device: DeviceSelector, +} + +#[derive(Args, Debug)] +pub struct KeymapSetArgs { + /// Layer (number like 4, or mode.layer like windows.base) + #[arg(long)] + pub layer: String, + + /// Key position: name (F1, ESC, YELLOW, KNOB_CCW), row,col (3,12) or #index + pub position: String, + + /// Keycode: name (BRID, F20, MISSION_CONTROL, MO(5), TRNS) or hex (0x00be) + pub code: String, + + /// Show what would be written without writing + #[arg(long)] + pub dry_run: bool, + + /// Print JSON + #[arg(long)] + pub json: bool, + + #[command(flatten)] + pub device: DeviceSelector, +} + +#[derive(Args, Debug)] +pub struct KeymapApplyArgs { + /// TOML keymap file + pub file: PathBuf, + + /// Show the diff without writing + #[arg(long)] + pub dry_run: bool, + + /// Print JSON + #[arg(long)] + pub json: bool, + + #[command(flatten)] + pub device: DeviceSelector, +} + +#[derive(Args, Debug)] +pub struct KeymapResetArgs { + /// Which layers to reset + #[arg(long, value_enum)] + pub mode: KeymapResetMode, + + /// Confirm the reset (required) + #[arg(long)] + pub yes: bool, + + /// Print JSON + #[arg(long)] + pub json: bool, + + #[command(flatten)] + pub device: DeviceSelector, +} + +#[derive(Args, Debug)] +pub struct KeymapRestoreBackupArgs { + /// Binary backup (from `keymap dump --raw-out` or an automatic backup) + pub file: PathBuf, + + /// Show the diff without writing + #[arg(long)] + pub dry_run: bool, + + /// Print JSON + #[arg(long)] + pub json: bool, + + #[command(flatten)] + pub device: DeviceSelector, +} + +#[derive(Args, Debug)] +pub struct KeymapListArgs { + /// Print JSON + #[arg(long)] + pub json: bool, +} + #[derive(Subcommand, Debug)] pub enum RawSubcommand { /// Send a raw output report (64 bytes) diff --git a/src/hid_transport.rs b/src/hid_transport.rs index 66294a0..abd18b5 100644 --- a/src/hid_transport.rs +++ b/src/hid_transport.rs @@ -27,6 +27,34 @@ impl HidResponder { send_report(dev, report_id, packet)?; wait_for_response(dev, expected_cmd, self.timeout) } + + /// Send a packet and wait for the first input report accepted by `matches`; + /// other reports (stale replies, unsolicited reports) are skipped. + pub fn send_and_expect_match( + &self, + dev: &HidDevice, + report_id: u8, + packet: &Report, + matches: impl Fn(&Report) -> bool, + ) -> Result { + send_report(dev, report_id, packet)?; + let started = Instant::now(); + loop { + let elapsed = started.elapsed(); + if elapsed >= self.timeout { + bail!( + "timeout waiting for HID response command 0x{:02x}", + packet[1] + ); + } + let step = (self.timeout - elapsed).min(Duration::from_millis(80)); + if let Some(report) = read_input_report(dev, step)? + && matches(&report) + { + return Ok(report); + } + } + } } pub fn list_devices(api: &HidApi) { diff --git a/src/keycodes.rs b/src/keycodes.rs new file mode 100644 index 0000000..4f2115c --- /dev/null +++ b/src/keycodes.rs @@ -0,0 +1,1103 @@ +//! Keycode catalogue for the NuPhy keymap (`0xb1`/`0xb2`/`0xb3`) commands. +//! +//! Keycodes are QMK-style 16-bit values: basic HID usages and modifiers as-is, +//! consumer/system/mouse codes in QMK's `0xa5..=0xdf` block, `MO(n)` as +//! `0x5220 | n`, QMK RGB codes at `0x7820..=0x782a`, and NuPhy's own codes at +//! `0x7e00 + n`. Names and values were taken from the NuPhy IO web app +//! (drive.nuphy.io) keycode converter. + +use anyhow::{Result, bail}; + +const MO_BASE: u16 = 0x5220; +const MO_MAX_LAYER: u16 = 0x1f; + +#[derive(Clone, Copy, Debug)] +pub enum KeycodeGroup { + Special, + Basic, + Modifier, + Media, + Mouse, + Lighting, + Nuphy, +} + +impl KeycodeGroup { + pub const fn name(self) -> &'static str { + match self { + Self::Special => "special", + Self::Basic => "basic", + Self::Modifier => "modifier", + Self::Media => "media", + Self::Mouse => "mouse", + Self::Lighting => "lighting", + Self::Nuphy => "nuphy", + } + } +} + +#[derive(Debug)] +pub struct Keycode { + pub code: u16, + /// Canonical name, printed by `keymap dump`. + pub name: &'static str, + /// Additional names accepted on input. + pub aliases: &'static [&'static str], + pub group: KeycodeGroup, + pub description: &'static str, +} + +macro_rules! kc { + ($group:ident, $code:expr, $name:expr, [$($alias:expr),*], $desc:expr) => { + Keycode { + code: $code, + name: $name, + aliases: &[$($alias),*], + group: KeycodeGroup::$group, + description: $desc, + } + }; +} + +pub static KEYCODES: &[Keycode] = &[ + kc!(Special, 0x0000, "NO", ["NONE", "XXXXXXX"], "no key"), + kc!( + Special, + 0x0001, + "TRNS", + ["TRANSPARENT", "_______"], + "transparent (falls through to the layer below)" + ), + kc!(Basic, 0x0004, "A", [], "A"), + kc!(Basic, 0x0005, "B", [], "B"), + kc!(Basic, 0x0006, "C", [], "C"), + kc!(Basic, 0x0007, "D", [], "D"), + kc!(Basic, 0x0008, "E", [], "E"), + kc!(Basic, 0x0009, "F", [], "F"), + kc!(Basic, 0x000a, "G", [], "G"), + kc!(Basic, 0x000b, "H", [], "H"), + kc!(Basic, 0x000c, "I", [], "I"), + kc!(Basic, 0x000d, "J", [], "J"), + kc!(Basic, 0x000e, "K", [], "K"), + kc!(Basic, 0x000f, "L", [], "L"), + kc!(Basic, 0x0010, "M", [], "M"), + kc!(Basic, 0x0011, "N", [], "N"), + kc!(Basic, 0x0012, "O", [], "O"), + kc!(Basic, 0x0013, "P", [], "P"), + kc!(Basic, 0x0014, "Q", [], "Q"), + kc!(Basic, 0x0015, "R", [], "R"), + kc!(Basic, 0x0016, "S", [], "S"), + kc!(Basic, 0x0017, "T", [], "T"), + kc!(Basic, 0x0018, "U", [], "U"), + kc!(Basic, 0x0019, "V", [], "V"), + kc!(Basic, 0x001a, "W", [], "W"), + kc!(Basic, 0x001b, "X", [], "X"), + kc!(Basic, 0x001c, "Y", [], "Y"), + kc!(Basic, 0x001d, "Z", [], "Z"), + kc!(Basic, 0x001e, "1", [], "1 !"), + kc!(Basic, 0x001f, "2", [], "2 @"), + kc!(Basic, 0x0020, "3", [], "3 #"), + kc!(Basic, 0x0021, "4", [], "4 $"), + kc!(Basic, 0x0022, "5", [], "5 %"), + kc!(Basic, 0x0023, "6", [], "6 ^"), + kc!(Basic, 0x0024, "7", [], "7 &"), + kc!(Basic, 0x0025, "8", [], "8 *"), + kc!(Basic, 0x0026, "9", [], "9 ("), + kc!(Basic, 0x0027, "0", [], "0 )"), + kc!(Basic, 0x0028, "ENT", ["ENTER"], "Enter"), + kc!(Basic, 0x0029, "ESC", ["ESCAPE"], "Escape"), + kc!(Basic, 0x002a, "BSPC", ["BACKSPACE"], "Backspace"), + kc!(Basic, 0x002b, "TAB", [], "Tab"), + kc!(Basic, 0x002c, "SPC", ["SPACE"], "Space"), + kc!(Basic, 0x002d, "MINS", ["MINUS"], "- _"), + kc!(Basic, 0x002e, "EQL", ["EQUAL"], "= +"), + kc!(Basic, 0x002f, "LBRC", ["LEFT_BRACKET", "LBRACKET"], "[ {"), + kc!(Basic, 0x0030, "RBRC", ["RIGHT_BRACKET", "RBRACKET"], "] }"), + kc!(Basic, 0x0031, "BSLS", ["BACKSLASH"], "\\ |"), + kc!(Basic, 0x0032, "NUHS", ["NONUS_HASH"], "non-US # ~"), + kc!(Basic, 0x0033, "SCLN", ["SEMICOLON"], "; :"), + kc!(Basic, 0x0034, "QUOT", ["QUOTE"], "' \""), + kc!(Basic, 0x0035, "GRV", ["GRAVE"], "` ~"), + kc!(Basic, 0x0036, "COMM", ["COMMA"], ", <"), + kc!(Basic, 0x0037, "DOT", [], ". >"), + kc!(Basic, 0x0038, "SLSH", ["SLASH"], "/ ?"), + kc!(Basic, 0x0039, "CAPS", ["CAPS_LOCK"], "Caps Lock"), + kc!(Basic, 0x003a, "F1", [], "F1"), + kc!(Basic, 0x003b, "F2", [], "F2"), + kc!(Basic, 0x003c, "F3", [], "F3"), + kc!(Basic, 0x003d, "F4", [], "F4"), + kc!(Basic, 0x003e, "F5", [], "F5"), + kc!(Basic, 0x003f, "F6", [], "F6"), + kc!(Basic, 0x0040, "F7", [], "F7"), + kc!(Basic, 0x0041, "F8", [], "F8"), + kc!(Basic, 0x0042, "F9", [], "F9"), + kc!(Basic, 0x0043, "F10", [], "F10"), + kc!(Basic, 0x0044, "F11", [], "F11"), + kc!(Basic, 0x0045, "F12", [], "F12"), + kc!(Basic, 0x0046, "PSCR", ["PRINT_SCREEN"], "Print Screen"), + kc!(Basic, 0x0047, "SCRL", ["SCROLL_LOCK"], "Scroll Lock"), + kc!(Basic, 0x0048, "PAUS", ["PAUSE"], "Pause"), + kc!(Basic, 0x0049, "INS", ["INSERT"], "Insert"), + kc!(Basic, 0x004a, "HOME", [], "Home"), + kc!(Basic, 0x004b, "PGUP", ["PAGE_UP"], "Page Up"), + kc!(Basic, 0x004c, "DEL", ["DELETE"], "Delete"), + kc!(Basic, 0x004d, "END", [], "End"), + kc!(Basic, 0x004e, "PGDN", ["PAGE_DOWN"], "Page Down"), + kc!(Basic, 0x004f, "RGHT", ["RIGHT"], "Right arrow"), + kc!(Basic, 0x0050, "LEFT", [], "Left arrow"), + kc!(Basic, 0x0051, "DOWN", [], "Down arrow"), + kc!(Basic, 0x0052, "UP", [], "Up arrow"), + kc!(Basic, 0x0053, "NUM", ["NUM_LOCK"], "Num Lock"), + kc!(Basic, 0x0054, "PSLS", ["KP_SLASH"], "keypad /"), + kc!(Basic, 0x0055, "PAST", ["KP_ASTERISK"], "keypad *"), + kc!(Basic, 0x0056, "PMNS", ["KP_MINUS"], "keypad -"), + kc!(Basic, 0x0057, "PPLS", ["KP_PLUS"], "keypad +"), + kc!(Basic, 0x0058, "PENT", ["KP_ENTER"], "keypad Enter"), + kc!(Basic, 0x0059, "P1", ["KP_1"], "keypad 1"), + kc!(Basic, 0x005a, "P2", ["KP_2"], "keypad 2"), + kc!(Basic, 0x005b, "P3", ["KP_3"], "keypad 3"), + kc!(Basic, 0x005c, "P4", ["KP_4"], "keypad 4"), + kc!(Basic, 0x005d, "P5", ["KP_5"], "keypad 5"), + kc!(Basic, 0x005e, "P6", ["KP_6"], "keypad 6"), + kc!(Basic, 0x005f, "P7", ["KP_7"], "keypad 7"), + kc!(Basic, 0x0060, "P8", ["KP_8"], "keypad 8"), + kc!(Basic, 0x0061, "P9", ["KP_9"], "keypad 9"), + kc!(Basic, 0x0062, "P0", ["KP_0"], "keypad 0"), + kc!(Basic, 0x0063, "PDOT", ["KP_DOT"], "keypad ."), + kc!(Basic, 0x0064, "NUBS", ["NONUS_BACKSLASH"], "non-US \\ |"), + kc!( + Basic, + 0x0065, + "APP", + ["APPLICATION", "MENU_KEY"], + "Application / context menu" + ), + kc!(Basic, 0x0066, "KB_POWER", [], "keyboard Power"), + kc!(Basic, 0x0067, "PEQL", ["KP_EQUAL"], "keypad ="), + kc!(Basic, 0x0068, "F13", [], "F13"), + kc!(Basic, 0x0069, "F14", [], "F14"), + kc!(Basic, 0x006a, "F15", [], "F15"), + kc!(Basic, 0x006b, "F16", [], "F16"), + kc!(Basic, 0x006c, "F17", [], "F17"), + kc!(Basic, 0x006d, "F18", [], "F18"), + kc!(Basic, 0x006e, "F19", [], "F19"), + kc!(Basic, 0x006f, "F20", [], "F20"), + kc!(Basic, 0x0070, "F21", [], "F21"), + kc!(Basic, 0x0071, "F22", [], "F22"), + kc!(Basic, 0x0072, "F23", [], "F23"), + kc!(Basic, 0x0073, "F24", [], "F24"), + kc!(Basic, 0x0074, "EXEC", ["EXECUTE"], "Execute"), + kc!(Basic, 0x0075, "HELP", [], "Help"), + kc!(Basic, 0x0076, "MENU", [], "Menu"), + kc!(Basic, 0x0077, "SLCT", ["SELECT"], "Select"), + kc!(Basic, 0x0078, "STOP", [], "Stop"), + kc!(Basic, 0x0079, "AGIN", ["AGAIN"], "Again"), + kc!(Basic, 0x007a, "UNDO", [], "Undo"), + kc!(Basic, 0x007b, "CUT", [], "Cut"), + kc!(Basic, 0x007c, "COPY", [], "Copy"), + kc!(Basic, 0x007d, "PSTE", ["PASTE"], "Paste"), + kc!(Basic, 0x007e, "FIND", [], "Find"), + kc!(Basic, 0x007f, "KB_MUTE", [], "keyboard-page Mute"), + kc!(Basic, 0x0080, "KB_VOLUME_UP", [], "keyboard-page Volume Up"), + kc!( + Basic, + 0x0081, + "KB_VOLUME_DOWN", + [], + "keyboard-page Volume Down" + ), + kc!( + Basic, + 0x0082, + "LCAP", + ["LOCKING_CAPS_LOCK"], + "locking Caps Lock" + ), + kc!( + Basic, + 0x0083, + "LNUM", + ["LOCKING_NUM_LOCK"], + "locking Num Lock" + ), + kc!( + Basic, + 0x0084, + "LSCR", + ["LOCKING_SCROLL_LOCK"], + "locking Scroll Lock" + ), + kc!(Basic, 0x0085, "PCMM", ["KP_COMMA"], "keypad ,"), + kc!(Basic, 0x0086, "KP_EQUAL_AS400", [], "keypad = (AS/400)"), + kc!( + Basic, + 0x0087, + "INT1", + ["INTERNATIONAL_1", "RO"], + "International 1 (JIS Ro)" + ), + kc!( + Basic, + 0x0088, + "INT2", + ["INTERNATIONAL_2", "KANA"], + "International 2 (Kana)" + ), + kc!( + Basic, + 0x0089, + "INT3", + ["INTERNATIONAL_3", "JYEN"], + "International 3 (Yen)" + ), + kc!( + Basic, + 0x008a, + "INT4", + ["INTERNATIONAL_4", "HENK"], + "International 4 (Henkan)" + ), + kc!( + Basic, + 0x008b, + "INT5", + ["INTERNATIONAL_5", "MHEN"], + "International 5 (Muhenkan)" + ), + kc!( + Basic, + 0x008c, + "INT6", + ["INTERNATIONAL_6"], + "International 6" + ), + kc!( + Basic, + 0x008d, + "INT7", + ["INTERNATIONAL_7"], + "International 7" + ), + kc!( + Basic, + 0x008e, + "INT8", + ["INTERNATIONAL_8"], + "International 8" + ), + kc!( + Basic, + 0x008f, + "INT9", + ["INTERNATIONAL_9"], + "International 9" + ), + kc!( + Basic, + 0x0090, + "LNG1", + ["LANGUAGE_1", "HAEN"], + "Language 1 (Han/Yeong)" + ), + kc!( + Basic, + 0x0091, + "LNG2", + ["LANGUAGE_2", "HANJ"], + "Language 2 (Hanja)" + ), + kc!(Basic, 0x0092, "LNG3", ["LANGUAGE_3"], "Language 3"), + kc!(Basic, 0x0093, "LNG4", ["LANGUAGE_4"], "Language 4"), + kc!(Basic, 0x0094, "LNG5", ["LANGUAGE_5"], "Language 5"), + kc!(Basic, 0x0095, "LNG6", ["LANGUAGE_6"], "Language 6"), + kc!(Basic, 0x0096, "LNG7", ["LANGUAGE_7"], "Language 7"), + kc!(Basic, 0x0097, "LNG8", ["LANGUAGE_8"], "Language 8"), + kc!(Basic, 0x0098, "LNG9", ["LANGUAGE_9"], "Language 9"), + kc!( + Basic, + 0x0099, + "ERAS", + ["ALTERNATE_ERASE"], + "Alternate Erase" + ), + kc!(Basic, 0x009a, "SYRQ", ["SYSTEM_REQUEST"], "SysReq"), + kc!(Basic, 0x009b, "CNCL", ["CANCEL"], "Cancel"), + kc!(Basic, 0x009c, "CLR", ["CLEAR"], "Clear"), + kc!(Basic, 0x009d, "PRIR", ["PRIOR"], "Prior"), + kc!(Basic, 0x009e, "RETN", ["RETURN"], "Return"), + kc!(Basic, 0x009f, "SEPR", ["SEPARATOR"], "Separator"), + kc!(Basic, 0x00a0, "OUT", [], "Out"), + kc!(Basic, 0x00a1, "OPER", [], "Oper"), + kc!(Basic, 0x00a2, "CLAG", ["CLEAR_AGAIN"], "Clear/Again"), + kc!(Basic, 0x00a3, "CRSL", ["CRSEL"], "CrSel"), + kc!(Basic, 0x00a4, "EXSL", ["EXSEL"], "ExSel"), + kc!(Media, 0x00a5, "PWR", ["SYSTEM_POWER"], "system Power"), + kc!(Media, 0x00a6, "SLEP", ["SYSTEM_SLEEP"], "system Sleep"), + kc!(Media, 0x00a7, "WAKE", ["SYSTEM_WAKE"], "system Wake"), + kc!(Media, 0x00a8, "MUTE", ["AUDIO_MUTE"], "Mute"), + kc!( + Media, + 0x00a9, + "VOLU", + ["AUDIO_VOL_UP", "VOL_UP"], + "Volume Up" + ), + kc!( + Media, + 0x00aa, + "VOLD", + ["AUDIO_VOL_DOWN", "VOL_DOWN"], + "Volume Down" + ), + kc!( + Media, + 0x00ab, + "MNXT", + ["MEDIA_NEXT_TRACK", "MEDIA_NEXT"], + "Next track" + ), + kc!( + Media, + 0x00ac, + "MPRV", + ["MEDIA_PREV_TRACK", "MEDIA_PREV"], + "Previous track" + ), + kc!(Media, 0x00ad, "MSTP", ["MEDIA_STOP"], "Stop media"), + kc!( + Media, + 0x00ae, + "MPLY", + ["MEDIA_PLAY_PAUSE", "PLAY"], + "Play/Pause" + ), + kc!( + Media, + 0x00af, + "MSEL", + ["MEDIA_SELECT", "MEDIA_PLAYER"], + "Media player" + ), + kc!(Media, 0x00b0, "EJCT", ["EJECT", "MEDIA_EJECT"], "Eject"), + kc!(Media, 0x00b1, "MAIL", [], "Mail"), + kc!(Media, 0x00b2, "CALC", ["CALCULATOR"], "Calculator"), + kc!( + Media, + 0x00b3, + "MYCM", + ["MY_COMPUTER"], + "My Computer / file browser" + ), + kc!(Media, 0x00b4, "WSCH", ["WWW_SEARCH"], "Browser search"), + kc!(Media, 0x00b5, "WHOM", ["WWW_HOME"], "Browser home"), + kc!(Media, 0x00b6, "WBAK", ["WWW_BACK"], "Browser back"), + kc!(Media, 0x00b7, "WFWD", ["WWW_FORWARD"], "Browser forward"), + kc!(Media, 0x00b8, "WSTP", ["WWW_STOP"], "Browser stop"), + kc!(Media, 0x00b9, "WREF", ["WWW_REFRESH"], "Browser refresh"), + kc!( + Media, + 0x00ba, + "WFAV", + ["WWW_FAVORITES"], + "Browser favorites" + ), + kc!( + Media, + 0x00bb, + "MFFD", + ["MEDIA_FAST_FORWARD"], + "Fast forward (not offered by NuPhy IO)" + ), + kc!( + Media, + 0x00bc, + "MRWD", + ["MEDIA_REWIND"], + "Rewind (not offered by NuPhy IO)" + ), + kc!( + Media, + 0x00bd, + "BRIU", + ["BRIGHTNESS_UP"], + "Screen brightness up" + ), + kc!( + Media, + 0x00be, + "BRID", + ["BRIGHTNESS_DOWN"], + "Screen brightness down" + ), + kc!( + Media, + 0x00bf, + "CPNL", + ["CONTROL_PANEL"], + "Control panel (not offered by NuPhy IO)" + ), + kc!( + Media, + 0x00c0, + "ASST", + ["ASSISTANT"], + "Assistant (not offered by NuPhy IO)" + ), + kc!(Mouse, 0x00cd, "MS_UP", [], "mouse cursor up"), + kc!(Mouse, 0x00ce, "MS_DOWN", [], "mouse cursor down"), + kc!(Mouse, 0x00cf, "MS_LEFT", [], "mouse cursor left"), + kc!(Mouse, 0x00d0, "MS_RGHT", ["MS_RIGHT"], "mouse cursor right"), + kc!(Mouse, 0x00d1, "BTN1", ["MS_BTN1"], "mouse left button"), + kc!(Mouse, 0x00d2, "BTN2", ["MS_BTN2"], "mouse right button"), + kc!(Mouse, 0x00d3, "BTN3", ["MS_BTN3"], "mouse middle button"), + kc!(Mouse, 0x00d4, "BTN4", ["MS_BTN4"], "mouse back button"), + kc!(Mouse, 0x00d5, "BTN5", ["MS_BTN5"], "mouse forward button"), + kc!(Mouse, 0x00d9, "WH_U", ["MS_WH_UP"], "mouse wheel up"), + kc!(Mouse, 0x00da, "WH_D", ["MS_WH_DOWN"], "mouse wheel down"), + kc!(Mouse, 0x00db, "WH_L", ["MS_WH_LEFT"], "mouse wheel left"), + kc!(Mouse, 0x00dc, "WH_R", ["MS_WH_RIGHT"], "mouse wheel right"), + kc!( + Modifier, + 0x00e0, + "LCTL", + ["LEFT_CTRL", "LCTRL"], + "Left Ctrl" + ), + kc!( + Modifier, + 0x00e1, + "LSFT", + ["LEFT_SHIFT", "LSHIFT"], + "Left Shift" + ), + kc!( + Modifier, + 0x00e2, + "LALT", + ["LEFT_ALT", "LOPT"], + "Left Alt / Option" + ), + kc!( + Modifier, + 0x00e3, + "LGUI", + ["LEFT_GUI", "LWIN", "LCMD"], + "Left Win / Cmd" + ), + kc!( + Modifier, + 0x00e4, + "RCTL", + ["RIGHT_CTRL", "RCTRL"], + "Right Ctrl" + ), + kc!( + Modifier, + 0x00e5, + "RSFT", + ["RIGHT_SHIFT", "RSHIFT"], + "Right Shift" + ), + kc!( + Modifier, + 0x00e6, + "RALT", + ["RIGHT_ALT", "ROPT", "ALGR"], + "Right Alt / Option" + ), + kc!( + Modifier, + 0x00e7, + "RGUI", + ["RIGHT_GUI", "RWIN", "RCMD"], + "Right Win / Cmd" + ), + kc!(Lighting, 0x7820, "RGB_TOG", [], "backlight toggle"), + kc!( + Lighting, + 0x7821, + "RGB_MOD", + ["RGB_MODE_FORWARD"], + "backlight next effect" + ), + kc!( + Lighting, + 0x7822, + "RGB_RMOD", + ["RGB_MODE_REVERSE"], + "backlight previous effect" + ), + kc!(Lighting, 0x7823, "RGB_HUI", [], "backlight hue up"), + kc!(Lighting, 0x7824, "RGB_HUD", [], "backlight hue down"), + kc!(Lighting, 0x7825, "RGB_SAI", [], "backlight saturation up"), + kc!(Lighting, 0x7826, "RGB_SAD", [], "backlight saturation down"), + kc!(Lighting, 0x7827, "RGB_VAI", [], "backlight brightness up"), + kc!(Lighting, 0x7828, "RGB_VAD", [], "backlight brightness down"), + kc!(Lighting, 0x7829, "RGB_SPI", [], "backlight speed up"), + kc!(Lighting, 0x782a, "RGB_SPD", [], "backlight speed down"), + kc!( + Special, + 0x7c59, + "QK_LOCK", + ["KEYBOARD_LOCK"], + "keyboard lock" + ), + kc!(Nuphy, 0x7e00, "BOOT", ["RF_DFU"], "enter bootloader"), + kc!( + Nuphy, + 0x7e01, + "LINK_USB", + ["LNK_USB", "USB"], + "connection: USB" + ), + kc!( + Nuphy, + 0x7e02, + "LINK_24G", + ["LNK_RF", "RF", "2_4G"], + "connection: 2.4 GHz dongle" + ), + kc!( + Nuphy, + 0x7e03, + "BT1", + ["LNK_BLE1", "BLE1"], + "connection: Bluetooth 1" + ), + kc!( + Nuphy, + 0x7e04, + "BT2", + ["LNK_BLE2", "BLE2"], + "connection: Bluetooth 2" + ), + kc!( + Nuphy, + 0x7e05, + "BT3", + ["LNK_BLE3", "BLE3"], + "connection: Bluetooth 3" + ), + kc!( + Nuphy, + 0x7e06, + "MISSION_CONTROL", + ["MAC_TASK"], + "macOS Mission Control (Linux: KEY_SCALE)" + ), + kc!( + Nuphy, + 0x7e07, + "MAC_SEARCH", + ["SPOTLIGHT"], + "macOS search (sends Super+Space)" + ), + kc!( + Nuphy, + 0x7e08, + "SIRI", + ["MAC_VOICE", "VOICE"], + "Siri / voice command (Linux: KEY_VOICECOMMAND)" + ), + kc!( + Nuphy, + 0x7e09, + "LAUNCHPAD", + ["MAC_CONSOLE"], + "macOS Launchpad" + ), + kc!( + Nuphy, + 0x7e0a, + "SCREENSHOT", + ["MAC_PRT", "SCREENSHOT_FULL"], + "full-screen screenshot" + ), + kc!( + Nuphy, + 0x7e0b, + "SCREENSHOT_AREA", + ["MAC_PRTA"], + "area screenshot" + ), + kc!( + Nuphy, + 0x7e0c, + "MAC_GLOBE", + ["MAC_GLOBOL", "GLOBE"], + "macOS Globe key" + ), + kc!(Nuphy, 0x7e0d, "SIDE_VAI", [], "side light brightness up"), + kc!(Nuphy, 0x7e0e, "SIDE_VAD", [], "side light brightness down"), + kc!(Nuphy, 0x7e0f, "SIDE_MOD", [], "side light next mode"), + kc!(Nuphy, 0x7e10, "SIDE_HUI", [], "side light next color"), + kc!(Nuphy, 0x7e11, "SIDE_SPI", [], "side light speed up"), + kc!(Nuphy, 0x7e12, "SIDE_SPD", [], "side light speed down"), + kc!( + Nuphy, + 0x7e13, + "DEV_RESET", + ["DEVICE_RESET"], + "factory reset the keyboard" + ), + kc!( + Nuphy, + 0x7e14, + "SLEEP_TOGGLE", + ["NP_SLEEP"], + "toggle the keyboard sleep function" + ), + kc!(Nuphy, 0x7e15, "BATTERY", ["BAT_SHOW"], "show battery level"), + kc!( + Nuphy, + 0x7e16, + "DND", + ["MAC_DND", "DO_NOT_DISTURB"], + "Do Not Disturb (Linux: KEY_DO_NOT_DISTURB)" + ), + kc!(Nuphy, 0x7e17, "BAT_NUM", [], "battery number (internal)"), + kc!( + Nuphy, + 0x7e18, + "RGB_OFF", + ["TOGGLE_RGB_SW"], + "backlight on/off" + ), + kc!(Nuphy, 0x7e19, "RGB_TEST", [], "backlight test"), + kc!( + Nuphy, + 0x7e1a, + "TOGGLE_DIRECTION", + ["TOOGLE_DIRECTION"], + "toggle direction (internal)" + ), + kc!(Nuphy, 0x7e1b, "LOOP_HUE", [], "backlight color loop"), + kc!(Nuphy, 0x7e1c, "LOOP_LIGHT", [], "backlight brightness loop"), + kc!(Nuphy, 0x7e1d, "LOOP_SPEED", [], "backlight speed loop"), + kc!( + Nuphy, + 0x7e1e, + "LOCK_WIN", + ["WIN_KEY_LOCK"], + "lock the Win key" + ), + kc!( + Nuphy, + 0x7e1f, + "RGB_DIR_LEFT", + [], + "backlight direction left" + ), + kc!( + Nuphy, + 0x7e20, + "RGB_DIR_RIGHT", + ["RGB_DEI_RIGHT"], + "backlight direction right" + ), + kc!( + Nuphy, + 0x7e21, + "RGB_DIR_TOGGLE", + [], + "backlight direction toggle" + ), + kc!( + Nuphy, + 0x7e22, + "BLE_TEST_START", + [], + "factory test (internal)" + ), + kc!( + Nuphy, + 0x7e23, + "BLE_TEST_STOP", + [], + "factory test (internal)" + ), + kc!( + Nuphy, + 0x7e24, + "LOW_SLEEP_TEST", + [], + "factory test (internal)" + ), + kc!( + Nuphy, + 0x7e25, + "DEEP_SLEEP_TEST", + [], + "factory test (internal)" + ), + kc!(Nuphy, 0x7e26, "SIDE_TEST", [], "factory test (internal)"), + kc!(Nuphy, 0x7e27, "DEBUG_STATUS", [], "debug status"), + kc!( + Nuphy, + 0x7e28, + "PS_SHOW_OR_HIDE_RULERS", + [], + "Photoshop: show/hide rulers" + ), + kc!( + Nuphy, + 0x7e29, + "PS_NEW_DUPLICATE_LAYER", + [], + "Photoshop: duplicate layer" + ), + kc!( + Nuphy, + 0x7e2a, + "PS_MERGE_LAYERS", + [], + "Photoshop: merge layers" + ), + kc!(Nuphy, 0x7e2b, "PS_FILL", [], "Photoshop: fill"), + kc!( + Nuphy, + 0x7e2c, + "PS_LEVELS_ADJUSTMENT", + [], + "Photoshop: levels" + ), + kc!(Nuphy, 0x7e2d, "PS_ZOOM_IN", [], "Photoshop: zoom in"), + kc!(Nuphy, 0x7e2e, "PS_ZOOM_OUT", [], "Photoshop: zoom out"), + kc!( + Nuphy, + 0x7e2f, + "AI_SAVE_FOR_WEB", + [], + "Illustrator: save for web" + ), + kc!(Nuphy, 0x7e30, "AI_UNDO", [], "Illustrator: undo"), + kc!( + Nuphy, + 0x7e31, + "AI_SHOW_OR_HIDE_GUIDES", + [], + "Illustrator: show/hide guides" + ), + kc!(Nuphy, 0x7e32, "AI_ZOOM_IN", [], "Illustrator: zoom in"), + kc!(Nuphy, 0x7e33, "AI_ZOOM_OUT", [], "Illustrator: zoom out"), + kc!( + Nuphy, + 0x7e34, + "FIGMA_SHOW_OR_HIDE_UI", + [], + "Figma: show/hide UI" + ), + kc!(Nuphy, 0x7e35, "FIGMA_PIXEL_GRID", [], "Figma: pixel grid"), + kc!(Nuphy, 0x7e36, "FIGMA_ALIGN_LEFT", [], "Figma: align left"), + kc!( + Nuphy, + 0x7e37, + "FIGMA_FLATTEN_SELECTION", + [], + "Figma: flatten selection" + ), + kc!( + Nuphy, + 0x7e38, + "FIGMA_STRIKETHROUGH", + [], + "Figma: strikethrough" + ), + kc!(Nuphy, 0x7e39, "FIGMA_ZOOM_IN", [], "Figma: zoom in"), + kc!(Nuphy, 0x7e3a, "FIGMA_ZOOM_OUT", [], "Figma: zoom out"), + kc!( + Nuphy, + 0x7e3b, + "TOG_CAP_IND", + [], + "toggle Caps Lock indicator" + ), + kc!(Nuphy, 0x7e3c, "TOG_SIDE_LED", [], "toggle side light"), + kc!(Nuphy, 0x7e3d, "FN_REVERSE", [], "swap Fn and media row"), + kc!(Nuphy, 0x7e3e, "SIDE_SWITCH", [], "side light switch"), + kc!(Nuphy, 0x7e3f, "MAC_SRT", [], "macOS SRT (unknown)"), + kc!( + Nuphy, + 0x7e40, + "LOCK_SCREEN_WIN", + ["WIN_LOCK_SCREEN"], + "Windows: lock screen" + ), + kc!( + Nuphy, + 0x7e41, + "LOCK_SCREEN_MAC", + ["MAC_LOCK_SCREEN"], + "macOS: lock screen" + ), + kc!( + Nuphy, + 0x7e42, + "MAC_FINDER_SEARCH", + [], + "macOS: Finder search" + ), + kc!(Nuphy, 0x7e43, "MAC_FORCE_QUIT", [], "macOS: force quit"), + kc!(Nuphy, 0x7e44, "EMOJI", ["MAC_KC_EMOJI"], "emoji picker"), + kc!( + Nuphy, + 0x7e45, + "BACK_TO_DESKTOP", + ["MAC_BACK_2_DESKTOP"], + "back to desktop" + ), + kc!( + Nuphy, + 0x7e46, + "TASK_MANAGER", + ["WIN_TASK_MANAGER"], + "Windows: task manager" + ), + kc!(Nuphy, 0x7e47, "TOUCH_SWITCH", [], "touch bar switch"), + kc!( + Nuphy, + 0x7e48, + "WIN_SWITCH_DESKTOPS_LEFT", + [], + "Windows: previous desktop" + ), + kc!( + Nuphy, + 0x7e49, + "WIN_SWITCH_DESKTOPS_RIGHT", + [], + "Windows: next desktop" + ), + kc!( + Nuphy, + 0x7e4a, + "MAC_SWITCH_DESKTOPS_LEFT", + [], + "macOS: previous desktop" + ), + kc!( + Nuphy, + 0x7e4b, + "MAC_SWITCH_DESKTOPS_RIGHT", + [], + "macOS: next desktop" + ), + kc!( + Nuphy, + 0x7e4c, + "WIN_TOUCH_PGM_LEFT", + [], + "Windows: touch program left" + ), + kc!( + Nuphy, + 0x7e4d, + "WIN_TOUCH_PGM_RIGHT", + [], + "Windows: touch program right" + ), + kc!( + Nuphy, + 0x7e4e, + "MAC_TOUCH_PGM_LEFT", + [], + "macOS: touch program left" + ), + kc!( + Nuphy, + 0x7e4f, + "MAC_TOUCH_PGM_RIGHT", + [], + "macOS: touch program right" + ), + kc!( + Nuphy, + 0x7e50, + "TASK_VIEW", + ["CTRL_CENTER", "WIN_CTRL_CENTER"], + "Windows: task view" + ), + kc!(Nuphy, 0x7e51, "WINDOW_WELT_LEFT", [], "snap window left"), + kc!(Nuphy, 0x7e52, "WINDOW_WELT_RIGHT", [], "snap window right"), + kc!( + Nuphy, + 0x7e53, + "SHOW_DESKTOP", + ["WIN_SHOW_DESKTOP"], + "Windows: show desktop" + ), + kc!( + Nuphy, + 0x7e54, + "MAC_LINGO_SWITCH", + [], + "macOS: switch input language" + ), + kc!( + Nuphy, + 0x7e55, + "DICTATE", + ["WIN_DICTATE"], + "Windows: dictation" + ), + kc!(Nuphy, 0x7e56, "FLEXO1", [], "Flexo 1"), + kc!( + Nuphy, + 0x7e57, + "WIN_REPORT_REVERSE", + [], + "Windows: report reverse" + ), +]; + +fn normalize(input: &str) -> String { + let upper = input.trim().to_ascii_uppercase(); + match upper.strip_prefix("KC_") { + Some(rest) if !rest.is_empty() => rest.to_string(), + _ => upper, + } +} + +pub fn lookup_name(name: &str) -> Option<&'static Keycode> { + let wanted = normalize(name); + KEYCODES + .iter() + .find(|k| k.name == wanted || k.aliases.contains(&wanted.as_str())) +} + +pub fn lookup_code(code: u16) -> Option<&'static Keycode> { + KEYCODES.iter().find(|k| k.code == code) +} + +/// Parse a keycode: a name from the catalogue (optionally `KC_`-prefixed, +/// case-insensitive), `MO(n)`, or a hex value like `0x00be`. +pub fn parse_keycode(input: &str) -> Result { + let trimmed = input.trim(); + if let Some(hex) = trimmed + .strip_prefix("0x") + .or_else(|| trimmed.strip_prefix("0X")) + { + return u16::from_str_radix(hex, 16) + .map_err(|_| anyhow::anyhow!("invalid hex keycode: {trimmed}")); + } + + let upper = trimmed.to_ascii_uppercase(); + if let Some(inner) = upper + .strip_prefix("MO(") + .and_then(|rest| rest.strip_suffix(')')) + { + let layer: u16 = inner + .trim() + .parse() + .map_err(|_| anyhow::anyhow!("invalid layer in {trimmed}"))?; + if layer > MO_MAX_LAYER { + bail!("layer out of range in {trimmed} (max {MO_MAX_LAYER})"); + } + return Ok(MO_BASE | layer); + } + + match lookup_name(trimmed) { + Some(k) => Ok(k.code), + None => bail!( + "unknown keycode {trimmed:?}; see `nuphyctl keymap keycodes` or pass hex like 0x00be" + ), + } +} + +/// Symbolic name for a keycode, or `None` if it is not in the catalogue. +pub fn keycode_name(code: u16) -> Option { + if let Some(k) = lookup_code(code) { + return Some(k.name.to_string()); + } + if code & !MO_MAX_LAYER == MO_BASE { + return Some(format!("MO({})", code & MO_MAX_LAYER)); + } + None +} + +pub fn format_hex(code: u16) -> String { + format!("0x{code:04x}") +} + +/// Name when known, otherwise hex. +pub fn display_keycode(code: u16) -> String { + keycode_name(code).unwrap_or_else(|| format_hex(code)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + #[test] + fn names_and_aliases_are_unique() { + let mut seen: HashMap<&str, u16> = HashMap::new(); + for k in KEYCODES { + for name in std::iter::once(&k.name).chain(k.aliases.iter()) { + assert_eq!(name.to_ascii_uppercase(), *name, "{name} not uppercase"); + if let Some(prev) = seen.insert(name, k.code) { + panic!("{name} maps to both {prev:#06x} and {:#06x}", k.code); + } + } + } + } + + #[test] + fn codes_are_unique() { + let mut seen = std::collections::HashSet::new(); + for k in KEYCODES { + assert!(seen.insert(k.code), "duplicate code {:#06x}", k.code); + } + } + + #[test] + fn parses_names_hex_and_layers() { + assert_eq!(parse_keycode("BRID").unwrap(), 0x00be); + assert_eq!(parse_keycode("kc_brid").unwrap(), 0x00be); + assert_eq!(parse_keycode("BRIGHTNESS_DOWN").unwrap(), 0x00be); + assert_eq!(parse_keycode("F20").unwrap(), 0x006f); + assert_eq!(parse_keycode("F1").unwrap(), 0x003a); + assert_eq!(parse_keycode("F13").unwrap(), 0x0068); + assert_eq!(parse_keycode("MISSION_CONTROL").unwrap(), 0x7e06); + assert_eq!(parse_keycode("DND").unwrap(), 0x7e16); + assert_eq!(parse_keycode("TRNS").unwrap(), 0x0001); + assert_eq!(parse_keycode("_______").unwrap(), 0x0001); + assert_eq!(parse_keycode("NO").unwrap(), 0x0000); + assert_eq!(parse_keycode("MO(5)").unwrap(), 0x5225); + assert_eq!(parse_keycode("mo( 1 )").unwrap(), 0x5221); + assert_eq!(parse_keycode("0x7E0A").unwrap(), 0x7e0a); + assert_eq!(parse_keycode(" 0x00be ").unwrap(), 0x00be); + assert_eq!(parse_keycode("1").unwrap(), 0x001e); + assert_eq!(parse_keycode("0").unwrap(), 0x0027); + assert_eq!(parse_keycode("KC_0").unwrap(), 0x0027); + } + + #[test] + fn rejects_bad_keycodes() { + assert!(parse_keycode("NOPE").is_err()); + assert!(parse_keycode("0xZZ").is_err()); + assert!(parse_keycode("0x10000").is_err()); + assert!(parse_keycode("MO(32)").is_err()); + assert!(parse_keycode("MO(x)").is_err()); + assert!(parse_keycode("KC_").is_err()); + } + + #[test] + fn names_codes() { + assert_eq!(display_keycode(0x00be), "BRID"); + assert_eq!(display_keycode(0x7e06), "MISSION_CONTROL"); + assert_eq!(display_keycode(0x5225), "MO(5)"); + assert_eq!(display_keycode(0x5221), "MO(1)"); + assert_eq!(display_keycode(0x1234), "0x1234"); + assert_eq!(display_keycode(0x0001), "TRNS"); + } + + #[test] + fn display_round_trips() { + for code in [ + 0x0000u16, 0x0001, 0x0029, 0x00e7, 0x5223, 0x7821, 0x7e57, 0x4242, + ] { + assert_eq!(parse_keycode(&display_keycode(code)).unwrap(), code); + } + } +} diff --git a/src/keymap.rs b/src/keymap.rs new file mode 100644 index 0000000..3ac7100 --- /dev/null +++ b/src/keymap.rs @@ -0,0 +1,1139 @@ +//! Keymap model: device geometry, named key positions, the keymap buffer, +//! declarative TOML configs, diffs and write planning. Nothing here touches +//! the device, so all of it is unit-tested. + +use crate::keycodes::{display_keycode, parse_keycode}; +use anyhow::{Context, Result, bail}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Largest payload a single `0xb2`/`0xb3` packet carries (64 - 8 header bytes). +pub const MAX_CHUNK: usize = 56; + +pub const MODE_NAMES: [&str; 2] = ["mac", "windows"]; +pub const LAYER_NAMES: [&str; 4] = ["base", "fn", "fn2", "fn3"]; + +/// Decoded `GetBase` (`0xa0`) reply. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Geometry { + pub current_mode: u8, + pub layer_count: u8, + pub layers_per_mode: u8, + pub rows: u8, + pub cols: u8, + pub extras: u8, +} + +impl Geometry { + /// NuPhy Air75 V3 as reported by firmware 1.0.17.6 (`01 08 04 06 10 82`). + pub const AIR75_V3: Self = Self { + current_mode: 1, + layer_count: 8, + layers_per_mode: 4, + rows: 6, + cols: 16, + extras: 2, + }; + + pub fn from_base(data: &[u8]) -> Result { + if data.len() < 6 { + bail!("GetBase reply too short: {} bytes", data.len()); + } + // Same rule as NuPhy IO: bit 7 set means the low bits carry the + // number of extra (non-matrix) entries per layer, otherwise 2. + let extras = if data[5] & 0x80 != 0 { + data[5] & 0x7f + } else { + 2 + }; + let geometry = Self { + current_mode: data[0], + layer_count: data[1], + layers_per_mode: data[2], + rows: data[3], + cols: data[4], + extras, + }; + if geometry.layer_count == 0 || geometry.rows == 0 || geometry.cols == 0 { + bail!("implausible keyboard geometry from GetBase: {data:02x?}"); + } + if geometry.total_bytes() > usize::from(u16::MAX) { + bail!("keymap too large for 16-bit addressing: {data:02x?}"); + } + Ok(geometry) + } + + pub fn matrix_keys(&self) -> usize { + usize::from(self.rows) * usize::from(self.cols) + } + + pub fn keys_per_layer(&self) -> usize { + self.matrix_keys() + usize::from(self.extras) + } + + pub fn layer_bytes(&self) -> usize { + 2 * self.keys_per_layer() + } + + pub fn total_bytes(&self) -> usize { + usize::from(self.layer_count) * self.layer_bytes() + } + + /// Byte address of a key entry in the device keymap. + pub fn offset(&self, layer: u8, index: usize) -> usize { + usize::from(layer) * self.layer_bytes() + 2 * index + } + + pub fn check_layer(&self, layer: u8) -> Result<()> { + if layer >= self.layer_count { + bail!( + "layer {layer} out of range (device has {} layers)", + self.layer_count + ); + } + Ok(()) + } + + pub fn mode_count(&self) -> u8 { + self.layer_count + .checked_div(self.layers_per_mode) + .unwrap_or(1) + } + + pub fn mode_name(&self, mode: u8) -> String { + MODE_NAMES + .get(usize::from(mode)) + .map(|s| s.to_string()) + .unwrap_or_else(|| format!("mode{mode}")) + } + + /// Layers belonging to a mode (`mac` = 0, `windows` = 1). + pub fn mode_layers(&self, mode: u8) -> Result> { + if mode >= self.mode_count() { + bail!("mode {mode} out of range"); + } + let start = mode * self.layers_per_mode; + Ok(start..start + self.layers_per_mode) + } + + /// Human label like `windows base` for a layer. + pub fn layer_label(&self, layer: u8) -> String { + if self.layers_per_mode == 0 { + return format!("layer {layer}"); + } + let mode = layer / self.layers_per_mode; + let within = layer % self.layers_per_mode; + let name = LAYER_NAMES + .get(usize::from(within)) + .map(|s| s.to_string()) + .unwrap_or_else(|| format!("layer{within}")); + format!("{} {}", self.mode_name(mode), name) + } + + pub fn row_col(&self, index: usize) -> Option<(u8, u8)> { + if index < self.matrix_keys() { + let cols = usize::from(self.cols); + Some(((index / cols) as u8, (index % cols) as u8)) + } else { + None + } + } +} + +pub fn parse_mode(input: &str) -> Result { + match input.trim().to_ascii_lowercase().as_str() { + "mac" | "macos" | "osx" => Ok(0), + "windows" | "win" => Ok(1), + other => bail!("unknown mode {other:?} (expected mac or windows)"), + } +} + +fn parse_layer_name(input: &str) -> Result { + let lower = input.trim().to_ascii_lowercase(); + if let Some(i) = LAYER_NAMES.iter().position(|n| *n == lower) { + return Ok(i as u8); + } + if let Some(n) = lower.strip_prefix("layer") + && let Ok(n) = n.parse::() + { + return Ok(n); + } + bail!("unknown layer name {input:?} (expected base, fn, fn2 or fn3)") +} + +/// Parse a layer: an absolute number (`5`) or `mode.name` (`windows.fn`). +pub fn parse_layer_spec(input: &str, geometry: &Geometry) -> Result { + let trimmed = input.trim(); + let layer = if let Ok(n) = trimmed.parse::() { + n + } else if let Some((mode, name)) = trimmed.split_once(['.', ' ', ':']) { + resolve_mode_layer(parse_mode(mode)?, parse_layer_name(name)?, geometry)? + } else { + bail!("invalid layer {input:?} (expected a number or e.g. windows.fn)"); + }; + geometry.check_layer(layer)?; + Ok(layer) +} + +fn resolve_mode_layer(mode: u8, within: u8, geometry: &Geometry) -> Result { + if within >= geometry.layers_per_mode { + bail!( + "layer {within} out of range within a mode ({} layers per mode)", + geometry.layers_per_mode + ); + } + let layer = geometry.mode_layers(mode)?.start + within; + geometry.check_layer(layer)?; + Ok(layer) +} + +/// A named physical key position. +#[derive(Debug)] +pub struct NamedPosition { + pub name: &'static str, + pub index: usize, + pub aliases: &'static [&'static str], + pub description: &'static str, +} + +macro_rules! pos { + ($name:expr, $row:expr, $col:expr) => { + pos!($name, $row, $col, [], "") + }; + ($name:expr, $row:expr, $col:expr, [$($alias:expr),*], $desc:expr) => { + NamedPosition { + name: $name, + index: $row * 16 + $col, + aliases: &[$($alias),*], + description: $desc, + } + }; +} + +/// Air75 V3 key positions, named after the Windows-mode legends. +pub static AIR75_V3_POSITIONS: &[NamedPosition] = &[ + pos!("ESC", 0, 0, ["ESCAPE"], ""), + pos!("F1", 0, 1), + pos!("F2", 0, 2), + pos!("F3", 0, 3), + pos!("F4", 0, 4), + pos!("F5", 0, 5), + pos!("F6", 0, 6), + pos!("F7", 0, 7), + pos!("F8", 0, 8), + pos!("F9", 0, 9), + pos!("F10", 0, 10), + pos!("F11", 0, 11), + pos!("F12", 0, 12), + pos!( + "PSCR", + 0, + 13, + ["PRINT_SCREEN", "PRTSC"], + "factory: area screenshot" + ), + pos!( + "YELLOW", + 0, + 14, + ["INS", "INSERT"], + "yellow key, factory Insert" + ), + pos!("GRV", 1, 0, ["GRAVE"], ""), + pos!("1", 1, 1), + pos!("2", 1, 2), + pos!("3", 1, 3), + pos!("4", 1, 4), + pos!("5", 1, 5), + pos!("6", 1, 6), + pos!("7", 1, 7), + pos!("8", 1, 8), + pos!("9", 1, 9), + pos!("0", 1, 10), + pos!("MINS", 1, 11, ["MINUS"], ""), + pos!("EQL", 1, 12, ["EQUAL"], ""), + pos!("BSPC", 1, 13, ["BACKSPACE"], ""), + pos!("PGUP", 1, 15, ["PAGE_UP"], ""), + pos!("TAB", 2, 0), + pos!("Q", 2, 1), + pos!("W", 2, 2), + pos!("E", 2, 3), + pos!("R", 2, 4), + pos!("T", 2, 5), + pos!("Y", 2, 6), + pos!("U", 2, 7), + pos!("I", 2, 8), + pos!("O", 2, 9), + pos!("P", 2, 10), + pos!("LBRC", 2, 11, ["LEFT_BRACKET", "LBRACKET"], ""), + pos!("RBRC", 2, 12, ["RIGHT_BRACKET", "RBRACKET"], ""), + pos!("BSLS", 2, 13, ["BACKSLASH"], ""), + pos!("PGDN", 2, 15, ["PAGE_DOWN"], ""), + pos!("CAPS", 3, 0, ["CAPS_LOCK"], ""), + pos!("A", 3, 1), + pos!("S", 3, 2), + pos!("D", 3, 3), + pos!("F", 3, 4), + pos!("G", 3, 5), + pos!("H", 3, 6), + pos!("J", 3, 7), + pos!("K", 3, 8), + pos!("L", 3, 9), + pos!("SCLN", 3, 10, ["SEMICOLON"], ""), + pos!("QUOT", 3, 11, ["QUOTE"], ""), + pos!( + "KNOB", + 3, + 12, + ["RED", "KNOB_PRESS", "MODULE"], + "red top-right key / knob module press, factory Mute" + ), + pos!("ENT", 3, 13, ["ENTER"], ""), + pos!("HOME", 3, 15), + pos!("LSFT", 4, 0, ["LSHIFT", "LEFT_SHIFT"], ""), + pos!("Z", 4, 2), + pos!("X", 4, 3), + pos!("C", 4, 4), + pos!("V", 4, 5), + pos!("B", 4, 6), + pos!("N", 4, 7), + pos!("M", 4, 8), + pos!("COMM", 4, 9, ["COMMA"], ""), + pos!("DOT", 4, 10), + pos!("SLSH", 4, 11, ["SLASH"], ""), + pos!("RSFT", 4, 12, ["RSHIFT", "RIGHT_SHIFT"], ""), + pos!("UP", 4, 14), + pos!("END", 4, 15), + pos!("LCTL", 5, 0, ["LCTRL", "LEFT_CTRL"], ""), + pos!( + "LGUI", + 5, + 1, + ["LWIN", "LOPT"], + "Win in Windows mode, Option in Mac mode" + ), + pos!( + "LALT", + 5, + 2, + ["LCMD"], + "Alt in Windows mode, Cmd in Mac mode" + ), + pos!("SPC", 5, 6, ["SPACE"], ""), + pos!( + "RALT", + 5, + 10, + ["RCMD"], + "Alt in Windows mode, Cmd in Mac mode" + ), + pos!("FN", 5, 11, [], "factory MO(1)/MO(5)"), + pos!("RCTL", 5, 12, ["RCTRL", "RIGHT_CTRL"], ""), + pos!("LEFT", 5, 13), + pos!("DOWN", 5, 14), + pos!("RGHT", 5, 15, ["RIGHT"], ""), + NamedPosition { + name: "KNOB_CCW", + index: 96, + aliases: &["CCW"], + description: "knob turned counter-clockwise, factory Volume Down", + }, + NamedPosition { + name: "KNOB_CW", + index: 97, + aliases: &["CW"], + description: "knob turned clockwise, factory Volume Up", + }, +]; + +/// Resolves key positions for one keyboard. +#[derive(Clone, Copy, Debug)] +pub struct Layout { + pub geometry: Geometry, + named: &'static [NamedPosition], +} + +impl Layout { + /// Named positions are used when the matrix matches the Air75 V3. + pub fn new(geometry: Geometry) -> Self { + let named: &'static [NamedPosition] = + if geometry.rows == 6 && geometry.cols == 16 && geometry.extras == 2 { + AIR75_V3_POSITIONS + } else { + &[] + }; + Self { geometry, named } + } + + pub fn has_names(&self) -> bool { + !self.named.is_empty() + } + + pub fn name_of(&self, index: usize) -> Option<&'static str> { + self.named.iter().find(|p| p.index == index).map(|p| p.name) + } + + /// Parse a position: a name (`F1`, `KNOB_CCW`, `YELLOW`), `row,col`, or + /// `#index` (entry index within a layer; extras follow the matrix). + pub fn parse_position(&self, input: &str) -> Result { + let trimmed = input.trim(); + let keys = self.geometry.keys_per_layer(); + + if let Some(idx) = trimmed.strip_prefix('#') { + let index: usize = idx + .trim() + .parse() + .with_context(|| format!("invalid position index {trimmed:?}"))?; + if index >= keys { + bail!("position index {index} out of range (0..{keys})"); + } + return Ok(index); + } + + if let Some((r, c)) = trimmed.split_once(',') { + let row: u8 = r + .trim() + .parse() + .with_context(|| format!("invalid row in {trimmed:?}"))?; + let col: u8 = c + .trim() + .parse() + .with_context(|| format!("invalid column in {trimmed:?}"))?; + if row >= self.geometry.rows || col >= self.geometry.cols { + bail!( + "position {row},{col} outside the {}x{} matrix", + self.geometry.rows, + self.geometry.cols + ); + } + return Ok(usize::from(row) * usize::from(self.geometry.cols) + usize::from(col)); + } + + let upper = trimmed.to_ascii_uppercase(); + let upper = upper.strip_prefix("KC_").unwrap_or(&upper); + if let Some(p) = self + .named + .iter() + .find(|p| p.name == upper || p.aliases.contains(&upper)) + { + return Ok(p.index); + } + + // Knob extras exist on any board reporting at least two extras. + let extra = self.geometry.matrix_keys(); + match upper { + "KNOB_CCW" | "CCW" if self.geometry.extras >= 1 => return Ok(extra), + "KNOB_CW" | "CW" if self.geometry.extras >= 2 => return Ok(extra + 1), + _ => {} + } + + if self.has_names() { + bail!( + "unknown key position {trimmed:?}; see `nuphyctl keymap positions`, or use row,col or #index" + ) + } + bail!( + "unknown key position {trimmed:?}; this keyboard's layout has no named positions, use row,col or #index" + ) + } + + /// Short label for a position: name when known, else `r,c` / `#index`. + pub fn label(&self, index: usize) -> String { + if let Some(name) = self.name_of(index) { + return name.to_string(); + } + match self.geometry.row_col(index) { + Some((r, c)) => format!("{r},{c}"), + None => format!("#{index}"), + } + } +} + +/// A full keymap buffer as read with `GetUseKeys`/`GetDefaultKeys`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Keymap { + pub geometry: Geometry, + pub bytes: Vec, +} + +impl Keymap { + pub fn from_bytes(geometry: Geometry, bytes: Vec) -> Result { + if bytes.len() != geometry.total_bytes() { + bail!( + "keymap is {} bytes, expected {} ({} layers x {} bytes)", + bytes.len(), + geometry.total_bytes(), + geometry.layer_count, + geometry.layer_bytes() + ); + } + Ok(Self { geometry, bytes }) + } + + pub fn get(&self, layer: u8, index: usize) -> u16 { + let off = self.geometry.offset(layer, index); + u16::from_be_bytes([self.bytes[off], self.bytes[off + 1]]) + } + + pub fn set(&mut self, layer: u8, index: usize, code: u16) { + let off = self.geometry.offset(layer, index); + self.bytes[off..off + 2].copy_from_slice(&code.to_be_bytes()); + } +} + +/// One key assignment requested by the user. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Assignment { + pub layer: u8, + pub index: usize, + pub code: u16, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Change { + pub layer: u8, + pub index: usize, + pub from: u16, + pub to: u16, +} + +impl Change { + pub fn describe(&self, layout: &Layout) -> String { + let pos = match layout.geometry.row_col(self.index) { + Some((r, c)) => format!("{} ({r},{c})", layout.label(self.index)), + None => format!("{} (#{})", layout.label(self.index), self.index), + }; + format!( + "layer {} ({}) {}: {} (0x{:04x}) -> {} (0x{:04x})", + self.layer, + layout.geometry.layer_label(self.layer), + pos, + display_keycode(self.from), + self.from, + display_keycode(self.to), + self.to + ) + } +} + +/// Changes needed to turn `current` into the requested assignments. +pub fn diff_assignments(current: &Keymap, assignments: &[Assignment]) -> Vec { + assignments + .iter() + .filter_map(|a| { + let from = current.get(a.layer, a.index); + (from != a.code).then_some(Change { + layer: a.layer, + index: a.index, + from, + to: a.code, + }) + }) + .collect() +} + +/// All entries that differ between two keymaps of the same geometry. +pub fn diff_keymaps(current: &Keymap, desired: &Keymap) -> Vec { + let g = current.geometry; + let mut out = Vec::new(); + for layer in 0..g.layer_count { + for index in 0..g.keys_per_layer() { + let from = current.get(layer, index); + let to = desired.get(layer, index); + if from != to { + out.push(Change { + layer, + index, + from, + to, + }); + } + } + } + out +} + +pub fn apply_changes(keymap: &Keymap, changes: &[Change]) -> Keymap { + let mut out = keymap.clone(); + for c in changes { + out.set(c.layer, c.index, c.to); + } + out +} + +/// A `SetUseKeys` write: byte address plus payload. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WriteChunk { + pub addr: u16, + pub data: Vec, +} + +/// Group changed entries into writes. Only changed entries are written; +/// adjacent ones share a packet (up to `MAX_CHUNK` bytes). +pub fn plan_writes(geometry: &Geometry, changes: &[Change]) -> Vec { + let mut entries: Vec<(usize, u16)> = changes + .iter() + .map(|c| (geometry.offset(c.layer, c.index), c.to)) + .collect(); + entries.sort_by_key(|(off, _)| *off); + entries.dedup_by_key(|(off, _)| *off); + + let mut chunks: Vec = Vec::new(); + let mut next_off = usize::MAX; + for (off, code) in entries { + match chunks.last_mut() { + Some(chunk) if off == next_off && chunk.data.len() + 2 <= MAX_CHUNK => { + chunk.data.extend_from_slice(&code.to_be_bytes()); + } + _ => chunks.push(WriteChunk { + addr: off as u16, + data: code.to_be_bytes().to_vec(), + }), + } + next_off = off + 2; + } + chunks +} + +/// Parse a declarative keymap file. Layout: +/// +/// ```toml +/// [windows.base] # mac|windows . base|fn|fn2|fn3 +/// F1 = "BRID" +/// "3,12" = "MUTE" +/// [windows.base.knob] # ccw / cw / press +/// ccw = "VOLD" +/// [layers.6] # absolute layer number +/// ESC = 0x29 +/// ``` +pub fn parse_config(text: &str, layout: &Layout) -> Result> { + let table: toml::Table = text.parse().context("invalid TOML")?; + let geometry = &layout.geometry; + let mut out: BTreeMap<(u8, usize), (u16, String)> = BTreeMap::new(); + + for (top, value) in &table { + let section = value + .as_table() + .with_context(|| format!("[{top}] must be a table"))?; + match top.as_str() { + "layers" | "layer" => { + for (layer_key, keys) in section { + let layer: u8 = layer_key + .parse() + .with_context(|| format!("invalid layer number [{top}.{layer_key}]"))?; + geometry.check_layer(layer)?; + let keys = keys + .as_table() + .with_context(|| format!("[{top}.{layer_key}] must be a table"))?; + collect_layer(layout, layer, &format!("{top}.{layer_key}"), keys, &mut out)?; + } + } + mode_name => { + let mode = parse_mode(mode_name).with_context(|| { + format!("unknown section [{top}] (expected mac, windows or layers)") + })?; + for (layer_key, keys) in section { + let within = parse_layer_name(layer_key) + .with_context(|| format!("in [{top}.{layer_key}]"))?; + let layer = resolve_mode_layer(mode, within, geometry) + .with_context(|| format!("in [{top}.{layer_key}]"))?; + let keys = keys + .as_table() + .with_context(|| format!("[{top}.{layer_key}] must be a table"))?; + collect_layer(layout, layer, &format!("{top}.{layer_key}"), keys, &mut out)?; + } + } + } + } + + Ok(out + .into_iter() + .map(|((layer, index), (code, _))| Assignment { layer, index, code }) + .collect()) +} + +fn collect_layer( + layout: &Layout, + layer: u8, + section: &str, + keys: &toml::Table, + out: &mut BTreeMap<(u8, usize), (u16, String)>, +) -> Result<()> { + for (key, value) in keys { + if key.eq_ignore_ascii_case("knob") && value.is_table() { + let knob = value.as_table().expect("checked is_table"); + for (dir, value) in knob { + let pos = match dir.to_ascii_lowercase().as_str() { + "ccw" => "KNOB_CCW", + "cw" => "KNOB_CW", + "press" => "KNOB", + other => bail!( + "unknown knob entry {other:?} in [{section}.knob] (expected ccw, cw or press)" + ), + }; + let source = format!("{section}.knob.{dir}"); + insert_assignment(layout, layer, pos, value, &source, out)?; + } + continue; + } + let source = format!("{section}.{key}"); + insert_assignment(layout, layer, key, value, &source, out)?; + } + Ok(()) +} + +fn insert_assignment( + layout: &Layout, + layer: u8, + position: &str, + value: &toml::Value, + source: &str, + out: &mut BTreeMap<(u8, usize), (u16, String)>, +) -> Result<()> { + let index = layout + .parse_position(position) + .with_context(|| format!("in {source}"))?; + let code = match value { + toml::Value::String(s) => parse_keycode(s).with_context(|| format!("in {source}"))?, + toml::Value::Integer(n) => u16::try_from(*n) + .ok() + .with_context(|| format!("keycode {n} out of range in {source}"))?, + other => bail!( + "{source} must be a keycode string or integer, got {}", + other.type_str() + ), + }; + if let Some((_, previous)) = out.get(&(layer, index)) { + bail!("{source} assigns the same key as {previous}"); + } + out.insert((layer, index), (code, source.to_string())); + Ok(()) +} + +/// Render the keys of `current` that differ from `defaults` as a config file +/// that `parse_config` reads back. +pub fn export_config(current: &Keymap, defaults: &Keymap, layout: &Layout) -> String { + let g = current.geometry; + let mut out = String::from( + "# nuphyctl keymap: keys that differ from the factory keymap\n# apply with: nuphyctl keymap apply \n", + ); + for layer in 0..g.layer_count { + let changed: Vec = (0..g.keys_per_layer()) + .filter(|&i| current.get(layer, i) != defaults.get(layer, i)) + .collect(); + if changed.is_empty() { + continue; + } + let header = if g.layers_per_mode > 0 + && usize::from(layer / g.layers_per_mode) < MODE_NAMES.len() + && usize::from(layer % g.layers_per_mode) < LAYER_NAMES.len() + { + format!( + "{}.{}", + MODE_NAMES[usize::from(layer / g.layers_per_mode)], + LAYER_NAMES[usize::from(layer % g.layers_per_mode)] + ) + } else { + format!("layers.{layer}") + }; + out.push_str(&format!("\n[{header}] # layer {layer}\n")); + for index in changed { + let label = layout.label(index); + let key = if label.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') + && !label.chars().all(|c| c.is_ascii_digit()) + { + label + } else { + format!("\"{label}\"") + }; + out.push_str(&format!( + "{key} = \"{}\" # factory {}\n", + display_keycode(current.get(layer, index)), + display_keycode(defaults.get(layer, index)) + )); + } + } + out +} + +/// `$XDG_STATE_HOME/nuphyctl`, falling back to `~/.local/state/nuphyctl`. +pub fn default_backup_dir() -> Result { + if let Some(state) = std::env::var_os("XDG_STATE_HOME").filter(|v| !v.is_empty()) { + return Ok(PathBuf::from(state).join("nuphyctl")); + } + let home = std::env::var_os("HOME") + .filter(|v| !v.is_empty()) + .context("neither XDG_STATE_HOME nor HOME is set; cannot choose a backup directory")?; + Ok(PathBuf::from(home).join(".local/state/nuphyctl")) +} + +/// Write a backup as `keymap-YYYYMMDDTHHMMSSZ.bin` and return its path. +pub fn write_backup(dir: &Path, bytes: &[u8]) -> Result { + std::fs::create_dir_all(dir) + .with_context(|| format!("failed to create backup directory {}", dir.display()))?; + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let stamp = utc_timestamp(secs); + for n in 0..1000 { + let name = if n == 0 { + format!("keymap-{stamp}.bin") + } else { + format!("keymap-{stamp}-{n}.bin") + }; + let path = dir.join(name); + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + { + Ok(mut f) => { + use std::io::Write; + f.write_all(bytes) + .with_context(|| format!("failed to write backup {}", path.display()))?; + f.sync_all() + .with_context(|| format!("failed to sync backup {}", path.display()))?; + return Ok(path); + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => { + return Err(e) + .with_context(|| format!("failed to create backup {}", path.display())); + } + } + } + bail!( + "could not find a free backup file name in {}", + dir.display() + ) +} + +/// `YYYYMMDDTHHMMSSZ` for a Unix timestamp (UTC). +pub fn utc_timestamp(secs: u64) -> String { + let days = (secs / 86_400) as i64; + let rem = secs % 86_400; + // Howard Hinnant's civil_from_days. + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = yoe + era * 400 + i64::from(month <= 2); + format!( + "{year:04}{month:02}{day:02}T{:02}{:02}{:02}Z", + rem / 3600, + (rem / 60) % 60, + rem % 60 + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn layout() -> Layout { + Layout::new(Geometry::AIR75_V3) + } + + fn blank() -> Keymap { + let g = Geometry::AIR75_V3; + Keymap::from_bytes(g, vec![0; g.total_bytes()]).unwrap() + } + + #[test] + fn decodes_get_base() { + let g = Geometry::from_base(&[0x01, 0x08, 0x04, 0x06, 0x10, 0x82, 0, 0]).unwrap(); + assert_eq!(g, Geometry::AIR75_V3); + assert_eq!(g.layer_bytes(), 196); + assert_eq!(g.total_bytes(), 1568); + let g = Geometry::from_base(&[0, 8, 4, 6, 16, 0]).unwrap(); + assert_eq!(g.extras, 2); + assert!(Geometry::from_base(&[0, 0, 4, 6, 16, 0x82]).is_err()); + assert!(Geometry::from_base(&[0, 8]).is_err()); + } + + #[test] + fn offsets() { + let g = Geometry::AIR75_V3; + assert_eq!(g.offset(0, 0), 0); + assert_eq!(g.offset(4, 1), 4 * 196 + 2); + assert_eq!(g.offset(5, 96), 5 * 196 + 192); + assert_eq!(g.offset(7, 97), 7 * 196 + 194); + assert_eq!(g.row_col(60), Some((3, 12))); + assert_eq!(g.row_col(96), None); + assert_eq!(g.mode_layers(1).unwrap(), 4..8); + assert_eq!(g.mode_layers(0).unwrap(), 0..4); + assert!(g.mode_layers(2).is_err()); + assert_eq!(g.layer_label(5), "windows fn"); + assert_eq!(g.layer_label(2), "mac fn2"); + } + + #[test] + fn parses_layers() { + let g = Geometry::AIR75_V3; + assert_eq!(parse_layer_spec("4", &g).unwrap(), 4); + assert_eq!(parse_layer_spec("windows.base", &g).unwrap(), 4); + assert_eq!(parse_layer_spec("windows.fn", &g).unwrap(), 5); + assert_eq!(parse_layer_spec("mac.fn3", &g).unwrap(), 3); + assert_eq!(parse_layer_spec("win.layer2", &g).unwrap(), 6); + assert!(parse_layer_spec("8", &g).is_err()); + assert!(parse_layer_spec("linux.base", &g).is_err()); + assert!(parse_layer_spec("windows.fn4", &g).is_err()); + } + + #[test] + fn parses_positions() { + let l = layout(); + assert_eq!(l.parse_position("ESC").unwrap(), 0); + assert_eq!(l.parse_position("f1").unwrap(), 1); + assert_eq!(l.parse_position("KC_F12").unwrap(), 12); + assert_eq!(l.parse_position("PSCR").unwrap(), 13); + assert_eq!(l.parse_position("YELLOW").unwrap(), 14); + assert_eq!(l.parse_position("INSERT").unwrap(), 14); + assert_eq!(l.parse_position("KNOB").unwrap(), 60); + assert_eq!(l.parse_position("KNOB_CCW").unwrap(), 96); + assert_eq!(l.parse_position("knob_cw").unwrap(), 97); + assert_eq!(l.parse_position("1").unwrap(), 17); + assert_eq!(l.parse_position("0").unwrap(), 26); + assert_eq!(l.parse_position("FN").unwrap(), 91); + assert_eq!(l.parse_position("RIGHT").unwrap(), 95); + assert_eq!(l.parse_position("3,12").unwrap(), 60); + assert_eq!(l.parse_position(" 0 , 15 ").unwrap(), 15); + assert_eq!(l.parse_position("#17").unwrap(), 17); + assert_eq!(l.parse_position("#97").unwrap(), 97); + assert!(l.parse_position("#98").is_err()); + assert!(l.parse_position("6,0").is_err()); + assert!(l.parse_position("0,16").is_err()); + assert!(l.parse_position("NOPE").is_err()); + } + + #[test] + fn position_names_are_unique_and_in_range() { + let mut seen = std::collections::HashSet::new(); + let mut indices = std::collections::HashSet::new(); + for p in AIR75_V3_POSITIONS { + assert!(p.index < Geometry::AIR75_V3.keys_per_layer()); + assert!(indices.insert(p.index), "duplicate index {}", p.index); + for name in std::iter::once(&p.name).chain(p.aliases.iter()) { + assert!(seen.insert(*name), "duplicate position name {name}"); + } + } + } + + #[test] + fn unnamed_layout_still_accepts_row_col_and_knob() { + let g = Geometry { + rows: 5, + cols: 15, + ..Geometry::AIR75_V3 + }; + let l = Layout::new(g); + assert!(!l.has_names()); + assert_eq!(l.parse_position("1,2").unwrap(), 17); + assert_eq!(l.parse_position("KNOB_CW").unwrap(), 76); + assert!(l.parse_position("ESC").is_err()); + assert_eq!(l.label(17), "1,2"); + assert_eq!(l.label(76), "#76"); + } + + #[test] + fn keymap_get_set_big_endian() { + let mut k = blank(); + k.set(4, 1, 0x00be); + k.set(5, 97, 0x7e06); + assert_eq!(&k.bytes[4 * 196 + 2..4 * 196 + 4], &[0x00, 0xbe]); + assert_eq!(k.get(4, 1), 0x00be); + assert_eq!(k.get(5, 97), 0x7e06); + assert!(Keymap::from_bytes(Geometry::AIR75_V3, vec![0; 100]).is_err()); + } + + const SAMPLE: &str = r##" + [windows.base] # layer 4 + F1 = "BRID" + F3 = "MISSION_CONTROL" + "3,12" = "MUTE" + "#14" = 0x49 + + [windows.base.knob] + ccw = "VOLD" + cw = "kc_volu" + + [windows.fn] # layer 5 + F1 = "F1" + FN = "MO(5)" + + [layers.2] + ESC = "TRNS" + "##; + + #[test] + fn parses_config() { + let a = parse_config(SAMPLE, &layout()).unwrap(); + let got: Vec<(u8, usize, u16)> = a.iter().map(|a| (a.layer, a.index, a.code)).collect(); + assert_eq!( + got, + vec![ + (2, 0, 0x0001), + (4, 1, 0x00be), + (4, 3, 0x7e06), + (4, 14, 0x0049), + (4, 60, 0x00a8), + (4, 96, 0x00aa), + (4, 97, 0x00a9), + (5, 1, 0x003a), + (5, 91, 0x5225), + ] + ); + } + + #[test] + fn config_errors() { + let l = layout(); + assert!(parse_config("[linux.base]\nF1 = \"F1\"", &l).is_err()); + assert!(parse_config("[windows.fn9]\nF1 = \"F1\"", &l).is_err()); + assert!(parse_config("[windows.base]\nNOPE = \"F1\"", &l).is_err()); + assert!(parse_config("[windows.base]\nF1 = \"NOPE\"", &l).is_err()); + assert!(parse_config("[windows.base]\nF1 = 70000", &l).is_err()); + assert!(parse_config("[windows.base]\nF1 = true", &l).is_err()); + assert!(parse_config("[layers.8]\nF1 = \"F1\"", &l).is_err()); + assert!(parse_config("[windows.base.knob]\nleft = \"F1\"", &l).is_err()); + let dup = "[windows.base]\nF1 = \"F1\"\n\"0,1\" = \"F2\""; + let err = parse_config(dup, &l).unwrap_err(); + assert!(format!("{err:#}").contains("same key"), "{err:#}"); + let dup = "[windows.base]\nKNOB_CW = \"F1\"\n[windows.base.knob]\ncw = \"F1\""; + assert!(parse_config(dup, &l).is_err()); + let dup = "[windows.base]\nESC = \"F1\"\n[layers.4]\nESC = \"F1\""; + assert!(parse_config(dup, &l).is_err()); + } + + #[test] + fn diff_and_idempotence() { + let current = blank(); + let assignments = parse_config(SAMPLE, &layout()).unwrap(); + let changes = diff_assignments(¤t, &assignments); + assert_eq!(changes.len(), assignments.len()); + assert!(changes.iter().all(|c| c.from == 0)); + + let desired = apply_changes(¤t, &changes); + assert!(diff_assignments(&desired, &assignments).is_empty()); + assert_eq!(diff_keymaps(¤t, &desired), changes); + assert!(diff_keymaps(&desired, &desired).is_empty()); + } + + #[test] + fn diff_skips_equal_values() { + let mut current = blank(); + current.set(4, 1, 0x00be); + let a = vec![ + Assignment { + layer: 4, + index: 1, + code: 0x00be, + }, + Assignment { + layer: 4, + index: 2, + code: 0x00bd, + }, + ]; + let changes = diff_assignments(¤t, &a); + assert_eq!( + changes, + vec![Change { + layer: 4, + index: 2, + from: 0, + to: 0x00bd + }] + ); + let text = changes[0].describe(&layout()); + assert_eq!( + text, + "layer 4 (windows base) F2 (0,2): NO (0x0000) -> BRIU (0x00bd)" + ); + } + + #[test] + fn plans_coalesced_writes() { + let g = Geometry::AIR75_V3; + let change = |layer, index, to| Change { + layer, + index, + from: 0, + to, + }; + let changes = vec![ + change(4, 2, 0x00bd), + change(4, 1, 0x00be), + change(4, 5, 0x006f), + change(5, 0, 0x0001), + ]; + let plan = plan_writes(&g, &changes); + assert_eq!( + plan, + vec![ + WriteChunk { + addr: 786, + data: vec![0x00, 0xbe, 0x00, 0xbd] + }, + WriteChunk { + addr: 794, + data: vec![0x00, 0x6f] + }, + WriteChunk { + addr: 980, + data: vec![0x00, 0x01] + }, + ] + ); + + // A whole changed layer splits into packets of at most MAX_CHUNK bytes. + let all: Vec = (0..g.keys_per_layer()) + .map(|i| change(0, i, 0x0004)) + .collect(); + let plan = plan_writes(&g, &all); + assert!(plan.iter().all(|c| c.data.len() <= MAX_CHUNK)); + assert_eq!(plan.iter().map(|c| c.data.len()).sum::(), 196); + assert_eq!(plan[1].addr, 56); + } + + #[test] + fn export_round_trips() { + let defaults = blank(); + let mut current = blank(); + current.set(4, 1, 0x00be); + current.set(4, 17, 0x7e03); + current.set(4, 60, 0x00a8); + current.set(5, 96, 0x00aa); + let l = layout(); + let text = export_config(¤t, &defaults, &l); + assert!(text.contains("[windows.base]"), "{text}"); + assert!(text.contains("\"1\" = \"BT1\""), "{text}"); + let a = parse_config(&text, &l).unwrap(); + let rebuilt = apply_changes(&defaults, &diff_assignments(&defaults, &a)); + assert_eq!(rebuilt, current); + } + + #[test] + fn timestamps() { + assert_eq!(utc_timestamp(0), "19700101T000000Z"); + assert_eq!(utc_timestamp(951_782_400), "20000229T000000Z"); + assert_eq!(utc_timestamp(1_791_990_000), "20261014T150000Z"); + } + + #[test] + fn backups_never_overwrite() { + let dir = std::env::temp_dir().join(format!("nuphyctl-test-{}", std::process::id())); + let a = write_backup(&dir, &[1, 2, 3]).unwrap(); + let b = write_backup(&dir, &[4, 5, 6]).unwrap(); + assert_ne!(a, b); + assert_eq!(std::fs::read(&a).unwrap(), vec![1, 2, 3]); + assert_eq!(std::fs::read(&b).unwrap(), vec![4, 5, 6]); + std::fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/src/keymap_cmd.rs b/src/keymap_cmd.rs new file mode 100644 index 0000000..3e1e6fc --- /dev/null +++ b/src/keymap_cmd.rs @@ -0,0 +1,601 @@ +//! `nuphyctl keymap ...` command handlers. + +use crate::app::is_retryable_rgb_error; +use crate::cli::{ + DeviceSelector, KeymapApplyArgs, KeymapDumpArgs, KeymapExportArgs, KeymapInfoArgs, + KeymapListArgs, KeymapResetArgs, KeymapResetMode, KeymapRestoreBackupArgs, KeymapSetArgs, +}; +use crate::hid_transport::{clear_input_reports, open_selected_device}; +use crate::keycodes::{KEYCODES, format_hex, keycode_name, parse_keycode}; +use crate::keymap::{ + AIR75_V3_POSITIONS, Assignment, Change, Geometry, Keymap, Layout, apply_changes, + default_backup_dir, diff_assignments, diff_keymaps, export_config, parse_config, + parse_layer_spec, plan_writes, write_backup, +}; +use crate::nuphy_protocol::{KeyboardProtocol, format_firmware_version}; +use anyhow::{Context, Result, bail}; +use hidapi::HidApi; +use serde_json::{Value, json}; +use std::path::{Path, PathBuf}; +use std::{thread, time::Duration}; + +const CONNECT_MAX_ATTEMPTS: usize = 4; +const CONNECT_RETRY_BASE_DELAY_MS: u64 = 120; + +struct Keyboard<'a> { + protocol: KeyboardProtocol<'a>, + layout: Layout, + product: Option, +} + +impl Keyboard<'_> { + fn geometry(&self) -> Geometry { + self.layout.geometry + } + + fn read(&self, factory: bool) -> Result { + let g = self.geometry(); + let bytes = self.protocol.read_keymap(factory, g.total_bytes())?; + Keymap::from_bytes(g, bytes) + } + + /// Back up the current keymap, write `changes`, then read everything back + /// and require it to equal `current` with `changes` applied. + fn write_and_verify(&self, current: &Keymap, changes: &[Change]) -> Result { + let backup = backup_keymap(current)?; + eprintln!("backup: {}", backup.display()); + let desired = apply_changes(current, changes); + for chunk in plan_writes(&self.geometry(), changes) { + self.protocol + .write_keymap(chunk.addr, &chunk.data) + .with_context(|| { + format!( + "write failed; restore with: nuphyctl keymap restore-backup {}", + backup.display() + ) + })?; + } + self.verify(&desired, &backup)?; + Ok(backup) + } + + fn verify(&self, desired: &Keymap, backup: &Path) -> Result<()> { + let after = self + .read(false) + .context("failed to read keymap back for verification")?; + let mismatches = diff_keymaps(desired, &after); + if !mismatches.is_empty() { + let lines: Vec = mismatches + .iter() + .map(|c| { + format!( + " layer {} {}: expected {}, device has {}", + c.layer, + self.layout.label(c.index), + format_hex(c.from), + format_hex(c.to) + ) + }) + .collect(); + bail!( + "verification failed, {} key(s) differ from what was expected:\n{}\nbackup of the previous keymap: {}", + mismatches.len(), + lines.join("\n"), + backup.display() + ); + } + Ok(()) + } +} + +/// Open the keyboard, negotiate a session key, read its geometry and run `f`. +/// Only connecting is retried; `f` runs once. +fn with_keyboard( + api: &HidApi, + selector: &DeviceSelector, + f: impl FnOnce(&Keyboard) -> Result, +) -> Result { + let mut attempt = 0; + loop { + attempt += 1; + let connected = open_selected_device(api, selector).and_then(|dev| { + clear_input_reports(&dev)?; + Ok(dev) + }); + let err = match connected { + Ok(dev) => match KeyboardProtocol::new(&dev) { + Ok(protocol) => { + let geometry = Geometry::from_base(&protocol.get_base()?)?; + let product = dev.get_product_string().ok().flatten(); + let keyboard = Keyboard { + protocol, + layout: Layout::new(geometry), + product, + }; + return f(&keyboard); + } + Err(err) => err, + }, + Err(err) => err, + }; + if !is_retryable_rgb_error(&err) || attempt >= CONNECT_MAX_ATTEMPTS { + return Err(err); + } + eprintln!( + "transient HID error (attempt {}/{}): {} -- retrying", + attempt, CONNECT_MAX_ATTEMPTS, err + ); + thread::sleep(Duration::from_millis( + CONNECT_RETRY_BASE_DELAY_MS * attempt as u64, + )); + } +} + +fn backup_keymap(keymap: &Keymap) -> Result { + let dir = default_backup_dir()?; + write_backup(&dir, &keymap.bytes).context("failed to save keymap backup; nothing was written") +} + +fn print_json(value: &Value) -> Result<()> { + println!("{}", serde_json::to_string_pretty(value)?); + Ok(()) +} + +fn code_json(code: u16) -> Value { + json!({ "code": format_hex(code), "value": code, "name": keycode_name(code) }) +} + +fn position_json(layout: &Layout, layer: u8, index: usize) -> Value { + let (row, col) = match layout.geometry.row_col(index) { + Some((r, c)) => (json!(r), json!(c)), + None => (Value::Null, Value::Null), + }; + json!({ + "layer": layer, + "layer_name": layout.geometry.layer_label(layer), + "index": index, + "row": row, + "col": col, + "position": layout.name_of(index), + }) +} + +fn change_json(layout: &Layout, change: &Change) -> Value { + let mut v = position_json(layout, change.layer, change.index); + v["from"] = code_json(change.from); + v["to"] = code_json(change.to); + v +} + +fn print_changes(layout: &Layout, changes: &[Change]) { + for c in changes { + println!("{}", c.describe(layout)); + } +} + +pub fn run_info(api: &HidApi, args: &KeymapInfoArgs) -> Result<()> { + with_keyboard(api, &args.device, |kb| { + let fw = kb.protocol.get_firmware_info()?; + let version = format_firmware_version(&fw); + let g = kb.geometry(); + let modes: Vec = (0..g.mode_count()) + .map(|m| { + let layers = g + .mode_layers(m) + .map(|r| r.collect::>()) + .unwrap_or_default(); + json!({ "mode": m, "name": g.mode_name(m), "layers": layers }) + }) + .collect(); + if args.json { + return print_json(&json!({ + "product": kb.product, + "firmware_version": version, + "session_key": format!("0x{:02x}", kb.protocol.session_key().value()), + "current_mode": { "mode": g.current_mode, "name": g.mode_name(g.current_mode) }, + "layer_count": g.layer_count, + "layers_per_mode": g.layers_per_mode, + "modes": modes, + "matrix": { "rows": g.rows, "cols": g.cols }, + "extra_entries": g.extras, + "layer_bytes": g.layer_bytes(), + "keymap_bytes": g.total_bytes(), + "named_positions": kb.layout.has_names(), + })); + } + println!("product: {}", kb.product.as_deref().unwrap_or("unknown")); + println!("firmware: {version}"); + println!( + "current mode: {} ({})", + g.mode_name(g.current_mode), + g.current_mode + ); + println!("layers: {} ({} per mode)", g.layer_count, g.layers_per_mode); + for m in 0..g.mode_count() { + if let Ok(r) = g.mode_layers(m) { + println!(" {}: layers {}-{}", g.mode_name(m), r.start, r.end - 1); + } + } + println!( + "matrix: {} rows x {} cols + {} extra entries (knob)", + g.rows, g.cols, g.extras + ); + println!( + "keymap size: {} bytes per layer, {} total", + g.layer_bytes(), + g.total_bytes() + ); + if !kb.layout.has_names() { + println!("note: no named positions for this matrix; use row,col or #index"); + } + Ok(()) + }) +} + +pub fn run_dump(api: &HidApi, args: &KeymapDumpArgs) -> Result<()> { + with_keyboard(api, &args.device, |kb| { + let g = kb.geometry(); + let keymap = kb.read(args.defaults)?; + let factory = if args.changed { + Some(kb.read(true)?) + } else { + None + }; + + if let Some(path) = &args.raw_out { + std::fs::write(path, &keymap.bytes) + .with_context(|| format!("failed to write {}", path.display()))?; + eprintln!("saved {} bytes to {}", keymap.bytes.len(), path.display()); + } + + let layers: Vec = match (&args.layer, args.mode) { + (Some(spec), _) => vec![parse_layer_spec(spec, &g)?], + (None, Some(mode)) => g.mode_layers(mode.index())?.collect(), + (None, None) => (0..g.layer_count).collect(), + }; + + let layout = &kb.layout; + let mut json_layers = Vec::new(); + for &layer in &layers { + let mut keys = Vec::new(); + let mut lines = Vec::new(); + for index in 0..g.keys_per_layer() { + let code = keymap.get(layer, index); + let default = factory.as_ref().map(|f| f.get(layer, index)); + if let Some(d) = default + && d == code + { + continue; + } + if args.json { + let mut v = position_json(layout, layer, index); + v["code"] = json!(format_hex(code)); + v["value"] = json!(code); + v["name"] = json!(keycode_name(code)); + if let Some(d) = default { + v["factory"] = code_json(d); + } + keys.push(v); + continue; + } + if !args.changed && layout.name_of(index).is_none() && code == 0 { + continue; + } + let rc = match g.row_col(index) { + Some((r, c)) => format!("{r},{c}"), + None => format!("#{index}"), + }; + let mut line = format!( + " {:<10} {:<6} {} {}", + layout.label(index), + rc, + format_hex(code), + keycode_name(code).unwrap_or_default() + ); + if let Some(d) = default { + line.push_str(&format!( + " (factory {})", + keycode_name(d).unwrap_or_else(|| format_hex(d)) + )); + } + lines.push(line.trim_end().to_string()); + } + if !args.json && !(args.changed && lines.is_empty()) { + println!("layer {layer} ({})", g.layer_label(layer)); + for line in &lines { + println!("{line}"); + } + } + if args.json { + json_layers.push(json!({ + "layer": layer, + "layer_name": g.layer_label(layer), + "keys": keys, + })); + } + } + if args.json { + print_json(&json!({ + "source": if args.defaults { "factory" } else { "active" }, + "changed_only": args.changed, + "layers": json_layers, + }))?; + } + Ok(()) + }) +} + +pub fn run_export(api: &HidApi, args: &KeymapExportArgs) -> Result<()> { + with_keyboard(api, &args.device, |kb| { + let current = kb.read(false)?; + let factory = kb.read(true)?; + print!("{}", export_config(¤t, &factory, &kb.layout)); + Ok(()) + }) +} + +pub fn run_set(api: &HidApi, args: &KeymapSetArgs) -> Result<()> { + let code = parse_keycode(&args.code)?; + with_keyboard(api, &args.device, |kb| { + let layer = parse_layer_spec(&args.layer, &kb.geometry())?; + let index = kb.layout.parse_position(&args.position)?; + let current = kb.read(false)?; + let change = Change { + layer, + index, + from: current.get(layer, index), + to: code, + }; + let unchanged = change.from == change.to; + + let backup = if args.dry_run { + None + } else { + // An explicit `set` always writes (even an unchanged value) and verifies. + Some(kb.write_and_verify(¤t, std::slice::from_ref(&change))?) + }; + + if args.json { + let mut v = change_json(&kb.layout, &change); + v["unchanged"] = json!(unchanged); + v["dry_run"] = json!(args.dry_run); + v["verified"] = json!(backup.is_some()); + v["backup"] = json!(backup.map(|p| p.display().to_string())); + return print_json(&v); + } + println!("{}", change.describe(&kb.layout)); + match (args.dry_run, unchanged) { + (true, _) => println!("dry run: nothing written"), + (false, true) => println!("value unchanged; rewritten and verified"), + (false, false) => println!("written and verified"), + } + Ok(()) + }) +} + +pub fn run_apply(api: &HidApi, args: &KeymapApplyArgs) -> Result<()> { + let text = std::fs::read_to_string(&args.file) + .with_context(|| format!("failed to read {}", args.file.display()))?; + with_keyboard(api, &args.device, |kb| { + let assignments: Vec = parse_config(&text, &kb.layout) + .with_context(|| format!("invalid keymap file {}", args.file.display()))?; + let current = kb.read(false)?; + let changes = diff_assignments(¤t, &assignments); + + let backup = if args.dry_run || changes.is_empty() { + None + } else { + Some(kb.write_and_verify(¤t, &changes)?) + }; + + if args.json { + let list: Vec = changes.iter().map(|c| change_json(&kb.layout, c)).collect(); + return print_json(&json!({ + "file": args.file.display().to_string(), + "keys_in_file": assignments.len(), + "changes": list, + "dry_run": args.dry_run, + "written": backup.is_some(), + "verified": backup.is_some(), + "backup": backup.map(|p| p.display().to_string()), + })); + } + if changes.is_empty() { + println!( + "no changes ({} keys in file already match)", + assignments.len() + ); + return Ok(()); + } + print_changes(&kb.layout, &changes); + if args.dry_run { + println!("dry run: {} change(s), nothing written", changes.len()); + } else { + println!("{} change(s) written and verified", changes.len()); + } + Ok(()) + }) +} + +pub fn run_reset(api: &HidApi, args: &KeymapResetArgs) -> Result<()> { + with_keyboard(api, &args.device, |kb| { + let g = kb.geometry(); + let layers = match args.mode { + KeymapResetMode::Mac => g.mode_layers(0)?, + KeymapResetMode::Windows => g.mode_layers(1)?, + KeymapResetMode::All => 0..g.layer_count, + }; + if !args.yes { + bail!( + "refusing to reset layers {}-{} to factory without --yes", + layers.start, + layers.end - 1 + ); + } + let current = kb.read(false)?; + let factory = kb.read(true)?; + let backup = backup_keymap(¤t)?; + eprintln!("backup: {}", backup.display()); + + let mut desired = current.clone(); + for layer in layers.clone() { + for index in 0..g.keys_per_layer() { + desired.set(layer, index, factory.get(layer, index)); + } + } + let changes = diff_keymaps(¤t, &desired); + kb.protocol + .restore_layers(layers.start, layers.end - layers.start) + .with_context(|| { + format!( + "restore with: nuphyctl keymap restore-backup {}", + backup.display() + ) + })?; + kb.verify(&desired, &backup)?; + + if args.json { + let list: Vec = changes.iter().map(|c| change_json(&kb.layout, c)).collect(); + return print_json(&json!({ + "layers": layers.collect::>(), + "changes": list, + "verified": true, + "backup": backup.display().to_string(), + })); + } + print_changes(&kb.layout, &changes); + println!( + "layers {}-{} reset to factory ({} key(s) changed), verified", + layers.start, + layers.end - 1, + changes.len() + ); + Ok(()) + }) +} + +pub fn run_restore_backup(api: &HidApi, args: &KeymapRestoreBackupArgs) -> Result<()> { + let bytes = std::fs::read(&args.file) + .with_context(|| format!("failed to read {}", args.file.display()))?; + with_keyboard(api, &args.device, |kb| { + let desired = Keymap::from_bytes(kb.geometry(), bytes) + .with_context(|| format!("{} does not match this keyboard", args.file.display()))?; + let current = kb.read(false)?; + let changes = diff_keymaps(¤t, &desired); + + let backup = if args.dry_run || changes.is_empty() { + None + } else { + Some(kb.write_and_verify(¤t, &changes)?) + }; + + if args.json { + let list: Vec = changes.iter().map(|c| change_json(&kb.layout, c)).collect(); + return print_json(&json!({ + "file": args.file.display().to_string(), + "changes": list, + "dry_run": args.dry_run, + "written": backup.is_some(), + "verified": backup.is_some(), + "backup": backup.map(|p| p.display().to_string()), + })); + } + if changes.is_empty() { + println!( + "no changes (keyboard already matches {})", + args.file.display() + ); + return Ok(()); + } + print_changes(&kb.layout, &changes); + if args.dry_run { + println!("dry run: {} change(s), nothing written", changes.len()); + } else { + println!("{} change(s) written and verified", changes.len()); + } + Ok(()) + }) +} + +pub fn run_keycodes(args: &KeymapListArgs) -> Result<()> { + if args.json { + let list: Vec = KEYCODES + .iter() + .map(|k| { + json!({ + "name": k.name, + "code": format_hex(k.code), + "value": k.code, + "aliases": k.aliases, + "group": k.group.name(), + "description": k.description, + }) + }) + .collect(); + return print_json(&json!({ + "keycodes": list, + "patterns": [ + { "pattern": "MO(n)", "code": "0x5220 | n", "description": "momentary layer n while held (0-31)" }, + { "pattern": "0xNNNN", "description": "any raw 16-bit keycode" }, + ], + })); + } + for k in KEYCODES { + let aliases = if k.aliases.is_empty() { + String::new() + } else { + format!(" [{}]", k.aliases.join(", ")) + }; + println!( + "{} {:<26} {:<8} {}{}", + format_hex(k.code), + k.name, + k.group.name(), + k.description, + aliases + ); + } + println!("0x522n MO(n) layer momentary layer n while held"); + println!("any other value can be given as hex, e.g. 0x7e0a"); + Ok(()) +} + +pub fn run_positions(args: &KeymapListArgs) -> Result<()> { + let g = Geometry::AIR75_V3; + if args.json { + let list: Vec = AIR75_V3_POSITIONS + .iter() + .map(|p| { + let (row, col) = match g.row_col(p.index) { + Some((r, c)) => (json!(r), json!(c)), + None => (Value::Null, Value::Null), + }; + json!({ + "name": p.name, + "index": p.index, + "row": row, + "col": col, + "aliases": p.aliases, + "description": p.description, + }) + }) + .collect(); + return print_json(&json!({ "layout": "Air75 V3", "positions": list })); + } + println!("Air75 V3 positions (Windows legends; also accepted: row,col and #index)"); + for p in AIR75_V3_POSITIONS { + let rc = match g.row_col(p.index) { + Some((r, c)) => format!("{r},{c}"), + None => "-".to_string(), + }; + let mut line = format!(" {:<9} {:<6} #{:<3}", p.name, rc, p.index); + if !p.aliases.is_empty() { + line.push_str(&format!(" [{}]", p.aliases.join(", "))); + } + if !p.description.is_empty() { + line.push_str(&format!(" {}", p.description)); + } + println!("{}", line.trim_end()); + } + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index e0f2290..d301dd9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,9 @@ mod app; mod cli; mod color; mod hid_transport; +mod keycodes; +mod keymap; +mod keymap_cmd; mod nuphy_protocol; use anyhow::{Context, Result}; diff --git a/src/nuphy_protocol.rs b/src/nuphy_protocol.rs index a505e0d..c97a0ad 100644 --- a/src/nuphy_protocol.rs +++ b/src/nuphy_protocol.rs @@ -12,6 +12,16 @@ const CMD_SET_DATA: u8 = 0xd6; const CMD_APPLY: u8 = 0xd5; const CMD_KEY_EXCHANGE: u8 = 0xee; +const CMD_GET_BASE: u8 = 0xa0; +const CMD_GET_FIRMWARE_INFO: u8 = 0xa1; +const CMD_GET_DEFAULT_KEYS: u8 = 0xb1; +const CMD_GET_USE_KEYS: u8 = 0xb2; +const CMD_SET_USE_KEYS: u8 = 0xb3; +const CMD_RESTORE_USE_LAYERS: u8 = 0xb4; + +const DATA_INFO_LEN: u8 = 8; +const DATA_COMMAND_ATTEMPTS: usize = 3; + const SUBCMD_MAIN_LIGHT: u8 = 0x09; const SUBCMD_SIDE_LIGHT: u8 = 0x08; const SUBCMD_MAIN_BRIGHTNESS: u8 = 0x01; @@ -169,6 +179,80 @@ impl<'a> KeyboardProtocol<'a> { Ok(()) } + /// `GetBase` (`0xa0`): mode, layer count, layers per mode, rows, cols, extras. + pub fn get_base(&self) -> Result> { + self.data_command(CMD_GET_BASE, 0, DATA_INFO_LEN, &[]) + .context("GetBase (0xa0) failed") + } + + /// `GetFirmwareInfo` (`0xa1`). + pub fn get_firmware_info(&self) -> Result> { + self.data_command(CMD_GET_FIRMWARE_INFO, 0, DATA_INFO_LEN, &[]) + .context("GetFirmwareInfo (0xa1) failed") + } + + /// Read `len` bytes of the active (`GetUseKeys`, `0xb2`) or factory + /// (`GetDefaultKeys`, `0xb1`) keymap, starting at byte 0. + pub fn read_keymap(&self, factory: bool, len: usize) -> Result> { + let cmd = if factory { + CMD_GET_DEFAULT_KEYS + } else { + CMD_GET_USE_KEYS + }; + let mut out = Vec::with_capacity(len); + while out.len() < len { + let chunk = (len - out.len()).min(KEY_EXCHANGE_PAYLOAD_LEN); + let addr = u16::try_from(out.len()).context("keymap address exceeds 16 bits")?; + let data = self + .data_command(cmd, addr, chunk as u8, &[]) + .with_context(|| format!("keymap read (0x{cmd:02x}) at byte {addr} failed"))?; + out.extend_from_slice(&data); + } + Ok(out) + } + + /// `SetUseKeys` (`0xb3`): write raw keymap bytes at a byte address. + pub fn write_keymap(&self, addr: u16, data: &[u8]) -> Result<()> { + if data.is_empty() || data.len() > KEY_EXCHANGE_PAYLOAD_LEN { + bail!( + "keymap write must be 1..={} bytes", + KEY_EXCHANGE_PAYLOAD_LEN + ); + } + self.data_command(CMD_SET_USE_KEYS, addr, data.len() as u8, data) + .with_context(|| format!("keymap write (0xb3) at byte {addr} failed"))?; + Ok(()) + } + + /// `RestoreUseLayers` (`0xb4`): reset `count` layers starting at `first` + /// to the factory keymap. + pub fn restore_layers(&self, first: u8, count: u8) -> Result<()> { + let zeros = vec![0u8; usize::from(count)]; + self.data_command(CMD_RESTORE_USE_LAYERS, u16::from(first), count, &zeros) + .context("RestoreUseLayers (0xb4) failed")?; + Ok(()) + } + + /// Send one data command and return its decoded payload. Retries on + /// timeouts and malformed replies; every data command is idempotent. + fn data_command(&self, cmd: u8, addr: u16, len: u8, data: &[u8]) -> Result> { + let packet = build_data_packet(cmd, addr, len, data, self.session_key)?; + let key = self.session_key; + let mut last_err = None; + for _ in 0..DATA_COMMAND_ATTEMPTS { + let reply = self + .responder + .send_and_expect_match(self.dev, 0, &packet, |r| { + r[0] == 0xaa && r[1] == cmd && decode_data_header(r, key) == (len, addr) + }); + match reply.and_then(|r| parse_data_response(&r, cmd, addr, len, key)) { + Ok(payload) => return Ok(payload), + Err(err) => last_err = Some(err), + } + } + Err(last_err.expect("at least one attempt")) + } + fn send_set_data(&self, subcommand: u8, offset: u16, payload: &[u8], size: u8) -> Result<()> { let packet = build_protocol_packet( CMD_SET_DATA, @@ -416,9 +500,155 @@ fn build_protocol_packet( Ok(packet) } +/// Data-command request: `55 cmd 00 sum len^K addr_lo^K addr_hi^K 00^K data^K`. +/// Same framing as `build_protocol_packet`, with the length in byte 4. +fn build_data_packet(cmd: u8, addr: u16, len: u8, data: &[u8], key: SessionKey) -> Result { + build_protocol_packet(cmd, len, addr, data, 0, key) +} + +fn decode_data_header(report: &Report, key: SessionKey) -> (u8, u16) { + let k = key.value(); + let len = report[4] ^ k; + let addr = u16::from(report[5] ^ k) | (u16::from(report[6] ^ k) << 8); + (len, addr) +} + +/// Validate a data-command reply (`aa cmd .. sum len addr .. data`, all +/// fields after byte 3 XORed with the session key) and return its payload. +fn parse_data_response( + report: &Report, + cmd: u8, + addr: u16, + len: u8, + key: SessionKey, +) -> Result> { + validate_ack(report, cmd)?; + let (got_len, got_addr) = decode_data_header(report, key); + if got_len != len || got_addr != addr { + bail!( + "reply to 0x{cmd:02x} echoes len={got_len} addr={got_addr}, expected len={len} addr={addr}" + ); + } + let len = usize::from(len); + if len > KEY_EXCHANGE_PAYLOAD_LEN { + bail!("reply length {len} exceeds packet payload"); + } + Ok(report[8..8 + len].iter().map(|b| b ^ key.value()).collect()) +} + +/// Firmware version from `GetFirmwareInfo`, formatted like NuPhy IO +/// (`major.minor.patch[.build]`). +pub fn format_firmware_version(info: &[u8]) -> String { + if info.len() < 6 { + return format!("{info:02x?}"); + } + let mut version = format!("{}.{}.{}", info[2], info[1], info[0]); + if info[3] == 0xaa { + version.push_str(&format!( + ".{}", + u16::from(info[4]) | (u16::from(info[5]) << 8) + )); + } + version +} + fn calc_checksum(packet: &Report) -> u8 { packet[4..] .iter() .copied() .fold(0u8, |acc, b| acc.wrapping_add(b)) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn data_packet_without_key() { + let p = build_data_packet(CMD_GET_USE_KEYS, 784, 56, &[], SessionKey(0)).unwrap(); + assert_eq!( + &p[..8], + &[0x55, 0xb2, 0x00, 56 + 0x10 + 0x03, 56, 0x10, 0x03, 0x00] + ); + assert!(p[8..].iter().all(|&b| b == 0)); + } + + #[test] + fn data_packet_xor_and_checksum() { + let key = SessionKey(0x5a); + let p = build_data_packet(CMD_SET_USE_KEYS, 0x0312, 2, &[0x00, 0xbe], key).unwrap(); + assert_eq!(p[0], 0x55); + assert_eq!(p[1], 0xb3); + assert_eq!(p[2], 0x00); + assert_eq!( + &p[4..10], + &[2 ^ 0x5a, 0x12 ^ 0x5a, 0x03 ^ 0x5a, 0x5a, 0x5a, 0xbe ^ 0x5a] + ); + // Unused payload bytes stay zero (not XORed), as in NuPhy IO. + assert!(p[10..].iter().all(|&b| b == 0)); + let sum = p[4..].iter().fold(0u8, |a, &b| a.wrapping_add(b)); + assert_eq!(p[3], sum); + } + + #[test] + fn restore_packet_layout() { + let p = build_data_packet(CMD_RESTORE_USE_LAYERS, 4, 4, &[0; 4], SessionKey(0)).unwrap(); + assert_eq!( + &p[..12], + &[0x55, 0xb4, 0x00, 0x08, 0x04, 0x04, 0x00, 0x00, 0, 0, 0, 0] + ); + } + + fn reply(cmd: u8, len: u8, addr: u16, data: &[u8], key: u8) -> Report { + let mut r = [0u8; REPORT_LEN]; + r[0] = 0xaa; + r[1] = cmd; + r[4] = len ^ key; + r[5] = (addr as u8) ^ key; + r[6] = ((addr >> 8) as u8) ^ key; + r[7] = key; + for (i, b) in data.iter().enumerate() { + r[8 + i] = b ^ key; + } + r[3] = calc_checksum(&r); + r + } + + #[test] + fn parses_data_reply_with_key() { + let key = SessionKey(0x3c); + let r = reply(0xb2, 4, 784, &[0x00, 0x29, 0x00, 0xbe], 0x3c); + assert_eq!(decode_data_header(&r, key), (4, 784)); + let data = parse_data_response(&r, 0xb2, 784, 4, key).unwrap(); + assert_eq!(data, vec![0x00, 0x29, 0x00, 0xbe]); + } + + #[test] + fn rejects_bad_replies() { + let key = SessionKey(0); + let good = reply(0xb2, 2, 0, &[0, 0x29], 0); + assert!(parse_data_response(&good, 0xb2, 0, 2, key).is_ok()); + assert!(parse_data_response(&good, 0xb2, 2, 2, key).is_err()); + assert!(parse_data_response(&good, 0xb1, 0, 2, key).is_err()); + let mut bad_sum = good; + bad_sum[3] ^= 1; + assert!(parse_data_response(&bad_sum, 0xb2, 0, 2, key).is_err()); + let mut bad_header = good; + bad_header[0] = 0x55; + bad_header[3] = calc_checksum(&bad_header); + assert!(parse_data_response(&bad_header, 0xb2, 0, 2, key).is_err()); + } + + #[test] + fn firmware_version() { + assert_eq!( + format_firmware_version(&[4, 0, 1, 0xaa, 5, 0, 0, 0]), + "1.0.4.5" + ); + assert_eq!( + format_firmware_version(&[17, 0, 1, 0xaa, 6, 0, 0, 0]), + "1.0.17.6" + ); + assert_eq!(format_firmware_version(&[3, 2, 1, 0, 0, 0]), "1.2.3"); + } +}