diff --git a/README.md b/README.md
index 9fae8532..d1dabd94 100644
--- a/README.md
+++ b/README.md
@@ -122,7 +122,7 @@ curl -X POST http://your-pi-ip:5000/api/v3/plugins/install \
| Plugin | Description | Preview |
|--------|-------------|---------|
| [Weather Display](./plugins/ledmatrix-weather/) | Current conditions, hourly & daily forecasts (Open-Meteo) | |
-| [Tide Display](./plugins/tide-display/) | Coastal tides with animated wave level, schedule & 24-hour chart (NOAA) | |
+| [Tide Display](./plugins/tide-display/) | Coastal tides with animated wave level, schedule & 24-hour chart (NOAA) |
|
### Media (2)
diff --git a/docs/assets/tide-display/colors.png b/docs/assets/tide-display/colors.png
new file mode 100644
index 00000000..f34750b8
Binary files /dev/null and b/docs/assets/tide-display/colors.png differ
diff --git a/docs/assets/tide-display/display-modes.png b/docs/assets/tide-display/display-modes.png
new file mode 100644
index 00000000..79ab4f1e
Binary files /dev/null and b/docs/assets/tide-display/display-modes.png differ
diff --git a/docs/assets/tide-display/fixtures/noaa-boston.json b/docs/assets/tide-display/fixtures/noaa-boston.json
new file mode 100644
index 00000000..2e0d0885
--- /dev/null
+++ b/docs/assets/tide-display/fixtures/noaa-boston.json
@@ -0,0 +1 @@
+{"matches":[{"url_contains":"datagetter","params_contain":{"units":"english","product":"water_level"},"body":{"metadata":{"id":"8443970","name":"Boston","lat":"42.3539","lon":"-71.0503"},"data":[{"t":"2026-09-02 18:30","v":"6.577","s":"0.059","f":"0,0,0,0","q":"p"}]}},{"url_contains":"datagetter","params_contain":{"units":"english","interval":"hilo"},"body":{"predictions":[{"t":"2026-09-02 03:14","v":"9.72","type":"H"},{"t":"2026-09-02 09:21","v":"0.489","type":"L"},{"t":"2026-09-02 15:31","v":"10.599","type":"H"},{"t":"2026-09-02 21:57","v":"0.123","type":"L"}]}},{"url_contains":"datagetter","params_contain":{"units":"english","interval":"h"},"body":{"predictions":[{"t":"2026-09-02 00:00","v":"4.756"},{"t":"2026-09-02 01:00","v":"6.951"},{"t":"2026-09-02 02:00","v":"8.763"},{"t":"2026-09-02 03:00","v":"9.683"},{"t":"2026-09-02 04:00","v":"9.356"},{"t":"2026-09-02 05:00","v":"7.933"},{"t":"2026-09-02 06:00","v":"5.852"},{"t":"2026-09-02 07:00","v":"3.619"},{"t":"2026-09-02 08:00","v":"1.699"},{"t":"2026-09-02 09:00","v":"0.581"},{"t":"2026-09-02 10:00","v":"0.818"},{"t":"2026-09-02 11:00","v":"2.473"},{"t":"2026-09-02 12:00","v":"4.833"},{"t":"2026-09-02 13:00","v":"7.178"},{"t":"2026-09-02 14:00","v":"9.178"},{"t":"2026-09-02 15:00","v":"10.42"},{"t":"2026-09-02 16:00","v":"10.448"},{"t":"2026-09-02 17:00","v":"9.25"},{"t":"2026-09-02 18:00","v":"7.202"},{"t":"2026-09-02 19:00","v":"4.796"},{"t":"2026-09-02 20:00","v":"2.508"},{"t":"2026-09-02 21:00","v":"0.774"},{"t":"2026-09-02 22:00","v":"0.124"},{"t":"2026-09-02 23:00","v":"0.92"}]}},{"url_contains":"datagetter","params_contain":{"units":"metric","product":"water_level"},"body":{"metadata":{"id":"8443970","name":"Boston","lat":"42.3539","lon":"-71.0503"},"data":[{"t":"2026-09-02 18:30","v":"2.005","s":"0.018","f":"0,0,0,0","q":"p"}]}},{"url_contains":"datagetter","params_contain":{"units":"metric","interval":"hilo"},"body":{"predictions":[{"t":"2026-09-02 03:14","v":"2.963","type":"H"},{"t":"2026-09-02 09:21","v":"0.149","type":"L"},{"t":"2026-09-02 15:31","v":"3.231","type":"H"},{"t":"2026-09-02 21:57","v":"0.037","type":"L"}]}},{"url_contains":"datagetter","params_contain":{"units":"metric","interval":"h"},"body":{"predictions":[{"t":"2026-09-02 00:00","v":"1.45"},{"t":"2026-09-02 01:00","v":"2.119"},{"t":"2026-09-02 02:00","v":"2.671"},{"t":"2026-09-02 03:00","v":"2.951"},{"t":"2026-09-02 04:00","v":"2.852"},{"t":"2026-09-02 05:00","v":"2.418"},{"t":"2026-09-02 06:00","v":"1.784"},{"t":"2026-09-02 07:00","v":"1.103"},{"t":"2026-09-02 08:00","v":"0.518"},{"t":"2026-09-02 09:00","v":"0.177"},{"t":"2026-09-02 10:00","v":"0.249"},{"t":"2026-09-02 11:00","v":"0.754"},{"t":"2026-09-02 12:00","v":"1.473"},{"t":"2026-09-02 13:00","v":"2.188"},{"t":"2026-09-02 14:00","v":"2.798"},{"t":"2026-09-02 15:00","v":"3.176"},{"t":"2026-09-02 16:00","v":"3.184"},{"t":"2026-09-02 17:00","v":"2.82"},{"t":"2026-09-02 18:00","v":"2.195"},{"t":"2026-09-02 19:00","v":"1.462"},{"t":"2026-09-02 20:00","v":"0.765"},{"t":"2026-09-02 21:00","v":"0.236"},{"t":"2026-09-02 22:00","v":"0.038"},{"t":"2026-09-02 23:00","v":"0.28"}]}}]}
\ No newline at end of file
diff --git a/docs/assets/tide-display/hero.png b/docs/assets/tide-display/hero.png
new file mode 100644
index 00000000..6538d569
Binary files /dev/null and b/docs/assets/tide-display/hero.png differ
diff --git a/docs/assets/tide-display/moon-phase.png b/docs/assets/tide-display/moon-phase.png
new file mode 100644
index 00000000..58c135b0
Binary files /dev/null and b/docs/assets/tide-display/moon-phase.png differ
diff --git a/docs/assets/tide-display/panel-sizes.png b/docs/assets/tide-display/panel-sizes.png
new file mode 100644
index 00000000..858f5712
Binary files /dev/null and b/docs/assets/tide-display/panel-sizes.png differ
diff --git a/docs/assets/tide-display/shots.json b/docs/assets/tide-display/shots.json
new file mode 100644
index 00000000..6e7e9ea4
--- /dev/null
+++ b/docs/assets/tide-display/shots.json
@@ -0,0 +1,442 @@
+{
+ "plugin": "tide-display",
+ "defaults": {
+ "width": 128,
+ "height": 32,
+ "scale": 6,
+ "freeze_time": "2026-09-02T22:30:00+00:00",
+ "http_replay": "fixtures/noaa-boston.json"
+ },
+ "shots": [
+ {
+ "name": "hero",
+ "config": {
+ "enabled": true,
+ "station_id": "8443970",
+ "station_name": "BOSTON",
+ "show_current": true,
+ "show_schedule": false,
+ "show_chart": false,
+ "show_stats": false
+ }
+ },
+ {
+ "name": "m-current",
+ "config": {
+ "enabled": true,
+ "station_id": "8443970",
+ "station_name": "BOSTON",
+ "show_current": true,
+ "show_schedule": false,
+ "show_chart": false,
+ "show_stats": false
+ },
+ "standalone": false
+ },
+ {
+ "name": "m-schedule",
+ "config": {
+ "enabled": true,
+ "station_id": "8443970",
+ "station_name": "BOSTON",
+ "show_current": false,
+ "show_schedule": true,
+ "show_chart": false,
+ "show_stats": false
+ },
+ "standalone": false
+ },
+ {
+ "name": "m-chart",
+ "config": {
+ "enabled": true,
+ "station_id": "8443970",
+ "station_name": "BOSTON",
+ "show_current": false,
+ "show_schedule": false,
+ "show_chart": true,
+ "show_stats": false
+ },
+ "standalone": false
+ },
+ {
+ "name": "m-stats",
+ "config": {
+ "enabled": true,
+ "station_id": "8443970",
+ "station_name": "BOSTON",
+ "show_current": false,
+ "show_schedule": false,
+ "show_chart": false,
+ "show_stats": true
+ },
+ "standalone": false
+ },
+ {
+ "name": "m64-stats",
+ "config": {
+ "enabled": true,
+ "station_id": "8443970",
+ "station_name": "BOSTON",
+ "show_current": false,
+ "show_schedule": false,
+ "show_chart": false,
+ "show_stats": true
+ },
+ "height": 64,
+ "scale": 6,
+ "standalone": false
+ },
+ {
+ "name": "m64-chart",
+ "config": {
+ "enabled": true,
+ "station_id": "8443970",
+ "station_name": "BOSTON",
+ "show_current": false,
+ "show_schedule": false,
+ "show_chart": true,
+ "show_stats": false
+ },
+ "height": 64,
+ "scale": 6,
+ "standalone": false
+ },
+ {
+ "name": "u-imperial",
+ "config": {
+ "enabled": true,
+ "station_id": "8443970",
+ "station_name": "BOSTON",
+ "show_current": false,
+ "show_schedule": true,
+ "show_chart": false,
+ "show_stats": false,
+ "units": "imperial"
+ },
+ "standalone": false
+ },
+ {
+ "name": "u-metric",
+ "config": {
+ "enabled": true,
+ "station_id": "8443970",
+ "station_name": "BOSTON",
+ "show_current": false,
+ "show_schedule": true,
+ "show_chart": false,
+ "show_stats": false,
+ "units": "metric"
+ },
+ "standalone": false
+ },
+ {
+ "name": "moon-on",
+ "config": {
+ "enabled": true,
+ "station_id": "8443970",
+ "station_name": "BOSTON",
+ "show_current": false,
+ "show_schedule": false,
+ "show_chart": false,
+ "show_stats": true,
+ "show_moon_phase": true
+ },
+ "height": 64,
+ "scale": 6,
+ "standalone": false
+ },
+ {
+ "name": "moon-off",
+ "config": {
+ "enabled": true,
+ "station_id": "8443970",
+ "station_name": "BOSTON",
+ "show_current": false,
+ "show_schedule": false,
+ "show_chart": false,
+ "show_stats": true,
+ "show_moon_phase": false
+ },
+ "height": 64,
+ "scale": 6,
+ "standalone": false
+ },
+ {
+ "name": "col-default",
+ "config": {
+ "enabled": true,
+ "station_id": "8443970",
+ "station_name": "BOSTON",
+ "show_current": true,
+ "show_schedule": false,
+ "show_chart": false,
+ "show_stats": false
+ },
+ "standalone": false
+ },
+ {
+ "name": "col-teal",
+ "config": {
+ "enabled": true,
+ "station_id": "8443970",
+ "station_name": "BOSTON",
+ "show_current": true,
+ "show_schedule": false,
+ "show_chart": false,
+ "show_stats": false,
+ "tide_color": [
+ 0,
+ 140,
+ 130
+ ],
+ "highlight_color": [
+ 120,
+ 255,
+ 220
+ ]
+ },
+ "standalone": false
+ },
+ {
+ "name": "col-amber",
+ "config": {
+ "enabled": true,
+ "station_id": "8443970",
+ "station_name": "BOSTON",
+ "show_current": true,
+ "show_schedule": false,
+ "show_chart": false,
+ "show_stats": false,
+ "tide_color": [
+ 150,
+ 80,
+ 0
+ ],
+ "highlight_color": [
+ 255,
+ 200,
+ 60
+ ]
+ },
+ "standalone": false
+ },
+ {
+ "name": "col-violet",
+ "config": {
+ "enabled": true,
+ "station_id": "8443970",
+ "station_name": "BOSTON",
+ "show_current": true,
+ "show_schedule": false,
+ "show_chart": false,
+ "show_stats": false,
+ "tide_color": [
+ 70,
+ 40,
+ 150
+ ],
+ "highlight_color": [
+ 200,
+ 150,
+ 255
+ ]
+ },
+ "standalone": false
+ },
+ {
+ "name": "p-64",
+ "config": {
+ "enabled": true,
+ "station_id": "8443970",
+ "station_name": "BOSTON",
+ "show_current": true,
+ "show_schedule": false,
+ "show_chart": false,
+ "show_stats": false
+ },
+ "width": 64,
+ "scale": 8,
+ "standalone": false
+ },
+ {
+ "name": "p-128",
+ "config": {
+ "enabled": true,
+ "station_id": "8443970",
+ "station_name": "BOSTON",
+ "show_current": true,
+ "show_schedule": false,
+ "show_chart": false,
+ "show_stats": false
+ },
+ "width": 128,
+ "scale": 8,
+ "standalone": false
+ },
+ {
+ "name": "p-12864",
+ "config": {
+ "enabled": true,
+ "station_id": "8443970",
+ "station_name": "BOSTON",
+ "show_current": true,
+ "show_schedule": false,
+ "show_chart": false,
+ "show_stats": false
+ },
+ "width": 128,
+ "height": 64,
+ "scale": 8,
+ "standalone": false
+ },
+ {
+ "name": "p-256",
+ "config": {
+ "enabled": true,
+ "station_id": "8443970",
+ "station_name": "BOSTON",
+ "show_current": true,
+ "show_schedule": false,
+ "show_chart": false,
+ "show_stats": false
+ },
+ "width": 256,
+ "scale": 4,
+ "standalone": false
+ }
+ ],
+ "composites": [
+ {
+ "name": "display-modes",
+ "columns": 1,
+ "cells": [
+ {
+ "shot": "m-current",
+ "label": "current",
+ "sublabel": "live level, direction, and the next tide either side"
+ },
+ {
+ "shot": "m-schedule",
+ "label": "schedule",
+ "sublabel": "today's four tides in columns; the next one is highlighted"
+ },
+ {
+ "shot": "m-chart",
+ "label": "chart",
+ "sublabel": "the 24-hour curve with H/L markers and a now line"
+ },
+ {
+ "shot": "m-stats",
+ "label": "stats",
+ "sublabel": "moon phase, spring/neap, and today's range"
+ }
+ ]
+ },
+ {
+ "name": "taller-panel",
+ "columns": 1,
+ "cells": [
+ {
+ "shot": "m-stats",
+ "label": "stats on 128x32",
+ "sublabel": "the range line and the percentage overlap"
+ },
+ {
+ "shot": "m64-stats",
+ "label": "stats on 128x64",
+ "sublabel": "the same screen with room to breathe"
+ },
+ {
+ "shot": "m64-chart",
+ "label": "chart on 128x64",
+ "sublabel": "a taller curve is easier to read at a glance"
+ }
+ ]
+ },
+ {
+ "name": "units",
+ "columns": 2,
+ "cells": [
+ {
+ "shot": "u-imperial",
+ "label": "units: imperial",
+ "sublabel": "the default; heights in feet"
+ },
+ {
+ "shot": "u-metric",
+ "label": "units: metric",
+ "sublabel": "heights in metres"
+ }
+ ]
+ },
+ {
+ "name": "moon-phase",
+ "columns": 2,
+ "cells": [
+ {
+ "shot": "moon-on",
+ "label": "show_moon_phase: true",
+ "sublabel": "the default; icon and phase name"
+ },
+ {
+ "shot": "moon-off",
+ "label": "show_moon_phase: false",
+ "sublabel": "the rest of the stats screen keeps its place"
+ }
+ ]
+ },
+ {
+ "name": "colors",
+ "columns": 2,
+ "cells": [
+ {
+ "shot": "col-default",
+ "label": "The defaults",
+ "sublabel": "tide_color [0,100,200], highlight_color [0,220,255]"
+ },
+ {
+ "shot": "col-teal",
+ "label": "Teal",
+ "sublabel": ""
+ },
+ {
+ "shot": "col-amber",
+ "label": "Amber",
+ "sublabel": "warmer water, for a dim room"
+ },
+ {
+ "shot": "col-violet",
+ "label": "Violet",
+ "sublabel": ""
+ }
+ ]
+ },
+ {
+ "name": "panel-sizes",
+ "columns": 1,
+ "cells": [
+ {
+ "shot": "p-64",
+ "label": "64 x 32",
+ "sublabel": "the level and direction only"
+ },
+ {
+ "shot": "p-128",
+ "label": "128 x 32",
+ "sublabel": "the common two-panel chain"
+ },
+ {
+ "shot": "p-12864",
+ "label": "128 x 64",
+ "sublabel": "more water, and the text stops competing"
+ },
+ {
+ "shot": "p-256",
+ "label": "256 x 32",
+ "sublabel": "a long chain"
+ }
+ ]
+ }
+ ]
+}
diff --git a/docs/assets/tide-display/taller-panel.png b/docs/assets/tide-display/taller-panel.png
new file mode 100644
index 00000000..56c00c07
Binary files /dev/null and b/docs/assets/tide-display/taller-panel.png differ
diff --git a/docs/assets/tide-display/units.png b/docs/assets/tide-display/units.png
new file mode 100644
index 00000000..0d1ddc83
Binary files /dev/null and b/docs/assets/tide-display/units.png differ
diff --git a/plugins.json b/plugins.json
index f900d57c..c4acd492 100644
--- a/plugins.json
+++ b/plugins.json
@@ -877,10 +877,10 @@
"plugin_path": "plugins/tide-display",
"stars": 0,
"downloads": 0,
- "last_updated": "2026-07-31",
+ "last_updated": "2026-09-02",
"verified": true,
"screenshot": "",
- "latest_version": "1.2.0",
+ "latest_version": "1.3.0",
"icon": "fa-water"
},
{
diff --git a/plugins/tide-display/README.md b/plugins/tide-display/README.md
index 9c77a4b8..fff87410 100644
--- a/plugins/tide-display/README.md
+++ b/plugins/tide-display/README.md
@@ -1,70 +1,315 @@
# Tide Display
-Coastal tide information with four auto-rotating display modes.
+Live coastal tides on your LED matrix, from NOAA. Four screens — an animated
+water level, today's tide schedule, a 24-hour curve, and moon-phase stats —
+rotating on their own, for any of the ~3000 NOAA tide stations.
-**Data source:** [NOAA Tides & Currents](https://tidesandcurrents.noaa.gov/) — free, no API key required (US stations).
+
+
+*Every image in this README is real plugin output, rendered at the true panel
+size from recorded NOAA responses for station 8443970 (Boston) on 2 September
+2026. The times and heights are the real predictions for that day.*
---
-## Display Modes
+## Table of Contents
-| Mode | Shows |
-|------|-------|
-| **Current** | Animated wave-level bar, tide direction (rising/falling), current height, next two tide events |
-| **Schedule** | Today's full tide schedule in columns (up to 4 high/low tides) with mini height bars |
-| **Chart** | 24-hour filled tide curve with current-time marker and H/L labels |
-| **Stats** | Moon phase icon + name, spring/neap indicator, tidal range, cycle progress bar |
+1. [The Four Screens](#the-four-screens)
+2. [Installation](#installation)
+3. [Finding Your Station](#finding-your-station)
+4. [Configuration Reference](#configuration-reference)
+ - [Station and units](#station-and-units)
+ - [Choosing screens](#choosing-screens)
+ - [Colours](#colours)
+ - [Fonts](#fonts)
+5. [Panel Sizes](#panel-sizes)
+6. [Where the Data Comes From](#where-the-data-comes-from)
+ - [Caching and what happens offline](#caching-and-what-happens-offline)
+7. [Troubleshooting](#troubleshooting)
+8. [Development](#development)
+9. [Support](#support)
---
-## Setup
+## The Four Screens
+
+Each is a separate display mode. All four are on by default and rotate every
+`display_duration` seconds.
+
+
+
+| Screen | Setting | Shows |
+|--------|---------|-------|
+| **current** | `show_current` | The live water level with an animated surface, whether the tide is rising or falling, and the next tide either side |
+| **schedule** | `show_schedule` | Today's four tides as columns, with the next one highlighted |
+| **chart** | `show_chart` | The 24-hour prediction curve with H/L markers and a line at the current time |
+| **stats** | `show_stats` | Moon phase, whether tides are spring or neap, and today's range |
+
+The `current` screen prefers a **live** water-level reading where the station
+has a sensor, falling back to the prediction where it does not. Many stations
+are prediction-only, which is normal and not an error.
+
+---
+
+## Installation
+
+**From the Plugin Store (recommended).** Open the LEDMatrix web interface at
+`http://:5000`, go to **Plugin Manager**, find **Tide Display** in
+the **Plugin Store** section, and click **Install**.
-1. Find your nearest NOAA tide station at **[tidesandcurrents.noaa.gov/stations.html](https://tidesandcurrents.noaa.gov/stations.html)**
-2. Note the 7-digit station ID (e.g., `9447130` for Seattle, `8724580` for Key West)
-3. Enter it in the **Station ID** field in the plugin configuration
+**Manually.** Copy this directory into your LEDMatrix `plugin-repos/` and
+restart the display service.
+
+`enabled` defaults to **`false`**, and `station_id` is blank — with no station
+set the plugin draws a short setup prompt rather than failing, so a blank panel
+means it is not enabled at all.
---
-## Configuration
+## Finding Your Station
+
+Tide predictions are per-station, so this is the one setting you must supply.
+
+1. Open .
+2. Find the station nearest your stretch of coast.
+3. Copy its **7-digit ID** — for example `8443970` for Boston, `9414290` for
+ San Francisco, `8724580` for Key West.
+
+```json
+{
+ "tide-display": {
+ "enabled": true,
+ "station_id": "8443970",
+ "station_name": "BOSTON"
+ }
+}
+```
+
+`station_name` is only a label. Leave it blank and the ID is shown instead —
+useful while you are checking you picked the right station, less so afterwards.
-| Option | Default | Description |
-|--------|---------|-------------|
-| `station_id` | — | **Required.** 7-digit NOAA station ID |
-| `station_name` | `""` | Optional display name override |
-| `units` | `imperial` | `imperial` (feet) or `metric` (meters) |
-| `display_duration` | `12` | Seconds to show each mode before rotating |
-| `show_current` | `true` | Show the Current screen (animated wave level) |
-| `show_schedule` | `true` | Show the Schedule screen (today's H/L tides) |
-| `show_chart` | `true` | Show the Chart screen (24-hour tide curve) |
-| `show_stats` | `true` | Show the Stats screen (moon phase, spring/neap, range) |
-| `show_moon_phase` | `true` | Show moon phase icon on the stats screen |
+Some stations to start from:
-> Disabling every screen above falls back to showing all four — the plugin never goes blank.
-| `tide_color` | `[0,100,200]` | RGB color for tide water fill |
-| `highlight_color` | `[0,220,255]` | RGB color for wave crests and chart line |
+| Location | ID | Location | ID |
+|----------|-----|----------|-----|
+| Seattle, WA | `9447130` | Boston, MA | `8443970` |
+| San Francisco, CA | `9414290` | New York, NY | `8518750` |
+| Los Angeles, CA | `9410660` | Bar Harbor, ME | `8413320` |
+| Key West, FL | `8724580` | Galveston, TX | `8771341` |
+| Miami, FL | `8723170` | Honolulu, HI | `1612340` |
+
+Stations differ in what they offer. All have tide *predictions*; only some have
+a live water-level sensor. A station with no sensor still fills every screen,
+using predictions throughout.
---
-## Popular Station IDs
-
-| Location | Station ID |
-|----------|-----------|
-| Seattle, WA | 9447130 |
-| San Francisco, CA | 9414290 |
-| Los Angeles, CA | 9410660 |
-| Key West, FL | 8724580 |
-| Miami, FL | 8723170 |
-| Boston, MA | 8443970 |
-| New York, NY | 8518750 |
-| Bar Harbor, ME | 8413320 |
-| Galveston, TX | 8771341 |
-| Honolulu, HI | 1612340 |
+## Configuration Reference
+
+### Station and units
+
+| Option | Type | Default | What it does |
+|--------|------|---------|--------------|
+| `enabled` | boolean | `false` | Whether the plugin runs at all |
+| `station_id` | string | `""` | The 7-digit NOAA station ID |
+| `station_name` | string | `""` | Label shown instead of the ID |
+| `units` | string | `imperial` | `imperial` (feet) or `metric` (metres) |
+| `display_duration` | number | `12` | Seconds each screen holds the panel |
+
+
+
+The unit conversion is done by NOAA rather than locally — the plugin asks the
+API for `english` or `metric` and displays what comes back — so the heights are
+the authoritative figures either way, not a rounding of one into the other.
+
+### Choosing screens
+
+| Option | Default | What it does |
+|--------|---------|--------------|
+| `show_current` | `true` | The animated water-level screen |
+| `show_schedule` | `true` | Today's tide times in columns |
+| `show_chart` | `true` | The 24-hour curve |
+| `show_stats` | `true` | Moon phase and range |
+| `show_moon_phase` | `true` | The moon icon and phase name on the stats screen |
+
+
+
+Turning a screen off shortens the rotation rather than leaving a gap. With all
+four on and the default 12 seconds each, a full cycle takes just under a
+minute.
+
+**Turning all four off shows all four.** The plugin treats an empty selection
+as "no preference" rather than "show nothing", so it can never go dark by
+configuration alone.
+
+`show_moon_phase` only affects the stats screen — the rest of that screen keeps
+its layout, so the phase name is replaced by space rather than everything
+shifting up.
+
+### Colours
+
+| Option | Default | What it does |
+|--------|---------|--------------|
+| `tide_color` | `[0, 100, 200]` | The water fill |
+| `highlight_color` | `[0, 220, 255]` | Wave crests and the chart line |
+
+
+
+The two work together: `tide_color` is the body of the water and
+`highlight_color` picks out its surface, so keeping the highlight lighter than
+the fill is what makes the water read as water.
+
+### Fonts
+
+`customization.tide_text` (the figures) and `customization.label_text` (the
+HIGH/LOW captions) each take `font`, `font_size` and `text_color`.
+
+| Font | Kind | Notes |
+|------|------|-------|
+| `4x6-font.ttf` | Scalable | The default; fits the small columns well |
+| `PressStart2P-Regular.ttf` | Scalable | Chunky; readable further away, at the cost of fitting less |
+| `5by7.regular.ttf` | Scalable | A rounder 5×7 face |
+| `5x7.bdf` | Bitmap | Crisp; drawn at its native 7px |
+| `4x6.bdf` | Bitmap | Native 6px, which matches the default `font_size` |
+
+**`font_size` only affects the scalable faces.** A `.bdf` is drawn at the one
+pixel size its file declares. Defaults are `font_size: 6` with
+`tide_text.text_color` `[205, 225, 255]` and `label_text.text_color`
+`[120, 150, 200]`.
---
-## Notes
+## Panel Sizes
+
+
+
+Height helps this plugin more than width, because three of the four screens
+stack text:
+
+
+
+- **64×32** fits the level and direction; the schedule columns get tight.
+- **128×32** is the size the layouts are tuned for.
+- **128×64** is where the chart really pays off — a taller curve is far easier
+ to read at a glance — and the stats screen stops crowding.
+- **256×32** gives the schedule columns and the chart more room horizontally.
+
+> On a 32-row panel the stats screen draws the range line and the percentage
+> close enough to touch, and they overlap at some values. It is legible but
+> untidy; the chart and schedule screens are unaffected, and 64 rows clears it.
+
+---
+
+## Where the Data Comes From
+
+NOAA's Tides and Currents API (`api.tidesandcurrents.noaa.gov`). No API key and
+no account are needed.
+
+Three requests per refresh, all for the configured station:
+
+| Request | Used by |
+|---------|---------|
+| High/low predictions for today | schedule, current, stats |
+| Hourly predictions for today | chart |
+| Latest water level | current, where the station has a sensor |
+
+Predictions use the **MLLW** datum and the station's local time including
+daylight saving, which is why the times shown match published local tide
+tables.
+
+### Caching and what happens offline
+
+Requests are kept to a minimum, because a day's predictions do not change once
+published:
+
+- **Predictions** are cached under a stable per-station key and refetched only
+ when the cached entry is for a different day.
+- **The live water level** is cached for 6 minutes.
+- **If NOAA is unreachable**, the last good predictions keep being served for up
+ to **two days**. Past that the plugin shows a placeholder instead: tides shift
+ roughly 50 minutes a day, so three-day-old times would be confidently wrong,
+ which is worse than admitting there is no data.
+
+---
+
+## Troubleshooting
+
+**Nothing appears.**
+`enabled` defaults to `false`.
+
+**It shows a setup prompt.**
+`station_id` is blank. See [Finding Your Station](#finding-your-station).
+
+**"No data" or empty screens with a station set.**
+Check the ID is the 7-digit tide-station number, not a buoy or current-station
+ID from a different NOAA product. The log records the API error NOAA returned,
+which usually names the problem directly.
+
+**The current screen shows a prediction rather than a live reading.**
+Most stations have no live water-level sensor. This is expected and logged at
+debug level rather than as an error.
+
+**Heights look wrong for my area.**
+Check `units`, then check the station — neighbouring stations can differ by
+several feet, and the nearest one by road is not always the nearest by water.
+
+**The times are off by an hour.**
+Predictions come back in the station's local time including daylight saving. If
+they disagree with a published table, the table may be in standard time
+year-round.
+
+**I picked a font and nothing changed.**
+On a current version all five faces work. Older versions loaded a `.bdf` only
+when its native pixel size happened to equal `font_size`, so `4x6.bdf` worked
+at the default 6 and `5x7.bdf` silently fell back.
+
+---
+
+## Development
+
+### Project structure
+
+```text
+tide-display/
+├── manifest.json # Plugin metadata and version history
+├── manager.py # TideDisplayPlugin — all four screens
+├── config_schema.json # Settings schema; source of truth for defaults
+├── requirements.txt
+└── README.md
+```
+
+Screenshots come from `scripts/render_docs_assets.py`, which runs the **real
+plugin** against recorded NOAA responses. An earlier `render_preview.py` drew
+mock versions of the screens from its own copy of the palette — its header
+asked that the colours "must match manager.py" — so its output could drift from
+what the plugin actually draws, with nothing to catch it. It has been removed
+now that the images are generated from the plugin itself.
+
+### Regenerating the images in this README
+
+```bash
+python scripts/render_docs_assets.py --plugin tide-display
+```
+
+`--check` verifies the committed images still match. The fixture under
+`docs/assets/tide-display/fixtures/` holds real NOAA responses for both unit
+systems, matched on the query parameters, so the metric screens show genuine
+metric predictions rather than the same numbers relabelled.
+
+---
+
+## Support
+
+- YouTube:
+- Instagram:
+- Discord:
+- Sponsor: [GitHub Sponsors](https://github.com/sponsors/ChuckBuilds) ·
+ [Buy Me a Coffee](https://buymeacoffee.com/chuckbuilds) ·
+ [Ko-fi](https://ko-fi.com/chuckbuilds/)
-- Tide predictions are cached for 24 hours — minimal API usage
-- Live water level (where available) is cached for 6 minutes
-- If the station does not provide live observations, the plugin interpolates from hourly predictions
-- Not all NOAA stations provide real-time water level data; predictions are always available
+Released under the GNU General Public License v3.0 — see [LICENSE](LICENSE).
diff --git a/plugins/tide-display/manager.py b/plugins/tide-display/manager.py
index d3d8d991..378025a6 100644
--- a/plugins/tide-display/manager.py
+++ b/plugins/tide-display/manager.py
@@ -211,12 +211,42 @@ def _load_element_font(self, element_cfg: Dict):
font = ImageFont.truetype(path, size)
break
except Exception as e:
+ # A .bdf exists at exactly one pixel size and FreeType
+ # rejects any other. That made the picker's behaviour depend
+ # on font_size by coincidence: 4x6.bdf loaded because its
+ # native 6 happens to match the default, while 5x7.bdf
+ # silently fell back to the default face. Retry at the size
+ # the file declares.
+ native = self._bdf_pixel_size(path)
+ if native is not None and native != size:
+ try:
+ font = ImageFont.truetype(path, native)
+ self.logger.debug(
+ "Loaded bitmap font %s at its native size %d "
+ "(requested %d)", name, native, size)
+ break
+ except Exception:
+ pass
self.logger.warning("Could not load font %s@%d: %s", name, size, e)
if font is None and not any(os.path.exists(p) for p in candidates):
self.logger.warning("Font file not found: %s; using default font", name)
self._font_cache[key] = font
return self._font_cache[key]
+ @staticmethod
+ def _bdf_pixel_size(path):
+ """The pixel size a .bdf font declares, or None if it does not."""
+ try:
+ with open(path, "r", encoding="latin-1") as handle:
+ for line in handle:
+ if line.startswith("PIXEL_SIZE"):
+ return int(line.split()[1])
+ if line.startswith("CHARS"):
+ break # past the header
+ except (OSError, ValueError, IndexError):
+ return None
+ return None
+
def _element_style(self, color, small: bool):
"""Map a draw call's palette colour to its customization element.
diff --git a/plugins/tide-display/manifest.json b/plugins/tide-display/manifest.json
index 961781b1..1363edae 100644
--- a/plugins/tide-display/manifest.json
+++ b/plugins/tide-display/manifest.json
@@ -1,7 +1,7 @@
{
"id": "tide-display",
"name": "Tide Display",
- "version": "1.2.0",
+ "version": "1.3.0",
"author": "ChuckBuilds",
"description": "Coastal tide display with animated wave level, tide schedule, 24-hour chart, and tidal statistics. Powered by the free NOAA Tides & Currents API (US stations, no API key required).",
"entry_point": "manager.py",
@@ -25,6 +25,11 @@
">=2.0.0"
],
"versions": [
+ {
+ "version": "1.3.0",
+ "released": "2026-09-02",
+ "ledmatrix_min": "2.0.0"
+ },
{
"version": "1.2.0",
"released": "2026-07-31",
@@ -83,7 +88,7 @@
"license": "GPL-3.0",
"homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/tide-display",
"config_schema": "config_schema.json",
- "last_updated": "2026-07-31",
+ "last_updated": "2026-09-02",
"stars": 0,
"downloads": 0,
"verified": true,
diff --git a/plugins/tide-display/preview_chart.png b/plugins/tide-display/preview_chart.png
deleted file mode 100644
index 857f03af..00000000
Binary files a/plugins/tide-display/preview_chart.png and /dev/null differ
diff --git a/plugins/tide-display/preview_chart_sizes.png b/plugins/tide-display/preview_chart_sizes.png
deleted file mode 100644
index 6ac93d13..00000000
Binary files a/plugins/tide-display/preview_chart_sizes.png and /dev/null differ
diff --git a/plugins/tide-display/preview_current.png b/plugins/tide-display/preview_current.png
deleted file mode 100644
index bf5c7be2..00000000
Binary files a/plugins/tide-display/preview_current.png and /dev/null differ
diff --git a/plugins/tide-display/preview_current_sizes.png b/plugins/tide-display/preview_current_sizes.png
deleted file mode 100644
index fef9b79e..00000000
Binary files a/plugins/tide-display/preview_current_sizes.png and /dev/null differ
diff --git a/plugins/tide-display/preview_schedule.png b/plugins/tide-display/preview_schedule.png
deleted file mode 100644
index fd329195..00000000
Binary files a/plugins/tide-display/preview_schedule.png and /dev/null differ
diff --git a/plugins/tide-display/preview_sheet.png b/plugins/tide-display/preview_sheet.png
deleted file mode 100644
index da728532..00000000
Binary files a/plugins/tide-display/preview_sheet.png and /dev/null differ
diff --git a/plugins/tide-display/preview_stats.png b/plugins/tide-display/preview_stats.png
deleted file mode 100644
index 936aa36c..00000000
Binary files a/plugins/tide-display/preview_stats.png and /dev/null differ
diff --git a/plugins/tide-display/render_preview.py b/plugins/tide-display/render_preview.py
deleted file mode 100644
index 02709fdf..00000000
--- a/plugins/tide-display/render_preview.py
+++ /dev/null
@@ -1,569 +0,0 @@
-#!/usr/bin/env python3
-"""
-Render tide-display plugin preview images without needing the full LEDMatrix system.
-Outputs one PNG per mode per display size, plus a composite sheet.
-
-Usage: python3 render_preview.py
-"""
-
-import math, os
-from datetime import datetime, timedelta
-
-from PIL import Image, ImageDraw, ImageFont
-
-# ── Palette (must match manager.py) ──────────────────────────────────────────
-C_BG = ( 0, 0, 5)
-C_SKY_HORIZON = ( 0, 20, 65)
-C_WATER_TOP = ( 0, 30, 90)
-C_WATER_MID = ( 0, 65, 160)
-C_WATER_DEEP = ( 0, 40, 120)
-C_WAVE1 = ( 0, 140, 220)
-C_WAVE_CREST = (160, 240, 255)
-C_CHART_FILL = ( 0, 45, 130)
-C_CHART_LINE = ( 0, 215, 255)
-C_CHART_GLOW1 = ( 0, 110, 185)
-C_CHART_GLOW2 = ( 0, 65, 135)
-C_GRID = ( 30, 48, 96)
-C_NOW_LINE = (255, 220, 40)
-C_HIGH = (255, 195, 45)
-C_LOW = ( 75, 190, 255)
-C_RISING = ( 45, 230, 95)
-C_FALLING = (255, 75, 75)
-C_SLACK = (255, 210, 60)
-C_TEXT = (205, 225, 255)
-C_LABEL = (120, 150, 200)
-C_DIM = ( 75, 90, 120)
-C_MOON = (245, 238, 200)
-C_BAR_OUT = ( 45, 72, 130)
-C_COL_HIGH = ( 31, 23, 10)
-C_COL_LOW = ( 9, 23, 36)
-C_COL_HIGH_NEXT = ( 56, 43, 15)
-C_COL_LOW_NEXT = ( 16, 42, 61)
-# aliases for backward compat with render helpers
-C_BAR_OUTLINE = C_BAR_OUT
-
-
-def _lerp(c1, c2, t):
- return tuple(int(a + (b - a) * t) for a, b in zip(c1, c2))
-
-def _safe_iso(s):
- try:
- return datetime.fromisoformat(s)
- except (TypeError, ValueError):
- return None
-
-def _layout(dw, dh):
- c_ml, c_mr, c_mt = 3, 3, 1
- c_axis = max(9, int(dh * 0.20)) # 9px min: 1px line + 2px gap + 6px font
- row1 = 1
- row2 = max(9, int(dh * 0.28))
- row3 = max(18, int(dh * 0.55))
- row4 = max(27, int(dh * 0.78))
- wave_amp = max(2, min(5, dh // 10))
- return dict(
- c_x=c_ml, c_y=c_mt,
- c_w=dw - c_ml - c_mr,
- c_h=dh - c_axis - c_mt - 1,
- c_axis=c_axis,
- wave_amp=wave_amp,
- row1=row1, row2=row2, row3=row3, row4=row4,
- half=dw // 2,
- small=(dw <= 64), medium=(64 < dw <= 128), large=(dw > 128),
- )
-
-# ── Fake tide data (Seattle-ish semi-diurnal) ─────────────────────────────────
-_BASE = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
-
-def _make_hilo():
- """Four typical semi-diurnal tides for today."""
- return [
- {'dt': (_BASE + timedelta(hours=2, minutes=24)).isoformat(), 'height': 5.6, 'type': 'H'},
- {'dt': (_BASE + timedelta(hours=8, minutes=47)).isoformat(), 'height': 0.8, 'type': 'L'},
- {'dt': (_BASE + timedelta(hours=15, minutes=13)).isoformat(), 'height': 4.9, 'type': 'H'},
- {'dt': (_BASE + timedelta(hours=21, minutes=35)).isoformat(), 'height': 1.2, 'type': 'L'},
- ]
-
-def _make_hourly():
- """24-point cosine curve matching the hilo data."""
- hrs = []
- for h in range(24):
- v = (3.2 + 2.4 * math.cos((h - 2.4) * 2 * math.pi / 12.4)
- + 0.6 * math.cos((h - 2.4) * 2 * math.pi / 24.8))
- hrs.append(max(0.2, v))
- return hrs
-
-
-# ── Font loader ────────────────────────────────────────────────────────────────
-def _load_fonts():
- search = [
- "/var/home/chuck/Github/LEDMatrix/assets/fonts/4x6-font.ttf",
- "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
- "/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf",
- ]
- for path in search:
- if os.path.exists(path):
- try:
- tiny = ImageFont.truetype(path, 6)
- small = ImageFont.truetype(path, 7)
- return tiny, small
- except OSError:
- continue
- # Fallback to PIL default
- def_ = ImageFont.load_default()
- return def_, def_
-
-FONT_TINY, FONT_SMALL = _load_fonts()
-
-def _txt(draw, x, y, text, color=C_TEXT, font=None):
- draw.text((x, y), text, fill=color, font=font or FONT_TINY)
-
-def _txt_c(draw, cx, y, text, color=C_TEXT, font=None):
- fnt = font or FONT_TINY
- bbox = draw.textbbox((0, 0), text, font=fnt)
- w = bbox[2] - bbox[0]
- draw.text((cx - w // 2, y), text, fill=color, font=fnt)
-
-
-# ── Drawing helpers (same logic as manager.py) ─────────────────────────────────
-
-WAVE_PHASE = 24.0 # fixed phase for preview
-
-def _wave_y(px, wave_phase=WAVE_PHASE):
- p = wave_phase
- y1 = math.sin((px + p) * 0.28) * 0.85
- y2 = math.sin((px + p * 1.35) * 0.47) * 0.45
- y3 = math.sin((px + p * 0.72) * 0.71) * 0.2
- return y1 + y2 + y3
-
-def _draw_stars(draw, dw, sky_h):
- n = max(0, (dw * sky_h) // 120)
- h = 2654435761
- for i in range(n):
- h = (h ^ (i * 2246822519 + 1)) & 0xFFFFFFFF
- sx = h % dw
- sy = (h >> 16) % max(1, sky_h - 2)
- b = 18 + (h >> 8) % 34
- draw.point((sx, sy), fill=(b, b + 8, b + 22))
-
-def full_wave(canvas, draw, dw, dh, fill_ratio, wave_phase=WAVE_PHASE):
- """Full-display animated water: mirrors manager.py _full_wave()."""
- effective = min(fill_ratio, 0.18)
- fill_px = max(4, int(dh * effective))
- surf_y = dh - fill_px
-
- sky_top = (2, 4, 18)
- for py in range(surf_y + 1):
- t = py / max(surf_y, 1)
- draw.line([(0,py),(dw-1,py)], fill=_lerp(sky_top, C_SKY_HORIZON, t*t))
-
- _draw_stars(draw, dw, surf_y)
-
- for py in range(surf_y, dh):
- t = (py - surf_y) / max(fill_px, 1)
- if t < 0.5:
- color = _lerp(C_WATER_TOP, C_WATER_MID, t * 2)
- else:
- color = _lerp(C_WATER_MID, C_WATER_DEEP, (t - 0.5) * 2)
- draw.line([(0,py),(dw-1,py)], fill=color)
-
- horizon_c = _lerp(_lerp(C_SKY_HORIZON, C_WATER_TOP, 0.5), (80, 140, 220), 0.35)
- draw.line([(0, surf_y), (dw-1, surf_y)], fill=horizon_c)
-
- wave_ys = [surf_y + int(_wave_y(px, wave_phase)) for px in range(dw)]
-
- for px in range(dw):
- wy = wave_ys[px]
- if 0 <= wy < surf_y:
- bt = (math.sin((px + wave_phase * 1.3) * 0.11) + 1) * 0.5
- draw.point((px, wy), fill=_lerp(C_WAVE1, C_WAVE_CREST, bt * 0.72))
- if wy + 1 < dh:
- draw.point((px, wy+1), fill=_lerp(C_WATER_TOP, C_WAVE1, 0.7))
-
- for px in range(0, dw):
- wy_p = wave_ys[max(0, px-2)]
- wy_c = wave_ys[px]
- wy_n = wave_ys[min(dw-1, px+2)]
- if wy_c <= wy_p and wy_c <= wy_n and wy_c < surf_y:
- wy = wy_c - 1
- if 0 <= wy < dh:
- draw.point((px, wy), fill=(220, 252, 255))
-
- return surf_y
-
-def _txt_s(draw, x, y, text, color=C_TEXT, font=None):
- """Draw text with a 1px drop shadow."""
- fnt = font or FONT_TINY
- draw.text((x + 1, y + 1), text, fill=(0, 0, 8), font=fnt)
- draw.text((x, y), text, fill=color, font=fnt)
-
-def draw_arrow(draw, cx, cy, direction, sz=4):
- c = C_RISING if direction=='RISING' else C_FALLING if direction=='FALLING' else C_SLACK
- if direction == 'RISING':
- draw.polygon([(cx,cy-sz),(cx-sz,cy+sz//2),(cx+sz,cy+sz//2)], fill=c)
- elif direction == 'FALLING':
- draw.polygon([(cx,cy+sz),(cx-sz,cy-sz//2),(cx+sz,cy-sz//2)], fill=c)
- else:
- draw.line([(cx-sz,cy),(cx+sz,cy)], fill=c, width=2)
-
-def draw_moon(draw, cx, cy, r, phase):
- bbox = [cx-r, cy-r, cx+r, cy+r]
- if phase < 0.04 or phase > 0.96:
- draw.ellipse(bbox, outline=C_LABEL, width=1); return
- if 0.47 < phase < 0.53:
- draw.ellipse(bbox, fill=C_MOON, outline=C_MOON); return
- draw.ellipse(bbox, fill=C_MOON, outline=C_MOON)
- frac = abs(phase - 0.5) * 2
- dark_w = max(0, min(r*2, int(r*2*frac)))
- dx = (cx - r) if phase < 0.5 else (cx + r - dark_w)
- if dark_w > 0:
- draw.ellipse([dx, cy-r, dx+dark_w, cy+r], fill=C_BG)
- draw.ellipse(bbox, outline=_lerp(C_BG, C_MOON, 0.4), width=1)
-
-def _fmth(h, unit='ft'): return f"{h:.1f}{unit}"
-def _fmtt(iso):
- try:
- dt = datetime.fromisoformat(iso)
- hr = dt.hour % 12 or 12
- return f"{hr}:{dt.minute:02d}{'a' if dt.hour<12 else 'p'}"
- except (TypeError, ValueError):
- return '--'
-
-# ── Mode renderers ─────────────────────────────────────────────────────────────
-
-def render_current(dw, dh, hilo, hourly, phase=24.0):
- canvas = Image.new('RGB', (dw, dh), C_BG)
- draw = ImageDraw.Draw(canvas)
- L = _layout(dw, dh)
-
- heights = [e['height'] for e in hilo]
- lo_h, hi_h = min(heights), max(heights)
- cur_level = lo_h + (hi_h - lo_h) * 0.42
- fill_ratio = (cur_level - lo_h) / max(hi_h - lo_h, 0.01)
- direction = 'RISING'
-
- surf_y = full_wave(canvas, draw, dw, dh, fill_ratio, phase)
- sky_h = surf_y
-
- PAD = 2
- r1 = PAD
- r2 = r1 + 8
- r3 = r2 + 8 if (r2 + 8) < sky_h - 4 else None
- r4 = r3 + 7 if r3 and (r3 + 7) < sky_h - 4 else None
-
- dir_c = C_RISING
- _txt_s(draw, 3, r1, direction, dir_c)
- arr_x = 3 + len(direction) * 4 + 3
- if arr_x < dw // 2 - 6:
- draw_arrow(draw, arr_x, r1 + 3, direction, sz=3)
- _txt_s(draw, 3, r2, _fmth(cur_level), C_TEXT)
-
- mid = dw // 2 - 1
- if sky_h > 12:
- draw.line([(mid, PAD), (mid, sky_h - PAD)], fill=C_BAR_OUT)
-
- rx = dw // 2 + 3
- now = datetime.now()
- nexts = [e for e in hilo if _safe_iso(e['dt']) and _safe_iso(e['dt']) > now][:2]
-
- if nexts:
- t0 = nexts[0]
- sym = 'HI' if t0.get('type','?') == 'H' else 'LO'
- _txt_s(draw, rx, r1, f"{sym} {_fmtt(t0['dt'])}", C_TEXT)
- tc0 = C_HIGH if t0.get('type','?') == 'H' else C_LOW
- _txt_s(draw, rx, r2, _fmth(t0['height']), tc0)
-
- if len(nexts) >= 2 and r3 is not None:
- t1 = nexts[1]
- sym2 = 'HI' if t1.get('type','?') == 'H' else 'LO'
- tc1 = C_HIGH if t1.get('type','?') == 'H' else C_LOW
- _txt_s(draw, rx, r3, f"{sym2} {_fmtt(t1['dt'])}", C_TEXT)
- if r4 is not None:
- _txt_s(draw, rx, r4, _fmth(t1['height']), tc1)
-
- last = (r4 or r3 or r2) + 8
- if last + 5 < sky_h:
- _txt_s(draw, 3, last + 2, 'Seattle', C_LABEL)
- pct_str = f"{int(fill_ratio * 100)}%"
- pct_w = len(pct_str) * 4 + 2
- _txt_s(draw, dw - pct_w - 2, last + 2, pct_str, C_LABEL)
-
- return canvas
-
-
-def render_schedule(dw, dh, hilo):
- canvas = Image.new('RGB', (dw, dh), C_BG)
- draw = ImageDraw.Draw(canvas)
- L = _layout(dw, dh)
- now = datetime.now()
- tides = hilo[:4]
- n = len(tides)
- if n == 0: return canvas
-
- col_w = dw // n
- heights = [e['height'] for e in hilo]
- lo_h, hi_h = min(heights), max(heights)
- h_range = max(hi_h - lo_h, 0.01)
-
- # simulate next upcoming = index 1 (2nd tide, the low)
- next_idx = 1
-
- for i, tide in enumerate(tides):
- cx = i * col_w + col_w // 2
- is_high = tide.get('type','?') == 'H'
- dt = _safe_iso(tide['dt'])
- is_past = dt is not None and dt < now
- tc = C_HIGH if is_high else C_LOW
-
- if i == next_idx:
- bg = C_COL_HIGH_NEXT if is_high else C_COL_LOW_NEXT
- else:
- bg = C_COL_HIGH if is_high else C_COL_LOW
- draw.rectangle([i*col_w+1, 0, i*col_w+col_w-2, dh-3], fill=bg)
- if i == next_idx:
- draw.line([(i*col_w+1, 0), (i*col_w+col_w-2, 0)], fill=tc)
-
- type_label = ('HIGH' if is_high else 'LOW') if not L['small'] else ('H' if is_high else 'L')
- _txt_c(draw, cx, L['row1'], type_label, tc if not is_past else C_DIM, FONT_TINY)
- _txt_c(draw, cx, L['row2'], _fmtt(tide['dt']), C_TEXT if not is_past else C_DIM, FONT_TINY)
- _txt_c(draw, cx, L['row3'], _fmth(tide['height']),
- _lerp(C_LOW, C_HIGH, (tide['height']-lo_h)/h_range) if not is_past else C_DIM,
- FONT_TINY)
-
- bar_max = max(3, dh - L['row3'] - 10)
- bar_h_px = max(2, int((tide['height']-lo_h)/h_range * bar_max))
- bx1, bx2 = i*col_w+3, i*col_w+col_w-4
- bar_color = tc if not is_past else _lerp(C_DIM, tc, 0.3)
- draw.rectangle([bx1, dh-2-bar_h_px, bx2, dh-1], fill=bar_color)
- if i == next_idx:
- draw.rectangle([bx1, dh-2-bar_h_px, bx2, dh-1], outline=C_TEXT)
-
- for i in range(1, n):
- draw.line([(i*col_w, 0), (i*col_w, dh-1)], fill=C_BAR_OUT)
-
- return canvas
-
-
-def render_chart(dw, dh, hilo, hourly):
- canvas = Image.new('RGB', (dw, dh), C_BG)
- draw = ImageDraw.Draw(canvas)
- L = _layout(dw, dh)
-
- cx, cy = L['c_x'], L['c_y']
- cw, ch = L['c_w'], L['c_h']
-
- heights = hourly[:24]
- lo, hi = min(heights), max(heights)
- h_range = hi - lo or 1.0
-
- def _py(h): return cy + ch - int((h-lo)/h_range*ch)
- def _px(i): return cx + int(i * cw / max(len(heights)-1, 1))
-
- for pct in (0.25, 0.5, 0.75):
- gy = cy + ch - int(pct*ch)
- draw.line([(cx, gy), (cx+cw, gy)], fill=C_GRID)
-
- pts = [(_px(i), _py(h)) for i,h in enumerate(heights)]
-
- base_y = cy + ch
- poly = pts + [(_px(len(pts)-1), base_y), (_px(0), base_y)]
- if len(poly) >= 3:
- draw.polygon(poly, fill=C_CHART_FILL)
-
- for dy, gc in [(2, C_CHART_GLOW2), (1, C_CHART_GLOW1), (0, C_CHART_LINE)]:
- for i in range(len(pts)-1):
- x1,y1 = pts[i]; x2,y2 = pts[i+1]
- draw.line([(x1,y1+dy),(x2,y2+dy)], fill=gc, width=1)
- if dy > 0:
- draw.line([(x1,y1-dy),(x2,y2-dy)], fill=gc, width=1)
-
- for tide in hilo:
- try:
- dt = datetime.fromisoformat(tide['dt'])
- frac_hr = dt.hour + dt.minute/60.0
- tx2 = cx + int(frac_hr * cw / 23)
- ty2 = _py(tide['height'])
- is_high = tide.get('type','?') == 'H'
- lc = C_HIGH if is_high else C_LOW
- sym = 'H' if is_high else 'L'
- lx = max(cx, min(cx+cw-5, tx2-2))
- ly = max(cy, min(cy+ch-8, (ty2-9) if is_high else (ty2+2)))
- draw.text((lx, ly), sym, fill=lc, font=FONT_TINY)
- draw.line([(tx2, ty2-1),(tx2, ty2+1)], fill=(255,255,255))
- except (KeyError, ValueError, TypeError):
- continue
-
- # Current time — fix at 10:30 for preview
- now_frac = 10.5
- now_x = cx + int(now_frac * cw / 23)
- draw.line([(now_x, cy),(now_x, cy+ch)], fill=C_NOW_LINE, width=1)
- cur_h_idx = min(int(now_frac), len(heights)-1)
- cur_py = _py(heights[cur_h_idx])
- r = max(1, dh // 20)
- draw.ellipse([now_x-r, cur_py-r, now_x+r, cur_py+r], outline=C_NOW_LINE, width=1)
- if r > 1:
- draw.ellipse([now_x-r+1, cur_py-r+1, now_x+r-1, cur_py+r-1],
- fill=_lerp(C_BG, C_NOW_LINE, 0.45))
-
- ax_y = min(dh - 7, cy + ch + 2) # guarantee 7px room to bottom
- ax_labels = [(0,'12a'),(6,'6a'),(12,'12p'),(18,'6p')]
- if L['small']: ax_labels = [(0,'0'),(12,'12')]
- for lh, lt in ax_labels:
- lx = cx + int(lh * cw / 23)
- tw = len(lt) * 4
- label_x = max(0, min(dw - tw - 1, lx - tw // 2))
- draw.text((label_x, ax_y), lt, fill=C_LABEL, font=FONT_TINY)
-
- draw.line([(cx, cy+ch+1),(cx+cw, cy+ch+1)], fill=C_BAR_OUTLINE)
- return canvas
-
-
-def render_stats(dw, dh, hilo):
- canvas = Image.new('RGB', (dw, dh), C_BG)
- draw = ImageDraw.Draw(canvas)
- L = _layout(dw, dh)
-
- heights = [e['height'] for e in hilo]
- lo_h, hi_h = min(heights), max(heights)
- tidal_range = hi_h - lo_h
-
- # Waxing gibbous for the preview
- phase = 0.38
- phase_name = 'Waxing Gibbous'
- spring_label = 'NEAP TIDE'
- spring_color = C_LOW
- cycle_pct = 47
-
- moon_r = max(4, min(10, dh // 5))
- moon_cx = moon_r + 3
- moon_cy = dh // 2 - (4 if L['small'] else 6)
-
- draw_moon(draw, moon_cx, moon_cy, moon_r, phase)
- txt_x = moon_cx + moon_r + 5
-
- short_name = phase_name.replace(' Moon','').replace(' Quarter',' Qtr')
- if L['small']: short_name = short_name[:6]
- _txt(draw, txt_x, L['row1'], short_name, C_MOON, FONT_TINY)
- _txt(draw, txt_x, L['row2'], spring_label, spring_color, FONT_TINY)
- _txt(draw, txt_x, L['row3'], f"Range {tidal_range:.1f}ft", C_LOW, FONT_TINY)
- if not L['small']:
- _txt(draw, txt_x, L['row4'], f"H {hi_h:.1f} L {lo_h:.1f}ft", C_LABEL, FONT_TINY)
-
- bar_h = max(2, dh // 16)
- bar_y = dh - bar_h - 1
- bar_x0 = txt_x
- bar_x1 = dw - 3
- blen = max(1, bar_x1 - bar_x0)
- flen = int(blen * cycle_pct / 100)
- draw.rectangle([bar_x0, bar_y, bar_x1, bar_y+bar_h], fill=(0, 8, 25))
- if flen > 0:
- for px in range(flen):
- t2 = px / max(flen, 1)
- draw.line([(bar_x0+px, bar_y),(bar_x0+px, bar_y+bar_h)],
- fill=_lerp(C_LOW, C_HIGH, t2))
- # % label above the bar so it can't clip past the display bottom
- pct_str = f"{cycle_pct}%"
- pct_w = len(pct_str) * 4 + 1
- pct_x = max(bar_x0, min(dw - pct_w - 1, bar_x0 + flen - pct_w // 2))
- draw.text((pct_x, max(1, bar_y - 7)), pct_str, fill=C_LABEL, font=FONT_TINY)
-
- return canvas
-
-
-# ── Composite sheet ────────────────────────────────────────────────────────────
-
-def make_sheet(sizes, hilo, hourly):
- modes = ['current', 'schedule', 'chart', 'stats']
- mode_names = ['Current', 'Schedule', 'Chart', 'Stats']
-
- SCALE = 4 # enlarge each pixel so details are visible
- PAD = 6 # padding between cells
- LABEL_H = 12 # height of text label above each cell
- HEADER_H = 18 # column header height
- LEFT_W = 60 # row label area
-
- cells_w = max(dw for dw,_ in sizes)
- cells_h = max(dh for _,dh in sizes)
- n_modes = len(modes)
- n_sizes = len(sizes)
-
- sheet_w = LEFT_W + n_modes * (cells_w * SCALE + PAD) + PAD
- sheet_h = HEADER_H + n_sizes * (LABEL_H + cells_h * SCALE + PAD) + PAD
-
- sheet = Image.new('RGB', (sheet_w, sheet_h), (12, 12, 20))
- sdraw = ImageDraw.Draw(sheet)
-
- # Column headers
- for col, (m, mn) in enumerate(zip(modes, mode_names)):
- hx = LEFT_W + PAD + col * (cells_w * SCALE + PAD) + (cells_w * SCALE) // 2
- sdraw.text((hx - len(mn)*3, 4), mn, fill=(180, 200, 240), font=FONT_TINY)
-
- # Rows
- for row, (dw, dh) in enumerate(sizes):
- ry = HEADER_H + row * (LABEL_H + cells_h * SCALE + PAD)
- size_label = f"{dw}×{dh}"
- sdraw.text((4, ry + LABEL_H + cells_h * SCALE // 2 - 3),
- size_label, fill=(120, 140, 180), font=FONT_TINY)
-
- for col, mode in enumerate(modes):
- cx = LEFT_W + PAD + col * (cells_w * SCALE + PAD)
- cy = ry + LABEL_H
-
- # Render at native size
- if mode == 'current': img = render_current(dw, dh, hilo, hourly, phase=22)
- elif mode == 'schedule': img = render_schedule(dw, dh, hilo)
- elif mode == 'chart': img = render_chart(dw, dh, hilo, hourly)
- else: img = render_stats(dw, dh, hilo)
-
- # Scale up (nearest neighbour to preserve LED pixel look)
- big = img.resize((dw * SCALE, dh * SCALE), Image.NEAREST)
-
- # Centre within column
- ox = cx + (cells_w * SCALE - dw * SCALE) // 2
- oy = cy + (cells_h * SCALE - dh * SCALE) // 2
- sheet.paste(big, (ox, oy))
-
- # Dim border around cell area
- sdraw.rectangle([ox - 1, oy - 1,
- ox + dw * SCALE, oy + dh * SCALE],
- outline=(30, 40, 60))
-
- return sheet
-
-
-if __name__ == '__main__':
- out_dir = os.path.dirname(os.path.abspath(__file__))
- hilo = _make_hilo()
- hourly = _make_hourly()
-
- sizes = [
- (64, 32),
- (128, 32),
- (192, 48),
- (256, 64),
- ]
-
- print("Rendering tide display previews …")
-
- sheet = make_sheet(sizes, hilo, hourly)
- out = os.path.join(out_dir, 'preview_sheet.png')
- sheet.save(out)
- print(f" Saved: {out} ({sheet.width}×{sheet.height})")
-
- # Also save individual mode PNGs at 192×48 (most common)
- dw, dh = 192, 48
- SCALE = 5
- for mode, fn in [('current','preview_current.png'),
- ('schedule','preview_schedule.png'),
- ('chart','preview_chart.png'),
- ('stats','preview_stats.png')]:
- if mode == 'current': img = render_current(dw, dh, hilo, hourly, phase=22)
- elif mode == 'schedule': img = render_schedule(dw, dh, hilo)
- elif mode == 'chart': img = render_chart(dw, dh, hilo, hourly)
- else: img = render_stats(dw, dh, hilo)
-
- big = img.resize((dw*SCALE, dh*SCALE), Image.NEAREST)
- path = os.path.join(out_dir, fn)
- big.save(path)
- print(f" Saved: {path}")
-
- print("Done.")
diff --git a/scripts/docs_render_support/_docs_http_replay.py b/scripts/docs_render_support/_docs_http_replay.py
index 9082ba5f..bdcefb26 100644
--- a/scripts/docs_render_support/_docs_http_replay.py
+++ b/scripts/docs_render_support/_docs_http_replay.py
@@ -12,6 +12,16 @@
{"url_contains": "baseball/mlb/scoreboard", "body": { ...ESPN JSON... }}
]}
+An entry may also carry ``params_contain``, for an API that puts several
+endpoints behind one URL and tells them apart by query string::
+
+ {"url_contains": "datagetter",
+ "params_contain": {"product": "water_level"},
+ "body": { ... }}
+
+Entries are tried in order and the first whose URL *and* params both match
+wins, so put the more specific entries first.
+
Only matching URLs are intercepted. Everything else -- logo downloads in
particular -- goes to the real network untouched.
"""
@@ -65,24 +75,31 @@ def install():
if not matches:
return
- def _match(url):
+ _MISS = object()
+
+ def _match(url, params):
+ params = params or {}
for entry in matches:
- if entry.get("url_contains", "") in url:
- return entry.get("body")
- return None
+ if entry.get("url_contains", "") not in url:
+ continue
+ wanted = entry.get("params_contain") or {}
+ if any(str(params.get(key)) != str(value) for key, value in wanted.items()):
+ continue
+ return entry.get("body")
+ return _MISS
real_session_get = requests.Session.get
real_get = requests.get
def session_get(self, url, *args, **kwargs):
- body = _match(str(url))
- if body is None:
+ body = _match(str(url), kwargs.get("params"))
+ if body is _MISS:
return real_session_get(self, url, *args, **kwargs)
return _ReplayResponse(body)
def plain_get(url, *args, **kwargs):
- body = _match(str(url))
- if body is None:
+ body = _match(str(url), kwargs.get("params"))
+ if body is _MISS:
return real_get(url, *args, **kwargs)
return _ReplayResponse(body)