Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 59 additions & 5 deletions src/display_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,20 @@
# Get logger with consistent configuration
logger = get_logger(__name__)

# How long startup will wait for plugins to fetch their first data before
# showing anything. Each plugin's update blocks for up to the executor's 30s
# timeout and they run one after another, so the uncapped total is the sum of
# every slow plugin: 82 seconds on the worst boot measured, with a blank panel
# throughout. Whatever does not finish in time is picked up by the scheduled
# update tick moments later, with the display already running.
_INITIAL_UPDATE_BUDGET_SECONDS = 20.0

# The least budget worth starting a plugin with. Below this the plugin is
# deferred instead: granting it a floor would let the pass run past its
# deadline, and granting it the true remainder would record a timeout for a
# slot it never had a chance to use.
_MIN_INITIAL_UPDATE_TIMEOUT_SECONDS = 2.0

# Vegas mode import (lazy loaded to avoid circular imports)
_vegas_mode_imported = False
VegasModeCoordinator = None
Expand Down Expand Up @@ -461,7 +475,7 @@ def _controller_config_change(old_config: Dict[str, Any], new_config: Dict[str,
# Initial data update for plugins (ensures data available on first display)
logger.info("Performing initial plugin data update...")
update_start = time.time()
self._update_modules()
self._update_modules(deadline=update_start + _INITIAL_UPDATE_BUDGET_SECONDS)
logger.info("Initial plugin update completed in %.3f seconds", time.time() - update_start)

# Initialize Vegas mode coordinator
Expand Down Expand Up @@ -817,14 +831,42 @@ def _check_dim_schedule(self) -> int:
self._cached_target_brightness = normal_brightness # persist for minute-gate
return normal_brightness

def _update_modules(self):
"""Update all plugin modules."""
def _update_modules(self, deadline: Optional[float] = None):
"""Update all plugin modules.

Args:
deadline: Wall-clock time after which remaining plugins are left
for the scheduled update tick instead of being waited on. Each
update blocks this thread for up to the executor's timeout, and
they run one after another, so without a bound the total is the
sum of every slow plugin on the system. Measured at startup on
a live rig: 82 seconds, 55 and 26 on the two boots before -- all
of it with nothing on the panel.
"""
if not self.plugin_manager:
return

# Update all loaded plugins
plugins_dict = getattr(self.plugin_manager, 'loaded_plugins', None) or getattr(self.plugin_manager, 'plugins', {})
deferred = []
for plugin_id, plugin_instance in plugins_dict.items():
update_timeout = None
if deadline is not None:
update_timeout = deadline - time.time()
if update_timeout < _MIN_INITIAL_UPDATE_TIMEOUT_SECONDS:
# Too little left to be worth starting. Deferring rather
# than granting a floor keeps the budget a real ceiling --
# clamping up to a minimum let a plugin that began with a
# sliver left run on past the deadline -- and a plugin
# handed a slot it cannot use would just be recorded as
# having timed out.
#
# Nothing is lost either way: a plugin that has never
# updated is immediately due, so run_scheduled_updates()
# picks it up within seconds, with the display already
# running.
deferred.append(plugin_id)
continue
# Check circuit breaker before attempting update
if hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker:
if self.plugin_manager.health_tracker.should_skip_plugin(plugin_id):
Expand All @@ -833,7 +875,13 @@ def _update_modules(self):

# Use PluginExecutor if available for safe execution
if hasattr(self.plugin_manager, 'plugin_executor'):
success = self.plugin_manager.plugin_executor.execute_update(plugin_instance, plugin_id)
# The remaining budget is the timeout, so the pass cannot
# run past its deadline. Bounding the loop alone did not do
# it: the last plugin to start could still block for the
# executor's full 30s, which turned a 20s budget into a 31.8s
# pass on the rig.
success = self.plugin_manager.plugin_executor.execute_update(
plugin_instance, plugin_id, timeout=update_timeout)
if success and hasattr(self.plugin_manager, 'plugin_last_update'):
self.plugin_manager.plugin_last_update[plugin_id] = time.time()
else:
Expand All @@ -852,6 +900,12 @@ def _update_modules(self):
if hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker:
self.plugin_manager.health_tracker.record_failure(plugin_id, exc)

if deferred:
logger.info(
"Initial update budget spent; %d plugin(s) left to the update "
"tick so the display can start: %s",
len(deferred), ", ".join(deferred))

def _tick_plugin_updates_for_vegas(self) -> None:
"""Run scheduled plugin updates and tell Vegas mode which plugins
actually got fresh data, so it can hot-swap them into the scroll
Expand Down
93 changes: 91 additions & 2 deletions src/display_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

import json
import os
import socket
import tempfile
if os.getenv("EMULATOR", "false") == "true":
from RGBMatrixEmulator import RGBMatrix, RGBMatrixOptions
Expand Down Expand Up @@ -497,6 +498,91 @@ def get_brightness(self) -> int:
logger.warning(f"[BRIGHTNESS] Matrix does not support brightness property: {e}", exc_info=True)
return -1

@staticmethod
def _local_ip() -> Optional[str]:
"""This device's address on the network it routes through, or None.

Deliberately not `hostname -I` or a systemctl probe for AP mode, which
is how the web launcher does it: both spawn processes with multi-second
timeouts, and this runs on the startup path the rest of this change
exists to shorten. Connecting a UDP socket sends no packets -- it only
asks the kernel which source address it would use -- so it costs
microseconds and works with the network down, as long as a route
exists.
"""
sock = None
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(0.2)
sock.connect(("8.8.8.8", 80)) # nosec B104 - no traffic; selects a route
ip = sock.getsockname()[0]
return ip if ip and not ip.startswith("127.") else None
except OSError:
return None
finally:
if sock is not None:
try:
sock.close()
except OSError:
pass

def _fitting_font(self, lines, width):
"""The largest font from the usual ladder that fits every line."""
candidates = [self.font,
("assets/fonts/4x6-font.ttf", 6)]
for candidate in candidates:
try:
font = candidate
if isinstance(candidate, tuple):
font = ImageFont.truetype(candidate[0], candidate[1])
if all(self.draw.textlength(t, font=font) <= width for t in lines):
return font
except (OSError, ValueError, AttributeError):
continue
return self.font

def _draw_startup_banner(self, lines, width: int, height: int) -> None:
"""Centre `lines` over whatever the test pattern already drew.

This screen stays on the panel for the whole initial plugin update, and
on a headless Pi it is the only place the device's address appears
without going looking for it -- so it has to be readable off a wall,
not merely present.

The font is chosen to fit rather than fixed at 8px: "Initializing" is
96px in PressStart2P, which ran off the side of a 64px panel even
before an address was added. And the pattern is punched out behind the
text, because the diagonal runs through the middle of the panel, which
is exactly where this sits.

The text stays blue. It is not decoration: the pattern draws one pure
channel per element -- red border, green diagonal, blue text -- so that
a glance at the panel says whether led_rgb_sequence is right. Swap the
wiring to BGR and the border comes up blue and this text red. Drawing
it white would light all three channels and destroy the only blue
reference on the screen, which is why it is worth a comment rather
than a quiet preference.
"""
if not lines:
return
font = self._fitting_font(lines, width - 2)
line_height = self.draw.textbbox((0, 0), "Ag", font=font)[3] + 1
block_height = line_height * len(lines)
block_top = max(1, (height - block_height) // 2)
block_width = max(self.draw.textlength(t, font=font) for t in lines)
block_left = max(0, (width - block_width) // 2)

self.draw.rectangle(
[block_left - 2, block_top - 1,
block_left + block_width + 1, block_top + block_height],
fill=(0, 0, 0))

for row, line in enumerate(lines):
line_width = self.draw.textlength(line, font=font)
self.draw.text(
(max(0, (width - line_width) // 2), block_top + row * line_height),
line, font=font, fill=(0, 0, 255))

def _draw_test_pattern(self):
"""Draw a test pattern to verify the display is working."""
try:
Expand All @@ -516,8 +602,11 @@ def _draw_test_pattern(self):
# Draw a diagonal line
self.draw.line([0, 0, self.matrix.width-1, self.matrix.height-1], fill=(0, 255, 0))

# Draw some text - changed from "TEST" to "Initializing" with smaller font
self.draw.text((10, 10), "Initializing", font=self.font, fill=(0, 0, 255))
lines = ["Initializing"]
ip = self._local_ip()
if ip:
lines.append(ip)
self._draw_startup_banner(lines, self.matrix.width, self.matrix.height)

# Update the display once after everything is drawn
self.update_display()
Expand Down
Loading
Loading