commit 9405cde151ec86950d60f3c81eef403556659d1d
Author: DAEMON-404 <zer0sec.xp@icloud.com>
Date: Wed, 7 Oct 2026 22:26:47 +0100
NixDaemon: flake for the Valeon II 17 (fan fix, toolbox support, Hyprland + Caelestia)
- hosts/laptop: fan-throttle-guard.nix (vault gpu-fan-fix, unchanged) with
fanfix and stability_guard.py beside it; uniwill-laptop built from the
Linux 6.19 sources as an out-of-tree module so the guard has its hwmon on
the 6.18 kernel; nvidia (open module, panel on the dGPU), Samsung SSD
crypttab, Hyprland cachix, envfs + ~/.local/bin on PATH, padx udev rule;
greetd/tuigreet, Hyprland under uwsm.
- hosts/bootstrap: today's GNOME install plus the fan fix and toolbox
prerequisites, from nixpkgs-stable (stage A).
- home: Hyprland Lua config (stock Omarchy binds re-expressed, carried
bindings.lua / lid.lua / caelestia.lua), Caelestia Shell with the carried
shell.json and the static Rosé Pine M3 scheme registered as a CLI scheme,
kitty + DMMono Nerd Font, toolbox runtime closure, GTK/cursor/icons.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
44 files changed, 5692 insertions(+), 0 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1,2 @@
+result
+result-*
diff --git a/flake.lock b/flake.lock
@@ -0,0 +1,789 @@
+{
+ "nodes": {
+ "aquamarine": {
+ "inputs": {
+ "hyprutils": [
+ "hyprland",
+ "hyprutils"
+ ],
+ "hyprwayland-scanner": [
+ "hyprland",
+ "hyprwayland-scanner"
+ ],
+ "nixpkgs": [
+ "hyprland",
+ "nixpkgs"
+ ],
+ "systems": [
+ "hyprland",
+ "systems"
+ ]
+ },
+ "locked": {
+ "lastModified": 1791041429,
+ "narHash": "sha256-Y/Q4JtMo6nHB6XS1LPSfK7Y4AnuNl9jci/5sCps1MRc=",
+ "owner": "hyprwm",
+ "repo": "aquamarine",
+ "rev": "52ea11f06dd492f34b3cc0b39a9c805beecafcff",
+ "type": "github"
+ },
+ "original": {
+ "owner": "hyprwm",
+ "repo": "aquamarine",
+ "type": "github"
+ }
+ },
+ "bun2nix": {
+ "inputs": {
+ "flake-parts": [
+ "llm-agents",
+ "flake-parts"
+ ],
+ "nixpkgs": [
+ "llm-agents",
+ "nixpkgs"
+ ],
+ "systems": [
+ "llm-agents",
+ "systems"
+ ],
+ "treefmt-nix": [
+ "llm-agents",
+ "treefmt-nix"
+ ]
+ },
+ "locked": {
+ "lastModified": 1790001035,
+ "narHash": "sha256-1/SLBjSYBzDdDhvLQ83wrzBakyS/xNGgtxHGNuveOEQ=",
+ "owner": "Mic92",
+ "repo": "bun2nix",
+ "rev": "07a5bfc8ac36c5370199343fa620b69f63186e33",
+ "type": "github"
+ },
+ "original": {
+ "owner": "Mic92",
+ "ref": "llm-agents",
+ "repo": "bun2nix",
+ "type": "github"
+ }
+ },
+ "caelestia-cli": {
+ "inputs": {
+ "caelestia-shell": [
+ "caelestia-shell"
+ ],
+ "nixpkgs": [
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1791350709,
+ "narHash": "sha256-tZNylOUnjpSwXCbYUx6Ts5HjH7QI7eH2CRKfWCsN2RI=",
+ "owner": "caelestia-dots",
+ "repo": "cli",
+ "rev": "ffaf093d5a486506709b881e0a057cd51c7babb6",
+ "type": "github"
+ },
+ "original": {
+ "owner": "caelestia-dots",
+ "repo": "cli",
+ "type": "github"
+ }
+ },
+ "caelestia-shell": {
+ "inputs": {
+ "caelestia-cli": [
+ "caelestia-cli"
+ ],
+ "m3shapes": "m3shapes",
+ "nixpkgs": [
+ "nixpkgs"
+ ],
+ "quickshell": "quickshell"
+ },
+ "locked": {
+ "lastModified": 1791262010,
+ "narHash": "sha256-QVHAZ9wvODj1I67THL8nRr1eDTf9OB6kwoAzf9ZBf8o=",
+ "owner": "caelestia-dots",
+ "repo": "shell",
+ "rev": "6f7ce62b7a6ff9e37b66526065643ca6e9d65783",
+ "type": "github"
+ },
+ "original": {
+ "owner": "caelestia-dots",
+ "repo": "shell",
+ "type": "github"
+ }
+ },
+ "flake-compat": {
+ "flake": false,
+ "locked": {
+ "lastModified": 1767039857,
+ "narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
+ "owner": "NixOS",
+ "repo": "flake-compat",
+ "rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "repo": "flake-compat",
+ "type": "github"
+ }
+ },
+ "flake-parts": {
+ "inputs": {
+ "nixpkgs-lib": [
+ "llm-agents",
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1790898470,
+ "narHash": "sha256-zTwuwezD0w3hpga6a6P8emidzAZFyWqlNeRBlpWOOl8=",
+ "owner": "hercules-ci",
+ "repo": "flake-parts",
+ "rev": "024633cd702b10285db5cb19b40ad48d2399ba60",
+ "type": "github"
+ },
+ "original": {
+ "owner": "hercules-ci",
+ "repo": "flake-parts",
+ "type": "github"
+ }
+ },
+ "home-manager": {
+ "inputs": {
+ "nixpkgs": [
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1791306979,
+ "narHash": "sha256-KuLePuhwJM9NLt/gTLHzXaOoSZKGoB3m7NfXjiQ98HY=",
+ "owner": "nix-community",
+ "repo": "home-manager",
+ "rev": "fae6e9e42c3b762ab47635cddcfaf6f52374a61b",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nix-community",
+ "repo": "home-manager",
+ "type": "github"
+ }
+ },
+ "hyprcursor": {
+ "inputs": {
+ "hyprlang": [
+ "hyprland",
+ "hyprlang"
+ ],
+ "nixpkgs": [
+ "hyprland",
+ "nixpkgs"
+ ],
+ "systems": [
+ "hyprland",
+ "systems"
+ ]
+ },
+ "locked": {
+ "lastModified": 1786464181,
+ "narHash": "sha256-2alOMkLjXANh7unkZnYnCF2K2rApZaOLMoQ3o+VX2CY=",
+ "owner": "hyprwm",
+ "repo": "hyprcursor",
+ "rev": "e4ed7c08123df5af460a0a70961380cbfb872f76",
+ "type": "github"
+ },
+ "original": {
+ "owner": "hyprwm",
+ "repo": "hyprcursor",
+ "type": "github"
+ }
+ },
+ "hyprgraphics": {
+ "inputs": {
+ "hyprutils": [
+ "hyprland",
+ "hyprutils"
+ ],
+ "nixpkgs": [
+ "hyprland",
+ "nixpkgs"
+ ],
+ "systems": [
+ "hyprland",
+ "systems"
+ ]
+ },
+ "locked": {
+ "lastModified": 1791066858,
+ "narHash": "sha256-hLh6ALHrGpgYm8viWCo1pO+ErC22YDGdAvVPcfYsXA8=",
+ "owner": "hyprwm",
+ "repo": "hyprgraphics",
+ "rev": "80c7d00f2236732853e390019ff8d79d17fa3612",
+ "type": "github"
+ },
+ "original": {
+ "owner": "hyprwm",
+ "repo": "hyprgraphics",
+ "type": "github"
+ }
+ },
+ "hyprgraphics_2": {
+ "inputs": {
+ "hyprutils": [
+ "hyprland",
+ "xdph",
+ "hyprutils"
+ ],
+ "nixpkgs": [
+ "hyprland",
+ "xdph",
+ "nixpkgs"
+ ],
+ "systems": [
+ "hyprland",
+ "xdph",
+ "systems"
+ ]
+ },
+ "locked": {
+ "lastModified": 1786464367,
+ "narHash": "sha256-k58p4wbzIXWyRWrW84pP8tD+iaZSSYiiM+fr0Auk4oU=",
+ "owner": "hyprwm",
+ "repo": "hyprgraphics",
+ "rev": "7c895c44e3ca6d28ed68ddd80ec02b02b925e7fc",
+ "type": "github"
+ },
+ "original": {
+ "owner": "hyprwm",
+ "repo": "hyprgraphics",
+ "type": "github"
+ }
+ },
+ "hyprland": {
+ "inputs": {
+ "aquamarine": "aquamarine",
+ "hyprcursor": "hyprcursor",
+ "hyprgraphics": "hyprgraphics",
+ "hyprland-guiutils": "hyprland-guiutils",
+ "hyprland-protocols": "hyprland-protocols",
+ "hyprlang": "hyprlang",
+ "hyprtoolkit": "hyprtoolkit",
+ "hyprutils": "hyprutils",
+ "hyprwayland-scanner": "hyprwayland-scanner",
+ "hyprwire": "hyprwire",
+ "nixpkgs": "nixpkgs",
+ "pre-commit-hooks": "pre-commit-hooks",
+ "systems": "systems",
+ "xdph": "xdph"
+ },
+ "locked": {
+ "lastModified": 1791292844,
+ "narHash": "sha256-zD7XTuKKvko7gt6chqY3KrjgpLUUNGegn7MKPm6h48k=",
+ "owner": "hyprwm",
+ "repo": "Hyprland",
+ "rev": "5a78b5e927345860a27e2893bf894f97ee620c48",
+ "type": "github"
+ },
+ "original": {
+ "owner": "hyprwm",
+ "repo": "Hyprland",
+ "type": "github"
+ }
+ },
+ "hyprland-guiutils": {
+ "inputs": {
+ "aquamarine": [
+ "hyprland",
+ "aquamarine"
+ ],
+ "hyprgraphics": [
+ "hyprland",
+ "hyprgraphics"
+ ],
+ "hyprlang": [
+ "hyprland",
+ "hyprlang"
+ ],
+ "hyprtoolkit": [
+ "hyprland",
+ "hyprtoolkit"
+ ],
+ "hyprutils": [
+ "hyprland",
+ "hyprutils"
+ ],
+ "hyprwayland-scanner": [
+ "hyprland",
+ "hyprwayland-scanner"
+ ],
+ "nixpkgs": [
+ "hyprland",
+ "nixpkgs"
+ ],
+ "systems": [
+ "hyprland",
+ "systems"
+ ]
+ },
+ "locked": {
+ "lastModified": 1786464504,
+ "narHash": "sha256-7sHwM86KILQyHDHDuE2SDBlQ2jvZ0EW3hY7sW009/cg=",
+ "owner": "hyprwm",
+ "repo": "hyprland-guiutils",
+ "rev": "4c30cf3097ea963c0e250749ee0c59f8b08816d6",
+ "type": "github"
+ },
+ "original": {
+ "owner": "hyprwm",
+ "repo": "hyprland-guiutils",
+ "type": "github"
+ }
+ },
+ "hyprland-protocols": {
+ "inputs": {
+ "nixpkgs": [
+ "hyprland",
+ "nixpkgs"
+ ],
+ "systems": [
+ "hyprland",
+ "systems"
+ ]
+ },
+ "locked": {
+ "lastModified": 1789475883,
+ "narHash": "sha256-YRFLrPESYitsDYqQKquOFg8OWNI7tHKwPnTrTZWXxWY=",
+ "owner": "hyprwm",
+ "repo": "hyprland-protocols",
+ "rev": "cc9a8fd253bdc00f48a967ecf4828211ef08751f",
+ "type": "github"
+ },
+ "original": {
+ "owner": "hyprwm",
+ "repo": "hyprland-protocols",
+ "type": "github"
+ }
+ },
+ "hyprlang": {
+ "inputs": {
+ "hyprutils": [
+ "hyprland",
+ "hyprutils"
+ ],
+ "nixpkgs": [
+ "hyprland",
+ "nixpkgs"
+ ],
+ "systems": [
+ "hyprland",
+ "systems"
+ ]
+ },
+ "locked": {
+ "lastModified": 1786464129,
+ "narHash": "sha256-339AkTlpMYSIvFuG0rnR+8Yg4/AZKeJalshJavlnKfg=",
+ "owner": "hyprwm",
+ "repo": "hyprlang",
+ "rev": "9508458be316a0d70d37ebed1ab725ccd10411ff",
+ "type": "github"
+ },
+ "original": {
+ "owner": "hyprwm",
+ "repo": "hyprlang",
+ "type": "github"
+ }
+ },
+ "hyprtoolkit": {
+ "inputs": {
+ "aquamarine": [
+ "hyprland",
+ "aquamarine"
+ ],
+ "hyprgraphics": [
+ "hyprland",
+ "hyprgraphics"
+ ],
+ "hyprlang": [
+ "hyprland",
+ "hyprlang"
+ ],
+ "hyprutils": [
+ "hyprland",
+ "hyprutils"
+ ],
+ "hyprwayland-scanner": [
+ "hyprland",
+ "hyprwayland-scanner"
+ ],
+ "nixpkgs": [
+ "hyprland",
+ "nixpkgs"
+ ],
+ "systems": [
+ "hyprland",
+ "systems"
+ ]
+ },
+ "locked": {
+ "lastModified": 1789562890,
+ "narHash": "sha256-LFzeVJkDMQLjCpi55S9oc3N/wMrW2RCS1ShPL+1HB78=",
+ "owner": "hyprwm",
+ "repo": "hyprtoolkit",
+ "rev": "b3f2d0e725e4eaf804af7e529cb69bbed43d1a04",
+ "type": "github"
+ },
+ "original": {
+ "owner": "hyprwm",
+ "repo": "hyprtoolkit",
+ "type": "github"
+ }
+ },
+ "hyprutils": {
+ "inputs": {
+ "nixpkgs": [
+ "hyprland",
+ "nixpkgs"
+ ],
+ "systems": [
+ "hyprland",
+ "systems"
+ ]
+ },
+ "locked": {
+ "lastModified": 1789968180,
+ "narHash": "sha256-YGRfhw+fJW7oKW3IspoceD34jeokN1KXNxKU6APlFH4=",
+ "owner": "hyprwm",
+ "repo": "hyprutils",
+ "rev": "95983ee836ff205e615bba67d1f67098e1231941",
+ "type": "github"
+ },
+ "original": {
+ "owner": "hyprwm",
+ "repo": "hyprutils",
+ "type": "github"
+ }
+ },
+ "hyprwayland-scanner": {
+ "inputs": {
+ "nixpkgs": [
+ "hyprland",
+ "nixpkgs"
+ ],
+ "systems": [
+ "hyprland",
+ "systems"
+ ]
+ },
+ "locked": {
+ "lastModified": 1786464033,
+ "narHash": "sha256-QM8Qe4/L8lpdVN4bgwahmi+jyyc4fisseDMe4afcDxA=",
+ "owner": "hyprwm",
+ "repo": "hyprwayland-scanner",
+ "rev": "62e62c1ca23da17612c6890d4ad2064f575643db",
+ "type": "github"
+ },
+ "original": {
+ "owner": "hyprwm",
+ "repo": "hyprwayland-scanner",
+ "type": "github"
+ }
+ },
+ "hyprwire": {
+ "inputs": {
+ "hyprutils": [
+ "hyprland",
+ "hyprutils"
+ ],
+ "nixpkgs": [
+ "hyprland",
+ "nixpkgs"
+ ],
+ "systems": [
+ "hyprland",
+ "systems"
+ ]
+ },
+ "locked": {
+ "lastModified": 1786464294,
+ "narHash": "sha256-ZQsZ2WvBdkboCIyh8LStDPdAIARmxzn0XMNxxoOhjPE=",
+ "owner": "hyprwm",
+ "repo": "hyprwire",
+ "rev": "4ce7cd6b6128c1ac41caf23c58a30a26b327f9dd",
+ "type": "github"
+ },
+ "original": {
+ "owner": "hyprwm",
+ "repo": "hyprwire",
+ "type": "github"
+ }
+ },
+ "llm-agents": {
+ "inputs": {
+ "bun2nix": "bun2nix",
+ "flake-parts": "flake-parts",
+ "nixpkgs": "nixpkgs_2",
+ "systems": "systems_2",
+ "treefmt-nix": "treefmt-nix"
+ },
+ "locked": {
+ "lastModified": 1791352800,
+ "narHash": "sha256-3exhcZ9ebN8cVVP+RJe2omsc0l6Mb6ju/kmqCwJJQpo=",
+ "owner": "numtide",
+ "repo": "llm-agents.nix",
+ "rev": "c9ef0fb47c91abf5c637218b69a018d3b37bb8d9",
+ "type": "github"
+ },
+ "original": {
+ "owner": "numtide",
+ "repo": "llm-agents.nix",
+ "type": "github"
+ }
+ },
+ "m3shapes": {
+ "inputs": {
+ "nixpkgs": [
+ "caelestia-shell",
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1787906858,
+ "narHash": "sha256-YZelgEZflFNwGutX4/tIzBdbOeghJgE2oDw0uWYGxns=",
+ "owner": "soramanew",
+ "repo": "m3shapes",
+ "rev": "32ad9ce328bb77ed349b40a3be10ee9ea610b8ab",
+ "type": "github"
+ },
+ "original": {
+ "owner": "soramanew",
+ "repo": "m3shapes",
+ "rev": "32ad9ce328bb77ed349b40a3be10ee9ea610b8ab",
+ "type": "github"
+ }
+ },
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1791048980,
+ "narHash": "sha256-KgItSKML8Xvte0B7/uGnBDsYzSnnKHcOaiUbgWBXLXw=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "a7868a727837f3c09cee2ce0ca671c76b1589fed",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixos-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "nixpkgs-stable": {
+ "locked": {
+ "lastModified": 1791353474,
+ "narHash": "sha256-v72qr4LsSz61tQki/41nHKZKPY6NSeZavtGlAInOCng=",
+ "owner": "nixos",
+ "repo": "nixpkgs",
+ "rev": "2efa67fd26b6df417c33e4603185c701f260dd83",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nixos",
+ "ref": "nixos-26.05",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "nixpkgs_2": {
+ "locked": {
+ "lastModified": 1790600678,
+ "narHash": "sha256-6k3n7X2UsZ6tAlWTz99RimiZab+17tMUrw9sRUcELiA=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "f45c6f04c2f013f004bf94e284e95d72898d9393",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixpkgs-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "nixpkgs_3": {
+ "locked": {
+ "lastModified": 1791260994,
+ "narHash": "sha256-Miqqk/ammqnTUxaoCyvtwoPeLbI1ksFyLxi/CazZpWY=",
+ "owner": "nixos",
+ "repo": "nixpkgs",
+ "rev": "151fa4e8ddfdd8dd25d945ad94ed54a13de9f6e4",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nixos",
+ "ref": "nixos-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "pre-commit-hooks": {
+ "inputs": {
+ "flake-compat": "flake-compat",
+ "nixpkgs": [
+ "hyprland",
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1790500375,
+ "narHash": "sha256-XN3sDtn8TU9hAc9xqZ+SqRB/HHm2wjxM6aSWYLJU+oo=",
+ "owner": "cachix",
+ "repo": "git-hooks.nix",
+ "rev": "a0e4241b51206fbcbf52fd322eb5f0cd80f153c4",
+ "type": "github"
+ },
+ "original": {
+ "owner": "cachix",
+ "repo": "git-hooks.nix",
+ "type": "github"
+ }
+ },
+ "quickshell": {
+ "inputs": {
+ "nixpkgs": [
+ "caelestia-shell",
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1791027806,
+ "narHash": "sha256-uPP+R90C7CagG4H9ibYaC0kYLu7lzC/5I6g+yFMuyu8=",
+ "ref": "refs/heads/master",
+ "rev": "5d5d49873fe8cf1f99ddfd5006ceb2057c5c9b13",
+ "revCount": 865,
+ "type": "git",
+ "url": "https://git.outfoxxed.me/outfoxxed/quickshell"
+ },
+ "original": {
+ "type": "git",
+ "url": "https://git.outfoxxed.me/outfoxxed/quickshell"
+ }
+ },
+ "root": {
+ "inputs": {
+ "caelestia-cli": "caelestia-cli",
+ "caelestia-shell": "caelestia-shell",
+ "home-manager": "home-manager",
+ "hyprland": "hyprland",
+ "llm-agents": "llm-agents",
+ "nixpkgs": "nixpkgs_3",
+ "nixpkgs-stable": "nixpkgs-stable"
+ }
+ },
+ "systems": {
+ "locked": {
+ "lastModified": 1689347949,
+ "narHash": "sha256-12tWmuL2zgBgZkdoB6qXZsgJEH9LR3oUgpaQq2RbI80=",
+ "owner": "nix-systems",
+ "repo": "default-linux",
+ "rev": "31732fcf5e8fea42e59c2488ad31a0e651500f68",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nix-systems",
+ "repo": "default-linux",
+ "type": "github"
+ }
+ },
+ "systems_2": {
+ "locked": {
+ "lastModified": 1791244623,
+ "narHash": "sha256-uuanbkFk8a313Zfrxdcz8Us/yQtaxNnKJtX8v0ZPpd8=",
+ "owner": "nix-systems",
+ "repo": "default",
+ "rev": "d36eeed142f88b5a636f2e3856a53c61654cf29b",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nix-systems",
+ "repo": "default",
+ "type": "github"
+ }
+ },
+ "treefmt-nix": {
+ "inputs": {
+ "nixpkgs": [
+ "llm-agents",
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1791203292,
+ "narHash": "sha256-vuzVwvjeucSnwKwMBpyP8r5IzdrMKCHEdwTrRFwUN7I=",
+ "owner": "numtide",
+ "repo": "treefmt-nix",
+ "rev": "03d8ee1bcbc8f9638907bb79edb9673151d0e22b",
+ "type": "github"
+ },
+ "original": {
+ "owner": "numtide",
+ "repo": "treefmt-nix",
+ "type": "github"
+ }
+ },
+ "xdph": {
+ "inputs": {
+ "aquamarine": [
+ "hyprland",
+ "aquamarine"
+ ],
+ "hyprgraphics": "hyprgraphics_2",
+ "hyprland-protocols": [
+ "hyprland",
+ "hyprland-protocols"
+ ],
+ "hyprlang": [
+ "hyprland",
+ "hyprlang"
+ ],
+ "hyprtoolkit": [
+ "hyprland",
+ "hyprtoolkit"
+ ],
+ "hyprutils": [
+ "hyprland",
+ "hyprutils"
+ ],
+ "hyprwayland-scanner": [
+ "hyprland",
+ "hyprwayland-scanner"
+ ],
+ "nixpkgs": [
+ "hyprland",
+ "nixpkgs"
+ ],
+ "systems": [
+ "hyprland",
+ "systems"
+ ]
+ },
+ "locked": {
+ "lastModified": 1790080407,
+ "narHash": "sha256-uKM+FAvcllYFcHoh4LEmyB0CGLaT3dMOjK4dxXyIIgc=",
+ "owner": "hyprwm",
+ "repo": "xdg-desktop-portal-hyprland",
+ "rev": "e87ae7823e7bf0601385220c69b5a3b245123fc5",
+ "type": "github"
+ },
+ "original": {
+ "owner": "hyprwm",
+ "repo": "xdg-desktop-portal-hyprland",
+ "type": "github"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/flake.nix b/flake.nix
@@ -0,0 +1,85 @@
+{
+ description = "NixDaemon: NixOS + home-manager for the PCSpecialist Valeon II 17 (Hyprland, Caelestia Shell, dead-GPU-fan workaround)";
+
+ inputs = {
+ nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
+
+ # The release branch this machine was installed from. Only the `bootstrap`
+ # configuration uses it (the fan fix on today's GNOME install, see README.md).
+ # Delete this input together with hosts/bootstrap/ once `nixos` is in use.
+ nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-26.05";
+
+ home-manager = {
+ url = "github:nix-community/home-manager";
+ inputs.nixpkgs.follows = "nixpkgs";
+ };
+
+ # Deliberately not following nixpkgs: Hyprland pins its own, and the
+ # hyprland.cachix.org cache (hosts/laptop/nix-settings.nix) only serves
+ # builds made against those pins.
+ hyprland.url = "github:hyprwm/Hyprland";
+
+ # Shell and CLI pin each other, so one revision of each is used everywhere.
+ caelestia-shell = {
+ url = "github:caelestia-dots/shell";
+ inputs.nixpkgs.follows = "nixpkgs";
+ inputs.caelestia-cli.follows = "caelestia-cli";
+ };
+ caelestia-cli = {
+ url = "github:caelestia-dots/cli";
+ inputs.nixpkgs.follows = "nixpkgs";
+ inputs.caelestia-shell.follows = "caelestia-shell";
+ };
+
+ # Claude Code and the Claude desktop app (modules/workstation.nix).
+ llm-agents.url = "github:numtide/llm-agents.nix";
+ };
+
+ outputs =
+ inputs@{ self, nixpkgs, nixpkgs-stable, home-manager, ... }:
+ let
+ system = "x86_64-linux";
+ user = "daemonsec";
+ in
+ {
+ nixosConfigurations = {
+ # The target: Hyprland + Caelestia Shell.
+ # sudo nixos-rebuild boot --flake ~/NixDaemon#nixos && reboot
+ nixos = nixpkgs.lib.nixosSystem {
+ inherit system;
+ specialArgs = { inherit inputs user; };
+ modules = [
+ ./hosts/laptop
+ ./modules/workstation.nix
+ inputs.hyprland.nixosModules.default
+ home-manager.nixosModules.home-manager
+ {
+ home-manager = {
+ useGlobalPkgs = true;
+ useUserPackages = true;
+ backupFileExtension = "hm-bak";
+ extraSpecialArgs = { inherit inputs user; };
+ sharedModules = [
+ inputs.hyprland.homeManagerModules.default
+ inputs.caelestia-shell.homeManagerModules.default
+ ];
+ users.${user} = import ./home;
+ };
+ }
+ ];
+ };
+
+ # Stage A: today's GNOME install plus the fan fix and the toolbox
+ # prerequisites, built from the same nixpkgs the machine runs now.
+ # sudo nixos-rebuild switch --flake ~/NixDaemon#bootstrap && reboot
+ bootstrap = nixpkgs-stable.lib.nixosSystem {
+ inherit system;
+ specialArgs = { inherit inputs user; };
+ modules = [
+ ./hosts/bootstrap
+ ./modules/workstation.nix
+ ];
+ };
+ };
+ };
+}
diff --git a/home/caelestia/rose-pine-dark.txt b/home/caelestia/rose-pine-dark.txt
@@ -0,0 +1,74 @@
+background 191724
+surface 191724
+surfaceDim 191724
+surfaceBright 524f67
+surfaceContainerLowest 16141f
+surfaceContainerLow 1f1d2e
+surfaceContainer 26233a
+surfaceContainerHigh 2a273f
+surfaceContainerHighest 403d52
+surfaceVariant 26233a
+onBackground e0def4
+onSurface e0def4
+onSurfaceVariant 908caa
+outline 6e6a86
+outlineVariant 403d52
+inverseSurface e0def4
+inverseOnSurface 191724
+inversePrimary b4637a
+surfaceTint eb6f92
+shadow 000000
+scrim 000000
+primary_paletteKeyColor eb6f92
+secondary_paletteKeyColor c4a7e7
+tertiary_paletteKeyColor 9ccfd8
+neutral_paletteKeyColor 6e6a86
+neutral_variant_paletteKeyColor 908caa
+term0 191724
+term1 eb6f92
+term2 31748f
+term3 f6c177
+term4 9ccfd8
+term5 c4a7e7
+term6 ebbcba
+term7 e0def4
+term8 6e6a86
+term9 eb6f92
+term10 31748f
+term11 f6c177
+term12 9ccfd8
+term13 c4a7e7
+term14 ebbcba
+term15 e0def4
+primary eb6f92
+onPrimary 191724
+primaryContainer 583145
+onPrimaryContainer e6a6c3
+primaryFixed 583145
+primaryFixedDim eb6f92
+onPrimaryFixed e6a6c3
+onPrimaryFixedVariant e6a6c3
+secondary c4a7e7
+onSecondary 191724
+secondaryContainer 4c425e
+onSecondaryContainer d2c2ee
+secondaryFixed 4c425e
+secondaryFixedDim c4a7e7
+onSecondaryFixed d2c2ee
+onSecondaryFixedVariant d2c2ee
+tertiary 9ccfd8
+onTertiary 191724
+tertiaryContainer 404e5a
+onTertiaryContainer bed6e6
+tertiaryFixed 404e5a
+tertiaryFixedDim 9ccfd8
+onTertiaryFixed bed6e6
+onTertiaryFixedVariant bed6e6
+error eb6f92
+onError 191724
+errorContainer 583145
+onErrorContainer e6a6c3
+success 31748f
+onSuccess e0def4
+successContainer 203344
+onSuccessContainer 88a9c2
diff --git a/home/caelestia/scheme.json b/home/caelestia/scheme.json
@@ -0,0 +1,82 @@
+{
+ "name": "rose-pine",
+ "flavour": "rose-pine-dark",
+ "mode": "dark",
+ "variant": "tonalspot",
+ "colours": {
+ "background": "191724",
+ "surface": "191724",
+ "surfaceDim": "191724",
+ "surfaceBright": "524f67",
+ "surfaceContainerLowest": "16141f",
+ "surfaceContainerLow": "1f1d2e",
+ "surfaceContainer": "26233a",
+ "surfaceContainerHigh": "2a273f",
+ "surfaceContainerHighest": "403d52",
+ "surfaceVariant": "26233a",
+ "onBackground": "e0def4",
+ "onSurface": "e0def4",
+ "onSurfaceVariant": "908caa",
+ "outline": "6e6a86",
+ "outlineVariant": "403d52",
+ "inverseSurface": "e0def4",
+ "inverseOnSurface": "191724",
+ "inversePrimary": "b4637a",
+ "surfaceTint": "eb6f92",
+ "shadow": "000000",
+ "scrim": "000000",
+ "primary_paletteKeyColor": "eb6f92",
+ "secondary_paletteKeyColor": "c4a7e7",
+ "tertiary_paletteKeyColor": "9ccfd8",
+ "neutral_paletteKeyColor": "6e6a86",
+ "neutral_variant_paletteKeyColor": "908caa",
+ "term0": "191724",
+ "term1": "eb6f92",
+ "term2": "31748f",
+ "term3": "f6c177",
+ "term4": "9ccfd8",
+ "term5": "c4a7e7",
+ "term6": "ebbcba",
+ "term7": "e0def4",
+ "term8": "6e6a86",
+ "term9": "eb6f92",
+ "term10": "31748f",
+ "term11": "f6c177",
+ "term12": "9ccfd8",
+ "term13": "c4a7e7",
+ "term14": "ebbcba",
+ "term15": "e0def4",
+ "primary": "eb6f92",
+ "onPrimary": "191724",
+ "primaryContainer": "583145",
+ "onPrimaryContainer": "e6a6c3",
+ "primaryFixed": "583145",
+ "primaryFixedDim": "eb6f92",
+ "onPrimaryFixed": "e6a6c3",
+ "onPrimaryFixedVariant": "e6a6c3",
+ "secondary": "c4a7e7",
+ "onSecondary": "191724",
+ "secondaryContainer": "4c425e",
+ "onSecondaryContainer": "d2c2ee",
+ "secondaryFixed": "4c425e",
+ "secondaryFixedDim": "c4a7e7",
+ "onSecondaryFixed": "d2c2ee",
+ "onSecondaryFixedVariant": "d2c2ee",
+ "tertiary": "9ccfd8",
+ "onTertiary": "191724",
+ "tertiaryContainer": "404e5a",
+ "onTertiaryContainer": "bed6e6",
+ "tertiaryFixed": "404e5a",
+ "tertiaryFixedDim": "9ccfd8",
+ "onTertiaryFixed": "bed6e6",
+ "onTertiaryFixedVariant": "bed6e6",
+ "error": "eb6f92",
+ "onError": "191724",
+ "errorContainer": "583145",
+ "onErrorContainer": "e6a6c3",
+ "success": "31748f",
+ "onSuccess": "e0def4",
+ "successContainer": "203344",
+ "onSuccessContainer": "88a9c2"
+ }
+}
diff --git a/home/caelestia/shell-tokens.json b/home/caelestia/shell-tokens.json
@@ -0,0 +1,3 @@
+{
+ "sizes": { "bar": { "innerWidth": 28 } }
+}
diff --git a/home/default.nix b/home/default.nix
@@ -0,0 +1,20 @@
+# home/default.nix — home-manager for the laptop user (imported from flake.nix).
+{ config, pkgs, lib, user, ... }:
+{
+ imports = [
+ ./modules/hyprland.nix # compositor config (Lua) and the carried shortcuts
+ ./modules/caelestia.nix # programs.caelestia, shell.json, the Rosé Pine scheme
+ ./modules/terminal.nix # kitty, fonts
+ ./modules/tools.nix # toolbox runtime closure, python env, dotfiles links
+ ./modules/gtk.nix # Yaru-purple icons, cursor, prefer-dark
+ ];
+
+ home = {
+ username = user;
+ homeDirectory = "/home/${user}";
+ stateVersion = "26.05";
+ };
+
+ programs.home-manager.enable = true;
+ xdg.enable = true;
+}
diff --git a/home/hypr/bindings.lua b/home/hypr/bindings.lua
@@ -0,0 +1,220 @@
+-- bindings.lua — shortcuts/bindings.lua from the vault, adapted for NixOS:
+-- * /home/daemon-sec/.local/bin became o.bin (the user is daemonsec here);
+-- * Omarchy-only commands (Omamail, the lock-screen explorer) show what they
+-- did instead of failing silently; tools not in the carried bin/
+-- (clipboard-backup, omarchy-menu-tmux-keybindings) run when present;
+-- * the stock Obsidian and YouTube binds on SUPER+SHIFT+O / Y are unbound
+-- before vault-open and bakx take those keys (on Omarchy both fired);
+-- * `require("hypr.caelestia")` moved to hyprland.lua, which loads
+-- caelestia.lua after this file.
+-- Everything else, comments included, is as carried.
+
+-- Keep only your personal keybinding overrides here. Add new bindings or
+-- unbind defaults before replacing them.
+
+-- Omamail (replaces the default Email webapp binding)
+hl.unbind("SUPER + SHIFT + E")
+o.bind("SUPER + SHIFT + E", "Omamail", "omarchy-not-ported Omamail 'Omarchy mail webapp'")
+
+-- Lock Screen Explorer (io.github.sirjul1337.lock-explorer)
+o.bind("SUPER + SHIFT + L", "Lock screen explorer", "omarchy-not-ported 'Lock screen explorer' 'omarchy-shell lock explore'")
+
+-- Trackpad on/off at the firmware level (the pad's corner touch-button is
+-- inert on Linux — see ~/trackpad-fix/README.md). LED lights when disabled.
+o.bind("SUPER + SHIFT + T", "Trackpad toggle", o.bin .. "/padx toggle --notify")
+
+-- ── Move floating windows with Super+Shift+arrows ────────────────────────────
+-- By default Super+Shift+arrows only *swap* tiled windows (a no-op on a
+-- floating window). This keeps the tiled-swap behaviour, but when the focused
+-- window is floating it nudges the window in that direction instead. Runs the
+-- branch at keypress time as a Lua function dispatcher (same mechanism Omarchy
+-- uses for its universal clipboard keys).
+local move_step = 60 -- pixels per nudge for floating windows
+
+local function move_or_swap(direction, dx, dy)
+ return function()
+ local win = hl.get_active_window()
+ if win and win.floating then
+ hl.dispatch(hl.dsp.window.move({ x = dx, y = dy, relative = true }))
+ else
+ hl.dispatch(hl.dsp.window.swap({ direction = direction }))
+ end
+ end
+end
+
+hl.unbind("SUPER + SHIFT + LEFT")
+hl.unbind("SUPER + SHIFT + RIGHT")
+hl.unbind("SUPER + SHIFT + UP")
+hl.unbind("SUPER + SHIFT + DOWN")
+
+o.bind("SUPER + SHIFT + LEFT", "Move/swap window left", move_or_swap("l", -move_step, 0))
+o.bind("SUPER + SHIFT + RIGHT", "Move/swap window right", move_or_swap("r", move_step, 0))
+o.bind("SUPER + SHIFT + UP", "Move/swap window up", move_or_swap("u", 0, -move_step))
+o.bind("SUPER + SHIFT + DOWN", "Move/swap window down", move_or_swap("d", 0, move_step))
+
+
+-- ── Terminal cheat sheets (Kitty & Tmux) ─────────────────────────────────────
+-- SUPER+ALT+K opens the Kitty cheat sheet (K for Kitty).
+-- Tmux keybindings moved to SUPER+ALT+T (T for Tmux).
+hl.unbind("SUPER + ALT + K")
+o.bind("SUPER + ALT + K", "Kitty cheat sheet", "omarchy-menu-kitty")
+o.bind("SUPER + ALT + T", "Tmux keybindings", o.cmd_or_gone("omarchy-menu-tmux-keybindings", "Tmux keybindings", "the script is not in the carried bin/"))
+
+
+-- ── RK Royal Kludge R65 keyboard cheat sheet ─────────────────────────────────
+-- Same Quickshell popup as the rest of the K family: every Fn combo, the volume
+-- knob and the B/G mode switch of the R65 (66-key 65%, BT/2.4G/wired, mono/RGB).
+-- Pure reference; Enter copies the highlighted combo to the clipboard.
+-- On SUPER+CTRL+ALT+K to stay clear of the K family's taken slots
+-- (K=keys, ALT+K, CTRL+K, SHIFT+K); this 3-modifier combo was free, so no
+-- unbind is needed and existing K-family cheats are left untouched.
+o.bind("SUPER + CTRL + ALT + K", "RK R65 keyboard (cheat sheet)", "omarchy-menu-r65")
+
+-- ── Editor cheat sheets ─────────────────────────────────────────────────
+-- Mnemonic editor initials on two unused default/user combinations. These use
+-- the same searchable Omarchy menu as the K-family sheets; Enter copies the
+-- highlighted shortcut to the clipboard.
+o.bind("SUPER + ALT + E", "Emacs cheat sheet", "omarchy-menu-emacs")
+o.bind("SUPER + ALT + N", "Neovim / AstroNvim cheat sheet", "omarchy-menu-nvim")
+
+-- ── mpv cheat sheet ──────────────────────────────────────────────────────────
+-- Same searchable popup as the rest of the family: every DEFAULT mpv key
+-- binding (there is no ~/.config/mpv/input.conf on this box, so nothing is
+-- overridden). Enter copies the highlighted key to the clipboard.
+-- SUPER+ALT+M keeps the mnemonic-initial pattern of ALT+E / ALT+N and was free:
+-- SUPER+M is Window mode, SUPER+SHIFT+M is Music and SUPER+SHIFT+ALT+M is the
+-- music TUI, so no unbind is needed.
+-- Terminal twin of the same card: `mpv-cheat` (sectioned, in colour).
+o.bind("SUPER + ALT + M", "mpv cheat sheet", "omarchy-menu-mpv")
+
+-- ── zathura cheat sheet ──────────────────────────────────────────────────────
+-- Same searchable popup as the rest of the family: every DEFAULT zathura key
+-- (the zathurarc here sets no `map` lines), the : commands, the settings worth
+-- flipping mid-document, and the zimg / zbook wrappers that cover the formats
+-- this poppler build cannot open. Enter copies the highlighted key.
+-- SUPER+ALT+Z keeps the mnemonic-initial pattern of ALT+E / ALT+N / ALT+M and
+-- was free: only SUPER+CTRL+Z and SUPER+CTRL+ALT+Z (Reset zoom) use Z, so no
+-- unbind is needed.
+o.bind("SUPER + ALT + Z", "Zathura cheat sheet", "omarchy-menu-zathura")
+
+-- ── NetrunnerVault + dotfiles ───────────────────────────────────────────────
+-- SUPER+SHIFT+O opens the vault in Obsidian (focuses it if already open).
+-- SUPER+SHIFT+Y banks BOTH repos up with `bakx`: ports live config into
+-- daemon-sec-dotfiles, commits each delta long-hand (every added/modified/
+-- removed path in the message), and pushes. Fast-forward only — a real
+-- divergence stops and tells you, never an auto-merge.
+--
+-- These replaced the daemon-sec.vault / daemon-sec.dotfiles bar plugins
+-- (retired 2026-09-09): backing up is a manual act now, not a daily timer.
+-- A keybind run is headless, so bakx reports through notify-send; run it in a
+-- terminal for the full breakdown, or `bakx status` to just look.
+local BIN = o.bin
+hl.unbind("SUPER + SHIFT + O") -- stock Obsidian bind
+hl.unbind("SUPER + SHIFT + Y") -- stock YouTube bind
+o.bind("SUPER + SHIFT + O", "Open NetrunnerVault (Obsidian)", BIN .. "/vault-open")
+o.bind("SUPER + SHIFT + Y", "Bank up vault + dotfiles (bakx)", BIN .. "/bakx")
+
+-- SUPER+SHIFT+V backs up clipboard history + saved images: tar + xz + gpg
+-- to the vault (see clipboard-backup). SUPER+V (Universal paste) and
+-- SUPER+CTRL+V (Clipboard manager, opens History) are stock Omarchy binds
+-- and were left untouched.
+o.bind("SUPER + SHIFT + V", "Backup clipboard history (encrypted)", o.cmd_or_gone(BIN .. "/clipboard-backup", "Backup clipboard history (encrypted)", "clipboard-backup is not in the carried bin/"))
+
+-- ── Window Mode: a modal for moving/resizing/snapping windows ────────────────
+-- SUPER+M enters a submap where bare keys manage the focused window, so you can
+-- rapid-fire moves and snaps without holding modifiers. Reuses move_or_swap
+-- (above) so movement works on tiled *and* floating windows. Floating snaps go
+-- through ~/.local/bin/winsnap (Hyprland only auto-tiles; floating has no snap).
+-- Escape / Return / q / SUPER+M all exit. If ever stuck:
+-- hyprctl dispatch 'hl.dsp.submap("reset")'
+local WINSNAP = BIN .. "/winsnap"
+local rstep = 40 -- px per resize tick in window mode
+
+local function resize_by(dx, dy)
+ return hl.dsp.window.resize({ x = dx, y = dy, relative = true })
+end
+
+o.bind("SUPER + M", "Window mode: hjkl move · S+hjkl halves · 1-4 quarters · C+hjkl resize · f fill · c center · t float · g group · Esc exit", function()
+ hl.dispatch(hl.dsp.submap("window"))
+ hl.dispatch(hl.dsp.exec_cmd(
+ "notify-send -t 2500 -a 'Window mode' 'Window mode' "
+ .. "'hjkl move · Ctrl+hjkl resize · Shift+hjkl halves\\n1-4 quarters · f full · c center · t float · g group · Esc exit'"))
+end)
+
+hl.define_submap("window", function()
+ -- Move / swap (works on floating and tiled), auto-repeat while held.
+ hl.bind("h", move_or_swap("l", -move_step, 0), { repeating = true })
+ hl.bind("j", move_or_swap("d", 0, move_step), { repeating = true })
+ hl.bind("k", move_or_swap("u", 0, -move_step), { repeating = true })
+ hl.bind("l", move_or_swap("r", move_step, 0), { repeating = true })
+ hl.bind("left", move_or_swap("l", -move_step, 0), { repeating = true })
+ hl.bind("down", move_or_swap("d", 0, move_step), { repeating = true })
+ hl.bind("up", move_or_swap("u", 0, -move_step), { repeating = true })
+ hl.bind("right", move_or_swap("r", move_step, 0), { repeating = true })
+
+ -- Resize: Ctrl + hjkl (h/l = width, k/j = height).
+ hl.bind("CTRL + h", resize_by(-rstep, 0), { repeating = true })
+ hl.bind("CTRL + l", resize_by(rstep, 0), { repeating = true })
+ hl.bind("CTRL + k", resize_by(0, -rstep), { repeating = true })
+ hl.bind("CTRL + j", resize_by(0, rstep), { repeating = true })
+
+ -- Floating snap: Shift + hjkl = left/bottom/top/right half.
+ hl.bind("SHIFT + h", hl.dsp.exec_cmd(WINSNAP .. " left"))
+ hl.bind("SHIFT + j", hl.dsp.exec_cmd(WINSNAP .. " bottom"))
+ hl.bind("SHIFT + k", hl.dsp.exec_cmd(WINSNAP .. " top"))
+ hl.bind("SHIFT + l", hl.dsp.exec_cmd(WINSNAP .. " right"))
+
+ -- Quarters + fill + recenter.
+ hl.bind("1", hl.dsp.exec_cmd(WINSNAP .. " tl"))
+ hl.bind("2", hl.dsp.exec_cmd(WINSNAP .. " tr"))
+ hl.bind("3", hl.dsp.exec_cmd(WINSNAP .. " bl"))
+ hl.bind("4", hl.dsp.exec_cmd(WINSNAP .. " br"))
+ hl.bind("f", hl.dsp.exec_cmd(WINSNAP .. " full"))
+ hl.bind("c", hl.dsp.exec_cmd(WINSNAP .. " center"))
+
+ -- Toggles that fit the same modal.
+ hl.bind("t", hl.dsp.window.float({ action = "toggle" }))
+ hl.bind("g", hl.dsp.group.toggle())
+ hl.bind("v", hl.dsp.layout("togglesplit"))
+
+ -- Exits.
+ hl.bind("escape", hl.dsp.submap("reset"))
+ hl.bind("return", hl.dsp.submap("reset"))
+ hl.bind("q", hl.dsp.submap("reset"))
+ hl.bind("SUPER + M", hl.dsp.submap("reset"))
+end)
+
+-- ── Yazi file manager ────────────────────────────────────────────────────────
+-- TUI file manager in a kitty window. SUPER+ALT+Y was free by default.
+-- (yazi is not in the toolbox closure; add it to home/modules/tools.nix to use this.)
+o.bind("SUPER + ALT + Y", "Yazi (file manager)", { launch = "kitty --class yazi -e yazi" })
+
+-- SUPER+W remains Omarchy's default close shortcut. SUPER+Q is a convenient
+-- alias: a swallowed MPV window closes independently, revealing Yazi beneath.
+o.bind("SUPER + Q", "Close window", hl.dsp.window.close())
+
+-- ── Dropdown terminal (quake style) ──────────────────────────────────────────
+-- SUPER+` slides a kitty down from the top edge on special:dropterm, riding the
+-- springy slidevert animation from looknfeel.lua. ~/.local/bin/dropterm spawns
+-- it on first use and toggles it afterwards; a 4-finger swipe down does the
+-- same (gesture in looknfeel.lua). SUPER+` was free by default.
+o.window("^dropterm$", {
+ workspace = "special:dropterm",
+ float = true,
+ -- size/move take muParser expression strings ("x y"), with monitor_w/h and
+ -- window_w/h as variables (percent syntax is the deprecated hyprlang form).
+ size = "monitor_w*0.94 monitor_h*0.58",
+ move = "monitor_w*0.03 monitor_h*0.04",
+})
+o.bind("SUPER + GRAVE", "Dropdown terminal", BIN .. "/dropterm")
+hl.gesture({ fingers = 4, direction = "down", action = "special", workspace_name = "dropterm" })
+
+-- ── AstroNvim ────────────────────────────────────────────────────────────────
+-- Opens the editor straight to its dashboard in a kitty window of its own.
+-- SUPER+SHIFT+N ("Editor") already reaches Neovim via omarchy-launch-editor,
+-- but that follows ~/.local/state/omarchy/defaults/editor and gives the window
+-- a generic app-id. This is a direct, dedicated launcher with its own class, so
+-- window rules can target it and it keeps the SUPER+ALT+<initial> pattern of
+-- ALT+Y (yazi). SUPER+ALT+A was free by default, so no unbind is needed.
+-- (neovim is not in the toolbox closure; add it to home/modules/tools.nix to use this.)
+o.bind("SUPER + ALT + A", "AstroNvim", { launch = "kitty --class astronvim -e nvim" })
diff --git a/home/hypr/caelestia.lua b/home/hypr/caelestia.lua
@@ -0,0 +1,56 @@
+-- caelestia.lua — shortcuts/caelestia.lua from the vault, adapted:
+-- * the ~/.local/state/caelestia-mode/enabled gate is gone: Caelestia is the
+-- only shell here, so these keys are always live;
+-- * `qs -p … ipc call` became `caelestia shell <target> <fn> …`;
+-- * this shell version has no audio raise/lower IPC and no per-panel toggle,
+-- so volume goes through wpctl (the OSD still reacts) and the Wi-Fi /
+-- Bluetooth / Audio / Battery panels open the sidebar, where they live now;
+-- * the Omarchy root menu does not exist on NixOS.
+local SHELL = "caelestia shell "
+
+-- Rebind a key the stock binds already use: unbind first, then bind.
+local function take(keys, description, command, options)
+ hl.unbind(keys)
+ o.bind(keys, description, command, options)
+end
+
+-- ── Drawers ──────────────────────────────────────────────────────────────────
+take("SUPER + SPACE", "Caelestia launcher", hl.dsp.global("caelestia:launcher"))
+take("SUPER + SHIFT + SPACE", "Omarchy menu", "omarchy-not-ported 'Omarchy menu' 'the Omarchy root menu'")
+take("SUPER + ESCAPE", "Caelestia session menu", hl.dsp.global("caelestia:session"))
+o.bind("SUPER + D", "Caelestia dashboard", hl.dsp.global("caelestia:dashboard"))
+o.bind("SUPER + A", "Caelestia sidebar", hl.dsp.global("caelestia:sidebar"))
+o.bind("SUPER + U", "Caelestia utilities", hl.dsp.global("caelestia:utilities"))
+o.bind("SUPER + I", "Caelestia settings", hl.dsp.global("caelestia:nexus"))
+
+-- ── Panels (replace the Omarchy shell popups on the same keys) ───────────────
+take("SUPER + CTRL + W", "Wi-Fi panel", SHELL .. "drawers toggle sidebar")
+take("SUPER + CTRL + B", "Bluetooth panel", SHELL .. "drawers toggle sidebar")
+take("SUPER + CTRL + A", "Audio panel", SHELL .. "drawers toggle sidebar")
+o.bind("SUPER + ALT + B", "Battery panel", SHELL .. "drawers toggle sidebar")
+
+-- ── Notifications ────────────────────────────────────────────────────────────
+take("SUPER + COMMA", "Clear notifications", hl.dsp.global("caelestia:clearNotifs"))
+take("SUPER + CTRL + COMMA", "Do not disturb", SHELL .. "notifs toggleDnd")
+
+-- ── OSD: volume, brightness, media ───────────────────────────────────────────
+local held = { locked = true, repeating = true }
+local locked = { locked = true }
+take("XF86AudioRaiseVolume", "Volume up", "wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 5%+", held)
+take("XF86AudioLowerVolume", "Volume down", "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-", held)
+take("XF86AudioMute", "Mute", "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle", locked)
+take("XF86AudioMicMute", "Mute microphone", "wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle", locked)
+take("XF86MonBrightnessUp", "Brightness up", SHELL .. "brightness set +5%", held)
+take("XF86MonBrightnessDown", "Brightness down", SHELL .. "brightness set 5%-", held)
+take("XF86AudioPlay", "Play/pause", hl.dsp.global("caelestia:mediaToggle"), locked)
+take("XF86AudioPause", "Play/pause", hl.dsp.global("caelestia:mediaToggle"), locked)
+take("XF86AudioNext", "Next track", hl.dsp.global("caelestia:mediaNext"), locked)
+take("XF86AudioPrev", "Previous track", hl.dsp.global("caelestia:mediaPrev"), locked)
+
+-- ── Look ─────────────────────────────────────────────────────────────────────
+-- Frosted glass for the translucent Caelestia layers.
+hl.layer_rule({
+ match = { namespace = "^caelestia-" },
+ blur = true,
+ ignore_alpha = 0.82,
+})
diff --git a/home/hypr/core.lua b/home/hypr/core.lua
@@ -0,0 +1,55 @@
+-- core.lua — compositor settings for the Valeon II 17.
+--
+-- Stock Hyprland except what the migration prompt pins: panel mode and scale,
+-- keyboard options, square corners, blur on, Rosé Pine borders, the stock
+-- Caelestia layer rules. The animation rice from the old looknfeel.lua is
+-- deliberately not carried.
+
+-- Panel at 1.25; anything plugged in later gets its preferred mode.
+hl.monitor({ output = "eDP-1", mode = "2560x1440@240", position = "0x0", scale = 1.25 })
+hl.monitor({ output = "", mode = "preferred", position = "auto", scale = 1 })
+
+-- Cursor theme files come from the dotfiles checkout (home/modules/tools.nix).
+hl.env("XCURSOR_THEME", "modernxp-retro-black")
+hl.env("XCURSOR_SIZE", "24")
+hl.env("HYPRCURSOR_THEME", "modernxp-retro-black-hyprcursor")
+hl.env("HYPRCURSOR_SIZE", "24")
+
+hl.config({
+ general = {
+ gaps_in = 5, -- Hyprland defaults, stated because the gaps toggle restores them
+ gaps_out = 20,
+ col = {
+ -- Rosé Pine: love → iris at 45°; inactive the stock grey
+ active_border = { colors = { "rgba(eb6f92ee)", "rgba(c4a7e7ee)" }, angle = 45 },
+ inactive_border = "rgba(595959aa)",
+ },
+ layout = "dwindle",
+ },
+ decoration = {
+ rounding = 0,
+ blur = { enabled = true },
+ },
+ dwindle = { preserve_split = true },
+ input = {
+ kb_layout = "us",
+ kb_options = "compose:caps,shift:both_capslock_cancel",
+ follow_mouse = 1,
+ },
+ misc = {
+ force_default_wallpaper = 0,
+ disable_hyprland_logo = true,
+ },
+})
+
+-- Stock Caelestia layer rules (caelestia-dots/caelestia, hypr/hyprland/rules.lua).
+-- The blur / ignore_alpha rule for ^caelestia- is in caelestia.lua, as carried.
+hl.layer_rule({ match = { namespace = "caelestia-(border-exclusion|area-picker)" }, no_anim = true })
+hl.layer_rule({ match = { namespace = "caelestia-(drawers|background)" }, animation = "fade" })
+
+hl.on("hyprland.start", function()
+ -- uwsm waits for the compositor to hand over its environment
+ -- (HYPRLAND_INSTANCE_SIGNATURE and friends) before graphical-session.target.
+ hl.exec_cmd("uwsm finalize")
+ hl.exec_cmd("hyprctl setcursor modernxp-retro-black-hyprcursor 24")
+end)
diff --git a/home/hypr/defaults.lua b/home/hypr/defaults.lua
@@ -0,0 +1,289 @@
+-- defaults.lua — the stock Omarchy binds as they were live on the Arch install
+-- (vault shortcuts/keybinds.txt), re-expressed for NixOS + Caelestia with the
+-- same keys and descriptions. bindings.lua, lid.lua and caelestia.lua load
+-- after this file and unbind-then-rebind the keys they take over.
+--
+-- Keys whose Omarchy service has no counterpart here (reminders, bar panels,
+-- Herdr, OCR, …) keep their place but run `omarchy-not-ported`, which names
+-- the gap in a notification instead of failing silently. Apps not in the
+-- toolbox closure (spotify, lazydocker, btop, tmux, …) are treated the same.
+local o = require("omarchy")
+
+local function launch(cmd) return "uwsm app -- " .. cmd end
+local function web(url) return launch("firefox --new-window " .. url) end
+local function gone(description, was)
+ return string.format("omarchy-not-ported %q %q", description, was or "")
+end
+local function resize(x, y) return hl.dsp.window.resize({ x = x, y = y, relative = true }) end
+
+local sidebar = "caelestia shell drawers toggle sidebar"
+local locked = { locked = true }
+local held = { locked = true, repeating = true }
+local mouse = { mouse = true }
+
+-- ── Apps ─────────────────────────────────────────────────────────────────────
+o.bind("SUPER + RETURN", "Terminal", launch("kitty"))
+o.bind("SUPER + SHIFT + RETURN", "Browser", launch("firefox"))
+o.bind("SUPER + SHIFT + B", "Browser", launch("firefox"))
+o.bind("SUPER + SHIFT + ALT + B", "Browser (private)", launch("firefox --private-window"))
+o.bind("SUPER + SHIFT + F", "File manager", launch("nautilus --new-window"))
+o.bind("SUPER + SHIFT + ALT + F", "File manager (cwd)", launch("nautilus --new-window"))
+o.bind("SUPER + SHIFT + N", "Editor", launch("kitty -e ${EDITOR:-nano}"))
+o.bind("SUPER + SHIFT + O", "Obsidian", launch("obsidian")) -- bindings.lua rebinds (vault-open)
+o.bind("SUPER + SHIFT + ALT + M", "Music TUI", launch("kitty --class rmpc -e rmpc"))
+o.bind("SUPER + SHIFT + CTRL + A", "Agent", launch("kitty --class agent -e claude"))
+o.bind("CTRL + SUPER + Q", "Calculator", launch("kitty --class calculator -e qalc"))
+o.bind("XF86Calculator", "Calculator", launch("kitty --class calculator -e qalc"))
+o.bind("SUPER + SHIFT + M", "Music", gone("Music", "spotify"))
+o.bind("SUPER + SHIFT + D", "Docker", gone("Docker", "lazydocker"))
+o.bind("SUPER + SHIFT + G", "Signal", gone("Signal", "signal-desktop"))
+o.bind("SUPER + SHIFT + SLASH", "Passwords", gone("Passwords", "1password"))
+o.bind("SUPER + SHIFT + W", "Omawrite", gone("Omawrite", "Omarchy writing webapp"))
+o.bind("SUPER + SHIFT + E", "Email", gone("Email", "Omarchy mail webapp")) -- bindings.lua rebinds (Omamail)
+o.bind("SUPER + SHIFT + ALT + E", "New email", gone("New email", "Omarchy mail webapp"))
+o.bind("CTRL + SUPER + T", "Activity", gone("Activity", "btop"))
+o.bind("ALT + SUPER + RETURN", "Tmux", gone("Tmux", "tmux is not in the toolbox closure"))
+o.bind("CTRL + SUPER + RETURN", "Herdr", gone("Herdr"))
+o.bind("CTRL + SUPER + K", "Herdr keybindings", gone("Herdr keybindings"))
+
+-- ── Web apps (Firefox windows) ───────────────────────────────────────────────
+o.bind("SUPER + SHIFT + A", "ChatGPT", web("https://chatgpt.com"))
+o.bind("SUPER + SHIFT + ALT + A", "Grok", web("https://grok.com"))
+o.bind("SUPER + SHIFT + C", "Calendar", web("https://calendar.google.com"))
+o.bind("SUPER + SHIFT + Y", "YouTube", web("https://youtube.com")) -- bindings.lua rebinds (bakx)
+o.bind("SUPER + SHIFT + ALT + G", "WhatsApp", web("https://web.whatsapp.com"))
+o.bind("SUPER + SHIFT + CTRL + G", "Google Messages", web("https://messages.google.com/web"))
+o.bind("SUPER + SHIFT + X", "X", web("https://x.com"))
+o.bind("SUPER + SHIFT + ALT + X", "X Post", web("https://x.com/compose/post"))
+o.bind("SUPER + SHIFT + P", "Google Photos", web("https://photos.google.com"))
+o.bind("SUPER + SHIFT + S", "Google Maps", web("https://maps.google.com"))
+
+-- ── Shell: menus, panels, notifications ──────────────────────────────────────
+o.bind("SUPER + SPACE", "Omarchy menu", hl.dsp.global("caelestia:launcher")) -- caelestia.lua rebinds
+o.bind("ALT + SUPER + SPACE", "Apps menu", hl.dsp.global("caelestia:launcher"))
+o.bind("SUPER + ESCAPE", "System menu", hl.dsp.global("caelestia:session")) -- caelestia.lua rebinds
+o.bind("CTRL + SUPER + P", "Power", hl.dsp.global("caelestia:session"))
+o.bind("XF86PowerOff", "Power menu", hl.dsp.global("caelestia:session"), locked)
+o.bind("SUPER + K", "Keybindings", "keybinds-menu")
+o.bind("SUPER + SHIFT + SPACE", "Toggle top bar", gone("Toggle top bar", "the Caelestia bar has no toggle"))
+o.bind("SUPER + SHIFT + CTRL + SPACE", "Theme menu", gone("Theme menu", "the theme is static Rosé Pine here"))
+o.bind("CTRL + SUPER + SPACE", "Background switcher", "caelestia wallpaper -r")
+o.bind("CTRL + SUPER + O", "Toggle menu", gone("Toggle menu"))
+o.bind("CTRL + SUPER + D", "Display", gone("Display", "omarchy display menu"))
+o.bind("CTRL + SUPER + H", "Hardware menu", gone("Hardware menu"))
+o.bind("CTRL + SUPER + S", "Share", gone("Share"))
+o.bind("CTRL + SUPER + PERIOD", "Transcode", gone("Transcode"))
+o.bind("CTRL + SUPER + W", "Network", sidebar) -- caelestia.lua rebinds
+o.bind("CTRL + SUPER + B", "Bluetooth", sidebar) -- caelestia.lua rebinds
+o.bind("CTRL + SUPER + A", "Audio", sidebar) -- caelestia.lua rebinds
+o.bind("CTRL + SUPER + E", "Emojis", "caelestia emoji")
+o.bind("CTRL + SUPER + V", "Clipboard manager", "caelestia clipboard")
+o.bind("CTRL + SUPER + C", "Capture menu", "caelestia shell picker openFreeze")
+o.bind("CTRL + SUPER + N", "Toggle nightlight", "nightlight-toggle")
+o.bind("CTRL + SUPER + I", "Toggle locking on idle", "caelestia shell idleInhibitor toggle")
+o.bind("CTRL + SUPER + L", "Lock system", hl.dsp.global("caelestia:lock"))
+o.bind("CTRL + SUPER + R", "Set reminder", gone("Set reminder"))
+o.bind("CTRL + ALT + SUPER + R", "Show reminders", gone("Show reminders"))
+o.bind("SHIFT + CTRL + SUPER + R", "Clear reminders", gone("Clear reminders"))
+o.bind("CTRL + ALT + SUPER + B", "Show battery remaining", "nixdaemon-show battery")
+o.bind("CTRL + ALT + SUPER + T", "Show time", "nixdaemon-show time")
+o.bind("CTRL + ALT + SUPER + D", "Calendar", "nixdaemon-show calendar")
+o.bind("CTRL + ALT + SUPER + W", "Toggle weather", "caelestia shell drawers toggle dashboard")
+o.bind("SUPER + comma", "Dismiss last notification", hl.dsp.global("caelestia:clearNotifs")) -- caelestia.lua rebinds
+o.bind("SUPER + SHIFT + comma", "Dismiss all notifications", hl.dsp.global("caelestia:clearNotifs"))
+o.bind("CTRL + SUPER + comma", "Toggle silencing notifications", "caelestia shell notifs toggleDnd") -- caelestia.lua rebinds
+o.bind("ALT + SUPER + comma", "Invoke last notification", gone("Invoke last notification"))
+o.bind("SHIFT + ALT + SUPER + comma", "Open notification history", sidebar)
+for i = 1, 9 do
+ o.bind("CTRL + SUPER + code:" .. (9 + i), "Bar panel " .. i, gone("Bar panel " .. i, "Omarchy bar panels"))
+end
+
+-- ── Capture ──────────────────────────────────────────────────────────────────
+o.bind("PRINT", "Screenshot", hl.dsp.global("caelestia:screenshot"))
+o.bind("ALT + PRINT", "Screenrecording", "caelestia record")
+o.bind("SUPER + PRINT", "Color picker", "hyprpicker -a")
+o.bind("CTRL + SUPER + PRINT", "Extract text (OCR) from screenshot", gone("Extract text (OCR) from screenshot", "tesseract"))
+
+-- ── Media and hardware keys ──────────────────────────────────────────────────
+o.bind("XF86AudioRaiseVolume", "Volume up", "wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 5%+", held)
+o.bind("XF86AudioLowerVolume", "Volume down", "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-", held)
+o.bind("ALT + XF86AudioRaiseVolume", "Volume up precise", "wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 1%+", held)
+o.bind("ALT + XF86AudioLowerVolume", "Volume down precise", "wpctl set-volume @DEFAULT_AUDIO_SINK@ 1%-", held)
+o.bind("XF86AudioMute", "Mute", "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle", locked)
+o.bind("XF86AudioMicMute", "Mute microphone", "wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle", locked)
+o.bind("SHIFT + XF86AudioMute", "Switch audio output", "caelestia shell audio cycleOutput", locked)
+o.bind("XF86AudioPlay", "Play", hl.dsp.global("caelestia:mediaToggle"), locked)
+o.bind("XF86AudioPause", "Pause", hl.dsp.global("caelestia:mediaToggle"), locked)
+o.bind("XF86AudioNext", "Next track", hl.dsp.global("caelestia:mediaNext"), locked)
+o.bind("XF86AudioPrev", "Previous track", hl.dsp.global("caelestia:mediaPrev"), locked)
+o.bind("ALT + XF86AudioPlay", "Next track", hl.dsp.global("caelestia:mediaNext"), locked)
+o.bind("SHIFT + ALT + XF86AudioPlay", "Previous track", hl.dsp.global("caelestia:mediaPrev"), locked)
+o.bind("SHIFT + XF86AudioPlay", "Switch media source", gone("Switch media source", "playerctld shift"), locked)
+o.bind("SHIFT + XF86AudioPause", "Switch media source", gone("Switch media source", "playerctld shift"), locked)
+o.bind("XF86MonBrightnessUp", "Brightness up", "caelestia shell brightness set +5%", held)
+o.bind("XF86MonBrightnessDown", "Brightness down", "caelestia shell brightness set 5%-", held)
+o.bind("ALT + XF86MonBrightnessUp", "Brightness up precise", "caelestia shell brightness set +1%", held)
+o.bind("ALT + XF86MonBrightnessDown", "Brightness down precise", "caelestia shell brightness set 1%-", held)
+o.bind("SHIFT + XF86MonBrightnessUp", "Brightness maximum", "caelestia shell brightness set 100%", held)
+o.bind("SHIFT + XF86MonBrightnessDown", "Brightness minimum", "caelestia shell brightness set 1%", held)
+o.bind("XF86KbdBrightnessUp", "Keyboard brightness up", "brightnessctl -q -d '*kbd_backlight' set 1+", held)
+o.bind("XF86KbdBrightnessDown", "Keyboard brightness down", "brightnessctl -q -d '*kbd_backlight' set 1-", held)
+o.bind("XF86KbdLightOnOff", "Keyboard backlight cycle", "nixdaemon-show kbd-backlight-cycle", locked)
+o.bind("XF86Eject", "Eject media", gone("Eject media"), locked)
+o.bind("XF86TouchpadToggle", "Toggle touchpad", o.bin .. "/padx toggle --notify", locked)
+o.bind("XF86TouchpadOn", "Enable touchpad", o.bin .. "/padx on --notify", locked)
+o.bind("XF86TouchpadOff", "Disable touchpad", o.bin .. "/padx off --notify", locked)
+o.bind("switch:on:Lid Switch", "Laptop lid closed", o.bin .. "/lid-control close", locked) -- lid.lua rebinds
+o.bind("switch:off:Lid Switch", "Laptop lid opened", o.bin .. "/lid-control wake", locked) -- lid.lua rebinds
+
+-- ── Windows ──────────────────────────────────────────────────────────────────
+o.bind("SUPER + W", "Close window", hl.dsp.window.close())
+o.bind("CTRL + ALT + DELETE", "Close all windows", function()
+ for _, w in ipairs(hl.get_windows()) do
+ hl.dispatch(hl.dsp.window.close({ window = w }))
+ end
+end)
+o.bind("SUPER + F", "Full screen", hl.dsp.window.fullscreen({ mode = "fullscreen" }))
+o.bind("CTRL + SUPER + F", "Tiled full screen", hl.dsp.window.fullscreen_state({ internal = 0, client = 2 }))
+o.bind("ALT + SUPER + F", "Full width", hl.dsp.window.fullscreen({ mode = "maximized" }))
+o.bind("SUPER + T", "Toggle window floating/tiling", hl.dsp.window.float({ action = "toggle" }))
+o.bind("SUPER + O", "Pop window out (float & pin)", function()
+ hl.dispatch(hl.dsp.window.float({ action = "enable" }))
+ hl.dispatch(hl.dsp.window.pin({ action = "enable" }))
+end)
+o.bind("SUPER + P", "Pseudo window", hl.dsp.window.pseudo())
+o.bind("SUPER + J", "Toggle window split", hl.dsp.layout("togglesplit"))
+o.bind("SUPER + BACKSPACE", "Toggle window transparency", hl.dsp.window.set_prop({ prop = "opaque", value = "toggle" }))
+o.bind("CTRL + SUPER + BACKSPACE", "Toggle single-window square aspect", gone("Toggle single-window square aspect"))
+
+local gaps_on = true
+o.bind("SUPER + SHIFT + BACKSPACE", "Toggle window gaps", function()
+ gaps_on = not gaps_on
+ if gaps_on then
+ hl.config({ general = { gaps_in = 5, gaps_out = 20 } })
+ else
+ hl.config({ general = { gaps_in = 0, gaps_out = 0 } })
+ end
+end)
+
+local layout = "dwindle"
+o.bind("SUPER + L", "Toggle workspace layout", function()
+ layout = layout == "dwindle" and "master" or "dwindle"
+ hl.config({ general = { layout = layout } })
+end)
+
+-- Focus, swap, move
+o.bind("SUPER + LEFT", "Focus on left window", hl.dsp.focus({ direction = "left" }))
+o.bind("SUPER + RIGHT", "Focus on right window", hl.dsp.focus({ direction = "right" }))
+o.bind("SUPER + UP", "Focus on above window", hl.dsp.focus({ direction = "up" }))
+o.bind("SUPER + DOWN", "Focus on below window", hl.dsp.focus({ direction = "down" }))
+o.bind("SUPER + SHIFT + LEFT", "Swap window to the left", hl.dsp.window.swap({ direction = "left" })) -- bindings.lua rebinds
+o.bind("SUPER + SHIFT + RIGHT", "Swap window to the right", hl.dsp.window.swap({ direction = "right" }))
+o.bind("SUPER + SHIFT + UP", "Swap window up", hl.dsp.window.swap({ direction = "up" }))
+o.bind("SUPER + SHIFT + DOWN", "Swap window down", hl.dsp.window.swap({ direction = "down" }))
+o.bind("ALT + TAB", "Focus on next window", hl.dsp.window.cycle_next())
+o.bind("ALT + TAB", "Reveal active window on top", hl.dsp.window.bring_to_top())
+o.bind("SHIFT + ALT + TAB", "Focus on previous window", hl.dsp.window.cycle_next({ next = false }))
+o.bind("SHIFT + ALT + TAB", "Reveal active window on top", hl.dsp.window.bring_to_top())
+
+-- Resize (keycodes 20/21 = the -/= keys, layout independent, as Omarchy)
+o.bind("SUPER + code:20", "Expand window left", resize(-100, 0))
+o.bind("SUPER + code:21", "Shrink window left", resize(100, 0))
+o.bind("SUPER + SHIFT + code:20", "Shrink window up", resize(0, -100))
+o.bind("SUPER + SHIFT + code:21", "Expand window down", resize(0, 100))
+o.bind("ALT + SUPER + code:20", "Expand window left a little", resize(-50, 0))
+o.bind("ALT + SUPER + code:21", "Shrink window left a little", resize(50, 0))
+o.bind("SHIFT + ALT + SUPER + code:20", "Shrink window up a little", resize(0, -50))
+o.bind("SHIFT + ALT + SUPER + code:21", "Expand window down a little", resize(0, 50))
+o.bind("CTRL + SUPER + code:20", "Expand window left a lot", resize(-200, 0))
+o.bind("CTRL + SUPER + code:21", "Shrink window left a lot", resize(200, 0))
+o.bind("SHIFT + CTRL + SUPER + code:20", "Shrink window up a lot", resize(0, -200))
+o.bind("SHIFT + CTRL + SUPER + code:21", "Expand window down a lot", resize(0, 200))
+o.bind("SUPER + Home", "Restore window width", gone("Restore window width", "omarchy window width memory"))
+o.bind("ALT + SUPER + Home", "Save window width", gone("Save window width", "omarchy window width memory"))
+-- "Make webcam overlay smaller/larger" (ALT+SUPER, keycodes unknown): not carried; Omarchy webcam overlay.
+
+-- Mouse
+o.bind("SUPER + mouse:272", "Move window", hl.dsp.window.drag(), mouse)
+o.bind("SUPER + mouse:273", "Resize window", hl.dsp.window.resize(), mouse)
+o.bind("SUPER + mouse_down", "Scroll active workspace forward", hl.dsp.focus({ workspace = "e+1" }))
+o.bind("SUPER + mouse_up", "Scroll active workspace backward", hl.dsp.focus({ workspace = "e-1" }))
+
+-- Universal clipboard: terminals get CTRL+SHIFT, everything else CTRL
+local terminal_class = "^(kitty|dropterm|Alacritty|foot|org%.codeberg%.dnkl%.foot|com%.mitchellh%.ghostty)$"
+local function universal(key)
+ return function()
+ local w = hl.get_active_window()
+ local mods = (w and (w.class or ""):match(terminal_class)) and "CTRL SHIFT" or "CTRL"
+ hl.dispatch(hl.dsp.send_shortcut({ mods = mods, key = key }))
+ end
+end
+o.bind("SUPER + C", "Universal copy", universal("C"))
+o.bind("SUPER + V", "Universal paste", universal("V"))
+o.bind("SUPER + X", "Universal cut", universal("X"))
+
+-- ── Workspaces ───────────────────────────────────────────────────────────────
+for i = 1, 10 do
+ local code = "code:" .. (9 + i) -- keycodes 10..19 = keys 1..9,0 (layout independent, as Omarchy)
+ o.bind("SUPER + " .. code, "Switch to workspace " .. i, hl.dsp.focus({ workspace = i }))
+ o.bind("SUPER + SHIFT + " .. code, "Move window to workspace " .. i, hl.dsp.window.move({ workspace = i }))
+ o.bind("SUPER + SHIFT + ALT + " .. code, "Move window silently to workspace " .. i, hl.dsp.window.move({ workspace = i, follow = false }))
+end
+o.bind("SUPER + TAB", "Next workspace", hl.dsp.focus({ workspace = "e+1" }))
+o.bind("SUPER + SHIFT + TAB", "Previous workspace", hl.dsp.focus({ workspace = "e-1" }))
+o.bind("CTRL + SUPER + TAB", "Former workspace", hl.dsp.focus({ workspace = "previous" }))
+o.bind("SUPER + S", "Toggle scratchpad", hl.dsp.workspace.toggle_special("scratchpad"))
+o.bind("ALT + SUPER + S", "Move window to scratchpad", hl.dsp.window.move({ workspace = "special:scratchpad" }))
+o.bind("CTRL + ALT + TAB", "Focus on next monitor", hl.dsp.focus({ monitor = "+1" }))
+o.bind("SHIFT + CTRL + ALT + TAB", "Focus on previous monitor", hl.dsp.focus({ monitor = "-1" }))
+o.bind("SHIFT + ALT + SUPER + LEFT", "Move workspace to left monitor", hl.dsp.workspace.move({ monitor = "l" }))
+o.bind("SHIFT + ALT + SUPER + RIGHT", "Move workspace to right monitor", hl.dsp.workspace.move({ monitor = "r" }))
+o.bind("SHIFT + ALT + SUPER + UP", "Move workspace to up monitor", hl.dsp.workspace.move({ monitor = "u" }))
+o.bind("SHIFT + ALT + SUPER + DOWN", "Move workspace to down monitor", hl.dsp.workspace.move({ monitor = "d" }))
+
+-- ── Groups ───────────────────────────────────────────────────────────────────
+o.bind("SUPER + G", "Toggle window grouping", hl.dsp.group.toggle())
+o.bind("ALT + SUPER + G", "Move active window out of group", hl.dsp.window.move({ out_of_group = true }))
+o.bind("ALT + SUPER + TAB", "Next window in group", hl.dsp.group.next())
+o.bind("SHIFT + ALT + SUPER + TAB", "Previous window in group", hl.dsp.group.prev())
+o.bind("ALT + SUPER + mouse_down", "Next window in group", hl.dsp.group.next())
+o.bind("ALT + SUPER + mouse_up", "Previous window in group", hl.dsp.group.prev())
+for i = 1, 5 do
+ o.bind("ALT + SUPER + code:" .. (9 + i), "Switch to group window " .. i, hl.dsp.group.active({ index = i }))
+end
+o.bind("CTRL + SUPER + LEFT", "Move grouped window focus left", hl.dsp.group.move_window({ forward = false }))
+o.bind("CTRL + SUPER + RIGHT", "Move grouped window focus right", hl.dsp.group.move_window({ forward = true }))
+o.bind("ALT + SUPER + LEFT", "Move window to group on left", hl.dsp.window.move({ direction = "left", group_aware = true }))
+o.bind("ALT + SUPER + RIGHT", "Move window to group on right", hl.dsp.window.move({ direction = "right", group_aware = true }))
+o.bind("ALT + SUPER + UP", "Move window to group on top", hl.dsp.window.move({ direction = "up", group_aware = true }))
+o.bind("ALT + SUPER + DOWN", "Move window to group on bottom", hl.dsp.window.move({ direction = "down", group_aware = true }))
+
+-- ── Monitor scale and zoom ───────────────────────────────────────────────────
+local function rescale(delta)
+ return function()
+ local m = hl.get_active_monitor()
+ if not m then return end
+ local s = math.max(1, (m.scale or 1) + delta)
+ if m.name == "eDP-1" then
+ hl.monitor({ output = "eDP-1", mode = "2560x1440@240", position = "0x0", scale = s })
+ else
+ hl.monitor({ output = m.name, mode = "preferred", position = "auto", scale = s })
+ end
+ end
+end
+o.bind("SUPER + SLASH", "Monitor scaling up", rescale(0.25))
+o.bind("ALT + SUPER + SLASH", "Monitor scaling down", rescale(-0.25))
+
+local zoom = 1.0
+o.bind("CTRL + SUPER + Z", "Zoom in", function()
+ zoom = zoom + 0.5
+ hl.config({ cursor = { zoom_factor = zoom } })
+end)
+o.bind("CTRL + ALT + SUPER + Z", "Reset zoom", function()
+ zoom = 1.0
+ hl.config({ cursor = { zoom_factor = 1.0 } })
+end)
+
+-- Laptop display toggles were Omarchy monitor scripts; display recovery is lid.lua's SUPER+CTRL+F12.
+o.bind("CTRL + SUPER + Delete", "Toggle laptop display", gone("Toggle laptop display", "omarchy monitor script"))
+o.bind("CTRL + ALT + SUPER + Delete", "Toggle laptop display mirroring", gone("Toggle laptop display mirroring", "omarchy monitor script"))
diff --git a/home/hypr/lid.lua b/home/hypr/lid.lua
@@ -0,0 +1,8 @@
+-- lid.lua — shortcuts/lid.lua from the vault. Only the hard-coded old home
+-- (/home/daemon-sec) became o.bin.
+-- Replace the stock lock/clamshell bindings with selectable lid handling.
+hl.unbind("switch:on:Lid Switch")
+hl.unbind("switch:off:Lid Switch")
+o.bind("switch:on:Lid Switch", "Laptop lid closed", o.bin .. "/lid-control close", { locked = true })
+o.bind("switch:off:Lid Switch", "Laptop lid opened", o.bin .. "/lid-control wake", { locked = true })
+o.bind("SUPER + CTRL + F12", "Recover laptop display", o.bin .. "/lid-control wake", { locked = true })
diff --git a/home/hypr/omarchy.lua b/home/hypr/omarchy.lua
@@ -0,0 +1,58 @@
+-- omarchy.lua — compatibility shim for the carried Omarchy-style Lua files.
+--
+-- Omarchy configures Hyprland in Lua on top of Hyprland's own `hl` API and adds
+-- a few `o.*` conveniences. bindings.lua, lid.lua and caelestia.lua were
+-- written against those, so the two helpers they use are provided here with
+-- the same shapes, plus one NixOS addition (o.cmd_or_gone). `o` is also set as
+-- a global, which is how the carried files reach it.
+local o = {}
+
+o.bin = os.getenv("HOME") .. "/.local/bin"
+
+local function to_dispatcher(command)
+ if type(command) == "string" then
+ return hl.dsp.exec_cmd(command)
+ end
+ if type(command) == "table" and command.launch then
+ -- launch = "cmd": run in its own systemd scope (uwsm) like Omarchy's launcher did
+ return hl.dsp.exec_cmd("uwsm app -- " .. command.launch)
+ end
+ return command -- a dispatcher (hl.dsp.*) or a Lua function
+end
+
+-- o.bind(keys, description, command, options)
+-- command: shell string, { launch = "…" }, hl.dsp.* dispatcher, or function
+-- options: hl.bind flags (locked, repeating, release, mouse, …)
+function o.bind(keys, description, command, options)
+ local flags = {}
+ for k, v in pairs(options or {}) do
+ flags[k] = v
+ end
+ if description and description ~= "" then
+ flags.description = description
+ end
+ return hl.bind(keys, to_dispatcher(command), flags)
+end
+
+-- o.window(match, rules): match is a class regex or a match table
+function o.window(match, rules)
+ local rule = { match = type(match) == "table" and match or { class = match } }
+ for k, v in pairs(rules) do
+ rule[k] = v
+ end
+ return hl.window_rule(rule)
+end
+
+-- o.cmd_or_gone(cmd, description, note): shell string that runs `cmd` when its
+-- program is on PATH and otherwise shows what the key used to do. For binds
+-- whose tool is not in the carried toolbox (yet).
+function o.cmd_or_gone(cmd, description, note)
+ local name = cmd:match("^%S+")
+ return string.format(
+ "sh -c 'command -v %s >/dev/null 2>&1 && exec %s || exec omarchy-not-ported %q %q'",
+ name, cmd, description, note or ""
+ )
+end
+
+_G.o = o
+return o
diff --git a/home/modules/caelestia.nix b/home/modules/caelestia.nix
@@ -0,0 +1,197 @@
+# home/modules/caelestia.nix — Caelestia Shell as bar, launcher, notifications,
+# OSD, lock and session menu; its CLI; the static Rosé Pine (M3) scheme.
+#
+# Settings are the carried shell.json with the Omarchy-isms swapped for NixOS
+# ones (vault caelestia/README.md "On NixOS"): launcher actions and session
+# commands, the audio app, the wallpaper directory. `useTwelveHourClock` no
+# longer exists in this shell version and was dropped. The shell is started by
+# the module's systemd user service under graphical-session.target (uwsm), so
+# there is no `caelestia shell -d` exec-once: two starts would mean two shells.
+{ config, pkgs, lib, inputs, ... }:
+let
+ system = pkgs.stdenv.hostPlatform.system;
+ hyprPkg = inputs.hyprland.packages.${system}.hyprland;
+
+ # The CLI knows schemes by name and re-reads their colours whenever the
+ # wallpaper changes, so the carried Rosé Pine mapping is registered as a real
+ # scheme (name rose-pine, flavour rose-pine-dark, mode dark). Its colours are
+ # ../caelestia/rose-pine-dark.txt, generated from the same scheme.json.
+ cli = (inputs.caelestia-cli.packages.${system}.default).overrideAttrs (old: {
+ patchPhase = (old.patchPhase or "") + ''
+ install -Dm644 ${../caelestia/rose-pine-dark.txt} src/caelestia/data/schemes/rose-pine/rose-pine-dark/dark.txt
+ '';
+ });
+
+ shell = (inputs.caelestia-shell.packages.${system}.with-cli).override {
+ caelestia-cli = cli;
+ hyprland = hyprPkg; # hyprctl from the same Hyprland the compositor runs
+ };
+
+ wallpaperDir = "${config.home.homeDirectory}/git/daemon-sec-dotfiles/home/.config/omarchy/backgrounds/rose-pine-dark";
+in
+{
+ programs.caelestia = {
+ enable = true;
+ package = shell;
+
+ cli = {
+ enable = true;
+ package = cli;
+ # Theming is static Rosé Pine from this repo; the CLI must not rewrite
+ # kitty, GTK, Hyprland or anything else when the wallpaper changes.
+ settings.theme = {
+ enableTerm = false;
+ enableHypr = false;
+ enableDiscord = false;
+ enableSpicetify = false;
+ enablePandora = false;
+ enableFuzzel = false;
+ enableBtop = false;
+ enableNvtop = false;
+ enableHtop = false;
+ enableGtk = false;
+ enableQt = false;
+ enableWarp = false;
+ enableChromium = false;
+ enableZed = false;
+ enableCava = false;
+ };
+ };
+
+ settings = {
+ appearance = {
+ font = {
+ clock = "Rubik";
+ workspaces = "Rubik";
+ headline.family = "Noto Sans";
+ title.family = "Noto Sans";
+ body.family = "Noto Sans";
+ label.family = "Noto Sans";
+ mono.family = "DMMono Nerd Font";
+ };
+ anim.durations.scale = 1.15;
+ transparency = {
+ enabled = true;
+ base = 0.85;
+ layers = 0.4;
+ };
+ };
+ general = {
+ apps = {
+ terminal = [ "kitty" ];
+ audio = [ "caelestia" "shell" "drawers" "toggle" "sidebar" ]; # was the Omarchy audio popup
+ playback = [ "mpv" ];
+ explorer = [ "nautilus" ];
+ };
+ idle = {
+ lockBeforeSleep = false;
+ inhibitWhenAudio = true;
+ timeouts = [ ];
+ };
+ };
+ # Omarchy drew the wallpaper; here Caelestia does. Colours stay static.
+ background = {
+ enabled = true;
+ wallpaperEnabled = true;
+ visualiser.enabled = false;
+ };
+ bar = {
+ persistent = true;
+ showOnHover = true;
+ workspaces = {
+ shown = 5;
+ activeIndicator = true;
+ showWindows = true;
+ activeTrail = true;
+ };
+ activeWindow.compact = false;
+ clock = {
+ showDate = true;
+ showIcon = true;
+ };
+ statusIcons = [
+ { id = "lockStatus"; enabled = true; }
+ { id = "audio"; enabled = true; }
+ { id = "microphone"; enabled = false; }
+ { id = "kbLayout"; enabled = false; }
+ { id = "network"; enabled = true; }
+ { id = "bluetooth"; enabled = true; }
+ { id = "battery"; enabled = true; }
+ ];
+ };
+ border = {
+ thickness = 5;
+ rounding = 0;
+ smoothing = 0;
+ };
+ dashboard = {
+ enabled = true;
+ showWeather = true;
+ performance.showGpu = true;
+ };
+ launcher = {
+ enabled = true;
+ maxShown = 8;
+ actions = [
+ { name = "Calculator"; icon = "calculate"; description = "Do simple math equations (powered by Qalc)"; command = [ "autocomplete" "calc" ]; enabled = true; dangerous = false; }
+ { name = "Wallpaper"; icon = "image"; description = "Pick a wallpaper"; command = [ "caelestia" "wallpaper" ]; enabled = true; dangerous = false; }
+ { name = "Lock"; icon = "lock"; description = "Lock the session"; command = [ "caelestia" "shell" "lock" "lock" ]; enabled = true; dangerous = false; }
+ { name = "Sleep"; icon = "bedtime"; description = "Suspend"; command = [ "systemctl" "suspend" ]; enabled = true; dangerous = false; }
+ { name = "Settings"; icon = "settings"; description = "Configure the shell"; command = [ "caelestia" "shell" "nexus" "open" ]; enabled = true; dangerous = false; }
+ { name = "Logout"; icon = "exit_to_app"; description = "Log out of the current session"; command = [ "uwsm" "stop" ]; enabled = true; dangerous = true; }
+ { name = "Reboot"; icon = "cached"; description = "Reboot the system"; command = [ "systemctl" "reboot" ]; enabled = true; dangerous = true; }
+ { name = "Shutdown"; icon = "power_settings_new"; description = "Shutdown the system"; command = [ "systemctl" "poweroff" ]; enabled = true; dangerous = true; }
+ ];
+ };
+ lock.enabled = true; # Caelestia is the lock screen here (Omarchy had hyprlock)
+ notifs = {
+ expire = true;
+ defaultExpireTimeout = 6000;
+ actionOnClick = true;
+ };
+ osd = {
+ enabled = true;
+ enableBrightness = true;
+ };
+ services = {
+ gpuType = "Generic"; # must be a string
+ smartScheme = false; # never derive colours from the wallpaper
+ defaultPlayer = "rmpc";
+ weatherLocation = "";
+ };
+ session = {
+ enabled = true;
+ icons.hibernate = "bedtime";
+ commands = {
+ logout = [ "uwsm" "stop" ];
+ shutdown = [ "systemctl" "poweroff" ];
+ hibernate = [ "systemctl" "suspend" ];
+ reboot = [ "systemctl" "reboot" ];
+ };
+ };
+ sidebar.enabled = true;
+ utilities.enabled = true;
+ paths.wallpaperDir = wallpaperDir;
+ };
+ };
+
+ # The scheme the shell reads at start: the carried Rosé Pine → M3 mapping.
+ # `caelestia scheme set -n rose-pine` rewrites this file with the same
+ # colours (home-manager backs the symlink up as .hm-bak when that happens).
+ home.file.".local/state/caelestia/scheme.json".source = ../caelestia/scheme.json;
+
+ # Carried for reference: this shell version has no loader for it (the bar
+ # width token came from the Omarchy-side build overlay).
+ home.file.".config/caelestia/shell-tokens.json".source = ../caelestia/shell-tokens.json;
+
+ # First wallpaper, only if none was ever chosen (Caelestia keeps the choice in state).
+ home.activation.caelestiaWallpaper = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
+ st="$HOME/.local/state/caelestia/wallpaper"
+ if [ ! -e "$st/path.txt" ]; then
+ wp=$(ls "${wallpaperDir}"/*.png 2>/dev/null | head -1 || true)
+ if [ -n "$wp" ]; then
+ mkdir -p "$st" && printf '%s' "$wp" > "$st/path.txt" && ln -sfn "$wp" "$st/current"
+ fi
+ fi
+ '';
+}
diff --git a/home/modules/gtk.nix b/home/modules/gtk.nix
@@ -0,0 +1,26 @@
+# home/modules/gtk.nix — icons, cursor, dark preference.
+# The cursor theme files are symlinked from the dotfiles checkout in tools.nix
+# (modernxp-retro-black for Xcursor, modernxp-retro-black-hyprcursor for
+# Hyprland), so there is no package to point home.pointerCursor at.
+{ pkgs, ... }:
+{
+ gtk = {
+ enable = true;
+ iconTheme = {
+ name = "Yaru-purple";
+ package = pkgs.yaru-theme;
+ };
+ cursorTheme = {
+ name = "modernxp-retro-black";
+ size = 24;
+ };
+ colorScheme = "dark"; # GTK prefer-dark
+ };
+
+ home.sessionVariables = {
+ XCURSOR_THEME = "modernxp-retro-black";
+ XCURSOR_SIZE = "24";
+ HYPRCURSOR_THEME = "modernxp-retro-black-hyprcursor"; # name from the theme's manifest.hl
+ HYPRCURSOR_SIZE = "24";
+ };
+}
diff --git a/home/modules/hyprland.nix b/home/modules/hyprland.nix
@@ -0,0 +1,133 @@
+# home/modules/hyprland.nix — Hyprland (Lua config) and the carried shortcuts.
+#
+# Hyprland 0.56 is configured in Lua (hyprland.lua, `hl.*` API); that is also
+# the dialect the carried shortcuts and the toolbox helpers (dropterm, winsnap,
+# vault-open, lid-control: `hyprctl dispatch 'hl.dsp…'`) already speak. The
+# config is split like the vault's shortcuts/:
+# hypr/omarchy.lua the `o` helpers the carried files were written against
+# hypr/core.lua monitor, env, look, input, Caelestia layer rules
+# hypr/defaults.lua the stock Omarchy binds as captured in keybinds.txt
+# hypr/bindings.lua shortcuts/bindings.lua (user overrides, window mode)
+# hypr/lid.lua shortcuts/lid.lua
+# hypr/caelestia.lua shortcuts/caelestia.lua (Caelestia keys, always on here)
+{ config, pkgs, lib, inputs, ... }:
+let
+ system = pkgs.stdenv.hostPlatform.system;
+ hyprPkg = inputs.hyprland.packages.${system}.hyprland;
+
+ # Small helpers the stock Omarchy binds relied on. They exist only to serve
+ # this config, so they live here rather than in ~/.local/bin.
+ helpers = [
+ # The picker the omarchy-menu-* cheat sheets (bin/) pipe into. Prints the chosen line.
+ (pkgs.writeShellScriptBin "omarchy-menu-select" ''
+ title=''${1:-Select}
+ exec ${pkgs.fuzzel}/bin/fuzzel --dmenu --prompt "$title › " --width 110 --lines 24
+ '')
+ # omarchy-not-ported "<bind description>" ["<what it did on Omarchy>"]: an
+ # honest notification instead of a key that silently does nothing.
+ (pkgs.writeShellScriptBin "omarchy-not-ported" ''
+ ${pkgs.libnotify}/bin/notify-send -a NixDaemon -t 4000 "$1" "Not set up on this NixOS build.''${2:+ Omarchy: $2}"
+ '')
+ (pkgs.writeShellScriptBin "nixdaemon-show" ''
+ # nixdaemon-show time|battery|calendar|kbd-backlight-cycle
+ n() { ${pkgs.libnotify}/bin/notify-send -a NixDaemon -t 5000 "$@"; }
+ case "''${1:-}" in
+ time) n "$(date '+%H:%M')" "$(date '+%A %-d %B %Y')" ;;
+ battery) b=/sys/class/power_supply/BAT0; n "Battery $(cat $b/capacity)%" "$(cat $b/status)" ;;
+ calendar) n "$(date '+%B %Y')" "$(cal | tail -n +2)" ;;
+ kbd-backlight-cycle)
+ d=$(ls -d /sys/class/leds/*kbd_backlight 2>/dev/null | head -1); [ -n "$d" ] || exit 0
+ max=$(cat "$d/max_brightness"); cur=$(cat "$d/brightness"); next=$(( (cur + 1) % (max + 1) ))
+ ${pkgs.brightnessctl}/bin/brightnessctl -q -d "$(basename "$d")" set "$next" ;;
+ *) echo "usage: nixdaemon-show time|battery|calendar|kbd-backlight-cycle" >&2; exit 2 ;;
+ esac
+ '')
+ # SUPER+K: searchable list of the live binds (what Omarchy's keybindings menu did). Enter copies the key.
+ (pkgs.writeShellScriptBin "keybinds-menu" ''
+ sel=$(hyprctl binds -j | ${pkgs.python3}/bin/python3 -I -c '
+ import json, sys
+ M = {1: "SHIFT", 4: "CTRL", 8: "ALT", 64: "SUPER"}
+ rows = set()
+ for x in json.load(sys.stdin):
+ mods = "+".join(n for v, n in sorted(M.items()) if x["modmask"] & v)
+ key = x["key"] or ("code:%d" % x["keycode"])
+ sub = x.get("submap", "")
+ rows.add(("%s%s%s %s" % (sub + ": " if sub else "", mods + " + " if mods else "", key, x.get("description", ""))).rstrip())
+ print("\n".join(sorted(rows)))' | ${pkgs.fuzzel}/bin/fuzzel --dmenu --prompt "Keybindings › " --width 120 --lines 30) || exit 0
+ [ -n "$sel" ] && printf '%s' "''${sel%% *}" | ${pkgs.wl-clipboard}/bin/wl-copy
+ '')
+ (pkgs.writeShellScriptBin "nightlight-toggle" ''
+ if pgrep -x hyprsunset >/dev/null; then
+ pkill -x hyprsunset; ${pkgs.libnotify}/bin/notify-send -a NixDaemon -t 2000 "Night light" "off"
+ else
+ ${pkgs.hyprsunset}/bin/hyprsunset --temperature 4000 >/dev/null 2>&1 &
+ ${pkgs.libnotify}/bin/notify-send -a NixDaemon -t 2000 "Night light" "on (4000 K)"
+ fi
+ '')
+ ];
+in
+{
+ wayland.windowManager.hyprland = {
+ enable = true;
+ package = hyprPkg; # same derivation NixOS installs; no second copy
+ portalPackage = null; # programs.hyprland (NixOS) provides the portal
+ configType = "lua";
+ systemd.enable = false; # uwsm owns graphical-session.target
+ xwayland.enable = true;
+
+ extraLuaFiles = {
+ omarchy = { content = ../hypr/omarchy.lua; autoLoad = false; };
+ core = { content = ../hypr/core.lua; autoLoad = false; };
+ defaults = { content = ../hypr/defaults.lua; autoLoad = false; };
+ bindings = { content = ../hypr/bindings.lua; autoLoad = false; };
+ lid = { content = ../hypr/lid.lua; autoLoad = false; };
+ caelestia = { content = ../hypr/caelestia.lua; autoLoad = false; };
+ };
+
+ # Explicit load order: the stock binds first, then the carried files that
+ # unbind-and-rebind the keys they take over, Caelestia's keys last.
+ extraConfig = ''
+ local cfg = (os.getenv("XDG_CONFIG_HOME") or (os.getenv("HOME") .. "/.config")) .. "/hypr"
+ package.path = cfg .. "/?.lua;" .. package.path
+ require("omarchy")
+ require("core")
+ require("defaults")
+ require("bindings")
+ require("lid")
+ require("caelestia")
+ '';
+ };
+
+ home.packages = helpers ++ (with pkgs; [
+ hyprpicker # colour picker (SUPER+PRINT)
+ hyprsunset # night light
+ fuzzel # dmenu for the cheat sheets and keybinds-menu
+ nautilus # the file manager shell.json and the stock binds point at
+ brightnessctl
+ pamixer
+ grim
+ slurp
+ wl-clipboard
+ libnotify
+ ]);
+
+ # Session services under graphical-session.target (started by uwsm).
+ systemd.user.services.hyprpolkitagent = {
+ Unit = {
+ Description = "Hyprland polkit authentication agent";
+ After = [ "graphical-session.target" ];
+ PartOf = [ "graphical-session.target" ];
+ };
+ Service = {
+ ExecStart = "${pkgs.hyprpolkitagent}/libexec/hyprpolkitagent";
+ Restart = "on-failure";
+ Slice = "session.slice";
+ };
+ Install.WantedBy = [ "graphical-session.target" ];
+ };
+ services.cliphist.enable = true; # history for `caelestia clipboard`
+ services.udiskie = {
+ enable = true;
+ tray = "auto";
+ };
+}
diff --git a/home/modules/terminal.nix b/home/modules/terminal.nix
@@ -0,0 +1,64 @@
+# home/modules/terminal.nix — kitty with DMMono Nerd Font and Rosé Pine (main).
+#
+# The six patched DMMono TTFs come from the dotfiles checkout (tools.nix links
+# ~/git/daemon-sec-dotfiles/assets/fonts/nerd-fonts-dm-mono into
+# ~/.local/share/fonts). The family has no Bold, so bold maps to Medium.
+#
+# Palette rule from the migration prompt: pine #31748f is a fill, never ink,
+# so it must not sit in an ANSI foreground slot. The Rosé Pine terminal theme
+# puts pine in the green slot; the substitute is PARKED for the owner's
+# decision. Until then `ansiGreen` below carries foam, the colour the toolbox
+# itself uses wherever pine would have been read as text (bin/install.sh).
+{ pkgs, lib, ... }:
+let
+ ansiGreen = "#9ccfd8"; # PARKED: ask before changing; see header
+in
+{
+ fonts.fontconfig.enable = true;
+
+ programs.kitty = {
+ enable = true;
+ font = {
+ name = "DMMono Nerd Font";
+ size = 10;
+ };
+ settings = {
+ bold_font = ''family="DMMono Nerd Font" style="Medium"'';
+ italic_font = ''family="DMMono Nerd Font" style="Italic"'';
+ bold_italic_font = ''family="DMMono Nerd Font" style="Medium Italic"'';
+
+ # Rosé Pine, main (dark)
+ foreground = "#e0def4";
+ background = "#191724";
+ selection_foreground = "#e0def4";
+ selection_background = "#403d52"; # highlight med
+ cursor = "#524f67"; # highlight high
+ cursor_text_color = "#e0def4";
+ url_color = "#c4a7e7";
+
+ active_border_color = "#eb6f92";
+ inactive_border_color = "#6e6a86";
+ active_tab_foreground = "#e0def4";
+ active_tab_background = "#26233a";
+ inactive_tab_foreground = "#6e6a86";
+ inactive_tab_background = "#191724";
+
+ color0 = "#26233a";
+ color8 = "#6e6a86";
+ color1 = "#eb6f92";
+ color9 = "#eb6f92";
+ color2 = ansiGreen;
+ color10 = ansiGreen;
+ color3 = "#f6c177";
+ color11 = "#f6c177";
+ color4 = "#9ccfd8";
+ color12 = "#9ccfd8";
+ color5 = "#c4a7e7";
+ color13 = "#c4a7e7";
+ color6 = "#ebbcba";
+ color14 = "#ebbcba";
+ color7 = "#e0def4";
+ color15 = "#e0def4";
+ };
+ };
+}
diff --git a/home/modules/tools.nix b/home/modules/tools.nix
@@ -0,0 +1,81 @@
+# home/modules/tools.nix — runtime closure for the hand-written toolbox in
+# ~/.local/bin, plus the files that come from the dotfiles checkout.
+#
+# ~/.local/bin itself is not managed here on purpose: it is a flat git repo
+# (vault README: "git init there afterwards so editing a file edits the live
+# command"). NixOS puts it first on PATH (hosts/laptop/toolbox.nix).
+{ config, pkgs, lib, ... }:
+let
+ dotfiles = "${config.home.homeDirectory}/git/daemon-sec-dotfiles";
+ link = path: config.lib.file.mkOutOfStoreSymlink "${dotfiles}/${path}";
+
+ pythonEnv = pkgs.python3.withPackages (ps: with ps; [
+ cryptography
+ argon2-cffi
+ rich
+ questionary
+ pikepdf
+ mutagen
+ pillow
+ numpy
+ pyqt6
+ youtube-transcript-api
+ ]);
+in
+{
+ home.packages = with pkgs; [
+ pythonEnv
+ perl
+ git
+ jujutsu
+ gnutar
+ zstd
+ pigz
+ xz
+ rsync
+ rclone
+ aria2
+ p7zip
+ libarchive
+ gnupg
+ openssl
+ pinentry-gnome3
+ ffmpeg
+ yt-dlp
+ atomicparsley
+ gallery-dl
+ mpd
+ mpc
+ rmpc
+ mpv
+ imagemagick
+ img2pdf
+ resvg
+ zathura
+ calibre
+ poppler-utils
+ starship
+ bat
+ eza
+ fd
+ ripgrep
+ fzf
+ zoxide
+ atuin
+ jq
+ curl
+ wget
+ gh
+ fastfetch
+ wl-clipboard
+ libnotify
+ ];
+
+ # From the dotfiles checkout, as symlinks (no copy into the store, and the
+ # files follow the repo). They dangle harmlessly until the repo is cloned.
+ home.file = {
+ ".local/share/fonts/nerd-fonts-dm-mono".source = link "assets/fonts/nerd-fonts-dm-mono";
+ ".local/share/icons/modernxp-retro-black".source = link "home/.local/share/icons/modernxp-retro-black";
+ ".local/share/icons/modernxp-retro-black-hyprcursor".source = link "home/.local/share/icons/modernxp-retro-black-hyprcursor";
+ };
+}
diff --git a/hosts/bootstrap/default.nix b/hosts/bootstrap/default.nix
@@ -0,0 +1,86 @@
+# hosts/bootstrap/default.nix
+#
+# Stage A. The GNOME install exactly as the installer left it (this is the old
+# configuration.nix, reorganised) plus:
+# - the dead-GPU-fan workaround and its sensor driver,
+# - the toolbox prerequisites (envfs, ~/.local/bin on PATH, padx udev rule,
+# a python3 with the tools' modules, jq, fzf),
+# - the Hyprland binary cache, so the `nixos` configuration downloads
+# instead of compiling.
+#
+# Built from nixpkgs-stable (the revision this machine runs), so the switch is
+# small and GNOME stays untouched. Apply, reboot (new kernel modules only load
+# from the booted system), verify with `fanfix status`, then move on to
+# `nixos`. Delete this directory and the nixpkgs-stable input afterwards.
+{ config, pkgs, lib, user, ... }:
+{
+ imports = [
+ ../laptop/hardware-configuration.nix
+ ../laptop/fan-throttle-guard.nix
+ ../laptop/uniwill-laptop.nix
+ ../laptop/nix-settings.nix
+ ../laptop/toolbox.nix
+ ];
+
+ boot.loader.systemd-boot.enable = true;
+ boot.loader.efi.canTouchEfiVariables = true;
+
+ networking.hostName = "nixos";
+ networking.networkmanager.enable = true;
+
+ time.timeZone = "Europe/Isle_of_Man";
+ i18n.defaultLocale = "en_US.UTF-8";
+
+ services.displayManager.gdm.enable = true;
+ services.desktopManager.gnome.enable = true;
+ services.xserver.xkb = {
+ layout = "us";
+ variant = "";
+ };
+
+ services.printing.enable = true;
+
+ services.pulseaudio.enable = false;
+ security.rtkit.enable = true;
+ services.pipewire = {
+ enable = true;
+ alsa.enable = true;
+ alsa.support32Bit = true;
+ pulse.enable = true;
+ };
+
+ users.users.${user} = {
+ isNormalUser = true;
+ description = "daemon-sec";
+ extraGroups = [ "networkmanager" "wheel" ];
+ };
+
+ programs.firefox.enable = true;
+ nixpkgs.config.allowUnfree = true;
+
+ # Enough of the toolbox runtime to use ~/.local/bin from GNOME meanwhile.
+ # The full closure arrives with home-manager in the `nixos` configuration.
+ environment.systemPackages = with pkgs; [
+ (python3.withPackages (ps: with ps; [
+ cryptography
+ argon2-cffi
+ rich
+ questionary
+ pikepdf
+ mutagen
+ pillow
+ numpy
+ pyqt6
+ youtube-transcript-api
+ ]))
+ perl
+ jq
+ fzf
+ bat
+ eza
+ fd
+ ripgrep
+ ];
+
+ system.stateVersion = "26.05";
+}
diff --git a/hosts/laptop/default.nix b/hosts/laptop/default.nix
@@ -0,0 +1,120 @@
+# hosts/laptop/default.nix
+#
+# PCSpecialist Valeon II 17 (TongFang GM7RGxM): Ryzen 9 6900HX, Radeon 680M at
+# 06:00.0, RTX 3070 Ti Laptop at 01:00.0, 2560x1440@240 panel. Hyprland under
+# uwsm, Caelestia Shell from home-manager (home/), greetd + tuigreet to log in.
+{ config, pkgs, lib, user, ... }:
+{
+ imports = [
+ ./hardware-configuration.nix
+ ./fan-throttle-guard.nix # dead-GPU-fan workaround (vault gpu-fan-fix/, imported unchanged)
+ ./uniwill-laptop.nix # the `uniwill` hwmon the guard reads: kernel 6.19 driver built for this kernel
+ ./nvidia.nix
+ ./ssd.nix
+ ./nix-settings.nix
+ ./toolbox.nix
+ ];
+
+ boot.loader.systemd-boot.enable = true;
+ boot.loader.efi.canTouchEfiVariables = true;
+
+ networking.hostName = "nixos";
+ networking.networkmanager.enable = true;
+
+ time.timeZone = "Europe/Isle_of_Man";
+ i18n.defaultLocale = "en_US.UTF-8";
+ services.xserver.xkb = {
+ layout = "us";
+ options = "compose:caps,shift:both_capslock_cancel";
+ };
+
+ nixpkgs.config.allowUnfree = true; # nvidia, obsidian, claude-desktop
+
+ users.users.${user} = {
+ isNormalUser = true;
+ description = "daemon-sec";
+ extraGroups = [ "networkmanager" "wheel" ];
+ };
+
+ ##### Desktop ################################################################
+ # Hyprland package and portal come from inputs.hyprland.nixosModules.default.
+ programs.hyprland = {
+ enable = true;
+ withUWSM = true; # systemd-managed session; graphical-session.target starts Caelestia, polkit agent, udiskie
+ xwayland.enable = true;
+ };
+
+ # Display manager: greetd with the tuigreet text greeter. Remembers the last
+ # user and session, so a boot is: password, Enter. No theme engine, no X.
+ services.greetd = {
+ enable = true;
+ useTextGreeter = true;
+ settings.default_session.command = lib.concatStringsSep " " [
+ "${pkgs.tuigreet}/bin/tuigreet"
+ "--time"
+ "--remember"
+ "--remember-session"
+ "--asterisks"
+ "--sessions ${config.services.displayManager.sessionData.desktops}/share/wayland-sessions"
+ ];
+ };
+ security.pam.services.greetd.enableGnomeKeyring = true; # unlock the keyring at login (Claude desktop uses it)
+
+ xdg.portal.extraPortals = [ pkgs.xdg-desktop-portal-gtk ]; # file chooser; hyprland portal does screencast
+ security.polkit.enable = true; # agent: hyprpolkitagent user service (home/modules/hyprland.nix)
+ services.udisks2.enable = true; # udiskie
+ services.power-profiles-daemon.enable = true; # the fan fix depends on it
+ services.gnome.gnome-keyring.enable = true;
+ programs.dconf.enable = true;
+
+ services.pulseaudio.enable = false;
+ security.rtkit.enable = true;
+ services.pipewire = {
+ enable = true;
+ alsa.enable = true;
+ alsa.support32Bit = true;
+ pulse.enable = true;
+ wireplumber.enable = true;
+ };
+
+ programs.firefox.enable = true;
+ services.printing.enable = true;
+
+ programs.gnupg.agent = {
+ enable = true;
+ pinentryPackage = pkgs.pinentry-gnome3;
+ };
+
+ ##### Fonts ##################################################################
+ # DMMono Nerd Font is not in nixpkgs; home/modules/tools.nix links it from
+ # ~/git/daemon-sec-dotfiles into ~/.local/share/fonts.
+ fonts.packages = with pkgs; [
+ noto-fonts
+ noto-fonts-color-emoji
+ noto-fonts-cjk-sans
+ rubik # Caelestia clock font
+ material-symbols # Caelestia icons
+ ];
+ fonts.fontconfig.defaultFonts = {
+ monospace = [ "DMMono Nerd Font" "Noto Sans Mono" ];
+ sansSerif = [ "Noto Sans" ];
+ serif = [ "Noto Serif" ];
+ emoji = [ "Noto Color Emoji" ];
+ };
+
+ ##### Session environment ####################################################
+ # uwsm imports these through the login shell. GPU-specific ones are in nvidia.nix.
+ environment.sessionVariables = {
+ ELECTRON_OZONE_PLATFORM_HINT = "auto";
+ GDK_BACKEND = "wayland,x11";
+ QT_QPA_PLATFORM = "wayland;xcb";
+ QT_WAYLAND_DISABLE_WINDOWDECORATION = "1";
+ };
+
+ environment.systemPackages = with pkgs; [
+ pciutils # lspci
+ usbutils
+ ];
+
+ system.stateVersion = "26.05";
+}
diff --git a/hosts/laptop/fan-reference/99-cpu-freq-cap.conf b/hosts/laptop/fan-reference/99-cpu-freq-cap.conf
@@ -0,0 +1,3 @@
+# fanfix: broken GPU fan -> EC pins CPU at 399 MHz at ~79 C. Cap every core so it never gets there.
+# Remove with: fanfix uninstall (after the fan is replaced)
+w /sys/devices/system/cpu/cpu*/cpufreq/scaling_max_freq - - - - 3200000
diff --git a/hosts/laptop/fan-reference/README.md b/hosts/laptop/fan-reference/README.md
@@ -0,0 +1,45 @@
+# fan/ — the throttle fix, as captured and as declared
+
+**Slot:** `hosts/laptop/fan-throttle-guard.nix` (import it from `hosts/laptop/default.nix`).
+
+## What is in here
+
+| File | Origin on the Arch install | Role |
+|---|---|---|
+| `fan-throttle-guard.nix` | written this pass | the whole fix as one NixOS module |
+| `fanfix` | `~/.local/bin/fanfix` | CLI + fan-curve daemon (bash, 372 lines) |
+| `stability_guard.py` | `/usr/local/lib/motherboardctl/stability_guard.py` | staged ceiling 3200/2400/1800 MHz |
+| `99-cpu-freq-cap.conf` | `/etc/tmpfiles.d/` | the static floor as it is today |
+| `fanfix.conf` | `/etc/fanfix.conf` | `CURVE="0:60 100:60"` |
+| `fanfix-fan.service`, `motherboard-stability.service` | `/etc/systemd/system/` | the two units, verbatim |
+| `modules-load.conf`, `uniwill-laptop.conf` | `/etc/modules-load.d/`, `/etc/modprobe.d/` | module load + `force=1` |
+| `fan-ctl`, `fan-state` | bar plugin `daemon-sec.fans/bin/` | pkexec dispatcher and the read-only poller the widget uses |
+
+## Measured state at capture (2026-10-07 15:21 BST, idle, mains)
+
+```
+scaling_max_freq 3200000 scaling_min 1121095 cpuinfo_max 5169491 boost 1
+governor performance driver amd-pstate-epp (status active) profile performance
+k10temp Tctl 38 °C · uniwill fan1 4347 rpm pwm1 255 · fan2 0 rpm pwm2 255 · board temp2 37 °C
+amdgpu package 7 W · RTX 3070 Ti 37 °C, 20 W, 16 % util (nvidia-smi fan [N/A])
+motherboard-stability: active (limit 3200, stage 0, "mains power; normal temperatures")
+fanfix-fan.service: enabled but stopped at 15:08 today; EC commanding both fans 100 %
+```
+
+## Things that change on NixOS (read before enabling)
+
+- [ ] `fanfix install` / `uninstall` and `fan-ctl cap` / `uncap` **write `/etc/tmpfiles.d` imperatively**. On NixOS the floor is the `systemd.tmpfiles.rules` line in the module; treat `install`/`cap` as a no-op and change `capKhz` instead. `uncap` still works at runtime but the guard re-applies 3200 MHz within a second, so stop `motherboard-stability.service` first if an uncapped run is wanted.
+- [ ] `fanfix fan setup` has an **Arch-only package-install branch**. Dead code on NixOS; the module supplies `linuxPackages.acpi_call`. Do not run `fan setup`.
+- [ ] `fanfix` elevates with `sudo` inside `need_root`; `fan-ctl` expects to be launched by **pkexec** from a bar widget. Both need a polkit agent in the session. Which shell provides it is an open question in the capture note.
+- [ ] `stability_guard.py` hard-fails unless **exactly 16 cpufreq policies** exist and `/sys/class/power_supply/AC0/online` is the mains sensor. Same hardware, so fine, but check the power-supply name on the NixOS kernel.
+- [ ] `fanfix status` **false-negatives on the latched low-temperature clamp** (its detector is temperature-gated). Trust `/proc/cpuinfo` "cpu MHz" vs `scaling_min_freq`: cores below the kernel floor = EC clamp.
+- [ ] Never set the global cpufreq `boost` to 0 (see module header).
+
+## First-boot verification
+
+```sh
+fanfix status # cap 3200 MHz · boost 1 · profile performance · fan line present
+sudo fanfix fan status # fan-abnormal=1 is expected; universal-fan-ctrl / custom-tables show which EC path is live
+fanfix test 30 # all-core stress: expect 0 throttle events, peak < 75 °C
+systemctl status motherboard-stability fanfix-fan
+```
diff --git a/hosts/laptop/fan-reference/fan-ctl b/hosts/laptop/fan-reference/fan-ctl
@@ -0,0 +1,55 @@
+#!/usr/bin/env bash
+# fan-ctl — root-side actions for the daemon-sec.fans bar widget (run via
+# pkexec; the shell's polkit plugin presents the auth dialog). Thin dispatcher
+# over ~/.local/bin/fanfix, except `cap`, which re-applies the clock cap
+# without fanfix's built-in 20 s stress test so a panel click stays instant.
+set -uo pipefail
+
+FANFIX=/home/daemon-sec/.local/bin/fanfix
+CONF=/etc/tmpfiles.d/99-cpu-freq-cap.conf
+CPU0=/sys/devices/system/cpu/cpu0/cpufreq
+
+[ "$(id -u)" = 0 ] || { echo "fan-ctl must run as root (pkexec)" >&2; exit 1; }
+
+case "${1:-}" in
+ auto|max)
+ exec "$FANFIX" fan "$1" ;;
+ set)
+ pct="${2:?usage: fan-ctl set <30-100>}"
+ exec "$FANFIX" fan "$pct" ;;
+ curve)
+ case "${2:-}" in
+ on|off) exec "$FANFIX" fan curve "$2" ;;
+ *) echo "usage: fan-ctl curve on|off" >&2; exit 2 ;;
+ esac ;;
+ drivers)
+ exec "$FANFIX" fans --persist ;;
+ cap)
+ mhz="${2:-3200}"
+ [[ "$mhz" =~ ^[0-9]+$ ]] || { echo "cap must be a number (MHz)" >&2; exit 2; }
+ khz=$(( mhz < 100000 ? mhz * 1000 : mhz ))
+ hwmax=$(cat "$CPU0/cpuinfo_max_freq")
+ { [ "$khz" -ge 1500000 ] && [ "$khz" -le "$hwmax" ]; } \
+ || { echo "cap out of range (1500-$(( hwmax / 1000 )) MHz)" >&2; exit 2; }
+ cat > "$CONF" <<EOF
+# fanfix: broken GPU fan -> EC pins CPU at 399 MHz at ~79 C. Cap every core so it never gets there.
+# Remove with: fanfix uninstall (after the fan is replaced)
+w /sys/devices/system/cpu/cpu*/cpufreq/scaling_max_freq - - - - ${khz}
+EOF
+ systemd-tmpfiles --create "$CONF"
+ powerprofilesctl set performance 2>/dev/null || true
+ systemd-tmpfiles --create "$CONF" # profile switches toggle boost; re-assert
+ echo "cap = $(( $(cat "$CPU0/scaling_max_freq") / 1000 )) MHz, persisted in $CONF" ;;
+ uncap)
+ # Full boost. The EC's fan-abnormal policy still stalls the CPU at ~79 C,
+ # so sustained all-core loads may hit the 400 MHz clamp — deliberate choice.
+ rm -f "$CONF"
+ echo 1 > /sys/devices/system/cpu/cpufreq/boost
+ for f in /sys/devices/system/cpu/cpu*/cpufreq/scaling_max_freq; do
+ cat "${f%scaling_max_freq}cpuinfo_max_freq" > "$f"
+ done
+ echo "uncapped: max = $(( $(cat "$CPU0/scaling_max_freq") / 1000 )) MHz, turbo boost on" ;;
+ *)
+ echo "usage: fan-ctl auto|max|set <pct>|curve on|off|drivers|cap [MHz]|uncap" >&2
+ exit 2 ;;
+esac
diff --git a/hosts/laptop/fan-reference/fan-state b/hosts/laptop/fan-reference/fan-state
@@ -0,0 +1,80 @@
+#!/usr/bin/env bash
+# fan-state — one tab-separated line describing fans/thermals/throttle-guard,
+# for the daemon-sec.fans Omarchy bar widget + panel. Everything here is
+# readable without root; polled every few seconds so it must stay cheap.
+#
+# TCTL \t GPU_C \t FAN1_RPM \t FAN1_PCT \t FAN2_RPM \t FAN2_PCT \t DRIVER \t
+# CAP_MHZ \t HWMAX_MHZ \t PERSIST \t BOOST \t PROFILE \t AVG_MHZ \t MAX_MHZ \t
+# BUSY_PCT \t THROTTLE \t CURVE \t PKG_W
+#
+# TCTL = CPU Tctl °C (k10temp)
+# GPU_C = dGPU °C from the uniwill EC driver (empty if not loaded)
+# FAN1/2 = CPU/GPU fan rpm + commanded duty % (uniwill; GPU fan is dead)
+# DRIVER = 1 when the uniwill hwmon is present (fanfix fans loads it)
+# CAP/HWMAX = cpu0 scaling_max_freq / cpuinfo_max_freq in MHz
+# PERSIST = 1 when the fanfix tmpfiles cap survives reboots
+# BOOST = global cpufreq boost flag (must stay 1 for power-profiles-daemon)
+# AVG/MAX = instantaneous core clocks from /proc/cpuinfo
+# BUSY_PCT = CPU utilisation since the PREVIOUS poll (stat snapshot in
+# $XDG_RUNTIME_DIR — no sleep in the poll path)
+# THROTTLE = 1 when the EC clamp is active right now (fanfix's rule:
+# busy ≥ 25 % yet no core above 600 MHz)
+# CURVE = 1 when the fanfix-fan.service curve daemon is running
+# PKG_W = SoC package power in W (amdgpu)
+set -o pipefail
+export LC_ALL=C
+
+TRIP_MHZ=600
+BUSY_MIN=25
+CONF=/etc/tmpfiles.d/99-cpu-freq-cap.conf
+STATE_FILE=${XDG_RUNTIME_DIR:-/tmp}/daemon-sec.fans.stat
+
+hwmon_by_name() { local h; for h in /sys/class/hwmon/hwmon*; do [ "$(cat "$h/name" 2>/dev/null)" = "$1" ] && { echo "$h"; return 0; }; done; return 1; }
+rd() { cat "$1" 2>/dev/null || echo 0; }
+
+tctl=0
+h=$(hwmon_by_name k10temp) && tctl=$(( $(rd "$h/temp1_input") / 1000 ))
+
+driver=0 gpu="" f1r="" f1p="" f2r="" f2p=""
+if h=$(hwmon_by_name uniwill); then
+ driver=1
+ gpu=$(( $(rd "$h/temp2_input") / 1000 ))
+ f1r=$(rd "$h/fan1_input"); f1p=$(( $(rd "$h/pwm1") * 100 / 255 ))
+ f2r=$(rd "$h/fan2_input"); f2p=$(( $(rd "$h/pwm2") * 100 / 255 ))
+fi
+
+pkg=""
+h=$(hwmon_by_name amdgpu) && pkg=$(awk "BEGIN{printf \"%.1f\", $(rd "$h/power1_input")/1e6}")
+
+cap=$(( $(rd /sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq) / 1000 ))
+hwmax=$(( $(rd /sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq) / 1000 ))
+persist=0; [ -e "$CONF" ] && persist=1
+boost=$(rd /sys/devices/system/cpu/cpufreq/boost)
+profile=$(powerprofilesctl get 2>/dev/null || echo "")
+
+read -r avg mx <<<"$(grep MHz /proc/cpuinfo \
+ | awk '{s+=$4; if($4>m)m=$4} END{if(NR)printf "%d %d", s/NR, m; else print "0 0"}')"
+
+busy=0
+cur=$(head -1 /proc/stat)
+if [ -r "$STATE_FILE" ]; then
+ busy=$(awk -v a="$(cat "$STATE_FILE")" -v b="$cur" 'BEGIN {
+ na = split(a, pa, " "); nb = split(b, pb, " ")
+ for (i = 2; i <= 9 && i <= na && i <= nb; i++) { ta += pa[i]; tb += pb[i] }
+ ia = pa[5] + pa[6]; ib = pb[5] + pb[6] # idle + iowait
+ dt = tb - ta
+ printf "%d", (dt > 0) ? 100 - (ib - ia) * 100 / dt : 0
+ }')
+fi
+printf '%s\n' "$cur" > "$STATE_FILE"
+
+throttle=0
+[ "$busy" -ge "$BUSY_MIN" ] && [ "$mx" -gt 0 ] && [ "$mx" -lt "$TRIP_MHZ" ] && throttle=1
+
+curve=0
+systemctl is-active --quiet fanfix-fan.service 2>/dev/null && curve=1
+
+printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
+ "$tctl" "$gpu" "$f1r" "$f1p" "$f2r" "$f2p" "$driver" \
+ "$cap" "$hwmax" "$persist" "$boost" "$profile" "$avg" "$mx" \
+ "$busy" "$throttle" "$curve" "$pkg"
diff --git a/hosts/laptop/fan-reference/fanfix-fan.service b/hosts/laptop/fan-reference/fanfix-fan.service
@@ -0,0 +1,11 @@
+[Unit]
+Description=fanfix: temperature → fan-duty curve for the surviving CPU fan (dead GPU fan workaround)
+After=multi-user.target
+[Service]
+Type=simple
+ExecStart=/home/daemon-sec/.local/bin/fanfix fan-daemon
+ExecStopPost=/home/daemon-sec/.local/bin/fanfix fan-release
+Restart=on-failure
+RestartSec=5
+[Install]
+WantedBy=multi-user.target
diff --git a/hosts/laptop/fan-reference/fanfix.conf b/hosts/laptop/fan-reference/fanfix.conf
@@ -0,0 +1,3 @@
+# Keep the surviving CPU fan at a constant 60% duty.
+# fanfix-fan.service re-applies this target periodically and starts at boot.
+CURVE="0:60 100:60"
diff --git a/hosts/laptop/fan-reference/modules-load.conf b/hosts/laptop/fan-reference/modules-load.conf
@@ -0,0 +1,2 @@
+acpi_call
+uniwill-laptop
diff --git a/hosts/laptop/fan-reference/motherboard-stability.service b/hosts/laptop/fan-reference/motherboard-stability.service
@@ -0,0 +1,27 @@
+[Unit]
+Description=CPU stability limits for the GM7RGxM fan/power fault workaround
+After=systemd-tmpfiles-setup.service
+
+[Service]
+Type=simple
+ExecStart=/usr/bin/python3 -I /usr/local/lib/motherboardctl/stability_guard.py
+Restart=on-failure
+RestartSec=3
+RuntimeDirectory=motherboard-stability
+RuntimeDirectoryMode=0755
+NoNewPrivileges=true
+ProtectSystem=strict
+ProtectHome=true
+ReadWritePaths=/sys/devices/system/cpu /run/motherboard-stability
+PrivateTmp=true
+PrivateDevices=true
+ProtectKernelModules=true
+ProtectControlGroups=true
+RestrictAddressFamilies=AF_UNIX
+LockPersonality=true
+RestrictSUIDSGID=true
+CapabilityBoundingSet=
+UMask=0022
+
+[Install]
+WantedBy=multi-user.target
diff --git a/hosts/laptop/fan-reference/uniwill-laptop.conf b/hosts/laptop/fan-reference/uniwill-laptop.conf
@@ -0,0 +1 @@
+options uniwill-laptop force=1
diff --git a/hosts/laptop/fan-throttle-guard.nix b/hosts/laptop/fan-throttle-guard.nix
@@ -0,0 +1,115 @@
+# hosts/laptop/fan-throttle-guard.nix
+#
+# Dead-GPU-fan workaround for the PCSpecialist Valeon II 17 (TongFang GM7RGxM,
+# Ryzen 9 6900HX + RTX 3070 Ti Laptop). The embedded controller sees the dead
+# "Secondary" fan (fan2: 0 rpm while commanded 100 %), raises its fan-abnormal
+# flag and, once Tctl reaches ~79 °C, asserts PROCHOT and pins all 16 threads at
+# 399 MHz until ~47 °C. That policy is firmware; Linux cannot switch it off.
+# The fix is to keep the CPU from ever reaching the trip point:
+#
+# 1. a static scaling_max_freq floor of 3.2 GHz applied by tmpfiles at boot
+# (3.2 GHz ≈ base clock → ~67-70 °C under all-core load, no trips),
+# 2. a staged guard (3200 → 2400 → 1800 MHz) driven by k10temp + the uniwill
+# board sensor, which also covers the "latched" low-temperature clamp,
+# 3. the surviving CPU fan held at 60 % duty through the EC's own ACPI
+# methods (acpi_call → \_SB.INOU.ECRR/ECRW, TUXEDO register recipe),
+# 4. power-profiles-daemon kept on, profile "performance", and the global
+# cpufreq boost flag left at 1 — never use boost=0, ppd 0.30 writes
+# per-policy boost on every switch and fails with EINVAL otherwise.
+#
+# Everything here was measured on the Arch install this was captured from
+# (fanfix 2026-08-23, stability guard 2026-09-07). Files beside this module:
+# fanfix the CLI/daemon (bash) ← copied verbatim
+# stability_guard.py the staged ceiling (python3) ← copied verbatim
+# fan-ctl, fan-state bar-widget helpers; need a polkit agent and a bar slot
+#
+# Verify on first boot: fanfix status · fanfix fan status · fanfix test 30
+# expected: cap 3200 MHz, boost 1, profile performance, no THROTTLE event,
+# peak < 75 °C. If a trip still happens: lower the floor to 3000000 below.
+{ config, pkgs, lib, ... }:
+
+let
+ # fanfix is plain bash; wrap it so the shebang resolves and PATH is supplied
+ # by the unit (fanDeps) rather than by whatever shell invoked it.
+ fanfix = pkgs.writeShellScriptBin "fanfix" (builtins.readFile ./fanfix);
+
+ fanDeps = with pkgs; [
+ coreutils gnugrep gawk gnused procps util-linux
+ kmod # modprobe acpi_call / uniwill-laptop
+ systemd # systemctl, systemd-tmpfiles
+ power-profiles-daemon # powerprofilesctl
+ ];
+
+ capKhz = 3200000; # the floor. 3000000 is the documented fallback.
+in
+{
+ ##### 1. EC access and fan/temperature readout ##############################
+ # acpi_call is out-of-tree (nixpkgs: linuxPackages.acpi_call). uniwill-laptop
+ # is in-tree; `force=1` is required because the DMI match list does not carry
+ # this GM7RGxM. Verify `modinfo uniwill-laptop` exists on the chosen kernel.
+ boot.extraModulePackages = [ config.boot.kernelPackages.acpi_call ];
+ boot.kernelModules = [ "acpi_call" "uniwill-laptop" ];
+ boot.extraModprobeConfig = ''
+ options uniwill-laptop force=1
+ '';
+
+ ##### 2. Static floor, applied before any user load exists ###################
+ systemd.tmpfiles.rules = [
+ "w /sys/devices/system/cpu/cpu*/cpufreq/scaling_max_freq - - - - ${toString capKhz}"
+ ];
+
+ ##### 3. power-profiles-daemon stays on ######################################
+ services.power-profiles-daemon.enable = true;
+
+ ##### 4. Staged thermal ceiling (replaces /etc/systemd/system/motherboard-stability.service)
+ systemd.services.motherboard-stability = {
+ description = "CPU stability limits for the GM7RGxM fan/power fault workaround";
+ after = [ "systemd-tmpfiles-setup.service" ];
+ wantedBy = [ "multi-user.target" ];
+ serviceConfig = {
+ Type = "simple";
+ ExecStart = "${pkgs.python3}/bin/python3 -I ${./stability_guard.py}";
+ Restart = "on-failure";
+ RestartSec = 3;
+ RuntimeDirectory = "motherboard-stability";
+ RuntimeDirectoryMode = "0755";
+ NoNewPrivileges = true;
+ ProtectSystem = "strict";
+ ProtectHome = true;
+ ReadWritePaths = [ "/sys/devices/system/cpu" "/run/motherboard-stability" ];
+ PrivateTmp = true;
+ PrivateDevices = true;
+ ProtectKernelModules = true;
+ ProtectControlGroups = true;
+ RestrictAddressFamilies = "AF_UNIX";
+ LockPersonality = true;
+ RestrictSUIDSGID = true;
+ CapabilityBoundingSet = "";
+ UMask = "0022";
+ };
+ };
+
+ ##### 5. Surviving CPU fan at a fixed 60 % duty ##############################
+ # CURVE is "temp:pct …" pairs; a flat 0:60 100:60 is what has been running.
+ # fanfix refuses anything below 30 %. Edit here, not in /etc, then rebuild.
+ environment.etc."fanfix.conf".text = ''
+ CURVE="0:60 100:60"
+ '';
+
+ systemd.services.fanfix-fan = {
+ description = "fanfix: temperature → fan-duty curve for the surviving CPU fan (dead GPU fan workaround)";
+ after = [ "multi-user.target" ];
+ wantedBy = [ "multi-user.target" ];
+ path = fanDeps;
+ serviceConfig = {
+ Type = "simple";
+ ExecStart = "${fanfix}/bin/fanfix fan-daemon";
+ ExecStopPost = "${fanfix}/bin/fanfix fan-release";
+ Restart = "on-failure";
+ RestartSec = 5;
+ };
+ };
+
+ ##### 6. Tools on PATH ######################################################
+ environment.systemPackages = [ fanfix pkgs.lm_sensors ] ++ fanDeps;
+}
diff --git a/hosts/laptop/fanfix b/hosts/laptop/fanfix
@@ -0,0 +1,372 @@
+#!/bin/bash
+# fanfix — toolkit for living with the dead GPU fan on this laptop
+# (PCSpecialist Valeon II 17 / TongFang GM7RGxM, Ryzen 9 6900HX + RTX 3070 Ti).
+#
+# Background: with a fan dead, the embedded controller (EC) pins every CPU core at 399 MHz the
+# instant the CPU reaches ~79 °C and holds it until ~47 °C. That policy lives in firmware and
+# cannot be switched off from Linux; the workaround is to cap CPU clocks so the CPU never gets
+# there (3.2 GHz ≈ base clock → ~70 °C under full load), and optionally to run the surviving
+# CPU fan harder than the EC's quiet-tuned curve.
+#
+# Fan control talks to the EC through the firmware's own ACPI methods (\_SB.INOU.ECRR/ECRW —
+# the same ones the kernel's uniwill-laptop driver uses) via the acpi_call module, and follows
+# the register procedure of TUXEDO's driver for this chassis family (uniwill_keyboard.h).
+#
+# Usage: fanfix help
+set -uo pipefail
+
+CAP_DEFAULT_KHZ=3200000
+CONF=/etc/tmpfiles.d/99-cpu-freq-cap.conf
+OLD_CONF=/etc/tmpfiles.d/99-cpu-no-boost.conf
+TRIP_MHZ=600 # any core reporting below this under load = EC clamp active
+CPU0=/sys/devices/system/cpu/cpu0/cpufreq
+ACPI_CALL=/proc/acpi/call
+EC_DEV='\_SB.INOU' # ACPI device behind INOU0000 (Uniwill EC bridge)
+FAN_UNIT=fanfix-fan.service
+FAN_CONF=/etc/fanfix.conf # optional: CURVE="45:30 55:45 60:55 65:70 70:85 75:100"
+DEFAULT_CURVE="45:30 55:45 60:55 65:70 70:85 75:100"
+FAN_MIN_PCT=30 # refuse slower manual settings (EC trips at 79 °C; 34 % held 69 °C capped)
+SELF=$(readlink -f "$0")
+[ -r "$FAN_CONF" ] && . "$FAN_CONF"
+
+# ---------- generic helpers ----------------------------------------------------------------
+hwmon_by_name() { local h; for h in /sys/class/hwmon/hwmon*; do [ "$(cat "$h/name" 2>/dev/null)" = "$1" ] && { echo "$h"; return 0; }; done; return 1; }
+rd() { cat "$1" 2>/dev/null || echo 0; }
+tctl() { local h; h=$(hwmon_by_name k10temp) && echo $(( $(rd "$h/temp1_input") / 1000 )) || echo 0; }
+ppt_w() { local h; h=$(hwmon_by_name amdgpu) && awk "BEGIN{printf \"%.1f\", $(rd "$h/power1_input")/1e6}" || echo "?"; }
+freqs() { grep MHz /proc/cpuinfo | awk '{s+=$4; if($4>m)m=$4; if(n==""||$4<n)n=$4} END{printf "%d %d %d", s/NR, n, m}'; } # avg min max
+fan_line() {
+ local h; h=$(hwmon_by_name uniwill) || { echo "fans: driver not loaded (fanfix fans)"; return; }
+ printf "CPU fan %4d rpm (%3d%%) GPU fan %4d rpm (%3d%%) GPU %2d°C" \
+ "$(rd "$h/fan1_input")" $(( $(rd "$h/pwm1")*100/255 )) "$(rd "$h/fan2_input")" $(( $(rd "$h/pwm2")*100/255 )) $(( $(rd "$h/temp2_input")/1000 ))
+}
+cpu_busy_pct() { # CPU utilisation over ~0.3 s
+ local a b; a=($(head -1 /proc/stat)); sleep 0.3; b=($(head -1 /proc/stat))
+ local ia=$(( a[4]+a[5] )) ib=$(( b[4]+b[5] )) ta=0 tb=0 i
+ for i in 1 2 3 4 5 6 7 8; do ta=$(( ta + a[i] )); tb=$(( tb + b[i] )); done
+ local dt=$(( tb - ta )); [ "$dt" -gt 0 ] && echo $(( 100 - (ib-ia)*100/dt )) || echo 0
+}
+cap_khz() { rd "$CPU0/scaling_max_freq"; }
+max_khz() { rd "$CPU0/cpuinfo_max_freq"; }
+bold() { printf '\033[1m%s\033[0m\n' "$*"; }
+ok() { printf ' \033[32m✔\033[0m %s\n' "$*"; }
+warn() { printf ' \033[33m!\033[0m %s\n' "$*"; }
+bad() { printf ' \033[31m✘\033[0m %s\n' "$*"; }
+die() { bad "$*"; exit 1; }
+need_root() { [ "$(id -u)" = 0 ] || exec sudo -p "[sudo] password (needed to talk to the EC): " "$SELF" "$@"; }
+
+stress_start() { STRESS_PIDS=(); local i n=${1:-$(nproc)} secs=${2:-30}; for i in $(seq "$n"); do timeout $((secs+5)) bash -c 'while :; do :; done' & STRESS_PIDS+=($!); done; }
+stress_stop() { [ -n "${STRESS_PIDS:-}" ] && kill -9 "${STRESS_PIDS[@]}" 2>/dev/null; wait 2>/dev/null; }
+
+# ---------- EC access (root; needs acpi_call) ----------------------------------------------
+ec_ready() { [ -w "$ACPI_CALL" ] || modprobe acpi_call 2>/dev/null; [ -w "$ACPI_CALL" ]; }
+ec_raw() { printf '%s' "$1" > "$ACPI_CALL" || return 1; local o; o=$(tr -d '\0\n\r ' < "$ACPI_CALL"); printf '%s' "$o"; }
+ec_read() { # ec_read 0x0741 -> decimal value
+ local out; out=$(ec_raw "$(printf '%s.ECRR 0x%04x' "$EC_DEV" "$1")") || return 1
+ [[ "$out" =~ ^0x[0-9a-fA-F]+$ ]] || { echo "EC read $(printf '0x%04x' "$1") failed: $out" >&2; return 1; }
+ echo $(( out ))
+}
+ec_write() { # ec_write 0x1804 200
+ local out; out=$(ec_raw "$(printf '%s.ECRW 0x%04x 0x%02x' "$EC_DEV" "$1" "$2")") || return 1
+ case "$out" in Error*|"not called"|"") echo "EC write $(printf '0x%04x' "$1") failed: ${out:-no result}" >&2; return 1;; esac
+ sleep 0.005
+}
+ec_set_bits() { local v; v=$(ec_read "$1") || return 1; ec_write "$1" $(( v | $2 )); }
+ec_clear_bits() { local v; v=$(ec_read "$1") || return 1; ec_write "$1" $(( v & ~$2 & 0xff )); }
+ec_bit() { local v; v=$(ec_read "$1") || return 1; echo $(( (v >> $2) & 1 )); }
+
+# EC register map (TongFang/Uniwill; from kernel uniwill-laptop + tuxedo-drivers + qc71_laptop)
+R_AP_OEM=0x0741 # bit0 manual-ctrl/OEM-app-present, bit5 FAN_ABNORMAL
+R_FAN_MODE=0x0751 # bit6 "full fan mode" (old direct control), bit4 turbo, bit5 high, bit7 user
+R_PWM1=0x075B; R_PWM2=0x075C # current duty 0..200 (read-only)
+R_FAN_CTRL=0x078E # bit6 = universal (custom-table) fan control supported
+R_TBL_SPLIT=0x07C5 # bit7 = separate CPU/GPU tables
+R_TBL_ENABLE=0x07C6 # bit2 = enable custom tables 0x0F00..
+R_CPU_TBL_END=0x0F00; R_CPU_TBL_START=0x0F10; R_CPU_TBL_SPEED=0x0F20
+R_GPU_TBL_END=0x0F30; R_GPU_TBL_START=0x0F40; R_GPU_TBL_SPEED=0x0F50
+R_PWM1_W=0x1804; R_PWM2_W=0x1809 # writable duty 0..200 (old direct control)
+R_CPU_TEMP=0x043E; R_GPU_TEMP=0x044F
+R_FAN1_RPM=0x0464; R_FAN2_RPM=0x046C # 16-bit big-endian
+
+universal_ctrl() { ec_bit $R_FAN_CTRL 6; } # 1 = custom-table path available
+tables_enabled() { ec_bit $R_TBL_ENABLE 2; }
+pct_to_duty() { echo $(( $1 * 200 / 100 )); }
+duty_to_pct() { echo $(( $1 * 100 / 200 )); }
+
+fan_init_tables() { # TUXEDO uw_init_fan(): one controllable zone 0-115 °C + dummy fail-safe zones at 100 %
+ local i
+ ec_clear_bits $R_FAN_MODE 0x40
+ [ "$(ec_bit $R_TBL_SPLIT 7)" = 1 ] || ec_set_bits $R_TBL_SPLIT 0x80
+ ec_write $R_CPU_TBL_END 115; ec_write $R_CPU_TBL_START 0; ec_write $R_CPU_TBL_SPEED 1
+ ec_write $R_GPU_TBL_END 120; ec_write $R_GPU_TBL_START 0; ec_write $R_GPU_TBL_SPEED 1
+ for i in $(seq 1 15); do
+ ec_write $(( R_CPU_TBL_END + i )) $(( 115 + i + 1 )); ec_write $(( R_CPU_TBL_START + i )) $(( 115 + i )); ec_write $(( R_CPU_TBL_SPEED + i )) 200
+ ec_write $(( R_GPU_TBL_END + i )) $(( 115 + i + 1 )); ec_write $(( R_GPU_TBL_START + i )) $(( 115 + i )); ec_write $(( R_GPU_TBL_SPEED + i )) 200
+ done
+ [ "$(tables_enabled)" = 1 ] || ec_set_bits $R_TBL_ENABLE 0x04
+}
+fan_apply_duty() { # fan_apply_duty <0..200> (both fans; GPU fan is dead anyway)
+ local d=$1
+ if [ "$(universal_ctrl)" = 1 ]; then
+ [ "$(tables_enabled)" = 1 ] && [ "$(ec_bit $R_FAN_MODE 6)" = 0 ] || fan_init_tables
+ ec_write $R_CPU_TBL_SPEED "$d"; ec_write $R_GPU_TBL_SPEED "$d"
+ ec_write $R_PWM1_W "$d"; ec_write $R_PWM2_W "$d"
+ else # old path: "full fan mode" + direct duty; EC ramps to 100 % on entry, so write quickly & repeatedly
+ local i; [ "$(ec_bit $R_FAN_MODE 6)" = 1 ] || ec_set_bits $R_FAN_MODE 0x40
+ for i in 1 2 3 4 5 6 7 8 9 10; do ec_write $R_PWM1_W "$d"; ec_write $R_PWM2_W "$d"; sleep 0.01; done
+ fi
+}
+fan_set_auto() {
+ if [ "$(universal_ctrl)" = 1 ]; then
+ [ "$(tables_enabled)" = 1 ] && ec_clear_bits $R_TBL_ENABLE 0x04
+ [ "$(ec_bit $R_TBL_SPLIT 7)" = 1 ] && ec_clear_bits $R_TBL_SPLIT 0x80
+ fi
+ [ "$(ec_bit $R_FAN_MODE 6)" = 1 ] && ec_clear_bits $R_FAN_MODE 0x40
+ return 0
+}
+fan_mode_text() { # what is controlling the fans right now
+ if systemctl is-active --quiet "$FAN_UNIT" 2>/dev/null; then echo "curve daemon (fanfix)"; return; fi
+ if [ "$(ec_bit $R_FAN_MODE 6)" = 1 ]; then echo "manual (old full-fan mode)"; return; fi
+ if [ "$(tables_enabled)" = 1 ]; then echo "manual (custom table, $(duty_to_pct "$(ec_read $R_CPU_TBL_SPEED)") %)"; return; fi
+ echo "auto (EC curve)"
+}
+curve_pct_for_temp() { # linear interpolation over CURVE="t:p t:p ..."
+ local t=$1 curve=${CURVE:-$DEFAULT_CURVE} pt pp lt=-1 lp=-1
+ for pair in $curve; do pt=${pair%%:*}; pp=${pair##*:}
+ if [ "$t" -le "$pt" ]; then
+ if [ "$lt" -lt 0 ]; then echo "$pp"; else echo $(( lp + (pp - lp) * (t - lt) / (pt - lt) )); fi; return
+ fi; lt=$pt; lp=$pp
+ done
+ echo "$lp"
+}
+
+# ---------- commands -----------------------------------------------------------------------
+cmd_status() {
+ bold "fanfix status — $(date '+%H:%M:%S')"
+ local cap=$(( $(cap_khz)/1000 )) mx=$(( $(max_khz)/1000 ))
+ if [ "$cap" -le 3300 ]; then ok "CPU cap: ${cap} MHz (turbo disabled; hardware max ${mx} MHz)"; else bad "CPU NOT capped (${cap} MHz) — run: fanfix install"; fi
+ [ -e "$CONF" ] && ok "cap persists across reboots ($CONF)" || warn "cap is not persisted — run: fanfix install"
+ [ "$(rd /sys/devices/system/cpu/cpufreq/boost)" = 1 ] && ok "global boost flag = 1 (needed by power-profiles-daemon)" || warn "global boost flag = 0 — power-profiles-daemon profile switching will fail; run: fanfix install"
+ local prof; prof=$(powerprofilesctl get 2>/dev/null || echo '?')
+ [ "$prof" = performance ] && ok "power profile: performance (instant ramp-up)" || warn "power profile: $prof (performance is snappier; power-saver also safe)"
+ read -r avg mn mxf <<<"$(freqs)"
+ echo " temp: CPU $(tctl) °C package $(ppt_w) W clocks avg ${avg} / max ${mxf} MHz"
+ echo " $(fan_line)"
+ if systemctl is-active --quiet "$FAN_UNIT" 2>/dev/null; then ok "fan curve daemon running (fanfix fan curve off to stop)"; fi
+ local busy; busy=$(cpu_busy_pct); read -r avg mn mxf <<<"$(freqs)"
+ if [ "$busy" -gt 25 ] && [ "$mxf" -lt "$TRIP_MHZ" ]; then bad "EC THROTTLE ACTIVE RIGHT NOW (cores at ${mxf} MHz with ${busy}% load) — lower the cap: fanfix install 3000"; else ok "no EC throttle active (load ${busy}%)"; fi
+}
+
+cmd_watch() {
+ local iv=${1:-1}
+ echo "live view every ${iv}s — Ctrl-C to quit (THROTTLE = EC clamp active)"
+ printf "%-8s %-6s %-8s %-20s %-s\n" "time" "Tctl" "pkg W" "clocks avg/max MHz" "fans"
+ while true; do
+ read -r avg mn mxf <<<"$(freqs)"
+ local flag=""; [ "$mxf" -lt "$TRIP_MHZ" ] && flag=" <-- THROTTLE"
+ printf "%-8s %3d°C %6s %5d / %5d %s%s\n" "$(date +%H:%M:%S)" "$(tctl)" "$(ppt_w)" "$avg" "$mxf" "$(fan_line)" "$flag"
+ sleep "$iv"
+ done
+}
+
+cmd_test() {
+ local secs=${1:-30} threads=${2:-$(nproc)}
+ bold "fanfix test — ${threads} threads for ${secs}s (Ctrl-C aborts)"
+ echo "cap: $(( $(cap_khz)/1000 )) MHz profile: $(powerprofilesctl get 2>/dev/null)"
+ stress_start "$threads" "$secs"; trap 'stress_stop; exit 130' INT TERM
+ local i trips=0 prev=9999 tmax=0 fsum=0 n=0
+ for i in $(seq 1 "$secs"); do
+ sleep 1
+ read -r avg mn mxf <<<"$(freqs)"; local t; t=$(tctl)
+ [ "$mxf" -lt "$TRIP_MHZ" ] && [ "$prev" -ge "$TRIP_MHZ" ] && trips=$((trips+1)); prev=$mxf
+ [ "$t" -gt "$tmax" ] && tmax=$t; fsum=$((fsum+avg)); n=$((n+1))
+ printf " %3ds Tctl=%2d°C pkg=%5sW avg=%4d max=%4d MHz %s%s\n" "$i" "$t" "$(ppt_w)" "$avg" "$mxf" "$(fan_line)" "$([ "$mxf" -lt "$TRIP_MHZ" ] && echo ' <-- THROTTLE')"
+ done
+ stress_stop; trap - INT TERM
+ echo; bold "result: peak ${tmax}°C, mean clock $((fsum/n)) MHz, EC throttle events: ${trips}"
+ if [ "$trips" -gt 0 ]; then bad "throttling still happens — lower the cap: fanfix install 3000"; else ok "no throttling — cap is doing its job"; fi
+ [ "$tmax" -ge 75 ] && warn "peak ${tmax}°C is close to the 79°C trip; consider: fanfix install 3000"
+}
+
+cmd_install() {
+ local cap=${1:-$CAP_DEFAULT_KHZ}
+ [[ "$cap" =~ ^[0-9]+$ ]] || die "cap must be a number (MHz or kHz), e.g. 3200 or 3200000"
+ [ "$cap" -lt 100000 ] && cap=$((cap*1000)) # MHz given → kHz
+ if [ "$cap" -lt 1500000 ] || [ "$cap" -gt "$(max_khz)" ]; then die "cap out of range (1500–$(( $(max_khz)/1000 )) MHz)"; fi
+ bold "fanfix install — cap every core at $((cap/1000)) MHz (asks for sudo)"
+ if [ -e "$OLD_CONF" ] || [ "$(rd /sys/devices/system/cpu/cpufreq/boost)" = 0 ]; then
+ echo " undoing old global-boost approach (conflicts with power-profiles-daemon)"
+ sudo rm -f "$OLD_CONF"; echo 1 | sudo tee /sys/devices/system/cpu/cpufreq/boost >/dev/null
+ fi
+ sudo tee "$CONF" >/dev/null <<EOF
+# fanfix: broken GPU fan → EC pins CPU at 399 MHz at ~79 C. Cap every core so it never gets there.
+# Remove with: fanfix uninstall (after the fan is replaced)
+w /sys/devices/system/cpu/cpu*/cpufreq/scaling_max_freq - - - - ${cap}
+EOF
+ sudo systemd-tmpfiles --create "$CONF"
+ powerprofilesctl set performance 2>/dev/null || warn "could not set performance profile (powerprofilesctl get)"
+ sudo systemd-tmpfiles --create "$CONF" # profile switches toggle boost; re-assert the cap
+ ok "cap = $(( $(cap_khz)/1000 )) MHz on all cores, persisted in $CONF"
+ ok "profile = $(powerprofilesctl get 2>/dev/null)"
+ echo; cmd_test 20
+}
+
+cmd_uninstall() {
+ bold "fanfix uninstall — remove the CPU cap (asks for sudo)"
+ sudo rm -f "$CONF" "$OLD_CONF"
+ echo 1 | sudo tee /sys/devices/system/cpu/cpufreq/boost >/dev/null
+ local f; for f in /sys/devices/system/cpu/cpu*/cpufreq/scaling_max_freq; do sudo tee "$f" >/dev/null < "${f%scaling_max_freq}cpuinfo_max_freq"; done
+ ok "cap removed: max = $(( $(cap_khz)/1000 )) MHz, turbo boost back on"
+ warn "only do this once the GPU fan is replaced — otherwise the 399 MHz stalls come back"
+}
+
+cmd_fans() {
+ local persist=0; [ "${1:-}" = "--persist" ] && persist=1
+ if ! hwmon_by_name uniwill >/dev/null; then
+ bold "loading fan driver (uniwill-laptop force=1) (asks for sudo)"
+ sudo modprobe uniwill-laptop force=1 || die "modprobe failed"; sleep 1
+ fi
+ if [ "$persist" = 1 ]; then
+ echo 'uniwill-laptop' | sudo tee /etc/modules-load.d/uniwill-laptop.conf >/dev/null
+ echo 'options uniwill-laptop force=1' | sudo tee /etc/modprobe.d/uniwill-laptop.conf >/dev/null
+ ok "fan driver will load at every boot (remove /etc/modules-load.d/uniwill-laptop.conf to undo)"
+ fi
+ echo " $(fan_line)"
+ local h; h=$(hwmon_by_name uniwill); [ "$(rd "$h/fan2_input")" = 0 ] && [ "$(rd "$h/pwm2")" -gt 0 ] && bad "GPU fan commanded $(( $(rd "$h/pwm2")*100/255 ))% but 0 rpm → dead/jammed" || true
+}
+
+# ---- fan control ----
+cmd_fan() {
+ local sub=${1:-status}; shift || true
+ case "$sub" in
+ setup) cmd_fan_setup "$@" ;;
+ status) need_root fan status; cmd_fan_status ;;
+ auto) need_root fan auto; fan_guard; fan_stop_daemon; fan_set_auto && ok "fans back to automatic (EC curve)" ;;
+ max) need_root fan max; fan_guard; fan_stop_daemon; fan_apply_duty 200 && ok "fans set to 100 % (manual) — 'fanfix fan auto' to release" ;;
+ curve) cmd_fan_curve "$@" ;;
+ [0-9]*) local p=${sub%\%}; [[ "$p" =~ ^[0-9]+$ ]] || die "percent must be a number"
+ [ "$p" -le 100 ] || die "max is 100"
+ [ "$p" -ge "$FAN_MIN_PCT" ] || die "refusing < ${FAN_MIN_PCT} % — EC stalls the CPU at 79 °C; 30 % is the floor (use 'fanfix fan auto' for idle fan-off)"
+ need_root fan "$sub"; fan_guard; fan_stop_daemon
+ fan_apply_duty "$(pct_to_duty "$p")" && ok "fans set to ${p} % (manual) — stays until 'fanfix fan auto' (EC trip at 79 °C still protects you)" ;;
+ -h|--help|help) cmd_help ;;
+ *) die "unknown fan command: $sub" ;;
+ esac
+}
+fan_guard() { ec_ready || die "acpi_call not available — run: fanfix fan setup"; ec_read $R_AP_OEM >/dev/null || die "EC not reachable via ${EC_DEV}.ECRR"; }
+fan_stop_daemon() { systemctl is-active --quiet "$FAN_UNIT" 2>/dev/null && { systemctl stop "$FAN_UNIT"; echo " (stopped fan curve daemon)"; }; return 0; }
+
+cmd_fan_setup() {
+ bold "fanfix fan setup — install EC access (acpi_call) and load drivers (asks for sudo)"
+ if ! modinfo acpi_call >/dev/null 2>&1; then
+ sudo pacman -S --needed --noconfirm acpi_call-dkms || die "pacman failed (needs internet; package: acpi_call-dkms)"
+ fi
+ sudo modprobe acpi_call || die "could not load acpi_call"
+ sudo modprobe uniwill-laptop force=1 2>/dev/null || true
+ printf 'acpi_call\nuniwill-laptop\n' | sudo tee /etc/modules-load.d/fanfix.conf >/dev/null
+ echo 'options uniwill-laptop force=1' | sudo tee /etc/modprobe.d/uniwill-laptop.conf >/dev/null
+ ok "acpi_call + uniwill-laptop load at every boot"
+ sudo "$SELF" fan status
+}
+
+cmd_fan_status() {
+ fan_guard
+ bold "fanfix fan status"
+ local ap; ap=$(ec_read $R_AP_OEM)
+ echo " mode : $(fan_mode_text)"
+ echo " CPU fan : $(( $(ec_read $R_FAN1_RPM)*256 + $(ec_read $((R_FAN1_RPM+1))) )) rpm duty $(duty_to_pct "$(ec_read $R_PWM1)") % CPU $(ec_read $R_CPU_TEMP) °C"
+ echo " GPU fan : $(( $(ec_read $R_FAN2_RPM)*256 + $(ec_read $((R_FAN2_RPM+1))) )) rpm duty $(duty_to_pct "$(ec_read $R_PWM2)") % GPU $(ec_read $R_GPU_TEMP) °C"
+ echo " EC flags : fan-abnormal=$(( (ap>>5)&1 )) manual-ctrl=$(( ap&1 )) universal-fan-ctrl=$(universal_ctrl) custom-tables=$(tables_enabled) full-fan-mode=$(ec_bit $R_FAN_MODE 6) (0x0751=$(printf '0x%02x' "$(ec_read $R_FAN_MODE)"))"
+ [ "$(( (ap>>5)&1 ))" = 1 ] && warn "EC reports FAN ABNORMAL — this is the flag behind the 79 °C throttle policy"
+ systemctl is-active --quiet "$FAN_UNIT" 2>/dev/null && echo " curve : ${CURVE:-$DEFAULT_CURVE} (from ${FAN_CONF}, if present)"
+ return 0
+}
+
+cmd_fan_curve() {
+ local a=${1:-status}
+ case "$a" in
+ on|start)
+ need_root fan curve on; fan_guard
+ cat > /etc/systemd/system/$FAN_UNIT <<EOF
+[Unit]
+Description=fanfix: temperature → fan-duty curve for the surviving CPU fan (dead GPU fan workaround)
+After=multi-user.target
+[Service]
+Type=simple
+ExecStart=$SELF fan-daemon
+ExecStopPost=$SELF fan-release
+Restart=on-failure
+RestartSec=5
+[Install]
+WantedBy=multi-user.target
+EOF
+ systemctl daemon-reload && systemctl enable --now "$FAN_UNIT" && ok "fan curve daemon enabled + started (survives reboot). Curve: ${CURVE:-$DEFAULT_CURVE}" \
+ && echo " edit ${FAN_CONF} to change it, e.g. CURVE=\"45:30 55:45 65:70 75:100\" then: fanfix fan curve restart" ;;
+ off|stop)
+ need_root fan curve off
+ systemctl disable --now "$FAN_UNIT" 2>/dev/null; rm -f /etc/systemd/system/$FAN_UNIT; systemctl daemon-reload
+ fan_guard; fan_set_auto && ok "curve daemon removed, fans back to automatic" ;;
+ restart) need_root fan curve restart; systemctl restart "$FAN_UNIT" && ok "restarted" ;;
+ status|*) systemctl is-active --quiet "$FAN_UNIT" 2>/dev/null && { ok "curve daemon running"; systemctl status "$FAN_UNIT" --no-pager -n 5 2>/dev/null | tail -6; } || echo " curve daemon not running (fanfix fan curve on)" ;;
+ esac
+}
+
+cmd_fan_daemon() { # root; run by the systemd unit
+ [ -r "$FAN_CONF" ] && . "$FAN_CONF"
+ ec_ready || { echo "acpi_call missing"; exit 1; }
+ trap 'fan_set_auto; exit 0' TERM INT
+ local last=-1 t p d n=0
+ echo "fanfix fan-daemon: curve ${CURVE:-$DEFAULT_CURVE}"
+ while true; do
+ t=$(tctl); [ "$t" -le 0 ] && t=$(ec_read $R_CPU_TEMP 2>/dev/null || echo 0)
+ if [ "$t" -le 0 ]; then fan_set_auto; echo "no temperature reading — fans released to auto"; sleep 5; continue; fi
+ p=$(curve_pct_for_temp "$t"); [ "$p" -lt "$FAN_MIN_PCT" ] && p=$FAN_MIN_PCT
+ d=$(pct_to_duty "$p")
+ # re-apply when the target changes by ≥2 %, and at least every 60 s (old-path ECs revert after a while)
+ local changed=0; [ $(( d > last ? d - last : last - d )) -ge 4 ] && changed=1
+ if [ "$changed" = 1 ] || [ $(( n % 30 )) = 0 ]; then
+ fan_apply_duty "$d" && last=$d
+ [ "$changed" = 1 ] && echo "CPU ${t} °C → fan ${p} %"
+ fi
+ n=$((n+1)); sleep 2
+ done
+}
+
+cmd_help() {
+ cat <<EOF
+fanfix — live with the dead GPU fan (firmware pins CPU at 399 MHz when CPU ≥ ~79 °C)
+
+CPU clock cap (the fix for the stalls)
+ fanfix status what's applied, temps, fans, is the throttle active right now
+ fanfix watch [sec] live one-line-per-second view (Ctrl-C to quit)
+ fanfix test [sec] [thr] all-core stress test with readout; counts throttle events (default 30 s)
+ fanfix install [MHz] apply + persist the clock cap (default 3200) and performance profile [sudo]
+ fanfix uninstall remove the cap — only after the fan is replaced [sudo]
+ fanfix fans [--persist] load the fan-RPM driver (and optionally at every boot) [sudo]
+
+Fan control (surviving CPU fan; talks to the EC the way TUXEDO's driver does)
+ fanfix fan setup one-time: install acpi_call-dkms, load + persist drivers [sudo]
+ fanfix fan status mode, rpm, duty, EC flags (incl. the fan-abnormal flag) [sudo]
+ fanfix fan <30-100> fixed fan speed in % (manual, until 'auto'); e.g. fanfix fan 60 [sudo]
+ fanfix fan max 100 % [sudo]
+ fanfix fan auto give control back to the EC [sudo]
+ fanfix fan curve on|off background daemon: CPU temp → fan %, more cooling than the EC's curve [sudo]
+ default curve ${DEFAULT_CURVE}; override in ${FAN_CONF}
+
+Typical: fanfix status · stalls seen? → fanfix install 3000 · want more headroom? → fanfix fan curve on · fan replaced? → fanfix uninstall
+EOF
+}
+
+case "${1:-help}" in
+ status) shift; cmd_status "$@" ;;
+ watch) shift; cmd_watch "$@" ;;
+ test) shift; cmd_test "$@" ;;
+ install|cap) shift; cmd_install "$@" ;;
+ uninstall|uncap) shift; cmd_uninstall "$@" ;;
+ fans) shift; cmd_fans "$@" ;;
+ fan) shift; cmd_fan "$@" ;;
+ fan-daemon) cmd_fan_daemon ;;
+ fan-release) ec_ready && fan_set_auto ;;
+ help|-h|--help) cmd_help ;;
+ *) bad "unknown command: $1"; cmd_help; exit 2 ;;
+esac
diff --git a/hosts/laptop/hardware-configuration.nix b/hosts/laptop/hardware-configuration.nix
@@ -0,0 +1,45 @@
+# Do not modify this file! It was generated by ‘nixos-generate-config’
+# and may be overwritten by future invocations. Please make changes
+# to /etc/nixos/configuration.nix instead.
+{ config, lib, pkgs, modulesPath, ... }:
+
+{
+ imports =
+ [ (modulesPath + "/installer/scan/not-detected.nix")
+ ];
+
+ boot.initrd.availableKernelModules = [ "nvme" "xhci_pci" "usbhid" "usb_storage" "sd_mod" ];
+ boot.initrd.kernelModules = [ ];
+ boot.kernelModules = [ "kvm-amd" ];
+ boot.extraModulePackages = [ ];
+
+ fileSystems."/" =
+ { device = "/dev/disk/by-uuid/e2664576-4e65-4c79-ac1c-9e2fa9e12a26";
+ fsType = "btrfs";
+ };
+
+ fileSystems."/home" =
+ { device = "/dev/disk/by-uuid/e2664576-4e65-4c79-ac1c-9e2fa9e12a26";
+ fsType = "btrfs";
+ options = [ "subvol=home" ];
+ };
+
+ fileSystems."/nix" =
+ { device = "/dev/disk/by-uuid/e2664576-4e65-4c79-ac1c-9e2fa9e12a26";
+ fsType = "btrfs";
+ options = [ "subvol=nix" ];
+ };
+
+ fileSystems."/boot" =
+ { device = "/dev/disk/by-uuid/D3FC-22C2";
+ fsType = "vfat";
+ options = [ "fmask=0077" "dmask=0077" ];
+ };
+
+ swapDevices =
+ [ { device = "/dev/disk/by-uuid/6aee8a42-a2a1-4d18-8f5c-695195e0c122"; }
+ ];
+
+ nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
+ hardware.cpu.amd.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware;
+}
diff --git a/hosts/laptop/nix-settings.nix b/hosts/laptop/nix-settings.nix
@@ -0,0 +1,17 @@
+# hosts/laptop/nix-settings.nix
+{ ... }:
+{
+ nix.settings = {
+ experimental-features = [ "nix-command" "flakes" ];
+ # Hyprland is built from its own flake pins, which cache.nixos.org does
+ # not have. Without the Hyprland cache every update compiles it locally.
+ substituters = [
+ "https://cache.nixos.org"
+ "https://hyprland.cachix.org"
+ ];
+ trusted-public-keys = [
+ "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="
+ "hyprland.cachix.org-1:a7pgxzMz7+chwVL3/pzj6jIBMioiJM7ypFP8PwtkuGc="
+ ];
+ };
+}
diff --git a/hosts/laptop/nvidia.nix b/hosts/laptop/nvidia.nix
@@ -0,0 +1,42 @@
+# hosts/laptop/nvidia.nix
+#
+# The panel (eDP-1) is wired to the RTX 3070 Ti at 01:00.0: the firmware MUX is
+# in discrete mode (amdgpu's eDP-2 reports disconnected). Linux cannot change
+# the MUX, so this configures what the hardware presents: NVIDIA open kernel
+# module with modesetting, the Radeon 680M left as a secondary DRM device.
+#
+# PARKED: if the MUX is switched to hybrid in the BIOS, replace the AQ_DRM_DEVICES
+# order with the amdgpu card first and add the prime offload block at the bottom.
+{ config, pkgs, lib, ... }:
+{
+ services.xserver.videoDrivers = [ "nvidia" ];
+
+ hardware.graphics = {
+ enable = true;
+ extraPackages = [ pkgs.nvidia-vaapi-driver ];
+ };
+
+ hardware.nvidia = {
+ open = true; # GA104 is supported by the open kernel modules
+ modesetting.enable = true; # nvidia-drm.modeset=1
+ package = config.boot.kernelPackages.nvidiaPackages.stable;
+ nvidiaSettings = false;
+ # powerManagement.enable = true; # suspend/resume helpers; untested on this laptop, left at default
+ };
+
+ environment.sessionVariables = {
+ # Stable device order for Hyprland/aquamarine: dGPU (panel) first, iGPU second.
+ AQ_DRM_DEVICES = "/dev/dri/by-path/pci-0000:01:00.0-card:/dev/dri/by-path/pci-0000:06:00.0-card";
+ LIBVA_DRIVER_NAME = "nvidia";
+ __GLX_VENDOR_LIBRARY_NAME = "nvidia";
+ NVD_BACKEND = "direct";
+ };
+
+ # Hybrid (iGPU drives the panel, dGPU on demand). Only if the BIOS MUX is set to hybrid:
+ # hardware.nvidia.prime = {
+ # offload.enable = true;
+ # offload.enableOffloadCmd = true;
+ # amdgpuBusId = "PCI:6:0:0";
+ # nvidiaBusId = "PCI:1:0:0";
+ # };
+}
diff --git a/hosts/laptop/ssd.nix b/hosts/laptop/ssd.nix
@@ -0,0 +1,30 @@
+# hosts/laptop/ssd.nix
+#
+# Spare Samsung SSD 980 1 TB (LUKS2, btrfs label SSD) at /mnt/ssd, unlocked at
+# boot from /etc/secrets/ssd.key. The key is restored by hand once (vault
+# samsung-ssd.md, section 2):
+#
+# sudo mkdir -p -m 700 /etc/secrets
+# gpg -d --pinentry-mode loopback ssd.key.gpg | sudo tee /etc/secrets/ssd.key >/dev/null
+# sudo chmod 400 /etc/secrets/ssd.key
+#
+# `nofail` on both lines: the laptop boots normally while the key (or the
+# drive) is missing; the unit just fails. Swap the key path for `none` to type
+# the passphrase at boot instead.
+{ ... }:
+{
+ environment.etc.crypttab.text = ''
+ ssd UUID=8cd17d0f-adf3-430f-9d95-d17ef42df9b0 /etc/secrets/ssd.key luks,nofail
+ '';
+
+ fileSystems."/mnt/ssd" = {
+ device = "/dev/mapper/ssd";
+ fsType = "btrfs";
+ options = [
+ "compress=zstd:3"
+ "noatime"
+ "nofail"
+ "x-systemd.device-timeout=10s"
+ ];
+ };
+}
diff --git a/hosts/laptop/stability_guard.py b/hosts/laptop/stability_guard.py
@@ -0,0 +1,115 @@
+#!/usr/bin/python3
+"""Use ordinary Linux CPU ceilings to reduce power/temperature clamp triggers."""
+from datetime import datetime, timezone
+import json
+import math
+import os
+from pathlib import Path
+import signal
+import time
+
+POLICIES = Path('/sys/devices/system/cpu/cpufreq')
+STATUS = Path('/run/motherboard-stability/status.json')
+
+
+def read_number(path, divisor=1):
+ try:
+ value = float(Path(path).read_text().strip()) / divisor
+ return value if math.isfinite(value) else None
+ except (OSError, ValueError):
+ return None
+
+
+def choose_limit(ac_online, cpu_c, gpu_c, main_rpm, previous_stage=0):
+ if (cpu_c is None or gpu_c is None or main_rpm is None or main_rpm <= 0
+ or not all(math.isfinite(t) and 0 <= t <= 125 for t in (cpu_c, gpu_c))):
+ return 1800000, 2, 'essential sensor or main fan unavailable'
+ hottest = max(cpu_c, gpu_c)
+ if hottest >= 73 or (previous_stage == 2 and hottest > 66):
+ return 1800000, 2, 'hot: CPU or board GPU temperature at/above 73C, or cooling down'
+ if hottest >= 65 or (previous_stage >= 1 and hottest > 58):
+ return 2400000, 1, 'warm: CPU or board GPU temperature at/above 65C, or cooling down'
+ if ac_online is not True:
+ return 2400000, 0, 'battery or mains status unavailable'
+ return 3200000, 0, 'mains power; normal temperatures'
+
+
+def find_hwmon(name):
+ for path in Path('/sys/class/hwmon').glob('hwmon*'):
+ try:
+ if (path / 'name').read_text().strip() == name:
+ return path
+ except OSError:
+ continue
+ return None
+
+
+def set_limits(target):
+ policies = sorted(POLICIES.glob('policy*'))
+ if len(policies) != 16:
+ raise RuntimeError('Unexpected CPU policy count')
+ for p in policies:
+ low = read_number(p / 'scaling_min_freq')
+ high = read_number(p / 'cpuinfo_max_freq')
+ if low is None or high is None or not low <= target <= high:
+ raise RuntimeError('Requested ceiling conflicts with CPU bounds: ' + p.name)
+ for p in policies:
+ if read_number(p / 'scaling_max_freq') != target:
+ (p / 'scaling_max_freq').write_text(str(target))
+ deadline = time.monotonic() + 5
+ while True:
+ values = [read_number(p / 'scaling_max_freq') for p in policies]
+ if all(value == target for value in values):
+ return
+ if time.monotonic() >= deadline:
+ raise RuntimeError('CPU ceiling readback did not settle: ' + repr(values))
+ time.sleep(0.05)
+
+
+def write_status(data):
+ temporary = STATUS.with_suffix('.tmp')
+ temporary.write_text(json.dumps(data, indent=2) + '\n')
+ os.replace(temporary, STATUS)
+
+
+def main():
+ if Path('/sys/class/dmi/id/board_name').read_text().strip() != 'GM7RGxM':
+ raise RuntimeError('Unsupported motherboard')
+ if 'AMD Ryzen 9 6900HX' not in Path('/proc/cpuinfo').read_text():
+ raise RuntimeError('Unsupported CPU')
+ stage = 0
+ running = True
+ last = None
+ data = {}
+ def stop(signum, frame):
+ nonlocal running
+ running = False
+ signal.signal(signal.SIGTERM, stop)
+ signal.signal(signal.SIGINT, stop)
+ try:
+ while running:
+ cpu, board = find_hwmon('k10temp'), find_hwmon('uniwill')
+ cpu_c = read_number(cpu / 'temp1_input', 1000) if cpu else None
+ gpu_c = read_number(board / 'temp2_input', 1000) if board else None
+ rpm = read_number(board / 'fan1_input') if board else None
+ ac = read_number('/sys/class/power_supply/AC0/online') == 1
+ target, stage, reason = choose_limit(ac, cpu_c, gpu_c, rpm, stage)
+ set_limits(target)
+ data = {'running': True, 'timestamp': datetime.now(timezone.utc).isoformat(),
+ 'limit_mhz': target // 1000, 'thermal_stage': stage, 'reason': reason,
+ 'mains_online': ac, 'cpu_c': cpu_c, 'board_gpu_c': gpu_c, 'main_fan_rpm': rpm,
+ 'note': 'Avoidance workaround; firmware thermal/power protection remains active.'}
+ write_status(data)
+ if last != (target, stage, reason):
+ print(json.dumps(data), flush=True)
+ last = (target, stage, reason)
+ time.sleep(1)
+ finally:
+ if data:
+ data['running'] = False
+ data['timestamp'] = datetime.now(timezone.utc).isoformat()
+ write_status(data)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/hosts/laptop/toolbox.nix b/hosts/laptop/toolbox.nix
@@ -0,0 +1,26 @@
+# hosts/laptop/toolbox.nix
+#
+# NixOS-side support for the hand-written toolbox that lives, flat, in
+# ~/.local/bin (its own git repo; not in the Nix store). Shared by the target
+# and the bootstrap configuration.
+{ pkgs, lib, user, ... }:
+{
+ # Several tools keep Arch-style shebangs (#!/bin/bash: fanfix, dropterm,
+ # omarchy-menu-*; #!/usr/bin/python3: lid-control). envfs resolves those
+ # paths from the caller's PATH instead of patching the scripts.
+ services.envfs.enable = true;
+
+ # ~/.local/bin first on PATH (prepends in /etc/set-environment).
+ environment.localBinInPath = true;
+
+ users.users.${user}.extraGroups = [
+ "input" # padx talks to the touchpad over hidraw
+ "video"
+ ];
+
+ # padx: the Pixart 093A:0274 touchpad behind the UNIW0001 I2C-HID bridge.
+ # Replaces ~/trackpad-fix/60-padx-touchpad.rules from the Arch install.
+ services.udev.extraRules = ''
+ KERNEL=="hidraw*", SUBSYSTEM=="hidraw", ATTRS{modalias}=="hid:b0018g*v0000093Ap00000274", GROUP="input", MODE="0660"
+ '';
+}
diff --git a/hosts/laptop/uniwill-laptop.nix b/hosts/laptop/uniwill-laptop.nix
@@ -0,0 +1,20 @@
+# hosts/laptop/uniwill-laptop.nix
+#
+# fan-throttle-guard.nix expects the in-tree `uniwill-laptop` driver (the
+# `uniwill` hwmon: fan1/fan2 rpm, pwm1/pwm2, board temps; also the keyboard
+# backlight LED and the touchpad-toggle bit padx mentions). Upstream merged it
+# in Linux 6.19; the nixpkgs 6.18 kernel predates it and the 7.2 kernel config
+# leaves X86_PLATFORM_DRIVERS_UNIWILL off. Building the v6.19 sources as an
+# out-of-tree module against whatever boot.kernelPackages selects is the cheap
+# fix: a 10-second compile, no custom kernel. Verified to build on 6.18.55.
+#
+# Without this hwmon stability_guard.py pins the CPU at 1.8 GHz ("essential
+# sensor or main fan unavailable"), so this file is not optional.
+{ config, lib, pkgs, ... }:
+{
+ boot.extraModulePackages = [
+ (config.boot.kernelPackages.callPackage ./uniwill-laptop/package.nix { })
+ ];
+ # `boot.kernelModules` and the `force=1` modprobe option (this GM7RGxM is not
+ # in the driver's DMI list) are set in fan-throttle-guard.nix.
+}
diff --git a/hosts/laptop/uniwill-laptop/Makefile b/hosts/laptop/uniwill-laptop/Makefile
@@ -0,0 +1,11 @@
+# Out-of-tree build of the in-tree uniwill-laptop driver (one module from two objects).
+ifneq ($(KERNELRELEASE),)
+obj-m := uniwill-laptop.o
+uniwill-laptop-y := uniwill-acpi.o uniwill-wmi.o
+else
+KDIR ?= /lib/modules/$(shell uname -r)/build
+all:
+ $(MAKE) -C $(KDIR) M=$(CURDIR) modules
+clean:
+ $(MAKE) -C $(KDIR) M=$(CURDIR) clean
+endif
diff --git a/hosts/laptop/uniwill-laptop/package.nix b/hosts/laptop/uniwill-laptop/package.nix
@@ -0,0 +1,41 @@
+# Out-of-tree build of drivers/platform/x86/uniwill from torvalds/linux v6.19
+# (uniwill-acpi.c, uniwill-wmi.c, uniwill-wmi.h, vendored verbatim; GPL-2.0-or-later).
+# Takes `kernel` and `kernelModuleMakeFlags` from the kernelPackages scope.
+{
+ lib,
+ stdenv,
+ kernel,
+ kernelModuleMakeFlags,
+}:
+stdenv.mkDerivation {
+ pname = "uniwill-laptop";
+ version = "6.19-${kernel.version}";
+
+ src = lib.fileset.toSource {
+ root = ./.;
+ fileset = lib.fileset.unions [
+ ./Makefile
+ ./uniwill-acpi.c
+ ./uniwill-wmi.c
+ ./uniwill-wmi.h
+ ];
+ };
+
+ hardeningDisable = [ "pic" ];
+ nativeBuildInputs = kernel.moduleBuildDependencies;
+
+ makeFlags = kernelModuleMakeFlags ++ [
+ "KDIR=${kernel.dev}/lib/modules/${kernel.modDirVersion}/build"
+ ];
+
+ installPhase = ''
+ install -D uniwill-laptop.ko $out/lib/modules/${kernel.modDirVersion}/extra/uniwill-laptop.ko
+ '';
+
+ meta = {
+ description = "Uniwill/TongFang laptop extras (hwmon fans and temperatures, lightbar, hotkeys), Linux 6.19 driver for older kernels";
+ homepage = "https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/drivers/platform/x86/uniwill";
+ license = lib.licenses.gpl2Plus;
+ platforms = [ "x86_64-linux" ];
+ };
+}
diff --git a/hosts/laptop/uniwill-laptop/uniwill-acpi.c b/hosts/laptop/uniwill-laptop/uniwill-acpi.c
@@ -0,0 +1,1919 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+/*
+ * Linux driver for Uniwill notebooks.
+ *
+ * Special thanks go to Pőcze Barnabás, Christoffer Sandberg and Werner Sembach
+ * for supporting the development of this driver either through prior work or
+ * by answering questions regarding the underlying ACPI and WMI interfaces.
+ *
+ * Copyright (C) 2025 Armin Wolf <W_Armin@gmx.de>
+ */
+
+#define pr_fmt(fmt) KBUILD_MODNAME ": " fmt
+
+#include <linux/acpi.h>
+#include <linux/array_size.h>
+#include <linux/bits.h>
+#include <linux/bitfield.h>
+#include <linux/cleanup.h>
+#include <linux/debugfs.h>
+#include <linux/delay.h>
+#include <linux/device.h>
+#include <linux/device/driver.h>
+#include <linux/dmi.h>
+#include <linux/errno.h>
+#include <linux/fixp-arith.h>
+#include <linux/hwmon.h>
+#include <linux/hwmon-sysfs.h>
+#include <linux/init.h>
+#include <linux/input.h>
+#include <linux/input/sparse-keymap.h>
+#include <linux/kernel.h>
+#include <linux/kstrtox.h>
+#include <linux/leds.h>
+#include <linux/led-class-multicolor.h>
+#include <linux/limits.h>
+#include <linux/list.h>
+#include <linux/minmax.h>
+#include <linux/module.h>
+#include <linux/mutex.h>
+#include <linux/notifier.h>
+#include <linux/platform_device.h>
+#include <linux/pm.h>
+#include <linux/printk.h>
+#include <linux/regmap.h>
+#include <linux/string.h>
+#include <linux/sysfs.h>
+#include <linux/types.h>
+#include <linux/units.h>
+
+#include <acpi/battery.h>
+
+#include "uniwill-wmi.h"
+
+#define EC_ADDR_BAT_POWER_UNIT_1 0x0400
+
+#define EC_ADDR_BAT_POWER_UNIT_2 0x0401
+
+#define EC_ADDR_BAT_DESIGN_CAPACITY_1 0x0402
+
+#define EC_ADDR_BAT_DESIGN_CAPACITY_2 0x0403
+
+#define EC_ADDR_BAT_FULL_CAPACITY_1 0x0404
+
+#define EC_ADDR_BAT_FULL_CAPACITY_2 0x0405
+
+#define EC_ADDR_BAT_DESIGN_VOLTAGE_1 0x0408
+
+#define EC_ADDR_BAT_DESIGN_VOLTAGE_2 0x0409
+
+#define EC_ADDR_BAT_STATUS_1 0x0432
+#define BAT_DISCHARGING BIT(0)
+
+#define EC_ADDR_BAT_STATUS_2 0x0433
+
+#define EC_ADDR_BAT_CURRENT_1 0x0434
+
+#define EC_ADDR_BAT_CURRENT_2 0x0435
+
+#define EC_ADDR_BAT_REMAIN_CAPACITY_1 0x0436
+
+#define EC_ADDR_BAT_REMAIN_CAPACITY_2 0x0437
+
+#define EC_ADDR_BAT_VOLTAGE_1 0x0438
+
+#define EC_ADDR_BAT_VOLTAGE_2 0x0439
+
+#define EC_ADDR_CPU_TEMP 0x043E
+
+#define EC_ADDR_GPU_TEMP 0x044F
+
+#define EC_ADDR_MAIN_FAN_RPM_1 0x0464
+
+#define EC_ADDR_MAIN_FAN_RPM_2 0x0465
+
+#define EC_ADDR_SECOND_FAN_RPM_1 0x046C
+
+#define EC_ADDR_SECOND_FAN_RPM_2 0x046D
+
+#define EC_ADDR_DEVICE_STATUS 0x047B
+#define WIFI_STATUS_ON BIT(7)
+/* BIT(5) is also unset depending on the rfkill state (bluetooth?) */
+
+#define EC_ADDR_BAT_ALERT 0x0494
+
+#define EC_ADDR_BAT_CYCLE_COUNT_1 0x04A6
+
+#define EC_ADDR_BAT_CYCLE_COUNT_2 0x04A7
+
+#define EC_ADDR_PROJECT_ID 0x0740
+
+#define EC_ADDR_AP_OEM 0x0741
+#define ENABLE_MANUAL_CTRL BIT(0)
+#define ITE_KBD_EFFECT_REACTIVE BIT(3)
+#define FAN_ABNORMAL BIT(5)
+
+#define EC_ADDR_SUPPORT_5 0x0742
+#define FAN_TURBO_SUPPORTED BIT(4)
+#define FAN_SUPPORT BIT(5)
+
+#define EC_ADDR_CTGP_DB_CTRL 0x0743
+#define CTGP_DB_GENERAL_ENABLE BIT(0)
+#define CTGP_DB_DB_ENABLE BIT(1)
+#define CTGP_DB_CTGP_ENABLE BIT(2)
+
+#define EC_ADDR_CTGP_OFFSET 0x0744
+
+#define EC_ADDR_TPP_OFFSET 0x0745
+
+#define EC_ADDR_MAX_TGP 0x0746
+
+#define EC_ADDR_LIGHTBAR_AC_CTRL 0x0748
+#define LIGHTBAR_APP_EXISTS BIT(0)
+#define LIGHTBAR_POWER_SAVE BIT(1)
+#define LIGHTBAR_S0_OFF BIT(2)
+#define LIGHTBAR_S3_OFF BIT(3) // Breathing animation when suspended
+#define LIGHTBAR_WELCOME BIT(7) // Rainbow animation
+
+#define EC_ADDR_LIGHTBAR_AC_RED 0x0749
+
+#define EC_ADDR_LIGHTBAR_AC_GREEN 0x074A
+
+#define EC_ADDR_LIGHTBAR_AC_BLUE 0x074B
+
+#define EC_ADDR_BIOS_OEM 0x074E
+#define FN_LOCK_STATUS BIT(4)
+
+#define EC_ADDR_MANUAL_FAN_CTRL 0x0751
+#define FAN_LEVEL_MASK GENMASK(2, 0)
+#define FAN_MODE_TURBO BIT(4)
+#define FAN_MODE_HIGH BIT(5)
+#define FAN_MODE_BOOST BIT(6)
+#define FAN_MODE_USER BIT(7)
+
+#define EC_ADDR_PWM_1 0x075B
+
+#define EC_ADDR_PWM_2 0x075C
+
+/* Unreliable */
+#define EC_ADDR_SUPPORT_1 0x0765
+#define AIRPLANE_MODE BIT(0)
+#define GPS_SWITCH BIT(1)
+#define OVERCLOCK BIT(2)
+#define MACRO_KEY BIT(3)
+#define SHORTCUT_KEY BIT(4)
+#define SUPER_KEY_LOCK BIT(5)
+#define LIGHTBAR BIT(6)
+#define FAN_BOOST BIT(7)
+
+#define EC_ADDR_SUPPORT_2 0x0766
+#define SILENT_MODE BIT(0)
+#define USB_CHARGING BIT(1)
+#define RGB_KEYBOARD BIT(2)
+#define CHINA_MODE BIT(5)
+#define MY_BATTERY BIT(6)
+
+#define EC_ADDR_TRIGGER 0x0767
+#define TRIGGER_SUPER_KEY_LOCK BIT(0)
+#define TRIGGER_LIGHTBAR BIT(1)
+#define TRIGGER_FAN_BOOST BIT(2)
+#define TRIGGER_SILENT_MODE BIT(3)
+#define TRIGGER_USB_CHARGING BIT(4)
+#define RGB_APPLY_COLOR BIT(5)
+#define RGB_LOGO_EFFECT BIT(6)
+#define RGB_RAINBOW_EFFECT BIT(7)
+
+#define EC_ADDR_SWITCH_STATUS 0x0768
+#define SUPER_KEY_LOCK_STATUS BIT(0)
+#define LIGHTBAR_STATUS BIT(1)
+#define FAN_BOOST_STATUS BIT(2)
+#define MACRO_KEY_STATUS BIT(3)
+#define MY_BAT_POWER_BAT_STATUS BIT(4)
+
+#define EC_ADDR_RGB_RED 0x0769
+
+#define EC_ADDR_RGB_GREEN 0x076A
+
+#define EC_ADDR_RGB_BLUE 0x076B
+
+#define EC_ADDR_ROMID_START 0x0770
+#define ROMID_LENGTH 14
+
+#define EC_ADDR_ROMID_EXTRA_1 0x077E
+
+#define EC_ADDR_ROMID_EXTRA_2 0x077F
+
+#define EC_ADDR_BIOS_OEM_2 0x0782
+#define FAN_V2_NEW BIT(0)
+#define FAN_QKEY BIT(1)
+#define FAN_TABLE_OFFICE_MODE BIT(2)
+#define FAN_V3 BIT(3)
+#define DEFAULT_MODE BIT(4)
+
+#define EC_ADDR_PL1_SETTING 0x0783
+
+#define EC_ADDR_PL2_SETTING 0x0784
+
+#define EC_ADDR_PL4_SETTING 0x0785
+
+#define EC_ADDR_FAN_DEFAULT 0x0786
+#define FAN_CURVE_LENGTH 5
+
+#define EC_ADDR_KBD_STATUS 0x078C
+#define KBD_WHITE_ONLY BIT(0) // ~single color
+#define KBD_SINGLE_COLOR_OFF BIT(1)
+#define KBD_TURBO_LEVEL_MASK GENMASK(3, 2)
+#define KBD_APPLY BIT(4)
+#define KBD_BRIGHTNESS GENMASK(7, 5)
+
+#define EC_ADDR_FAN_CTRL 0x078E
+#define FAN3P5 BIT(1)
+#define CHARGING_PROFILE BIT(3)
+#define UNIVERSAL_FAN_CTRL BIT(6)
+
+#define EC_ADDR_BIOS_OEM_3 0x07A3
+#define FAN_REDUCED_DURY_CYCLE BIT(5)
+#define FAN_ALWAYS_ON BIT(6)
+
+#define EC_ADDR_BIOS_BYTE 0x07A4
+#define FN_LOCK_SWITCH BIT(3)
+
+#define EC_ADDR_OEM_3 0x07A5
+#define POWER_LED_MASK GENMASK(1, 0)
+#define POWER_LED_LEFT 0x00
+#define POWER_LED_BOTH 0x01
+#define POWER_LED_NONE 0x02
+#define FAN_QUIET BIT(2)
+#define OVERBOOST BIT(4)
+#define HIGH_POWER BIT(7)
+
+#define EC_ADDR_OEM_4 0x07A6
+#define OVERBOOST_DYN_TEMP_OFF BIT(1)
+#define TOUCHPAD_TOGGLE_OFF BIT(6)
+
+#define EC_ADDR_CHARGE_CTRL 0x07B9
+#define CHARGE_CTRL_MASK GENMASK(6, 0)
+#define CHARGE_CTRL_REACHED BIT(7)
+
+#define EC_ADDR_UNIVERSAL_FAN_CTRL 0x07C5
+#define SPLIT_TABLES BIT(7)
+
+#define EC_ADDR_AP_OEM_6 0x07C6
+#define ENABLE_UNIVERSAL_FAN_CTRL BIT(2)
+#define BATTERY_CHARGE_FULL_OVER_24H BIT(3)
+#define BATTERY_ERM_STATUS_REACHED BIT(4)
+
+#define EC_ADDR_CHARGE_PRIO 0x07CC
+#define CHARGING_PERFORMANCE BIT(7)
+
+/* Same bits as EC_ADDR_LIGHTBAR_AC_CTRL except LIGHTBAR_S3_OFF */
+#define EC_ADDR_LIGHTBAR_BAT_CTRL 0x07E2
+
+#define EC_ADDR_LIGHTBAR_BAT_RED 0x07E3
+
+#define EC_ADDR_LIGHTBAR_BAT_GREEN 0x07E4
+
+#define EC_ADDR_LIGHTBAR_BAT_BLUE 0x07E5
+
+#define EC_ADDR_CPU_TEMP_END_TABLE 0x0F00
+
+#define EC_ADDR_CPU_TEMP_START_TABLE 0x0F10
+
+#define EC_ADDR_CPU_FAN_SPEED_TABLE 0x0F20
+
+#define EC_ADDR_GPU_TEMP_END_TABLE 0x0F30
+
+#define EC_ADDR_GPU_TEMP_START_TABLE 0x0F40
+
+#define EC_ADDR_GPU_FAN_SPEED_TABLE 0x0F50
+
+/*
+ * Those two registers technically allow for manual fan control,
+ * but are unstable on some models and are likely not meant to
+ * be used by applications as they are only accessible when using
+ * the WMI interface.
+ */
+#define EC_ADDR_PWM_1_WRITEABLE 0x1804
+
+#define EC_ADDR_PWM_2_WRITEABLE 0x1809
+
+#define DRIVER_NAME "uniwill"
+
+/*
+ * The OEM software always sleeps up to 6 ms after reading/writing EC
+ * registers, so we emulate this behaviour for maximum compatibility.
+ */
+#define UNIWILL_EC_DELAY_US 6000
+
+#define PWM_MAX 200
+#define FAN_TABLE_LENGTH 16
+
+#define LED_CHANNELS 3
+#define LED_MAX_BRIGHTNESS 200
+
+#define UNIWILL_FEATURE_FN_LOCK_TOGGLE BIT(0)
+#define UNIWILL_FEATURE_SUPER_KEY_TOGGLE BIT(1)
+#define UNIWILL_FEATURE_TOUCHPAD_TOGGLE BIT(2)
+#define UNIWILL_FEATURE_LIGHTBAR BIT(3)
+#define UNIWILL_FEATURE_BATTERY BIT(4)
+#define UNIWILL_FEATURE_HWMON BIT(5)
+
+struct uniwill_data {
+ struct device *dev;
+ acpi_handle handle;
+ struct regmap *regmap;
+ struct acpi_battery_hook hook;
+ unsigned int last_charge_ctrl;
+ struct mutex battery_lock; /* Protects the list of currently registered batteries */
+ unsigned int last_switch_status;
+ struct mutex super_key_lock; /* Protects the toggling of the super key lock state */
+ struct list_head batteries;
+ struct mutex led_lock; /* Protects writes to the lightbar registers */
+ struct led_classdev_mc led_mc_cdev;
+ struct mc_subled led_mc_subled_info[LED_CHANNELS];
+ struct mutex input_lock; /* Protects input sequence during notify */
+ struct input_dev *input_device;
+ struct notifier_block nb;
+};
+
+struct uniwill_battery_entry {
+ struct list_head head;
+ struct power_supply *battery;
+};
+
+static bool force;
+module_param_unsafe(force, bool, 0);
+MODULE_PARM_DESC(force, "Force loading without checking for supported devices\n");
+
+/* Feature bitmask since the associated registers are not reliable */
+static unsigned int supported_features;
+
+static const char * const uniwill_temp_labels[] = {
+ "CPU",
+ "GPU",
+};
+
+static const char * const uniwill_fan_labels[] = {
+ "Main",
+ "Secondary",
+};
+
+static const struct key_entry uniwill_keymap[] = {
+ /* Reported via keyboard controller */
+ { KE_IGNORE, UNIWILL_OSD_CAPSLOCK, { KEY_CAPSLOCK }},
+ { KE_IGNORE, UNIWILL_OSD_NUMLOCK, { KEY_NUMLOCK }},
+
+ /* Reported when the user locks/unlocks the super key */
+ { KE_IGNORE, UNIWILL_OSD_SUPER_KEY_LOCK_ENABLE, { KEY_UNKNOWN }},
+ { KE_IGNORE, UNIWILL_OSD_SUPER_KEY_LOCK_DISABLE, { KEY_UNKNOWN }},
+ /* Optional, might not be reported by all devices */
+ { KE_IGNORE, UNIWILL_OSD_SUPER_KEY_LOCK_CHANGED, { KEY_UNKNOWN }},
+
+ /* Reported in manual mode when toggling the airplane mode status */
+ { KE_KEY, UNIWILL_OSD_RFKILL, { KEY_RFKILL }},
+ { KE_IGNORE, UNIWILL_OSD_RADIOON, { KEY_UNKNOWN }},
+ { KE_IGNORE, UNIWILL_OSD_RADIOOFF, { KEY_UNKNOWN }},
+
+ /* Reported when user wants to cycle the platform profile */
+ { KE_KEY, UNIWILL_OSD_PERFORMANCE_MODE_TOGGLE, { KEY_F14 }},
+
+ /* Reported when the user wants to adjust the brightness of the keyboard */
+ { KE_KEY, UNIWILL_OSD_KBDILLUMDOWN, { KEY_KBDILLUMDOWN }},
+ { KE_KEY, UNIWILL_OSD_KBDILLUMUP, { KEY_KBDILLUMUP }},
+
+ /* Reported when the user wants to toggle the microphone mute status */
+ { KE_KEY, UNIWILL_OSD_MIC_MUTE, { KEY_MICMUTE }},
+
+ /* Reported when the user wants to toggle the mute status */
+ { KE_IGNORE, UNIWILL_OSD_MUTE, { KEY_MUTE }},
+
+ /* Reported when the user locks/unlocks the Fn key */
+ { KE_IGNORE, UNIWILL_OSD_FN_LOCK, { KEY_FN_ESC }},
+
+ /* Reported when the user wants to toggle the brightness of the keyboard */
+ { KE_KEY, UNIWILL_OSD_KBDILLUMTOGGLE, { KEY_KBDILLUMTOGGLE }},
+ { KE_KEY, UNIWILL_OSD_KB_LED_LEVEL0, { KEY_KBDILLUMTOGGLE }},
+ { KE_KEY, UNIWILL_OSD_KB_LED_LEVEL1, { KEY_KBDILLUMTOGGLE }},
+ { KE_KEY, UNIWILL_OSD_KB_LED_LEVEL2, { KEY_KBDILLUMTOGGLE }},
+ { KE_KEY, UNIWILL_OSD_KB_LED_LEVEL3, { KEY_KBDILLUMTOGGLE }},
+ { KE_KEY, UNIWILL_OSD_KB_LED_LEVEL4, { KEY_KBDILLUMTOGGLE }},
+
+ /* FIXME: find out the exact meaning of those events */
+ { KE_IGNORE, UNIWILL_OSD_BAT_CHARGE_FULL_24_H, { KEY_UNKNOWN }},
+ { KE_IGNORE, UNIWILL_OSD_BAT_ERM_UPDATE, { KEY_UNKNOWN }},
+
+ /* Reported when the user wants to toggle the benchmark mode status */
+ { KE_IGNORE, UNIWILL_OSD_BENCHMARK_MODE_TOGGLE, { KEY_UNKNOWN }},
+
+ /* Reported when the user wants to toggle the webcam */
+ { KE_IGNORE, UNIWILL_OSD_WEBCAM_TOGGLE, { KEY_UNKNOWN }},
+
+ { KE_END }
+};
+
+static int uniwill_ec_reg_write(void *context, unsigned int reg, unsigned int val)
+{
+ union acpi_object params[2] = {
+ {
+ .integer = {
+ .type = ACPI_TYPE_INTEGER,
+ .value = reg,
+ },
+ },
+ {
+ .integer = {
+ .type = ACPI_TYPE_INTEGER,
+ .value = val,
+ },
+ },
+ };
+ struct uniwill_data *data = context;
+ struct acpi_object_list input = {
+ .count = ARRAY_SIZE(params),
+ .pointer = params,
+ };
+ acpi_status status;
+
+ status = acpi_evaluate_object(data->handle, "ECRW", &input, NULL);
+ if (ACPI_FAILURE(status))
+ return -EIO;
+
+ usleep_range(UNIWILL_EC_DELAY_US, UNIWILL_EC_DELAY_US * 2);
+
+ return 0;
+}
+
+static int uniwill_ec_reg_read(void *context, unsigned int reg, unsigned int *val)
+{
+ union acpi_object params[1] = {
+ {
+ .integer = {
+ .type = ACPI_TYPE_INTEGER,
+ .value = reg,
+ },
+ },
+ };
+ struct uniwill_data *data = context;
+ struct acpi_object_list input = {
+ .count = ARRAY_SIZE(params),
+ .pointer = params,
+ };
+ unsigned long long output;
+ acpi_status status;
+
+ status = acpi_evaluate_integer(data->handle, "ECRR", &input, &output);
+ if (ACPI_FAILURE(status))
+ return -EIO;
+
+ if (output > U8_MAX)
+ return -ENXIO;
+
+ usleep_range(UNIWILL_EC_DELAY_US, UNIWILL_EC_DELAY_US * 2);
+
+ *val = output;
+
+ return 0;
+}
+
+static const struct regmap_bus uniwill_ec_bus = {
+ .reg_write = uniwill_ec_reg_write,
+ .reg_read = uniwill_ec_reg_read,
+ .reg_format_endian_default = REGMAP_ENDIAN_LITTLE,
+ .val_format_endian_default = REGMAP_ENDIAN_LITTLE,
+};
+
+static bool uniwill_writeable_reg(struct device *dev, unsigned int reg)
+{
+ switch (reg) {
+ case EC_ADDR_AP_OEM:
+ case EC_ADDR_LIGHTBAR_AC_CTRL:
+ case EC_ADDR_LIGHTBAR_AC_RED:
+ case EC_ADDR_LIGHTBAR_AC_GREEN:
+ case EC_ADDR_LIGHTBAR_AC_BLUE:
+ case EC_ADDR_BIOS_OEM:
+ case EC_ADDR_TRIGGER:
+ case EC_ADDR_OEM_4:
+ case EC_ADDR_CHARGE_CTRL:
+ case EC_ADDR_LIGHTBAR_BAT_CTRL:
+ case EC_ADDR_LIGHTBAR_BAT_RED:
+ case EC_ADDR_LIGHTBAR_BAT_GREEN:
+ case EC_ADDR_LIGHTBAR_BAT_BLUE:
+ return true;
+ default:
+ return false;
+ }
+}
+
+static bool uniwill_readable_reg(struct device *dev, unsigned int reg)
+{
+ switch (reg) {
+ case EC_ADDR_CPU_TEMP:
+ case EC_ADDR_GPU_TEMP:
+ case EC_ADDR_MAIN_FAN_RPM_1:
+ case EC_ADDR_MAIN_FAN_RPM_2:
+ case EC_ADDR_SECOND_FAN_RPM_1:
+ case EC_ADDR_SECOND_FAN_RPM_2:
+ case EC_ADDR_BAT_ALERT:
+ case EC_ADDR_PROJECT_ID:
+ case EC_ADDR_AP_OEM:
+ case EC_ADDR_LIGHTBAR_AC_CTRL:
+ case EC_ADDR_LIGHTBAR_AC_RED:
+ case EC_ADDR_LIGHTBAR_AC_GREEN:
+ case EC_ADDR_LIGHTBAR_AC_BLUE:
+ case EC_ADDR_BIOS_OEM:
+ case EC_ADDR_PWM_1:
+ case EC_ADDR_PWM_2:
+ case EC_ADDR_TRIGGER:
+ case EC_ADDR_SWITCH_STATUS:
+ case EC_ADDR_OEM_4:
+ case EC_ADDR_CHARGE_CTRL:
+ case EC_ADDR_LIGHTBAR_BAT_CTRL:
+ case EC_ADDR_LIGHTBAR_BAT_RED:
+ case EC_ADDR_LIGHTBAR_BAT_GREEN:
+ case EC_ADDR_LIGHTBAR_BAT_BLUE:
+ return true;
+ default:
+ return false;
+ }
+}
+
+static bool uniwill_volatile_reg(struct device *dev, unsigned int reg)
+{
+ switch (reg) {
+ case EC_ADDR_CPU_TEMP:
+ case EC_ADDR_GPU_TEMP:
+ case EC_ADDR_MAIN_FAN_RPM_1:
+ case EC_ADDR_MAIN_FAN_RPM_2:
+ case EC_ADDR_SECOND_FAN_RPM_1:
+ case EC_ADDR_SECOND_FAN_RPM_2:
+ case EC_ADDR_BAT_ALERT:
+ case EC_ADDR_PWM_1:
+ case EC_ADDR_PWM_2:
+ case EC_ADDR_TRIGGER:
+ case EC_ADDR_SWITCH_STATUS:
+ case EC_ADDR_CHARGE_CTRL:
+ return true;
+ default:
+ return false;
+ }
+}
+
+static const struct regmap_config uniwill_ec_config = {
+ .reg_bits = 16,
+ .val_bits = 8,
+ .writeable_reg = uniwill_writeable_reg,
+ .readable_reg = uniwill_readable_reg,
+ .volatile_reg = uniwill_volatile_reg,
+ .can_sleep = true,
+ .max_register = 0xFFF,
+ .cache_type = REGCACHE_MAPLE,
+ .use_single_read = true,
+ .use_single_write = true,
+};
+
+static ssize_t fn_lock_toggle_enable_store(struct device *dev, struct device_attribute *attr,
+ const char *buf, size_t count)
+{
+ struct uniwill_data *data = dev_get_drvdata(dev);
+ unsigned int value;
+ bool enable;
+ int ret;
+
+ ret = kstrtobool(buf, &enable);
+ if (ret < 0)
+ return ret;
+
+ if (enable)
+ value = FN_LOCK_STATUS;
+ else
+ value = 0;
+
+ ret = regmap_update_bits(data->regmap, EC_ADDR_BIOS_OEM, FN_LOCK_STATUS, value);
+ if (ret < 0)
+ return ret;
+
+ return count;
+}
+
+static ssize_t fn_lock_toggle_enable_show(struct device *dev, struct device_attribute *attr,
+ char *buf)
+{
+ struct uniwill_data *data = dev_get_drvdata(dev);
+ unsigned int value;
+ int ret;
+
+ ret = regmap_read(data->regmap, EC_ADDR_BIOS_OEM, &value);
+ if (ret < 0)
+ return ret;
+
+ return sysfs_emit(buf, "%d\n", !!(value & FN_LOCK_STATUS));
+}
+
+static DEVICE_ATTR_RW(fn_lock_toggle_enable);
+
+static ssize_t super_key_toggle_enable_store(struct device *dev, struct device_attribute *attr,
+ const char *buf, size_t count)
+{
+ struct uniwill_data *data = dev_get_drvdata(dev);
+ unsigned int value;
+ bool enable;
+ int ret;
+
+ ret = kstrtobool(buf, &enable);
+ if (ret < 0)
+ return ret;
+
+ guard(mutex)(&data->super_key_lock);
+
+ ret = regmap_read(data->regmap, EC_ADDR_SWITCH_STATUS, &value);
+ if (ret < 0)
+ return ret;
+
+ /*
+ * We can only toggle the super key lock, so we return early if the setting
+ * is already in the correct state.
+ */
+ if (enable == !(value & SUPER_KEY_LOCK_STATUS))
+ return count;
+
+ ret = regmap_write_bits(data->regmap, EC_ADDR_TRIGGER, TRIGGER_SUPER_KEY_LOCK,
+ TRIGGER_SUPER_KEY_LOCK);
+ if (ret < 0)
+ return ret;
+
+ return count;
+}
+
+static ssize_t super_key_toggle_enable_show(struct device *dev, struct device_attribute *attr,
+ char *buf)
+{
+ struct uniwill_data *data = dev_get_drvdata(dev);
+ unsigned int value;
+ int ret;
+
+ ret = regmap_read(data->regmap, EC_ADDR_SWITCH_STATUS, &value);
+ if (ret < 0)
+ return ret;
+
+ return sysfs_emit(buf, "%d\n", !(value & SUPER_KEY_LOCK_STATUS));
+}
+
+static DEVICE_ATTR_RW(super_key_toggle_enable);
+
+static ssize_t touchpad_toggle_enable_store(struct device *dev, struct device_attribute *attr,
+ const char *buf, size_t count)
+{
+ struct uniwill_data *data = dev_get_drvdata(dev);
+ unsigned int value;
+ bool enable;
+ int ret;
+
+ ret = kstrtobool(buf, &enable);
+ if (ret < 0)
+ return ret;
+
+ if (enable)
+ value = 0;
+ else
+ value = TOUCHPAD_TOGGLE_OFF;
+
+ ret = regmap_update_bits(data->regmap, EC_ADDR_OEM_4, TOUCHPAD_TOGGLE_OFF, value);
+ if (ret < 0)
+ return ret;
+
+ return count;
+}
+
+static ssize_t touchpad_toggle_enable_show(struct device *dev, struct device_attribute *attr,
+ char *buf)
+{
+ struct uniwill_data *data = dev_get_drvdata(dev);
+ unsigned int value;
+ int ret;
+
+ ret = regmap_read(data->regmap, EC_ADDR_OEM_4, &value);
+ if (ret < 0)
+ return ret;
+
+ return sysfs_emit(buf, "%d\n", !(value & TOUCHPAD_TOGGLE_OFF));
+}
+
+static DEVICE_ATTR_RW(touchpad_toggle_enable);
+
+static ssize_t rainbow_animation_store(struct device *dev, struct device_attribute *attr,
+ const char *buf, size_t count)
+{
+ struct uniwill_data *data = dev_get_drvdata(dev);
+ unsigned int value;
+ bool enable;
+ int ret;
+
+ ret = kstrtobool(buf, &enable);
+ if (ret < 0)
+ return ret;
+
+ if (enable)
+ value = LIGHTBAR_WELCOME;
+ else
+ value = 0;
+
+ guard(mutex)(&data->led_lock);
+
+ ret = regmap_update_bits(data->regmap, EC_ADDR_LIGHTBAR_AC_CTRL, LIGHTBAR_WELCOME, value);
+ if (ret < 0)
+ return ret;
+
+ ret = regmap_update_bits(data->regmap, EC_ADDR_LIGHTBAR_BAT_CTRL, LIGHTBAR_WELCOME, value);
+ if (ret < 0)
+ return ret;
+
+ return count;
+}
+
+static ssize_t rainbow_animation_show(struct device *dev, struct device_attribute *attr, char *buf)
+{
+ struct uniwill_data *data = dev_get_drvdata(dev);
+ unsigned int value;
+ int ret;
+
+ ret = regmap_read(data->regmap, EC_ADDR_LIGHTBAR_AC_CTRL, &value);
+ if (ret < 0)
+ return ret;
+
+ return sysfs_emit(buf, "%d\n", !!(value & LIGHTBAR_WELCOME));
+}
+
+static DEVICE_ATTR_RW(rainbow_animation);
+
+static ssize_t breathing_in_suspend_store(struct device *dev, struct device_attribute *attr,
+ const char *buf, size_t count)
+{
+ struct uniwill_data *data = dev_get_drvdata(dev);
+ unsigned int value;
+ bool enable;
+ int ret;
+
+ ret = kstrtobool(buf, &enable);
+ if (ret < 0)
+ return ret;
+
+ if (enable)
+ value = 0;
+ else
+ value = LIGHTBAR_S3_OFF;
+
+ /* We only access a single register here, so we do not need to use data->led_lock */
+ ret = regmap_update_bits(data->regmap, EC_ADDR_LIGHTBAR_AC_CTRL, LIGHTBAR_S3_OFF, value);
+ if (ret < 0)
+ return ret;
+
+ return count;
+}
+
+static ssize_t breathing_in_suspend_show(struct device *dev, struct device_attribute *attr,
+ char *buf)
+{
+ struct uniwill_data *data = dev_get_drvdata(dev);
+ unsigned int value;
+ int ret;
+
+ ret = regmap_read(data->regmap, EC_ADDR_LIGHTBAR_AC_CTRL, &value);
+ if (ret < 0)
+ return ret;
+
+ return sysfs_emit(buf, "%d\n", !(value & LIGHTBAR_S3_OFF));
+}
+
+static DEVICE_ATTR_RW(breathing_in_suspend);
+
+static struct attribute *uniwill_attrs[] = {
+ /* Keyboard-related */
+ &dev_attr_fn_lock_toggle_enable.attr,
+ &dev_attr_super_key_toggle_enable.attr,
+ &dev_attr_touchpad_toggle_enable.attr,
+ /* Lightbar-related */
+ &dev_attr_rainbow_animation.attr,
+ &dev_attr_breathing_in_suspend.attr,
+ NULL
+};
+
+static umode_t uniwill_attr_is_visible(struct kobject *kobj, struct attribute *attr, int n)
+{
+ if (attr == &dev_attr_fn_lock_toggle_enable.attr) {
+ if (supported_features & UNIWILL_FEATURE_FN_LOCK_TOGGLE)
+ return attr->mode;
+ }
+
+ if (attr == &dev_attr_super_key_toggle_enable.attr) {
+ if (supported_features & UNIWILL_FEATURE_SUPER_KEY_TOGGLE)
+ return attr->mode;
+ }
+
+ if (attr == &dev_attr_touchpad_toggle_enable.attr) {
+ if (supported_features & UNIWILL_FEATURE_TOUCHPAD_TOGGLE)
+ return attr->mode;
+ }
+
+ if (attr == &dev_attr_rainbow_animation.attr ||
+ attr == &dev_attr_breathing_in_suspend.attr) {
+ if (supported_features & UNIWILL_FEATURE_LIGHTBAR)
+ return attr->mode;
+ }
+
+ return 0;
+}
+
+static const struct attribute_group uniwill_group = {
+ .is_visible = uniwill_attr_is_visible,
+ .attrs = uniwill_attrs,
+};
+
+static const struct attribute_group *uniwill_groups[] = {
+ &uniwill_group,
+ NULL
+};
+
+static int uniwill_read(struct device *dev, enum hwmon_sensor_types type, u32 attr, int channel,
+ long *val)
+{
+ struct uniwill_data *data = dev_get_drvdata(dev);
+ unsigned int value;
+ __be16 rpm;
+ int ret;
+
+ switch (type) {
+ case hwmon_temp:
+ switch (channel) {
+ case 0:
+ ret = regmap_read(data->regmap, EC_ADDR_CPU_TEMP, &value);
+ break;
+ case 1:
+ ret = regmap_read(data->regmap, EC_ADDR_GPU_TEMP, &value);
+ break;
+ default:
+ return -EOPNOTSUPP;
+ }
+
+ if (ret < 0)
+ return ret;
+
+ *val = value * MILLIDEGREE_PER_DEGREE;
+ return 0;
+ case hwmon_fan:
+ switch (channel) {
+ case 0:
+ ret = regmap_bulk_read(data->regmap, EC_ADDR_MAIN_FAN_RPM_1, &rpm,
+ sizeof(rpm));
+ break;
+ case 1:
+ ret = regmap_bulk_read(data->regmap, EC_ADDR_SECOND_FAN_RPM_1, &rpm,
+ sizeof(rpm));
+ break;
+ default:
+ return -EOPNOTSUPP;
+ }
+
+ if (ret < 0)
+ return ret;
+
+ *val = be16_to_cpu(rpm);
+ return 0;
+ case hwmon_pwm:
+ switch (channel) {
+ case 0:
+ ret = regmap_read(data->regmap, EC_ADDR_PWM_1, &value);
+ break;
+ case 1:
+ ret = regmap_read(data->regmap, EC_ADDR_PWM_2, &value);
+ break;
+ default:
+ return -EOPNOTSUPP;
+ }
+
+ if (ret < 0)
+ return ret;
+
+ *val = fixp_linear_interpolate(0, 0, PWM_MAX, U8_MAX, value);
+ return 0;
+ default:
+ return -EOPNOTSUPP;
+ }
+}
+
+static int uniwill_read_string(struct device *dev, enum hwmon_sensor_types type, u32 attr,
+ int channel, const char **str)
+{
+ switch (type) {
+ case hwmon_temp:
+ *str = uniwill_temp_labels[channel];
+ return 0;
+ case hwmon_fan:
+ *str = uniwill_fan_labels[channel];
+ return 0;
+ default:
+ return -EOPNOTSUPP;
+ }
+}
+
+static const struct hwmon_ops uniwill_ops = {
+ .visible = 0444,
+ .read = uniwill_read,
+ .read_string = uniwill_read_string,
+};
+
+static const struct hwmon_channel_info * const uniwill_info[] = {
+ HWMON_CHANNEL_INFO(chip, HWMON_C_REGISTER_TZ),
+ HWMON_CHANNEL_INFO(temp,
+ HWMON_T_INPUT | HWMON_T_LABEL,
+ HWMON_T_INPUT | HWMON_T_LABEL),
+ HWMON_CHANNEL_INFO(fan,
+ HWMON_F_INPUT | HWMON_F_LABEL,
+ HWMON_F_INPUT | HWMON_F_LABEL),
+ HWMON_CHANNEL_INFO(pwm,
+ HWMON_PWM_INPUT,
+ HWMON_PWM_INPUT),
+ NULL
+};
+
+static const struct hwmon_chip_info uniwill_chip_info = {
+ .ops = &uniwill_ops,
+ .info = uniwill_info,
+};
+
+static int uniwill_hwmon_init(struct uniwill_data *data)
+{
+ struct device *hdev;
+
+ if (!(supported_features & UNIWILL_FEATURE_HWMON))
+ return 0;
+
+ hdev = devm_hwmon_device_register_with_info(data->dev, "uniwill", data,
+ &uniwill_chip_info, NULL);
+
+ return PTR_ERR_OR_ZERO(hdev);
+}
+
+static const unsigned int uniwill_led_channel_to_bat_reg[LED_CHANNELS] = {
+ EC_ADDR_LIGHTBAR_BAT_RED,
+ EC_ADDR_LIGHTBAR_BAT_GREEN,
+ EC_ADDR_LIGHTBAR_BAT_BLUE,
+};
+
+static const unsigned int uniwill_led_channel_to_ac_reg[LED_CHANNELS] = {
+ EC_ADDR_LIGHTBAR_AC_RED,
+ EC_ADDR_LIGHTBAR_AC_GREEN,
+ EC_ADDR_LIGHTBAR_AC_BLUE,
+};
+
+static int uniwill_led_brightness_set(struct led_classdev *led_cdev, enum led_brightness brightness)
+{
+ struct led_classdev_mc *led_mc_cdev = lcdev_to_mccdev(led_cdev);
+ struct uniwill_data *data = container_of(led_mc_cdev, struct uniwill_data, led_mc_cdev);
+ unsigned int value;
+ int ret;
+
+ ret = led_mc_calc_color_components(led_mc_cdev, brightness);
+ if (ret < 0)
+ return ret;
+
+ guard(mutex)(&data->led_lock);
+
+ for (int i = 0; i < LED_CHANNELS; i++) {
+ /* Prevent the brightness values from overflowing */
+ value = min(LED_MAX_BRIGHTNESS, data->led_mc_subled_info[i].brightness);
+ ret = regmap_write(data->regmap, uniwill_led_channel_to_ac_reg[i], value);
+ if (ret < 0)
+ return ret;
+
+ ret = regmap_write(data->regmap, uniwill_led_channel_to_bat_reg[i], value);
+ if (ret < 0)
+ return ret;
+ }
+
+ if (brightness)
+ value = 0;
+ else
+ value = LIGHTBAR_S0_OFF;
+
+ ret = regmap_update_bits(data->regmap, EC_ADDR_LIGHTBAR_AC_CTRL, LIGHTBAR_S0_OFF, value);
+ if (ret < 0)
+ return ret;
+
+ return regmap_update_bits(data->regmap, EC_ADDR_LIGHTBAR_BAT_CTRL, LIGHTBAR_S0_OFF, value);
+}
+
+#define LIGHTBAR_MASK (LIGHTBAR_APP_EXISTS | LIGHTBAR_S0_OFF | LIGHTBAR_S3_OFF | LIGHTBAR_WELCOME)
+
+static int uniwill_led_init(struct uniwill_data *data)
+{
+ struct led_init_data init_data = {
+ .devicename = DRIVER_NAME,
+ .default_label = "multicolor:" LED_FUNCTION_STATUS,
+ .devname_mandatory = true,
+ };
+ unsigned int color_indices[3] = {
+ LED_COLOR_ID_RED,
+ LED_COLOR_ID_GREEN,
+ LED_COLOR_ID_BLUE,
+ };
+ unsigned int value;
+ int ret;
+
+ if (!(supported_features & UNIWILL_FEATURE_LIGHTBAR))
+ return 0;
+
+ ret = devm_mutex_init(data->dev, &data->led_lock);
+ if (ret < 0)
+ return ret;
+
+ /*
+ * The EC has separate lightbar settings for AC and battery mode,
+ * so we have to ensure that both settings are the same.
+ */
+ ret = regmap_read(data->regmap, EC_ADDR_LIGHTBAR_AC_CTRL, &value);
+ if (ret < 0)
+ return ret;
+
+ value |= LIGHTBAR_APP_EXISTS;
+ ret = regmap_write(data->regmap, EC_ADDR_LIGHTBAR_AC_CTRL, value);
+ if (ret < 0)
+ return ret;
+
+ /*
+ * The breathing animation during suspend is not supported when
+ * running on battery power.
+ */
+ value |= LIGHTBAR_S3_OFF;
+ ret = regmap_update_bits(data->regmap, EC_ADDR_LIGHTBAR_BAT_CTRL, LIGHTBAR_MASK, value);
+ if (ret < 0)
+ return ret;
+
+ data->led_mc_cdev.led_cdev.color = LED_COLOR_ID_MULTI;
+ data->led_mc_cdev.led_cdev.max_brightness = LED_MAX_BRIGHTNESS;
+ data->led_mc_cdev.led_cdev.flags = LED_REJECT_NAME_CONFLICT;
+ data->led_mc_cdev.led_cdev.brightness_set_blocking = uniwill_led_brightness_set;
+
+ if (value & LIGHTBAR_S0_OFF)
+ data->led_mc_cdev.led_cdev.brightness = 0;
+ else
+ data->led_mc_cdev.led_cdev.brightness = LED_MAX_BRIGHTNESS;
+
+ for (int i = 0; i < LED_CHANNELS; i++) {
+ data->led_mc_subled_info[i].color_index = color_indices[i];
+
+ ret = regmap_read(data->regmap, uniwill_led_channel_to_ac_reg[i], &value);
+ if (ret < 0)
+ return ret;
+
+ /*
+ * Make sure that the initial intensity value is not greater than
+ * the maximum brightness.
+ */
+ value = min(LED_MAX_BRIGHTNESS, value);
+ ret = regmap_write(data->regmap, uniwill_led_channel_to_ac_reg[i], value);
+ if (ret < 0)
+ return ret;
+
+ ret = regmap_write(data->regmap, uniwill_led_channel_to_bat_reg[i], value);
+ if (ret < 0)
+ return ret;
+
+ data->led_mc_subled_info[i].intensity = value;
+ data->led_mc_subled_info[i].channel = i;
+ }
+
+ data->led_mc_cdev.subled_info = data->led_mc_subled_info;
+ data->led_mc_cdev.num_colors = LED_CHANNELS;
+
+ return devm_led_classdev_multicolor_register_ext(data->dev, &data->led_mc_cdev,
+ &init_data);
+}
+
+static int uniwill_get_property(struct power_supply *psy, const struct power_supply_ext *ext,
+ void *drvdata, enum power_supply_property psp,
+ union power_supply_propval *val)
+{
+ struct uniwill_data *data = drvdata;
+ union power_supply_propval prop;
+ unsigned int regval;
+ int ret;
+
+ switch (psp) {
+ case POWER_SUPPLY_PROP_HEALTH:
+ ret = power_supply_get_property_direct(psy, POWER_SUPPLY_PROP_PRESENT, &prop);
+ if (ret < 0)
+ return ret;
+
+ if (!prop.intval) {
+ val->intval = POWER_SUPPLY_HEALTH_NO_BATTERY;
+ return 0;
+ }
+
+ ret = power_supply_get_property_direct(psy, POWER_SUPPLY_PROP_STATUS, &prop);
+ if (ret < 0)
+ return ret;
+
+ if (prop.intval == POWER_SUPPLY_STATUS_UNKNOWN) {
+ val->intval = POWER_SUPPLY_HEALTH_UNKNOWN;
+ return 0;
+ }
+
+ ret = regmap_read(data->regmap, EC_ADDR_BAT_ALERT, ®val);
+ if (ret < 0)
+ return ret;
+
+ if (regval) {
+ /* Charging issue */
+ val->intval = POWER_SUPPLY_HEALTH_UNSPEC_FAILURE;
+ return 0;
+ }
+
+ val->intval = POWER_SUPPLY_HEALTH_GOOD;
+ return 0;
+ case POWER_SUPPLY_PROP_CHARGE_CONTROL_END_THRESHOLD:
+ ret = regmap_read(data->regmap, EC_ADDR_CHARGE_CTRL, ®val);
+ if (ret < 0)
+ return ret;
+
+ val->intval = clamp_val(FIELD_GET(CHARGE_CTRL_MASK, regval), 0, 100);
+ return 0;
+ default:
+ return -EINVAL;
+ }
+}
+
+static int uniwill_set_property(struct power_supply *psy, const struct power_supply_ext *ext,
+ void *drvdata, enum power_supply_property psp,
+ const union power_supply_propval *val)
+{
+ struct uniwill_data *data = drvdata;
+
+ switch (psp) {
+ case POWER_SUPPLY_PROP_CHARGE_CONTROL_END_THRESHOLD:
+ if (val->intval < 1 || val->intval > 100)
+ return -EINVAL;
+
+ return regmap_update_bits(data->regmap, EC_ADDR_CHARGE_CTRL, CHARGE_CTRL_MASK,
+ val->intval);
+ default:
+ return -EINVAL;
+ }
+}
+
+static int uniwill_property_is_writeable(struct power_supply *psy,
+ const struct power_supply_ext *ext, void *drvdata,
+ enum power_supply_property psp)
+{
+ if (psp == POWER_SUPPLY_PROP_CHARGE_CONTROL_END_THRESHOLD)
+ return true;
+
+ return false;
+}
+
+static const enum power_supply_property uniwill_properties[] = {
+ POWER_SUPPLY_PROP_HEALTH,
+ POWER_SUPPLY_PROP_CHARGE_CONTROL_END_THRESHOLD,
+};
+
+static const struct power_supply_ext uniwill_extension = {
+ .name = DRIVER_NAME,
+ .properties = uniwill_properties,
+ .num_properties = ARRAY_SIZE(uniwill_properties),
+ .get_property = uniwill_get_property,
+ .set_property = uniwill_set_property,
+ .property_is_writeable = uniwill_property_is_writeable,
+};
+
+static int uniwill_add_battery(struct power_supply *battery, struct acpi_battery_hook *hook)
+{
+ struct uniwill_data *data = container_of(hook, struct uniwill_data, hook);
+ struct uniwill_battery_entry *entry;
+ int ret;
+
+ entry = kzalloc(sizeof(*entry), GFP_KERNEL);
+ if (!entry)
+ return -ENOMEM;
+
+ ret = power_supply_register_extension(battery, &uniwill_extension, data->dev, data);
+ if (ret < 0) {
+ kfree(entry);
+ return ret;
+ }
+
+ guard(mutex)(&data->battery_lock);
+
+ entry->battery = battery;
+ list_add(&entry->head, &data->batteries);
+
+ return 0;
+}
+
+static int uniwill_remove_battery(struct power_supply *battery, struct acpi_battery_hook *hook)
+{
+ struct uniwill_data *data = container_of(hook, struct uniwill_data, hook);
+ struct uniwill_battery_entry *entry, *tmp;
+
+ scoped_guard(mutex, &data->battery_lock) {
+ list_for_each_entry_safe(entry, tmp, &data->batteries, head) {
+ if (entry->battery == battery) {
+ list_del(&entry->head);
+ kfree(entry);
+ break;
+ }
+ }
+ }
+
+ power_supply_unregister_extension(battery, &uniwill_extension);
+
+ return 0;
+}
+
+static int uniwill_battery_init(struct uniwill_data *data)
+{
+ int ret;
+
+ if (!(supported_features & UNIWILL_FEATURE_BATTERY))
+ return 0;
+
+ ret = devm_mutex_init(data->dev, &data->battery_lock);
+ if (ret < 0)
+ return ret;
+
+ INIT_LIST_HEAD(&data->batteries);
+ data->hook.name = "Uniwill Battery Extension";
+ data->hook.add_battery = uniwill_add_battery;
+ data->hook.remove_battery = uniwill_remove_battery;
+
+ return devm_battery_hook_register(data->dev, &data->hook);
+}
+
+static int uniwill_notifier_call(struct notifier_block *nb, unsigned long action, void *dummy)
+{
+ struct uniwill_data *data = container_of(nb, struct uniwill_data, nb);
+ struct uniwill_battery_entry *entry;
+
+ switch (action) {
+ case UNIWILL_OSD_BATTERY_ALERT:
+ mutex_lock(&data->battery_lock);
+ list_for_each_entry(entry, &data->batteries, head) {
+ power_supply_changed(entry->battery);
+ }
+ mutex_unlock(&data->battery_lock);
+
+ return NOTIFY_OK;
+ case UNIWILL_OSD_DC_ADAPTER_CHANGED:
+ /* noop for the time being, will change once charging priority
+ * gets implemented.
+ */
+
+ return NOTIFY_OK;
+ default:
+ mutex_lock(&data->input_lock);
+ sparse_keymap_report_event(data->input_device, action, 1, true);
+ mutex_unlock(&data->input_lock);
+
+ return NOTIFY_OK;
+ }
+}
+
+static int uniwill_input_init(struct uniwill_data *data)
+{
+ int ret;
+
+ ret = devm_mutex_init(data->dev, &data->input_lock);
+ if (ret < 0)
+ return ret;
+
+ data->input_device = devm_input_allocate_device(data->dev);
+ if (!data->input_device)
+ return -ENOMEM;
+
+ ret = sparse_keymap_setup(data->input_device, uniwill_keymap, NULL);
+ if (ret < 0)
+ return ret;
+
+ data->input_device->name = "Uniwill WMI hotkeys";
+ data->input_device->phys = "wmi/input0";
+ data->input_device->id.bustype = BUS_HOST;
+ ret = input_register_device(data->input_device);
+ if (ret < 0)
+ return ret;
+
+ data->nb.notifier_call = uniwill_notifier_call;
+
+ return devm_uniwill_wmi_register_notifier(data->dev, &data->nb);
+}
+
+static void uniwill_disable_manual_control(void *context)
+{
+ struct uniwill_data *data = context;
+
+ regmap_clear_bits(data->regmap, EC_ADDR_AP_OEM, ENABLE_MANUAL_CTRL);
+}
+
+static int uniwill_ec_init(struct uniwill_data *data)
+{
+ unsigned int value;
+ int ret;
+
+ ret = regmap_read(data->regmap, EC_ADDR_PROJECT_ID, &value);
+ if (ret < 0)
+ return ret;
+
+ dev_dbg(data->dev, "Project ID: %u\n", value);
+
+ ret = regmap_set_bits(data->regmap, EC_ADDR_AP_OEM, ENABLE_MANUAL_CTRL);
+ if (ret < 0)
+ return ret;
+
+ return devm_add_action_or_reset(data->dev, uniwill_disable_manual_control, data);
+}
+
+static int uniwill_probe(struct platform_device *pdev)
+{
+ struct uniwill_data *data;
+ struct regmap *regmap;
+ acpi_handle handle;
+ int ret;
+
+ handle = ACPI_HANDLE(&pdev->dev);
+ if (!handle)
+ return -ENODEV;
+
+ data = devm_kzalloc(&pdev->dev, sizeof(*data), GFP_KERNEL);
+ if (!data)
+ return -ENOMEM;
+
+ data->dev = &pdev->dev;
+ data->handle = handle;
+ platform_set_drvdata(pdev, data);
+
+ regmap = devm_regmap_init(&pdev->dev, &uniwill_ec_bus, data, &uniwill_ec_config);
+ if (IS_ERR(regmap))
+ return PTR_ERR(regmap);
+
+ data->regmap = regmap;
+ ret = devm_mutex_init(&pdev->dev, &data->super_key_lock);
+ if (ret < 0)
+ return ret;
+
+ ret = uniwill_ec_init(data);
+ if (ret < 0)
+ return ret;
+
+ ret = uniwill_battery_init(data);
+ if (ret < 0)
+ return ret;
+
+ ret = uniwill_led_init(data);
+ if (ret < 0)
+ return ret;
+
+ ret = uniwill_hwmon_init(data);
+ if (ret < 0)
+ return ret;
+
+ return uniwill_input_init(data);
+}
+
+static void uniwill_shutdown(struct platform_device *pdev)
+{
+ struct uniwill_data *data = platform_get_drvdata(pdev);
+
+ regmap_clear_bits(data->regmap, EC_ADDR_AP_OEM, ENABLE_MANUAL_CTRL);
+}
+
+static int uniwill_suspend_keyboard(struct uniwill_data *data)
+{
+ if (!(supported_features & UNIWILL_FEATURE_SUPER_KEY_TOGGLE))
+ return 0;
+
+ /*
+ * The EC_ADDR_SWITCH_STATUS is marked as volatile, so we have to restore it
+ * ourselves.
+ */
+ return regmap_read(data->regmap, EC_ADDR_SWITCH_STATUS, &data->last_switch_status);
+}
+
+static int uniwill_suspend_battery(struct uniwill_data *data)
+{
+ if (!(supported_features & UNIWILL_FEATURE_BATTERY))
+ return 0;
+
+ /*
+ * Save the current charge limit in order to restore it during resume.
+ * We cannot use the regmap code for that since this register needs to
+ * be declared as volatile due to CHARGE_CTRL_REACHED.
+ */
+ return regmap_read(data->regmap, EC_ADDR_CHARGE_CTRL, &data->last_charge_ctrl);
+}
+
+static int uniwill_suspend(struct device *dev)
+{
+ struct uniwill_data *data = dev_get_drvdata(dev);
+ int ret;
+
+ ret = uniwill_suspend_keyboard(data);
+ if (ret < 0)
+ return ret;
+
+ ret = uniwill_suspend_battery(data);
+ if (ret < 0)
+ return ret;
+
+ regcache_cache_only(data->regmap, true);
+ regcache_mark_dirty(data->regmap);
+
+ return 0;
+}
+
+static int uniwill_resume_keyboard(struct uniwill_data *data)
+{
+ unsigned int value;
+ int ret;
+
+ if (!(supported_features & UNIWILL_FEATURE_SUPER_KEY_TOGGLE))
+ return 0;
+
+ ret = regmap_read(data->regmap, EC_ADDR_SWITCH_STATUS, &value);
+ if (ret < 0)
+ return ret;
+
+ if ((data->last_switch_status & SUPER_KEY_LOCK_STATUS) == (value & SUPER_KEY_LOCK_STATUS))
+ return 0;
+
+ return regmap_write_bits(data->regmap, EC_ADDR_TRIGGER, TRIGGER_SUPER_KEY_LOCK,
+ TRIGGER_SUPER_KEY_LOCK);
+}
+
+static int uniwill_resume_battery(struct uniwill_data *data)
+{
+ if (!(supported_features & UNIWILL_FEATURE_BATTERY))
+ return 0;
+
+ return regmap_update_bits(data->regmap, EC_ADDR_CHARGE_CTRL, CHARGE_CTRL_MASK,
+ data->last_charge_ctrl);
+}
+
+static int uniwill_resume(struct device *dev)
+{
+ struct uniwill_data *data = dev_get_drvdata(dev);
+ int ret;
+
+ regcache_cache_only(data->regmap, false);
+
+ ret = regcache_sync(data->regmap);
+ if (ret < 0)
+ return ret;
+
+ ret = uniwill_resume_keyboard(data);
+ if (ret < 0)
+ return ret;
+
+ return uniwill_resume_battery(data);
+}
+
+static DEFINE_SIMPLE_DEV_PM_OPS(uniwill_pm_ops, uniwill_suspend, uniwill_resume);
+
+/*
+ * We only use the DMI table for auoloading because the ACPI device itself
+ * does not guarantee that the underlying EC implementation is supported.
+ */
+static const struct acpi_device_id uniwill_id_table[] = {
+ { "INOU0000" },
+ { },
+};
+
+static struct platform_driver uniwill_driver = {
+ .driver = {
+ .name = DRIVER_NAME,
+ .dev_groups = uniwill_groups,
+ .probe_type = PROBE_PREFER_ASYNCHRONOUS,
+ .acpi_match_table = uniwill_id_table,
+ .pm = pm_sleep_ptr(&uniwill_pm_ops),
+ },
+ .probe = uniwill_probe,
+ .shutdown = uniwill_shutdown,
+};
+
+static const struct dmi_system_id uniwill_dmi_table[] __initconst = {
+ {
+ .ident = "XMG FUSION 15",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "SchenkerTechnologiesGmbH"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "LAPQC71A"),
+ },
+ },
+ {
+ .ident = "XMG FUSION 15",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "SchenkerTechnologiesGmbH"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "LAPQC71B"),
+ },
+ },
+ {
+ .ident = "Intel NUC x15",
+ .matches = {
+ DMI_EXACT_MATCH(DMI_SYS_VENDOR, "Intel(R) Client Systems"),
+ DMI_EXACT_MATCH(DMI_PRODUCT_NAME, "LAPAC71H"),
+ },
+ .driver_data = (void *)(UNIWILL_FEATURE_FN_LOCK_TOGGLE |
+ UNIWILL_FEATURE_SUPER_KEY_TOGGLE |
+ UNIWILL_FEATURE_TOUCHPAD_TOGGLE |
+ UNIWILL_FEATURE_BATTERY |
+ UNIWILL_FEATURE_HWMON),
+ },
+ {
+ .ident = "Intel NUC x15",
+ .matches = {
+ DMI_EXACT_MATCH(DMI_SYS_VENDOR, "Intel(R) Client Systems"),
+ DMI_EXACT_MATCH(DMI_PRODUCT_NAME, "LAPKC71F"),
+ },
+ .driver_data = (void *)(UNIWILL_FEATURE_FN_LOCK_TOGGLE |
+ UNIWILL_FEATURE_SUPER_KEY_TOGGLE |
+ UNIWILL_FEATURE_TOUCHPAD_TOGGLE |
+ UNIWILL_FEATURE_LIGHTBAR |
+ UNIWILL_FEATURE_BATTERY |
+ UNIWILL_FEATURE_HWMON),
+ },
+ {
+ .ident = "TUXEDO InfinityBook Pro 14 Gen6 Intel",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "PHxTxX1"),
+ },
+ },
+ {
+ .ident = "TUXEDO InfinityBook Pro 14 Gen6 Intel",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "PHxTQx1"),
+ },
+ },
+ {
+ .ident = "TUXEDO InfinityBook Pro 14/16 Gen7 Intel",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "PHxARX1_PHxAQF1"),
+ },
+ },
+ {
+ .ident = "TUXEDO InfinityBook Pro 16 Gen7 Intel/Commodore Omnia-Book Pro Gen 7",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "PH6AG01_PH6AQ71_PH6AQI1"),
+ },
+ },
+ {
+ .ident = "TUXEDO InfinityBook Pro 14/16 Gen8 Intel/Commodore Omnia-Book Pro Gen 8",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "PH4PRX1_PH6PRX1"),
+ },
+ },
+ {
+ .ident = "TUXEDO InfinityBook Pro 14 Gen8 Intel/Commodore Omnia-Book Pro Gen 8",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "PH4PG31"),
+ },
+ },
+ {
+ .ident = "TUXEDO InfinityBook Pro 16 Gen8 Intel",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "PH6PG01_PH6PG71"),
+ },
+ },
+ {
+ .ident = "TUXEDO InfinityBook Pro 14/15 Gen9 AMD",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "GXxHRXx"),
+ },
+ },
+ {
+ .ident = "TUXEDO InfinityBook Pro 14/15 Gen9 Intel/Commodore Omnia-Book 15 Gen9",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "GXxMRXx"),
+ },
+ },
+ {
+ .ident = "TUXEDO InfinityBook Pro 14/15 Gen10 AMD",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "XxHP4NAx"),
+ },
+ },
+ {
+ .ident = "TUXEDO InfinityBook Pro 14/15 Gen10 AMD",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "XxKK4NAx_XxSP4NAx"),
+ },
+ },
+ {
+ .ident = "TUXEDO InfinityBook Pro 15 Gen10 Intel",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "XxAR4NAx"),
+ },
+ },
+ {
+ .ident = "TUXEDO InfinityBook Max 15 Gen10 AMD",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "X5KK45xS_X5SP45xS"),
+ },
+ },
+ {
+ .ident = "TUXEDO InfinityBook Max 16 Gen10 AMD",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "X6HP45xU"),
+ },
+ },
+ {
+ .ident = "TUXEDO InfinityBook Max 16 Gen10 AMD",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "X6KK45xU_X6SP45xU"),
+ },
+ },
+ {
+ .ident = "TUXEDO InfinityBook Max 15 Gen10 Intel",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "X5AR45xS"),
+ },
+ },
+ {
+ .ident = "TUXEDO InfinityBook Max 16 Gen10 Intel",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "X6AR55xU"),
+ },
+ },
+ {
+ .ident = "TUXEDO Polaris 15 Gen1 AMD",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "POLARIS1501A1650TI"),
+ },
+ },
+ {
+ .ident = "TUXEDO Polaris 15 Gen1 AMD",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "POLARIS1501A2060"),
+ },
+ },
+ {
+ .ident = "TUXEDO Polaris 17 Gen1 AMD",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "POLARIS1701A1650TI"),
+ },
+ },
+ {
+ .ident = "TUXEDO Polaris 17 Gen1 AMD",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "POLARIS1701A2060"),
+ },
+ },
+ {
+ .ident = "TUXEDO Polaris 15 Gen1 Intel",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "POLARIS1501I1650TI"),
+ },
+ },
+ {
+ .ident = "TUXEDO Polaris 15 Gen1 Intel",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "POLARIS1501I2060"),
+ },
+ },
+ {
+ .ident = "TUXEDO Polaris 17 Gen1 Intel",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "POLARIS1701I1650TI"),
+ },
+ },
+ {
+ .ident = "TUXEDO Polaris 17 Gen1 Intel",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "POLARIS1701I2060"),
+ },
+ },
+ {
+ .ident = "TUXEDO Trinity 15 Intel Gen1",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "TRINITY1501I"),
+ },
+ },
+ {
+ .ident = "TUXEDO Trinity 17 Intel Gen1",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "TRINITY1701I"),
+ },
+ },
+ {
+ .ident = "TUXEDO Polaris 15/17 Gen2 AMD",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "GMxMGxx"),
+ },
+ },
+ {
+ .ident = "TUXEDO Polaris 15/17 Gen2 Intel",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "GMxNGxx"),
+ },
+ },
+ {
+ .ident = "TUXEDO Stellaris/Polaris 15/17 Gen3 AMD",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "GMxZGxx"),
+ },
+ },
+ {
+ .ident = "TUXEDO Stellaris/Polaris 15/17 Gen3 Intel",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "GMxTGxx"),
+ },
+ },
+ {
+ .ident = "TUXEDO Stellaris/Polaris 15/17 Gen4 AMD",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "GMxRGxx"),
+ },
+ },
+ {
+ .ident = "TUXEDO Stellaris 15 Gen4 Intel",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "GMxAGxx"),
+ },
+ },
+ {
+ .ident = "TUXEDO Polaris 15/17 Gen5 AMD",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "GMxXGxx"),
+ },
+ },
+ {
+ .ident = "TUXEDO Stellaris 16 Gen5 AMD",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "GM6XGxX"),
+ },
+ },
+ {
+ .ident = "TUXEDO Stellaris 16/17 Gen5 Intel/Commodore ORION Gen 5",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "GMxPXxx"),
+ },
+ },
+ {
+ .ident = "TUXEDO Stellaris Slim 15 Gen6 AMD",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "GMxHGxx"),
+ },
+ },
+ {
+ .ident = "TUXEDO Stellaris Slim 15 Gen6 Intel/Commodore ORION Slim 15 Gen6",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "GM5IXxA"),
+ },
+ },
+ {
+ .ident = "TUXEDO Stellaris 16 Gen6 Intel/Commodore ORION 16 Gen6",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "GM6IXxB_MB1"),
+ },
+ },
+ {
+ .ident = "TUXEDO Stellaris 16 Gen6 Intel/Commodore ORION 16 Gen6",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "GM6IXxB_MB2"),
+ },
+ },
+ {
+ .ident = "TUXEDO Stellaris 17 Gen6 Intel/Commodore ORION 17 Gen6",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "GM7IXxN"),
+ },
+ },
+ {
+ .ident = "TUXEDO Stellaris 16 Gen7 AMD",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "X6FR5xxY"),
+ },
+ },
+ {
+ .ident = "TUXEDO Stellaris 16 Gen7 Intel",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "X6AR5xxY"),
+ },
+ },
+ {
+ .ident = "TUXEDO Stellaris 16 Gen7 Intel",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "X6AR5xxY_mLED"),
+ },
+ },
+ {
+ .ident = "TUXEDO Book BA15 Gen10 AMD",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "PF5PU1G"),
+ },
+ },
+ {
+ .ident = "TUXEDO Pulse 14 Gen1 AMD",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "PULSE1401"),
+ },
+ },
+ {
+ .ident = "TUXEDO Pulse 15 Gen1 AMD",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "PULSE1501"),
+ },
+ },
+ {
+ .ident = "TUXEDO Pulse 15 Gen2 AMD",
+ .matches = {
+ DMI_MATCH(DMI_SYS_VENDOR, "TUXEDO"),
+ DMI_EXACT_MATCH(DMI_BOARD_NAME, "PF5LUXG"),
+ },
+ },
+ { }
+};
+MODULE_DEVICE_TABLE(dmi, uniwill_dmi_table);
+
+static int __init uniwill_init(void)
+{
+ const struct dmi_system_id *id;
+ int ret;
+
+ id = dmi_first_match(uniwill_dmi_table);
+ if (!id) {
+ if (!force)
+ return -ENODEV;
+
+ /* Assume that the device supports all features */
+ supported_features = UINT_MAX;
+ pr_warn("Loading on a potentially unsupported device\n");
+ } else {
+ supported_features = (uintptr_t)id->driver_data;
+ }
+
+ ret = platform_driver_register(&uniwill_driver);
+ if (ret < 0)
+ return ret;
+
+ ret = uniwill_wmi_register_driver();
+ if (ret < 0) {
+ platform_driver_unregister(&uniwill_driver);
+ return ret;
+ }
+
+ return 0;
+}
+module_init(uniwill_init);
+
+static void __exit uniwill_exit(void)
+{
+ uniwill_wmi_unregister_driver();
+ platform_driver_unregister(&uniwill_driver);
+}
+module_exit(uniwill_exit);
+
+MODULE_AUTHOR("Armin Wolf <W_Armin@gmx.de>");
+MODULE_DESCRIPTION("Uniwill notebook driver");
+MODULE_LICENSE("GPL");
diff --git a/hosts/laptop/uniwill-laptop/uniwill-wmi.c b/hosts/laptop/uniwill-laptop/uniwill-wmi.c
@@ -0,0 +1,92 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+/*
+ * Linux hotkey driver for Uniwill notebooks.
+ *
+ * Special thanks go to Pőcze Barnabás, Christoffer Sandberg and Werner Sembach
+ * for supporting the development of this driver either through prior work or
+ * by answering questions regarding the underlying WMI interface.
+ *
+ * Copyright (C) 2025 Armin Wolf <W_Armin@gmx.de>
+ */
+
+#define pr_fmt(fmt) KBUILD_MODNAME ": " fmt
+
+#include <linux/acpi.h>
+#include <linux/device.h>
+#include <linux/init.h>
+#include <linux/mod_devicetable.h>
+#include <linux/notifier.h>
+#include <linux/printk.h>
+#include <linux/types.h>
+#include <linux/wmi.h>
+
+#include "uniwill-wmi.h"
+
+#define DRIVER_NAME "uniwill-wmi"
+#define UNIWILL_EVENT_GUID "ABBC0F72-8EA1-11D1-00A0-C90629100000"
+
+static BLOCKING_NOTIFIER_HEAD(uniwill_wmi_chain_head);
+
+static void devm_uniwill_wmi_unregister_notifier(void *data)
+{
+ struct notifier_block *nb = data;
+
+ blocking_notifier_chain_unregister(&uniwill_wmi_chain_head, nb);
+}
+
+int devm_uniwill_wmi_register_notifier(struct device *dev, struct notifier_block *nb)
+{
+ int ret;
+
+ ret = blocking_notifier_chain_register(&uniwill_wmi_chain_head, nb);
+ if (ret < 0)
+ return ret;
+
+ return devm_add_action_or_reset(dev, devm_uniwill_wmi_unregister_notifier, nb);
+}
+
+static void uniwill_wmi_notify(struct wmi_device *wdev, union acpi_object *obj)
+{
+ u32 value;
+
+ if (obj->type != ACPI_TYPE_INTEGER)
+ return;
+
+ value = obj->integer.value;
+
+ dev_dbg(&wdev->dev, "Received WMI event %u\n", value);
+
+ blocking_notifier_call_chain(&uniwill_wmi_chain_head, value, NULL);
+}
+
+/*
+ * We cannot fully trust this GUID since Uniwill just copied the WMI GUID
+ * from the Windows driver example, and others probably did the same.
+ *
+ * Because of this we cannot use this WMI GUID for autoloading. Instead the
+ * associated driver will be registered manually after matching a DMI table.
+ */
+static const struct wmi_device_id uniwill_wmi_id_table[] = {
+ { UNIWILL_EVENT_GUID, NULL },
+ { }
+};
+
+static struct wmi_driver uniwill_wmi_driver = {
+ .driver = {
+ .name = DRIVER_NAME,
+ .probe_type = PROBE_PREFER_ASYNCHRONOUS,
+ },
+ .id_table = uniwill_wmi_id_table,
+ .notify = uniwill_wmi_notify,
+ .no_singleton = true,
+};
+
+int __init uniwill_wmi_register_driver(void)
+{
+ return wmi_driver_register(&uniwill_wmi_driver);
+}
+
+void __exit uniwill_wmi_unregister_driver(void)
+{
+ wmi_driver_unregister(&uniwill_wmi_driver);
+}
diff --git a/hosts/laptop/uniwill-laptop/uniwill-wmi.h b/hosts/laptop/uniwill-laptop/uniwill-wmi.h
@@ -0,0 +1,129 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+/*
+ * Linux hotkey driver for Uniwill notebooks.
+ *
+ * Copyright (C) 2025 Armin Wolf <W_Armin@gmx.de>
+ */
+
+#ifndef UNIWILL_WMI_H
+#define UNIWILL_WMI_H
+
+#include <linux/init.h>
+
+#define UNIWILL_OSD_CAPSLOCK 0x01
+#define UNIWILL_OSD_NUMLOCK 0x02
+#define UNIWILL_OSD_SCROLLLOCK 0x03
+
+#define UNIWILL_OSD_TOUCHPAD_ON 0x04
+#define UNIWILL_OSD_TOUCHPAD_OFF 0x05
+
+#define UNIWILL_OSD_SILENT_MODE_ON 0x06
+#define UNIWILL_OSD_SILENT_MODE_OFF 0x07
+
+#define UNIWILL_OSD_WLAN_ON 0x08
+#define UNIWILL_OSD_WLAN_OFF 0x09
+
+#define UNIWILL_OSD_WIMAX_ON 0x0A
+#define UNIWILL_OSD_WIMAX_OFF 0x0B
+
+#define UNIWILL_OSD_BLUETOOTH_ON 0x0C
+#define UNIWILL_OSD_BLUETOOTH_OFF 0x0D
+
+#define UNIWILL_OSD_RF_ON 0x0E
+#define UNIWILL_OSD_RF_OFF 0x0F
+
+#define UNIWILL_OSD_3G_ON 0x10
+#define UNIWILL_OSD_3G_OFF 0x11
+
+#define UNIWILL_OSD_WEBCAM_ON 0x12
+#define UNIWILL_OSD_WEBCAM_OFF 0x13
+
+#define UNIWILL_OSD_BRIGHTNESSUP 0x14
+#define UNIWILL_OSD_BRIGHTNESSDOWN 0x15
+
+#define UNIWILL_OSD_RADIOON 0x1A
+#define UNIWILL_OSD_RADIOOFF 0x1B
+
+#define UNIWILL_OSD_POWERSAVE_ON 0x31
+#define UNIWILL_OSD_POWERSAVE_OFF 0x32
+
+#define UNIWILL_OSD_MENU 0x34
+
+#define UNIWILL_OSD_MUTE 0x35
+#define UNIWILL_OSD_VOLUMEDOWN 0x36
+#define UNIWILL_OSD_VOLUMEUP 0x37
+
+#define UNIWILL_OSD_MENU_2 0x38
+
+#define UNIWILL_OSD_LIGHTBAR_ON 0x39
+#define UNIWILL_OSD_LIGHTBAR_OFF 0x3A
+
+#define UNIWILL_OSD_KB_LED_LEVEL0 0x3B
+#define UNIWILL_OSD_KB_LED_LEVEL1 0x3C
+#define UNIWILL_OSD_KB_LED_LEVEL2 0x3D
+#define UNIWILL_OSD_KB_LED_LEVEL3 0x3E
+#define UNIWILL_OSD_KB_LED_LEVEL4 0x3F
+
+#define UNIWILL_OSD_SUPER_KEY_LOCK_ENABLE 0x40
+#define UNIWILL_OSD_SUPER_KEY_LOCK_DISABLE 0x41
+
+#define UNIWILL_OSD_MENU_JP 0x42
+
+#define UNIWILL_OSD_CAMERA_ON 0x90
+#define UNIWILL_OSD_CAMERA_OFF 0x91
+
+#define UNIWILL_OSD_RFKILL 0xA4
+
+#define UNIWILL_OSD_SUPER_KEY_LOCK_CHANGED 0xA5
+
+#define UNIWILL_OSD_LIGHTBAR_STATE_CHANGED 0xA6
+
+#define UNIWILL_OSD_FAN_BOOST_STATE_CHANGED 0xA7
+
+#define UNIWILL_OSD_LCD_SW 0xA9
+
+#define UNIWILL_OSD_FAN_OVERTEMP 0xAA
+
+#define UNIWILL_OSD_DC_ADAPTER_CHANGED 0xAB
+
+#define UNIWILL_OSD_BAT_HP_OFF 0xAC
+
+#define UNIWILL_OSD_FAN_DOWN_TEMP 0xAD
+
+#define UNIWILL_OSD_BATTERY_ALERT 0xAE
+
+#define UNIWILL_OSD_TIMAP_HAIERLB_SW 0xAF
+
+#define UNIWILL_OSD_PERFORMANCE_MODE_TOGGLE 0xB0
+
+#define UNIWILL_OSD_KBDILLUMDOWN 0xB1
+#define UNIWILL_OSD_KBDILLUMUP 0xB2
+
+#define UNIWILL_OSD_BACKLIGHT_LEVEL_CHANGE 0xB3
+#define UNIWILL_OSD_BACKLIGHT_POWER_CHANGE 0xB4
+
+#define UNIWILL_OSD_MIC_MUTE 0xB7
+
+#define UNIWILL_OSD_FN_LOCK 0xB8
+#define UNIWILL_OSD_KBDILLUMTOGGLE 0xB9
+
+#define UNIWILL_OSD_BAT_CHARGE_FULL_24_H 0xBE
+
+#define UNIWILL_OSD_BAT_ERM_UPDATE 0xBF
+
+#define UNIWILL_OSD_BENCHMARK_MODE_TOGGLE 0xC0
+
+#define UNIWILL_OSD_WEBCAM_TOGGLE 0xCF
+
+#define UNIWILL_OSD_KBD_BACKLIGHT_CHANGED 0xF0
+
+struct device;
+struct notifier_block;
+
+int devm_uniwill_wmi_register_notifier(struct device *dev, struct notifier_block *nb);
+
+int __init uniwill_wmi_register_driver(void);
+
+void __exit uniwill_wmi_unregister_driver(void);
+
+#endif /* UNIWILL_WMI_H */
diff --git a/modules/workstation.nix b/modules/workstation.nix
@@ -0,0 +1,43 @@
+# Claude Code, the Claude desktop app, Obsidian, and the git / GitHub / GitLab
+# command-line tools. Written by nixdaemon-bootstrap.sh; edit freely.
+{ lib, pkgs, inputs, ... }:
+let
+ # Anthropic's own Linux builds, repackaged for Nix by numtide and refreshed
+ # daily. (NixOS isn't a distro Anthropic supports directly: its desktop app
+ # ships as a .deb for Debian/Ubuntu.)
+ claude = inputs.llm-agents.packages.${pkgs.stdenv.hostPlatform.system};
+in
+{
+ nix.settings.experimental-features = [ "nix-command" "flakes" ];
+
+ # Unfree packages (Obsidian) are already allowed elsewhere in this config.
+
+ environment.systemPackages = [
+ claude.claude-code # terminal: `claude`
+ claude.claude-desktop # desktop app: "Claude" in your launcher, or `claude-desktop`
+ pkgs.obsidian
+ pkgs.git
+ pkgs.gh # GitHub CLI
+ pkgs.glab # GitLab CLI
+ pkgs.qemu_kvm # only for the desktop app's Cowork tab
+ ];
+
+ # The desktop app keeps its sign-in in the system keyring; without one it
+ # asks you to log in on every launch.
+ services.gnome.gnome-keyring.enable = lib.mkDefault true;
+
+ # The desktop app's Cowork tab runs its tasks in a local VM, which needs KVM.
+ # If you don't use Cowork, delete these two lines and qemu_kvm above.
+ boot.kernelModules = [ "vhost_vsock" ];
+ users.groups.kvm.members = [ "daemonsec" ];
+
+ # Run Electron apps (Claude, Obsidian) natively on Wayland, e.g. on Hyprland.
+ environment.sessionVariables.NIXOS_OZONE_WL = lib.mkDefault "1";
+
+ # Optional: numtide's binary cache, so the Claude packages download instead
+ # of building a couple of small helper tools locally on each update.
+ # nix.settings.extra-substituters = [ "https://cache.numtide.com" ];
+ # nix.settings.extra-trusted-public-keys = [
+ # "niks3.numtide.com-1:DTx8wZduET09hRmMtKdQDxNNthLQETkc/yaX7M4qK0g="
+ # ];
+}