A NixOS-native, declarative replacement for TUXEDO laptop hardware control (fan
curves, performance profiles, keyboard backlight and charging profiles), built by
reverse-engineering the tuxedo_io kernel ioctl interface.
⚠️ Safety. This drives a root daemon that writes to your laptop's embedded controller (fan duty, EC registers). It can set the fan to 0%. The daemon enforces a temperature curve and restores the EC's automatic control on exit, but you run it at your own risk. Only one fan controller may run at a time, so disabletailord/tccdfirst. Validated on one model so far (see Supported hardware).
On NixOS, TUXEDO hardware control has two unsatisfying options:
- TUXEDO Control Center (TCC): the official Electron app +
tccddaemon. Not packaged in nixpkgs, and an Electron/Node app is awkward to package declaratively. - tuxedo-rs / tailord: a Rust daemon is in nixpkgs (
hardware.tuxedo-rs), but its fan control misbehaves on some Uniwill-based AMD models. On the reference machine (TUXEDO InfinityBook Pro AMD Gen9, boardGXxHRXx) tailord kept the fan loud at a cold idle: a GUI-setenthusiastperformance profile stuck, and re-applyingpower_saveover D-Bus did not quiet the fan at 35 °C even though the active curve was 0 % below 49 °C. Disabling tailord and handing the fan back to the EC was the only fix that worked.
Both tccd and tailord drive the same kernel interface, /dev/tuxedo_io
(tuxedo_io.ko, from the GPL tuxedo-drivers). The plan: understand that ioctl protocol
directly, build a small correct daemon, and wrap it in a first-class NixOS module. No
Electron, no guessing.
services.tuxedo-control = {
enable = true;
fan.curve = [ { temp = 25; speed = 0; } { temp = 50; speed = 0; } /* … */ ];
performanceProfile = "power_save"; # decoupled from the fan curve
charging.profile = "stationary"; # stationary | balanced | high_capacity
keyboard.backlight.brightness = 2; # 0..max_brightness (0–4 on the reference board)
};A pure, declarative module that works on AMD/Uniwill boards, plus a flake output for the daemon and a dev shell for the RE work.
Reference machine: InfinityBook Pro AMD Gen9 (Uniwill, tuxedo_io v0.3.9).
| Phase | Deliverable | State |
|---|---|---|
| 0 | Recon: docs/hardware-interface.md |
✅ |
| 1 | Protocol map + root-cause in docs/phase1-protocol.md: every Uniwill ioctl, fan scaling (0–0xc8), the 0x40-bit bug |
✅ source-derived |
| 2 | Prober (prober/): drives fan 0→60→auto; confirmed the protocol + fix on hardware |
✅ validated |
| 3 | Daemon (daemon/): temp→duty curve, hysteresis, clears 0x40 each tick, perf profile decoupled, restores EC auto on SIGTERM |
✅ validated |
| 4 | NixOS module + flake (nix/): services.tuxedo-control, declarative curve/profile; force-disables tuxedo-rs, loads tuxedo_io |
✅ evals + builds |
| 5 | TUI (tui/): live temps/fan/mode dashboard + quick controls |
✅ |
| 6 | GUI (gui/): libadwaita/GTK4 app — fan, performance profiles, keyboard backlight, charging profile, follow-system theme |
✅ |
Beyond the phases: named performance profiles with their own fan curves
(create/delete/set-default), built-in profiles modelled on TUXEDO Control Center, and
import of TCC's exported profiles. CI (nix flake check + clippy/fmt) runs on every
push, and the first tagged release is v0.1.0 (see
CHANGELOG.md).
The bug, in one line: uw_set_fan ignores the requested duty unless bit 0x40
of EC RAM 0x0751 is clear. Once anything (a stuck perf/mode write) sets 0x40, the EC
keeps the fan on its own loud curve. tuxedo-rs/tailord doesn't guard against this; this
daemon clears 0x40 before every fan write. Full analysis in docs/phase1-protocol.md.
cargo build --release # workspace: prober, daemon, tui (or: nix build .#default)
sudo ./target/release/tuxedo-prober info # read temps/fan/mode
sudo ./target/release/tuxedo-prober set 40 # both fans -> 40%
sudo ./target/release/tuxedo-prober auto # hand back to the EC
sudo ./target/release/tuxedo-tui # live dashboardOn NixOS, add this flake as an input and enable the module:
services.tuxedo-control = {
enable = true;
performanceProfile = "power_save";
fan.curve = [
{ temp = 25; speed = 0; } { temp = 50; speed = 0; }
{ temp = 62; speed = 24; } { temp = 80; speed = 60; } { temp = 90; speed = 100; }
];
};The driver enforces a ~25 % on-speed floor; a requested duty below ~12 % becomes 0 (off).
The initial roadmap (phases 0–6, declarative module options, a NixOS VM test, and model
gating via R_UW_MODEL_ID) is complete. Model gating now refuses fan/EC writes on any board
not in the known-models registry, so the daemon is safe to run on other Uniwill boards
(read-only until validated). Future direction is breadth: validating and adding more boards
to the registry — see docs/model-gating.md and
CONTRIBUTING.
- The kernel module is GPL.
tuxedo-driversdefines the ioctl request numbers and structs. Read them; don't reverse the wire blind. strace -e ioctlthe existing daemons (tailord, andtccdif obtainable) to see the exactTUXEDO_IO_*calls and argument values on this board.- Cross-reference the two implementations: tuxedo-rs (Rust, MIT) and TCC (
tccd, GPL). The divergence explains the Uniwill-AMD fan bug. - Validate every step on real hardware via the prober before baking it into the daemon.
tuxedo-drivers: the GPL kernel modules (ioctl source of truth).tuxedo-rs(tailord): Rust daemon, MIT; the base to fix or learn from.tuxedo-control-center: the official Electron app +tccd.blitz/tuxedo-nixos: an existing community flake; evaluate as a packaging reference.
(Exact upstream URLs collected in docs/reverse-engineering-plan.md.)
A board is listed as validated only once fan control has been exercised on it.
| Model | Board | tuxedo_io |
Status |
|---|---|---|---|
| TUXEDO InfinityBook Pro AMD Gen9 | GXxHRXx (Uniwill) |
0.3.9 | ✅ validated: fan, perf profile, keyboard backlight, charging profile |
Other Uniwill TUXEDO laptops may work but are unverified. Adding a board is a
deliberate, source-first process. See CONTRIBUTING.
Features that the EC doesn't expose (e.g. TDP control returns ENODEV on the reference
board) are detected at runtime and hidden rather than guessed.
Pre-1.0. While 0.y.z, any 0.y release may change the CLI, the daemon's socket
protocol, the NixOS module options, or ioctl behaviour. See CHANGELOG.md.
Issues and PRs welcome. Please read CONTRIBUTING.md (especially the hardware-safety section) and the Code of Conduct. For security or physical-safety issues, follow SECURITY.md; do not open a public issue.
Parts of this project — code, Nix packaging, and docs — were written with AI assistance. I reviewed, tested, and validated everything that shipped, especially the root daemon that writes to the embedded controller, before committing it.
MIT.