Skip to content

Commit af6c8d3

Browse files
fhirschmannclaude
andcommitted
feat: per-day listening-time statistics (today / yesterday / 7d / 30d)
New Playstats module accumulates playback seconds per local calendar day in a 365-day ring buffer, persisted in NVS (~1.5 KB blob, written at most once a minute and on shutdown). Local day number is derived from the calendar fields via days_from_civil (tm_gmtoff isn't available in this newlib build). Ticks are ignored until the clock (NTP/RTC) is valid; the buffer self-heals gaps when days roll over or the device was off. - AudioPlayer: Playstats_Init() at start, Playstats_AddSecond() per played second, Playstats_Save() on shutdown. - /info audio block exposes playToday/playYesterday/play7d/play30d (seconds); the info dialog shows them next to total playtime. i18n de/en/fr. Verified on the test device: playing /audio.mp3 increments playToday in real time; 7d/30d include today; values survive a reboot (NVS persist); info labels localize. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 03c7b5a commit af6c8d3

9 files changed

Lines changed: 191 additions & 3 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ neon logo that doubles as the SVG favicon ([`7be5254`](../../commit/7be5254)):
139139
| **No standby while on external power**: optional setting (General tab) that keeps the device from entering the inactivity standby while it is on external power. The `complete` board has no charge/USB sense, so "on power" is inferred heuristically from the battery voltage against a configurable threshold (default **3.5 V** — the LiFePO4 charge voltage sits above its resting voltage). The manual sleep timer is unaffected. i18n de/en/fr | [`no-sleep-on-power`](../../releases/tag/no-sleep-on-power) |
140140
| **Auto-save settings (General + Buttons)**: the **General** and **Buttons** settings sub-tabs no longer have a submit button — changes are persisted automatically (debounced ~0.6 s) as soon as you toggle/select or leave a field. Populating the form on load is suppressed so it never triggers a spurious save; the **Reset** button now applies the defaults right away. The credential/connection tabs (WiFi, security, MQTT, FTP, Bluetooth) keep their explicit submit button on purpose (half-typed passwords / reboot on Bluetooth-mode change). i18n de/en/fr | [`settings-autosave`](../../releases/tag/settings-autosave) |
141141
| **RFID settings sub-tab + learned-cards list**: the old inline RFID-assignment form (id field + play-mode + submit) is gone — music cards are learned from the file-browser context menu (scan popup). A new **RFID** settings sub-tab learns **modification cards** (pick a modification action → **Learn card** → scan popup) and lists **all learned cards** (music = path + play mode, modification = action) with a delete button each. i18n de/en/fr | [`rfid-settings-tab`](../../releases/tag/rfid-settings-tab) |
142+
| **Listening-time statistics**: tracks how long was actually listened per local calendar day in a 365-day ring buffer (persisted in NVS, ~1.5 KB). The info dialog (ℹ️) shows **today / yesterday / last 7 days / last 30 days** next to the existing total playtime. Seconds are accumulated by the audio loop while playing (clock from NTP/RTC; ignored until the clock is valid), flushed to NVS at most once a minute and on shutdown. New `/info` audio fields `playToday`/`playYesterday`/`play7d`/`play30d` (seconds). i18n de/en/fr | [`listening-stats`](../../releases/tag/listening-stats) |
142143
| **OpenAPI/Swagger spec updated**: the bundled `REST_API.yaml` (served at `/swagger.html`) now documents all fork-added endpoints (`/sync`, `/syncstop`, `/playlist`, `/githubupdate`, `/version`, `/login`, `/logout`, `/security`, `/eqrule(s)`, `/activeequalizer`, `/bluetooth*`, `/sdclean`) and declares the security schemes (`X-API-Key` header / `apikey` query param, or the `ESPUINO_SESSION` login cookie) so the Swagger "Authorize" button works. The web-interface footer links to the Swagger UI (`/swagger.html`) | [`f8298d9`](../../commit/f8298d9) |
143144

144145
#### Feature highlights

html/locales/de.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -725,6 +725,10 @@
725725
"audiotimesincestart": "Spielzeit seit letztem Start: {{audiotimesincestart}}",
726726
"currvoltage": "Aktuelle Batteriespannung: {{currvoltage}} V",
727727
"chargelevel": "Aktuelle Batterieladung: {{chargelevel}} %",
728-
"hallsensor": "Hall-Sensor Nullfeldwert: {{hsnullfieldvalue}}, aktueller Wert: {{hsactual}}, Differenz:{{hsdiff}}, letzter WaitFor-Zustand:{{hslastwaitforstate}} (Wartezeit:{{hswaited}} ms)"
728+
"hallsensor": "Hall-Sensor Nullfeldwert: {{hsnullfieldvalue}}, aktueller Wert: {{hsactual}}, Differenz:{{hsdiff}}, letzter WaitFor-Zustand:{{hslastwaitforstate}} (Wartezeit:{{hswaited}} ms)",
729+
"lblPlayToday": "Heute gehört",
730+
"lblPlayYesterday": "Gestern gehört",
731+
"lblPlay7d": "Letzte 7 Tage",
732+
"lblPlay30d": "Letzte 30 Tage"
729733
}
730734
}

html/locales/en.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -725,6 +725,10 @@
725725
"audiotimesincestart": "Playtime since last start: {{audiotimesincestart}}",
726726
"currvoltage": "Current battery voltage: {{currvoltage}} V",
727727
"chargelevel": "Current charge level: {{chargelevel}} %",
728-
"hallsensor": "HallEffectSensor NullFieldValue: {{hsnullfieldvalue}}, actual: {{hsactual}}, diff:{{hsdiff}}, LastWaitFor_State:{{hslastwaitforstate}} (waited:{{hswaited}} ms)"
728+
"hallsensor": "HallEffectSensor NullFieldValue: {{hsnullfieldvalue}}, actual: {{hsactual}}, diff:{{hsdiff}}, LastWaitFor_State:{{hslastwaitforstate}} (waited:{{hswaited}} ms)",
729+
"lblPlayToday": "Listened today",
730+
"lblPlayYesterday": "Listened yesterday",
731+
"lblPlay7d": "Last 7 days",
732+
"lblPlay30d": "Last 30 days"
729733
}
730734
}

html/locales/fr.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -714,6 +714,10 @@
714714
"audiotimesincestart": "Temps de lecture depuis le dernier démarrage : {{audiotimesincestart}}",
715715
"currvoltage": "Tension de la batterie actuelle : {{currvoltage}} V",
716716
"chargelevel": "Niveau de charge actuel : {{chargelevel}} %",
717-
"hallsensor": "Valeur nulle du capteur d'effet Hall : {{hsnullfieldvalue}}, actuel : {{hsactual}}, diff : {{hsdiff}}, Dernier état (LastWaitFor) : {{hslastwaitforstate}} (attendu : {{hswaited}} ms)"
717+
"hallsensor": "Valeur nulle du capteur d'effet Hall : {{hsnullfieldvalue}}, actuel : {{hsactual}}, diff : {{hsdiff}}, Dernier état (LastWaitFor) : {{hslastwaitforstate}} (attendu : {{hswaited}} ms)",
718+
"lblPlayToday": "Écouté aujourd'hui",
719+
"lblPlayYesterday": "Écouté hier",
720+
"lblPlay7d": "7 derniers jours",
721+
"lblPlay30d": "30 derniers jours"
718722
}
719723
}

html/management.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6227,6 +6227,11 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
62276227
add('systeminfo.lblSince', secondsToDate(info.audio.firstStart));
62286228
add('systeminfo.lblPlayTotal', timestampToDuration(info.audio.playtimeTotal, false));
62296229
if (info.audio.playtimeSinceStart > 0) add('systeminfo.lblPlaySince', timestampToDuration(info.audio.playtimeSinceStart, true));
6230+
// daily listening-time statistics (values are in seconds -> ms for the formatter)
6231+
if (info.audio.playToday !== undefined) add('systeminfo.lblPlayToday', timestampToDuration(info.audio.playToday * 1000, false));
6232+
if (info.audio.playYesterday !== undefined) add('systeminfo.lblPlayYesterday', timestampToDuration(info.audio.playYesterday * 1000, false));
6233+
if (info.audio.play7d !== undefined) add('systeminfo.lblPlay7d', timestampToDuration(info.audio.play7d * 1000, false));
6234+
if (info.audio.play30d !== undefined) add('systeminfo.lblPlay30d', timestampToDuration(info.audio.play30d * 1000, false));
62306235
if (info.sdcard && info.sdcard.size) add('systeminfo.lblSd', (info.sdcard.free / 1024).toFixed(1) + ' / ' + (info.sdcard.size / 1024).toFixed(1) + ' GB');
62316236
if (info.battery) {
62326237
add('systeminfo.lblVoltage', info.battery.currVoltage.toFixed(2) + ' V');

src/AudioPlayer.cpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
#include "Log.h"
1313
#include "MemX.h"
1414
#include "Mqtt.h"
15+
#include "Playstats.h"
1516
#include "Port.h"
1617
#include "Queues.h"
1718
#include "Rfid.h"
@@ -280,6 +281,7 @@ void AudioPlayer_Init(void) {
280281

281282
// load playtime total from NVS
282283
playTimeSecTotal = gPrefsSettings.getULong("playTimeTotal", 0);
284+
Playstats_Init(); // daily listening-time statistics (today / yesterday / 7d / 30d)
283285

284286
uint8_t playListSortModeValue = gPrefsSettings.getUChar("PLSortMode", EnumUtils::underlying_value(AudioPlayer_PlaylistSortMode));
285287
AudioPlayer_PlaylistSortMode = EnumUtils::to_enum<playlistSortMode>(playListSortModeValue);
@@ -402,6 +404,7 @@ void AudioPlayer_Exit(void) {
402404
// save playtime total to NVS
403405
playTimeSecTotal += playTimeSecSinceStart;
404406
gPrefsSettings.putULong("playTimeTotal", playTimeSecTotal);
407+
Playstats_Save(); // flush daily listening-time statistics on shutdown
405408
// Make sure last playposition for audiobook is saved when playback is active while shutdown was initiated
406409
if (gPrefsSettings.getBool("savePosShutdown", false) && !gPlayProperties.pausePlay && (gPlayProperties.playMode == AUDIOBOOK || gPlayProperties.playMode == AUDIOBOOK_LOOP || gPlayProperties.playMode == AUDIOBOOK_RECURSIVE)) {
407410
AudioPlayer_SetTrackControl(PAUSEPLAY);
@@ -424,6 +427,7 @@ void AudioPlayer_Cyclic(void) {
424427
// audio is playing, update the playtime since start
425428
lastPlayingTimestamp = millis();
426429
playTimeSecSinceStart += 1;
430+
Playstats_AddSecond(); // accumulate per-day listening-time statistics
427431
}
428432

429433
// Actual loop stuff

src/Playstats.cpp

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
#include <Arduino.h>
2+
#include "settings.h"
3+
4+
#include "Playstats.h"
5+
6+
#include "Log.h"
7+
#include "System.h"
8+
9+
#include <string.h>
10+
#include <time.h>
11+
12+
// 365-day ring buffer of playback seconds per local calendar day. Indexed by (dayNumber % DAYS),
13+
// where dayNumber is the count of local days since the epoch. lastDay tracks the most recent day
14+
// written so the gap since then can be zeroed when the day rolls over (or the device was off).
15+
static constexpr uint16_t PLAYSTATS_DAYS = 365;
16+
static constexpr uint32_t PLAYSTATS_MAGIC = 0x504C5331; // "PLS1"
17+
static constexpr uint32_t PLAYSTATS_MIN_VALID_DAY = 18262; // ~2020-01-01, guards against pre-NTP clock
18+
19+
static uint32_t gDays[PLAYSTATS_DAYS] = {0};
20+
static uint32_t gLastDay = 0; // local day number of the most recent tracked day (0 = none yet)
21+
static bool gDirty = false;
22+
static uint32_t gLastSaveMs = 0;
23+
24+
// Days since 1970-01-01 for a (proleptic Gregorian) calendar date (Howard Hinnant's algorithm).
25+
// Avoids needing tm_gmtoff (not available in this newlib build): we feed it the local Y/M/D.
26+
static long Playstats_DaysFromCivil(int y, unsigned m, unsigned d) {
27+
y -= m <= 2;
28+
long era = (y >= 0 ? y : y - 399) / 400;
29+
unsigned yoe = (unsigned) (y - era * 400);
30+
unsigned doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1;
31+
unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
32+
return era * 146097 + (long) doe - 719468;
33+
}
34+
35+
// Local calendar day number (days since epoch in local time). 0 if the clock is not valid yet.
36+
static uint32_t Playstats_CurrentDay(void) {
37+
time_t now = time(nullptr);
38+
struct tm timeinfo;
39+
if (!localtime_r(&now, &timeinfo)) {
40+
return 0;
41+
}
42+
long day = Playstats_DaysFromCivil(timeinfo.tm_year + 1900, timeinfo.tm_mon + 1, timeinfo.tm_mday);
43+
if (day < (long) PLAYSTATS_MIN_VALID_DAY) {
44+
return 0;
45+
}
46+
return (uint32_t) day;
47+
}
48+
49+
// Roll the ring buffer forward to <today>, zeroing days that elapsed since gLastDay (so stale
50+
// slots from ~a year ago don't leak into the windows). Resets everything if the gap exceeds a year.
51+
static void Playstats_AdvanceTo(uint32_t today) {
52+
if (today == 0 || today == gLastDay) {
53+
return;
54+
}
55+
if (gLastDay == 0 || (today - gLastDay) >= PLAYSTATS_DAYS) {
56+
for (uint16_t i = 0; i < PLAYSTATS_DAYS; i++) {
57+
gDays[i] = 0;
58+
}
59+
} else {
60+
for (uint32_t d = gLastDay + 1; d <= today; d++) {
61+
gDays[d % PLAYSTATS_DAYS] = 0;
62+
}
63+
}
64+
gLastDay = today;
65+
gDirty = true;
66+
}
67+
68+
void Playstats_Init(void) {
69+
size_t got = gPrefsSettings.getBytesLength("playStats");
70+
if (got == sizeof(uint32_t) /*magic*/ + sizeof(uint32_t) /*lastDay*/ + sizeof(gDays)) {
71+
uint8_t buf[sizeof(uint32_t) * 2 + sizeof(gDays)];
72+
gPrefsSettings.getBytes("playStats", buf, sizeof(buf));
73+
uint32_t magic;
74+
memcpy(&magic, buf, sizeof(magic));
75+
if (magic == PLAYSTATS_MAGIC) {
76+
memcpy(&gLastDay, buf + sizeof(uint32_t), sizeof(gLastDay));
77+
memcpy(gDays, buf + sizeof(uint32_t) * 2, sizeof(gDays));
78+
}
79+
}
80+
gLastSaveMs = millis();
81+
}
82+
83+
void Playstats_AddSecond(void) {
84+
uint32_t today = Playstats_CurrentDay();
85+
if (today == 0) {
86+
return; // clock not valid yet
87+
}
88+
Playstats_AdvanceTo(today);
89+
gDays[today % PLAYSTATS_DAYS]++;
90+
gDirty = true;
91+
92+
// Persist at most once per minute to spare the NVS flash; a final flush happens on shutdown.
93+
if (millis() - gLastSaveMs >= 60000u) {
94+
Playstats_Save();
95+
}
96+
}
97+
98+
void Playstats_Save(void) {
99+
if (!gDirty) {
100+
return;
101+
}
102+
uint8_t buf[sizeof(uint32_t) * 2 + sizeof(gDays)];
103+
uint32_t magic = PLAYSTATS_MAGIC;
104+
memcpy(buf, &magic, sizeof(magic));
105+
memcpy(buf + sizeof(uint32_t), &gLastDay, sizeof(gLastDay));
106+
memcpy(buf + sizeof(uint32_t) * 2, gDays, sizeof(gDays));
107+
gPrefsSettings.putBytes("playStats", buf, sizeof(buf));
108+
gDirty = false;
109+
gLastSaveMs = millis();
110+
}
111+
112+
uint32_t Playstats_GetToday(void) {
113+
uint32_t today = Playstats_CurrentDay();
114+
if (today == 0) {
115+
return 0;
116+
}
117+
Playstats_AdvanceTo(today);
118+
return gDays[today % PLAYSTATS_DAYS];
119+
}
120+
121+
uint32_t Playstats_GetYesterday(void) {
122+
uint32_t today = Playstats_CurrentDay();
123+
if (today == 0 || gLastDay == 0) {
124+
return 0;
125+
}
126+
Playstats_AdvanceTo(today);
127+
return (today >= 1) ? gDays[(today - 1) % PLAYSTATS_DAYS] : 0;
128+
}
129+
130+
uint32_t Playstats_GetLastDays(uint16_t days) {
131+
uint32_t today = Playstats_CurrentDay();
132+
if (today == 0) {
133+
return 0;
134+
}
135+
Playstats_AdvanceTo(today);
136+
if (days > PLAYSTATS_DAYS) {
137+
days = PLAYSTATS_DAYS;
138+
}
139+
uint32_t sum = 0;
140+
for (uint16_t i = 0; i < days && i <= today; i++) {
141+
sum += gDays[(today - i) % PLAYSTATS_DAYS];
142+
}
143+
return sum;
144+
}

src/Playstats.h

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
#pragma once
2+
3+
#include <stdint.h>
4+
5+
// Daily listening-time statistics: accumulates playback seconds per local calendar day in a
6+
// 365-day ring buffer (persisted in NVS), so the web interface can show how long was listened
7+
// today / yesterday / last 7 / last 30 days. Time comes from the system clock (NTP/RTC); while
8+
// the clock is not yet valid, ticks are ignored.
9+
10+
void Playstats_Init(void);
11+
void Playstats_AddSecond(void); // called once per played second from the audio loop
12+
void Playstats_Save(void); // flush the RAM buffer to NVS if dirty (periodic + on shutdown)
13+
14+
uint32_t Playstats_GetToday(void); // seconds listened today (local calendar day)
15+
uint32_t Playstats_GetYesterday(void); // seconds listened yesterday
16+
uint32_t Playstats_GetLastDays(uint16_t days); // sum over the last <days> calendar days (incl. today)

src/Web.cpp

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
#include "Log.h"
2020
#include "MemX.h"
2121
#include "Mqtt.h"
22+
#include "Playstats.h"
2223
#include "Rfid.h"
2324
#include "RotaryEncoder.h"
2425
#include "Rtc.h"
@@ -1905,6 +1906,11 @@ void handleGetInfo(AsyncWebServerRequest *request) {
19051906
audioObj["playtimeTotal"] = AudioPlayer_GetPlayTimeAllTime();
19061907
audioObj["playtimeSinceStart"] = AudioPlayer_GetPlayTimeSinceStart();
19071908
audioObj["firstStart"] = gPrefsSettings.getULong("firstStart", 0);
1909+
// daily listening-time statistics (seconds)
1910+
audioObj["playToday"] = Playstats_GetToday();
1911+
audioObj["playYesterday"] = Playstats_GetYesterday();
1912+
audioObj["play7d"] = Playstats_GetLastDays(7);
1913+
audioObj["play30d"] = Playstats_GetLastDays(30);
19081914
}
19091915
// sd card
19101916
if ((section == "") || (section == "sdcard")) {

0 commit comments

Comments
 (0)