Skip to content

Commit 4c90ff4

Browse files
fhirschmannclaude
andcommitted
feat: full settings backup export/import in tools tab
Exports all settings sections plus RFID assignments as a JSON file; WiFi credentials (incl. static-ip config) can optionally be included via /savedSSIDs?credentials. Import replays the file through the existing REST endpoints section by section. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 4e68541 commit 4c90ff4

5 files changed

Lines changed: 211 additions & 2 deletions

File tree

html/locales/de.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,23 @@
477477
}
478478
},
479479
"tools": {
480+
"backup": {
481+
"title": "Backup",
482+
"desc": "Exportiert bzw. importiert alle Einstellungen und RFID-Zuweisungen als JSON-Datei.",
483+
"export": {
484+
"title": "Export",
485+
"includeWifi": "WLAN-Zugangsdaten einschließen",
486+
"button": "Backup herunterladen"
487+
},
488+
"import": {
489+
"title": "Import",
490+
"desc": "Backup-Datei auswählen und importieren. Einige Einstellungen (z. B. MQTT) erfordern danach einen Neustart.",
491+
"button": "Importieren",
492+
"invalid": "Keine gültige Backup-Datei",
493+
"partial": "Import mit {{count}} Fehlern abgeschlossen",
494+
"done": "Import abgeschlossen"
495+
}
496+
},
480497
"nvs": {
481498
"title": "Gespeicherte RFID-Zuweisungen",
482499
"desc": "Die RFID-Zuweisungen werden nicht auf der SD-Karte, sondern im internen, nicht-flüchtigen Speicher (NVS) des ESPuinos gespeichert.",

html/locales/en.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,23 @@
479479
}
480480
},
481481
"tools": {
482+
"backup": {
483+
"title": "Backup",
484+
"desc": "Exports or imports all settings and RFID assignments as a JSON file.",
485+
"export": {
486+
"title": "Export",
487+
"includeWifi": "Include WiFi credentials",
488+
"button": "Download backup"
489+
},
490+
"import": {
491+
"title": "Import",
492+
"desc": "Select and import a backup file. Some settings (e.g. MQTT) require a restart afterwards.",
493+
"button": "Import",
494+
"invalid": "Not a valid backup file",
495+
"partial": "Import finished with {{count}} errors",
496+
"done": "Import finished"
497+
}
498+
},
482499
"nvs": {
483500
"title": "Saved RFID assignments",
484501
"desc": "The RFID assignments are not stored on the SD card but on the internal, non-volative storage (NVS) of the ESPuino.",

html/locales/fr.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,23 @@
466466
}
467467
},
468468
"tools": {
469+
"backup": {
470+
"title": "Sauvegarde",
471+
"desc": "Exporte ou importe tous les réglages et attributions RFID dans un fichier JSON.",
472+
"export": {
473+
"title": "Export",
474+
"includeWifi": "Inclure les identifiants WiFi",
475+
"button": "Télécharger la sauvegarde"
476+
},
477+
"import": {
478+
"title": "Import",
479+
"desc": "Sélectionner et importer un fichier de sauvegarde. Certains réglages (p. ex. MQTT) nécessitent ensuite un redémarrage.",
480+
"button": "Importer",
481+
"invalid": "Fichier de sauvegarde invalide",
482+
"partial": "Import terminé avec {{count}} erreurs",
483+
"done": "Import terminé"
484+
}
485+
},
469486
"nvs": {
470487
"title": "Affectations RFID enregistrées",
471488
"desc": "Les affectations RFID ne sont pas stockées sur la carte SD, mais sur la mémoire interne non volatile (NVS) de l'ESPuino.",

html/management.html

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2320,6 +2320,31 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
23202320
</span>
23212321
<hr>
23222322
</div>
2323+
<div class="container" id="backup">
2324+
<legend data-i18n="tools.backup.title"></legend>
2325+
<p data-i18n="tools.backup.desc"></p>
2326+
<span class="container" style="padding: 0">
2327+
<p style="font-weight: bold" data-i18n="tools.backup.export.title"></p>
2328+
<div class="d-flex gap-2">
2329+
<input type="checkbox" id="backupIncludeWifi" value="false">
2330+
<label for="backupIncludeWifi" data-i18n="tools.backup.export.includeWifi"></label>
2331+
</div><br>
2332+
<button type="button" class="btn btn-primary" onclick="exportBackup()"
2333+
data-i18n="tools.backup.export.button"></button>
2334+
<br><br>
2335+
</span>
2336+
<span class="container" style="padding: 0">
2337+
<p style="font-weight: bold" data-i18n="tools.backup.import.title"></p>
2338+
<p data-i18n="tools.backup.import.desc"></p>
2339+
<input type="file" class="form-control-file btn btn-primary" id="backupUpload" accept=".json">
2340+
<br><br>
2341+
<div class="text-center">
2342+
<button type="button" class="btn btn-primary" onclick="importBackup()"
2343+
data-i18n="tools.backup.import.button"></button>
2344+
</div>
2345+
</span>
2346+
<hr>
2347+
</div>
23232348
<div class="container" id="httpUpdate">
23242349
<legend id="legendfwupdate" data-i18n="tools.fwupdate.title"></legend>
23252350
<form id="firmwareUploadForm" method="POST" enctype="multipart/form-data" action="/update">
@@ -4354,6 +4379,123 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
43544379
}
43554380
return dDisplay + hDisplay + mDisplay;
43564381
}
4382+
async function exportBackup() {
4383+
const backup = {
4384+
espuinoBackup: 1,
4385+
created: new Date().toISOString(),
4386+
settings: await (await fetch("http://" + host + "/settings")).json(),
4387+
rfid: await (await fetch("http://" + host + "/rfid")).json()
4388+
};
4389+
delete backup.settings.ssids; // names only - not usable for a restore
4390+
if (document.getElementById('backupIncludeWifi').checked) {
4391+
backup.wifiConfig = await (await fetch("http://" + host + "/wificonfig")).json();
4392+
backup.wifiNetworks = await (await fetch("http://" + host + "/savedSSIDs?credentials=1")).json();
4393+
}
4394+
const blob = new Blob([JSON.stringify(backup, null, "\t")], {
4395+
type: "application/json"
4396+
});
4397+
const anchor = document.createElement('a');
4398+
anchor.href = URL.createObjectURL(blob);
4399+
anchor.download = 'espuino-backup_' + new Date().toISOString().substring(0, 10) + '.json';
4400+
anchor.click();
4401+
URL.revokeObjectURL(anchor.href);
4402+
}
4403+
4404+
async function importBackup() {
4405+
const fileInput = document.getElementById('backupUpload');
4406+
if (!fileInput.files.length) {
4407+
alert(i18next.t("files.upload.selectFile"));
4408+
return;
4409+
}
4410+
let backup;
4411+
try {
4412+
backup = JSON.parse(await fileInput.files[0].text());
4413+
} catch (e) {
4414+
toaster.error(i18next.t("tools.backup.import.invalid"));
4415+
return;
4416+
}
4417+
if (backup.espuinoBackup !== 1) {
4418+
toaster.error(i18next.t("tools.backup.import.invalid"));
4419+
return;
4420+
}
4421+
let errors = 0;
4422+
// the firmware applies one settings section per request
4423+
const sections = ['general', 'equalizer', 'led', 'battery', 'playlist', 'buttons', 'rotary', 'ftp', 'mqtt', 'bluetooth'];
4424+
for (const section of sections) {
4425+
if (backup.settings && backup.settings[section]) {
4426+
const response = await fetch("http://" + host + "/settings", {
4427+
method: "POST",
4428+
headers: {
4429+
"Content-Type": "application/json"
4430+
},
4431+
body: JSON.stringify({
4432+
[section]: backup.settings[section]
4433+
})
4434+
});
4435+
if (!response.ok) {
4436+
errors++;
4437+
}
4438+
}
4439+
}
4440+
if (Array.isArray(backup.rfid)) {
4441+
for (const entry of backup.rfid) {
4442+
if (!entry.id) {
4443+
continue;
4444+
}
4445+
const response = await fetch("http://" + host + "/rfid", {
4446+
method: "POST",
4447+
headers: {
4448+
"Content-Type": "application/json"
4449+
},
4450+
body: JSON.stringify(entry)
4451+
});
4452+
if (!response.ok) {
4453+
errors++;
4454+
}
4455+
}
4456+
}
4457+
if (Array.isArray(backup.wifiNetworks)) {
4458+
for (const network of backup.wifiNetworks) {
4459+
if (!network.ssid) {
4460+
continue;
4461+
}
4462+
const response = await fetch("http://" + host + "/savedSSIDs", {
4463+
method: "POST",
4464+
headers: {
4465+
"Content-Type": "application/json"
4466+
},
4467+
body: JSON.stringify(network)
4468+
});
4469+
if (!response.ok) {
4470+
errors++;
4471+
}
4472+
}
4473+
await rebuildSSIDList();
4474+
}
4475+
if (backup.wifiConfig) {
4476+
const response = await fetch("http://" + host + "/wificonfig", {
4477+
method: "POST",
4478+
headers: {
4479+
"Content-Type": "application/json"
4480+
},
4481+
body: JSON.stringify(backup.wifiConfig)
4482+
});
4483+
if (!response.ok) {
4484+
errors++;
4485+
}
4486+
}
4487+
if (errors) {
4488+
toaster.warning(i18next.t("tools.backup.import.partial", {
4489+
count: errors
4490+
}));
4491+
} else {
4492+
toaster.success(i18next.t("tools.backup.import.done"));
4493+
}
4494+
// reload the imported settings into the UI
4495+
socket.send('{"settings":{"settings":"settings"}}');
4496+
rebuildRFIDList();
4497+
}
4498+
43574499
async function cleanSd() {
43584500
$('#cleanSdModal').modal('hide');
43594501
toaster.info(i18next.t("files.clean.running"));

src/Web.cpp

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2417,10 +2417,26 @@ void handleTrackProgressRequest(AsyncWebServerRequest *request) {
24172417
}
24182418

24192419
void handleGetSavedSSIDs(AsyncWebServerRequest *request) {
2420+
// optional parameter to include credentials & static-ip config (used for backups)
2421+
const bool withCredentials = request->hasParam("credentials");
24202422
AsyncJsonResponse *response = new AsyncJsonResponse(true);
24212423
JsonArray json_ssids = response->getRoot();
2422-
Wlan_GetSavedNetworks([json_ssids](const WiFiSettings &network) {
2423-
json_ssids.add(network.ssid);
2424+
Wlan_GetSavedNetworks([json_ssids, withCredentials](const WiFiSettings &network) {
2425+
if (!withCredentials) {
2426+
json_ssids.add(network.ssid);
2427+
return;
2428+
}
2429+
JsonObject entry = json_ssids.add<JsonObject>();
2430+
entry["ssid"] = network.ssid;
2431+
entry["pwd"] = network.password;
2432+
if (network.staticIp.isValid()) {
2433+
entry["static"] = true;
2434+
entry["static_addr"] = network.staticIp.addr.toString();
2435+
entry["static_subnet"] = network.staticIp.subnet.toString();
2436+
entry["static_gateway"] = network.staticIp.gateway.toString();
2437+
entry["static_dns1"] = network.staticIp.dns1.toString();
2438+
entry["static_dns2"] = network.staticIp.dns2.toString();
2439+
}
24242440
});
24252441

24262442
response->setLength();

0 commit comments

Comments
 (0)