diff options
| -rw-r--r-- | TESTING.md | 284 | ||||
| -rw-r--r-- | main/CMakeLists.txt | 2 | ||||
| -rw-r--r-- | main/app_main.c | 15 | ||||
| -rw-r--r-- | main/http_api.c | 93 | ||||
| -rw-r--r-- | main/http_api.h | 5 | ||||
| -rw-r--r-- | main/idf_component.yml | 17 | ||||
| -rw-r--r-- | main/mdns_service.c | 65 | ||||
| -rw-r--r-- | main/mdns_service.h | 9 | ||||
| -rw-r--r-- | main/viewport_state.c | 46 | ||||
| -rw-r--r-- | main/viewport_state.h | 49 |
10 files changed, 579 insertions, 6 deletions
diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..dde0cdc --- /dev/null +++ b/TESTING.md @@ -0,0 +1,284 @@ +# Testing & Verification + +This file tracks which milestones have been verified on hardware and how. Per-milestone entries below each list: + +- **Acceptance** β what "done" means (mirrors the impl guide). +- **How to verify** β exact commands or actions. +- **Status** β β¬ not verified / π‘ partial / β
verified, with a one-line note (date, board rev, anything weird). + +Hardware-required milestones can't be verified by a clean build alone β a checked-in build success counts as π‘ (compiles, not yet run). + +--- + +## M1 β Board Bring-Up + +**Acceptance**: device gets a DHCP lease over Ethernet and logs its IP. + +**How to verify** + +```bash +source ~/Dev/code/git/esp32/env.sh +idf.py -p /dev/cu.usbmodem* flash monitor +``` + +Expected log within ~5β10s of boot: + +``` +I (xxx) viewport: Scrypted Viewport boot +I (xxx) net_eth: ethernet driver started, waiting for link + DHCP +I (xxx) net_eth: link up, mac xx:xx:xx:xx:xx:xx +I (xxx) net_eth: got ip 192.168.x.x gw 192.168.x.1 netmask 255.255.255.0 +I (xxx) viewport: online at 192.168.x.x +``` + +Cross-check from another host: + +```bash +ping 192.168.x.x +``` + +**Status**: π‘ builds clean against ESP-IDF 5.4 (commit e2ac22e). Not yet flashed to hardware β awaiting first board run. + +> Combined hardware verification of M1+M2 is fine β they both run from the same flash session. + +--- + +## M2 β HTTP + mDNS + +**Acceptance**: `GET /state` returns JSON; mDNS service is discoverable. + +**How to verify** + +After flash, from a host on the same LAN: + +```bash +# Direct call by IP (always works): +curl http://<device-ip>/state | jq . + +# mDNS hostname (requires OS-level .local resolution: macOS Bonjour, Linux nss-mdns): +curl http://viewport.local/state | jq . + +# Service discovery browse (preferred β what Scrypted will do, no OS .local resolver needed): +dns-sd -B _scrypted-viewport._tcp local. # macOS +avahi-browse -r _scrypted-viewport._tcp # Linux +``` + +Expected `/state` body on a fresh device (unconfigured): + +```json +{ + "name": null, + "version": "0.1.0", + "configured": false, + "state": "unconfigured", + "uptime_ms": 12345, + "last_frame_ms_ago": null, + "frames_received": 0, + "decode_errors": 0, + "state_post_failures": 0, + "resolution": "480x800", + "ip": "192.168.x.x", + "free_heap": 200000, + "free_psram": 30000000 +} +``` + +Expected mDNS browse output should show a `_scrypted-viewport._tcp` instance with TXT records `version=`, `resolution=`, `orientation=`, `name=` (empty for `name`). + +**Status**: π‘ builds clean against ESP-IDF 5.4 with espressif/mdns managed component. Awaiting first board run. + +--- + +## M3 β Display Bring-Up + +**Acceptance**: panel shows a test pattern; backlight toggles via API. + +**How to verify**: TBD (write after M3 implementation). + +**Status**: β¬ pending. + +--- + +## M4 β Config Persistence + +**Acceptance**: `POST /config` persists across reboot; partial-update semantics work. + +**How to verify** + +```bash +# Full config: +curl -X POST -H "Content-Type: application/json" \ + -d '{"viewport":"mudroom","scrypted":"http://host/endpoint/scrypted-viewport","orientation":"landscape"}' \ + http://<device-ip>/config + +# Read back: +curl http://<device-ip>/config | jq . + +# Partial update (only brightness): +curl -X POST -H "Content-Type: application/json" \ + -d '{"brightness":50}' \ + http://<device-ip>/config + +# Reboot, then re-read β values should survive: +curl http://<device-ip>/config | jq . +``` + +Also verify validation: `idle_timeout_ms: 1000` (below 5000) returns 400; `orientation: "sideways"` returns 400; etc. + +**Status**: β¬ pending. + +--- + +## M5 β JPEG Frame Push + +**Acceptance**: `POST /frame` paints an 800x480 (or 480x800 portrait) JPEG. + +**How to verify** + +```bash +# After /config sets state=wake (M6 dep β for now test post-/wake or hardcode awake): +curl -X POST -H "Content-Type: image/jpeg" \ + --data-binary @test-480x800.jpg \ + http://<device-ip>/frame +``` + +Visual: matching pattern appears on the panel. Re-test in landscape orientation with `test-800x480.jpg`. + +Negative tests: +- Non-JPEG body β 400. +- 1.1 MB body β 413. +- 640x480 body (wrong size) β 400 or visible distortion (depends on decoder strictness). + +**Status**: β¬ pending. + +--- + +## M6 β State + Idle Timer + +**Acceptance**: `POST /state` toggles wake/sleep; `/frame` is 409 when asleep; idle timer fires after `idle_timeout_ms`. + +**How to verify** + +```bash +curl -X POST -d '{"state":"sleep"}' http://<device-ip>/state # backlight off +curl -X POST -d '{"state":"wake"}' http://<device-ip>/state # backlight on, loading + +# /frame while asleep: +curl -X POST -d '{"state":"sleep"}' http://<device-ip>/state +curl -i -X POST -H "Content-Type: image/jpeg" \ + --data-binary @test-480x800.jpg \ + http://<device-ip>/frame +# expect: HTTP/1.1 409 Conflict + +# Idle timer (assuming default 60000ms): +curl -X POST -d '{"state":"wake"}' http://<device-ip>/state +# wait 65s without sending /frame +curl http://<device-ip>/state | jq .state +# expect: "asleep" +``` + +**Status**: β¬ pending. + +--- + +## M7 β Touch + Outbound `/state` POST + +**Acceptance**: tap toggles wake/sleep locally; device POSTs `{viewport,state}` to `<scrypted>/state`. + +**How to verify** + +Stand up a test HTTP receiver: + +```bash +# In a separate terminal on Scrypted host: +python3 -m http.server 11080 # or a small flask app that logs body +``` + +Configure the device to point at it: + +```bash +curl -X POST -H "Content-Type: application/json" \ + -d '{"viewport":"mudroom","scrypted":"http://<host>:11080"}' \ + http://<device-ip>/config +``` + +Then: +- Tap while asleep β device wakes, receiver logs `POST /state {"viewport":"mudroom","state":"wake"}`. +- Tap while awake β device sleeps, receiver logs `POST /state {"viewport":"mudroom","state":"sleep"}`. +- Wait `idle_timeout_ms` with no `/frame` β receiver logs same sleep POST. +- Kill receiver, tap β `/state` `state_post_failures` increments. + +**Status**: β¬ pending. + +--- + +## M8 β Local Screens + BOOT button + +**Acceptance**: IP screen on first boot; loading screen on every wake; BOOT button works. + +**How to verify** + +- Fresh flash β screen shows `viewport.local` and the device IP centered. +- `POST /config` β IP screen clears; backlight off (device enters sleep). +- Tap while asleep β "Loadingβ¦" screen until next `/frame`. +- BOOT short-press (any state) β IP screen overlays for 15s, then prior state restored. +- BOOT 5s-hold β NVS clears, device reboots, IP screen reappears. + +**Status**: β¬ pending. + +--- + +## M9 β Live Stream (`POST /stream`) + +**Acceptance**: β₯ 10 fps multipart MJPEG over a single chunked POST. + +**How to verify**: ffmpeg-driven test stream from a host, measure fps from the device's frame counter. + +**Status**: β¬ pending. + +--- + +## Integration & system tests (post-M9) + +Run these once all milestones are implemented and individually verified. The point is to exercise races, edge cases, and longevity that single-milestone tests miss. + +### A. End-to-end with Scrypted + +- Real Scrypted-side Script (M7's scope): doorbell-event β `/state {wake}` β frame stream β idle timer fires β `/state {sleep}` callback β Scrypted stops. +- Bound camera switching: change the binding in Scrypted, confirm next wake routes to the new camera. + +### B. Race conditions + +- Concurrent tap-while-Scrypted-POSTs-`/state`: spam both, confirm the device converges (mutex serializes; last-write-wins). Use the wake/sleep counters in `/state` to track transitions. +- Mid-stream tap-to-sleep: while Scrypted is streaming frames, tap to sleep. Inflight `/frame` should return 409. Confirm no half-painted frames, no re-wake. +- Idle-timeout coincides with a fresh tap-wake: the second event wins. Verify by repeating with timing jitter. +- Stale Scrypted sleep timer races a fresh wake callback: Scrypted-side `cancelPendingSleep` must work β if a stale sleep lands after a wake, viewport sleeps; user taps again; recovery in one extra tap. + +### C. Failure modes + +- Cable pull mid-frame: device idle-sleeps after `idle_timeout_ms`. On reconnect, mDNS re-advertises; Scrypted re-finds and continues. +- Scrypted unreachable on tap: device still toggles backlight, `state_post_failures` increments. Recovery: Scrypted comes back, next tap syncs. +- DHCP lease change: device gets new IP, re-advertises via mDNS. Scrypted's periodic browse picks up the new address. +- `/state_post_failures` count should be observable via `GET /state`. + +### D. Longevity + +- 24h soak with a slow camera stream (~1 frame every 10s): verify no memory leaks (`free_heap` / `free_psram` stable in `/state`), no decode_errors growing. +- Frame storm: 30 minutes at max sustainable fps. Verify watchdog never fires, no resets. + +### E. Power & boot + +- PoE injector cycle: device boots clean, gets DHCP, re-registers via mDNS, no manual intervention needed. +- Brown-out (PoE on edge of spec): device should reboot cleanly, not corrupt NVS. + +### F. Negative protocol + +- Garbage JSON to `/config` β 400. +- `/frame` with `Content-Type: image/png` β 400. +- `/state` with `{"state":"middle"}` β 400. +- `/frame` with body > 1 MB β 413. +- Two concurrent `/frame` posts β one wins, the other 503. + +### G. Multi-viewport + +Once at least two physical units are configured: confirm each routes its callback to Scrypted with its own `viewport` name; confirm Scrypted's discovery map has both IPs; confirm a camera event on the wrong-named viewport is correctly ignored on the right one. diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index ee5cb24..ad43a53 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -1,5 +1,5 @@ idf_component_register( SRC_DIRS "." INCLUDE_DIRS "." - REQUIRES esp_eth esp_event esp_netif nvs_flash + REQUIRES esp_eth esp_event esp_netif esp_http_server esp_timer json mdns nvs_flash ) diff --git a/main/app_main.c b/main/app_main.c index 8b91537..9389642 100644 --- a/main/app_main.c +++ b/main/app_main.c @@ -1,4 +1,7 @@ +#include "http_api.h" +#include "mdns_service.h" #include "net_eth.h" +#include "viewport_state.h" #include "esp_event.h" #include "esp_log.h" @@ -13,7 +16,8 @@ void app_main(void) ESP_ERROR_CHECK(esp_netif_init()); ESP_ERROR_CHECK(esp_event_loop_create_default()); - ESP_LOGI(TAG, "Scrypted Viewport boot"); + viewport_state_init(); + ESP_LOGI(TAG, "Scrypted Viewport boot (v%s)", VIEWPORT_VERSION); ESP_ERROR_CHECK(net_eth_init()); if (net_eth_wait_for_ip(30 * 1000) == ESP_OK) { @@ -22,12 +26,13 @@ void app_main(void) ESP_LOGW(TAG, "no DHCP lease after 30s, will keep retrying in the background"); } - // TODO M2: mDNS _scrypted-viewport._tcp.local - // TODO M2: HTTP server: GET /state + ESP_ERROR_CHECK(mdns_service_start()); + ESP_ERROR_CHECK(http_api_start()); + // TODO M3: MIPI-DSI panel init (800x480 IPS, default portrait 480x800) - // TODO M4: /config persistence (NVS) + // TODO M4: /config persistence (NVS) + GET /config + POST /config // TODO M5: /frame JPEG decode -> framebuffer - // TODO M6: /state POST + idle timer + // TODO M6: POST /state + idle timer // TODO M7: Capacitive touch -> outbound /state POST // TODO M8: Local screens (IP, loading) + BOOT button } diff --git a/main/http_api.c b/main/http_api.c new file mode 100644 index 0000000..4c0008d --- /dev/null +++ b/main/http_api.c @@ -0,0 +1,93 @@ +#include "http_api.h" + +#include <inttypes.h> +#include <stdio.h> +#include <string.h> + +#include "cJSON.h" +#include "esp_check.h" +#include "esp_heap_caps.h" +#include "esp_http_server.h" +#include "esp_log.h" +#include "esp_timer.h" + +#include "net_eth.h" +#include "viewport_state.h" + +static const char *TAG = "http_api"; + +static const char *state_name(viewport_run_state_t s) +{ + switch (s) { + case VIEWPORT_STATE_AWAKE: return "awake"; + case VIEWPORT_STATE_ASLEEP: return "asleep"; + default: return "unconfigured"; + } +} + +static esp_err_t state_get_handler(httpd_req_t *req) +{ + viewport_state_lock(); + viewport_state_t *st = viewport_state_get(); + + 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) + ? -1 + : (int64_t)((now_us - (uint64_t)st->last_frame_us) / 1000); + + cJSON *root = cJSON_CreateObject(); + cJSON_AddItemToObject(root, "name", + st->viewport_name[0] ? cJSON_CreateString(st->viewport_name) + : cJSON_CreateNull()); + cJSON_AddStringToObject(root, "version", VIEWPORT_VERSION); + 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", + last_age_ms < 0 ? cJSON_CreateNull() + : cJSON_CreateNumber((double)last_age_ms)); + cJSON_AddNumberToObject(root, "frames_received", (double)st->frames_received); + cJSON_AddNumberToObject(root, "decode_errors", (double)st->decode_errors); + cJSON_AddNumberToObject(root, "state_post_failures", (double)st->state_post_failures); + cJSON_AddStringToObject(root, "resolution", viewport_state_resolution_str()); + cJSON_AddStringToObject(root, "ip", net_eth_get_ip_str()); + cJSON_AddNumberToObject(root, "free_heap", + (double)heap_caps_get_free_size(MALLOC_CAP_INTERNAL)); + cJSON_AddNumberToObject(root, "free_psram", + (double)heap_caps_get_free_size(MALLOC_CAP_SPIRAM)); + + 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; +} + +static const httpd_uri_t s_state_get = { + .uri = "/state", + .method = HTTP_GET, + .handler = state_get_handler, + .user_ctx = NULL, +}; + +esp_err_t http_api_start(void) +{ + httpd_config_t cfg = HTTPD_DEFAULT_CONFIG(); + 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"); + + ESP_LOGI(TAG, "http server listening on :80 (GET /state)"); + return ESP_OK; +} diff --git a/main/http_api.h b/main/http_api.h new file mode 100644 index 0000000..8d1a9ae --- /dev/null +++ b/main/http_api.h @@ -0,0 +1,5 @@ +#pragma once + +#include "esp_err.h" + +esp_err_t http_api_start(void); diff --git a/main/idf_component.yml b/main/idf_component.yml new file mode 100644 index 0000000..e5dcd75 --- /dev/null +++ b/main/idf_component.yml @@ -0,0 +1,17 @@ +## IDF Component Manager Manifest File +dependencies: + ## Required IDF version + idf: + version: '>=4.1.0' + # # Put list of dependencies here + # # For components maintained by Espressif: + # component: "~1.0.0" + # # For 3rd party components: + # username/component: ">=1.0.0,<2.0.0" + # username2/component2: + # version: "~1.0.0" + # # For transient dependencies `public` flag can be set. + # # `public` flag doesn't have an effect dependencies of the `main` component. + # # All dependencies of `main` are public by default. + # public: true + espressif/mdns: '*' diff --git a/main/mdns_service.c b/main/mdns_service.c new file mode 100644 index 0000000..761ade5 --- /dev/null +++ b/main/mdns_service.c @@ -0,0 +1,65 @@ +#include "mdns_service.h" + +#include <stdio.h> +#include <string.h> + +#include "esp_check.h" +#include "esp_log.h" +#include "mdns.h" + +#include "viewport_state.h" + +static const char *TAG = "mdns"; +static const char *SERVICE = "_scrypted-viewport"; +static const char *PROTO = "_tcp"; +static const uint16_t PORT = 80; + +static esp_err_t apply_records(void) +{ + viewport_state_lock(); + viewport_state_t *st = viewport_state_get(); + + char hostname[80]; + if (st->viewport_name[0]) { + snprintf(hostname, sizeof(hostname), "viewport-%s", st->viewport_name); + } else { + snprintf(hostname, sizeof(hostname), "viewport"); + } + + const char *resolution = viewport_state_resolution_str(); + const char *orientation = (st->orientation == VIEWPORT_ORIENTATION_PORTRAIT) + ? "portrait" : "landscape"; + const char *name_value = st->viewport_name[0] ? st->viewport_name : ""; + + viewport_state_unlock(); + + mdns_txt_item_t txt[] = { + { "version", VIEWPORT_VERSION }, + { "resolution", resolution }, + { "orientation", orientation }, + { "name", name_value }, + }; + const size_t txt_count = sizeof(txt) / sizeof(txt[0]); + + ESP_RETURN_ON_ERROR(mdns_hostname_set(hostname), TAG, "hostname_set"); + ESP_RETURN_ON_ERROR(mdns_service_txt_set(SERVICE, PROTO, txt, txt_count), + TAG, "txt_set"); + + ESP_LOGI(TAG, "advertising %s.local on %s.%s.local:%u", + hostname, SERVICE, PROTO, PORT); + return ESP_OK; +} + +esp_err_t mdns_service_start(void) +{ + ESP_RETURN_ON_ERROR(mdns_init(), TAG, "mdns_init"); + ESP_RETURN_ON_ERROR( + mdns_service_add(NULL, SERVICE, PROTO, PORT, NULL, 0), + TAG, "service_add"); + return apply_records(); +} + +esp_err_t mdns_service_refresh(void) +{ + return apply_records(); +} diff --git a/main/mdns_service.h b/main/mdns_service.h new file mode 100644 index 0000000..1e7eb12 --- /dev/null +++ b/main/mdns_service.h @@ -0,0 +1,9 @@ +#pragma once + +#include "esp_err.h" + +esp_err_t mdns_service_start(void); + +// Update the mDNS hostname and TXT records to reflect the current +// viewport name / orientation. Call after /config writes either field. +esp_err_t mdns_service_refresh(void); diff --git a/main/viewport_state.c b/main/viewport_state.c new file mode 100644 index 0000000..9115ce3 --- /dev/null +++ b/main/viewport_state.c @@ -0,0 +1,46 @@ +#include "viewport_state.h" + +#include <string.h> + +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" + +static viewport_state_t s_state; +static SemaphoreHandle_t s_mutex; + +void viewport_state_init(void) +{ + memset(&s_state, 0, sizeof(s_state)); + s_state.configured = false; + s_state.state = VIEWPORT_STATE_UNCONFIGURED; + s_state.brightness = 80; + s_state.idle_timeout_ms = 60000; + s_state.orientation = VIEWPORT_ORIENTATION_PORTRAIT; + s_state.boot_us = (uint64_t)esp_timer_get_time(); + s_state.last_frame_us = -1; + + s_mutex = xSemaphoreCreateMutex(); +} + +viewport_state_t *viewport_state_get(void) +{ + return &s_state; +} + +void viewport_state_lock(void) +{ + xSemaphoreTake(s_mutex, portMAX_DELAY); +} + +void viewport_state_unlock(void) +{ + xSemaphoreGive(s_mutex); +} + +const char *viewport_state_resolution_str(void) +{ + return (s_state.orientation == VIEWPORT_ORIENTATION_PORTRAIT) + ? "480x800" + : "800x480"; +} diff --git a/main/viewport_state.h b/main/viewport_state.h new file mode 100644 index 0000000..b580ff7 --- /dev/null +++ b/main/viewport_state.h @@ -0,0 +1,49 @@ +#pragma once + +#include <stdbool.h> +#include <stdint.h> + +#define VIEWPORT_VERSION "0.1.0" +#define VIEWPORT_PANEL_WIDTH 800 +#define VIEWPORT_PANEL_HEIGHT 480 + +typedef enum { + VIEWPORT_ORIENTATION_PORTRAIT = 0, // effective: 480x800 + VIEWPORT_ORIENTATION_LANDSCAPE, // effective: 800x480 +} viewport_orientation_t; + +typedef enum { + VIEWPORT_STATE_UNCONFIGURED = 0, + VIEWPORT_STATE_AWAKE, + VIEWPORT_STATE_ASLEEP, +} viewport_run_state_t; + +// Read-mostly snapshot of device state used by /state. Counters are +// updated under a mutex by the modules that own them. +typedef struct { + char viewport_name[64]; // empty before /config + char scrypted_url[256]; // empty before /config + bool configured; + viewport_run_state_t state; + + uint8_t brightness; // 0β100, default 80 + uint32_t idle_timeout_ms; // 0 = disabled, else >= 5000, default 60000 + viewport_orientation_t orientation; // default portrait + + uint64_t boot_us; // esp_timer_get_time() at boot + int64_t last_frame_us; // -1 if no frame received yet + + uint64_t frames_received; + uint64_t decode_errors; + uint64_t state_post_failures; +} viewport_state_t; + +void viewport_state_init(void); +viewport_state_t *viewport_state_get(void); +void viewport_state_lock(void); +void viewport_state_unlock(void); + +// Returns the effective resolution string ("480x800" or "800x480") based +// on the current orientation. Caller-owned static storage; safe to read +// without the lock (atomic snapshot). +const char *viewport_state_resolution_str(void); |
