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

168 lines
6.8 KiB
Nix

{
config,
lib,
pkgs,
username,
...
}:
# Forza Horizon 4 / 5 → DualSense adaptive trigger bridge (variant D, "Cosmii"
# port). The daemon listens for Forza's fixed-format UDP "Data Out" telemetry
# stream at 60 Hz, parses each packet via fdp (nettrom/forza_motorsport, MIT),
# and drives the PS5 DualSense's adaptive triggers and lightbar via
# dualsense-controller (PyPI, MIT) over hidraw.
#
# Reference design: cosmii02/ForzaDSXlegacy (variant D in our taxonomy):
# - Continuous baseline resistance on both triggers (always feels)
# - Vibration overlay on slip events (both triggers)
# - RPM-reactive lightbar in-race; car-class color in menus
# - EWMA smoothing per channel
# - No body LRA (avoids the "shakes my whole hand" complaint)
# See forza_trigger.py module docstring for the full reference table and
# divergences from upstream.
#
# 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.
#
# Tuning env vars (read by the daemon at startup; values clamp to [0, 1]):
# FORZA_L2_INTENSITY=<0..1> global L2 (brake) feel scale. default 1.0
# FORZA_R2_INTENSITY=<0..1> global R2 (throttle) feel scale. default 1.0
# FORZA_LIGHTBAR=<0|1> enable lightbar feedback. default 1
# Set them in `services.forzaTrigger.environment` (an Environment= block on
# the systemd unit) to override.
let
cfg = config.services.forzaTrigger;
pythonPackages = import ./python-packages.nix { inherit lib pkgs; };
inherit (pythonPackages) dualsense-controller fdp;
forzaTriggerSrc = ./forza_trigger.py;
forzaTriggerBin = pkgs.writers.writePython3Bin "forza-trigger" {
libraries = [
dualsense-controller
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 forzaTriggerSrc);
# Build-time unit tests for the haptic computation. Failure here breaks the
# NixOS build, so deploys can't ship a daemon whose pure-function logic has
# regressed. Tests use stdlib unittest + a FakeController; no hardware needed.
forzaTriggerTests =
pkgs.runCommand "forza-trigger-tests"
{
nativeBuildInputs = [
(pkgs.python3.withPackages (_: [
dualsense-controller
fdp
]))
];
}
''
cp ${forzaTriggerSrc} forza_trigger.py
cp ${./test_forza_trigger.py} test_forza_trigger.py
python -m unittest discover -p 'test_*.py' -v
touch $out
'';
# The binary the system actually depends on. overrideAttrs adds the test
# derivation as a build dependency: if the tests fail, forzaTriggerTests
# fails to build, forzaTriggerBin's buildInputs can't be satisfied, and
# the system build fails. This is the standard nixpkgs idiom for build-time
# gating without ceremony around system.checks / passthru.tests.
forzaTrigger = forzaTriggerBin.overrideAttrs (old: {
buildInputs = (old.buildInputs or [ ]) ++ [ forzaTriggerTests ];
});
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;
};
};
};
}