Initial public release of Labfy RGB
This commit is contained in:
commit
f95149c5df
28 changed files with 2109 additions and 0 deletions
43
.gitignore
vendored
Normal file
43
.gitignore
vendored
Normal 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
21
LICENSE
Normal 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
265
README.md
Normal 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
78
THIRD_PARTY_NOTICES.md
Normal 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
65
docs/protocol.md
Normal 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
74
docs/quickshell.md
Normal 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
24
meson.build
Normal 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)
|
||||
5
packaging/70-labfy-rgb.rules
Normal file
5
packaging/70-labfy-rgb.rules
Normal 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"
|
||||
65
packaging/apply-udev-rule.sh
Normal file
65
packaging/apply-udev-rule.sh
Normal 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
158
src/hid_device.c
Normal 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
28
src/hid_device.h
Normal 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
83
src/lamp_array.c
Normal 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
29
src/lamp_array.h
Normal 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
188
src/main.c
Normal 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
50
src/profile.inc
Normal 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
94
src/rgb_controller.c
Normal 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
34
src/rgb_controller.h
Normal 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
116
src/rgb_state.c
Normal 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
24
src/rgb_state.h
Normal 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
37
tests/cli.sh
Executable 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
20
tests/data/README.md
Normal 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`.
|
||||
BIN
tests/data/ite5570-5570.desc
Normal file
BIN
tests/data/ite5570-5570.desc
Normal file
Binary file not shown.
130
tests/test_cli_integration.c
Normal file
130
tests/test_cli_integration.c
Normal 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
33
tests/test_concurrency.c
Normal 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
154
tests/test_controller.c
Normal 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
129
tests/test_core.c
Normal 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
61
tests/test_state.c
Normal 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
101
tests/test_transport.c
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue