forza-trigger: things

This commit is contained in:
2026-05-06 19:14:54 -04:00
parent 0568a571a1
commit 03c3d01c66
3 changed files with 1415 additions and 225 deletions

View File

@@ -5,12 +5,20 @@
username,
...
}:
# Forza Horizon 4 / 5 → DualSense adaptive trigger bridge.
# 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.
#
# 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 dualsense-controller (PyPI, MIT) which talks HID 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
@@ -20,12 +28,20 @@
# - 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;
forzaTrigger = pkgs.writers.writePython3Bin "forza-trigger" {
forzaTriggerSrc = ./forza_trigger.py;
forzaTriggerBin = pkgs.writers.writePython3Bin "forza-trigger" {
libraries = [
dualsense-controller
fdp
@@ -33,7 +49,36 @@ let
# 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);
} (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
{