diff --git a/README.md b/README.md
index 9fae8532..9bd0c2f3 100644
--- a/README.md
+++ b/README.md
@@ -145,6 +145,12 @@ curl -X POST http://your-pi-ip:5000/api/v3/plugins/install \
| [MQTT Notifications](./plugins/mqtt-notifications/) | HomeAssistant notifications via MQTT | |
| [On Air Light](./plugins/on-air/) | Broadcast ON AIR tally light, triggered remotely via MQTT / Home Assistant | |
+### Productivity (1)
+
+| Plugin | Description | Preview |
+|--------|-------------|---------|
+| [Incoming Packages](./plugins/incoming-packages/) | Rotating carrier cards for packages arriving today, via Home Assistant |
|
+
### Custom (3)
| Plugin | Description | Preview |
diff --git a/docs/assets/incoming-packages/card-types.png b/docs/assets/incoming-packages/card-types.png
new file mode 100644
index 00000000..c20dc887
Binary files /dev/null and b/docs/assets/incoming-packages/card-types.png differ
diff --git a/docs/assets/incoming-packages/hero.png b/docs/assets/incoming-packages/hero.png
new file mode 100644
index 00000000..e88b1979
Binary files /dev/null and b/docs/assets/incoming-packages/hero.png differ
diff --git a/docs/assets/incoming-packages/highlight-today.png b/docs/assets/incoming-packages/highlight-today.png
new file mode 100644
index 00000000..03b565c7
Binary files /dev/null and b/docs/assets/incoming-packages/highlight-today.png differ
diff --git a/docs/assets/incoming-packages/panel-sizes.png b/docs/assets/incoming-packages/panel-sizes.png
new file mode 100644
index 00000000..b89cfc46
Binary files /dev/null and b/docs/assets/incoming-packages/panel-sizes.png differ
diff --git a/docs/assets/incoming-packages/shots.json b/docs/assets/incoming-packages/shots.json
new file mode 100644
index 00000000..224c59a9
--- /dev/null
+++ b/docs/assets/incoming-packages/shots.json
@@ -0,0 +1,139 @@
+{
+ "plugin": "incoming-packages",
+ "defaults": {
+ "width": 128,
+ "height": 32,
+ "scale": 6,
+ "freeze_time": "2026-07-24T09:00:00+00:00",
+ "config": {
+ "enabled": true,
+ "provider": "mock"
+ },
+ "attrs": {
+ "current_index": 0
+ }
+ },
+ "shots": [
+ {
+ "name": "hero"
+ },
+ {
+ "name": "card-dashboard",
+ "standalone": false
+ },
+ {
+ "name": "card-today",
+ "attrs": {
+ "current_index": 1
+ },
+ "standalone": false
+ },
+ {
+ "name": "card-transit",
+ "attrs": {
+ "current_index": 3
+ },
+ "standalone": false
+ },
+ {
+ "name": "highlight-on",
+ "attrs": {
+ "current_index": 1
+ },
+ "standalone": false,
+ "config": {
+ "highlight_today": true
+ }
+ },
+ {
+ "name": "highlight-off",
+ "attrs": {
+ "current_index": 1
+ },
+ "standalone": false,
+ "config": {
+ "highlight_today": false
+ }
+ },
+ {
+ "name": "dashboard-on",
+ "standalone": false,
+ "config": {
+ "show_dashboard": true
+ }
+ },
+ {
+ "name": "dashboard-off",
+ "standalone": false,
+ "config": {
+ "show_dashboard": false
+ }
+ },
+ {
+ "name": "logo-on",
+ "attrs": {
+ "current_index": 1
+ },
+ "standalone": false,
+ "config": {
+ "show_carrier_logo": true
+ }
+ },
+ {
+ "name": "logo-off",
+ "attrs": {
+ "current_index": 1
+ },
+ "standalone": false,
+ "config": {
+ "show_carrier_logo": false
+ }
+ },
+ {
+ "name": "delivered-on",
+ "width": 128,
+ "height": 64,
+ "attrs": {
+ "current_index": 4
+ },
+ "standalone": false,
+ "config": {
+ "show_delivered": true
+ }
+ },
+ {
+ "name": "delivered-off",
+ "width": 128,
+ "height": 64,
+ "attrs": {
+ "current_index": 4
+ },
+ "standalone": false,
+ "config": {
+ "show_delivered": false
+ }
+ },
+ {
+ "name": "size-64x32",
+ "width": 64,
+ "standalone": false
+ },
+ {
+ "name": "size-128x32",
+ "width": 128,
+ "standalone": false
+ },
+ {
+ "name": "size-128x64",
+ "width": 128,
+ "height": 64,
+ "standalone": false
+ },
+ {
+ "name": "size-256x32",
+ "width": 256,
+ "standalone": false
+ }
+ ],
+ "composites": []
+}
diff --git a/docs/assets/incoming-packages/show-carrier-logo.png b/docs/assets/incoming-packages/show-carrier-logo.png
new file mode 100644
index 00000000..5dfbec29
Binary files /dev/null and b/docs/assets/incoming-packages/show-carrier-logo.png differ
diff --git a/docs/assets/incoming-packages/show-dashboard.png b/docs/assets/incoming-packages/show-dashboard.png
new file mode 100644
index 00000000..6bd9b425
Binary files /dev/null and b/docs/assets/incoming-packages/show-dashboard.png differ
diff --git a/docs/assets/incoming-packages/show-delivered.png b/docs/assets/incoming-packages/show-delivered.png
new file mode 100644
index 00000000..00001d7f
Binary files /dev/null and b/docs/assets/incoming-packages/show-delivered.png differ
diff --git a/docs/assets/incoming-packages/survey.png b/docs/assets/incoming-packages/survey.png
new file mode 100644
index 00000000..aa92a908
Binary files /dev/null and b/docs/assets/incoming-packages/survey.png differ
diff --git a/plugins.json b/plugins.json
index f900d57c..a4e5363b 100644
--- a/plugins.json
+++ b/plugins.json
@@ -1,6 +1,6 @@
{
"version": "1.0.0",
- "last_updated": "2026-09-02",
+ "last_updated": "2026-09-03",
"plugins": [
{
"id": "cricket-scoreboard",
@@ -1140,10 +1140,10 @@
"plugin_path": "plugins/incoming-packages",
"stars": 0,
"downloads": 0,
- "last_updated": "2026-07-27",
+ "last_updated": "2026-09-03",
"verified": true,
"screenshot": "https://raw.githubusercontent.com/ChuckBuilds/ledmatrix-plugins/main/plugins/incoming-packages/assets/screenshot.png",
- "latest_version": "1.0.0",
+ "latest_version": "1.0.1",
"icon": "fas fa-box"
},
{
diff --git a/plugins/incoming-packages/README.md b/plugins/incoming-packages/README.md
index 66d46ad6..2800eaf7 100644
--- a/plugins/incoming-packages/README.md
+++ b/plugins/incoming-packages/README.md
@@ -4,6 +4,13 @@ Rotating cards on your LEDMatrix for packages headed your way — one per active
carrier, with the count **arriving today** prioritized and highlighted, plus a
lead summary card.
+
+
+*Every image in this README is real plugin output from the built-in `mock`
+provider, rendered at the true panel size and scaled up so the pixels stay
+pixels.*
+
> **This plugin does not use the Shop app** — Shopify exposes no public API for a
> consumer's package list. Instead it reads a normalized snapshot from a pluggable
> **provider** (default: Home Assistant). The plugin only ever stores an API URL +
@@ -83,20 +90,101 @@ count/image, and Home Assistant's own summary string.
hiccup rather than blanking, with a small amber "Xm ago" freshness marker once
the data is stale (`stale_after_minutes`).
+
+
+Carriers with something arriving today are sorted ahead of carriers that only
+have packages in transit, and their count is drawn in `accent_color`.
+
Carrier badges are drawn (colored badge + abbreviation), so no trademarked logo
images are bundled. Drop a `assets/carrier_logos/.png` (e.g. `ups.png`,
`fedex.png`) to override a badge with your own image.
-## Key options
+
+
+The plugin reads the panel size every frame and picks a bitmap font tier to
+match, shortening labels rather than overflowing — `in transit` becomes
+`in trans.` when the card is narrow.
+
+## Configuration
+
+Settings live in the plugin's tab in the web UI and in `config/config.json`
+under `incoming-packages`. The full schema is
+[`config_schema.json`](config_schema.json).
+
+### Connection
+
+| Key | Default | Notes |
+|---|---|---|
+| `enabled` | `false` | Master switch |
+| `provider` | `homeassistant` | `homeassistant`, `aftership`, or `mock` — see [Providers](#providers) |
+| `ha_base_url` | `""` | Home Assistant URL, e.g. `http://homeassistant.local:8123`. Used by the `homeassistant` provider |
+| `ha_token` | — | Home Assistant long-lived access token. Stored as a secret, so the web UI masks it |
+| `api_key` | — | AfterShip API key. Only read when `provider` is `aftership`. Also a secret |
+| `entity_prefix` | `sensor.mail_` | Mail and Packages entity prefix. Advanced; the provider auto-discovers the sensors, so this is rarely needed |
+| `update_interval` | `600` | Seconds between provider refreshes (60–7200). Advanced |
+| `stale_after_minutes` | `60` | Data older than this gets the amber "Xm ago" freshness marker (5–1440). Advanced |
+
+### What appears on the cards
+
+| Key | Default | Notes |
+|---|---|---|
+| `show_dashboard` | `true` | Lead with the badge grid rather than a compact text summary |
+| `show_carrier_logo` | `true` | Draw the carrier badge on each card |
+| `show_delivered` | `true` | Add the "N delivered" confirmation for packages delivered today |
+| `include_delivered` | `false` | Also give already-delivered packages their own card |
+| `show_usps_mail_image` | `true` | Include the USPS Informed Delivery mail card when there is mail |
+| `show_delivery_images` | `true` | Show a carrier's scanned delivery photo when it is out for delivery today |
+| `highlight_today` | `true` | Sort arriving-today carriers first and accent their count |
+| `accent_color` | `[0, 220, 120]` | The accent colour used for that highlight |
+| `customization.title_text.text_color` | `[255, 255, 255]` | Colour of the primary text |
+
+### Rotation and motion
+
+| Key | Default | Notes |
+|---|---|---|
+| `display_duration` | `30` | Seconds the plugin holds the screen per turn in the rotation (1–300) |
+| `rotation_interval` | `6` | Seconds each card is shown before the next (1–60). Advanced |
+| `max_cards` | `8` | Cap on how many cards are in the rotation (1–20). Advanced |
+| `image_frame_seconds` | `1.5` | Seconds per frame when animating the USPS mail image (0.2–10). Advanced |
+| `scroll_enabled` | `true` | Marquee-scroll text too long to fit rather than truncating. Advanced |
+| `scroll_speed` | `5` | Frames between marquee steps; higher is slower (1–30). Advanced |
+| `scroll_separator` | `" "` | Text inserted between marquee loops. Advanced |
+| `timezone` | `""` | Override the timezone used to decide what "today" means. Empty follows the global LEDMatrix timezone. Advanced |
+
+Settings marked advanced sit in the collapsed **Advanced Settings** section of
+the web UI form.
+
+### What the toggles look like
+
+`show_dashboard` decides whether the rotation opens with the badge grid or a
+compact summary:
+
+
+
+`highlight_today` controls both the accent colour and the sort order:
+
+
+
+`show_carrier_logo` drops the badge and gives the text the full width:
+
+
+
+`show_delivered` adds the confirmation line, which needs a panel tall enough
+for a third row:
+
+
+
+### Previewing without Home Assistant
-| Option | Default | Notes |
-|--------|---------|-------|
-| `provider` | `homeassistant` | `homeassistant` \| `aftership` \| `mock` |
-| `ha_base_url` / `ha_token` | — | Home Assistant URL + long-lived token |
-| `highlight_today` | `true` | Prioritize + accent arriving-today |
-| `accent_color` | `[0,220,120]` | RGB accent |
-| `rotation_interval` | `6` | Seconds per card |
-| `update_interval` | `600` | Provider refresh cadence (s) |
-| `max_cards` | `8` | Cards in the rotation |
+Set `provider` to `mock` to see the layout with built-in demo data and no
+credentials at all — four carriers, two of them arriving today. Every image in
+this README is that provider.
-See `config_schema.json` for the full list.
+```json
+{
+ "incoming-packages": {
+ "enabled": true,
+ "provider": "mock"
+ }
+}
+```
diff --git a/plugins/incoming-packages/manifest.json b/plugins/incoming-packages/manifest.json
index 3cf209ef..6f7c1a87 100644
--- a/plugins/incoming-packages/manifest.json
+++ b/plugins/incoming-packages/manifest.json
@@ -1,7 +1,7 @@
{
"id": "incoming-packages",
"name": "Incoming Packages",
- "version": "1.0.0",
+ "version": "1.0.1",
"author": "ChuckBuilds",
"description": "Rotating cards for packages headed your way - per carrier, with the count arriving today highlighted. Reads a normalized snapshot from a pluggable provider: the Home Assistant Mail and Packages integration (default), AfterShip, or a built-in demo. Email scanning stays inside Home Assistant; the plugin only stores an API URL + token.",
"category": "productivity",
@@ -27,6 +27,12 @@
"pillow"
],
"versions": [
+ {
+ "released": "2026-09-03",
+ "version": "1.0.1",
+ "notes": "Documentation. The README's 'Key options' table listed 7 of the 25 settings and said to read config_schema.json for the rest; it now documents all 25, grouped by connection, card contents, and rotation, with the advanced ones marked. Adds real rendered screenshots throughout -- the three card types, four panel sizes, and before/after pairs for show_dashboard, highlight_today, show_carrier_logo and show_delivered -- all produced by the built-in mock provider, so they need no credentials to reproduce.",
+ "ledmatrix_min": "2.0.0"
+ },
{
"released": "2026-07-24",
"version": "1.0.0",
@@ -34,7 +40,7 @@
"notes": "Initial release. Provider-agnostic incoming-package display with a Home Assistant Mail and Packages provider (default, auto-discovers sensor.mail_* entities over the HA REST API), an AfterShip provider, and a built-in demo provider. Size-adaptive rotating cards: one per active carrier with a drawn carrier badge, the count arriving today (prioritized and shown in an accent color), the count in transit, and a lead summary card, plus optional USPS mail count. Renders on all panel sizes from 64x32 to 256x64 with adaptive font tiers and marquee text; on-panel setup/error messages."
}
],
- "last_updated": "2026-07-27",
+ "last_updated": "2026-09-03",
"stars": 0,
"downloads": 0,
"verified": true,
diff --git a/scripts/docs_render_support/sitecustomize.py b/scripts/docs_render_support/sitecustomize.py
index 20844d5e..90fe76c1 100644
--- a/scripts/docs_render_support/sitecustomize.py
+++ b/scripts/docs_render_support/sitecustomize.py
@@ -52,3 +52,65 @@ def today(cls):
_docs_http_replay.install()
except Exception: # never let doc tooling break the render it is measuring
pass
+
+
+# Some plugins hold their interesting state in memory, put there by an event
+# the renderer cannot produce -- an MQTT message, a webhook, a button press.
+# No configuration reaches that state, so a documentation render would only
+# ever show the idle frame. LEDMATRIX_DOCS_ATTRS names attributes to set on the
+# plugin instance once the core's loader has built it, which is the same thing
+# the event would have done, without the plugin knowing it is being rendered.
+_ATTRS = os.environ.get("LEDMATRIX_DOCS_ATTRS")
+if _ATTRS:
+ import json as _json
+ import sys as _sys
+
+ _WANTED = _json.loads(_ATTRS)
+ _TARGET = "src.plugin_system.plugin_loader"
+
+ def _apply(instance):
+ for name, value in _WANTED.items():
+ if isinstance(value, list) and len(value) == 3 and all(
+ isinstance(v, int) for v in value):
+ value = tuple(value) # colours are tuples everywhere in the core
+ setattr(instance, name, value)
+ return instance
+
+ class _PatchingLoader:
+ """Wraps the real loader so the module is patched right after it runs."""
+
+ def __init__(self, inner):
+ self._inner = inner
+
+ def create_module(self, spec):
+ return self._inner.create_module(spec)
+
+ def exec_module(self, module):
+ self._inner.exec_module(module)
+ real = module.PluginLoader.load_plugin
+
+ def load_plugin(self, *args, **kwargs):
+ instance, mod = real(self, *args, **kwargs)
+ return _apply(instance), mod
+
+ module.PluginLoader.load_plugin = load_plugin
+
+ def __getattr__(self, name):
+ return getattr(self._inner, name)
+
+ class _AttrFinder:
+ """Hands back the real spec with its loader wrapped, once."""
+
+ def find_spec(self, name, path=None, target=None):
+ if name != _TARGET:
+ return None
+ index = _sys.meta_path.index(self)
+ for finder in _sys.meta_path[index + 1:]:
+ find = getattr(finder, "find_spec", None)
+ spec = find(name, path, target) if find else None
+ if spec is not None and spec.loader is not None:
+ spec.loader = _PatchingLoader(spec.loader)
+ return spec
+ return None
+
+ _sys.meta_path.insert(0, _AttrFinder())
diff --git a/scripts/render_docs_assets.py b/scripts/render_docs_assets.py
index 42a1d93d..36c7bd4b 100644
--- a/scripts/render_docs_assets.py
+++ b/scripts/render_docs_assets.py
@@ -263,12 +263,16 @@ def render_shot(
# clock, a countdown, or a "starts in 2h" line differs on every run.
freeze_time = shot.get("freeze_time", defaults.get("freeze_time"))
http_replay = shot.get("http_replay", defaults.get("http_replay"))
- if freeze_time or http_replay:
+ # Runtime state that an event would normally have set; see the shim.
+ attrs = shot.get("attrs", defaults.get("attrs"))
+ if freeze_time or http_replay or attrs:
support_dir = str(Path(__file__).resolve().parent / "docs_render_support")
existing = env.get("PYTHONPATH")
env["PYTHONPATH"] = f"{support_dir}{os.pathsep}{existing}" if existing else support_dir
if freeze_time:
env["LEDMATRIX_DOCS_FREEZE_TIME"] = str(freeze_time)
+ if attrs:
+ env["LEDMATRIX_DOCS_ATTRS"] = json.dumps(attrs)
if http_replay:
replay_path = (shot_list_dir / http_replay).resolve()
if not replay_path.is_file():