Add scrolling, countdown, stopwatch, images, seconds, multiplier and degree sign

- Text elements can scroll in a window at a set speed; clock, countdown and
  stopwatch can show seconds. Moving pages are sent as instant frames (new
  Nano command D1) and paced by the estimated flip time.
- Countdown to a date and time (smart, days, days + time, with seconds),
  stopwatch (web UI, HA switch and reset button), image element (fixed or
  by Home Assistant state), multiplier for HA values, degree sign (char 127)
  in all fonts and the font converter.
- Page format version 2; version 1 pages are converted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-15 21:01:14 +02:00
1 parent ca8d78ea8a
commit fc6fa1a180
38 files changed
+1357 -264

No files matched your search

+6 -1
View File
@@ -88,7 +88,8 @@ See README.md for the overview.
- Frames are only sent when the screen changes. The schedule uses 08:00 as the start of - Frames are only sent when the screen changes. The schedule uses 08:00 as the start of
the day on purpose (that is what the old code did, and the README documents it). the day on purpose (that is what the old code did, and the README documents it).
- Home Assistant switches are not optimistic: callbacks must call `sender->setState()`. - Home Assistant switches are not optimistic: callbacks must call `sender->setState()`.
The unique ids `FlipDotDisplayClock` / `FlipDotDisplayPower` and `HA_DEVICE_ID` must stay The unique ids (`FlipDotDisplayPower`, `FlipDotDisplayMode`, `FlipDotDisplayOffImage`,
`FlipDotDisplayStopwatch`, ...) and `HA_DEVICE_ID` must stay
the same, or Home Assistant creates new entities. the same, or Home Assistant creates new entities.
## Keeping ESP32 OTA alive (most important requirement) ## Keeping ESP32 OTA alive (most important requirement)
@@ -144,6 +145,10 @@ See README.md for the overview.
previews, so there is one renderer. Pages and bitmaps share one namespace (checked in previews, so there is one renderer. Pages and bitmaps share one namespace (checked in
`app.cpp`); things a page uses can't be deleted. The built-in page "Clock" replaces the `app.cpp`); things a page uses can't be deleted. The built-in page "Clock" replaces the
old hardcoded clock (also the default and the HA "Clock" option). old hardcoded clock (also the default and the HA "Clock" option).
- Moving pages: `renderPage()` returns when the page changes next (scroll step, second);
`FlipdotFrames` sends those frames as `D1` (no effect) and paces them by the estimated
flip time, because the Nano has no acknowledgement. Character 127 is the degree sign in
page texts and fonts (UTF-8 ° in JSON).
- Fonts use the "FDF1" format of `logic/font.h`; the web page writes the same format. - Fonts use the "FDF1" format of `logic/font.h`; the web page writes the same format.
`logic/builtin_fonts.cpp` is generated: change `tools/make_builtin_fonts.py` and rerun it. `logic/builtin_fonts.cpp` is generated: change `tools/make_builtin_fonts.py` and rerun it.
- Home Assistant values come from HA's REST API (`net/ha_api`), one blocking request per - Home Assistant values come from HA's REST API (`net/ha_api`), one blocking request per
+5 -1
View File
@@ -95,7 +95,7 @@ view of the display: it shows what the dots show right now, updated every 1.5 s.
| Tab | What you can do | | Tab | What you can do |
|-----|-----------------| |-----|-----------------|
| **Show** | Choose what the display shows during the day: a dynamic page (e.g. the built-in Clock: Pikachu with the time) or any bitmap. The schedule still decides when the display is on. | | **Show** | Choose what the display shows during the day: a dynamic page (e.g. the built-in Clock: Pikachu with the time) or any bitmap. The schedule still decides when the display is on. |
| **Pages** | Dynamic pages: a background bitmap per time of day (e.g. Pikachu from 00:00, yawning Pikachu from 17:00) with up to 8 elements on top: clock (`08:05` / `8:05`), date (7 formats, Dutch or English), Home Assistant value (entity or attribute, text before and after, decimals) and text. Each element has a position (drag it on the preview), alignment, font and overlay (on top, box that clears the dots behind it, inverted). The preview is drawn by the ESP32, so it is exactly what the display shows. Built-in pages can be copied, not changed. 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. | | **Pages** | Dynamic pages: a background bitmap per time of day (e.g. Pikachu from 00:00, yawning Pikachu from 17:00) with up to 8 elements on top: clock (`08:05`, `8:05`, optionally with seconds), date (7 formats, Dutch or English), Home Assistant value (entity or attribute, text before and after, multiplier, decimals), text, 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 (start, stop and reset in the Show tab, the editor and Home Assistant) and image (a bitmap at a position, fixed or chosen by a Home Assistant state, e.g. `weather ` + `cloudy`). Texts can scroll in a window at a chosen speed, and can contain the degree sign. Each element has a position (drag it on the preview), alignment, font and overlay (on top, box that clears the dots behind it, inverted). The preview is drawn by the ESP32, so it is exactly what the display shows. Built-in pages can be copied, not changed. 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. |
| **Bitmaps** | All bitmaps with a preview: show one as the page, send it live, open it in the editor, delete it. The built-in ones (Nintendo, Pikachu, Pikachu yawning) can't be deleted, nor can a bitmap that a page uses as background. | | **Bitmaps** | All bitmaps with a preview: show one as the page, send it live, open it in the editor, delete it. The built-in ones (Nintendo, Pikachu, Pikachu yawning) can't be deleted, nor can a bitmap that a page uses as background. |
| **Draw / Live** | Draw on an 84 x 19 canvas (click or drag), or load an image: it is converted in the browser (threshold, invert, dithering, fit / fill / stretch). "Show on display now" sends it to the display right away (*live mode*); with "live while drawing" every change appears on the dots. Save it as a bitmap with a name. | | **Draw / Live** | Draw on an 84 x 19 canvas (click or drag), or load an image: it is converted in the browser (threshold, invert, dithering, fit / fill / stretch). "Show on display now" sends it to the display right away (*live mode*); with "live while drawing" every change appears on the dots. Save it as a bitmap with a name. |
| **Settings** | Schedule on/off, when the display is on, the late-evening quarter-hour mode, when it switches off; the "No WiFi" screen; the flip time, the pause after each pulse, pulses per dot and the refresh effect (instant, sparkle, wipe, rain, from the middle out, diagonal, spiral, flip all then reveal, push, random; optionally only for small changes like the clock's minutes); test patterns; the status LED. | | **Settings** | Schedule on/off, when the display is on, the late-evening quarter-hour mode, when it switches off; the "No WiFi" screen; the flip time, the pause after each pulse, pulses per dot and the refresh effect (instant, sparkle, wipe, rain, from the middle out, diagonal, spiral, flip all then reveal, push, random; optionally only for small changes like the clock's minutes); test patterns; the status LED. |
@@ -111,6 +111,8 @@ a thousand bitmaps, 50 pages, 20 fonts). Pages and bitmaps share their names. Th
the new flash layout from the [one-time installation](#one-time-installation-usb); the new flash layout from the [one-time installation](#one-time-installation-usb);
before that, only the built-in ones are available. before that, only the built-in ones are available.
**Moving pages** (scrolling text, seconds, a running stopwatch) send a frame whenever the content moves, without the refresh effect (`D1`, needs the Nano firmware from version 2.5.0), and not faster than the Nano can flip the dots: with 3 pulses and a 300 us gap a 1-dot scroll step of a line of text takes about 0.1-0.2 s.
**Home Assistant values** on the shown page are read every minute (one entity per loop, **Home Assistant values** on the shown page are read every minute (one entity per loop,
at most about 3 s). An unknown or unreachable value shows `--`. The display only changes at most about 3 s). An unknown or unreachable value shows `--`. The display only changes
when the shown text changes, so round values with the decimals setting. when the shown text changes, so round values with the decimals setting.
@@ -308,6 +310,8 @@ The ESP32 talks to the Nano on **UART2** (`Serial2`, 115200 baud). The ESP32's o
- `FlipDot Display Off image` dropdown: what is left on the dots when the display - `FlipDot Display Off image` dropdown: what is left on the dots when the display
switches off (at night or with the Power switch): blank or one of the bitmaps. switches off (at night or with the Power switch): blank or one of the bitmaps.
- `FlipDot Display IP` sensor: the ESP32's IP address. - `FlipDot Display IP` sensor: the ESP32's IP address.
- `FlipDot Display Stopwatch` switch and `FlipDot Display Stopwatch reset` button: the
stopwatch of the dynamic pages.
4. **Nano.** The Nano reads commands from the serial line and updates its frame buffer. 4. **Nano.** The Nano reads commands from the serial line and updates its frame buffer.
On `D` it flips only the dots that changed. Every 2 minutes it redraws the whole screen On `D` it flips only the dots that changed. Every 2 minutes it redraws the whole screen
to fix dots that did not flip. to fix dots that did not flip.
+3 -3
View File
@@ -65,10 +65,10 @@ void BROSE9323::begin(void)
} }
#endif #endif
void BROSE9323::display(bool force) void BROSE9323::display(bool force, bool instant)
{ {
#ifdef ESPSERIAL #ifdef ESPSERIAL
stream->print("D\n"); stream->print(instant ? "D1\n" : "D\n");
stream->flush(); stream->flush();
// Shadow copy of what the dots show now (for the web UI) // Shadow copy of what the dots show now (for the web UI)
memcpy(_old_buffer, _new_buffer, _buffer_size); memcpy(_old_buffer, _new_buffer, _buffer_size);
@@ -79,7 +79,7 @@ void BROSE9323::display(bool force)
_force_next = false; _force_next = false;
_drawing = false; _drawing = false;
// An explicit full redraw is always instant (the periodic refresh) // An explicit full redraw is always instant (the periodic refresh)
if (_effect == EFFECT_INSTANT || force) if (_effect == EFFECT_INSTANT || force || instant)
{ {
_animating = false; _animating = false;
_drawAll(all); _drawAll(all);
+3 -1
View File
@@ -105,7 +105,9 @@ public:
void begin(Stream * = &Serial); void begin(Stream * = &Serial);
#endif #endif
void begin(void); void begin(void);
void display(bool force = false); // Shows the buffer: the changed dots, all with force. instant: without the refresh
// effect (frames of moving content, e.g. scrolling text). ESP32: sends D or D1.
void display(bool force = false, bool instant = false);
void drawPixel(int16_t x, int16_t y, uint16_t color); void drawPixel(int16_t x, int16_t y, uint16_t color);
void fillScreen(uint16_t); void fillScreen(uint16_t);
void setDirect(bool); void setDirect(bool);
+17
View File
@@ -1,6 +1,7 @@
#include "app.h" #include "app.h"
#include <string.h> #include <string.h>
#include <sys/time.h>
#include <time.h> #include <time.h>
#include "config.h" #include "config.h"
@@ -70,6 +71,7 @@ static void apply()
controller.setSettings(settings.schedule, settings.clock); controller.setSettings(settings.schedule, settings.clock);
controller.setEffectTime(effectTime(settings.drive)); controller.setEffectTime(effectTime(settings.drive));
status.setDrive(settings.flip_time, settings.drive); status.setDrive(settings.flip_time, settings.drive);
frames.setDrive(settings.flip_time, settings.drive);
haApiSetSettings(settings.home_assistant); haApiSetSettings(settings.home_assistant);
applyOffPage(); applyOffPage();
applyPage(); applyPage();
@@ -230,6 +232,17 @@ const char *appDeleteFont(const char *name)
return deleteFont(name); return deleteFont(name);
} }
void appStopwatch(StopwatchAction action)
{
if (action == StopwatchAction::Start)
stopwatch.start(millis());
else if (action == StopwatchAction::Stop)
stopwatch.stop(millis());
else
stopwatch.reset();
controller.pageChanged();
}
void appShowLive(const uint8_t *bitmap) void appShowLive(const uint8_t *bitmap)
{ {
frames.setLiveBitmap(bitmap); frames.setLiveBitmap(bitmap);
@@ -252,6 +265,10 @@ bool appLocalTime(LocalTime &time)
time.weekday = now.tm_wday; time.weekday = now.tm_wday;
time.hour = now.tm_hour; time.hour = now.tm_hour;
time.minute = now.tm_min; time.minute = now.tm_min;
time.second = now.tm_sec;
struct timeval tv;
gettimeofday(&tv, nullptr);
time.millisecond = tv.tv_usec / 1000;
return true; return true;
} }
+9
View File
@@ -40,6 +40,15 @@ const char *appDeletePage(const char *name);
const char *appSaveFont(const char *name, const uint8_t *data, size_t size); const char *appSaveFont(const char *name, const uint8_t *data, size_t size);
const char *appDeleteFont(const char *name); const char *appDeleteFont(const char *name);
// The stopwatch of the dynamic pages (web UI and Home Assistant); redraws the display
enum class StopwatchAction
{
Start,
Stop,
Reset,
};
void appStopwatch(StopwatchAction action);
// Live mode of the web UI // Live mode of the web UI
void appShowLive(const uint8_t *bitmap); void appShowLive(const uint8_t *bitmap);
void appStopLive(); void appStopLive();
+4 -1
View File
@@ -49,6 +49,9 @@ const uint32_t POWER_OFF_DELAY = 30000;
// timeout (a read blocks loop() for at most about twice this) // timeout (a read blocks loop() for at most about twice this)
const uint32_t HA_POLL_INTERVAL = 60000; const uint32_t HA_POLL_INTERVAL = 60000;
const uint32_t HA_HTTP_TIMEOUT = 1500; const uint32_t HA_HTTP_TIMEOUT = 1500;
// Moving dynamic pages (scrolling, seconds): sending a frame to the Nano takes about this
// long (a bitmap at 115200 baud), plus the flip time of the changed dots
const uint32_t FRAME_SEND_TIME = 60;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Flashing the Nano over WiFi (see README.md, pio run -e nano_ota -t upload) // Flashing the Nano over WiFi (see README.md, pio run -e nano_ota -t upload)
@@ -99,7 +102,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_CONNECT_TIMEOUT = 30000;
const uint32_t WIFI_RESTART_TIME = 10UL * 60 * 1000; const uint32_t WIFI_RESTART_TIME = 10UL * 60 * 1000;
#define FIRMWARE_VERSION "2.4.0" #define FIRMWARE_VERSION "2.5.0"
// Home Assistant device id (do not change: Home Assistant's entities are linked to it) // 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}; const uint8_t HA_DEVICE_ID[] = {0x34, 0x86, 0x5d, 0x3a, 0x08, 0x28};
+2 -1
View File
@@ -110,7 +110,8 @@ bool loadBitmap(const char *name, uint8_t *bitmap)
memcpy_P(bitmap, b->data, BITMAP_BYTES); memcpy_P(bitmap, b->data, BITMAP_BYTES);
return true; return true;
} }
if (!mounted || !validBitmapName(name)) // Checked first: open() logs an error for a missing file (images try names by state)
if (!mounted || !validBitmapName(name) || !LittleFS.exists(path(name)))
return false; return false;
File file = LittleFS.open(path(name), "r"); File file = LittleFS.open(path(name), "r");
if (!file) if (!file)
+29 -5
View File
@@ -3,17 +3,41 @@
#include <stdio.h> #include <stdio.h>
#include <string.h> #include <string.h>
#include "config.h"
#include "display/text.h" #include "display/text.h"
FlipdotFrames::FlipdotFrames(BROSE9323 &display, PageResources &resources) : _display(display), _resources(resources) FlipdotFrames::FlipdotFrames(BROSE9323 &display, PageResources &resources) : _display(display), _resources(resources)
{ {
} }
void FlipdotFrames::dynamicPage(const LocalTime &time) uint32_t FlipdotFrames::dynamicPage(const LocalTime &time, uint32_t now, bool redraw)
{ {
uint8_t bitmap[BITMAP_BYTES]; uint8_t bitmap[BITMAP_BYTES];
renderPage(_dynamic, time, _resources, bitmap); uint32_t wait = renderPage(_dynamic, time, now, _resources, bitmap);
_drawFullScreen(bitmap); bool moving = wait != 0;
if (!redraw && memcmp(bitmap, _last_dynamic, BITMAP_BYTES) == 0)
return wait;
// The Nano has no acknowledgement: wait until it has flipped the dots of this frame,
// or the next frame overflows its serial buffer
uint32_t busy = _flipMs(bitmap, redraw);
_drawFullScreen(bitmap, moving);
memcpy(_last_dynamic, bitmap, BITMAP_BYTES);
return moving && wait < busy ? busy : wait;
}
void FlipdotFrames::setDrive(uint16_t flip_time, const DriveSettings &drive)
{
_dot_us = (uint32_t)drive.pulses * (flip_time + drive.flip_gap);
}
// About how long the Nano takes to receive and show the bitmap (all: every dot flips)
uint32_t FlipdotFrames::_flipMs(const uint8_t *bitmap, bool all) const
{
uint32_t dots = 0;
for (int y = 0; y < BITMAP_HEIGHT; y++)
for (int x = 0; x < BITMAP_WIDTH; x++)
dots += all || bitmapPixel(bitmap, x, y) != _display.shownPixel(x, y);
return FRAME_SEND_TIME + dots * _dot_us / 1000;
} }
void FlipdotFrames::noWifi(int hour, int minute) void FlipdotFrames::noWifi(int hour, int minute)
@@ -62,9 +86,9 @@ void FlipdotFrames::setOffBitmap(const uint8_t *bitmap)
memset(_off, 0, BITMAP_BYTES); memset(_off, 0, BITMAP_BYTES);
} }
void FlipdotFrames::_drawFullScreen(const uint8_t *bitmap) void FlipdotFrames::_drawFullScreen(const uint8_t *bitmap, bool instant)
{ {
_display.fillScreen(0); _display.fillScreen(0);
_display.drawBitmap(0, 0, bitmap, BITMAP_WIDTH, BITMAP_HEIGHT, 1); _display.drawBitmap(0, 0, bitmap, BITMAP_WIDTH, BITMAP_HEIGHT, 1);
_display.display(); _display.display(false, instant);
} }
+11 -3
View File
@@ -4,6 +4,7 @@
#include "logic/bitmap_format.h" #include "logic/bitmap_format.h"
#include "logic/display_controller.h" #include "logic/display_controller.h"
#include "logic/page_render.h" #include "logic/page_render.h"
#include "logic/settings.h"
// Draws the screens on the flipdot display. Each method sends one complete frame. // Draws the screens on the flipdot display. Each method sends one complete frame.
class FlipdotFrames : public Frames class FlipdotFrames : public Frames
@@ -11,8 +12,10 @@ class FlipdotFrames : public Frames
public: public:
FlipdotFrames(BROSE9323 &display, PageResources &resources); FlipdotFrames(BROSE9323 &display, PageResources &resources);
// The page set with setDynamicPage(), drawn for this time // The page set with setDynamicPage(), drawn for this time. Not sent when it looks the
void dynamicPage(const LocalTime &time) override; // same as the last frame (unless redraw). Moving pages are sent without the refresh
// effect, and not faster than the Nano can flip the dots.
uint32_t dynamicPage(const LocalTime &time, uint32_t now, bool redraw) override;
// "No WiFi" with the time below it // "No WiFi" with the time below it
void noWifi(int hour, int minute) override; void noWifi(int hour, int minute) override;
// The bitmap set with setPageBitmap() // The bitmap set with setPageBitmap()
@@ -23,6 +26,8 @@ public:
void off() override; void off() override;
void setDynamicPage(const DynamicPage &page); void setDynamicPage(const DynamicPage &page);
// How the Nano flips the dots: for the time a frame takes
void setDrive(uint16_t flip_time, const DriveSettings &drive);
void setPageBitmap(const uint8_t *bitmap); void setPageBitmap(const uint8_t *bitmap);
void setLiveBitmap(const uint8_t *bitmap); void setLiveBitmap(const uint8_t *bitmap);
// nullptr: blank // nullptr: blank
@@ -32,9 +37,12 @@ private:
BROSE9323 &_display; BROSE9323 &_display;
PageResources &_resources; PageResources &_resources;
DynamicPage _dynamic; DynamicPage _dynamic;
uint8_t _last_dynamic[BITMAP_BYTES] = {};
uint32_t _dot_us = 1000; // per changed dot: pulses x (flip time + gap)
uint8_t _page[BITMAP_BYTES] = {}; uint8_t _page[BITMAP_BYTES] = {};
uint8_t _live[BITMAP_BYTES] = {}; uint8_t _live[BITMAP_BYTES] = {};
uint8_t _off[BITMAP_BYTES] = {}; // all zero: blank uint8_t _off[BITMAP_BYTES] = {}; // all zero: blank
void _drawFullScreen(const uint8_t *bitmap); void _drawFullScreen(const uint8_t *bitmap, bool instant = false);
uint32_t _flipMs(const uint8_t *bitmap, bool all) const;
}; };
+14 -1
View File
@@ -1,10 +1,13 @@
#include "display/page_resources.h" #include "display/page_resources.h"
#include <Arduino.h>
#include "display/bitmap_store.h" #include "display/bitmap_store.h"
#include "display/font_store.h" #include "display/font_store.h"
#include "net/ha_api.h" #include "net/ha_api.h"
StoredPageResources pageResources; Stopwatch stopwatch;
StoredPageResources pageResources(stopwatch);
bool StoredPageResources::background(const char *name, uint8_t *bitmap) bool StoredPageResources::background(const char *name, uint8_t *bitmap)
{ {
@@ -20,3 +23,13 @@ const char *StoredPageResources::haValue(const PageElement &element)
{ {
return haApiValue(element.entity, element.attribute); return haApiValue(element.entity, element.attribute);
} }
uint32_t StoredPageResources::stopwatchMs()
{
return _stopwatch.elapsed(millis());
}
bool StoredPageResources::stopwatchRunning()
{
return _stopwatch.running();
}
+12 -2
View File
@@ -1,15 +1,25 @@
#pragma once #pragma once
#include "logic/page_render.h" #include "logic/page_render.h"
#include "logic/stopwatch.h"
// Bitmaps from display/bitmap_store, fonts from display/font_store and Home Assistant // Bitmaps from display/bitmap_store, fonts from display/font_store, Home Assistant values
// values from net/ha_api, for drawing dynamic pages on the display and in the web UI // from net/ha_api and the stopwatch, for drawing dynamic pages on the display and in the
// web UI
class StoredPageResources : public PageResources class StoredPageResources : public PageResources
{ {
public: public:
explicit StoredPageResources(Stopwatch &stopwatch) : _stopwatch(stopwatch) {}
bool background(const char *name, uint8_t *bitmap) override; bool background(const char *name, uint8_t *bitmap) override;
Font font(const char *name) override; Font font(const char *name) override;
const char *haValue(const PageElement &element) override; const char *haValue(const PageElement &element) override;
uint32_t stopwatchMs() override;
bool stopwatchRunning() override;
private:
Stopwatch &_stopwatch;
}; };
extern Stopwatch stopwatch;
extern StoredPageResources pageResources; extern StoredPageResources pageResources;
+84 -6
View File
@@ -7,8 +7,8 @@
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 then ignored) // Increase when DynamicPage changes; older files are ignored unless converted below
static const uint16_t VERSION = 1; static const uint16_t VERSION = 2;
struct StoredPage struct StoredPage
{ {
@@ -17,6 +17,68 @@ struct StoredPage
DynamicPage page; DynamicPage page;
}; };
// Version 1 (before scrolling, countdown, stopwatch, images and the multiplier): frozen
// copy of the layout, converted when loaded. Saving writes version 2.
namespace v1
{
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];
};
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;
};
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];
e.type = o.type;
e.x = o.x;
e.y = o.y;
e.align = o.align;
e.overlay = o.overlay;
memcpy(e.font, o.font, sizeof(e.font));
e.format = o.format;
e.language = o.language;
e.decimals = o.decimals;
memcpy(e.text, o.text, sizeof(e.text));
memcpy(e.suffix, o.suffix, sizeof(e.suffix));
memcpy(e.entity, o.entity, sizeof(e.entity));
memcpy(e.attribute, o.attribute, sizeof(e.attribute));
}
}
} // namespace v1
static uint32_t revision = 0; static uint32_t revision = 0;
static String path(const char *name) static String path(const char *name)
@@ -64,14 +126,30 @@ bool loadPage(const char *name, DynamicPage &page)
File file = LittleFS.open(path(name), "r"); File file = LittleFS.open(path(name), "r");
if (!file) if (!file)
return false; return false;
bool ok = false;
size_t size = file.size();
if (size == sizeof(StoredPage))
{
StoredPage *stored = new StoredPage; StoredPage *stored = new StoredPage;
bool ok = file.read((uint8_t *)stored, sizeof(StoredPage)) == sizeof(StoredPage) && ok = file.read((uint8_t *)stored, sizeof(StoredPage)) == sizeof(StoredPage) && stored->version == VERSION &&
stored->version == VERSION && stored->size == sizeof(DynamicPage) && stored->size == sizeof(DynamicPage) && validatePage(stored->page) == nullptr;
validatePage(stored->page) == nullptr;
file.close();
if (ok) if (ok)
page = stored->page; page = stored->page;
delete stored; delete stored;
}
else if (size == sizeof(v1::Stored))
{
v1::Stored *stored = new v1::Stored;
ok = file.read((uint8_t *)stored, sizeof(v1::Stored)) == sizeof(v1::Stored) && stored->version == 1 &&
stored->size == sizeof(v1::Page);
if (ok)
{
v1::convert(stored->page, page);
ok = validatePage(page) == nullptr;
}
delete stored;
}
file.close();
return ok; return ok;
} }
+119 -116
View File
@@ -7,144 +7,147 @@
#include "logic/bitmap_format.h" #include "logic/bitmap_format.h"
static const uint8_t FONT_SMALL[] = { static const uint8_t FONT_SMALL[] = {
0x46, 0x44, 0x46, 0x31, 0x06, 0x01, 0x20, 0x5f, 0x02, 0x01, 0x03, 0x03, 0x03, 0x03, 0x03, 0x01, 0x46, 0x44, 0x46, 0x31, 0x06, 0x01, 0x20, 0x60, 0x02, 0x01, 0x03, 0x03, 0x03, 0x03, 0x03, 0x01,
0x02, 0x02, 0x03, 0x03, 0x02, 0x03, 0x01, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x02, 0x02, 0x03, 0x03, 0x02, 0x03, 0x01, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03,
0x03, 0x03, 0x01, 0x02, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x01, 0x02, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03,
0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03,
0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x02, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x02, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03,
0x03, 0x01, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x01, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03,
0x03, 0x03, 0x03, 0x03, 0x01, 0x03, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x80, 0x80, 0x03, 0x03, 0x03, 0x03, 0x01, 0x03, 0x03, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x80,
0x00, 0x80, 0x00, 0xa0, 0xa0, 0x00, 0x00, 0x00, 0x00, 0xa0, 0xe0, 0xa0, 0xe0, 0xa0, 0x00, 0x60, 0x80, 0x00, 0x80, 0x00, 0xa0, 0xa0, 0x00, 0x00, 0x00, 0x00, 0xa0, 0xe0, 0xa0, 0xe0, 0xa0, 0x00,
0xc0, 0x60, 0xc0, 0x40, 0x00, 0x80, 0x20, 0x40, 0x80, 0x20, 0x00, 0xc0, 0xc0, 0xe0, 0xa0, 0x60, 0x60, 0xc0, 0x60, 0xc0, 0x40, 0x00, 0x80, 0x20, 0x40, 0x80, 0x20, 0x00, 0xc0, 0xc0, 0xe0, 0xa0,
0x00, 0x80, 0x80, 0x00, 0x00, 0x00, 0x00, 0x40, 0x80, 0x80, 0x80, 0x40, 0x00, 0x80, 0x40, 0x40, 0x60, 0x00, 0x80, 0x80, 0x00, 0x00, 0x00, 0x00, 0x40, 0x80, 0x80, 0x80, 0x40, 0x00, 0x80, 0x40,
0x40, 0x80, 0x00, 0xa0, 0x40, 0xa0, 0x00, 0x00, 0x00, 0x00, 0x40, 0xe0, 0x40, 0x00, 0x00, 0x00, 0x40, 0x40, 0x80, 0x00, 0xa0, 0x40, 0xa0, 0x00, 0x00, 0x00, 0x00, 0x40, 0xe0, 0x40, 0x00, 0x00,
0x00, 0x00, 0x40, 0x80, 0x00, 0x00, 0x00, 0xe0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x40, 0x80, 0x00, 0x00, 0x00, 0xe0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x20, 0x20, 0x40, 0x80, 0x80, 0x00, 0x60, 0xa0, 0xa0, 0xa0, 0xc0, 0x00, 0x20, 0x60, 0x20, 0x80, 0x00, 0x20, 0x20, 0x40, 0x80, 0x80, 0x00, 0x60, 0xa0, 0xa0, 0xa0, 0xc0, 0x00, 0x20, 0x60,
0x20, 0x20, 0x00, 0xc0, 0x20, 0x40, 0x80, 0xe0, 0x00, 0xc0, 0x20, 0x40, 0x20, 0xc0, 0x00, 0xa0, 0x20, 0x20, 0x20, 0x00, 0xc0, 0x20, 0x40, 0x80, 0xe0, 0x00, 0xc0, 0x20, 0x40, 0x20, 0xc0, 0x00,
0xa0, 0xe0, 0x20, 0x20, 0x00, 0xe0, 0x80, 0xc0, 0x20, 0xc0, 0x00, 0x60, 0x80, 0xe0, 0xa0, 0xe0, 0xa0, 0xa0, 0xe0, 0x20, 0x20, 0x00, 0xe0, 0x80, 0xc0, 0x20, 0xc0, 0x00, 0x60, 0x80, 0xe0, 0xa0,
0x00, 0xe0, 0x20, 0x40, 0x80, 0x80, 0x00, 0xe0, 0xa0, 0xe0, 0xa0, 0xe0, 0x00, 0xe0, 0xa0, 0xe0, 0xe0, 0x00, 0xe0, 0x20, 0x40, 0x80, 0x80, 0x00, 0xe0, 0xa0, 0xe0, 0xa0, 0xe0, 0x00, 0xe0, 0xa0,
0x20, 0xc0, 0x00, 0x00, 0x80, 0x00, 0x80, 0x00, 0x00, 0x00, 0x40, 0x00, 0x40, 0x80, 0x00, 0x20, 0xe0, 0x20, 0xc0, 0x00, 0x00, 0x80, 0x00, 0x80, 0x00, 0x00, 0x00, 0x40, 0x00, 0x40, 0x80, 0x00,
0x40, 0x80, 0x40, 0x20, 0x00, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0x00, 0x80, 0x40, 0x20, 0x40, 0x80, 0x20, 0x40, 0x80, 0x40, 0x20, 0x00, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0x00, 0x80, 0x40, 0x20, 0x40,
0x00, 0xe0, 0x20, 0x40, 0x00, 0x40, 0x00, 0x40, 0xa0, 0xe0, 0x80, 0x60, 0x00, 0x40, 0xa0, 0xe0, 0x80, 0x00, 0xe0, 0x20, 0x40, 0x00, 0x40, 0x00, 0x40, 0xa0, 0xe0, 0x80, 0x60, 0x00, 0x40, 0xa0,
0xa0, 0xa0, 0x00, 0xc0, 0xa0, 0xc0, 0xa0, 0xc0, 0x00, 0x60, 0x80, 0x80, 0x80, 0x60, 0x00, 0xc0, 0xe0, 0xa0, 0xa0, 0x00, 0xc0, 0xa0, 0xc0, 0xa0, 0xc0, 0x00, 0x60, 0x80, 0x80, 0x80, 0x60, 0x00,
0xa0, 0xa0, 0xa0, 0xc0, 0x00, 0xe0, 0x80, 0xe0, 0x80, 0xe0, 0x00, 0xe0, 0x80, 0xe0, 0x80, 0x80, 0xc0, 0xa0, 0xa0, 0xa0, 0xc0, 0x00, 0xe0, 0x80, 0xe0, 0x80, 0xe0, 0x00, 0xe0, 0x80, 0xe0, 0x80,
0x00, 0x60, 0x80, 0xe0, 0xa0, 0x60, 0x00, 0xa0, 0xa0, 0xe0, 0xa0, 0xa0, 0x00, 0xe0, 0x40, 0x40, 0x80, 0x00, 0x60, 0x80, 0xe0, 0xa0, 0x60, 0x00, 0xa0, 0xa0, 0xe0, 0xa0, 0xa0, 0x00, 0xe0, 0x40,
0x40, 0xe0, 0x00, 0x20, 0x20, 0x20, 0xa0, 0x40, 0x00, 0xa0, 0xa0, 0xc0, 0xa0, 0xa0, 0x00, 0x80, 0x40, 0x40, 0xe0, 0x00, 0x20, 0x20, 0x20, 0xa0, 0x40, 0x00, 0xa0, 0xa0, 0xc0, 0xa0, 0xa0, 0x00,
0x80, 0x80, 0x80, 0xe0, 0x00, 0xa0, 0xe0, 0xe0, 0xa0, 0xa0, 0x00, 0xa0, 0xe0, 0xe0, 0xe0, 0xa0, 0x80, 0x80, 0x80, 0x80, 0xe0, 0x00, 0xa0, 0xe0, 0xe0, 0xa0, 0xa0, 0x00, 0xa0, 0xe0, 0xe0, 0xe0,
0x00, 0x40, 0xa0, 0xa0, 0xa0, 0x40, 0x00, 0xc0, 0xa0, 0xc0, 0x80, 0x80, 0x00, 0x40, 0xa0, 0xa0, 0xa0, 0x00, 0x40, 0xa0, 0xa0, 0xa0, 0x40, 0x00, 0xc0, 0xa0, 0xc0, 0x80, 0x80, 0x00, 0x40, 0xa0,
0xe0, 0x60, 0x00, 0xc0, 0xa0, 0xe0, 0xc0, 0xa0, 0x00, 0x60, 0x80, 0x40, 0x20, 0xc0, 0x00, 0xe0, 0xa0, 0xe0, 0x60, 0x00, 0xc0, 0xa0, 0xe0, 0xc0, 0xa0, 0x00, 0x60, 0x80, 0x40, 0x20, 0xc0, 0x00,
0x40, 0x40, 0x40, 0x40, 0x00, 0xa0, 0xa0, 0xa0, 0xa0, 0x60, 0x00, 0xa0, 0xa0, 0xa0, 0x40, 0x40, 0xe0, 0x40, 0x40, 0x40, 0x40, 0x00, 0xa0, 0xa0, 0xa0, 0xa0, 0x60, 0x00, 0xa0, 0xa0, 0xa0, 0x40,
0x00, 0xa0, 0xa0, 0xe0, 0xe0, 0xa0, 0x00, 0xa0, 0xa0, 0x40, 0xa0, 0xa0, 0x00, 0xa0, 0xa0, 0x40, 0x40, 0x00, 0xa0, 0xa0, 0xe0, 0xe0, 0xa0, 0x00, 0xa0, 0xa0, 0x40, 0xa0, 0xa0, 0x00, 0xa0, 0xa0,
0x40, 0x40, 0x00, 0xe0, 0x20, 0x40, 0x80, 0xe0, 0x00, 0xe0, 0x80, 0x80, 0x80, 0xe0, 0x00, 0x00, 0x40, 0x40, 0x40, 0x00, 0xe0, 0x20, 0x40, 0x80, 0xe0, 0x00, 0xe0, 0x80, 0x80, 0x80, 0xe0, 0x00,
0x80, 0x40, 0x20, 0x00, 0x00, 0xe0, 0x20, 0x20, 0x20, 0xe0, 0x00, 0x40, 0xa0, 0x00, 0x00, 0x00, 0x00, 0x80, 0x40, 0x20, 0x00, 0x00, 0xe0, 0x20, 0x20, 0x20, 0xe0, 0x00, 0x40, 0xa0, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0x00, 0x80, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0x60, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0x00, 0x80, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0,
0xa0, 0xe0, 0x00, 0x80, 0xc0, 0xa0, 0xa0, 0xc0, 0x00, 0x00, 0x60, 0x80, 0x80, 0x60, 0x00, 0x20, 0x60, 0xa0, 0xe0, 0x00, 0x80, 0xc0, 0xa0, 0xa0, 0xc0, 0x00, 0x00, 0x60, 0x80, 0x80, 0x60, 0x00,
0x60, 0xa0, 0xa0, 0x60, 0x00, 0x00, 0x60, 0xa0, 0xc0, 0x60, 0x00, 0x20, 0x40, 0xe0, 0x40, 0x40, 0x20, 0x60, 0xa0, 0xa0, 0x60, 0x00, 0x00, 0x60, 0xa0, 0xc0, 0x60, 0x00, 0x20, 0x40, 0xe0, 0x40,
0x00, 0x00, 0x60, 0xa0, 0xe0, 0x20, 0x40, 0x80, 0xc0, 0xa0, 0xa0, 0xa0, 0x00, 0x80, 0x00, 0x80, 0x40, 0x00, 0x00, 0x60, 0xa0, 0xe0, 0x20, 0x40, 0x80, 0xc0, 0xa0, 0xa0, 0xa0, 0x00, 0x80, 0x00,
0x80, 0x80, 0x00, 0x20, 0x00, 0x20, 0x20, 0xa0, 0x40, 0x80, 0xa0, 0xc0, 0xc0, 0xa0, 0x00, 0xc0, 0x80, 0x80, 0x80, 0x00, 0x20, 0x00, 0x20, 0x20, 0xa0, 0x40, 0x80, 0xa0, 0xc0, 0xc0, 0xa0, 0x00,
0x40, 0x40, 0x40, 0xe0, 0x00, 0x00, 0xe0, 0xe0, 0xe0, 0xa0, 0x00, 0x00, 0xc0, 0xa0, 0xa0, 0xa0, 0xc0, 0x40, 0x40, 0x40, 0xe0, 0x00, 0x00, 0xe0, 0xe0, 0xe0, 0xa0, 0x00, 0x00, 0xc0, 0xa0, 0xa0,
0x00, 0x00, 0x40, 0xa0, 0xa0, 0x40, 0x00, 0x00, 0xc0, 0xa0, 0xa0, 0xc0, 0x80, 0x00, 0x60, 0xa0, 0xa0, 0x00, 0x00, 0x40, 0xa0, 0xa0, 0x40, 0x00, 0x00, 0xc0, 0xa0, 0xa0, 0xc0, 0x80, 0x00, 0x60,
0xa0, 0x60, 0x20, 0x00, 0x60, 0x80, 0x80, 0x80, 0x00, 0x00, 0x60, 0xc0, 0x60, 0xc0, 0x00, 0x40, 0xa0, 0xa0, 0x60, 0x20, 0x00, 0x60, 0x80, 0x80, 0x80, 0x00, 0x00, 0x60, 0xc0, 0x60, 0xc0, 0x00,
0xe0, 0x40, 0x40, 0x60, 0x00, 0x00, 0xa0, 0xa0, 0xa0, 0x60, 0x00, 0x00, 0xa0, 0xa0, 0xe0, 0x40, 0x40, 0xe0, 0x40, 0x40, 0x60, 0x00, 0x00, 0xa0, 0xa0, 0xa0, 0x60, 0x00, 0x00, 0xa0, 0xa0, 0xe0,
0x00, 0x00, 0xa0, 0xe0, 0xe0, 0xe0, 0x00, 0x00, 0xa0, 0x40, 0x40, 0xa0, 0x00, 0x00, 0xa0, 0xa0, 0x40, 0x00, 0x00, 0xa0, 0xe0, 0xe0, 0xe0, 0x00, 0x00, 0xa0, 0x40, 0x40, 0xa0, 0x00, 0x00, 0xa0,
0x60, 0x20, 0x40, 0x00, 0xe0, 0x60, 0xc0, 0xe0, 0x00, 0x60, 0x40, 0x80, 0x40, 0x60, 0x00, 0x80, 0xa0, 0x60, 0x20, 0x40, 0x00, 0xe0, 0x60, 0xc0, 0xe0, 0x00, 0x60, 0x40, 0x80, 0x40, 0x60, 0x00,
0x80, 0x00, 0x80, 0x80, 0x00, 0xc0, 0x40, 0x20, 0x40, 0xc0, 0x00, 0x60, 0xc0, 0x00, 0x00, 0x00, 0x80, 0x80, 0x00, 0x80, 0x80, 0x00, 0xc0, 0x40, 0x20, 0x40, 0xc0, 0x00, 0x60, 0xc0, 0x00, 0x00,
0x00, 0x00, 0x00, 0x40, 0xa0, 0x40, 0x00, 0x00, 0x00,
}; };
static const uint8_t FONT_NORMAL[] = { static const uint8_t FONT_NORMAL[] = {
0x46, 0x44, 0x46, 0x31, 0x08, 0x01, 0x20, 0x5f, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x46, 0x44, 0x46, 0x31, 0x08, 0x01, 0x20, 0x60, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05,
0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05,
0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05,
0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05,
0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05,
0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05,
0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x20, 0x20, 0x20, 0x20, 0x00, 0x20, 0x00, 0x50, 0x50, 0x50, 0x00, 0x00, 0x00, 0x00, 0x00, 0x50, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x20, 0x00, 0x50, 0x50, 0x50, 0x00, 0x00, 0x00, 0x00, 0x00,
0x50, 0xf8, 0x50, 0xf8, 0x50, 0x50, 0x00, 0x20, 0x78, 0xa0, 0x70, 0x28, 0xf0, 0x20, 0x00, 0xc0, 0x50, 0x50, 0xf8, 0x50, 0xf8, 0x50, 0x50, 0x00, 0x20, 0x78, 0xa0, 0x70, 0x28, 0xf0, 0x20, 0x00,
0xc8, 0x10, 0x20, 0x40, 0x98, 0x18, 0x00, 0x40, 0xa0, 0xa0, 0x40, 0xa8, 0x90, 0x68, 0x00, 0x30, 0xc0, 0xc8, 0x10, 0x20, 0x40, 0x98, 0x18, 0x00, 0x40, 0xa0, 0xa0, 0x40, 0xa8, 0x90, 0x68, 0x00,
0x30, 0x20, 0x40, 0x00, 0x00, 0x00, 0x00, 0x10, 0x20, 0x40, 0x40, 0x40, 0x20, 0x10, 0x00, 0x40, 0x30, 0x30, 0x20, 0x40, 0x00, 0x00, 0x00, 0x00, 0x10, 0x20, 0x40, 0x40, 0x40, 0x20, 0x10, 0x00,
0x20, 0x10, 0x10, 0x10, 0x20, 0x40, 0x00, 0x20, 0xa8, 0x70, 0xf8, 0x70, 0xa8, 0x20, 0x00, 0x00, 0x40, 0x20, 0x10, 0x10, 0x10, 0x20, 0x40, 0x00, 0x20, 0xa8, 0x70, 0xf8, 0x70, 0xa8, 0x20, 0x00,
0x20, 0x20, 0xf8, 0x20, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x30, 0x20, 0x40, 0x00, 0x00, 0x20, 0x20, 0xf8, 0x20, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x30, 0x20, 0x40,
0x00, 0x00, 0xf8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x30, 0x00,
0x08, 0x10, 0x20, 0x40, 0x80, 0x00, 0x00, 0x70, 0x88, 0x98, 0xa8, 0xc8, 0x88, 0x70, 0x00, 0x20, 0x00, 0x08, 0x10, 0x20, 0x40, 0x80, 0x00, 0x00, 0x70, 0x88, 0x98, 0xa8, 0xc8, 0x88, 0x70, 0x00,
0x60, 0x20, 0x20, 0x20, 0x20, 0x70, 0x00, 0x70, 0x88, 0x08, 0x70, 0x80, 0x80, 0xf8, 0x00, 0xf8, 0x20, 0x60, 0x20, 0x20, 0x20, 0x20, 0x70, 0x00, 0x70, 0x88, 0x08, 0x70, 0x80, 0x80, 0xf8, 0x00,
0x08, 0x10, 0x30, 0x08, 0x88, 0x70, 0x00, 0x10, 0x30, 0x50, 0x90, 0xf8, 0x10, 0x10, 0x00, 0xf8, 0xf8, 0x08, 0x10, 0x30, 0x08, 0x88, 0x70, 0x00, 0x10, 0x30, 0x50, 0x90, 0xf8, 0x10, 0x10, 0x00,
0x80, 0xf0, 0x08, 0x08, 0x88, 0x70, 0x00, 0x38, 0x40, 0x80, 0xf0, 0x88, 0x88, 0x70, 0x00, 0xf8, 0xf8, 0x80, 0xf0, 0x08, 0x08, 0x88, 0x70, 0x00, 0x38, 0x40, 0x80, 0xf0, 0x88, 0x88, 0x70, 0x00,
0x08, 0x08, 0x10, 0x20, 0x40, 0x80, 0x00, 0x70, 0x88, 0x88, 0x70, 0x88, 0x88, 0x70, 0x00, 0x70, 0xf8, 0x08, 0x08, 0x10, 0x20, 0x40, 0x80, 0x00, 0x70, 0x88, 0x88, 0x70, 0x88, 0x88, 0x70, 0x00,
0x88, 0x88, 0x78, 0x08, 0x10, 0xe0, 0x00, 0x00, 0x00, 0x20, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x70, 0x88, 0x88, 0x78, 0x08, 0x10, 0xe0, 0x00, 0x00, 0x00, 0x20, 0x00, 0x20, 0x00, 0x00, 0x00,
0x00, 0x20, 0x00, 0x20, 0x20, 0x40, 0x00, 0x08, 0x10, 0x20, 0x40, 0x20, 0x10, 0x08, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x20, 0x20, 0x40, 0x00, 0x08, 0x10, 0x20, 0x40, 0x20, 0x10, 0x08, 0x00,
0x00, 0xf8, 0x00, 0xf8, 0x00, 0x00, 0x00, 0x40, 0x20, 0x10, 0x08, 0x10, 0x20, 0x40, 0x00, 0x70, 0x00, 0x00, 0xf8, 0x00, 0xf8, 0x00, 0x00, 0x00, 0x40, 0x20, 0x10, 0x08, 0x10, 0x20, 0x40, 0x00,
0x88, 0x08, 0x30, 0x20, 0x00, 0x20, 0x00, 0x70, 0x88, 0xa8, 0xb8, 0xb0, 0x80, 0x78, 0x00, 0x20, 0x70, 0x88, 0x08, 0x30, 0x20, 0x00, 0x20, 0x00, 0x70, 0x88, 0xa8, 0xb8, 0xb0, 0x80, 0x78, 0x00,
0x50, 0x88, 0x88, 0xf8, 0x88, 0x88, 0x00, 0xf0, 0x88, 0x88, 0xf0, 0x88, 0x88, 0xf0, 0x00, 0x70, 0x20, 0x50, 0x88, 0x88, 0xf8, 0x88, 0x88, 0x00, 0xf0, 0x88, 0x88, 0xf0, 0x88, 0x88, 0xf0, 0x00,
0x88, 0x80, 0x80, 0x80, 0x88, 0x70, 0x00, 0xf0, 0x88, 0x88, 0x88, 0x88, 0x88, 0xf0, 0x00, 0xf8, 0x70, 0x88, 0x80, 0x80, 0x80, 0x88, 0x70, 0x00, 0xf0, 0x88, 0x88, 0x88, 0x88, 0x88, 0xf0, 0x00,
0x80, 0x80, 0xf0, 0x80, 0x80, 0xf8, 0x00, 0xf8, 0x80, 0x80, 0xf0, 0x80, 0x80, 0x80, 0x00, 0x78, 0xf8, 0x80, 0x80, 0xf0, 0x80, 0x80, 0xf8, 0x00, 0xf8, 0x80, 0x80, 0xf0, 0x80, 0x80, 0x80, 0x00,
0x88, 0x80, 0x80, 0x98, 0x88, 0x78, 0x00, 0x88, 0x88, 0x88, 0xf8, 0x88, 0x88, 0x88, 0x00, 0x70, 0x78, 0x88, 0x80, 0x80, 0x98, 0x88, 0x78, 0x00, 0x88, 0x88, 0x88, 0xf8, 0x88, 0x88, 0x88, 0x00,
0x20, 0x20, 0x20, 0x20, 0x20, 0x70, 0x00, 0x38, 0x10, 0x10, 0x10, 0x10, 0x90, 0x60, 0x00, 0x88, 0x70, 0x20, 0x20, 0x20, 0x20, 0x20, 0x70, 0x00, 0x38, 0x10, 0x10, 0x10, 0x10, 0x90, 0x60, 0x00,
0x90, 0xa0, 0xc0, 0xa0, 0x90, 0x88, 0x00, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0xf8, 0x00, 0x88, 0x88, 0x90, 0xa0, 0xc0, 0xa0, 0x90, 0x88, 0x00, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0xf8, 0x00,
0xd8, 0xa8, 0xa8, 0xa8, 0x88, 0x88, 0x00, 0x88, 0x88, 0xc8, 0xa8, 0x98, 0x88, 0x88, 0x00, 0x70, 0x88, 0xd8, 0xa8, 0xa8, 0xa8, 0x88, 0x88, 0x00, 0x88, 0x88, 0xc8, 0xa8, 0x98, 0x88, 0x88, 0x00,
0x88, 0x88, 0x88, 0x88, 0x88, 0x70, 0x00, 0xf0, 0x88, 0x88, 0xf0, 0x80, 0x80, 0x80, 0x00, 0x70, 0x70, 0x88, 0x88, 0x88, 0x88, 0x88, 0x70, 0x00, 0xf0, 0x88, 0x88, 0xf0, 0x80, 0x80, 0x80, 0x00,
0x88, 0x88, 0x88, 0xa8, 0x90, 0x68, 0x00, 0xf0, 0x88, 0x88, 0xf0, 0xa0, 0x90, 0x88, 0x00, 0x70, 0x70, 0x88, 0x88, 0x88, 0xa8, 0x90, 0x68, 0x00, 0xf0, 0x88, 0x88, 0xf0, 0xa0, 0x90, 0x88, 0x00,
0x88, 0x80, 0x70, 0x08, 0x88, 0x70, 0x00, 0xf8, 0xa8, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x88, 0x70, 0x88, 0x80, 0x70, 0x08, 0x88, 0x70, 0x00, 0xf8, 0xa8, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00,
0x88, 0x88, 0x88, 0x88, 0x88, 0x70, 0x00, 0x88, 0x88, 0x88, 0x88, 0x88, 0x50, 0x20, 0x00, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x70, 0x00, 0x88, 0x88, 0x88, 0x88, 0x88, 0x50, 0x20, 0x00,
0x88, 0x88, 0xa8, 0xa8, 0xa8, 0x50, 0x00, 0x88, 0x88, 0x50, 0x20, 0x50, 0x88, 0x88, 0x00, 0x88, 0x88, 0x88, 0x88, 0xa8, 0xa8, 0xa8, 0x50, 0x00, 0x88, 0x88, 0x50, 0x20, 0x50, 0x88, 0x88, 0x00,
0x88, 0x50, 0x20, 0x20, 0x20, 0x20, 0x00, 0xf8, 0x08, 0x10, 0x70, 0x40, 0x80, 0xf8, 0x00, 0x78, 0x88, 0x88, 0x50, 0x20, 0x20, 0x20, 0x20, 0x00, 0xf8, 0x08, 0x10, 0x70, 0x40, 0x80, 0xf8, 0x00,
0x40, 0x40, 0x40, 0x40, 0x40, 0x78, 0x00, 0x00, 0x80, 0x40, 0x20, 0x10, 0x08, 0x00, 0x00, 0x78, 0x78, 0x40, 0x40, 0x40, 0x40, 0x40, 0x78, 0x00, 0x00, 0x80, 0x40, 0x20, 0x10, 0x08, 0x00, 0x00,
0x08, 0x08, 0x08, 0x08, 0x08, 0x78, 0x00, 0x20, 0x50, 0x88, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x78, 0x08, 0x08, 0x08, 0x08, 0x08, 0x78, 0x00, 0x20, 0x50, 0x88, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0x00, 0x60, 0x60, 0x20, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0x00, 0x60, 0x60, 0x20, 0x10, 0x00, 0x00, 0x00, 0x00,
0x00, 0x60, 0x10, 0x70, 0x90, 0x78, 0x00, 0x80, 0x80, 0xb0, 0xc8, 0x88, 0xc8, 0xb0, 0x00, 0x00, 0x00, 0x00, 0x60, 0x10, 0x70, 0x90, 0x78, 0x00, 0x80, 0x80, 0xb0, 0xc8, 0x88, 0xc8, 0xb0, 0x00,
0x00, 0x70, 0x88, 0x80, 0x88, 0x70, 0x00, 0x08, 0x08, 0x68, 0x98, 0x88, 0x98, 0x68, 0x00, 0x00, 0x00, 0x00, 0x70, 0x88, 0x80, 0x88, 0x70, 0x00, 0x08, 0x08, 0x68, 0x98, 0x88, 0x98, 0x68, 0x00,
0x00, 0x70, 0x88, 0xf8, 0x80, 0x70, 0x00, 0x10, 0x28, 0x20, 0x70, 0x20, 0x20, 0x20, 0x00, 0x00, 0x00, 0x00, 0x70, 0x88, 0xf8, 0x80, 0x70, 0x00, 0x10, 0x28, 0x20, 0x70, 0x20, 0x20, 0x20, 0x00,
0x00, 0x70, 0x98, 0x98, 0x68, 0x08, 0x70, 0x80, 0x80, 0xb0, 0xc8, 0x88, 0x88, 0x88, 0x00, 0x20, 0x00, 0x00, 0x70, 0x98, 0x98, 0x68, 0x08, 0x70, 0x80, 0x80, 0xb0, 0xc8, 0x88, 0x88, 0x88, 0x00,
0x00, 0x60, 0x20, 0x20, 0x20, 0x70, 0x00, 0x10, 0x00, 0x10, 0x10, 0x10, 0x90, 0x60, 0x00, 0x80, 0x20, 0x00, 0x60, 0x20, 0x20, 0x20, 0x70, 0x00, 0x10, 0x00, 0x10, 0x10, 0x10, 0x90, 0x60, 0x00,
0x80, 0x90, 0xa0, 0xc0, 0xa0, 0x90, 0x00, 0x60, 0x20, 0x20, 0x20, 0x20, 0x20, 0x70, 0x00, 0x00, 0x80, 0x80, 0x90, 0xa0, 0xc0, 0xa0, 0x90, 0x00, 0x60, 0x20, 0x20, 0x20, 0x20, 0x20, 0x70, 0x00,
0x00, 0xd0, 0xa8, 0xa8, 0xa8, 0xa8, 0x00, 0x00, 0x00, 0xb0, 0xc8, 0x88, 0x88, 0x88, 0x00, 0x00, 0x00, 0x00, 0xd0, 0xa8, 0xa8, 0xa8, 0xa8, 0x00, 0x00, 0x00, 0xb0, 0xc8, 0x88, 0x88, 0x88, 0x00,
0x00, 0x70, 0x88, 0x88, 0x88, 0x70, 0x00, 0x00, 0x00, 0xb0, 0xc8, 0xc8, 0xb0, 0x80, 0x80, 0x00, 0x00, 0x00, 0x70, 0x88, 0x88, 0x88, 0x70, 0x00, 0x00, 0x00, 0xb0, 0xc8, 0xc8, 0xb0, 0x80, 0x80,
0x00, 0x68, 0x98, 0x98, 0x68, 0x08, 0x08, 0x00, 0x00, 0xb0, 0xc8, 0x80, 0x80, 0x80, 0x00, 0x00, 0x00, 0x00, 0x68, 0x98, 0x98, 0x68, 0x08, 0x08, 0x00, 0x00, 0xb0, 0xc8, 0x80, 0x80, 0x80, 0x00,
0x00, 0x78, 0x80, 0x70, 0x08, 0xf0, 0x00, 0x20, 0x20, 0xf8, 0x20, 0x20, 0x28, 0x10, 0x00, 0x00, 0x00, 0x00, 0x78, 0x80, 0x70, 0x08, 0xf0, 0x00, 0x20, 0x20, 0xf8, 0x20, 0x20, 0x28, 0x10, 0x00,
0x00, 0x88, 0x88, 0x88, 0x98, 0x68, 0x00, 0x00, 0x00, 0x88, 0x88, 0x88, 0x50, 0x20, 0x00, 0x00, 0x00, 0x00, 0x88, 0x88, 0x88, 0x98, 0x68, 0x00, 0x00, 0x00, 0x88, 0x88, 0x88, 0x50, 0x20, 0x00,
0x00, 0x88, 0x88, 0xa8, 0xa8, 0x50, 0x00, 0x00, 0x00, 0x88, 0x50, 0x20, 0x50, 0x88, 0x00, 0x00, 0x00, 0x00, 0x88, 0x88, 0xa8, 0xa8, 0x50, 0x00, 0x00, 0x00, 0x88, 0x50, 0x20, 0x50, 0x88, 0x00,
0x00, 0x88, 0x88, 0x78, 0x08, 0x88, 0x70, 0x00, 0x00, 0xf8, 0x10, 0x20, 0x40, 0xf8, 0x00, 0x10, 0x00, 0x00, 0x88, 0x88, 0x78, 0x08, 0x88, 0x70, 0x00, 0x00, 0xf8, 0x10, 0x20, 0x40, 0xf8, 0x00,
0x20, 0x20, 0x40, 0x20, 0x20, 0x10, 0x00, 0x20, 0x20, 0x20, 0x00, 0x20, 0x20, 0x20, 0x00, 0x40, 0x10, 0x20, 0x20, 0x40, 0x20, 0x20, 0x10, 0x00, 0x20, 0x20, 0x20, 0x00, 0x20, 0x20, 0x20, 0x00,
0x20, 0x20, 0x10, 0x20, 0x20, 0x40, 0x00, 0x40, 0xa8, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x20, 0x20, 0x10, 0x20, 0x20, 0x40, 0x00, 0x40, 0xa8, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00,
0x60, 0x90, 0x90, 0x60, 0x00, 0x00, 0x00, 0x00,
}; };
static const uint8_t FONT_FULL[] = { static const uint8_t FONT_FULL[] = {
0x46, 0x44, 0x46, 0x31, 0x13, 0x02, 0x20, 0x5f, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46, 0x44, 0x46, 0x31, 0x13, 0x02, 0x20, 0x60, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x03, 0x00, 0x0a, 0x0a, 0x0a, 0x0a, 0x0a, 0x0a, 0x0a, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x03, 0x00, 0x0a, 0x0a, 0x0a, 0x0a, 0x0a, 0x0a, 0x0a, 0x0a,
0x0a, 0x0a, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0a, 0x0a, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0xfc, 0xfc, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0xfc, 0xfc, 0xfc, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0xe0,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0xe0, 0xe0, 0xe0, 0x7f, 0x80, 0xff, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1,
0x7f, 0x80, 0xff, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1,
0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xc0, 0xe1, 0xc0, 0xff, 0xc0, 0x7f, 0x80, 0x01, 0x80, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01,
0xe1, 0xc0, 0xff, 0xc0, 0x7f, 0x80, 0x01, 0x80, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01,
0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0x80, 0x7f, 0x80, 0xff,
0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0x80, 0x7f, 0x80, 0xff, 0xc0, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x7f, 0xc0, 0xff,
0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x7f, 0xc0, 0xff, 0xc0, 0xc0, 0xff, 0x80, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xff,
0xff, 0x80, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xff, 0xc0, 0xc0, 0x7f, 0x80, 0x7f, 0x80, 0xff, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01,
0x7f, 0x80, 0x7f, 0x80, 0xff, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0xc0, 0x01, 0xc0, 0x7f, 0xc0, 0xff, 0xc0, 0x7f, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01,
0x01, 0xc0, 0x7f, 0xc0, 0xff, 0xc0, 0x7f, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0xff, 0xc0, 0x7f, 0x80, 0x61, 0x80, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1,
0x01, 0xc0, 0x01, 0xc0, 0xff, 0xc0, 0x7f, 0x80, 0x61, 0x80, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xff, 0xc0, 0xff, 0xc0, 0x7f, 0xc0, 0x01,
0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xff, 0xc0, 0xff, 0xc0, 0x7f, 0xc0, 0x01, 0xc0, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0x80, 0x7f,
0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0x80, 0x7f, 0x80, 0x80, 0xff, 0xc0, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xff,
0xff, 0xc0, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xff, 0x80, 0x80, 0xff, 0xc0, 0x7f, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01,
0xff, 0xc0, 0x7f, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0xc0, 0xff, 0xc0, 0x7f, 0x80, 0x7f, 0x80, 0xff, 0xc0, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xe0,
0xff, 0xc0, 0x7f, 0x80, 0x7f, 0x80, 0xff, 0xc0, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xff, 0x80, 0xff, 0xc0, 0xff, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1,
0xe0, 0x00, 0xe0, 0x00, 0xff, 0x80, 0xff, 0xc0, 0xff, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xff, 0xc0, 0x7f, 0x80, 0x7f, 0x80, 0xff, 0xc0, 0x01,
0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xff, 0xc0, 0x7f, 0x80, 0x7f, 0x80, 0xff, 0xc0, 0x01, 0xc0, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01,
0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01,
0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0x80, 0x80, 0x7f, 0x80, 0xff, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1,
0x7f, 0x80, 0xff, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xc0, 0xff, 0xc0, 0xff, 0xc0, 0xff, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1,
0xff, 0xc0, 0xff, 0xc0, 0xff, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xc0, 0xe1, 0xc0, 0xff, 0xc0, 0x7f, 0x80, 0x7f, 0x80, 0xff, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1,
0xe1, 0xc0, 0xff, 0xc0, 0x7f, 0x80, 0x7f, 0x80, 0xff, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xff, 0xc0, 0xff, 0xc0, 0x7f, 0xc0, 0x01, 0xc0, 0x01,
0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xff, 0xc0, 0xff, 0xc0, 0x7f, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0xff, 0xc0, 0x7f, 0x80, 0x00, 0x00, 0x00,
0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0xff, 0xc0, 0x7f, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0xe0, 0xe0, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0xe0, 0xe0, 0x00, 0x00, 0x00, 0x00,
0xe0, 0xe0, 0xe0, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0xe0, 0xe0, 0x00, 0x00, 0x00, 0x00, 0x78, 0xcc, 0x84, 0x84, 0xcc, 0x78, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00,
}; };
const BuiltinFont BUILTIN_FONTS[] = { const BuiltinFont BUILTIN_FONTS[] = {
+15 -6
View File
@@ -119,7 +119,7 @@ void DisplayController::update(uint32_t now, bool time_valid, const LocalTime &t
_off_timer.cancel(); _off_timer.cancel();
_ensureOn(); _ensureOn();
if (_page == Page::Dynamic) if (_page == Page::Dynamic)
_showDynamic(time, show_no_wifi); _showDynamic(now, time, show_no_wifi);
else else
_showOnce(Screen::Page); _showOnce(Screen::Page);
break; break;
@@ -139,17 +139,26 @@ void DisplayController::_ensureOn()
_screen = Screen::Unknown; _screen = Screen::Unknown;
} }
// Drawn again each minute (time, date and background can change) and when the content changed // Drawn again each minute (time, date and background can change), when the content changed,
void DisplayController::_showDynamic(const LocalTime &time, bool no_wifi) // and as often as a moving page asks for (scrolling, seconds)
void DisplayController::_showDynamic(uint32_t now, const LocalTime &time, bool no_wifi)
{ {
Screen screen = no_wifi ? Screen::NoWifi : Screen::Dynamic; Screen screen = no_wifi ? Screen::NoWifi : Screen::Dynamic;
if (_screen == screen && _shown_hour == time.hour && _shown_minute == time.minute && bool redraw = _screen != screen || _shown_revision != _content_revision;
_shown_revision == _content_revision) bool due = screen == Screen::Dynamic && _dynamic_moving && (int32_t)(now - _dynamic_due) >= 0;
if (!redraw && !due && _shown_hour == time.hour && _shown_minute == time.minute)
return; return;
if (no_wifi) if (no_wifi)
{
_frames.noWifi(time.hour, time.minute); _frames.noWifi(time.hour, time.minute);
_dynamic_moving = false;
}
else else
_frames.dynamicPage(time); {
uint32_t wait = _frames.dynamicPage(time, now, redraw);
_dynamic_moving = wait != 0;
_dynamic_due = now + wait;
}
_screen = screen; _screen = screen;
_shown_hour = time.hour; _shown_hour = time.hour;
_shown_minute = time.minute; _shown_minute = time.minute;
+8 -3
View File
@@ -20,8 +20,11 @@ public:
class Frames class Frames
{ {
public: public:
// The dynamic page selected as page, drawn for this time // The dynamic page selected as page, drawn for this time. now: millis(). redraw: send it
virtual void dynamicPage(const LocalTime &time) = 0; // even when it looks the same as the last frame (after power on, a new page). Returns
// the ms until it should be drawn again (scrolling, seconds), or 0 when it only changes
// with the minute or pageChanged().
virtual uint32_t dynamicPage(const LocalTime &time, uint32_t now, bool redraw) = 0;
// Shown instead of a dynamic page while WiFi is down (the time keeps running without it) // Shown instead of a dynamic page while WiFi is down (the time keeps running without it)
virtual void noWifi(int hour, int minute) = 0; virtual void noWifi(int hour, int minute) = 0;
// The bitmap selected as page // The bitmap selected as page
@@ -96,6 +99,8 @@ private:
Screen _screen = Screen::Unknown; Screen _screen = Screen::Unknown;
int _shown_hour = -1; int _shown_hour = -1;
int _shown_minute = -1; int _shown_minute = -1;
uint32_t _dynamic_due = 0; // millis() when a moving dynamic page is drawn again
bool _dynamic_moving = false;
uint32_t _content_revision = 0; // changed page, its content, bitmap or live image uint32_t _content_revision = 0; // changed page, its content, bitmap or live image
uint32_t _shown_revision = 0; uint32_t _shown_revision = 0;
@@ -106,6 +111,6 @@ private:
uint32_t _wifi_lost_since = 0; uint32_t _wifi_lost_since = 0;
void _ensureOn(); void _ensureOn();
void _showDynamic(const LocalTime &time, bool no_wifi); void _showDynamic(uint32_t now, const LocalTime &time, bool no_wifi);
void _showOnce(Screen screen); void _showOnce(Screen screen);
}; };
+7 -2
View File
@@ -81,7 +81,8 @@ static void drawDot(uint8_t *bitmap, int x, int y, DrawMode mode)
setBitmapPixel(bitmap, x, y, on); setBitmapPixel(bitmap, x, y, on);
} }
void drawText(uint8_t *bitmap, const Font &font, int x, int y, const char *text, DrawMode mode) void drawText(uint8_t *bitmap, const Font &font, int x, int y, const char *text, DrawMode mode, int clip_left,
int clip_right)
{ {
if (!usable(font) || text == nullptr) if (!usable(font) || text == nullptr)
return; return;
@@ -103,7 +104,11 @@ void drawText(uint8_t *bitmap, const Font &font, int x, int y, const char *text,
continue; continue;
for (int dy = 0; dy < scale; dy++) for (int dy = 0; dy < scale; dy++)
for (int dx = 0; dx < scale; dx++) for (int dx = 0; dx < scale; dx++)
drawDot(bitmap, x + col * scale + dx, y + row * scale + dy, mode); {
int dot_x = x + col * scale + dx;
if (dot_x >= clip_left && dot_x < clip_right)
drawDot(bitmap, dot_x, y + row * scale + dy, mode);
}
} }
x += width * scale; x += width * scale;
} }
+6 -2
View File
@@ -45,5 +45,9 @@ enum class DrawMode : uint8_t
}; };
// Draws the text with its top left corner at x, y into a full screen bitmap // Draws the text with its top left corner at x, y into a full screen bitmap
// (logic/bitmap_format.h). Dots outside the screen are skipped. // (logic/bitmap_format.h). Dots outside the screen, and left of clip_left or from
void drawText(uint8_t *bitmap, const Font &font, int x, int y, const char *text, DrawMode mode); // clip_right on, are skipped.
const int NO_CLIP_LEFT = -32768;
const int NO_CLIP_RIGHT = 32767;
void drawText(uint8_t *bitmap, const Font &font, int x, int y, const char *text, DrawMode mode,
int clip_left = NO_CLIP_LEFT, int clip_right = NO_CLIP_RIGHT);
+2
View File
@@ -9,4 +9,6 @@ struct LocalTime
int weekday = 6; // 0 = Sunday int weekday = 6; // 0 = Sunday
int hour = 0; int hour = 0;
int minute = 0; int minute = 0;
int second = 0;
int millisecond = 0;
}; };
+222 -23
View File
@@ -1,24 +1,26 @@
#include "logic/page.h" #include "logic/page.h"
#include <ctype.h> #include <ctype.h>
#include <math.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
static const uint16_t MINUTES_PER_DAY = 24 * 60; static const uint16_t MINUTES_PER_DAY = 24 * 60;
static const uint32_t MAX_TARGET = 36525UL * MINUTES_PER_DAY; // 2100-01-01
static bool terminated(const char *text, size_t size) static bool terminated(const char *text, size_t size)
{ {
return memchr(text, '\0', size) != nullptr; return memchr(text, '\0', size) != nullptr;
} }
// Printable ASCII only: the fonts have no other characters // Plain ASCII and the degree sign only: the fonts have no other characters
static bool printable(const char *text, size_t size) static bool printable(const char *text, size_t size)
{ {
if (!terminated(text, size)) if (!terminated(text, size))
return false; return false;
for (const char *c = text; *c; c++) for (const char *c = text; *c; c++)
if (*c < 32 || *c > 126) if (*c < 32 || *c > DEGREE_SIGN)
return false; return false;
return true; return true;
} }
@@ -44,6 +46,23 @@ static bool validAttribute(const char *attribute)
return true; return true;
} }
static uint8_t formatCount(ElementType type)
{
switch (type)
{
case ElementType::Clock:
return CLOCK_FORMAT_COUNT;
case ElementType::Date:
return DATE_FORMAT_COUNT;
case ElementType::Countdown:
return COUNTDOWN_FORMAT_COUNT;
case ElementType::Stopwatch:
return STOPWATCH_FORMAT_COUNT;
default:
return 255; // not used
}
}
const char *validatePage(const DynamicPage &page) const char *validatePage(const DynamicPage &page)
{ {
if (page.slot_count > PAGE_SLOTS) if (page.slot_count > PAGE_SLOTS)
@@ -73,18 +92,32 @@ const char *validatePage(const DynamicPage &page)
if (!terminated(e.font, sizeof(e.font)) || !validBitmapName(e.font)) if (!terminated(e.font, sizeof(e.font)) || !validBitmapName(e.font))
return "Invalid font name"; return "Invalid font name";
if (!printable(e.text, sizeof(e.text)) || !printable(e.suffix, sizeof(e.suffix))) if (!printable(e.text, sizeof(e.text)) || !printable(e.suffix, sizeof(e.suffix)))
return "Texts can only contain plain characters (no accents), up to 32"; return "Texts can only contain plain characters (no accents) and the degree sign, up to 32";
if (!terminated(e.entity, sizeof(e.entity)) || !terminated(e.attribute, sizeof(e.attribute)) || if (!terminated(e.entity, sizeof(e.entity)) || !terminated(e.attribute, sizeof(e.attribute)) ||
!validAttribute(e.attribute)) !validAttribute(e.attribute))
return "Invalid entity or attribute"; return "Invalid entity or attribute";
if (e.decimals < DECIMALS_AS_IS || e.decimals > DECIMALS_MAX) if (e.decimals < DECIMALS_AS_IS || e.decimals > DECIMALS_MAX)
return "Decimals must be 0-4, or as received"; return "Decimals must be 0-4, or as received";
if (e.type == ElementType::Clock && e.format >= CLOCK_FORMAT_COUNT) if (e.format >= formatCount(e.type))
return "Unknown clock format"; return "Unknown format";
if (e.type == ElementType::Date && e.format >= DATE_FORMAT_COUNT) if (!isfinite(e.factor) || e.factor == 0)
return "Unknown date format"; 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";
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::Countdown && e.target >= MAX_TARGET)
return "The countdown date must be before 2100";
if (e.type == ElementType::Image)
{
if (e.entity[0] && !validEntityId(e.entity))
return "Invalid entity id";
char name[ELEMENT_TEXT_SIZE + 1];
snprintf(name, sizeof(name), "%sx", e.text); // a prefix must be the start of a valid name
if (e.entity[0] ? (e.text[0] == ' ' || !validBitmapName(name)) : !validBitmapName(e.text))
return "Images need a bitmap name (or the start of one, with an entity)";
}
} }
return nullptr; return nullptr;
} }
@@ -100,6 +133,71 @@ const PageSlot *activeSlot(const DynamicPage &page, int minute_of_day)
return active; return active;
} }
// ---------------------------------------------------------------------------
// Dates
// ---------------------------------------------------------------------------
// Days since 1970-01-01 (proleptic Gregorian calendar)
static int32_t daysFromCivil(int y, int m, int d)
{
y -= m <= 2;
int32_t era = (y >= 0 ? y : y - 399) / 400;
int32_t yoe = y - era * 400;
int32_t doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1;
int32_t doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
return era * 146097 + doe - 719468;
}
static void civilFromDays(int32_t z, int &y, int &m, int &d)
{
z += 719468;
int32_t era = (z >= 0 ? z : z - 146096) / 146097;
int32_t doe = z - era * 146097;
int32_t yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
int32_t doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
int32_t mp = (5 * doy + 2) / 153;
d = doy - (153 * mp + 2) / 5 + 1;
m = mp < 10 ? mp + 3 : mp - 9;
y = yoe + era * 400 + (m <= 2);
}
static const int32_t DAYS_1970_TO_2000 = 10957;
uint32_t minutesSince2000(const LocalTime &t)
{
int32_t days = daysFromCivil(t.year, t.month, t.day) - DAYS_1970_TO_2000;
if (days < 0)
return 0;
return (uint32_t)days * MINUTES_PER_DAY + t.hour * 60 + t.minute;
}
bool parseTarget(const char *text, uint32_t &minutes)
{
if (text == nullptr || strlen(text) != 16)
return false;
int y, mo, d, h, mi;
char extra;
if (sscanf(text, "%4d-%2d-%2dT%2d:%2d%c", &y, &mo, &d, &h, &mi, &extra) != 5)
return false;
if (y < 2000 || y > 2099 || mo < 1 || mo > 12 || d < 1 || d > 31 || h > 23 || mi > 59 || h < 0 || mi < 0)
return false;
int cy, cm, cd; // rejects e.g. 31 February
int32_t days = daysFromCivil(y, mo, d);
civilFromDays(days, cy, cm, cd);
if (cd != d)
return false;
minutes = (uint32_t)(days - DAYS_1970_TO_2000) * MINUTES_PER_DAY + h * 60 + mi;
return true;
}
void formatTarget(uint32_t minutes, char *out)
{
int y, m, d;
civilFromDays(minutes / MINUTES_PER_DAY + DAYS_1970_TO_2000, y, m, d);
unsigned minute_of_day = minutes % MINUTES_PER_DAY;
snprintf(out, 17, "%04d-%02d-%02dT%02u:%02u", y % 10000, m, d, minute_of_day / 60, minute_of_day % 60);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Texts // Texts
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -122,10 +220,18 @@ static const char *const MONTHS_SHORT[][12] = {
{"jan", "feb", "mrt", "apr", "mei", "jun", "jul", "aug", "sep", "okt", "nov", "dec"}, {"jan", "feb", "mrt", "apr", "mei", "jun", "jul", "aug", "sep", "okt", "nov", "dec"},
{"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}, {"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"},
}; };
// Countdown units: singular, plural
static const char *const DAYS_WORD[][2] = {{"dag", "dagen"}, {"day", "days"}};
static const char *const HOURS_WORD[][2] = {{"uur", "uur"}, {"hour", "hours"}};
static int language(const PageElement &e)
{
return e.language < Language::Count ? (int)e.language : 0;
}
static void formatDate(const PageElement &e, const LocalTime &t, char *out, size_t size) static void formatDate(const PageElement &e, const LocalTime &t, char *out, size_t size)
{ {
int lang = e.language < Language::Count ? (int)e.language : 0; int lang = language(e);
int wd = t.weekday >= 0 && t.weekday < 7 ? t.weekday : 0; int wd = t.weekday >= 0 && t.weekday < 7 ? t.weekday : 0;
int mo = t.month >= 1 && t.month <= 12 ? t.month - 1 : 0; int mo = t.month >= 1 && t.month <= 12 ? t.month - 1 : 0;
switch (e.format) switch (e.format)
@@ -154,50 +260,143 @@ static void formatDate(const PageElement &e, const LocalTime &t, char *out, size
} }
} }
// Rounded to the element's decimals when it is a number; "--" when unknown // Rounded to the element's decimals (after the multiplier) when it is a number; "--" when unknown
static void formatHaValue(const PageElement &e, const char *value, char *out, size_t size) static void formatHaValue(const PageElement &e, const char *value, char *out, size_t size)
{ {
if (value == nullptr || value[0] == '\0' || strcmp(value, "unavailable") == 0 || strcmp(value, "unknown") == 0) if (value == nullptr || value[0] == '\0' || strcmp(value, "unavailable") == 0 || strcmp(value, "unknown") == 0)
value = "--"; value = "--";
char number[24]; char number[24];
if (e.decimals >= 0 && strcmp(value, "--") != 0)
{
char *end; char *end;
double v = strtod(value, &end); double v = strtod(value, &end);
if (end != value && *end == '\0') if (strcmp(value, "--") != 0 && end != value && *end == '\0' && (e.decimals >= 0 || e.factor != 1))
{ {
v *= e.factor;
if (e.decimals >= 0)
snprintf(number, sizeof(number), "%.*f", e.decimals, v); snprintf(number, sizeof(number), "%.*f", e.decimals, v);
if (strcmp(number, "-0") == 0 || strncmp(number, "-0.", 3) == 0) else
{ snprintf(number, sizeof(number), "%.6g", v);
// -0.0 after rounding: no minus sign // -0.0 after rounding: no minus sign
bool zero = true; if (number[0] == '-' && strspn(number + 1, "0.") == strlen(number + 1))
for (const char *c = number + 1; *c; c++)
if (*c != '0' && *c != '.')
zero = false;
if (zero)
memmove(number, number + 1, strlen(number)); memmove(number, number + 1, strlen(number));
}
value = number; value = number;
} }
}
snprintf(out, size, "%s%s%s", e.text, value, e.suffix); snprintf(out, size, "%s%s%s", e.text, value, e.suffix);
} }
void elementText(const PageElement &e, const LocalTime &t, const char *ha_value, char *out, size_t size) // Seconds until the target; negative when it has passed
static int64_t secondsLeft(const PageElement &e, const LocalTime &t)
{
return (int64_t)e.target * 60 - ((int64_t)minutesSince2000(t) * 60 + t.second);
}
static void formatCountdown(const PageElement &e, const LocalTime &t, char *out, size_t size)
{
int64_t left = secondsLeft(e, t);
if (left <= 0)
{
snprintf(out, size, "%s%s", e.text, e.suffix[0] ? e.suffix : "0");
return;
}
int lang = language(e);
long days = (long)(left / 86400), hours = (long)(left % 86400 / 3600), minutes = (long)(left % 3600 / 60),
seconds = (long)(left % 60);
char value[32];
switch (e.format)
{
case COUNTDOWN_DAYS:
{
// Calendar days: tomorrow is 1 day, whatever the time
long calendar = (long)(e.target / MINUTES_PER_DAY) - (long)(minutesSince2000(t) / MINUTES_PER_DAY);
snprintf(value, sizeof(value), "%ld %s", calendar, DAYS_WORD[lang][calendar != 1]);
break;
}
case COUNTDOWN_DAYS_TIME:
if (days)
snprintf(value, sizeof(value), "%ldd %02ld:%02ld", days, hours, minutes);
else
snprintf(value, sizeof(value), "%02ld:%02ld", hours, minutes);
break;
case COUNTDOWN_SECONDS:
if (days)
snprintf(value, sizeof(value), "%ldd %02ld:%02ld:%02ld", days, hours, minutes, seconds);
else
snprintf(value, sizeof(value), "%02ld:%02ld:%02ld", hours, minutes, seconds);
break;
default: // smart: the largest unit
if (days)
snprintf(value, sizeof(value), "%ld %s", days, DAYS_WORD[lang][days != 1]);
else if (hours)
snprintf(value, sizeof(value), "%ld %s", hours, HOURS_WORD[lang][hours != 1]);
else if (minutes)
snprintf(value, sizeof(value), "%ld min", minutes);
else
snprintf(value, sizeof(value), "0:%02ld", seconds);
break;
}
snprintf(out, size, "%s%s", e.text, value);
}
static void formatStopwatch(const PageElement &e, uint32_t ms, char *out, size_t size)
{
unsigned long s = ms / 1000, h = s / 3600, m = s % 3600 / 60, sec = s % 60;
char value[24];
if (e.format == STOPWATCH_H_MM)
snprintf(value, sizeof(value), "%lu:%02lu", h, m);
else if (e.format == STOPWATCH_H_MM_SS || h)
snprintf(value, sizeof(value), "%lu:%02lu:%02lu", h, m, sec);
else
snprintf(value, sizeof(value), "%lu:%02lu", m, sec);
snprintf(out, size, "%s%s%s", e.text, value, e.suffix);
}
void elementText(const PageElement &e, const LocalTime &t, const ElementValues &values, char *out, size_t size)
{ {
switch (e.type) switch (e.type)
{ {
case ElementType::Clock: case ElementType::Clock:
if (e.format == CLOCK_HH_MM_SS || e.format == CLOCK_H_MM_SS)
snprintf(out, size, e.format == CLOCK_H_MM_SS ? "%d:%02d:%02d" : "%02d:%02d:%02d", t.hour, t.minute,
t.second);
else
snprintf(out, size, e.format == CLOCK_H_MM ? "%d:%02d" : "%02d:%02d", t.hour, t.minute); snprintf(out, size, e.format == CLOCK_H_MM ? "%d:%02d" : "%02d:%02d", t.hour, t.minute);
break; break;
case ElementType::Date: case ElementType::Date:
formatDate(e, t, out, size); formatDate(e, t, out, size);
break; break;
case ElementType::HaValue: case ElementType::HaValue:
formatHaValue(e, ha_value, out, size); formatHaValue(e, values.ha_value, out, size);
break;
case ElementType::Countdown:
formatCountdown(e, t, out, size);
break;
case ElementType::Stopwatch:
formatStopwatch(e, values.stopwatch_ms, out, size);
break;
case ElementType::Image:
out[0] = '\0';
break; break;
default: default:
snprintf(out, size, "%s", e.text); snprintf(out, size, "%s", e.text);
break; break;
} }
} }
bool elementShowsSeconds(const PageElement &e, const LocalTime &t, bool stopwatch_running)
{
switch (e.type)
{
case ElementType::Clock:
return e.format == CLOCK_HH_MM_SS || e.format == CLOCK_H_MM_SS;
case ElementType::Stopwatch:
return stopwatch_running && e.format != STOPWATCH_H_MM;
case ElementType::Countdown:
{
int64_t left = secondsLeft(e, t);
if (left <= 0)
return false;
return e.format == COUNTDOWN_SECONDS || (e.format == COUNTDOWN_SMART && left < 60);
}
default:
return false;
}
}
+62 -12
View File
@@ -7,9 +7,11 @@
#include "logic/local_time.h" #include "logic/local_time.h"
// Dynamic pages: a background bitmap that depends on the time of day, with elements // Dynamic pages: a background bitmap that depends on the time of day, with elements
// (clock, date, Home Assistant value, text) drawn on top. Stored as a binary struct on // (clock, date, Home Assistant value, text, countdown, stopwatch, image) drawn on top.
// LittleFS (display/page_store), edited in the web UI. Pure logic, tested in // Stored as a binary struct on LittleFS (display/page_store), edited in the web UI. Pure
// test/test_esp32. // logic, tested in test/test_esp32.
//
// Texts are plain ASCII; character 127 is the degree sign (the web UI shows it as °).
const int PAGE_NAME_SIZE = BITMAP_NAME_MAX + 1; const int PAGE_NAME_SIZE = BITMAP_NAME_MAX + 1;
const int PAGE_SLOTS = 8; const int PAGE_SLOTS = 8;
@@ -18,6 +20,7 @@ const int ELEMENT_TEXT_SIZE = 33;
const int ELEMENT_SUFFIX_SIZE = 17; const int ELEMENT_SUFFIX_SIZE = 17;
const int ELEMENT_ENTITY_SIZE = 65; const int ELEMENT_ENTITY_SIZE = 65;
const int ELEMENT_ATTRIBUTE_SIZE = 33; const int ELEMENT_ATTRIBUTE_SIZE = 33;
const char DEGREE_SIGN = 127;
enum class ElementType : uint8_t enum class ElementType : uint8_t
{ {
@@ -25,6 +28,9 @@ enum class ElementType : uint8_t
Date, Date,
HaValue, // a Home Assistant entity state (or attribute) HaValue, // a Home Assistant entity state (or attribute)
Text, Text,
Countdown, // time left until a date and time
Stopwatch, // the stopwatch (web UI and Home Assistant start, stop and reset it)
Image, // a bitmap, fixed or chosen by a Home Assistant state
Count, Count,
}; };
@@ -56,7 +62,9 @@ enum class Language : uint8_t
// Clock formats // Clock formats
const uint8_t CLOCK_HH_MM = 0; // 08:05 const uint8_t CLOCK_HH_MM = 0; // 08:05
const uint8_t CLOCK_H_MM = 1; // 8:05 const uint8_t CLOCK_H_MM = 1; // 8:05
const uint8_t CLOCK_FORMAT_COUNT = 2; const uint8_t CLOCK_HH_MM_SS = 2; // 08:05:09
const uint8_t CLOCK_H_MM_SS = 3; // 8:05:09
const uint8_t CLOCK_FORMAT_COUNT = 4;
// Date formats (Dutch / English examples) // Date formats (Dutch / English examples)
const uint8_t DATE_WEEKDAY_DAY_MONTH = 0; // do 15 okt / Thu 15 Oct const uint8_t DATE_WEEKDAY_DAY_MONTH = 0; // do 15 okt / Thu 15 Oct
@@ -68,10 +76,28 @@ const uint8_t DATE_WEEKDAY = 5; // donderdag / Thursday
const uint8_t DATE_WEEKDAY_SHORT = 6; // do / Thu const uint8_t DATE_WEEKDAY_SHORT = 6; // do / Thu
const uint8_t DATE_FORMAT_COUNT = 7; const uint8_t DATE_FORMAT_COUNT = 7;
// Countdown formats
const uint8_t COUNTDOWN_SMART = 0; // 12 dagen, 5 uur, 34 min, then 0:45
const uint8_t COUNTDOWN_DAYS = 1; // 12 dagen (calendar days)
const uint8_t COUNTDOWN_DAYS_TIME = 2; // 12d 04:05, below a day 04:05
const uint8_t COUNTDOWN_SECONDS = 3; // 12d 04:05:33, below a day 04:05:33
const uint8_t COUNTDOWN_FORMAT_COUNT = 4;
// Stopwatch formats
const uint8_t STOPWATCH_M_SS = 0; // 4:05, from an hour on 1:04:05
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;
// Home Assistant values: decimals as received // Home Assistant values: decimals as received
const int8_t DECIMALS_AS_IS = -1; 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
const uint8_t SCROLL_SPEED_MIN = 1;
const uint8_t SCROLL_SPEED_MAX = 30;
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
struct PageSlot struct PageSlot
{ {
@@ -87,13 +113,20 @@ struct PageElement
Align align = Align::Left; Align align = Align::Left;
Overlay overlay = Overlay::OnTop; Overlay overlay = Overlay::OnTop;
char font[PAGE_NAME_SIZE] = "Normal"; char font[PAGE_NAME_SIZE] = "Normal";
uint8_t format = 0; // CLOCK_* or DATE_* uint8_t format = 0; // CLOCK_*, DATE_*, COUNTDOWN_* or STOPWATCH_*
Language language = Language::Dutch; Language language = Language::Dutch;
int8_t decimals = DECIMALS_AS_IS; int8_t decimals = DECIMALS_AS_IS;
char text[ELEMENT_TEXT_SIZE] = ""; // Text: the text; HaValue: shown before the value // Text: the text; HaValue, Countdown, Stopwatch: shown before the value; Image: the
char suffix[ELEMENT_SUFFIX_SIZE] = ""; // HaValue: shown after the value (unit) // bitmap name, or with an entity the start of it (the state is added)
char entity[ELEMENT_ENTITY_SIZE] = ""; // HaValue: e.g. sensor.outside_temperature char text[ELEMENT_TEXT_SIZE] = "";
char attribute[ELEMENT_ATTRIBUTE_SIZE] = ""; // HaValue: empty = the state // HaValue, Stopwatch: shown after the value; Countdown: shown when the time has passed
char suffix[ELEMENT_SUFFIX_SIZE] = "";
char entity[ELEMENT_ENTITY_SIZE] = ""; // HaValue, Image: e.g. sensor.outside_temperature
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
uint32_t target = 0; // Countdown: minutes since 2000-01-01 00:00 (local time)
}; };
struct DynamicPage struct DynamicPage
@@ -112,10 +145,27 @@ const char *validatePage(const DynamicPage &page);
// before when it is earlier than the first one. nullptr without slots. // before when it is earlier than the first one. nullptr without slots.
const PageSlot *activeSlot(const DynamicPage &page, int minute_of_day); const PageSlot *activeSlot(const DynamicPage &page, int minute_of_day);
// The text of an element. ha_value is the entity state (nullptr: not known yet, shown as // What a text element needs besides the time
// "--"). out_size must be at least 2. struct ElementValues
void elementText(const PageElement &element, const LocalTime &time, const char *ha_value, char *out, {
const char *ha_value = nullptr; // nullptr: not known yet, shown as "--"
uint32_t stopwatch_ms = 0;
};
// The text of an element (not for images). out_size must be at least 2.
void elementText(const PageElement &element, const LocalTime &time, const ElementValues &values, char *out,
size_t out_size); 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);
// Entity ids look like "sensor.outside_temperature" (lower case, digits, '_', one '.') // Entity ids look like "sensor.outside_temperature" (lower case, digits, '_', one '.')
bool validEntityId(const char *entity); bool validEntityId(const char *entity);
// Countdown targets: minutes since 2000-01-01 00:00 <-> "YYYY-MM-DDTHH:MM" (17 characters,
// the format of an HTML datetime-local input). Years 2000-2099.
bool parseTarget(const char *text, uint32_t &minutes);
// out must hold 17 characters
void formatTarget(uint32_t minutes, char *out);
// The local time in minutes since 2000-01-01 00:00 (seconds left out)
uint32_t minutesSince2000(const LocalTime &time);
+140 -23
View File
@@ -1,5 +1,6 @@
#include "logic/page_render.h" #include "logic/page_render.h"
#include <stdio.h>
#include <string.h> #include <string.h>
#include "logic/bitmap_format.h" #include "logic/bitmap_format.h"
@@ -20,39 +21,155 @@ static void clearBox(uint8_t *bitmap, int x, int y, int width, int height)
setBitmapPixel(bitmap, xx, yy, false); setBitmapPixel(bitmap, xx, yy, false);
} }
void renderPage(const DynamicPage &page, const LocalTime &time, PageResources &resources, uint8_t *bitmap, // The earlier of two "next change" times (0 = none)
ElementBox *boxes) static uint32_t sooner(uint32_t a, uint32_t b)
{
if (a == 0)
return b;
if (b == 0)
return a;
return a < b ? a : b;
}
// Image: a bitmap drawn with its top left corner at x, y. With an entity the name is the
// element's text followed by the state, or followed by "default" when there is no bitmap
// for the state.
static void drawImage(const PageElement &e, PageResources &resources, uint8_t *bitmap, ElementBox &box)
{
uint8_t image[BITMAP_BYTES];
bool found;
if (e.entity[0])
{
char name[64];
const char *state = resources.haValue(e);
snprintf(name, sizeof(name), "%s%s", e.text, state ? state : "");
found = state && resources.background(name, image);
if (!found)
{
snprintf(name, sizeof(name), "%sdefault", e.text);
found = resources.background(name, image);
}
}
else
found = resources.background(e.text, image);
box = ElementBox();
box.x = e.x;
box.y = e.y;
if (!found)
return;
// The box of its dots
int left = BITMAP_WIDTH, right = -1, top = BITMAP_HEIGHT, bottom = -1;
for (int y = 0; y < BITMAP_HEIGHT; y++)
for (int x = 0; x < BITMAP_WIDTH; x++)
if (bitmapPixel(image, x, y))
{
left = x < left ? x : left;
right = x > right ? x : right;
top = y < top ? y : top;
bottom = y > bottom ? y : bottom;
}
if (right < 0)
return;
box.x = e.x + left;
box.y = e.y + top;
box.width = right - left + 1;
box.height = bottom - top + 1;
if (e.overlay == Overlay::Box)
clearBox(bitmap, box.x - 1, box.y - 1, box.width + 2, box.height + 2);
for (int y = top; y <= bottom; y++)
for (int x = left; x <= right; x++)
if (bitmapPixel(image, x, y))
{
int bx = e.x + x, by = e.y + y;
bool on = e.overlay == Overlay::Invert ? !bitmapPixel(bitmap, bx, by) : true;
setBitmapPixel(bitmap, bx, by, on);
}
}
// A text element; returns the ms until it scrolls on (0: it doesn't scroll)
static uint32_t drawTextElement(const PageElement &e, const char *text, uint32_t now_ms, PageResources &resources,
uint8_t *bitmap, ElementBox &box)
{
Font font = usableFont(resources, e.font);
int width = textWidth(font, text), height = fontHeight(font);
DrawMode mode = e.overlay == Overlay::Invert ? DrawMode::Invert : DrawMode::Set;
// Without scrolling the element is as wide as its text, otherwise the window
int area = e.scroll_width ? e.scroll_width : width;
int left = e.x;
if (e.align == Align::Center)
left -= area / 2;
else if (e.align == Align::Right)
left -= area;
box.x = left;
box.y = e.y;
box.width = area;
box.height = height;
if (e.overlay == Overlay::Box)
clearBox(bitmap, left - 1, e.y - 1, area + 2, height + 2);
if (width <= area)
{
// Fits: aligned in the window like the element
int x = left;
if (e.align == Align::Center)
x += (area - width) / 2;
else if (e.align == Align::Right)
x += area - width;
drawText(bitmap, font, x, e.y, text, mode);
return 0;
}
// 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;
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);
return wait ? wait : 1;
}
uint32_t renderPage(const DynamicPage &page, const LocalTime &time, uint32_t now_ms, PageResources &resources,
uint8_t *bitmap, ElementBox *boxes)
{ {
memset(bitmap, 0, BITMAP_BYTES); memset(bitmap, 0, BITMAP_BYTES);
const PageSlot *slot = activeSlot(page, time.hour * 60 + time.minute); const PageSlot *slot = activeSlot(page, time.hour * 60 + time.minute);
if (slot && slot->bitmap[0] && !resources.background(slot->bitmap, bitmap)) if (slot && slot->bitmap[0] && !resources.background(slot->bitmap, bitmap))
memset(bitmap, 0, BITMAP_BYTES); memset(bitmap, 0, BITMAP_BYTES);
uint32_t next = 0;
bool running = resources.stopwatchRunning();
uint32_t stopwatch_ms = resources.stopwatchMs();
for (int i = 0; i < page.element_count && i < PAGE_ELEMENTS; i++) for (int i = 0; i < page.element_count && i < PAGE_ELEMENTS; i++)
{ {
const PageElement &e = page.elements[i]; const PageElement &e = page.elements[i];
const char *ha_value = e.type == ElementType::HaValue ? resources.haValue(e) : nullptr; ElementBox box;
char text[64]; if (e.type == ElementType::Image)
elementText(e, time, ha_value, text, sizeof(text)); drawImage(e, resources, bitmap, box);
else
Font font = usableFont(resources, e.font);
int width = textWidth(font, text), height = fontHeight(font);
int x = e.x;
if (e.align == Align::Center)
x -= width / 2;
else if (e.align == Align::Right)
x -= width;
if (e.overlay == Overlay::Box)
clearBox(bitmap, x - 1, e.y - 1, width + 2, height + 2);
drawText(bitmap, font, x, e.y, text, e.overlay == Overlay::Invert ? DrawMode::Invert : DrawMode::Set);
if (boxes)
{ {
boxes[i].x = x; ElementValues values;
boxes[i].y = e.y; if (e.type == ElementType::HaValue)
boxes[i].width = width; values.ha_value = resources.haValue(e);
boxes[i].height = height; values.stopwatch_ms = stopwatch_ms;
char text[80];
elementText(e, time, values, text, sizeof(text));
next = sooner(next, drawTextElement(e, text, now_ms, resources, bitmap, box));
if (elementShowsSeconds(e, time, running)) // the stopwatch's seconds count from its start
next = sooner(next, e.type == ElementType::Stopwatch ? 1000 - stopwatch_ms % 1000
: 1000 - (time.millisecond % 1000));
else if (e.type == ElementType::Stopwatch && running) // H:MM: the next stopwatch minute
next = sooner(next, 60000 - stopwatch_ms % 60000);
} }
if (boxes)
boxes[i] = box;
} }
return next;
} }
+13 -6
View File
@@ -9,19 +9,22 @@
// Draws a dynamic page into a full screen bitmap (logic/bitmap_format.h). Used for the // Draws a dynamic page into a full screen bitmap (logic/bitmap_format.h). Used for the
// display and for the previews in the web UI. Pure logic, tested in test/test_esp32. // display and for the previews in the web UI. Pure logic, tested in test/test_esp32.
// Where the page gets its bitmaps, fonts and Home Assistant values // Where the page gets its bitmaps, fonts, Home Assistant values and the stopwatch
class PageResources class PageResources
{ {
public: public:
// Loads a background bitmap; false when it is gone (the background is blank then) // Loads a bitmap (background or image); false when it is gone
virtual bool background(const char *name, uint8_t *bitmap) = 0; virtual bool background(const char *name, uint8_t *bitmap) = 0;
// A font by name; a font without data when it is gone ("Normal" is used then) // A font by name; a font without data when it is gone ("Normal" is used then)
virtual Font font(const char *name) = 0; virtual Font font(const char *name) = 0;
// The current value of a Home Assistant element; nullptr when not known // The current value of a Home Assistant element; nullptr when not known
virtual const char *haValue(const PageElement &element) = 0; virtual const char *haValue(const PageElement &element) = 0;
virtual uint32_t stopwatchMs() { return 0; }
virtual bool stopwatchRunning() { return false; }
}; };
// The area an element covers on the screen (for the editor), may be partly off screen // The area an element covers on the screen (for the editor), may be partly off screen.
// For scrolling texts, the window.
struct ElementBox struct ElementBox
{ {
int16_t x = 0; int16_t x = 0;
@@ -30,6 +33,10 @@ struct ElementBox
int16_t height = 0; int16_t height = 0;
}; };
// boxes: nullptr, or room for page.element_count boxes // now_ms: a running millisecond counter (millis()), for scrolling. boxes: nullptr, or room
void renderPage(const DynamicPage &page, const LocalTime &time, PageResources &resources, uint8_t *bitmap, // for page.element_count boxes.
ElementBox *boxes = nullptr); // Returns the ms until the page looks different again (scrolling, seconds, a running
// stopwatch), or 0 when it only changes with the minute or its content (Home Assistant
// values, the stopwatch being started or stopped).
uint32_t renderPage(const DynamicPage &page, const LocalTime &time, uint32_t now_ms, PageResources &resources,
uint8_t *bitmap, ElementBox *boxes = nullptr);
+37
View File
@@ -0,0 +1,37 @@
#pragma once
#include <stdint.h>
// The stopwatch of the dynamic pages, started and stopped in the web UI and Home Assistant.
// Times are millis(). Pure logic, tested in test/test_esp32.
class Stopwatch
{
public:
void start(uint32_t now)
{
if (_running)
return;
_running = true;
_started = now;
}
void stop(uint32_t now)
{
if (!_running)
return;
_before += now - _started;
_running = false;
}
// Back to 0, stopped
void reset()
{
_running = false;
_before = 0;
}
bool running() const { return _running; }
uint32_t elapsed(uint32_t now) const { return _before + (_running ? now - _started : 0); }
private:
bool _running = false;
uint32_t _started = 0;
uint32_t _before = 0; // ms counted before the last start
};
+3 -1
View File
@@ -70,7 +70,8 @@ void haApiWatch(const DynamicPage *page)
for (int i = 0; i < page->element_count; i++) for (int i = 0; i < page->element_count; i++)
{ {
const PageElement &element = page->elements[i]; const PageElement &element = page->elements[i];
if (element.type != ElementType::HaValue || !validEntityId(element.entity)) bool uses_state = element.type == ElementType::HaValue || element.type == ElementType::Image;
if (!uses_state || !validEntityId(element.entity))
continue; continue;
Entry &e = add(element.entity, element.attribute); Entry &e = add(element.entity, element.attribute);
if (!e.watched) if (!e.watched)
@@ -127,6 +128,7 @@ static const char *fetch(const String &entity, const String &attribute, String &
value = ""; value = "";
serializeJson(v, value); // numbers, true/false serializeJson(v, value); // numbers, true/false
} }
value.replace("\xC2\xB0", String(DEGREE_SIGN)); // UTF-8 degree sign: the fonts have it as 127
return nullptr; return nullptr;
} }
+17
View File
@@ -12,6 +12,7 @@
#include "config.h" #include "config.h"
#include "credentials.h" #include "credentials.h"
#include "display/bitmap_store.h" #include "display/bitmap_store.h"
#include "display/page_resources.h"
#include "display/page_store.h" #include "display/page_store.h"
#include "net/ha_page_select.h" #include "net/ha_page_select.h"
#include "system/boot_target.h" #include "system/boot_target.h"
@@ -27,6 +28,8 @@ static HAPageSelect offSelect("FlipDotDisplayOffImage");
static HASensor firmwareSensor("FlipDotDisplayFirmware"); static HASensor firmwareSensor("FlipDotDisplayFirmware");
static HASensor ipSensor("FlipDotDisplayIP"); static HASensor ipSensor("FlipDotDisplayIP");
static HAButton recoveryButton("FlipDotDisplayRecovery"); static HAButton recoveryButton("FlipDotDisplayRecovery");
static HASwitch stopwatchSwitch("FlipDotDisplayStopwatch");
static HAButton stopwatchReset("FlipDotDisplayStopwatchReset");
// Removed entities: their retained discovery config is cleared, so Home Assistant // Removed entities: their retained discovery config is cleared, so Home Assistant
// deletes them instead of showing them as unavailable // deletes them instead of showing them as unavailable
@@ -215,6 +218,17 @@ void homeAssistantBegin(DisplayController &controller, const HomeAssistantSettin
offSelect.onCommand([](const char *option) { offImage.command(option); }); offSelect.onCommand([](const char *option) { offImage.command(option); });
offImage.update(); offImage.update();
stopwatchSwitch.setName("FlipDot Display Stopwatch");
stopwatchSwitch.setIcon("mdi:timer-outline");
stopwatchSwitch.setCurrentState(stopwatch.running());
stopwatchSwitch.onCommand([](bool state, HASwitch *sender) {
appStopwatch(state ? StopwatchAction::Start : StopwatchAction::Stop);
sender->setState(stopwatch.running());
});
stopwatchReset.setName("FlipDot Display Stopwatch reset");
stopwatchReset.setIcon("mdi:timer-refresh-outline");
stopwatchReset.onCommand([](HAButton *) { appStopwatch(StopwatchAction::Reset); });
recoveryButton.setName("FlipDot Display Recovery"); recoveryButton.setName("FlipDot Display Recovery");
recoveryButton.setIcon("mdi:lifebuoy"); recoveryButton.setIcon("mdi:lifebuoy");
recoveryButton.onCommand([](HAButton *) { rebootToRecovery(RecoveryReason::Manual); }); recoveryButton.onCommand([](HAButton *) { rebootToRecovery(RecoveryReason::Manual); });
@@ -260,6 +274,9 @@ void homeAssistantLoop()
firmware.loop(); firmware.loop();
mode.update(); mode.update();
offImage.update(); offImage.update();
// Started or stopped in the web UI
if (stopwatchSwitch.getCurrentState() != stopwatch.running())
stopwatchSwitch.setState(stopwatch.running());
} }
void homeAssistantSetFirmwareStatus(const char *status) void homeAssistantSetFirmwareStatus(const char *status)
+2
View File
@@ -12,6 +12,8 @@
// FlipDot Display IP sensor: the IP address // FlipDot Display IP sensor: the IP address
// FlipDot Display Firmware sensor: version and health, e.g. "2.1.0, ok, 0 crashes, ..." // FlipDot Display Firmware sensor: version and health, e.g. "2.1.0, ok, 0 crashes, ..."
// FlipDot Display Recovery button: restart into the recovery app (for 10 minutes) // FlipDot Display Recovery button: restart into the recovery app (for 10 minutes)
// FlipDot Display Stopwatch switch: the stopwatch of the dynamic pages runs
// FlipDot Display Stopwatch reset button: back to 0
// //
// The broker comes from the settings (web UI); an empty host means BROKER_ADDR, MQTT_USER // The broker comes from the settings (web UI); an empty host means BROKER_ADDR, MQTT_USER
// and MQTT_PASSWORD from secrets.h. Changes need a restart. // and MQTT_PASSWORD from secrets.h. Changes need a restart.
+29 -7
View File
@@ -5,7 +5,7 @@
#include "logic/settings.h" #include "logic/settings.h"
static const char *const TYPES[] = {"clock", "date", "ha", "text"}; static const char *const TYPES[] = {"clock", "date", "ha", "text", "countdown", "stopwatch", "image"};
static const char *const ALIGNS[] = {"left", "center", "right"}; static const char *const ALIGNS[] = {"left", "center", "right"};
static const char *const OVERLAYS[] = {"top", "box", "invert"}; static const char *const OVERLAYS[] = {"top", "box", "invert"};
static const char *const LANGUAGES[] = {"nl", "en"}; static const char *const LANGUAGES[] = {"nl", "en"};
@@ -18,6 +18,15 @@ template <size_t N> static int indexOf(const char *const (&names)[N], const char
return -1; return -1;
} }
static const char *DEGREE_UTF8 = "\xC2\xB0";
static String withDegrees(const char *text)
{
String s = text;
s.replace(String(DEGREE_SIGN), DEGREE_UTF8);
return s;
}
void pageToJson(const DynamicPage &page, JsonObject out) void pageToJson(const DynamicPage &page, JsonObject out)
{ {
char time[6]; char time[6];
@@ -43,20 +52,27 @@ void pageToJson(const DynamicPage &page, JsonObject out)
o["format"] = e.format; o["format"] = e.format;
o["language"] = LANGUAGES[(int)e.language]; o["language"] = LANGUAGES[(int)e.language];
o["decimals"] = e.decimals; o["decimals"] = e.decimals;
o["text"] = e.text; o["text"] = withDegrees(e.text);
o["suffix"] = e.suffix; o["suffix"] = withDegrees(e.suffix);
o["entity"] = e.entity; o["entity"] = e.entity;
o["attribute"] = e.attribute; o["attribute"] = e.attribute;
o["factor"] = e.factor;
o["scroll_width"] = e.scroll_width;
o["scroll_speed"] = e.scroll_speed;
char target[17];
formatTarget(e.target, target);
o["target"] = String(target);
} }
} }
// False when the text does not fit // False when the text does not fit. The degree sign (UTF-8) becomes DEGREE_SIGN.
static bool copyText(JsonVariantConst value, char *out, size_t size) static bool copyText(JsonVariantConst value, char *out, size_t size)
{ {
const char *text = value | ""; String text = value | "";
if (strlen(text) >= size) text.replace(DEGREE_UTF8, String(DEGREE_SIGN));
if (text.length() >= size)
return false; return false;
strcpy(out, text); strcpy(out, text.c_str());
return true; return true;
} }
@@ -95,6 +111,12 @@ const char *pageFromJson(JsonVariantConst in, DynamicPage &page)
e.y = constrain(o["y"] | 0, -1000, 1000); e.y = constrain(o["y"] | 0, -1000, 1000);
e.format = constrain(o["format"] | 0, 0, 255); e.format = constrain(o["format"] | 0, 0, 255);
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.scroll_width = constrain(o["scroll_width"] | 0, 0, 255);
e.scroll_speed = constrain(o["scroll_speed"] | 8, 0, 255);
const char *target = o["target"] | "";
if (e.type == ElementType::Countdown && !parseTarget(target, e.target))
return "Countdown: enter a date and time";
if (!copyText(o["font"], e.font, sizeof(e.font)) || !copyText(o["text"], e.text, sizeof(e.text)) || if (!copyText(o["font"], e.font, sizeof(e.font)) || !copyText(o["text"], e.text, sizeof(e.text)) ||
!copyText(o["suffix"], e.suffix, sizeof(e.suffix)) || !copyText(o["entity"], e.entity, sizeof(e.entity)) || !copyText(o["suffix"], e.suffix, sizeof(e.suffix)) || !copyText(o["entity"], e.entity, sizeof(e.entity)) ||
!copyText(o["attribute"], e.attribute, sizeof(e.attribute))) !copyText(o["attribute"], e.attribute, sizeof(e.attribute)))
+3 -1
View File
@@ -8,7 +8,9 @@
// {slots: [{start: "08:00", bitmap: "Pikachu"}], // {slots: [{start: "08:00", bitmap: "Pikachu"}],
// elements: [{type: "clock"|"date"|"ha"|"text", x, y, align: "left"|"center"|"right", // elements: [{type: "clock"|"date"|"ha"|"text", x, y, align: "left"|"center"|"right",
// overlay: "top"|"box"|"invert", font, format, language: "nl"|"en", // overlay: "top"|"box"|"invert", font, format, language: "nl"|"en",
// decimals (-1 = as received), text, suffix, entity, attribute}]} // decimals (-1 = as received), text, suffix, entity, attribute, factor,
// scroll_width, scroll_speed, target ("YYYY-MM-DDTHH:MM")}]}
// type also "countdown", "stopwatch", "image". Texts use the UTF-8 degree sign.
void pageToJson(const DynamicPage &page, JsonObject out); void pageToJson(const DynamicPage &page, JsonObject out);
// Returns nullptr or a message for the user. Validates the page (validatePage()). // Returns nullptr or a message for the user. Validates the page (validatePage()).
+48 -21
View File
@@ -50,6 +50,8 @@ button.d{color:var(--bad)}
<section id="t-show"><div class="card"><h2>What to show during the day</h2> <section id="t-show"><div class="card"><h2>What to show during the day</h2>
<div id="livebox" class="row" style="display:none"><b>Live mode is on.</b><button class="s" onclick="stopLive()">Back to normal</button></div> <div id="livebox" class="row" style="display:none"><b>Live mode is on.</b><button class="s" onclick="stopLive()">Back to normal</button></div>
<div class="grid" id="pages"></div> <div class="grid" id="pages"></div>
<div class="row"><b>Stopwatch</b> <span id="swtime">0:00</span><button class="s" onclick="stopwatch('start')">Start</button>
<button class="s" onclick="stopwatch('stop')">Stop</button><button class="s" onclick="stopwatch('reset')">Reset</button></div>
<p class="hint">The schedule (Settings) decides when the display is on. The page can also be chosen with the Home Assistant "Mode" dropdown.</p></div> <p class="hint">The schedule (Settings) decides when the display is on. The page can also be chosen with the Home Assistant "Mode" dropdown.</p></div>
<div class="card"><h2>Left on the display when it switches off</h2><div class="grid" id="offpages"></div> <div class="card"><h2>Left on the display when it switches off</h2><div class="grid" id="offpages"></div>
<p class="hint">Drawn at night and when the Home Assistant Power switch turns the display off; the dots keep it without power. Also in Home Assistant: "Off image".</p></div></section> <p class="hint">Drawn at night and when the Home Assistant Power switch turns the display off; the dots keep it without power. Also in Home Assistant: "Off image".</p></div></section>
@@ -68,8 +70,10 @@ button.d{color:var(--bad)}
<p class="hint">Each background is shown from its time until the next one; the last one continues after midnight.</p> <p class="hint">Each background is shown from its time until the next one; the last one continues after midnight.</p>
<h3>Elements</h3><div id="pe_elems"></div> <h3>Elements</h3><div id="pe_elems"></div>
<div class="row"><button class="s" onclick="peAdd('clock')">+ Clock</button><button class="s" onclick="peAdd('date')">+ Date</button> <div class="row"><button class="s" onclick="peAdd('clock')">+ Clock</button><button class="s" onclick="peAdd('date')">+ Date</button>
<button class="s" onclick="peAdd('ha')">+ Home Assistant value</button><button class="s" onclick="peAdd('text')">+ Text</button></div> <button class="s" onclick="peAdd('ha')">+ Home Assistant value</button><button class="s" onclick="peAdd('text')">+ Text</button>
<p class="hint">Drag elements on the preview, or select one and use the arrow keys. X is the left edge, the middle or the right edge (Align). Box clears the dots behind the element, Inverted flips the dots below it. Home Assistant values need the URL and token in the Home Assistant tab.</p></div> <button class="s" onclick="peAdd('countdown')">+ Countdown</button><button class="s" onclick="peAdd('stopwatch')">+ Stopwatch</button>
<button class="s" onclick="peAdd('image')">+ Image</button></div>
<p class="hint">Drag elements on the preview, or select one and use the arrow keys. X is the left edge, the middle or the right edge (Align). Box clears the dots behind the element, Inverted flips the dots below it. Home Assistant values need the URL and token in the Home Assistant tab. Type &deg; for degrees. A text wider than its scroll window scrolls; moving pages (scrolling, seconds) are shown without the refresh effect, as fast as the dots can flip.</p></div>
<div class="card"><h2>Fonts</h2><div class="form" id="fontlist"></div> <div class="card"><h2>Fonts</h2><div class="form" id="fontlist"></div>
<h3>Add a font</h3> <h3>Add a font</h3>
<div class="row"><label><input type="file" id="f_file" accept=".ttf,.otf,.woff,.woff2"> TTF / OTF file</label></div> <div class="row"><label><input type="file" id="f_file" accept=".ttf,.otf,.woff,.woff2"> TTF / OTF file</label></div>
@@ -81,7 +85,7 @@ button.d{color:var(--bad)}
<label>Threshold <input type="range" id="f_thr" min="1" max="254" value="128"></label> <label>Threshold <input type="range" id="f_thr" min="1" max="254" value="128"></label>
<label><input type="checkbox" id="f_mono" checked> equal width digits</label></div> <label><input type="checkbox" id="f_mono" checked> equal width digits</label></div>
<div id="f_prev" style="display:none"><canvas id="f_canvas" width="840" height="190"></canvas> <div id="f_prev" style="display:none"><canvas id="f_canvas" width="840" height="190"></canvas>
<div class="row"><input id="f_sample" value="12:34 do 15 okt" maxlength="40"><input id="f_name" placeholder="font name" maxlength="24"> <div class="row"><input id="f_sample" value="12:34 do 15 okt 17.5&deg;C" maxlength="40"><input id="f_name" placeholder="font name" maxlength="24">
<button class="b" onclick="fontSave()">Save font</button></div></div> <button class="b" onclick="fontSave()">Save font</button></div></div>
<p class="hint">Converted in the browser to dots. Pixel fonts look best at their own size. Height: the dots of one line (19 at most); Baseline: the row the letters stand on; Size: the font size it is drawn with. Only plain characters (no accents).</p></div></section> <p class="hint">Converted in the browser to dots. Pixel fonts look best at their own size. Height: the dots of one line (19 at most); Baseline: the row the letters stand on; Size: the font size it is drawn with. Only plain characters (no accents).</p></div></section>
@@ -183,7 +187,7 @@ async function pollScreen(){try{const s=await api('/api/screen');draw($('mirror'
setTimeout(pollScreen,document.hidden?5000:1500)} setTimeout(pollScreen,document.hidden?5000:1500)}
async function pollStatus(){try{status=await api('/api/status'); async function pollStatus(){try{status=await api('/api/status');
$('statusline').textContent=(status.time||'--:--')+' | WiFi '+(status.wifi.rssi||'')+' dBm | '+status.firmware; $('statusline').textContent=(status.time||'--:--')+' | WiFi '+(status.wifi.rssi||'')+' dBm | '+status.firmware;
$('livebox').style.display=status.live?'flex':'none';sysinfo()}catch(e){}setTimeout(pollStatus,5000)} $('livebox').style.display=status.live?'flex':'none';swShow(status.stopwatch);sysinfo()}catch(e){}setTimeout(pollStatus,5000)}
function sysinfo(){if(!status)return;const r=[['Firmware',status.firmware],['Last restart',status.reset_reason],['Uptime',Math.floor(status.uptime/3600)+' h '+Math.floor(status.uptime%3600/60)+' min'], function sysinfo(){if(!status)return;const r=[['Firmware',status.firmware],['Last restart',status.reset_reason],['Uptime',Math.floor(status.uptime/3600)+' h '+Math.floor(status.uptime%3600/60)+' min'],
['WiFi',status.wifi.ssid+' ('+status.wifi.ip+', '+status.wifi.rssi+' dBm)'],['Home Assistant',status.ha.enabled?(status.ha.connected?'connected':'not connected'):'disabled'], ['WiFi',status.wifi.ssid+' ('+status.wifi.ip+', '+status.wifi.rssi+' dBm)'],['Home Assistant',status.ha.enabled?(status.ha.connected?'connected':'not connected'):'disabled'],
['HA power switch',status.power_enabled?'on':'off'], ['HA power switch',status.power_enabled?'on':'off'],
@@ -219,13 +223,20 @@ async function selectOff(p){const r=await post('/api/settings',{off_page:p});set
async function delBitmap(n){if(!confirm('Delete '+n+'?'))return;await post('/api/bitmap/delete',{name:n});msg('Deleted');settings=await api('/api/settings');loadLists()} async function delBitmap(n){if(!confirm('Delete '+n+'?'))return;await post('/api/bitmap/delete',{name:n});msg('Deleted');settings=await api('/api/settings');loadLists()}
async function delPage(n){if(!confirm('Delete page '+n+'?'))return;await post('/api/pages/delete',{name:n});msg('Deleted');if(pe&&same(pe.orig,n))peClose();settings=await api('/api/settings');loadLists()} async function delPage(n){if(!confirm('Delete page '+n+'?'))return;await post('/api/pages/delete',{name:n});msg('Deleted');if(pe&&same(pe.orig,n))peClose();settings=await api('/api/settings');loadLists()}
async function delFont(n){if(!confirm('Delete font '+n+'?'))return;await post('/api/fonts/delete',{name:n});msg('Deleted');loadLists()} async function delFont(n){if(!confirm('Delete font '+n+'?'))return;await post('/api/fonts/delete',{name:n});msg('Deleted');loadLists()}
function swText(ms){const s=Math.floor(ms/1000),h=Math.floor(s/3600),m=Math.floor(s%3600/60),p=n=>String(n).padStart(2,'0');return(h?h+':'+p(m):m)+':'+p(s%60)}
function swShow(sw){if(sw)$('swtime').textContent=swText(sw.ms)+(sw.running?' (running)':'')}
async function stopwatch(action){swShow(await post('/api/stopwatch',{action}));if(pe)pePreview()}
async function live(bits){await post('/api/live',{data:toHex(bits)});msg('Shown on the display')} async function live(bits){await post('/api/live',{data:toHex(bits)});msg('Shown on the display')}
async function stopLive(){await post('/api/live/stop');msg('Back to normal')} async function stopLive(){await post('/api/live/stop');msg('Back to normal')}
// page editor: the ESP32 draws the preview and returns the element boxes // page editor: the ESP32 draws the preview and returns the element boxes
const ETYPES={clock:'Clock',date:'Date',ha:'Home Assistant value',text:'Text'}; const ETYPES={clock:'Clock',date:'Date',ha:'Home Assistant value',text:'Text',countdown:'Countdown',stopwatch:'Stopwatch',image:'Image'};
const TEXTTYPES=['clock','date','ha','text','countdown','stopwatch'];
const CDF=[['smart: 12 dagen, 5 uur, 34 min, 0:45','smart: 12 days, 5 hours, 34 min, 0:45'],['days: 12 dagen','days: 12 days'],['12d 04:05','12d 04:05'],['12d 04:05:33 (every second)','12d 04:05:33 (every second)']];
const DATEF=[['do 15 okt','Thu 15 Oct'],['15 okt','15 Oct'],['15 oktober','15 October'],['15-10','15-10'],['15-10-2026','15-10-2026'],['donderdag','Thursday'],['do','Thu']]; const DATEF=[['do 15 okt','Thu 15 Oct'],['15 okt','15 Oct'],['15 oktober','15 October'],['15-10','15-10'],['15-10-2026','15-10-2026'],['donderdag','Thursday'],['do','Thu']];
let pe=null,peTimer=null,peSeq=0,peData=null,peDrag=null; let pe=null,peTimer=null,peSeq=0,peData=null,peDrag=null;
function newEl(t){return{type:t,x:t=='clock'?27:0,y:t=='clock'?5:0,align:'left',overlay:'top',font:'Normal',format:0,language:'nl',decimals:-1,text:t=='text'?'Hallo':'',suffix:'',entity:'',attribute:''}} function tomorrow(){const d=new Date(Date.now()+864e5),p=n=>String(n).padStart(2,'0');return d.getFullYear()+'-'+p(d.getMonth()+1)+'-'+p(d.getDate())+'T12:00'}
function newEl(t){return{type:t,x:t=='clock'?27:0,y:t=='clock'?5:0,align:'left',overlay:'top',font:'Normal',format:0,language:'nl',decimals:-1,
text:t=='text'?'Hallo':'',suffix:'',entity:'',attribute:'',factor:1,scroll_width:0,scroll_speed:8,target:tomorrow()}}
function peShow(title,name){$('pe_title').textContent=title;$('pe_name').value=name;$('pe').style.display='block';peData=null;peBuild();pePreview();$('pe').scrollIntoView({behavior:'smooth'})} function peShow(title,name){$('pe_title').textContent=title;$('pe_name').value=name;$('pe').style.display='block';peData=null;peBuild();pePreview();$('pe').scrollIntoView({behavior:'smooth'})}
function peOpen(pg,copy){pe={orig:copy?'':pg.name,page:JSON.parse(JSON.stringify(pg.page)),sel:-1,boxes:[],pos:[]};peShow(copy?'Copy of '+pg.name:'Edit '+pg.name,copy?'':pg.name)} function peOpen(pg,copy){pe={orig:copy?'':pg.name,page:JSON.parse(JSON.stringify(pg.page)),sel:-1,boxes:[],pos:[]};peShow(copy?'Copy of '+pg.name:'Edit '+pg.name,copy?'':pg.name)}
function peNew(){pe={orig:'',page:{slots:[{start:'00:00',bitmap:''}],elements:[newEl('clock')]},sel:-1,boxes:[],pos:[]};peShow('New page','')} function peNew(){pe={orig:'',page:{slots:[{start:'00:00',bitmap:''}],elements:[newEl('clock')]},sel:-1,boxes:[],pos:[]};peShow('New page','')}
@@ -233,6 +244,7 @@ function peClose(){pe=null;$('pe').style.display='none';$('t-pages').scrollIntoV
function peBuild(){peSlots();peElems()} function peBuild(){peSlots();peElems()}
function field(label,input){return mk('label',{},label+' ',input)} function field(label,input){return mk('label',{},label+' ',input)}
function inp(o,k,type,props){const i=mk('input',Object.assign({type:type||'text',value:o[k]},props||{}));i.oninput=()=>{o[k]=type=='number'?Math.round(+i.value||0):i.value;pePreview()};return i} function inp(o,k,type,props){const i=mk('input',Object.assign({type:type||'text',value:o[k]},props||{}));i.oninput=()=>{o[k]=type=='number'?Math.round(+i.value||0):i.value;pePreview()};return i}
function inpf(o,k,props){const i=mk('input',Object.assign({type:'number',step:'any',value:o[k]},props||{}));i.oninput=()=>{if(i.value!==''&&+i.value)o[k]=+i.value;pePreview()};return i}
function sel(o,k,opts,after){const s=mk('select');opts.forEach(([v,t])=>s.append(mk('option',{value:v,textContent:t})));s.value=o[k]; function sel(o,k,opts,after){const s=mk('select');opts.forEach(([v,t])=>s.append(mk('option',{value:v,textContent:t})));s.value=o[k];
s.onchange=()=>{o[k]=typeof o[k]=='number'?+s.value:s.value;if(after)after();pePreview()};return s} s.onchange=()=>{o[k]=typeof o[k]=='number'?+s.value:s.value;if(after)after();pePreview()};return s}
function peSlots(){const d=$('pe_slots');d.innerHTML='';const opts=[['','Blank']].concat(bitmapList.map(b=>[b.name,b.name])); function peSlots(){const d=$('pe_slots');d.innerHTML='';const opts=[['','Blank']].concat(bitmapList.map(b=>[b.name,b.name]));
@@ -242,21 +254,36 @@ function peSlots(){const d=$('pe_slots');d.innerHTML='';const opts=[['','Blank']
function peAddSlot(){if(pe.page.slots.length>=8){msg('8 time slots at most',true);return}pe.page.slots.push({start:'12:00',bitmap:''});peSort()} function peAddSlot(){if(pe.page.slots.length>=8){msg('8 time slots at most',true);return}pe.page.slots.push({start:'12:00',bitmap:''});peSort()}
function bySlot(a,b){return a.start<b.start?-1:a.start>b.start?1:0} function bySlot(a,b){return a.start<b.start?-1:a.start>b.start?1:0}
function peSort(){pe.page.slots.sort(bySlot);peSlots();pePreview()} function peSort(){pe.page.slots.sort(bySlot);peSlots();pePreview()}
function peElems(){const d=$('pe_elems');d.innerHTML=''; function peElems(){const d=$('pe_elems');d.innerHTML='';d.append(mk('datalist',{id:'bmnames'},...bitmapList.map(b=>mk('option',{value:b.name}))));
pe.page.elements.forEach((e,i)=>{const box=mk('div',{className:'el'+(i==pe.sel?' sel':'')});box.onclick=()=>{if(pe.sel!=i){pe.sel=i;peMark()}}; pe.page.elements.forEach((e,i)=>{const box=mk('div',{className:'el'+(i==pe.sel?' sel':'')});box.onclick=()=>{if(pe.sel!=i){pe.sel=i;peMark()}};
const rows=[mk('div',{className:'row'},mk('b',{textContent:(i+1)+'. '+ETYPES[e.type]}),btn('Remove',()=>{pe.page.elements.splice(i,1);pe.sel=-1;peElems();pePreview()},'d'))]; const rows=[mk('div',{className:'row'},mk('b',{textContent:(i+1)+'. '+ETYPES[e.type]}),btn('Remove',()=>{pe.page.elements.splice(i,1);pe.sel=-1;peElems();pePreview()},'d'))];
if(e.type=='clock')rows.push(mk('div',{className:'row'},field('Format',sel(e,'format',[[0,'08:05'],[1,'8:05']])))); const row=(...k)=>rows.push(mk('div',{className:'row'},...k));
if(e.type=='date')rows.push(mk('div',{className:'row'},field('Format',sel(e,'format',DATEF.map((x,k)=>[k,x[e.language=='en'?1:0]]))), if(e.type=='clock')row(field('Format',sel(e,'format',[[0,'08:05'],[1,'8:05'],[2,'08:05:09 (every second)'],[3,'8:05:09 (every second)']])));
field('Language',sel(e,'language',[['nl','Nederlands'],['en','English']],peElems)))); if(e.type=='date')row(field('Format',sel(e,'format',DATEF.map((x,k)=>[k,x[e.language=='en'?1:0]]))),
if(e.type=='ha'){rows.push(mk('div',{className:'row'},field('Entity',inp(e,'entity','text',{placeholder:'sensor.outside_temperature',size:28,maxLength:64})), field('Language',sel(e,'language',[['nl','Nederlands'],['en','English']],peElems)));
field('Attribute',inp(e,'attribute','text',{placeholder:'(the state)',size:12,maxLength:32})),btn('Test',()=>haTest(e)))); if(e.type=='ha'){row(field('Entity',inp(e,'entity','text',{placeholder:'sensor.outside_temperature',size:28,maxLength:64})),
rows.push(mk('div',{className:'row'},field('Before',inp(e,'text','text',{size:8,maxLength:32})),field('After',inp(e,'suffix','text',{size:6,maxLength:16})), field('Attribute',inp(e,'attribute','text',{placeholder:'(the state)',size:12,maxLength:32})),btn('Test',()=>haTest(e)));
field('Decimals',sel(e,'decimals',[[-1,'as received'],[0,'0'],[1,'1'],[2,'2'],[3,'3'],[4,'4']]))))} row(field('Before',inp(e,'text','text',{size:8,maxLength:32})),field('After',inp(e,'suffix','text',{size:6,maxLength:16})),
if(e.type=='text')rows.push(mk('div',{className:'row'},field('Text',inp(e,'text','text',{size:24,maxLength:32})))); field('Multiply by',inpf(e,'factor',{style:'width:6em'})),field('Decimals',sel(e,'decimals',[[-1,'as received'],[0,'0'],[1,'1'],[2,'2'],[3,'3'],[4,'4']])))}
if(e.type=='text')row(field('Text',inp(e,'text','text',{size:24,maxLength:32})));
if(e.type=='countdown'){row(field('Until',inp(e,'target','datetime-local')),field('Format',sel(e,'format',CDF.map((x,k)=>[k,x[e.language=='en'?1:0]]))),
field('Language',sel(e,'language',[['nl','Nederlands'],['en','English']],peElems)));
row(field('Before',inp(e,'text','text',{size:10,maxLength:32})),field('When passed',inp(e,'suffix','text',{size:8,maxLength:16,placeholder:'0'})))}
if(e.type=='stopwatch'){row(field('Format',sel(e,'format',[[0,'4:05 (every second)'],[1,'0:04 (hours, minutes)'],[2,'0:04:05 (every second)']])),
field('Before',inp(e,'text','text',{size:8,maxLength:32})),field('After',inp(e,'suffix','text',{size:6,maxLength:16})));
row(btn('Start',()=>stopwatch('start')),btn('Stop',()=>stopwatch('stop')),btn('Reset',()=>stopwatch('reset')))}
if(e.type=='image'){const name=inp(e,'text','text',{size:20,maxLength:24,placeholder:e.entity?'weather ':'name'});name.setAttribute('list','bmnames');
row(field(e.entity?'Bitmap name start':'Bitmap',name),field('Entity (optional)',inp(e,'entity','text',{placeholder:'weather.knmi_home',size:24,maxLength:64})),
field('Attribute',inp(e,'attribute','text',{placeholder:'(the state)',size:10,maxLength:32})),btn('Test',()=>haTest(e)));
rows.push(mk('p',{className:'hint',textContent:'With an entity the bitmap is the name start + the state (e.g. "weather " + "cloudy" = "weather cloudy"), or the name start + "default" when there is none. The image is drawn with its top left corner at X, Y.'}))}
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 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)']);
rows.push(mk('div',{className:'row'},field('X',inp(e,'x','number',{style:'width:5em'})),field('Y',inp(e,'y','number',{style:'width:5em'})), const pos=[field('X',inp(e,'x','number',{style:'width:5em'})),field('Y',inp(e,'y','number',{style:'width:5em'}))];
field('Align',sel(e,'align',[['left','left'],['center','center'],['right','right']])),field('Font',sel(e,'font',fonts)), const ovl=field('Overlay',sel(e,'overlay',[['top','on top'],['box','box (clear behind)'],['invert','inverted']]));
field('Overlay',sel(e,'overlay',[['top','on top'],['box','box (clear behind)'],['invert','inverted']])))); 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')}
else row(...pos,ovl);
box.append(...rows);d.append(box)})} box.append(...rows);d.append(box)})}
function peAdd(t){if(pe.page.elements.length>=8){msg('8 elements at most',true);return}pe.page.elements.push(newEl(t));pe.sel=pe.page.elements.length-1;peElems();pePreview()} function peAdd(t){if(pe.page.elements.length>=8){msg('8 elements at most',true);return}pe.page.elements.push(newEl(t));pe.sel=pe.page.elements.length-1;peElems();pePreview()}
function peMark(){document.querySelectorAll('#pe_elems .el').forEach((d,i)=>d.classList.toggle('sel',i==pe.sel));peDraw()} function peMark(){document.querySelectorAll('#pe_elems .el').forEach((d,i)=>d.classList.toggle('sel',i==pe.sel));peDraw()}
@@ -297,18 +324,18 @@ $('f_h').oninput=()=>{const h=+$('f_h').value;$('f_base').value=Math.round(h*0.8
function fontConvert(){if(!fontFam)return;const h=Math.min(19,Math.max(4,Math.round(+$('f_h').value||10))),size=+$('f_size').value||h,base=+$('f_base').value||h,thr=+$('f_thr').value; function fontConvert(){if(!fontFam)return;const h=Math.min(19,Math.max(4,Math.round(+$('f_h').value||10))),size=+$('f_size').value||h,base=+$('f_base').value||h,thr=+$('f_thr').value;
const c=document.createElement('canvas');c.width=Math.ceil(size*2)+16;c.height=h;const g=c.getContext('2d',{willReadFrequently:true});g.font=size+'px "'+fontFam+'"';g.textBaseline='alphabetic'; const c=document.createElement('canvas');c.width=Math.ceil(size*2)+16;c.height=h;const g=c.getContext('2d',{willReadFrequently:true});g.font=size+'px "'+fontFam+'"';g.textBaseline='alphabetic';
const glyphs=[]; const glyphs=[];
for(let k=32;k<127;k++){const ch=String.fromCharCode(k);g.clearRect(0,0,c.width,h);g.fillStyle='#fff';g.fillText(ch,8,base); for(let k=32;k<128;k++){const ch=k==127?'\u00b0':String.fromCharCode(k);g.clearRect(0,0,c.width,h);g.fillStyle='#fff';g.fillText(ch,8,base);
const d=g.getImageData(0,0,c.width,h).data,on=(x,y)=>d[(y*c.width+x)*4+3]>thr;let x0=c.width,x1=-1; const d=g.getImageData(0,0,c.width,h).data,on=(x,y)=>d[(y*c.width+x)*4+3]>thr;let x0=c.width,x1=-1;
for(let x=0;x<c.width;x++)for(let y=0;y<h;y++)if(on(x,y)){x0=Math.min(x0,x);x1=Math.max(x1,x)} for(let x=0;x<c.width;x++)for(let y=0;y<h;y++)if(on(x,y)){x0=Math.min(x0,x);x1=Math.max(x1,x)}
let w=x1<0?0:Math.min(32,x1-x0+1);const blank=k==32;if(blank)w=Math.min(32,Math.max(1,Math.round(g.measureText(' ').width))); let w=x1<0?0:Math.min(32,x1-x0+1);const blank=k==32;if(blank)w=Math.min(32,Math.max(1,Math.round(g.measureText(' ').width)));
const rows=[];for(let y=0;y<h;y++){const r=[];for(let x=0;x<w;x++)r.push(!blank&&on(x0+x,y)?1:0);rows.push(r)}glyphs.push({w,rows})} const rows=[];for(let y=0;y<h;y++){const r=[];for(let x=0;x<w;x++)r.push(!blank&&on(x0+x,y)?1:0);rows.push(r)}glyphs.push({w,rows})}
if($('f_mono').checked){const dg=glyphs.slice(16,26),mw=Math.max(...dg.map(x=>x.w)); if($('f_mono').checked){const dg=glyphs.slice(16,26),mw=Math.max(...dg.map(x=>x.w));
dg.forEach(x=>{const l=Math.floor((mw-x.w)/2),r=mw-x.w-l;x.rows=x.rows.map(row=>Array(l).fill(0).concat(row,Array(r).fill(0)));x.w=mw})} dg.forEach(x=>{const l=Math.floor((mw-x.w)/2),r=mw-x.w-l;x.rows=x.rows.map(row=>Array(l).fill(0).concat(row,Array(r).fill(0)));x.w=mw})}
const sp=Math.min(4,Math.max(0,Math.round(+$('f_sp').value||0))),bytes=[70,68,70,49,h,sp,32,95].concat(glyphs.map(x=>x.w)); const sp=Math.min(4,Math.max(0,Math.round(+$('f_sp').value||0))),bytes=[70,68,70,49,h,sp,32,96].concat(glyphs.map(x=>x.w));
glyphs.forEach(x=>x.rows.forEach(row=>{for(let i=0;i<x.w;i+=8){let v=0;for(let b=0;b<8;b++)if(row[i+b])v|=0x80>>b;bytes.push(v)}})); glyphs.forEach(x=>x.rows.forEach(row=>{for(let i=0;i<x.w;i+=8){let v=0;for(let b=0;b<8;b++)if(row[i+b])v|=0x80>>b;bytes.push(v)}}));
fontData={bytes,glyphs,h,sp};fontPreview()} fontData={bytes,glyphs,h,sp};fontPreview()}
function fontPreview(){const b=new Uint8Array(W*H);let x=0,first=true; function fontPreview(){const b=new Uint8Array(W*H);let x=0,first=true;
for(const ch of $('f_sample').value){const k=ch.charCodeAt(0)-32;if(k<0||k>94)continue;const gl=fontData.glyphs[k];if(!gl.w)continue;if(!first)x+=fontData.sp;first=false; for(const ch of $('f_sample').value){const k=ch=='\u00b0'?95:ch.charCodeAt(0)-32;if(k<0||k>95)continue;const gl=fontData.glyphs[k];if(!gl.w)continue;if(!first)x+=fontData.sp;first=false;
for(let y=0;y<fontData.h;y++)for(let i=0;i<gl.w;i++)if(gl.rows[y][i]&&x+i<W)b[y*W+x+i]=1;x+=gl.w} for(let y=0;y<fontData.h;y++)for(let i=0;i<gl.w;i++)if(gl.rows[y][i]&&x+i<W)b[y*W+x+i]=1;x+=gl.w}
draw($('f_canvas'),b)} draw($('f_canvas'),b)}
async function fontSave(){const n=$('f_name').value.trim();if(!n||!fontData){msg('Choose a font file and a name',true);return} async function fontSave(){const n=$('f_name').value.trim();if(!n||!fontData){msg('Choose a font file and a name',true);return}
+30 -2
View File
@@ -161,6 +161,9 @@ static void handleStatus()
doc["page"] = appSettings().page; doc["page"] = appSettings().page;
doc["off_page"] = appSettings().off_page; doc["off_page"] = appSettings().off_page;
doc["power_enabled"] = controller.powerEnabled(); doc["power_enabled"] = controller.powerEnabled();
JsonObject sw = doc["stopwatch"].to<JsonObject>();
sw["running"] = stopwatch.running();
sw["ms"] = stopwatch.elapsed(millis());
JsonObject ha = doc["ha"].to<JsonObject>(); JsonObject ha = doc["ha"].to<JsonObject>();
ha["enabled"] = appSettings().home_assistant.enabled; ha["enabled"] = appSettings().home_assistant.enabled;
@@ -282,7 +285,7 @@ static void handlePages()
{ {
if (!loadPage(info.name.c_str(), *page)) if (!loadPage(info.name.c_str(), *page))
continue; continue;
renderPage(*page, time, pageResources, bitmap); renderPage(*page, time, millis(), pageResources, bitmap);
bitmapToHex(bitmap, hex); bitmapToHex(bitmap, hex);
JsonDocument doc; JsonDocument doc;
doc["name"] = info.name; doc["name"] = info.name;
@@ -346,7 +349,7 @@ static void handlePagePreview()
uint8_t bitmap[BITMAP_BYTES]; uint8_t bitmap[BITMAP_BYTES];
ElementBox boxes[PAGE_ELEMENTS]; ElementBox boxes[PAGE_ELEMENTS];
renderPage(*page, time, pageResources, bitmap, boxes); renderPage(*page, time, millis(), pageResources, bitmap, boxes);
char hex[BITMAP_HEX_LENGTH + 1]; char hex[BITMAP_HEX_LENGTH + 1];
bitmapToHex(bitmap, hex); bitmapToHex(bitmap, hex);
JsonDocument doc; JsonDocument doc;
@@ -479,6 +482,30 @@ static void handleLive()
sendOk(); sendOk();
} }
// {action: "start" | "stop" | "reset"}
static void handleStopwatch()
{
JsonDocument body;
if (!readBody(body))
return;
const char *action = body["action"] | "";
if (strcmp(action, "start") == 0)
appStopwatch(StopwatchAction::Start);
else if (strcmp(action, "stop") == 0)
appStopwatch(StopwatchAction::Stop);
else if (strcmp(action, "reset") == 0)
appStopwatch(StopwatchAction::Reset);
else
{
sendError(400, "Unknown stopwatch action");
return;
}
JsonDocument doc;
doc["running"] = stopwatch.running();
doc["ms"] = stopwatch.elapsed(millis());
sendJson(200, doc);
}
static void handleLiveStop() static void handleLiveStop()
{ {
appStopLive(); appStopLive();
@@ -647,6 +674,7 @@ void webUiBegin()
server.on("/api/page", HTTP_POST, handlePage); server.on("/api/page", HTTP_POST, handlePage);
server.on("/api/live", HTTP_POST, handleLive); server.on("/api/live", HTTP_POST, handleLive);
server.on("/api/live/stop", HTTP_POST, handleLiveStop); server.on("/api/live/stop", HTTP_POST, handleLiveStop);
server.on("/api/stopwatch", HTTP_POST, handleStopwatch);
server.on("/api/settings", HTTP_GET, handleSettingsGet); server.on("/api/settings", HTTP_GET, handleSettingsGet);
server.on("/api/settings", HTTP_POST, handleSettingsPost); server.on("/api/settings", HTTP_POST, handleSettingsPost);
server.on("/api/restart", HTTP_POST, handleRestart); server.on("/api/restart", HTTP_POST, handleRestart);
+1
View File
@@ -23,6 +23,7 @@
// POST /api/page {page: a dynamic page or bitmap name} // POST /api/page {page: a dynamic page or bitmap name}
// POST /api/live show now {data} // POST /api/live show now {data}
// POST /api/live/stop // POST /api/live/stop
// POST /api/stopwatch {action: "start" | "stop" | "reset"} -> {running, ms}
// GET /api/settings (the MQTT password and HA token only with ?secrets=1, for // GET /api/settings (the MQTT password and HA token only with ?secrets=1, for
// the export; otherwise only *_set) // the export; otherwise only *_set)
// POST /api/settings changed fields; {settings, restart} // POST /api/settings changed fields; {settings, restart}
+1 -1
View File
@@ -78,7 +78,7 @@ skipped and the next line starts clean. Coordinates are 2-digit **hex**. Color i
| `s` | `sXXYYc` | Set pixel in the buffer | | `s` | `sXXYYc` | Set pixel in the buffer |
| `S` | `SXXYYc` | Set pixel and flip it right away (direct mode) | | `S` | `SXXYYc` | Set pixel and flip it right away (direct mode) |
| `F` | `Fc` | Fill the whole buffer | | `F` | `Fc` | Fill the whole buffer |
| `D` | `D` | Push the buffer to the display (changed dots only) | | `D` | `D` or `D1` | Push the buffer to the display (changed dots only). `D1`: a frame of moving content (scrolling text, seconds), shown without the refresh effect; the periodic full redraw then waits until the serial line is idle. |
| `P` | `PXXYYcsTEXT` | Print text at (XX,YY), color `c`, size `s`. A literal `\n` (backslash, n) in TEXT starts a new line. The whole line can be at most 64 characters. | | `P` | `PXXYYcsTEXT` | Print text at (XX,YY), color `c`, size `s`. A literal `\n` (backslash, n) in TEXT starts a new line. The whole line can be at most 64 characters. |
| `B` | `BXXYYWWHHcHEX...` | Draw a bitmap. WW must be a multiple of 4. Pixel data is one hex digit per 4 pixels, row by row. The data is drawn while it arrives, so it is not limited to 64 characters. | | `B` | `BXXYYWWHHcHEX...` | Draw a bitmap. WW must be a multiple of 4. Pixel data is one hex digit per 4 pixels, row by row. The data is drawn while it arrives, so it is not limited to 64 characters. |
| `R` | `R` | Print the buffer as ASCII art on the serial port | | `R` | `R` | Print the buffer as ASCII art on the serial port |
+7
View File
@@ -116,8 +116,15 @@ void CommandParser::_runLine()
break; break;
case 'd': case 'd':
case 'D': case 'D':
// D1: a frame of moving content, shown without the effect. More frames follow
// soon, so the periodic full redraw waits until the serial line is idle.
if (_line[1] == '1')
_display.display(false, true);
else
{
_display.display(); _display.display();
_frame_ended = true; _frame_ended = true;
}
break; break;
case 'f': case 'f':
case 'F': case 'F':
+1 -1
View File
@@ -23,7 +23,7 @@ public:
BROSE9323(uint8_t, uint8_t, uint8_t, uint16_t) {} BROSE9323(uint8_t, uint8_t, uint8_t, uint16_t) {}
void begin() { log("begin"); } void begin() { log("begin"); }
void display(bool force = false) { log("display", force); } void display(bool force = false, bool instant = false) { log("display", force, instant); }
void drawPixel(int16_t x, int16_t y, uint16_t color) { log(direct ? "directPixel" : "pixel", x, y, color); } void drawPixel(int16_t x, int16_t y, uint16_t color) { log(direct ? "directPixel" : "pixel", x, y, color); }
void fillScreen(uint16_t color) { log("fill", color); } void fillScreen(uint16_t color) { log("fill", color); }
void setDirect(bool d) { direct = d; } void setDirect(bool d) { direct = d; }
+350 -11
View File
@@ -24,6 +24,7 @@
#include "logic/schedule.h" #include "logic/schedule.h"
#include "logic/settings.h" #include "logic/settings.h"
#include "logic/status_led.h" #include "logic/status_led.h"
#include "logic/stopwatch.h"
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Fakes // Fakes
@@ -53,12 +54,16 @@ class FakeFrames : public Frames
{ {
public: public:
std::vector<std::string> shown; std::vector<std::string> shown;
uint32_t wait = 0; // what dynamicPage() returns
bool last_redraw = false;
void dynamicPage(const LocalTime &time) override uint32_t dynamicPage(const LocalTime &time, uint32_t, bool redraw) override
{ {
char s[24]; char s[24];
snprintf(s, sizeof(s), "clock %02d:%02d", time.hour, time.minute); snprintf(s, sizeof(s), "clock %02d:%02d", time.hour, time.minute);
shown.push_back(s); shown.push_back(s);
last_redraw = redraw;
return wait;
} }
void page() override { shown.push_back("page"); } void page() override { shown.push_back("page"); }
void live() override { shown.push_back("live"); } void live() override { shown.push_back("live"); }
@@ -995,7 +1000,10 @@ static std::string textOf(const PageElement &e, const char *ha_value = nullptr)
t.hour = 8; t.hour = 8;
t.minute = 5; t.minute = 5;
char out[64]; char out[64];
elementText(e, t, ha_value, out, sizeof(out)); ElementValues values;
values.ha_value = ha_value;
values.stopwatch_ms = 65000;
elementText(e, t, values, out, sizeof(out));
return out; return out;
} }
@@ -1085,13 +1093,13 @@ void test_render_background_follows_the_slots()
FakeResources res; FakeResources res;
DynamicPage page = twoSlotPage(); DynamicPage page = twoSlotPage();
uint8_t b[BITMAP_BYTES]; uint8_t b[BITMAP_BYTES];
renderPage(page, at(9, 0), res, b); renderPage(page, at(9, 0), 0, res, b);
TEST_ASSERT_TRUE(bitmapPixel(b, 0, 0)); TEST_ASSERT_TRUE(bitmapPixel(b, 0, 0));
TEST_ASSERT_FALSE(bitmapPixel(b, 1, 0)); TEST_ASSERT_FALSE(bitmapPixel(b, 1, 0));
renderPage(page, at(18, 0), res, b); renderPage(page, at(18, 0), 0, res, b);
TEST_ASSERT_TRUE(bitmapPixel(b, 1, 0)); TEST_ASSERT_TRUE(bitmapPixel(b, 1, 0));
strcpy(page.slots[1].bitmap, "Gone"); strcpy(page.slots[1].bitmap, "Gone");
renderPage(page, at(18, 0), res, b); renderPage(page, at(18, 0), 0, res, b);
for (int i = 0; i < BITMAP_BYTES; i++) for (int i = 0; i < BITMAP_BYTES; i++)
TEST_ASSERT_EQUAL_HEX8(0, b[i]); // blank TEST_ASSERT_EQUAL_HEX8(0, b[i]); // blank
} }
@@ -1111,12 +1119,12 @@ void test_render_overlays()
uint8_t b[BITMAP_BYTES]; uint8_t b[BITMAP_BYTES];
e.overlay = Overlay::OnTop; // on a full background nothing changes e.overlay = Overlay::OnTop; // on a full background nothing changes
renderPage(page, at(12, 0), res, b); renderPage(page, at(12, 0), 0, res, b);
TEST_ASSERT_TRUE(bitmapPixel(b, 10, 2)); TEST_ASSERT_TRUE(bitmapPixel(b, 10, 2));
TEST_ASSERT_TRUE(bitmapPixel(b, 12, 2)); TEST_ASSERT_TRUE(bitmapPixel(b, 12, 2));
e.overlay = Overlay::Box; // cleared area with one dot around it, then the text e.overlay = Overlay::Box; // cleared area with one dot around it, then the text
renderPage(page, at(12, 0), res, b); renderPage(page, at(12, 0), 0, res, b);
TEST_ASSERT_FALSE(bitmapPixel(b, 9, 1)); TEST_ASSERT_FALSE(bitmapPixel(b, 9, 1));
TEST_ASSERT_FALSE(bitmapPixel(b, 10, 2)); TEST_ASSERT_FALSE(bitmapPixel(b, 10, 2));
TEST_ASSERT_TRUE(bitmapPixel(b, 12, 2)); TEST_ASSERT_TRUE(bitmapPixel(b, 12, 2));
@@ -1125,7 +1133,7 @@ void test_render_overlays()
TEST_ASSERT_TRUE(bitmapPixel(b, 8, 2)); TEST_ASSERT_TRUE(bitmapPixel(b, 8, 2));
e.overlay = Overlay::Invert; e.overlay = Overlay::Invert;
renderPage(page, at(12, 0), res, b); renderPage(page, at(12, 0), 0, res, b);
TEST_ASSERT_TRUE(bitmapPixel(b, 10, 2)); TEST_ASSERT_TRUE(bitmapPixel(b, 10, 2));
TEST_ASSERT_FALSE(bitmapPixel(b, 12, 2)); TEST_ASSERT_FALSE(bitmapPixel(b, 12, 2));
} }
@@ -1144,7 +1152,7 @@ void test_render_alignment_and_boxes()
page.elements[1].align = Align::Center; page.elements[1].align = Align::Center;
ElementBox boxes[2]; ElementBox boxes[2];
uint8_t b[BITMAP_BYTES]; uint8_t b[BITMAP_BYTES];
renderPage(page, at(12, 34), res, b, boxes); renderPage(page, at(12, 34), 0, res, b, boxes);
TEST_ASSERT_EQUAL(84 - 29, boxes[0].x); TEST_ASSERT_EQUAL(84 - 29, boxes[0].x);
TEST_ASSERT_EQUAL(29, boxes[0].width); TEST_ASSERT_EQUAL(29, boxes[0].width);
TEST_ASSERT_EQUAL(8, boxes[0].height); TEST_ASSERT_EQUAL(8, boxes[0].height);
@@ -1161,7 +1169,7 @@ void test_render_unknown_font_uses_normal()
strcpy(page.elements[0].font, "Gone"); strcpy(page.elements[0].font, "Gone");
ElementBox box; ElementBox box;
uint8_t b[BITMAP_BYTES]; uint8_t b[BITMAP_BYTES];
renderPage(page, at(12, 34), res, b, &box); renderPage(page, at(12, 34), 0, res, b, &box);
TEST_ASSERT_EQUAL(29, box.width); TEST_ASSERT_EQUAL(29, box.width);
} }
@@ -1179,7 +1187,7 @@ void test_builtin_clock_page_is_the_old_clock()
FakeResources res; FakeResources res;
TEST_ASSERT_TRUE(builtinPage(BUILTIN_PAGE_CLOCK, page)); TEST_ASSERT_TRUE(builtinPage(BUILTIN_PAGE_CLOCK, page));
uint8_t b[BITMAP_BYTES]; uint8_t b[BITMAP_BYTES];
renderPage(page, at(12, 34), res, b); renderPage(page, at(12, 34), 0, res, b);
TEST_ASSERT_TRUE(bitmapPixel(b, 48, 4)); // the '1' TEST_ASSERT_TRUE(bitmapPixel(b, 48, 4)); // the '1'
TEST_ASSERT_TRUE(bitmapPixel(b, 48, 10)); TEST_ASSERT_TRUE(bitmapPixel(b, 48, 10));
TEST_ASSERT_FALSE(bitmapPixel(b, 48, 11)); TEST_ASSERT_FALSE(bitmapPixel(b, 48, 11));
@@ -1191,6 +1199,325 @@ void test_default_page_is_the_builtin_clock()
TEST_ASSERT_TRUE(builtinPage(defaults.page, page)); TEST_ASSERT_TRUE(builtinPage(defaults.page, page));
} }
// ---------------------------------------------------------------------------
// Seconds, countdown, stopwatch, multiplier, degree sign
// ---------------------------------------------------------------------------
void test_clock_with_seconds()
{
PageElement e;
e.format = CLOCK_HH_MM_SS;
TEST_ASSERT_EQUAL_STRING("08:05:00", textOf(e).c_str());
e.format = CLOCK_H_MM_SS;
TEST_ASSERT_EQUAL_STRING("8:05:00", textOf(e).c_str());
TEST_ASSERT_TRUE(elementShowsSeconds(e, LocalTime(), false));
e.format = CLOCK_HH_MM;
TEST_ASSERT_FALSE(elementShowsSeconds(e, LocalTime(), false));
}
void test_countdown_target_parse_and_format()
{
uint32_t m = 0;
TEST_ASSERT_TRUE(parseTarget("2000-01-01T00:00", m));
TEST_ASSERT_EQUAL_UINT32(0, m);
TEST_ASSERT_TRUE(parseTarget("2026-12-24T18:30", m));
char out[17];
formatTarget(m, out);
TEST_ASSERT_EQUAL_STRING("2026-12-24T18:30", out);
TEST_ASSERT_FALSE(parseTarget("2026-02-31T10:00", m)); // no 31 February
TEST_ASSERT_FALSE(parseTarget("2026-12-24 18:30", m));
TEST_ASSERT_FALSE(parseTarget("1999-12-24T18:30", m));
TEST_ASSERT_FALSE(parseTarget("2026-12-24T24:00", m));
TEST_ASSERT_TRUE(parseTarget("2028-02-29T00:00", m)); // leap year
LocalTime t;
t.year = 2026;
t.month = 10;
t.day = 15;
t.hour = 8;
t.minute = 5;
TEST_ASSERT_TRUE(parseTarget("2026-10-15T08:05", m));
TEST_ASSERT_EQUAL_UINT32(m, minutesSince2000(t));
}
// Countdown from Thursday 15 October 2026, 08:05:00 (textOf) to `target`
static std::string countdown(const char *target, uint8_t format, Language lang = Language::Dutch)
{
PageElement e;
e.type = ElementType::Countdown;
e.format = format;
e.language = lang;
TEST_ASSERT_TRUE(parseTarget(target, e.target));
return textOf(e);
}
void test_countdown_formats()
{
TEST_ASSERT_EQUAL_STRING("12 dagen", countdown("2026-10-27T09:00", COUNTDOWN_SMART).c_str());
TEST_ASSERT_EQUAL_STRING("1 day", countdown("2026-10-16T09:00", COUNTDOWN_SMART, Language::English).c_str());
TEST_ASSERT_EQUAL_STRING("5 uur", countdown("2026-10-15T13:30", COUNTDOWN_SMART).c_str());
TEST_ASSERT_EQUAL_STRING("1 hour", countdown("2026-10-15T09:30", COUNTDOWN_SMART, Language::English).c_str());
TEST_ASSERT_EQUAL_STRING("34 min", countdown("2026-10-15T08:39", COUNTDOWN_SMART).c_str());
TEST_ASSERT_EQUAL_STRING("1 min", countdown("2026-10-15T08:06", COUNTDOWN_SMART).c_str());
// Calendar days: tomorrow 00:30 is 1 day away
TEST_ASSERT_EQUAL_STRING("1 dag", countdown("2026-10-16T00:30", COUNTDOWN_DAYS).c_str());
TEST_ASSERT_EQUAL_STRING("12 days", countdown("2026-10-27T01:00", COUNTDOWN_DAYS, Language::English).c_str());
TEST_ASSERT_EQUAL_STRING("12d 00:55", countdown("2026-10-27T09:00", COUNTDOWN_DAYS_TIME).c_str());
TEST_ASSERT_EQUAL_STRING("05:25", countdown("2026-10-15T13:30", COUNTDOWN_DAYS_TIME).c_str());
TEST_ASSERT_EQUAL_STRING("12d 00:55:00", countdown("2026-10-27T09:00", COUNTDOWN_SECONDS).c_str());
TEST_ASSERT_EQUAL_STRING("05:25:00", countdown("2026-10-15T13:30", COUNTDOWN_SECONDS).c_str());
// Passed: the suffix, or 0
TEST_ASSERT_EQUAL_STRING("0", countdown("2026-10-15T08:05", COUNTDOWN_SMART).c_str());
PageElement e;
e.type = ElementType::Countdown;
strcpy(e.text, "Disney ");
strcpy(e.suffix, "nu!");
TEST_ASSERT_TRUE(parseTarget("2026-01-01T00:00", e.target));
TEST_ASSERT_EQUAL_STRING("Disney nu!", textOf(e).c_str());
}
void test_countdown_shows_seconds_only_when_needed()
{
PageElement e;
e.type = ElementType::Countdown;
LocalTime t;
t.year = 2026;
t.month = 10;
t.day = 15;
t.hour = 8;
t.minute = 5;
t.second = 30;
TEST_ASSERT_TRUE(parseTarget("2026-10-15T08:06", e.target)); // 30 s left
e.format = COUNTDOWN_SMART;
TEST_ASSERT_TRUE(elementShowsSeconds(e, t, false));
char out[32];
elementText(e, t, ElementValues(), out, sizeof(out));
TEST_ASSERT_EQUAL_STRING("0:30", out);
e.format = COUNTDOWN_DAYS_TIME;
TEST_ASSERT_FALSE(elementShowsSeconds(e, t, false));
e.format = COUNTDOWN_SECONDS;
TEST_ASSERT_TRUE(elementShowsSeconds(e, t, false));
TEST_ASSERT_TRUE(parseTarget("2026-10-15T08:05", e.target)); // passed
TEST_ASSERT_FALSE(elementShowsSeconds(e, t, false));
}
void test_stopwatch()
{
Stopwatch sw;
TEST_ASSERT_EQUAL_UINT32(0, sw.elapsed(5000));
sw.start(1000);
TEST_ASSERT_TRUE(sw.running());
TEST_ASSERT_EQUAL_UINT32(2000, sw.elapsed(3000));
sw.stop(4000);
TEST_ASSERT_EQUAL_UINT32(3000, sw.elapsed(9000));
sw.start(10000);
sw.start(11000); // already running: no restart
TEST_ASSERT_EQUAL_UINT32(5000, sw.elapsed(12000));
sw.reset();
TEST_ASSERT_FALSE(sw.running());
TEST_ASSERT_EQUAL_UINT32(0, sw.elapsed(20000));
sw.start(0xFFFFFF00u); // across the millis() overflow
TEST_ASSERT_EQUAL_UINT32(0x200, sw.elapsed(0x100));
PageElement e; // textOf: 65 s on the stopwatch
e.type = ElementType::Stopwatch;
e.format = STOPWATCH_M_SS;
TEST_ASSERT_EQUAL_STRING("1:05", textOf(e).c_str());
e.format = STOPWATCH_H_MM;
TEST_ASSERT_EQUAL_STRING("0:01", textOf(e).c_str());
e.format = STOPWATCH_H_MM_SS;
strcpy(e.text, "T ");
TEST_ASSERT_EQUAL_STRING("T 0:01:05", textOf(e).c_str());
TEST_ASSERT_TRUE(elementShowsSeconds(e, LocalTime(), true));
TEST_ASSERT_FALSE(elementShowsSeconds(e, LocalTime(), false)); // stopped
}
void test_home_assistant_multiplier()
{
PageElement e;
e.type = ElementType::HaValue;
e.factor = 1000;
strcpy(e.suffix, " l");
TEST_ASSERT_EQUAL_STRING("637 l", textOf(e, "0.637").c_str());
e.factor = 0.001f;
e.decimals = 1;
strcpy(e.suffix, "kWh");
TEST_ASSERT_EQUAL_STRING("26.3kWh", textOf(e, "26300").c_str());
TEST_ASSERT_EQUAL_STRING("--kWh", textOf(e, "unknown").c_str());
DynamicPage page;
page.element_count = 1;
page.elements[0] = e;
strcpy(page.elements[0].entity, "sensor.x");
page.elements[0].factor = 0;
TEST_ASSERT_NOT_NULL(validatePage(page));
}
void test_degree_sign_in_the_fonts()
{
const char degree[] = {DEGREE_SIGN, 0};
TEST_ASSERT_EQUAL(4, textWidth(fontNamed("Normal"), degree));
TEST_ASSERT_EQUAL(3, textWidth(fontNamed("Small"), degree));
TEST_ASSERT_EQUAL(6, textWidth(fontNamed("Full height"), degree));
DynamicPage page;
page.element_count = 1;
page.elements[0].type = ElementType::Text;
strcpy(page.elements[0].text, "17.5\x7f" "C");
TEST_ASSERT_NULL(validatePage(page));
}
// ---------------------------------------------------------------------------
// Scrolling and images
// ---------------------------------------------------------------------------
static DynamicPage scrollPage()
{
DynamicPage page;
page.element_count = 1;
PageElement &e = page.elements[0];
e.type = ElementType::Text;
strcpy(e.text, "A long text that does not fit");
e.x = 10;
e.y = 2;
e.scroll_width = 30;
e.scroll_speed = 10;
return page;
}
static bool anyDot(const uint8_t *b, int x0, int x1)
{
for (int y = 0; y < BITMAP_HEIGHT; y++)
for (int x = x0; x < x1; x++)
if (bitmapPixel(b, x, y))
return true;
return false;
}
void test_scrolling_text_moves_in_its_window()
{
FakeResources res;
DynamicPage page = scrollPage();
uint8_t a[BITMAP_BYTES], b[BITMAP_BYTES];
ElementBox box;
uint32_t wait = renderPage(page, at(12, 0), 0, res, a, &box);
TEST_ASSERT_EQUAL(100, wait); // 10 dots per second
TEST_ASSERT_EQUAL(10, box.x);
TEST_ASSERT_EQUAL(30, box.width);
TEST_ASSERT_FALSE(anyDot(a, 0, 10)); // clipped to the window
TEST_ASSERT_FALSE(anyDot(a, 40, BITMAP_WIDTH));
TEST_ASSERT_TRUE(anyDot(a, 10, 40));
TEST_ASSERT_EQUAL(60, renderPage(page, at(12, 0), 1040, res, b, &box)); // until 1100
TEST_ASSERT_TRUE(memcmp(a, b, BITMAP_BYTES) != 0);
// One round later the same image again: text width + gap
int period = textWidth(fontNamed("Normal"), page.elements[0].text) + SCROLL_GAP;
renderPage(page, at(12, 0), period * 100, res, b);
TEST_ASSERT_EQUAL_MEMORY(a, b, BITMAP_BYTES);
}
void test_short_text_in_a_window_does_not_scroll()
{
FakeResources res;
DynamicPage page = scrollPage();
strcpy(page.elements[0].text, "Hi");
page.elements[0].align = Align::Right; // right edge of the window at x 10
uint8_t b[BITMAP_BYTES];
ElementBox box;
TEST_ASSERT_EQUAL(0, renderPage(page, at(12, 0), 0, res, b, &box));
TEST_ASSERT_EQUAL(-20, box.x);
TEST_ASSERT_TRUE(anyDot(b, 0, 10)); // the text at the right of the window
}
void test_render_asks_for_the_next_second()
{
FakeResources res;
DynamicPage page;
page.element_count = 1;
uint8_t b[BITMAP_BYTES];
LocalTime t = at(12, 0);
t.millisecond = 300;
TEST_ASSERT_EQUAL(0, renderPage(page, t, 0, res, b));
page.elements[0].format = CLOCK_HH_MM_SS;
TEST_ASSERT_EQUAL(700, renderPage(page, t, 0, res, b));
}
class ImageResources : public FakeResources
{
public:
bool background(const char *name, uint8_t *bitmap) override
{
memset(bitmap, 0, BITMAP_BYTES);
if (strcmp(name, "icon sunny") == 0)
{
setBitmapPixel(bitmap, 1, 1, true);
setBitmapPixel(bitmap, 3, 2, true);
return true;
}
if (strcmp(name, "icon default") == 0)
{
setBitmapPixel(bitmap, 0, 0, true);
return true;
}
return false;
}
};
void test_image_element_by_state()
{
ImageResources res;
DynamicPage page;
page.element_count = 1;
PageElement &e = page.elements[0];
e.type = ElementType::Image;
strcpy(e.text, "icon ");
strcpy(e.entity, "weather.home");
e.x = 50;
e.y = 5;
TEST_ASSERT_NULL(validatePage(page));
uint8_t b[BITMAP_BYTES];
ElementBox box;
res.value = "sunny";
renderPage(page, at(12, 0), 0, res, b, &box);
TEST_ASSERT_TRUE(bitmapPixel(b, 51, 6));
TEST_ASSERT_TRUE(bitmapPixel(b, 53, 7));
TEST_ASSERT_EQUAL(51, box.x);
TEST_ASSERT_EQUAL(6, box.y);
TEST_ASSERT_EQUAL(3, box.width);
TEST_ASSERT_EQUAL(2, box.height);
res.value = "rainy"; // no bitmap for it: the default
renderPage(page, at(12, 0), 0, res, b, &box);
TEST_ASSERT_TRUE(bitmapPixel(b, 50, 5));
TEST_ASSERT_FALSE(bitmapPixel(b, 51, 6));
e.entity[0] = '\0'; // fixed bitmap
strcpy(e.text, "icon sunny");
TEST_ASSERT_NULL(validatePage(page));
renderPage(page, at(12, 0), 0, res, b);
TEST_ASSERT_TRUE(bitmapPixel(b, 51, 6));
strcpy(e.text, "../x");
TEST_ASSERT_NOT_NULL(validatePage(page));
}
void test_moving_page_is_redrawn_when_due()
{
frames.wait = 250;
run(12, 0);
TEST_ASSERT_EQUAL(1, (int)frames.shown.size());
TEST_ASSERT_TRUE(frames.last_redraw);
run(12, 0, 200); // not yet (the run steps are 100 ms)
TEST_ASSERT_EQUAL(2, (int)frames.shown.size());
TEST_ASSERT_FALSE(frames.last_redraw);
frames.wait = 0; // stops moving
run(12, 0, 1000);
TEST_ASSERT_EQUAL(3, (int)frames.shown.size());
}
int main() int main()
{ {
UNITY_BEGIN(); UNITY_BEGIN();
@@ -1288,6 +1615,18 @@ int main()
RUN_TEST(test_render_unknown_font_uses_normal); RUN_TEST(test_render_unknown_font_uses_normal);
RUN_TEST(test_builtin_clock_page_is_the_old_clock); RUN_TEST(test_builtin_clock_page_is_the_old_clock);
RUN_TEST(test_default_page_is_the_builtin_clock); RUN_TEST(test_default_page_is_the_builtin_clock);
RUN_TEST(test_clock_with_seconds);
RUN_TEST(test_countdown_target_parse_and_format);
RUN_TEST(test_countdown_formats);
RUN_TEST(test_countdown_shows_seconds_only_when_needed);
RUN_TEST(test_stopwatch);
RUN_TEST(test_home_assistant_multiplier);
RUN_TEST(test_degree_sign_in_the_fonts);
RUN_TEST(test_scrolling_text_moves_in_its_window);
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_moving_page_is_redrawn_when_due);
return UNITY_END(); return UNITY_END();
} }
+23
View File
@@ -103,6 +103,27 @@ void test_display()
send("D\n"); send("D\n");
TEST_ASSERT_EQUAL(1, display.count("display")); TEST_ASSERT_EQUAL(1, display.count("display"));
TEST_ASSERT_EQUAL(0, display.last("display")->a); // not forced TEST_ASSERT_EQUAL(0, display.last("display")->a); // not forced
TEST_ASSERT_EQUAL(0, display.last("display")->b); // with the effect
}
void test_display_instant_frame()
{
send("D1\n");
TEST_ASSERT_EQUAL(1, display.count("display"));
TEST_ASSERT_EQUAL(0, display.last("display")->a);
TEST_ASSERT_EQUAL(1, display.last("display")->b); // without the effect
}
void test_refresh_waits_for_idle_after_instant_frames()
{
last_refresh = fake_millis;
fake_millis += 120000;
send("D1\n");
TEST_ASSERT_EQUAL(1, display.count("display")); // no full redraw: more frames follow
fake_millis += 3000;
loop();
TEST_ASSERT_EQUAL(2, display.count("display"));
TEST_ASSERT_EQUAL(1, display.calls.back().a);
} }
void test_lowercase_and_uppercase_commands() void test_lowercase_and_uppercase_commands()
@@ -498,6 +519,8 @@ int main()
RUN_TEST(test_direct_pixel_flips_immediately_and_resets_direct_mode); RUN_TEST(test_direct_pixel_flips_immediately_and_resets_direct_mode);
RUN_TEST(test_fill); RUN_TEST(test_fill);
RUN_TEST(test_display); RUN_TEST(test_display);
RUN_TEST(test_display_instant_frame);
RUN_TEST(test_refresh_waits_for_idle_after_instant_frames);
RUN_TEST(test_lowercase_and_uppercase_commands); RUN_TEST(test_lowercase_and_uppercase_commands);
RUN_TEST(test_command_split_over_several_reads_with_crlf); RUN_TEST(test_command_split_over_several_reads_with_crlf);
RUN_TEST(test_print_text_with_newline_escape); RUN_TEST(test_print_text_with_newline_escape);
+12 -1
View File
@@ -1,4 +1,5 @@
"""Generates src/esp32/logic/builtin_fonts.cpp: the built-in fonts of the dynamic pages. """Generates src/esp32/logic/builtin_fonts.cpp: the built-in fonts of the dynamic pages.
Characters 32-126, and 127 as the degree sign.
python tools/make_builtin_fonts.py python tools/make_builtin_fonts.py
@@ -21,7 +22,14 @@ GFX = os.path.join(ROOT, ".pio", "libdeps", "esp32", "Adafruit GFX Library")
OUT = os.path.join(ROOT, "src", "esp32", "logic", "builtin_fonts.cpp") OUT = os.path.join(ROOT, "src", "esp32", "logic", "builtin_fonts.cpp")
FIRST = 32 FIRST = 32
LAST = 126 LAST = 126 # plus character 127: the degree sign (DEGREE in each font)
# The degree sign per font height, drawn at the top of the character
DEGREE = {
6: [".#.", "#.#", ".#.", "...", "...", "..."],
8: [".##.", "#..#", "#..#", ".##.", "....", "....", "....", "...."],
19: [".####.", "##..##", "#....#", "#....#", "##..##", ".####."] + ["......"] * 13,
}
def numbers(text): def numbers(text):
@@ -62,6 +70,7 @@ def classic():
columns = font[c * 5 : c * 5 + 5] columns = font[c * 5 : c * 5 + 5]
rows = ["".join("#" if columns[x] >> y & 1 else "." for x in range(5)) for y in range(8)] rows = ["".join("#" if columns[x] >> y & 1 else "." for x in range(5)) for y in range(8)]
glyphs.append((5, rows)) glyphs.append((5, rows))
glyphs.append((4, DEGREE[8]))
return encode("Normal", 8, 1, glyphs) return encode("Normal", 8, 1, glyphs)
@@ -91,6 +100,7 @@ def tom_thumb():
for i in digits: for i in digits:
width, rows = glyphs[i] width, rows = glyphs[i]
glyphs[i] = (widest, ["." * (widest - width) + r for r in rows]) glyphs[i] = (widest, ["." * (widest - width) + r for r in rows])
glyphs.append((3, DEGREE[6]))
return encode("Small", 6, 1, glyphs) return encode("Small", 6, 1, glyphs)
@@ -146,6 +156,7 @@ def full_height():
glyphs.append((len(special[ch][0]), special[ch])) glyphs.append((len(special[ch][0]), special[ch]))
else: else:
glyphs.append((0, [""] * 19)) # not in this font: skipped glyphs.append((0, [""] * 19)) # not in this font: skipped
glyphs.append((6, DEGREE[19]))
return encode("Full height", 19, 2, glyphs) return encode("Full height", 19, 2, glyphs)