yarn: forza dualsense adaptive trigger bridge

This commit is contained in:
2026-05-01 14:26:32 -04:00
parent 4d0ba317e1
commit 1fc2f995c7
5 changed files with 1563 additions and 31 deletions

View File

@@ -20,19 +20,6 @@
# - 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; };
@@ -90,16 +77,45 @@ in
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 = {
# 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";
wantedBy = [ "default.target" ];
after = [ "graphical-session.target" ];
# 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}";
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;
};
};
};