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.