From 36ee75bd1bc1dd2824c468c791543c384346018a Mon Sep 17 00:00:00 2001 From: misonyah Date: Sat, 8 Aug 2026 16:23:34 +0200 Subject: [PATCH 1/3] Serve a diagnostic frame when camera init fails When esp_camera_init() fails (e.g. sensor not seated in its socket), the stream server used to just skip registering "/" entirely, so clients lost the video feed with no indication of why. CameraManager now renders the esp_err_t as a small hex code using a hand-rolled 5x7 pixel font, tiled in a 3x3 grid across the frame with each tile independently rotated 0/90/180/270 degrees. A single large centered label isn't reliable here: eye-tracking clients (Baballonia, EyeTrackVR, etc.) crop/zoom toward wherever they guess the pupil is, which can clip a single label entirely or blow it up into an unreadable blob at whatever rotation the crop applies. Tiling with per-tile rotation means at least one instance should land inside the crop window right-side-up. The frame is encoded once via fmt2jpg and served on a loop through the existing StreamServer multipart path. Adds DIAGNOSTIC_ERROR_CODES.md documenting which esp_err_t codes are actually reachable through this path (and which esp32-camera codes aren't, since the frame only renders 3 hex digits). Currently hardcoded to the 240x240 grayscale frame this board's CameraManager config uses - not derived from the configured camera_config_t, so boards with a different resolution/pixel format would need to adjust FRAME_DIM/PIXFORMAT_GRAYSCALE accordingly. --- .../CameraManager/CameraManager.cpp | 198 ++++++++++++++++++ .../CameraManager/CameraManager.hpp | 13 ++ .../CameraManager/DIAGNOSTIC_ERROR_CODES.md | 69 ++++++ components/StreamServer/CMakeLists.txt | 2 +- .../StreamServer/StreamServer.cpp | 76 +++++-- .../StreamServer/StreamServer.hpp | 13 +- main/openiris_main.cpp | 2 +- 7 files changed, 350 insertions(+), 23 deletions(-) create mode 100644 components/CameraManager/DIAGNOSTIC_ERROR_CODES.md diff --git a/components/CameraManager/CameraManager/CameraManager.cpp b/components/CameraManager/CameraManager/CameraManager.cpp index 835a97a..a7430ec 100644 --- a/components/CameraManager/CameraManager/CameraManager.cpp +++ b/components/CameraManager/CameraManager/CameraManager.cpp @@ -1,7 +1,111 @@ #include "CameraManager.hpp" +#include "esp_heap_caps.h" +#include "img_converters.h" + +#include +#include +#include const char* CAMERA_MANAGER_TAG = "[CAMERA_MANAGER]"; +namespace +{ +// 5x7 pixel font, hex digits 0-F only. Each glyph is 7 rows, bit4 = leftmost column. +// Hand-drawn (not lifted from any font asset) - legibility over accuracy to any real typeface. +constexpr uint8_t FONT_5X7[16][7] = { + {0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110}, // 0 + {0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110}, // 1 + {0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0b01000, 0b11111}, // 2 + {0b11111, 0b00010, 0b00100, 0b00010, 0b00001, 0b10001, 0b01110}, // 3 + {0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010}, // 4 + {0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110}, // 5 + {0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110}, // 6 + {0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000}, // 7 + {0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110}, // 8 + {0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100}, // 9 + {0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001}, // A + {0b11110, 0b10001, 0b10001, 0b11110, 0b10001, 0b10001, 0b11110}, // b + {0b01111, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b01111}, // C + {0b11100, 0b10010, 0b10001, 0b10001, 0b10001, 0b10010, 0b11100}, // d + {0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111}, // E + {0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000}, // F +}; + +constexpr uint16_t FRAME_DIM = 240; +constexpr uint8_t BG_SHADE = 30; +constexpr uint8_t FG_SHADE = 255; + +void fillRect(uint8_t* buf, int x0, int y0, int x1, int y1, uint8_t shade) +{ + x0 = std::max(x0, 0); + y0 = std::max(y0, 0); + x1 = std::min(x1, (int)FRAME_DIM); + y1 = std::min(y1, (int)FRAME_DIM); + for (int y = y0; y < y1; y++) + { + std::memset(buf + (size_t)y * FRAME_DIM + x0, shade, std::max(0, x1 - x0)); + } +} + +// A 3-hex-digit label is a grid of cells GRID_W (17) x GRID_H (7): each character is 5 cols +// wide with a 1-col gap between characters (5+1+5+1+5 = 17), 7 rows tall. +constexpr int GRID_W = 17; +constexpr int GRID_H = 7; + +// Rotates a label-local cell coordinate by 0/90/180/270 degrees (clockwise) around the label's +// own origin. Since rotations are multiples of 90 degrees, grid cells map to grid cells exactly - +// no interpolation needed. rotation is 0-3 (x90 degrees). +void rotateCell(int gx, int gy, int rotation, int* outGx, int* outGy) +{ + switch (rotation & 0x3) + { + case 0: + *outGx = gx; + *outGy = gy; + break; + case 1: // 90 CW + *outGx = GRID_H - 1 - gy; + *outGy = gx; + break; + case 2: // 180 + *outGx = GRID_W - 1 - gx; + *outGy = GRID_H - 1 - gy; + break; + default: // 270 CW + *outGx = gy; + *outGy = GRID_W - 1 - gx; + break; + } +} + +// Draws a 3-hex-digit label rotated by `rotation` (0-3, x90 degrees clockwise), with its +// rotated bounding box's top-left corner at (originX, originY) in the destination buffer. +void drawLabelRotated(uint8_t* buf, int originX, int originY, int scale, int rotation, const uint8_t nibbles[3]) +{ + for (int charIdx = 0; charIdx < 3; charIdx++) + { + const auto& rows = FONT_5X7[nibbles[charIdx] & 0xF]; + for (int row = 0; row < 7; row++) + { + for (int col = 0; col < 5; col++) + { + if (!(rows[row] & (1 << (4 - col)))) + continue; + + const int gx = charIdx * 6 + col; // 5 cols + 1 gap per character + const int gy = row; + int rgx, rgy; + rotateCell(gx, gy, rotation, &rgx, &rgy); + + const int x = originX + rgx * scale; + const int y = originY + rgy * scale; + fillRect(buf, x, y, x + scale, y + scale, FG_SHADE); + } + } + } +} +} // namespace + CameraManager::CameraManager(std::shared_ptr projectConfig, QueueHandle_t eventQueue) : projectConfig(projectConfig), eventQueue(eventQueue) {} void CameraManager::setupCameraPinout() @@ -167,6 +271,8 @@ bool CameraManager::setupCamera() "Please " "fix the " "camera and reboot the device.\r\n"); + this->lastCameraError = hasCameraBeenInitialized; + this->generateDiagnosticFrame(); constexpr auto event = SystemEvent{EventSource::CAMERA, CameraState_e::Camera_Error}; xQueueSend(this->eventQueue, &event, 10); return false; @@ -226,4 +332,96 @@ int CameraManager::setVieWindow(int offsetX, int offsetY, int outputX, int outpu { // todo safariMonkey made a PoC, implement it here return 0; +} + +esp_err_t CameraManager::getLastCameraError() const +{ + return lastCameraError; +} + +bool CameraManager::getDiagnosticFrame(const uint8_t** outBuf, size_t* outLen) const +{ + if (!diagnosticJpegBuf || diagnosticJpegLen == 0) + return false; + + *outBuf = diagnosticJpegBuf; + *outLen = diagnosticJpegLen; + return true; +} + +void CameraManager::generateDiagnosticFrame() +{ + if (diagnosticJpegBuf) + { + free(diagnosticJpegBuf); + diagnosticJpegBuf = nullptr; + diagnosticJpegLen = 0; + } + + auto* raw = static_cast(heap_caps_malloc((size_t)FRAME_DIM * FRAME_DIM, MALLOC_CAP_SPIRAM)); + if (!raw) + { + ESP_LOGE(CAMERA_MANAGER_TAG, "Failed to allocate diagnostic frame buffer"); + return; + } + + std::memset(raw, BG_SHADE, (size_t)FRAME_DIM * FRAME_DIM); + + // The error code magnitude fits comfortably in 3 hex digits for every esp_err_t this + // component can produce (e.g. ESP_ERR_NOT_FOUND = 0x105), so we only render 3. + const uint32_t code = static_cast(std::abs(static_cast(lastCameraError))) & 0xFFF; + const uint8_t nibbles[3] = { + static_cast((code >> 8) & 0xF), + static_cast((code >> 4) & 0xF), + static_cast(code & 0xF), + }; + + // Eye-tracking clients (Baballonia et al.) crop/zoom this frame toward whatever they guess + // is the pupil, so a single centered label isn't reliable - it can land almost anywhere, + // rotated or clipped, and blow up into an unreadable blob. + // Tile small instances of the code across the frame in a grid, each at a different 90-degree + // rotation, so whatever crop/rotation the client applies, at least one instance lands both + // inside the crop window AND right-side-up. + constexpr int scale = 3; // pixels per font cell + constexpr int labelPxW = GRID_W * scale; + constexpr int labelPxH = GRID_H * scale; + // Max footprint across all 4 rotations is a square of the longer dimension, so every tile + // gets the same amount of clearance regardless of which rotation lands there. + constexpr int cellSpan = (labelPxW > labelPxH ? labelPxW : labelPxH); + constexpr int centerXs[3] = {14 + cellSpan / 2, FRAME_DIM / 2, FRAME_DIM - 14 - cellSpan / 2}; + constexpr int centerYs[3] = {14 + cellSpan / 2, FRAME_DIM / 2, FRAME_DIM - 14 - cellSpan / 2}; + + int rotation = 0; + for (int cy : centerYs) + { + for (int cx : centerXs) + { + const bool swapped = (rotation & 1) != 0; // 90/270 swap the bounding box's W/H + const int w = swapped ? labelPxH : labelPxW; + const int h = swapped ? labelPxW : labelPxH; + drawLabelRotated(raw, cx - w / 2, cy - h / 2, scale, rotation, nibbles); + rotation = (rotation + 1) & 0x3; + } + } + + // Thin border so it's obviously a diagnostic card rather than a corrupted stream frame. + fillRect(raw, 0, 0, FRAME_DIM, 4, FG_SHADE); + fillRect(raw, 0, FRAME_DIM - 4, FRAME_DIM, FRAME_DIM, FG_SHADE); + fillRect(raw, 0, 0, 4, FRAME_DIM, FG_SHADE); + fillRect(raw, FRAME_DIM - 4, 0, FRAME_DIM, FRAME_DIM, FG_SHADE); + + uint8_t* jpegOut = nullptr; + size_t jpegLen = 0; + const bool ok = fmt2jpg(raw, (size_t)FRAME_DIM * FRAME_DIM, FRAME_DIM, FRAME_DIM, PIXFORMAT_GRAYSCALE, 30, &jpegOut, &jpegLen); + free(raw); + + if (!ok) + { + ESP_LOGE(CAMERA_MANAGER_TAG, "Failed to encode diagnostic frame to JPEG"); + return; + } + + diagnosticJpegBuf = jpegOut; + diagnosticJpegLen = jpegLen; + ESP_LOGI(CAMERA_MANAGER_TAG, "Generated diagnostic frame for error 0x%03lx (%u bytes)", (unsigned long)code, (unsigned)jpegLen); } \ No newline at end of file diff --git a/components/CameraManager/CameraManager/CameraManager.hpp b/components/CameraManager/CameraManager/CameraManager.hpp index 21c952a..605b6eb 100644 --- a/components/CameraManager/CameraManager/CameraManager.hpp +++ b/components/CameraManager/CameraManager/CameraManager.hpp @@ -24,6 +24,10 @@ class CameraManager QueueHandle_t eventQueue; camera_config_t config; + esp_err_t lastCameraError = ESP_OK; + uint8_t* diagnosticJpegBuf = nullptr; + size_t diagnosticJpegLen = 0; + public: CameraManager(std::shared_ptr projectConfig, QueueHandle_t eventQueue); int setCameraResolution(framesize_t frameSize); @@ -32,10 +36,19 @@ class CameraManager int setHFlip(int direction); int setVieWindow(int offsetX, int offsetY, int outputX, int outputY); + // Returns the last esp_err_t returned by esp_camera_init(), only meaningful after setupCamera() fails. + esp_err_t getLastCameraError() const; + + // Fetches a cached JPEG that renders getLastCameraError() as large 7-segment-style hex digits, + // so streaming clients (Baballonia/EyeTrackVR/etc.) see the failure instead of just losing the feed. + // Returns false if no diagnostic frame has been generated (e.g. camera never failed). + bool getDiagnosticFrame(const uint8_t** outBuf, size_t* outLen) const; + private: void loadConfigData(); void setupCameraPinout(); void setupCameraSensor(); + void generateDiagnosticFrame(); }; #endif // CAMERAMANAGER_HPP \ No newline at end of file diff --git a/components/CameraManager/DIAGNOSTIC_ERROR_CODES.md b/components/CameraManager/DIAGNOSTIC_ERROR_CODES.md new file mode 100644 index 0000000..8d0cfe1 --- /dev/null +++ b/components/CameraManager/DIAGNOSTIC_ERROR_CODES.md @@ -0,0 +1,69 @@ +# Camera diagnostic frame - error code reference + +When `esp_camera_init()` fails, `CameraManager::generateDiagnosticFrame()` renders the +returned `esp_err_t` as a 3-hex-digit code (e.g. `105`) tiled across the fallback MJPEG +frame instead of a live picture, so eye-tracking clients (Baballonia, EyeTrackVR, etc.) +show *something* explanatory instead of a dead feed. + +**Only the low 3 hex digits are shown.** That's enough to disambiguate every code this +component can realistically produce (see table below), but it isn't unique across the +whole `esp_err_t` space - see [Codes outside this range](#codes-outside-this-range). + +## Codes reachable from `esp_camera_init()` + +These come from `esp_camera_init()` (`managed_components/espressif__esp32-camera/driver/esp_camera.c`), +specifically `camera_probe()` / `SCCB_Probe()` for the sensor detection step. + +| Code | Name | Meaning | Typical cause | +|---------|--------------------------|-----------------------------------------------------------------------|----------------| +| `0x105` | `ESP_ERR_NOT_FOUND` | SCCB (I2C) probe got no ACK from any known sensor address | Camera sensor not seated in its socket, bad ribbon/connector, or dead sensor. | +| `0x101` | `ESP_ERR_NO_MEM` | Frame buffer / DMA allocation failed | PSRAM not detected or exhausted before camera init. | +| `0x102` | `ESP_ERR_INVALID_ARG` | Bad `camera_config_t` (pins, `xclk_freq_hz`, format/framesize combo) | Firmware bug, not a hardware fault - check `CameraManager::setupCameraPinout()` against the board's Kconfig pin defaults. | +| `0x103` | `ESP_ERR_INVALID_STATE` | `esp_camera_init()` called twice without `esp_camera_deinit()` | Firmware bug (double-init) - see the OV5640-specific reinit path in `CameraManager::setupCamera()`. | + +## Full base `esp_err_t` table (for reference) + +Every generic ESP-IDF error code fits in 3 hex digits, so if the frame ever shows one of +these it's unambiguous: + +| Code | Name | +|---------|----------------------------| +| `0x101` | `ESP_ERR_NO_MEM` | +| `0x102` | `ESP_ERR_INVALID_ARG` | +| `0x103` | `ESP_ERR_INVALID_STATE` | +| `0x104` | `ESP_ERR_INVALID_SIZE` | +| `0x105` | `ESP_ERR_NOT_FOUND` | +| `0x106` | `ESP_ERR_NOT_SUPPORTED` | +| `0x107` | `ESP_ERR_TIMEOUT` | +| `0x108` | `ESP_ERR_INVALID_RESPONSE` | +| `0x109` | `ESP_ERR_INVALID_CRC` | +| `0x10A` | `ESP_ERR_INVALID_VERSION` | +| `0x10B` | `ESP_ERR_INVALID_MAC` | +| `0x10C` | `ESP_ERR_NOT_FINISHED` | +| `0x10D` | `ESP_ERR_NOT_ALLOWED` | + +(Source: `esp-idf/components/esp_common/include/esp_err.h`.) + +## Codes outside this range + +`esp32-camera` also defines its own error base, which does **not** fit in 3 hex digits and +will get silently truncated by the diagnostic frame's `& 0xFFF` mask: + +| Full code | Name | Would render as | +|-----------|---------------------------------------------|------------------| +| `0x20001` | `ESP_ERR_CAMERA_NOT_DETECTED` | `001` | +| `0x20002` | `ESP_ERR_CAMERA_FAILED_TO_SET_FRAME_SIZE` | `002` | +| `0x20003` | `ESP_ERR_CAMERA_FAILED_TO_SET_OUT_FORMAT` | `003` | +| `0x20004` | `ESP_ERR_CAMERA_NOT_SUPPORTED` | `004` | + +(Source: `managed_components/espressif__esp32-camera/driver/include/esp_camera.h`.) + +`esp_camera_init()` itself (`esp_camera.c`, ~lines 278-293, via `camera_probe()`/`SCCB_Probe()`) +only ever returns `ESP_ERR_NOT_FOUND` (`0x105`) on a missing sensor. The `ESP_ERR_CAMERA_*` +codes above aren't returned from the init path at all - they live in the unrelated +`esp_camera_save_to_nvs()` / `esp_camera_load_from_nvs()` helpers, which `CameraManager` +doesn't call. So they're currently unreachable through this diagnostic path, not just an +edge case of it. If that ever changes (e.g. a future change starts persisting camera +settings through those NVS helpers and surfacing their errors here), the frame would need +to render 4+ hex digits (or special-case the `0x2xxxx` camera error base) to stay +unambiguous - see `CameraManager::generateDiagnosticFrame()`. diff --git a/components/StreamServer/CMakeLists.txt b/components/StreamServer/CMakeLists.txt index 3c0a30a..dc2d0bb 100644 --- a/components/StreamServer/CMakeLists.txt +++ b/components/StreamServer/CMakeLists.txt @@ -1,4 +1,4 @@ idf_component_register(SRCS "StreamServer/StreamServer.cpp" INCLUDE_DIRS "StreamServer" - REQUIRES esp32-camera StateManager ProjectConfig esp_http_server Helpers WebSocketLogger + REQUIRES esp32-camera StateManager ProjectConfig esp_http_server Helpers WebSocketLogger CameraManager ) \ No newline at end of file diff --git a/components/StreamServer/StreamServer/StreamServer.cpp b/components/StreamServer/StreamServer/StreamServer.cpp index efc0372..dbeb60e 100644 --- a/components/StreamServer/StreamServer/StreamServer.cpp +++ b/components/StreamServer/StreamServer/StreamServer.cpp @@ -1,4 +1,5 @@ #include "StreamServer.hpp" +#include constexpr static const char* STREAM_CONTENT_TYPE = "multipart/x-mixed-replace;boundary=" PART_BOUNDARY; constexpr static const char* STREAM_BOUNDARY = "\r\n--" PART_BOUNDARY "\r\n"; @@ -6,7 +7,10 @@ constexpr static const char* STREAM_PART = "Content-Type: image/jpeg\r\nContent- static const char* STREAM_SERVER_TAG = "[STREAM_SERVER]"; -StreamServer::StreamServer(const int STREAM_PORT, StateManager* stateManager) : STREAM_SERVER_PORT(STREAM_PORT), stateManager(stateManager) {} +StreamServer::StreamServer(const int STREAM_PORT, StateManager* stateManager, std::shared_ptr cameraManager) + : STREAM_SERVER_PORT(STREAM_PORT), stateManager(stateManager), cameraManager(cameraManager) +{ +} esp_err_t StreamHelpers::stream(httpd_req_t* req) { @@ -23,8 +27,11 @@ esp_err_t StreamHelpers::stream(httpd_req_t* req) if (!last_frame) last_frame = esp_timer_get_time(); - // Pull event queue from user_ctx to send STREAM on/off notifications - auto* stateManager = static_cast(req->user_ctx); + // Pull state/camera managers from user_ctx to send STREAM on/off notifications and, if the + // camera failed to init, fall back to a diagnostic frame instead of just dropping the feed. + auto* ctx = static_cast(req->user_ctx); + StateManager* stateManager = ctx ? ctx->stateManager : nullptr; + CameraManager* cameraManager = ctx ? ctx->cameraManager : nullptr; QueueHandle_t eventQueue = stateManager ? stateManager->GetEventQueue() : nullptr; bool stream_on_sent = false; @@ -38,24 +45,41 @@ esp_err_t StreamHelpers::stream(httpd_req_t* req) if (SendStreamEvent(eventQueue, StreamState_e::Stream_ON)) stream_on_sent = true; + const bool cameraFailed = stateManager && stateManager->GetCameraState() == CameraState_e::Camera_Error; + const uint8_t* diagBuf = nullptr; + size_t diagLen = 0; + const bool haveDiagFrame = cameraFailed && cameraManager && cameraManager->getDiagnosticFrame(&diagBuf, &diagLen); + while (true) { - fb = esp_camera_fb_get(); - - if (!fb) + if (haveDiagFrame) { - ESP_LOGE(STREAM_SERVER_TAG, "Camera capture failed"); - response = ESP_FAIL; - // Don't break immediately, try to recover - vTaskDelay(pdMS_TO_TICKS(10)); - continue; + // No real camera frames are coming; re-serve the same diagnostic JPEG slowly so + // clients see it as a (mostly static) video feed instead of a broken connection. + gettimeofday(&_timestamp, nullptr); + _jpg_buf_len = diagLen; + _jpg_buf = const_cast(diagBuf); + fb = nullptr; } else { - _timestamp.tv_sec = fb->timestamp.tv_sec; - _timestamp.tv_usec = fb->timestamp.tv_usec; - _jpg_buf_len = fb->len; - _jpg_buf = fb->buf; + fb = esp_camera_fb_get(); + + if (!fb) + { + ESP_LOGE(STREAM_SERVER_TAG, "Camera capture failed"); + response = ESP_FAIL; + // Don't break immediately, try to recover + vTaskDelay(pdMS_TO_TICKS(10)); + continue; + } + else + { + _timestamp.tv_sec = fb->timestamp.tv_sec; + _timestamp.tv_usec = fb->timestamp.tv_usec; + _jpg_buf_len = fb->len; + _jpg_buf = fb->buf; + } } if (response == ESP_OK) response = httpd_resp_send_chunk(req, STREAM_BOUNDARY, strlen(STREAM_BOUNDARY)); @@ -72,14 +96,22 @@ esp_err_t StreamHelpers::stream(httpd_req_t* req) fb = NULL; _jpg_buf = NULL; } - else if (_jpg_buf) + else if (_jpg_buf && !haveDiagFrame) { + // _jpg_buf points at the diagnostic frame's cached buffer when haveDiagFrame is set, + // which is owned by CameraManager and reused every iteration - never free it here. free(_jpg_buf); _jpg_buf = NULL; } if (response != ESP_OK) break; + if (haveDiagFrame) + { + // No real capture rate to pace against; throttle the repeated diagnostic frame. + vTaskDelay(pdMS_TO_TICKS(500)); + } + if (esp_log_level_get(STREAM_SERVER_TAG) >= ESP_LOG_INFO) { static long last_request_time = 0; @@ -130,11 +162,12 @@ esp_err_t StreamServer::startStreamServer() config.send_wait_timeout = 5; // 5 seconds for sending config.lru_purge_enable = true; // Enable LRU purge for better connection handling + this->userCtx = StreamUserCtx{this->stateManager, this->cameraManager.get()}; httpd_uri_t stream_page = { .uri = "/", .method = HTTP_GET, .handler = &StreamHelpers::stream, - .user_ctx = this->stateManager, + .user_ctx = &this->userCtx, }; httpd_uri_t logs_ws = { @@ -154,10 +187,13 @@ esp_err_t StreamServer::startStreamServer() } httpd_register_uri_handler(camera_stream, &logs_ws); - if (this->stateManager->GetCameraState() != CameraState_e::Camera_Success) + + const bool cameraOk = this->stateManager->GetCameraState() == CameraState_e::Camera_Success; + if (!cameraOk) { - ESP_LOGE(STREAM_SERVER_TAG, "Camera not initialized. Cannot start stream server. Logs server will be running."); - return ESP_FAIL; + // Still register "/" - the handler serves a diagnostic frame (error code rendered as a + // JPEG) instead of a real feed, so clients see the failure instead of a dead connection. + ESP_LOGW(STREAM_SERVER_TAG, "Camera not initialized. Stream will serve a diagnostic frame instead of live video."); } httpd_register_uri_handler(camera_stream, &stream_page); diff --git a/components/StreamServer/StreamServer/StreamServer.hpp b/components/StreamServer/StreamServer/StreamServer.hpp index 8161a3e..9d29c3d 100644 --- a/components/StreamServer/StreamServer/StreamServer.hpp +++ b/components/StreamServer/StreamServer/StreamServer.hpp @@ -4,6 +4,7 @@ #define PART_BOUNDARY "123456789000000000000987654321" +#include #include #include #include @@ -14,6 +15,14 @@ extern WebSocketLogger webSocketLogger; +// Passed as httpd_req_t::user_ctx so the stream handler can fall back to a diagnostic +// frame (see CameraManager::getDiagnosticFrame) when the camera failed to init. +struct StreamUserCtx +{ + StateManager* stateManager; + CameraManager* cameraManager; +}; + namespace StreamHelpers { esp_err_t stream(httpd_req_t* req); @@ -25,10 +34,12 @@ class StreamServer private: int STREAM_SERVER_PORT; StateManager* stateManager; + std::shared_ptr cameraManager; + StreamUserCtx userCtx{}; httpd_handle_t camera_stream = nullptr; public: - StreamServer(const int STREAM_PORT, StateManager* StateManager); + StreamServer(const int STREAM_PORT, StateManager* StateManager, std::shared_ptr cameraManager); esp_err_t startStreamServer(); esp_err_t stream(httpd_req_t* req); diff --git a/main/openiris_main.cpp b/main/openiris_main.cpp index 8975d1d..b00dfa0 100644 --- a/main/openiris_main.cpp +++ b/main/openiris_main.cpp @@ -62,7 +62,7 @@ auto wifiManager = std::make_shared(deviceConfig, eventQueue, state MDNSManager mdnsManager(deviceConfig, eventQueue); std::shared_ptr cameraHandler = std::make_shared(deviceConfig, eventQueue); -StreamServer streamServer(80, stateManager); +StreamServer streamServer(80, stateManager, cameraHandler); std::shared_ptr restAPI = std::make_shared("http://0.0.0.0:81", commandManager); From 7673a3ce335ca8726fa67e527998d926168609c9 Mon Sep 17 00:00:00 2001 From: misonyah Date: Sat, 8 Aug 2026 18:09:46 +0200 Subject: [PATCH 2/3] Auto-retry camera init while failed, recover live streams in place Camera init previously only ran once at boot; if it failed there was no way to recover without a manual reboot. CameraManager now spawns a background task that retries setupCamera() every 5s while it hasn't succeeded, and StreamServer re-checks camera state on every loop iteration (not just once at connection start) so an already-open stream picks up live video automatically the moment the camera comes up, no client reconnect required. --- .../CameraManager/CameraManager.cpp | 34 +++++++++++++++++-- .../CameraManager/CameraManager.hpp | 10 ++++++ .../StreamServer/StreamServer.cpp | 13 ++++--- main/openiris_main.cpp | 1 + 4 files changed, 50 insertions(+), 8 deletions(-) diff --git a/components/CameraManager/CameraManager/CameraManager.cpp b/components/CameraManager/CameraManager/CameraManager.cpp index a7430ec..98b2dbe 100644 --- a/components/CameraManager/CameraManager/CameraManager.cpp +++ b/components/CameraManager/CameraManager/CameraManager.cpp @@ -1,5 +1,6 @@ #include "CameraManager.hpp" #include "esp_heap_caps.h" +#include "freertos/task.h" #include "img_converters.h" #include @@ -260,6 +261,7 @@ bool CameraManager::setupCamera() { ESP_LOGI(CAMERA_MANAGER_TAG, "Camera initialized: %s \r\n", esp_err_to_name(hasCameraBeenInitialized)); + this->cameraOk = true; constexpr auto event = SystemEvent{EventSource::CAMERA, CameraState_e::Camera_Success}; xQueueSend(this->eventQueue, &event, 10); } @@ -268,9 +270,8 @@ bool CameraManager::setupCamera() ESP_LOGE(CAMERA_MANAGER_TAG, "Camera initialization failed with error: %s \r\n", esp_err_to_name(hasCameraBeenInitialized)); ESP_LOGE(CAMERA_MANAGER_TAG, "Camera most likely not seated properly in the socket. " - "Please " - "fix the " - "camera and reboot the device.\r\n"); + "Please fix the camera - it'll be retried automatically, no reboot needed.\r\n"); + this->cameraOk = false; this->lastCameraError = hasCameraBeenInitialized; this->generateDiagnosticFrame(); constexpr auto event = SystemEvent{EventSource::CAMERA, CameraState_e::Camera_Error}; @@ -297,6 +298,33 @@ bool CameraManager::setupCamera() return true; } +bool CameraManager::isCameraOk() const +{ + return cameraOk; +} + +namespace +{ +void CameraRetryTask(void* param) +{ + auto* self = static_cast(param); + while (true) + { + vTaskDelay(pdMS_TO_TICKS(5000)); + if (!self->isCameraOk()) + { + ESP_LOGI(CAMERA_MANAGER_TAG, "Camera not initialized, retrying..."); + self->setupCamera(); + } + } +} +} // namespace + +void CameraManager::startAutoRetry() +{ + xTaskCreate(CameraRetryTask, "CameraRetryTask", 1024 * 3, this, 1, nullptr); +} + void CameraManager::loadConfigData() { ESP_LOGD(CAMERA_MANAGER_TAG, "Loading camera config data"); diff --git a/components/CameraManager/CameraManager/CameraManager.hpp b/components/CameraManager/CameraManager/CameraManager.hpp index 605b6eb..cb19dea 100644 --- a/components/CameraManager/CameraManager/CameraManager.hpp +++ b/components/CameraManager/CameraManager/CameraManager.hpp @@ -27,6 +27,7 @@ class CameraManager esp_err_t lastCameraError = ESP_OK; uint8_t* diagnosticJpegBuf = nullptr; size_t diagnosticJpegLen = 0; + bool cameraOk = false; public: CameraManager(std::shared_ptr projectConfig, QueueHandle_t eventQueue); @@ -44,6 +45,15 @@ class CameraManager // Returns false if no diagnostic frame has been generated (e.g. camera never failed). bool getDiagnosticFrame(const uint8_t** outBuf, size_t* outLen) const; + // True once setupCamera() has succeeded. Checked by the retry task below and by + // StreamServer to decide whether to serve live frames or the diagnostic fallback. + bool isCameraOk() const; + + // Spawns a background task that keeps retrying setupCamera() every 5s while it hasn't + // succeeded yet (e.g. the sensor was reseated after a failed boot), so the camera can + // recover on its own without a manual reboot. + void startAutoRetry(); + private: void loadConfigData(); void setupCameraPinout(); diff --git a/components/StreamServer/StreamServer/StreamServer.cpp b/components/StreamServer/StreamServer/StreamServer.cpp index dbeb60e..cd66088 100644 --- a/components/StreamServer/StreamServer/StreamServer.cpp +++ b/components/StreamServer/StreamServer/StreamServer.cpp @@ -45,13 +45,16 @@ esp_err_t StreamHelpers::stream(httpd_req_t* req) if (SendStreamEvent(eventQueue, StreamState_e::Stream_ON)) stream_on_sent = true; - const bool cameraFailed = stateManager && stateManager->GetCameraState() == CameraState_e::Camera_Error; - const uint8_t* diagBuf = nullptr; - size_t diagLen = 0; - const bool haveDiagFrame = cameraFailed && cameraManager && cameraManager->getDiagnosticFrame(&diagBuf, &diagLen); - while (true) { + // Re-checked every iteration (not just once at connection start) so an already-open + // stream picks up a camera that recovers mid-connection (see CameraManager's retry + // task) without the client needing to reconnect. + const bool cameraFailed = stateManager && stateManager->GetCameraState() == CameraState_e::Camera_Error; + const uint8_t* diagBuf = nullptr; + size_t diagLen = 0; + const bool haveDiagFrame = cameraFailed && cameraManager && cameraManager->getDiagnosticFrame(&diagBuf, &diagLen); + if (haveDiagFrame) { // No real camera frames are coming; re-serve the same diagnostic JPEG slowly so diff --git a/main/openiris_main.cpp b/main/openiris_main.cpp index b00dfa0..9e78313 100644 --- a/main/openiris_main.cpp +++ b/main/openiris_main.cpp @@ -282,6 +282,7 @@ extern "C" void app_main(void) xTaskCreate(HandleLEDDisplayTask, "HandleLEDDisplayTask", 1024 * 2, ledManager.get(), 3, nullptr); cameraHandler->setupCamera(); + cameraHandler->startAutoRetry(); // let's keep the serial manager running for the duration of the setup // we'll clean it up later if need be From 5cc1c3861cde118c6131d8376fab3d42bff593ae Mon Sep 17 00:00:00 2001 From: misonyah Date: Sat, 8 Aug 2026 19:02:37 +0200 Subject: [PATCH 3/3] Fix LED stuck on CameraError forever after the camera recovers StateManager never sent an LED event on Camera_Success, and LEDManager::updateState() unconditionally refused to leave any error state once entered. Both were fine when a camera failure was permanent until reboot, but now that CameraManager retries and can genuinely recover, the LED needs to reflect that instead of showing CameraError forever after the camera is already back and streaming live video. StateManager now sends LedStateNone on Camera_Success. LEDManager's error guard switches to the already-sketched-out "recoverable" variant: error states still block other error states from overriding them, but a non-error transition (like the recovery signal above) is allowed through. --- components/LEDManager/LEDManager/LEDManager.cpp | 12 +++++------- .../StateManager/StateManager/StateManager.cpp | 7 +++++++ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/components/LEDManager/LEDManager/LEDManager.cpp b/components/LEDManager/LEDManager/LEDManager.cpp index 587d057..9cffc3b 100644 --- a/components/LEDManager/LEDManager/LEDManager.cpp +++ b/components/LEDManager/LEDManager/LEDManager.cpp @@ -110,15 +110,13 @@ void LEDManager::displayCurrentPattern() void LEDManager::updateState(const LEDStates_e newState) { - // If we've got an error state - that's it, keep repeating it indefinitely - if (ledStateMap[this->currentState].isError) + // Error states are sticky against other errors (so e.g. a WiFi hiccup can't paper over a + // camera fault) but must still be clearable by a genuine recovery - CameraManager's retry + // task can bring the camera back on its own, and the LED needs to reflect that instead of + // showing CameraError forever after it already recovered. + if (ledStateMap[this->currentState].isError && ledStateMap[newState].isError) return; - // Alternative (recoverable error states): - // Allow recovery from error states by only blocking transitions when both, current and new states are error. Uncomment to enable recovery. - // if (ledStateMap[this->currentState].isError && ledStateMap[newState].isError) - // return; - // Only update when new state differs and is known. if (!ledStateMap.contains(newState)) return; diff --git a/components/StateManager/StateManager/StateManager.cpp b/components/StateManager/StateManager/StateManager.cpp index a25814f..e82c01e 100644 --- a/components/StateManager/StateManager/StateManager.cpp +++ b/components/StateManager/StateManager/StateManager.cpp @@ -49,6 +49,13 @@ void StateManager::HandleUpdateState() ledStreamState = LEDStates_e::CameraError; xQueueSend(this->ledStateQueue, &ledStreamState, 10); } + else if (this->camera_state == CameraState_e::Camera_Success) + { + // Camera can recover on its own now (CameraManager's retry task), so clear the + // error pattern instead of leaving the LED stuck showing CameraError forever. + ledStreamState = LEDStates_e::LedStateNone; + xQueueSend(this->ledStateQueue, &ledStreamState, 10); + } break; }