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
24 changes: 23 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ The plugin class **inherits from `BasePlugin`** (`src.plugin_system.base_plugin.
in the core repo) and is constructed as
`__init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager)`.
Key methods (see `plugins/hello-world/manager.py` for a minimal reference):
- `update(self)` — fetch/refresh data (called on `update_interval`); never draw here
- `update(self)` — fetch/refresh data (called on `update_interval`); never draw to
`self.display_manager` here. Pre-rendering *offscreen* images into your own cache
does belong here — see "Pre-rendering" below
- `display(self, force_clear=False)` — render via `self.display_manager` then call `update_display()`
- `validate_config(self)` — call `super().validate_config()` then check plugin-specific keys
- `get_info(self)` / `cleanup(self)` — web-UI info and unload teardown
Expand Down Expand Up @@ -106,6 +108,26 @@ settings). Typical use: size the on-screen time to the width of scrolling conten
or extend live games. See `plugins/football-scoreboard/DYNAMIC_DURATION.md` and
the `supports_dynamic_duration` implementations in the sports managers.

### Pre-rendering (expensive images belong in `update()`)
"Never draw in `update()`" means never touch `self.display_manager` there — not
that `update()` may not build images. Building a PIL image into the plugin's own
cache is expensive work, and expensive work is exactly what `update()` is for:
it runs on the update worker, while `display()` runs on the render thread, where
a stall shows up as a frozen panel or a stuttering marquee. So a plugin whose
frame costs real time should render offscreen in `update()` and have `display()`
do nothing but paste the result and draw the parts that must be live (a clock, a
countdown). Precedent: `plugins/f1-scoreboard/manager.py`
(`_prepare_scroll_content`, 12.46s of scroll images), `plugins/ledmatrix-elections`
(`_build_scroll_image`), `plugins/geochron` (`_render_for_size`).

Two things make this safe:
- **Key the cache on `(width, height)`.** Vegas captures plugins at a narrower
width than the panel (`vegas_width_pct`), so the same plugin is asked to render
at two sizes and a single-entry cache thrashes between them. Re-render every
cached size in `update()`.
- **Keep a lazy path in `display()`.** A size that has never been rendered has to
be built on demand; that's a one-off, not the steady state.

### High-FPS / smooth scrolling
Scrolling plugins render far faster than the default loop for smooth motion.
Two mechanisms:
Expand Down
56 changes: 54 additions & 2 deletions docs/plugin-development/01-plugin-anatomy.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,20 +67,28 @@ mandatory, but a useful plugin implements at least `update` and `display`.
| Method | Signature | When the core calls it | Rule |
|--------|-----------|------------------------|------|
| `__init__` | `(self, plugin_id, config, display_manager, cache_manager, plugin_manager)` | Once, at load | Call `super().__init__`; set up state; **don't** fetch or draw |
| `update` | `(self)` | Every `update_interval` seconds | Fetch/refresh data only — **never draw** |
| `update` | `(self)` | Every `update_interval` seconds | Fetch/refresh data and pre-render offscreen — **never touch `display_manager`** |
| `display` | `(self, force_clear=False)` | Every render turn | Draw via `self.display_manager`, then call `self.display_manager.update_display()` |
| `validate_config` | `(self)` | When validating config | Call `super().validate_config()` first, then check your keys; return `bool` |
| `get_info` | `(self)` | For the web UI | `info = super().get_info()`; add keys; return the dict |
| `cleanup` | `(self)` | On unload/teardown | Release resources; call `super().cleanup()` |

### `update(self)` — fetch, don't draw
### `update(self)` — fetch and prepare, don't touch the display

Called on the interval you set via the `update_interval` config key. This is the
**only** place you should hit the network or do expensive work. Store results on
`self` for `display()` to render. Cache network responses through
`self.cache_manager` (see [topic 2](./02-core-api.md#cache-manager)) so a restart
or a second plugin doesn't re-hit the API.

"Don't draw here" means **don't touch `self.display_manager`** — don't paste into
its image, don't call `update_display()`. It does *not* mean `update()` may not
build images. `update()` runs on the update worker; `display()` runs on the render
thread, where a slow frame freezes the panel and stalls the Vegas marquee. So if
your frame is expensive to build, build it here, offscreen, into your own cache,
and let `display()` paste it. See [pre-rendering](#pre-rendering-expensive-frames)
below.

```python
def update(self):
data = self.cache_manager.get(f"{self.plugin_id}:feed", max_age=self.update_interval)
Expand Down Expand Up @@ -115,6 +123,50 @@ def display(self, force_clear=False):
> Returning `False` lets some plugins signal "nothing to show, skip me." See
> [`plugins/hockey-scoreboard/manager.py`](../../plugins/hockey-scoreboard/manager.py).

### Pre-rendering expensive frames

If building your frame costs real time — stitching a long scroll image, drawing a
world map, compositing dozens of cards — build it in `update()` and paste it in
`display()`. The split is about *which thread pays*: `update()` runs on the
update worker, `display()` on the render thread, so an expensive `display()`
freezes the panel and stalls the Vegas marquee for everyone.

Two details make this work:

**Key the cache on `(width, height)`.** Vegas captures plugins at a narrower width
than the panel (`vegas_width_pct`, see [topic 3](./03-advanced-features.md)), so the same
plugin gets asked for the same content at two different sizes. A single-entry
cache misses on every switch and rebuilds from scratch each time — the exact
thrash `plugins/geochron` hit, at ~290 ms per pass on the render thread. Cache per
size, and re-render every cached size in `update()`:

```python
def update(self):
self.data = self._fetch() # size-independent, computed once
sizes = set(self._frame_cache) # every size asked for so far...
sizes.add((self.display_manager.width, self.display_manager.height)) # ...plus the live panel
for size in sizes:
self._render_for_size(size, self.data)

def display(self, force_clear=False):
size = (self.display_manager.width, self.display_manager.height)
frame = self._frame_cache.get(size)
if frame is None: # never rendered at this size: one-off, not steady state
frame = self._render_for_size(size, self.data)
self.display_manager.image.paste(frame, (0, 0))
self._draw_clock() # live parts stay in display(), outside the cache
self.display_manager.update_display()
```

**Keep live parts out of the cached image.** Anything that must be current on
every frame — a clock, a countdown, a "LIVE" pulse — is drawn in `display()`
*after* the paste. Folding it into the cached image freezes it at render time.

Worked examples: `plugins/f1-scoreboard/manager.py` (`_prepare_scroll_content`,
which also fingerprints its inputs so it can skip an unchanged rebuild),
`plugins/ledmatrix-elections/manager.py` (`_build_scroll_image`), and
`plugins/geochron/manager.py` (`_render_for_size`).

### `validate_config(self)` — fail loudly, early

Called to check the plugin's config. Convention: call the base first, then
Expand Down
2 changes: 1 addition & 1 deletion plugins.json
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@
"last_updated": "2026-07-17",
"verified": true,
"screenshot": "",
"latest_version": "1.0.2",
"latest_version": "1.0.3",
"icon": "fa-globe"
},
{
Expand Down
79 changes: 59 additions & 20 deletions plugins/geochron/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_man

self._cached_map = None
self._cached_layout = None
# Rendered map per (width, height). Vegas captures at a different
# width than the panel, and a single entry thrashed between the two.
self._map_cache = {}
# Terminator grid, shared across sizes -- it is lat/lon, not pixels.
self._darkness = None
self._subsolar_lat = 0.0
self._subsolar_lon = 0.0
self._last_update_utc = None
Expand Down Expand Up @@ -140,6 +145,21 @@ def _render_base_map(self):
# ------------------------------------------------------------------

def update(self):
"""Recompute the terminator and re-render every panel size in use.

The map is cached per (width, height) rather than as a single image.
The Vegas marquee captures this plugin through the display-capture
fallback at a narrower width than the panel -- 153px against 512px on
the rig this was measured on -- and the old single-entry cache
mismatched on every switch. display() then re-rendered inline, which
for a capture means on the render thread: 105ms recomputing a
terminator that does not depend on size at all, plus ~150ms rendering
the map, roughly 290ms of stalled marquee every time round.

Re-rendering the sizes here, on the update worker, means the render
thread finds a warm entry and pays nothing. The terminator is computed
once and shared across sizes, since it is a lat/lon grid.
"""
try:
now_utc = datetime.now(timezone.utc)
darkness, sub_lat, sub_lon = solar.compute_terminator(
Expand All @@ -148,17 +168,34 @@ def update(self):
self._subsolar_lat = sub_lat
self._subsolar_lon = sub_lon
self._last_update_utc = now_utc

dw = self.display_manager.width
dh = self.display_manager.height
layout = gr._layout(dw, dh, map_center_lon=self.map_center_longitude)
self._cached_layout = layout
self._cached_map = gr.render_map_image(
self._base_map, darkness, layout, self.night_brightness, self.colors["night_tint_color"]
)
self._darkness = darkness

# Whatever sizes have been asked for so far, plus the live panel.
# Copy first: display() inserts a newly-seen size from the render
# thread, and iterating the live dict could catch it mid-write.
sizes = set(self._map_cache.copy())
sizes.add((self.display_manager.width, self.display_manager.height))
for size in sizes:
self._render_for_size(size, darkness)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
except Exception as e:
self.logger.error("Error updating geochron: %s", e, exc_info=True)

def _render_for_size(self, size, darkness):
"""Render and cache the map for one panel size."""
dw, dh = size
layout = gr._layout(dw, dh, map_center_lon=self.map_center_longitude)
image = gr.render_map_image(
self._base_map, darkness, layout, self.night_brightness,
self.colors["night_tint_color"]
)
self._map_cache[size] = (layout, image)
# Keep the single-entry attributes pointing at the live panel so
# anything still reading them (get_info, tests) sees what is on screen.
if (dw, dh) == (self.display_manager.width, self.display_manager.height):
self._cached_layout = layout
self._cached_map = image
return layout, image

def display(self, force_clear=False):
try:
if force_clear:
Expand All @@ -167,20 +204,22 @@ def display(self, force_clear=False):
dw = self.display_manager.width
dh = self.display_manager.height

layout = self._cached_layout
if (
layout is None
or self._cached_map is None
or layout["dw"] != dw
or layout["dh"] != dh
):
self.update()
layout = self._cached_layout

if layout is None or self._cached_map is None:
cached = self._map_cache.get((dw, dh))
if cached is None:
# First time at this size. If the terminator has never been
# computed there is nothing to render from, so fall back to a
# full update; otherwise reuse it and render just this size.
if self._darkness is None:
self.update()
cached = self._map_cache.get((dw, dh))
else:
cached = self._render_for_size((dw, dh), self._darkness)

if cached is None:
return
layout, map_image = cached

self.display_manager.image.paste(self._cached_map, (layout["map_x"], layout["map_y"]))
self.display_manager.image.paste(map_image, (layout["map_x"], layout["map_y"]))
draw = self.display_manager.draw

if layout["sidebar_w"]:
Expand Down
27 changes: 23 additions & 4 deletions plugins/geochron/manifest.json
Original file line number Diff line number Diff line change
@@ -1,16 +1,35 @@
{
"id": "geochron",
"name": "Geochron World Clock",
"version": "1.0.2",
"version": "1.0.3",
"author": "ChuckBuilds",
"description": "High-fidelity world map clock showing the real-time day/night terminator with civil/nautical/astronomical twilight bands, the subsolar point, configurable city markers, and a digital UTC/local clock - a classic Geochron, reimagined for LED matrix panels of any size.",
"entry_point": "manager.py",
"class_name": "GeochronPlugin",
"category": "time",
"tags": ["clock", "world map", "geochron", "sun", "terminator", "astronomy", "time", "globe"],
"display_modes": ["geochron"],
"compatible_versions": [">=2.0.0"],
"tags": [
"clock",
"world map",
"geochron",
"sun",
"terminator",
"astronomy",
"time",
"globe"
],
"display_modes": [
"geochron"
],
"compatible_versions": [
">=2.0.0"
],
"versions": [
{
"version": "1.0.3",
"released": "2026-08-14",
"ledmatrix_min_version": "2.0.0",
"notes": "Cache the rendered map per panel size instead of holding one image and re-rendering whenever the size changes. The Vegas marquee captures this plugin at a narrower width than the panel -- 153px against 512px on the rig this was measured on -- so the single entry alternated between the two and every switch re-rendered from scratch on the render thread: 105ms recomputing a terminator that does not depend on size, plus ~150ms drawing the map, about 290ms of stalled marquee each pass. The terminator is now computed once and shared across sizes, and update() refreshes every size in use on the worker thread so the render thread finds a warm image. The clock is untouched -- it is drawn after the map on every frame, so nothing about it goes stale."
},
{
"released": "2026-07-17",
"version": "1.0.2",
Expand Down
Loading
Loading