Stopwatch counts in real time from a start date, kept over a restart

The stopwatch counted with millis(), so it started at 0 after every
restart and would overflow after 49 days. It now keeps the real start
time (UTC ms) and the time counted before a pause, stored in NVS
(system/stopwatch_store) on every change, so it keeps counting while
the display is off or restarting. Stop pauses, Start continues, and the
Stopwatch tab can set the start to a date and time (local, summer time
by mktime) to count from, e.g. days since an event. Start, stop and set
need the synced time.

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-20 22:22:32 +02:00
1 parent f5e77c7eca
commit 0166d0ee46
20 files changed
+278 -67

No files matched your search

+3
View File
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 131 KiB

+6 -2
View File
@@ -36,7 +36,7 @@ pages that can be changed; their Reset button brings back the original. See the
| Home Assistant value | An entity or attribute (also a path into lists, e.g. `forecast.1.temperature`), text before and after. Shown as a number (multiplier, decimals, auto unit `1257 W` → `1.3 kW`, without minus sign), a duration (`2h 21m`, `2:21:00`, `2:21`, `2 uur`), a time from an HA timestamp (`23:07`, `do 23:07`, `15-10 23:07`, `over 34 min`) or with own texts per state (`printing=Bezig, standby=Klaar`) |
| Text | Up to 32 characters |
| Countdown | To a date and time: smart (`12 dagen`, `5 uur`, `34 min`, `0:45`), days, `12d 04:05`, with seconds; a text when it has passed |
| Stopwatch | `4:05`, `0:04`, `0:04:05`; started in the [Stopwatch](#stopwatch) tab or Home Assistant |
| Stopwatch | `4:05`, `0:04`, `0:04:05`, `12d 4:05:09`, `3w 2d`; started in the [Stopwatch](#stopwatch) tab or Home Assistant |
| Image | An icon (or a bitmap) at a position, fixed or chosen by a Home Assistant state (`weer ` + `cloudy` = the icon `weer cloudy`, `weer default` when there is none) |
| Progress bar | Filled from the left by a Home Assistant number: value x multiplier / "full at" (a percentage: full at 100). Width, height, with or without a frame. Optionally the percentage on top (e.g. 42%, rounded down, in the chosen font): its dots invert the bar, so it stays readable on the filled and the empty part; left out when it doesn't fit inside (Small needs a height of 7 with a frame) |
| Game of Life | [Conway's Game of Life](https://conwaylife.com/wiki/Conway%27s_Game_of_Life) in an area (the whole display by default), one per page. Start: random dots (density %), one pattern, or the patterns whose names start with a text (e.g. `life_`) in turn or in random order, in the middle or at a random place. Edges: wrap around, dead, or a hidden margin (the world is 16 dots larger on every side, so gliders fly out of view and disappear). Speed in generations per second. It restarts when it stops changing (still, or repeating within 16 generations) and/or after a number of generations, after running on for a few seconds (an empty board at once) |
@@ -80,7 +80,11 @@ is shown instead. The display schedule ([Settings](#settings)) still decides whe
## Stopwatch
Start, stop and reset the stopwatch that pages can show (also in Home Assistant).
Start, stop and reset the stopwatch that pages can show (also in Home Assistant). It
counts in real time from its start, so it keeps counting while the display is off or
restarting (it is stored in NVS), and can run for years. Stop pauses it, Start continues.
**Set start** makes it count from a date and time you pick (e.g. days since an event).
Start and Set start need the synced time.
![Stopwatch](../Pictures/web-ui/stopwatch.png)
+2 -1
View File
@@ -29,7 +29,7 @@ debug output. Wiring is described in the [documentation](../../docs/wiring.md).
| `logic/life.*` | Conway's Game of Life: the board with its edges and cycle check (`LifeBoard`), timing and restarts (`LifeRunner`) |
| `logic/page_schedule.*` | Schedule blocks and trigger rules: the structs, checks, rule conditions |
| `logic/page_selector.*` | Which page is shown now: rules, a manual pick, the rotating schedule (`PageSelector`) |
| `logic/stopwatch.h` | The stopwatch of the dynamic pages |
| `logic/stopwatch.h` | The stopwatch of the dynamic pages (real time, stored by `system/stopwatch_store`) |
| `logic/power_timer.*` | Countdown to switching the display off |
| `logic/status_led.h` | Blink pattern of the built-in LED |
| `logic/boot_policy.*` | How to start after a reset: normal, OTA-only window, safe mode |
@@ -39,6 +39,7 @@ debug output. Wiring is described in the [documentation](../../docs/wiring.md).
| `system/system_store.*` | Settings in NVS: WiFi, recovery reason, firmware slot and version, power-on count |
| `system/settings_store.*` | The web UI settings in NVS |
| `system/schedule_store.*` | The page schedule and rules in NVS |
| `system/stopwatch_store.*` | The stopwatch in NVS (keeps counting over a restart) |
| **`display/`** | **What is drawn** |
| `display/frames.*` | The screens: dynamic page, "No WiFi", page bitmap, live image, off image (`FlipdotFrames`) |
| `display/bitmap_store.*` | Built-in bitmaps and the uploaded ones on LittleFS |
+44 -3
View File
@@ -15,6 +15,7 @@
#include "net/ha_api.h"
#include "system/schedule_store.h"
#include "system/settings_store.h"
#include "system/stopwatch_store.h"
static void copyName(char *out, const char *name)
{
@@ -119,6 +120,7 @@ void appLoadSettings()
{
loadSettings(settings);
loadSchedule(page_schedule);
loadStopwatch(stopwatch);
selector.setSchedule(&page_schedule);
selector.setChosen(settings.page);
haApiWatchRules(&page_schedule);
@@ -486,15 +488,54 @@ const char *appDeleteFont(const char *name)
return deleteFont(name);
}
void appStopwatch(StopwatchAction action)
static const char *NO_TIME = "The time is not synced yet";
const char *appStopwatch(StopwatchAction action)
{
// It counts in real time: before the sync, the clock still says 1970
if (!time_known && action != StopwatchAction::Reset)
return NO_TIME;
int64_t now = epochMs();
if (action == StopwatchAction::Start)
stopwatch.start(millis());
stopwatch.start(now);
else if (action == StopwatchAction::Stop)
stopwatch.stop(millis());
stopwatch.stop(now);
else
stopwatch.reset();
saveStopwatch(stopwatch);
controller.pageChanged();
return nullptr;
}
const char *appStopwatchSetStart(const char *local)
{
uint32_t minutes;
if (!parseTarget(local, minutes))
return "Invalid start: use YYYY-MM-DDTHH:MM";
if (!time_known)
return NO_TIME;
struct tm t = {};
sscanf(local, "%4d-%2d-%2dT%2d:%2d", &t.tm_year, &t.tm_mon, &t.tm_mday, &t.tm_hour, &t.tm_min);
t.tm_year -= 1900;
t.tm_mon -= 1;
t.tm_isdst = -1; // the time zone decides (summer time at that date)
time_t start = mktime(&t);
if (start == (time_t)-1 || !stopwatch.setStart((int64_t)start * 1000, epochMs()))
return "The start is in the future";
saveStopwatch(stopwatch);
controller.pageChanged();
return nullptr;
}
bool appStopwatchStart(char *out)
{
if (!time_known || !stopwatch.running())
return false;
time_t start = (time_t)((epochMs() - (int64_t)stopwatch.elapsed(epochMs())) / 1000);
struct tm t;
localtime_r(&start, &t);
strftime(out, 20, "%Y-%m-%dT%H:%M:%S", &t);
return true;
}
void appShowLive(const uint8_t *bitmap)
+7 -1
View File
@@ -77,7 +77,13 @@ enum class StopwatchAction
Stop,
Reset,
};
void appStopwatch(StopwatchAction action);
// nullptr or what went wrong (start and stop need the synced time)
const char *appStopwatch(StopwatchAction action);
// Counts from this local date and time on ("YYYY-MM-DDTHH:MM", in the past), running
const char *appStopwatchSetStart(const char *local);
// The local time it counts from when it is running ("YYYY-MM-DDTHH:MM:SS"; out holds 20
// characters); false when it is stopped or the time is not synced
bool appStopwatchStart(char *out);
// Live mode of the web UI
void appShowLive(const uint8_t *bitmap);
+1 -1
View File
@@ -118,7 +118,7 @@ const uint32_t SETUP_AP_DELAY = 20000; // open the AP when WiFi fails thi
const uint32_t WIFI_CONNECT_TIMEOUT = 30000;
const uint32_t WIFI_RESTART_TIME = 10UL * 60 * 1000;
#define FIRMWARE_VERSION "2.18.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};
+10 -2
View File
@@ -1,6 +1,7 @@
#include "display/page_resources.h"
#include <Arduino.h>
#include <sys/time.h>
#include "display/bitmap_store.h"
#include "display/font_store.h"
@@ -32,9 +33,16 @@ const char *StoredPageResources::haValue(const PageElement &element)
return haApiValue(element.entity, element.attribute);
}
uint32_t StoredPageResources::stopwatchMs()
uint64_t StoredPageResources::stopwatchMs()
{
return _stopwatch.elapsed(millis());
return _stopwatch.elapsed(epochMs());
}
int64_t epochMs()
{
struct timeval tv;
gettimeofday(&tv, nullptr);
return (int64_t)tv.tv_sec * 1000 + tv.tv_usec / 1000;
}
bool StoredPageResources::stopwatchRunning()
+3 -1
View File
@@ -14,7 +14,7 @@ public:
bool background(const char *name, uint8_t *bitmap) override;
Font font(const char *name) override;
const char *haValue(const PageElement &element) override;
uint32_t stopwatchMs() override;
uint64_t stopwatchMs() override;
bool stopwatchRunning() override;
const LifeRunner *life(const PageElement &element) override;
@@ -23,4 +23,6 @@ private:
};
extern Stopwatch stopwatch;
// The real time in ms since 1970 (UTC), for the stopwatch; only right once the time is synced
int64_t epochMs();
extern StoredPageResources pageResources;
+15 -5
View File
@@ -778,13 +778,23 @@ static void formatCountdown(const PageElement &e, const LocalTime &t, char *out,
snprintf(out, size, "%s%s", e.text, value);
}
static void formatStopwatch(const PageElement &e, uint32_t ms, char *out, size_t size)
static void formatStopwatch(const PageElement &e, uint64_t ms, char *out, size_t size)
{
unsigned long s = ms / 1000, h = s / 3600, m = s % 3600 / 60, sec = s % 60;
unsigned long s = (unsigned long)(ms / 1000), h = s / 3600, m = s % 3600 / 60, sec = s % 60;
unsigned long days = h / 24;
char value[24];
if (e.format == STOPWATCH_H_MM)
if (e.format == STOPWATCH_W_D)
{
if (days >= 7)
snprintf(value, sizeof(value), "%luw %lud", days / 7, days % 7);
else
snprintf(value, sizeof(value), "%lud", days);
}
else if (e.format == STOPWATCH_D_H_MM_SS && days)
snprintf(value, sizeof(value), "%lud %lu:%02lu:%02lu", days, h % 24, m, sec);
else if (e.format == STOPWATCH_H_MM)
snprintf(value, sizeof(value), "%lu:%02lu", h, m);
else if (e.format == STOPWATCH_H_MM_SS || h)
else if (e.format == STOPWATCH_H_MM_SS || e.format == STOPWATCH_D_H_MM_SS || h)
snprintf(value, sizeof(value), "%lu:%02lu:%02lu", h, m, sec);
else
snprintf(value, sizeof(value), "%lu:%02lu", m, sec);
@@ -860,7 +870,7 @@ bool elementShowsSeconds(const PageElement &e, const LocalTime &t, bool stopwatc
case ElementType::Clock:
return e.format == CLOCK_HH_MM_SS || e.format == CLOCK_H_MM_SS;
case ElementType::Stopwatch:
return stopwatch_running && e.format != STOPWATCH_H_MM;
return stopwatch_running && e.format != STOPWATCH_H_MM && e.format != STOPWATCH_W_D;
case ElementType::Countdown:
{
int64_t left = secondsLeft(e, t);
+4 -2
View File
@@ -97,7 +97,9 @@ const uint8_t COUNTDOWN_FORMAT_COUNT = 4;
const uint8_t STOPWATCH_M_SS = 0; // 4:05, from an hour on 1:04:05
const uint8_t STOPWATCH_H_MM = 1; // 0:04 (hours and minutes)
const uint8_t STOPWATCH_H_MM_SS = 2; // 0:04:05
const uint8_t STOPWATCH_FORMAT_COUNT = 3;
const uint8_t STOPWATCH_D_H_MM_SS = 3; // 12d 4:05:09, below a day 4:05:09
const uint8_t STOPWATCH_W_D = 4; // 3w 2d, without the weeks below a week
const uint8_t STOPWATCH_FORMAT_COUNT = 5;
// Progress bar formats
const uint8_t BAR_PLAIN = 0; // only the bar
@@ -238,7 +240,7 @@ const PageSlot *activeSlot(const DynamicPage &page, int minute_of_day);
struct ElementValues
{
const char *ha_value = nullptr; // nullptr: not known yet, shown as "--"
uint32_t stopwatch_ms = 0;
uint64_t stopwatch_ms = 0;
};
// The text of an element (not for images). out_size must be at least 2.
+4 -4
View File
@@ -270,7 +270,7 @@ uint32_t renderPage(const DynamicPage &page, const LocalTime &time, uint32_t now
uint32_t next = 0;
bool running = resources.stopwatchRunning();
uint32_t stopwatch_ms = resources.stopwatchMs();
uint64_t stopwatch_ms = resources.stopwatchMs();
for (int i = 0; i < page.element_count && i < PAGE_ELEMENTS; i++)
{
const PageElement &e = page.elements[i];
@@ -292,10 +292,10 @@ uint32_t renderPage(const DynamicPage &page, const LocalTime &time, uint32_t now
next = sooner(next, drawTextElement(e, text, now_ms, resources, bitmap, box));
if (elementShowsSeconds(e, time, running)) // the stopwatch's seconds count from its start
next = sooner(next, e.type == ElementType::Stopwatch ? 1000 - stopwatch_ms % 1000
next = sooner(next, e.type == ElementType::Stopwatch ? 1000 - (uint32_t)(stopwatch_ms % 1000)
: 1000 - (time.millisecond % 1000));
else if (e.type == ElementType::Stopwatch && running) // H:MM: the next stopwatch minute
next = sooner(next, 60000 - stopwatch_ms % 60000);
else if (e.type == ElementType::Stopwatch && running) // H:MM, weeks/days: the next stopwatch minute
next = sooner(next, 60000 - (uint32_t)(stopwatch_ms % 60000));
}
if (boxes)
boxes[i] = box;
+1 -1
View File
@@ -20,7 +20,7 @@ public:
virtual Font font(const char *name) = 0;
// The current value of a Home Assistant element; nullptr when not known
virtual const char *haValue(const PageElement &element) = 0;
virtual uint32_t stopwatchMs() { return 0; }
virtual uint64_t stopwatchMs() { return 0; }
virtual bool stopwatchRunning() { return false; }
// The running Game of Life of the shown page when this element starts like it
// (sameLifeStart); nullptr otherwise (the start is drawn then)
+42 -17
View File
@@ -3,35 +3,60 @@
#include <stdint.h>
// The stopwatch of the dynamic pages, started and stopped in the web UI and Home Assistant.
// Times are millis(). Pure logic, tested in test/test_esp32.
// Times are the real time in ms since 1970 (UTC), so it counts from a date, keeps counting
// while the display is off or restarting (system/stopwatch_store keeps it in NVS) and can
// run for years. Pure logic, tested in test/test_esp32.
class Stopwatch
{
public:
void start(uint32_t now)
// The state as it is stored
struct State
{
if (_running)
bool running = false;
int64_t started = 0; // when it was started (again)
int64_t before = 0; // ms counted before the last start
};
void start(int64_t now)
{
if (_state.running)
return;
_running = true;
_started = now;
_state.running = true;
_state.started = now;
}
void stop(uint32_t now)
void stop(int64_t now)
{
if (!_running)
if (!_state.running)
return;
_before += now - _started;
_running = false;
_state.before = elapsed(now);
_state.running = false;
}
// Back to 0, stopped
void reset()
void reset() { _state = State(); }
// Counts from this moment (in the past) on, running; false (nothing changed) when it is
// in the future
bool setStart(int64_t start, int64_t now)
{
_running = false;
_before = 0;
if (start > now)
return false;
_state.running = true;
_state.started = start;
_state.before = 0;
return true;
}
bool running() const { return _running; }
uint32_t elapsed(uint32_t now) const { return _before + (_running ? now - _started : 0); }
bool running() const { return _state.running; }
// Never negative (the clock set back)
uint64_t elapsed(int64_t now) const
{
int64_t ms = _state.before;
if (_state.running && now > _state.started)
ms += now - _state.started;
return ms > 0 ? (uint64_t)ms : 0;
}
const State &state() const { return _state; }
void restore(const State &state) { _state = state; }
private:
bool _running = false;
uint32_t _started = 0;
uint32_t _before = 0; // ms counted before the last start
State _state;
};
+41
View File
@@ -0,0 +1,41 @@
#include "system/stopwatch_store.h"
#include <Preferences.h>
static const char *NAMESPACE = "flipdot";
static const char *KEY = "stopwatch";
// Increase when Stopwatch::State changes; records of other versions are ignored
static const uint16_t VERSION = 1;
struct StoredStopwatch
{
uint16_t version;
uint16_t size;
Stopwatch::State state;
};
void loadStopwatch(Stopwatch &stopwatch)
{
stopwatch.reset();
StoredStopwatch stored;
Preferences prefs;
prefs.begin(NAMESPACE, true);
bool ok = prefs.isKey(KEY) && prefs.getBytesLength(KEY) == sizeof(stored) &&
prefs.getBytes(KEY, &stored, sizeof(stored)) == sizeof(stored);
prefs.end();
if (ok && stored.version == VERSION && stored.size == sizeof(Stopwatch::State) && stored.state.before >= 0 &&
stored.state.started >= 0)
stopwatch.restore(stored.state);
}
void saveStopwatch(const Stopwatch &stopwatch)
{
StoredStopwatch stored;
stored.version = VERSION;
stored.size = sizeof(Stopwatch::State);
stored.state = stopwatch.state();
Preferences prefs;
prefs.begin(NAMESPACE, false);
prefs.putBytes(KEY, &stored, sizeof(stored));
prefs.end();
}
+8
View File
@@ -0,0 +1,8 @@
#pragma once
#include "logic/stopwatch.h"
// The stopwatch in NVS (namespace "flipdot", key "stopwatch"), so it keeps counting over a
// restart. A missing, outdated or invalid record gives a stopwatch at 0.
void loadStopwatch(Stopwatch &stopwatch);
void saveStopwatch(const Stopwatch &stopwatch);
+8 -5
View File
@@ -108,9 +108,11 @@ details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:13px;m
<section id="t-stopwatch"><div class="card"><h2>Stopwatch</h2>
<div class="row"><b id="swtime" style="font-size:32px;font-variant-numeric:tabular-nums">0:00</b></div>
<div class="row"><span id="swsince" class="hint"></span></div>
<div class="row"><button class="b" onclick="stopwatch('start')">Start</button><button class="s" onclick="stopwatch('stop')">Stop</button>
<button class="s" onclick="stopwatch('reset')">Reset</button></div>
<p class="hint">Shown by pages with a stopwatch element (e.g. the Stopwatch page). Also in Home Assistant: the Stopwatch switch and the Stopwatch reset button.</p></div></section>
<div class="row"><label>Counting since <input type="datetime-local" id="swstart"></label><button class="s" onclick="swSetStart()">Set start</button></div>
<p class="hint">Shown by pages with a stopwatch element (e.g. the Stopwatch page). It counts in real time from its start, also while the display is off or restarting. Stop pauses it, Start continues. Set start: counts from that date and time on (e.g. days since an event). Also in Home Assistant: the Stopwatch switch and the Stopwatch reset button.</p></div></section>
<section id="t-bitmaps"><div class="card"><h2>Bitmaps</h2><div class="grid" id="bitmaps"></div>
<details id="bitmaps_used_box"><summary></summary><div class="grid" id="bitmaps_used"></div></details>
@@ -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');
+22 -10
View File
@@ -137,6 +137,16 @@ static void handleRoot()
server.send_P(200, "text/html", WEB_PAGE);
}
// {running, ms, start (local, while running)}
static void stopwatchJson(JsonObject sw)
{
sw["running"] = stopwatch.running();
sw["ms"] = stopwatch.elapsed(epochMs());
char start[20];
if (appStopwatchStart(start))
sw["start"] = start;
}
static void handleStatus()
{
JsonDocument doc;
@@ -168,9 +178,7 @@ static void handleStatus()
doc["off_page"] = appSettings().off_page;
doc["power_enabled"] = controller.powerEnabled();
doc["slow_mode"] = controller.slowModeActive();
JsonObject sw = doc["stopwatch"].to<JsonObject>();
sw["running"] = stopwatch.running();
sw["ms"] = stopwatch.elapsed(millis());
stopwatchJson(doc["stopwatch"].to<JsonObject>());
JsonObject load = doc["load"].to<JsonObject>();
load["loops_per_s"] = loopStats.loopsPerSecond();
@@ -632,27 +640,31 @@ static void handleLive()
sendOk();
}
// {action: "start" | "stop" | "reset"}
// {action: "start" | "stop" | "reset" | "set", start: "YYYY-MM-DDTHH:MM" (set, local time)}
static void handleStopwatch()
{
JsonDocument body;
if (!readBody(body))
return;
const char *action = body["action"] | "";
const char *error;
if (strcmp(action, "start") == 0)
appStopwatch(StopwatchAction::Start);
error = appStopwatch(StopwatchAction::Start);
else if (strcmp(action, "stop") == 0)
appStopwatch(StopwatchAction::Stop);
error = appStopwatch(StopwatchAction::Stop);
else if (strcmp(action, "reset") == 0)
appStopwatch(StopwatchAction::Reset);
error = appStopwatch(StopwatchAction::Reset);
else if (strcmp(action, "set") == 0)
error = appStopwatchSetStart(body["start"] | "");
else
error = "Unknown stopwatch action";
if (error)
{
sendError(400, "Unknown stopwatch action");
sendError(400, error);
return;
}
JsonDocument doc;
doc["running"] = stopwatch.running();
doc["ms"] = stopwatch.elapsed(millis());
stopwatchJson(doc.to<JsonObject>());
sendJson(200, doc);
}
+2 -1
View File
@@ -33,7 +33,8 @@
// POST /api/schedule/back end a manual pick
// POST /api/live show now {data}
// POST /api/live/stop
// POST /api/stopwatch {action: "start" | "stop" | "reset"} -> {running, ms}
// POST /api/stopwatch {action: "start" | "stop" | "reset" | "set", start:
// "YYYY-MM-DDTHH:MM" (set, local)} -> {running, ms, start}
// GET /api/settings (the MQTT password and HA token only with ?secrets=1, for
// the export; otherwise only *_set)
// POST /api/settings changed fields; {settings, restart}
+55 -11
View File
@@ -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()