Public Access
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:
1 parent
ca8d78ea8a
commit
fc6fa1a180
38 files changed
+1370
-277
No files matched your search
@@ -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
|
||||
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()`.
|
||||
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.
|
||||
|
||||
## 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
|
||||
`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).
|
||||
- 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.
|
||||
`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
|
||||
|
||||
@@ -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 |
|
||||
|-----|-----------------|
|
||||
| **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. |
|
||||
| **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. |
|
||||
@@ -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);
|
||||
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,
|
||||
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.
|
||||
@@ -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
|
||||
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 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.
|
||||
On `D` it flips only the dots that changed. Every 2 minutes it redraws the whole screen
|
||||
to fix dots that did not flip.
|
||||
|
||||
@@ -65,10 +65,10 @@ void BROSE9323::begin(void)
|
||||
}
|
||||
#endif
|
||||
|
||||
void BROSE9323::display(bool force)
|
||||
void BROSE9323::display(bool force, bool instant)
|
||||
{
|
||||
#ifdef ESPSERIAL
|
||||
stream->print("D\n");
|
||||
stream->print(instant ? "D1\n" : "D\n");
|
||||
stream->flush();
|
||||
// Shadow copy of what the dots show now (for the web UI)
|
||||
memcpy(_old_buffer, _new_buffer, _buffer_size);
|
||||
@@ -79,7 +79,7 @@ void BROSE9323::display(bool force)
|
||||
_force_next = false;
|
||||
_drawing = false;
|
||||
// An explicit full redraw is always instant (the periodic refresh)
|
||||
if (_effect == EFFECT_INSTANT || force)
|
||||
if (_effect == EFFECT_INSTANT || force || instant)
|
||||
{
|
||||
_animating = false;
|
||||
_drawAll(all);
|
||||
|
||||
@@ -105,7 +105,9 @@ public:
|
||||
void begin(Stream * = &Serial);
|
||||
#endif
|
||||
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 fillScreen(uint16_t);
|
||||
void setDirect(bool);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "app.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <sys/time.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "config.h"
|
||||
@@ -70,6 +71,7 @@ static void apply()
|
||||
controller.setSettings(settings.schedule, settings.clock);
|
||||
controller.setEffectTime(effectTime(settings.drive));
|
||||
status.setDrive(settings.flip_time, settings.drive);
|
||||
frames.setDrive(settings.flip_time, settings.drive);
|
||||
haApiSetSettings(settings.home_assistant);
|
||||
applyOffPage();
|
||||
applyPage();
|
||||
@@ -230,6 +232,17 @@ const char *appDeleteFont(const char *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)
|
||||
{
|
||||
frames.setLiveBitmap(bitmap);
|
||||
@@ -252,6 +265,10 @@ bool appLocalTime(LocalTime &time)
|
||||
time.weekday = now.tm_wday;
|
||||
time.hour = now.tm_hour;
|
||||
time.minute = now.tm_min;
|
||||
time.second = now.tm_sec;
|
||||
struct timeval tv;
|
||||
gettimeofday(&tv, nullptr);
|
||||
time.millisecond = tv.tv_usec / 1000;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 *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
|
||||
void appShowLive(const uint8_t *bitmap);
|
||||
void appStopLive();
|
||||
|
||||
+4
-1
@@ -49,6 +49,9 @@ const uint32_t POWER_OFF_DELAY = 30000;
|
||||
// timeout (a read blocks loop() for at most about twice this)
|
||||
const uint32_t HA_POLL_INTERVAL = 60000;
|
||||
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)
|
||||
@@ -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_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)
|
||||
const uint8_t HA_DEVICE_ID[] = {0x34, 0x86, 0x5d, 0x3a, 0x08, 0x28};
|
||||
|
||||
@@ -110,7 +110,8 @@ bool loadBitmap(const char *name, uint8_t *bitmap)
|
||||
memcpy_P(bitmap, b->data, BITMAP_BYTES);
|
||||
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;
|
||||
File file = LittleFS.open(path(name), "r");
|
||||
if (!file)
|
||||
|
||||
@@ -3,17 +3,41 @@
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "config.h"
|
||||
#include "display/text.h"
|
||||
|
||||
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];
|
||||
renderPage(_dynamic, time, _resources, bitmap);
|
||||
_drawFullScreen(bitmap);
|
||||
uint32_t wait = renderPage(_dynamic, time, now, _resources, 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)
|
||||
@@ -62,9 +86,9 @@ void FlipdotFrames::setOffBitmap(const uint8_t *bitmap)
|
||||
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.drawBitmap(0, 0, bitmap, BITMAP_WIDTH, BITMAP_HEIGHT, 1);
|
||||
_display.display();
|
||||
_display.display(false, instant);
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "logic/bitmap_format.h"
|
||||
#include "logic/display_controller.h"
|
||||
#include "logic/page_render.h"
|
||||
#include "logic/settings.h"
|
||||
|
||||
// Draws the screens on the flipdot display. Each method sends one complete frame.
|
||||
class FlipdotFrames : public Frames
|
||||
@@ -11,8 +12,10 @@ class FlipdotFrames : public Frames
|
||||
public:
|
||||
FlipdotFrames(BROSE9323 &display, PageResources &resources);
|
||||
|
||||
// The page set with setDynamicPage(), drawn for this time
|
||||
void dynamicPage(const LocalTime &time) override;
|
||||
// The page set with setDynamicPage(), drawn for this time. Not sent when it looks the
|
||||
// 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
|
||||
void noWifi(int hour, int minute) override;
|
||||
// The bitmap set with setPageBitmap()
|
||||
@@ -23,6 +26,8 @@ public:
|
||||
void off() override;
|
||||
|
||||
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 setLiveBitmap(const uint8_t *bitmap);
|
||||
// nullptr: blank
|
||||
@@ -32,9 +37,12 @@ private:
|
||||
BROSE9323 &_display;
|
||||
PageResources &_resources;
|
||||
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 _live[BITMAP_BYTES] = {};
|
||||
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;
|
||||
};
|
||||
@@ -1,10 +1,13 @@
|
||||
#include "display/page_resources.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#include "display/bitmap_store.h"
|
||||
#include "display/font_store.h"
|
||||
#include "net/ha_api.h"
|
||||
|
||||
StoredPageResources pageResources;
|
||||
Stopwatch stopwatch;
|
||||
StoredPageResources pageResources(stopwatch);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
uint32_t StoredPageResources::stopwatchMs()
|
||||
{
|
||||
return _stopwatch.elapsed(millis());
|
||||
}
|
||||
|
||||
bool StoredPageResources::stopwatchRunning()
|
||||
{
|
||||
return _stopwatch.running();
|
||||
}
|
||||
@@ -1,15 +1,25 @@
|
||||
#pragma once
|
||||
|
||||
#include "logic/page_render.h"
|
||||
#include "logic/stopwatch.h"
|
||||
|
||||
// Bitmaps from display/bitmap_store, fonts from display/font_store and Home Assistant
|
||||
// values from net/ha_api, for drawing dynamic pages on the display and in the web UI
|
||||
// Bitmaps from display/bitmap_store, fonts from display/font_store, Home Assistant values
|
||||
// from net/ha_api and the stopwatch, for drawing dynamic pages on the display and in the
|
||||
// web UI
|
||||
class StoredPageResources : public PageResources
|
||||
{
|
||||
public:
|
||||
explicit StoredPageResources(Stopwatch &stopwatch) : _stopwatch(stopwatch) {}
|
||||
|
||||
bool background(const char *name, uint8_t *bitmap) override;
|
||||
Font font(const char *name) override;
|
||||
const char *haValue(const PageElement &element) override;
|
||||
uint32_t stopwatchMs() override;
|
||||
bool stopwatchRunning() override;
|
||||
|
||||
private:
|
||||
Stopwatch &_stopwatch;
|
||||
};
|
||||
|
||||
extern Stopwatch stopwatch;
|
||||
extern StoredPageResources pageResources;
|
||||
@@ -7,8 +7,8 @@
|
||||
|
||||
static const char *DIR = "/pages";
|
||||
static const char *EXTENSION = ".pg";
|
||||
// Increase when DynamicPage changes (older files are then ignored)
|
||||
static const uint16_t VERSION = 1;
|
||||
// Increase when DynamicPage changes; older files are ignored unless converted below
|
||||
static const uint16_t VERSION = 2;
|
||||
|
||||
struct StoredPage
|
||||
{
|
||||
@@ -17,6 +17,68 @@ struct StoredPage
|
||||
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 String path(const char *name)
|
||||
@@ -64,14 +126,30 @@ bool loadPage(const char *name, DynamicPage &page)
|
||||
File file = LittleFS.open(path(name), "r");
|
||||
if (!file)
|
||||
return false;
|
||||
StoredPage *stored = new StoredPage;
|
||||
bool ok = file.read((uint8_t *)stored, sizeof(StoredPage)) == sizeof(StoredPage) &&
|
||||
stored->version == VERSION && stored->size == sizeof(DynamicPage) &&
|
||||
validatePage(stored->page) == nullptr;
|
||||
bool ok = false;
|
||||
size_t size = file.size();
|
||||
if (size == sizeof(StoredPage))
|
||||
{
|
||||
StoredPage *stored = new StoredPage;
|
||||
ok = file.read((uint8_t *)stored, sizeof(StoredPage)) == sizeof(StoredPage) && stored->version == VERSION &&
|
||||
stored->size == sizeof(DynamicPage) && validatePage(stored->page) == nullptr;
|
||||
if (ok)
|
||||
page = stored->page;
|
||||
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();
|
||||
if (ok)
|
||||
page = stored->page;
|
||||
delete stored;
|
||||
return ok;
|
||||
}
|
||||
|
||||
|
||||
+119
-116
@@ -7,144 +7,147 @@
|
||||
#include "logic/bitmap_format.h"
|
||||
|
||||
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,
|
||||
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, 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, 0x03, 0x03, 0x03, 0x01, 0x03, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x80, 0x80,
|
||||
0x00, 0x80, 0x00, 0xa0, 0xa0, 0x00, 0x00, 0x00, 0x00, 0xa0, 0xe0, 0xa0, 0xe0, 0xa0, 0x00, 0x60,
|
||||
0xc0, 0x60, 0xc0, 0x40, 0x00, 0x80, 0x20, 0x40, 0x80, 0x20, 0x00, 0xc0, 0xc0, 0xe0, 0xa0, 0x60,
|
||||
0x00, 0x80, 0x80, 0x00, 0x00, 0x00, 0x00, 0x40, 0x80, 0x80, 0x80, 0x40, 0x00, 0x80, 0x40, 0x40,
|
||||
0x40, 0x80, 0x00, 0xa0, 0x40, 0xa0, 0x00, 0x00, 0x00, 0x00, 0x40, 0xe0, 0x40, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x40, 0x80, 0x00, 0x00, 0x00, 0xe0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80,
|
||||
0x00, 0x20, 0x20, 0x40, 0x80, 0x80, 0x00, 0x60, 0xa0, 0xa0, 0xa0, 0xc0, 0x00, 0x20, 0x60, 0x20,
|
||||
0x20, 0x20, 0x00, 0xc0, 0x20, 0x40, 0x80, 0xe0, 0x00, 0xc0, 0x20, 0x40, 0x20, 0xc0, 0x00, 0xa0,
|
||||
0xa0, 0xe0, 0x20, 0x20, 0x00, 0xe0, 0x80, 0xc0, 0x20, 0xc0, 0x00, 0x60, 0x80, 0xe0, 0xa0, 0xe0,
|
||||
0x00, 0xe0, 0x20, 0x40, 0x80, 0x80, 0x00, 0xe0, 0xa0, 0xe0, 0xa0, 0xe0, 0x00, 0xe0, 0xa0, 0xe0,
|
||||
0x20, 0xc0, 0x00, 0x00, 0x80, 0x00, 0x80, 0x00, 0x00, 0x00, 0x40, 0x00, 0x40, 0x80, 0x00, 0x20,
|
||||
0x40, 0x80, 0x40, 0x20, 0x00, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0x00, 0x80, 0x40, 0x20, 0x40, 0x80,
|
||||
0x00, 0xe0, 0x20, 0x40, 0x00, 0x40, 0x00, 0x40, 0xa0, 0xe0, 0x80, 0x60, 0x00, 0x40, 0xa0, 0xe0,
|
||||
0xa0, 0xa0, 0x00, 0xc0, 0xa0, 0xc0, 0xa0, 0xc0, 0x00, 0x60, 0x80, 0x80, 0x80, 0x60, 0x00, 0xc0,
|
||||
0xa0, 0xa0, 0xa0, 0xc0, 0x00, 0xe0, 0x80, 0xe0, 0x80, 0xe0, 0x00, 0xe0, 0x80, 0xe0, 0x80, 0x80,
|
||||
0x00, 0x60, 0x80, 0xe0, 0xa0, 0x60, 0x00, 0xa0, 0xa0, 0xe0, 0xa0, 0xa0, 0x00, 0xe0, 0x40, 0x40,
|
||||
0x40, 0xe0, 0x00, 0x20, 0x20, 0x20, 0xa0, 0x40, 0x00, 0xa0, 0xa0, 0xc0, 0xa0, 0xa0, 0x00, 0x80,
|
||||
0x80, 0x80, 0x80, 0xe0, 0x00, 0xa0, 0xe0, 0xe0, 0xa0, 0xa0, 0x00, 0xa0, 0xe0, 0xe0, 0xe0, 0xa0,
|
||||
0x00, 0x40, 0xa0, 0xa0, 0xa0, 0x40, 0x00, 0xc0, 0xa0, 0xc0, 0x80, 0x80, 0x00, 0x40, 0xa0, 0xa0,
|
||||
0xe0, 0x60, 0x00, 0xc0, 0xa0, 0xe0, 0xc0, 0xa0, 0x00, 0x60, 0x80, 0x40, 0x20, 0xc0, 0x00, 0xe0,
|
||||
0x40, 0x40, 0x40, 0x40, 0x00, 0xa0, 0xa0, 0xa0, 0xa0, 0x60, 0x00, 0xa0, 0xa0, 0xa0, 0x40, 0x40,
|
||||
0x00, 0xa0, 0xa0, 0xe0, 0xe0, 0xa0, 0x00, 0xa0, 0xa0, 0x40, 0xa0, 0xa0, 0x00, 0xa0, 0xa0, 0x40,
|
||||
0x40, 0x40, 0x00, 0xe0, 0x20, 0x40, 0x80, 0xe0, 0x00, 0xe0, 0x80, 0x80, 0x80, 0xe0, 0x00, 0x00,
|
||||
0x80, 0x40, 0x20, 0x00, 0x00, 0xe0, 0x20, 0x20, 0x20, 0xe0, 0x00, 0x40, 0xa0, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0x00, 0x80, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0x60,
|
||||
0xa0, 0xe0, 0x00, 0x80, 0xc0, 0xa0, 0xa0, 0xc0, 0x00, 0x00, 0x60, 0x80, 0x80, 0x60, 0x00, 0x20,
|
||||
0x60, 0xa0, 0xa0, 0x60, 0x00, 0x00, 0x60, 0xa0, 0xc0, 0x60, 0x00, 0x20, 0x40, 0xe0, 0x40, 0x40,
|
||||
0x00, 0x00, 0x60, 0xa0, 0xe0, 0x20, 0x40, 0x80, 0xc0, 0xa0, 0xa0, 0xa0, 0x00, 0x80, 0x00, 0x80,
|
||||
0x80, 0x80, 0x00, 0x20, 0x00, 0x20, 0x20, 0xa0, 0x40, 0x80, 0xa0, 0xc0, 0xc0, 0xa0, 0x00, 0xc0,
|
||||
0x40, 0x40, 0x40, 0xe0, 0x00, 0x00, 0xe0, 0xe0, 0xe0, 0xa0, 0x00, 0x00, 0xc0, 0xa0, 0xa0, 0xa0,
|
||||
0x00, 0x00, 0x40, 0xa0, 0xa0, 0x40, 0x00, 0x00, 0xc0, 0xa0, 0xa0, 0xc0, 0x80, 0x00, 0x60, 0xa0,
|
||||
0xa0, 0x60, 0x20, 0x00, 0x60, 0x80, 0x80, 0x80, 0x00, 0x00, 0x60, 0xc0, 0x60, 0xc0, 0x00, 0x40,
|
||||
0xe0, 0x40, 0x40, 0x60, 0x00, 0x00, 0xa0, 0xa0, 0xa0, 0x60, 0x00, 0x00, 0xa0, 0xa0, 0xe0, 0x40,
|
||||
0x00, 0x00, 0xa0, 0xe0, 0xe0, 0xe0, 0x00, 0x00, 0xa0, 0x40, 0x40, 0xa0, 0x00, 0x00, 0xa0, 0xa0,
|
||||
0x60, 0x20, 0x40, 0x00, 0xe0, 0x60, 0xc0, 0xe0, 0x00, 0x60, 0x40, 0x80, 0x40, 0x60, 0x00, 0x80,
|
||||
0x80, 0x00, 0x80, 0x80, 0x00, 0xc0, 0x40, 0x20, 0x40, 0xc0, 0x00, 0x60, 0xc0, 0x00, 0x00, 0x00,
|
||||
0x00,
|
||||
0x03, 0x03, 0x03, 0x03, 0x01, 0x03, 0x03, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x80,
|
||||
0x80, 0x00, 0x80, 0x00, 0xa0, 0xa0, 0x00, 0x00, 0x00, 0x00, 0xa0, 0xe0, 0xa0, 0xe0, 0xa0, 0x00,
|
||||
0x60, 0xc0, 0x60, 0xc0, 0x40, 0x00, 0x80, 0x20, 0x40, 0x80, 0x20, 0x00, 0xc0, 0xc0, 0xe0, 0xa0,
|
||||
0x60, 0x00, 0x80, 0x80, 0x00, 0x00, 0x00, 0x00, 0x40, 0x80, 0x80, 0x80, 0x40, 0x00, 0x80, 0x40,
|
||||
0x40, 0x40, 0x80, 0x00, 0xa0, 0x40, 0xa0, 0x00, 0x00, 0x00, 0x00, 0x40, 0xe0, 0x40, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x40, 0x80, 0x00, 0x00, 0x00, 0xe0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x80, 0x00, 0x20, 0x20, 0x40, 0x80, 0x80, 0x00, 0x60, 0xa0, 0xa0, 0xa0, 0xc0, 0x00, 0x20, 0x60,
|
||||
0x20, 0x20, 0x20, 0x00, 0xc0, 0x20, 0x40, 0x80, 0xe0, 0x00, 0xc0, 0x20, 0x40, 0x20, 0xc0, 0x00,
|
||||
0xa0, 0xa0, 0xe0, 0x20, 0x20, 0x00, 0xe0, 0x80, 0xc0, 0x20, 0xc0, 0x00, 0x60, 0x80, 0xe0, 0xa0,
|
||||
0xe0, 0x00, 0xe0, 0x20, 0x40, 0x80, 0x80, 0x00, 0xe0, 0xa0, 0xe0, 0xa0, 0xe0, 0x00, 0xe0, 0xa0,
|
||||
0xe0, 0x20, 0xc0, 0x00, 0x00, 0x80, 0x00, 0x80, 0x00, 0x00, 0x00, 0x40, 0x00, 0x40, 0x80, 0x00,
|
||||
0x20, 0x40, 0x80, 0x40, 0x20, 0x00, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0x00, 0x80, 0x40, 0x20, 0x40,
|
||||
0x80, 0x00, 0xe0, 0x20, 0x40, 0x00, 0x40, 0x00, 0x40, 0xa0, 0xe0, 0x80, 0x60, 0x00, 0x40, 0xa0,
|
||||
0xe0, 0xa0, 0xa0, 0x00, 0xc0, 0xa0, 0xc0, 0xa0, 0xc0, 0x00, 0x60, 0x80, 0x80, 0x80, 0x60, 0x00,
|
||||
0xc0, 0xa0, 0xa0, 0xa0, 0xc0, 0x00, 0xe0, 0x80, 0xe0, 0x80, 0xe0, 0x00, 0xe0, 0x80, 0xe0, 0x80,
|
||||
0x80, 0x00, 0x60, 0x80, 0xe0, 0xa0, 0x60, 0x00, 0xa0, 0xa0, 0xe0, 0xa0, 0xa0, 0x00, 0xe0, 0x40,
|
||||
0x40, 0x40, 0xe0, 0x00, 0x20, 0x20, 0x20, 0xa0, 0x40, 0x00, 0xa0, 0xa0, 0xc0, 0xa0, 0xa0, 0x00,
|
||||
0x80, 0x80, 0x80, 0x80, 0xe0, 0x00, 0xa0, 0xe0, 0xe0, 0xa0, 0xa0, 0x00, 0xa0, 0xe0, 0xe0, 0xe0,
|
||||
0xa0, 0x00, 0x40, 0xa0, 0xa0, 0xa0, 0x40, 0x00, 0xc0, 0xa0, 0xc0, 0x80, 0x80, 0x00, 0x40, 0xa0,
|
||||
0xa0, 0xe0, 0x60, 0x00, 0xc0, 0xa0, 0xe0, 0xc0, 0xa0, 0x00, 0x60, 0x80, 0x40, 0x20, 0xc0, 0x00,
|
||||
0xe0, 0x40, 0x40, 0x40, 0x40, 0x00, 0xa0, 0xa0, 0xa0, 0xa0, 0x60, 0x00, 0xa0, 0xa0, 0xa0, 0x40,
|
||||
0x40, 0x00, 0xa0, 0xa0, 0xe0, 0xe0, 0xa0, 0x00, 0xa0, 0xa0, 0x40, 0xa0, 0xa0, 0x00, 0xa0, 0xa0,
|
||||
0x40, 0x40, 0x40, 0x00, 0xe0, 0x20, 0x40, 0x80, 0xe0, 0x00, 0xe0, 0x80, 0x80, 0x80, 0xe0, 0x00,
|
||||
0x00, 0x80, 0x40, 0x20, 0x00, 0x00, 0xe0, 0x20, 0x20, 0x20, 0xe0, 0x00, 0x40, 0xa0, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0x00, 0x80, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0,
|
||||
0x60, 0xa0, 0xe0, 0x00, 0x80, 0xc0, 0xa0, 0xa0, 0xc0, 0x00, 0x00, 0x60, 0x80, 0x80, 0x60, 0x00,
|
||||
0x20, 0x60, 0xa0, 0xa0, 0x60, 0x00, 0x00, 0x60, 0xa0, 0xc0, 0x60, 0x00, 0x20, 0x40, 0xe0, 0x40,
|
||||
0x40, 0x00, 0x00, 0x60, 0xa0, 0xe0, 0x20, 0x40, 0x80, 0xc0, 0xa0, 0xa0, 0xa0, 0x00, 0x80, 0x00,
|
||||
0x80, 0x80, 0x80, 0x00, 0x20, 0x00, 0x20, 0x20, 0xa0, 0x40, 0x80, 0xa0, 0xc0, 0xc0, 0xa0, 0x00,
|
||||
0xc0, 0x40, 0x40, 0x40, 0xe0, 0x00, 0x00, 0xe0, 0xe0, 0xe0, 0xa0, 0x00, 0x00, 0xc0, 0xa0, 0xa0,
|
||||
0xa0, 0x00, 0x00, 0x40, 0xa0, 0xa0, 0x40, 0x00, 0x00, 0xc0, 0xa0, 0xa0, 0xc0, 0x80, 0x00, 0x60,
|
||||
0xa0, 0xa0, 0x60, 0x20, 0x00, 0x60, 0x80, 0x80, 0x80, 0x00, 0x00, 0x60, 0xc0, 0x60, 0xc0, 0x00,
|
||||
0x40, 0xe0, 0x40, 0x40, 0x60, 0x00, 0x00, 0xa0, 0xa0, 0xa0, 0x60, 0x00, 0x00, 0xa0, 0xa0, 0xe0,
|
||||
0x40, 0x00, 0x00, 0xa0, 0xe0, 0xe0, 0xe0, 0x00, 0x00, 0xa0, 0x40, 0x40, 0xa0, 0x00, 0x00, 0xa0,
|
||||
0xa0, 0x60, 0x20, 0x40, 0x00, 0xe0, 0x60, 0xc0, 0xe0, 0x00, 0x60, 0x40, 0x80, 0x40, 0x60, 0x00,
|
||||
0x80, 0x80, 0x00, 0x80, 0x80, 0x00, 0xc0, 0x40, 0x20, 0x40, 0xc0, 0x00, 0x60, 0xc0, 0x00, 0x00,
|
||||
0x00, 0x00, 0x40, 0xa0, 0x40, 0x00, 0x00, 0x00,
|
||||
};
|
||||
|
||||
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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20,
|
||||
0x20, 0x20, 0x20, 0x20, 0x00, 0x20, 0x00, 0x50, 0x50, 0x50, 0x00, 0x00, 0x00, 0x00, 0x00, 0x50,
|
||||
0x50, 0xf8, 0x50, 0xf8, 0x50, 0x50, 0x00, 0x20, 0x78, 0xa0, 0x70, 0x28, 0xf0, 0x20, 0x00, 0xc0,
|
||||
0xc8, 0x10, 0x20, 0x40, 0x98, 0x18, 0x00, 0x40, 0xa0, 0xa0, 0x40, 0xa8, 0x90, 0x68, 0x00, 0x30,
|
||||
0x30, 0x20, 0x40, 0x00, 0x00, 0x00, 0x00, 0x10, 0x20, 0x40, 0x40, 0x40, 0x20, 0x10, 0x00, 0x40,
|
||||
0x20, 0x10, 0x10, 0x10, 0x20, 0x40, 0x00, 0x20, 0xa8, 0x70, 0xf8, 0x70, 0xa8, 0x20, 0x00, 0x00,
|
||||
0x20, 0x20, 0xf8, 0x20, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x30, 0x20, 0x40, 0x00,
|
||||
0x00, 0x00, 0xf8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x30, 0x00, 0x00,
|
||||
0x08, 0x10, 0x20, 0x40, 0x80, 0x00, 0x00, 0x70, 0x88, 0x98, 0xa8, 0xc8, 0x88, 0x70, 0x00, 0x20,
|
||||
0x60, 0x20, 0x20, 0x20, 0x20, 0x70, 0x00, 0x70, 0x88, 0x08, 0x70, 0x80, 0x80, 0xf8, 0x00, 0xf8,
|
||||
0x08, 0x10, 0x30, 0x08, 0x88, 0x70, 0x00, 0x10, 0x30, 0x50, 0x90, 0xf8, 0x10, 0x10, 0x00, 0xf8,
|
||||
0x80, 0xf0, 0x08, 0x08, 0x88, 0x70, 0x00, 0x38, 0x40, 0x80, 0xf0, 0x88, 0x88, 0x70, 0x00, 0xf8,
|
||||
0x08, 0x08, 0x10, 0x20, 0x40, 0x80, 0x00, 0x70, 0x88, 0x88, 0x70, 0x88, 0x88, 0x70, 0x00, 0x70,
|
||||
0x88, 0x88, 0x78, 0x08, 0x10, 0xe0, 0x00, 0x00, 0x00, 0x20, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x20, 0x00, 0x20, 0x20, 0x40, 0x00, 0x08, 0x10, 0x20, 0x40, 0x20, 0x10, 0x08, 0x00, 0x00,
|
||||
0x00, 0xf8, 0x00, 0xf8, 0x00, 0x00, 0x00, 0x40, 0x20, 0x10, 0x08, 0x10, 0x20, 0x40, 0x00, 0x70,
|
||||
0x88, 0x08, 0x30, 0x20, 0x00, 0x20, 0x00, 0x70, 0x88, 0xa8, 0xb8, 0xb0, 0x80, 0x78, 0x00, 0x20,
|
||||
0x50, 0x88, 0x88, 0xf8, 0x88, 0x88, 0x00, 0xf0, 0x88, 0x88, 0xf0, 0x88, 0x88, 0xf0, 0x00, 0x70,
|
||||
0x88, 0x80, 0x80, 0x80, 0x88, 0x70, 0x00, 0xf0, 0x88, 0x88, 0x88, 0x88, 0x88, 0xf0, 0x00, 0xf8,
|
||||
0x80, 0x80, 0xf0, 0x80, 0x80, 0xf8, 0x00, 0xf8, 0x80, 0x80, 0xf0, 0x80, 0x80, 0x80, 0x00, 0x78,
|
||||
0x88, 0x80, 0x80, 0x98, 0x88, 0x78, 0x00, 0x88, 0x88, 0x88, 0xf8, 0x88, 0x88, 0x88, 0x00, 0x70,
|
||||
0x20, 0x20, 0x20, 0x20, 0x20, 0x70, 0x00, 0x38, 0x10, 0x10, 0x10, 0x10, 0x90, 0x60, 0x00, 0x88,
|
||||
0x90, 0xa0, 0xc0, 0xa0, 0x90, 0x88, 0x00, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0xf8, 0x00, 0x88,
|
||||
0xd8, 0xa8, 0xa8, 0xa8, 0x88, 0x88, 0x00, 0x88, 0x88, 0xc8, 0xa8, 0x98, 0x88, 0x88, 0x00, 0x70,
|
||||
0x88, 0x88, 0x88, 0x88, 0x88, 0x70, 0x00, 0xf0, 0x88, 0x88, 0xf0, 0x80, 0x80, 0x80, 0x00, 0x70,
|
||||
0x88, 0x88, 0x88, 0xa8, 0x90, 0x68, 0x00, 0xf0, 0x88, 0x88, 0xf0, 0xa0, 0x90, 0x88, 0x00, 0x70,
|
||||
0x88, 0x80, 0x70, 0x08, 0x88, 0x70, 0x00, 0xf8, 0xa8, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x88,
|
||||
0x88, 0x88, 0x88, 0x88, 0x88, 0x70, 0x00, 0x88, 0x88, 0x88, 0x88, 0x88, 0x50, 0x20, 0x00, 0x88,
|
||||
0x88, 0x88, 0xa8, 0xa8, 0xa8, 0x50, 0x00, 0x88, 0x88, 0x50, 0x20, 0x50, 0x88, 0x88, 0x00, 0x88,
|
||||
0x88, 0x50, 0x20, 0x20, 0x20, 0x20, 0x00, 0xf8, 0x08, 0x10, 0x70, 0x40, 0x80, 0xf8, 0x00, 0x78,
|
||||
0x40, 0x40, 0x40, 0x40, 0x40, 0x78, 0x00, 0x00, 0x80, 0x40, 0x20, 0x10, 0x08, 0x00, 0x00, 0x78,
|
||||
0x08, 0x08, 0x08, 0x08, 0x08, 0x78, 0x00, 0x20, 0x50, 0x88, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0x00, 0x60, 0x60, 0x20, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x60, 0x10, 0x70, 0x90, 0x78, 0x00, 0x80, 0x80, 0xb0, 0xc8, 0x88, 0xc8, 0xb0, 0x00, 0x00,
|
||||
0x00, 0x70, 0x88, 0x80, 0x88, 0x70, 0x00, 0x08, 0x08, 0x68, 0x98, 0x88, 0x98, 0x68, 0x00, 0x00,
|
||||
0x00, 0x70, 0x88, 0xf8, 0x80, 0x70, 0x00, 0x10, 0x28, 0x20, 0x70, 0x20, 0x20, 0x20, 0x00, 0x00,
|
||||
0x00, 0x70, 0x98, 0x98, 0x68, 0x08, 0x70, 0x80, 0x80, 0xb0, 0xc8, 0x88, 0x88, 0x88, 0x00, 0x20,
|
||||
0x00, 0x60, 0x20, 0x20, 0x20, 0x70, 0x00, 0x10, 0x00, 0x10, 0x10, 0x10, 0x90, 0x60, 0x00, 0x80,
|
||||
0x80, 0x90, 0xa0, 0xc0, 0xa0, 0x90, 0x00, 0x60, 0x20, 0x20, 0x20, 0x20, 0x20, 0x70, 0x00, 0x00,
|
||||
0x00, 0xd0, 0xa8, 0xa8, 0xa8, 0xa8, 0x00, 0x00, 0x00, 0xb0, 0xc8, 0x88, 0x88, 0x88, 0x00, 0x00,
|
||||
0x00, 0x70, 0x88, 0x88, 0x88, 0x70, 0x00, 0x00, 0x00, 0xb0, 0xc8, 0xc8, 0xb0, 0x80, 0x80, 0x00,
|
||||
0x00, 0x68, 0x98, 0x98, 0x68, 0x08, 0x08, 0x00, 0x00, 0xb0, 0xc8, 0x80, 0x80, 0x80, 0x00, 0x00,
|
||||
0x00, 0x78, 0x80, 0x70, 0x08, 0xf0, 0x00, 0x20, 0x20, 0xf8, 0x20, 0x20, 0x28, 0x10, 0x00, 0x00,
|
||||
0x00, 0x88, 0x88, 0x88, 0x98, 0x68, 0x00, 0x00, 0x00, 0x88, 0x88, 0x88, 0x50, 0x20, 0x00, 0x00,
|
||||
0x00, 0x88, 0x88, 0xa8, 0xa8, 0x50, 0x00, 0x00, 0x00, 0x88, 0x50, 0x20, 0x50, 0x88, 0x00, 0x00,
|
||||
0x00, 0x88, 0x88, 0x78, 0x08, 0x88, 0x70, 0x00, 0x00, 0xf8, 0x10, 0x20, 0x40, 0xf8, 0x00, 0x10,
|
||||
0x20, 0x20, 0x40, 0x20, 0x20, 0x10, 0x00, 0x20, 0x20, 0x20, 0x00, 0x20, 0x20, 0x20, 0x00, 0x40,
|
||||
0x20, 0x20, 0x10, 0x20, 0x20, 0x40, 0x00, 0x40, 0xa8, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x20, 0x00, 0x50, 0x50, 0x50, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x50, 0x50, 0xf8, 0x50, 0xf8, 0x50, 0x50, 0x00, 0x20, 0x78, 0xa0, 0x70, 0x28, 0xf0, 0x20, 0x00,
|
||||
0xc0, 0xc8, 0x10, 0x20, 0x40, 0x98, 0x18, 0x00, 0x40, 0xa0, 0xa0, 0x40, 0xa8, 0x90, 0x68, 0x00,
|
||||
0x30, 0x30, 0x20, 0x40, 0x00, 0x00, 0x00, 0x00, 0x10, 0x20, 0x40, 0x40, 0x40, 0x20, 0x10, 0x00,
|
||||
0x40, 0x20, 0x10, 0x10, 0x10, 0x20, 0x40, 0x00, 0x20, 0xa8, 0x70, 0xf8, 0x70, 0xa8, 0x20, 0x00,
|
||||
0x00, 0x20, 0x20, 0xf8, 0x20, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x30, 0x20, 0x40,
|
||||
0x00, 0x00, 0x00, 0xf8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x30, 0x00,
|
||||
0x00, 0x08, 0x10, 0x20, 0x40, 0x80, 0x00, 0x00, 0x70, 0x88, 0x98, 0xa8, 0xc8, 0x88, 0x70, 0x00,
|
||||
0x20, 0x60, 0x20, 0x20, 0x20, 0x20, 0x70, 0x00, 0x70, 0x88, 0x08, 0x70, 0x80, 0x80, 0xf8, 0x00,
|
||||
0xf8, 0x08, 0x10, 0x30, 0x08, 0x88, 0x70, 0x00, 0x10, 0x30, 0x50, 0x90, 0xf8, 0x10, 0x10, 0x00,
|
||||
0xf8, 0x80, 0xf0, 0x08, 0x08, 0x88, 0x70, 0x00, 0x38, 0x40, 0x80, 0xf0, 0x88, 0x88, 0x70, 0x00,
|
||||
0xf8, 0x08, 0x08, 0x10, 0x20, 0x40, 0x80, 0x00, 0x70, 0x88, 0x88, 0x70, 0x88, 0x88, 0x70, 0x00,
|
||||
0x70, 0x88, 0x88, 0x78, 0x08, 0x10, 0xe0, 0x00, 0x00, 0x00, 0x20, 0x00, 0x20, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x20, 0x00, 0x20, 0x20, 0x40, 0x00, 0x08, 0x10, 0x20, 0x40, 0x20, 0x10, 0x08, 0x00,
|
||||
0x00, 0x00, 0xf8, 0x00, 0xf8, 0x00, 0x00, 0x00, 0x40, 0x20, 0x10, 0x08, 0x10, 0x20, 0x40, 0x00,
|
||||
0x70, 0x88, 0x08, 0x30, 0x20, 0x00, 0x20, 0x00, 0x70, 0x88, 0xa8, 0xb8, 0xb0, 0x80, 0x78, 0x00,
|
||||
0x20, 0x50, 0x88, 0x88, 0xf8, 0x88, 0x88, 0x00, 0xf0, 0x88, 0x88, 0xf0, 0x88, 0x88, 0xf0, 0x00,
|
||||
0x70, 0x88, 0x80, 0x80, 0x80, 0x88, 0x70, 0x00, 0xf0, 0x88, 0x88, 0x88, 0x88, 0x88, 0xf0, 0x00,
|
||||
0xf8, 0x80, 0x80, 0xf0, 0x80, 0x80, 0xf8, 0x00, 0xf8, 0x80, 0x80, 0xf0, 0x80, 0x80, 0x80, 0x00,
|
||||
0x78, 0x88, 0x80, 0x80, 0x98, 0x88, 0x78, 0x00, 0x88, 0x88, 0x88, 0xf8, 0x88, 0x88, 0x88, 0x00,
|
||||
0x70, 0x20, 0x20, 0x20, 0x20, 0x20, 0x70, 0x00, 0x38, 0x10, 0x10, 0x10, 0x10, 0x90, 0x60, 0x00,
|
||||
0x88, 0x90, 0xa0, 0xc0, 0xa0, 0x90, 0x88, 0x00, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0xf8, 0x00,
|
||||
0x88, 0xd8, 0xa8, 0xa8, 0xa8, 0x88, 0x88, 0x00, 0x88, 0x88, 0xc8, 0xa8, 0x98, 0x88, 0x88, 0x00,
|
||||
0x70, 0x88, 0x88, 0x88, 0x88, 0x88, 0x70, 0x00, 0xf0, 0x88, 0x88, 0xf0, 0x80, 0x80, 0x80, 0x00,
|
||||
0x70, 0x88, 0x88, 0x88, 0xa8, 0x90, 0x68, 0x00, 0xf0, 0x88, 0x88, 0xf0, 0xa0, 0x90, 0x88, 0x00,
|
||||
0x70, 0x88, 0x80, 0x70, 0x08, 0x88, 0x70, 0x00, 0xf8, 0xa8, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00,
|
||||
0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x70, 0x00, 0x88, 0x88, 0x88, 0x88, 0x88, 0x50, 0x20, 0x00,
|
||||
0x88, 0x88, 0x88, 0xa8, 0xa8, 0xa8, 0x50, 0x00, 0x88, 0x88, 0x50, 0x20, 0x50, 0x88, 0x88, 0x00,
|
||||
0x88, 0x88, 0x50, 0x20, 0x20, 0x20, 0x20, 0x00, 0xf8, 0x08, 0x10, 0x70, 0x40, 0x80, 0xf8, 0x00,
|
||||
0x78, 0x40, 0x40, 0x40, 0x40, 0x40, 0x78, 0x00, 0x00, 0x80, 0x40, 0x20, 0x10, 0x08, 0x00, 0x00,
|
||||
0x78, 0x08, 0x08, 0x08, 0x08, 0x08, 0x78, 0x00, 0x20, 0x50, 0x88, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0x00, 0x60, 0x60, 0x20, 0x10, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x60, 0x10, 0x70, 0x90, 0x78, 0x00, 0x80, 0x80, 0xb0, 0xc8, 0x88, 0xc8, 0xb0, 0x00,
|
||||
0x00, 0x00, 0x70, 0x88, 0x80, 0x88, 0x70, 0x00, 0x08, 0x08, 0x68, 0x98, 0x88, 0x98, 0x68, 0x00,
|
||||
0x00, 0x00, 0x70, 0x88, 0xf8, 0x80, 0x70, 0x00, 0x10, 0x28, 0x20, 0x70, 0x20, 0x20, 0x20, 0x00,
|
||||
0x00, 0x00, 0x70, 0x98, 0x98, 0x68, 0x08, 0x70, 0x80, 0x80, 0xb0, 0xc8, 0x88, 0x88, 0x88, 0x00,
|
||||
0x20, 0x00, 0x60, 0x20, 0x20, 0x20, 0x70, 0x00, 0x10, 0x00, 0x10, 0x10, 0x10, 0x90, 0x60, 0x00,
|
||||
0x80, 0x80, 0x90, 0xa0, 0xc0, 0xa0, 0x90, 0x00, 0x60, 0x20, 0x20, 0x20, 0x20, 0x20, 0x70, 0x00,
|
||||
0x00, 0x00, 0xd0, 0xa8, 0xa8, 0xa8, 0xa8, 0x00, 0x00, 0x00, 0xb0, 0xc8, 0x88, 0x88, 0x88, 0x00,
|
||||
0x00, 0x00, 0x70, 0x88, 0x88, 0x88, 0x70, 0x00, 0x00, 0x00, 0xb0, 0xc8, 0xc8, 0xb0, 0x80, 0x80,
|
||||
0x00, 0x00, 0x68, 0x98, 0x98, 0x68, 0x08, 0x08, 0x00, 0x00, 0xb0, 0xc8, 0x80, 0x80, 0x80, 0x00,
|
||||
0x00, 0x00, 0x78, 0x80, 0x70, 0x08, 0xf0, 0x00, 0x20, 0x20, 0xf8, 0x20, 0x20, 0x28, 0x10, 0x00,
|
||||
0x00, 0x00, 0x88, 0x88, 0x88, 0x98, 0x68, 0x00, 0x00, 0x00, 0x88, 0x88, 0x88, 0x50, 0x20, 0x00,
|
||||
0x00, 0x00, 0x88, 0x88, 0xa8, 0xa8, 0x50, 0x00, 0x00, 0x00, 0x88, 0x50, 0x20, 0x50, 0x88, 0x00,
|
||||
0x00, 0x00, 0x88, 0x88, 0x78, 0x08, 0x88, 0x70, 0x00, 0x00, 0xf8, 0x10, 0x20, 0x40, 0xf8, 0x00,
|
||||
0x10, 0x20, 0x20, 0x40, 0x20, 0x20, 0x10, 0x00, 0x20, 0x20, 0x20, 0x00, 0x20, 0x20, 0x20, 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[] = {
|
||||
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,
|
||||
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, 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, 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, 0xe0, 0xe0, 0xe0,
|
||||
0x7f, 0x80, 0xff, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0,
|
||||
0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0,
|
||||
0xe1, 0xc0, 0xff, 0xc0, 0x7f, 0x80, 0x01, 0x80, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0,
|
||||
0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0,
|
||||
0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0x80, 0x7f, 0x80, 0xff, 0xc0,
|
||||
0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x7f, 0xc0, 0xff, 0xc0,
|
||||
0xff, 0x80, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xff, 0xc0,
|
||||
0x7f, 0x80, 0x7f, 0x80, 0xff, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0,
|
||||
0x01, 0xc0, 0x7f, 0xc0, 0xff, 0xc0, 0x7f, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0,
|
||||
0x01, 0xc0, 0x01, 0xc0, 0xff, 0xc0, 0x7f, 0x80, 0x61, 0x80, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0,
|
||||
0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xff, 0xc0, 0xff, 0xc0, 0x7f, 0xc0, 0x01, 0xc0,
|
||||
0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0x80, 0x7f, 0x80,
|
||||
0xff, 0xc0, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xff, 0x80,
|
||||
0xff, 0xc0, 0x7f, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0,
|
||||
0xff, 0xc0, 0x7f, 0x80, 0x7f, 0x80, 0xff, 0xc0, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00,
|
||||
0xe0, 0x00, 0xe0, 0x00, 0xff, 0x80, 0xff, 0xc0, 0xff, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0,
|
||||
0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xff, 0xc0, 0x7f, 0x80, 0x7f, 0x80, 0xff, 0xc0, 0x01, 0xc0,
|
||||
0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0,
|
||||
0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0x80,
|
||||
0x7f, 0x80, 0xff, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0,
|
||||
0xff, 0xc0, 0xff, 0xc0, 0xff, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0,
|
||||
0xe1, 0xc0, 0xff, 0xc0, 0x7f, 0x80, 0x7f, 0x80, 0xff, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0,
|
||||
0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xff, 0xc0, 0xff, 0xc0, 0x7f, 0xc0, 0x01, 0xc0, 0x01, 0xc0,
|
||||
0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0xff, 0xc0, 0x7f, 0x80, 0x00, 0x00, 0x00, 0x00,
|
||||
0xe0, 0xe0, 0xe0, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0xe0, 0xe0, 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, 0xe0, 0xe0,
|
||||
0xe0, 0x7f, 0x80, 0xff, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1,
|
||||
0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1,
|
||||
0xc0, 0xe1, 0xc0, 0xff, 0xc0, 0x7f, 0x80, 0x01, 0x80, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01,
|
||||
0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01,
|
||||
0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0x80, 0x7f, 0x80, 0xff,
|
||||
0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x7f, 0xc0, 0xff,
|
||||
0xc0, 0xff, 0x80, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xff,
|
||||
0xc0, 0x7f, 0x80, 0x7f, 0x80, 0xff, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01,
|
||||
0xc0, 0x01, 0xc0, 0x7f, 0xc0, 0xff, 0xc0, 0x7f, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01,
|
||||
0xc0, 0x01, 0xc0, 0x01, 0xc0, 0xff, 0xc0, 0x7f, 0x80, 0x61, 0x80, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1,
|
||||
0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xff, 0xc0, 0xff, 0xc0, 0x7f, 0xc0, 0x01,
|
||||
0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0x80, 0x7f,
|
||||
0x80, 0xff, 0xc0, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xff,
|
||||
0x80, 0xff, 0xc0, 0x7f, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01,
|
||||
0xc0, 0xff, 0xc0, 0x7f, 0x80, 0x7f, 0x80, 0xff, 0xc0, 0xe0, 0x00, 0xe0, 0x00, 0xe0, 0x00, 0xe0,
|
||||
0x00, 0xe0, 0x00, 0xe0, 0x00, 0xff, 0x80, 0xff, 0xc0, 0xff, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1,
|
||||
0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xff, 0xc0, 0x7f, 0x80, 0x7f, 0x80, 0xff, 0xc0, 0x01,
|
||||
0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01,
|
||||
0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01,
|
||||
0x80, 0x7f, 0x80, 0xff, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1,
|
||||
0xc0, 0xff, 0xc0, 0xff, 0xc0, 0xff, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1,
|
||||
0xc0, 0xe1, 0xc0, 0xff, 0xc0, 0x7f, 0x80, 0x7f, 0x80, 0xff, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1,
|
||||
0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xe1, 0xc0, 0xff, 0xc0, 0xff, 0xc0, 0x7f, 0xc0, 0x01, 0xc0, 0x01,
|
||||
0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0x01, 0xc0, 0xff, 0xc0, 0x7f, 0x80, 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[] = {
|
||||
|
||||
@@ -119,7 +119,7 @@ void DisplayController::update(uint32_t now, bool time_valid, const LocalTime &t
|
||||
_off_timer.cancel();
|
||||
_ensureOn();
|
||||
if (_page == Page::Dynamic)
|
||||
_showDynamic(time, show_no_wifi);
|
||||
_showDynamic(now, time, show_no_wifi);
|
||||
else
|
||||
_showOnce(Screen::Page);
|
||||
break;
|
||||
@@ -139,17 +139,26 @@ void DisplayController::_ensureOn()
|
||||
_screen = Screen::Unknown;
|
||||
}
|
||||
|
||||
// Drawn again each minute (time, date and background can change) and when the content changed
|
||||
void DisplayController::_showDynamic(const LocalTime &time, bool no_wifi)
|
||||
// Drawn again each minute (time, date and background can change), when the content changed,
|
||||
// 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;
|
||||
if (_screen == screen && _shown_hour == time.hour && _shown_minute == time.minute &&
|
||||
_shown_revision == _content_revision)
|
||||
bool redraw = _screen != screen || _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;
|
||||
if (no_wifi)
|
||||
{
|
||||
_frames.noWifi(time.hour, time.minute);
|
||||
_dynamic_moving = false;
|
||||
}
|
||||
else
|
||||
_frames.dynamicPage(time);
|
||||
{
|
||||
uint32_t wait = _frames.dynamicPage(time, now, redraw);
|
||||
_dynamic_moving = wait != 0;
|
||||
_dynamic_due = now + wait;
|
||||
}
|
||||
_screen = screen;
|
||||
_shown_hour = time.hour;
|
||||
_shown_minute = time.minute;
|
||||
|
||||
@@ -20,8 +20,11 @@ public:
|
||||
class Frames
|
||||
{
|
||||
public:
|
||||
// The dynamic page selected as page, drawn for this time
|
||||
virtual void dynamicPage(const LocalTime &time) = 0;
|
||||
// The dynamic page selected as page, drawn for this time. now: millis(). redraw: send it
|
||||
// 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)
|
||||
virtual void noWifi(int hour, int minute) = 0;
|
||||
// The bitmap selected as page
|
||||
@@ -96,6 +99,8 @@ private:
|
||||
Screen _screen = Screen::Unknown;
|
||||
int _shown_hour = -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 _shown_revision = 0;
|
||||
|
||||
@@ -106,6 +111,6 @@ private:
|
||||
uint32_t _wifi_lost_since = 0;
|
||||
|
||||
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);
|
||||
};
|
||||
@@ -81,7 +81,8 @@ static void drawDot(uint8_t *bitmap, int x, int y, DrawMode mode)
|
||||
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)
|
||||
return;
|
||||
@@ -103,7 +104,11 @@ void drawText(uint8_t *bitmap, const Font &font, int x, int y, const char *text,
|
||||
continue;
|
||||
for (int dy = 0; dy < scale; dy++)
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// (logic/bitmap_format.h). Dots outside the screen are skipped.
|
||||
void drawText(uint8_t *bitmap, const Font &font, int x, int y, const char *text, DrawMode mode);
|
||||
// (logic/bitmap_format.h). Dots outside the screen, and left of clip_left or from
|
||||
// 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);
|
||||
@@ -9,4 +9,6 @@ struct LocalTime
|
||||
int weekday = 6; // 0 = Sunday
|
||||
int hour = 0;
|
||||
int minute = 0;
|
||||
int second = 0;
|
||||
int millisecond = 0;
|
||||
};
|
||||
+228
-29
@@ -1,24 +1,26 @@
|
||||
#include "logic/page.h"
|
||||
|
||||
#include <ctype.h>
|
||||
#include <math.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
if (!terminated(text, size))
|
||||
return false;
|
||||
for (const char *c = text; *c; c++)
|
||||
if (*c < 32 || *c > 126)
|
||||
if (*c < 32 || *c > DEGREE_SIGN)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
@@ -44,6 +46,23 @@ static bool validAttribute(const char *attribute)
|
||||
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)
|
||||
{
|
||||
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))
|
||||
return "Invalid font name";
|
||||
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)) ||
|
||||
!validAttribute(e.attribute))
|
||||
return "Invalid entity or attribute";
|
||||
if (e.decimals < DECIMALS_AS_IS || e.decimals > DECIMALS_MAX)
|
||||
return "Decimals must be 0-4, or as received";
|
||||
if (e.type == ElementType::Clock && e.format >= CLOCK_FORMAT_COUNT)
|
||||
return "Unknown clock format";
|
||||
if (e.type == ElementType::Date && e.format >= DATE_FORMAT_COUNT)
|
||||
return "Unknown date format";
|
||||
if (e.format >= formatCount(e.type))
|
||||
return "Unknown format";
|
||||
if (!isfinite(e.factor) || e.factor == 0)
|
||||
return "The multiplier must be a number other than 0";
|
||||
if (e.scroll_width > 2 * BITMAP_WIDTH ||
|
||||
(e.scroll_width && (e.scroll_speed < SCROLL_SPEED_MIN || e.scroll_speed > SCROLL_SPEED_MAX)))
|
||||
return "Scrolling: window up to 168 dots, speed 1-30 dots per second";
|
||||
if (e.type == ElementType::HaValue && !validEntityId(e.entity))
|
||||
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;
|
||||
}
|
||||
@@ -100,6 +133,71 @@ const PageSlot *activeSlot(const DynamicPage &page, int minute_of_day)
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -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", "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)
|
||||
{
|
||||
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 mo = t.month >= 1 && t.month <= 12 ? t.month - 1 : 0;
|
||||
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)
|
||||
{
|
||||
if (value == nullptr || value[0] == '\0' || strcmp(value, "unavailable") == 0 || strcmp(value, "unknown") == 0)
|
||||
value = "--";
|
||||
char number[24];
|
||||
if (e.decimals >= 0 && strcmp(value, "--") != 0)
|
||||
char *end;
|
||||
double v = strtod(value, &end);
|
||||
if (strcmp(value, "--") != 0 && end != value && *end == '\0' && (e.decimals >= 0 || e.factor != 1))
|
||||
{
|
||||
char *end;
|
||||
double v = strtod(value, &end);
|
||||
if (end != value && *end == '\0')
|
||||
{
|
||||
v *= e.factor;
|
||||
if (e.decimals >= 0)
|
||||
snprintf(number, sizeof(number), "%.*f", e.decimals, v);
|
||||
if (strcmp(number, "-0") == 0 || strncmp(number, "-0.", 3) == 0)
|
||||
{
|
||||
// -0.0 after rounding: no minus sign
|
||||
bool zero = true;
|
||||
for (const char *c = number + 1; *c; c++)
|
||||
if (*c != '0' && *c != '.')
|
||||
zero = false;
|
||||
if (zero)
|
||||
memmove(number, number + 1, strlen(number));
|
||||
}
|
||||
value = number;
|
||||
}
|
||||
else
|
||||
snprintf(number, sizeof(number), "%.6g", v);
|
||||
// -0.0 after rounding: no minus sign
|
||||
if (number[0] == '-' && strspn(number + 1, "0.") == strlen(number + 1))
|
||||
memmove(number, number + 1, strlen(number));
|
||||
value = number;
|
||||
}
|
||||
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)
|
||||
{
|
||||
case ElementType::Clock:
|
||||
snprintf(out, size, e.format == CLOCK_H_MM ? "%d:%02d" : "%02d:%02d", t.hour, t.minute);
|
||||
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);
|
||||
break;
|
||||
case ElementType::Date:
|
||||
formatDate(e, t, out, size);
|
||||
break;
|
||||
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;
|
||||
default:
|
||||
snprintf(out, size, "%s", e.text);
|
||||
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;
|
||||
}
|
||||
}
|
||||
+64
-14
@@ -7,9 +7,11 @@
|
||||
#include "logic/local_time.h"
|
||||
|
||||
// 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
|
||||
// LittleFS (display/page_store), edited in the web UI. Pure logic, tested in
|
||||
// test/test_esp32.
|
||||
// (clock, date, Home Assistant value, text, countdown, stopwatch, image) drawn on top.
|
||||
// Stored as a binary struct on LittleFS (display/page_store), edited in the web UI. Pure
|
||||
// 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_SLOTS = 8;
|
||||
@@ -18,6 +20,7 @@ const int ELEMENT_TEXT_SIZE = 33;
|
||||
const int ELEMENT_SUFFIX_SIZE = 17;
|
||||
const int ELEMENT_ENTITY_SIZE = 65;
|
||||
const int ELEMENT_ATTRIBUTE_SIZE = 33;
|
||||
const char DEGREE_SIGN = 127;
|
||||
|
||||
enum class ElementType : uint8_t
|
||||
{
|
||||
@@ -25,6 +28,9 @@ enum class ElementType : uint8_t
|
||||
Date,
|
||||
HaValue, // a Home Assistant entity state (or attribute)
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -54,9 +60,11 @@ enum class Language : uint8_t
|
||||
};
|
||||
|
||||
// Clock formats
|
||||
const uint8_t CLOCK_HH_MM = 0; // 08:05
|
||||
const uint8_t CLOCK_H_MM = 1; // 8:05
|
||||
const uint8_t CLOCK_FORMAT_COUNT = 2;
|
||||
const uint8_t CLOCK_HH_MM = 0; // 08:05
|
||||
const uint8_t CLOCK_H_MM = 1; // 8:05
|
||||
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)
|
||||
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_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
|
||||
const int8_t DECIMALS_AS_IS = -1;
|
||||
const int8_t DECIMALS_MAX = 4;
|
||||
|
||||
// Scrolling: dots per second, and the empty dots between the end and the next start
|
||||
const uint8_t SCROLL_SPEED_MIN = 1;
|
||||
const uint8_t SCROLL_SPEED_MAX = 30;
|
||||
const int SCROLL_GAP = 12;
|
||||
|
||||
// Background from `start` (minutes since midnight) until the next slot
|
||||
struct PageSlot
|
||||
{
|
||||
@@ -87,13 +113,20 @@ struct PageElement
|
||||
Align align = Align::Left;
|
||||
Overlay overlay = Overlay::OnTop;
|
||||
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;
|
||||
int8_t decimals = DECIMALS_AS_IS;
|
||||
char text[ELEMENT_TEXT_SIZE] = ""; // Text: the text; HaValue: shown before the value
|
||||
char suffix[ELEMENT_SUFFIX_SIZE] = ""; // HaValue: shown after the value (unit)
|
||||
char entity[ELEMENT_ENTITY_SIZE] = ""; // HaValue: e.g. sensor.outside_temperature
|
||||
char attribute[ELEMENT_ATTRIBUTE_SIZE] = ""; // HaValue: empty = the state
|
||||
// Text: the text; HaValue, Countdown, Stopwatch: shown before the value; Image: the
|
||||
// bitmap name, or with an entity the start of it (the state is added)
|
||||
char text[ELEMENT_TEXT_SIZE] = "";
|
||||
// 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
|
||||
@@ -112,10 +145,27 @@ const char *validatePage(const DynamicPage &page);
|
||||
// before when it is earlier than the first one. nullptr without slots.
|
||||
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
|
||||
// "--"). out_size must be at least 2.
|
||||
void elementText(const PageElement &element, const LocalTime &time, const char *ha_value, char *out,
|
||||
// What a text element needs besides the time
|
||||
struct ElementValues
|
||||
{
|
||||
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);
|
||||
|
||||
// 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 '.')
|
||||
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
@@ -1,5 +1,6 @@
|
||||
#include "logic/page_render.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.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);
|
||||
}
|
||||
|
||||
void renderPage(const DynamicPage &page, const LocalTime &time, PageResources &resources, uint8_t *bitmap,
|
||||
ElementBox *boxes)
|
||||
// The earlier of two "next change" times (0 = none)
|
||||
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);
|
||||
const PageSlot *slot = activeSlot(page, time.hour * 60 + time.minute);
|
||||
if (slot && slot->bitmap[0] && !resources.background(slot->bitmap, bitmap))
|
||||
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++)
|
||||
{
|
||||
const PageElement &e = page.elements[i];
|
||||
const char *ha_value = e.type == ElementType::HaValue ? resources.haValue(e) : nullptr;
|
||||
char text[64];
|
||||
elementText(e, time, ha_value, text, sizeof(text));
|
||||
|
||||
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)
|
||||
ElementBox box;
|
||||
if (e.type == ElementType::Image)
|
||||
drawImage(e, resources, bitmap, box);
|
||||
else
|
||||
{
|
||||
boxes[i].x = x;
|
||||
boxes[i].y = e.y;
|
||||
boxes[i].width = width;
|
||||
boxes[i].height = height;
|
||||
ElementValues values;
|
||||
if (e.type == ElementType::HaValue)
|
||||
values.ha_value = resources.haValue(e);
|
||||
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;
|
||||
}
|
||||
@@ -9,19 +9,22 @@
|
||||
// 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.
|
||||
|
||||
// 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
|
||||
{
|
||||
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;
|
||||
// A font by name; a font without data when it is gone ("Normal" is used then)
|
||||
virtual Font font(const char *name) = 0;
|
||||
// The current value of a Home Assistant element; nullptr when not known
|
||||
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
|
||||
{
|
||||
int16_t x = 0;
|
||||
@@ -30,6 +33,10 @@ struct ElementBox
|
||||
int16_t height = 0;
|
||||
};
|
||||
|
||||
// boxes: nullptr, or room for page.element_count boxes
|
||||
void renderPage(const DynamicPage &page, const LocalTime &time, PageResources &resources, uint8_t *bitmap,
|
||||
ElementBox *boxes = nullptr);
|
||||
// now_ms: a running millisecond counter (millis()), for scrolling. boxes: nullptr, or room
|
||||
// for page.element_count boxes.
|
||||
// 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);
|
||||
@@ -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
|
||||
};
|
||||
@@ -70,7 +70,8 @@ void haApiWatch(const DynamicPage *page)
|
||||
for (int i = 0; i < page->element_count; 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;
|
||||
Entry &e = add(element.entity, element.attribute);
|
||||
if (!e.watched)
|
||||
@@ -127,6 +128,7 @@ static const char *fetch(const String &entity, const String &attribute, String &
|
||||
value = "";
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#include "config.h"
|
||||
#include "credentials.h"
|
||||
#include "display/bitmap_store.h"
|
||||
#include "display/page_resources.h"
|
||||
#include "display/page_store.h"
|
||||
#include "net/ha_page_select.h"
|
||||
#include "system/boot_target.h"
|
||||
@@ -27,6 +28,8 @@ static HAPageSelect offSelect("FlipDotDisplayOffImage");
|
||||
static HASensor firmwareSensor("FlipDotDisplayFirmware");
|
||||
static HASensor ipSensor("FlipDotDisplayIP");
|
||||
static HAButton recoveryButton("FlipDotDisplayRecovery");
|
||||
static HASwitch stopwatchSwitch("FlipDotDisplayStopwatch");
|
||||
static HAButton stopwatchReset("FlipDotDisplayStopwatchReset");
|
||||
|
||||
// Removed entities: their retained discovery config is cleared, so Home Assistant
|
||||
// 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); });
|
||||
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.setIcon("mdi:lifebuoy");
|
||||
recoveryButton.onCommand([](HAButton *) { rebootToRecovery(RecoveryReason::Manual); });
|
||||
@@ -260,6 +274,9 @@ void homeAssistantLoop()
|
||||
firmware.loop();
|
||||
mode.update();
|
||||
offImage.update();
|
||||
// Started or stopped in the web UI
|
||||
if (stopwatchSwitch.getCurrentState() != stopwatch.running())
|
||||
stopwatchSwitch.setState(stopwatch.running());
|
||||
}
|
||||
|
||||
void homeAssistantSetFirmwareStatus(const char *status)
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
// FlipDot Display IP sensor: the IP address
|
||||
// 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 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
|
||||
// and MQTT_PASSWORD from secrets.h. Changes need a restart.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
#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 OVERLAYS[] = {"top", "box", "invert"};
|
||||
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;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
char time[6];
|
||||
@@ -43,20 +52,27 @@ void pageToJson(const DynamicPage &page, JsonObject out)
|
||||
o["format"] = e.format;
|
||||
o["language"] = LANGUAGES[(int)e.language];
|
||||
o["decimals"] = e.decimals;
|
||||
o["text"] = e.text;
|
||||
o["suffix"] = e.suffix;
|
||||
o["text"] = withDegrees(e.text);
|
||||
o["suffix"] = withDegrees(e.suffix);
|
||||
o["entity"] = e.entity;
|
||||
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)
|
||||
{
|
||||
const char *text = value | "";
|
||||
if (strlen(text) >= size)
|
||||
String text = value | "";
|
||||
text.replace(DEGREE_UTF8, String(DEGREE_SIGN));
|
||||
if (text.length() >= size)
|
||||
return false;
|
||||
strcpy(out, text);
|
||||
strcpy(out, text.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -95,6 +111,12 @@ const char *pageFromJson(JsonVariantConst in, DynamicPage &page)
|
||||
e.y = constrain(o["y"] | 0, -1000, 1000);
|
||||
e.format = constrain(o["format"] | 0, 0, 255);
|
||||
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)) ||
|
||||
!copyText(o["suffix"], e.suffix, sizeof(e.suffix)) || !copyText(o["entity"], e.entity, sizeof(e.entity)) ||
|
||||
!copyText(o["attribute"], e.attribute, sizeof(e.attribute)))
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
// {slots: [{start: "08:00", bitmap: "Pikachu"}],
|
||||
// elements: [{type: "clock"|"date"|"ha"|"text", x, y, align: "left"|"center"|"right",
|
||||
// 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);
|
||||
// Returns nullptr or a message for the user. Validates the page (validatePage()).
|
||||
|
||||
+48
-21
@@ -50,6 +50,8 @@ button.d{color:var(--bad)}
|
||||
<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 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>
|
||||
<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>
|
||||
@@ -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>
|
||||
<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>
|
||||
<button class="s" onclick="peAdd('ha')">+ Home Assistant value</button><button class="s" onclick="peAdd('text')">+ Text</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.</p></div>
|
||||
<button class="s" onclick="peAdd('ha')">+ Home Assistant value</button><button class="s" onclick="peAdd('text')">+ Text</button>
|
||||
<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 ° 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>
|
||||
<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>
|
||||
@@ -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><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 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°C" maxlength="40"><input id="f_name" placeholder="font name" maxlength="24">
|
||||
<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>
|
||||
|
||||
@@ -183,7 +187,7 @@ async function pollScreen(){try{const s=await api('/api/screen');draw($('mirror'
|
||||
setTimeout(pollScreen,document.hidden?5000:1500)}
|
||||
async function pollStatus(){try{status=await api('/api/status');
|
||||
$('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'],
|
||||
['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'],
|
||||
@@ -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 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()}
|
||||
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 stopLive(){await post('/api/live/stop');msg('Back to normal')}
|
||||
// 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']];
|
||||
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 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','')}
|
||||
@@ -233,6 +244,7 @@ function peClose(){pe=null;$('pe').style.display='none';$('t-pages').scrollIntoV
|
||||
function peBuild(){peSlots();peElems()}
|
||||
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 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];
|
||||
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]));
|
||||
@@ -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 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 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()}};
|
||||
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']]))));
|
||||
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]]))),
|
||||
field('Language',sel(e,'language',[['nl','Nederlands'],['en','English']],peElems))));
|
||||
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('Attribute',inp(e,'attribute','text',{placeholder:'(the state)',size:12,maxLength:32})),btn('Test',()=>haTest(e))));
|
||||
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('Decimals',sel(e,'decimals',[[-1,'as received'],[0,'0'],[1,'1'],[2,'2'],[3,'3'],[4,'4']]))))}
|
||||
if(e.type=='text')rows.push(mk('div',{className:'row'},field('Text',inp(e,'text','text',{size:24,maxLength:32}))));
|
||||
const row=(...k)=>rows.push(mk('div',{className:'row'},...k));
|
||||
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)']])));
|
||||
if(e.type=='date')row(field('Format',sel(e,'format',DATEF.map((x,k)=>[k,x[e.language=='en'?1:0]]))),
|
||||
field('Language',sel(e,'language',[['nl','Nederlands'],['en','English']],peElems)));
|
||||
if(e.type=='ha'){row(field('Entity',inp(e,'entity','text',{placeholder:'sensor.outside_temperature',size:28,maxLength:64})),
|
||||
field('Attribute',inp(e,'attribute','text',{placeholder:'(the state)',size:12,maxLength:32})),btn('Test',()=>haTest(e)));
|
||||
row(field('Before',inp(e,'text','text',{size:8,maxLength:32})),field('After',inp(e,'suffix','text',{size:6,maxLength:16})),
|
||||
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)']);
|
||||
rows.push(mk('div',{className:'row'},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)),
|
||||
field('Overlay',sel(e,'overlay',[['top','on top'],['box','box (clear behind)'],['invert','inverted']]))));
|
||||
const pos=[field('X',inp(e,'x','number',{style:'width:5em'})),field('Y',inp(e,'y','number',{style:'width:5em'}))];
|
||||
const ovl=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)})}
|
||||
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()}
|
||||
@@ -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;
|
||||
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=[];
|
||||
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;
|
||||
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)));
|
||||
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));
|
||||
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)}}));
|
||||
fontData={bytes,glyphs,h,sp};fontPreview()}
|
||||
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}
|
||||
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}
|
||||
|
||||
@@ -161,6 +161,9 @@ static void handleStatus()
|
||||
doc["page"] = appSettings().page;
|
||||
doc["off_page"] = appSettings().off_page;
|
||||
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>();
|
||||
ha["enabled"] = appSettings().home_assistant.enabled;
|
||||
@@ -282,7 +285,7 @@ static void handlePages()
|
||||
{
|
||||
if (!loadPage(info.name.c_str(), *page))
|
||||
continue;
|
||||
renderPage(*page, time, pageResources, bitmap);
|
||||
renderPage(*page, time, millis(), pageResources, bitmap);
|
||||
bitmapToHex(bitmap, hex);
|
||||
JsonDocument doc;
|
||||
doc["name"] = info.name;
|
||||
@@ -346,7 +349,7 @@ static void handlePagePreview()
|
||||
|
||||
uint8_t bitmap[BITMAP_BYTES];
|
||||
ElementBox boxes[PAGE_ELEMENTS];
|
||||
renderPage(*page, time, pageResources, bitmap, boxes);
|
||||
renderPage(*page, time, millis(), pageResources, bitmap, boxes);
|
||||
char hex[BITMAP_HEX_LENGTH + 1];
|
||||
bitmapToHex(bitmap, hex);
|
||||
JsonDocument doc;
|
||||
@@ -479,6 +482,30 @@ static void handleLive()
|
||||
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()
|
||||
{
|
||||
appStopLive();
|
||||
@@ -647,6 +674,7 @@ void webUiBegin()
|
||||
server.on("/api/page", HTTP_POST, handlePage);
|
||||
server.on("/api/live", HTTP_POST, handleLive);
|
||||
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_POST, handleSettingsPost);
|
||||
server.on("/api/restart", HTTP_POST, handleRestart);
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
// POST /api/page {page: a dynamic page or bitmap name}
|
||||
// POST /api/live show now {data}
|
||||
// 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
|
||||
// the export; otherwise only *_set)
|
||||
// POST /api/settings changed fields; {settings, restart}
|
||||
|
||||
+1
-1
@@ -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 and flip it right away (direct mode) |
|
||||
| `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. |
|
||||
| `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 |
|
||||
|
||||
@@ -116,8 +116,15 @@ void CommandParser::_runLine()
|
||||
break;
|
||||
case 'd':
|
||||
case 'D':
|
||||
_display.display();
|
||||
_frame_ended = true;
|
||||
// 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();
|
||||
_frame_ended = true;
|
||||
}
|
||||
break;
|
||||
case 'f':
|
||||
case 'F':
|
||||
|
||||
@@ -23,7 +23,7 @@ public:
|
||||
BROSE9323(uint8_t, uint8_t, uint8_t, uint16_t) {}
|
||||
|
||||
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 fillScreen(uint16_t color) { log("fill", color); }
|
||||
void setDirect(bool d) { direct = d; }
|
||||
|
||||
+350
-11
@@ -24,6 +24,7 @@
|
||||
#include "logic/schedule.h"
|
||||
#include "logic/settings.h"
|
||||
#include "logic/status_led.h"
|
||||
#include "logic/stopwatch.h"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fakes
|
||||
@@ -53,12 +54,16 @@ class FakeFrames : public Frames
|
||||
{
|
||||
public:
|
||||
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];
|
||||
snprintf(s, sizeof(s), "clock %02d:%02d", time.hour, time.minute);
|
||||
shown.push_back(s);
|
||||
last_redraw = redraw;
|
||||
return wait;
|
||||
}
|
||||
void page() override { shown.push_back("page"); }
|
||||
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.minute = 5;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1085,13 +1093,13 @@ void test_render_background_follows_the_slots()
|
||||
FakeResources res;
|
||||
DynamicPage page = twoSlotPage();
|
||||
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_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));
|
||||
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++)
|
||||
TEST_ASSERT_EQUAL_HEX8(0, b[i]); // blank
|
||||
}
|
||||
@@ -1111,12 +1119,12 @@ void test_render_overlays()
|
||||
|
||||
uint8_t b[BITMAP_BYTES];
|
||||
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, 12, 2));
|
||||
|
||||
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, 10, 2));
|
||||
TEST_ASSERT_TRUE(bitmapPixel(b, 12, 2));
|
||||
@@ -1125,7 +1133,7 @@ void test_render_overlays()
|
||||
TEST_ASSERT_TRUE(bitmapPixel(b, 8, 2));
|
||||
|
||||
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_FALSE(bitmapPixel(b, 12, 2));
|
||||
}
|
||||
@@ -1144,7 +1152,7 @@ void test_render_alignment_and_boxes()
|
||||
page.elements[1].align = Align::Center;
|
||||
ElementBox boxes[2];
|
||||
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(29, boxes[0].width);
|
||||
TEST_ASSERT_EQUAL(8, boxes[0].height);
|
||||
@@ -1161,7 +1169,7 @@ void test_render_unknown_font_uses_normal()
|
||||
strcpy(page.elements[0].font, "Gone");
|
||||
ElementBox box;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1179,7 +1187,7 @@ void test_builtin_clock_page_is_the_old_clock()
|
||||
FakeResources res;
|
||||
TEST_ASSERT_TRUE(builtinPage(BUILTIN_PAGE_CLOCK, page));
|
||||
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, 10));
|
||||
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));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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()
|
||||
{
|
||||
UNITY_BEGIN();
|
||||
@@ -1288,6 +1615,18 @@ int main()
|
||||
RUN_TEST(test_render_unknown_font_uses_normal);
|
||||
RUN_TEST(test_builtin_clock_page_is_the_old_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();
|
||||
}
|
||||
@@ -103,6 +103,27 @@ void test_display()
|
||||
send("D\n");
|
||||
TEST_ASSERT_EQUAL(1, display.count("display"));
|
||||
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()
|
||||
@@ -498,6 +519,8 @@ int main()
|
||||
RUN_TEST(test_direct_pixel_flips_immediately_and_resets_direct_mode);
|
||||
RUN_TEST(test_fill);
|
||||
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_command_split_over_several_reads_with_crlf);
|
||||
RUN_TEST(test_print_text_with_newline_escape);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""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
|
||||
|
||||
@@ -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")
|
||||
|
||||
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):
|
||||
@@ -62,6 +70,7 @@ def classic():
|
||||
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)]
|
||||
glyphs.append((5, rows))
|
||||
glyphs.append((4, DEGREE[8]))
|
||||
return encode("Normal", 8, 1, glyphs)
|
||||
|
||||
|
||||
@@ -91,6 +100,7 @@ def tom_thumb():
|
||||
for i in digits:
|
||||
width, rows = glyphs[i]
|
||||
glyphs[i] = (widest, ["." * (widest - width) + r for r in rows])
|
||||
glyphs.append((3, DEGREE[6]))
|
||||
return encode("Small", 6, 1, glyphs)
|
||||
|
||||
|
||||
@@ -146,6 +156,7 @@ def full_height():
|
||||
glyphs.append((len(special[ch][0]), special[ch]))
|
||||
else:
|
||||
glyphs.append((0, [""] * 19)) # not in this font: skipped
|
||||
glyphs.append((6, DEGREE[19]))
|
||||
return encode("Full height", 19, 2, glyphs)
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user