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.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user