├── default.nix ├── flake.nix ├── src ├── module.nix ├── fprint-script.nix ├── options.nix └── config.nix ├── .github └── workflows │ └── flakehub-publish-rolling.yml ├── LICENSE └── README.md /default.nix: -------------------------------------------------------------------------------- 1 | import ./src/module.nix 2 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "NixOS module for changing the LED color on Framework laptops"; 3 | 4 | outputs = 5 | { self }: 6 | { 7 | nixosModules.default = import self; 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /src/module.nix: -------------------------------------------------------------------------------- 1 | { 2 | config, 3 | lib, 4 | pkgs, 5 | ... 6 | }: 7 | 8 | { 9 | options = import ./options.nix { inherit lib; }; 10 | config = import ./config.nix { inherit config lib pkgs; }; 11 | } 12 | -------------------------------------------------------------------------------- /.github/workflows/flakehub-publish-rolling.yml: -------------------------------------------------------------------------------- 1 | name: "Publish every Git push to main to FlakeHub" 2 | on: 3 | push: 4 | branches: 5 | - "main" 6 | jobs: 7 | flakehub-publish: 8 | runs-on: "ubuntu-latest" 9 | permissions: 10 | id-token: "write" 11 | contents: "read" 12 | steps: 13 | - uses: "actions/checkout@v3" 14 | - uses: "DeterminateSystems/nix-installer-action@main" 15 | - uses: "DeterminateSystems/flakehub-push@main" 16 | with: 17 | name: "io12/nixos-framework-led" 18 | rolling: true 19 | visibility: "public" 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Benjamin Levy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/fprint-script.nix: -------------------------------------------------------------------------------- 1 | { 2 | pkgs, 3 | lib, 4 | cfg, 5 | shellLed, 6 | }: 7 | 8 | let 9 | 10 | name = "framework-led-fprint"; 11 | 12 | src = '' 13 | #!/usr/bin/env python3 14 | 15 | from pydbus import SystemBus 16 | from gi.repository import GLib 17 | from os import system 18 | from time import sleep 19 | 20 | 21 | ${ 22 | if builtins.isNull cfg.fprint.flashDuration then 23 | '' 24 | def off(): 25 | pass 26 | '' 27 | else 28 | '' 29 | def off(): 30 | sleep(${lib.strings.floatToString (cfg.fprint.flashDuration)}) 31 | system("${shellLed cfg.default.color}") 32 | '' 33 | } 34 | 35 | 36 | def callback(_, __, ___, signal_name, fields): 37 | if signal_name == "VerifyFingerSelected": 38 | system("${shellLed cfg.fprint.scanColor}") 39 | elif signal_name == "VerifyStatus": 40 | (result, done) = fields 41 | if result == "verify-match": 42 | system("${shellLed cfg.fprint.successColor}") 43 | off() 44 | else: 45 | system("${shellLed cfg.fprint.failColor}") 46 | if done: 47 | off() 48 | 49 | 50 | bus = SystemBus() 51 | bus.subscribe(iface="net.reactivated.Fprint.Device", signal_fired=callback) 52 | 53 | loop = GLib.MainLoop() 54 | loop.run() 55 | ''; 56 | 57 | in 58 | pkgs.stdenv.mkDerivation { 59 | inherit name src; 60 | passAsFile = [ "src" ]; 61 | nativeBuildInputs = with pkgs; [ 62 | gobject-introspection 63 | wrapGAppsNoGuiHook 64 | ]; 65 | buildInputs = [ (pkgs.python3.withPackages (ps: with ps; [ pydbus ])) ]; 66 | dontUnpack = true; 67 | buildPhase = '' 68 | mkdir -p $out/bin 69 | mv $srcPath $out/bin/$name 70 | chmod +x $out/bin/$name 71 | ''; 72 | meta.mainProgram = name; 73 | } 74 | -------------------------------------------------------------------------------- /src/options.nix: -------------------------------------------------------------------------------- 1 | { lib }: 2 | 3 | let 4 | mkColorOption = 5 | default: description: 6 | lib.mkOption { 7 | inherit default description; 8 | type = lib.types.enum [ 9 | "off" 10 | "red" 11 | "green" 12 | "yellow" 13 | "white" 14 | "amber" 15 | ]; 16 | }; 17 | 18 | mkFlashDurationOption = 19 | default: description: 20 | lib.mkOption { 21 | inherit default description; 22 | type = lib.types.nullOr lib.types.float; 23 | }; 24 | 25 | in 26 | { 27 | services.framework-led = { 28 | user = lib.mkOption { 29 | type = lib.types.str; 30 | default = "root"; 31 | description = "User to run the systemd services"; 32 | }; 33 | 34 | default = { 35 | color = mkColorOption "white" "The default color of the power LED"; 36 | setOnBoot = lib.mkEnableOption "Set Framework LED color to default on boot"; 37 | }; 38 | 39 | fprint = { 40 | enable = lib.mkEnableOption "Change Framework LED color to fprintd status"; 41 | 42 | flashDuration = mkFlashDurationOption 0.3 "Duration of power LED flash when fprintd succeeds or fails, or null for the new color to persist"; 43 | 44 | scanColor = mkColorOption "yellow" "Color of the power LED when scanning for fingerprints with fprintd"; 45 | 46 | successColor = mkColorOption "green" "Color of the power LED when fingerprint verification succeeds"; 47 | 48 | failColor = mkColorOption "red" "Color of the power LED when fingerprint verification fails"; 49 | }; 50 | 51 | shell = { 52 | enableBash = lib.mkEnableOption "Set Framework LED color to last Bash command status"; 53 | 54 | enableZsh = lib.mkEnableOption "Set Framework LED color to last Zsh command status"; 55 | 56 | flashDuration = mkFlashDurationOption 5.0e-2 "Duration of power LED flash when shell command succeeds or fails, or null for the new color to persist"; 57 | 58 | successColor = mkColorOption "green" "Color of the power LED when shell command succeeds"; 59 | 60 | failColor = mkColorOption "red" "Color of the power LED when shell command fails"; 61 | }; 62 | }; 63 | } 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nixos-framework-led 2 | 3 | NixOS module for changing the LED color on Framework laptops 4 | 5 | ## Features 6 | 7 | - Set a default LED color to be applied on boot, 8 | or turn the power LED off on boot 9 | - Make the LED blink a color depending on whether shell commands succeeded or failed 10 | - Make the LED color track the status of the fingerprint reader (by default yellow when scanning for fingerprints, green on success, and red on failure) 11 | 12 | ## Installation 13 | 14 | ### Flakes 15 | 16 | Example minimal`/etc/nixos/flake.nix`: 17 | 18 | ``` nix 19 | { 20 | description = "NixOS configuration"; 21 | 22 | inputs = { 23 | nixos-framework-led.url = "github:io12/nixos-framework-led"; 24 | }; 25 | 26 | outputs = { nixpkgs, nixos-framework-led }: { 27 | nixosConfigurations.my-hostname = nixpkgs.lib.nixosSystem { 28 | system = "x86_64-linux"; 29 | modules = [ 30 | (import nixos-framework-led) 31 | { 32 | services.framework-led = { 33 | default = { 34 | color = "off"; 35 | setOnBoot = true; 36 | }; 37 | fprint.enable = true; 38 | shell = { 39 | enableBash = true; 40 | enableZsh = true; 41 | }; 42 | }; 43 | } 44 | ]; 45 | }; 46 | }; 47 | } 48 | ``` 49 | 50 | A full list of options can be explored with the `nixos-option services.framework-led` command. 51 | 52 | ### Channels 53 | 54 | Add a `nixos-framework-led` channel with 55 | 56 | ``` sh 57 | sudo nix-channel --add https://github.com/io12/nixos-framework-led/archive/main.tar.gz nixos-framework-led 58 | sudo nix-channel --update 59 | ``` 60 | 61 | Then make the following modification to `/etc/nixos/configuration.nix`: 62 | 63 | ``` nix 64 | { ... }: 65 | 66 | { 67 | imports = [ 68 | (import ) 69 | ]; 70 | 71 | services.framework-led = { 72 | user = "user"; 73 | default = { 74 | color = "off"; 75 | setOnBoot = true; 76 | }; 77 | fprint.enable = true; 78 | shell = { 79 | enableBash = true; 80 | enableZsh = true; 81 | }; 82 | }; 83 | } 84 | ``` 85 | 86 | A full list of options can be explored with the `nixos-option services.framework-led` command. 87 | -------------------------------------------------------------------------------- /src/config.nix: -------------------------------------------------------------------------------- 1 | { 2 | config, 3 | lib, 4 | pkgs, 5 | }: 6 | 7 | let 8 | cfg = config.services.framework-led; 9 | 10 | somethingEnabled = 11 | cfg.default.setOnBoot || cfg.fprint.enable || cfg.shell.enableBash || cfg.shell.enableZsh; 12 | 13 | shellLed = color: "${config.security.wrapperDir}/framework-led ${color}"; 14 | 15 | shellBlink = color: '' 16 | ${shellLed color} 17 | ${lib.optionalString (!(builtins.isNull cfg.shell.flashDuration)) '' 18 | sleep ${lib.strings.floatToString (cfg.shell.flashDuration)} 19 | ${shellLed cfg.default.color} 20 | ''} 21 | ''; 22 | 23 | shellInitCommon = '' 24 | precmd() { 25 | if [ $? -eq 0 ]; then 26 | ((${shellBlink cfg.shell.successColor}) &) 27 | else 28 | ((${shellBlink cfg.shell.failColor}) &) 29 | fi 30 | } 31 | trap '${shellLed cfg.default.color}' EXIT 32 | ''; 33 | 34 | in 35 | lib.mkMerge [ 36 | 37 | # Normally ectool requires root. 38 | # Create a setuid wrapper so other users can use it. 39 | (lib.mkIf somethingEnabled { 40 | security.wrappers.framework-led = { 41 | setuid = true; 42 | owner = "root"; 43 | group = "root"; 44 | source = pkgs.writeScript "framework-led" '' 45 | #!${pkgs.bash}/bin/bash -p 46 | # Ensure argument is nonempty to avoid segfault 47 | [ -n "$1" ] && ${pkgs.fw-ectool}/bin/ectool led power "$1" 48 | ''; 49 | }; 50 | }) 51 | 52 | (lib.mkIf cfg.shell.enableBash { 53 | programs.bash.interactiveShellInit = '' 54 | ${shellInitCommon} 55 | PROMPT_COMMAND=precmd 56 | ''; 57 | }) 58 | 59 | (lib.mkIf cfg.shell.enableZsh { 60 | programs.zsh.interactiveShellInit = shellInitCommon; 61 | }) 62 | 63 | (lib.mkIf cfg.default.setOnBoot { 64 | systemd.services.framework-led-boot-default = { 65 | wantedBy = [ "multi-user.target" ]; 66 | description = "Set default Framework power LED color on boot"; 67 | serviceConfig = { 68 | Type = "oneshot"; 69 | User = cfg.user; 70 | ExecStart = shellLed cfg.default.color; 71 | }; 72 | }; 73 | }) 74 | 75 | (lib.mkIf cfg.fprint.enable { 76 | systemd.services.framework-led-fprint = { 77 | wantedBy = [ "fprintd.service" ]; 78 | description = "Change Framework power LED during fprintd events"; 79 | serviceConfig = { 80 | User = cfg.user; 81 | ExecStart = lib.getExe ( 82 | import ./fprint-script.nix { 83 | inherit 84 | pkgs 85 | lib 86 | cfg 87 | shellLed 88 | ; 89 | } 90 | ); 91 | }; 92 | }; 93 | }) 94 | 95 | ] 96 | --------------------------------------------------------------------------------