src.nth.io/

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--TESTING.md59
-rw-r--r--main/CMakeLists.txt4
-rw-r--r--main/app_main.c8
-rw-r--r--main/display.c38
-rw-r--r--main/display.h9
-rw-r--r--main/http_api.c108
-rw-r--r--main/jpeg_decoder.c97
-rw-r--r--main/jpeg_decoder.h33
8 files changed, 345 insertions, 11 deletions
diff --git a/TESTING.md b/TESTING.md
index dd17d94..e5f156c 100644
--- a/TESTING.md
+++ b/TESTING.md
@@ -201,25 +201,70 @@ Side-effects to confirm:
## M5 — JPEG Frame Push
-**Acceptance**: `POST /frame` paints an 800x480 (or 480x800 portrait) JPEG.
+**Acceptance**: `POST /frame` paints a 480x800 (portrait, default) or 800x480 (landscape) JPEG. Orientation determines the expected dimensions and the panel-side rotation.
**How to verify**
```bash
-# After /config sets state=wake (M6 dep — for now test post-/wake or hardcode awake):
+# Default orientation (portrait) — send 480x800:
curl -X POST -H "Content-Type: image/jpeg" \
--data-binary @test-480x800.jpg \
http://<device-ip>/frame
+
+# Then switch to landscape and re-test with an 800x480 JPEG:
+curl -X POST -H "Content-Type: application/json" \
+ -d '{"orientation":"landscape"}' http://<device-ip>/config
+
+curl -X POST -H "Content-Type: image/jpeg" \
+ --data-binary @test-800x480.jpg \
+ http://<device-ip>/frame
```
-Visual: matching pattern appears on the panel. Re-test in landscape orientation with `test-800x480.jpg`.
+Visual: image fills the panel correctly oriented. After paint, `GET /state` shows `frames_received` incremented and `last_frame_ms_ago` populated.
+
+Generate test JPEGs with ImageMagick:
+
+```bash
+# 8 vertical color bars at 480x800 portrait:
+convert -size 60x800 gradient:white-black -duplicate 7 +append test-480x800.jpg
+# or just any 480x800 JPEG:
+convert -size 480x800 plasma: test-480x800.jpg
+convert -size 800x480 plasma: 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.
+```bash
+# Wrong Content-Type — expect 400:
+curl -i -X POST -H "Content-Type: image/png" --data-binary @anything \
+ http://<device-ip>/frame
+
+# Oversize — expect 413:
+dd if=/dev/urandom of=big.bin bs=1M count=2
+curl -i -X POST -H "Content-Type: image/jpeg" --data-binary @big.bin \
+ http://<device-ip>/frame
+
+# Wrong dimensions — expect 400 with "expected WxH, got WxH":
+convert -size 640x480 plasma: test-640x480.jpg
+curl -i -X POST -H "Content-Type: image/jpeg" --data-binary @test-640x480.jpg \
+ http://<device-ip>/frame
+
+# Concurrent posts — second gets 503:
+curl -X POST -H "Content-Type: image/jpeg" --data-binary @test-480x800.jpg \
+ http://<device-ip>/frame &
+curl -i -X POST -H "Content-Type: image/jpeg" --data-binary @test-480x800.jpg \
+ http://<device-ip>/frame # expect 503 if first is still in flight
+
+# Garbage bytes claiming to be JPEG — expect 400:
+curl -i -X POST -H "Content-Type: image/jpeg" -d 'not a jpeg' \
+ http://<device-ip>/frame
+```
+
+After every error: `decode_errors` in `GET /state` should increment.
+
+**Known gap (M6 closes this)**: M5 paints regardless of wake/sleep state. The `/frame` → `409` when asleep rule is added with `POST /state` in M6. Until then, a configured device boots ASLEEP per spec but `/frame` still paints because the state guard isn't wired yet.
+
+**Status**: 🟡 builds clean against ESP-IDF 5.4. Awaiting hardware verification (the test pattern from M3 confirms paint works; M5 adds the JPEG path).
---
diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt
index 90a4c83..e3268e2 100644
--- a/main/CMakeLists.txt
+++ b/main/CMakeLists.txt
@@ -1,6 +1,6 @@
idf_component_register(
SRC_DIRS "."
INCLUDE_DIRS "."
- REQUIRES driver esp_driver_i2c esp_eth esp_event esp_http_server
- esp_lcd esp_netif esp_timer json mdns nvs_flash
+ REQUIRES driver esp_driver_i2c esp_driver_jpeg esp_eth esp_event
+ esp_http_server esp_lcd esp_netif esp_timer json mdns nvs_flash
)
diff --git a/main/app_main.c b/main/app_main.c
index 316f702..4ac925d 100644
--- a/main/app_main.c
+++ b/main/app_main.c
@@ -1,5 +1,6 @@
#include "display.h"
#include "http_api.h"
+#include "jpeg_decoder.h"
#include "mdns_service.h"
#include "net_eth.h"
#include "nvs_config.h"
@@ -41,7 +42,12 @@ void app_main(void)
ESP_LOGW(TAG, "display init failed — continuing without panel");
}
- // TODO M5: /frame JPEG decode -> framebuffer
+ // JPEG decoder is the M5 dependency for POST /frame. Best-effort:
+ // if it fails (e.g. hardware not present in sim) /frame just returns 500.
+ if (jpeg_decoder_init() != ESP_OK) {
+ ESP_LOGW(TAG, "jpeg decoder init failed — /frame will be unavailable");
+ }
+
// 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/display.c b/main/display.c
index a1a64fd..21e8e5e 100644
--- a/main/display.c
+++ b/main/display.c
@@ -249,6 +249,44 @@ esp_err_t display_fill(uint16_t rgb565)
PANEL_H_ACTIVE, PANEL_V_ACTIVE, fb);
}
+esp_err_t display_present_rgb565(const uint16_t *src,
+ uint16_t src_w,
+ uint16_t src_h)
+{
+ if (!s_up) return ESP_ERR_INVALID_STATE;
+
+ viewport_state_lock();
+ viewport_orientation_t orient = viewport_state_get()->orientation;
+ viewport_state_unlock();
+
+ void *fb = NULL;
+ esp_err_t err = esp_lcd_dpi_panel_get_frame_buffer(s_panel, 1, &fb);
+ if (err != ESP_OK) return err;
+ uint16_t *dst = (uint16_t *)fb;
+
+ if (orient == VIEWPORT_ORIENTATION_LANDSCAPE) {
+ if (src_w != PANEL_H_ACTIVE || src_h != PANEL_V_ACTIVE)
+ return ESP_ERR_INVALID_SIZE;
+ memcpy(dst, src, (size_t)src_w * src_h * sizeof(uint16_t));
+ } else {
+ // Portrait: src is 480x800, rotate 90° CW into the 800x480 panel.
+ // dst dims = src_h x src_w. dst_stride = src_h.
+ // src(x,y) -> dst(x, src_h - 1 - y)
+ if (src_w != PANEL_V_ACTIVE || src_h != PANEL_H_ACTIVE)
+ return ESP_ERR_INVALID_SIZE;
+ const uint16_t dst_stride = src_h;
+ for (uint16_t y = 0; y < src_h; ++y) {
+ const uint16_t *srow = src + (size_t)y * src_w;
+ const uint16_t dst_col = (uint16_t)(src_h - 1 - y);
+ for (uint16_t x = 0; x < src_w; ++x) {
+ dst[(size_t)x * dst_stride + dst_col] = srow[x];
+ }
+ }
+ }
+ return esp_lcd_panel_draw_bitmap(s_panel, 0, 0,
+ PANEL_H_ACTIVE, PANEL_V_ACTIVE, fb);
+}
+
esp_err_t display_test_pattern(void)
{
if (!s_up) return ESP_ERR_INVALID_STATE;
diff --git a/main/display.h b/main/display.h
index 58e71b7..e26845f 100644
--- a/main/display.h
+++ b/main/display.h
@@ -28,3 +28,12 @@ esp_err_t display_fill(uint16_t rgb565);
// Show a deterministic test pattern (vertical color bars). M3 acceptance.
esp_err_t display_test_pattern(void);
+
+// Blit an RGB565 source image to the panel, applying the current
+// orientation. Source dimensions must match the effective resolution:
+// portrait -> src is 480x800 (rotated 90° CW into the 800x480 panel)
+// landscape -> src is 800x480 (copied 1:1)
+// Used by /frame after JPEG decode.
+esp_err_t display_present_rgb565(const uint16_t *src,
+ uint16_t src_w,
+ uint16_t src_h);
diff --git a/main/http_api.c b/main/http_api.c
index 7e97407..8bf5f81 100644
--- a/main/http_api.c
+++ b/main/http_api.c
@@ -12,6 +12,7 @@
#include "esp_timer.h"
#include "display.h"
+#include "jpeg_decoder.h"
#include "mdns_service.h"
#include "net_eth.h"
#include "nvs_config.h"
@@ -273,6 +274,105 @@ static esp_err_t config_post_handler(httpd_req_t *req)
}
// ============================================================================
+// POST /frame
+// ============================================================================
+static esp_err_t respond_status(httpd_req_t *req, const char *status, const char *body)
+{
+ httpd_resp_set_status(req, status);
+ httpd_resp_set_type(req, "text/plain");
+ return httpd_resp_send(req, body, body ? HTTPD_RESP_USE_STRLEN : 0);
+}
+
+static void expected_dims(uint16_t *w, uint16_t *h)
+{
+ viewport_state_lock();
+ bool portrait = (viewport_state_get()->orientation == VIEWPORT_ORIENTATION_PORTRAIT);
+ viewport_state_unlock();
+ if (portrait) { *w = 480; *h = 800; }
+ else { *w = 800; *h = 480; }
+}
+
+static esp_err_t frame_post_handler(httpd_req_t *req)
+{
+ // Content-Type must be image/jpeg.
+ char ct[40] = {0};
+ if (httpd_req_get_hdr_value_str(req, "Content-Type", ct, sizeof(ct)) != ESP_OK ||
+ strncasecmp(ct, "image/jpeg", 10) != 0) {
+ return respond_status(req, "400 Bad Request", "Content-Type must be image/jpeg");
+ }
+
+ if (req->content_len == 0) {
+ return respond_status(req, "400 Bad Request", "empty body");
+ }
+ if (req->content_len > JPEG_DECODER_MAX_INPUT_BYTES) {
+ return respond_status(req, "413 Payload Too Large", "JPEG > 1 MB");
+ }
+
+ if (!display_is_up()) {
+ return respond_status(req, "500 Internal Server Error", "display not initialized");
+ }
+
+ // Single in-flight frame. Concurrent posts get 503 (spec).
+ if (!jpeg_decoder_try_lock(0)) {
+ return respond_status(req, "503 Service Unavailable", "frame in flight");
+ }
+
+ esp_err_t result = ESP_OK;
+ uint8_t *in = jpeg_decoder_input_buffer();
+ size_t got = 0;
+ while (got < req->content_len) {
+ int n = httpd_req_recv(req, (char *)(in + got), req->content_len - got);
+ if (n <= 0) { result = ESP_FAIL; break; }
+ got += n;
+ }
+
+ if (result != ESP_OK) {
+ jpeg_decoder_unlock();
+ return respond_status(req, "400 Bad Request", "body read failed");
+ }
+
+ void *rgb = NULL;
+ uint16_t w = 0, h = 0;
+ esp_err_t dec_err = jpeg_decoder_decode(got, &rgb, &w, &h);
+ if (dec_err != ESP_OK) {
+ viewport_state_lock();
+ viewport_state_get()->decode_errors++;
+ viewport_state_unlock();
+ jpeg_decoder_unlock();
+ return respond_status(req, "400 Bad Request", "JPEG decode failed");
+ }
+
+ uint16_t want_w, want_h;
+ expected_dims(&want_w, &want_h);
+ if (w != want_w || h != want_h) {
+ viewport_state_lock();
+ viewport_state_get()->decode_errors++;
+ viewport_state_unlock();
+ jpeg_decoder_unlock();
+ char msg[80];
+ snprintf(msg, sizeof(msg), "expected %ux%u, got %ux%u", want_w, want_h, w, h);
+ return respond_status(req, "400 Bad Request", msg);
+ }
+
+ esp_err_t paint_err = display_present_rgb565((const uint16_t *)rgb, w, h);
+ if (paint_err != ESP_OK) {
+ jpeg_decoder_unlock();
+ return respond_status(req, "500 Internal Server Error", "display paint failed");
+ }
+
+ viewport_state_lock();
+ viewport_state_t *st = viewport_state_get();
+ st->frames_received++;
+ st->last_frame_us = esp_timer_get_time();
+ viewport_state_unlock();
+
+ jpeg_decoder_unlock();
+
+ 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 = {
@@ -284,6 +384,9 @@ static const httpd_uri_t s_config_get = {
static const httpd_uri_t s_config_post = {
.uri = "/config", .method = HTTP_POST, .handler = config_post_handler,
};
+static const httpd_uri_t s_frame_post = {
+ .uri = "/frame", .method = HTTP_POST, .handler = frame_post_handler,
+};
esp_err_t http_api_start(void)
{
@@ -300,7 +403,10 @@ esp_err_t http_api_start(void)
TAG, "register GET /config");
ESP_RETURN_ON_ERROR(httpd_register_uri_handler(server, &s_config_post),
TAG, "register POST /config");
+ ESP_RETURN_ON_ERROR(httpd_register_uri_handler(server, &s_frame_post),
+ TAG, "register POST /frame");
- ESP_LOGI(TAG, "http server listening on :80 (GET /state, GET/POST /config)");
+ ESP_LOGI(TAG, "http server listening on :80 "
+ "(GET /state, GET/POST /config, POST /frame)");
return ESP_OK;
}
diff --git a/main/jpeg_decoder.c b/main/jpeg_decoder.c
new file mode 100644
index 0000000..b34612d
--- /dev/null
+++ b/main/jpeg_decoder.c
@@ -0,0 +1,97 @@
+#include "jpeg_decoder.h"
+
+#include <string.h>
+
+#include "driver/jpeg_decode.h"
+#include "esp_check.h"
+#include "esp_log.h"
+#include "freertos/FreeRTOS.h"
+#include "freertos/semphr.h"
+
+static const char *TAG = "jpeg";
+
+static jpeg_decoder_handle_t s_engine;
+static SemaphoreHandle_t s_mutex;
+static void *s_in_buf;
+static size_t s_in_cap;
+static void *s_out_buf;
+static size_t s_out_cap;
+
+esp_err_t jpeg_decoder_init(void)
+{
+ s_mutex = xSemaphoreCreateMutex();
+ if (!s_mutex) return ESP_ERR_NO_MEM;
+
+ jpeg_decode_engine_cfg_t cfg = {
+ .intr_priority = 0,
+ .timeout_ms = 200,
+ };
+ ESP_RETURN_ON_ERROR(jpeg_new_decoder_engine(&cfg, &s_engine),
+ TAG, "jpeg_new_decoder_engine");
+
+ // DMA-aligned PSRAM scratch buffers. Allocate once, reuse.
+ jpeg_decode_memory_alloc_cfg_t in_cfg = { .buffer_direction = JPEG_DEC_ALLOC_INPUT_BUFFER };
+ jpeg_decode_memory_alloc_cfg_t out_cfg = { .buffer_direction = JPEG_DEC_ALLOC_OUTPUT_BUFFER };
+
+ s_in_buf = jpeg_alloc_decoder_mem(JPEG_DECODER_MAX_INPUT_BYTES, &in_cfg, &s_in_cap);
+ if (!s_in_buf) {
+ ESP_LOGE(TAG, "jpeg input buf alloc failed");
+ return ESP_ERR_NO_MEM;
+ }
+ s_out_buf = jpeg_alloc_decoder_mem(JPEG_DECODER_MAX_OUTPUT_BYTES, &out_cfg, &s_out_cap);
+ if (!s_out_buf) {
+ ESP_LOGE(TAG, "jpeg output buf alloc failed");
+ return ESP_ERR_NO_MEM;
+ }
+
+ ESP_LOGI(TAG, "decoder ready (in=%u bytes, out=%u bytes)",
+ (unsigned)s_in_cap, (unsigned)s_out_cap);
+ return ESP_OK;
+}
+
+bool jpeg_decoder_try_lock(uint32_t timeout_ms)
+{
+ return xSemaphoreTake(s_mutex, pdMS_TO_TICKS(timeout_ms)) == pdTRUE;
+}
+
+void jpeg_decoder_unlock(void)
+{
+ xSemaphoreGive(s_mutex);
+}
+
+void *jpeg_decoder_input_buffer(void) { return s_in_buf; }
+
+esp_err_t jpeg_decoder_decode(size_t jpeg_len,
+ void **out_rgb565,
+ uint16_t *out_width,
+ uint16_t *out_height)
+{
+ if (jpeg_len == 0 || jpeg_len > s_in_cap) return ESP_ERR_INVALID_SIZE;
+
+ jpeg_decode_cfg_t dec_cfg = {
+ .output_format = JPEG_DECODE_OUT_FORMAT_RGB565,
+ .rgb_order = JPEG_DEC_RGB_ELEMENT_ORDER_RGB,
+ .conv_std = JPEG_YUV_RGB_CONV_STD_BT601,
+ };
+
+ jpeg_decode_picture_info_t info = {0};
+ esp_err_t err = jpeg_decoder_get_info(s_in_buf, jpeg_len, &info);
+ if (err != ESP_OK) {
+ ESP_LOGW(TAG, "jpeg_decoder_get_info: %s", esp_err_to_name(err));
+ return err;
+ }
+
+ uint32_t out_size = 0;
+ err = jpeg_decoder_process(s_engine, &dec_cfg,
+ s_in_buf, jpeg_len,
+ s_out_buf, s_out_cap, &out_size);
+ if (err != ESP_OK) {
+ ESP_LOGW(TAG, "jpeg_decoder_process: %s", esp_err_to_name(err));
+ return err;
+ }
+
+ *out_rgb565 = s_out_buf;
+ *out_width = info.width;
+ *out_height = info.height;
+ return ESP_OK;
+}
diff --git a/main/jpeg_decoder.h b/main/jpeg_decoder.h
new file mode 100644
index 0000000..9c5bf89
--- /dev/null
+++ b/main/jpeg_decoder.h
@@ -0,0 +1,33 @@
+#pragma once
+
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdint.h>
+
+#include "esp_err.h"
+
+#define JPEG_DECODER_MAX_INPUT_BYTES (1024 * 1024) // 1 MB
+#define JPEG_DECODER_MAX_OUTPUT_BYTES (800 * 480 * 2) // RGB565 panel native
+
+// One-time setup of the ESP32-P4 hardware JPEG decoder. Allocates reusable
+// DMA-aligned input + output buffers in PSRAM.
+esp_err_t jpeg_decoder_init(void);
+
+// The decoder owns shared scratch buffers. Caller must serialize access.
+// http_api uses a try-lock so concurrent /frame POSTs get 503 instead of
+// queueing.
+bool jpeg_decoder_try_lock(uint32_t timeout_ms);
+void jpeg_decoder_unlock(void);
+
+// Pointer to the input scratch buffer (PSRAM, DMA-aligned). Capacity =
+// JPEG_DECODER_MAX_INPUT_BYTES. Caller fills before calling decode.
+void *jpeg_decoder_input_buffer(void);
+
+// Decode the JPEG sitting in the input buffer. Fills out_rgb565 with a
+// pointer to the decoded RGB565 image and reports its width/height in
+// pixels. The output buffer is owned by the decoder — valid only until
+// the next jpeg_decoder_unlock().
+esp_err_t jpeg_decoder_decode(size_t jpeg_len,
+ void **out_rgb565,
+ uint16_t *out_width,
+ uint16_t *out_height);