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) | tide-display on an LED panel | ### 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). +![The current-tide screen on a 128x32 panel: FALLING, 6.8ft, with the next low +tide at 9:57p](../../docs/assets/tide-display/hero.png) + +*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. + +![The four screens on a 128x32 panel: current, schedule, chart and +stats](../../docs/assets/tide-display/display-modes.png) + +| 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 schedule screen in feet and in metres](../../docs/assets/tide-display/units.png) + +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 | + +![The stats screen with the moon phase on and off](../../docs/assets/tide-display/moon-phase.png) + +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 current screen in the default blue, teal, amber and +violet](../../docs/assets/tide-display/colors.png) + +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 + +![The current screen on 64x32, 128x32, 128x64 and 256x32 +panels](../../docs/assets/tide-display/panel-sizes.png) + +Height helps this plugin more than width, because three of the four screens +stack text: + +![The stats screen at 32 and 64 rows, and the chart at 64 +rows](../../docs/assets/tide-display/taller-panel.png) + +- **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)