forza-trigger: stop emitting mode 0x05 every frame in pre-race idle
The previous fix used canonical Off (mode 0x05) everywhere we wanted the trigger to feel released \u2014 pre-race per-frame, idle timeout, shutdown. Per Sony's docs (Nielk1 Rev 6) mode 0x05 "actively returns the trigger stop to the neutral position". Re-asserting it 60 times/sec from main thread, propagated by pydualsense's BG thread to the controller at ~250 Hz, made the trigger motor audibly whine as the firmware repeatedly snapped the (already-neutral) trigger back to neutral. Right answer: hybrid. One-shot 0x05 on the in-race \u2192 not-in-race transition (and on the telemetry-idle timeout) so the firmware actually retracts the motor; mode 0x00 (TriggerModes.Off, no-op clear) for steady-state pre-race / idle frames so we're not yelling RESET in the firmware's ear forever. Implementation: prev_in_race tracks the last frame's race state. Steady non-race frames call _apply_normal (mode 0x00); the first frame after a race-end transition calls _apply_off (mode 0x05). pydualsense's BG thread holds the 0x05 in memory long enough (one main-thread frame = ~16ms = ~4 BG iterations) to publish it to the controller before main switches the in-memory state to 0x00. Restores _apply_normal and DS_MODE_NORMAL that the previous commit deleted. Updates divergence #4 in the module docstring.
This commit is contained in:
@@ -61,14 +61,19 @@ 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).
|
4. Trigger release combines mode 0x05 (active) with mode 0x00 (steady-state).
|
||||||
RacingDSX dispatches `TriggerMode.Normal` for pre-race / between-race state,
|
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
|
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*
|
*clears* state and does not retract the trigger motor; mode 0x05 *actively*
|
||||||
returns the trigger stop to neutral. On Linux/pydualsense, RacingDSX's 0x00
|
returns the trigger stop to neutral. RacingDSX-on-Windows gets away with
|
||||||
leaves R2 with residual tension after a race ends because nothing forces a
|
0x00 because something on Windows (Steam Input or the OS) reliably resets
|
||||||
physical reset (Windows DSX or Steam Input must do it on that platform).
|
the motor on focus loss; on Linux nothing does, and R2 keeps residual
|
||||||
We always emit 0x05 for any \"trigger should feel free\" intent.
|
tension after a race ends. But re-asserting 0x05 every frame in steady-state
|
||||||
|
pre-race causes the trigger motor to audibly whine as the firmware repeatedly
|
||||||
|
snaps the (already-neutral) trigger back to neutral. So we use 0x05 as a
|
||||||
|
one-shot on the in-race \u2192 not-in-race transition (and on the telemetry-idle
|
||||||
|
timeout), then mode 0x00 for steady-state pre-race / idle frames \u2014 motor
|
||||||
|
stays released, no continuous retraction noise.
|
||||||
|
|
||||||
## Threading note
|
## Threading note
|
||||||
|
|
||||||
@@ -132,9 +137,11 @@ 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 (no-op; clears command without retracting motor)
|
||||||
# TriggerModes(0x05) = 0x05 (canonical Sony Off / Reset)
|
# TriggerModes(0x05) = 0x05 (canonical Sony Off / Reset)
|
||||||
# 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 # 0x00 "clear command"; motor stays in last-set physical state
|
||||||
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
|
||||||
@@ -335,6 +342,22 @@ def _apply_simple_vibration(trig, position: int, amplitude: int, frequency: int)
|
|||||||
trig.mode = DS_MODE_SIMPLE_VIBRATION
|
trig.mode = DS_MODE_SIMPLE_VIBRATION
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_normal(trig) -> None:
|
||||||
|
"""Mode 0x00 (TriggerModes.Off) + zero forces.
|
||||||
|
|
||||||
|
Per Sony's docs (Nielk1 Rev 6) mode 0x00 is a *clear/no-op* command \u2014 the
|
||||||
|
firmware's last-set physical effect persists. We use this for steady-state
|
||||||
|
pre-race / idle frames after `_apply_off` has already retracted the motor
|
||||||
|
via mode 0x05. Re-asserting 0x05 every frame causes the motor to audibly
|
||||||
|
whine as the firmware repeatedly snaps the (already-neutral) trigger back
|
||||||
|
to neutral.
|
||||||
|
"""
|
||||||
|
for i in range(7):
|
||||||
|
trig.forces[i] = 0
|
||||||
|
trig.mode = DS_MODE_NORMAL
|
||||||
|
|
||||||
|
|
||||||
def reset_triggers(ds: pydualsense) -> None:
|
def reset_triggers(ds: pydualsense) -> None:
|
||||||
"""Both triggers to canonical Off (mode 0x05). Actively retracts the motor."""
|
"""Both triggers to canonical Off (mode 0x05). Actively retracts the motor."""
|
||||||
_apply_off(ds.triggerL)
|
_apply_off(ds.triggerL)
|
||||||
@@ -711,6 +734,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
|
||||||
|
prev_in_race = False # for transition detection \u2014 see _apply_normal docstring
|
||||||
have_telemetry = False # True between the first packet and the IDLE_TIMEOUT_S reset
|
have_telemetry = False # True between the first packet and the IDLE_TIMEOUT_S reset
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -735,10 +759,15 @@ def run(host: str, port: int, debug: bool) -> int:
|
|||||||
# leave the last lightbar/trigger state asserted forever.
|
# leave the last lightbar/trigger state asserted forever.
|
||||||
if have_telemetry and (now - last_seen) > IDLE_TIMEOUT_S:
|
if have_telemetry and (now - last_seen) > IDLE_TIMEOUT_S:
|
||||||
LOG.info("forza idle for %.1fs \u2014 resetting controller", IDLE_TIMEOUT_S)
|
LOG.info("forza idle for %.1fs \u2014 resetting controller", IDLE_TIMEOUT_S)
|
||||||
|
# One-shot 0x05 to actively retract the trigger motor; the BG
|
||||||
|
# thread will publish it ~12 times in the next 50ms before main
|
||||||
|
# thread loops back here. Subsequent idle iterations don't
|
||||||
|
# re-enter this branch (have_telemetry is now False).
|
||||||
reset_triggers(ds)
|
reset_triggers(ds)
|
||||||
reset_lightbar(ds)
|
reset_lightbar(ds)
|
||||||
have_telemetry = False
|
have_telemetry = False
|
||||||
in_race = False
|
in_race = False
|
||||||
|
prev_in_race = False
|
||||||
continue
|
continue
|
||||||
|
|
||||||
pkt = parse_packet(data)
|
pkt = parse_packet(data)
|
||||||
@@ -751,12 +780,19 @@ 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:
|
||||||
# Pre-race: lightbar -> car class color, both triggers actively
|
# Transition into pre-race: one-shot mode 0x05 to actively
|
||||||
# released to neutral (mode 0x05, Sony's canonical Off). RacingDSX
|
# retract the trigger motor. Subsequent steady-state frames
|
||||||
# writes mode 0x00 (Normal) here; see divergence #4 in module docstring.
|
# send mode 0x00 (no command); re-asserting 0x05 every frame
|
||||||
|
# makes the firmware audibly whine retracting an already-
|
||||||
|
# neutral trigger. Divergence #4 in the module docstring.
|
||||||
|
if prev_in_race:
|
||||||
_apply_off(ds.triggerL)
|
_apply_off(ds.triggerL)
|
||||||
_apply_off(ds.triggerR)
|
_apply_off(ds.triggerR)
|
||||||
|
else:
|
||||||
|
_apply_normal(ds.triggerL)
|
||||||
|
_apply_normal(ds.triggerR)
|
||||||
apply_lightbar_pre_race(ds, pkt, forza_state)
|
apply_lightbar_pre_race(ds, pkt, forza_state)
|
||||||
|
prev_in_race = False
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if debug:
|
if debug:
|
||||||
@@ -780,6 +816,7 @@ def run(host: str, port: int, debug: bool) -> int:
|
|||||||
apply_lightbar_in_race(ds, pkt)
|
apply_lightbar_in_race(ds, pkt)
|
||||||
apply_left_trigger(ds, pkt, brake_state)
|
apply_left_trigger(ds, pkt, brake_state)
|
||||||
apply_right_trigger(ds, pkt, throttle_state)
|
apply_right_trigger(ds, pkt, throttle_state)
|
||||||
|
prev_in_race = True
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
LOG.info("shutting down")
|
LOG.info("shutting down")
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
Reference in New Issue
Block a user