Icons: small images of their own size for pages

Bitmaps that only pages use (backgrounds, images by state) are now icons:
own width and height, stored in /icons, not in the page lists or the Home
Assistant dropdowns. Page backgrounds and image elements use icons or
bitmaps; the renderer gets icons expanded at the top left, so pages look
the same. Web UI: Icons tab with an editor, Make icon / Make bitmap, Save
as icon on the Draw tab, icons in the backup.

A one-time migration in time-boxed steps moves the uploaded bitmaps that
only pages use, after the new firmware is marked good.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-16 22:03:18 +02:00
1 parent 1704cbf17b
commit ab001e5d18
37 files changed
+955 -78

No files matched your search

+106
View File
@@ -18,6 +18,7 @@
#include "logic/builtin_fonts.h"
#include "logic/builtin_pages.h"
#include "logic/font.h"
#include "logic/icon_format.h"
#include "logic/boot_policy.h"
#include "logic/display_controller.h"
#include "logic/page.h"
@@ -1536,6 +1537,108 @@ void test_image_element_by_state()
TEST_ASSERT_NOT_NULL(validatePage(page));
}
void test_icon_pixels_and_hex()
{
Icon icon;
icon.width = 11; // 2 bytes per row
icon.height = 3;
TEST_ASSERT_EQUAL(2, iconRowBytes(11));
TEST_ASSERT_EQUAL(6, iconBytes(11, 3));
setIconPixel(icon, 0, 0, true);
setIconPixel(icon, 10, 2, true);
setIconPixel(icon, 11, 0, true); // outside
TEST_ASSERT_TRUE(iconPixel(icon, 0, 0));
TEST_ASSERT_TRUE(iconPixel(icon, 10, 2));
TEST_ASSERT_FALSE(iconPixel(icon, 11, 0));
TEST_ASSERT_FALSE(iconPixel(icon, 1, 0));
char hex[BITMAP_HEX_LENGTH + 1];
iconToHex(icon, hex);
TEST_ASSERT_EQUAL_STRING("800000000020", hex);
Icon back;
TEST_ASSERT_TRUE(iconFromHex(11, 3, hex, back));
TEST_ASSERT_EQUAL(11, back.width);
TEST_ASSERT_EQUAL(3, back.height);
TEST_ASSERT_TRUE(iconPixel(back, 10, 2));
TEST_ASSERT_FALSE(iconPixel(back, 9, 2));
// Bits after the last dot of a row are cleared
TEST_ASSERT_TRUE(iconFromHex(11, 1, "ffff", back));
TEST_ASSERT_EQUAL_HEX8(0xE0, back.data[1]);
TEST_ASSERT_FALSE(iconFromHex(11, 3, "8000", back)); // too short
TEST_ASSERT_FALSE(iconFromHex(11, 3, "80000000002g", back)); // not hex
TEST_ASSERT_FALSE(iconFromHex(0, 3, "", back));
TEST_ASSERT_FALSE(iconFromHex(85, 1, "0000000000000000000000", back));
TEST_ASSERT_FALSE(iconFromHex(1, 20, "0000000000000000000000000000000000000000", back));
TEST_ASSERT_FALSE(iconFromHex(1, 1, nullptr, back));
TEST_ASSERT_TRUE(iconFromHex(84, 19, std::string(BITMAP_HEX_LENGTH, 'f').c_str(), back));
TEST_ASSERT_TRUE(iconPixel(back, 83, 18));
TEST_ASSERT_EQUAL(84, back.width);
TEST_ASSERT_TRUE(validIconSize(1, 1));
TEST_ASSERT_FALSE(validIconSize(84, 0));
}
void test_icon_from_bitmap_keeps_its_dots_in_place()
{
// Like "bg dag": a sun at x 9-19, y 0-10, nothing on the left
uint8_t bitmap[BITMAP_BYTES] = {};
setBitmapPixel(bitmap, 9, 5, true);
setBitmapPixel(bitmap, 19, 0, true);
setBitmapPixel(bitmap, 14, 10, true);
Icon icon;
iconFromBitmap(bitmap, icon);
TEST_ASSERT_EQUAL(20, icon.width);
TEST_ASSERT_EQUAL(11, icon.height);
uint8_t back[BITMAP_BYTES];
memset(back, 0xAA, sizeof(back));
iconToBitmap(icon, back);
TEST_ASSERT_EQUAL_MEMORY(bitmap, back, BITMAP_BYTES);
// Full screen
memset(bitmap, 0, sizeof(bitmap));
setBitmapPixel(bitmap, 83, 18, true);
iconFromBitmap(bitmap, icon);
TEST_ASSERT_EQUAL(84, icon.width);
TEST_ASSERT_EQUAL(19, icon.height);
iconToBitmap(icon, back);
TEST_ASSERT_EQUAL_MEMORY(bitmap, back, BITMAP_BYTES);
// No dots: a blank dot
memset(bitmap, 0, sizeof(bitmap));
iconFromBitmap(bitmap, icon);
TEST_ASSERT_EQUAL(1, icon.width);
TEST_ASSERT_EQUAL(1, icon.height);
TEST_ASSERT_FALSE(iconPixel(icon, 0, 0));
}
void test_page_uses_image()
{
DynamicPage page;
page.slot_count = 1;
strcpy(page.slots[0].bitmap, "bg printer");
page.element_count = 3;
page.elements[0].type = ElementType::Image;
strcpy(page.elements[0].text, "plex logo");
page.elements[1].type = ElementType::Image;
strcpy(page.elements[1].text, "weer ");
strcpy(page.elements[1].entity, "weather.home");
page.elements[2].type = ElementType::Text; // texts are no images
strcpy(page.elements[2].text, "hello");
TEST_ASSERT_TRUE(pageUsesImage(page, "BG Printer"));
TEST_ASSERT_TRUE(pageUsesImage(page, "plex logo"));
TEST_ASSERT_FALSE(pageUsesImage(page, "hello"));
TEST_ASSERT_FALSE(pageUsesImage(page, "plex"));
// By state: only the default, unless all states are asked for
TEST_ASSERT_TRUE(pageUsesImage(page, "weer default"));
TEST_ASSERT_FALSE(pageUsesImage(page, "weer sunny"));
TEST_ASSERT_TRUE(pageUsesImage(page, "Weer sunny", true));
TEST_ASSERT_FALSE(pageUsesImage(page, "weer", true));
TEST_ASSERT_FALSE(pageUsesImage(page, "weerbericht", false));
TEST_ASSERT_FALSE(pageUsesImage(page, "sunny", true));
}
void test_moving_page_is_redrawn_when_due()
{
frames.wait = 250;
@@ -2300,6 +2403,9 @@ int main()
RUN_TEST(test_short_text_in_a_window_does_not_scroll);
RUN_TEST(test_render_asks_for_the_next_second);
RUN_TEST(test_image_element_by_state);
RUN_TEST(test_icon_pixels_and_hex);
RUN_TEST(test_icon_from_bitmap_keeps_its_dots_in_place);
RUN_TEST(test_page_uses_image);
RUN_TEST(test_moving_page_is_redrawn_when_due);
RUN_TEST(test_schedule_validation);
RUN_TEST(test_rule_comparisons);