Skip to content

Repository files navigation

tuxedo-control-nix

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.

CI License: MIT

⚠️ 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 disable tailord/tccd first. Validated on one model so far (see Supported hardware).

Why this exists

On NixOS, TUXEDO hardware control has two unsatisfying options:

  1. TUXEDO Control Center (TCC): the official Electron app + tccd daemon. Not packaged in nixpkgs, and an Electron/Node app is awkward to package declaratively.
  2. 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, board GXxHRXx) tailord kept the fan loud at a cold idle: a GUI-set enthusiast performance profile stuck, and re-applying power_save over 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.

Goal

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.

Status: all phases working on the reference machine ✅

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.

Usage

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 dashboard

On 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).

Roadmap (next)

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.

Methodology (source-first, no black-box guessing)

  • The kernel module is GPL. tuxedo-drivers defines the ioctl request numbers and structs. Read them; don't reverse the wire blind.
  • strace -e ioctl the existing daemons (tailord, and tccd if obtainable) to see the exact TUXEDO_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.

Prior art / references

  • 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.)

Supported hardware

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.

Versioning

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.

Contributing

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.

AI assistance

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.

License

MIT.

About

Declarative TUXEDO Uniwill fan/performance control for NixOS, reverse-engineered from the tuxedo_io ioctl interface (daemon + CLI + TUI + GTK4 GUI + NixOS module).

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages