Skip to main content

hal-driver-development

This skill should be used when writing, porting, or debugging device drivers in hypercolor-hal. Triggers on "add a driver", "port a driver", "implement protocol", "device not working", "wire format", "encode frame", "USB HID packet", "zerocopy struct", "CommandBuffer", "device database entry", "transport type", "frame encoding", "protocol implementation", "add device support", or any work in crates/hypercolor-hal/.

Source facts

Repository
hyperb1iss/hypercolor
Last source activity
August 26, 2026 at 17:08
Detected SKILL.md language
English
Stars
32
Forks
0

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

File Explorer
3 files

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
hal-driver-development
version
1.0.0
description
This skill should be used when writing, porting, or debugging device drivers in hypercolor-hal. Triggers on "add a driver", "port a driver", "implement protocol", "device not working", "wire format", "encode frame", "USB HID packet", "zerocopy struct", "CommandBuffer", "device database entry", "transport type", "frame encoding", "protocol implementation", "add device support", or any work in crates/hypercolor-hal/.
# Hypercolor HAL Driver Development ## Architecture Boundary `hypercolor-hal` must never depend on `hypercolor-core` — that would create a circular dependency (`core` depends on `hal`). Dependencies: `hypercolor-color`, `hypercolor-types`, `async-trait`, `thiserror`, `tracing`, `nusb`, `tokio`, `tokio-serial`, `zerocopy`, `hidapi`, `midir`, `image`, `turbojpeg`. Linux adds `alsa`, `async-hid`, `futures-util`, `i2cdev`, `nix`; Windows adds `hypercolor-windows-pawnio`. ## HAL Descriptor or Driver Module? A HAL device descriptor is not the only way to add hardware support, and it is the wrong way for most non-USB devices. Every driver module assembled from HAL descriptors is granted exactly one capability (`database.rs`): ```rust DriverCapabilitySet { protocol_catalog: true, config: false, discovery: false, pairing: false, output_backend: false, runtime_cache: false, credentials: false, presentation: false, controls: false, } ``` So a HAL descriptor buys wire-format encoding on the shared USB output backend and nothing else. If the device needs driver-scoped config, a pairing or authorization flow, active discovery, stored credentials, presentation metadata, or dynamic control surfaces, it needs a real `DriverModule` in `hypercolor-driver-api` (see `docs/specs/51-unified-driver-module-api.md` and `docs/specs/52-dynamic-driver-control-surfaces.md`), not a descriptor here. The network drivers (`hypercolor-driver-hue`, `-nanoleaf`, `-wled`, `-govee`) are the worked examples. The HAL's own module descriptors are derived, not hand-written: `ProtocolDatabase::module_descriptors()` groups `DEVICE_DESCRIPTORS` by `DeviceDescriptor::driver_id()`, and `hypercolor-driver-builtin`'s `hal_catalog_driver_modules()` wraps each group. Every module stamps `DRIVER_MODULE_API_SCHEMA_VERSION`. ## Before USB Driver Surgery For “device jank”, “USB jank”, or “all USB devices stutter” reports, query daemon telemetry before editing protocol code: ```bash just diagnose -- --json hypercolor diagnose --system -j curl -s -X POST http://127.0.0.1:9420/api/v1/diagnose \ -H 'content-type: application/json' -d '{"system":true}' ``` Use the data to place the bug: - `snapshot.render.latest_frame.gpu_sample_stale` or `output_frame_source=published_frame` means LEDs may be receiving old sampled data before any USB write happens. - `snapshot.device_output.items[]` shows per-device queue health: `backend_id`, `fps_sent`, `fps_queued`, `frames_dropped`, `avg_queue_wait_ms`, `avg_write_ms`, `worker_finished`, `last_error`, `last_sequence`. - `snapshot.usb.display_frames_delayed_for_led_total` and wait times reveal shared USB actor display-lane contention, not necessarily LED protocol failure. - If all USB devices jank in unison and queues are healthy, investigate shared render sampling/output reuse first. - If `output_frame_source=current_frame`, `gpu_sample_retry_hit=true`, writes are fast, and slow-frame warnings are `wake_late`, look for host scheduler pressure such as active Rust/Servo builds before editing USB protocol code. - Queue `frames_dropped` is not automatically bad: capped devices intentionally replace stale pending payloads when render FPS exceeds device FPS. Compare `fps_sent` to `fps_target` and check write/queue latency plus errors. - If one family has high `avg_write_ms`, drops, or errors while others are clean, then inspect transport/protocol encoding. Do not lower FPS, resolution, LED counts, or performance caps to hide driver symptoms. ## Protocol Trait Contract Every driver implements `Protocol` (in `src/protocol.rs`). The trait is declared `pub trait Protocol: Send + Sync`, so any interior mutability has to be a `Mutex` or `RwLock`. A `RefCell` field compiles fine inside the driver and only blows up later at the `Box<dyn Protocol>` boundary in `ProtocolBinding::build`. Required methods (no default body): - `name()` → human-readable protocol name (`&'static str`) - `init_sequence()` → commands sent on device connect (mode switch, firmware probe) - `shutdown_sequence()` → graceful release (restore hardware control) - `encode_frame(&self, colors: &[[u8; 3]]) -> Vec<ProtocolCommand>` — convenience wrapper - `parse_response(&self, data: &[u8]) -> Result<ProtocolResponse, ProtocolError>` — device replies - `zones()` → physical LED zones for spatial mapping - `capabilities()` → what the device supports - `total_leds()` → LED count (determines color slice length) - `frame_interval()` → target frame timing Provided methods worth overriding: - `encode_frame_into(&self, colors, commands: &mut Vec<ProtocolCommand>)` — **prefer this** — reuses the command vector across frames (zero-alloc hot path); the default just forwards to `encode_frame` - `encode_brightness(&self, brightness: u8) -> Option<Vec<ProtocolCommand>>` — hardware brightness control - `encode_scroll_mode(&self, mode: ScrollMode) -> Option<Vec<ProtocolCommand>>` — hardware scroll wheel mode (Razer mice) - `encode_scroll_smart_reel(&self, enabled: bool) -> Option<Vec<ProtocolCommand>>` - `encode_scroll_acceleration(&self, enabled: bool) -> Option<Vec<ProtocolCommand>>` - `connection_diagnostics()` → optional one-shot verification commands for write-only devices - `keepalive()` → returns `Option<ProtocolKeepalive>` (commands + interval) for devices that need periodic traffic to stay in direct mode - `keepalive_commands()` → resolves the command sequence for a keepalive tick (override for stateful keepalives) - `response_timeout()` → budget for commands expecting a reply (default 1s) - `encode_display_frame(&self, jpeg_data: &[u8]) -> Option<Vec<ProtocolCommand>>` — pixel display frame encoding (Corsair LCD, Push 2) - `encode_display_frame_into(&self, jpeg_data, commands) -> Option<()>` — buffer-reusing variant - `encode_display_payload_into(&self, payload: DisplayFramePayload<'_>, commands) -> Option<()>` — format dispatch; the default routes `DisplayFrameFormat::Jpeg` to `encode_display_frame_into` and rejects `Rgb` **Always implement `encode_frame_into`**. The default `encode_frame` allocates a new Vec per frame — fine for tests, terrible at 60 FPS. ## ProtocolError and ProtocolResponse `ProtocolError` has exactly four variants and every one of them is a struct variant. There is no unit variant and no `InternalError`: ```rust ProtocolError::CrcMismatch { expected: u8, actual: u8 } ProtocolError::MalformedResponse { detail: String } ProtocolError::DeviceError { status: ResponseStatus } ProtocolError::EncodingError { detail: String } ``` `ResponseStatus` is `Ok | Busy | Failed | Timeout | Unsupported`. Return `Unsupported` for a response marker the protocol does not recognize; there is no `Unknown` variant. `ProtocolResponse` has no impl block, so there are no constructors. Build it as a struct literal: ```rust Ok(ProtocolResponse { status: ResponseStatus::Ok, data: payload.to_vec() }) ``` For a lock that cannot legitimately be poisoned, use `.expect("reason")` rather than reaching for an error variant that does not exist. `drivers/asus/protocol.rs` does exactly that for its topology `RwLock`. ## ProtocolCommand Structure Each command carries metadata for the transport layer: ```rust ProtocolCommand { data: Vec<u8>, expects_response: bool, // read after sending? response_delay: Duration, // pause before reading post_delay: Duration, // pause after operation transfer_type: TransferType, // Primary | Bulk | HidReport } ``` `transfer_type` tells the transport _how_ to send — some devices mix HID feature reports for commands with bulk transfers for color data (Corsair LINK), or feature reports for commands with output reports for colors (Lian Li). ## CommandBuffer API `CommandBuffer::new(commands)` wraps a `&mut Vec<ProtocolCommand>` for zero-alloc frame encoding: ```rust let mut buffer = CommandBuffer::new(commands); buffer.push_struct(&my_packet, false, Duration::ZERO, COMMAND_DELAY, TransferType::HidReport); // push_fill takes a FnOnce(&mut Vec<u8>) closure — write directly into the reusable buffer buffer.push_fill(false, Duration::ZERO, Duration::ZERO, TransferType::Primary, |buf| { buf.resize(65, 0x00); }); // push_slice is a convenience wrapper over push_fill buffer.push_slice(&raw_bytes, false, Duration::ZERO, Duration::ZERO, TransferType::Primary); buffer.finish(); // truncates to actual used count ``` `push_struct` writes any `IntoBytes + Immutable` struct directly — no intermediate `Vec<u8>`. `push_fill` signature: `push_fill(expects_response, response_delay, post_delay, transfer_type, FnOnce(&mut Vec<u8>))`. ## Zerocopy Wire-Format Structs **Mandatory pattern** for all fixed-size protocol packets: ```rust #[derive(FromZeros, IntoBytes, KnownLayout, Immutable)] #[repr(C)] struct MyPacket { report_id: u8, command: u8, data: [u8; 62], } const _: () = assert!( std::mem::size_of::<MyPacket>() == 64, "MyPacket must match wire size" ); ``` Rules: - `#[repr(C)]` is **required** — without it, Rust reorders fields - Compile-time size assertion is **mandatory** for every packet struct - `FromZeros + IntoBytes` for write-only packets (most frame encoding) - `FromBytes + IntoBytes` for packets also parsed from responses - **Never derive both `FromBytes` and `FromZeros`** — `FromBytes` implies `FromZeros`, dual derive causes `E0119` - Use `read_from_prefix()` not `read_from_bytes()` for parsing — HID transports may return larger buffers (extra report ID byte) - Multi-byte fields: `zerocopy::byteorder::{LittleEndian, U16}` for wire endianness ## Color Slice Normalization Effects produce variable-length color slices. Protocols expect exact LED counts. There is no shared helper for this: each driver defines its own private `normalize_colors` method on its protocol struct, because the expected length and the truncate/pad policy are protocol-specific. Nine drivers currently carry one. Use `Cow` to avoid allocation when lengths already match: ```rust fn normalize_colors<'a>(&self, colors: &'a [[u8; 3]]) -> Cow<'a, [[u8; 3]]> { let expected = usize::try_from(self.total_leds()).unwrap_or(0); if expected == 0 { return Cow::Borrowed(&[]); } if colors.len() == expected { return Cow::Borrowed(colors); } let mut normalized = vec![[0_u8; 3]; expected]; let copy_len = min(colors.len(), expected); normalized[..copy_len].copy_from_slice(&colors[..copy_len]); Cow::Owned(normalized) } ``` Reference implementation: `drivers/razer/protocol.rs`, which also logs a `warn!` on every length mismatch. ## Device Database Registration `src/database.rs` holds a static `LazyLock<Vec<DeviceDescriptor>>`. Each driver module exposes a `descriptors()` function, usually in its own `devices.rs` but not always: Razer nests it at `drivers/razer/devices/mod.rs`, and Corsair splits across four sub-modules (`lighting_node`, `lcd`, `link`, `peripheral`) that `drivers/corsair/devices.rs` concatenates. ```rust fn is_v2_firmware(candidate: &str) -> bool { candidate.trim().starts_with("2.") } static DESCRIPTORS: &[DeviceDescriptor] = &[DeviceDescriptor { vendor_id: 0x1234, product_id: 0x5678, name: "My Device", family: DeviceFamily::new_static("myvendor", "My Vendor"), transport: TransportType::UsbHid { interface: 0 }, protocol: ProtocolBinding { id: "myvendor/mydevice", build: || Box::new(MyProtocol::new()), }, firmware_predicate: None, // or Some(is_v2_firmware) }]; pub fn descriptors() -> &'static [DeviceDescriptor] { DESCRIPTORS } ``` That plain-slice form works only because every field is const-constructible. A descriptor whose `transport` comes from `resolve_current_transport(...).expect(...)` cannot live in a `static ...: &[DeviceDescriptor]` at all; see Transport Selection Guide below. `DeviceFamily` is a **struct, not an enum**. There is no `DeviceFamily::MyFamily` variant to reach for. Three constructors exist: `new_static(id, name)` for const contexts (what every HAL driver uses), `new(id, name)`, and `named(name)` which derives the id from the display name. The id must be lowercase ASCII; `new` and `named` sanitize it for you, `new_static` does not, so pass a clean id. **`protocol.id` shape is load-bearing.** `DeviceDescriptor::driver_id()` splits it on the first `/` and takes the left half, falling back to the family id when there is no slash. That driver id becomes the driver module ID, which is in turn the key under `config.drivers` in the daemon config and the filter key for `enabled_driver_ids`. Pick `"<driver_id>/<model>"` and keep the left half stable across every descriptor in the module. **Firmware predicates** disambiguate same-PID devices with different protocols (Lian Li AL firmware 1.7 uses HID, 1.0 uses vendor control). Write a named local predicate; `firmware_matches` is a private free function inside `drivers/lianli/devices.rs` and cannot be imported. Resolution order in `ProtocolDatabase::lookup_with_firmware_for_driver_ids`: when a firmware string is known, the first descriptor whose predicate matches wins; otherwise the first descriptor with **no** predicate wins; only if every candidate has a predicate does the first candidate win as a last resort. So a predicate-free descriptor is the fallback for a device whose firmware was never read. ## Known Wire Format Gotchas | Device Family | Gotcha | | ------------- | --------------------------------------------------------------------------------------------- |
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub