Skip to content

Commit 61a0b8c

Browse files
committed
feat: add config backup and restore with schema versioning
Add export/import of the full device configuration as a versioned JSON file via the web UI. Backup downloads from GET /api/backup (secrets optional via checkbox); restore uploads to POST /api/restore, applies recognized settings leniently, and reboots. - config_backup module serializes/deserializes config via ArduinoJson, gated by CONFIG_SCHEMA_VERSION with a migrate() hook for future non-additive schema changes - Lenient restore: recognized fields applied (clamped via setters), unknown ignored, absent left at current values; cross-version restore proceeds with a mismatch warning - Secrets (WiFi/MQTT passwords, HA token) omitted unless requested and never erased by a no-secrets restore - Backup and Restore card on the System settings page - Docs: configuration guide and API reference
1 parent 1f1abd5 commit 61a0b8c

9 files changed

Lines changed: 616 additions & 0 deletions

File tree

config.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ enum LogSeverity {
2323
// SYSTEM CONFIGURATION
2424
// =============================================================================
2525

26+
// Config backup/restore schema version. Bump when fields are added, renamed,
27+
// removed, or change meaning. See config_backup.cpp migrate() for the
28+
// non-additive migration hook.
29+
#define CONFIG_SCHEMA_VERSION 1
30+
2631
// Memory allocation sizes
2732
// NOTE: These values automatically control PPA hardware accelerator buffer sizes:
2833
// - PPA source buffer = FULL_IMAGE_BUFFER_SIZE

config_backup.cpp

Lines changed: 380 additions & 0 deletions
Large diffs are not rendered by default.

config_backup.h

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
#pragma once
2+
#include <Arduino.h>
3+
4+
// Config backup/restore module. Single owner of the mapping between
5+
// ConfigStorage state and the versioned JSON backup document.
6+
namespace ConfigBackup {
7+
struct RestoreResult {
8+
bool ok = false;
9+
String error;
10+
int fileVersion = 0;
11+
int applied = 0;
12+
int skipped = 0;
13+
bool versionMismatch = false;
14+
bool secretsIncluded = false;
15+
};
16+
17+
// Serialize the full device configuration to JSON. When includeSecrets is
18+
// false the wifiPassword, mqttPassword, and haAccessToken keys are omitted.
19+
String exportJson(bool includeSecrets);
20+
21+
// Parse a backup document and apply recognized fields through ConfigStorage
22+
// setters, then saveConfig(). Does not reboot; the caller handles that.
23+
RestoreResult importJson(const String& body);
24+
}

docs/03_configuration.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Complete guide to configuring the ESP32-P4 AllSky Display firmware, covering bot
1010
- [Web UI Configuration](#web-ui-configuration)
1111
- [MQTT Configuration](#mqtt-configuration)
1212
- [Multi-Image Setup](#multi-image-setup)
13+
- [Backup and Restore](#backup-and-restore)
1314
- [Serial Commands Reference](#serial-commands-reference)
1415
- [Advanced Configuration](#advanced-configuration)
1516

@@ -650,6 +651,57 @@ mkdir -p "${OUTPUT_DIR}"
650651

651652
---
652653

654+
## Backup and Restore
655+
656+
Export the full device configuration to a file, then restore that file onto a device that has been wiped or reset. The backup captures runtime settings stored in NVS: device name, WiFi, MQTT, Home Assistant, image sources and transforms, display settings, and system thresholds. Compile-time settings are not part of a backup.
657+
658+
### Where the Controls Live
659+
660+
The controls are on the System settings page, in the "Backup and Restore" card.
661+
662+
1. Navigate to `http://allskyesp32.lan:8080/system`
663+
2. Find the "Backup and Restore" card
664+
665+
The card contains a "Download backup" button, an "Include passwords and tokens" checkbox, a file picker, and a "Restore and reboot" button.
666+
667+
### Download a Backup
668+
669+
1. Decide whether to include secrets. The "Include passwords and tokens" checkbox controls whether the WiFi password, MQTT password, and Home Assistant access token are written to the file.
670+
2. Select "Download backup". The browser saves a `.json` file named after the device.
671+
672+
Warning: when "Include passwords and tokens" is checked, the secrets are stored in plaintext in the downloaded file. There is no encryption. Store the file in a protected location. When the checkbox is unchecked, the secret keys are omitted from the file, and other identifiers such as the WiFi SSID and MQTT username are still written.
673+
674+
### Restore and Reboot
675+
676+
1. Select the file picker and choose a `.json` backup file.
677+
2. Select "Restore and reboot" and confirm the prompt.
678+
3. The device applies the recognized settings, saves them to NVS, and reboots. After the reboot, the device runs with the restored settings.
679+
680+
A restore that fails to parse the file does not reboot the device.
681+
682+
### Version Behavior
683+
684+
The backup file carries a schema version. Restore is lenient and best-effort:
685+
686+
- Recognized fields are applied.
687+
- Unknown fields are ignored.
688+
- Fields absent from the file keep their current value on the device.
689+
- A backup made on a different schema version still restores. The restore proceeds and reports a version mismatch warning.
690+
- A no-secrets backup does not erase existing credentials. Secret fields are applied only when present and non-empty, so restoring a file saved without secrets leaves the current WiFi password, MQTT password, and Home Assistant token in place.
691+
692+
### Wipe Then Restore Sequence
693+
694+
After a factory reset the web UI is not reachable until WiFi is configured, because a factory reset clears WiFi credentials. Use this order:
695+
696+
1. Factory reset the device.
697+
2. Complete WiFi setup through the captive portal so the device joins your network and the web UI becomes reachable.
698+
3. Open the System settings page and restore the backup file.
699+
4. The device reboots with the restored configuration.
700+
701+
If the backup includes secrets, the restored WiFi credentials take effect after the reboot. If the backup excludes secrets, the WiFi credentials entered during setup are retained.
702+
703+
---
704+
653705
## Serial Commands Reference
654706

655707
Connect via Serial Monitor at 9600 baud to access these commands:

docs/developer/api_reference.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -835,6 +835,59 @@ bool isRunning();
835835
bool isOTAInProgress() const;
836836
```
837837

838+
### REST API Endpoints
839+
840+
#### GET /api/backup
841+
842+
Returns the device configuration as a JSON file attachment.
843+
844+
Query parameters:
845+
846+
| Parameter | Values | Description |
847+
|-----------|--------|-------------|
848+
| `secrets` | `0` or `1` | `1` includes passwords and tokens (WiFi password, MQTT password, Home Assistant access token). `0` omits them. |
849+
850+
Response: the configuration document with `Content-Type: application/json` and a `Content-Disposition: attachment` header. The filename is derived from the device name.
851+
852+
Example:
853+
854+
```bash
855+
# Download a backup with secrets included
856+
curl -OJ "http://allskyesp32.lan:8080/api/backup?secrets=1"
857+
858+
# Download a backup without secrets
859+
curl -OJ "http://allskyesp32.lan:8080/api/backup?secrets=0"
860+
```
861+
862+
#### POST /api/restore
863+
864+
Applies a backup file, saves the configuration, and reboots on success.
865+
866+
Request body: the raw backup file text (the JSON document returned by `GET /api/backup`). The body is read from the `plain` argument.
867+
868+
Behavior: the handler parses the body, applies every recognized field through the matching `ConfigStorage` setters, saves the configuration, then reboots the device. Unknown fields are ignored. Absent fields keep their current value. Secret fields are applied only when present and non-empty, so a no-secrets backup does not erase existing credentials.
869+
870+
Response JSON fields:
871+
872+
| Field | Type | Description |
873+
|-------|------|-------------|
874+
| `status` | string | Result of the restore. |
875+
| `message` | string | Human-readable detail. |
876+
| `applied` | number | Count of recognized fields applied. |
877+
| `skipped` | number | Count of unknown fields ignored. |
878+
| `fileVersion` | number | Schema version read from the file. Absent value is treated as 0. |
879+
| `versionMismatch` | boolean | `true` when `fileVersion` differs from the firmware schema version. The restore still proceeds. |
880+
881+
Errors: an empty or unparseable body returns HTTP 400 and does not reboot. A successful restore reboots after sending the response.
882+
883+
Example:
884+
885+
```bash
886+
# Restore a previously downloaded backup
887+
curl -X POST "http://allskyesp32.lan:8080/api/restore" \
888+
--data-binary @allsky-config-backup.json
889+
```
890+
838891
---
839892

840893
## ConfigStorage

web_config.cpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ bool WebConfig::begin(int port) {
6464
server->on("/api/update-image-duration", HTTP_POST, [this]() { handleUpdateImageDuration(); });
6565
server->on("/api/restart", HTTP_POST, [this]() { handleRestart(); });
6666
server->on("/api/factory-reset", HTTP_POST, [this]() { handleFactoryReset(); });
67+
server->on("/api/backup", HTTP_GET, [this]() { handleBackup(); });
68+
server->on("/api/restore", HTTP_POST, [this]() { handleRestore(); });
6769
server->on("/api/set-log-severity", HTTP_POST, [this]() { handleSetLogSeverity(); });
6870
server->on("/api/clear-crash-logs", HTTP_POST, [this]() { handleClearCrashLogs(); });
6971
server->on("/api/force-brightness-update", HTTP_POST, [this]() { handleForceBrightnessUpdate(); });

web_config.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ class WebConfig {
6868
void handleUpdateImageDuration();
6969
void handleRestart();
7070
void handleFactoryReset();
71+
void handleBackup();
72+
void handleRestore();
7173
void handleSetLogSeverity();
7274
void handleClearCrashLogs();
7375
void handleForceBrightnessUpdate();

web_config_api.cpp

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
#include "ha_discovery.h"
1313
#include "logging.h"
1414
#include "image_presets.h"
15+
#include "config_backup.h"
1516
#include <Update.h>
1617
#include <driver/jpeg_encode.h> // ESP32-P4 hardware JPEG encoder (screenshot endpoint)
1718

@@ -1105,6 +1106,62 @@ void WebConfig::handleFactoryReset() {
11051106
ESP.restart();
11061107
}
11071108

1109+
void WebConfig::handleBackup() {
1110+
bool includeSecrets = server->hasArg("secrets") && server->arg("secrets") == "1";
1111+
LOG_INFO_F("[WebAPI] Configuration backup requested (secrets: %s)\n", includeSecrets ? "included" : "omitted");
1112+
1113+
String body = ConfigBackup::exportJson(includeSecrets);
1114+
server->sendHeader("Content-Disposition", "attachment; filename=\"allsky-config.json\"");
1115+
sendResponse(200, "application/json", body);
1116+
}
1117+
1118+
void WebConfig::handleRestore() {
1119+
if (!server->hasArg("plain") || server->arg("plain").length() == 0) {
1120+
LOG_WARNING("[WebAPI] Configuration restore called with empty body");
1121+
sendResponse(400, "application/json", "{\"status\":\"error\",\"message\":\"Request body is empty. Upload a backup file.\"}");
1122+
return;
1123+
}
1124+
1125+
LOG_INFO("[WebAPI] Configuration restore request received");
1126+
ConfigBackup::RestoreResult r = ConfigBackup::importJson(server->arg("plain"));
1127+
1128+
// Build the human-readable message, noting cross-version restores.
1129+
String message;
1130+
if (r.ok) {
1131+
message = "Configuration restored. Device restarting now...";
1132+
if (r.versionMismatch) {
1133+
message = "Configuration restored from a different schema version (file v" + String(r.fileVersion) +
1134+
"); applied best-effort. Device restarting now...";
1135+
}
1136+
} else {
1137+
message = r.error;
1138+
}
1139+
1140+
String json = "{";
1141+
json += "\"status\":\"" + String(r.ok ? "success" : "error") + "\",";
1142+
json += "\"message\":\"" + escapeJson(message) + "\",";
1143+
json += "\"applied\":" + String(r.applied) + ",";
1144+
json += "\"skipped\":" + String(r.skipped) + ",";
1145+
json += "\"fileVersion\":" + String(r.fileVersion) + ",";
1146+
json += "\"versionMismatch\":" + String(r.versionMismatch ? "true" : "false");
1147+
json += "}";
1148+
1149+
if (!r.ok) {
1150+
LOG_WARNING_F("[WebAPI] Configuration restore failed: %s\n", r.error.c_str());
1151+
sendResponse(400, "application/json", json);
1152+
return;
1153+
}
1154+
1155+
LOG_INFO_F("[WebAPI] Configuration restore applied (applied: %d, skipped: %d) - rebooting\n", r.applied, r.skipped);
1156+
sendResponse(200, "application/json", json);
1157+
1158+
displayManager.debugPrint("Configuration restored...", COLOR_YELLOW);
1159+
1160+
delay(500);
1161+
crashLogger.saveBeforeReboot();
1162+
ESP.restart();
1163+
}
1164+
11081165
void WebConfig::handleSetLogSeverity() {
11091166
if (!server->hasArg("severity")) {
11101167
sendResponse(400, "application/json", "{\"status\":\"error\",\"message\":\"Missing severity parameter\"}");

web_config_pages.cpp

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,47 @@ String WebConfig::generateAdvancedPage() {
583583
"</script>";
584584
html += "</div>";
585585

586+
// Backup and Restore Section
587+
html += "<div class='card' style='margin-top:1.5rem'><h2>💾 Backup and Restore</h2>";
588+
html += "<p style='color:#94a3b8;margin-bottom:1rem'>Download the full device configuration to a JSON file, or restore a previously saved file. Restoring replaces the current configuration and reboots the device.</p>";
589+
html += "<div style='background:rgba(245,158,11,0.1);border:1px solid #f59e0b;border-radius:8px;padding:1rem;margin-bottom:1rem'>";
590+
html += "<p style='color:#f59e0b;margin:0;font-size:0.9rem'><i class='fas fa-exclamation-triangle' style='margin-right:8px'></i>If <strong>Include passwords and tokens</strong> is checked, the backup file stores the WiFi password, MQTT password, and Home Assistant token in plaintext.</p>";
591+
html += "</div>";
592+
html += "<div class='form-group'><label><input type='checkbox' id='backupSecrets'> Include passwords and tokens</label></div>";
593+
html += "<div style='display:flex;gap:0.75rem;flex-wrap:wrap;margin-bottom:1.5rem'>";
594+
html += "<button type='button' class='btn btn-primary' onclick='downloadBackup()'>⬇️ Download backup</button></div>";
595+
html += "<div class='form-group'><label for='restoreFile'>Restore from file</label>";
596+
html += "<input type='file' id='restoreFile' accept='.json,application/json' class='form-control'></div>";
597+
html += "<div style='display:flex;gap:0.75rem;flex-wrap:wrap;margin-bottom:0.75rem'>";
598+
html += "<button type='button' class='btn btn-primary' onclick='restoreBackup()'>♻️ Restore and reboot</button></div>";
599+
html += "<div id='backupStatus' style='color:#94a3b8'></div>";
600+
html += "<script>"
601+
"function downloadBackup(){"
602+
"var s=document.getElementById('backupSecrets').checked?'1':'0';"
603+
"window.location='/api/backup?secrets='+s;"
604+
"}"
605+
"function restoreBackup(){"
606+
"var st=document.getElementById('backupStatus');"
607+
"var fi=document.getElementById('restoreFile');"
608+
"if(!fi.files||!fi.files.length){st.textContent='Select a backup file first.';return;}"
609+
"if(!confirm('Restore this configuration? The current settings will be replaced and the device will reboot.'))return;"
610+
"var rd=new FileReader();"
611+
"rd.onload=function(){"
612+
"st.textContent='Restoring...';"
613+
"fetch('/api/restore',{method:'POST',body:rd.result}).then(function(r){"
614+
"return r.json().then(function(j){return {ok:r.ok,body:j};});}).then(function(res){"
615+
"var msg=(res.body&&res.body.message)?res.body.message:('HTTP '+(res.body&&res.body.status?res.body.status:'error'));"
616+
"if(res.body&&res.body.versionMismatch){msg+=' (backup schema version differs from this firmware; recognized fields were applied on a best-effort basis)';}"
617+
"st.textContent=msg;"
618+
"if(res.ok){st.textContent=msg+' Device is rebooting...';}"
619+
"}).catch(function(e){st.textContent='Restore failed: '+e.message;});"
620+
"};"
621+
"rd.onerror=function(){st.textContent='Could not read the selected file.';};"
622+
"rd.readAsText(fi.files[0]);"
623+
"}"
624+
"</script>";
625+
html += "</div>";
626+
586627
html += "</div></div>";
587628
return html;
588629
}

0 commit comments

Comments
 (0)