main
  1# Automatically inhibit (disable) the laptop's internal keyboard when an
  2# external keyboard is connected, and re-enable it on disconnect.
  3#
  4# Uses bluetooth-monitor for Bluetooth keyboards (D-Bus events) and udev
  5# rules for USB keyboards. Writes to the kernel's input device `inhibited`
  6# sysfs attribute (Linux 5.11+).
  7#
  8# Usage:
  9#   services.laptop-keyboard-inhibit = {
 10#     enable = true;
 11#     # Match by device name (uses bluetooth-monitor for BT detection)
 12#     keyboards = [ "Eyelash Corne" ];
 13#     # Or match USB keyboards by vendor:product ID (uses udev)
 14#     usbKeyboards = [ { vendor = "3297"; product = "1969"; } ];
 15#   };
 16{
 17  config,
 18  lib,
 19  pkgs,
 20  ...
 21}:
 22let
 23  inherit (lib)
 24    mkEnableOption
 25    mkIf
 26    mkOption
 27    types
 28    concatMapStringsSep
 29    ;
 30  cfg = config.services.laptop-keyboard-inhibit;
 31
 32  # Script that finds and inhibits/uninhibits the internal keyboard
 33  # Searches for keyboards by name pattern (works for both PS/2 and USB internal keyboards)
 34  toggleScript = pkgs.writeShellScript "toggle-internal-kbd" ''
 35    ACTION="$1" # "inhibit" or "uninhibit"
 36
 37    VALUE=1
 38    if [ "$ACTION" = "uninhibit" ]; then
 39      VALUE=0
 40    fi
 41
 42    # Find internal keyboard by common name patterns
 43    # Matches: "AT Translated Set 2 keyboard", "Asus Keyboard", etc.
 44    for input_dir in /sys/class/input/input*/; do
 45      if [ ! -f "$input_dir/name" ]; then
 46        continue
 47      fi
 48      
 49      name=$(cat "$input_dir/name")
 50      
 51      # Match internal laptop keyboards (not external devices)
 52      if echo "$name" | grep -qiE "(AT Translated|Asus Keyboard|Internal Keyboard|Laptop Keyboard)"; then
 53        inhibit_file="$input_dir/inhibited"
 54        if [ -f "$inhibit_file" ]; then
 55          echo "$VALUE" > "$inhibit_file" 2>/dev/null && \
 56            echo "[$ACTION] $name" || true
 57        fi
 58      fi
 59    done
 60  '';
 61
 62  # Generate udev rules for USB vendor:product matching
 63  usbRules = concatMapStringsSep "\n" (kb: ''
 64    # Inhibit internal keyboard when USB keyboard ${kb.vendor}:${kb.product} connects
 65    ACTION=="add", SUBSYSTEM=="input", ATTR{id/vendor}=="${kb.vendor}", ATTR{id/product}=="${kb.product}", ATTR{name}!="", RUN+="${toggleScript} inhibit"
 66    ACTION=="remove", SUBSYSTEM=="input", ENV{ID_VENDOR_ID}=="${kb.vendor}", ENV{ID_MODEL_ID}=="${kb.product}", RUN+="${toggleScript} uninhibit"
 67  '') cfg.usbKeyboards;
 68in
 69{
 70  options = {
 71    services.laptop-keyboard-inhibit = {
 72      enable = mkEnableOption "Inhibit internal laptop keyboard when external keyboard is connected";
 73
 74      keyboards = mkOption {
 75        type = with types; listOf str;
 76        default = [ ];
 77        example = [
 78          "Eyelash Corne"
 79          "My Custom Keyboard"
 80        ];
 81        description = ''
 82          List of external keyboard device names to watch for.
 83          When a keyboard with this name connects, the internal laptop keyboard
 84          is inhibited (disabled). It is re-enabled when the external keyboard
 85          disconnects.
 86
 87          Find your keyboard's name with: cat /proc/bus/input/devices
 88        '';
 89      };
 90
 91      usbKeyboards = mkOption {
 92        type = with types; listOf (attrsOf str);
 93        default = [ ];
 94        example = [
 95          {
 96            vendor = "3297";
 97            product = "1969";
 98          }
 99        ];
100        description = ''
101          List of USB keyboard vendor/product ID pairs to watch for.
102          Find your keyboard's IDs with: lsusb
103
104          Example: ZSA Moonlander is vendor 3297, product 1969.
105        '';
106      };
107    };
108  };
109
110  config = mkIf cfg.enable {
111    # USB keyboard detection via udev
112    services.udev.extraRules = mkIf (usbRules != "") usbRules;
113
114    # Bluetooth keyboard detection via bluetooth-monitor
115    systemd.services.bluetooth-keyboard-monitor = mkIf (cfg.keyboards != [ ]) {
116      description = "Monitor Bluetooth keyboards and toggle internal keyboard";
117      after = [ "bluetooth.service" ];
118      requires = [ "bluetooth.service" ];
119      wantedBy = [ "multi-user.target" ];
120      path = with pkgs; [
121        bluez
122        gawk
123      ];
124
125      serviceConfig = {
126        Type = "simple";
127        Restart = "always";
128        RestartSec = 5;
129      };
130
131      script =
132        let
133          # Get Bluetooth MAC addresses for configured keyboard names
134          # User needs to pair keyboards first with bluetoothctl
135          getDeviceScript = pkgs.writeShellScript "get-bt-devices" ''
136            # Wait for bluetooth to be ready
137            sleep 2
138
139            # Find MAC addresses for configured keyboard names
140            ${concatMapStringsSep "\n" (name: ''
141              MAC=$(bluetoothctl devices | grep -i "${name}" | awk '{print $2}' | head -1)
142              if [ -n "$MAC" ]; then
143                echo "$MAC"
144              fi
145            '') cfg.keyboards}
146          '';
147        in
148        ''
149          set -euo pipefail
150
151          # Get MAC addresses of configured keyboards
152          MACS=$(${getDeviceScript})
153
154          if [ -z "$MACS" ]; then
155            echo "No Bluetooth keyboards found. Make sure they are paired."
156            echo "Use: bluetoothctl pair <MAC>"
157            sleep 10
158            exit 1
159          fi
160
161          # Monitor each keyboard
162          while IFS= read -r MAC; do
163            (
164              echo "Monitoring Bluetooth keyboard: $MAC"
165              while true; do
166                if bluetoothctl info "$MAC" 2>/dev/null | grep -q "Connected: yes"; then
167                  # Keyboard connected - disable internal keyboard
168                  ${toggleScript} inhibit
169                else
170                  # Keyboard disconnected - enable internal keyboard
171                  ${toggleScript} uninhibit
172                fi
173                sleep 5
174              done
175            ) &
176          done <<< "$MACS"
177
178          # Wait for all background monitors
179          wait
180        '';
181    };
182  };
183}