Rules can leave a manual pick alone, Clock is an editable page, fix HA attributes

- Rules: "also over a page chosen by hand" (on by default); off, a rule
  waits until the manual pick ends. Schedule store version 3, version 2
  records are read with the option on.
- The Clock page is stored at boot from the built-in one and can be
  edited; Reset (deleting it) brings back the original.
- Home Assistant attributes were never found: ArduinoJson 7.4 takes a
  char array key as a literal of its full array length.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-16 22:13:23 +02:00
1 parent ab001e5d18
commit 51b8d87e86
37 files changed
+134 -42

No files matched your search

+7 -2
View File
@@ -164,8 +164,13 @@ See README.md for the overview.
versioned binary struct (`display/page_store.cpp`, bump `VERSION` when `DynamicPage` versioned binary struct (`display/page_store.cpp`, bump `VERSION` when `DynamicPage`
changes). They are drawn only on the ESP32 (`logic/page_render`), also for the web changes). They are drawn only on the ESP32 (`logic/page_render`), also for the web
previews, so there is one renderer. Pages and bitmaps share one namespace (checked in previews, so there is one renderer. Pages and bitmaps share one namespace (checked in
`app.cpp`); things a page uses can't be deleted. The built-in page "Clock" replaces the `app.cpp`); things a page uses can't be deleted. The page "Clock" replaces the
old hardcoded clock (also the default and the HA "Clock" option). 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.
- `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).
- Moving pages: `renderPage()` returns when the page changes next (scroll step, second); - Moving pages: `renderPage()` returns when the page changes next (scroll step, second);
`FlipdotFrames` sends those frames as `D1` (no effect) and paces them by the estimated `FlipdotFrames` sends those frames as `D1` (no effect) and paces them by the estimated
receive and flip time, because the Nano has no acknowledgement. Full screen frames go receive and flip time, because the Nano has no acknowledgement. Full screen frames go
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 354 KiB

After

Width:  |  Height:  |  Size: 371 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 414 KiB

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 356 KiB

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 167 KiB

+4 -2
View File
@@ -25,7 +25,8 @@ Bitmaps that pages or rules use are in their own subsection.
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 8 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. Built-in pages can be copied, not changed. See the [pictures of the pages](pages.md). 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).
| Element | Options | | Element | Options |
|---------|---------| |---------|---------|
@@ -57,7 +58,8 @@ Rules bring a page to the front from a Home Assistant state (an entity or attrib
`is not`, `above`, `below`): they show it while the state is true, add it to the rotation `is not`, `above`, `below`): they show it while the state is true, add it to the rotation
while it is true, or show it for a while when it becomes true. Each rule is read at its own while it is true, or show it for a while when it becomes true. Each rule is read at its own
interval (2-600 s) and can be limited to some of the blocks. The first rule that shows a interval (2-600 s) and can be limited to some of the blocks. The first rule that shows a
page wins. The display schedule ([Settings](#settings)) still decides when the display is on. page wins. A rule can leave a page chosen by hand alone (untick "also over a page chosen by
hand"): it then waits until that pick ends (the next block or "Back to schedule"). The display schedule ([Settings](#settings)) still decides when the display is on.
![Schedule](../Pictures/web-ui/schedule.png) ![Schedule](../Pictures/web-ui/schedule.png)
+9 -1
View File
@@ -335,11 +335,19 @@ const char *appSavePage(const char *name, const DynamicPage &page)
const char *appDeletePage(const char *name) const char *appDeletePage(const char *name)
{ {
if (scheduleUses(page_schedule, name)) // A stored copy of a built-in page: deleting it resets the page, which stays
bool resets = builtinPageName(name) != nullptr;
if (!resets && scheduleUses(page_schedule, name))
return USED_BY_SCHEDULE; return USED_BY_SCHEDULE;
const char *error = deletePage(name); const char *error = deletePage(name);
if (error) if (error)
return error; return error;
if (resets)
{
if (sameName(shown, name))
reloadPage();
return nullptr;
}
if (sameName(settings.page, name)) if (sameName(settings.page, name))
setChosen(DEFAULT_PAGE); setChosen(DEFAULT_PAGE);
return nullptr; return nullptr;
+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.10.0" #define FIRMWARE_VERSION "2.11.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};
+27 -19
View File
@@ -301,14 +301,31 @@ static String path(const char *name)
return String(DIR) + "/" + name + EXTENSION; return String(DIR) + "/" + name + EXTENSION;
} }
static bool storedPage(const char *name)
{
return bitmapStorageAvailable() && validBitmapName(name) && LittleFS.exists(path(name));
}
void pageStoreBegin()
{
DynamicPage *page = new DynamicPage;
for (int i = 0; i < builtinPageCount(); i++)
if (bitmapStorageAvailable() && !storedPage(builtinPageName(i)) && builtinPage(builtinPageName(i), *page))
{
const char *error = savePage(builtinPageName(i), *page);
Serial.printf("Page \"%s\" stored from the original: %s\n", builtinPageName(i), error ? error : "ok");
}
delete page;
}
std::vector<PageInfo> listPages() std::vector<PageInfo> listPages()
{ {
std::vector<PageInfo> list; std::vector<PageInfo> list;
for (int i = 0; i < builtinPageCount(); i++) for (int i = 0; i < builtinPageCount(); i++)
list.push_back({builtinPageName(i), true}); if (!storedPage(builtinPageName(i)))
list.push_back({builtinPageName(i), true, false});
for (const String &name : storedNames(DIR, EXTENSION)) for (const String &name : storedNames(DIR, EXTENSION))
if (!builtinPageExists(name.c_str())) list.push_back({name, false, builtinPageName(name.c_str()) != nullptr});
list.push_back({name, false});
return list; return list;
} }
@@ -319,28 +336,21 @@ uint32_t pageListRevision()
bool builtinPageExists(const char *name) bool builtinPageExists(const char *name)
{ {
for (int i = 0; i < builtinPageCount(); i++) return builtinPageName(name) != nullptr && !storedPage(name);
if (sameName(builtinPageName(i), name))
return true;
return false;
} }
bool pageExists(const char *name) bool pageExists(const char *name)
{ {
if (builtinPageExists(name)) return builtinPageName(name) != nullptr || storedPage(name);
return true;
return bitmapStorageAvailable() && validBitmapName(name) && LittleFS.exists(path(name));
} }
bool loadPage(const char *name, DynamicPage &page) bool loadPage(const char *name, DynamicPage &page)
{ {
if (builtinPage(name, page)) if (!storedPage(name))
return true; return builtinPage(name, page);
if (!bitmapStorageAvailable() || !validBitmapName(name) || !LittleFS.exists(path(name)))
return false;
File file = LittleFS.open(path(name), "r"); File file = LittleFS.open(path(name), "r");
if (!file) if (!file)
return false; return builtinPage(name, page);
bool ok = false; bool ok = false;
size_t size = file.size(); size_t size = file.size();
if (size == sizeof(StoredPage)) if (size == sizeof(StoredPage))
@@ -401,15 +411,13 @@ bool loadPage(const char *name, DynamicPage &page)
delete stored; delete stored;
} }
file.close(); file.close();
return ok; return ok || builtinPage(name, page);
} }
const char *savePage(const char *name, const DynamicPage &page) const char *savePage(const char *name, const DynamicPage &page)
{ {
if (!validBitmapName(name)) if (!validBitmapName(name))
return "Invalid name: use 1-24 letters, digits, spaces, - or _"; return "Invalid name: use 1-24 letters, digits, spaces, - or _";
if (builtinPageExists(name))
return "A built-in page has this name";
const char *error = validatePage(page); const char *error = validatePage(page);
if (error) if (error)
return error; return error;
@@ -435,7 +443,7 @@ const char *deletePage(const char *name)
{ {
if (builtinPageExists(name)) if (builtinPageExists(name))
return "Built-in pages can't be deleted"; return "Built-in pages can't be deleted";
if (!bitmapStorageAvailable() || !validBitmapName(name) || !LittleFS.exists(path(name))) if (!storedPage(name))
return "Page not found"; return "Page not found";
if (!LittleFS.remove(path(name))) if (!LittleFS.remove(path(name)))
return "Could not delete the file"; return "Could not delete the file";
+9 -2
View File
@@ -12,16 +12,23 @@
struct PageInfo struct PageInfo
{ {
String name; String name;
bool builtin; bool builtin; // the original from logic/builtin_pages (no stored page of that name)
bool original; // a stored page that replaces a built-in one: deleting it brings that back
}; };
const int MAX_PAGES = 50; const int MAX_PAGES = 50;
// Built-in pages first, then the stored ones // At boot: stores a copy of each built-in page that is not stored (the Clock page), so it
// can be edited like the others. The built-in one stays the fallback: deleting the stored
// copy resets it (it is stored again at the next boot).
void pageStoreBegin();
// Built-in pages without a stored copy first, then the stored ones
std::vector<PageInfo> listPages(); std::vector<PageInfo> listPages();
// Changes each time a page is saved or deleted // Changes each time a page is saved or deleted
uint32_t pageListRevision(); uint32_t pageListRevision();
bool pageExists(const char *name); bool pageExists(const char *name);
// A built-in page that no stored page replaces
bool builtinPageExists(const char *name); bool builtinPageExists(const char *name);
bool loadPage(const char *name, DynamicPage &page); bool loadPage(const char *name, DynamicPage &page);
+8
View File
@@ -16,6 +16,14 @@ const char *builtinPageName(int index)
return index >= 0 && index < builtinPageCount() ? NAMES[index] : nullptr; return index >= 0 && index < builtinPageCount() ? NAMES[index] : nullptr;
} }
const char *builtinPageName(const char *name)
{
for (const char *n : NAMES)
if (name && sameName(n, name))
return n;
return nullptr;
}
static void clockPage(DynamicPage &page) static void clockPage(DynamicPage &page)
{ {
page = DynamicPage(); page = DynamicPage();
+4 -2
View File
@@ -2,8 +2,8 @@
#include "logic/page.h" #include "logic/page.h"
// The built-in dynamic pages. They can't be changed or deleted; the web UI saves an edited // The original dynamic pages. display/page_store stores a copy of each at boot that can be
// copy under a new name. // edited; the original is the fallback (deleting the copy resets the page).
// //
// Clock Pikachu with the time in a thought bubble, yawning from 17:00 (the clock of // Clock Pikachu with the time in a thought bubble, yawning from 17:00 (the clock of
// the old firmware; also the default page) // the old firmware; also the default page)
@@ -12,5 +12,7 @@
int builtinPageCount(); int builtinPageCount();
const char *builtinPageName(int index); const char *builtinPageName(int index);
// The built-in name as it is written, nullptr when it is no built-in page
const char *builtinPageName(const char *name);
// False when there is no built-in page with this name (compared without case) // False when there is no built-in page with this name (compared without case)
bool builtinPage(const char *name, DynamicPage &page); bool builtinPage(const char *name, DynamicPage &page);
+1
View File
@@ -64,6 +64,7 @@ struct TriggerRule
uint16_t seconds = 30; // AddToRotation: time in the rotation; ForSeconds: how long uint16_t seconds = 30; // AddToRotation: time in the rotation; ForSeconds: how long
uint16_t interval = 10; // read the entity this often (s) uint16_t interval = 10; // read the entity this often (s)
uint8_t blocks = 0; // bit i: active during block i; 0: always (also without a schedule) uint8_t blocks = 0; // bit i: active during block i; 0: always (also without a schedule)
bool over_manual = true; // also over a page picked by hand (false: it waits until the pick ends)
}; };
struct PageSchedule struct PageSchedule
+3 -2
View File
@@ -106,7 +106,7 @@ PageChoice PageSelector::update(int minute, uint32_t now)
_rule_timer[i] = false; _rule_timer[i] = false;
bool takes_over = ((r.mode == TriggerMode::WhileTrue && _rule_active[i]) || bool takes_over = ((r.mode == TriggerMode::WhileTrue && _rule_active[i]) ||
(r.mode == TriggerMode::ForSeconds && _rule_timer[i])) && (r.mode == TriggerMode::ForSeconds && _rule_timer[i])) &&
ruleInBlock(r, _block); ruleInBlock(r, _block) && (r.over_manual || !_picked);
if (takes_over) if (takes_over)
{ {
choice.page = r.page; choice.page = r.page;
@@ -138,7 +138,8 @@ PageChoice PageSelector::update(int minute, uint32_t now)
for (int i = 0; i < rule_count; i++) for (int i = 0; i < rule_count; i++)
{ {
const TriggerRule &r = _schedule->rules[i]; const TriggerRule &r = _schedule->rules[i];
if (r.mode == TriggerMode::AddToRotation && _rule_active[i] && ruleInBlock(r, _block)) if (r.mode == TriggerMode::AddToRotation && _rule_active[i] && ruleInBlock(r, _block) &&
(r.over_manual || !_picked))
items[count++] = {r.page, r.seconds * 1000UL, ShowReason::Rotation, i}; items[count++] = {r.page, r.seconds * 1000UL, ShowReason::Rotation, i};
} }
+2 -1
View File
@@ -6,7 +6,8 @@
// Decides which page or bitmap is shown, from (highest priority first): // Decides which page or bitmap is shown, from (highest priority first):
// 1. take-over rules (TriggerMode::WhileTrue / ForSeconds), in their order; a rule can be // 1. take-over rules (TriggerMode::WhileTrue / ForSeconds), in their order; a rule can be
// limited to some schedule blocks (TriggerRule::blocks) // limited to some schedule blocks (TriggerRule::blocks), and can leave a manual pick
// alone (TriggerRule::over_manual false: also its AddToRotation page)
// 2. a manual pick (web UI, Home Assistant "Mode"), until the next schedule block starts // 2. a manual pick (web UI, Home Assistant "Mode"), until the next schedule block starts
// 3. the schedule: the pages of the current block rotate, plus the pages of active // 3. the schedule: the pages of the current block rotate, plus the pages of active
// AddToRotation rules // AddToRotation rules
+2
View File
@@ -7,6 +7,7 @@
#include "app.h" #include "app.h"
#include "config.h" #include "config.h"
#include "display/bitmap_store.h" #include "display/bitmap_store.h"
#include "display/page_store.h"
#include "logic/status_led.h" #include "logic/status_led.h"
#include "nano_link/nano_flash_bridge.h" #include "nano_link/nano_flash_bridge.h"
#include "net/ha_api.h" #include "net/ha_api.h"
@@ -33,6 +34,7 @@ void setup()
BootMode boot_mode = firmwareGuardBegin(); BootMode boot_mode = firmwareGuardBegin();
bitmapStoreBegin(); bitmapStoreBegin();
pageStoreBegin(); // the Clock page as a page that can be edited
appLoadSettings(); appLoadSettings();
if (boot_mode == BootMode::Normal) if (boot_mode == BootMode::Normal)
+2 -2
View File
@@ -99,7 +99,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"][r.attribute] = true; filter["attributes"][(const char *)r.attribute] = true; // a char array would be taken as a literal of 32 characters
else else
filter["state"] = true; filter["state"] = true;
JsonDocument doc; JsonDocument doc;
@@ -110,7 +110,7 @@ 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"][r.attribute]; v = doc["attributes"][(const char *)r.attribute];
if (v.isNull()) if (v.isNull())
return "Unknown attribute"; return "Unknown attribute";
String text; String text;
+1 -1
View File
@@ -97,7 +97,7 @@ static std::vector<String> pageNames()
{ {
std::vector<String> names, stored; std::vector<String> names, stored;
for (const PageInfo &p : listPages()) for (const PageInfo &p : listPages())
(p.builtin ? names : stored).push_back(p.name); (p.builtin || p.original ? names : stored).push_back(p.name);
std::sort(stored.begin(), stored.end(), lessName); std::sort(stored.begin(), stored.end(), lessName);
names.insert(names.end(), stored.begin(), stored.end()); names.insert(names.end(), stored.begin(), stored.end());
std::vector<String> bitmaps = bitmapNames(); std::vector<String> bitmaps = bitmapNames();
+12 -4
View File
@@ -6,7 +6,10 @@
static const char *NAMESPACE = "flipdot"; static const char *NAMESPACE = "flipdot";
static const char *KEY = "schedule"; static const char *KEY = "schedule";
// Increase when PageSchedule changes; older records are ignored unless converted below // Increase when PageSchedule changes; older records are ignored unless converted below
static const uint16_t VERSION = 2; static const uint16_t VERSION = 3;
// Version 2: the same layout, but TriggerRule::over_manual was padding (set to true)
static const uint16_t VERSION_WITHOUT_OVER_MANUAL = 2;
static_assert(sizeof(TriggerRule) == 164, "version 2 records are only read when TriggerRule kept its size");
struct StoredSchedule struct StoredSchedule
{ {
@@ -52,7 +55,7 @@ static void convert(const Schedule &old, PageSchedule &s)
s.enabled = old.enabled; s.enabled = old.enabled;
s.block_count = old.block_count; s.block_count = old.block_count;
memcpy(s.blocks, old.blocks, sizeof(s.blocks)); memcpy(s.blocks, old.blocks, sizeof(s.blocks));
s.rule_count = old.rule_count; s.rule_count = old.rule_count; // over_manual: the default (true), like before
for (int i = 0; i < TRIGGER_RULES; i++) for (int i = 0; i < TRIGGER_RULES; i++)
{ {
const Rule &o = old.rules[i]; const Rule &o = old.rules[i];
@@ -81,9 +84,14 @@ void loadSchedule(PageSchedule &schedule)
{ {
StoredSchedule *stored = new StoredSchedule; StoredSchedule *stored = new StoredSchedule;
if (prefs.getBytes(KEY, stored, sizeof(StoredSchedule)) == sizeof(StoredSchedule) && if (prefs.getBytes(KEY, stored, sizeof(StoredSchedule)) == sizeof(StoredSchedule) &&
stored->version == VERSION && stored->size == sizeof(PageSchedule) && (stored->version == VERSION || stored->version == VERSION_WITHOUT_OVER_MANUAL) &&
validateSchedule(stored->schedule) == nullptr) stored->size == sizeof(PageSchedule) && validateSchedule(stored->schedule) == nullptr)
{
if (stored->version == VERSION_WITHOUT_OVER_MANUAL)
for (TriggerRule &r : stored->schedule.rules)
r.over_manual = true;
schedule = stored->schedule; schedule = stored->schedule;
}
delete stored; delete stored;
} }
else if (length == sizeof(v1::Stored)) else if (length == sizeof(v1::Stored))
+2
View File
@@ -59,6 +59,7 @@ void scheduleToJson(const PageSchedule &s, JsonObject out)
o["mode"] = MODES[(int)r.mode]; o["mode"] = MODES[(int)r.mode];
o["seconds"] = r.seconds; o["seconds"] = r.seconds;
o["interval"] = r.interval; o["interval"] = r.interval;
o["over_manual"] = r.over_manual;
JsonArray in_blocks = o["blocks"].to<JsonArray>(); JsonArray in_blocks = o["blocks"].to<JsonArray>();
for (int b = 0; b < s.block_count; b++) for (int b = 0; b < s.block_count; b++)
if ((r.blocks >> b) & 1) if ((r.blocks >> b) & 1)
@@ -111,6 +112,7 @@ const char *scheduleFromJson(JsonVariantConst in, PageSchedule &s)
return "Rule text too long"; return "Rule text too long";
r.seconds = constrain(o["seconds"] | 30, 0, 65535); r.seconds = constrain(o["seconds"] | 30, 0, 65535);
r.interval = constrain(o["interval"] | 10, 0, 65535); r.interval = constrain(o["interval"] | 10, 0, 65535);
r.over_manual = o["over_manual"] | true;
// The blocks by their start time (none: always) // The blocks by their start time (none: always)
for (JsonVariantConst start : o["blocks"].as<JsonArrayConst>()) for (JsonVariantConst start : o["blocks"].as<JsonArrayConst>())
{ {
+6 -3
View File
@@ -101,7 +101,7 @@ details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:13px;m
<div id="sc_blocks"></div><div class="row"><button class="s" onclick="scAddBlock()">Add block</button></div> <div id="sc_blocks"></div><div class="row"><button class="s" onclick="scAddBlock()">Add block</button></div>
<p class="hint">Every day: a block runs from its time until the next block (the last one continues after midnight). Its pages rotate, each for its time. Without blocks, or switched off, the chosen page (Show tab) is shown. A page chosen by hand is shown until the next block starts.</p></div> <p class="hint">Every day: a block runs from its time until the next block (the last one continues after midnight). Its pages rotate, each for its time. Without blocks, or switched off, the chosen page (Show tab) is shown. A page chosen by hand is shown until the next block starts.</p></div>
<div class="card"><h2>Rules</h2><div id="sc_rules"></div><div class="row"><button class="s" onclick="scAddRule()">Add rule</button></div> <div class="card"><h2>Rules</h2><div id="sc_rules"></div><div class="row"><button class="s" onclick="scAddRule()">Add rule</button></div>
<p class="hint">Home Assistant states that bring a page to the front (needs the token in the Home Assistant tab). The first rule that shows a page wins, over the schedule and a page chosen by hand. Texts are compared without case; above and below compare numbers. Rules only act while the display is on, and only in the blocks ticked for them (none ticked: always; without a running schedule the blocks don't matter).</p></div> <p class="hint">Home Assistant states that bring a page to the front (needs the token in the Home Assistant tab). The first rule that shows a page wins, over the schedule and (when ticked) over a page chosen by hand. Texts are compared without case; above and below compare numbers. Rules only act while the display is on, and only in the blocks ticked for them (none ticked: always; without a running schedule the blocks don't matter).</p></div>
<button class="b" onclick="scSave()">Save schedule and rules</button></section> <button class="b" onclick="scSave()">Save schedule and rules</button></section>
<section id="t-stopwatch"><div class="card"><h2>Stopwatch</h2> <section id="t-stopwatch"><div class="card"><h2>Stopwatch</h2>
@@ -274,7 +274,7 @@ async function loadLists(){const[b,p,f,sc,ic]=await Promise.all([getList('/api/b
pages.appendChild(card(pg.name,tag,pg.bits,()=>selectPage(pg.name),settings&&same(settings.page,pg.name))); pages.appendChild(card(pg.name,tag,pg.bits,()=>selectPage(pg.name),settings&&same(settings.page,pg.name)));
const c=card(pg.name,tag,pg.bits,null,settings&&same(settings.page,pg.name)),r=mk('div',{className:'row'}); const c=card(pg.name,tag,pg.bits,null,settings&&same(settings.page,pg.name)),r=mk('div',{className:'row'});
r.append(btn('Show',()=>selectPage(pg.name)),pg.builtin?btn('Copy',()=>peOpen(pg,true)):btn('Edit',()=>peOpen(pg,false))); r.append(btn('Show',()=>selectPage(pg.name)),pg.builtin?btn('Copy',()=>peOpen(pg,true)):btn('Edit',()=>peOpen(pg,false)));
if(!pg.builtin)r.append(btn('Export',()=>exportItem('page',pg.name)),btn('Delete',()=>delPage(pg.name),'d'));c.appendChild(r);dyn.appendChild(c)} if(!pg.builtin)r.append(btn('Export',()=>exportItem('page',pg.name)),pg.original?btn('Reset',()=>resetPage(pg.name),'d'):btn('Delete',()=>delPage(pg.name),'d'));c.appendChild(r);dyn.appendChild(c)}
for(const bm of bitmapList){const data=bm.bits,tag=bm.builtin?'built-in bitmap':'bitmap'; for(const bm of bitmapList){const data=bm.bits,tag=bm.builtin?'built-in bitmap':'bitmap';
const u=used.has(bm.name.toLowerCase()); const u=used.has(bm.name.toLowerCase());
$(u?'pages_used':'pages').appendChild(card(bm.name,tag,data,()=>selectPage(bm.name),settings&&same(settings.page,bm.name))); $(u?'pages_used':'pages').appendChild(card(bm.name,tag,data,()=>selectPage(bm.name),settings&&same(settings.page,bm.name)));
@@ -303,6 +303,7 @@ async function delBitmap(n){if(!confirm('Delete '+n+'?'))return;await post('/api
async function delIcon(n){if(!confirm('Delete icon '+n+'?'))return;await post('/api/icon/delete',{name:n});msg('Deleted');if(ic&&same(ic.orig,n))icClose();loadLists()} async function delIcon(n){if(!confirm('Delete icon '+n+'?'))return;await post('/api/icon/delete',{name:n});msg('Deleted');if(ic&&same(ic.orig,n))icClose();loadLists()}
async function toIcon(n){if(!confirm('Make '+n+' an icon? It is then only used on pages, not shown on its own.'))return;await post('/api/bitmap/to_icon',{name:n});msg(n+' is now an icon');loadLists()} async function toIcon(n){if(!confirm('Make '+n+' an icon? It is then only used on pages, not shown on its own.'))return;await post('/api/bitmap/to_icon',{name:n});msg(n+' is now an icon');loadLists()}
async function toBitmap(n){await post('/api/icon/to_bitmap',{name:n});msg(n+' is now a bitmap');if(ic&&same(ic.orig,n))icClose();loadLists()} async function toBitmap(n){await post('/api/icon/to_bitmap',{name:n});msg(n+' is now a bitmap');if(ic&&same(ic.orig,n))icClose();loadLists()}
async function resetPage(n){if(!confirm('Reset '+n+' to the original page? Your changes to it are lost.'))return;await post('/api/pages/delete',{name:n});msg(n+' reset');if(pe&&same(pe.orig,n))peClose();loadLists()}
async function delPage(n){if(!confirm('Delete page '+n+'?'))return;await post('/api/pages/delete',{name:n});msg('Deleted');if(pe&&same(pe.orig,n))peClose();settings=await api('/api/settings');loadLists()} async function delPage(n){if(!confirm('Delete page '+n+'?'))return;await post('/api/pages/delete',{name:n});msg('Deleted');if(pe&&same(pe.orig,n))peClose();settings=await api('/api/settings');loadLists()}
async function delFont(n){if(!confirm('Delete font '+n+'?'))return;await post('/api/fonts/delete',{name:n});msg('Deleted');loadLists()} async function delFont(n){if(!confirm('Delete font '+n+'?'))return;await post('/api/fonts/delete',{name:n});msg('Deleted');loadLists()}
function swText(ms){const s=Math.floor(ms/1000),h=Math.floor(s/3600),m=Math.floor(s%3600/60),p=n=>String(n).padStart(2,'0');return(h?h+':'+p(m):m)+':'+p(s%60)} function swText(ms){const s=Math.floor(ms/1000),h=Math.floor(s/3600),m=Math.floor(s%3600/60),p=n=>String(n).padStart(2,'0');return(h?h+':'+p(m):m)+':'+p(s%60)}
@@ -339,6 +340,8 @@ function schedBuild(){if(!sched)return;schedLink();$('sc_en').checked=sched.enab
btn('Test',async()=>{state.textContent='now: '+await haValue(ru.entity,ru.attribute)})), btn('Test',async()=>{state.textContent='now: '+await haValue(ru.entity,ru.attribute)})),
mk('div',{className:'row'},'then',ssel(ru,'page',opts),ssel(ru,'mode',RMODE,()=>{secs.style.display=ru.mode=='while'?'none':''}),secs, mk('div',{className:'row'},'then',ssel(ru,'page',opts),ssel(ru,'mode',RMODE,()=>{secs.style.display=ru.mode=='while'?'none':''}),secs,
'read every',sinp(ru,'interval','number',{style:'width:5em',min:2,max:600}),'s',state,btn('Remove',()=>{sched.rules.splice(i,1);schedDirty=true;schedBuild()},'d')), 'read every',sinp(ru,'interval','number',{style:'width:5em',min:2,max:600}),'s',state,btn('Remove',()=>{sched.rules.splice(i,1);schedDirty=true;schedBuild()},'d')),
mk('div',{className:'row'},(()=>{const c=mk('input',{type:'checkbox',checked:ru.over_manual!==false});c.onchange=()=>{ru.over_manual=c.checked;schedDirty=true};
return mk('label',{},c,' also over a page chosen by hand')})(),mk('span',{className:'hint',textContent:'(off: it waits until the chosen page ends: the next block or Back to schedule)'})),
sched.blocks.length?mk('div',{className:'row'},'only in the blocks',...sched.blocks.map(b=>{const c=mk('input',{type:'checkbox',checked:ru._b.includes(b)}); sched.blocks.length?mk('div',{className:'row'},'only in the blocks',...sched.blocks.map(b=>{const c=mk('input',{type:'checkbox',checked:ru._b.includes(b)});
c.onchange=()=>{ru._b=c.checked?ru._b.concat([b]):ru._b.filter(x=>x!==b);schedDirty=true};return mk('label',{},c,' '+b.start)}), c.onchange=()=>{ru._b=c.checked?ru._b.concat([b]):ru._b.filter(x=>x!==b);schedDirty=true};return mk('label',{},c,' '+b.start)}),
mk('span',{className:'hint',textContent:'(none ticked: always)'})):''))}); mk('span',{className:'hint',textContent:'(none ticked: always)'})):''))});
@@ -346,7 +349,7 @@ function schedBuild(){if(!sched)return;schedLink();$('sc_en').checked=sched.enab
function scAddBlock(){if(sched.blocks.length>=8){msg('8 blocks at most',true);return} function scAddBlock(){if(sched.blocks.length>=8){msg('8 blocks at most',true);return}
sched.blocks.push({start:'12:00',effect:true,items:[{page:settings.page,seconds:30}]});sched.blocks.sort(bySlot);schedDirty=true;schedBuild()} sched.blocks.push({start:'12:00',effect:true,items:[{page:settings.page,seconds:30}]});sched.blocks.sort(bySlot);schedDirty=true;schedBuild()}
function scAddRule(){if(sched.rules.length>=8){msg('8 rules at most',true);return} function scAddRule(){if(sched.rules.length>=8){msg('8 rules at most',true);return}
sched.rules.push({entity:'',attribute:'',compare:'eq',value:'',page:settings.page,mode:'while',seconds:30,interval:10});schedDirty=true;schedBuild()} sched.rules.push({entity:'',attribute:'',compare:'eq',value:'',page:settings.page,mode:'while',seconds:30,interval:10,over_manual:true});schedDirty=true;schedBuild()}
async function scSave(){sched.blocks.sort(bySlot);sched=await post('/api/schedule',schedPayload());schedDirty=false;schedBuild();msg('Schedule saved');loadLists()} async function scSave(){sched.blocks.sort(bySlot);sched=await post('/api/schedule',schedPayload());schedDirty=false;schedBuild();msg('Schedule saved');loadLists()}
async function backToSchedule(){await post('/api/schedule/back');msg('Back to the schedule');setTimeout(pollStatusOnce,500)} async function backToSchedule(){await post('/api/schedule/back');msg('Back to the schedule');setTimeout(pollStatusOnce,500)}
async function pollStatusOnce(){try{status=await api('/api/status');$('sc_now').textContent='Showing: '+status.shown+' ('+status.shown_status+')';$('shownnow').textContent=$('sc_now').textContent}catch(e){}} async function pollStatusOnce(){try{status=await api('/api/status');$('sc_now').textContent='Showing: '+status.shown+' ('+status.shown_status+')';$('shownnow').textContent=$('sc_now').textContent}catch(e){}}
+2
View File
@@ -396,6 +396,8 @@ static void handlePages()
JsonDocument doc; JsonDocument doc;
doc["name"] = info.name; doc["name"] = info.name;
doc["builtin"] = info.builtin; doc["builtin"] = info.builtin;
if (info.original)
doc["original"] = true; // Delete resets it
doc["data"] = hex; doc["data"] = hex;
pageToJson(*page, doc["page"].to<JsonObject>()); pageToJson(*page, doc["page"].to<JsonObject>());
String item; String item;
+32
View File
@@ -1841,6 +1841,37 @@ void test_selector_take_over_rules()
TEST_ASSERT_TRUE(c.instant); // a rule's page leaves at once TEST_ASSERT_TRUE(c.instant); // a rule's page leaves at once
} }
void test_selector_rule_can_leave_a_manual_pick_alone()
{
PageSchedule s = twoBlocks(); // 07:00 Weer + Datum, 17:00 Energie
s.rule_count = 2;
s.rules[0] = rule("3D printer", TriggerMode::WhileTrue);
s.rules[0].over_manual = false;
s.rules[1] = rule("Deurbel", TriggerMode::AddToRotation, 10);
s.rules[1].over_manual = false;
PageSelector sel;
sel.setSchedule(&s);
sel.update(8 * 60, 0);
sel.setRuleState(0, true, 0);
TEST_ASSERT_EQUAL_STRING("3D printer", sel.update(8 * 60, 0).page); // no pick: it takes over
sel.pick("Clock");
PageChoice c = sel.update(8 * 60, 1000);
TEST_ASSERT_EQUAL_STRING("Clock", c.page); // waits while the pick lasts
TEST_ASSERT_TRUE(c.reason == ShowReason::Chosen);
sel.setRuleState(1, true, 1000);
for (uint32_t t = 1000; t < 120000; t += 5000)
TEST_ASSERT_EQUAL_STRING("Clock", sel.update(8 * 60, t).page); // not in the rotation either
sel.backToSchedule();
TEST_ASSERT_EQUAL_STRING("3D printer", sel.update(8 * 60, 121000).page);
// The next block ends a pick too
sel.pick("Clock");
TEST_ASSERT_EQUAL_STRING("Clock", sel.update(16 * 60, 122000).page);
TEST_ASSERT_EQUAL_STRING("3D printer", sel.update(17 * 60, 123000).page);
}
void test_selector_rules_only_in_their_blocks() void test_selector_rules_only_in_their_blocks()
{ {
PageSchedule s = twoBlocks(); // 07:00 Weer + Datum, 17:00 Energie PageSchedule s = twoBlocks(); // 07:00 Weer + Datum, 17:00 Energie
@@ -2415,6 +2446,7 @@ int main()
RUN_TEST(test_selector_one_block_starts_again_each_day); RUN_TEST(test_selector_one_block_starts_again_each_day);
RUN_TEST(test_selector_take_over_rules); RUN_TEST(test_selector_take_over_rules);
RUN_TEST(test_selector_rules_only_in_their_blocks); RUN_TEST(test_selector_rules_only_in_their_blocks);
RUN_TEST(test_selector_rule_can_leave_a_manual_pick_alone);
RUN_TEST(test_selector_add_to_rotation); RUN_TEST(test_selector_add_to_rotation);
RUN_TEST(test_ha_auto_unit_and_absolute); RUN_TEST(test_ha_auto_unit_and_absolute);
RUN_TEST(test_ha_duration); RUN_TEST(test_ha_duration);