From 876864c854733c0ee3c59db6923807703010fae9 Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Sat, 2 May 2026 18:10:23 -0400 Subject: [PATCH] forza-trigger: actively release trigger and clear lightbar on idle Two issues in the deployed daemon: 1. After FH5 exits, the lightbar stayed lit. reset_triggers() touched only triggers; pydualsense's BG sendReport thread kept re-publishing whatever TouchpadColor we last set, so the controller stayed in the last race color forever. 2. R2 had residual tension in FH5's main menu and on the desktop after a race. Pre-race / idle states were emitting RacingDSX's NormalTrigger (mode byte 0x00), which per Sony's docs (Nielk1 Rev6) only clears state without retracting the trigger motor; mode 0x05 (canonical Off / Reset) actively returns the trigger to neutral. RacingDSX-on-Windows gets away with 0x00 because something else (Steam Input or the OS) reliably resets the motor on focus loss; on Linux nothing does. Fixes: - Drop _apply_normal/DS_MODE_NORMAL. Use _apply_off (mode 0x05) for every 'release the trigger' intent: pre-race per-frame, idle timeout, mid-race zero-strength fallback, shutdown. - Add reset_lightbar() that writes RGB(0,0,0). - Track have_telemetry and fire the idle-timeout branch whenever telemetry has been silent for IDLE_TIMEOUT_S, regardless of in_race. Reset both triggers and lightbar in that branch. Documented as divergence #4 in the module docstring. --- hosts/yarn/forza-trigger/forza_trigger.py | 80 ++++++++++++++--------- 1 file changed, 49 insertions(+), 31 deletions(-) diff --git a/hosts/yarn/forza-trigger/forza_trigger.py b/hosts/yarn/forza-trigger/forza_trigger.py index b08d78f..e33c48e 100644 --- a/hosts/yarn/forza-trigger/forza_trigger.py +++ b/hosts/yarn/forza-trigger/forza_trigger.py @@ -61,6 +61,15 @@ output report at fixed offsets. That is exactly what we want. on those cars. Mask `cpi & 0xFF` in `apply_lightbar_pre_race` to match RacingDSX byte-for-byte if you want bug-faithful Windows-equivalent dimming. +4. Trigger release uses canonical Off (mode 0x05) instead of Normal (mode 0x00). + RacingDSX dispatches `TriggerMode.Normal` for pre-race / between-race state, + which becomes mode byte 0x00. Per Sony's docs (Nielk1 Rev 6), mode 0x00 only + *clears* state and does not retract the trigger motor; mode 0x05 *actively* + returns the trigger stop to neutral. On Linux/pydualsense, RacingDSX's 0x00 + leaves R2 with residual tension after a race ends because nothing forces a + physical reset (Windows DSX or Steam Input must do it on that platform). + We always emit 0x05 for any \"trigger should feel free\" intent. + ## Threading note pydualsense's `sendReport` background thread reads `triggerR/L.mode` and @@ -123,11 +132,9 @@ LOG = logging.getLogger("forza-trigger") # --- Mode bytes --------------------------------------------------------------- # pydualsense's IntFlag aliases happen to cover the modes we need: -# TriggerModes.Off = 0x00 (between-race idle; pydualsense's name) -# TriggerModes(0x05) = 0x05 (canonical Sony Off / Reset; mid-race zero-force) +# TriggerModes(0x05) = 0x05 (canonical Sony Off / Reset) # TriggerModes.Pulse_B = 0x06 (Simple_Vibration / Simple_AutomaticGun) # TriggerModes.Rigid_A = 0x21 (Feedback, canonical) -DS_MODE_NORMAL = TriggerModes.Off DS_MODE_OFF = TriggerModes(0x05) DS_MODE_SIMPLE_VIBRATION = TriggerModes.Pulse_B DS_MODE_FEEDBACK = TriggerModes.Rigid_A @@ -229,30 +236,21 @@ def _is_in_motion(pkt: ForzaDataPacket) -> bool: # --- Effect encoders ---------------------------------------------------------- -def _apply_normal(trig) -> None: - """`TriggerMode.Normal` in DSX's vocabulary \u2014 mode byte 0, all params 0. - - Mirrors `DualSense_USB_Updated.cs` `bytes2 = NormalTrigger = 0L` then - `array[11..17,20] = bytes2[0..7]`. Used between races / on idle, matching - RacingDSX's `GetPreRaceInstructions()`. - """ - # Write forces before mode so pydualsense's BG sendReport thread, which - # reads mode then forces non-atomically (~250 Hz USB / ~1 kHz BT), is more - # likely to observe a self-consistent (mode, forces) pair. See the - # threading-hazard note in the module docstring. - for i in range(7): - trig.forces[i] = 0 - trig.mode = DS_MODE_NORMAL - def _apply_off(trig) -> None: """Canonical Sony Off / Reset \u2014 mode byte 0x05, all params 0. - Mirrors `TriggerEffectGenerator.Reset()` in DSX. Per Sony's docs, mode 5 - actively returns the trigger stop to the neutral position; mode 0 just - clears state. DSX uses Reset() as the fall-through for `Resistance(0,0)`, - so we route mid-race zero-strength fallbacks here for byte-perfect parity. + Mirrors `TriggerEffectGenerator.Reset()` in DSX. Per Sony's docs (Nielk1 + Rev 6), mode 0x05 *actively* returns the trigger stop to the neutral + position; mode 0x00 (TriggerModes.Off, what RacingDSX writes for its + NormalTrigger pre-race state) only clears state without retracting the + motor, so the trigger stays in whatever Feedback/Vibration position was + last applied. We route every \"release the trigger\" intent here \u2014 + pre-race, idle-timeout, mid-race zero-strength fallback, shutdown. """ + # Write forces before mode so pydualsense's BG sendReport thread, which + # reads mode then forces non-atomically, is more likely to observe a + # self-consistent (mode, forces) pair. See module-docstring threading note. for i in range(7): trig.forces[i] = 0 trig.mode = DS_MODE_OFF @@ -338,8 +336,20 @@ def _apply_simple_vibration(trig, position: int, amplitude: int, frequency: int) def reset_triggers(ds: pydualsense) -> None: - _apply_normal(ds.triggerL) - _apply_normal(ds.triggerR) + """Both triggers to canonical Off (mode 0x05). Actively retracts the motor.""" + _apply_off(ds.triggerL) + _apply_off(ds.triggerR) + + +def reset_lightbar(ds: pydualsense) -> None: + """Lightbar to off (RGB 0,0,0). + + Used when telemetry has been idle long enough that we should stop asserting + a race color \u2014 e.g. Forza exited or hasn't started a session yet. Without + this, pydualsense's BG sendReport thread keeps re-publishing whatever + `TouchpadColor` we last set, so the controller stays lit indefinitely. + """ + ds.light.setColorI(0, 0, 0) # --- RacingDSX math primitives ------------------------------------------------ @@ -701,6 +711,7 @@ def run(host: str, port: int, debug: bool) -> int: forza_state = _ForzaState() last_seen = 0.0 in_race = False + have_telemetry = False # True between the first packet and the IDLE_TIMEOUT_S reset try: while True: @@ -716,10 +727,17 @@ def run(host: str, port: int, debug: bool) -> int: try: data, _ = sock.recvfrom(2048) last_seen = now + have_telemetry = True except socket.timeout: - if in_race and (now - last_seen) > IDLE_TIMEOUT_S: - LOG.info("forza idle for %.1fs \u2014 resetting triggers", IDLE_TIMEOUT_S) + # Reset on telemetry-idle regardless of in_race state. After Forza + # exits with the user in its main menu (is_race_on=0 packets just + # before exit, so in_race was already False), the old check would + # leave the last lightbar/trigger state asserted forever. + if have_telemetry and (now - last_seen) > IDLE_TIMEOUT_S: + LOG.info("forza idle for %.1fs \u2014 resetting controller", IDLE_TIMEOUT_S) reset_triggers(ds) + reset_lightbar(ds) + have_telemetry = False in_race = False continue @@ -733,11 +751,11 @@ def run(host: str, port: int, debug: bool) -> int: in_race = forza_is_race_on(pkt, forza_state) if not in_race: - # GetPreRaceInstructions: lightbar -> car class color, both - # triggers -> Normal (mode 0x00). Re-asserted every frame to - # mirror RacingDSX's per-packet emission. - _apply_normal(ds.triggerL) - _apply_normal(ds.triggerR) + # Pre-race: lightbar -> car class color, both triggers actively + # released to neutral (mode 0x05, Sony's canonical Off). RacingDSX + # writes mode 0x00 (Normal) here; see divergence #4 in module docstring. + _apply_off(ds.triggerL) + _apply_off(ds.triggerR) apply_lightbar_pre_race(ds, pkt, forza_state) continue