diff --git a/Pictures/pages/bad.png b/Pictures/pages/bad.png index 9466cf9..894f26b 100644 Binary files a/Pictures/pages/bad.png and b/Pictures/pages/bad.png differ diff --git a/docs/web-ui.md b/docs/web-ui.md index 7351454..5ca77e5 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -37,7 +37,7 @@ its Reset button brings back the original. See the [pictures of the pages](pages | Countdown | To a date and time: smart (`12 dagen`, `5 uur`, `34 min`, `0:45`), days, `12d 04:05`, with seconds; a text when it has passed | | Stopwatch | `4:05`, `0:04`, `0:04:05`; started in the [Stopwatch](#stopwatch) tab or Home Assistant | | Image | An icon (or a bitmap) at a position, fixed or chosen by a Home Assistant state (`weer ` + `cloudy` = the icon `weer cloudy`, `weer default` when there is none) | -| Progress bar | Filled from the left by a Home Assistant number: value x multiplier / "full at" (a percentage: full at 100). Width, height, with or without a frame | +| Progress bar | Filled from the left by a Home Assistant number: value x multiplier / "full at" (a percentage: full at 100). Width, height, with or without a frame. Optionally the percentage on top (e.g. 42%, rounded down, in the chosen font): its dots invert the bar, so it stays readable on the filled and the empty part; left out when it doesn't fit inside (Small needs a height of 7 with a frame) | Every element has a position, alignment, font and overlay (on top, box that clears the dots behind it, inverted). Entity ids can contain `{plex_user}`, replaced by the Plex user diff --git a/examples/home-pages.json b/examples/home-pages.json index 0efd07d..b57795a 100644 --- a/examples/home-pages.json +++ b/examples/home-pages.json @@ -1,7 +1,7 @@ { "format": "flipdot-backup", "version": 1, - "firmware": "2.13.0", + "firmware": "2.16.0", "exported": "2026-10-16T00:00:00Z", "bitmaps": [], "icons": [ @@ -298,6 +298,7 @@ "bitmap": "bg printer" } ], + "effect": -1, "elements": [ { "type": "ha", @@ -398,6 +399,7 @@ "bitmap": "bg printer" } ], + "effect": -1, "elements": [ { "type": "ha", @@ -527,6 +529,7 @@ "bitmap": "bg kerst" } ], + "effect": -1, "elements": [ { "type": "text", @@ -598,6 +601,7 @@ "bitmap": "bg bad" } ], + "effect": 0, "elements": [ { "type": "clock", @@ -634,8 +638,8 @@ "y": 11, "align": "left", "overlay": "top", - "font": "Normal", - "format": 0, + "font": "Small", + "format": 1, "language": "nl", "decimals": -1, "text": "", @@ -669,6 +673,7 @@ "bitmap": "bg binnen" } ], + "effect": -1, "elements": [ { "type": "ha", @@ -831,6 +836,7 @@ "bitmap": "bg nacht" } ], + "effect": -1, "elements": [ { "type": "clock", @@ -902,6 +908,7 @@ "bitmap": "bg datum" } ], + "effect": -1, "elements": [ { "type": "date", @@ -1002,6 +1009,7 @@ "bitmap": "bg energie" } ], + "effect": -1, "elements": [ { "type": "ha", @@ -1106,6 +1114,7 @@ "bitmap": "bg vol" } ], + "effect": -1, "elements": [ { "type": "clock", @@ -1148,6 +1157,7 @@ "bitmap": "plex logo" } ], + "effect": 0, "elements": [ { "type": "ha", @@ -1272,6 +1282,7 @@ "name": "Stopwatch", "page": { "slots": [], + "effect": -1, "elements": [ { "type": "stopwatch", @@ -1314,6 +1325,7 @@ "bitmap": "bg verbruik" } ], + "effect": -1, "elements": [ { "type": "ha", @@ -1438,6 +1450,7 @@ "name": "Verwachting", "page": { "slots": [], + "effect": -1, "elements": [ { "type": "image", @@ -1615,7 +1628,7 @@ }, { "type": "ha", - "x": 47, + "x": 46, "y": 14, "align": "right", "overlay": "top", @@ -1731,7 +1744,7 @@ }, { "type": "ha", - "x": 71, + "x": 70, "y": 14, "align": "right", "overlay": "top", @@ -1794,6 +1807,7 @@ "name": "Weer", "page": { "slots": [], + "effect": -1, "elements": [ { "type": "clock", diff --git a/src/esp32/config.h b/src/esp32/config.h index 31c8330..3a710e5 100644 --- a/src/esp32/config.h +++ b/src/esp32/config.h @@ -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.15.1" +#define FIRMWARE_VERSION "2.16.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}; diff --git a/src/esp32/logic/page.cpp b/src/esp32/logic/page.cpp index d718ab8..232a4c4 100644 --- a/src/esp32/logic/page.cpp +++ b/src/esp32/logic/page.cpp @@ -150,6 +150,8 @@ static uint8_t formatCount(const PageElement &e) return COUNTDOWN_FORMAT_COUNT; case ElementType::Stopwatch: return STOPWATCH_FORMAT_COUNT; + case ElementType::Bar: + return BAR_FORMAT_COUNT; default: return 255; // not used } @@ -788,6 +790,20 @@ int barFill(const PageElement &e, const char *ha_value, int fill_width) return n; } +void barPercent(const PageElement &e, const char *ha_value, char *out, size_t size) +{ + double v; + if (!ha_value || !parseNumber(ha_value, v)) + { + snprintf(out, size, "--"); + return; + } + double percent = v * e.factor / e.bar_max * 100; + // Rounded down, so 100% only shows when the bar is full; + 1e-9 keeps 0.29 x 100 at 29 + int n = !(percent > 0) ? 0 : percent >= 100 ? 100 : (int)(percent + 1e-9); + snprintf(out, size, "%d%%", n); +} + bool elementShowsSeconds(const PageElement &e, const LocalTime &t, bool stopwatch_running) { switch (e.type) diff --git a/src/esp32/logic/page.h b/src/esp32/logic/page.h index 0ca6fc1..e854e6a 100644 --- a/src/esp32/logic/page.h +++ b/src/esp32/logic/page.h @@ -98,6 +98,11 @@ const uint8_t STOPWATCH_H_MM = 1; // 0:04 (hours and minutes) const uint8_t STOPWATCH_H_MM_SS = 2; // 0:04:05 const uint8_t STOPWATCH_FORMAT_COUNT = 3; +// Progress bar formats +const uint8_t BAR_PLAIN = 0; // only the bar +const uint8_t BAR_PERCENT = 1; // with the percentage on top (inverting the bar below it) +const uint8_t BAR_FORMAT_COUNT = 2; + // How a Home Assistant value is shown enum class HaShow : uint8_t { @@ -152,7 +157,7 @@ struct PageElement Align align = Align::Left; Overlay overlay = Overlay::OnTop; char font[PAGE_NAME_SIZE] = "Normal"; - uint8_t format = 0; // CLOCK_*, DATE_*, COUNTDOWN_*, STOPWATCH_*, HaValue: DURATION_* or TIMESTAMP_* + uint8_t format = 0; // CLOCK_*, DATE_*, COUNTDOWN_*, STOPWATCH_*, BAR_*, HaValue: DURATION_* or TIMESTAMP_* Language language = Language::Dutch; int8_t decimals = DECIMALS_AS_IS; // Text: the text; HaValue, Countdown, Stopwatch: shown before the value; Image: the @@ -222,6 +227,8 @@ void elementText(const PageElement &element, const LocalTime &time, const Elemen // Bar: how many dots of fill_width are filled for this Home Assistant value (value x // factor / bar_max, 0 to fill_width); 0 when the value is unknown or not a number int barFill(const PageElement &element, const char *ha_value, int fill_width); +// Bar with BAR_PERCENT: the text on it, e.g. "42%" (rounded down, 0-100), "--" when unknown +void barPercent(const PageElement &element, const char *ha_value, char *out, size_t out_size); // Whether the element's text shows seconds now (it then changes every second) bool elementShowsSeconds(const PageElement &element, const LocalTime &time, bool stopwatch_running); diff --git a/src/esp32/logic/page_render.cpp b/src/esp32/logic/page_render.cpp index 0f518d4..3825f66 100644 --- a/src/esp32/logic/page_render.cpp +++ b/src/esp32/logic/page_render.cpp @@ -90,6 +90,38 @@ static void drawImage(const PageElement &e, PageResources &resources, uint8_t *b } } +// Bar with BAR_PERCENT: the percentage in the middle of the area inside the frame, its +// dots flip what is below them (so it shows on the filled and the empty part). Centred on +// the dots the text really has (fonts have empty rows); left out when it doesn't fit. +static void drawBarPercent(const PageElement &e, PageResources &resources, uint8_t *bitmap, int x, int y, int w, + int h) +{ + char text[8]; + barPercent(e, resources.haValue(e), text, sizeof(text)); + Font font = usableFont(resources, e.font); + uint8_t dots[BITMAP_BYTES]; + memset(dots, 0, BITMAP_BYTES); + drawText(dots, font, 0, 0, text, DrawMode::Set); + int left = BITMAP_WIDTH, right = -1, top = BITMAP_HEIGHT, bottom = -1; + for (int yy = 0; yy < BITMAP_HEIGHT; yy++) + for (int xx = 0; xx < BITMAP_WIDTH; xx++) + if (bitmapPixel(dots, xx, yy)) + { + left = xx < left ? xx : left; + right = xx > right ? xx : right; + top = yy < top ? yy : top; + bottom = yy > bottom ? yy : bottom; + } + int tw = right - left + 1, th = bottom - top + 1; + if (right < 0 || tw > w || th > h) + return; + int ox = x + (w - tw) / 2 - left, oy = y + (h - th) / 2 - top; + for (int yy = top; yy <= bottom; yy++) + for (int xx = left; xx <= right; xx++) + if (bitmapPixel(dots, xx, yy)) + setBitmapPixel(bitmap, ox + xx, oy + yy, !bitmapPixel(bitmap, ox + xx, oy + yy)); +} + // Bar: a frame (optional) filled from the left by the Home Assistant value static void drawBar(const PageElement &e, PageResources &resources, uint8_t *bitmap, ElementBox &box) { @@ -120,6 +152,8 @@ static void drawBar(const PageElement &e, PageResources &resources, uint8_t *bit int bx = left + x, by = e.y + y; setBitmapPixel(bitmap, bx, by, e.overlay == Overlay::Invert ? !bitmapPixel(bitmap, bx, by) : true); } + if (e.format == BAR_PERCENT) + drawBarPercent(e, resources, bitmap, left + inner, e.y + inner, w - 2 * inner, h - 2 * inner); } // A text element; returns the ms until it scrolls on (0: it doesn't scroll) diff --git a/src/esp32/web/web_page.h b/src/esp32/web/web_page.h index 8bb486f..e5271dd 100644 --- a/src/esp32/web/web_page.h +++ b/src/esp32/web/web_page.h @@ -429,6 +429,8 @@ function peElems(){const d=$('pe_elems');d.innerHTML='';d.append(mk('datalist',{ row(field('Full at',inpf(e,'bar_max',{style:'width:6em'})),field('Multiply by',inpf(e,'factor',{style:'width:6em'})), field('Width',inp(e,'bar_width','number',{style:'width:4em',min:1,max:84})),field('Height',inp(e,'bar_height','number',{style:'width:4em',min:1,max:19})), chk(e,'bar_frame','frame (from 3 x 3 dots)')); + const pc=mk('input',{type:'checkbox',checked:e.format==1});pc.onchange=()=>{e.format=pc.checked?1:0;if(pc.checked&&same(e.font,'Normal'))e.font='Small';peElems();pePreview()}; + row(mk('label',{},pc,' percentage on top (inverts the bar below it; only shown when it fits inside, Small needs a height of 7 with frame)')); rows.push(mk('p',{className:'hint',textContent:'Filled from the left: value x multiplier / full at (e.g. a percentage: full at 100; seconds of a duration: use an entity with the percentage). Unknown values show an empty bar.'}))} const fonts=fontList.map(f=>[f.name,f.name+' ('+f.height+')']);if(!fontList.some(f=>same(f.name,e.font)))fonts.push([e.font,e.font+' (gone)']); const pos=[field('X',inp(e,'x','number',{style:'width:5em'})),field('Y',inp(e,'y','number',{style:'width:5em'}))]; @@ -437,7 +439,7 @@ function peElems(){const d=$('pe_elems');d.innerHTML='';d.append(mk('datalist',{ row(...pos,field('Align',sel(e,'align',[['left','left'],['center','center'],['right','right']])),field('Font',sel(e,'font',fonts)),ovl); row(field('Scroll in a window of',inp(e,'scroll_width','number',{style:'width:5em',min:0,max:168})),'dots (0: off), at', inpf(e,'scroll_speed',{style:'width:5em',min:0.05,max:30}),'dots per second (0.25: one every 4 s)')} - else if(e.type=='bar')row(...pos,field('Align',sel(e,'align',[['left','left'],['center','center'],['right','right']])),ovl); + else if(e.type=='bar')row(...pos,field('Align',sel(e,'align',[['left','left'],['center','center'],['right','right']])),...(e.format==1?[field('Font',sel(e,'font',fonts))]:[]),ovl); else row(...pos,ovl); box.append(...rows);d.append(box)})} 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()} diff --git a/test/test_esp32/test_main.cpp b/test/test_esp32/test_main.cpp index 5c72495..004e4dd 100644 --- a/test/test_esp32/test_main.cpp +++ b/test/test_esp32/test_main.cpp @@ -2443,6 +2443,109 @@ void test_bar_render() TEST_ASSERT_TRUE(bitmapPixel(b, 61, 17)); } +void test_bar_percent_text() +{ + PageElement e = barPage().elements[0]; + char t[8]; + barPercent(e, nullptr, t, sizeof(t)); + TEST_ASSERT_EQUAL_STRING("--", t); + barPercent(e, "unavailable", t, sizeof(t)); + TEST_ASSERT_EQUAL_STRING("--", t); + barPercent(e, "-5", t, sizeof(t)); + TEST_ASSERT_EQUAL_STRING("0%", t); + barPercent(e, "42.9", t, sizeof(t)); // rounded down + TEST_ASSERT_EQUAL_STRING("42%", t); + barPercent(e, "29", t, sizeof(t)); + TEST_ASSERT_EQUAL_STRING("29%", t); + barPercent(e, "99.99", t, sizeof(t)); // 100% only when full + TEST_ASSERT_EQUAL_STRING("99%", t); + barPercent(e, "250", t, sizeof(t)); + TEST_ASSERT_EQUAL_STRING("100%", t); + e.bar_max = 7200; + barPercent(e, "1800", t, sizeof(t)); + TEST_ASSERT_EQUAL_STRING("25%", t); +} + +// The dots that differ between two bitmaps +static int countDiff(const uint8_t *a, const uint8_t *b, int &left, int &top, int &right, int &bottom) +{ + int n = 0; + left = top = 99; + right = bottom = -1; + for (int y = 0; y < BITMAP_HEIGHT; y++) + for (int x = 0; x < BITMAP_WIDTH; x++) + if (bitmapPixel(a, x, y) != bitmapPixel(b, x, y)) + { + n++; + left = x < left ? x : left; + right = x > right ? x : right; + top = y < top ? y : top; + bottom = y > bottom ? y : bottom; + } + return n; +} + +void test_bar_percent_render() +{ + FakeResources res; + DynamicPage page = barPage(); + PageElement &e = page.elements[0]; + e.x = 10; + e.y = 5; + e.bar_width = 30; // 28 x 7 inside the frame + e.bar_height = 9; + strcpy(e.font, "Small"); + TEST_ASSERT_NULL(validatePage(page)); + uint8_t plain[BITMAP_BYTES], with[BITMAP_BYTES], text[BITMAP_BYTES]; + res.value = "50"; + renderPage(page, at(12, 0), 0, res, plain); + e.format = BAR_PERCENT; + TEST_ASSERT_NULL(validatePage(page)); + ElementBox box; + renderPage(page, at(12, 0), 0, res, with, &box); + TEST_ASSERT_EQUAL(30, box.width); // the box stays the bar + + // Every dot of the text flips the bar: as many changes as the text has dots, all inside + memset(text, 0, BITMAP_BYTES); + drawText(text, builtinFont(*findBuiltinFont("Small")), 0, 0, "50%", DrawMode::Set); + int left, top, right, bottom; + TEST_ASSERT_EQUAL(countOn(text), countDiff(plain, with, left, top, right, bottom)); + TEST_ASSERT_TRUE(left >= 11 && right <= 38 && top >= 6 && bottom <= 12); + // Centred: 5 dots high in 7 rows, one row free above and below + TEST_ASSERT_EQUAL(7, top); + TEST_ASSERT_EQUAL(11, bottom); + int free_left = left - 11, free_right = 38 - right; + TEST_ASSERT_TRUE(free_left - free_right <= 1 && free_right - free_left <= 1); + // Columns 11-24 are filled: the text is dark there and lit over the empty part + int dark = 0, lit = 0; + for (int y = top; y <= bottom; y++) + for (int x = left; x <= right; x++) + if (bitmapPixel(plain, x, y) != bitmapPixel(with, x, y)) + { + TEST_ASSERT_EQUAL(x > 24, bitmapPixel(with, x, y)); + (x > 24 ? lit : dark)++; + } + TEST_ASSERT_TRUE(dark > 0 && lit > 0); + + // Unknown: "--" on the empty bar + res.value = nullptr; + renderPage(page, at(12, 0), 0, res, with); + TEST_ASSERT_TRUE(countOn(with) > 2 * 30 + 2 * 7); + + // Doesn't fit (Small digits are 5 dots high, 4 rows inside): only the bar + res.value = "50"; + e.bar_height = 6; + e.format = BAR_PLAIN; + renderPage(page, at(12, 0), 0, res, plain); + e.format = BAR_PERCENT; + renderPage(page, at(12, 0), 0, res, with); + TEST_ASSERT_EQUAL(0, countDiff(plain, with, left, top, right, bottom)); + + // Format 2 doesn't exist + e.format = BAR_FORMAT_COUNT; + TEST_ASSERT_NOT_NULL(validatePage(page)); +} + void test_date_days_ahead() { LocalTime t; // Thursday 31 December 2026 @@ -2703,6 +2806,8 @@ int main() RUN_TEST(test_frame_check_periodic_resync); RUN_TEST(test_bar_fill); RUN_TEST(test_bar_render); + RUN_TEST(test_bar_percent_text); + RUN_TEST(test_bar_percent_render); RUN_TEST(test_bar_validation); RUN_TEST(test_date_days_ahead); RUN_TEST(test_attribute_paths_and_16_elements);