Skip to content

Commit 646ec5c

Browse files
fhirschmannclaude
andcommitted
feat: complete backup (EQ rules, stats, sync) + backup password hygiene
1 + 3 from the roadmap: - Backup/restore now also covers per-path EQ rules, the listening-statistics ring buffer (new GET/POST /playstats with Playstats ring accessors) and the Sync settings section (the restore section list previously omitted 'sync', so server/peer/branding sync config was silently dropped on restore). - Password hygiene: sync/peer/MQTT/FTP passwords are only written into the export when the credentials checkbox is ticked (relabelled 'include passwords'); otherwise they are blanked so a shared backup doesn't leak secrets in cleartext. Verified on the test device: set an EQ rule -> assemble a backup -> wipe -> UI import from file restores it; /playstats round-trips (365-day ring, lastDay); no console errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ef47b08 commit 646ec5c

8 files changed

Lines changed: 99 additions & 6 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ neon logo that doubles as the SVG favicon ([`7be5254`](../../commit/7be5254)):
104104
| PWA support: web app manifest + app icon, "add to home screen" with proper icon and name | [`b4287b9`](../../commit/b4287b9) |
105105
| PWA offline fallback: a service worker serves a cyberpunk "ESPuino Offline" page (with auto-reconnect) instead of a black screen when the home-screen app is launched while the player is powered off | [`bd07a7c`](../../commit/bd07a7c) |
106106
| Full backup: export/import of all settings + RFID assignments as JSON, WiFi credentials optional | [`4c90ff4`](../../commit/4c90ff4) |
107+
| **Backup completeness + password hygiene**: the JSON backup/restore now also covers the **per-path EQ rules**, the **listening-statistics** ring buffer (new `GET`/`POST /playstats`) and the **Sync** settings section (which the restore previously skipped) — so a restore no longer silently drops them. Passwords (sync/peer/MQTT/FTP) are only included when the (renamed) **"include passwords"** checkbox is ticked, so a shared backup doesn't leak credentials in cleartext | [`backup-complete`](../../releases/tag/backup-complete) |
107108
| One-click OTA update: GitHub Actions publishes a rolling `latest` release (`firmware.bin`); a Tools-tab button makes the device pull that firmware from GitHub over HTTPS and flash it via OTA, then reboot. Also triggerable via the bindable command **186** (button/RFID modifier) and the MQTT command-topic `firmware_update` (`ON`/`update`); the state-topic reports `idle`/`updating`/`up_to_date`/`failed` | [`8527f5e`](../../commit/8527f5e) |
108109
| Equalizer profiles: dropdown presets (Flat / Music / Audiobook-Speech / Deep voices / Custom) on top of the 3-band tone control; speech presets cut bass and lift mids/highs so deep narrator voices stay intelligible, persisted in NVS. Profiles can also be assigned per file or directory (right-click in the file browser) — e.g. set the speech profile for all Bibi Blocksberg episodes at once; the RFID tab shows the active profile for the highlighted file (or "No EQ set"). The active profile can be cycled with the bindable command **154** (button/RFID modifier) and set/reported via the MQTT topic `equalizer` (`flat`/`music`/`speech`/`voiceBoost`) | [`11ade33`](../../commit/11ade33) |
109110
| Blinking "OK" indicator next to the battery replaces the generic "action successful" toast | [`f41bb72`](../../commit/f41bb72) |

html/locales/de.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -666,7 +666,7 @@
666666
"desc": "Exportiert bzw. importiert alle Einstellungen und RFID-Zuweisungen als JSON-Datei.",
667667
"export": {
668668
"title": "Export",
669-
"includeWifi": "WLAN-Zugangsdaten einschließen",
669+
"includeWifi": "Passwörter einschließen (WLAN, Sync, Peers)",
670670
"button": "Backup herunterladen"
671671
},
672672
"import": {

html/locales/en.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -666,7 +666,7 @@
666666
"desc": "Exports or imports all settings and RFID assignments as a JSON file.",
667667
"export": {
668668
"title": "Export",
669-
"includeWifi": "Include WiFi credentials",
669+
"includeWifi": "Include passwords (WiFi, sync, peers)",
670670
"button": "Download backup"
671671
},
672672
"import": {

html/locales/fr.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -655,7 +655,7 @@
655655
"desc": "Exporte ou importe tous les réglages et attributions RFID dans un fichier JSON.",
656656
"export": {
657657
"title": "Export",
658-
"includeWifi": "Inclure les identifiants WiFi",
658+
"includeWifi": "Inclure les mots de passe (WiFi, sync, pairs)",
659659
"button": "Télécharger la sauvegarde"
660660
},
661661
"import": {

html/management.html

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6127,16 +6127,29 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
61276127
return dDisplay + hDisplay + mDisplay;
61286128
}
61296129
async function exportBackup() {
6130+
const includeCreds = document.getElementById('backupIncludeWifi').checked;
61306131
const backup = {
61316132
espuinoBackup: 1,
61326133
created: new Date().toISOString(),
61336134
settings: await (await fetch("http://" + host + "/settings")).json(),
6134-
rfid: await (await fetch("http://" + host + "/rfid")).json()
6135+
rfid: await (await fetch("http://" + host + "/rfid")).json(),
6136+
eqRules: await (await fetch("http://" + host + "/eqrules")).json(),
6137+
playStats: await (await fetch("http://" + host + "/playstats")).json()
61356138
};
61366139
delete backup.settings.ssids; // names only - not usable for a restore
6137-
if (document.getElementById('backupIncludeWifi').checked) {
6140+
if (includeCreds) {
61386141
backup.wifiConfig = await (await fetch("http://" + host + "/wificonfig")).json();
61396142
backup.wifiNetworks = await (await fetch("http://" + host + "/savedSSIDs?credentials=1")).json();
6143+
} else {
6144+
// strip secrets so a shared backup doesn't leak passwords in cleartext
6145+
const s = backup.settings || {};
6146+
if (s.sync) {
6147+
s.sync.password = "";
6148+
s.sync.rfidPeerKey = "";
6149+
try { const peers = JSON.parse(s.sync.rfidPeers || "[]"); peers.forEach(p => { p.key = ""; }); s.sync.rfidPeers = JSON.stringify(peers); } catch (e) { }
6150+
}
6151+
if (s.mqtt && 'password' in s.mqtt) s.mqtt.password = "";
6152+
if (s.ftp && 'password' in s.ftp) s.ftp.password = "";
61406153
}
61416154
const blob = new Blob([JSON.stringify(backup, null, "\t")], {
61426155
type: "application/json"
@@ -6167,7 +6180,7 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
61676180
}
61686181
let errors = 0;
61696182
// the firmware applies one settings section per request
6170-
const sections = ['general', 'equalizer', 'led', 'battery', 'playlist', 'buttons', 'rotary', 'ftp', 'mqtt', 'bluetooth'];
6183+
const sections = ['general', 'equalizer', 'led', 'battery', 'playlist', 'buttons', 'rotary', 'ftp', 'mqtt', 'bluetooth', 'sync'];
61716184
for (const section of sections) {
61726185
if (backup.settings && backup.settings[section]) {
61736186
const response = await fetch("http://" + host + "/settings", {
@@ -6201,6 +6214,30 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
62016214
}
62026215
}
62036216
}
6217+
// restore per-path EQ rules
6218+
if (Array.isArray(backup.eqRules)) {
6219+
for (const r of backup.eqRules) {
6220+
if (!r || !r.p) {
6221+
continue;
6222+
}
6223+
const qs = "path=" + encodeURIComponent(r.p) + "&low=" + (r.l | 0) + "&band=" + (r.b | 0) + "&high=" + (r.h | 0) + "&profile=" + encodeURIComponent(r.pr || "");
6224+
const response = await fetch("http://" + host + "/eqrule?" + qs, { method: "POST" });
6225+
if (!response.ok) {
6226+
errors++;
6227+
}
6228+
}
6229+
}
6230+
// restore listening statistics (ring buffer)
6231+
if (backup.playStats && Array.isArray(backup.playStats.days)) {
6232+
const response = await fetch("http://" + host + "/playstats", {
6233+
method: "POST",
6234+
headers: { "Content-Type": "application/json" },
6235+
body: JSON.stringify(backup.playStats)
6236+
});
6237+
if (!response.ok) {
6238+
errors++;
6239+
}
6240+
}
62046241
if (Array.isArray(backup.wifiNetworks)) {
62056242
for (const network of backup.wifiNetworks) {
62066243
if (!network.ssid) {

src/Playstats.cpp

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,24 @@ uint32_t Playstats_GetLastDays(uint16_t days) {
142142
}
143143
return sum;
144144
}
145+
146+
uint16_t Playstats_GetRingSize(void) {
147+
return PLAYSTATS_DAYS;
148+
}
149+
uint32_t Playstats_GetRingLastDay(void) {
150+
return gLastDay;
151+
}
152+
uint32_t Playstats_GetRingSlot(uint16_t i) {
153+
return (i < PLAYSTATS_DAYS) ? gDays[i] : 0;
154+
}
155+
void Playstats_RestoreRing(uint32_t lastDay, const uint32_t *slots, uint16_t count) {
156+
if (count > PLAYSTATS_DAYS) {
157+
count = PLAYSTATS_DAYS;
158+
}
159+
for (uint16_t i = 0; i < PLAYSTATS_DAYS; i++) {
160+
gDays[i] = (i < count && slots) ? slots[i] : 0;
161+
}
162+
gLastDay = lastDay;
163+
gDirty = true;
164+
Playstats_Save();
165+
}

src/Playstats.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,9 @@ void Playstats_Save(void); // flush the RAM buffer to NVS if dirty (periodic + o
1414
uint32_t Playstats_GetToday(void); // seconds listened today (local calendar day)
1515
uint32_t Playstats_GetYesterday(void); // seconds listened yesterday
1616
uint32_t Playstats_GetLastDays(uint16_t days); // sum over the last <days> calendar days (incl. today)
17+
18+
// Raw ring-buffer access for backup/restore (JSON is built/parsed by the caller).
19+
uint16_t Playstats_GetRingSize(void); // number of day-slots (365)
20+
uint32_t Playstats_GetRingLastDay(void); // local day number of the most recent tracked day
21+
uint32_t Playstats_GetRingSlot(uint16_t i); // seconds in raw slot i (0..size-1)
22+
void Playstats_RestoreRing(uint32_t lastDay, const uint32_t *slots, uint16_t count); // restore from backup

src/Web.cpp

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1027,6 +1027,34 @@ void webserverStart(void) {
10271027
request->send(response);
10281028
});
10291029

1030+
// Listening-stats ring buffer (for backup/restore). GET returns it, POST restores it.
1031+
wServer.on("/playstats", HTTP_GET, [](AsyncWebServerRequest *request) {
1032+
AsyncJsonResponse *response = new AsyncJsonResponse(false);
1033+
JsonObject o = response->getRoot();
1034+
o["lastDay"] = Playstats_GetRingLastDay();
1035+
JsonArray a = o["days"].to<JsonArray>();
1036+
const uint16_t n = Playstats_GetRingSize();
1037+
for (uint16_t i = 0; i < n; i++) {
1038+
a.add(Playstats_GetRingSlot(i));
1039+
}
1040+
response->setLength();
1041+
request->send(response);
1042+
});
1043+
wServer.addHandler(new AsyncCallbackJsonWebHandler("/playstats", [](AsyncWebServerRequest *request, JsonVariant &json) {
1044+
JsonObject o = json.as<JsonObject>();
1045+
static uint32_t buf[365];
1046+
uint16_t c = 0;
1047+
if (o["days"].is<JsonArray>()) {
1048+
for (JsonVariant v : o["days"].as<JsonArray>()) {
1049+
if (c < 365) {
1050+
buf[c++] = v.as<uint32_t>();
1051+
}
1052+
}
1053+
}
1054+
Playstats_RestoreRing(o["lastDay"].as<uint32_t>(), buf, c);
1055+
request->send(200, "text/plain; charset=utf-8", "ok");
1056+
}));
1057+
10301058
// WiFi scan
10311059
wServer.on("/wifiscan", HTTP_GET, handleWiFiScanRequest);
10321060

0 commit comments

Comments
 (0)