Weather forecast page, 16 elements per page, attribute paths, examples from the display

- Home Assistant attributes can be paths into lists (forecast.1.condition).
- Date elements can show a day ahead (day offset 0-7).
- Pages hold 16 elements (page format 6; the old formats are frozen at 8).
  Pages are reset in place (clearPage), not through a 4 KB temporary.
- Verwachting page: today (icon, temperature, max/min) and the next 2
  days with 7 x 7 icons (weer7 <condition>), from a template sensor
  (docs/home-assistant.md).
- examples/home-pages.json is exported from the display with all pages
  and their icons, private entity id parts replaced by placeholders
  (tools/export_examples.py replaces tools/make_example_pages.py).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-16 22:36:29 +02:00
1 parent 839e86e8f1
commit a6b946e17c
33 files changed
+1918 -1050

No files matched your search

+8
View File
@@ -168,6 +168,14 @@ See README.md for the overview.
old hardcoded clock (also the default and the HA "Clock" option). `pageStoreBegin()` old hardcoded clock (also the default and the HA "Clock" option). `pageStoreBegin()`
stores a copy of each built-in page (`logic/builtin_pages`) at boot when none is stored, stores a copy of each built-in page (`logic/builtin_pages`) at boot when none is stored,
so it can be edited; the built-in one is the fallback, and deleting the copy resets it. so it can be edited; the built-in one is the fallback, and deleting the copy resets it.
- Pages hold 16 elements (format 6). The old formats in `page_store.cpp` are frozen at
`OLD_PAGE_ELEMENTS` (8). A `DynamicPage` is about 4 KB: allocate it with `new`, and reset
it with `clearPage()`, not `page = DynamicPage()` (a temporary on the 8 KB loop stack).
- HA attributes may be paths into lists (`forecast.1.condition`, `net/ha_api`). The
forecast comes from the template sensor in `docs/home-assistant.md`.
- `examples/home-pages.json` is written by `tools/export_examples.py` from the display.
Its PRIVATE map replaces private parts of entity ids; extend it for new pages and check
the output.
- `ha_api` attribute lookups: cast `char` arrays to `const char *` for ArduinoJson keys. - `ha_api` attribute lookups: cast `char` arrays to `const char *` for ArduinoJson keys.
ArduinoJson 7.4 takes a `char[N]` as a literal of length N-1, which never matches (that ArduinoJson 7.4 takes a `char[N]` as a literal of length N-1, which never matches (that
broke all attributes from 2.9.0 to 2.10.0). broke all attributes from 2.9.0 to 2.10.0).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

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: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

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: 159 KiB

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 346 KiB

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 414 KiB

After

Width:  |  Height:  |  Size: 405 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 367 KiB

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 131 KiB

+35 -2
View File
@@ -50,12 +50,45 @@ 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.
## Weather forecast
Weather entities have no forecast attribute anymore (since Home Assistant 2024.3); the
forecast comes from the `weather.get_forecasts` action. The **Verwachting** page reads it
from a template sensor that keeps the next days as an attribute. Add to
`configuration.yaml` (and restart Home Assistant, or reload the template entities):
```yaml
template:
- trigger:
- trigger: time_pattern
minutes: /30
- trigger: homeassistant
event: start
action:
- action: weather.get_forecasts
data: { type: daily }
target: { entity_id: weather.knmi_home }
response_variable: f
sensor:
- name: Weer verwachting
unique_id: weer_verwachting
state: "{{ f['weather.knmi_home'].forecast[0].condition }}"
attributes:
forecast: "{{ f['weather.knmi_home'].forecast[:4] }}"
```
The page then reads `sensor.weer_verwachting` with attribute paths: `forecast.0` is today,
`forecast.1` tomorrow, `forecast.2` the day after, e.g. `forecast.1.condition` (chooses the icon `weer7 <condition>`,
7 x 7 dots), `forecast.1.temperature` (max) and `forecast.1.templow` (min). The weekdays
are Date elements with a day offset. Use another weather entity by replacing
`weather.knmi_home` in the YAML (and on the page, for today's icon and temperature).
## Plex and Tautulli ## Plex and Tautulli
The **Plex** page (in [examples/home-pages.json](../examples/home-pages.json)) uses Home The **Plex** page (in [examples/home-pages.json](../examples/home-pages.json)) uses Home
Assistant's Plex and Tautulli integrations, no extra firmware: Assistant's Plex and Tautulli integrations, no extra firmware:
- `sensor.mangavalkserver` (Plex): how many are watching. - `sensor.plex_server` (Plex, named after your server): how many are watching.
- `sensor.{plex_user}_state` (Tautulli, per user): `playing`, `paused`, ... chooses the icon - `sensor.{plex_user}_state` (Tautulli, per user): `playing`, `paused`, ... chooses the icon
(icons `plex playing`, `plex paused`, `plex buffering`). (icons `plex playing`, `plex paused`, `plex buffering`).
- `sensor.{plex_user}_full_title` and `sensor.{plex_user}_progress` (Tautulli, per user): - `sensor.{plex_user}_full_title` and `sensor.{plex_user}_progress` (Tautulli, per user):
@@ -64,4 +97,4 @@ Assistant's Plex and Tautulli integrations, no extra firmware:
`{plex_user}` is the Plex user set in the web UI (Home Assistant tab), written the way Home `{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 Assistant puts it in entity ids (`MangaValk` -> `mangavalk`). A rule (Schedule tab) adds
the page to the rotation while `sensor.mangavalkserver` is above 0. the page to the rotation while `sensor.plex_server` is above 0.
+10 -4
View File
@@ -3,22 +3,28 @@
[← Back to the README](../README.md) [← Back to the README](../README.md)
The dynamic pages on the display, drawn by the ESP32 itself (`python The dynamic pages on the display, drawn by the ESP32 itself (`python
tools/make_page_screenshots.py` makes these pictures again). The Clock page is built in; tools/make_page_screenshots.py` makes these pictures again). The Clock page comes with the firmware (it can be changed, Reset brings back the original);
the others are the example pages in [examples/home-pages.json](../examples/home-pages.json) the others are the example pages in [examples/home-pages.json](../examples/home-pages.json)
(import them in the web UI, System tab; they use this home's Home Assistant entities). with their icons (import them in the web UI, System tab). They use this home's Home
Assistant entities: change them to yours in the page editor (`climate.home`,
`sensor.envoy_000000000000_...` and `sensor.plex_server` are placeholders).
`python tools/export_examples.py` writes the file again from the pages on the display and
replaces private parts of entity ids by these placeholders.
| Page | | | Page | |
|---|---| |---|---|
| **Clock** (built in), day and from 17:00 | ![Clock](../Pictures/pages/clock-0000.png) ![Clock, evening](../Pictures/pages/clock-1700.png) | | **Clock** (from the firmware), day and from 17:00 | ![Clock](../Pictures/pages/clock-0000.png) ![Clock, evening](../Pictures/pages/clock-1700.png) |
| **Weer**: clock, weather icon by state, humidity, temperature | ![Weer](../Pictures/pages/weer.png) | | **Weer**: clock, weather icon by state, humidity, temperature | ![Weer](../Pictures/pages/weer.png) |
| **Verwachting**: today (icon, temperature now, max/min) and the next 2 days (weekday, small icon, max/min); needs the [forecast sensor](home-assistant.md#weather-forecast) | ![Verwachting](../Pictures/pages/verwachting.png) |
| **Dag en nacht**: sun from 07:00, moon and stars from 19:00, date | ![Dag en nacht, day](../Pictures/pages/dag-en-nacht-0700.png) ![Dag en nacht, night](../Pictures/pages/dag-en-nacht-1900.png) | | **Dag en nacht**: sun from 07:00, moon and stars from 19:00, date | ![Dag en nacht, day](../Pictures/pages/dag-en-nacht-0700.png) ![Dag en nacht, night](../Pictures/pages/dag-en-nacht-1900.png) |
| **Energie**: net power now, solar today, clock | ![Energie](../Pictures/pages/energie.png) | | **Energie**: net power now, solar today, clock | ![Energie](../Pictures/pages/energie.png) |
| **3D printer**: progress, time left, status message (scrolls), nozzle temperature | ![3D printer](../Pictures/pages/3d-printer.png) | | **3D printer**: progress, time left, status message (scrolls), nozzle temperature | ![3D printer](../Pictures/pages/3d-printer.png) |
| **3D printer finished** (not in the examples): shown for 10 minutes after a print completes (rule), with the status message | ![3D printer finished](../Pictures/pages/3d-printer-finished.png) | | **3D printer finished**: shown for 10 minutes after a print completes (rule), with the status message | ![3D printer finished](../Pictures/pages/3d-printer-finished.png) |
| **Binnen**: room and outside temperature, CO2, air quality | ![Binnen](../Pictures/pages/binnen.png) | | **Binnen**: room and outside temperature, CO2, air quality | ![Binnen](../Pictures/pages/binnen.png) |
| **Verbruik**: water and energy used today, solar today | ![Verbruik](../Pictures/pages/verbruik.png) | | **Verbruik**: water and energy used today, solar today | ![Verbruik](../Pictures/pages/verbruik.png) |
| **Grote klok**: big clock, inverted from 22:00 | ![Grote klok](../Pictures/pages/grote-klok-0700.png) ![Grote klok, night](../Pictures/pages/grote-klok-2200.png) | | **Grote klok**: big clock, inverted from 22:00 | ![Grote klok](../Pictures/pages/grote-klok-0700.png) ![Grote klok, night](../Pictures/pages/grote-klok-2200.png) |
| **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) |
| **Bad**: how full the bath is (a progress bar from a Home Assistant number) and the clock; a rule shows it while the bath fills | ![Bad](../Pictures/pages/bad.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) | | **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) |
+3 -3
View File
@@ -23,7 +23,7 @@ Bitmaps that pages or rules use are in their own subsection.
## Pages ## Pages
Dynamic pages: a background per time of day, a bitmap or an [icon](#icons) (e.g. Pikachu Dynamic pages: a background per time of day, a bitmap or an [icon](#icons) (e.g. Pikachu
from 00:00, yawning Pikachu from 17:00) with up to 8 elements on top. The preview is drawn by the ESP32, so it from 00:00, yawning Pikachu from 17:00) with up to 16 elements on top. The preview is drawn by the ESP32, so it
is exactly what the display shows; drag the elements on it, or select one and use the is exactly what the display shows; drag the elements on it, or select one and use the
arrow keys. The Clock page (Pikachu, the default) is a normal page that can be changed; arrow keys. The Clock page (Pikachu, the default) is a normal page that can be changed;
its Reset button brings back the original. See the [pictures of the pages](pages.md). its Reset button brings back the original. See the [pictures of the pages](pages.md).
@@ -31,8 +31,8 @@ its Reset button brings back the original. See the [pictures of the pages](pages
| Element | Options | | Element | Options |
|---------|---------| |---------|---------|
| Clock | `08:05`, `8:05`, optionally with seconds | | Clock | `08:05`, `8:05`, optionally with seconds |
| Date | 7 formats, Dutch or English (`do 15 okt`, `15 oktober`, `donderdag`, ...) | | Date | 7 formats, Dutch or English (`do 15 okt`, `15 oktober`, `donderdag`, ...), today or up to 7 days ahead (the weekdays of a forecast) |
| Home Assistant value | An entity or attribute, text before and after. Shown as a number (multiplier, decimals, auto unit `1257 W` → `1.3 kW`, without minus sign), a duration (`2h 21m`, `2:21:00`, `2:21`, `2 uur`), a time from an HA timestamp (`23:07`, `do 23:07`, `15-10 23:07`, `over 34 min`) or with own texts per state (`printing=Bezig, standby=Klaar`) | | Home Assistant value | An entity or attribute (also a path into lists, e.g. `forecast.1.temperature`), text before and after. Shown as a number (multiplier, decimals, auto unit `1257 W` → `1.3 kW`, without minus sign), a duration (`2h 21m`, `2:21:00`, `2:21`, `2 uur`), a time from an HA timestamp (`23:07`, `do 23:07`, `15-10 23:07`, `over 34 min`) or with own texts per state (`printing=Bezig, standby=Klaar`) |
| Text | Up to 32 characters | | Text | Up to 32 characters |
| 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 |
+1506 -618
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -118,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.12.0" #define FIRMWARE_VERSION "2.13.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};
+92 -13
View File
@@ -8,7 +8,9 @@
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 = 5; static const uint16_t VERSION = 6;
// Elements per page up to version 5 (the old formats below are frozen with it)
static const int OLD_PAGE_ELEMENTS = 8;
struct StoredPage struct StoredPage
{ {
@@ -42,7 +44,7 @@ struct Page
uint8_t slot_count; uint8_t slot_count;
PageSlot slots[PAGE_SLOTS]; PageSlot slots[PAGE_SLOTS];
uint8_t element_count; uint8_t element_count;
Element elements[PAGE_ELEMENTS]; Element elements[OLD_PAGE_ELEMENTS];
}; };
struct Stored struct Stored
@@ -54,11 +56,11 @@ struct Stored
static void convert(const Page &old, DynamicPage &page) static void convert(const Page &old, DynamicPage &page)
{ {
page = DynamicPage(); clearPage(page);
page.slot_count = old.slot_count; page.slot_count = old.slot_count;
memcpy(page.slots, old.slots, sizeof(page.slots)); memcpy(page.slots, old.slots, sizeof(page.slots));
page.element_count = old.element_count; page.element_count = old.element_count;
for (int i = 0; i < PAGE_ELEMENTS; i++) for (int i = 0; i < OLD_PAGE_ELEMENTS; i++)
{ {
const Element &o = old.elements[i]; const Element &o = old.elements[i];
PageElement &e = page.elements[i]; PageElement &e = page.elements[i];
@@ -108,7 +110,7 @@ struct Page
uint8_t slot_count; uint8_t slot_count;
PageSlot slots[PAGE_SLOTS]; PageSlot slots[PAGE_SLOTS];
uint8_t element_count; uint8_t element_count;
Element elements[PAGE_ELEMENTS]; Element elements[OLD_PAGE_ELEMENTS];
}; };
struct Stored struct Stored
@@ -120,11 +122,11 @@ struct Stored
static void convert(const Page &old, DynamicPage &page) static void convert(const Page &old, DynamicPage &page)
{ {
page = DynamicPage(); clearPage(page);
page.slot_count = old.slot_count; page.slot_count = old.slot_count;
memcpy(page.slots, old.slots, sizeof(page.slots)); memcpy(page.slots, old.slots, sizeof(page.slots));
page.element_count = old.element_count; page.element_count = old.element_count;
for (int i = 0; i < PAGE_ELEMENTS; i++) for (int i = 0; i < OLD_PAGE_ELEMENTS; i++)
{ {
const Element &o = old.elements[i]; const Element &o = old.elements[i];
PageElement &e = page.elements[i]; PageElement &e = page.elements[i];
@@ -187,7 +189,7 @@ struct Page
uint8_t slot_count; uint8_t slot_count;
PageSlot slots[PAGE_SLOTS]; PageSlot slots[PAGE_SLOTS];
uint8_t element_count; uint8_t element_count;
Element elements[PAGE_ELEMENTS]; Element elements[OLD_PAGE_ELEMENTS];
}; };
struct Stored struct Stored
@@ -205,11 +207,11 @@ static_assert(offsetof(PageElement, scroll_speed) >= BEFORE_SPEED &&
static void convert(const Page &old, DynamicPage &page) static void convert(const Page &old, DynamicPage &page)
{ {
page = DynamicPage(); clearPage(page);
page.slot_count = old.slot_count; page.slot_count = old.slot_count;
memcpy(page.slots, old.slots, sizeof(page.slots)); memcpy(page.slots, old.slots, sizeof(page.slots));
page.element_count = old.element_count; page.element_count = old.element_count;
for (int i = 0; i < PAGE_ELEMENTS; i++) for (int i = 0; i < OLD_PAGE_ELEMENTS; i++)
{ {
const Element &o = old.elements[i]; const Element &o = old.elements[i];
PageElement &e = page.elements[i]; PageElement &e = page.elements[i];
@@ -264,7 +266,7 @@ struct Page
uint8_t slot_count; uint8_t slot_count;
PageSlot slots[PAGE_SLOTS]; PageSlot slots[PAGE_SLOTS];
uint8_t element_count; uint8_t element_count;
Element elements[PAGE_ELEMENTS]; Element elements[OLD_PAGE_ELEMENTS];
}; };
struct Stored struct Stored
@@ -283,15 +285,80 @@ static void convert(const Page &old, DynamicPage &page)
mid->slot_count = old.slot_count; mid->slot_count = old.slot_count;
memcpy(mid->slots, old.slots, sizeof(mid->slots)); memcpy(mid->slots, old.slots, sizeof(mid->slots));
mid->element_count = old.element_count; mid->element_count = old.element_count;
for (int i = 0; i < PAGE_ELEMENTS; i++) for (int i = 0; i < OLD_PAGE_ELEMENTS; i++)
memcpy((void *)&mid->elements[i], &old.elements[i], COMMON); memcpy((void *)&mid->elements[i], &old.elements[i], COMMON);
v4::convert(*mid, page); v4::convert(*mid, page);
delete mid; delete mid;
} }
} // namespace v3 } // namespace v3
// Version 5 (before 16 elements and the day offset of dates): 8 elements of the current
// layout without day_offset. Frozen copy, the elements are copied as a block.
namespace v5
{
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;
float scroll_speed;
uint32_t target;
HaShow show;
uint8_t input_unit;
bool auto_unit;
bool absolute;
char map[ELEMENT_MAP_SIZE];
uint8_t bar_width;
uint8_t bar_height;
float bar_max;
bool bar_frame;
};
struct Page
{
uint8_t slot_count;
PageSlot slots[PAGE_SLOTS];
uint8_t element_count;
Element elements[OLD_PAGE_ELEMENTS];
};
struct Stored
{
uint16_t version;
uint16_t size;
Page page;
};
static const size_t COMMON = offsetof(Element, bar_frame) + sizeof(Element::bar_frame);
static_assert(offsetof(PageElement, bar_frame) == offsetof(Element, bar_frame) &&
offsetof(PageElement, map) == offsetof(Element, map),
"same layout up to bar_frame");
static void convert(const Page &old, DynamicPage &page)
{
clearPage(page); // day_offset 0
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 < OLD_PAGE_ELEMENTS; i++)
memcpy((void *)&page.elements[i], &old.elements[i], COMMON);
}
} // namespace v5
static_assert(sizeof(v1::Stored) != sizeof(v2::Stored) && sizeof(v2::Stored) != sizeof(v3::Stored) && static_assert(sizeof(v1::Stored) != sizeof(v2::Stored) && sizeof(v2::Stored) != sizeof(v3::Stored) &&
sizeof(v3::Stored) != sizeof(v4::Stored) && sizeof(v4::Stored) != sizeof(StoredPage), sizeof(v3::Stored) != sizeof(v4::Stored) && sizeof(v4::Stored) != sizeof(v5::Stored) &&
sizeof(v5::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;
@@ -362,6 +429,18 @@ bool loadPage(const char *name, DynamicPage &page)
page = stored->page; page = stored->page;
delete stored; delete stored;
} }
else if (size == sizeof(v5::Stored))
{
v5::Stored *stored = new v5::Stored;
ok = file.read((uint8_t *)stored, sizeof(v5::Stored)) == sizeof(v5::Stored) && stored->version == 5 &&
stored->size == sizeof(v5::Page);
if (ok)
{
v5::convert(stored->page, page);
ok = validatePage(page) == nullptr;
}
delete stored;
}
else if (size == sizeof(v4::Stored)) else if (size == sizeof(v4::Stored))
{ {
v4::Stored *stored = new v4::Stored; v4::Stored *stored = new v4::Stored;
+1 -1
View File
@@ -26,7 +26,7 @@ const char *builtinPageName(const char *name)
static void clockPage(DynamicPage &page) static void clockPage(DynamicPage &page)
{ {
page = DynamicPage(); clearPage(page);
page.slot_count = 2; page.slot_count = 2;
page.slots[0].start = 0; page.slots[0].start = 0;
strcpy(page.slots[0].bitmap, "Pikachu"); strcpy(page.slots[0].bitmap, "Pikachu");
+33 -2
View File
@@ -127,7 +127,7 @@ static bool strncasecmpAscii(const char *a, const char *b, size_t n)
static bool validAttribute(const char *attribute) static bool validAttribute(const char *attribute)
{ {
for (const char *c = attribute; *c; c++) for (const char *c = attribute; *c; c++)
if (!isalnum((unsigned char)*c) && *c != '_') if (!isalnum((unsigned char)*c) && *c != '_' && *c != '.') // dots: a path, e.g. forecast.1.condition
return false; return false;
return true; return true;
} }
@@ -196,6 +196,8 @@ const char *validatePage(const DynamicPage &page)
return "The list of texts can only contain plain characters, up to 63"; return "The list of texts can only contain plain characters, up to 63";
if (e.format >= formatCount(e)) if (e.format >= formatCount(e))
return "Unknown format"; return "Unknown format";
if (e.day_offset < 0 || e.day_offset > DAY_OFFSET_MAX)
return "Date: 0-7 days after today";
if (!isfinite(e.factor) || e.factor == 0) if (!isfinite(e.factor) || e.factor == 0)
return "The multiplier must be a number other than 0"; return "The multiplier must be a number other than 0";
if (e.scroll_width > 2 * BITMAP_WIDTH || if (e.scroll_width > 2 * BITMAP_WIDTH ||
@@ -368,8 +370,37 @@ static int language(const PageElement &e)
return e.language < Language::Count ? (int)e.language : 0; return e.language < Language::Count ? (int)e.language : 0;
} }
static void formatDate(const PageElement &e, const LocalTime &t, char *out, size_t size) static int daysInMonth(int year, int month)
{ {
static const int DAYS[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
bool leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
return month == 2 && leap ? 29 : DAYS[(month - 1 + 12) % 12];
}
LocalTime addDays(const LocalTime &time, int days)
{
LocalTime t = time;
if (t.month < 1 || t.month > 12)
return t;
for (int i = 0; i < days; i++)
{
t.weekday = (t.weekday + 1) % 7;
if (++t.day > daysInMonth(t.year, t.month))
{
t.day = 1;
if (++t.month > 12)
{
t.month = 1;
t.year++;
}
}
}
return t;
}
static void formatDate(const PageElement &e, const LocalTime &today, char *out, size_t size)
{
LocalTime t = addDays(today, e.day_offset);
int lang = language(e); int lang = language(e);
int wd = t.weekday >= 0 && t.weekday < 7 ? t.weekday : 0; int wd = t.weekday >= 0 && t.weekday < 7 ? t.weekday : 0;
int mo = t.month >= 1 && t.month <= 12 ? t.month - 1 : 0; int mo = t.month >= 1 && t.month <= 12 ? t.month - 1 : 0;
+16 -1
View File
@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <new>
#include <stddef.h> #include <stddef.h>
#include <stdint.h> #include <stdint.h>
@@ -16,7 +17,7 @@
const int PAGE_NAME_SIZE = BITMAP_NAME_MAX + 1; const int PAGE_NAME_SIZE = BITMAP_NAME_MAX + 1;
const int PAGE_SLOTS = 8; const int PAGE_SLOTS = 8;
const int PAGE_ELEMENTS = 8; const int PAGE_ELEMENTS = 16;
const int ELEMENT_TEXT_SIZE = 33; const int ELEMENT_TEXT_SIZE = 33;
const int ELEMENT_SUFFIX_SIZE = 17; const int ELEMENT_SUFFIX_SIZE = 17;
const int ELEMENT_ENTITY_SIZE = 65; const int ELEMENT_ENTITY_SIZE = 65;
@@ -78,6 +79,8 @@ const uint8_t DATE_NUMERIC_YEAR = 4; // 15-10-2026
const uint8_t DATE_WEEKDAY = 5; // donderdag / Thursday const uint8_t DATE_WEEKDAY = 5; // donderdag / Thursday
const uint8_t DATE_WEEKDAY_SHORT = 6; // do / Thu const uint8_t DATE_WEEKDAY_SHORT = 6; // do / Thu
const uint8_t DATE_FORMAT_COUNT = 7; const uint8_t DATE_FORMAT_COUNT = 7;
// Date elements can show a later day (a forecast's weekday)
const int8_t DAY_OFFSET_MAX = 7;
// Countdown formats // Countdown formats
const uint8_t COUNTDOWN_SMART = 0; // 12 dagen, 5 uur, 34 min, then 0:45 const uint8_t COUNTDOWN_SMART = 0; // 12 dagen, 5 uur, 34 min, then 0:45
@@ -171,6 +174,8 @@ struct PageElement
uint8_t bar_height = 3; uint8_t bar_height = 3;
float bar_max = 100; // Bar: the value (after the multiplier) that fills it float bar_max = 100; // Bar: the value (after the multiplier) that fills it
bool bar_frame = true; // Bar: a line around it, filled inside bool bar_frame = true; // Bar: a line around it, filled inside
// Added in page format 6
int8_t day_offset = 0; // Date: days after today (0-DAY_OFFSET_MAX), e.g. 1: tomorrow
}; };
struct DynamicPage struct DynamicPage
@@ -181,6 +186,13 @@ struct DynamicPage
PageElement elements[PAGE_ELEMENTS]; PageElement elements[PAGE_ELEMENTS];
}; };
// Back to an empty page with the defaults, without `page = DynamicPage()`: that can put a
// copy (about 4 KB) on the stack
inline void clearPage(DynamicPage &page)
{
new (&page) DynamicPage();
}
// Returns nullptr when the page is fine, otherwise a message for the user. Does not check // Returns nullptr when the page is fine, otherwise a message for the user. Does not check
// that the bitmaps and fonts exist. // that the bitmaps and fonts exist.
const char *validatePage(const DynamicPage &page); const char *validatePage(const DynamicPage &page);
@@ -241,5 +253,8 @@ bool parseTimestamp(const char *text, int64_t &seconds);
bool parseTarget(const char *text, uint32_t &minutes); bool parseTarget(const char *text, uint32_t &minutes);
// out must hold 17 characters // out must hold 17 characters
void formatTarget(uint32_t minutes, char *out); void formatTarget(uint32_t minutes, char *out);
// The date `days` (0 or more) after this one, with the weekday; the time stays
LocalTime addDays(const LocalTime &time, int days);
// The local time in minutes since 2000-01-01 00:00 (seconds left out) // The local time in minutes since 2000-01-01 00:00 (seconds left out)
uint32_t minutesSince2000(const LocalTime &time); uint32_t minutesSince2000(const LocalTime &time);
+51 -3
View File
@@ -57,7 +57,7 @@ static String token;
static char plex_user[PLEX_USER_SIZE] = ""; 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 = 32;
static QueueHandle_t requests = nullptr; static QueueHandle_t requests = nullptr;
static QueueHandle_t responses = nullptr; static QueueHandle_t responses = nullptr;
@@ -74,6 +74,46 @@ static uint32_t preview_done_at = 0;
// The task // The task
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Attributes can be a path into lists and objects: "forecast.1.condition" is the condition
// of the second forecast. One part of it (up to the next dot) goes into `part`; returns the
// rest, nullptr after the last part.
static const char *nextPart(const char *path, char *part, size_t size)
{
size_t n = strcspn(path, ".");
strlcpy(part, path, n + 1 < size ? n + 1 : size);
return path[n] == '.' ? path + n + 1 : nullptr;
}
static bool isIndex(const char *part)
{
if (!part[0])
return false;
for (const char *c = part; *c; c++)
if (!isdigit((unsigned char)*c))
return false;
return true;
}
// Only the path is kept from the answer. List indexes become [0]: ArduinoJson applies the
// first element of a filter list to all elements.
static void addFilter(JsonVariant f, const char *path)
{
char part[ELEMENT_ATTRIBUTE_SIZE];
const char *rest = nextPart(path, part, sizeof(part));
if (isIndex(part))
{
JsonVariant element = f.to<JsonArray>().add<JsonVariant>();
if (rest)
addFilter(element, rest);
else
element.set(true);
}
else if (rest)
addFilter(f[(const char *)part].to<JsonVariant>(), rest); // char arrays would be taken as literals
else
f[(const char *)part] = true;
}
// Blocks at most about 2 x HA_HTTP_TIMEOUT (connect, then the answer) // 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) static const char *fetch(const Request &r, char *value, size_t value_size)
{ {
@@ -99,7 +139,7 @@ static const char *fetch(const Request &r, char *value, size_t value_size)
JsonDocument filter; JsonDocument filter;
if (r.attribute[0]) if (r.attribute[0])
filter["attributes"][(const char *)r.attribute] = true; // a char array would be taken as a literal of 32 characters addFilter(filter["attributes"].to<JsonVariant>(), r.attribute);
else else
filter["state"] = true; filter["state"] = true;
JsonDocument doc; JsonDocument doc;
@@ -110,7 +150,15 @@ static const char *fetch(const Request &r, char *value, size_t value_size)
JsonVariantConst v = doc["state"]; JsonVariantConst v = doc["state"];
if (r.attribute[0]) if (r.attribute[0])
v = doc["attributes"][(const char *)r.attribute]; {
v = doc["attributes"];
char part[ELEMENT_ATTRIBUTE_SIZE];
for (const char *path = r.attribute; path && !v.isNull();)
{
path = nextPart(path, part, sizeof(part));
v = isIndex(part) ? v[(size_t)atoi(part)] : v[(const char *)part];
}
}
if (v.isNull()) if (v.isNull())
return "Unknown attribute"; return "Unknown attribute";
String text; String text;
+3 -1
View File
@@ -73,6 +73,7 @@ void pageToJson(const DynamicPage &page, JsonObject out)
o["bar_height"] = e.bar_height; o["bar_height"] = e.bar_height;
o["bar_max"] = e.bar_max; o["bar_max"] = e.bar_max;
o["bar_frame"] = e.bar_frame; o["bar_frame"] = e.bar_frame;
o["day_offset"] = e.day_offset;
} }
} }
@@ -89,7 +90,7 @@ static bool copyText(JsonVariantConst value, char *out, size_t size)
const char *pageFromJson(JsonVariantConst in, DynamicPage &page) const char *pageFromJson(JsonVariantConst in, DynamicPage &page)
{ {
page = DynamicPage(); clearPage(page);
JsonArrayConst slots = in["slots"]; JsonArrayConst slots = in["slots"];
JsonArrayConst elements = in["elements"]; JsonArrayConst elements = in["elements"];
if (slots.size() > (size_t)PAGE_SLOTS) if (slots.size() > (size_t)PAGE_SLOTS)
@@ -136,6 +137,7 @@ const char *pageFromJson(JsonVariantConst in, DynamicPage &page)
e.bar_height = constrain(o["bar_height"] | 3, 0, 255); e.bar_height = constrain(o["bar_height"] | 3, 0, 255);
e.bar_max = o["bar_max"] | 100.0f; e.bar_max = o["bar_max"] | 100.0f;
e.bar_frame = o["bar_frame"] | true; e.bar_frame = o["bar_frame"] | true;
e.day_offset = constrain(o["day_offset"] | 0, -1, DAY_OFFSET_MAX + 1); // out of range: validatePage says so
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"] | "";
+5 -4
View File
@@ -362,7 +362,7 @@ 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}} bar_width:40,bar_height:3,bar_max:100,bar_frame:true,day_offset:0}}
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','')}
@@ -388,9 +388,10 @@ function peElems(){const d=$('pe_elems');d.innerHTML='';d.append(mk('datalist',{
const row=(...k)=>rows.push(mk('div',{className:'row'},...k)); const row=(...k)=>rows.push(mk('div',{className:'row'},...k));
if(e.type=='clock')row(field('Format',sel(e,'format',[[0,'08:05'],[1,'8:05'],[2,'08:05:09 (every second)'],[3,'8:05:09 (every second)']]))); if(e.type=='clock')row(field('Format',sel(e,'format',[[0,'08:05'],[1,'8:05'],[2,'08:05:09 (every second)'],[3,'8:05:09 (every second)']])));
if(e.type=='date')row(field('Format',sel(e,'format',DATEF.map((x,k)=>[k,x[e.language=='en'?1:0]]))), if(e.type=='date')row(field('Format',sel(e,'format',DATEF.map((x,k)=>[k,x[e.language=='en'?1:0]]))),
field('Language',sel(e,'language',[['nl','Nederlands'],['en','English']],peElems))); field('Language',sel(e,'language',[['nl','Nederlands'],['en','English']],peElems)),
field('Day',sel(e,'day_offset',[[0,'today'],[1,'tomorrow'],[2,'+2 days'],[3,'+3 days'],[4,'+4 days'],[5,'+5 days'],[6,'+6 days'],[7,'+7 days']])));
if(e.type=='ha'){row(field('Entity',inp(e,'entity','text',{placeholder:'sensor.outside_temperature',size:28,maxLength:64})), if(e.type=='ha'){row(field('Entity',inp(e,'entity','text',{placeholder:'sensor.outside_temperature',size:28,maxLength:64})),
field('Attribute',inp(e,'attribute','text',{placeholder:'(the state)',size:12,maxLength:32})),btn('Test',()=>haTest(e))); field('Attribute',inp(e,'attribute','text',{placeholder:'(the state) or forecast.1.temperature',size:12,maxLength:32})),btn('Test',()=>haTest(e)));
const sh=e.show||'number',fmt=()=>{e.format=0;peElems()}; const sh=e.show||'number',fmt=()=>{e.format=0;peElems()};
row(field('Show as',sel(e,'show',[['number','number'],['duration','duration'],['time','time / date'],['map','own texts']],fmt)), row(field('Show as',sel(e,'show',[['number','number'],['duration','duration'],['time','time / date'],['map','own texts']],fmt)),
field('Before',inp(e,'text','text',{size:8,maxLength:32})),field('After',inp(e,'suffix','text',{size:6,maxLength:16}))); field('Before',inp(e,'text','text',{size:8,maxLength:32})),field('After',inp(e,'suffix','text',{size:6,maxLength:16})));
@@ -430,7 +431,7 @@ function peElems(){const d=$('pe_elems');d.innerHTML='';d.append(mk('datalist',{
else if(e.type=='bar')row(...pos,field('Align',sel(e,'align',[['left','left'],['center','center'],['right','right']])),ovl); 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>=16){msg('16 elements at most',true);return}pe.page.elements.push(newEl(t));pe.sel=pe.page.elements.length-1;peElems();pePreview()}
function peMark(){document.querySelectorAll('#pe_elems .el').forEach((d,i)=>d.classList.toggle('sel',i==pe.sel));peDraw()} function peMark(){document.querySelectorAll('#pe_elems .el').forEach((d,i)=>d.classList.toggle('sel',i==pe.sel));peDraw()}
function pePage(){const p=JSON.parse(JSON.stringify(pe.page));p.slots.sort(bySlot);return p} function pePage(){const p=JSON.parse(JSON.stringify(pe.page));p.slots.sort(bySlot);return p}
function pePreview(){clearTimeout(peTimer);peTimer=setTimeout(peRender,120)} function pePreview(){clearTimeout(peTimer);peTimer=setTimeout(peRender,120)}
+64
View File
@@ -2273,6 +2273,68 @@ void test_bar_render()
TEST_ASSERT_TRUE(bitmapPixel(b, 61, 17)); TEST_ASSERT_TRUE(bitmapPixel(b, 61, 17));
} }
void test_date_days_ahead()
{
LocalTime t; // Thursday 31 December 2026
t.year = 2026;
t.month = 12;
t.day = 31;
t.weekday = 4;
LocalTime n = addDays(t, 1);
TEST_ASSERT_EQUAL(2027, n.year);
TEST_ASSERT_EQUAL(1, n.month);
TEST_ASSERT_EQUAL(1, n.day);
TEST_ASSERT_EQUAL(5, n.weekday);
t.year = 2028; // leap year: 28 February + 1 = 29 February
t.month = 2;
t.day = 28;
TEST_ASSERT_EQUAL(29, addDays(t, 1).day);
TEST_ASSERT_EQUAL(3, addDays(t, 2).month);
t.year = 2027;
TEST_ASSERT_EQUAL(1, addDays(t, 1).day);
DynamicPage page;
page.element_count = 1;
PageElement &e = page.elements[0];
e.type = ElementType::Date;
e.format = DATE_WEEKDAY_SHORT;
LocalTime thu;
thu.year = 2026;
thu.month = 10;
thu.day = 15;
thu.weekday = 4;
char text[32];
elementText(e, thu, ElementValues(), text, sizeof(text));
TEST_ASSERT_EQUAL_STRING("do", text);
e.day_offset = 3;
elementText(e, thu, ElementValues(), text, sizeof(text));
TEST_ASSERT_EQUAL_STRING("zo", text);
TEST_ASSERT_NULL(validatePage(page));
e.day_offset = 8;
TEST_ASSERT_NOT_NULL(validatePage(page));
e.day_offset = -1;
TEST_ASSERT_NOT_NULL(validatePage(page));
}
void test_attribute_paths_and_16_elements()
{
DynamicPage page;
page.element_count = PAGE_ELEMENTS;
TEST_ASSERT_EQUAL(16, PAGE_ELEMENTS);
for (int i = 0; i < PAGE_ELEMENTS; i++)
{
page.elements[i].type = ElementType::HaValue;
strcpy(page.elements[i].entity, "sensor.weer_verwachting");
strcpy(page.elements[i].attribute, "forecast.1.templow");
}
TEST_ASSERT_NULL(validatePage(page));
strcpy(page.elements[3].attribute, "forecast/1");
TEST_ASSERT_NOT_NULL(validatePage(page));
page.elements[3].attribute[0] = '\0';
page.element_count = PAGE_ELEMENTS + 1;
TEST_ASSERT_NOT_NULL(validatePage(page));
}
void test_bar_validation() void test_bar_validation()
{ {
DynamicPage page = barPage(); DynamicPage page = barPage();
@@ -2462,6 +2524,8 @@ int main()
RUN_TEST(test_bar_fill); RUN_TEST(test_bar_fill);
RUN_TEST(test_bar_render); RUN_TEST(test_bar_render);
RUN_TEST(test_bar_validation); RUN_TEST(test_bar_validation);
RUN_TEST(test_date_days_ahead);
RUN_TEST(test_attribute_paths_and_16_elements);
RUN_TEST(test_plex_user_placeholder); RUN_TEST(test_plex_user_placeholder);
RUN_TEST(test_slow_scrolling_below_one_dot_per_second); RUN_TEST(test_slow_scrolling_below_one_dot_per_second);
+90
View File
@@ -0,0 +1,90 @@
"""Writes examples/home-pages.json from the pages on a display: the dynamic pages with the
icons, bitmaps and fonts they use, as a backup file that the web UI imports (System tab).
python tools/export_examples.py [ESP32 address, default 10.1.1.198]
Private parts of entity ids are replaced by placeholders (PRIVATE below); the export stops
when a page still contains one of them. The pages that come with the firmware (Clock) and
their icons (the Pikachus) are left out: every display has them. Only the Python standard
library is used.
"""
import json
import os
import re
import sys
import urllib.parse
import urllib.request
ESP = 'http://' + (sys.argv[1] if len(sys.argv) > 1 else '10.1.1.198')
OUT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'examples', 'home-pages.json')
# Private text -> placeholder (in entity ids, attributes and texts)
PRIVATE = {
'vigilantestraat': 'home', # the street (climate entity)
'122225067403': '000000000000', # the Envoy's serial number
'mangavalkserver': 'plex_server', # the Plex server's name
}
FROM_FIRMWARE_PAGES = {'clock'}
FROM_FIRMWARE_ICONS = {'pikachu', 'pikachu yawning'}
def get(path):
with urllib.request.urlopen(ESP + path, timeout=60) as r:
return json.loads(r.read())
def clean(value):
if isinstance(value, str):
for private, placeholder in PRIVATE.items():
value = re.sub(re.escape(private), placeholder, value, flags=re.IGNORECASE)
return value
def main():
pages = [p for p in get('/api/pages')['pages'] if p['name'].lower() not in FROM_FIRMWARE_PAGES]
icons = {i['name'].lower(): i for i in get('/api/icons')['icons']}
bitmaps = {b['name'].lower(): b for b in get('/api/bitmaps')['bitmaps'] if not b['builtin']}
fonts = {f['name'].lower(): f for f in get('/api/fonts')['fonts'] if not f['builtin']}
# What the pages use: backgrounds, fixed images, the images by state (name start), fonts
images, used_fonts = set(), set()
for p in pages:
for s in p['page']['slots']:
images.add(s['bitmap'].lower())
for e in p['page']['elements']:
used_fonts.add(e['font'].lower())
if e['type'] != 'image':
continue
if not e['entity']:
images.add(e['text'].lower())
else:
start = e['text'].lower()
images.update(n for n in list(icons) + list(bitmaps) if n.startswith(start))
for e in p['page']['elements']:
for k, v in e.items():
e[k] = clean(v)
out = {
'format': 'flipdot-backup', 'version': 1, 'firmware': get('/api/status')['version'],
'exported': '2026-10-16T00:00:00Z',
'bitmaps': [{'name': b['name'], 'data': b['data']} for n, b in sorted(bitmaps.items()) if n in images],
'icons': [{k: i[k] for k in ('name', 'width', 'height', 'data')} for n, i in sorted(icons.items())
if n in images and n not in FROM_FIRMWARE_ICONS],
'pages': [{'name': p['name'], 'page': p['page']} for p in pages],
'fonts': [],
}
for n in sorted(used_fonts & set(fonts)):
out['fonts'].append(get('/api/font?name=' + urllib.parse.quote(fonts[n]['name'])))
text = json.dumps(out, ensure_ascii=False, indent=1)
left = [p for p in PRIVATE if p.lower() in text.lower()]
if left:
sys.exit('Private text still in the export: ' + ', '.join(left))
with open(OUT, 'w', encoding='utf-8', newline='\n') as f:
f.write(text + '\n')
print(len(out['pages']), 'pages,', len(out['icons']), 'icons,', len(out['bitmaps']), 'bitmaps,',
len(out['fonts']), 'fonts ->', OUT)
main()
-397
View File
@@ -1,397 +0,0 @@
"""Builds examples/home-pages.json: icons, backgrounds and dynamic pages for this home (import it in the web UI, System tab).
python tools/make_example_pages.py examples/home-pages.json
"""
import json
import sys
W, H = 84, 19
def blank():
return [[0] * W for _ in range(H)]
def put(img, art, x0, y0):
for y, row in enumerate(art):
for x, c in enumerate(row):
if c == '#' and 0 <= x0 + x < W and 0 <= y0 + y < H:
img[y0 + y][x0 + x] = 1
return img
def to_hex(img):
out = ''
for y in range(H):
for i in range(11):
v = 0
for k in range(8):
x = i * 8 + k
if x < W and img[y][x]:
v |= 0x80 >> k
out += '%02x' % v
return out
CLOUD = [
"...........",
"....###....",
"..##.####..",
".#########.",
"###########",
"###########",
".#########.",
]
WEATHER = {
'sunny': [
".....#.....",
".#...#...#.",
"..#.....#..",
"....###....",
"...#####...",
"##.#####.##",
"...#####...",
"....###....",
"..#.....#..",
".#...#...#.",
".....#.....",
],
'clear-night': [
"....###....",
"..###......",
".###.......",
".##......#.",
"###.......",
"###........",
"###........",
".###.......",
".####....#.",
"..#######..",
"....###....",
],
'partlycloudy': [
"..#..#.....",
"....###....",
".#.#####...",
"...####....",
"#.##..###..",
"..#########",
".##########",
"###########",
"###########",
".#########.",
"...........",
],
'cloudy': ["...........", "..........."] + CLOUD + ["..........."] * 2,
'fog': [
"...........",
"###########",
"...........",
".##########",
"...........",
"#########..",
"...........",
"..#########",
"...........",
"#########..",
"...........",
],
'rainy': CLOUD + [
"...........",
".#...#...#.",
"#...#...#..",
"...........",
],
'pouring': CLOUD + [
".#.#.#.#.#.",
"#.#.#.#.#..",
".#.#.#.#.#.",
"#.#.#.#.#..",
],
'lightning': CLOUD[:6] + [
".....##....",
"....##.....",
"...#####...",
".....##....",
"....##.....",
],
'lightning-rainy': CLOUD[:6] + [
".#...##..#.",
"#...##..#..",
"...#####...",
".#...##..#.",
"#...##..#..",
],
'snowy': CLOUD + [
".#...#...#.",
"...#...#...",
".#...#...#.",
"...#...#...",
],
'snowy-rainy': CLOUD + [
".#...#...#.",
"#...#...#..",
"...#...#...",
".#...#...#.",
],
'hail': CLOUD + [
".##..##..##",
".##..##..##",
"...........",
"..##..##...",
],
'windy': [
"...........",
"......##...",
"........#..",
"#########..",
"...........",
"###########",
"...........",
"#######....",
"........#..",
"......##...",
"...........",
],
'windy-variant': CLOUD + [
"...........",
"#########..",
"...........",
"..#########",
],
'default': [
".....#.....",
"....#.#....",
"....#.#....",
"...#.#.#...",
"...#.#.#...",
"..#..#..#..",
"..#..#..#..",
".#.......#.",
".#...#...#.",
"#.........#",
"###########",
],
}
WEATHER['exceptional'] = WEATHER['default']
BOLT = [
"....##.",
"...##..",
"..##...",
".#####.",
"...##..",
"..##...",
".##....",
"##.....",
]
SUN_SMALL = [
"....#....",
".#.....#.",
"...###...",
"..#####..",
"#.#####.#",
"..#####..",
"...###...",
".#.....#.",
"....#....",
]
DROP = [
"...#...",
"...#...",
"..###..",
"..###..",
".#####.",
"#######",
"#######",
"#######",
".#####.",
"..###..",
]
HOUSE = [
".....#.....",
"....###....",
"...##.##...",
"..##...##..",
".##.....##.",
"##.......##",
".#.......#.",
".#.##....#.",
".#.##.##.#.",
".#.##.##.#.",
".#########.",
]
PRINTER = [
"###########",
"#.........#",
"#.#######.#",
"#....#....#",
"#....#....#",
"#.........#",
"#...###...#",
"#..#####..#",
"###########",
"#.........#",
"###########",
]
CALENDAR = [
".#.....#...",
"###########",
"#.........#",
"###########",
"#.#.#.#.#.#",
"#.........#",
"#.#.#.#.#.#",
"#.........#",
"#.#.#.#...#",
"#.........#",
"###########",
]
TREE = [
".....#.....",
"....###....",
".....#.....",
"....###....",
"...#####...",
"....###....",
"...#####...",
"..#######..",
"...#####...",
"..#######..",
".#########.",
".....#.....",
"....###....",
]
MOON = WEATHER['clear-night']
STARS = [(1, 2), (24, 1), (27, 7), (3, 9), (22, 4)]
CORNER = ["#####", "#....", "#....", "#...."]
def corners():
img = blank()
put(img, CORNER, 0, 0)
put(img, [r[::-1] for r in CORNER], W - 5, 0)
put(img, CORNER[::-1], 0, H - 4)
put(img, [r[::-1] for r in CORNER[::-1]], W - 5, H - 4)
return img
bitmaps = {}
for state, art in WEATHER.items():
bitmaps['weer ' + state] = put(blank(), art, 0, 0)
bitmaps['bg energie'] = put(put(blank(), BOLT, 1, 0), SUN_SMALL, 0, 10)
bitmaps['bg printer'] = put(blank(), PRINTER, 0, 0)
bitmaps['bg binnen'] = put(blank(), HOUSE, 0, 0)
bitmaps['bg verbruik'] = put(put(blank(), DROP, 1, 0), BOLT, 1, 11)
bitmaps['bg datum'] = put(blank(), CALENDAR, 0, 4)
bitmaps['bg kerst'] = put(blank(), TREE, 0, 3)
bitmaps['bg dag'] = put(blank(), WEATHER['sunny'], 9, 0)
night = put(blank(), MOON, 9, 0)
for x, y in STARS:
night[y][x] = 1
bitmaps['bg nacht'] = night
bitmaps['bg kader'] = corners()
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):
e = {'type': type, 'x': x, 'y': y, 'align': 'left', 'overlay': 'top', 'font': font, 'format': 0,
'language': 'nl', 'decimals': -1, 'text': '', 'suffix': '', 'entity': '', '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}
e.update(kw)
return e
# Placeholders: put your own entity ids here (the Envoy ones contain its serial number)
ENVOY = 'sensor.envoy_000000000000_'
pages = {
# 1. Weather clock: large clock, weather icon, outside humidity and temperature
'Weer': {'slots': [], 'elements': [
el('clock', 0, 0, 'Full height'),
el('image', 55, 0, text='weer ', entity='weather.knmi_home'),
el('ha', 84, 2, 'Small', align='right', entity='weather.knmi_home', attribute='humidity', suffix='%', decimals=0),
el('ha', 84, 13, 'Small', align='right', entity='weather.knmi_home', attribute='temperature', suffix='°C', decimals=1),
]},
# 2. Sun by day, moon and stars at night, large clock and the date
'Dag en nacht': {'slots': [{'start': '07:00', 'bitmap': 'bg dag'}, {'start': '19:00', 'bitmap': 'bg nacht'}], 'elements': [
el('clock', 84, 0, 'Full height', align='right'),
el('date', 0, 13, 'Small', format=1),
]},
# 3. Energy: net power now (negative = export), solar today, small clock
'Energie': {'slots': [{'start': '00:00', 'bitmap': 'bg energie'}], 'elements': [
el('ha', 10, 0, entity=ENVOY + 'current_net_power_consumption', suffix=' W', decimals=0),
el('ha', 11, 11, entity=ENVOY + 'today_s_energy_production', suffix=' kWh', decimals=1, factor=0.001),
el('clock', 84, 0, 'Small', align='right'),
]},
# 4. 3D printer: progress, time left, status message (scrolls), nozzle temperature
'3D printer': {'slots': [{'start': '00:00', 'bitmap': 'bg printer'}], 'elements': [
el('ha', 14, 0, entity='sensor.mainsailos_progress', suffix='%', decimals=0),
el('ha', 84, 0, align='right', entity='sensor.mainsailos_print_time_left', suffix='m', decimals=0, factor=1 / 60),
el('ha', 14, 13, 'Small', entity='sensor.mainsailos_current_display_message', scroll_width=46, scroll_speed=6),
el('ha', 84, 13, 'Small', align='right', entity='sensor.mainsailos_extruder_temperature', suffix='°', decimals=0),
]},
# 5. Indoor climate: room temperature, outside, CO2 and air quality
'Binnen': {'slots': [{'start': '00:00', 'bitmap': 'bg binnen'}], 'elements': [
el('ha', 14, 2, entity='climate.home', attribute='current_temperature', suffix='°', decimals=1),
el('image', 49, 0, text='weer ', entity='weather.knmi_home'),
el('ha', 84, 2, 'Small', align='right', entity='weather.knmi_home', attribute='temperature', suffix='°', decimals=1),
el('ha', 0, 13, 'Small', entity='sensor.eco2', text='CO2 ', suffix='ppm', decimals=0),
el('ha', 84, 13, 'Small', align='right', entity='sensor.air_quality_index_aqi_2', text='lucht '),
]},
# 6. Today: water and energy use, solar production
'Verbruik': {'slots': [{'start': '00:00', 'bitmap': 'bg verbruik'}], 'elements': [
el('ha', 10, 1, entity='sensor.util_water_usage_daily', suffix=' l', decimals=0, factor=1000),
el('ha', 10, 11, entity=ENVOY + 'today_s_energy_consumption', suffix=' kWh', decimals=1, factor=0.001),
el('clock', 84, 0, 'Small', align='right'),
el('ha', 84, 13, 'Small', align='right', entity=ENVOY + 'today_s_energy_production', text='zon ', suffix='kWh', decimals=1, factor=0.001),
]},
# 7. Big clock in a frame; from 22:00 black digits on yellow
'Grote klok': {'slots': [{'start': '07:00', 'bitmap': 'bg kader'}, {'start': '22:00', 'bitmap': 'bg vol'}], 'elements': [
el('clock', 42, 0, 'Full height', align='center', overlay='invert'),
]},
# 8. The date in Dutch with a calendar
'Datum': {'slots': [{'start': '00:00', 'bitmap': 'bg datum'}], 'elements': [
el('date', 14, 1, format=5),
el('date', 14, 12, 'Small', format=2),
el('clock', 84, 12, 'Small', align='right'),
]},
# Countdown to Christmas
'Aftellen': {'slots': [{'start': '00:00', 'bitmap': 'bg kerst'}], 'elements': [
el('text', 14, 1, 'Small', text='Kerst over'),
el('countdown', 14, 10, target='2026-12-25T00:00', suffix='Vrolijk kerst!', scroll_width=70, scroll_speed=8),
]},
# Stopwatch, large
'Stopwatch': {'slots': [], 'elements': [
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 = {
'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()],
'pages': [{'name': n, 'page': p} for n, p in pages.items()],
'fonts': [],
}
json.dump(backup, open(sys.argv[1], 'w', encoding='utf-8'), ensure_ascii=False, indent=1)
print(len(bitmaps), 'bitmaps,', len(pages), 'pages')