diff --git a/docs/BRINGUP.md b/docs/BRINGUP.md index ed6245a..58b2c94 100644 --- a/docs/BRINGUP.md +++ b/docs/BRINGUP.md @@ -142,26 +142,28 @@ Engine model: one worker owns the V4L2 node persistently (media-ctl / v4l2-ctl configure sequences identical to gfhardware/cam.py, factory exposure/gain/WB, software hflip in the demosaic); starts on demand, full teardown after 10 s idle so gfhardware one-shot grabs still work. -The cameras share the hardware video-mux; arbitration: -- **Stream clients pin the selection.** A STREAM request for the other - camera waits up to 3 s for the pin to drain (absorbs page - navigations), then returns 409. The index page therefore runs a - single toggled stream, never two. -- **Snapshots never fail busy.** A snapshot of the other camera makes - the worker *borrow* the mux: pause the stream, switch, grab one - frame, switch back (~1-2 s freeze for stream viewers; "Head peek" on - the index page uses this). Arbitration compares against the engine's - home camera, so requests racing the borrow window still 409. +The cameras share the hardware video-mux; the NEWEST request wins it +(single-operator model): +- **Streams preempt.** A STREAM request for the other camera kicks the + current stream clients - their streams end cleanly (viewers freeze on + the last frame) - and switches. The only stream failure mode is a + switch timeout (a kicked client not draining within 3 s). +- **Snapshots borrow.** A snapshot of the other camera does not switch: + the worker pauses the stream, switches, grabs one frame, switches + back (~1-2 s freeze; "Head peek" on the index page uses this). + Arbitration compares against the engine's home camera, so stream + requests racing the borrow window preempt correctly. The per-camera lamp (`pic/lid_led` / `head/white_led`) is raised to `FORGECTRL_LAMP` (default 132) while capturing and restored on idle. Bench (2026-08-03, on the board): stream 3.2 fps sustained at 1296×972; full-res snapshot 2.4 s warm / 2.7 s cold (cold includes the pipeline -bring-up); camera switch works both ways; two parallel clients share the -frame rate; idle teardown observed. Borrow verified: head snapshot 200 -during a lid stream, the stream rode through the gap (120 frames over a -40 s window with one borrow), and head-stream requests fired both into -the borrow window and during normal streaming got 409. **Motion coexistence proven**: X +bring-up); two parallel same-camera clients share the frame rate; idle +teardown observed. Borrow verified: head snapshot 200 during a lid +stream, the stream riding through the ~1-2 s gap. Preemption verified: +a head-stream request ended the lid viewer's stream cleanly (curl exit +0 mid-stream) and was serving head frames within ~2 s; switching back +likewise. **Motion coexistence proven**: X round-trip jogs at F1200 with an active stream — producer stats `clamped 0`, max behind 4.5 ms (the daemon runs at nice +5, single core). Run by hand: `/usr/bin/forgectrl >> /data/forgectrl.log 2>&1 &` diff --git a/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/cam.c b/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/cam.c index 54e4b39..d522da9 100644 --- a/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/cam.c +++ b/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/cam.c @@ -91,6 +91,8 @@ static struct { cam_id_t home_cam; /* camera the engine serves for streaming - * what arbitration must compare against */ int clients; + uint64_t kick_gen; /* bumped to preempt all current stream + * clients (their streams end cleanly) */ struct timespec last_activity; /* published stream frame (half-res JPEG) */ @@ -727,9 +729,15 @@ static int ensure_engine(cam_id_t cam, char *err, size_t errlen) if (running && cur != cam) { if (clients > 0) { - /* Grace: a client that just disconnected releases its pin - * only when the MHD send fails on the next frame - absorb - * that (page navigations) instead of failing instantly. */ + /* Last request wins: preempt the current stream clients + * (single-operator machine - the newest ask is the + * operator). Kicked clients wake, end their streams + * cleanly (viewers freeze on their last frame), and + * release their pins; wait for that to drain. */ + pthread_mutex_lock(&eng.lock); + eng.kick_gen++; + pthread_cond_broadcast(&eng.frame_cv); + pthread_mutex_unlock(&eng.lock); struct timespec t0, t; now_ts(&t0); do { @@ -741,8 +749,8 @@ static int ensure_engine(cam_id_t cam, char *err, size_t errlen) } while (clients > 0 && ts_diff(&t, &t0) < SWITCH_GRACE_S); if (clients > 0) { snprintf(err, errlen, - "camera busy: %d client(s) streaming %s", - clients, camdefs[cur].name); + "camera switch timed out: %d client(s) still " + "attached to %s", clients, camdefs[cur].name); return -1; } } @@ -878,6 +886,7 @@ int cam_snapshot(cam_id_t cam, int full, int quality, struct cam_client { uint64_t last_seq; + uint64_t gen; /* kick generation at open; a bump ends the stream */ uint8_t *buf; size_t cap; }; @@ -897,6 +906,7 @@ cam_client_t *cam_client_open(cam_id_t cam, char *err, size_t errlen) } pthread_mutex_lock(&eng.lock); eng.clients++; + c->gen = eng.kick_gen; now_ts(&eng.last_activity); pthread_mutex_unlock(&eng.lock); pthread_mutex_unlock(&eng.ctl); @@ -907,7 +917,7 @@ long cam_client_next(cam_client_t *c, const uint8_t **jpeg) { pthread_mutex_lock(&eng.lock); int timeouts = 0; - while (eng.running && eng.seq <= c->last_seq) { + while (eng.running && c->gen == eng.kick_gen && eng.seq <= c->last_seq) { struct timespec deadline; clock_gettime(CLOCK_REALTIME, &deadline); deadline.tv_sec += CLIENT_WAIT_S; @@ -915,7 +925,7 @@ long cam_client_next(cam_client_t *c, const uint8_t **jpeg) == ETIMEDOUT && ++timeouts >= 2) break; } - if (!eng.running || eng.seq <= c->last_seq) { + if (!eng.running || c->gen != eng.kick_gen || eng.seq <= c->last_seq) { pthread_mutex_unlock(&eng.lock); return -1; } diff --git a/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/cam.h b/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/cam.h index 943e8a6..6458c96 100644 --- a/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/cam.h +++ b/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/cam.h @@ -36,9 +36,11 @@ void cam_engine_shutdown(void); int cam_snapshot(cam_id_t cam, int full, int quality, uint8_t **jpeg, size_t *len, char *err, size_t errlen); -/* Stream client: open pins the engine to a camera (starting or switching it - * if needed), next blocks for a frame newer than the last one returned and - * copies it into a client-owned buffer, close releases the pin. */ +/* Stream client: open makes the engine serve `cam` (starting it, or + * preempting the current clients and switching - last request wins; the + * preempted clients' next() returns -1 so their streams end cleanly). + * next blocks for a frame newer than the last one returned and copies it + * into a client-owned buffer, close releases the pin. */ typedef struct cam_client cam_client_t; cam_client_t *cam_client_open(cam_id_t cam, char *err, size_t errlen); diff --git a/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/main.c b/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/main.c index 3d0cdbb..19823c0 100644 --- a/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/main.c +++ b/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/main.c @@ -12,11 +12,11 @@ * GET /cam/snapshot?cam=&res=full|half&q= single JPEG (default full res) * GET /cam/status JSON engine status * - * The two cameras share the hardware mux, so streaming clients pin the - * selection: a STREAM request for the other camera waits a short grace - * for the pin to drain, then returns 409. A SNAPSHOT of the other camera - * never fails busy - the engine borrows the mux for one frame and the - * stream freezes for a few seconds instead. + * The two cameras share the hardware mux; the newest request wins it. A + * STREAM request for the other camera preempts the current stream + * clients (their streams end cleanly - viewers freeze on the last frame) + * and switches. A SNAPSHOT of the other camera does not switch: the + * engine borrows the mux for one frame and the stream freezes briefly. * Environment: FORGECTRL_PORT (8080), FORGECTRL_STREAM_Q (75), * FORGECTRL_LAMP (132). * @@ -246,11 +246,17 @@ static const char index_html[] = "msg=document.getElementById('msg');" "function setCam(c){cam=c;retries=0;msg.textContent='';" "v.src='/cam/stream?cam='+c+'&t='+Date.now();}" - "v.onerror=function(){if(retries++<5){" - "msg.textContent='stream retrying...';" - "setTimeout(function(){v.src='/cam/stream?cam='+cam+'&t='+Date.now();}," - "700);}else{msg.textContent=" - "'stream unavailable (camera busy from another viewer?)';}};" + "function reload(){v.src='/cam/stream?cam='+cam+'&t='+Date.now();}" + /* Retry only when the engine still serves our camera - if another + * viewer preempted it, retrying would steal it right back. */ + "v.onerror=function(){fetch('/cam/status').then(function(r){" + "return r.json();}).then(function(s){" + "if(s.cam===cam&&retries++<5){msg.textContent='stream retrying...';" + "setTimeout(reload,700);}else if(s.cam!==cam){msg.textContent=" + "'stream taken by another viewer ('+s.cam+') - press a button to resume';}" + "else{msg.textContent='stream error - press a stream button to retry';}" + "}).catch(function(){msg.textContent=" + "'service unreachable';});};" "function peek(){" "msg.textContent='head peek (stream pauses a few seconds)...';" "p.style.display='inline';p.onload=function(){msg.textContent='';};"