Scroll speeds below 1 dot per second
scroll_speed is now a float (0.05-30 dots per second, e.g. 0.25 = one dot every 4 s). Page format 5; version 4 pages are converted, version 3 goes through version 4. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No files matched your search
|
Before Width: | Height: | Size: 179 KiB After Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 405 KiB After Width: | Height: | Size: 418 KiB |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 182 KiB After Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 350 KiB After Width: | Height: | Size: 365 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 167 KiB |
@@ -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
|
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
|
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
|
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
|
digits), and your own: upload a TTF/OTF, choose the height in dots, the browser converts
|
||||||
it.
|
it.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
static const char *DIR = "/pages";
|
static const char *DIR = "/pages";
|
||||||
static const char *EXTENSION = ".pg";
|
static const char *EXTENSION = ".pg";
|
||||||
// Increase when DynamicPage changes; older files are ignored unless converted below
|
// 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
|
struct StoredPage
|
||||||
{
|
{
|
||||||
@@ -149,8 +149,89 @@ static void convert(const Page &old, DynamicPage &page)
|
|||||||
}
|
}
|
||||||
} // namespace v2
|
} // namespace v2
|
||||||
|
|
||||||
// Version 3 (before the progress bar): the elements lacked the fields from `bar_width` on.
|
// Version 4 (before scroll speeds below 1): scroll_speed was a whole number (uint8_t).
|
||||||
// The rest has the same layout, so it is copied as a block.
|
// 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
|
namespace v3
|
||||||
{
|
{
|
||||||
struct Element
|
struct Element
|
||||||
@@ -194,21 +275,23 @@ struct Stored
|
|||||||
};
|
};
|
||||||
|
|
||||||
static const size_t COMMON = offsetof(Element, map) + sizeof(Element::map);
|
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)
|
static void convert(const Page &old, DynamicPage &page)
|
||||||
{
|
{
|
||||||
page = DynamicPage();
|
v4::Page *mid = new v4::Page; // bar fields at their defaults
|
||||||
page.slot_count = old.slot_count;
|
mid->slot_count = old.slot_count;
|
||||||
memcpy(page.slots, old.slots, sizeof(page.slots));
|
memcpy(mid->slots, old.slots, sizeof(mid->slots));
|
||||||
page.element_count = old.element_count;
|
mid->element_count = old.element_count;
|
||||||
for (int i = 0; i < PAGE_ELEMENTS; i++)
|
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
|
} // namespace v3
|
||||||
|
|
||||||
static_assert(sizeof(v1::Stored) != sizeof(v2::Stored) && sizeof(v2::Stored) != sizeof(v3::Stored) &&
|
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");
|
"the versions are told apart by their size");
|
||||||
|
|
||||||
static uint32_t revision = 0;
|
static uint32_t revision = 0;
|
||||||
@@ -269,6 +352,18 @@ bool loadPage(const char *name, DynamicPage &page)
|
|||||||
page = stored->page;
|
page = stored->page;
|
||||||
delete stored;
|
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))
|
else if (size == sizeof(v3::Stored))
|
||||||
{
|
{
|
||||||
v3::Stored *stored = new v3::Stored;
|
v3::Stored *stored = new v3::Stored;
|
||||||
|
|||||||
@@ -173,8 +173,8 @@ const char *validatePage(const DynamicPage &page)
|
|||||||
if (!isfinite(e.factor) || e.factor == 0)
|
if (!isfinite(e.factor) || e.factor == 0)
|
||||||
return "The multiplier must be a number other than 0";
|
return "The multiplier must be a number other than 0";
|
||||||
if (e.scroll_width > 2 * BITMAP_WIDTH ||
|
if (e.scroll_width > 2 * BITMAP_WIDTH ||
|
||||||
(e.scroll_width && (e.scroll_speed < SCROLL_SPEED_MIN || e.scroll_speed > SCROLL_SPEED_MAX)))
|
(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";
|
return "Scrolling: window up to 168 dots, speed 0.05-30 dots per second";
|
||||||
if (e.type == ElementType::HaValue && !validEntityId(e.entity))
|
if (e.type == ElementType::HaValue && !validEntityId(e.entity))
|
||||||
return "Home Assistant values need an entity id like sensor.outside_temperature";
|
return "Home Assistant values need an entity id like sensor.outside_temperature";
|
||||||
if (e.type == ElementType::Bar)
|
if (e.type == ElementType::Bar)
|
||||||
|
|||||||
@@ -127,8 +127,8 @@ const int8_t DECIMALS_AS_IS = -1;
|
|||||||
const int8_t DECIMALS_MAX = 4;
|
const int8_t DECIMALS_MAX = 4;
|
||||||
|
|
||||||
// Scrolling: dots per second, and the empty dots between the end and the next start
|
// Scrolling: dots per second, and the empty dots between the end and the next start
|
||||||
const uint8_t SCROLL_SPEED_MIN = 1;
|
const float SCROLL_SPEED_MIN = 0.05f; // one dot every 20 s
|
||||||
const uint8_t SCROLL_SPEED_MAX = 30;
|
const float SCROLL_SPEED_MAX = 30;
|
||||||
const int SCROLL_GAP = 12;
|
const int SCROLL_GAP = 12;
|
||||||
|
|
||||||
// Background from `start` (minutes since midnight) until the next slot
|
// Background from `start` (minutes since midnight) until the next slot
|
||||||
@@ -158,7 +158,7 @@ struct PageElement
|
|||||||
char attribute[ELEMENT_ATTRIBUTE_SIZE] = ""; // empty = the state
|
char attribute[ELEMENT_ATTRIBUTE_SIZE] = ""; // empty = the state
|
||||||
float factor = 1; // HaValue: numbers are multiplied by it (e.g. 1000: m3 -> l)
|
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_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)
|
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)
|
// 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
|
HaShow show = HaShow::Number; // HaValue: how the value is shown
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#include "logic/page_render.h"
|
#include "logic/page_render.h"
|
||||||
|
|
||||||
|
#include <math.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
|
||||||
@@ -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
|
// Scrolls from right to left, the next round follows after SCROLL_GAP dots
|
||||||
uint32_t period = width + SCROLL_GAP;
|
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);
|
int offset = (int)(steps % period);
|
||||||
drawText(bitmap, font, left - offset, e.y, text, mode, left, left + area);
|
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);
|
drawText(bitmap, font, left - offset + (int)period, e.y, text, mode, left, left + area);
|
||||||
// Until the next step
|
// Until the next step
|
||||||
uint64_t next_step_ms = ((steps + 1) * 1000 + e.scroll_speed - 1) / e.scroll_speed;
|
uint64_t next_step_ms = (uint64_t)ceil((steps + 1) * ms_per_step);
|
||||||
uint32_t wait = (uint32_t)(next_step_ms - now_ms);
|
uint32_t wait = next_step_ms > now_ms ? (uint32_t)(next_step_ms - now_ms) : 0;
|
||||||
return wait ? wait : 1;
|
return wait ? wait : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ const char *pageFromJson(JsonVariantConst in, DynamicPage &page)
|
|||||||
e.decimals = constrain(o["decimals"] | (int)DECIMALS_AS_IS, -100, 100);
|
e.decimals = constrain(o["decimals"] | (int)DECIMALS_AS_IS, -100, 100);
|
||||||
e.factor = o["factor"] | 1.0f;
|
e.factor = o["factor"] | 1.0f;
|
||||||
e.scroll_width = constrain(o["scroll_width"] | 0, 0, 255);
|
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");
|
int show = indexOf(SHOWS, o["show"] | "number"), unit = indexOf(UNITS, o["input_unit"] | "s");
|
||||||
if (show < 0 || unit < 0)
|
if (show < 0 || unit < 0)
|
||||||
return "Unknown Home Assistant value setting";
|
return "Unknown Home Assistant value setting";
|
||||||
|
|||||||
@@ -378,7 +378,7 @@ function peElems(){const d=$('pe_elems');d.innerHTML='';d.append(mk('datalist',{
|
|||||||
if(TEXTTYPES.includes(e.type)){
|
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(...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',
|
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 if(e.type=='bar')row(...pos,field('Align',sel(e,'align',[['left','left'],['center','center'],['right','right']])),ovl);
|
||||||
else row(...pos,ovl);
|
else row(...pos,ovl);
|
||||||
box.append(...rows);d.append(box)})}
|
box.append(...rows);d.append(box)})}
|
||||||
|
|||||||
@@ -1401,6 +1401,34 @@ static bool anyDot(const uint8_t *b, int x0, int x1)
|
|||||||
return false;
|
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()
|
void test_scrolling_text_moves_in_its_window()
|
||||||
{
|
{
|
||||||
FakeResources res;
|
FakeResources res;
|
||||||
@@ -2297,6 +2325,7 @@ int main()
|
|||||||
RUN_TEST(test_bar_render);
|
RUN_TEST(test_bar_render);
|
||||||
RUN_TEST(test_bar_validation);
|
RUN_TEST(test_bar_validation);
|
||||||
RUN_TEST(test_plex_user_placeholder);
|
RUN_TEST(test_plex_user_placeholder);
|
||||||
|
RUN_TEST(test_slow_scrolling_below_one_dot_per_second);
|
||||||
|
|
||||||
return UNITY_END();
|
return UNITY_END();
|
||||||
}
|
}
|
||||||