From 38cac409b708e123257f5e779330842dd0c81536 Mon Sep 17 00:00:00 2001 From: Kesavan Date: Sun, 7 Jun 2026 20:19:08 -0400 Subject: [PATCH] Integrate ACUITYnano temperature controller (config, web control, SDKs) - config.yml: add `temperature:` block (serial backend on COM8) so the device layer registers a `temperature` device. - Web Devices header: live water-temperature readout + setpoint control - data.py: GET /api/devices/temperature/status, POST /api/devices/temperature/set - devices.js: temperature panel (poll + set); main.css pill; index.html markup - Vendor integration SDKs: MQTT (test_temperature_controller.py) and USB serial (test_temp_usb.py). --- config/config.yml | 27 +++- .../hardware/dispim/devices/test_temp_usb.py | 90 +++++++++++++ .../devices/test_temperature_controller.py | 99 +++++++++++++++ gently/ui/web/routes/data.py | 55 ++++++++ gently/ui/web/static/css/main.css | 65 ++++++++++ gently/ui/web/static/js/devices.js | 118 ++++++++++++++++++ gently/ui/web/templates/index.html | 14 +++ 7 files changed, 467 insertions(+), 1 deletion(-) create mode 100644 gently/hardware/dispim/devices/test_temp_usb.py create mode 100644 gently/hardware/dispim/devices/test_temperature_controller.py diff --git a/config/config.yml b/config/config.yml index 086de3f1..9c05c274 100644 --- a/config/config.yml +++ b/config/config.yml @@ -10,4 +10,29 @@ mmdirectory: "C:/Program Files/Micro-Manager-1.4" switchbot: name: room_light address: "EC:6F:04:06:5B:23" - timeout: 20.0 \ No newline at end of file + timeout: 20.0 + +# ACUITYnano Precision Thermal Controller (Peltier/TEC, 0.0–99.9 °C). When this +# block is present the device layer registers a `temperature` device and the +# Devices tab shows a setpoint control; plans can also block on it via +# `bps.mv(temperature, 20.0)`. Remove/comment the block to skip registration. +# +# backend: mqtt talks to the controller over the vendor's MQTT bridge +# (acuitynano_precision_thermalizer_api). With no broker/port/user/password +# keys it uses the vendor package's embedded HiveMQ Cloud defaults — set them +# here only to point at a different broker. The vendor package must be +# installed on the device-layer machine (not on PyPI). +temperature: + name: temperature + backend: serial # serial | mqtt | mock — USB serial is the working + # link on this machine (MQTT cloud is firewalled) + com_port: "COM8" + baud_rate: 115200 + stabilize_timeout: 600 # seconds to wait for "[ SYSTEM LOCKED ]" on a blocking set + feedback_peltier: false # true = control off the peltier sensor instead of water + # MQTT alternative (vendor HiveMQ Cloud defaults; needs outbound TLS :8883): + # backend: mqtt + # broker: "your-broker.example.com" # optional — overrides the embedded default + # port: 8883 + # user: "username" + # password: "secret" \ No newline at end of file diff --git a/gently/hardware/dispim/devices/test_temp_usb.py b/gently/hardware/dispim/devices/test_temp_usb.py new file mode 100644 index 00000000..f6394aee --- /dev/null +++ b/gently/hardware/dispim/devices/test_temp_usb.py @@ -0,0 +1,90 @@ +import serial +import time +import threading + +class AcuityNanoPrecisionThermalizerSerial: + def __init__(self, com_port, baud_rate=115200): + self.telemetry = { + "target": 20.0, + "water": 20.0, + "peltier": 20.0, + "state": "DISCONNECTED", + "errors": "0" + } + self.running = True + self.ser = serial.Serial(com_port, baud_rate, timeout=0.1) + time.sleep(2) + + self.thread = threading.Thread(target=self._read_loop, daemon=True) + self.thread.start() + + def _read_loop(self): + while self.running and self.ser.is_open: + try: + if self.ser.in_waiting: + line = self.ser.readline().decode('utf-8', errors='ignore').strip() + if "=" in line: + key, val = line.split("=", 1) + if key == "TARGET": self.telemetry["target"] = float(val) + elif key == "WATER": self.telemetry["water"] = float(val) + elif key == "ACTUAL": self.telemetry["peltier"] = float(val) + elif key == "STATE": self.telemetry["state"] = val + elif key == "ERRORS": self.telemetry["errors"] = val + except Exception: + pass + time.sleep(0.01) + + def close(self): + self.running = False + if self.ser.is_open: + self.ser.close() + + def set_temperature(self, target_celsius): + if 0.0 <= target_celsius <= 99.9: + cmd = f"TEMP={target_celsius}\n" + self.ser.write(cmd.encode('utf-8')) + else: + raise ValueError("Target must be between 0.0 and 99.9 C") + + def enable_tec(self, enable=True): + val = "1" if enable else "0" + cmd = f"ENABLE={val}\n" + self.ser.write(cmd.encode('utf-8')) + + def set_feedback_sensor(self, use_peltier=False): + val = "1" if use_peltier else "0" + cmd = f"SENSOR={val}\n" + self.ser.write(cmd.encode('utf-8')) + + def get_water_temp(self): + return self.telemetry["water"] + + def get_system_state(self): + return self.telemetry["state"] + + def wait_for_target(self, timeout_seconds=300): + start = time.time() + while time.time() - start < timeout_seconds: + if "[ SYSTEM LOCKED ]" in self.telemetry["state"]: + return True + time.sleep(0.5) + return False + +if __name__ == "__main__": + import time + + print("Connecting to ACUITYnano...") + acuity = AcuityNanoPrecisionThermalizerSerial("COM8") + + print("Commanding 37.0 C...") + acuity.set_temperature(37.0) + acuity.enable_tec(True) + + print("Waiting for thermal stabilization...") + if acuity.wait_for_target(timeout_seconds=600): + print(f"System locked at {acuity.get_water_temp()} C!") + # Trigger external camera or syringe pump here + else: + print("Timeout reached before system stabilized.") + + acuity.close() \ No newline at end of file diff --git a/gently/hardware/dispim/devices/test_temperature_controller.py b/gently/hardware/dispim/devices/test_temperature_controller.py new file mode 100644 index 00000000..a7efddfe --- /dev/null +++ b/gently/hardware/dispim/devices/test_temperature_controller.py @@ -0,0 +1,99 @@ +""" +ACUITYnano Third-Party Integration SDK +Provides a clean, object-oriented API for external software automation. +""" +import paho.mqtt.client as mqtt +import time +import threading + +class AcuityNanoPrecisionThermalizerAPI: + def __init__(self, broker="d0246aa97d194c9da52a19e6f46063eb.s1.eu.hivemq.cloud", port=8883, user="acuitynano", password="Bg984V!@wfhBrkp"): + self.prefix = "acuitynano_hhmi_shroff_diSPIM_001" + self.telemetry = { + "target": 20.0, + "water": 20.0, + "peltier": 20.0, + "state": "DISCONNECTED", + "errors": "0" + } + + try: + self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) + except AttributeError: + self.client = mqtt.Client() + + self.client.username_pw_set(user, password) + self.client.tls_set() + self.client.on_connect = self._on_connect + self.client.on_message = self._on_message + + self.thread = threading.Thread(target=self._start_loop, daemon=True) + self.thread.start() + time.sleep(2) + + def _start_loop(self): + self.client.connect(broker, port, 60) + self.client.loop_forever() + + def _on_connect(self, client, userdata, flags, rc, properties=None): + self.client.subscribe(f"{self.prefix}/telemetry/#") + + def _on_message(self, client, userdata, msg): + topic = msg.topic.split("/")[-1] + payload = msg.payload.decode('utf-8') + + if topic == "target": self.telemetry["target"] = float(payload) + elif topic == "water": self.telemetry["water"] = float(payload) + elif topic == "actual": self.telemetry["peltier"] = float(payload) + elif topic == "state": self.telemetry["state"] = payload + elif topic == "errors": self.telemetry["errors"] = payload + + def set_temperature(self, target_celsius): + if 0.0 <= target_celsius <= 99.9: + self.client.publish(f"{self.prefix}/cmd/temp", str(target_celsius)) + else: + raise ValueError("Target must be between 0.0 and 99.9 C") + + def enable_tec(self, enable=True): + val = "1" if enable else "0" + self.client.publish(f"{self.prefix}/cmd/enable", val) + + def set_feedback_sensor(self, use_peltier=False): + val = "1" if use_peltier else "0" + self.client.publish(f"{self.prefix}/cmd/sensor", val) + + def get_water_temp(self): + return self.telemetry["water"] + + def get_peltier_temp(self): + return self.telemetry["peltier"] + + def get_system_state(self): + return self.telemetry["state"] + + def wait_for_target(self, timeout_seconds=300): + start = time.time() + while time.time() - start < timeout_seconds: + if "[ SYSTEM LOCKED ]" in self.telemetry["state"]: + return True + time.sleep(0.5) + return False + + + +if __name__ == "__main__": + + import time + from acuitynano_precision_thermalizer_api import AcuityNanoPrecisionThermalizerAPI + + acuity = AcuityNanoPrecisionThermalizerAPI() + print("Commanding ACUITYnano to 37.0 C...") + acuity.set_temperature(30.0) + acuity.enable_tec(True) + + print("Waiting for thermal stabilization...") + if acuity.wait_for_target(timeout_seconds=600): + print(f"System locked at {acuity.get_water_temp()} C!") + # Trigger image acquisition here + else: + print("Timeout reached.") diff --git a/gently/ui/web/routes/data.py b/gently/ui/web/routes/data.py index 3133ec60..c8c0ebe7 100644 --- a/gently/ui/web/routes/data.py +++ b/gently/ui/web/routes/data.py @@ -304,6 +304,61 @@ async def set_room_light(payload: dict = Body(...)): raise HTTPException(status_code=502, detail=res.get("error", "room light command failed")) return {"state": res.get("state", state)} + @router.get("/api/devices/temperature/status") + async def get_temperature_status(): + """Live water temperature, setpoint, and lock state (cheap to poll). + + Cached at the device layer (no per-call hardware round trip), so the + Devices header can poll it like the room light. ``available`` is false + when no controller is configured/connected, which hides the control. + """ + client = _resolve_client() + if client is None: + return {"available": False, "state": "unknown"} + try: + res = await client.get_temperature() + except Exception as exc: + logger.debug("temperature status fetch failed: %s", exc) + return {"available": False, "state": "unknown"} + return { + "available": bool(res.get("success", False)), + "temperature_c": res.get("temperature_c"), + "setpoint_c": res.get("setpoint_c"), + "state": res.get("state", "unknown"), + "peltier_c": res.get("peltier_c"), + } + + @router.post("/api/devices/temperature/set", + dependencies=[Depends(require_control)]) + async def set_temperature(payload: dict = Body(...)): + """Command the temperature setpoint. Body: {"target_c": float}. + + Non-blocking: the controller ramps and the status poll reflects progress + (and the SYSTEM LOCKED state once it stabilizes). + """ + try: + target = float(payload.get("target_c")) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="target_c must be a number") + if not (0.0 <= target <= 99.9): + raise HTTPException(status_code=400, detail="target_c must be between 0.0 and 99.9 C") + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + res = await client.set_temperature(target) + except Exception as exc: + logger.exception("Temperature command failed") + raise HTTPException(status_code=502, detail=f"temperature command failed: {exc}") + if not res.get("success"): + raise HTTPException(status_code=502, detail=res.get("error", "temperature command failed")) + return { + "target_c": res.get("target_c", target), + "temperature_c": res.get("temperature_c"), + "state": res.get("state", "unknown"), + "waited": res.get("waited", False), + } + @router.get("/api/calibration") async def list_calibration(embryo_id: Optional[str] = None): """Get calibration images""" diff --git a/gently/ui/web/static/css/main.css b/gently/ui/web/static/css/main.css index fdafcc1d..10bc6f62 100644 --- a/gently/ui/web/static/css/main.css +++ b/gently/ui/web/static/css/main.css @@ -9671,6 +9671,71 @@ body.modal-open { } .devices-room-light.is-busy { opacity: 0.65; cursor: progress; } +/* --- Temperature controller ------------------------------------------- */ +/* Readout + setpoint input + Set, styled as a pill to sit beside the + room-light toggle in the Devices header. Hidden until a controller is + available (mirrors the room light). */ +.devices-temp { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.12rem 0.28rem 0.12rem 0.5rem; + border: 1px solid var(--map-overlay-edge); + background: var(--map-overlay-bg); + border-radius: 999px; + color: var(--map-ink-mute); + font-family: inherit; + font-size: 0.65rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; +} +.devices-temp[hidden] { display: none; } +.devices-temp-icon { display: inline-flex; align-items: center; color: var(--map-ink-mute); transition: color 0.15s, filter 0.15s; } +/* "locked" — cool glow once the controller reports SYSTEM LOCKED */ +.devices-temp.is-locked { border-color: rgba(90, 200, 250, 0.6); color: #5ac8fa; background: rgba(90, 200, 250, 0.1); } +.devices-temp.is-locked .devices-temp-icon { color: #5ac8fa; filter: drop-shadow(0 0 5px rgba(90, 200, 250, 0.6)); } +.devices-temp.is-busy { opacity: 0.7; cursor: progress; } +.devices-temp-readout { + font-variant-numeric: tabular-nums; + letter-spacing: 0.02em; + min-width: 3.4em; + text-align: right; + white-space: nowrap; +} +.devices-temp-input { + width: 3.4em; + padding: 0.1rem 0.3rem; + border: 1px solid var(--map-overlay-edge); + background: var(--map-paper, rgba(0, 0, 0, 0.2)); + border-radius: 6px; + color: var(--map-ink); + font-family: inherit; + font-size: 0.72rem; + font-variant-numeric: tabular-nums; + text-align: right; + -moz-appearance: textfield; +} +.devices-temp-input:focus { outline: none; border-color: var(--map-accent); } +.devices-temp-input::-webkit-outer-spin-button, +.devices-temp-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } +.devices-temp-set { + padding: 0.16rem 0.5rem; + border: 1px solid var(--map-overlay-edge); + background: var(--map-overlay-bg); + border-radius: 999px; + color: var(--map-ink-mute); + font-family: inherit; + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + cursor: pointer; + transition: color 0.15s, border-color 0.15s, background 0.15s; +} +.devices-temp-set:hover:not(:disabled) { border-color: var(--map-accent); color: var(--map-ink); } +.devices-temp-set:disabled { opacity: 0.5; cursor: default; } + /* --- Containers ------------------------------------------------------- */ .devices-view { display: flex; flex-direction: column; flex: 1; min-height: 0; } .devices-view-details { gap: 1rem; } diff --git a/gently/ui/web/static/js/devices.js b/gently/ui/web/static/js/devices.js index 27bc12c3..caa1bf48 100644 --- a/gently/ui/web/static/js/devices.js +++ b/gently/ui/web/static/js/devices.js @@ -81,6 +81,13 @@ const DevicesManager = (function () { let _roomLightBusy = false; let _roomLightTimer = null; + // Temperature-controller panel DOM + state + let _tempEl, _tempReadout, _tempInput, _tempSet; + let _tempState = 'unknown'; + let _tempAvailable = false; + let _tempBusy = false; + let _tempTimer = null; + let _lastTs = 0; let _previousTs = 0; let _lastWallTs = 0; @@ -147,6 +154,11 @@ const DevicesManager = (function () { _roomLightToggle = document.getElementById('devices-room-light-toggle'); _roomLightLabel = document.getElementById('devices-room-light-label'); + _tempEl = document.getElementById('devices-temp'); + _tempReadout = document.getElementById('devices-temp-readout'); + _tempInput = document.getElementById('devices-temp-input'); + _tempSet = document.getElementById('devices-temp-set'); + // Recompute the scale bar caption whenever the canvas resizes. if (_mapSvg && window.ResizeObserver) { new ResizeObserver(() => updateScalebar()).observe(_mapSvg); @@ -1369,6 +1381,111 @@ const DevicesManager = (function () { _roomLightTimer = setInterval(loadRoomLightStatus, 15000); } + // ===================================================================== + // Temperature controller (ACUITYnano) — readout + setpoint + // ===================================================================== + + function fmtTemp(v) { + return (v === null || v === undefined || isNaN(v)) ? '—' : Number(v).toFixed(1) + '°'; + } + + function applyTemperature(data) { + _tempAvailable = !!(data && data.available); + if (!_tempEl) return; + _tempEl.hidden = !_tempAvailable; + if (!_tempAvailable) return; + _tempState = (data && data.state) || 'unknown'; + const locked = /LOCK/i.test(_tempState); + _tempEl.classList.toggle('is-locked', locked); + if (_tempBusy) return; // a set() is in flight; leave its transient label + const cur = fmtTemp(data.temperature_c); + const hasSp = data.setpoint_c !== null && data.setpoint_c !== undefined; + const sp = hasSp ? fmtTemp(data.setpoint_c) : null; + _tempReadout.textContent = sp ? (cur + ' → ' + sp) : cur; + _tempReadout.title = 'Water ' + cur + (sp ? (', setpoint ' + sp) : '') + + (locked ? ' (locked)' : ''); + // Seed the input with the current setpoint once, while untouched, so the + // operator sees where it is before nudging it. + if (_tempInput && document.activeElement !== _tempInput && _tempInput.value === '' && hasSp) { + _tempInput.value = Number(data.setpoint_c).toFixed(1); + } + } + + async function loadTemperatureStatus() { + if (!_tempEl || _tempBusy) return; + try { + const res = await fetch('/api/devices/temperature/status'); + if (!res.ok) { applyTemperature({ available: false }); return; } + applyTemperature(await res.json()); + } catch (err) { + console.debug('temperature status fetch failed:', err); + applyTemperature({ available: false }); + } + } + + async function setTemperature() { + if (!_tempEl || _tempBusy || !_tempAvailable) return; + const target = parseFloat(_tempInput && _tempInput.value); + if (isNaN(target) || target < 0 || target > 99.9) { + _tempReadout.textContent = '0–99.9 °C'; + setTimeout(loadTemperatureStatus, 1500); + return; + } + _tempBusy = true; + _tempEl.classList.add('is-busy'); + if (_tempSet) _tempSet.disabled = true; + _tempReadout.textContent = 'Set ' + target.toFixed(1) + '°…'; + + // Settle back to the resolved state, or surface a transient message + // (insufficient control / error) for 2 s before reverting. + const finish = (msg) => { + _tempBusy = false; + _tempEl.classList.remove('is-busy'); + if (_tempSet) _tempSet.disabled = false; + if (msg) { + _tempReadout.textContent = msg; + setTimeout(loadTemperatureStatus, 2000); + } else { + loadTemperatureStatus(); // controller ramps; poll shows progress + } + }; + + try { + const res = await fetch('/api/devices/temperature/set', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ target_c: target }), + }); + if (res.status === 401 || res.status === 403) { finish('Need control'); return; } + if (!res.ok) { + console.error('temperature set failed:', await res.text()); + finish('Error'); + return; + } + await res.json(); + finish(null); + } catch (err) { + console.error('temperature set failed:', err); + finish('Error'); + } + } + + function setupTemperature() { + if (!_tempEl) return; + if (_tempSet) _tempSet.addEventListener('click', setTemperature); + if (_tempInput) { + _tempInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); setTemperature(); } + }); + } + loadTemperatureStatus(); + // Periodic refresh: the setpoint can also change from agent plans, and a + // commanded ramp settles over time. Status is cached at the device layer, + // so polling is cheap; it also reveals the control once the layer connects. + if (_tempTimer) clearInterval(_tempTimer); + _tempTimer = setInterval(loadTemperatureStatus, 15000); + } + // ===================================================================== // View switching // ===================================================================== @@ -1429,6 +1546,7 @@ const DevicesManager = (function () { setupViewSwitcher(); setupCameraWiring(); setupRoomLight(); + setupTemperature(); loadCoverslip(); loadEmbryosSnapshot(); switchView(_currentView); diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index e8164eca..85a60345 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -304,6 +304,20 @@

Device
+