forza-trigger: hysteresis on is_race_on (real fix for between-race clicks)
Strace post-deploy showed the daemon flipping every other packet between in-race state (mode 0x21 FEEDBACK + LED green) and out-of-race state (mode 0x05 OFF + LED black). 8 writes, 8 transitions in 5s — every HID OUT report a state change. Root cause: FH5 emits packets where the is_race_on field alternates True/False at packet rate during menu/loading/transition states. Cosmii's RPM_ACCUMULATOR debounce only handles the 'flag stuck True when we're really in a menu' case (quirk #1); it does nothing for 'flag flipping at packet rate' (quirk #2). Fix: split the read from the hysteresis. is_race_on now returns the raw flag with quirk #1 applied. commit_in_race applies a packet-count hysteresis (IN_RACE_HYSTERESIS_PACKETS = 30 ≈ 0.5s at 60Hz) — only after N consecutive packets of the new value does the committed in-race state flip. Alternation just keeps the pending counter oscillating near 0; it never reaches threshold and the run-loop sees a stable state. Architecture: hysteresis in a separate function (not in is_race_on) because the run-loop must update state.last_in_race AFTER side effects, not before — otherwise the elif transition-detection breaks. is_race_on stays pure read; commit_in_race is the gatekeeper; run-loop sets state.last_in_race once at end of packet handling. Tests grew 54→58. New TestCommitInRace covers: - stable input (no pending growth) - worst-case alternation (never commits) - N consecutive packets commit - matching raw resets pending counter TestIsRaceOn renamed to reflect new (narrower) responsibility.
This commit is contained in:
@@ -150,6 +150,13 @@ COLOR_CLASS_X = (105, 182, 72) # green for above-S2
|
||||
# override is_race_on to false. ~3.3s at 60Hz packet rate. (legacy_Program.cs:35)
|
||||
RPM_ACCUMULATOR_TRIGGER_RACE_OFF = 200
|
||||
|
||||
# Hysteresis on is_race_on flag flips. FH5 emits packets where the bit
|
||||
# alternates True/False at packet rate during menu/loading transitions.
|
||||
# This many consecutive packets of the OPPOSITE current state are required
|
||||
# before is_race_on() commits a flip. ~0.5s at 60Hz — long enough to
|
||||
# filter alternation, short enough to keep race-start/end responsive.
|
||||
IN_RACE_HYSTERESIS_PACKETS = 30
|
||||
|
||||
# --- User-facing intensity scales (env vars) --------------------------------
|
||||
# Read once at module import. Process restart picks up new values.
|
||||
|
||||
@@ -330,6 +337,15 @@ class DaemonState:
|
||||
# path (otherwise stale slip flags suppress the seeding fix in
|
||||
# handle_throttle/handle_brake on the first packet of race 2).
|
||||
last_in_race: bool = False
|
||||
# is_race_on hysteresis. Forza Horizon emits packets where the
|
||||
# is_race_on field alternates True/False at packet rate during
|
||||
# menu/loading transitions. Without hysteresis, the run-loop
|
||||
# transitions in-race → menu → in-race → menu every other packet,
|
||||
# which the controller perceives as periodic clicks (every state
|
||||
# change forces a fresh HID OUT report; the firmware reacts on
|
||||
# receipt). The hysteresis counter requires N consecutive packets
|
||||
# of the OPPOSITE flag value before flipping committed_in_race.
|
||||
in_race_pending_count: int = 0
|
||||
# IsRaceOn debounce.
|
||||
last_rpm: float = 0.0
|
||||
rpm_accumulator: int = 0
|
||||
@@ -356,6 +372,7 @@ class DaemonState:
|
||||
self.was_brake_slipping = False
|
||||
self.last_rpm = 0.0
|
||||
self.rpm_accumulator = 0
|
||||
self.in_race_pending_count = 0
|
||||
self.last_color = (0, 0, 0)
|
||||
|
||||
|
||||
@@ -363,25 +380,51 @@ class DaemonState:
|
||||
|
||||
|
||||
def is_race_on(pkt: ForzaDataPacket, state: DaemonState) -> bool:
|
||||
"""Cosmii's IsRaceOn debounce (legacy_Program.cs:89-109).
|
||||
|
||||
Returns True iff the car is actively being driven. Combines the explicit
|
||||
is_race_on flag with the RPM-stability + zero-power workaround for FH5's
|
||||
unreliable flag.
|
||||
"""Read the raw is_race_on flag with FH5's RPM-stuck-true workaround
|
||||
(Cosmii's RPM_ACCUMULATOR debounce — legacy_Program.cs:89-109) applied.
|
||||
Hysteresis on flag flips is the caller's job (see commit_in_race).
|
||||
"""
|
||||
flag = bool(_safe_field(pkt, "is_race_on", 0.0))
|
||||
raw_flag = bool(_safe_field(pkt, "is_race_on", 0.0))
|
||||
current_rpm = _safe_field(pkt, "current_engine_rpm", 0.0)
|
||||
power = _safe_field(pkt, "power", 0.0)
|
||||
|
||||
if abs(current_rpm - state.last_rpm) < 1e-3 and power <= 0:
|
||||
state.rpm_accumulator += 1
|
||||
if state.rpm_accumulator > RPM_ACCUMULATOR_TRIGGER_RACE_OFF:
|
||||
flag = False
|
||||
raw_flag = False
|
||||
else:
|
||||
state.rpm_accumulator = 0
|
||||
|
||||
state.last_rpm = current_rpm
|
||||
return flag
|
||||
return raw_flag
|
||||
|
||||
|
||||
def commit_in_race(raw_flag: bool, state: DaemonState) -> bool:
|
||||
"""Apply per-packet hysteresis to the raw flag. Returns the committed
|
||||
in-race state — the value the run-loop should react to.
|
||||
|
||||
FH5 emits packets where is_race_on alternates True/False at packet rate
|
||||
during menu transitions and loading screens. Without hysteresis, the
|
||||
run-loop transitions every other packet and the controller emits
|
||||
audible clicks (every state change forces a fresh HID OUT report; the
|
||||
firmware reacts on receipt). The hysteresis counter requires
|
||||
IN_RACE_HYSTERESIS_PACKETS consecutive packets of the new value before
|
||||
we commit a flip — long enough to filter alternation, short enough
|
||||
(~0.5s at 60Hz) that legitimate race-start/end transitions stay
|
||||
responsive.
|
||||
|
||||
Note: this function does NOT mutate state.last_in_race. The caller
|
||||
must update state.last_in_race AFTER running any transition side
|
||||
effects, otherwise the elif state.last_in_race transition-detection
|
||||
in the run-loop won't fire.
|
||||
"""
|
||||
if raw_flag == state.last_in_race:
|
||||
state.in_race_pending_count = 0
|
||||
return state.last_in_race
|
||||
state.in_race_pending_count += 1
|
||||
if state.in_race_pending_count < IN_RACE_HYSTERESIS_PACKETS:
|
||||
return state.last_in_race
|
||||
state.in_race_pending_count = 0
|
||||
return raw_flag
|
||||
|
||||
|
||||
# --- Trigger handlers -------------------------------------------------------
|
||||
@@ -773,7 +816,7 @@ def run(host: str, port: int, exit_on_idle: bool = False) -> int:
|
||||
if pkt is None:
|
||||
continue
|
||||
|
||||
in_race = is_race_on(pkt, state)
|
||||
in_race = commit_in_race(is_race_on(pkt, state), state)
|
||||
if in_race:
|
||||
handle_throttle(controller, pkt, state)
|
||||
handle_brake(controller, pkt, state)
|
||||
|
||||
Reference in New Issue
Block a user