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
|
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.
|
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
|
## Threading note
|
||||||
|
|
||||||
pydualsense's `sendReport` background thread reads `triggerR/L.mode` and
|
pydualsense's `sendReport` background thread reads `triggerR/L.mode` and
|
||||||
@@ -123,11 +132,9 @@ LOG = logging.getLogger("forza-trigger")
|
|||||||
|
|
||||||
# --- Mode bytes ---------------------------------------------------------------
|
# --- Mode bytes ---------------------------------------------------------------
|
||||||
# pydualsense's IntFlag aliases happen to cover the modes we need:
|
# 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)
|
||||||
# TriggerModes(0x05) = 0x05 (canonical Sony Off / Reset; mid-race zero-force)
|
|
||||||
# TriggerModes.Pulse_B = 0x06 (Simple_Vibration / Simple_AutomaticGun)
|
# TriggerModes.Pulse_B = 0x06 (Simple_Vibration / Simple_AutomaticGun)
|
||||||
# TriggerModes.Rigid_A = 0x21 (Feedback, canonical)
|
# TriggerModes.Rigid_A = 0x21 (Feedback, canonical)
|
||||||
DS_MODE_NORMAL = TriggerModes.Off
|
|
||||||
DS_MODE_OFF = TriggerModes(0x05)
|
DS_MODE_OFF = TriggerModes(0x05)
|
||||||
DS_MODE_SIMPLE_VIBRATION = TriggerModes.Pulse_B
|
DS_MODE_SIMPLE_VIBRATION = TriggerModes.Pulse_B
|
||||||
DS_MODE_FEEDBACK = TriggerModes.Rigid_A
|
DS_MODE_FEEDBACK = TriggerModes.Rigid_A
|
||||||
@@ -229,30 +236,21 @@ def _is_in_motion(pkt: ForzaDataPacket) -> bool:
|
|||||||
# --- Effect encoders ----------------------------------------------------------
|
# --- 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:
|
def _apply_off(trig) -> None:
|
||||||
"""Canonical Sony Off / Reset \u2014 mode byte 0x05, all params 0.
|
"""Canonical Sony Off / Reset \u2014 mode byte 0x05, all params 0.
|
||||||
|
|
||||||
Mirrors `TriggerEffectGenerator.Reset()` in DSX. Per Sony's docs, mode 5
|
Mirrors `TriggerEffectGenerator.Reset()` in DSX. Per Sony's docs (Nielk1
|
||||||
actively returns the trigger stop to the neutral position; mode 0 just
|
Rev 6), mode 0x05 *actively* returns the trigger stop to the neutral
|
||||||
clears state. DSX uses Reset() as the fall-through for `Resistance(0,0)`,
|
position; mode 0x00 (TriggerModes.Off, what RacingDSX writes for its
|
||||||
so we route mid-race zero-strength fallbacks here for byte-perfect parity.
|
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):
|
for i in range(7):
|
||||||
trig.forces[i] = 0
|
trig.forces[i] = 0
|
||||||
trig.mode = DS_MODE_OFF
|
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:
|
def reset_triggers(ds: pydualsense) -> None:
|
||||||
_apply_normal(ds.triggerL)
|
"""Both triggers to canonical Off (mode 0x05). Actively retracts the motor."""
|
||||||
_apply_normal(ds.triggerR)
|
_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 ------------------------------------------------
|
# --- RacingDSX math primitives ------------------------------------------------
|
||||||
@@ -701,6 +711,7 @@ def run(host: str, port: int, debug: bool) -> int:
|
|||||||
forza_state = _ForzaState()
|
forza_state = _ForzaState()
|
||||||
last_seen = 0.0
|
last_seen = 0.0
|
||||||
in_race = False
|
in_race = False
|
||||||
|
have_telemetry = False # True between the first packet and the IDLE_TIMEOUT_S reset
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
@@ -716,10 +727,17 @@ def run(host: str, port: int, debug: bool) -> int:
|
|||||||
try:
|
try:
|
||||||
data, _ = sock.recvfrom(2048)
|
data, _ = sock.recvfrom(2048)
|
||||||
last_seen = now
|
last_seen = now
|
||||||
|
have_telemetry = True
|
||||||
except socket.timeout:
|
except socket.timeout:
|
||||||
if in_race and (now - last_seen) > IDLE_TIMEOUT_S:
|
# Reset on telemetry-idle regardless of in_race state. After Forza
|
||||||
LOG.info("forza idle for %.1fs \u2014 resetting triggers", IDLE_TIMEOUT_S)
|
# 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_triggers(ds)
|
||||||
|
reset_lightbar(ds)
|
||||||
|
have_telemetry = False
|
||||||
in_race = False
|
in_race = False
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -733,11 +751,11 @@ def run(host: str, port: int, debug: bool) -> int:
|
|||||||
in_race = forza_is_race_on(pkt, forza_state)
|
in_race = forza_is_race_on(pkt, forza_state)
|
||||||
|
|
||||||
if not in_race:
|
if not in_race:
|
||||||
# GetPreRaceInstructions: lightbar -> car class color, both
|
# Pre-race: lightbar -> car class color, both triggers actively
|
||||||
# triggers -> Normal (mode 0x00). Re-asserted every frame to
|
# released to neutral (mode 0x05, Sony's canonical Off). RacingDSX
|
||||||
# mirror RacingDSX's per-packet emission.
|
# writes mode 0x00 (Normal) here; see divergence #4 in module docstring.
|
||||||
_apply_normal(ds.triggerL)
|
_apply_off(ds.triggerL)
|
||||||
_apply_normal(ds.triggerR)
|
_apply_off(ds.triggerR)
|
||||||
apply_lightbar_pre_race(ds, pkt, forza_state)
|
apply_lightbar_pre_race(ds, pkt, forza_state)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user