Skip to content

Commit 0863f75

Browse files
fhirschmannclaude
andcommitted
feat: optional 'no standby while on external power' setting
Adds a General-tab option to suppress the inactivity deep-sleep while the device is on external power. The complete board has no charge/VBUS sense, so 'on power' is a heuristic: battery voltage >= a configurable threshold (default 3.5 V, since the LiFePO4 charge voltage sits above its resting voltage). The manual sleep timer is unaffected. - System: System_IsExternallyPowered() + System_ReloadSleepSettings(); the idle branch of System_SleepHandler() refreshes the activity timer instead of sleeping when enabled and powered. Config in NVS (noSleepPwr / pwrSleepVolt), applied live on settings save. - Web: checkbox + voltage-threshold field in the General settings sub-tab; settings JSON read/write. i18n de/en/fr. Verified on the test device: settings round-trip persists and is read back, other settings preserved, UI fields populate and localize, no console errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1cff7bf commit 0863f75

8 files changed

Lines changed: 82 additions & 5 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ neon logo that doubles as the SVG favicon ([`7be5254`](../../commit/7be5254)):
136136
| **Fully offline web interface**: all third-party libraries (jQuery, jQuery-UI, Bootstrap JS/CSS, jstree + themes, bootstrap-slider, Font Awesome + webfonts, i18next) and the Orbitron/Rajdhani/Share Tech Mono fonts are now vendored and served gzipped from the device instead of public CDNs. This fixes the ~2-minute first-load hang with empty (untranslated) text after a cold boot/flash, and makes the management UI work without any internet access (incl. AP mode). `processHtml.py` embeds the assets into flash (build size grows accordingly) and now derives unique header symbol names from the full asset path. The libs are concatenated into a single JS and a single CSS bundle (`tools/bundle_assets.py`) so the page makes only a handful of requests — many parallel requests to the ESP32's async web server made some browsers (Safari/Chrome) stall for minutes while Firefox coped | [`c618191`](../../commit/c618191) |
137137
| **M3U playlist builder**: a "Create playlist" button in the Files tab opens a two-pane builder — browse the SD card on the left, an ordered (drag-to-reorder) track list on the right — to assemble an `.m3u` from SD files, whole folders, and webradio URLs (which `LOCAL_M3U` plays interchangeably). Saving writes `/Playlists/<name>.m3u` via a new `POST /playlist` endpoint; the file can then be assigned to an RFID tag with play mode 11. i18n de/en/fr | [`af041fc`](../../commit/af041fc) |
138138
| **File browser: assignment badges + context-menu RFID learn (scan-after-select)**: in the Files-tab tree each row is badged (right-aligned, after the name) when that exact path has an **RFID tag** assigned (tag icon) and/or a **per-path EQ rule** (sliders icon), so you can see at a glance what is mapped where. The explorer **context menu** learns/removes RFID assignments directly: right-click a file/folder → **RFID anlernen** → pick a play mode, then **lay the card** — the next scanned card is assigned to that path (no tab switch / submit); or **remove** an existing assignment. Mirrors the existing EQ-profile submenu. Works with physical cards and the [virtual cards](#virtual-rfid-cards) (`CMD_VIRTUAL_RFID_CARD_*`) alike. Badges refresh when a folder is (re)opened and live after assigning/removing. i18n de/en/fr | [`7b8e303`](../../commit/7b8e303) |
139+
| **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) |
139140
| **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) |
140141

141142
#### Feature highlights

‎html/locales/de.json‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -560,7 +560,11 @@
560560
"recoverVolBootExp": "Die Box erinnert sich über einen Neustart hinweg, welche Lautstärke zuletzt verwendet wurde und stellt diese automatisch wieder ein.",
561561
"volumeCurve": "Feinere Abstufungen bei niedriger Lautstärke",
562562
"volumeCurveExp": "Verwendung von logarithmischer Lautstärkeberechnung. Kann helfen, wenn im unteren Lautstärkebereich die Abstufungen zu grob sind.",
563-
"readypath": "Pfad zur Begrüßungs-MP3 (Kaltstart)"
563+
"readypath": "Pfad zur Begrüßungs-MP3 (Kaltstart)",
564+
"noSleepWhenPowered": "Kein Standby wenn am Strom",
565+
"noSleepWhenPoweredExp": "Wenn aktiv, geht das Gerät nicht wegen Inaktivität in den Standby, solange es am Strom hängt. Mangels Lade-/USB-Erkennung wird „am Strom“ über die Akkuspannung geschätzt (Ladespannung liegt über der Ruhespannung) — siehe Schwelle. Der manuelle Sleep-Timer bleibt davon unberührt.",
566+
"poweredVoltage": "Schwelle „am Strom“ (V)",
567+
"poweredVoltageExp": "Ab dieser Akkuspannung gilt das Gerät als „am Strom“. Für LiFePO4 ist ~3,5 V ein guter Startwert (Ladespannung ~3,6 V, Ruhespannung voll ~3,3–3,4 V)."
564568
},
565569
"neopixel": {
566570
"title": "Neopixel (Helligkeit)",

‎html/locales/en.json‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -560,7 +560,11 @@
560560
"recoverVolBootExp": "After a restart, the speaker remembers which volume was last used and automatically adjusts it again.",
561561
"volumeCurve": "Finer gradations at low volume",
562562
"volumeCurveExp": "Use of logarithmic volume calculation. Can help if the gradations in the lower volume range are too coarse.",
563-
"readypath": "Path to ready MP3 (cold start)"
563+
"readypath": "Path to ready MP3 (cold start)",
564+
"noSleepWhenPowered": "No standby while on power",
565+
"noSleepWhenPoweredExp": "When enabled, the device will not go into standby due to inactivity while it is on external power. Since there is no charge/USB sense, being 'on power' is estimated from the battery voltage (charge voltage sits above the resting voltage) — see threshold. The manual sleep timer is unaffected.",
566+
"poweredVoltage": "'On power' threshold (V)",
567+
"poweredVoltageExp": "At or above this battery voltage the device is considered 'on power'. For LiFePO4, ~3.5 V is a good starting point (charge voltage ~3.6 V, full resting ~3.3-3.4 V)."
564568
},
565569
"neopixel": {
566570
"title": "Neopixel (brightness)",

‎html/locales/fr.json‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -549,7 +549,11 @@
549549
"recoverVolBootExp": "Le boîtier se souvient, après un redémarrage, du dernier volume utilisé et le rétablit automatiquement.",
550550
"volumeCurve": "Des nuances plus fines à faible volume",
551551
"volumeCurveExp": "Utilisation du calcul logarithmique du volume. Peut aider si les nuances sont trop grossières dans la plage de volume inférieure.",
552-
"readypath": "Chemin vers le MP3 de bienvenue"
552+
"readypath": "Chemin vers le MP3 de bienvenue",
553+
"noSleepWhenPowered": "Pas de veille sur secteur",
554+
"noSleepWhenPoweredExp": "Si activé, l'appareil ne se met pas en veille par inactivité tant qu'il est sur secteur. Faute de détection de charge/USB, « sur secteur » est estimé via la tension de la batterie (la tension de charge dépasse la tension au repos) — voir le seuil. La minuterie de veille manuelle n'est pas affectée.",
555+
"poweredVoltage": "Seuil « sur secteur » (V)",
556+
"poweredVoltageExp": "À partir de cette tension de batterie, l'appareil est considéré « sur secteur ». Pour le LiFePO4, ~3,5 V est un bon point de départ (tension de charge ~3,6 V, repos plein ~3,3-3,4 V)."
553557
},
554558
"neopixel": {
555559
"title": "Neopixel (luminosité)",

‎html/management.html‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2261,6 +2261,21 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
22612261
data-i18n="[data-bs-content]general.options.playMonoExp" tabindex="0"><i
22622262
class="fas fa-circle-question"></i></a>
22632263
</div>
2264+
<div class="col-12 col-md-6 d-flex gap-2 align-items-center mb-1">
2265+
<input type="checkbox" id="noSleepWhenPowered" name="noSleepWhenPowered" value="false">
2266+
<label for="noSleepWhenPowered" data-i18n="general.options.noSleepWhenPowered"></label>
2267+
<a href="#" class="link-secondary" data-bs-toggle="popover"
2268+
data-i18n="[data-bs-content]general.options.noSleepWhenPoweredExp" tabindex="0"><i
2269+
class="fas fa-circle-question"></i></a>
2270+
</div>
2271+
<div class="col-12 col-md-6 d-flex gap-2 align-items-center mb-1">
2272+
<label for="poweredVoltage" data-i18n="general.options.poweredVoltage"></label>
2273+
<input type="number" class="form-control" id="poweredVoltage" name="poweredVoltage"
2274+
min="2.5" max="4.5" step="0.05" style="max-width:7em">
2275+
<a href="#" class="link-secondary" data-bs-toggle="popover"
2276+
data-i18n="[data-bs-content]general.options.poweredVoltageExp" tabindex="0"><i
2277+
class="fas fa-circle-question"></i></a>
2278+
</div>
22642279
<div class="col-12 col-md-6 d-flex gap-2 align-items-center mb-1">
22652280
<input type="checkbox" id="volumeCurve" name="volumeCurve" value="false">
22662281
<label for="volumeCurve" data-i18n="general.options.volumeCurve"></label>
@@ -4550,6 +4565,10 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
45504565
$('#dontAcceptRfidTwice').prop('disabled', genSettings.pauseIfRfidRemoved);
45514566
$('#pauseOnMinVolume').prop('checked', genSettings.pauseOnMinVol);
45524567
$('#recoverVolBoot').prop('checked', genSettings.recoverVolBoot);
4568+
$('#noSleepWhenPowered').prop('checked', genSettings.noSleepWhenPowered);
4569+
if (genSettings.poweredVoltage !== undefined) {
4570+
$('#poweredVoltage').val(genSettings.poweredVoltage);
4571+
}
45534572
$('#volumeCurve').prop('checked', genSettings.volumeCurve > 0);
45544573
if (genSettings.rfidReaderType !== undefined) {
45554574
$('#rfidReaderType').val(genSettings.rfidReaderType);
@@ -5031,6 +5050,8 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
50315050
dontAcceptRfidTwice: $('#dontAcceptRfidTwice').prop('checked'),
50325051
pauseOnMinVol: $('#pauseOnMinVolume').prop('checked'),
50335052
recoverVolBoot: $('#recoverVolBoot').prop('checked'),
5053+
noSleepWhenPowered: $('#noSleepWhenPowered').prop('checked'),
5054+
poweredVoltage: Number($('#poweredVoltage').val()),
50345055
volumeCurve: $("#volumeCurve").prop('checked') ? 1 : 0,
50355056
rfidReaderType: Number($('#rfidReaderType').val()),
50365057
pn5180Lpcd: $('#pn5180Lpcd').prop('checked'),

‎src/System.cpp‎

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
#include "Audio.h"
77
#include "AudioPlayer.h"
8+
#include "Battery.h"
89
#include "Bluetooth.h"
910
#include "Display.h"
1011
#include "Ftp.h"
@@ -40,6 +41,12 @@ std::atomic<bool> System_LockControls = false; // Flag if buttons and rotary enc
4041
uint8_t System_MaxInactivityTime = 10u; // Time in minutes, after uC is put to deep sleep because of inactivity (and modified later via GUI)
4142
uint8_t System_SleepTimer = 30u; // Sleep timer in minutes that can be optionally used (and modified later via MQTT or RFID)
4243

44+
// "Don't fall asleep due to inactivity while on external power". This hardware has no charge/VBUS
45+
// sense, so being powered is inferred heuristically from the battery voltage (charge voltage sits
46+
// above the resting voltage). Both values come from NVS and are configurable in the web settings.
47+
static bool System_NoSleepWhenPowered = false;
48+
static float System_PoweredVoltageThreshold = 3.5f;
49+
4350
// Operation Mode
4451
std::atomic<uint8_t> System_OperationMode;
4552

@@ -68,6 +75,25 @@ void System_Init(void) {
6875
}
6976

7077
System_OperationMode = gPrefsSettings.getUChar("operationMode", OPMODE_NORMAL);
78+
79+
System_ReloadSleepSettings();
80+
}
81+
82+
// (Re)load the "no sleep while powered" config from NVS into RAM (called at boot and after the
83+
// web settings are saved, so the change takes effect without a reboot).
84+
void System_ReloadSleepSettings(void) {
85+
System_NoSleepWhenPowered = gPrefsSettings.getBool("noSleepPwr", false);
86+
System_PoweredVoltageThreshold = gPrefsSettings.getFloat("pwrSleepVolt", 3.5f);
87+
}
88+
89+
// Heuristic "external power is connected": the battery voltage is at/above the (charge) threshold.
90+
// Always false on builds without battery measurement (no voltage to look at).
91+
bool System_IsExternallyPowered(void) {
92+
#ifdef BATTERY_MEASURE_ENABLE
93+
return Battery_GetVoltage() >= System_PoweredVoltageThreshold;
94+
#else
95+
return false;
96+
#endif
7197
}
7298

7399
void System_Cyclic(void) {
@@ -220,8 +246,14 @@ void System_SleepHandler(void) {
220246
// Only check inactivity if the limit is greater than 0
221247
if (System_MaxInactivityTime > 0 && m >= lastActive) {
222248
if (m - lastActive >= (System_MaxInactivityTime * 60000u)) {
223-
Log_Println(goToSleepDueToIdle, LOGLEVEL_INFO);
224-
System_RequestSleep();
249+
if (System_NoSleepWhenPowered && System_IsExternallyPowered()) {
250+
// On external power and configured to stay awake: keep refreshing the timer
251+
// instead of sleeping due to idle (the explicit sleep timer still applies).
252+
System_UpdateActivityTimer();
253+
} else {
254+
Log_Println(goToSleepDueToIdle, LOGLEVEL_INFO);
255+
System_RequestSleep();
256+
}
225257
}
226258
}
227259

‎src/System.h‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ void System_Init(void);
99
void System_Cyclic(void);
1010
void System_UpdateActivityTimer(void);
1111
void System_RequestSleep(void);
12+
void System_ReloadSleepSettings(void); // re-read "no sleep while powered" config from NVS
13+
bool System_IsExternallyPowered(void); // heuristic: battery voltage at/above the powered threshold
1214
void System_Restart(void);
1315
bool System_SetSleepTimer(uint8_t minutes);
1416
void System_DisableSleepTimer();

‎src/Web.cpp‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1119,6 +1119,13 @@ WebsocketCodeType JSONToSettings(JsonObject doc) {
11191119
success = success && (gPrefsSettings.putBool("pauseOnMinVol", generalObj["pauseOnMinVol"].as<bool>()) != 0);
11201120
success = success && (gPrefsSettings.putBool("recoverVolBoot", generalObj["recoverVolBoot"].as<bool>()) != 0);
11211121
success = success && (gPrefsSettings.putUChar("volumeCurve", generalObj["volumeCurve"].as<uint8_t>()) != 0);
1122+
if (generalObj["noSleepWhenPowered"].is<bool>()) {
1123+
gPrefsSettings.putBool("noSleepPwr", generalObj["noSleepWhenPowered"].as<bool>());
1124+
}
1125+
if (generalObj["poweredVoltage"].is<float>()) {
1126+
gPrefsSettings.putFloat("pwrSleepVolt", generalObj["poweredVoltage"].as<float>());
1127+
}
1128+
System_ReloadSleepSettings(); // apply the "no sleep while powered" change without a reboot
11221129
if (generalObj["readyPath"].is<const char *>()) {
11231130
const char *path = generalObj["readyPath"].as<const char *>();
11241131
size_t written = gPrefsSettings.putString("readyPath", path);
@@ -1524,6 +1531,8 @@ static void settingsToJSON(JsonObject obj, const String section) {
15241531
generalObj["recoverVolBoot"].set(gPrefsSettings.getBool("recoverVolBoot", false)); // USE_LAST_VOLUME_AFTER_REBOOT
15251532
generalObj["volumeCurve"].set(gPrefsSettings.getUChar("volumeCurve", 0)); // VOLUMECURVE
15261533
generalObj["readyPath"].set(gPrefsSettings.getString("readyPath", "/ready.mp3")); // READY_PATH
1534+
generalObj["noSleepWhenPowered"].set(gPrefsSettings.getBool("noSleepPwr", false)); // stay awake on external power
1535+
generalObj["poweredVoltage"].set(gPrefsSettings.getFloat("pwrSleepVolt", 3.5f)); // voltage threshold for "powered"
15271536
}
15281537
if ((section == "") || (section == "equalizer")) {
15291538
// equalizer settings

0 commit comments

Comments
 (0)