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

+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_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};
+92 -13
View File
@@ -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;
+1 -1
View File
@@ -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");
+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)
{
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;
+16 -1
View File
@@ -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);
+51 -3
View File
@@ -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;
+3 -1
View File
@@ -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"] | "";
+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 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)}