Commit Graph
10 Commits
Author SHA1 Message Date
mckero 46f0f37d58 Log keypresses, keep frames flowing when idle, blank the screen when off
Three things reported from actual use, plus the bug the logging exposed.

1. Keys are logged (source "key"), including refusals and presses while powered
   off. Without this there was no way to tell "the key never arrived" from "the
   firmware did something else with it" -- which is exactly what was needed below.

2. /stream resends the current frame every IDLE_FRAME_INTERVAL_S even when nothing
   changed. Change-detection alone made a static screen indistinguishable from a
   dead connection, and a client joining mid-idle stayed blank. Measured: 6 frames
   in 10 idle seconds, where before it was 0.

3. Off now actually blanks the screen. The dark panel moved to a .screenwrap
   wrapper and the <img> is hidden; setting a background on the <img> alone did
   nothing visible, because the image kept painting the last frame over it.

Then the reported bug: in the menu, UP/DOWN behaved like another MENU press. The
key log made it diagnosable and the cause was mine -- LONG_PRESS_AFTER_MS was set
to the firmware's own 400 ms boundary, but a deliberate click runs 100-500 ms, so
ordinary clicks sent tap AND held and the firmware acted on both:

  held DOWN  auto-repeated, gMenuCursor 3 -> 12 from one click
  held MENU  entered the submenu, gIsInSubMenu 0 -> 1

The UI threshold is now 900 ms, well clear of any click, and FIRMWARE_HELD_MS is a
separate constant so the two are not conflated again. Verified against the real
firmware: 120/300/500/800 ms clicks each move the cursor exactly +1 with
submenu=0, while a deliberate 1400 ms hold still auto-repeats (+9).
2026-08-28 10:38:58 +01:00
mckero c164917632 Log power events, QEMU stderr and firmware serial; show them in a fixed pane
Three sources into one buffer: power events from the supervisor, QEMU's stderr
(which run.sh and the tests used to discard), and firmware serial, which the
machine model tags SERIAL. default_launcher now captures stderr rather than
sending it to DEVNULL, which is what made the last two reachable.

The pane is a fixed-height scroll box as asked: 180px with overflow-y:auto, so it
never grows with content -- older lines move up out of view and you scroll back to
read them.

Two details that make that usable rather than annoying:

- Autoscroll only sticks when you are already at the bottom. Otherwise a new line
  arriving would yank the view away from whatever you had scrolled up to read.
- MAX_LOG_LINES caps the <pre> as well. The box is fixed-height either way, but an
  unbounded DOM node would still grow memory across a long session.

Verified on the live server: power on produced power/qemu/serial lines including
"UV-K5 Firmware, EGZUMER-F4HWN+NR7Y c91cec95", each Reset logs the event and the
banner reappearing, and since= never resent a line. A capacity-500 buffer fed 2000
lines keeps exactly 500 and does not replay evicted entries to a stale cursor.
2026-08-28 10:06:18 +01:00
mckero 20e7577257 Cut key latency: fire at pointerdown and shorten the tap
Two changes, both aimed only at latency, since that is what the link makes
expensive.

1. Send on pointerdown instead of pointerup. Waiting for release left the network
   idle for the entire click. The duration is unknown at that moment, so the
   speculative request asks for a short press; holding past LONG_PRESS_AFTER_MS
   sends a second, deliberately long press, which is how held events stay
   reachable.

2. TAP_MS 200 -> 60 ms. The server blocks for hold_ms before replying, so this is
   latency the user pays directly. 200 ms was a guess that gave back most of what
   change 1 saved.

The 60 ms is measured, and the sample size mattered: at 4 trials per value 30 ms
looked reliable, but at 12 trials 20 ms registered only 5/12 while 30 ms was 12/12.
The nominal 20 ms debounce is not sufficient alone because KEYBOARD_Poll samples
each column 8 times wanting 2 matching reads. 60 ms is double the proven floor.

Click-to-visible at 400 ms RTT, where one round trip is an unavoidable 400 ms:

  original (2 requests, on release)   2525 ms   (+2125 over the floor)
  one request, on release              657 ms   (+257)
  current (1 request, on press)        505 ms   (+105)

Long press still works: a 60 ms press opens the menu (gScreenToDisplay 0 -> 1)
while a 900 ms press is treated as held and correctly does not, so the firmware
still distinguishes them.

Also drops MIN_HOLD_MS, now dead: the browser no longer measures press duration,
so there is no measurement to clamp. Its test asserted only that the string
appeared, which would have kept passing over dead code.
2026-08-28 09:56:04 +01:00
mckero 5b53cbbc82 Start powered off; the user presses On
The server now owns the QEMU process by default but does not launch it. You open
the page to a dark screen and press On, which is the behaviour asked for: like
walking up to a machine rather than finding it already booted.

This inverts the flag from the plan. Owning the process has to be the default,
since it is the only way On/Off can work at all; --attach is the opt-in for joining
a run.sh instance, where Off is refused.

Two tests guard the intent rather than the wiring: one asserts main() has --attach
and not --own-emulator, another asserts main() never calls power_on(), so a future
edit cannot quietly restore auto-boot.

Verified on the live server with no QEMU running beforehand:
  startup   0 QEMU processes, powered=false, frame.png 503, page 200
  On        1 QEMU process, powered=true, frame.png 200 (2920 bytes)
  Off       0 QEMU processes, frame.png 503, page still 200, keys 409
  On again  1 QEMU process, powered=true, frame.png 200

The web server stays up across Off, which is what you asked for: the screen goes
dark and waits for the next person to press On.
2026-08-28 06:09:56 +01:00
mckero 6a9cf9f28a Add an On/Off/Reset bar above the screen
Buttons sit above the LCD as asked, with a state label that turns green when the
guest is up. Powered off dims the panel via a screen-off class, so a dark screen
is the signal rather than a frozen last frame.

Off asks for confirmation: it ends the guest, and a stray click should not do that
silently. Buttons disable while a power action is in flight, since On and Off take
a couple of seconds and a double click would race.

The stream is restarted after every power action. The old multipart response ends
when the emulator goes away, so without a fresh src the image would stay blank
after On.

Refusals are surfaced in the status line rather than swallowed -- that is how the
409 for an adopted emulator becomes visible instead of looking like a dead button.

49 unit tests, and the generated page passes node --check.
2026-08-28 06:02:30 +01:00
mckero 699561794a Add power endpoints and survive the emulator being off
POST /api/power/{on,off,reset,pause,resume}, and every route now tolerates there
being no emulator: /api/status reports powered:false, /frame.png returns 503,
keypresses return 409 with "press On first". Previously create_app required a live
client and the whole page would 500.

The client is fetched through the supervisor per request rather than captured once,
because a power cycle replaces it and the captured one goes stale.

After any power action the pump is rebound, so Off actually goes dark instead of
freezing on the last frame.

Off is refused with 409 for an adopted emulator: we did not start that process.
Reset is allowed either way, since system_reset ends nothing.

Verified over HTTP with a real supervisor and real QEMU:
  start     powered=False  qemu=0  frame=503
  key       409 as expected
  On        powered=True   qemu=1  frame=2920 bytes
  Reset     qemu=1 (process survived)
  Off       powered=False  qemu=0  frame=503
  On again  powered=True   qemu=1  frame=2920 bytes
The web server stayed up throughout, which is the requested behaviour.
2026-08-28 06:00:50 +01:00
mckero b500697224 Serve frames from the pump instead of reading QMP per request
/stream and /frame.png now read the pump's shared buffer. A slow client falls
behind in frames rather than in QMP reads, and reconnects no longer multiply the
load on the emulator.

/frame.png returns 503 when there is no frame rather than raising, because that is
a real state: the emulator can be powered off and the page still has to load. The
same reason create_app now tolerates client=None.

Measured on the live server with 4 concurrent streams, which is what a reconnecting
browser produces: keypress latency went from 6 ms avg to 5 ms, so -1 ms, i.e. noise.
All four clients received the same ~27 frames. /stream first byte in 4 ms.

Note this rules out my earlier guess: I had assumed concurrent streams were
starving keypresses, and the numbers said otherwise both before and after. The
pump is worth having for constant QMP load, not because contention was the
slowness.
2026-08-28 05:55:03 +01:00
mckero 7fe605eeaf Send one request per keypress carrying the measured duration
The browser now times the press with performance.now() and posts hold_ms once,
instead of sending down and up as two requests. Halves the round trips per key and
makes press duration independent of the link.

Deletes test_sends_down_and_up_not_just_tap: it asserted the behaviour being
replaced, so keeping it would have meant asserting the bug.

MIN_HOLD_MS is injected into the page from webui.py so the two agree on the floor,
which exists because anything under the firmware's 20 ms debounce does not register
at all -- a very fast click still has to ask for 60 ms.

Verified over a simulated 400 ms link: three keys in 1.58 s where the old design
needed ~2.45 s, and a 120 ms press opens the menu (gScreenToDisplay 0 -> 1) with
DOWN then moving the cursor. The generated page also passes node --check.
2026-08-28 05:41:59 +01:00
mckero e7f3f9da76 Hold keys for a server-side duration, not a network round trip
POST /api/key now accepts {"key": "MENU", "hold_ms": 120} and holds the key for
exactly that long, locally.

The old design sent down and up as two requests so the browser would own press
duration. That is correct on loopback and broken over a real link: the round trip
between the two requests *is* the press duration. Measured against this server at
400 ms RTT, an intended tap arrived as a 407 ms hold, and since the firmware reads
400 ms as held (key_repeat_delay_10ms = 40), every short press was dispatched to
the hold path where MAIN_Key_MENU does nothing. Jitter either side of that
threshold is why it felt intermittent rather than simply broken.

Verified at 400 ms simulated RTT:
  one request  531 ms total, firmware saw 120 ms -> short press
  two requests 816 ms total, firmware saw 408 ms -> held (the bug)

hold_ms is clamped to MAX_HOLD_MS, rejects negatives and non-numbers, and defaults
to TAP_MS. A deliberate 900 ms hold is preserved, so long-press events still work.
down and up stay for scripting on a fast link.
2026-08-28 05:35:37 +01:00
mckero b4f3b28a39 Add the web remote control: live LCD plus clickable keypad
Flask app on the existing QMP socket. GET / serves the page, GET /stream is a
multipart PNG sequence at up to 15 fps, POST /api/key drives the keypad model.

The keypad takes discrete down/up rather than a fixed-duration tap, because the
firmware reads anything past 400 ms as a held key and dispatches it differently.
Letting the browser own the timing is what makes both short and long presses
reachable; tap stays available for scripting and is pinned between the 20 ms
debounce and the 400 ms hold, with a test asserting that range.

The stream only re-encodes when the framebuffer bytes change. The LCD is static
most of the time, so idle CPU stays near zero.

Unknown key names are rejected with 400 before reaching QMP, so a PTT button
cannot be added by accident. The front end releases on pointerleave,
pointercancel and blur, and /api/release-all is the safety valve for a key that
somehow stays down.

Verified against a live emulator over HTTP: /frame.png renders the dual-VFO main
screen, a MENU tap opens the menu at 01/79, and two DOWN presses reach 03/79.
19 unit tests, 41 across the whole suite.
2026-08-28 04:45:51 +01:00