123 lines
4.7 KiB
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;
|
|
};
|
|
};
|
|
};
|
|
}
|