"""T3R Stepper Controller driver for ScanEngine-3. Owns the serial connection and an internal reader thread. Events are delivered as plain-Python callbacks (see ``Signal``); commands are fire-and-forget writes. No Qt — GUIs wrap this with gui.qt_t3r.QtT3RAdapter, which re-emits every event as a queued Qt signal on the GUI thread. Callbacks run on the reader thread. Keep them short, and never touch Qt widgets from one directly. Gear train (stage rotation via GR-axis, ch3): Motor → 10T pinion → 30T idler → 125T index gear (stage) Ratio motor:stage = 125/10 = 12.5 """ from __future__ import annotations import logging import threading from . import t3r_protocol as proto from .serial_util import open_8n1 logger = logging.getLogger(__name__) class Signal: """Minimal observer slot: ``connect(fn)`` then ``emit(*args)``. Mirrors the pyqtSignal API used by the existing panels so the same call sites work against either this driver or a Qt adapter over it. A raising subscriber is logged and skipped so one bad listener cannot kill the reader thread. """ __slots__ = ("_subs", "_name") def __init__(self, name: str = ""): self._subs: list = [] self._name = name def connect(self, fn) -> None: self._subs.append(fn) def disconnect(self, fn) -> None: if fn in self._subs: self._subs.remove(fn) def emit(self, *args) -> None: for fn in list(self._subs): try: fn(*args) except Exception: logger.exception("T3R %s subscriber failed", self._name) class _Reader(threading.Thread): """Blocking read loop — decodes frames and hands them to `on_frame`.""" def __init__(self, ser, on_frame, on_finished): super().__init__(daemon=True, name="T3RReader") self._ser = ser self._on_frame = on_frame self._on_finished = on_finished self._running = True self._parser = proto.FrameParser() def stop(self): self._running = False def run(self): reason = "" while self._running: try: data = self._ser.read(256) except Exception as exc: reason = str(exc) or exc.__class__.__name__ break if data: for cmd, payload in self._parser.feed(data): self._on_frame(cmd, payload) self._on_finished(reason) class T3RDriver: """Driver for the T3R four-channel stepper controller. Usage:: driver = T3RDriver() driver.handshake_ok.connect(lambda pv, fw, nc: ...) driver.open("/dev/ttyUSB0") driver.move(0, steps=3200, velocity=8000, accel=4000) driver.wait_motion_done(0, timeout=30.0) """ CHANNEL_NAMES = ["T-axis (focus)", "Axis 1", "Axis 2", "GR-axis"] GR_AXIS_CH = 3 # Gear train constants MOTOR_FULL_STEPS_PER_REV = 200 GEAR_TEETH_MOTOR = 10 GEAR_TEETH_STAGE = 125 # idler is 30T but does not change ratio POLL_INTERVAL_S = 0.25 def __init__(self): self._ser = None self._reader: _Reader | None = None self._write_lock = threading.Lock() self._tearing_down = False self._is_open = False self._poll_stop = threading.Event() self._poll_thread: threading.Thread | None = None # Per-channel motion-completion events, so a caller can block on a # move finishing instead of guessing its duration. self._motion_events = [threading.Event() for _ in range(proto.NUM_CHANNELS)] self.port_opened = Signal("port_opened") # () self.handshake_ok = Signal("handshake_ok") # proto_ver, fw_ver, n_ch self.disconnected = Signal("disconnected") # reason ("" = user) self.info_updated = Signal("info_updated") # ch, proto.Info self.drv_status_updated = Signal("drv_status_updated") # ch, proto.DrvStatus self.position_updated = Signal("position_updated") # ch, position self.motion_done = Signal("motion_done") # ch, final_position self.stopped = Signal("stopped") # ch, final_position self.fault_occurred = Signal("fault_occurred") # ch, fault_mask self.ack_received = Signal("ack_received") # req_cmd, status self.frame_received = Signal("frame_received") # raw cmd, payload # ── Connection ──────────────────────────────────────────────────────────── @property def is_open(self) -> bool: return self._is_open def open(self, port: str, baud: int = 115200) -> None: """Open the serial port and start the reader; emits port_opened.""" if self._is_open: self.close() try: self._ser = open_8n1(port, baudrate=baud, timeout=0.05) except Exception as exc: raise RuntimeError(f"Cannot open {port}: {exc}") from exc self._tearing_down = False self._is_open = True self._reader = _Reader(self._ser, self._on_frame, self._on_reader_finished) self._reader.start() self.port_opened.emit() self.send_frame(proto.ping()) # handshake; polling starts on PONG def close(self) -> None: """Close the port and stop polling.""" self._teardown("") def _on_reader_finished(self, reason: str): if reason: self._teardown(reason) def _teardown(self, reason: str): if self._tearing_down or not self._is_open: return self._tearing_down = True self.stop_polling() self._is_open = False # Release anyone blocked in wait_motion_done so a disconnect during a # move raises there instead of hanging until the timeout. for ev in self._motion_events: ev.set() reader, self._reader = self._reader, None ser, self._ser = self._ser, None if reader is not None: reader.stop() if threading.current_thread() is not reader: reader.join(1.0) if ser is not None: try: ser.close() except Exception: pass self.disconnected.emit(reason) # ── Sending ─────────────────────────────────────────────────────────────── def send_frame(self, frame: bytes) -> bool: if not self._is_open or self._ser is None: return False try: with self._write_lock: self._ser.write(frame) return True except Exception as exc: self._teardown(str(exc) or exc.__class__.__name__) return False # ── Command API ─────────────────────────────────────────────────────────── def ping(self): self.send_frame(proto.ping()) def stop_all(self): self.send_frame(proto.stop_all()) def enable(self, ch: int): self.send_frame(proto.enable(ch)) def disable(self, ch: int): self.send_frame(proto.disable(ch)) def enable_mask(self, mask: int): self.send_frame(proto.enable_mask(mask)) def set_microstep(self, ch: int, microsteps: int): self.send_frame(proto.set_microstep(ch, microsteps)) def set_current(self, ch: int, run_ma: int, hold_ma: int, ihold_delay: int): self.send_frame(proto.set_current(ch, run_ma, hold_ma, ihold_delay)) def move(self, ch: int, steps: int, velocity: int, accel: int): self._motion_events[ch].clear() self.send_frame(proto.move(ch, steps, velocity, accel)) def jog(self, ch: int, velocity: int, accel: int): self.send_frame(proto.jog(ch, velocity, accel)) def stop(self, ch: int, hard: bool = False): self.send_frame(proto.stop(ch, hard)) def move_group(self, mask: int, steps: int, velocity: int, accel: int): self.send_frame(proto.move_group(mask, steps, velocity, accel)) def jog_group(self, mask: int, velocity: int, accel: int): self.send_frame(proto.jog_group(mask, velocity, accel)) def get_info(self, ch: int): self.send_frame(proto.get_info(ch)) def get_drv_status(self, ch: int): self.send_frame(proto.get_drv_status(ch)) def get_position(self, ch: int): self.send_frame(proto.get_position(ch)) def set_position(self, ch: int, position: int): self.send_frame(proto.set_position(ch, position)) # ── Rotation helpers ────────────────────────────────────────────────────── def steps_for_angle(self, angle_deg: float, microsteps: int) -> int: """GR-axis microsteps needed to rotate the stage by angle_deg.""" gear_ratio = self.GEAR_TEETH_STAGE / self.GEAR_TEETH_MOTOR steps_per_stage_rev = self.MOTOR_FULL_STEPS_PER_REV * microsteps * gear_ratio return round(steps_per_stage_rev * angle_deg / 360.0) def rotate_stage(self, angle_deg: float, microsteps: int, velocity: int = 8000, accel: int = 4000): """Move GR-axis by the steps that rotate the stage by angle_deg.""" self.move(self.GR_AXIS_CH, self.steps_for_angle(angle_deg, microsteps), velocity, accel) def wait_motion_done(self, ch: int, timeout: float) -> bool: """Block until the channel reports MOTION_DONE. False on timeout. Cleared by ``move()``, set by the MOTION_DONE event and by teardown, so a disconnect mid-move unblocks immediately. """ return self._motion_events[ch].wait(timeout) # ── Polling ─────────────────────────────────────────────────────────────── def start_polling(self): if self._poll_thread is not None and self._poll_thread.is_alive(): return self._poll_stop.clear() self._poll_thread = threading.Thread(target=self._poll_loop, daemon=True, name="T3RPoll") self._poll_thread.start() def stop_polling(self): self._poll_stop.set() def _poll_loop(self): while not self._poll_stop.wait(self.POLL_INTERVAL_S): if not self._is_open: break for ch in range(proto.NUM_CHANNELS): self.send_frame(proto.get_info(ch)) # ── Frame dispatcher ────────────────────────────────────────────────────── def _on_frame(self, cmd: int, payload: bytes): self.frame_received.emit(cmd, payload) if cmd == proto.RSP_PONG: p = proto.decode_pong(payload) if p: self.handshake_ok.emit(p.proto_ver, p.fw_version, p.num_channels) self.start_polling() elif cmd == proto.RSP_ACK: a = proto.decode_ack(payload) if a: self.ack_received.emit(a.req_cmd, a.status) elif cmd == proto.RSP_INFO: info = proto.decode_info(payload) if info and 0 <= info.ch < proto.NUM_CHANNELS: self.info_updated.emit(info.ch, info) elif cmd == proto.RSP_DRV_STATUS: st = proto.decode_drv_status(payload) if st and 0 <= st.ch < proto.NUM_CHANNELS: self.drv_status_updated.emit(st.ch, st) elif cmd == proto.RSP_POSITION: pos = proto.decode_position(payload) if pos and 0 <= pos.ch < proto.NUM_CHANNELS: self.position_updated.emit(pos.ch, pos.position) elif cmd == proto.EVT_MOTION_DONE: ev = proto.decode_event_position(payload) if ev and 0 <= ev.ch < proto.NUM_CHANNELS: self._motion_events[ev.ch].set() self.motion_done.emit(ev.ch, ev.position) elif cmd == proto.EVT_STOPPED: ev = proto.decode_event_position(payload) if ev and 0 <= ev.ch < proto.NUM_CHANNELS: self._motion_events[ev.ch].set() self.stopped.emit(ev.ch, ev.position) elif cmd == proto.EVT_FAULT: ev = proto.decode_fault(payload) if ev and 0 <= ev.ch < proto.NUM_CHANNELS: self._motion_events[ev.ch].set() self.fault_occurred.emit(ev.ch, ev.position)