From f95149c5df31b2054083d895ecadb71895077aff Mon Sep 17 00:00:00 2001 From: fy59 <259912584+labfytools@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:01:00 +0100 Subject: [PATCH] Initial public release of Labfy RGB --- .gitignore | 43 ++++++ LICENSE | 21 +++ README.md | 265 +++++++++++++++++++++++++++++++++++ THIRD_PARTY_NOTICES.md | 78 +++++++++++ docs/protocol.md | 65 +++++++++ docs/quickshell.md | 74 ++++++++++ meson.build | 24 ++++ packaging/70-labfy-rgb.rules | 5 + packaging/apply-udev-rule.sh | 65 +++++++++ src/hid_device.c | 158 +++++++++++++++++++++ src/hid_device.h | 28 ++++ src/lamp_array.c | 83 +++++++++++ src/lamp_array.h | 29 ++++ src/main.c | 188 +++++++++++++++++++++++++ src/profile.inc | 50 +++++++ src/rgb_controller.c | 94 +++++++++++++ src/rgb_controller.h | 34 +++++ src/rgb_state.c | 116 +++++++++++++++ src/rgb_state.h | 24 ++++ tests/cli.sh | 37 +++++ tests/data/README.md | 20 +++ tests/data/ite5570-5570.desc | Bin 0 -> 748 bytes tests/test_cli_integration.c | 130 +++++++++++++++++ tests/test_concurrency.c | 33 +++++ tests/test_controller.c | 154 ++++++++++++++++++++ tests/test_core.c | 129 +++++++++++++++++ tests/test_state.c | 61 ++++++++ tests/test_transport.c | 101 +++++++++++++ 28 files changed, 2109 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 THIRD_PARTY_NOTICES.md create mode 100644 docs/protocol.md create mode 100644 docs/quickshell.md create mode 100644 meson.build create mode 100644 packaging/70-labfy-rgb.rules create mode 100644 packaging/apply-udev-rule.sh create mode 100644 src/hid_device.c create mode 100644 src/hid_device.h create mode 100644 src/lamp_array.c create mode 100644 src/lamp_array.h create mode 100644 src/main.c create mode 100644 src/profile.inc create mode 100644 src/rgb_controller.c create mode 100644 src/rgb_controller.h create mode 100644 src/rgb_state.c create mode 100644 src/rgb_state.h create mode 100755 tests/cli.sh create mode 100644 tests/data/README.md create mode 100644 tests/data/ite5570-5570.desc create mode 100644 tests/test_cli_integration.c create mode 100644 tests/test_concurrency.c create mode 100644 tests/test_controller.c create mode 100644 tests/test_core.c create mode 100644 tests/test_state.c create mode 100644 tests/test_transport.c diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7749e11 --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# Public source tree allowlist: local audits, screenshots, builds and scratch +# files remain private even when someone runs git add . from the project root. +/* +!/.gitignore +!/README.md +!/LICENSE +!/THIRD_PARTY_NOTICES.md +!/meson.build +!/src/ +!/tests/ +!/packaging/ +!/docs/ + +# Generated files and local credentials must not enter allowed directories. +**/__pycache__/ +*.py[cod] +*.o +*.a +*.so +*.so.* +*.out +*.exe +*.gcda +*.gcno +*.profraw +*.profdata +*.log +*.png +*.jpg +*.jpeg +*.webp +*.pdf +*.tmp +*.bak +*~ +.env +.env.* +*.pem +*.key +*.p12 +*.pfx +state.bin +.state.pending diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4b6f3e6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 fy59 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d0e35f2 --- /dev/null +++ b/README.md @@ -0,0 +1,265 @@ +# Labfy RGB + +A small native **C17 Linux command-line controller** for the RGB keyboard on the +**ASUS Vivobook S16 M5606UA**, using its **ITE5570 I2C HID controller (`0B05:5570`)** +and the standard **HID LampArray** protocol. + +Labfy RGB talks to Linux `hidraw` through `open`, `close` and HID Feature ioctls. +It is a userspace application, not a kernel module. Static colors need no daemon. +It works independently of Sway, Wayland, X11 and QuickShell. + +- Dynamically discovers the matching device; never assumes `/dev/hidraw1`. +- Checks the bus, VID/PID and complete known HID report descriptor before control. +- Reads live lamp capabilities before converting RGB and intensity values. +- Supports static colors, intensity, off, and explicit firmware control. +- Serializes cooperating instances and attempts firmware recovery on update errors. +- Keeps command history separate from hardware observations. + +## Tested hardware and compatibility + +| Item | Verified on the tested machine | +|---|---| +| Laptop | ASUS Vivobook S16 M5606UA | +| Controller | ITE5570, I2C HID `0B05:5570` | +| Kernel HID driver | `hid-generic` | +| Operating system | Arch Linux, x86-64 | +| Logical lamps | One, ID 0 | +| Programmability | Programmable RGB | +| Maximum R/G/B/intensity values | 255 / 255 / 255 / 255 | +| Minimum update interval | 3000 microseconds | +| Physical tests | Static RGB colors, including the eight examples below, and return to ASUS firmware control | +| Unprivileged access | Confirmed with the targeted udev rule and an active local session | + +Firmware control returned the tested keyboard to white. Other firmware versions +may choose a different effect or color. + +Other Vivobook models, other VID/PID combinations, USB devices and descriptor +variants are **not validated or currently accepted**. Even another `0B05:5570` +may have a different descriptor. The strict profile is deliberate: a familiar +product name or report length is not enough to safely identify field semantics. +The protocol may exist on other hardware, but that is not a compatibility claim. + +## Build + +Requirements: Linux headers, a C17 compiler, Meson and Ninja. No Python or HID +library is required for the controller implementation. + +On Arch Linux, the usual build tools are provided by `base-devel`, `meson` and +`ninja`. The optional permission helper also needs Bash, systemd's `udevadm`, +coreutils and `getfacl` from `acl`. + +```sh +git clone https://git.labfytools.com/fy59/labfy-rgb.git +cd labfy-rgb +meson setup build --prefix="$HOME/.local" +meson compile -C build +meson test -C build --print-errorlogs +``` + +GitHub mirror: . + +Meson selects C17 and enables `-Wall -Wextra -Wpedantic -Werror` with GCC/Clang. + +## Install + +Install the executable for your user: + +```sh +meson install -C build +``` + +With the prefix above, the executable is `$HOME/.local/bin/labfy-rgb`. Make sure +that directory is in your session's `PATH`. A system installation can instead +use a suitable prefix and an administrator-managed install step. + +Installing the executable does **not** install udev rules, configure an autostart, +create a systemd service or modify desktop configuration. + +## Device permissions + +The device may initially be root-only. Do not use `chmod 666`, run your desktop +shell as root or grant access to all HID devices. + +The opt-in rule in `packaging/70-labfy-rgb.rules` matches only: + +```udev +SUBSYSTEM=="hidraw", KERNEL=="hidraw*", SUBSYSTEMS=="hid", KERNELS=="0018:0B05:5570.*", TAG+="uaccess" +``` + +On a systemd-logind system, `uaccess` grants access to the active local seat user. +A remote/headless session is not automatically entitled to access. This grants +access to the matching HID node as a whole, not just its lighting reports. + +First inspect the read-only installation plan: + +```sh +bash packaging/apply-udev-rule.sh --check +``` + +Then, only if you want to install and activate that rule: + +```sh +sudo -- bash packaging/apply-udev-rule.sh --apply +``` + +The helper validates the narrow rule, dynamically identifies exactly one matching +controller, refuses to overwrite a different rule, installs it as root-owned +`0644`, reloads udev rules and triggers a `change` event **only for that hidraw +node**. It displays the resulting ACL. It sends no HID reports and changes no +kernel modules. Reloading rules reparses the system's rule set; other rules for +the targeted event may also run. + +Verify as your normal user: + +```sh +labfy-rgb status +``` + +For diagnostics, inspect the discovered node with `getfacl`. A successful rule +installation alone does not prove that an ACL was granted. Logind seat membership +and the resulting ACL must be checked. Do not broaden permissions as a fallback. +Automatic reapplication after reboot or device re-enumeration is expected from +udev matching, but was not physically tested by rebooting or reconnecting the +internal keyboard during development. + +## Commands + +```sh +labfy-rgb status +labfy-rgb status --json +labfy-rgb probe +labfy-rgb set '#8000FF' +labfy-rgb set '#8000FF' 60 +labfy-rgb brightness 30 +labfy-rgb off +labfy-rgb auto +``` + +| Command | Behavior | +|---|---| +| `status [--json]` | Read-only HID identity, descriptor and array attributes; local command history is labeled separately | +| `probe` | Select lamp 0 with SET `0x42`, then read attributes with GET `0x43`; this **is an HID write**, although it does not request a color or mode change | +| `set '#RRGGBB' [P]` | Set a uniform RGB color at intensity P, default **100%**; retain host control after success | +| `brightness P` | Reapply the last locally recorded color at absolute intensity 0–100%; refuse if that color is unknown | +| `off` | Send RGBI zero under host control; preserve a previously known color for a later brightness command | +| `auto` | Explicitly restore autonomous firmware control; do not promise the previous color | +| `--dry-run COMMAND [ARG...]` | Validate arguments and print the plan without opening HID or reading state | + +Color strings must be exactly `#RRGGBB`; quote them in a shell. Intensities are +decimal integers from 0 through 100. Wire levels are rounded to the live channel +maxima. The HID intensity is distinct from the WMI `asus::kbd_backlight` setting; +Labfy RGB does not adjust that setting or assume a synchronized relationship. + +### Saturated color examples + +| Color | Command | +|---|---| +| Violet | `labfy-rgb set '#8000FF'` | +| Blue | `labfy-rgb set '#0040FF'` | +| Cyan | `labfy-rgb set '#00FFFF'` | +| Green | `labfy-rgb set '#00FF40'` | +| Pink | `labfy-rgb set '#FF0080'` | +| Red | `labfy-rgb set '#FF0000'` | +| Orange | `labfy-rgb set '#FF6000'` | +| Yellow | `labfy-rgb set '#FFFF00'` | + +## Firmware recovery and limitations + +A color operation verifies live capabilities, disables autonomous mode, waits +the device's minimum interval and sends one range update for lamp 0. On success, +it leaves host control enabled so that the color persists. + +If a host-control/update operation fails, Labfy RGB attempts **one** firmware +restoration and reports the original error. It also handles ambiguous/short +ioctl returns conservatively, without blindly retrying SET reports. `auto` does +not require a successful capability read or a known color cache. + +SIGKILL, default SIGINT/SIGTERM handling, power loss or disconnection can interrupt +recovery. The keyboard can remain in host control. When access is available, use: + +```sh +labfy-rgb auto +``` + +An ioctl acknowledgement is not an optical measurement or a guarantee of lasting +visual state. Suspend/resume, other tools and firmware may alter the keyboard. +Do not run another lighting controller concurrently: advisory locking only +serializes cooperating Labfy RGB instances. Suspend/resume restoration and the +interaction with WMI brightness have not been comprehensively characterized. + +## Runtime state and JSON + +Normal user state lives at `$XDG_RUNTIME_DIR/labfy-rgb/state.bin`, with a `0700` +directory and `0600` file. Without XDG_RUNTIME_DIR, the fallback is `/run/user/UID`. +Paths, ownership and permissions are checked; symlinks are rejected at the opened +boundaries. Root uses a separate namespace and does not import the old root cache. +State is versioned, bounded and atomically published, with no native-struct +serialization. A replaced device node invalidates its cached identity. + +The cache records the **last acknowledged command**, not current physical color. +`status --json` schema 1 exposes `commanded_mode`, nullable `last_color` and +`last_percent`, while `observed_mode` remains `unknown`. Missing/corrupt state does +not become an invented color. A successful firmware restoration can still return +a cache error if local state could not be invalidated or saved. + +| Exit code | Meaning | +|---|---| +| 0 | Success or valid dry-run | +| 2 | Invalid arguments | +| 3 | Device absent or identity mismatch | +| 4 | Permission denied | +| 5 | Unsupported capabilities, descriptor/protocol mismatch or ambiguous discovery | +| 6 | I/O failure, including a failed operation whose recovery succeeded | +| 7 | Last color unknown/unreadable for brightness; no SET sent | +| 8 | Original operation and its single recovery both failed; device state uncertain | +| 9 | Runtime state/cache error; read the diagnostic, because firmware may already have been restored | +| 10 | Another cooperating instance holds the device lock | + +## Tests + +Seven Meson suites use memory transports, syscall doubles and temporary files. +They do not send reports to a real keyboard. Tests cover color parsing, RGBI +conversion, exact report bytes, discovery, identity rejection, cache validation, +process concurrency, errors, exit codes and single-attempt recovery. + +```sh +CC=clang meson setup build-clang +meson test -C build-clang --print-errorlogs + +CC=gcc meson setup build-sanitize -Db_sanitize=address,undefined -Db_lundef=false +meson test -C build-sanitize --print-errorlogs + +CC=clang meson setup build-clang-sanitize -Db_sanitize=address,undefined -Db_lundef=false +meson test -C build-clang-sanitize --print-errorlogs + +udevadm verify packaging/70-labfy-rgb.rules +bash -n packaging/apply-udev-rule.sh +git diff --check +``` + +Development validation used GCC 16.2.1 and Clang 23.1.4 on Arch Linux. Other Linux +distributions/toolchain versions are not claimed as hardware-tested platforms. +GNU-compatible linker wrapping is used for Linux transport tests. + +## Optional QuickShell integration + +QuickShell is **not required** for the CLI. An existing Control Center can call +it through native `Process` objects using argument arrays, never shell command +strings, sudo or pkexec. Serialize writes, handle exit/error states, and refresh +with `status --json` after completion. Keep the distinction between command +history and physical measurements visible. + +The tested local integration used QuickShell 0.3.1 on SwayFX 0.6 with saturated +presets and a firmware button. It is not automatically installed by this project; +no private desktop configuration is bundled. See [docs/quickshell.md](docs/quickshell.md). +Startup restoration must remain opt-in; this CLI installs no autostart or service. + +## Protocol and acknowledgements + +See [docs/protocol.md](docs/protocol.md) for the accepted report layout and +[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) for reference implementations and +license notices. No third-party implementation source is vendored. + +## License + +MIT, copyright (c) 2026 fy59. See [LICENSE](LICENSE). diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..a0afaa0 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,78 @@ +# Third-party references and notices + +Labfy RGB is an independently written C17 userspace implementation. No VRGB or +hid-rgb-ctl implementation source is vendored. Their public implementations were +consulted to understand discovery and the standard HID LampArray report layouts; +the tested device descriptor was also inspected directly. The notices below +retain attribution to these MIT references and do not imply endorsement. + +## VRGB + +Repository: https://github.com/vrgb-dev/vrgb + +Reviewed revision: `b2ec4a24e3af6769e9758c607f0520a37194c6c7`. +Relevant source: `vrgb.py` (discovery, range update, firmware mode). + +```text +MIT License + +Copyright (c) 2026 vrgb-dev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +## hid-rgb-ctl + +Repository: https://github.com/xz-dev/hid-rgb-ctl + +Reviewed revision: `b711cff99aac5af6839f424fdd3ccdd5b62fa21c`. +Relevant source: `src/device.rs` (attributes, LevelCount conversion and control). + +```text +MIT License + +Copyright (c) 2026 Xiangzhe + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +## System interfaces and protocol metadata + +Linux libc/UAPI headers are build dependencies, not copied implementation files. +The project uses the documented Linux hidraw userspace interface. The included +HID descriptor fixture contains report layout metadata from the tested device, +not a firmware image or personal input capture. USB-IF protocol documentation +and Linux sources are linked in `docs/protocol.md`; neither document nor kernel +implementation source is bundled. diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 0000000..376aafb --- /dev/null +++ b/docs/protocol.md @@ -0,0 +1,65 @@ +# Accepted HID LampArray profile + +Labfy RGB deliberately supports one exact 748-byte descriptor, on I2C HID +`0018:00000B05:00005570`. The corresponding fixture and SHA-256 are documented in +[tests/data/README.md](../tests/data/README.md). Device numbering is discovered +at runtime. The open fd is checked again through RAWINFO and descriptor ioctls +before any control report, so sysfs discovery alone is not trusted. + +This profile uses Lighting and Illumination usage page `0x0059`. A second vendor +page `0xFF59` and its report IDs are not used. Report sizes below include the ID +byte. Multibyte integers are explicitly little endian. + +| ID | Report | Bytes | Payload | +|---|---|---:|---| +| `0x41` | Array attributes | 23 | lamp count u16, dimensions/kind/minimum interval as five u32 | +| `0x42` | Lamp attributes request | 3 | lamp ID u16 | +| `0x43` | Lamp attributes response | 29 | ID u16, X/Y/Z/latency/purposes as five u32, RGBI maxima/programmability/input binding as six u8 | +| `0x44` | Multi update, unused by this implementation | 51 | count/flags, eight IDs and eight RGBI tuples | +| `0x45` | Range update | 10 | flags u8, start/end u16, R/G/B/I u8 | +| `0x46` | Array control | 2 | AutonomousMode u8 | + +All relevant logical minima are zero. Count/IDs have a descriptor maximum of +65535, the u32 fields a maximum of 2147483647, and channel/attribute bytes a +maximum of 255. The descriptor allows flags through 8; the implementation emits +only the defined bit 0, LampUpdateComplete. AutonomousMode is limited to 0 or 1. +Semantic validation is stricter than descriptor widths: IsProgrammable must be +boolean, positions must fit the array bounds, and the accepted array has exactly +one logical lamp, ID 0. Missing RGB channels or unusable intensity are rejected +for color control. Zero RGBI can still be sent for off on a valid fixed-color lamp. + +## Operation + +1. GET `0x41` and require one logical lamp. +2. SET `42 00 00`, then GET `0x43`; check the returned lamp ID and attributes. +3. For color control, require programmable RGB with nonzero RGBI maxima. +4. SET `46 00` to take host control. +5. Wait the array's live minimum interval (3000 us on the tested machine). +6. SET `45 01 00 00 00 00 R G B I`, covering inclusive range [0,0]. +7. Keep host control after success. Restore only on explicit `auto` or recovery. + +The selector in step 2 **is a write**, even though it requests attributes rather +than a lighting change. `status` does not send this selector. `probe` does. + +For an input color channel C in 0..255 and live maximum L, the wire value is +`(C * L + 127) / 255`. For intensity P in 0..100, it is `(P * L + 50) / 100`. +Products are bounded and use sufficient width. LevelCount is the maximum nonzero +intensity, not a count from which one should subtract one; zero means off. +The tested device declares 255 for all four maxima. + +An error from the first attempted host-control SET onward causes exactly one SET +`46 01`. This includes a failed/short host SET whose effect may be ambiguous. +No SET is blindly retried. Original and restoration errors are reported separately. +Explicit `auto` needs identity/descriptor validation, but no RGB capability query +or successful cache access. Neither ioctl success nor cached mode is an optical +measurement. Abrupt termination can prevent recovery. + +## Reference material + +- [USB-IF HUTRR84: Lighting and Illumination](https://www.usb.org/sites/default/files/hutrr84_-_lighting_and_illumination_page.pdf), particularly color attributes, autonomous mode and range updates. +- [Linux hidraw documentation](https://docs.kernel.org/hid/hidraw.html). +- Linux [hidraw.c](https://github.com/torvalds/linux/blob/master/drivers/hid/hidraw.c) and [i2c-hid-core.c](https://github.com/torvalds/linux/blob/master/drivers/hid/i2c-hid/i2c-hid-core.c) for Feature request return semantics. +- The MIT reference implementations acknowledged in [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md). + +Reports are encoded into byte arrays; native C structures, padding and host byte +order are never used as a persistent or wire format. diff --git a/docs/quickshell.md b/docs/quickshell.md new file mode 100644 index 0000000..5fc0bae --- /dev/null +++ b/docs/quickshell.md @@ -0,0 +1,74 @@ +# Optional QuickShell integration + +The CLI runs without QuickShell. This repository does not install a Control +Center, copy personal dotfiles or alter compositor/session configuration. + +A local integration was validated with QuickShell 0.3.1 and SwayFX 0.6 on the +tested laptop. It adds a compact page to an existing Control Center. Its general +Catppuccin Mocha theme is independent of the saturated keyboard colors: + +| Preset | RGB | +|---|---| +| Violet (first choice) | `#8000FF` | +| Blue | `#0040FF` | +| Cyan | `#00FFFF` | +| Green | `#00FF40` | +| Pink | `#FF0080` | +| Red | `#FF0000` | +| Orange | `#FF6000` | +| Yellow | `#FFFF00` | + +## Process contract + +Use native `Quickshell.Io.Process` and an argument array. For example, the +command for a validated violet preset is: + +```qml +Process { + command: ["labfy-rgb", "set", "#8000FF"] + // Start only in response to an explicit user action. + running: false +} +``` + +A production integration should: + +- Validate custom colors with `^#[0-9A-Fa-f]{6}$` and integer intensity 0..100. +- Reserve a single shared busy owner before starting the process, including + across multiple screen bars. Refuse concurrent writes. +- Use `["labfy-rgb", "set", color]` for a preset click; it uses the CLI default + intensity of 100%. The optional fourth argument can combine color/intensity. +- Use `["labfy-rgb", "brightness", String(percent)]` for intensity and + `["labfy-rgb", "auto"]` for an explicit firmware button. +- Update a slider draft without spawning processes on every movement. Apply + on a deliberate button or a controlled release/debounce strategy. +- Handle both nonzero exit/crash status and failure to start. Clear busy on all + completion paths and show a bounded error message. +- Refresh with `["labfy-rgb", "status", "--json"]` after a command completes. + Validate schema and fields before displaying results. +- Keep the firmware button usable if a previous status/cache read failed; + the CLI performs its own identity checks. + +Do not use sudo, pkexec, `sh -c`, `bash -c` or shell command concatenation. The +normal user must already have access to the controller, as described in README. +An absolute installed executable path is also acceptable when the session PATH +is unsuitable; it should not be a hard-coded personal checkout path. + +## State and optional restoration + +Label cached values as the **last commanded** color/intensity/mode. Do not imply +that `observed_mode: "unknown"` or a null color is a measured white keyboard. +On the tested firmware, `auto` visibly returned to white; a different firmware +may select another effect. + +Keep startup restoration disabled by default and require an explicit opt-in. +Store only validated, successfully acknowledged preferences. Firmware preference +must take precedence over an old color, and uncertain write failures should +invalidate replay eligibility. If implemented, use a bounded one-shot startup +check with clear error handling, not permanent polling. Do not replay private or +corrupt runtime cache bytes across logins. Suspend/resume restoration is not +provided or enabled by this project. + +The native cache is temporary command history. Persistent UI preferences are a +separate opt-in concern. No root service, extra daemon, autostart entry or +QuickShell dependency is installed with Labfy RGB. diff --git a/meson.build b/meson.build new file mode 100644 index 0000000..4eb3921 --- /dev/null +++ b/meson.build @@ -0,0 +1,24 @@ +project('labfy-rgb', 'c', version: '0.3.0', default_options: ['c_std=c17', 'warning_level=3', 'werror=true']) +inc = include_directories('src') +# Tests use assert for checks and fixture operations, including in release builds. +test_args = ['-UNDEBUG'] +core = static_library('labfy-rgb-core', ['src/hid_device.c', 'src/lamp_array.c', 'src/rgb_controller.c', 'src/rgb_state.c'], include_directories: inc) +cli = executable('labfy-rgb', 'src/main.c', link_with: core, include_directories: inc, install: true) +t = executable('test-core', 'tests/test_core.c', link_with: core, include_directories: inc, c_args: test_args) +test('core-no-hardware', t, args: [meson.current_source_dir() / 'tests/data/ite5570-5570.desc']) +test('cli-no-hardware', find_program('tests/cli.sh'), args: [cli]) +controller_test = executable('test-controller', 'tests/test_controller.c', link_with: core, include_directories: inc, c_args: test_args) +test('controller-memory-only', controller_test) +state_test = executable('test-state', 'tests/test_state.c', link_with: core, include_directories: inc, c_args: test_args) +test('state-tempdir-only', state_test) +transport_test = executable('test-transport', 'tests/test_transport.c', link_with: core, include_directories: inc, c_args: test_args, + link_args: ['-Wl,--wrap=open', '-Wl,--wrap=open64', '-Wl,--wrap=close', '-Wl,--wrap=ioctl', '-Wl,--wrap=flock']) +test('transport-syscall-doubles-only', transport_test) +cli_under_test = static_library('cli-under-test', 'src/main.c', include_directories: inc, c_args: ['-Dmain=rgb_cli_main']) +cli_integration = executable('test-cli-integration', 'tests/test_cli_integration.c', link_with: [cli_under_test, core], include_directories: inc, c_args: test_args, + link_args: ['-Wl,--wrap=hid_discover', '-Wl,--wrap=hid_descriptor', '-Wl,--wrap=hid_open_control', + '-Wl,--wrap=hid_open_verified', '-Wl,--wrap=hid_get', '-Wl,--wrap=hid_set', '-Wl,--wrap=hid_wait', + '-Wl,--wrap=rgb_state_open', '-Wl,--wrap=rgb_state_save']) +test('cli-memory-transport-only', cli_integration) +concurrency_test = executable('test-concurrency', 'tests/test_concurrency.c', link_with: core, include_directories: inc, c_args: test_args) +test('concurrency-tempfile-only', concurrency_test) diff --git a/packaging/70-labfy-rgb.rules b/packaging/70-labfy-rgb.rules new file mode 100644 index 0000000..a091a40 --- /dev/null +++ b/packaging/70-labfy-rgb.rules @@ -0,0 +1,5 @@ +# Opt-in installation only; this rule grants access to the active local seat. +# Match the observed HID parent, with I2C bus + ASUS VID + exact PID. +# The instance suffix may change after reboot; the hidraw number is irrelevant. +# uaccess needs an active local logind seat; order precedes 73-seat-late.rules. +SUBSYSTEM=="hidraw", KERNEL=="hidraw*", SUBSYSTEMS=="hid", KERNELS=="0018:0B05:5570.*", TAG+="uaccess" diff --git a/packaging/apply-udev-rule.sh b/packaging/apply-udev-rule.sh new file mode 100644 index 0000000..400f1fd --- /dev/null +++ b/packaging/apply-udev-rule.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# WHY: a global hidraw trigger would affect unrelated devices. Discover exactly +# one I2C/ASUS/5570 controller and trigger only its current hidraw sysfs node. +# CONTRACT: default/--check is read-only. --apply requires root AND the owner's +# prior approval; no sudo, chmod of /dev, module change or HID report is issued. +set -euo pipefail +mode=${1:---check} +if [[ $# -gt 1 || ( $mode != --check && $mode != --apply ) ]]; then + printf 'Usage: %s [--check|--apply]\n' "$0" >&2 + exit 2 +fi +script_dir=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) +rule=$script_dir/70-labfy-rgb.rules +target=/etc/udev/rules.d/70-labfy-rgb.rules +udevadm verify "$rule" +# Refuse a broadened proposal, even if its udev syntax is valid. +expected='SUBSYSTEM=="hidraw", KERNEL=="hidraw*", SUBSYSTEMS=="hid", KERNELS=="0018:0B05:5570.*", TAG+="uaccess"' +mapfile -t active_lines < <(sed '/^[[:space:]]*#/d; /^[[:space:]]*$/d' "$rule") +if [[ ${#active_lines[@]} -ne 1 || ${active_lines[0]} != "$expected" ]]; then + printf 'Rule differs from the reviewed narrow match; refusing.\n' >&2 + exit 5 +fi +shopt -s nullglob +matches=() +for candidate in /sys/class/hidraw/hidraw*; do + identity='' + while IFS= read -r line; do + if [[ $line == HID_ID=* ]]; then identity=${line#HID_ID=}; fi + done < "$candidate/device/uevent" + [[ $identity == 0018:00000B05:00005570 ]] || continue + parent=$(readlink -f -- "$candidate/device") + [[ ${parent##*/} == 0018:0B05:5570.* ]] || continue + matches+=("$candidate") +done +if [[ ${#matches[@]} -ne 1 ]]; then + printf 'Expected exactly one matching controller; found %s. No changes.\n' "${#matches[@]}" >&2 + exit 3 +fi +sysfs=${matches[0]} +node=/dev/${sysfs##*/} +if [[ -L $target ]]; then + printf 'Refusing existing symlink: %s\n' "$target" >&2 + exit 5 +fi +if [[ -e $target ]] && ! cmp -s -- "$rule" "$target"; then + printf 'Refusing to overwrite a different existing rule: %s\n' "$target" >&2 + exit 5 +fi +printf 'Rule: %s\nTarget: %s\nDevice: %s\nSysfs: %s\n' "$rule" "$target" "$node" "$sysfs" +if [[ $mode == --check ]]; then + printf 'Read-only plan: install rule root:root 0644, reload rules, trigger change on this node only, settle, inspect ACL.\nNo changes applied.\n' + exit 0 +fi +if (( EUID != 0 )); then + printf -- '--apply requires administrator execution and prior explicit approval. No changes.\n' >&2 + exit 4 +fi +# No overwrite of an unrelated file. A reload reparses all rules, while the +# event is limited to the one discovered node; neither operation sends HID data. +if [[ ! -e $target ]]; then install -o root -g root -m 0644 -- "$rule" "$target"; fi +udevadm control --reload-rules +udevadm trigger --action=change "$sysfs" +udevadm settle --timeout=10 +getfacl -p -- "$node" +printf 'Rule application completed. Run labfy-rgb status as your normal user to verify access.\n' diff --git a/src/hid_device.c b/src/hid_device.c new file mode 100644 index 0000000..d734f03 --- /dev/null +++ b/src/hid_device.c @@ -0,0 +1,158 @@ +#define _POSIX_C_SOURCE 200809L +#include "hid_device.h" +#include "lamp_array.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +static bool valid_name(const char *n) +{ + if (!n || strncmp(n, "hidraw", 6) || !n[6]) return false; + for (const char *p = n+6; *p; ++p) if (*p < '0' || *p > '9') return false; + return true; +} +static int path_for(char *path, size_t cap, const char *root, const char *name, const char *leaf) +{ + if (!root || !valid_name(name)) { errno = EINVAL; return -1; } + int n = snprintf(path, cap, "%s/%s/device/%s", root, name, leaf); + if (n < 0 || (size_t)n >= cap) { errno = ENAMETOOLONG; return -1; } + return 0; +} +static int read_bounded(const char *path, uint8_t *out, size_t capacity, size_t *size) +{ + int fd = open(path, O_RDONLY | O_CLOEXEC); + if (fd < 0) return -1; + size_t used = 0; + int saved = 0; + while (used < capacity) { + ssize_t n = read(fd, out+used, capacity-used); + if (n < 0) { if (errno == EINTR) continue; saved = errno; break; } + if (!n) break; + used += (size_t)n; + } + if (!saved && used == capacity) { + uint8_t extra; + ssize_t n; + do { n = read(fd, &extra, 1); } while (n < 0 && errno == EINTR); + if (n != 0) saved = n < 0 ? errno : EOVERFLOW; + } + if (close(fd) < 0 && !saved) saved = errno; + if (saved) { errno = saved; return -1; } + *size = used; + return 0; +} +bool hid_identity(const char *uevent) +{ + if (!uevent) return false; + const char expected[] = "HID_ID=0018:00000B05:00005570"; + for (const char *p = uevent; *p;) { + const char *end = strchr(p, '\n'); + size_t n = end ? (size_t)(end-p) : strlen(p); + if (n == sizeof expected-1 && !memcmp(p, expected, n)) return true; + if (!end) break; + p = end+1; + } + return false; +} +int hid_discover(const char *root, char *name, size_t capacity) +{ + if (!root || !name || !capacity) { errno = EINVAL; return -1; } + DIR *dir = opendir(root); + if (!dir) return -1; + int saved = 0; + unsigned matches = 0; + for (;;) { + errno = 0; + struct dirent *ent = readdir(dir); + if (!ent) { if (errno) saved = errno; break; } + if (!valid_name(ent->d_name)) continue; + char path[4096], uevent[4096]; + size_t size; + if (path_for(path, sizeof path, root, ent->d_name, "uevent") < 0 || + read_bounded(path, (uint8_t *)uevent, sizeof uevent-1, &size) < 0) { saved = errno; break; } + uevent[size] = '\0'; + if (memchr(uevent, 0, size)) { saved = EPROTO; break; } + if (!hid_identity(uevent)) continue; + /* WHY: never pick a device by directory enumeration order. */ + if (++matches > 1) { saved = EEXIST; break; } + size_t length = strlen(ent->d_name); + if (length >= capacity) { saved = ENAMETOOLONG; break; } + memcpy(name, ent->d_name, length+1); + } + if (closedir(dir) < 0 && !saved) saved = errno; + if (!saved && !matches) saved = ENODEV; + if (saved) { name[0] = 0; errno = saved; return -1; } + return 0; +} +int hid_descriptor(const char *root, const char *name, uint8_t *out, size_t capacity, size_t *size) +{ + if (!out || !size || !capacity) { errno = EINVAL; return -1; } + char path[4096]; + if (path_for(path, sizeof path, root, name, "report_descriptor") < 0) return -1; + return read_bounded(path, out, capacity, size); +} +static int open_verified(const char *name, bool control) +{ + if (!valid_name(name)) { errno = EINVAL; return -1; } + char path[128]; + int n = snprintf(path, sizeof path, "/dev/%s", name); + if (n < 0 || (size_t)n >= sizeof path) { errno = ENAMETOOLONG; return -1; } + int fd = open(path, (control ? O_RDWR : O_RDONLY) | O_CLOEXEC | O_NOFOLLOW); + if (fd < 0) return -1; + struct hidraw_devinfo info = {0}; + int size = 0; + struct hidraw_report_descriptor desc = {0}; + int saved = 0; + if (ioctl(fd, HIDIOCGRAWINFO, &info) < 0) saved = errno; + else if (info.bustype != BUS_I2C || (uint16_t)info.vendor != 0x0b05 || (uint16_t)info.product != 0x5570) saved = ENODEV; + else if (ioctl(fd, HIDIOCGRDESCSIZE, &size) < 0) saved = errno; + else if (size <= 0 || (unsigned)size > HID_MAX_DESCRIPTOR_SIZE) saved = EPROTO; + else { + desc.size = (uint32_t)size; + if (ioctl(fd, HIDIOCGRDESC, &desc) < 0) saved = errno; + else if (desc.size != (uint32_t)size || !la_profile(desc.value, desc.size)) saved = EPROTO; + } + if (!saved && control && hid_lock(fd) < 0) saved = errno; + if (saved) { (void)close(fd); errno = saved; return -1; } + return fd; +} +int hid_lock(int fd) { return flock(fd, LOCK_EX | LOCK_NB); } +int hid_open_verified(const char *name) { return open_verified(name, false); } +int hid_open_control(const char *name) { return open_verified(name, true); } +int hid_get(void *context, uint8_t *report, size_t size) +{ + if (!context || !report || !((size == 23 && report[0] == 0x41) || + (size == 29 && report[0] == 0x43))) { errno = EINVAL; return -1; } + return ioctl(*(int *)context, HIDIOCGFEATURE(size), report); +} +int hid_set(void *context, const uint8_t *report, size_t size) +{ + if (!context || !report) { errno = EINVAL; return -1; } + bool valid = (size == 3 && report[0] == 0x42 && report[1] == 0 && report[2] == 0) || + (size == 2 && report[0] == 0x46 && report[1] <= 1) || + (size == 10 && report[0] == 0x45 && report[1] == 1 && + report[2] == 0 && report[3] == 0 && report[4] == 0 && report[5] == 0); + if (!valid) { errno = EINVAL; return -1; } + /* Linux ioctl has an in/out encoding. Keep caller-owned const bytes intact + * even if a driver writes back. Maximum allowed report is ten bytes. */ + uint8_t copy[LA_RANGE_SIZE]; + memcpy(copy, report, size); + int ret = ioctl(*(int *)context, HIDIOCSFEATURE(size), copy); + if (ret >= 0 && (size_t)ret != size) { errno = EPROTO; return -1; } + return ret; +} +int hid_wait(void *context, uint32_t microseconds) +{ + (void)context; + struct timespec remaining = {.tv_sec = (time_t)(microseconds / 1000000u), + .tv_nsec = (long)(microseconds % 1000000u) * 1000L}; + while (nanosleep(&remaining, &remaining) < 0) if (errno != EINTR) return -1; + return 0; +} diff --git a/src/hid_device.h b/src/hid_device.h new file mode 100644 index 0000000..453bfae --- /dev/null +++ b/src/hid_device.h @@ -0,0 +1,28 @@ +#ifndef LABFY_HID_DEVICE_H +#define LABFY_HID_DEVICE_H +#include +#include +#include +/* Discovery uses a caller-supplied sysfs root for deterministic fake-device tests. + * Paths are bounded; ambiguity, truncation and unreadable entries fail closed. + * Returns 0 or -1 with errno. Returned name is owned by the caller. */ +int hid_discover(const char *root, char *name, size_t capacity); +bool hid_identity(const char *uevent); +int hid_descriptor(const char *root, const char *name, uint8_t *out, size_t capacity, size_t *size); +/* Read-only compatibility entry point. Validates live RAWINFO and descriptor. + * Returned fd is owned; caller must close it. */ +int hid_open_verified(const char *name); +/* Read/write open adds a nonblocking exclusive flock, held until close. This + * serializes cooperating CLI instances, not other applications. No report is + * sent by open. Invalid live identities/descriptors are rejected before SET. */ +int hid_open_control(const char *name); +/* Same exclusive nonblocking admission used by hid_open_control; borrowed fd. */ +int hid_lock(int fd); +/* Callback context is a borrowed int fd. Only audited exact report IDs/sizes + * are accepted. SET validates selector/range/flags/mode, copies bounded bytes + * and requires an exact I2C-HID transfer count. Never retries a failed ioctl. */ +int hid_get(void *context, uint8_t *report, size_t size); +int hid_set(void *context, const uint8_t *report, size_t size); +/* Sleeps at least the requested interval, resuming nanosleep on EINTR. */ +int hid_wait(void *context, uint32_t microseconds); +#endif diff --git a/src/lamp_array.c b/src/lamp_array.c new file mode 100644 index 0000000..dbd0665 --- /dev/null +++ b/src/lamp_array.c @@ -0,0 +1,83 @@ +#include "lamp_array.h" +#include +#include +#include "profile.inc" +static int hex_digit(char c) +{ + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} +bool la_color(const char *text, uint8_t rgb[3]) +{ + if (!text || !rgb || strlen(text) != 7 || text[0] != '#') return false; + uint8_t tmp[3]; + for (size_t i = 0; i < 3; ++i) { + int hi = hex_digit(text[1 + 2*i]), lo = hex_digit(text[2 + 2*i]); + if (hi < 0 || lo < 0) return false; + tmp[i] = (uint8_t)(16*hi + lo); + } + memcpy(rgb, tmp, sizeof tmp); + return true; +} +bool la_percent(const char *text, unsigned *value) +{ + if (!text || !value || !*text || strlen(text) > 3) return false; + unsigned n = 0; + for (const char *p = text; *p; ++p) { + if (*p < '0' || *p > '9') return false; + n = n*10 + (unsigned)(*p - '0'); + } + if (n > 100) return false; + *value = n; + return true; +} +bool la_profile(const uint8_t *descriptor, size_t size) +{ + return descriptor && size == sizeof profile && memcmp(descriptor, profile, size) == 0; +} +static uint32_t le32(const uint8_t *p) +{ + return (uint32_t)p[0] | (uint32_t)p[1] << 8 | (uint32_t)p[2] << 16 | (uint32_t)p[3] << 24; +} +bool la_attributes_decode(const uint8_t *r, size_t size, struct la_attributes *out) +{ + if (!r || !out || size != LA_ATTRIBUTES_SIZE || r[0] != 0x41) return false; + struct la_attributes a = { + .count = (uint16_t)((unsigned)r[1] | (unsigned)r[2] << 8), + .width_um = le32(r+3), .height_um = le32(r+7), .depth_um = le32(r+11), + .kind = le32(r+15), .min_interval_us = le32(r+19) + }; + /* INVARIANT: descriptor logical maxima are signed 31-bit for u32 fields. + * Zero lamps cannot form a nonempty update range; reject before count-1. */ + if (!a.count || a.width_um > INT32_MAX || a.height_um > INT32_MAX || + a.depth_um > INT32_MAX || a.kind > INT32_MAX || a.min_interval_us > INT32_MAX) return false; + *out = a; + return true; +} +bool la_range(uint16_t count, const uint8_t rgbi[4], uint8_t *out, size_t capacity) +{ + if (!count || !rgbi || !out || capacity < LA_RANGE_SIZE) return false; + uint16_t end = (uint16_t)(count - 1); + const uint8_t r[LA_RANGE_SIZE] = {0x45, 1, 0, 0, (uint8_t)end, + (uint8_t)(end >> 8), rgbi[0], rgbi[1], rgbi[2], rgbi[3]}; + memcpy(out, r, sizeof r); + return true; +} +bool la_control(bool autonomous, uint8_t *out, size_t capacity) +{ + if (!out || capacity < LA_CONTROL_SIZE) return false; + out[0] = 0x46; + out[1] = autonomous ? 1 : 0; + return true; +} +bool la_read_attributes(la_get_fn get, void *context, struct la_attributes *out) +{ + if (!get || !out) { errno = EINVAL; return false; } + uint8_t report[LA_ATTRIBUTES_SIZE] = {0x41}; + int n = get(context, report, sizeof report); + if (n < 0) return false; + if (!la_attributes_decode(report, (size_t)n, out)) { errno = EPROTO; return false; } + return true; +} diff --git a/src/lamp_array.h b/src/lamp_array.h new file mode 100644 index 0000000..d340845 --- /dev/null +++ b/src/lamp_array.h @@ -0,0 +1,29 @@ +#ifndef LABFY_LAMP_ARRAY_H +#define LABFY_LAMP_ARRAY_H +#include +#include +#include +/* CONTRACT: fixed wire sizes include report ID; no native structs are serialized. */ +enum { LA_ATTRIBUTES_SIZE = 23, LA_RANGE_SIZE = 10, LA_CONTROL_SIZE = 2 }; +struct la_attributes { + uint16_t count; + uint32_t width_um, height_um, depth_um, kind, min_interval_us; +}; +/* All pointers are borrowed for the duration of the call. Invalid input returns + * false; output must not be used on failure. Color is exactly #RRGGBB. */ +bool la_color(const char *text, uint8_t rgb[3]); +bool la_percent(const char *text, unsigned *value); +/* Exact known descriptor match deliberately rejects firmware/profile variants. + * WHY: matching only lengths cannot validate field order, page or semantics. */ +bool la_profile(const uint8_t *descriptor, size_t size); +bool la_attributes_decode(const uint8_t *report, size_t size, struct la_attributes *out); +/* Offline encoders only. RGBI are device wire levels, NOT validated user levels. + * Caller must establish LevelCounts/programmability before submitting a report. + * Count is the logical lamp count, not a physical keyboard-zone assertion. */ +bool la_range(uint16_t count, const uint8_t rgbi[4], uint8_t *out, size_t capacity); +bool la_control(bool autonomous, uint8_t *out, size_t capacity); +/* Mockable read-only transport: returns bytes read or -1 with errno set. + * No request selector/SET is sent, including during attribute discovery. */ +typedef int (*la_get_fn)(void *context, uint8_t *report, size_t size); +bool la_read_attributes(la_get_fn get, void *context, struct la_attributes *out); +#endif diff --git a/src/main.c b/src/main.c new file mode 100644 index 0000000..88edd2b --- /dev/null +++ b/src/main.c @@ -0,0 +1,188 @@ +#include "hid_device.h" +#include "lamp_array.h" +#include "rgb_controller.h" +#include "rgb_state.h" +#include +#include +#include +#include +#include +#include +/* Linux devtmpfs identity is encoded in fixed-width cache fields, never narrowed. */ +_Static_assert(sizeof(((struct stat *)0)->st_ino) <= sizeof(uint64_t), "inode width unsupported"); +_Static_assert(sizeof(((struct stat *)0)->st_rdev) <= sizeof(uint64_t), "device width unsupported"); +enum { RC_USAGE = 2, RC_NODEVICE = 3, RC_PERMISSION = 4, RC_PROTOCOL = 5, RC_IO = 6, + RC_UNKNOWN = 7, RC_RECOVERY = 8, RC_STATE = 9, RC_BUSY = 10 }; +static int failure(const char *what) +{ + int e = errno; + fprintf(stderr, "%s: %s\n", what, strerror(e)); + if (e == EACCES || e == EPERM) return RC_PERMISSION; + if (e == ENODEV || e == ENOENT) return RC_NODEVICE; + if (e == EPROTO || e == EEXIST || e == ENOTSUP) return RC_PROTOCOL; + if (e == EWOULDBLOCK) return RC_BUSY; + return RC_IO; +} +static int find_device(char name[64], bool control) +{ + if (hid_discover("/sys/class/hidraw", name, 64) < 0) return -1; + uint8_t descriptor[HID_MAX_DESCRIPTOR_SIZE]; + size_t size; + if (hid_descriptor("/sys/class/hidraw", name, descriptor, sizeof descriptor, &size) < 0) return -1; + if (!la_profile(descriptor, size)) { errno = EPROTO; return -1; } + return control ? hid_open_control(name) : hid_open_verified(name); +} +static int status(bool json) +{ + char name[64]; + int fd = find_device(name, false); + if (fd < 0) return failure("read-only discovery/identity/descriptor"); + struct la_attributes a; + if (!la_read_attributes(hid_get, &fd, &a)) { + int saved = errno; (void)close(fd); errno = saved; return failure("GET_FEATURE 0x41"); + } + struct rgb_state state = {0}; + struct stat node; + int dir = rgb_state_open(); + bool cached = dir >= 0 && fstat(fd, &node) == 0 && + rgb_state_load(dir, (uint64_t)node.st_ino, (uint64_t)node.st_rdev, &state) == 0; + if (dir >= 0) (void)close(dir); + if (close(fd) < 0) return failure("close"); + const char *mode = cached && state.mode == 1 ? "software" : cached && state.mode == 2 ? "firmware" : "unknown"; + char color[16] = "null"; + if (cached && state.has_color) (void)snprintf(color, sizeof color, "\"#%02X%02X%02X\"", state.rgb[0], state.rgb[1], state.rgb[2]); + if (json) { + printf("{\"schema\":1,\"available\":true,\"device\":\"/dev/%s\",\"logical_lamps\":%u," + "\"min_interval_us\":%u,\"observed_mode\":\"unknown\",\"commanded_mode\":\"%s\"," + "\"last_color\":%s,\"last_percent\":", name, (unsigned)a.count, (unsigned)a.min_interval_us, mode, color); + if (cached && state.has_color) printf("%u", state.percent); else printf("null"); + puts("}"); + return 0; + } + printf("commanded_mode=%s last_color=%s (local history, not hardware readback)\n", mode, color); + printf("device=/dev/%s bus=I2C vid=0B05 pid=5570\ndescriptor=audited-exact-match bytes=748\n" + "feature_sizes=41:23,42:3,43:29,44:51,45:10,46:2\n" + "logical_lamps=%u\nwidth_um=%u\nheight_um=%u\ndepth_um=%u\nkind=%u\nmin_interval_us=%u\n", + name, (unsigned)a.count, (unsigned)a.width_um, (unsigned)a.height_um, (unsigned)a.depth_um, + (unsigned)a.kind, (unsigned)a.min_interval_us); + puts("RGBI_levels=unqueried programmable=unqueried\nstatus performs no SET; use probe only with selector-write approval"); + return 0; +} +static void print_lamp(const struct la_attributes *a, const struct rgb_lamp *l) +{ + printf("logical_lamps=%u lamp_id=%u min_interval_us=%u\nprogrammable=%u\n" + "red_max=%u green_max=%u blue_max=%u intensity_max=%u\nlatency_us=%u purposes=%u input_binding=%u\n", + (unsigned)a->count, (unsigned)l->id, (unsigned)a->min_interval_us, (unsigned)l->programmable, + (unsigned)l->levels[0], (unsigned)l->levels[1], (unsigned)l->levels[2], (unsigned)l->levels[3], + (unsigned)l->latency_us, (unsigned)l->purposes, (unsigned)l->input_binding); +} +static int modify(const char *command, const uint8_t color[3], unsigned percent) +{ + char name[64]; + int fd = find_device(name, true); + if (fd < 0) return failure("control discovery/identity/descriptor/lock"); + struct rgb_transport t = {.context = &fd, .get = hid_get, .set = hid_set, .wait = hid_wait}; + int rc = 0; + struct stat node; + bool node_known = fstat(fd, &node) == 0; + if (!strcmp(command, "probe")) { + struct la_attributes a; struct rgb_lamp l; + if (rgb_probe(&t, &a, &l) < 0) rc = failure("probe (SET 42, GET 43)"); + else print_lamp(&a, &l); + } else { + int dir = rgb_state_open(); + bool automatic = !strcmp(command, "auto"), off = !strcmp(command, "off"); + struct rgb_state s = {0}; + bool have_color = false; + if (!automatic) { + if (dir < 0) { (void)failure("state directory; no SET sent"); rc = RC_STATE; goto done; } + struct stat st; + if (fstat(fd, &st) < 0) { rc = failure("device cache identity"); goto state_done; } + s.inode = (uint64_t)st.st_ino; s.device = (uint64_t)st.st_rdev; + if (!strcmp(command, "set")) { memcpy(s.rgb, color, 3); have_color = true; } + else { + have_color = rgb_state_load(dir, s.inode, s.device, &s) == 0 && s.has_color; + if ((!have_color || !s.has_color) && !off) { + fprintf(stderr, "Last successful color unknown/unreadable; no SET sent. Run set first.\n"); + rc = RC_UNKNOWN; goto state_done; + } + } + s.percent = off ? 0 : percent; + s.mode = 1; s.has_color = have_color; + if (rgb_state_invalidate(dir) < 0) { + (void)failure("cache invalidation; no SET sent"); rc = RC_STATE; goto state_done; + } + } + /* WHY: firmware recovery must work even when the runtime cache is + * inaccessible. Invalidate if possible, but never gate SET 46 01 on it. */ + int cache_error = 0; + if (automatic && (dir < 0 || rgb_state_invalidate(dir) < 0)) cache_error = errno; + struct rgb_result r = automatic ? rgb_auto(&t) : rgb_apply(&t, s.rgb, s.percent, off); + if (!r.error && automatic && dir >= 0 && node_known) { + struct rgb_state firmware = {.inode = (uint64_t)node.st_ino, .device = (uint64_t)node.st_rdev, .mode = 2}; + if (rgb_state_save(dir, &firmware) < 0) cache_error = errno; + } + if (!r.error && !automatic && rgb_state_save(dir, &s) < 0) { + /* Successful hardware update without published history is uncertain + * for brightness: restore once and report the cache failure. */ + r.error = errno; r.recovery_attempted = true; r.recovery_error = rgb_auto(&t).error; + rc = RC_STATE; + } + if (r.error) { + errno = r.error; + int original_rc = failure("operation failed"); + if (!rc) rc = original_rc; + if (r.recovery_attempted) { + if (r.recovery_error) { + fprintf(stderr, "Single firmware restoration FAILED: %s; device state uncertain\n", strerror(r.recovery_error)); + rc = RC_RECOVERY; + } else fprintf(stderr, "Single firmware restoration acknowledged (46 01)\n"); + } + } else { + puts(automatic ? "Firmware autonomous control acknowledged; previous color not restored." : + "RGBI update acknowledged; host control retained (visual verification still required)."); + if (cache_error) { + fprintf(stderr, "Firmware restored, but cache invalidation failed: %s\n", strerror(cache_error)); + rc = RC_STATE; + } + } +state_done: + if (dir >= 0 && close(dir) < 0 && !rc) rc = RC_STATE; + } +done: + if (close(fd) < 0 && !rc) rc = failure("close device"); + return rc; +} +int main(int argc, char **argv) +{ + if (argc == 2 && !strcmp(argv[1], "--help")) { + puts("labfy-rgb status [--json] | probe | set '#RRGGBB' [0..100] | brightness 0..100 | off | auto\n" + "status is read-only. probe writes selector 42. All other commands write hardware.\n" + "--dry-run COMMAND [ARG] validates and prints the plan without opening any device."); + return 0; + } + bool dry = argc > 1 && !strcmp(argv[1], "--dry-run"); + if (dry) { --argc; ++argv; } + if ((argc == 2 || (argc == 3 && !strcmp(argv[2], "--json"))) && !strcmp(argv[1], "status")) { + if (dry) { puts("Read-only discovery and GET 41 (not executed)"); return 0; } + return status(argc == 3); + } + uint8_t rgb[3] = {0}; + unsigned percent = 100; + bool valid = ((argc == 3 || (argc == 4 && la_percent(argv[3], &percent))) && !strcmp(argv[1], "set") && la_color(argv[2], rgb)) || + (argc == 3 && !strcmp(argv[1], "brightness") && la_percent(argv[2], &percent)) || + (argc == 2 && (!strcmp(argv[1], "off") || !strcmp(argv[1], "auto") || !strcmp(argv[1], "probe"))); + if (!valid) { fprintf(stderr, "Invalid arguments; see --help\n"); return RC_USAGE; } + if (!strcmp(argv[1], "off")) percent = 0; + if (dry) { + if (!strcmp(argv[1], "auto")) puts("Proposed SET_FEATURE(2): 46 01; no GET/capability/cache prerequisite"); + else { + puts("Verify I2C/0B05/5570 + exact descriptor; GET 41; require count=1; SET 42 00 00; GET 43"); + if (!strcmp(argv[1], "off")) puts("SET 46 00; wait MinUpdateInterval; SET 45 01 00 00 00 00 00 00 00 00; retain host on success; one SET 46 01 on failure"); + else if (strcmp(argv[1], "probe")) puts("Validate RGB capabilities; SET 46 00; wait MinUpdateInterval; SET 45 01 00 00 00 00 R G B I; retain host on success; one SET 46 01 on failure"); + printf("command=%s percent=%u; RGBI wire values require live attributes; nothing executed\n", argv[1], percent); + } + return 0; + } + return modify(argv[1], rgb, percent); +} diff --git a/src/profile.inc b/src/profile.inc new file mode 100644 index 0000000..cf2c7aa --- /dev/null +++ b/src/profile.inc @@ -0,0 +1,50 @@ +/* Exact audited descriptor; see tests/data/README.md for provenance and SHA-256. */ +static const unsigned char profile[] = { + 0x05, 0x01, 0x09, 0x0c, 0xa1, 0x01, 0x85, 0x04, 0x15, 0x00, 0x25, 0x01, 0x09, 0xc6, 0x95, 0x01, + 0x75, 0x01, 0x81, 0x06, 0x75, 0x07, 0x81, 0x03, 0x09, 0xc7, 0x75, 0x01, 0x91, 0x02, 0x75, 0x07, + 0x91, 0x03, 0xc0, 0x06, 0x30, 0xff, 0x09, 0x82, 0xa1, 0x01, 0x85, 0x5f, 0x19, 0x00, 0x2a, 0xff, + 0x00, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x32, 0x81, 0x00, 0x19, 0x00, 0x2a, 0xff, + 0x00, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x32, 0xb1, 0x00, 0x19, 0x00, 0x2a, 0xff, + 0x00, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x32, 0x91, 0x02, 0xc0, 0x05, 0x59, 0x09, + 0x01, 0xa1, 0x01, 0x85, 0x41, 0x09, 0x02, 0xa1, 0x02, 0x09, 0x03, 0x15, 0x00, 0x27, 0xff, 0xff, + 0x00, 0x00, 0x75, 0x10, 0x95, 0x01, 0xb1, 0x03, 0x09, 0x04, 0x09, 0x05, 0x09, 0x06, 0x09, 0x07, + 0x09, 0x08, 0x15, 0x00, 0x27, 0xff, 0xff, 0xff, 0x7f, 0x75, 0x20, 0x95, 0x05, 0xb1, 0x03, 0xc0, + 0x85, 0x42, 0x09, 0x20, 0xa1, 0x02, 0x09, 0x21, 0x15, 0x00, 0x27, 0xff, 0xff, 0x00, 0x00, 0x75, + 0x10, 0x95, 0x01, 0xb1, 0x02, 0xc0, 0x85, 0x43, 0x09, 0x22, 0xa1, 0x02, 0x09, 0x21, 0x15, 0x00, + 0x27, 0xff, 0xff, 0x00, 0x00, 0x75, 0x10, 0x95, 0x01, 0xb1, 0x02, 0x09, 0x23, 0x09, 0x24, 0x09, + 0x25, 0x09, 0x27, 0x09, 0x26, 0x15, 0x00, 0x27, 0xff, 0xff, 0xff, 0x7f, 0x75, 0x20, 0x95, 0x05, + 0xb1, 0x02, 0x09, 0x28, 0x09, 0x29, 0x09, 0x2a, 0x09, 0x2b, 0x09, 0x2c, 0x09, 0x2d, 0x15, 0x00, + 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x06, 0xb1, 0x02, 0xc0, 0x85, 0x44, 0x09, 0x50, 0xa1, 0x02, + 0x09, 0x03, 0x09, 0x55, 0x15, 0x00, 0x25, 0x08, 0x75, 0x08, 0x95, 0x02, 0xb1, 0x02, 0x09, 0x21, + 0x15, 0x00, 0x27, 0xff, 0xff, 0x00, 0x00, 0x75, 0x10, 0x95, 0x08, 0xb1, 0x02, 0x09, 0x51, 0x09, + 0x52, 0x09, 0x53, 0x09, 0x54, 0x09, 0x51, 0x09, 0x52, 0x09, 0x53, 0x09, 0x54, 0x09, 0x51, 0x09, + 0x52, 0x09, 0x53, 0x09, 0x54, 0x09, 0x51, 0x09, 0x52, 0x09, 0x53, 0x09, 0x54, 0x09, 0x51, 0x09, + 0x52, 0x09, 0x53, 0x09, 0x54, 0x09, 0x51, 0x09, 0x52, 0x09, 0x53, 0x09, 0x54, 0x09, 0x51, 0x09, + 0x52, 0x09, 0x53, 0x09, 0x54, 0x09, 0x51, 0x09, 0x52, 0x09, 0x53, 0x09, 0x54, 0x15, 0x00, 0x26, + 0xff, 0x00, 0x75, 0x08, 0x95, 0x20, 0xb1, 0x02, 0xc0, 0x85, 0x45, 0x09, 0x60, 0xa1, 0x02, 0x09, + 0x55, 0x15, 0x00, 0x25, 0x08, 0x75, 0x08, 0x95, 0x01, 0xb1, 0x02, 0x09, 0x61, 0x09, 0x62, 0x15, + 0x00, 0x27, 0xff, 0xff, 0x00, 0x00, 0x75, 0x10, 0x95, 0x02, 0xb1, 0x02, 0x09, 0x51, 0x09, 0x52, + 0x09, 0x53, 0x09, 0x54, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x04, 0xb1, 0x02, 0xc0, + 0x85, 0x46, 0x09, 0x70, 0xa1, 0x02, 0x09, 0x71, 0x15, 0x00, 0x25, 0x01, 0x75, 0x08, 0x95, 0x01, + 0xb1, 0x02, 0xc0, 0xc0, 0x06, 0x59, 0xff, 0x09, 0x01, 0xa1, 0x01, 0x85, 0x31, 0x09, 0x02, 0xa1, + 0x02, 0x09, 0x03, 0x15, 0x00, 0x27, 0xff, 0xff, 0x00, 0x00, 0x75, 0x10, 0x95, 0x01, 0xb1, 0x03, + 0x09, 0x04, 0x09, 0x05, 0x09, 0x06, 0x09, 0x07, 0x09, 0x08, 0x15, 0x00, 0x27, 0xff, 0xff, 0xff, + 0x7f, 0x75, 0x20, 0x95, 0x05, 0xb1, 0x03, 0xc0, 0x85, 0x32, 0x09, 0x20, 0xa1, 0x02, 0x09, 0x21, + 0x15, 0x00, 0x27, 0xff, 0xff, 0x00, 0x00, 0x75, 0x10, 0x95, 0x01, 0xb1, 0x02, 0xc0, 0x85, 0x33, + 0x09, 0x22, 0xa1, 0x02, 0x09, 0x21, 0x15, 0x00, 0x27, 0xff, 0xff, 0x00, 0x00, 0x75, 0x10, 0x95, + 0x01, 0xb1, 0x02, 0x09, 0x23, 0x09, 0x24, 0x09, 0x25, 0x09, 0x27, 0x09, 0x26, 0x15, 0x00, 0x27, + 0xff, 0xff, 0xff, 0x7f, 0x75, 0x20, 0x95, 0x05, 0xb1, 0x02, 0x09, 0x28, 0x09, 0x29, 0x09, 0x2a, + 0x09, 0x2b, 0x09, 0x2c, 0x09, 0x2d, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x06, 0xb1, + 0x02, 0xc0, 0x85, 0x34, 0x09, 0x50, 0xa1, 0x02, 0x09, 0x03, 0x09, 0x55, 0x15, 0x00, 0x25, 0x08, + 0x75, 0x08, 0x95, 0x02, 0xb1, 0x02, 0x09, 0x21, 0x15, 0x00, 0x27, 0xff, 0xff, 0x00, 0x00, 0x75, + 0x10, 0x95, 0x08, 0xb1, 0x02, 0x09, 0x51, 0x09, 0x52, 0x09, 0x53, 0x09, 0x54, 0x09, 0x51, 0x09, + 0x52, 0x09, 0x53, 0x09, 0x54, 0x09, 0x51, 0x09, 0x52, 0x09, 0x53, 0x09, 0x54, 0x09, 0x51, 0x09, + 0x52, 0x09, 0x53, 0x09, 0x54, 0x09, 0x51, 0x09, 0x52, 0x09, 0x53, 0x09, 0x54, 0x09, 0x51, 0x09, + 0x52, 0x09, 0x53, 0x09, 0x54, 0x09, 0x51, 0x09, 0x52, 0x09, 0x53, 0x09, 0x54, 0x09, 0x51, 0x09, + 0x52, 0x09, 0x53, 0x09, 0x54, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x20, 0xb1, 0x02, + 0xc0, 0x85, 0x35, 0x09, 0x60, 0xa1, 0x02, 0x09, 0x55, 0x15, 0x00, 0x25, 0x08, 0x75, 0x08, 0x95, + 0x01, 0xb1, 0x02, 0x09, 0x61, 0x09, 0x62, 0x15, 0x00, 0x27, 0xff, 0xff, 0x00, 0x00, 0x75, 0x10, + 0x95, 0x02, 0xb1, 0x02, 0x09, 0x51, 0x09, 0x52, 0x09, 0x53, 0x09, 0x54, 0x15, 0x00, 0x26, 0xff, + 0x00, 0x75, 0x08, 0x95, 0x04, 0xb1, 0x02, 0xc0, 0x85, 0x36, 0x09, 0x70, 0xa1, 0x02, 0x09, 0x71, + 0x15, 0x00, 0x25, 0x01, 0x75, 0x08, 0x95, 0x01, 0xb1, 0x02, 0xc0, 0xc0, +}; diff --git a/src/rgb_controller.c b/src/rgb_controller.c new file mode 100644 index 0000000..1a631cc --- /dev/null +++ b/src/rgb_controller.c @@ -0,0 +1,94 @@ +#include "rgb_controller.h" +#include +#include +static uint32_t le32(const uint8_t *p) +{ + return (uint32_t)p[0] | (uint32_t)p[1]<<8 | (uint32_t)p[2]<<16 | (uint32_t)p[3]<<24; +} +bool rgb_lamp_decode(const uint8_t *r, size_t n, struct rgb_lamp *out) +{ + if (!r || !out || n != 29 || r[0] != 0x43) return false; + struct rgb_lamp l = {.id = (uint16_t)((unsigned)r[1] | (unsigned)r[2]<<8), + .position = {le32(r+3), le32(r+7), le32(r+11)}, + .latency_us = le32(r+15), .purposes = le32(r+19), + .levels = {r[23], r[24], r[25], r[26]}, .programmable = r[27], .input_binding = r[28]}; + for (size_t i = 0; i < 3; ++i) if (l.position[i] > INT32_MAX) return false; + /* The descriptor allows 0..255 for the byte group, but the standard gives + * IsProgrammable boolean semantics. Reject reserved values rather than guess. */ + if (l.latency_us > INT32_MAX || l.purposes > INT32_MAX || l.programmable > 1) return false; + *out = l; + return true; +} +uint8_t rgb_scale(uint8_t value, uint8_t maximum) +{ + return (uint8_t)(((uint32_t)value * maximum + 127u) / 255u); +} +bool rgb_convert(const struct rgb_lamp *l, const uint8_t rgb[3], unsigned percent, uint8_t out[4]) +{ + if (!l || !rgb || !out || percent > 100 || l->id != 0 || l->programmable != 1) return false; + /* WHY: V2 promises full RGB; refuse missing channels or unusable intensity + * instead of silently dropping a requested component or inventing a level. */ + for (size_t i = 0; i < 4; ++i) if (!l->levels[i]) return false; + uint8_t r[4]; + for (size_t i = 0; i < 3; ++i) r[i] = rgb_scale(rgb[i], l->levels[i]); + r[3] = (uint8_t)((percent * l->levels[3] + 50u) / 100u); + memcpy(out, r, sizeof r); + return true; +} +static bool transport_valid(const struct rgb_transport *t) +{ + return t && t->get && t->set && t->wait; +} +static int set_exact(const struct rgb_transport *t, const uint8_t *r, size_t n) +{ + int ret = t->set(t->context, r, n); + if (ret < 0) return -1; + if ((size_t)ret != n) { errno = EPROTO; return -1; } + return 0; +} +int rgb_probe(const struct rgb_transport *t, struct la_attributes *a, struct rgb_lamp *l) +{ + if (!transport_valid(t) || !a || !l) { errno = EINVAL; return -1; } + if (!la_read_attributes(t->get, t->context, a)) return -1; + if (a->count != 1) { errno = ENOTSUP; return -1; } + const uint8_t request[3] = {0x42, 0, 0}; + if (set_exact(t, request, sizeof request) < 0) return -1; + uint8_t response[29] = {0x43}; + int ret = t->get(t->context, response, sizeof response); + if (ret < 0) return -1; + if (!rgb_lamp_decode(response, (size_t)ret, l) || l->id != 0 || + l->position[0] > a->width_um || l->position[1] > a->height_um || l->position[2] > a->depth_um) { + errno = EPROTO; return -1; + } + return 0; +} +struct rgb_result rgb_auto(const struct rgb_transport *t) +{ + if (!t || !t->set) return (struct rgb_result){.error = EINVAL}; + uint8_t r[LA_CONTROL_SIZE]; + (void)la_control(true, r, sizeof r); + return (struct rgb_result){.error = set_exact(t, r, sizeof r) < 0 ? errno : 0}; +} +struct rgb_result rgb_apply(const struct rgb_transport *t, const uint8_t rgb[3], unsigned percent, bool off) +{ + struct rgb_result result = {0}; + if (!transport_valid(t) || !rgb || percent > 100) return (struct rgb_result){.error = EINVAL}; + struct la_attributes a; + struct rgb_lamp l; + if (rgb_probe(t, &a, &l) < 0) return (struct rgb_result){.error = errno}; + uint8_t rgbi[4] = {0}; + if (!off && !rgb_convert(&l, rgb, percent, rgbi)) return (struct rgb_result){.error = ENOTSUP}; + uint8_t control[LA_CONTROL_SIZE], range[LA_RANGE_SIZE]; + (void)la_control(false, control, sizeof control); + (void)la_range(1, rgbi, range, sizeof range); + /* CONTRACT: recovery covers even a failed host SET: the kernel/device may + * have accepted it before reporting failure. SET is never blindly retried. */ + if (set_exact(t, control, sizeof control) < 0 || + t->wait(t->context, a.min_interval_us) < 0 || + set_exact(t, range, sizeof range) < 0) { + result.error = errno; + result.recovery_attempted = true; + result.recovery_error = rgb_auto(t).error; + } + return result; +} diff --git a/src/rgb_controller.h b/src/rgb_controller.h new file mode 100644 index 0000000..e871ed9 --- /dev/null +++ b/src/rgb_controller.h @@ -0,0 +1,34 @@ +#ifndef LABFY_RGB_CONTROLLER_H +#define LABFY_RGB_CONTROLLER_H +#include "lamp_array.h" +/* All callback/context pointers are borrowed synchronously. GET/SET return the + * exact byte count, or -1/errno; wait returns 0 or -1/errno. No SET retries. + * The owner MUST verify live identity/descriptor and hold the device lock before + * calling. Only one logical lamp is supported by this V2 controller. */ +typedef int (*rgb_set_fn)(void *, const uint8_t *, size_t); +typedef int (*rgb_wait_fn)(void *, uint32_t); +struct rgb_transport { void *context; la_get_fn get; rgb_set_fn set; rgb_wait_fn wait; }; +struct rgb_lamp { + uint16_t id; + uint32_t position[3], latency_us, purposes; + uint8_t levels[4], programmable, input_binding; +}; +/* The result distinguishes original failure from the single attempted recovery. + * A successful ioctl is a transport acknowledgement, not visual verification. */ +struct rgb_result { int error, recovery_error; bool recovery_attempted; }; +bool rgb_lamp_decode(const uint8_t *, size_t, struct rgb_lamp *); +/* Round-to-nearest, ties upward. LevelCount is max nonzero wire value (not N-1). + * Products are bounded by 255*255 or 100*255. */ +uint8_t rgb_scale(uint8_t value, uint8_t maximum); +bool rgb_convert(const struct rgb_lamp *, const uint8_t rgb[3], unsigned percent, uint8_t out[4]); +/* GET 41, SET 42 00 00, GET 43. probe never changes AutonomousMode/color. + * It DOES write the attribute selector and requires hardware authorization. */ +int rgb_probe(const struct rgb_transport *, struct la_attributes *, struct rgb_lamp *); +/* Both apply/off query live attributes before any host-control SET. apply keeps + * host control on success. off sends RGBI all zero, including fixed-color lamps. + * Any failure from the first host SET onward triggers exactly one SET 46 01, + * including ambiguous failed/short host SETs. No retries or destructor writes. */ +struct rgb_result rgb_apply(const struct rgb_transport *, const uint8_t rgb[3], unsigned percent, bool off); +/* Recovery must remain available without GET 41/43, RGB support, or cache. */ +struct rgb_result rgb_auto(const struct rgb_transport *); +#endif diff --git a/src/rgb_state.c b/src/rgb_state.c new file mode 100644 index 0000000..70596e6 --- /dev/null +++ b/src/rgb_state.c @@ -0,0 +1,116 @@ +#define _POSIX_C_SOURCE 200809L +#include "rgb_state.h" +#include +#include +#include +#include +#include +#include +#include +static const char filename[] = "state.bin"; +static void put64(uint8_t *p, uint64_t v) +{ + for (unsigned i = 0; i < 8; ++i) p[i] = (uint8_t)(v >> (i*8)); +} +static uint64_t get64(const uint8_t *p) +{ + uint64_t v = 0; + for (unsigned i = 0; i < 8; ++i) v |= (uint64_t)p[i] << (i*8); + return v; +} +int rgb_state_open_at(const char *directory) +{ + if (!directory) { errno = EINVAL; return -1; } + int fd = open(directory, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW); + if (fd < 0) return -1; + struct stat st; + int saved = 0; + if (fstat(fd, &st) < 0) saved = errno; + else if (st.st_uid != geteuid() || (st.st_mode & 0022)) saved = EPERM; + if (saved) { (void)close(fd); errno = saved; return -1; } + return fd; +} +int rgb_state_open(void) +{ + const char *runtime = getenv("XDG_RUNTIME_DIR"); + char fallback[128]; + if (!runtime || !*runtime) { + if (geteuid() == 0) runtime = "/run"; + else { + int n = snprintf(fallback, sizeof fallback, "/run/user/%lu", (unsigned long)geteuid()); + if (n < 0 || (size_t)n >= sizeof fallback) { errno = ENAMETOOLONG; return -1; } + runtime = fallback; + } + } + if (runtime[0] != '/') { errno = EINVAL; return -1; } + int parent = rgb_state_open_at(runtime); + if (parent < 0) return -1; + const char *child = geteuid() == 0 ? "labfy-rgb-root" : "labfy-rgb"; + int saved = 0; + if (mkdirat(parent, child, 0700) < 0 && errno != EEXIST) saved = errno; + int fd = saved ? -1 : openat(parent, child, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW); + if (fd < 0 && !saved) saved = errno; + struct stat st; + if (!saved && fstat(fd, &st) < 0) saved = errno; + if (!saved && (st.st_uid != geteuid() || (st.st_mode & 0777) != 0700)) saved = EPERM; + if (close(parent) < 0 && !saved) saved = errno; + if (saved) { if (fd >= 0) (void)close(fd); errno = saved; return -1; } + return fd; +} +int rgb_state_load(int dir, uint64_t inode, uint64_t device, struct rgb_state *out) +{ + if (!out) { errno = EINVAL; return -1; } + int fd = openat(dir, filename, O_RDONLY | O_NONBLOCK | O_CLOEXEC | O_NOFOLLOW); + if (fd < 0) { if (errno == ENOENT) errno = ENODATA; return -1; } + struct stat st; + int saved = 0; + if (fstat(fd, &st) < 0) saved = errno; + else if (!S_ISREG(st.st_mode) || st.st_uid != geteuid() || st.st_nlink != 1 || + (st.st_mode & 0777) != 0600 || st.st_size != 32) saved = EPROTO; + uint8_t r[32]; + size_t used = 0; + while (!saved && used < sizeof r) { + ssize_t n = read(fd, r+used, sizeof r-used); + if (n < 0) { if (errno == EINTR) continue; saved = errno; break; } + if (!n) { saved = EPROTO; break; } + used += (size_t)n; + } + if (close(fd) < 0 && !saved) saved = errno; + if (!saved && (memcmp(r, "LFRGB003", 8) || r[27] > 100 || r[28] > 2 || r[29] > 1 || r[30] || r[31] || (r[29] && r[28] != 1))) saved = EPROTO; + if (!saved && (get64(r+8) != inode || get64(r+16) != device)) saved = ENODATA; + if (saved) { errno = saved; return -1; } + *out = (struct rgb_state){.inode = inode, .device = device, .rgb = {r[24], r[25], r[26]}, .percent = r[27], .mode = r[28], .has_color = r[29] != 0}; + return 0; +} +int rgb_state_invalidate(int dir) +{ + if (unlinkat(dir, filename, 0) < 0 && errno != ENOENT) return -1; + return 0; +} +int rgb_state_save(int dir, const struct rgb_state *s) +{ + if (!s || s->percent > 100 || s->mode > 2 || (s->has_color && s->mode != 1)) { errno = EINVAL; return -1; } + uint8_t r[32] = {0}; + memcpy(r, "LFRGB003", 8); + put64(r+8, s->inode); put64(r+16, s->device); + memcpy(r+24, s->rgb, 3); r[27] = (uint8_t)s->percent; r[28] = (uint8_t)s->mode; r[29] = s->has_color ? 1 : 0; + /* One bounded staging pathname per UID. The device lock serializes callers; + * remove an interrupted save before O_EXCL creation, never follow symlinks. */ + const char temp[] = ".state.pending"; + if (unlinkat(dir, temp, 0) < 0 && errno != ENOENT) return -1; + int fd = openat(dir, temp, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0600); + if (fd < 0) return -1; + int saved = 0; + if (fchmod(fd, 0600) < 0) saved = errno; + size_t used = 0; + while (!saved && used < sizeof r) { + ssize_t count = write(fd, r+used, sizeof r-used); + if (count < 0) { if (errno == EINTR) continue; saved = errno; break; } + if (!count) { saved = EIO; break; } + used += (size_t)count; + } + if (close(fd) < 0 && !saved) saved = errno; + if (!saved && renameat(dir, temp, dir, filename) < 0) saved = errno; + if (saved) { (void)unlinkat(dir, temp, 0); errno = saved; return -1; } + return 0; +} diff --git a/src/rgb_state.h b/src/rgb_state.h new file mode 100644 index 0000000..6d2995c --- /dev/null +++ b/src/rgb_state.h @@ -0,0 +1,24 @@ +#ifndef LABFY_RGB_STATE_H +#define LABFY_RGB_STATE_H +#include +#include +struct rgb_state { uint64_t inode, device; uint8_t rgb[3]; unsigned percent; unsigned mode; bool has_color; }; +/* Volatile last-successful-command cache, never a device readback or capability + * authority. mode: 0 unknown, 1 software, 2 firmware. has_color gates RGB/percent. + * Namespace is effective UID, keyed by devtmpfs inode + rdev. /run + * expires on reboot; a replaced node invalidates the key. Open functions return + * an owned fd; load/save/invalidate borrow the directory fd until return. + * XDG_RUNTIME_DIR must be absolute, owned by effective UID and not writable by + * group/others. The labfy-rgb child must be exactly 0700, opened without + * following symlinks. Environment values never bypass these validations. open_at is public only to permit isolated temporary tests. */ +int rgb_state_open(void); +int rgb_state_open_at(const char *directory); +/* load: 0 or -1/errno; unknown/mismatched node is ENODATA. No output on error. + * save: atomic rename of a 0600, fixed 32-byte LE record; no native structs. + * invalidate BEFORE device modification so crash/error cannot retain stale data. + * No reboot durability is promised; a successfully saved cache is only local + * command history and may be overridden by firmware/other programs. */ +int rgb_state_load(int directory_fd, uint64_t inode, uint64_t device, struct rgb_state *out); +int rgb_state_save(int directory_fd, const struct rgb_state *state); +int rgb_state_invalidate(int directory_fd); +#endif diff --git a/tests/cli.sh b/tests/cli.sh new file mode 100755 index 0000000..385a44a --- /dev/null +++ b/tests/cli.sh @@ -0,0 +1,37 @@ +#!/bin/sh +# Only invalid input/help/dry-run reach the real CLI: never open hardware. +set -eu +cli=$1 +check() { + expected=$1 + shift + actual=0 + "$cli" "$@" >/dev/null 2>&1 || actual=$? + if [ "$actual" -ne "$expected" ]; then + echo "Expected $expected got $actual for $*" >&2 + exit 1 + fi +} +check 0 --help +check 2 +check 2 unknown +check 2 set '#GG0000' +check 2 set '#fff' +check 2 set +check 2 set '#000000' extra +check 2 brightness -1 +check 2 brightness 101 +check 2 brightness 99999999999999999999999999 +check 2 auto extra +check 0 --dry-run set '#B4BEFE' +check 0 --dry-run set '#FF0000' +check 0 --dry-run set '#00FF00' +check 0 --dry-run set '#0000FF' +check 0 --dry-run brightness 60 +check 0 --dry-run brightness 0 +check 0 --dry-run brightness 100 +check 0 --dry-run off +check 0 --dry-run auto +check 0 --dry-run probe +check 0 --dry-run status +check 2 --dry-run set bad diff --git a/tests/data/README.md b/tests/data/README.md new file mode 100644 index 0000000..a8837c2 --- /dev/null +++ b/tests/data/README.md @@ -0,0 +1,20 @@ +# HID report descriptor fixture + +`ite5570-5570.desc` is a 748-byte HID report descriptor captured from the tested +ASUS Vivobook S16 M5606UA, I2C HID `0018:00000B05:00005570` (ITE5570). +It describes report layouts and usages; it contains no user input, serial number, +network information, account name, hostname or workstation path. + +SHA-256: + +```text +fa58cf01fa1552f63301e840f8f828f25af065babb95268be095520acdb18bb0 +``` + +The production profile in `src/profile.inc` contains the same bytes as a C array. +Tests load this separate fixture to check exact matching and descriptor mutations. +They never read a real keyboard descriptor or open a real HID device. + +This is protocol metadata from the tested device, not a captured Feature response, +firmware image, executable, private audit log or screenshot. Unknown descriptor +variants are intentionally rejected; see `docs/protocol.md`. diff --git a/tests/data/ite5570-5570.desc b/tests/data/ite5570-5570.desc new file mode 100644 index 0000000000000000000000000000000000000000..7907e954b1fd3d4bac2c8d2cdc3fada58e6e8a0b GIT binary patch literal 748 zcmchUJ5Iwu5Qb-W?X`c2g2n}%fjl691mV&12sO_l6^|1@u{AQ158%QF$PxAiTZLmS zvk?{vNkfFFRx_G^{(ruW1y)9)j52BqjB8;;p-oaKd>BzllT^y-eUEpZd03;Kr;IwD zOj`E~nyu}Rx{@v)La^`@awMTL>UvLPR%>AuBE-;0kWSkE&E$1#l`GGX^YHrguyo}) zHs-q6LuefrhI|4Gd?Mo*SNtg?v{NUyFAzp z@W^(Jnc(eDc>X>sxPRF-vM9(d@xrp#@E@F!WzOlLpKv_*4afdMj)#B1@#ud!9xuo7 HWS-+EOBB2k literal 0 HcmV?d00001 diff --git a/tests/test_cli_integration.c b/tests/test_cli_integration.c new file mode 100644 index 0000000..ab72680 --- /dev/null +++ b/tests/test_cli_integration.c @@ -0,0 +1,130 @@ +#define _POSIX_C_SOURCE 200809L +#include "hid_device.h" +#include "rgb_state.h" +#include +#include +#include +#include +#include +#include +#include "../src/profile.inc" +int rgb_cli_main(int, char **); +int __real_rgb_state_save(int, const struct rgb_state *); +static char runtime[] = "/tmp/labfy-rgb-cli-XXXXXX"; +static FILE *node; +static unsigned operations, writes, restores, host_sets, range_sets; +static unsigned fail_operation, fail_recovery; +static int open_error, cache_error, save_error, discovery_error, descriptor_error; +static unsigned logical_count = 1, programmable = 1; +static uint8_t last_range[10]; +int __wrap_hid_discover(const char *root, char *name, size_t capacity) +{ + assert(!strcmp(root, "/sys/class/hidraw") && capacity >= 10); + if (discovery_error) { errno = discovery_error; return -1; } + memcpy(name, "hidraw77", 9); return 0; +} +int __wrap_hid_descriptor(const char *root, const char *name, uint8_t *r, size_t cap, size_t *n) +{ + (void)root; assert(!strcmp(name, "hidraw77") && cap >= sizeof profile); + memcpy(r, profile, sizeof profile); *n = sizeof profile; + if (descriptor_error) r[0] ^= 1; + return 0; +} +int __wrap_hid_open_control(const char *name) +{ + assert(!strcmp(name, "hidraw77")); + if (open_error) { errno = open_error; return -1; } + return dup(fileno(node)); +} +int __wrap_hid_open_verified(const char *name) { return __wrap_hid_open_control(name); } +static int operation(void) +{ + ++operations; + if (operations == fail_operation || operations == fail_recovery) { errno = EIO; return -1; } + return 0; +} +int __wrap_hid_get(void *context, uint8_t *r, size_t n) +{ + assert(context && *(int *)context >= 0); + if (operation() < 0) return -1; + if (r[0] == 0x41) { + assert(n == 23); memset(r, 0, n); r[0] = 0x41; r[1] = (uint8_t)logical_count; + r[19] = 0xb8; r[20] = 0x0b; + } else { + assert(n == 29 && r[0] == 0x43); memset(r, 0, n); r[0] = 0x43; + r[23] = 255; r[24] = 255; r[25] = 255; r[26] = 100; r[27] = (uint8_t)programmable; + } + return (int)n; +} +int __wrap_hid_set(void *context, const uint8_t *r, size_t n) +{ + assert(context && *(int *)context >= 0); ++writes; + if (r[0] == 0x46 && r[1] == 1) ++restores; + else if (r[0] == 0x46) ++host_sets; + else if (r[0] == 0x45) { assert(n == 10); memcpy(last_range, r, n); ++range_sets; } + else assert(n == 3 && r[0] == 0x42 && !r[1] && !r[2]); + if (operation() < 0) return -1; + return (int)n; +} +int __wrap_hid_wait(void *context, uint32_t us) { assert(context && us == 3000); return operation(); } +int __wrap_rgb_state_open(void) +{ + if (cache_error) { errno = cache_error; return -1; } + return rgb_state_open_at(runtime); +} +int __wrap_rgb_state_save(int dir, const struct rgb_state *s) +{ + if (save_error) { errno = save_error; return -1; } + return __real_rgb_state_save(dir, s); +} +static void reset(void) +{ + operations = writes = restores = host_sets = range_sets = 0; + fail_operation = fail_recovery = 0; + open_error = cache_error = save_error = discovery_error = descriptor_error = 0; + logical_count = programmable = 1; +} +static int invoke(const char *command, const char *value) +{ + char *args[] = {"labfy-rgb", (char *)command, (char *)value, NULL}; + return rgb_cli_main(value ? 3 : 2, args); +} +int main(void) +{ + assert(mkdtemp(runtime)); node = tmpfile(); assert(node); + reset(); assert(invoke("brightness", "60") == 7 && !writes); + reset(); assert(invoke("status", NULL) == 0 && !writes && operations == 1); + reset(); assert(invoke("status", "--json") == 0 && !writes && operations == 1); + reset(); assert(invoke("probe", NULL) == 0 && writes == 1 && !host_sets && !restores); + reset(); assert(invoke("set", "#B4BEFE") == 0 && writes == 3 && !restores); + assert(last_range[6] == 180 && last_range[7] == 190 && last_range[8] == 254 && last_range[9] == 100); + reset(); assert(invoke("brightness", "60") == 0 && !restores && last_range[6] == 180 && last_range[9] == 60); + reset(); assert(invoke("off", NULL) == 0 && !restores); + for (size_t i = 6; i < 10; ++i) assert(last_range[i] == 0); + reset(); assert(invoke("brightness", "60") == 0 && last_range[6] == 180 && last_range[9] == 60); + reset(); assert(invoke("auto", NULL) == 0 && writes == 1 && restores == 1 && operations == 1); + reset(); assert(invoke("brightness", "60") == 7 && !writes); + reset(); fail_operation = 6; + assert(invoke("set", "#FF0000") == 6 && restores == 1); + reset(); assert(invoke("brightness", "60") == 7 && !writes); + reset(); fail_operation = 6; fail_recovery = 7; + assert(invoke("set", "#FF0000") == 8 && restores == 1); + reset(); save_error = EIO; + assert(invoke("set", "#FF0000") == 9 && restores == 1); + reset(); cache_error = EACCES; + assert(invoke("auto", NULL) == 9 && restores == 1 && operations == 1); + reset(); cache_error = EACCES; + assert(invoke("set", "#FF0000") == 9 && !writes); + reset(); open_error = EACCES; assert(invoke("auto", NULL) == 4 && !writes); + reset(); open_error = EWOULDBLOCK; assert(invoke("auto", NULL) == 10 && !writes); + reset(); discovery_error = ENODEV; assert(invoke("set", "#FF0000") == 3 && !writes); + reset(); descriptor_error = 1; assert(invoke("auto", NULL) == 5 && !writes); + reset(); logical_count = 2; assert(invoke("set", "#FF0000") == 5 && !writes); + reset(); programmable = 0; assert(invoke("set", "#FF0000") == 5 && writes == 1 && !host_sets); + reset(); fail_operation = 1; assert(invoke("auto", NULL) == 6 && restores == 1 && writes == 1); + int dir = rgb_state_open_at(runtime); assert(dir >= 0); + assert(rgb_state_invalidate(dir) == 0 && close(dir) == 0); + assert(fclose(node) == 0 && rmdir(runtime) == 0); + puts("Real CLI command and exit-code integration passed; all HID endpoints replaced with memory doubles."); + return 0; +} diff --git a/tests/test_concurrency.c b/tests/test_concurrency.c new file mode 100644 index 0000000..9660fcf --- /dev/null +++ b/tests/test_concurrency.c @@ -0,0 +1,33 @@ +#define _POSIX_C_SOURCE 200809L +#include "hid_device.h" +#include +#include +#include +#include +#include +#include +#include +int main(void) +{ + /* Real process/lock semantics exercised only on a temporary regular file, + * using the production admission function; no HID descriptor is opened. */ + char path[] = "/tmp/labfy-lock-XXXXXX"; + int fd = mkstemp(path); assert(fd >= 0 && hid_lock(fd) == 0); + int notify[2], resume[2]; assert(pipe(notify) == 0 && pipe(resume) == 0); + pid_t child = fork(); assert(child >= 0); + if (!child) { + assert(close(fd) == 0); + int other = open(path, O_RDWR); assert(other >= 0); + assert(hid_lock(other) == -1 && errno == EWOULDBLOCK); + assert(write(notify[1], "x", 1) == 1); + char byte; assert(read(resume[0], &byte, 1) == 1); + assert(hid_lock(other) == 0 && close(other) == 0); + _exit(0); + } + char byte; assert(read(notify[0], &byte, 1) == 1); + assert(close(fd) == 0 && write(resume[1], "x", 1) == 1); + int result; assert(waitpid(child, &result, 0) == child && WIFEXITED(result) && WEXITSTATUS(result) == 0); + assert(close(notify[0]) == 0 && close(notify[1]) == 0 && close(resume[0]) == 0 && close(resume[1]) == 0); + assert(unlink(path) == 0); + return 0; +} diff --git a/tests/test_controller.c b/tests/test_controller.c new file mode 100644 index 0000000..6095bdf --- /dev/null +++ b/tests/test_controller.c @@ -0,0 +1,154 @@ +#include "rgb_controller.h" +#include +#include +#include +#include +struct mock { + unsigned step, fail_step, recovery_fail_step; + int short_step; + uint16_t count; + uint8_t lamp[29]; + uint8_t range[10]; + unsigned recovery_calls, host_calls, range_calls; +}; +static struct mock fresh(void) +{ + struct mock m = {.short_step = -1, .count = 1, .lamp = {0x43}}; + m.lamp[23] = 255; m.lamp[24] = 255; m.lamp[25] = 255; m.lamp[26] = 100; m.lamp[27] = 1; + return m; +} +static int fault(struct mock *m) +{ + ++m->step; + if (m->step == m->fail_step || m->step == m->recovery_fail_step) { errno = EIO; return -1; } + return 0; +} +static int get(void *context, uint8_t *r, size_t n) +{ + struct mock *m = context; + for (size_t i = 1; i < n; ++i) assert(!r[i]); + if (fault(m) < 0) return -1; + if (n == 23) { + assert(r[0] == 0x41 && m->step == 1); + memset(r, 0, n); r[0] = 0x41; r[1] = (uint8_t)m->count; r[2] = (uint8_t)(m->count >> 8); + r[19] = 0xb8; r[20] = 0x0b; /* 3000 us, deliberate LE fixture. */ + } else { + assert(n == 29 && r[0] == 0x43 && m->step == 3); + memcpy(r, m->lamp, n); + } + return (int)m->step == m->short_step ? (int)n-1 : (int)n; +} +static int set(void *context, const uint8_t *r, size_t n) +{ + struct mock *m = context; + if (n == 2 && r[0] == 0x46 && r[1] == 1) ++m->recovery_calls; + else if (n == 2) { assert(r[0] == 0x46 && r[1] == 0 && m->step == 3); ++m->host_calls; } + else if (n == 3) { assert(r[0] == 0x42 && !r[1] && !r[2] && m->step == 1); } + else { assert(n == 10 && m->step == 5); memcpy(m->range, r, n); ++m->range_calls; } + if (fault(m) < 0) return -1; + return (int)m->step == m->short_step ? (int)n-1 : (int)n; +} +static int wait_for(void *context, uint32_t us) +{ + struct mock *m = context; + assert(m->step == 4 && us == 3000); + return fault(m); +} +static struct rgb_transport transport(struct mock *m) +{ + return (struct rgb_transport){.context = m, .get = get, .set = set, .wait = wait_for}; +} +int main(void) +{ + const uint8_t lavender[3] = {180, 190, 254}; + for (unsigned max = 0; max <= 255; ++max) { + assert(rgb_scale(0, (uint8_t)max) == 0); + assert(rgb_scale(255, (uint8_t)max) == max); + unsigned previous = 0; + for (unsigned value = 0; value <= 255; ++value) { + unsigned wire = rgb_scale((uint8_t)value, (uint8_t)max); + assert(wire <= max && wire >= previous); + /* Bound the rounding error independently, rather than reimplementing conversion. */ + int delta = (int)(wire*255) - (int)(value*max); + assert(delta >= -127 && delta <= 127); + previous = wire; + } + } + struct mock m = fresh(); struct rgb_transport t = transport(&m); + struct rgb_lamp l; + assert(rgb_lamp_decode(m.lamp, 29, &l)); + uint8_t v[4]; + assert(rgb_convert(&l, lavender, 60, v) && v[0] == 180 && v[1] == 190 && v[2] == 254 && v[3] == 60); + l.levels[0] = 10; l.levels[1] = 20; l.levels[2] = 30; l.levels[3] = 3; + assert(rgb_convert(&l, lavender, 60, v) && v[0] == 7 && v[1] == 15 && v[2] == 30 && v[3] == 2); + for (unsigned max = 1; max <= 255; ++max) { + l.levels[3] = (uint8_t)max; + assert(rgb_convert(&l, lavender, 0, v) && v[3] == 0); + assert(rgb_convert(&l, lavender, 100, v) && v[3] == max); + unsigned previous = 0; + for (unsigned p = 0; p <= 100; ++p) { + assert(rgb_convert(&l, lavender, p, v) && v[3] <= max && v[3] >= previous); + int delta = (int)v[3]*100 - (int)(p*max); + assert(delta >= -50 && delta <= 50); + previous = v[3]; + } + } + assert(!rgb_convert(&l, lavender, 101, v)); + l.programmable = 0; assert(!rgb_convert(&l, lavender, 60, v)); l.programmable = 1; + for (unsigned i = 0; i < 4; ++i) { + uint8_t saved = l.levels[i]; l.levels[i] = 0; + assert(!rgb_convert(&l, lavender, 100, v)); l.levels[i] = saved; + } + assert(!rgb_lamp_decode(m.lamp, 28, &l) && !rgb_lamp_decode(m.lamp, 30, &l)); + m.lamp[0] = 0x33; assert(!rgb_lamp_decode(m.lamp, 29, &l)); m = fresh(); + m.lamp[27] = 2; assert(!rgb_lamp_decode(m.lamp, 29, &l)); m = fresh(); + m.lamp[18] = 0x80; assert(!rgb_lamp_decode(m.lamp, 29, &l)); m = fresh(); + struct rgb_result r = rgb_apply(&t, lavender, 100, false); + const uint8_t expected[10] = {0x45, 1, 0, 0, 0, 0, 180, 190, 254, 100}; + assert(!r.error && m.step == 6 && m.host_calls == 1 && !m.recovery_calls && !memcmp(m.range, expected, 10)); + /* Failure injection covers every operation in order. Before host control, + * no recovery write; after ANY attempted host SET, exactly one restoration. */ + for (unsigned fail = 1; fail <= 6; ++fail) { + m = fresh(); m.fail_step = fail; + r = rgb_apply(&t, lavender, 100, false); + assert(r.error == EIO && r.recovery_attempted == (fail >= 4)); + assert(m.recovery_calls == (fail >= 4 ? 1u : 0u) && !r.recovery_error); + assert(m.step == fail + (fail >= 4 ? 1u : 0u)); + } + for (int short_step = 1; short_step <= 6; ++short_step) { + if (short_step == 5) continue; + m = fresh(); m.short_step = short_step; + r = rgb_apply(&t, lavender, 100, false); + assert(r.error == EPROTO && m.recovery_calls == (short_step >= 4 ? 1u : 0u)); + } + m = fresh(); m.fail_step = 6; m.recovery_fail_step = 7; + r = rgb_apply(&t, lavender, 100, false); + assert(r.error == EIO && r.recovery_error == EIO && m.recovery_calls == 1 && m.step == 7); + m = fresh(); m.fail_step = 6; m.short_step = 7; + r = rgb_apply(&t, lavender, 100, false); + assert(r.error == EIO && r.recovery_error == EPROTO && m.recovery_calls == 1); + m = fresh(); m.lamp[1] = 1; r = rgb_apply(&t, lavender, 100, false); + assert(r.error == EPROTO && !m.host_calls && !m.recovery_calls); + m = fresh(); m.count = 2; r = rgb_apply(&t, lavender, 100, false); + assert(r.error == ENOTSUP && m.step == 1); + m = fresh(); m.lamp[23] = 0; r = rgb_apply(&t, lavender, 100, false); + assert(r.error == ENOTSUP && !m.host_calls); + m = fresh(); m.lamp[27] = 0; r = rgb_apply(&t, lavender, 100, false); + assert(r.error == ENOTSUP && !m.host_calls); + m = fresh(); m.lamp[27] = 0; + r = rgb_apply(&t, lavender, 0, true); + assert(!r.error && !m.recovery_calls); + for (size_t i = 6; i < 10; ++i) assert(m.range[i] == 0); + m = fresh(); r = rgb_auto(&t); + assert(!r.error && m.step == 1 && m.recovery_calls == 1 && !m.host_calls); + m = fresh(); m.fail_step = 1; r = rgb_auto(&t); + assert(r.error == EIO && m.step == 1 && !r.recovery_attempted); + m = fresh(); + struct la_attributes a; + assert(rgb_probe(&t, &a, &l) == 0 && m.step == 3 && !m.host_calls && !m.recovery_calls); + assert(rgb_apply(NULL, lavender, 0, false).error == EINVAL); + assert(rgb_apply(&t, NULL, 0, false).error == EINVAL); + assert(rgb_auto(NULL).error == EINVAL); + puts("Controller conversion, exact sequence, failure and recovery tests passed (memory transport only)."); + return 0; +} diff --git a/tests/test_core.c b/tests/test_core.c new file mode 100644 index 0000000..51bc5d3 --- /dev/null +++ b/tests/test_core.c @@ -0,0 +1,129 @@ +#define _POSIX_C_SOURCE 200809L +#include "hid_device.h" +#include "lamp_array.h" +#include +#include +#include +#include +#include +#include +#include +static void write_file(const char *path, const void *data, size_t size) +{ + FILE *f = fopen(path, "wb"); + assert(f); + assert(fwrite(data, 1, size, f) == size); + assert(fclose(f) == 0); +} +static void fixture(const char *root, const char *name, const char *identity) +{ + char path[512]; + assert(snprintf(path, sizeof path, "%s/%s", root, name) > 0); + assert(mkdir(path, 0700) == 0); + assert(snprintf(path, sizeof path, "%s/%s/device", root, name) > 0); + assert(mkdir(path, 0700) == 0); + assert(snprintf(path, sizeof path, "%s/%s/device/uevent", root, name) > 0); + write_file(path, identity, strlen(identity)); +} +static void remove_fixture(const char *root, const char *name) +{ + char path[512]; + (void)snprintf(path, sizeof path, "%s/%s/device/uevent", root, name); + assert(unlink(path) == 0); + (void)snprintf(path, sizeof path, "%s/%s/device", root, name); + assert(rmdir(path) == 0); + (void)snprintf(path, sizeof path, "%s/%s", root, name); + assert(rmdir(path) == 0); +} +struct mock { uint8_t bytes[23]; int result; unsigned calls; }; +static int fake_get(void *context, uint8_t *report, size_t size) +{ + struct mock *m = context; + assert(size == 23 && report[0] == 0x41); + for (size_t i = 1; i < size; ++i) assert(report[i] == 0); + ++m->calls; + if (m->result < 0) { errno = EACCES; return -1; } + memcpy(report, m->bytes, size); + return m->result; +} +int main(int argc, char **argv) +{ + assert(argc == 2); + uint8_t rgb[3] = {0}; + assert(la_color("#B4BEFE", rgb) && rgb[0] == 180 && rgb[1] == 190 && rgb[2] == 254); + assert(la_color("#aBcD09", rgb) && rgb[0] == 171 && rgb[1] == 205 && rgb[2] == 9); + assert(la_color("#000000", rgb) && la_color("#FFFFFF", rgb)); + const char *bad[] = {"", "B4BEFE", "#fff", "#FFFFFG", "#0000000", " #000000", "#000000\n"}; + for (size_t i = 0; i < sizeof bad/sizeof bad[0]; ++i) assert(!la_color(bad[i], rgb)); + assert(!la_color(NULL, rgb) && !la_color("#000000", NULL)); + unsigned percent = 999; + assert(la_percent("0", &percent) && percent == 0); + assert(la_percent("60", &percent) && percent == 60); + assert(la_percent("100", &percent) && percent == 100); + const char *bad_percent[] = {"", "-1", "+1", "101", "999999999999", "60%", " 60", "1.0"}; + for (size_t i = 0; i < sizeof bad_percent/sizeof bad_percent[0]; ++i) assert(!la_percent(bad_percent[i], &percent)); + assert(!la_percent(NULL, &percent) && !la_percent("0", NULL)); + /* Golden wire bytes from the audited layout, including LE range boundaries. + * These are offline synthetic counts/levels, not claims about the keyboard. */ + uint8_t guarded[12]; memset(guarded, 0xaa, sizeof guarded); + const uint8_t rgbi[4] = {0xb4, 0xbe, 0xfe, 0xff}; + const uint8_t golden[10] = {0x45, 1, 0, 0, 1, 1, 0xb4, 0xbe, 0xfe, 0xff}; + assert(la_range(258, rgbi, guarded+1, 10)); + assert(guarded[0] == 0xaa && guarded[11] == 0xaa && !memcmp(guarded+1, golden, 10)); + assert(!la_range(0, rgbi, guarded, 10) && !la_range(1, rgbi, guarded, 9)); + assert(!la_range(1, NULL, guarded, 10) && !la_range(1, rgbi, NULL, 10)); + assert(la_range(65535, rgbi, guarded, 10) && guarded[4] == 0xfe && guarded[5] == 0xff); + assert(la_range(1, rgbi, guarded, 10) && guarded[4] == 0 && guarded[5] == 0); + assert(la_control(true, guarded, 2) && guarded[0] == 0x46 && guarded[1] == 1); + assert(la_control(false, guarded, 2) && guarded[1] == 0); + assert(!la_control(true, guarded, 1) && !la_control(true, NULL, 2)); + struct mock m = {.bytes = {0x41, 2, 1}, .result = 23}; + m.bytes[19] = 0x78; m.bytes[20] = 0x56; m.bytes[21] = 0x34; m.bytes[22] = 0x12; + struct la_attributes a; + assert(la_read_attributes(fake_get, &m, &a) && a.count == 258 && a.min_interval_us == 0x12345678 && m.calls == 1); + m.result = 22; + assert(!la_read_attributes(fake_get, &m, &a) && errno == EPROTO); + m.result = 24; + assert(!la_read_attributes(fake_get, &m, &a) && errno == EPROTO); + m.result = -1; + assert(!la_read_attributes(fake_get, &m, &a) && errno == EACCES); + m.result = 23; m.bytes[0] = 0x31; + assert(!la_read_attributes(fake_get, &m, &a)); + m.bytes[0] = 0x41; m.bytes[1] = 0; m.bytes[2] = 0; + assert(!la_read_attributes(fake_get, &m, &a)); + m.bytes[1] = 1; m.bytes[22] = 0x80; + assert(!la_read_attributes(fake_get, &m, &a)); + assert(!la_read_attributes(NULL, &m, &a) && errno == EINVAL); + assert(hid_get(NULL, guarded, 10) == -1 && errno == EINVAL); + assert(hid_open_verified("../hidraw1") == -1 && errno == EINVAL); + assert(hid_identity("DRIVER=hid-generic\nHID_ID=0018:00000B05:00005570\n")); + assert(!hid_identity("HID_ID=0003:00000B05:00005570\n")); + assert(!hid_identity("XHID_ID=0018:00000B05:00005570\n")); + assert(!hid_identity("HID_ID=0018:00000B05:000055700\n")); + char root[] = "/tmp/labfy-rgb-test-XXXXXX"; + assert(mkdtemp(root)); + char name[64]; + assert(hid_discover(root, name, sizeof name) == -1 && errno == ENODEV); + fixture(root, "hidraw0", "HID_ID=0018:0000093A:00003017\n"); + fixture(root, "hidraw27", "HID_ID=0018:00000B05:00005570\n"); + assert(hid_discover(root, name, sizeof name) == 0 && !strcmp(name, "hidraw27")); + assert(hid_discover(root, name, 2) == -1 && errno == ENAMETOOLONG); + uint8_t desc[4096]; + FILE *f = fopen(argv[1], "rb"); assert(f); + size_t n = fread(desc, 1, sizeof desc, f); assert(!ferror(f) && feof(f)); assert(fclose(f) == 0); + assert(la_profile(desc, n) && !la_profile(desc, n-1)); + desc[0] ^= 1; assert(!la_profile(desc, n)); desc[0] ^= 1; + char path[512]; + (void)snprintf(path, sizeof path, "%s/hidraw27/device/report_descriptor", root); + write_file(path, desc, n); + uint8_t copy[4096]; size_t count; + assert(hid_descriptor(root, "hidraw27", copy, sizeof copy, &count) == 0 && count == n && !memcmp(copy, desc, n)); + assert(hid_descriptor(root, "hidraw27", copy, n-1, &count) == -1 && errno == EOVERFLOW); + assert(unlink(path) == 0); + fixture(root, "hidraw2", "HID_ID=0018:00000B05:00005570\n"); + assert(hid_discover(root, name, sizeof name) == -1 && errno == EEXIST); + remove_fixture(root, "hidraw2"); remove_fixture(root, "hidraw27"); remove_fixture(root, "hidraw0"); + assert(rmdir(root) == 0); + puts("All offline core tests passed; no real HID device opened."); + return 0; +} diff --git a/tests/test_state.c b/tests/test_state.c new file mode 100644 index 0000000..867c835 --- /dev/null +++ b/tests/test_state.c @@ -0,0 +1,61 @@ +#define _POSIX_C_SOURCE 200809L +#include "rgb_state.h" +#include +#include +#include +#include +#include +#include +#include +#include +int main(void) +{ + char root[] = "/tmp/labfy-rgb-state-XXXXXX"; + assert(mkdtemp(root)); + int dir = rgb_state_open_at(root); assert(dir >= 0); + struct rgb_state s = {.inode = UINT64_C(0x1020304050607080), .device = UINT64_C(0x8877665544332211), .rgb = {180, 190, 254}, .percent = 60, .mode = 1, .has_color = true}, out; + assert(rgb_state_load(dir, s.inode, s.device, &out) == -1 && errno == ENODATA); + assert(rgb_state_save(dir, &s) == 0); + assert(rgb_state_load(dir, s.inode, s.device, &out) == 0 && out.inode == s.inode && out.device == s.device && !memcmp(out.rgb, s.rgb, 3) && out.percent == 60); + assert(rgb_state_load(dir, s.inode+1, s.device, &out) == -1 && errno == ENODATA); + assert(rgb_state_load(dir, s.inode, s.device+1, &out) == -1 && errno == ENODATA); + s.percent = 0; assert(rgb_state_save(dir, &s) == 0); + assert(rgb_state_load(dir, s.inode, s.device, &out) == 0 && !out.percent); + int fd = openat(dir, "state.bin", O_RDWR); assert(fd >= 0); + uint8_t bytes[32]; assert(read(fd, bytes, 32) == 32); + assert(!memcmp(bytes, "LFRGB003", 8) && bytes[8] == 0x80 && bytes[15] == 0x10 && bytes[24] == 180 && bytes[26] == 254); + assert(fchmod(fd, 0644) == 0); + assert(rgb_state_load(dir, s.inode, s.device, &out) == -1 && errno == EPROTO); + assert(fchmod(fd, 0600) == 0); + bytes[27] = 101; assert(pwrite(fd, bytes, 32, 0) == 32); + assert(rgb_state_load(dir, s.inode, s.device, &out) == -1 && errno == EPROTO); + bytes[27] = 60; bytes[31] = 1; assert(pwrite(fd, bytes, 32, 0) == 32); + assert(rgb_state_load(dir, s.inode, s.device, &out) == -1 && errno == EPROTO); + assert(ftruncate(fd, 31) == 0); + assert(rgb_state_load(dir, s.inode, s.device, &out) == -1 && errno == EPROTO); + assert(close(fd) == 0); + assert(rgb_state_invalidate(dir) == 0 && rgb_state_invalidate(dir) == 0); + assert(symlinkat("/dev/null", dir, "state.bin") == 0); + assert(rgb_state_load(dir, s.inode, s.device, &out) == -1 && errno == ELOOP); + assert(rgb_state_invalidate(dir) == 0); + s.percent = 101; assert(rgb_state_save(dir, &s) == -1 && errno == EINVAL); + assert(chmod(root, 0777) == 0); + assert(rgb_state_open_at(root) == -1 && errno == EPERM); + assert(chmod(root, 0700) == 0); + assert(setenv("XDG_RUNTIME_DIR", root, 1) == 0); + int runtime = rgb_state_open(); assert(runtime >= 0); + struct stat private_st; assert(fstat(runtime, &private_st) == 0 && (private_st.st_mode & 0777) == 0700); + assert(close(runtime) == 0); + char child[256]; (void)snprintf(child, sizeof child, "%s/%s", root, geteuid() == 0 ? "labfy-rgb-root" : "labfy-rgb"); + assert(chmod(child, 0755) == 0); + assert(rgb_state_open() == -1 && errno == EPERM); + assert(chmod(child, 0700) == 0 && rmdir(child) == 0); + assert(symlink("/tmp", child) == 0); + assert(rgb_state_open() == -1); + assert(unlink(child) == 0); + assert(setenv("XDG_RUNTIME_DIR", "relative/path", 1) == 0); + assert(rgb_state_open() == -1 && errno == EINVAL); + assert(close(dir) == 0 && rmdir(root) == 0); + puts("Volatile cache encoding, atomic replacement, corruption, identity and permission tests passed."); + return 0; +} diff --git a/tests/test_transport.c b/tests/test_transport.c new file mode 100644 index 0000000..4000843 --- /dev/null +++ b/tests/test_transport.c @@ -0,0 +1,101 @@ +#define _POSIX_C_SOURCE 200809L +#include "hid_device.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "../src/profile.inc" +static unsigned calls, closes, opens; +static int mode, flags, ret_override = -2; +/* Linker wrappers never forward ANY call to /dev or ioctl. The actual + * production transport runs against these syscall doubles, including validation. */ +int __wrap_open(const char *path, int oflag, ...) +{ + ++opens; + assert(!strcmp(path, "/dev/hidraw77")); flags = oflag; + if (mode == 8) { errno = EACCES; return -1; } + return 77; +} +int __wrap_open64(const char *path, int oflag, ...) { return __wrap_open(path, oflag); } +int __wrap_close(int fd) { assert(fd == 77); ++closes; return 0; } +int __wrap_flock(int fd, int operation) +{ + assert(fd == 77 && operation == (LOCK_EX | LOCK_NB)); + if (mode == 7) { errno = EWOULDBLOCK; return -1; } + return 0; +} +int __wrap_ioctl(int fd, unsigned long request, ...) +{ + assert(fd == 77); ++calls; + va_list ap; va_start(ap, request); void *argument = va_arg(ap, void *); va_end(ap); + if (request == HIDIOCGRAWINFO) { + struct hidraw_devinfo *info = argument; + info->bustype = mode == 1 ? BUS_USB : BUS_I2C; + info->vendor = mode == 2 ? 0x1234 : 0x0b05; + info->product = mode == 3 ? 0x9999 : 0x5570; + return 0; + } + if (request == HIDIOCGRDESCSIZE) { + *(int *)argument = mode == 4 ? HID_MAX_DESCRIPTOR_SIZE+1 : (int)sizeof profile; + return 0; + } + if (request == HIDIOCGRDESC) { + struct hidraw_report_descriptor *d = argument; + assert(d->size == sizeof profile); + memcpy(d->value, profile, sizeof profile); + if (mode == 5) d->value[0] ^= 1; + if (mode == 6) ++d->size; + return 0; + } + assert(request == HIDIOCGFEATURE(23) || request == HIDIOCGFEATURE(29) || + request == HIDIOCSFEATURE(2) || request == HIDIOCSFEATURE(3) || request == HIDIOCSFEATURE(10)); + uint8_t *r = argument; + unsigned n = _IOC_SIZE(request); + if (_IOC_NR(request) == _IOC_NR(HIDIOCSFEATURE(2))) r[0] = 0; /* ensure caller const bytes survive */ + if (ret_override == -1) { errno = EIO; return -1; } + return ret_override == -2 ? (int)n : ret_override; +} +int main(void) +{ + assert(hid_open_verified("hidraw77") == 77 && (flags & O_ACCMODE) == O_RDONLY); + assert(__wrap_close(77) == 0); + assert(hid_open_control("hidraw77") == 77 && (flags & O_ACCMODE) == O_RDWR && (flags & O_NOFOLLOW) && (flags & O_CLOEXEC)); + assert(__wrap_close(77) == 0); + for (mode = 1; mode <= 8; ++mode) { + unsigned before = closes; + assert(hid_open_control("hidraw77") == -1); + assert(closes == before + (mode == 8 ? 0u : 1u)); + assert(errno == (mode <= 3 ? ENODEV : mode <= 6 ? EPROTO : mode == 7 ? EWOULDBLOCK : EACCES)); + } + mode = 0; + int fd = 77; + uint8_t r[29] = {0x41}; + assert(hid_get(&fd, r, 23) == 23); + r[0] = 0x43; assert(hid_get(&fd, r, 29) == 29); + const uint8_t selector[] = {0x42, 0, 0}, host[] = {0x46, 0}, automatic[] = {0x46, 1}; + assert(hid_set(&fd, selector, 3) == 3 && selector[0] == 0x42); + assert(hid_set(&fd, host, 2) == 2 && host[0] == 0x46); + assert(hid_set(&fd, automatic, 2) == 2); + uint8_t range[10] = {0x45, 1, 0, 0, 0, 0, 180, 190, 254, 255}; + assert(hid_set(&fd, range, 10) == 10 && range[0] == 0x45); + unsigned before = calls; + range[1] = 8; assert(hid_set(&fd, range, 10) == -1 && errno == EINVAL); + range[1] = 1; range[4] = 1; assert(hid_set(&fd, range, 10) == -1); + assert(hid_set(&fd, selector, 2) == -1 && hid_set(NULL, host, 2) == -1); + r[0] = 0x31; assert(hid_get(&fd, r, 23) == -1); + assert(calls == before); + ret_override = 1; assert(hid_set(&fd, host, 2) == -1 && errno == EPROTO); + ret_override = 0; assert(hid_set(&fd, host, 2) == -1 && errno == EPROTO); + ret_override = 3; assert(hid_set(&fd, host, 2) == -1 && errno == EPROTO); + ret_override = -1; assert(hid_set(&fd, host, 2) == -1 && errno == EIO); + assert(hid_open_control("../../hidraw77") == -1 && errno == EINVAL); + assert(opens == 10); + puts("Production hidraw transport verified via syscall doubles; no device opened."); + return 0; +}