From e3faf8bdd8c21600a2e4b301f099a91e50d91cd6 Mon Sep 17 00:00:00 2001 From: beniroquai Date: Sat, 29 Oct 2022 23:47:05 +0200 Subject: [PATCH 1/4] Adding camera to the API --- uc2rest/TEST/TEST_esp32camera.py | 22 ++++++++++++ uc2rest/UC2Client.py | 10 ++++-- uc2rest/camera.py | 57 ++++++++++++++++++++++++++++++++ uc2rest/mserial.py | 13 +++++--- 4 files changed, 95 insertions(+), 7 deletions(-) create mode 100644 uc2rest/TEST/TEST_esp32camera.py create mode 100644 uc2rest/camera.py diff --git a/uc2rest/TEST/TEST_esp32camera.py b/uc2rest/TEST/TEST_esp32camera.py new file mode 100644 index 0000000..9b95471 --- /dev/null +++ b/uc2rest/TEST/TEST_esp32camera.py @@ -0,0 +1,22 @@ +import uc2rest +import numpy as np +import time +import matplotlib.pyplot as plt + +esp32cam = uc2rest.UC2Client(serialport="Unknown", identity="UC2_Camera") + +# turn on led +esp32cam.camera.set_led(255) + +# test Cam +for i in range(10): + image = esp32cam.camera.get_frame() + plt.figure(i) + plt.imshow(image) + plt.show() + time.sleep(0.5) + +# turn off led +esp32cam.camera.set_led(0) + +print("done") \ No newline at end of file diff --git a/uc2rest/UC2Client.py b/uc2rest/UC2Client.py index 3759505..ecd9039 100755 --- a/uc2rest/UC2Client.py +++ b/uc2rest/UC2Client.py @@ -19,6 +19,7 @@ from .state import State from .laser import Laser from .wifi import Wifi +from .camera import Camera try: @@ -40,7 +41,7 @@ class UC2Client(object): BAUDRATE = 115200 - def __init__(self, host=None, port=31950, serialport=None, baudrate=BAUDRATE): + def __init__(self, host=None, port=31950, serialport=None, identity=None, baudrate=BAUDRATE): ''' This client connects to the UC2-REST microcontroller that can be found here https://github.com/openUC2/UC2-REST @@ -61,7 +62,7 @@ def __init__(self, host=None, port=31950, serialport=None, baudrate=BAUDRATE): # initialize communication channel (# connect to wifi or usb) if serialport is not None: # use USB connection - self.serial = Serial(serialport, baudrate, parent=self) + self.serial = Serial(serialport, baudrate, parent=self, identity=identity) self.is_serial = True self.is_connected = True @@ -99,12 +100,17 @@ def __init__(self, host=None, port=31950, serialport=None, baudrate=BAUDRATE): # initialize laser self.state = State(self) + # initialize galvo self.galvo1 = Galvo(self, 1) + # initialize laser self.laser = Laser(self) + # initialize wifi self.wifi = Wifi(self) + # initialize camera + self.camera = Camera(self) def post_json(self, path, payload, timeout=1): if self.is_wifi: diff --git a/uc2rest/camera.py b/uc2rest/camera.py new file mode 100644 index 0000000..359a771 --- /dev/null +++ b/uc2rest/camera.py @@ -0,0 +1,57 @@ +from PIL import Image +import base64 +import io +import numpy as np + +class Camera(object): + + def __init__(self, parent, width=320, height=240, fps=10): + self._parent = parent + self.width = width + self.height = height + self.lastFrame = 255*np.random.randn(self.height, self.width) + + + def set_camera(self, width=None, height=None, fps=None): + path = "/camera_set" + payload = {"path":path, + "width":width, + "height":height, + "fps":fps} + r = self._parent.post_json(path, payload, timeout=timeout) + return r + + def get_camera(self): + path = "/camera_get" + return None + + def get_frame(self, timeout=10): + path = "/camera_act" + payload = { + "grabimage":1 + } + r = self._parent.post_json(path, payload, timeout=timeout) + + #%% + + try: + #read image and decode + imageB64 = r['frame'] + image = np.array(Image.open(io.BytesIO(base64.b64decode(imageB64)))) + + except Exception as e: + self._parent.logger.error(f"Error: {e}") + image = None + image = self.lasetFrame + + return image + + + def set_led(self, value): + path = "/led_act" + payload = { + "value":value + } + r = self._parent.post_json(path, payload, timeout=2) + + return r diff --git a/uc2rest/mserial.py b/uc2rest/mserial.py index bb2151a..662ff7a 100644 --- a/uc2rest/mserial.py +++ b/uc2rest/mserial.py @@ -6,11 +6,12 @@ class Serial(object): - def __init__(self, port, baudrate, timeout=1, parent=None): + def __init__(self, port, baudrate, timeout=1, identity="UC2_Feather", parent=None): self.serialport = port self.baudrate = baudrate self.timeout = timeout self._parent = parent + self.identity = identity self.NumberRetryReconnect = 0 self.MaxNumberRetryReconnect = 20 @@ -64,7 +65,7 @@ def checkFirmware(self, serialdevice, timeout=1): """Check if the firmware is correct""" path = "/state_get" _state = self.post_json(path, {"task":path}, timeout=timeout) - if _state["identifier_name"] == "UC2_Feather": + if _state["identifier_name"] == self.identity: return True else: return False @@ -141,7 +142,7 @@ def readSerial(self, is_blocking=True, timeout = 15): # TODO: hardcoded timeout while is_blocking: try: rmessage = self.serialdevice.readline().decode() - #self._parent.logger.debug(rmessage) + # self._parent.logger.debug(rmessage) returnmessage += rmessage if rmessage.find("--")==0: break @@ -151,10 +152,12 @@ def readSerial(self, is_blocking=True, timeout = 15): # TODO: hardcoded timeout break # casting to dict try: - returnmessage = json.loads(returnmessage.split("--")[0].split("++")[-1]) + # TODO: check if this is a valid JSON + returnmessage = returnmessage.split("--")[0].split("++")[-1].replace("\r","").replace("\n", "").replace("'", '"') + returnmessage = json.loads(returnmessage) except: self._parent.logger.debug("Casting json string from serial to Python dict failed") - returnmessage = "" + returnmessage = None return returnmessage From 4e6d806c632a0ad9bfb34e9e53fb6ba40df93d42 Mon Sep 17 00:00:00 2001 From: beniroquai Date: Sun, 30 Oct 2022 10:34:12 +0100 Subject: [PATCH 2/4] Bump Version and improving reconnecting features --- uc2rest/__version__.py | 2 +- uc2rest/camera.py | 6 +++--- uc2rest/mserial.py | 8 +++++--- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/uc2rest/__version__.py b/uc2rest/__version__.py index 077015c..50cb015 100644 --- a/uc2rest/__version__.py +++ b/uc2rest/__version__.py @@ -6,7 +6,7 @@ __title__ = 'UC2 REST' __description__ = 'This pacage will help you to drive the ESP32-driven microscopy control modules from UC2' -__version__ = '0.1.0.12' +__version__ = '0.1.0.13' __author__ = 'Benedict Diederich' __author_email__ = 'benedictdied@gmail.com' __license__ = 'GPL v3' diff --git a/uc2rest/camera.py b/uc2rest/camera.py index 359a771..14d60bf 100644 --- a/uc2rest/camera.py +++ b/uc2rest/camera.py @@ -12,7 +12,7 @@ def __init__(self, parent, width=320, height=240, fps=10): self.lastFrame = 255*np.random.randn(self.height, self.width) - def set_camera(self, width=None, height=None, fps=None): + def set_camera(self, width=None, height=None, fps=None, timeout=3): path = "/camera_set" payload = {"path":path, "width":width, @@ -25,7 +25,7 @@ def get_camera(self): path = "/camera_get" return None - def get_frame(self, timeout=10): + def get_frame(self, timeout=5): path = "/camera_act" payload = { "grabimage":1 @@ -42,7 +42,7 @@ def get_frame(self, timeout=10): except Exception as e: self._parent.logger.error(f"Error: {e}") image = None - image = self.lasetFrame + image = self.lastFrame return image diff --git a/uc2rest/mserial.py b/uc2rest/mserial.py index 662ff7a..ee78565 100644 --- a/uc2rest/mserial.py +++ b/uc2rest/mserial.py @@ -14,7 +14,7 @@ def __init__(self, port, baudrate, timeout=1, identity="UC2_Feather", parent=Non self.identity = identity self.NumberRetryReconnect = 0 - self.MaxNumberRetryReconnect = 20 + self.MaxNumberRetryReconnect = 3 self.open() # creates self.serialdevice @@ -107,9 +107,11 @@ def writeSerial(self, payload): """Write JSON document to serial device""" try: if self.serialport == "NotConnected" and self.NumberRetryReconnect Date: Thu, 20 Aug 2026 08:54:42 +0200 Subject: [PATCH 3/4] add speed multiplier to interface --- uc2rest/motor.py | 81 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/uc2rest/motor.py b/uc2rest/motor.py index 6c62584..84fa2c4 100644 --- a/uc2rest/motor.py +++ b/uc2rest/motor.py @@ -1343,6 +1343,87 @@ def get_joystick_direction(self, axis=None, timeout=1): return result return None + def set_speed_multiplier(self, axis, multiplier=1, timeout=1): + ''' + Set the joystick-jog speed multiplier for a specific motor axis. + Scales how fast the stage moves per joystick tick on the device. + + Parameters: + ----------- + axis : int or str + Motor axis (0/"A", 1/"X", 2/"Y", 3/"Z") + multiplier : int or float + Speed multiplier applied on the device for joystick jogging + timeout : int + Command timeout in seconds + + Returns: + -------- + Response from ESP32 + + Example: + -------- + # Triple the joystick jog speed for the X-axis + motor.set_speed_multiplier(axis="X", multiplier=3) + ''' + if type(axis) != int: + axis = self.xyztTo1230(axis) + # {"task":"/motor_act", "speedmult": {"steppers": [{"stepperid": 1, "multiplier": 15}]}} + path = "/motor_act" + payload = { + "task": path, + "speedmult": { + "steppers": [{ + "stepperid": axis, + "multiplier": multiplier + }] + } + } + + r = self._parent.post_json(path, payload, timeout=timeout) + return r + + def get_speed_multiplier(self, axis=None, timeout=1): + ''' + Get the joystick-jog speed multiplier configuration for axes. + + Parameters: + ----------- + axis : int or str, optional + Motor axis (0/"A", 1/"X", 2/"Y", 3/"Z"). If None, returns all axes. + timeout : int + Command timeout in seconds + + Returns: + -------- + int/float or list : Speed multiplier for the specified axis, or all axes + + Example: + -------- + # Get multiplier for X-axis + x_mult = motor.get_speed_multiplier(axis="X") + # Get multiplier for all axes + all_mult = motor.get_speed_multiplier() + ''' + motors = self.get_motors(timeout=timeout) + + if motors and "steppers" in motors: + if axis is not None: + if type(axis) != int: + axis = self.xyztTo1230(axis) + for stepper in motors["steppers"]: + if stepper.get("stepperid") == axis: + return stepper.get("speedMultiplier", 1) + else: + result = [] + for stepper in motors["steppers"]: + result.append({ + "axis": stepper.get("stepperid"), + "multiplier": stepper.get("speedMultiplier", 1) + }) + return result + return None + def get_motor(self, axis=1, timeout=1): path = "/motor_get" payload = { From cf9921e79bdac4313174e94fece5981d95efcc7a Mon Sep 17 00:00:00 2001 From: beniroquai Date: Thu, 20 Aug 2026 08:55:00 +0200 Subject: [PATCH 4/4] Add asyncio UC2 client facade and mock test Introduces `uc2rest.aio` with an opt-in `AsyncUC2Client` wrapper around the synchronous `UC2Client`, running blocking operations via `asyncio.to_thread` and exposing a typed async event stream for serial callback frames (`steppers`, `home`, `emergency`, `message`, etc.). It also adds async convenience methods for motors, homing, illumination, objective control, CAN, galvo, and firmware/system calls, plus cancellation-safe stop behavior. A new hardware-free test (`TEST_aio_mock.py`) verifies MockSerial fallback and confirms raw callback frames are bridged into typed async events. --- uc2rest/TEST/TEST_aio_mock.py | 66 +++++++ uc2rest/aio.py | 360 ++++++++++++++++++++++++++++++++++ 2 files changed, 426 insertions(+) create mode 100644 uc2rest/TEST/TEST_aio_mock.py create mode 100644 uc2rest/aio.py diff --git a/uc2rest/TEST/TEST_aio_mock.py b/uc2rest/TEST/TEST_aio_mock.py new file mode 100644 index 0000000..9a039af --- /dev/null +++ b/uc2rest/TEST/TEST_aio_mock.py @@ -0,0 +1,66 @@ +"""Hardware-free test for uc2rest.aio: MockSerial fallback + typed event bridge. + +Run: python uc2rest/TEST/TEST_aio_mock.py +""" + +import asyncio +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from uc2rest.aio import ( # noqa: E402 + AsyncUC2Client, + EmergencyEvent, + MessageEvent, + SteppersEvent, +) + + +def _inject_frame(client, frame: dict) -> None: + """Push a fake firmware JSON frame through the registered serial callbacks.""" + for entry in client.sync.serial.callBackList: + if entry["pattern"] in frame: + entry["callbackfct"](frame) + + +def test_mock_connect_and_events() -> None: + """Facade constructs on MockSerial and bridges pattern frames to typed events.""" + + async def scenario(): + client = await AsyncUC2Client.create(serialport="auto", skipFirmwareCheck=True) + assert client.is_connected is False # mock fallback, no hardware + assert client.sync.serial.manufacturer == "UC2Mock" + + received = [] + + async def collect(): + async for event in client.events(): + received.append(event) + if len(received) >= 3: + return + + collector = asyncio.create_task(collect()) + await asyncio.sleep(0) + + _inject_frame(client, {"steppers": [{"stepperid": 1, "position": 4200}]}) + _inject_frame(client, {"message": {"key": "btnA", "data": 1}}) + _inject_frame(client, {"emergency": {"active": 1}}) + + await asyncio.wait_for(collector, timeout=2.0) + await client.aclose() + return received + + received = asyncio.run(scenario()) + + steppers = [e for e in received if isinstance(e, SteppersEvent)] + assert len(steppers) == 1 and steppers[0].steppers[0]["position"] == 4200 + messages = [e for e in received if isinstance(e, MessageEvent)] + assert len(messages) == 1 and messages[0].key == "btnA" + emergencies = [e for e in received if isinstance(e, EmergencyEvent)] + assert len(emergencies) == 1 + + +if __name__ == "__main__": + test_mock_connect_and_events() + print("PASS TEST_aio_mock") diff --git a/uc2rest/aio.py b/uc2rest/aio.py new file mode 100644 index 0000000..8c3a367 --- /dev/null +++ b/uc2rest/aio.py @@ -0,0 +1,360 @@ +""" +Asyncio facade for uc2rest. + +Wraps the blocking, thread-based ``UC2Client`` so it can be driven from +asyncio applications (e.g. the newswitch backend) without blocking the event +loop. Every blocking call runs via ``asyncio.to_thread``; firmware events +arriving on the serial read thread (pattern-keyed callbacks such as +``steppers``, ``home``, ``emergency``, ``message``) are bridged onto the event +loop and exposed as a typed async event stream. + +This module is purely additive and opt-in: + +* the synchronous ``UC2Client`` API is unchanged (ImSwitch keeps working), +* it is NOT imported by ``uc2rest/__init__.py`` so the package still imports + on older Pythons; import it explicitly:: + + from uc2rest.aio import AsyncUC2Client + +Requires Python >= 3.9 (``asyncio.to_thread``). +""" + +from __future__ import annotations + +import sys + +if sys.version_info < (3, 9): # pragma: no cover + raise ImportError("uc2rest.aio requires Python >= 3.9 (asyncio.to_thread)") + +import asyncio +import logging +from dataclasses import dataclass, field +from typing import Any, AsyncIterator, Callable, Optional, Union + +from .UC2Client import UC2Client + +_log = logging.getLogger(__name__) + +_DEFAULT_QUEUE_SIZE = 1024 + +# Firmware JSON keys we subscribe to on the serial read thread by default. +DEFAULT_EVENT_PATTERNS = ( + "steppers", # live motor positions + "home", # homing state + "emergency", # e-stop + "message", # key/value events (e.g. hardware buttons) + "gpio", # collision detector + "ptz", # PTZ keyboard / joystick bridge + "laser", # laser status +) + + +# --------------------------------------------------------------------------- +# Typed events +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class SteppersEvent: + """Live stepper positions; ``steppers`` is the raw firmware list + (``[{"stepperid": 1, "position": ...}, ...]``).""" + + steppers: list = field(default_factory=list) + + +@dataclass(frozen=True) +class HomeEvent: + """Homing state update; ``data`` is the raw ``home`` payload.""" + + data: dict = field(default_factory=dict) + + +@dataclass(frozen=True) +class EmergencyEvent: + """Emergency-stop notification from the firmware.""" + + data: dict = field(default_factory=dict) + + +@dataclass(frozen=True) +class MessageEvent: + """Key/value firmware event (hardware button presses etc.).""" + + key: Any = None + data: Any = None + + +@dataclass(frozen=True) +class RawEvent: + """Any subscribed pattern without a dedicated event type; carries the + matched pattern and the full JSON frame.""" + + pattern: str = "" + frame: dict = field(default_factory=dict) + + +SerialEvent = Union[SteppersEvent, HomeEvent, EmergencyEvent, MessageEvent, RawEvent] + +# Firmware axis ids: A=0, X=1, Y=2, Z=3 (matches uc2rest.motor). +AXIS_TO_ID = {"A": 0, "X": 1, "Y": 2, "Z": 3} + + +def _frame_to_event(pattern: str, frame: dict) -> SerialEvent: + """Convert a raw firmware JSON frame into a typed event.""" + if pattern == "steppers": + steppers = frame.get("steppers") + if isinstance(steppers, dict): + steppers = [steppers] + return SteppersEvent(steppers=steppers or []) + if pattern == "home": + return HomeEvent(data=frame.get("home") or frame) + if pattern == "emergency": + return EmergencyEvent(data=frame.get("emergency") or frame) + if pattern == "message": + message = frame.get("message") or {} + return MessageEvent(key=message.get("key"), data=message.get("data")) + return RawEvent(pattern=pattern, frame=frame) + + +# --------------------------------------------------------------------------- +# Async client +# --------------------------------------------------------------------------- + +class AsyncUC2Client: + """Asyncio wrapper around the blocking ``UC2Client`` (serial JSON protocol). + + Construct with ``await AsyncUC2Client.create(serialport=..., ...)`` (same + kwargs as ``UC2Client``). The wrapped sync client is available as ``.sync`` + for the full ~35-submodule API; ``await client.call(fn, ...)`` runs any + blocking callable off-loop. + + Note on threading: ``UC2Client`` serializes port access internally + (write lock + single read thread), so concurrent async calls are safe; + they are simply queued at the serial port. + """ + + def __init__( + self, + client: UC2Client, + loop: Optional[asyncio.AbstractEventLoop] = None, + event_patterns: tuple = DEFAULT_EVENT_PATTERNS, + ): + if not hasattr(client, "serial"): + raise RuntimeError( + "UC2Client has no serial connection; pass serialport= " + "(use serialport='auto' for port auto-discovery)." + ) + self._client = client + self._loop = loop or asyncio.get_running_loop() + self._queues: list[asyncio.Queue] = [] + self._closed = False + + for pattern in event_patterns: + self._client.serial.register_callback( + self._make_pattern_callback(pattern), pattern=pattern + ) + + @classmethod + async def create(cls, event_patterns: tuple = DEFAULT_EVENT_PATTERNS, + **kwargs: Any) -> "AsyncUC2Client": + """Open the serial connection off-loop and return a ready async client. + + Accepts the same keyword arguments as ``UC2Client`` (serialport, + baudrate, identity, device_id, requireMaster, ...). A missing/None + ``serialport`` triggers port auto-discovery (unlike the sync client, + which requires an explicit port to build its serial link at all). + """ + # UC2Client only constructs its Serial when serialport is not None; + # Serial itself falls back to findCorrectSerialDevice() for unknown + # ports, so map None -> "auto" to get auto-discovery. + if kwargs.get("serialport") is None: + kwargs["serialport"] = "auto" + loop = asyncio.get_running_loop() + client = await asyncio.to_thread(lambda: UC2Client(**kwargs)) + return cls(client, loop=loop, event_patterns=event_patterns) + + # -- plumbing ----------------------------------------------------------- + + @property + def sync(self) -> UC2Client: + """The wrapped synchronous client (full uc2rest API surface).""" + return self._client + + @property + def is_connected(self) -> bool: + """Whether the serial link is currently alive.""" + return bool(getattr(self._client, "is_connected", False)) + + async def call(self, fn: Callable[..., Any], /, *args: Any, **kwargs: Any) -> Any: + """Run any blocking callable (e.g. ``client.sync.motor.get_position``) off-loop.""" + return await asyncio.to_thread(fn, *args, **kwargs) + + async def aclose(self) -> None: + """Close the serial connection and stop the read thread.""" + self._closed = True + await asyncio.to_thread(self._client.close) + + async def __aenter__(self) -> "AsyncUC2Client": + return self + + async def __aexit__(self, *args: Any) -> None: + await self.aclose() + + # -- event bridge (serial read thread -> event loop) ---------------------- + + def _make_pattern_callback(self, pattern: str) -> Callable[[dict], None]: + def _callback(frame: dict) -> None: + if self._closed: + return + try: + event = _frame_to_event(pattern, frame) + except Exception as exc: # defensive: firmware frames vary + _log.debug("Could not parse %r frame: %s", pattern, exc) + event = RawEvent(pattern=pattern, frame=frame) + try: + self._loop.call_soon_threadsafe(self._publish_on_loop, event) + except RuntimeError: + pass # loop already closed during shutdown + + return _callback + + def _publish_on_loop(self, event: SerialEvent) -> None: + for queue in list(self._queues): + if queue.full(): + try: + queue.get_nowait() # drop oldest + except asyncio.QueueEmpty: + pass + queue.put_nowait(event) + + async def events(self, queue_size: int = _DEFAULT_QUEUE_SIZE) -> AsyncIterator[SerialEvent]: + """Yield typed firmware events as they arrive. + + Multiple consumers may iterate concurrently; each gets its own queue. + The oldest events are dropped if a consumer falls behind. + """ + queue: asyncio.Queue = asyncio.Queue(maxsize=queue_size) + self._queues.append(queue) + try: + while True: + yield await queue.get() + finally: + self._queues.remove(queue) + + # -- motor ---------------------------------------------------------------- + + async def move_axis(self, axis: str = "X", steps: int = 0, speed: int = 20000, + acceleration: Optional[int] = None, is_absolute: bool = False, + is_blocking: bool = True, timeout: float = 60.0) -> Any: + """Move a named axis ("X"/"Y"/"Z"/"A") by/to ``steps``. + + With ``is_blocking=True`` the firmware-side wait runs in a worker + thread, so awaiting this returns when the motion is done. On + cancellation the axis is stopped before CancelledError propagates. + """ + try: + return await asyncio.to_thread( + self._client.motor.move_axis_by_name, + axis=axis, steps=steps, speed=speed, acceleration=acceleration, + is_absolute=is_absolute, is_blocking=is_blocking, timeout=timeout, + ) + except asyncio.CancelledError: + await asyncio.shield(self.stop(axis=axis)) + raise + + async def stop(self, axis: Optional[str] = None) -> None: + """Stop one axis (or all axes when ``axis`` is None).""" + await asyncio.to_thread(self._client.motor.stop, axis) + + async def get_positions(self, timeout: float = 1.0) -> Any: + """Read all motor positions (steps), as reported by the firmware.""" + return await asyncio.to_thread(self._client.motor.get_position, None, timeout) + + async def home_axis(self, axis: str = "X", speed: Optional[int] = None, + direction: Optional[int] = None, is_blocking: bool = True, + timeout: Optional[float] = None) -> Any: + """Home a named axis; blocking variant waits for completion off-loop.""" + try: + return await asyncio.to_thread( + self._client.home.home, + axis=axis, timeout=timeout, speed=speed, direction=direction, + isBlocking=is_blocking, + ) + except asyncio.CancelledError: + await asyncio.shield( + asyncio.to_thread(self._client.home.stop_home, axis) + ) + raise + + # -- illumination ----------------------------------------------------------- + + async def set_laser(self, channel: int = 1, value: int = 0) -> Any: + """Set a laser/LED PWM channel value.""" + return await asyncio.to_thread(self._client.laser.set_laser, channel, value) + + async def led_fill(self, r: int = 255, g: int = 255, b: int = 255) -> Any: + """Fill the full LED matrix with one colour.""" + return await asyncio.to_thread( + self._client.led.send_LEDMatrix_full, (r, g, b) + ) + + async def led_off(self) -> Any: + """Turn the whole LED matrix off.""" + return await asyncio.to_thread(self._client.led.send_LEDMatrix_full, (0, 0, 0)) + + # -- objective changer -------------------------------------------------------- + + async def objective_move(self, slot: int = 1, is_blocking: bool = True) -> Any: + """Move the objective slider to a slot (1-based).""" + return await asyncio.to_thread( + lambda: self._client.objective.move(slot=slot, isBlocking=is_blocking) + ) + + async def objective_home(self, is_blocking: bool = True) -> Any: + """Home the objective slider.""" + return await asyncio.to_thread( + lambda: self._client.objective.home(isBlocking=is_blocking) + ) + + async def objective_calibrate(self, is_blocking: bool = True) -> Any: + """Calibrate the objective slider end positions.""" + return await asyncio.to_thread( + lambda: self._client.objective.calibrate(isBlocking=is_blocking) + ) + + async def objective_status(self) -> Any: + """Read the objective slider status.""" + return await asyncio.to_thread(self._client.objective.getstatus) + + # -- CAN fleet ---------------------------------------------------------------- + + async def can_scan(self) -> Any: + """Ask the master firmware for the CAN devices it can reach.""" + return await asyncio.to_thread(self._client.can.get_available_devices) + + # -- galvo ------------------------------------------------------------------- + + async def galvo_goto(self, x: int, y: int) -> Any: + """Move the galvo to an absolute XY position (DAC counts).""" + return await asyncio.to_thread(self._client.galvo.set_position, x, y) + + async def galvo_scan(self, **kwargs: Any) -> Any: + """Configure and start a galvo scan (see ``Galvo.set_galvo_scan`` kwargs).""" + return await asyncio.to_thread(lambda: self._client.galvo.set_galvo_scan(**kwargs)) + + async def galvo_stop(self) -> Any: + """Stop any active galvo scan.""" + return await asyncio.to_thread(self._client.galvo.stop_galvo_scan) + + async def galvo_status(self) -> Any: + """Read the galvo status.""" + return await asyncio.to_thread(self._client.galvo.get_galvo_status) + + # -- system -------------------------------------------------------------------- + + async def get_firmware_info(self) -> Any: + """Read firmware identity ({name, version, date, author, pindef, isMaster}).""" + return await asyncio.to_thread(self._client.state.get_firmware_info) + + async def ping(self, timeout: float = 0.5) -> bool: + """Check link liveness.""" + return await asyncio.to_thread(self._client.serial.ping, timeout)