src.nth.io/

summaryrefslogtreecommitdiff
path: root/main/http_api.c
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 /main/http_api.c
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.
Diffstat (limited to 'main/http_api.c')
-rw-r--r--main/http_api.c233
1 files changed, 223 insertions, 10 deletions
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;
}