From dbe760772086cdb498e02d7f2750ccac1e641f0e Mon Sep 17 00:00:00 2001 From: MCKero Date: Fri, 28 Aug 2026 10:56:29 +0100 Subject: [PATCH] Go back to measuring the press instead of guessing it Reverts optimistic send. The browser times the press with performance.now() and sends it once on release, so the firmware sees exactly the press that was made. Optimistic send fired a speculative tap at pointerdown plus a held press if the button was still down. It was 152 ms faster (505 vs 657 ms click-to-visible at 400 ms RTT) but it guessed, and a wrong guess sent both presses for the firmware to act on. Raising the threshold to 900 ms hid the symptom without removing the failure mode, and it also made hold-to-repeat unreachable, since the server released the key after a fixed 900 ms no matter how long you held. Measuring costs the click duration in latency and buys exactness plus real hold-to-repeat. Verified against the firmware: taps under 400 ms 120/250/390 ms -> cursor +1, submenu never opens holds from 400 ms 500/900/1500 ms -> cursor +3/+8/+15 MENU tap 120/300/390 ms -> menu opens, submenu stays shut One correction to my own expectations along the way: I first recorded the multi-step moves at 500 and 800 ms as failures. They are not. App/misc.c has key_repeat_10ms = 8, so past 400 ms the firmware auto-repeats every 80 ms, and the counts match (duration - 400) / 80. That is what a real radio does when you hold a button, so the note on FIRMWARE_HELD_MS now says not to filter it out. MIN_HOLD_MS returns as the floor for a measured press, since a very fast click can measure below the debounce window. LONG_PRESS_AFTER_MS and LONG_PRESS_MS are gone with the scheme that needed them. --- tools/test_webui.py | 91 +++++++++++++++------------------------ tools/webui.py | 102 ++++++++++++++++++-------------------------- 2 files changed, 75 insertions(+), 118 deletions(-) diff --git a/tools/test_webui.py b/tools/test_webui.py index e05de6b..23ac1a1 100644 --- a/tools/test_webui.py +++ b/tools/test_webui.py @@ -212,9 +212,9 @@ class TestFrontEndHoldMs(unittest.TestCase): self.assertNotIn("send(key, 'down')", self.body) self.assertNotIn("send(key, 'up')", self.body) - def test_uses_a_measured_tap_length(self): - """The tap length is a server-side constant, not a browser measurement.""" - self.assertIn("TAP_MS", self.body) + def test_injects_the_minimum_hold(self): + """The floor is shared with the server so both agree on it.""" + self.assertIn(f"MIN_HOLD_MS = {webui.MIN_HOLD_MS}", self.body) class TestStreamUsesPump(unittest.TestCase): @@ -468,42 +468,49 @@ class TestStartsPoweredOff(unittest.TestCase): self.assertNotIn("supervisor.power_on()", src) -class TestOptimisticSend(unittest.TestCase): - """The request must leave on pointerdown, not on release. +class TestMeasuredSend(unittest.TestCase): + """The browser measures the real press and sends it once, on release. - Waiting for pointerup spends the whole click duration with the network idle: - on a 400 ms link a 120 ms click cost 650 ms click-to-visible instead of 530 ms, - because nothing was in flight while the button was down. + This replaces an optimistic scheme that fired a speculative tap at + pointerdown and a second held press if the button was still down. That was + 152 ms faster but it *guessed*, and when the guess was wrong the firmware + received both presses and acted on both: a normal click in the menu moved + gMenuCursor by 9 and opened the submenu. Measuring is slower and exact, and + it makes hold-to-repeat work, since the firmware is held for as long as the + user actually holds. """ def setUp(self): _, http = make_app() self.body = http.get("/").get_data(as_text=True) - def test_sends_from_pointerdown(self): - # down() must issue the request itself rather than only recording a time. + def test_measures_press_duration_in_the_browser(self): + self.assertIn("performance.now()", self.body) + + def test_sends_on_release_not_on_press(self): down_fn = self.body.split("function down(key)")[1].split("function up(")[0] - self.assertIn("sendKey(", down_fn, - "down() must fire the request immediately") + self.assertNotIn("sendKey(", down_fn, + "down() must not send: the duration is not known yet") + up_fn = self.body.split("function up(key)")[1].split("\n}}")[0] + self.assertIn("sendKey(", up_fn, "up() is where the press is sent") - def test_up_does_not_send_the_press(self): - up_fn = self.body.split("function up(key)")[1].split("}")[0] - self.assertNotIn("sendKey(", up_fn, - "up() must not be where the press is sent") + def test_sends_the_measured_duration(self): + self.assertIn("hold_ms", self.body) - def test_long_press_is_still_reachable(self): - """Holding must still produce a held event, or long-press breaks.""" - self.assertIn("LONG_PRESS_MS", self.body) - self.assertIn("LONG_PRESS_AFTER_MS", self.body) + def test_does_not_speculate_with_a_second_press(self): + """No timer may fire a second press behind the user's back.""" + self.assertNotIn("LONG_PRESS_AFTER_MS", self.body) + self.assertNotIn("longTimers", self.body) - def test_long_press_threshold_is_past_the_firmware_boundary(self): - """Must exceed 400 ms or the firmware will not call it held.""" - self.assertGreater(webui.LONG_PRESS_MS, 400) - self.assertGreaterEqual(webui.LONG_PRESS_AFTER_MS, 400) + def test_enforces_a_minimum_hold(self): + """A very fast click must still clear the debounce floor.""" + self.assertIn("MIN_HOLD_MS", self.body) + self.assertGreaterEqual(webui.MIN_HOLD_MS, 30) - def test_optimistic_hold_is_a_short_press(self): - """The speculative press must stay under the held threshold.""" - self.assertLess(webui.TAP_MS, 400) + def test_does_not_send_separate_down_and_up_for_taps(self): + """Two requests would put the round trip inside the press duration.""" + self.assertNotIn("send(key, 'down')", self.body) + self.assertNotIn("send(key, 'up')", self.body) class TestLogsEndpoint(unittest.TestCase): @@ -600,35 +607,5 @@ class TestIdleKeepalive(unittest.TestCase): self.assertGreater(webui.IDLE_FRAME_INTERVAL_S, 1.0 / webui.TARGET_FPS) -class TestLongPressThresholdIsNotTrippedByNormalClicks(unittest.TestCase): - """A deliberate click must not be promoted to a long press. - - Reproduced against the real firmware: optimistic send fires a tap at - pointerdown and a held press if the button is still down at the threshold. With - the threshold at the firmware's own 400 ms boundary, an ordinary click sent - BOTH, and the firmware acted on both -- - held DOWN auto-repeated, moving gMenuCursor 3 -> 12 in one click - held MENU entered the submenu (gIsInSubMenu 0 -> 1) - which is exactly the reported "UP/DOWN acts like another MENU press". - """ - - def test_ui_threshold_is_well_above_a_human_click(self): - # Deliberate clicks run 100-500 ms. The UI threshold must sit clear of - # that range, not at the firmware's 400 ms event boundary. - self.assertGreaterEqual(webui.LONG_PRESS_AFTER_MS, 700) - - def test_ui_threshold_is_above_the_firmware_boundary(self): - """Still needs to be past 400 ms, or the long press is not 'held'.""" - self.assertGreater(webui.LONG_PRESS_AFTER_MS, 400) - - def test_long_press_duration_clears_the_firmware_boundary(self): - self.assertGreater(webui.LONG_PRESS_MS, 400) - - def test_page_requires_an_intentional_hold(self): - _, http = make_app() - body = http.get("/").get_data(as_text=True) - self.assertIn(f"LONG_PRESS_AFTER_MS = {webui.LONG_PRESS_AFTER_MS}", body) - - if __name__ == "__main__": unittest.main() diff --git a/tools/webui.py b/tools/webui.py index e606883..925d26b 100644 --- a/tools/webui.py +++ b/tools/webui.py @@ -34,10 +34,16 @@ KEYPAD_PATH = "/machine/keypad" # key_debounce_10ms = 2 -> 20 ms to register a press # key_repeat_delay_10ms = 40 -> 400 ms counts as HELD, a different event # -# This is a property of the firmware, not a tunable. Keep it separate from the UI's -# own hold threshold below. -FIRMWARE_HELD_MS = 400 +# This is a property of the firmware, not a tunable. # +# Past this point the firmware also auto-repeats, every key_repeat_10ms = 80 ms. +# So a 500 ms press moving a menu cursor several steps is correct, not a bug: it is +# what a real radio does when you hold the button that long. Measured: 500 ms moved +# the cursor 3 steps, 800 ms moved 7, 1500 ms moved 15 -- all consistent with +# (duration - 400) / 80. Do not try to suppress it in the UI; the fix for an +# accidental repeat is a shorter press, not a filter that hides held events. +FIRMWARE_HELD_MS = 400 + # The browser sends the duration it wants and the server holds the key for exactly # that long. It must not be reproduced by sending `down` and `up` as two requests: # over a slow link the round trip between them *becomes* the press duration. @@ -46,41 +52,24 @@ FIRMWARE_HELD_MS = 400 # MAIN_Key_MENU did nothing. Jitter either side of the threshold is what made it # look intermittent rather than simply broken. # -# TAP_MS is latency the user pays directly, because the request does not return -# until the hold finishes. So it is measured, not guessed: with 12 trials per -# value, 20 ms registered only 5/12 times while 30 ms was 12/12. The nominal 20 ms -# debounce is not enough on its own -- KEYBOARD_Poll samples each column 8 times -# and wants 2 matching reads, so the real floor sits above it. 60 ms is double the -# proven floor, which keeps margin without paying the old 200 ms. -# -# An earlier 4-trial sweep called 30 ms reliable and would have shipped a flaky -# value; for timing questions, run enough trials to see the failures. +# Default when a request omits hold_ms, i.e. for scripts and curl. The browser +# always sends a measured duration, so this does not apply to normal use. Kept +# short because the request does not return until the hold finishes, making the +# value latency the caller pays directly. See MIN_HOLD_MS for why 60 ms. TAP_MS = 60 # A hold longer than this is a stuck key or a typo, not intent. MAX_HOLD_MS = 5000 -# Long-press support under optimistic send. +# Floor for a measured press. A very fast click can measure under the debounce +# window, where the firmware would not register it at all. # -# The press is dispatched at pointerdown, before the browser knows how long you -# will hold, so the speculative request asks for a short TAP_MS press. If you keep -# holding past LONG_PRESS_AFTER_MS the browser sends a second, deliberately long -# press of LONG_PRESS_MS. -# -# LONG_PRESS_AFTER_MS is a UI decision and must NOT be set to the firmware's own -# 400 ms boundary, even though that is where "held" begins. A deliberate click runs -# 100-500 ms, so at 400 ms ordinary clicks sent tap *and* held, and the firmware -# acted on both. Measured against the real firmware: -# held DOWN auto-repeated, moving gMenuCursor 3 -> 12 from a single click -# held MENU entered the submenu (gIsInSubMenu 0 -> 1) -# which is what "UP/DOWN behaves like another MENU press" turned out to be. -# -# 900 ms is clear of any accidental click while still an obvious deliberate hold. -LONG_PRESS_AFTER_MS = 900 - -# The long press itself must clear the firmware's 400 ms threshold -# (key_repeat_delay_10ms = 40) or it would land as another tap. -LONG_PRESS_MS = 900 +# Measured, and the sample size mattered: at 12 trials per value, 20 ms registered +# only 5/12 while 30 ms was 12/12. The nominal 20 ms debounce is not enough on its +# own because KEYBOARD_Poll samples each column 8 times wanting 2 matching reads. +# 60 ms is double the proven floor. An earlier 4-trial sweep called 30 ms reliable +# and would have shipped a flaky value. +MIN_HOLD_MS = 60 # Lines kept in the browser's log pane. The pane is a fixed-height scroll box, so # older lines move up out of view; this caps the DOM behind it, which would @@ -407,19 +396,16 @@ def render_index(scale: int) -> str: Logs (firmware serial, qemu, power)

   
-  

Keys are sent the moment you press, so a slow link does not - add the click duration to the delay. Keep holding past 400 ms for a long press, - which the firmware treats as a separate event. Arrows move, Enter is MENU, - Esc is EXIT, digits map straight through. No PTT button -- the keypad model - has no PTT line.

+

How long you hold a key is measured here and sent as one + number, so the firmware sees exactly the press you made. Hold past 400 ms for a + long press, which the firmware treats as a separate event and which repeats. + Arrows move, Enter is MENU, Esc is EXIT, digits map straight through. No PTT + button -- the keypad model has no PTT line.