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>
No files matched your search
@@ -168,6 +168,14 @@ See README.md for the overview.
|
||||
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,
|
||||
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.
|
||||
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).
|
||||
|
||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 159 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 346 KiB After Width: | Height: | Size: 370 KiB |
|
Before Width: | Height: | Size: 414 KiB After Width: | Height: | Size: 405 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 367 KiB After Width: | Height: | Size: 339 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 131 KiB |
@@ -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
|
||||
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
|
||||
|
||||
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_server` (Plex, named after your server): how many are watching.
|
||||
- `sensor.{plex_user}_state` (Tautulli, per user): `playing`, `paused`, ... chooses the icon
|
||||
(icons `plex playing`, `plex paused`, `plex buffering`).
|
||||
- `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
|
||||
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.
|
||||
@@ -3,22 +3,28 @@
|
||||
[← Back to the README](../README.md)
|
||||
|
||||
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)
|
||||
(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 | |
|
||||
|---|---|
|
||||
| **Clock** (built in), day and from 17:00 |   |
|
||||
| **Clock** (from the firmware), day and from 17:00 |   |
|
||||
| **Weer**: clock, weather icon by state, humidity, temperature |  |
|
||||
| **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) |  |
|
||||
| **Dag en nacht**: sun from 07:00, moon and stars from 19:00, date |   |
|
||||
| **Energie**: net power now, solar today, clock |  |
|
||||
| **3D printer**: progress, time left, status message (scrolls), nozzle temperature |  |
|
||||
| **3D printer finished** (not in the examples): shown for 10 minutes after a print completes (rule), with the status message |  |
|
||||
| **3D printer finished**: shown for 10 minutes after a print completes (rule), with the status message |  |
|
||||
| **Binnen**: room and outside temperature, CO2, air quality |  |
|
||||
| **Verbruik**: water and energy used today, solar today |  |
|
||||
| **Grote klok**: big clock, inverted from 22:00 |   |
|
||||
| **Datum**: weekday and date |  |
|
||||
| **Aftellen**: countdown to Christmas |  |
|
||||
| **Stopwatch** |  |
|
||||
| **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 |  |
|
||||
| **Plex**: how many watch, play/pause, title (scrolls) and progress of the Plex user ([Tautulli](home-assistant.md#plex-and-tautulli)) |  |
|
||||
@@ -23,7 +23,7 @@ Bitmaps that pages or rules use are in their own subsection.
|
||||
## Pages
|
||||
|
||||
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
|
||||
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).
|
||||
@@ -31,8 +31,8 @@ its Reset button brings back the original. See the [pictures of the pages](pages
|
||||
| Element | Options |
|
||||
|---------|---------|
|
||||
| Clock | `08:05`, `8:05`, optionally with seconds |
|
||||
| Date | 7 formats, Dutch or English (`do 15 okt`, `15 oktober`, `donderdag`, ...) |
|
||||
| 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`) |
|
||||
| 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 (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 |
|
||||
| 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 |
|
||||
|
||||
@@ -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_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)
|
||||
const uint8_t HA_DEVICE_ID[] = {0x34, 0x86, 0x5d, 0x3a, 0x08, 0x28};
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
static const char *DIR = "/pages";
|
||||
static const char *EXTENSION = ".pg";
|
||||
// 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
|
||||
{
|
||||
@@ -42,7 +44,7 @@ struct Page
|
||||
uint8_t slot_count;
|
||||
PageSlot slots[PAGE_SLOTS];
|
||||
uint8_t element_count;
|
||||
Element elements[PAGE_ELEMENTS];
|
||||
Element elements[OLD_PAGE_ELEMENTS];
|
||||
};
|
||||
|
||||
struct Stored
|
||||
@@ -54,11 +56,11 @@ struct Stored
|
||||
|
||||
static void convert(const Page &old, DynamicPage &page)
|
||||
{
|
||||
page = DynamicPage();
|
||||
clearPage(page);
|
||||
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++)
|
||||
for (int i = 0; i < OLD_PAGE_ELEMENTS; i++)
|
||||
{
|
||||
const Element &o = old.elements[i];
|
||||
PageElement &e = page.elements[i];
|
||||
@@ -108,7 +110,7 @@ struct Page
|
||||
uint8_t slot_count;
|
||||
PageSlot slots[PAGE_SLOTS];
|
||||
uint8_t element_count;
|
||||
Element elements[PAGE_ELEMENTS];
|
||||
Element elements[OLD_PAGE_ELEMENTS];
|
||||
};
|
||||
|
||||
struct Stored
|
||||
@@ -120,11 +122,11 @@ struct Stored
|
||||
|
||||
static void convert(const Page &old, DynamicPage &page)
|
||||
{
|
||||
page = DynamicPage();
|
||||
clearPage(page);
|
||||
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++)
|
||||
for (int i = 0; i < OLD_PAGE_ELEMENTS; i++)
|
||||
{
|
||||
const Element &o = old.elements[i];
|
||||
PageElement &e = page.elements[i];
|
||||
@@ -187,7 +189,7 @@ struct Page
|
||||
uint8_t slot_count;
|
||||
PageSlot slots[PAGE_SLOTS];
|
||||
uint8_t element_count;
|
||||
Element elements[PAGE_ELEMENTS];
|
||||
Element elements[OLD_PAGE_ELEMENTS];
|
||||
};
|
||||
|
||||
struct Stored
|
||||
@@ -205,11 +207,11 @@ static_assert(offsetof(PageElement, scroll_speed) >= BEFORE_SPEED &&
|
||||
|
||||
static void convert(const Page &old, DynamicPage &page)
|
||||
{
|
||||
page = DynamicPage();
|
||||
clearPage(page);
|
||||
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++)
|
||||
for (int i = 0; i < OLD_PAGE_ELEMENTS; i++)
|
||||
{
|
||||
const Element &o = old.elements[i];
|
||||
PageElement &e = page.elements[i];
|
||||
@@ -264,7 +266,7 @@ struct Page
|
||||
uint8_t slot_count;
|
||||
PageSlot slots[PAGE_SLOTS];
|
||||
uint8_t element_count;
|
||||
Element elements[PAGE_ELEMENTS];
|
||||
Element elements[OLD_PAGE_ELEMENTS];
|
||||
};
|
||||
|
||||
struct Stored
|
||||
@@ -283,15 +285,80 @@ static void convert(const Page &old, DynamicPage &page)
|
||||
mid->slot_count = old.slot_count;
|
||||
memcpy(mid->slots, old.slots, sizeof(mid->slots));
|
||||
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);
|
||||
v4::convert(*mid, page);
|
||||
delete mid;
|
||||
}
|
||||
} // 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) &&
|
||||
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");
|
||||
|
||||
static uint32_t revision = 0;
|
||||
@@ -362,6 +429,18 @@ bool loadPage(const char *name, DynamicPage &page)
|
||||
page = stored->page;
|
||||
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))
|
||||
{
|
||||
v4::Stored *stored = new v4::Stored;
|
||||
|
||||
@@ -26,7 +26,7 @@ const char *builtinPageName(const char *name)
|
||||
|
||||
static void clockPage(DynamicPage &page)
|
||||
{
|
||||
page = DynamicPage();
|
||||
clearPage(page);
|
||||
page.slot_count = 2;
|
||||
page.slots[0].start = 0;
|
||||
strcpy(page.slots[0].bitmap, "Pikachu");
|
||||
|
||||
@@ -127,7 +127,7 @@ static bool strncasecmpAscii(const char *a, const char *b, size_t n)
|
||||
static bool validAttribute(const char *attribute)
|
||||
{
|
||||
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 true;
|
||||
}
|
||||
@@ -196,6 +196,8 @@ const char *validatePage(const DynamicPage &page)
|
||||
return "The list of texts can only contain plain characters, up to 63";
|
||||
if (e.format >= formatCount(e))
|
||||
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)
|
||||
return "The multiplier must be a number other than 0";
|
||||
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;
|
||||
}
|
||||
|
||||
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 wd = t.weekday >= 0 && t.weekday < 7 ? t.weekday : 0;
|
||||
int mo = t.month >= 1 && t.month <= 12 ? t.month - 1 : 0;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <new>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
@@ -16,7 +17,7 @@
|
||||
|
||||
const int PAGE_NAME_SIZE = BITMAP_NAME_MAX + 1;
|
||||
const int PAGE_SLOTS = 8;
|
||||
const int PAGE_ELEMENTS = 8;
|
||||
const int PAGE_ELEMENTS = 16;
|
||||
const int ELEMENT_TEXT_SIZE = 33;
|
||||
const int ELEMENT_SUFFIX_SIZE = 17;
|
||||
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_SHORT = 6; // do / Thu
|
||||
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
|
||||
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;
|
||||
float bar_max = 100; // Bar: the value (after the multiplier) that fills it
|
||||
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
|
||||
@@ -181,6 +186,13 @@ struct DynamicPage
|
||||
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
|
||||
// that the bitmaps and fonts exist.
|
||||
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);
|
||||
// out must hold 17 characters
|
||||
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)
|
||||
uint32_t minutesSince2000(const LocalTime &time);
|
||||
@@ -57,7 +57,7 @@ static String token;
|
||||
static char plex_user[PLEX_USER_SIZE] = "";
|
||||
static std::vector<Entry> entries;
|
||||
// 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 responses = nullptr;
|
||||
@@ -74,6 +74,46 @@ static uint32_t preview_done_at = 0;
|
||||
// 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)
|
||||
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;
|
||||
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
|
||||
filter["state"] = true;
|
||||
JsonDocument doc;
|
||||
@@ -110,7 +150,15 @@ static const char *fetch(const Request &r, char *value, size_t value_size)
|
||||
|
||||
JsonVariantConst v = doc["state"];
|
||||
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())
|
||||
return "Unknown attribute";
|
||||
String text;
|
||||
|
||||
@@ -73,6 +73,7 @@ void pageToJson(const DynamicPage &page, JsonObject out)
|
||||
o["bar_height"] = e.bar_height;
|
||||
o["bar_max"] = e.bar_max;
|
||||
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)
|
||||
{
|
||||
page = DynamicPage();
|
||||
clearPage(page);
|
||||
JsonArrayConst slots = in["slots"];
|
||||
JsonArrayConst elements = in["elements"];
|
||||
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_max = o["bar_max"] | 100.0f;
|
||||
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)))
|
||||
return "The list of texts is too long (63 characters)";
|
||||
const char *target = o["target"] | "";
|
||||
|
||||
@@ -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 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:'',
|
||||
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 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','')}
|
||||
@@ -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));
|
||||
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]]))),
|
||||
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})),
|
||||
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()};
|
||||
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})));
|
||||
@@ -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 row(...pos,ovl);
|
||||
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 pePage(){const p=JSON.parse(JSON.stringify(pe.page));p.slots.sort(bySlot);return p}
|
||||
function pePreview(){clearTimeout(peTimer);peTimer=setTimeout(peRender,120)}
|
||||
|
||||
@@ -2273,6 +2273,68 @@ void test_bar_render()
|
||||
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()
|
||||
{
|
||||
DynamicPage page = barPage();
|
||||
@@ -2462,6 +2524,8 @@ int main()
|
||||
RUN_TEST(test_bar_fill);
|
||||
RUN_TEST(test_bar_render);
|
||||
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_slow_scrolling_below_one_dot_per_second);
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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')
|
||||