diff --git a/tools/test_webui.py b/tools/test_webui.py index 16fb29c..e05de6b 100644 --- a/tools/test_webui.py +++ b/tools/test_webui.py @@ -554,5 +554,81 @@ class TestLogPane(unittest.TestCase): self.assertIn("clientHeight", self.body) +class TestKeyLogging(unittest.TestCase): + """Keys must appear in the log, or the UI cannot be debugged from the browser.""" + + def test_key_press_is_logged(self): + client, sup, http = make_supervised() + log = http.application.config["LOG"] + http.post("/api/key", json={"key": "MENU", "hold_ms": 60}) + texts = [e["text"] for e in log.entries() if e["source"] == "key"] + self.assertTrue(any("MENU" in t for t in texts), texts) + + def test_log_records_the_hold_duration(self): + """Distinguishing a tap from a hold is the whole point of the record.""" + client, sup, http = make_supervised() + log = http.application.config["LOG"] + http.post("/api/key", json={"key": "MENU", "hold_ms": 900}) + texts = [e["text"] for e in log.entries() if e["source"] == "key"] + self.assertTrue(any("900" in t for t in texts), texts) + + def test_log_marks_a_long_press_as_held(self): + client, sup, http = make_supervised() + log = http.application.config["LOG"] + http.post("/api/key", json={"key": "MENU", "hold_ms": 900}) + texts = [e["text"].lower() for e in log.entries() if e["source"] == "key"] + self.assertTrue(any("held" in t for t in texts), texts) + + def test_rejected_key_is_logged_too(self): + """A refused key must be visible, or it looks like nothing happened.""" + client, sup, http = make_supervised() + log = http.application.config["LOG"] + http.post("/api/key", json={"key": "PTT"}) + texts = [e["text"] for e in log.entries() if e["source"] == "key"] + self.assertTrue(any("PTT" in t for t in texts), texts) + + +class TestIdleKeepalive(unittest.TestCase): + """A static screen must still produce frames, just slowly.""" + + def test_keepalive_interval_is_defined(self): + self.assertTrue(hasattr(webui, "IDLE_FRAME_INTERVAL_S")) + self.assertGreater(webui.IDLE_FRAME_INTERVAL_S, 0) + + def test_keepalive_is_slower_than_the_live_rate(self): + """Slower on idle, but never stopped.""" + 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 52be02c..e606883 100644 --- a/tools/webui.py +++ b/tools/webui.py @@ -34,6 +34,10 @@ 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 +# # 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. @@ -63,9 +67,19 @@ MAX_HOLD_MS = 5000 # holding past LONG_PRESS_AFTER_MS the browser sends a second, deliberately long # press of LONG_PRESS_MS. # -# LONG_PRESS_MS must clear the firmware's 400 ms held threshold -# (key_repeat_delay_10ms = 40) or the second press would land as another tap. -LONG_PRESS_AFTER_MS = 400 +# 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 # Lines kept in the browser's log pane. The pane is a fixed-height scroll box, so @@ -76,6 +90,13 @@ MAX_LOG_LINES = 500 BOUNDARY = "uvk5frame" TARGET_FPS = 15 +# Resend the current frame at least this often even when nothing changed. +# +# Sending only on change saves bandwidth but makes a static screen +# indistinguishable from a dead connection, and a client that joined mid-idle +# would sit blank until something moved. Slow rather than stopped. +IDLE_FRAME_INTERVAL_S = 2.0 + # Physical layout of the UV-K5 keypad, for the on-screen grid. KEY_GRID = [ ["MENU", "UP", "DOWN", "EXIT"], @@ -188,6 +209,9 @@ def create_app(client, frame_addr: int, status_addr: int, scale: int = 4, action = (body.get("action") or "tap").strip().lower() if not is_valid(key): + # Log refusals too: a silently dropped key is indistinguishable from + # a dead button in the browser. + log.add("key", f"{body.get('key')!r} rejected: not a key on this model") return jsonify(error=f"unknown key {body.get('key')!r}", valid=list(KEYS)), 400 if action not in ("down", "up", "tap"): @@ -209,16 +233,25 @@ def create_app(client, frame_addr: int, status_addr: int, scale: int = 4, try: if action == "down": + log.add("key", f"{key} down") set_press(key) elif action == "up": + log.add("key", f"{key} up") set_press("") else: + # Label by what the FIRMWARE will conclude, so the log says what + # the radio saw. That boundary is 400 ms (key_repeat_delay_10ms), + # not the UI's hold threshold -- conflating the two is what caused + # the tap+held double send in the first place. + kind = "held" if hold_ms >= FIRMWARE_HELD_MS else "tap" + log.add("key", f"{key} {kind} {hold_ms}ms") # Hold here, locally. See the note on TAP_MS: doing this as two # requests puts the network round trip inside the press duration. set_press(key) time.sleep(hold_ms / 1000) set_press("") except LookupError: + log.add("key", f"{key} ignored: emulator is off") return jsonify(error="emulator is off; press On first"), 409 return jsonify(ok=True, key=key, action=action, hold_ms=hold_ms) @@ -255,14 +288,19 @@ def create_app(client, frame_addr: int, status_addr: int, scale: int = 4, interval = 1.0 / TARGET_FPS def frames(): - sent, seen = 0, -1 + sent, seen, last_sent_at = 0, -1, 0.0 while limit is None or sent < limit: png = pump.latest() generation = pump.generation() - # Send only when the pump reports a new frame. A slow client - # therefore falls behind in frames, never in QMP reads. - if png is not None and (generation != seen or limit is not None): + now = time.monotonic() + # Send on change, and otherwise at the idle keepalive rate. Change + # detection alone leaves a static screen looking like a dead + # connection, and a client joining mid-idle would stay blank. + stale = now - last_sent_at >= IDLE_FRAME_INTERVAL_S + if png is not None and (generation != seen or stale + or limit is not None): seen = generation + last_sent_at = now yield (b"--" + BOUNDARY.encode() + b"\r\n" b"Content-Type: image/png\r\n" b"Content-Length: " + str(len(png)).encode() @@ -303,8 +341,8 @@ def render_index(scale: int) -> str: padding:20px; background:#1e2126; border:1px solid #2d333b; border-radius:14px; }} /* pixelated keeps the 128x64 LCD crisp when scaled up */ - #screen {{ display:block; image-rendering:pixelated; background:#c8d6b9; - border:3px solid #0d1117; border-radius:4px; }} + /* The border lives on .screenwrap so it stays put when the frame is hidden. */ + #screen {{ display:block; image-rendering:pixelated; background:#c8d6b9; }} .body {{ display:flex; gap:14px; align-items:flex-start; }} .sides {{ display:flex; flex-direction:column; gap:8px; }} .pad {{ display:flex; flex-direction:column; gap:8px; }} @@ -325,8 +363,15 @@ def render_index(scale: int) -> str: .pwr:disabled {{ opacity:0.5; cursor:default; }} #powerstate {{ font-size:12px; color:#6e7681; margin-left:auto; }} #powerstate.on {{ color:#3fb950; }} - /* Powered off is a dark panel, not a frozen last frame. */ - .screen-off {{ background:#0b0d10 !important; }} + /* + * Powered off is a dark panel, drawn by the wrapper so the frame itself can be + * hidden. An earlier attempt put a dark background on the alone, which + * changed nothing visible: the image kept painting the last frame over it, so + * Off looked like it had not worked. + */ + .screenwrap {{ background:#0b0d10; border:3px solid #0d1117; border-radius:4px; + line-height:0; }} + .screenwrap.screen-off #screen {{ visibility:hidden; }} #logpane {{ align-self:stretch; }} #logpane summary {{ font-size:12px; color:#6e7681; cursor:pointer; user-select:none; }} @@ -349,8 +394,10 @@ def render_index(scale: int) -> str: - - radio LCD +
+ radio LCD +
{sides}
{grid}
@@ -482,7 +529,8 @@ function showPower(powered) {{ const label = document.getElementById('powerstate'); label.textContent = powered ? 'on' : 'off'; label.classList.toggle('on', powered); - document.getElementById('screen').classList.toggle('screen-off', !powered); + document.getElementById('screenwrap') + .classList.toggle('screen-off', !powered); }} async function poll() {{