diff options
Diffstat (limited to 'main')
| -rw-r--r-- | main/app_main.c | 7 | ||||
| -rw-r--r-- | main/http_api.c | 233 | ||||
| -rw-r--r-- | main/nvs_config.c | 119 | ||||
| -rw-r--r-- | main/nvs_config.h | 16 |
4 files changed, 362 insertions, 13 deletions
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); |
