Initial public release of Labfy RGB

This commit is contained in:
fy59 2026-11-09 19:01:00 +01:00
commit f95149c5df
28 changed files with 2109 additions and 0 deletions

43
.gitignore vendored Normal file
View file

@ -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

21
LICENSE Normal file
View file

@ -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.

265
README.md Normal file
View file

@ -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: <https://github.com/labfytools/labfy-rgb>.
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).

78
THIRD_PARTY_NOTICES.md Normal file
View file

@ -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.

65
docs/protocol.md Normal file
View file

@ -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.

74
docs/quickshell.md Normal file
View file

@ -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.

24
meson.build Normal file
View file

@ -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)

View file

@ -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"

View file

@ -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'

158
src/hid_device.c Normal file
View file

@ -0,0 +1,158 @@
#define _POSIX_C_SOURCE 200809L
#include "hid_device.h"
#include "lamp_array.h"
#include <dirent.h>
#include <errno.h>
#include <fcntl.h>
#include <linux/hidraw.h>
#include <linux/input.h>
#include <stdio.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/file.h>
#include <time.h>
#include <unistd.h>
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;
}

28
src/hid_device.h Normal file
View file

@ -0,0 +1,28 @@
#ifndef LABFY_HID_DEVICE_H
#define LABFY_HID_DEVICE_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
/* 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

83
src/lamp_array.c Normal file
View file

@ -0,0 +1,83 @@
#include "lamp_array.h"
#include <errno.h>
#include <string.h>
#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;
}

29
src/lamp_array.h Normal file
View file

@ -0,0 +1,29 @@
#ifndef LABFY_LAMP_ARRAY_H
#define LABFY_LAMP_ARRAY_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
/* 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

188
src/main.c Normal file
View file

@ -0,0 +1,188 @@
#include "hid_device.h"
#include "lamp_array.h"
#include "rgb_controller.h"
#include "rgb_state.h"
#include <errno.h>
#include <linux/hid.h>
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
/* 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);
}

50
src/profile.inc Normal file
View file

@ -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,
};

94
src/rgb_controller.c Normal file
View file

@ -0,0 +1,94 @@
#include "rgb_controller.h"
#include <errno.h>
#include <string.h>
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;
}

34
src/rgb_controller.h Normal file
View file

@ -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

116
src/rgb_state.c Normal file
View file

@ -0,0 +1,116 @@
#define _POSIX_C_SOURCE 200809L
#include "rgb_state.h"
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
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;
}

24
src/rgb_state.h Normal file
View file

@ -0,0 +1,24 @@
#ifndef LABFY_RGB_STATE_H
#define LABFY_RGB_STATE_H
#include <stdint.h>
#include <stdbool.h>
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

37
tests/cli.sh Executable file
View file

@ -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

20
tests/data/README.md Normal file
View file

@ -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`.

Binary file not shown.

View file

@ -0,0 +1,130 @@
#define _POSIX_C_SOURCE 200809L
#include "hid_device.h"
#include "rgb_state.h"
#include <assert.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#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;
}

33
tests/test_concurrency.c Normal file
View file

@ -0,0 +1,33 @@
#define _POSIX_C_SOURCE 200809L
#include "hid_device.h"
#include <assert.h>
#include <errno.h>
#include <fcntl.h>
#include <stdlib.h>
#include <sys/file.h>
#include <sys/wait.h>
#include <unistd.h>
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;
}

154
tests/test_controller.c Normal file
View file

@ -0,0 +1,154 @@
#include "rgb_controller.h"
#include <assert.h>
#include <errno.h>
#include <stdio.h>
#include <string.h>
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;
}

129
tests/test_core.c Normal file
View file

@ -0,0 +1,129 @@
#define _POSIX_C_SOURCE 200809L
#include "hid_device.h"
#include "lamp_array.h"
#include <assert.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
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;
}

61
tests/test_state.c Normal file
View file

@ -0,0 +1,61 @@
#define _POSIX_C_SOURCE 200809L
#include "rgb_state.h"
#include <assert.h>
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
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;
}

101
tests/test_transport.c Normal file
View file

@ -0,0 +1,101 @@
#define _POSIX_C_SOURCE 200809L
#include "hid_device.h"
#include <assert.h>
#include <errno.h>
#include <fcntl.h>
#include <linux/hidraw.h>
#include <linux/input.h>
#include <stdarg.h>
#include <stdio.h>
#include <string.h>
#include <sys/file.h>
#include <unistd.h>
#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;
}