Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ button.warn{background:#3a2326;color:#f87171;border:1px solid #5b2a2e}
.claim{text-align:center;color:var(--ok);font-weight:600;min-height:1.2em}
.muted{color:var(--mut);font-size:12px;margin-top:6px;word-break:break-all}
.stream{display:none;width:100%;max-width:320px;margin-top:10px;border-radius:8px;background:#000;aspect-ratio:4/3}
.big .u{font-size:15px;font-weight:500;color:var(--mut);margin-left:4px}
.cap{display:none;width:100%;border-radius:8px;margin-top:12px;background:#000;aspect-ratio:4/3;object-fit:contain}
.noimg{display:none;width:100%;aspect-ratio:4/3;margin-top:12px;border-radius:8px;background:#0c0e12;border:1px dashed var(--bd);color:var(--mut);font-size:14px;align-items:center;justify-content:center;text-align:center}
.toast{position:fixed;left:50%;bottom:20px;transform:translateX(-50%);background:var(--ok);color:#06210f;padding:10px 16px;border-radius:8px;font-weight:600;opacity:0;transition:opacity .2s;pointer-events:none}
.toast.show{opacity:1}
</style>
Expand All @@ -92,6 +95,14 @@ button.warn{background:#3a2326;color:#f87171;border:1px solid #5b2a2e}
<div class=claim id=claim>&mdash;</div>
</div>

<div class=card id=lastCard style=display:none>
<h2>Last capture</h2>
<div class=big><span id=lastSpeed>&mdash;</span><span class=u id=lastUnit></span></div>
<div class=muted id=lastMeta style=text-align:center>&mdash;</div>
<img id=lastImg class=cap alt="last capture photo">
<div id=lastNoImg class=noimg>No photo available</div>
</div>

<div class=card>
<h2>Device</h2>
<label class=switch><span>Units (KPH)</span><input type=checkbox id=isKph></label>
Expand Down Expand Up @@ -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;
Expand All @@ -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(){
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 <img> (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) {
Expand Down Expand Up @@ -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
Expand All @@ -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 };
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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).
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <Arduino.h>
#include <esp_timer.h> // esp_timer_get_time(): 64-bit us clock for the pass age
#include <string.h>

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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand Down