diff --git a/Pictures/web-ui/bitmaps.png b/Pictures/web-ui/bitmaps.png index 08b9934..327f15e 100644 Binary files a/Pictures/web-ui/bitmaps.png and b/Pictures/web-ui/bitmaps.png differ diff --git a/Pictures/web-ui/draw.png b/Pictures/web-ui/draw.png index 4608b7c..022909a 100644 Binary files a/Pictures/web-ui/draw.png and b/Pictures/web-ui/draw.png differ diff --git a/Pictures/web-ui/home-assistant.png b/Pictures/web-ui/home-assistant.png index 1694bcb..3af3444 100644 Binary files a/Pictures/web-ui/home-assistant.png and b/Pictures/web-ui/home-assistant.png differ diff --git a/Pictures/web-ui/pages.png b/Pictures/web-ui/pages.png index 315e870..108b77a 100644 Binary files a/Pictures/web-ui/pages.png and b/Pictures/web-ui/pages.png differ diff --git a/Pictures/web-ui/schedule.png b/Pictures/web-ui/schedule.png index b5950d6..ccd4264 100644 Binary files a/Pictures/web-ui/schedule.png and b/Pictures/web-ui/schedule.png differ diff --git a/Pictures/web-ui/settings.png b/Pictures/web-ui/settings.png index c753ab0..e5c8421 100644 Binary files a/Pictures/web-ui/settings.png and b/Pictures/web-ui/settings.png differ diff --git a/Pictures/web-ui/show.png b/Pictures/web-ui/show.png index 336ba72..24418be 100644 Binary files a/Pictures/web-ui/show.png and b/Pictures/web-ui/show.png differ diff --git a/Pictures/web-ui/stopwatch.png b/Pictures/web-ui/stopwatch.png index 0a095a6..1064d2c 100644 Binary files a/Pictures/web-ui/stopwatch.png and b/Pictures/web-ui/stopwatch.png differ diff --git a/Pictures/web-ui/system.png b/Pictures/web-ui/system.png index 186fd0d..581fcd9 100644 Binary files a/Pictures/web-ui/system.png and b/Pictures/web-ui/system.png differ diff --git a/docs/web-ui.md b/docs/web-ui.md index 11e98d3..60c2a22 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -40,7 +40,7 @@ arrow keys. Built-in pages can be copied, not changed. See the [pictures of the 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 -of the Home Assistant tab (e.g. `sensor.{plex_user}_state`). Texts can scroll in a window at a chosen speed and can contain +of the Home Assistant tab (e.g. `sensor.{plex_user}_state`). Texts can scroll in a window at a chosen speed (0.05-30 dots per second, e.g. 0.25 = one dot every 4 s) and can contain the degree sign. Fonts: Small (3x5), Normal (5x7), Large (5x7 doubled), Full height (19 dot digits), and your own: upload a TTF/OTF, choose the height in dots, the browser converts it. diff --git a/src/esp32/display/page_store.cpp b/src/esp32/display/page_store.cpp index c361af8..8ebf5b8 100644 --- a/src/esp32/display/page_store.cpp +++ b/src/esp32/display/page_store.cpp @@ -8,7 +8,7 @@ static const char *DIR = "/pages"; static const char *EXTENSION = ".pg"; // Increase when DynamicPage changes; older files are ignored unless converted below -static const uint16_t VERSION = 4; +static const uint16_t VERSION = 5; struct StoredPage { @@ -149,8 +149,89 @@ static void convert(const Page &old, DynamicPage &page) } } // namespace v2 -// Version 3 (before the progress bar): the elements lacked the fields from `bar_width` on. -// The rest has the same layout, so it is copied as a block. +// Version 4 (before scroll speeds below 1): scroll_speed was a whole number (uint8_t). +// Frozen copy, converted when loaded. Version 3 is converted through it. +namespace v4 +{ +struct Element +{ + ElementType type; + int16_t x, y; + Align align; + Overlay overlay; + char font[PAGE_NAME_SIZE]; + uint8_t format; + Language language; + int8_t decimals; + char text[ELEMENT_TEXT_SIZE]; + char suffix[ELEMENT_SUFFIX_SIZE]; + char entity[ELEMENT_ENTITY_SIZE]; + char attribute[ELEMENT_ATTRIBUTE_SIZE]; + float factor; + uint8_t scroll_width; + uint8_t scroll_speed; + uint32_t target; + HaShow show; + uint8_t input_unit; + bool auto_unit; + bool absolute; + char map[ELEMENT_MAP_SIZE]; + uint8_t bar_width = 40; + uint8_t bar_height = 3; + float bar_max = 100; + bool bar_frame = true; +}; + +struct Page +{ + uint8_t slot_count; + PageSlot slots[PAGE_SLOTS]; + uint8_t element_count; + Element elements[PAGE_ELEMENTS]; +}; + +struct Stored +{ + uint16_t version; + uint16_t size; + Page page; +}; + +// Up to scroll_speed the layout is the same, so that part is copied as a block +static const size_t BEFORE_SPEED = offsetof(Element, scroll_speed); +static_assert(offsetof(PageElement, scroll_speed) >= BEFORE_SPEED && + offsetof(PageElement, scroll_width) == offsetof(Element, scroll_width), + "same layout up to scroll_width"); + +static void convert(const Page &old, DynamicPage &page) +{ + page = DynamicPage(); + page.slot_count = old.slot_count; + memcpy(page.slots, old.slots, sizeof(page.slots)); + page.element_count = old.element_count; + for (int i = 0; i < PAGE_ELEMENTS; i++) + { + const Element &o = old.elements[i]; + PageElement &e = page.elements[i]; + memcpy((void *)&e, &o, BEFORE_SPEED); + e.scroll_speed = o.scroll_speed; + e.target = o.target; + e.show = o.show; + e.input_unit = o.input_unit; + e.auto_unit = o.auto_unit; + e.absolute = o.absolute; + memcpy(e.map, o.map, sizeof(e.map)); + e.bar_width = o.bar_width; + e.bar_height = o.bar_height; + e.bar_max = o.bar_max; + e.bar_frame = o.bar_frame; + } +} +} // namespace v4 + +// Version 3 (before the progress bar): the version 4 elements without the fields from +// `bar_width` on. The rest has the same layout, so it is copied as a block, then +// converted like version 4. namespace v3 { struct Element @@ -194,21 +275,23 @@ struct Stored }; static const size_t COMMON = offsetof(Element, map) + sizeof(Element::map); -static_assert(offsetof(PageElement, map) == offsetof(Element, map), "same layout up to map"); +static_assert(offsetof(v4::Element, map) == offsetof(Element, map), "same layout up to map"); static void convert(const Page &old, DynamicPage &page) { - page = DynamicPage(); - page.slot_count = old.slot_count; - memcpy(page.slots, old.slots, sizeof(page.slots)); - page.element_count = old.element_count; + v4::Page *mid = new v4::Page; // bar fields at their defaults + mid->slot_count = old.slot_count; + memcpy(mid->slots, old.slots, sizeof(mid->slots)); + mid->element_count = old.element_count; for (int i = 0; i < PAGE_ELEMENTS; i++) - memcpy((void *)&page.elements[i], &old.elements[i], COMMON); // the bar fields keep their defaults + memcpy((void *)&mid->elements[i], &old.elements[i], COMMON); + v4::convert(*mid, page); + delete mid; } } // namespace v3 static_assert(sizeof(v1::Stored) != sizeof(v2::Stored) && sizeof(v2::Stored) != sizeof(v3::Stored) && - sizeof(v3::Stored) != sizeof(StoredPage), + sizeof(v3::Stored) != sizeof(v4::Stored) && sizeof(v4::Stored) != sizeof(StoredPage), "the versions are told apart by their size"); static uint32_t revision = 0; @@ -269,6 +352,18 @@ bool loadPage(const char *name, DynamicPage &page) page = stored->page; delete stored; } + else if (size == sizeof(v4::Stored)) + { + v4::Stored *stored = new v4::Stored; + ok = file.read((uint8_t *)stored, sizeof(v4::Stored)) == sizeof(v4::Stored) && stored->version == 4 && + stored->size == sizeof(v4::Page); + if (ok) + { + v4::convert(stored->page, page); + ok = validatePage(page) == nullptr; + } + delete stored; + } else if (size == sizeof(v3::Stored)) { v3::Stored *stored = new v3::Stored; diff --git a/src/esp32/logic/page.cpp b/src/esp32/logic/page.cpp index 74c7ae1..f6762f4 100644 --- a/src/esp32/logic/page.cpp +++ b/src/esp32/logic/page.cpp @@ -173,8 +173,8 @@ const char *validatePage(const DynamicPage &page) if (!isfinite(e.factor) || e.factor == 0) return "The multiplier must be a number other than 0"; if (e.scroll_width > 2 * BITMAP_WIDTH || - (e.scroll_width && (e.scroll_speed < SCROLL_SPEED_MIN || e.scroll_speed > SCROLL_SPEED_MAX))) - return "Scrolling: window up to 168 dots, speed 1-30 dots per second"; + (e.scroll_width && !(e.scroll_speed >= SCROLL_SPEED_MIN && e.scroll_speed <= SCROLL_SPEED_MAX))) + return "Scrolling: window up to 168 dots, speed 0.05-30 dots per second"; if (e.type == ElementType::HaValue && !validEntityId(e.entity)) return "Home Assistant values need an entity id like sensor.outside_temperature"; if (e.type == ElementType::Bar) diff --git a/src/esp32/logic/page.h b/src/esp32/logic/page.h index ccfb81a..4821c60 100644 --- a/src/esp32/logic/page.h +++ b/src/esp32/logic/page.h @@ -127,8 +127,8 @@ const int8_t DECIMALS_AS_IS = -1; const int8_t DECIMALS_MAX = 4; // Scrolling: dots per second, and the empty dots between the end and the next start -const uint8_t SCROLL_SPEED_MIN = 1; -const uint8_t SCROLL_SPEED_MAX = 30; +const float SCROLL_SPEED_MIN = 0.05f; // one dot every 20 s +const float SCROLL_SPEED_MAX = 30; const int SCROLL_GAP = 12; // Background from `start` (minutes since midnight) until the next slot @@ -158,7 +158,7 @@ struct PageElement char attribute[ELEMENT_ATTRIBUTE_SIZE] = ""; // empty = the state float factor = 1; // HaValue: numbers are multiplied by it (e.g. 1000: m3 -> l) uint8_t scroll_width = 0; // texts: 0, or the width of a window the text scrolls in - uint8_t scroll_speed = 8; // dots per second + float scroll_speed = 8; // dots per second, may be below 1 (0.25: a dot every 4 s) uint32_t target = 0; // Countdown: minutes since 2000-01-01 00:00 (local time) // Added in page format 3 (keep new fields at the end, see display/page_store.cpp) HaShow show = HaShow::Number; // HaValue: how the value is shown diff --git a/src/esp32/logic/page_render.cpp b/src/esp32/logic/page_render.cpp index b0dbfaa..0f518d4 100644 --- a/src/esp32/logic/page_render.cpp +++ b/src/esp32/logic/page_render.cpp @@ -1,5 +1,6 @@ #include "logic/page_render.h" +#include #include #include @@ -157,13 +158,15 @@ static uint32_t drawTextElement(const PageElement &e, const char *text, uint32_t // Scrolls from right to left, the next round follows after SCROLL_GAP dots uint32_t period = width + SCROLL_GAP; - uint64_t steps = (uint64_t)now_ms * e.scroll_speed / 1000; + // Step n starts at n * 1000 / speed ms (the speed can be below 1 dot per second) + double ms_per_step = 1000.0 / e.scroll_speed; + uint64_t steps = (uint64_t)(now_ms / ms_per_step); int offset = (int)(steps % period); drawText(bitmap, font, left - offset, e.y, text, mode, left, left + area); drawText(bitmap, font, left - offset + (int)period, e.y, text, mode, left, left + area); // Until the next step - uint64_t next_step_ms = ((steps + 1) * 1000 + e.scroll_speed - 1) / e.scroll_speed; - uint32_t wait = (uint32_t)(next_step_ms - now_ms); + uint64_t next_step_ms = (uint64_t)ceil((steps + 1) * ms_per_step); + uint32_t wait = next_step_ms > now_ms ? (uint32_t)(next_step_ms - now_ms) : 0; return wait ? wait : 1; } diff --git a/src/esp32/web/page_json.cpp b/src/esp32/web/page_json.cpp index 6301d22..b7b603f 100644 --- a/src/esp32/web/page_json.cpp +++ b/src/esp32/web/page_json.cpp @@ -124,7 +124,7 @@ const char *pageFromJson(JsonVariantConst in, DynamicPage &page) e.decimals = constrain(o["decimals"] | (int)DECIMALS_AS_IS, -100, 100); e.factor = o["factor"] | 1.0f; e.scroll_width = constrain(o["scroll_width"] | 0, 0, 255); - e.scroll_speed = constrain(o["scroll_speed"] | 8, 0, 255); + e.scroll_speed = o["scroll_speed"] | 8.0f; // checked in validatePage() int show = indexOf(SHOWS, o["show"] | "number"), unit = indexOf(UNITS, o["input_unit"] | "s"); if (show < 0 || unit < 0) return "Unknown Home Assistant value setting"; diff --git a/src/esp32/web/web_page.h b/src/esp32/web/web_page.h index 7af7c5e..92a1c2b 100644 --- a/src/esp32/web/web_page.h +++ b/src/esp32/web/web_page.h @@ -378,7 +378,7 @@ function peElems(){const d=$('pe_elems');d.innerHTML='';d.append(mk('datalist',{ if(TEXTTYPES.includes(e.type)){ 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', - inp(e,'scroll_speed','number',{style:'width:4em',min:1,max:30}),'dots per second')} + 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 row(...pos,ovl); box.append(...rows);d.append(box)})} diff --git a/test/test_esp32/test_main.cpp b/test/test_esp32/test_main.cpp index db59303..c3a5e83 100644 --- a/test/test_esp32/test_main.cpp +++ b/test/test_esp32/test_main.cpp @@ -1401,6 +1401,34 @@ static bool anyDot(const uint8_t *b, int x0, int x1) return false; } +void test_slow_scrolling_below_one_dot_per_second() +{ + FakeResources res; + DynamicPage page = scrollPage(); + page.elements[0].scroll_speed = 0.25f; // a dot every 4 s + TEST_ASSERT_NULL(validatePage(page)); + uint8_t a[BITMAP_BYTES], b[BITMAP_BYTES]; + TEST_ASSERT_EQUAL(4000, renderPage(page, at(12, 0), 0, res, a)); + TEST_ASSERT_EQUAL(1, renderPage(page, at(12, 0), 3999, res, b)); + TEST_ASSERT_EQUAL(0, memcmp(a, b, BITMAP_BYTES)); // not moved yet + TEST_ASSERT_EQUAL(4000, renderPage(page, at(12, 0), 4000, res, b)); + TEST_ASSERT_TRUE(memcmp(a, b, BITMAP_BYTES) != 0); // one dot on + TEST_ASSERT_EQUAL(1500, renderPage(page, at(12, 0), 6500, res, b)); + + page.elements[0].scroll_speed = 0.3f; // 3333.3 ms per dot: no drift over a long time + TEST_ASSERT_EQUAL(3334, renderPage(page, at(12, 0), 0, res, a)); + uint32_t t = 3000000000UL; // ~35 days of millis() + uint32_t wait = renderPage(page, at(12, 0), t, res, a); + TEST_ASSERT_TRUE(wait >= 1 && wait <= 3334); + + page.elements[0].scroll_speed = 0.01f; // too slow + TEST_ASSERT_NOT_NULL(validatePage(page)); + page.elements[0].scroll_speed = NAN; + TEST_ASSERT_NOT_NULL(validatePage(page)); + page.elements[0].scroll_speed = 31; + TEST_ASSERT_NOT_NULL(validatePage(page)); +} + void test_scrolling_text_moves_in_its_window() { FakeResources res; @@ -2297,6 +2325,7 @@ int main() RUN_TEST(test_bar_render); RUN_TEST(test_bar_validation); RUN_TEST(test_plex_user_placeholder); + RUN_TEST(test_slow_scrolling_below_one_dot_per_second); return UNITY_END(); }