Home Assistant requests in a task, progress bar, Plex page, bitmap cache

- net/ha_api: the HTTP requests run in a FreeRTOS task on core 0; loop()
  only hands over copies through queues. The web UI's test button polls.
  No more 3-4 s loop() stalls once a minute; timeout raised to 5 s.
- Progress bar page element (page format 4, v3 pages are converted)
- Plex user setting (settings v6): {plex_user} in entity ids
- Plex page (examples): watchers, play/pause icon, title, progress
- Bitmaps cached in RAM: a LittleFS read took ~100 ms on every render
  and web preview; long JSON lists sent in 2 KB pieces
- Backup includes the refresh settings and the Plex user

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-16 16:17:23 +02:00
1 parent 090c2bf8ae
commit 490cefc360
42 files changed
+1074 -175

No files matched your search

+12 -2
View File
@@ -172,8 +172,18 @@ See README.md for the overview.
after adding features; the web page could be served gzipped if it gets tight. after adding features; the web page could be served gzipped if it gets tight.
- Fonts use the "FDF1" format of `logic/font.h`; the web page writes the same format. - Fonts use the "FDF1" format of `logic/font.h`; the web page writes the same format.
`logic/builtin_fonts.cpp` is generated: change `tools/make_builtin_fonts.py` and rerun it. `logic/builtin_fonts.cpp` is generated: change `tools/make_builtin_fonts.py` and rerun it.
- Home Assistant values come from HA's REST API (`net/ha_api`), one blocking request per - Home Assistant values come from HA's REST API (`net/ha_api`). The HTTP requests run in
`loop()` with `HA_HTTP_TIMEOUT`. Keep it that way (watchdog, OTA). their own FreeRTOS task (core 0, `haApiBegin()`), one at a time; everything else
(entries, watching, values) stays in `loop()`. The task gets copies of what it needs and
answers through a queue of plain structs: never share Strings or other state with it,
and never wait for it in `loop()`. The web UI's test button polls (`{"pending":true}`).
The web server stays in `loop()` on purpose (its handlers touch everything).
- Entity ids may contain `{plex_user}` (`expandEntity()` in `logic/page`), filled in by
`net/ha_api` with `settings.plex_user` in entity id form (`plexUserSlug()`).
- Bitmaps are cached in RAM (`display/bitmap_store`, also missing names): a LittleFS read
takes about 100 ms, and pages are drawn for every frame and every web preview. Writes
must go through `saveBitmap()`/`deleteBitmap()`, which clear the cache. Long JSON lists
are sent in pieces of about 2 KB (`sendPiece()` in `web_ui.cpp`).
- Check the page's JavaScript after changes: extract the `<script>` part and run - Check the page's JavaScript after changes: extract the `<script>` part and run
`node --check` on it (node is installed). `node --check` on it (node is installed).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 403 KiB

After

Width:  |  Height:  |  Size: 405 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 338 KiB

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 153 KiB

+20 -2
View File
@@ -37,8 +37,10 @@ its attributes, as a number (multiplier, decimals, auto unit, without minus sign
duration, a time from a timestamp, or with own texts per state (see the duration, a time from a timestamp, or with own texts per state (see the
[web UI](web-ui.md#pages)). [web UI](web-ui.md#pages)).
**Home Assistant values** on the shown page are read every minute (one entity per loop, **Home Assistant values** on the shown page are read every minute, one entity at a time,
at most about 3 s). An unknown or unreachable value shows `--`. The display only changes by a task of their own (on the ESP32's other core), so a slow or unreachable Home
Assistant never holds up the display or the web UI. An unknown or unreachable value shows
`--`. The display only changes
when the shown text changes, so round values with the decimals setting. when the shown text changes, so round values with the decimals setting.
## Rules ## Rules
@@ -47,3 +49,19 @@ The [schedule](web-ui.md#schedule) can bring a page to the front from a Home Ass
state, e.g. the 3D printer page while `sensor.mainsailos_current_print_state` is state, e.g. the 3D printer page while `sensor.mainsailos_current_print_state` is
`printing`. Each rule is read at its own interval; when Home Assistant can't be reached it `printing`. Each rule is read at its own interval; when Home Assistant can't be reached it
is read once a minute at most, so the display keeps running. is read once a minute at most, so the display keeps running.
## Plex and Tautulli
The **Plex** page (in [examples/home-pages.json](../examples/home-pages.json)) uses Home
Assistant's Plex and Tautulli integrations, no extra firmware:
- `sensor.mangavalkserver` (Plex): how many are watching.
- `sensor.{plex_user}_state` (Tautulli, per user): `playing`, `paused`, ... chooses the icon
(bitmaps `plex playing`, `plex paused`, `plex buffering`).
- `sensor.{plex_user}_full_title` and `sensor.{plex_user}_progress` (Tautulli, per user):
the title and the progress bar. Home Assistant creates these disabled: enable them on
the Tautulli user's device in Home Assistant.
`{plex_user}` is the Plex user set in the web UI (Home Assistant tab), written the way Home
Assistant puts it in entity ids (`MangaValk` -> `mangavalk`). A rule (Schedule tab) adds
the page to the rotation while `sensor.mangavalkserver` is above 0.
+1
View File
@@ -20,3 +20,4 @@ the others are the example pages in [examples/home-pages.json](../examples/home-
| **Datum**: weekday and date | ![Datum](../Pictures/pages/datum.png) | | **Datum**: weekday and date | ![Datum](../Pictures/pages/datum.png) |
| **Aftellen**: countdown to Christmas | ![Aftellen](../Pictures/pages/aftellen.png) | | **Aftellen**: countdown to Christmas | ![Aftellen](../Pictures/pages/aftellen.png) |
| **Stopwatch** | ![Stopwatch](../Pictures/pages/stopwatch.png) | | **Stopwatch** | ![Stopwatch](../Pictures/pages/stopwatch.png) |
| **Plex**: how many watch, play/pause, title (scrolls) and progress of the Plex user ([Tautulli](home-assistant.md#plex-and-tautulli)) | ![Plex](../Pictures/pages/plex.png) |
+5 -3
View File
@@ -36,9 +36,11 @@ arrow keys. Built-in pages can be copied, not changed. See the [pictures of the
| Countdown | To a date and time: smart (`12 dagen`, `5 uur`, `34 min`, `0:45`), days, `12d 04:05`, with seconds; a text when it has passed | | Countdown | To a date and time: smart (`12 dagen`, `5 uur`, `34 min`, `0:45`), days, `12d 04:05`, with seconds; a text when it has passed |
| Stopwatch | `4:05`, `0:04`, `0:04:05`; started in the [Stopwatch](#stopwatch) tab or Home Assistant | | Stopwatch | `4:05`, `0:04`, `0:04:05`; started in the [Stopwatch](#stopwatch) tab or Home Assistant |
| Image | A bitmap at a position, fixed or chosen by a Home Assistant state (`weer ` + `cloudy` = the bitmap `weer cloudy`) | | Image | A bitmap at a position, fixed or chosen by a Home Assistant state (`weer ` + `cloudy` = the bitmap `weer cloudy`) |
| Progress bar | Filled from the left by a Home Assistant number: value x multiplier / "full at" (a percentage: full at 100). Width, height, with or without a frame |
Every element has a position, alignment, font and overlay (on top, box that clears the Every element has a position, alignment, font and overlay (on top, box that clears the
dots behind it, inverted). Texts can scroll in a window at a chosen speed and can contain dots behind it, inverted). Entity ids can contain `{plex_user}`, replaced by the Plex user
of the Home Assistant tab (e.g. `sensor.{plex_user}_state`). Texts can scroll in a window at a chosen speed and can contain
the degree sign. Fonts: Small (3x5), Normal (5x7), Large (5x7 doubled), Full height (19 dot the degree sign. Fonts: Small (3x5), Normal (5x7), Large (5x7 doubled), Full height (19 dot
digits), and your own: upload a TTF/OTF, choose the height in dots, the browser converts digits), and your own: upload a TTF/OTF, choose the height in dots, the browser converts
it. it.
@@ -98,8 +100,8 @@ switches either off); test patterns; the status LED.
MQTT: on/off, broker, port, user, password (changing these restarts the ESP32; an empty MQTT: on/off, broker, port, user, password (changing these restarts the ESP32; an empty
broker uses `secrets.h`). The URL and long-lived access token for the values on dynamic broker uses `secrets.h`). The URL and long-lived access token for the values on dynamic
pages and the rules (REST API, plain http; empty URL: `http://<broker>:8123`). See pages and the rules (REST API, plain http; empty URL: `http://<broker>:8123`). The Plex
[Home Assistant](home-assistant.md). user for `{plex_user}` in entity ids. See [Home Assistant](home-assistant.md).
![Home Assistant](../Pictures/web-ui/home-assistant.png) ![Home Assistant](../Pictures/web-ui/home-assistant.png)
+268 -31
View File
@@ -1,8 +1,8 @@
{ {
"format": "flipdot-backup", "format": "flipdot-backup",
"version": 1, "version": 1,
"firmware": "2.5.0", "firmware": "2.9.0",
"exported": "2026-10-15T00:00:00Z", "exported": "2026-10-16T00:00:00Z",
"bitmaps": [ "bitmaps": [
{ {
"name": "weer sunny", "name": "weer sunny",
@@ -107,6 +107,22 @@
{ {
"name": "bg vol", "name": "bg vol",
"data": "fffffffffffffffffffff0fffffffffffffffffffff0fffffffffffffffffffff0fffffffffffffffffffff0fffffffffffffffffffff0fffffffffffffffffffff0fffffffffffffffffffff0fffffffffffffffffffff0fffffffffffffffffffff0fffffffffffffffffffff0fffffffffffffffffffff0fffffffffffffffffffff0fffffffffffffffffffff0fffffffffffffffffffff0fffffffffffffffffffff0fffffffffffffffffffff0fffffffffffffffffffff0fffffffffffffffffffff0fffffffffffffffffffff0" "data": "fffffffffffffffffffff0fffffffffffffffffffff0fffffffffffffffffffff0fffffffffffffffffffff0fffffffffffffffffffff0fffffffffffffffffffff0fffffffffffffffffffff0fffffffffffffffffffff0fffffffffffffffffffff0fffffffffffffffffffff0fffffffffffffffffffff0fffffffffffffffffffff0fffffffffffffffffffff0fffffffffffffffffffff0fffffffffffffffffffff0fffffffffffffffffffff0fffffffffffffffffffff0fffffffffffffffffffff0fffffffffffffffffffff0"
},
{
"name": "plex logo",
"data": "0000000000000000000000f800000000000000000000f8000000000000000000007c000000000000000000007c000000000000000000003e000000000000000000003e000000000000000000001f000000000000000000001f000000000000000000000f800000000000000000001f000000000000000000001f000000000000000000003e000000000000000000003e000000000000000000007c000000000000000000007c00000000000000000000f800000000000000000000f8000000000000000000000000000000000000000000"
},
{
"name": "plex playing",
"data": "8000000000000000000000c000000000000000000000e000000000000000000000c000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
},
{
"name": "plex paused",
"data": "d800000000000000000000d800000000000000000000d800000000000000000000d800000000000000000000d80000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
},
{
"name": "plex buffering",
"data": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a80000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
} }
], ],
"pages": [ "pages": [
@@ -132,7 +148,11 @@
"factor": 1, "factor": 1,
"scroll_width": 0, "scroll_width": 0,
"scroll_speed": 8, "scroll_speed": 8,
"target": "2000-01-01T00:00" "target": "2000-01-01T00:00",
"bar_width": 40,
"bar_height": 3,
"bar_max": 100,
"bar_frame": true
}, },
{ {
"type": "image", "type": "image",
@@ -151,7 +171,11 @@
"factor": 1, "factor": 1,
"scroll_width": 0, "scroll_width": 0,
"scroll_speed": 8, "scroll_speed": 8,
"target": "2000-01-01T00:00" "target": "2000-01-01T00:00",
"bar_width": 40,
"bar_height": 3,
"bar_max": 100,
"bar_frame": true
}, },
{ {
"type": "ha", "type": "ha",
@@ -170,7 +194,11 @@
"factor": 1, "factor": 1,
"scroll_width": 0, "scroll_width": 0,
"scroll_speed": 8, "scroll_speed": 8,
"target": "2000-01-01T00:00" "target": "2000-01-01T00:00",
"bar_width": 40,
"bar_height": 3,
"bar_max": 100,
"bar_frame": true
}, },
{ {
"type": "ha", "type": "ha",
@@ -189,7 +217,11 @@
"factor": 1, "factor": 1,
"scroll_width": 0, "scroll_width": 0,
"scroll_speed": 8, "scroll_speed": 8,
"target": "2000-01-01T00:00" "target": "2000-01-01T00:00",
"bar_width": 40,
"bar_height": 3,
"bar_max": 100,
"bar_frame": true
} }
] ]
} }
@@ -225,7 +257,11 @@
"factor": 1, "factor": 1,
"scroll_width": 0, "scroll_width": 0,
"scroll_speed": 8, "scroll_speed": 8,
"target": "2000-01-01T00:00" "target": "2000-01-01T00:00",
"bar_width": 40,
"bar_height": 3,
"bar_max": 100,
"bar_frame": true
}, },
{ {
"type": "date", "type": "date",
@@ -244,7 +280,11 @@
"factor": 1, "factor": 1,
"scroll_width": 0, "scroll_width": 0,
"scroll_speed": 8, "scroll_speed": 8,
"target": "2000-01-01T00:00" "target": "2000-01-01T00:00",
"bar_width": 40,
"bar_height": 3,
"bar_max": 100,
"bar_frame": true
} }
] ]
} }
@@ -276,7 +316,11 @@
"factor": 1, "factor": 1,
"scroll_width": 0, "scroll_width": 0,
"scroll_speed": 8, "scroll_speed": 8,
"target": "2000-01-01T00:00" "target": "2000-01-01T00:00",
"bar_width": 40,
"bar_height": 3,
"bar_max": 100,
"bar_frame": true
}, },
{ {
"type": "ha", "type": "ha",
@@ -295,7 +339,11 @@
"factor": 0.001, "factor": 0.001,
"scroll_width": 0, "scroll_width": 0,
"scroll_speed": 8, "scroll_speed": 8,
"target": "2000-01-01T00:00" "target": "2000-01-01T00:00",
"bar_width": 40,
"bar_height": 3,
"bar_max": 100,
"bar_frame": true
}, },
{ {
"type": "clock", "type": "clock",
@@ -314,7 +362,11 @@
"factor": 1, "factor": 1,
"scroll_width": 0, "scroll_width": 0,
"scroll_speed": 8, "scroll_speed": 8,
"target": "2000-01-01T00:00" "target": "2000-01-01T00:00",
"bar_width": 40,
"bar_height": 3,
"bar_max": 100,
"bar_frame": true
} }
] ]
} }
@@ -346,7 +398,11 @@
"factor": 1, "factor": 1,
"scroll_width": 0, "scroll_width": 0,
"scroll_speed": 8, "scroll_speed": 8,
"target": "2000-01-01T00:00" "target": "2000-01-01T00:00",
"bar_width": 40,
"bar_height": 3,
"bar_max": 100,
"bar_frame": true
}, },
{ {
"type": "ha", "type": "ha",
@@ -365,7 +421,11 @@
"factor": 0.016666666666666666, "factor": 0.016666666666666666,
"scroll_width": 0, "scroll_width": 0,
"scroll_speed": 8, "scroll_speed": 8,
"target": "2000-01-01T00:00" "target": "2000-01-01T00:00",
"bar_width": 40,
"bar_height": 3,
"bar_max": 100,
"bar_frame": true
}, },
{ {
"type": "ha", "type": "ha",
@@ -384,7 +444,11 @@
"factor": 1, "factor": 1,
"scroll_width": 46, "scroll_width": 46,
"scroll_speed": 6, "scroll_speed": 6,
"target": "2000-01-01T00:00" "target": "2000-01-01T00:00",
"bar_width": 40,
"bar_height": 3,
"bar_max": 100,
"bar_frame": true
}, },
{ {
"type": "ha", "type": "ha",
@@ -403,7 +467,11 @@
"factor": 1, "factor": 1,
"scroll_width": 0, "scroll_width": 0,
"scroll_speed": 8, "scroll_speed": 8,
"target": "2000-01-01T00:00" "target": "2000-01-01T00:00",
"bar_width": 40,
"bar_height": 3,
"bar_max": 100,
"bar_frame": true
} }
] ]
} }
@@ -435,7 +503,11 @@
"factor": 1, "factor": 1,
"scroll_width": 0, "scroll_width": 0,
"scroll_speed": 8, "scroll_speed": 8,
"target": "2000-01-01T00:00" "target": "2000-01-01T00:00",
"bar_width": 40,
"bar_height": 3,
"bar_max": 100,
"bar_frame": true
}, },
{ {
"type": "image", "type": "image",
@@ -454,7 +526,11 @@
"factor": 1, "factor": 1,
"scroll_width": 0, "scroll_width": 0,
"scroll_speed": 8, "scroll_speed": 8,
"target": "2000-01-01T00:00" "target": "2000-01-01T00:00",
"bar_width": 40,
"bar_height": 3,
"bar_max": 100,
"bar_frame": true
}, },
{ {
"type": "ha", "type": "ha",
@@ -473,7 +549,11 @@
"factor": 1, "factor": 1,
"scroll_width": 0, "scroll_width": 0,
"scroll_speed": 8, "scroll_speed": 8,
"target": "2000-01-01T00:00" "target": "2000-01-01T00:00",
"bar_width": 40,
"bar_height": 3,
"bar_max": 100,
"bar_frame": true
}, },
{ {
"type": "ha", "type": "ha",
@@ -492,7 +572,11 @@
"factor": 1, "factor": 1,
"scroll_width": 0, "scroll_width": 0,
"scroll_speed": 8, "scroll_speed": 8,
"target": "2000-01-01T00:00" "target": "2000-01-01T00:00",
"bar_width": 40,
"bar_height": 3,
"bar_max": 100,
"bar_frame": true
}, },
{ {
"type": "ha", "type": "ha",
@@ -511,7 +595,11 @@
"factor": 1, "factor": 1,
"scroll_width": 0, "scroll_width": 0,
"scroll_speed": 8, "scroll_speed": 8,
"target": "2000-01-01T00:00" "target": "2000-01-01T00:00",
"bar_width": 40,
"bar_height": 3,
"bar_max": 100,
"bar_frame": true
} }
] ]
} }
@@ -543,7 +631,11 @@
"factor": 1000, "factor": 1000,
"scroll_width": 0, "scroll_width": 0,
"scroll_speed": 8, "scroll_speed": 8,
"target": "2000-01-01T00:00" "target": "2000-01-01T00:00",
"bar_width": 40,
"bar_height": 3,
"bar_max": 100,
"bar_frame": true
}, },
{ {
"type": "ha", "type": "ha",
@@ -562,7 +654,11 @@
"factor": 0.001, "factor": 0.001,
"scroll_width": 0, "scroll_width": 0,
"scroll_speed": 8, "scroll_speed": 8,
"target": "2000-01-01T00:00" "target": "2000-01-01T00:00",
"bar_width": 40,
"bar_height": 3,
"bar_max": 100,
"bar_frame": true
}, },
{ {
"type": "clock", "type": "clock",
@@ -581,7 +677,11 @@
"factor": 1, "factor": 1,
"scroll_width": 0, "scroll_width": 0,
"scroll_speed": 8, "scroll_speed": 8,
"target": "2000-01-01T00:00" "target": "2000-01-01T00:00",
"bar_width": 40,
"bar_height": 3,
"bar_max": 100,
"bar_frame": true
}, },
{ {
"type": "ha", "type": "ha",
@@ -600,7 +700,11 @@
"factor": 0.001, "factor": 0.001,
"scroll_width": 0, "scroll_width": 0,
"scroll_speed": 8, "scroll_speed": 8,
"target": "2000-01-01T00:00" "target": "2000-01-01T00:00",
"bar_width": 40,
"bar_height": 3,
"bar_max": 100,
"bar_frame": true
} }
] ]
} }
@@ -636,7 +740,11 @@
"factor": 1, "factor": 1,
"scroll_width": 0, "scroll_width": 0,
"scroll_speed": 8, "scroll_speed": 8,
"target": "2000-01-01T00:00" "target": "2000-01-01T00:00",
"bar_width": 40,
"bar_height": 3,
"bar_max": 100,
"bar_frame": true
} }
] ]
} }
@@ -668,7 +776,11 @@
"factor": 1, "factor": 1,
"scroll_width": 0, "scroll_width": 0,
"scroll_speed": 8, "scroll_speed": 8,
"target": "2000-01-01T00:00" "target": "2000-01-01T00:00",
"bar_width": 40,
"bar_height": 3,
"bar_max": 100,
"bar_frame": true
}, },
{ {
"type": "date", "type": "date",
@@ -687,7 +799,11 @@
"factor": 1, "factor": 1,
"scroll_width": 0, "scroll_width": 0,
"scroll_speed": 8, "scroll_speed": 8,
"target": "2000-01-01T00:00" "target": "2000-01-01T00:00",
"bar_width": 40,
"bar_height": 3,
"bar_max": 100,
"bar_frame": true
}, },
{ {
"type": "clock", "type": "clock",
@@ -706,7 +822,11 @@
"factor": 1, "factor": 1,
"scroll_width": 0, "scroll_width": 0,
"scroll_speed": 8, "scroll_speed": 8,
"target": "2000-01-01T00:00" "target": "2000-01-01T00:00",
"bar_width": 40,
"bar_height": 3,
"bar_max": 100,
"bar_frame": true
} }
] ]
} }
@@ -738,7 +858,11 @@
"factor": 1, "factor": 1,
"scroll_width": 0, "scroll_width": 0,
"scroll_speed": 8, "scroll_speed": 8,
"target": "2000-01-01T00:00" "target": "2000-01-01T00:00",
"bar_width": 40,
"bar_height": 3,
"bar_max": 100,
"bar_frame": true
}, },
{ {
"type": "countdown", "type": "countdown",
@@ -757,7 +881,11 @@
"factor": 1, "factor": 1,
"scroll_width": 70, "scroll_width": 70,
"scroll_speed": 8, "scroll_speed": 8,
"target": "2026-12-25T00:00" "target": "2026-12-25T00:00",
"bar_width": 40,
"bar_height": 3,
"bar_max": 100,
"bar_frame": true
} }
] ]
} }
@@ -784,7 +912,116 @@
"factor": 1, "factor": 1,
"scroll_width": 0, "scroll_width": 0,
"scroll_speed": 8, "scroll_speed": 8,
"target": "2000-01-01T00:00" "target": "2000-01-01T00:00",
"bar_width": 40,
"bar_height": 3,
"bar_max": 100,
"bar_frame": true
}
]
}
},
{
"name": "Plex",
"page": {
"slots": [
{
"start": "00:00",
"bitmap": "plex logo"
}
],
"elements": [
{
"type": "ha",
"x": 13,
"y": 0,
"align": "left",
"overlay": "top",
"font": "Normal",
"format": 0,
"language": "nl",
"decimals": -1,
"text": "",
"suffix": " watching",
"entity": "sensor.mangavalkserver",
"attribute": "",
"factor": 1,
"scroll_width": 0,
"scroll_speed": 8,
"target": "2000-01-01T00:00",
"bar_width": 40,
"bar_height": 3,
"bar_max": 100,
"bar_frame": true
},
{
"type": "image",
"x": 13,
"y": 9,
"align": "left",
"overlay": "top",
"font": "Normal",
"format": 0,
"language": "nl",
"decimals": -1,
"text": "plex ",
"suffix": "",
"entity": "sensor.{plex_user}_state",
"attribute": "",
"factor": 1,
"scroll_width": 0,
"scroll_speed": 8,
"target": "2000-01-01T00:00",
"bar_width": 40,
"bar_height": 3,
"bar_max": 100,
"bar_frame": true
},
{
"type": "ha",
"x": 19,
"y": 9,
"align": "left",
"overlay": "top",
"font": "Small",
"format": 0,
"language": "nl",
"decimals": -1,
"text": "",
"suffix": "",
"entity": "sensor.{plex_user}_full_title",
"attribute": "",
"factor": 1,
"scroll_width": 65,
"scroll_speed": 8,
"target": "2000-01-01T00:00",
"bar_width": 40,
"bar_height": 3,
"bar_max": 100,
"bar_frame": true
},
{
"type": "bar",
"x": 13,
"y": 16,
"align": "left",
"overlay": "top",
"font": "Normal",
"format": 0,
"language": "nl",
"decimals": -1,
"text": "",
"suffix": "",
"entity": "sensor.{plex_user}_progress",
"attribute": "",
"factor": 1,
"scroll_width": 0,
"scroll_speed": 8,
"target": "2000-01-01T00:00",
"bar_width": 71,
"bar_height": 3,
"bar_max": 100,
"bar_frame": true
} }
] ]
} }
+4 -1
View File
@@ -91,7 +91,10 @@ static void apply()
controller.setEffectTime(effectTime(settings.drive)); controller.setEffectTime(effectTime(settings.drive));
status.setDrive(settings.flip_time, settings.drive, settings.refresh.dots); status.setDrive(settings.flip_time, settings.drive, settings.refresh.dots);
frames.setDrive(settings.flip_time, settings.drive); frames.setDrive(settings.flip_time, settings.drive);
haApiSetSettings(settings.home_assistant); char user[PLEX_USER_SIZE];
plexUserSlug(settings.plex_user, user, sizeof(user));
haApiSetSettings(settings.home_assistant, user);
haApiWatchRules(&page_schedule); // {plex_user} may have changed
applyOffPage(); applyOffPage();
applyPage(); applyPage();
} }
+9 -3
View File
@@ -46,11 +46,17 @@ const uint32_t LIVE_TIMEOUT = 30UL * 60 * 1000;
// Power off this long after the display is no longer needed (night, standby) // Power off this long after the display is no longer needed (night, standby)
const uint32_t POWER_OFF_DELAY = 30000; const uint32_t POWER_OFF_DELAY = 30000;
// Home Assistant values on dynamic pages (net/ha_api): read this often, and the HTTP // Home Assistant values on dynamic pages (net/ha_api): read this often, and the HTTP
// timeout (a read blocks loop() for at most about twice this) // timeout (connect, then the answer; the requests run in their own task, loop() doesn't wait)
const uint32_t HA_POLL_INTERVAL = 60000; const uint32_t HA_POLL_INTERVAL = 60000;
const uint32_t HA_HTTP_TIMEOUT = 1500; const uint32_t HA_HTTP_TIMEOUT = 5000;
// After a failed read (Home Assistant down) an entity is read again after this at the soonest // After a failed read (Home Assistant down) an entity is read again after this at the soonest
const uint32_t HA_ERROR_INTERVAL = 60000; const uint32_t HA_ERROR_INTERVAL = 60000;
// The task that does the requests (net/ha_api): core 0 runs WiFi and the network stack,
// loop() runs on core 1. Stack in bytes.
const int HA_TASK_CORE = 0;
const uint32_t HA_TASK_STACK = 8192;
// A value of the web UI's test button is kept this long for the page to pick it up (ms)
const uint32_t HA_PREVIEW_KEEP = 10000;
// Moving dynamic pages (scrolling, seconds): the next frame waits until the Nano received // Moving dynamic pages (scrolling, seconds): the next frame waits until the Nano received
// this one (characters per ms at NANO_BAUD) and flipped its changed dots, plus a margin // this one (characters per ms at NANO_BAUD) and flipped its changed dots, plus a margin
const uint32_t NANO_CHARS_PER_MS = NANO_BAUD / 10000; const uint32_t NANO_CHARS_PER_MS = NANO_BAUD / 10000;
@@ -112,7 +118,7 @@ const uint32_t SETUP_AP_DELAY = 20000; // open the AP when WiFi fails thi
const uint32_t WIFI_CONNECT_TIMEOUT = 30000; const uint32_t WIFI_CONNECT_TIMEOUT = 30000;
const uint32_t WIFI_RESTART_TIME = 10UL * 60 * 1000; const uint32_t WIFI_RESTART_TIME = 10UL * 60 * 1000;
#define FIRMWARE_VERSION "2.8.0" #define FIRMWARE_VERSION "2.9.0"
// Home Assistant device id (do not change: Home Assistant's entities are linked to it) // Home Assistant device id (do not change: Home Assistant's entities are linked to it)
const uint8_t HA_DEVICE_ID[] = {0x34, 0x86, 0x5d, 0x3a, 0x08, 0x28}; const uint8_t HA_DEVICE_ID[] = {0x34, 0x86, 0x5d, 0x3a, 0x08, 0x28};
+43 -8
View File
@@ -1,6 +1,7 @@
#include "display/bitmap_store.h" #include "display/bitmap_store.h"
#include <LittleFS.h> #include <LittleFS.h>
#include <map>
#include <strings.h> #include <strings.h>
#include "display/bitmaps.h" #include "display/bitmaps.h"
@@ -22,6 +23,18 @@ static const BuiltinBitmap BUILTIN[] = {
static bool mounted = false; static bool mounted = false;
static uint32_t revision = 0; static uint32_t revision = 0;
// Bitmaps read before, also the names that don't exist (images try a name per state, then
// "default"). Reading one from LittleFS takes about 100 ms, and pages are drawn again for
// every frame (scrolling, seconds) and for every preview in the web UI. Cleared when a
// bitmap is saved or deleted, and when it holds CACHE_MAX names.
struct CachedBitmap
{
bool exists;
uint8_t data[BITMAP_BYTES];
};
static std::map<String, CachedBitmap> cache;
static const size_t CACHE_MAX = 64;
static const BuiltinBitmap *builtin(const char *name) static const BuiltinBitmap *builtin(const char *name)
{ {
for (const BuiltinBitmap &b : BUILTIN) for (const BuiltinBitmap &b : BUILTIN)
@@ -99,7 +112,8 @@ bool bitmapExists(const char *name)
{ {
if (builtin(name)) if (builtin(name))
return true; return true;
return mounted && validBitmapName(name) && LittleFS.exists(path(name)); uint8_t bitmap[BITMAP_BYTES];
return loadBitmap(name, bitmap); // through the cache
} }
bool loadBitmap(const char *name, uint8_t *bitmap) bool loadBitmap(const char *name, uint8_t *bitmap)
@@ -110,14 +124,33 @@ bool loadBitmap(const char *name, uint8_t *bitmap)
memcpy_P(bitmap, b->data, BITMAP_BYTES); memcpy_P(bitmap, b->data, BITMAP_BYTES);
return true; return true;
} }
if (!mounted || !validBitmapName(name))
return false;
auto cached = cache.find(name);
if (cached != cache.end())
{
if (cached->second.exists)
memcpy(bitmap, cached->second.data, BITMAP_BYTES);
return cached->second.exists;
}
// Checked first: open() logs an error for a missing file (images try names by state) // Checked first: open() logs an error for a missing file (images try names by state)
if (!mounted || !validBitmapName(name) || !LittleFS.exists(path(name))) bool ok = false;
return false; if (LittleFS.exists(path(name)))
File file = LittleFS.open(path(name), "r"); {
if (!file) File file = LittleFS.open(path(name), "r");
return false; if (file)
bool ok = file.read(bitmap, BITMAP_BYTES) == (size_t)BITMAP_BYTES; {
file.close(); ok = file.read(bitmap, BITMAP_BYTES) == (size_t)BITMAP_BYTES;
file.close();
}
}
if (cache.size() >= CACHE_MAX)
cache.clear();
CachedBitmap &entry = cache[name];
entry.exists = ok;
if (ok)
memcpy(entry.data, bitmap, BITMAP_BYTES);
return ok; return ok;
} }
@@ -138,6 +171,7 @@ const char *saveBitmap(const char *name, const uint8_t *bitmap)
bool ok = file.write(bitmap, BITMAP_BYTES) == (size_t)BITMAP_BYTES; bool ok = file.write(bitmap, BITMAP_BYTES) == (size_t)BITMAP_BYTES;
file.close(); file.close();
revision++; revision++;
cache.clear();
return ok ? nullptr : "Storage full"; return ok ? nullptr : "Storage full";
} }
@@ -150,5 +184,6 @@ const char *deleteBitmap(const char *name)
if (!LittleFS.remove(path(name))) if (!LittleFS.remove(path(name)))
return "Could not delete the file"; return "Could not delete the file";
revision++; revision++;
cache.clear();
return nullptr; return nullptr;
} }
+73 -2
View File
@@ -8,7 +8,7 @@
static const char *DIR = "/pages"; static const char *DIR = "/pages";
static const char *EXTENSION = ".pg"; static const char *EXTENSION = ".pg";
// Increase when DynamicPage changes; older files are ignored unless converted below // Increase when DynamicPage changes; older files are ignored unless converted below
static const uint16_t VERSION = 3; static const uint16_t VERSION = 4;
struct StoredPage struct StoredPage
{ {
@@ -149,7 +149,66 @@ static void convert(const Page &old, DynamicPage &page)
} }
} // namespace v2 } // namespace v2
static_assert(sizeof(v1::Stored) != sizeof(v2::Stored) && sizeof(v2::Stored) != sizeof(StoredPage), // Version 3 (before the progress bar): the elements lacked the fields from `bar_width` on.
// The rest has the same layout, so it is copied as a block.
namespace v3
{
struct Element
{
ElementType type;
int16_t x, y;
Align align;
Overlay overlay;
char font[PAGE_NAME_SIZE];
uint8_t format;
Language language;
int8_t decimals;
char text[ELEMENT_TEXT_SIZE];
char suffix[ELEMENT_SUFFIX_SIZE];
char entity[ELEMENT_ENTITY_SIZE];
char attribute[ELEMENT_ATTRIBUTE_SIZE];
float factor;
uint8_t scroll_width;
uint8_t scroll_speed;
uint32_t target;
HaShow show;
uint8_t input_unit;
bool auto_unit;
bool absolute;
char map[ELEMENT_MAP_SIZE];
};
struct Page
{
uint8_t slot_count;
PageSlot slots[PAGE_SLOTS];
uint8_t element_count;
Element elements[PAGE_ELEMENTS];
};
struct Stored
{
uint16_t version;
uint16_t size;
Page page;
};
static const size_t COMMON = offsetof(Element, map) + sizeof(Element::map);
static_assert(offsetof(PageElement, map) == offsetof(Element, map), "same layout up to map");
static void convert(const Page &old, DynamicPage &page)
{
page = DynamicPage();
page.slot_count = old.slot_count;
memcpy(page.slots, old.slots, sizeof(page.slots));
page.element_count = old.element_count;
for (int i = 0; i < PAGE_ELEMENTS; i++)
memcpy((void *)&page.elements[i], &old.elements[i], COMMON); // the bar fields keep their defaults
}
} // namespace v3
static_assert(sizeof(v1::Stored) != sizeof(v2::Stored) && sizeof(v2::Stored) != sizeof(v3::Stored) &&
sizeof(v3::Stored) != sizeof(StoredPage),
"the versions are told apart by their size"); "the versions are told apart by their size");
static uint32_t revision = 0; static uint32_t revision = 0;
@@ -210,6 +269,18 @@ bool loadPage(const char *name, DynamicPage &page)
page = stored->page; page = stored->page;
delete stored; delete stored;
} }
else if (size == sizeof(v3::Stored))
{
v3::Stored *stored = new v3::Stored;
ok = file.read((uint8_t *)stored, sizeof(v3::Stored)) == sizeof(v3::Stored) && stored->version == 3 &&
stored->size == sizeof(v3::Page);
if (ok)
{
v3::convert(stored->page, page);
ok = validatePage(page) == nullptr;
}
delete stored;
}
else if (size == sizeof(v2::Stored)) else if (size == sizeof(v2::Stored))
{ {
v2::Stored *stored = new v2::Stored; v2::Stored *stored = new v2::Stored;
+76
View File
@@ -25,10 +25,62 @@ static bool printable(const char *text, size_t size)
return true; return true;
} }
bool expandEntity(const char *entity, const char *user, char *out, size_t out_size)
{
const char *at = strstr(entity, PLEX_USER_PLACEHOLDER);
int n;
if (at)
n = snprintf(out, out_size, "%.*s%s%s", (int)(at - entity), entity, user,
at + strlen(PLEX_USER_PLACEHOLDER));
else
n = snprintf(out, out_size, "%s", entity);
return n >= 0 && (size_t)n < out_size;
}
void plexUserSlug(const char *name, char *out, size_t out_size)
{
size_t n = 0;
bool gap = false;
for (const char *c = name; *c && n + 1 < out_size; c++)
{
if (isalnum((unsigned char)*c))
{
if (gap && n > 0 && n + 2 < out_size)
out[n++] = '_';
gap = false;
out[n++] = tolower((unsigned char)*c);
}
else
gap = true;
}
out[n] = '\0';
}
bool elementUsesHa(const PageElement &element)
{
switch (element.type)
{
case ElementType::HaValue:
return true;
case ElementType::Image:
case ElementType::Bar:
return element.entity[0] != '\0';
default:
return false;
}
}
bool validEntityId(const char *entity) bool validEntityId(const char *entity)
{ {
if (entity == nullptr) if (entity == nullptr)
return false; return false;
// Checked with a stand-in user name
if (strstr(entity, PLEX_USER_PLACEHOLDER))
{
char full[ELEMENT_ENTITY_SIZE];
return strlen(entity) < (size_t)ELEMENT_ENTITY_SIZE && !strstr(strstr(entity, "}") + 1, "{") &&
expandEntity(entity, "x", full, sizeof(full)) && validEntityId(full);
}
const char *dot = strchr(entity, '.'); const char *dot = strchr(entity, '.');
if (dot == nullptr || dot == entity || dot[1] == '\0' || strchr(dot + 1, '.')) if (dot == nullptr || dot == entity || dot[1] == '\0' || strchr(dot + 1, '.'))
return false; return false;
@@ -125,6 +177,15 @@ const char *validatePage(const DynamicPage &page)
return "Scrolling: window up to 168 dots, speed 1-30 dots per second"; return "Scrolling: window up to 168 dots, speed 1-30 dots per second";
if (e.type == ElementType::HaValue && !validEntityId(e.entity)) if (e.type == ElementType::HaValue && !validEntityId(e.entity))
return "Home Assistant values need an entity id like sensor.outside_temperature"; return "Home Assistant values need an entity id like sensor.outside_temperature";
if (e.type == ElementType::Bar)
{
if (!validEntityId(e.entity))
return "Progress bars need an entity id like sensor.progress";
if (e.bar_width < 1 || e.bar_width > BITMAP_WIDTH || e.bar_height < 1 || e.bar_height > BITMAP_HEIGHT)
return "Progress bar: width 1-84, height 1-19 dots";
if (!isfinite(e.bar_max) || e.bar_max <= 0)
return "Progress bar: the full value must be a number above 0";
}
if (e.type == ElementType::Countdown && e.target >= MAX_TARGET) if (e.type == ElementType::Countdown && e.target >= MAX_TARGET)
return "The countdown date must be before 2100"; return "The countdown date must be before 2100";
if (e.type == ElementType::Image) if (e.type == ElementType::Image)
@@ -645,6 +706,7 @@ void elementText(const PageElement &e, const LocalTime &t, const ElementValues &
formatStopwatch(e, values.stopwatch_ms, out, size); formatStopwatch(e, values.stopwatch_ms, out, size);
break; break;
case ElementType::Image: case ElementType::Image:
case ElementType::Bar:
out[0] = '\0'; out[0] = '\0';
break; break;
default: default:
@@ -653,6 +715,20 @@ void elementText(const PageElement &e, const LocalTime &t, const ElementValues &
} }
} }
int barFill(const PageElement &e, const char *ha_value, int fill_width)
{
double v;
if (!ha_value || !parseNumber(ha_value, v) || fill_width <= 0)
return 0;
double part = v * e.factor / e.bar_max;
if (!(part > 0)) // also NaN
return 0;
if (part >= 1)
return fill_width;
int n = (int)(part * fill_width + 0.5);
return n;
}
bool elementShowsSeconds(const PageElement &e, const LocalTime &t, bool stopwatch_running) bool elementShowsSeconds(const PageElement &e, const LocalTime &t, bool stopwatch_running)
{ {
switch (e.type) switch (e.type)
+30 -2
View File
@@ -7,7 +7,8 @@
#include "logic/local_time.h" #include "logic/local_time.h"
// Dynamic pages: a background bitmap that depends on the time of day, with elements // Dynamic pages: a background bitmap that depends on the time of day, with elements
// (clock, date, Home Assistant value, text, countdown, stopwatch, image) drawn on top. // (clock, date, Home Assistant value, text, countdown, stopwatch, image, progress bar)
// drawn on top.
// Stored as a binary struct on LittleFS (display/page_store), edited in the web UI. Pure // Stored as a binary struct on LittleFS (display/page_store), edited in the web UI. Pure
// logic, tested in test/test_esp32. // logic, tested in test/test_esp32.
// //
@@ -32,6 +33,7 @@ enum class ElementType : uint8_t
Countdown, // time left until a date and time Countdown, // time left until a date and time
Stopwatch, // the stopwatch (web UI and Home Assistant start, stop and reset it) Stopwatch, // the stopwatch (web UI and Home Assistant start, stop and reset it)
Image, // a bitmap, fixed or chosen by a Home Assistant state Image, // a bitmap, fixed or chosen by a Home Assistant state
Bar, // a progress bar filled by a Home Assistant number (e.g. 0-100 %)
Count, Count,
}; };
@@ -164,6 +166,11 @@ struct PageElement
bool auto_unit = false; // HaValue Number: the text after is the unit; 1257 W -> 1.3 kW bool auto_unit = false; // HaValue Number: the text after is the unit; 1257 W -> 1.3 kW
bool absolute = false; // HaValue Number: without minus sign bool absolute = false; // HaValue Number: without minus sign
char map[ELEMENT_MAP_SIZE] = ""; // HaValue Map: "state=text, state=text" (states without case) char map[ELEMENT_MAP_SIZE] = ""; // HaValue Map: "state=text, state=text" (states without case)
// Added in page format 4
uint8_t bar_width = 40; // Bar: size in dots, frame included; x is its left, middle or right
uint8_t bar_height = 3;
float bar_max = 100; // Bar: the value (after the multiplier) that fills it
bool bar_frame = true; // Bar: a line around it, filled inside
}; };
struct DynamicPage struct DynamicPage
@@ -193,10 +200,31 @@ struct ElementValues
void elementText(const PageElement &element, const LocalTime &time, const ElementValues &values, char *out, void elementText(const PageElement &element, const LocalTime &time, const ElementValues &values, char *out,
size_t out_size); size_t out_size);
// Bar: how many dots of fill_width are filled for this Home Assistant value (value x
// factor / bar_max, 0 to fill_width); 0 when the value is unknown or not a number
int barFill(const PageElement &element, const char *ha_value, int fill_width);
// Whether the element's text shows seconds now (it then changes every second) // Whether the element's text shows seconds now (it then changes every second)
bool elementShowsSeconds(const PageElement &element, const LocalTime &time, bool stopwatch_running); bool elementShowsSeconds(const PageElement &element, const LocalTime &time, bool stopwatch_running);
// Entity ids look like "sensor.outside_temperature" (lower case, digits, '_', one '.') // Longest text of a Home Assistant value that is kept (longer ones are cut)
const int HA_VALUE_SIZE = 128;
// "{plex_user}" in an entity id (page elements, rules) stands for the Plex user of the
// settings, e.g. sensor.{plex_user}_state for Tautulli's sensors of that user
#define PLEX_USER_PLACEHOLDER "{plex_user}"
const int PLEX_USER_SIZE = 33;
// The entity id with the placeholder replaced by user (out_size: ELEMENT_ENTITY_SIZE).
// False when it doesn't fit.
bool expandEntity(const char *entity, const char *user, char *out, size_t out_size);
// A user name the way Home Assistant puts it in entity ids: "Manga Valk" -> "manga_valk"
void plexUserSlug(const char *name, char *out, size_t out_size);
// The element shows a Home Assistant value (HaValue; Image, Bar with an entity)
bool elementUsesHa(const PageElement &element);
// Entity ids look like "sensor.outside_temperature" (lower case, digits, '_', one '.');
// {plex_user} may be in them
bool validEntityId(const char *entity); bool validEntityId(const char *entity);
// Home Assistant timestamps ("2026-10-15T21:07:00+00:00", "...Z", ".123", or a space // Home Assistant timestamps ("2026-10-15T21:07:00+00:00", "...Z", ".123", or a space
+34
View File
@@ -89,6 +89,38 @@ static void drawImage(const PageElement &e, PageResources &resources, uint8_t *b
} }
} }
// Bar: a frame (optional) filled from the left by the Home Assistant value
static void drawBar(const PageElement &e, PageResources &resources, uint8_t *bitmap, ElementBox &box)
{
int w = e.bar_width, h = e.bar_height;
int left = e.x;
if (e.align == Align::Center)
left -= w / 2;
else if (e.align == Align::Right)
left -= w;
box.x = left;
box.y = e.y;
box.width = w;
box.height = h;
if (e.overlay == Overlay::Box)
clearBox(bitmap, left - 1, e.y - 1, w + 2, h + 2);
// With a frame the fill is inside it (a bar of 1 or 2 dots high has no room: no frame)
bool frame = e.bar_frame && w >= 3 && h >= 3;
int inner = frame ? 1 : 0;
int fill = barFill(e, resources.haValue(e), w - 2 * inner);
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
{
bool edge = frame && (x == 0 || y == 0 || x == w - 1 || y == h - 1);
bool filled = !edge && x - inner < fill;
if (!edge && !filled)
continue;
int bx = left + x, by = e.y + y;
setBitmapPixel(bitmap, bx, by, e.overlay == Overlay::Invert ? !bitmapPixel(bitmap, bx, by) : true);
}
}
// A text element; returns the ms until it scrolls on (0: it doesn't scroll) // A text element; returns the ms until it scrolls on (0: it doesn't scroll)
static uint32_t drawTextElement(const PageElement &e, const char *text, uint32_t now_ms, PageResources &resources, static uint32_t drawTextElement(const PageElement &e, const char *text, uint32_t now_ms, PageResources &resources,
uint8_t *bitmap, ElementBox &box) uint8_t *bitmap, ElementBox &box)
@@ -152,6 +184,8 @@ uint32_t renderPage(const DynamicPage &page, const LocalTime &time, uint32_t now
ElementBox box; ElementBox box;
if (e.type == ElementType::Image) if (e.type == ElementType::Image)
drawImage(e, resources, bitmap, box); drawImage(e, resources, bitmap, box);
else if (e.type == ElementType::Bar)
drawBar(e, resources, bitmap, box);
else else
{ {
ElementValues values; ElementValues values;
+1 -1
View File
@@ -41,7 +41,7 @@ const char *validateSettings(const Settings &s)
const HomeAssistantSettings &ha = s.home_assistant; const HomeAssistantSettings &ha = s.home_assistant;
if (!terminated(ha.host, sizeof(ha.host)) || !terminated(ha.user, sizeof(ha.user)) || if (!terminated(ha.host, sizeof(ha.host)) || !terminated(ha.user, sizeof(ha.user)) ||
!terminated(ha.password, sizeof(ha.password)) || !terminated(ha.api_url, sizeof(ha.api_url)) || !terminated(ha.password, sizeof(ha.password)) || !terminated(ha.api_url, sizeof(ha.api_url)) ||
!terminated(ha.token, sizeof(ha.token))) !terminated(ha.token, sizeof(ha.token)) || !terminated(s.plex_user, sizeof(s.plex_user)))
return "Home Assistant text too long"; return "Home Assistant text too long";
if (ha.port == 0) if (ha.port == 0)
return "Invalid MQTT port"; return "Invalid MQTT port";
+2
View File
@@ -83,6 +83,8 @@ struct Settings
char off_page[PAGE_NAME_SIZE] = OFF_PAGE_BLANK; char off_page[PAGE_NAME_SIZE] = OFF_PAGE_BLANK;
DriveSettings drive; DriveSettings drive;
RefreshSettings refresh; RefreshSettings refresh;
// Plex user name for {plex_user} in entity ids (Tautulli's sensors of that user)
char plex_user[PLEX_USER_SIZE] = "";
// New fields go here, at the end (see system/settings_store.cpp) // New fields go here, at the end (see system/settings_store.cpp)
}; };
+2 -1
View File
@@ -62,6 +62,7 @@ void setup()
status.show("Getting", "the time...", true); status.show("Getting", "the time...", true);
configTzTime(TIMEZONE, NTP_SERVER); configTzTime(TIMEZONE, NTP_SERVER);
haApiBegin(); // Home Assistant values are read in a task of their own
homeAssistantBegin(controller, appSettings().home_assistant); homeAssistantBegin(controller, appSettings().home_assistant);
webUiBegin(); webUiBegin();
@@ -80,7 +81,7 @@ void loop()
homeAssistantLoop(); homeAssistantLoop();
webUiLoop(); webUiLoop();
appLoop(); appLoop();
// Home Assistant values of the shown page (can block about 3 s once a minute) // Home Assistant values of the shown page (read by a task, never blocks)
if (haApiLoop(wifiConnected())) if (haApiLoop(wifiConnected()))
controller.pageChanged(); controller.pageChanged();
+235 -78
View File
@@ -3,14 +3,40 @@
#include <ArduinoJson.h> #include <ArduinoJson.h>
#include <HTTPClient.h> #include <HTTPClient.h>
#include <WiFi.h> #include <WiFi.h>
#include <freertos/FreeRTOS.h>
#include <freertos/queue.h>
#include <freertos/task.h>
#include <vector> #include <vector>
#include "config.h" #include "config.h"
#include "credentials.h" #include "credentials.h"
// The requests run in their own task (HA_TASK_CORE), so a slow or unreachable Home
// Assistant never blocks loop(). Everything else stays in loop(): the task only gets a
// copy of what it needs and sends a copy of the answer back, through two queues of plain
// structs (no shared Strings, no locks).
struct Request
{
char url[sizeof(HomeAssistantSettings::api_url) + 16 + ELEMENT_ENTITY_SIZE];
char token[sizeof(HomeAssistantSettings::token)];
char entity[ELEMENT_ENTITY_SIZE];
char attribute[ELEMENT_ATTRIBUTE_SIZE];
bool preview;
};
struct Response
{
char entity[ELEMENT_ENTITY_SIZE];
char attribute[ELEMENT_ATTRIBUTE_SIZE];
char value[HA_VALUE_SIZE];
const char *error; // a string literal, nullptr when the value was read
bool preview;
};
struct Entry struct Entry
{ {
String entity; String entity; // with the placeholders filled in
String attribute; String attribute;
String value; String value;
bool known; bool known;
@@ -28,11 +54,104 @@ struct Entry
static String base_url; static String base_url;
static String token; static String token;
static char plex_user[PLEX_USER_SIZE] = "";
static std::vector<Entry> entries; static std::vector<Entry> entries;
// Kept for the editor's previews, besides the watched ones // Kept for the editor's previews, besides the watched ones
static const size_t MAX_ENTRIES = 24; static const size_t MAX_ENTRIES = 24;
void haApiSetSettings(const HomeAssistantSettings &settings) static QueueHandle_t requests = nullptr;
static QueueHandle_t responses = nullptr;
static bool poll_busy = false; // a poll request is with the task
// The web UI's test button: one at a time, the answer is kept until it is picked up
static bool preview_busy = false;
static bool preview_done = false;
static String preview_entity, preview_attribute, preview_value;
static const char *preview_error = nullptr;
static uint32_t preview_done_at = 0;
// ---------------------------------------------------------------------------
// The task
// ---------------------------------------------------------------------------
// Blocks at most about 2 x HA_HTTP_TIMEOUT (connect, then the answer)
static const char *fetch(const Request &r, char *value, size_t value_size)
{
if (!WiFi.isConnected())
return "No WiFi";
HTTPClient http;
http.setConnectTimeout(HA_HTTP_TIMEOUT);
http.setTimeout(HA_HTTP_TIMEOUT);
if (!http.begin(r.url))
return "Invalid Home Assistant URL";
http.addHeader("Authorization", String("Bearer ") + r.token);
int code = http.GET();
if (code != 200)
{
http.end();
if (code == 401)
return "Home Assistant refused the token";
if (code == 404)
return "Unknown entity";
return code < 0 ? "Home Assistant not reachable" : "Unexpected answer from Home Assistant";
}
JsonDocument filter;
if (r.attribute[0])
filter["attributes"][r.attribute] = true;
else
filter["state"] = true;
JsonDocument doc;
DeserializationError error = deserializeJson(doc, http.getString(), DeserializationOption::Filter(filter));
http.end();
if (error)
return "Invalid answer from Home Assistant";
JsonVariantConst v = doc["state"];
if (r.attribute[0])
v = doc["attributes"][r.attribute];
if (v.isNull())
return "Unknown attribute";
String text;
if (v.is<const char *>())
text = v.as<const char *>();
else
serializeJson(v, text); // numbers, true/false
text.replace("\xC2\xB0", String(DEGREE_SIGN)); // UTF-8 degree sign: the fonts have it as 127
strlcpy(value, text.c_str(), value_size);
return nullptr;
}
static void haTask(void *)
{
static Request r; // static: keeps the large structs off the task's stack
static Response res;
while (true)
{
if (xQueueReceive(requests, &r, portMAX_DELAY) != pdTRUE)
continue;
strlcpy(res.entity, r.entity, sizeof(res.entity));
strlcpy(res.attribute, r.attribute, sizeof(res.attribute));
res.value[0] = '\0';
res.preview = r.preview;
res.error = fetch(r, res.value, sizeof(res.value));
xQueueSend(responses, &res, portMAX_DELAY);
}
}
void haApiBegin()
{
requests = xQueueCreate(2, sizeof(Request));
responses = xQueueCreate(2, sizeof(Response));
xTaskCreatePinnedToCore(haTask, "ha_api", HA_TASK_STACK, nullptr, 1, nullptr, HA_TASK_CORE);
}
// ---------------------------------------------------------------------------
// loop() side
// ---------------------------------------------------------------------------
void haApiSetSettings(const HomeAssistantSettings &settings, const char *user)
{ {
if (settings.api_url[0]) if (settings.api_url[0])
base_url = settings.api_url; base_url = settings.api_url;
@@ -41,11 +160,25 @@ void haApiSetSettings(const HomeAssistantSettings &settings)
while (base_url.endsWith("/")) while (base_url.endsWith("/"))
base_url.remove(base_url.length() - 1); base_url.remove(base_url.length() - 1);
token = settings.token; token = settings.token;
strlcpy(plex_user, user ? user : "", sizeof(plex_user));
for (Entry &e : entries) for (Entry &e : entries)
e.next_poll = millis(); // read again with the new settings e.next_poll = millis(); // read again with the new settings
} }
static Entry *find(const char *entity, const char *attribute) // The entity with the placeholders filled in; false when that is not a valid entity id
// (also with {plex_user} while no user is set)
static bool expand(const char *entity, String &out)
{
if (!plex_user[0] && strstr(entity, PLEX_USER_PLACEHOLDER))
return false;
char full[ELEMENT_ENTITY_SIZE];
if (!expandEntity(entity, plex_user, full, sizeof(full)) || !validEntityId(full))
return false;
out = full;
return true;
}
static Entry *find(const String &entity, const char *attribute)
{ {
for (Entry &e : entries) for (Entry &e : entries)
if (e.entity == entity && e.attribute == attribute) if (e.entity == entity && e.attribute == attribute)
@@ -53,7 +186,7 @@ static Entry *find(const char *entity, const char *attribute)
return nullptr; return nullptr;
} }
static Entry &add(const char *entity, const char *attribute) static Entry &add(const String &entity, const char *attribute)
{ {
Entry *e = find(entity, attribute); Entry *e = find(entity, attribute);
if (e) if (e)
@@ -78,10 +211,10 @@ void haApiWatch(const DynamicPage *page)
for (int i = 0; i < page->element_count; i++) for (int i = 0; i < page->element_count; i++)
{ {
const PageElement &element = page->elements[i]; const PageElement &element = page->elements[i];
bool uses_state = element.type == ElementType::HaValue || element.type == ElementType::Image; String entity;
if (!uses_state || !validEntityId(element.entity)) if (!elementUsesHa(element) || !expand(element.entity, entity))
continue; continue;
Entry &e = add(element.entity, element.attribute); Entry &e = add(entity, element.attribute);
if (!e.watched()) if (!e.watched())
e.next_poll = millis(); e.next_poll = millis();
e.page_watched = true; e.page_watched = true;
@@ -97,7 +230,10 @@ void haApiWatchRules(const PageSchedule *schedule)
for (int i = 0; i < schedule->rule_count; i++) for (int i = 0; i < schedule->rule_count; i++)
{ {
const TriggerRule &rule = schedule->rules[i]; const TriggerRule &rule = schedule->rules[i];
Entry &e = add(rule.entity, rule.attribute); String entity;
if (!expand(rule.entity, entity))
continue;
Entry &e = add(entity, rule.attribute);
if (!e.watched()) if (!e.watched())
e.next_poll = millis(); e.next_poll = millis();
uint32_t ms = rule.interval * 1000UL; uint32_t ms = rule.interval * 1000UL;
@@ -106,104 +242,125 @@ void haApiWatchRules(const PageSchedule *schedule)
} }
} }
// Blocks at most about 2 x HA_HTTP_TIMEOUT (connect, then the answer) static bool send(const String &entity, const String &attribute, bool preview)
static const char *fetch(const String &entity, const String &attribute, String &value)
{ {
if (!token.length()) static Request r;
return "No Home Assistant token set (Home Assistant tab)"; snprintf(r.url, sizeof(r.url), "%s/api/states/%s", base_url.c_str(), entity.c_str());
if (!WiFi.isConnected()) strlcpy(r.token, token.c_str(), sizeof(r.token));
return "No WiFi"; strlcpy(r.entity, entity.c_str(), sizeof(r.entity));
strlcpy(r.attribute, attribute.c_str(), sizeof(r.attribute));
r.preview = preview;
// The test button goes first; never wait here (loop() must not block)
return (preview ? xQueueSendToFront(requests, &r, 0) : xQueueSend(requests, &r, 0)) == pdTRUE;
}
HTTPClient http; // An answer from the task; true when a value of the shown page changed
http.setConnectTimeout(HA_HTTP_TIMEOUT); static bool receive(const Response &res)
http.setTimeout(HA_HTTP_TIMEOUT); {
if (!http.begin(base_url + "/api/states/" + entity)) if (res.preview)
return "Invalid Home Assistant URL";
http.addHeader("Authorization", "Bearer " + token);
int code = http.GET();
if (code != 200)
{ {
http.end(); preview_busy = false;
if (code == 401) preview_done = true;
return "Home Assistant refused the token"; preview_done_at = millis();
if (code == 404) preview_entity = res.entity;
return "Unknown entity"; preview_attribute = res.attribute;
return code < 0 ? "Home Assistant not reachable" : "Unexpected answer from Home Assistant"; preview_error = res.error;
preview_value = res.value;
if (!res.error)
{
Entry &e = add(res.entity, res.attribute);
e.value = res.value;
e.known = true;
}
return false;
} }
JsonDocument filter; poll_busy = false;
if (attribute.length()) Entry *e = find(res.entity, res.attribute);
filter["attributes"][attribute] = true; if (!e)
else return false; // no longer kept
filter["state"] = true; if (res.error)
JsonDocument doc;
DeserializationError error = deserializeJson(doc, http.getString(), DeserializationOption::Filter(filter));
http.end();
if (error)
return "Invalid answer from Home Assistant";
JsonVariantConst v = doc["state"];
if (attribute.length())
v = doc["attributes"][attribute];
if (v.isNull())
return "Unknown attribute";
if (v.is<const char *>())
value = v.as<const char *>();
else
{ {
value = ""; // Shown as "--" until it can be read again. Read less often when Home Assistant
serializeJson(v, value); // numbers, true/false // is down.
Serial.printf("Home Assistant %s: %s\n", res.entity, res.error);
if (e->interval() < HA_ERROR_INTERVAL)
e->next_poll = millis() + HA_ERROR_INTERVAL;
bool changed = e->known && e->page_watched;
e->known = false;
return changed;
} }
value.replace("\xC2\xB0", String(DEGREE_SIGN)); // UTF-8 degree sign: the fonts have it as 127 bool changed = (!e->known || e->value != res.value) && e->page_watched;
return nullptr; e->value = res.value;
e->known = true;
return changed;
} }
bool haApiLoop(bool wifi_connected) bool haApiLoop(bool wifi_connected)
{ {
if (!wifi_connected || !token.length()) if (!requests)
return false; return false;
bool changed = false;
static Response res;
while (xQueueReceive(responses, &res, 0) == pdTRUE)
changed |= receive(res);
if (!wifi_connected || !token.length() || poll_busy)
return changed;
uint32_t now = millis(); uint32_t now = millis();
for (Entry &e : entries) for (Entry &e : entries)
{ {
if (!e.watched() || (int32_t)(now - e.next_poll) < 0) if (!e.watched() || (int32_t)(now - e.next_poll) < 0)
continue; continue;
e.next_poll = now + e.interval(); if (send(e.entity, e.attribute, false))
String value;
const char *error = fetch(e.entity, e.attribute, value);
if (error)
{ {
// Shown as "--" until it can be read again. Read less often, so a Home e.next_poll = now + e.interval();
// Assistant that is down doesn't block loop() all the time. poll_busy = true;
Serial.printf("Home Assistant %s: %s\n", e.entity.c_str(), error);
if (e.interval() < HA_ERROR_INTERVAL)
e.next_poll = now + HA_ERROR_INTERVAL;
bool changed = e.known && e.page_watched;
e.known = false;
return changed;
} }
bool changed = (!e.known || e.value != value) && e.page_watched; break; // one request at a time
e.value = value;
e.known = true;
return changed; // one entity per loop()
} }
return false; return changed;
} }
const char *haApiValue(const char *entity, const char *attribute) const char *haApiValue(const char *entity, const char *attribute)
{ {
Entry *e = find(entity, attribute); String full;
if (!expand(entity, full))
return nullptr;
Entry *e = find(full, attribute);
return e && e->known ? e->value.c_str() : nullptr; return e && e->known ? e->value.c_str() : nullptr;
} }
const char *haApiFetchNow(const char *entity, const char *attribute, String &value) const char *haApiPreview(const char *entity, const char *attribute, String &value, bool &pending)
{ {
if (!validEntityId(entity)) pending = false;
String full;
if (!plex_user[0] && strstr(entity, PLEX_USER_PLACEHOLDER))
return "Set the Plex user first (Home Assistant tab)";
if (!expand(entity, full))
return "Invalid entity id"; return "Invalid entity id";
const char *error = fetch(entity, attribute, value); if (!token.length())
if (error) return "No Home Assistant token set (Home Assistant tab)";
return error; if (!requests)
Entry &e = add(entity, attribute); return "Home Assistant not started";
e.value = value;
e.known = true; // The answer to an earlier call for the same value (picked up within a few seconds)
if (preview_done && preview_entity == full && preview_attribute == attribute &&
millis() - preview_done_at < HA_PREVIEW_KEEP)
{
preview_done = false;
if (preview_error)
return preview_error;
value = preview_value;
return nullptr;
}
if (!preview_busy)
{
if (!send(full, attribute, true))
return "Home Assistant busy, try again";
preview_busy = true;
preview_done = false;
}
pending = true;
return nullptr; return nullptr;
} }
+16 -8
View File
@@ -9,11 +9,17 @@
// Values of Home Assistant entities for the dynamic pages, read over Home Assistant's REST // Values of Home Assistant entities for the dynamic pages, read over Home Assistant's REST
// API (GET <url>/api/states/<entity> with a long-lived access token). Only the entities of // API (GET <url>/api/states/<entity> with a long-lived access token). Only the entities of
// the shown page (every HA_POLL_INTERVAL) and of the trigger rules (their own interval) are // the shown page (every HA_POLL_INTERVAL) and of the trigger rules (their own interval) are
// polled, one per loop() so loop() never blocks longer than about 2 x HA_HTTP_TIMEOUT. // polled, one at a time. The requests run in a task of their own (haApiBegin()), so
// Plain http only. // loop() never waits for Home Assistant; everything else here is called from loop().
// Plain http only. Entity ids may contain {plex_user} (logic/page.h).
// URL and token; empty URL: http://<MQTT broker>:8123 // Starts the request task (in setup())
void haApiSetSettings(const HomeAssistantSettings &settings); void haApiBegin();
// URL and token (empty URL: http://<MQTT broker>:8123) and the Plex user for {plex_user}
// (already in entity id form, see plexUserSlug()). Watch the page and the rules again
// after a change of the user.
void haApiSetSettings(const HomeAssistantSettings &settings, const char *plex_user);
// The page whose entities are polled (nullptr: none). They are read right away. // The page whose entities are polled (nullptr: none). They are read right away.
void haApiWatch(const DynamicPage *page); void haApiWatch(const DynamicPage *page);
@@ -21,12 +27,14 @@ void haApiWatch(const DynamicPage *page);
// The entities of the trigger rules, each read at its rule's interval (nullptr: none) // The entities of the trigger rules, each read at its rule's interval (nullptr: none)
void haApiWatchRules(const PageSchedule *schedule); void haApiWatchRules(const PageSchedule *schedule);
// Polls one entity that is due. Returns true when a value of the shown page changed (redraw). // Hands answers over and starts the next due request. Returns true when a value of the
// After an error an entity is read again after HA_ERROR_INTERVAL at the soonest. // shown page changed (redraw). After an error an entity is read again after
// HA_ERROR_INTERVAL at the soonest.
bool haApiLoop(bool wifi_connected); bool haApiLoop(bool wifi_connected);
// The last value read for an entity (attribute empty: the state); nullptr when unknown // The last value read for an entity (attribute empty: the state); nullptr when unknown
const char *haApiValue(const char *entity, const char *attribute); const char *haApiValue(const char *entity, const char *attribute);
// Reads one value now (the web UI's test button) and keeps it. Returns nullptr or an error. // The web UI's test button: reads one value in the background. pending: not there yet,
const char *haApiFetchNow(const char *entity, const char *attribute, String &value); // ask again (the answer is kept for HA_PREVIEW_KEEP). Returns nullptr or an error.
const char *haApiPreview(const char *entity, const char *attribute, String &value, bool &pending);
+13 -5
View File
@@ -9,7 +9,7 @@ static const char *KEY = "settings";
// Increase when the Settings struct changes; old records are then ignored (defaults), // Increase when the Settings struct changes; old records are then ignored (defaults),
// unless the code below says how to read them. When fields are only added at the end, // unless the code below says how to read them. When fields are only added at the end,
// an old record is the start of the new layout: add an OldVersion for it like v3 does. // an old record is the start of the new layout: add an OldVersion for it like v3 does.
static const uint16_t VERSION = 5; static const uint16_t VERSION = 6;
struct OldVersion struct OldVersion
{ {
@@ -78,8 +78,12 @@ static void convert(const Settings &old, ::Settings &s)
static_assert(sizeof(v3::Settings) <= sizeof(Settings), "the old record must fit the buffer"); static_assert(sizeof(v3::Settings) <= sizeof(Settings), "the old record must fit the buffer");
// Version 4: the current layout without refresh (added at the end, so it is a prefix) // Versions 4 and 5: the current layout without refresh and plex_user (added at the end, so
static const OldVersion V4 = {4, offsetof(Settings, refresh)}; // they are a prefix of it)
static const OldVersion PREFIXES[] = {
{4, offsetof(Settings, refresh)},
{5, offsetof(Settings, plex_user)},
};
struct StoredSettings struct StoredSettings
{ {
@@ -111,9 +115,13 @@ void loadSettings(Settings &settings)
Settings loaded; Settings loaded;
if (read == sizeof(stored) && stored.version == VERSION && stored.size == sizeof(Settings)) if (read == sizeof(stored) && stored.version == VERSION && stored.size == sizeof(Settings))
loaded = stored.settings; loaded = stored.settings;
else if (stored.version == V4.version) else if (stored.version >= 4 && stored.version < VERSION)
{ {
if (!readOld(stored, read, V4.fields, loaded)) bool ok = false;
for (const OldVersion &o : PREFIXES)
if (o.version == stored.version)
ok = readOld(stored, read, o.fields, loaded);
if (!ok)
return; return;
} }
else else
+9 -1
View File
@@ -5,7 +5,7 @@
#include "logic/settings.h" #include "logic/settings.h"
static const char *const TYPES[] = {"clock", "date", "ha", "text", "countdown", "stopwatch", "image"}; static const char *const TYPES[] = {"clock", "date", "ha", "text", "countdown", "stopwatch", "image", "bar"};
static const char *const ALIGNS[] = {"left", "center", "right"}; static const char *const ALIGNS[] = {"left", "center", "right"};
static const char *const OVERLAYS[] = {"top", "box", "invert"}; static const char *const OVERLAYS[] = {"top", "box", "invert"};
static const char *const LANGUAGES[] = {"nl", "en"}; static const char *const LANGUAGES[] = {"nl", "en"};
@@ -69,6 +69,10 @@ void pageToJson(const DynamicPage &page, JsonObject out)
o["auto_unit"] = e.auto_unit; o["auto_unit"] = e.auto_unit;
o["absolute"] = e.absolute; o["absolute"] = e.absolute;
o["map"] = withDegrees(e.map); o["map"] = withDegrees(e.map);
o["bar_width"] = e.bar_width;
o["bar_height"] = e.bar_height;
o["bar_max"] = e.bar_max;
o["bar_frame"] = e.bar_frame;
} }
} }
@@ -128,6 +132,10 @@ const char *pageFromJson(JsonVariantConst in, DynamicPage &page)
e.input_unit = unit; e.input_unit = unit;
e.auto_unit = o["auto_unit"] | false; e.auto_unit = o["auto_unit"] | false;
e.absolute = o["absolute"] | false; e.absolute = o["absolute"] | false;
e.bar_width = constrain(o["bar_width"] | 40, 0, 255);
e.bar_height = constrain(o["bar_height"] | 3, 0, 255);
e.bar_max = o["bar_max"] | 100.0f;
e.bar_frame = o["bar_frame"] | true;
if (!copyText(o["map"], e.map, sizeof(e.map))) if (!copyText(o["map"], e.map, sizeof(e.map)))
return "The list of texts is too long (63 characters)"; return "The list of texts is too long (63 characters)";
const char *target = o["target"] | ""; const char *target = o["target"] | "";
+23 -11
View File
@@ -74,8 +74,8 @@ details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:13px;m
<div class="row"><button class="s" onclick="peAdd('clock')">+ Clock</button><button class="s" onclick="peAdd('date')">+ Date</button> <div class="row"><button class="s" onclick="peAdd('clock')">+ Clock</button><button class="s" onclick="peAdd('date')">+ Date</button>
<button class="s" onclick="peAdd('ha')">+ Home Assistant value</button><button class="s" onclick="peAdd('text')">+ Text</button> <button class="s" onclick="peAdd('ha')">+ Home Assistant value</button><button class="s" onclick="peAdd('text')">+ Text</button>
<button class="s" onclick="peAdd('countdown')">+ Countdown</button><button class="s" onclick="peAdd('stopwatch')">+ Stopwatch</button> <button class="s" onclick="peAdd('countdown')">+ Countdown</button><button class="s" onclick="peAdd('stopwatch')">+ Stopwatch</button>
<button class="s" onclick="peAdd('image')">+ Image</button></div> <button class="s" onclick="peAdd('image')">+ Image</button><button class="s" onclick="peAdd('bar')">+ Progress bar</button></div>
<p class="hint">Drag elements on the preview, or select one and use the arrow keys. X is the left edge, the middle or the right edge (Align). Box clears the dots behind the element, Inverted flips the dots below it. Home Assistant values need the URL and token in the Home Assistant tab. Type &deg; for degrees. A text wider than its scroll window scrolls; moving pages (scrolling, seconds) are shown without the refresh effect, as fast as the dots can flip.</p></div> <p class="hint">Drag elements on the preview, or select one and use the arrow keys. X is the left edge, the middle or the right edge (Align). Box clears the dots behind the element, Inverted flips the dots below it. Home Assistant values need the URL and token in the Home Assistant tab. Type &deg; for degrees. Entity ids can contain {plex_user} (Home Assistant tab). A text wider than its scroll window scrolls; moving pages (scrolling, seconds) are shown without the refresh effect, as fast as the dots can flip.</p></div>
<div class="card"><h2>Fonts</h2><div class="form" id="fontlist"></div> <div class="card"><h2>Fonts</h2><div class="form" id="fontlist"></div>
<h3>Add a font</h3> <h3>Add a font</h3>
<div class="row"><label><input type="file" id="f_file" accept=".ttf,.otf,.woff,.woff2"> TTF / OTF file</label></div> <div class="row"><label><input type="file" id="f_file" accept=".ttf,.otf,.woff,.woff2"> TTF / OTF file</label></div>
@@ -167,7 +167,8 @@ details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:13px;m
<div class="card"><h2>Values on dynamic pages (REST API)</h2><div class="form"> <div class="card"><h2>Values on dynamic pages (REST API)</h2><div class="form">
<label>URL</label><input id="ha_url" placeholder="empty: http://&lt;broker&gt;:8123"> <label>URL</label><input id="ha_url" placeholder="empty: http://&lt;broker&gt;:8123">
<label>Token</label><input type="password" id="ha_token" placeholder="none" autocomplete="new-password"> <label>Token</label><input type="password" id="ha_token" placeholder="none" autocomplete="new-password">
</div><p class="hint">A long-lived access token: in Home Assistant, your profile, Security tab. Only http (use the IP address, .local names don't work). The values of the shown page are read every minute.</p></div> <label>Plex user</label><input id="ha_plex" placeholder="e.g. mangavalk" autocomplete="off">
</div><p class="hint">A long-lived access token: in Home Assistant, your profile, Security tab. Only http (use the IP address, .local names don't work). The values of the shown page are read every minute. Plex user: entity ids in pages and rules can contain {plex_user}, which is replaced by this name the way Home Assistant writes it (lower case, _ for spaces), e.g. sensor.{plex_user}_state for Tautulli.</p></div>
<button class="b" onclick="saveHa()">Save</button></section> <button class="b" onclick="saveHa()">Save</button></section>
<section id="t-system"><div class="card"><h2>System</h2><div class="form" id="sysinfo"></div> <section id="t-system"><div class="card"><h2>System</h2><div class="form" id="sysinfo"></div>
@@ -291,7 +292,7 @@ function schedBuild(){if(!sched)return;schedLink();$('sc_en').checked=sched.enab
rd.append(mk('div',{className:'el'}, rd.append(mk('div',{className:'el'},
mk('div',{className:'row'},mk('b',{textContent:(i+1)+'. When'}),sinp(ru,'entity','text',{placeholder:'sensor.mainsailos_current_print_state',size:30,maxLength:64}), mk('div',{className:'row'},mk('b',{textContent:(i+1)+'. When'}),sinp(ru,'entity','text',{placeholder:'sensor.mainsailos_current_print_state',size:30,maxLength:64}),
sinp(ru,'attribute','text',{placeholder:'(the state)',size:10,maxLength:32}),ssel(ru,'compare',RCMP),sinp(ru,'value','text',{size:12,maxLength:32,placeholder:'printing'}), sinp(ru,'attribute','text',{placeholder:'(the state)',size:10,maxLength:32}),ssel(ru,'compare',RCMP),sinp(ru,'value','text',{size:12,maxLength:32,placeholder:'printing'}),
btn('Test',async()=>{const v=await api('/api/ha/value?entity='+encodeURIComponent(ru.entity)+'&attribute='+encodeURIComponent(ru.attribute));state.textContent='now: '+v.value})), btn('Test',async()=>{state.textContent='now: '+await haValue(ru.entity,ru.attribute)})),
mk('div',{className:'row'},'then',ssel(ru,'page',opts),ssel(ru,'mode',RMODE,()=>{secs.style.display=ru.mode=='while'?'none':''}),secs, mk('div',{className:'row'},'then',ssel(ru,'page',opts),ssel(ru,'mode',RMODE,()=>{secs.style.display=ru.mode=='while'?'none':''}),secs,
'read every',sinp(ru,'interval','number',{style:'width:5em',min:2,max:600}),'s',state,btn('Remove',()=>{sched.rules.splice(i,1);schedDirty=true;schedBuild()},'d')), 'read every',sinp(ru,'interval','number',{style:'width:5em',min:2,max:600}),'s',state,btn('Remove',()=>{sched.rules.splice(i,1);schedDirty=true;schedBuild()},'d')),
sched.blocks.length?mk('div',{className:'row'},'only in the blocks',...sched.blocks.map(b=>{const c=mk('input',{type:'checkbox',checked:ru._b.includes(b)}); sched.blocks.length?mk('div',{className:'row'},'only in the blocks',...sched.blocks.map(b=>{const c=mk('input',{type:'checkbox',checked:ru._b.includes(b)});
@@ -306,14 +307,15 @@ async function scSave(){sched.blocks.sort(bySlot);sched=await post('/api/schedul
async function backToSchedule(){await post('/api/schedule/back');msg('Back to the schedule');setTimeout(pollStatusOnce,500)} async function backToSchedule(){await post('/api/schedule/back');msg('Back to the schedule');setTimeout(pollStatusOnce,500)}
async function pollStatusOnce(){try{status=await api('/api/status');$('sc_now').textContent='Showing: '+status.shown+' ('+status.shown_status+')';$('shownnow').textContent=$('sc_now').textContent}catch(e){}} async function pollStatusOnce(){try{status=await api('/api/status');$('sc_now').textContent='Showing: '+status.shown+' ('+status.shown_status+')';$('shownnow').textContent=$('sc_now').textContent}catch(e){}}
// page editor: the ESP32 draws the preview and returns the element boxes // page editor: the ESP32 draws the preview and returns the element boxes
const ETYPES={clock:'Clock',date:'Date',ha:'Home Assistant value',text:'Text',countdown:'Countdown',stopwatch:'Stopwatch',image:'Image'}; const ETYPES={clock:'Clock',date:'Date',ha:'Home Assistant value',text:'Text',countdown:'Countdown',stopwatch:'Stopwatch',image:'Image',bar:'Progress bar'};
const TEXTTYPES=['clock','date','ha','text','countdown','stopwatch']; const TEXTTYPES=['clock','date','ha','text','countdown','stopwatch'];
const CDF=[['smart: 12 dagen, 5 uur, 34 min, 0:45','smart: 12 days, 5 hours, 34 min, 0:45'],['days: 12 dagen','days: 12 days'],['12d 04:05','12d 04:05'],['12d 04:05:33 (every second)','12d 04:05:33 (every second)']]; const CDF=[['smart: 12 dagen, 5 uur, 34 min, 0:45','smart: 12 days, 5 hours, 34 min, 0:45'],['days: 12 dagen','days: 12 days'],['12d 04:05','12d 04:05'],['12d 04:05:33 (every second)','12d 04:05:33 (every second)']];
const DATEF=[['do 15 okt','Thu 15 Oct'],['15 okt','15 Oct'],['15 oktober','15 October'],['15-10','15-10'],['15-10-2026','15-10-2026'],['donderdag','Thursday'],['do','Thu']]; const DATEF=[['do 15 okt','Thu 15 Oct'],['15 okt','15 Oct'],['15 oktober','15 October'],['15-10','15-10'],['15-10-2026','15-10-2026'],['donderdag','Thursday'],['do','Thu']];
let pe=null,peTimer=null,peSeq=0,peData=null,peDrag=null; let pe=null,peTimer=null,peSeq=0,peData=null,peDrag=null;
function tomorrow(){const d=new Date(Date.now()+864e5),p=n=>String(n).padStart(2,'0');return d.getFullYear()+'-'+p(d.getMonth()+1)+'-'+p(d.getDate())+'T12:00'} function tomorrow(){const d=new Date(Date.now()+864e5),p=n=>String(n).padStart(2,'0');return d.getFullYear()+'-'+p(d.getMonth()+1)+'-'+p(d.getDate())+'T12:00'}
function newEl(t){return{type:t,x:t=='clock'?27:0,y:t=='clock'?5:0,align:'left',overlay:'top',font:'Normal',format:0,language:'nl',decimals:-1, function newEl(t){return{type:t,x:t=='clock'?27:0,y:t=='clock'?5:0,align:'left',overlay:'top',font:'Normal',format:0,language:'nl',decimals:-1,
text:t=='text'?'Hallo':'',suffix:'',entity:'',attribute:'',factor:1,scroll_width:0,scroll_speed:8,target:tomorrow(),show:'number',input_unit:'s',auto_unit:false,absolute:false,map:''}} text:t=='text'?'Hallo':'',suffix:'',entity:'',attribute:'',factor:1,scroll_width:0,scroll_speed:8,target:tomorrow(),show:'number',input_unit:'s',auto_unit:false,absolute:false,map:'',
bar_width:40,bar_height:3,bar_max:100,bar_frame:true}}
function peShow(title,name){$('pe_title').textContent=title;$('pe_name').value=name;$('pe').style.display='block';peData=null;peBuild();pePreview();$('pe').scrollIntoView({behavior:'smooth'})} function peShow(title,name){$('pe_title').textContent=title;$('pe_name').value=name;$('pe').style.display='block';peData=null;peBuild();pePreview();$('pe').scrollIntoView({behavior:'smooth'})}
function peOpen(pg,copy){pe={orig:copy?'':pg.name,page:JSON.parse(JSON.stringify(pg.page)),sel:-1,boxes:[],pos:[]};peShow(copy?'Copy of '+pg.name:'Edit '+pg.name,copy?'':pg.name)} function peOpen(pg,copy){pe={orig:copy?'':pg.name,page:JSON.parse(JSON.stringify(pg.page)),sel:-1,boxes:[],pos:[]};peShow(copy?'Copy of '+pg.name:'Edit '+pg.name,copy?'':pg.name)}
function peNew(){pe={orig:'',page:{slots:[{start:'00:00',bitmap:''}],elements:[newEl('clock')]},sel:-1,boxes:[],pos:[]};peShow('New page','')} function peNew(){pe={orig:'',page:{slots:[{start:'00:00',bitmap:''}],elements:[newEl('clock')]},sel:-1,boxes:[],pos:[]};peShow('New page','')}
@@ -364,6 +366,12 @@ function peElems(){const d=$('pe_elems');d.innerHTML='';d.append(mk('datalist',{
row(field(e.entity?'Bitmap name start':'Bitmap',name),field('Entity (optional)',inp(e,'entity','text',{placeholder:'weather.knmi_home',size:24,maxLength:64})), row(field(e.entity?'Bitmap name start':'Bitmap',name),field('Entity (optional)',inp(e,'entity','text',{placeholder:'weather.knmi_home',size:24,maxLength:64})),
field('Attribute',inp(e,'attribute','text',{placeholder:'(the state)',size:10,maxLength:32})),btn('Test',()=>haTest(e))); field('Attribute',inp(e,'attribute','text',{placeholder:'(the state)',size:10,maxLength:32})),btn('Test',()=>haTest(e)));
rows.push(mk('p',{className:'hint',textContent:'With an entity the bitmap is the name start + the state (e.g. "weather " + "cloudy" = "weather cloudy"), or the name start + "default" when there is none. The image is drawn with its top left corner at X, Y.'}))} rows.push(mk('p',{className:'hint',textContent:'With an entity the bitmap is the name start + the state (e.g. "weather " + "cloudy" = "weather cloudy"), or the name start + "default" when there is none. The image is drawn with its top left corner at X, Y.'}))}
if(e.type=='bar'){row(field('Entity',inp(e,'entity','text',{placeholder:'sensor.{plex_user}_progress',size:28,maxLength:64})),
field('Attribute',inp(e,'attribute','text',{placeholder:'(the state)',size:12,maxLength:32})),btn('Test',()=>haTest(e)));
row(field('Full at',inpf(e,'bar_max',{style:'width:6em'})),field('Multiply by',inpf(e,'factor',{style:'width:6em'})),
field('Width',inp(e,'bar_width','number',{style:'width:4em',min:1,max:84})),field('Height',inp(e,'bar_height','number',{style:'width:4em',min:1,max:19})),
chk(e,'bar_frame','frame (from 3 x 3 dots)'));
rows.push(mk('p',{className:'hint',textContent:'Filled from the left: value x multiplier / full at (e.g. a percentage: full at 100; seconds of a duration: use an entity with the percentage). Unknown values show an empty bar.'}))}
const fonts=fontList.map(f=>[f.name,f.name+' ('+f.height+')']);if(!fontList.some(f=>same(f.name,e.font)))fonts.push([e.font,e.font+' (gone)']); const fonts=fontList.map(f=>[f.name,f.name+' ('+f.height+')']);if(!fontList.some(f=>same(f.name,e.font)))fonts.push([e.font,e.font+' (gone)']);
const pos=[field('X',inp(e,'x','number',{style:'width:5em'})),field('Y',inp(e,'y','number',{style:'width:5em'}))]; const pos=[field('X',inp(e,'x','number',{style:'width:5em'})),field('Y',inp(e,'y','number',{style:'width:5em'}))];
const ovl=field('Overlay',sel(e,'overlay',[['top','on top'],['box','box (clear behind)'],['invert','inverted']])); const ovl=field('Overlay',sel(e,'overlay',[['top','on top'],['box','box (clear behind)'],['invert','inverted']]));
@@ -371,6 +379,7 @@ function peElems(){const d=$('pe_elems');d.innerHTML='';d.append(mk('datalist',{
row(...pos,field('Align',sel(e,'align',[['left','left'],['center','center'],['right','right']])),field('Font',sel(e,'font',fonts)),ovl); row(...pos,field('Align',sel(e,'align',[['left','left'],['center','center'],['right','right']])),field('Font',sel(e,'font',fonts)),ovl);
row(field('Scroll in a window of',inp(e,'scroll_width','number',{style:'width:5em',min:0,max:168})),'dots (0: off), at', row(field('Scroll in a window of',inp(e,'scroll_width','number',{style:'width:5em',min:0,max:168})),'dots (0: off), at',
inp(e,'scroll_speed','number',{style:'width:4em',min:1,max:30}),'dots per second')} inp(e,'scroll_speed','number',{style:'width:4em',min:1,max:30}),'dots per second')}
else if(e.type=='bar')row(...pos,field('Align',sel(e,'align',[['left','left'],['center','center'],['right','right']])),ovl);
else row(...pos,ovl); else row(...pos,ovl);
box.append(...rows);d.append(box)})} box.append(...rows);d.append(box)})}
function peAdd(t){if(pe.page.elements.length>=8){msg('8 elements at most',true);return}pe.page.elements.push(newEl(t));pe.sel=pe.page.elements.length-1;peElems();pePreview()} function peAdd(t){if(pe.page.elements.length>=8){msg('8 elements at most',true);return}pe.page.elements.push(newEl(t));pe.sel=pe.page.elements.length-1;peElems();pePreview()}
@@ -397,7 +406,10 @@ pc.onpointermove=e=>{if(!peDrag)return;const[x,y]=pcPos(e),el=pe.page.elements[p
pc.onpointerup=()=>{if(!peDrag)return;peDrag=null;peElems()}; pc.onpointerup=()=>{if(!peDrag)return;peDrag=null;peElems()};
document.addEventListener('keydown',e=>{if(!pe||pe.sel<0||/INPUT|SELECT|TEXTAREA/.test(e.target.tagName))return;const d={ArrowLeft:[-1,0],ArrowRight:[1,0],ArrowUp:[0,-1],ArrowDown:[0,1]}[e.key]; document.addEventListener('keydown',e=>{if(!pe||pe.sel<0||/INPUT|SELECT|TEXTAREA/.test(e.target.tagName))return;const d={ArrowLeft:[-1,0],ArrowRight:[1,0],ArrowUp:[0,-1],ArrowDown:[0,1]}[e.key];
if(!d||!$('t-pages').classList.contains('on'))return;e.preventDefault();const el=pe.page.elements[pe.sel];el.x+=d[0];el.y+=d[1];peDraw();peElems();pePreview()}); if(!d||!$('t-pages').classList.contains('on'))return;e.preventDefault();const el=pe.page.elements[pe.sel];el.x+=d[0];el.y+=d[1];peDraw();peElems();pePreview()});
async function haTest(e){const r=await api('/api/ha/value?entity='+encodeURIComponent(e.entity)+'&attribute='+encodeURIComponent(e.attribute));msg(e.entity+(e.attribute?' '+e.attribute:'')+' = '+r.value);pePreview()} // The ESP32 reads the value in the background: ask again until it is there
async function haValue(entity,attribute){for(let i=0;i<25;i++){const r=await api('/api/ha/value?entity='+encodeURIComponent(entity)+'&attribute='+encodeURIComponent(attribute));
if(!r.pending)return r.value;await new Promise(f=>setTimeout(f,300))}msg('No answer from Home Assistant',true);throw 0}
async function haTest(e){const v=await haValue(e.entity,e.attribute);msg(e.entity+(e.attribute?' '+e.attribute:'')+' = '+v);pePreview()}
async function peSave(){const n=$('pe_name').value.trim();if(!n){msg('Enter a name',true);return} async function peSave(){const n=$('pe_name').value.trim();if(!n){msg('Enter a name',true);return}
await post('/api/pages/save',{name:n,page:pePage()});pe.orig=n;$('pe_title').textContent='Edit '+n;msg('Saved '+n);loadLists()} await post('/api/pages/save',{name:n,page:pePage()});pe.orig=n;$('pe_title').textContent='Edit '+n;msg('Saved '+n);loadLists()}
async function peLive(){if(peData)await live(peData)} async function peLive(){if(peData)await live(peData)}
@@ -431,9 +443,9 @@ async function fontSave(){const n=$('f_name').value.trim();if(!n||!fontData){msg
// backup: everything in one JSON file; the import posts the chosen parts to the normal endpoints // backup: everything in one JSON file; the import posts the chosen parts to the normal endpoints
const SGROUPS=[['schedule','Schedule',s=>({schedule:s.schedule})], const SGROUPS=[['schedule','Schedule',s=>({schedule:s.schedule})],
['display','Shown page, off image, No WiFi screen',s=>({page:s.page,off_page:s.off_page,clock:s.clock})], ['display','Shown page, off image, No WiFi screen',s=>({page:s.page,off_page:s.off_page,clock:s.clock})],
['hardware','Hardware (flip settings, refresh effect, status LED)',s=>({flip_time:s.flip_time,drive:s.drive,status_led:s.status_led})], ['hardware','Hardware (flip settings, refresh effect, status LED)',s=>({flip_time:s.flip_time,drive:s.drive,refresh:s.refresh,status_led:s.status_led})],
['mqtt','Home Assistant MQTT (restarts the ESP32)',s=>{const h=s.home_assistant||{},o={enabled:h.enabled,host:h.host,port:h.port,user:h.user};if(h.password!==undefined)o.password=h.password;return{home_assistant:o}}], ['mqtt','Home Assistant MQTT (restarts the ESP32)',s=>{const h=s.home_assistant||{},o={enabled:h.enabled,host:h.host,port:h.port,user:h.user};if(h.password!==undefined)o.password=h.password;return{home_assistant:o}}],
['haapi','Home Assistant values (URL, token)',s=>{const h=s.home_assistant||{},o={api_url:h.api_url};if(h.token!==undefined)o.token=h.token;return{home_assistant:o}}]]; ['haapi','Home Assistant values (URL, token, Plex user)',s=>{const h=s.home_assistant||{},o={api_url:h.api_url};if(h.token!==undefined)o.token=h.token;return{home_assistant:o,plex_user:s.plex_user}}]];
function merge(a,b){for(const k in b){if(b[k]===undefined)continue;a[k]=b[k]&&typeof b[k]=='object'&&!Array.isArray(b[k])?merge(a[k]||{},b[k]):b[k]}return a} function merge(a,b){for(const k in b){if(b[k]===undefined)continue;a[k]=b[k]&&typeof b[k]=='object'&&!Array.isArray(b[k])?merge(a[k]||{},b[k]):b[k]}return a}
function download(out,file){const a=mk('a',{href:URL.createObjectURL(new Blob([JSON.stringify(out,null,1)],{type:'application/json'})),download:file}); function download(out,file){const a=mk('a',{href:URL.createObjectURL(new Blob([JSON.stringify(out,null,1)],{type:'application/json'})),download:file});
document.body.append(a);a.click();a.remove()} document.body.append(a);a.click();a.remove()}
@@ -543,14 +555,14 @@ function fill(){const s=settings;$('s_en').checked=s.schedule.enabled;$('s_day')
$('c_nw').checked=s.clock.no_wifi_screen;$('h_flip').value=s.flip_time;$('h_led').checked=s.status_led; $('c_nw').checked=s.clock.no_wifi_screen;$('h_flip').value=s.flip_time;$('h_led').checked=s.status_led;
$('h_gap').value=s.drive.flip_gap;$('h_pulses').value=s.drive.pulses;$('h_effect').value=s.drive.effect;$('h_efft').value=s.drive.effect_time;$('h_small').checked=s.drive.effect_small_only;$('h_rdots').value=s.refresh.dots;$('h_rimg').value=s.refresh.image; $('h_gap').value=s.drive.flip_gap;$('h_pulses').value=s.drive.pulses;$('h_effect').value=s.drive.effect;$('h_efft').value=s.drive.effect_time;$('h_small').checked=s.drive.effect_small_only;$('h_rdots').value=s.refresh.dots;$('h_rimg').value=s.refresh.image;
$('ha_en').checked=s.home_assistant.enabled;$('ha_host').value=s.home_assistant.host;$('ha_port').value=s.home_assistant.port;$('ha_user').value=s.home_assistant.user; $('ha_en').checked=s.home_assistant.enabled;$('ha_host').value=s.home_assistant.host;$('ha_port').value=s.home_assistant.port;$('ha_user').value=s.home_assistant.user;
$('ha_pass').placeholder=s.home_assistant.password_set?'unchanged':'none';$('ha_url').value=s.home_assistant.api_url; $('ha_pass').placeholder=s.home_assistant.password_set?'unchanged':'none';$('ha_url').value=s.home_assistant.api_url;$('ha_plex').value=s.plex_user||'';
$('ha_token').value='';$('ha_token').placeholder=s.home_assistant.token_set?'unchanged':'none'} $('ha_token').value='';$('ha_token').placeholder=s.home_assistant.token_set?'unchanged':'none'}
function collect(){const s=JSON.parse(JSON.stringify(settings)); function collect(){const s=JSON.parse(JSON.stringify(settings));
Object.assign(s.schedule,{enabled:$('s_en').checked,day_start:$('s_day').value,late_start:$('s_late').value,quarter_hours:$('s_q').checked,night_start:$('s_night').value.trim()}); Object.assign(s.schedule,{enabled:$('s_en').checked,day_start:$('s_day').value,late_start:$('s_late').value,quarter_hours:$('s_q').checked,night_start:$('s_night').value.trim()});
s.clock={no_wifi_screen:$('c_nw').checked}; s.clock={no_wifi_screen:$('c_nw').checked};
s.flip_time=+$('h_flip').value;s.status_led=$('h_led').checked; s.flip_time=+$('h_flip').value;s.status_led=$('h_led').checked;
s.drive={flip_gap:+$('h_gap').value,pulses:+$('h_pulses').value,effect:+$('h_effect').value,effect_time:+$('h_efft').value,effect_small_only:$('h_small').checked};s.refresh={dots:+$('h_rdots').value,image:+$('h_rimg').value}; s.drive={flip_gap:+$('h_gap').value,pulses:+$('h_pulses').value,effect:+$('h_effect').value,effect_time:+$('h_efft').value,effect_small_only:$('h_small').checked};s.refresh={dots:+$('h_rdots').value,image:+$('h_rimg').value};
Object.assign(s.home_assistant,{enabled:$('ha_en').checked,host:$('ha_host').value.trim(),port:+$('ha_port').value,user:$('ha_user').value.trim(),api_url:$('ha_url').value.trim()}); Object.assign(s.home_assistant,{enabled:$('ha_en').checked,host:$('ha_host').value.trim(),port:+$('ha_port').value,user:$('ha_user').value.trim(),api_url:$('ha_url').value.trim()});s.plex_user=$('ha_plex').value.trim();
if($('ha_pass').value)s.home_assistant.password=$('ha_pass').value;if($('ha_token').value)s.home_assistant.token=$('ha_token').value.trim();return s} if($('ha_pass').value)s.home_assistant.password=$('ha_pass').value;if($('ha_token').value)s.home_assistant.token=$('ha_token').value.trim();return s}
async function saveSettings(){const r=await post('/api/settings',collect());settings=r.settings;fill();msg(r.restart?'Saved, restarting...':'Settings saved')} async function saveSettings(){const r=await post('/api/settings',collect());settings=r.settings;fill();msg(r.restart?'Saved, restarting...':'Settings saved')}
async function saveHa(){await saveSettings()} async function saveHa(){await saveSettings()}
+37 -14
View File
@@ -224,11 +224,26 @@ static void handleScreen()
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Streamed: with many bitmaps the JSON is too big to build in memory at once // Streamed: with many bitmaps the JSON is too big to build in memory at once
// Long lists are sent in pieces of about this many bytes. Small pieces (like a lone comma)
// each wait for the browser's delayed ACK (Nagle), up to 200 ms each, while loop() waits.
static const size_t SEND_PIECE = 2048;
static void sendPiece(String &out, bool last = false)
{
if (out.length() >= SEND_PIECE || (last && out.length()))
{
server.sendContent(out);
out = "";
}
if (last)
server.sendContent(""); // end of the chunked answer
}
static void handleBitmaps() static void handleBitmaps()
{ {
server.setContentLength(CONTENT_LENGTH_UNKNOWN); server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, "application/json", ""); server.send(200, "application/json", "");
server.sendContent(bitmapStorageAvailable() ? "{\"storage\":true,\"bitmaps\":[" : "{\"storage\":false,\"bitmaps\":["); String out = bitmapStorageAvailable() ? "{\"storage\":true,\"bitmaps\":[" : "{\"storage\":false,\"bitmaps\":[";
bool first = true; bool first = true;
uint8_t bitmap[BITMAP_BYTES]; uint8_t bitmap[BITMAP_BYTES];
@@ -239,13 +254,13 @@ static void handleBitmaps()
continue; continue;
bitmapToHex(bitmap, hex); bitmapToHex(bitmap, hex);
// Names only contain letters, digits, space, '-' and '_': no JSON escaping needed // Names only contain letters, digits, space, '-' and '_': no JSON escaping needed
String item = String(first ? "" : ",") + "{\"name\":\"" + info.name + "\",\"builtin\":" + out += String(first ? "" : ",") + "{\"name\":\"" + info.name + "\",\"builtin\":" +
(info.builtin ? "true" : "false") + ",\"data\":\"" + hex + "\"}"; (info.builtin ? "true" : "false") + ",\"data\":\"" + hex + "\"}";
server.sendContent(item);
first = false; first = false;
sendPiece(out);
} }
server.sendContent("]}"); out += "]}";
server.sendContent(""); sendPiece(out, true);
} }
static void handleBitmapGet() static void handleBitmapGet()
@@ -297,7 +312,7 @@ static void handlePages()
{ {
server.setContentLength(CONTENT_LENGTH_UNKNOWN); server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, "application/json", ""); server.send(200, "application/json", "");
server.sendContent("{\"pages\":["); String out = "{\"pages\":[";
bool first = true; bool first = true;
DynamicPage *page = new DynamicPage; DynamicPage *page = new DynamicPage;
@@ -316,15 +331,16 @@ static void handlePages()
doc["data"] = hex; doc["data"] = hex;
pageToJson(*page, doc["page"].to<JsonObject>()); pageToJson(*page, doc["page"].to<JsonObject>());
String item; String item;
serializeJson(doc, item); // replaces the string's content, so the comma is sent apart serializeJson(doc, item); // replaces the string's content, so it is added to out after
if (!first) if (!first)
server.sendContent(","); out += ",";
server.sendContent(item); out += item;
first = false; first = false;
sendPiece(out);
} }
delete page; delete page;
server.sendContent("]}"); out += "]}";
server.sendContent(""); sendPiece(out, true);
} }
static void handlePageSave() static void handlePageSave()
@@ -457,15 +473,20 @@ static void handleFontDelete()
// Reads a value now (test button in the page editor); it is then also used in previews // Reads a value now (test button in the page editor); it is then also used in previews
static void handleHaValue() static void handleHaValue()
{ {
// Read in the background: {"pending":true} until the value is there (ask again)
String value; String value;
const char *error = haApiFetchNow(server.arg("entity").c_str(), server.arg("attribute").c_str(), value); bool pending;
const char *error = haApiPreview(server.arg("entity").c_str(), server.arg("attribute").c_str(), value, pending);
if (error) if (error)
{ {
sendError(400, error); sendError(400, error);
return; return;
} }
JsonDocument doc; JsonDocument doc;
doc["value"] = value; if (pending)
doc["pending"] = true;
else
doc["value"] = value;
sendJson(200, doc); sendJson(200, doc);
} }
@@ -612,6 +633,7 @@ static void settingsToJson(const Settings &s, JsonObject o)
ha["user"] = s.home_assistant.user; ha["user"] = s.home_assistant.user;
ha["password_set"] = s.home_assistant.password[0] != '\0'; ha["password_set"] = s.home_assistant.password[0] != '\0';
ha["api_url"] = s.home_assistant.api_url; ha["api_url"] = s.home_assistant.api_url;
o["plex_user"] = s.plex_user;
ha["token_set"] = s.home_assistant.token[0] != '\0'; ha["token_set"] = s.home_assistant.token[0] != '\0';
} }
@@ -679,6 +701,7 @@ static void handleSettingsPost()
copyText(ha["user"], s.home_assistant.user, sizeof(s.home_assistant.user)); copyText(ha["user"], s.home_assistant.user, sizeof(s.home_assistant.user));
copyText(ha["password"], s.home_assistant.password, sizeof(s.home_assistant.password)); copyText(ha["password"], s.home_assistant.password, sizeof(s.home_assistant.password));
copyText(ha["api_url"], s.home_assistant.api_url, sizeof(s.home_assistant.api_url)); copyText(ha["api_url"], s.home_assistant.api_url, sizeof(s.home_assistant.api_url));
copyText(body["plex_user"], s.plex_user, sizeof(s.plex_user));
copyText(ha["token"], s.home_assistant.token, sizeof(s.home_assistant.token)); copyText(ha["token"], s.home_assistant.token, sizeof(s.home_assistant.token));
bool restart = false; bool restart = false;
+139
View File
@@ -2029,6 +2029,141 @@ void test_frame_check_periodic_resync()
TEST_ASSERT_EQUAL_UINT32(0, c.stats().repairs); // not counted as a repair TEST_ASSERT_EQUAL_UINT32(0, c.stats().repairs); // not counted as a repair
} }
static int countOn(const uint8_t *b)
{
int n = 0;
for (int y = 0; y < BITMAP_HEIGHT; y++)
for (int x = 0; x < BITMAP_WIDTH; x++)
n += bitmapPixel(b, x, y);
return n;
}
static DynamicPage barPage()
{
DynamicPage page;
page.element_count = 1;
PageElement &e = page.elements[0];
e.type = ElementType::Bar;
strcpy(e.entity, "sensor.{plex_user}_progress");
e.x = 10;
e.y = 14;
e.bar_width = 22; // 20 inside the frame
e.bar_height = 4;
return page;
}
void test_bar_fill()
{
PageElement e = barPage().elements[0];
TEST_ASSERT_EQUAL(0, barFill(e, nullptr, 20));
TEST_ASSERT_EQUAL(0, barFill(e, "unavailable", 20));
TEST_ASSERT_EQUAL(0, barFill(e, "-5", 20));
TEST_ASSERT_EQUAL(0, barFill(e, "0", 20));
TEST_ASSERT_EQUAL(10, barFill(e, "50", 20));
TEST_ASSERT_EQUAL(7, barFill(e, "33.3", 20));
TEST_ASSERT_EQUAL(20, barFill(e, "100", 20));
TEST_ASSERT_EQUAL(20, barFill(e, "250", 20));
e.bar_max = 1;
e.factor = 0.01f; // 0-100 as 0-1
TEST_ASSERT_EQUAL(5, barFill(e, "25", 20));
e.bar_max = 7200;
e.factor = 1;
TEST_ASSERT_EQUAL(10, barFill(e, "3600", 20)); // e.g. seconds of a 2 hour film
}
void test_bar_render()
{
FakeResources res;
DynamicPage page = barPage();
TEST_ASSERT_NULL(validatePage(page));
uint8_t b[BITMAP_BYTES];
ElementBox box;
res.value = "50";
renderPage(page, at(12, 0), 0, res, b, &box);
TEST_ASSERT_EQUAL(10, box.x);
TEST_ASSERT_EQUAL(14, box.y);
TEST_ASSERT_EQUAL(22, box.width);
TEST_ASSERT_EQUAL(4, box.height);
// Frame: 2 x 22 + 2 x 2 dots; inside 10 of 20 columns x 2 rows
TEST_ASSERT_EQUAL(2 * 22 + 2 * 2 + 10 * 2, countOn(b));
TEST_ASSERT_TRUE(bitmapPixel(b, 10, 14)); // corner
TEST_ASSERT_TRUE(bitmapPixel(b, 11, 15)); // first filled dot
TEST_ASSERT_TRUE(bitmapPixel(b, 20, 16)); // last filled dot
TEST_ASSERT_FALSE(bitmapPixel(b, 21, 16)); // empty half
res.value = nullptr; // unknown: only the frame
renderPage(page, at(12, 0), 0, res, b);
TEST_ASSERT_EQUAL(2 * 22 + 2 * 2, countOn(b));
// Without frame, right aligned, inverted over a full background
PageElement &e = page.elements[0];
e.bar_frame = false;
e.align = Align::Right;
e.x = 84;
e.overlay = Overlay::Invert;
page.slot_count = 1;
strcpy(page.slots[0].bitmap, "Full");
res.value = "100";
renderPage(page, at(12, 0), 0, res, b, &box);
TEST_ASSERT_EQUAL(62, box.x);
TEST_ASSERT_EQUAL(BITMAP_WIDTH * BITMAP_HEIGHT - 22 * 4, countOn(b));
TEST_ASSERT_FALSE(bitmapPixel(b, 83, 17));
TEST_ASSERT_TRUE(bitmapPixel(b, 61, 17));
}
void test_bar_validation()
{
DynamicPage page = barPage();
page.elements[0].entity[0] = '\0';
TEST_ASSERT_NOT_NULL(validatePage(page));
page = barPage();
page.elements[0].bar_width = 0;
TEST_ASSERT_NOT_NULL(validatePage(page));
page = barPage();
page.elements[0].bar_height = 20;
TEST_ASSERT_NOT_NULL(validatePage(page));
page = barPage();
page.elements[0].bar_max = 0;
TEST_ASSERT_NOT_NULL(validatePage(page));
page = barPage();
page.elements[0].bar_max = NAN;
TEST_ASSERT_NOT_NULL(validatePage(page));
TEST_ASSERT_TRUE(elementUsesHa(barPage().elements[0]));
}
void test_plex_user_placeholder()
{
char out[ELEMENT_ENTITY_SIZE];
TEST_ASSERT_TRUE(expandEntity("sensor.{plex_user}_state", "mangavalk", out, sizeof(out)));
TEST_ASSERT_EQUAL_STRING("sensor.mangavalk_state", out);
TEST_ASSERT_TRUE(expandEntity("sensor.tautulli_watching", "mangavalk", out, sizeof(out)));
TEST_ASSERT_EQUAL_STRING("sensor.tautulli_watching", out);
// No user set: not a valid entity id, so nothing is read
TEST_ASSERT_TRUE(expandEntity("sensor.{plex_user}", "", out, sizeof(out)));
TEST_ASSERT_FALSE(validEntityId(out));
// Too long once filled in
char user[PLEX_USER_SIZE];
memset(user, 'a', sizeof(user) - 1);
user[sizeof(user) - 1] = '\0';
TEST_ASSERT_FALSE(expandEntity("sensor.{plex_user}_a_long_name_that_is_too_long", user, out, sizeof(out)));
TEST_ASSERT_TRUE(validEntityId("sensor.{plex_user}_state"));
TEST_ASSERT_TRUE(validEntityId("sensor.{plex_user}"));
TEST_ASSERT_FALSE(validEntityId("sensor.{plex_user}{plex_user}"));
TEST_ASSERT_FALSE(validEntityId("sensor.{other}_state"));
TEST_ASSERT_FALSE(validEntityId("{plex_user}")); // no domain
plexUserSlug("MangaValk", user, sizeof(user));
TEST_ASSERT_EQUAL_STRING("mangavalk", user);
plexUserSlug(" Manga Valk-2 ", user, sizeof(user));
TEST_ASSERT_EQUAL_STRING("manga_valk_2", user);
plexUserSlug("", user, sizeof(user));
TEST_ASSERT_EQUAL_STRING("", user);
char small[5];
plexUserSlug("ab cd ef", small, sizeof(small));
TEST_ASSERT_EQUAL_STRING("ab_c", small);
}
int main() int main()
{ {
UNITY_BEGIN(); UNITY_BEGIN();
@@ -2158,6 +2293,10 @@ int main()
RUN_TEST(test_frame_check_no_reply); RUN_TEST(test_frame_check_no_reply);
RUN_TEST(test_frame_check_errors_and_restarts); RUN_TEST(test_frame_check_errors_and_restarts);
RUN_TEST(test_frame_check_periodic_resync); RUN_TEST(test_frame_check_periodic_resync);
RUN_TEST(test_bar_fill);
RUN_TEST(test_bar_render);
RUN_TEST(test_bar_validation);
RUN_TEST(test_plex_user_placeholder);
return UNITY_END(); return UNITY_END();
} }
+22 -2
View File
@@ -294,11 +294,23 @@ bitmaps['bg nacht'] = night
bitmaps['bg kader'] = corners() bitmaps['bg kader'] = corners()
bitmaps['bg vol'] = [[1] * W for _ in range(H)] bitmaps['bg vol'] = [[1] * W for _ in range(H)]
# Plex: the chevron (a thick ">", tip on the middle row) and the Tautulli state icons
plex = blank()
for y in range(1, 18):
start = (8 - abs(y - 9)) // 2
for x in range(start, start + 5):
plex[y][x] = 1
bitmaps['plex logo'] = plex
bitmaps['plex playing'] = put(blank(), ["#..", "##.", "###", "##.", "#.."], 0, 0)
bitmaps['plex paused'] = put(blank(), ["##.##"] * 5, 0, 0)
bitmaps['plex buffering'] = put(blank(), [".....", ".....", ".....", ".....", "#.#.#"], 0, 0)
def el(type, x, y, font='Normal', **kw): def el(type, x, y, font='Normal', **kw):
e = {'type': type, 'x': x, 'y': y, 'align': 'left', 'overlay': 'top', 'font': font, 'format': 0, e = {'type': type, 'x': x, 'y': y, 'align': 'left', 'overlay': 'top', 'font': font, 'format': 0,
'language': 'nl', 'decimals': -1, 'text': '', 'suffix': '', 'entity': '', 'attribute': '', 'language': 'nl', 'decimals': -1, 'text': '', 'suffix': '', 'entity': '', 'attribute': '',
'factor': 1, 'scroll_width': 0, 'scroll_speed': 8, 'target': '2000-01-01T00:00'} 'factor': 1, 'scroll_width': 0, 'scroll_speed': 8, 'target': '2000-01-01T00:00',
'bar_width': 40, 'bar_height': 3, 'bar_max': 100, 'bar_frame': True}
e.update(kw) e.update(kw)
return e return e
@@ -364,10 +376,18 @@ pages = {
'Stopwatch': {'slots': [], 'elements': [ 'Stopwatch': {'slots': [], 'elements': [
el('stopwatch', 42, 0, 'Full height', align='center'), el('stopwatch', 42, 0, 'Full height', align='center'),
]}, ]},
# Plex: how many watch (Plex integration), and for the Plex user of the settings
# (Tautulli's sensors): play/pause icon, title (scrolls) and a progress bar
'Plex': {'slots': [{'start': '00:00', 'bitmap': 'plex logo'}], 'elements': [
el('ha', 13, 0, entity='sensor.mangavalkserver', suffix=' watching'),
el('image', 13, 9, text='plex ', entity='sensor.{plex_user}_state'),
el('ha', 19, 9, 'Small', entity='sensor.{plex_user}_full_title', scroll_width=65, scroll_speed=8),
el('bar', 13, 16, entity='sensor.{plex_user}_progress', bar_width=71, bar_height=3),
]},
} }
backup = { backup = {
'format': 'flipdot-backup', 'version': 1, 'firmware': '2.5.0', 'exported': '2026-10-15T00:00:00Z', 'format': 'flipdot-backup', 'version': 1, 'firmware': '2.9.0', 'exported': '2026-10-16T00:00:00Z',
'bitmaps': [{'name': n, 'data': to_hex(b)} for n, b in bitmaps.items()], 'bitmaps': [{'name': n, 'data': to_hex(b)} for n, b in bitmaps.items()],
'pages': [{'name': n, 'page': p} for n, p in pages.items()], 'pages': [{'name': n, 'page': p} for n, p in pages.items()],
'fonts': [], 'fonts': [],