Public Access
Compare commits
6
Commits
16b7856593
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abd63d070f | ||
|
|
7a28e34380 | ||
|
|
41af2944bc | ||
|
|
72a193130e | ||
|
|
0166d0ee46 | ||
|
|
f5e77c7eca |
No files matched your search
@@ -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 |
@@ -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
@@ -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.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -182,28 +182,28 @@ 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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user