diff --git a/CLAUDE.md b/CLAUDE.md index c142eac..8d9a169 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -218,6 +218,9 @@ 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 diff --git a/Pictures/web-ui/stopwatch.png b/Pictures/web-ui/stopwatch.png index 1a9e03c..2ea516d 100644 Binary files a/Pictures/web-ui/stopwatch.png and b/Pictures/web-ui/stopwatch.png differ diff --git a/docs/web-ui.md b/docs/web-ui.md index 3a62ad0..172bd9a 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -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) diff --git a/src/esp32/README.md b/src/esp32/README.md index 983ad18..f8b88a9 100644 --- a/src/esp32/README.md +++ b/src/esp32/README.md @@ -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 | diff --git a/src/esp32/app.cpp b/src/esp32/app.cpp index 0b318c3..f00e7d9 100644 --- a/src/esp32/app.cpp +++ b/src/esp32/app.cpp @@ -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) diff --git a/src/esp32/app.h b/src/esp32/app.h index c05df9d..eebda4d 100644 --- a/src/esp32/app.h +++ b/src/esp32/app.h @@ -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); diff --git a/src/esp32/config.h b/src/esp32/config.h index 4f6afc3..a73288e 100644 --- a/src/esp32/config.h +++ b/src/esp32/config.h @@ -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.2" +#define FIRMWARE_VERSION "2.19.0" // Home Assistant device id (do not change: Home Assistant's entities are linked to it) const uint8_t HA_DEVICE_ID[] = {0x34, 0x86, 0x5d, 0x3a, 0x08, 0x28}; diff --git a/src/esp32/display/page_resources.cpp b/src/esp32/display/page_resources.cpp index 5fd9612..50bea15 100644 --- a/src/esp32/display/page_resources.cpp +++ b/src/esp32/display/page_resources.cpp @@ -1,6 +1,7 @@ #include "display/page_resources.h" #include +#include #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() diff --git a/src/esp32/display/page_resources.h b/src/esp32/display/page_resources.h index 4d2307a..9de0102 100644 --- a/src/esp32/display/page_resources.h +++ b/src/esp32/display/page_resources.h @@ -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; diff --git a/src/esp32/logic/page.cpp b/src/esp32/logic/page.cpp index 725f325..d716083 100644 --- a/src/esp32/logic/page.cpp +++ b/src/esp32/logic/page.cpp @@ -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); diff --git a/src/esp32/logic/page.h b/src/esp32/logic/page.h index 86861b1..3aa840c 100644 --- a/src/esp32/logic/page.h +++ b/src/esp32/logic/page.h @@ -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. diff --git a/src/esp32/logic/page_render.cpp b/src/esp32/logic/page_render.cpp index 0ec95b0..4081bd8 100644 --- a/src/esp32/logic/page_render.cpp +++ b/src/esp32/logic/page_render.cpp @@ -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; diff --git a/src/esp32/logic/page_render.h b/src/esp32/logic/page_render.h index 56ba823..e43ba4a 100644 --- a/src/esp32/logic/page_render.h +++ b/src/esp32/logic/page_render.h @@ -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) diff --git a/src/esp32/logic/stopwatch.h b/src/esp32/logic/stopwatch.h index 99143a4..33ca519 100644 --- a/src/esp32/logic/stopwatch.h +++ b/src/esp32/logic/stopwatch.h @@ -3,35 +3,60 @@ #include // 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; }; diff --git a/src/esp32/system/stopwatch_store.cpp b/src/esp32/system/stopwatch_store.cpp new file mode 100644 index 0000000..dcb394b --- /dev/null +++ b/src/esp32/system/stopwatch_store.cpp @@ -0,0 +1,41 @@ +#include "system/stopwatch_store.h" + +#include + +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(); +} diff --git a/src/esp32/system/stopwatch_store.h b/src/esp32/system/stopwatch_store.h new file mode 100644 index 0000000..1364000 --- /dev/null +++ b/src/esp32/system/stopwatch_store.h @@ -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); diff --git a/src/esp32/web/web_page.h b/src/esp32/web/web_page.h index b15dab1..7e93a54 100644 --- a/src/esp32/web/web_page.h +++ b/src/esp32/web/web_page.h @@ -108,9 +108,11 @@ details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:13px;m

Stopwatch

0:00
+
-

Shown by pages with a stopwatch element (e.g. the Stopwatch page). Also in Home Assistant: the Stopwatch switch and the Stopwatch reset button.

+
+

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.

Bitmaps

@@ -315,9 +317,10 @@ async function toBitmap(n){await post('/api/icon/to_bitmap',{name:n});msg(n+' is 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()} +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 @@ -428,7 +431,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'); diff --git a/src/esp32/web/web_ui.cpp b/src/esp32/web/web_ui.cpp index 6e717f2..aca5f3c 100644 --- a/src/esp32/web/web_ui.cpp +++ b/src/esp32/web/web_ui.cpp @@ -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(); - sw["running"] = stopwatch.running(); - sw["ms"] = stopwatch.elapsed(millis()); + stopwatchJson(doc["stopwatch"].to()); JsonObject load = doc["load"].to(); 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()); sendJson(200, doc); } diff --git a/src/esp32/web/web_ui.h b/src/esp32/web/web_ui.h index 17baa4e..b94889c 100644 --- a/src/esp32/web/web_ui.h +++ b/src/esp32/web/web_ui.h @@ -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} diff --git a/test/test_esp32/test_main.cpp b/test/test_esp32/test_main.cpp index 0514681..3a42476 100644 --- a/test/test_esp32/test_main.cpp +++ b/test/test_esp32/test_main.cpp @@ -1475,21 +1475,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 +1523,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()