Skip to content

Commit 9763436

Browse files
fhirschmannclaude
andcommitted
feat: auto-backup the full config to the sync server
Upload the same full backup the web "export" button produces (settings + RFID assignments + per-path EQ rules + listening statistics, never any passwords) to a server straight from the device — no browser needed. - new Backup module: streams the backup to SD entry-by-entry first (so a large RFID list fits in the limited heap), then POSTs the file to the configured URL, reusing the file-sync credentials and sending the device hostname in X-ESPuino-Host - daily auto-backup (once the clock is set + WiFi is up), plus manual run - Backup settings section in the Sync tab (URL + once-a-day toggle); a "Back up now" button with live status; DE/EN/FR locales - bindable command 189 and MQTT topic "backup" (HA button + status sensor) - companion server/backup.php stores backups per device and prunes old ones - /backupupload GET/POST endpoints + OpenAPI spec; GitHub-Pages demo mock - README entry Verified on device: a real upload produced valid JSON (13 settings sections, 14 RFID tags, EQ rules, 365-day stats) with all passwords stripped, accepted with HTTP 200. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1d77353 commit 9763436

18 files changed

Lines changed: 654 additions & 7 deletions

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ neon logo that doubles as the SVG favicon ([`7be5254`](../../commit/7be5254)):
150150
| **Now-playing metadata**: parses ID3 title/artist/album + embedded cover (with a folder `cover.jpg`/`folder.jpg` fallback); an info dialog shows the full detail (codec / sample-rate / bitrate / channels, path, play mode, RFID tag) via `GET /currenttrack`; artist/album are also published to MQTT + Home Assistant | [`ce3116c`](../../commit/ce3116c) · [`fda14a3`](../../commit/fda14a3) · [`4123db6`](../../commit/4123db6) · [`5cb0b8b`](../../commit/5cb0b8b) |
151151
| **Listening statistics**: per-day listening time (today / yesterday / 7 d / 30 d) in a 365-day NVS ring buffer, plus a most-played-cards top list — shown in the info dialog (`GET /info`, `GET /topcards`). The info dialog also draws a **30-day bar chart** (inline SVG, themed, no chart lib) and offers a **CSV export** of the full daily series (`GET /stats.csv`, `date,seconds`) | [`af6c8d3`](../../commit/af6c8d3) · [`c2e7d44`](../../commit/c2e7d44) |
152152
| **Full backup**: export/import all settings + RFID assignments + per-path EQ rules + listening stats as one JSON file; passwords are only included when explicitly ticked, so a shared backup doesn't leak credentials | [`4c90ff4`](../../commit/4c90ff4) · [`646ec5c`](../../commit/646ec5c) |
153+
| **Auto-backup to the sync server**: upload that same full backup (settings + RFID assignments + EQ rules + listening stats, **never** passwords) to a server straight from the device — no browser needed. A **Backup** section in the Sync settings tab takes the server URL (reusing the file-sync credentials) and a **once-a-day automatic** toggle; the file is streamed to SD entry-by-entry first (so a large RFID list fits in the limited heap) and then POSTed to the server, where the companion [`server/backup.php`](server/backup.php) stores it per-device and prunes old ones. The stored JSON drops straight back into the web interface's *import backup*. Run it from the Sync tab, command **189** or MQTT `backup` (Home Assistant button + status sensor included) | [`b134e7d`](../../commit/b134e7d) |
153154
| **Home Assistant MQTT discovery**: auto-registers all entities under one HA device — track/status/firmware/WiFi/battery sensors, volume & LED-brightness numbers, lock & ambient-light switches, equalizer select, transport/update/shutdown buttons | [`db73db1`](../../commit/db73db1) |
154155
| **Apple HomeKit + Siri** (`HOMEKIT_ENABLE`, via [HomeSpan](https://github.com/HomeSpan/HomeSpan)): pair the player straight into the Home app as a bridge with named tiles — **Playback** (play/pause + battery), **Volume** (brightness dimmer) and **Button-lock** — all controllable by Siri and usable in automations. State is mirrored back, so changes from buttons/RFID show up on the iPhone. Each device generates its **own random pairing code** on first boot (persisted in NVS) instead of a shared hard-coded one, so multiple ESPuinos — and everyone running this firmware — stay distinct and private. A dedicated **HomeKit settings section** shows the scannable pairing **QR code** + that setup code, a "reset pairing" button and an **on/off switch** to disable HomeKit entirely (HomeSpan then never starts, so it costs nothing at boot). HomeSpan now also comes up **lazily after boot** instead of inside `setup()`, so its multi-second start-up no longer stalls the boot LED. Pairs over the existing WiFi (no MFi chip needed); the HAP server runs on its own port and the poll task is pinned to core 0 so it never disturbs the audio pipeline on core 1 | [`6387563`](../../commit/6387563) |
155156
| **Control tab**: Repeat / Sleep-Timer (live countdown) / Night-Mode / button-lock / FTP start-stop and a Bluetooth-mode picker (Normal / Speaker / Headphones) right in the control tab; mobile-optimised (full-width louder/quieter buttons instead of the fiddly slider) | [`88a742c`](../../commit/88a742c) · [`520815a`](../../commit/520815a) · [`d57f24a`](../../commit/d57f24a) · [`aef1765`](../../commit/aef1765) |

‎REST-API.yaml‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -684,6 +684,37 @@ paths:
684684
responses:
685685
"200":
686686
description: Stop requested.
687+
/backupupload:
688+
get:
689+
tags: [Fork]
690+
summary: Get configuration-backup upload status.
691+
description: Poll the progress/result of the backup upload to the server.
692+
responses:
693+
"200":
694+
description: Current backup state.
695+
content:
696+
application/json:
697+
schema:
698+
type: object
699+
properties:
700+
status:
701+
type: integer
702+
description: "0=idle, 1=running, 2=done, 3=failed"
703+
message:
704+
type: string
705+
description: Result or error message.
706+
post:
707+
tags: [Fork]
708+
summary: Upload a configuration backup to the server.
709+
description: >-
710+
Assembles the full configuration backup (settings + RFID assignments +
711+
per-path EQ rules + listening statistics, never any passwords) on the SD
712+
card and uploads it to the configured backup URL via HTTP POST, reusing
713+
the file-sync credentials. No-op if an upload is already running. Also
714+
bindable as command 189 or MQTT topic "backup".
715+
responses:
716+
"200":
717+
description: Upload started (or already running).
687718
/playlist:
688719
post:
689720
tags: [Fork]

‎html/locales/de.json‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,18 @@
549549
"peerHostPlaceholder": "IP oder Hostname",
550550
"peerHostRequired": "Bitte IP/Hostname angeben",
551551
"noPeers": "Noch keine Peers."
552+
},
553+
"backup": {
554+
"title": "Auto-Backup auf Server",
555+
"desc": "Lädt ein vollständiges Konfigurations-Backup (Einstellungen, RFID-Zuordnungen, EQ-Regeln und Hörstatistik) auf einen Server – dasselbe Backup wie beim Export-Knopf, also per Import wiederherstellbar. Passwörter sind nie enthalten. Verwendet Benutzername/Passwort aus dem Datei-Sync-Abschnitt oben.",
556+
"url": "Backup-URL (backup.php)",
557+
"urlPlaceholder": "http://server/espuino/backup.php",
558+
"auto": "Einmal täglich automatisch sichern",
559+
"autoHint": "Einmal pro Tag (sobald die Uhr gestellt und WLAN verbunden ist) wird das Backup im Hintergrund auf den Server geladen.",
560+
"run": "Jetzt sichern",
561+
"started": "Backup-Upload gestartet…",
562+
"done": "Backup hochgeladen",
563+
"failed": "Backup-Upload fehlgeschlagen"
552564
}
553565
},
554566
"playlist": {

‎html/locales/en.json‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,18 @@
549549
"peerHostPlaceholder": "IP or hostname",
550550
"peerHostRequired": "Please enter an IP/hostname",
551551
"noPeers": "No peers yet."
552+
},
553+
"backup": {
554+
"title": "Auto-backup to server",
555+
"desc": "Uploads a full configuration backup (settings, RFID assignments, EQ rules and listening statistics) to a server – the same backup the export button produces, so it can be restored via import. Passwords are never included. Uses the username/password from the file-sync section above.",
556+
"url": "Backup URL (backup.php)",
557+
"urlPlaceholder": "http://server/espuino/backup.php",
558+
"auto": "Back up automatically once a day",
559+
"autoHint": "Once a day (after the clock is set and WiFi is up) the backup is uploaded to the server in the background.",
560+
"run": "Back up now",
561+
"started": "Backup upload started…",
562+
"done": "Backup uploaded",
563+
"failed": "Backup upload failed"
552564
}
553565
},
554566
"playlist": {

‎html/locales/fr.json‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,18 @@
542542
"peerHostPlaceholder": "IP ou nom d'hôte",
543543
"peerHostRequired": "Veuillez saisir une IP/nom d'hôte",
544544
"noPeers": "Aucun pair pour le moment."
545+
},
546+
"backup": {
547+
"title": "Sauvegarde auto vers le serveur",
548+
"desc": "Téléverse une sauvegarde complète de la configuration (réglages, affectations RFID, règles d'égaliseur et statistiques d'écoute) vers un serveur – la même sauvegarde que le bouton d'export, donc restaurable via l'import. Les mots de passe ne sont jamais inclus. Utilise l'identifiant/mot de passe de la section de synchronisation de fichiers ci-dessus.",
549+
"url": "URL de sauvegarde (backup.php)",
550+
"urlPlaceholder": "http://serveur/espuino/backup.php",
551+
"auto": "Sauvegarder automatiquement une fois par jour",
552+
"autoHint": "Une fois par jour (dès que l'horloge est réglée et le Wi-Fi connecté), la sauvegarde est téléversée vers le serveur en arrière-plan.",
553+
"run": "Sauvegarder maintenant",
554+
"started": "Téléversement de la sauvegarde démarré…",
555+
"done": "Sauvegarde téléversée",
556+
"failed": "Échec du téléversement de la sauvegarde"
545557
}
546558
},
547559
"playlist": {

‎html/management.html‎

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3449,6 +3449,31 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
34493449
data-i18n="sync.run.gotools"></button>
34503450
</div>
34513451
</fieldset>
3452+
3453+
<!-- ===== Section 3: auto-backup to the server ===== -->
3454+
<fieldset class="col-md-12 mb-4">
3455+
<legend class="w-auto" data-i18n="sync.backup.title"></legend>
3456+
<p class="small text-muted" data-i18n="[html]sync.backup.desc"></p>
3457+
<label for="backupUrl" data-i18n="[prepend]sync.backup.url">:</label>
3458+
<input type="url" class="form-control" id="backupUrl" name="backupUrl"
3459+
data-i18n="[placeholder]sync.backup.urlPlaceholder" value="">
3460+
<small class="text-muted d-block mb-2">
3461+
<span data-i18n="sync.rfid.phpHint"></span>
3462+
<a href="https://github.com/fhirschmann/leoino/blob/master/server/backup.php"
3463+
target="_blank" rel="noopener">backup.php</a>
3464+
</small>
3465+
<div class="d-flex gap-2 mt-2">
3466+
<input type="checkbox" id="backupAuto" name="backupAuto" value="false">
3467+
<label for="backupAuto" data-i18n="sync.backup.auto"></label>
3468+
</div>
3469+
<small class="text-muted" data-i18n="sync.backup.autoHint"></small>
3470+
<div class="text-center mt-3">
3471+
<button type="button" class="btn btn-primary" id="backupSaveBtn" onclick="syncSettings()" data-i18n="submit"></button>
3472+
<button type="button" class="btn btn-primary ms-2" id="backupRunBtn" onclick="runBackupUpload()">
3473+
<i class="fas fa-cloud-upload-alt"></i> <span data-i18n="sync.backup.run"></span></button>
3474+
</div>
3475+
<div id="backupRunStatus" class="text-center small text-muted mt-2"></div>
3476+
</fieldset>
34523477
</div>
34533478
</div>
34543479
<div class="tab-pane fade" id="nav-homekit" role="tabpanel" aria-labelledby="nav-homekit-tab">
@@ -5439,6 +5464,10 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
54395464
if (syncSettingsData.rfidLearn !== undefined) {
54405465
document.getElementById("rfidSyncLearn").checked = syncSettingsData.rfidLearn;
54415466
}
5467+
document.getElementById("backupUrl").value = syncSettingsData.backupUrl || "";
5468+
if (syncSettingsData.backupAuto !== undefined) {
5469+
document.getElementById("backupAuto").checked = syncSettingsData.backupAuto;
5470+
}
54425471
updateSyncSaveButtons();
54435472
}
54445473
// mqtt
@@ -6086,7 +6115,9 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
60866115
abortOnButton: document.getElementById('syncAbortBtn').checked,
60876116
rfidUrl: document.getElementById('rfidSyncUrl').value,
60886117
rfidPeers: JSON.stringify(window.rfidPeers || []),
6089-
rfidLearn: document.getElementById('rfidSyncLearn').checked
6118+
rfidLearn: document.getElementById('rfidSyncLearn').checked,
6119+
backupUrl: document.getElementById('backupUrl').value,
6120+
backupAuto: document.getElementById('backupAuto').checked
60906121
}
60916122
};
60926123
await fetch("http://" + host + "/settings", {
@@ -6115,6 +6146,28 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
61156146
}, 1500);
61166147
}
61176148

6149+
// Trigger a configuration-backup upload to the server and poll its status.
6150+
let backupPollTimer = null;
6151+
async function runBackupUpload() {
6152+
await fetch("http://" + host + "/backupupload", { method: "POST" });
6153+
toaster.info(i18next.t("sync.backup.started"));
6154+
clearInterval(backupPollTimer);
6155+
backupPollTimer = setInterval(async function () {
6156+
try {
6157+
const s = await (await fetch("http://" + host + "/backupupload")).json();
6158+
const el = document.getElementById('backupRunStatus');
6159+
if (el) {
6160+
el.textContent = (s.status === 1 ? "… " : "") + (s.message || "");
6161+
}
6162+
if (s.status !== 1) {
6163+
clearInterval(backupPollTimer);
6164+
if (s.status === 2) toaster.info(i18next.t("sync.backup.done"));
6165+
else if (s.status === 3) toaster.error(i18next.t("sync.backup.failed") + (s.message ? ": " + s.message : ""));
6166+
}
6167+
} catch (e) { clearInterval(backupPollTimer); }
6168+
}, 1500);
6169+
}
6170+
61186171
let syncPollTimer = null;
61196172
/* toggles the Sync now / Stop buttons depending on whether a sync is running */
61206173
function setSyncRunning(running) {
@@ -6253,9 +6306,13 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
62536306
if (s && su) s.disabled = (su.value.trim() === "");
62546307
var r = document.getElementById('rfidSyncSaveBtn'), ru = document.getElementById('rfidSyncUrl');
62556308
if (r && ru) r.disabled = (ru.value.trim() === "");
6309+
var b = document.getElementById('backupSaveBtn'), bu = document.getElementById('backupUrl');
6310+
var brun = document.getElementById('backupRunBtn');
6311+
if (b && bu) b.disabled = (bu.value.trim() === "");
6312+
if (brun && bu) brun.disabled = (bu.value.trim() === "");
62566313
}
62576314
document.addEventListener("DOMContentLoaded", function () {
6258-
['syncUrl', 'rfidSyncUrl'].forEach(function (id) {
6315+
['syncUrl', 'rfidSyncUrl', 'backupUrl'].forEach(function (id) {
62596316
var el = document.getElementById(id);
62606317
if (el) el.addEventListener('input', updateSyncSaveButtons);
62616318
});

‎server/backup.php‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<?php
2+
/**
3+
* ESPuino configuration-backup receiver.
4+
*
5+
* POST backup.php (body = backup JSON, header X-ESPuino-Host: <hostname>)
6+
* -> stores ./backups/<host>/<host>_<UTC timestamp>.json and prunes old ones
7+
*
8+
* The ESPuino's auto-backup uploads the same JSON the web interface's "export backup" button
9+
* produces (settings + RFID assignments + EQ rules + listening statistics, never any passwords).
10+
* The stored file can be fed straight back into the web interface's "import backup".
11+
*
12+
* Deploy: place this at the web root that serves your sync domain (next to manifest.php). Backups
13+
* land in ./backups (must be writable by PHP-FPM). Point the ESPuino's backup URL at
14+
* https://<host>/backup.php and protect it with the same basic auth as the file sync.
15+
*
16+
* nginx (e.g. SWAG) — route to PHP-FPM and protect with basic auth:
17+
* location = /backup.php {
18+
* auth_basic "Restricted";
19+
* auth_basic_user_file /config/nginx/.htpasswd_espuino;
20+
* client_max_body_size 8m; # backups with many RFID tags can be large
21+
* include /etc/nginx/fastcgi_params;
22+
* fastcgi_param SCRIPT_FILENAME /path/to/espuino/backup.php;
23+
* fastcgi_pass 127.0.0.1:9000;
24+
* }
25+
*/
26+
27+
header('Content-Type: application/json; charset=utf-8');
28+
29+
// How many backups to keep per device (older ones are pruned). 0 = keep all.
30+
const KEEP_PER_HOST = 30;
31+
32+
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
33+
http_response_code(405);
34+
echo json_encode(['error' => 'POST required']);
35+
exit;
36+
}
37+
38+
$body = file_get_contents('php://input');
39+
if ($body === false || strlen($body) === 0) {
40+
http_response_code(400);
41+
echo json_encode(['error' => 'empty body']);
42+
exit;
43+
}
44+
45+
// Validate it is the expected backup JSON (cheap sanity check, not a full schema check).
46+
$json = json_decode($body, true);
47+
if (!is_array($json) || ($json['espuinoBackup'] ?? 0) != 1) {
48+
http_response_code(400);
49+
echo json_encode(['error' => 'not an ESPuino backup']);
50+
exit;
51+
}
52+
53+
// Sanitize the device hostname into a safe directory/file component.
54+
$host = $_SERVER['HTTP_X_ESPUINO_HOST'] ?? 'espuino';
55+
$host = preg_replace('/[^A-Za-z0-9_.-]/', '_', $host);
56+
if ($host === '' || $host === null) {
57+
$host = 'espuino';
58+
}
59+
60+
$dir = __DIR__ . '/backups/' . $host;
61+
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
62+
http_response_code(500);
63+
echo json_encode(['error' => 'cannot create backup directory']);
64+
exit;
65+
}
66+
67+
$file = $dir . '/' . $host . '_' . gmdate('Ymd-His') . '.json';
68+
if (file_put_contents($file, $body) === false) {
69+
http_response_code(500);
70+
echo json_encode(['error' => 'cannot write backup']);
71+
exit;
72+
}
73+
74+
// Prune old backups, keeping the newest KEEP_PER_HOST.
75+
if (KEEP_PER_HOST > 0) {
76+
$existing = glob($dir . '/*.json');
77+
if ($existing !== false && count($existing) > KEEP_PER_HOST) {
78+
sort($existing); // names sort chronologically (host_YYYYMMDD-HHMMSS.json)
79+
$remove = array_slice($existing, 0, count($existing) - KEEP_PER_HOST);
80+
foreach ($remove as $old) {
81+
@unlink($old);
82+
}
83+
}
84+
}
85+
86+
echo json_encode(['ok' => true, 'stored' => basename($file), 'bytes' => strlen($body)]);

0 commit comments

Comments
 (0)