diff --git a/docs/superpowers/plans/2026-06-29-manual-mode-dual-camera.md b/docs/superpowers/plans/2026-06-29-manual-mode-dual-camera.md new file mode 100644 index 00000000..96e4c141 --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-manual-mode-dual-camera.md @@ -0,0 +1,38 @@ +# Manual mode B2 — dual-camera + laser-preset browser + timelapse form Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`. + +**Goal:** Extend B1's single-camera manual mode with a laser-preset browser, dual-camera (side A/B) config, and a manual timelapse-config form — building the headless parts now (live dual-view + real acquisition are rig-deferred). + +**Architecture:** New `require_control` proxy routes wrapping existing client/device-layer + agent-tool paths; device_factory registers a second camera defensively; UI additions to `#devices-view-manual` / `devices.js`. + +## Global Constraints +- HEADLESS-buildable parts only; mark RIG-DEFERRED parts (live dual view, real acquisition/timelapse) as noted in the spec — don't fake hardware. +- Backward compatible: single-camera rigs must still start (defensive HamCam2 registration); the manual-view-entry laser-off safety (B1 I3) stays intact. +- Laser preset list already exists: `GET /api/devices/laser/configs` (data.py:526). Reuse it; add only the set proxy + UI. +- Proxy routes mirror the existing `routes/data.py` `require_control` pattern (e.g. `/api/devices/laser/off` :508). Device-layer/client calls mirror existing ones. +- UI extends `#devices-view-manual` (index.html ~:720-835) + `DevicesManager` in `devices.js`. +- Git hygiene: stage only your files by explicit path; never `git add -A`. + +--- + +### Task 1: Laser-preset browser +**Files:** Modify `gently/ui/web/routes/data.py` (add `POST /api/devices/laser/config` `require_control` → `client.set_laser_config(name)`, mirror `/laser/off` :508); `gently/ui/web/templates/index.html` (the Illumination group ~:800 — replace the static `#devices-ls-laser-status` indicator with a `` populated from `GET /api/devices/laser/configs`; on change POST the + chosen preset. Keep "ALL OFF" the safe default + the existing manual-view-entry laser-off safety (don't + remove the I3 guard — selecting a preset is an explicit user action). +- **Rig-deferred:** the actual laser firing (the preset just calls `setConfig` on the rig). + +## 2. Dual-camera config +- **Backend (headless):** register a second `DiSPIMCamera("HamCam2")` as `devices["camera_b"]` in + `device_factory.py` — DEFENSIVELY (only if the camera is in the core's loaded devices; skip + log + otherwise, so single-camera rigs still start). Add a `side` field ('A'|'B') to `_ls_params` + + `handle_lightsheet_params`; `_ensure_lightsheet_sequence_sync` picks `camera` vs `camera_b` by side and + restarts the sequence on side change (reuse the exposure-change restart path). New `GET /api/devices/cameras` + endpoint listing available camera roles (A always; B if registered). +- **UI (headless):** a "Side A / B" selector in the manual rail → carries `side` on the live/params POST. +- **Rig-deferred:** live DUAL view via the "Multi Camera" fusion device (live-only) + dual-side acquisition + (two parallel `startSequenceAcquisition` + tag demux). v1 = single live stream, switchable side. + +## 3. Timelapse config form +- **Backend (headless):** new `POST /api/devices/timelapse/start` (`require_control`) proxy wrapping the + agent path `start_adaptive_timelapse(embryo_ids, stop_condition, interval_seconds, condition_value, + monitoring_mode)` (validate params; resolve the orchestrator like the agent tool does). The volume + geometry (num_slices/exposure/galvo±/piezo±/laser_config/power) is captured in the form + passed through + / persisted to per-embryo calibration where applicable. +- **UI (headless):** a collapsible "Timelapse" panel in the manual rail gathering cadence/stop/embryos/ + monitoring_mode + the volume geometry, reading `GET /api/devices/scan_geometry` + `/api/devices/laser/configs` + for defaults. A "Start timelapse" submit → the new proxy. +- **Rig-deferred:** the actual timelapse run + galvo/piezo motion. + +## 4. Out of scope / deferred +- Live Multi-Camera dual view + dual-side acquisition demux (rig). +- Saving timelapse configs as reusable presets (could reuse the tactic-library/plan-template later). +- Per-line laser power UI beyond the preset (the clamps in `optical.py` still apply). + +## 5. Testing +- Laser-preset: the POST proxy (TestClient + mock client asserts `set_laser_config(name)`); `node --check` + + Chrome audit of the dropdown populated from a stubbed configs endpoint. +- Dual-camera: device_factory registers camera_b against a FAKE core that has HamCam2 (and skips when + absent); the `side` param threads into `_ls_params` + selects the camera; `/api/devices/cameras` lists + roles; `node --check` + Chrome audit of the side selector. +- Timelapse: the start proxy validates + calls the orchestrator path (mock); the form gathers + posts the + params; `node --check` + Chrome audit of the form. +- All three: backward compatible (single-camera rig unaffected; the laser-off safety intact). diff --git a/gently/app/agent.py b/gently/app/agent.py index 47b2421d..3aa4989d 100644 --- a/gently/app/agent.py +++ b/gently/app/agent.py @@ -474,6 +474,7 @@ def exit_plan_mode(self) -> str: from gently.app.tools.operation_plan_seed import ( seed_operation_plan_from_plan_item, ) + seed_operation_plan_from_plan_item(self.context_store, self.session_id) except Exception: logger.exception("operation-plan seeding failed") @@ -650,9 +651,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: diff --git a/gently/app/operation_plan_updater.py b/gently/app/operation_plan_updater.py index d2e2b9e4..738fe10c 100644 --- a/gently/app/operation_plan_updater.py +++ b/gently/app/operation_plan_updater.py @@ -12,13 +12,14 @@ `tactic_id` or session → skip. Handler exceptions are caught so a bad event never crashes the bus. """ + from __future__ import annotations import logging -from typing import Callable +from collections.abc import Callable +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/orchestration/role_scope.py b/gently/app/orchestration/role_scope.py index b36bd89e..66c9b9d5 100644 --- a/gently/app/orchestration/role_scope.py +++ b/gently/app/orchestration/role_scope.py @@ -52,9 +52,7 @@ def resolve_scope_embryos(scope: dict | None, embryos: list[dict]) -> list[str]: if mode == "role": target_role = scope.get("role") return [ - e["embryo_id"] - for e in embryos - if "embryo_id" in e and e.get("role") == target_role + e["embryo_id"] for e in embryos if "embryo_id" in e and e.get("role") == target_role ] return [] diff --git a/gently/app/orchestration/temperature_protocol.py b/gently/app/orchestration/temperature_protocol.py index 115234f2..e25ee148 100644 --- a/gently/app/orchestration/temperature_protocol.py +++ b/gently/app/orchestration/temperature_protocol.py @@ -1,8 +1,12 @@ -import asyncio, logging +import asyncio +import logging + from gently.app.orchestration.exclusive import BurstAcquisition from gently.core.event_bus import EventType + logger = logging.getLogger(__name__) + async def wait_for_temperature_lock(client, timeout_s: float, poll_s: float = 2.0) -> bool: """Poll the controller until it reports a locked state, or timeout. Substring 'LOCKED'.""" loop = asyncio.get_event_loop() @@ -11,7 +15,8 @@ async def wait_for_temperature_lock(client, timeout_s: float, poll_s: float = 2. try: resp = await client.get_temperature() except Exception as exc: - logger.warning("wait_for_temperature_lock poll failed: %s", exc); resp = {} + logger.warning("wait_for_temperature_lock poll failed: %s", exc) + resp = {} if "LOCKED" in str(resp.get("state", "")): return True if loop.time() - t0 >= timeout_s: @@ -19,35 +24,66 @@ async def wait_for_temperature_lock(client, timeout_s: float, poll_s: float = 2. await asyncio.sleep(poll_s) -async def run_temp_change_burst_protocol(orchestrator, embryo_id, target_setpoint_c, *, - frames=60, mode="1hz", num_slices=1, bursts_before=1, bursts_after=1, - lock_timeout_s=600.0, poll_s=2.0, burst_runner=None, tactic_id=None): +async def run_temp_change_burst_protocol( + orchestrator, + embryo_id, + target_setpoint_c, + *, + frames=60, + mode="1hz", + num_slices=1, + bursts_before=1, + bursts_after=1, + lock_timeout_s=600.0, + poll_s=2.0, + burst_runner=None, + tactic_id=None, +): """Scripted temp-change burst protocol: brightfield before/during/after a setpoint change.""" client = orchestrator.client if burst_runner is None: - async def burst_runner(b): await b.run(orchestrator) + + async def burst_runner(b): + await b.run(orchestrator) async def one_burst(phase): - b = BurstAcquisition(embryo_id, frames=frames, mode=mode, num_slices=num_slices, - temperature_provider=getattr(orchestrator, "_temperature_provider", None), - laser_config="ALL OFF") + b = BurstAcquisition( + embryo_id, + frames=frames, + mode=mode, + num_slices=num_slices, + temperature_provider=getattr(orchestrator, "_temperature_provider", None), + laser_config="ALL OFF", + ) b._phase = phase await burst_runner(b) - locked = False; error = None; cancelled = False + locked = False + error = None + cancelled = False try: await client.set_laser_config("ALL OFF") await client.set_led("Open") - orchestrator._emit_event(EventType.TEMP_PROTOCOL_STARTED, - {"embryo_id": embryo_id, "target_setpoint_c": target_setpoint_c, - "frames": frames, "bursts_before": bursts_before, "bursts_after": bursts_after, - "tactic_id": tactic_id}) + orchestrator._emit_event( + EventType.TEMP_PROTOCOL_STARTED, + { + "embryo_id": embryo_id, + "target_setpoint_c": target_setpoint_c, + "frames": frames, + "bursts_before": bursts_before, + "bursts_after": bursts_after, + "tactic_id": tactic_id, + }, + ) for _ in range(bursts_before): await one_burst("before") await client.set_temperature(target_setpoint_c) - orchestrator._emit_event(EventType.TEMPERATURE_SETPOINT_CHANGED, - {"embryo_id": embryo_id, "to": target_setpoint_c}) - loop = asyncio.get_event_loop(); t0 = loop.time() + orchestrator._emit_event( + EventType.TEMPERATURE_SETPOINT_CHANGED, + {"embryo_id": embryo_id, "to": target_setpoint_c}, + ) + loop = asyncio.get_event_loop() + t0 = loop.time() while True: await one_burst("during") try: @@ -55,17 +91,27 @@ async def one_burst(phase): except Exception: st = "" if "LOCKED" in st: - locked = True; break + locked = True + break if loop.time() - t0 >= lock_timeout_s: break for _ in range(bursts_after): await one_burst("after") except asyncio.CancelledError: - cancelled = True; raise + cancelled = True + raise except Exception as exc: - error = str(exc); logger.exception("temp-change burst protocol failed") + error = str(exc) + logger.exception("temp-change burst protocol failed") finally: - orchestrator._emit_event(EventType.TEMP_PROTOCOL_COMPLETED, - {"embryo_id": embryo_id, "locked": locked, "cancelled": cancelled, "error": error, - "tactic_id": tactic_id}) + orchestrator._emit_event( + EventType.TEMP_PROTOCOL_COMPLETED, + { + "embryo_id": embryo_id, + "locked": locked, + "cancelled": cancelled, + "error": error, + "tactic_id": tactic_id, + }, + ) return {"locked": locked, "cancelled": cancelled, "error": error} 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/app/tools/operation_plan_seed.py b/gently/app/tools/operation_plan_seed.py index 3878e704..3bf0caa1 100644 --- a/gently/app/tools/operation_plan_seed.py +++ b/gently/app/tools/operation_plan_seed.py @@ -50,9 +50,7 @@ def _resolve_plan_item_with_tactics(context_store, session_id: str): items = context_store.get_plan_items(campaign_id=cid) for item in items: # Match items that list this session - session_ids = item.session_ids or ( - [item.session_id] if item.session_id else [] - ) + session_ids = item.session_ids or ([item.session_id] if item.session_id else []) if session_id not in session_ids and item.session_id != session_id: continue # Must carry an imaging_spec with at least one tactic outline entry diff --git a/gently/app/tools/tactic_library_tools.py b/gently/app/tools/tactic_library_tools.py index 96a3eee5..78b141d5 100644 --- a/gently/app/tools/tactic_library_tools.py +++ b/gently/app/tools/tactic_library_tools.py @@ -124,7 +124,9 @@ async def list_tactics( lines = [f"Tactic Library ({len(tactics)} template{'s' if len(tactics) != 1 else ''}):"] for t in tactics: - lines.append(f" [{t.get('id', '?')}] {t.get('name', '(unnamed)')} — kind: {t.get('kind', '?')}") + lines.append( + f" [{t.get('id', '?')}] {t.get('name', '(unnamed)')} — kind: {t.get('kind', '?')}" + ) return "\n".join(lines) diff --git a/gently/app/tools/temperature_protocol_tools.py b/gently/app/tools/temperature_protocol_tools.py index 3e79055f..80d6166a 100644 --- a/gently/app/tools/temperature_protocol_tools.py +++ b/gently/app/tools/temperature_protocol_tools.py @@ -8,7 +8,11 @@ import asyncio -from gently.harness.tools.helpers import ctx_get, require_agent, require_microscope, require_timelapse_orchestrator +from gently.harness.tools.helpers import ( + require_agent, + require_microscope, + require_timelapse_orchestrator, +) from gently.harness.tools.registry import ToolCategory, ToolExample, tool @@ -33,7 +37,12 @@ ), ToolExample( "Cold-shock embryo_2 to 15 C with 2 bursts before and after", - {"embryo_id": "embryo_2", "target_setpoint_c": 15.0, "bursts_before": 2, "bursts_after": 2}, + { + "embryo_id": "embryo_2", + "target_setpoint_c": 15.0, + "bursts_before": 2, + "bursts_after": 2, + }, ), ], ) diff --git a/gently/core/event_bus.py b/gently/core/event_bus.py index 0b5db1cd..4df91bb4 100644 --- a/gently/core/event_bus.py +++ b/gently/core/event_bus.py @@ -129,7 +129,9 @@ class EventType(Enum): # Temperature protocol events (Phase X / 10) — temp-change burst tactic TEMPERATURE_SETPOINT_CHANGED = auto() # {embryo_id, to} - TEMP_PROTOCOL_STARTED = auto() # {embryo_id, target_setpoint_c, frames, bursts_before, bursts_after} + TEMP_PROTOCOL_STARTED = ( + auto() + ) # {embryo_id, target_setpoint_c, frames, bursts_before, bursts_after} TEMP_PROTOCOL_COMPLETED = auto() # {embryo_id, locked, cancelled, error} # Reactive control telemetry (Phase 5 / 10) diff --git a/gently/core/file_store.py b/gently/core/file_store.py index d9dafaaa..0f82a486 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. @@ -778,9 +779,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 1437a8a8..b4b38557 100644 --- a/gently/hardware/dispim/client.py +++ b/gently/hardware/dispim/client.py @@ -834,6 +834,15 @@ async def get_laser_configs(self) -> dict: """ return await self._api_get("/api/laser/configs") + async def get_cameras(self) -> dict: + """List available SPIM camera roles. + + Hits ``GET /api/cameras`` — returns ``{"cameras": ["A"]}`` on + single-camera rigs or ``{"cameras": ["A", "B"]}`` when HamCam2 is + registered as camera_b in the device layer. + """ + return await self._api_get("/api/cameras") + async def get_led_status(self) -> dict: """Get current LED status.""" return await self._api_get("/api/led/status") @@ -1015,9 +1024,9 @@ async def stream_lightsheet(self, timeout: float | None = None): logger.warning("Malformed lightsheet SSE payload skipped: %s", exc) async def set_lightsheet_live_params( - self, galvo=None, piezo=None, exposure=None + self, galvo=None, piezo=None, exposure=None, side=None ) -> dict: - """POST live galvo/piezo/exposure to the device-layer lightsheet streamer.""" + """POST live galvo/piezo/exposure/side to the device-layer lightsheet streamer.""" body = {} if galvo is not None: body["galvo"] = float(galvo) @@ -1025,6 +1034,8 @@ async def set_lightsheet_live_params( body["piezo"] = float(piezo) if exposure is not None: body["exposure"] = float(exposure) + if side is not None: + body["side"] = str(side) return await self._api_post("/api/lightsheet/live/params", body) async def set_camera_led_mode(self, use_led: bool = False) -> dict: diff --git a/gently/hardware/dispim/device_factory.py b/gently/hardware/dispim/device_factory.py index e914b1b1..b0bfb81c 100644 --- a/gently/hardware/dispim/device_factory.py +++ b/gently/hardware/dispim/device_factory.py @@ -64,8 +64,12 @@ def create_devices_from_mmcore(core: pymmcore.CMMCore, config: dict | None = Non default_config = { "xy_stage_name": "XYStage:XY:31", "camera_name": "HamCam1", + "camera_b_name": "HamCam2", "scanner_name": "Scanner:AB:33", "piezo_name": "PiezoStage:P:34", + # Side-B optics: registered defensively (absent on single-side rigs) + "scanner_b_name": "Scanner:CD:33", + "piezo_b_name": "PiezoStage:Q:35", "fdrive_name": "ZStage:V:37", "bottom_camera_name": "Bottom PCO", "led_name": "LED:X:31", @@ -108,6 +112,60 @@ def create_devices_from_mmcore(core: pymmcore.CMMCore, config: dict | None = Non except Exception as e: logger.warning("Could not create camera: %s", e) + # Defensively register the second SPIM camera (side B) only when present + # in the core's loaded-device list. Single-camera rigs that lack HamCam2 + # continue to start normally; camera_b is simply absent from devices. + try: + from .devices import DiSPIMCamera as _DiSPIMCamera + + cam_b_name = cfg.get("camera_b_name", "HamCam2") + loaded_devices = list(core.getLoadedDevices()) + if cam_b_name in loaded_devices: + camera_b = _DiSPIMCamera(device_name=cam_b_name, core=core) + devices["camera_b"] = camera_b + logger.info("Created camera_b (side B): %s", cam_b_name) + else: + logger.warning( + "camera_b (%s) not in loaded devices — single-camera rig or device absent; skipping", # noqa: E501 + cam_b_name, + ) + except Exception as e: + logger.warning("Could not create camera_b: %s", e) + + # Defensively register the side-B galvo scanner (Scanner:CD:33). + # Absent on single-side rigs — skip + log, do not crash. + try: + scanner_b_name = cfg.get("scanner_b_name", "Scanner:CD:33") + loaded_devices = list(core.getLoadedDevices()) + if scanner_b_name in loaded_devices: + scanner_b = DiSPIMScanner(name=scanner_b_name, core=core) + devices["scanner_b"] = scanner_b + logger.info("Created scanner_b (side B): %s", scanner_b_name) + else: + logger.warning( + "scanner_b (%s) not in loaded devices — single-side rig or device absent; skipping", + scanner_b_name, + ) + except Exception as e: + logger.warning("Could not create scanner_b: %s", e) + + # Defensively register the side-B imaging piezo (PiezoStage:Q:35). + # Absent on single-side rigs — skip + log, do not crash. + try: + piezo_b_name = cfg.get("piezo_b_name", "PiezoStage:Q:35") + loaded_devices = list(core.getLoadedDevices()) + if piezo_b_name in loaded_devices: + piezo_b = DiSPIMPiezo(name=piezo_b_name, core=core) + devices["piezo_b"] = piezo_b + logger.info("Created piezo_b (side B): %s", piezo_b_name) + else: + logger.warning( + "piezo_b (%s) not in loaded devices — single-side rig or device absent; skipping", + piezo_b_name, + ) + except Exception as e: + logger.warning("Could not create piezo_b: %s", e) + try: from .devices import DiSPIMLightSource diff --git a/gently/hardware/dispim/device_layer.py b/gently/hardware/dispim/device_layer.py index 0e44ebfa..0042f5af 100644 --- a/gently/hardware/dispim/device_layer.py +++ b/gently/hardware/dispim/device_layer.py @@ -171,14 +171,22 @@ def __init__( # 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_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_params: dict = { + "galvo": 0.0, + "piezo": 50.0, + "exposure": 20.0, + "side": "A", + # "snap" → per-frame snapImage()+getImage() — hard memory ceiling, safe default. + # "continuous" → startContinuousSequenceAcquisition (opt-in, higher FPS). + "mode": "snap", + } 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 + 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. @@ -981,6 +989,11 @@ async def _broadcast_camera(self, payload: dict[str, Any]): def _park_lightsheet_sync(self) -> None: """Park scanner galvo + imaging piezo at the current live params (static sheet). + Side 'A' drives devices["scanner"] / devices["piezo"] (Scanner:AB:33 / + PiezoStage:P:34). Side 'B' drives devices["scanner_b"] / devices["piezo_b"] + (Scanner:CD:33 / PiezoStage:Q:35) when registered. Falls back to side A + with a warning on single-side rigs so they are never broken. + 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). @@ -988,8 +1001,32 @@ def _park_lightsheet_sync(self) -> None: last-applied value (no-op on steady-state frames). """ p = self._ls_params - scanner = self.devices.get("scanner") - piezo = self.devices.get("piezo") + side = p.get("side", "A") + + # Resolve scanner for the requested side (with fallback to A). + if side == "B": + if "scanner_b" in self.devices: + scanner = self.devices["scanner_b"] + else: + logger.warning( + "Side B requested but scanner_b not registered; falling back to side A for scanner" # noqa: E501 + ) + scanner = self.devices.get("scanner") + else: + scanner = self.devices.get("scanner") + + # Resolve piezo for the requested side (with fallback to A). + if side == "B": + if "piezo_b" in self.devices: + piezo = self.devices["piezo_b"] + else: + logger.warning( + "Side B requested but piezo_b not registered; falling back to side A for piezo" + ) + piezo = self.devices.get("piezo") + else: + 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: @@ -1016,37 +1053,111 @@ def _park_lightsheet_sync(self) -> None: 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.""" + """Configure (or reconfigure on exposure/side change) the SPIM camera. + + Two modes gated by ``_ls_params["mode"]``: + + "snap" (default, safe) — per-frame snapImage()+getImage(). No circular + buffer at all; memory is bounded to one frame. Sets camera device + and exposure only when a reconfiguration is needed; never calls + startContinuousSequenceAcquisition. + + "continuous" (opt-in) — startContinuousSequenceAcquisition with a hard + footprint cap (256 MB) so the buffer can't balloon on a production + box. The grab loop drains with clearCircularBuffer() after each peek. + + Side 'A' uses devices["camera"] (HamCam1); side 'B' uses devices["camera_b"] + (HamCam2) when registered. If side B is requested but camera_b is absent, + falls back to side A and logs a warning — single-camera rigs are unaffected. + """ core = self.system.core - cam = self.devices.get("camera") + p = self._ls_params + side = p.get("side", "A") + mode = p.get("mode", "snap") + + # Resolve the camera for the requested side + if side == "B" and "camera_b" in self.devices: + cam = self.devices["camera_b"] + else: + if side == "B": + logger.warning( + "Side B requested but camera_b not registered; falling back to side A" + ) + cam = self.devices.get("camera") + if cam is None: raise RuntimeError("No lightsheet camera configured") - p = self._ls_params - need_restart = ( + + need_reconfigure = ( not self._ls_seq_started or self._ls_applied.get("exposure") != p["exposure"] + or self._ls_applied.get("side") != side ) - 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"] + + if mode == "snap": + # Snap mode: configure camera/exposure on change; no continuous sequence. + if need_reconfigure: + # Stop any lingering continuous sequence before snap takes over. + if core.isSequenceRunning(): + core.stopSequenceAcquisition() + try: + core.clearCircularBuffer() + except Exception: + pass + if core.getCameraDevice() != cam.name: + core.setCameraDevice(cam.name) + core.setExposure(cam.name, float(p["exposure"])) + self._ls_seq_started = True # "camera configured" flag; no sequence running + self._ls_applied["exposure"] = p["exposure"] + self._ls_applied["side"] = side + else: + # Continuous mode: cap buffer footprint then start sequence. + if need_reconfigure: + if core.isSequenceRunning(): + core.stopSequenceAcquisition() + if core.getCameraDevice() != cam.name: + core.setCameraDevice(cam.name) + core.setExposure(cam.name, float(p["exposure"])) + # Hard cap: prevent buffer balloon that crashed the production box. + try: + core.setCircularBufferMemoryFootprint(256) + except Exception: + logger.debug( + "setCircularBufferMemoryFootprint not available on this core", + exc_info=True, + ) + core.startContinuousSequenceAcquisition(self._ls_interval_sec * 1000.0) + self._ls_seq_started = True + self._ls_applied["exposure"] = p["exposure"] + self._ls_applied["side"] = side def _grab_lightsheet_frame_sync(self): - """Park → ensure sequence running → peek the latest frame (never drain).""" + """Park → configure camera → grab one frame. + + Snap mode (default): snapImage()+getImage() — no circular buffer, + hard memory ceiling, safe for long live-view sessions. + + Continuous mode (opt-in): peeks via getLastImage() then drains the + buffer with clearCircularBuffer() to prevent it sitting full. + """ try: - self._park_lightsheet_sync() # galvo/piezo applied live - self._ensure_lightsheet_sequence_sync() # start / restart on exposure + self._park_lightsheet_sync() + self._ensure_lightsheet_sequence_sync() try: from gently.hardware.dispim.devices.acquisition import _safe_obtain except (ImportError, AttributeError): _safe_obtain = None core = self.system.core - img = core.getLastImage() + mode = self._ls_params.get("mode", "snap") + if mode == "snap": + core.snapImage() + img = core.getImage() + else: + img = core.getLastImage() + try: + core.clearCircularBuffer() + except Exception: + pass if _safe_obtain is not None: try: img = _safe_obtain(img) @@ -1063,6 +1174,11 @@ def _stop_lightsheet_sequence_sync(self) -> None: self.system.core.stopSequenceAcquisition() except Exception: logger.debug("stop lightsheet sequence failed", exc_info=True) + # Drain the buffer on every stop path (snap mode: no-op; continuous: release memory). + try: + self.system.core.clearCircularBuffer() + except Exception: + pass self._ls_seq_started = False self._ls_applied = {} # Reset park guard so the next stream session re-idles the state @@ -1083,7 +1199,9 @@ async def _lightsheet_streamer(self): 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) + self._encode_frame_for_stream( + img, self._ls_target_max_dim, self._ls_jpeg_quality + ) if img is not None else None ) @@ -1167,15 +1285,17 @@ async def handle_lightsheet_stream(self, request): return response async def handle_lightsheet_params(self, request): - """POST /api/lightsheet/live/params — update live galvo/piezo/exposure. + """POST /api/lightsheet/live/params — update live galvo/piezo/exposure/side. - Body: {"galvo": float, "piezo": float, "exposure": float} (all optional). - Galvo/piezo apply on the next grab; exposure change triggers sequence restart. + Body: {"galvo": float, "piezo": float, "exposure": float, "side": "A"|"B"} (all optional). + Galvo/piezo apply on the next grab; exposure or side 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]) + if "side" in body and body["side"] in ("A", "B"): + self._ls_params["side"] = body["side"] return web.json_response({"params": self._ls_params}) # ========================================================================= @@ -2084,6 +2204,17 @@ async def handle_get_laser_configs(self, request): status=500, ) + async def handle_get_cameras(self, request): + """GET /api/cameras — list available SPIM camera roles. + + Returns ``{"cameras": ["A"]}`` on single-camera rigs, or + ``{"cameras": ["A", "B"]}`` when camera_b (HamCam2) is registered. + """ + cameras = ["A"] + if "camera_b" in self.devices: + cameras.append("B") + return web.json_response({"cameras": cameras}) + async def handle_get_camera_exposure(self, request): """GET /api/camera/exposure - Get bottom camera exposure time""" try: @@ -3100,6 +3231,7 @@ async def on_start(self): # 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) + self._app.router.add_get("/api/cameras", self.handle_get_cameras) # Start plan executor self._executor_task = asyncio.create_task(self._plan_executor()) diff --git a/gently/ui/web/routes/__init__.py b/gently/ui/web/routes/__init__.py index caebd0ec..a881a0bb 100644 --- a/gently/ui/web/routes/__init__.py +++ b/gently/ui/web/routes/__init__.py @@ -13,14 +13,14 @@ 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 .operation_plan import create_router as create_operation_plan_router -from .roles import create_router as create_roles_router -from .tactic_library import create_router as create_tactic_library_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 .operation_plan import create_router as create_operation_plan_router from .pages import create_router as create_pages_router +from .roles import create_router as create_roles_router from .sessions import create_router as create_sessions_router +from .tactic_library import create_router as create_tactic_library_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 eed3e5a9..075117be 100644 --- a/gently/ui/web/routes/data.py +++ b/gently/ui/web/routes/data.py @@ -476,14 +476,17 @@ async def stop_lightsheet_live_stream(): @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.""" + """Forward galvo/piezo/exposure/side 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")) + galvo=payload.get("galvo"), + piezo=payload.get("piezo"), + exposure=payload.get("exposure"), + side=payload.get("side"), + ) except Exception as exc: logger.exception("lightsheet live params failed") raise HTTPException(status_code=502, detail=f"params failed: {exc}") from exc @@ -539,6 +542,42 @@ async def laser_configs(): logger.exception("Laser configs fetch failed") raise HTTPException(status_code=502, detail=f"laser configs failed: {exc}") from exc + @router.post("/api/devices/laser/config", dependencies=[Depends(require_control)]) + async def laser_config_set(payload: dict = Body(...)): # noqa: B008 + """Apply a named Laser config-group preset (e.g. "ALL OFF", "488 only"). + + Body: {"config": ""} + + Returns the device layer response. 400 if config is missing/empty; + 503 if the microscope is not connected; 502 on device error. + """ + config = payload.get("config") + if not config or not isinstance(config, str): + raise HTTPException(status_code=400, detail="config must be a non-empty string") + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + return await client.set_laser_config(config) + except Exception as exc: + logger.exception("Laser config set command failed") + raise HTTPException(status_code=502, detail=f"laser config failed: {exc}") from exc + + @router.get("/api/devices/cameras") + async def cameras_list(): + """Return the available SPIM camera roles (A always; B if camera_b registered). + + No require_control — read-only status route, mirrors GET /api/devices/laser/configs. + """ + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + return await client.get_cameras() + except Exception as exc: + logger.exception("Cameras list fetch failed") + raise HTTPException(status_code=502, detail=f"cameras 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}.""" @@ -564,7 +603,7 @@ async def stage_move(payload: dict = Body(...)): # noqa: B008 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") + 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 @@ -635,6 +674,124 @@ async def acquire_volume(payload: dict = Body(...)): # noqa: B008 logger.exception("Volume acquisition failed") raise HTTPException(status_code=502, detail=f"volume failed: {exc}") from exc + # ------------------------------------------------------------------ + # Timelapse + # ------------------------------------------------------------------ + + @router.post("/api/devices/timelapse/start", dependencies=[Depends(require_control)]) + async def timelapse_start(payload: dict = Body(...)): # noqa: B008 + """Start an adaptive timelapse from the manual UI. + + Body fields (all optional except interval_seconds has a default): + interval_seconds (float, default 120) — cadence; must be > 0 + stop_condition (str, default "manual") — "manual", "timepoints", "duration" + embryo_ids (list[str] | null) — null = all active embryos + condition_value (int | null) — timepoints count or duration hours + monitoring_mode (str | null) — "idle" / "expression_monitoring" / + "pre_terminal_monitoring" + num_slices (int, default 50) — must be >= 1 if provided + exposure_ms (float, default 10.0) + galvo_amplitude (float, default 0.5) + galvo_center (float, default 0.0) + piezo_amplitude (float, default 25.0) + piezo_center (float, default 50.0) + laser_config (str | null) + + Validation: + - interval_seconds must be > 0 + - num_slices must be >= 1 + + Orchestrator access: server.agent_bridge.agent.timelapse_orchestrator + RIG-DEFERRED: the actual acquisition + galvo/piezo motion. + """ + # --- Validate --- + raw_interval = payload.get("interval_seconds", 120.0) + try: + interval_seconds = float(raw_interval) + except (TypeError, ValueError): + raise HTTPException( # B904 + status_code=400, detail="interval_seconds must be a number" + ) from None + if interval_seconds <= 0: + raise HTTPException(status_code=400, detail="interval_seconds must be > 0") + + raw_slices = payload.get("num_slices") + if raw_slices is not None: + try: + num_slices = int(raw_slices) + except (TypeError, ValueError): + raise HTTPException( # B904 + status_code=400, detail="num_slices must be an integer" + ) from None + if num_slices < 1: + raise HTTPException(status_code=400, detail="num_slices must be >= 1") + else: + num_slices = 50 + + stop_condition = str(payload.get("stop_condition") or "manual") + embryo_ids = payload.get("embryo_ids") or None + condition_value = payload.get("condition_value") + monitoring_mode = payload.get("monitoring_mode") or None + + # Volume geometry — passed through for context / future calibration write; + # not forwarded to orchestrator.start (which owns its own geometry via the + # per-embryo calibration). RIG-DEFERRED: real acquisition uses these. + volume_geometry = { + "num_slices": num_slices, + "exposure_ms": float(payload.get("exposure_ms", 10.0)), + "galvo_amplitude": float(payload.get("galvo_amplitude", 0.5)), + "galvo_center": float(payload.get("galvo_center", 0.0)), + "piezo_amplitude": float(payload.get("piezo_amplitude", 25.0)), + "piezo_center": float(payload.get("piezo_center", 50.0)), + "laser_config": payload.get("laser_config") or None, + } + + # --- Resolve orchestrator --- + bridge = getattr(server, "agent_bridge", None) + agent = bridge.agent if bridge is not None else None + orchestrator = getattr(agent, "timelapse_orchestrator", None) if agent else None + if orchestrator is None: + raise HTTPException( + status_code=503, + detail="Timelapse orchestrator not initialised (agent not running or no session)", + ) + + # --- Start timelapse (RIG-DEFERRED: real acquisition) --- + # TODO: UI-initiated timelapses skip the agent tool's plan auto-linking; + # this is intentional — the agent path wires the plan, this route does not. + try: + result = await orchestrator.start( + embryo_ids=embryo_ids, + stop_condition=stop_condition, + base_interval_seconds=interval_seconds, + condition_value=condition_value, + ) + except Exception as exc: + logger.exception("Timelapse start failed") + raise HTTPException(status_code=502, detail=f"timelapse start failed: {exc}") from exc + + # Optionally install a monitoring mode at startup (mirrors start_adaptive_timelapse) + mode_result = None + if monitoring_mode and monitoring_mode != "idle": + try: + mode_result = orchestrator.enable_monitoring_mode(monitoring_mode) + except Exception as exc: + mode_result = f"warning: failed to enable monitoring mode: {exc}" + + return { + "started": True, + "result": result, + "monitoring_mode_result": mode_result, + "config": { + "interval_seconds": interval_seconds, + "stop_condition": stop_condition, + "embryo_ids": embryo_ids, + "condition_value": condition_value, + "monitoring_mode": monitoring_mode, + "volume_geometry": volume_geometry, + }, + } + @router.get("/api/calibration") async def list_calibration(embryo_id: str | None = None): """Get calibration images""" diff --git a/gently/ui/web/routes/operation_plan.py b/gently/ui/web/routes/operation_plan.py index 9e0d8c82..a5e636eb 100644 --- a/gently/ui/web/routes/operation_plan.py +++ b/gently/ui/web/routes/operation_plan.py @@ -16,7 +16,9 @@ def _resolve_session(session_id: str) -> str: if session_id == "current": store = getattr(server, "gently_store", None) if store is None: - raise HTTPException(status_code=503, detail="FileStore not configured on viz server") + raise HTTPException( + status_code=503, detail="FileStore not configured on viz server" + ) sessions = store.list_sessions() if not sessions: raise HTTPException(status_code=404, detail="No sessions in store") 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 4c04716e..d7b51a37 100644 --- a/gently/ui/web/static/css/main.css +++ b/gently/ui/web/static/css/main.css @@ -10725,6 +10725,46 @@ body.modal-open { flex-shrink: 0; } +.ls-laser-dot--on { + background: var(--map-accent); +} + +/* Laser preset row (B2) */ +.ls-laser-row { + display: flex; + align-items: center; + gap: 0.4rem; + margin-top: 0.2rem; +} + +.ls-laser-label { + font-size: 0.6rem; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--map-ink-mute); + flex-shrink: 0; +} + +.ls-laser-select { + flex: 1; + min-width: 0; + background: var(--map-paper-2); + border: 1px solid var(--map-overlay-edge); + color: var(--map-ink); + font-family: inherit; + font-size: 0.62rem; + border-radius: 4px; + padding: 0.18rem 0.3rem; + cursor: pointer; + transition: border-color 0.12s; +} + +.ls-laser-select:focus { + outline: none; + border-color: var(--map-accent); +} + /* Temperature setpoint button */ .ls-set-btn { background: transparent; @@ -10807,3 +10847,477 @@ body.modal-open { color: var(--map-ink); word-break: break-all; } + +/* ── Timelapse collapsible panel (B2 Task 3) ─────────────────────────────── */ + +/* Clickable header button (also carries .ls-label for font/color) */ +.ls-collapsible-head { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + background: transparent; + border: none; + padding: 0; + cursor: pointer; + text-align: left; +} + +/* ▶/▼ caret — rotates when expanded */ +.ls-collapsible-arrow { + font-size: 0.5rem; + color: var(--map-ink-mute); + flex-shrink: 0; + transition: transform 0.15s; +} + +.ls-collapsible-head[aria-expanded="true"] .ls-collapsible-arrow { + transform: rotate(90deg); +} + +/* Collapsible body — hidden via [hidden] attribute; explicit rule prevents + display overrides from fighting the attribute (mirrors .ls-lastcap[hidden]) */ +.ls-collapsible-body { + display: flex; + flex-direction: column; + gap: 0.32rem; + margin-top: 0.35rem; +} + +.ls-collapsible-body[hidden] { display: none; } + +/* Sub-rows: like .ls-row but slightly indented for the body interior */ +.ls-sub-row { + display: flex; + align-items: center; + gap: 0.4rem; + padding-left: 0.25rem; +} + +/* Sub-label: like .ls-label but fixed-width to align inputs column */ +.ls-sub-label { + font-size: 0.58rem; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--map-ink-mute); + min-width: 4.5rem; + flex-shrink: 0; +} + +/* Volume-Geometry inset sub-group */ +.ls-sub-section { + display: flex; + flex-direction: column; + gap: 0.28rem; + padding: 0.4rem 0.5rem; + background: var(--map-paper-2); + border: 1px solid var(--map-overlay-edge); + border-radius: 4px; + margin: 0.15rem 0; +} + +/* "VOLUME GEOMETRY" sub-header */ +.ls-sub-section-label { + font-size: 0.52rem; + font-weight: 600; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--map-ink-mute); + margin-bottom: 0.1rem; +} + +/* Start-result / status line — mono, muted */ +.ls-tl-status { + font-family: 'JetBrains Mono', monospace; + font-size: 0.62rem; + color: var(--map-ink-mute); + padding: 0.2rem 0.25rem; + word-break: break-all; +} + +.ls-tl-status[hidden] { display: none; } + +/* ── Timelapse accordion (active/inactive section states — B3 UX) ─────────── */ + +/* Wrapper for each accordion section */ +.ls-acc-section { + display: flex; + flex-direction: column; + border-bottom: 1px solid var(--map-rule-soft); + padding-bottom: 0.12rem; + margin-bottom: 0.08rem; +} +.ls-acc-section:last-of-type { + border-bottom: none; + margin-bottom: 0; +} + +/* Section header button */ +.ls-acc-head { + display: flex; + align-items: center; + gap: 0.32rem; + background: transparent; + border: none; + padding: 0.26rem 0; + cursor: pointer; + width: 100%; + text-align: left; + font-family: inherit; +} + +/* Status dot — hollow/muted when inactive */ +.ls-acc-dot { + width: 5px; + height: 5px; + border-radius: 50%; + border: 1px solid var(--map-ink-mute); + background: transparent; + flex-shrink: 0; + transition: background 0.15s, border-color 0.15s; +} + +/* Section label */ +.ls-acc-title { + font-size: 0.575rem; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--map-ink-mute); + flex-shrink: 0; + transition: color 0.15s; +} + +/* One-line summary — shown only when section is touched */ +.ls-acc-summary { + flex: 1; + font-size: 0.57rem; + font-family: 'JetBrains Mono', monospace; + letter-spacing: 0.02em; + color: var(--map-ink-mute); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} +.ls-acc-summary[hidden] { display: none; } + +/* Caret arrow */ +.ls-acc-arrow { + font-size: 0.44rem; + color: var(--map-ink-mute); + flex-shrink: 0; + margin-left: auto; + transition: transform 0.15s, color 0.15s; +} +.ls-acc-head[aria-expanded="true"] .ls-acc-arrow { + transform: rotate(90deg); +} + +/* ── Active / touched state ────────────────────────────────────────────────── */ +.ls-acc-head.is-active .ls-acc-dot { + background: var(--map-accent); + border-color: var(--map-accent); +} +.ls-acc-head.is-active .ls-acc-title { + color: var(--map-accent); +} +.ls-acc-head.is-active .ls-acc-summary { + color: var(--map-accent); + opacity: 0.85; +} +.ls-acc-head.is-active .ls-acc-arrow { + color: var(--map-accent); +} + +/* Section body */ +.ls-acc-body { + display: flex; + flex-direction: column; + gap: 0.26rem; + padding: 0.18rem 0 0.32rem 0.5rem; +} +.ls-acc-body[hidden] { display: none; } + +/* Outer timelapse panel header dot — signals any section is set */ +.ls-tl-outer-dot { + width: 5px; + height: 5px; + border-radius: 50%; + border: 1px solid transparent; + background: transparent; + flex-shrink: 0; + transition: background 0.15s, border-color 0.15s; +} +.ls-tl-outer-dot.is-active { + background: var(--map-accent); + border-color: var(--map-accent); +} + +/* Start button reads "ready" (accent) once any section is configured */ +.ls-tl-start-btn { + transition: border-color 0.15s, color 0.15s, background 0.15s; +} +.ls-tl-start-btn.is-ready { + border-color: var(--map-accent); + color: var(--map-accent); + background: rgba(34, 211, 238, 0.08); +} + +/* Submit row — give the button its full row width */ +.ls-tl-submit-row { + margin-top: 0.3rem; +} + +/* ========================================== + Gallery Tab + ========================================== */ + +.gallery-tab-container { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + padding: 1.25rem 1.5rem 0; + box-sizing: border-box; +} + +.gallery-tab-header { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; + flex-shrink: 0; +} + +.gallery-tab-title { + font-size: 1.35rem; + font-weight: 300; + letter-spacing: -0.01em; + color: var(--text); + margin: 0; + line-height: 1.2; + flex-shrink: 0; +} + +.gallery-tab-title-script { + font-family: 'Georgia', serif; + font-style: italic; + color: var(--text-muted); +} + +.gallery-tab-title-em { + font-style: normal; + font-weight: 500; + color: var(--accent); +} + +.gallery-tab-controls { + display: flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; +} + +.gallery-filter-select { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + font-size: 0.8rem; + padding: 0.3rem 0.6rem; + cursor: pointer; + transition: border-color 0.15s; +} + +.gallery-filter-select:hover, +.gallery-filter-select:focus { + border-color: var(--accent); + outline: none; +} + +.gallery-refresh-btn { + background: transparent; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-muted); + font-size: 1rem; + width: 2rem; + height: 2rem; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: color 0.15s, border-color 0.15s; +} + +.gallery-refresh-btn:hover { + color: var(--accent); + border-color: var(--accent); +} + +.gallery-tab-body { + flex: 1; + overflow-y: auto; + padding-bottom: 1.5rem; +} + +.gallery-tab-empty, +.gallery-tab-loading, +.gallery-tab-error { + display: flex; + align-items: center; + justify-content: center; + height: 200px; + color: var(--text-muted); + font-size: 0.9rem; +} + +.gallery-tab-error { + color: var(--accent-red, #ef4444); +} + +.gallery-tab-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 0.75rem; +} + +.gallery-tab-item { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + cursor: pointer; + transition: border-color 0.15s, transform 0.15s, box-shadow 0.15s; +} + +.gallery-tab-item:hover { + border-color: var(--accent); + transform: translateY(-2px); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); +} + +.gallery-tab-thumb { + display: block; + width: 100%; + aspect-ratio: 1; + object-fit: contain; + background: var(--bg-dark); +} + +.gallery-tab-thumb-placeholder { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + aspect-ratio: 1; + background: var(--bg-dark); + color: var(--text-muted); + font-size: 0.7rem; + text-align: center; + padding: 0.5rem; + box-sizing: border-box; +} + +.gallery-tab-item-info { + padding: 0.4rem 0.5rem 0.45rem; + border-top: 1px solid var(--border); +} + +.gallery-tab-item-type { + font-size: 0.72rem; + font-weight: 600; + color: var(--accent); + text-transform: lowercase; + letter-spacing: 0.02em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.gallery-tab-item-embryo { + font-size: 0.68rem; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 0.1rem; +} + +.gallery-tab-item-ts { + font-size: 0.62rem; + color: var(--text-muted); + opacity: 0.65; + margin-top: 0.1rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ========================================== + Gently Toast — volume acquired / burst acquired + ========================================== */ + +.gently-toast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%) translateY(20px); + background: var(--bg-card); + border: 1px solid var(--accent-green); + border-radius: 10px; + padding: 0.6rem 1rem 0.6rem 1.1rem; + display: flex; + align-items: center; + gap: 0.65rem; + z-index: 10010; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.35); + opacity: 0; + transition: opacity 0.25s ease, transform 0.25s ease; + pointer-events: auto; +} + +.gently-toast.visible { + opacity: 1; + transform: translateX(-50%) translateY(0); +} + +.gently-toast-msg { + font-size: 0.85rem; + font-weight: 500; + color: var(--text); + white-space: nowrap; +} + +.gently-toast-action { + background: transparent; + border: 1px solid var(--accent); + border-radius: 5px; + color: var(--accent); + font-size: 0.78rem; + padding: 0.2rem 0.55rem; + cursor: pointer; + transition: background 0.15s, color 0.15s; + white-space: nowrap; +} + +.gently-toast-action:hover { + background: var(--accent); + color: var(--bg-dark); +} + +.gently-toast-dismiss { + background: transparent; + border: none; + color: var(--text-muted); + font-size: 1.1rem; + line-height: 1; + cursor: pointer; + padding: 0 0.1rem; + opacity: 0.7; + transition: opacity 0.15s; + margin-left: 0.2rem; +} +} diff --git a/gently/ui/web/static/js/app.js b/gently/ui/web/static/js/app.js index 41bad885..6f51da38 100644 --- a/gently/ui/web/static/js/app.js +++ b/gently/ui/web/static/js/app.js @@ -105,6 +105,11 @@ function switchTab(tabName) { NotebookApp.init(); } + // Lazy-init Gallery tab + if (tabName === TABS.GALLERY && typeof GalleryTab !== 'undefined') { + GalleryTab.init(); + } + // Update statusbar for context updateStatusbar(); } @@ -656,7 +661,7 @@ document.addEventListener('DOMContentLoaded', () => { const hash = window.location.hash.slice(1); // remove # if (hash) { const [tab, param] = hash.split(':'); - if (tab === TABS.HOME || tab === TABS.PLANS || tab === TABS.SESSIONS || tab === TABS.EMBRYOS || tab === TABS.CALIBRATION || tab === TABS.EVENTS || tab === TABS.EXPERIMENT || tab === TABS.NOTEBOOK) { + if (tab === TABS.HOME || tab === TABS.PLANS || tab === TABS.SESSIONS || tab === TABS.EMBRYOS || tab === TABS.CALIBRATION || tab === TABS.EVENTS || tab === TABS.EXPERIMENT || tab === TABS.NOTEBOOK || tab === TABS.GALLERY) { switchTab(tab); if (tab === TABS.PLANS && param && typeof openCampaign === 'function') { setTimeout(() => openCampaign(param), 200); diff --git a/gently/ui/web/static/js/devices.js b/gently/ui/web/static/js/devices.js index 2de449e4..8c1c8733 100644 --- a/gently/ui/web/static/js/devices.js +++ b/gently/ui/web/static/js/devices.js @@ -91,16 +91,31 @@ const DevicesManager = (function () { let _lsGalvo = 0; let _lsPiezo = 50; let _lsExposure = 20; // matches device-layer _ls_params default (20 ms) + let _lsSide = 'A'; // SPIM side — 'A' (HamCam1) or 'B' (HamCam2 if present) let _lsParamTimer = null; // Lightsheet control inputs (rail) let _lsGalvoSlider, _lsGalvoNum, _lsPiezoSlider, _lsPiezoNum, _lsExposureNum; - let _lsLedOpen, _lsLedClosed, _lsCamLed, _lsRoomLightBtn; + let _lsLedToggle, _lsCamLed, _lsRoomLightBtn; + let _lsLedIsOpen = false; // LED toggle state: false = Closed (safe default) let _lsCamLedOn = false; + let _lsLaserToggle; + let _lsLaserOn = false; // Laser toggle state: false = OFF (entry-safe default) let _lsSnapVolBtn, _lsBurstBtn, _lsLastcap, _lsLastcapRef; - let _lsLaserStatus; // span inside .ls-laser-indicator — driven by actual laser/off calls + let _lsLaserStatus; // span inside .ls-laser-indicator — driven by actual laser/off calls + let _lsLaserPreset; // — shown only when camera_b present let _lsTempInput, _lsTempSet; + // Timelapse form DOM refs (Manual view — #devices-tl-group) + let _tlToggle, _tlBody; + let _tlInterval, _tlStop, _tlCondRow, _tlCondLabel, _tlCondVal; + let _tlEmbryos, _tlMode; + let _tlSlices, _tlExposure, _tlGalvoAmp, _tlGalvoCtr, _tlPiezoAmp, _tlPiezoCtr, _tlLaser; + let _tlStart, _tlStatus, _tlStatusText; + // Accordion active-state per section: { sched, targets, geom } + let _tlTouched = { sched: false, targets: false, geom: false }; + // 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. @@ -192,18 +207,41 @@ const DevicesManager = (function () { _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'); + _lsLedToggle = document.getElementById('devices-ls-led-toggle'); _lsCamLed = document.getElementById('devices-ls-cam-led'); _lsRoomLightBtn = document.getElementById('devices-ls-room-light-btn'); + _lsLaserToggle = document.getElementById('devices-ls-laser-toggle'); _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'); + _lsLaserPreset = document.getElementById('devices-laser-preset'); + _lsSideSelect = document.getElementById('devices-ls-side'); _lsTempInput = document.getElementById('devices-ls-temp-input'); _lsTempSet = document.getElementById('devices-ls-temp-set'); + // Timelapse form + _tlToggle = document.getElementById('devices-tl-toggle'); + _tlBody = document.getElementById('devices-tl-body'); + _tlInterval = document.getElementById('devices-tl-interval'); + _tlStop = document.getElementById('devices-tl-stop'); + _tlCondRow = document.getElementById('devices-tl-cond-row'); + _tlCondLabel = document.getElementById('devices-tl-cond-label'); + _tlCondVal = document.getElementById('devices-tl-cond-val'); + _tlEmbryos = document.getElementById('devices-tl-embryos'); + _tlMode = document.getElementById('devices-tl-mode'); + _tlSlices = document.getElementById('devices-tl-slices'); + _tlExposure = document.getElementById('devices-tl-exposure'); + _tlGalvoAmp = document.getElementById('devices-tl-galvo-amp'); + _tlGalvoCtr = document.getElementById('devices-tl-galvo-ctr'); + _tlPiezoAmp = document.getElementById('devices-tl-piezo-amp'); + _tlPiezoCtr = document.getElementById('devices-tl-piezo-ctr'); + _tlLaser = document.getElementById('devices-tl-laser'); + _tlStart = document.getElementById('devices-tl-start'); + _tlStatus = document.getElementById('devices-tl-status'); + _tlStatusText = document.getElementById('devices-tl-status-text'); + _roomLightToggle = document.getElementById('devices-room-light-toggle'); _roomLightLabel = document.getElementById('devices-room-light-label'); @@ -1359,12 +1397,320 @@ const DevicesManager = (function () { if (_lsLaserStatus) { _lsLaserStatus.textContent = res.ok ? 'OFF (brightfield)' : 'warning: state unknown'; } + if (res.ok) _setLaserToggleState(false); } catch (err) { if (_lsLaserStatus) _lsLaserStatus.textContent = 'warning: state unknown'; console.debug('laser off call failed:', err); } } + /** Fetch laser config-group presets and populate the #devices-laser-preset select. + * Selects "ALL OFF" by default (entry safety preset). + * Wires the change handler to POST the selected preset. + * Fire-and-forget safe — failure leaves the fallback "ALL OFF" option in place. */ + async function populateLaserPresets() { + cacheDom(); + if (!_lsLaserPreset) return; + try { + const res = await fetch('/api/devices/laser/configs'); + if (!res.ok) return; + const data = await res.json(); + // data may be an array of preset names or {configs: [...]} + const presets = Array.isArray(data) ? data : (data.configs || []); + if (!presets.length) return; + // Rebuild option list + _lsLaserPreset.innerHTML = ''; + for (const name of presets) { + const opt = document.createElement('option'); + opt.value = name; + opt.textContent = name; + _lsLaserPreset.appendChild(opt); + } + // Default to "ALL OFF" — entry safety state + if (presets.includes('ALL OFF')) _lsLaserPreset.value = 'ALL OFF'; + // Wire change handler — only POST if laser is currently ON; if OFF, it's + // just a selection that will be activated when the toggle is pressed. + _lsLaserPreset.onchange = () => { if (_lsLaserOn) setLaserPreset(_lsLaserPreset.value); }; + } catch (err) { + console.debug('laser preset fetch failed:', err); + } + } + + /** Fetch SPIM camera roles and show the Side A/B selector if camera_b is present. + * Called on manual-view entry. Hides the selector on single-camera rigs. + * Fire-and-forget safe — failure leaves the selector hidden (safe default). */ + async function populateCameraRoles() { + cacheDom(); + const group = document.getElementById('devices-ls-side-group'); + try { + const res = await fetch('/api/devices/cameras'); + if (!res.ok) return; + const data = await res.json(); + // data may be {cameras: [...]} or a raw array + const cameras = Array.isArray(data) ? data : (data.cameras || []); + const hasSideB = cameras.includes('B'); + if (group) group.style.display = hasSideB ? '' : 'none'; + if (_lsSideSelect && hasSideB) { + _lsSideSelect.onchange = () => { + _lsSide = _lsSideSelect.value; + postLightsheetParams(); + }; + } + } catch (err) { + console.debug('camera roles fetch failed:', err); + } + } + + /** POST a named laser preset to the device layer. + * Updates the status indicator on success. + * Fire-and-forget safe — failure shows a warning, never throws. */ + async function setLaserPreset(config) { + cacheDom(); + if (!config) return; + try { + const res = await fetch('/api/devices/laser/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config }), + }); + if (_lsLaserStatus) { + _lsLaserStatus.textContent = res.ok ? config : 'warning: state unknown'; + } + if (res.ok) _setLaserToggleState(config !== 'ALL OFF'); + if (!res.ok) console.debug('laser preset set failed:', await res.text()); + } catch (err) { + if (_lsLaserStatus) _lsLaserStatus.textContent = 'warning: state unknown'; + console.debug('laser preset set failed:', err); + } + } + + // ===================================================================== + // Timelapse config form (Manual view) + // ===================================================================== + + // ── Accordion section summary builders ─────────────────────────────────── + + function _tlSchedSummary() { + const interval = (_tlInterval && _tlInterval.value) ? _tlInterval.value : '120'; + const stop = (_tlStop && _tlStop.value) ? _tlStop.value : 'manual'; + const condVal = (_tlCondVal && _tlCondVal.value) ? _tlCondVal.value : '10'; + if (stop === 'timepoints') return `${interval} s · ${condVal} frames`; + if (stop === 'duration') return `${interval} s · ${condVal} h`; + return `${interval} s · manual`; + } + + function _tlTargetsSummary() { + const embryos = (_tlEmbryos && _tlEmbryos.value.trim()) + ? _tlEmbryos.value.trim() + : 'all'; + const modeEl = _tlMode; + const modeText = (modeEl && modeEl.value) + ? modeEl.options[modeEl.selectedIndex].text + : 'none'; + return `${embryos} · ${modeText}`; + } + + function _tlGeomSummary() { + const slices = (_tlSlices && _tlSlices.value) ? _tlSlices.value : '50'; + const exposure = (_tlExposure && _tlExposure.value) ? _tlExposure.value : '10'; + const laser = (_tlLaser && _tlLaser.value) ? _tlLaser.value : 'ALL OFF'; + return `${slices} sl · ${exposure} ms · ${laser}`; + } + + /** Update a section's header active state and summary text, then sync the + * outer panel dot and the start button. sec = 'sched'|'targets'|'geom'. */ + function _tlUpdateSection(sec) { + const head = document.getElementById(`devices-tlacc-${sec}-head`); + const summary = document.getElementById(`devices-tlacc-${sec}-sum`); + const touched = _tlTouched[sec]; + + if (head) head.classList.toggle('is-active', touched); + if (summary) { + summary.hidden = !touched; + if (touched) { + if (sec === 'sched') summary.textContent = _tlSchedSummary(); + else if (sec === 'targets') summary.textContent = _tlTargetsSummary(); + else if (sec === 'geom') summary.textContent = _tlGeomSummary(); + } + } + + // Outer panel dot + start button "ready" state + const anyActive = Object.values(_tlTouched).some(Boolean); + const outerDot = document.getElementById('devices-tl-outer-dot'); + if (outerDot) outerDot.classList.toggle('is-active', anyActive); + if (_tlStart) _tlStart.classList.toggle('is-ready', anyActive); + } + + /** Wire the timelapse panel: outer collapsible toggle, accordion section + * toggles, touch listeners, and the submit button. + * Safe to call multiple times (re-assigns handlers idempotently). */ + function initTlForm() { + cacheDom(); + + // Reset touched state on each init (re-entering the manual view = fresh) + _tlTouched = { sched: false, targets: false, geom: false }; + // Clear any leftover active-state visuals from a previous visit + ['sched', 'targets', 'geom'].forEach(sec => _tlUpdateSection(sec)); + + // ── Outer collapsible toggle ────────────────────────────────────────── + if (_tlToggle && _tlBody) { + _tlToggle.onclick = () => { + const open = _tlBody.hidden; + _tlBody.hidden = !open; + _tlToggle.setAttribute('aria-expanded', String(open)); + const arrow = _tlToggle.querySelector('.ls-collapsible-arrow'); + if (arrow) arrow.textContent = open ? '▼' : '▶'; + }; + } + + // ── Accordion section toggles ───────────────────────────────────────── + ['sched', 'targets', 'geom'].forEach(sec => { + const head = document.getElementById(`devices-tlacc-${sec}-head`); + const body = document.getElementById(`devices-tlacc-${sec}-body`); + if (!head || !body) return; + head.onclick = () => { + const open = body.hidden; + body.hidden = !open; + head.setAttribute('aria-expanded', String(open)); + const arrow = head.querySelector('.ls-acc-arrow'); + if (arrow) arrow.textContent = open ? '▼' : '▶'; + }; + }); + + // ── Touch listeners ─────────────────────────────────────────────────── + const markTouched = sec => { + _tlTouched[sec] = true; + _tlUpdateSection(sec); + }; + + // Schedule — interval and stop condition drive summary; cond-row visibility unchanged + [_tlInterval, _tlCondVal].forEach(el => { + if (el) el.addEventListener('input', () => markTouched('sched')); + }); + if (_tlStop) { + _tlStop.addEventListener('change', () => { + const v = _tlStop.value; + const show = v === 'timepoints' || v === 'duration'; + if (_tlCondRow) _tlCondRow.hidden = !show; + if (_tlCondLabel) _tlCondLabel.textContent = v === 'duration' ? 'Hours' : 'Count'; + markTouched('sched'); + }); + } + + // Targets + if (_tlEmbryos) _tlEmbryos.addEventListener('input', () => markTouched('targets')); + if (_tlMode) _tlMode.addEventListener('change', () => markTouched('targets')); + + // Volume geometry + [_tlSlices, _tlExposure, _tlGalvoAmp, _tlGalvoCtr, _tlPiezoAmp, _tlPiezoCtr].forEach(el => { + if (el) el.addEventListener('input', () => markTouched('geom')); + }); + if (_tlLaser) _tlLaser.addEventListener('change', () => markTouched('geom')); + + // ── Submit ──────────────────────────────────────────────────────────── + if (_tlStart) _tlStart.onclick = startTimelapse; + } + + /** Populate timelapse volume-geometry defaults from GET /api/devices/scan_geometry, + * and populate the laser preset select from GET /api/devices/laser/configs. + * Fire-and-forget safe — failure leaves form-coded defaults in place. */ + async function populateTlDefaults() { + cacheDom(); + // Geometry defaults + try { + const res = await fetch('/api/devices/scan_geometry'); + if (res.ok) { + const data = await res.json(); + const scan = data.scan || {}; + if (_tlSlices && scan.num_slices != null) _tlSlices.value = scan.num_slices; + if (_tlExposure && scan.exposure_ms != null) _tlExposure.value = scan.exposure_ms; + if (_tlGalvoAmp && scan.galvo_amplitude_deg != null) _tlGalvoAmp.value = scan.galvo_amplitude_deg; + if (_tlGalvoCtr && scan.galvo_center_deg != null) _tlGalvoCtr.value = scan.galvo_center_deg; + if (_tlPiezoAmp && scan.piezo_amplitude_um != null) _tlPiezoAmp.value = scan.piezo_amplitude_um; + if (_tlPiezoCtr && scan.piezo_center_um != null) _tlPiezoCtr.value = scan.piezo_center_um; + } + } catch (err) { + console.debug('tl scan_geometry fetch failed:', err); + } + // Laser presets — reuse the shared endpoint; mirror populateLaserPresets() + if (!_tlLaser) return; + try { + const res = await fetch('/api/devices/laser/configs'); + if (!res.ok) return; + const data = await res.json(); + const presets = Array.isArray(data) ? data : (data.configs || []); + if (!presets.length) return; + _tlLaser.innerHTML = ''; + for (const name of presets) { + const opt = document.createElement('option'); + opt.value = name; + opt.textContent = name; + _tlLaser.appendChild(opt); + } + if (presets.includes('ALL OFF')) _tlLaser.value = 'ALL OFF'; + } catch (err) { + console.debug('tl laser configs fetch failed:', err); + } + } + + /** Gather form values, POST to /api/devices/timelapse/start, show result. */ + async function startTimelapse() { + cacheDom(); + if (!_tlStart) return; + _tlStart.disabled = true; + + // Build payload + const interval = parseFloat(_tlInterval ? _tlInterval.value : '120') || 120; + const stop_condition = _tlStop ? _tlStop.value : 'manual'; + const condRaw = _tlCondVal ? _tlCondVal.value : ''; + const condition_value = condRaw ? parseInt(condRaw, 10) : null; + const embryoRaw = _tlEmbryos ? _tlEmbryos.value.trim() : ''; + const embryo_ids = embryoRaw + ? embryoRaw.split(',').map(s => s.trim()).filter(Boolean) + : null; + const monitoring_mode = _tlMode ? (_tlMode.value || null) : null; + + const payload = { + interval_seconds: interval, + stop_condition, + embryo_ids, + condition_value, + monitoring_mode, + num_slices: _tlSlices ? parseInt(_tlSlices.value, 10) : 50, + exposure_ms: _tlExposure ? parseFloat(_tlExposure.value) : 10.0, + galvo_amplitude: _tlGalvoAmp ? parseFloat(_tlGalvoAmp.value) : 0.5, + galvo_center: _tlGalvoCtr ? parseFloat(_tlGalvoCtr.value) : 0.0, + piezo_amplitude: _tlPiezoAmp ? parseFloat(_tlPiezoAmp.value) : 25.0, + piezo_center: _tlPiezoCtr ? parseFloat(_tlPiezoCtr.value) : 50.0, + laser_config: _tlLaser ? (_tlLaser.value || null) : null, + }; + + if (_tlStatus) _tlStatus.hidden = false; + if (_tlStatusText) _tlStatusText.textContent = 'Starting…'; + + try { + const res = await fetch('/api/devices/timelapse/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const body = await res.json().catch(() => ({})); + if (res.ok) { + const msg = body.result || 'Timelapse started.'; + if (_tlStatusText) _tlStatusText.textContent = msg; + } else { + const detail = body.detail || `error ${res.status}`; + if (_tlStatusText) _tlStatusText.textContent = `Error: ${detail}`; + console.debug('timelapse start failed:', body); + } + } catch (err) { + if (_tlStatusText) _tlStatusText.textContent = `Network error: ${err.message}`; + console.debug('timelapse start failed:', err); + } finally { + if (_tlStart) _tlStart.disabled = false; + } + } + async function toggleLightsheetStream() { if (!_lsToggle) return; _lsToggle.disabled = true; @@ -1552,7 +1898,7 @@ const DevicesManager = (function () { fetch('/api/devices/lightsheet/live/params', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ galvo: _lsGalvo, piezo: _lsPiezo, exposure: _lsExposure }), + body: JSON.stringify({ galvo: _lsGalvo, piezo: _lsPiezo, exposure: _lsExposure, side: _lsSide }), }).catch(err => console.debug('lightsheet params post failed:', err)); } @@ -1598,6 +1944,54 @@ const DevicesManager = (function () { } catch (err) { console.debug('LED preset failed:', err); } } + /** Single LED toggle — mirrors Cam LED / Room Light aria-pressed pattern. + * Flips between Open (active) and Closed (inactive/safe default). */ + async function toggleLedPreset() { + _lsLedIsOpen = !_lsLedIsOpen; + if (_lsLedToggle) { + _lsLedToggle.classList.toggle('ls-illum-btn--active', _lsLedIsOpen); + _lsLedToggle.setAttribute('aria-pressed', _lsLedIsOpen ? 'true' : 'false'); + _lsLedToggle.textContent = _lsLedIsOpen ? 'LED: Open' : 'LED: Closed'; + } + await postLedPreset(_lsLedIsOpen ? 'Open' : 'Closed'); + } + + /** Update laser toggle button + dot to reflect on/off state. + * Called by setLaserOff() and setLaserPreset() after a successful API call. */ + function _setLaserToggleState(on) { + _lsLaserOn = on; + if (_lsLaserToggle) { + _lsLaserToggle.classList.toggle('ls-illum-btn--active', on); + _lsLaserToggle.setAttribute('aria-pressed', on ? 'true' : 'false'); + _lsLaserToggle.textContent = on ? 'Laser: ON' : 'Laser: OFF'; + } + const dot = document.querySelector('.ls-laser-dot'); + if (dot) dot.classList.toggle('ls-laser-dot--on', on); + } + + /** Laser on/off toggle — OFF fires laser/off; ON applies the selected preset. + * If selected preset is "ALL OFF", picks the first non-"ALL OFF" option. + * Entry safety: starts OFF (setLaserOff fires on manual-view entry). */ + async function toggleLaser() { + if (_lsLaserOn) { + await setLaserOff(); + } else { + let config = _lsLaserPreset ? _lsLaserPreset.value : null; + if (!config || config === 'ALL OFF') { + const opts = _lsLaserPreset ? Array.from(_lsLaserPreset.options) : []; + const first = opts.find(o => o.value !== 'ALL OFF'); + if (first) { + config = first.value; + _lsLaserPreset.value = config; + } else { + if (_lsLaserStatus) _lsLaserStatus.textContent = 'select a laser line first'; + return; + } + } + await setLaserPreset(config); + } + } + async function toggleCamLedMode() { _lsCamLedOn = !_lsCamLedOn; if (_lsCamLed) { @@ -1672,6 +2066,15 @@ const DevicesManager = (function () { if (_lsLastcapRef) { _lsLastcapRef.textContent = data.volume_path || data.path || data.id || 'done'; } + // Show confirmation toast — no inline preview to keep manual mode uncluttered + if (typeof showGentlyToast === 'function') { + const label = mode === 'burst' ? 'Burst acquired' : 'Volume acquired'; + showGentlyToast(label, 'View in Gallery', () => { + if (typeof switchTab === 'function' && typeof TABS !== 'undefined') { + switchTab(TABS.GALLERY); + } + }); + } } catch (err) { console.error('acquire error:', err); } finally { @@ -1709,10 +2112,10 @@ const DevicesManager = (function () { 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 (_lsLedToggle) _lsLedToggle.addEventListener('click', toggleLedPreset); + if (_lsCamLed) _lsCamLed.addEventListener('click', toggleCamLedMode); if (_lsRoomLightBtn) _lsRoomLightBtn.addEventListener('click', toggleManualRoomLight); + if (_lsLaserToggle) _lsLaserToggle.addEventListener('click', toggleLaser); // Acquire if (_lsSnapVolBtn) _lsSnapVolBtn.addEventListener('click', () => runLightsheetAcquire('volume')); @@ -1972,7 +2375,9 @@ const DevicesManager = (function () { } } // Entering Manual view — gate lasers off immediately (brightfield-safe). - if (viewName === 'manual') setLaserOff(); + // populateLaserPresets() runs after setLaserOff() so the select is always + // seeded with the entry-safety state first. + if (viewName === 'manual') { setLaserOff(); populateLaserPresets(); populateCameraRoles(); initTlForm(); populateTlDefaults(); } } function setupViewSwitcher() { diff --git a/gently/ui/web/static/js/gallery.js b/gently/ui/web/static/js/gallery.js index 42159d18..259e6636 100644 --- a/gently/ui/web/static/js/gallery.js +++ b/gently/ui/web/static/js/gallery.js @@ -1631,3 +1631,173 @@ function filterByEmbryo(list) { if (!state.embryoFilter) return list; return list.filter(img => img.metadata?.embryo_id === state.embryoFilter); } + +// ========================================== +// GalleryTab — top-level Gallery tab controller +// ========================================== + +const GalleryTab = { + _allItems: [], + _embryoFilter: '', + _typeFilter: '', + + async init() { + const panel = document.getElementById('gallery-tab-body'); + if (!panel) return; + + // Wire filter controls (idempotent) + const embryoSel = document.getElementById('gallery-embryo-filter'); + const typeSel = document.getElementById('gallery-type-filter'); + const refreshBtn = document.getElementById('gallery-refresh-btn'); + if (embryoSel && !embryoSel._gtWired) { + embryoSel._gtWired = true; + embryoSel.addEventListener('change', () => { + this._embryoFilter = embryoSel.value; + this._renderGrid(); + }); + } + if (typeSel && !typeSel._gtWired) { + typeSel._gtWired = true; + typeSel.addEventListener('change', () => { + this._typeFilter = typeSel.value; + this._renderGrid(); + }); + } + if (refreshBtn && !refreshBtn._gtWired) { + refreshBtn._gtWired = true; + refreshBtn.addEventListener('click', () => this._load()); + } + + await this._load(); + }, + + async _load() { + const panel = document.getElementById('gallery-tab-body'); + if (!panel) return; + panel.innerHTML = ''; + try { + const data = await fetch('/api/snapshots').then(r => r.json()); + const items = data.snapshots || []; + // Sort newest first + items.sort((a, b) => (b.timestamp || '').localeCompare(a.timestamp || '')); + this._allItems = items; + this._populateEmbroyFilter(items); + this._renderGrid(); + } catch (err) { + panel.innerHTML = ''; + } + }, + + _populateEmbroyFilter(items) { + const sel = document.getElementById('gallery-embryo-filter'); + if (!sel) return; + const embryos = [...new Set(items.map(i => i.metadata?.embryo_id).filter(Boolean))].sort(); + const cur = sel.value; + // Rebuild options beyond the "All embryos" placeholder + while (sel.options.length > 1) sel.remove(1); + embryos.forEach(eid => { + const opt = document.createElement('option'); + opt.value = eid; + opt.textContent = eid; + sel.appendChild(opt); + }); + if (cur && embryos.includes(cur)) sel.value = cur; + }, + + _renderGrid() { + const panel = document.getElementById('gallery-tab-body'); + if (!panel) return; + + let items = this._allItems; + if (this._embryoFilter) { + items = items.filter(i => (i.metadata?.embryo_id || '') === this._embryoFilter); + } + if (this._typeFilter) { + items = items.filter(i => i.data_type === this._typeFilter); + } + + if (items.length === 0) { + panel.innerHTML = ''; + return; + } + + const html = ``; + panel.innerHTML = html; + + // Wire click handlers + panel.querySelectorAll('.gallery-tab-item').forEach(el => { + el.addEventListener('click', () => { + const idx = parseInt(el.dataset.idx, 10); + Lightbox.open(items, idx, 'gallery'); + }); + }); + }, + + _itemHtml(img, idx) { + const embryo = img.metadata?.embryo_id || ''; + const ts = img.timestamp ? img.timestamp.slice(0, 19).replace('T', ' ') : ''; + const typeLabel = img.data_type || 'image'; + const thumb = img.base64_png + ? `${typeLabel}` + : ``; + return ` + + `; + }, +}; + +// ========================================== +// showGentlyToast — lightweight global toast (volume acquired, etc.) +// ========================================== + +/** + * Show a brief toast notification with an optional action link. + * @param {string} message - Primary message text + * @param {string|null} actionLabel - Label for the action button (null = no button) + * @param {Function|null} actionFn - Callback invoked when the action is clicked + * @param {number} [duration=6000] - Auto-dismiss delay in ms + */ +function showGentlyToast(message, actionLabel, actionFn, duration = 6000) { + // Remove any existing gently-toast + document.querySelectorAll('.gently-toast').forEach(t => t.remove()); + + const toast = document.createElement('div'); + toast.className = 'gently-toast'; + + const msgSpan = document.createElement('span'); + msgSpan.className = 'gently-toast-msg'; + msgSpan.textContent = message; + toast.appendChild(msgSpan); + + if (actionLabel && actionFn) { + const actionBtn = document.createElement('button'); + actionBtn.className = 'gently-toast-action'; + actionBtn.textContent = actionLabel; + actionBtn.addEventListener('click', () => { + actionFn(); + toast.remove(); + }); + toast.appendChild(actionBtn); + } + + const dismiss = document.createElement('button'); + dismiss.className = 'gently-toast-dismiss'; + dismiss.setAttribute('aria-label', 'Dismiss'); + dismiss.textContent = '×'; + dismiss.addEventListener('click', () => toast.remove()); + toast.appendChild(dismiss); + + document.body.appendChild(toast); + // Trigger transition + requestAnimationFrame(() => toast.classList.add('visible')); + + const timer = setTimeout(() => toast.remove(), duration); + dismiss.addEventListener('click', () => clearTimeout(timer)); +} diff --git a/gently/ui/web/static/js/utils.js b/gently/ui/web/static/js/utils.js index b321506c..abf2dee0 100644 --- a/gently/ui/web/static/js/utils.js +++ b/gently/ui/web/static/js/utils.js @@ -3,7 +3,7 @@ // ══════════════════════════════════════════════════════════ // Tab and view name constants -const TABS = { HOME: 'home', EMBRYOS: 'embryos', CALIBRATION: 'calibration', EVENTS: 'events', PLANS: 'plans', SESSIONS: 'sessions', DEVICES: 'devices', EXPERIMENT: 'experiment', NOTEBOOK: 'notebook' }; +const TABS = { HOME: 'home', EMBRYOS: 'embryos', CALIBRATION: 'calibration', EVENTS: 'events', PLANS: 'plans', SESSIONS: 'sessions', DEVICES: 'devices', EXPERIMENT: 'experiment', NOTEBOOK: 'notebook', GALLERY: 'gallery' }; /** * Extract the XY firmware fence (the addressable stage box) from a device-state diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index 1f09ba06..109d3be8 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -124,6 +124,7 @@

Take a quick look

+
System
@@ -758,6 +759,16 @@

Properties

+ + +
@@ -786,17 +797,26 @@

Properties

Illumination
- - -
-
+
+
+ +
+
+ + +
Laser: OFF (brightfield) @@ -830,12 +850,187 @@

Properties

+ +
+ + +
+
+ + +