`, and Snap-volume / Burst buttons + a `#devices-ls-lastcap` card. Mirror the `.devices-camera-*` class structure for the image stage so the existing zoom/pan inline machinery can be reused or replicated.
+
+- [ ] **Step 2: JS — frame paint + controls**
+
+Add `'manual'` to `VIEWS`. Add a frame handler mirroring `handleCameraFrame` (separate DOM ids `_lsImg`/`_lsMeta`, its own FPS window) and subscribe `ClientEventBus.on('LIGHTSHEET_FRAME', handleLightsheetFrame)` in `setupCameraWiring` (or a new `setupManualWiring`). Live toggle hits `/api/devices/lightsheet/live/start|stop`. Galvo/piezo/exposure inputs: on `input`, **debounce ~120 ms**, then `fetch('/api/devices/lightsheet/live/params', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({galvo, piezo, exposure})})`. Illumination toggles POST their routes. Acquire buttons POST `/api/devices/acquire/burst|volume` with the current params, show "acquiring…" then render the result ref in `#devices-ls-lastcap`. Embed A's graph: `if (window.TemperatureGraph) TemperatureGraph.init(document.getElementById('devices-ls-tempgraph'), 'current')`. FPS readout from the frame handler's window (reuse the `computeCameraFps` approach).
+
+- [ ] **Step 3: `node --check`** — `node --check gently/ui/web/static/js/devices.js` (exit 0).
+
+- [ ] **Step 4: Chrome-MCP harness verification** — build a standalone harness (like A's): copy `event-bus.js`, `temperature-graph.js`, the new manual JS, and `main.css`; stub `fetch` for `live/params` + status + acquire; feed simulated `LIGHTSHEET_FRAME` events (a moving synthetic gradient that shifts when galvo/piezo "params" change) to demonstrate the slide-and-see; screenshot; run the alignment/spacing/contrast UI audit and fix flaws. Live in-app + FPS verification is deferred to the rig.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add gently/ui/web/templates/index.html gently/ui/web/static/js/devices.js gently/ui/web/static/css/main.css
+git commit -m "feat(manual-mode): Manual view — SPIM live canvas, galvo/piezo/exposure, illumination, acquire, temp"
+```
+
+---
+
+### Task 7: FPS measurement (rig) + conditional binary transport
+
+**Files:**
+- Create: `docs/superpowers/notes/2026-06-28-lightsheet-fps-measurement.md` (record the numbers)
+- (Conditional) Modify: `gently/ui/web/connection_manager.py` + `agent_ws`/`server.py` + `websocket.js` for a binary frame path.
+
+**This task is a measurement + a gate, not unconditional code.**
+
+- [ ] **Step 1:** On the rig, start lightsheet live and record from the Manual-view FPS readout + device logs: **device grab rate**, **delivered rate**, **browser paint rate**, at 512 px/q70 and at a reduced 384 px/q60. Write them into the notes file with the exposure used.
+- [ ] **Step 2: Diagnose.** If device grab < ~15 fps → limiter is exposure/readout/rpyc, not transport — tune exposure/size/quality, stop here. If device grab ≥ target but browser paint < target → transport is the bottleneck → do Step 3.
+- [ ] **Step 3 (conditional): binary WebSocket path.** Add a `send_bytes`-based frame channel: device→agent SSE stays; on the agent→browser hop, push the raw JPEG via `websocket.send_bytes(prefix + jpeg)` (small type byte), bypassing base64 + the per-client `json.dumps` + the EventBus fan-out; browser `onmessage` binary → `createImageBitmap(new Blob([buf]))` → `ctx.drawImage`. Re-measure and record.
+- [ ] **Step 4: Commit** the notes (and any binary-path code, if built).
+
+```bash
+git add docs/superpowers/notes/2026-06-28-lightsheet-fps-measurement.md
+git commit -m "docs(manual-mode): lightsheet live FPS measurement + transport decision"
+```
+
+---
+
+## Self-Review
+
+**Spec coverage:** §2.1 streamer → Task 2; §2.2 monitor → Task 4; §2.3 proxy routes → Task 5; §2.4 client methods → Task 3; §2.5 Manual view → Task 6; §2.6 concurrency (`_state_pause_counter` back-off) → Task 2 streamer loop; §2.7 brightfield laser-off → Tasks 2 & 5 (`laser/off`); §3 transport baseline + measurement + binary escalation → Tasks 1/4 (baseline) + Task 7 (measure/escalate); §4 error handling → Tasks 2/5 (try/except, 503/502); §5 testing → each task's tests + Task 6 harness; LIGHTSHEET_FRAME event → Task 1. Single-camera (no side A/B) reflected throughout. ✓
+
+**Open confirmations (explicit, not placeholders):** pymmcore sequence-acq method availability + core handle (Task 2); scanner/piezo park method names (Task 2); the SetupPanel scanner/beam question (Task 2); the client low-level POST helper name (Task 3); the `require_control` test-override mechanism (Task 5). Each names a concrete fallback.
+
+**Type consistency:** frame payload `{t, shape, downsample, mime, jpeg_b64}` (reused encoder) across Tasks 2/3/4/6; live params `{galvo, piezo, exposure}` across Tasks 2/3/5/6; event `LIGHTSHEET_FRAME` across Tasks 1/4/6; `lightsheet_monitor` attr across Tasks 4/5. Consistent.
diff --git a/docs/superpowers/specs/2026-06-28-manual-mode-live-view-design.md b/docs/superpowers/specs/2026-06-28-manual-mode-live-view-design.md
new file mode 100644
index 00000000..3d846a36
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-28-manual-mode-live-view-design.md
@@ -0,0 +1,218 @@
+# Design: Manual Mode — SPIM single-slice brightfield live view (sub-project B1)
+
+Status: design approved in brainstorm (2026-06-28).
+Base branch: off `feature/temperature-interface` (sub-project A) — B1 embeds A's now-portable
+temperature graph and its acquisition temperature stamp.
+
+## 0. Where this sits (execution roadmap)
+
+Sub-project **B** of the temperature-strain experiment prep. B is split:
+
+- **B1 (this spec) — the hand-driven experiment surface:** a **Manual view** in the Devices
+ tab with a SPIM single-slice brightfield live view (one selectable side A/B), live
+ galvo/piezo/exposure controls, brightfield illumination (LED, no laser), temperature
+ setpoint + A's graph, and by-hand burst/volume triggers.
+- **B2 (deferred):** laser channel-preset browser, full timelapse/volume config form,
+ side-by-side dual view, richer camera selection.
+
+This is **load-bearing for next week** — the experiments are imaged by hand first. The first
+experiment round is hand-driven through this view.
+
+## 1. Overview
+
+Today the Devices tab is read-only and there is no human-driven imaging surface. The
+temperature experiments image with the **SPIM cameras under LED/brightfield illumination (no
+laser, DIC/BF-like)** — a *single plane*, not a scan — and the operator needs to slide the
+**galvo (sheet position)** and **imaging piezo (focal plane)** and *see the effect live*, then
+trigger bursts/volumes by hand around a temperature change.
+
+### What already exists (reused)
+- The MMCore core is a real `pymmcore.CMMCore`; **continuous sequence acquisition** is already
+ proven in the volume path (`acquisition.py:199-231`: `startSequenceAcquisition`/`popNextImage`/
+ `getRemainingImageCount`/`stopSequenceAcquisition`).
+- `capture_lightsheet_image(piezo_position, galvo_position, exposure_ms)` (`client.py:493`) —
+ a single-plane snap primitive (but it goes through the Bluesky queue → too heavy for a live
+ loop; used only for one-shot snaps).
+- The **bottom-camera live streamer** (`device_layer.py` `_bottom_camera_streamer`) — the
+ structural template (subscriber-gated SSE, drop-oldest backpressure, `_state_pause_counter`
+ heavy-plan back-off, JPEG/base64 encode, EventBus→WS transport).
+- Direct device writes: `set_led`, `set_laser_power`, `set_camera_led_mode`, `set_room_light`,
+ `set_temperature`, `move_to_position`, `acquire_volume`, `acquire_burst` (client methods).
+- `require_control` auth (`auth.py`) — localhost = CONTROL on the rig; remote needs a token.
+- The Devices view-switcher pattern (Map/Details/3D) to host a new "Manual" view.
+- A's portable temperature-graph component + per-acquisition temperature stamp.
+
+### What must be built
+A device-layer **lightsheet live streamer** (continuous sequence + peek-latest), an agent-side
+**LightsheetStreamMonitor**, **`require_control` browser proxy routes**, two new client methods,
+and the **Manual view** UI. Transport reuses the JSON/base64 path first; a binary path is a
+**conditional escalation gated on an FPS measurement**.
+
+## 2. Architecture — seven units
+
+### 2.1 Lightsheet live streamer (device layer, new)
+`_lightsheet_live_streamer` task + `handle_lightsheet_stream` SSE handler in `device_layer.py`,
+mirroring the bottom-camera streamer's skeleton (subscriber-gated lifecycle, per-subscriber
+`asyncio.Queue` drop-oldest backpressure, `_state_pause_counter` back-off).
+
+**Grab mechanism — the MM live pattern (replacing the snap loop):**
+- On first subscriber: select the SPIM side camera (`core.setCameraDevice`), set exposure,
+ **park** galvo + piezo at the current params, `core.startContinuousSequenceAcquisition(0)`.
+- Loop: **peek the latest** frame (`getLastImage` / `getNBeforeLastTaggedImage`) — never
+ `popNextImage` (don't drain). Encode → broadcast. Pace to `max(exposure, ~1/30 s)` up to the
+ display cap (MM's clamp).
+- On last subscriber: `stopSequenceAcquisition`, restore prior camera/state.
+
+**Live param updates** (`POST /api/lightsheet/live/params {side, galvo, piezo, exposure}`):
+- **galvo / piezo → applied live, no restart** (move scanner/imaging-piezo; next frames show
+ the new plane — the slide-and-see response).
+- **exposure / side → stop → reconfigure → restart** (camera params can't change mid-sequence).
+
+**Resolution/quality:** the lightsheet stream gets its **own configurable size/quality**
+(default ~512 px, JPEG q≈70) — higher than the bottom-camera 360 px thumbnail, since this view
+is for judging focus. Resolution↔FPS tradeoff feeds the §3 measurement.
+
+**rpyc caveat:** the core may be an rpyc proxy, so each `getLastImage` transfers a full frame
+across that boundary *inside the device-layer process*; the streamer encodes to JPEG there, so
+only a small payload leaves the process. rpyc bounds the *grab* rate, not the web payload.
+
+**Implementer confirmations (flagged, not guesses):** the exact device methods to park the
+galvo (scanner) + imaging piezo and the SPIM side-camera device names
+(`device_factory.py`/`devices/scanner.py`/`optical.py`); and whether brightfield/LED live needs
+the scanner/beam enabled at all or a fully static park — confirm against
+`../micro-manager/plugins/ASIdiSPIM/.../SetupPanel.java`. B1 assumes static park, laser off.
+
+### 2.2 Lightsheet stream monitor (agent Service, new)
+Mirrors `BottomCameraStreamMonitor`: consumes the device SSE via a new client generator and
+republishes frames on the EventBus as a new high-volume `EventType.LIGHTSHEET_FRAME`
+(added to `_NO_HISTORY_TYPES` and the `websocket.js` events-table exclusion). Opt-in;
+started/stopped via the proxy routes.
+
+### 2.3 Browser proxy routes (viz server, `require_control`, new)
+In `data.py`'s `create_router`, each `Depends(require_control)`, each resolving the live client
+via `_resolve_client()`:
+
+| Route | Calls |
+|---|---|
+| `POST /api/devices/lightsheet/live/start` | start `LightsheetStreamMonitor` |
+| `POST /api/devices/lightsheet/live/stop` | stop the monitor |
+| `GET /api/devices/lightsheet/live/status` | `{available, streaming, side, last_frame_ts, fps}` |
+| `POST /api/devices/lightsheet/live/params` | `client.set_lightsheet_live_params(side,galvo,piezo,exposure)` |
+| `POST /api/devices/led/set` | `client.set_led(state)` |
+| `POST /api/devices/laser/off` | `client.set_laser_power(wl, 0)` / "ALL OFF" |
+| `POST /api/devices/camera/led_mode` | `client.set_camera_led_mode(use_led)` |
+| `POST /api/devices/room_light/set` | *(exists `data.py:335`)* |
+| `POST /api/devices/stage/move` | `client.move_to_position(x,y)` |
+| `POST /api/devices/acquire/burst` | `client.acquire_burst(...)` |
+| `POST /api/devices/acquire/volume` | `client.acquire_volume(...)` |
+| `POST /api/devices/temperature/set` | *(exists `data.py:381`)* |
+
+The device-layer aiohttp routes have no auth; the browser must go through this proxy.
+
+### 2.4 New client methods
+`DiSPIMMicroscope.stream_lightsheet(...)` (async generator over `GET /api/lightsheet/stream`,
+mirroring `stream_bottom_camera`) and `set_lightsheet_live_params(side, galvo, piezo, exposure)`
+(`POST /api/lightsheet/live/params`). Everything else already exists.
+
+### 2.5 Manual view (frontend, new `#devices-view-manual`)
+A "Manual" entry in the Devices view-switcher → two-column layout:
+- **Left:** the live image canvas (reusing the bottom-camera render + `zoom-pan.js` +
+ crosshair), an FPS + side overlay, and a Start/Stop Live toggle; a "Last capture" card below.
+- **Right control rail:** Camera (side A/B selector, exposure) · Focus & sheet (galvo + piezo
+ sliders with numeric fields + nudge) · Illumination (LED, camera-LED-mode, room light, laser
+ OFF indicator) · Temperature (setpoint + A's embedded graph) · Acquire (Snap volume / Burst
+ buttons with a small param summary).
+
+Interactions: galvo/piezo sliders → **debounced** `POST live/params` → canvas updates in a
+couple of frames; acquire → "acquiring…" overlay → Last-capture card → live resumes; FPS
+readout surfaces the §3 measurement. Toggle/setpoint widgets model on the existing room-light
+and temp-set controls.
+
+### 2.6 Concurrency rule
+Live holds the camera/MMCore, so a triggered burst/volume **stops the live sequence → acquires
+→ resumes** (via the existing `_state_pause_counter` back-off). Only **one live stream at a
+time** (lightsheet vs bottom contend for MMCore). Acquisitions land with A's temperature stamp.
+
+### 2.7 Brightfield safety
+On entering manual/brightfield live, **laser is forced off**; `DiSPIMLightSource` power-limit
+clamps already cap any laser power.
+
+## 3. Transport & the FPS measure→escalate path (Approach ①)
+
+**Baseline (build first):** lightsheet frames ride the existing path unchanged —
+device base64-JPEG-in-JSON over SSE → `LightsheetStreamMonitor` → EventBus `LIGHTSHEET_FRAME`
+→ `ConnectionManager.broadcast` (`json.dumps` + `send_text`) → browser canvas. Additions: the
+new EventType (+ `_NO_HISTORY_TYPES` + `websocket.js` exclusion) and a browser paint handler.
+
+**Instrumented measurement (the gate):** three counters — **device grab rate**, **delivered
+rate**, **browser paint rate** (the FPS readout). Target **≥ ~15 fps usable** (stretch 25–30),
+latency < ~150 ms. Run on the rig; record the numbers in the plan.
+
+**Diagnosis fork:**
+- device grab < target → limiter is exposure / readout / rpyc, *not* transport → tune
+ exposure / size / quality; binary won't help.
+- device grab ≥ target but browser paint < target → transport is the bottleneck → escalate.
+
+**Escalation (conditional task):** a **binary WebSocket path on the agent→browser hop** —
+`websocket.send_bytes(jpeg)` with a small type prefix, browser `createImageBitmap` → canvas —
+bypassing base64 (+33%), the per-client `json.dumps`, and the EventBus fan-out. The device→agent
+SSE stays (single consumer). Built **only if** the measurement points to transport.
+
+## 4. Error handling & edges
+- No SPIM camera / device down → `live/status {available:false}` → empty canvas, controls
+ disabled. Sequence fails to start → surfaced, graceful (no crash).
+- rpyc/slow grab → backpressure drops oldest; FPS readout shows reality.
+- Param out of range → `DiSPIMLightSource` clamps + UI input bounds; laser forced off in
+ brightfield.
+- Only one live stream at a time; live is subscriber-gated → stops on view-switch / page-close.
+- `require_control` denied (remote, no token) → 403 → controls shown disabled.
+
+## 5. Testing
+- **Device layer:** streamer against a fake core supporting sequence acquisition
+ (start/stop/`getLastImage`) — param updates (galvo/piezo live vs exposure/side restart),
+ heavy-plan pause/resume, subscriber lifecycle.
+- **Proxy routes:** FastAPI TestClient + mock client — `require_control` 403 gate, param
+ forwarding.
+- **Client methods:** `stream_lightsheet` SSE parsing; `set_lightsheet_live_params` POST.
+- **Frontend:** `node --check` + a Chrome-MCP harness (like A) for canvas + slide-and-see +
+ states, plus a UI audit. (No JS unit harness in repo.)
+- **FPS:** a documented **rig measurement step** (grab/deliver/paint fps); the binary path is a
+ **conditional follow-up task gated on those numbers**.
+- **Integration (rig):** live renders; slider changes the image; burst persists with
+ temperature; live resumes.
+
+Much of B1 needs the **real rig** (rpyc core, SPIM cameras, galvo/piezo) to verify; off-rig we
+cover the streamer (fake core), proxy routes, client methods, and the frontend harness, and
+defer live/FPS verification to the microscope — as with A.
+
+## 6. Out of scope (B2)
+Laser channel-preset browser; full timelapse/volume configuration form; side-by-side dual view;
+richer camera selection.
+
+## 7. Appendix: ASIdiSPIM two-camera model (B2 reference)
+
+From the MM ASIdiSPIM plugin (`../micro-manager/plugins/ASIdiSPIM`), for when B2 adds side A/B:
+
+- **Camera roles** (`data/Devices.java:93-121`): `CAMERAA` (side-A imaging), `CAMERAB`
+ (side-B imaging), `CAMERALOWER` (bottom), `MULTICAMERA` (the MMCore *Utilities* "Multi Camera"
+ fusion adapter). `SPIM_CAMERAS = {CAMERAA, CAMERAB}`; a `Sides` enum (A/B/NONE).
+- **Live / single-side select** (`data/Cameras.java:163-186`, `setCamera`): `core.setCameraDevice(
+ mmDevice)` bracketed by stop/start-live; `getCurrentCamera` (`:193-210`) treats the Core as the
+ source of truth. Live can target one side **or** `MULTICAMERA` (dual live via the Utilities
+ Multi-Camera device).
+- **Multi-Camera discovery** (`DevicesPanel.java:167-180`): auto-detected by
+ `getDeviceLibrary=="Utilities"` AND `getDeviceDescription=="Combine multiple physical cameras
+ into a single logical camera"`.
+- **Acquisition does NOT use the fusion device** (`AcquisitionPanel.java:2668-2671`): it runs
+ **two parallel `startSequenceAcquisition`** on the physical side cameras into one shared buffer
+ (equal ROI required) and **demuxes by the per-image `"Camera"` tag** (`:2736-2772`; side A → even
+ channel indices, side B → odd). The Utilities Multi-Camera is reserved for **dual live**.
+
+**Implication for gently:** B1's streamer already does `core.setCameraDevice(cam.name)` before the
+continuous sequence — that IS the MM side-select point. To add side A/B in **B2**: register
+`CAMERAA`/`CAMERAB` (and auto-discover the Utilities "Multi Camera") in `device_factory.py`; add a
+camera-role selector that sets the device before starting the live sequence; for dual **live**,
+point the sequence at the Multi-Camera device; for dual **acquisition**, either use the
+Multi-Camera or replicate the two-sequence + `"Camera"`-tag demux. Correction to the working
+assumption: ASIdiSPIM acquisition is **two independent sequences demuxed by tag**, not the
+multicamera fusion — the fusion device is a live-only convenience.
diff --git a/gently/app/agent.py b/gently/app/agent.py
index afe2e649..a24a00f1 100644
--- a/gently/app/agent.py
+++ b/gently/app/agent.py
@@ -215,6 +215,8 @@ def __init__(
# Opt-in bottom-camera stream bridge — created when viz starts, but
# left unstarted until the operator clicks "Start camera" in the UI.
self.bottom_camera_monitor = None
+ # Opt-in lightsheet stream bridge — same lifecycle as bottom_camera_monitor.
+ self.lightsheet_monitor = None
# ===== Create delegate managers =====
@@ -636,9 +638,7 @@ def _init_timelapse_orchestrator(self):
store=self.store,
claude_client=self.claude,
temperature_provider=lambda: (
- self.temperature_sampler.latest
- if self.temperature_sampler
- else None
+ self.temperature_sampler.latest if self.temperature_sampler else None
),
)
except Exception as e:
@@ -859,6 +859,16 @@ async def start_viz_server(
logger.warning(f"Failed to construct bottom-camera monitor: {e}")
self.bottom_camera_monitor = None
+ if self.microscope is not None and self.lightsheet_monitor is None:
+ try:
+ from .lightsheet_monitor import LightSheetStreamMonitor
+
+ self.lightsheet_monitor = LightSheetStreamMonitor(self.microscope)
+ logger.info("Lightsheet monitor ready (not started)")
+ except Exception as e:
+ logger.warning(f"Failed to construct lightsheet monitor: {e}")
+ self.lightsheet_monitor = None
+
async def stop_viz_server(self):
"""Stop the visualization server if running."""
if self.bottom_camera_monitor is not None:
@@ -867,6 +877,12 @@ async def stop_viz_server(self):
except Exception:
logger.exception("Failed to stop bottom-camera monitor")
self.bottom_camera_monitor = None
+ if self.lightsheet_monitor is not None:
+ try:
+ await self.lightsheet_monitor.stop()
+ except Exception:
+ logger.exception("Failed to stop lightsheet monitor")
+ self.lightsheet_monitor = None
if self.device_state_monitor is not None:
try:
await self.device_state_monitor.stop()
diff --git a/gently/app/lightsheet_monitor.py b/gently/app/lightsheet_monitor.py
new file mode 100644
index 00000000..78b7ca39
--- /dev/null
+++ b/gently/app/lightsheet_monitor.py
@@ -0,0 +1,98 @@
+"""
+LightSheetStreamMonitor — bridges the device-layer lightsheet SSE stream
+onto the EventBus as ``LIGHTSHEET_FRAME`` events.
+
+Modelled on :class:`gently.app.bottom_camera_monitor.BottomCameraStreamMonitor`,
+with the same opt-in semantics: streaming is not started on agent boot — only
+when the operator enables it explicitly from the UI. The agent's start/stop
+methods are the only path that connects/disconnects this monitor.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from typing import TYPE_CHECKING
+
+from gently.core.event_bus import EventType, get_event_bus
+from gently.core.service import Service
+
+if TYPE_CHECKING:
+ from gently.hardware.dispim.client import DiSPIMMicroscope
+
+logger = logging.getLogger(__name__)
+
+
+class LightSheetStreamMonitor(Service):
+ """Consumes the lightsheet SSE stream and republishes frames on the bus.
+
+ The browser receives frames via the viz server's wildcard subscription;
+ no additional plumbing is needed at the agent layer beyond starting the
+ bridge when the operator asks for it.
+ """
+
+ def __init__(
+ self,
+ microscope: DiSPIMMicroscope,
+ reconnect_delay_sec: float = 2.0,
+ ):
+ super().__init__(name="lightsheet-monitor", service_type="bridge")
+ self.microscope = microscope
+ self.reconnect_delay_sec = reconnect_delay_sec
+ self._task: asyncio.Task | None = None
+ self._stop_requested = False
+ self._last_frame_ts: float | None = None
+
+ @property
+ def running(self) -> bool:
+ return self._task is not None and not self._task.done()
+
+ async def on_start(self):
+ if self.running:
+ return
+ self._stop_requested = False
+ self._task = asyncio.create_task(self._run(), name="lightsheet-monitor")
+ logger.info("LightSheetStreamMonitor: started")
+
+ async def on_stop(self):
+ self._stop_requested = True
+ if self._task and not self._task.done():
+ self._task.cancel()
+ try:
+ await self._task
+ except asyncio.CancelledError:
+ pass
+ self._task = None
+ logger.info("LightSheetStreamMonitor: stopped")
+
+ async def _run(self):
+ bus = get_event_bus()
+ while not self._stop_requested:
+ try:
+ logger.info("LightSheetStreamMonitor: opening stream")
+ async for payload in self.microscope.stream_lightsheet():
+ if self._stop_requested:
+ break
+ self._last_frame_ts = payload.get("t")
+ try:
+ bus.publish(
+ event_type=EventType.LIGHTSHEET_FRAME,
+ data=payload,
+ source="lightsheet-monitor",
+ )
+ except Exception:
+ logger.exception("Failed to publish frame")
+ except asyncio.CancelledError:
+ raise
+ except Exception as exc:
+ logger.warning(
+ "LightSheetStreamMonitor: stream ended (%s) — reconnecting in %.1fs",
+ exc,
+ self.reconnect_delay_sec,
+ )
+ if self._stop_requested:
+ break
+ try:
+ await asyncio.sleep(self.reconnect_delay_sec)
+ except asyncio.CancelledError:
+ raise
diff --git a/gently/app/temperature_sampler.py b/gently/app/temperature_sampler.py
index e762534c..0995d1f2 100644
--- a/gently/app/temperature_sampler.py
+++ b/gently/app/temperature_sampler.py
@@ -6,14 +6,15 @@
and publishes TEMPERATURE_UPDATE for the live graph. A failed poll is a gap, not a
crash; with no active session the loop idles.
"""
+
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timezone
+from gently.core.event_bus import EventType, get_event_bus
from gently.core.service import Service
-from gently.core.event_bus import get_event_bus, EventType
logger = logging.getLogger(__name__)
diff --git a/gently/app/tools/acquisition_tools.py b/gently/app/tools/acquisition_tools.py
index 6e029ad4..bfc892ea 100644
--- a/gently/app/tools/acquisition_tools.py
+++ b/gently/app/tools/acquisition_tools.py
@@ -213,10 +213,9 @@ async def acquire_volume(
},
}
from gently.app.temperature_sampler import temperature_stamp as _ts
+
_temp_stamp = _ts(
- getattr(
- getattr(agent, "temperature_sampler", None), "latest", None
- )
+ getattr(getattr(agent, "temperature_sampler", None), "latest", None)
)
if _temp_stamp is not None:
acq_metadata["temperature"] = _temp_stamp
diff --git a/gently/core/event_bus.py b/gently/core/event_bus.py
index b3bd7e68..9774bdaa 100644
--- a/gently/core/event_bus.py
+++ b/gently/core/event_bus.py
@@ -86,6 +86,7 @@ class EventType(Enum):
DEVICE_STATE_UPDATE = auto() # Periodic device-state snapshot from device layer
TEMPERATURE_UPDATE = auto() # Temperature reading from device layer
BOTTOM_CAMERA_FRAME = auto() # Live JPEG frame from the bottom camera stream
+ LIGHTSHEET_FRAME = auto() # Live JPEG frame from the SPIM lightsheet live stream
EMBRYOS_UPDATE = auto() # Full embryo list snapshot from agent.experiment
SCAN_GEOMETRY_UPDATE = auto() # Scan cuboid + light-sheet mode for the 3D optical-space view
@@ -188,6 +189,7 @@ class EventType(Enum):
EventType.DEVICE_STATE_UPDATE,
EventType.TEMPERATURE_UPDATE, # High-volume telemetry from temperature controller
EventType.BOTTOM_CAMERA_FRAME, # ~2 Hz JPEG frames — would crowd history out
+ EventType.LIGHTSHEET_FRAME, # High-volume live frames — keep out of history
EventType.LOG_RECORD, # log lines can hit hundreds/min during
# calibration; durable copy is in the
# gently_*.log file already
diff --git a/gently/core/file_store.py b/gently/core/file_store.py
index e7fb2174..46c781b2 100644
--- a/gently/core/file_store.py
+++ b/gently/core/file_store.py
@@ -450,7 +450,8 @@ def append_temperature_sample(self, session_id: str, sample: dict) -> None:
_append_jsonl(sd / "temperature.jsonl", sample)
def read_temperature_log(self, session_id: str, since: str | None = None) -> list[dict]:
- """Return temperature samples for a session, optionally filtered to t >= since (ISO-UTC string).
+ """Return temperature samples for a session, optionally filtered to
+ t >= since (ISO-UTC string).
Reads lines tolerantly: a truncated trailing line (e.g. after a mid-append
crash) is silently skipped rather than raising a JSONDecodeError.
@@ -771,9 +772,7 @@ def get_volume_meta(self, session_id: str, embryo_id: str, timepoint: int) -> di
sd = self._session_dir(session_id)
if sd is None:
return None
- meta_path = (
- sd / "embryos" / embryo_id / "volumes" / self._volume_meta_filename(timepoint)
- )
+ meta_path = sd / "embryos" / embryo_id / "volumes" / self._volume_meta_filename(timepoint)
return _read_yaml(meta_path)
def list_volumes(self, session_id: str, embryo_id: str | None = None) -> list[VolumeInfo]:
diff --git a/gently/hardware/dispim/client.py b/gently/hardware/dispim/client.py
index 56a9a33c..58746750 100644
--- a/gently/hardware/dispim/client.py
+++ b/gently/hardware/dispim/client.py
@@ -811,6 +811,29 @@ async def get_laser_power(self, wavelength: int) -> dict:
except Exception as e:
return {"success": False, "error": str(e)}
+ async def set_laser_config(self, config_name: str) -> dict:
+ """Apply a Laser config-group preset (e.g. "ALL OFF").
+
+ Hits ``POST /api/laser/config`` — direct, no Bluesky queue.
+ Use ``"ALL OFF"`` to gate every laser line off via the PLogic
+ OutputChannel; other presets from the MM config group are also
+ accepted (e.g. ``"488 only"``, ``"561 only"``).
+
+ Parameters
+ ----------
+ config_name : str
+ Exact preset name from the Laser config group.
+ """
+ return await self._api_post("/api/laser/config", {"config": config_name})
+
+ async def get_laser_configs(self) -> dict:
+ """List available Laser config-group presets.
+
+ Hits ``GET /api/laser/configs`` — returns ``{"configs": [...]}``
+ with the preset names from the MM Laser config group.
+ """
+ return await self._api_get("/api/laser/configs")
+
async def get_led_status(self) -> dict:
"""Get current LED status."""
return await self._api_get("/api/led/status")
@@ -954,6 +977,54 @@ async def stream_bottom_camera(self, timeout: float | None = None):
except Exception as exc:
logger.warning("Malformed bottom-camera SSE payload skipped: %s", exc)
+ async def stream_lightsheet(self, timeout: float | None = None):
+ """Async generator yielding JPEG frames from the lightsheet live SSE stream.
+
+ Mirrors :meth:`stream_bottom_camera`; subscriber-gated on the device layer.
+ """
+ self._ensure_connected()
+ client_timeout = aiohttp.ClientTimeout(
+ total=None,
+ sock_read=timeout,
+ sock_connect=10.0,
+ )
+ url = f"{self.http_url}/api/lightsheet/stream"
+ async with self._session.get(url, timeout=client_timeout) as resp:
+ resp.raise_for_status()
+ buf = b""
+ async for chunk in resp.content.iter_any():
+ if not chunk:
+ continue
+ buf += chunk
+ while b"\n\n" in buf:
+ event_block, buf = buf.split(b"\n\n", 1)
+ data_lines = []
+ for line in event_block.splitlines():
+ if not line or line.startswith(b":"):
+ continue
+ if line.startswith(b"data:"):
+ data_lines.append(line[5:].lstrip())
+ if not data_lines:
+ continue
+ raw = b"\n".join(data_lines).decode("utf-8", errors="replace")
+ try:
+ import json as _json
+
+ yield _json.loads(raw)
+ except Exception as exc:
+ logger.warning("Malformed lightsheet SSE payload skipped: %s", exc)
+
+ async def set_lightsheet_live_params(self, galvo=None, piezo=None, exposure=None) -> dict:
+ """POST live galvo/piezo/exposure to the device-layer lightsheet streamer."""
+ body = {}
+ if galvo is not None:
+ body["galvo"] = float(galvo)
+ if piezo is not None:
+ body["piezo"] = float(piezo)
+ if exposure is not None:
+ body["exposure"] = float(exposure)
+ return await self._api_post("/api/lightsheet/live/params", body)
+
async def set_camera_led_mode(self, use_led: bool = False) -> dict:
"""Enable/disable automatic LED for bottom camera captures."""
return await self._api_post("/api/camera/led_mode", {"use_led": use_led})
diff --git a/gently/hardware/dispim/device_layer.py b/gently/hardware/dispim/device_layer.py
index 55e8225d..6173236f 100644
--- a/gently/hardware/dispim/device_layer.py
+++ b/gently/hardware/dispim/device_layer.py
@@ -168,6 +168,18 @@ def __init__(
self._cam_target_max_dim: int = 360 # ~360px thumbnail
self._cam_jpeg_quality: int = 55
+ # Lightsheet (SPIM) live stream — continuous sequence acquisition.
+ self._ls_subscribers: list[asyncio.Queue] = []
+ self._ls_task: asyncio.Task | None = None
+ self._ls_interval_sec: float = 0.0 # peek as fast as exposure allows
+ self._ls_target_max_dim: int = 512
+ self._ls_jpeg_quality: int = 70
+ self._ls_params: dict = {"galvo": 0.0, "piezo": 50.0, "exposure": 20.0}
+ self._ls_seq_started: bool = False
+ self._ls_applied: dict = {} # last-applied galvo/piezo/exposure
+ self._ls_parked: dict = {} # last-parked galvo/piezo setPosition values
+ self._ls_spim_idle: bool = False # whether SPIM state machine was set Idle this session
+
# Plans that hold MMCore for long performance-critical work.
# Anything in this set runs with state polling paused.
self._heavy_plans = frozenset(
@@ -845,7 +857,9 @@ def _capture_bottom_frame_sync(self) -> np.ndarray | None:
logger.debug("Bottom-camera grab failed: %s", exc)
return None
- def _encode_frame_for_stream(self, img: np.ndarray) -> dict[str, Any] | None:
+ def _encode_frame_for_stream(
+ self, img: np.ndarray, max_dim: int | None = None, quality: int | None = None
+ ) -> dict[str, Any] | None:
"""Downsample + auto-contrast + JPEG-encode a uint16 frame for SSE.
Optimised for streaming throughput:
@@ -854,6 +868,10 @@ def _encode_frame_for_stream(self, img: np.ndarray) -> dict[str, Any] | None:
full image — np.partition on the subsample is O(n) and avoids
sorting ~120K pixels every frame
* JPEG quality 55 (visually fine at thumbnail size)
+
+ ``max_dim`` and ``quality`` default to the ``_cam_*`` instance values,
+ allowing callers (e.g. the lightsheet streamer) to pass different
+ settings without duplicating the encoder.
"""
if img is None or img.size == 0:
return None
@@ -865,9 +883,12 @@ def _encode_frame_for_stream(self, img: np.ndarray) -> dict[str, Any] | None:
logger.warning("Cannot encode frame — OpenCV unavailable: %s", exc)
return None
+ target_max_dim = max_dim if max_dim is not None else self._cam_target_max_dim
+ jpeg_quality = quality if quality is not None else self._cam_jpeg_quality
+
h, w = img.shape[:2]
# Stride slicing — no interpolation, just take every Nth pixel.
- factor = max(1, max(h, w) // self._cam_target_max_dim)
+ factor = max(1, max(h, w) // target_max_dim)
small = img[::factor, ::factor]
# Auto-contrast off a small random sample. Robust to hot pixels
@@ -889,7 +910,7 @@ def _encode_frame_for_stream(self, img: np.ndarray) -> dict[str, Any] | None:
scale = 255.0 / (hi - lo)
small = np.clip((small.astype(np.float32) - lo) * scale, 0, 255).astype(np.uint8)
- ok, jpeg = cv2.imencode(".jpg", small, [cv2.IMWRITE_JPEG_QUALITY, self._cam_jpeg_quality])
+ ok, jpeg = cv2.imencode(".jpg", small, [cv2.IMWRITE_JPEG_QUALITY, jpeg_quality])
if not ok:
return None
b64 = base64.b64encode(jpeg.tobytes()).decode("ascii")
@@ -953,6 +974,209 @@ async def _broadcast_camera(self, payload: dict[str, Any]):
except ValueError:
pass
+ # =========================================================================
+ # Lightsheet (SPIM) Live Streamer — continuous sequence acquisition
+ # =========================================================================
+
+ def _park_lightsheet_sync(self) -> None:
+ """Park scanner galvo + imaging piezo at the current live params (static sheet).
+
+ Guards:
+ - set_spim_state("Idle") fires once per stream session, not per frame
+ (4 serial round-trips on first call → 0 on every subsequent frame).
+ - setPosition fires only when the commanded value differs from the
+ last-applied value (no-op on steady-state frames).
+ """
+ p = self._ls_params
+ scanner = self.devices.get("scanner")
+ piezo = self.devices.get("piezo")
+ # SPIM Idle state machine: drive both devices Idle once per session.
+ if not self._ls_spim_idle:
+ if scanner is not None:
+ try:
+ scanner.set_spim_state("Idle")
+ except Exception:
+ pass
+ if piezo is not None:
+ try:
+ piezo.set_spim_state("Idle")
+ except Exception:
+ pass
+ self._ls_spim_idle = True
+ # setPosition only when the value changed from last-applied.
+ if scanner is not None:
+ want = float(p["galvo"])
+ if self._ls_parked.get("galvo") != want:
+ scanner.sa_offset_y.setPosition(want)
+ self._ls_parked["galvo"] = want
+ if piezo is not None:
+ want = float(p["piezo"])
+ if self._ls_parked.get("piezo") != want:
+ piezo.setPosition(want)
+ self._ls_parked["piezo"] = want
+
+ def _ensure_lightsheet_sequence_sync(self) -> None:
+ """Start (or restart on exposure change) the continuous sequence on the SPIM camera."""
+ core = self.system.core
+ cam = self.devices.get("camera")
+ if cam is None:
+ raise RuntimeError("No lightsheet camera configured")
+ p = self._ls_params
+ need_restart = not self._ls_seq_started or self._ls_applied.get("exposure") != p["exposure"]
+ if need_restart:
+ if core.isSequenceRunning():
+ core.stopSequenceAcquisition()
+ if core.getCameraDevice() != cam.name:
+ core.setCameraDevice(cam.name)
+ core.setExposure(cam.name, float(p["exposure"]))
+ core.startContinuousSequenceAcquisition(self._ls_interval_sec * 1000.0)
+ self._ls_seq_started = True
+ self._ls_applied["exposure"] = p["exposure"]
+
+ def _grab_lightsheet_frame_sync(self):
+ """Park → ensure sequence running → peek the latest frame (never drain)."""
+ try:
+ self._park_lightsheet_sync() # galvo/piezo applied live
+ self._ensure_lightsheet_sequence_sync() # start / restart on exposure
+ try:
+ from gently.hardware.dispim.devices.acquisition import _safe_obtain
+ except (ImportError, AttributeError):
+ _safe_obtain = None
+ core = self.system.core
+ img = core.getLastImage()
+ if _safe_obtain is not None:
+ try:
+ img = _safe_obtain(img)
+ except Exception:
+ pass
+ return np.asarray(img)
+ except Exception as exc:
+ logger.debug("Lightsheet grab failed: %s", exc)
+ return None
+
+ def _stop_lightsheet_sequence_sync(self) -> None:
+ try:
+ if self.system.core.isSequenceRunning():
+ self.system.core.stopSequenceAcquisition()
+ except Exception:
+ logger.debug("stop lightsheet sequence failed", exc_info=True)
+ self._ls_seq_started = False
+ self._ls_applied = {}
+ # Reset park guard so the next stream session re-idles the state
+ # machine and re-parks the axes (hardware may have moved).
+ self._ls_spim_idle = False
+ self._ls_parked = {}
+
+ async def _lightsheet_streamer(self):
+ logger.info("Lightsheet streamer started")
+ try:
+ while self._ls_subscribers:
+ if self._state_pause_counter > 0:
+ # Heavy plan owns MMCore: release the sequence and back off.
+ if self._ls_seq_started:
+ await asyncio.to_thread(self._stop_lightsheet_sequence_sync)
+ await asyncio.sleep(0.1)
+ continue
+ tick = time.monotonic()
+ img = await asyncio.to_thread(self._grab_lightsheet_frame_sync)
+ payload = (
+ self._encode_frame_for_stream(
+ img, self._ls_target_max_dim, self._ls_jpeg_quality
+ )
+ if img is not None
+ else None
+ )
+ if payload is not None:
+ await self._broadcast_lightsheet(payload)
+ elapsed = time.monotonic() - tick
+ # Pace to at least the exposure; peek-rate caps near the camera rate.
+ floor = max(self._ls_interval_sec, self._ls_params["exposure"] / 1000.0)
+ await asyncio.sleep(max(0.0, floor - elapsed))
+ except asyncio.CancelledError:
+ raise
+ except Exception:
+ logger.exception("Lightsheet streamer crashed")
+ finally:
+ await asyncio.to_thread(self._stop_lightsheet_sequence_sync)
+ logger.info("Lightsheet streamer exiting")
+
+ async def _broadcast_lightsheet(self, payload: dict[str, Any]):
+ if not self._ls_subscribers:
+ return
+ dead: list[asyncio.Queue] = []
+ for q in self._ls_subscribers:
+ try:
+ q.put_nowait(payload)
+ except asyncio.QueueFull:
+ try:
+ _ = q.get_nowait()
+ q.put_nowait(payload)
+ except Exception:
+ dead.append(q)
+ for q in dead:
+ try:
+ self._ls_subscribers.remove(q)
+ except ValueError:
+ pass
+
+ async def handle_lightsheet_stream(self, request):
+ """GET /api/lightsheet/stream — SSE of base64-JPEG frames from SPIM camera.
+
+ The streamer task spins up on first connect and exits when the last
+ subscriber leaves. Uses continuous sequence acquisition for best FPS.
+ """
+ response = web.StreamResponse(
+ status=200,
+ reason="OK",
+ headers={
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ "X-Accel-Buffering": "no",
+ },
+ )
+ await response.prepare(request)
+
+ queue: asyncio.Queue = asyncio.Queue(maxsize=4)
+ self._ls_subscribers.append(queue)
+ if len(self._ls_subscribers) == 1 and (self._ls_task is None or self._ls_task.done()):
+ self._ls_task = asyncio.create_task(
+ self._lightsheet_streamer(), name="lightsheet-streamer"
+ )
+ try:
+ await response.write(b": connected\n\n")
+ while True:
+ try:
+ payload = await asyncio.wait_for(queue.get(), timeout=10.0)
+ except asyncio.TimeoutError:
+ await response.write(b": keepalive\n\n")
+ continue
+ if payload is None:
+ break
+ await response.write(f"data: {json.dumps(payload)}\n\n".encode())
+ except (asyncio.CancelledError, ConnectionResetError, ConnectionAbortedError):
+ pass
+ except Exception:
+ logger.exception("Lightsheet SSE writer failed")
+ finally:
+ try:
+ self._ls_subscribers.remove(queue)
+ except ValueError:
+ pass
+ return response
+
+ async def handle_lightsheet_params(self, request):
+ """POST /api/lightsheet/live/params — update live galvo/piezo/exposure.
+
+ Body: {"galvo": float, "piezo": float, "exposure": float} (all optional).
+ Galvo/piezo apply on the next grab; exposure change triggers sequence restart.
+ """
+ body = await request.json()
+ for k in ("galvo", "piezo", "exposure"):
+ if k in body and body[k] is not None:
+ self._ls_params[k] = float(body[k])
+ return web.json_response({"params": self._ls_params})
+
# =========================================================================
# MMCore Push Callbacks
# =========================================================================
@@ -1795,6 +2019,70 @@ async def handle_get_light_source_power(self, request):
status=500,
)
+ async def handle_set_laser_config(self, request):
+ """POST /api/laser/config — apply a Laser config-group preset.
+
+ Body: {"config":
}
+ Calls light_source.set(config_name) which maps to
+ core.setConfig(group_name, config_name) + waitForConfig.
+ """
+ try:
+ data = await request.json()
+ config_name = data.get("config")
+ if not config_name:
+ return web.json_response(
+ {"success": False, "error": "missing 'config' field"},
+ status=400,
+ )
+ light_source = self.devices.get("light_source") or self.devices.get("laser_control")
+ if light_source is None:
+ return web.json_response(
+ {"success": False, "error": "Light source device not found"},
+ status=503,
+ )
+ try:
+ light_source.set(config_name)
+ except Exception as e:
+ return web.json_response(
+ {"success": False, "error": str(e)},
+ status=400,
+ )
+ return web.json_response({"success": True, "config": config_name})
+ except Exception as e:
+ import traceback
+
+ return web.json_response(
+ {
+ "success": False,
+ "error": str(e),
+ "traceback": traceback.format_exc(),
+ },
+ status=500,
+ )
+
+ async def handle_get_laser_configs(self, request):
+ """GET /api/laser/configs — list available Laser config-group presets."""
+ try:
+ light_source = self.devices.get("light_source") or self.devices.get("laser_control")
+ if light_source is None:
+ return web.json_response(
+ {"success": False, "error": "Light source device not found"},
+ status=503,
+ )
+ configs = light_source._get_available_configs()
+ return web.json_response({"configs": configs})
+ except Exception as e:
+ import traceback
+
+ return web.json_response(
+ {
+ "success": False,
+ "error": str(e),
+ "traceback": traceback.format_exc(),
+ },
+ status=500,
+ )
+
async def handle_get_camera_exposure(self, request):
"""GET /api/camera/exposure - Get bottom camera exposure time"""
try:
@@ -2787,6 +3075,8 @@ async def on_start(self):
self._app.router.add_get("/api/camera/exposure", self.handle_get_camera_exposure)
self._app.router.add_post("/api/light_source/power", self.handle_set_light_source_power)
self._app.router.add_get("/api/light_source/power", self.handle_get_light_source_power)
+ self._app.router.add_post("/api/laser/config", self.handle_set_laser_config)
+ self._app.router.add_get("/api/laser/configs", self.handle_get_laser_configs)
self._app.router.add_get("/api/plan_log", self.handle_get_plan_log)
self._app.router.add_post("/session/configure", self.handle_session_configure)
@@ -2806,6 +3096,10 @@ async def on_start(self):
# Bottom-camera live stream (subscriber-gated, off when nobody listens)
self._app.router.add_get("/api/bottom_camera/stream", self.handle_bottom_camera_stream)
+ # Lightsheet (SPIM) live stream — continuous sequence acquisition
+ self._app.router.add_get("/api/lightsheet/stream", self.handle_lightsheet_stream)
+ self._app.router.add_post("/api/lightsheet/live/params", self.handle_lightsheet_params)
+
# Start plan executor
self._executor_task = asyncio.create_task(self._plan_executor())
diff --git a/gently/hardware/dispim/devices/acquisition.py b/gently/hardware/dispim/devices/acquisition.py
index 9b2658ef..fda839d1 100644
--- a/gently/hardware/dispim/devices/acquisition.py
+++ b/gently/hardware/dispim/devices/acquisition.py
@@ -188,6 +188,14 @@ def wait():
self.core.setConfig(self.laser_control.group_name, self._laser_config)
self.core.waitForConfig(self.laser_control.group_name, self._laser_config)
+ # Defensive stop: the lightsheet live streamer may have left the
+ # camera in continuous acquisition. Starting a new sequence on
+ # an already-running camera triggers an MMCore error (and is
+ # thread-unsafe). Stop cleanly before reconfiguring.
+ if self.core.isSequenceRunning():
+ self.core.stopSequenceAcquisition()
+ self.core.waitForDevice(self.camera.name)
+
# Prepare circular buffer
self.core.clearCircularBuffer()
buffer_capacity = self.core.getBufferTotalCapacity()
diff --git a/gently/ui/web/routes/__init__.py b/gently/ui/web/routes/__init__.py
index 66e8842c..3a5e2051 100644
--- a/gently/ui/web/routes/__init__.py
+++ b/gently/ui/web/routes/__init__.py
@@ -13,11 +13,11 @@
from .context import create_router as create_context_router
from .data import create_router as create_data_router
from .experiments import create_router as create_experiments_router
-from .temperature import create_router as create_temperature_router
from .images import create_router as create_images_router
from .notebook import create_router as create_notebook_router
from .pages import create_router as create_pages_router
from .sessions import create_router as create_sessions_router
+from .temperature import create_router as create_temperature_router
from .volumes import create_router as create_volumes_router
from .websocket import create_router as create_websocket_router
diff --git a/gently/ui/web/routes/data.py b/gently/ui/web/routes/data.py
index a609526a..3844b131 100644
--- a/gently/ui/web/routes/data.py
+++ b/gently/ui/web/routes/data.py
@@ -412,6 +412,231 @@ async def set_temperature(payload: dict = Body(...)): # noqa: B008
"waited": res.get("waited", False),
}
+ # ------------------------------------------------------------------
+ # Lightsheet live stream
+ # ------------------------------------------------------------------
+
+ @router.get("/api/devices/lightsheet/live/status")
+ async def get_lightsheet_live_status():
+ """Return whether the lightsheet live stream bridge is running."""
+ bridge = getattr(server, "agent_bridge", None)
+ agent = bridge.agent if bridge is not None else None
+ monitor = getattr(agent, "lightsheet_monitor", None) if agent else None
+ return {
+ "available": monitor is not None,
+ "streaming": bool(monitor and monitor.running),
+ "last_frame_ts": getattr(monitor, "_last_frame_ts", None) if monitor else None,
+ }
+
+ @router.post(
+ "/api/devices/lightsheet/live/start",
+ dependencies=[Depends(require_control)],
+ )
+ async def start_lightsheet_live_stream():
+ """Start the lightsheet live stream bridge.
+
+ Idempotent — calling start() while already running is a no-op.
+ """
+ bridge = getattr(server, "agent_bridge", None)
+ agent = bridge.agent if bridge is not None else None
+ monitor = getattr(agent, "lightsheet_monitor", None) if agent else None
+ if monitor is None:
+ raise HTTPException(
+ status_code=503,
+ detail="Lightsheet monitor not initialised (agent or microscope not ready)",
+ )
+ try:
+ await monitor.start()
+ except Exception as exc:
+ logger.exception("Failed to start lightsheet monitor")
+ raise HTTPException(status_code=500, detail=f"start failed: {exc}") from exc
+ return {"streaming": monitor.running}
+
+ @router.post(
+ "/api/devices/lightsheet/live/stop",
+ dependencies=[Depends(require_control)],
+ )
+ async def stop_lightsheet_live_stream():
+ """Stop the lightsheet live stream bridge. Idempotent."""
+ bridge = getattr(server, "agent_bridge", None)
+ agent = bridge.agent if bridge is not None else None
+ monitor = getattr(agent, "lightsheet_monitor", None) if agent else None
+ if monitor is None:
+ return {"streaming": False}
+ try:
+ await monitor.stop()
+ except Exception as exc:
+ logger.exception("Failed to stop lightsheet monitor")
+ raise HTTPException(status_code=500, detail=f"stop failed: {exc}") from exc
+ return {"streaming": False}
+
+ # ------------------------------------------------------------------
+ # Lightsheet live params
+ # ------------------------------------------------------------------
+
+ @router.post("/api/devices/lightsheet/live/params", dependencies=[Depends(require_control)])
+ async def lightsheet_live_params(payload: dict = Body(...)): # noqa: B008
+ """Forward galvo/piezo/exposure params to the device-layer lightsheet streamer."""
+ client = _resolve_client()
+ if client is None:
+ raise HTTPException(status_code=503, detail="Microscope not connected")
+ try:
+ res = await client.set_lightsheet_live_params(
+ galvo=payload.get("galvo"),
+ piezo=payload.get("piezo"),
+ exposure=payload.get("exposure"),
+ )
+ except Exception as exc:
+ logger.exception("lightsheet live params failed")
+ raise HTTPException(status_code=502, detail=f"params failed: {exc}") from exc
+ return res
+
+ # ------------------------------------------------------------------
+ # LED / laser / camera
+ # ------------------------------------------------------------------
+
+ @router.post("/api/devices/led/set", dependencies=[Depends(require_control)])
+ async def led_set(payload: dict = Body(...)): # noqa: B008
+ """Set the LED shutter state. Body: {"state": "Open"|"Closed"}."""
+ client = _resolve_client()
+ if client is None:
+ raise HTTPException(status_code=503, detail="Microscope not connected")
+ try:
+ return await client.set_led(str(payload.get("state", "Closed")))
+ except Exception as exc:
+ logger.exception("LED set command failed")
+ raise HTTPException(status_code=502, detail=f"led failed: {exc}") from exc
+
+ @router.post("/api/devices/laser/off", dependencies=[Depends(require_control)])
+ async def laser_off():
+ """Gate ALL laser lines off via the Laser config group "ALL OFF" preset.
+
+ Uses setConfig("Laser", "ALL OFF") which drives the PLogic
+ OutputChannel to "none of outputs 5-8" — this gates every line
+ (488, 561, 405, 637) off, not just the 488 nm setpoint.
+ Required for safe brightfield live-view (spec §2.7).
+ """
+ client = _resolve_client()
+ if client is None:
+ raise HTTPException(status_code=503, detail="Microscope not connected")
+ try:
+ return await client.set_laser_config("ALL OFF")
+ except Exception as exc:
+ logger.exception("Laser off command failed")
+ raise HTTPException(status_code=502, detail=f"laser off failed: {exc}") from exc
+
+ @router.get("/api/devices/laser/configs")
+ async def laser_configs():
+ """Return the available Laser config-group presets from the device layer.
+
+ No require_control — read-only status route, mirrors GET status
+ routes like room_light/status and temperature/status.
+ """
+ client = _resolve_client()
+ if client is None:
+ raise HTTPException(status_code=503, detail="Microscope not connected")
+ try:
+ return await client.get_laser_configs()
+ except Exception as exc:
+ logger.exception("Laser configs fetch failed")
+ raise HTTPException(status_code=502, detail=f"laser configs failed: {exc}") from exc
+
+ @router.post("/api/devices/camera/led_mode", dependencies=[Depends(require_control)])
+ async def camera_led_mode(payload: dict = Body(...)): # noqa: B008
+ """Enable/disable automatic LED for bottom-camera captures. Body: {"use_led": bool}."""
+ client = _resolve_client()
+ if client is None:
+ raise HTTPException(status_code=503, detail="Microscope not connected")
+ try:
+ return await client.set_camera_led_mode(bool(payload.get("use_led", False)))
+ except Exception as exc:
+ logger.exception("Camera LED mode command failed")
+ raise HTTPException(status_code=502, detail=f"camera led mode failed: {exc}") from exc
+
+ # ------------------------------------------------------------------
+ # Stage
+ # ------------------------------------------------------------------
+
+ @router.post("/api/devices/stage/move", dependencies=[Depends(require_control)])
+ async def stage_move(payload: dict = Body(...)): # noqa: B008
+ """Move the stage to an absolute XY position. Body: {"x": float, "y": float}."""
+ client = _resolve_client()
+ if client is None:
+ raise HTTPException(status_code=503, detail="Microscope not connected")
+ try:
+ return await client.move_to_position(float(payload["x"]), float(payload["y"]))
+ except KeyError:
+ raise HTTPException(status_code=400, detail="x and y required") from None
+ except Exception as exc:
+ logger.exception("Stage move command failed")
+ raise HTTPException(status_code=502, detail=f"stage move failed: {exc}") from exc
+
+ # ------------------------------------------------------------------
+ # Acquisition
+ # ------------------------------------------------------------------
+
+ @router.post("/api/devices/acquire/burst", dependencies=[Depends(require_control)])
+ async def acquire_burst(payload: dict = Body(...)): # noqa: B008
+ """Trigger a burst acquisition.
+
+ Body: {frames, mode, num_slices, exposure_ms,
+ laser_config?, piezo_center?, galvo_center?}.
+ laser_config is forwarded directly to the device client so callers
+ can send "ALL OFF" for brightfield-safe Manual-view captures.
+ piezo_center and galvo_center capture at the dialled focal plane.
+ """
+ client = _resolve_client()
+ if client is None:
+ raise HTTPException(status_code=503, detail="Microscope not connected")
+ try:
+ kw: dict = {}
+ if payload.get("laser_config") is not None:
+ kw["laser_config"] = str(payload["laser_config"])
+ if payload.get("piezo_center") is not None:
+ kw["piezo_center"] = float(payload["piezo_center"])
+ if payload.get("galvo_center") is not None:
+ kw["galvo_center"] = float(payload["galvo_center"])
+ return await client.acquire_burst(
+ frames=int(payload.get("frames", 60)),
+ mode=str(payload.get("mode", "1hz")),
+ num_slices=int(payload.get("num_slices", 1)),
+ exposure_ms=float(payload.get("exposure_ms", 5.0)),
+ **kw,
+ )
+ except Exception as exc:
+ logger.exception("Burst acquisition failed")
+ raise HTTPException(status_code=502, detail=f"burst failed: {exc}") from exc
+
+ @router.post("/api/devices/acquire/volume", dependencies=[Depends(require_control)])
+ async def acquire_volume(payload: dict = Body(...)): # noqa: B008
+ """Trigger a volume acquisition.
+
+ Body: {num_slices, exposure_ms,
+ laser_config?, piezo_center?, galvo_center?}.
+ laser_config is forwarded directly to the device client so callers
+ can send "ALL OFF" for brightfield-safe Manual-view captures.
+ piezo_center and galvo_center capture at the dialled focal plane.
+ """
+ client = _resolve_client()
+ if client is None:
+ raise HTTPException(status_code=503, detail="Microscope not connected")
+ try:
+ kw: dict = {}
+ if payload.get("laser_config") is not None:
+ kw["laser_config"] = str(payload["laser_config"])
+ if payload.get("piezo_center") is not None:
+ kw["piezo_center"] = float(payload["piezo_center"])
+ if payload.get("galvo_center") is not None:
+ kw["galvo_center"] = float(payload["galvo_center"])
+ return await client.acquire_volume(
+ num_slices=int(payload.get("num_slices", 50)),
+ exposure_ms=float(payload.get("exposure_ms", 10.0)),
+ **kw,
+ )
+ except Exception as exc:
+ logger.exception("Volume acquisition failed")
+ raise HTTPException(status_code=502, detail=f"volume failed: {exc}") from exc
+
@router.get("/api/calibration")
async def list_calibration(embryo_id: str | None = None):
"""Get calibration images"""
diff --git a/gently/ui/web/routes/temperature.py b/gently/ui/web/routes/temperature.py
index 1505714b..90916181 100644
--- a/gently/ui/web/routes/temperature.py
+++ b/gently/ui/web/routes/temperature.py
@@ -3,6 +3,7 @@
Live updates ride the TEMPERATURE_UPDATE event channel; this route is backfill only.
Mirrors routes/experiments.py session resolution.
"""
+
import urllib.parse
from fastapi import APIRouter, HTTPException, Request
diff --git a/gently/ui/web/static/css/main.css b/gently/ui/web/static/css/main.css
index 36124bd9..4c04716e 100644
--- a/gently/ui/web/static/css/main.css
+++ b/gently/ui/web/static/css/main.css
@@ -10550,3 +10550,260 @@ body.modal-open {
opacity: 0.5;
cursor: not-allowed;
}
+
+/* =========================================================================
+ Manual view — lightsheet live panel + control rail
+ ========================================================================= */
+
+/* Two-column layout: image stage (flex-grow) + fixed control rail */
+.ls-layout {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+ gap: 1rem;
+ font-family: 'Inter Tight', system-ui, sans-serif;
+}
+
+/* Left column — live image stage */
+.ls-image-col {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.45rem;
+}
+
+.ls-stage-head {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ flex-wrap: wrap;
+}
+
+/* The stage itself reuses .devices-camera-stage aspect + overflow */
+.ls-stage {
+ flex: 1;
+ min-height: 280px;
+ /* override aspect-ratio: let the flex column stretch it */
+ aspect-ratio: unset;
+}
+
+/* Right column — control rail */
+.ls-rail {
+ width: 220px;
+ flex-shrink: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.85rem;
+ overflow-y: auto;
+ padding: 0.1rem 0 0.5rem;
+}
+
+/* Control group block */
+.ls-group {
+ display: flex;
+ flex-direction: column;
+ gap: 0.32rem;
+ padding: 0.55rem 0.65rem;
+ background: var(--map-overlay-bg);
+ border: 1px solid var(--map-overlay-edge);
+ border-radius: 5px;
+}
+
+.ls-label {
+ font-size: 0.6rem;
+ font-weight: 600;
+ letter-spacing: 0.18em;
+ text-transform: uppercase;
+ color: var(--map-ink-mute);
+}
+
+/* Generic inline row */
+.ls-row {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+}
+
+/* Slider + number inline */
+.ls-slider-row {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+}
+
+.ls-slider {
+ flex: 1;
+ min-width: 0;
+ height: 3px;
+ accent-color: var(--map-accent);
+ cursor: pointer;
+}
+
+.ls-number {
+ width: 64px;
+ background: var(--map-paper-2);
+ border: 1px solid var(--map-overlay-edge);
+ color: var(--map-ink);
+ border-radius: 4px;
+ padding: 0.2rem 0.35rem;
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.7rem;
+ font-variant-numeric: tabular-nums;
+}
+
+.ls-number-sm {
+ width: 50px;
+}
+
+.ls-number:focus {
+ outline: none;
+ border-color: var(--map-accent);
+}
+
+.ls-unit {
+ font-family: 'Inter Tight', system-ui, sans-serif;
+ font-size: 0.62rem;
+ color: var(--map-ink-mute);
+ flex-shrink: 0;
+}
+
+/* Illumination button rows */
+.ls-btn-row {
+ display: flex;
+ gap: 0.35rem;
+ flex-wrap: wrap;
+}
+
+.ls-illum-btn {
+ flex: 1;
+ min-width: 0;
+ background: transparent;
+ border: 1px solid var(--map-overlay-edge);
+ color: var(--map-ink);
+ font-family: inherit;
+ font-size: 0.6rem;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ padding: 0.28rem 0.4rem;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: background 0.12s, border-color 0.12s, color 0.12s;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.ls-illum-btn:hover {
+ background: rgba(255,255,255,0.06);
+ border-color: var(--map-accent);
+ color: var(--map-accent);
+}
+
+.ls-illum-btn--active {
+ background: rgba(34, 211, 238, 0.14);
+ border-color: var(--map-accent);
+ color: var(--map-accent);
+}
+
+/* Laser-off indicator */
+.ls-laser-indicator {
+ font-size: 0.6rem;
+ color: var(--map-ink-mute);
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ margin-top: 0.1rem;
+}
+
+.ls-laser-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--map-ink-mute);
+ flex-shrink: 0;
+}
+
+/* Temperature setpoint button */
+.ls-set-btn {
+ background: transparent;
+ border: 1px solid var(--map-overlay-edge);
+ color: var(--map-ink);
+ font-family: inherit;
+ font-size: 0.6rem;
+ font-weight: 600;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ padding: 0.2rem 0.55rem;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: background 0.12s, border-color 0.12s;
+}
+
+.ls-set-btn:hover {
+ background: rgba(255,255,255,0.06);
+ border-color: var(--map-accent);
+}
+
+/* Temperature mini-graph */
+.ls-tempgraph {
+ min-height: 48px;
+ margin-top: 0.25rem;
+}
+
+/* Acquire buttons */
+.ls-acquire-btn {
+ flex: 1;
+ background: transparent;
+ border: 1px solid var(--map-overlay-edge);
+ color: var(--map-ink);
+ font-family: inherit;
+ font-size: 0.62rem;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ padding: 0.35rem 0.5rem;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: background 0.12s, border-color 0.12s, color 0.12s;
+}
+
+.ls-acquire-btn:hover:not(:disabled) {
+ background: rgba(255,255,255,0.06);
+ border-color: var(--map-accent);
+ color: var(--map-accent);
+}
+
+.ls-acquire-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* Last-captured ref card */
+.ls-lastcap {
+ margin-top: 0.35rem;
+ padding: 0.35rem 0.45rem;
+ background: var(--map-paper-2);
+ border: 1px solid var(--map-overlay-edge);
+ border-radius: 4px;
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+}
+
+.ls-lastcap[hidden] { display: none; }
+
+.ls-lastcap-label {
+ font-size: 0.58rem;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--map-ink-mute);
+}
+
+.ls-lastcap-ref {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.65rem;
+ color: var(--map-ink);
+ word-break: break-all;
+}
diff --git a/gently/ui/web/static/js/devices.js b/gently/ui/web/static/js/devices.js
index a56f0015..2de449e4 100644
--- a/gently/ui/web/static/js/devices.js
+++ b/gently/ui/web/static/js/devices.js
@@ -14,7 +14,7 @@
*/
const DevicesManager = (function () {
const STALE_AFTER_MS = 4000;
- const VIEWS = ['map', 'details', 'optical3d'];
+ const VIEWS = ['map', 'details', 'optical3d', 'manual'];
const SVG_NS = 'http://www.w3.org/2000/svg';
// Status / details DOM
@@ -72,6 +72,35 @@ const DevicesManager = (function () {
const _CAM_ZOOM_MAX = 8;
const _CAM_ZOOM_STEP = 1.15; // multiplicative per wheel notch
+ // Lightsheet live panel DOM + state (Manual view)
+ let _lsToggle, _lsImg, _lsPlaceholder, _lsLed, _lsMeta, _lsStage;
+ let _lsStreaming = false;
+ let _lsLastFrameTs = 0;
+ let _lsHasFrame = false;
+ let _lsStaleTimer = null;
+ const _LS_FPS_WINDOW = 12;
+ let _lsFrameTimes = [];
+
+ // Lightsheet zoom / pan (mirrors camera zoom/pan)
+ let _lsZoom = 1;
+ let _lsTx = 0;
+ let _lsTy = 0;
+ let _lsPanLast = null;
+
+ // Lightsheet live params — debounced POST to /api/devices/lightsheet/live/params
+ let _lsGalvo = 0;
+ let _lsPiezo = 50;
+ let _lsExposure = 20; // matches device-layer _ls_params default (20 ms)
+ let _lsParamTimer = null;
+
+ // Lightsheet control inputs (rail)
+ let _lsGalvoSlider, _lsGalvoNum, _lsPiezoSlider, _lsPiezoNum, _lsExposureNum;
+ let _lsLedOpen, _lsLedClosed, _lsCamLed, _lsRoomLightBtn;
+ let _lsCamLedOn = false;
+ let _lsSnapVolBtn, _lsBurstBtn, _lsLastcap, _lsLastcapRef;
+ let _lsLaserStatus; // span inside .ls-laser-indicator — driven by actual laser/off calls
+ let _lsTempInput, _lsTempSet;
+
// Room-light toggle (header). Drives the SwitchBot Bot that switches the
// diSPIM room light. State is the bot's cached on/off; hidden until the
// device layer reports the accessory is configured.
@@ -151,6 +180,30 @@ const DevicesManager = (function () {
_camLed = document.getElementById('devices-camera-led');
_camMeta = document.getElementById('devices-camera-meta');
+ // Manual / lightsheet panel
+ _lsToggle = document.getElementById('devices-ls-toggle');
+ _lsImg = document.getElementById('devices-ls-img');
+ _lsPlaceholder = document.getElementById('devices-ls-placeholder');
+ _lsStage = document.getElementById('devices-ls-stage');
+ _lsLed = document.getElementById('devices-ls-led');
+ _lsMeta = document.getElementById('devices-ls-meta');
+ _lsGalvoSlider = document.getElementById('devices-ls-galvo-slider');
+ _lsGalvoNum = document.getElementById('devices-ls-galvo');
+ _lsPiezoSlider = document.getElementById('devices-ls-piezo-slider');
+ _lsPiezoNum = document.getElementById('devices-ls-piezo');
+ _lsExposureNum = document.getElementById('devices-ls-exposure');
+ _lsLedOpen = document.getElementById('devices-ls-led-open');
+ _lsLedClosed = document.getElementById('devices-ls-led-closed');
+ _lsCamLed = document.getElementById('devices-ls-cam-led');
+ _lsRoomLightBtn = document.getElementById('devices-ls-room-light-btn');
+ _lsSnapVolBtn = document.getElementById('devices-ls-snap-volume');
+ _lsBurstBtn = document.getElementById('devices-ls-burst');
+ _lsLastcap = document.getElementById('devices-ls-lastcap');
+ _lsLastcapRef = document.getElementById('devices-ls-lastcap-ref');
+ _lsLaserStatus = document.getElementById('devices-ls-laser-status');
+ _lsTempInput = document.getElementById('devices-ls-temp-input');
+ _lsTempSet = document.getElementById('devices-ls-temp-set');
+
_roomLightToggle = document.getElementById('devices-room-light-toggle');
_roomLightLabel = document.getElementById('devices-room-light-label');
@@ -1292,6 +1345,405 @@ const DevicesManager = (function () {
}
}
+ // =====================================================================
+ // Lightsheet live panel (Manual view)
+ // =====================================================================
+
+ /** Gate ALL lasers off via the Laser "ALL OFF" config-group preset.
+ * Updates the indicator span from the actual API result (not a static label).
+ * Fire-and-forget safe — failure shows a warning, never throws. */
+ async function setLaserOff() {
+ cacheDom();
+ try {
+ const res = await fetch('/api/devices/laser/off', { method: 'POST' });
+ if (_lsLaserStatus) {
+ _lsLaserStatus.textContent = res.ok ? 'OFF (brightfield)' : 'warning: state unknown';
+ }
+ } catch (err) {
+ if (_lsLaserStatus) _lsLaserStatus.textContent = 'warning: state unknown';
+ console.debug('laser off call failed:', err);
+ }
+ }
+
+ async function toggleLightsheetStream() {
+ if (!_lsToggle) return;
+ _lsToggle.disabled = true;
+ try {
+ const starting = !_lsStreaming;
+ const endpoint = _lsStreaming
+ ? '/api/devices/lightsheet/live/stop'
+ : '/api/devices/lightsheet/live/start';
+ const res = await fetch(endpoint, { method: 'POST' });
+ if (!res.ok) {
+ const detail = await res.text();
+ console.error('Lightsheet toggle failed:', detail);
+ if (_lsMeta) _lsMeta.textContent = `error: ${res.status}`;
+ return;
+ }
+ const data = await res.json();
+ applyLightsheetState(!!data.streaming);
+ // Gate lasers off whenever live starts — brightfield-safe by default.
+ if (starting && data.streaming) setLaserOff();
+ } catch (err) {
+ console.error('Lightsheet toggle failed:', err);
+ if (_lsMeta) _lsMeta.textContent = `error: ${err}`;
+ } finally {
+ _lsToggle.disabled = false;
+ }
+ }
+
+ function applyLightsheetState(streaming) {
+ _lsStreaming = streaming;
+ if (_lsToggle) {
+ _lsToggle.textContent = streaming ? 'Stop' : 'Start';
+ _lsToggle.classList.toggle('active', streaming);
+ }
+ if (_lsLed) {
+ _lsLed.classList.toggle('live', streaming);
+ _lsLed.classList.remove('stale');
+ }
+ if (!streaming) {
+ _lsHasFrame = false;
+ _lsFrameTimes = [];
+ if (_lsImg) _lsImg.classList.remove('has-frame');
+ if (_lsPlaceholder) _lsPlaceholder.hidden = false;
+ if (_lsMeta) _lsMeta.textContent = 'stream off';
+ if (_lsStaleTimer) { clearTimeout(_lsStaleTimer); _lsStaleTimer = null; }
+ resetLightsheetZoom();
+ } else {
+ _lsFrameTimes = [];
+ if (_lsMeta) _lsMeta.textContent = 'waiting…';
+ }
+ }
+
+ function handleLightsheetFrame(payload) {
+ if (!payload || !payload.jpeg_b64 || !_lsImg) return;
+ _lsImg.src = `data:${payload.mime || 'image/jpeg'};base64,${payload.jpeg_b64}`;
+ if (!_lsHasFrame) {
+ _lsHasFrame = true;
+ _lsImg.classList.add('has-frame');
+ if (_lsPlaceholder) _lsPlaceholder.hidden = true;
+ }
+ const now = performance.now();
+ _lsLastFrameTs = Date.now();
+ _lsFrameTimes.push(now);
+ if (_lsFrameTimes.length > _LS_FPS_WINDOW) _lsFrameTimes.shift();
+ if (_lsLed) {
+ _lsLed.classList.add('live');
+ _lsLed.classList.remove('stale');
+ }
+ if (_lsMeta) {
+ const shape = payload.shape || [];
+ const dims = shape.length === 2 ? `${shape[1]}×${shape[0]}` : '';
+ const fps = computeLightsheetFps();
+ _lsMeta.textContent = dims
+ ? `${dims} · ${fps != null ? fps.toFixed(1) + ' fps' : '…'}`
+ : (fps != null ? `${fps.toFixed(1)} fps` : 'live');
+ }
+ scheduleLightsheetStaleCheck();
+ }
+
+ function computeLightsheetFps() {
+ const n = _lsFrameTimes.length;
+ if (n < 2) return null;
+ const span = _lsFrameTimes[n - 1] - _lsFrameTimes[0];
+ if (span <= 0) return null;
+ return ((n - 1) * 1000) / span;
+ }
+
+ function scheduleLightsheetStaleCheck() {
+ if (_lsStaleTimer) clearTimeout(_lsStaleTimer);
+ _lsStaleTimer = setTimeout(() => {
+ const age = (Date.now() - _lsLastFrameTs) / 1000;
+ if (_lsMeta) _lsMeta.textContent = `last frame ${age.toFixed(1)}s ago`;
+ if (_lsLed) _lsLed.classList.add('stale');
+ }, 1500);
+ }
+
+ async function syncInitialLightsheetState() {
+ try {
+ const res = await fetch('/api/devices/lightsheet/live/status');
+ if (!res.ok) return;
+ const data = await res.json();
+ applyLightsheetState(!!data.streaming);
+ } catch (err) {
+ console.debug('lightsheet status check failed:', err);
+ }
+ }
+
+ // ---- Lightsheet zoom / pan (mirrors camera zoom/pan) ----------------
+ function applyLightsheetTransform() {
+ if (!_lsImg) return;
+ _lsImg.style.transform =
+ `translate(${_lsTx}px, ${_lsTy}px) scale(${_lsZoom})`;
+ }
+
+ function resetLightsheetZoom() {
+ _lsZoom = 1;
+ _lsTx = 0;
+ _lsTy = 0;
+ applyLightsheetTransform();
+ if (_lsStage) _lsStage.classList.remove('camera-zoomed', 'camera-panning');
+ }
+
+ function clampLightsheetPan() {
+ if (!_lsStage) return;
+ const rect = _lsStage.getBoundingClientRect();
+ const maxX = (rect.width * (_lsZoom - 1)) / 2;
+ const maxY = (rect.height * (_lsZoom - 1)) / 2;
+ _lsTx = Math.max(-maxX, Math.min(maxX, _lsTx));
+ _lsTy = Math.max(-maxY, Math.min(maxY, _lsTy));
+ }
+
+ function onLightsheetWheel(event) {
+ if (!_lsStage) return;
+ event.preventDefault();
+ const rect = _lsStage.getBoundingClientRect();
+ const cx = event.clientX - rect.left - rect.width / 2;
+ const cy = event.clientY - rect.top - rect.height / 2;
+ const oldZoom = _lsZoom;
+ const factor = event.deltaY < 0 ? _CAM_ZOOM_STEP : 1 / _CAM_ZOOM_STEP;
+ const newZoom = Math.max(_CAM_ZOOM_MIN, Math.min(_CAM_ZOOM_MAX, oldZoom * factor));
+ if (newZoom === oldZoom) return;
+ const ratio = newZoom / oldZoom;
+ _lsTx = cx - (cx - _lsTx) * ratio;
+ _lsTy = cy - (cy - _lsTy) * ratio;
+ _lsZoom = newZoom;
+ if (Math.abs(_lsZoom - 1) < 0.001) { resetLightsheetZoom(); return; }
+ clampLightsheetPan();
+ applyLightsheetTransform();
+ _lsStage.classList.add('camera-zoomed');
+ }
+
+ function onLightsheetPointerDown(event) {
+ if (event.button !== 0) return;
+ if (_lsZoom <= 1) return;
+ _lsPanLast = { x: event.clientX, y: event.clientY };
+ try { _lsStage.setPointerCapture(event.pointerId); } catch (_) {}
+ _lsStage.classList.add('camera-panning');
+ event.preventDefault();
+ }
+
+ function onLightsheetPointerMove(event) {
+ if (!_lsPanLast) return;
+ _lsTx += event.clientX - _lsPanLast.x;
+ _lsTy += event.clientY - _lsPanLast.y;
+ _lsPanLast = { x: event.clientX, y: event.clientY };
+ clampLightsheetPan();
+ applyLightsheetTransform();
+ }
+
+ function onLightsheetPointerEnd(event) {
+ if (!_lsPanLast) return;
+ _lsPanLast = null;
+ try { _lsStage.releasePointerCapture(event.pointerId); } catch (_) {}
+ if (_lsStage) _lsStage.classList.remove('camera-panning');
+ }
+
+ function onLightsheetDoubleClick(event) {
+ if (_lsZoom !== 1 || _lsTx !== 0 || _lsTy !== 0) {
+ event.preventDefault();
+ resetLightsheetZoom();
+ }
+ }
+
+ // ---- Lightsheet live params (debounced) -----------------------------
+ function postLightsheetParams() {
+ fetch('/api/devices/lightsheet/live/params', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ galvo: _lsGalvo, piezo: _lsPiezo, exposure: _lsExposure }),
+ }).catch(err => console.debug('lightsheet params post failed:', err));
+ }
+
+ function scheduleLightsheetParamPost() {
+ if (_lsParamTimer) clearTimeout(_lsParamTimer);
+ _lsParamTimer = setTimeout(postLightsheetParams, 120);
+ }
+
+ function onGalvoInput(src) {
+ const v = parseFloat(src.value);
+ if (isNaN(v)) return;
+ _lsGalvo = v;
+ // Sync the sibling control
+ if (src === _lsGalvoSlider && _lsGalvoNum) _lsGalvoNum.value = v;
+ if (src === _lsGalvoNum && _lsGalvoSlider) _lsGalvoSlider.value = v;
+ scheduleLightsheetParamPost();
+ }
+
+ function onPiezoInput(src) {
+ const v = parseFloat(src.value);
+ if (isNaN(v)) return;
+ _lsPiezo = v;
+ if (src === _lsPiezoSlider && _lsPiezoNum) _lsPiezoNum.value = v;
+ if (src === _lsPiezoNum && _lsPiezoSlider) _lsPiezoSlider.value = v;
+ scheduleLightsheetParamPost();
+ }
+
+ function onExposureInput() {
+ const v = parseFloat(_lsExposureNum && _lsExposureNum.value);
+ if (isNaN(v) || v < 1) return;
+ _lsExposure = v;
+ scheduleLightsheetParamPost();
+ }
+
+ // ---- Illumination toggles -------------------------------------------
+ async function postLedPreset(preset) {
+ try {
+ await fetch('/api/devices/led/set', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ state: preset }),
+ });
+ } catch (err) { console.debug('LED preset failed:', err); }
+ }
+
+ async function toggleCamLedMode() {
+ _lsCamLedOn = !_lsCamLedOn;
+ if (_lsCamLed) {
+ _lsCamLed.classList.toggle('ls-illum-btn--active', _lsCamLedOn);
+ _lsCamLed.setAttribute('aria-pressed', _lsCamLedOn ? 'true' : 'false');
+ }
+ try {
+ await fetch('/api/devices/camera/led_mode', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ use_led: _lsCamLedOn }),
+ });
+ } catch (err) { console.debug('cam LED mode failed:', err); }
+ }
+
+ async function toggleManualRoomLight() {
+ const nextState = _roomLightState === 'on' ? 'off' : 'on';
+ if (_lsRoomLightBtn) {
+ _lsRoomLightBtn.classList.toggle('ls-illum-btn--active', nextState === 'on');
+ _lsRoomLightBtn.setAttribute('aria-pressed', nextState === 'on' ? 'true' : 'false');
+ }
+ try {
+ const res = await fetch('/api/devices/room_light/set', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ state: nextState }),
+ });
+ if (res.ok) {
+ const data = await res.json();
+ _roomLightState = data.state || nextState;
+ if (_lsRoomLightBtn) {
+ const on = _roomLightState === 'on';
+ _lsRoomLightBtn.classList.toggle('ls-illum-btn--active', on);
+ _lsRoomLightBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
+ }
+ }
+ } catch (err) { console.debug('manual room light toggle failed:', err); }
+ }
+
+ // ---- Acquire --------------------------------------------------------
+ async function runLightsheetAcquire(mode) {
+ const btn = mode === 'burst' ? _lsBurstBtn : _lsSnapVolBtn;
+ if (btn) { btn.disabled = true; btn.textContent = 'acquiring…'; }
+ try {
+ let res;
+ if (mode === 'burst') {
+ res = await fetch('/api/devices/acquire/burst', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ frames: 10, mode: 'brightfield',
+ num_slices: 50, exposure_ms: _lsExposure,
+ laser_config: 'ALL OFF',
+ piezo_center: _lsPiezo,
+ galvo_center: _lsGalvo }),
+ });
+ } else {
+ res = await fetch('/api/devices/acquire/volume', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ num_slices: 50, exposure_ms: _lsExposure,
+ laser_config: 'ALL OFF',
+ piezo_center: _lsPiezo,
+ galvo_center: _lsGalvo }),
+ });
+ }
+ if (!res.ok) {
+ console.error('acquire failed:', res.status, await res.text());
+ return;
+ }
+ const data = await res.json();
+ if (_lsLastcap) _lsLastcap.hidden = false;
+ if (_lsLastcapRef) {
+ _lsLastcapRef.textContent = data.volume_path || data.path || data.id || 'done';
+ }
+ } catch (err) {
+ console.error('acquire error:', err);
+ } finally {
+ if (btn) {
+ btn.disabled = false;
+ btn.textContent = mode === 'burst' ? 'Burst' : 'Snap Volume';
+ }
+ }
+ }
+
+ // ---- Temperature set (rail copy, delegates to same API) -------------
+ async function setLightsheetTemperature() {
+ if (!_lsTempInput) return;
+ const target = parseFloat(_lsTempInput.value);
+ if (isNaN(target) || target < 0 || target > 99.9) return;
+ try {
+ await fetch('/api/devices/temperature/set', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ target_c: target }),
+ });
+ } catch (err) { console.debug('ls temp set failed:', err); }
+ }
+
+ function setupManualWiring() {
+ if (!_lsToggle) return;
+ _lsToggle.addEventListener('click', toggleLightsheetStream);
+ applyLightsheetState(false);
+
+ // Param controls — slider ↔ number sync + debounced POST
+ if (_lsGalvoSlider) _lsGalvoSlider.addEventListener('input', () => onGalvoInput(_lsGalvoSlider));
+ if (_lsGalvoNum) _lsGalvoNum.addEventListener('input', () => onGalvoInput(_lsGalvoNum));
+ if (_lsPiezoSlider) _lsPiezoSlider.addEventListener('input', () => onPiezoInput(_lsPiezoSlider));
+ if (_lsPiezoNum) _lsPiezoNum.addEventListener('input', () => onPiezoInput(_lsPiezoNum));
+ if (_lsExposureNum) _lsExposureNum.addEventListener('input', onExposureInput);
+
+ // Illumination
+ if (_lsLedOpen) _lsLedOpen.addEventListener('click', () => postLedPreset('Open'));
+ if (_lsLedClosed) _lsLedClosed.addEventListener('click', () => postLedPreset('Closed'));
+ if (_lsCamLed) _lsCamLed.addEventListener('click', toggleCamLedMode);
+ if (_lsRoomLightBtn) _lsRoomLightBtn.addEventListener('click', toggleManualRoomLight);
+
+ // Acquire
+ if (_lsSnapVolBtn) _lsSnapVolBtn.addEventListener('click', () => runLightsheetAcquire('volume'));
+ if (_lsBurstBtn) _lsBurstBtn.addEventListener('click', () => runLightsheetAcquire('burst'));
+
+ // Temperature
+ if (_lsTempSet) _lsTempSet.addEventListener('click', setLightsheetTemperature);
+ if (_lsTempInput) {
+ _lsTempInput.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') { e.preventDefault(); setLightsheetTemperature(); }
+ });
+ }
+
+ // Zoom / pan
+ if (_lsStage) {
+ _lsStage.addEventListener('wheel', onLightsheetWheel, { passive: false });
+ _lsStage.addEventListener('pointerdown', onLightsheetPointerDown);
+ _lsStage.addEventListener('pointermove', onLightsheetPointerMove);
+ _lsStage.addEventListener('pointerup', onLightsheetPointerEnd);
+ _lsStage.addEventListener('pointercancel', onLightsheetPointerEnd);
+ _lsStage.addEventListener('dblclick', onLightsheetDoubleClick);
+ }
+
+ // Subscribe to LIGHTSHEET_FRAME events
+ if (typeof ClientEventBus !== 'undefined') {
+ ClientEventBus.on('LIGHTSHEET_FRAME', handleLightsheetFrame);
+ }
+
+ syncInitialLightsheetState();
+ }
+
// =====================================================================
// Room-light toggle
// =====================================================================
@@ -1506,6 +1958,21 @@ const DevicesManager = (function () {
if (viewName === 'optical3d' && typeof Occupancy3DManager !== 'undefined') {
Occupancy3DManager.init();
}
+ // Initialize temperature graph for the active view. The TemperatureGraph
+ // is a singleton; reinit on view switch ensures only one graph target
+ // is live at a time. ClientEventBus.off/on in TemperatureGraph.init
+ // makes re-init safe (idempotent).
+ if (window.TemperatureGraph) {
+ if (viewName === 'manual') {
+ const el = document.getElementById('devices-ls-tempgraph');
+ if (el) TemperatureGraph.init(el, 'current');
+ } else {
+ const el = document.getElementById('devices-temp-graph');
+ if (el) TemperatureGraph.init(el, 'current');
+ }
+ }
+ // Entering Manual view — gate lasers off immediately (brightfield-safe).
+ if (viewName === 'manual') setLaserOff();
}
function setupViewSwitcher() {
@@ -1552,10 +2019,9 @@ const DevicesManager = (function () {
cacheDom();
setupViewSwitcher();
setupCameraWiring();
+ setupManualWiring();
setupRoomLight();
setupTemperature();
- const _tgEl = document.getElementById('devices-temp-graph');
- if (_tgEl && window.TemperatureGraph) TemperatureGraph.init(_tgEl, 'current');
loadCoverslip();
loadEmbryosSnapshot();
switchView(_currentView);
@@ -1577,13 +2043,14 @@ const DevicesManager = (function () {
document.addEventListener('keydown', onMapKeyDown);
setStatus('stale', 'waiting', 'no payload yet');
syncInitialCameraState();
- // Stop the camera stream if the tab is closed while it's running,
- // so MMCore isn't held by a disconnected browser.
+ // Stop the camera and lightsheet streams if the tab is closed while
+ // running, so MMCore isn't held by a disconnected browser.
window.addEventListener('beforeunload', () => {
if (_camStreaming) {
- try {
- navigator.sendBeacon('/api/devices/bottom_camera/stream/stop');
- } catch (_) {}
+ try { navigator.sendBeacon('/api/devices/bottom_camera/stream/stop'); } catch (_) {}
+ }
+ if (_lsStreaming) {
+ try { navigator.sendBeacon('/api/devices/lightsheet/live/stop'); } catch (_) {}
}
});
}
diff --git a/gently/ui/web/static/js/websocket.js b/gently/ui/web/static/js/websocket.js
index bcbef78f..cd2ba5d5 100644
--- a/gently/ui/web/static/js/websocket.js
+++ b/gently/ui/web/static/js/websocket.js
@@ -104,7 +104,8 @@ function handleMessage(msg) {
// client event bus so the Devices tab gets the payload.
if (msg.event_type !== 'DEVICE_STATE_UPDATE' &&
msg.event_type !== 'BOTTOM_CAMERA_FRAME' &&
- msg.event_type !== 'TEMPERATURE_UPDATE') {
+ msg.event_type !== 'TEMPERATURE_UPDATE' &&
+ msg.event_type !== 'LIGHTSHEET_FRAME') {
handleFullEvent({
event_type: msg.event_type,
data: msg.data,
diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html
index 7e1f0726..1b1dba8e 100644
--- a/gently/ui/web/templates/index.html
+++ b/gently/ui/web/templates/index.html
@@ -446,6 +446,7 @@ Device Map
+