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:
@@ -169,6 +169,11 @@ class TestCombinedSlip(unittest.TestCase):
|
||||
|
||||
|
||||
class TestIsRaceOn(unittest.TestCase):
|
||||
"""is_race_on returns the RAW flag (with FH5 RPM-stuck workaround)
|
||||
only — hysteresis lives in commit_in_race so the run-loop can sequence
|
||||
transition handling correctly. Tests here exercise just the raw read.
|
||||
"""
|
||||
|
||||
def test_simple_yes(self):
|
||||
s = ft.DaemonState()
|
||||
pkt = FakePacket(is_race_on=1.0, current_engine_rpm=4000, power=200)
|
||||
@@ -180,16 +185,15 @@ class TestIsRaceOn(unittest.TestCase):
|
||||
pkt = FakePacket(is_race_on=0.0)
|
||||
self.assertFalse(ft.is_race_on(pkt, s))
|
||||
|
||||
def test_debounce_overrides_stuck_flag(self):
|
||||
# Simulate FH5's stuck-flag bug: is_race_on=1 but RPM and power show
|
||||
# the car is in a menu (power<=0, RPM unchanged).
|
||||
def test_rpm_stuck_overrides_flag_after_threshold(self):
|
||||
# FH5's stuck-flag bug: is_race_on=1 but RPM and power show the car
|
||||
# is actually in a menu (power<=0, RPM unchanged). is_race_on must
|
||||
# return False once the accumulator trips, regardless of the flag.
|
||||
s = ft.DaemonState()
|
||||
pkt = FakePacket(is_race_on=1.0, current_engine_rpm=800, power=0)
|
||||
s.last_rpm = 800
|
||||
# Pump packets until the accumulator trips.
|
||||
for _ in range(ft.RPM_ACCUMULATOR_TRIGGER_RACE_OFF):
|
||||
self.assertTrue(ft.is_race_on(pkt, s))
|
||||
# One more samples and we cross the threshold.
|
||||
self.assertFalse(ft.is_race_on(pkt, s))
|
||||
|
||||
def test_rpm_change_resets_accumulator(self):
|
||||
@@ -201,6 +205,53 @@ class TestIsRaceOn(unittest.TestCase):
|
||||
self.assertEqual(s.rpm_accumulator, 0)
|
||||
|
||||
|
||||
class TestCommitInRace(unittest.TestCase):
|
||||
"""Hysteresis on the raw is_race_on flag. FH5 emits packets where
|
||||
the bit alternates True/False at packet rate during menu transitions
|
||||
and loading screens. commit_in_race must filter those alternations.
|
||||
"""
|
||||
|
||||
def test_stable_no_pending_count_never_grows(self):
|
||||
s = ft.DaemonState()
|
||||
s.last_in_race = False
|
||||
for _ in range(100):
|
||||
self.assertFalse(ft.commit_in_race(False, s))
|
||||
self.assertEqual(s.in_race_pending_count, 0)
|
||||
|
||||
def test_alternation_does_not_commit(self):
|
||||
# Worst case from real FH5: flag flips every other packet. We
|
||||
# must keep returning the committed (initial) value forever.
|
||||
s = ft.DaemonState()
|
||||
s.last_in_race = False
|
||||
for i in range(200):
|
||||
raw = bool(i % 2)
|
||||
self.assertFalse(ft.commit_in_race(raw, s))
|
||||
# Pending count never accumulates past 1 because each True is
|
||||
# immediately followed by a False that resets it.
|
||||
self.assertLessEqual(s.in_race_pending_count, 1)
|
||||
|
||||
def test_n_consecutive_packets_commit_flip(self):
|
||||
s = ft.DaemonState()
|
||||
s.last_in_race = False
|
||||
# First N-1 same-value packets do NOT commit yet.
|
||||
for _ in range(ft.IN_RACE_HYSTERESIS_PACKETS - 1):
|
||||
self.assertFalse(ft.commit_in_race(True, s))
|
||||
# The Nth commits.
|
||||
self.assertTrue(ft.commit_in_race(True, s))
|
||||
# Pending count was reset to 0 on commit.
|
||||
self.assertEqual(s.in_race_pending_count, 0)
|
||||
|
||||
def test_committed_value_returned_when_raw_matches(self):
|
||||
s = ft.DaemonState()
|
||||
s.last_in_race = True
|
||||
# Even though state.last_in_race is True, raw flag matches so
|
||||
# we just return last_in_race without touching pending count.
|
||||
s.in_race_pending_count = 5
|
||||
result = ft.commit_in_race(True, s)
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(s.in_race_pending_count, 0)
|
||||
|
||||
|
||||
# --- Trigger handler tests --------------------------------------------------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user