From 8c63b4b41edcba0a34f01cd26102c5410475ca63 Mon Sep 17 00:00:00 2001 From: Daniel Frenkel Date: Sat, 11 Jul 2026 08:02:02 -0700 Subject: [PATCH 1/2] feat(r1.1/esp32): show last capture (speed + photo) on config portal Retain the most recent capture's JPEG in PSRAM (last_photo.h) and add a top-of-page 'Last capture' card to the config portal showing the newest pass's speed, direction, age, and photo. - last_photo.h: one retained JPEG copy in PSRAM (~hundreds of KB of the board's 8 MB; internal SRAM untouched). Writer (radar task, Core 1) vs. single httpd reader is guarded by a portMUX pointer-swap plus a borrow/release protocol, so a fresh capture mid-send never frees the buffer the web server is still streaming. - GET /api/last.jpg: chunked JPEG, Host-allowlisted like /api/state, cached per-seq so the frame is refetched only when it changes. - /api/state gains lastSeq/lastSpeed/lastKph/lastDir/lastAgeSec + photoSeq; eventsRecord() now returns the pass seq so the photo ties to its event. Co-Authored-By: Claude Fable 5 --- .../src/config_portal.h | 97 +++++++++++++- .../esp32_firmware_platformio/src/events.h | 23 +++- .../src/last_photo.h | 124 ++++++++++++++++++ .../esp32_firmware_platformio/src/main.cpp | 10 +- 4 files changed, 249 insertions(+), 5 deletions(-) create mode 100644 version/r1.1/firmware/platformio/esp32_firmware_platformio/src/last_photo.h diff --git a/version/r1.1/firmware/platformio/esp32_firmware_platformio/src/config_portal.h b/version/r1.1/firmware/platformio/esp32_firmware_platformio/src/config_portal.h index 8e1be0a..ad35078 100644 --- a/version/r1.1/firmware/platformio/esp32_firmware_platformio/src/config_portal.h +++ b/version/r1.1/firmware/platformio/esp32_firmware_platformio/src/config_portal.h @@ -80,6 +80,8 @@ 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} .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} @@ -92,6 +94,14 @@ button.warn{background:#3a2326;color:#f87171;border:1px solid #5b2a2e}
+ +

Device

@@ -159,6 +169,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 +186,25 @@ async function refresh(){ filled=true; } } +// "Last capture" card. Text (speed/direction/age) comes from the newest /api/events +// pass on every poll; the photo is fetched only when photoSeq changes, so the +// hundreds-of-KB JPEG isn't re-pulled on every 4s /api/state refresh. +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'),note=$('lastNote'); + if(s.photoSeq){ + if(s.photoSeq!==shownPhotoSeq){img.src='/api/last.jpg?seq='+s.photoSeq;img.style.display='block';shownPhotoSeq=s.photoSeq} + if(s.photoSeqfetch(p,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},body:JSON.stringify(b)}); async function saveSettings(){ @@ -252,6 +282,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 +474,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 +676,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 +694,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 +703,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) { From d5455d43a795797be8c68a60b11619d176a7aa76 Mon Sep 17 00:00:00 2001 From: Daniel Frenkel Date: Sat, 11 Jul 2026 08:17:28 -0700 Subject: [PATCH 2/2] feat(r1.1/esp32): last-capture card shows a placeholder, never a stale photo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The image now always corresponds to the newest pass: shown only when that pass has its own photo (photoSeq === lastSeq), otherwise a same-size 'No photo — speed-only pass' placeholder box. Drops the previous behavior of displaying an older pass's photo with an explanatory note. Co-Authored-By: Claude Fable 5 --- .../src/config_portal.h | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/version/r1.1/firmware/platformio/esp32_firmware_platformio/src/config_portal.h b/version/r1.1/firmware/platformio/esp32_firmware_platformio/src/config_portal.h index ad35078..de94220 100644 --- a/version/r1.1/firmware/platformio/esp32_firmware_platformio/src/config_portal.h +++ b/version/r1.1/firmware/platformio/esp32_firmware_platformio/src/config_portal.h @@ -82,6 +82,7 @@ button.warn{background:#3a2326;color:#f87171;border:1px solid #5b2a2e} .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} @@ -99,7 +100,7 @@ button.warn{background:#3a2326;color:#f87171;border:1px solid #5b2a2e}
last capture photo - +
No photo available
@@ -187,8 +188,10 @@ async function refresh(){ } } // "Last capture" card. Text (speed/direction/age) comes from the newest /api/events -// pass on every poll; the photo is fetched only when photoSeq changes, so the -// hundreds-of-KB JPEG isn't re-pulled on every 4s /api/state refresh. +// 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':''; @@ -198,12 +201,14 @@ function updateLastCapture(s){ txt('lastSpeed',s.lastSpeed); txt('lastUnit',s.lastKph?'KPH':'MPH'); txt('lastMeta',ago(s.lastAgeSec)+dirTxt(s.lastDir)); - const img=$('lastImg'),note=$('lastNote'); - if(s.photoSeq){ - if(s.photoSeq!==shownPhotoSeq){img.src='/api/last.jpg?seq='+s.photoSeq;img.style.display='block';shownPhotoSeq=s.photoSeq} - if(s.photoSeqfetch(p,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},body:JSON.stringify(b)});