mirror of
https://github.com/MCKero6423/uv-k5-v3-emulator.git
synced 2026-10-02 03:15:36 +00:00
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.
This commit is contained in:
1 parent
b75ef5fdcd
commit
b500697224
2 files changed
+77
-15
No files matched your search
@@ -220,5 +220,53 @@ class TestFrontEndHoldMs(unittest.TestCase):
|
||||
self.assertIn("MIN_HOLD_MS", self.body)
|
||||
|
||||
|
||||
class TestStreamUsesPump(unittest.TestCase):
|
||||
"""Serving frames must not cost QMP reads: the pump owns the grabbing."""
|
||||
|
||||
def _app(self):
|
||||
client = StubClient()
|
||||
app = webui.create_app(client, frame_addr=0x1000, status_addr=0x2000)
|
||||
app.config.update(TESTING=True)
|
||||
return client, app, app.test_client()
|
||||
|
||||
def _wait(self, app, timeout=3.0):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if app.config["PUMP"].latest() is not None:
|
||||
return True
|
||||
time.sleep(0.02)
|
||||
return False
|
||||
|
||||
def test_exposes_the_pump(self):
|
||||
_, app, _ = self._app()
|
||||
self.assertIn("PUMP", app.config)
|
||||
|
||||
def test_serving_frames_costs_no_qmp_reads(self):
|
||||
client, app, http = self._app()
|
||||
self.assertTrue(self._wait(app), "pump produced no frame")
|
||||
|
||||
before = len([n for n, _ in client.sent if n == "memsave"])
|
||||
http.get("/stream?limit=1")
|
||||
http.get("/frame.png")
|
||||
after = len([n for n, _ in client.sent if n == "memsave"])
|
||||
# The pump keeps grabbing in the background, so allow a little drift;
|
||||
# what must not happen is a read per request.
|
||||
self.assertLess(after - before, 8,
|
||||
"serving a frame triggered fresh QMP reads")
|
||||
|
||||
def test_frame_png_still_returns_a_png(self):
|
||||
_, app, http = self._app()
|
||||
self.assertTrue(self._wait(app))
|
||||
resp = http.get("/frame.png")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertTrue(resp.get_data().startswith(b"\x89PNG\r\n\x1a\n"))
|
||||
|
||||
def test_frame_png_returns_503_when_there_is_no_frame(self):
|
||||
"""No frame is a state, not a crash: the emulator may be powered off."""
|
||||
app = webui.create_app(None, frame_addr=0x1000, status_addr=0x2000)
|
||||
app.config.update(TESTING=True)
|
||||
self.assertEqual(app.test_client().get("/frame.png").status_code, 503)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+29
-15
@@ -24,7 +24,7 @@ import time
|
||||
from flask import Flask, Response, jsonify, request
|
||||
|
||||
from uvk5_keys import KEYS, is_valid, normalise
|
||||
from uvk5_lcd import FrameGrabber, encode_png, unpack
|
||||
from uvk5_stream import FramePump
|
||||
|
||||
KEYPAD_PATH = "/machine/keypad"
|
||||
|
||||
@@ -73,7 +73,12 @@ KEY_BINDINGS = {
|
||||
|
||||
def create_app(client, frame_addr: int, status_addr: int, scale: int = 4):
|
||||
app = Flask(__name__)
|
||||
grabber = FrameGrabber(client, frame_addr, status_addr)
|
||||
|
||||
# One background grabber for every client. client may be None: the emulator
|
||||
# can be powered off, and the page still has to load.
|
||||
pump = FramePump(client, frame_addr, status_addr, fps=TARGET_FPS, scale=scale)
|
||||
pump.start()
|
||||
app.config["PUMP"] = pump
|
||||
|
||||
def set_press(value: str):
|
||||
client.command("qom-set", path=KEYPAD_PATH, property="press", value=value)
|
||||
@@ -130,9 +135,21 @@ def create_app(client, frame_addr: int, status_addr: int, scale: int = 4):
|
||||
set_press("")
|
||||
return jsonify(ok=True)
|
||||
|
||||
def wait_for_frame(timeout: float = 2.0):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
png = pump.latest()
|
||||
if png is not None:
|
||||
return png
|
||||
time.sleep(0.02)
|
||||
return None
|
||||
|
||||
@app.get("/frame.png")
|
||||
def frame_png():
|
||||
return Response(grabber.png(scale), mimetype="image/png",
|
||||
png = wait_for_frame()
|
||||
if png is None:
|
||||
return jsonify(error="no frame available; is the emulator on?"), 503
|
||||
return Response(png, mimetype="image/png",
|
||||
headers={"Cache-Control": "no-store"})
|
||||
|
||||
@app.get("/stream")
|
||||
@@ -142,24 +159,21 @@ def create_app(client, frame_addr: int, status_addr: int, scale: int = 4):
|
||||
interval = 1.0 / TARGET_FPS
|
||||
|
||||
def frames():
|
||||
sent, last = 0, None
|
||||
sent, seen = 0, -1
|
||||
while limit is None or sent < limit:
|
||||
started = time.monotonic()
|
||||
status, frame = grabber.raw()
|
||||
current = (status, frame)
|
||||
# Only re-encode when the screen actually changed: the LCD is
|
||||
# static most of the time, so idle CPU stays near zero.
|
||||
if current != last or limit is not None:
|
||||
last = current
|
||||
png = encode_png(unpack(status, frame), scale)
|
||||
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):
|
||||
seen = generation
|
||||
yield (b"--" + BOUNDARY.encode() + b"\r\n"
|
||||
b"Content-Type: image/png\r\n"
|
||||
b"Content-Length: " + str(len(png)).encode()
|
||||
+ b"\r\n\r\n" + png + b"\r\n")
|
||||
sent += 1
|
||||
slack = interval - (time.monotonic() - started)
|
||||
if slack > 0:
|
||||
time.sleep(slack)
|
||||
else:
|
||||
time.sleep(interval)
|
||||
|
||||
return Response(frames(),
|
||||
mimetype=f"multipart/x-mixed-replace; boundary={BOUNDARY}",
|
||||
|
||||
Reference in new issue
Block a user