diff --git a/CLAUDE.md b/CLAUDE.md index 5e042ad..bbac47e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -138,6 +138,16 @@ See README.md for the overview. - Bitmaps on LittleFS in the `spiffs` partition (`/bitmaps/.bin`, 209 bytes). Names are restricted (`validBitmapName`), which also makes them safe in paths and JSON. - The display mirror comes from the driver's shadow buffer (`BROSE9323::shownPixel`). +- Dynamic pages (`logic/page.h`): background per time slot + elements, stored as a + 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 + 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 + old hardcoded clock (also the default and the HA "Clock" option). +- Fonts use the "FDF1" format of `logic/font.h`; the web page writes the same format. + `logic/builtin_fonts.cpp` is generated: change `tools/make_builtin_fonts.py` and rerun it. +- Home Assistant values come from HA's REST API (`net/ha_api`), one blocking request per + `loop()` with `HA_HTTP_TIMEOUT`. Keep it that way (watchdog, OTA). - Check the page's JavaScript after changes: extract the `)rawliteral"; diff --git a/src/esp32/web/web_ui.cpp b/src/esp32/web/web_ui.cpp index e833bb0..0ab0e5a 100644 --- a/src/esp32/web/web_ui.cpp +++ b/src/esp32/web/web_ui.cpp @@ -4,14 +4,21 @@ #include #include #include +#include #include "app.h" #include "config.h" #include "display/bitmap_store.h" +#include "display/font_store.h" +#include "display/page_resources.h" +#include "display/page_store.h" #include "logic/bitmap_format.h" +#include "logic/page_render.h" +#include "net/ha_api.h" #include "net/home_assistant.h" #include "system/boot_target.h" #include "system/firmware_guard.h" +#include "web/page_json.h" #include "web/web_page.h" static WebServer server(80); @@ -70,6 +77,54 @@ static bool copyTime(JsonVariantConst value, uint16_t &out) return parseTime(value.as(), out); } +static void sendResult(const char *error) +{ + if (error) + sendError(400, error); + else + sendOk(); +} + +static int hexDigit(char c) +{ + if (c >= '0' && c <= '9') + return c - '0'; + if (c >= 'a' && c <= 'f') + return c - 'a' + 10; + if (c >= 'A' && c <= 'F') + return c - 'A' + 10; + return -1; +} + +// Any even number of hex digits, at most max_size bytes +static bool fromHex(const char *hex, std::vector &out, size_t max_size) +{ + size_t length = strlen(hex); + if (length % 2 || length / 2 > max_size) + return false; + out.resize(length / 2); + for (size_t i = 0; i < out.size(); i++) + { + int high = hexDigit(hex[2 * i]), low = hexDigit(hex[2 * i + 1]); + if (high < 0 || low < 0) + return false; + out[i] = high << 4 | low; + } + return true; +} + +// The time pages are drawn for in previews: now, or 12:34 before the time is synced +static LocalTime previewTime() +{ + LocalTime time; + if (!appLocalTime(time)) + { + time.hour = 12; + time.minute = 34; + } + return time; +} + // --------------------------------------------------------------------------- // Page and status // --------------------------------------------------------------------------- @@ -195,14 +250,7 @@ static void handleBitmapSave() sendError(400, "Invalid bitmap data"); return; } - const char *error = saveBitmap(name, bitmap); - if (error) - { - sendError(400, error); - return; - } - appBitmapChanged(name); - sendOk(); + sendResult(appSaveBitmap(name, bitmap)); } static void handleBitmapDelete() @@ -210,18 +258,162 @@ static void handleBitmapDelete() JsonDocument body; if (!readBody(body)) return; - const char *name = body["name"] | ""; - const char *error = deleteBitmap(name); + sendResult(appDeleteBitmap(body["name"] | "")); +} + +// --------------------------------------------------------------------------- +// Dynamic pages +// --------------------------------------------------------------------------- + +// Streamed like the bitmaps, each with a preview drawn for the current time +static void handlePages() +{ + server.setContentLength(CONTENT_LENGTH_UNKNOWN); + server.send(200, "application/json", ""); + server.sendContent("{\"pages\":["); + + bool first = true; + DynamicPage *page = new DynamicPage; + LocalTime time = previewTime(); + uint8_t bitmap[BITMAP_BYTES]; + char hex[BITMAP_HEX_LENGTH + 1]; + for (const PageInfo &info : listPages()) + { + if (!loadPage(info.name.c_str(), *page)) + continue; + renderPage(*page, time, pageResources, bitmap); + bitmapToHex(bitmap, hex); + JsonDocument doc; + doc["name"] = info.name; + doc["builtin"] = info.builtin; + doc["data"] = hex; + pageToJson(*page, doc["page"].to()); + String item = first ? "" : ","; + serializeJson(doc, item); + server.sendContent(item); + first = false; + } + delete page; + server.sendContent("]}"); + server.sendContent(""); +} + +static void handlePageSave() +{ + JsonDocument body; + if (!readBody(body)) + return; + DynamicPage *page = new DynamicPage; + const char *error = pageFromJson(body["page"], *page); + if (!error) + error = appSavePage(body["name"] | "", *page); + delete page; + sendResult(error); +} + +static void handlePageDelete() +{ + JsonDocument body; + if (!readBody(body)) + return; + sendResult(appDeletePage(body["name"] | "")); +} + +// Draws a page from the editor: {page, time (optional "HH:MM")} -> {data, boxes: [[x, y, w, h]]} +static void handlePagePreview() +{ + JsonDocument body; + if (!readBody(body)) + return; + DynamicPage *page = new DynamicPage; + const char *error = pageFromJson(body["page"], *page); + LocalTime time = previewTime(); + uint16_t minutes; + if (!error && body["time"].is() && parseTime(body["time"], minutes)) + { + time.hour = minutes / 60 % 24; + time.minute = minutes % 60; + } + if (error) + { + delete page; + sendError(400, error); + return; + } + + uint8_t bitmap[BITMAP_BYTES]; + ElementBox boxes[PAGE_ELEMENTS]; + renderPage(*page, time, pageResources, bitmap, boxes); + char hex[BITMAP_HEX_LENGTH + 1]; + bitmapToHex(bitmap, hex); + JsonDocument doc; + doc["data"] = hex; + JsonArray list = doc["boxes"].to(); + for (int i = 0; i < page->element_count; i++) + { + JsonArray box = list.add(); + box.add(boxes[i].x); + box.add(boxes[i].y); + box.add(boxes[i].width); + box.add(boxes[i].height); + } + delete page; + sendJson(200, doc); +} + +// --------------------------------------------------------------------------- +// Fonts and Home Assistant values +// --------------------------------------------------------------------------- + +static void handleFonts() +{ + JsonDocument doc; + JsonArray list = doc["fonts"].to(); + for (const FontInfo &info : listFonts()) + { + JsonObject font = list.add(); + font["name"] = info.name; + font["builtin"] = info.builtin; + font["height"] = info.height; + } + sendJson(200, doc); +} + +static void handleFontSave() +{ + JsonDocument body; + if (!readBody(body)) + return; + std::vector data; + if (!fromHex(body["data"] | "", data, FONT_MAX_SIZE)) + { + sendError(400, "Invalid font data"); + return; + } + sendResult(appSaveFont(body["name"] | "", data.data(), data.size())); +} + +static void handleFontDelete() +{ + JsonDocument body; + if (!readBody(body)) + return; + sendResult(appDeleteFont(body["name"] | "")); +} + +// Reads a value now (test button in the page editor); it is then also used in previews +static void handleHaValue() +{ + String value; + const char *error = haApiFetchNow(server.arg("entity").c_str(), server.arg("attribute").c_str(), value); if (error) { sendError(400, error); return; } - if (strcmp(appSettings().page, name) == 0) - appSelectPage(PAGE_CLOCK); - if (strcmp(appSettings().off_page, name) == 0) - appSelectOffPage(OFF_PAGE_BLANK); - sendOk(); + JsonDocument doc; + doc["value"] = value; + sendJson(200, doc); } // --------------------------------------------------------------------------- @@ -296,9 +488,6 @@ static void settingsToJson(const Settings &s, JsonObject o) schedule["night_start"] = String(time); JsonObject clock = o["clock"].to(); - clock["evening_enabled"] = s.clock.evening_enabled; - formatTime(s.clock.evening_start, time); - clock["evening_start"] = String(time); clock["no_wifi_screen"] = s.clock.no_wifi_screen; JsonObject ha = o["home_assistant"].to(); @@ -307,6 +496,8 @@ static void settingsToJson(const Settings &s, JsonObject o) ha["port"] = s.home_assistant.port; ha["user"] = s.home_assistant.user; ha["password_set"] = s.home_assistant.password[0] != '\0'; + ha["api_url"] = s.home_assistant.api_url; + ha["token_set"] = s.home_assistant.token[0] != '\0'; } static void handleSettingsGet() @@ -345,11 +536,9 @@ static void handleSettingsPost() JsonVariantConst schedule = body["schedule"]; copyBool(schedule["enabled"], s.schedule.enabled); copyBool(schedule["quarter_hours"], s.schedule.quarter_hours); - JsonVariantConst clock = body["clock"]; - copyBool(clock["evening_enabled"], s.clock.evening_enabled); - copyBool(clock["no_wifi_screen"], s.clock.no_wifi_screen); + copyBool(body["clock"]["no_wifi_screen"], s.clock.no_wifi_screen); if (!copyTime(schedule["day_start"], s.schedule.day_start) || !copyTime(schedule["late_start"], s.schedule.late_start) || - !copyTime(schedule["night_start"], s.schedule.night_start) || !copyTime(clock["evening_start"], s.clock.evening_start)) + !copyTime(schedule["night_start"], s.schedule.night_start)) { sendError(400, "Times must look like 08:30 (00:00 - 24:00)"); return; @@ -362,6 +551,8 @@ static void handleSettingsPost() s.home_assistant.port = ha["port"].as(); copyText(ha["user"], s.home_assistant.user, sizeof(s.home_assistant.user)); copyText(ha["password"], s.home_assistant.password, sizeof(s.home_assistant.password)); + copyText(ha["api_url"], s.home_assistant.api_url, sizeof(s.home_assistant.api_url)); + copyText(ha["token"], s.home_assistant.token, sizeof(s.home_assistant.token)); bool restart = false; const char *error = appSaveSettings(s, restart); @@ -410,6 +601,14 @@ void webUiBegin() server.on("/api/bitmap", HTTP_GET, handleBitmapGet); server.on("/api/bitmap", HTTP_POST, handleBitmapSave); server.on("/api/bitmap/delete", HTTP_POST, handleBitmapDelete); + server.on("/api/pages", HTTP_GET, handlePages); + server.on("/api/pages/save", HTTP_POST, handlePageSave); + server.on("/api/pages/delete", HTTP_POST, handlePageDelete); + server.on("/api/pages/preview", HTTP_POST, handlePagePreview); + server.on("/api/fonts", HTTP_GET, handleFonts); + server.on("/api/fonts/save", HTTP_POST, handleFontSave); + server.on("/api/fonts/delete", HTTP_POST, handleFontDelete); + server.on("/api/ha/value", HTTP_GET, handleHaValue); server.on("/api/page", HTTP_POST, handlePage); server.on("/api/live", HTTP_POST, handleLive); server.on("/api/live/stop", HTTP_POST, handleLiveStop); diff --git a/src/esp32/web/web_ui.h b/src/esp32/web/web_ui.h index b308549..004143a 100644 --- a/src/esp32/web/web_ui.h +++ b/src/esp32/web/web_ui.h @@ -1,8 +1,8 @@ #pragma once // Web UI of the clock firmware on port 80 (http:///): live view of the display, -// choosing the page, bitmaps (upload, draw, store), live mode, settings and Home -// Assistant. The page itself is in web/web_page.h. +// choosing the page, dynamic pages and fonts, bitmaps (upload, draw, store), live mode, +// settings and Home Assistant. The page itself is in web/web_page.h. // // JSON API (bitmaps are 418 hex digits, see logic/bitmap_format.h): // GET /api/status time, WiFi, firmware, Home Assistant, storage @@ -11,10 +11,18 @@ // GET /api/bitmap?name= one bitmap {name, data} // POST /api/bitmap store {name, data} // POST /api/bitmap/delete {name} -// POST /api/page {page: "clock" or a bitmap name} +// GET /api/pages dynamic pages {pages:[{name, builtin, page, data (preview)}]} +// POST /api/pages/save {name, page} (page format: web/page_json.h) +// POST /api/pages/delete {name} +// POST /api/pages/preview {page, time ("HH:MM", optional)} -> {data, boxes:[[x,y,w,h]]} +// GET /api/fonts {fonts:[{name, builtin, height}]} +// POST /api/fonts/save {name, data (hex, format of logic/font.h)} +// POST /api/fonts/delete {name} +// GET /api/ha/value?entity=&attribute= read a Home Assistant value now {value} +// POST /api/page {page: a dynamic page or bitmap name} // POST /api/live show now {data} // POST /api/live/stop -// GET /api/settings (the MQTT password is never sent, only password_set) +// GET /api/settings (the MQTT password and HA token are never sent, only *_set) // POST /api/settings changed fields; {settings, restart} // POST /api/restart // POST /api/recovery diff --git a/test/test_esp32/test_main.cpp b/test/test_esp32/test_main.cpp index 28ac841..c659a63 100644 --- a/test/test_esp32/test_main.cpp +++ b/test/test_esp32/test_main.cpp @@ -13,8 +13,13 @@ #include "config.h" #include "logic/bitmap_format.h" +#include "logic/builtin_fonts.h" +#include "logic/builtin_pages.h" +#include "logic/font.h" #include "logic/boot_policy.h" #include "logic/display_controller.h" +#include "logic/page.h" +#include "logic/page_render.h" #include "logic/power_timer.h" #include "logic/schedule.h" #include "logic/settings.h" @@ -49,10 +54,10 @@ class FakeFrames : public Frames public: std::vector shown; - void clock(int hour, int minute, bool evening) override + void dynamicPage(const LocalTime &time) override { char s[24]; - snprintf(s, sizeof(s), "clock %02d:%02d%s", hour, minute, evening ? " evening" : ""); + snprintf(s, sizeof(s), "clock %02d:%02d", time.hour, time.minute); shown.push_back(s); } void page() override { shown.push_back("page"); } @@ -94,7 +99,10 @@ static void run(int hour, int minute, uint32_t ms = 0) uint32_t end = now + ms; do { - controller->update(now, true, hour, minute, wifi); + LocalTime time; + time.hour = hour; + time.minute = minute; + controller->update(now, true, time, wifi); now += 100; } while ((int32_t)(end - now) >= 0); } @@ -136,13 +144,6 @@ void test_schedule_night_from_23_31() TEST_ASSERT_TRUE(scheduleFor(23, 59, defaults.schedule) == ScheduleMode::Night); } -void test_evening_from_17() -{ - TEST_ASSERT_FALSE(isEvening(16, 59, defaults.clock)); - TEST_ASSERT_TRUE(isEvening(17, 0, defaults.clock)); - TEST_ASSERT_TRUE(isEvening(23, 0, defaults.clock)); -} - // --------------------------------------------------------------------------- // Power timer // --------------------------------------------------------------------------- @@ -336,7 +337,7 @@ void test_led_blinks_slow_with_wifi_and_fast_without() void test_nothing_happens_until_time_is_synced() { power.is_on = true; // showing "Connecting..." - controller->update(now, false, 0, 0, true); + controller->update(now, false, LocalTime(), true); TEST_ASSERT_EQUAL(0, power.off_count); TEST_ASSERT_EQUAL(0, (int)frames.shown.size()); } @@ -381,7 +382,7 @@ void test_standby_keeps_the_last_time_and_powers_off() run(22, 0); run(22, 1, POWER_OFF_DELAY + 200); TEST_ASSERT_FALSE(power.is_on); - TEST_ASSERT_EQUAL_STRING("clock 22:00 evening", frames.last().c_str()); // no off screen + TEST_ASSERT_EQUAL_STRING("clock 22:00", frames.last().c_str()); // no off screen } void test_quarter_hour_powers_on_again_and_redraws() @@ -391,7 +392,7 @@ void test_quarter_hour_powers_on_again_and_redraws() TEST_ASSERT_FALSE(power.is_on); run(22, 15); TEST_ASSERT_TRUE(power.is_on); - TEST_ASSERT_EQUAL_STRING("clock 22:15 evening", frames.last().c_str()); + TEST_ASSERT_EQUAL_STRING("clock 22:15", frames.last().c_str()); } void test_clock_cancels_pending_power_off() @@ -599,7 +600,7 @@ void test_settings_flip_time_and_port_ranges() TEST_ASSERT_NOT_NULL(validateSettings(s)); } -void test_settings_page_must_be_clock_or_bitmap_name() +void test_settings_page_must_be_a_name() { Settings s; strcpy(s.page, "My bitmap"); @@ -680,7 +681,8 @@ void test_bitmap_names() TEST_ASSERT_FALSE(validBitmapName("trail ")); TEST_ASSERT_FALSE(validBitmapName("../etc")); TEST_ASSERT_FALSE(validBitmapName("a/b")); - TEST_ASSERT_FALSE(validBitmapName("Clock")); // the clock page + TEST_ASSERT_TRUE(sameName("Clock", "clock")); + TEST_ASSERT_FALSE(sameName("Clock", "Clocks")); TEST_ASSERT_TRUE(validBitmapName("123456789012345678901234")); TEST_ASSERT_FALSE(validBitmapName("1234567890123456789012345")); } @@ -718,13 +720,6 @@ void test_schedule_without_quarter_hours_stays_on_until_night() TEST_ASSERT_TRUE(scheduleFor(23, 31, s) == ScheduleMode::Night); } -void test_evening_can_be_disabled() -{ - ClockSettings c; - c.evening_enabled = false; - TEST_ASSERT_FALSE(isEvening(20, 0, c)); -} - // --------------------------------------------------------------------------- // Pages and live mode // --------------------------------------------------------------------------- @@ -751,7 +746,7 @@ void test_switching_back_to_the_clock_page() { controller->setPage(Page::Bitmap); run(12, 0); - controller->setPage(Page::Clock); + controller->setPage(Page::Dynamic); run(12, 0); TEST_ASSERT_EQUAL_STRING("clock 12:00", frames.last().c_str()); } @@ -765,21 +760,22 @@ void test_bitmap_page_follows_the_schedule() TEST_ASSERT_FALSE(power.is_on); } -void test_evening_clock_redrawn_when_the_evening_starts() +void test_dynamic_page_redrawn_when_its_content_changes() { - run(16, 59); - run(17, 0); - TEST_ASSERT_EQUAL_STRING("clock 17:00 evening", frames.last().c_str()); + run(18, 0); + controller->pageChanged(); // e.g. a Home Assistant value changed + run(18, 0); + TEST_ASSERT_EQUAL(2, (int)frames.shown.size()); } void test_settings_change_redraws() { run(18, 0); ClockSettings c; - c.evening_enabled = false; + c.no_wifi_screen = false; controller->setSettings(defaults.schedule, c); run(18, 0); - TEST_ASSERT_EQUAL_STRING("clock 18:00", frames.last().c_str()); + TEST_ASSERT_EQUAL(2, (int)frames.shown.size()); } void test_live_is_shown_right_away_even_at_night() @@ -794,7 +790,7 @@ void test_live_is_shown_right_away_even_at_night() void test_live_works_before_the_time_is_synced() { controller->showLive(now); - controller->update(now, false, 0, 0, true); + controller->update(now, false, LocalTime(), true); TEST_ASSERT_EQUAL_STRING("live", frames.last().c_str()); } @@ -834,6 +830,367 @@ void test_live_respects_the_power_switch() TEST_ASSERT_EQUAL(0, (int)std::count(frames.shown.begin(), frames.shown.end(), std::string("live"))); } +// --------------------------------------------------------------------------- +// Fonts +// --------------------------------------------------------------------------- + +static Font fontNamed(const char *name) +{ + const BuiltinFont *b = findBuiltinFont(name); + TEST_ASSERT_NOT_NULL(b); + return builtinFont(*b); +} + +void test_builtin_fonts_are_valid() +{ + for (int i = 0; i < BUILTIN_FONT_COUNT; i++) + TEST_ASSERT_TRUE_MESSAGE(validFont(BUILTIN_FONTS[i].data, BUILTIN_FONTS[i].size), BUILTIN_FONTS[i].name); + TEST_ASSERT_NOT_NULL(findBuiltinFont("normal")); + TEST_ASSERT_NULL(findBuiltinFont("Comic Sans")); + TEST_ASSERT_EQUAL(2, fontNamed("Large").scale); +} + +void test_font_sizes() +{ + // Monospaced digits: the clock doesn't move when the time changes + TEST_ASSERT_EQUAL(29, textWidth(fontNamed("Normal"), "12:34")); + TEST_ASSERT_EQUAL(29, textWidth(fontNamed("Normal"), "00:00")); + TEST_ASSERT_EQUAL(58, textWidth(fontNamed("Large"), "12:34")); + TEST_ASSERT_EQUAL(17, textWidth(fontNamed("Small"), "12:34")); + TEST_ASSERT_EQUAL(51, textWidth(fontNamed("Full height"), "12:34")); + TEST_ASSERT_EQUAL(8, fontHeight(fontNamed("Normal"))); + TEST_ASSERT_EQUAL(16, fontHeight(fontNamed("Large"))); + TEST_ASSERT_EQUAL(6, fontHeight(fontNamed("Small"))); + TEST_ASSERT_EQUAL(19, fontHeight(fontNamed("Full height"))); + // Characters that are not in the font are skipped + TEST_ASSERT_EQUAL(10, textWidth(fontNamed("Full height"), "a1b")); + TEST_ASSERT_EQUAL(0, textWidth(fontNamed("Normal"), "")); +} + +void test_draw_text_uses_the_classic_glyphs() +{ + uint8_t b[BITMAP_BYTES] = {}; + drawText(b, fontNamed("Normal"), 10, 2, "1", DrawMode::Set); + // glcdfont '1': 0x00, 0x42, 0x7F, 0x40, 0x00 (columns, bit 0 = top) + for (int row = 0; row < 7; row++) + TEST_ASSERT_TRUE(bitmapPixel(b, 12, 2 + row)); + TEST_ASSERT_FALSE(bitmapPixel(b, 12, 9)); + TEST_ASSERT_TRUE(bitmapPixel(b, 11, 3)); + TEST_ASSERT_FALSE(bitmapPixel(b, 11, 2)); + TEST_ASSERT_TRUE(bitmapPixel(b, 13, 8)); + TEST_ASSERT_FALSE(bitmapPixel(b, 10, 5)); +} + +void test_draw_text_scaled_and_inverted() +{ + uint8_t b[BITMAP_BYTES]; + memset(b, 0xFF, sizeof(b)); + drawText(b, fontNamed("Large"), 0, 0, "1", DrawMode::Invert); + TEST_ASSERT_FALSE(bitmapPixel(b, 4, 0)); // column 2 doubled: 4 and 5 + TEST_ASSERT_FALSE(bitmapPixel(b, 5, 13)); + TEST_ASSERT_TRUE(bitmapPixel(b, 4, 14)); + TEST_ASSERT_TRUE(bitmapPixel(b, 0, 0)); +} + +void test_draw_text_is_clipped() +{ + uint8_t b[BITMAP_BYTES] = {}; + drawText(b, fontNamed("Full height"), 80, 5, "88", DrawMode::Set); // must not write outside + drawText(b, fontNamed("Full height"), -5, -10, "88", DrawMode::Set); + TEST_ASSERT_TRUE(bitmapPixel(b, 81, 6)); +} + +void test_invalid_fonts_are_rejected() +{ + const BuiltinFont *n = findBuiltinFont("Normal"); + std::vector f(n->data, n->data + n->size); + TEST_ASSERT_TRUE(validFont(f.data(), f.size())); + TEST_ASSERT_FALSE(validFont(f.data(), f.size() - 1)); + f.push_back(0); + TEST_ASSERT_FALSE(validFont(f.data(), f.size())); + f.pop_back(); + f[0] = 'X'; + TEST_ASSERT_FALSE(validFont(f.data(), f.size())); + f[0] = 'F'; + f[4] = 20; // too high + TEST_ASSERT_FALSE(validFont(f.data(), f.size())); + TEST_ASSERT_FALSE(validFont(nullptr, 0)); +} + +// --------------------------------------------------------------------------- +// Dynamic pages +// --------------------------------------------------------------------------- + +static DynamicPage twoSlotPage() +{ + DynamicPage page; + page.slot_count = 2; + page.slots[0].start = 8 * 60; + strcpy(page.slots[0].bitmap, "Day"); + page.slots[1].start = 17 * 60; + strcpy(page.slots[1].bitmap, "Evening"); + return page; +} + +void test_active_slot_follows_the_time() +{ + DynamicPage page = twoSlotPage(); + TEST_ASSERT_EQUAL_STRING("Evening", activeSlot(page, 7 * 60)->bitmap); // from the day before + TEST_ASSERT_EQUAL_STRING("Day", activeSlot(page, 8 * 60)->bitmap); + TEST_ASSERT_EQUAL_STRING("Day", activeSlot(page, 17 * 60 - 1)->bitmap); + TEST_ASSERT_EQUAL_STRING("Evening", activeSlot(page, 17 * 60)->bitmap); + TEST_ASSERT_NULL(activeSlot(DynamicPage(), 600)); +} + +void test_page_validation() +{ + DynamicPage page = twoSlotPage(); + TEST_ASSERT_NULL(validatePage(page)); + page.slots[1].start = 8 * 60; + TEST_ASSERT_NOT_NULL(validatePage(page)); // not in order + page = twoSlotPage(); + page.slots[1].start = 24 * 60; + TEST_ASSERT_NOT_NULL(validatePage(page)); + page = twoSlotPage(); + strcpy(page.slots[0].bitmap, "../x"); + TEST_ASSERT_NOT_NULL(validatePage(page)); + + page = DynamicPage(); + page.element_count = 1; + PageElement &e = page.elements[0]; + TEST_ASSERT_NULL(validatePage(page)); + e.type = ElementType::HaValue; + TEST_ASSERT_NOT_NULL(validatePage(page)); // needs an entity + strcpy(e.entity, "sensor.outside_temperature"); + TEST_ASSERT_NULL(validatePage(page)); + strcpy(e.attribute, "bad attr"); + TEST_ASSERT_NOT_NULL(validatePage(page)); + e.attribute[0] = '\0'; + strcpy(e.text, "caf\xc3\xa9"); + TEST_ASSERT_NOT_NULL(validatePage(page)); // the fonts have no accents + e.text[0] = '\0'; + e.decimals = 5; + TEST_ASSERT_NOT_NULL(validatePage(page)); + e.decimals = 1; + e.type = ElementType::Date; + e.format = DATE_FORMAT_COUNT; + TEST_ASSERT_NOT_NULL(validatePage(page)); + page.element_count = PAGE_ELEMENTS + 1; + TEST_ASSERT_NOT_NULL(validatePage(page)); + + TEST_ASSERT_TRUE(validEntityId("weather.home")); + TEST_ASSERT_FALSE(validEntityId("Sensor.x")); + TEST_ASSERT_FALSE(validEntityId("sensor")); + TEST_ASSERT_FALSE(validEntityId("a.b.c")); + TEST_ASSERT_FALSE(validEntityId(".x")); +} + +static std::string textOf(const PageElement &e, const char *ha_value = nullptr) +{ + LocalTime t; // Thursday 15 October 2026, 08:05 + t.year = 2026; + t.month = 10; + t.day = 15; + t.weekday = 4; + t.hour = 8; + t.minute = 5; + char out[64]; + elementText(e, t, ha_value, out, sizeof(out)); + return out; +} + +void test_clock_and_date_texts() +{ + PageElement e; + TEST_ASSERT_EQUAL_STRING("08:05", textOf(e).c_str()); + e.format = CLOCK_H_MM; + TEST_ASSERT_EQUAL_STRING("8:05", textOf(e).c_str()); + + e.type = ElementType::Date; + const char *dutch[] = {"do 15 okt", "15 okt", "15 oktober", "15-10", "15-10-2026", "donderdag", "do"}; + const char *english[] = {"Thu 15 Oct", "15 Oct", "15 October", "15-10", "15-10-2026", "Thursday", "Thu"}; + for (uint8_t f = 0; f < DATE_FORMAT_COUNT; f++) + { + e.format = f; + e.language = Language::Dutch; + TEST_ASSERT_EQUAL_STRING(dutch[f], textOf(e).c_str()); + e.language = Language::English; + TEST_ASSERT_EQUAL_STRING(english[f], textOf(e).c_str()); + } + + e = PageElement(); + e.type = ElementType::Text; + strcpy(e.text, "Hallo"); + TEST_ASSERT_EQUAL_STRING("Hallo", textOf(e).c_str()); +} + +void test_home_assistant_value_texts() +{ + PageElement e; + e.type = ElementType::HaValue; + strcpy(e.text, "T "); + strcpy(e.suffix, "C"); + TEST_ASSERT_EQUAL_STRING("T 21.46C", textOf(e, "21.46").c_str()); + TEST_ASSERT_EQUAL_STRING("T --C", textOf(e, nullptr).c_str()); + TEST_ASSERT_EQUAL_STRING("T --C", textOf(e, "unavailable").c_str()); + e.decimals = 1; + TEST_ASSERT_EQUAL_STRING("T 21.5C", textOf(e, "21.46").c_str()); + TEST_ASSERT_EQUAL_STRING("T 0.0C", textOf(e, "-0.04").c_str()); + TEST_ASSERT_EQUAL_STRING("T -1.0C", textOf(e, "-1").c_str()); + TEST_ASSERT_EQUAL_STRING("T onC", textOf(e, "on").c_str()); // not a number: as is + e.decimals = 0; + TEST_ASSERT_EQUAL_STRING("T 21C", textOf(e, "21.46").c_str()); +} + +// Backgrounds: "Full" is all dots on, "Day"/"Evening" have one dot at x 0 / x 1 +class FakeResources : public PageResources +{ +public: + const char *value = "21.5"; + + bool background(const char *name, uint8_t *bitmap) override + { + memset(bitmap, 0, BITMAP_BYTES); + if (strcmp(name, "Full") == 0) + memset(bitmap, 0xFF, BITMAP_BYTES); + else if (strcmp(name, "Day") == 0) + setBitmapPixel(bitmap, 0, 0, true); + else if (strcmp(name, "Evening") == 0) + setBitmapPixel(bitmap, 1, 0, true); + else + { + memset(bitmap, 0xAA, BITMAP_BYTES); // garbage: must be cleared + return false; + } + return true; + } + Font font(const char *name) override + { + const BuiltinFont *b = findBuiltinFont(name); + return b ? builtinFont(*b) : Font(); + } + const char *haValue(const PageElement &) override { return value; } +}; + +static LocalTime at(int hour, int minute) +{ + LocalTime t; + t.hour = hour; + t.minute = minute; + return t; +} + +void test_render_background_follows_the_slots() +{ + FakeResources res; + DynamicPage page = twoSlotPage(); + uint8_t b[BITMAP_BYTES]; + renderPage(page, at(9, 0), res, b); + TEST_ASSERT_TRUE(bitmapPixel(b, 0, 0)); + TEST_ASSERT_FALSE(bitmapPixel(b, 1, 0)); + renderPage(page, at(18, 0), res, b); + TEST_ASSERT_TRUE(bitmapPixel(b, 1, 0)); + strcpy(page.slots[1].bitmap, "Gone"); + renderPage(page, at(18, 0), res, b); + for (int i = 0; i < BITMAP_BYTES; i++) + TEST_ASSERT_EQUAL_HEX8(0, b[i]); // blank +} + +void test_render_overlays() +{ + FakeResources res; + DynamicPage page; + page.slot_count = 1; + strcpy(page.slots[0].bitmap, "Full"); + page.element_count = 1; + PageElement &e = page.elements[0]; + e.type = ElementType::Text; + strcpy(e.text, "1"); + e.x = 10; + e.y = 2; + + uint8_t b[BITMAP_BYTES]; + e.overlay = Overlay::OnTop; // on a full background nothing changes + renderPage(page, at(12, 0), res, b); + TEST_ASSERT_TRUE(bitmapPixel(b, 10, 2)); + TEST_ASSERT_TRUE(bitmapPixel(b, 12, 2)); + + e.overlay = Overlay::Box; // cleared area with one dot around it, then the text + renderPage(page, at(12, 0), res, b); + TEST_ASSERT_FALSE(bitmapPixel(b, 9, 1)); + TEST_ASSERT_FALSE(bitmapPixel(b, 10, 2)); + TEST_ASSERT_TRUE(bitmapPixel(b, 12, 2)); + TEST_ASSERT_FALSE(bitmapPixel(b, 15, 10)); + TEST_ASSERT_TRUE(bitmapPixel(b, 16, 10)); + TEST_ASSERT_TRUE(bitmapPixel(b, 8, 2)); + + e.overlay = Overlay::Invert; + renderPage(page, at(12, 0), res, b); + TEST_ASSERT_TRUE(bitmapPixel(b, 10, 2)); + TEST_ASSERT_FALSE(bitmapPixel(b, 12, 2)); +} + +void test_render_alignment_and_boxes() +{ + FakeResources res; + DynamicPage page; + page.element_count = 2; + page.elements[0].x = 84; + page.elements[0].align = Align::Right; + page.elements[1].type = ElementType::HaValue; + strcpy(page.elements[1].entity, "sensor.t"); + page.elements[1].x = 42; + page.elements[1].y = 11; + page.elements[1].align = Align::Center; + ElementBox boxes[2]; + uint8_t b[BITMAP_BYTES]; + renderPage(page, at(12, 34), res, b, boxes); + TEST_ASSERT_EQUAL(84 - 29, boxes[0].x); + TEST_ASSERT_EQUAL(29, boxes[0].width); + TEST_ASSERT_EQUAL(8, boxes[0].height); + TEST_ASSERT_EQUAL(23, boxes[1].width); // "21.5" + TEST_ASSERT_EQUAL(42 - 11, boxes[1].x); + TEST_ASSERT_EQUAL(11, boxes[1].y); +} + +void test_render_unknown_font_uses_normal() +{ + FakeResources res; + DynamicPage page; + page.element_count = 1; + strcpy(page.elements[0].font, "Gone"); + ElementBox box; + uint8_t b[BITMAP_BYTES]; + renderPage(page, at(12, 34), res, b, &box); + TEST_ASSERT_EQUAL(29, box.width); +} + +void test_builtin_clock_page_is_the_old_clock() +{ + DynamicPage page; + TEST_ASSERT_TRUE(builtinPage("clock", page)); // old settings stored "clock" + TEST_ASSERT_NULL(validatePage(page)); + TEST_ASSERT_EQUAL_STRING("Pikachu", activeSlot(page, 16 * 60 + 59)->bitmap); + TEST_ASSERT_EQUAL_STRING("Pikachu yawning", activeSlot(page, 17 * 60)->bitmap); + TEST_ASSERT_EQUAL_STRING(BUILTIN_PAGE_CLOCK, builtinPageName(0)); + TEST_ASSERT_FALSE(builtinPage("Pikachu", page)); + + // The time where the old firmware printed it: cursor 46, 4 with the classic font + FakeResources res; + TEST_ASSERT_TRUE(builtinPage(BUILTIN_PAGE_CLOCK, page)); + uint8_t b[BITMAP_BYTES]; + renderPage(page, at(12, 34), res, b); + TEST_ASSERT_TRUE(bitmapPixel(b, 48, 4)); // the '1' + TEST_ASSERT_TRUE(bitmapPixel(b, 48, 10)); + TEST_ASSERT_FALSE(bitmapPixel(b, 48, 11)); +} + +void test_default_page_is_the_builtin_clock() +{ + DynamicPage page; + TEST_ASSERT_TRUE(builtinPage(defaults.page, page)); +} + int main() { UNITY_BEGIN(); @@ -842,7 +1199,6 @@ int main() RUN_TEST(test_schedule_clock_from_8_to_22); RUN_TEST(test_schedule_quarter_hours_until_23_30); RUN_TEST(test_schedule_night_from_23_31); - RUN_TEST(test_evening_from_17); RUN_TEST(test_timer_expires_once_after_delay); RUN_TEST(test_timer_keeps_earlier_deadline); @@ -894,7 +1250,7 @@ int main() RUN_TEST(test_settings_off_page_blank_or_bitmap_name); RUN_TEST(test_changed_off_image_is_redrawn_while_the_display_is_on); RUN_TEST(test_settings_flip_time_and_port_ranges); - RUN_TEST(test_settings_page_must_be_clock_or_bitmap_name); + RUN_TEST(test_settings_page_must_be_a_name); RUN_TEST(test_time_parse_and_format); RUN_TEST(test_bitmap_is_84_by_19_in_drawbitmap_format); RUN_TEST(test_bitmap_hex_round_trip); @@ -903,12 +1259,11 @@ int main() RUN_TEST(test_schedule_disabled_is_always_on); RUN_TEST(test_schedule_custom_times); RUN_TEST(test_schedule_without_quarter_hours_stays_on_until_night); - RUN_TEST(test_evening_can_be_disabled); RUN_TEST(test_bitmap_page_is_shown_once); RUN_TEST(test_bitmap_page_redrawn_when_changed); RUN_TEST(test_switching_back_to_the_clock_page); RUN_TEST(test_bitmap_page_follows_the_schedule); - RUN_TEST(test_evening_clock_redrawn_when_the_evening_starts); + RUN_TEST(test_dynamic_page_redrawn_when_its_content_changes); RUN_TEST(test_settings_change_redraws); RUN_TEST(test_live_is_shown_right_away_even_at_night); RUN_TEST(test_live_works_before_the_time_is_synced); @@ -917,5 +1272,22 @@ int main() RUN_TEST(test_live_ends_after_timeout); RUN_TEST(test_live_respects_the_power_switch); + RUN_TEST(test_builtin_fonts_are_valid); + RUN_TEST(test_font_sizes); + RUN_TEST(test_draw_text_uses_the_classic_glyphs); + RUN_TEST(test_draw_text_scaled_and_inverted); + RUN_TEST(test_draw_text_is_clipped); + RUN_TEST(test_invalid_fonts_are_rejected); + RUN_TEST(test_active_slot_follows_the_time); + RUN_TEST(test_page_validation); + RUN_TEST(test_clock_and_date_texts); + RUN_TEST(test_home_assistant_value_texts); + RUN_TEST(test_render_background_follows_the_slots); + RUN_TEST(test_render_overlays); + RUN_TEST(test_render_alignment_and_boxes); + RUN_TEST(test_render_unknown_font_uses_normal); + RUN_TEST(test_builtin_clock_page_is_the_old_clock); + RUN_TEST(test_default_page_is_the_builtin_clock); + return UNITY_END(); } diff --git a/tools/make_builtin_fonts.py b/tools/make_builtin_fonts.py new file mode 100644 index 0000000..a5158cf --- /dev/null +++ b/tools/make_builtin_fonts.py @@ -0,0 +1,204 @@ +"""Generates src/esp32/logic/builtin_fonts.cpp: the built-in fonts of the dynamic pages. + + python tools/make_builtin_fonts.py + +Needs the Adafruit GFX library in .pio/libdeps/esp32 (run `pio run -e esp32` once): + Small TomThumb 3x5 (Fonts/TomThumb.h, BSD license, see that file) + Normal the classic 5x7 font (glcdfont.c), the font the clock always used + Full height digits drawn below (seven segments with rounded corners, 19 dots high) +"Large" is Normal drawn twice as big, so it has no data of its own. + +The font format is described in src/esp32/logic/font.h. Only the Python standard +library is used. +""" + +import os +import re +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +GFX = os.path.join(ROOT, ".pio", "libdeps", "esp32", "Adafruit GFX Library") +OUT = os.path.join(ROOT, "src", "esp32", "logic", "builtin_fonts.cpp") + +FIRST = 32 +LAST = 126 + + +def numbers(text): + return [int(v, 0) for v in re.findall(r"0x[0-9A-Fa-f]+|-?\d+", text)] + + +def array_body(source, name): + match = re.search(name + r"\[\]\s*PROGMEM\s*=\s*\{(.*?)\};", source, re.S) + if not match: + sys.exit("Not found: " + name) + # drop comments, they contain numbers too + return re.sub(r"/\*.*?\*/|//[^\n]*", "", match.group(1), flags=re.S) + + +def encode(name, height, spacing, glyphs): + """glyphs: list of (width, rows) per character FIRST..LAST, rows = list of strings""" + data = bytearray(b"FDF1") + data += bytes([height, spacing, FIRST, len(glyphs)]) + data += bytes(width for width, _ in glyphs) + for width, rows in glyphs: + assert len(rows) == height, name + for row in rows: + for start in range(0, width, 8): + byte = 0 + for bit in range(8): + x = start + bit + if x < width and row[x] == "#": + byte |= 0x80 >> bit + data.append(byte) + return bytes(data) + + +def classic(): + source = open(os.path.join(GFX, "glcdfont.c")).read() + font = numbers(array_body(source, "font")) + glyphs = [] + for c in range(FIRST, LAST + 1): + columns = font[c * 5 : c * 5 + 5] + rows = ["".join("#" if columns[x] >> y & 1 else "." for x in range(5)) for y in range(8)] + glyphs.append((5, rows)) + return encode("Normal", 8, 1, glyphs) + + +def tom_thumb(): + source = open(os.path.join(GFX, "Fonts", "TomThumb.h")).read() + bitmaps = numbers(array_body(source, "TomThumbBitmaps")) + table = numbers(array_body(source, "TomThumbGlyphs")) + glyphs = [] + for i, c in enumerate(range(FIRST, LAST + 1)): + offset, w, h, advance, x_offset, y_offset = table[i * 6 : i * 6 + 6] + width = max(advance - 1, 1) + rows = [["."] * width for _ in range(6)] + bit = 0 + for y in range(h): + for x in range(w): + byte = bitmaps[offset + bit // 8] + if byte & (0x80 >> (bit % 8)): + rows[5 + y_offset + y][x_offset + x] = "#" + bit += 1 + if c == ord(" "): + rows = [["."] * 2 for _ in range(6)] + width = 2 + glyphs.append((width, ["".join(r) for r in rows])) + # Digits all as wide as the widest one (right aligned), so a clock doesn't move + digits = range(ord("0") - FIRST, ord("9") - FIRST + 1) + widest = max(glyphs[i][0] for i in digits) + for i in digits: + width, rows = glyphs[i] + glyphs[i] = (widest, ["." * (widest - width) + r for r in rows]) + return encode("Small", 6, 1, glyphs) + + +# Full height digits: seven segments, 10 x 19 dots +SEGMENTS = { + "a": (0, 0, 10, 2), # x, y, width, height + "b": (7, 0, 3, 11), + "c": (7, 8, 3, 11), + "d": (0, 17, 10, 2), + "e": (0, 8, 3, 11), + "f": (0, 0, 3, 11), + "g": (0, 8, 10, 3), +} +DIGITS = ["abcdef", "bc", "abdeg", "abcdg", "bcfg", "acdfg", "acdefg", "abc", "abcdefg", "abcdfg"] + + +def rounded(rows): + """Rounds the outer corners: clears a corner dot of the glyph box or of the middle bar + when it has no neighbour above or below and none left or right""" + height, width = len(rows), len(rows[0]) + on = lambda x, y: 0 <= x < width and 0 <= y < height and rows[y][x] == "#" + out = [list(r) for r in rows] + for y in (0, 8, 10, height - 1): + for x in (0, width - 1): + if on(x, y) and not (on(x - 1, y) and on(x + 1, y)) and not (on(x, y - 1) and on(x, y + 1)) and (not on(x - 1, y) or not on(x + 1, y)) and (not on(x, y - 1) or not on(x, y + 1)): + out[y][x] = "." + return ["".join(r) for r in out] + + +def box(width, height, dots): + rows = [["."] * width for _ in range(height)] + for x, y, w, h in dots: + for yy in range(y, y + h): + for xx in range(x, x + w): + rows[yy][xx] = "#" + return ["".join(r) for r in rows] + + +def full_height(): + special = { + " ": box(4, 19, []), + ":": box(3, 19, [(0, 4, 3, 3), (0, 12, 3, 3)]), + ".": box(3, 19, [(0, 16, 3, 3)]), + "-": box(6, 19, [(0, 8, 6, 3)]), + } + glyphs = [] + for c in range(FIRST, LAST + 1): + ch = chr(c) + if ch.isdigit(): + rows = rounded(box(10, 19, [SEGMENTS[s] for s in DIGITS[int(ch)]])) + glyphs.append((10, rows)) + elif ch in special: + glyphs.append((len(special[ch][0]), special[ch])) + else: + glyphs.append((0, [""] * 19)) # not in this font: skipped + return encode("Full height", 19, 2, glyphs) + + +def c_array(name, data): + lines = [] + for i in range(0, len(data), 16): + lines.append("\t" + ", ".join("0x%02x" % b for b in data[i : i + 16]) + ",") + return "static const uint8_t %s[] = {\n%s\n};\n" % (name, "\n".join(lines)) + + +LOOKUP = """ +const BuiltinFont *findBuiltinFont(const char *name) +{ + for (int i = 0; i < BUILTIN_FONT_COUNT; i++) + if (sameName(BUILTIN_FONTS[i].name, name)) + return &BUILTIN_FONTS[i]; + return nullptr; +} + +Font builtinFont(const BuiltinFont &font) +{ + Font f; + f.data = font.data; + f.size = font.size; + f.scale = font.scale; + return f; +} +""" + + +def main(): + fonts = [("FONT_SMALL", tom_thumb()), ("FONT_NORMAL", classic()), ("FONT_FULL", full_height())] + out = [ + "// Generated by tools/make_builtin_fonts.py, do not edit.\n", + "// Small: TomThumb (BSD license, Brian J. Swetland, Vassilii Khachaturov, Dan Marks,\n", + "// Robey Pointer); Normal: Adafruit GFX glcdfont (BSD license, Adafruit Industries).\n\n", + '#include "logic/builtin_fonts.h"\n\n', + '#include "logic/bitmap_format.h"\n\n', + ] + for name, data in fonts: + out.append(c_array(name, data) + "\n") + out.append("const BuiltinFont BUILTIN_FONTS[] = {\n") + out.append('\t{"Small", FONT_SMALL, sizeof(FONT_SMALL), 1},\n') + out.append('\t{"Normal", FONT_NORMAL, sizeof(FONT_NORMAL), 1},\n') + out.append('\t{"Large", FONT_NORMAL, sizeof(FONT_NORMAL), 2},\n') + out.append('\t{"Full height", FONT_FULL, sizeof(FONT_FULL), 1},\n') + out.append("};\n") + out.append("const int BUILTIN_FONT_COUNT = sizeof(BUILTIN_FONTS) / sizeof(BUILTIN_FONTS[0]);\n") + out.append(LOOKUP) + with open(OUT, "w", newline="\n") as f: + f.write("".join(out)) + print("Wrote", OUT) + + +if __name__ == "__main__": + main()