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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions gently/hardware/dispim/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -819,6 +819,16 @@ async def get_laser_power(self, wavelength: int) -> dict:
except Exception as e:
return {"success": False, "error": str(e)}

async def get_properties(self, device: str | None = None) -> dict:
"""Every property of one Micro-Manager device, or the device list.

Read-only. Asks the hardware what it reports about itself rather than
what this codebase models — the difference that matters when no one can
look at the instrument.
"""
suffix = f"?device={device}" if device else ""
return await self._api_get(f"/api/properties{suffix}")

async def get_beam(self) -> dict:
"""Read whether the beam is armed, per side, from the hardware.

Expand Down
62 changes: 62 additions & 0 deletions gently/hardware/dispim/device_layer.py
Original file line number Diff line number Diff line change
Expand Up @@ -2443,6 +2443,67 @@ def _read_beam(self) -> dict:
out[side] = None
return out

async def handle_get_properties(self, request):
"""GET /api/properties?device=<label> — every property of one device.

Read-only, and the read is the point: it asks the hardware what it says
about itself, rather than what this codebase happens to model. Gently
knows four properties on the Coherent Scientific Remote — the per-line
`PowerSetpoint (%)` — because those are the ones it writes. The adapter
exposes many more, and among them are the ones that answer whether a
laser is actually emitting: enable state, interlock, faults, power
readback.

That matters when nobody can look at the instrument. A camera can only
report photons that reach it, which needs the specimen in focus; the
controller can report its own state regardless.

With no `device`, lists the loaded device labels.
"""
try:
label = request.query.get("device")
core = None
for dev in self.devices.values():
core = getattr(dev, "core", None)
if core is not None:
break
if core is None:
return web.json_response(
{"success": False, "error": "no core available"}, status=503
)

if not label:
return web.json_response(
{"success": True, "devices": sorted(core.getLoadedDevices())}
)

if label not in set(core.getLoadedDevices()):
return web.json_response(
{"success": False, "error": f"unknown device {label!r}"}, status=404
)

props = {}
for name in core.getDevicePropertyNames(label):
try:
entry: dict[str, Any] = {"value": core.getProperty(label, name)}
# Allowed values turn an opaque string into a state machine
# you can reason about; read-only marks what is a readback
# rather than a setting, which is exactly the distinction
# being chased here.
allowed = list(core.getAllowedPropertyValues(label, name))
if allowed:
entry["allowed"] = allowed
if core.isPropertyReadOnly(label, name):
entry["read_only"] = True
props[name] = entry
except Exception as exc:
props[name] = {"error": str(exc)}

return web.json_response({"success": True, "device": label, "properties": props})
except Exception as exc:
logger.exception("[properties] read failed")
return web.json_response({"success": False, "error": str(exc)}, status=500)

async def handle_get_beam(self, request):
"""GET /api/scanner/beam — is the beam armed, per side, from hardware."""
try:
Expand Down Expand Up @@ -3833,6 +3894,7 @@ async def on_start(self):
self._app.router.add_post("/api/spim/fdrive/nudge", self.handle_nudge_fdrive)
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_get("/api/properties", self.handle_get_properties)
self._app.router.add_get("/api/scanner/beam", self.handle_get_beam)
self._app.router.add_post("/api/scanner/beam", self.handle_set_beam)
self._app.router.add_post("/api/laser/config", self.handle_set_laser_config)
Expand Down
17 changes: 17 additions & 0 deletions gently/ui/web/routes/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -820,6 +820,23 @@ async def laser_off():
logger.exception("Laser off command failed")
raise HTTPException(status_code=502, detail=f"laser off failed: {exc}") from exc

@router.get("/api/devices/properties")
async def device_properties(device: str | None = None):
"""Every Micro-Manager property of one device, or the device list.

Read-only diagnostic. No `require_control`: it commands nothing, and it
is most needed exactly when someone is trying to work out what the
hardware is doing without being able to look at it.
"""
client = _resolve_client()
if client is None or not client.is_connected:
raise HTTPException(status_code=503, detail="Microscope not connected")
try:
return await client.get_properties(device)
except Exception as exc:
logger.exception("Device property read failed")
raise HTTPException(status_code=502, detail=f"property read failed: {exc}") from exc

@router.get("/api/devices/beam")
async def beam_get():
"""Is the beam armed, per side? Read from hardware. Read-only route."""
Expand Down
180 changes: 72 additions & 108 deletions gently/ui/web/static/css/operate.css
Original file line number Diff line number Diff line change
Expand Up @@ -871,114 +871,6 @@
a containing block for it. */
.op-cam { position: relative; }

/* Contrast/brightness strip — fades in on hover or keyboard focus. The
video-player idiom: discoverable, nothing to open or close, and gone while
you are just watching the frame. */
.iv-bar {
position: absolute;
left: 6px;
right: 6px;
bottom: 6px;
display: flex;
align-items: center;
gap: 10px;
padding: 4px 8px;
border-radius: 5px;
background: rgba(0, 0, 0, 0.55);
opacity: 0;
transition: opacity 0.15s ease;
pointer-events: none;
}

.op-cam:hover .iv-bar,
.iv-bar:focus-within {
opacity: 1;
pointer-events: auto;
}

@media (prefers-reduced-motion: reduce) {
.iv-bar { transition: none; }
}

/* Display window: one track, a black point and a white point. The lit span
between them is the range being mapped to the full output. */
.iv-lbl {
flex: 0 0 auto;
font-size: 0.62rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.65);
}

.iv-win {
position: relative;
flex: 1 1 auto;
min-width: 70px;
height: 18px;
touch-action: none; /* the handles own the gesture */
cursor: ew-resize;
}

.iv-win-track {
position: absolute;
inset: 7px 0 auto 0;
height: 4px;
border-radius: 2px;
/* black-to-white, because that is literally the axis being windowed */
background: linear-gradient(90deg, #000, #fff);
outline: 1px solid rgba(255, 255, 255, 0.25);
}

.iv-win-span {
position: absolute;
top: 6px;
height: 6px;
border-radius: 3px;
background: rgba(96, 165, 250, 0.55);
pointer-events: none;
}

.iv-win-h {
position: absolute;
top: 2px;
width: 10px;
height: 14px;
margin-left: -5px;
border-radius: 2px;
background: #fff;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.7);
cursor: ew-resize;
}

.iv-win-h:focus-visible {
outline: 2px solid var(--accent, #3b82f6);
outline-offset: 1px;
}

/* Numbers only once the window is not full — a permanent "0–100" is a label
reporting that nothing has been changed. */
.iv-read {
flex: 0 0 auto;
min-width: 42px;
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
font-size: 0.64rem;
color: #fff;
text-align: right;
}

.iv-reset {
flex: 0 0 auto;
font-size: 0.64rem;
padding: 1px 6px;
border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: 3px;
background: transparent;
color: #fff;
cursor: pointer;
}

/* The zoom badge shares the corner with the strip; lift it clear on hover. */
.op-cam:hover .iv-badge { bottom: 34px; }

/* ── Marking panel ────────────────────────────────────────────────────────
Two counts side by side, because they are two different facts and showing
Expand Down Expand Up @@ -1053,3 +945,75 @@
grid-column: 1 / -1;
margin: 0;
}

/* ── Display panel ────────────────────────────────────────────────────────
A histogram with the transfer line drawn across it, after ImageJ/Fiji's
Brightness/Contrast. It sits UNDER the frame, never over it: the image is
the instrument, and an overlay both hides data and swallows clicks meant
for markers. The picture is the readout, so there is no numeric status. */
.op-block-display .lp-head {
display: flex;
align-items: baseline;
justify-content: space-between;
}

/* Says what the histogram is of, because it is not raw camera counts. */
.iv-src {
font-size: 0.62rem;
color: var(--op-ink-dim);
cursor: help;
}

.iv-hist {
position: relative;
height: 60px;
margin-bottom: 10px;
border-radius: 3px;
/* Black-to-white behind the bars: the axis being windowed, made literal. */
background: linear-gradient(90deg, #0b0f14, #2b3441);
outline: 1px solid var(--op-rule);
touch-action: none;
cursor: ew-resize;
}

.iv-hist-c {
display: block;
width: 100%;
height: 100%;
}

.iv-h {
position: absolute;
top: -3px;
bottom: -6px;
width: 2px;
margin-left: -1px;
background: #60a5fa;
cursor: ew-resize;
}

/* A grip at the foot, so the handle is grabbable without hiding the data. */
.iv-h::after {
content: '';
position: absolute;
left: -4px;
bottom: -1px;
width: 10px;
height: 8px;
border-radius: 2px;
background: #60a5fa;
}

.iv-h-lo::after { border-bottom-left-radius: 0; }
.iv-h-hi::after { border-bottom-right-radius: 0; }

.iv-h:focus-visible {
outline: 2px solid var(--accent, #3b82f6);
outline-offset: 2px;
}

.iv-acts {
display: flex;
gap: 6px;
justify-content: flex-end;
}
4 changes: 2 additions & 2 deletions gently/ui/web/static/js/operate.js
Original file line number Diff line number Diff line change
Expand Up @@ -1295,8 +1295,8 @@ const OperateManager = (function () {
// magnification.
function attachImageViews() {
if (typeof ImageView === 'undefined') return;
ImageView.attach('op-cam-bottom');
ImageView.attach('op-cam-spim');
ImageView.attach('op-cam-bottom', { controlsHost: 'op-display-bottom' });
ImageView.attach('op-cam-spim', { controlsHost: 'op-display-spim' });
}

let _lightMounted = false;
Expand Down
Loading
Loading