Compare commits

...
6 Commits
Author SHA1 Message Date
mangavalkandClaude Opus 5.5 abd63d070f Tests against lost bytes in dense frames
- test_nano: every hex digit of B data is one drawDots() call, never single
  drawPixel() calls (the slow path that broke dense images in 2.19.2)
- tools/display_smoke_test.py: all yellow, a dense image and a checkerboard
  in live mode on the real display; fails on any frame check problem (wrong
  image, no answer, Nano restart, ERR reply)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 00:30:31 +02:00
mangavalkandClaude Opus 5.5 7a28e34380 Fix dense images on the Nano and the off image in slow mode
- Nano: B data is drawn fast enough for the serial line. A hex digit arrives
  every 87 us; with a division and four drawPixel() calls per digit a lit
  digit took about 105 us, so dense frames (all yellow, the panda bitmap)
  overflowed the 64 byte receive buffer: frames not shown or wrong, "Line
  too long", frame check repairs that failed the same way. Now the parser
  counts columns and rows and BROSE9323::drawDots() sets the 4 dots
  (about 20 us).
- ESP32: Power off and the start of the night draw the off image also when
  slow mode had the power off already (it powers on for it, once)
- Tests: drawDots() against drawPixel(), off image in slow mode
- Version 2.19.3

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 00:24:14 +02:00
mangavalkandClaude Opus 5.5 41af2944bc Faster web UI: only changed lists reloaded, icon cache kept, storage usage cached
- Web UI: loadLists(only) fetches only the lists an action changed; saving a
  bitmap, icon or font also reloads the pages that use it
- Icons: a save or delete forgets only that name and updates the name list in
  place instead of listing the directory again
- Storage usage cached until a store writes or deletes a file (at most 60 s):
  /api/status 0.6 s -> 0.08 s
- Version 2.19.2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 00:13:46 +02:00
mangavalkandClaude Opus 5.5 72a193130e Page names kept in RAM: /api/pages 1.8 s -> 1.05 s
Measured on the display with 16 pages: listing the directory took 350 ms,
and every loadPage() first asked LittleFS whether the file exists (26 ms
each) before opening it (25 ms) and reading it (11 ms); drawing all
previews took only 29 ms. The names of the stored pages are now kept
until a page is saved or deleted, and storedPage() looks there instead
of calling exists().

Version 2.19.1, tested on the display over OTA (save, list, delete).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:31:01 +02:00
mangavalkandClaude Opus 5.5 0166d0ee46 Stopwatch counts in real time from a start date, kept over a restart
The stopwatch counted with millis(), so it started at 0 after every
restart and would overflow after 49 days. It now keeps the real start
time (UTC ms) and the time counted before a pause, stored in NVS
(system/stopwatch_store) on every change, so it keeps counting while
the display is off or restarting. Stop pauses, Start continues, and the
Stopwatch tab can set the start to a date and time (local, summer time
by mktime) to count from, e.g. days since an event. Start, stop and set
need the synced time.

New page formats: 12d 4:05:09 and 3w 2d.

Version 2.19.0, tested on the display over OTA (set start, future and
invalid dates refused, pause, restart).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:22:32 +02:00
mangavalkandClaude Opus 5.5 f5e77c7eca Icon cache holds all icons, icon list kept in RAM
With the 17 life_ patterns there are 66 icons, more than the 64 names the
cache held. It was cleared when full, so every pass over all icons (Icons
tab, page previews) read each one from LittleFS again: /api/icons took
6.5 s and blocked loop() all that time. The cache now holds 128 names and
forgets the missing names first. The sorted icon list is kept until an
icon is saved or deleted. /api/icons: 6.6 s -> 0.12 s.

Version 2.18.2, tested on the display over OTA.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:10:47 +02:00
37 changed files with 706 additions and 123 deletions

No files matched your search

+22
View File
@@ -69,6 +69,16 @@ See README.md for the overview.
longer than about 5 ms overflows the 64-byte serial receive buffer; lost bytes shift the
rest of a bitmap. That was the "picture moved up" bug. `test_driver` checks the time per
`update()` call with a simulated panel.
- The `B` data path must also keep up with the line: a hex digit arrives every 87 µs.
`_bitmapData()` counts columns and rows (no divisions) and sets each digit's 4 dots with
`BROSE9323::drawDots()`. With `p % w`, `p / w` and four `drawPixel()` calls it took about
105 µs per `F`, so dense images (all yellow, a full picture) lost bytes ("Line too long",
frame check mismatches). Check new per-character work with `avr-objdump` (the mock
clock in the tests doesn't count CPU time); `test_nano` only checks that each digit is
one `drawDots()` call. After flashing Nano or driver changes, run
`python tools/display_smoke_test.py` (asks the user first: it flips the dots for about
20 s): all yellow, a dense image and a checkerboard in live mode, fails on any frame
check problem.
- Flip time is stored in EEPROM as a magic word at address 0 and a `uint16_t` at
address 2. Values outside 50–2000 µs are rejected.
- The Nano replies `READY` (at boot), `ERR: ...` and `C xxxx` (checksum of its image, for
@@ -218,12 +228,24 @@ See README.md for the overview.
answers through a queue of plain structs: never share Strings or other state with it,
and never wait for it in `loop()`. The web UI's test button polls (`{"pending":true}`).
The web server stays in `loop()` on purpose (its handlers touch everything).
- The stopwatch (`logic/stopwatch.h`) counts in real time (UTC ms from `epochMs()`, not
`millis()`), stored in NVS on every change (`system/stopwatch_store.cpp`, bump `VERSION`
when `Stopwatch::State` changes). Start, stop and set need the synced time.
- Entity ids may contain `{plex_user}` (`expandEntity()` in `logic/page`), filled in by
`net/ha_api` with `settings.plex_user` in entity id form (`plexUserSlug()`).
- Bitmaps are cached in RAM (`display/bitmap_store`, also missing names): a LittleFS read
takes about 100 ms, and pages are drawn for every frame and every web preview. Writes
must go through `saveBitmap()`/`deleteBitmap()`, which clear the cache. Long JSON lists
are sent in pieces of about 2 KB (`sendPiece()` in `web_ui.cpp`).
- Icons are cached too (`display/icon_store`, all of them). A save or delete only forgets
that name (`forget()`) and updates the cached name list in place (`listAdd()`/
`listRemove()`): clearing it all made the next icon list read every icon again (7 s).
- Storage usage (`bitmapStorageUsage()`, for `/api/status`) is cached: `usedBytes()` walks
the file system (about 0.5 s). Every store calls `bitmapStorageChanged()` after a write
or delete; the cache also expires after 60 s.
- The web UI reloads only the lists that changed (`loadLists(only)`, the ESP32 answers one
request at a time): saving a bitmap, icon or font also reloads the pages when a page
uses it (`pagesUsing()`, `pagesWithFont()`).
- Check the page's JavaScript after changes: extract the `<script>` part and run
`node --check` on it (node is installed).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 131 KiB

+2 -1
View File
@@ -29,7 +29,8 @@
Schedule pages and rotation rules with "skip in slow mode" are left out of the rotation
(none left in the block: the chosen page).
- **23:31–08:00:** the screen is cleared (or shows the off image chosen in the web UI)
and power is cut after 30 seconds.
and power is cut after 30 seconds. When slow mode had the power off already, it is
switched on for this. The same goes for the Power switch.
3. **Home Assistant**: switches, dropdowns and sensors over MQTT, see
[Home Assistant](home-assistant.md).
4. **Nano.** The Nano reads commands from the serial line and updates its frame buffer.
+6 -2
View File
@@ -36,7 +36,7 @@ pages that can be changed; their Reset button brings back the original. See the
| Home Assistant value | An entity or attribute (also a path into lists, e.g. `forecast.1.temperature`), text before and after. Shown as a number (multiplier, decimals, auto unit `1257 W` → `1.3 kW`, without minus sign), a duration (`2h 21m`, `2:21:00`, `2:21`, `2 uur`), a time from an HA timestamp (`23:07`, `do 23:07`, `15-10 23:07`, `over 34 min`) or with own texts per state (`printing=Bezig, standby=Klaar`) |
| Text | Up to 32 characters |
| 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 | `4:05`, `0:04`, `0:04:05`; started in the [Stopwatch](#stopwatch) tab or Home Assistant |
| Stopwatch | `4:05`, `0:04`, `0:04:05`, `12d 4:05:09`, `3w 2d`; started in the [Stopwatch](#stopwatch) tab or Home Assistant |
| Image | An icon (or a bitmap) at a position, fixed or chosen by a Home Assistant state (`weer ` + `cloudy` = the icon `weer cloudy`, `weer default` when there is none) |
| Progress bar | Filled from the left by a Home Assistant number: value x multiplier / "full at" (a percentage: full at 100). Width, height, with or without a frame. Optionally the percentage on top (e.g. 42%, rounded down, in the chosen font): its dots invert the bar, so it stays readable on the filled and the empty part; left out when it doesn't fit inside (Small needs a height of 7 with a frame) |
| Game of Life | [Conway's Game of Life](https://conwaylife.com/wiki/Conway%27s_Game_of_Life) in an area (the whole display by default), one per page. Start: random dots (density %), one pattern, or the patterns whose names start with a text (e.g. `life_`) in turn or in random order, in the middle or at a random place. Edges: wrap around, dead, or a hidden margin (the world is 16 dots larger on every side, so gliders fly out of view and disappear). Speed in generations per second. It restarts when it stops changing (still, or repeating within 16 generations) and/or after a number of generations, after running on for a few seconds (an empty board at once) |
@@ -80,7 +80,11 @@ is shown instead. The display schedule ([Settings](#settings)) still decides whe
## Stopwatch
Start, stop and reset the stopwatch that pages can show (also in Home Assistant).
Start, stop and reset the stopwatch that pages can show (also in Home Assistant). It
counts in real time from its start, so it keeps counting while the display is off or
restarting (it is stored in NVS), and can run for years. Stop pauses it, Start continues.
**Set start** makes it count from a date and time you pick (e.g. days since an event).
Start and Set start need the synced time.
![Stopwatch](../Pictures/web-ui/stopwatch.png)
+19
View File
@@ -290,6 +290,25 @@ void BROSE9323::printBuffer(void)
}
}
void BROSE9323::drawDots(int16_t x, int16_t y, uint8_t bits, bool color)
{
if (!(bits & 0xF) || y < 0 || y >= height())
return;
// Never used in direct mode (B commands), so only the buffer changes
_drawing = true;
uint8_t *row = _new_buffer + y * _buffer_width;
for (uint8_t mask = 8; mask; mask >>= 1, x++)
{
if (!(bits & mask) || x < 0 || x >= width())
continue;
uint8_t bit = 1 << (x & 7);
if (color)
row[x >> 3] |= bit;
else
row[x >> 3] &= ~bit;
}
}
void BROSE9323::_selectColumn(uint8_t col)
{
if (_active_col == col)
+4
View File
@@ -154,6 +154,10 @@ public:
bool refreshing(void) const { return _refreshing; }
#endif
#ifndef ESPSERIAL
// Draws the set bits of a hex digit of B data into the buffer (bit 3 at x, bit 0 at
// x + 3), like drawPixel() but fast enough for the serial port: dense B lines take
// about 23 us per dot with drawPixel(), and a digit arrives every 87 us
void drawDots(int16_t x, int16_t y, uint8_t bits, bool color);
void printBuffer(void);
#endif
size_t print(const char *ifsh);
+2 -1
View File
@@ -29,7 +29,7 @@ debug output. Wiring is described in the [documentation](../../docs/wiring.md).
| `logic/life.*` | Conway's Game of Life: the board with its edges and cycle check (`LifeBoard`), timing and restarts (`LifeRunner`) |
| `logic/page_schedule.*` | Schedule blocks and trigger rules: the structs, checks, rule conditions |
| `logic/page_selector.*` | Which page is shown now: rules, a manual pick, the rotating schedule (`PageSelector`) |
| `logic/stopwatch.h` | The stopwatch of the dynamic pages |
| `logic/stopwatch.h` | The stopwatch of the dynamic pages (real time, stored by `system/stopwatch_store`) |
| `logic/power_timer.*` | Countdown to switching the display off |
| `logic/status_led.h` | Blink pattern of the built-in LED |
| `logic/boot_policy.*` | How to start after a reset: normal, OTA-only window, safe mode |
@@ -39,6 +39,7 @@ debug output. Wiring is described in the [documentation](../../docs/wiring.md).
| `system/system_store.*` | Settings in NVS: WiFi, recovery reason, firmware slot and version, power-on count |
| `system/settings_store.*` | The web UI settings in NVS |
| `system/schedule_store.*` | The page schedule and rules in NVS |
| `system/stopwatch_store.*` | The stopwatch in NVS (keeps counting over a restart) |
| **`display/`** | **What is drawn** |
| `display/frames.*` | The screens: dynamic page, "No WiFi", page bitmap, live image, off image (`FlipdotFrames`) |
| `display/bitmap_store.*` | Built-in bitmaps and the uploaded ones on LittleFS |
+44 -3
View File
@@ -15,6 +15,7 @@
#include "net/ha_api.h"
#include "system/schedule_store.h"
#include "system/settings_store.h"
#include "system/stopwatch_store.h"
static void copyName(char *out, const char *name)
{
@@ -119,6 +120,7 @@ void appLoadSettings()
{
loadSettings(settings);
loadSchedule(page_schedule);
loadStopwatch(stopwatch);
selector.setSchedule(&page_schedule);
selector.setChosen(settings.page);
haApiWatchRules(&page_schedule);
@@ -486,15 +488,54 @@ const char *appDeleteFont(const char *name)
return deleteFont(name);
}
void appStopwatch(StopwatchAction action)
static const char *NO_TIME = "The time is not synced yet";
const char *appStopwatch(StopwatchAction action)
{
// It counts in real time: before the sync, the clock still says 1970
if (!time_known && action != StopwatchAction::Reset)
return NO_TIME;
int64_t now = epochMs();
if (action == StopwatchAction::Start)
stopwatch.start(millis());
stopwatch.start(now);
else if (action == StopwatchAction::Stop)
stopwatch.stop(millis());
stopwatch.stop(now);
else
stopwatch.reset();
saveStopwatch(stopwatch);
controller.pageChanged();
return nullptr;
}
const char *appStopwatchSetStart(const char *local)
{
uint32_t minutes;
if (!parseTarget(local, minutes))
return "Invalid start: use YYYY-MM-DDTHH:MM";
if (!time_known)
return NO_TIME;
struct tm t = {};
sscanf(local, "%4d-%2d-%2dT%2d:%2d", &t.tm_year, &t.tm_mon, &t.tm_mday, &t.tm_hour, &t.tm_min);
t.tm_year -= 1900;
t.tm_mon -= 1;
t.tm_isdst = -1; // the time zone decides (summer time at that date)
time_t start = mktime(&t);
if (start == (time_t)-1 || !stopwatch.setStart((int64_t)start * 1000, epochMs()))
return "The start is in the future";
saveStopwatch(stopwatch);
controller.pageChanged();
return nullptr;
}
bool appStopwatchStart(char *out)
{
if (!time_known || !stopwatch.running())
return false;
time_t start = (time_t)((epochMs() - (int64_t)stopwatch.elapsed(epochMs())) / 1000);
struct tm t;
localtime_r(&start, &t);
strftime(out, 20, "%Y-%m-%dT%H:%M:%S", &t);
return true;
}
void appShowLive(const uint8_t *bitmap)
+7 -1
View File
@@ -77,7 +77,13 @@ enum class StopwatchAction
Stop,
Reset,
};
void appStopwatch(StopwatchAction action);
// nullptr or what went wrong (start and stop need the synced time)
const char *appStopwatch(StopwatchAction action);
// Counts from this local date and time on ("YYYY-MM-DDTHH:MM", in the past), running
const char *appStopwatchSetStart(const char *local);
// The local time it counts from when it is running ("YYYY-MM-DDTHH:MM:SS"; out holds 20
// characters); false when it is stopped or the time is not synced
bool appStopwatchStart(char *out);
// Live mode of the web UI
void appShowLive(const uint8_t *bitmap);
+1 -1
View File
@@ -118,7 +118,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.18.1"
#define FIRMWARE_VERSION "2.19.3"
// 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};
+23 -2
View File
@@ -62,10 +62,29 @@ bool bitmapStorageAvailable()
return mounted;
}
// usedBytes() walks the whole file system (about 0.5 s), and the web UI asks every 5 s:
// kept until a file is written or deleted, at most USAGE_MAX_AGE
static const uint32_t USAGE_MAX_AGE = 60000;
static bool usage_valid = false;
static uint32_t usage_time = 0;
static size_t usage_used = 0, usage_total = 0;
void bitmapStorageChanged()
{
usage_valid = false;
}
void bitmapStorageUsage(size_t &used, size_t &total)
{
used = mounted ? LittleFS.usedBytes() : 0;
total = mounted ? LittleFS.totalBytes() : 0;
if (!usage_valid || millis() - usage_time >= USAGE_MAX_AGE)
{
usage_used = mounted ? LittleFS.usedBytes() : 0;
usage_total = mounted ? LittleFS.totalBytes() : 0;
usage_valid = true;
usage_time = millis();
}
used = usage_used;
total = usage_total;
}
std::vector<String> storedNames(const char *dir_path, const char *extension)
@@ -175,6 +194,7 @@ const char *saveBitmap(const char *name, const uint8_t *bitmap)
bool ok = file.write(bitmap, BITMAP_BYTES) == (size_t)BITMAP_BYTES;
file.close();
revision++;
bitmapStorageChanged();
cache.clear();
return ok ? nullptr : "Storage full";
}
@@ -188,6 +208,7 @@ const char *deleteBitmap(const char *name)
if (!LittleFS.remove(path(name)))
return "Could not delete the file";
revision++;
bitmapStorageChanged();
cache.clear();
return nullptr;
}
+2
View File
@@ -24,6 +24,8 @@ bool bitmapStoreBegin();
// (LittleFS is shared with display/page_store and display/font_store)
bool bitmapStorageAvailable();
void bitmapStorageUsage(size_t &used, size_t &total);
// A file in the storage was written or deleted (the stores call it): usage changed
void bitmapStorageChanged();
// The valid names of the files with this extension in a LittleFS directory
std::vector<String> storedNames(const char *dir, const char *extension);
+2
View File
@@ -97,6 +97,7 @@ const char *saveFont(const char *name, const uint8_t *data, size_t size)
file.close();
cache.clear();
revision++;
bitmapStorageChanged();
return ok ? nullptr : "Could not write the file (storage full?)";
}
@@ -110,5 +111,6 @@ const char *deleteFont(const char *name)
return "Could not delete the file";
cache.clear();
revision++;
bitmapStorageChanged();
return nullptr;
}
+69 -8
View File
@@ -2,6 +2,7 @@
#include <LittleFS.h>
#include <algorithm>
#include <iterator>
#include <map>
#include <strings.h>
@@ -49,14 +50,36 @@ static uint32_t revision = 0;
static int count = -1;
// Like the bitmaps (display/bitmap_store.cpp): images try a name per state, and pages are
// drawn for every frame and preview, so icons and missing names are kept in RAM
// drawn for every frame and preview, so icons and missing names are kept in RAM. Room for
// more names than there are icons: when the cache is cleared during a pass over all icons
// (Icons tab, page previews), every one of them is read again (6.5 s with 66 icons and a
// limit of 64). About 270 bytes per name.
struct CachedIcon
{
bool exists;
Icon icon;
};
static std::map<String, CachedIcon> cache;
static const size_t CACHE_MAX = 64;
static const size_t CACHE_MAX = 128;
// After a save or delete: only this name (in any case, as it was looked up) is read again.
// Clearing everything made the next list of all icons read each one again (7 s).
static void forget(const char *name)
{
for (auto it = cache.begin(); it != cache.end();)
it = it->first.equalsIgnoreCase(name) ? cache.erase(it) : std::next(it);
}
// Full: first forget the missing names, only then the icons
static void makeRoom()
{
if (cache.size() < CACHE_MAX)
return;
for (auto it = cache.begin(); it != cache.end();)
it = it->second.exists ? std::next(it) : cache.erase(it);
if (cache.size() >= CACHE_MAX)
cache.clear();
}
static String path(const char *name)
{
@@ -82,14 +105,46 @@ void iconStoreBegin()
}
}
// The sorted names, listed once (about a second) and then kept up to date
static std::vector<String> listed;
static bool listed_valid = false;
static bool nameBefore(const String &a, const String &b)
{
return strcasecmp(a.c_str(), b.c_str()) < 0;
}
static void listAdd(const char *name)
{
// It may replace a built-in one in another case: list again
if (builtinIconName(name))
listed_valid = false;
if (!listed_valid || std::find(listed.begin(), listed.end(), String(name)) != listed.end())
return;
listed.insert(std::lower_bound(listed.begin(), listed.end(), String(name), nameBefore), String(name));
}
static void listRemove(const char *name)
{
// A built-in one stays in the list (from the firmware): list again
if (builtinIconName(name))
listed_valid = false;
auto it = std::find(listed.begin(), listed.end(), String(name));
if (listed_valid && it != listed.end())
listed.erase(it);
}
std::vector<String> listIcons()
{
if (listed_valid)
return listed;
std::vector<String> names = storedNames(DIR, ".bin");
for (const BuiltinIcon &b : BUILTIN) // not stored (no storage)
if (std::none_of(names.begin(), names.end(), [&b](const String &n) { return strcasecmp(n.c_str(), b.name) == 0; }))
names.push_back(b.name);
std::sort(names.begin(), names.end(),
[](const String &a, const String &b) { return strcasecmp(a.c_str(), b.c_str()) < 0; });
std::sort(names.begin(), names.end(), nameBefore);
listed = names;
listed_valid = true;
return names;
}
@@ -138,8 +193,7 @@ bool loadIcon(const char *name, Icon &icon)
}
if (!ok)
ok = builtinIcon(name, loaded);
if (cache.size() >= CACHE_MAX)
cache.clear();
makeRoom();
CachedIcon &entry = cache[name];
entry.exists = ok;
if (ok)
@@ -172,15 +226,20 @@ const char *saveIcon(const char *name, const Icon &icon)
bool ok = file.write(size, 2) == 2 && file.write(icon.data, bytes) == bytes;
file.close();
revision++;
cache.clear();
bitmapStorageChanged();
forget(name);
if (!ok)
{
listed_valid = false;
LittleFS.remove(path(name)); // not half an icon
count = -1;
return "Storage full";
}
if (is_new)
{
count++;
listAdd(name);
}
return nullptr;
}
@@ -193,6 +252,8 @@ const char *deleteIcon(const char *name)
if (count > 0)
count--;
revision++;
cache.clear();
bitmapStorageChanged();
forget(name);
listRemove(name);
return nullptr;
}
+10 -2
View File
@@ -1,6 +1,7 @@
#include "display/page_resources.h"
#include <Arduino.h>
#include <sys/time.h>
#include "display/bitmap_store.h"
#include "display/font_store.h"
@@ -32,9 +33,16 @@ const char *StoredPageResources::haValue(const PageElement &element)
return haApiValue(element.entity, element.attribute);
}
uint32_t StoredPageResources::stopwatchMs()
uint64_t StoredPageResources::stopwatchMs()
{
return _stopwatch.elapsed(millis());
return _stopwatch.elapsed(epochMs());
}
int64_t epochMs()
{
struct timeval tv;
gettimeofday(&tv, nullptr);
return (int64_t)tv.tv_sec * 1000 + tv.tv_usec / 1000;
}
bool StoredPageResources::stopwatchRunning()
+3 -1
View File
@@ -14,7 +14,7 @@ public:
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;
uint64_t stopwatchMs() override;
bool stopwatchRunning() override;
const LifeRunner *life(const PageElement &element) override;
@@ -23,4 +23,6 @@ private:
};
extern Stopwatch stopwatch;
// The real time in ms since 1970 (UTC), for the stopwatch; only right once the time is synced
int64_t epochMs();
extern StoredPageResources pageResources;
+32 -5
View File
@@ -25,9 +25,29 @@ static String path(const char *name)
return String(DIR) + "/" + name + EXTENSION;
}
// The names of the stored pages, kept until a page is saved or deleted: listing the
// directory takes about 350 ms with 16 pages, and each exists() about 25 ms
static std::vector<String> names;
static bool names_valid = false;
static const std::vector<String> &storedPageNames()
{
if (!names_valid)
{
names = bitmapStorageAvailable() ? storedNames(DIR, EXTENSION) : std::vector<String>();
names_valid = true;
}
return names;
}
static bool storedPage(const char *name)
{
return bitmapStorageAvailable() && validBitmapName(name) && LittleFS.exists(path(name));
if (!bitmapStorageAvailable() || !validBitmapName(name))
return false;
for (const String &stored : storedPageNames())
if (stored == name) // like exists(): the file names are case-sensitive
return true;
return false;
}
void pageStoreBegin()
@@ -48,7 +68,7 @@ std::vector<PageInfo> listPages()
for (int i = 0; i < builtinPageCount(); i++)
if (!storedPage(builtinPageName(i)))
list.push_back({builtinPageName(i), true, false});
for (const String &name : storedNames(DIR, EXTENSION))
for (const String &name : storedPageNames())
list.push_back({name, false, builtinPageName(name.c_str()) != nullptr});
return list;
}
@@ -99,7 +119,7 @@ const char *savePage(const char *name, const DynamicPage &page)
return error;
if (!bitmapStorageAvailable())
return "No storage (install the new flash layout over USB, see README)";
if (!LittleFS.exists(path(name)) && storedNames(DIR, EXTENSION).size() >= (size_t)MAX_PAGES)
if (!storedPage(name) && storedPageNames().size() >= (size_t)MAX_PAGES)
return "Too many pages, delete some first";
StoredPage *stored = new StoredPage;
@@ -112,6 +132,8 @@ const char *savePage(const char *name, const DynamicPage &page)
file.close();
delete stored;
revision++;
bitmapStorageChanged();
names_valid = false;
return ok ? nullptr : "Could not write the file (storage full?)";
}
@@ -121,9 +143,12 @@ const char *deletePage(const char *name)
return "Built-in pages can't be deleted";
if (!storedPage(name))
return "Page not found";
if (!LittleFS.remove(path(name)))
bool removed = LittleFS.remove(path(name));
names_valid = false;
if (!removed)
return "Could not delete the file";
revision++;
bitmapStorageChanged();
return nullptr;
}
@@ -132,7 +157,9 @@ template <typename F> static String findPage(F uses)
{
DynamicPage *page = new DynamicPage;
String found;
for (const String &name : storedNames(DIR, EXTENSION))
// A copy: loadPage() may list the directory again
std::vector<String> list = storedPageNames();
for (const String &name : list)
if (loadPage(name.c_str(), *page) && uses(*page))
{
found = name;
+6 -2
View File
@@ -103,8 +103,9 @@ void DisplayController::update(uint32_t now, bool time_valid, const LocalTime &t
if (!_power_enabled)
{
if (_power.isOn())
if (_power.isOn() || _off_needed)
{
_ensureOn();
_showOnce(Screen::Off);
_off_timer.request(now, CLEAR_BEFORE_OFF_TIME + _effect_time);
}
@@ -129,8 +130,9 @@ void DisplayController::update(uint32_t now, bool time_valid, const LocalTime &t
case ScheduleMode::Night:
_slow_last = -1; // the morning starts with an update
_slow_keep = false;
if (_power.isOn())
if (_power.isOn() || _off_needed)
{
_ensureOn();
_showOnce(Screen::Off);
_off_timer.request(now, POWER_OFF_DELAY);
}
@@ -209,6 +211,7 @@ void DisplayController::_showDynamic(uint32_t now, const LocalTime &time, bool n
_dynamic_due = now + wait;
}
_screen = screen;
_off_needed = true;
_shown_hour = time.hour;
_shown_minute = time.minute;
_shown_revision = _content_revision;
@@ -232,5 +235,6 @@ void DisplayController::_showOnce(Screen screen)
break;
}
_screen = screen;
_off_needed = screen != Screen::Off;
_shown_revision = _content_revision;
}
+3
View File
@@ -109,6 +109,9 @@ private:
bool _power_enabled = true;
Screen _screen = Screen::Unknown;
// The dots show a frame other than the off screen, also while the power is off (slow
// mode): Power off and night power on to draw the off screen
bool _off_needed = false;
int _shown_hour = -1;
int _shown_minute = -1;
uint32_t _dynamic_due = 0; // millis() when a moving dynamic page is drawn again
+15 -5
View File
@@ -778,13 +778,23 @@ static void formatCountdown(const PageElement &e, const LocalTime &t, char *out,
snprintf(out, size, "%s%s", e.text, value);
}
static void formatStopwatch(const PageElement &e, uint32_t ms, char *out, size_t size)
static void formatStopwatch(const PageElement &e, uint64_t ms, char *out, size_t size)
{
unsigned long s = ms / 1000, h = s / 3600, m = s % 3600 / 60, sec = s % 60;
unsigned long s = (unsigned long)(ms / 1000), h = s / 3600, m = s % 3600 / 60, sec = s % 60;
unsigned long days = h / 24;
char value[24];
if (e.format == STOPWATCH_H_MM)
if (e.format == STOPWATCH_W_D)
{
if (days >= 7)
snprintf(value, sizeof(value), "%luw %lud", days / 7, days % 7);
else
snprintf(value, sizeof(value), "%lud", days);
}
else if (e.format == STOPWATCH_D_H_MM_SS && days)
snprintf(value, sizeof(value), "%lud %lu:%02lu:%02lu", days, h % 24, m, sec);
else if (e.format == STOPWATCH_H_MM)
snprintf(value, sizeof(value), "%lu:%02lu", h, m);
else if (e.format == STOPWATCH_H_MM_SS || h)
else if (e.format == STOPWATCH_H_MM_SS || e.format == STOPWATCH_D_H_MM_SS || h)
snprintf(value, sizeof(value), "%lu:%02lu:%02lu", h, m, sec);
else
snprintf(value, sizeof(value), "%lu:%02lu", m, sec);
@@ -860,7 +870,7 @@ bool elementShowsSeconds(const PageElement &e, const LocalTime &t, bool stopwatc
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;
return stopwatch_running && e.format != STOPWATCH_H_MM && e.format != STOPWATCH_W_D;
case ElementType::Countdown:
{
int64_t left = secondsLeft(e, t);
+4 -2
View File
@@ -97,7 +97,9 @@ const uint8_t COUNTDOWN_FORMAT_COUNT = 4;
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;
const uint8_t STOPWATCH_D_H_MM_SS = 3; // 12d 4:05:09, below a day 4:05:09
const uint8_t STOPWATCH_W_D = 4; // 3w 2d, without the weeks below a week
const uint8_t STOPWATCH_FORMAT_COUNT = 5;
// Progress bar formats
const uint8_t BAR_PLAIN = 0; // only the bar
@@ -238,7 +240,7 @@ const PageSlot *activeSlot(const DynamicPage &page, int minute_of_day);
struct ElementValues
{
const char *ha_value = nullptr; // nullptr: not known yet, shown as "--"
uint32_t stopwatch_ms = 0;
uint64_t stopwatch_ms = 0;
};
// The text of an element (not for images). out_size must be at least 2.
+4 -4
View File
@@ -270,7 +270,7 @@ uint32_t renderPage(const DynamicPage &page, const LocalTime &time, uint32_t now
uint32_t next = 0;
bool running = resources.stopwatchRunning();
uint32_t stopwatch_ms = resources.stopwatchMs();
uint64_t stopwatch_ms = resources.stopwatchMs();
for (int i = 0; i < page.element_count && i < PAGE_ELEMENTS; i++)
{
const PageElement &e = page.elements[i];
@@ -292,10 +292,10 @@ uint32_t renderPage(const DynamicPage &page, const LocalTime &time, uint32_t now
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
next = sooner(next, e.type == ElementType::Stopwatch ? 1000 - (uint32_t)(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);
else if (e.type == ElementType::Stopwatch && running) // H:MM, weeks/days: the next stopwatch minute
next = sooner(next, 60000 - (uint32_t)(stopwatch_ms % 60000));
}
if (boxes)
boxes[i] = box;
+1 -1
View File
@@ -20,7 +20,7 @@ public:
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 uint64_t stopwatchMs() { return 0; }
virtual bool stopwatchRunning() { return false; }
// The running Game of Life of the shown page when this element starts like it
// (sameLifeStart); nullptr otherwise (the start is drawn then)
+42 -17
View File
@@ -3,35 +3,60 @@
#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.
// Times are the real time in ms since 1970 (UTC), so it counts from a date, keeps counting
// while the display is off or restarting (system/stopwatch_store keeps it in NVS) and can
// run for years. Pure logic, tested in test/test_esp32.
class Stopwatch
{
public:
void start(uint32_t now)
// The state as it is stored
struct State
{
if (_running)
bool running = false;
int64_t started = 0; // when it was started (again)
int64_t before = 0; // ms counted before the last start
};
void start(int64_t now)
{
if (_state.running)
return;
_running = true;
_started = now;
_state.running = true;
_state.started = now;
}
void stop(uint32_t now)
void stop(int64_t now)
{
if (!_running)
if (!_state.running)
return;
_before += now - _started;
_running = false;
_state.before = elapsed(now);
_state.running = false;
}
// Back to 0, stopped
void reset()
void reset() { _state = State(); }
// Counts from this moment (in the past) on, running; false (nothing changed) when it is
// in the future
bool setStart(int64_t start, int64_t now)
{
_running = false;
_before = 0;
if (start > now)
return false;
_state.running = true;
_state.started = start;
_state.before = 0;
return true;
}
bool running() const { return _running; }
uint32_t elapsed(uint32_t now) const { return _before + (_running ? now - _started : 0); }
bool running() const { return _state.running; }
// Never negative (the clock set back)
uint64_t elapsed(int64_t now) const
{
int64_t ms = _state.before;
if (_state.running && now > _state.started)
ms += now - _state.started;
return ms > 0 ? (uint64_t)ms : 0;
}
const State &state() const { return _state; }
void restore(const State &state) { _state = state; }
private:
bool _running = false;
uint32_t _started = 0;
uint32_t _before = 0; // ms counted before the last start
State _state;
};
+41
View File
@@ -0,0 +1,41 @@
#include "system/stopwatch_store.h"
#include <Preferences.h>
static const char *NAMESPACE = "flipdot";
static const char *KEY = "stopwatch";
// Increase when Stopwatch::State changes; records of other versions are ignored
static const uint16_t VERSION = 1;
struct StoredStopwatch
{
uint16_t version;
uint16_t size;
Stopwatch::State state;
};
void loadStopwatch(Stopwatch &stopwatch)
{
stopwatch.reset();
StoredStopwatch stored;
Preferences prefs;
prefs.begin(NAMESPACE, true);
bool ok = prefs.isKey(KEY) && prefs.getBytesLength(KEY) == sizeof(stored) &&
prefs.getBytes(KEY, &stored, sizeof(stored)) == sizeof(stored);
prefs.end();
if (ok && stored.version == VERSION && stored.size == sizeof(Stopwatch::State) && stored.state.before >= 0 &&
stored.state.started >= 0)
stopwatch.restore(stored.state);
}
void saveStopwatch(const Stopwatch &stopwatch)
{
StoredStopwatch stored;
stored.version = VERSION;
stored.size = sizeof(Stopwatch::State);
stored.state = stopwatch.state();
Preferences prefs;
prefs.begin(NAMESPACE, false);
prefs.putBytes(KEY, &stored, sizeof(stored));
prefs.end();
}
+8
View File
@@ -0,0 +1,8 @@
#pragma once
#include "logic/stopwatch.h"
// The stopwatch in NVS (namespace "flipdot", key "stopwatch"), so it keeps counting over a
// restart. A missing, outdated or invalid record gives a stopwatch at 0.
void loadStopwatch(Stopwatch &stopwatch);
void saveStopwatch(const Stopwatch &stopwatch);
+45 -30
View File
@@ -108,9 +108,11 @@ details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:13px;m
<section id="t-stopwatch"><div class="card"><h2>Stopwatch</h2>
<div class="row"><b id="swtime" style="font-size:32px;font-variant-numeric:tabular-nums">0:00</b></div>
<div class="row"><span id="swsince" class="hint"></span></div>
<div class="row"><button class="b" 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">Shown by pages with a stopwatch element (e.g. the Stopwatch page). Also in Home Assistant: the Stopwatch switch and the Stopwatch reset button.</p></div></section>
<div class="row"><label>Counting since <input type="datetime-local" id="swstart"></label><button class="s" onclick="swSetStart()">Set start</button></div>
<p class="hint">Shown by pages with a stopwatch element (e.g. the Stopwatch page). It counts in real time from its start, also while the display is off or restarting. Stop pauses it, Start continues. Set start: counts from that date and time on (e.g. days since an event). Also in Home Assistant: the Stopwatch switch and the Stopwatch reset button.</p></div></section>
<section id="t-bitmaps"><div class="card"><h2>Bitmaps</h2><div class="grid" id="bitmaps"></div>
<details id="bitmaps_used_box"><summary></summary><div class="grid" id="bitmaps_used"></div></details>
@@ -214,7 +216,7 @@ details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:13px;m
</main><div id="msg"></div>
<script>
const W=84,H=19,RB=11,$=id=>document.getElementById(id);
let settings=null,status=null,bitmapList=[],iconList=[],pageList=[],fontList=[],sched=null,schedDirty=false;
let settings=null,status=null,bitmapList=[],iconList=[],pageList=[],fontList=[],storageOk=true,sched=null,schedDirty=false;
const tabs=[['show','Show'],['pages','Pages'],['schedule','Schedule'],['stopwatch','Stopwatch'],['bitmaps','Bitmaps'],['icons','Icons'],['draw','Draw / Live'],['settings','Settings'],['ha','Home Assistant'],['system','System']];
tabs.forEach(([id,name],i)=>{const b=document.createElement('button');b.textContent=name;b.onclick=()=>tab(id);b.id='b-'+id;$('tabs').appendChild(b)});
function tab(id){if(!$('t-'+id))id='show';tabs.forEach(([t])=>{$('t-'+t).classList.toggle('on',t==id);$('b-'+t).classList.toggle('on',t==id)});location.hash=id}
@@ -267,15 +269,27 @@ function usedBitmaps(){const u=new Set(),add=n=>n&&u.add(n.toLowerCase());
else if(e.text)bitmapList.forEach(b=>{if(b.name.toLowerCase().startsWith(e.text.toLowerCase()))add(b.name)})})}
if(sched)(sched.rules||[]).forEach(r=>add(r.page));return u}
// The bitmaps and icons a page shows (backgrounds, fixed images, the names of images by state)
function pageImages(page){const u=new Set(),add=n=>n&&u.add(n.toLowerCase());(page.slots||[]).forEach(s=>add(s.bitmap));
function pageImages(page,extra=[]){const u=new Set(),add=n=>n&&u.add(n.toLowerCase()),icons=iconList.concat(extra);(page.slots||[]).forEach(s=>add(s.bitmap));
(page.elements||[]).forEach(e=>{if(e.type=='life'&&e.start=='pattern')add(e.text);
if(e.type=='life'&&(e.start=='rotate'||e.start=='shuffle'))iconList.forEach(b=>{if(b.name.toLowerCase().startsWith((e.text||'').toLowerCase()))add(b.name)});
if(e.type=='life'&&(e.start=='rotate'||e.start=='shuffle'))icons.forEach(b=>{if(b.name.toLowerCase().startsWith((e.text||'').toLowerCase()))add(b.name)});
if(e.type!='image')return;if(!e.entity)add(e.text);
else if(e.text)bitmapList.concat(iconList).forEach(b=>{if(b.name.toLowerCase().startsWith(e.text.toLowerCase()))add(b.name)})});return u}
else if(e.text)bitmapList.concat(icons).forEach(b=>{if(b.name.toLowerCase().startsWith(e.text.toLowerCase()))add(b.name)})});return u}
function subsection(id,n){$(id+'_box').style.display=n?'':'none';$(id+'_box').querySelector('summary').textContent='Used by pages and rules ('+n+')'}
async function loadLists(){const[b,p,f,sc,ic]=await Promise.all([getList('/api/bitmaps','bitmaps'),getList('/api/pages','pages'),getList('/api/fonts','fonts'),getList('/api/schedule','blocks'),getList('/api/icons','icons')]);
bitmapList=b.bitmaps;pageList=p.pages;fontList=f.fonts;iconList=ic.icons;iconList.forEach(x=>x.bits=icFromHex(x.width,x.height,x.data));if(!schedDirty)sched=sc.blocks&&sc.rules?sc:{enabled:true,blocks:[],rules:[]};bitmapList.forEach(bm=>bm.bits=fromHex(bm.data));pageList.forEach(pg=>pg.bits=fromHex(pg.data));
$('storage').textContent=b.storage?'':'Storage not available: install the new flash layout (see README).';
// Pages that must be loaded again (their previews are drawn on the ESP32) when these
// bitmaps or icons change, or when this font changes
const pagesUsing=(...names)=>pageList.some(p=>{const u=pageImages(p.page,names.map(name=>({name})));return names.some(n=>u.has(n.toLowerCase()))})?['pages']:[];
const pagesWithFont=n=>pageList.some(p=>(p.page.elements||[]).some(e=>same(e.font,n)))?['pages']:[];
// Loads the lists named in `only` again (bitmaps, pages, fonts, schedule, icons; all when
// left out) and redraws them all. The ESP32 answers one request at a time, so after a
// change only what changed is fetched.
async function loadLists(only){const want=k=>!only||only.includes(k),get=(k,p,key)=>want(k)?getList(p,key):null;
const[b,p,f,sc,ic]=await Promise.all([get('bitmaps','/api/bitmaps','bitmaps'),get('pages','/api/pages','pages'),get('fonts','/api/fonts','fonts'),get('schedule','/api/schedule','blocks'),get('icons','/api/icons','icons')]);
if(b){bitmapList=b.bitmaps;bitmapList.forEach(bm=>bm.bits=fromHex(bm.data));storageOk=b.storage}
if(p){pageList=p.pages;pageList.forEach(pg=>pg.bits=fromHex(pg.data))}
if(f)fontList=f.fonts;
if(ic){iconList=ic.icons;iconList.forEach(x=>x.bits=icFromHex(x.width,x.height,x.data))}
if(sc&&!schedDirty)sched=sc.blocks&&sc.rules?sc:{enabled:true,blocks:[],rules:[]};
$('storage').textContent=storageOk?'':'Storage not available: install the new flash layout (see README).';
const pages=$('pages'),offs=$('offpages'),bms=$('bitmaps'),dyn=$('dynpages'),used=usedBitmaps();
['pages','offpages','bitmaps','dynpages','pages_used','offpages_used','bitmaps_used'].forEach(id=>$(id).innerHTML='');
offs.appendChild(card('Blank','all dots off',new Uint8Array(W*H),()=>selectOff(''),settings&&!settings.off_page));
@@ -306,18 +320,19 @@ async function loadLists(){const[b,p,f,sc,ic]=await Promise.all([getList('/api/b
function btn(t,f,cls){const b=document.createElement('button');b.className='s '+(cls||'');b.textContent=t;b.onclick=e=>{e.stopPropagation();f()};return b}
function card(name,tag,bits,onclick,cur){const d=mk('div',{className:'item'+(cur?' cur':'')});d.appendChild(mini(bits));
d.append(mk('div',{className:'name',textContent:name}),mk('div',{className:'tag',textContent:tag}));if(onclick)d.onclick=onclick;return d}
async function selectPage(p){await post('/api/page',{page:p});settings.page=p;msg('Page: '+p);loadLists()}
async function selectOff(p){const r=await post('/api/settings',{off_page:p});settings=r.settings;msg('Off image: '+(p||'blank'));loadLists()}
async function delBitmap(n){if(!confirm('Delete '+n+'?'))return;await post('/api/bitmap/delete',{name:n});msg('Deleted');settings=await api('/api/settings');loadLists()}
async function delIcon(n){if(!confirm('Delete icon '+n+'?'))return;await post('/api/icon/delete',{name:n});msg('Deleted');if(ic&&same(ic.orig,n))icClose();loadLists()}
async function toIcon(n){if(!confirm('Make '+n+' an icon? It is then only used on pages, not shown on its own.'))return;await post('/api/bitmap/to_icon',{name:n});msg(n+' is now an icon');loadLists()}
async function toBitmap(n){await post('/api/icon/to_bitmap',{name:n});msg(n+' is now a bitmap');if(ic&&same(ic.orig,n))icClose();loadLists()}
async function resetPage(n){if(!confirm('Reset '+n+' to the original page? Your changes to it are lost.'))return;await post('/api/pages/delete',{name:n});msg(n+' reset');if(pe&&same(pe.orig,n))peClose();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 selectPage(p){await post('/api/page',{page:p});settings.page=p;msg('Page: '+p);loadLists([])}
async function selectOff(p){const r=await post('/api/settings',{off_page:p});settings=r.settings;msg('Off image: '+(p||'blank'));loadLists([])}
async function delBitmap(n){if(!confirm('Delete '+n+'?'))return;await post('/api/bitmap/delete',{name:n});msg('Deleted');settings=await api('/api/settings');loadLists(['bitmaps'])}
async function delIcon(n){if(!confirm('Delete icon '+n+'?'))return;await post('/api/icon/delete',{name:n});msg('Deleted');if(ic&&same(ic.orig,n))icClose();loadLists(['icons'])}
async function toIcon(n){if(!confirm('Make '+n+' an icon? It is then only used on pages, not shown on its own.'))return;await post('/api/bitmap/to_icon',{name:n});msg(n+' is now an icon');loadLists(['bitmaps','icons'])}
async function toBitmap(n){await post('/api/icon/to_bitmap',{name:n});msg(n+' is now a bitmap');if(ic&&same(ic.orig,n))icClose();loadLists(['bitmaps','icons'])}
async function resetPage(n){if(!confirm('Reset '+n+' to the original page? Your changes to it are lost.'))return;await post('/api/pages/delete',{name:n});msg(n+' reset');if(pe&&same(pe.orig,n))peClose();loadLists(['pages'])}
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(['pages'])}
async function delFont(n){if(!confirm('Delete font '+n+'?'))return;await post('/api/fonts/delete',{name:n});msg('Deleted');loadLists(['fonts'])}
function swText(ms){const s=Math.floor(ms/1000),d=Math.floor(s/86400),h=Math.floor(s%86400/3600),m=Math.floor(s%3600/60),p=n=>String(n).padStart(2,'0');return(d?d+'d '+h+':'+p(m):h?h+':'+p(m):m)+':'+p(s%60)}
function swShow(sw){if(!sw)return;$('swtime').textContent=swText(sw.ms)+(sw.running?' (running)':'');$('swsince').textContent=sw.start?'Counting since '+sw.start.replace('T',' '):sw.ms?'Paused':''}
async function stopwatch(action,extra){swShow(await post('/api/stopwatch',Object.assign({action},extra)));if(pe)pePreview()}
async function swSetStart(){const v=$('swstart').value;if(!v){msg('Pick a date and time first',true);return}await stopwatch('set',{start:v.slice(0,16)});msg('Counting since '+v.replace('T',' '))}
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')}
// schedule and rules
@@ -364,7 +379,7 @@ function scAddBlock(){if(sched.blocks.length>=8){msg('8 blocks at most',true);re
sched.blocks.push({start:'12:00',effect:true,items:[{page:settings.page,seconds:30}]});sched.blocks.sort(bySlot);schedDirty=true;schedBuild()}
function scAddRule(){if(sched.rules.length>=8){msg('8 rules at most',true);return}
sched.rules.push({entity:'',attribute:'',compare:'eq',value:'',page:settings.page,mode:'while',seconds:30,interval:10,over_manual:true,over_slow_mode:false,skip_slow_mode:false});schedDirty=true;schedBuild()}
async function scSave(){sched.blocks.sort(bySlot);sched=await post('/api/schedule',schedPayload());schedDirty=false;schedBuild();msg('Schedule saved');loadLists()}
async function scSave(){sched.blocks.sort(bySlot);sched=await post('/api/schedule',schedPayload());schedDirty=false;schedBuild();msg('Schedule saved');loadLists([])}
async function backToSchedule(){await post('/api/schedule/back');msg('Back to the schedule');setTimeout(pollStatusOnce,500)}
async function pollStatusOnce(){try{status=await api('/api/status');$('sc_now').textContent='Showing: '+status.shown+' ('+status.shown_status+')';$('shownnow').textContent=$('sc_now').textContent}catch(e){}}
// page editor: the ESP32 draws the preview and returns the element boxes
@@ -428,7 +443,7 @@ function peElems(){const d=$('pe_elems');d.innerHTML='';d.append(mk('datalist',{
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)']])),
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)'],[3,'12d 4:05:09 (days, every second)'],[4,'3w 2d (weeks, days)']])),
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');
@@ -496,7 +511,7 @@ async function haValue(entity,attribute){for(let i=0;i<25;i++){const r=await api
if(!r.pending)return r.value;await new Promise(f=>setTimeout(f,300))}msg('No answer from Home Assistant',true);throw 0}
async function haTest(e){const v=await haValue(e.entity,e.attribute);msg(e.entity+(e.attribute?' '+e.attribute:'')+' = '+v);pePreview()}
async function peSave(){const n=$('pe_name').value.trim();if(!n){msg('Enter a name',true);return}
await post('/api/pages/save',{name:n,page:pePage()});pe.orig=n;$('pe_title').textContent='Edit '+n;msg('Saved '+n);loadLists()}
await post('/api/pages/save',{name:n,page:pePage()});pe.orig=n;$('pe_title').textContent='Edit '+n;msg('Saved '+n);loadLists(['pages'])}
async function peLive(){if(peData)await live(peData)}
// fonts: a TTF/OTF drawn with the browser, each character cut to its dots (format: src/esp32/logic/font.h)
let fontFam=null,fontData=null;
@@ -524,7 +539,7 @@ function fontPreview(){const b=new Uint8Array(W*H);let x=0,first=true;
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}
await post('/api/fonts/save',{name:n,data:fontData.bytes.map(v=>v.toString(16).padStart(2,'0')).join('')});msg('Saved font '+n);loadLists()}
await post('/api/fonts/save',{name:n,data:fontData.bytes.map(v=>v.toString(16).padStart(2,'0')).join('')});msg('Saved font '+n);loadLists(['fonts',...pagesWithFont(n)])}
// backup: everything in one JSON file; the import posts the chosen parts to the normal endpoints
const SGROUPS=[['schedule','Schedule',s=>({schedule:s.schedule})],
['display','Shown page, off image, No WiFi screen',s=>({page:s.page,off_page:s.off_page,clock:s.clock})],
@@ -617,9 +632,9 @@ function edClear(){ed.set(new Uint8Array(W*H));changed()}
function edInvert(){ed.set(ed.bits.map(v=>1-v));changed()}
async function sendLive(){await live(ed.bits)}
async function saveBitmap(){const n=$('savename').value.trim();if(!n){msg('Enter a name',true);return}
await post('/api/bitmap',{name:n,data:toHex(ed.bits)});msg('Saved '+n);loadLists()}
await post('/api/bitmap',{name:n,data:toHex(ed.bits)});msg('Saved '+n);loadLists(['bitmaps',...pagesUsing(n)])}
async function saveAsIcon(){const n=$('savename').value.trim();if(!n){msg('Enter a name',true);return}
const c=cropBits(ed.bits,W,H);await post('/api/icon',{name:n,width:c.w,height:c.h,data:icToHex(c.bits,c.w,c.h)});msg('Saved icon '+n+' ('+c.w+' x '+c.h+')');loadLists()}
const c=cropBits(ed.bits,W,H);await post('/api/icon',{name:n,width:c.w,height:c.h,data:icToHex(c.bits,c.w,c.h)});msg('Saved icon '+n+' ('+c.w+' x '+c.h+')');loadLists(['icons',...pagesUsing(n)])}
// image conversion: w x h dots, dark parts on
$('file').onchange=e=>{const f=e.target.files[0];if(!f)return;const img=new Image();img.onload=()=>{srcImg=img;$('imgopts').style.display='flex';convert()};img.src=URL.createObjectURL(f)};
['thr','inv','dith','fit'].forEach(id=>$(id).oninput=convert);
@@ -650,9 +665,9 @@ function rleBits(rle){const p=[];let x=0,y=0,w=1,n='';for(const c of rle){if(c>=
function lifeLib(){const d=$('lifepats');d.innerHTML='';for(const l of LIFEPATS){const p=rleBits(l[2]),have=iconList.some(x=>same(x.name,l[0]));
d.append(mk('div',{className:'item'},icCanvas(p.bits,p.w,p.h),mk('div',{className:'name',textContent:l[0]}),mk('div',{className:'tag',textContent:p.w+' x '+p.h+', '+l[1]}),
mk('div',{className:'row'},have?mk('span',{className:'hint',textContent:'added'}):btn('Add',()=>lifeAdd([l])))))}}
async function lifeAdd(list){let k=0,bad=0;for(const[n,,r]of list){if(iconList.some(x=>same(x.name,n)))continue;const p=rleBits(r);
try{await post('/api/icon',{name:n,width:p.w,height:p.h,data:icToHex(p.bits,p.w,p.h)});k++}catch(e){bad++}}
if(!bad)msg(k?'Added '+k+' pattern'+(k>1?'s':''):'All patterns are there');loadLists()}
async function lifeAdd(list){let k=0,bad=0;const added=[];for(const[n,,r]of list){if(iconList.some(x=>same(x.name,n)))continue;const p=rleBits(r);
try{await post('/api/icon',{name:n,width:p.w,height:p.h,data:icToHex(p.bits,p.w,p.h)});k++;added.push(n)}catch(e){bad++}}
if(!bad)msg(k?'Added '+k+' pattern'+(k>1?'s':''):'All patterns are there');loadLists(['icons',...pagesUsing(...added)])}
// icon editor: its own size, drawn like the bitmap editor
let ic=null,icPaint=null,icImg=null;const icc=$('ic_canvas');
function icShow(title){icImg=null;$('ic_imgopts').style.display='none';$('ic_file').value='';$('ic_title').textContent=title;$('ic_name').value=ic.orig;$('ic_w').value=ic.w;$('ic_h').value=ic.h;$('ic').style.display='block';icDraw();$('ic').scrollIntoView({behavior:'smooth'})}
@@ -675,7 +690,7 @@ $('ic_file').onchange=e=>{const f=e.target.files[0];if(!f||!ic)return;const img=
['ic_thr','ic_inv','ic_dith'].forEach(id=>$(id).oninput=()=>icConvert());
function icConvert(){if(!icImg||!ic)return;ic.bits=imgBits(icImg,ic.w,ic.h,+$('ic_thr').value,$('ic_inv').checked,$('ic_dith').checked,'contain');icDraw()}
async function icSave(){const n=$('ic_name').value.trim();if(!n){msg('Enter a name',true);return}
await post('/api/icon',{name:n,width:ic.w,height:ic.h,data:icToHex(ic.bits,ic.w,ic.h)});ic.orig=n;$('ic_title').textContent='Edit '+n;msg('Saved '+n);loadLists()}
await post('/api/icon',{name:n,width:ic.w,height:ic.h,data:icToHex(ic.bits,ic.w,ic.h)});ic.orig=n;$('ic_title').textContent='Edit '+n;msg('Saved '+n);loadLists(['icons',...pagesUsing(n)])}
async function icLive(){if(ic)await live(icFull(ic.bits,ic.w,ic.h))}
// test patterns (live mode)
let testTimer=null;
+22 -10
View File
@@ -137,6 +137,16 @@ static void handleRoot()
server.send_P(200, "text/html", WEB_PAGE);
}
// {running, ms, start (local, while running)}
static void stopwatchJson(JsonObject sw)
{
sw["running"] = stopwatch.running();
sw["ms"] = stopwatch.elapsed(epochMs());
char start[20];
if (appStopwatchStart(start))
sw["start"] = start;
}
static void handleStatus()
{
JsonDocument doc;
@@ -168,9 +178,7 @@ static void handleStatus()
doc["off_page"] = appSettings().off_page;
doc["power_enabled"] = controller.powerEnabled();
doc["slow_mode"] = controller.slowModeActive();
JsonObject sw = doc["stopwatch"].to<JsonObject>();
sw["running"] = stopwatch.running();
sw["ms"] = stopwatch.elapsed(millis());
stopwatchJson(doc["stopwatch"].to<JsonObject>());
JsonObject load = doc["load"].to<JsonObject>();
load["loops_per_s"] = loopStats.loopsPerSecond();
@@ -632,27 +640,31 @@ static void handleLive()
sendOk();
}
// {action: "start" | "stop" | "reset"}
// {action: "start" | "stop" | "reset" | "set", start: "YYYY-MM-DDTHH:MM" (set, local time)}
static void handleStopwatch()
{
JsonDocument body;
if (!readBody(body))
return;
const char *action = body["action"] | "";
const char *error;
if (strcmp(action, "start") == 0)
appStopwatch(StopwatchAction::Start);
error = appStopwatch(StopwatchAction::Start);
else if (strcmp(action, "stop") == 0)
appStopwatch(StopwatchAction::Stop);
error = appStopwatch(StopwatchAction::Stop);
else if (strcmp(action, "reset") == 0)
appStopwatch(StopwatchAction::Reset);
error = appStopwatch(StopwatchAction::Reset);
else if (strcmp(action, "set") == 0)
error = appStopwatchSetStart(body["start"] | "");
else
error = "Unknown stopwatch action";
if (error)
{
sendError(400, "Unknown stopwatch action");
sendError(400, error);
return;
}
JsonDocument doc;
doc["running"] = stopwatch.running();
doc["ms"] = stopwatch.elapsed(millis());
stopwatchJson(doc.to<JsonObject>());
sendJson(200, doc);
}
+2 -1
View File
@@ -33,7 +33,8 @@
// POST /api/schedule/back end a manual pick
// POST /api/live show now {data}
// POST /api/live/stop
// POST /api/stopwatch {action: "start" | "stop" | "reset"} -> {running, ms}
// POST /api/stopwatch {action: "start" | "stop" | "reset" | "set", start:
// "YYYY-MM-DDTHH:MM" (set, local)} -> {running, ms, start}
// 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}
+2 -1
View File
@@ -49,7 +49,8 @@ Arduino core, EEPROM and the display driver are replaced by fakes in
- **Nothing blocks.** Every frame (also instant ones, `D1`) is flipped from `update()` in
slices of about 1 ms, so `loop()` keeps reading the serial port. A blocking redraw used
to overflow the 64-byte receive buffer; the lost bytes shifted the rest of a bitmap
(the picture moved up and left) and nothing repaired it.
(the picture moved up and left) and nothing repaired it. Bitmap data is drawn as fast as
it arrives too (one hex digit every 87 µs); dense images used to be slower than that.
- **Every 2 minutes** (`I` command, stored in EEPROM, 0 = never) it flips every dot once
more, with one pulse, to fix dots that got stuck. This also runs in slices, only while
no frame is being received or shown, and continues where it was after a frame.
+11 -11
View File
@@ -182,29 +182,29 @@ bool CommandParser::_bitmapStart()
_error(F("Invalid bitmap height (0)"));
return false;
}
_bitmap_nibble = 0;
_bitmap_col = 0;
_bitmap_row = 0;
_bitmap_active = true;
return true;
}
// Runs for every hex digit while the line arrives (one every 87 us at 115200 baud), so it
// must be quick: no divisions, and drawDots() instead of four drawPixel() calls. Slower
// than that, a dense line (all yellow) overflowed the 64 byte receive buffer.
void CommandParser::_bitmapData(char c)
{
if (!isHex(c))
return;
uint16_t pixel = _bitmap_nibble * 4;
if (pixel >= (uint16_t)_bitmap_w * _bitmap_h)
if (_bitmap_row >= _bitmap_h)
return; // more data than the bitmap size, ignore
uint8_t b = hex2dec(c);
for (uint8_t i = 0; i < 4; i++)
_display.drawDots(_bitmap_x + _bitmap_col, _bitmap_y + _bitmap_row, hex2dec(c), _bitmap_color);
_bitmap_col += 4;
if (_bitmap_col >= _bitmap_w)
{
if (b & (0x8 >> i))
{
uint16_t p = pixel + i;
_display.drawPixel(_bitmap_x + p % _bitmap_w, _bitmap_y + p / _bitmap_w, _bitmap_color);
_bitmap_col = 0;
_bitmap_row++;
}
}
_bitmap_nibble++;
}
// Fc (c = 0/1 color)
void CommandParser::_fill()
+1 -1
View File
@@ -33,7 +33,7 @@ private:
bool _bitmap_active = false;
uint8_t _bitmap_x, _bitmap_y, _bitmap_w, _bitmap_h;
bool _bitmap_color;
uint16_t _bitmap_nibble;
uint8_t _bitmap_col, _bitmap_row; // where the next hex digit goes, in the bitmap
void _runLine();
void _error(const __FlashStringHelper *msg);
+8
View File
@@ -27,6 +27,14 @@ public:
void begin() { log("begin"); }
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); }
// Recorded as the pixel calls it stands for (the real one is tested in test_driver)
void drawDots(int16_t x, int16_t y, uint8_t bits, bool color)
{
log("dots", x, y, bits);
for (int i = 0; i < 4; i++)
if (bits & (8 >> i))
drawPixel(x + i, y, color);
}
void fillScreen(uint16_t color) { log("fill", color); }
void setDirect(bool d) { direct = d; }
void setTiming(uint16_t t) { log("timing", t); }
+33
View File
@@ -226,6 +226,38 @@ void test_crc_of_equal_images_is_equal()
delete b;
}
// The fast path for B data must give the same image as drawPixel(), also at the right edge
// (B widths are rounded up to 4, so x + 3 can be past it) and below the last row
void test_draw_dots_is_like_draw_pixel()
{
BROSE9323 *a = make(), *b = make();
a->fillScreen(1);
b->fillScreen(1);
for (int y = 0; y <= H; y++)
for (int x = 0; x < W; x += 4)
{
uint8_t bits = (x * 5 + y * 3) & 0xF;
bool color = (x / 4 + y) % 3 == 0;
a->drawDots(x + 2, y, bits, color);
for (int i = 0; i < 4; i++)
if (bits & (8 >> i))
b->drawPixel(x + 2 + i, y, color);
}
TEST_ASSERT_EQUAL_HEX16(b->crc(), a->crc());
for (int x = 0; x < W; x++)
for (int y = 0; y < H; y++)
{
a->drawPixel(x, y, 0);
b->drawPixel(x, y, 0);
}
a->drawDots(-2, 0, 0xF, 1); // dots 0 and 1 of the first row
b->drawPixel(0, 0, 1);
b->drawPixel(1, 0, 1);
TEST_ASSERT_EQUAL_HEX16(b->crc(), a->crc());
delete a;
delete b;
}
int main()
{
UNITY_BEGIN();
@@ -237,5 +269,6 @@ int main()
RUN_TEST(test_refresh_waits_for_a_running_effect);
RUN_TEST(test_interrupted_first_frame_still_reaches_every_dot);
RUN_TEST(test_crc_of_equal_images_is_equal);
RUN_TEST(test_draw_dots_is_like_draw_pixel);
return UNITY_END();
}
+89 -11
View File
@@ -583,6 +583,38 @@ void test_power_switch_off_keeps_display_off_during_the_day()
TEST_ASSERT_EQUAL(0, power.on_count);
}
// Slow mode had the power off already: the dots still show the clock, so the power goes on
// for the off image, once
void test_power_switch_off_in_slow_mode_draws_the_off_image()
{
slowMode(true);
run(12, 0, POWER_OFF_DELAY + 200);
TEST_ASSERT_FALSE(power.is_on);
controller->setPowerEnabled(false);
run(12, 1);
TEST_ASSERT_TRUE(power.is_on);
TEST_ASSERT_EQUAL_STRING("off", frames.last().c_str());
run(12, 1, CLEAR_BEFORE_OFF_TIME + 200);
TEST_ASSERT_FALSE(power.is_on);
run(12, 2, 60000);
TEST_ASSERT_EQUAL(2, power.on_count);
TEST_ASSERT_EQUAL(2, (int)frames.shown.size());
}
void test_night_in_slow_mode_draws_the_off_image()
{
slowMode(true);
run(23, 15, POWER_OFF_DELAY + 200);
TEST_ASSERT_FALSE(power.is_on);
run(23, 31);
TEST_ASSERT_TRUE(power.is_on);
TEST_ASSERT_EQUAL_STRING("off", frames.last().c_str());
run(23, 31, POWER_OFF_DELAY + 200);
TEST_ASSERT_FALSE(power.is_on);
run(3, 0, 60000);
TEST_ASSERT_EQUAL(2, power.on_count);
}
void test_power_switch_on_again_shows_clock()
{
run(12, 0);
@@ -1475,21 +1507,42 @@ void test_countdown_shows_seconds_only_when_needed()
void test_stopwatch()
{
const int64_t T = 1760000000000LL; // ms since 1970, in 2025
Stopwatch sw;
TEST_ASSERT_EQUAL_UINT32(0, sw.elapsed(5000));
sw.start(1000);
TEST_ASSERT_EQUAL_UINT64(0, sw.elapsed(T));
sw.start(T + 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));
TEST_ASSERT_EQUAL_UINT64(2000, sw.elapsed(T + 3000));
sw.stop(T + 4000);
TEST_ASSERT_EQUAL_UINT64(3000, sw.elapsed(T + 9000));
sw.start(T + 10000);
sw.start(T + 11000); // already running: no restart
TEST_ASSERT_EQUAL_UINT64(5000, sw.elapsed(T + 12000));
TEST_ASSERT_EQUAL_UINT64(3000, sw.elapsed(T + 5000)); // clock set back: never less than before
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));
TEST_ASSERT_EQUAL_UINT64(0, sw.elapsed(T + 20000));
// Past 49 days (millis() would overflow)
sw.start(T);
TEST_ASSERT_EQUAL_UINT64(60ULL * 86400000ULL, sw.elapsed(T + 60LL * 86400000LL));
// A start date by hand: counts from there, pausing keeps the time
TEST_ASSERT_FALSE(sw.setStart(T + 1000, T)); // in the future
TEST_ASSERT_TRUE(sw.setStart(T - 3 * 86400000LL, T));
TEST_ASSERT_TRUE(sw.running());
TEST_ASSERT_EQUAL_UINT64(3 * 86400000ULL, sw.elapsed(T));
sw.stop(T);
TEST_ASSERT_EQUAL_UINT64(3 * 86400000ULL, sw.elapsed(T + 5000));
// Kept over a restart (system/stopwatch_store stores the state)
Stopwatch copy;
copy.restore(sw.state());
TEST_ASSERT_FALSE(copy.running());
TEST_ASSERT_EQUAL_UINT64(3 * 86400000ULL, copy.elapsed(T + 99999));
sw.start(T + 1000);
copy.restore(sw.state());
TEST_ASSERT_EQUAL_UINT64(3 * 86400000ULL + 1000, copy.elapsed(T + 2000));
PageElement e; // textOf: 65 s on the stopwatch
e.type = ElementType::Stopwatch;
@@ -1502,6 +1555,29 @@ void test_stopwatch()
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
strcpy(e.text, "");
// Days and weeks
ElementValues values;
char text[40];
e.format = STOPWATCH_D_H_MM_SS;
values.stopwatch_ms = 65000;
elementText(e, LocalTime(), values, text, sizeof(text));
TEST_ASSERT_EQUAL_STRING("0:01:05", text); // below a day: without the days
values.stopwatch_ms = (12ULL * 86400 + 4 * 3600 + 5 * 60 + 9) * 1000;
elementText(e, LocalTime(), values, text, sizeof(text));
TEST_ASSERT_EQUAL_STRING("12d 4:05:09", text);
TEST_ASSERT_TRUE(elementShowsSeconds(e, LocalTime(), true));
e.format = STOPWATCH_W_D;
elementText(e, LocalTime(), values, text, sizeof(text));
TEST_ASSERT_EQUAL_STRING("1w 5d", text);
TEST_ASSERT_FALSE(elementShowsSeconds(e, LocalTime(), true));
values.stopwatch_ms = 2ULL * 86400000 + 5000;
elementText(e, LocalTime(), values, text, sizeof(text));
TEST_ASSERT_EQUAL_STRING("2d", text);
values.stopwatch_ms = 400ULL * 86400000; // more than a year, past 32 bit ms
elementText(e, LocalTime(), values, text, sizeof(text));
TEST_ASSERT_EQUAL_STRING("57w 1d", text);
}
void test_home_assistant_multiplier()
@@ -3226,6 +3302,8 @@ int main()
RUN_TEST(test_power_switch_off_clears_and_powers_off_quickly);
RUN_TEST(test_power_switch_off_waits_for_the_refresh_effect);
RUN_TEST(test_power_switch_off_keeps_display_off_during_the_day);
RUN_TEST(test_power_switch_off_in_slow_mode_draws_the_off_image);
RUN_TEST(test_night_in_slow_mode_draws_the_off_image);
RUN_TEST(test_power_switch_on_again_shows_clock);
RUN_TEST(test_redraws_after_power_was_switched_off_elsewhere);
RUN_TEST(test_no_wifi_is_shown_after_a_minute_without_wifi);
+16
View File
@@ -193,6 +193,21 @@ void test_full_screen_bitmap_is_streamed()
TEST_ASSERT_EQUAL(84 * 19, display.count("pixel"));
}
// A hex digit arrives every 87 us, so the Nano must draw each one with a single quick
// drawDots() call. Four drawPixel() calls and a division per dot took about 105 us: dense
// images (all yellow) overflowed the receive buffer (2.19.3). The PC can't time the Nano,
// so this checks the way it is drawn; the speed itself: tools/display_smoke_test.py.
void test_bitmap_digits_are_drawn_with_one_quick_call_each()
{
send("B000054131" + std::string(399, 'F') + "\n");
TEST_ASSERT_EQUAL(399, display.count("dots"));
TEST_ASSERT_EQUAL(4 * 399, display.count("pixel")); // only the ones drawDots() stands for
const DisplayCall *last = display.last("dots");
TEST_ASSERT_EQUAL(80, last->a); // the last digit of the last row
TEST_ASSERT_EQUAL(18, last->b);
TEST_ASSERT_EQUAL(0xF, last->c);
}
// The commands the ESP32 driver sends for a frame (lib/BROSE9323/frame_commands.h), run
// through this parser: the drawn dots must give the wanted image.
static const int IMG_BYTES = 11 * 19;
@@ -711,6 +726,7 @@ int main()
RUN_TEST(test_bitmap_pixels);
RUN_TEST(test_full_screen_bitmap_is_streamed);
RUN_TEST(test_bitmap_digits_are_drawn_with_one_quick_call_each);
RUN_TEST(test_frame_commands_send_only_the_changed_dots);
RUN_TEST(test_frame_commands_full_frame);
RUN_TEST(test_frame_commands_edge_and_nothing_changed);
+105
View File
@@ -0,0 +1,105 @@
"""Checks on the real display that the Nano gets every frame right, also very dense ones.
Run it after changes to the Nano firmware, the driver (lib/BROSE9323) or the frames.
python tools/display_smoke_test.py [ESP32 address, default 10.1.1.198]
It shows a few test images in live mode (all yellow, a dense image, a checkerboard), waits
for the frame check of each (the Nano's checksum of what it received) and then goes back
to normal. It fails when the frame check found a wrong image, a missing answer, a Nano
restart or an ERR reply (e.g. "Line too long": lost bytes). This is the speed test the PC
tests can't do: with 2.19.2 the Nano drew dense images slower than they arrived, which
broke all yellow. The dots flip for about 20 s. Only the Python standard library is used.
"""
import json
import sys
import time
import urllib.error
import urllib.request
ESP = 'http://' + (sys.argv[1] if len(sys.argv) > 1 else '10.1.1.198')
W, H, ROW_BYTES = 84, 19, 11
CHECK_TIMEOUT = 20 # s: the ESP asks every 3 s at most, the answer takes up to 2 s
COUNTERS = ('mismatches', 'no_reply', 'errors', 'restarts', 'repairs')
def request(path, body=None):
data = None if body is None else json.dumps(body).encode()
req = urllib.request.Request(ESP + path, data=data, headers={'Content-Type': 'application/json'})
try:
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read())
except urllib.error.HTTPError as e:
sys.exit('%s: %s' % (path, e.read().decode(errors='replace')))
def image(dot):
"""Bitmap hex (logic/bitmap_format.h: rows of 11 bytes, MSB = left dot) of dot(x, y)."""
out = []
for y in range(H):
for i in range(ROW_BYTES):
v = 0
for k in range(8):
x = i * 8 + k
if x < W and dot(x, y):
v |= 0x80 >> k
out.append('%02x' % v)
return ''.join(out)
TESTS = [
('all yellow', image(lambda x, y: True)),
# About 90 % of the dots, scattered: like the panda bitmap that failed
('dense image', image(lambda x, y: (x * 7 + y * 13) % 11 != 0)),
('checkerboard', image(lambda x, y: (x + y) % 2 == 0)),
]
def link():
return request('/api/status')['nano_link']
def answered(counts):
return counts['checks'] + counts['mismatches'] + counts['no_reply']
def main():
status = request('/api/status')
if not status['power_enabled']:
sys.exit('The Power switch is off (Home Assistant): live mode is not possible')
effect_s = request('/api/settings')['drive']['effect_time'] / 1000
start = link()
print('Firmware %s, frame check before: %s' % (status['version'], start))
failed = False
try:
for name, data in TESTS:
before = link()
request('/api/live', {'data': data})
deadline = time.time() + CHECK_TIMEOUT
now = before
while answered(now) == answered(before) and time.time() < deadline:
time.sleep(0.5)
now = link()
if answered(now) == answered(before):
print('%-13s no frame check within %d s' % (name, CHECK_TIMEOUT))
failed = True
continue
bad = {c: now[c] - before[c] for c in COUNTERS if now[c] != before[c]}
print('%-13s %s' % (name, 'ok' if not bad else 'FAILED %s %s' % (bad, now['last_error'])))
failed |= bool(bad)
time.sleep(effect_s + 1) # let it finish flipping, so each image can be seen
finally:
request('/api/live/stop', {})
end = link()
bad = {c: end[c] - start[c] for c in COUNTERS if end[c] != start[c]}
if bad:
print('Frame check after: %s, last error: %s' % (bad, end['last_error']))
failed = True
print('FAILED' if failed else 'All frames arrived right')
sys.exit(1 if failed else 0)
if __name__ == '__main__':
main()