{ config, lib, pkgs, username, ... }: # Forza Horizon 4 / 5 → DualSense adaptive trigger bridge. # # Forza emits a fixed-format UDP telemetry stream ("Data Out") at 60 Hz on a # user-configured port. We listen on that port, parse each packet via fdp # (nettrom/forza_motorsport, MIT), and drive the PS5 DualSense's adaptive # triggers via pydualsense (PyPI, MIT) which talks HID over hidraw. # # Setup on the user side, once enabled here: # - plug the DualSense in over USB and disable Steam Input for the # controller (Settings → Controller → "PlayStation Configuration Support": # OFF). Bluetooth works too but the udev/hidraw path is more reliable # over USB. # - in Forza, HUD options → set Data Out: ON, Data Out IP: 127.0.0.1, # Data Out IP Port: 5300, and (FM7 only) Data Out Packet Format: CAR DASH. # # System-interaction notes: # - With multiple DualSense controllers connected, pydualsense picks one # non-deterministically (`# TODO: implement multiple controllers working` # in pydualsense's source). Forza Horizon is single-player so this is # usually fine. If you need to pin a specific controller, the cleanest # route is monkey-patching `pydualsense.__find_device`. # - `pkgs.dualsensectl` is intentionally NOT installed by default # (single-shot writes from it get overwritten by our BG thread within # ~4 ms). Bring it in ad-hoc with `nix-shell -p dualsensectl` and stop # this service first via `systemctl --user stop forza-trigger`. # - Hot-plug recovery happens in-process: the daemon polls pydualsense's BG # thread liveness and re-runs `pydualsense.init()` on disconnect. systemd's # `Restart=on-failure` exists only as a crash-recovery safety net. let cfg = config.services.forzaTrigger; pythonPackages = import ./python-packages.nix { inherit lib pkgs; }; inherit (pythonPackages) pydualsense fdp; forzaTrigger = pkgs.writers.writePython3Bin "forza-trigger" { libraries = [ pydualsense fdp ]; # The wrapped binary doesn't need style enforcement — readability of # the source file is what matters, and that lives in forza_trigger.py. doCheck = false; } (builtins.readFile ./forza_trigger.py); in { options.services.forzaTrigger = { enable = lib.mkEnableOption "Forza Horizon → DualSense adaptive trigger bridge"; user = lib.mkOption { type = lib.types.str; default = username; description = '' User the trigger daemon runs as. Must be the user playing Forza so the DualSense's hidraw uaccess ACL applies. ''; }; port = lib.mkOption { type = lib.types.port; default = 5300; description = '' UDP port the daemon listens on for Forza Data Out packets. Must match the value configured in Forza's HUD options. ''; }; }; config = lib.mkIf cfg.enable { # uaccess hands /dev/hidraw* of the connected PS5 DualSense to the # active-seat user via ACL. Steam ships near-identical rules; declaring # them here keeps the module self-contained (and works even if Steam # isn't running). services.udev.extraRules = '' # PS5 DualSense (USB) KERNEL=="hidraw*", ATTRS{idVendor}=="054c", ATTRS{idProduct}=="0ce6", MODE="0660", TAG+="uaccess" # PS5 DualSense Edge (USB) KERNEL=="hidraw*", ATTRS{idVendor}=="054c", ATTRS{idProduct}=="0df2", MODE="0660", TAG+="uaccess" # PS5 DualSense (Bluetooth) KERNEL=="hidraw*", KERNELS=="*054C:0CE6*", MODE="0660", TAG+="uaccess" # PS5 DualSense Edge (Bluetooth) KERNEL=="hidraw*", KERNELS=="*054C:0DF2*", MODE="0660", TAG+="uaccess" ''; environment.systemPackages = [ forzaTrigger ]; # User-level service so it inherits the seat-bound uaccess ACL on # /dev/hidraw* and dies cleanly when the user logs out. systemd.user.services.forza-trigger = { description = "Forza Horizon → DualSense adaptive trigger bridge"; wantedBy = [ "default.target" ]; after = [ "graphical-session.target" ]; serviceConfig = { ExecStart = "${forzaTrigger}/bin/forza-trigger --host 127.0.0.1 --port ${toString cfg.port}"; Restart = "on-failure"; RestartSec = 3; }; }; }; }