Public Access
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:
1 parent
f5e77c7eca
commit
0166d0ee46
20 files changed
+278
-67
No files matched your search
@@ -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,
|
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}`).
|
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 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
|
- 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()`).
|
`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
|
- 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
@@ -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`) |
|
| 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 |
|
| 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 |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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
|
## 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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
+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/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_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/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/power_timer.*` | Countdown to switching the display off |
|
||||||
| `logic/status_led.h` | Blink pattern of the built-in LED |
|
| `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 |
|
| `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/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/settings_store.*` | The web UI settings in NVS |
|
||||||
| `system/schedule_store.*` | The page schedule and rules 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/`** | **What is drawn** |
|
||||||
| `display/frames.*` | The screens: dynamic page, "No WiFi", page bitmap, live image, off image (`FlipdotFrames`) |
|
| `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 |
|
| `display/bitmap_store.*` | Built-in bitmaps and the uploaded ones on LittleFS |
|
||||||
|
|||||||
+44
-3
@@ -15,6 +15,7 @@
|
|||||||
#include "net/ha_api.h"
|
#include "net/ha_api.h"
|
||||||
#include "system/schedule_store.h"
|
#include "system/schedule_store.h"
|
||||||
#include "system/settings_store.h"
|
#include "system/settings_store.h"
|
||||||
|
#include "system/stopwatch_store.h"
|
||||||
|
|
||||||
static void copyName(char *out, const char *name)
|
static void copyName(char *out, const char *name)
|
||||||
{
|
{
|
||||||
@@ -119,6 +120,7 @@ void appLoadSettings()
|
|||||||
{
|
{
|
||||||
loadSettings(settings);
|
loadSettings(settings);
|
||||||
loadSchedule(page_schedule);
|
loadSchedule(page_schedule);
|
||||||
|
loadStopwatch(stopwatch);
|
||||||
selector.setSchedule(&page_schedule);
|
selector.setSchedule(&page_schedule);
|
||||||
selector.setChosen(settings.page);
|
selector.setChosen(settings.page);
|
||||||
haApiWatchRules(&page_schedule);
|
haApiWatchRules(&page_schedule);
|
||||||
@@ -486,15 +488,54 @@ const char *appDeleteFont(const char *name)
|
|||||||
return deleteFont(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)
|
if (action == StopwatchAction::Start)
|
||||||
stopwatch.start(millis());
|
stopwatch.start(now);
|
||||||
else if (action == StopwatchAction::Stop)
|
else if (action == StopwatchAction::Stop)
|
||||||
stopwatch.stop(millis());
|
stopwatch.stop(now);
|
||||||
else
|
else
|
||||||
stopwatch.reset();
|
stopwatch.reset();
|
||||||
|
saveStopwatch(stopwatch);
|
||||||
controller.pageChanged();
|
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)
|
void appShowLive(const uint8_t *bitmap)
|
||||||
|
|||||||
+7
-1
@@ -77,7 +77,13 @@ enum class StopwatchAction
|
|||||||
Stop,
|
Stop,
|
||||||
Reset,
|
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
|
// Live mode of the web UI
|
||||||
void appShowLive(const uint8_t *bitmap);
|
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_CONNECT_TIMEOUT = 30000;
|
||||||
const uint32_t WIFI_RESTART_TIME = 10UL * 60 * 1000;
|
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)
|
// 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};
|
const uint8_t HA_DEVICE_ID[] = {0x34, 0x86, 0x5d, 0x3a, 0x08, 0x28};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#include "display/page_resources.h"
|
#include "display/page_resources.h"
|
||||||
|
|
||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
|
#include <sys/time.h>
|
||||||
|
|
||||||
#include "display/bitmap_store.h"
|
#include "display/bitmap_store.h"
|
||||||
#include "display/font_store.h"
|
#include "display/font_store.h"
|
||||||
@@ -32,9 +33,16 @@ const char *StoredPageResources::haValue(const PageElement &element)
|
|||||||
return haApiValue(element.entity, element.attribute);
|
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()
|
bool StoredPageResources::stopwatchRunning()
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ public:
|
|||||||
bool background(const char *name, uint8_t *bitmap) override;
|
bool background(const char *name, uint8_t *bitmap) override;
|
||||||
Font font(const char *name) override;
|
Font font(const char *name) override;
|
||||||
const char *haValue(const PageElement &element) override;
|
const char *haValue(const PageElement &element) override;
|
||||||
uint32_t stopwatchMs() override;
|
uint64_t stopwatchMs() override;
|
||||||
bool stopwatchRunning() override;
|
bool stopwatchRunning() override;
|
||||||
const LifeRunner *life(const PageElement &element) override;
|
const LifeRunner *life(const PageElement &element) override;
|
||||||
|
|
||||||
@@ -23,4 +23,6 @@ private:
|
|||||||
};
|
};
|
||||||
|
|
||||||
extern Stopwatch stopwatch;
|
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;
|
extern StoredPageResources pageResources;
|
||||||
@@ -778,13 +778,23 @@ static void formatCountdown(const PageElement &e, const LocalTime &t, char *out,
|
|||||||
snprintf(out, size, "%s%s", e.text, value);
|
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];
|
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);
|
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);
|
snprintf(value, sizeof(value), "%lu:%02lu:%02lu", h, m, sec);
|
||||||
else
|
else
|
||||||
snprintf(value, sizeof(value), "%lu:%02lu", m, sec);
|
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:
|
case ElementType::Clock:
|
||||||
return e.format == CLOCK_HH_MM_SS || e.format == CLOCK_H_MM_SS;
|
return e.format == CLOCK_HH_MM_SS || e.format == CLOCK_H_MM_SS;
|
||||||
case ElementType::Stopwatch:
|
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:
|
case ElementType::Countdown:
|
||||||
{
|
{
|
||||||
int64_t left = secondsLeft(e, t);
|
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_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 = 1; // 0:04 (hours and minutes)
|
||||||
const uint8_t STOPWATCH_H_MM_SS = 2; // 0:04:05
|
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
|
// Progress bar formats
|
||||||
const uint8_t BAR_PLAIN = 0; // only the bar
|
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
|
struct ElementValues
|
||||||
{
|
{
|
||||||
const char *ha_value = nullptr; // nullptr: not known yet, shown as "--"
|
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.
|
// 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;
|
uint32_t next = 0;
|
||||||
bool running = resources.stopwatchRunning();
|
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++)
|
for (int i = 0; i < page.element_count && i < PAGE_ELEMENTS; i++)
|
||||||
{
|
{
|
||||||
const PageElement &e = 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));
|
next = sooner(next, drawTextElement(e, text, now_ms, resources, bitmap, box));
|
||||||
|
|
||||||
if (elementShowsSeconds(e, time, running)) // the stopwatch's seconds count from its start
|
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));
|
: 1000 - (time.millisecond % 1000));
|
||||||
else if (e.type == ElementType::Stopwatch && running) // H:MM: the next stopwatch minute
|
else if (e.type == ElementType::Stopwatch && running) // H:MM, weeks/days: the next stopwatch minute
|
||||||
next = sooner(next, 60000 - stopwatch_ms % 60000);
|
next = sooner(next, 60000 - (uint32_t)(stopwatch_ms % 60000));
|
||||||
}
|
}
|
||||||
if (boxes)
|
if (boxes)
|
||||||
boxes[i] = box;
|
boxes[i] = box;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ public:
|
|||||||
virtual Font font(const char *name) = 0;
|
virtual Font font(const char *name) = 0;
|
||||||
// The current value of a Home Assistant element; nullptr when not known
|
// The current value of a Home Assistant element; nullptr when not known
|
||||||
virtual const char *haValue(const PageElement &element) = 0;
|
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; }
|
virtual bool stopwatchRunning() { return false; }
|
||||||
// The running Game of Life of the shown page when this element starts like it
|
// The running Game of Life of the shown page when this element starts like it
|
||||||
// (sameLifeStart); nullptr otherwise (the start is drawn then)
|
// (sameLifeStart); nullptr otherwise (the start is drawn then)
|
||||||
|
|||||||
+42
-17
@@ -3,35 +3,60 @@
|
|||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
|
|
||||||
// The stopwatch of the dynamic pages, started and stopped in the web UI and Home Assistant.
|
// 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
|
class Stopwatch
|
||||||
{
|
{
|
||||||
public:
|
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;
|
return;
|
||||||
_running = true;
|
_state.running = true;
|
||||||
_started = now;
|
_state.started = now;
|
||||||
}
|
}
|
||||||
void stop(uint32_t now)
|
void stop(int64_t now)
|
||||||
{
|
{
|
||||||
if (!_running)
|
if (!_state.running)
|
||||||
return;
|
return;
|
||||||
_before += now - _started;
|
_state.before = elapsed(now);
|
||||||
_running = false;
|
_state.running = false;
|
||||||
}
|
}
|
||||||
// Back to 0, stopped
|
// 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;
|
if (start > now)
|
||||||
_before = 0;
|
return false;
|
||||||
|
_state.running = true;
|
||||||
|
_state.started = start;
|
||||||
|
_state.before = 0;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
bool running() const { return _running; }
|
bool running() const { return _state.running; }
|
||||||
uint32_t elapsed(uint32_t now) const { return _before + (_running ? now - _started : 0); }
|
// 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:
|
private:
|
||||||
bool _running = false;
|
State _state;
|
||||||
uint32_t _started = 0;
|
|
||||||
uint32_t _before = 0; // ms counted before the last start
|
|
||||||
};
|
};
|
||||||
@@ -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);
|
||||||
@@ -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>
|
<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"><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>
|
<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>
|
<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>
|
<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>
|
<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 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 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()}
|
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 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)$('swtime').textContent=swText(sw.ms)+(sw.running?' (running)':'')}
|
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){swShow(await post('/api/stopwatch',{action}));if(pe)pePreview()}
|
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 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')}
|
async function stopLive(){await post('/api/live/stop');msg('Back to normal')}
|
||||||
// schedule and rules
|
// 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]]))),
|
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)));
|
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'})))}
|
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})));
|
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')))}
|
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');
|
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
@@ -137,6 +137,16 @@ static void handleRoot()
|
|||||||
server.send_P(200, "text/html", WEB_PAGE);
|
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()
|
static void handleStatus()
|
||||||
{
|
{
|
||||||
JsonDocument doc;
|
JsonDocument doc;
|
||||||
@@ -168,9 +178,7 @@ static void handleStatus()
|
|||||||
doc["off_page"] = appSettings().off_page;
|
doc["off_page"] = appSettings().off_page;
|
||||||
doc["power_enabled"] = controller.powerEnabled();
|
doc["power_enabled"] = controller.powerEnabled();
|
||||||
doc["slow_mode"] = controller.slowModeActive();
|
doc["slow_mode"] = controller.slowModeActive();
|
||||||
JsonObject sw = doc["stopwatch"].to<JsonObject>();
|
stopwatchJson(doc["stopwatch"].to<JsonObject>());
|
||||||
sw["running"] = stopwatch.running();
|
|
||||||
sw["ms"] = stopwatch.elapsed(millis());
|
|
||||||
|
|
||||||
JsonObject load = doc["load"].to<JsonObject>();
|
JsonObject load = doc["load"].to<JsonObject>();
|
||||||
load["loops_per_s"] = loopStats.loopsPerSecond();
|
load["loops_per_s"] = loopStats.loopsPerSecond();
|
||||||
@@ -632,27 +640,31 @@ static void handleLive()
|
|||||||
sendOk();
|
sendOk();
|
||||||
}
|
}
|
||||||
|
|
||||||
// {action: "start" | "stop" | "reset"}
|
// {action: "start" | "stop" | "reset" | "set", start: "YYYY-MM-DDTHH:MM" (set, local time)}
|
||||||
static void handleStopwatch()
|
static void handleStopwatch()
|
||||||
{
|
{
|
||||||
JsonDocument body;
|
JsonDocument body;
|
||||||
if (!readBody(body))
|
if (!readBody(body))
|
||||||
return;
|
return;
|
||||||
const char *action = body["action"] | "";
|
const char *action = body["action"] | "";
|
||||||
|
const char *error;
|
||||||
if (strcmp(action, "start") == 0)
|
if (strcmp(action, "start") == 0)
|
||||||
appStopwatch(StopwatchAction::Start);
|
error = appStopwatch(StopwatchAction::Start);
|
||||||
else if (strcmp(action, "stop") == 0)
|
else if (strcmp(action, "stop") == 0)
|
||||||
appStopwatch(StopwatchAction::Stop);
|
error = appStopwatch(StopwatchAction::Stop);
|
||||||
else if (strcmp(action, "reset") == 0)
|
else if (strcmp(action, "reset") == 0)
|
||||||
appStopwatch(StopwatchAction::Reset);
|
error = appStopwatch(StopwatchAction::Reset);
|
||||||
|
else if (strcmp(action, "set") == 0)
|
||||||
|
error = appStopwatchSetStart(body["start"] | "");
|
||||||
else
|
else
|
||||||
|
error = "Unknown stopwatch action";
|
||||||
|
if (error)
|
||||||
{
|
{
|
||||||
sendError(400, "Unknown stopwatch action");
|
sendError(400, error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
JsonDocument doc;
|
JsonDocument doc;
|
||||||
doc["running"] = stopwatch.running();
|
stopwatchJson(doc.to<JsonObject>());
|
||||||
doc["ms"] = stopwatch.elapsed(millis());
|
|
||||||
sendJson(200, doc);
|
sendJson(200, doc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,8 @@
|
|||||||
// POST /api/schedule/back end a manual pick
|
// POST /api/schedule/back end a manual pick
|
||||||
// POST /api/live show now {data}
|
// POST /api/live show now {data}
|
||||||
// POST /api/live/stop
|
// 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
|
// GET /api/settings (the MQTT password and HA token only with ?secrets=1, for
|
||||||
// the export; otherwise only *_set)
|
// the export; otherwise only *_set)
|
||||||
// POST /api/settings changed fields; {settings, restart}
|
// POST /api/settings changed fields; {settings, restart}
|
||||||
|
|||||||
@@ -1475,21 +1475,42 @@ void test_countdown_shows_seconds_only_when_needed()
|
|||||||
|
|
||||||
void test_stopwatch()
|
void test_stopwatch()
|
||||||
{
|
{
|
||||||
|
const int64_t T = 1760000000000LL; // ms since 1970, in 2025
|
||||||
Stopwatch sw;
|
Stopwatch sw;
|
||||||
TEST_ASSERT_EQUAL_UINT32(0, sw.elapsed(5000));
|
TEST_ASSERT_EQUAL_UINT64(0, sw.elapsed(T));
|
||||||
sw.start(1000);
|
sw.start(T + 1000);
|
||||||
TEST_ASSERT_TRUE(sw.running());
|
TEST_ASSERT_TRUE(sw.running());
|
||||||
TEST_ASSERT_EQUAL_UINT32(2000, sw.elapsed(3000));
|
TEST_ASSERT_EQUAL_UINT64(2000, sw.elapsed(T + 3000));
|
||||||
sw.stop(4000);
|
sw.stop(T + 4000);
|
||||||
TEST_ASSERT_EQUAL_UINT32(3000, sw.elapsed(9000));
|
TEST_ASSERT_EQUAL_UINT64(3000, sw.elapsed(T + 9000));
|
||||||
sw.start(10000);
|
sw.start(T + 10000);
|
||||||
sw.start(11000); // already running: no restart
|
sw.start(T + 11000); // already running: no restart
|
||||||
TEST_ASSERT_EQUAL_UINT32(5000, sw.elapsed(12000));
|
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();
|
sw.reset();
|
||||||
TEST_ASSERT_FALSE(sw.running());
|
TEST_ASSERT_FALSE(sw.running());
|
||||||
TEST_ASSERT_EQUAL_UINT32(0, sw.elapsed(20000));
|
TEST_ASSERT_EQUAL_UINT64(0, sw.elapsed(T + 20000));
|
||||||
sw.start(0xFFFFFF00u); // across the millis() overflow
|
|
||||||
TEST_ASSERT_EQUAL_UINT32(0x200, sw.elapsed(0x100));
|
// 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
|
PageElement e; // textOf: 65 s on the stopwatch
|
||||||
e.type = ElementType::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_EQUAL_STRING("T 0:01:05", textOf(e).c_str());
|
||||||
TEST_ASSERT_TRUE(elementShowsSeconds(e, LocalTime(), true));
|
TEST_ASSERT_TRUE(elementShowsSeconds(e, LocalTime(), true));
|
||||||
TEST_ASSERT_FALSE(elementShowsSeconds(e, LocalTime(), false)); // stopped
|
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()
|
void test_home_assistant_multiplier()
|
||||||
|
|||||||
Reference in new issue
Block a user