mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-13 14:48:06 +00:00
* fix(startup): bound the initial plugin update so the panel lights sooner
DisplayController.__init__ calls _update_modules() once to populate
plugin data before the first frame. It walks every loaded plugin in
turn, and each update blocks the calling thread for up to the executor's
30s timeout, so the uncapped total is the sum of every slow plugin on
the system. The rig's own log:
Initial plugin update completed in 82.255 seconds
Initial plugin update completed in 55.123 seconds
Initial plugin update completed in 25.975 seconds
The panel shows nothing for all of it.
Nothing is lost by stopping early. A plugin that has never updated is
immediately due, so run_scheduled_updates() collects it seconds later --
with the display already running rather than blank.
A deadline alone was not enough: it is checked before each plugin, so
the last one to start could still block for the full 30s, and a 20s
budget produced a 31.8s pass on the rig. The remaining budget is now
passed down as that update's timeout too, with a floor so a plugin
starting on the last sliver is not handed ~0s and recorded as having
timed out for a slot it never had. Measured after: 20.006s.
Found while profiling a scroll freeze with py-spy, which caught the main
thread 9.34s inside execute_with_timeout's join. Worth being clear that
this is startup latency, not the recurring stutter -- _update_modules
has exactly one caller and runtime updates already run off the display
thread.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
* feat(display): show the device address on the startup screen
That screen is what the panel holds for the whole initial plugin update,
and on a headless Pi it is the only place the address appears without
going looking for it -- so it now carries the address under
"Initializing".
The lookup connects a UDP socket, which sends no packets: it only asks
the kernel which source address it would route from. That costs 0.03ms
and works with the network down so long as a route exists. Deliberately
not `hostname -I` plus a systemctl probe for AP mode, which is how the
web launcher does it -- two subprocesses with multi-second timeouts, on
the startup path this branch exists to shorten.
Two things had to change for the address to be worth putting there.
The text is now sized to fit rather than fixed at 8px: "Initializing" is
96px in PressStart2P, drawn at x=10, so it already ran off the side of a
64px panel before an address was added. It falls back to 4x6 where that
does not fit, and both lines are centred.
And the test pattern is punched out from behind the block, with the text
drawn white rather than blue. The diagonal runs through the middle of
the panel, which is exactly where this sits, and blue on black reads
fine on a monitor but is marginal on a dim panel. An address that cannot
be read off the wall is not worth showing.
The rendering tests assert against pixels -- no green left behind the
text at any supported size, enough lit pixels to be visible -- rather
than against the geometry that produced them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
* fix(display): keep the startup text blue -- it is a channel reference
The test pattern lights one pure channel per element: red border, green
diagonal, blue text. That is how a glance at the panel tells you whether
led_rgb_sequence is right -- wire it BGR and the border comes up blue
and the text red. Drawing the text white, as the previous commit did for
contrast, lights all three channels and destroys the only blue reference
on the screen.
Reverted to blue, with the reason written down so it is not treated as a
style preference again, and with tests that pin it: the text must be
pure blue, nothing on the screen may be white, and all three primaries
must be present.
The punched-out backdrop stays. It only removes the diagonal from behind
the glyphs, which costs nothing diagnostically -- the diagonal is still
plainly visible across the rest of the panel -- and it is what makes the
address readable at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
* fix(startup): defer a plugin with too little budget, rather than clamp it
The per-plugin timeout was clamped up to a floor, so a plugin that began
with a sliver of budget left was granted the full floor and ran on past
the deadline: a 20s budget could take 22. The floor existed to stop a
plugin being handed a slot too short to use and then recorded as having
timed out, which is a real concern, but clamping solved it by breaking
the bound.
Deferring solves both. Below the floor the plugin is left to the update
tick, which was already the fate of everything after the deadline, so
nothing new is lost -- a plugin that has never updated is immediately
due. Above it, the timeout is the exact remainder, and the pass cannot
outlast its deadline.
Measured on the rig after the change: 20.002s, 5 plugins deferred.
Also names an unused binding in the initializing-screen test.
Both reported by CodeRabbit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
191 lines
7.9 KiB
Python
191 lines
7.9 KiB
Python
"""Tests the startup screen that shows while plugins fetch their first data.
|
|
|
|
That screen is on the panel for the whole initial-update window, and on a
|
|
headless Pi it is the only place the device's address appears without going
|
|
looking for it -- so it now carries the address as well as "Initializing".
|
|
|
|
Two things have to hold. It must fit every supported panel: the old fixed
|
|
8px PressStart2P drew "Initializing" 96px wide at x=10, which ran off the
|
|
side of a 64px panel before an address was ever added. And the lookup must be
|
|
cheap, because this runs on the startup path that the rest of this change
|
|
exists to shorten.
|
|
"""
|
|
|
|
import os
|
|
import time
|
|
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
import pytest
|
|
|
|
os.environ.setdefault("EMULATOR", "true")
|
|
|
|
from src.display_manager import DisplayManager # noqa: E402
|
|
|
|
SIZES = [(64, 32), (128, 32), (128, 64), (256, 32), (512, 64)]
|
|
|
|
|
|
class FakeMatrix:
|
|
def __init__(self, width, height):
|
|
self.width, self.height = width, height
|
|
|
|
|
|
def _manager(width, height):
|
|
dm = DisplayManager.__new__(DisplayManager)
|
|
dm.image = Image.new('RGB', (width, height))
|
|
dm.draw = ImageDraw.Draw(dm.image)
|
|
dm.matrix = FakeMatrix(width, height)
|
|
dm.font = ImageFont.truetype('assets/fonts/PressStart2P-Regular.ttf', 8)
|
|
return dm
|
|
|
|
|
|
def _layout(dm, lines):
|
|
"""The geometry _draw_startup_banner uses."""
|
|
font = dm._fitting_font(lines, dm.matrix.width - 2)
|
|
line_height = dm.draw.textbbox((0, 0), "Ag", font=font)[3] + 1
|
|
top = max(1, (dm.matrix.height - line_height * len(lines)) // 2)
|
|
widths = [dm.draw.textlength(t, font=font) for t in lines]
|
|
return font, widths, top, top + line_height * len(lines)
|
|
|
|
|
|
def _render_over_pattern(width, height, lines):
|
|
"""Draw the test pattern, then the banner over it, as startup does."""
|
|
dm = _manager(width, height)
|
|
dm.draw.rectangle([0, 0, width - 1, height - 1], outline=(255, 0, 0))
|
|
dm.draw.line([0, 0, width - 1, height - 1], fill=(0, 255, 0))
|
|
dm._draw_startup_banner(lines, width, height)
|
|
return dm
|
|
|
|
|
|
class TestTheAddressLookup:
|
|
def test_it_never_reports_loopback(self):
|
|
# A loopback address on the panel would be actively misleading -- it is
|
|
# not something anyone can browse to.
|
|
ip = DisplayManager._local_ip()
|
|
assert ip is None or not ip.startswith("127."), ip
|
|
|
|
def test_it_looks_like_an_address_when_there_is_one(self):
|
|
ip = DisplayManager._local_ip()
|
|
if ip is None:
|
|
pytest.skip("host has no routable address")
|
|
parts = ip.split(".")
|
|
assert len(parts) == 4 and all(p.isdigit() for p in parts), ip
|
|
|
|
def test_it_is_cheap_enough_for_the_startup_path(self):
|
|
DisplayManager._local_ip() # warm anything cacheable
|
|
started = time.perf_counter()
|
|
for _ in range(20):
|
|
DisplayManager._local_ip()
|
|
per_call = (time.perf_counter() - started) / 20
|
|
# `hostname -I` with its 2s timeout, which the web launcher uses, would
|
|
# be thousands of times this.
|
|
assert per_call < 0.05, "%.1f ms per call" % (per_call * 1000)
|
|
|
|
def test_it_returns_none_rather_than_raising(self, monkeypatch):
|
|
import src.display_manager as mod
|
|
|
|
def no_network(*a, **k):
|
|
raise OSError("network is unreachable")
|
|
|
|
monkeypatch.setattr(mod.socket, "socket", no_network)
|
|
assert DisplayManager._local_ip() is None
|
|
|
|
|
|
class TestItFitsEveryPanel:
|
|
@pytest.mark.parametrize("width,height", SIZES)
|
|
def test_both_lines_fit_with_an_address(self, width, height):
|
|
dm = _manager(width, height)
|
|
_font, widths, top, bottom = _layout(dm, ["Initializing", "255.255.255.255"])
|
|
assert all(w <= width - 2 for w in widths), (width, widths)
|
|
assert bottom <= height and top >= 0, (top, bottom, height)
|
|
|
|
@pytest.mark.parametrize("width,height", SIZES)
|
|
def test_it_still_fits_with_no_address(self, width, height):
|
|
dm = _manager(width, height)
|
|
_font, widths, _top, bottom = _layout(dm, ["Initializing"])
|
|
assert all(w <= width - 2 for w in widths), (width, widths)
|
|
assert bottom <= height, (bottom, height)
|
|
|
|
def test_the_smallest_panel_drops_to_a_narrower_font(self):
|
|
# The regression this guards: PressStart2P at 8px is 96px wide for
|
|
# "Initializing", which does not fit 64px however it is positioned.
|
|
dm = _manager(64, 32)
|
|
font, widths, _t, _b = _layout(dm, ["Initializing", "10.0.20.104"])
|
|
assert font is not dm.font, "kept a font that cannot fit"
|
|
assert max(widths) <= 62, widths
|
|
|
|
def test_a_roomy_panel_keeps_the_larger_font(self):
|
|
dm = _manager(256, 32)
|
|
font, _w, _t, _b = _layout(dm, ["Initializing", "10.0.20.104"])
|
|
assert font is dm.font, "needlessly shrank on a panel with room"
|
|
|
|
|
|
class TestPlacement:
|
|
@pytest.mark.parametrize("width,height", SIZES)
|
|
def test_the_lines_are_centred(self, width, height):
|
|
dm = _manager(width, height)
|
|
lines = ["Initializing", "10.0.20.104"]
|
|
_font, widths, _t, _b = _layout(dm, lines)
|
|
for w in widths:
|
|
left = max(0, (width - w) // 2)
|
|
assert abs((left + (left + w)) - width) <= 2, (left, w, width)
|
|
|
|
def test_the_address_sits_under_the_word(self):
|
|
dm = _manager(128, 64)
|
|
font, _w, top, bottom = _layout(dm, ["Initializing", "10.0.20.104"])
|
|
line_height = dm.draw.textbbox((0, 0), "Ag", font=font)[3] + 1
|
|
assert bottom - top == line_height * 2
|
|
|
|
|
|
class TestItIsActuallyReadable:
|
|
"""The point of the address is that someone can read it off the wall."""
|
|
|
|
@pytest.mark.parametrize("width,height", SIZES)
|
|
def test_the_diagonal_does_not_cross_the_text(self, width, height):
|
|
lines = ["Initializing", "10.0.20.104"]
|
|
dm = _render_over_pattern(width, height, lines)
|
|
_font, widths, top, bottom = _layout(dm, lines)
|
|
# textlength returns a float, so these must be floored before they
|
|
# can index pixels.
|
|
block_width = int(max(widths))
|
|
left = int(max(0, (width - block_width) // 2))
|
|
|
|
px = dm.image.load()
|
|
green = 0
|
|
for y in range(int(top), min(int(bottom), height)):
|
|
for x in range(left, min(left + block_width, width)):
|
|
r, g, b = px[x, y]
|
|
if g > 128 and r < 128 and b < 128:
|
|
green += 1
|
|
assert green == 0, "%d green pixels behind the text at %dx%d" % (
|
|
green, width, height)
|
|
|
|
@pytest.mark.parametrize("width,height", SIZES)
|
|
def test_the_text_stays_pure_blue(self, width, height):
|
|
# Not a style choice. The pattern lights one channel per element --
|
|
# red border, green diagonal, blue text -- so a glance says whether
|
|
# led_rgb_sequence is right: wire it BGR and the border comes up blue
|
|
# and this text red. White text would light all three and destroy the
|
|
# only blue reference on the screen.
|
|
dm = _render_over_pattern(width, height, ["Initializing", "10.0.20.104"])
|
|
px = dm.image.load()
|
|
blue = sum(1 for y in range(height) for x in range(width)
|
|
if px[x, y] == (0, 0, 255))
|
|
assert blue > 20, "only %d blue pixels at %dx%d" % (blue, width, height)
|
|
white = sum(1 for y in range(height) for x in range(width)
|
|
if px[x, y] == (255, 255, 255))
|
|
assert white == 0, "%d white pixels would muddy the channel check" % white
|
|
|
|
def test_each_element_lights_one_channel(self):
|
|
# The whole point of the pattern: three pure primaries on screen.
|
|
dm = _render_over_pattern(128, 64, ["Initializing", "10.0.20.104"])
|
|
seen = set(dm.image.getdata())
|
|
assert (255, 0, 0) in seen, "no pure red border"
|
|
assert (0, 255, 0) in seen, "no pure green diagonal"
|
|
assert (0, 0, 255) in seen, "no pure blue text"
|
|
|
|
def test_nothing_is_drawn_for_no_lines(self):
|
|
dm = _manager(128, 64)
|
|
before = dm.image.tobytes()
|
|
dm._draw_startup_banner([], 128, 64)
|
|
assert dm.image.tobytes() == before
|