@@ -159,6 +170,7 @@ async function refresh(){
txt('bootCount',s.bootCount);
txt('fwVersion',s.fwVersion);
txt('otaStatus',s.otaStatus);
+ updateLastCapture(s);
syncStream(s.streamActive);
if(!filled){
$('isKph').checked=s.isKph;
@@ -175,6 +187,29 @@ async function refresh(){
filled=true;
}
}
+// "Last capture" card. Text (speed/direction/age) comes from the newest /api/events
+// pass on every poll. The image always corresponds to THAT pass: shown only when
+// the newest pass has its own photo (photoSeq === lastSeq), otherwise a same-size
+// "no photo" placeholder -- never a stale image from an earlier pass. The JPEG is
+// fetched only when it changes, so it isn't re-pulled on every 4s /api/state poll.
+let shownPhotoSeq=-1;
+const ago=n=>n<5?'just now':n<60?n+'s ago':n<3600?Math.floor(n/60)+'m ago':Math.floor(n/3600)+'h ago';
+const dirTxt=d=>d==1?' • approaching':d==2?' • receding':'';
+function updateLastCapture(s){
+ if(!s.lastSeq){$('lastCard').style.display='none';return}
+ $('lastCard').style.display='';
+ txt('lastSpeed',s.lastSpeed);
+ txt('lastUnit',s.lastKph?'KPH':'MPH');
+ txt('lastMeta',ago(s.lastAgeSec)+dirTxt(s.lastDir));
+ const img=$('lastImg'),noimg=$('lastNoImg');
+ if(s.photoSeq && s.photoSeq===s.lastSeq){
+ if(s.photoSeq!==shownPhotoSeq){img.src='/api/last.jpg?seq='+s.photoSeq;shownPhotoSeq=s.photoSeq}
+ img.style.display='block';noimg.style.display='none';
+ }else{
+ img.style.display='none';img.removeAttribute('src');shownPhotoSeq=-1;
+ noimg.style.display='flex';
+ }
+}
const csrf=(document.querySelector('meta[name=csrf]')||{}).content||'';
const post=(p,b)=>fetch(p,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},body:JSON.stringify(b)});
async function saveSettings(){
@@ -252,6 +287,30 @@ static void portalBuildState(String& out) {
doc["streamActive"] = (bool)stream_active; // page embeds /stream inline on this same server
+ // --- last capture (top-of-page "Last capture" card) ---
+ // lastSeq/Speed/Dir/AgeSec describe the newest pass from the /api/events ring
+ // (every pass, photo or not). photoSeq is the seq of the pass whose JPEG we
+ // still hold in PSRAM (0 = none): the page shows the image when a photo exists
+ // and notes when photoSeq < lastSeq (newest pass was speed-only). Keeping the
+ // image on a separate seq lets the page refetch the ~hundreds-of-KB JPEG only
+ // when it actually changes, not on every 4 s /api/state poll.
+ {
+ int lastSpeed = 0; uint8_t lastDir = 0; bool lastKph = is_kph;
+ uint32_t lastSeq = 0, lastAge = 0;
+ if (eventsLatest(&lastSpeed, &lastDir, &lastKph, &lastSeq, &lastAge)) {
+ doc["lastSeq"] = lastSeq;
+ doc["lastSpeed"] = lastSpeed;
+ doc["lastKph"] = lastKph;
+ doc["lastDir"] = lastDir; // 0 unknown, 1 approaching, 2 receding
+ doc["lastAgeSec"] = lastAge;
+ } else {
+ doc["lastSeq"] = 0;
+ }
+ uint32_t photoSeq = 0;
+ lastPhotoMeta(&photoSeq, nullptr, nullptr, nullptr, nullptr); // leaves photoSeq 0 when none held
+ doc["photoSeq"] = photoSeq;
+ }
+
// --- current settings (populate the form once, on first load) ---
doc["isKph"] = is_kph;
doc["powerSaver"] = (bool)power_saver;
@@ -420,6 +479,41 @@ static esp_err_t portalEventsGet(httpd_req_t* req) {
return httpd_resp_sendstr(req, out.c_str());
}
+// GET /api/last.jpg -> the most recent capture's JPEG, held in PSRAM by
+// last_photo.h. The page shows it in the "Last capture" card. Like /api/state
+// this can disclose road/vehicle imagery, so it gets the same Host allowlist
+// (DNS-rebinding defense) but no CSRF -- it's a read, and the page must be able
+// to load it in an (which can't set custom headers). Sent chunked so the
+// hundreds-of-KB frame never needs a contiguous send buffer, and via the
+// borrow/release protocol so a fresh capture mid-send can't free it underfoot.
+static esp_err_t portalLastPhotoGet(httpd_req_t* req) {
+ if (!portalHostAllowed(req)) {
+ httpd_resp_send_err(req, HTTPD_403_FORBIDDEN, "bad host");
+ return ESP_FAIL;
+ }
+ size_t len = 0;
+ const uint8_t* buf = lastPhotoBorrow(&len);
+ if (buf == nullptr) {
+ httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "no capture yet");
+ return ESP_FAIL;
+ }
+ httpd_resp_set_type(req, "image/jpeg");
+ // Immutable per seq: the page appends ?seq=N and only refetches when N changes,
+ // so let the browser cache a given frame hard and skip re-downloading it.
+ httpd_resp_set_hdr(req, "Cache-Control", "private, max-age=31536000, immutable");
+ // Chunk the JPEG so we never allocate a second full-frame contiguous buffer.
+ esp_err_t rc = ESP_OK;
+ const size_t CHUNK = 8192;
+ for (size_t off = 0; off < len; off += CHUNK) {
+ const size_t n = (len - off < CHUNK) ? (len - off) : CHUNK;
+ if (httpd_resp_send_chunk(req, (const char*)buf + off, n) != ESP_OK) { rc = ESP_FAIL; break; }
+ }
+ lastPhotoRelease(); // release BEFORE the terminating chunk; the buffer is no longer read
+ if (rc == ESP_OK) httpd_resp_send_chunk(req, nullptr, 0); // end of chunked response
+ // On failure we skip the terminator and return ESP_FAIL, so httpd closes the socket.
+ return rc;
+}
+
// POST /api/settings -> device settings; written to globals + NVS, applied live
// (no reboot). Each field falls back to its current value if absent/malformed.
static esp_err_t portalSettingsPost(httpd_req_t* req) {
@@ -587,9 +681,9 @@ void load_config_portal(void) {
// /api/state polls; the old separate :81 stream server is gone (its sockets
// freed), so this single server can afford more open sockets.
config.max_open_sockets = 7;
- config.max_uri_handlers = 12; // 9 registered (/, /api/state, /api/events, /api/settings,
- // /api/wifi, /api/clear, /api/stream, /api/ota/check, GET /stream)
- // plus a little headroom
+ config.max_uri_handlers = 12; // 10 registered (/, /api/state, /api/events, /api/last.jpg,
+ // /api/settings, /api/wifi, /api/clear, /api/stream,
+ // /api/ota/check, GET /stream) plus a little headroom
config.lru_purge_enable = true;
config.stack_size = 8192; // headroom for ArduinoJson + String building
// Cap a single blocking recv so a stalled client can't pin the worker for the
@@ -605,6 +699,7 @@ void load_config_portal(void) {
httpd_uri_t u_root = { .uri = "/", .method = HTTP_GET, .handler = portalRootGet, .user_ctx = NULL };
httpd_uri_t u_state = { .uri = "/api/state", .method = HTTP_GET, .handler = portalStateGet, .user_ctx = NULL };
httpd_uri_t u_events = { .uri = "/api/events", .method = HTTP_GET, .handler = portalEventsGet, .user_ctx = NULL };
+ httpd_uri_t u_photo = { .uri = "/api/last.jpg", .method = HTTP_GET, .handler = portalLastPhotoGet, .user_ctx = NULL };
httpd_uri_t u_set = { .uri = "/api/settings", .method = HTTP_POST, .handler = portalSettingsPost, .user_ctx = NULL };
httpd_uri_t u_wifi = { .uri = "/api/wifi", .method = HTTP_POST, .handler = portalWifiPost, .user_ctx = NULL };
httpd_uri_t u_clear = { .uri = "/api/clear", .method = HTTP_POST, .handler = portalClearPost, .user_ctx = NULL };
@@ -613,6 +708,7 @@ void load_config_portal(void) {
httpd_register_uri_handler(config_httpd, &u_root);
httpd_register_uri_handler(config_httpd, &u_state);
httpd_register_uri_handler(config_httpd, &u_events);
+ httpd_register_uri_handler(config_httpd, &u_photo);
httpd_register_uri_handler(config_httpd, &u_set);
httpd_register_uri_handler(config_httpd, &u_wifi);
httpd_register_uri_handler(config_httpd, &u_clear);
diff --git a/version/r1.1/firmware/platformio/esp32_firmware_platformio/src/events.h b/version/r1.1/firmware/platformio/esp32_firmware_platformio/src/events.h
index c6df2b3..5a56014 100644
--- a/version/r1.1/firmware/platformio/esp32_firmware_platformio/src/events.h
+++ b/version/r1.1/firmware/platformio/esp32_firmware_platformio/src/events.h
@@ -66,7 +66,9 @@ static portMUX_TYPE g_speed_events_mux = portMUX_INITIALIZER_UNLOCKED;
// Radar task (Core 1): record one finished run. Called for every pass (speed-only or with
// a photo), so the companion sees the same events the cloud does. Cheap + non-blocking.
-static void eventsRecord(int speed, uint8_t dir, uint16_t mag) {
+// Returns the monotonic seq assigned to this pass, so the caller can tie an attached photo
+// (last_photo.h) to the same event the /api/events feed and the "Last capture" card share.
+static uint32_t eventsRecord(int speed, uint8_t dir, uint16_t mag) {
const int64_t now_us = esp_timer_get_time(); // read the clock outside the lock (no allocation)
portENTER_CRITICAL(&g_speed_events_mux);
SpeedEventRec& e = g_speed_events[g_speed_events_head];
@@ -78,7 +80,26 @@ static void eventsRecord(int speed, uint8_t dir, uint16_t mag) {
e.at_us = now_us;
g_speed_events_head = (g_speed_events_head + 1) % SPEED_EVENTS_CAP;
if (g_speed_events_count < SPEED_EVENTS_CAP) g_speed_events_count++;
+ const uint32_t seq = e.seq;
portEXIT_CRITICAL(&g_speed_events_mux);
+ return seq;
+}
+
+// httpd task: fetch the newest pass (speed/dir/unit/seq + age since it happened) for the
+// config portal's "Last capture" card. Returns false if no pass has been recorded yet.
+static bool eventsLatest(int* speed, uint8_t* dir, bool* kph, uint32_t* seq, uint32_t* ageSec) {
+ const int64_t now_us = esp_timer_get_time();
+ portENTER_CRITICAL(&g_speed_events_mux);
+ if (g_speed_events_count == 0) { portEXIT_CRITICAL(&g_speed_events_mux); return false; }
+ const int idx = ((g_speed_events_head - 1) % SPEED_EVENTS_CAP + SPEED_EVENTS_CAP) % SPEED_EVENTS_CAP;
+ const SpeedEventRec e = g_speed_events[idx]; // copy out under the lock
+ portEXIT_CRITICAL(&g_speed_events_mux);
+ if (speed) *speed = e.speed;
+ if (dir) *dir = e.dir;
+ if (kph) *kph = (bool)e.kph;
+ if (seq) *seq = e.seq;
+ if (ageSec) *ageSec = (uint32_t)((now_us - e.at_us) / 1000000LL);
+ return true;
}
// httpd task: serialize the ring newest-first into `out` (see the payload contract above).
diff --git a/version/r1.1/firmware/platformio/esp32_firmware_platformio/src/last_photo.h b/version/r1.1/firmware/platformio/esp32_firmware_platformio/src/last_photo.h
new file mode 100644
index 0000000..901904f
--- /dev/null
+++ b/version/r1.1/firmware/platformio/esp32_firmware_platformio/src/last_photo.h
@@ -0,0 +1,124 @@
+/**
+ * last_photo.h - Retain the most recent capture's JPEG in PSRAM so the config
+ * portal can show the last speed photo (GET /api/last.jpg) plus its metadata
+ * (the "Last capture" card).
+ *
+ * The cloud upload frees its JPEG copy as soon as the run is POSTed (see
+ * taskCore0 in main.cpp / sendUpload in api.h), so nothing normally survives
+ * for the UI to display. This module keeps ONE extra JPEG copy alive in PSRAM.
+ * A UXGA/quality-20 frame is a few hundred KB; the board's 8 MB OPI PSRAM
+ * already holds the camera driver's two framebuffers, so one more retained
+ * frame is a small, bounded cost. Internal SRAM is never touched.
+ *
+ * Concurrency -- the tricky part. The radar task (Core 1) stages a new photo at
+ * run-end via lastPhotoStage(); the port-80 httpd task serves it via
+ * lastPhotoBorrow()/lastPhotoRelease(). esp_http_server runs its URI handlers
+ * in a single task, so the reader never overlaps itself: the only race is one
+ * writer vs. one reader. A tiny portMUX critical section guards the pointer
+ * swap (never malloc/free inside it). The slow WiFi send runs with NO lock
+ * held. If the writer replaces the buffer while the reader is mid-send, the old
+ * buffer is handed to the reader to free on release (lastPhotoRelease) rather
+ * than freed under its feet -- so a fast car during a slow page load can never
+ * cause a use-after-free.
+ *
+ * Include order (main.cpp): after variables.h/events.h and before
+ * config_portal.h (whose /api/last.jpg handler and /api/state builder call in).
+ */
+#pragma once
+
+#include
+#include // esp_timer_get_time(): 64-bit us clock for the pass age
+#include
+
+static portMUX_TYPE g_lp_mux = portMUX_INITIALIZER_UNLOCKED;
+static uint8_t* g_lp_buf = nullptr; // current retained JPEG (PSRAM); nullptr = none held yet
+static size_t g_lp_len = 0;
+static uint8_t* g_lp_inflight = nullptr; // buffer the reader is currently sending (borrowed, do not free)
+static uint8_t* g_lp_reap = nullptr; // buffer the writer retired while borrowed; the reader frees it on release
+// Metadata of the pass whose photo we hold (g_lp_seq matches an /api/events seq):
+static uint32_t g_lp_seq = 0; // 0 = no photo held
+static int g_lp_speed = 0; // run max speed, in the unit given by g_lp_kph
+static uint8_t g_lp_kph = 0; // unit AT CAPTURE (1 = kph, 0 = mph)
+static uint8_t g_lp_dir = 0; // 0 = unknown, 1 = approaching, 2 = receding
+static int64_t g_lp_at_us = 0; // esp_timer_get_time() at capture (64-bit; age never wraps)
+
+// Radar task (Core 1): stage a copy of `buf` (len bytes) as the last capture.
+// Allocates a fresh PSRAM copy so ownership is independent of the upload buffer
+// that Core 0 frees after POSTing. On allocation failure the previous photo is
+// kept unchanged (better a stale image than none). `seq` ties the photo to its
+// /api/events pass so the portal can tell whether the newest pass has an image.
+static void lastPhotoStage(const uint8_t* buf, size_t len, uint32_t seq,
+ int speed, bool kph, uint8_t dir) {
+ if (buf == nullptr || len == 0) return;
+ uint8_t* copy = (uint8_t*)ps_malloc(len);
+ if (copy == nullptr) return; // keep the existing retained photo rather than dropping it
+ memcpy(copy, buf, len);
+ const int64_t now_us = esp_timer_get_time(); // read the clock outside the critical section
+
+ uint8_t* to_free = nullptr;
+ portENTER_CRITICAL(&g_lp_mux);
+ uint8_t* old = g_lp_buf;
+ g_lp_buf = copy;
+ g_lp_len = len;
+ g_lp_seq = seq;
+ g_lp_speed = speed;
+ g_lp_kph = kph ? 1 : 0;
+ g_lp_dir = dir;
+ g_lp_at_us = now_us;
+ if (old != nullptr && old == g_lp_inflight) {
+ g_lp_reap = old; // reader is sending `old`; it frees it on release. (Only ever one borrow at a
+ // time -- single httpd task -- so g_lp_reap holds at most one pending buffer.)
+ } else {
+ to_free = old; // nobody is reading `old`; free it once we leave the critical section
+ }
+ portEXIT_CRITICAL(&g_lp_mux);
+ if (to_free) free(to_free);
+}
+
+// httpd task: borrow the retained JPEG for sending. Returns nullptr (and sets
+// *len = 0) when no photo is held. MUST be paired with lastPhotoRelease(): while
+// borrowed, the writer will not free this buffer.
+static const uint8_t* lastPhotoBorrow(size_t* len) {
+ portENTER_CRITICAL(&g_lp_mux);
+ const uint8_t* b = g_lp_buf;
+ if (b == nullptr) {
+ portEXIT_CRITICAL(&g_lp_mux);
+ if (len) *len = 0;
+ return nullptr;
+ }
+ if (len) *len = g_lp_len;
+ g_lp_inflight = g_lp_buf;
+ portEXIT_CRITICAL(&g_lp_mux);
+ return b;
+}
+
+// httpd task: end a borrow started by lastPhotoBorrow(). Frees any buffer the
+// writer retired while the send was in flight.
+static void lastPhotoRelease(void) {
+ uint8_t* to_free = nullptr;
+ portENTER_CRITICAL(&g_lp_mux);
+ g_lp_inflight = nullptr;
+ if (g_lp_reap != nullptr) { to_free = g_lp_reap; g_lp_reap = nullptr; }
+ portEXIT_CRITICAL(&g_lp_mux);
+ if (to_free) free(to_free);
+}
+
+// httpd task: snapshot the retained photo's metadata for the "Last capture"
+// card. Returns false when no photo is held. ageSec is seconds since the pass.
+static bool lastPhotoMeta(uint32_t* seq, int* speed, bool* kph, uint8_t* dir, uint32_t* ageSec) {
+ const int64_t now_us = esp_timer_get_time();
+ portENTER_CRITICAL(&g_lp_mux);
+ if (g_lp_seq == 0) { portEXIT_CRITICAL(&g_lp_mux); return false; }
+ const uint32_t s = g_lp_seq;
+ const int sp = g_lp_speed;
+ const uint8_t k = g_lp_kph;
+ const uint8_t d = g_lp_dir;
+ const int64_t at = g_lp_at_us;
+ portEXIT_CRITICAL(&g_lp_mux);
+ if (seq) *seq = s;
+ if (speed) *speed = sp;
+ if (kph) *kph = (bool)k;
+ if (dir) *dir = d;
+ if (ageSec) *ageSec = (uint32_t)((now_us - at) / 1000000LL);
+ return true;
+}
diff --git a/version/r1.1/firmware/platformio/esp32_firmware_platformio/src/main.cpp b/version/r1.1/firmware/platformio/esp32_firmware_platformio/src/main.cpp
index 0626e33..e879efd 100644
--- a/version/r1.1/firmware/platformio/esp32_firmware_platformio/src/main.cpp
+++ b/version/r1.1/firmware/platformio/esp32_firmware_platformio/src/main.cpp
@@ -67,6 +67,7 @@ static const unsigned long POST_BOOT_WIFI_GRACE_MS = 120000;
#include "variables.h"
#include "events.h" // recent-detection ring served by GET /api/events (LAN companions)
+#include "last_photo.h" // retained last-capture JPEG for the config portal (GET /api/last.jpg)
#include "diagnostics.h"
#include "camera.h"
#include "camera_stream.h"
@@ -718,7 +719,14 @@ void taskCore1(void* parameter) { // Code for task running on Core 1
if (maxSpeed > 0) {
// Record into the local ring served by GET /api/events, so a LAN companion
// (e.g. the Blipscope "Speedscope" edition) can show recent speeds without the cloud.
- eventsRecord(req.speed_actual, req.direction, req.peak_mag);
+ uint32_t ev_seq = eventsRecord(req.speed_actual, req.direction, req.peak_mag);
+ // Retain this pass's photo (if it took one) for the config portal's "Last capture"
+ // card. lastPhotoStage() makes its own PSRAM copy, so the buffer handed to Core 0
+ // via the queue below is still owned/freed by Core 0 as before. Tagged with ev_seq
+ // so the portal can tell whether the newest /api/events pass actually has an image.
+ if (photo_buf != nullptr) {
+ lastPhotoStage(photo_buf, photo_len, ev_seq, req.speed_actual, is_kph, req.direction);
+ }
if (xQueueSend(uploadQueue, &req, 0) != pdTRUE) {
Serial.println("[RUN] upload queue FULL, dropping event");
if (photo_buf != nullptr) {