Files
nixos/hosts/yarn/forza-trigger/default.nix

123 lines
4.7 KiB
Nix

{
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.
#
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 ];
# Socket-activated by Forza's Data Out UDP stream on port 5300.
# The service starts when Forza Horizon 4/5 sends its first telemetry
# packet, runs for the entire session, and exits on 3 s of silence
# (--exit-on-idle). systemd restarts automatically on the next launch.
#
# Runs as a system service (not user service) so there is exactly one
# instance regardless of how many users have active sessions. The service
# runs as `cfg.user` (primary) for hidraw uaccess compatibility with the
# udev TAG+="uaccess" rules.
#
# Crash-recovery: Restart=on-failure restarts the daemon mid-session if
# it dies abnormally (non-zero exit). The clean exit-on-idle path returns
# 0, which systemd leaves alone — the socket unit stays listening.
systemd.services.forza-trigger = {
description = "Forza Horizon DualSense adaptive trigger bridge";
# No wantedBy — socket activation pulls it in when Forza sends UDP.
serviceConfig = {
ExecStart = "${forzaTrigger}/bin/forza-trigger --host 127.0.0.1 --port ${toString cfg.port} --exit-on-idle";
Restart = "on-failure";
RestartSec = 3;
User = cfg.user;
# KillMode=control-group is the default, but be explicit.
# systemd sends SIGTERM by default; Python doesn't route that to
# KeyboardInterrupt, so we tell systemd to send SIGINT instead.
# The daemon registers handlers for both for robustness.
KillMode = "control-group";
KillSignal = "SIGINT";
TimeoutStopSec = 5;
};
};
systemd.sockets.forza-trigger = {
wantedBy = [ "sockets.target" ];
socketConfig = {
ListenDatagram = "127.0.0.1:${toString cfg.port}";
# Guard against rapid re-triggering. One activation per Forza session
# (minutes+) is normal; > 3 within 10 s is pathological (bug).
TriggerLimitIntervalSec = 10;
TriggerLimitBurst = 3;
};
};
};
}