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:
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.