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
27 changes: 26 additions & 1 deletion config/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
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"
90 changes: 90 additions & 0 deletions gently/hardware/dispim/devices/test_temp_usb.py
Original file line number Diff line number Diff line change
@@ -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()
99 changes: 99 additions & 0 deletions gently/hardware/dispim/devices/test_temperature_controller.py
Original file line number Diff line number Diff line change
@@ -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.")
55 changes: 55 additions & 0 deletions gently/ui/web/routes/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand Down
65 changes: 65 additions & 0 deletions gently/ui/web/static/css/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
Loading