NixDaemon

NixOS pentest workstation as one flake — IceBreaker's successor
git clone https://git.daemon-sec.xyz/NixDaemon.git
Log | Files | Refs | README

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:
A.gitignore | 2++
Aflake.lock | 789+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aflake.nix | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahome/caelestia/rose-pine-dark.txt | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahome/caelestia/scheme.json | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahome/caelestia/shell-tokens.json | 3+++
Ahome/default.nix | 20++++++++++++++++++++
Ahome/hypr/bindings.lua | 220+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahome/hypr/caelestia.lua | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahome/hypr/core.lua | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahome/hypr/defaults.lua | 289++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahome/hypr/lid.lua | 8++++++++
Ahome/hypr/omarchy.lua | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahome/modules/caelestia.nix | 197+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahome/modules/gtk.nix | 26++++++++++++++++++++++++++
Ahome/modules/hyprland.nix | 133+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahome/modules/terminal.nix | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahome/modules/tools.nix | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahosts/bootstrap/default.nix | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahosts/laptop/default.nix | 120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahosts/laptop/fan-reference/99-cpu-freq-cap.conf | 3+++
Ahosts/laptop/fan-reference/README.md | 45+++++++++++++++++++++++++++++++++++++++++++++
Ahosts/laptop/fan-reference/fan-ctl | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahosts/laptop/fan-reference/fan-state | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahosts/laptop/fan-reference/fanfix-fan.service | 11+++++++++++
Ahosts/laptop/fan-reference/fanfix.conf | 3+++
Ahosts/laptop/fan-reference/modules-load.conf | 2++
Ahosts/laptop/fan-reference/motherboard-stability.service | 27+++++++++++++++++++++++++++
Ahosts/laptop/fan-reference/uniwill-laptop.conf | 1+
Ahosts/laptop/fan-throttle-guard.nix | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahosts/laptop/fanfix | 372+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahosts/laptop/hardware-configuration.nix | 45+++++++++++++++++++++++++++++++++++++++++++++
Ahosts/laptop/nix-settings.nix | 17+++++++++++++++++
Ahosts/laptop/nvidia.nix | 42++++++++++++++++++++++++++++++++++++++++++
Ahosts/laptop/ssd.nix | 30++++++++++++++++++++++++++++++
Ahosts/laptop/stability_guard.py | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahosts/laptop/toolbox.nix | 26++++++++++++++++++++++++++
Ahosts/laptop/uniwill-laptop.nix | 20++++++++++++++++++++
Ahosts/laptop/uniwill-laptop/Makefile | 11+++++++++++
Ahosts/laptop/uniwill-laptop/package.nix | 41+++++++++++++++++++++++++++++++++++++++++
Ahosts/laptop/uniwill-laptop/uniwill-acpi.c | 1919+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahosts/laptop/uniwill-laptop/uniwill-wmi.c | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahosts/laptop/uniwill-laptop/uniwill-wmi.h | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amodules/workstation.nix | 43+++++++++++++++++++++++++++++++++++++++++++
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, &regval); + 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, &regval); + 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=" + # ]; +}