diff --git a/hosts/yarn/forza-trigger/forza_trigger.py b/hosts/yarn/forza-trigger/forza_trigger.py index ddea9db..b54252a 100644 --- a/hosts/yarn/forza-trigger/forza_trigger.py +++ b/hosts/yarn/forza-trigger/forza_trigger.py @@ -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) diff --git a/hosts/yarn/forza-trigger/test_forza_trigger.py b/hosts/yarn/forza-trigger/test_forza_trigger.py index b2cbc9d..244c2f0 100644 --- a/hosts/yarn/forza-trigger/test_forza_trigger.py +++ b/hosts/yarn/forza-trigger/test_forza_trigger.py @@ -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 --------------------------------------------------