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
9 changes: 6 additions & 3 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ checked box. -->
forgetting this means users won't receive the change)
- [ ] `class_name` in `manifest.json` matches the actual class in
`manager.py` exactly (case-sensitive, no spaces)
- [ ] `entry_point` matches the real file (or is omitted to use
the `manager.py` default)
- [ ] `entry_point` matches the real file (the core manifest schema
requires it)
- [ ] Updated the plugin's `README.md` if config keys changed
- [ ] `config_schema.json` is the source of truth for the web UI
form — any new option is in the schema with a `default`,
Expand All @@ -57,7 +57,10 @@ checked box. -->

- [ ] Plugin id matches the directory name and is unique
- [ ] `manifest.json` has all required fields (`id`, `name`,
`version`, `class_name`, `display_modes`)
`version`, `author`, `entry_point`, `class_name`,
`compatible_versions`, plus `display_modes`)
- [ ] Added the plugin's entry to `plugins.json` (new plugins are the
one hand-added case; `update_registry.py --check` fails without it)
- [ ] `manager.py` inherits from `BasePlugin` and implements
`update()` and `display()`
- [ ] `config_schema.json` exists and validates as JSON Schema Draft-7
Expand Down
9 changes: 7 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ These are not general Python advice — they exist because of how *this* stack w
2. **Never hand-edit `plugins.json`.** Commit with the pre-commit hook
(`cp scripts/pre-commit .git/hooks/pre-commit`) or run
`python update_registry.py`. CI also regenerates on push to `main`.
Exception: a **new** plugin's entry (and review fields like `verified`) is
added by hand — the script only updates existing entries, and `--check`
fails without one (docs topic 07).
3. **Fetch in `update()`, draw in `display()`.** Never hit the network from
`display()`. Cache network data via `self.cache_manager`, keys namespaced by
plugin id.
Expand Down Expand Up @@ -105,8 +108,10 @@ Plugin class: subclass `BasePlugin` from the core
(`src.plugin_system.base_plugin.BasePlugin`). Constructor args:
`plugin_id, config, display_manager, cache_manager, plugin_manager`.

Required manifest fields: `id` (matches directory), `name`, `version`,
`class_name`, `display_modes`. Full field list / schema conventions →
Required manifest fields (core `schema/manifest_schema.json`, validated in CI):
`id` (matches directory), `name`, `version`, `author`, `entry_point`,
`class_name`, `compatible_versions` — plus `display_modes`, which every plugin
needs to be shown. Full field list / schema conventions →
`docs/plugin-development/06-manifest-and-config-schema.md`.

Config schemas are JSON Schema Draft-07 with UI `x-*` extensions
Expand Down
42 changes: 28 additions & 14 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,10 @@ short version:
- `class_name` must match the actual class name in `manager.py`
**exactly** (case-sensitive, no spaces — see
[VERIFICATION.md](VERIFICATION.md) for why this matters)
- Set `entry_point` (defaults to `manager.py` if omitted)
- Set `version`, `author`, `category`, `tags`, `display_modes`
- Set `entry_point` (usually `manager.py`; the core manifest schema CI
validates against requires it)
- Set `version`, `author`, `compatible_versions`, `category`, `tags`,
`display_modes`
4. Implement `update()` and `display()` in your plugin class.
5. Define the configuration schema in `config_schema.json`. The web
UI form is generated automatically from this — every key you want
Expand All @@ -119,8 +121,12 @@ short version:
6. Write a `README.md` covering: what the plugin does, install,
configuration, and any external service / API key requirements.
7. Add a `LICENSE` (usually a copy of the project GPL-3.0).
8. Test locally via the symlink + dev_server flow above.
9. Open a PR.
8. Add the plugin's entry to `plugins.json` by hand (copy a neighbour's
shape, `plugin_path: "plugins/<plugin-id>"`) — `update_registry.py` only
updates existing entries, and CI's `update_registry.py --check` fails a
new plugin directory without one.
9. Test locally via the symlink + dev_server flow above.
10. Open a PR.

## Commit message convention

Expand All @@ -136,9 +142,11 @@ specific plugin later.

## Testing

Per-plugin tests live in the LEDMatrix repo at `test/plugins/`. If
you're adding a test for your plugin, open a corresponding PR in
LEDMatrix. The dev preview server (`scripts/dev_server.py` in
Per-plugin unit tests live with the plugin, as `plugins/<id>/test_*.py` (or
under its `test/` directory); CI runs them with `scripts/run_plugin_tests.py`
for every plugin a PR changes. Safety-harness fixtures and golden images go in
`plugins/<id>/test/` (see
[topic 7](docs/plugin-development/07-testing-ci-and-registry.md)). The dev preview server (`scripts/dev_server.py` in
LEDMatrix) is the fastest way to iterate visually — its **All Sizes**
button renders your plugin at every harness panel size side by side.

Expand All @@ -148,7 +156,7 @@ check but fails users. Use the adaptive layout system
(`docs/ADAPTIVE_LAYOUT.md` in LEDMatrix: `self.layout`, `draw_fit`,
`draw_image`, `scoreboard_regions`) and check the harness's
`fill warn` output in `check_plugin.py` reports. Declare your layout's
design size in the manifest (`"display": {"design_size": ...}`) and, once
design size in the manifest (`"display": {"design_size": {"width": 128, "height": 32}}`) and, once
your plugin is adaptive, opt into strict checking via
`test/harness.json`: `{"fill_check": "strict"}`. See
[docs/plugin-development/05-adaptive-layout.md](docs/plugin-development/05-adaptive-layout.md).
Expand All @@ -157,12 +165,18 @@ your plugin is adaptive, opt into strict checking via

- **Plugin Safety** (`test-plugins.yml`): for each changed plugin, enforces the
version bump, validates `manifest.json` against the core schema, installs its
`requirements.txt`, and runs the safety harness across all matrix sizes.
- **Module Collisions** (`module-collisions.yml`): runs
`check_module_collisions.py` across all plugins.

A changed plugin whose code (anything outside `test/`, including its README) is
not accompanied by a `version` bump **fails the PR**.
`requirements.txt`, runs the safety harness across all matrix sizes, and runs
its unit tests; on every run it also checks `plugins.json` against the
manifests (`update_registry.py --check`) and runs every `scripts/test_*.py`.
- **Plugin Structure** (`module-collisions.yml`): runs
`check_module_collisions.py` and the other all-plugin structural checks.
- **Sports Lineage Drift** (`sports-drift.yml`): on scoreboard changes, fails
when copies of a shared sports function that agreed start to disagree.

A changed plugin whose code (anything outside `test/` and root-level
`test_*.py`, including its README) is not accompanied by a `version` bump
**fails the PR**. Details:
[topic 7](docs/plugin-development/07-testing-ci-and-registry.md#ci-workflows).

## Code of Conduct

Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ curl -X POST http://your-pi-ip:5000/api/v3/plugins/install \
| [Masters Tournament](./plugins/masters-tournament/) | Live Masters golf leaderboard, hole tracking, player cards | <a href="./plugins/masters-tournament/"><img src="./docs/assets/masters-tournament/hero.png" width="240" alt="masters-tournament on an LED panel"></a> |
| [NFL Draft](./plugins/nfl-draft/) | Projected & live NFL draft picks from ESPN | <a href="./plugins/nfl-draft/"><img src="./docs/assets/nfl-draft/hero.png" width="240" alt="nfl-draft on an LED panel"></a> |
| [March Madness](./plugins/march-madness/) | NCAA tournament bracket tracker with round branding and live scores | <a href="./plugins/march-madness/"><img src="./docs/assets/march-madness/hero.png" width="240" alt="march-madness on an LED panel"></a> |
| [NFL Stat Leaders](./plugins/nfl-stat-leaders/) | Scrolling NFL statistical leaderboards: passing, rushing & receiving yards and TDs | <a href="./plugins/nfl-stat-leaders/"><img src="./docs/assets/nfl-stat-leaders/hero.png" width="240" alt="nfl-stat-leaders on an LED panel"></a> |
| [NFL Stat Leaders](./plugins/nfl-stat-leaders/) | Scrolling NFL statistical leaderboards: passing, rushing & receiving yards and TDs, plus receptions, sacks, INTs, tackles & passer rating | <a href="./plugins/nfl-stat-leaders/"><img src="./docs/assets/nfl-stat-leaders/hero.png" width="240" alt="nfl-stat-leaders on an LED panel"></a> |
| [Fantasy Blitz](./plugins/fantasy-blitz/) | Arcade-style NFL fantasy football: top scorers as player cards, big plays, busts, waiver pickups and injuries | <a href="./plugins/fantasy-blitz/"><img src="./docs/assets/fantasy-blitz/hero.png" width="240" alt="fantasy-blitz on an LED panel"></a> |
| [Sports Leaderboard](./plugins/ledmatrix-leaderboard/) | League standings, rankings, conference records | <a href="./plugins/ledmatrix-leaderboard/"><img src="./docs/assets/ledmatrix-leaderboard/hero.png" width="240" alt="ledmatrix-leaderboard on an LED panel"></a> |
| [Olympics Countdown](./plugins/olympics/) | Countdown to next Olympics with live medal counts | <a href="./plugins/olympics/"><img src="./docs/assets/olympics/hero.png" width="240" alt="olympics on an LED panel"></a> |
Expand Down Expand Up @@ -354,7 +354,11 @@ Optional but recommended:
|-------|------|-------------|
| `id` | string | Unique plugin identifier |
| `name` | string | Human-readable name |
| `version` | string | Semver, e.g. `1.0.0` |
| `author` | string | Plugin author |
| `entry_point` | string | Python file holding the plugin class, e.g. `manager.py` |
| `class_name` | string | Plugin class name (must match class in entry point) |
| `compatible_versions` | array | LEDMatrix version constraints, e.g. `[">=2.0.0"]` |
| `display_modes` | array | Display mode names |

See the [manifest schema](https://github.com/ChuckBuilds/LEDMatrix/blob/main/schema/manifest_schema.json) for complete field reference.
Expand Down
15 changes: 12 additions & 3 deletions SUBMISSION.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ Want to add your plugin to the official registry? Follow these steps!
Before submitting, ensure your plugin:

- ✅ Has a complete `manifest.json` with all required fields
(`id`, `name`, `version`, `class_name`, `display_modes`)
(`id`, `name`, `version`, `author`, `entry_point`, `class_name`,
`compatible_versions` — the core manifest schema requires these — plus
`display_modes`)
- ✅ Follows the [plugin development guide](docs/plugin-development/)
- ✅ Has comprehensive README documentation
- ✅ Includes example configuration
Expand Down Expand Up @@ -36,10 +38,17 @@ Submit a PR to add your plugin directly to this repository:
README.md
```

3. **Submit Pull Request**
3. **Add a `plugins.json` entry**
`update_registry.py` only updates entries that already exist, so a new
plugin's entry is the one thing added by hand: copy a neighbour's shape with
`plugin_path: "plugins/your-plugin-id"`. CI (`update_registry.py --check`)
fails a PR that adds a plugin directory without one. See
[the registry](docs/plugin-development/07-testing-ci-and-registry.md#the-registry-pluginsjson).

4. **Submit Pull Request**
Create PR with title: "Add plugin: your-plugin-name"

After approval, your plugin will be added to `plugins.json` and available in the Plugin Store.
After approval and merge, your plugin is available in the Plugin Store.

### Option B: Keep Your Own Repository (3rd-Party)

Expand Down
10 changes: 6 additions & 4 deletions VERIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,16 @@ module-collision check. Use this list for the human judgment CI can't make

## Manifest Validation

- [ ] All required fields present (`id`, `name`, `version`, `class_name`,
`display_modes`)
- [ ] All required fields present (`id`, `name`, `version`, `author`,
`entry_point`, `class_name`, `compatible_versions` per the core
manifest schema, plus `display_modes`)
- [ ] `class_name` matches the actual class name in the entry point
(case-sensitive, no spaces) — the loader does
`getattr(module, class_name)` and will fail with `AttributeError`
otherwise
- [ ] `entry_point` either matches the real file name or is omitted
(defaults to `manager.py`)
- [ ] `entry_point` matches the real file name (the loader defaults to
`manager.py` when it is absent, but the core manifest schema CI
validates against requires it)
- [ ] `id` matches the directory name
- [ ] Valid JSON syntax
- [ ] Correct version format (semver)
Expand Down
Binary file modified docs/assets/7-segment-clock/colors.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/7-segment-clock/digit-spacing.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/7-segment-clock/panel-sizes.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/7-segment-clock/separator.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions docs/assets/7-segment-clock/shots.json
Original file line number Diff line number Diff line change
Expand Up @@ -289,12 +289,12 @@
{
"shot": "narrow-spacing-2",
"label": "64 x 32, spacing 2",
"sublabel": "fits with room to spare"
"sublabel": "the digits fill the height"
},
{
"shot": "narrow-spacing-10",
"label": "64 x 32, spacing 10",
"sublabel": "too wide: the outer digits are clipped"
"sublabel": "still fits, but the digits shrink to make room"
}
]
}
Expand Down
Binary file modified docs/assets/7-segment-clock/time-formats.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/christmas-countdown/countdown.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/christmas-countdown/hero.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/christmas-countdown/panel-sizes.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/christmas-countdown/text-color.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/hello-world/colors.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/hello-world/hero.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/hello-world/message-length.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/hello-world/panel-sizes.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions docs/assets/hello-world/shots.json
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@
{
"shot": "msg-long",
"label": "\"Welcome to the workshop\"",
"sublabel": "too long: the plugin does not shrink or wrap, so the ends are clipped"
"sublabel": "too long: the font steps down until it fits, at the cost of legibility"
}
]
},
Expand All @@ -197,7 +197,7 @@
{
"shot": "p-64",
"label": "64 x 32",
"sublabel": "the default message no longer fits"
"sublabel": "the default message shrinks to fit, but barely reads"
},
{
"shot": "p-128",
Expand Down
Binary file modified docs/assets/hello-world/show-time.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/ledmatrix-stocks/display-mode.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/ledmatrix-stocks/gain-loss.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/ledmatrix-stocks/hero.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/ledmatrix-stocks/panel-sizes.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/ledmatrix-stocks/toggle-chart.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 7 additions & 4 deletions docs/plugin-development/01-plugin-anatomy.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,9 @@ draw here.

## The lifecycle methods

The core calls six methods on your plugin. Only `__init__` is strictly
mandatory, but a useful plugin implements at least `update` and `display`.
The core calls six methods on your plugin. `update` and `display` are abstract
on `BasePlugin`, so every plugin must implement them (a class missing either
cannot be instantiated); the rest have working base implementations.

| Method | Signature | When the core calls it | Rule |
|--------|-----------|------------------------|------|
Expand Down Expand Up @@ -110,8 +111,9 @@ def display(self, force_clear=False):
if force_clear:
self.display_manager.clear()
w, h = self.display_manager.width, self.display_manager.height
# x is the left edge unless centered=True; y is the top of the text
self.display_manager.draw_text(self.message, x=w // 2, y=h // 2,
color=self.color, font=self.bdf_font)
color=self.color, font=self.bdf_font, centered=True)
self.display_manager.update_display()
```

Expand Down Expand Up @@ -219,7 +221,8 @@ class MyPlugin(BasePlugin):
if force_clear:
self.display_manager.clear()
w, h = self.display_manager.width, self.display_manager.height
self.display_manager.draw_text(self.message, x=w // 2, y=h // 2, color=self.color)
self.display_manager.draw_text(self.message, x=w // 2, y=h // 2,
color=self.color, centered=True)
self.display_manager.update_display()

def validate_config(self):
Expand Down
24 changes: 16 additions & 8 deletions docs/plugin-development/02-core-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,13 @@ what each provides.
| `self.cache_manager` | Shared network/data cache — use it for anything fetched |
| `self.plugin_manager` | Access to shared services: `font_manager`, `config_manager`, other plugins |

The base class also derives some convenience values from config that most plugins
read directly: `self.enabled` (default `True`), `self.display_duration`,
`self.update_interval`, and `self.global_config` (the `global` config block,
where cross-cutting settings like `target_fps` live).
The base class also sets `self.enabled` (from config, default `True`) and
exposes `self.global_config` — the **whole** LEDMatrix config, read-only, where
device-wide settings like `target_fps` and `timezone` live (`{}` when
unavailable). It does **not** set `self.display_duration` or
`self.update_interval`: read those from `self.config` yourself (the core asks
through `get_display_duration()` / `get_update_interval()`, which you can
override).

## `self.logger`

Expand Down Expand Up @@ -59,7 +62,7 @@ The drawing surface. The most common members:
| `.small_font` / `.extra_small_font` / `.regular_font` | Pre-loaded fonts you can use without registering your own |

Scrolling plugins additionally coordinate with the core loop through
`.set_scrolling_state(...)`, `.is_currently_scrolling`, `.defer_update(...)`, and
`.set_scrolling_state(...)`, `.is_currently_scrolling()`, `.defer_update(...)`, and
`.process_deferred_updates()` — see [topic 3](./03-advanced-features.md#high-fps--smooth-scrolling).

Two rendering styles coexist: draw with `draw_text` for simple text (like
Expand All @@ -77,9 +80,9 @@ and multiple plugins don't re-hit APIs. Fetch in `update()`, never in
|--------|---------|
| `get(key, max_age=<seconds>)` | Return the cached value, or `None` if missing/older than `max_age` |
| `set(key, value, ttl=<seconds>)` | Store a value with an optional time-to-live |
| `get_cached_data_with_strategy(key, strategy)` | Strategy-driven read (e.g. `'leaderboard'`) that layers a TTL/refresh policy on top of raw get/set |
| `get_cached_data_with_strategy(key, data_type)` | Strategy-driven read (e.g. `'leaderboard'`) that layers a TTL/refresh policy on top of raw get/set |
| `save_cache(key, data)` | Strategy-driven write partner to the above |
| `get_with_auto_strategy(...)` | Auto-selected strategy variant |
| `get_with_auto_strategy(key)` | Same, with the data type inferred from the key |
| `delete(key)` / `clear_cache()` | Invalidate one key / everything |

**Always namespace keys with your plugin id** so they never collide with another
Expand Down Expand Up @@ -137,10 +140,15 @@ fm.register_manager_font(
**Fetch in `display()`:**

```python
message_font = fm.get_font(f"{self.plugin_id}.message")
message_font = fm.resolve_font(f"{self.plugin_id}.message", "press_start", 10,
plugin_id=self.plugin_id)
self.display_manager.draw_text(self.message, x=..., y=..., font=message_font)
```

`resolve_font(element_key, family, size_px, plugin_id=None)` applies any user
override for that element, then loads the font; `get_font(family, size_px)`
takes no element key and skips overrides.

The `element_key` convention is `f"{self.plugin_id}.<element>"` — prefixing with
the plugin id avoids collisions with other plugins' registered fonts. Common
families seen across plugins include `press_start` and `four_by_six`. Provide a
Expand Down
Loading
Loading