Progress bars can show the percentage on top

The percentage (rounded down, in the element's font) is centred inside the
bar and its dots invert the bar, so it reads on the filled and the empty
part. It uses the bar's unused format and font fields, so the page format
stays the same. The Bad page shows it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-18 16:27:56 +02:00
1 parent 9959c1aa74
commit 948087077d
9 files changed
+187 -9

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

+1 -1
View File
@@ -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
+19 -5
View File
@@ -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",
+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.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};
+16
View File
@@ -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)
+8 -1
View File
@@ -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);
+34
View File
@@ -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)
+3 -1
View File
@@ -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()}
+105
View File
@@ -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);