src.nth.io/

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLuke Hoersten <[email protected]>2026-06-13 22:42:14 -0500
committerLuke Hoersten <[email protected]>2026-06-13 22:42:14 -0500
commite7feac61e5ea275f303574fedd942ebed8fc8e73 (patch)
tree26bbb23c8faf16294325b27c254df6dd6fbdf1e8
parente91ed43dee0f2715362fed9f52fa684e655de7be (diff)
M4: NVS-backed /config with partial updates + validation
nvs_config.{h,c} — persist the runtime config (viewport, scrypted, idle_timeout_ms, orientation, brightness) under a single NVS namespace. nvs_config_load() applies persisted values over the in-RAM defaults on boot and flips state from UNCONFIGURED to ASLEEP once both name and Scrypted URL are present. nvs_config_save() commits the whole record atomically. http_api.c — add GET /config and POST /config: - GET serializes viewport_state to the spec's JSON shape, with null for unset string fields and defaults filled in for the rest. - POST is partial: each field is optional; only present fields are validated and applied. Validation runs on a staged copy and errors short-circuit with 400 + reason before any state mutation, so a rejected request leaves the device untouched. - Validation rules: viewport non-empty <64 chars; scrypted starts with http:// and <256 chars; idle_timeout_ms 0 or >=5000; orientation in {portrait,landscape}; brightness 0..100. - Side-effects fire after the lock + save: brightness change pushes PWM to the panel MCU; viewport/orientation change reapplies mDNS hostname + TXT. - 204 on success; 400 with a single-line reason on validation error. app_main calls nvs_config_load() right after viewport_state_init(), so mdns_service_start() and display_init() see the persisted hostname, orientation, and brightness from the first packet/PWM. Build clean against ESP-IDF 5.4 (binary ~620 KB). TESTING.md M3 now documents the Hosyond jumper wiring (5V/GND/SDA=GPIO7/ SCL=GPIO8 from board to panel header; DSI FPC carries only the high- speed lanes). M4 entry expands the validation matrix and side-effects to verify.
-rw-r--r--TESTING.md80
-rw-r--r--main/app_main.c7
-rw-r--r--main/http_api.c233
-rw-r--r--main/nvs_config.c119
-rw-r--r--main/nvs_config.h16
5 files changed, 437 insertions, 18 deletions
diff --git a/TESTING.md b/TESTING.md
index dde0cdc..dd17d94 100644
--- a/TESTING.md
+++ b/TESTING.md
@@ -91,11 +91,52 @@ Expected mDNS browse output should show a `_scrypted-viewport._tcp` instance wit
## M3 — Display Bring-Up
-**Acceptance**: panel shows a test pattern; backlight toggles via API.
+**Acceptance**: panel powers on, MCU at I2C 0x45 responds, color-bar test pattern renders, brightness control works.
-**How to verify**: TBD (write after M3 implementation).
+**Wiring** (Hosyond 5" 800x480 panel ↔ Waveshare ESP32-P4-ETH):
-**Status**: ⬜ pending.
+The DSI FPC adapter (15-pin Pi side → 22-pin Waveshare side) carries the DSI data lanes and power. I2C and panel power are jumpered separately from the ESP32-P4 board to the Hosyond's auxiliary header (the same header Pi users normally connect to the Pi's 40-pin GPIO):
+
+| Hosyond panel pin | Wire to ESP32-P4 board |
+| --- | --- |
+| 5V | board 5V rail |
+| GND | board GND |
+| SDA | GPIO 7 |
+| SCL | GPIO 8 |
+
+GPIO 7/8 match Waveshare's BSP convention for touch I2C on their bundled-panel kit. If different pins are more convenient, change `PIN_I2C_SDA` / `PIN_I2C_SCL` in `display.c`.
+
+**Bring-up sequence**
+
+1. Power the ESP32-P4 board over USB (don't plug DSI yet).
+2. Connect 5V + GND jumpers to the panel. Panel LED (if present) should light.
+3. Connect SDA + SCL jumpers.
+4. Plug the DSI FPC cable.
+5. Flash:
+
+```bash
+idf.py -p /dev/cu.usbmodem* flash monitor
+```
+
+**Expected log**
+
+```
+I (xxx) display: panel MCU id 0xC3 — Pi 7" architecture ack'd
+I (xxx) display: panel powered on
+I (xxx) display: DSI up: 800x480 30 MHz, 2-lane 480 Mbps
+I (xxx) viewport: display up — test pattern on screen
+```
+
+**Visual check**: 8 vertical color bars (white, yellow, cyan, green, magenta, red, blue, black) across the panel. Brightness should look perceptually mid-range (default 80/100 with gamma).
+
+**Failure-mode signals**
+
+- `panel MCU @0x45 unreachable` → jumper or pull-up problem on I2C, no need to suspect DSI yet.
+- I2C ack but no image → DSI cable / FPC adapter orientation. Pi FPCs are easy to install upside-down.
+- Image but wrong colors → check RGB565 byte order; flip `LCD_COLOR_PIXEL_FORMAT_RGB565` to a variant with swap.
+- Image but vertical/horizontal sync issues → adjust the `PANEL_*SYNC_*` timings; Pi 7" canonical values used as defaults.
+
+**Status**: 🟡 builds clean against ESP-IDF 5.4. Driver ported from Linux `panel-raspberrypi-touchscreen.c`. Awaiting hardware bring-up with confirmed jumper wiring.
---
@@ -123,9 +164,38 @@ curl -X POST -H "Content-Type: application/json" \
curl http://<device-ip>/config | jq .
```
-Also verify validation: `idle_timeout_ms: 1000` (below 5000) returns 400; `orientation: "sideways"` returns 400; etc.
+Also verify validation:
-**Status**: ⬜ pending.
+```bash
+# idle_timeout_ms below 5000 (and non-zero) — expect 400:
+curl -i -X POST -H "Content-Type: application/json" \
+ -d '{"idle_timeout_ms":1000}' http://<device-ip>/config
+
+# bogus orientation — expect 400:
+curl -i -X POST -H "Content-Type: application/json" \
+ -d '{"orientation":"sideways"}' http://<device-ip>/config
+
+# brightness out of range — expect 400:
+curl -i -X POST -H "Content-Type: application/json" \
+ -d '{"brightness":150}' http://<device-ip>/config
+
+# scrypted without http:// — expect 400:
+curl -i -X POST -H "Content-Type: application/json" \
+ -d '{"scrypted":"scrypted.local"}' http://<device-ip>/config
+
+# Garbage JSON — expect 400:
+curl -i -X POST -H "Content-Type: application/json" \
+ -d 'not json' http://<device-ip>/config
+```
+
+Idle-timer disable (`idle_timeout_ms: 0`) is intentionally allowed.
+
+Side-effects to confirm:
+- After `POST /config` with `brightness`: panel brightness changes immediately (if display is up).
+- After `POST /config` with `viewport` or `orientation`: mDNS TXT records update; `viewport-<name>.local` resolves; browse shows new TXT.
+- After `POST /config` with both `viewport` and `scrypted` (any order, on any subsequent call): `GET /state` shows `configured: true`, `state: "asleep"`.
+
+**Status**: 🟡 builds clean against ESP-IDF 5.4. Logic exercised in code but unverified on hardware.
---
diff --git a/main/app_main.c b/main/app_main.c
index c3c12c0..316f702 100644
--- a/main/app_main.c
+++ b/main/app_main.c
@@ -2,6 +2,7 @@
#include "http_api.h"
#include "mdns_service.h"
#include "net_eth.h"
+#include "nvs_config.h"
#include "viewport_state.h"
#include "esp_event.h"
@@ -18,6 +19,7 @@ void app_main(void)
ESP_ERROR_CHECK(esp_event_loop_create_default());
viewport_state_init();
+ nvs_config_load(); // apply persisted config over defaults (best-effort)
ESP_LOGI(TAG, "Scrypted Viewport boot (v%s)", VIEWPORT_VERSION);
ESP_ERROR_CHECK(net_eth_init());
@@ -30,8 +32,8 @@ void app_main(void)
ESP_ERROR_CHECK(mdns_service_start());
ESP_ERROR_CHECK(http_api_start());
- // Display is best-effort during bring-up: a missing/miswired panel must
- // not kill networking + /state. M3 acceptance: show a test pattern.
+ // Display is best-effort — a missing/miswired panel must not kill
+ // networking + /state. M3 acceptance: show a test pattern.
if (display_init() == ESP_OK) {
display_test_pattern();
ESP_LOGI(TAG, "display up — test pattern on screen");
@@ -39,7 +41,6 @@ void app_main(void)
ESP_LOGW(TAG, "display init failed — continuing without panel");
}
- // TODO M4: /config persistence (NVS) + GET /config + POST /config
// TODO M5: /frame JPEG decode -> framebuffer
// TODO M6: POST /state + idle timer
// TODO M7: Capacitive touch -> outbound /state POST
diff --git a/main/http_api.c b/main/http_api.c
index 4c0008d..7e97407 100644
--- a/main/http_api.c
+++ b/main/http_api.c
@@ -11,11 +11,17 @@
#include "esp_log.h"
#include "esp_timer.h"
+#include "display.h"
+#include "mdns_service.h"
#include "net_eth.h"
+#include "nvs_config.h"
#include "viewport_state.h"
static const char *TAG = "http_api";
+#define MAX_BODY_BYTES 2048
+#define MIN_IDLE_TIMEOUT 5000
+
static const char *state_name(viewport_run_state_t s)
{
switch (s) {
@@ -25,6 +31,14 @@ static const char *state_name(viewport_run_state_t s)
}
}
+static const char *orientation_name(viewport_orientation_t o)
+{
+ return (o == VIEWPORT_ORIENTATION_LANDSCAPE) ? "landscape" : "portrait";
+}
+
+// ============================================================================
+// GET /state
+// ============================================================================
static esp_err_t state_get_handler(httpd_req_t *req)
{
viewport_state_lock();
@@ -32,7 +46,7 @@ static esp_err_t state_get_handler(httpd_req_t *req)
uint64_t now_us = (uint64_t)esp_timer_get_time();
uint64_t up_ms = (now_us - st->boot_us) / 1000;
- int64_t last_age_ms = (st->last_frame_us < 0)
+ int64_t last_age_ms = (st->last_frame_us < 0)
? -1
: (int64_t)((now_us - (uint64_t)st->last_frame_us) / 1000);
@@ -41,10 +55,10 @@ static esp_err_t state_get_handler(httpd_req_t *req)
st->viewport_name[0] ? cJSON_CreateString(st->viewport_name)
: cJSON_CreateNull());
cJSON_AddStringToObject(root, "version", VIEWPORT_VERSION);
- cJSON_AddBoolToObject(root, "configured", st->configured);
+ cJSON_AddBoolToObject (root, "configured", st->configured);
cJSON_AddStringToObject(root, "state", state_name(st->state));
cJSON_AddNumberToObject(root, "uptime_ms", (double)up_ms);
- cJSON_AddItemToObject(root, "last_frame_ms_ago",
+ cJSON_AddItemToObject (root, "last_frame_ms_ago",
last_age_ms < 0 ? cJSON_CreateNull()
: cJSON_CreateNumber((double)last_age_ms));
cJSON_AddNumberToObject(root, "frames_received", (double)st->frames_received);
@@ -69,25 +83,224 @@ static esp_err_t state_get_handler(httpd_req_t *req)
return err;
}
+// ============================================================================
+// GET /config
+// ============================================================================
+static esp_err_t config_get_handler(httpd_req_t *req)
+{
+ viewport_state_lock();
+ viewport_state_t *st = viewport_state_get();
+
+ cJSON *root = cJSON_CreateObject();
+ cJSON_AddItemToObject(root, "viewport",
+ st->viewport_name[0] ? cJSON_CreateString(st->viewport_name)
+ : cJSON_CreateNull());
+ cJSON_AddItemToObject(root, "scrypted",
+ st->scrypted_url[0] ? cJSON_CreateString(st->scrypted_url)
+ : cJSON_CreateNull());
+ cJSON_AddNumberToObject(root, "idle_timeout_ms", (double)st->idle_timeout_ms);
+ cJSON_AddStringToObject(root, "orientation", orientation_name(st->orientation));
+ cJSON_AddNumberToObject(root, "brightness", (double)st->brightness);
+
+ viewport_state_unlock();
+
+ char *body = cJSON_PrintUnformatted(root);
+ cJSON_Delete(root);
+ if (!body) return ESP_ERR_NO_MEM;
+
+ httpd_resp_set_type(req, "application/json");
+ esp_err_t err = httpd_resp_send(req, body, HTTPD_RESP_USE_STRLEN);
+ cJSON_free(body);
+ return err;
+}
+
+// ============================================================================
+// POST /config — partial-update, atomic, validated
+// ============================================================================
+static esp_err_t respond_400(httpd_req_t *req, const char *reason)
+{
+ ESP_LOGW(TAG, "/config 400: %s", reason);
+ httpd_resp_set_status(req, "400 Bad Request");
+ httpd_resp_set_type(req, "text/plain");
+ return httpd_resp_send(req, reason, HTTPD_RESP_USE_STRLEN);
+}
+
+static esp_err_t read_body(httpd_req_t *req, char *buf, size_t cap)
+{
+ if (req->content_len == 0 || req->content_len >= cap) return ESP_FAIL;
+ size_t got = 0;
+ while (got < req->content_len) {
+ int n = httpd_req_recv(req, buf + got, req->content_len - got);
+ if (n <= 0) return ESP_FAIL;
+ got += n;
+ }
+ buf[got] = '\0';
+ return ESP_OK;
+}
+
+static esp_err_t config_post_handler(httpd_req_t *req)
+{
+ char body[MAX_BODY_BYTES];
+ if (read_body(req, body, sizeof(body)) != ESP_OK)
+ return respond_400(req, "missing or oversized body");
+
+ cJSON *root = cJSON_Parse(body);
+ if (!root) return respond_400(req, "invalid JSON");
+
+ // Stage validated values; commit atomically at the end. Each "have_*"
+ // flag records whether the field was present in the request.
+ bool have_vp = false, have_sc = false, have_idle = false;
+ bool have_orient = false, have_bright = false;
+
+ char vp[64] = {0};
+ char sc[256] = {0};
+ uint32_t idle_ms = 0;
+ viewport_orientation_t orient = VIEWPORT_ORIENTATION_PORTRAIT;
+ uint8_t bright = 80;
+
+ cJSON *j;
+
+ if ((j = cJSON_GetObjectItemCaseSensitive(root, "viewport"))) {
+ if (!cJSON_IsString(j) || j->valuestring[0] == '\0' ||
+ strlen(j->valuestring) >= sizeof(vp)) {
+ cJSON_Delete(root);
+ return respond_400(req, "viewport must be a non-empty string < 64 chars");
+ }
+ strncpy(vp, j->valuestring, sizeof(vp) - 1);
+ have_vp = true;
+ }
+
+ if ((j = cJSON_GetObjectItemCaseSensitive(root, "scrypted"))) {
+ if (!cJSON_IsString(j) || strncmp(j->valuestring, "http://", 7) != 0 ||
+ strlen(j->valuestring) >= sizeof(sc)) {
+ cJSON_Delete(root);
+ return respond_400(req, "scrypted must be http://... and < 256 chars");
+ }
+ strncpy(sc, j->valuestring, sizeof(sc) - 1);
+ have_sc = true;
+ }
+
+ if ((j = cJSON_GetObjectItemCaseSensitive(root, "idle_timeout_ms"))) {
+ if (!cJSON_IsNumber(j) || j->valuedouble < 0 ||
+ j->valuedouble > 0xFFFFFFFFULL) {
+ cJSON_Delete(root);
+ return respond_400(req, "idle_timeout_ms must be a u32");
+ }
+ idle_ms = (uint32_t)j->valuedouble;
+ if (idle_ms != 0 && idle_ms < MIN_IDLE_TIMEOUT) {
+ cJSON_Delete(root);
+ return respond_400(req, "idle_timeout_ms must be 0 or >= 5000");
+ }
+ have_idle = true;
+ }
+
+ if ((j = cJSON_GetObjectItemCaseSensitive(root, "orientation"))) {
+ if (!cJSON_IsString(j)) {
+ cJSON_Delete(root);
+ return respond_400(req, "orientation must be a string");
+ }
+ if (strcmp(j->valuestring, "portrait") == 0) {
+ orient = VIEWPORT_ORIENTATION_PORTRAIT;
+ } else if (strcmp(j->valuestring, "landscape") == 0) {
+ orient = VIEWPORT_ORIENTATION_LANDSCAPE;
+ } else {
+ cJSON_Delete(root);
+ return respond_400(req, "orientation must be portrait or landscape");
+ }
+ have_orient = true;
+ }
+
+ if ((j = cJSON_GetObjectItemCaseSensitive(root, "brightness"))) {
+ if (!cJSON_IsNumber(j) || j->valuedouble < 0 || j->valuedouble > 100) {
+ cJSON_Delete(root);
+ return respond_400(req, "brightness must be 0..100");
+ }
+ bright = (uint8_t)j->valuedouble;
+ have_bright = true;
+ }
+
+ cJSON_Delete(root);
+
+ // Apply atomically.
+ bool brightness_changed = false;
+ bool name_or_orient_changed = false;
+
+ viewport_state_lock();
+ viewport_state_t *st = viewport_state_get();
+
+ if (have_vp) {
+ if (strcmp(st->viewport_name, vp) != 0) name_or_orient_changed = true;
+ strncpy(st->viewport_name, vp, sizeof(st->viewport_name) - 1);
+ }
+ if (have_sc) strncpy(st->scrypted_url, sc, sizeof(st->scrypted_url) - 1);
+ if (have_idle) st->idle_timeout_ms = idle_ms;
+ if (have_orient) {
+ if (st->orientation != orient) name_or_orient_changed = true;
+ st->orientation = orient;
+ }
+ if (have_bright) {
+ if (st->brightness != bright) brightness_changed = true;
+ st->brightness = bright;
+ }
+
+ // A configured device has both a viewport name and a scrypted URL.
+ if (st->viewport_name[0] && st->scrypted_url[0] && !st->configured) {
+ st->configured = true;
+ if (st->state == VIEWPORT_STATE_UNCONFIGURED) st->state = VIEWPORT_STATE_ASLEEP;
+ }
+
+ viewport_state_unlock();
+
+ esp_err_t save_err = nvs_config_save();
+ if (save_err != ESP_OK) {
+ ESP_LOGE(TAG, "nvs_config_save failed: %s", esp_err_to_name(save_err));
+ // Don't fail the request — config is applied in RAM and will reapply
+ // on next save. Caller can re-POST.
+ }
+
+ if (brightness_changed && display_is_up()) {
+ viewport_state_lock();
+ uint8_t b = viewport_state_get()->brightness;
+ viewport_state_unlock();
+ display_set_brightness(b);
+ }
+ if (name_or_orient_changed) {
+ mdns_service_refresh();
+ }
+
+ httpd_resp_set_status(req, "204 No Content");
+ return httpd_resp_send(req, NULL, 0);
+}
+
+// ============================================================================
+// Route table + start
+// ============================================================================
static const httpd_uri_t s_state_get = {
- .uri = "/state",
- .method = HTTP_GET,
- .handler = state_get_handler,
- .user_ctx = NULL,
+ .uri = "/state", .method = HTTP_GET, .handler = state_get_handler,
+};
+static const httpd_uri_t s_config_get = {
+ .uri = "/config", .method = HTTP_GET, .handler = config_get_handler,
+};
+static const httpd_uri_t s_config_post = {
+ .uri = "/config", .method = HTTP_POST, .handler = config_post_handler,
};
esp_err_t http_api_start(void)
{
httpd_config_t cfg = HTTPD_DEFAULT_CONFIG();
- cfg.server_port = 80;
+ cfg.server_port = 80;
cfg.max_uri_handlers = 8;
cfg.lru_purge_enable = true;
httpd_handle_t server = NULL;
ESP_RETURN_ON_ERROR(httpd_start(&server, &cfg), TAG, "httpd_start");
ESP_RETURN_ON_ERROR(httpd_register_uri_handler(server, &s_state_get),
- TAG, "register /state");
+ TAG, "register GET /state");
+ ESP_RETURN_ON_ERROR(httpd_register_uri_handler(server, &s_config_get),
+ TAG, "register GET /config");
+ ESP_RETURN_ON_ERROR(httpd_register_uri_handler(server, &s_config_post),
+ TAG, "register POST /config");
- ESP_LOGI(TAG, "http server listening on :80 (GET /state)");
+ ESP_LOGI(TAG, "http server listening on :80 (GET /state, GET/POST /config)");
return ESP_OK;
}
diff --git a/main/nvs_config.c b/main/nvs_config.c
new file mode 100644
index 0000000..164db53
--- /dev/null
+++ b/main/nvs_config.c
@@ -0,0 +1,119 @@
+#include "nvs_config.h"
+
+#include <string.h>
+
+#include "esp_log.h"
+#include "nvs.h"
+#include "nvs_flash.h"
+
+#include "viewport_state.h"
+
+static const char *TAG = "nvs_config";
+static const char *NS = "viewport";
+
+static const char *K_VIEWPORT = "viewport";
+static const char *K_SCRYPTED = "scrypted";
+static const char *K_IDLE_MS = "idle_ms";
+static const char *K_ORIENT = "orient"; // 0 = portrait, 1 = landscape
+static const char *K_BRIGHT = "bright";
+
+esp_err_t nvs_config_load(void)
+{
+ nvs_handle_t h;
+ esp_err_t err = nvs_open(NS, NVS_READONLY, &h);
+ if (err == ESP_ERR_NVS_NOT_FOUND) {
+ ESP_LOGI(TAG, "no saved config — first boot");
+ return ESP_OK;
+ }
+ if (err != ESP_OK) return err;
+
+ viewport_state_lock();
+ viewport_state_t *st = viewport_state_get();
+
+ size_t len = sizeof(st->viewport_name);
+ err = nvs_get_str(h, K_VIEWPORT, st->viewport_name, &len);
+ if (err != ESP_OK && err != ESP_ERR_NVS_NOT_FOUND) goto done;
+
+ len = sizeof(st->scrypted_url);
+ err = nvs_get_str(h, K_SCRYPTED, st->scrypted_url, &len);
+ if (err != ESP_OK && err != ESP_ERR_NVS_NOT_FOUND) goto done;
+
+ uint32_t u32 = 0;
+ err = nvs_get_u32(h, K_IDLE_MS, &u32);
+ if (err == ESP_OK) st->idle_timeout_ms = u32;
+ else if (err != ESP_ERR_NVS_NOT_FOUND) goto done;
+
+ uint8_t u8 = 0;
+ err = nvs_get_u8(h, K_ORIENT, &u8);
+ if (err == ESP_OK) {
+ st->orientation = (u8 == 1) ? VIEWPORT_ORIENTATION_LANDSCAPE
+ : VIEWPORT_ORIENTATION_PORTRAIT;
+ } else if (err != ESP_ERR_NVS_NOT_FOUND) {
+ goto done;
+ }
+
+ err = nvs_get_u8(h, K_BRIGHT, &u8);
+ if (err == ESP_OK) st->brightness = u8;
+ else if (err != ESP_ERR_NVS_NOT_FOUND) goto done;
+
+ err = ESP_OK;
+
+ // A device is "configured" only once both name and Scrypted URL are set.
+ if (st->viewport_name[0] && st->scrypted_url[0]) {
+ st->configured = true;
+ st->state = VIEWPORT_STATE_ASLEEP; // configured devices boot asleep
+ ESP_LOGI(TAG, "loaded config: viewport=%s scrypted=%s "
+ "idle_ms=%u orient=%s bright=%u",
+ st->viewport_name, st->scrypted_url,
+ (unsigned)st->idle_timeout_ms,
+ st->orientation == VIEWPORT_ORIENTATION_LANDSCAPE
+ ? "landscape" : "portrait",
+ st->brightness);
+ } else {
+ ESP_LOGI(TAG, "partial config in NVS — staying unconfigured");
+ }
+
+done:
+ viewport_state_unlock();
+ nvs_close(h);
+ return err;
+}
+
+esp_err_t nvs_config_save(void)
+{
+ nvs_handle_t h;
+ esp_err_t err = nvs_open(NS, NVS_READWRITE, &h);
+ if (err != ESP_OK) return err;
+
+ viewport_state_lock();
+ viewport_state_t *st = viewport_state_get();
+
+ if ((err = nvs_set_str(h, K_VIEWPORT, st->viewport_name)) != ESP_OK) goto done;
+ if ((err = nvs_set_str(h, K_SCRYPTED, st->scrypted_url)) != ESP_OK) goto done;
+ if ((err = nvs_set_u32(h, K_IDLE_MS, st->idle_timeout_ms)) != ESP_OK) goto done;
+ if ((err = nvs_set_u8 (h, K_ORIENT,
+ (st->orientation == VIEWPORT_ORIENTATION_LANDSCAPE)
+ ? 1 : 0)) != ESP_OK) goto done;
+ if ((err = nvs_set_u8 (h, K_BRIGHT, st->brightness)) != ESP_OK) goto done;
+
+ err = nvs_commit(h);
+
+done:
+ viewport_state_unlock();
+ nvs_close(h);
+ return err;
+}
+
+esp_err_t nvs_config_reset(void)
+{
+ nvs_handle_t h;
+ esp_err_t err = nvs_open(NS, NVS_READWRITE, &h);
+ if (err == ESP_ERR_NVS_NOT_FOUND) return ESP_OK;
+ if (err != ESP_OK) return err;
+
+ nvs_erase_all(h);
+ err = nvs_commit(h);
+ nvs_close(h);
+ ESP_LOGI(TAG, "NVS config cleared");
+ return err;
+}
diff --git a/main/nvs_config.h b/main/nvs_config.h
new file mode 100644
index 0000000..7688d81
--- /dev/null
+++ b/main/nvs_config.h
@@ -0,0 +1,16 @@
+#pragma once
+
+#include "esp_err.h"
+
+// Read the persisted config into viewport_state. Safe to call on a fresh
+// device: missing keys keep their first-boot defaults from viewport_state_init.
+// Sets state = ASLEEP and configured = true if a viewport name + scrypted URL
+// are both present.
+esp_err_t nvs_config_load(void);
+
+// Persist the current viewport_state to NVS atomically. The caller is expected
+// to have already mutated viewport_state under viewport_state_lock().
+esp_err_t nvs_config_save(void);
+
+// Clear all persisted config. Caller is responsible for rebooting.
+esp_err_t nvs_config_reset(void);