197 lines
7.1 KiB
Nix
197 lines
7.1 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 \u2192 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`.
|
|
# - The included `dualsensectl` will be overwritten by our BG thread within
|
|
# ~4 ms; use `systemctl --user stop forza-trigger` first when debugging.
|
|
# - 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;
|
|
python = pkgs.python3;
|
|
|
|
# CFFI bindings to libhidapi. Upstream is flok/hidapi-cffi published on
|
|
# PyPI under the name `hidapi-usb`. The shipped hidapi.py picks a libhidapi
|
|
# soname via ffi.dlopen() — we substitute absolute store paths so the
|
|
# interpreter inside our wrapped python env can find it without
|
|
# LD_LIBRARY_PATH gymnastics.
|
|
hidapi-usb = python.pkgs.buildPythonPackage rec {
|
|
pname = "hidapi-usb";
|
|
version = "0.3.2";
|
|
format = "setuptools";
|
|
|
|
# PyPI's project URL slug uses a hyphen (`hidapi-usb`) but the sdist file
|
|
# itself is PEP-625-normalized to an underscore (`hidapi_usb-…`). Stock
|
|
# fetchPypi assumes they match — they don't here, so fetch by direct URL.
|
|
src = pkgs.fetchurl {
|
|
url = "https://files.pythonhosted.org/packages/55/80/960ae94b615e26a7d1aeebe8e9fefda2f25608bf1016f9aec268b328c35e/hidapi_usb-${version}.tar.gz";
|
|
hash = "sha256-oxp+2i+qqYd1uwiS2Dh8/PzO62iYQQXpR936MnDIFk0=";
|
|
};
|
|
|
|
propagatedBuildInputs = [ python.pkgs.cffi ];
|
|
|
|
postPatch = ''
|
|
substituteInPlace hidapi.py \
|
|
--replace-fail "'libhidapi-hidraw.so'," "'${pkgs.hidapi}/lib/libhidapi-hidraw.so'," \
|
|
--replace-fail "'libhidapi-hidraw.so.0'," "'${pkgs.hidapi}/lib/libhidapi-hidraw.so.0',"
|
|
'';
|
|
|
|
pythonImportsCheck = [ "hidapi" ];
|
|
|
|
meta = {
|
|
description = "CFFI wrapper for hidapi (used by pydualsense)";
|
|
homepage = "https://github.com/flok/hidapi-cffi";
|
|
license = lib.licenses.bsd3;
|
|
};
|
|
};
|
|
|
|
pydualsense = python.pkgs.buildPythonPackage rec {
|
|
pname = "pydualsense";
|
|
version = "0.7.5";
|
|
format = "pyproject";
|
|
|
|
src = python.pkgs.fetchPypi {
|
|
pname = "pydualsense";
|
|
inherit version;
|
|
hash = "sha256-YgX8AJE4f8p7geKT3xlCD0Mlh1GcyHpBz4rEIqdwKgs=";
|
|
};
|
|
|
|
nativeBuildInputs = [ python.pkgs.poetry-core ];
|
|
propagatedBuildInputs = [ hidapi-usb ];
|
|
|
|
pythonImportsCheck = [ "pydualsense" ];
|
|
|
|
meta = {
|
|
description = "Control your PS5 DualSense controller from Python";
|
|
homepage = "https://github.com/flok/pydualsense";
|
|
license = lib.licenses.mit;
|
|
};
|
|
};
|
|
|
|
# Single-file Forza UDP packet parser. Pinned to a known-good commit; the
|
|
# repo is dormant (last commit 2021) but the FH4 packet layout is frozen
|
|
# and FH5 reuses it byte-for-byte.
|
|
fdp = python.pkgs.buildPythonPackage {
|
|
pname = "fdp";
|
|
version = "0-unstable-2021-05-28";
|
|
format = "other";
|
|
|
|
src = pkgs.fetchurl {
|
|
url = "https://raw.githubusercontent.com/nettrom/forza_motorsport/61845cb7ff4082211292a51ce3c49edbfd2d6503/fdp.py";
|
|
hash = "sha256-osFaVF9VaEzU4dp3x6KN6OF7SXsd9ZBwvilU+xTT7mM=";
|
|
};
|
|
|
|
dontUnpack = true;
|
|
|
|
installPhase = ''
|
|
runHook preInstall
|
|
install -Dm644 $src $out/${python.sitePackages}/fdp.py
|
|
runHook postInstall
|
|
'';
|
|
|
|
pythonImportsCheck = [ "fdp" ];
|
|
|
|
meta = {
|
|
description = "ForzaDataPacket — Forza Motorsport / Horizon UDP packet parser";
|
|
homepage = "https://github.com/nettrom/forza_motorsport";
|
|
license = lib.licenses.mit;
|
|
};
|
|
};
|
|
|
|
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
|
|
# CLI companion for sanity-checking the controller (battery, lightbar,
|
|
# raw trigger modes, monitor add/remove events).
|
|
pkgs.dualsensectl
|
|
];
|
|
|
|
# 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 = {
|
|
description = "Forza Horizon → DualSense adaptive trigger bridge";
|
|
wantedBy = [ "default.target" ];
|
|
after = [ "graphical-session.target" ];
|
|
serviceConfig = {
|
|
ExecStart = "${forzaTrigger}/bin/forza-trigger --host 127.0.0.1 --port ${toString cfg.port}";
|
|
Restart = "on-failure";
|
|
RestartSec = 3;
|
|
};
|
|
};
|
|
};
|
|
}
|