From 50ebcad9d7f10f9ce8bb050e05fc55969876b730 Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Thu, 22 Jan 2026 15:07:32 +0800 Subject: [PATCH 01/56] feat: add ZDT_X42 motor and XKC sensor drivers --- unilabos/devices/motor/ZDT_X42.py | 376 +++++++++++++++++ unilabos/devices/separator/xkc_sensor.py | 379 ++++++++++++++++++ unilabos/registry/devices/motor.yaml | 286 +++++++++++++ unilabos/registry/devices/sensor.yaml | 148 +++++++ .../test/experiments/xkc_sensor_test.json | 29 ++ unilabos/test/experiments/zdt_motor_test.json | 28 ++ 6 files changed, 1246 insertions(+) create mode 100644 unilabos/devices/motor/ZDT_X42.py create mode 100644 unilabos/devices/separator/xkc_sensor.py create mode 100644 unilabos/registry/devices/motor.yaml create mode 100644 unilabos/registry/devices/sensor.yaml create mode 100644 unilabos/test/experiments/xkc_sensor_test.json create mode 100644 unilabos/test/experiments/zdt_motor_test.json diff --git a/unilabos/devices/motor/ZDT_X42.py b/unilabos/devices/motor/ZDT_X42.py new file mode 100644 index 000000000..0d1566c36 --- /dev/null +++ b/unilabos/devices/motor/ZDT_X42.py @@ -0,0 +1,376 @@ +# -*- coding: utf-8 -*- +""" +ZDT X42 Closed-Loop Stepper Motor Driver +RS485 Serial Communication via USB-Serial Converter + +- Baudrate: 115200 +""" + +import serial +import time +import threading +import struct +import logging +from typing import Optional, Any + +try: + from unilabos.device_comms.universal_driver import UniversalDriver +except ImportError: + class UniversalDriver: + def __init__(self, *args, **kwargs): + self.logger = logging.getLogger(self.__class__.__name__) + def execute_command_from_outer(self, command: Any): pass + +from serial.rs485 import RS485Settings + + +class ZDTX42Driver(UniversalDriver): + """ + ZDT X42 闭环步进电机驱动器 + + 支持功能: + - 速度模式运行 + - 位置模式运行 (相对/绝对) + - 位置读取和清零 + - 使能/禁用控制 + + 通信协议: + - 帧格式: [设备ID] [功能码] [数据...] [校验位=0x6B] + - 响应长度根据功能码决定 + """ + + def __init__( + self, + port: str, + baudrate: int = 115200, + device_id: int = 1, + timeout: float = 0.5, + debug: bool = False + ): + """ + 初始化 ZDT X42 电机驱动 + + Args: + port: 串口设备路径 + baudrate: 波特率 (默认 115200) + device_id: 设备地址 (1-255) + timeout: 通信超时时间(秒) + debug: 是否启用调试输出 + """ + super().__init__() + self.id = device_id + self.debug = debug + self.lock = threading.RLock() + self.status = "idle" # 对应注册表中的 status (str) + self.position = 0 # 对应注册表中的 position (int) + + try: + self.ser = serial.Serial( + port=port, + baudrate=baudrate, + timeout=timeout, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE + ) + + # 启用 RS485 模式 + try: + self.ser.rs485_mode = RS485Settings( + rts_level_for_tx=True, + rts_level_for_rx=False + ) + except Exception: + pass # RS485 模式是可选的 + + self.logger.info( + f"ZDT X42 Motor connected: {port} " + f"(Baud: {baudrate}, ID: {device_id})" + ) + # 自动使能电机,确保初始状态可运动 + self.enable(True) + + # 启动背景轮询线程,确保 position 实时刷新 + self._stop_event = threading.Event() + self._polling_thread = threading.Thread( + target=self._update_loop, + name=f"ZDTPolling_{port}", + daemon=True + ) + self._polling_thread.start() + except Exception as e: + self.logger.error(f"Failed to open serial port {port}: {e}") + self.ser = None + + def _update_loop(self): + """背景循环读取电机位置""" + while not self._stop_event.is_set(): + try: + self.get_position() + except Exception as e: + if self.debug: + self.logger.error(f"Polling error: {e}") + time.sleep(1.0) # 每1秒刷新一次位置数据 + + def _send(self, func_code: int, payload: list) -> bytes: + """ + 发送指令并接收响应 + + Args: + func_code: 功能码 + payload: 数据负载 (list of bytes) + + Returns: + 响应数据 (bytes) + """ + if not self.ser: + self.logger.error("Serial port not available") + return b"" + + with self.lock: + # 清空输入缓冲区 + self.ser.reset_input_buffer() + + # 构建消息: [ID] [功能码] [数据...] [校验位=0x6B] + message = bytes([self.id, func_code] + payload + [0x6B]) + + # 发送 + self.ser.write(message) + + # 根据功能码决定响应长度 + # 查询类指令返回 10 字节,控制类指令返回 4 字节 + read_len = 10 if func_code in [0x31, 0x32, 0x35, 0x24, 0x27] else 4 + response = self.ser.read(read_len) + + # 调试输出 + if self.debug: + sent_hex = message.hex().upper() + recv_hex = response.hex().upper() if response else 'TIMEOUT' + print(f"[ID {self.id}] TX: {sent_hex} → RX: {recv_hex}") + + return response + + def enable(self, on: bool = True) -> bool: + """ + 使能/禁用电机 + + Args: + on: True=使能(锁轴), False=禁用(松轴) + + Returns: + 是否成功 + """ + state = 1 if on else 0 + resp = self._send(0xF3, [0xAB, state, 0]) + return len(resp) >= 4 + + def move_speed( + self, + speed_rpm: int, + direction: str = "CW", + acceleration: int = 10 + ) -> bool: + """ + 速度模式运行 + + Args: + speed_rpm: 转速 (RPM) + direction: 方向 ("CW"=顺时针, "CCW"=逆时针) + acceleration: 加速度 (0-255) + + Returns: + 是否成功 + """ + dir_val = 0 if direction.upper() in ["CW", "顺时针"] else 1 + speed_bytes = struct.pack('>H', int(speed_rpm)) + self.status = f"moving@{speed_rpm}rpm" + resp = self._send(0xF6, [dir_val, speed_bytes[0], speed_bytes[1], acceleration, 0]) + return len(resp) >= 4 + + def move_position( + self, + pulses: int, + speed_rpm: int, + direction: str = "CW", + acceleration: int = 10, + absolute: bool = False + ) -> bool: + """ + 位置模式运行 + + Args: + pulses: 脉冲数 + speed_rpm: 转速 (RPM) + direction: 方向 ("CW"=顺时针, "CCW"=逆时针) + acceleration: 加速度 (0-255) + absolute: True=绝对位置, False=相对位置 + + Returns: + 是否成功 + """ + dir_val = 0 if direction.upper() in ["CW", "顺时针"] else 1 + speed_bytes = struct.pack('>H', int(speed_rpm)) + self.status = f"moving_to_{pulses}" + pulse_bytes = struct.pack('>I', int(pulses)) + abs_flag = 1 if absolute else 0 + + payload = [ + dir_val, + speed_bytes[0], speed_bytes[1], + acceleration, + pulse_bytes[0], pulse_bytes[1], pulse_bytes[2], pulse_bytes[3], + abs_flag, + 0 + ] + + resp = self._send(0xFD, payload) + return len(resp) >= 4 + + def stop(self) -> bool: + """ + 停止电机 + + Returns: + 是否成功 + """ + self.status = "idle" + resp = self._send(0xFE, [0x98, 0]) + return len(resp) >= 4 + + def rotate_quarter(self, speed_rpm: int = 60, direction: str = "CW") -> bool: + """ + 电机旋转 1/4 圈 (阻塞式) + 假设电机细分为 3200 脉冲/圈,1/4 圈 = 800 脉冲 + """ + pulses = 800 + success = self.move_position(pulses=pulses, speed_rpm=speed_rpm, direction=direction, absolute=False) + + if success: + # 计算预估旋转时间并进行阻塞等待 (Time = revolutions / (RPM/60)) + # 1/4 rev / (RPM/60) = 15.0 / RPM + estimated_time = 15.0 / max(1, speed_rpm) + time.sleep(estimated_time + 0.5) # 额外给 0.5 秒缓冲 + self.status = "idle" + + return success + + def wait_time(self, duration_s: float) -> bool: + """ + 等待指定时间 (秒) + """ + self.logger.info(f"Waiting for {duration_s} seconds...") + time.sleep(duration_s) + return True + + def set_zero(self) -> bool: + """ + 清零当前位置 + + Returns: + 是否成功 + """ + resp = self._send(0x0A, []) + return len(resp) >= 4 + + def get_position(self) -> Optional[int]: + """ + 读取当前位置 (脉冲数) + + Returns: + 当前位置脉冲数,失败返回 None + """ + resp = self._send(0x32, []) + + if len(resp) >= 8: + # 响应格式: [ID] [Func] [符号位] [数值4字节] [校验] + sign = resp[2] # 0=正, 1=负 + value = struct.unpack('>I', resp[3:7])[0] + self.position = -value if sign == 1 else value + + if self.debug: + print(f"[Position] Raw: {resp.hex().upper()}, Parsed: {self.position}") + + return self.position + + self.logger.warning("Failed to read position") + return None + + def close(self): + """关闭串口连接并停止线程""" + if hasattr(self, '_stop_event'): + self._stop_event.set() + + if self.ser and self.ser.is_open: + self.ser.close() + self.logger.info("Serial port closed") + + +# ============================================================ +# 测试和调试代码 +# ============================================================ + +def test_motor(): + """基础功能测试""" + logging.basicConfig(level=logging.INFO) + + print("="*60) + print("ZDT X42 电机驱动测试") + print("="*60) + + driver = ZDTX42Driver( + port="/dev/tty.usbserial-3110", + baudrate=115200, + device_id=2, + debug=True + ) + + if not driver.ser: + print("❌ 串口打开失败") + return + + try: + # 测试 1: 读取位置 + print("\n[1] 读取当前位置") + pos = driver.get_position() + print(f"✓ 当前位置: {pos} 脉冲") + + # 测试 2: 使能 + print("\n[2] 使能电机") + driver.enable(True) + time.sleep(0.3) + print("✓ 电机已锁定") + + # 测试 3: 相对位置运动 + print("\n[3] 相对位置运动 (1000脉冲)") + driver.move_position(pulses=1000, speed_rpm=60, direction="CW") + time.sleep(2) + pos = driver.get_position() + print(f"✓ 新位置: {pos}") + + # 测试 4: 速度运动 + print("\n[4] 速度模式 (30RPM, 3秒)") + driver.move_speed(speed_rpm=30, direction="CW") + time.sleep(3) + driver.stop() + pos = driver.get_position() + print(f"✓ 停止后位置: {pos}") + + # 测试 5: 禁用 + print("\n[5] 禁用电机") + driver.enable(False) + print("✓ 电机已松开") + + print("\n" + "="*60) + print("✅ 测试完成") + print("="*60) + + except Exception as e: + print(f"\n❌ 测试失败: {e}") + import traceback + traceback.print_exc() + finally: + driver.close() + + +if __name__ == "__main__": + test_motor() diff --git a/unilabos/devices/separator/xkc_sensor.py b/unilabos/devices/separator/xkc_sensor.py new file mode 100644 index 000000000..c954a2e02 --- /dev/null +++ b/unilabos/devices/separator/xkc_sensor.py @@ -0,0 +1,379 @@ +# -*- coding: utf-8 -*- +""" +XKC RS485 液位传感器 (Modbus RTU) + +说明: + 1. 遵循 Modbus-RTU 协议。 + 2. 数据寄存器: 0x0001 (液位状态, 1=有液, 0=无液), 0x0002 (RSSI 信号强度)。 + 3. 地址寄存器: 0x0004 (可读写, 范围 1-254)。 + 4. 波特率寄存器: 0x0005 (可写, 代码表见 change_baudrate 方法)。 +""" + +import struct +import threading +import time +import logging +import serial +from typing import Optional, Dict, Any, List + +from unilabos.device_comms.universal_driver import UniversalDriver + +class TransportManager: + """ + 统一通信管理类。 + 仅支持 串口 (Serial/有线) 连接。 + """ + def __init__(self, port: str, baudrate: int = 9600, timeout: float = 3.0, logger=None): + self.port = port + self.baudrate = baudrate + self.timeout = timeout + self.logger = logger + self.lock = threading.RLock() # 线程锁,确保多设备共用一个连接时不冲突 + + self.serial = None + self._connect_serial() + + def _connect_serial(self): + try: + self.serial = serial.Serial( + port=self.port, + baudrate=self.baudrate, + timeout=self.timeout + ) + except Exception as e: + raise ConnectionError(f"Serial open failed: {e}") + + def close(self): + """关闭连接""" + if self.serial and self.serial.is_open: + self.serial.close() + + def clear_buffer(self): + """清空缓冲区 (Thread-safe)""" + with self.lock: + if self.serial: + self.serial.reset_input_buffer() + + def write(self, data: bytes): + """发送原始字节""" + with self.lock: + if self.serial: + self.serial.write(data) + + def read(self, size: int) -> bytes: + """读取指定长度字节""" + if self.serial: + return self.serial.read(size) + return b'' + +class XKCSensorDriver(UniversalDriver): + """XKC RS485 液位传感器 (Modbus RTU)""" + + def __init__(self, port: str, baudrate: int = 9600, device_id: int = 6, + threshold: int = 300, timeout: float = 3.0, debug: bool = False): + super().__init__() + self.port = port + self.baudrate = baudrate + self.device_id = device_id + self.threshold = threshold + self.timeout = timeout + self.debug = debug + self.level = False + self.rssi = 0 + self.status = {"level": self.level, "rssi": self.rssi} + + try: + self.transport = TransportManager(port, baudrate, timeout, logger=self.logger) + self.logger.info(f"XKCSensorDriver connected to {port} (ID: {device_id})") + except Exception as e: + self.logger.error(f"Failed to connect XKCSensorDriver: {e}") + self.transport = None + + # 启动背景轮询线程,确保 status 实时刷新 + self._stop_event = threading.Event() + self._polling_thread = threading.Thread( + target=self._update_loop, + name=f"XKCPolling_{port}", + daemon=True + ) + if self.transport: + self._polling_thread.start() + + def _update_loop(self): + """背景循环读取传感器数据""" + while not self._stop_event.is_set(): + try: + self.read_level() + except Exception as e: + if self.debug: + self.logger.error(f"Polling error: {e}") + time.sleep(2.0) # 每2秒刷新一次数据 + + def _crc(self, data: bytes) -> bytes: + crc = 0xFFFF + for byte in data: + crc ^= byte + for _ in range(8): + if crc & 0x0001: crc = (crc >> 1) ^ 0xA001 + else: crc >>= 1 + return struct.pack(' Optional[Dict[str, Any]]: + """ + 读取液位。 + 返回: {'level': bool, 'rssi': int} + """ + if not self.transport: + return None + + with self.transport.lock: + self.transport.clear_buffer() + # Modbus Read Registers: 01 03 00 01 00 02 CRC + payload = struct.pack('>HH', 0x0001, 0x0002) + msg = struct.pack('BB', self.device_id, 0x03) + payload + msg += self._crc(msg) + + if self.debug: + self.logger.info(f"TX (ID {self.device_id}): {msg.hex().upper()}") + + self.transport.write(msg) + + # Read header + h = self.transport.read(3) # Addr, Func, Len + if self.debug: + self.logger.info(f"RX Header: {h.hex().upper()}") + + if len(h) < 3: return None + length = h[2] + + # Read body + CRC + body = self.transport.read(length + 2) + if self.debug: + self.logger.info(f"RX Body+CRC: {body.hex().upper()}") + if len(body) < length + 2: + # Firmware bug fix specific to some modules + if len(body) == 4 and length == 4: + pass + else: + return None + + data = body[:-2] + # 根据手册说明: + # 寄存器 0x0001 (data[0:2]): 液位状态 (00 01 为有液, 00 00 为无液) + # 寄存器 0x0002 (data[2:4]): 信号强度 RSSI + + hw_level = False + rssi = 0 + + if len(data) >= 4: + hw_level = ((data[0] << 8) | data[1]) == 1 + rssi = (data[2] << 8) | data[3] + elif len(data) == 2: + # 兼容模式: 某些老固件可能只返回 1 个寄存器 + rssi = (data[0] << 8) | data[1] + hw_level = rssi > self.threshold + else: + return None + + # 最终判定: 优先使用硬件层级的 level 判定,但 RSSI 阈值逻辑作为补充/校验 + # 注意: 如果用户显式设置了 THRESHOLD,我们可以在逻辑中做权衡 + self.level = hw_level or (rssi > self.threshold) + self.rssi = rssi + result = { + 'level': self.level, + 'rssi': self.rssi + } + self.status = result + return result + + def wait_level(self, target_state: bool, timeout: float = 60.0) -> bool: + """ + 等待液位达到目标状态 (阻塞式) + """ + self.logger.info(f"Waiting for level: {target_state}") + start_time = time.time() + while (time.time() - start_time) < timeout: + res = self.read_level() + if res and res.get('level') == target_state: + return True + time.sleep(0.5) + self.logger.warning(f"Wait level timeout ({timeout}s)") + return False + + def wait_for_liquid(self, target_state: bool, timeout: float = 120.0) -> bool: + """ + 实时检测电导率(RSSI)并等待用户指定的“有液”或“无液”状态。 + 一旦检测到符合目标状态,立即返回。 + + Args: + target_state: True 为“有液”, False 为“无液” + timeout: 最大等待时间(秒) + """ + state_str = "有液" if target_state else "无液" + self.logger.info(f"开始实时检测电导率,等待状态: {state_str} (超时: {timeout}s)") + + start_time = time.time() + while (time.time() - start_time) < timeout: + res = self.read_level() # 内部已更新 self.level 和 self.rssi + if res: + current_level = res.get('level') + current_rssi = res.get('rssi') + if current_level == target_state: + self.logger.info(f"✅ 检测到目标状态: {state_str} (当前电导率/RSSI: {current_rssi})") + return True + + if self.debug: + self.logger.debug(f"当前状态: {'有液' if current_level else '无液'}, RSSI: {current_rssi}") + + time.sleep(0.2) # 高频采样 + + self.logger.warning(f"❌ 等待 {state_str} 状态超时 ({timeout}s)") + return False + + def set_threshold(self, threshold: int): + """设置液位判定阈值""" + self.threshold = int(threshold) + self.logger.info(f"Threshold updated to: {self.threshold}") + + def change_device_id(self, new_id: int) -> bool: + """ + 修改设备的 Modbus 从站地址。 + 寄存器: 0x0004, 功能码: 0x06 + """ + if not (1 <= new_id <= 254): + self.logger.error(f"Invalid device ID: {new_id}. Must be 1-254.") + return False + + self.logger.info(f"Changing device ID from {self.device_id} to {new_id}") + success = self._write_single_register(0x0004, new_id) + if success: + self.device_id = new_id # 更新内存中的地址 + self.logger.info(f"Device ID update command sent successfully (target {new_id}).") + return success + + def change_baudrate(self, baud_code: int) -> bool: + """ + 更改通讯波特率 (寄存器: 0x0005)。 + 设置成功后传感器 LED 会闪烁,通常无数据返回。 + + 波特率代码对照表 (16进制): + 05: 2400 + 06: 4800 + 07: 9600 (默认) + 08: 14400 + 09: 19200 + 0A: 28800 + 0C: 57600 + 0D: 115200 + 0E: 128000 + 0F: 256000 + """ + self.logger.info(f"Sending baudrate change command (Code: {baud_code:02X})") + # 写入寄存器 0x0005 + self._write_single_register(0x0005, baud_code) + self.logger.info("Baudrate change command executed. Device LED should flash. Please update connection settings.") + return True + + def factory_reset(self) -> bool: + """ + 恢复出厂设置 (通过广播地址 FF)。 + 设置地址为 01,逻辑为向 0x0004 写入 0x0002 + """ + self.logger.info("Sending factory reset command via broadcast address FF...") + # 广播指令通常无回显 + self._write_single_register(0x0004, 0x0002, slave_id=0xFF) + self.logger.info("Factory reset command sent. Device address should be 01 now.") + return True + + def _write_single_register(self, reg_addr: int, value: int, slave_id: Optional[int] = None) -> bool: + """内部辅助函数: Modbus 功能码 06 写单个寄存器""" + if not self.transport: return False + + target_id = slave_id if slave_id is not None else self.device_id + msg = struct.pack('BBHH', target_id, 0x06, reg_addr, value) + msg += self._crc(msg) + + with self.transport.lock: + self.transport.clear_buffer() + if self.debug: + self.logger.info(f"TX Write (Reg {reg_addr:#06x}): {msg.hex().upper()}") + + self.transport.write(msg) + + # 广播地址、波特率修改或厂家特定指令可能无回显 + if target_id == 0xFF or reg_addr == 0x0005: + time.sleep(0.5) + return True + + # 等待返回 (正常应返回相同报文) + resp = self.transport.read(len(msg)) + if self.debug: + self.logger.info(f"RX Write Response: {resp.hex().upper()}") + + return resp == msg + + def close(self): + if self.transport: + self.transport.close() + +if __name__ == "__main__": + # 快速实例化测试 + import logging + # 减少冗余日志,仅显示重要信息 + logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s') + + # 硬件配置 (根据实际情况修改) + TEST_PORT = "/dev/tty.usbserial-3110" + SLAVE_ID = 1 + THRESHOLD = 300 + + print("\n" + "="*50) + print(f" XKC RS485 传感器独立测试程序") + print(f" 端口: {TEST_PORT} | 地址: {SLAVE_ID} | 阈值: {THRESHOLD}") + print("="*50) + + sensor = XKCSensorDriver(port=TEST_PORT, device_id=SLAVE_ID, threshold=THRESHOLD, debug=False) + + try: + if sensor.transport: + print(f"\n开始实时连续采样测试 (持续 15 秒)...") + print(f"按 Ctrl+C 可提前停止\n") + + start_time = time.time() + duration = 15 + count = 0 + + while time.time() - start_time < duration: + count += 1 + res = sensor.read_level() + if res: + rssi = res['rssi'] + level = res['level'] + status_str = "【有液】" if level else "【无液】" + # 使用 \r 实现单行刷新显示 (或者不刷,直接打印历史) + # 为了方便查看变化,我们直接打印 + elapsed = time.time() - start_time + print(f" [{elapsed:4.1f}s] 采样 {count:<3}: 电导率/RSSI = {rssi:<5} | 判定结果: {status_str}") + else: + print(f" [{time.time()-start_time:4.1f}s] 采样 {count:<3}: 通信失败 (无响应)") + + time.sleep(0.5) # 每秒采样 2 次 + + print(f"\n--- 15 秒采样测试完成 (总计 {count} 次) ---") + + # [3] 测试动态修改阈值 + print(f"\n[3] 动态修改阈值演示...") + new_threshold = 400 + sensor.set_threshold(new_threshold) + res = sensor.read_level() + if res: + print(f" 采样 (当前阈值={new_threshold}): 电导率/RSSI = {res['rssi']:<5} | 判定结果: {'【有液】' if res['level'] else '【无液】'}") + sensor.set_threshold(THRESHOLD) # 还原 + + except KeyboardInterrupt: + print("\n[!] 用户中断测试") + except Exception as e: + print(f"\n[!] 测试运行出错: {e}") + finally: + sensor.close() + print("\n--- 测试程序已退出 ---\n") diff --git a/unilabos/registry/devices/motor.yaml b/unilabos/registry/devices/motor.yaml new file mode 100644 index 000000000..7b603ae53 --- /dev/null +++ b/unilabos/registry/devices/motor.yaml @@ -0,0 +1,286 @@ +motor.zdt_x42: + category: + - motor + class: + action_value_mappings: + auto-enable: + feedback: {} + goal: {} + goal_default: + 'on': true + handles: {} + placeholder_keys: {} + result: {} + schema: + description: 使能或禁用电机。使能后电机进入锁轴状态,可接收运动指令;禁用后电机进入松轴状态。 + properties: + feedback: {} + goal: + properties: + 'on': + default: true + type: boolean + required: [] + type: object + result: {} + required: + - goal + title: enable参数 + type: object + type: UniLabJsonCommand + auto-get_position: + feedback: {} + goal: {} + goal_default: {} + handles: {} + placeholder_keys: {} + result: {} + schema: + description: 获取当前电机脉冲位置。 + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: + properties: + position: + type: integer + type: object + required: + - goal + title: get_position参数 + type: object + type: UniLabJsonCommand + auto-move_position: + feedback: {} + goal: {} + goal_default: + absolute: false + acceleration: 10 + direction: CW + pulses: 1000 + speed_rpm: 60 + handles: {} + placeholder_keys: {} + result: {} + schema: + description: 位置模式运行。控制电机移动到指定脉冲位置或相对于当前位置移动指定脉冲数。 + properties: + feedback: {} + goal: + properties: + absolute: + default: false + type: boolean + acceleration: + default: 10 + maximum: 255 + minimum: 0 + type: integer + direction: + default: CW + enum: + - CW + - CCW + type: string + pulses: + default: 1000 + type: integer + speed_rpm: + default: 60 + minimum: 0 + type: integer + required: + - pulses + - speed_rpm + type: object + result: {} + required: + - goal + title: move_position参数 + type: object + type: UniLabJsonCommand + auto-move_speed: + feedback: {} + goal: {} + goal_default: + acceleration: 10 + direction: CW + speed_rpm: 60 + handles: {} + placeholder_keys: {} + result: {} + schema: + description: 速度模式运行。控制电机以指定转速和方向持续转动。 + properties: + feedback: {} + goal: + properties: + acceleration: + default: 10 + maximum: 255 + minimum: 0 + type: integer + direction: + default: CW + enum: + - CW + - CCW + type: string + speed_rpm: + default: 60 + minimum: 0 + type: integer + required: + - speed_rpm + type: object + result: {} + required: + - goal + title: move_speed参数 + type: object + type: UniLabJsonCommand + auto-rotate_quarter: + feedback: {} + goal: {} + goal_default: + direction: CW + speed_rpm: 60 + handles: {} + placeholder_keys: {} + result: {} + schema: + description: 电机旋转 1/4 圈 (阻塞式)。 + properties: + feedback: {} + goal: + properties: + direction: + default: CW + enum: + - CW + - CCW + type: string + speed_rpm: + default: 60 + minimum: 1 + type: integer + required: [] + type: object + result: {} + required: + - goal + title: rotate_quarter参数 + type: object + type: UniLabJsonCommand + auto-set_zero: + feedback: {} + goal: {} + goal_default: {} + handles: {} + placeholder_keys: {} + result: {} + schema: + description: 将当前电机位置设为零点。 + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: {} + required: + - goal + title: set_zero参数 + type: object + type: UniLabJsonCommand + auto-stop: + feedback: {} + goal: {} + goal_default: {} + handles: {} + placeholder_keys: {} + result: {} + schema: + description: 立即停止电机运动。 + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: {} + required: + - goal + title: stop参数 + type: object + type: UniLabJsonCommand + auto-wait_time: + feedback: {} + goal: {} + goal_default: + duration_s: 1.0 + handles: {} + placeholder_keys: {} + result: {} + schema: + description: 等待指定时间 (秒)。 + properties: + feedback: {} + goal: + properties: + duration_s: + default: 1.0 + minimum: 0 + type: number + required: + - duration_s + type: object + result: {} + required: + - goal + title: wait_time参数 + type: object + type: UniLabJsonCommand + module: unilabos.devices.motor.ZDT_X42:ZDTX42Driver + status_types: + position: int + status: str + type: python + config_info: [] + description: ZDT X42 闭环步进电机驱动。支持速度运行、精确位置控制、位置查询和清零功能。适用于各种需要精确运动控制的实验室自动化场景。 + handles: [] + icon: '' + init_param_schema: + config: + properties: + baudrate: + default: 115200 + type: integer + debug: + default: false + type: boolean + device_id: + default: 1 + type: integer + port: + type: string + timeout: + default: 0.5 + type: number + required: + - port + type: object + data: + properties: + position: + type: integer + status: + type: string + required: + - status + - position + type: object + version: 1.0.0 diff --git a/unilabos/registry/devices/sensor.yaml b/unilabos/registry/devices/sensor.yaml new file mode 100644 index 000000000..81d05b0fb --- /dev/null +++ b/unilabos/registry/devices/sensor.yaml @@ -0,0 +1,148 @@ +sensor.xkc_rs485: + category: + - sensor + - separator + class: + action_value_mappings: + auto-change_baudrate: + goal: + baud_code: 7 + handles: {} + schema: + description: '更改通讯波特率 (设置成功后无返回,且需手动切换波特率重连)。代码表 (16进制): 05=2400, 06=4800, + 07=9600, 08=14400, 09=19200, 0A=28800, 0C=57600, 0D=115200, 0E=128000, + 0F=256000' + properties: + goal: + properties: + baud_code: + description: '波特率代码 (例如: 7 为 9600, 13 即 0x0D 为 115200)' + type: integer + required: + - baud_code + type: object + type: UniLabJsonCommand + auto-change_device_id: + goal: + new_id: 1 + handles: {} + schema: + description: 修改传感器的 Modbus 从站地址 + properties: + goal: + properties: + new_id: + description: 新的从站地址 (1-254) + maximum: 254 + minimum: 1 + type: integer + required: + - new_id + type: object + type: UniLabJsonCommand + auto-factory_reset: + goal: {} + handles: {} + schema: + description: 恢复出厂设置 (地址重置为 01) + properties: + goal: + type: object + type: UniLabJsonCommand + auto-read_level: + goal: {} + handles: {} + schema: + description: 直接读取当前液位及信号强度 + properties: + goal: + type: object + type: object + type: UniLabJsonCommand + auto-set_threshold: + goal: + threshold: 300 + handles: {} + schema: + description: 设置液位判定阈值 + properties: + goal: + properties: + threshold: + type: integer + required: + - threshold + type: object + type: UniLabJsonCommand + auto-wait_for_liquid: + goal: + target_state: true + timeout: 120 + handles: {} + schema: + description: 实时检测电导率(RSSI)并等待用户指定的状态 + properties: + goal: + properties: + target_state: + default: true + description: 目标状态 (True=有液, False=无液) + type: boolean + timeout: + default: 120 + description: 超时时间 (秒) + required: + - target_state + type: object + type: UniLabJsonCommand + auto-wait_level: + goal: + level: true + timeout: 10 + handles: {} + schema: + description: 等待液位达到目标状态 + properties: + goal: + properties: + level: + type: boolean + timeout: + type: number + required: + - level + type: object + type: UniLabJsonCommand + module: unilabos.devices.separator.xkc_sensor:XKCSensorDriver + status_types: + level: bool + rssi: int + type: python + config_info: [] + description: XKC RS485 非接触式液位传感器 (Modbus RTU) + handles: [] + icon: '' + init_param_schema: + config: + properties: + baudrate: + default: 9600 + type: integer + debug: + default: false + type: boolean + device_id: + default: 1 + type: integer + port: + type: string + threshold: + default: 300 + type: integer + timeout: + default: 3.0 + type: number + required: + - port + type: object + version: 1.0.0 diff --git a/unilabos/test/experiments/xkc_sensor_test.json b/unilabos/test/experiments/xkc_sensor_test.json new file mode 100644 index 000000000..ef50ddefb --- /dev/null +++ b/unilabos/test/experiments/xkc_sensor_test.json @@ -0,0 +1,29 @@ +{ + "nodes": [ + { + "id": "Liquid_Sensor_1", + "name": "XKC Sensor", + "children": [], + "parent": null, + "type": "device", + "class": "sensor.xkc_rs485", + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "config": { + "port": "/dev/tty.usbserial-3110", + "baudrate": 9600, + "device_id": 1, + "threshold": 300, + "timeout": 3.0 + }, + "data": { + "level": false, + "rssi": 0 + } + } + ], + "links": [] +} \ No newline at end of file diff --git a/unilabos/test/experiments/zdt_motor_test.json b/unilabos/test/experiments/zdt_motor_test.json new file mode 100644 index 000000000..692e40ef4 --- /dev/null +++ b/unilabos/test/experiments/zdt_motor_test.json @@ -0,0 +1,28 @@ +{ + "nodes": [ + { + "id": "ZDT_Motor", + "name": "ZDT Motor", + "children": [], + "parent": null, + "type": "device", + "class": "motor.zdt_x42", + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "config": { + "port": "/dev/tty.usbserial-3110", + "baudrate": 115200, + "device_id": 1, + "debug": true + }, + "data": { + "position": 0, + "status": "idle" + } + } + ], + "links": [] +} \ No newline at end of file From e11070315dcbdbc7b5dbd522b9d447b3c5b91d5d Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Mon, 26 Jan 2026 23:34:47 +0800 Subject: [PATCH 02/56] feat: add separation_step with sensor-motor linkage --- unilabos/devices/separator/chinwe.py | 102 ++++++++++++++++++++++++++ unilabos/registry/devices/chinwe.yaml | 41 +++++++++++ 2 files changed, 143 insertions(+) diff --git a/unilabos/devices/separator/chinwe.py b/unilabos/devices/separator/chinwe.py index 8beac447f..7adfdbe4c 100644 --- a/unilabos/devices/separator/chinwe.py +++ b/unilabos/devices/separator/chinwe.py @@ -623,6 +623,108 @@ def wait_time(self, duration: int) -> bool: time.sleep(duration) return True + def separation_step(self, motor_id: int = 5, speed: int = 60, pulses: int = 700, + max_cycles: int = 0, timeout: int = 300) -> bool: + """ + 分液步骤 - 液位传感器与电机联动 + 当液位传感器检测到"有液"时,电机顺时针旋转指定脉冲数 + 当液位传感器检测到"无液"时,电机逆时针旋转指定脉冲数 + + :param motor_id: 电机ID (必须在初始化时配置的motor_ids中) + :param speed: 电机转速 (RPM) + :param pulses: 每次旋转的脉冲数 (默认700约为1/4圈,假设3200脉冲/圈) + :param max_cycles: 最大执行循环次数 (0=无限制,默认0) + :param timeout: 整体超时时间 (秒) + :return: 成功返回True,超时或失败返回False + """ + motor_id = int(motor_id) + speed = int(speed) + pulses = int(pulses) + max_cycles = int(max_cycles) + timeout = int(timeout) + + # 检查电机是否存在 + if motor_id not in self.motors: + self.logger.error(f"Motor {motor_id} not found in configured motors: {list(self.motors.keys())}") + return False + + # 检查传感器是否可用 + if not self.sensor: + self.logger.error("Sensor not initialized") + return False + + motor = self.motors[motor_id] + + # 使能电机 + self.logger.info(f"Enabling motor {motor_id}...") + motor.enable(True) + time.sleep(0.2) + + self.logger.info(f"Starting separation step: motor_id={motor_id}, speed={speed} RPM, " + f"pulses={pulses}, max_cycles={max_cycles}, timeout={timeout}s") + + # 记录上一次的液位状态 + last_level = None + cycle_count = 0 + start_time = time.time() + error_count = 0 + + while True: + # 检查超时 + if time.time() - start_time > timeout: + self.logger.warning(f"Separation step timeout after {timeout} seconds") + return False + + # 检查循环次数限制 + if max_cycles > 0 and cycle_count >= max_cycles: + self.logger.info(f"Separation step completed: reached max_cycles={max_cycles}") + return True + + # 读取传感器数据 + data = self.sensor.read_level() + + if data is None: + error_count += 1 + if error_count > 5: + self.logger.warning("Sensor read failed multiple times, retrying...") + error_count = 0 + time.sleep(0.5) + continue + + error_count = 0 + current_level = data['level'] + rssi = data['rssi'] + + # 检测状态变化 + if current_level != last_level and last_level is not None: + cycle_count += 1 + + if current_level: + # 有液 -> 电机顺时针旋转 + self.logger.info(f"[Cycle {cycle_count}] Liquid detected (RSSI={rssi}), " + f"rotating motor {motor_id} clockwise {pulses} pulses") + motor.run_position(pulses=pulses, speed_rpm=speed, direction=0, absolute=False) + + # 等待电机完成 (预估时间) + estimated_time = 15.0 / max(1, speed) + time.sleep(estimated_time + 0.5) + + else: + # 无液 -> 电机逆时针旋转 + self.logger.info(f"[Cycle {cycle_count}] No liquid detected (RSSI={rssi}), " + f"rotating motor {motor_id} counter-clockwise {pulses} pulses") + motor.run_position(pulses=pulses, speed_rpm=speed, direction=1, absolute=False) + + # 等待电机完成 (预估时间) + estimated_time = 15.0 / max(1, speed) + time.sleep(estimated_time + 0.5) + + # 更新状态 + last_level = current_level + + # 轮询间隔 + time.sleep(0.1) + def execute_command_from_outer(self, command_dict: Dict[str, Any]) -> bool: """支持标准 JSON 指令调用""" return super().execute_command_from_outer(command_dict) diff --git a/unilabos/registry/devices/chinwe.yaml b/unilabos/registry/devices/chinwe.yaml index 2078d0f0b..468fc27c7 100644 --- a/unilabos/registry/devices/chinwe.yaml +++ b/unilabos/registry/devices/chinwe.yaml @@ -317,6 +317,47 @@ separator.chinwe: - port type: object type: UniLabJsonCommand + separation_step: + goal: + max_cycles: 0 + motor_id: 5 + pulses: 700 + speed: 60 + timeout: 300 + handles: {} + schema: + description: 分液步骤 - 液位传感器与电机联动 (有液→顺时针, 无液→逆时针) + properties: + goal: + properties: + max_cycles: + default: 0 + description: 最大循环次数 (0=无限制) + type: integer + motor_id: + default: '5' + description: 选择电机 + enum: + - '4' + - '5' + title: '注: 4=搅拌, 5=旋钮' + type: string + pulses: + default: 700 + description: 每次旋转脉冲数 (约1/4圈) + type: integer + speed: + default: 60 + description: 电机转速 (RPM) + type: integer + timeout: + default: 300 + description: 超时时间 (秒) + type: integer + required: + - motor_id + type: object + type: UniLabJsonCommand wait_sensor_level: goal: target_state: 有液 From c4a3be149847438165388c23df9264a5ab7d67c6 Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Tue, 27 Jan 2026 12:37:09 +0800 Subject: [PATCH 03/56] feat: enhance separation_step logic with polling thread management and error handling --- unilabos/devices/separator/chinwe.py | 119 +++++++++++++++------------ 1 file changed, 65 insertions(+), 54 deletions(-) diff --git a/unilabos/devices/separator/chinwe.py b/unilabos/devices/separator/chinwe.py index 7adfdbe4c..b8c36a727 100644 --- a/unilabos/devices/separator/chinwe.py +++ b/unilabos/devices/separator/chinwe.py @@ -655,6 +655,12 @@ def separation_step(self, motor_id: int = 5, speed: int = 60, pulses: int = 700, motor = self.motors[motor_id] + # 停止轮询线程,避免与 separation_step 同时读取传感器造成串口冲突 + self.logger.info("Stopping polling thread for separation_step...") + self._stop_event.set() + if self._poll_thread and self._poll_thread.is_alive(): + self._poll_thread.join(timeout=2.0) + # 使能电机 self.logger.info(f"Enabling motor {motor_id}...") motor.enable(True) @@ -669,61 +675,66 @@ def separation_step(self, motor_id: int = 5, speed: int = 60, pulses: int = 700, start_time = time.time() error_count = 0 - while True: - # 检查超时 - if time.time() - start_time > timeout: - self.logger.warning(f"Separation step timeout after {timeout} seconds") - return False - - # 检查循环次数限制 - if max_cycles > 0 and cycle_count >= max_cycles: - self.logger.info(f"Separation step completed: reached max_cycles={max_cycles}") - return True - - # 读取传感器数据 - data = self.sensor.read_level() - - if data is None: - error_count += 1 - if error_count > 5: - self.logger.warning("Sensor read failed multiple times, retrying...") - error_count = 0 - time.sleep(0.5) - continue - - error_count = 0 - current_level = data['level'] - rssi = data['rssi'] - - # 检测状态变化 - if current_level != last_level and last_level is not None: - cycle_count += 1 - - if current_level: - # 有液 -> 电机顺时针旋转 - self.logger.info(f"[Cycle {cycle_count}] Liquid detected (RSSI={rssi}), " - f"rotating motor {motor_id} clockwise {pulses} pulses") - motor.run_position(pulses=pulses, speed_rpm=speed, direction=0, absolute=False) - - # 等待电机完成 (预估时间) - estimated_time = 15.0 / max(1, speed) - time.sleep(estimated_time + 0.5) - - else: - # 无液 -> 电机逆时针旋转 - self.logger.info(f"[Cycle {cycle_count}] No liquid detected (RSSI={rssi}), " - f"rotating motor {motor_id} counter-clockwise {pulses} pulses") - motor.run_position(pulses=pulses, speed_rpm=speed, direction=1, absolute=False) - - # 等待电机完成 (预估时间) - estimated_time = 15.0 / max(1, speed) - time.sleep(estimated_time + 0.5) - - # 更新状态 - last_level = current_level + try: + while True: + # 检查超时 + if time.time() - start_time > timeout: + self.logger.warning(f"Separation step timeout after {timeout} seconds") + return False + + # 检查循环次数限制 + if max_cycles > 0 and cycle_count >= max_cycles: + self.logger.info(f"Separation step completed: reached max_cycles={max_cycles}") + return True + + # 读取传感器数据 + data = self.sensor.read_level() - # 轮询间隔 - time.sleep(0.1) + if data is None: + error_count += 1 + if error_count > 5: + self.logger.warning("Sensor read failed multiple times, retrying...") + error_count = 0 + time.sleep(0.5) + continue + + error_count = 0 + current_level = data['level'] + rssi = data['rssi'] + + # 检测状态变化 (包括首次检测) + if current_level != last_level: + cycle_count += 1 + + if current_level: + # 有液 -> 电机顺时针旋转 + self.logger.info(f"[Cycle {cycle_count}] Liquid detected (RSSI={rssi}), " + f"rotating motor {motor_id} clockwise {pulses} pulses") + motor.run_position(pulses=pulses, speed_rpm=speed, direction=0, absolute=False) + + # 等待电机完成 (预估时间) + estimated_time = 15.0 / max(1, speed) + time.sleep(estimated_time + 0.5) + + else: + # 无液 -> 电机逆时针旋转 + self.logger.info(f"[Cycle {cycle_count}] No liquid detected (RSSI={rssi}), " + f"rotating motor {motor_id} counter-clockwise {pulses} pulses") + motor.run_position(pulses=pulses, speed_rpm=speed, direction=1, absolute=False) + + # 等待电机完成 (预估时间) + estimated_time = 15.0 / max(1, speed) + time.sleep(estimated_time + 0.5) + + # 更新状态 + last_level = current_level + + # 轮询间隔 + time.sleep(0.1) + finally: + # 恢复轮询线程 + self.logger.info("Restarting polling thread...") + self._start_polling() def execute_command_from_outer(self, command_dict: Dict[str, Any]) -> bool: """支持标准 JSON 指令调用""" From 6bf57f18c17cc25382468cc40e2ab78d9fb09052 Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Thu, 29 Jan 2026 11:29:38 +0800 Subject: [PATCH 04/56] Collaboration With Cursor --- .cursor/rules/device-drivers.mdc | 328 +++++++++++++++++++++++ .cursor/rules/protocol-development.mdc | 240 +++++++++++++++++ .cursor/rules/registry-config.mdc | 319 ++++++++++++++++++++++ .cursor/rules/ros-integration.mdc | 233 ++++++++++++++++ .cursor/rules/testing-patterns.mdc | 357 +++++++++++++++++++++++++ .cursor/rules/unilabos-project.mdc | 353 ++++++++++++++++++++++++ .cursorignore | 188 +++++++++++++ .gitignore | 1 - 8 files changed, 2018 insertions(+), 1 deletion(-) create mode 100644 .cursor/rules/device-drivers.mdc create mode 100644 .cursor/rules/protocol-development.mdc create mode 100644 .cursor/rules/registry-config.mdc create mode 100644 .cursor/rules/ros-integration.mdc create mode 100644 .cursor/rules/testing-patterns.mdc create mode 100644 .cursor/rules/unilabos-project.mdc create mode 100644 .cursorignore diff --git a/.cursor/rules/device-drivers.mdc b/.cursor/rules/device-drivers.mdc new file mode 100644 index 000000000..8adfb33c1 --- /dev/null +++ b/.cursor/rules/device-drivers.mdc @@ -0,0 +1,328 @@ +--- +description: 设备驱动开发规范 +globs: ["unilabos/devices/**/*.py"] +--- + +# 设备驱动开发规范 + +## 目录结构 + +``` +unilabos/devices/ +├── virtual/ # 虚拟设备(用于测试) +│ ├── virtual_stirrer.py +│ └── virtual_centrifuge.py +├── liquid_handling/ # 液体处理设备 +├── balance/ # 天平设备 +├── hplc/ # HPLC设备 +├── pump_and_valve/ # 泵和阀门 +├── temperature/ # 温度控制设备 +├── workstation/ # 工作站(组合设备) +└── ... +``` + +## 设备类完整模板 + +```python +import asyncio +import logging +import time as time_module +from typing import Dict, Any, Optional + +from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode + + +class MyDevice: + """ + 设备类描述 + + Attributes: + device_id: 设备唯一标识 + config: 设备配置字典 + data: 设备状态数据 + """ + + _ros_node: BaseROS2DeviceNode + + def __init__( + self, + device_id: str = None, + config: Dict[str, Any] = None, + **kwargs + ): + """ + 初始化设备 + + Args: + device_id: 设备ID + config: 配置字典 + **kwargs: 其他参数 + """ + # 兼容不同调用方式 + if device_id is None and 'id' in kwargs: + device_id = kwargs.pop('id') + if config is None and 'config' in kwargs: + config = kwargs.pop('config') + + self.device_id = device_id or "unknown_device" + self.config = config or {} + self.data = {} + + # 从config读取参数 + self.port = self.config.get('port') or kwargs.get('port', 'COM1') + self._max_value = self.config.get('max_value', 1000.0) + + # 初始化日志 + self.logger = logging.getLogger(f"MyDevice.{self.device_id}") + + self.logger.info(f"设备 {self.device_id} 已创建") + + def post_init(self, ros_node: BaseROS2DeviceNode): + """ + ROS节点注入 - 在ROS节点创建后调用 + + Args: + ros_node: ROS2设备节点实例 + """ + self._ros_node = ros_node + + async def initialize(self) -> bool: + """ + 初始化设备 - 连接硬件、设置初始状态 + + Returns: + bool: 初始化是否成功 + """ + self.logger.info(f"初始化设备 {self.device_id}") + + try: + # 执行硬件初始化 + # await self._connect_hardware() + + # 设置初始状态 + self.data.update({ + "status": "待机", + "is_running": False, + "current_value": 0.0, + }) + + self.logger.info(f"设备 {self.device_id} 初始化完成") + return True + + except Exception as e: + self.logger.error(f"初始化失败: {e}") + self.data["status"] = f"错误: {e}" + return False + + async def cleanup(self) -> bool: + """ + 清理设备 - 断开连接、释放资源 + + Returns: + bool: 清理是否成功 + """ + self.logger.info(f"清理设备 {self.device_id}") + + self.data.update({ + "status": "离线", + "is_running": False, + }) + + return True + + # ==================== 设备动作 ==================== + + async def execute_action( + self, + param1: float, + param2: str = "", + **kwargs + ) -> bool: + """ + 执行设备动作 + + Args: + param1: 参数1 + param2: 参数2(可选) + + Returns: + bool: 动作是否成功 + """ + # 类型转换和验证 + try: + param1 = float(param1) + except (ValueError, TypeError) as e: + self.logger.error(f"参数类型错误: {e}") + return False + + # 参数验证 + if param1 > self._max_value: + self.logger.error(f"参数超出范围: {param1} > {self._max_value}") + return False + + self.logger.info(f"执行动作: param1={param1}, param2={param2}") + + # 更新状态 + self.data.update({ + "status": "运行中", + "is_running": True, + }) + + # 执行动作(带进度反馈) + duration = 10.0 # 秒 + start_time = time_module.time() + + while True: + elapsed = time_module.time() - start_time + remaining = max(0, duration - elapsed) + progress = min(100, (elapsed / duration) * 100) + + self.data.update({ + "status": f"运行中: {progress:.0f}%", + "remaining_time": remaining, + }) + + if remaining <= 0: + break + + await self._ros_node.sleep(1.0) + + # 完成 + self.data.update({ + "status": "完成", + "is_running": False, + }) + + self.logger.info("动作执行完成") + return True + + # ==================== 状态属性 ==================== + + @property + def status(self) -> str: + """设备状态 - 自动发布为ROS Topic""" + return self.data.get("status", "未知") + + @property + def is_running(self) -> bool: + """是否正在运行""" + return self.data.get("is_running", False) + + @property + def current_value(self) -> float: + """当前值""" + return self.data.get("current_value", 0.0) + + # ==================== 辅助方法 ==================== + + def get_device_info(self) -> Dict[str, Any]: + """获取设备信息""" + return { + "device_id": self.device_id, + "status": self.status, + "is_running": self.is_running, + "current_value": self.current_value, + } + + def __str__(self) -> str: + return f"MyDevice({self.device_id}: {self.status})" +``` + +## 关键规则 + +### 1. 参数处理 + +所有动作方法的参数都可能以字符串形式传入,必须进行类型转换: + +```python +async def my_action(self, value: float, **kwargs) -> bool: + # 始终进行类型转换 + try: + value = float(value) + except (ValueError, TypeError) as e: + self.logger.error(f"参数类型错误: {e}") + return False +``` + +### 2. vessel 参数处理 + +vessel 参数可能是字符串ID或字典: + +```python +def extract_vessel_id(vessel: Union[str, dict]) -> str: + if isinstance(vessel, dict): + return vessel.get("id", "") + return str(vessel) if vessel else "" +``` + +### 3. 状态更新 + +使用 `self.data` 字典存储状态,属性读取状态: + +```python +# 更新状态 +self.data["status"] = "运行中" +self.data["current_speed"] = 300.0 + +# 读取状态(通过属性) +@property +def status(self) -> str: + return self.data.get("status", "待机") +``` + +### 4. 异步等待 + +使用 ROS 节点的 sleep 方法: + +```python +# 正确 +await self._ros_node.sleep(1.0) + +# 避免(除非在纯 Python 测试环境) +await asyncio.sleep(1.0) +``` + +### 5. 进度反馈 + +长时间运行的操作需要提供进度反馈: + +```python +while remaining > 0: + progress = (elapsed / total_time) * 100 + self.data["status"] = f"运行中: {progress:.0f}%" + self.data["remaining_time"] = remaining + + await self._ros_node.sleep(1.0) +``` + +## 虚拟设备 + +虚拟设备用于测试和演示,放在 `unilabos/devices/virtual/` 目录: + +- 类名以 `Virtual` 开头 +- 文件名以 `virtual_` 开头 +- 模拟真实设备的行为和时序 +- 使用表情符号增强日志可读性(可选) + +## 工作站设备 + +工作站是组合多个设备的复杂设备: + +```python +from unilabos.devices.workstation.workstation_base import WorkstationBase + +class MyWorkstation(WorkstationBase): + """组合工作站""" + + async def execute_workflow(self, workflow: Dict[str, Any]) -> bool: + """执行工作流""" + pass +``` + +## 设备注册 + +设备类开发完成后,需要在注册表中注册: + +1. 创建/编辑 `unilabos/registry/devices/my_category.yaml` +2. 添加设备配置(参考 `virtual_device.yaml`) +3. 运行 `--complete_registry` 自动生成 schema diff --git a/.cursor/rules/protocol-development.mdc b/.cursor/rules/protocol-development.mdc new file mode 100644 index 000000000..a94f947d8 --- /dev/null +++ b/.cursor/rules/protocol-development.mdc @@ -0,0 +1,240 @@ +--- +description: 协议编译器开发规范 +globs: ["unilabos/compile/**/*.py"] +--- + +# 协议编译器开发规范 + +## 概述 + +协议编译器负责将高级实验操作(如 Stir、Add、Filter)编译为设备可执行的动作序列。 + +## 文件命名 + +- 位置: `unilabos/compile/` +- 命名: `{operation}_protocol.py` +- 示例: `stir_protocol.py`, `add_protocol.py`, `filter_protocol.py` + +## 协议函数模板 + +```python +from typing import List, Dict, Any, Union +import networkx as nx +import logging + +from .utils.unit_parser import parse_time_input +from .utils.vessel_parser import extract_vessel_id + +logger = logging.getLogger(__name__) + + +def generate_{operation}_protocol( + G: nx.DiGraph, + vessel: Union[str, dict], + param1: Union[str, float] = "0", + param2: float = 0.0, + **kwargs +) -> List[Dict[str, Any]]: + """ + 生成{操作}协议序列 + + Args: + G: 物理拓扑图 (NetworkX DiGraph) + vessel: 容器ID或Resource字典 + param1: 参数1(支持字符串单位,如 "5 min") + param2: 参数2 + **kwargs: 其他参数 + + Returns: + List[Dict]: 动作序列 + + Raises: + ValueError: 参数无效时 + """ + # 1. 提取 vessel_id + vessel_id = extract_vessel_id(vessel) + + # 2. 验证参数 + if not vessel_id: + raise ValueError("vessel 参数不能为空") + + if vessel_id not in G.nodes(): + raise ValueError(f"容器 '{vessel_id}' 不存在于系统中") + + # 3. 解析参数(支持单位) + parsed_param1 = parse_time_input(param1) # "5 min" -> 300.0 + + # 4. 查找设备 + device_id = find_connected_device(G, vessel_id, device_type="my_device") + + # 5. 生成动作序列 + action_sequence = [] + + action = { + "device_id": device_id, + "action_name": "my_action", + "action_kwargs": { + "vessel": {"id": vessel_id}, # 始终使用字典格式 + "param1": float(parsed_param1), + "param2": float(param2), + } + } + action_sequence.append(action) + + logger.info(f"生成协议: {len(action_sequence)} 个动作") + return action_sequence + + +def find_connected_device( + G: nx.DiGraph, + vessel_id: str, + device_type: str = "" +) -> str: + """ + 查找与容器相连的设备 + + Args: + G: 拓扑图 + vessel_id: 容器ID + device_type: 设备类型关键字 + + Returns: + str: 设备ID + """ + # 查找所有匹配类型的设备 + device_nodes = [] + for node in G.nodes(): + node_class = G.nodes[node].get('class', '') or '' + if device_type.lower() in node_class.lower(): + device_nodes.append(node) + + # 检查连接 + if vessel_id and device_nodes: + for device in device_nodes: + if G.has_edge(device, vessel_id) or G.has_edge(vessel_id, device): + return device + + # 返回第一个可用设备 + if device_nodes: + return device_nodes[0] + + # 默认设备 + return f"{device_type}_1" +``` + +## 关键规则 + +### 1. vessel 参数处理 + +vessel 参数可能是字符串或字典,需要统一处理: + +```python +def extract_vessel_id(vessel: Union[str, dict]) -> str: + """提取vessel_id""" + if isinstance(vessel, dict): + # 可能是 {"id": "xxx"} 或完整 Resource 对象 + return vessel.get("id", list(vessel.values())[0].get("id", "")) + return str(vessel) if vessel else "" +``` + +### 2. action_kwargs 中的 vessel + +始终使用 `{"id": vessel_id}` 格式传递 vessel: + +```python +# 正确 +"action_kwargs": { + "vessel": {"id": vessel_id}, # 字符串ID包装为字典 +} + +# 避免 +"action_kwargs": { + "vessel": vessel_resource, # 不要传递完整 Resource 对象 +} +``` + +### 3. 单位解析 + +使用 `parse_time_input` 解析时间参数: + +```python +from .utils.unit_parser import parse_time_input + +# 支持格式: "5 min", "1 h", "300", "1.5 hours" +time_seconds = parse_time_input("5 min") # -> 300.0 +time_seconds = parse_time_input(120) # -> 120.0 +time_seconds = parse_time_input("1 h") # -> 3600.0 +``` + +### 4. 参数验证 + +所有参数必须进行验证和类型转换: + +```python +# 验证范围 +if speed < 10.0 or speed > 1500.0: + logger.warning(f"速度 {speed} 超出范围,修正为 300") + speed = 300.0 + +# 类型转换 +param = float(param) if not isinstance(param, (int, float)) else param +``` + +### 5. 日志记录 + +使用项目日志记录器: + +```python +logger = logging.getLogger(__name__) + +def generate_protocol(...): + logger.info(f"开始生成协议...") + logger.debug(f"参数: vessel={vessel_id}, time={time}") + logger.warning(f"参数修正: {old_value} -> {new_value}") +``` + +## 便捷函数 + +为常用操作提供便捷函数: + +```python +def stir_briefly(G: nx.DiGraph, vessel: Union[str, dict], + speed: float = 300.0) -> List[Dict[str, Any]]: + """短时间搅拌(30秒)""" + return generate_stir_protocol(G, vessel, time="30", stir_speed=speed) + +def stir_vigorously(G: nx.DiGraph, vessel: Union[str, dict], + time: str = "5 min") -> List[Dict[str, Any]]: + """剧烈搅拌""" + return generate_stir_protocol(G, vessel, time=time, stir_speed=800.0) +``` + +## 测试函数 + +每个协议文件应包含测试函数: + +```python +def test_{operation}_protocol(): + """测试协议生成""" + # 测试参数处理 + vessel_dict = {"id": "flask_1", "name": "反应瓶1"} + vessel_id = extract_vessel_id(vessel_dict) + assert vessel_id == "flask_1" + + # 测试单位解析 + time_s = parse_time_input("5 min") + assert time_s == 300.0 + + +if __name__ == "__main__": + test_{operation}_protocol() +``` + +## 现有协议参考 + +- `stir_protocol.py` - 搅拌操作 +- `add_protocol.py` - 添加物料 +- `filter_protocol.py` - 过滤操作 +- `heatchill_protocol.py` - 加热/冷却 +- `separate_protocol.py` - 分离操作 +- `evaporate_protocol.py` - 蒸发操作 diff --git a/.cursor/rules/registry-config.mdc b/.cursor/rules/registry-config.mdc new file mode 100644 index 000000000..bba2f221d --- /dev/null +++ b/.cursor/rules/registry-config.mdc @@ -0,0 +1,319 @@ +--- +description: 注册表配置规范 (YAML) +globs: ["unilabos/registry/**/*.yaml"] +--- + +# 注册表配置规范 + +## 概述 + +注册表使用 YAML 格式定义设备和资源类型,是 Uni-Lab-OS 的核心配置系统。 + +## 目录结构 + +``` +unilabos/registry/ +├── devices/ # 设备类型注册 +│ ├── virtual_device.yaml +│ ├── liquid_handler.yaml +│ └── ... +├── device_comms/ # 通信设备配置 +│ ├── communication_devices.yaml +│ └── modbus_ioboard.yaml +└── resources/ # 资源类型注册 + ├── bioyond/ + ├── organic/ + ├── opentrons/ + └── ... +``` + +## 设备注册表格式 + +### 基本结构 + +```yaml +device_type_id: + # 基本信息 + description: "设备描述" + version: "1.0.0" + category: + - category_name + icon: "icon_device.webp" + + # 类配置 + class: + module: "unilabos.devices.my_module:MyClass" + type: python + + # 状态类型(属性 -> ROS消息类型) + status_types: + status: String + temperature: Float64 + is_running: Bool + + # 动作映射 + action_value_mappings: + action_name: + type: UniLabJsonCommand # 或 UniLabJsonCommandAsync + goal: {} + feedback: {} + result: {} + schema: {...} + handles: {} +``` + +### action_value_mappings 详细格式 + +```yaml +action_value_mappings: + # 同步动作 + my_sync_action: + type: UniLabJsonCommand + goal: + param1: param1 + param2: param2 + feedback: {} + result: + success: success + message: message + goal_default: + param1: 0.0 + param2: "" + handles: {} + placeholder_keys: + device_param: unilabos_devices # 设备选择器 + resource_param: unilabos_resources # 资源选择器 + schema: + title: "动作名称参数" + description: "动作描述" + type: object + properties: + goal: + type: object + properties: + param1: + type: number + param2: + type: string + required: + - param1 + feedback: {} + result: + type: object + properties: + success: + type: boolean + message: + type: string + required: + - goal + + # 异步动作 + my_async_action: + type: UniLabJsonCommandAsync + goal: {} + feedback: + progress: progress + current_status: status + result: + success: success + schema: {...} +``` + +### 自动生成的动作 + +以 `auto-` 开头的动作由系统自动生成: + +```yaml +action_value_mappings: + auto-initialize: + type: UniLabJsonCommandAsync + goal: {} + feedback: {} + result: {} + schema: {...} + + auto-cleanup: + type: UniLabJsonCommandAsync + goal: {} + feedback: {} + result: {} + schema: {...} +``` + +### handles 配置 + +用于工作流编辑器中的数据流连接: + +```yaml +handles: + input: + - handler_key: "input_resource" + data_type: "resource" + label: "输入资源" + data_source: "handle" + data_key: "resources" + output: + - handler_key: "output_labware" + data_type: "resource" + label: "输出器皿" + data_source: "executor" + data_key: "created_resource.@flatten" +``` + +## 资源注册表格式 + +```yaml +resource_type_id: + description: "资源描述" + version: "1.0.0" + category: + - category_name + icon: "" + handles: [] + init_param_schema: {} + + class: + module: "unilabos.resources.my_module:MyResource" + type: pylabrobot # 或 python +``` + +### PyLabRobot 资源示例 + +```yaml +BIOYOND_Electrolyte_6VialCarrier: + category: + - bottle_carriers + - bioyond + class: + module: "unilabos.resources.bioyond.bottle_carriers:BIOYOND_Electrolyte_6VialCarrier" + type: pylabrobot + version: "1.0.0" +``` + +## 状态类型映射 + +Python 类型到 ROS 消息类型的映射: + +| Python 类型 | ROS 消息类型 | +|------------|-------------| +| `str` | `String` | +| `bool` | `Bool` | +| `int` | `Int64` | +| `float` | `Float64` | +| `list` | `String` (序列化) | +| `dict` | `String` (序列化) | + +## 自动完善注册表 + +使用 `--complete_registry` 参数自动生成 schema: + +```bash +python -m unilabos.app.main --complete_registry +``` + +这会: +1. 扫描设备类的方法签名 +2. 自动生成 `auto-` 前缀的动作 +3. 生成 JSON Schema +4. 更新 YAML 文件 + +## 验证规则 + +1. **device_type_id** 必须唯一 +2. **module** 路径必须正确可导入 +3. **status_types** 的类型必须是有效的 ROS 消息类型 +4. **schema** 必须是有效的 JSON Schema + +## 示例:完整设备配置 + +```yaml +virtual_stirrer: + category: + - virtual_device + description: "虚拟搅拌器设备" + version: "1.0.0" + icon: "icon_stirrer.webp" + handles: [] + init_param_schema: {} + + class: + module: "unilabos.devices.virtual.virtual_stirrer:VirtualStirrer" + type: python + + status_types: + status: String + operation_mode: String + current_speed: Float64 + is_stirring: Bool + remaining_time: Float64 + + action_value_mappings: + auto-initialize: + type: UniLabJsonCommandAsync + goal: {} + feedback: {} + result: {} + schema: + title: "initialize参数" + type: object + properties: + goal: + type: object + properties: {} + feedback: {} + result: {} + required: + - goal + + stir: + type: UniLabJsonCommandAsync + goal: + stir_time: stir_time + stir_speed: stir_speed + settling_time: settling_time + feedback: + current_speed: current_speed + remaining_time: remaining_time + result: + success: success + goal_default: + stir_time: 60.0 + stir_speed: 300.0 + settling_time: 30.0 + handles: {} + schema: + title: "stir参数" + description: "搅拌操作" + type: object + properties: + goal: + type: object + properties: + stir_time: + type: number + description: "搅拌时间(秒)" + stir_speed: + type: number + description: "搅拌速度(RPM)" + settling_time: + type: number + description: "沉降时间(秒)" + required: + - stir_time + - stir_speed + feedback: + type: object + properties: + current_speed: + type: number + remaining_time: + type: number + result: + type: object + properties: + success: + type: boolean + required: + - goal +``` diff --git a/.cursor/rules/ros-integration.mdc b/.cursor/rules/ros-integration.mdc new file mode 100644 index 000000000..4057b48eb --- /dev/null +++ b/.cursor/rules/ros-integration.mdc @@ -0,0 +1,233 @@ +--- +description: ROS 2 集成开发规范 +globs: ["unilabos/ros/**/*.py", "**/*_node.py"] +--- + +# ROS 2 集成开发规范 + +## 概述 + +Uni-Lab-OS 使用 ROS 2 作为设备通信中间件,基于 rclpy 实现。 + +## 核心组件 + +### BaseROS2DeviceNode + +设备节点基类,提供: +- ROS Topic 自动发布(状态属性) +- Action Server 自动创建(设备动作) +- 资源管理服务 +- 异步任务调度 + +```python +from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode +``` + +### 消息转换器 + +```python +from unilabos.ros.msgs.message_converter import ( + convert_to_ros_msg, + convert_from_ros_msg_with_mapping, + msg_converter_manager, + ros_action_to_json_schema, + ros_message_to_json_schema, +) +``` + +## 设备与 ROS 集成 + +### post_init 方法 + +设备类必须实现 `post_init` 方法接收 ROS 节点: + +```python +class MyDevice: + _ros_node: BaseROS2DeviceNode + + def post_init(self, ros_node: BaseROS2DeviceNode): + """ROS节点注入""" + self._ros_node = ros_node +``` + +### 状态属性发布 + +设备的 `@property` 属性会自动发布为 ROS Topic: + +```python +class MyDevice: + @property + def temperature(self) -> float: + return self._temperature + + # 自动发布到 /{namespace}/temperature Topic +``` + +### Topic 配置装饰器 + +```python +from unilabos.utils.decorator import topic_config + +class MyDevice: + @property + @topic_config(period=1.0, print_publish=False, qos=10) + def fast_data(self) -> float: + """高频数据 - 每秒发布一次""" + return self._fast_data + + @property + @topic_config(period=5.0) + def slow_data(self) -> str: + """低频数据 - 每5秒发布一次""" + return self._slow_data +``` + +### 订阅装饰器 + +```python +from unilabos.utils.decorator import subscribe + +class MyDevice: + @subscribe(topic="/external/sensor_data", qos=10) + def on_sensor_data(self, msg): + """订阅外部Topic""" + self._sensor_value = msg.data +``` + +## 异步操作 + +### 使用 ROS 节点睡眠 + +```python +# 推荐:使用ROS节点的睡眠方法 +await self._ros_node.sleep(1.0) + +# 不推荐:直接使用asyncio(可能导致回调阻塞) +await asyncio.sleep(1.0) +``` + +### 获取事件循环 + +```python +from unilabos.ros.x.rclpyx import get_event_loop + +loop = get_event_loop() +``` + +## 消息类型 + +### unilabos_msgs 包 + +```python +from unilabos_msgs.msg import Resource +from unilabos_msgs.srv import ( + ResourceAdd, + ResourceDelete, + ResourceUpdate, + ResourceList, + SerialCommand, +) +from unilabos_msgs.action import SendCmd +``` + +### Resource 消息结构 + +```python +Resource: + id: str + name: str + category: str + type: str + parent: str + children: List[str] + config: str # JSON字符串 + data: str # JSON字符串 + sample_id: str + pose: Pose +``` + +## 日志适配器 + +```python +from unilabos.utils.log import info, debug, warning, error, trace + +class MyDevice: + def __init__(self): + # 创建设备专属日志器 + self.logger = logging.getLogger(f"MyDevice.{self.device_id}") +``` + +ROSLoggerAdapter 同时向自定义日志和 ROS 日志发送消息。 + +## Action Server + +设备动作自动创建为 ROS Action Server: + +```yaml +# 在注册表中配置 +action_value_mappings: + my_action: + type: UniLabJsonCommandAsync # 异步Action + goal: {...} + feedback: {...} + result: {...} +``` + +### Action 类型 + +- **UniLabJsonCommand**: 同步动作 +- **UniLabJsonCommandAsync**: 异步动作(支持feedback) + +## 服务客户端 + +```python +from rclpy.client import Client + +# 调用其他节点的服务 +response = await self._ros_node.call_service( + service_name="/other_node/service", + request=MyServiceRequest(...) +) +``` + +## 命名空间 + +设备节点使用命名空间隔离: + +``` +/{device_id}/ # 设备命名空间 +/{device_id}/status # 状态Topic +/{device_id}/temperature # 温度Topic +/{device_id}/my_action # 动作Server +``` + +## 调试 + +### 查看 Topic + +```bash +ros2 topic list +ros2 topic echo /{device_id}/status +``` + +### 查看 Action + +```bash +ros2 action list +ros2 action info /{device_id}/my_action +``` + +### 查看 Service + +```bash +ros2 service list +ros2 service call /{device_id}/resource_list unilabos_msgs/srv/ResourceList +``` + +## 最佳实践 + +1. **状态属性命名**: 使用蛇形命名法(snake_case) +2. **Topic 频率**: 根据数据变化频率调整,避免过高频率 +3. **Action 反馈**: 长时间操作提供进度反馈 +4. **错误处理**: 使用 try-except 捕获并记录错误 +5. **资源清理**: 在 cleanup 方法中正确清理资源 diff --git a/.cursor/rules/testing-patterns.mdc b/.cursor/rules/testing-patterns.mdc new file mode 100644 index 000000000..73df7b0c8 --- /dev/null +++ b/.cursor/rules/testing-patterns.mdc @@ -0,0 +1,357 @@ +--- +description: 测试开发规范 +globs: ["tests/**/*.py", "**/test_*.py"] +--- + +# 测试开发规范 + +## 目录结构 + +``` +tests/ +├── __init__.py +├── devices/ # 设备测试 +│ └── liquid_handling/ +│ └── test_transfer_liquid.py +├── resources/ # 资源测试 +│ ├── test_bottle_carrier.py +│ └── test_resourcetreeset.py +├── ros/ # ROS消息测试 +│ └── msgs/ +│ ├── test_basic.py +│ ├── test_conversion.py +│ └── test_mapping.py +└── workflow/ # 工作流测试 + └── merge_workflow.py +``` + +## 测试框架 + +使用 pytest 作为测试框架: + +```bash +# 运行所有测试 +pytest tests/ + +# 运行特定测试文件 +pytest tests/resources/test_bottle_carrier.py + +# 运行特定测试函数 +pytest tests/resources/test_bottle_carrier.py::test_bottle_carrier + +# 显示详细输出 +pytest -v tests/ + +# 显示打印输出 +pytest -s tests/ +``` + +## 测试文件模板 + +```python +import pytest +from typing import List, Dict, Any + +# 导入被测试的模块 +from unilabos.resources.bioyond.bottle_carriers import ( + BIOYOND_Electrolyte_6VialCarrier, +) +from unilabos.resources.bioyond.bottles import ( + BIOYOND_PolymerStation_Solid_Vial, +) + + +class TestBottleCarrier: + """BottleCarrier 测试类""" + + def setup_method(self): + """每个测试方法前执行""" + self.carrier = BIOYOND_Electrolyte_6VialCarrier("test_carrier") + + def teardown_method(self): + """每个测试方法后执行""" + pass + + def test_carrier_creation(self): + """测试载架创建""" + assert self.carrier.name == "test_carrier" + assert len(self.carrier.sites) == 6 + + def test_bottle_placement(self): + """测试瓶子放置""" + bottle = BIOYOND_PolymerStation_Solid_Vial("test_bottle") + # 测试逻辑... + assert bottle.name == "test_bottle" + + +def test_standalone_function(): + """独立测试函数""" + result = some_function() + assert result is True + + +# 参数化测试 +@pytest.mark.parametrize("input,expected", [ + ("5 min", 300.0), + ("1 h", 3600.0), + ("120", 120.0), + (60, 60.0), +]) +def test_time_parsing(input, expected): + """测试时间解析""" + from unilabos.compile.utils.unit_parser import parse_time_input + assert parse_time_input(input) == expected + + +# 异常测试 +def test_invalid_input_raises_error(): + """测试无效输入抛出异常""" + with pytest.raises(ValueError) as exc_info: + invalid_function("bad_input") + assert "invalid" in str(exc_info.value).lower() + + +# 跳过条件测试 +@pytest.mark.skipif( + not os.environ.get("ROS_DISTRO"), + reason="需要ROS环境" +) +def test_ros_feature(): + """需要ROS环境的测试""" + pass +``` + +## 设备测试 + +### 虚拟设备测试 + +```python +import pytest +import asyncio +from unittest.mock import MagicMock, AsyncMock + +from unilabos.devices.virtual.virtual_stirrer import VirtualStirrer + + +class TestVirtualStirrer: + """VirtualStirrer 测试""" + + @pytest.fixture + def stirrer(self): + """创建测试用搅拌器""" + device = VirtualStirrer( + device_id="test_stirrer", + config={"max_speed": 1500.0, "min_speed": 50.0} + ) + + # Mock ROS节点 + mock_node = MagicMock() + mock_node.sleep = AsyncMock(return_value=None) + device.post_init(mock_node) + + return device + + @pytest.mark.asyncio + async def test_initialize(self, stirrer): + """测试初始化""" + result = await stirrer.initialize() + assert result is True + assert stirrer.status == "待机中" + + @pytest.mark.asyncio + async def test_stir_action(self, stirrer): + """测试搅拌动作""" + await stirrer.initialize() + + result = await stirrer.stir( + stir_time=5.0, + stir_speed=300.0, + settling_time=2.0 + ) + + assert result is True + assert stirrer.operation_mode == "Completed" + + @pytest.mark.asyncio + async def test_stir_invalid_speed(self, stirrer): + """测试无效速度""" + await stirrer.initialize() + + # 速度超出范围 + result = await stirrer.stir( + stir_time=5.0, + stir_speed=2000.0, # 超过max_speed + settling_time=0.0 + ) + + assert result is False + assert "错误" in stirrer.status +``` + +### 异步测试配置 + +```python +# conftest.py +import pytest +import asyncio + + +@pytest.fixture(scope="session") +def event_loop(): + """创建事件循环""" + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() +``` + +## 资源测试 + +```python +import pytest +from unilabos.resources.resource_tracker import ( + ResourceTreeSet, + ResourceTreeInstance, +) + + +def test_resource_tree_creation(): + """测试资源树创建""" + tree_set = ResourceTreeSet() + + # 添加资源 + resource = {"id": "res_1", "name": "Resource 1"} + tree_set.add_resource(resource) + + # 验证 + assert len(tree_set.all_nodes) == 1 + assert tree_set.get_resource("res_1") is not None + + +def test_resource_tree_merge(): + """测试资源树合并""" + local_set = ResourceTreeSet() + remote_set = ResourceTreeSet() + + # 设置数据... + + local_set.merge_remote_resources(remote_set) + + # 验证合并结果... +``` + +## ROS 消息测试 + +```python +import pytest +from unilabos.ros.msgs.message_converter import ( + convert_to_ros_msg, + convert_from_ros_msg_with_mapping, + msg_converter_manager, +) + + +def test_message_conversion(): + """测试消息转换""" + # Python -> ROS + python_data = {"id": "test", "value": 42} + ros_msg = convert_to_ros_msg(python_data, MyMsgType) + + assert ros_msg.id == "test" + assert ros_msg.value == 42 + + # ROS -> Python + result = convert_from_ros_msg_with_mapping(ros_msg, mapping) + assert result["id"] == "test" +``` + +## 协议测试 + +```python +import pytest +import networkx as nx +from unilabos.compile.stir_protocol import ( + generate_stir_protocol, + extract_vessel_id, +) + + +@pytest.fixture +def topology_graph(): + """创建测试拓扑图""" + G = nx.DiGraph() + G.add_node("flask_1", **{"class": "flask"}) + G.add_node("stirrer_1", **{"class": "virtual_stirrer"}) + G.add_edge("stirrer_1", "flask_1") + return G + + +def test_generate_stir_protocol(topology_graph): + """测试搅拌协议生成""" + actions = generate_stir_protocol( + G=topology_graph, + vessel="flask_1", + time="5 min", + stir_speed=300.0 + ) + + assert len(actions) == 1 + assert actions[0]["device_id"] == "stirrer_1" + assert actions[0]["action_name"] == "stir" + + +def test_extract_vessel_id(): + """测试vessel_id提取""" + # 字典格式 + assert extract_vessel_id({"id": "flask_1"}) == "flask_1" + + # 字符串格式 + assert extract_vessel_id("flask_2") == "flask_2" + + # 空值 + assert extract_vessel_id("") == "" +``` + +## 测试标记 + +```python +# 慢速测试 +@pytest.mark.slow +def test_long_running(): + pass + +# 需要网络 +@pytest.mark.network +def test_network_call(): + pass + +# 需要ROS +@pytest.mark.ros +def test_ros_feature(): + pass +``` + +运行特定标记的测试: + +```bash +pytest -m "not slow" # 排除慢速测试 +pytest -m ros # 仅ROS测试 +``` + +## 覆盖率 + +```bash +# 生成覆盖率报告 +pytest --cov=unilabos tests/ + +# HTML报告 +pytest --cov=unilabos --cov-report=html tests/ +``` + +## 最佳实践 + +1. **测试命名**: `test_{功能}_{场景}_{预期结果}` +2. **独立性**: 每个测试独立运行,不依赖其他测试 +3. **Mock外部依赖**: 使用 unittest.mock 模拟外部服务 +4. **参数化**: 使用 `@pytest.mark.parametrize` 减少重复代码 +5. **fixtures**: 使用 fixtures 共享测试设置 +6. **断言清晰**: 每个断言只验证一件事 diff --git a/.cursor/rules/unilabos-project.mdc b/.cursor/rules/unilabos-project.mdc new file mode 100644 index 000000000..1b6a24ee3 --- /dev/null +++ b/.cursor/rules/unilabos-project.mdc @@ -0,0 +1,353 @@ +--- +description: Uni-Lab-OS 实验室自动化平台开发规范 - 核心规则 +globs: ["**/*.py", "**/*.yaml", "**/*.json"] +--- + +# Uni-Lab-OS 项目开发规范 + +## 项目概述 + +Uni-Lab-OS 是一个实验室自动化操作系统,用于连接和控制各种实验设备,实现实验工作流的自动化和标准化。 + +## 技术栈 + +- **Python 3.11** - 核心开发语言 +- **ROS 2** - 设备通信中间件 (rclpy) +- **Conda/Mamba** - 包管理 (robostack-staging, conda-forge) +- **FastAPI** - Web API 服务 +- **WebSocket** - 实时通信 +- **NetworkX** - 拓扑图管理 +- **YAML** - 配置和注册表定义 +- **PyLabRobot** - 实验室自动化库集成 +- **pytest** - 测试框架 +- **asyncio** - 异步编程 + +## 项目结构 + +``` +unilabos/ +├── app/ # 应用入口、Web服务、后端 +├── compile/ # 协议编译器 (stir, add, filter 等) +├── config/ # 配置管理 +├── devices/ # 设备驱动 (真实/虚拟) +├── device_comms/ # 设备通信协议 +├── device_mesh/ # 3D网格和可视化 +├── registry/ # 设备和资源类型注册表 (YAML) +├── resources/ # 资源定义 +├── ros/ # ROS 2 集成 +├── utils/ # 工具函数 +└── workflow/ # 工作流管理 +``` + +## 代码规范 + +### Python 风格 + +1. **类型注解**:所有函数必须使用类型注解 + ```python + def transfer_liquid( + source: str, + destination: str, + volume: float, + **kwargs + ) -> List[Dict[str, Any]]: + ``` + +2. **Docstring**:使用 Google 风格的文档字符串 + ```python + def initialize(self) -> bool: + """ + 初始化设备 + + Returns: + bool: 初始化是否成功 + """ + ``` + +3. **导入顺序**: + - 标准库 + - 第三方库 + - ROS 相关 (rclpy, unilabos_msgs) + - 项目内部模块 + +### 异步编程 + +1. 设备操作方法使用 `async def` +2. 使用 `await self._ros_node.sleep()` 而非 `asyncio.sleep()` +3. 长时间运行操作需提供进度反馈 + +```python +async def stir(self, stir_time: float, stir_speed: float, **kwargs) -> bool: + """执行搅拌操作""" + start_time = time_module.time() + while True: + elapsed = time_module.time() - start_time + remaining = max(0, stir_time - elapsed) + + self.data.update({ + "remaining_time": remaining, + "status": f"搅拌中: {stir_speed} RPM" + }) + + if remaining <= 0: + break + await self._ros_node.sleep(1.0) + return True +``` + +### 日志规范 + +使用项目自定义日志系统: + +```python +from unilabos.utils.log import logger, info, debug, warning, error, trace + +# 在设备类中使用 +self.logger = logging.getLogger(f"DeviceName.{self.device_id}") +self.logger.info("设备初始化完成") +``` + +## 设备驱动开发 + +### 设备类结构 + +```python +from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode + +class MyDevice: + """设备驱动类""" + + _ros_node: BaseROS2DeviceNode + + def __init__(self, device_id: str = None, config: Dict[str, Any] = None, **kwargs): + self.device_id = device_id or "unknown_device" + self.config = config or {} + self.data = {} # 设备状态数据 + + def post_init(self, ros_node: BaseROS2DeviceNode): + """ROS节点注入""" + self._ros_node = ros_node + + async def initialize(self) -> bool: + """初始化设备""" + pass + + async def cleanup(self) -> bool: + """清理设备""" + pass + + # 状态属性 - 自动发布为 ROS Topic + @property + def status(self) -> str: + return self.data.get("status", "待机") +``` + +### 状态属性装饰器 + +```python +from unilabos.utils.decorator import topic_config + +class MyDevice: + @property + @topic_config(period=1.0, qos=10) # 每秒发布一次 + def temperature(self) -> float: + return self._temperature +``` + +### 虚拟设备 + +虚拟设备放置在 `unilabos/devices/virtual/` 目录下,命名为 `virtual_*.py` + +## 注册表配置 + +### 设备注册表 (YAML) + +位置: `unilabos/registry/devices/*.yaml` + +```yaml +my_device_type: + category: + - my_category + description: "设备描述" + version: "1.0.0" + class: + module: "unilabos.devices.my_device:MyDevice" + type: python + status_types: + status: String + temperature: Float64 + action_value_mappings: + auto-initialize: + type: UniLabJsonCommandAsync + goal: {} + feedback: {} + result: {} + schema: {...} +``` + +### 资源注册表 (YAML) + +位置: `unilabos/registry/resources/**/*.yaml` + +```yaml +my_container: + category: + - container + class: + module: "unilabos.resources.my_resource:MyContainer" + type: pylabrobot + version: "1.0.0" +``` + +## 协议编译器 + +位置: `unilabos/compile/*_protocol.py` + +### 协议生成函数模板 + +```python +from typing import List, Dict, Any, Union +import networkx as nx + +def generate_my_protocol( + G: nx.DiGraph, + vessel: Union[str, dict], + param1: float = 0.0, + **kwargs +) -> List[Dict[str, Any]]: + """ + 生成操作协议序列 + + Args: + G: 物理拓扑图 + vessel: 容器ID或字典 + param1: 参数1 + + Returns: + List[Dict]: 动作序列 + """ + # 提取vessel_id + vessel_id = vessel if isinstance(vessel, str) else vessel.get("id", "") + + # 查找设备 + device_id = find_connected_device(G, vessel_id) + + # 生成动作 + action_sequence = [{ + "device_id": device_id, + "action_name": "my_action", + "action_kwargs": { + "vessel": {"id": vessel_id}, + "param1": float(param1) + } + }] + + return action_sequence +``` + +## 测试规范 + +### 测试文件位置 + +- 单元测试: `tests/` 目录 +- 设备测试: `tests/devices/` +- 资源测试: `tests/resources/` +- ROS消息测试: `tests/ros/msgs/` + +### 测试命名 + +```python +# tests/devices/my_device/test_my_device.py + +import pytest + +def test_device_initialization(): + """测试设备初始化""" + pass + +def test_device_action(): + """测试设备动作""" + pass +``` + +## 错误处理 + +```python +from unilabos.utils.exception import UniLabException + +try: + result = await device.execute_action() +except ValueError as e: + self.logger.error(f"参数错误: {e}") + self.data["status"] = "错误: 参数无效" + return False +except Exception as e: + self.logger.error(f"执行失败: {e}") + raise +``` + +## 配置管理 + +```python +from unilabos.config.config import BasicConfig, HTTPConfig + +# 读取配置 +port = BasicConfig.port +is_host = BasicConfig.is_host_mode + +# 配置文件: local_config.py +``` + +## 常用工具 + +### 单例模式 + +```python +from unilabos.utils.decorator import singleton + +@singleton +class MyManager: + pass +``` + +### 类型检查 + +```python +from unilabos.utils.type_check import NoAliasDumper + +yaml.dump(data, f, Dumper=NoAliasDumper) +``` + +### 导入管理 + +```python +from unilabos.utils.import_manager import get_class + +device_class = get_class("unilabos.devices.my_device:MyDevice") +``` + +## Git 提交规范 + +提交信息格式: +``` +(): + + +``` + +类型: +- `feat`: 新功能 +- `fix`: 修复bug +- `docs`: 文档更新 +- `refactor`: 重构 +- `test`: 测试相关 +- `chore`: 构建/工具相关 + +示例: +``` +feat(devices): 添加虚拟搅拌器设备 + +- 实现VirtualStirrer类 +- 支持定时搅拌和持续搅拌模式 +- 添加速度验证逻辑 +``` diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 000000000..0bd258b56 --- /dev/null +++ b/.cursorignore @@ -0,0 +1,188 @@ +# ============================================================ +# Uni-Lab-OS Cursor Ignore 配置,控制 Cursor AI 的文件索引范围 +# ============================================================ + +# ==================== 敏感配置文件 ==================== +# 本地配置(可能包含密钥) +**/local_config.py +test_config.py +local_test*.py + +# 环境变量和密钥 +.env +.env.* +**/.certs/ +*.pem +*.key +credentials.json +secrets.yaml + +# ==================== 二进制和 3D 模型文件 ==================== +# 3D 模型文件(无需索引) +*.stl +*.dae +*.glb +*.gltf +*.obj +*.fbx +*.blend + +# URDF/Xacro 机器人描述文件(大型XML) +*.xacro + +# 图片文件 +*.png +*.jpg +*.jpeg +*.gif +*.webp +*.ico +*.svg +*.bmp + +# 压缩包 +*.zip +*.tar +*.tar.gz +*.tgz +*.bz2 +*.rar +*.7z + +# ==================== Python 生成文件 ==================== +__pycache__/ +*.py[cod] +*$py.class +*.so +*.pyd +*.egg +*.egg-info/ +.eggs/ +dist/ +build/ +*.manifest +*.spec + +# ==================== IDE 和编辑器 ==================== +.idea/ +.vscode/ +*.swp +*.swo +*~ +.#* + +# ==================== 测试和覆盖率 ==================== +.pytest_cache/ +.coverage +.coverage.* +htmlcov/ +.tox/ +.nox/ +coverage.xml +*.cover + +# ==================== 虚拟环境 ==================== +.venv/ +venv/ +env/ +ENV/ + +# ==================== ROS 2 生成文件 ==================== +# ROS 构建目录 +build/ +install/ +log/ +logs/ +devel/ + +# ROS 消息生成 +msg_gen/ +srv_gen/ +msg/*Action.msg +msg/*ActionFeedback.msg +msg/*ActionGoal.msg +msg/*ActionResult.msg +msg/*Feedback.msg +msg/*Goal.msg +msg/*Result.msg +msg/_*.py +srv/_*.py +build_isolated/ +devel_isolated/ + +# ROS 动态配置 +*.cfgc +/cfg/cpp/ +/cfg/*.py + +# ==================== 项目特定目录 ==================== +# 工作数据目录 +unilabos_data/ + +# 临时和输出目录 +temp/ +output/ +cursor_docs/ +configs/ + +# 文档构建 +docs/_build/ +/site + +# ==================== 大型数据文件 ==================== +# 点云数据 +*.pcd + +# GraphML 图形文件 +*.graphml + +# 日志文件 +*.log + +# 数据库 +*.sqlite3 +*.db + +# Jupyter 检查点 +.ipynb_checkpoints/ + +# ==================== 设备网格资源 ==================== +# 3D 网格文件目录(包含大量 STL/DAE 文件) +unilabos/device_mesh/devices/**/*.stl +unilabos/device_mesh/devices/**/*.dae +unilabos/device_mesh/resources/**/*.stl +unilabos/device_mesh/resources/**/*.glb +unilabos/device_mesh/resources/**/*.xacro + +# RViz 配置 +*.rviz + +# ==================== 系统文件 ==================== +.DS_Store +Thumbs.db +desktop.ini + +# ==================== 锁文件 ==================== +poetry.lock +Pipfile.lock +pdm.lock +package-lock.json +yarn.lock + +# ==================== 类型检查缓存 ==================== +.mypy_cache/ +.dmypy.json +.pytype/ +.pyre/ +pyrightconfig.json + +# ==================== 其他 ==================== +# Catkin +CATKIN_IGNORE + +# Eclipse/Qt +.project +.cproject +CMakeLists.txt.user +*.user +qtcreator-* diff --git a/.gitignore b/.gitignore index 838331e3a..610be6149 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,6 @@ temp/ output/ unilabos_data/ pyrightconfig.json -.cursorignore ## Python # Byte-compiled / optimized / DLL files From 37ec49f318c5130e8db527d43a9b78545fc84b34 Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Thu, 29 Jan 2026 16:31:14 +0800 Subject: [PATCH 05/56] Refactor Bioyond resource handling: update warehouse mapping retrieval, add TipBox support, and improve liquid tracking logic. Migrate TipBox creation to bottle_carriers.py for better structure. --- .../workstation/bioyond_studio/station.py | 2 +- .../resources/bioyond/bottle_carriers.yaml | 13 +++ .../registry/resources/bioyond/bottles.yaml | 11 -- unilabos/resources/bioyond/bottle_carriers.py | 109 +++++++++++++++++- unilabos/resources/bioyond/bottles.py | 56 ++------- unilabos/resources/graphio.py | 47 ++++---- unilabos/resources/resource_tracker.py | 1 + 7 files changed, 154 insertions(+), 85 deletions(-) diff --git a/unilabos/devices/workstation/bioyond_studio/station.py b/unilabos/devices/workstation/bioyond_studio/station.py index 327d8195c..60c18e1e7 100644 --- a/unilabos/devices/workstation/bioyond_studio/station.py +++ b/unilabos/devices/workstation/bioyond_studio/station.py @@ -258,7 +258,7 @@ def sync_to_external(self, resource: Any) -> bool: logger.info(f"[同步→Bioyond] ➕ 物料不存在于 Bioyond,将创建新物料并入库") # 第1步:从配置中获取仓库配置 - warehouse_mapping = self.bioyond_config.get("warehouse_mapping", {}) + warehouse_mapping = self.workstation.bioyond_config.get("warehouse_mapping", {}) # 确定目标仓库名称 parent_name = None diff --git a/unilabos/registry/resources/bioyond/bottle_carriers.yaml b/unilabos/registry/resources/bioyond/bottle_carriers.yaml index 764a8aa5c..89f2bdd1b 100644 --- a/unilabos/registry/resources/bioyond/bottle_carriers.yaml +++ b/unilabos/registry/resources/bioyond/bottle_carriers.yaml @@ -46,3 +46,16 @@ BIOYOND_PolymerStation_8StockCarrier: init_param_schema: {} registry_type: resource version: 1.0.0 +BIOYOND_PolymerStation_TipBox: + category: + - bottle_carriers + - tip_racks + class: + module: unilabos.resources.bioyond.bottle_carriers:BIOYOND_PolymerStation_TipBox + type: pylabrobot + description: BIOYOND_PolymerStation_TipBox (4x6布局,24个枪头孔位) + handles: [] + icon: '' + init_param_schema: {} + registry_type: resource + version: 1.0.0 diff --git a/unilabos/registry/resources/bioyond/bottles.yaml b/unilabos/registry/resources/bioyond/bottles.yaml index ecc5525d8..e493e7b19 100644 --- a/unilabos/registry/resources/bioyond/bottles.yaml +++ b/unilabos/registry/resources/bioyond/bottles.yaml @@ -82,14 +82,3 @@ BIOYOND_PolymerStation_Solution_Beaker: icon: '' init_param_schema: {} version: 1.0.0 -BIOYOND_PolymerStation_TipBox: - category: - - bottles - - tip_boxes - class: - module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_TipBox - type: pylabrobot - handles: [] - icon: '' - init_param_schema: {} - version: 1.0.0 diff --git a/unilabos/resources/bioyond/bottle_carriers.py b/unilabos/resources/bioyond/bottle_carriers.py index d79b84959..e1932b207 100644 --- a/unilabos/resources/bioyond/bottle_carriers.py +++ b/unilabos/resources/bioyond/bottle_carriers.py @@ -1,4 +1,4 @@ -from pylabrobot.resources import create_homogeneous_resources, Coordinate, ResourceHolder, create_ordered_items_2d +from pylabrobot.resources import create_homogeneous_resources, Coordinate, ResourceHolder, create_ordered_items_2d, Container from unilabos.resources.itemized_carrier import BottleCarrier from unilabos.resources.bioyond.bottles import ( @@ -9,6 +9,28 @@ BIOYOND_PolymerStation_Reagent_Bottle, BIOYOND_PolymerStation_Flask, ) + + +def BIOYOND_PolymerStation_Tip(name: str, size_x: float = 8.0, size_y: float = 8.0, size_z: float = 50.0) -> Container: + """创建单个枪头资源 + + Args: + name: 枪头名称 + size_x: 枪头宽度 (mm) + size_y: 枪头长度 (mm) + size_z: 枪头高度 (mm) + + Returns: + Container: 枪头容器 + """ + return Container( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + category="tip", + model="BIOYOND_PolymerStation_Tip", + ) # 命名约定:试剂瓶-Bottle,烧杯-Beaker,烧瓶-Flask,小瓶-Vial @@ -322,3 +344,88 @@ def BIOYOND_Electrolyte_1BottleCarrier(name: str) -> BottleCarrier: carrier.num_items_z = 1 carrier[0] = BIOYOND_PolymerStation_Solution_Beaker(f"{name}_beaker_1") return carrier + + +def BIOYOND_PolymerStation_TipBox( + name: str, + size_x: float = 127.76, # 枪头盒宽度 + size_y: float = 85.48, # 枪头盒长度 + size_z: float = 100.0, # 枪头盒高度 + barcode: str = None, +) -> BottleCarrier: + """创建4×6枪头盒 (24个枪头) - 使用 BottleCarrier 结构 + + Args: + name: 枪头盒名称 + size_x: 枪头盒宽度 (mm) + size_y: 枪头盒长度 (mm) + size_z: 枪头盒高度 (mm) + barcode: 条形码 + + Returns: + BottleCarrier: 包含24个枪头孔位的枪头盒载架 + + 布局说明: + - 4行×6列 (A-D, 1-6) + - 枪头孔位间距: 18mm (x方向) × 18mm (y方向) + - 起始位置居中对齐 + - 索引顺序: 列优先 (0=A1, 1=B1, 2=C1, 3=D1, 4=A2, ...) + """ + # 枪头孔位参数 + num_cols = 6 # 1-6 (x方向) + num_rows = 4 # A-D (y方向) + tip_diameter = 8.0 # 枪头孔位直径 + tip_spacing_x = 18.0 # 列间距 (增加到18mm,更宽松) + tip_spacing_y = 18.0 # 行间距 (增加到18mm,更宽松) + + # 计算起始位置 (居中对齐) + total_width = (num_cols - 1) * tip_spacing_x + tip_diameter + total_height = (num_rows - 1) * tip_spacing_y + tip_diameter + start_x = (size_x - total_width) / 2 + start_y = (size_y - total_height) / 2 + + # 使用 create_ordered_items_2d 创建孔位 + # create_ordered_items_2d 返回的 key 是数字索引: 0, 1, 2, ... + # 顺序是列优先: 先y后x (即 0=A1, 1=B1, 2=C1, 3=D1, 4=A2, 5=B2, ...) + sites = create_ordered_items_2d( + klass=ResourceHolder, + num_items_x=num_cols, + num_items_y=num_rows, + dx=start_x, + dy=start_y, + dz=5.0, + item_dx=tip_spacing_x, + item_dy=tip_spacing_y, + size_x=tip_diameter, + size_y=tip_diameter, + size_z=50.0, # 枪头深度 + ) + + # 更新 sites 中每个 ResourceHolder 的名称 + for k, v in sites.items(): + v.name = f"{name}_{v.name}" + + # 创建枪头盒载架 + # 注意:不设置 category,使用默认的 "bottle_carrier",这样前端会显示为完整的矩形载架 + tip_box = BottleCarrier( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + sites=sites, # 直接使用数字索引的 sites + model="BIOYOND_PolymerStation_TipBox", + ) + + # 设置自定义属性 + tip_box.barcode = barcode + tip_box.tip_count = 24 # 4行×6列 + tip_box.num_items_x = num_cols + tip_box.num_items_y = num_rows + tip_box.num_items_z = 1 + + # ⭐ 枪头盒不需要放入子资源 + # 与其他 carrier 不同,枪头盒在 Bioyond 中是一个整体 + # 不需要追踪每个枪头的状态,保持为空的 ResourceHolder 即可 + # 这样前端会显示24个空槽位,可以用于放置枪头 + + return tip_box diff --git a/unilabos/resources/bioyond/bottles.py b/unilabos/resources/bioyond/bottles.py index 7045d8b72..73343bc68 100644 --- a/unilabos/resources/bioyond/bottles.py +++ b/unilabos/resources/bioyond/bottles.py @@ -116,7 +116,9 @@ def BIOYOND_PolymerStation_TipBox( size_z: float = 100.0, # 枪头盒高度 barcode: str = None, ): - """创建4×6枪头盒 (24个枪头) + """创建4×6枪头盒 (24个枪头) - 使用 BottleCarrier 结构 + + 注意:此函数已弃用,请使用 bottle_carriers.py 中的版本 Args: name: 枪头盒名称 @@ -126,55 +128,11 @@ def BIOYOND_PolymerStation_TipBox( barcode: 条形码 Returns: - TipBoxCarrier: 包含24个枪头孔位的枪头盒 + BottleCarrier: 包含24个枪头孔位的枪头盒载架 """ - from pylabrobot.resources import Container, Coordinate - - # 创建枪头盒容器 - tip_box = Container( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - category="tip_rack", - model="BIOYOND_PolymerStation_TipBox_4x6", - ) - - # 设置自定义属性 - tip_box.barcode = barcode - tip_box.tip_count = 24 # 4行×6列 - tip_box.num_items_x = 6 # 6列 - tip_box.num_items_y = 4 # 4行 - - # 创建24个枪头孔位 (4行×6列) - # 假设孔位间距为 9mm - tip_spacing_x = 9.0 # 列间距 - tip_spacing_y = 9.0 # 行间距 - start_x = 14.38 # 第一个孔位的x偏移 - start_y = 11.24 # 第一个孔位的y偏移 - - for row in range(4): # A, B, C, D - for col in range(6): # 1-6 - spot_name = f"{chr(65 + row)}{col + 1}" # A1, A2, ..., D6 - x = start_x + col * tip_spacing_x - y = start_y + row * tip_spacing_y - - # 创建枪头孔位容器 - tip_spot = Container( - name=spot_name, - size_x=8.0, # 单个枪头孔位大小 - size_y=8.0, - size_z=size_z - 10.0, # 略低于盒子高度 - category="tip_spot", - ) - - # 添加到枪头盒 - tip_box.assign_child_resource( - tip_spot, - location=Coordinate(x=x, y=y, z=0) - ) - - return tip_box + # 重定向到 bottle_carriers.py 中的实现 + from unilabos.resources.bioyond.bottle_carriers import BIOYOND_PolymerStation_TipBox as TipBox_Carrier + return TipBox_Carrier(name=name, size_x=size_x, size_y=size_y, size_z=size_z, barcode=barcode) def BIOYOND_PolymerStation_Flask( diff --git a/unilabos/resources/graphio.py b/unilabos/resources/graphio.py index 8233ae7af..7c11cd8d5 100644 --- a/unilabos/resources/graphio.py +++ b/unilabos/resources/graphio.py @@ -759,9 +759,12 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st bottle = plr_material[number] = initialize_resource( {"name": f'{detail["name"]}_{number}', "class": reverse_type_mapping[typeName][0]}, resource_type=ResourcePLR ) - bottle.tracker.liquids = [ - (detail["name"], float(detail.get("quantity", 0)) if detail.get("quantity") else 0) - ] + # 只有具有 tracker 属性的容器才设置液体信息(如 Bottle, Well) + # ResourceHolder 等不支持液体追踪的容器跳过 + if hasattr(bottle, "tracker"): + bottle.tracker.liquids = [ + (detail["name"], float(detail.get("quantity", 0)) if detail.get("quantity") else 0) + ] bottle.code = detail.get("code", "") logger.debug(f" └─ [子物料] {detail['name']} → {plr_material.name}[{number}] (类型:{typeName})") else: @@ -770,9 +773,11 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st # 只对有 capacity 属性的容器(液体容器)处理液体追踪 if hasattr(plr_material, 'capacity'): bottle = plr_material[0] if plr_material.capacity > 0 else plr_material - bottle.tracker.liquids = [ - (material["name"], float(material.get("quantity", 0)) if material.get("quantity") else 0) - ] + # 确保 bottle 有 tracker 属性才设置液体信息 + if hasattr(bottle, "tracker"): + bottle.tracker.liquids = [ + (material["name"], float(material.get("quantity", 0)) if material.get("quantity") else 0) + ] plr_materials.append(plr_material) @@ -801,24 +806,29 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st wh_name = loc.get("whName") logger.debug(f"[物料位置] {unique_name} 尝试放置到 warehouse: {wh_name} (Bioyond坐标: x={loc.get('x')}, y={loc.get('y')}, z={loc.get('z')})") + # Bioyond坐标映射 (重要!): x→行(1=A,2=B...), y→列(1=01,2=02...), z→层(通常=1) + # 必须在warehouse映射之前先获取坐标,以便后续调整 + x = loc.get("x", 1) # 行号 (1-based: 1=A, 2=B, 3=C, 4=D) + y = loc.get("y", 1) # 列号 (1-based: 1=01, 2=02, 3=03...) + z = loc.get("z", 1) # 层号 (1-based, 通常为1) + # 特殊处理: Bioyond的"堆栈1"需要映射到"堆栈1左"或"堆栈1右" - # 根据列号(x)判断: 1-4映射到左侧, 5-8映射到右侧 + # 根据列号(y)判断: 1-4映射到左侧, 5-8映射到右侧 if wh_name == "堆栈1": - x_val = loc.get("x", 1) - if 1 <= x_val <= 4: + if 1 <= y <= 4: wh_name = "堆栈1左" - elif 5 <= x_val <= 8: + elif 5 <= y <= 8: wh_name = "堆栈1右" + y = y - 4 # 调整列号: 5-8映射到1-4 else: - logger.warning(f"物料 {material['name']} 的列号 x={x_val} 超出范围,无法映射到堆栈1左或堆栈1右") + logger.warning(f"物料 {material['name']} 的列号 y={y} 超出范围,无法映射到堆栈1左或堆栈1右") continue # 特殊处理: Bioyond的"站内Tip盒堆栈"也需要进行拆分映射 if wh_name == "站内Tip盒堆栈": - y_val = loc.get("y", 1) - if y_val == 1: + if y == 1: wh_name = "站内Tip盒堆栈(右)" - elif y_val in [2, 3]: + elif y in [2, 3]: wh_name = "站内Tip盒堆栈(左)" y = y - 1 # 调整列号,因为左侧仓库对应的 Bioyond y=2 实际上是它的第1列 @@ -826,15 +836,6 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st warehouse = deck.warehouses[wh_name] logger.debug(f"[Warehouse匹配] 找到warehouse: {wh_name} (容量: {warehouse.capacity}, 行×列: {warehouse.num_items_x}×{warehouse.num_items_y})") - # Bioyond坐标映射 (重要!): x→行(1=A,2=B...), y→列(1=01,2=02...), z→层(通常=1) - x = loc.get("x", 1) # 行号 (1-based: 1=A, 2=B, 3=C, 4=D) - y = loc.get("y", 1) # 列号 (1-based: 1=01, 2=02, 3=03...) - z = loc.get("z", 1) # 层号 (1-based, 通常为1) - - # 如果是右侧堆栈,需要调整列号 (5→1, 6→2, 7→3, 8→4) - if wh_name == "堆栈1右": - y = y - 4 # 将5-8映射到1-4 - # 特殊处理竖向warehouse(站内试剂存放堆栈、测量小瓶仓库) # 这些warehouse使用 vertical-col-major 布局 if wh_name in ["站内试剂存放堆栈", "测量小瓶仓库(测密度)"]: diff --git a/unilabos/resources/resource_tracker.py b/unilabos/resources/resource_tracker.py index 4097782da..9401182c4 100644 --- a/unilabos/resources/resource_tracker.py +++ b/unilabos/resources/resource_tracker.py @@ -338,6 +338,7 @@ def replace_plr_type(source: str): "deck": "deck", "tip_rack": "tip_rack", "tip_spot": "tip_spot", + "tip": "tip", # 添加 tip 类型支持 "tube": "tube", "bottle_carrier": "bottle_carrier", } From e30c01d54ecec7aec4681249e20ef3e57d7b2d9f Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Mon, 2 Feb 2026 23:57:13 +0800 Subject: [PATCH 06/56] Dev backward (#228) * Workbench example, adjust log level, and ci check (#220) * TestLatency Return Value Example & gitignore update * Adjust log level & Add workbench virtual example & Add not action decorator & Add check_mode & * Add CI Check * CI Check Fix 1 * CI Check Fix 2 * CI Check Fix 3 * CI Check Fix 4 * CI Check Fix 5 * Upgrade to py 3.11.14; ros 0.7; unilabos 0.10.16 * Update to ROS2 Humble 0.7 * Fix Build 1 * Fix Build 2 * Fix Build 3 * Fix Build 4 * Fix Build 5 * Fix Build 6 * Fix Build 7 * ci(deps): bump actions/configure-pages from 4 to 5 (#222) Bumps [actions/configure-pages](https://github.com/actions/configure-pages) from 4 to 5. - [Release notes](https://github.com/actions/configure-pages/releases) - [Commits](https://github.com/actions/configure-pages/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/configure-pages dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * ci(deps): bump actions/upload-artifact from 4 to 6 (#224) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 6. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * ci(deps): bump actions/upload-pages-artifact from 3 to 4 (#225) Bumps [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) from 3 to 4. - [Release notes](https://github.com/actions/upload-pages-artifact/releases) - [Commits](https://github.com/actions/upload-pages-artifact/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/upload-pages-artifact dependency-version: '4' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * ci(deps): bump actions/checkout from 4 to 6 (#223) Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Fix Build 8 * Fix Build 9 * Fix Build 10 * Fix Build 11 * Fix Build 12 * Fix Build 13 * v0.10.17 (cherry picked from commit 176de521b44693a890c9d676d31aee0027442724) * CI Check use production mode * Fix OT2 & ReAdd Virtual Devices * add msg goal * transfer liquid handles * gather query * add unilabos_class * Support root node change pos * save class name when deserialize & protocol execute test * fix upload workflow json * workflow upload & set liquid fix & add set liquid with plate * speed up registry load --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: hanhua@dp.tech <2509856570@qq.com> --- .conda/base/recipe.yaml | 60 ++ .conda/environment/recipe.yaml | 39 + .conda/full/recipe.yaml | 42 ++ .conda/recipe.yaml | 91 --- .conda/scripts/post-link.bat | 9 - .conda/scripts/post-link.sh | 9 - .cursorignore | 26 - .github/dependabot.yml | 19 + .github/workflows/ci-check.yml | 67 ++ .github/workflows/conda-pack-build.yml | 43 +- .github/workflows/deploy-docs.yml | 37 +- .github/workflows/multi-platform-build.yml | 46 +- .github/workflows/unilabos-conda-build.yml | 113 ++- .gitignore | 1 + MANIFEST.in | 1 + README.md | 38 +- README_zh.md | 38 +- docs/user_guide/best_practice.md | 78 +- docs/user_guide/image/add_protocol.png | Bin 0 -> 83318 bytes docs/user_guide/installation.md | 192 +++-- recipes/msgs/recipe.yaml | 8 +- recipes/unilabos/recipe.yaml | 2 +- scripts/create_readme.py | 4 +- scripts/dev_install.py | 214 ++++++ setup.py | 2 +- tests/workflow/test.json | 213 ++++++ unilabos/__init__.py | 2 +- unilabos/__main__.py | 6 + unilabos/app/main.py | 51 +- unilabos/app/utils.py | 34 +- unilabos/app/web/client.py | 2 - unilabos/app/web/controller.py | 4 +- unilabos/app/ws_client.py | 81 +- unilabos/config/config.py | 1 + .../{ros/x => devices/Qone_nmr}/__init__.py | 0 .../liquid_handler_abstract.py | 692 +++++++++++------- .../devices/liquid_handling/prcxi/prcxi.py | 399 +++++----- .../neware_battery_test_system/__init__.py | 0 unilabos/devices/virtual/workbench.py | 687 +++++++++++++++++ .../bioyond_studio/bioyond_cell/__init__.py | 0 .../dispensing_station/__init__.py | 0 .../reaction_station/__init__.py | 0 unilabos/devices/xrd_d7mate/__init__.py | 0 unilabos/devices/zhida_hplc/__init__.py | 0 unilabos/registry/devices/liquid_handler.yaml | 187 ++++- unilabos/registry/devices/virtual_device.yaml | 378 ++++++++++ unilabos/registry/registry.py | 610 ++++++++------- unilabos/resources/graphio.py | 4 +- unilabos/resources/resource_tracker.py | 19 +- unilabos/ros/msgs/message_converter.py | 40 +- unilabos/ros/nodes/base_device_node.py | 58 +- unilabos/ros/nodes/presets/host_node.py | 68 +- unilabos/ros/nodes/presets/workstation.py | 63 +- unilabos/ros/x/rclpyx.py | 182 ----- unilabos/test/experiments/virtual_bench.json | 28 + unilabos/utils/README_LOGGING.md | 187 ----- unilabos/utils/decorator.py | 46 ++ unilabos/utils/environment_check.py | 1 + unilabos/utils/import_manager.py | 14 + unilabos/utils/pywinauto_util.py | 8 +- unilabos/utils/requirements.txt | 18 + unilabos/workflow/common.py | 371 ++++++++-- unilabos/workflow/convert_from_json.py | 259 +++---- .../legacy/convert_from_json_legacy.py | 356 +++++++++ unilabos_msgs/package.xml | 2 +- 65 files changed, 4599 insertions(+), 1651 deletions(-) create mode 100644 .conda/base/recipe.yaml create mode 100644 .conda/environment/recipe.yaml create mode 100644 .conda/full/recipe.yaml delete mode 100644 .conda/recipe.yaml delete mode 100644 .conda/scripts/post-link.bat delete mode 100644 .conda/scripts/post-link.sh delete mode 100644 .cursorignore create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci-check.yml create mode 100644 docs/user_guide/image/add_protocol.png create mode 100644 scripts/dev_install.py create mode 100644 tests/workflow/test.json create mode 100644 unilabos/__main__.py rename unilabos/{ros/x => devices/Qone_nmr}/__init__.py (100%) create mode 100644 unilabos/devices/neware_battery_test_system/__init__.py create mode 100644 unilabos/devices/virtual/workbench.py create mode 100644 unilabos/devices/workstation/bioyond_studio/bioyond_cell/__init__.py create mode 100644 unilabos/devices/workstation/bioyond_studio/dispensing_station/__init__.py create mode 100644 unilabos/devices/workstation/bioyond_studio/reaction_station/__init__.py create mode 100644 unilabos/devices/xrd_d7mate/__init__.py create mode 100644 unilabos/devices/zhida_hplc/__init__.py delete mode 100644 unilabos/ros/x/rclpyx.py create mode 100644 unilabos/test/experiments/virtual_bench.json delete mode 100644 unilabos/utils/README_LOGGING.md create mode 100644 unilabos/utils/requirements.txt create mode 100644 unilabos/workflow/legacy/convert_from_json_legacy.py diff --git a/.conda/base/recipe.yaml b/.conda/base/recipe.yaml new file mode 100644 index 000000000..3c5bb88c9 --- /dev/null +++ b/.conda/base/recipe.yaml @@ -0,0 +1,60 @@ +# unilabos: Production package (depends on unilabos-env + pip unilabos) +# For production deployment + +package: + name: unilabos + version: 0.10.17 + +source: + path: ../../unilabos + target_directory: unilabos + +build: + python: + entry_points: + - unilab = unilabos.app.main:main + script: + - set PIP_NO_INDEX= + - if: win + then: + - copy %RECIPE_DIR%\..\..\MANIFEST.in %SRC_DIR% + - copy %RECIPE_DIR%\..\..\setup.cfg %SRC_DIR% + - copy %RECIPE_DIR%\..\..\setup.py %SRC_DIR% + - pip install %SRC_DIR% + - if: unix + then: + - cp $RECIPE_DIR/../../MANIFEST.in $SRC_DIR + - cp $RECIPE_DIR/../../setup.cfg $SRC_DIR + - cp $RECIPE_DIR/../../setup.py $SRC_DIR + - pip install $SRC_DIR + +requirements: + host: + - python ==3.11.14 + - pip + - setuptools + - zstd + - zstandard + run: + - zstd + - zstandard + - networkx + - typing_extensions + - websockets + - pint + - fastapi + - jinja2 + - requests + - uvicorn + - opcua + - pyserial + - pandas + - pymodbus + - matplotlib + - pylibftdi + - uni-lab::unilabos-env ==0.10.17 + +about: + repository: https://github.com/deepmodeling/Uni-Lab-OS + license: GPL-3.0-only + description: "UniLabOS - Production package with minimal ROS2 dependencies" diff --git a/.conda/environment/recipe.yaml b/.conda/environment/recipe.yaml new file mode 100644 index 000000000..3f8df0f64 --- /dev/null +++ b/.conda/environment/recipe.yaml @@ -0,0 +1,39 @@ +# unilabos-env: conda environment dependencies (ROS2 + conda packages) + +package: + name: unilabos-env + version: 0.10.17 + +build: + noarch: generic + +requirements: + run: + # Python + - zstd + - zstandard + - conda-forge::python ==3.11.14 + - conda-forge::opencv + # ROS2 dependencies (from ci-check.yml) + - robostack-staging::ros-humble-ros-core + - robostack-staging::ros-humble-action-msgs + - robostack-staging::ros-humble-std-msgs + - robostack-staging::ros-humble-geometry-msgs + - robostack-staging::ros-humble-control-msgs + - robostack-staging::ros-humble-nav2-msgs + - robostack-staging::ros-humble-cv-bridge + - robostack-staging::ros-humble-vision-opencv + - robostack-staging::ros-humble-tf-transformations + - robostack-staging::ros-humble-moveit-msgs + - robostack-staging::ros-humble-tf2-ros + - robostack-staging::ros-humble-tf2-ros-py + - conda-forge::transforms3d + - conda-forge::uv + + # UniLabOS custom messages + - uni-lab::ros-humble-unilabos-msgs + +about: + repository: https://github.com/deepmodeling/Uni-Lab-OS + license: GPL-3.0-only + description: "UniLabOS Environment - ROS2 and conda dependencies" diff --git a/.conda/full/recipe.yaml b/.conda/full/recipe.yaml new file mode 100644 index 000000000..037f5b4fc --- /dev/null +++ b/.conda/full/recipe.yaml @@ -0,0 +1,42 @@ +# unilabos-full: Full package with all features +# Depends on unilabos + complete ROS2 desktop + dev tools + +package: + name: unilabos-full + version: 0.10.17 + +build: + noarch: generic + +requirements: + run: + # Base unilabos package (includes unilabos-env) + - uni-lab::unilabos ==0.10.17 + # Documentation tools + - sphinx + - sphinx_rtd_theme + # Web UI + - gradio + - flask + # Interactive development + - ipython + - jupyter + - jupyros + - colcon-common-extensions + # ROS2 full desktop (includes rviz2, gazebo, etc.) + - robostack-staging::ros-humble-desktop-full + # Navigation and motion control + - ros-humble-navigation2 + - ros-humble-ros2-control + - ros-humble-robot-state-publisher + - ros-humble-joint-state-publisher + # MoveIt motion planning + - ros-humble-moveit + - ros-humble-moveit-servo + # Simulation + - ros-humble-simulation + +about: + repository: https://github.com/deepmodeling/Uni-Lab-OS + license: GPL-3.0-only + description: "UniLabOS Full - Complete package with ROS2 Desktop, MoveIt, Navigation2, Gazebo, Jupyter" diff --git a/.conda/recipe.yaml b/.conda/recipe.yaml deleted file mode 100644 index 2b041c800..000000000 --- a/.conda/recipe.yaml +++ /dev/null @@ -1,91 +0,0 @@ -package: - name: unilabos - version: 0.10.15 - -source: - path: ../unilabos - target_directory: unilabos - -build: - python: - entry_points: - - unilab = unilabos.app.main:main - script: - - set PIP_NO_INDEX= - - if: win - then: - - copy %RECIPE_DIR%\..\MANIFEST.in %SRC_DIR% - - copy %RECIPE_DIR%\..\setup.cfg %SRC_DIR% - - copy %RECIPE_DIR%\..\setup.py %SRC_DIR% - - call %PYTHON% -m pip install %SRC_DIR% - - if: unix - then: - - cp $RECIPE_DIR/../MANIFEST.in $SRC_DIR - - cp $RECIPE_DIR/../setup.cfg $SRC_DIR - - cp $RECIPE_DIR/../setup.py $SRC_DIR - - $PYTHON -m pip install $SRC_DIR - -requirements: - host: - - python ==3.11.11 - - pip - - setuptools - - zstd - - zstandard - run: - - conda-forge::python ==3.11.11 - - compilers - - cmake - - zstd - - zstandard - - ninja - - if: unix - then: - - make - - sphinx - - sphinx_rtd_theme - - numpy - - scipy - - pandas - - networkx - - matplotlib - - pint - - pyserial - - pyusb - - pylibftdi - - pymodbus - - python-can - - pyvisa - - opencv - - pydantic - - fastapi - - uvicorn - - gradio - - flask - - websockets - - ipython - - jupyter - - jupyros - - colcon-common-extensions - - robostack-staging::ros-humble-desktop-full - - robostack-staging::ros-humble-control-msgs - - robostack-staging::ros-humble-sensor-msgs - - robostack-staging::ros-humble-trajectory-msgs - - ros-humble-navigation2 - - ros-humble-ros2-control - - ros-humble-robot-state-publisher - - ros-humble-joint-state-publisher - - ros-humble-rosbridge-server - - ros-humble-cv-bridge - - ros-humble-tf2 - - ros-humble-moveit - - ros-humble-moveit-servo - - ros-humble-simulation - - ros-humble-tf-transformations - - transforms3d - - uni-lab::ros-humble-unilabos-msgs - -about: - repository: https://github.com/deepmodeling/Uni-Lab-OS - license: GPL-3.0-only - description: "Uni-Lab-OS" diff --git a/.conda/scripts/post-link.bat b/.conda/scripts/post-link.bat deleted file mode 100644 index 352b78ca9..000000000 --- a/.conda/scripts/post-link.bat +++ /dev/null @@ -1,9 +0,0 @@ -@echo off -setlocal enabledelayedexpansion - -REM upgrade pip -"%PREFIX%\python.exe" -m pip install --upgrade pip - -REM install extra deps -"%PREFIX%\python.exe" -m pip install paho-mqtt opentrons_shared_data -"%PREFIX%\python.exe" -m pip install git+https://github.com/Xuwznln/pylabrobot.git diff --git a/.conda/scripts/post-link.sh b/.conda/scripts/post-link.sh deleted file mode 100644 index ef96f1591..000000000 --- a/.conda/scripts/post-link.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash -set -euxo pipefail - -# make sure pip is available -"$PREFIX/bin/python" -m pip install --upgrade pip - -# install extra deps -"$PREFIX/bin/python" -m pip install paho-mqtt opentrons_shared_data -"$PREFIX/bin/python" -m pip install git+https://github.com/Xuwznln/pylabrobot.git diff --git a/.cursorignore b/.cursorignore deleted file mode 100644 index 7b0d4f90d..000000000 --- a/.cursorignore +++ /dev/null @@ -1,26 +0,0 @@ -.conda -# .github -.idea -# .vscode -output -pylabrobot_repo -recipes -scripts -service -temp -# unilabos/test -# unilabos/app/web -unilabos/device_mesh -unilabos_data -unilabos_msgs -unilabos.egg-info -CONTRIBUTORS -# LICENSE -MANIFEST.in -pyrightconfig.json -# README.md -# README_zh.md -setup.py -setup.cfg -.gitattrubutes -**/__pycache__ diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..20a5faa50 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,19 @@ +version: 2 +updates: +# GitHub Actions +- package-ecosystem: "github-actions" + directory: "/" + target-branch: "dev" + schedule: + interval: "weekly" + day: "monday" + time: "06:00" + open-pull-requests-limit: 5 + reviewers: + - "msgcenterpy-team" + labels: + - "dependencies" + - "github-actions" + commit-message: + prefix: "ci" + include: "scope" diff --git a/.github/workflows/ci-check.yml b/.github/workflows/ci-check.yml new file mode 100644 index 000000000..57245d94c --- /dev/null +++ b/.github/workflows/ci-check.yml @@ -0,0 +1,67 @@ +name: CI Check + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + +jobs: + registry-check: + runs-on: windows-latest + + env: + # Fix Unicode encoding issue on Windows runner (cp1252 -> utf-8) + PYTHONIOENCODING: utf-8 + PYTHONUTF8: 1 + + defaults: + run: + shell: cmd + + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Miniforge + uses: conda-incubator/setup-miniconda@v3 + with: + miniforge-version: latest + use-mamba: true + channels: robostack-staging,conda-forge,uni-lab + channel-priority: flexible + activate-environment: check-env + auto-update-conda: false + show-channel-urls: true + + - name: Install ROS dependencies, uv and unilabos-msgs + run: | + echo Installing ROS dependencies... + mamba install -n check-env conda-forge::uv conda-forge::opencv robostack-staging::ros-humble-ros-core robostack-staging::ros-humble-action-msgs robostack-staging::ros-humble-std-msgs robostack-staging::ros-humble-geometry-msgs robostack-staging::ros-humble-control-msgs robostack-staging::ros-humble-nav2-msgs uni-lab::ros-humble-unilabos-msgs robostack-staging::ros-humble-cv-bridge robostack-staging::ros-humble-vision-opencv robostack-staging::ros-humble-tf-transformations robostack-staging::ros-humble-moveit-msgs robostack-staging::ros-humble-tf2-ros robostack-staging::ros-humble-tf2-ros-py conda-forge::transforms3d -c robostack-staging -c conda-forge -c uni-lab -y + + - name: Install pip dependencies and unilabos + run: | + call conda activate check-env + echo Installing pip dependencies... + uv pip install -r unilabos/utils/requirements.txt + uv pip install pywinauto git+https://github.com/Xuwznln/pylabrobot.git + uv pip uninstall enum34 || echo enum34 not installed, skipping + uv pip install . + + - name: Run check mode (complete_registry) + run: | + call conda activate check-env + echo Running check mode... + python -m unilabos --check_mode --skip_env_check + + - name: Check for uncommitted changes + shell: bash + run: | + if ! git diff --exit-code; then + echo "::error::检测到文件变化!请先在本地运行 'python -m unilabos --complete_registry' 并提交变更" + echo "变化的文件:" + git diff --name-only + exit 1 + fi + echo "检查通过:无文件变化" diff --git a/.github/workflows/conda-pack-build.yml b/.github/workflows/conda-pack-build.yml index 3a379fa5a..ed45db9d4 100644 --- a/.github/workflows/conda-pack-build.yml +++ b/.github/workflows/conda-pack-build.yml @@ -13,6 +13,11 @@ on: required: false default: 'win-64' type: string + build_full: + description: '是否构建完整版 unilabos-full (默认构建轻量版 unilabos)' + required: false + default: false + type: boolean jobs: build-conda-pack: @@ -57,7 +62,7 @@ jobs: echo "should_build=false" >> $GITHUB_OUTPUT fi - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 if: steps.should_build.outputs.should_build == 'true' with: ref: ${{ github.event.inputs.branch }} @@ -69,7 +74,7 @@ jobs: with: miniforge-version: latest use-mamba: true - python-version: '3.11.11' + python-version: '3.11.14' channels: conda-forge,robostack-staging,uni-lab,defaults channel-priority: flexible activate-environment: unilab @@ -81,7 +86,14 @@ jobs: run: | echo Installing unilabos and dependencies to unilab environment... echo Using mamba for faster and more reliable dependency resolution... - mamba install -n unilab uni-lab::unilabos conda-pack -c uni-lab -c robostack-staging -c conda-forge -y + echo Build full: ${{ github.event.inputs.build_full }} + if "${{ github.event.inputs.build_full }}"=="true" ( + echo Installing unilabos-full ^(complete package^)... + mamba install -n unilab uni-lab::unilabos-full conda-pack -c uni-lab -c robostack-staging -c conda-forge -y + ) else ( + echo Installing unilabos ^(minimal package^)... + mamba install -n unilab uni-lab::unilabos conda-pack -c uni-lab -c robostack-staging -c conda-forge -y + ) - name: Install conda-pack, unilabos and dependencies (Unix) if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64' @@ -89,7 +101,14 @@ jobs: run: | echo "Installing unilabos and dependencies to unilab environment..." echo "Using mamba for faster and more reliable dependency resolution..." - mamba install -n unilab uni-lab::unilabos conda-pack -c uni-lab -c robostack-staging -c conda-forge -y + echo "Build full: ${{ github.event.inputs.build_full }}" + if [[ "${{ github.event.inputs.build_full }}" == "true" ]]; then + echo "Installing unilabos-full (complete package)..." + mamba install -n unilab uni-lab::unilabos-full conda-pack -c uni-lab -c robostack-staging -c conda-forge -y + else + echo "Installing unilabos (minimal package)..." + mamba install -n unilab uni-lab::unilabos conda-pack -c uni-lab -c robostack-staging -c conda-forge -y + fi - name: Get latest ros-humble-unilabos-msgs version (Windows) if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64' @@ -293,7 +312,7 @@ jobs: - name: Upload distribution package if: steps.should_build.outputs.should_build == 'true' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }} path: dist-package/ @@ -308,7 +327,12 @@ jobs: echo ========================================== echo Platform: ${{ matrix.platform }} echo Branch: ${{ github.event.inputs.branch }} - echo Python version: 3.11.11 + echo Python version: 3.11.14 + if "${{ github.event.inputs.build_full }}"=="true" ( + echo Package: unilabos-full ^(complete^) + ) else ( + echo Package: unilabos ^(minimal^) + ) echo. echo Distribution package contents: dir dist-package @@ -328,7 +352,12 @@ jobs: echo "==========================================" echo "Platform: ${{ matrix.platform }}" echo "Branch: ${{ github.event.inputs.branch }}" - echo "Python version: 3.11.11" + echo "Python version: 3.11.14" + if [[ "${{ github.event.inputs.build_full }}" == "true" ]]; then + echo "Package: unilabos-full (complete)" + else + echo "Package: unilabos (minimal)" + fi echo "" echo "Distribution package contents:" ls -lh dist-package/ diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 66aef8d68..f3ac4d11f 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -1,10 +1,12 @@ name: Deploy Docs on: - push: - branches: [main] - pull_request: + # 在 CI Check 成功后自动触发(仅 main 分支) + workflow_run: + workflows: ["CI Check"] + types: [completed] branches: [main] + # 手动触发 workflow_dispatch: inputs: branch: @@ -33,12 +35,19 @@ concurrency: jobs: # Build documentation build: + # 只在以下情况运行: + # 1. workflow_run 触发且 CI Check 成功 + # 2. 手动触发 + if: | + github.event_name == 'workflow_dispatch' || + (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: - ref: ${{ github.event.inputs.branch || github.ref }} + # workflow_run 时使用触发工作流的分支,手动触发时使用输入的分支 + ref: ${{ github.event.workflow_run.head_branch || github.event.inputs.branch || github.ref }} fetch-depth: 0 - name: Setup Miniforge (with mamba) @@ -46,7 +55,7 @@ jobs: with: miniforge-version: latest use-mamba: true - python-version: '3.11.11' + python-version: '3.11.14' channels: conda-forge,robostack-staging,uni-lab,defaults channel-priority: flexible activate-environment: unilab @@ -75,8 +84,10 @@ jobs: - name: Setup Pages id: pages - uses: actions/configure-pages@v4 - if: github.ref == 'refs/heads/main' || (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true') + uses: actions/configure-pages@v5 + if: | + github.event.workflow_run.head_branch == 'main' || + (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true') - name: Build Sphinx documentation run: | @@ -94,14 +105,18 @@ jobs: test -f docs/_build/html/index.html && echo "✓ index.html exists" || echo "✗ index.html missing" - name: Upload build artifacts - uses: actions/upload-pages-artifact@v3 - if: github.ref == 'refs/heads/main' || (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true') + uses: actions/upload-pages-artifact@v4 + if: | + github.event.workflow_run.head_branch == 'main' || + (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true') with: path: docs/_build/html # Deploy to GitHub Pages deploy: - if: github.ref == 'refs/heads/main' || (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true') + if: | + github.event.workflow_run.head_branch == 'main' || + (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true') environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} diff --git a/.github/workflows/multi-platform-build.yml b/.github/workflows/multi-platform-build.yml index bcba6dbfd..4e1cf4f7b 100644 --- a/.github/workflows/multi-platform-build.yml +++ b/.github/workflows/multi-platform-build.yml @@ -1,11 +1,16 @@ name: Multi-Platform Conda Build on: - push: + # 在 CI Check 工作流完成后触发(仅限 main/dev 分支) + workflow_run: + workflows: ["CI Check"] + types: + - completed branches: [main, dev] + # 支持 tag 推送(不依赖 CI Check) + push: tags: ['v*'] - pull_request: - branches: [main, dev] + # 手动触发 workflow_dispatch: inputs: platforms: @@ -17,9 +22,37 @@ on: required: false default: false type: boolean + skip_ci_check: + description: '跳过等待 CI Check (手动触发时可选)' + required: false + default: false + type: boolean jobs: + # 等待 CI Check 完成的 job (仅用于 workflow_run 触发) + wait-for-ci: + runs-on: ubuntu-latest + if: github.event_name == 'workflow_run' + outputs: + should_continue: ${{ steps.check.outputs.should_continue }} + steps: + - name: Check CI status + id: check + run: | + if [[ "${{ github.event.workflow_run.conclusion }}" == "success" ]]; then + echo "should_continue=true" >> $GITHUB_OUTPUT + echo "CI Check passed, proceeding with build" + else + echo "should_continue=false" >> $GITHUB_OUTPUT + echo "CI Check did not succeed (status: ${{ github.event.workflow_run.conclusion }}), skipping build" + fi + build: + needs: [wait-for-ci] + # 运行条件:workflow_run 触发且 CI 成功,或者其他触发方式 + if: | + always() && + (needs.wait-for-ci.result == 'skipped' || needs.wait-for-ci.outputs.should_continue == 'true') strategy: fail-fast: false matrix: @@ -44,8 +77,10 @@ jobs: shell: bash -l {0} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: + # 如果是 workflow_run 触发,使用触发 CI Check 的 commit + ref: ${{ github.event.workflow_run.head_sha || github.ref }} fetch-depth: 0 - name: Check if platform should be built @@ -69,7 +104,6 @@ jobs: channels: conda-forge,robostack-staging,defaults channel-priority: strict activate-environment: build-env - auto-activate-base: false auto-update-conda: false show-channel-urls: true @@ -115,7 +149,7 @@ jobs: - name: Upload conda package artifacts if: steps.should_build.outputs.should_build == 'true' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: conda-package-${{ matrix.platform }} path: conda-packages-temp diff --git a/.github/workflows/unilabos-conda-build.yml b/.github/workflows/unilabos-conda-build.yml index 214f9bfc9..d116a67ee 100644 --- a/.github/workflows/unilabos-conda-build.yml +++ b/.github/workflows/unilabos-conda-build.yml @@ -1,25 +1,62 @@ name: UniLabOS Conda Build on: - push: + # 在 CI Check 成功后自动触发 + workflow_run: + workflows: ["CI Check"] + types: [completed] branches: [main, dev] + # 标签推送时直接触发(发布版本) + push: tags: ['v*'] - pull_request: - branches: [main, dev] + # 手动触发 workflow_dispatch: inputs: platforms: description: '选择构建平台 (逗号分隔): linux-64, osx-64, osx-arm64, win-64' required: false default: 'linux-64' + build_full: + description: '是否构建 unilabos-full 完整包 (默认只构建 unilabos 基础包)' + required: false + default: false + type: boolean upload_to_anaconda: description: '是否上传到Anaconda.org' required: false default: false type: boolean + skip_ci_check: + description: '跳过等待 CI Check (手动触发时可选)' + required: false + default: false + type: boolean jobs: + # 等待 CI Check 完成的 job (仅用于 workflow_run 触发) + wait-for-ci: + runs-on: ubuntu-latest + if: github.event_name == 'workflow_run' + outputs: + should_continue: ${{ steps.check.outputs.should_continue }} + steps: + - name: Check CI status + id: check + run: | + if [[ "${{ github.event.workflow_run.conclusion }}" == "success" ]]; then + echo "should_continue=true" >> $GITHUB_OUTPUT + echo "CI Check passed, proceeding with build" + else + echo "should_continue=false" >> $GITHUB_OUTPUT + echo "CI Check did not succeed (status: ${{ github.event.workflow_run.conclusion }}), skipping build" + fi + build: + needs: [wait-for-ci] + # 运行条件:workflow_run 触发且 CI 成功,或者其他触发方式 + if: | + always() && + (needs.wait-for-ci.result == 'skipped' || needs.wait-for-ci.outputs.should_continue == 'true') strategy: fail-fast: false matrix: @@ -40,8 +77,10 @@ jobs: shell: bash -l {0} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: + # 如果是 workflow_run 触发,使用触发 CI Check 的 commit + ref: ${{ github.event.workflow_run.head_sha || github.ref }} fetch-depth: 0 - name: Check if platform should be built @@ -65,7 +104,6 @@ jobs: channels: conda-forge,robostack-staging,uni-lab,defaults channel-priority: strict activate-environment: build-env - auto-activate-base: false auto-update-conda: false show-channel-urls: true @@ -81,12 +119,61 @@ jobs: conda list | grep -E "(rattler-build|anaconda-client)" echo "Platform: ${{ matrix.platform }}" echo "OS: ${{ matrix.os }}" - echo "Building UniLabOS package" + echo "Build full package: ${{ github.event.inputs.build_full || 'false' }}" + echo "Building packages:" + echo " - unilabos-env (environment dependencies)" + echo " - unilabos (with pip package)" + if [[ "${{ github.event.inputs.build_full }}" == "true" ]]; then + echo " - unilabos-full (complete package)" + fi + + - name: Build unilabos-env (conda environment only, noarch) + if: steps.should_build.outputs.should_build == 'true' + run: | + echo "Building unilabos-env (conda environment dependencies)..." + rattler-build build -r .conda/environment/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge - - name: Build conda package + - name: Upload unilabos-env to Anaconda.org (if enabled) + if: steps.should_build.outputs.should_build == 'true' && github.event.inputs.upload_to_anaconda == 'true' + run: | + echo "Uploading unilabos-env to uni-lab organization..." + for package in $(find ./output -name "unilabos-env*.conda"); do + anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" + done + + - name: Build unilabos (with pip package) if: steps.should_build.outputs.should_build == 'true' run: | - rattler-build build -r .conda/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge + echo "Building unilabos package..." + # 如果已上传到 Anaconda,从 uni-lab channel 获取 unilabos-env;否则从本地 output 获取 + rattler-build build -r .conda/base/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge --channel ./output + + - name: Upload unilabos to Anaconda.org (if enabled) + if: steps.should_build.outputs.should_build == 'true' && github.event.inputs.upload_to_anaconda == 'true' + run: | + echo "Uploading unilabos to uni-lab organization..." + for package in $(find ./output -name "unilabos-0*.conda" -o -name "unilabos-[0-9]*.conda"); do + anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" + done + + - name: Build unilabos-full - Only when explicitly requested + if: | + steps.should_build.outputs.should_build == 'true' && + github.event.inputs.build_full == 'true' + run: | + echo "Building unilabos-full package on ${{ matrix.platform }}..." + rattler-build build -r .conda/full/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge --channel ./output + + - name: Upload unilabos-full to Anaconda.org (if enabled) + if: | + steps.should_build.outputs.should_build == 'true' && + github.event.inputs.build_full == 'true' && + github.event.inputs.upload_to_anaconda == 'true' + run: | + echo "Uploading unilabos-full to uni-lab organization..." + for package in $(find ./output -name "unilabos-full*.conda"); do + anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" + done - name: List built packages if: steps.should_build.outputs.should_build == 'true' @@ -108,17 +195,9 @@ jobs: - name: Upload conda package artifacts if: steps.should_build.outputs.should_build == 'true' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: conda-package-unilabos-${{ matrix.platform }} path: conda-packages-temp if-no-files-found: warn retention-days: 30 - - - name: Upload to Anaconda.org (uni-lab organization) - if: github.event.inputs.upload_to_anaconda == 'true' - run: | - for package in $(find ./output -name "*.conda"); do - echo "Uploading $package to uni-lab organization..." - anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" - done diff --git a/.gitignore b/.gitignore index 610be6149..838331e3a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ temp/ output/ unilabos_data/ pyrightconfig.json +.cursorignore ## Python # Byte-compiled / optimized / DLL files diff --git a/MANIFEST.in b/MANIFEST.in index d81945e60..156ca52b5 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,5 @@ recursive-include unilabos/test * +recursive-include unilabos/utils * recursive-include unilabos/registry *.yaml recursive-include unilabos/app/web/static * recursive-include unilabos/app/web/templates * diff --git a/README.md b/README.md index f10cc0f04..fa0d9dd84 100644 --- a/README.md +++ b/README.md @@ -31,26 +31,46 @@ Detailed documentation can be found at: ## Quick Start -1. Setup Conda Environment +### 1. Setup Conda Environment -Uni-Lab-OS recommends using `mamba` for environment management: +Uni-Lab-OS recommends using `mamba` for environment management. Choose the package that fits your needs: + +| Package | Use Case | Contents | +|---------|----------|----------| +| `unilabos` | **Recommended for most users** | Complete package, ready to use | +| `unilabos-env` | Developers (editable install) | Environment only, install unilabos via pip | +| `unilabos-full` | Simulation/Visualization | unilabos + ROS2 Desktop + Gazebo + MoveIt | ```bash # Create new environment -mamba create -n unilab python=3.11.11 +mamba create -n unilab python=3.11.14 mamba activate unilab -mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge + +# Option A: Standard installation (recommended for most users) +mamba install uni-lab::unilabos -c robostack-staging -c conda-forge + +# Option B: For developers (editable mode development) +mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge +# Then install unilabos and dependencies: +git clone https://github.com/deepmodeling/Uni-Lab-OS.git && cd Uni-Lab-OS +pip install -e . +uv pip install -r unilabos/utils/requirements.txt + +# Option C: Full installation (simulation/visualization) +mamba install uni-lab::unilabos-full -c robostack-staging -c conda-forge ``` -2. Install Dev Uni-Lab-OS +**When to use which?** +- **unilabos**: Standard installation for production deployment and general usage (recommended) +- **unilabos-env**: For developers who need `pip install -e .` editable mode, modify source code +- **unilabos-full**: For simulation (Gazebo), visualization (rviz2), and Jupyter notebooks + +### 2. Clone Repository (Optional, for developers) ```bash -# Clone the repository +# Clone the repository (only needed for development or examples) git clone https://github.com/deepmodeling/Uni-Lab-OS.git cd Uni-Lab-OS - -# Install Uni-Lab-OS -pip install . ``` 3. Start Uni-Lab System diff --git a/README_zh.md b/README_zh.md index c4dba7d1f..20b8f53ab 100644 --- a/README_zh.md +++ b/README_zh.md @@ -31,26 +31,46 @@ Uni-Lab-OS 是一个用于实验室自动化的综合平台,旨在连接和控 ## 快速开始 -1. 配置 Conda 环境 +### 1. 配置 Conda 环境 -Uni-Lab-OS 建议使用 `mamba` 管理环境。根据您的操作系统选择适当的环境文件: +Uni-Lab-OS 建议使用 `mamba` 管理环境。根据您的需求选择合适的安装包: + +| 安装包 | 适用场景 | 包含内容 | +|--------|----------|----------| +| `unilabos` | **推荐大多数用户** | 完整安装包,开箱即用 | +| `unilabos-env` | 开发者(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos | +| `unilabos-full` | 仿真/可视化 | unilabos + ROS2 桌面版 + Gazebo + MoveIt | ```bash # 创建新环境 -mamba create -n unilab python=3.11.11 +mamba create -n unilab python=3.11.14 mamba activate unilab -mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge + +# 方案 A:标准安装(推荐大多数用户) +mamba install uni-lab::unilabos -c robostack-staging -c conda-forge + +# 方案 B:开发者环境(可编辑模式开发) +mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge +# 然后安装 unilabos 和依赖: +git clone https://github.com/deepmodeling/Uni-Lab-OS.git && cd Uni-Lab-OS +pip install -e . +uv pip install -r unilabos/utils/requirements.txt + +# 方案 C:完整安装(仿真/可视化) +mamba install uni-lab::unilabos-full -c robostack-staging -c conda-forge ``` -2. 安装开发版 Uni-Lab-OS: +**如何选择?** +- **unilabos**:标准安装,适用于生产部署和日常使用(推荐) +- **unilabos-env**:开发者使用,支持 `pip install -e .` 可编辑模式,可修改源代码 +- **unilabos-full**:需要仿真(Gazebo)、可视化(rviz2)或 Jupyter Notebook + +### 2. 克隆仓库(可选,供开发者使用) ```bash -# 克隆仓库 +# 克隆仓库(仅开发或查看示例时需要) git clone https://github.com/deepmodeling/Uni-Lab-OS.git cd Uni-Lab-OS - -# 安装 Uni-Lab-OS -pip install . ``` 3. 启动 Uni-Lab 系统 diff --git a/docs/user_guide/best_practice.md b/docs/user_guide/best_practice.md index e1ffc2491..767dc4d81 100644 --- a/docs/user_guide/best_practice.md +++ b/docs/user_guide/best_practice.md @@ -31,6 +31,14 @@ 详细的安装步骤请参考 [安装指南](installation.md)。 +**选择合适的安装包:** + +| 安装包 | 适用场景 | 包含组件 | +|--------|----------|----------| +| `unilabos` | **推荐大多数用户**,生产部署 | 完整安装包,开箱即用 | +| `unilabos-env` | 开发者(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos | +| `unilabos-full` | 仿真/可视化 | unilabos + 完整 ROS2 桌面版 + Gazebo + MoveIt | + **关键步骤:** ```bash @@ -38,15 +46,30 @@ # 下载 Miniforge: https://github.com/conda-forge/miniforge/releases # 2. 创建 Conda 环境 -mamba create -n unilab python=3.11.11 +mamba create -n unilab python=3.11.14 # 3. 激活环境 mamba activate unilab -# 4. 安装 Uni-Lab-OS +# 4. 安装 Uni-Lab-OS(选择其一) + +# 方案 A:标准安装(推荐大多数用户) mamba install uni-lab::unilabos -c robostack-staging -c conda-forge + +# 方案 B:开发者环境(可编辑模式开发) +mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge +pip install -e /path/to/Uni-Lab-OS # 可编辑安装 +uv pip install -r unilabos/utils/requirements.txt # 安装 pip 依赖 + +# 方案 C:完整版(仿真/可视化) +mamba install uni-lab::unilabos-full -c robostack-staging -c conda-forge ``` +**选择建议:** +- **日常使用/生产部署**:使用 `unilabos`(推荐),完整功能,开箱即用 +- **开发者**:使用 `unilabos-env` + `pip install -e .` + `uv pip install -r unilabos/utils/requirements.txt`,代码修改立即生效 +- **仿真/可视化**:使用 `unilabos-full`,含 Gazebo、rviz2、MoveIt + #### 1.2 验证安装 ```bash @@ -416,6 +439,9 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json 1. 访问 Web 界面,进入"仪器耗材"模块 2. 在"仪器设备"区域找到并添加上述设备 3. 在"物料耗材"区域找到并添加容器 +4. 在workstation中配置protocol_type包含PumpTransferProtocol + +![添加Protocol类型](image/add_protocol.png) ![物料列表](image/material.png) @@ -768,7 +794,43 @@ Waiting for host service... 详细的设备驱动编写指南请参考 [添加设备驱动](../developer_guide/add_device.md)。 -#### 9.1 为什么需要自定义设备? +#### 9.1 开发环境准备 + +**推荐使用 `unilabos-env` + `pip install -e .` + `uv pip install`** 进行设备开发: + +```bash +# 1. 创建环境并安装 unilabos-env(ROS2 + conda 依赖 + uv) +mamba create -n unilab python=3.11.14 +conda activate unilab +mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge + +# 2. 克隆代码 +git clone https://github.com/deepmodeling/Uni-Lab-OS.git +cd Uni-Lab-OS + +# 3. 以可编辑模式安装(推荐使用脚本,自动检测中文环境) +python scripts/dev_install.py + +# 或手动安装: +pip install -e . +uv pip install -r unilabos/utils/requirements.txt +``` + +**为什么使用这种方式?** +- `unilabos-env` 提供 ROS2 核心组件和 uv(通过 conda 安装,避免编译) +- `unilabos/utils/requirements.txt` 包含所有运行时需要的 pip 依赖 +- `dev_install.py` 自动检测中文环境,中文系统自动使用清华镜像 +- 使用 `uv` 替代 `pip`,安装速度更快 +- 可编辑模式:代码修改**立即生效**,无需重新安装 + +**如果安装失败或速度太慢**,可以手动执行(使用清华镜像): + +```bash +pip install -e . -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple +uv pip install -r unilabos/utils/requirements.txt -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple +``` + +#### 9.2 为什么需要自定义设备? Uni-Lab-OS 内置了常见设备,但您的实验室可能有特殊设备需要集成: @@ -777,7 +839,7 @@ Uni-Lab-OS 内置了常见设备,但您的实验室可能有特殊设备需要 - 特殊的实验流程 - 第三方设备集成 -#### 9.2 创建 Python 包 +#### 9.3 创建 Python 包 为了方便开发和管理,建议为您的实验室创建独立的 Python 包。 @@ -814,7 +876,7 @@ touch my_lab_devices/my_lab_devices/__init__.py touch my_lab_devices/my_lab_devices/devices/__init__.py ``` -#### 9.3 创建 setup.py +#### 9.4 创建 setup.py ```python # my_lab_devices/setup.py @@ -845,7 +907,7 @@ setup( ) ``` -#### 9.4 开发安装 +#### 9.5 开发安装 使用 `-e` 参数进行可编辑安装,这样代码修改后立即生效: @@ -860,7 +922,7 @@ pip install -e . -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple - 方便调试和测试 - 支持版本控制(git) -#### 9.5 编写设备驱动 +#### 9.6 编写设备驱动 创建设备驱动文件: @@ -1001,7 +1063,7 @@ class MyPump: - **返回 Dict**:所有动作方法返回字典类型 - **文档字符串**:详细说明参数和功能 -#### 9.6 测试设备驱动 +#### 9.7 测试设备驱动 创建简单的测试脚本: diff --git a/docs/user_guide/image/add_protocol.png b/docs/user_guide/image/add_protocol.png new file mode 100644 index 0000000000000000000000000000000000000000..ce3b3813d5392a3c0c7ae526f4dc905456c4834f GIT binary patch literal 83318 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 literal 0 HcmV?d00001 diff --git a/docs/user_guide/installation.md b/docs/user_guide/installation.md index 3f94f2ff7..acf8fb641 100644 --- a/docs/user_guide/installation.md +++ b/docs/user_guide/installation.md @@ -13,15 +13,26 @@ - 开发者需要 Git 和基本的 Python 开发知识 - 自定义 msgs 需要 GitHub 账号 +## 安装包选择 + +Uni-Lab-OS 提供三个安装包版本,根据您的需求选择: + +| 安装包 | 适用场景 | 包含组件 | 磁盘占用 | +|--------|----------|----------|----------| +| **unilabos** | **推荐大多数用户**,生产部署 | 完整安装包,开箱即用 | ~2-3 GB | +| **unilabos-env** | 开发者环境(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos | ~2 GB | +| **unilabos-full** | 仿真可视化、完整功能体验 | unilabos + 完整 ROS2 桌面版 + Gazebo + MoveIt | ~8-10 GB | + ## 安装方式选择 根据您的使用场景,选择合适的安装方式: -| 安装方式 | 适用人群 | 特点 | 安装时间 | -| ---------------------- | -------------------- | ------------------------------ | ---------------------------- | -| **方式一:一键安装** | 实验室用户、快速体验 | 预打包环境,离线可用,无需配置 | 5-10 分钟 (网络良好的情况下) | -| **方式二:手动安装** | 标准用户、生产环境 | 灵活配置,版本可控 | 10-20 分钟 | -| **方式三:开发者安装** | 开发者、需要修改源码 | 可编辑模式,支持自定义 msgs | 20-30 分钟 | +| 安装方式 | 适用人群 | 推荐安装包 | 特点 | 安装时间 | +| ---------------------- | -------------------- | ----------------- | ------------------------------ | ---------------------------- | +| **方式一:一键安装** | 快速体验、演示 | 预打包环境 | 离线可用,无需配置 | 5-10 分钟 (网络良好的情况下) | +| **方式二:手动安装** | **大多数用户** | `unilabos` | 完整功能,开箱即用 | 10-20 分钟 | +| **方式三:开发者安装** | 开发者、需要修改源码 | `unilabos-env` | 可编辑模式,支持自定义开发 | 20-30 分钟 | +| **仿真/可视化** | 仿真测试、可视化调试 | `unilabos-full` | 含 Gazebo、rviz2、MoveIt | 30-60 分钟 | --- @@ -144,17 +155,38 @@ bash Miniforge3-$(uname)-$(uname -m).sh 使用以下命令创建 Uni-Lab 专用环境: ```bash -mamba create -n unilab python=3.11.11 # 目前ros2组件依赖版本大多为3.11.11 +mamba create -n unilab python=3.11.14 # 目前ros2组件依赖版本大多为3.11.14 mamba activate unilab -mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge + +# 选择安装包(三选一): + +# 方案 A:标准安装(推荐大多数用户) +mamba install uni-lab::unilabos -c robostack-staging -c conda-forge + +# 方案 B:开发者环境(可编辑模式开发) +mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge +# 然后安装 unilabos 和 pip 依赖: +git clone https://github.com/deepmodeling/Uni-Lab-OS.git && cd Uni-Lab-OS +pip install -e . +uv pip install -r unilabos/utils/requirements.txt + +# 方案 C:完整版(含仿真和可视化工具) +mamba install uni-lab::unilabos-full -c robostack-staging -c conda-forge ``` **参数说明**: - `-n unilab`: 创建名为 "unilab" 的环境 -- `uni-lab::unilabos`: 从 uni-lab channel 安装 unilabos 包 +- `uni-lab::unilabos`: 安装 unilabos 完整包,开箱即用(推荐) +- `uni-lab::unilabos-env`: 仅安装环境依赖,适合开发者使用 `pip install -e .` +- `uni-lab::unilabos-full`: 安装完整包(含 ROS2 Desktop、Gazebo、MoveIt 等) - `-c robostack-staging -c conda-forge`: 添加额外的软件源 +**包选择建议**: +- **日常使用/生产部署**:安装 `unilabos`(推荐,完整功能,开箱即用) +- **开发者**:安装 `unilabos-env`,然后使用 `uv pip install -r unilabos/utils/requirements.txt` 安装依赖,再 `pip install -e .` 进行可编辑安装 +- **仿真/可视化**:安装 `unilabos-full`(Gazebo、rviz2、MoveIt) + **如果遇到网络问题**,可以使用清华镜像源加速下载: ```bash @@ -163,8 +195,14 @@ mamba config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/m mamba config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/free/ mamba config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/ -# 然后重新执行安装命令 +# 然后重新执行安装命令(推荐标准安装) mamba create -n unilab uni-lab::unilabos -c robostack-staging + +# 或完整版(仿真/可视化) +mamba create -n unilab uni-lab::unilabos-full -c robostack-staging + +# pip 安装时使用清华镜像(开发者安装时使用) +uv pip install -r unilabos/utils/requirements.txt -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple ``` ### 第三步:激活环境 @@ -203,58 +241,87 @@ cd Uni-Lab-OS cd Uni-Lab-OS ``` -### 第二步:安装基础环境 - -**推荐方式**:先通过**方式一(一键安装)**或**方式二(手动安装)**完成基础环境的安装,这将包含所有必需的依赖项(ROS2、msgs 等)。 - -#### 选项 A:通过一键安装(推荐) +### 第二步:安装开发环境(unilabos-env) -参考上文"方式一:一键安装",完成基础环境的安装后,激活环境: +**重要**:开发者请使用 `unilabos-env` 包,它专为开发者设计: +- 包含 ROS2 核心组件和消息包(ros-humble-ros-core、std-msgs、geometry-msgs 等) +- 包含 transforms3d、cv-bridge、tf2 等 conda 依赖 +- 包含 `uv` 工具,用于快速安装 pip 依赖 +- **不包含** pip 依赖和 unilabos 包(由 `pip install -e .` 和 `uv pip install` 安装) ```bash +# 创建并激活环境 +mamba create -n unilab python=3.11.14 conda activate unilab + +# 安装开发者环境包(ROS2 + conda 依赖 + uv) +mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge ``` -#### 选项 B:通过手动安装 +### 第三步:安装 pip 依赖和可编辑模式安装 -参考上文"方式二:手动安装",创建并安装环境: +克隆代码并安装依赖: ```bash -mamba create -n unilab python=3.11.11 +# 确保环境已激活 conda activate unilab -mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge -``` -**说明**:这会安装包括 Python 3.11.11、ROS2 Humble、ros-humble-unilabos-msgs 和所有必需依赖 +# 克隆仓库(如果还未克隆) +git clone https://github.com/deepmodeling/Uni-Lab-OS.git +cd Uni-Lab-OS -### 第三步:切换到开发版本 +# 切换到 dev 分支(可选) +git checkout dev +git pull +``` -现在你已经有了一个完整可用的 Uni-Lab 环境,接下来将 unilabos 包切换为开发版本: +**推荐:使用安装脚本**(自动检测中文环境,使用 uv 加速): ```bash -# 确保环境已激活 -conda activate unilab +# 自动检测中文环境,如果是中文系统则使用清华镜像 +python scripts/dev_install.py + +# 或者手动指定: +python scripts/dev_install.py --china # 强制使用清华镜像 +python scripts/dev_install.py --no-mirror # 强制使用 PyPI +python scripts/dev_install.py --skip-deps # 跳过 pip 依赖安装 +python scripts/dev_install.py --use-pip # 使用 pip 而非 uv +``` -# 卸载 pip 安装的 unilabos(保留所有 conda 依赖) -pip uninstall unilabos -y +**手动安装**(如果脚本安装失败或速度太慢): -# 克隆 dev 分支(如果还未克隆) -cd /path/to/your/workspace -git clone -b dev https://github.com/deepmodeling/Uni-Lab-OS.git -# 或者如果已经克隆,切换到 dev 分支 -cd Uni-Lab-OS -git checkout dev -git pull +```bash +# 1. 安装 unilabos(可编辑模式) +pip install -e . + +# 2. 使用 uv 安装 pip 依赖(推荐,速度更快) +uv pip install -r unilabos/utils/requirements.txt -# 以可编辑模式安装开发版 unilabos +# 国内用户使用清华镜像: pip install -e . -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple +uv pip install -r unilabos/utils/requirements.txt -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple ``` -**参数说明**: +**注意**: +- `uv` 已包含在 `unilabos-env` 中,无需单独安装 +- `unilabos/utils/requirements.txt` 包含运行 unilabos 所需的所有 pip 依赖 +- 部分特殊包(如 pylabrobot)会在运行时由 unilabos 自动检测并安装 + +**为什么使用可编辑模式?** -- `-e`: editable mode(可编辑模式),代码修改立即生效,无需重新安装 -- `-i`: 使用清华镜像源加速下载 -- `pip uninstall unilabos`: 只卸载 pip 安装的 unilabos 包,不影响 conda 安装的其他依赖(如 ROS2、msgs 等) +- `-e` (editable mode):代码修改**立即生效**,无需重新安装 +- 适合开发调试:修改代码后直接运行测试 +- 与 `unilabos-env` 配合:环境依赖由 conda 管理,unilabos 代码由 pip 管理 + +**验证安装**: + +```bash +# 检查 unilabos 版本 +python -c "import unilabos; print(unilabos.__version__)" + +# 检查安装位置(应该指向你的代码目录) +pip show unilabos | grep Location +``` ### 第四步:安装或自定义 ros-humble-unilabos-msgs(可选) @@ -464,7 +531,45 @@ cd $CONDA_PREFIX/envs/unilab ### 问题 8: 环境很大,有办法减小吗? -**解决方案**: 预打包的环境包含所有依赖,通常较大(压缩后 2-5GB)。这是为了确保离线安装和完整功能。如果空间有限,考虑使用方式二手动安装,只安装需要的组件。 +**解决方案**: + +1. **使用 `unilabos` 标准版**(推荐大多数用户): + ```bash + mamba install uni-lab::unilabos -c robostack-staging -c conda-forge + ``` + 标准版包含完整功能,环境大小约 2-3GB(相比完整版的 8-10GB)。 + +2. **使用 `unilabos-env` 开发者版**(最小化): + ```bash + mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge + # 然后手动安装依赖 + pip install -e . + uv pip install -r unilabos/utils/requirements.txt + ``` + 开发者版只包含环境依赖,体积最小约 2GB。 + +3. **按需安装额外组件**: + 如果后续需要特定功能,可以单独安装: + ```bash + # 需要 Jupyter + mamba install jupyter jupyros + + # 需要可视化 + mamba install matplotlib opencv + + # 需要仿真(注意:这会安装大量依赖) + mamba install ros-humble-gazebo-ros + ``` + +4. **预打包环境问题**: + 预打包环境(方式一)包含所有依赖,通常较大(压缩后 2-5GB)。这是为了确保离线安装和完整功能。 + +**包选择建议**: +| 需求 | 推荐包 | 预估大小 | +|------|--------|----------| +| 日常使用/生产部署 | `unilabos` | ~2-3 GB | +| 开发调试(可编辑模式) | `unilabos-env` | ~2 GB | +| 仿真/可视化 | `unilabos-full` | ~8-10 GB | ### 问题 9: 如何更新到最新版本? @@ -511,6 +616,7 @@ mamba update ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-f **提示**: -- 生产环境推荐使用方式二(手动安装)的稳定版本 -- 开发和测试推荐使用方式三(开发者安装) -- 快速体验和演示推荐使用方式一(一键安装) +- **大多数用户**推荐使用方式二(手动安装)的 `unilabos` 标准版 +- **开发者**推荐使用方式三(开发者安装),安装 `unilabos-env` 后使用 `uv pip install -r unilabos/utils/requirements.txt` 安装依赖 +- **仿真/可视化**推荐安装 `unilabos-full` 完整版 +- **快速体验和演示**推荐使用方式一(一键安装) diff --git a/recipes/msgs/recipe.yaml b/recipes/msgs/recipe.yaml index 6d329081e..f78df2e96 100644 --- a/recipes/msgs/recipe.yaml +++ b/recipes/msgs/recipe.yaml @@ -1,6 +1,6 @@ package: name: ros-humble-unilabos-msgs - version: 0.10.15 + version: 0.10.17 source: path: ../../unilabos_msgs target_directory: src @@ -25,7 +25,7 @@ requirements: build: - ${{ compiler('cxx') }} - ${{ compiler('c') }} - - python ==3.11.11 + - python ==3.11.14 - numpy - if: build_platform != target_platform then: @@ -63,14 +63,14 @@ requirements: - robostack-staging::ros-humble-rosidl-default-generators - robostack-staging::ros-humble-std-msgs - robostack-staging::ros-humble-geometry-msgs - - robostack-staging::ros2-distro-mutex=0.6 + - robostack-staging::ros2-distro-mutex=0.7 run: - robostack-staging::ros-humble-action-msgs - robostack-staging::ros-humble-ros-workspace - robostack-staging::ros-humble-rosidl-default-runtime - robostack-staging::ros-humble-std-msgs - robostack-staging::ros-humble-geometry-msgs - - robostack-staging::ros2-distro-mutex=0.6 + - robostack-staging::ros2-distro-mutex=0.7 - if: osx and x86_64 then: - __osx >=${{ MACOSX_DEPLOYMENT_TARGET|default('10.14') }} diff --git a/recipes/unilabos/recipe.yaml b/recipes/unilabos/recipe.yaml index be3f1a161..feca503a3 100644 --- a/recipes/unilabos/recipe.yaml +++ b/recipes/unilabos/recipe.yaml @@ -1,6 +1,6 @@ package: name: unilabos - version: "0.10.15" + version: "0.10.17" source: path: ../.. diff --git a/scripts/create_readme.py b/scripts/create_readme.py index c4f39330b..e87c1d8bc 100644 --- a/scripts/create_readme.py +++ b/scripts/create_readme.py @@ -85,7 +85,7 @@ def get_readme_content(platform: str, branch: str) -> str: ------------- The verify_installation.py script will check: - - Python version (3.11.11) + - Python version (3.11.14) - ROS2 rclpy installation - UniLabOS installation and dependencies @@ -104,7 +104,7 @@ def get_readme_content(platform: str, branch: str) -> str: Branch: {branch} Platform: {platform} - Python: 3.11.11 + Python: 3.11.14 Date: {build_date} Troubleshooting: diff --git a/scripts/dev_install.py b/scripts/dev_install.py new file mode 100644 index 000000000..002db24ed --- /dev/null +++ b/scripts/dev_install.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +""" +Development installation script for UniLabOS. +Auto-detects Chinese locale and uses appropriate mirror. + +Usage: + python scripts/dev_install.py + python scripts/dev_install.py --no-mirror # Force no mirror + python scripts/dev_install.py --china # Force China mirror + python scripts/dev_install.py --skip-deps # Skip pip dependencies installation + +Flow: + 1. pip install -e . (install unilabos in editable mode) + 2. Detect Chinese locale + 3. Use uv to install pip dependencies from requirements.txt + 4. Special packages (like pylabrobot) are handled by environment_check.py at runtime +""" + +import locale +import subprocess +import sys +import argparse +from pathlib import Path + +# Tsinghua mirror URL +TSINGHUA_MIRROR = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" + + +def is_chinese_locale() -> bool: + """ + Detect if system is in Chinese locale. + Same logic as EnvironmentChecker._is_chinese_locale() + """ + try: + lang = locale.getdefaultlocale()[0] + if lang and ("zh" in lang.lower() or "chinese" in lang.lower()): + return True + except Exception: + pass + return False + + +def run_command(cmd: list, description: str, retry: int = 2) -> bool: + """Run command with retry support.""" + print(f"[INFO] {description}") + print(f"[CMD] {' '.join(cmd)}") + + for attempt in range(retry + 1): + try: + result = subprocess.run(cmd, check=True, timeout=600) + print(f"[OK] {description}") + return True + except subprocess.CalledProcessError as e: + if attempt < retry: + print(f"[WARN] Attempt {attempt + 1} failed, retrying...") + else: + print(f"[ERROR] {description} failed: {e}") + return False + except subprocess.TimeoutExpired: + print(f"[ERROR] {description} timed out") + return False + return False + + +def install_editable(project_root: Path, use_mirror: bool) -> bool: + """Install unilabos in editable mode using pip.""" + cmd = [sys.executable, "-m", "pip", "install", "-e", str(project_root)] + if use_mirror: + cmd.extend(["-i", TSINGHUA_MIRROR]) + + return run_command(cmd, "Installing unilabos in editable mode") + + +def install_requirements_uv(requirements_file: Path, use_mirror: bool) -> bool: + """Install pip dependencies using uv (installed via conda-forge::uv).""" + cmd = ["uv", "pip", "install", "-r", str(requirements_file)] + if use_mirror: + cmd.extend(["-i", TSINGHUA_MIRROR]) + + return run_command(cmd, "Installing pip dependencies with uv", retry=2) + + +def install_requirements_pip(requirements_file: Path, use_mirror: bool) -> bool: + """Fallback: Install pip dependencies using pip.""" + cmd = [sys.executable, "-m", "pip", "install", "-r", str(requirements_file)] + if use_mirror: + cmd.extend(["-i", TSINGHUA_MIRROR]) + + return run_command(cmd, "Installing pip dependencies with pip", retry=2) + + +def check_uv_available() -> bool: + """Check if uv is available (installed via conda-forge::uv).""" + try: + subprocess.run(["uv", "--version"], capture_output=True, check=True) + return True + except (subprocess.CalledProcessError, FileNotFoundError): + return False + + +def main(): + parser = argparse.ArgumentParser(description="Development installation script for UniLabOS") + parser.add_argument("--china", action="store_true", help="Force use China mirror (Tsinghua)") + parser.add_argument("--no-mirror", action="store_true", help="Force use default PyPI (no mirror)") + parser.add_argument( + "--skip-deps", action="store_true", help="Skip pip dependencies installation (only install unilabos)" + ) + parser.add_argument("--use-pip", action="store_true", help="Use pip instead of uv for dependencies") + args = parser.parse_args() + + # Determine project root + script_dir = Path(__file__).parent + project_root = script_dir.parent + requirements_file = project_root / "unilabos" / "utils" / "requirements.txt" + + if not (project_root / "setup.py").exists(): + print(f"[ERROR] setup.py not found in {project_root}") + sys.exit(1) + + print("=" * 60) + print("UniLabOS Development Installation") + print("=" * 60) + print(f"Project root: {project_root}") + print() + + # Determine mirror usage based on locale + if args.no_mirror: + use_mirror = False + print("[INFO] Mirror disabled by --no-mirror flag") + elif args.china: + use_mirror = True + print("[INFO] China mirror enabled by --china flag") + else: + use_mirror = is_chinese_locale() + if use_mirror: + print("[INFO] Chinese locale detected, using Tsinghua mirror") + else: + print("[INFO] Non-Chinese locale detected, using default PyPI") + + print() + + # Step 1: Install unilabos in editable mode + print("[STEP 1] Installing unilabos in editable mode...") + if not install_editable(project_root, use_mirror): + print("[ERROR] Failed to install unilabos") + print() + print("Manual fallback:") + if use_mirror: + print(f" pip install -e {project_root} -i {TSINGHUA_MIRROR}") + else: + print(f" pip install -e {project_root}") + sys.exit(1) + + print() + + # Step 2: Install pip dependencies + if args.skip_deps: + print("[INFO] Skipping pip dependencies installation (--skip-deps)") + else: + print("[STEP 2] Installing pip dependencies...") + + if not requirements_file.exists(): + print(f"[WARN] Requirements file not found: {requirements_file}") + print("[INFO] Skipping dependencies installation") + else: + # Try uv first (faster), fallback to pip + if args.use_pip: + print("[INFO] Using pip (--use-pip flag)") + success = install_requirements_pip(requirements_file, use_mirror) + elif check_uv_available(): + print("[INFO] Using uv (installed via conda-forge::uv)") + success = install_requirements_uv(requirements_file, use_mirror) + if not success: + print("[WARN] uv failed, falling back to pip...") + success = install_requirements_pip(requirements_file, use_mirror) + else: + print("[WARN] uv not available (should be installed via: mamba install conda-forge::uv)") + print("[INFO] Falling back to pip...") + success = install_requirements_pip(requirements_file, use_mirror) + + if not success: + print() + print("[WARN] Failed to install some dependencies automatically.") + print("You can manually install them:") + if use_mirror: + print(f" uv pip install -r {requirements_file} -i {TSINGHUA_MIRROR}") + print(" or:") + print(f" pip install -r {requirements_file} -i {TSINGHUA_MIRROR}") + else: + print(f" uv pip install -r {requirements_file}") + print(" or:") + print(f" pip install -r {requirements_file}") + + print() + print("=" * 60) + print("Installation complete!") + print("=" * 60) + print() + print("Note: Some special packages (like pylabrobot) are installed") + print("automatically at runtime by unilabos if needed.") + print() + print("Verify installation:") + print(' python -c "import unilabos; print(unilabos.__version__)"') + print() + print("If you encounter issues, you can manually install dependencies:") + if use_mirror: + print(f" uv pip install -r unilabos/utils/requirements.txt -i {TSINGHUA_MIRROR}") + else: + print(" uv pip install -r unilabos/utils/requirements.txt") + print() + + +if __name__ == "__main__": + main() diff --git a/setup.py b/setup.py index b6ae5edb8..b3a00f1d9 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ setup( name=package_name, - version='0.10.15', + version='0.10.17', packages=find_packages(), include_package_data=True, install_requires=['setuptools'], diff --git a/tests/workflow/test.json b/tests/workflow/test.json new file mode 100644 index 000000000..8fc644961 --- /dev/null +++ b/tests/workflow/test.json @@ -0,0 +1,213 @@ +{ + "workflow": [ + { + "action": "transfer_liquid", + "action_args": { + "sources": "cell_lines", + "targets": "Liquid_1", + "asp_vol": 100.0, + "dis_vol": 74.75, + "asp_flow_rate": 94.0, + "dis_flow_rate": 95.5 + } + }, + { + "action": "transfer_liquid", + "action_args": { + "sources": "cell_lines", + "targets": "Liquid_2", + "asp_vol": 100.0, + "dis_vol": 74.75, + "asp_flow_rate": 94.0, + "dis_flow_rate": 95.5 + } + }, + { + "action": "transfer_liquid", + "action_args": { + "sources": "cell_lines", + "targets": "Liquid_3", + "asp_vol": 100.0, + "dis_vol": 74.75, + "asp_flow_rate": 94.0, + "dis_flow_rate": 95.5 + } + }, + { + "action": "transfer_liquid", + "action_args": { + "sources": "cell_lines_2", + "targets": "Liquid_4", + "asp_vol": 100.0, + "dis_vol": 74.75, + "asp_flow_rate": 94.0, + "dis_flow_rate": 95.5 + } + }, + { + "action": "transfer_liquid", + "action_args": { + "sources": "cell_lines_2", + "targets": "Liquid_5", + "asp_vol": 100.0, + "dis_vol": 74.75, + "asp_flow_rate": 94.0, + "dis_flow_rate": 95.5 + } + }, + { + "action": "transfer_liquid", + "action_args": { + "sources": "cell_lines_2", + "targets": "Liquid_6", + "asp_vol": 100.0, + "dis_vol": 74.75, + "asp_flow_rate": 94.0, + "dis_flow_rate": 95.5 + } + }, + { + "action": "transfer_liquid", + "action_args": { + "sources": "cell_lines_3", + "targets": "dest_set", + "asp_vol": 100.0, + "dis_vol": 74.75, + "asp_flow_rate": 94.0, + "dis_flow_rate": 95.5 + } + }, + { + "action": "transfer_liquid", + "action_args": { + "sources": "cell_lines_3", + "targets": "dest_set_2", + "asp_vol": 100.0, + "dis_vol": 74.75, + "asp_flow_rate": 94.0, + "dis_flow_rate": 95.5 + } + }, + { + "action": "transfer_liquid", + "action_args": { + "sources": "cell_lines_3", + "targets": "dest_set_3", + "asp_vol": 100.0, + "dis_vol": 74.75, + "asp_flow_rate": 94.0, + "dis_flow_rate": 95.5 + } + } + ], + "reagent": { + "Liquid_1": { + "slot": 1, + "well": [ + "A4", + "A7", + "A10" + ], + "labware": "rep 1" + }, + "Liquid_4": { + "slot": 1, + "well": [ + "A4", + "A7", + "A10" + ], + "labware": "rep 1" + }, + "dest_set": { + "slot": 1, + "well": [ + "A4", + "A7", + "A10" + ], + "labware": "rep 1" + }, + "Liquid_2": { + "slot": 2, + "well": [ + "A3", + "A5", + "A8" + ], + "labware": "rep 2" + }, + "Liquid_5": { + "slot": 2, + "well": [ + "A3", + "A5", + "A8" + ], + "labware": "rep 2" + }, + "dest_set_2": { + "slot": 2, + "well": [ + "A3", + "A5", + "A8" + ], + "labware": "rep 2" + }, + "Liquid_3": { + "slot": 3, + "well": [ + "A4", + "A6", + "A10" + ], + "labware": "rep 3" + }, + "Liquid_6": { + "slot": 3, + "well": [ + "A4", + "A6", + "A10" + ], + "labware": "rep 3" + }, + "dest_set_3": { + "slot": 3, + "well": [ + "A4", + "A6", + "A10" + ], + "labware": "rep 3" + }, + "cell_lines": { + "slot": 4, + "well": [ + "A1", + "A3", + "A5" + ], + "labware": "DRUG + YOYO-MEDIA" + }, + "cell_lines_2": { + "slot": 4, + "well": [ + "A1", + "A3", + "A5" + ], + "labware": "DRUG + YOYO-MEDIA" + }, + "cell_lines_3": { + "slot": 4, + "well": [ + "A1", + "A3", + "A5" + ], + "labware": "DRUG + YOYO-MEDIA" + } + } +} \ No newline at end of file diff --git a/unilabos/__init__.py b/unilabos/__init__.py index d5ac10aa7..50ab2b04b 100644 --- a/unilabos/__init__.py +++ b/unilabos/__init__.py @@ -1 +1 @@ -__version__ = "0.10.15" +__version__ = "0.10.17" diff --git a/unilabos/__main__.py b/unilabos/__main__.py new file mode 100644 index 000000000..648322609 --- /dev/null +++ b/unilabos/__main__.py @@ -0,0 +1,6 @@ +"""Entry point for `python -m unilabos`.""" + +from unilabos.app.main import main + +if __name__ == "__main__": + main() diff --git a/unilabos/app/main.py b/unilabos/app/main.py index 8ec26c036..a6539c33a 100644 --- a/unilabos/app/main.py +++ b/unilabos/app/main.py @@ -7,7 +7,6 @@ import threading import time from typing import Dict, Any, List - import networkx as nx import yaml @@ -17,9 +16,9 @@ if unilabos_dir not in sys.path: sys.path.append(unilabos_dir) +from unilabos.app.utils import cleanup_for_restart from unilabos.utils.banner_print import print_status, print_unilab_banner from unilabos.config.config import load_config, BasicConfig, HTTPConfig -from unilabos.app.utils import cleanup_for_restart # Global restart flags (used by ws_client and web/server) _restart_requested: bool = False @@ -161,6 +160,12 @@ def parse_args(): default=False, help="Complete registry information", ) + parser.add_argument( + "--check_mode", + action="store_true", + default=False, + help="Run in check mode for CI: validates registry imports and ensures no file changes", + ) parser.add_argument( "--no_update_feedback", action="store_true", @@ -211,7 +216,10 @@ def main(): args_dict = vars(args) # 环境检查 - 检查并自动安装必需的包 (可选) - if not args_dict.get("skip_env_check", False): + skip_env_check = args_dict.get("skip_env_check", False) + check_mode = args_dict.get("check_mode", False) + + if not skip_env_check: from unilabos.utils.environment_check import check_environment if not check_environment(auto_install=True): @@ -222,7 +230,21 @@ def main(): # 加载配置文件,优先加载config,然后从env读取 config_path = args_dict.get("config") - if os.getcwd().endswith("unilabos_data"): + + if check_mode: + args_dict["working_dir"] = os.path.abspath(os.getcwd()) + # 当 skip_env_check 时,默认使用当前目录作为 working_dir + if skip_env_check and not args_dict.get("working_dir") and not config_path: + working_dir = os.path.abspath(os.getcwd()) + print_status(f"跳过环境检查模式:使用当前目录作为工作目录 {working_dir}", "info") + # 检查当前目录是否有 local_config.py + local_config_in_cwd = os.path.join(working_dir, "local_config.py") + if os.path.exists(local_config_in_cwd): + config_path = local_config_in_cwd + print_status(f"发现本地配置文件: {config_path}", "info") + else: + print_status(f"未指定config路径,可通过 --config 传入 local_config.py 文件路径", "info") + elif os.getcwd().endswith("unilabos_data"): working_dir = os.path.abspath(os.getcwd()) else: working_dir = os.path.abspath(os.path.join(os.getcwd(), "unilabos_data")) @@ -241,7 +263,7 @@ def main(): working_dir = os.path.dirname(config_path) elif os.path.exists(working_dir) and os.path.exists(os.path.join(working_dir, "local_config.py")): config_path = os.path.join(working_dir, "local_config.py") - elif not config_path and ( + elif not skip_env_check and not config_path and ( not os.path.exists(working_dir) or not os.path.exists(os.path.join(working_dir, "local_config.py")) ): print_status(f"未指定config路径,可通过 --config 传入 local_config.py 文件路径", "info") @@ -255,9 +277,11 @@ def main(): print_status(f"已创建 local_config.py 路径: {config_path}", "info") else: os._exit(1) - # 加载配置文件 + + # 加载配置文件 (check_mode 跳过) print_status(f"当前工作目录为 {working_dir}", "info") - load_config_from_file(config_path) + if not check_mode: + load_config_from_file(config_path) # 根据配置重新设置日志级别 from unilabos.utils.log import configure_logger, logger @@ -313,6 +337,7 @@ def main(): machine_name = "".join([c if c.isalnum() or c == "_" else "_" for c in machine_name]) BasicConfig.machine_name = machine_name BasicConfig.vis_2d_enable = args_dict["2d_vis"] + BasicConfig.check_mode = check_mode from unilabos.resources.graphio import ( read_node_link_json, @@ -331,10 +356,14 @@ def main(): # 显示启动横幅 print_unilab_banner(args_dict) - # 注册表 - lab_registry = build_registry( - args_dict["registry_path"], args_dict.get("complete_registry", False), BasicConfig.upload_registry - ) + # 注册表 - check_mode 时强制启用 complete_registry + complete_registry = args_dict.get("complete_registry", False) or check_mode + lab_registry = build_registry(args_dict["registry_path"], complete_registry, BasicConfig.upload_registry) + + # Check mode: complete_registry 完成后直接退出,git diff 检测由 CI workflow 执行 + if check_mode: + print_status("Check mode: complete_registry 完成,退出", "info") + os._exit(0) if BasicConfig.upload_registry: # 设备注册到服务端 - 需要 ak 和 sk diff --git a/unilabos/app/utils.py b/unilabos/app/utils.py index d10c2e055..f6114a13c 100644 --- a/unilabos/app/utils.py +++ b/unilabos/app/utils.py @@ -4,8 +4,40 @@ 提供清理、重启等工具函数 """ -import gc +import glob import os +import shutil +import sys + + +def patch_rclpy_dll_windows(): + """在 Windows + conda 环境下为 rclpy 打 DLL 加载补丁""" + if sys.platform != "win32" or not os.environ.get("CONDA_PREFIX"): + return + try: + import rclpy + + return + except ImportError as e: + if not str(e).startswith("DLL load failed"): + return + cp = os.environ["CONDA_PREFIX"] + impl = os.path.join(cp, "Lib", "site-packages", "rclpy", "impl", "implementation_singleton.py") + pyd = glob.glob(os.path.join(cp, "Lib", "site-packages", "rclpy", "_rclpy_pybind11*.pyd")) + if not os.path.exists(impl) or not pyd: + return + with open(impl, "r", encoding="utf-8") as f: + content = f.read() + lib_bin = os.path.join(cp, "Library", "bin").replace("\\", "/") + patch = f'# UniLabOS DLL Patch\nimport os,ctypes\nos.add_dll_directory("{lib_bin}") if hasattr(os,"add_dll_directory") else None\ntry: ctypes.CDLL("{pyd[0].replace(chr(92),"/")}")\nexcept: pass\n# End Patch\n' + shutil.copy2(impl, impl + ".bak") + with open(impl, "w", encoding="utf-8") as f: + f.write(patch + content) + + +patch_rclpy_dll_windows() + +import gc import threading import time diff --git a/unilabos/app/web/client.py b/unilabos/app/web/client.py index 64a9418b5..0ecf4608f 100644 --- a/unilabos/app/web/client.py +++ b/unilabos/app/web/client.py @@ -359,9 +359,7 @@ def workflow_import( Returns: Dict: API响应数据,包含 code 和 data (uuid, name) """ - # target_lab_uuid 暂时使用默认值,后续由后端根据 ak/sk 获取 payload = { - "target_lab_uuid": "28c38bb0-63f6-4352-b0d8-b5b8eb1766d5", "name": name, "data": { "workflow_uuid": workflow_uuid, diff --git a/unilabos/app/web/controller.py b/unilabos/app/web/controller.py index 9b0f1ff69..acd1f56b3 100644 --- a/unilabos/app/web/controller.py +++ b/unilabos/app/web/controller.py @@ -58,14 +58,14 @@ def store_result( feedback=feedback or {}, timestamp=time.time(), ) - logger.debug(f"[JobResultStore] Stored result for job {job_id[:8]}, status={status}") + logger.trace(f"[JobResultStore] Stored result for job {job_id[:8]}, status={status}") def get_and_remove(self, job_id: str) -> Optional[JobResult]: """获取并删除任务结果""" with self._results_lock: result = self._results.pop(job_id, None) if result: - logger.debug(f"[JobResultStore] Retrieved and removed result for job {job_id[:8]}") + logger.trace(f"[JobResultStore] Retrieved and removed result for job {job_id[:8]}") return result def get_result(self, job_id: str) -> Optional[JobResult]: diff --git a/unilabos/app/ws_client.py b/unilabos/app/ws_client.py index 95526f0c5..8644353b0 100644 --- a/unilabos/app/ws_client.py +++ b/unilabos/app/ws_client.py @@ -23,7 +23,7 @@ from urllib.parse import urlparse from enum import Enum -from jedi.inference.gradual.typing import TypedDict +from typing_extensions import TypedDict from unilabos.app.model import JobAddReq from unilabos.ros.nodes.presets.host_node import HostNode @@ -154,7 +154,7 @@ def add_queue_request(self, job_info: JobInfo) -> bool: job_info.set_ready_timeout(10) # 设置10秒超时 self.active_jobs[device_key] = job_info job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name) - logger.info(f"[DeviceActionManager] Job {job_log} can start immediately for {device_key}") + logger.trace(f"[DeviceActionManager] Job {job_log} can start immediately for {device_key}") return True def start_job(self, job_id: str) -> bool: @@ -210,8 +210,9 @@ def end_job(self, job_id: str) -> Optional[JobInfo]: job_info.update_timestamp() # 从all_jobs中移除已结束的job del self.all_jobs[job_id] - job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name) - logger.info(f"[DeviceActionManager] Job {job_log} ended for {device_key}") + # job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name) + # logger.debug(f"[DeviceActionManager] Job {job_log} ended for {device_key}") + pass else: job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name) logger.warning(f"[DeviceActionManager] Job {job_log} was not active for {device_key}") @@ -227,7 +228,7 @@ def end_job(self, job_id: str) -> Optional[JobInfo]: next_job_log = format_job_log( next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name ) - logger.info(f"[DeviceActionManager] Next job {next_job_log} can start for {device_key}") + logger.trace(f"[DeviceActionManager] Next job {next_job_log} can start for {device_key}") return next_job return None @@ -268,7 +269,7 @@ def cancel_job(self, job_id: str) -> bool: # 从all_jobs中移除 del self.all_jobs[job_id] job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name) - logger.info(f"[DeviceActionManager] Active job {job_log} cancelled for {device_key}") + logger.trace(f"[DeviceActionManager] Active job {job_log} cancelled for {device_key}") # 启动下一个任务 if device_key in self.device_queues and self.device_queues[device_key]: @@ -281,7 +282,7 @@ def cancel_job(self, job_id: str) -> bool: next_job_log = format_job_log( next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name ) - logger.info(f"[DeviceActionManager] Next job {next_job_log} can start after cancel") + logger.trace(f"[DeviceActionManager] Next job {next_job_log} can start after cancel") return True # 如果是排队中的任务 @@ -295,7 +296,7 @@ def cancel_job(self, job_id: str) -> bool: job_log = format_job_log( job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name ) - logger.info(f"[DeviceActionManager] Queued job {job_log} cancelled for {device_key}") + logger.trace(f"[DeviceActionManager] Queued job {job_log} cancelled for {device_key}") return True job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name) @@ -494,8 +495,12 @@ async def _message_handler(self): await self._process_message(message_type, message_data) else: if message_type.endswith("_material"): - logger.trace(f"[MessageProcessor] 收到一条归属 {data.get('edge_session')} 的旧消息:{data}") - logger.debug(f"[MessageProcessor] 跳过了一条归属 {data.get('edge_session')} 的旧消息: {data.get('action')}") + logger.trace( + f"[MessageProcessor] 收到一条归属 {data.get('edge_session')} 的旧消息:{data}" + ) + logger.debug( + f"[MessageProcessor] 跳过了一条归属 {data.get('edge_session')} 的旧消息: {data.get('action')}" + ) else: await self._process_message(message_type, message_data) except json.JSONDecodeError: @@ -565,7 +570,7 @@ async def _send_handler(self): async def _process_message(self, message_type: str, message_data: Dict[str, Any]): """处理收到的消息""" - logger.debug(f"[MessageProcessor] Processing message: {message_type}") + logger.trace(f"[MessageProcessor] Processing message: {message_type}") try: if message_type == "pong": @@ -637,13 +642,13 @@ async def _handle_query_action_state(self, data: Dict[str, Any]): await self._send_action_state_response( device_id, action_name, task_id, job_id, "query_action_status", True, 0 ) - logger.info(f"[MessageProcessor] Job {job_log} can start immediately") + logger.trace(f"[MessageProcessor] Job {job_log} can start immediately") else: # 需要排队 await self._send_action_state_response( device_id, action_name, task_id, job_id, "query_action_status", False, 10 ) - logger.info(f"[MessageProcessor] Job {job_log} queued") + logger.trace(f"[MessageProcessor] Job {job_log} queued") # 通知QueueProcessor有新的队列更新 if self.queue_processor: @@ -847,9 +852,7 @@ async def _handle_resource_tree_update(self, resource_uuid_list: List[WSResource device_action_groups[key_add] = [] device_action_groups[key_add].append(item["uuid"]) - logger.info( - f"[资源同步] 跨站Transfer: {item['uuid'][:8]} from {device_old_id} to {device_id}" - ) + logger.info(f"[资源同步] 跨站Transfer: {item['uuid'][:8]} from {device_old_id} to {device_id}") else: # 正常update key = (device_id, "update") @@ -863,7 +866,9 @@ async def _handle_resource_tree_update(self, resource_uuid_list: List[WSResource device_action_groups[key] = [] device_action_groups[key].append(item["uuid"]) - logger.trace(f"[资源同步] 动作 {action} 分组数量: {len(device_action_groups)}, 总数量: {len(resource_uuid_list)}") + logger.trace( + f"[资源同步] 动作 {action} 分组数量: {len(device_action_groups)}, 总数量: {len(resource_uuid_list)}" + ) # 为每个(device_id, action)创建独立的更新线程 for (device_id, actual_action), items in device_action_groups.items(): @@ -911,13 +916,13 @@ async def _handle_request_restart(self, data: Dict[str, Any]): # 发送确认消息 if self.websocket_client: - await self.websocket_client.send_message({ - "action": "restart_acknowledged", - "data": {"reason": reason, "delay": delay} - }) + await self.websocket_client.send_message( + {"action": "restart_acknowledged", "data": {"reason": reason, "delay": delay}} + ) # 设置全局重启标志 import unilabos.app.main as main_module + main_module._restart_requested = True main_module._restart_reason = reason @@ -927,10 +932,12 @@ async def _handle_request_restart(self, data: Dict[str, Any]): # 在新线程中执行清理,避免阻塞当前事件循环 def do_cleanup(): import time + time.sleep(0.5) # 给当前消息处理完成的时间 logger.info(f"[MessageProcessor] Starting cleanup for restart, reason: {reason}") try: from unilabos.app.utils import cleanup_for_restart + if cleanup_for_restart(): logger.info("[MessageProcessor] Cleanup successful, main() will restart") else: @@ -1128,7 +1135,7 @@ def _send_busy_status(self): success = self.message_processor.send_message(message) job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name) if success: - logger.debug(f"[QueueProcessor] Sent busy/need_more for queued job {job_log}") + logger.trace(f"[QueueProcessor] Sent busy/need_more for queued job {job_log}") else: logger.warning(f"[QueueProcessor] Failed to send busy status for job {job_log}") @@ -1151,7 +1158,7 @@ def handle_job_completed(self, job_id: str, status: str) -> None: job_info.action_name, ) - logger.info(f"[QueueProcessor] Job {job_log} completed with status: {status}") + logger.trace(f"[QueueProcessor] Job {job_log} completed with status: {status}") # 结束任务,获取下一个可执行的任务 next_job = self.device_manager.end_job(job_id) @@ -1171,8 +1178,8 @@ def handle_job_completed(self, job_id: str, status: str) -> None: }, } self.message_processor.send_message(message) - next_job_log = format_job_log(next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name) - logger.info(f"[QueueProcessor] Notified next job {next_job_log} can start") + # next_job_log = format_job_log(next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name) + # logger.debug(f"[QueueProcessor] Notified next job {next_job_log} can start") # 立即触发下一轮状态检查 self.notify_queue_update() @@ -1314,7 +1321,7 @@ def publish_job_status( except (KeyError, AttributeError): logger.warning(f"[WebSocketClient] Failed to remove job {item.job_id} from HostNode status") - logger.info(f"[WebSocketClient] Intercepting final status for job_id: {item.job_id} - {status}") + # logger.debug(f"[WebSocketClient] Intercepting final status for job_id: {item.job_id} - {status}") # 通知队列处理器job完成(包括timeout的job) self.queue_processor.handle_job_completed(item.job_id, status) @@ -1381,7 +1388,9 @@ def publish_host_ready(self) -> None: if host_node: # 获取设备信息 for device_id, namespace in host_node.devices_names.items(): - device_key = f"{namespace}/{device_id}" if namespace.startswith("/") else f"/{namespace}/{device_id}" + device_key = ( + f"{namespace}/{device_id}" if namespace.startswith("/") else f"/{namespace}/{device_id}" + ) is_online = device_key in host_node._online_devices # 获取设备的动作信息 @@ -1395,14 +1404,16 @@ def publish_host_ready(self) -> None: "action_type": str(type(client).__name__), } - devices.append({ - "device_id": device_id, - "namespace": namespace, - "device_key": device_key, - "is_online": is_online, - "machine_name": host_node.device_machine_names.get(device_id, machine_name), - "actions": actions, - }) + devices.append( + { + "device_id": device_id, + "namespace": namespace, + "device_key": device_key, + "is_online": is_online, + "machine_name": host_node.device_machine_names.get(device_id, machine_name), + "actions": actions, + } + ) logger.info(f"[WebSocketClient] Collected {len(devices)} devices for host_ready") except Exception as e: diff --git a/unilabos/config/config.py b/unilabos/config/config.py index f3dba5da8..c91a07d4c 100644 --- a/unilabos/config/config.py +++ b/unilabos/config/config.py @@ -22,6 +22,7 @@ class BasicConfig: startup_json_path = None # 填写绝对路径 disable_browser = False # 禁止浏览器自动打开 port = 8002 # 本地HTTP服务 + check_mode = False # CI 检查模式,用于验证 registry 导入和文件一致性 # 'TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL' log_level: Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "DEBUG" diff --git a/unilabos/ros/x/__init__.py b/unilabos/devices/Qone_nmr/__init__.py similarity index 100% rename from unilabos/ros/x/__init__.py rename to unilabos/devices/Qone_nmr/__init__.py diff --git a/unilabos/devices/liquid_handling/liquid_handler_abstract.py b/unilabos/devices/liquid_handling/liquid_handler_abstract.py index d02129c79..aa695a080 100644 --- a/unilabos/devices/liquid_handling/liquid_handler_abstract.py +++ b/unilabos/devices/liquid_handling/liquid_handler_abstract.py @@ -28,21 +28,40 @@ Tip, ) +from unilabos.registry.placeholder_type import ResourceSlot from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode +from unilabos.resources.resource_tracker import ResourceTreeSet + + class SimpleReturn(TypedDict): samples: list volumes: list + +class SetLiquidReturn(TypedDict): + wells: list + volumes: list + + +class SetLiquidFromPlateReturn(TypedDict): + plate: list + wells: list + volumes: list + + class LiquidHandlerMiddleware(LiquidHandler): - def __init__(self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool = False, channel_num: int = 8, **kwargs): + def __init__( + self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool = False, channel_num: int = 8, **kwargs + ): self._simulator = simulator self.channel_num = channel_num self.pending_liquids_dict = {} joint_config = kwargs.get("joint_config", None) if simulator: if joint_config: - self._simulate_backend = UniLiquidHandlerRvizBackend(channel_num, kwargs["total_height"], - joint_config=joint_config, lh_device_id=deck.name) + self._simulate_backend = UniLiquidHandlerRvizBackend( + channel_num, kwargs["total_height"], joint_config=joint_config, lh_device_id=deck.name + ) else: self._simulate_backend = LiquidHandlerChatterboxBackend(channel_num) self._simulate_handler = LiquidHandlerAbstract(self._simulate_backend, deck, False) @@ -137,7 +156,7 @@ async def drop_tips( ) await super().drop_tips(tip_spots, use_channels, offsets, allow_nonzero_volume, **backend_kwargs) self.pending_liquids_dict = {} - return + return async def return_tips( self, use_channels: Optional[list[int]] = None, allow_nonzero_volume: bool = False, **backend_kwargs @@ -159,11 +178,13 @@ async def discard_tips( if not offsets or (isinstance(offsets, list) and len(offsets) != len(use_channels)): offsets = [Coordinate.zero()] * len(use_channels) if self._simulator: - return await self._simulate_handler.discard_tips(use_channels, allow_nonzero_volume, offsets, **backend_kwargs) + return await self._simulate_handler.discard_tips( + use_channels, allow_nonzero_volume, offsets, **backend_kwargs + ) await super().discard_tips(use_channels, allow_nonzero_volume, offsets, **backend_kwargs) self.pending_liquids_dict = {} - return - + return + def _check_containers(self, resources: Sequence[Resource]): super()._check_containers(resources) @@ -180,7 +201,6 @@ async def aspirate( **backend_kwargs, ): - if self._simulator: return await self._simulate_handler.aspirate( resources, @@ -208,15 +228,16 @@ async def aspirate( res_samples = [] res_volumes = [] for resource, volume, channel in zip(resources, vols, use_channels): - res_samples.append({"name": resource.name, "sample_uuid": resource.unilabos_extra.get("sample_uuid", None)}) + res_samples.append( + {"name": resource.name, "sample_uuid": resource.unilabos_extra.get("sample_uuid", None)} + ) res_volumes.append(volume) self.pending_liquids_dict[channel] = { "sample_uuid": resource.unilabos_extra.get("sample_uuid", None), - "volume": volume + "volume": volume, } return SimpleReturn(samples=res_samples, volumes=res_volumes) - async def dispense( self, resources: Sequence[Container], @@ -261,7 +282,7 @@ async def dispense( res_volumes.append(volume) return SimpleReturn(samples=res_samples, volumes=res_volumes) - + async def transfer( self, source: Well, @@ -578,10 +599,18 @@ async def consolidate_tip_inventory(self, tip_racks: List[TipRack], use_channels class LiquidHandlerAbstract(LiquidHandlerMiddleware): """Extended LiquidHandler with additional operations.""" + support_touch_tip = True _ros_node: BaseROS2DeviceNode - def __init__(self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool=False, channel_num:int = 8, total_height:float = 310): + def __init__( + self, + backend: LiquidHandlerBackend, + deck: Deck, + simulator: bool = False, + channel_num: int = 8, + total_height: float = 310, + ): """Initialize a LiquidHandler. Args: @@ -605,6 +634,7 @@ def __init__(self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool=Fa module_name = ".".join(components[:-1]) try: import importlib + mod = importlib.import_module(module_name) except ImportError: mod = None @@ -614,6 +644,7 @@ def __init__(self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool=Fa # Try pylabrobot style import (if available) try: import pylabrobot + backend_cls = getattr(pylabrobot, type_str, None) except Exception: backend_cls = None @@ -631,16 +662,56 @@ def post_init(self, ros_node: BaseROS2DeviceNode): self._ros_node = ros_node @classmethod - def set_liquid(cls, wells: list[Well], liquid_names: list[str], volumes: list[float]) -> SimpleReturn: - """Set the liquid in a well.""" - res_samples = [] + def set_liquid(cls, wells: list[Well], liquid_names: list[str], volumes: list[float]) -> SetLiquidReturn: + """Set the liquid in a well. + + 如果 liquid_names 和 volumes 为空,但 wells 不为空,直接返回 wells。 + """ + res_volumes = [] + # 如果 liquid_names 和 volumes 都为空,直接返回 wells + if not liquid_names and not volumes: + return SetLiquidReturn( + wells=ResourceTreeSet.from_plr_resources(wells, known_newly_created=False).dump(), volumes=res_volumes # type: ignore + ) + + for well, liquid_name, volume in zip(wells, liquid_names, volumes): + well.set_liquids([(liquid_name, volume)]) # type: ignore + res_volumes.append(volume) + + return SetLiquidReturn( + wells=ResourceTreeSet.from_plr_resources(wells, known_newly_created=False).dump(), volumes=res_volumes # type: ignore + ) + + @classmethod + def set_liquid_from_plate( + cls, plate: ResourceSlot, well_names: list[str], liquid_names: list[str], volumes: list[float] + ) -> SetLiquidFromPlateReturn: + """Set the liquid in wells of a plate by well names (e.g., A1, A2, B3). + + 如果 liquid_names 和 volumes 为空,但 plate 和 well_names 不为空,直接返回 plate 和 wells。 + """ + # 根据 well_names 获取对应的 Well 对象 + wells = [plate.get_well(name) for name in well_names] res_volumes = [] + + # 如果 liquid_names 和 volumes 都为空,直接返回 + if not liquid_names and not volumes: + return SetLiquidFromPlateReturn( + plate=ResourceTreeSet.from_plr_resources([plate], known_newly_created=False).dump(), # type: ignore + wells=ResourceTreeSet.from_plr_resources(wells, known_newly_created=False).dump(), # type: ignore + volumes=res_volumes, + ) + for well, liquid_name, volume in zip(wells, liquid_names, volumes): well.set_liquids([(liquid_name, volume)]) # type: ignore - res_samples.append({"name": well.name, "sample_uuid": well.unilabos_extra.get("sample_uuid", None)}) res_volumes.append(volume) - - return SimpleReturn(samples=res_samples, volumes=res_volumes) + + return SetLiquidFromPlateReturn( + plate=ResourceTreeSet.from_plr_resources([plate], known_newly_created=False).dump(), # type: ignore + wells=ResourceTreeSet.from_plr_resources(wells, known_newly_created=False).dump(), # type: ignore + volumes=res_volumes, + ) + # --------------------------------------------------------------- # REMOVE LIQUID -------------------------------------------------- # --------------------------------------------------------------- @@ -655,7 +726,7 @@ async def transfer_group(self, source_group_name: str, target_group_name: str, u source_wells = self.group_info.get(source_group_name, []) target_wells = self.group_info.get(target_group_name, []) - + rack_info = dict() for child in self.deck.children: if issubclass(child.__class__, TipRack): @@ -666,17 +737,17 @@ async def transfer_group(self, source_group_name: str, target_group_name: str, u break else: rack_info[rack.name] = (rack, tip.maximal_volume - unit_volume) - + if len(rack_info) == 0: raise ValueError(f"No tip rack can support volume {unit_volume}.") - + rack_info = sorted(rack_info.items(), key=lambda x: x[1][1]) for child in self.deck.children: if child.name == rack_info[0][0]: target_rack = child target_rack = cast(TipRack, target_rack) available_tips = {} - for (idx, tipSpot) in enumerate(target_rack.get_all_items()): + for idx, tipSpot in enumerate(target_rack.get_all_items()): if tipSpot.has_tip(): available_tips[idx] = tipSpot continue @@ -684,10 +755,10 @@ async def transfer_group(self, source_group_name: str, target_group_name: str, u print("channel_num", self.channel_num) if self.channel_num == 8: - tip_prefix = list(available_tips.values())[0].name.split('_')[0] - colnum_list = [int(tip.name.split('_')[-1][1:]) for tip in available_tips.values()] + tip_prefix = list(available_tips.values())[0].name.split("_")[0] + colnum_list = [int(tip.name.split("_")[-1][1:]) for tip in available_tips.values()] available_cols = [colnum for colnum, count in dict(Counter(colnum_list)).items() if count == 8] - available_cols.sort() + available_cols.sort() available_tips_dict = {tip.name: tip for tip in available_tips.values()} tips_to_use = [available_tips_dict[f"{tip_prefix}_{chr(65 + i)}{available_cols[0]}"] for i in range(8)] print("tips_to_use", tips_to_use) @@ -698,16 +769,16 @@ async def transfer_group(self, source_group_name: str, target_group_name: str, u await self.dispense(target_wells, [unit_volume] * 8, use_channels=list(range(0, 8))) await self.discard_tips(use_channels=list(range(0, 8))) - elif self.channel_num == 1: - + elif self.channel_num == 1: + for num_well in range(len(target_wells)): - tip_to_use = available_tips[list(available_tips.keys())[num_well]] + tip_to_use = available_tips[list(available_tips.keys())[num_well]] print("tip_to_use", tip_to_use) await self.pick_up_tips([tip_to_use], use_channels=[0]) print("source_wells", source_wells) print("target_wells", target_wells) if len(source_wells) == 1: - await self.aspirate([source_wells[0]], [unit_volume], use_channels=[0]) + await self.aspirate([source_wells[0]], [unit_volume], use_channels=[0]) else: await self.aspirate([source_wells[num_well]], [unit_volume], use_channels=[0]) await self.dispense([target_wells[num_well]], [unit_volume], use_channels=[0]) @@ -729,7 +800,6 @@ async def create_protocol( """Create a new protocol with the given metadata.""" pass - async def remove_liquid( self, vols: List[float], @@ -787,11 +857,12 @@ async def remove_liquid( await self.discard_tips() elif len(use_channels) == 8 and self.backend.num_channels == 8: - - + # 对于8个的情况,需要判断此时任务是不是能被8通道移液站来成功处理 if len(sources) % 8 != 0: - raise ValueError(f"Length of `sources` {len(sources)} must be a multiple of 8 for 8-channel mode.") + raise ValueError( + f"Length of `sources` {len(sources)} must be a multiple of 8 for 8-channel mode." + ) # 8个8个来取任务序列 @@ -800,18 +871,28 @@ async def remove_liquid( for _ in range(len(use_channels)): tip.extend(next(self.current_tip)) await self.pick_up_tips(tip) - current_targets = waste_liquid[i:i + 8] - current_reagent_sources = sources[i:i + 8] - current_asp_vols = vols[i:i + 8] - current_dis_vols = vols[i:i + 8] - current_asp_flow_rates = flow_rates[i:i + 8] if flow_rates else [None] * 8 - current_dis_flow_rates = flow_rates[-i*8-8:len(flow_rates)-i*8] if flow_rates else [None] * 8 - current_asp_offset = offsets[i:i + 8] if offsets else [None] * 8 - current_dis_offset = offsets[-i*8-8:len(offsets)-i*8] if offsets else [None] * 8 - current_asp_liquid_height = liquid_height[i:i + 8] if liquid_height else [None] * 8 - current_dis_liquid_height = liquid_height[-i*8-8:len(liquid_height)-i*8] if liquid_height else [None] * 8 - current_asp_blow_out_air_volume = blow_out_air_volume[i:i + 8] if blow_out_air_volume else [None] * 8 - current_dis_blow_out_air_volume = blow_out_air_volume[-i*8-8:len(blow_out_air_volume)-i*8] if blow_out_air_volume else [None] * 8 + current_targets = waste_liquid[i : i + 8] + current_reagent_sources = sources[i : i + 8] + current_asp_vols = vols[i : i + 8] + current_dis_vols = vols[i : i + 8] + current_asp_flow_rates = flow_rates[i : i + 8] if flow_rates else [None] * 8 + current_dis_flow_rates = ( + flow_rates[-i * 8 - 8 : len(flow_rates) - i * 8] if flow_rates else [None] * 8 + ) + current_asp_offset = offsets[i : i + 8] if offsets else [None] * 8 + current_dis_offset = offsets[-i * 8 - 8 : len(offsets) - i * 8] if offsets else [None] * 8 + current_asp_liquid_height = liquid_height[i : i + 8] if liquid_height else [None] * 8 + current_dis_liquid_height = ( + liquid_height[-i * 8 - 8 : len(liquid_height) - i * 8] if liquid_height else [None] * 8 + ) + current_asp_blow_out_air_volume = ( + blow_out_air_volume[i : i + 8] if blow_out_air_volume else [None] * 8 + ) + current_dis_blow_out_air_volume = ( + blow_out_air_volume[-i * 8 - 8 : len(blow_out_air_volume) - i * 8] + if blow_out_air_volume + else [None] * 8 + ) await self.aspirate( resources=current_reagent_sources, @@ -838,7 +919,7 @@ async def remove_liquid( if delays is not None and len(delays) > 1: await self.custom_delay(seconds=delays[1]) await self.touch_tip(current_targets) - await self.discard_tips() + await self.discard_tips() except Exception as e: traceback.print_exc() @@ -872,127 +953,136 @@ async def add_liquid( # """A complete *add* (aspirate reagent → dispense into targets) operation.""" # # try: - if is_96_well: - pass # This mode is not verified. - else: - if len(asp_vols) != len(targets): - raise ValueError(f"Length of `asp_vols` {len(asp_vols)} must match `targets` {len(targets)}.") - # 首先应该对任务分组,然后每次1个/8个进行操作处理 - if len(use_channels) == 1: - for _ in range(len(targets)): - tip = [] - for x in range(len(use_channels)): - tip.extend(next(self.current_tip)) - await self.pick_up_tips(tip) - - await self.aspirate( - resources=[reagent_sources[_]], - vols=[asp_vols[_]], - use_channels=use_channels, - flow_rates=[flow_rates[0]] if flow_rates else None, - offsets=[offsets[0]] if offsets else None, - liquid_height=[liquid_height[0]] if liquid_height else None, - blow_out_air_volume=[blow_out_air_volume[0]] if blow_out_air_volume else None, - spread=spread, - ) - - if delays is not None: - await self.custom_delay(seconds=delays[0]) - await self.dispense( - resources=[targets[_]], - vols=[dis_vols[_]], - use_channels=use_channels, - flow_rates=[flow_rates[1]] if flow_rates else None, - offsets=[offsets[1]] if offsets else None, - blow_out_air_volume=[blow_out_air_volume[1]] if blow_out_air_volume else None, - liquid_height=[liquid_height[1]] if liquid_height else None, - spread=spread, - ) - - if delays is not None and len(delays) > 1: - await self.custom_delay(seconds=delays[1]) - # 只有在 mix_time 有效时才调用 mix - if mix_time is not None and mix_time > 0: - await self.mix( - targets=[targets[_]], - mix_time=mix_time, - mix_vol=mix_vol, - offsets=offsets if offsets else None, - height_to_bottom=mix_liquid_height if mix_liquid_height else None, - mix_rate=mix_rate if mix_rate else None, - ) - if delays is not None and len(delays) > 1: - await self.custom_delay(seconds=delays[1]) - await self.touch_tip(targets[_]) - await self.discard_tips() - - elif len(use_channels) == 8: - # 对于8个的情况,需要判断此时任务是不是能被8通道移液站来成功处理 - if len(targets) % 8 != 0: - raise ValueError(f"Length of `targets` {len(targets)} must be a multiple of 8 for 8-channel mode.") + if is_96_well: + pass # This mode is not verified. + else: + if len(asp_vols) != len(targets): + raise ValueError(f"Length of `asp_vols` {len(asp_vols)} must match `targets` {len(targets)}.") + # 首先应该对任务分组,然后每次1个/8个进行操作处理 + if len(use_channels) == 1: + for _ in range(len(targets)): + tip = [] + for x in range(len(use_channels)): + tip.extend(next(self.current_tip)) + await self.pick_up_tips(tip) + + await self.aspirate( + resources=[reagent_sources[_]], + vols=[asp_vols[_]], + use_channels=use_channels, + flow_rates=[flow_rates[0]] if flow_rates else None, + offsets=[offsets[0]] if offsets else None, + liquid_height=[liquid_height[0]] if liquid_height else None, + blow_out_air_volume=[blow_out_air_volume[0]] if blow_out_air_volume else None, + spread=spread, + ) - for i in range(0, len(targets), 8): - tip = [] - for _ in range(len(use_channels)): - tip.extend(next(self.current_tip)) - await self.pick_up_tips(tip) - current_targets = targets[i:i + 8] - current_reagent_sources = reagent_sources[i:i + 8] - current_asp_vols = asp_vols[i:i + 8] - current_dis_vols = dis_vols[i:i + 8] - current_asp_flow_rates = flow_rates[i:i + 8] if flow_rates else [None] * 8 - current_dis_flow_rates = flow_rates[-i*8-8:len(flow_rates)-i*8] if flow_rates else [None] * 8 - current_asp_offset = offsets[i:i + 8] if offsets else [None] * 8 - current_dis_offset = offsets[-i*8-8:len(offsets)-i*8] if offsets else [None] * 8 - current_asp_liquid_height = liquid_height[i:i + 8] if liquid_height else [None] * 8 - current_dis_liquid_height = liquid_height[-i*8-8:len(liquid_height)-i*8] if liquid_height else [None] * 8 - current_asp_blow_out_air_volume = blow_out_air_volume[i:i + 8] if blow_out_air_volume else [None] * 8 - current_dis_blow_out_air_volume = blow_out_air_volume[-i*8-8:len(blow_out_air_volume)-i*8] if blow_out_air_volume else [None] * 8 + if delays is not None: + await self.custom_delay(seconds=delays[0]) + await self.dispense( + resources=[targets[_]], + vols=[dis_vols[_]], + use_channels=use_channels, + flow_rates=[flow_rates[1]] if flow_rates else None, + offsets=[offsets[1]] if offsets else None, + blow_out_air_volume=[blow_out_air_volume[1]] if blow_out_air_volume else None, + liquid_height=[liquid_height[1]] if liquid_height else None, + spread=spread, + ) - await self.aspirate( - resources=current_reagent_sources, - vols=current_asp_vols, - use_channels=use_channels, - flow_rates=current_asp_flow_rates, - offsets=current_asp_offset, - liquid_height=current_asp_liquid_height, - blow_out_air_volume=current_asp_blow_out_air_volume, - spread=spread, - ) - if delays is not None: - await self.custom_delay(seconds=delays[0]) - await self.dispense( - resources=current_targets, - vols=current_dis_vols, - use_channels=use_channels, - flow_rates=current_dis_flow_rates, - offsets=current_dis_offset, - liquid_height=current_dis_liquid_height, - blow_out_air_volume=current_dis_blow_out_air_volume, - spread=spread, + if delays is not None and len(delays) > 1: + await self.custom_delay(seconds=delays[1]) + # 只有在 mix_time 有效时才调用 mix + if mix_time is not None and mix_time > 0: + await self.mix( + targets=[targets[_]], + mix_time=mix_time, + mix_vol=mix_vol, + offsets=offsets if offsets else None, + height_to_bottom=mix_liquid_height if mix_liquid_height else None, + mix_rate=mix_rate if mix_rate else None, ) - if delays is not None and len(delays) > 1: - await self.custom_delay(seconds=delays[1]) - - # 只有在 mix_time 有效时才调用 mix - if mix_time is not None and mix_time > 0: - await self.mix( - targets=current_targets, - mix_time=mix_time, - mix_vol=mix_vol, - offsets=offsets if offsets else None, - height_to_bottom=mix_liquid_height if mix_liquid_height else None, - mix_rate=mix_rate if mix_rate else None, - ) - if delays is not None and len(delays) > 1: - await self.custom_delay(seconds=delays[1]) - await self.touch_tip(current_targets) - await self.discard_tips() + if delays is not None and len(delays) > 1: + await self.custom_delay(seconds=delays[1]) + await self.touch_tip(targets[_]) + await self.discard_tips() + + elif len(use_channels) == 8: + # 对于8个的情况,需要判断此时任务是不是能被8通道移液站来成功处理 + if len(targets) % 8 != 0: + raise ValueError(f"Length of `targets` {len(targets)} must be a multiple of 8 for 8-channel mode.") + + for i in range(0, len(targets), 8): + tip = [] + for _ in range(len(use_channels)): + tip.extend(next(self.current_tip)) + await self.pick_up_tips(tip) + current_targets = targets[i : i + 8] + current_reagent_sources = reagent_sources[i : i + 8] + current_asp_vols = asp_vols[i : i + 8] + current_dis_vols = dis_vols[i : i + 8] + current_asp_flow_rates = flow_rates[i : i + 8] if flow_rates else [None] * 8 + current_dis_flow_rates = ( + flow_rates[-i * 8 - 8 : len(flow_rates) - i * 8] if flow_rates else [None] * 8 + ) + current_asp_offset = offsets[i : i + 8] if offsets else [None] * 8 + current_dis_offset = offsets[-i * 8 - 8 : len(offsets) - i * 8] if offsets else [None] * 8 + current_asp_liquid_height = liquid_height[i : i + 8] if liquid_height else [None] * 8 + current_dis_liquid_height = ( + liquid_height[-i * 8 - 8 : len(liquid_height) - i * 8] if liquid_height else [None] * 8 + ) + current_asp_blow_out_air_volume = ( + blow_out_air_volume[i : i + 8] if blow_out_air_volume else [None] * 8 + ) + current_dis_blow_out_air_volume = ( + blow_out_air_volume[-i * 8 - 8 : len(blow_out_air_volume) - i * 8] + if blow_out_air_volume + else [None] * 8 + ) + await self.aspirate( + resources=current_reagent_sources, + vols=current_asp_vols, + use_channels=use_channels, + flow_rates=current_asp_flow_rates, + offsets=current_asp_offset, + liquid_height=current_asp_liquid_height, + blow_out_air_volume=current_asp_blow_out_air_volume, + spread=spread, + ) + if delays is not None: + await self.custom_delay(seconds=delays[0]) + await self.dispense( + resources=current_targets, + vols=current_dis_vols, + use_channels=use_channels, + flow_rates=current_dis_flow_rates, + offsets=current_dis_offset, + liquid_height=current_dis_liquid_height, + blow_out_air_volume=current_dis_blow_out_air_volume, + spread=spread, + ) + if delays is not None and len(delays) > 1: + await self.custom_delay(seconds=delays[1]) + + # 只有在 mix_time 有效时才调用 mix + if mix_time is not None and mix_time > 0: + await self.mix( + targets=current_targets, + mix_time=mix_time, + mix_vol=mix_vol, + offsets=offsets if offsets else None, + height_to_bottom=mix_liquid_height if mix_liquid_height else None, + mix_rate=mix_rate if mix_rate else None, + ) + if delays is not None and len(delays) > 1: + await self.custom_delay(seconds=delays[1]) + await self.touch_tip(current_targets) + await self.discard_tips() - # except Exception as e: - # traceback.print_exc() - # raise RuntimeError(f"Liquid addition failed: {e}") from e + # except Exception as e: + # traceback.print_exc() + # raise RuntimeError(f"Liquid addition failed: {e}") from e # --------------------------------------------------------------- # TRANSFER LIQUID ------------------------------------------------ @@ -1050,12 +1140,12 @@ async def transfer_liquid( Number of mix cycles. If *None* (default) no mixing occurs regardless of mix_stage. """ - + # 确保 use_channels 有默认值 if use_channels is None: # 默认使用设备所有通道(例如 8 通道移液站默认就是 0-7) use_channels = list(range(self.channel_num)) if self.channel_num > 0 else [0] - + if is_96_well: pass # This mode is not verified. else: @@ -1064,7 +1154,7 @@ async def transfer_liquid( asp_vols = [float(asp_vols)] else: asp_vols = [float(v) for v in asp_vols] - + if isinstance(dis_vols, (int, float)): dis_vols = [float(dis_vols)] else: @@ -1081,37 +1171,79 @@ async def transfer_liquid( pass if mix_times is not None: mix_times = int(mix_times) - + # 识别传输模式(mix_times 为 None 也应该能正常移液,只是不做 mix) num_sources = len(sources) num_targets = len(targets) - + if num_sources == 1 and num_targets > 1: # 模式1: 一对多 (1 source -> N targets) await self._transfer_one_to_many( - sources[0], targets, tip_racks, use_channels, - asp_vols, dis_vols, asp_flow_rates, dis_flow_rates, - offsets, touch_tip, liquid_height, blow_out_air_volume, - spread, mix_stage, mix_times, mix_vol, mix_rate, - mix_liquid_height, delays + sources[0], + targets, + tip_racks, + use_channels, + asp_vols, + dis_vols, + asp_flow_rates, + dis_flow_rates, + offsets, + touch_tip, + liquid_height, + blow_out_air_volume, + spread, + mix_stage, + mix_times, + mix_vol, + mix_rate, + mix_liquid_height, + delays, ) elif num_sources > 1 and num_targets == 1: # 模式2: 多对一 (N sources -> 1 target) await self._transfer_many_to_one( - sources, targets[0], tip_racks, use_channels, - asp_vols, dis_vols, asp_flow_rates, dis_flow_rates, - offsets, touch_tip, liquid_height, blow_out_air_volume, - spread, mix_stage, mix_times, mix_vol, mix_rate, - mix_liquid_height, delays + sources, + targets[0], + tip_racks, + use_channels, + asp_vols, + dis_vols, + asp_flow_rates, + dis_flow_rates, + offsets, + touch_tip, + liquid_height, + blow_out_air_volume, + spread, + mix_stage, + mix_times, + mix_vol, + mix_rate, + mix_liquid_height, + delays, ) elif num_sources == num_targets: # 模式3: 一对一 (N sources -> N targets) await self._transfer_one_to_one( - sources, targets, tip_racks, use_channels, - asp_vols, dis_vols, asp_flow_rates, dis_flow_rates, - offsets, touch_tip, liquid_height, blow_out_air_volume, - spread, mix_stage, mix_times, mix_vol, mix_rate, - mix_liquid_height, delays + sources, + targets, + tip_racks, + use_channels, + asp_vols, + dis_vols, + asp_flow_rates, + dis_flow_rates, + offsets, + touch_tip, + liquid_height, + blow_out_air_volume, + spread, + mix_stage, + mix_times, + mix_vol, + mix_rate, + mix_liquid_height, + delays, ) else: raise ValueError( @@ -1174,7 +1306,9 @@ async def _transfer_one_to_one( flow_rates=[asp_flow_rates[_]] if asp_flow_rates and len(asp_flow_rates) > _ else None, offsets=[offsets[_]] if offsets and len(offsets) > _ else None, liquid_height=[liquid_height[_]] if liquid_height and len(liquid_height) > _ else None, - blow_out_air_volume=[blow_out_air_volume[_]] if blow_out_air_volume and len(blow_out_air_volume) > _ else None, + blow_out_air_volume=( + [blow_out_air_volume[_]] if blow_out_air_volume and len(blow_out_air_volume) > _ else None + ), spread=spread, ) if delays is not None: @@ -1185,7 +1319,9 @@ async def _transfer_one_to_one( use_channels=use_channels, flow_rates=[dis_flow_rates[_]] if dis_flow_rates and len(dis_flow_rates) > _ else None, offsets=[offsets[_]] if offsets and len(offsets) > _ else None, - blow_out_air_volume=[blow_out_air_volume[_]] if blow_out_air_volume and len(blow_out_air_volume) > _ else None, + blow_out_air_volume=( + [blow_out_air_volume[_]] if blow_out_air_volume and len(blow_out_air_volume) > _ else None + ), liquid_height=[liquid_height[_]] if liquid_height and len(liquid_height) > _ else None, spread=spread, ) @@ -1214,18 +1350,18 @@ async def _transfer_one_to_one( for _ in range(len(use_channels)): tip.extend(next(self.current_tip)) await self.pick_up_tips(tip) - current_targets = targets[i:i + 8] - current_reagent_sources = sources[i:i + 8] - current_asp_vols = asp_vols[i:i + 8] - current_dis_vols = dis_vols[i:i + 8] - current_asp_flow_rates = asp_flow_rates[i:i + 8] if asp_flow_rates else None - current_asp_offset = offsets[i:i + 8] if offsets else [None] * 8 - current_dis_offset = offsets[i:i + 8] if offsets else [None] * 8 - current_asp_liquid_height = liquid_height[i:i + 8] if liquid_height else [None] * 8 - current_dis_liquid_height = liquid_height[i:i + 8] if liquid_height else [None] * 8 - current_asp_blow_out_air_volume = blow_out_air_volume[i:i + 8] if blow_out_air_volume else [None] * 8 - current_dis_blow_out_air_volume = blow_out_air_volume[i:i + 8] if blow_out_air_volume else [None] * 8 - current_dis_flow_rates = dis_flow_rates[i:i + 8] if dis_flow_rates else None + current_targets = targets[i : i + 8] + current_reagent_sources = sources[i : i + 8] + current_asp_vols = asp_vols[i : i + 8] + current_dis_vols = dis_vols[i : i + 8] + current_asp_flow_rates = asp_flow_rates[i : i + 8] if asp_flow_rates else None + current_asp_offset = offsets[i : i + 8] if offsets else [None] * 8 + current_dis_offset = offsets[i : i + 8] if offsets else [None] * 8 + current_asp_liquid_height = liquid_height[i : i + 8] if liquid_height else [None] * 8 + current_dis_liquid_height = liquid_height[i : i + 8] if liquid_height else [None] * 8 + current_asp_blow_out_air_volume = blow_out_air_volume[i : i + 8] if blow_out_air_volume else [None] * 8 + current_dis_blow_out_air_volume = blow_out_air_volume[i : i + 8] if blow_out_air_volume else [None] * 8 + current_dis_flow_rates = dis_flow_rates[i : i + 8] if dis_flow_rates else None if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0: await self.mix( @@ -1275,7 +1411,7 @@ async def _transfer_one_to_one( if delays is not None and len(delays) > 1: await self.custom_delay(seconds=delays[1]) await self.touch_tip(current_targets) - await self.discard_tips([0,1,2,3,4,5,6,7]) + await self.discard_tips([0, 1, 2, 3, 4, 5, 6, 7]) async def _transfer_one_to_many( self, @@ -1307,7 +1443,7 @@ async def _transfer_one_to_many( asp_vol = asp_vols[0] if asp_vols[0] >= total_asp_vol else total_asp_vol else: raise ValueError("For one-to-many mode, `asp_vols` should be a single value or list with one element.") - + if len(dis_vols) != len(targets): raise ValueError(f"Length of `dis_vols` {len(dis_vols)} must match `targets` {len(targets)}.") @@ -1324,7 +1460,7 @@ async def _transfer_one_to_many( targets=[target], mix_time=mix_times, mix_vol=mix_vol, - offsets=offsets[idx:idx + 1] if offsets and len(offsets) > idx else None, + offsets=offsets[idx : idx + 1] if offsets and len(offsets) > idx else None, height_to_bottom=mix_liquid_height if mix_liquid_height else None, mix_rate=mix_rate if mix_rate else None, ) @@ -1337,13 +1473,15 @@ async def _transfer_one_to_many( flow_rates=[asp_flow_rates[0]] if asp_flow_rates and len(asp_flow_rates) > 0 else None, offsets=[offsets[0]] if offsets and len(offsets) > 0 else None, liquid_height=[liquid_height[0]] if liquid_height and len(liquid_height) > 0 else None, - blow_out_air_volume=[blow_out_air_volume[0]] if blow_out_air_volume and len(blow_out_air_volume) > 0 else None, + blow_out_air_volume=( + [blow_out_air_volume[0]] if blow_out_air_volume and len(blow_out_air_volume) > 0 else None + ), spread=spread, ) - + if delays is not None: await self.custom_delay(seconds=delays[0]) - + # 分多次分液到不同的目标容器 for idx, target in enumerate(targets): await self.dispense( @@ -1352,7 +1490,9 @@ async def _transfer_one_to_many( use_channels=use_channels, flow_rates=[dis_flow_rates[idx]] if dis_flow_rates and len(dis_flow_rates) > idx else None, offsets=[offsets[idx]] if offsets and len(offsets) > idx else None, - blow_out_air_volume=[blow_out_air_volume[idx]] if blow_out_air_volume and len(blow_out_air_volume) > idx else None, + blow_out_air_volume=( + [blow_out_air_volume[idx]] if blow_out_air_volume and len(blow_out_air_volume) > idx else None + ), liquid_height=[liquid_height[idx]] if liquid_height and len(liquid_height) > idx else None, spread=spread, ) @@ -1363,46 +1503,54 @@ async def _transfer_one_to_many( targets=[target], mix_time=mix_times, mix_vol=mix_vol, - offsets=offsets[idx:idx+1] if offsets else None, + offsets=offsets[idx : idx + 1] if offsets else None, height_to_bottom=mix_liquid_height if mix_liquid_height else None, mix_rate=mix_rate if mix_rate else None, ) if touch_tip: await self.touch_tip([target]) - + await self.discard_tips(use_channels=use_channels) - + elif len(use_channels) == 8: # 8通道模式:需要确保目标数量是8的倍数 if len(targets) % 8 != 0: raise ValueError(f"For 8-channel mode, number of targets {len(targets)} must be a multiple of 8.") - + # 每次处理8个目标 for i in range(0, len(targets), 8): tip = [] for _ in range(len(use_channels)): tip.extend(next(self.current_tip)) await self.pick_up_tips(tip) - - current_targets = targets[i:i + 8] - current_dis_vols = dis_vols[i:i + 8] - + + current_targets = targets[i : i + 8] + current_dis_vols = dis_vols[i : i + 8] + # 8个通道都从同一个源容器吸液,每个通道的吸液体积等于对应的分液体积 - current_asp_flow_rates = asp_flow_rates[0:1] * 8 if asp_flow_rates and len(asp_flow_rates) > 0 else None + current_asp_flow_rates = ( + asp_flow_rates[0:1] * 8 if asp_flow_rates and len(asp_flow_rates) > 0 else None + ) current_asp_offset = offsets[0:1] * 8 if offsets and len(offsets) > 0 else [None] * 8 - current_asp_liquid_height = liquid_height[0:1] * 8 if liquid_height and len(liquid_height) > 0 else [None] * 8 - current_asp_blow_out_air_volume = blow_out_air_volume[0:1] * 8 if blow_out_air_volume and len(blow_out_air_volume) > 0 else [None] * 8 - + current_asp_liquid_height = ( + liquid_height[0:1] * 8 if liquid_height and len(liquid_height) > 0 else [None] * 8 + ) + current_asp_blow_out_air_volume = ( + blow_out_air_volume[0:1] * 8 + if blow_out_air_volume and len(blow_out_air_volume) > 0 + else [None] * 8 + ) + if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0: await self.mix( targets=current_targets, mix_time=mix_times, mix_vol=mix_vol, - offsets=offsets[i:i + 8] if offsets else None, + offsets=offsets[i : i + 8] if offsets else None, height_to_bottom=mix_liquid_height if mix_liquid_height else None, mix_rate=mix_rate if mix_rate else None, ) - + # 从源容器吸液(8个通道都从同一个源,但每个通道的吸液体积不同) await self.aspirate( resources=[source] * 8, # 8个通道都从同一个源 @@ -1414,16 +1562,16 @@ async def _transfer_one_to_many( blow_out_air_volume=current_asp_blow_out_air_volume, spread=spread, ) - + if delays is not None: await self.custom_delay(seconds=delays[0]) - + # 分液到8个目标 - current_dis_flow_rates = dis_flow_rates[i:i + 8] if dis_flow_rates else None - current_dis_offset = offsets[i:i + 8] if offsets else [None] * 8 - current_dis_liquid_height = liquid_height[i:i + 8] if liquid_height else [None] * 8 - current_dis_blow_out_air_volume = blow_out_air_volume[i:i + 8] if blow_out_air_volume else [None] * 8 - + current_dis_flow_rates = dis_flow_rates[i : i + 8] if dis_flow_rates else None + current_dis_offset = offsets[i : i + 8] if offsets else [None] * 8 + current_dis_liquid_height = liquid_height[i : i + 8] if liquid_height else [None] * 8 + current_dis_blow_out_air_volume = blow_out_air_volume[i : i + 8] if blow_out_air_volume else [None] * 8 + await self.dispense( resources=current_targets, vols=current_dis_vols, @@ -1434,10 +1582,10 @@ async def _transfer_one_to_many( liquid_height=current_dis_liquid_height, spread=spread, ) - + if delays is not None and len(delays) > 1: await self.custom_delay(seconds=delays[1]) - + if mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0: await self.mix( targets=current_targets, @@ -1447,11 +1595,11 @@ async def _transfer_one_to_many( height_to_bottom=mix_liquid_height if mix_liquid_height else None, mix_rate=mix_rate if mix_rate else None, ) - + if touch_tip: await self.touch_tip(current_targets) - - await self.discard_tips([0,1,2,3,4,5,6,7]) + + await self.discard_tips([0, 1, 2, 3, 4, 5, 6, 7]) async def _transfer_many_to_one( self, @@ -1479,7 +1627,7 @@ async def _transfer_many_to_one( # 验证和扩展体积参数 if len(asp_vols) != len(sources): raise ValueError(f"Length of `asp_vols` {len(asp_vols)} must match `sources` {len(sources)}.") - + # 支持两种模式: # 1. dis_vols 为单个值:所有源汇总,使用总吸液体积或指定分液体积 # 2. dis_vols 长度等于 asp_vols:每个源按不同比例分液(按比例混合) @@ -1509,7 +1657,7 @@ async def _transfer_many_to_one( height_to_bottom=mix_liquid_height if mix_liquid_height else None, mix_rate=mix_rate if mix_rate else None, ) - + # 从每个源容器吸液并分液到目标容器 for idx, source in enumerate(sources): tip = [] @@ -1524,13 +1672,15 @@ async def _transfer_many_to_one( flow_rates=[asp_flow_rates[idx]] if asp_flow_rates and len(asp_flow_rates) > idx else None, offsets=[offsets[idx]] if offsets and len(offsets) > idx else None, liquid_height=[liquid_height[idx]] if liquid_height and len(liquid_height) > idx else None, - blow_out_air_volume=[blow_out_air_volume[idx]] if blow_out_air_volume and len(blow_out_air_volume) > idx else None, + blow_out_air_volume=( + [blow_out_air_volume[idx]] if blow_out_air_volume and len(blow_out_air_volume) > idx else None + ), spread=spread, ) - + if delays is not None: await self.custom_delay(seconds=delays[0]) - + # 分液到目标容器 if use_proportional_mixing: # 按不同比例混合:使用对应的 dis_vols @@ -1538,15 +1688,19 @@ async def _transfer_many_to_one( dis_flow_rate = dis_flow_rates[idx] if dis_flow_rates and len(dis_flow_rates) > idx else None dis_offset = offsets[idx] if offsets and len(offsets) > idx else None dis_liquid_height = liquid_height[idx] if liquid_height and len(liquid_height) > idx else None - dis_blow_out = blow_out_air_volume[idx] if blow_out_air_volume and len(blow_out_air_volume) > idx else None + dis_blow_out = ( + blow_out_air_volume[idx] if blow_out_air_volume and len(blow_out_air_volume) > idx else None + ) else: # 标准模式:分液体积等于吸液体积 dis_vol = asp_vols[idx] dis_flow_rate = dis_flow_rates[0] if dis_flow_rates and len(dis_flow_rates) > 0 else None dis_offset = offsets[0] if offsets and len(offsets) > 0 else None dis_liquid_height = liquid_height[0] if liquid_height and len(liquid_height) > 0 else None - dis_blow_out = blow_out_air_volume[0] if blow_out_air_volume and len(blow_out_air_volume) > 0 else None - + dis_blow_out = ( + blow_out_air_volume[0] if blow_out_air_volume and len(blow_out_air_volume) > 0 else None + ) + await self.dispense( resources=[target], vols=[dis_vol], @@ -1557,12 +1711,12 @@ async def _transfer_many_to_one( liquid_height=[dis_liquid_height] if dis_liquid_height is not None else None, spread=spread, ) - + if delays is not None and len(delays) > 1: await self.custom_delay(seconds=delays[1]) - + await self.discard_tips(use_channels=use_channels) - + # 最后在目标容器中混合(如果需要) if mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0: await self.mix( @@ -1573,15 +1727,15 @@ async def _transfer_many_to_one( height_to_bottom=mix_liquid_height if mix_liquid_height else None, mix_rate=mix_rate if mix_rate else None, ) - + if touch_tip: await self.touch_tip([target]) - + elif len(use_channels) == 8: # 8通道模式:需要确保源数量是8的倍数 if len(sources) % 8 != 0: raise ValueError(f"For 8-channel mode, number of sources {len(sources)} must be a multiple of 8.") - + # 每次处理8个源 if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0: await self.mix( @@ -1598,14 +1752,14 @@ async def _transfer_many_to_one( for _ in range(len(use_channels)): tip.extend(next(self.current_tip)) await self.pick_up_tips(tip) - - current_sources = sources[i:i + 8] - current_asp_vols = asp_vols[i:i + 8] - current_asp_flow_rates = asp_flow_rates[i:i + 8] if asp_flow_rates else None - current_asp_offset = offsets[i:i + 8] if offsets else [None] * 8 - current_asp_liquid_height = liquid_height[i:i + 8] if liquid_height else [None] * 8 - current_asp_blow_out_air_volume = blow_out_air_volume[i:i + 8] if blow_out_air_volume else [None] * 8 - + + current_sources = sources[i : i + 8] + current_asp_vols = asp_vols[i : i + 8] + current_asp_flow_rates = asp_flow_rates[i : i + 8] if asp_flow_rates else None + current_asp_offset = offsets[i : i + 8] if offsets else [None] * 8 + current_asp_liquid_height = liquid_height[i : i + 8] if liquid_height else [None] * 8 + current_asp_blow_out_air_volume = blow_out_air_volume[i : i + 8] if blow_out_air_volume else [None] * 8 + # 从8个源容器吸液 await self.aspirate( resources=current_sources, @@ -1617,26 +1771,30 @@ async def _transfer_many_to_one( liquid_height=current_asp_liquid_height, spread=spread, ) - + if delays is not None: await self.custom_delay(seconds=delays[0]) - + # 分液到目标容器(每个通道分液到同一个目标) if use_proportional_mixing: # 按比例混合:使用对应的 dis_vols - current_dis_vols = dis_vols[i:i + 8] - current_dis_flow_rates = dis_flow_rates[i:i + 8] if dis_flow_rates else None - current_dis_offset = offsets[i:i + 8] if offsets else [None] * 8 - current_dis_liquid_height = liquid_height[i:i + 8] if liquid_height else [None] * 8 - current_dis_blow_out_air_volume = blow_out_air_volume[i:i + 8] if blow_out_air_volume else [None] * 8 + current_dis_vols = dis_vols[i : i + 8] + current_dis_flow_rates = dis_flow_rates[i : i + 8] if dis_flow_rates else None + current_dis_offset = offsets[i : i + 8] if offsets else [None] * 8 + current_dis_liquid_height = liquid_height[i : i + 8] if liquid_height else [None] * 8 + current_dis_blow_out_air_volume = ( + blow_out_air_volume[i : i + 8] if blow_out_air_volume else [None] * 8 + ) else: # 标准模式:每个通道分液体积等于其吸液体积 current_dis_vols = current_asp_vols current_dis_flow_rates = dis_flow_rates[0:1] * 8 if dis_flow_rates else None current_dis_offset = offsets[0:1] * 8 if offsets else [None] * 8 current_dis_liquid_height = liquid_height[0:1] * 8 if liquid_height else [None] * 8 - current_dis_blow_out_air_volume = blow_out_air_volume[0:1] * 8 if blow_out_air_volume else [None] * 8 - + current_dis_blow_out_air_volume = ( + blow_out_air_volume[0:1] * 8 if blow_out_air_volume else [None] * 8 + ) + await self.dispense( resources=[target] * 8, # 8个通道都分到同一个目标 vols=current_dis_vols, @@ -1647,12 +1805,12 @@ async def _transfer_many_to_one( liquid_height=current_dis_liquid_height, spread=spread, ) - + if delays is not None and len(delays) > 1: await self.custom_delay(seconds=delays[1]) - - await self.discard_tips([0,1,2,3,4,5,6,7]) - + + await self.discard_tips([0, 1, 2, 3, 4, 5, 6, 7]) + # 最后在目标容器中混合(如果需要) if mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0: await self.mix( @@ -1663,7 +1821,7 @@ async def _transfer_many_to_one( height_to_bottom=mix_liquid_height if mix_liquid_height else None, mix_rate=mix_rate if mix_rate else None, ) - + if touch_tip: await self.touch_tip([target]) @@ -1671,7 +1829,6 @@ async def _transfer_many_to_one( # traceback.print_exc() # raise RuntimeError(f"Liquid addition failed: {e}") from e - # --------------------------------------------------------------- # Helper utilities # --------------------------------------------------------------- @@ -1692,7 +1849,6 @@ async def custom_delay(self, seconds=0, msg=None): print(f"Current time: {time.strftime('%H:%M:%S')}") async def touch_tip(self, targets: Sequence[Container]): - """Touch the tip to the side of the well.""" if not self.support_touch_tip: diff --git a/unilabos/devices/liquid_handling/prcxi/prcxi.py b/unilabos/devices/liquid_handling/prcxi/prcxi.py index e0c7e80b7..4f9625599 100644 --- a/unilabos/devices/liquid_handling/prcxi/prcxi.py +++ b/unilabos/devices/liquid_handling/prcxi/prcxi.py @@ -30,9 +30,30 @@ ResourceMove, ResourceDrop, ) -from pylabrobot.resources import ResourceHolder, ResourceStack, Tip, Deck, Plate, Well, TipRack, Resource, Container, Coordinate, TipSpot, Trash, PlateAdapter, TubeRack +from pylabrobot.resources import ( + ResourceHolder, + ResourceStack, + Tip, + Deck, + Plate, + Well, + TipRack, + Resource, + Container, + Coordinate, + TipSpot, + Trash, + PlateAdapter, + TubeRack, +) -from unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract, SimpleReturn +from unilabos.devices.liquid_handling.liquid_handler_abstract import ( + LiquidHandlerAbstract, + SimpleReturn, + SetLiquidReturn, + SetLiquidFromPlateReturn, +) +from unilabos.registry.placeholder_type import ResourceSlot from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode @@ -80,6 +101,7 @@ def assign_child_at_slot(self, resource: Resource, slot: int, reassign: bool = F self.slots[slot - 1] = resource super().assign_child_resource(resource, location=self.slot_locations[slot - 1]) + class PRCXI9300Container(Plate): """PRCXI 9300 的专用 Container 类,继承自 Plate,用于槽位定位和未知模块。 @@ -108,20 +130,29 @@ def load_state(self, state: Dict[str, Any]) -> None: def serialize_state(self) -> Dict[str, Dict[str, Any]]: data = super().serialize_state() data.update(self._unilabos_state) - return data + return data + + class PRCXI9300Plate(Plate): - """ + """ 专用孔板类: 1. 继承自 PLR 原生 Plate,保留所有物理特性。 2. 增加 material_info 参数,用于在初始化时直接绑定 Unilab UUID。 """ - def __init__(self, name: str, size_x: float, size_y: float, size_z: float, - category: str = "plate", - ordered_items: collections.OrderedDict = None, - ordering: Optional[collections.OrderedDict] = None, - model: Optional[str] = None, - material_info: Optional[Dict[str, Any]] = None, - **kwargs): + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + category: str = "plate", + ordered_items: collections.OrderedDict = None, + ordering: Optional[collections.OrderedDict] = None, + model: Optional[str] = None, + material_info: Optional[Dict[str, Any]] = None, + **kwargs, + ): # 如果 ordered_items 不为 None,直接使用 if ordered_items is not None: items = ordered_items @@ -142,40 +173,34 @@ def __init__(self, name: str, size_x: float, size_y: float, size_z: float, else: items = None ordering_param = None - + # 根据情况传递不同的参数 if items is not None: - super().__init__(name, size_x, size_y, size_z, - ordered_items=items, - category=category, - model=model, **kwargs) + super().__init__( + name, size_x, size_y, size_z, ordered_items=items, category=category, model=model, **kwargs + ) elif ordering_param is not None: # 传递 ordering 参数,让 Plate 自己创建 Well 对象 - super().__init__(name, size_x, size_y, size_z, - ordering=ordering_param, - category=category, - model=model, **kwargs) + super().__init__( + name, size_x, size_y, size_z, ordering=ordering_param, category=category, model=model, **kwargs + ) else: - super().__init__(name, size_x, size_y, size_z, - category=category, - model=model, **kwargs) - + super().__init__(name, size_x, size_y, size_z, category=category, model=model, **kwargs) + self._unilabos_state = {} if material_info: self._unilabos_state["Material"] = material_info - def load_state(self, state: Dict[str, Any]) -> None: super().load_state(state) self._unilabos_state = state - def serialize_state(self) -> Dict[str, Dict[str, Any]]: try: data = super().serialize_state() except AttributeError: data = {} - if hasattr(self, '_unilabos_state') and self._unilabos_state: + if hasattr(self, "_unilabos_state") and self._unilabos_state: safe_state = {} for k, v in self._unilabos_state.items(): # 如果是 Material 字典,深入检查 @@ -188,23 +213,32 @@ def serialize_state(self) -> Dict[str, Dict[str, Any]]: else: # 打印日志提醒(可选) # print(f"Warning: Removing non-serializable key {mk} from {self.name}") - pass + pass safe_state[k] = safe_material # 其他顶层属性也进行类型检查 elif isinstance(v, (str, int, float, bool, list, dict, type(None))): safe_state[k] = v - + data.update(safe_state) - return data # 其他顶层属性也进行类型检查 + return data # 其他顶层属性也进行类型检查 + + class PRCXI9300TipRack(TipRack): - """ 专用吸头盒类 """ - def __init__(self, name: str, size_x: float, size_y: float, size_z: float, - category: str = "tip_rack", - ordered_items: collections.OrderedDict = None, - ordering: Optional[collections.OrderedDict] = None, - model: Optional[str] = None, - material_info: Optional[Dict[str, Any]] = None, - **kwargs): + """专用吸头盒类""" + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + category: str = "tip_rack", + ordered_items: collections.OrderedDict = None, + ordering: Optional[collections.OrderedDict] = None, + model: Optional[str] = None, + material_info: Optional[Dict[str, Any]] = None, + **kwargs, + ): # 如果 ordered_items 不为 None,直接使用 if ordered_items is not None: items = ordered_items @@ -225,27 +259,23 @@ def __init__(self, name: str, size_x: float, size_y: float, size_z: float, else: items = None ordering_param = None - + # 根据情况传递不同的参数 if items is not None: - super().__init__(name, size_x, size_y, size_z, - ordered_items=items, - category=category, - model=model, **kwargs) + super().__init__( + name, size_x, size_y, size_z, ordered_items=items, category=category, model=model, **kwargs + ) elif ordering_param is not None: # 传递 ordering 参数,让 TipRack 自己创建 Tip 对象 - super().__init__(name, size_x, size_y, size_z, - ordering=ordering_param, - category=category, - model=model, **kwargs) + super().__init__( + name, size_x, size_y, size_z, ordering=ordering_param, category=category, model=model, **kwargs + ) else: - super().__init__(name, size_x, size_y, size_z, - category=category, - model=model, **kwargs) + super().__init__(name, size_x, size_y, size_z, category=category, model=model, **kwargs) self._unilabos_state = {} if material_info: self._unilabos_state["Material"] = material_info - + def load_state(self, state: Dict[str, Any]) -> None: super().load_state(state) self._unilabos_state = state @@ -255,7 +285,7 @@ def serialize_state(self) -> Dict[str, Dict[str, Any]]: data = super().serialize_state() except AttributeError: data = {} - if hasattr(self, '_unilabos_state') and self._unilabos_state: + if hasattr(self, "_unilabos_state") and self._unilabos_state: safe_state = {} for k, v in self._unilabos_state.items(): # 如果是 Material 字典,深入检查 @@ -268,26 +298,33 @@ def serialize_state(self) -> Dict[str, Dict[str, Any]]: else: # 打印日志提醒(可选) # print(f"Warning: Removing non-serializable key {mk} from {self.name}") - pass + pass safe_state[k] = safe_material # 其他顶层属性也进行类型检查 elif isinstance(v, (str, int, float, bool, list, dict, type(None))): safe_state[k] = v - + data.update(safe_state) return data - + + class PRCXI9300Trash(Trash): """PRCXI 9300 的专用 Trash 类,继承自 Trash。 该类定义了 PRCXI 9300 的工作台布局和槽位信息。 """ - def __init__(self, name: str, size_x: float, size_y: float, size_z: float, - category: str = "trash", - material_info: Optional[Dict[str, Any]] = None, - **kwargs): - + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + category: str = "trash", + material_info: Optional[Dict[str, Any]] = None, + **kwargs, + ): + if name != "trash": print(f"Warning: PRCXI9300Trash usually expects name='trash' for backend logic, but got '{name}'.") super().__init__(name, size_x, size_y, size_z, **kwargs) @@ -306,7 +343,7 @@ def serialize_state(self) -> Dict[str, Dict[str, Any]]: data = super().serialize_state() except AttributeError: data = {} - if hasattr(self, '_unilabos_state') and self._unilabos_state: + if hasattr(self, "_unilabos_state") and self._unilabos_state: safe_state = {} for k, v in self._unilabos_state.items(): # 如果是 Material 字典,深入检查 @@ -319,29 +356,37 @@ def serialize_state(self) -> Dict[str, Dict[str, Any]]: else: # 打印日志提醒(可选) # print(f"Warning: Removing non-serializable key {mk} from {self.name}") - pass + pass safe_state[k] = safe_material # 其他顶层属性也进行类型检查 elif isinstance(v, (str, int, float, bool, list, dict, type(None))): safe_state[k] = v - + data.update(safe_state) return data + class PRCXI9300TubeRack(TubeRack): """ 专用管架类:用于 EP 管架、试管架等。 继承自 PLR 的 TubeRack,并支持注入 material_info (UUID)。 """ - def __init__(self, name: str, size_x: float, size_y: float, size_z: float, - category: str = "tube_rack", - items: Optional[Dict[str, Any]] = None, - ordered_items: Optional[OrderedDict] = None, - ordering: Optional[OrderedDict] = None, - model: Optional[str] = None, - material_info: Optional[Dict[str, Any]] = None, - **kwargs): - + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + category: str = "tube_rack", + items: Optional[Dict[str, Any]] = None, + ordered_items: Optional[OrderedDict] = None, + ordering: Optional[OrderedDict] = None, + model: Optional[str] = None, + material_info: Optional[Dict[str, Any]] = None, + **kwargs, + ): + # 如果 ordered_items 不为 None,直接使用 if ordered_items is not None: items_to_pass = ordered_items @@ -367,24 +412,16 @@ def __init__(self, name: str, size_x: float, size_y: float, size_z: float, else: items_to_pass = None ordering_param = None - + # 根据情况传递不同的参数 if items_to_pass is not None: - super().__init__(name, size_x, size_y, size_z, - ordered_items=items_to_pass, - model=model, - **kwargs) + super().__init__(name, size_x, size_y, size_z, ordered_items=items_to_pass, model=model, **kwargs) elif ordering_param is not None: # 传递 ordering 参数,让 TubeRack 自己创建 Tube 对象 - super().__init__(name, size_x, size_y, size_z, - ordering=ordering_param, - model=model, - **kwargs) + super().__init__(name, size_x, size_y, size_z, ordering=ordering_param, model=model, **kwargs) else: - super().__init__(name, size_x, size_y, size_z, - model=model, - **kwargs) - + super().__init__(name, size_x, size_y, size_z, model=model, **kwargs) + self._unilabos_state = {} if material_info: self._unilabos_state["Material"] = material_info @@ -394,7 +431,7 @@ def serialize_state(self) -> Dict[str, Dict[str, Any]]: data = super().serialize_state() except AttributeError: data = {} - if hasattr(self, '_unilabos_state') and self._unilabos_state: + if hasattr(self, "_unilabos_state") and self._unilabos_state: safe_state = {} for k, v in self._unilabos_state.items(): # 如果是 Material 字典,深入检查 @@ -407,33 +444,41 @@ def serialize_state(self) -> Dict[str, Dict[str, Any]]: else: # 打印日志提醒(可选) # print(f"Warning: Removing non-serializable key {mk} from {self.name}") - pass + pass safe_state[k] = safe_material # 其他顶层属性也进行类型检查 elif isinstance(v, (str, int, float, bool, list, dict, type(None))): safe_state[k] = v - + data.update(safe_state) return data + class PRCXI9300PlateAdapter(PlateAdapter): """ 专用板式适配器类:用于承载 Plate 的底座(如 PCR 适配器、磁吸架等)。 支持注入 material_info (UUID)。 """ - def __init__(self, name: str, size_x: float, size_y: float, size_z: float, - category: str = "plate_adapter", - model: Optional[str] = None, - material_info: Optional[Dict[str, Any]] = None, - # 参数给予默认值 (标准96孔板尺寸) - adapter_hole_size_x: float = 127.76, - adapter_hole_size_y: float = 85.48, - adapter_hole_size_z: float = 10.0, # 假设凹槽深度或板子放置高度 - dx: Optional[float] = None, - dy: Optional[float] = None, - dz: float = 0.0, # 默认Z轴偏移 - **kwargs): - + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + category: str = "plate_adapter", + model: Optional[str] = None, + material_info: Optional[Dict[str, Any]] = None, + # 参数给予默认值 (标准96孔板尺寸) + adapter_hole_size_x: float = 127.76, + adapter_hole_size_y: float = 85.48, + adapter_hole_size_z: float = 10.0, # 假设凹槽深度或板子放置高度 + dx: Optional[float] = None, + dy: Optional[float] = None, + dz: float = 0.0, # 默认Z轴偏移 + **kwargs, + ): + # 自动居中计算:如果未指定 dx/dy,则根据适配器尺寸和孔尺寸计算居中位置 if dx is None: dx = (size_x - adapter_hole_size_x) / 2 @@ -441,20 +486,20 @@ def __init__(self, name: str, size_x: float, size_y: float, size_z: float, dy = (size_y - adapter_hole_size_y) / 2 super().__init__( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, dx=dx, dy=dy, dz=dz, adapter_hole_size_x=adapter_hole_size_x, adapter_hole_size_y=adapter_hole_size_y, adapter_hole_size_z=adapter_hole_size_z, - model=model, - **kwargs + model=model, + **kwargs, ) - + self._unilabos_state = {} if material_info: self._unilabos_state["Material"] = material_info @@ -464,7 +509,7 @@ def serialize_state(self) -> Dict[str, Dict[str, Any]]: data = super().serialize_state() except AttributeError: data = {} - if hasattr(self, '_unilabos_state') and self._unilabos_state: + if hasattr(self, "_unilabos_state") and self._unilabos_state: safe_state = {} for k, v in self._unilabos_state.items(): # 如果是 Material 字典,深入检查 @@ -477,15 +522,16 @@ def serialize_state(self) -> Dict[str, Dict[str, Any]]: else: # 打印日志提醒(可选) # print(f"Warning: Removing non-serializable key {mk} from {self.name}") - pass + pass safe_state[k] = safe_material # 其他顶层属性也进行类型检查 elif isinstance(v, (str, int, float, bool, list, dict, type(None))): safe_state[k] = v - + data.update(safe_state) return data + class PRCXI9300Handler(LiquidHandlerAbstract): support_touch_tip = False @@ -518,7 +564,9 @@ def __init__( if "Material" in child.children[0]._unilabos_state: number = int(child.name.replace("T", "")) tablets_info.append( - WorkTablets(Number=number, Code=f"T{number}", Material=child.children[0]._unilabos_state["Material"]) + WorkTablets( + Number=number, Code=f"T{number}", Material=child.children[0]._unilabos_state["Material"] + ) ) if is_9320: print("当前设备是9320") @@ -538,9 +586,14 @@ def post_init(self, ros_node: BaseROS2DeviceNode): super().post_init(ros_node) self._unilabos_backend.post_init(ros_node) - def set_liquid(self, wells: list[Well], liquid_names: list[str], volumes: list[float]) -> SimpleReturn: + def set_liquid(self, wells: list[Well], liquid_names: list[str], volumes: list[float]) -> SetLiquidReturn: return super().set_liquid(wells, liquid_names, volumes) + def set_liquid_from_plate( + self, plate: ResourceSlot, well_names: list[str], liquid_names: list[str], volumes: list[float] + ) -> SetLiquidFromPlateReturn: + return super().set_liquid_from_plate(plate, well_names, liquid_names, volumes) + def set_group(self, group_name: str, wells: List[Well], volumes: List[float]): return super().set_group(group_name, wells, volumes) @@ -799,7 +852,8 @@ async def shaker_action(self, time: int, module_no: int, amplitude: int, is_wait return await self._unilabos_backend.shaker_action(time, module_no, amplitude, is_wait) async def heater_action(self, temperature: float, time: int): - return await self._unilabos_backend.heater_action(temperature, time) + return await self._unilabos_backend.heater_action(temperature, time) + async def move_plate( self, plate: Plate, @@ -822,10 +876,11 @@ async def move_plate( drop_direction, pickup_direction, pickup_distance_from_top, - target_plate_number = to, + target_plate_number=to, **backend_kwargs, ) + class PRCXI9300Backend(LiquidHandlerBackend): """PRCXI 9300 的后端实现,继承自 LiquidHandlerBackend。 @@ -878,31 +933,28 @@ async def shaker_action(self, time: int, module_no: int, amplitude: int, is_wait self.steps_todo_list.append(step) return step - async def pick_up_resource(self, pickup: ResourcePickup, **backend_kwargs): - - resource=pickup.resource - offset=pickup.offset - pickup_distance_from_top=pickup.pickup_distance_from_top - direction=pickup.direction + + resource = pickup.resource + offset = pickup.offset + pickup_distance_from_top = pickup.pickup_distance_from_top + direction = pickup.direction plate_number = int(resource.parent.name.replace("T", "")) is_whole_plate = True balance_height = 0 step = self.api_client.clamp_jaw_pick_up(plate_number, is_whole_plate, balance_height) - + self.steps_todo_list.append(step) return step async def drop_resource(self, drop: ResourceDrop, **backend_kwargs): - plate_number = None target_plate_number = backend_kwargs.get("target_plate_number", None) if target_plate_number is not None: plate_number = int(target_plate_number.name.replace("T", "")) - is_whole_plate = True balance_height = 0 if plate_number is None: @@ -911,7 +963,6 @@ async def drop_resource(self, drop: ResourceDrop, **backend_kwargs): self.steps_todo_list.append(step) return step - async def heater_action(self, temperature: float, time: int): print(f"\n\nHeater action: temperature={temperature}, time={time}\n\n") # return await self.api_client.heater_action(temperature, time) @@ -968,7 +1019,7 @@ async def setup(self): error_code = self.api_client.get_error_code() if error_code: print(f"PRCXI9300 error code detected: {error_code}") - + # 清除错误代码 self.api_client.clear_error_code() print("PRCXI9300 error code cleared.") @@ -976,11 +1027,11 @@ async def setup(self): # 执行重置 print("Starting PRCXI9300 reset...") self.api_client.call("IAutomation", "Reset") - + # 检查重置状态并等待完成 while not self.is_reset_ok: print("Waiting for PRCXI9300 to reset...") - if hasattr(self, '_ros_node') and self._ros_node is not None: + if hasattr(self, "_ros_node") and self._ros_node is not None: await self._ros_node.sleep(1) else: await asyncio.sleep(1) @@ -998,7 +1049,7 @@ async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int] = None): """Pick up tips from the specified resource.""" # INSERT_YOUR_CODE # Ensure use_channels is converted to a list of ints if it's an array - if hasattr(use_channels, 'tolist'): + if hasattr(use_channels, "tolist"): _use_channels = use_channels.tolist() else: _use_channels = list(use_channels) if use_channels is not None else None @@ -1052,7 +1103,7 @@ async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int] = None): async def drop_tips(self, ops: List[Drop], use_channels: List[int] = None): """Pick up tips from the specified resource.""" - if hasattr(use_channels, 'tolist'): + if hasattr(use_channels, "tolist"): _use_channels = use_channels.tolist() else: _use_channels = list(use_channels) if use_channels is not None else None @@ -1135,7 +1186,7 @@ async def mix( none_keys: List[str] = [], ): """Mix liquid in the specified resources.""" - + plate_indexes = [] for op in targets: deck = op.parent.parent.parent @@ -1178,7 +1229,7 @@ async def mix( async def aspirate(self, ops: List[SingleChannelAspiration], use_channels: List[int] = None): """Aspirate liquid from the specified resources.""" - if hasattr(use_channels, 'tolist'): + if hasattr(use_channels, "tolist"): _use_channels = use_channels.tolist() else: _use_channels = list(use_channels) if use_channels is not None else None @@ -1235,7 +1286,7 @@ async def aspirate(self, ops: List[SingleChannelAspiration], use_channels: List[ async def dispense(self, ops: List[SingleChannelDispense], use_channels: List[int] = None): """Dispense liquid into the specified resources.""" - if hasattr(use_channels, 'tolist'): + if hasattr(use_channels, "tolist"): _use_channels = use_channels.tolist() else: _use_channels = list(use_channels) if use_channels is not None else None @@ -1416,7 +1467,6 @@ def wait_for_finish(self) -> bool: time.sleep(1) return success - def call(self, service: str, method: str, params: Optional[list] = None) -> Any: payload = json.dumps( {"ServiceName": service, "MethodName": method, "Paramters": params or []}, separators=(",", ":") @@ -1543,7 +1593,7 @@ def Imbibing( assist_fun5: str = "", liquid_method: str = "NormalDispense", axis: str = "Left", - ) -> Dict[str, Any]: + ) -> Dict[str, Any]: return { "StepAxis": axis, "Function": "Imbibing", @@ -1621,7 +1671,7 @@ def Blending( assist_fun5: str = "", liquid_method: str = "NormalDispense", axis: str = "Left", - ) -> Dict[str, Any]: + ) -> Dict[str, Any]: return { "StepAxis": axis, "Function": "Blending", @@ -1681,11 +1731,11 @@ def UnLoad( "LiquidDispensingMethod": liquid_method, } - def clamp_jaw_pick_up(self, + def clamp_jaw_pick_up( + self, plate_no: int, is_whole_plate: bool, balance_height: int, - ) -> Dict[str, Any]: return { "StepAxis": "ClampingJaw", @@ -1695,7 +1745,7 @@ def clamp_jaw_pick_up(self, "HoleRow": 1, "HoleCol": 1, "BalanceHeight": balance_height, - "PlateOrHoleNum": f"T{plate_no}" + "PlateOrHoleNum": f"T{plate_no}", } def clamp_jaw_drop( @@ -1703,7 +1753,6 @@ def clamp_jaw_drop( plate_no: int, is_whole_plate: bool, balance_height: int, - ) -> Dict[str, Any]: return { "StepAxis": "ClampingJaw", @@ -1713,7 +1762,7 @@ def clamp_jaw_drop( "HoleRow": 1, "HoleCol": 1, "BalanceHeight": balance_height, - "PlateOrHoleNum": f"T{plate_no}" + "PlateOrHoleNum": f"T{plate_no}", } def shaker_action(self, time: int, module_no: int, amplitude: int, is_wait: bool): @@ -1726,6 +1775,7 @@ def shaker_action(self, time: int, module_no: int, amplitude: int, is_wait: bool "AssistFun4": is_wait, } + class DefaultLayout: def __init__(self, product_name: str = "PRCXI9300"): @@ -2104,7 +2154,9 @@ def get_tip_rack(name: str, child_prefix: str = "tip") -> PRCXI9300TipRack: size_y=50, size_z=10, category="tip_rack", - ordered_items=collections.OrderedDict({k: f"{child_prefix}_{k}" for k, v in tip_racks["ordering"].items()}), + ordered_items=collections.OrderedDict( + {k: f"{child_prefix}_{k}" for k, v in tip_racks["ordering"].items()} + ), ) tip_rack_serialized = tip_rack.serialize() tip_rack_serialized["parent_name"] = deck.name @@ -2299,43 +2351,37 @@ def get_tip_rack(name: str, child_prefix: str = "tip") -> PRCXI9300TipRack: A = tree_to_list([resource_plr_to_ulab(deck)]) with open("deck.json", "w", encoding="utf-8") as f: - A.insert(0, { - "id": "PRCXI", - "name": "PRCXI", - "parent": None, - "type": "device", - "class": "liquid_handler.prcxi", - "position": { - "x": 0, - "y": 0, - "z": 0 - }, - "config": { - "deck": { - "_resource_child_name": "PRCXI_Deck", - "_resource_type": "unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Deck" + A.insert( + 0, + { + "id": "PRCXI", + "name": "PRCXI", + "parent": None, + "type": "device", + "class": "liquid_handler.prcxi", + "position": {"x": 0, "y": 0, "z": 0}, + "config": { + "deck": { + "_resource_child_name": "PRCXI_Deck", + "_resource_type": "unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Deck", + }, + "host": "192.168.0.121", + "port": 9999, + "timeout": 10.0, + "axis": "Right", + "channel_num": 1, + "setup": False, + "debug": True, + "simulator": True, + "matrix_id": "5de524d0-3f95-406c-86dd-f83626ebc7cb", + "is_9320": True, }, - "host": "192.168.0.121", - "port": 9999, - "timeout": 10.0, - "axis": "Right", - "channel_num": 1, - "setup": False, - "debug": True, - "simulator": True, - "matrix_id": "5de524d0-3f95-406c-86dd-f83626ebc7cb", - "is_9320": True + "data": {}, + "children": ["PRCXI_Deck"], }, - "data": {}, - "children": [ - "PRCXI_Deck" - ] - }) + ) A[1]["parent"] = "PRCXI" - json.dump({ - "nodes": A, - "links": [] - }, f, indent=4, ensure_ascii=False) + json.dump({"nodes": A, "links": []}, f, indent=4, ensure_ascii=False) handler = PRCXI9300Handler( deck=deck, @@ -2377,7 +2423,6 @@ def get_tip_rack(name: str, child_prefix: str = "tip") -> PRCXI9300TipRack: time.sleep(5) os._exit(0) - prcxi_api = PRCXI9300Api(host="192.168.0.121", port=9999) prcxi_api.list_matrices() prcxi_api.get_all_materials() diff --git a/unilabos/devices/neware_battery_test_system/__init__.py b/unilabos/devices/neware_battery_test_system/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/unilabos/devices/virtual/workbench.py b/unilabos/devices/virtual/workbench.py new file mode 100644 index 000000000..7a8e14540 --- /dev/null +++ b/unilabos/devices/virtual/workbench.py @@ -0,0 +1,687 @@ +""" +Virtual Workbench Device - 模拟工作台设备 +包含: +- 1个机械臂 (每次操作3s, 独占锁) +- 3个加热台 (每次加热10s, 可并行) + +工作流程: +1. A1-A5 物料同时启动,竞争机械臂 +2. 机械臂将物料移动到空闲加热台 +3. 加热完成后,机械臂将物料移动到C1-C5 + +注意:调用来自线程池,使用 threading.Lock 进行同步 +""" +import logging +import time +from typing import Dict, Any, Optional +from dataclasses import dataclass +from enum import Enum +from threading import Lock, RLock + +from typing_extensions import TypedDict + +from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode +from unilabos.utils.decorator import not_action + + +# ============ TypedDict 返回类型定义 ============ + +class MoveToHeatingStationResult(TypedDict): + """move_to_heating_station 返回类型""" + success: bool + station_id: int + material_id: str + material_number: int + message: str + + +class StartHeatingResult(TypedDict): + """start_heating 返回类型""" + success: bool + station_id: int + material_id: str + material_number: int + message: str + + +class MoveToOutputResult(TypedDict): + """move_to_output 返回类型""" + success: bool + station_id: int + material_id: str + + +class PrepareMaterialsResult(TypedDict): + """prepare_materials 返回类型 - 批量准备物料""" + success: bool + count: int + material_1: int # 物料编号1 + material_2: int # 物料编号2 + material_3: int # 物料编号3 + material_4: int # 物料编号4 + material_5: int # 物料编号5 + message: str + + +# ============ 状态枚举 ============ + +class HeatingStationState(Enum): + """加热台状态枚举""" + IDLE = "idle" # 空闲 + OCCUPIED = "occupied" # 已放置物料,等待加热 + HEATING = "heating" # 加热中 + COMPLETED = "completed" # 加热完成,等待取走 + + +class ArmState(Enum): + """机械臂状态枚举""" + IDLE = "idle" # 空闲 + BUSY = "busy" # 工作中 + + +@dataclass +class HeatingStation: + """加热台数据结构""" + station_id: int + state: HeatingStationState = HeatingStationState.IDLE + current_material: Optional[str] = None # 当前物料 (如 "A1", "A2") + material_number: Optional[int] = None # 物料编号 (1-5) + heating_start_time: Optional[float] = None + heating_progress: float = 0.0 + + +class VirtualWorkbench: + """ + Virtual Workbench Device - 虚拟工作台设备 + + 模拟一个包含1个机械臂和3个加热台的工作站 + - 机械臂操作耗时3秒,同一时间只能执行一个操作 + - 加热台加热耗时10秒,3个加热台可并行工作 + + 工作流: + 1. 物料A1-A5并发启动(线程池),竞争机械臂使用权 + 2. 获取机械臂后,查找空闲加热台 + 3. 机械臂将物料放入加热台,开始加热 + 4. 加热完成后,机械臂将物料移动到目标位置Cn + """ + + _ros_node: BaseROS2DeviceNode + + # 配置常量 + ARM_OPERATION_TIME: float = 3.0 # 机械臂操作时间(秒) + HEATING_TIME: float = 10.0 # 加热时间(秒) + NUM_HEATING_STATIONS: int = 3 # 加热台数量 + + def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs): + # 处理可能的不同调用方式 + if device_id is None and "id" in kwargs: + device_id = kwargs.pop("id") + if config is None and "config" in kwargs: + config = kwargs.pop("config") + + self.device_id = device_id or "virtual_workbench" + self.config = config or {} + + self.logger = logging.getLogger(f"VirtualWorkbench.{self.device_id}") + self.data: Dict[str, Any] = {} + + # 从config中获取可配置参数 + self.ARM_OPERATION_TIME = float(self.config.get("arm_operation_time", 3.0)) + self.HEATING_TIME = float(self.config.get("heating_time", 10.0)) + self.NUM_HEATING_STATIONS = int(self.config.get("num_heating_stations", 3)) + + # 机械臂状态和锁 (使用threading.Lock) + self._arm_lock = Lock() + self._arm_state = ArmState.IDLE + self._arm_current_task: Optional[str] = None + + # 加热台状态 (station_id -> HeatingStation) - 立即初始化,不依赖initialize() + self._heating_stations: Dict[int, HeatingStation] = { + i: HeatingStation(station_id=i) + for i in range(1, self.NUM_HEATING_STATIONS + 1) + } + self._stations_lock = RLock() # 可重入锁,保护加热台状态 + + # 任务追踪 + self._active_tasks: Dict[str, Dict[str, Any]] = {} # material_id -> task_info + self._tasks_lock = Lock() + + # 处理其他kwargs参数 + skip_keys = {"arm_operation_time", "heating_time", "num_heating_stations"} + for key, value in kwargs.items(): + if key not in skip_keys and not hasattr(self, key): + setattr(self, key, value) + + self.logger.info(f"=== 虚拟工作台 {self.device_id} 已创建 ===") + self.logger.info( + f"机械臂操作时间: {self.ARM_OPERATION_TIME}s | " + f"加热时间: {self.HEATING_TIME}s | " + f"加热台数量: {self.NUM_HEATING_STATIONS}" + ) + + @not_action + def post_init(self, ros_node: BaseROS2DeviceNode): + """ROS节点初始化后回调""" + self._ros_node = ros_node + + @not_action + def initialize(self) -> bool: + """初始化虚拟工作台""" + self.logger.info(f"初始化虚拟工作台 {self.device_id}") + + # 重置加热台状态 (已在__init__中创建,这里重置为初始状态) + with self._stations_lock: + for station in self._heating_stations.values(): + station.state = HeatingStationState.IDLE + station.current_material = None + station.material_number = None + station.heating_progress = 0.0 + + # 初始化状态 + self.data.update({ + "status": "Ready", + "arm_state": ArmState.IDLE.value, + "arm_current_task": None, + "heating_stations": self._get_stations_status(), + "active_tasks_count": 0, + "message": "工作台就绪", + }) + + self.logger.info(f"工作台初始化完成: {self.NUM_HEATING_STATIONS}个加热台就绪") + return True + + @not_action + def cleanup(self) -> bool: + """清理虚拟工作台""" + self.logger.info(f"清理虚拟工作台 {self.device_id}") + + self._arm_state = ArmState.IDLE + self._arm_current_task = None + + with self._stations_lock: + self._heating_stations.clear() + + with self._tasks_lock: + self._active_tasks.clear() + + self.data.update({ + "status": "Offline", + "arm_state": ArmState.IDLE.value, + "heating_stations": {}, + "message": "工作台已关闭", + }) + return True + + def _get_stations_status(self) -> Dict[int, Dict[str, Any]]: + """获取所有加热台状态""" + with self._stations_lock: + return { + station_id: { + "state": station.state.value, + "current_material": station.current_material, + "material_number": station.material_number, + "heating_progress": station.heating_progress, + } + for station_id, station in self._heating_stations.items() + } + + def _update_data_status(self, message: Optional[str] = None): + """更新状态数据""" + self.data.update({ + "arm_state": self._arm_state.value, + "arm_current_task": self._arm_current_task, + "heating_stations": self._get_stations_status(), + "active_tasks_count": len(self._active_tasks), + }) + if message: + self.data["message"] = message + + def _find_available_heating_station(self) -> Optional[int]: + """查找空闲的加热台 + + Returns: + 空闲加热台ID,如果没有则返回None + """ + with self._stations_lock: + for station_id, station in self._heating_stations.items(): + if station.state == HeatingStationState.IDLE: + return station_id + return None + + def _acquire_arm(self, task_description: str) -> bool: + """获取机械臂使用权(阻塞直到获取) + + Args: + task_description: 任务描述,用于日志 + + Returns: + 是否成功获取 + """ + self.logger.info(f"[{task_description}] 等待获取机械臂...") + + # 阻塞等待获取锁 + self._arm_lock.acquire() + + self._arm_state = ArmState.BUSY + self._arm_current_task = task_description + self._update_data_status(f"机械臂执行: {task_description}") + + self.logger.info(f"[{task_description}] 成功获取机械臂使用权") + return True + + def _release_arm(self): + """释放机械臂""" + task = self._arm_current_task + self._arm_state = ArmState.IDLE + self._arm_current_task = None + self._arm_lock.release() + self._update_data_status(f"机械臂已释放 (完成: {task})") + self.logger.info(f"机械臂已释放 (完成: {task})") + + def prepare_materials( + self, + count: int = 5, + ) -> PrepareMaterialsResult: + """ + 批量准备物料 - 虚拟起始节点 + + 作为工作流的起始节点,生成指定数量的物料编号供后续节点使用。 + 输出5个handle (material_1 ~ material_5),分别对应实验1~5。 + + Args: + count: 待生成的物料数量,默认5 (生成 A1-A5) + + Returns: + PrepareMaterialsResult: 包含 material_1 ~ material_5 用于传递给 move_to_heating_station + """ + # 生成物料列表 A1 - A{count} + materials = [i for i in range(1, count + 1)] + + self.logger.info( + f"[准备物料] 生成 {count} 个物料: " + f"A1-A{count} -> material_1~material_{count}" + ) + + return { + "success": True, + "count": count, + "material_1": materials[0] if len(materials) > 0 else 0, + "material_2": materials[1] if len(materials) > 1 else 0, + "material_3": materials[2] if len(materials) > 2 else 0, + "material_4": materials[3] if len(materials) > 3 else 0, + "material_5": materials[4] if len(materials) > 4 else 0, + "message": f"已准备 {count} 个物料: A1-A{count}", + } + + def move_to_heating_station( + self, + material_number: int, + ) -> MoveToHeatingStationResult: + """ + 将物料从An位置移动到加热台 + + 多线程并发调用时,会竞争机械臂使用权,并自动查找空闲加热台 + + Args: + material_number: 物料编号 (1-5) + + Returns: + MoveToHeatingStationResult: 包含 station_id, material_number 等用于传递给下一个节点 + """ + # 根据物料编号生成物料ID + material_id = f"A{material_number}" + task_desc = f"移动{material_id}到加热台" + self.logger.info(f"[任务] {task_desc} - 开始执行") + + # 记录任务 + with self._tasks_lock: + self._active_tasks[material_id] = { + "status": "waiting_for_arm", + "start_time": time.time(), + } + + try: + # 步骤1: 等待获取机械臂使用权(竞争) + with self._tasks_lock: + self._active_tasks[material_id]["status"] = "waiting_for_arm" + self._acquire_arm(task_desc) + + # 步骤2: 查找空闲加热台 + with self._tasks_lock: + self._active_tasks[material_id]["status"] = "finding_station" + station_id = None + + # 循环等待直到找到空闲加热台 + while station_id is None: + station_id = self._find_available_heating_station() + if station_id is None: + self.logger.info(f"[{material_id}] 没有空闲加热台,等待中...") + # 释放机械臂,等待后重试 + self._release_arm() + time.sleep(0.5) + self._acquire_arm(task_desc) + + # 步骤3: 占用加热台 - 立即标记为OCCUPIED,防止其他任务选择同一加热台 + with self._stations_lock: + self._heating_stations[station_id].state = HeatingStationState.OCCUPIED + self._heating_stations[station_id].current_material = material_id + self._heating_stations[station_id].material_number = material_number + + # 步骤4: 模拟机械臂移动操作 (3秒) + with self._tasks_lock: + self._active_tasks[material_id]["status"] = "arm_moving" + self._active_tasks[material_id]["assigned_station"] = station_id + self.logger.info(f"[{material_id}] 机械臂正在移动到加热台{station_id}...") + + time.sleep(self.ARM_OPERATION_TIME) + + # 步骤5: 放入加热台完成 + self._update_data_status(f"{material_id}已放入加热台{station_id}") + self.logger.info(f"[{material_id}] 已放入加热台{station_id} (用时{self.ARM_OPERATION_TIME}s)") + + # 释放机械臂 + self._release_arm() + + with self._tasks_lock: + self._active_tasks[material_id]["status"] = "placed_on_station" + + return { + "success": True, + "station_id": station_id, + "material_id": material_id, + "material_number": material_number, + "message": f"{material_id}已成功移动到加热台{station_id}", + } + + except Exception as e: + self.logger.error(f"[{material_id}] 移动失败: {str(e)}") + if self._arm_lock.locked(): + self._release_arm() + return { + "success": False, + "station_id": -1, + "material_id": material_id, + "material_number": material_number, + "message": f"移动失败: {str(e)}", + } + + def start_heating( + self, + station_id: int, + material_number: int, + ) -> StartHeatingResult: + """ + 启动指定加热台的加热程序 + + Args: + station_id: 加热台ID (1-3),从 move_to_heating_station 的 handle 传入 + material_number: 物料编号,从 move_to_heating_station 的 handle 传入 + + Returns: + StartHeatingResult: 包含 station_id, material_number 等用于传递给下一个节点 + """ + self.logger.info(f"[加热台{station_id}] 开始加热") + + if station_id not in self._heating_stations: + return { + "success": False, + "station_id": station_id, + "material_id": "", + "material_number": material_number, + "message": f"无效的加热台ID: {station_id}", + } + + with self._stations_lock: + station = self._heating_stations[station_id] + + if station.current_material is None: + return { + "success": False, + "station_id": station_id, + "material_id": "", + "material_number": material_number, + "message": f"加热台{station_id}上没有物料", + } + + if station.state == HeatingStationState.HEATING: + return { + "success": False, + "station_id": station_id, + "material_id": station.current_material, + "material_number": material_number, + "message": f"加热台{station_id}已经在加热中", + } + + material_id = station.current_material + + # 开始加热 + station.state = HeatingStationState.HEATING + station.heating_start_time = time.time() + station.heating_progress = 0.0 + + with self._tasks_lock: + if material_id in self._active_tasks: + self._active_tasks[material_id]["status"] = "heating" + + self._update_data_status(f"加热台{station_id}开始加热{material_id}") + + # 模拟加热过程 (10秒) + start_time = time.time() + while True: + elapsed = time.time() - start_time + progress = min(100.0, (elapsed / self.HEATING_TIME) * 100) + + with self._stations_lock: + self._heating_stations[station_id].heating_progress = progress + + self._update_data_status(f"加热台{station_id}加热中: {progress:.1f}%") + + if elapsed >= self.HEATING_TIME: + break + + time.sleep(1.0) + + # 加热完成 + with self._stations_lock: + self._heating_stations[station_id].state = HeatingStationState.COMPLETED + self._heating_stations[station_id].heating_progress = 100.0 + + with self._tasks_lock: + if material_id in self._active_tasks: + self._active_tasks[material_id]["status"] = "heating_completed" + + self._update_data_status(f"加热台{station_id}加热完成") + self.logger.info(f"[加热台{station_id}] {material_id}加热完成 (用时{self.HEATING_TIME}s)") + + return { + "success": True, + "station_id": station_id, + "material_id": material_id, + "material_number": material_number, + "message": f"加热台{station_id}加热完成", + } + + def move_to_output( + self, + station_id: int, + material_number: int, + ) -> MoveToOutputResult: + """ + 将物料从加热台移动到输出位置Cn + + Args: + station_id: 加热台ID (1-3),从 start_heating 的 handle 传入 + material_number: 物料编号,从 start_heating 的 handle 传入,用于确定输出位置 Cn + + Returns: + MoveToOutputResult: 包含执行结果 + """ + output_number = material_number # 物料编号决定输出位置 + + if station_id not in self._heating_stations: + return { + "success": False, + "station_id": station_id, + "material_id": "", + "output_position": f"C{output_number}", + "message": f"无效的加热台ID: {station_id}", + } + + with self._stations_lock: + station = self._heating_stations[station_id] + material_id = station.current_material + + if material_id is None: + return { + "success": False, + "station_id": station_id, + "material_id": "", + "output_position": f"C{output_number}", + "message": f"加热台{station_id}上没有物料", + } + + if station.state != HeatingStationState.COMPLETED: + return { + "success": False, + "station_id": station_id, + "material_id": material_id, + "output_position": f"C{output_number}", + "message": f"加热台{station_id}尚未完成加热 (当前状态: {station.state.value})", + } + + output_position = f"C{output_number}" + task_desc = f"从加热台{station_id}移动{material_id}到{output_position}" + self.logger.info(f"[任务] {task_desc}") + + try: + with self._tasks_lock: + if material_id in self._active_tasks: + self._active_tasks[material_id]["status"] = "waiting_for_arm_output" + + # 获取机械臂 + self._acquire_arm(task_desc) + + with self._tasks_lock: + if material_id in self._active_tasks: + self._active_tasks[material_id]["status"] = "arm_moving_to_output" + + # 模拟机械臂操作 (3秒) + self.logger.info(f"[{material_id}] 机械臂正在从加热台{station_id}取出并移动到{output_position}...") + time.sleep(self.ARM_OPERATION_TIME) + + # 清空加热台 + with self._stations_lock: + self._heating_stations[station_id].state = HeatingStationState.IDLE + self._heating_stations[station_id].current_material = None + self._heating_stations[station_id].material_number = None + self._heating_stations[station_id].heating_progress = 0.0 + self._heating_stations[station_id].heating_start_time = None + + # 释放机械臂 + self._release_arm() + + # 任务完成 + with self._tasks_lock: + if material_id in self._active_tasks: + self._active_tasks[material_id]["status"] = "completed" + self._active_tasks[material_id]["end_time"] = time.time() + + self._update_data_status(f"{material_id}已移动到{output_position}") + self.logger.info(f"[{material_id}] 已成功移动到{output_position} (用时{self.ARM_OPERATION_TIME}s)") + + return { + "success": True, + "station_id": station_id, + "material_id": material_id, + "output_position": output_position, + "message": f"{material_id}已成功移动到{output_position}", + } + + except Exception as e: + self.logger.error(f"移动到输出位置失败: {str(e)}") + if self._arm_lock.locked(): + self._release_arm() + return { + "success": False, + "station_id": station_id, + "material_id": "", + "output_position": output_position, + "message": f"移动失败: {str(e)}", + } + + # ============ 状态属性 ============ + + @property + def status(self) -> str: + return self.data.get("status", "Unknown") + + @property + def arm_state(self) -> str: + return self._arm_state.value + + @property + def arm_current_task(self) -> str: + return self._arm_current_task or "" + + @property + def heating_station_1_state(self) -> str: + with self._stations_lock: + station = self._heating_stations.get(1) + return station.state.value if station else "unknown" + + @property + def heating_station_1_material(self) -> str: + with self._stations_lock: + station = self._heating_stations.get(1) + return station.current_material or "" if station else "" + + @property + def heating_station_1_progress(self) -> float: + with self._stations_lock: + station = self._heating_stations.get(1) + return station.heating_progress if station else 0.0 + + @property + def heating_station_2_state(self) -> str: + with self._stations_lock: + station = self._heating_stations.get(2) + return station.state.value if station else "unknown" + + @property + def heating_station_2_material(self) -> str: + with self._stations_lock: + station = self._heating_stations.get(2) + return station.current_material or "" if station else "" + + @property + def heating_station_2_progress(self) -> float: + with self._stations_lock: + station = self._heating_stations.get(2) + return station.heating_progress if station else 0.0 + + @property + def heating_station_3_state(self) -> str: + with self._stations_lock: + station = self._heating_stations.get(3) + return station.state.value if station else "unknown" + + @property + def heating_station_3_material(self) -> str: + with self._stations_lock: + station = self._heating_stations.get(3) + return station.current_material or "" if station else "" + + @property + def heating_station_3_progress(self) -> float: + with self._stations_lock: + station = self._heating_stations.get(3) + return station.heating_progress if station else 0.0 + + @property + def active_tasks_count(self) -> int: + with self._tasks_lock: + return len(self._active_tasks) + + @property + def message(self) -> str: + return self.data.get("message", "") diff --git a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/__init__.py b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/unilabos/devices/workstation/bioyond_studio/dispensing_station/__init__.py b/unilabos/devices/workstation/bioyond_studio/dispensing_station/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/unilabos/devices/workstation/bioyond_studio/reaction_station/__init__.py b/unilabos/devices/workstation/bioyond_studio/reaction_station/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/unilabos/devices/xrd_d7mate/__init__.py b/unilabos/devices/xrd_d7mate/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/unilabos/devices/zhida_hplc/__init__.py b/unilabos/devices/zhida_hplc/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/unilabos/registry/devices/liquid_handler.yaml b/unilabos/registry/devices/liquid_handler.yaml index 298eb701c..b2612e76c 100644 --- a/unilabos/registry/devices/liquid_handler.yaml +++ b/unilabos/registry/devices/liquid_handler.yaml @@ -9284,7 +9284,13 @@ liquid_handler.prcxi: data_source: handle data_type: resource handler_key: input_wells - label: InputWells + label: 待设定液体孔 + output: + - data_key: wells.@flatten + data_source: executor + data_type: resource + handler_key: output_wells + label: 已设定液体孔 placeholder_keys: wells: unilabos_resources result: {} @@ -9400,6 +9406,163 @@ liquid_handler.prcxi: title: LiquidHandlerSetLiquid type: object type: LiquidHandlerSetLiquid + set_liquid_from_plate: + feedback: {} + goal: {} + goal_default: + liquid_names: null + plate: null + volumes: null + well_names: null + handles: + input: + - data_key: plate + data_source: handle + data_type: resource + handler_key: input_plate + label: 待设定液体板 + output: + - data_key: plate.@flatten + data_source: executor + data_type: resource + handler_key: output_plate + label: 已设定液体板 + - data_key: wells.@flatten + data_source: executor + data_type: resource + handler_key: output_wells + label: 已设定液体孔 + - data_key: volumes + data_source: executor + data_type: number_array + handler_key: output_volumes + label: 各孔设定体积 + placeholder_keys: + plate: unilabos_resources + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + liquid_names: + items: + type: string + type: array + plate: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: plate + type: object + volumes: + items: + type: number + type: array + well_names: + items: + type: string + type: array + required: + - plate + - well_names + - liquid_names + - volumes + type: object + result: + properties: + plate: + items: {} + title: Plate + type: array + volumes: + items: + type: number + title: Volumes + type: array + wells: + items: {} + title: Wells + type: array + required: + - plate + - wells + - volumes + title: SetLiquidFromPlateReturn + type: object + required: + - goal + title: set_liquid_from_plate参数 + type: object + type: UniLabJsonCommand set_tiprack: feedback: {} goal: @@ -9745,21 +9908,21 @@ liquid_handler.prcxi: - 0 handles: input: - - data_key: liquid + - data_key: sources data_source: handle data_type: resource - handler_key: sources - label: sources - - data_key: liquid - data_source: executor + handler_key: sources_identifier + label: 待移动液体 + - data_key: targets + data_source: handle data_type: resource - handler_key: targets - label: targets - - data_key: liquid - data_source: executor + handler_key: targets_identifier + label: 转移目标 + - data_key: tip_rack + data_source: handle data_type: resource - handler_key: tip_rack - label: tip_rack + handler_key: tip_rack_identifier + label: 墙头盒 output: - data_key: liquid data_source: handle diff --git a/unilabos/registry/devices/virtual_device.yaml b/unilabos/registry/devices/virtual_device.yaml index 77ac5336e..c38655cac 100644 --- a/unilabos/registry/devices/virtual_device.yaml +++ b/unilabos/registry/devices/virtual_device.yaml @@ -5792,3 +5792,381 @@ virtual_vacuum_pump: - status type: object version: 1.0.0 +virtual_workbench: + category: + - virtual_device + class: + action_value_mappings: + auto-move_to_heating_station: + feedback: {} + goal: {} + goal_default: + material_number: null + handles: + input: + - data_key: material_number + data_source: handle + data_type: workbench_material + handler_key: material_input + label: 物料编号 + output: + - data_key: station_id + data_source: executor + data_type: workbench_station + handler_key: heating_station_output + label: 加热台ID + - data_key: material_number + data_source: executor + data_type: workbench_material + handler_key: material_number_output + label: 物料编号 + placeholder_keys: {} + result: {} + schema: + description: 将物料从An位置移动到空闲加热台,返回分配的加热台ID + properties: + feedback: {} + goal: + properties: + material_number: + description: 物料编号,1-5,物料ID自动生成为A{n} + type: integer + required: + - material_number + type: object + result: + description: move_to_heating_station 返回类型 + properties: + material_id: + title: Material Id + type: string + material_number: + title: Material Number + type: integer + message: + title: Message + type: string + station_id: + description: 分配的加热台ID + title: Station Id + type: integer + success: + title: Success + type: boolean + required: + - success + - station_id + - material_id + - material_number + - message + title: MoveToHeatingStationResult + type: object + required: + - goal + title: move_to_heating_station参数 + type: object + type: UniLabJsonCommand + auto-move_to_output: + feedback: {} + goal: {} + goal_default: + material_number: null + station_id: null + handles: + input: + - data_key: station_id + data_source: handle + data_type: workbench_station + handler_key: output_station_input + label: 加热台ID + - data_key: material_number + data_source: handle + data_type: workbench_material + handler_key: output_material_input + label: 物料编号 + placeholder_keys: {} + result: {} + schema: + description: 将物料从加热台移动到输出位置Cn + properties: + feedback: {} + goal: + properties: + material_number: + description: 物料编号,用于确定输出位置Cn + type: integer + station_id: + description: 加热台ID,1-3,从上一节点传入 + type: integer + required: + - station_id + - material_number + type: object + result: + description: move_to_output 返回类型 + properties: + material_id: + title: Material Id + type: string + station_id: + title: Station Id + type: integer + success: + title: Success + type: boolean + required: + - success + - station_id + - material_id + title: MoveToOutputResult + type: object + required: + - goal + title: move_to_output参数 + type: object + type: UniLabJsonCommand + auto-prepare_materials: + feedback: {} + goal: {} + goal_default: + count: 5 + handles: + output: + - data_key: material_1 + data_source: executor + data_type: workbench_material + handler_key: channel_1 + label: 实验1 + - data_key: material_2 + data_source: executor + data_type: workbench_material + handler_key: channel_2 + label: 实验2 + - data_key: material_3 + data_source: executor + data_type: workbench_material + handler_key: channel_3 + label: 实验3 + - data_key: material_4 + data_source: executor + data_type: workbench_material + handler_key: channel_4 + label: 实验4 + - data_key: material_5 + data_source: executor + data_type: workbench_material + handler_key: channel_5 + label: 实验5 + placeholder_keys: {} + result: {} + schema: + description: 批量准备物料 - 虚拟起始节点,生成A1-A5物料,输出5个handle供后续节点使用 + properties: + feedback: {} + goal: + properties: + count: + default: 5 + description: 待生成的物料数量,默认5 (生成 A1-A5) + type: integer + required: [] + type: object + result: + description: prepare_materials 返回类型 - 批量准备物料 + properties: + count: + title: Count + type: integer + material_1: + title: Material 1 + type: integer + material_2: + title: Material 2 + type: integer + material_3: + title: Material 3 + type: integer + material_4: + title: Material 4 + type: integer + material_5: + title: Material 5 + type: integer + message: + title: Message + type: string + success: + title: Success + type: boolean + required: + - success + - count + - material_1 + - material_2 + - material_3 + - material_4 + - material_5 + - message + title: PrepareMaterialsResult + type: object + required: + - goal + title: prepare_materials参数 + type: object + type: UniLabJsonCommand + auto-start_heating: + feedback: {} + goal: {} + goal_default: + material_number: null + station_id: null + handles: + input: + - data_key: station_id + data_source: handle + data_type: workbench_station + handler_key: station_id_input + label: 加热台ID + - data_key: material_number + data_source: handle + data_type: workbench_material + handler_key: material_number_input + label: 物料编号 + output: + - data_key: station_id + data_source: executor + data_type: workbench_station + handler_key: heating_done_station + label: 加热完成-加热台ID + - data_key: material_number + data_source: executor + data_type: workbench_material + handler_key: heating_done_material + label: 加热完成-物料编号 + placeholder_keys: {} + result: {} + schema: + description: 启动指定加热台的加热程序 + properties: + feedback: {} + goal: + properties: + material_number: + description: 物料编号,从上一节点传入 + type: integer + station_id: + description: 加热台ID,1-3,从上一节点传入 + type: integer + required: + - station_id + - material_number + type: object + result: + description: start_heating 返回类型 + properties: + material_id: + title: Material Id + type: string + material_number: + title: Material Number + type: integer + message: + title: Message + type: string + station_id: + title: Station Id + type: integer + success: + title: Success + type: boolean + required: + - success + - station_id + - material_id + - material_number + - message + title: StartHeatingResult + type: object + required: + - goal + title: start_heating参数 + type: object + type: UniLabJsonCommand + module: unilabos.devices.virtual.workbench:VirtualWorkbench + status_types: + active_tasks_count: int + arm_current_task: str + arm_state: str + heating_station_1_material: str + heating_station_1_progress: float + heating_station_1_state: str + heating_station_2_material: str + heating_station_2_progress: float + heating_station_2_state: str + heating_station_3_material: str + heating_station_3_progress: float + heating_station_3_state: str + message: str + status: str + type: python + config_info: [] + description: Virtual Workbench with 1 robotic arm and 3 heating stations for concurrent + material processing + handles: [] + icon: '' + init_param_schema: + config: + properties: + config: + type: string + device_id: + type: string + required: [] + type: object + data: + properties: + active_tasks_count: + type: integer + arm_current_task: + type: string + arm_state: + type: string + heating_station_1_material: + type: string + heating_station_1_progress: + type: number + heating_station_1_state: + type: string + heating_station_2_material: + type: string + heating_station_2_progress: + type: number + heating_station_2_state: + type: string + heating_station_3_material: + type: string + heating_station_3_progress: + type: number + heating_station_3_state: + type: string + message: + type: string + status: + type: string + required: + - status + - arm_state + - arm_current_task + - heating_station_1_state + - heating_station_1_material + - heating_station_1_progress + - heating_station_2_state + - heating_station_2_material + - heating_station_2_progress + - heating_station_3_state + - heating_station_3_material + - heating_station_3_progress + - active_tasks_count + - message + type: object + version: 1.0.0 diff --git a/unilabos/registry/registry.py b/unilabos/registry/registry.py index f09b79c1b..ef111e611 100644 --- a/unilabos/registry/registry.py +++ b/unilabos/registry/registry.py @@ -4,6 +4,8 @@ import sys import inspect import importlib +import threading +from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path from typing import Any, Dict, List, Union, Tuple @@ -60,6 +62,7 @@ def __init__(self, registry_paths=None): self.device_module_to_registry = {} self.resource_type_registry = {} self._setup_called = False # 跟踪setup是否已调用 + self._registry_lock = threading.Lock() # 多线程加载时的锁 # 其他状态变量 # self.is_host_mode = False # 移至BasicConfig中 @@ -71,6 +74,20 @@ def setup(self, complete_registry=False, upload_registry=False): from unilabos.app.web.utils.action_utils import get_yaml_from_goal_type + # 获取 HostNode 类的增强信息,用于自动生成 action schema + host_node_enhanced_info = get_enhanced_class_info( + "unilabos.ros.nodes.presets.host_node:HostNode", use_dynamic=True + ) + + # 为 test_latency 生成 schema,保留原有 description + test_latency_method_info = host_node_enhanced_info.get("action_methods", {}).get("test_latency", {}) + test_latency_schema = self._generate_unilab_json_command_schema( + test_latency_method_info.get("args", []), + "test_latency", + test_latency_method_info.get("return_annotation"), + ) + test_latency_schema["description"] = "用于测试延迟的动作,返回延迟时间和时间差。" + self.device_type_registry.update( { "host_node": { @@ -149,17 +166,22 @@ def setup(self, complete_registry=False, upload_registry=False): "res_id": "unilabos_resources", # 将当前实验室的全部物料id作为下拉框可选择 "device_id": "unilabos_devices", # 将当前实验室的全部设备id作为下拉框可选择 "parent": "unilabos_nodes", # 将当前实验室的设备/物料作为下拉框可选择 + "class_name": "unilabos_class", }, }, "test_latency": { - "type": self.EmptyIn, + "type": ( + "UniLabJsonCommandAsync" + if test_latency_method_info.get("is_async", False) + else "UniLabJsonCommand" + ), "goal": {}, "feedback": {}, "result": {}, - "schema": ros_action_to_json_schema( - self.EmptyIn, "用于测试延迟的动作,返回延迟时间和时间差。" - ), - "goal_default": {}, + "schema": test_latency_schema, + "goal_default": { + arg["name"]: arg["default"] for arg in test_latency_method_info.get("args", []) + }, "handles": {}, }, "auto-test_resource": { @@ -242,67 +264,115 @@ def setup(self, complete_registry=False, upload_registry=False): # 标记setup已被调用 self._setup_called = True + def _load_single_resource_file( + self, file: Path, complete_registry: bool, upload_registry: bool + ) -> Tuple[Dict[str, Any], Dict[str, Any], bool]: + """ + 加载单个资源文件 (线程安全) + + Returns: + (data, complete_data, is_valid): 资源数据, 完整数据, 是否有效 + """ + try: + with open(file, encoding="utf-8", mode="r") as f: + data = yaml.safe_load(io.StringIO(f.read())) + except Exception as e: + logger.warning(f"[UniLab Registry] 读取资源文件失败: {file}, 错误: {e}") + return {}, {}, False + + if not data: + return {}, {}, False + + complete_data = {} + for resource_id, resource_info in data.items(): + if "version" not in resource_info: + resource_info["version"] = "1.0.0" + if "category" not in resource_info: + resource_info["category"] = [file.stem] + elif file.stem not in resource_info["category"]: + resource_info["category"].append(file.stem) + elif not isinstance(resource_info.get("category"), list): + resource_info["category"] = [resource_info["category"]] + if "config_info" not in resource_info: + resource_info["config_info"] = [] + if "icon" not in resource_info: + resource_info["icon"] = "" + if "handles" not in resource_info: + resource_info["handles"] = [] + if "init_param_schema" not in resource_info: + resource_info["init_param_schema"] = {} + if "config_info" in resource_info: + del resource_info["config_info"] + if "file_path" in resource_info: + del resource_info["file_path"] + complete_data[resource_id] = copy.deepcopy(dict(sorted(resource_info.items()))) + if upload_registry: + class_info = resource_info.get("class", {}) + if len(class_info) and "module" in class_info: + if class_info.get("type") == "pylabrobot": + res_class = get_class(class_info["module"]) + if callable(res_class) and not isinstance(res_class, type): + res_instance = res_class(res_class.__name__) + res_ulr = tree_to_list([resource_plr_to_ulab(res_instance)]) + resource_info["config_info"] = res_ulr + resource_info["registry_type"] = "resource" + resource_info["file_path"] = str(file.absolute()).replace("\\", "/") + + complete_data = dict(sorted(complete_data.items())) + complete_data = copy.deepcopy(complete_data) + + if complete_registry: + try: + with open(file, "w", encoding="utf-8") as f: + yaml.dump(complete_data, f, allow_unicode=True, default_flow_style=False, Dumper=NoAliasDumper) + except Exception as e: + logger.warning(f"[UniLab Registry] 写入资源文件失败: {file}, 错误: {e}") + + return data, complete_data, True + def load_resource_types(self, path: os.PathLike, complete_registry: bool, upload_registry: bool): abs_path = Path(path).absolute() resource_path = abs_path / "resources" files = list(resource_path.glob("*/*.yaml")) - logger.trace(f"[UniLab Registry] load resources? {resource_path.exists()}, total: {len(files)}") - current_resource_number = len(self.resource_type_registry) + 1 - for i, file in enumerate(files): - with open(file, encoding="utf-8", mode="r") as f: - data = yaml.safe_load(io.StringIO(f.read())) - complete_data = {} - if data: - # 为每个资源添加文件路径信息 - for resource_id, resource_info in data.items(): - if "version" not in resource_info: - resource_info["version"] = "1.0.0" - if "category" not in resource_info: - resource_info["category"] = [file.stem] - elif file.stem not in resource_info["category"]: - resource_info["category"].append(file.stem) - elif not isinstance(resource_info.get("category"), list): - resource_info["category"] = [resource_info["category"]] - if "config_info" not in resource_info: - resource_info["config_info"] = [] - if "icon" not in resource_info: - resource_info["icon"] = "" - if "handles" not in resource_info: - resource_info["handles"] = [] - if "init_param_schema" not in resource_info: - resource_info["init_param_schema"] = {} - if "config_info" in resource_info: - del resource_info["config_info"] - if "file_path" in resource_info: - del resource_info["file_path"] - complete_data[resource_id] = copy.deepcopy(dict(sorted(resource_info.items()))) - if upload_registry: - class_info = resource_info.get("class", {}) - if len(class_info) and "module" in class_info: - if class_info.get("type") == "pylabrobot": - res_class = get_class(class_info["module"]) - if callable(res_class) and not isinstance( - res_class, type - ): # 有的是类,有的是函数,这里暂时只登记函数类的 - res_instance = res_class(res_class.__name__) - res_ulr = tree_to_list([resource_plr_to_ulab(res_instance)]) - resource_info["config_info"] = res_ulr - resource_info["registry_type"] = "resource" - resource_info["file_path"] = str(file.absolute()).replace("\\", "/") - complete_data = dict(sorted(complete_data.items())) - complete_data = copy.deepcopy(complete_data) - if complete_registry: - with open(file, "w", encoding="utf-8") as f: - yaml.dump(complete_data, f, allow_unicode=True, default_flow_style=False, Dumper=NoAliasDumper) + logger.debug(f"[UniLab Registry] resources: {resource_path.exists()}, total: {len(files)}") + if not files: + return + + # 使用线程池并行加载 + max_workers = min(8, len(files)) + results = [] + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + future_to_file = { + executor.submit(self._load_single_resource_file, file, complete_registry, upload_registry): file + for file in files + } + for future in as_completed(future_to_file): + file = future_to_file[future] + try: + data, complete_data, is_valid = future.result() + if is_valid: + results.append((file, data)) + except Exception as e: + logger.warning(f"[UniLab Registry] 处理资源文件异常: {file}, 错误: {e}") + + # 线程安全地更新注册表 + current_resource_number = len(self.resource_type_registry) + 1 + with self._registry_lock: + for i, (file, data) in enumerate(results): self.resource_type_registry.update(data) - logger.trace( # type: ignore - f"[UniLab Registry] Resource-{current_resource_number} File-{i+1}/{len(files)} " + logger.trace( + f"[UniLab Registry] Resource-{current_resource_number} File-{i+1}/{len(results)} " + f"Add {list(data.keys())}" ) current_resource_number += 1 - else: - logger.debug(f"[UniLab Registry] Res File-{i+1}/{len(files)} Not Valid YAML File: {file.absolute()}") + + # 记录无效文件 + valid_files = {r[0] for r in results} + for file in files: + if file not in valid_files: + logger.debug(f"[UniLab Registry] Res File Not Valid YAML File: {file.absolute()}") def _extract_class_docstrings(self, module_string: str) -> Dict[str, str]: """ @@ -540,11 +610,9 @@ def _generate_unilab_json_command_schema( return final_schema - def _preserve_field_descriptions( - self, new_schema: Dict[str, Any], previous_schema: Dict[str, Any] - ) -> None: + def _preserve_field_descriptions(self, new_schema: Dict[str, Any], previous_schema: Dict[str, Any]) -> None: """ - 保留之前 schema 中 goal/feedback/result 下一级字段的 description + 保留之前 schema 中 goal/feedback/result 下一级字段的 description 和 title Args: new_schema: 新生成的 schema(会被修改) @@ -566,6 +634,9 @@ def _preserve_field_descriptions( # 保留字段的 description if "description" in prev_field and prev_field["description"]: field_schema["description"] = prev_field["description"] + # 保留字段的 title(用户自定义的中文名) + if "title" in prev_field and prev_field["title"]: + field_schema["title"] = prev_field["title"] def _is_typed_dict(self, annotation: Any) -> bool: """ @@ -653,213 +724,244 @@ def _add_builtin_actions(self, device_config: Dict[str, Any], device_id: str): "handles": {}, } + def _load_single_device_file( + self, file: Path, complete_registry: bool, get_yaml_from_goal_type + ) -> Tuple[Dict[str, Any], Dict[str, Any], bool, List[str]]: + """ + 加载单个设备文件 (线程安全) + + Returns: + (data, complete_data, is_valid, device_ids): 设备数据, 完整数据, 是否有效, 设备ID列表 + """ + try: + with open(file, encoding="utf-8", mode="r") as f: + data = yaml.safe_load(io.StringIO(f.read())) + except Exception as e: + logger.warning(f"[UniLab Registry] 读取设备文件失败: {file}, 错误: {e}") + return {}, {}, False, [] + + if not data: + return {}, {}, False, [] + + complete_data = {} + action_str_type_mapping = { + "UniLabJsonCommand": "UniLabJsonCommand", + "UniLabJsonCommandAsync": "UniLabJsonCommandAsync", + } + status_str_type_mapping = {} + device_ids = [] + + for device_id, device_config in data.items(): + if "version" not in device_config: + device_config["version"] = "1.0.0" + if "category" not in device_config: + device_config["category"] = [file.stem] + elif file.stem not in device_config["category"]: + device_config["category"].append(file.stem) + if "config_info" not in device_config: + device_config["config_info"] = [] + if "description" not in device_config: + device_config["description"] = "" + if "icon" not in device_config: + device_config["icon"] = "" + if "handles" not in device_config: + device_config["handles"] = [] + if "init_param_schema" not in device_config: + device_config["init_param_schema"] = {} + if "class" in device_config: + if "status_types" not in device_config["class"] or device_config["class"]["status_types"] is None: + device_config["class"]["status_types"] = {} + if ( + "action_value_mappings" not in device_config["class"] + or device_config["class"]["action_value_mappings"] is None + ): + device_config["class"]["action_value_mappings"] = {} + enhanced_info = {} + if complete_registry: + device_config["class"]["status_types"].clear() + enhanced_info = get_enhanced_class_info(device_config["class"]["module"], use_dynamic=True) + if not enhanced_info.get("dynamic_import_success", False): + continue + device_config["class"]["status_types"].update( + {k: v["return_type"] for k, v in enhanced_info["status_methods"].items()} + ) + for status_name, status_type in device_config["class"]["status_types"].items(): + if isinstance(status_type, tuple) or status_type in ["Any", "None", "Unknown"]: + status_type = "String" + device_config["class"]["status_types"][status_name] = status_type + try: + target_type = self._replace_type_with_class(status_type, device_id, f"状态 {status_name}") + except ROSMsgNotFound: + continue + if target_type in [dict, list]: + target_type = String + status_str_type_mapping[status_type] = target_type + device_config["class"]["status_types"] = dict(sorted(device_config["class"]["status_types"].items())) + if complete_registry: + old_action_configs = {} + for action_name, action_config in device_config["class"]["action_value_mappings"].items(): + old_action_configs[action_name] = action_config + + device_config["class"]["action_value_mappings"] = { + k: v + for k, v in device_config["class"]["action_value_mappings"].items() + if not k.startswith("auto-") + } + device_config["class"]["action_value_mappings"].update( + { + f"auto-{k}": { + "type": "UniLabJsonCommandAsync" if v["is_async"] else "UniLabJsonCommand", + "goal": {}, + "feedback": {}, + "result": {}, + "schema": self._generate_unilab_json_command_schema( + v["args"], + k, + v.get("return_annotation"), + old_action_configs.get(f"auto-{k}", {}).get("schema"), + ), + "goal_default": {i["name"]: i["default"] for i in v["args"]}, + "handles": old_action_configs.get(f"auto-{k}", {}).get("handles", []), + "placeholder_keys": { + i["name"]: ( + "unilabos_resources" + if i["type"] == "unilabos.registry.placeholder_type:ResourceSlot" + or i["type"] == ("list", "unilabos.registry.placeholder_type:ResourceSlot") + else "unilabos_devices" + ) + for i in v["args"] + if i.get("type", "") + in [ + "unilabos.registry.placeholder_type:ResourceSlot", + "unilabos.registry.placeholder_type:DeviceSlot", + ("list", "unilabos.registry.placeholder_type:ResourceSlot"), + ("list", "unilabos.registry.placeholder_type:DeviceSlot"), + ] + }, + } + for k, v in enhanced_info["action_methods"].items() + if k not in device_config["class"]["action_value_mappings"] + } + ) + for action_name, old_config in old_action_configs.items(): + if action_name in device_config["class"]["action_value_mappings"]: + old_schema = old_config.get("schema", {}) + if "description" in old_schema and old_schema["description"]: + device_config["class"]["action_value_mappings"][action_name]["schema"][ + "description" + ] = old_schema["description"] + device_config["init_param_schema"] = {} + device_config["init_param_schema"]["config"] = self._generate_unilab_json_command_schema( + enhanced_info["init_params"], "__init__" + )["properties"]["goal"] + device_config["init_param_schema"]["data"] = self._generate_status_types_schema( + enhanced_info["status_methods"] + ) + + device_config.pop("schema", None) + device_config["class"]["action_value_mappings"] = dict( + sorted(device_config["class"]["action_value_mappings"].items()) + ) + for action_name, action_config in device_config["class"]["action_value_mappings"].items(): + if "handles" not in action_config: + action_config["handles"] = {} + elif isinstance(action_config["handles"], list): + if len(action_config["handles"]): + logger.error(f"设备{device_id} {action_name} 的handles配置错误,应该是字典类型") + continue + else: + action_config["handles"] = {} + if "type" in action_config: + action_type_str: str = action_config["type"] + if not action_type_str.startswith("UniLabJsonCommand"): + try: + target_type = self._replace_type_with_class( + action_type_str, device_id, f"动作 {action_name}" + ) + except ROSMsgNotFound: + continue + action_str_type_mapping[action_type_str] = target_type + if target_type is not None: + action_config["goal_default"] = yaml.safe_load( + io.StringIO(get_yaml_from_goal_type(target_type.Goal)) + ) + action_config["schema"] = ros_action_to_json_schema(target_type) + else: + logger.warning( + f"[UniLab Registry] 设备 {device_id} 的动作 {action_name} 类型为空,跳过替换" + ) + complete_data[device_id] = copy.deepcopy(dict(sorted(device_config.items()))) + for status_name, status_type in device_config["class"]["status_types"].items(): + device_config["class"]["status_types"][status_name] = status_str_type_mapping[status_type] + for action_name, action_config in device_config["class"]["action_value_mappings"].items(): + if action_config["type"] not in action_str_type_mapping: + continue + action_config["type"] = action_str_type_mapping[action_config["type"]] + self._add_builtin_actions(device_config, device_id) + device_config["file_path"] = str(file.absolute()).replace("\\", "/") + device_config["registry_type"] = "device" + device_ids.append(device_id) + + complete_data = dict(sorted(complete_data.items())) + complete_data = copy.deepcopy(complete_data) + try: + with open(file, "w", encoding="utf-8") as f: + yaml.dump(complete_data, f, allow_unicode=True, default_flow_style=False, Dumper=NoAliasDumper) + except Exception as e: + logger.warning(f"[UniLab Registry] 写入设备文件失败: {file}, 错误: {e}") + + return data, complete_data, True, device_ids + def load_device_types(self, path: os.PathLike, complete_registry: bool): - # return abs_path = Path(path).absolute() devices_path = abs_path / "devices" device_comms_path = abs_path / "device_comms" files = list(devices_path.glob("*.yaml")) + list(device_comms_path.glob("*.yaml")) - logger.trace( # type: ignore + logger.trace( f"[UniLab Registry] devices: {devices_path.exists()}, device_comms: {device_comms_path.exists()}, " + f"total: {len(files)}" ) - current_device_number = len(self.device_type_registry) + 1 + + if not files: + return + from unilabos.app.web.utils.action_utils import get_yaml_from_goal_type - for i, file in enumerate(files): - with open(file, encoding="utf-8", mode="r") as f: - data = yaml.safe_load(io.StringIO(f.read())) - complete_data = {} - action_str_type_mapping = { - "UniLabJsonCommand": "UniLabJsonCommand", - "UniLabJsonCommandAsync": "UniLabJsonCommandAsync", + # 使用线程池并行加载 + max_workers = min(8, len(files)) + results = [] + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + future_to_file = { + executor.submit(self._load_single_device_file, file, complete_registry, get_yaml_from_goal_type): file + for file in files } - status_str_type_mapping = {} - if data: - # 在添加到注册表前处理类型替换 - for device_id, device_config in data.items(): - # 添加文件路径信息 - 使用规范化的完整文件路径 - if "version" not in device_config: - device_config["version"] = "1.0.0" - if "category" not in device_config: - device_config["category"] = [file.stem] - elif file.stem not in device_config["category"]: - device_config["category"].append(file.stem) - if "config_info" not in device_config: - device_config["config_info"] = [] - if "description" not in device_config: - device_config["description"] = "" - if "icon" not in device_config: - device_config["icon"] = "" - if "handles" not in device_config: - device_config["handles"] = [] - if "init_param_schema" not in device_config: - device_config["init_param_schema"] = {} - if "class" in device_config: - if ( - "status_types" not in device_config["class"] - or device_config["class"]["status_types"] is None - ): - device_config["class"]["status_types"] = {} - if ( - "action_value_mappings" not in device_config["class"] - or device_config["class"]["action_value_mappings"] is None - ): - device_config["class"]["action_value_mappings"] = {} - enhanced_info = {} - if complete_registry: - device_config["class"]["status_types"].clear() - enhanced_info = get_enhanced_class_info(device_config["class"]["module"], use_dynamic=True) - if not enhanced_info.get("dynamic_import_success", False): - continue - device_config["class"]["status_types"].update( - {k: v["return_type"] for k, v in enhanced_info["status_methods"].items()} - ) - for status_name, status_type in device_config["class"]["status_types"].items(): - if isinstance(status_type, tuple) or status_type in ["Any", "None", "Unknown"]: - status_type = "String" # 替换成ROS的String,便于显示 - device_config["class"]["status_types"][status_name] = status_type - try: - target_type = self._replace_type_with_class( - status_type, device_id, f"状态 {status_name}" - ) - except ROSMsgNotFound: - continue - if target_type in [ - dict, - list, - ]: # 对于嵌套类型返回的对象,暂时处理成字符串,无法直接进行转换 - target_type = String - status_str_type_mapping[status_type] = target_type - device_config["class"]["status_types"] = dict( - sorted(device_config["class"]["status_types"].items()) - ) - if complete_registry: - # 保存原有的 action 配置(用于保留 schema 的 description 和 handles 等) - old_action_configs = {} - for action_name, action_config in device_config["class"]["action_value_mappings"].items(): - old_action_configs[action_name] = action_config - - device_config["class"]["action_value_mappings"] = { - k: v - for k, v in device_config["class"]["action_value_mappings"].items() - if not k.startswith("auto-") - } - # 处理动作值映射 - device_config["class"]["action_value_mappings"].update( - { - f"auto-{k}": { - "type": "UniLabJsonCommandAsync" if v["is_async"] else "UniLabJsonCommand", - "goal": {}, - "feedback": {}, - "result": {}, - "schema": self._generate_unilab_json_command_schema( - v["args"], - k, - v.get("return_annotation"), - # 传入旧的 schema 以保留字段 description - old_action_configs.get(f"auto-{k}", {}).get("schema"), - ), - "goal_default": {i["name"]: i["default"] for i in v["args"]}, - # 保留原有的 handles 配置 - "handles": old_action_configs.get(f"auto-{k}", {}).get("handles", []), - "placeholder_keys": { - i["name"]: ( - "unilabos_resources" - if i["type"] == "unilabos.registry.placeholder_type:ResourceSlot" - or i["type"] - == ("list", "unilabos.registry.placeholder_type:ResourceSlot") - else "unilabos_devices" - ) - for i in v["args"] - if i.get("type", "") - in [ - "unilabos.registry.placeholder_type:ResourceSlot", - "unilabos.registry.placeholder_type:DeviceSlot", - ("list", "unilabos.registry.placeholder_type:ResourceSlot"), - ("list", "unilabos.registry.placeholder_type:DeviceSlot"), - ] - }, - } - # 不生成已配置action的动作 - for k, v in enhanced_info["action_methods"].items() - if k not in device_config["class"]["action_value_mappings"] - } - ) - # 恢复原有的 description 信息(非 auto- 开头的动作) - for action_name, old_config in old_action_configs.items(): - if action_name in device_config["class"]["action_value_mappings"]: # 有一些会被删除 - old_schema = old_config.get("schema", {}) - if "description" in old_schema and old_schema["description"]: - device_config["class"]["action_value_mappings"][action_name]["schema"][ - "description" - ] = old_schema["description"] - device_config["init_param_schema"] = {} - device_config["init_param_schema"]["config"] = self._generate_unilab_json_command_schema( - enhanced_info["init_params"], "__init__" - )["properties"]["goal"] - device_config["init_param_schema"]["data"] = self._generate_status_types_schema( - enhanced_info["status_methods"] - ) - - device_config.pop("schema", None) - device_config["class"]["action_value_mappings"] = dict( - sorted(device_config["class"]["action_value_mappings"].items()) - ) - for action_name, action_config in device_config["class"]["action_value_mappings"].items(): - if "handles" not in action_config: - action_config["handles"] = {} - elif isinstance(action_config["handles"], list): - if len(action_config["handles"]): - logger.error(f"设备{device_id} {action_name} 的handles配置错误,应该是字典类型") - continue - else: - action_config["handles"] = {} - if "type" in action_config: - action_type_str: str = action_config["type"] - # 通过Json发放指令,而不是通过特殊的ros action进行处理 - if not action_type_str.startswith("UniLabJsonCommand"): - try: - target_type = self._replace_type_with_class( - action_type_str, device_id, f"动作 {action_name}" - ) - except ROSMsgNotFound: - continue - action_str_type_mapping[action_type_str] = target_type - if target_type is not None: - action_config["goal_default"] = yaml.safe_load( - io.StringIO(get_yaml_from_goal_type(target_type.Goal)) - ) - action_config["schema"] = ros_action_to_json_schema(target_type) - else: - logger.warning( - f"[UniLab Registry] 设备 {device_id} 的动作 {action_name} 类型为空,跳过替换" - ) - complete_data[device_id] = copy.deepcopy(dict(sorted(device_config.items()))) # 稍后dump到文件 - for status_name, status_type in device_config["class"]["status_types"].items(): - device_config["class"]["status_types"][status_name] = status_str_type_mapping[status_type] - for action_name, action_config in device_config["class"]["action_value_mappings"].items(): - if action_config["type"] not in action_str_type_mapping: - continue - action_config["type"] = action_str_type_mapping[action_config["type"]] - # 添加内置的驱动命令动作 - self._add_builtin_actions(device_config, device_id) - device_config["file_path"] = str(file.absolute()).replace("\\", "/") - device_config["registry_type"] = "device" - logger.trace( # type: ignore - f"[UniLab Registry] Device-{current_device_number} File-{i+1}/{len(files)} Add {device_id} " + for future in as_completed(future_to_file): + file = future_to_file[future] + try: + data, complete_data, is_valid, device_ids = future.result() + if is_valid: + results.append((file, data, device_ids)) + except Exception as e: + logger.warning(f"[UniLab Registry] 处理设备文件异常: {file}, 错误: {e}") + + # 线程安全地更新注册表 + current_device_number = len(self.device_type_registry) + 1 + with self._registry_lock: + for file, data, device_ids in results: + self.device_type_registry.update(data) + for device_id in device_ids: + logger.trace( + f"[UniLab Registry] Device-{current_device_number} Add {device_id} " + f"[{data[device_id].get('name', '未命名设备')}]" ) current_device_number += 1 - complete_data = dict(sorted(complete_data.items())) - complete_data = copy.deepcopy(complete_data) - with open(file, "w", encoding="utf-8") as f: - yaml.dump(complete_data, f, allow_unicode=True, default_flow_style=False, Dumper=NoAliasDumper) - self.device_type_registry.update(data) - else: - logger.debug( - f"[UniLab Registry] Device File-{i+1}/{len(files)} Not Valid YAML File: {file.absolute()}" - ) + + # 记录无效文件 + valid_files = {r[0] for r in results} + for file in files: + if file not in valid_files: + logger.debug(f"[UniLab Registry] Device File Not Valid YAML File: {file.absolute()}") def obtain_registry_device_info(self): devices = [] diff --git a/unilabos/resources/graphio.py b/unilabos/resources/graphio.py index 1c514d446..e1f3a0b74 100644 --- a/unilabos/resources/graphio.py +++ b/unilabos/resources/graphio.py @@ -260,7 +260,7 @@ def read_node_link_json( resource_tree_set = canonicalize_nodes_data(nodes) # 标准化边数据 - links = data.get("links", []) + links = data.get("links", data.get("edges", [])) standardized_links = canonicalize_links_ports(links, resource_tree_set) # 构建 NetworkX 图(需要转换回 dict 格式) @@ -597,6 +597,8 @@ def replace_plr_type_to_ulab(source: str): "tube": "tube", "bottle_carrier": "bottle_carrier", "plate_adapter": "plate_adapter", + "electrode_sheet": "electrode_sheet", + "material_hole": "material_hole", } if source in replace_info: return replace_info[source] diff --git a/unilabos/resources/resource_tracker.py b/unilabos/resources/resource_tracker.py index 4097782da..8a0fef39e 100644 --- a/unilabos/resources/resource_tracker.py +++ b/unilabos/resources/resource_tracker.py @@ -13,6 +13,9 @@ from pylabrobot.resources import Resource as PLRResource +EXTRA_CLASS = "unilabos_resource_class" + + class ResourceDictPositionSize(BaseModel): depth: float = Field(description="Depth", default=0.0) # z width: float = Field(description="Width", default=0.0) # x @@ -393,7 +396,7 @@ def resource_plr_inner( "parent": parent_resource, # 直接传入 ResourceDict 对象 "parent_uuid": parent_uuid, # 使用 parent_uuid 而不是 parent 对象 "type": replace_plr_type(d.get("category", "")), - "class": d.get("class", ""), + "class": extra.get(EXTRA_CLASS, ""), "position": pos, "pose": pos, "config": { @@ -443,7 +446,7 @@ def resource_plr_inner( trees.append(tree_instance) return cls(trees) - def to_plr_resources(self) -> List["PLRResource"]: + def to_plr_resources(self, skip_devices=True) -> List["PLRResource"]: """ 将 ResourceTreeSet 转换为 PLR 资源列表 @@ -468,6 +471,7 @@ def collect_node_data(node: ResourceDictInstance, name_to_uuid: dict, all_states name_to_uuid[node.res_content.name] = node.res_content.uuid all_states[node.res_content.name] = node.res_content.data name_to_extra[node.res_content.name] = node.res_content.extra + name_to_extra[node.res_content.name][EXTRA_CLASS] = node.res_content.klass for child in node.children: collect_node_data(child, name_to_uuid, all_states, name_to_extra) @@ -512,7 +516,10 @@ def node_to_plr_dict(node: ResourceDictInstance, has_model: bool): plr_dict = node_to_plr_dict(tree.root_node, has_model) try: sub_cls = find_subclass(plr_dict["type"], PLRResource) - if sub_cls is None: + if skip_devices and plr_dict["type"] == "device": + logger.info(f"跳过更新 {plr_dict['name']} 设备是class") + continue + elif sub_cls is None: raise ValueError( f"无法找到类型 {plr_dict['type']} 对应的 PLR 资源类。原始信息:{tree.root_node.res_content}" ) @@ -520,6 +527,10 @@ def node_to_plr_dict(node: ResourceDictInstance, has_model: bool): if "category" not in spec.parameters: plr_dict.pop("category", None) plr_resource = sub_cls.deserialize(plr_dict, allow_marshal=True) + from pylabrobot.resources import Coordinate + from pylabrobot.serializer import deserialize + location = cast(Coordinate, deserialize(plr_dict["location"])) + plr_resource.location = location plr_resource.load_all_state(all_states) # 使用 DeviceNodeResourceTracker 设置 UUID 和 Extra tracker.loop_set_uuid(plr_resource, name_to_uuid) @@ -986,7 +997,7 @@ def process(res): extra = name_to_extra_map[resource_name] self.set_resource_extra(res, extra) if len(extra): - logger.debug(f"设置资源Extra: {resource_name} -> {extra}") + logger.trace(f"设置资源Extra: {resource_name} -> {extra}") return 1 return 0 diff --git a/unilabos/ros/msgs/message_converter.py b/unilabos/ros/msgs/message_converter.py index 632d5e1ac..b526d5f54 100644 --- a/unilabos/ros/msgs/message_converter.py +++ b/unilabos/ros/msgs/message_converter.py @@ -770,13 +770,16 @@ def ros_message_to_json_schema(msg_class: Any, field_name: str) -> Dict[str, Any return schema -def ros_action_to_json_schema(action_class: Any, description="") -> Dict[str, Any]: +def ros_action_to_json_schema( + action_class: Any, description="", previous_schema: Optional[Dict[str, Any]] = None +) -> Dict[str, Any]: """ 将 ROS Action 类转换为 JSON Schema Args: action_class: ROS Action 类 description: 描述 + previous_schema: 之前的 schema,用于保留 goal/feedback/result 下一级字段的 description Returns: 完整的 JSON Schema 定义 @@ -810,9 +813,44 @@ def ros_action_to_json_schema(action_class: Any, description="") -> Dict[str, An "required": ["goal"], } + # 保留之前 schema 中 goal/feedback/result 下一级字段的 description + if previous_schema: + _preserve_field_descriptions(schema, previous_schema) + return schema +def _preserve_field_descriptions( + new_schema: Dict[str, Any], previous_schema: Dict[str, Any] +) -> None: + """ + 保留之前 schema 中 goal/feedback/result 下一级字段的 description 和 title + + Args: + new_schema: 新生成的 schema(会被修改) + previous_schema: 之前的 schema + """ + for section in ["goal", "feedback", "result"]: + new_section = new_schema.get("properties", {}).get(section, {}) + prev_section = previous_schema.get("properties", {}).get(section, {}) + + if not new_section or not prev_section: + continue + + new_props = new_section.get("properties", {}) + prev_props = prev_section.get("properties", {}) + + for field_name, field_schema in new_props.items(): + if field_name in prev_props: + prev_field = prev_props[field_name] + # 保留字段的 description + if "description" in prev_field and prev_field["description"]: + field_schema["description"] = prev_field["description"] + # 保留字段的 title(用户自定义的中文名) + if "title" in prev_field and prev_field["title"]: + field_schema["title"] = prev_field["title"] + + def convert_ros_action_to_jsonschema( action_name_or_type: Union[str, Type], output_file: Optional[str] = None, format: str = "json" ) -> Dict[str, Any]: diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index 737167ab5..3d1ffdad1 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -49,7 +49,6 @@ ResourceTreeInstance, ResourceDictInstance, ) -from unilabos.ros.x.rclpyx import get_event_loop from unilabos.ros.utils.driver_creator import WorkstationNodeCreator, PyLabRobotCreator, DeviceClassCreator from rclpy.task import Task, Future from unilabos.utils.import_manager import default_manager @@ -185,7 +184,7 @@ def __init__( f"创建发布者 {name} 失败,可能由于注册表有误,类型: {msg_type},错误: {ex}\n{traceback.format_exc()}" ) self.timer = node.create_timer(self.timer_period, self.publish_property) - self.__loop = get_event_loop() + self.__loop = ROS2DeviceNode.get_asyncio_loop() str_msg_type = str(msg_type)[8:-2] self.node.lab_logger().trace(f"发布属性: {name}, 类型: {str_msg_type}, 周期: {initial_period}秒, QoS: {qos}") @@ -885,6 +884,9 @@ def _handle_update( parent_appended = True # 加载状态 + original_instance.location = plr_resource.location + original_instance.rotation = plr_resource.rotation + original_instance.barcode = plr_resource.barcode original_instance.load_all_state(states) child_count = len(original_instance.get_all_children()) self.lab_logger().info( @@ -1320,19 +1322,32 @@ def ACTION(**kwargs): resource_inputs = action_kwargs[k] if is_sequence else [action_kwargs[k]] # 批量查询资源 - queried_resources = [] - for resource_data in resource_inputs: + queried_resources: list = [None] * len(resource_inputs) + uuid_indices: list[tuple[int, str, dict]] = [] # (index, uuid, resource_data) + + # 第一遍:处理没有uuid的资源,收集有uuid的资源信息 + for idx, resource_data in enumerate(resource_inputs): unilabos_uuid = resource_data.get("data", {}).get("unilabos_uuid") if unilabos_uuid is None: plr_resource = await self.get_resource_with_dir( resource_id=resource_data["id"], with_children=True ) + if "sample_id" in resource_data: + plr_resource.unilabos_extra["sample_uuid"] = resource_data["sample_id"] + queried_resources[idx] = plr_resource else: - resource_tree = await self.get_resource([unilabos_uuid]) - plr_resource = resource_tree.to_plr_resources()[0] - if "sample_id" in resource_data: - plr_resource.unilabos_extra["sample_uuid"] = resource_data["sample_id"] - queried_resources.append(plr_resource) + uuid_indices.append((idx, unilabos_uuid, resource_data)) + + # 第二遍:批量查询有uuid的资源 + if uuid_indices: + uuids = [item[1] for item in uuid_indices] + resource_tree = await self.get_resource(uuids) + plr_resources = resource_tree.to_plr_resources() + for i, (idx, _, resource_data) in enumerate(uuid_indices): + plr_resource = plr_resources[i] + if "sample_id" in resource_data: + plr_resource.unilabos_extra["sample_uuid"] = resource_data["sample_id"] + queried_resources[idx] = plr_resource self.lab_logger().debug(f"资源查询结果: 共 {len(queried_resources)} 个资源") @@ -1757,6 +1772,15 @@ class ROS2DeviceNode: 它不继承设备类,而是通过代理模式访问设备类的属性和方法。 """ + # 类变量,用于循环管理 + _asyncio_loop = None + _asyncio_loop_running = False + _asyncio_loop_thread = None + + @classmethod + def get_asyncio_loop(cls): + return cls._asyncio_loop + @staticmethod async def safe_task_wrapper(trace_callback, func, **kwargs): try: @@ -1833,6 +1857,11 @@ def __init__( print_publish: 是否打印发布信息 driver_is_ros: """ + # 在初始化时检查循环状态 + if ROS2DeviceNode._asyncio_loop_running and ROS2DeviceNode._asyncio_loop_thread is not None: + pass + elif ROS2DeviceNode._asyncio_loop_thread is None: + self._start_loop() # 保存设备类是否支持异步上下文 self._has_async_context = hasattr(driver_class, "__aenter__") and hasattr(driver_class, "__aexit__") @@ -1924,6 +1953,17 @@ def __init__( except Exception as e: self._ros_node.lab_logger().error(f"设备后初始化失败: {e}") + def _start_loop(self): + def run_event_loop(): + loop = asyncio.new_event_loop() + ROS2DeviceNode._asyncio_loop = loop + asyncio.set_event_loop(loop) + loop.run_forever() + + ROS2DeviceNode._asyncio_loop_thread = threading.Thread(target=run_event_loop, daemon=True, name="ROS2DeviceNode") + ROS2DeviceNode._asyncio_loop_thread.start() + logger.info(f"循环线程已启动") + class DeviceInfoType(TypedDict): id: str diff --git a/unilabos/ros/nodes/presets/host_node.py b/unilabos/ros/nodes/presets/host_node.py index 101476a1b..e95b3932b 100644 --- a/unilabos/ros/nodes/presets/host_node.py +++ b/unilabos/ros/nodes/presets/host_node.py @@ -5,7 +5,8 @@ import time import traceback import uuid -from typing import TYPE_CHECKING, Optional, Dict, Any, List, ClassVar, Set, TypedDict, Union +from typing import TYPE_CHECKING, Optional, Dict, Any, List, ClassVar, Set, Union +from typing_extensions import TypedDict from action_msgs.msg import GoalStatus from geometry_msgs.msg import Point @@ -62,6 +63,18 @@ class TestResourceReturn(TypedDict): devices: List[DeviceSlot] +class TestLatencyReturn(TypedDict): + """test_latency方法的返回值类型""" + + avg_rtt_ms: float + avg_time_diff_ms: float + max_time_error_ms: float + task_delay_ms: float + raw_delay_ms: float + test_count: int + status: str + + class HostNode(BaseROS2DeviceNode): """ 主机节点类,负责管理设备、资源和控制器 @@ -795,6 +808,7 @@ def assign_sample_id(obj): goal_msg = convert_to_ros_msg(action_client._action_type.Goal(), action_kwargs) self.lab_logger().info(f"[Host Node] Sending goal for {action_id}: {str(goal_msg)[:1000]}") + self.lab_logger().trace(f"[Host Node] Sending goal for {action_id}: {action_kwargs}") self.lab_logger().trace(f"[Host Node] Sending goal for {action_id}: {goal_msg}") action_client.wait_for_server() goal_uuid_obj = UUID(uuid=list(u.bytes)) @@ -853,8 +867,13 @@ def get_result_callback(self, item: "QueueItem", action_id: str, future) -> None # 适配后端的一些额外处理 return_value = return_info.get("return_value") if isinstance(return_value, dict): - unilabos_samples = return_info.get("unilabos_samples") - if isinstance(unilabos_samples, list): + unilabos_samples = return_value.pop("unilabos_samples", None) + if isinstance(unilabos_samples, list) and unilabos_samples: + self.lab_logger().info( + f"[Host Node] Job {job_id[:8]} returned {len(unilabos_samples)} sample(s): " + f"{[s.get('name', s.get('id', 'unknown')) if isinstance(s, dict) else str(s)[:20] for s in unilabos_samples[:5]]}" + f"{'...' if len(unilabos_samples) > 5 else ''}" + ) return_info["unilabos_samples"] = unilabos_samples suc = return_info.get("suc", False) if not suc: @@ -881,7 +900,7 @@ def get_result_callback(self, item: "QueueItem", action_id: str, future) -> None # 清理 _goals 中的记录 if job_id in self._goals: del self._goals[job_id] - self.lab_logger().debug(f"[Host Node] Removed goal {job_id[:8]} from _goals") + self.lab_logger().trace(f"[Host Node] Removed goal {job_id[:8]} from _goals") # 存储结果供 HTTP API 查询 try: @@ -1326,10 +1345,20 @@ def _resource_list_callback(self, request, response): self.lab_logger().debug(f"[Host Node-Resource] List parameters: {request}") return response - def test_latency(self): + def test_latency(self) -> TestLatencyReturn: """ 测试网络延迟的action实现 通过5次ping-pong机制校对时间误差并计算实际延迟 + + Returns: + TestLatencyReturn: 包含延迟测试结果的字典,包括: + - avg_rtt_ms: 平均往返时间(毫秒) + - avg_time_diff_ms: 平均时间差(毫秒) + - max_time_error_ms: 最大时间误差(毫秒) + - task_delay_ms: 实际任务延迟(毫秒),-1表示无法计算 + - raw_delay_ms: 原始时间差(毫秒),-1表示无法计算 + - test_count: 有效测试次数 + - status: 测试状态,"success"表示成功,"all_timeout"表示全部超时 """ import uuid as uuid_module @@ -1392,7 +1421,15 @@ def test_latency(self): if not ping_results: self.lab_logger().error("❌ 所有ping-pong测试都失败了") - return {"status": "all_timeout"} + return { + "avg_rtt_ms": -1.0, + "avg_time_diff_ms": -1.0, + "max_time_error_ms": -1.0, + "task_delay_ms": -1.0, + "raw_delay_ms": -1.0, + "test_count": 0, + "status": "all_timeout", + } # 统计分析 rtts = [r["rtt_ms"] for r in ping_results] @@ -1400,7 +1437,7 @@ def test_latency(self): avg_rtt_ms = sum(rtts) / len(rtts) avg_time_diff_ms = sum(time_diffs) / len(time_diffs) - max_time_diff_error_ms = max(abs(min(time_diffs)), abs(max(time_diffs))) + max_time_diff_error_ms: float = max(abs(min(time_diffs)), abs(max(time_diffs))) self.lab_logger().info("-" * 50) self.lab_logger().info("[测试统计]") @@ -1440,7 +1477,7 @@ def test_latency(self): self.lab_logger().info("=" * 60) - return { + res: TestLatencyReturn = { "avg_rtt_ms": avg_rtt_ms, "avg_time_diff_ms": avg_time_diff_ms, "max_time_error_ms": max_time_diff_error_ms, @@ -1451,9 +1488,14 @@ def test_latency(self): "test_count": len(ping_results), "status": "success", } + return res def test_resource( - self, resource: ResourceSlot = None, resources: List[ResourceSlot] = None, device: DeviceSlot = None, devices: List[DeviceSlot] = None + self, + resource: ResourceSlot = None, + resources: List[ResourceSlot] = None, + device: DeviceSlot = None, + devices: List[DeviceSlot] = None, ) -> TestResourceReturn: if resources is None: resources = [] @@ -1514,7 +1556,9 @@ def notify_resource_tree_update(self, device_id: str, action: str, resource_uuid # 构建服务地址 srv_address = f"/srv{namespace}/s2c_resource_tree" - self.lab_logger().trace(f"[Host Node-Resource] Host -> {device_id} ResourceTree {action} operation started -------") + self.lab_logger().trace( + f"[Host Node-Resource] Host -> {device_id} ResourceTree {action} operation started -------" + ) # 创建服务客户端 sclient = self.create_client(SerialCommand, srv_address) @@ -1549,7 +1593,9 @@ def notify_resource_tree_update(self, device_id: str, action: str, resource_uuid time.sleep(0.05) response = future.result() - self.lab_logger().trace(f"[Host Node-Resource] Host -> {device_id} ResourceTree {action} operation completed -------") + self.lab_logger().trace( + f"[Host Node-Resource] Host -> {device_id} ResourceTree {action} operation completed -------" + ) return True except Exception as e: diff --git a/unilabos/ros/nodes/presets/workstation.py b/unilabos/ros/nodes/presets/workstation.py index ed3fe144d..f30e33b2b 100644 --- a/unilabos/ros/nodes/presets/workstation.py +++ b/unilabos/ros/nodes/presets/workstation.py @@ -6,8 +6,6 @@ import rclpy from rosidl_runtime_py import message_to_ordereddict -from unilabos_msgs.msg import Resource -from unilabos_msgs.srv import ResourceUpdate from unilabos.messages import * # type: ignore # protocol names from rclpy.action import ActionServer, ActionClient @@ -15,7 +13,6 @@ from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response from unilabos.compile import action_protocol_generators -from unilabos.resources.graphio import nested_dict_to_list from unilabos.ros.initialize_device import initialize_device_from_dict from unilabos.ros.msgs.message_converter import ( get_action_type, @@ -231,15 +228,15 @@ async def execute_protocol(goal_handle: ServerGoalHandle): try: # 统一处理单个或多个资源 resource_id = ( - protocol_kwargs[k]["id"] if v == "unilabos_msgs/Resource" else protocol_kwargs[k][0]["id"] + protocol_kwargs[k]["id"] + if v == "unilabos_msgs/Resource" + else protocol_kwargs[k][0]["id"] ) resource_uuid = protocol_kwargs[k].get("uuid", None) r = SerialCommand_Request() r.command = json.dumps({"id": resource_id, "uuid": resource_uuid, "with_children": True}) # 发送请求并等待响应 - response: SerialCommand_Response = await self._resource_clients[ - "resource_get" - ].call_async( + response: SerialCommand_Response = await self._resource_clients["resource_get"].call_async( r ) # type: ignore raw_data = json.loads(response.response) @@ -307,12 +304,52 @@ async def execute_protocol(goal_handle: ServerGoalHandle): # 向Host更新物料当前状态 for k, v in goal.get_fields_and_field_types().items(): - if v in ["unilabos_msgs/Resource", "sequence"]: - r = ResourceUpdate.Request() - r.resources = [ - convert_to_ros_msg(Resource, rs) for rs in nested_dict_to_list(protocol_kwargs[k]) - ] - response = await self._resource_clients["resource_update"].call_async(r) + if v not in ["unilabos_msgs/Resource", "sequence"]: + continue + self.lab_logger().info(f"更新资源状态: {k}") + try: + # 去重:使用 seen 集合获取唯一的资源对象 + seen = set() + unique_resources = [] + + # 获取资源数据,统一转换为列表 + resource_data = protocol_kwargs[k] + is_sequence = v != "unilabos_msgs/Resource" + if not is_sequence: + resource_list = [resource_data] if isinstance(resource_data, dict) else resource_data + else: + # 处理序列类型,可能是嵌套列表 + resource_list = [] + if isinstance(resource_data, list): + for item in resource_data: + if isinstance(item, list): + resource_list.extend(item) + else: + resource_list.append(item) + else: + resource_list = [resource_data] + + for res_data in resource_list: + if not isinstance(res_data, dict): + continue + res_name = res_data.get("id") or res_data.get("name") + if not res_name: + continue + + # 使用 resource_tracker 获取本地 PLR 实例 + plr = self.resource_tracker.figure_resource({"name": res_name}, try_mode=False) + # 获取父资源 + res = self.resource_tracker.parent_resource(plr) + if id(res) not in seen: + seen.add(id(res)) + unique_resources.append(res) + + # 使用新的资源树接口更新 + if unique_resources: + await self.update_resource(unique_resources) + except Exception as e: + self.lab_logger().error(f"资源更新失败: {e}") + self.lab_logger().error(traceback.format_exc()) # 设置成功状态和返回值 execution_success = True diff --git a/unilabos/ros/x/rclpyx.py b/unilabos/ros/x/rclpyx.py deleted file mode 100644 index a723922d7..000000000 --- a/unilabos/ros/x/rclpyx.py +++ /dev/null @@ -1,182 +0,0 @@ -import asyncio -from asyncio import events -import threading - -import rclpy -from rclpy.impl.implementation_singleton import rclpy_implementation as _rclpy -from rclpy.executors import await_or_execute, Executor -from rclpy.action import ActionClient, ActionServer -from rclpy.action.server import ServerGoalHandle, GoalResponse, GoalInfo, GoalStatus -from std_msgs.msg import String -from action_tutorials_interfaces.action import Fibonacci - - -loop = None - -def get_event_loop(): - global loop - return loop - - -async def default_handle_accepted_callback_async(goal_handle): - """Execute the goal.""" - await goal_handle.execute() - - -class ServerGoalHandleX(ServerGoalHandle): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - async def execute(self, execute_callback=None): - # It's possible that there has been a request to cancel the goal prior to executing. - # In this case we want to avoid the illegal state transition to EXECUTING - # but still call the users execute callback to let them handle canceling the goal. - if not self.is_cancel_requested: - self._update_state(_rclpy.GoalEvent.EXECUTE) - await self._action_server.notify_execute_async(self, execute_callback) - - -class ActionServerX(ActionServer): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.register_handle_accepted_callback(default_handle_accepted_callback_async) - - async def _execute_goal_request(self, request_header_and_message): - request_header, goal_request = request_header_and_message - goal_uuid = goal_request.goal_id - goal_info = GoalInfo() - goal_info.goal_id = goal_uuid - - self._node.get_logger().debug('New goal request with ID: {0}'.format(goal_uuid.uuid)) - - # Check if goal ID is already being tracked by this action server - with self._lock: - goal_id_exists = self._handle.goal_exists(goal_info) - - accepted = False - if not goal_id_exists: - # Call user goal callback - response = await await_or_execute(self._goal_callback, goal_request.goal) - if not isinstance(response, GoalResponse): - self._node.get_logger().warning( - 'Goal request callback did not return a GoalResponse type. Rejecting goal.') - else: - accepted = GoalResponse.ACCEPT == response - - if accepted: - # Stamp time of acceptance - goal_info.stamp = self._node.get_clock().now().to_msg() - - # Create a goal handle - try: - with self._lock: - goal_handle = ServerGoalHandleX(self, goal_info, goal_request.goal) - except RuntimeError as e: - self._node.get_logger().error( - 'Failed to accept new goal with ID {0}: {1}'.format(goal_uuid.uuid, e)) - accepted = False - else: - self._goal_handles[bytes(goal_uuid.uuid)] = goal_handle - - # Send response - response_msg = self._action_type.Impl.SendGoalService.Response() - response_msg.accepted = accepted - response_msg.stamp = goal_info.stamp - self._handle.send_goal_response(request_header, response_msg) - - if not accepted: - self._node.get_logger().debug('New goal rejected: {0}'.format(goal_uuid.uuid)) - return - - self._node.get_logger().debug('New goal accepted: {0}'.format(goal_uuid.uuid)) - - # Provide the user a reference to the goal handle - # await await_or_execute(self._handle_accepted_callback, goal_handle) - asyncio.create_task(self._handle_accepted_callback(goal_handle)) - - async def notify_execute_async(self, goal_handle, execute_callback): - # Use provided callback, defaulting to a previously registered callback - if execute_callback is None: - if self._execute_callback is None: - return - execute_callback = self._execute_callback - - # Schedule user callback for execution - self._node.get_logger().info(f"{events.get_running_loop()}") - asyncio.create_task(self._execute_goal(execute_callback, goal_handle)) - # loop = asyncio.new_event_loop() - # asyncio.set_event_loop(loop) - # task = loop.create_task(self._execute_goal(execute_callback, goal_handle)) - # await task - - -class ActionClientX(ActionClient): - feedback_queue = asyncio.Queue() - - async def feedback_cb(self, msg): - await self.feedback_queue.put(msg) - - async def send_goal_async(self, goal_msg): - goal_future = super().send_goal_async( - goal_msg, - feedback_callback=self.feedback_cb - ) - client_goal_handle = await asyncio.ensure_future(goal_future) - if not client_goal_handle.accepted: - raise Exception("Goal rejected.") - result_future = client_goal_handle.get_result_async() - while True: - feedback_future = asyncio.ensure_future(self.feedback_queue.get()) - tasks = [result_future, feedback_future] - await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) - if result_future.done(): - result = result_future.result().result - yield (None, result) - break - else: - feedback = feedback_future.result().feedback - yield (feedback, None) - - -async def main(node): - print('Node started.') - action_client = ActionClientX(node, Fibonacci, 'fibonacci') - goal_msg = Fibonacci.Goal() - goal_msg.order = 10 - async for (feedback, result) in action_client.send_goal_async(goal_msg): - if feedback: - print(f'Feedback: {feedback}') - else: - print(f'Result: {result}') - print('Finished.') - - -async def ros_loop_node(node): - while rclpy.ok(): - rclpy.spin_once(node, timeout_sec=0) - await asyncio.sleep(1e-4) - - -async def ros_loop(executor: Executor): - while rclpy.ok(): - executor.spin_once(timeout_sec=0) - await asyncio.sleep(1e-4) - - -def run_event_loop(): - global loop - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - loop.run_forever() - - -def run_event_loop_in_thread(): - thread = threading.Thread(target=run_event_loop, args=()) - thread.start() - - -if __name__ == "__main__": - rclpy.init() - node = rclpy.create_node('async_subscriber') - future = asyncio.wait([ros_loop(node), main()]) - asyncio.get_event_loop().run_until_complete(future) \ No newline at end of file diff --git a/unilabos/test/experiments/virtual_bench.json b/unilabos/test/experiments/virtual_bench.json new file mode 100644 index 000000000..d37fa6ee4 --- /dev/null +++ b/unilabos/test/experiments/virtual_bench.json @@ -0,0 +1,28 @@ +{ + "nodes": [ + { + "id": "workbench_1", + "name": "虚拟工作台", + "children": [], + "parent": null, + "type": "device", + "class": "virtual_workbench", + "position": { + "x": 400, + "y": 300, + "z": 0 + }, + "config": { + "arm_operation_time": 3.0, + "heating_time": 10.0, + "num_heating_stations": 3 + }, + "data": { + "status": "Ready", + "arm_state": "idle", + "message": "工作台就绪" + } + } + ], + "links": [] +} diff --git a/unilabos/utils/README_LOGGING.md b/unilabos/utils/README_LOGGING.md deleted file mode 100644 index 9cb551bb4..000000000 --- a/unilabos/utils/README_LOGGING.md +++ /dev/null @@ -1,187 +0,0 @@ -# UniLabOS 日志配置说明 - -> **文件位置**: `unilabos/utils/log.py` -> **最后更新**: 2026-01-11 -> **维护者**: Uni-Lab-OS 开发团队 - -本文档说明 UniLabOS 日志系统中对第三方库和内部模块的日志级别配置,避免控制台被过多的 DEBUG 日志淹没。 - ---- - -## 📋 已屏蔽的日志 - -以下库/模块的日志已被设置为 **WARNING** 或 **INFO** 级别,不再显示 DEBUG 日志: - -### 1. pymodbus(Modbus 通信库) - -**配置位置**: `log.py` 第196-200行 - -```python -# pymodbus 库的日志太详细,设置为 WARNING -logging.getLogger('pymodbus').setLevel(logging.WARNING) -logging.getLogger('pymodbus.logging').setLevel(logging.WARNING) -logging.getLogger('pymodbus.logging.base').setLevel(logging.WARNING) -logging.getLogger('pymodbus.logging.decoders').setLevel(logging.WARNING) -``` - -**屏蔽原因**: -- pymodbus 在 DEBUG 级别会输出每一次 Modbus 通信的详细信息 -- 包括 `Processing: 0x5 0x1e 0x0 0x0...` 等原始数据 -- 包括 `decoded PDU function_code(3 sub -1) -> ReadHoldingRegistersResponse(...)` 等解码信息 -- 这些信息对日常使用价值不大,但会快速刷屏 - -**典型被屏蔽的日志**: -``` -[DEBUG] Processing: 0x5 0x1e 0x0 0x0 0x0 0x7 0x1 0x3 0x4 0x0 0x0 0x0 0x0 [handleFrame:72] [pymodbus.logging.base] -[DEBUG] decoded PDU function_code(3 sub -1) -> ReadHoldingRegistersResponse(...) [decode:79] [pymodbus.logging.decoders] -``` - ---- - -### 2. websockets(WebSocket 库) - -**配置位置**: `log.py` 第202-205行 - -```python -# websockets 库的日志输出较多,设置为 WARNING -logging.getLogger('websockets').setLevel(logging.WARNING) -logging.getLogger('websockets.client').setLevel(logging.WARNING) -logging.getLogger('websockets.server').setLevel(logging.WARNING) -``` - -**屏蔽原因**: -- WebSocket 连接、断开、心跳等信息在 DEBUG 级别会频繁输出 -- 对于长时间运行的服务,这些日志意义不大 - ---- - -### 3. ROS Host Node(设备状态更新) - -**配置位置**: `log.py` 第207-208行 - -```python -# ROS 节点的状态更新日志过于频繁,设置为 INFO -logging.getLogger('unilabos.ros.nodes.presets.host_node').setLevel(logging.INFO) -``` - -**屏蔽原因**: -- 设备状态更新(如手套箱压力)每隔几秒就会更新一次 -- DEBUG 日志会记录每一次状态变化,导致日志刷屏 -- 这些频繁的状态更新对调试价值不大 - -**典型被屏蔽的日志**: -``` -[DEBUG] [/devices/host_node] Status updated: BatteryStation.data_glove_box_pressure = 4.229457855224609 [property_callback:666] [unilabos.ros.nodes.presets.host_node] -``` - ---- - -### 4. asyncio 和 urllib3 - -**配置位置**: `log.py` 第224-225行 - -```python -logging.getLogger("asyncio").setLevel(logging.INFO) -logging.getLogger("urllib3").setLevel(logging.INFO) -``` - -**屏蔽原因**: -- asyncio: 异步 IO 的内部调试信息 -- urllib3: HTTP 请求库的连接池、重试等详细信息 - ---- - -## 🔧 如何临时启用这些日志(调试用) - -### 方法1: 修改 log.py(永久启用) - -在 `log.py` 的 `configure_logger()` 函数中,将对应库的日志级别改为 `logging.DEBUG`: - -```python -# 临时启用 pymodbus 的 DEBUG 日志 -logging.getLogger('pymodbus').setLevel(logging.DEBUG) -logging.getLogger('pymodbus.logging').setLevel(logging.DEBUG) -logging.getLogger('pymodbus.logging.base').setLevel(logging.DEBUG) -logging.getLogger('pymodbus.logging.decoders').setLevel(logging.DEBUG) -``` - -### 方法2: 在代码中临时启用(单次调试) - -在需要调试的代码文件中添加: - -```python -import logging - -# 临时启用 pymodbus DEBUG 日志 -logging.getLogger('pymodbus').setLevel(logging.DEBUG) - -# 你的 Modbus 调试代码 -... - -# 调试完成后恢复 -logging.getLogger('pymodbus').setLevel(logging.WARNING) -``` - -### 方法3: 使用环境变量或配置文件(推荐) - -未来可以考虑在启动参数中添加 `--debug-modbus` 等选项来动态控制。 - ---- - -## 📊 日志级别说明 - -| 级别 | 数值 | 用途 | 是否显示 | -|------|------|------|---------| -| TRACE | 5 | 最详细的跟踪信息 | ✅ | -| DEBUG | 10 | 调试信息 | ✅ | -| INFO | 20 | 一般信息 | ✅ | -| WARNING | 30 | 警告信息 | ✅ | -| ERROR | 40 | 错误信息 | ✅ | -| CRITICAL | 50 | 严重错误 | ✅ | - -**当前配置**: -- UniLabOS 自身代码: DEBUG 及以上全部显示 -- pymodbus/websockets: **WARNING** 及以上显示(屏蔽 DEBUG/INFO) -- ROS host_node: **INFO** 及以上显示(屏蔽 DEBUG) - ---- - -## ⚠️ 重要提示 - -### 修改生效时间 -- 修改 `log.py` 后需要 **重启 unilab 服务** 才能生效 -- 不需要重新安装或重新编译 - -### 调试 Modbus 通信问题 -如果需要调试 Modbus 通信故障,应该: -1. 临时启用 pymodbus DEBUG 日志(方法2) -2. 复现问题 -3. 查看详细的通信日志 -4. 调试完成后记得恢复 WARNING 级别 - -### 调试设备状态问题 -如果需要调试设备状态更新问题: -```python -logging.getLogger('unilabos.ros.nodes.presets.host_node').setLevel(logging.DEBUG) -``` - ---- - -## 📝 维护记录 - -| 日期 | 修改内容 | 操作人 | -|------|---------|--------| -| 2026-01-11 | 初始创建,添加 pymodbus、websockets、ROS host_node 屏蔽 | - | -| 2026-01-07 | 添加 pymodbus 和 websockets 屏蔽(log-0107.py) | - | - ---- - -## 🔗 相关文件 - -- `log.py` - 日志配置主文件 -- `unilabos/devices/workstation/coin_cell_assembly/` - 使用 Modbus 的扣电工作站代码 -- `unilabos/ros/nodes/presets/host_node.py` - ROS 主机节点代码 - ---- - -**维护提示**: 如果添加了新的第三方库或发现新的日志刷屏问题,请在此文档中记录并更新 `log.py` 配置。 diff --git a/unilabos/utils/decorator.py b/unilabos/utils/decorator.py index 667f3531b..57e968a8b 100644 --- a/unilabos/utils/decorator.py +++ b/unilabos/utils/decorator.py @@ -182,3 +182,49 @@ def get_all_subscriptions(instance) -> list: except Exception: pass return subscriptions + + +def not_action(func: F) -> F: + """ + 标记方法为非动作的装饰器 + + 用于装饰 driver 类中的方法,使其在 complete_registry 时不被识别为动作。 + 适用于辅助方法、内部工具方法等不应暴露为设备动作的公共方法。 + + Example: + class MyDriver: + @not_action + def helper_method(self): + # 这个方法不会被注册为动作 + pass + + def actual_action(self, param: str): + # 这个方法会被注册为动作 + self.helper_method() + + Note: + - 可以与其他装饰器组合使用,@not_action 应放在最外层 + - 仅影响 complete_registry 的动作识别,不影响方法的正常调用 + """ + + @wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + # 在函数上附加标记 + wrapper._is_not_action = True # type: ignore[attr-defined] + + return wrapper # type: ignore[return-value] + + +def is_not_action(func) -> bool: + """ + 检查函数是否被标记为非动作 + + Args: + func: 被检查的函数 + + Returns: + 如果函数被 @not_action 装饰则返回 True,否则返回 False + """ + return getattr(func, "_is_not_action", False) diff --git a/unilabos/utils/environment_check.py b/unilabos/utils/environment_check.py index 3963b9ef4..73c0b10bf 100644 --- a/unilabos/utils/environment_check.py +++ b/unilabos/utils/environment_check.py @@ -24,6 +24,7 @@ def __init__(self): "msgcenterpy": "msgcenterpy", "opentrons_shared_data": "opentrons_shared_data", "typing_extensions": "typing_extensions", + "crcmod": "crcmod-plus", } # 特殊安装包(需要特殊处理的包) diff --git a/unilabos/utils/import_manager.py b/unilabos/utils/import_manager.py index 00fcd06be..2df763604 100644 --- a/unilabos/utils/import_manager.py +++ b/unilabos/utils/import_manager.py @@ -28,6 +28,7 @@ from ast import Constant from unilabos.utils import logger +from unilabos.utils.decorator import is_not_action class ImportManager: @@ -275,6 +276,9 @@ def _get_dynamic_class_info(self, class_path: str) -> Dict[str, Any]: method_info = self._analyze_method_signature(method) result["status_methods"][actual_name] = method_info elif not name.startswith("_"): + # 检查是否被 @not_action 装饰器标记 + if is_not_action(method): + continue # 其他非_开头的方法归类为action method_info = self._analyze_method_signature(method) result["action_methods"][name] = method_info @@ -330,6 +334,9 @@ def _get_static_class_info(self, module_path: str) -> Dict[str, Any]: if actual_name not in result["status_methods"]: result["status_methods"][actual_name] = method_info else: + # 检查是否被 @not_action 装饰器标记 + if self._is_not_action_method(node): + continue # 其他非_开头的方法归类为action result["action_methods"][method_name] = method_info return result @@ -450,6 +457,13 @@ def _is_setter_method(self, node: ast.FunctionDef) -> bool: return True return False + def _is_not_action_method(self, node: ast.FunctionDef) -> bool: + """检查是否是@not_action装饰的方法""" + for decorator in node.decorator_list: + if isinstance(decorator, ast.Name) and decorator.id == "not_action": + return True + return False + def _get_property_name_from_setter(self, node: ast.FunctionDef) -> str: """从setter装饰器中获取属性名""" for decorator in node.decorator_list: diff --git a/unilabos/utils/pywinauto_util.py b/unilabos/utils/pywinauto_util.py index 3b78632ad..70eeb96db 100644 --- a/unilabos/utils/pywinauto_util.py +++ b/unilabos/utils/pywinauto_util.py @@ -1,7 +1,11 @@ import psutil import pywinauto -from pywinauto_recorder import UIApplication -from pywinauto_recorder.player import UIPath, click, focus_on_application, exists, find, get_wrapper_path +try: + from pywinauto_recorder import UIApplication + from pywinauto_recorder.player import UIPath, click, focus_on_application, exists, find, get_wrapper_path +except ImportError: + print("未安装pywinauto_recorder,部分功能无法使用,安装时注意enum") + pass from pywinauto.controls.uiawrapper import UIAWrapper from pywinauto.application import WindowSpecification from pywinauto import findbestmatch diff --git a/unilabos/utils/requirements.txt b/unilabos/utils/requirements.txt new file mode 100644 index 000000000..65d724fc1 --- /dev/null +++ b/unilabos/utils/requirements.txt @@ -0,0 +1,18 @@ +networkx +typing_extensions +websockets +msgcenterpy>=0.1.5 +opentrons_shared_data +pint +fastapi +jinja2 +requests +uvicorn +pyautogui +opcua +pyserial +pandas +crcmod-plus +pymodbus +matplotlib +pylibftdi \ No newline at end of file diff --git a/unilabos/workflow/common.py b/unilabos/workflow/common.py index 9bff04947..ad073d9b0 100644 --- a/unilabos/workflow/common.py +++ b/unilabos/workflow/common.py @@ -1,3 +1,89 @@ +""" +工作流转换模块 - JSON 到 WorkflowGraph 的转换流程 + +==================== 输入格式 (JSON) ==================== + +{ + "workflow": [ + {"action": "transfer_liquid", "action_args": {"sources": "cell_lines", "targets": "Liquid_1", "asp_vol": 100.0, "dis_vol": 74.75, ...}}, + ... + ], + "reagent": { + "cell_lines": {"slot": 4, "well": ["A1", "A3", "A5"], "labware": "DRUG + YOYO-MEDIA"}, + "Liquid_1": {"slot": 1, "well": ["A4", "A7", "A10"], "labware": "rep 1"}, + ... + } +} + +==================== 转换步骤 ==================== + +第一步: 按 slot 去重创建 create_resource 节点(创建板子) +-------------------------------------------------------------------------------- +- 遍历所有 reagent,按 slot 去重,为每个唯一的 slot 创建一个板子 +- 生成参数: + res_id: plate_slot_{slot} + device_id: /PRCXI + class_name: PRCXI_BioER_96_wellplate + parent: /PRCXI/PRCXI_Deck/T{slot} + slot_on_deck: "{slot}" +- 输出端口: labware(用于连接 set_liquid_from_plate) +- 控制流: create_resource 之间通过 ready 端口串联 + +示例: slot=1, slot=4 -> 创建 2 个 create_resource 节点 + +第二步: 为每个 reagent 创建 set_liquid_from_plate 节点(设置液体) +-------------------------------------------------------------------------------- +- 遍历所有 reagent,为每个试剂创建 set_liquid_from_plate 节点 +- 生成参数: + plate: [](通过连接传递,来自 create_resource 的 labware) + well_names: ["A1", "A3", "A5"](来自 reagent 的 well 数组) + liquid_names: ["cell_lines", "cell_lines", "cell_lines"](与 well 数量一致) + volumes: [1e5, 1e5, 1e5](与 well 数量一致,默认体积) +- 输入连接: create_resource (labware) -> set_liquid_from_plate (input_plate) +- 输出端口: output_wells(用于连接 transfer_liquid) +- 控制流: set_liquid_from_plate 连接在所有 create_resource 之后,通过 ready 端口串联 + +第三步: 解析 workflow,创建 transfer_liquid 等动作节点 +-------------------------------------------------------------------------------- +- 遍历 workflow 数组,为每个动作创建步骤节点 +- 参数重命名: asp_vol -> asp_vols, dis_vol -> dis_vols, asp_flow_rate -> asp_flow_rates, dis_flow_rate -> dis_flow_rates +- 参数扩展: 根据 targets 的 wells 数量,将单值扩展为数组 + 例: asp_vol=100.0, targets 有 3 个 wells -> asp_vols=[100.0, 100.0, 100.0] +- 连接处理: 如果 sources/targets 已通过 set_liquid_from_plate 连接,参数值改为 [] +- 输入连接: set_liquid_from_plate (output_wells) -> transfer_liquid (sources_identifier / targets_identifier) +- 输出端口: sources_out, targets_out(用于连接下一个 transfer_liquid) + +==================== 连接关系图 ==================== + +控制流 (ready 端口串联): + create_resource_1 -> create_resource_2 -> ... -> set_liquid_1 -> set_liquid_2 -> ... -> transfer_liquid_1 -> transfer_liquid_2 -> ... + +物料流: + [create_resource] --labware--> [set_liquid_from_plate] --output_wells--> [transfer_liquid] --sources_out/targets_out--> [下一个 transfer_liquid] + (slot=1) (cell_lines) (input_plate) (sources_identifier) (sources_identifier) + (slot=4) (Liquid_1) (targets_identifier) (targets_identifier) + +==================== 端口映射 ==================== + +create_resource: + 输出: labware + +set_liquid_from_plate: + 输入: input_plate + 输出: output_plate, output_wells + +transfer_liquid: + 输入: sources -> sources_identifier, targets -> targets_identifier + 输出: sources -> sources_out, targets -> targets_out + +==================== 校验规则 ==================== + +- 检查 sources/targets 是否在 reagent 中定义 +- 检查 sources 和 targets 的 wells 数量是否匹配 +- 检查参数数组长度是否与 wells 数量一致 +- 如有问题,在 footer 中添加 [WARN: ...] 标记 +""" + import re import uuid @@ -8,6 +94,28 @@ Json = Dict[str, Any] + +# ==================== 默认配置 ==================== + +# create_resource 节点默认参数 +CREATE_RESOURCE_DEFAULTS = { + "device_id": "/PRCXI", + "parent_template": "/PRCXI/PRCXI_Deck/T{slot}", # {slot} 会被替换为实际的 slot 值 + "class_name": "PRCXI_BioER_96_wellplate", +} + +# 默认液体体积 (uL) +DEFAULT_LIQUID_VOLUME = 1e5 + +# 参数重命名映射:单数 -> 复数(用于 transfer_liquid 等动作) +PARAM_RENAME_MAPPING = { + "asp_vol": "asp_vols", + "dis_vol": "dis_vols", + "asp_flow_rate": "asp_flow_rates", + "dis_flow_rate": "dis_flow_rates", +} + + # ---------------- Graph ---------------- @@ -228,7 +336,7 @@ def refactor_data( def build_protocol_graph( - labware_info: List[Dict[str, Any]], + labware_info: Dict[str, Dict[str, Any]], protocol_steps: List[Dict[str, Any]], workstation_name: str, action_resource_mapping: Optional[Dict[str, str]] = None, @@ -236,112 +344,227 @@ def build_protocol_graph( """统一的协议图构建函数,根据设备类型自动选择构建逻辑 Args: - labware_info: labware 信息字典 + labware_info: labware 信息字典,格式为 {name: {slot, well, labware, ...}, ...} protocol_steps: 协议步骤列表 workstation_name: 工作站名称 action_resource_mapping: action 到 resource_name 的映射字典,可选 """ G = WorkflowGraph() - resource_last_writer = {} + resource_last_writer = {} # reagent_name -> "node_id:port" + slot_to_create_resource = {} # slot -> create_resource node_id protocol_steps = refactor_data(protocol_steps, action_resource_mapping) - # 有机化学&移液站协议图构建 - WORKSTATION_ID = workstation_name - # 为所有labware创建资源节点 - res_index = 0 + # ==================== 第一步:按 slot 去重创建 create_resource 节点 ==================== + # 收集所有唯一的 slot + slots_info = {} # slot -> {labware, res_id} for labware_id, item in labware_info.items(): - # item_id = item.get("id") or item.get("name", f"item_{uuid.uuid4()}") + slot = str(item.get("slot", "")) + if slot and slot not in slots_info: + res_id = f"plate_slot_{slot}" + slots_info[slot] = { + "labware": item.get("labware", ""), + "res_id": res_id, + } + + # 为每个唯一的 slot 创建 create_resource 节点 + res_index = 0 + last_create_resource_id = None + for slot, info in slots_info.items(): node_id = str(uuid.uuid4()) - - # 判断节点类型 - if "Rack" in str(labware_id) or "Tip" in str(labware_id): - lab_node_type = "Labware" - description = f"Prepare Labware: {labware_id}" - liquid_type = [] - liquid_volume = [] - elif item.get("type") == "hardware" or "reactor" in str(labware_id).lower(): - if "reactor" not in str(labware_id).lower(): - continue - lab_node_type = "Sample" - description = f"Prepare Reactor: {labware_id}" - liquid_type = [] - liquid_volume = [] - else: - lab_node_type = "Reagent" - description = f"Add Reagent to Flask: {labware_id}" - liquid_type = [labware_id] - liquid_volume = [1e5] + res_id = info["res_id"] res_index += 1 G.add_node( node_id, template_name="create_resource", resource_name="host_node", - name=f"Res {res_index}", - description=description, - lab_node_type=lab_node_type, + name=f"Plate {res_index}", + description=f"Create plate on slot {slot}", + lab_node_type="Labware", footer="create_resource-host_node", param={ - "res_id": labware_id, - "device_id": WORKSTATION_ID, - "class_name": "container", - "parent": WORKSTATION_ID, + "res_id": res_id, + "device_id": CREATE_RESOURCE_DEFAULTS["device_id"], + "class_name": CREATE_RESOURCE_DEFAULTS["class_name"], + "parent": CREATE_RESOURCE_DEFAULTS["parent_template"].format(slot=slot), "bind_locations": {"x": 0.0, "y": 0.0, "z": 0.0}, - "liquid_input_slot": [-1], - "liquid_type": liquid_type, - "liquid_volume": liquid_volume, - "slot_on_deck": "", + "slot_on_deck": slot, + }, + ) + slot_to_create_resource[slot] = node_id + + # create_resource 之间通过 ready 串联 + if last_create_resource_id is not None: + G.add_edge(last_create_resource_id, node_id, source_port="ready", target_port="ready") + last_create_resource_id = node_id + + # ==================== 第二步:为每个 reagent 创建 set_liquid_from_plate 节点 ==================== + set_liquid_index = 0 + last_set_liquid_id = last_create_resource_id # set_liquid_from_plate 连接在 create_resource 之后 + + for labware_id, item in labware_info.items(): + # 跳过 Tip/Rack 类型 + if "Rack" in str(labware_id) or "Tip" in str(labware_id): + continue + if item.get("type") == "hardware": + continue + + slot = str(item.get("slot", "")) + wells = item.get("well", []) + if not wells or not slot: + continue + + # res_id 不能有空格 + res_id = str(labware_id).replace(" ", "_") + well_count = len(wells) + + node_id = str(uuid.uuid4()) + set_liquid_index += 1 + + G.add_node( + node_id, + template_name="set_liquid_from_plate", + resource_name="liquid_handler.prcxi", + name=f"SetLiquid {set_liquid_index}", + description=f"Set liquid: {labware_id}", + lab_node_type="Reagent", + footer="set_liquid_from_plate-liquid_handler.prcxi", + param={ + "plate": [], # 通过连接传递 + "well_names": wells, # 孔位名数组,如 ["A1", "A3", "A5"] + "liquid_names": [res_id] * well_count, + "volumes": [DEFAULT_LIQUID_VOLUME] * well_count, }, ) - resource_last_writer[labware_id] = f"{node_id}:labware" - last_control_node_id = None + # ready 连接:上一个节点 -> set_liquid_from_plate + if last_set_liquid_id is not None: + G.add_edge(last_set_liquid_id, node_id, source_port="ready", target_port="ready") + last_set_liquid_id = node_id + + # 物料流:create_resource 的 labware -> set_liquid_from_plate 的 input_plate + create_res_node_id = slot_to_create_resource.get(slot) + if create_res_node_id: + G.add_edge(create_res_node_id, node_id, source_port="labware", target_port="input_plate") + + # set_liquid_from_plate 的输出 output_wells 用于连接 transfer_liquid + resource_last_writer[labware_id] = f"{node_id}:output_wells" + + last_control_node_id = last_set_liquid_id + + # 端口名称映射:JSON 字段名 -> 实际 handle key + INPUT_PORT_MAPPING = { + "sources": "sources_identifier", + "targets": "targets_identifier", + "vessel": "vessel", + "to_vessel": "to_vessel", + "from_vessel": "from_vessel", + "reagent": "reagent", + "solvent": "solvent", + "compound": "compound", + } + + OUTPUT_PORT_MAPPING = { + "sources": "sources_out", # 输出端口是 xxx_out + "targets": "targets_out", # 输出端口是 xxx_out + "vessel": "vessel_out", + "to_vessel": "to_vessel_out", + "from_vessel": "from_vessel_out", + "filtrate_vessel": "filtrate_out", + "reagent": "reagent", + "solvent": "solvent", + "compound": "compound", + } + + # 需要根据 wells 数量扩展的参数列表(复数形式) + EXPAND_BY_WELLS_PARAMS = ["asp_vols", "dis_vols", "asp_flow_rates", "dis_flow_rates"] # 处理协议步骤 for step in protocol_steps: node_id = str(uuid.uuid4()) - G.add_node(node_id, **step) + params = step.get("param", {}).copy() # 复制一份,避免修改原数据 + connected_params = set() # 记录被连接的参数 + warnings = [] # 收集警告信息 + + # 参数重命名:单数 -> 复数 + for old_name, new_name in PARAM_RENAME_MAPPING.items(): + if old_name in params: + params[new_name] = params.pop(old_name) + + # 处理输入连接 + for param_key, target_port in INPUT_PORT_MAPPING.items(): + resource_name = params.get(param_key) + if resource_name and resource_name in resource_last_writer: + source_node, source_port = resource_last_writer[resource_name].split(":") + G.add_edge(source_node, node_id, source_port=source_port, target_port=target_port) + connected_params.add(param_key) + elif resource_name and resource_name not in resource_last_writer: + # 资源名在 labware_info 中不存在 + warnings.append(f"{param_key}={resource_name} 未找到") + + # 获取 targets 对应的 wells 数量,用于扩展参数 + targets_name = params.get("targets") + sources_name = params.get("sources") + targets_wells_count = 1 + sources_wells_count = 1 + + if targets_name and targets_name in labware_info: + target_wells = labware_info[targets_name].get("well", []) + targets_wells_count = len(target_wells) if target_wells else 1 + elif targets_name: + warnings.append(f"targets={targets_name} 未在 reagent 中定义") + + if sources_name and sources_name in labware_info: + source_wells = labware_info[sources_name].get("well", []) + sources_wells_count = len(source_wells) if source_wells else 1 + elif sources_name: + warnings.append(f"sources={sources_name} 未在 reagent 中定义") + + # 检查 sources 和 targets 的 wells 数量是否匹配 + if targets_wells_count != sources_wells_count and targets_name and sources_name: + warnings.append(f"wells 数量不匹配: sources={sources_wells_count}, targets={targets_wells_count}") + + # 使用 targets 的 wells 数量来扩展参数 + wells_count = targets_wells_count + + # 扩展单值参数为数组(根据 targets 的 wells 数量) + for expand_param in EXPAND_BY_WELLS_PARAMS: + if expand_param in params: + value = params[expand_param] + # 如果是单个值,扩展为数组 + if not isinstance(value, list): + params[expand_param] = [value] * wells_count + # 如果已经是数组但长度不对,记录警告 + elif len(value) != wells_count: + warnings.append(f"{expand_param} 数量({len(value)})与 wells({wells_count})不匹配") + + # 如果 sources/targets 已通过连接传递,将参数值改为空数组 + for param_key in connected_params: + if param_key in params: + params[param_key] = [] + + # 更新 step 的 param 和 footer + step_copy = step.copy() + step_copy["param"] = params + + # 如果有警告,修改 footer 添加警告标记(警告放前面) + if warnings: + original_footer = step.get("footer", "") + step_copy["footer"] = f"[WARN: {'; '.join(warnings)}] {original_footer}" + + G.add_node(node_id, **step_copy) # 控制流 if last_control_node_id is not None: G.add_edge(last_control_node_id, node_id, source_port="ready", target_port="ready") last_control_node_id = node_id - # 物料流 - params = step.get("param", {}) - input_resources_possible_names = [ - "vessel", - "to_vessel", - "from_vessel", - "reagent", - "solvent", - "compound", - "sources", - "targets", - ] - - for target_port in input_resources_possible_names: - resource_name = params.get(target_port) - if resource_name and resource_name in resource_last_writer: - source_node, source_port = resource_last_writer[resource_name].split(":") - G.add_edge(source_node, node_id, source_port=source_port, target_port=target_port) - - output_resources = { - "vessel_out": params.get("vessel"), - "from_vessel_out": params.get("from_vessel"), - "to_vessel_out": params.get("to_vessel"), - "filtrate_out": params.get("filtrate_vessel"), - "reagent": params.get("reagent"), - "solvent": params.get("solvent"), - "compound": params.get("compound"), - "sources_out": params.get("sources"), - "targets_out": params.get("targets"), - } - - for source_port, resource_name in output_resources.items(): + # 处理输出:更新 resource_last_writer + for param_key, output_port in OUTPUT_PORT_MAPPING.items(): + resource_name = step.get("param", {}).get(param_key) # 使用原始参数值 if resource_name: - resource_last_writer[resource_name] = f"{node_id}:{source_port}" + resource_last_writer[resource_name] = f"{node_id}:{output_port}" return G diff --git a/unilabos/workflow/convert_from_json.py b/unilabos/workflow/convert_from_json.py index 7a6d2b40b..ff749d722 100644 --- a/unilabos/workflow/convert_from_json.py +++ b/unilabos/workflow/convert_from_json.py @@ -1,21 +1,68 @@ """ JSON 工作流转换模块 -提供从多种 JSON 格式转换为统一工作流格式的功能。 -支持的格式: -1. workflow/reagent 格式 -2. steps_info/labware_info 格式 +将 workflow/reagent 格式的 JSON 转换为统一工作流格式。 + +输入格式: +{ + "workflow": [ + {"action": "...", "action_args": {...}}, + ... + ], + "reagent": { + "reagent_name": {"slot": int, "well": [...], "labware": "..."}, + ... + } +} """ import json from os import PathLike from pathlib import Path -from typing import Any, Dict, List, Optional, Set, Tuple, Union +from typing import Any, Dict, List, Optional, Tuple, Union from unilabos.workflow.common import WorkflowGraph, build_protocol_graph from unilabos.registry.registry import lab_registry +# ==================== 字段映射配置 ==================== + +# action 到 resource_name 的映射 +ACTION_RESOURCE_MAPPING: Dict[str, str] = { + # 生物实验操作 + "transfer_liquid": "liquid_handler.prcxi", + "transfer": "liquid_handler.prcxi", + "incubation": "incubator.prcxi", + "move_labware": "labware_mover.prcxi", + "oscillation": "shaker.prcxi", + # 有机化学操作 + "HeatChillToTemp": "heatchill.chemputer", + "StopHeatChill": "heatchill.chemputer", + "StartHeatChill": "heatchill.chemputer", + "HeatChill": "heatchill.chemputer", + "Dissolve": "stirrer.chemputer", + "Transfer": "liquid_handler.chemputer", + "Evaporate": "rotavap.chemputer", + "Recrystallize": "reactor.chemputer", + "Filter": "filter.chemputer", + "Dry": "dryer.chemputer", + "Add": "liquid_handler.chemputer", +} + +# action_args 字段到 parameters 字段的映射 +# 格式: {"old_key": "new_key"}, 仅映射需要重命名的字段 +ARGS_FIELD_MAPPING: Dict[str, str] = { + # 如果需要字段重命名,在这里配置 + # "old_field_name": "new_field_name", +} + +# 默认工作站名称 +DEFAULT_WORKSTATION = "PRCXI" + + +# ==================== 核心转换函数 ==================== + + def get_action_handles(resource_name: str, template_name: str) -> Dict[str, List[str]]: """ 从 registry 获取指定设备和动作的 handles 配置 @@ -39,12 +86,10 @@ def get_action_handles(resource_name: str, template_name: str) -> Dict[str, List handles = action_config.get("handles", {}) if isinstance(handles, dict): - # 处理 input handles (作为 target) for handle in handles.get("input", []): handler_key = handle.get("handler_key", "") if handler_key: result["source"].append(handler_key) - # 处理 output handles (作为 source) for handle in handles.get("output", []): handler_key = handle.get("handler_key", "") if handler_key: @@ -69,12 +114,9 @@ def validate_workflow_handles(graph: WorkflowGraph) -> Tuple[bool, List[str]]: for edge in graph.edges: left_uuid = edge.get("source") right_uuid = edge.get("target") - # target_handle_key是target, right的输入节点(入节点) - # source_handle_key是source, left的输出节点(出节点) right_source_conn_key = edge.get("target_handle_key", "") left_target_conn_key = edge.get("source_handle_key", "") - # 获取源节点和目标节点信息 left_node = nodes.get(left_uuid, {}) right_node = nodes.get(right_uuid, {}) @@ -83,164 +125,93 @@ def validate_workflow_handles(graph: WorkflowGraph) -> Tuple[bool, List[str]]: right_res_name = right_node.get("resource_name", "") right_template_name = right_node.get("template_name", "") - # 获取源节点的 output handles left_node_handles = get_action_handles(left_res_name, left_template_name) target_valid_keys = left_node_handles.get("target", []) target_valid_keys.append("ready") - # 获取目标节点的 input handles right_node_handles = get_action_handles(right_res_name, right_template_name) source_valid_keys = right_node_handles.get("source", []) source_valid_keys.append("ready") - # 如果节点配置了 output handles,则 source_port 必须有效 + # 验证目标节点(right)的输入端口 if not right_source_conn_key: - node_name = left_node.get("name", left_uuid[:8]) - errors.append(f"源节点 '{node_name}' 的 source_handle_key 为空," f"应设置为: {source_valid_keys}") + node_name = right_node.get("name", right_uuid[:8]) + errors.append(f"目标节点 '{node_name}' 的输入端口 (target_handle_key) 为空,应设置为: {source_valid_keys}") elif right_source_conn_key not in source_valid_keys: - node_name = left_node.get("name", left_uuid[:8]) + node_name = right_node.get("name", right_uuid[:8]) errors.append( - f"源节点 '{node_name}' 的 source 端点 '{right_source_conn_key}' 不存在," f"支持的端点: {source_valid_keys}" + f"目标节点 '{node_name}' 的输入端口 '{right_source_conn_key}' 不存在,支持的输入端口: {source_valid_keys}" ) - # 如果节点配置了 input handles,则 target_port 必须有效 + # 验证源节点(left)的输出端口 if not left_target_conn_key: - node_name = right_node.get("name", right_uuid[:8]) - errors.append(f"目标节点 '{node_name}' 的 target_handle_key 为空," f"应设置为: {target_valid_keys}") + node_name = left_node.get("name", left_uuid[:8]) + errors.append(f"源节点 '{node_name}' 的输出端口 (source_handle_key) 为空,应设置为: {target_valid_keys}") elif left_target_conn_key not in target_valid_keys: - node_name = right_node.get("name", right_uuid[:8]) + node_name = left_node.get("name", left_uuid[:8]) errors.append( - f"目标节点 '{node_name}' 的 target 端点 '{left_target_conn_key}' 不存在," - f"支持的端点: {target_valid_keys}" + f"源节点 '{node_name}' 的输出端口 '{left_target_conn_key}' 不存在,支持的输出端口: {target_valid_keys}" ) return len(errors) == 0, errors -# action 到 resource_name 的映射 -ACTION_RESOURCE_MAPPING: Dict[str, str] = { - # 生物实验操作 - "transfer_liquid": "liquid_handler.prcxi", - "transfer": "liquid_handler.prcxi", - "incubation": "incubator.prcxi", - "move_labware": "labware_mover.prcxi", - "oscillation": "shaker.prcxi", - # 有机化学操作 - "HeatChillToTemp": "heatchill.chemputer", - "StopHeatChill": "heatchill.chemputer", - "StartHeatChill": "heatchill.chemputer", - "HeatChill": "heatchill.chemputer", - "Dissolve": "stirrer.chemputer", - "Transfer": "liquid_handler.chemputer", - "Evaporate": "rotavap.chemputer", - "Recrystallize": "reactor.chemputer", - "Filter": "filter.chemputer", - "Dry": "dryer.chemputer", - "Add": "liquid_handler.chemputer", -} - - -def normalize_steps(data: List[Dict[str, Any]]) -> List[Dict[str, Any]]: +def normalize_workflow_steps(workflow: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """ - 将不同格式的步骤数据规范化为统一格式 + 将 workflow 格式的步骤数据规范化 - 支持的输入格式: - - action + parameters - - action + action_args - - operation + parameters + 输入格式: + [{"action": "...", "action_args": {...}}, ...] + + 输出格式: + [{"action": "...", "parameters": {...}, "step_number": int}, ...] Args: - data: 原始步骤数据列表 + workflow: workflow 数组 Returns: - 规范化后的步骤列表,格式为 [{"action": str, "parameters": dict, "description": str?, "step_number": int?}, ...] + 规范化后的步骤列表 """ normalized = [] - for idx, step in enumerate(data): - # 获取动作名称(支持 action 或 operation 字段) - action = step.get("action") or step.get("operation") + for idx, step in enumerate(workflow): + action = step.get("action") if not action: continue - # 获取参数(支持 parameters 或 action_args 字段) - raw_params = step.get("parameters") or step.get("action_args") or {} - params = dict(raw_params) - - # 规范化 source/target -> sources/targets - if "source" in raw_params and "sources" not in raw_params: - params["sources"] = raw_params["source"] - if "target" in raw_params and "targets" not in raw_params: - params["targets"] = raw_params["target"] + # 获取参数: action_args + raw_params = step.get("action_args", {}) + params = {} - # 获取描述(支持 description 或 purpose 字段) - description = step.get("description") or step.get("purpose") + # 应用字段映射 + for key, value in raw_params.items(): + mapped_key = ARGS_FIELD_MAPPING.get(key, key) + params[mapped_key] = value - # 获取步骤编号(优先使用原始数据中的 step_number,否则使用索引+1) - step_number = step.get("step_number", idx + 1) + step_dict = { + "action": action, + "parameters": params, + "step_number": idx + 1, + } - step_dict = {"action": action, "parameters": params, "step_number": step_number} - if description: - step_dict["description"] = description + # 保留描述字段 + if "description" in step: + step_dict["description"] = step["description"] normalized.append(step_dict) return normalized -def normalize_labware(data: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]: - """ - 将不同格式的 labware 数据规范化为统一的字典格式 - - 支持的输入格式: - - reagent_name + material_name + positions - - name + labware + slot - - Args: - data: 原始 labware 数据列表 - - Returns: - 规范化后的 labware 字典,格式为 {name: {"slot": int, "labware": str, "well": list, "type": str, "role": str, "name": str}, ...} - """ - labware = {} - for item in data: - # 获取 key 名称(优先使用 reagent_name,其次是 material_name 或 name) - reagent_name = item.get("reagent_name") - key = reagent_name or item.get("material_name") or item.get("name") - if not key: - continue - - key = str(key) - - # 处理重复 key,自动添加后缀 - idx = 1 - original_key = key - while key in labware: - idx += 1 - key = f"{original_key}_{idx}" - - labware[key] = { - "slot": item.get("positions") or item.get("slot"), - "labware": item.get("material_name") or item.get("labware"), - "well": item.get("well", []), - "type": item.get("type", "reagent"), - "role": item.get("role", ""), - "name": key, - } - - return labware - - def convert_from_json( data: Union[str, PathLike, Dict[str, Any]], - workstation_name: str = "PRCXi", + workstation_name: str = DEFAULT_WORKSTATION, validate: bool = True, ) -> WorkflowGraph: """ 从 JSON 数据或文件转换为 WorkflowGraph - 支持的 JSON 格式: - 1. {"workflow": [...], "reagent": {...}} - 直接格式 - 2. {"steps_info": [...], "labware_info": [...]} - 需要规范化的格式 + JSON 格式: + {"workflow": [...], "reagent": {...}} Args: data: JSON 文件路径、字典数据、或 JSON 字符串 @@ -251,7 +222,7 @@ def convert_from_json( WorkflowGraph: 构建好的工作流图 Raises: - ValueError: 不支持的 JSON 格式 或 句柄校验失败 + ValueError: 不支持的 JSON 格式 FileNotFoundError: 文件不存在 json.JSONDecodeError: JSON 解析失败 """ @@ -262,7 +233,6 @@ def convert_from_json( with path.open("r", encoding="utf-8") as fp: json_data = json.load(fp) elif isinstance(data, str): - # 尝试作为 JSON 字符串解析 json_data = json.loads(data) else: raise FileNotFoundError(f"文件不存在: {data}") @@ -271,30 +241,24 @@ def convert_from_json( else: raise TypeError(f"不支持的数据类型: {type(data)}") - # 根据格式解析数据 - if "workflow" in json_data and "reagent" in json_data: - # 格式1: workflow/reagent(已经是规范格式) - protocol_steps = json_data["workflow"] - labware_info = json_data["reagent"] - elif "steps_info" in json_data and "labware_info" in json_data: - # 格式2: steps_info/labware_info(需要规范化) - protocol_steps = normalize_steps(json_data["steps_info"]) - labware_info = normalize_labware(json_data["labware_info"]) - elif "steps" in json_data and "labware" in json_data: - # 格式3: steps/labware(另一种常见格式) - protocol_steps = normalize_steps(json_data["steps"]) - if isinstance(json_data["labware"], list): - labware_info = normalize_labware(json_data["labware"]) - else: - labware_info = json_data["labware"] - else: + # 校验格式 + if "workflow" not in json_data or "reagent" not in json_data: raise ValueError( - "不支持的 JSON 格式。支持的格式:\n" - "1. {'workflow': [...], 'reagent': {...}}\n" - "2. {'steps_info': [...], 'labware_info': [...]}\n" - "3. {'steps': [...], 'labware': [...]}" + "不支持的 JSON 格式。请使用标准格式:\n" + '{"workflow": [{"action": "...", "action_args": {...}}, ...], ' + '"reagent": {"name": {"slot": int, "well": [...], "labware": "..."}, ...}}' ) + # 提取数据 + workflow = json_data["workflow"] + reagent = json_data["reagent"] + + # 规范化步骤数据 + protocol_steps = normalize_workflow_steps(workflow) + + # reagent 已经是字典格式,直接使用 + labware_info = reagent + # 构建工作流图 graph = build_protocol_graph( labware_info=labware_info, @@ -317,7 +281,7 @@ def convert_from_json( def convert_json_to_node_link( data: Union[str, PathLike, Dict[str, Any]], - workstation_name: str = "PRCXi", + workstation_name: str = DEFAULT_WORKSTATION, ) -> Dict[str, Any]: """ 将 JSON 数据转换为 node-link 格式的字典 @@ -335,7 +299,7 @@ def convert_json_to_node_link( def convert_json_to_workflow_list( data: Union[str, PathLike, Dict[str, Any]], - workstation_name: str = "PRCXi", + workstation_name: str = DEFAULT_WORKSTATION, ) -> List[Dict[str, Any]]: """ 将 JSON 数据转换为工作流列表格式 @@ -349,8 +313,3 @@ def convert_json_to_workflow_list( """ graph = convert_from_json(data, workstation_name) return graph.to_dict() - - -# 为了向后兼容,保留下划线前缀的别名 -_normalize_steps = normalize_steps -_normalize_labware = normalize_labware diff --git a/unilabos/workflow/legacy/convert_from_json_legacy.py b/unilabos/workflow/legacy/convert_from_json_legacy.py new file mode 100644 index 000000000..7a6d2b40b --- /dev/null +++ b/unilabos/workflow/legacy/convert_from_json_legacy.py @@ -0,0 +1,356 @@ +""" +JSON 工作流转换模块 + +提供从多种 JSON 格式转换为统一工作流格式的功能。 +支持的格式: +1. workflow/reagent 格式 +2. steps_info/labware_info 格式 +""" + +import json +from os import PathLike +from pathlib import Path +from typing import Any, Dict, List, Optional, Set, Tuple, Union + +from unilabos.workflow.common import WorkflowGraph, build_protocol_graph +from unilabos.registry.registry import lab_registry + + +def get_action_handles(resource_name: str, template_name: str) -> Dict[str, List[str]]: + """ + 从 registry 获取指定设备和动作的 handles 配置 + + Args: + resource_name: 设备资源名称,如 "liquid_handler.prcxi" + template_name: 动作模板名称,如 "transfer_liquid" + + Returns: + 包含 source 和 target handler_keys 的字典: + {"source": ["sources_out", "targets_out", ...], "target": ["sources", "targets", ...]} + """ + result = {"source": [], "target": []} + + device_info = lab_registry.device_type_registry.get(resource_name, {}) + if not device_info: + return result + + action_mappings = device_info.get("class", {}).get("action_value_mappings", {}) + action_config = action_mappings.get(template_name, {}) + handles = action_config.get("handles", {}) + + if isinstance(handles, dict): + # 处理 input handles (作为 target) + for handle in handles.get("input", []): + handler_key = handle.get("handler_key", "") + if handler_key: + result["source"].append(handler_key) + # 处理 output handles (作为 source) + for handle in handles.get("output", []): + handler_key = handle.get("handler_key", "") + if handler_key: + result["target"].append(handler_key) + + return result + + +def validate_workflow_handles(graph: WorkflowGraph) -> Tuple[bool, List[str]]: + """ + 校验工作流图中所有边的句柄配置是否正确 + + Args: + graph: 工作流图对象 + + Returns: + (is_valid, errors): 是否有效,错误信息列表 + """ + errors = [] + nodes = graph.nodes + + for edge in graph.edges: + left_uuid = edge.get("source") + right_uuid = edge.get("target") + # target_handle_key是target, right的输入节点(入节点) + # source_handle_key是source, left的输出节点(出节点) + right_source_conn_key = edge.get("target_handle_key", "") + left_target_conn_key = edge.get("source_handle_key", "") + + # 获取源节点和目标节点信息 + left_node = nodes.get(left_uuid, {}) + right_node = nodes.get(right_uuid, {}) + + left_res_name = left_node.get("resource_name", "") + left_template_name = left_node.get("template_name", "") + right_res_name = right_node.get("resource_name", "") + right_template_name = right_node.get("template_name", "") + + # 获取源节点的 output handles + left_node_handles = get_action_handles(left_res_name, left_template_name) + target_valid_keys = left_node_handles.get("target", []) + target_valid_keys.append("ready") + + # 获取目标节点的 input handles + right_node_handles = get_action_handles(right_res_name, right_template_name) + source_valid_keys = right_node_handles.get("source", []) + source_valid_keys.append("ready") + + # 如果节点配置了 output handles,则 source_port 必须有效 + if not right_source_conn_key: + node_name = left_node.get("name", left_uuid[:8]) + errors.append(f"源节点 '{node_name}' 的 source_handle_key 为空," f"应设置为: {source_valid_keys}") + elif right_source_conn_key not in source_valid_keys: + node_name = left_node.get("name", left_uuid[:8]) + errors.append( + f"源节点 '{node_name}' 的 source 端点 '{right_source_conn_key}' 不存在," f"支持的端点: {source_valid_keys}" + ) + + # 如果节点配置了 input handles,则 target_port 必须有效 + if not left_target_conn_key: + node_name = right_node.get("name", right_uuid[:8]) + errors.append(f"目标节点 '{node_name}' 的 target_handle_key 为空," f"应设置为: {target_valid_keys}") + elif left_target_conn_key not in target_valid_keys: + node_name = right_node.get("name", right_uuid[:8]) + errors.append( + f"目标节点 '{node_name}' 的 target 端点 '{left_target_conn_key}' 不存在," + f"支持的端点: {target_valid_keys}" + ) + + return len(errors) == 0, errors + + +# action 到 resource_name 的映射 +ACTION_RESOURCE_MAPPING: Dict[str, str] = { + # 生物实验操作 + "transfer_liquid": "liquid_handler.prcxi", + "transfer": "liquid_handler.prcxi", + "incubation": "incubator.prcxi", + "move_labware": "labware_mover.prcxi", + "oscillation": "shaker.prcxi", + # 有机化学操作 + "HeatChillToTemp": "heatchill.chemputer", + "StopHeatChill": "heatchill.chemputer", + "StartHeatChill": "heatchill.chemputer", + "HeatChill": "heatchill.chemputer", + "Dissolve": "stirrer.chemputer", + "Transfer": "liquid_handler.chemputer", + "Evaporate": "rotavap.chemputer", + "Recrystallize": "reactor.chemputer", + "Filter": "filter.chemputer", + "Dry": "dryer.chemputer", + "Add": "liquid_handler.chemputer", +} + + +def normalize_steps(data: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + 将不同格式的步骤数据规范化为统一格式 + + 支持的输入格式: + - action + parameters + - action + action_args + - operation + parameters + + Args: + data: 原始步骤数据列表 + + Returns: + 规范化后的步骤列表,格式为 [{"action": str, "parameters": dict, "description": str?, "step_number": int?}, ...] + """ + normalized = [] + for idx, step in enumerate(data): + # 获取动作名称(支持 action 或 operation 字段) + action = step.get("action") or step.get("operation") + if not action: + continue + + # 获取参数(支持 parameters 或 action_args 字段) + raw_params = step.get("parameters") or step.get("action_args") or {} + params = dict(raw_params) + + # 规范化 source/target -> sources/targets + if "source" in raw_params and "sources" not in raw_params: + params["sources"] = raw_params["source"] + if "target" in raw_params and "targets" not in raw_params: + params["targets"] = raw_params["target"] + + # 获取描述(支持 description 或 purpose 字段) + description = step.get("description") or step.get("purpose") + + # 获取步骤编号(优先使用原始数据中的 step_number,否则使用索引+1) + step_number = step.get("step_number", idx + 1) + + step_dict = {"action": action, "parameters": params, "step_number": step_number} + if description: + step_dict["description"] = description + + normalized.append(step_dict) + + return normalized + + +def normalize_labware(data: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]: + """ + 将不同格式的 labware 数据规范化为统一的字典格式 + + 支持的输入格式: + - reagent_name + material_name + positions + - name + labware + slot + + Args: + data: 原始 labware 数据列表 + + Returns: + 规范化后的 labware 字典,格式为 {name: {"slot": int, "labware": str, "well": list, "type": str, "role": str, "name": str}, ...} + """ + labware = {} + for item in data: + # 获取 key 名称(优先使用 reagent_name,其次是 material_name 或 name) + reagent_name = item.get("reagent_name") + key = reagent_name or item.get("material_name") or item.get("name") + if not key: + continue + + key = str(key) + + # 处理重复 key,自动添加后缀 + idx = 1 + original_key = key + while key in labware: + idx += 1 + key = f"{original_key}_{idx}" + + labware[key] = { + "slot": item.get("positions") or item.get("slot"), + "labware": item.get("material_name") or item.get("labware"), + "well": item.get("well", []), + "type": item.get("type", "reagent"), + "role": item.get("role", ""), + "name": key, + } + + return labware + + +def convert_from_json( + data: Union[str, PathLike, Dict[str, Any]], + workstation_name: str = "PRCXi", + validate: bool = True, +) -> WorkflowGraph: + """ + 从 JSON 数据或文件转换为 WorkflowGraph + + 支持的 JSON 格式: + 1. {"workflow": [...], "reagent": {...}} - 直接格式 + 2. {"steps_info": [...], "labware_info": [...]} - 需要规范化的格式 + + Args: + data: JSON 文件路径、字典数据、或 JSON 字符串 + workstation_name: 工作站名称,默认 "PRCXi" + validate: 是否校验句柄配置,默认 True + + Returns: + WorkflowGraph: 构建好的工作流图 + + Raises: + ValueError: 不支持的 JSON 格式 或 句柄校验失败 + FileNotFoundError: 文件不存在 + json.JSONDecodeError: JSON 解析失败 + """ + # 处理输入数据 + if isinstance(data, (str, PathLike)): + path = Path(data) + if path.exists(): + with path.open("r", encoding="utf-8") as fp: + json_data = json.load(fp) + elif isinstance(data, str): + # 尝试作为 JSON 字符串解析 + json_data = json.loads(data) + else: + raise FileNotFoundError(f"文件不存在: {data}") + elif isinstance(data, dict): + json_data = data + else: + raise TypeError(f"不支持的数据类型: {type(data)}") + + # 根据格式解析数据 + if "workflow" in json_data and "reagent" in json_data: + # 格式1: workflow/reagent(已经是规范格式) + protocol_steps = json_data["workflow"] + labware_info = json_data["reagent"] + elif "steps_info" in json_data and "labware_info" in json_data: + # 格式2: steps_info/labware_info(需要规范化) + protocol_steps = normalize_steps(json_data["steps_info"]) + labware_info = normalize_labware(json_data["labware_info"]) + elif "steps" in json_data and "labware" in json_data: + # 格式3: steps/labware(另一种常见格式) + protocol_steps = normalize_steps(json_data["steps"]) + if isinstance(json_data["labware"], list): + labware_info = normalize_labware(json_data["labware"]) + else: + labware_info = json_data["labware"] + else: + raise ValueError( + "不支持的 JSON 格式。支持的格式:\n" + "1. {'workflow': [...], 'reagent': {...}}\n" + "2. {'steps_info': [...], 'labware_info': [...]}\n" + "3. {'steps': [...], 'labware': [...]}" + ) + + # 构建工作流图 + graph = build_protocol_graph( + labware_info=labware_info, + protocol_steps=protocol_steps, + workstation_name=workstation_name, + action_resource_mapping=ACTION_RESOURCE_MAPPING, + ) + + # 校验句柄配置 + if validate: + is_valid, errors = validate_workflow_handles(graph) + if not is_valid: + import warnings + + for error in errors: + warnings.warn(f"句柄校验警告: {error}") + + return graph + + +def convert_json_to_node_link( + data: Union[str, PathLike, Dict[str, Any]], + workstation_name: str = "PRCXi", +) -> Dict[str, Any]: + """ + 将 JSON 数据转换为 node-link 格式的字典 + + Args: + data: JSON 文件路径、字典数据、或 JSON 字符串 + workstation_name: 工作站名称,默认 "PRCXi" + + Returns: + Dict: node-link 格式的工作流数据 + """ + graph = convert_from_json(data, workstation_name) + return graph.to_node_link_dict() + + +def convert_json_to_workflow_list( + data: Union[str, PathLike, Dict[str, Any]], + workstation_name: str = "PRCXi", +) -> List[Dict[str, Any]]: + """ + 将 JSON 数据转换为工作流列表格式 + + Args: + data: JSON 文件路径、字典数据、或 JSON 字符串 + workstation_name: 工作站名称,默认 "PRCXi" + + Returns: + List: 工作流节点列表 + """ + graph = convert_from_json(data, workstation_name) + return graph.to_dict() + + +# 为了向后兼容,保留下划线前缀的别名 +_normalize_steps = normalize_steps +_normalize_labware = normalize_labware diff --git a/unilabos_msgs/package.xml b/unilabos_msgs/package.xml index b9c263269..68ad13286 100644 --- a/unilabos_msgs/package.xml +++ b/unilabos_msgs/package.xml @@ -2,7 +2,7 @@ unilabos_msgs - 0.10.15 + 0.10.17 ROS2 Messages package for unilabos devices Junhan Chang Xuwznln From f8a52860ad8f39d3fc6179ede1025d67a5fcc152 Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Tue, 3 Feb 2026 10:25:47 +0800 Subject: [PATCH 07/56] Add BIOYOND deck imports and update JSON configurations with new UUIDs for various components --- unilabos/resources/plr_additional_res_reg.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/unilabos/resources/plr_additional_res_reg.py b/unilabos/resources/plr_additional_res_reg.py index 1c019dedf..4dc1c4b55 100644 --- a/unilabos/resources/plr_additional_res_reg.py +++ b/unilabos/resources/plr_additional_res_reg.py @@ -18,3 +18,9 @@ def register(): from unilabos.devices.liquid_handling.rviz_backend import UniLiquidHandlerRvizBackend from unilabos.devices.liquid_handling.laiyu.backend.laiyu_v_backend import UniLiquidHandlerLaiyuBackend + # noinspection PyUnresolvedReferences + from unilabos.resources.bioyond.decks import ( + BIOYOND_PolymerReactionStation_Deck, + BIOYOND_PolymerPreparationStation_Deck, + BIOYOND_YB_Deck, + ) From 15ff0e9d30ff3d82b8a84e97cf30600cb5baf549 Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Tue, 3 Feb 2026 10:28:51 +0800 Subject: [PATCH 08/56] feat: add Bioyond deck imports to resource registration --- unilabos/resources/plr_additional_res_reg.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/unilabos/resources/plr_additional_res_reg.py b/unilabos/resources/plr_additional_res_reg.py index 1c019dedf..4dc1c4b55 100644 --- a/unilabos/resources/plr_additional_res_reg.py +++ b/unilabos/resources/plr_additional_res_reg.py @@ -18,3 +18,9 @@ def register(): from unilabos.devices.liquid_handling.rviz_backend import UniLiquidHandlerRvizBackend from unilabos.devices.liquid_handling.laiyu.backend.laiyu_v_backend import UniLiquidHandlerLaiyuBackend + # noinspection PyUnresolvedReferences + from unilabos.resources.bioyond.decks import ( + BIOYOND_PolymerReactionStation_Deck, + BIOYOND_PolymerPreparationStation_Deck, + BIOYOND_YB_Deck, + ) From 47a29a0c2f16dd29d0683ee99ae012bad49dd1da Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:54:31 +0800 Subject: [PATCH 09/56] add:skill&agent --- .cursor/skills/add-device/SKILL.md | 24 + .cursor/skills/add-protocol/SKILL.md | 323 +++++ .cursor/skills/add-protocol/reference.md | 207 ++++ .cursor/skills/add-resource/SKILL.md | 371 ++++++ .cursor/skills/add-resource/reference.md | 292 +++++ .cursor/skills/add-workstation/SKILL.md | 500 ++++++++ .cursor/skills/add-workstation/reference.md | 371 ++++++ .cursor/skills/edit-experiment-graph/SKILL.md | 381 ++++++ .../skills/edit-experiment-graph/reference.md | 255 ++++ .github/copilot-instructions.md | 11 + AGENTS.md | 21 + CLAUDE.md | 14 + docs/ai_guides/add_device.md | 1100 +++++++++++++++++ docs/ai_guides/agent_prompt_template.md | 344 ++++++ 14 files changed, 4214 insertions(+) create mode 100644 .cursor/skills/add-device/SKILL.md create mode 100644 .cursor/skills/add-protocol/SKILL.md create mode 100644 .cursor/skills/add-protocol/reference.md create mode 100644 .cursor/skills/add-resource/SKILL.md create mode 100644 .cursor/skills/add-resource/reference.md create mode 100644 .cursor/skills/add-workstation/SKILL.md create mode 100644 .cursor/skills/add-workstation/reference.md create mode 100644 .cursor/skills/edit-experiment-graph/SKILL.md create mode 100644 .cursor/skills/edit-experiment-graph/reference.md create mode 100644 .github/copilot-instructions.md create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 docs/ai_guides/add_device.md create mode 100644 docs/ai_guides/agent_prompt_template.md diff --git a/.cursor/skills/add-device/SKILL.md b/.cursor/skills/add-device/SKILL.md new file mode 100644 index 000000000..d2272030a --- /dev/null +++ b/.cursor/skills/add-device/SKILL.md @@ -0,0 +1,24 @@ +--- +name: add-device +description: Guide for adding new devices to Uni-Lab-OS (接入新设备). Walks through device category selection (thing model), communication protocol, command protocol collection, driver creation, registry YAML, and graph file setup. Use when the user wants to add/integrate a new device, create a device driver, write a device class, configure device registry, or mentions 接入设备/添加设备/设备驱动/物模型. +--- + +# 添加新设备到 Uni-Lab-OS + +**第一步:** 使用 Read 工具读取 `docs/ai_guides/add_device.md`,获取完整的设备接入指南并严格遵循。 + +该指南包含: +- 8 步完整流程(设备类别、通信协议、指令收集、接口对齐、驱动创建、注册表、图文件、验证) +- 所有物模型代码模板(注射泵、电磁阀、蠕动泵、温控、电机等) +- 通信协议代码片段(Serial、Modbus、TCP、HTTP、OPC UA) +- 现有设备接口快照(用于第四步对齐,包含参数名、status_types、方法签名) +- 常见错误检查清单 + +**Cursor 工具映射:** + +| 指南中的操作 | Cursor 中使用的工具 | +|---|---| +| 向用户确认设备类别、协议等信息 | 使用 AskQuestion 工具 | +| 搜索已有设备注册表 | 使用 Grep 在 `unilabos/registry/devices/` 中搜索 | +| 读取用户提供的协议文档/SDK 代码 | 使用 Read 工具 | +| 第四步对齐:查找同类设备接口 | 优先使用 Grep 搜索仓库中的最新注册表;指南中的「现有设备接口快照」作为兜底参考 | diff --git a/.cursor/skills/add-protocol/SKILL.md b/.cursor/skills/add-protocol/SKILL.md new file mode 100644 index 000000000..2537051b1 --- /dev/null +++ b/.cursor/skills/add-protocol/SKILL.md @@ -0,0 +1,323 @@ +--- +name: add-protocol +description: Guide for adding new experiment protocols to Uni-Lab-OS (添加新实验操作协议). Walks through ROS Action definition, Pydantic model creation, protocol generator implementation, and registration. Use when the user wants to add a new protocol, create a compile function, implement an experiment operation, or mentions 协议/protocol/编译/compile/实验操作. +--- + +# 添加新实验操作协议(Protocol) + +Protocol 是对实验有意义的完整动作(如泵转移、过滤、溶解),需要多设备协同。`compile/` 中的生成函数根据设备连接图将抽象操作"编译"为设备指令序列。 + +添加一个 Protocol 需修改 **6 个文件**,按以下流程执行。 + +--- + +## 第一步:确认协议信息 + +向用户确认: + +| 信息 | 示例 | +|------|------| +| 协议英文名 | `MyNewProtocol` | +| 操作描述 | 将固体样品研磨至目标粒径 | +| Goal 参数(必需 + 可选) | `vessel: dict`, `time: float = 300.0` | +| Result 字段 | `success: bool`, `message: str` | +| 需要哪些设备协同 | 研磨器、搅拌器 | + +--- + +## 第二步:创建 ROS Action 定义 + +路径:`unilabos_msgs/action/.action` + +三段式结构(Goal / Result / Feedback),用 `---` 分隔: + +``` +# Goal +Resource vessel +float64 time +string mode +--- +# Result +bool success +string return_info +--- +# Feedback +string status +string current_device +builtin_interfaces/Duration time_spent +builtin_interfaces/Duration time_remaining +``` + +**类型映射:** + +| Python 类型 | ROS 类型 | 说明 | +|------------|----------|------| +| `dict` | `Resource` | 容器/设备引用,自定义消息类型 | +| `float` | `float64` | | +| `int` | `int32` | | +| `str` | `string` | | +| `bool` | `bool` | | + +> `Resource` 是 `unilabos_msgs/msg/Resource.msg` 中定义的自定义消息类型。 + +--- + +## 第三步:注册 Action 到 CMakeLists + +在 `unilabos_msgs/CMakeLists.txt` 的 `set(action_files ...)` 块中添加: + +```cmake +"action/MyNewAction.action" +``` + +> 调试时需编译:`cd unilabos_msgs && colcon build && source ./install/local_setup.sh && cd ..` +> PR 合并后 CI/CD 自动发布,`mamba update ros-humble-unilabos-msgs` 即可。 + +--- + +## 第四步:创建 Pydantic 模型 + +在 `unilabos/messages/__init__.py` 中添加(位于 `# Start Protocols` 和 `# End Protocols` 之间): + +```python +class MyNewProtocol(BaseModel): + # === 必需参数 === + vessel: dict = Field(..., description="目标容器") + + # === 可选参数 === + time: float = Field(300.0, description="操作时间 (秒)") + mode: str = Field("default", description="操作模式") + + def model_post_init(self, __context): + """参数验证和修正""" + if self.time <= 0: + self.time = 300.0 +``` + +**规则:** +- 参数名必须与 `.action` 文件中 Goal 字段完全一致 +- `dict` 类型对应 `.action` 中的 `Resource` +- 将类名加入文件末尾的 `__all__` 列表 + +--- + +## 第五步:实现协议生成函数 + +路径:`unilabos/compile/_protocol.py` + +```python +import networkx as nx +from typing import List, Dict, Any + + +def generate_my_new_protocol( + G: nx.DiGraph, + vessel: dict, + time: float = 300.0, + mode: str = "default", + **kwargs, +) -> List[Dict[str, Any]]: + """将 MyNewProtocol 编译为设备动作序列。 + + Args: + G: 设备连接图(NetworkX),节点为设备/容器,边为物理连接 + vessel: 目标容器 {"id": "reactor_1"} + time: 操作时间(秒) + mode: 操作模式 + + Returns: + 动作列表,每个元素为: + - dict: 单步动作 + - list[dict]: 并行动作 + """ + from unilabos.compile.utils.vessel_parser import get_vessel + + vessel_id, vessel_data = get_vessel(vessel) + actions = [] + + # 查找相关设备(通过图的连接关系) + # 生成动作序列 + actions.append({ + "device_id": "target_device_id", + "action_name": "some_action", + "action_kwargs": {"param": "value"} + }) + + # 等待 + actions.append({ + "action_name": "wait", + "action_kwargs": {"time": time} + }) + + return actions +``` + +### 动作字典格式 + +```python +# 单步动作(发给子设备) +{"device_id": "pump_1", "action_name": "set_position", "action_kwargs": {"position": 10.0}} + +# 发给工作站自身 +{"device_id": "self", "action_name": "my_action", "action_kwargs": {...}} + +# 等待 +{"action_name": "wait", "action_kwargs": {"time": 5.0}} + +# 并行动作(列表嵌套) +[ + {"device_id": "pump_1", "action_name": "set_position", "action_kwargs": {"position": 10.0}}, + {"device_id": "stirrer_1", "action_name": "start_stir", "action_kwargs": {"stir_speed": 300}} +] +``` + +### 关于 `vessel` 参数类型 + +现有协议的 `vessel` 参数类型不统一: +- 新协议趋势:使用 `dict`(如 `{"id": "reactor_1"}`) +- 旧协议:使用 `str`(如 `"reactor_1"`) +- 兼容写法:`Union[str, dict]` + +**建议新协议统一使用 `dict` 类型**,通过 `get_vessel()` 兼容两种输入。 + +### 公共工具函数(`unilabos/compile/utils/`) + +| 函数 | 用途 | +|------|------| +| `get_vessel(vessel)` | 解析容器参数为 `(vessel_id, vessel_data)`,兼容 dict 和 str | +| `find_solvent_vessel(G, solvent)` | 根据溶剂名查找容器(精确→命名规则→模糊→液体类型) | +| `find_reagent_vessel(G, reagent)` | 根据试剂名查找容器(支持固体和液体) | +| `find_connected_stirrer(G, vessel)` | 查找与容器相连的搅拌器 | +| `find_solid_dispenser(G)` | 查找固体加样器 | + +### 协议内专属查找函数 + +许多协议在自己的文件内定义了专属的 `find_*` 函数(不在 `utils/` 中)。编写新协议时,优先复用 `utils/` 中的公共函数;如需特殊查找逻辑,在协议文件内部定义即可: + +```python +def find_my_special_device(G: nx.DiGraph, vessel: str) -> str: + """查找与容器相关的特殊设备""" + for node in G.nodes(): + if 'my_device_type' in G.nodes[node].get('class', '').lower(): + return node + raise ValueError("未找到特殊设备") +``` + +### 复用已有协议 + +复杂协议通常组合已有协议: + +```python +from unilabos.compile.pump_protocol import generate_pump_protocol_with_rinsing + +actions.extend(generate_pump_protocol_with_rinsing( + G, from_vessel=solvent_vessel, to_vessel=vessel, volume=volume +)) +``` + +### 图查询模式 + +```python +# 查找与容器相连的特定类型设备 +for neighbor in G.neighbors(vessel_id): + node_data = G.nodes[neighbor] + if "heater" in node_data.get("class", ""): + heater_id = neighbor + break + +# 查找最短路径(泵转移) +path = nx.shortest_path(G, source=from_vessel_id, target=to_vessel_id) +``` + +--- + +## 第六步:注册协议生成函数 + +在 `unilabos/compile/__init__.py` 中: + +1. 顶部添加导入: + +```python +from .my_new_protocol import generate_my_new_protocol +``` + +2. 在 `action_protocol_generators` 字典中添加映射: + +```python +action_protocol_generators = { + # ... 已有协议 + MyNewProtocol: generate_my_new_protocol, +} +``` + +--- + +## 第七步:配置图文件 + +在工作站的图文件中,将协议名加入 `protocol_type`: + +```json +{ + "id": "my_station", + "class": "workstation", + "config": { + "protocol_type": ["PumpTransferProtocol", "MyNewProtocol"] + } +} +``` + +--- + +## 第八步:验证 + +```bash +# 1. 模块可导入 +python -c "from unilabos.messages import MyNewProtocol; print(MyNewProtocol.model_fields)" + +# 2. 生成函数可导入 +python -c "from unilabos.compile import action_protocol_generators; print(list(action_protocol_generators.keys()))" + +# 3. 启动测试(可选) +unilab -g .json --complete_registry +``` + +--- + +## 工作流清单 + +``` +协议接入进度: +- [ ] 1. 确认协议名、参数、涉及设备 +- [ ] 2. 创建 .action 文件 (unilabos_msgs/action/.action) +- [ ] 3. 注册到 CMakeLists.txt +- [ ] 4. 创建 Pydantic 模型 (unilabos/messages/__init__.py) + 更新 __all__ +- [ ] 5. 实现生成函数 (unilabos/compile/_protocol.py) +- [ ] 6. 注册到 compile/__init__.py +- [ ] 7. 配置图文件 protocol_type +- [ ] 8. 验证 +``` + +--- + +## 高级模式 + +实现复杂协议时,详见 [reference.md](reference.md):协议运行时数据流、mock graph 测试模式、单位解析工具(`unit_parser.py`)、复杂协议组合模式(以 dissolve 为例)。 + +--- + +## 现有协议速查 + +| 协议 | Pydantic 类 | 生成函数 | 核心参数 | +|------|-------------|---------|---------| +| 泵转移 | `PumpTransferProtocol` | `generate_pump_protocol_with_rinsing` | `from_vessel, to_vessel, volume` | +| 简单转移 | `TransferProtocol` | `generate_pump_protocol` | `from_vessel, to_vessel, volume` | +| 加样 | `AddProtocol` | `generate_add_protocol` | `vessel, reagent, volume` | +| 过滤 | `FilterProtocol` | `generate_filter_protocol` | `vessel, filtrate_vessel` | +| 溶解 | `DissolveProtocol` | `generate_dissolve_protocol` | `vessel, solvent, volume` | +| 加热/冷却 | `HeatChillProtocol` | `generate_heat_chill_protocol` | `vessel, temp, time` | +| 搅拌 | `StirProtocol` | `generate_stir_protocol` | `vessel, time` | +| 分离 | `SeparateProtocol` | `generate_separate_protocol` | `from_vessel, separation_vessel, solvent` | +| 蒸发 | `EvaporateProtocol` | `generate_evaporate_protocol` | `vessel, pressure, temp, time` | +| 清洗 | `CleanProtocol` | `generate_clean_protocol` | `vessel, solvent, volume` | +| 离心 | `CentrifugeProtocol` | `generate_centrifuge_protocol` | `vessel, speed, time` | +| 抽气充气 | `EvacuateAndRefillProtocol` | `generate_evacuateandrefill_protocol` | `vessel, gas` | diff --git a/.cursor/skills/add-protocol/reference.md b/.cursor/skills/add-protocol/reference.md new file mode 100644 index 000000000..a212ced9c --- /dev/null +++ b/.cursor/skills/add-protocol/reference.md @@ -0,0 +1,207 @@ +# 协议高级参考 + +本文件是 SKILL.md 的补充,包含协议运行时数据流、测试模式、单位解析工具和复杂协议组合模式。Agent 在需要实现这些功能时按需阅读。 + +--- + +## 1. 协议运行时数据流 + +从图文件到协议执行的完整链路: + +``` +实验图 JSON + ↓ graphio.read_node_link_json() +physical_setup_graph (NetworkX DiGraph) + ↓ ROS2WorkstationNode._setup_protocol_names(protocol_type) +为每个 protocol_name 创建 ActionServer + ↓ 收到 Action Goal +_create_protocol_execute_callback() + ↓ convert_from_ros_msg_with_mapping(goal, mapping) +protocol_kwargs (Python dict) + ↓ 向 Host 查询 Resource 类型参数的当前状态 +protocol_kwargs 更新(vessel 带上 children、data 等) + ↓ protocol_steps_generator(G=physical_setup_graph, **protocol_kwargs) +List[Dict] 动作序列 + ↓ 逐步 execute_single_action / 并行 create_task +子设备 ActionClient 执行 +``` + +### `_setup_protocol_names` 核心逻辑 + +```python +def _setup_protocol_names(self, protocol_type): + if isinstance(protocol_type, str): + self.protocol_names = [p.strip() for p in protocol_type.split(",")] + else: + self.protocol_names = protocol_type + self.protocol_action_mappings = {} + for protocol_name in self.protocol_names: + protocol_type = globals()[protocol_name] # 从 messages 模块取 Pydantic 类 + self.protocol_action_mappings[protocol_name] = get_action_type(protocol_type) +``` + +### `_create_protocol_execute_callback` 关键步骤 + +1. `convert_from_ros_msg_with_mapping(goal, action_value_mapping["goal"])` — ROS Goal → Python dict +2. 对 `Resource` 类型字段,通过 `resource_get` Service 查询 Host 的最新资源状态 +3. `protocol_steps_generator(G=physical_setup_graph, **protocol_kwargs)` — 调用编译函数 +4. 遍历 steps:`dict` 串行执行,`list` 并行执行 +5. `execute_single_action` 通过 `_action_clients[device_id]` 向子设备发送 Action Goal +6. 执行完毕后通过 `resource_update` Service 更新资源状态 + +--- + +## 2. 测试模式 + +### 2.1 协议文件内测试函数 + +许多协议文件末尾有 `test_*` 函数,主要测试参数解析工具: + +```python +def test_dissolve_protocol(): + """测试溶解协议的各种参数解析""" + volumes = ["10 mL", "?", 10.0, "1 L", "500 μL"] + for vol in volumes: + result = parse_volume_input(vol) + print(f"体积解析: {vol} → {result}mL") + + masses = ["2.9 g", "?", 2.5, "500 mg"] + for mass in masses: + result = parse_mass_input(mass) + print(f"质量解析: {mass} → {result}g") +``` + +### 2.2 使用 mock graph 测试协议生成器 + +推荐的端到端测试模式: + +```python +import pytest +import networkx as nx +from unilabos.compile.stir_protocol import generate_stir_protocol + + +@pytest.fixture +def topology_graph(): + """创建测试拓扑图""" + G = nx.DiGraph() + G.add_node("flask_1", **{"class": "flask", "type": "container"}) + G.add_node("stirrer_1", **{"class": "virtual_stirrer", "type": "device"}) + G.add_edge("stirrer_1", "flask_1") + return G + + +def test_generate_stir_protocol(topology_graph): + """测试搅拌协议生成""" + actions = generate_stir_protocol( + G=topology_graph, + vessel="flask_1", + time="5 min", + stir_speed=300.0 + ) + assert len(actions) >= 1 + assert actions[0]["device_id"] == "stirrer_1" +``` + +**要点:** +- 用 `nx.DiGraph()` 构建最小拓扑 +- `add_node(id, **attrs)` 设置 `class`、`type`、`data` 等 +- `add_edge(src, dst)` 建立物理连接 +- 协议内的 `find_*` 函数依赖这些节点和边 + +--- + +## 3. 单位解析工具 + +路径:`unilabos/compile/utils/unit_parser.py` + +| 函数 | 输入 | 返回 | 默认值 | +|------|------|------|--------| +| `parse_volume_input(input, default_unit)` | `"100 mL"`, `"2.5 L"`, `"500 μL"`, `10.0`, `"?"` | mL (float) | 50.0 | +| `parse_mass_input(input)` | `"19.3 g"`, `"500 mg"`, `2.5`, `"?"` | g (float) | 1.0 | +| `parse_time_input(input)` | `"30 min"`, `"1 h"`, `"300"`, `60.0`, `"?"` | 秒 (float) | 60.0 | + +支持的单位: + +- **体积**: mL, L, μL/uL, milliliter, liter, microliter +- **质量**: g, mg, kg, gram, milligram, kilogram +- **时间**: s/sec/second, min/minute, h/hr/hour, d/day + +特殊值 `"?"`、`"unknown"`、`"tbd"` 返回默认值。 + +--- + +## 4. 复杂协议组合模式 + +以 `dissolve_protocol` 为例,展示如何组合多个子操作: + +### 整体流程 + +``` +1. 解析参数 (parse_volume_input, parse_mass_input, parse_time_input) +2. 设备发现 (find_connected_heatchill, find_connected_stirrer, find_solid_dispenser) +3. 判断溶解类型 (液体 vs 固体) +4. 组合动作序列: + a. heat_chill_start / start_stir (启动加热/搅拌) + b. wait (等待温度稳定) + c. pump_protocol_with_rinsing (液体转移, 通过 extend 拼接) + 或 add_solid (固体加样) + d. heat_chill / stir / wait (溶解等待) + e. heat_chill_stop (停止加热) +``` + +### 关键代码模式 + +**设备发现 → 条件组合:** + +```python +heatchill_id = find_connected_heatchill(G, vessel_id) +stirrer_id = find_connected_stirrer(G, vessel_id) +solid_dispenser_id = find_solid_dispenser(G) + +actions = [] + +# 启动阶段 +if heatchill_id and temp > 25.0: + actions.append({ + "device_id": heatchill_id, + "action_name": "heat_chill_start", + "action_kwargs": {"vessel": {"id": vessel_id}, "temp": temp} + }) + actions.append({"action_name": "wait", "action_kwargs": {"time": 30}}) +elif stirrer_id: + actions.append({ + "device_id": stirrer_id, + "action_name": "start_stir", + "action_kwargs": {"vessel": {"id": vessel_id}, "stir_speed": stir_speed} + }) + +# 转移阶段(复用已有协议) +pump_actions = generate_pump_protocol_with_rinsing( + G=G, from_vessel=solvent_vessel, to_vessel=vessel_id, volume=volume +) +actions.extend(pump_actions) + +# 等待阶段 +if heatchill_id: + actions.append({ + "device_id": heatchill_id, + "action_name": "heat_chill", + "action_kwargs": {"vessel": {"id": vessel_id}, "temp": temp, "time": time} + }) +else: + actions.append({"action_name": "wait", "action_kwargs": {"time": time}}) +``` + +--- + +## 5. 关键路径 + +| 内容 | 路径 | +|------|------| +| 协议执行回调 | `unilabos/ros/nodes/presets/workstation.py` | +| ROS 消息映射 | `unilabos/ros/msgs/message_converter.py` | +| 物理拓扑图 | `unilabos/resources/graphio.py` (`physical_setup_graph`) | +| 单位解析 | `unilabos/compile/utils/unit_parser.py` | +| 容器解析 | `unilabos/compile/utils/vessel_parser.py` | +| 溶解协议(组合示例) | `unilabos/compile/dissolve_protocol.py` | diff --git a/.cursor/skills/add-resource/SKILL.md b/.cursor/skills/add-resource/SKILL.md new file mode 100644 index 000000000..29b1bdff6 --- /dev/null +++ b/.cursor/skills/add-resource/SKILL.md @@ -0,0 +1,371 @@ +--- +name: add-resource +description: Guide for adding new resources (materials, bottles, carriers, decks, warehouses) to Uni-Lab-OS (添加新物料/资源). Covers Bottle, Carrier, Deck, WareHouse definitions and registry YAML. Use when the user wants to add resources, define materials, create a deck layout, add bottles/carriers/plates, or mentions 物料/资源/resource/bottle/carrier/deck/plate/warehouse. +--- + +# 添加新物料资源 + +Uni-Lab-OS 的资源体系基于 PyLabRobot,通过扩展实现 Bottle、Carrier、WareHouse、Deck 等实验室物料管理。 + +--- + +## 第一步:确认资源类型 + +向用户确认需要添加的资源类型: + +| 类型 | 基类 | 用途 | 示例 | +|------|------|------|------| +| **Bottle** | `Well` (PyLabRobot) | 单个容器(瓶、小瓶、烧杯、反应器) | 试剂瓶、粉末瓶 | +| **BottleCarrier** | `ItemizedCarrier` | 多槽位载架(放多个 Bottle) | 6 位试剂架、枪头盒 | +| **WareHouse** | `ItemizedCarrier` | 堆栈/仓库(放多个 Carrier) | 4x4 堆栈 | +| **Deck** | `Deck` (PyLabRobot) | 工作站台面(放多个 WareHouse) | 反应站 Deck | + +**层级关系:** `Deck` → `WareHouse` → `BottleCarrier` → `Bottle` + +还需确认: +- 资源所属的项目/场景(如 bioyond、battery、通用) +- 尺寸参数(直径、高度、最大容积等) +- 布局参数(行列数、间距等) + +--- + +## 第二步:创建资源定义 + +### 文件位置 + +``` +unilabos/resources/ +├── / # 按项目分组 +│ ├── bottles.py # Bottle 工厂函数 +│ ├── bottle_carriers.py # Carrier 工厂函数 +│ ├── warehouses.py # WareHouse 工厂函数 +│ └── decks.py # Deck 类定义 +├── itemized_carrier.py # Bottle, BottleCarrier, ItemizedCarrier 基类 +├── warehouse.py # WareHouse 基类 +└── container.py # 通用容器 +``` + +### 2A. 添加 Bottle(工厂函数) + +```python +from unilabos.resources.itemized_carrier import Bottle + + +def My_Reagent_Bottle( + name: str, + diameter: float = 70.0, # 瓶体直径 (mm) + height: float = 120.0, # 瓶体高度 (mm) + max_volume: float = 500000.0, # 最大容积 (μL) + barcode: str = None, +) -> Bottle: + """创建试剂瓶""" + return Bottle( + name=name, + diameter=diameter, + height=height, + max_volume=max_volume, + barcode=barcode, + model="My_Reagent_Bottle", # 唯一标识,用于注册表和物料映射 + ) +``` + +**Bottle 参数:** +- `name`: 实例名称(运行时唯一) +- `diameter`: 瓶体直径 (mm) +- `height`: 瓶体高度 (mm) +- `max_volume`: 最大容积 (**μL**,注意单位!500mL = 500000) +- `barcode`: 条形码(可选) +- `model`: 模型标识,与注册表 key 一致 + +### 2B. 添加 BottleCarrier(工厂函数) + +```python +from pylabrobot.resources import ResourceHolder +from pylabrobot.resources.carrier import create_ordered_items_2d + +from unilabos.resources.itemized_carrier import BottleCarrier + + +def My_6SlotCarrier(name: str) -> BottleCarrier: + """创建 3x2 六槽位载架""" + sites = create_ordered_items_2d( + klass=ResourceHolder, + num_items_x=3, # 列数 + num_items_y=2, # 行数 + dx=10.0, # X 起始偏移 + dy=10.0, # Y 起始偏移 + dz=5.0, # Z 偏移 + item_dx=42.0, # X 间距 + item_dy=35.0, # Y 间距 + size_x=20.0, # 槽位宽 + size_y=20.0, # 槽位深 + size_z=50.0, # 槽位高 + ) + carrier = BottleCarrier( + name=name, + size_x=146.0, # 载架总宽 + size_y=80.0, # 载架总深 + size_z=55.0, # 载架总高 + sites=sites, + model="My_6SlotCarrier", + ) + carrier.num_items_x = 3 + carrier.num_items_y = 2 + carrier.num_items_z = 1 + + # 预装 Bottle(可选) + ordering = ["A01", "A02", "A03", "B01", "B02", "B03"] + for i in range(6): + carrier[i] = My_Reagent_Bottle(f"{ordering[i]}") + + return carrier +``` + +### 2C. 添加 WareHouse(工厂函数) + +```python +from unilabos.resources.warehouse import warehouse_factory + + +def my_warehouse_4x4(name: str) -> "WareHouse": + """创建 4行x4列 堆栈仓库""" + return warehouse_factory( + name=name, + num_items_x=4, # 列数 + num_items_y=4, # 行数 + num_items_z=1, # 层数(通常为 1) + dx=137.0, # X 起始偏移 + dy=96.0, # Y 起始偏移 + dz=120.0, # Z 起始偏移 + item_dx=137.0, # X 间距 + item_dy=125.0, # Y 间距 + item_dz=10.0, # Z 间距(多层时用) + resource_size_x=127.0, # 槽位宽 + resource_size_y=85.0, # 槽位深 + resource_size_z=100.0, # 槽位高 + model="my_warehouse_4x4", + ) +``` + +**`warehouse_factory` 参数说明:** + +| 参数 | 说明 | +|------|------| +| `num_items_x/y/z` | 列数/行数/层数 | +| `dx, dy, dz` | 第一个槽位的起始坐标偏移 | +| `item_dx, item_dy, item_dz` | 相邻槽位间距 | +| `resource_size_x/y/z` | 单个槽位的物理尺寸 | +| `col_offset` | 列命名偏移(如设 4 则从 A05 开始) | +| `row_offset` | 行命名偏移(如设 5 则从 F 行开始) | +| `layout` | 排序方式:`"col-major"`(列优先,默认)/ `"row-major"`(行优先) | +| `removed_positions` | 要移除的位置索引列表 | + +自动生成 `ResourceHolder` 槽位,命名规则为 `A01, B01, C01, D01, A02, ...`(列优先)或 `A01, A02, A03, A04, B01, ...`(行优先)。 + +### 2D. 添加 Deck(类定义) + +```python +from pylabrobot.resources import Deck, Coordinate + + +class MyStation_Deck(Deck): + def __init__( + self, + name: str = "MyStation_Deck", + size_x: float = 2700.0, + size_y: float = 1080.0, + size_z: float = 1500.0, + category: str = "deck", + setup: bool = False, + ) -> None: + super().__init__(name=name, size_x=size_x, size_y=size_y, size_z=size_z) + if setup: + self.setup() + + def setup(self) -> None: + self.warehouses = { + "仓库A": my_warehouse_4x4("仓库A"), + "仓库B": my_warehouse_4x4("仓库B"), + } + self.warehouse_locations = { + "仓库A": Coordinate(-200.0, 400.0, 0.0), + "仓库B": Coordinate(2350.0, 400.0, 0.0), + } + for wh_name, wh in self.warehouses.items(): + self.assign_child_resource(wh, location=self.warehouse_locations[wh_name]) +``` + +**Deck 要点:** +- 继承 `pylabrobot.resources.Deck` +- `setup()` 创建 WareHouse 并通过 `assign_child_resource` 放置到指定坐标 +- `setup` 参数控制是否在构造时自动调用 `setup()`(图文件中通过 `config.setup: true` 触发) + +--- + +## 第三步:创建注册表 YAML + +路径:`unilabos/registry/resources//.yaml` + +### Bottle 注册 + +```yaml +My_Reagent_Bottle: + category: + - bottles + class: + module: unilabos.resources.my_project.bottles:My_Reagent_Bottle + type: pylabrobot + description: 我的试剂瓶 + handles: [] + icon: '' + init_param_schema: {} + version: 1.0.0 +``` + +### Carrier 注册 + +```yaml +My_6SlotCarrier: + category: + - bottle_carriers + class: + module: unilabos.resources.my_project.bottle_carriers:My_6SlotCarrier + type: pylabrobot + handles: [] + icon: '' + init_param_schema: {} + version: 1.0.0 +``` + +### Deck 注册 + +```yaml +MyStation_Deck: + category: + - deck + class: + module: unilabos.resources.my_project.decks:MyStation_Deck + type: pylabrobot + description: 我的工作站 Deck + handles: [] + icon: '' + init_param_schema: {} + registry_type: resource + version: 1.0.0 +``` + +**注册表规则:** +- `class.module` 格式为 `python.module.path:ClassName_or_FunctionName` +- `class.type` 固定为 `pylabrobot` +- Key(如 `My_Reagent_Bottle`)必须与工厂函数名 / 类名一致 +- `category` 按类型标注(`bottles`, `bottle_carriers`, `deck` 等) + +--- + +## 第四步:在图文件中引用 + +### Deck 在工作站中的引用 + +工作站节点通过 `deck` 字段引用,Deck 作为子节点: + +```json +{ + "id": "my_station", + "children": ["my_deck"], + "deck": { + "data": { + "_resource_child_name": "my_deck", + "_resource_type": "unilabos.resources.my_project.decks:MyStation_Deck" + } + } +}, +{ + "id": "my_deck", + "parent": "my_station", + "type": "deck", + "class": "MyStation_Deck", + "config": {"type": "MyStation_Deck", "setup": true} +} +``` + +### 物料类型映射(外部系统对接时) + +如果工作站需要与外部系统同步物料,在 config 中配置 `material_type_mappings`: + +```json +"material_type_mappings": { + "My_Reagent_Bottle": ["试剂瓶", "external-type-uuid"], + "My_6SlotCarrier": ["六槽载架", "external-type-uuid"] +} +``` + +--- + +## 第五步:注册 PLR 扩展(如需要) + +如果添加了新的 Deck 类,需要在 `unilabos/resources/plr_additional_res_reg.py` 中导入,使 `find_subclass` 能发现它: + +```python +def register(): + from unilabos.resources.my_project.decks import MyStation_Deck +``` + +--- + +## 第六步:验证 + +```bash +# 1. 资源可导入 +python -c "from unilabos.resources.my_project.bottles import My_Reagent_Bottle; print(My_Reagent_Bottle('test'))" + +# 2. Deck 可创建 +python -c " +from unilabos.resources.my_project.decks import MyStation_Deck +d = MyStation_Deck('test', setup=True) +print(d.children) +" + +# 3. 启动测试 +unilab -g .json --complete_registry +``` + +--- + +## 工作流清单 + +``` +资源接入进度: +- [ ] 1. 确定资源类型(Bottle / Carrier / WareHouse / Deck) +- [ ] 2. 创建资源定义(工厂函数/类) +- [ ] 3. 创建注册表 YAML (unilabos/registry/resources//.yaml) +- [ ] 4. 在图文件中引用(如需要) +- [ ] 5. 注册 PLR 扩展(Deck 类需要) +- [ ] 6. 验证 +``` + +--- + +## 高级模式 + +实现复杂资源系统时,详见 [reference.md](reference.md):类继承体系完整图、序列化/反序列化流程、Bioyond 物料双向同步、非瓶类资源(ElectrodeSheet / Magazine)、仓库工厂 layout 模式。 + +--- + +## 现有资源参考 + +| 项目 | Bottles | Carriers | WareHouses | Decks | +|------|---------|----------|------------|-------| +| bioyond | `bioyond/bottles.py` | `bioyond/bottle_carriers.py` | `bioyond/warehouses.py`, `YB_warehouses.py` | `bioyond/decks.py` | +| battery | — | `battery/bottle_carriers.py` | — | — | +| 通用 | — | — | `warehouse.py` | — | + +### 关键路径 + +| 内容 | 路径 | +|------|------| +| Bottle/Carrier 基类 | `unilabos/resources/itemized_carrier.py` | +| WareHouse 基类 + 工厂 | `unilabos/resources/warehouse.py` | +| PLR 注册 | `unilabos/resources/plr_additional_res_reg.py` | +| 资源注册表 | `unilabos/registry/resources/` | +| 图文件加载 | `unilabos/resources/graphio.py` | +| 资源跟踪器 | `unilabos/resources/resource_tracker.py` | diff --git a/.cursor/skills/add-resource/reference.md b/.cursor/skills/add-resource/reference.md new file mode 100644 index 000000000..a227d0c8d --- /dev/null +++ b/.cursor/skills/add-resource/reference.md @@ -0,0 +1,292 @@ +# 资源高级参考 + +本文件是 SKILL.md 的补充,包含类继承体系、序列化/反序列化、Bioyond 物料同步、非瓶类资源和仓库工厂模式。Agent 在需要实现这些功能时按需阅读。 + +--- + +## 1. 类继承体系 + +``` +PyLabRobot +├── Resource (PLR 基类) +│ ├── Well +│ │ └── Bottle (unilabos) → 瓶/小瓶/烧杯/反应器 +│ ├── Deck +│ │ └── 自定义 Deck 类 (unilabos) → 工作站台面 +│ ├── ResourceHolder → 槽位占位符 +│ └── Container +│ └── Battery (unilabos) → 组装好的电池 +│ +├── ItemizedCarrier (unilabos, 继承 Resource) +│ ├── BottleCarrier (unilabos) → 瓶载架 +│ └── WareHouse (unilabos) → 堆栈仓库 +│ +├── ItemizedResource (PLR) +│ └── MagazineHolder (unilabos) → 子弹夹载架 +│ +└── ResourceStack (PLR) + └── Magazine (unilabos) → 子弹夹洞位 +``` + +### Bottle 类细节 + +```python +class Bottle(Well): + def __init__(self, name, diameter, height, max_volume, + size_x=0.0, size_y=0.0, size_z=0.0, + barcode=None, category="container", model=None, **kwargs): + super().__init__( + name=name, + size_x=diameter, # PLR 用 diameter 作为 size_x/size_y + size_y=diameter, + size_z=height, # PLR 用 height 作为 size_z + max_volume=max_volume, + category=category, + model=model, + bottom_type="flat", + cross_section_type="circle" + ) +``` + +注意 `size_x = size_y = diameter`,`size_z = height`。 + +### ItemizedCarrier 核心方法 + +| 方法 | 说明 | +|------|------| +| `__getitem__(identifier)` | 通过索引或 Excel 标识(如 `"A01"`)访问槽位 | +| `__setitem__(identifier, resource)` | 向槽位放入资源 | +| `get_child_identifier(child)` | 获取子资源的标识符 | +| `capacity` | 总槽位数 | +| `sites` | 所有槽位字典 | + +--- + +## 2. 序列化与反序列化 + +### PLR ↔ UniLab 转换 + +| 函数 | 位置 | 方向 | +|------|------|------| +| `ResourceTreeSet.from_plr_resources(resources)` | `resource_tracker.py` | PLR → UniLab | +| `ResourceTreeSet.to_plr_resources()` | `resource_tracker.py` | UniLab → PLR | + +### `from_plr_resources` 流程 + +``` +PLR Resource + ↓ build_uuid_mapping (递归生成 UUID) + ↓ resource.serialize() → dict + ↓ resource.serialize_all_state() → states + ↓ resource_plr_inner (递归构建 ResourceDictInstance) +ResourceTreeSet +``` + +关键:每个 PLR 资源通过 `unilabos_uuid` 属性携带 UUID,`unilabos_extra` 携带扩展数据(如 `class` 名)。 + +### `to_plr_resources` 流程 + +``` +ResourceTreeSet + ↓ collect_node_data (收集 UUID、状态、扩展数据) + ↓ node_to_plr_dict (转为 PLR 字典格式) + ↓ find_subclass(type_name, PLRResource) (查找 PLR 子类) + ↓ sub_cls.deserialize(plr_dict) (反序列化) + ↓ loop_set_uuid, loop_set_extra (递归设置 UUID 和扩展) +PLR Resource +``` + +### Bottle 序列化 + +```python +class Bottle(Well): + def serialize(self) -> dict: + data = super().serialize() + return {**data, "diameter": self.diameter, "height": self.height} + + @classmethod + def deserialize(cls, data: dict, allow_marshal=False): + barcode_data = data.pop("barcode", None) + instance = super().deserialize(data, allow_marshal=allow_marshal) + if barcode_data and isinstance(barcode_data, str): + instance.barcode = barcode_data + return instance +``` + +--- + +## 3. Bioyond 物料同步 + +### 双向转换函数 + +| 函数 | 位置 | 方向 | +|------|------|------| +| `resource_bioyond_to_plr(materials, type_mapping, deck)` | `graphio.py` | Bioyond → PLR | +| `resource_plr_to_bioyond(resources, type_mapping, warehouse_mapping)` | `graphio.py` | PLR → Bioyond | + +### `resource_bioyond_to_plr` 流程 + +``` +Bioyond 物料列表 + ↓ reverse_type_mapping: {typeName → (model, UUID)} + ↓ 对每个物料: + typeName → 查映射 → model (如 "BIOYOND_PolymerStation_Reactor") + initialize_resource({"name": unique_name, "class": model}) + ↓ 设置 unilabos_extra (material_bioyond_id, material_bioyond_name 等) + ↓ 处理 detail (子物料/坐标) + ↓ 按 locationName 放入 deck.warehouses 对应槽位 +PLR 资源列表 +``` + +### `resource_plr_to_bioyond` 流程 + +``` +PLR 资源列表 + ↓ 遍历每个资源: + 载架(capacity > 1): 生成 details 子物料 + 坐标 + 单瓶: 直接映射 + ↓ type_mapping 查找 typeId + ↓ warehouse_mapping 查找位置 UUID + ↓ 组装 Bioyond 格式 (name, typeName, typeId, quantity, Parameters, locations) +Bioyond 物料列表 +``` + +### BioyondResourceSynchronizer + +工作站通过 `ResourceSynchronizer` 自动同步物料: + +```python +class BioyondResourceSynchronizer(ResourceSynchronizer): + def sync_from_external(self) -> bool: + all_data = [] + all_data.extend(api_client.stock_material('{"typeMode": 0}')) # 耗材 + all_data.extend(api_client.stock_material('{"typeMode": 1}')) # 样品 + all_data.extend(api_client.stock_material('{"typeMode": 2}')) # 试剂 + unilab_resources = resource_bioyond_to_plr( + all_data, + type_mapping=self.workstation.bioyond_config["material_type_mappings"], + deck=self.workstation.deck + ) + # 更新 deck 上的资源 +``` + +--- + +## 4. 非瓶类资源 + +### ElectrodeSheet(极片) + +路径:`unilabos/resources/battery/electrode_sheet.py` + +```python +class ElectrodeSheet(ResourcePLR): + """片状材料(极片、隔膜、弹片、垫片等)""" + _unilabos_state = { + "diameter": 0.0, + "thickness": 0.0, + "mass": 0.0, + "material_type": "", + "color": "", + "info": "", + } +``` + +工厂函数:`PositiveCan`, `PositiveElectrode`, `NegativeCan`, `NegativeElectrode`, `SpringWasher`, `FlatWasher`, `AluminumFoil` + +### Battery(电池) + +```python +class Battery(Container): + """组装好的电池""" + _unilabos_state = { + "color": "", + "electrolyte_name": "", + "open_circuit_voltage": 0.0, + } +``` + +### Magazine / MagazineHolder(子弹夹) + +```python +class Magazine(ResourceStack): + """子弹夹洞位,可堆叠 ElectrodeSheet""" + # direction, max_sheets + +class MagazineHolder(ItemizedResource): + """多洞位子弹夹""" + # hole_diameter, hole_depth, max_sheets_per_hole +``` + +工厂函数 `magazine_factory()` 用 `create_homogeneous_resources` 生成洞位,可选预填 `ElectrodeSheet` 或 `Battery`。 + +--- + +## 5. 仓库工厂模式参考 + +### 实际 warehouse 工厂函数示例 + +```python +# 行优先 4x4 仓库 +def bioyond_warehouse_1x4x4(name: str) -> WareHouse: + return warehouse_factory( + name=name, + num_items_x=4, num_items_y=4, num_items_z=1, + dx=10.0, dy=10.0, dz=10.0, + item_dx=147.0, item_dy=106.0, item_dz=130.0, + layout="row-major", # A01,A02,A03,A04, B01,... + ) + +# 右侧 4x4 仓库(列名偏移) +def bioyond_warehouse_1x4x4_right(name: str) -> WareHouse: + return warehouse_factory( + name=name, + num_items_x=4, num_items_y=4, num_items_z=1, + dx=10.0, dy=10.0, dz=10.0, + item_dx=147.0, item_dy=106.0, item_dz=130.0, + col_offset=4, # A05,A06,A07,A08 + layout="row-major", + ) + +# 竖向仓库(站内试剂存放) +def bioyond_warehouse_reagent_storage(name: str) -> WareHouse: + return warehouse_factory( + name=name, + num_items_x=1, num_items_y=2, num_items_z=1, + dx=10.0, dy=10.0, dz=10.0, + item_dx=147.0, item_dy=106.0, item_dz=130.0, + layout="vertical-col-major", + ) + +# 行偏移(F 行开始) +def bioyond_warehouse_5x3x1(name: str, row_offset: int = 0) -> WareHouse: + return warehouse_factory( + name=name, + num_items_x=3, num_items_y=5, num_items_z=1, + dx=10.0, dy=10.0, dz=10.0, + item_dx=159.0, item_dy=183.0, item_dz=130.0, + row_offset=row_offset, # 0→A行起,5→F行起 + layout="row-major", + ) +``` + +### layout 类型说明 + +| layout | 命名顺序 | 适用场景 | +|--------|---------|---------| +| `col-major` (默认) | A01,B01,C01,D01, A02,B02,... | 列优先,标准堆栈 | +| `row-major` | A01,A02,A03,A04, B01,B02,... | 行优先,Bioyond 前端展示 | +| `vertical-col-major` | 竖向排列,标签从底部开始 | 竖向仓库(试剂存放、测密度) | + +--- + +## 6. 关键路径 + +| 内容 | 路径 | +|------|------| +| Bottle/Carrier 基类 | `unilabos/resources/itemized_carrier.py` | +| WareHouse 类 + 工厂 | `unilabos/resources/warehouse.py` | +| ResourceTreeSet 转换 | `unilabos/resources/resource_tracker.py` | +| Bioyond 物料转换 | `unilabos/resources/graphio.py` | +| Bioyond 仓库定义 | `unilabos/resources/bioyond/warehouses.py` | +| 电池资源 | `unilabos/resources/battery/` | +| PLR 注册 | `unilabos/resources/plr_additional_res_reg.py` | diff --git a/.cursor/skills/add-workstation/SKILL.md b/.cursor/skills/add-workstation/SKILL.md new file mode 100644 index 000000000..2762d4e4c --- /dev/null +++ b/.cursor/skills/add-workstation/SKILL.md @@ -0,0 +1,500 @@ +--- +name: add-workstation +description: Guide for adding new workstations to Uni-Lab-OS (接入新工作站). Walks through workstation type selection, sub-device composition, external system integration, driver creation, registry YAML, deck setup, and graph file configuration. Use when the user wants to add/integrate a new workstation, create a workstation driver, configure a station with sub-devices, set up deck and materials, or mentions 工作站/工站/station/workstation. +--- + +# Uni-Lab-OS 工作站接入指南 + +工作站(workstation)是组合多个子设备的大型设备,拥有独立的物料管理系统(PLR Deck)和工作流引擎。本指南覆盖从需求分析到验证的全流程。 + +> **前置知识**:工作站接入基于 `docs/ai_guides/add_device.md` 的通用设备接入框架,但有显著差异。阅读本指南前无需先读通用指南。 + +## 第一步:确定工作站类型 + +向用户确认以下信息: + +**Q1: 工作站的业务场景?** + +| 类型 | 基类 | 适用场景 | 示例 | +|------|------|----------|------| +| **Protocol 工作站** | `ProtocolNode` | 标准化学操作协议(过滤、转移、加热等) | FilterProtocolStation | +| **外部系统工作站** | `WorkstationBase` | 与外部 LIMS/MES 系统对接,有专属 API | BioyondStation | +| **硬件控制工作站** | `WorkstationBase` | 直接控制 PLC/硬件,无外部系统 | CoinCellAssembly | + +**Q2: 工作站英文名称?**(如 `my_reaction_station`) + +**Q3: 与外部系统的交互方式?** + +| 方式 | 适用场景 | 需要的配置 | +|------|----------|-----------| +| 无外部系统 | Protocol 工作站、纯硬件控制 | 无 | +| HTTP API | LIMS/MES 系统(如 Bioyond) | `api_host`, `api_key` | +| Modbus TCP | PLC 控制 | `address`, `port` | +| OPC UA | 工业设备 | `url` | + +**Q4: 子设备组成?** +- 列出所有子设备(如反应器、泵、阀、传感器等) +- 哪些是已有设备类型?哪些需要新增? +- 子设备之间的硬件代理关系(如泵通过串口设备通信) + +**Q5: 物料管理需求?** +- 是否需要 Deck(物料面板)? +- 物料类型(plate、tip_rack、bottle 等) +- 是否需要与外部物料系统同步? + +--- + +## 第二步:理解工作站架构 + +工作站与普通设备的核心差异: + +| 维度 | 普通设备 | 工作站 | +|------|---------|--------| +| 基类 | 无(纯 Python 类) | `WorkstationBase` 或 `ProtocolNode` | +| ROS 节点 | `BaseROS2DeviceNode` | `ROS2WorkstationNode` | +| 状态管理 | `self.data` 字典 | 通常不用 `self.data`,用 `@property` 直接访问 | +| 子设备 | 无 | `children` 列表,通过 `self._children` 访问 | +| 物料 | 无 | `self.deck`(PLR Deck) | +| 图文件角色 | `parent: null` 或 `parent: ""` | `parent: null`,含 `children` 和 `deck` | + +### 继承体系 + +`WorkstationBase` (ABC) → `ProtocolNode` (通用协议) / `BioyondWorkstation` (→ ReactionStation, DispensingStation) / `CoinCellAssemblyWorkstation` (硬件控制) + +### ROS 层 + +`ROS2WorkstationNode` 额外负责:初始化 children 子设备节点、为子设备创建 ActionClient、配置硬件代理、为 protocol_type 创建协议 ActionServer。 + +--- + +## 第三步:创建驱动文件 + +文件路径:`unilabos/devices/workstation//.py` + +### 模板 A:基于外部系统的工作站 + +适用于与 LIMS/MES 等外部系统对接的场景。 + +```python +import logging +from typing import Dict, Any, Optional, List +from pylabrobot.resources import Deck + +from unilabos.devices.workstation.workstation_base import WorkstationBase + +try: + from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode +except ImportError: + ROS2WorkstationNode = None + + +class MyWorkstation(WorkstationBase): + """工作站描述""" + + _ros_node: "ROS2WorkstationNode" + + def __init__( + self, + config: dict = None, + deck: Optional[Deck] = None, + protocol_type: list = None, + **kwargs, + ): + super().__init__(deck=deck, **kwargs) + self.config = config or {} + self.logger = logging.getLogger(f"MyWorkstation") + + # 外部系统连接配置 + self.api_host = self.config.get("api_host", "") + self.api_key = self.config.get("api_key", "") + + # 工作站业务状态(不同于 self.data 模式) + self._status = "Idle" + + def post_init(self, ros_node: "ROS2WorkstationNode") -> None: + super().post_init(ros_node) + # 在这里启动后台服务、连接监控等 + + # ============ 子设备访问 ============ + + def _get_child_device(self, device_id: str): + """通过 ID 获取子设备节点""" + return self._children.get(device_id) + + # ============ 动作方法 ============ + + async def scheduler_start(self, **kwargs) -> Dict[str, Any]: + """启动调度器""" + return {"success": True} + + async def create_order(self, json_str: str, **kwargs) -> Dict[str, Any]: + """创建工单""" + return {"success": True} + + # ============ 属性 ============ + + @property + def workflow_sequence(self) -> str: + return "[]" + + @property + def material_info(self) -> str: + return "{}" +``` + +### 模板 B:基于硬件控制的工作站 + +适用于直接与 PLC/硬件通信的场景。 + +```python +import logging +from typing import Dict, Any, Optional +from pylabrobot.resources import Deck + +from unilabos.devices.workstation.workstation_base import WorkstationBase + +try: + from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode +except ImportError: + ROS2WorkstationNode = None + + +class MyHardwareWorkstation(WorkstationBase): + """硬件控制工作站""" + + _ros_node: "ROS2WorkstationNode" + + def __init__( + self, + config: dict = None, + deck: Optional[Deck] = None, + address: str = "192.168.1.100", + port: str = "502", + debug_mode: bool = False, + *args, + **kwargs, + ): + super().__init__(deck=deck, *args, **kwargs) + self.config = config or {} + self.address = address + self.port = int(port) + self.debug_mode = debug_mode + self.logger = logging.getLogger("MyHardwareWorkstation") + + # 初始化通信客户端 + if not debug_mode: + from unilabos.device_comms.modbus_plc.client import ModbusTcpClient + self.client = ModbusTcpClient(host=self.address, port=self.port) + else: + self.client = None + + def post_init(self, ros_node: "ROS2WorkstationNode") -> None: + super().post_init(ros_node) + + # ============ 硬件读写 ============ + + def _read_register(self, name: str): + """读取 Modbus 寄存器""" + if self.debug_mode: + return 0 + # 实际读取逻辑 + pass + + # ============ 动作方法 ============ + + async def start_process(self, **kwargs) -> Dict[str, Any]: + """启动加工流程""" + return {"success": True} + + async def stop_process(self, **kwargs) -> Dict[str, Any]: + """停止加工流程""" + return {"success": True} + + # ============ 属性(从硬件实时读取)============ + + @property + def sys_status(self) -> str: + return str(self._read_register("SYS_STATUS")) +``` + +### 模板 C:Protocol 工作站 + +适用于标准化学操作协议的场景,直接使用 `ProtocolNode`。 + +```python +from typing import List, Optional +from pylabrobot.resources import Resource as PLRResource + +from unilabos.devices.workstation.workstation_base import ProtocolNode + + +class MyProtocolStation(ProtocolNode): + """Protocol 工作站 — 使用标准化学操作协议""" + + def __init__( + self, + protocol_type: List[str], + deck: Optional[PLRResource] = None, + *args, + **kwargs, + ): + super().__init__(protocol_type=protocol_type, deck=deck, *args, **kwargs) +``` + +> Protocol 工作站通常不需要自定义驱动类,直接使用 `ProtocolNode` 并在注册表和图文件中配置 `protocol_type` 即可。 + +--- + +## 第四步:创建子设备驱动(如需要) + +工作站的子设备本身是独立设备。按 `docs/ai_guides/add_device.md` 的标准流程创建。 + +子设备的关键约束: +- 在图文件中 `parent` 指向工作站 ID +- 图文件中在工作站的 `children` 数组里列出 +- 如需硬件代理,在子设备的 `config.hardware_interface.name` 指向通信设备 ID + +--- + +## 第五步:创建注册表 YAML + +路径:`unilabos/registry/devices/.yaml` + +### 最小配置 + +```yaml +my_workstation: + category: + - workstation + class: + module: unilabos.devices.workstation.my_station.my_station:MyWorkstation + type: python +``` + +启动时 `--complete_registry` 自动补全 `status_types` 和 `action_value_mappings`。 + +### 完整配置参考 + +```yaml +my_workstation: + description: "我的工作站" + version: "1.0.0" + category: + - workstation + - my_category + class: + module: unilabos.devices.workstation.my_station.my_station:MyWorkstation + type: python + status_types: + workflow_sequence: String + material_info: String + action_value_mappings: + scheduler_start: + type: UniLabJsonCommandAsync + goal: {} + result: + success: success + create_order: + type: UniLabJsonCommandAsync + goal: + json_str: json_str + result: + success: success + init_param_schema: + config: + type: object + deck: + type: object + protocol_type: + type: array +``` + +### 子设备注册表 + +子设备有独立的注册表文件,需要在 `category` 中包含工作站标识: + +```yaml +my_reactor: + category: + - reactor + - my_workstation + class: + module: unilabos.devices.workstation.my_station.my_reactor:MyReactor + type: python +``` + +--- + +## 第六步:配置 Deck 资源(如需要) + +如果工作站有物料管理需求,需要定义 Deck 类。 + +### 使用已有 Deck 类 + +查看 `unilabos/resources/` 目录下是否有适用的 Deck 类。 + +### 创建自定义 Deck + +在 `unilabos/resources//decks.py` 中定义: + +```python +from pylabrobot.resources import Deck +from pylabrobot.resources.coordinate import Coordinate + + +def MyStation_Deck(name: str = "MyStation_Deck") -> Deck: + deck = Deck(name=name, size_x=2700.0, size_y=1080.0, size_z=1500.0) + # 在 deck 上定义子资源位置(carrier、plate 等) + return deck +``` + +在 `unilabos/resources//` 下注册或通过注册表引用。 + +--- + +## 第七步:配置图文件 + +图文件路径:`unilabos/test/experiments/.json` + +### 完整结构 + +```json +{ + "nodes": [ + { + "id": "my_station", + "name": "my_station", + "children": ["my_deck", "sub_device_1", "sub_device_2"], + "parent": null, + "type": "device", + "class": "my_workstation", + "position": {"x": 0, "y": 0, "z": 0}, + "config": { + "api_host": "http://192.168.1.100:8080", + "api_key": "YOUR_KEY" + }, + "deck": { + "data": { + "_resource_child_name": "my_deck", + "_resource_type": "unilabos.resources.my_module.decks:MyStation_Deck" + } + }, + "size_x": 2700.0, + "size_y": 1080.0, + "size_z": 1500.0, + "protocol_type": [], + "data": {} + }, + { + "id": "my_deck", + "name": "my_deck", + "children": [], + "parent": "my_station", + "type": "deck", + "class": "MyStation_Deck", + "position": {"x": 0, "y": 0, "z": 0}, + "config": { + "type": "MyStation_Deck", + "setup": true, + "rotation": {"x": 0, "y": 0, "z": 0, "type": "Rotation"} + }, + "data": {} + }, + { + "id": "sub_device_1", + "name": "sub_device_1", + "children": [], + "parent": "my_station", + "type": "device", + "class": "sub_device_registry_name", + "position": {"x": 100, "y": 0, "z": 0}, + "config": {}, + "data": {} + } + ] +} +``` + +### 图文件规则 + +| 字段 | 说明 | +|------|------| +| `id` | 节点唯一标识,与 `children` 数组中的引用一致 | +| `children` | 包含 deck ID 和所有子设备 ID | +| `parent` | 工作站节点为 `null`;子设备/deck 指向工作站 ID | +| `type` | 工作站和子设备为 `"device"`;deck 为 `"deck"` | +| `class` | 对应注册表中的设备名 | +| `deck.data._resource_child_name` | 必须与 deck 节点的 `id` 一致 | +| `deck.data._resource_type` | Deck 工厂函数的完整 Python 路径 | +| `protocol_type` | Protocol 工作站填入协议名列表;否则为 `[]` | +| `config` | 传入驱动 `__init__` 的 `config` 参数 | + +--- + +## 第八步:验证 + +```bash +# 1. 模块可导入 +python -c "from unilabos.devices.workstation.. import " + +# 2. 注册表补全 +unilab -g .json --complete_registry + +# 3. 启动测试 +unilab -g .json +``` + +--- + +## 高级模式 + +实现外部系统对接型工作站时,详见 [reference.md](reference.md):RPC 客户端、HTTP 回调服务、连接监控、Config 结构模式(material_type_mappings / warehouse_mapping / workflow_mappings)、ResourceSynchronizer、update_resource、工作流序列、站间物料转移、post_init 完整模式。 + +--- + +## 关键规则 + +1. **`__init__` 必须接受 `deck` 和 `**kwargs`** — `WorkstationBase.__init__` 需要 `deck` 参数 +2. **通过 `self._children` 访问子设备** — 不要自行维护子设备引用 +3. **`post_init` 中启动后台服务** — 不要在 `__init__` 中启动网络连接 +4. **异步方法使用 `await self._ros_node.sleep()`** — 禁止 `time.sleep()` 和 `asyncio.sleep()` +5. **子设备在图文件中声明** — 不在驱动代码中创建子设备实例 +6. **`deck` 配置中的 `_resource_child_name` 必须与 deck 节点 ID 一致** +7. **Protocol 工作站优先使用 `ProtocolNode`** — 不需要自定义类 + +--- + +## 工作流清单 + +``` +工作站接入进度: +- [ ] 1. 确定工作站类型(Protocol / 外部系统 / 硬件控制) +- [ ] 2. 确认子设备组成和物料需求 +- [ ] 3. 创建工作站驱动 unilabos/devices/workstation//.py +- [ ] 4. 创建子设备驱动(如需要,按 add_device.md 流程) +- [ ] 5. 创建注册表 unilabos/registry/devices/.yaml +- [ ] 6. 创建/选择 Deck 资源类(如需要) +- [ ] 7. 配置图文件 unilabos/test/experiments/.json +- [ ] 8. 验证:可导入 + 注册表补全 + 启动测试 +``` + +--- + +## 现有工作站参考 + +| 工作站 | 注册表名 | 驱动类 | 类型 | +|--------|----------|--------|------| +| Protocol 通用 | `workstation` | `ProtocolNode` | Protocol | +| Bioyond 反应站 | `reaction_station.bioyond` | `BioyondReactionStation` | 外部系统 | +| Bioyond 配液站 | `bioyond_dispensing_station` | `BioyondDispensingStation` | 外部系统 | +| 纽扣电池组装 | `coincellassemblyworkstation_device` | `CoinCellAssemblyWorkstation` | 硬件控制 | + +### 参考文件路径 + +- 基类: `unilabos/devices/workstation/workstation_base.py` +- Bioyond 基类: `unilabos/devices/workstation/bioyond_studio/station.py` +- 反应站: `unilabos/devices/workstation/bioyond_studio/reaction_station/reaction_station.py` +- 配液站: `unilabos/devices/workstation/bioyond_studio/dispensing_station/dispensing_station.py` +- 纽扣电池: `unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py` +- ROS 节点: `unilabos/ros/nodes/presets/workstation.py` +- 图文件: `unilabos/test/experiments/reaction_station_bioyond.json`, `dispensing_station_bioyond.json` diff --git a/.cursor/skills/add-workstation/reference.md b/.cursor/skills/add-workstation/reference.md new file mode 100644 index 000000000..0c1b9f0d5 --- /dev/null +++ b/.cursor/skills/add-workstation/reference.md @@ -0,0 +1,371 @@ +# 工作站高级模式参考 + +本文件是 SKILL.md 的补充,包含外部系统集成、物料同步、配置结构等高级模式。 +Agent 在需要实现这些功能时按需阅读。 + +--- + +## 1. 外部系统集成模式 + +### 1.1 RPC 客户端 + +与外部 LIMS/MES 系统通信的标准模式。继承 `BaseRequest`,所有接口统一用 POST。 + +```python +from unilabos.device_comms.rpc import BaseRequest + + +class MySystemRPC(BaseRequest): + """外部系统 RPC 客户端""" + + def __init__(self, host: str, api_key: str): + super().__init__(host) + self.api_key = api_key + + def _request(self, endpoint: str, data: dict = None) -> dict: + return self.post( + url=f"{self.host}/api/{endpoint}", + params={ + "apiKey": self.api_key, + "requestTime": self.get_current_time_iso8601(), + "data": data or {}, + }, + ) + + def query_status(self) -> dict: + return self._request("status/query") + + def create_order(self, order_data: dict) -> dict: + return self._request("order/create", order_data) +``` + +参考:`unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py`(`BioyondV1RPC`) + +### 1.2 HTTP 回调服务 + +接收外部系统报送的标准模式。使用 `WorkstationHTTPService`,在 `post_init` 中启动。 + +```python +from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService + + +class MyWorkstation(WorkstationBase): + def __init__(self, config=None, deck=None, **kwargs): + super().__init__(deck=deck, **kwargs) + self.config = config or {} + http_cfg = self.config.get("http_service_config", {}) + self._http_service_config = { + "host": http_cfg.get("http_service_host", "127.0.0.1"), + "port": http_cfg.get("http_service_port", 8080), + } + self.http_service = None + + def post_init(self, ros_node): + super().post_init(ros_node) + self.http_service = WorkstationHTTPService( + workstation_instance=self, + host=self._http_service_config["host"], + port=self._http_service_config["port"], + ) + self.http_service.start() +``` + +**HTTP 服务路由**(固定端点,由 `WorkstationHTTPHandler` 自动分发): + +| 端点 | 调用的工作站方法 | +|------|-----------------| +| `/report/step_finish` | `process_step_finish_report(report_request)` | +| `/report/sample_finish` | `process_sample_finish_report(report_request)` | +| `/report/order_finish` | `process_order_finish_report(report_request, used_materials)` | +| `/report/material_change` | `process_material_change_report(report_data)` | +| `/report/error_handling` | `handle_external_error(error_data)` | + +实现对应方法即可接收回调: + +```python +def process_step_finish_report(self, report_request) -> Dict[str, Any]: + """处理步骤完成报告""" + step_name = report_request.data.get("stepName") + return {"success": True, "message": f"步骤 {step_name} 已处理"} + +def process_order_finish_report(self, report_request, used_materials) -> Dict[str, Any]: + """处理订单完成报告""" + order_code = report_request.data.get("orderCode") + return {"success": True} +``` + +参考:`unilabos/devices/workstation/workstation_http_service.py` + +### 1.3 连接监控 + +独立线程周期性检测外部系统连接状态,状态变化时发布 ROS 事件。 + +```python +class ConnectionMonitor: + def __init__(self, workstation, check_interval=30): + self.workstation = workstation + self.check_interval = check_interval + self._running = False + self._thread = None + + def start(self): + self._running = True + self._thread = threading.Thread(target=self._monitor_loop, daemon=True) + self._thread.start() + + def _monitor_loop(self): + while self._running: + try: + # 调用外部系统接口检测连接 + self.workstation.hardware_interface.ping() + status = "online" + except Exception: + status = "offline" + time.sleep(self.check_interval) +``` + +参考:`unilabos/devices/workstation/bioyond_studio/station.py`(`ConnectionMonitor`) + +--- + +## 2. Config 结构模式 + +工作站的 `config` 在图文件中定义,传入 `__init__`。以下是常见字段模式: + +### 2.1 外部系统连接 + +```json +{ + "api_host": "http://192.168.1.100:8080", + "api_key": "YOUR_API_KEY" +} +``` + +### 2.2 HTTP 回调服务 + +```json +{ + "http_service_config": { + "http_service_host": "127.0.0.1", + "http_service_port": 8080 + } +} +``` + +### 2.3 物料类型映射 + +将 PLR 资源类名映射到外部系统的物料类型(名称 + UUID)。用于双向物料转换。 + +```json +{ + "material_type_mappings": { + "PLR_ResourceClassName": ["外部系统显示名", "external-type-uuid"], + "BIOYOND_PolymerStation_Reactor": ["反应器", "3a14233b-902d-0d7b-..."] + } +} +``` + +### 2.4 仓库映射 + +将仓库名映射到外部系统的仓库 UUID 和库位 UUID。用于入库/出库操作。 + +```json +{ + "warehouse_mapping": { + "仓库名": { + "uuid": "warehouse-uuid", + "site_uuids": { + "A01": "site-uuid-A01", + "A02": "site-uuid-A02" + } + } + } +} +``` + +### 2.5 工作流映射 + +将内部工作流名映射到外部系统的工作流 ID。 + +```json +{ + "workflow_mappings": { + "internal_workflow_name": "external-workflow-uuid" + } +} +``` + +### 2.6 物料默认参数 + +```json +{ + "material_default_parameters": { + "NMP": { + "unit": "毫升", + "density": "1.03", + "densityUnit": "g/mL", + "description": "N-甲基吡咯烷酮" + } + } +} +``` + +--- + +## 3. 资源同步机制 + +### 3.1 ResourceSynchronizer + +抽象基类,用于与外部物料系统双向同步。定义在 `workstation_base.py`。 + +```python +from unilabos.devices.workstation.workstation_base import ResourceSynchronizer + + +class MyResourceSynchronizer(ResourceSynchronizer): + def __init__(self, workstation, api_client): + super().__init__(workstation) + self.api_client = api_client + + def sync_from_external(self) -> bool: + """从外部系统拉取物料到 deck""" + external_materials = self.api_client.list_materials() + for material in external_materials: + plr_resource = self._convert_to_plr(material) + self.workstation.deck.assign_child_resource(plr_resource, coordinate) + return True + + def sync_to_external(self, plr_resource) -> bool: + """将 deck 中的物料变更推送到外部系统""" + external_data = self._convert_from_plr(plr_resource) + self.api_client.update_material(external_data) + return True + + def handle_external_change(self, change_info) -> bool: + """处理外部系统推送的物料变更""" + return True +``` + +### 3.2 update_resource — 上传资源树到云端 + +将 PLR Deck 序列化后通过 ROS 服务上传。典型使用场景: + +```python +# 在 post_init 中上传初始 deck +from unilabos.ros.nodes.base_device_node import ROS2DeviceNode + +ROS2DeviceNode.run_async_func( + self._ros_node.update_resource, True, + **{"resources": [self.deck]} +) + +# 在动作方法中更新特定资源 +ROS2DeviceNode.run_async_func( + self._ros_node.update_resource, True, + **{"resources": [updated_plate]} +) +``` + +--- + +## 4. 工作流序列管理 + +工作站通过 `workflow_sequence` 属性管理任务队列(JSON 字符串形式)。 + +```python +class MyWorkstation(WorkstationBase): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._workflow_sequence = [] + + @property + def workflow_sequence(self) -> str: + """返回 JSON 字符串,ROS 自动发布""" + import json + return json.dumps(self._workflow_sequence) + + async def append_to_workflow_sequence(self, workflow_name: str) -> Dict[str, Any]: + """添加工作流到队列""" + self._workflow_sequence.append({ + "name": workflow_name, + "status": "pending", + "created_at": time.time(), + }) + return {"success": True} + + async def clear_workflows(self) -> Dict[str, Any]: + """清空工作流队列""" + self._workflow_sequence = [] + return {"success": True} +``` + +--- + +## 5. 站间物料转移 + +工作站之间转移物料的模式。通过 ROS ActionClient 调用目标站的动作。 + +```python +async def transfer_materials_to_another_station( + self, + target_device_id: str, + transfer_groups: list, + **kwargs, +) -> Dict[str, Any]: + """将物料转移到另一个工作站""" + target_node = self._children.get(target_device_id) + if not target_node: + # 通过 ROS 节点查找非子设备的目标站 + pass + + for group in transfer_groups: + resource = self.find_resource_by_name(group["resource_name"]) + # 从本站 deck 移除 + resource.unassign() + # 调用目标站的接收方法 + # ... + + return {"success": True, "transferred": len(transfer_groups)} +``` + +参考:`BioyondDispensingStation.transfer_materials_to_reaction_station` + +--- + +## 6. post_init 完整模式 + +`post_init` 是工作站初始化的关键阶段,此时 ROS 节点和子设备已就绪。 + +```python +def post_init(self, ros_node): + super().post_init(ros_node) + + # 1. 初始化外部系统客户端(此时 config 已可用) + self.rpc_client = MySystemRPC( + host=self.config.get("api_host"), + api_key=self.config.get("api_key"), + ) + self.hardware_interface = self.rpc_client + + # 2. 启动连接监控 + self.connection_monitor = ConnectionMonitor(self) + self.connection_monitor.start() + + # 3. 启动 HTTP 回调服务 + if hasattr(self, '_http_service_config'): + self.http_service = WorkstationHTTPService( + workstation_instance=self, + host=self._http_service_config["host"], + port=self._http_service_config["port"], + ) + self.http_service.start() + + # 4. 上传 deck 到云端 + ROS2DeviceNode.run_async_func( + self._ros_node.update_resource, True, + **{"resources": [self.deck]} + ) + + # 5. 初始化资源同步器(可选) + self.resource_synchronizer = MyResourceSynchronizer(self, self.rpc_client) +``` diff --git a/.cursor/skills/edit-experiment-graph/SKILL.md b/.cursor/skills/edit-experiment-graph/SKILL.md new file mode 100644 index 000000000..fa2367897 --- /dev/null +++ b/.cursor/skills/edit-experiment-graph/SKILL.md @@ -0,0 +1,381 @@ +--- +name: edit-experiment-graph +description: Guide for creating and editing experiment graph files in Uni-Lab-OS (创建/编辑实验组态图). Covers node types, link types, parent-child relationships, deck configuration, and common graph patterns. Use when the user wants to create a graph file, edit an experiment configuration, set up device topology, or mentions 图文件/graph/组态/拓扑/实验图/experiment JSON. +--- + +# 创建/编辑实验组态图 + +实验图(Graph File)定义设备拓扑、物理连接和物料配置。系统启动时加载图文件,初始化所有设备和连接关系。 + +路径:`unilabos/test/experiments/.json` + +--- + +## 第一步:确认需求 + +向用户确认: + +| 信息 | 说明 | +|------|------| +| 场景类型 | 单设备调试 / 多设备联调 / 工作站完整图 | +| 包含的设备 | 设备 ID、注册表 class 名、配置参数 | +| 连接关系 | 物理连接(管道)/ 通信连接(串口)/ 无连接 | +| 父子关系 | 是否有工作站包含子设备 | +| 物料需求 | 是否需要 Deck、容器、试剂瓶 | + +--- + +## 第二步:JSON 顶层结构 + +```json +{ + "nodes": [], + "links": [] +} +``` + +> `links` 也可写作 `edges`,加载时两者等效。 + +--- + +## 第三步:定义 Nodes + +### 节点字段 + +| 字段 | 类型 | 必需 | 默认值 | 说明 | +|------|------|------|--------|------| +| `id` | string | **是** | — | 节点唯一标识,links 和 children 中引用此值 | +| `class` | string | **是** | — | 对应注册表名(设备/资源 YAML 的 key),容器可为 `null` | +| `name` | string | 否 | 同 `id` | 显示名称,缺省时自动用 `id` | +| `type` | string | 否 | `"device"` | 节点类型(见下表),缺省时自动设为 `"device"` | +| `children` | string[] | 否 | `[]` | 子节点 ID 列表 | +| `parent` | string\|null | 否 | `null` | 父节点 ID,顶层设备为 `null` | +| `position` | object | 否 | `{x:0,y:0,z:0}` | 空间坐标 | +| `config` | object | 否 | `{}` | 传给驱动 `__init__` 的参数 | +| `data` | object | 否 | `{}` | 初始运行状态 | +| `size_x/y/z` | float | 否 | — | 节点物理尺寸(工作站节点常用) | + +> 非标准字段(如 `api_host`)会自动移入 `config`。 + +### 节点类型 + +| `type` | 用途 | `class` 要求 | +|--------|------|-------------| +| `device` | 设备(默认) | 注册表中的设备名 | +| `deck` | 工作台面 | Deck 工厂函数/类名 | +| `container` | 容器(烧瓶、反应釜) | `null` 或具体容器类名 | + +### 设备节点模板 + +```json +{ + "id": "my_device", + "name": "我的设备", + "children": [], + "parent": null, + "type": "device", + "class": "registry_device_name", + "position": {"x": 0, "y": 0, "z": 0}, + "config": { + "port": "/dev/ttyUSB0", + "baudrate": 115200 + }, + "data": { + "status": "Idle" + } +} +``` + +### 容器节点模板 + +容器用于协议系统中表示试剂瓶、反应釜等,`class` 通常为 `null`: + +```json +{ + "id": "flask_DMF", + "name": "DMF试剂瓶", + "children": [], + "parent": "my_station", + "type": "container", + "class": null, + "position": {"x": 200, "y": 500, "z": 0}, + "config": {"max_volume": 1000.0}, + "data": { + "liquid": [{"liquid_type": "DMF", "liquid_volume": 800.0}] + } +} +``` + +### Deck 节点模板 + +```json +{ + "id": "my_deck", + "name": "my_deck", + "children": [], + "parent": "my_station", + "type": "deck", + "class": "MyStation_Deck", + "position": {"x": 0, "y": 0, "z": 0}, + "config": { + "type": "MyStation_Deck", + "setup": true, + "rotation": {"x": 0, "y": 0, "z": 0, "type": "Rotation"} + }, + "data": {} +} +``` + +--- + +## 第四步:定义 Links + +### Link 字段 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `source` | string | 源节点 ID | +| `target` | string | 目标节点 ID | +| `type` | string | `"physical"` / `"fluid"` / `"communication"` | +| `port` | object | 端口映射 `{source_id: "port_name", target_id: "port_name"}` | + +### 物理/流体连接 + +设备间的管道连接,协议系统用此查找路径: + +```json +{ + "source": "multiway_valve_1", + "target": "flask_DMF", + "type": "fluid", + "port": { + "multiway_valve_1": "2", + "flask_DMF": "outlet" + } +} +``` + +### 通信连接 + +设备间的串口/IO 通信代理,加载时自动将端口信息写入目标设备 config: + +```json +{ + "source": "pump_1", + "target": "serial_device", + "type": "communication", + "port": { + "pump_1": "port", + "serial_device": "port" + } +} +``` + +--- + +## 第五步:父子关系与工作站配置 + +### 工作站 + 子设备 + +工作站节点的 `children` 列出所有子节点 ID,子节点的 `parent` 指向工作站: + +```json +{ + "id": "my_station", + "children": ["my_deck", "pump_1", "valve_1", "reactor_1"], + "parent": null, + "type": "device", + "class": "workstation", + "config": { + "protocol_type": ["PumpTransferProtocol", "CleanProtocol"] + } +} +``` + +### 工作站 + Deck 引用 + +工作站节点中通过 `deck` 字段引用 Deck: + +```json +{ + "id": "my_station", + "children": ["my_deck", "sub_device_1"], + "deck": { + "data": { + "_resource_child_name": "my_deck", + "_resource_type": "unilabos.resources.my_module.decks:MyDeck" + } + } +} +``` + +**关键约束:** +- `_resource_child_name` 必须与 Deck 节点的 `id` 一致 +- `_resource_type` 为 Deck 类/工厂函数的完整 Python 路径 + +--- + +## 常见图模式 + +### 模式 A:单设备调试 + +最简形式,一个设备节点,无连接: + +```json +{ + "nodes": [ + { + "id": "my_device", + "name": "my_device", + "children": [], + "parent": null, + "type": "device", + "class": "motor.zdt_x42", + "position": {"x": 0, "y": 0, "z": 0}, + "config": {"port": "/dev/ttyUSB0", "baudrate": 115200}, + "data": {"status": "idle"} + } + ], + "links": [] +} +``` + +### 模式 B:Protocol 工作站(泵+阀+容器) + +工作站配合泵、阀、容器和物理连接,用于协议编译: + +```json +{ + "nodes": [ + { + "id": "station", "name": "协议工作站", + "class": "workstation", "type": "device", "parent": null, + "children": ["pump", "valve", "flask_solvent", "reactor", "waste"], + "config": {"protocol_type": ["PumpTransferProtocol"]} + }, + {"id": "pump", "name": "转移泵", "class": "virtual_transfer_pump", + "type": "device", "parent": "station", + "config": {"port": "VIRTUAL", "max_volume": 25.0}, + "data": {"status": "Idle", "position": 0.0, "valve_position": "0"}}, + {"id": "valve", "name": "多通阀", "class": "virtual_multiway_valve", + "type": "device", "parent": "station", + "config": {"port": "VIRTUAL", "positions": 8}}, + {"id": "flask_solvent", "name": "溶剂瓶", "type": "container", + "class": null, "parent": "station", + "config": {"max_volume": 1000.0}, + "data": {"liquid": [{"liquid_type": "DMF", "liquid_volume": 500}]}}, + {"id": "reactor", "name": "反应器", "type": "container", + "class": null, "parent": "station"}, + {"id": "waste", "name": "废液瓶", "type": "container", + "class": null, "parent": "station"} + ], + "links": [ + {"source": "pump", "target": "valve", "type": "fluid", + "port": {"pump": "transferpump", "valve": "transferpump"}}, + {"source": "valve", "target": "flask_solvent", "type": "fluid", + "port": {"valve": "1", "flask_solvent": "outlet"}}, + {"source": "valve", "target": "reactor", "type": "fluid", + "port": {"valve": "2", "reactor": "inlet"}}, + {"source": "valve", "target": "waste", "type": "fluid", + "port": {"valve": "3", "waste": "inlet"}} + ] +} +``` + +### 模式 C:外部系统工作站 + Deck + +```json +{ + "nodes": [ + { + "id": "bioyond_station", "class": "reaction_station.bioyond", + "parent": null, "children": ["bioyond_deck"], + "config": { + "api_host": "http://192.168.1.100:8080", + "api_key": "YOUR_KEY", + "material_type_mappings": {}, + "warehouse_mapping": {} + }, + "deck": { + "data": { + "_resource_child_name": "bioyond_deck", + "_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_PolymerReactionStation_Deck" + } + } + }, + { + "id": "bioyond_deck", "class": "BIOYOND_PolymerReactionStation_Deck", + "parent": "bioyond_station", "type": "deck", + "config": {"type": "BIOYOND_PolymerReactionStation_Deck", "setup": true} + } + ], + "links": [] +} +``` + +### 模式 D:通信代理(串口设备) + +泵通过串口设备通信,使用 `communication` 类型的 link。加载时系统会自动将串口端口信息写入泵的 `config`: + +```json +{ + "nodes": [ + {"id": "station", "name": "工作站", "type": "device", + "class": "workstation", "parent": null, + "children": ["serial_1", "pump_1"]}, + {"id": "serial_1", "name": "串口", "type": "device", + "class": "serial", "parent": "station", + "config": {"port": "COM7", "baudrate": 9600}}, + {"id": "pump_1", "name": "注射泵", "type": "device", + "class": "syringe_pump_with_valve.runze.SY03B-T08", "parent": "station"} + ], + "links": [ + {"source": "pump_1", "target": "serial_1", "type": "communication", + "port": {"pump_1": "port", "serial_1": "port"}} + ] +} +``` + +--- + +## 验证 + +```bash +# 启动测试 +unilab -g unilabos/test/experiments/.json --complete_registry + +# 仅检查注册表 +python -m unilabos --check_mode --skip_env_check +``` + +--- + +## 高级模式 + +处理复杂图文件时,详见 [reference.md](reference.md):ResourceDict 完整字段 schema、Pose 标准化规则、Handle 验证机制、GraphML 格式支持、外部系统工作站完整 config 结构。 + +--- + +## 常见错误 + +| 错误 | 原因 | 修复 | +|------|------|------| +| `class` 找不到 | 注册表中无此设备名 | 在 `unilabos/registry/devices/` 或 `resources/` 中搜索正确名称 | +| children/parent 不一致 | 子节点 `parent` 与父节点 `children` 不匹配 | 确保双向一致 | +| `_resource_child_name` 不匹配 | Deck 引用名与 Deck 节点 `id` 不同 | 保持一致 | +| Link 端口错误 | `port` 中的 key 不是 source/target 的 `id` | key 必须是对应节点的 `id` | +| 重复 UUID | 多个节点有相同 `uuid` | 删除或修改 UUID | + +--- + +## 参考路径 + +| 内容 | 路径 | +|------|------| +| 图文件目录 | `unilabos/test/experiments/` | +| 协议测试站 | `unilabos/test/experiments/Protocol_Test_Station/` | +| 图加载代码 | `unilabos/resources/graphio.py` | +| 节点模型 | `unilabos/resources/resource_tracker.py` | +| 设备注册表 | `unilabos/registry/devices/` | +| 资源注册表 | `unilabos/registry/resources/` | +| 用户文档 | `docs/user_guide/graph_files.md` | diff --git a/.cursor/skills/edit-experiment-graph/reference.md b/.cursor/skills/edit-experiment-graph/reference.md new file mode 100644 index 000000000..8582acd4f --- /dev/null +++ b/.cursor/skills/edit-experiment-graph/reference.md @@ -0,0 +1,255 @@ +# 实验图高级参考 + +本文件是 SKILL.md 的补充,包含 ResourceDict 完整 schema、Handle 验证、GraphML 格式、Pose 标准化规则和复杂图文件结构。Agent 在需要处理这些场景时按需阅读。 + +--- + +## 1. ResourceDict 完整字段 + +`unilabos/resources/resource_tracker.py` 中定义的节点数据模型: + +| 字段 | 类型 | 别名 | 说明 | +|------|------|------|------| +| `id` | `str` | — | 节点唯一标识 | +| `uuid` | `str` | — | 全局唯一标识 | +| `name` | `str` | — | 显示名称 | +| `description` | `str` | — | 描述(默认 `""` ) | +| `resource_schema` | `Dict[str, Any]` | `schema` | 资源 schema | +| `model` | `Dict[str, Any]` | — | 3D 模型信息 | +| `icon` | `str` | — | 图标(默认 `""` ) | +| `parent_uuid` | `Optional[str]` | — | 父节点 UUID | +| `parent` | `Optional[ResourceDict]` | — | 父节点引用(序列化时 exclude) | +| `type` | `Union[Literal["device"], str]` | — | 节点类型 | +| `klass` | `str` | `class` | 注册表类名 | +| `pose` | `ResourceDictPosition` | — | 位姿信息 | +| `config` | `Dict[str, Any]` | — | 配置参数 | +| `data` | `Dict[str, Any]` | — | 运行时数据 | +| `extra` | `Dict[str, Any]` | — | 扩展数据 | + +### Pose 完整结构(ResourceDictPosition) + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `size` | `{width, height, depth}` | `{0,0,0}` | 节点尺寸 | +| `scale` | `{x, y, z}` | `{1,1,1}` | 缩放比例 | +| `layout` | `"2d"/"x-y"/"z-y"/"x-z"` | `"x-y"` | 布局方向 | +| `position` | `{x, y, z}` | `{0,0,0}` | 2D 位置 | +| `position3d` | `{x, y, z}` | `{0,0,0}` | 3D 位置 | +| `rotation` | `{x, y, z}` | `{0,0,0}` | 旋转角度 | +| `cross_section_type` | `"rectangle"/"circle"/"rounded_rectangle"` | `"rectangle"` | 横截面形状 | + +--- + +## 2. Position / Pose 标准化规则 + +图文件中的 `position` 有多种写法,加载时自动标准化。 + +### 输入格式兼容 + +```json +// 格式 A: 直接 {x, y, z}(最常用) +"position": {"x": 100, "y": 200, "z": 0} + +// 格式 B: 嵌套 position +"position": {"position": {"x": 100, "y": 200, "z": 0}} + +// 格式 C: 使用 pose 字段 +"pose": {"position": {"x": 100, "y": 200, "z": 0}} + +// 格式 D: 顶层 x, y, z(无 position 字段) +"x": 100, "y": 200, "z": 0 +``` + +### 标准化流程 + +1. **graphio.py `canonicalize_nodes_data`**:若 `position` 不是 dict,从节点顶层提取 `x/y/z` 填入 `pose.position` +2. **resource_tracker.py `get_resource_instance_from_dict`**:若 `position.x` 存在(旧格式),转为 `{"position": {"x":..., "y":..., "z":...}}` +3. `pose.size` 从 `config.size_x/size_y/size_z` 自动填充 + +--- + +## 3. Handle 验证 + +启动时系统验证 link 中的 `sourceHandle` / `targetHandle` 是否在注册表的 `handles` 中定义。 + +```python +# unilabos/app/main.py (约 449-481 行) +source_handler_keys = [ + h["handler_key"] for h in materials[source_node.klass]["handles"] + if h["io_type"] == "source" +] +target_handler_keys = [ + h["handler_key"] for h in materials[target_node.klass]["handles"] + if h["io_type"] == "target" +] +if source_handle not in source_handler_keys: + print_status(f"节点 {source_node.id} 的source端点 {source_handle} 不存在", "error") + resource_edge_info.pop(...) # 移除非法 link +``` + +**Handle 定义在注册表 YAML 中:** + +```yaml +my_device: + handles: + - handler_key: access + io_type: target + data_type: fluid + side: NORTH + label: access +``` + +> 大多数简单设备不定义 handles,此验证仅对有 `sourceHandle`/`targetHandle` 的 link 生效。 + +--- + +## 4. GraphML 格式支持 + +除 JSON 外,系统也支持 GraphML 格式(`unilabos/resources/graphio.py::read_graphml`)。 + +### 与 JSON 的关键差异 + +| 特性 | JSON | GraphML | +|------|------|---------| +| 父子关系 | `parent`/`children` 字段 | `::` 分隔的节点 ID(如 `station::pump_1`) | +| 加载后 | 直接解析 | 先 `nx.read_graphml` 再转 JSON 格式 | +| 输出 | 不生成副本 | 自动生成等价的 `.json` 文件 | + +### GraphML 转换流程 + +``` +nx.read_graphml(file) + ↓ 用 label 重映射节点名 + ↓ 从 "::" 推断 parent_relation +nx.relabel_nodes + nx.node_link_data + ↓ canonicalize_nodes_data + canonicalize_links_ports + ↓ 写出等价 JSON 文件 +physical_setup_graph + handle_communications +``` + +--- + +## 5. 复杂图文件结构示例 + +### 外部系统工作站完整 config + +以 `reaction_station_bioyond.json` 为例,工作站 `config` 中的关键字段: + +```json +{ + "config": { + "api_key": "DE9BDDA0", + "api_host": "http://172.21.103.36:45388", + + "workflow_mappings": { + "scheduler_start": {"workflow": "start", "params": {}}, + "create_order": {"workflow": "create_order", "params": {}} + }, + + "material_type_mappings": { + "BIOYOND_PolymerStation_Reactor": ["反应器", "type-uuid-here"], + "BIOYOND_PolymerStation_1BottleCarrier": ["试剂瓶", "type-uuid-here"] + }, + + "warehouse_mapping": { + "堆栈1左": { + "uuid": "warehouse-uuid-here", + "site_uuids": { + "A01": "site-uuid-1", + "A02": "site-uuid-2" + } + } + }, + + "http_service_config": { + "enabled": true, + "host": "0.0.0.0", + "port": 45399, + "routes": ["/callback/workflow", "/callback/material"] + }, + + "deck": { + "data": { + "_resource_child_name": "Bioyond_Deck", + "_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_PolymerReactionStation_Deck" + } + }, + + "size_x": 2700.0, + "size_y": 1080.0, + "size_z": 2500.0, + "protocol_type": [], + "data": {} + } +} +``` + +### 子设备 Reactor 节点 + +```json +{ + "id": "reactor_1", + "name": "reactor_1", + "parent": "reaction_station_bioyond", + "type": "device", + "class": "bioyond_reactor", + "position": {"x": 1150, "y": 300, "z": 0}, + "config": { + "reactor_index": 0, + "bioyond_workflow_key": "reactor_1" + }, + "data": {} +} +``` + +### Deck 节点 + +```json +{ + "id": "Bioyond_Deck", + "name": "Bioyond_Deck", + "parent": "reaction_station_bioyond", + "type": "deck", + "class": "BIOYOND_PolymerReactionStation_Deck", + "position": {"x": 0, "y": 0, "z": 0}, + "config": { + "type": "BIOYOND_PolymerReactionStation_Deck", + "setup": true, + "rotation": {"x": 0, "y": 0, "z": 0, "type": "Rotation"} + }, + "data": {} +} +``` + +--- + +## 6. Link 端口标准化 + +`graphio.py::canonicalize_links_ports` 处理 `port` 字段的多种格式: + +```python +# 输入: 字符串格式 "(A,B)" +"port": "(pump_1, valve_1)" +# 输出: 字典格式 +"port": {"source_id": "pump_1", "target_id": "valve_1"} + +# 输入: 已是字典 +"port": {"pump_1": "port", "serial_1": "port"} +# 保持不变 + +# 输入: 无 port 字段 +# 自动补充空 port +``` + +--- + +## 7. 关键路径 + +| 内容 | 路径 | +|------|------| +| ResourceDict 模型 | `unilabos/resources/resource_tracker.py` | +| 图加载 + 标准化 | `unilabos/resources/graphio.py` | +| Handle 验证 | `unilabos/app/main.py` (449-481 行) | +| 反应站图文件 | `unilabos/test/experiments/reaction_station_bioyond.json` | +| 配液站图文件 | `unilabos/test/experiments/dispensing_station_bioyond.json` | +| 用户文档 | `docs/user_guide/graph_files.md` | diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..6cd38be82 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,11 @@ +## 设备接入 + +当被要求添加设备驱动时,参考 `docs/ai_guides/add_device.md`。 +该指南包含完整的模板和已有设备接口参考。 + +## 关键规则 + +- 动作方法的参数名是接口契约,不可重命名 +- `status` 字符串必须与同类已有设备一致 +- `self.data` 必须在 `__init__` 中预填充所有属性字段 +- 异步方法中使用 `await self._ros_node.sleep()`,禁止 `time.sleep()` diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..049ee6f83 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,21 @@ +# Uni-Lab-OS AI Agent 指南 + +## 设备接入 + +当用户要求添加/接入新设备时,读取 `docs/ai_guides/add_device.md` 并按其流程执行。 +该指南完全自包含,包含物模型模板、现有设备接口快照、常见错误和验证清单。 + +## 关键规则 + +- 动作方法的参数名是接口契约,不可重命名(如 `volume` 不能改为 `volume_ml`) +- `status` 字符串必须与同类已有设备一致(如 `"Idle"` 不能改为 `"就绪"`) +- `self.data` 必须在 `__init__` 中预填充所有属性字段 +- 异步方法中使用 `await self._ros_node.sleep()`,禁止 `time.sleep()` 和 `asyncio.sleep()` + +## 项目结构 + +- 设备驱动:`unilabos/devices//.py` +- 设备注册表:`unilabos/registry/devices/.yaml` +- 实验图文件:`unilabos/test/experiments/*.json` +- 人类开发文档:`docs/developer_guide/` +- AI 专用指南:`docs/ai_guides/` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..235d4c3bc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,14 @@ +# Uni-Lab-OS + +## 设备接入 + +读取 `docs/ai_guides/add_device.md` 获取完整的自包含指南。 +如果可以访问仓库,优先搜索 `unilabos/registry/devices/` 获取最新设备接口; +否则使用指南中内联的「现有设备接口快照」。 + +## 关键规则 + +- 动作方法的参数名是接口契约,不可重命名(如 `volume` 不能改为 `volume_ml`) +- `status` 字符串必须与同类已有设备一致(如 `"Idle"` 不能改为 `"就绪"`) +- `self.data` 必须在 `__init__` 中预填充所有属性字段 +- 异步方法中使用 `await self._ros_node.sleep()`,禁止 `time.sleep()` 和 `asyncio.sleep()` diff --git a/docs/ai_guides/add_device.md b/docs/ai_guides/add_device.md new file mode 100644 index 000000000..b5ca25c45 --- /dev/null +++ b/docs/ai_guides/add_device.md @@ -0,0 +1,1100 @@ +# Uni-Lab-OS 设备接入指南(AI 专用·自包含版) + +> **本文件是完全自包含的。** 即使你无法访问 Uni-Lab-OS 仓库,也能根据本指南正确生成设备驱动。 +> 如果你能访问仓库,建议搜索 `unilabos/registry/devices/` 目录获取最新的已有设备接口。 +> 最新版本也可通过 GitHub 获取:https://github.com/dptech-corp/Uni-Lab-OS/tree/main/unilabos/registry/devices/ + +端到端向导,通过**设备类别(物模型)** 和 **通信协议** 两个维度引导设备接入。 + +--- + +## 第一步:选择设备类别(物模型) + +每种设备类别有标准的属性和动作接口。向用户确认以下信息: + +**Q1: 设备属于哪个类别?** + +| 类别 ID | 说明 | 标准属性 | 标准动作 | +|---|---|---|---| +| `temperature` | 加热/冷却/温控 | `temp`, `temp_target`, `status` | `set_temperature`, `stop` | +| `pump_and_valve` | 泵、阀门、注射器 | 见下方子类型表 | 见下方子类型表 | +| `motor` | 电机、步进马达 | `position`, `status` | `enable`, `move_position`, `move_speed`, `stop` | +| `heaterstirrer` | 加热搅拌一体机 | `temp`, `stir_speed`, `status` | `set_temperature`, `stir`, `stop` | +| `balance` | 天平/称重 | `weight`, `unit`, `status` | `tare`, `read_weight` | +| `sensor` | 传感器(液位/温度/...) | `value`, `level`, `status` | `read_value`, `set_threshold` | +| `liquid_handling` | 液体处理机器人 | `status`, `deck_state` | `transfer_liquid`, `aspirate`, `dispense` | +| `robot_arm` | 机械臂 | `arm_pose`, `arm_status` | `moveit_task`, `pick_and_place` | +| `workstation` | 工作站(组合设备) | `workflow_sequence`, `material_info` | `create_order`, `scheduler_start`/`stop` | +| `virtual` | 虚拟/模拟设备 | 按模拟的真实设备定义 | 按模拟的真实设备定义 | +| `custom` | 不属于以上任何类别 | 用户自定义 | 用户自定义 | + +**pump_and_valve 子类型:** 该类别包含差异较大的子类型,下表仅列出**最小通用接口**。具体项目中可能有更多属性和动作,由第四步(对齐同类设备接口)动态发现。 + +| 子类型 | 最小通用属性 | 最小通用动作 | 单位约定 | +|---|---|---|---| +| 注射泵(syringe pump) | `status`, `valve_position`, `position`(mL) | `initialize`, `set_valve_position`, `set_position`(mL), `pull_plunger`(mL), `push_plunger`(mL), `stop_operation` | 体积=mL, 速度=mL/s | +| 电磁阀(solenoid valve) | `status`, `valve_position` | `open`, `close`, `set_valve_position` | — | +| 蠕动泵(peristaltic pump) | `status`, `speed` | `start`, `stop`, `set_speed` | 流速=mL/min | + +**单位约定(重要):** 设备对外暴露的属性和动作参数**必须使用用户友好的物理单位**,不能使用原始步数或寄存器值。驱动内部负责在物理单位和硬件原始值之间转换。 + +| 类别 | 位置/体积 | 速度 | 温度 | 其他 | +|---|---|---|---|---| +| pump_and_valve (注射泵) | **mL** | **mL/s** | — | — | +| pump_and_valve (蠕动泵) | — | **mL/min** | — | — | +| motor | **mm** 或 **度** | **mm/s** 或 **RPM** | — | — | +| temperature | — | — | **°C** | — | +| balance | **g** 或 **mg** | — | — | — | +| sensor | 按传感器物理量定 | — | — | — | + +**Q2: 设备英文名称?** (如 `my_heater`,用于类名和文件名) + +--- + +## 第二步:选择通信协议 + +**Q3: 设备使用什么通信协议?** + +| 协议 | config 参数 | 依赖包 | UniLab 现有抽象 | +|---|---|---|---| +| **Serial (RS232/RS485)** | `port`, `baudrate` | `pyserial` | 直接使用 `serial.Serial` | +| **Modbus RTU** | `port`, `baudrate`, `slave_id` | `pymodbus` | `device_comms/modbus_plc/`(RTUClient) | +| **Modbus TCP** | `host`, `port`, `slave_id` | `pymodbus` | `device_comms/modbus_plc/`(TCPClient) | +| **TCP Socket** | `host`, `port` | stdlib | 直接使用 `socket` | +| **HTTP API** | `url`, `token` | `requests` | `device_comms/rpc.py`(BaseRequest) | +| **OPC UA** | `url` | `opcua` | `device_comms/opcua_client/`(OpcUaClient) | +| **无通信(虚拟)** | 无 | 无 | 无 | + +--- + +## 第三步:收集指令协议(关键) + +物模型定义了设备"应该做什么",通信协议定义了"用什么方式通信",但**具体发什么指令**是硬件厂商私有的,AI 无法凭空生成。必须从以下来源获取: + +**Q4: 指令协议的信息来源?** + +| 来源 | AI 处理方式 | 示例 | +|---|---|---| +| **现成 SDK/驱动代码** | 读取代码,提取指令逻辑,包装进 UniLab 框架 | 用户提供 `.py` 文件或 pip 包名 | +| **协议文档/手册** | 读取文档(PDF/图片/文本),解析指令格式 | 用户提供通信协议手册 | +| **用户口述** | 按描述实现指令编解码 | "设温指令是 `01 06 00 0B` + 温度值 + CRC" | +| **标准协议** | 直接使用标准实现 | 标准 Modbus 寄存器表、SCPI 指令集 | +| **HTTP API 文档** | 读取 API 文档,映射到动作方法 | Swagger/OpenAPI 文档 | + +**根据来源执行对应流程:** + +### 场景 A:用户提供了现成 SDK 或驱动代码 + +1. 读取用户提供的驱动代码 +2. 分析其中的通信逻辑:初始化、指令编码、响应解码 +3. 将核心逻辑包装进 UniLab 设备类框架(加入 `self.data` 状态管理、`@property` 属性等) + +### 场景 B:用户提供了协议文档/手册 + +1. 读取文档(支持 PDF、图片、文本) +2. 从文档中提取: + - **指令格式**(文本型 `SET_TEMP 100\r\n`、二进制帧、Modbus 寄存器地址等) + - **响应格式**(如何解析返回数据) + - **寄存器/地址映射表**(哪个地址对应什么功能) +3. 实现指令编解码方法 + +### 场景 C:用户口头描述指令 + +逐个确认每个物模型动作对应的具体指令: + +``` +对于第一步选定的每个标准动作,询问: +- set_temperature → 硬件指令是什么?(如 Modbus 写寄存器 0x000B) +- read_temperature → 硬件指令是什么?(如 发送 0xfe 0xA2 0x00 0x00) +- stop → 硬件指令是什么? +``` + +### 场景 D:虚拟设备(无实际通信) + +跳过此步骤,动作方法中直接模拟行为(修改 `self.data`,用 `sleep` 模拟耗时)。 + +--- + +## 第四步:对齐同类设备接口(强制) + +第一步给出的是**最小通用接口**。本步骤在此基础上,对照仓库现有注册表,**补充**额外的属性和动作,确保新驱动能无缝替换同类设备。 + +> **此步骤是强制性的,不可跳过。** 跳过此步会导致参数名不匹配、status 字符串不一致、缺失属性等问题,使设备无法在工作流中正确运行。 + +**执行步骤:** + +1. 查阅下方「现有设备接口快照」章节,找到同类别的已有设备接口。如果你能访问仓库,建议直接搜索 `unilabos/registry/devices/` 目录获取最新版本。 + +2. 提取已有设备的**额外接口**(超出第一步最小通用接口的部分): + - **status_types** — 是否有额外属性? + - **action_value_mappings** — 是否有额外动作?**逐个记录参数名和类型** + - **status 字符串** — 已有设备用的是什么值?(如 `"Idle"` / `"Busy"` 还是中文?) + - **单位** — 确认单位是否与第一步约定一致 + +3. 对齐决策: + - 新驱动**必须实现**第一步的最小通用接口 + - 如果已有设备有额外属性/动作,**判断新硬件是否支持**: + - 硬件支持 → **必须实现**(保持接口一致) + - 硬件不支持 → 可提供合理的默认值或空实现,但属性必须存在 + - **参数名必须与已有设备完全一致**(这是最常出错的地方) + - **status 字符串值必须与已有设备一致** + - 可以**增加**新的属性和动作,但最小通用接口不能缺少 + +4. 如果同类别下没有已有设备,跳过对齐,按第一步的最小通用接口即可。 + +**对齐验证清单(完成第五步后必须逐项确认):** + +``` +- [ ] 所有动作方法的参数名与已有设备完全一致(如 volume 而非 volume_ml) +- [ ] status 属性返回的字符串值与已有设备一致(如 "Idle" 而非 "就绪") +- [ ] 已有设备的所有 status_types 字段在新驱动中都有对应 @property +- [ ] 已有设备的所有非 auto- 前缀的 action 在新驱动中都有对应方法 +- [ ] self.data 在 __init__ 中已预填充所有属性字段的默认值 +- [ ] 串口/二进制协议的响应解析先定位帧起始标记,不使用硬编码索引 +``` + +--- + +## 第五步:创建设备驱动文件 + +文件路径:`unilabos/devices//.py` + +### 核心结构 + +设备类 = 物模型标准接口 + 通信协议层 + 具体指令编解码: + +```python +import logging +import time as time_module +from typing import Dict, Any + +try: + from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode +except ImportError: + BaseROS2DeviceNode = None + + +class MyDevice: + """设备描述""" + + _ros_node: "BaseROS2DeviceNode" + + def __init__(self, device_id: str = None, config: Dict[str, Any] = None, **kwargs): + if device_id is None and 'id' in kwargs: + device_id = kwargs.pop('id') + if config is None and 'config' in kwargs: + config = kwargs.pop('config') + + self.device_id = device_id or "unknown_device" + self.config = config or {} + self.logger = logging.getLogger(f"MyDevice.{self.device_id}") + + # self.data 必须预填充所有 @property 对应的字段 + # status 字符串必须与同类已有设备一致(查看第四步) + self.data = { + "status": "Idle", + # "其他属性": 默认值, ← 每个 @property 都要有对应的键 + } + + # --- 通信层初始化(按第二步选择的协议填入)--- + # self.ser = serial.Serial(...) + # self.client = ModbusTcpClient(...) + + def post_init(self, ros_node: "BaseROS2DeviceNode"): + self._ros_node = ros_node + + async def initialize(self) -> bool: + self.data.update({"status": "Idle"}) + return True + + async def cleanup(self) -> bool: + self.data.update({"status": "Offline"}) + return True + + # --- 通信辅助方法(按第三步收集的指令协议实现)--- + # def _send_command(self, cmd: str) -> str: ... + + # --- 物模型标准动作(调用通信辅助方法发送实际指令)--- + # async def set_temperature(self, temp: float, **kwargs) -> bool: ... + + # --- 物模型标准属性 --- + @property + def status(self) -> str: + return self.data.get("status", "Idle") +``` + +### 关键规则 + +1. **参数类型转换** — 动作参数可能以字符串传入,必须显式 `float()`/`int()` 转换 +2. **异步等待** — 使用 `await self._ros_node.sleep()`,**禁止** `asyncio.sleep()`,也**禁止** `time.sleep()`(会阻塞事件循环) +3. **状态存储** — 用 `self.data` 字典存储,`@property` 读取并自动广播 +4. **进度反馈** — 长操作需循环更新 `self.data["status"]` 和 `remaining_time` +5. **返回值** — 返回 `bool` 或 `Dict[str, Any]`(含 `success` 字段),会显示在前端 + +### 禁止事项(严格遵守) + +以下是导致设备无法接入的常见错误,**必须逐条检查**: + +1. **禁止重命名模板参数** — 模板中的方法参数名(如 `volume`、`position`、`max_velocity`)是接口契约,框架通过参数名分派调用。**绝对不能**加后缀(如 `volume_ml`)、改名(如 `speed_ml_s`)或用其他"更可读"的名字替代。单位信息写在 docstring 中,不写在参数名中。 +2. **status 字符串必须与同类已有设备一致** — 如果已有设备使用英文(如 pump_and_valve 的 `"Idle"` / `"Busy"`),新驱动**必须使用相同的字符串**,不能改为中文。上层代码可能通过 `status == "Idle"` 来判断状态。 +3. **`self.data` 必须在 `__init__` 中预填充所有属性字段** — 不能用空字典 `{}`。框架在 `initialize()` 之前就可能读取属性值。每个 `@property` 对应的键都必须有初始值。 +4. **禁止跳过第四步** — 对齐同类设备接口是强制步骤,不是可选步骤。缺失的属性和动作会导致设备在工作流中不可互换。 +5. **禁止用硬编码索引解析串口响应** — RS-485 半双工总线上响应前常有回声/噪声字节。必须先定位帧起始标记(如 `/`、`0xFE`),再用相对偏移解析。否则所有解析方法(错误码、忙闲判断、数据提取)会同时出错,且部分可能歪打正着,造成隐蔽 bug。 + +### 特殊参数类型 + +需要前端资源/设备选择器时: + +```python +from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot + +def transfer(self, source: ResourceSlot, target: ResourceSlot, volume: float) -> Dict[str, Any]: + return {"success": True, "volume": volume} +``` + +| Python 类型 | 前端效果 | +|---|---| +| `ResourceSlot` | 单选资源下拉框 | +| `List[ResourceSlot]` | 多选资源下拉框 | +| `DeviceSlot` | 单选设备下拉框 | +| `List[DeviceSlot]` | 多选设备下拉框 | + +### 设备架构分支 + +| 场景 | 基类 | 说明 | +|---|---|---| +| 简单设备 | 无基类(纯 Python 类) | 大多数情况 | +| 工作站 | `WorkstationBase` | 组合多个子设备,有 Deck | +| 液体处理 | `LiquidHandlerAbstract` | PyLabRobot 集成 | +| Modbus 设备 | 可用 `device_comms/modbus_plc/` | 节点注册 + 工作流 | +| OPC UA 设备 | 可用 `device_comms/opcua_client/` | 节点发现 + CSV 配置 | + +--- + +## 第六步:创建注册表 YAML + +在 `unilabos/registry/devices/` 下创建。 + +### 最小配置(推荐) + +```yaml +my_device: + class: + module: unilabos.devices..:MyDevice + type: python +``` + +启动时 `--complete_registry` 自动生成 `status_types`、`action_value_mappings` 等全部字段。 + +### 手动补充(可选) + +```yaml +my_device: + category: + - temperature + description: "我的温控设备" + class: + module: unilabos.devices.temperature.my_heater:MyHeater + type: python +``` + +### 完整 YAML 结构参考 + +```yaml +my_device: + description: "设备描述" + version: "1.0.0" + category: [my_category] + icon: "" + handles: [] + class: + module: unilabos.devices.my_category.my_device:MyDevice + type: python + status_types: + status: String # str → String + temp: Float64 # float → Float64 + is_running: Bool # bool → Bool + position: Int64 # int → Int64 + action_value_mappings: + my_action: + type: UniLabJsonCommandAsync # 或 UniLabJsonCommand + goal: + param1: param1 + result: + success: success + goal_default: + param1: 0.0 + handles: {} + placeholder_keys: {} + schema: + title: my_action参数 + type: object + properties: + goal: + type: object + properties: + param1: + type: number + required: [param1] + required: [goal] +``` + +### Python → ROS 类型映射 + +| Python | ROS | YAML `status_types` | +|---|---|---| +| `str` | `std_msgs/String` | `String` | +| `bool` | `std_msgs/Bool` | `Bool` | +| `int` | `std_msgs/Int64` | `Int64` | +| `float` | `std_msgs/Float64` | `Float64` | +| `list`/`dict` | `std_msgs/String`(JSON 序列化) | `String` | + +--- + +## 第七步:配置图文件 + +在实验图文件(JSON)中添加设备节点: + +```json +{ + "id": "my_device_1", + "name": "我的设备", + "children": [], + "parent": null, + "type": "device", + "class": "my_device", + "position": {"x": 0, "y": 0, "z": 0}, + "config": { + "port": "/dev/ttyUSB0", + "baudrate": 9600 + }, + "data": {} +} +``` + +`config` 中的参数对应通信协议所需的连接信息,直接传入 `__init__` 的 `config` 字典。 + +--- + +## 第八步:验证 + +```bash +# 1. 模块可导入 +python -c "from unilabos.devices.. import " + +# 2. 注册表补全(可选) +unilab -g .json --complete_registry + +# 3. 启动测试 +unilab -g .json +``` + +--- + +## 工作流清单 + +``` +设备接入进度: +- [ ] 1. 确定设备类别(物模型)+ 单位约定 +- [ ] 2. 确定通信协议 +- [ ] 3. 收集指令协议(SDK/文档/口述) +- [ ] 4. 对齐同类设备接口(对照快照或搜索注册表) +- [ ] 5. 创建驱动 unilabos/devices//.py +- [ ] 6. 创建注册表 unilabos/registry/devices/.yaml +- [ ] 7. 配置图文件(如需要) +- [ ] 8. 验证可导入 + 启动测试 +``` + +--- + +## 现有设备接口快照 + +> 以下是仓库中已有设备的接口定义,用于第四步对齐。 +> 如果你能访问仓库,建议搜索 `unilabos/registry/devices/` 获取最新版本。 +> 最新版本也可通过 GitHub 获取: +> https://github.com/dptech-corp/Uni-Lab-OS/tree/main/unilabos/registry/devices/ + +### pump_and_valve — 注射泵子类型 + +已有设备:`syringe_pump_with_valve.runze.SY03B-T06` / `SY03B-T08` +驱动类:`unilabos.devices.pump_and_valve.runze_backbone:RunzeSyringePump` + +**status_types(属性):** + +| 属性名 | 类型 | 说明 | +|---|---|---| +| `status` | `str` | `"Idle"` / `"Busy"` | +| `valve_position` | `str` | 阀门位置 | +| `position` | `float` | 当前体积 (mL) | +| `max_velocity` | `float` | 最大速度 (mL/s) | +| `mode` | `int` | 运行模式 | +| `plunger_position` | `String` | 活塞位置 | +| `velocity_grade` | `String` | 速度档位 | +| `velocity_init` | `String` | 初始速度 | +| `velocity_end` | `String` | 终止速度 | + +**关键动作方法签名(参数名不可修改):** + +```python +def initialize(self) +def set_valve_position(self, position) # 参数名必须是 position +def set_position(self, position: float, max_velocity: float = None) +def pull_plunger(self, volume: float) # 参数名必须是 volume +def push_plunger(self, volume: float) # 参数名必须是 volume +def set_max_velocity(self, velocity: float) +def set_velocity_grade(self, velocity) +def stop_operation(self) +def send_command(self, full_command: str) +def set_baudrate(self, baudrate) +def close(self) +``` + +### pump_and_valve — 电磁阀子类型 + +已有设备:`solenoid_valve` / `solenoid_valve.mock` +驱动类:`unilabos.devices.pump_and_valve.solenoid_valve:SolenoidValve` + +**status_types:** + +| 属性名 | 类型 | 说明 | +|---|---|---| +| `status` | `str` | 状态 | +| `valve_position` | `str` | 阀门位置 | + +**关键动作方法签名:** + +```python +def open(self) +def close(self) +def set_valve_position(self, position) # 参数名是 position +def is_open(self) +def is_closed(self) +def send_command(self, command: str) +``` + +### temperature — 温控设备 + +已有设备:`dalong_heaterstirrer`(加热搅拌器) +驱动类:`unilabos.devices.temperature.dalong:DalongHeaterStirrer` + +**status_types:** + +| 属性名 | 类型 | 说明 | +|---|---|---| +| `status` | `str` | 状态 | +| `temp` | `float` | 当前温度 (°C) | +| `temp_target` | `float` | 目标温度 (°C) | +| `stir_speed` | `float` | 搅拌速度 (RPM) | +| `temp_warning` | `float` | 警告温度 (°C) | + +### motor — 电机设备 + +已有设备:`zdt_x42`(闭环步进电机) +驱动类:`unilabos.devices.motor.zdt_x42:ZDTX42Motor` + +**status_types:** + +| 属性名 | 类型 | 说明 | +|---|---|---| +| `status` | `str` | 状态 | +| `position` | `int` | 当前位置 | + +### sensor — 传感器 + +已有设备:`xkc_level_sensor`(液位传感器) +驱动类:`unilabos.devices.sensor.xkc_level_sensor:XKCLevelSensor` + +**status_types:** + +| 属性名 | 类型 | 说明 | +|---|---|---| +| `level` | `bool` | 液位状态 | +| `rssi` | `int` | 信号强度 | + +--- + +## 物模型代码模板 + +### temperature — 温控设备 + +```python +class MyTemperatureDevice: + """温控设备:加热器、冷却器、恒温槽等""" + + def __init__(self, device_id=None, config=None, **kwargs): + # ... 标准 init ... + self.data = { + "status": "Idle", + "temp": 25.0, + "temp_target": 25.0, + } + + async def set_temperature(self, temp: float, **kwargs) -> bool: + """设定目标温度 (°C)""" + temp = float(temp) + self.data["temp_target"] = temp + # >>> 在此填入实际指令 <<< + return True + + async def stop(self, **kwargs) -> bool: + self.data["status"] = "Idle" + # >>> 在此填入实际指令 <<< + return True + + @property + def temp(self) -> float: + return self.data.get("temp", 0.0) + + @property + def temp_target(self) -> float: + return self.data.get("temp_target", 0.0) + + @property + def status(self) -> str: + return self.data.get("status", "Idle") +``` + +### pump_and_valve — 注射泵 + +> **严禁重命名参数!** 下方模板中的参数名(`volume`、`position`、`max_velocity` 等)是接口契约。禁止加后缀(如 ~~`volume_ml`~~)、改名(如 ~~`speed_ml_s`~~)或用其他名字替代。单位信息写在 docstring 里,不写在参数名中。 + +```python +class MySyringePump: + """注射泵设备 — 含阀门控制""" + + def __init__(self, device_id=None, config=None, **kwargs): + # ... 标准 init ... + self.max_volume = float(config.get("max_volume", 25.0)) + self.total_steps = 6000 + self.data = { + "status": "Idle", # 必须用英文 "Idle" / "Busy" + "valve_position": "I", + "position": 0.0, # 当前体积位置 (mL) + # 第四步可能要求补充更多字段(如 max_velocity, mode 等) + } + + def initialize(self): + # >>> 发送初始化指令 <<< + return response + + def set_valve_position(self, position): + """设置阀门位置。参数名必须是 position""" + # >>> 发送阀门指令 <<< + return response + + def set_position(self, position: float, max_velocity: float = None): + """移动到绝对体积位置 (mL)。参数名 position / max_velocity 不可修改""" + pos_step = int(float(position) / self.max_volume * self.total_steps) + # >>> 发送绝对位置指令 <<< + return response + + def pull_plunger(self, volume: float): + """吸液 (mL)。参数名必须是 volume""" + pos_step = int(float(volume) / self.max_volume * self.total_steps) + # >>> 发送相对吸液指令 <<< + return response + + def push_plunger(self, volume: float): + """排液 (mL)。参数名必须是 volume""" + pos_step = int(float(volume) / self.max_volume * self.total_steps) + # >>> 发送相对排液指令 <<< + return response + + def stop_operation(self): + # >>> 发送终止指令 <<< + return response + + def close(self): + self.hardware_interface.close() + + @property + def status(self) -> str: + return self._status # "Idle" 或 "Busy" + + @property + def valve_position(self) -> str: + return self._valve_position + + @property + def position(self) -> float: + """当前体积位置 (mL)""" + return self._position +``` + +### pump_and_valve — 电磁阀 + +```python +class MySolenoidValve: + def __init__(self, device_id=None, config=None, **kwargs): + self.data = {"status": "Idle", "valve_position": "closed"} + + async def open(self, **kwargs) -> bool: + return True + + async def close(self, **kwargs) -> bool: + return True + + async def set_valve_position(self, position: str, **kwargs) -> bool: + self.data["valve_position"] = str(position) + return True + + @property + def valve_position(self) -> str: + return self.data.get("valve_position", "closed") + + @property + def status(self) -> str: + return self.data.get("status", "Idle") +``` + +### pump_and_valve — 蠕动泵 + +```python +class MyPeristalticPump: + def __init__(self, device_id=None, config=None, **kwargs): + self.data = {"status": "Idle", "speed": 0.0, "direction": "CW"} + + async def set_speed(self, speed: float, **kwargs) -> bool: + """设置流速 (mL/min)""" + self.data["speed"] = float(speed) + return True + + async def stop(self, **kwargs) -> bool: + self.data["speed"] = 0.0 + self.data["status"] = "Idle" + return True + + @property + def speed(self) -> float: + return self.data.get("speed", 0.0) + + @property + def status(self) -> str: + return self.data.get("status", "Idle") +``` + +### motor — 电机设备 + +```python +class MyMotor: + def __init__(self, device_id=None, config=None, **kwargs): + self.data = {"status": "Idle", "position": 0, "speed": 0.0} + + async def enable(self, **kwargs) -> bool: + self.data["status"] = "Enabled" + return True + + async def move_position(self, position: int, speed: float = 100.0, **kwargs) -> bool: + position, speed = int(position), float(speed) + return True + + async def move_speed(self, speed: float, **kwargs) -> bool: + self.data["speed"] = float(speed) + return True + + async def stop(self, **kwargs) -> bool: + self.data["status"] = "Idle" + self.data["speed"] = 0.0 + return True + + @property + def position(self) -> int: + return self.data.get("position", 0) + + @property + def status(self) -> str: + return self.data.get("status", "Idle") +``` + +### heaterstirrer — 加热搅拌 + +```python +class MyHeaterStirrer: + def __init__(self, device_id=None, config=None, **kwargs): + self.data = { + "status": "Idle", "temp": 25.0, "temp_target": 25.0, + "stir_speed": 0.0, "is_stirring": False, + } + + async def set_temperature(self, temp: float, **kwargs) -> bool: + self.data["temp_target"] = float(temp) + return True + + async def stir(self, stir_speed: float, stir_time: float = 0, settling_time: float = 0, **kwargs) -> bool: + self.data["stir_speed"] = float(stir_speed) + self.data["is_stirring"] = True + if stir_time > 0: + start = time_module.time() + while time_module.time() - start < stir_time: + self.data["remaining_time"] = max(0, stir_time - (time_module.time() - start)) + await self._ros_node.sleep(1.0) + self.data["is_stirring"] = False + return True + + async def stop(self, **kwargs) -> bool: + self.data.update({"status": "Idle", "stir_speed": 0.0, "is_stirring": False}) + return True + + @property + def temp(self) -> float: + return self.data.get("temp", 25.0) + + @property + def stir_speed(self) -> float: + return self.data.get("stir_speed", 0.0) + + @property + def status(self) -> str: + return self.data.get("status", "Idle") +``` + +### balance — 天平 + +```python +class MyBalance: + def __init__(self, device_id=None, config=None, **kwargs): + self.data = {"status": "Idle", "weight": 0.0, "unit": "g", "stable": True} + + def read_weight(self, **kwargs) -> Dict[str, Any]: + return {"success": True, "weight_g": self.data["weight"], "stable": self.data["stable"]} + + def tare(self, **kwargs) -> Dict[str, Any]: + self.data["weight"] = 0.0 + return {"success": True, "message": "去皮完成"} + + @property + def weight(self) -> float: + return self.data.get("weight", 0.0) + + @property + def status(self) -> str: + return self.data.get("status", "Idle") +``` + +### sensor — 传感器 + +```python +class MySensor: + def __init__(self, device_id=None, config=None, **kwargs): + self.data = {"status": "Idle", "value": 0.0, "level": False} + + def read_value(self, **kwargs) -> Dict[str, Any]: + return {"success": True, "value": self.data["value"]} + + async def wait_for_level(self, target_level: bool = True, timeout: float = 60.0, **kwargs) -> bool: + start = time_module.time() + while time_module.time() - start < float(timeout): + if self.data["level"] == bool(target_level): + return True + await self._ros_node.sleep(0.5) + return False + + @property + def value(self) -> float: + return self.data.get("value", 0.0) + + @property + def level(self) -> bool: + return self.data.get("level", False) + + @property + def status(self) -> str: + return self.data.get("status", "Idle") +``` + +--- + +## 指令协议模式 + +通信协议解决"用什么方式通信",指令协议解决"发什么内容"。 + +### 模式 1:文本指令 + +```python +def _send_command(self, cmd: str) -> str: + self.ser.write(f"{cmd}\r\n".encode()) + return self.ser.readline().decode().strip() +``` + +### 模式 2:自定义二进制帧 + +```python +def _build_frame(self, func_code: int, data: bytes) -> bytes: + frame = bytearray([0xFE, func_code]) + bytearray(data) + while len(frame) < 5: + frame.append(0x00) + checksum = sum(frame[1:]) % 256 + frame.append(checksum) + return bytes(frame) + +def _send_frame(self, func_code: int, data: bytes) -> bytes: + frame = self._build_frame(func_code, data) + self.ser.write(frame) + return self.ser.read(6) +``` + +### 模式 3:Modbus 寄存器读写 + +```python +REGISTER_MAP = { + "temp_target": {"addr": 0x000B, "scale": 10}, + "temp_current": {"addr": 0x0001, "scale": 10}, +} + +def set_temperature(self, temp: float, **kwargs) -> bool: + temp = float(temp) + reg = REGISTER_MAP["temp_target"] + value = int(temp * reg["scale"]) & 0xFFFF + self.client.write_register(reg["addr"], value, slave=self.slave_id) + self.data["temp_target"] = temp + return True +``` + +### 模式 4:JSON/REST API + +```python +API_MAP = { + "set_temperature": {"method": "POST", "endpoint": "/api/temperature", "body_key": "target"}, + "get_status": {"method": "GET", "endpoint": "/api/status"}, +} + +def set_temperature(self, temp: float, **kwargs) -> bool: + api = API_MAP["set_temperature"] + resp = self._post(api["endpoint"], {api["body_key"]: float(temp)}) + return resp.get("success", False) +``` + +### 模式 5:SDK 封装 + +```python +from my_device_sdk import DeviceController + +class MyDevice: + def __init__(self, device_id=None, config=None, **kwargs): + self.controller = DeviceController(port=config.get('port', 'COM1')) + self.data = {"status": "Idle"} + + def set_temperature(self, temp: float, **kwargs) -> bool: + self.controller.set_target_temp(float(temp)) + return True +``` + +--- + +## 通信协议代码片段 + +### Serial(RS232 / RS485) + +```python +import serial + +self.ser = serial.Serial( + port=self.config.get('port', 'COM1'), + baudrate=self.config.get('baudrate', 9600), + timeout=self.config.get('timeout', 1), +) + +# cleanup: +if hasattr(self, 'ser') and self.ser.is_open: + self.ser.close() +``` + +**串口响应解析健壮性(重要):** RS-485 半双工总线上,设备响应前经常有前导垃圾字节(TX 回声、总线噪声等)。**禁止用硬编码索引直接解析原始响应**,必须先定位帧起始标记: + +```python +# ✗ 错误 — 假设响应从 index 0 开始,前导垃圾字节会导致所有解析偏移 +status_byte = ord(response[2]) +data = response[3:etx_pos] + +# ✓ 正确 — 先找到帧起始标记,再用相对偏移解析 +def _normalize_response(self, raw: str, start_marker: str = "/") -> str: + """去除帧起始标记之前的垃圾字节""" + pos = raw.find(start_marker) + return raw[pos:] if pos >= 0 else raw + +# 在 _send_command 返回前调用: +resp_str = self._normalize_response(resp_str) +``` + +同理,二进制帧协议也必须先查找帧头字节(如 `0xFE`),不能假设 `response[0]` 就是帧头。 + +### Modbus RTU + +```python +from pymodbus.client import ModbusSerialClient + +self.client = ModbusSerialClient( + port=self.config.get('port', 'COM1'), + baudrate=self.config.get('baudrate', 9600), + timeout=self.config.get('timeout', 1), +) +self.client.connect() +self.slave_id = self.config.get('slave_id', 1) +``` + +### Modbus TCP + +```python +from pymodbus.client import ModbusTcpClient + +self.client = ModbusTcpClient( + host=self.config.get('host', '192.168.1.100'), + port=self.config.get('port', 502), +) +self.client.connect() +self.slave_id = self.config.get('slave_id', 1) +``` + +### TCP Socket + +```python +import socket + +self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +self.sock.settimeout(self.config.get('timeout', 5)) +self.sock.connect((self.config['host'], self.config['port'])) +``` + +### HTTP API + +```python +import requests + +self.base_url = self.config.get('url', 'http://localhost:8080') +self.session = requests.Session() +``` + +### OPC UA + +```python +from opcua import Client + +self.opc_client = Client(self.config.get('url', 'opc.tcp://localhost:4840')) +self.opc_client.connect() +``` + +--- + +## 常见错误(必读) + +以下是历史上导致设备无法接入的真实案例,**生成代码后必须逐条对照检查**: + +### 错误 1:重命名模板参数名 + +```python +# ✗ 错误 +async def pull_plunger(self, volume_ml: float, speed_ml_s: float = None, **kwargs): +# ✓ 正确 +async def pull_plunger(self, volume: float, **kwargs): + +# ✗ 错误 +async def set_position(self, position_ml: float, speed_ml_s: float = None, **kwargs): +# ✓ 正确 +async def set_position(self, position: float, max_velocity: float = None, **kwargs): + +# ✗ 错误 +async def set_valve_position(self, valve_position: int, **kwargs): +# ✓ 正确 +async def set_valve_position(self, position, **kwargs): +``` + +### 错误 2:status 字符串使用中文 + +```python +# ✗ 错误 +self.data["status"] = "就绪" +# ✓ 正确 +self.data["status"] = "Idle" +``` + +### 错误 3:self.data 初始化为空字典 + +```python +# ✗ 错误 +self.data = {} +# ✓ 正确 +self.data = {"status": "Idle", "valve_position": "I", "position": 0.0, "max_velocity": 0.0} +``` + +### 错误 4:跳过第四步,缺失已有设备的属性 + +```python +# ✓ 即使硬件不直接支持,也要提供属性(返回默认值) +@property +def max_velocity(self) -> float: + return self.data.get("max_velocity", 0.0) +``` + +### 错误 5:在 async 方法中使用 time.sleep() + +```python +# ✗ 错误 +time.sleep(0.5) +# ✓ 正确 +await self._ros_node.sleep(0.5) +``` + +### 错误 6:用硬编码索引解析串口响应 + +```python +# ✗ 错误 — RS-485 响应前有回声/噪声字节时,所有索引偏移,解析全部出错 +# 而且 _parse_error / _is_busy 可能歪打正着返回"正确"结果, +# 导致轮询失效(永远认为设备空闲)、错误被吞、状态查询异常 +status_byte = ord(response[2]) +data = response[3:etx_pos] + +# ✓ 正确 — 先定位帧起始标记(如 /、0xFE 等),再用相对偏移 +start = response.find("/") +if start >= 0: + response = response[start:] +status_byte = ord(response[2]) +data = response[3:etx_pos] +``` + +**规则:** 串口协议解析必须先定位帧起始标记,禁止假设 `response[0]` 就是帧头。 + +--- + +## 返回值设计 + +```python +return { + "success": True, + "message": "操作完成", + "temperature_celsius": 25.5, +} +``` + +--- + +## 图文件:工作站配置 + +工作站需要 `deck` 和 `children`: + +```json +{ + "nodes": [ + { + "id": "my_station", + "type": "device", + "class": "my_workstation", + "children": ["my_deck"], + "config": {}, + "deck": { + "data": { + "_resource_child_name": "my_deck", + "_resource_type": "unilabos.resources.my_module:MyDeck" + } + } + }, + { + "id": "my_deck", + "type": "deck", + "class": "MyDeckClass", + "parent": "my_station", + "children": [], + "config": {"type": "MyDeckClass", "setup": true} + } + ] +} +``` diff --git a/docs/ai_guides/agent_prompt_template.md b/docs/ai_guides/agent_prompt_template.md new file mode 100644 index 000000000..99dee3386 --- /dev/null +++ b/docs/ai_guides/agent_prompt_template.md @@ -0,0 +1,344 @@ +# Uni-Lab-OS 设备接入 Agent — 提示词模板 + +> 本文件提供一套可直接复制使用的 Agent 系统提示词,以及各平台的配置说明。 +> 提示词模板与 `add_device.md`(领域知识)配合使用,前者控制 Agent 行为,后者提供完整的技术细节。 + +--- + +## 系统提示词模板 + +以下内容可直接作为系统提示词 / Instructions / Custom Instructions 使用。`{{...}}` 标记的变量根据平台替换。 + +--- + +### 开始复制 ↓ + +``` +你是 Uni-Lab-OS 设备接入专家。你的任务是帮助用户将新的实验室硬件设备接入 Uni-Lab-OS 系统。 + +你能做的事: +- 根据用户描述,生成完整的设备驱动代码(Python)、注册表(YAML)和实验图文件(JSON) +- 解读用户提供的通信协议文档、SDK 代码、或口述的指令格式 +- 诊断已有驱动代码的接口对齐问题 + +你不能做的事: +- 凭空猜测硬件私有通信指令(必须从用户提供的资料中获取) +- 替代真实硬件联调测试 + +## 知识来源 + +{{KNOWLEDGE_LOADING}} + +## 工作流程 + +当用户要求接入新设备时,严格按以下流程执行。每个暂停点必须等待用户确认后再继续。 + +### 阶段 1:设备画像(交互) + +向用户收集以下三个信息,可以一次性提问: + +1. **设备类别** — 属于以下哪一种? + - temperature(温控)、pump_and_valve(泵阀)、motor(电机) + - heaterstirrer(加热搅拌)、balance(天平)、sensor(传感器) + - liquid_handling(液体处理)、robot_arm(机械臂)、workstation(工作站) + - virtual(虚拟设备)、custom(自定义) + - 如果是 pump_and_valve,进一步确认子类型:注射泵 / 电磁阀 / 蠕动泵 + +2. **设备英文名称** — 用于文件名和类名(如 my_heater、runze_sy03b) + +3. **通信协议** — Serial(RS232/RS485) / Modbus RTU / Modbus TCP / TCP Socket / HTTP API / OPC UA / 无通信(虚拟) + +⏸️ **暂停:等待用户回答后继续** + +### 阶段 2:指令协议收集(交互) + +根据上一步确定的通信协议,引导用户提供指令信息: + +- 如果用户有 **SDK/驱动代码**:请用户提供代码文件,你从中提取通信逻辑 +- 如果用户有 **协议文档**:请用户提供文档(PDF/图片/文本),你从中解析指令格式 +- 如果用户 **口头描述**:针对每个标准动作逐一确认硬件指令 +- 如果是 **标准协议**(Modbus 寄存器表、SCPI):请用户提供寄存器/指令映射 +- 如果是 **虚拟设备**:跳过此阶段 + +⏸️ **暂停:确认已获取足够的指令协议信息** + +### 阶段 3:确认摘要 + +在开始生成代码前,向用户展示你的理解摘要: + +``` +设备接入摘要: +- 设备名称: +- 设备类别:) +- 通信协议: +- 指令来源: +- 将要实现的属性: +- 将要实现的动作: +- 同类已有设备:(将对齐其接口) +``` + +⏸️ **暂停:用户确认"没问题"后再生成代码** + +### 阶段 4:自动生成(无需暂停) + +按以下顺序自动执行: + +1. **对齐同类设备接口**(指南第四步) + - 查阅指南中的「现有设备接口快照」或搜索仓库注册表 + - 确保所有已有设备的 status_types 和动作方法都被覆盖 + - 参数名必须完全一致 + +2. **生成驱动代码** — `unilabos/devices//.py` + +3. **生成注册表** — `unilabos/registry/devices/.yaml`(最小配置) + +4. **生成图文件** — `unilabos/test/experiments/graph_example_.json` + +### 阶段 5:验证输出 + +生成完成后,逐项检查对齐验证清单并展示结果: + +``` +对齐验证清单: +- [x] 所有动作方法的参数名与已有设备完全一致 +- [x] status 属性返回的字符串值与已有设备一致 +- [x] 已有设备的所有 status_types 字段都有对应 @property +- [x] 已有设备的所有非 auto- 前缀的 action 都有对应方法 +- [x] self.data 在 __init__ 中已预填充所有属性字段的默认值 +- [x] 串口/二进制协议的响应解析先定位帧起始标记 +``` + +如果有未通过的项,主动修复后再展示。 + +## 硬约束(违反任何一条都会导致设备接入失败) + +1. **禁止重命名参数** — 动作方法的参数名(如 volume、position、max_velocity)是接口契约,框架通过参数名分派调用。绝不能加后缀(如 volume_ml)、改名(如 speed_ml_s)。单位写在 docstring 中。 + +2. **status 字符串必须一致** — 如果同类已有设备用英文(如 "Idle" / "Busy"),新驱动必须用相同的字符串,不能改为中文(如 "就绪")。 + +3. **self.data 必须预填充** — 不能用空字典 {}。框架在 initialize() 之前就可能读取属性值。每个 @property 对应的键都必须在 __init__ 中有初始值。 + +4. **禁止跳过接口对齐** — 对齐同类设备接口是强制步骤。缺失的属性和动作会导致设备在工作流中不可互换。 + +5. **串口解析先找帧头** — RS-485 总线上响应前常有回声/噪声字节。必须先定位帧起始标记(如 /、0xFE),禁止用硬编码索引直接解析。 + +6. **异步等待用 _ros_node.sleep** — 在 async 方法中使用 await self._ros_node.sleep(),禁止 time.sleep()(阻塞事件循环)和 asyncio.sleep()。 + +7. **物理单位对外暴露** — 对外参数使用用户友好的物理单位(mL、°C、RPM),驱动内部负责转换到硬件原始值(步数、Hz、寄存器值)。 + +## 代码骨架参考 + +所有设备驱动遵循以下结构: + +```python +import logging +import time as time_module +from typing import Dict, Any + +try: + from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode +except ImportError: + BaseROS2DeviceNode = None + +class MyDevice: + _ros_node: "BaseROS2DeviceNode" + + def __init__(self, device_id: str = None, config: Dict[str, Any] = None, **kwargs): + if device_id is None and 'id' in kwargs: + device_id = kwargs.pop('id') + if config is None and 'config' in kwargs: + config = kwargs.pop('config') + self.device_id = device_id or "unknown_device" + self.config = config or {} + self.logger = logging.getLogger(f"MyDevice.{self.device_id}") + self.data = { + "status": "Idle", + # 所有 @property 的键都必须在此预填充 + } + + def post_init(self, ros_node: "BaseROS2DeviceNode"): + self._ros_node = ros_node + + async def initialize(self) -> bool: + self.data["status"] = "Idle" + return True + + async def cleanup(self) -> bool: + self.data["status"] = "Offline" + return True + + @property + def status(self) -> str: + return self.data.get("status", "Idle") +``` + +## 注册表最小配置 + +```yaml +my_device: + class: + module: unilabos.devices..:MyDevice + type: python +``` + +启动时 --complete_registry 自动生成 status_types 和 action_value_mappings。 + +## 图文件模板 + +```json +{ + "nodes": [ + { + "id": "my_device_1", + "name": "设备名称", + "children": [], + "parent": null, + "type": "device", + "class": "my_device", + "position": {"x": 0, "y": 0, "z": 0}, + "config": {}, + "data": {} + } + ] +} +``` + +## 现有设备接口快照(对齐用) + +对齐时参考以下已有设备接口。如果能联网,优先从 GitHub 获取最新版本: +https://github.com/dptech-corp/Uni-Lab-OS/tree/main/unilabos/registry/devices/ + +### pump_and_valve — 注射泵 + +已有设备:syringe_pump_with_valve.runze.SY03B-T06 + +属性:status(str, "Idle"/"Busy"), valve_position(str), position(float, mL), max_velocity(float, mL/s), mode(int), plunger_position(String), velocity_grade(String), velocity_init(String), velocity_end(String) + +方法签名(参数名不可改): +- initialize() +- set_valve_position(position) +- set_position(position: float, max_velocity: float = None) +- pull_plunger(volume: float) +- push_plunger(volume: float) +- set_max_velocity(velocity: float) +- set_velocity_grade(velocity) +- stop_operation() + +### pump_and_valve — 电磁阀 + +属性:status(str), valve_position(str) +方法:open(), close(), set_valve_position(position), is_open(), is_closed() + +### temperature + +属性:status(str), temp(float, °C), temp_target(float, °C), stir_speed(float, RPM), temp_warning(float, °C) + +### motor + +属性:status(str), position(int) + +### sensor + +属性:level(bool), rssi(int) +``` + +### 结束复制 ↑ + +--- + +## `{{KNOWLEDGE_LOADING}}` 变量替换 + +根据平台能力,将提示词中的 `{{KNOWLEDGE_LOADING}}` 替换为以下对应内容: + +### 方案 A:有知识库(Custom GPT / Claude Project) + +``` +你的知识库中包含 add_device.md 文件,这是完整的设备接入指南。 +执行工作流时,参考该文件获取物模型模板、通信协议代码片段、指令协议模式和常见错误检查清单。 +本提示词中的「现有设备接口快照」和「硬约束」是从指南中提炼的关键内容,以确保即使知识库检索不完整也能正确工作。 +``` + +### 方案 B:有联网能力 + +``` +执行工作流前,从以下 URL 获取完整的设备接入指南: +https://raw.githubusercontent.com/dptech-corp/Uni-Lab-OS/main/docs/ai_guides/add_device.md + +该指南包含物模型模板、通信协议代码片段、指令协议模式和常见错误检查清单。 +如果无法访问 URL,使用本提示词中内联的「现有设备接口快照」和「代码骨架参考」作为兜底。 +``` + +### 方案 C:无知识库、无联网 + +``` +完整的设备接入指南需要用户在对话中提供。 +如果用户未主动提供,请在阶段 1 开始前询问: +"请将 add_device.md 的内容粘贴到对话中,或上传该文件。如果没有该文件,我将使用内置的精简规则工作。" + +本提示词已内联了最关键的内容(硬约束 + 代码骨架 + 接口快照),足以生成基本正确的驱动。 +但完整指南包含更多物模型模板和通信协议代码片段,能显著提升生成质量。 +``` + +--- + +## 各平台配置指南 + +### OpenAI Custom GPT + +1. 进入 https://chat.openai.com/gpts/editor +2. **Name**:Uni-Lab-OS 设备接入助手 +3. **Description**:帮助用户将实验室硬件设备接入 Uni-Lab-OS 系统,自动生成驱动代码、注册表和图文件。 +4. **Instructions**:粘贴上方系统提示词,`{{KNOWLEDGE_LOADING}}` 替换为方案 A +5. **Knowledge**:上传 `docs/ai_guides/add_device.md` +6. **Capabilities**:开启 Code Interpreter(用于代码验证) +7. **Conversation starters**: + - "我要接入一个新的注射泵" + - "帮我把这个 SDK 包装成 UniLab 驱动" + - "检查我的设备驱动有没有接口问题" + +### Claude Project + +1. 创建新 Project +2. **Custom Instructions**:粘贴系统提示词,`{{KNOWLEDGE_LOADING}}` 替换为方案 A +3. **Project Knowledge**:上传 `docs/ai_guides/add_device.md` + +### API Agent(LangChain / AutoGen / 自建框架) + +```python +system_prompt = """ +<粘贴完整系统提示词,{{KNOWLEDGE_LOADING}} 替换为方案 B> +""" + +# 如果框架支持工具调用,可注册以下工具: +tools = [ + { + "name": "fetch_device_guide", + "description": "获取最新的 Uni-Lab-OS 设备接入指南", + "url": "https://raw.githubusercontent.com/dptech-corp/Uni-Lab-OS/main/docs/ai_guides/add_device.md" + }, + { + "name": "fetch_registry", + "description": "获取最新的设备注册表", + "url": "https://raw.githubusercontent.com/dptech-corp/Uni-Lab-OS/main/unilabos/registry/devices/{category}.yaml" + }, +] +``` + +### Cursor Agent Mode + +无需使用本模板。Cursor 中使用已有的 `.cursor/skills/add-device/SKILL.md`,它会自动读取 `docs/ai_guides/add_device.md` 并利用 Cursor 的工具能力(Grep 搜索注册表、AskQuestion 收集信息等)。 + +### 纯网页对话(ChatGPT / Claude 无 Project) + +1. 第一条消息粘贴系统提示词(`{{KNOWLEDGE_LOADING}}` 替换为方案 C) +2. 第二条消息上传或粘贴 `add_device.md` +3. 第三条消息开始描述设备 + +--- + +## 维护说明 + +- **硬约束更新**:如果 `add_device.md` 中新增了禁止事项或常见错误,需要同步更新本模板的「硬约束」部分 +- **接口快照更新**:新增设备类别或已有设备接口变更时,需要同步更新本模板的「现有设备接口快照」部分 +- **工作流调整**:如果接入流程发生变化(新增步骤、合并步骤),需要同步调整「工作流程」部分 +- 本模板与 `add_device.md` 是**互补关系**:模板定义 Agent 行为,指南提供领域知识。两者独立维护 From 92bfb069d50b13d30c99ba6601765340bd3b59f8 Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Mon, 9 Mar 2026 18:44:20 +0800 Subject: [PATCH 10/56] feat: Implement Laiyu liquid handling station with enhanced device control, testing, and documentation. --- .../liquid_handling/laiyu/backend/__init__.py | 6 +- .../laiyu/backend/laiyu_backend.py | 334 --------- .../laiyu/backend/laiyu_v_backend.py | 672 ++++++++---------- .../laiyu/controllers/pipette_controller.py | 260 ++++--- .../devices/liquid_handling/laiyu/laiyu.py | 121 +++- unilabos/registry/devices/liquid_handler.yaml | 19 +- 6 files changed, 566 insertions(+), 846 deletions(-) delete mode 100644 unilabos/devices/liquid_handling/laiyu/backend/laiyu_backend.py diff --git a/unilabos/devices/liquid_handling/laiyu/backend/__init__.py b/unilabos/devices/liquid_handling/laiyu/backend/__init__.py index 4bf29392d..b7e1b34a6 100644 --- a/unilabos/devices/liquid_handling/laiyu/backend/__init__.py +++ b/unilabos/devices/liquid_handling/laiyu/backend/__init__.py @@ -1,9 +1,7 @@ """ LaiYu液体处理设备后端模块 - -提供设备后端接口和实现 """ -from .laiyu_backend import LaiYuLiquidBackend, create_laiyu_backend +from .laiyu_v_backend import UniLiquidHandlerLaiyuBackend -__all__ = ['LaiYuLiquidBackend', 'create_laiyu_backend'] \ No newline at end of file +__all__ = ['UniLiquidHandlerLaiyuBackend'] diff --git a/unilabos/devices/liquid_handling/laiyu/backend/laiyu_backend.py b/unilabos/devices/liquid_handling/laiyu/backend/laiyu_backend.py deleted file mode 100644 index 5e8041c00..000000000 --- a/unilabos/devices/liquid_handling/laiyu/backend/laiyu_backend.py +++ /dev/null @@ -1,334 +0,0 @@ -""" -LaiYu液体处理设备后端实现 - -提供设备的后端接口和控制逻辑 -""" - -import logging -from typing import Dict, Any, Optional, List -from abc import ABC, abstractmethod - -# 尝试导入PyLabRobot后端 -try: - from pylabrobot.liquid_handling.backends import LiquidHandlerBackend - PYLABROBOT_AVAILABLE = True -except ImportError: - PYLABROBOT_AVAILABLE = False - # 创建模拟后端基类 - class LiquidHandlerBackend: - def __init__(self, name: str): - self.name = name - self.is_connected = False - - def connect(self): - """连接设备""" - pass - - def disconnect(self): - """断开连接""" - pass - - -class LaiYuLiquidBackend(LiquidHandlerBackend): - """LaiYu液体处理设备后端""" - - def __init__(self, name: str = "LaiYu_Liquid_Backend"): - """ - 初始化LaiYu液体处理设备后端 - - Args: - name: 后端名称 - """ - if PYLABROBOT_AVAILABLE: - # PyLabRobot 的 LiquidHandlerBackend 不接受参数 - super().__init__() - else: - # 模拟版本接受 name 参数 - super().__init__(name) - - self.name = name - self.logger = logging.getLogger(__name__) - self.is_connected = False - self.device_info = { - "name": "LaiYu液体处理设备", - "version": "1.0.0", - "manufacturer": "LaiYu", - "model": "LaiYu_Liquid_Handler" - } - - def connect(self) -> bool: - """ - 连接到LaiYu液体处理设备 - - Returns: - bool: 连接是否成功 - """ - try: - self.logger.info("正在连接到LaiYu液体处理设备...") - # 这里应该实现实际的设备连接逻辑 - # 目前返回模拟连接成功 - self.is_connected = True - self.logger.info("成功连接到LaiYu液体处理设备") - return True - except Exception as e: - self.logger.error(f"连接LaiYu液体处理设备失败: {e}") - self.is_connected = False - return False - - def disconnect(self) -> bool: - """ - 断开与LaiYu液体处理设备的连接 - - Returns: - bool: 断开连接是否成功 - """ - try: - self.logger.info("正在断开与LaiYu液体处理设备的连接...") - # 这里应该实现实际的设备断开连接逻辑 - self.is_connected = False - self.logger.info("成功断开与LaiYu液体处理设备的连接") - return True - except Exception as e: - self.logger.error(f"断开LaiYu液体处理设备连接失败: {e}") - return False - - def is_device_connected(self) -> bool: - """ - 检查设备是否已连接 - - Returns: - bool: 设备是否已连接 - """ - return self.is_connected - - def get_device_info(self) -> Dict[str, Any]: - """ - 获取设备信息 - - Returns: - Dict[str, Any]: 设备信息字典 - """ - return self.device_info.copy() - - def home_device(self) -> bool: - """ - 设备归零操作 - - Returns: - bool: 归零是否成功 - """ - if not self.is_connected: - self.logger.error("设备未连接,无法执行归零操作") - return False - - try: - self.logger.info("正在执行设备归零操作...") - # 这里应该实现实际的设备归零逻辑 - self.logger.info("设备归零操作完成") - return True - except Exception as e: - self.logger.error(f"设备归零操作失败: {e}") - return False - - def aspirate(self, volume: float, location: Dict[str, Any]) -> bool: - """ - 吸液操作 - - Args: - volume: 吸液体积 (微升) - location: 吸液位置信息 - - Returns: - bool: 吸液是否成功 - """ - if not self.is_connected: - self.logger.error("设备未连接,无法执行吸液操作") - return False - - try: - self.logger.info(f"正在执行吸液操作: 体积={volume}μL, 位置={location}") - # 这里应该实现实际的吸液逻辑 - self.logger.info("吸液操作完成") - return True - except Exception as e: - self.logger.error(f"吸液操作失败: {e}") - return False - - def dispense(self, volume: float, location: Dict[str, Any]) -> bool: - """ - 排液操作 - - Args: - volume: 排液体积 (微升) - location: 排液位置信息 - - Returns: - bool: 排液是否成功 - """ - if not self.is_connected: - self.logger.error("设备未连接,无法执行排液操作") - return False - - try: - self.logger.info(f"正在执行排液操作: 体积={volume}μL, 位置={location}") - # 这里应该实现实际的排液逻辑 - self.logger.info("排液操作完成") - return True - except Exception as e: - self.logger.error(f"排液操作失败: {e}") - return False - - def pick_up_tip(self, location: Dict[str, Any]) -> bool: - """ - 取枪头操作 - - Args: - location: 枪头位置信息 - - Returns: - bool: 取枪头是否成功 - """ - if not self.is_connected: - self.logger.error("设备未连接,无法执行取枪头操作") - return False - - try: - self.logger.info(f"正在执行取枪头操作: 位置={location}") - # 这里应该实现实际的取枪头逻辑 - self.logger.info("取枪头操作完成") - return True - except Exception as e: - self.logger.error(f"取枪头操作失败: {e}") - return False - - def drop_tip(self, location: Dict[str, Any]) -> bool: - """ - 丢弃枪头操作 - - Args: - location: 丢弃位置信息 - - Returns: - bool: 丢弃枪头是否成功 - """ - if not self.is_connected: - self.logger.error("设备未连接,无法执行丢弃枪头操作") - return False - - try: - self.logger.info(f"正在执行丢弃枪头操作: 位置={location}") - # 这里应该实现实际的丢弃枪头逻辑 - self.logger.info("丢弃枪头操作完成") - return True - except Exception as e: - self.logger.error(f"丢弃枪头操作失败: {e}") - return False - - def move_to(self, location: Dict[str, Any]) -> bool: - """ - 移动到指定位置 - - Args: - location: 目标位置信息 - - Returns: - bool: 移动是否成功 - """ - if not self.is_connected: - self.logger.error("设备未连接,无法执行移动操作") - return False - - try: - self.logger.info(f"正在移动到位置: {location}") - # 这里应该实现实际的移动逻辑 - self.logger.info("移动操作完成") - return True - except Exception as e: - self.logger.error(f"移动操作失败: {e}") - return False - - def get_status(self) -> Dict[str, Any]: - """ - 获取设备状态 - - Returns: - Dict[str, Any]: 设备状态信息 - """ - return { - "connected": self.is_connected, - "device_info": self.device_info, - "status": "ready" if self.is_connected else "disconnected" - } - - # PyLabRobot 抽象方法实现 - def stop(self): - """停止所有操作""" - self.logger.info("停止所有操作") - pass - - @property - def num_channels(self) -> int: - """返回通道数量""" - return 1 # 单通道移液器 - - def can_pick_up_tip(self, tip_rack, tip_position) -> bool: - """检查是否可以拾取吸头""" - return True # 简化实现,总是返回True - - def pick_up_tips(self, tip_rack, tip_positions): - """拾取多个吸头""" - self.logger.info(f"拾取吸头: {tip_positions}") - pass - - def drop_tips(self, tip_rack, tip_positions): - """丢弃多个吸头""" - self.logger.info(f"丢弃吸头: {tip_positions}") - pass - - def pick_up_tips96(self, tip_rack): - """拾取96个吸头""" - self.logger.info("拾取96个吸头") - pass - - def drop_tips96(self, tip_rack): - """丢弃96个吸头""" - self.logger.info("丢弃96个吸头") - pass - - def aspirate96(self, volume, plate, well_positions): - """96通道吸液""" - self.logger.info(f"96通道吸液: 体积={volume}") - pass - - def dispense96(self, volume, plate, well_positions): - """96通道排液""" - self.logger.info(f"96通道排液: 体积={volume}") - pass - - def pick_up_resource(self, resource, location): - """拾取资源""" - self.logger.info(f"拾取资源: {resource}") - pass - - def drop_resource(self, resource, location): - """放置资源""" - self.logger.info(f"放置资源: {resource}") - pass - - def move_picked_up_resource(self, resource, location): - """移动已拾取的资源""" - self.logger.info(f"移动资源: {resource} 到 {location}") - pass - - -def create_laiyu_backend(name: str = "LaiYu_Liquid_Backend") -> LaiYuLiquidBackend: - """ - 创建LaiYu液体处理设备后端实例 - - Args: - name: 后端名称 - - Returns: - LaiYuLiquidBackend: 后端实例 - """ - return LaiYuLiquidBackend(name) \ No newline at end of file diff --git a/unilabos/devices/liquid_handling/laiyu/backend/laiyu_v_backend.py b/unilabos/devices/liquid_handling/laiyu/backend/laiyu_v_backend.py index 9e824e1bd..24c075dd5 100644 --- a/unilabos/devices/liquid_handling/laiyu/backend/laiyu_v_backend.py +++ b/unilabos/devices/liquid_handling/laiyu/backend/laiyu_v_backend.py @@ -1,385 +1,307 @@ - -import json +"""LaiYu PLR 后端 — 对齐路径 B 硬件交互模式 + +硬件初始化顺序与 laiyu_liquid_station.py (路径 B) 一致: + 1. XYZController(auto_connect=True) — 先开串口 + 2. PipetteController.connect_shared() — 共享 XYZ 的串口 / 锁 + 3. home_all_axes() + pipette.initialize() +""" + +import logging from typing import List, Optional, Union -from pylabrobot.liquid_handling.backends.backend import ( - LiquidHandlerBackend, -) +from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend from pylabrobot.liquid_handling.standard import ( - Drop, - DropTipRack, - MultiHeadAspirationContainer, - MultiHeadAspirationPlate, - MultiHeadDispenseContainer, - MultiHeadDispensePlate, - Pickup, - PickupTipRack, - ResourceDrop, - ResourceMove, - ResourcePickup, - SingleChannelAspiration, - SingleChannelDispense, + Drop, + DropTipRack, + MultiHeadAspirationContainer, + MultiHeadAspirationPlate, + MultiHeadDispenseContainer, + MultiHeadDispensePlate, + Pickup, + PickupTipRack, + ResourceDrop, + ResourceMove, + ResourcePickup, + SingleChannelAspiration, + SingleChannelDispense, ) from pylabrobot.resources import Resource, Tip -import rclpy -from rclpy.node import Node -from sensor_msgs.msg import JointState -import time -from rclpy.action import ActionClient -from unilabos_msgs.action import SendCmd -import re +from unilabos.devices.liquid_handling.laiyu.controllers.xyz_controller import XYZController +from unilabos.devices.liquid_handling.laiyu.controllers.pipette_controller import ( + PipetteController, + TipStatus, +) -from unilabos.devices.ros_dev.liquid_handler_joint_publisher import JointStatePublisher -from unilabos.devices.liquid_handling.laiyu.controllers.pipette_controller import PipetteController, TipStatus +logger = logging.getLogger(__name__) class UniLiquidHandlerLaiyuBackend(LiquidHandlerBackend): - """Chatter box backend for device-free testing. Prints out all operations.""" - - _pip_length = 5 - _vol_length = 8 - _resource_length = 20 - _offset_length = 16 - _flow_rate_length = 10 - _blowout_length = 10 - _lld_z_length = 10 - _kwargs_length = 15 - _tip_type_length = 12 - _max_volume_length = 16 - _fitting_depth_length = 20 - _tip_length_length = 16 - # _pickup_method_length = 20 - _filter_length = 10 - - def __init__(self, num_channels: int = 8 , tip_length: float = 0 , total_height: float = 310, port: str = "/dev/ttyUSB0"): - """Initialize a chatter box backend.""" - super().__init__() - self._num_channels = num_channels - self.tip_length = tip_length - self.total_height = total_height -# rclpy.init() - if not rclpy.ok(): - rclpy.init() - self.joint_state_publisher = None - self.hardware_interface = PipetteController(port=port) - - async def setup(self): - # self.joint_state_publisher = JointStatePublisher() - # self.hardware_interface.xyz_controller.connect_device() - # self.hardware_interface.xyz_controller.home_all_axes() - await super().setup() - self.hardware_interface.connect() - self.hardware_interface.initialize() - - print("Setting up the liquid handler.") - - async def stop(self): - print("Stopping the liquid handler.") - - def serialize(self) -> dict: - return {**super().serialize(), "num_channels": self.num_channels} - - def pipette_aspirate(self, volume: float, flow_rate: float): - - self.hardware_interface.pipette.set_max_speed(flow_rate) - res = self.hardware_interface.pipette.aspirate(volume=volume) - - if not res: - self.hardware_interface.logger.error("吸取失败,当前体积: {self.hardware_interface.current_volume}") - return - - self.hardware_interface.current_volume += volume - - def pipette_dispense(self, volume: float, flow_rate: float): - - self.hardware_interface.pipette.set_max_speed(flow_rate) - res = self.hardware_interface.pipette.dispense(volume=volume) - if not res: - self.hardware_interface.logger.error("排液失败,当前体积: {self.hardware_interface.current_volume}") - return - self.hardware_interface.current_volume -= volume - - @property - def num_channels(self) -> int: - return self._num_channels - - async def assigned_resource_callback(self, resource: Resource): - print(f"Resource {resource.name} was assigned to the liquid handler.") - - async def unassigned_resource_callback(self, name: str): - print(f"Resource {name} was unassigned from the liquid handler.") - - async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int], **backend_kwargs): - print("Picking up tips:") - # print(ops.tip) - header = ( - f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} " - f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} " - f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} " - f"{'tip type':<{UniLiquidHandlerLaiyuBackend._tip_type_length}} " - f"{'max volume (µL)':<{UniLiquidHandlerLaiyuBackend._max_volume_length}} " - f"{'fitting depth (mm)':<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} " - f"{'tip length (mm)':<{UniLiquidHandlerLaiyuBackend._tip_length_length}} " - # f"{'pickup method':<{ChatterboxBackend._pickup_method_length}} " - f"{'filter':<{UniLiquidHandlerLaiyuBackend._filter_length}}" - ) - # print(header) - - for op, channel in zip(ops, use_channels): - offset = f"{round(op.offset.x, 1)},{round(op.offset.y, 1)},{round(op.offset.z, 1)}" - row = ( - f" p{channel}: " - f"{op.resource.name[-30:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} " - f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} " - f"{op.tip.__class__.__name__:<{UniLiquidHandlerLaiyuBackend._tip_type_length}} " - f"{op.tip.maximal_volume:<{UniLiquidHandlerLaiyuBackend._max_volume_length}} " - f"{op.tip.fitting_depth:<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} " - f"{op.tip.total_tip_length:<{UniLiquidHandlerLaiyuBackend._tip_length_length}} " - # f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} " - f"{'Yes' if op.tip.has_filter else 'No':<{UniLiquidHandlerLaiyuBackend._filter_length}}" - ) - # print(row) - # print(op.resource.get_absolute_location()) - - self.tip_length = ops[0].tip.total_tip_length - coordinate = ops[0].resource.get_absolute_location(x="c",y="c") - offset_xyz = ops[0].offset - x = coordinate.x + offset_xyz.x - y = coordinate.y + offset_xyz.y - z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z - # print("moving") - self.hardware_interface._update_tip_status() - if self.hardware_interface.tip_status == TipStatus.TIP_ATTACHED: - print("已有枪头,无需重复拾取") - return - self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200) - self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height,speed=100) - # self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "pick",channels=use_channels) - # goback() - - - - - async def drop_tips(self, ops: List[Drop], use_channels: List[int], **backend_kwargs): - print("Dropping tips:") - header = ( - f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} " - f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} " - f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} " - f"{'tip type':<{UniLiquidHandlerLaiyuBackend._tip_type_length}} " - f"{'max volume (µL)':<{UniLiquidHandlerLaiyuBackend._max_volume_length}} " - f"{'fitting depth (mm)':<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} " - f"{'tip length (mm)':<{UniLiquidHandlerLaiyuBackend._tip_length_length}} " - # f"{'pickup method':<{ChatterboxBackend._pickup_method_length}} " - f"{'filter':<{UniLiquidHandlerLaiyuBackend._filter_length}}" - ) - # print(header) - - for op, channel in zip(ops, use_channels): - offset = f"{round(op.offset.x, 1)},{round(op.offset.y, 1)},{round(op.offset.z, 1)}" - row = ( - f" p{channel}: " - f"{op.resource.name[-30:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} " - f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} " - f"{op.tip.__class__.__name__:<{UniLiquidHandlerLaiyuBackend._tip_type_length}} " - f"{op.tip.maximal_volume:<{UniLiquidHandlerLaiyuBackend._max_volume_length}} " - f"{op.tip.fitting_depth:<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} " - f"{op.tip.total_tip_length:<{UniLiquidHandlerLaiyuBackend._tip_length_length}} " - # f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} " - f"{'Yes' if op.tip.has_filter else 'No':<{UniLiquidHandlerLaiyuBackend._filter_length}}" - ) - # print(row) - - coordinate = ops[0].resource.get_absolute_location(x="c",y="c") - offset_xyz = ops[0].offset - x = coordinate.x + offset_xyz.x - y = coordinate.y + offset_xyz.y - z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z -20 - # print(x, y, z) - # print("moving") - self.hardware_interface._update_tip_status() - if self.hardware_interface.tip_status == TipStatus.NO_TIP: - print("无枪头,无需丢弃") - return - self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200) - self.hardware_interface.eject_tip - self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height) - - async def aspirate( - self, - ops: List[SingleChannelAspiration], - use_channels: List[int], - **backend_kwargs, - ): - print("Aspirating:") - header = ( - f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} " - f"{'vol(ul)':<{UniLiquidHandlerLaiyuBackend._vol_length}} " - f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} " - f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} " - f"{'flow rate':<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} " - f"{'blowout':<{UniLiquidHandlerLaiyuBackend._blowout_length}} " - f"{'lld_z':<{UniLiquidHandlerLaiyuBackend._lld_z_length}} " - # f"{'liquids':<20}" # TODO: add liquids - ) - for key in backend_kwargs: - header += f"{key:<{UniLiquidHandlerLaiyuBackend._kwargs_length}} "[-16:] - # print(header) - - for o, p in zip(ops, use_channels): - offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}" - row = ( - f" p{p}: " - f"{o.volume:<{UniLiquidHandlerLaiyuBackend._vol_length}} " - f"{o.resource.name[-20:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} " - f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} " - f"{str(o.flow_rate):<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} " - f"{str(o.blow_out_air_volume):<{UniLiquidHandlerLaiyuBackend._blowout_length}} " - f"{str(o.liquid_height):<{UniLiquidHandlerLaiyuBackend._lld_z_length}} " - # f"{o.liquids if o.liquids is not None else 'none'}" - ) - for key, value in backend_kwargs.items(): - if isinstance(value, list) and all(isinstance(v, bool) for v in value): - value = "".join("T" if v else "F" for v in value) - if isinstance(value, list): - value = "".join(map(str, value)) - row += f" {value:<15}" - # print(row) - coordinate = ops[0].resource.get_absolute_location(x="c",y="c") - offset_xyz = ops[0].offset - x = coordinate.x + offset_xyz.x - y = coordinate.y + offset_xyz.y - z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z - # print(x, y, z) - # print("moving") - - # 判断枪头是否存在 - self.hardware_interface._update_tip_status() - if not self.hardware_interface.tip_status == TipStatus.TIP_ATTACHED: - print("无枪头,无法吸液") - return - # 判断吸液量是否超过枪头容量 - flow_rate = backend_kwargs["flow_rate"] if "flow_rate" in backend_kwargs else 500 - blow_out_air_volume = backend_kwargs["blow_out_air_volume"] if "blow_out_air_volume" in backend_kwargs else 0 - if self.hardware_interface.current_volume + ops[0].volume + blow_out_air_volume > self.hardware_interface.max_volume: - self.hardware_interface.logger.error(f"吸液量超过枪头容量: {self.hardware_interface.current_volume + ops[0].volume} > {self.hardware_interface.max_volume}") - return - - # 移动到吸液位置 - self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200) - self.pipette_aspirate(volume=ops[0].volume, flow_rate=flow_rate) - - - self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height) - if blow_out_air_volume >0: - self.pipette_aspirate(volume=blow_out_air_volume, flow_rate=flow_rate) - - - - - async def dispense( - self, - ops: List[SingleChannelDispense], - use_channels: List[int], - **backend_kwargs, - ): - # print("Dispensing:") - header = ( - f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} " - f"{'vol(ul)':<{UniLiquidHandlerLaiyuBackend._vol_length}} " - f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} " - f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} " - f"{'flow rate':<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} " - f"{'blowout':<{UniLiquidHandlerLaiyuBackend._blowout_length}} " - f"{'lld_z':<{UniLiquidHandlerLaiyuBackend._lld_z_length}} " - # f"{'liquids':<20}" # TODO: add liquids - ) - for key in backend_kwargs: - header += f"{key:<{UniLiquidHandlerLaiyuBackend._kwargs_length}} "[-16:] - # print(header) - - for o, p in zip(ops, use_channels): - offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}" - row = ( - f" p{p}: " - f"{o.volume:<{UniLiquidHandlerLaiyuBackend._vol_length}} " - f"{o.resource.name[-20:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} " - f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} " - f"{str(o.flow_rate):<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} " - f"{str(o.blow_out_air_volume):<{UniLiquidHandlerLaiyuBackend._blowout_length}} " - f"{str(o.liquid_height):<{UniLiquidHandlerLaiyuBackend._lld_z_length}} " - # f"{o.liquids if o.liquids is not None else 'none'}" - ) - for key, value in backend_kwargs.items(): - if isinstance(value, list) and all(isinstance(v, bool) for v in value): - value = "".join("T" if v else "F" for v in value) - if isinstance(value, list): - value = "".join(map(str, value)) - row += f" {value:<{UniLiquidHandlerLaiyuBackend._kwargs_length}}" - # print(row) - coordinate = ops[0].resource.get_absolute_location(x="c",y="c") - offset_xyz = ops[0].offset - x = coordinate.x + offset_xyz.x - y = coordinate.y + offset_xyz.y - z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z - # print(x, y, z) - # print("moving") - - # 判断枪头是否存在 - self.hardware_interface._update_tip_status() - if not self.hardware_interface.tip_status == TipStatus.TIP_ATTACHED: - print("无枪头,无法排液") - return - # 判断排液量是否超过枪头容量 - flow_rate = backend_kwargs["flow_rate"] if "flow_rate" in backend_kwargs else 500 - blow_out_air_volume = backend_kwargs["blow_out_air_volume"] if "blow_out_air_volume" in backend_kwargs else 0 - if self.hardware_interface.current_volume - ops[0].volume - blow_out_air_volume < 0: - self.hardware_interface.logger.error(f"排液量超过枪头容量: {self.hardware_interface.current_volume - ops[0].volume - blow_out_air_volume} < 0") - return - - - # 移动到排液位置 - self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200) - self.pipette_dispense(volume=ops[0].volume, flow_rate=flow_rate) - - - self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height) - if blow_out_air_volume > 0: - self.pipette_dispense(volume=blow_out_air_volume, flow_rate=flow_rate) - # self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "",channels=use_channels) - - async def pick_up_tips96(self, pickup: PickupTipRack, **backend_kwargs): - print(f"Picking up tips from {pickup.resource.name}.") - - async def drop_tips96(self, drop: DropTipRack, **backend_kwargs): - print(f"Dropping tips to {drop.resource.name}.") - - async def aspirate96( - self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer] - ): - if isinstance(aspiration, MultiHeadAspirationPlate): - resource = aspiration.wells[0].parent - else: - resource = aspiration.container - print(f"Aspirating {aspiration.volume} from {resource}.") - - async def dispense96(self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]): - if isinstance(dispense, MultiHeadDispensePlate): - resource = dispense.wells[0].parent - else: - resource = dispense.container - print(f"Dispensing {dispense.volume} to {resource}.") - - async def pick_up_resource(self, pickup: ResourcePickup): - print(f"Picking up resource: {pickup}") - - async def move_picked_up_resource(self, move: ResourceMove): - print(f"Moving picked up resource: {move}") - - async def drop_resource(self, drop: ResourceDrop): - print(f"Dropping resource: {drop}") - - def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: - return True - + """LaiYu 硬件后端 — PLR Backend 接口实现""" + + def __init__( + self, + num_channels: int = 1, + tip_length: float = 0, + total_height: float = 310, + port: str = "/dev/ttyUSB0", + baudrate: int = 115200, + pipette_address: int = 4, + ): + super().__init__() + self._num_channels = num_channels + self.tip_length = tip_length + self.total_height = total_height + + # 保存配置,延迟到 setup() 再创建硬件对象 + self._port = port + self._baudrate = baudrate + self._pipette_address = pipette_address + + self._xyz: Optional[XYZController] = None + self._pipette_ctrl: Optional[PipetteController] = None + self._ros_node = None + + # ------------------------------------------------------------------ lifecycle + + def post_init(self, ros_node): + """接收 ROS 节点引用(由 Handler.post_init 调用)""" + self._ros_node = ros_node + + async def setup(self): + """按路径 B 顺序初始化硬件""" + await super().setup() + + # 1. XYZ 先开串口 + self._xyz = XYZController( + port=self._port, + baudrate=self._baudrate, + auto_connect=True, + ) + if not self._xyz.is_connected: + raise RuntimeError("XYZ 控制器连接失败") + + # 2. PipetteController 共享 XYZ 串口 + self._pipette_ctrl = PipetteController( + port=self._port, + address=self._pipette_address, + ) + self._pipette_ctrl.connect_shared( + serial_conn=self._xyz.serial_conn, + serial_lock=self._xyz.serial_lock, + xyz_controller=self._xyz, + ) + + # 3. 回零 + 移液器初始化 + self._xyz.home_all_axes() + self._pipette_ctrl.initialize() + + logger.info("LaiYu 后端硬件初始化完成") + + async def stop(self): + """正确断开硬件""" + try: + if self._pipette_ctrl: + self._pipette_ctrl.disconnect_shared() + if self._xyz: + self._xyz.disconnect() + logger.info("LaiYu 后端硬件已断开") + except Exception as e: + logger.error(f"停止后端失败: {e}") + + # ------------------------------------------------------------------ helpers + + def _plr_to_machine_coords(self, resource, offset): + """PLR Resource 坐标 → 机器坐标 (倒置龙门架: total_height - z, -y)""" + coordinate = resource.get_absolute_location(x="c", y="c") + x = coordinate.x + offset.x + y = coordinate.y + offset.y + z_plr = coordinate.z + offset.z + return x, -y, self.total_height - (z_plr + self.tip_length) + + def _pipette_aspirate(self, volume: float, flow_rate: float): + self._pipette_ctrl.pipette.set_max_speed(flow_rate) + res = self._pipette_ctrl.pipette.aspirate(volume=volume) + if not res: + logger.error(f"吸取失败,当前体积: {self._pipette_ctrl.current_volume}") + return + self._pipette_ctrl.current_volume += volume + + def _pipette_dispense(self, volume: float, flow_rate: float): + self._pipette_ctrl.pipette.set_max_speed(flow_rate) + res = self._pipette_ctrl.pipette.dispense(volume=volume) + if not res: + logger.error(f"排液失败,当前体积: {self._pipette_ctrl.current_volume}") + return + self._pipette_ctrl.current_volume -= volume + + # ------------------------------------------------------------------ properties + + def serialize(self) -> dict: + return {**super().serialize(), "num_channels": self.num_channels} + + @property + def num_channels(self) -> int: + return self._num_channels + + # ------------------------------------------------------------------ resource callbacks + + async def assigned_resource_callback(self, resource: Resource): + logger.info(f"Resource {resource.name} was assigned to the liquid handler.") + + async def unassigned_resource_callback(self, name: str): + logger.info(f"Resource {name} was unassigned from the liquid handler.") + + # ------------------------------------------------------------------ pick_up_tips + + async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int], **backend_kwargs): + tip = ops[0].tip + self.tip_length = tip.total_tip_length + x, y, z_top = self._plr_to_machine_coords(ops[0].resource, ops[0].offset) + + self._pipette_ctrl._update_tip_status() + if self._pipette_ctrl.tip_status == TipStatus.TIP_ATTACHED: + logger.warning("已有枪头,无需重复拾取") + return + + try: + # 1. 移到枪头正上方 + self._xyz.move_to_work_coord_safe(x=x, y=y, z=z_top, speed=200) + # 2. 下压到套枪头深度(fitting_depth 是枪头套入长度) + z_pickup = z_top + tip.fitting_depth + self._xyz.move_to_work_coord_safe(z=z_pickup, speed=100) + # 3. 退回安全高度 + self._xyz.move_to_work_coord_safe( + z=self._xyz.machine_config.safe_z_height, speed=100 + ) + except Exception as e: + logger.error(f"pick_up_tips 移动失败: {e}") + raise + + # ------------------------------------------------------------------ drop_tips + + async def drop_tips(self, ops: List[Drop], use_channels: List[int], **backend_kwargs): + x, y, z = self._plr_to_machine_coords(ops[0].resource, ops[0].offset) + z -= 20 # 额外下移补偿 + + self._pipette_ctrl._update_tip_status() + if self._pipette_ctrl.tip_status == TipStatus.NO_TIP: + logger.warning("无枪头,无需丢弃") + return + + try: + self._xyz.move_to_work_coord_safe(x=x, y=y, z=z, speed=200) + self._pipette_ctrl.eject_tip() # 修复: 原来缺少 () + self._xyz.move_to_work_coord_safe( + z=self._xyz.machine_config.safe_z_height + ) + except Exception as e: + logger.error(f"drop_tips 失败: {e}") + raise + + # ------------------------------------------------------------------ aspirate + + async def aspirate( + self, + ops: List[SingleChannelAspiration], + use_channels: List[int], + **backend_kwargs, + ): + x, y, z = self._plr_to_machine_coords(ops[0].resource, ops[0].offset) + + self._pipette_ctrl._update_tip_status() + if self._pipette_ctrl.tip_status != TipStatus.TIP_ATTACHED: + raise RuntimeError("无枪头,无法吸液") + + flow_rate = backend_kwargs.get("flow_rate", 500) + blow_out_air_volume = backend_kwargs.get("blow_out_air_volume", 0) + + if ( + self._pipette_ctrl.current_volume + ops[0].volume + blow_out_air_volume + > self._pipette_ctrl.max_volume + ): + raise RuntimeError( + f"吸液量超过枪头容量: " + f"{self._pipette_ctrl.current_volume + ops[0].volume} > {self._pipette_ctrl.max_volume}" + ) + + self._xyz.move_to_work_coord_safe(x=x, y=y, z=z, speed=200) + self._pipette_aspirate(volume=ops[0].volume, flow_rate=flow_rate) + + self._xyz.move_to_work_coord_safe( + z=self._xyz.machine_config.safe_z_height + ) + if blow_out_air_volume > 0: + self._pipette_aspirate(volume=blow_out_air_volume, flow_rate=flow_rate) + + # ------------------------------------------------------------------ dispense + + async def dispense( + self, + ops: List[SingleChannelDispense], + use_channels: List[int], + **backend_kwargs, + ): + x, y, z = self._plr_to_machine_coords(ops[0].resource, ops[0].offset) + + self._pipette_ctrl._update_tip_status() + if self._pipette_ctrl.tip_status != TipStatus.TIP_ATTACHED: + raise RuntimeError("无枪头,无法排液") + + flow_rate = backend_kwargs.get("flow_rate", 500) + blow_out_air_volume = backend_kwargs.get("blow_out_air_volume", 0) + + if ( + self._pipette_ctrl.current_volume - ops[0].volume - blow_out_air_volume < 0 + ): + raise RuntimeError( + f"排液量超过当前体积: " + f"{self._pipette_ctrl.current_volume - ops[0].volume - blow_out_air_volume} < 0" + ) + + self._xyz.move_to_work_coord_safe(x=x, y=y, z=z, speed=200) + self._pipette_dispense(volume=ops[0].volume, flow_rate=flow_rate) + + self._xyz.move_to_work_coord_safe( + z=self._xyz.machine_config.safe_z_height + ) + if blow_out_air_volume > 0: + self._pipette_dispense(volume=blow_out_air_volume, flow_rate=flow_rate) + + # ------------------------------------------------------------------ 96-channel stubs + + async def pick_up_tips96(self, pickup: PickupTipRack, **backend_kwargs): + logger.info(f"Picking up tips from {pickup.resource.name}.") + + async def drop_tips96(self, drop: DropTipRack, **backend_kwargs): + logger.info(f"Dropping tips to {drop.resource.name}.") + + async def aspirate96( + self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer] + ): + if isinstance(aspiration, MultiHeadAspirationPlate): + resource = aspiration.wells[0].parent + else: + resource = aspiration.container + logger.info(f"Aspirating {aspiration.volume} from {resource}.") + + async def dispense96( + self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer] + ): + if isinstance(dispense, MultiHeadDispensePlate): + resource = dispense.wells[0].parent + else: + resource = dispense.container + logger.info(f"Dispensing {dispense.volume} to {resource}.") + + async def pick_up_resource(self, pickup: ResourcePickup): + logger.info(f"Picking up resource: {pickup}") + + async def move_picked_up_resource(self, move: ResourceMove): + logger.info(f"Moving picked up resource: {move}") + + async def drop_resource(self, drop: ResourceDrop): + logger.info(f"Dropping resource: {drop}") + + def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: + return True diff --git a/unilabos/devices/liquid_handling/laiyu/controllers/pipette_controller.py b/unilabos/devices/liquid_handling/laiyu/controllers/pipette_controller.py index 17a47df1c..da08d3d7a 100644 --- a/unilabos/devices/liquid_handling/laiyu/controllers/pipette_controller.py +++ b/unilabos/devices/liquid_handling/laiyu/controllers/pipette_controller.py @@ -5,20 +5,15 @@ 封装SOPA移液器的高级控制功能 """ -# 添加项目根目录到Python路径以解决模块导入问题 import sys import os -from tkinter import N -from unilabos.devices.liquid_handling.laiyu.drivers.xyz_stepper_driver import ModbusException +_current_file = os.path.abspath(__file__) +_project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(_current_file))))) +if _project_root not in sys.path: + sys.path.insert(0, _project_root) -# 无论如何都添加项目根目录到路径 -current_file = os.path.abspath(__file__) -# 从 .../Uni-Lab-OS/unilabos/devices/LaiYu_Liquid/controllers/pipette_controller.py -# 向上5级到 .../Uni-Lab-OS -project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(current_file))))) -# 强制添加项目根目录到sys.path的开头 -sys.path.insert(0, project_root) +from unilabos.devices.liquid_handling.laiyu.drivers.xyz_stepper_driver import ModbusException import time import logging @@ -153,7 +148,7 @@ def connect(self) -> bool: logger.error("移液器连接失败") return False logger.info("移液器连接成功") - + # 连接XYZ步进电机控制器(如果提供了端口) if self.xyz_port != self.pipette_port: try: @@ -172,24 +167,62 @@ def connect(self) -> bool: try: self.xyz_controller = XYZController(self.xyz_port, auto_connect=False) self.xyz_controller.serial_conn = self.pipette.serial_port + self.xyz_controller.serial_lock = self.pipette.lock self.xyz_controller.is_connected = True + logger.info("XYZ控制器与移液器共享串口和互斥锁") except Exception as e: - logger.info("未配置XYZ步进电机端口,跳过运动控制器连接") - + logger.warning(f"共享端口 XYZ 控制器创建失败: {e}") + self.xyz_controller = None + self.xyz_connected = False + return True except Exception as e: logger.error(f"设备连接失败: {e}") return False + def connect_shared(self, serial_conn, serial_lock, xyz_controller: XYZController) -> bool: + """使用已连接的串口和XYZ控制器(路径 B 模式:XYZ 先开串口,移液器共享) + + Args: + serial_conn: 已打开的串口连接(来自 XYZController) + serial_lock: 串口互斥锁(来自 XYZController) + xyz_controller: 已连接的 XYZController 实例 + """ + try: + self.pipette.serial_port = serial_conn + self.pipette.lock = serial_lock + self.pipette.is_connected = True + + self.xyz_controller = xyz_controller + self.xyz_connected = True + + logger.info("移液控制器已通过 connect_shared 共享 XYZ 串口") + return True + except Exception as e: + logger.error(f"connect_shared 失败: {e}") + return False + + def disconnect_shared(self) -> None: + """释放共享串口引用(与 connect_shared 对称)。 + + 注意:不关闭串口本身,串口由 XYZController 负责关闭。 + """ + try: + self.pipette.serial_port = None + self.pipette.lock = None + self.pipette.is_connected = False + self.xyz_controller = None + self.xyz_connected = False + logger.info("移液控制器已释放共享串口引用") + except Exception as e: + logger.error(f"disconnect_shared 失败: {e}") + def initialize(self) -> bool: """初始化移液器""" try: if self.pipette.initialize(): logger.info("移液器初始化成功") - # 检查枪头状态 self._update_tip_status() - self.xyz_controller.home_all_axes() - self.xyz_controller.move_to_work_coord_safe(x=0, y=-150, z=0) return True return False except Exception as e: @@ -198,56 +231,58 @@ def initialize(self) -> bool: def disconnect(self): """断开连接""" - # 断开移液器连接 + if self.xyz_controller and self.xyz_connected: + if self.xyz_port != self.pipette_port: + try: + self.xyz_controller.disconnect() + logger.info("XYZ 步进电机已断开") + except Exception as e: + logger.error(f"断开 XYZ 步进电机失败: {e}") + else: + self.xyz_controller.serial_conn = None + self.xyz_connected = False + self.xyz_controller = None + self.pipette.disconnect() logger.info("移液器已断开") - - # 断开 XYZ 步进电机连接 - if self.xyz_controller and self.xyz_connected: - try: - self.xyz_controller.disconnect() - self.xyz_connected = False - logger.info("XYZ 步进电机已断开") - except Exception as e: - logger.error(f"断开 XYZ 步进电机失败: {e}") def _check_xyz_safety(self, axis: MotorAxis, target_position: int) -> bool: """ 检查 XYZ 轴移动的安全性 - + Args: axis: 电机轴 target_position: 目标位置(步数) - + Returns: 是否安全 """ try: # 获取当前电机状态 motor_position = self.xyz_controller.get_motor_status(axis) - + # 检查电机状态是否正常 (不是碰撞停止或限位停止) - if motor_position.status in [MotorStatus.COLLISION_STOP, - MotorStatus.FORWARD_LIMIT_STOP, + if motor_position.status in [MotorStatus.COLLISION_STOP, + MotorStatus.FORWARD_LIMIT_STOP, MotorStatus.REVERSE_LIMIT_STOP]: logger.error(f"{axis.name} 轴电机处于错误状态: {motor_position.status.name}") return False - + # 检查位置限制 (扩大安全范围以适应实际硬件) # 步进电机的位置范围通常很大,这里设置更合理的范围 if target_position < -500000 or target_position > 500000: logger.error(f"{axis.name} 轴目标位置超出安全范围: {target_position}") return False - + # 检查移动距离是否过大 (单次移动不超过 20000 步,约12mm) current_position = motor_position.steps move_distance = abs(target_position - current_position) if move_distance > 20000: logger.error(f"{axis.name} 轴单次移动距离过大: {move_distance}步") return False - + return True - + except Exception as e: logger.error(f"安全检查失败: {e}") return False @@ -255,48 +290,48 @@ def _check_xyz_safety(self, axis: MotorAxis, target_position: int) -> bool: def move_z_relative(self, distance_mm: float, speed: int = 2000, acceleration: int = 500) -> bool: """ Z轴相对移动 - + Args: distance_mm: 移动距离(mm),正值向下,负值向上 speed: 移动速度(rpm) acceleration: 加速度(rpm/s) - + Returns: 移动是否成功 """ if not self.xyz_controller or not self.xyz_connected: logger.error("XYZ 步进电机未连接,无法执行移动") return False - + try: # 参数验证 if abs(distance_mm) > 15.0: logger.error(f"移动距离过大: {distance_mm}mm,最大允许15mm") return False - + if speed < 100 or speed > 5000: logger.error(f"速度参数无效: {speed}rpm,范围应为100-5000") return False - + # 获取当前 Z 轴位置 current_status = self.xyz_controller.get_motor_status(MotorAxis.Z) current_z_position = current_status.steps - + # 计算移动距离对应的步数 (1mm = 1638.4步) mm_to_steps = 1638.4 move_distance_steps = int(distance_mm * mm_to_steps) - + # 计算目标位置 target_z_position = current_z_position + move_distance_steps - + # 安全检查 if not self._check_xyz_safety(MotorAxis.Z, target_z_position): logger.error("Z轴移动安全检查失败") return False - + logger.info(f"Z轴相对移动: {distance_mm}mm ({move_distance_steps}步)") logger.info(f"当前位置: {current_z_position}步 -> 目标位置: {target_z_position}步") - + # 执行移动 success = self.xyz_controller.move_to_position( axis=MotorAxis.Z, @@ -305,28 +340,28 @@ def move_z_relative(self, distance_mm: float, speed: int = 2000, acceleration: i acceleration=acceleration, precision=50 ) - + if not success: logger.error("Z轴移动命令发送失败") return False - + # 等待移动完成 if not self.xyz_controller.wait_for_completion(MotorAxis.Z, timeout=10.0): logger.error("Z轴移动超时") return False - + # 验证移动结果 final_status = self.xyz_controller.get_motor_status(MotorAxis.Z) final_position = final_status.steps position_error = abs(final_position - target_z_position) - + logger.info(f"Z轴移动完成,最终位置: {final_position}步,误差: {position_error}步") - + if position_error > 100: logger.warning(f"Z轴位置误差较大: {position_error}步") - + return True - + except ModbusException as e: logger.error(f"Modbus通信错误: {e}") return False @@ -337,21 +372,20 @@ def move_z_relative(self, distance_mm: float, speed: int = 2000, acceleration: i def emergency_stop(self) -> bool: """ 紧急停止所有运动 - + Returns: 停止是否成功 """ success = True - - # 停止移液器操作 + try: - if self.pipette and self.connected: - # 这里可以添加移液器的紧急停止逻辑 + if self.pipette and self.pipette.is_connected: + self.pipette.emergency_stop() logger.info("移液器紧急停止") except Exception as e: logger.error(f"移液器紧急停止失败: {e}") success = False - + # 停止 XYZ 轴运动 try: if self.xyz_controller and self.xyz_connected: @@ -360,7 +394,7 @@ def emergency_stop(self) -> bool: except Exception as e: logger.error(f"XYZ 轴紧急停止失败: {e}") success = False - + return success def pickup_tip(self) -> bool: @@ -376,7 +410,7 @@ def pickup_tip(self) -> bool: return True logger.info("开始装载枪头 - Z轴向下移动10mm") - + # 使用相对移动方法,向下移动10mm if self.move_z_relative(distance_mm=10.0, speed=2000, acceleration=500): # 更新枪头状态 @@ -688,31 +722,31 @@ def reset_statistics(self): if __name__ == "__main__": # 配置日志 import logging - + # 设置日志级别 logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) - + def interactive_test(): """交互式测试模式 - 适用于已连接的设备""" print("\n" + "=" * 60) print("🧪 移液器交互式测试模式") print("=" * 60) - + # 获取用户输入的连接参数 print("\n📡 设备连接配置:") port = input("请输入移液器串口端口 (默认: /dev/ttyUSB_CH340): ").strip() or "/dev/ttyUSB_CH340" address_input = input("请输入移液器设备地址 (默认: 4): ").strip() address = int(address_input) if address_input else 4 - + # 询问是否连接 XYZ 步进电机控制器 xyz_enable = input("是否连接 XYZ 步进电机控制器? (y/N): ").strip().lower() xyz_port = None if xyz_enable not in ['n', 'no']: xyz_port = input("请输入 XYZ 控制器串口端口 (默认: /dev/ttyUSB_CH340): ").strip() or "/dev/ttyUSB_CH340" - + try: # 创建移液控制器实例 if xyz_port: @@ -721,21 +755,21 @@ def interactive_test(): else: print(f"\n🔧 创建移液控制器实例 (端口: {port}, 地址: {address})...") pipette = PipetteController(port=port, address=address) - + # 连接设备 print("\n📞 连接移液器设备...") if not pipette.connect(): print("❌ 设备连接失败,请检查连接") return print("✅ 设备连接成功") - + # 初始化设备 print("\n🚀 初始化设备...") if not pipette.initialize(): print("❌ 设备初始化失败") return print("✅ 设备初始化成功") - + # 交互式菜单 while True: print("\n" + "=" * 50) @@ -755,9 +789,9 @@ def interactive_test(): print("99. 🚨 紧急停止") print("0. 🚪 退出程序") print("=" * 50) - + choice = input("\n请选择操作 (0-12, 99): ").strip() - + if choice == "0": print("\n👋 退出程序...") break @@ -773,7 +807,7 @@ def interactive_test(): # print(f" 🔧 枪头使用次数: {status['statistics']['tip_count']}") print(f" ⬆️ 吸液次数: {status['statistics']['aspirate_count']}") print(f" ⬇️ 排液次数: {status['statistics']['dispense_count']}") - + elif choice == "2": # 装载枪头 print("\n🔧 装载枪头...") @@ -781,14 +815,14 @@ def interactive_test(): print("📍 使用 XYZ 控制器进行 Z 轴定位 (下移 10mm)") else: print("⚠️ 未连接 XYZ 控制器,仅执行移液器枪头装载") - + if pipette.pickup_tip(): print("✅ 枪头装载成功") if pipette.xyz_connected: print("📍 Z 轴已移动到装载位置") else: print("❌ 枪头装载失败") - + elif choice == "3": # 弹出枪头 print("\n🗑️ 弹出枪头...") @@ -796,7 +830,7 @@ def interactive_test(): print("✅ 枪头弹出成功") else: print("❌ 枪头弹出失败") - + elif choice == "4": # 吸液操作 try: @@ -810,7 +844,7 @@ def interactive_test(): print("❌ 吸液失败") except ValueError: print("❌ 请输入有效的数字") - + elif choice == "5": # 排液操作 try: @@ -824,7 +858,7 @@ def interactive_test(): print("❌ 排液失败") except ValueError: print("❌ 请输入有效的数字") - + elif choice == "6": # 混合操作 try: @@ -838,7 +872,7 @@ def interactive_test(): print("❌ 混合失败") except ValueError: print("❌ 请输入有效的数字") - + elif choice == "7": # 液体转移 try: @@ -846,7 +880,7 @@ def interactive_test(): source = input("源孔位 (可选, 如A1): ").strip() or None dest = input("目标孔位 (可选, 如B1): ").strip() or None new_tip = input("是否使用新枪头? (y/n, 默认y): ").strip().lower() != 'n' - + print(f"\n🔄 执行液体转移 ({volume}ul)...") if pipette.transfer(volume=volume, source_well=source, dest_well=dest, new_tip=new_tip): print("✅ 液体转移完成") @@ -854,7 +888,7 @@ def interactive_test(): print("❌ 液体转移失败") except ValueError: print("❌ 请输入有效的数字") - + elif choice == "8": # 设置液体类型 print("\n🧪 可用液体类型:") @@ -864,16 +898,16 @@ def interactive_test(): "3": (LiquidClass.VISCOUS, "粘稠液体"), "4": (LiquidClass.VOLATILE, "挥发性液体") } - + for key, (liquid_class, description) in liquid_options.items(): print(f" {key}. {description}") - + liquid_choice = input("请选择液体类型 (1-4): ").strip() if liquid_choice in liquid_options: liquid_class, description = liquid_options[liquid_choice] pipette.set_liquid_class(liquid_class) print(f"✅ 液体类型设置为: {description}") - + # 显示参数 params = pipette.liquid_params print(f"📋 参数设置:") @@ -883,7 +917,7 @@ def interactive_test(): print(f" 💧 预润湿: {'是' if params.pre_wet else '否'}") else: print("❌ 无效选择") - + elif choice == "9": # 自定义参数 try: @@ -892,19 +926,19 @@ def interactive_test(): dispense_speed = input("排液速度 (默认800): ").strip() air_gap = input("空气间隙 (ul, 默认10.0): ").strip() pre_wet = input("预润湿 (y/n, 默认n): ").strip().lower() == 'y' - + custom_params = LiquidParameters( aspirate_speed=int(aspirate_speed) if aspirate_speed else 500, dispense_speed=int(dispense_speed) if dispense_speed else 800, air_gap=float(air_gap) if air_gap else 10.0, pre_wet=pre_wet ) - + pipette.set_custom_parameters(custom_params) print("✅ 自定义参数设置完成") except ValueError: print("❌ 请输入有效的数字") - + elif choice == "10": # 校准体积 try: @@ -914,12 +948,12 @@ def interactive_test(): print(f"✅ 校准完成,校准系数: {actual/expected:.3f}") except ValueError: print("❌ 请输入有效的数字") - + elif choice == "11": # 重置统计 pipette.reset_statistics() print("✅ 统计信息已重置") - + elif choice == "12": # 液体类型测试 print("\n🧪 液体类型参数对比:") @@ -929,7 +963,7 @@ def interactive_test(): (LiquidClass.VISCOUS, "粘稠液体"), (LiquidClass.VOLATILE, "挥发性液体") ] - + for liquid_class, description in liquid_tests: params = pipette.LIQUID_PARAMS[liquid_class] print(f"\n📋 {description} ({liquid_class.value}):") @@ -938,7 +972,7 @@ def interactive_test(): print(f" 💨 空气间隙: {params.air_gap}ul") print(f" 💧 预润湿: {'是' if params.pre_wet else '否'}") print(f" ⏱️ 吸液后延时: {params.delay_after_aspirate}s") - + elif choice == "99": # 紧急停止 print("\n🚨 执行紧急停止...") @@ -949,19 +983,19 @@ def interactive_test(): else: print("❌ 紧急停止执行失败") print("⚠️ 请手动检查设备状态并采取必要措施") - + # 紧急停止后询问是否继续 continue_choice = input("\n是否继续操作?(y/n): ").strip().lower() if continue_choice != 'y': print("🚪 退出程序") break - + else: print("❌ 无效选择,请重新输入") - + # 等待用户确认继续 input("\n按回车键继续...") - + except KeyboardInterrupt: print("\n\n⚠️ 用户中断操作") except Exception as e: @@ -974,19 +1008,19 @@ def interactive_test(): print("✅ 连接已断开") except: print("⚠️ 断开连接时出现问题") - + def demo_test(): """演示测试模式 - 完整功能演示""" print("\n" + "=" * 60) print("🎬 移液控制器演示测试") print("=" * 60) - + try: # 创建移液控制器实例 print("1. 🔧 创建移液控制器实例...") pipette = PipetteController(port="/dev/ttyUSB0", address=4) print("✅ 移液控制器实例创建成功") - + # 连接设备 print("\n2. 📞 连接移液器设备...") if pipette.connect(): @@ -994,7 +1028,7 @@ def demo_test(): else: print("❌ 设备连接失败") return False - + # 初始化设备 print("\n3. 🚀 初始化设备...") if pipette.initialize(): @@ -1002,19 +1036,19 @@ def demo_test(): else: print("❌ 设备初始化失败") return False - + # 装载枪头 print("\n4. 🔧 装载枪头...") if pipette.pickup_tip(): print("✅ 枪头装载成功") else: print("❌ 枪头装载失败") - + # 设置液体类型 print("\n5. 🧪 设置液体类型为血清...") pipette.set_liquid_class(LiquidClass.SERUM) print("✅ 液体类型设置完成") - + # 吸液操作 print("\n6. 💧 执行吸液操作...") volume_to_aspirate = 100.0 @@ -1023,7 +1057,7 @@ def demo_test(): print(f"📊 当前体积: {pipette.current_volume}ul") else: print("❌ 吸液失败") - + # 排液操作 print("\n7. 💦 执行排液操作...") volume_to_dispense = 50.0 @@ -1032,14 +1066,14 @@ def demo_test(): print(f"📊 剩余体积: {pipette.current_volume}ul") else: print("❌ 排液失败") - + # 混合操作 print("\n8. 🌀 执行混合操作...") if pipette.mix(cycles=3, volume=30.0): print("✅ 混合完成") else: print("❌ 混合失败") - + # 获取状态信息 print("\n9. 📊 获取设备状态...") status = pipette.get_status() @@ -1052,30 +1086,30 @@ def demo_test(): # print(f" 🔧 枪头使用次数: {status['statistics']['tip_count']}") print(f" ⬆️ 吸液次数: {status['statistics']['aspirate_count']}") print(f" ⬇️ 排液次数: {status['statistics']['dispense_count']}") - + # 弹出枪头 print("\n10. 🗑️ 弹出枪头...") if pipette.eject_tip(): print("✅ 枪头弹出成功") else: print("❌ 枪头弹出失败") - + print("\n" + "=" * 60) print("✅ 移液控制器演示测试完成") print("=" * 60) - + return True - + except Exception as e: print(f"\n❌ 测试过程中发生异常: {e}") return False - + finally: # 断开连接 print("\n📞 断开连接...") pipette.disconnect() print("✅ 连接已断开") - + # 主程序入口 print("🧪 移液器控制器测试程序") print("=" * 40) @@ -1083,9 +1117,9 @@ def demo_test(): print("2. 🎬 演示测试") print("0. 🚪 退出") print("=" * 40) - + mode = input("请选择测试模式 (0-2): ").strip() - + if mode == "1": interactive_test() elif mode == "2": @@ -1094,7 +1128,7 @@ def demo_test(): print("👋 再见!") else: print("❌ 无效选择") - + print("\n🎉 程序结束!") print("\n💡 使用说明:") print("1. 确保移液器硬件已正确连接") diff --git a/unilabos/devices/liquid_handling/laiyu/laiyu.py b/unilabos/devices/liquid_handling/laiyu/laiyu.py index 0d7074a76..8591c8885 100644 --- a/unilabos/devices/liquid_handling/laiyu/laiyu.py +++ b/unilabos/devices/liquid_handling/laiyu/laiyu.py @@ -13,7 +13,7 @@ SingleChannelDispense, PickupTipRack, DropTipRack, - MultiHeadAspirationPlate, ChatterBoxBackend, LiquidHandlerChatterboxBackend, + MultiHeadAspirationPlate, ) from pylabrobot.liquid_handling.standard import ( MultiHeadAspirationContainer, @@ -41,12 +41,6 @@ def __init__(self, name: str, size_x: float, size_y: float, size_z: float): super().__init__(name, size_x, size_y, size_z) self.name = name -class TransformXYZBackend(LiquidHandlerBackend): - def __init__(self, name: str, host: str, port: int, timeout: float): - super().__init__() - self.host = host - self.port = port - self.timeout = timeout class TransformXYZRvizBackend(UniLiquidHandlerRvizBackend): def __init__(self, name: str, channel_num: int): @@ -86,7 +80,9 @@ def serialize_state(self) -> Dict[str, Dict[str, Any]]: class TransformXYZHandler(LiquidHandlerAbstract): support_touch_tip = False - def __init__(self, deck: Deck, host: str = "127.0.0.1", port: int = 9999, timeout: float = 10.0, channel_num=1, simulator=True, **backend_kwargs): + def __init__(self, deck: Deck, host: str = "127.0.0.1", port: int = 9999, timeout: float = 10.0, channel_num=1, simulator=True, + serial_port: str = "/dev/ttyUSB0", baudrate: int = 115200, pipette_address: int = 4, + total_height: float = 310, **backend_kwargs): # Handle case where deck is passed as a dict (from serialization) if isinstance(deck, dict): # Try to create a TransformXYZDeck from the dict @@ -102,11 +98,22 @@ def __init__(self, deck: Deck, host: str = "127.0.0.1", port: int = 9999, timeou deck = TransformXYZDeck(name='deck', size_x=100, size_y=100, size_z=100) if simulator: - self._unilabos_backend = TransformXYZRvizBackend(name="laiyu",channel_num=channel_num) + self._unilabos_backend = TransformXYZRvizBackend(name="laiyu", channel_num=channel_num) else: - self._unilabos_backend = TransformXYZBackend(name="laiyu",host=host, port=port, timeout=timeout) + self._unilabos_backend = UniLiquidHandlerLaiyuBackend( + num_channels=channel_num, + total_height=total_height, + port=serial_port, + baudrate=baudrate, + pipette_address=pipette_address, + ) super().__init__(backend=self._unilabos_backend, deck=deck, simulator=simulator, channel_num=channel_num) + def post_init(self, ros_node): + super().post_init(ros_node) + if hasattr(self._unilabos_backend, 'post_init'): + self._unilabos_backend.post_init(ros_node) + async def add_liquid( self, asp_vols: Union[List[float], float], @@ -128,7 +135,25 @@ async def add_liquid( mix_liquid_height: Optional[float] = None, none_keys: List[str] = [], ): - pass + return await super().add_liquid( + asp_vols=asp_vols, + dis_vols=dis_vols, + reagent_sources=reagent_sources, + targets=targets, + use_channels=use_channels, + flow_rates=flow_rates, + offsets=offsets, + liquid_height=liquid_height, + blow_out_air_volume=blow_out_air_volume, + spread=spread, + is_96_well=is_96_well, + delays=delays, + mix_time=mix_time, + mix_vol=mix_vol, + mix_rate=mix_rate, + mix_liquid_height=mix_liquid_height, + none_keys=none_keys, + ) async def aspirate( self, @@ -142,7 +167,17 @@ async def aspirate( spread: Literal["wide", "tight", "custom"] = "wide", **backend_kwargs, ): - pass + return await super().aspirate( + resources=resources, + vols=vols, + use_channels=use_channels, + flow_rates=flow_rates, + offsets=offsets, + liquid_height=liquid_height, + blow_out_air_volume=blow_out_air_volume, + spread=spread, + **backend_kwargs, + ) async def dispense( self, @@ -156,7 +191,17 @@ async def dispense( spread: Literal["wide", "tight", "custom"] = "wide", **backend_kwargs, ): - pass + return await super().dispense( + resources=resources, + vols=vols, + use_channels=use_channels, + flow_rates=flow_rates, + offsets=offsets, + liquid_height=liquid_height, + blow_out_air_volume=blow_out_air_volume, + spread=spread, + **backend_kwargs, + ) async def drop_tips( self, @@ -166,7 +211,13 @@ async def drop_tips( allow_nonzero_volume: bool = False, **backend_kwargs, ): - pass + return await super().drop_tips( + tip_spots=tip_spots, + use_channels=use_channels, + offsets=offsets, + allow_nonzero_volume=allow_nonzero_volume, + **backend_kwargs, + ) async def mix( self, @@ -178,7 +229,15 @@ async def mix( mix_rate: Optional[float] = None, none_keys: List[str] = [], ): - pass + return await super().mix( + targets=targets, + mix_time=mix_time, + mix_vol=mix_vol, + height_to_bottom=height_to_bottom, + offsets=offsets, + mix_rate=mix_rate, + none_keys=none_keys, + ) async def pick_up_tips( self, @@ -187,7 +246,12 @@ async def pick_up_tips( offsets: Optional[List[Coordinate]] = None, **backend_kwargs, ): - pass + return await super().pick_up_tips( + tip_spots=tip_spots, + use_channels=use_channels, + offsets=offsets, + **backend_kwargs, + ) async def transfer_liquid( self, @@ -214,5 +278,26 @@ async def transfer_liquid( delays: Optional[List[int]] = None, none_keys: List[str] = [], ): - pass - \ No newline at end of file + return await super().transfer_liquid( + sources=sources, + targets=targets, + tip_racks=tip_racks, + use_channels=use_channels, + asp_vols=asp_vols, + dis_vols=dis_vols, + asp_flow_rates=asp_flow_rates, + dis_flow_rates=dis_flow_rates, + offsets=offsets, + touch_tip=touch_tip, + liquid_height=liquid_height, + blow_out_air_volume=blow_out_air_volume, + spread=spread, + is_96_well=is_96_well, + mix_stage=mix_stage, + mix_times=mix_times, + mix_vol=mix_vol, + mix_rate=mix_rate, + mix_liquid_height=mix_liquid_height, + delays=delays, + none_keys=none_keys, + ) diff --git a/unilabos/registry/devices/liquid_handler.yaml b/unilabos/registry/devices/liquid_handler.yaml index b04d63173..93a13c32d 100644 --- a/unilabos/registry/devices/liquid_handler.yaml +++ b/unilabos/registry/devices/liquid_handler.yaml @@ -6945,7 +6945,7 @@ liquid_handler.laiyu: properties: channel_num: default: 1 - type: string + type: integer deck: type: object host: @@ -6956,10 +6956,25 @@ liquid_handler.laiyu: type: integer simulator: default: true - type: string + type: boolean timeout: default: 10.0 type: number + serial_port: + default: /dev/ttyUSB0 + description: 硬件串口端口(非 simulator 模式下使用) + type: string + baudrate: + default: 115200 + type: integer + pipette_address: + default: 4 + description: SOPA 移液器 RS485 地址 + type: integer + total_height: + default: 310 + description: 龙门架总高度 (mm),用于坐标转换 + type: number required: - deck type: object From fe501c965f5796374e77efcc97000f9aae0107c8 Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:09:46 +0800 Subject: [PATCH 11/56] feat: Update workstation reference and templates with new PLC integration details and enhanced workflow mappings --- .cursor/skills/add-workstation/SKILL.md | 498 +++++--------------- .cursor/skills/add-workstation/reference.md | 485 ++++++++++++++++++- .cursor/skills/add-workstation/templates.md | 454 ++++++++++++++++++ 3 files changed, 1060 insertions(+), 377 deletions(-) create mode 100644 .cursor/skills/add-workstation/templates.md diff --git a/.cursor/skills/add-workstation/SKILL.md b/.cursor/skills/add-workstation/SKILL.md index 2762d4e4c..f3e59b46d 100644 --- a/.cursor/skills/add-workstation/SKILL.md +++ b/.cursor/skills/add-workstation/SKILL.md @@ -5,254 +5,89 @@ description: Guide for adding new workstations to Uni-Lab-OS (接入新工作站 # Uni-Lab-OS 工作站接入指南 -工作站(workstation)是组合多个子设备的大型设备,拥有独立的物料管理系统(PLR Deck)和工作流引擎。本指南覆盖从需求分析到验证的全流程。 +工作站是组合多个子设备的大型设备,拥有独立的物料管理系统(PLR Deck)和工作流引擎。 -> **前置知识**:工作站接入基于 `docs/ai_guides/add_device.md` 的通用设备接入框架,但有显著差异。阅读本指南前无需先读通用指南。 +> **完整代码模板**见 [templates.md](templates.md),**高级模式**见 [reference.md](reference.md)。 ## 第一步:确定工作站类型 -向用户确认以下信息: - -**Q1: 工作站的业务场景?** +向用户确认: | 类型 | 基类 | 适用场景 | 示例 | |------|------|----------|------| -| **Protocol 工作站** | `ProtocolNode` | 标准化学操作协议(过滤、转移、加热等) | FilterProtocolStation | -| **外部系统工作站** | `WorkstationBase` | 与外部 LIMS/MES 系统对接,有专属 API | BioyondStation | -| **硬件控制工作站** | `WorkstationBase` | 直接控制 PLC/硬件,无外部系统 | CoinCellAssembly | - -**Q2: 工作站英文名称?**(如 `my_reaction_station`) - -**Q3: 与外部系统的交互方式?** - -| 方式 | 适用场景 | 需要的配置 | -|------|----------|-----------| -| 无外部系统 | Protocol 工作站、纯硬件控制 | 无 | -| HTTP API | LIMS/MES 系统(如 Bioyond) | `api_host`, `api_key` | -| Modbus TCP | PLC 控制 | `address`, `port` | -| OPC UA | 工业设备 | `url` | +| **Protocol** | `ProtocolNode` | 标准化学操作协议 | FilterProtocolStation | +| **外部系统** | `WorkstationBase` | 对接 LIMS/MES API | BioyondStation | +| **硬件控制** | `WorkstationBase` | 直接控制 PLC/硬件 | CoinCellAssembly | -**Q4: 子设备组成?** -- 列出所有子设备(如反应器、泵、阀、传感器等) -- 哪些是已有设备类型?哪些需要新增? -- 子设备之间的硬件代理关系(如泵通过串口设备通信) - -**Q5: 物料管理需求?** -- 是否需要 Deck(物料面板)? -- 物料类型(plate、tip_rack、bottle 等) -- 是否需要与外部物料系统同步? +还需确认: +- 英文名称、通信方式(HTTP/Modbus/OPC UA/无) +- 子设备组成(哪些已有、哪些新增、硬件代理关系) +- 物料需求(是否需要 Deck、物料类型、是否需外部同步) --- ## 第二步:理解工作站架构 -工作站与普通设备的核心差异: - | 维度 | 普通设备 | 工作站 | |------|---------|--------| -| 基类 | 无(纯 Python 类) | `WorkstationBase` 或 `ProtocolNode` | +| 基类 | 纯 Python 类 | `WorkstationBase` / `ProtocolNode` | | ROS 节点 | `BaseROS2DeviceNode` | `ROS2WorkstationNode` | -| 状态管理 | `self.data` 字典 | 通常不用 `self.data`,用 `@property` 直接访问 | -| 子设备 | 无 | `children` 列表,通过 `self._children` 访问 | +| 状态管理 | `self.data` 字典 | `@property` 直接访问 | +| 子设备 | 无 | `self._children` / `self._ros_node.sub_devices` | | 物料 | 无 | `self.deck`(PLR Deck) | -| 图文件角色 | `parent: null` 或 `parent: ""` | `parent: null`,含 `children` 和 `deck` | ### 继承体系 -`WorkstationBase` (ABC) → `ProtocolNode` (通用协议) / `BioyondWorkstation` (→ ReactionStation, DispensingStation) / `CoinCellAssemblyWorkstation` (硬件控制) +``` +WorkstationBase (ABC) +├── BioyondWorkstation ← HTTP RPC + 资源同步 +│ ├── BioyondReactionStation +│ └── BioyondDispensingStation +├── CoinCellAssemblyWorkstation ← Modbus/PLC +└── ProtocolNode ← 标准化学协议 +``` -### ROS 层 +### 子设备初始化流程 -`ROS2WorkstationNode` 额外负责:初始化 children 子设备节点、为子设备创建 ActionClient、配置硬件代理、为 protocol_type 创建协议 ActionServer。 +`ROS2WorkstationNode.__init__` → 遍历 `children`(type=="device")→ `initialize_device_from_dict()` → 存入 `sub_devices` → 为每个动作创建 `ActionClient` → 识别通信设备(`serial_*`/`io_*`)→ `_setup_hardware_proxy()` --- ## 第三步:创建驱动文件 -文件路径:`unilabos/devices/workstation//.py` - -### 模板 A:基于外部系统的工作站 - -适用于与 LIMS/MES 等外部系统对接的场景。 - -```python -import logging -from typing import Dict, Any, Optional, List -from pylabrobot.resources import Deck - -from unilabos.devices.workstation.workstation_base import WorkstationBase - -try: - from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode -except ImportError: - ROS2WorkstationNode = None - - -class MyWorkstation(WorkstationBase): - """工作站描述""" - - _ros_node: "ROS2WorkstationNode" - - def __init__( - self, - config: dict = None, - deck: Optional[Deck] = None, - protocol_type: list = None, - **kwargs, - ): - super().__init__(deck=deck, **kwargs) - self.config = config or {} - self.logger = logging.getLogger(f"MyWorkstation") - - # 外部系统连接配置 - self.api_host = self.config.get("api_host", "") - self.api_key = self.config.get("api_key", "") - - # 工作站业务状态(不同于 self.data 模式) - self._status = "Idle" - - def post_init(self, ros_node: "ROS2WorkstationNode") -> None: - super().post_init(ros_node) - # 在这里启动后台服务、连接监控等 - - # ============ 子设备访问 ============ - - def _get_child_device(self, device_id: str): - """通过 ID 获取子设备节点""" - return self._children.get(device_id) - - # ============ 动作方法 ============ - - async def scheduler_start(self, **kwargs) -> Dict[str, Any]: - """启动调度器""" - return {"success": True} - - async def create_order(self, json_str: str, **kwargs) -> Dict[str, Any]: - """创建工单""" - return {"success": True} - - # ============ 属性 ============ - - @property - def workflow_sequence(self) -> str: - return "[]" - - @property - def material_info(self) -> str: - return "{}" -``` - -### 模板 B:基于硬件控制的工作站 - -适用于直接与 PLC/硬件通信的场景。 - -```python -import logging -from typing import Dict, Any, Optional -from pylabrobot.resources import Deck - -from unilabos.devices.workstation.workstation_base import WorkstationBase - -try: - from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode -except ImportError: - ROS2WorkstationNode = None - - -class MyHardwareWorkstation(WorkstationBase): - """硬件控制工作站""" - - _ros_node: "ROS2WorkstationNode" - - def __init__( - self, - config: dict = None, - deck: Optional[Deck] = None, - address: str = "192.168.1.100", - port: str = "502", - debug_mode: bool = False, - *args, - **kwargs, - ): - super().__init__(deck=deck, *args, **kwargs) - self.config = config or {} - self.address = address - self.port = int(port) - self.debug_mode = debug_mode - self.logger = logging.getLogger("MyHardwareWorkstation") - - # 初始化通信客户端 - if not debug_mode: - from unilabos.device_comms.modbus_plc.client import ModbusTcpClient - self.client = ModbusTcpClient(host=self.address, port=self.port) - else: - self.client = None - - def post_init(self, ros_node: "ROS2WorkstationNode") -> None: - super().post_init(ros_node) - - # ============ 硬件读写 ============ - - def _read_register(self, name: str): - """读取 Modbus 寄存器""" - if self.debug_mode: - return 0 - # 实际读取逻辑 - pass - - # ============ 动作方法 ============ - - async def start_process(self, **kwargs) -> Dict[str, Any]: - """启动加工流程""" - return {"success": True} - - async def stop_process(self, **kwargs) -> Dict[str, Any]: - """停止加工流程""" - return {"success": True} - - # ============ 属性(从硬件实时读取)============ - - @property - def sys_status(self) -> str: - return str(self._read_register("SYS_STATUS")) -``` - -### 模板 C:Protocol 工作站 +路径:`unilabos/devices/workstation//.py` -适用于标准化学操作协议的场景,直接使用 `ProtocolNode`。 +根据类型选择模板(完整代码见 [templates.md](templates.md)): -```python -from typing import List, Optional -from pylabrobot.resources import Resource as PLRResource +| 类型 | 模板 | 关键要素 | +|------|------|---------| +| 外部系统 | Template A | `config` 接收 API 配置,`post_init` 启动 RPC/HTTP 服务 | +| 硬件控制 | Template B | `TCPClient` + CSV 寄存器映射,`use_node()` 读写 | +| Protocol | Template C | 直接使用 `ProtocolNode`,通常不需要自定义类 | -from unilabos.devices.workstation.workstation_base import ProtocolNode +**所有模板的 `__init__` 必须接受 `deck` 和 `**kwargs`。** +--- -class MyProtocolStation(ProtocolNode): - """Protocol 工作站 — 使用标准化学操作协议""" +## 第四步:创建子设备(如需要) - def __init__( - self, - protocol_type: List[str], - deck: Optional[PLRResource] = None, - *args, - **kwargs, - ): - super().__init__(protocol_type=protocol_type, deck=deck, *args, **kwargs) -``` +子设备是独立设备,有自己的驱动类和注册表。完整模板见 [templates.md § 子设备模板](templates.md)。 -> Protocol 工作站通常不需要自定义驱动类,直接使用 `ProtocolNode` 并在注册表和图文件中配置 `protocol_type` 即可。 +### 关键要点 ---- +1. **驱动类**:普通 Python 类,`self.data` 预填所有属性 +2. **注册表**:`category` 包含工作站标识,`auto-` 前缀动作不创建 ActionClient +3. **图文件**:`parent` 指向工作站 ID,`type: "device"` +4. **代码访问**:`self._children.get("reactor_1").driver_instance` -## 第四步:创建子设备驱动(如需要) +### 硬件代理模式 -工作站的子设备本身是独立设备。按 `docs/ai_guides/add_device.md` 的标准流程创建。 +当子设备需要通过通信设备(串口/IO)通信时: -子设备的关键约束: -- 在图文件中 `parent` 指向工作站 ID -- 图文件中在工作站的 `children` 数组里列出 -- 如需硬件代理,在子设备的 `config.hardware_interface.name` 指向通信设备 ID +1. 通信设备 ID 必须以 `serial_` 或 `io_` 开头 +2. 子设备注册表中声明 `hardware_interface: {name, read, write}` +3. 子设备实例的 `name` 属性值 = 通信设备 ID +4. ROS 节点自动将通信设备的 read/write 方法注入到子设备上 --- @@ -260,103 +95,68 @@ class MyProtocolStation(ProtocolNode): 路径:`unilabos/registry/devices/.yaml` -### 最小配置 - -```yaml -my_workstation: - category: - - workstation - class: - module: unilabos.devices.workstation.my_station.my_station:MyWorkstation - type: python -``` - -启动时 `--complete_registry` 自动补全 `status_types` 和 `action_value_mappings`。 - -### 完整配置参考 +**最小配置(`--complete_registry` 自动补全):** ```yaml my_workstation: - description: "我的工作站" - version: "1.0.0" category: - workstation - - my_category class: module: unilabos.devices.workstation.my_station.my_station:MyWorkstation type: python - status_types: - workflow_sequence: String - material_info: String - action_value_mappings: - scheduler_start: - type: UniLabJsonCommandAsync - goal: {} - result: - success: success - create_order: - type: UniLabJsonCommandAsync - goal: - json_str: json_str - result: - success: success - init_param_schema: - config: - type: object - deck: - type: object - protocol_type: - type: array ``` -### 子设备注册表 - -子设备有独立的注册表文件,需要在 `category` 中包含工作站标识: - -```yaml -my_reactor: - category: - - reactor - - my_workstation - class: - module: unilabos.devices.workstation.my_station.my_reactor:MyReactor - type: python -``` +**完整配置**见 [templates.md § 注册表完整配置](templates.md)。 --- -## 第六步:配置 Deck 资源(如需要) +## 第六步:配置物料系统(如需要) -如果工作站有物料管理需求,需要定义 Deck 类。 +物料层级:`Deck` → `WareHouse` → `ResourceHolder` (site) → `BottleCarrier` → `Bottle` -### 使用已有 Deck 类 +### 快速流程 -查看 `unilabos/resources/` 目录下是否有适用的 Deck 类。 +1. **创建 Bottle**(`unilabos/resources//bottles.py`)— 工厂函数,返回 `Bottle` 实例 +2. **创建 Carrier**(`.../bottle_carriers.py`)— 工厂函数,用 `create_ordered_items_2d` 定义槽位 +3. **创建 WareHouse**(`.../warehouses.py`)— 用 `warehouse_factory()` 创建堆栈 +4. **创建 Deck**(`.../decks.py`)— 继承 `pylabrobot.resources.Deck`,`setup()` 中放置 WareHouse +5. **注册表**(`unilabos/registry/resources//`)— `class.type: pylabrobot` +6. **PLR 扩展**(`unilabos/resources/plr_additional_res_reg.py`)— 导入新 Deck 类 -### 创建自定义 Deck +完整代码模板见 [templates.md § 物料资源模板](templates.md)。 -在 `unilabos/resources//decks.py` 中定义: +### 图文件中的 Deck 配置 -```python -from pylabrobot.resources import Deck -from pylabrobot.resources.coordinate import Coordinate +工作站节点引用 Deck: + +```json +"deck": { + "data": { + "_resource_child_name": "my_deck", + "_resource_type": "unilabos.resources.my_project.decks:MyStation_Deck" + } +} +``` +Deck 子节点: -def MyStation_Deck(name: str = "MyStation_Deck") -> Deck: - deck = Deck(name=name, size_x=2700.0, size_y=1080.0, size_z=1500.0) - # 在 deck 上定义子资源位置(carrier、plate 等) - return deck +```json +{ + "id": "my_deck", + "parent": "my_station", + "type": "deck", + "class": "MyStation_Deck", + "config": {"type": "MyStation_Deck", "setup": true, "rotation": {"x": 0, "y": 0, "z": 0, "type": "Rotation"}} +} ``` -在 `unilabos/resources//` 下注册或通过注册表引用。 +> **`_resource_child_name`** 必须与 Deck 节点的 `id` 一致。 --- ## 第七步:配置图文件 -图文件路径:`unilabos/test/experiments/.json` - -### 完整结构 +路径:`unilabos/test/experiments/.json` ```json { @@ -364,53 +164,19 @@ def MyStation_Deck(name: str = "MyStation_Deck") -> Deck: { "id": "my_station", "name": "my_station", - "children": ["my_deck", "sub_device_1", "sub_device_2"], + "children": ["my_deck", "sub_device_1"], "parent": null, "type": "device", "class": "my_workstation", "position": {"x": 0, "y": 0, "z": 0}, - "config": { - "api_host": "http://192.168.1.100:8080", - "api_key": "YOUR_KEY" - }, - "deck": { - "data": { - "_resource_child_name": "my_deck", - "_resource_type": "unilabos.resources.my_module.decks:MyStation_Deck" - } - }, - "size_x": 2700.0, - "size_y": 1080.0, - "size_z": 1500.0, + "config": {}, + "deck": {"data": {"_resource_child_name": "my_deck", "_resource_type": "...decks:MyStation_Deck"}}, + "size_x": 2700.0, "size_y": 1080.0, "size_z": 1500.0, "protocol_type": [], "data": {} }, - { - "id": "my_deck", - "name": "my_deck", - "children": [], - "parent": "my_station", - "type": "deck", - "class": "MyStation_Deck", - "position": {"x": 0, "y": 0, "z": 0}, - "config": { - "type": "MyStation_Deck", - "setup": true, - "rotation": {"x": 0, "y": 0, "z": 0, "type": "Rotation"} - }, - "data": {} - }, - { - "id": "sub_device_1", - "name": "sub_device_1", - "children": [], - "parent": "my_station", - "type": "device", - "class": "sub_device_registry_name", - "position": {"x": 100, "y": 0, "z": 0}, - "config": {}, - "data": {} - } + {"id": "my_deck", "parent": "my_station", "type": "deck", "class": "MyStation_Deck", "config": {"type": "MyStation_Deck", "setup": true}}, + {"id": "sub_device_1", "parent": "my_station", "type": "device", "class": "sub_device_class", "config": {}} ] } ``` @@ -419,82 +185,76 @@ def MyStation_Deck(name: str = "MyStation_Deck") -> Deck: | 字段 | 说明 | |------|------| -| `id` | 节点唯一标识,与 `children` 数组中的引用一致 | | `children` | 包含 deck ID 和所有子设备 ID | -| `parent` | 工作站节点为 `null`;子设备/deck 指向工作站 ID | -| `type` | 工作站和子设备为 `"device"`;deck 为 `"deck"` | -| `class` | 对应注册表中的设备名 | -| `deck.data._resource_child_name` | 必须与 deck 节点的 `id` 一致 | -| `deck.data._resource_type` | Deck 工厂函数的完整 Python 路径 | -| `protocol_type` | Protocol 工作站填入协议名列表;否则为 `[]` | -| `config` | 传入驱动 `__init__` 的 `config` 参数 | +| `parent` | 工作站为 `null`;子设备/deck 指向工作站 ID | +| `type` | 工作站和子设备 `"device"`;deck 为 `"deck"` | +| `class` | 注册表中的设备名 | +| `protocol_type` | Protocol 工作站填协议名列表;否则 `[]` | +| `config` | 传入 `__init__` 的 `config` 参数 | + +### Config 字段速查 + +| 字段 | 外部系统 | PLC/硬件 | 说明 | +|------|---------|---------|------| +| `api_host` / `api_key` | ✅ | — | 外部 API 连接 | +| `address` / `port` | — | ✅ | PLC 地址(init 参数,非 config 内) | +| `workflow_mappings` | ✅ | — | 工作流名 → 外部 UUID | +| `material_type_mappings` | ✅ | — | PLR 资源类 → 外部物料类型 | +| `warehouse_mapping` | ✅ | — | 仓库 → 外部 UUID + 库位 UUID | +| `http_service_config` | ✅ | — | HTTP 回调 host/port | + +> 完整 Config 结构详见 [reference.md § 2](reference.md) --- ## 第八步:验证 ```bash -# 1. 模块可导入 python -c "from unilabos.devices.workstation.. import " - -# 2. 注册表补全 unilab -g .json --complete_registry - -# 3. 启动测试 unilab -g .json ``` --- -## 高级模式 - -实现外部系统对接型工作站时,详见 [reference.md](reference.md):RPC 客户端、HTTP 回调服务、连接监控、Config 结构模式(material_type_mappings / warehouse_mapping / workflow_mappings)、ResourceSynchronizer、update_resource、工作流序列、站间物料转移、post_init 完整模式。 - ---- - ## 关键规则 -1. **`__init__` 必须接受 `deck` 和 `**kwargs`** — `WorkstationBase.__init__` 需要 `deck` 参数 -2. **通过 `self._children` 访问子设备** — 不要自行维护子设备引用 -3. **`post_init` 中启动后台服务** — 不要在 `__init__` 中启动网络连接 -4. **异步方法使用 `await self._ros_node.sleep()`** — 禁止 `time.sleep()` 和 `asyncio.sleep()` -5. **子设备在图文件中声明** — 不在驱动代码中创建子设备实例 -6. **`deck` 配置中的 `_resource_child_name` 必须与 deck 节点 ID 一致** -7. **Protocol 工作站优先使用 `ProtocolNode`** — 不需要自定义类 +1. `__init__` 必须接受 `deck` 和 `**kwargs` +2. 通过 `self._children` 访问子设备,不自行维护引用 +3. `post_init` 中启动后台服务,不在 `__init__` 中启动网络连接 +4. 异步方法使用 `await self._ros_node.sleep()`,禁止 `time.sleep()` / `asyncio.sleep()` +5. 子设备在图文件中声明,不在驱动代码中创建 +6. `_resource_child_name` 必须与 deck 节点 ID 一致 +7. Protocol 工作站优先使用 `ProtocolNode` +8. 通信设备 ID 以 `serial_` 或 `io_` 开头 --- ## 工作流清单 ``` -工作站接入进度: -- [ ] 1. 确定工作站类型(Protocol / 外部系统 / 硬件控制) +- [ ] 1. 确定类型(Protocol / 外部系统 / 硬件控制) - [ ] 2. 确认子设备组成和物料需求 -- [ ] 3. 创建工作站驱动 unilabos/devices/workstation//.py -- [ ] 4. 创建子设备驱动(如需要,按 add_device.md 流程) -- [ ] 5. 创建注册表 unilabos/registry/devices/.yaml -- [ ] 6. 创建/选择 Deck 资源类(如需要) -- [ ] 7. 配置图文件 unilabos/test/experiments/.json -- [ ] 8. 验证:可导入 + 注册表补全 + 启动测试 +- [ ] 3. 创建工作站驱动 +- [ ] 4. 创建子设备驱动 + 注册表(如需要) +- [ ] 5. 创建工作站注册表 +- [ ] 6. 创建物料资源 Bottle→Carrier→WareHouse→Deck(如需要) +- [ ] 7. 注册 PLR 扩展(Deck 类需要) +- [ ] 8. 配置图文件 +- [ ] 9. 验证 ``` --- -## 现有工作站参考 - -| 工作站 | 注册表名 | 驱动类 | 类型 | -|--------|----------|--------|------| -| Protocol 通用 | `workstation` | `ProtocolNode` | Protocol | -| Bioyond 反应站 | `reaction_station.bioyond` | `BioyondReactionStation` | 外部系统 | -| Bioyond 配液站 | `bioyond_dispensing_station` | `BioyondDispensingStation` | 外部系统 | -| 纽扣电池组装 | `coincellassemblyworkstation_device` | `CoinCellAssemblyWorkstation` | 硬件控制 | +## 参考资源 -### 参考文件路径 +- **代码模板**:[templates.md](templates.md) — 驱动模板 A/B/C、子设备、注册表、物料资源 +- **高级模式**:[reference.md](reference.md) — 外部系统集成、Config 模式、资源同步、PLC 框架、端到端案例 +- **现有工作站**: -- 基类: `unilabos/devices/workstation/workstation_base.py` -- Bioyond 基类: `unilabos/devices/workstation/bioyond_studio/station.py` -- 反应站: `unilabos/devices/workstation/bioyond_studio/reaction_station/reaction_station.py` -- 配液站: `unilabos/devices/workstation/bioyond_studio/dispensing_station/dispensing_station.py` -- 纽扣电池: `unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py` -- ROS 节点: `unilabos/ros/nodes/presets/workstation.py` -- 图文件: `unilabos/test/experiments/reaction_station_bioyond.json`, `dispensing_station_bioyond.json` +| 工作站 | 注册表名 | 类型 | 驱动路径 | +|--------|----------|------|---------| +| Bioyond 反应站 | `reaction_station.bioyond` | 外部系统 | `bioyond_studio/reaction_station/` | +| Bioyond 配液站 | `bioyond_dispensing_station` | 外部系统 | `bioyond_studio/dispensing_station/` | +| 纽扣电池组装 | `coincellassemblyworkstation_device` | 硬件控制 | `coin_cell_assembly/` | +| Protocol 通用 | `workstation` | Protocol | `workstation_base.py` | diff --git a/.cursor/skills/add-workstation/reference.md b/.cursor/skills/add-workstation/reference.md index 0c1b9f0d5..2ad798256 100644 --- a/.cursor/skills/add-workstation/reference.md +++ b/.cursor/skills/add-workstation/reference.md @@ -1,6 +1,6 @@ # 工作站高级模式参考 -本文件是 SKILL.md 的补充,包含外部系统集成、物料同步、配置结构等高级模式。 +本文件是 SKILL.md 的补充,包含外部系统集成、物料同步、PLC 框架、硬件代理等高级模式。 Agent 在需要实现这些功能时按需阅读。 --- @@ -116,7 +116,6 @@ class ConnectionMonitor: def _monitor_loop(self): while self._running: try: - # 调用外部系统接口检测连接 self.workstation.hardware_interface.ping() status = "online" except Exception: @@ -210,6 +209,35 @@ class ConnectionMonitor: } ``` +### 2.7 工作流到工序名映射 + +```json +{ + "workflow_to_section_map": { + "reactor_taken_in": "反应器放入", + "reactor_taken_out": "反应器取出", + "Solid_feeding_vials": "固体投料-小瓶" + } +} +``` + +### 2.8 动作名称映射 + +```json +{ + "action_names": { + "reactor_taken_in": { + "config": "通量-配置", + "stirring": "反应模块-开始搅拌" + }, + "solid_feeding_vials": { + "feeding": "粉末加样模块-投料", + "observe": "反应模块-观察搅拌结果" + } + } +} +``` + --- ## 3. 资源同步机制 @@ -246,14 +274,25 @@ class MyResourceSynchronizer(ResourceSynchronizer): return True ``` -### 3.2 update_resource — 上传资源树到云端 +### 3.2 资源树回调 + +Bioyond 工作站注册了资源树变更回调,实现与外部系统的自动同步: + +| 回调名 | 触发时机 | 外部操作 | +|--------|---------|---------| +| `resource_tree_add` | PLR Deck 中添加资源 | 入库到外部系统 | +| `resource_tree_remove` | PLR Deck 中移除资源 | 出库 | +| `resource_tree_transfer` | 创建物料(不入库) | 创建外部物料记录 | +| `resource_tree_update` | 资源位置移动 | 更新外部系统库位 | + +### 3.3 update_resource — 上传资源树到云端 将 PLR Deck 序列化后通过 ROS 服务上传。典型使用场景: ```python -# 在 post_init 中上传初始 deck from unilabos.ros.nodes.base_device_node import ROS2DeviceNode +# 在 post_init 中上传初始 deck ROS2DeviceNode.run_async_func( self._ros_node.update_resource, True, **{"resources": [self.deck]} @@ -315,15 +354,11 @@ async def transfer_materials_to_another_station( """将物料转移到另一个工作站""" target_node = self._children.get(target_device_id) if not target_node: - # 通过 ROS 节点查找非子设备的目标站 pass for group in transfer_groups: resource = self.find_resource_by_name(group["resource_name"]) - # 从本站 deck 移除 resource.unassign() - # 调用目标站的接收方法 - # ... return {"success": True, "transferred": len(transfer_groups)} ``` @@ -369,3 +404,437 @@ def post_init(self, ros_node): # 5. 初始化资源同步器(可选) self.resource_synchronizer = MyResourceSynchronizer(self, self.rpc_client) ``` + +--- + +## 7. PLC/Modbus 完整框架 + +### 7.1 寄存器映射 CSV 格式 + +PLC 工作站使用 CSV 文件定义寄存器映射表。路径通常为工作站目录下的 `.csv`。 + +**CSV 列定义:** + +| 列名 | 说明 | 值示例 | +|------|------|--------| +| `Name` | 寄存器节点名称(代码中引用的唯一标识) | `COIL_SYS_START_CMD` | +| `DataType` | 数据类型 | `BOOL`, `INT16`, `FLOAT32` | +| `InitValue` | 初始值(可选) | — | +| `Comment` | 注释(可选) | — | +| `Attribute` | 自定义属性(可选) | — | +| `DeviceType` | Modbus 设备类型 | `coil`, `hold_register`, `input_register`, `discrete_inputs` | +| `Address` | Modbus 地址 | `8010`, `11000` | + +**CSV 示例:** + +```csv +Name,DataType,InitValue,Comment,Attribute,DeviceType,Address, +COIL_SYS_START_CMD,BOOL,,系统启动命令,,coil,8010, +COIL_SYS_STOP_CMD,BOOL,,系统停止命令,,coil,8020, +COIL_SYS_RESET_CMD,BOOL,,系统复位命令,,coil,8030, +REG_MSG_ELECTROLYTE_VOLUME,INT16,,电解液体积,,hold_register,11004, +REG_DATA_OPEN_CIRCUIT_VOLTAGE,FLOAT32,,开路电压,,hold_register,10002, +REG_DATA_AXIS_X_POS,FLOAT32,,X轴位置,,hold_register,10004, +``` + +**命名约定:** +- 线圈:`COIL_` 前缀(读写布尔量) +- 保持寄存器:`REG_MSG_`(消息/命令寄存器)、`REG_DATA_`(数据/状态寄存器) +- `_CMD` 后缀:写入命令 +- `_STATUS` 后缀:读取状态 + +### 7.2 TCPClient 初始化 + +```python +from unilabos.device_comms.modbus_plc.client import TCPClient, BaseClient +from unilabos.device_comms.modbus_plc.modbus import DataType, WorderOrder + +# 创建 Modbus TCP 客户端 +modbus_client = TCPClient(addr="192.168.1.100", port=502) +modbus_client.client.connect() + +# 从 CSV 加载寄存器映射 +import os +csv_path = os.path.join(os.path.dirname(__file__), 'register_map.csv') +nodes = BaseClient.load_csv(csv_path) +client = modbus_client.register_node_list(nodes) +``` + +### 7.3 寄存器读写操作 + +```python +# 读取线圈(布尔值) +result, err = client.use_node('COIL_SYS_START_STATUS').read(1) +is_started = result[0] if not err else False + +# 写入线圈 +client.use_node('COIL_SYS_START_CMD').write(True) + +# 读取保持寄存器(INT16) +result, err = client.use_node('REG_DATA_ASSEMBLY_COIN_CELL_NUM').read(1) + +# 读取保持寄存器(FLOAT32,需要 2 个寄存器) +result, err = client.use_node('REG_DATA_OPEN_CIRCUIT_VOLTAGE').read(2) + +# 写入保持寄存器(FLOAT32) +client.use_node('REG_MSG_ELECTROLYTE_VOLUME').write( + 100.0, + data_type=DataType.FLOAT32, + word_order=WorderOrder.LITTLE, +) +``` + +**FLOAT32 字节序注意:** 许多 PLC 使用 Big Byte Order + Little Word Order,需要交换两个 16 位寄存器的顺序。参考 `coin_cell_assembly.py` 中的 `_decode_float32_correct` 函数。 + +### 7.4 ModbusWorkflow 生命周期 + +PLC 工作站的动作通过 `ModbusWorkflow` + `WorkflowAction` 组织,每个动作有 4 个生命周期阶段: + +```python +from unilabos.device_comms.modbus_plc.client import ModbusWorkflow, WorkflowAction + +# 定义动作的生命周期函数 +def my_init(use_node): + """初始化:设置参数""" + use_node('REG_MSG_ELECTROLYTE_VOLUME').write( + 100.0, data_type=DataType.FLOAT32, word_order=WorderOrder.LITTLE + ) + return True + +def my_start(use_node): + """启动:触发动作并轮询等待完成""" + use_node('COIL_SYS_START_CMD').write(True) + while True: + result, err = use_node('COIL_SYS_START_STATUS').read(1) + if not err and result[0]: + break + time.sleep(0.5) + return True + +def my_stop(use_node): + """停止:复位触发信号""" + use_node('COIL_SYS_START_CMD').write(False) + return True + +def my_cleanup(use_node): + """清理:无论成功失败都执行""" + use_node('COIL_SYS_RESET_CMD').write(True) + +# 组合成工作流 +workflow = ModbusWorkflow( + name="我的加工流程", + actions=[ + WorkflowAction(init=my_init, start=my_start, stop=my_stop, cleanup=my_cleanup) + ], +) + +# 执行 +client.run_modbus_workflow(workflow) +``` + +**生命周期执行顺序:** `init` → `start` → `stop` → `cleanup`(cleanup 始终执行,即使前序步骤失败) + +### 7.5 PLC 工作站中的握手循环 + +纽扣电池组装站的典型 PLC 交互模式(信息交换握手): + +```python +async def _send_msg_to_plc(self, data: dict): + """向 PLC 发送消息并等待确认""" + # 1. 写入数据寄存器 + for key, value in data.items(): + self._write_register(key, value) + + # 2. 发送"消息已准备好"信号 + self._write_coil('COIL_UNILAB_SEND_MSG_SUCC_CMD', True) + + # 3. 等待 PLC 读取确认 + while not self._read_coil('COIL_REQUEST_REC_MSG_STATUS'): + await self._ros_node.sleep(0.3) + + # 4. 撤销发送信号 + self._write_coil('COIL_UNILAB_SEND_MSG_SUCC_CMD', False) + +async def _recv_msg_from_plc(self) -> dict: + """等待 PLC 发送消息""" + # 1. 等待 PLC 发送信号 + while not self._read_coil('COIL_REQUEST_SEND_MSG_STATUS'): + await self._ros_node.sleep(0.3) + + # 2. 读取数据寄存器 + data = {} + for key in self._recv_registers: + data[key] = self._read_register(key) + + # 3. 发送"已收到"确认 + self._write_coil('COIL_UNILAB_REC_MSG_SUCC_CMD', True) + + # 4. 等待 PLC 撤销发送信号 + while self._read_coil('COIL_REQUEST_SEND_MSG_STATUS'): + await self._ros_node.sleep(0.3) + + # 5. 撤销确认信号 + self._write_coil('COIL_UNILAB_REC_MSG_SUCC_CMD', False) + + return data +``` + +### 7.6 JSON 驱动的 PLC 工作流 + +PLC 工作站还支持通过 JSON 描述工作流,无需编写 Python 代码。使用 `BaseClient.execute_procedure_from_json`: + +```json +{ + "register_node_list_from_csv_path": {"path": "register_map.csv"}, + "create_flow": [ + { + "name": "初始化系统", + "action": [ + { + "address_function_to_create": [ + {"func_name": "write_start", "node_name": "COIL_SYS_START_CMD", "mode": "write", "value": true}, + {"func_name": "read_status", "node_name": "COIL_SYS_START_STATUS", "mode": "read", "value": 1} + ], + "create_init_function": null, + "create_start_function": { + "func_name": "start_sys", + "write_functions": ["write_start"], + "condition_functions": ["read_status"], + "stop_condition_expression": "read_status[0]" + }, + "create_stop_function": {"func_name": "stop_start", "node_name": "COIL_SYS_START_CMD", "mode": "write", "value": false}, + "create_cleanup_function": null + } + ] + } + ], + "execute_flow": ["初始化系统"] +} +``` + +参考:`unilabos/device_comms/modbus_plc/client.py`(`ExecuteProcedureJson` 类型定义) + +--- + +## 8. 端到端案例 Walkthrough:Bioyond 反应站 + +以 Bioyond 反应站为例,展示从零接入一个带物料输入的外部系统工作站的完整过程。 + +### 8.1 需求 + +- **类型**:外部系统工作站(与 Bioyond LIMS 系统对接) +- **通信**:HTTP API(RPC 客户端 + HTTP 回调服务) +- **子设备**:5 个反应器(reactor_1 ~ reactor_5) +- **物料**:反应器、试剂瓶、烧杯、样品板、小瓶、枪头盒 → 6 种 WareHouse → 1 个 Deck + +### 8.2 文件结构 + +``` +unilabos/ +├── devices/workstation/bioyond_studio/ +│ ├── station.py # BioyondWorkstation 基类 +│ ├── bioyond_rpc.py # RPC 客户端 +│ └── reaction_station/ +│ └── reaction_station.py # BioyondReactionStation + BioyondReactor +├── resources/bioyond/ +│ ├── bottles.py # Bottle 工厂函数(8 种) +│ ├── bottle_carriers.py # Carrier 工厂函数(8 种) +│ ├── warehouses.py # WareHouse 工厂函数(6 种) +│ └── decks.py # BIOYOND_PolymerReactionStation_Deck +├── registry/ +│ ├── devices/reaction_station_bioyond.yaml +│ └── resources/bioyond/ +│ ├── bottles.yaml +│ ├── bottle_carriers.yaml +│ └── decks.yaml +└── test/experiments/reaction_station_bioyond.json +``` + +### 8.3 继承链 + +``` +WorkstationBase +└── BioyondWorkstation # 通用 Bioyond 逻辑 + ├── __init__(config, deck, protocol_type) + ├── post_init() → 启动连接监控 + HTTP 服务 + 上传 deck + ├── BioyondResourceSynchronizer # 物料双向同步 + └── BioyondReactionStation # 反应站特化 + ├── reactor_taken_in() # 反应器放入工作流 + ├── solid_feeding_vials() # 固体投料 + ├── liquid_feeding_solvents() # 液体投料 + └── workflow_sequence @property # 工作流序列状态 +``` + +### 8.4 物料资源层级(反应站实例) + +``` +BIOYOND_PolymerReactionStation_Deck (2700×1080×1500mm) +├── 堆栈1左 (WareHouse 4x4) ← Coordinate(-200, 400, 0) +│ ├── A01 → BottleCarrier → Reactor +│ ├── A02 → BottleCarrier → Reactor +│ └── ...(共 16 槽位) +├── 堆栈1右 (WareHouse 4x4, col_offset=4) ← Coordinate(350, 400, 0) +│ ├── A05 → BottleCarrier → Reactor +│ └── ... +├── 站内试剂存放堆栈 (WareHouse 1x2) ← Coordinate(1050, 400, 0) +│ ├── A01 → 1BottleCarrier → Bottle +│ └── A02 → 1BottleCarrier → Bottle +├── 测量小瓶仓库 (WareHouse 3x2) ← Coordinate(...) +├── 站内Tip盒堆栈(左) (WareHouse, removed_positions) +└── 站内Tip盒堆栈(右) (WareHouse) +``` + +### 8.5 图文件关键结构 + +```json +{ + "nodes": [ + { + "id": "reaction_station_bioyond", + "children": ["Bioyond_Deck", "reactor_1", "reactor_2", "reactor_3", "reactor_4", "reactor_5"], + "parent": null, + "type": "device", + "class": "reaction_station.bioyond", + "config": { + "api_key": "DE9BDDA0", + "api_host": "http://172.21.103.36:45388", + "workflow_mappings": { + "reactor_taken_out": "3a16081e-...", + "reactor_taken_in": "3a160df6-..." + }, + "material_type_mappings": { + "BIOYOND_PolymerStation_Reactor": ["反应器", "3a14233b-..."], + "BIOYOND_PolymerStation_1BottleCarrier": ["试剂瓶", "3a14233b-..."] + }, + "warehouse_mapping": { + "堆栈1左": { + "uuid": "3a14aa17-...", + "site_uuids": {"A01": "3a14aa17-...", "A02": "3a14aa17-..."} + } + }, + "http_service_config": { + "http_service_host": "127.0.0.1", + "http_service_port": 8080 + } + }, + "deck": { + "data": { + "_resource_child_name": "Bioyond_Deck", + "_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_PolymerReactionStation_Deck" + } + }, + "size_x": 2700.0, + "size_y": 1080.0, + "size_z": 2500.0, + "protocol_type": [], + "data": {} + }, + { + "id": "Bioyond_Deck", + "parent": "reaction_station_bioyond", + "type": "deck", + "class": "BIOYOND_PolymerReactionStation_Deck", + "config": {"type": "BIOYOND_PolymerReactionStation_Deck", "setup": true} + }, + { + "id": "reactor_1", + "parent": "reaction_station_bioyond", + "type": "device", + "class": "reaction_station.reactor", + "position": {"x": 1150, "y": 300, "z": 0}, + "config": {} + } + ] +} +``` + +### 8.6 初始化时序 + +``` +1. ROS2WorkstationNode.__init__ + ├── 创建 BioyondReactionStation 实例(__init__) + ├── 加载 Deck(BIOYOND_PolymerReactionStation_Deck, setup=true → 创建 6 个 WareHouse) + ├── 初始化 reactor_1~5(BioyondReactor 实例)→ sub_devices + └── 为每个 reactor 创建 ActionClient + +2. BioyondReactionStation.post_init(ros_node) + ├── 初始化 BioyondV1RPC(HTTP 客户端) + ├── 初始化 BioyondResourceSynchronizer + ├── 启动 ConnectionMonitor(30s 轮询) + ├── 启动 WorkstationHTTPService(接收回调) + ├── sync_from_external()(从 Bioyond 拉取物料到 Deck) + └── update_resource([self.deck])(上传 Deck 到云端) +``` + +### 8.7 物料同步流程 + +``` +外部入库: + Bioyond API → stock_material() → 获取物料列表 + → resource_bioyond_to_plr() → 转为 PLR Bottle/Carrier + → deck.warehouses["堆栈1左"]["A01"] = carrier + → update_resource([deck]) + +外部变更回调: + Bioyond POST /report/material_change + → WorkstationHTTPService 接收 + → process_material_change_report() + → 更新 Deck 中的资源 + → update_resource([affected_resource]) +``` + +### 8.8 工作站动作执行流程(以 reactor_taken_in 为例) + +```python +async def reactor_taken_in(self, assign_material_name, cutoff, temperature, **kwargs): + # 1. 从 config 获取工作流 UUID + workflow_id = self.config["workflow_mappings"]["reactor_taken_in"] + + # 2. 构建工序参数 + sections = self._build_sections(temperature, cutoff, ...) + + # 3. 合并到工作流序列 + self._workflow_sequence.append({"name": "reactor_taken_in", ...}) + + # 4. 调用外部系统创建工单 + result = self.hardware_interface.create_order(order_data) + + # 5. 等待外部系统完成(通过 HTTP 回调通知) + # process_order_finish_report 被回调时更新状态 + + return {"success": True} +``` + +--- + +## 9. 现有工作站 Config 结构完整对比 + +| 特性 | BioyondReactionStation | BioyondDispensingStation | CoinCellAssemblyWorkstation | +|------|----------------------|------------------------|-----------------------------| +| **继承** | BioyondWorkstation | BioyondWorkstation | WorkstationBase (直接) | +| **通信方式** | HTTP RPC | HTTP RPC | Modbus TCP | +| **`__init__` 签名** | `(config, deck, protocol_type, **kwargs)` | `(config, deck, protocol_type, **kwargs)` | `(config, deck, address, port, debug_mode, **kwargs)` | +| **子设备** | 5 个 BioyondReactor | 无 | 无 | +| **Deck** | BioyondReactionDeck (6 个 WareHouse) | BioyondDispensingDeck | CoincellDeck | +| **物料同步** | BioyondResourceSynchronizer (双向) | BioyondResourceSynchronizer (双向) | 无(本地 PLR) | +| **status_types** | `workflow_sequence: str` | 空 | 18 个属性 (sys_status, 传感器数据等) | +| **动作风格** | 语义化 (reactor_taken_in, ...) | 语义化 (compute_experiment_design, ...) | PLC 操作 (func_pack_device_init, ...) | +| **post_init** | 连接监控 + HTTP 服务 + 资源同步 + 上传 deck | 继承父类 | 上传 deck | +| **工作流管理** | workflow_mappings → 合并序列 → create_order | batch_create → wait_for_reports | PLC 握手循环 | + +### Config 字段对比 + +| 字段 | 反应站 | 配液站 | 纽扣电池 | +|------|--------|--------|---------| +| `api_host` | ✅ | ✅ | — | +| `api_key` | ✅ | ✅ | — | +| `workflow_mappings` | ✅ (8 个工作流) | — | — | +| `material_type_mappings` | ✅ (8 种物料) | ✅ | — | +| `warehouse_mapping` | ✅ (6 个仓库) | ✅ (3 个仓库) | — | +| `workflow_to_section_map` | ✅ | — | — | +| `action_names` | ✅ | — | — | +| `http_service_config` | ✅ | — | — | +| `material_default_parameters` | ✅ | — | — | +| `address` (init 参数) | — | — | ✅ | +| `port` (init 参数) | — | — | ✅ | +| `debug_mode` (init 参数) | — | — | ✅ | diff --git a/.cursor/skills/add-workstation/templates.md b/.cursor/skills/add-workstation/templates.md new file mode 100644 index 000000000..3c4778356 --- /dev/null +++ b/.cursor/skills/add-workstation/templates.md @@ -0,0 +1,454 @@ +# 工作站代码模板 + +本文件包含 SKILL.md 引用的所有代码模板。Agent 根据需要按需阅读。 + +--- + +## Template A:外部系统工作站 + +```python +import logging +from typing import Dict, Any, Optional, List +from pylabrobot.resources import Deck + +from unilabos.devices.workstation.workstation_base import WorkstationBase + +try: + from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode +except ImportError: + ROS2WorkstationNode = None + + +class MyWorkstation(WorkstationBase): + _ros_node: "ROS2WorkstationNode" + + def __init__( + self, + config: dict = None, + deck: Optional[Deck] = None, + protocol_type: list = None, + **kwargs, + ): + super().__init__(deck=deck, **kwargs) + self.config = config or {} + self.logger = logging.getLogger(f"MyWorkstation") + self.api_host = self.config.get("api_host", "") + self.api_key = self.config.get("api_key", "") + self._status = "Idle" + + def post_init(self, ros_node: "ROS2WorkstationNode") -> None: + super().post_init(ros_node) + + def _get_child_device(self, device_id: str): + return self._children.get(device_id) + + async def scheduler_start(self, **kwargs) -> Dict[str, Any]: + return {"success": True} + + async def create_order(self, json_str: str, **kwargs) -> Dict[str, Any]: + return {"success": True} + + @property + def workflow_sequence(self) -> str: + return "[]" + + @property + def material_info(self) -> str: + return "{}" +``` + +--- + +## Template B:PLC/Modbus 硬件控制工作站 + +```python +import os +import logging +from typing import Dict, Any, Optional +from pylabrobot.resources import Deck + +from unilabos.devices.workstation.workstation_base import WorkstationBase +from unilabos.device_comms.modbus_plc.client import ( + TCPClient, BaseClient, ModbusWorkflow, WorkflowAction, +) +from unilabos.device_comms.modbus_plc.modbus import ( + Base as ModbusNodeBase, DataType, WorderOrder, +) + +try: + from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode +except ImportError: + ROS2WorkstationNode = None + + +class MyHardwareWorkstation(WorkstationBase): + _ros_node: "ROS2WorkstationNode" + + def __init__( + self, + config: dict = None, + deck: Optional[Deck] = None, + address: str = "192.168.1.100", + port: str = "502", + debug_mode: bool = False, + *args, + **kwargs, + ): + super().__init__(deck=deck, *args, **kwargs) + self.config = config or {} + self.address = address + self.port = int(port) + self.debug_mode = debug_mode + self.logger = logging.getLogger("MyHardwareWorkstation") + + if not debug_mode: + modbus_client = TCPClient(addr=self.address, port=self.port) + modbus_client.client.connect() + csv_path = os.path.join(os.path.dirname(__file__), 'register_map.csv') + self.nodes = BaseClient.load_csv(csv_path) + self.client = modbus_client.register_node_list(self.nodes) + else: + self.client = None + + def _read_coil(self, name: str) -> bool: + if self.debug_mode: + return False + result, err = self.client.use_node(name).read(1) + return result[0] if not err else False + + def _write_coil(self, name: str, value: bool): + if not self.debug_mode: + self.client.use_node(name).write(value) + + def _read_register(self, name: str, data_type: DataType = DataType.INT16): + if self.debug_mode: + return 0 + result, err = self.client.use_node(name).read( + 2 if data_type == DataType.FLOAT32 else 1 + ) + return result if not err else 0 + + def _write_register(self, name: str, value, data_type: DataType = DataType.FLOAT32): + if not self.debug_mode: + self.client.use_node(name).write( + value, data_type=data_type, word_order=WorderOrder.LITTLE + ) + + async def start_process(self, **kwargs) -> Dict[str, Any]: + self._write_coil('COIL_SYS_START_CMD', True) + return {"success": True} + + async def stop_process(self, **kwargs) -> Dict[str, Any]: + self._write_coil('COIL_SYS_STOP_CMD', True) + return {"success": True} + + @property + def sys_status(self) -> str: + return str(self._read_coil("COIL_SYS_START_STATUS")) +``` + +### PLC 寄存器映射 CSV 格式 + +```csv +Name,DataType,InitValue,Comment,Attribute,DeviceType,Address, +COIL_SYS_START_CMD,BOOL,,系统启动命令,,coil,8010, +COIL_SYS_STOP_CMD,BOOL,,系统停止命令,,coil,8020, +REG_MSG_ELECTROLYTE_VOLUME,INT16,,电解液体积,,hold_register,11004, +REG_DATA_OPEN_CIRCUIT_VOLTAGE,FLOAT32,,开路电压,,hold_register,10002, +``` + +命名约定:`COIL_` 线圈 / `REG_MSG_` 命令寄存器 / `REG_DATA_` 数据寄存器 / `_CMD` 写入 / `_STATUS` 读取 + +--- + +## Template C:Protocol 工作站 + +```python +from typing import List, Optional +from pylabrobot.resources import Resource as PLRResource +from unilabos.devices.workstation.workstation_base import ProtocolNode + + +class MyProtocolStation(ProtocolNode): + def __init__( + self, + protocol_type: List[str], + deck: Optional[PLRResource] = None, + *args, + **kwargs, + ): + super().__init__(protocol_type=protocol_type, deck=deck, *args, **kwargs) +``` + +> 通常不需要自定义类,直接在注册表和图文件中配置 `ProtocolNode` + `protocol_type` 即可。 + +--- + +## 子设备模板 + +### 驱动类 + +```python +class MyReactor: + def __init__(self, **kwargs): + self.data = { + "temperature": 0.0, + "status": "Idle", + } + + async def update_metrics(self, metrics_json: str, **kwargs): + import json + metrics = json.loads(metrics_json) + self.data["temperature"] = metrics.get("temperature", 0.0) + self.data["status"] = metrics.get("status", "Idle") + return {"success": True} +``` + +### 注册表 + +```yaml +reaction_station.reactor: + category: + - reactor + - my_workstation + class: + module: unilabos.devices.workstation.my_station.my_station:MyReactor + type: python + status_types: + temperature: Float64 + status: String + action_value_mappings: + auto-update_metrics: + type: UniLabJsonCommandAsync + goal: + metrics_json: json_str + result: + success: success +``` + +> `auto-` 前缀动作不创建 ActionClient,仅供工作站驱动内部调用。 + +### 图文件节点 + +```json +{ + "id": "reactor_1", + "name": "reactor_1", + "children": [], + "parent": "my_station", + "type": "device", + "class": "reaction_station.reactor", + "position": {"x": 1150, "y": 300, "z": 0}, + "config": {}, + "data": {} +} +``` + +### 代码中访问子设备 + +```python +child_node = self._children.get("reactor_1") +child_node.driver_instance.update_metrics(data) + +child = self._ros_node.sub_devices.get("reactor_1") +child.driver_instance.data["temperature"] +``` + +### 硬件代理配置 + +通信设备节点(ID 以 `serial_` 或 `io_` 开头): + +```json +{ + "id": "serial_port_1", + "parent": "my_station", + "type": "device", + "class": "serial_device", + "config": { + "hardware_interface": { + "name": "hardware_interface_name", + "read": "read_method", + "write": "write_method" + } + } +} +``` + +子设备注册表中声明代理: + +```yaml +my_sub_device: + class: + hardware_interface: + name: hardware_interface_name # 属性名,值为通信设备 ID + read: read_from_device # 注入的读方法 + write: write_to_device # 注入的写方法 +``` + +ROS 节点初始化时自动检测 `name` 属性值是否为另一子设备 ID,若是则注入通信设备的 read/write 方法。 + +--- + +## 注册表完整配置 + +```yaml +my_workstation: + description: "我的工作站" + version: "1.0.0" + category: + - workstation + - my_category + class: + module: unilabos.devices.workstation.my_station.my_station:MyWorkstation + type: python + status_types: + workflow_sequence: String + material_info: String + action_value_mappings: + scheduler_start: + type: UniLabJsonCommandAsync + goal: {} + result: + success: success + create_order: + type: UniLabJsonCommandAsync + goal: + json_str: json_str + result: + success: success + init_param_schema: + config: + type: object + deck: + type: object + protocol_type: + type: array +``` + +--- + +## 物料资源模板 + +### Bottle 工厂函数 + +```python +from unilabos.resources.itemized_carrier import Bottle + + +def My_Reagent_Bottle( + name: str, + diameter: float = 70.0, + height: float = 120.0, + max_volume: float = 500000.0, # 单位 μL(500mL = 500000) + barcode: str = None, +) -> Bottle: + return Bottle( + name=name, diameter=diameter, height=height, + max_volume=max_volume, barcode=barcode, + model="My_Reagent_Bottle", + ) +``` + +### BottleCarrier 工厂函数 + +```python +from pylabrobot.resources import ResourceHolder +from pylabrobot.resources.carrier import create_ordered_items_2d +from unilabos.resources.itemized_carrier import BottleCarrier + + +def My_6SlotCarrier(name: str) -> BottleCarrier: + sites = create_ordered_items_2d( + klass=ResourceHolder, + num_items_x=3, num_items_y=2, + dx=10.0, dy=10.0, dz=5.0, + item_dx=42.0, item_dy=35.0, + size_x=20.0, size_y=20.0, size_z=50.0, + ) + carrier = BottleCarrier( + name=name, size_x=146.0, size_y=80.0, size_z=55.0, + sites=sites, model="My_6SlotCarrier", + ) + carrier.num_items_x = 3 + carrier.num_items_y = 2 + carrier.num_items_z = 1 + return carrier +``` + +### WareHouse 工厂函数 + +```python +from unilabos.resources.warehouse import warehouse_factory + + +def my_warehouse_4x4(name: str) -> "WareHouse": + return warehouse_factory( + name=name, + num_items_x=4, num_items_y=4, num_items_z=1, + dx=137.0, dy=96.0, dz=120.0, + item_dx=137.0, item_dy=125.0, item_dz=10.0, + resource_size_x=127.0, resource_size_y=85.0, resource_size_z=100.0, + model="my_warehouse_4x4", + ) +``` + +### Deck 类 + +```python +from pylabrobot.resources import Deck, Coordinate + + +class MyStation_Deck(Deck): + def __init__( + self, + name: str = "MyStation_Deck", + size_x: float = 2700.0, + size_y: float = 1080.0, + size_z: float = 1500.0, + category: str = "deck", + setup: bool = False, + ) -> None: + super().__init__(name=name, size_x=size_x, size_y=size_y, size_z=size_z) + if setup: + self.setup() + + def setup(self) -> None: + self.warehouses = { + "堆栈A": my_warehouse_4x4("堆栈A"), + "堆栈B": my_warehouse_4x4("堆栈B"), + } + self.warehouse_locations = { + "堆栈A": Coordinate(-200.0, 400.0, 0.0), + "堆栈B": Coordinate(2350.0, 400.0, 0.0), + } + for wh_name, wh in self.warehouses.items(): + self.assign_child_resource(wh, location=self.warehouse_locations[wh_name]) +``` + +### 资源注册表 + +```yaml +# unilabos/registry/resources//bottles.yaml +My_Reagent_Bottle: + category: [bottles] + class: + module: unilabos.resources.my_project.bottles:My_Reagent_Bottle + type: pylabrobot + +# unilabos/registry/resources//decks.yaml +MyStation_Deck: + category: [deck] + class: + module: unilabos.resources.my_project.decks:MyStation_Deck + type: pylabrobot + registry_type: resource +``` + +### PLR 扩展注册 + +新 Deck 类需要在 `unilabos/resources/plr_additional_res_reg.py` 中导入: + +```python +def register(): + from unilabos.resources.my_project.decks import MyStation_Deck +``` From 9815961a1f6c0332a80fec3c4bf4b0a603ef131a Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:10:11 +0800 Subject: [PATCH 12/56] feat: Add new developer guides for old devices and PLC framework integration --- docs/developer_guide/add_old_device.md | 6 +- docs/developer_guide/plc_framework.md | 281 +++++++++++++++++++++++++ docs/index.md | 3 + 3 files changed, 288 insertions(+), 2 deletions(-) create mode 100644 docs/developer_guide/plc_framework.md diff --git a/docs/developer_guide/add_old_device.md b/docs/developer_guide/add_old_device.md index 583d0bb24..40507ce83 100644 --- a/docs/developer_guide/add_old_device.md +++ b/docs/developer_guide/add_old_device.md @@ -18,13 +18,15 @@ Uni-Lab 开发团队在仓库中提供了 3 个样例: - 单一机械设备**电夹爪**,通讯协议可见 [增广夹爪通讯协议](https://doc.rmaxis.com/docs/communication/fieldbus/),驱动代码位于 `unilabos/devices/gripper/rmaxis_v4.py` - 单一通信设备**IO 板卡**,驱动代码位于 `unilabos/device_comms/gripper/SRND_16_IO.py` -- 执行多设备复杂任务逻辑的**PLC**,Uni-Lab 提供了基于地址表的接入方式和点动工作流编写,测试代码位于 `unilabos/device_comms/modbus_plc/test/test_workflow.py` +- 执行多设备复杂任务逻辑的**PLC**,Uni-Lab 提供了基于地址表的接入方式和点动工作流编写,测试代码位于 `unilabos/device_comms/modbus_plc/test/test_workflow.py`。详细框架说明请参考 {doc}`plc_framework` --- ## 其他工业通信协议:CANopen, Ethernet, OPCUA... -【敬请期待】 +Uni-Lab 已实现基于 OPC UA 协议的 PLC 接管框架,用于后处理工站等项目。与 Modbus 框架相比,OPC UA 框架额外提供了自动节点发现、订阅推送、断线重连等特性。详细说明请参考 {doc}`plc_framework`。 + +其他协议(CANopen、EtherCAT 等)【敬请期待】 ## 没有接口的老设备老软件:使用 PyWinAuto diff --git a/docs/developer_guide/plc_framework.md b/docs/developer_guide/plc_framework.md new file mode 100644 index 000000000..b0e34928c --- /dev/null +++ b/docs/developer_guide/plc_framework.md @@ -0,0 +1,281 @@ +# PLC 设备接管框架 + +> 本文档面向初次接触 UniLab-OS 的开发者,介绍系统如何通过工业协议"接管"(连接并控制)PLC 设备。 + +## 什么是"PLC 接管"? + +**PLC**(可编程逻辑控制器)是工厂设备的控制核心,驱动机械臂、泵、阀门等硬件。UniLab-OS 通过网络协议直接读写 PLC 内部变量,从而控制设备运行: + +``` +UniLab-OS(Python) ←通信协议→ PLC ←电信号→ 电机/气缸/传感器 +``` + +UniLab-OS 提供两套接管框架,对应两种工业协议: + +| 框架 | 协议 | 应用项目 | 核心文件 | +| --------------- | ---------------- | ------------------ | ----------------------------------------------------------- | +| **Modbus 框架** | Modbus TCP / RTU | 扣式电池装配工站 | `unilabos/device_comms/modbus_plc/client.py` | +| **OPC UA 框架** | OPC UA | 后处理工站(怀柔) | `unilabos/devices/workstation/post_process/post_process.py` | + +两套框架**设计思想完全一致**,底层通信协议不同。理解一个,另一个基本触类旁通。 + +--- + +## 核心概念 + +### 节点(Node) + +节点是 PLC 内部一个具体的变量地址,可以理解为 PLC 的一个输入/输出端口。 + +| 属性 | 说明 | 示例 | +| ---- | -------------------------------------- | -------------------- | +| 名称 | 人类可读标识 | `COIL_SYS_START_CMD` | +| 地址 | PLC 内存地址 | `0x0064` | +| 类型 | Coil / HoldRegister / InputRegister 等 | `coil` | + +``` +PLC 内存空间 +├── Coil 区: True / False ← 控制开关量(启动/停止/复位) +├── Hold Reg: 120, 35.5 … ← 存参数值(速度、位置) +└── Input Reg: 99.8, 42 … ← 只读传感器数据 +``` + +### 动作生命周期(Action Lifecycle) + +每个设备动作被拆分为 4 个阶段,用 `try/finally` 保证安全性: + +```python +try: + init(...) # 写入参数(速度、位置等)— 可选 + start(...) # 发触发信号 + 轮询等待完成 + stop(...) # 复位触发信号(成功时执行) +except: + is_err = True +finally: + cleanup(...) # 无论成败都执行,作为安全兜底 +``` + +| 阶段 | 何时执行 | 典型内容 | +| --------- | ----------------------- | ------------------------------------ | +| `init` | 成功路径(可选) | 写运动速度 = 20.0 | +| `start` | 成功路径 | 写启动位 = True,等待完成位 = True | +| `stop` | 成功路径 | 写启动位 = False(正常复位) | +| `cleanup` | **无论成败**(finally) | 安全兜底复位,防止异常时设备持续运动 | + +> **为什么 `cleanup` 无论成败都执行?** +> 若 `start` 阶段因传感器故障抛出异常,`stop` 会被跳过,PLC 触发位仍为 `True`——设备可能持续运动。`cleanup` 放在 `finally` 块中,作为最后的安全保障,确保 PLC 一定被复位到安全状态。实际上大多数动作将 `cleanup` 设为 `null`,由 `stop` 负责正常复位即可。 + +--- + +## Modbus 框架 + +**核心文件**:`unilabos/device_comms/modbus_plc/client.py` +**参考实现**:`unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py` + +### 连接与节点注册 + +```python +from unilabos.device_comms.modbus_plc.client import TCPClient, BaseClient + +# 1. 建立 TCP 连接 +client = TCPClient(addr="172.16.28.102", port=502) +client.client.connect() + +# 2. 从 CSV 加载节点定义 +nodes = BaseClient.load_csv("coin_cell_assembly_b.csv") + +# 3. 注册节点,之后可按名称访问 +client.register_node_list(nodes) + +# 访问节点 +client.use_node('COIL_SYS_START_CMD').write(True) +value, err = client.use_node('COIL_SYS_START_STATUS').read(1) +``` + +**CSV 格式**(`Name` / `DeviceType` / `Address` / `DataType`): + +| Name | DeviceType | Address | DataType | +| ------------------ | ------------- | ------- | -------- | +| COIL_SYS_START_CMD | coil | 100 | INT16 | +| REG_SPEED | hold_register | 200 | FLOAT32 | + +### 三段式接管流程(扣式电池工站) + +PLC 设备通常需要按固定顺序切换模式,以扣式电池工站为例: + +``` +Python PLC + │── 写 HAND_CMD = True ─────────>│ 切换到手动模式 + │<─ 读 HAND_STATUS = True ────────│ 确认进入手动 + │── 写 INIT_CMD = True ──────────>│ 执行初始化 + │<─ 读 INIT_STATUS = True ─────────│ 初始化完成 + │── 写 HAND_CMD = False ──────────>│ 复位(脉冲信号) + │── 写 INIT_CMD = False ──────────>│ 复位 + │── 写 AUTO_CMD = True ───────────>│ 切换自动模式 + │<─ 读 AUTO_STATUS = True ─────────│ 自动模式就绪 + │── 写 AUTO_CMD = False ──────────>│ 复位 + │── 写 START_CMD = True ──────────>│ 开始运行 + │<─ 读 START_STATUS = True ────────│ 运行确认 + │── 写 START_CMD = False ──────────>│ 复位 +``` + +> **脉冲信号模式**:命令写 `True` → 等待 PLC 状态位确认 → 命令写回 `False`,这是大多数 PLC 的标准触发方式,而不是保持高电平。 + +### JSON 配置方式 + +Modbus 框架支持纯 JSON 配置,无需手写 Python 流程: + +```json +{ + "register_node_list_from_csv_path": {"path": "M01.csv"}, + "create_flow": [ + { + "name": "归位", + "action": [{ + "address_function_to_create": [ + {"func_name": "pos_tip", "node_name": "M01_idlepos_coil_w", "mode": "write", "value": true}, + {"func_name": "pos_tip_read", "node_name": "M01_idlepos_coil_r", "mode": "read", "value": 1}, + {"func_name": "manual_stop", "node_name": "M01_manual_stop_coil_r","mode": "read", "value": 1} + ], + "create_init_function": {"func_name": "idel_init", "node_name": "M01_idlepos_velocity_rw", "mode": "write", "value": 20.0}, + "create_start_function": { + "func_name": "idel_position", + "write_functions": ["pos_tip"], + "condition_functions": ["pos_tip_read", "manual_stop"], + "stop_condition_expression": "pos_tip_read[0] and manual_stop[0]" + }, + "create_stop_function": {"func_name": "idel_stop", "node_name": "M01_idlepos_coil_w", "mode": "write", "value": false}, + "create_cleanup_function": null + }] + } + ], + "execute_flow": ["归位"] +} +``` + +执行: + +```python +client.execute_procedure_from_json(json_data) +``` + +--- + +## OPC UA 框架 + +**核心文件**:`unilabos/devices/workstation/post_process/post_process.py` +**参考实现**:`unilabos/devices/workstation/post_process/opcua_huairou.json` + +### 与 Modbus 的主要区别 + +| 特性 | Modbus | OPC UA | +| ---------- | -------------------- | --------------------------------- | +| 节点发现 | 手动填写 CSV 地址 | **自动遍历**服务器节点树 | +| 数据获取 | 轮询(主动问) | **订阅推送**(有变化时通知) | +| 节点标识 | 数字地址(如 `100`) | 字符串 NodeId(如 `ns=2;s=速度`) | +| 断线处理 | 无 | **后台监控线程**自动重连 | +| 认证安全 | 无 | 支持用户名/密码 | +| 工作流调用 | 手动调用 | **自动注册为实例方法** | + +### 连接与节点发现 + +```python +from unilabos.devices.workstation.post_process.post_process import OpcUaClient + +client = OpcUaClient( + url="opc.tcp://192.168.1.100:4840", + username="admin", # 可选 + password="123456", # 可选 + config_path="opcua_huairou.json", # 自动加载工作流配置 + cache_timeout=5.0, # 节点值缓存 5 秒 + subscription_interval=500, # 每 500ms 接收推送 +) + +# 节点自动通过订阅保持最新值,读取直接查本地缓存 +value = client.get_node_value("grab_complete") +``` + +### JSON 配置方式 + +```json +{ + "register_node_list_from_csv_path": {"path": "opcua_nodes_huairou.csv"}, + "create_flow": [ + { + "name": "trigger_grab_action", + "description": "触发反应罐及原料罐抓取动作", + "parameters": ["reaction_tank_number", "raw_tank_number"], + "action": [{ + "init_function": { + "func_name": "init_grab_params", + "write_nodes": ["reaction_tank_number", "raw_tank_number"] + }, + "start_function": { + "func_name": "start_grab", + "write_nodes": {"grab_trigger": true}, + "condition_nodes": ["grab_complete"], + "stop_condition_expression": "grab_complete == True", + "timeout_seconds": 999999.0 + }, + "stop_function": { + "func_name": "stop_grab", + "write_nodes": {"grab_trigger": false} + } + }] + } + ] +} +``` + +配置加载后,工作流自动注册为实例方法: + +```python +# 直接调用,传入参数,框架自动写入对应节点 +client.trigger_grab_action(reaction_tank_number=2, raw_tank_number=3) +``` + +--- + +## 新增设备快速上手 + +### 使用 Modbus 框架 + +``` +1. 从 PLC 工程师处拿到地址表,按格式填写 CSV(Name/DeviceType/Address/DataType) +2. 继承 BaseClient,在 __init__ 中连接并注册节点 +3. 参考 coin_cell_assembly.py 编写三段式接管函数(手动→初始化→自动→启动) +4. 或直接编写 JSON 配置,调用 execute_procedure_from_json() +``` + +### 使用 OPC UA 框架 + +``` +1. 确认设备支持 OPC UA,拿到服务器 URL(格式:opc.tcp://IP:PORT) +2. 准备 CSV 节点定义文件(可选,也可让框架自动发现) +3. 编写 JSON 配置:定义 parameters、init/start/stop 函数 +4. 实例化 OpcUaClient,传入 config_path,直接调用自动注册的工作流方法 +``` + +--- + +## 常见问题 + +**Q: `node {name} is not registered` 报错?** +A: 节点名不在 CSV 或未调用 `register_node_list_from_csv_path()`。 + +**Q: 程序卡死在 `while not status(): sleep(1)`?** +A: PLC 未返回预期完成信号。检查:PLC 是否在正确运行模式、状态位地址是否正确、PLC 有无报警。 + +**Q: OPC UA 连接成功但读不到节点?** +A: 检查节点名称是否与服务器显示名一致(区分中英文)。可调用 `_find_nodes()` 打印服务器全部节点。 + +**Q: 应该选 Modbus 还是 OPC UA?** +A: 取决于设备支持的协议,由 PLC 工程师决定。OPC UA 功能更完整,条件允许优先选择。 + +--- + +## 下一步 + +- {doc}`add_device` - 将驱动集成进 UniLab-OS 设备节点 +- {doc}`add_action` - 为设备添加可调度的动作指令 +- {doc}`add_yaml` - 编写设备注册表 YAML 文件 diff --git a/docs/index.md b/docs/index.md index 6326bb8ce..d8f033b30 100644 --- a/docs/index.md +++ b/docs/index.md @@ -17,6 +17,9 @@ developer_guide/http_api.md developer_guide/networking_overview.md developer_guide/add_device.md developer_guide/add_action.md +developer_guide/add_old_device.md +developer_guide/plc_framework.md +developer_guide/add_protocol.md developer_guide/add_registry.md developer_guide/add_yaml.md developer_guide/action_includes.md From 3c8020813bb841e500ddb7f276da6347b3ef38dd Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Tue, 17 Mar 2026 09:54:12 +0800 Subject: [PATCH 13/56] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=AE=BE?= =?UTF-8?q?=E5=A4=87=E9=AA=8C=E8=AF=81=E6=8C=87=E5=8D=97=EF=BC=8C=E7=A1=AE?= =?UTF-8?q?=E4=BF=9D=E8=AE=BE=E5=A4=87=E5=AE=9E=E7=8E=B0=E7=AC=A6=E5=90=88?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E5=A5=91=E7=BA=A6=E5=92=8C=E7=BC=96=E7=A0=81?= =?UTF-8?q?=E6=A0=87=E5=87=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/skills/validate-device/SKILL.md | 180 ++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 .cursor/skills/validate-device/SKILL.md diff --git a/.cursor/skills/validate-device/SKILL.md b/.cursor/skills/validate-device/SKILL.md new file mode 100644 index 000000000..263345524 --- /dev/null +++ b/.cursor/skills/validate-device/SKILL.md @@ -0,0 +1,180 @@ +--- +name: validate-device +description: Validate Uni-Lab-OS device or workstation implementations against interface contracts and project rules. Use when users ask to validate device code, audit compliance, check contract compatibility, review registry alignment, or mention 验证设备/检查设备/设备审计/接口对齐/device validation/check compliance/audit device/workstation validation. Prioritize docs/ai_guides/add_device.md and unilabos/registry/devices/ as validation baselines. +--- + +# 设备合规验证 (Device Validation) + +验证设备实现是否符合 Uni-Lab-OS 的接口契约和编码标准。 + +## 第一步:确定验证目标 + +先确定验证范围: +- 单文件:`unilabos/devices/.../*.py` +- 同类别批量:如 `pump_and_valve`、`temperature` +- 自动检测:用户刚改动设备代码时,从上下文与 git 变更推断目标 + +## 第二步:读取必要文件(按优先级) + +使用 `ReadFile` 工具读取以下文件,并按优先级作为验证基线: + +1. **设备实现文件** - 待验证的 Python 设备类 +2. **对应的 YAML 注册表** - `unilabos/registry/devices/` 下的对应文件 +3. **设备接入指南** - `docs/ai_guides/add_device.md`(权威规则与接口快照) +4. **CLAUDE.md / AGENTS.md** - 项目规则(关键硬约束) +5. **同类设备参考** - `unilabos/registry/devices/` 中同类别设备(优先最新仓库内容) + +如果规则冲突,优先级为:**仓库当前注册表与 `add_device.md` > `CLAUDE.md/AGENTS.md` 中通用约束 > 历史示例**。 + +## 第三步:执行验证检查 + +按顺序执行并记录证据(文件路径 + 关键信息): + +### 检查 1:参数名契约验证 + +**规则:** 动作方法的参数名是接口契约,不可重命名。 + +**检查内容:** +- 扫描公开动作方法(不以 `_` 开头) +- 从 YAML `action_value_mappings` 提取动作参数名 +- 对比 Python 方法签名参数名是否完全一致(不能改名) + +### 检查 2:status 字符串一致性 + +**规则:** status 字符串必须与同类已有设备一致。 + +**检查内容:** +- 收集 `self.data["status"]` 所有赋值 +- 对比同类设备与注册表中的标准值 +- 标记中文状态、大小写不一致和自定义状态 + +### 检查 3:self.data 初始化完整性 + +**规则:** `self.data` 必须在 `__init__` 中预填充所有属性字段。 + +**检查内容:** +- 检查 `__init__` 中 `self.data` 初始化 +- 提取 `@property` 与 YAML `status_types` 字段 +- 校验 `self.data` 已预填充全部字段,且不是空字典 + +### 检查 4:异步 sleep 使用规范 + +**规则:** 异步方法中使用 `await self._ros_node.sleep()`,禁止 `time.sleep()` 和 `asyncio.sleep()`。 + +**检查内容:** +- 扫描所有 `async def` +- 禁止:`time.sleep(...)` / `asyncio.sleep(...)` +- 正确:`await self._ros_node.sleep(...)` + +### 检查 5:YAML 与代码接口对齐 + +**规则:** 设备实现必须与 YAML 注册表定义的接口完全匹配。 + +**检查内容:** +- 属性对齐:`status_types` 字段都有对应 `@property` +- 动作对齐:`action_value_mappings` 中非 `auto-` 动作都有实现 +- 返回值检查:优先 `bool` 或 `Dict[str, Any]` + +### 检查 6:串口响应解析健壮性(按需适用) + +**规则:** 禁止用硬编码索引解析串口响应,必须先定位帧起始标记。 + +**检查内容:** +- 仅在存在串口/二进制协议代码时执行 +- 禁止直接按固定索引解析原始响应 +- 必须先定位帧起始标记(如 `/`、`0xFE`) + +### 检查 7:代码质量检查(补充项,不计入强制合规) + +**可选检查项:** +- 是否有 `post_init` 方法接收 `ros_node` +- 是否有 `initialize` 和 `cleanup` 方法 +- 类型转换:参数是否显式转换(`float(temp)`、`int(position)`) +- 错误处理:是否有基本的异常捕获 +- 日志记录:是否使用 `self.logger` + +## 第四步:生成合规报告 + +使用以下结构输出(简洁、可执行): + +```markdown +# 设备验证报告 + +**设备:** unilabos/devices/pump_and_valve/my_pump.py +**类名:** MyPump +**类别:** pump_and_valve +**验证时间:** {{date}} + +## 总体评分(仅统计适用检查) + +- ✅ 通过检查:5/6(Applicable) +- ❌ 失败检查:1/6 +- ⏭️ 跳过检查:1 项(Not Applicable) +- ⚠️ 警告:3 项 + +## 详细结果 + +### ❌ 必须修复(Blocking) +1. [检查名] 问题描述(文件与位置) + - 当前:`...` + - 应改:`...` + +### ⚠️ 建议修复(Non-blocking) +1. [检查名] 问题描述(文件与位置) + - 建议:`...` + +### ✅ 通过项 +- 检查 1:... +- 检查 3:... + +## 结论 + +该设备实现存在 **N 个必须修复问题**。修复后复检。 +``` + +## 第五步:询问是否自动修复 + +如果发现了问题,询问用户: + +``` +发现了 2 个必须修复的问题。是否需要我自动修复这些问题? + +选项: +1. 自动修复所有问题 +2. 仅修复高优先级问题 +3. 手动修复(我会提供详细指导) +4. 仅查看报告,不修复 +``` + +如果用户选择自动修复,使用当前环境可用的编辑工具(优先 `ApplyPatch`)逐个修复问题,并在修复后重新验证。 + +## 注意事项 + +1. **不要过度严格** - 某些警告可能是合理的设计选择,询问用户确认 +2. **提供上下文** - 解释为什么某个规则很重要 +3. **批量验证** - 如果验证多个设备,提供汇总报告 +4. **版本兼容** - 旧设备可能使用旧的约定,注意区分 + +## 常见问题 + +**Q: 如果找不到对应的 YAML 文件怎么办?** +A: 提示用户可能需要先创建 YAML 注册表,或者仅执行不依赖 YAML 的检查(如 status 字符串、async sleep)。 + +**Q: 如果设备是工作站(workstation)怎么办?** +A: 工作站有不同的验证规则,检查是否继承自 `WorkstationBase`,是否有 `deck` 配置等。 + +**Q: 如何处理虚拟设备(mock)?** +A: 虚拟设备可以放宽某些要求(如串口解析),但接口契约仍需遵守。 + +## 触发示例 + +用户可能这样说: +- "验证一下这个设备代码" +- "检查 my_pump.py 是否符合规范" +- "这个设备实现有什么问题吗" +- "帮我 audit 一下设备接口" +- "检查设备合规性" +- "validate device implementation" +- "验证这个 workstation 是否合规" +- "帮我检查设备实现和 registry 是否对齐" +- "做一次设备接口审计" From 827169827a27aba0914af66f7a37fef7b7343b2f Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:20:17 +0800 Subject: [PATCH 14/56] Remove outdated templates and validation guides for device and experiment graph skills. - Deleted `templates.md` for workstation code templates. - Removed `SKILL.md` and `reference.md` for editing experiment graphs. - Deleted `SKILL.md` for validating device implementations. --- .cursor/skills/add-protocol/SKILL.md | 323 ------------- .cursor/skills/add-protocol/reference.md | 207 -------- .cursor/skills/add-workstation/templates.md | 454 ------------------ .cursor/skills/edit-experiment-graph/SKILL.md | 381 --------------- .../skills/edit-experiment-graph/reference.md | 255 ---------- .cursor/skills/validate-device/SKILL.md | 180 ------- 6 files changed, 1800 deletions(-) delete mode 100644 .cursor/skills/add-protocol/SKILL.md delete mode 100644 .cursor/skills/add-protocol/reference.md delete mode 100644 .cursor/skills/add-workstation/templates.md delete mode 100644 .cursor/skills/edit-experiment-graph/SKILL.md delete mode 100644 .cursor/skills/edit-experiment-graph/reference.md delete mode 100644 .cursor/skills/validate-device/SKILL.md diff --git a/.cursor/skills/add-protocol/SKILL.md b/.cursor/skills/add-protocol/SKILL.md deleted file mode 100644 index 2537051b1..000000000 --- a/.cursor/skills/add-protocol/SKILL.md +++ /dev/null @@ -1,323 +0,0 @@ ---- -name: add-protocol -description: Guide for adding new experiment protocols to Uni-Lab-OS (添加新实验操作协议). Walks through ROS Action definition, Pydantic model creation, protocol generator implementation, and registration. Use when the user wants to add a new protocol, create a compile function, implement an experiment operation, or mentions 协议/protocol/编译/compile/实验操作. ---- - -# 添加新实验操作协议(Protocol) - -Protocol 是对实验有意义的完整动作(如泵转移、过滤、溶解),需要多设备协同。`compile/` 中的生成函数根据设备连接图将抽象操作"编译"为设备指令序列。 - -添加一个 Protocol 需修改 **6 个文件**,按以下流程执行。 - ---- - -## 第一步:确认协议信息 - -向用户确认: - -| 信息 | 示例 | -|------|------| -| 协议英文名 | `MyNewProtocol` | -| 操作描述 | 将固体样品研磨至目标粒径 | -| Goal 参数(必需 + 可选) | `vessel: dict`, `time: float = 300.0` | -| Result 字段 | `success: bool`, `message: str` | -| 需要哪些设备协同 | 研磨器、搅拌器 | - ---- - -## 第二步:创建 ROS Action 定义 - -路径:`unilabos_msgs/action/.action` - -三段式结构(Goal / Result / Feedback),用 `---` 分隔: - -``` -# Goal -Resource vessel -float64 time -string mode ---- -# Result -bool success -string return_info ---- -# Feedback -string status -string current_device -builtin_interfaces/Duration time_spent -builtin_interfaces/Duration time_remaining -``` - -**类型映射:** - -| Python 类型 | ROS 类型 | 说明 | -|------------|----------|------| -| `dict` | `Resource` | 容器/设备引用,自定义消息类型 | -| `float` | `float64` | | -| `int` | `int32` | | -| `str` | `string` | | -| `bool` | `bool` | | - -> `Resource` 是 `unilabos_msgs/msg/Resource.msg` 中定义的自定义消息类型。 - ---- - -## 第三步:注册 Action 到 CMakeLists - -在 `unilabos_msgs/CMakeLists.txt` 的 `set(action_files ...)` 块中添加: - -```cmake -"action/MyNewAction.action" -``` - -> 调试时需编译:`cd unilabos_msgs && colcon build && source ./install/local_setup.sh && cd ..` -> PR 合并后 CI/CD 自动发布,`mamba update ros-humble-unilabos-msgs` 即可。 - ---- - -## 第四步:创建 Pydantic 模型 - -在 `unilabos/messages/__init__.py` 中添加(位于 `# Start Protocols` 和 `# End Protocols` 之间): - -```python -class MyNewProtocol(BaseModel): - # === 必需参数 === - vessel: dict = Field(..., description="目标容器") - - # === 可选参数 === - time: float = Field(300.0, description="操作时间 (秒)") - mode: str = Field("default", description="操作模式") - - def model_post_init(self, __context): - """参数验证和修正""" - if self.time <= 0: - self.time = 300.0 -``` - -**规则:** -- 参数名必须与 `.action` 文件中 Goal 字段完全一致 -- `dict` 类型对应 `.action` 中的 `Resource` -- 将类名加入文件末尾的 `__all__` 列表 - ---- - -## 第五步:实现协议生成函数 - -路径:`unilabos/compile/_protocol.py` - -```python -import networkx as nx -from typing import List, Dict, Any - - -def generate_my_new_protocol( - G: nx.DiGraph, - vessel: dict, - time: float = 300.0, - mode: str = "default", - **kwargs, -) -> List[Dict[str, Any]]: - """将 MyNewProtocol 编译为设备动作序列。 - - Args: - G: 设备连接图(NetworkX),节点为设备/容器,边为物理连接 - vessel: 目标容器 {"id": "reactor_1"} - time: 操作时间(秒) - mode: 操作模式 - - Returns: - 动作列表,每个元素为: - - dict: 单步动作 - - list[dict]: 并行动作 - """ - from unilabos.compile.utils.vessel_parser import get_vessel - - vessel_id, vessel_data = get_vessel(vessel) - actions = [] - - # 查找相关设备(通过图的连接关系) - # 生成动作序列 - actions.append({ - "device_id": "target_device_id", - "action_name": "some_action", - "action_kwargs": {"param": "value"} - }) - - # 等待 - actions.append({ - "action_name": "wait", - "action_kwargs": {"time": time} - }) - - return actions -``` - -### 动作字典格式 - -```python -# 单步动作(发给子设备) -{"device_id": "pump_1", "action_name": "set_position", "action_kwargs": {"position": 10.0}} - -# 发给工作站自身 -{"device_id": "self", "action_name": "my_action", "action_kwargs": {...}} - -# 等待 -{"action_name": "wait", "action_kwargs": {"time": 5.0}} - -# 并行动作(列表嵌套) -[ - {"device_id": "pump_1", "action_name": "set_position", "action_kwargs": {"position": 10.0}}, - {"device_id": "stirrer_1", "action_name": "start_stir", "action_kwargs": {"stir_speed": 300}} -] -``` - -### 关于 `vessel` 参数类型 - -现有协议的 `vessel` 参数类型不统一: -- 新协议趋势:使用 `dict`(如 `{"id": "reactor_1"}`) -- 旧协议:使用 `str`(如 `"reactor_1"`) -- 兼容写法:`Union[str, dict]` - -**建议新协议统一使用 `dict` 类型**,通过 `get_vessel()` 兼容两种输入。 - -### 公共工具函数(`unilabos/compile/utils/`) - -| 函数 | 用途 | -|------|------| -| `get_vessel(vessel)` | 解析容器参数为 `(vessel_id, vessel_data)`,兼容 dict 和 str | -| `find_solvent_vessel(G, solvent)` | 根据溶剂名查找容器(精确→命名规则→模糊→液体类型) | -| `find_reagent_vessel(G, reagent)` | 根据试剂名查找容器(支持固体和液体) | -| `find_connected_stirrer(G, vessel)` | 查找与容器相连的搅拌器 | -| `find_solid_dispenser(G)` | 查找固体加样器 | - -### 协议内专属查找函数 - -许多协议在自己的文件内定义了专属的 `find_*` 函数(不在 `utils/` 中)。编写新协议时,优先复用 `utils/` 中的公共函数;如需特殊查找逻辑,在协议文件内部定义即可: - -```python -def find_my_special_device(G: nx.DiGraph, vessel: str) -> str: - """查找与容器相关的特殊设备""" - for node in G.nodes(): - if 'my_device_type' in G.nodes[node].get('class', '').lower(): - return node - raise ValueError("未找到特殊设备") -``` - -### 复用已有协议 - -复杂协议通常组合已有协议: - -```python -from unilabos.compile.pump_protocol import generate_pump_protocol_with_rinsing - -actions.extend(generate_pump_protocol_with_rinsing( - G, from_vessel=solvent_vessel, to_vessel=vessel, volume=volume -)) -``` - -### 图查询模式 - -```python -# 查找与容器相连的特定类型设备 -for neighbor in G.neighbors(vessel_id): - node_data = G.nodes[neighbor] - if "heater" in node_data.get("class", ""): - heater_id = neighbor - break - -# 查找最短路径(泵转移) -path = nx.shortest_path(G, source=from_vessel_id, target=to_vessel_id) -``` - ---- - -## 第六步:注册协议生成函数 - -在 `unilabos/compile/__init__.py` 中: - -1. 顶部添加导入: - -```python -from .my_new_protocol import generate_my_new_protocol -``` - -2. 在 `action_protocol_generators` 字典中添加映射: - -```python -action_protocol_generators = { - # ... 已有协议 - MyNewProtocol: generate_my_new_protocol, -} -``` - ---- - -## 第七步:配置图文件 - -在工作站的图文件中,将协议名加入 `protocol_type`: - -```json -{ - "id": "my_station", - "class": "workstation", - "config": { - "protocol_type": ["PumpTransferProtocol", "MyNewProtocol"] - } -} -``` - ---- - -## 第八步:验证 - -```bash -# 1. 模块可导入 -python -c "from unilabos.messages import MyNewProtocol; print(MyNewProtocol.model_fields)" - -# 2. 生成函数可导入 -python -c "from unilabos.compile import action_protocol_generators; print(list(action_protocol_generators.keys()))" - -# 3. 启动测试(可选) -unilab -g .json --complete_registry -``` - ---- - -## 工作流清单 - -``` -协议接入进度: -- [ ] 1. 确认协议名、参数、涉及设备 -- [ ] 2. 创建 .action 文件 (unilabos_msgs/action/.action) -- [ ] 3. 注册到 CMakeLists.txt -- [ ] 4. 创建 Pydantic 模型 (unilabos/messages/__init__.py) + 更新 __all__ -- [ ] 5. 实现生成函数 (unilabos/compile/_protocol.py) -- [ ] 6. 注册到 compile/__init__.py -- [ ] 7. 配置图文件 protocol_type -- [ ] 8. 验证 -``` - ---- - -## 高级模式 - -实现复杂协议时,详见 [reference.md](reference.md):协议运行时数据流、mock graph 测试模式、单位解析工具(`unit_parser.py`)、复杂协议组合模式(以 dissolve 为例)。 - ---- - -## 现有协议速查 - -| 协议 | Pydantic 类 | 生成函数 | 核心参数 | -|------|-------------|---------|---------| -| 泵转移 | `PumpTransferProtocol` | `generate_pump_protocol_with_rinsing` | `from_vessel, to_vessel, volume` | -| 简单转移 | `TransferProtocol` | `generate_pump_protocol` | `from_vessel, to_vessel, volume` | -| 加样 | `AddProtocol` | `generate_add_protocol` | `vessel, reagent, volume` | -| 过滤 | `FilterProtocol` | `generate_filter_protocol` | `vessel, filtrate_vessel` | -| 溶解 | `DissolveProtocol` | `generate_dissolve_protocol` | `vessel, solvent, volume` | -| 加热/冷却 | `HeatChillProtocol` | `generate_heat_chill_protocol` | `vessel, temp, time` | -| 搅拌 | `StirProtocol` | `generate_stir_protocol` | `vessel, time` | -| 分离 | `SeparateProtocol` | `generate_separate_protocol` | `from_vessel, separation_vessel, solvent` | -| 蒸发 | `EvaporateProtocol` | `generate_evaporate_protocol` | `vessel, pressure, temp, time` | -| 清洗 | `CleanProtocol` | `generate_clean_protocol` | `vessel, solvent, volume` | -| 离心 | `CentrifugeProtocol` | `generate_centrifuge_protocol` | `vessel, speed, time` | -| 抽气充气 | `EvacuateAndRefillProtocol` | `generate_evacuateandrefill_protocol` | `vessel, gas` | diff --git a/.cursor/skills/add-protocol/reference.md b/.cursor/skills/add-protocol/reference.md deleted file mode 100644 index a212ced9c..000000000 --- a/.cursor/skills/add-protocol/reference.md +++ /dev/null @@ -1,207 +0,0 @@ -# 协议高级参考 - -本文件是 SKILL.md 的补充,包含协议运行时数据流、测试模式、单位解析工具和复杂协议组合模式。Agent 在需要实现这些功能时按需阅读。 - ---- - -## 1. 协议运行时数据流 - -从图文件到协议执行的完整链路: - -``` -实验图 JSON - ↓ graphio.read_node_link_json() -physical_setup_graph (NetworkX DiGraph) - ↓ ROS2WorkstationNode._setup_protocol_names(protocol_type) -为每个 protocol_name 创建 ActionServer - ↓ 收到 Action Goal -_create_protocol_execute_callback() - ↓ convert_from_ros_msg_with_mapping(goal, mapping) -protocol_kwargs (Python dict) - ↓ 向 Host 查询 Resource 类型参数的当前状态 -protocol_kwargs 更新(vessel 带上 children、data 等) - ↓ protocol_steps_generator(G=physical_setup_graph, **protocol_kwargs) -List[Dict] 动作序列 - ↓ 逐步 execute_single_action / 并行 create_task -子设备 ActionClient 执行 -``` - -### `_setup_protocol_names` 核心逻辑 - -```python -def _setup_protocol_names(self, protocol_type): - if isinstance(protocol_type, str): - self.protocol_names = [p.strip() for p in protocol_type.split(",")] - else: - self.protocol_names = protocol_type - self.protocol_action_mappings = {} - for protocol_name in self.protocol_names: - protocol_type = globals()[protocol_name] # 从 messages 模块取 Pydantic 类 - self.protocol_action_mappings[protocol_name] = get_action_type(protocol_type) -``` - -### `_create_protocol_execute_callback` 关键步骤 - -1. `convert_from_ros_msg_with_mapping(goal, action_value_mapping["goal"])` — ROS Goal → Python dict -2. 对 `Resource` 类型字段,通过 `resource_get` Service 查询 Host 的最新资源状态 -3. `protocol_steps_generator(G=physical_setup_graph, **protocol_kwargs)` — 调用编译函数 -4. 遍历 steps:`dict` 串行执行,`list` 并行执行 -5. `execute_single_action` 通过 `_action_clients[device_id]` 向子设备发送 Action Goal -6. 执行完毕后通过 `resource_update` Service 更新资源状态 - ---- - -## 2. 测试模式 - -### 2.1 协议文件内测试函数 - -许多协议文件末尾有 `test_*` 函数,主要测试参数解析工具: - -```python -def test_dissolve_protocol(): - """测试溶解协议的各种参数解析""" - volumes = ["10 mL", "?", 10.0, "1 L", "500 μL"] - for vol in volumes: - result = parse_volume_input(vol) - print(f"体积解析: {vol} → {result}mL") - - masses = ["2.9 g", "?", 2.5, "500 mg"] - for mass in masses: - result = parse_mass_input(mass) - print(f"质量解析: {mass} → {result}g") -``` - -### 2.2 使用 mock graph 测试协议生成器 - -推荐的端到端测试模式: - -```python -import pytest -import networkx as nx -from unilabos.compile.stir_protocol import generate_stir_protocol - - -@pytest.fixture -def topology_graph(): - """创建测试拓扑图""" - G = nx.DiGraph() - G.add_node("flask_1", **{"class": "flask", "type": "container"}) - G.add_node("stirrer_1", **{"class": "virtual_stirrer", "type": "device"}) - G.add_edge("stirrer_1", "flask_1") - return G - - -def test_generate_stir_protocol(topology_graph): - """测试搅拌协议生成""" - actions = generate_stir_protocol( - G=topology_graph, - vessel="flask_1", - time="5 min", - stir_speed=300.0 - ) - assert len(actions) >= 1 - assert actions[0]["device_id"] == "stirrer_1" -``` - -**要点:** -- 用 `nx.DiGraph()` 构建最小拓扑 -- `add_node(id, **attrs)` 设置 `class`、`type`、`data` 等 -- `add_edge(src, dst)` 建立物理连接 -- 协议内的 `find_*` 函数依赖这些节点和边 - ---- - -## 3. 单位解析工具 - -路径:`unilabos/compile/utils/unit_parser.py` - -| 函数 | 输入 | 返回 | 默认值 | -|------|------|------|--------| -| `parse_volume_input(input, default_unit)` | `"100 mL"`, `"2.5 L"`, `"500 μL"`, `10.0`, `"?"` | mL (float) | 50.0 | -| `parse_mass_input(input)` | `"19.3 g"`, `"500 mg"`, `2.5`, `"?"` | g (float) | 1.0 | -| `parse_time_input(input)` | `"30 min"`, `"1 h"`, `"300"`, `60.0`, `"?"` | 秒 (float) | 60.0 | - -支持的单位: - -- **体积**: mL, L, μL/uL, milliliter, liter, microliter -- **质量**: g, mg, kg, gram, milligram, kilogram -- **时间**: s/sec/second, min/minute, h/hr/hour, d/day - -特殊值 `"?"`、`"unknown"`、`"tbd"` 返回默认值。 - ---- - -## 4. 复杂协议组合模式 - -以 `dissolve_protocol` 为例,展示如何组合多个子操作: - -### 整体流程 - -``` -1. 解析参数 (parse_volume_input, parse_mass_input, parse_time_input) -2. 设备发现 (find_connected_heatchill, find_connected_stirrer, find_solid_dispenser) -3. 判断溶解类型 (液体 vs 固体) -4. 组合动作序列: - a. heat_chill_start / start_stir (启动加热/搅拌) - b. wait (等待温度稳定) - c. pump_protocol_with_rinsing (液体转移, 通过 extend 拼接) - 或 add_solid (固体加样) - d. heat_chill / stir / wait (溶解等待) - e. heat_chill_stop (停止加热) -``` - -### 关键代码模式 - -**设备发现 → 条件组合:** - -```python -heatchill_id = find_connected_heatchill(G, vessel_id) -stirrer_id = find_connected_stirrer(G, vessel_id) -solid_dispenser_id = find_solid_dispenser(G) - -actions = [] - -# 启动阶段 -if heatchill_id and temp > 25.0: - actions.append({ - "device_id": heatchill_id, - "action_name": "heat_chill_start", - "action_kwargs": {"vessel": {"id": vessel_id}, "temp": temp} - }) - actions.append({"action_name": "wait", "action_kwargs": {"time": 30}}) -elif stirrer_id: - actions.append({ - "device_id": stirrer_id, - "action_name": "start_stir", - "action_kwargs": {"vessel": {"id": vessel_id}, "stir_speed": stir_speed} - }) - -# 转移阶段(复用已有协议) -pump_actions = generate_pump_protocol_with_rinsing( - G=G, from_vessel=solvent_vessel, to_vessel=vessel_id, volume=volume -) -actions.extend(pump_actions) - -# 等待阶段 -if heatchill_id: - actions.append({ - "device_id": heatchill_id, - "action_name": "heat_chill", - "action_kwargs": {"vessel": {"id": vessel_id}, "temp": temp, "time": time} - }) -else: - actions.append({"action_name": "wait", "action_kwargs": {"time": time}}) -``` - ---- - -## 5. 关键路径 - -| 内容 | 路径 | -|------|------| -| 协议执行回调 | `unilabos/ros/nodes/presets/workstation.py` | -| ROS 消息映射 | `unilabos/ros/msgs/message_converter.py` | -| 物理拓扑图 | `unilabos/resources/graphio.py` (`physical_setup_graph`) | -| 单位解析 | `unilabos/compile/utils/unit_parser.py` | -| 容器解析 | `unilabos/compile/utils/vessel_parser.py` | -| 溶解协议(组合示例) | `unilabos/compile/dissolve_protocol.py` | diff --git a/.cursor/skills/add-workstation/templates.md b/.cursor/skills/add-workstation/templates.md deleted file mode 100644 index 3c4778356..000000000 --- a/.cursor/skills/add-workstation/templates.md +++ /dev/null @@ -1,454 +0,0 @@ -# 工作站代码模板 - -本文件包含 SKILL.md 引用的所有代码模板。Agent 根据需要按需阅读。 - ---- - -## Template A:外部系统工作站 - -```python -import logging -from typing import Dict, Any, Optional, List -from pylabrobot.resources import Deck - -from unilabos.devices.workstation.workstation_base import WorkstationBase - -try: - from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode -except ImportError: - ROS2WorkstationNode = None - - -class MyWorkstation(WorkstationBase): - _ros_node: "ROS2WorkstationNode" - - def __init__( - self, - config: dict = None, - deck: Optional[Deck] = None, - protocol_type: list = None, - **kwargs, - ): - super().__init__(deck=deck, **kwargs) - self.config = config or {} - self.logger = logging.getLogger(f"MyWorkstation") - self.api_host = self.config.get("api_host", "") - self.api_key = self.config.get("api_key", "") - self._status = "Idle" - - def post_init(self, ros_node: "ROS2WorkstationNode") -> None: - super().post_init(ros_node) - - def _get_child_device(self, device_id: str): - return self._children.get(device_id) - - async def scheduler_start(self, **kwargs) -> Dict[str, Any]: - return {"success": True} - - async def create_order(self, json_str: str, **kwargs) -> Dict[str, Any]: - return {"success": True} - - @property - def workflow_sequence(self) -> str: - return "[]" - - @property - def material_info(self) -> str: - return "{}" -``` - ---- - -## Template B:PLC/Modbus 硬件控制工作站 - -```python -import os -import logging -from typing import Dict, Any, Optional -from pylabrobot.resources import Deck - -from unilabos.devices.workstation.workstation_base import WorkstationBase -from unilabos.device_comms.modbus_plc.client import ( - TCPClient, BaseClient, ModbusWorkflow, WorkflowAction, -) -from unilabos.device_comms.modbus_plc.modbus import ( - Base as ModbusNodeBase, DataType, WorderOrder, -) - -try: - from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode -except ImportError: - ROS2WorkstationNode = None - - -class MyHardwareWorkstation(WorkstationBase): - _ros_node: "ROS2WorkstationNode" - - def __init__( - self, - config: dict = None, - deck: Optional[Deck] = None, - address: str = "192.168.1.100", - port: str = "502", - debug_mode: bool = False, - *args, - **kwargs, - ): - super().__init__(deck=deck, *args, **kwargs) - self.config = config or {} - self.address = address - self.port = int(port) - self.debug_mode = debug_mode - self.logger = logging.getLogger("MyHardwareWorkstation") - - if not debug_mode: - modbus_client = TCPClient(addr=self.address, port=self.port) - modbus_client.client.connect() - csv_path = os.path.join(os.path.dirname(__file__), 'register_map.csv') - self.nodes = BaseClient.load_csv(csv_path) - self.client = modbus_client.register_node_list(self.nodes) - else: - self.client = None - - def _read_coil(self, name: str) -> bool: - if self.debug_mode: - return False - result, err = self.client.use_node(name).read(1) - return result[0] if not err else False - - def _write_coil(self, name: str, value: bool): - if not self.debug_mode: - self.client.use_node(name).write(value) - - def _read_register(self, name: str, data_type: DataType = DataType.INT16): - if self.debug_mode: - return 0 - result, err = self.client.use_node(name).read( - 2 if data_type == DataType.FLOAT32 else 1 - ) - return result if not err else 0 - - def _write_register(self, name: str, value, data_type: DataType = DataType.FLOAT32): - if not self.debug_mode: - self.client.use_node(name).write( - value, data_type=data_type, word_order=WorderOrder.LITTLE - ) - - async def start_process(self, **kwargs) -> Dict[str, Any]: - self._write_coil('COIL_SYS_START_CMD', True) - return {"success": True} - - async def stop_process(self, **kwargs) -> Dict[str, Any]: - self._write_coil('COIL_SYS_STOP_CMD', True) - return {"success": True} - - @property - def sys_status(self) -> str: - return str(self._read_coil("COIL_SYS_START_STATUS")) -``` - -### PLC 寄存器映射 CSV 格式 - -```csv -Name,DataType,InitValue,Comment,Attribute,DeviceType,Address, -COIL_SYS_START_CMD,BOOL,,系统启动命令,,coil,8010, -COIL_SYS_STOP_CMD,BOOL,,系统停止命令,,coil,8020, -REG_MSG_ELECTROLYTE_VOLUME,INT16,,电解液体积,,hold_register,11004, -REG_DATA_OPEN_CIRCUIT_VOLTAGE,FLOAT32,,开路电压,,hold_register,10002, -``` - -命名约定:`COIL_` 线圈 / `REG_MSG_` 命令寄存器 / `REG_DATA_` 数据寄存器 / `_CMD` 写入 / `_STATUS` 读取 - ---- - -## Template C:Protocol 工作站 - -```python -from typing import List, Optional -from pylabrobot.resources import Resource as PLRResource -from unilabos.devices.workstation.workstation_base import ProtocolNode - - -class MyProtocolStation(ProtocolNode): - def __init__( - self, - protocol_type: List[str], - deck: Optional[PLRResource] = None, - *args, - **kwargs, - ): - super().__init__(protocol_type=protocol_type, deck=deck, *args, **kwargs) -``` - -> 通常不需要自定义类,直接在注册表和图文件中配置 `ProtocolNode` + `protocol_type` 即可。 - ---- - -## 子设备模板 - -### 驱动类 - -```python -class MyReactor: - def __init__(self, **kwargs): - self.data = { - "temperature": 0.0, - "status": "Idle", - } - - async def update_metrics(self, metrics_json: str, **kwargs): - import json - metrics = json.loads(metrics_json) - self.data["temperature"] = metrics.get("temperature", 0.0) - self.data["status"] = metrics.get("status", "Idle") - return {"success": True} -``` - -### 注册表 - -```yaml -reaction_station.reactor: - category: - - reactor - - my_workstation - class: - module: unilabos.devices.workstation.my_station.my_station:MyReactor - type: python - status_types: - temperature: Float64 - status: String - action_value_mappings: - auto-update_metrics: - type: UniLabJsonCommandAsync - goal: - metrics_json: json_str - result: - success: success -``` - -> `auto-` 前缀动作不创建 ActionClient,仅供工作站驱动内部调用。 - -### 图文件节点 - -```json -{ - "id": "reactor_1", - "name": "reactor_1", - "children": [], - "parent": "my_station", - "type": "device", - "class": "reaction_station.reactor", - "position": {"x": 1150, "y": 300, "z": 0}, - "config": {}, - "data": {} -} -``` - -### 代码中访问子设备 - -```python -child_node = self._children.get("reactor_1") -child_node.driver_instance.update_metrics(data) - -child = self._ros_node.sub_devices.get("reactor_1") -child.driver_instance.data["temperature"] -``` - -### 硬件代理配置 - -通信设备节点(ID 以 `serial_` 或 `io_` 开头): - -```json -{ - "id": "serial_port_1", - "parent": "my_station", - "type": "device", - "class": "serial_device", - "config": { - "hardware_interface": { - "name": "hardware_interface_name", - "read": "read_method", - "write": "write_method" - } - } -} -``` - -子设备注册表中声明代理: - -```yaml -my_sub_device: - class: - hardware_interface: - name: hardware_interface_name # 属性名,值为通信设备 ID - read: read_from_device # 注入的读方法 - write: write_to_device # 注入的写方法 -``` - -ROS 节点初始化时自动检测 `name` 属性值是否为另一子设备 ID,若是则注入通信设备的 read/write 方法。 - ---- - -## 注册表完整配置 - -```yaml -my_workstation: - description: "我的工作站" - version: "1.0.0" - category: - - workstation - - my_category - class: - module: unilabos.devices.workstation.my_station.my_station:MyWorkstation - type: python - status_types: - workflow_sequence: String - material_info: String - action_value_mappings: - scheduler_start: - type: UniLabJsonCommandAsync - goal: {} - result: - success: success - create_order: - type: UniLabJsonCommandAsync - goal: - json_str: json_str - result: - success: success - init_param_schema: - config: - type: object - deck: - type: object - protocol_type: - type: array -``` - ---- - -## 物料资源模板 - -### Bottle 工厂函数 - -```python -from unilabos.resources.itemized_carrier import Bottle - - -def My_Reagent_Bottle( - name: str, - diameter: float = 70.0, - height: float = 120.0, - max_volume: float = 500000.0, # 单位 μL(500mL = 500000) - barcode: str = None, -) -> Bottle: - return Bottle( - name=name, diameter=diameter, height=height, - max_volume=max_volume, barcode=barcode, - model="My_Reagent_Bottle", - ) -``` - -### BottleCarrier 工厂函数 - -```python -from pylabrobot.resources import ResourceHolder -from pylabrobot.resources.carrier import create_ordered_items_2d -from unilabos.resources.itemized_carrier import BottleCarrier - - -def My_6SlotCarrier(name: str) -> BottleCarrier: - sites = create_ordered_items_2d( - klass=ResourceHolder, - num_items_x=3, num_items_y=2, - dx=10.0, dy=10.0, dz=5.0, - item_dx=42.0, item_dy=35.0, - size_x=20.0, size_y=20.0, size_z=50.0, - ) - carrier = BottleCarrier( - name=name, size_x=146.0, size_y=80.0, size_z=55.0, - sites=sites, model="My_6SlotCarrier", - ) - carrier.num_items_x = 3 - carrier.num_items_y = 2 - carrier.num_items_z = 1 - return carrier -``` - -### WareHouse 工厂函数 - -```python -from unilabos.resources.warehouse import warehouse_factory - - -def my_warehouse_4x4(name: str) -> "WareHouse": - return warehouse_factory( - name=name, - num_items_x=4, num_items_y=4, num_items_z=1, - dx=137.0, dy=96.0, dz=120.0, - item_dx=137.0, item_dy=125.0, item_dz=10.0, - resource_size_x=127.0, resource_size_y=85.0, resource_size_z=100.0, - model="my_warehouse_4x4", - ) -``` - -### Deck 类 - -```python -from pylabrobot.resources import Deck, Coordinate - - -class MyStation_Deck(Deck): - def __init__( - self, - name: str = "MyStation_Deck", - size_x: float = 2700.0, - size_y: float = 1080.0, - size_z: float = 1500.0, - category: str = "deck", - setup: bool = False, - ) -> None: - super().__init__(name=name, size_x=size_x, size_y=size_y, size_z=size_z) - if setup: - self.setup() - - def setup(self) -> None: - self.warehouses = { - "堆栈A": my_warehouse_4x4("堆栈A"), - "堆栈B": my_warehouse_4x4("堆栈B"), - } - self.warehouse_locations = { - "堆栈A": Coordinate(-200.0, 400.0, 0.0), - "堆栈B": Coordinate(2350.0, 400.0, 0.0), - } - for wh_name, wh in self.warehouses.items(): - self.assign_child_resource(wh, location=self.warehouse_locations[wh_name]) -``` - -### 资源注册表 - -```yaml -# unilabos/registry/resources//bottles.yaml -My_Reagent_Bottle: - category: [bottles] - class: - module: unilabos.resources.my_project.bottles:My_Reagent_Bottle - type: pylabrobot - -# unilabos/registry/resources//decks.yaml -MyStation_Deck: - category: [deck] - class: - module: unilabos.resources.my_project.decks:MyStation_Deck - type: pylabrobot - registry_type: resource -``` - -### PLR 扩展注册 - -新 Deck 类需要在 `unilabos/resources/plr_additional_res_reg.py` 中导入: - -```python -def register(): - from unilabos.resources.my_project.decks import MyStation_Deck -``` diff --git a/.cursor/skills/edit-experiment-graph/SKILL.md b/.cursor/skills/edit-experiment-graph/SKILL.md deleted file mode 100644 index fa2367897..000000000 --- a/.cursor/skills/edit-experiment-graph/SKILL.md +++ /dev/null @@ -1,381 +0,0 @@ ---- -name: edit-experiment-graph -description: Guide for creating and editing experiment graph files in Uni-Lab-OS (创建/编辑实验组态图). Covers node types, link types, parent-child relationships, deck configuration, and common graph patterns. Use when the user wants to create a graph file, edit an experiment configuration, set up device topology, or mentions 图文件/graph/组态/拓扑/实验图/experiment JSON. ---- - -# 创建/编辑实验组态图 - -实验图(Graph File)定义设备拓扑、物理连接和物料配置。系统启动时加载图文件,初始化所有设备和连接关系。 - -路径:`unilabos/test/experiments/.json` - ---- - -## 第一步:确认需求 - -向用户确认: - -| 信息 | 说明 | -|------|------| -| 场景类型 | 单设备调试 / 多设备联调 / 工作站完整图 | -| 包含的设备 | 设备 ID、注册表 class 名、配置参数 | -| 连接关系 | 物理连接(管道)/ 通信连接(串口)/ 无连接 | -| 父子关系 | 是否有工作站包含子设备 | -| 物料需求 | 是否需要 Deck、容器、试剂瓶 | - ---- - -## 第二步:JSON 顶层结构 - -```json -{ - "nodes": [], - "links": [] -} -``` - -> `links` 也可写作 `edges`,加载时两者等效。 - ---- - -## 第三步:定义 Nodes - -### 节点字段 - -| 字段 | 类型 | 必需 | 默认值 | 说明 | -|------|------|------|--------|------| -| `id` | string | **是** | — | 节点唯一标识,links 和 children 中引用此值 | -| `class` | string | **是** | — | 对应注册表名(设备/资源 YAML 的 key),容器可为 `null` | -| `name` | string | 否 | 同 `id` | 显示名称,缺省时自动用 `id` | -| `type` | string | 否 | `"device"` | 节点类型(见下表),缺省时自动设为 `"device"` | -| `children` | string[] | 否 | `[]` | 子节点 ID 列表 | -| `parent` | string\|null | 否 | `null` | 父节点 ID,顶层设备为 `null` | -| `position` | object | 否 | `{x:0,y:0,z:0}` | 空间坐标 | -| `config` | object | 否 | `{}` | 传给驱动 `__init__` 的参数 | -| `data` | object | 否 | `{}` | 初始运行状态 | -| `size_x/y/z` | float | 否 | — | 节点物理尺寸(工作站节点常用) | - -> 非标准字段(如 `api_host`)会自动移入 `config`。 - -### 节点类型 - -| `type` | 用途 | `class` 要求 | -|--------|------|-------------| -| `device` | 设备(默认) | 注册表中的设备名 | -| `deck` | 工作台面 | Deck 工厂函数/类名 | -| `container` | 容器(烧瓶、反应釜) | `null` 或具体容器类名 | - -### 设备节点模板 - -```json -{ - "id": "my_device", - "name": "我的设备", - "children": [], - "parent": null, - "type": "device", - "class": "registry_device_name", - "position": {"x": 0, "y": 0, "z": 0}, - "config": { - "port": "/dev/ttyUSB0", - "baudrate": 115200 - }, - "data": { - "status": "Idle" - } -} -``` - -### 容器节点模板 - -容器用于协议系统中表示试剂瓶、反应釜等,`class` 通常为 `null`: - -```json -{ - "id": "flask_DMF", - "name": "DMF试剂瓶", - "children": [], - "parent": "my_station", - "type": "container", - "class": null, - "position": {"x": 200, "y": 500, "z": 0}, - "config": {"max_volume": 1000.0}, - "data": { - "liquid": [{"liquid_type": "DMF", "liquid_volume": 800.0}] - } -} -``` - -### Deck 节点模板 - -```json -{ - "id": "my_deck", - "name": "my_deck", - "children": [], - "parent": "my_station", - "type": "deck", - "class": "MyStation_Deck", - "position": {"x": 0, "y": 0, "z": 0}, - "config": { - "type": "MyStation_Deck", - "setup": true, - "rotation": {"x": 0, "y": 0, "z": 0, "type": "Rotation"} - }, - "data": {} -} -``` - ---- - -## 第四步:定义 Links - -### Link 字段 - -| 字段 | 类型 | 说明 | -|------|------|------| -| `source` | string | 源节点 ID | -| `target` | string | 目标节点 ID | -| `type` | string | `"physical"` / `"fluid"` / `"communication"` | -| `port` | object | 端口映射 `{source_id: "port_name", target_id: "port_name"}` | - -### 物理/流体连接 - -设备间的管道连接,协议系统用此查找路径: - -```json -{ - "source": "multiway_valve_1", - "target": "flask_DMF", - "type": "fluid", - "port": { - "multiway_valve_1": "2", - "flask_DMF": "outlet" - } -} -``` - -### 通信连接 - -设备间的串口/IO 通信代理,加载时自动将端口信息写入目标设备 config: - -```json -{ - "source": "pump_1", - "target": "serial_device", - "type": "communication", - "port": { - "pump_1": "port", - "serial_device": "port" - } -} -``` - ---- - -## 第五步:父子关系与工作站配置 - -### 工作站 + 子设备 - -工作站节点的 `children` 列出所有子节点 ID,子节点的 `parent` 指向工作站: - -```json -{ - "id": "my_station", - "children": ["my_deck", "pump_1", "valve_1", "reactor_1"], - "parent": null, - "type": "device", - "class": "workstation", - "config": { - "protocol_type": ["PumpTransferProtocol", "CleanProtocol"] - } -} -``` - -### 工作站 + Deck 引用 - -工作站节点中通过 `deck` 字段引用 Deck: - -```json -{ - "id": "my_station", - "children": ["my_deck", "sub_device_1"], - "deck": { - "data": { - "_resource_child_name": "my_deck", - "_resource_type": "unilabos.resources.my_module.decks:MyDeck" - } - } -} -``` - -**关键约束:** -- `_resource_child_name` 必须与 Deck 节点的 `id` 一致 -- `_resource_type` 为 Deck 类/工厂函数的完整 Python 路径 - ---- - -## 常见图模式 - -### 模式 A:单设备调试 - -最简形式,一个设备节点,无连接: - -```json -{ - "nodes": [ - { - "id": "my_device", - "name": "my_device", - "children": [], - "parent": null, - "type": "device", - "class": "motor.zdt_x42", - "position": {"x": 0, "y": 0, "z": 0}, - "config": {"port": "/dev/ttyUSB0", "baudrate": 115200}, - "data": {"status": "idle"} - } - ], - "links": [] -} -``` - -### 模式 B:Protocol 工作站(泵+阀+容器) - -工作站配合泵、阀、容器和物理连接,用于协议编译: - -```json -{ - "nodes": [ - { - "id": "station", "name": "协议工作站", - "class": "workstation", "type": "device", "parent": null, - "children": ["pump", "valve", "flask_solvent", "reactor", "waste"], - "config": {"protocol_type": ["PumpTransferProtocol"]} - }, - {"id": "pump", "name": "转移泵", "class": "virtual_transfer_pump", - "type": "device", "parent": "station", - "config": {"port": "VIRTUAL", "max_volume": 25.0}, - "data": {"status": "Idle", "position": 0.0, "valve_position": "0"}}, - {"id": "valve", "name": "多通阀", "class": "virtual_multiway_valve", - "type": "device", "parent": "station", - "config": {"port": "VIRTUAL", "positions": 8}}, - {"id": "flask_solvent", "name": "溶剂瓶", "type": "container", - "class": null, "parent": "station", - "config": {"max_volume": 1000.0}, - "data": {"liquid": [{"liquid_type": "DMF", "liquid_volume": 500}]}}, - {"id": "reactor", "name": "反应器", "type": "container", - "class": null, "parent": "station"}, - {"id": "waste", "name": "废液瓶", "type": "container", - "class": null, "parent": "station"} - ], - "links": [ - {"source": "pump", "target": "valve", "type": "fluid", - "port": {"pump": "transferpump", "valve": "transferpump"}}, - {"source": "valve", "target": "flask_solvent", "type": "fluid", - "port": {"valve": "1", "flask_solvent": "outlet"}}, - {"source": "valve", "target": "reactor", "type": "fluid", - "port": {"valve": "2", "reactor": "inlet"}}, - {"source": "valve", "target": "waste", "type": "fluid", - "port": {"valve": "3", "waste": "inlet"}} - ] -} -``` - -### 模式 C:外部系统工作站 + Deck - -```json -{ - "nodes": [ - { - "id": "bioyond_station", "class": "reaction_station.bioyond", - "parent": null, "children": ["bioyond_deck"], - "config": { - "api_host": "http://192.168.1.100:8080", - "api_key": "YOUR_KEY", - "material_type_mappings": {}, - "warehouse_mapping": {} - }, - "deck": { - "data": { - "_resource_child_name": "bioyond_deck", - "_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_PolymerReactionStation_Deck" - } - } - }, - { - "id": "bioyond_deck", "class": "BIOYOND_PolymerReactionStation_Deck", - "parent": "bioyond_station", "type": "deck", - "config": {"type": "BIOYOND_PolymerReactionStation_Deck", "setup": true} - } - ], - "links": [] -} -``` - -### 模式 D:通信代理(串口设备) - -泵通过串口设备通信,使用 `communication` 类型的 link。加载时系统会自动将串口端口信息写入泵的 `config`: - -```json -{ - "nodes": [ - {"id": "station", "name": "工作站", "type": "device", - "class": "workstation", "parent": null, - "children": ["serial_1", "pump_1"]}, - {"id": "serial_1", "name": "串口", "type": "device", - "class": "serial", "parent": "station", - "config": {"port": "COM7", "baudrate": 9600}}, - {"id": "pump_1", "name": "注射泵", "type": "device", - "class": "syringe_pump_with_valve.runze.SY03B-T08", "parent": "station"} - ], - "links": [ - {"source": "pump_1", "target": "serial_1", "type": "communication", - "port": {"pump_1": "port", "serial_1": "port"}} - ] -} -``` - ---- - -## 验证 - -```bash -# 启动测试 -unilab -g unilabos/test/experiments/.json --complete_registry - -# 仅检查注册表 -python -m unilabos --check_mode --skip_env_check -``` - ---- - -## 高级模式 - -处理复杂图文件时,详见 [reference.md](reference.md):ResourceDict 完整字段 schema、Pose 标准化规则、Handle 验证机制、GraphML 格式支持、外部系统工作站完整 config 结构。 - ---- - -## 常见错误 - -| 错误 | 原因 | 修复 | -|------|------|------| -| `class` 找不到 | 注册表中无此设备名 | 在 `unilabos/registry/devices/` 或 `resources/` 中搜索正确名称 | -| children/parent 不一致 | 子节点 `parent` 与父节点 `children` 不匹配 | 确保双向一致 | -| `_resource_child_name` 不匹配 | Deck 引用名与 Deck 节点 `id` 不同 | 保持一致 | -| Link 端口错误 | `port` 中的 key 不是 source/target 的 `id` | key 必须是对应节点的 `id` | -| 重复 UUID | 多个节点有相同 `uuid` | 删除或修改 UUID | - ---- - -## 参考路径 - -| 内容 | 路径 | -|------|------| -| 图文件目录 | `unilabos/test/experiments/` | -| 协议测试站 | `unilabos/test/experiments/Protocol_Test_Station/` | -| 图加载代码 | `unilabos/resources/graphio.py` | -| 节点模型 | `unilabos/resources/resource_tracker.py` | -| 设备注册表 | `unilabos/registry/devices/` | -| 资源注册表 | `unilabos/registry/resources/` | -| 用户文档 | `docs/user_guide/graph_files.md` | diff --git a/.cursor/skills/edit-experiment-graph/reference.md b/.cursor/skills/edit-experiment-graph/reference.md deleted file mode 100644 index 8582acd4f..000000000 --- a/.cursor/skills/edit-experiment-graph/reference.md +++ /dev/null @@ -1,255 +0,0 @@ -# 实验图高级参考 - -本文件是 SKILL.md 的补充,包含 ResourceDict 完整 schema、Handle 验证、GraphML 格式、Pose 标准化规则和复杂图文件结构。Agent 在需要处理这些场景时按需阅读。 - ---- - -## 1. ResourceDict 完整字段 - -`unilabos/resources/resource_tracker.py` 中定义的节点数据模型: - -| 字段 | 类型 | 别名 | 说明 | -|------|------|------|------| -| `id` | `str` | — | 节点唯一标识 | -| `uuid` | `str` | — | 全局唯一标识 | -| `name` | `str` | — | 显示名称 | -| `description` | `str` | — | 描述(默认 `""` ) | -| `resource_schema` | `Dict[str, Any]` | `schema` | 资源 schema | -| `model` | `Dict[str, Any]` | — | 3D 模型信息 | -| `icon` | `str` | — | 图标(默认 `""` ) | -| `parent_uuid` | `Optional[str]` | — | 父节点 UUID | -| `parent` | `Optional[ResourceDict]` | — | 父节点引用(序列化时 exclude) | -| `type` | `Union[Literal["device"], str]` | — | 节点类型 | -| `klass` | `str` | `class` | 注册表类名 | -| `pose` | `ResourceDictPosition` | — | 位姿信息 | -| `config` | `Dict[str, Any]` | — | 配置参数 | -| `data` | `Dict[str, Any]` | — | 运行时数据 | -| `extra` | `Dict[str, Any]` | — | 扩展数据 | - -### Pose 完整结构(ResourceDictPosition) - -| 字段 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| `size` | `{width, height, depth}` | `{0,0,0}` | 节点尺寸 | -| `scale` | `{x, y, z}` | `{1,1,1}` | 缩放比例 | -| `layout` | `"2d"/"x-y"/"z-y"/"x-z"` | `"x-y"` | 布局方向 | -| `position` | `{x, y, z}` | `{0,0,0}` | 2D 位置 | -| `position3d` | `{x, y, z}` | `{0,0,0}` | 3D 位置 | -| `rotation` | `{x, y, z}` | `{0,0,0}` | 旋转角度 | -| `cross_section_type` | `"rectangle"/"circle"/"rounded_rectangle"` | `"rectangle"` | 横截面形状 | - ---- - -## 2. Position / Pose 标准化规则 - -图文件中的 `position` 有多种写法,加载时自动标准化。 - -### 输入格式兼容 - -```json -// 格式 A: 直接 {x, y, z}(最常用) -"position": {"x": 100, "y": 200, "z": 0} - -// 格式 B: 嵌套 position -"position": {"position": {"x": 100, "y": 200, "z": 0}} - -// 格式 C: 使用 pose 字段 -"pose": {"position": {"x": 100, "y": 200, "z": 0}} - -// 格式 D: 顶层 x, y, z(无 position 字段) -"x": 100, "y": 200, "z": 0 -``` - -### 标准化流程 - -1. **graphio.py `canonicalize_nodes_data`**:若 `position` 不是 dict,从节点顶层提取 `x/y/z` 填入 `pose.position` -2. **resource_tracker.py `get_resource_instance_from_dict`**:若 `position.x` 存在(旧格式),转为 `{"position": {"x":..., "y":..., "z":...}}` -3. `pose.size` 从 `config.size_x/size_y/size_z` 自动填充 - ---- - -## 3. Handle 验证 - -启动时系统验证 link 中的 `sourceHandle` / `targetHandle` 是否在注册表的 `handles` 中定义。 - -```python -# unilabos/app/main.py (约 449-481 行) -source_handler_keys = [ - h["handler_key"] for h in materials[source_node.klass]["handles"] - if h["io_type"] == "source" -] -target_handler_keys = [ - h["handler_key"] for h in materials[target_node.klass]["handles"] - if h["io_type"] == "target" -] -if source_handle not in source_handler_keys: - print_status(f"节点 {source_node.id} 的source端点 {source_handle} 不存在", "error") - resource_edge_info.pop(...) # 移除非法 link -``` - -**Handle 定义在注册表 YAML 中:** - -```yaml -my_device: - handles: - - handler_key: access - io_type: target - data_type: fluid - side: NORTH - label: access -``` - -> 大多数简单设备不定义 handles,此验证仅对有 `sourceHandle`/`targetHandle` 的 link 生效。 - ---- - -## 4. GraphML 格式支持 - -除 JSON 外,系统也支持 GraphML 格式(`unilabos/resources/graphio.py::read_graphml`)。 - -### 与 JSON 的关键差异 - -| 特性 | JSON | GraphML | -|------|------|---------| -| 父子关系 | `parent`/`children` 字段 | `::` 分隔的节点 ID(如 `station::pump_1`) | -| 加载后 | 直接解析 | 先 `nx.read_graphml` 再转 JSON 格式 | -| 输出 | 不生成副本 | 自动生成等价的 `.json` 文件 | - -### GraphML 转换流程 - -``` -nx.read_graphml(file) - ↓ 用 label 重映射节点名 - ↓ 从 "::" 推断 parent_relation -nx.relabel_nodes + nx.node_link_data - ↓ canonicalize_nodes_data + canonicalize_links_ports - ↓ 写出等价 JSON 文件 -physical_setup_graph + handle_communications -``` - ---- - -## 5. 复杂图文件结构示例 - -### 外部系统工作站完整 config - -以 `reaction_station_bioyond.json` 为例,工作站 `config` 中的关键字段: - -```json -{ - "config": { - "api_key": "DE9BDDA0", - "api_host": "http://172.21.103.36:45388", - - "workflow_mappings": { - "scheduler_start": {"workflow": "start", "params": {}}, - "create_order": {"workflow": "create_order", "params": {}} - }, - - "material_type_mappings": { - "BIOYOND_PolymerStation_Reactor": ["反应器", "type-uuid-here"], - "BIOYOND_PolymerStation_1BottleCarrier": ["试剂瓶", "type-uuid-here"] - }, - - "warehouse_mapping": { - "堆栈1左": { - "uuid": "warehouse-uuid-here", - "site_uuids": { - "A01": "site-uuid-1", - "A02": "site-uuid-2" - } - } - }, - - "http_service_config": { - "enabled": true, - "host": "0.0.0.0", - "port": 45399, - "routes": ["/callback/workflow", "/callback/material"] - }, - - "deck": { - "data": { - "_resource_child_name": "Bioyond_Deck", - "_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_PolymerReactionStation_Deck" - } - }, - - "size_x": 2700.0, - "size_y": 1080.0, - "size_z": 2500.0, - "protocol_type": [], - "data": {} - } -} -``` - -### 子设备 Reactor 节点 - -```json -{ - "id": "reactor_1", - "name": "reactor_1", - "parent": "reaction_station_bioyond", - "type": "device", - "class": "bioyond_reactor", - "position": {"x": 1150, "y": 300, "z": 0}, - "config": { - "reactor_index": 0, - "bioyond_workflow_key": "reactor_1" - }, - "data": {} -} -``` - -### Deck 节点 - -```json -{ - "id": "Bioyond_Deck", - "name": "Bioyond_Deck", - "parent": "reaction_station_bioyond", - "type": "deck", - "class": "BIOYOND_PolymerReactionStation_Deck", - "position": {"x": 0, "y": 0, "z": 0}, - "config": { - "type": "BIOYOND_PolymerReactionStation_Deck", - "setup": true, - "rotation": {"x": 0, "y": 0, "z": 0, "type": "Rotation"} - }, - "data": {} -} -``` - ---- - -## 6. Link 端口标准化 - -`graphio.py::canonicalize_links_ports` 处理 `port` 字段的多种格式: - -```python -# 输入: 字符串格式 "(A,B)" -"port": "(pump_1, valve_1)" -# 输出: 字典格式 -"port": {"source_id": "pump_1", "target_id": "valve_1"} - -# 输入: 已是字典 -"port": {"pump_1": "port", "serial_1": "port"} -# 保持不变 - -# 输入: 无 port 字段 -# 自动补充空 port -``` - ---- - -## 7. 关键路径 - -| 内容 | 路径 | -|------|------| -| ResourceDict 模型 | `unilabos/resources/resource_tracker.py` | -| 图加载 + 标准化 | `unilabos/resources/graphio.py` | -| Handle 验证 | `unilabos/app/main.py` (449-481 行) | -| 反应站图文件 | `unilabos/test/experiments/reaction_station_bioyond.json` | -| 配液站图文件 | `unilabos/test/experiments/dispensing_station_bioyond.json` | -| 用户文档 | `docs/user_guide/graph_files.md` | diff --git a/.cursor/skills/validate-device/SKILL.md b/.cursor/skills/validate-device/SKILL.md deleted file mode 100644 index 263345524..000000000 --- a/.cursor/skills/validate-device/SKILL.md +++ /dev/null @@ -1,180 +0,0 @@ ---- -name: validate-device -description: Validate Uni-Lab-OS device or workstation implementations against interface contracts and project rules. Use when users ask to validate device code, audit compliance, check contract compatibility, review registry alignment, or mention 验证设备/检查设备/设备审计/接口对齐/device validation/check compliance/audit device/workstation validation. Prioritize docs/ai_guides/add_device.md and unilabos/registry/devices/ as validation baselines. ---- - -# 设备合规验证 (Device Validation) - -验证设备实现是否符合 Uni-Lab-OS 的接口契约和编码标准。 - -## 第一步:确定验证目标 - -先确定验证范围: -- 单文件:`unilabos/devices/.../*.py` -- 同类别批量:如 `pump_and_valve`、`temperature` -- 自动检测:用户刚改动设备代码时,从上下文与 git 变更推断目标 - -## 第二步:读取必要文件(按优先级) - -使用 `ReadFile` 工具读取以下文件,并按优先级作为验证基线: - -1. **设备实现文件** - 待验证的 Python 设备类 -2. **对应的 YAML 注册表** - `unilabos/registry/devices/` 下的对应文件 -3. **设备接入指南** - `docs/ai_guides/add_device.md`(权威规则与接口快照) -4. **CLAUDE.md / AGENTS.md** - 项目规则(关键硬约束) -5. **同类设备参考** - `unilabos/registry/devices/` 中同类别设备(优先最新仓库内容) - -如果规则冲突,优先级为:**仓库当前注册表与 `add_device.md` > `CLAUDE.md/AGENTS.md` 中通用约束 > 历史示例**。 - -## 第三步:执行验证检查 - -按顺序执行并记录证据(文件路径 + 关键信息): - -### 检查 1:参数名契约验证 - -**规则:** 动作方法的参数名是接口契约,不可重命名。 - -**检查内容:** -- 扫描公开动作方法(不以 `_` 开头) -- 从 YAML `action_value_mappings` 提取动作参数名 -- 对比 Python 方法签名参数名是否完全一致(不能改名) - -### 检查 2:status 字符串一致性 - -**规则:** status 字符串必须与同类已有设备一致。 - -**检查内容:** -- 收集 `self.data["status"]` 所有赋值 -- 对比同类设备与注册表中的标准值 -- 标记中文状态、大小写不一致和自定义状态 - -### 检查 3:self.data 初始化完整性 - -**规则:** `self.data` 必须在 `__init__` 中预填充所有属性字段。 - -**检查内容:** -- 检查 `__init__` 中 `self.data` 初始化 -- 提取 `@property` 与 YAML `status_types` 字段 -- 校验 `self.data` 已预填充全部字段,且不是空字典 - -### 检查 4:异步 sleep 使用规范 - -**规则:** 异步方法中使用 `await self._ros_node.sleep()`,禁止 `time.sleep()` 和 `asyncio.sleep()`。 - -**检查内容:** -- 扫描所有 `async def` -- 禁止:`time.sleep(...)` / `asyncio.sleep(...)` -- 正确:`await self._ros_node.sleep(...)` - -### 检查 5:YAML 与代码接口对齐 - -**规则:** 设备实现必须与 YAML 注册表定义的接口完全匹配。 - -**检查内容:** -- 属性对齐:`status_types` 字段都有对应 `@property` -- 动作对齐:`action_value_mappings` 中非 `auto-` 动作都有实现 -- 返回值检查:优先 `bool` 或 `Dict[str, Any]` - -### 检查 6:串口响应解析健壮性(按需适用) - -**规则:** 禁止用硬编码索引解析串口响应,必须先定位帧起始标记。 - -**检查内容:** -- 仅在存在串口/二进制协议代码时执行 -- 禁止直接按固定索引解析原始响应 -- 必须先定位帧起始标记(如 `/`、`0xFE`) - -### 检查 7:代码质量检查(补充项,不计入强制合规) - -**可选检查项:** -- 是否有 `post_init` 方法接收 `ros_node` -- 是否有 `initialize` 和 `cleanup` 方法 -- 类型转换:参数是否显式转换(`float(temp)`、`int(position)`) -- 错误处理:是否有基本的异常捕获 -- 日志记录:是否使用 `self.logger` - -## 第四步:生成合规报告 - -使用以下结构输出(简洁、可执行): - -```markdown -# 设备验证报告 - -**设备:** unilabos/devices/pump_and_valve/my_pump.py -**类名:** MyPump -**类别:** pump_and_valve -**验证时间:** {{date}} - -## 总体评分(仅统计适用检查) - -- ✅ 通过检查:5/6(Applicable) -- ❌ 失败检查:1/6 -- ⏭️ 跳过检查:1 项(Not Applicable) -- ⚠️ 警告:3 项 - -## 详细结果 - -### ❌ 必须修复(Blocking) -1. [检查名] 问题描述(文件与位置) - - 当前:`...` - - 应改:`...` - -### ⚠️ 建议修复(Non-blocking) -1. [检查名] 问题描述(文件与位置) - - 建议:`...` - -### ✅ 通过项 -- 检查 1:... -- 检查 3:... - -## 结论 - -该设备实现存在 **N 个必须修复问题**。修复后复检。 -``` - -## 第五步:询问是否自动修复 - -如果发现了问题,询问用户: - -``` -发现了 2 个必须修复的问题。是否需要我自动修复这些问题? - -选项: -1. 自动修复所有问题 -2. 仅修复高优先级问题 -3. 手动修复(我会提供详细指导) -4. 仅查看报告,不修复 -``` - -如果用户选择自动修复,使用当前环境可用的编辑工具(优先 `ApplyPatch`)逐个修复问题,并在修复后重新验证。 - -## 注意事项 - -1. **不要过度严格** - 某些警告可能是合理的设计选择,询问用户确认 -2. **提供上下文** - 解释为什么某个规则很重要 -3. **批量验证** - 如果验证多个设备,提供汇总报告 -4. **版本兼容** - 旧设备可能使用旧的约定,注意区分 - -## 常见问题 - -**Q: 如果找不到对应的 YAML 文件怎么办?** -A: 提示用户可能需要先创建 YAML 注册表,或者仅执行不依赖 YAML 的检查(如 status 字符串、async sleep)。 - -**Q: 如果设备是工作站(workstation)怎么办?** -A: 工作站有不同的验证规则,检查是否继承自 `WorkstationBase`,是否有 `deck` 配置等。 - -**Q: 如何处理虚拟设备(mock)?** -A: 虚拟设备可以放宽某些要求(如串口解析),但接口契约仍需遵守。 - -## 触发示例 - -用户可能这样说: -- "验证一下这个设备代码" -- "检查 my_pump.py 是否符合规范" -- "这个设备实现有什么问题吗" -- "帮我 audit 一下设备接口" -- "检查设备合规性" -- "validate device implementation" -- "验证这个 workstation 是否合规" -- "帮我检查设备实现和 registry 是否对齐" -- "做一次设备接口审计" From 57f5c8752df43450db40b1564f8674c82d96bc0a Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:09:20 +0800 Subject: [PATCH 15/56] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20BIOYOND=5FPolymerSta?= =?UTF-8?q?tion=5FTipBox=20=E5=88=B0=E7=93=B6=E5=AD=90=E5=92=8C=E5=B0=8F?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E7=9B=92=E5=88=86=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../registry/resources/bioyond/bottle_carriers.yaml | 13 ------------- unilabos/registry/resources/bioyond/bottles.yaml | 11 +++++++++++ 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/unilabos/registry/resources/bioyond/bottle_carriers.yaml b/unilabos/registry/resources/bioyond/bottle_carriers.yaml index 5301ee177..f72cc10d8 100644 --- a/unilabos/registry/resources/bioyond/bottle_carriers.yaml +++ b/unilabos/registry/resources/bioyond/bottle_carriers.yaml @@ -42,16 +42,3 @@ BIOYOND_PolymerStation_8StockCarrier: icon: '' init_param_schema: {} version: 1.0.0 -BIOYOND_PolymerStation_TipBox: - category: - - bottle_carriers - - tip_racks - class: - module: unilabos.resources.bioyond.bottle_carriers:BIOYOND_PolymerStation_TipBox - type: pylabrobot - description: BIOYOND_PolymerStation_TipBox (4x6布局,24个枪头孔位) - handles: [] - icon: '' - init_param_schema: {} - registry_type: resource - version: 1.0.0 diff --git a/unilabos/registry/resources/bioyond/bottles.yaml b/unilabos/registry/resources/bioyond/bottles.yaml index e493e7b19..ecc5525d8 100644 --- a/unilabos/registry/resources/bioyond/bottles.yaml +++ b/unilabos/registry/resources/bioyond/bottles.yaml @@ -82,3 +82,14 @@ BIOYOND_PolymerStation_Solution_Beaker: icon: '' init_param_schema: {} version: 1.0.0 +BIOYOND_PolymerStation_TipBox: + category: + - bottles + - tip_boxes + class: + module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_TipBox + type: pylabrobot + handles: [] + icon: '' + init_param_schema: {} + version: 1.0.0 From 7027bd5ed1cb55fb35213b8ce60e5c507434e088 Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:12:21 +0800 Subject: [PATCH 16/56] =?UTF-8?q?=E9=98=B2=E6=AD=A2=20Deck=20=E5=AD=90?= =?UTF-8?q?=E7=B1=BB=E5=9C=A8=20=5F=5Finit=5F=5F=20=E4=B8=AD=E8=B0=83?= =?UTF-8?q?=E7=94=A8=20setup()=20=E9=A2=84=E5=88=86=E9=85=8D=E5=AD=90?= =?UTF-8?q?=E8=B5=84=E6=BA=90=EF=BC=8C=E9=81=BF=E5=85=8D=E4=B8=8E=20PLR=20?= =?UTF-8?q?deserialize=20=E4=BA=A7=E7=94=9F=E5=91=BD=E5=90=8D=E5=86=B2?= =?UTF-8?q?=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- unilabos/resources/resource_tracker.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/unilabos/resources/resource_tracker.py b/unilabos/resources/resource_tracker.py index 8946e7225..14b2c2216 100644 --- a/unilabos/resources/resource_tracker.py +++ b/unilabos/resources/resource_tracker.py @@ -611,6 +611,10 @@ def node_to_plr_dict(node: ResourceDictInstance, has_model: bool): } if has_model: d["model"] = res.config.get("model", None) + # 防止 Deck 子类在 __init__ 中调用 setup() 预分配子资源, + # 与 PLR deserialize 从 children 列表再次分配同名资源产生命名冲突 + if "setup" in d: + d["setup"] = False return d plr_resources = [] From 70c6685283c32ac67c57c817e063a4bcb9c64f33 Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:27:58 +0800 Subject: [PATCH 17/56] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E5=88=86=E9=85=8D=E9=80=BB=E8=BE=91=EF=BC=8C=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E5=9C=A8=E5=8F=8D=E5=BA=8F=E5=88=97=E5=8C=96=E6=97=B6=E5=9B=A0?= =?UTF-8?q?=E5=90=8D=E7=A7=B0=E5=86=B2=E7=AA=81=E5=AF=BC=E8=87=B4=E7=9A=84?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- unilabos/resources/itemized_carrier.py | 5 +++++ unilabos/resources/resource_tracker.py | 7 ++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/unilabos/resources/itemized_carrier.py b/unilabos/resources/itemized_carrier.py index fe55c39e5..6114716af 100644 --- a/unilabos/resources/itemized_carrier.py +++ b/unilabos/resources/itemized_carrier.py @@ -179,6 +179,11 @@ def assign_child_resource( idx = i break + if idx is None: + # 反序列化时无法匹配 site(名称或坐标均不符),退回父类默认分配,不更新 site 跟踪 + super().assign_child_resource(resource, location=location, reassign=reassign) + return + if not reassign and self.sites[idx] is not None: raise ValueError(f"a site with index {idx} already exists") location = list(self.child_locations.values())[idx] diff --git a/unilabos/resources/resource_tracker.py b/unilabos/resources/resource_tracker.py index 14b2c2216..ecf853033 100644 --- a/unilabos/resources/resource_tracker.py +++ b/unilabos/resources/resource_tracker.py @@ -611,9 +611,10 @@ def node_to_plr_dict(node: ResourceDictInstance, has_model: bool): } if has_model: d["model"] = res.config.get("model", None) - # 防止 Deck 子类在 __init__ 中调用 setup() 预分配子资源, - # 与 PLR deserialize 从 children 列表再次分配同名资源产生命名冲突 - if "setup" in d: + # 仅当 PLR dict 中含有子节点时才禁用 setup(), + # 防止 setup() 预分配子资源后 PLR deserialize 再次分配同名资源产生命名冲突。 + # 若 children 为空,则保留 setup=True,依赖 setup() 来初始化仓库。 + if "setup" in d and d.get("children"): d["setup"] = False return d From a34ffcaeb9fe38cdb77577519a90c6630bb3b11b Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:39:08 +0800 Subject: [PATCH 18/56] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E9=80=BB=E8=BE=91=EF=BC=8C=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E5=90=8C=E4=B8=80=E8=B5=84=E6=BA=90=E5=AF=B9=E8=B1=A1=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E6=B3=A8=E5=86=8C=E5=AF=BC=E8=87=B4=E7=9A=84=E9=94=99?= =?UTF-8?q?=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- unilabos/resources/resource_tracker.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/unilabos/resources/resource_tracker.py b/unilabos/resources/resource_tracker.py index ecf853033..b45ab57a8 100644 --- a/unilabos/resources/resource_tracker.py +++ b/unilabos/resources/resource_tracker.py @@ -1335,6 +1335,16 @@ def figure_resource( else: res_list.extend(self.loop_find_resource(r, type(query_resource), "unilabos_uuid", res_uuid)) + # 同一资源对象可能通过"直接注册"和"作为父资源子节点"被搜索到两次,按对象 id 去重 + seen_ids: set = set() + deduped = [] + for item in res_list: + oid = id(item[1]) + if oid not in seen_ids: + seen_ids.add(oid) + deduped.append(item) + res_list = deduped + if not try_mode: assert len(res_list) > 0, f"没有找到资源 (uuid={res_uuid}),请检查资源是否存在" assert len(res_list) == 1, f"通过uuid={res_uuid} 找到多个资源,请检查资源是否唯一: {res_list}" @@ -1371,6 +1381,14 @@ def figure_resource( r, resource_cls_type, identifier_key, getattr(query_resource, identifier_key) ) ) + seen_ids2: set = set() + deduped2 = [] + for item in res_list: + oid = id(item[1]) + if oid not in seen_ids2: + seen_ids2.add(oid) + deduped2.append(item) + res_list = deduped2 if not try_mode: assert len(res_list) > 0, f"没有找到资源 {query_resource},请检查资源是否存在" assert len(res_list) == 1, f"{query_resource} 找到多个资源,请检查资源是否唯一: {res_list}" From 44afc7733b0250715385b8f00f05b54890a29cbd Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:51:43 +0800 Subject: [PATCH 19/56] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20WareHouse=20?= =?UTF-8?q?=E5=8F=8D=E5=BA=8F=E5=88=97=E5=8C=96=E6=97=B6=E5=AD=90=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E5=91=BD=E5=90=8D=E5=86=B2=E7=AA=81=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E9=85=8D=E6=B6=B2=E7=AB=99=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=20UUID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - itemized_carrier: assign_child_resource idx=None 时直接 return, 不调用 super(),避免 bottle_carrier 子树进入 PLR 命名冲突检查 - dispensing_station_bioyond.json: 替换 placeholder UUID 为真实配置值 Co-Authored-By: Claude Sonnet 4.6 --- unilabos/resources/itemized_carrier.py | 4 +- .../dispensing_station_bioyond.json | 106 +++++++++--------- 2 files changed, 55 insertions(+), 55 deletions(-) diff --git a/unilabos/resources/itemized_carrier.py b/unilabos/resources/itemized_carrier.py index 6114716af..93b03399e 100644 --- a/unilabos/resources/itemized_carrier.py +++ b/unilabos/resources/itemized_carrier.py @@ -180,8 +180,8 @@ def assign_child_resource( break if idx is None: - # 反序列化时无法匹配 site(名称或坐标均不符),退回父类默认分配,不更新 site 跟踪 - super().assign_child_resource(resource, location=location, reassign=reassign) + # 反序列化时无法匹配 site(名称或坐标均不符)。 + # WareHouse 通过 sites 追踪占用,无需将子资源加入 PLR 子树,直接跳过避免命名冲突。 return if not reassign and self.sites[idx] is not None: diff --git a/unilabos/test/experiments/dispensing_station_bioyond.json b/unilabos/test/experiments/dispensing_station_bioyond.json index a6bd53327..28d2d98d2 100644 --- a/unilabos/test/experiments/dispensing_station_bioyond.json +++ b/unilabos/test/experiments/dispensing_station_bioyond.json @@ -15,92 +15,92 @@ "z": 0 }, "config": { - "api_key": "YOUR_API_KEY", - "api_host": "http://your-api-host:port", + "api_key": "", + "api_host": "http://:", "material_type_mappings": { "BIOYOND_PolymerStation_1FlaskCarrier": [ "烧杯", - "uuid-placeholder-flask" + "" ], "BIOYOND_PolymerStation_1BottleCarrier": [ "试剂瓶", - "uuid-placeholder-bottle" + "" ], "BIOYOND_PolymerStation_6StockCarrier": [ "分装板", - "uuid-placeholder-stock-6" + "" ], "BIOYOND_PolymerStation_Liquid_Vial": [ "10%分装小瓶", - "uuid-placeholder-liquid-vial" + "" ], "BIOYOND_PolymerStation_Solid_Vial": [ "90%分装小瓶", - "uuid-placeholder-solid-vial" + "" ], "BIOYOND_PolymerStation_8StockCarrier": [ "样品板", - "uuid-placeholder-stock-8" + "" ], "BIOYOND_PolymerStation_Solid_Stock": [ "样品瓶", - "uuid-placeholder-solid-stock" + "" ] }, "warehouse_mapping": { "粉末堆栈": { - "uuid": "uuid-placeholder-powder-stack", + "uuid": "", "site_uuids": { - "A01": "uuid-placeholder-powder-A01", - "A02": "uuid-placeholder-powder-A02", - "A03": "uuid-placeholder-powder-A03", - "A04": "uuid-placeholder-powder-A04", - "B01": "uuid-placeholder-powder-B01", - "B02": "uuid-placeholder-powder-B02", - "B03": "uuid-placeholder-powder-B03", - "B04": "uuid-placeholder-powder-B04", - "C01": "uuid-placeholder-powder-C01", - "C02": "uuid-placeholder-powder-C02", - "C03": "uuid-placeholder-powder-C03", - "C04": "uuid-placeholder-powder-C04", - "D01": "uuid-placeholder-powder-D01", - "D02": "uuid-placeholder-powder-D02", - "D03": "uuid-placeholder-powder-D03", - "D04": "uuid-placeholder-powder-D04" + "A01": "", + "A02": "", + "A03": "", + "A04": "", + "B01": "", + "B02": "", + "B03": "", + "B04": "", + "C01": "", + "C02": "", + "C03": "", + "C04": "", + "D01": "", + "D02": "", + "D03": "", + "D04": "" } }, "溶液堆栈": { - "uuid": "uuid-placeholder-liquid-stack", + "uuid": "", "site_uuids": { - "A01": "uuid-placeholder-liquid-A01", - "A02": "uuid-placeholder-liquid-A02", - "A03": "uuid-placeholder-liquid-A03", - "A04": "uuid-placeholder-liquid-A04", - "B01": "uuid-placeholder-liquid-B01", - "B02": "uuid-placeholder-liquid-B02", - "B03": "uuid-placeholder-liquid-B03", - "B04": "uuid-placeholder-liquid-B04", - "C01": "uuid-placeholder-liquid-C01", - "C02": "uuid-placeholder-liquid-C02", - "C03": "uuid-placeholder-liquid-C03", - "C04": "uuid-placeholder-liquid-C04", - "D01": "uuid-placeholder-liquid-D01", - "D02": "uuid-placeholder-liquid-D02", - "D03": "uuid-placeholder-liquid-D03", - "D04": "uuid-placeholder-liquid-D04" + "A01": "", + "A02": "", + "A03": "", + "A04": "", + "B01": "", + "B02": "", + "B03": "", + "B04": "", + "C01": "", + "C02": "", + "C03": "", + "C04": "", + "D01": "", + "D02": "", + "D03": "", + "D04": "" } }, "试剂堆栈": { - "uuid": "uuid-placeholder-reagent-stack", + "uuid": "", "site_uuids": { - "A01": "uuid-placeholder-reagent-A01", - "A02": "uuid-placeholder-reagent-A02", - "A03": "uuid-placeholder-reagent-A03", - "A04": "uuid-placeholder-reagent-A04", - "B01": "uuid-placeholder-reagent-B01", - "B02": "uuid-placeholder-reagent-B02", - "B03": "uuid-placeholder-reagent-B03", - "B04": "uuid-placeholder-reagent-B04" + "A01": "", + "A02": "", + "A03": "", + "A04": "", + "B01": "", + "B02": "", + "B03": "", + "B04": "" } } }, @@ -156,4 +156,4 @@ "data": {} } ] -} \ No newline at end of file +} From 96c4be17dc5947bb8cdec3e97bc04743e05bce39 Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:59:29 +0800 Subject: [PATCH 20/56] =?UTF-8?q?merge=5Fremote=5Fresources:=20=E4=BB=A5?= =?UTF-8?q?=E8=BF=9C=E7=AB=AF=E4=B8=BA=E5=87=86=EF=BC=8C=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E5=B7=B2=E4=B8=8D=E5=9C=A8=E8=BF=9C=E7=AB=AF?= =?UTF-8?q?=E7=9A=84=E7=89=A9=E6=96=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 远端(bioyond)不存在的物料不应保留在本地资源树中, 否则这些过期物料会在 PLR 反序列化时产生命名冲突。 同步时对两级子节点均执行移除: - 三级物料(设备→仓库→物料) - 三级子物料(设备→物料→子物料) 同时修复 else 分支缺少 remote_child_name 存在性检查的潜在 KeyError。 Co-Authored-By: Claude Sonnet 4.6 --- unilabos/resources/resource_tracker.py | 38 ++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/unilabos/resources/resource_tracker.py b/unilabos/resources/resource_tracker.py index b45ab57a8..119af7604 100644 --- a/unilabos/resources/resource_tracker.py +++ b/unilabos/resources/resource_tracker.py @@ -868,13 +868,34 @@ def merge_remote_resources(self, remote_tree_set: "ResourceTreeSet") -> "Resourc f"已存在,跳过" ) + # 移除本地有但远端已不存在的物料(以远端为准) + remote_material_names = {m.res_content.name for m in remote_child.children} + removed_count = 0 + for child in list(local_sub_device.children): + if child.res_content.name not in remote_material_names: + local_sub_device.children.remove(child) + removed_count += 1 + logger.info( + f"移除远端已不存在的物料: '{remote_root_id}/{remote_child_name}/{child.res_content.name}'" + ) + if added_count > 0: logger.info( f"Device '{remote_root_id}/{remote_child_name}': " f"从远端同步了 {added_count} 个物料子树" ) + if removed_count > 0: + logger.info( + f"Device '{remote_root_id}/{remote_child_name}': " + f"移除了 {removed_count} 个远端已删除的物料" + ) else: # 二级物料已存在,比较三级子节点是否缺失 + if remote_child_name not in local_children_map: + logger.warning( + f"物料 '{remote_root_id}/{remote_child_name}' 在远端存在但本地不存在,跳过" + ) + continue local_material = local_children_map[remote_child_name] local_material_children_map = {child.res_content.name: child for child in local_material.children} @@ -890,11 +911,28 @@ def merge_remote_resources(self, remote_tree_set: "ResourceTreeSet") -> "Resourc f"物料 '{remote_root_id}/{remote_child_name}/{remote_sub_name}' " f"已存在,跳过" ) + + # 移除本地有但远端已不存在的子物料(以远端为准) + remote_sub_names = {s.res_content.name for s in remote_child.children} + removed_count = 0 + for child in list(local_material.children): + if child.res_content.name not in remote_sub_names: + local_material.children.remove(child) + removed_count += 1 + logger.info( + f"移除远端已不存在的子物料: '{remote_root_id}/{remote_child_name}/{child.res_content.name}'" + ) + if added_count > 0: logger.info( f"物料 '{remote_root_id}/{remote_child_name}': " f"从远端同步了 {added_count} 个子物料" ) + if removed_count > 0: + logger.info( + f"物料 '{remote_root_id}/{remote_child_name}': " + f"移除了 {removed_count} 个远端已删除的子物料" + ) else: # 情况1: 一级节点是物料(不是 device) # 检查是否已存在 From e212dc7781e29bbb79f1c12c823fa99e2a465a9d Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:34:09 +0800 Subject: [PATCH 21/56] =?UTF-8?q?node=5Fto=5Fplr=5Fdict:=20WareHouse=20?= =?UTF-8?q?=E5=AD=90=E8=8A=82=E7=82=B9=E4=B8=8D=E5=86=99=E5=85=A5=20PLR=20?= =?UTF-8?q?children=20=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WareHouse 通过 sites 字符串追踪占位,不依赖 PLR children tree。 当同一载架(如 BIOYOND_PolymerStation_1BottleCarrier)出现在多个 WareHouse 的 children 下时,PLR _check_naming_conflicts 会因 同名子资源(flask_1)重复而报 ValueError。 将 WareHouse 的 children 排除在 PLR dict 外,PLR 树只保留 WareHouse 本身(位置/尺寸),不包含其持有的载架。 Co-Authored-By: Claude Sonnet 4.6 --- unilabos/resources/resource_tracker.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/unilabos/resources/resource_tracker.py b/unilabos/resources/resource_tracker.py index 119af7604..1b4d85621 100644 --- a/unilabos/resources/resource_tracker.py +++ b/unilabos/resources/resource_tracker.py @@ -606,7 +606,10 @@ def node_to_plr_dict(node: ResourceDictInstance, has_model: bool): }, "rotation": {"x": 0, "y": 0, "z": 0, "type": "Rotation"}, "category": res.config.get("category", plr_type), - "children": [node_to_plr_dict(child, has_model) for child in node.children], + # WareHouse 通过 sites 字符串追踪占位,不依赖 PLR children tree。 + # 将 WareHouse 子节点排除在外,避免同名载架出现在多个 WareHouse 下时 + # PLR _check_naming_conflicts 报命名冲突。 + "children": [] if res.type == "warehouse" else [node_to_plr_dict(child, has_model) for child in node.children], "parent_name": res.parent_instance_name, } if has_model: From 54054eb54e3a2fab8746a5c0bb5b5cbbc654b134 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:28:18 +0800 Subject: [PATCH 22/56] Support display_name & desc in new registry system (cherry picked from commit f71ea2a2584c22fac0be2d5ee21007fa4ccf0388) --- .cursor/skills/add-device/SKILL.md | 42 +- unilabos/devices/virtual/workbench.py | 497 ++++++++++++++++------ unilabos/registry/ast_registry_scanner.py | 8 +- unilabos/registry/registry.py | 120 +++++- unilabos/registry/utils.py | 81 +++- 5 files changed, 583 insertions(+), 165 deletions(-) diff --git a/.cursor/skills/add-device/SKILL.md b/.cursor/skills/add-device/SKILL.md index 61b6252e6..522c05bf4 100644 --- a/.cursor/skills/add-device/SKILL.md +++ b/.cursor/skills/add-device/SKILL.md @@ -71,6 +71,22 @@ from unilabos.registry.decorators import action - `_` 开头的方法 → 不扫描 - `@not_action` 标记的方法 → 排除 +### 参数文档 → JSON Schema 元数据 + +在 `__init__` 和 action 方法 docstring 的 `Args:` 小节里,使用以下格式生成入参 schema 的显示信息: + +```python +""" +Args: + param[显示名称]: 参数说明,会写入 JSON Schema 的 description。 +""" +``` + +- `param[显示名称]` 的显示名称会写入 goal property 的 `title`。 +- `:` 后面的说明会写入 goal property 的 `description`。 +- 如果只写 `param: 参数说明`,`title` 会兜底为字段名,`description` 使用参数说明。 +- 如果没有写参数文档,生成器也会兜底补齐 `title=<字段名>` 和 `description=""`,但新设备应优先写清楚显示名和说明。 + ### @topic_config — 状态属性配置 ```python @@ -105,13 +121,27 @@ import logging from typing import Any, Dict, Optional from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode -from unilabos.registry.decorators import device, action, topic_config, not_action +from unilabos.registry.decorators import action, device, not_action, topic_config -@device(id="my_device", category=["my_category"], description="设备描述") +@device( + id="my_device", + category=["my_category"], + description="设备描述", + display_name="设备显示名", +) class MyDevice: + """设备类说明。""" + _ros_node: BaseROS2DeviceNode def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs): + """ + 初始化设备。 + + Args: + device_id[设备ID]: 设备实例 ID,默认使用 my_device。 + config[设备配置]: 设备启动配置。 + """ self.device_id = device_id or "my_device" self.config = config or {} self.logger = logging.getLogger(f"MyDevice.{self.device_id}") @@ -133,7 +163,13 @@ class MyDevice: @action(description="执行操作") def my_action(self, param: float = 0.0, name: str = "") -> Dict[str, Any]: - """带 @action 装饰器 → 注册为 'my_action' 动作""" + """ + 带 @action 装饰器 → 注册为 'my_action' 动作。 + + Args: + param[操作数值]: 操作使用的数值参数。 + name[操作名称]: 操作名称或备注。 + """ return {"success": True} def get_info(self) -> Dict[str, Any]: diff --git a/unilabos/devices/virtual/workbench.py b/unilabos/devices/virtual/workbench.py index c70c8f66a..80ae1bdf0 100644 --- a/unilabos/devices/virtual/workbench.py +++ b/unilabos/devices/virtual/workbench.py @@ -14,20 +14,30 @@ import logging import time -from typing import Dict, Any, Optional, List from dataclasses import dataclass from enum import Enum from threading import Lock, RLock +from typing import Any, Dict, List, Optional, cast from typing_extensions import TypedDict from unilabos.registry.decorators import ( - device, action, ActionInputHandle, ActionOutputHandle, DataSource, topic_config, not_action, NodeType + ActionInputHandle, + ActionOutputHandle, + DataSource, + NodeType, + action, + device, + not_action, + topic_config, ) from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode -from unilabos.resources.resource_tracker import SampleUUIDsType, LabSample, ResourceTreeSet - +from unilabos.resources.resource_tracker import ( + SampleUUIDsType, + LabSample, + ResourceTreeSet, +) # ============ TypedDict 返回类型定义 ============ @@ -112,6 +122,7 @@ class HeatingStation: @device( id="virtual_workbench", + display_name="虚拟工作台", category=["virtual_device"], description="Virtual Workbench with 1 robotic arm and 3 heating stations for concurrent material processing", ) @@ -137,7 +148,19 @@ class VirtualWorkbench: HEATING_TIME: float = 60.0 # 加热时间(秒) NUM_HEATING_STATIONS: int = 3 # 加热台数量 - def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs): + def __init__( + self, + device_id: Optional[str] = None, + config: Optional[Dict[str, Any]] = None, + **kwargs, + ): + """ + 初始化虚拟工作台。 + + Args: + device_id[设备ID]: 工作台设备实例 ID,默认使用 virtual_workbench。 + config[设备配置]: 可包含 arm_operation_time、heating_time、num_heating_stations。 + """ # 处理可能的不同调用方式 if device_id is None and "id" in kwargs: device_id = kwargs.pop("id") @@ -151,9 +174,13 @@ def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, A self.data: Dict[str, Any] = {} # 从config中获取可配置参数 - self.ARM_OPERATION_TIME = float(self.config.get("arm_operation_time", self.ARM_OPERATION_TIME)) + self.ARM_OPERATION_TIME = float( + self.config.get("arm_operation_time", self.ARM_OPERATION_TIME) + ) self.HEATING_TIME = float(self.config.get("heating_time", self.HEATING_TIME)) - self.NUM_HEATING_STATIONS = int(self.config.get("num_heating_stations", self.NUM_HEATING_STATIONS)) + self.NUM_HEATING_STATIONS = int( + self.config.get("num_heating_stations", self.NUM_HEATING_STATIONS) + ) # 机械臂状态和锁 self._arm_lock = Lock() @@ -162,7 +189,8 @@ def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, A # 加热台状态 self._heating_stations: Dict[int, HeatingStation] = { - i: HeatingStation(station_id=i) for i in range(1, self.NUM_HEATING_STATIONS + 1) + i: HeatingStation(station_id=i) + for i in range(1, self.NUM_HEATING_STATIONS + 1) } self._stations_lock = RLock() @@ -292,45 +320,113 @@ def _release_arm(self): self.logger.info(f"机械臂已释放 (完成: {task})") @action( - always_free=True, node_type=NodeType.MANUAL_CONFIRM, placeholder_keys={ - "assignee_user_ids": "unilabos_manual_confirm" - }, goal_default={ - "timeout_seconds": 3600, - "assignee_user_ids": [] - }, feedback_interval=300, + always_free=True, + node_type=NodeType.MANUAL_CONFIRM, + placeholder_keys={"assignee_user_ids": "unilabos_manual_confirm"}, + goal_default={"timeout_seconds": 3600, "assignee_user_ids": []}, + feedback_interval=300, handles=[ - ActionInputHandle(key="target_device", data_type="device_id", - label="目标设备", data_key="target_device", data_source=DataSource.HANDLE), - ActionInputHandle(key="resource", data_type="resource", - label="待转移资源", data_key="resource", data_source=DataSource.HANDLE), - ActionInputHandle(key="mount_resource", data_type="resource", - label="目标孔位", data_key="mount_resource", data_source=DataSource.HANDLE), - - ActionInputHandle(key="collector_mass", data_type="collector_mass", - label="极流体质量", data_key="collector_mass", data_source=DataSource.HANDLE), - ActionInputHandle(key="active_material", data_type="active_material", - label="活性物质含量", data_key="active_material", data_source=DataSource.HANDLE), - ActionInputHandle(key="capacity", data_type="capacity", - label="克容量", data_key="capacity", data_source=DataSource.HANDLE), - ActionInputHandle(key="battery_system", data_type="battery_system", - label="电池体系", data_key="battery_system", data_source=DataSource.HANDLE), + ActionInputHandle( + key="target_device", + data_type="device_id", + label="目标设备", + data_key="target_device", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="resource", + data_type="resource", + label="待转移资源", + data_key="resource", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="mount_resource", + data_type="resource", + label="目标孔位", + data_key="mount_resource", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="collector_mass", + data_type="collector_mass", + label="极流体质量", + data_key="collector_mass", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="active_material", + data_type="active_material", + label="活性物质含量", + data_key="active_material", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="capacity", + data_type="capacity", + label="克容量", + data_key="capacity", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="battery_system", + data_type="battery_system", + label="电池体系", + data_key="battery_system", + data_source=DataSource.HANDLE, + ), # transfer使用 - ActionOutputHandle(key="target_device", data_type="device_id", - label="目标设备", data_key="target_device", data_source=DataSource.EXECUTOR), - ActionOutputHandle(key="resource", data_type="resource", - label="待转移资源", data_key="resource.@flatten", data_source=DataSource.EXECUTOR), - ActionOutputHandle(key="mount_resource", data_type="resource", - label="目标孔位", data_key="mount_resource.@flatten", data_source=DataSource.EXECUTOR), + ActionOutputHandle( + key="target_device", + data_type="device_id", + label="目标设备", + data_key="target_device", + data_source=DataSource.EXECUTOR, + ), + ActionOutputHandle( + key="resource", + data_type="resource", + label="待转移资源", + data_key="resource.@flatten", + data_source=DataSource.EXECUTOR, + ), + ActionOutputHandle( + key="mount_resource", + data_type="resource", + label="目标孔位", + data_key="mount_resource.@flatten", + data_source=DataSource.EXECUTOR, + ), # test使用 - ActionOutputHandle(key="collector_mass", data_type="collector_mass", - label="极流体质量", data_key="collector_mass", data_source=DataSource.EXECUTOR), - ActionOutputHandle(key="active_material", data_type="active_material", - label="活性物质含量", data_key="active_material", data_source=DataSource.EXECUTOR), - ActionOutputHandle(key="capacity", data_type="capacity", - label="克容量", data_key="capacity", data_source=DataSource.EXECUTOR), - ActionOutputHandle(key="battery_system", data_type="battery_system", - label="电池体系", data_key="battery_system", data_source=DataSource.EXECUTOR), - ] + ActionOutputHandle( + key="collector_mass", + data_type="collector_mass", + label="极流体质量", + data_key="collector_mass", + data_source=DataSource.EXECUTOR, + ), + ActionOutputHandle( + key="active_material", + data_type="active_material", + label="活性物质含量", + data_key="active_material", + data_source=DataSource.EXECUTOR, + ), + ActionOutputHandle( + key="capacity", + data_type="capacity", + label="克容量", + data_key="capacity", + data_source=DataSource.EXECUTOR, + ), + ActionOutputHandle( + key="battery_system", + data_type="battery_system", + label="电池体系", + data_key="battery_system", + data_source=DataSource.EXECUTOR, + ), + ], ) def manual_confirm( self, @@ -343,67 +439,156 @@ def manual_confirm( battery_system: List[str], timeout_seconds: int, assignee_user_ids: list[str], - **kwargs + **kwargs, ) -> dict: """ - timeout_seconds: 超时时间(秒),默认3600秒 - collector_mass: 极流体质量 - active_material: 活性物质含量 - capacity: 克容量(mAh/g) - battery_system: 电池体系 - 修改的结果无效,是只读的 + 人工确认资源转移和扣电测试参数。 + + Args: + resource[待转移资源]: 需要人工确认的资源列表。 + target_device[目标设备]: 资源要转移到的目标设备 ID。 + mount_resource[目标孔位]: 资源要挂载到的目标孔位列表。 + collector_mass[极流体质量]: 每个样品对应的极流体质量。 + active_material[活性物质含量]: 每个样品对应的活性物质含量。 + capacity[克容量]: 每个样品对应的克容量,单位 mAh/g。 + battery_system[电池体系]: 每个样品对应的电池体系名称。 + timeout_seconds[超时时间]: 人工确认超时时间,单位秒。 + assignee_user_ids[确认人]: 指定处理人工确认任务的用户 ID 列表。 + + Note: + 修改的结果无效,是只读的。 """ - resource = ResourceTreeSet.from_plr_resources(resource).dump() - mount_resource = ResourceTreeSet.from_plr_resources(mount_resource).dump() + resource_tree = ResourceTreeSet.from_plr_resources(cast(Any, resource)).dump() + mount_resource_tree = ResourceTreeSet.from_plr_resources(cast(Any, mount_resource)).dump() kwargs.update(locals()) kwargs.pop("kwargs") kwargs.pop("self") + kwargs["resource"] = resource_tree + kwargs["mount_resource"] = mount_resource_tree + kwargs.pop("resource_tree") + kwargs.pop("mount_resource_tree") return kwargs @action( description="转移物料", handles=[ - ActionInputHandle(key="target_device", data_type="device_id", - label="目标设备", data_key="target_device", data_source=DataSource.HANDLE), - ActionInputHandle(key="resource", data_type="resource", - label="待转移资源", data_key="resource", data_source=DataSource.HANDLE), - ActionInputHandle(key="mount_resource", data_type="resource", - label="目标孔位", data_key="mount_resource", data_source=DataSource.HANDLE), - ] + ActionInputHandle( + key="target_device", + data_type="device_id", + label="目标设备", + data_key="target_device", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="resource", + data_type="resource", + label="待转移资源", + data_key="resource", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="mount_resource", + data_type="resource", + label="目标孔位", + data_key="mount_resource", + data_source=DataSource.HANDLE, + ), + ], ) - async def transfer(self, resource: List[ResourceSlot], target_device: DeviceSlot, mount_resource: List[ResourceSlot]): - future = ROS2DeviceNode.run_async_func(self._ros_node.transfer_resource_to_another, True, + async def transfer( + self, + resource: List[ResourceSlot], + target_device: DeviceSlot, + mount_resource: List[ResourceSlot], + ): + """ + 转移资源到目标设备。 + + Args: + resource[待转移资源]: 待转移的资源列表。 + target_device[目标设备]: 接收资源的目标设备 ID。 + mount_resource[目标孔位]: 目标设备上的挂载孔位列表。 + """ + future = ROS2DeviceNode.run_async_func( + self._ros_node.transfer_resource_to_another, + True, **{ "plr_resources": resource, "target_device_id": target_device, "target_resources": mount_resource, "sites": [None] * len(mount_resource), - }) + }, + ) result = await future return result - @action( description="扣电测试启动", handles=[ - ActionInputHandle(key="resource", data_type="resource", - label="待转移资源", data_key="resource", data_source=DataSource.HANDLE), - ActionInputHandle(key="mount_resource", data_type="resource", - label="目标孔位", data_key="mount_resource", data_source=DataSource.HANDLE), - - ActionInputHandle(key="collector_mass", data_type="collector_mass", - label="极流体质量", data_key="collector_mass", data_source=DataSource.HANDLE), - ActionInputHandle(key="active_material", data_type="active_material", - label="活性物质含量", data_key="active_material", data_source=DataSource.HANDLE), - ActionInputHandle(key="capacity", data_type="capacity", - label="克容量", data_key="capacity", data_source=DataSource.HANDLE), - ActionInputHandle(key="battery_system", data_type="battery_system", - label="电池体系", data_key="battery_system", data_source=DataSource.HANDLE), - ] + ActionInputHandle( + key="resource", + data_type="resource", + label="待转移资源", + data_key="resource", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="mount_resource", + data_type="resource", + label="目标孔位", + data_key="mount_resource", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="collector_mass", + data_type="collector_mass", + label="极流体质量", + data_key="collector_mass", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="active_material", + data_type="active_material", + label="活性物质含量", + data_key="active_material", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="capacity", + data_type="capacity", + label="克容量", + data_key="capacity", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="battery_system", + data_type="battery_system", + label="电池体系", + data_key="battery_system", + data_source=DataSource.HANDLE, + ), + ], ) async def test( - self, resource: List[ResourceSlot], mount_resource: List[ResourceSlot], collector_mass: List[float], active_material: List[float], capacity: List[float], battery_system: list[str] + self, + resource: List[ResourceSlot], + mount_resource: List[ResourceSlot], + collector_mass: List[float], + active_material: List[float], + capacity: List[float], + battery_system: list[str], ): + """ + 启动扣电测试。 + + Args: + resource[待测试资源]: 需要进行扣电测试的资源列表。 + mount_resource[测试孔位]: 扣电测试使用的目标孔位列表。 + collector_mass[极流体质量]: 每个样品对应的极流体质量。 + active_material[活性物质含量]: 每个样品对应的活性物质含量。 + capacity[克容量]: 每个样品对应的克容量,单位 mAh/g。 + battery_system[电池体系]: 每个样品对应的电池体系名称。 + """ print(resource) print(mount_resource) print(collector_mass) @@ -415,16 +600,11 @@ async def test( auto_prefix=True, description="批量准备物料 - 虚拟起始节点, 生成A1-A5物料, 输出5个handle供后续节点使用", handles=[ - ActionOutputHandle(key="channel_1", data_type="workbench_material", - label="实验1", data_key="material_1", data_source=DataSource.EXECUTOR), - ActionOutputHandle(key="channel_2", data_type="workbench_material", - label="实验2", data_key="material_2", data_source=DataSource.EXECUTOR), - ActionOutputHandle(key="channel_3", data_type="workbench_material", - label="实验3", data_key="material_3", data_source=DataSource.EXECUTOR), - ActionOutputHandle(key="channel_4", data_type="workbench_material", - label="实验4", data_key="material_4", data_source=DataSource.EXECUTOR), - ActionOutputHandle(key="channel_5", data_type="workbench_material", - label="实验5", data_key="material_5", data_source=DataSource.EXECUTOR), + ActionOutputHandle(key="channel_1", data_type="workbench_material", label="实验1", data_key="material_1", data_source=DataSource.EXECUTOR), # noqa: E501 + ActionOutputHandle(key="channel_2", data_type="workbench_material", label="实验2", data_key="material_2", data_source=DataSource.EXECUTOR), # noqa: E501 + ActionOutputHandle(key="channel_3", data_type="workbench_material", label="实验3", data_key="material_3", data_source=DataSource.EXECUTOR), # noqa: E501 + ActionOutputHandle(key="channel_4", data_type="workbench_material", label="实验4", data_key="material_4", data_source=DataSource.EXECUTOR), # noqa: E501 + ActionOutputHandle(key="channel_5", data_type="workbench_material", label="实验5", data_key="material_5", data_source=DataSource.EXECUTOR), # noqa: E501 ], ) def prepare_materials( @@ -437,6 +617,9 @@ def prepare_materials( 作为工作流的起始节点, 生成指定数量的物料编号供后续节点使用。 输出5个handle (material_1 ~ material_5), 分别对应实验1~5。 + + Args: + count[物料数量]: 要生成的物料数量,默认生成 5 个。 """ materials = [i for i in range(1, count + 1)] @@ -457,7 +640,11 @@ def prepare_materials( LabSample( sample_uuid=sample_uuid, oss_path="", - extra={"material_uuid": content} if isinstance(content, str) else (content.serialize() if content else {}), + extra=( + {"material_uuid": content} + if isinstance(content, str) + else (content.serialize() if content else {}) + ), ) for sample_uuid, content in sample_uuids.items() ], @@ -467,12 +654,27 @@ def prepare_materials( auto_prefix=True, description="将物料从An位置移动到空闲加热台, 返回分配的加热台ID", handles=[ - ActionInputHandle(key="material_input", data_type="workbench_material", - label="物料编号", data_key="material_number", data_source=DataSource.HANDLE), - ActionOutputHandle(key="heating_station_output", data_type="workbench_station", - label="加热台ID", data_key="station_id", data_source=DataSource.EXECUTOR), - ActionOutputHandle(key="material_number_output", data_type="workbench_material", - label="物料编号", data_key="material_number", data_source=DataSource.EXECUTOR), + ActionInputHandle( + key="material_input", + data_type="workbench_material", + label="物料编号", + data_key="material_number", + data_source=DataSource.HANDLE, + ), + ActionOutputHandle( + key="heating_station_output", + data_type="workbench_station", + label="加热台ID", + data_key="station_id", + data_source=DataSource.EXECUTOR, + ), + ActionOutputHandle( + key="material_number_output", + data_type="workbench_material", + label="物料编号", + data_key="material_number", + data_source=DataSource.EXECUTOR, + ), ], ) def move_to_heating_station( @@ -484,6 +686,9 @@ def move_to_heating_station( 将物料从An位置移动到加热台 多线程并发调用时, 会竞争机械臂使用权, 并自动查找空闲加热台 + + Args: + material_number[物料编号]: 要移动的物料编号,对应 A1、A2 等起始位置。 """ material_id = f"A{material_number}" task_desc = f"移动{material_id}到加热台" @@ -546,7 +751,8 @@ def move_to_heating_station( oss_path="", extra=( {"material_uuid": content} - if isinstance(content, str) else (content.serialize() if content else {}) + if isinstance(content, str) + else (content.serialize() if content else {}) ), ) for sample_uuid, content in sample_uuids.items() @@ -569,7 +775,8 @@ def move_to_heating_station( oss_path="", extra=( {"material_uuid": content} - if isinstance(content, str) else (content.serialize() if content else {}) + if isinstance(content, str) + else (content.serialize() if content else {}) ), ) for sample_uuid, content in sample_uuids.items() @@ -581,14 +788,34 @@ def move_to_heating_station( always_free=True, description="启动指定加热台的加热程序", handles=[ - ActionInputHandle(key="station_id_input", data_type="workbench_station", - label="加热台ID", data_key="station_id", data_source=DataSource.HANDLE), - ActionInputHandle(key="material_number_input", data_type="workbench_material", - label="物料编号", data_key="material_number", data_source=DataSource.HANDLE), - ActionOutputHandle(key="heating_done_station", data_type="workbench_station", - label="加热完成-加热台ID", data_key="station_id", data_source=DataSource.EXECUTOR), - ActionOutputHandle(key="heating_done_material", data_type="workbench_material", - label="加热完成-物料编号", data_key="material_number", data_source=DataSource.EXECUTOR), + ActionInputHandle( + key="station_id_input", + data_type="workbench_station", + label="加热台ID", + data_key="station_id", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="material_number_input", + data_type="workbench_material", + label="物料编号", + data_key="material_number", + data_source=DataSource.HANDLE, + ), + ActionOutputHandle( + key="heating_done_station", + data_type="workbench_station", + label="加热完成-加热台ID", + data_key="station_id", + data_source=DataSource.EXECUTOR, + ), + ActionOutputHandle( + key="heating_done_material", + data_type="workbench_material", + label="加热完成-物料编号", + data_key="material_number", + data_source=DataSource.EXECUTOR, + ), ], ) def start_heating( @@ -599,6 +826,10 @@ def start_heating( ) -> StartHeatingResult: """ 启动指定加热台的加热程序 + + Args: + station_id[加热台ID]: 要启动加热的加热台编号。 + material_number[物料编号]: 当前加热台上的物料编号。 """ self.logger.info(f"[加热台{station_id}] 开始加热") @@ -615,7 +846,8 @@ def start_heating( oss_path="", extra=( {"material_uuid": content} - if isinstance(content, str) else (content.serialize() if content else {}) + if isinstance(content, str) + else (content.serialize() if content else {}) ), ) for sample_uuid, content in sample_uuids.items() @@ -638,7 +870,8 @@ def start_heating( oss_path="", extra=( {"material_uuid": content} - if isinstance(content, str) else (content.serialize() if content else {}) + if isinstance(content, str) + else (content.serialize() if content else {}) ), ) for sample_uuid, content in sample_uuids.items() @@ -658,7 +891,8 @@ def start_heating( oss_path="", extra=( {"material_uuid": content} - if isinstance(content, str) else (content.serialize() if content else {}) + if isinstance(content, str) + else (content.serialize() if content else {}) ), ) for sample_uuid, content in sample_uuids.items() @@ -698,7 +932,9 @@ def start_heating( self._update_data_status(f"加热台{station_id}加热中: {progress:.1f}%") if time.time() - last_countdown_log >= 5.0: - self.logger.info(f"[加热台{station_id}] {material_id} 剩余 {remaining:.1f}s") + self.logger.info( + f"[加热台{station_id}] {material_id} 剩余 {remaining:.1f}s" + ) last_countdown_log = time.time() if elapsed >= self.HEATING_TIME: @@ -715,7 +951,9 @@ def start_heating( self._active_tasks[material_id]["status"] = "heating_completed" self._update_data_status(f"加热台{station_id}加热完成") - self.logger.info(f"[加热台{station_id}] {material_id}加热完成 (用时{self.HEATING_TIME}s)") + self.logger.info( + f"[加热台{station_id}] {material_id}加热完成 (用时{self.HEATING_TIME}s)" + ) return { "success": True, @@ -729,7 +967,8 @@ def start_heating( oss_path="", extra=( {"material_uuid": content} - if isinstance(content, str) else (content.serialize() if content else {}) + if isinstance(content, str) + else (content.serialize() if content else {}) ), ) for sample_uuid, content in sample_uuids.items() @@ -740,10 +979,20 @@ def start_heating( auto_prefix=True, description="将物料从加热台移动到输出位置Cn", handles=[ - ActionInputHandle(key="output_station_input", data_type="workbench_station", - label="加热台ID", data_key="station_id", data_source=DataSource.HANDLE), - ActionInputHandle(key="output_material_input", data_type="workbench_material", - label="物料编号", data_key="material_number", data_source=DataSource.HANDLE), + ActionInputHandle( + key="output_station_input", + data_type="workbench_station", + label="加热台ID", + data_key="station_id", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="output_material_input", + data_type="workbench_material", + label="物料编号", + data_key="material_number", + data_source=DataSource.HANDLE, + ), ], ) def move_to_output( @@ -754,6 +1003,10 @@ def move_to_output( ) -> MoveToOutputResult: """ 将物料从加热台移动到输出位置Cn + + Args: + station_id[加热台ID]: 已完成加热的加热台编号。 + material_number[物料编号]: 要移动到输出位置的物料编号,对应 Cn。 """ output_number = material_number @@ -770,7 +1023,8 @@ def move_to_output( oss_path="", extra=( {"material_uuid": content} - if isinstance(content, str) else (content.serialize() if content else {}) + if isinstance(content, str) + else (content.serialize() if content else {}) ), ) for sample_uuid, content in sample_uuids.items() @@ -794,7 +1048,8 @@ def move_to_output( oss_path="", extra=( {"material_uuid": content} - if isinstance(content, str) else (content.serialize() if content else {}) + if isinstance(content, str) + else (content.serialize() if content else {}) ), ) for sample_uuid, content in sample_uuids.items() @@ -814,7 +1069,8 @@ def move_to_output( oss_path="", extra=( {"material_uuid": content} - if isinstance(content, str) else (content.serialize() if content else {}) + if isinstance(content, str) + else (content.serialize() if content else {}) ), ) for sample_uuid, content in sample_uuids.items() @@ -896,7 +1152,8 @@ def move_to_output( oss_path="", extra=( {"material_uuid": content} - if isinstance(content, str) else (content.serialize() if content else {}) + if isinstance(content, str) + else (content.serialize() if content else {}) ), ) for sample_uuid, content in sample_uuids.items() diff --git a/unilabos/registry/ast_registry_scanner.py b/unilabos/registry/ast_registry_scanner.py index 62cd2dbed..530714994 100644 --- a/unilabos/registry/ast_registry_scanner.py +++ b/unilabos/registry/ast_registry_scanner.py @@ -32,7 +32,7 @@ MAX_SCAN_DEPTH = 10 # 最大目录递归深度 MAX_SCAN_FILES = 1000 # 最大扫描文件数量 -_CACHE_VERSION = 1 # 缓存格式版本号,格式变更时递增 +_CACHE_VERSION = 2 # 缓存格式版本号,格式变更时递增 # 合法的装饰器来源模块 _REGISTRY_DECORATOR_MODULE = "unilabos.registry.decorators" @@ -258,8 +258,6 @@ def _collect_results(futures_dict: Dict): } - - # --------------------------------------------------------------------------- # File-level parsing # --------------------------------------------------------------------------- @@ -361,6 +359,7 @@ def _parse_file( "actions": class_body.get("actions", {}), "status_properties": class_body.get("status_properties", {}), "init_params": class_body.get("init_params", []), + "init_docstring": class_body.get("init_docstring"), "auto_methods": class_body.get("auto_methods", {}), "import_map": import_map, } @@ -497,7 +496,6 @@ def _collect_imports(tree: ast.Module, module_path: str = "") -> Dict[str, str]: return import_map - # --------------------------------------------------------------------------- # Decorator finding & argument extraction # --------------------------------------------------------------------------- @@ -768,6 +766,7 @@ def _extract_class_body( "actions": {}, # method_name -> action_info "status_properties": {}, # prop_name -> status_info "init_params": [], # [{"name": ..., "type": ..., "default": ...}, ...] + "init_docstring": None, "auto_methods": {}, # method_name -> method_info (no @action decorator) } @@ -780,6 +779,7 @@ def _extract_class_body( # --- __init__ --- if method_name == "__init__": result["init_params"] = _extract_method_params(item, import_map) + result["init_docstring"] = ast.get_docstring(item) continue # --- Skip private/dunder --- diff --git a/unilabos/registry/registry.py b/unilabos/registry/registry.py index aa3db9b2f..173068102 100644 --- a/unilabos/registry/registry.py +++ b/unilabos/registry/registry.py @@ -271,6 +271,7 @@ def _run_ast_scan(self, devices_dirs=None, upload_registry=False, external_only= registry_cache.pkl 一个文件中,删除即可完全重置。 """ import time as _time + from unilabos.registry.ast_registry_scanner import _CACHE_VERSION as AST_SCAN_CACHE_VERSION from unilabos.registry.ast_registry_scanner import scan_directory scan_t0 = _time.perf_counter() @@ -286,6 +287,10 @@ def _run_ast_scan(self, devices_dirs=None, upload_registry=False, external_only= # ---- 统一缓存:一个 pkl 包含所有数据 ---- unified_cache = self._load_config_cache() ast_cache = unified_cache.setdefault("_ast_scan", {"files": {}}) + if ast_cache.get("version") != AST_SCAN_CACHE_VERSION: + ast_cache = {"version": AST_SCAN_CACHE_VERSION, "files": {}} + unified_cache["_ast_scan"] = ast_cache + unified_cache.pop("_build_results", None) # 默认:扫描 unilabos 包所在的父目录 pkg_root = Path(__file__).resolve().parent.parent # .../unilabos @@ -561,13 +566,38 @@ def _generate_schema_from_info( return prop_schema + @staticmethod + def _apply_docstring_param_metadata( + schema: Dict[str, Any], + doc_info: Dict[str, Any], + field_to_param: Optional[Dict[str, str]] = None, + ) -> None: + """Apply parsed docstring display names and descriptions to schema properties.""" + if not schema or not doc_info: + return + + props = schema.get("properties", {}) + if not isinstance(props, dict): + return + + param_descs = doc_info.get("params", {}) or {} + param_display_names = doc_info.get("param_display_names", {}) or {} + for field_name, prop_schema in props.items(): + if not isinstance(prop_schema, dict): + continue + param_name = field_to_param.get(field_name, field_name) if field_to_param else field_name + if not isinstance(param_name, str): + continue + param_name = param_name.removesuffix("[]") + prop_schema["title"] = param_display_names.get(param_name, prop_schema.get("title") or field_name) + prop_schema["description"] = param_descs.get(param_name, prop_schema.get("description") or "") + def _generate_unilab_json_command_schema( self, method_args: list, docstring: Optional[str] = None, import_map: Optional[Dict[str, str]] = None, ) -> Dict[str, Any]: """根据方法参数和 docstring 生成 UniLabJsonCommand schema""" doc_info = parse_docstring(docstring) - param_descs = doc_info.get("params", {}) schema = { "type": "object", @@ -598,12 +628,10 @@ def _generate_unilab_json_command_schema( param_name, param_type, param_default, import_map=import_map ) - if param_name in param_descs: - schema["properties"][param_name]["description"] = param_descs[param_name] - if param_required: schema["required"].append(param_name) + self._apply_docstring_param_metadata(schema, doc_info) return schema def _generate_status_types_schema(self, status_methods: Dict[str, Any]) -> Dict[str, Any]: @@ -799,6 +827,7 @@ def _build_json_command_entry(method_name, method_info, action_args=None): type_str = "UniLabJsonCommandAsync" if is_async else "UniLabJsonCommand" params = method_info.get("params", []) method_doc = method_info.get("docstring") + method_doc_info = parse_docstring(method_doc) goal_schema = self._generate_schema_from_ast_params(params, method_name, method_doc, imap) if action_args is not None: @@ -828,7 +857,11 @@ def _build_json_command_entry(method_name, method_info, action_args=None): # action handles: 从 @action(handles=[...]) 提取并转换为标准格式 raw_handles = (action_args or {}).get("handles") - handles = normalize_ast_action_handles(raw_handles) if isinstance(raw_handles, list) else (raw_handles or {}) + handles = ( + normalize_ast_action_handles(raw_handles) + if isinstance(raw_handles, list) + else (raw_handles or {}) + ) # placeholder_keys: 先从参数类型自动检测,再用装饰器显式配置覆盖/补充 pk = detect_placeholder_keys(params) @@ -847,7 +880,12 @@ def _build_json_command_entry(method_name, method_info, action_args=None): "goal": goal, "feedback": (action_args or {}).get("feedback") or {}, "result": (action_args or {}).get("result") or {}, - "schema": wrap_action_schema(goal_schema, action_name, result_schema=result_schema), + "schema": wrap_action_schema( + goal_schema, + action_name, + description=(action_args or {}).get("description") or method_doc_info.get("description", ""), + result_schema=result_schema, + ), "goal_default": goal_default, "handles": handles, "placeholder_keys": pk, @@ -886,7 +924,11 @@ def _build_json_command_entry(method_name, method_info, action_args=None): action_name = f"auto-{action_name}" raw_handles = action_args.get("handles") - handles = normalize_ast_action_handles(raw_handles) if isinstance(raw_handles, list) else (raw_handles or {}) + handles = ( + normalize_ast_action_handles(raw_handles) + if isinstance(raw_handles, list) + else (raw_handles or {}) + ) method_params = method_info.get("params", []) @@ -979,7 +1021,10 @@ def _build_json_command_entry(method_name, method_info, action_args=None): "schema": schema, "goal_default": goal_default, "handles": handles, - "placeholder_keys": {**detect_placeholder_keys(method_params), **(action_args.get("placeholder_keys") or {})}, + "placeholder_keys": { + **detect_placeholder_keys(method_params), + **(action_args.get("placeholder_keys") or {}), + }, } if action_args.get("always_free") or method_info.get("always_free"): action_entry["always_free"] = True @@ -988,13 +1033,21 @@ def _build_json_command_entry(method_name, method_info, action_args=None): nt = normalize_enum_value(action_args.get("node_type"), NodeType) if nt: action_entry["node_type"] = nt + goal_schema_for_docs = action_entry.get("schema", {}).get("properties", {}).get("goal", {}) + self._apply_docstring_param_metadata( + goal_schema_for_docs, + parse_docstring(method_info.get("docstring")), + goal, + ) action_value_mappings[action_name] = action_entry action_value_mappings = dict(sorted(action_value_mappings.items())) # --- init_param_schema = { config: , data: } --- init_params = ast_meta.get("init_params", []) - config_schema = self._generate_schema_from_ast_params(init_params, "__init__", import_map=imap) + config_schema = self._generate_schema_from_ast_params( + init_params, "__init__", ast_meta.get("init_docstring"), import_map=imap + ) data_schema = self._generate_status_schema_from_ast( ast_meta.get("status_properties", {}), imap ) @@ -1042,7 +1095,6 @@ def _generate_schema_from_ast_params( ) -> Dict[str, Any]: """Generate JSON Schema from AST-extracted parameter list.""" doc_info = parse_docstring(docstring) - param_descs = doc_info.get("params", {}) schema: Dict[str, Any] = { "type": "object", @@ -1072,12 +1124,10 @@ def _generate_schema_from_ast_params( pname, ptype, pdefault, import_map ) - if pname in param_descs: - schema["properties"][pname]["description"] = param_descs[pname] - if prequired: schema["required"].append(pname) + self._apply_docstring_param_metadata(schema, doc_info) return schema def _generate_status_schema_from_ast( @@ -1807,7 +1857,7 @@ def _load_single_device_file( else: action_key = f"auto-{k}" goal_schema = self._generate_unilab_json_command_schema( - v["args"], import_map=enhanced_import_map + v["args"], docstring=v.get("docstring"), import_map=enhanced_import_map ) ret_type = v.get("return_type", "") result_schema = None @@ -1816,7 +1866,13 @@ def _load_single_device_file( "result", ret_type, None, import_map=enhanced_import_map ) old_cfg = old_action_configs.get(action_key) or old_action_configs.get(f"auto-{k}", {}) - new_schema = wrap_action_schema(goal_schema, action_key, result_schema=result_schema) + doc_info = parse_docstring(v.get("docstring")) + new_schema = wrap_action_schema( + goal_schema, + action_key, + description=doc_info.get("description", ""), + result_schema=result_schema, + ) old_schema = old_cfg.get("schema", {}) if old_schema: preserve_field_descriptions(new_schema, old_schema) @@ -1882,6 +1938,12 @@ def _load_single_device_file( merged_pk = dict(old_cfg.get("placeholder_keys", {})) merged_pk.update(detect_placeholder_keys(v["args"])) + goal_schema_for_docs = ( + entry_schema.get("properties", {}).get("goal", {}) + if isinstance(entry_schema, dict) + else {} + ) + self._apply_docstring_param_metadata(goal_schema_for_docs, doc_info, entry_goal) entry = { "type": entry_type, @@ -1902,7 +1964,8 @@ def _load_single_device_file( device_config["init_param_schema"] = {} init_schema = self._generate_unilab_json_command_schema( - enhanced_info["init_params"], "__init__", + enhanced_info["init_params"], + docstring=enhanced_info.get("init_docstring"), import_map=enhanced_import_map, ) device_config["init_param_schema"]["config"] = init_schema @@ -1949,7 +2012,9 @@ def _load_single_device_file( action_str_type_mapping[action_type_str] = target_type if target_type is not None: try: - action_config["goal_default"] = ROS2MessageInstance(target_type.Goal()).get_python_dict() + action_config["goal_default"] = ROS2MessageInstance( + target_type.Goal() + ).get_python_dict() except Exception: action_config["goal_default"] = {} prev_schema = action_config.get("schema", {}) @@ -2141,10 +2206,15 @@ def obtain_registry_device_info(self): "unilabos_device_id": { "type": "string", "default": "", + "title": "设备ID", "description": "UniLabOS设备ID,用于指定执行动作的具体设备实例", }, **schema["properties"]["goal"]["properties"], } + for field_name, field_schema in schema["properties"]["goal"]["properties"].items(): + if isinstance(field_schema, dict): + field_schema.setdefault("title", field_name) + field_schema.setdefault("description", "") # 将 placeholder_keys 信息添加到 schema 中 if "placeholder_keys" in action_config and action_config.get("schema", {}).get( "properties", {} @@ -2212,7 +2282,14 @@ def get_yaml_output(self, device_id: str) -> str: lab_registry = Registry() -def build_registry(registry_paths=None, devices_dirs=None, upload_registry=False, check_mode=False, complete_registry=False, external_only=False): +def build_registry( + registry_paths=None, + devices_dirs=None, + upload_registry=False, + check_mode=False, + complete_registry=False, + external_only=False, +): """ 构建或获取Registry单例实例 """ @@ -2226,7 +2303,12 @@ def build_registry(registry_paths=None, devices_dirs=None, upload_registry=False if path not in current_paths: lab_registry.registry_paths.append(path) - lab_registry.setup(devices_dirs=devices_dirs, upload_registry=upload_registry, complete_registry=complete_registry, external_only=external_only) + lab_registry.setup( + devices_dirs=devices_dirs, + upload_registry=upload_registry, + complete_registry=complete_registry, + external_only=external_only, + ) # 将 AST 扫描的字符串类型替换为实际 ROS2 消息类(仅查找 ROS2 类型,不 import 设备模块) lab_registry.resolve_all_types() diff --git a/unilabos/registry/utils.py b/unilabos/registry/utils.py index eb342c5c7..6b1acb2be 100644 --- a/unilabos/registry/utils.py +++ b/unilabos/registry/utils.py @@ -36,16 +36,40 @@ class ROSMsgNotFound(Exception): # --------------------------------------------------------------------------- _SECTION_RE = re.compile(r"^(\w[\w\s]*):\s*$") +_PARAM_HEADER_RE = re.compile( + r"^\s*(?P\w[\w]*)\s*(?:\[(?P[^\]]+)\])?(?:\s*\([^)]*\))?\s*$" +) + + +def _parse_docstring_param_header(param_part: str) -> Tuple[str, Optional[str]]: + """Parse ``name[display_name]`` or Google-style ``name (type)``.""" + match = _PARAM_HEADER_RE.match(param_part.strip()) + if not match: + return param_part.strip().split("(")[0].strip(), None + + display_name = match.group("display_name") + if display_name is not None: + display_name = display_name.strip() or None + return match.group("name").strip(), display_name def parse_docstring(docstring: Optional[str]) -> Dict[str, Any]: """ - 解析 Google-style docstring,提取描述和参数说明。 + 解析 docstring,提取描述和参数说明。 + + 支持: + - Google-style ``Args:`` / ``Parameters:`` 小节 + - 直接参数行 ``field: desc`` + - 带显示名参数行 ``field[Display Name]: desc`` Returns: - {"description": "短描述", "params": {"param1": "参数1描述", ...}} + { + "description": "短描述", + "params": {"param1": "参数1描述", ...}, + "param_display_names": {"param1": "显示名", ...}, + } """ - result: Dict[str, Any] = {"description": "", "params": {}} + result: Dict[str, Any] = {"description": "", "params": {}, "param_display_names": {}} if not docstring: return result @@ -53,33 +77,53 @@ def parse_docstring(docstring: Optional[str]) -> Dict[str, Any]: if not lines: return result - result["description"] = lines[0].strip() - in_args = False + current_section: Optional[str] = None current_param: Optional[str] = None + current_display_name: Optional[str] = None current_desc_parts: list = [] - for line in lines[1:]: + def flush_current_param() -> None: + nonlocal current_param, current_display_name, current_desc_parts + if current_param is None: + return + result["params"][current_param] = "\n".join(current_desc_parts).strip() + if current_display_name: + result["param_display_names"][current_param] = current_display_name + current_param = None + current_display_name = None + current_desc_parts = [] + + first_line = lines[0].strip() + start_index = 0 + if not _SECTION_RE.match(first_line) and ":" not in first_line: + result["description"] = first_line + start_index = 1 + + for line in lines[start_index:]: stripped = line.strip() + if not stripped: + if current_param is not None: + current_desc_parts.append("") + continue + section_match = _SECTION_RE.match(stripped) if section_match: - if current_param is not None: - result["params"][current_param] = "\n".join(current_desc_parts).strip() - current_param = None - current_desc_parts = [] - section_name = section_match.group(1).lower() - in_args = section_name in ("args", "arguments", "parameters", "params") + flush_current_param() + current_section = section_match.group(1).lower() + in_args = current_section in ("args", "arguments", "parameters", "params") continue - if not in_args: + parse_as_param = in_args or current_section is None + if not parse_as_param: continue - if ":" in stripped and not stripped.startswith(" "): - if current_param is not None: - result["params"][current_param] = "\n".join(current_desc_parts).strip() + if ":" in stripped: + flush_current_param() param_part, _, desc_part = stripped.partition(":") - param_name = param_part.strip().split("(")[0].strip() + param_name, display_name = _parse_docstring_param_header(param_part) current_param = param_name + current_display_name = display_name current_desc_parts = [desc_part.strip()] elif current_param is not None: aline = line @@ -89,8 +133,7 @@ def parse_docstring(docstring: Optional[str]) -> Dict[str, Any]: aline = aline[1:] current_desc_parts.append(aline.strip()) - if current_param is not None: - result["params"][current_param] = "\n".join(current_desc_parts).strip() + flush_current_param() return result From 1c8ccf14a35ada04f493a1ed92a80cb164eb498e Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:47:52 +0800 Subject: [PATCH 23/56] Update registry for all param desc --- unilabos/registry/devices/Qone_nmr.yaml | 14 +++ unilabos/registry/devices/bioyond_cell.yaml | 30 +++++- .../devices/bioyond_dispensing_station.yaml | 10 +- .../devices/coin_cell_workstation.yaml | 58 ++++++------ .../registry/devices/laiyu_liquid_test.yaml | 47 ++++++++++ unilabos/registry/devices/liquid_handler.yaml | 22 +++++ .../devices/neware_battery_test_system.yaml | 21 +++-- .../devices/organic_miscellaneous.yaml | 9 ++ unilabos/registry/devices/pump_and_valve.yaml | 8 ++ .../devices/reaction_station_bioyond.yaml | 91 +++++++++++-------- unilabos/registry/devices/robot_arm.yaml | 5 + .../registry/devices/robot_linear_motion.yaml | 5 + unilabos/registry/devices/virtual_device.yaml | 19 ++++ unilabos/registry/devices/xrd_d7mate.yaml | 11 ++- unilabos/registry/devices/zhida_gcms.yaml | 5 + unilabos/registry/registry.py | 22 +++-- unilabos/utils/import_manager.py | 2 + 17 files changed, 289 insertions(+), 90 deletions(-) diff --git a/unilabos/registry/devices/Qone_nmr.yaml b/unilabos/registry/devices/Qone_nmr.yaml index 5c5f1f8a9..fd2761e48 100644 --- a/unilabos/registry/devices/Qone_nmr.yaml +++ b/unilabos/registry/devices/Qone_nmr.yaml @@ -51,14 +51,18 @@ Qone_nmr: properties: check_interval: default: 60 + description: 检查间隔时间(秒),默认60秒 type: string expected_count: default: 1 + description: 期望生成的.nmr文件数量,默认1个 type: string monitor_dir: + description: 要监督的目录路径,如果未指定则使用self.monitor_directory type: string stability_checks: default: 3 + description: 文件大小稳定性检查次数,默认3次 type: string required: [] type: object @@ -85,11 +89,14 @@ Qone_nmr: goal: properties: output_dir: + description: 输出目录(如果未指定,使用self.output_directory) type: string string_list: + description: 字符串列表 type: string txt_encoding: default: utf-8 + description: 文件编码 type: string required: - string_list @@ -151,6 +158,13 @@ Qone_nmr: additionalProperties: false properties: string: + description: '包含多个字符串的输入数据,支持两种格式: + + 1. 逗号分隔:如 "A 1 B 2 C 3, X 10 Y 20 Z 30" + + 2. 换行分隔:如 "A 1 B 2 C 3 + + X 10 Y 20 Z 30"' type: string title: StrSingleInput_Goal type: object diff --git a/unilabos/registry/devices/bioyond_cell.yaml b/unilabos/registry/devices/bioyond_cell.yaml index f57cd35c5..6b2d1b172 100644 --- a/unilabos/registry/devices/bioyond_cell.yaml +++ b/unilabos/registry/devices/bioyond_cell.yaml @@ -491,14 +491,17 @@ bioyond_cell: goal: properties: material_names: + description: 物料名称列表;默认使用 [LiPF6, LiDFOB, DTD, LiFSI, LiPO2F2] items: type: string type: array type_id: default: 3a190ca0-b2f6-9aeb-8067-547e72c11469 + description: 物料类型ID type: string warehouse_name: default: 粉末加样头堆栈 + description: 目标仓库名(用于取位置信息) type: string required: [] type: object @@ -527,12 +530,16 @@ bioyond_cell: goal: properties: location_name_or_id: + description: 具体库位名称(如 A01)或库位 UUID,由用户指定。 type: string material_name: + description: 物料名称(会优先匹配配置模板)。 type: string type_id: + description: 物料类型 ID(若为空则尝试从配置推断)。 type: string warehouse_name: + description: 需要入库的仓库名称;若为空则仅创建不入库。 type: string required: - material_name @@ -661,15 +668,20 @@ bioyond_cell: goal: properties: board_type: + description: 板类型,如 "5ml分液瓶板"、"配液瓶(小)板" type: string bottle_type: + description: 瓶类型,如 "5ml分液瓶"、"配液瓶(小)" type: string location_code: + description: 库位编号,例如 "A01" type: string name: + description: 物料名称 type: string warehouse_name: default: 手动堆栈 + description: 仓库名称,默认为 "手动堆栈",支持 "自动堆栈-左"、"自动堆栈-右" 等 type: string required: - name @@ -1956,19 +1968,19 @@ bioyond_cell: properties: source_wh_id: default: 3a19debc-84b4-0359-e2d4-b3beea49348b - description: 来源仓库ID + description: 来源仓库 Id (默认为3号仓库) type: string source_x: default: 1 - description: 来源位置X坐标 + description: 来源位置 X 坐标 type: integer source_y: default: 1 - description: 来源位置Y坐标 + description: 来源位置 Y 坐标 type: integer source_z: default: 1 - description: 来源位置Z坐标 + description: 来源位置 Z 坐标 type: integer required: [] type: object @@ -2061,9 +2073,11 @@ bioyond_cell: goal: properties: order_code: + description: 任务编号 type: string timeout: default: 36000 + description: 超时时间(秒) type: integer required: - order_code @@ -2092,12 +2106,15 @@ bioyond_cell: goal: properties: order_code: + description: 任务编号 type: string poll_interval: default: 0.5 + description: 轮询间隔(秒),默认 0.5 秒 type: number timeout: default: 36000 + description: 超时时间(秒) type: integer required: - order_code @@ -2154,10 +2171,15 @@ bioyond_cell: config: properties: bioyond_config: + description: '从 JSON 文件加载的 bioyond 配置字典 + + 包含 api_host, api_key, HTTP_host, HTTP_port 等配置' type: object deck: + description: Deck 配置(可选,会从 JSON 中自动处理) type: string protocol_type: + description: 协议类型(可选) type: string required: [] type: object diff --git a/unilabos/registry/devices/bioyond_dispensing_station.yaml b/unilabos/registry/devices/bioyond_dispensing_station.yaml index 547b54ffb..21f36e162 100644 --- a/unilabos/registry/devices/bioyond_dispensing_station.yaml +++ b/unilabos/registry/devices/bioyond_dispensing_station.yaml @@ -47,8 +47,10 @@ bioyond_dispensing_station: goal: properties: report_request: + description: WorkstationReportRequest 对象,包含任务完成信息 type: string used_materials: + description: 物料使用记录列表 type: string required: - report_request @@ -102,6 +104,7 @@ bioyond_dispensing_station: goal: properties: material_name: + description: 物料名称 type: string required: - material_name @@ -611,10 +614,10 @@ bioyond_dispensing_station: goal: properties: target_device_id: - description: 目标反应站设备ID(从设备列表中选择,所有转移组都使用同一个目标设备) + description: 目标反应站设备ID(所有转移组使用同一个设备) type: string transfer_groups: - description: 转移任务组列表,每组包含物料名称、目标堆栈和目标库位,可以添加多组 + description: '转移任务组列表,每组包含:' type: array required: - target_device_id @@ -694,10 +697,13 @@ bioyond_dispensing_station: config: properties: config: + description: 配置字典,应包含material_type_mappings等配置 type: object deck: + description: Deck对象 type: string protocol_type: + description: 协议类型(由ROS系统传递,此处忽略) type: string required: [] type: object diff --git a/unilabos/registry/devices/coin_cell_workstation.yaml b/unilabos/registry/devices/coin_cell_workstation.yaml index df5a35085..b692506ce 100644 --- a/unilabos/registry/devices/coin_cell_workstation.yaml +++ b/unilabos/registry/devices/coin_cell_workstation.yaml @@ -150,15 +150,15 @@ coincellassemblyworkstation_device: properties: assembly_pressure: default: 4200 - description: 电池压制力(N) + description: 电池压制力 (N) type: integer assembly_type: default: 7 - description: 组装类型(7=不用铝箔垫, 8=使用铝箔垫) + description: 组装类型 (7=不用铝箔垫, 8=使用铝箔垫) type: integer battery_clean_ignore: default: false - description: 是否忽略电池清洁步骤 + description: 是否忽略电池清洁 type: boolean battery_pressure_mode: default: true @@ -166,29 +166,29 @@ coincellassemblyworkstation_device: type: boolean dual_drop_first_volume: default: 25 - description: 二次滴液第一次排液体积(μL) + description: 二次滴液第一次排液体积 (μL) type: integer dual_drop_mode: default: false - description: 电解液添加模式(false=单次滴液, true=二次滴液) + description: 电解液添加模式 (False=单次滴液, True=二次滴液) type: boolean dual_drop_start_timing: default: false - description: 二次滴液开始滴液时机(false=正极片前, true=正极片后) + description: 二次滴液开始滴液时机 (False=正极片前, True=正极片后) type: boolean dual_drop_suction_timing: default: false - description: 二次滴液吸液时机(false=正常吸液, true=先吸液) + description: 二次滴液吸液时机 (False=正常吸液, True=先吸液) type: boolean elec_num: description: 电解液瓶数 type: string elec_use_num: - description: 每瓶电解液组装电池数 + description: 每瓶电解液组装的电池数 type: string elec_vol: default: 50 - description: 电解液吸液量(μL) + description: 电解液吸液量 (μL) type: integer file_path: default: /Users/sml/work @@ -196,7 +196,7 @@ coincellassemblyworkstation_device: type: string fujipian_juzhendianwei: default: 0 - description: 负极片矩阵点位。盘位置从1开始计数,有效范围:1-8, 13-20 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2) + description: 负极片矩阵点位 type: integer fujipian_panshu: default: 0 @@ -204,7 +204,7 @@ coincellassemblyworkstation_device: type: integer gemo_juzhendianwei: default: 0 - description: 隔膜矩阵点位。盘位置从1开始计数,有效范围:1-8, 13-20 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2) + description: 隔膜矩阵点位 type: integer gemopanshu: default: 0 @@ -216,7 +216,7 @@ coincellassemblyworkstation_device: type: boolean qiangtou_juzhendianwei: default: 0 - description: 枪头盒矩阵点位。盘位置从1开始计数,有效范围:1-32, 64-96 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2) + description: 枪头盒矩阵点位 type: integer required: - elec_num @@ -308,7 +308,13 @@ coincellassemblyworkstation_device: properties: material_search_enable: default: false - description: 是否启用物料搜寻功能。设备初始化后会弹出物料搜寻确认弹窗,此参数控制自动点击"是"(启用)或"否"(不启用)。默认为false(不启用物料搜寻) + description: '是否启用物料搜寻功能。 + + 设备初始化后会弹出物料搜寻确认弹窗, + + 此参数控制自动点击''是''(启用)或''否''(不启用)。 + + 默认为False(不启用物料搜寻)。' type: boolean required: [] type: object @@ -547,15 +553,15 @@ coincellassemblyworkstation_device: properties: assembly_pressure: default: 4200 - description: 电池压制力(N) + description: 电池压制力 (N) type: integer assembly_type: default: 7 - description: 组装类型(7=不用铝箔垫, 8=使用铝箔垫) + description: 组装类型 (7=不用铝箔垫, 8=使用铝箔垫) type: integer battery_clean_ignore: default: false - description: 是否忽略电池清洁步骤 + description: 是否忽略电池清洁 type: boolean battery_pressure_mode: default: true @@ -563,29 +569,29 @@ coincellassemblyworkstation_device: type: boolean dual_drop_first_volume: default: 25 - description: 二次滴液第一次排液体积(μL) + description: 二次滴液第一次排液体积 (μL) type: integer dual_drop_mode: default: false - description: 电解液添加模式(false=单次滴液, true=二次滴液) + description: 电解液添加模式 (False=单次滴液, True=二次滴液) type: boolean dual_drop_start_timing: default: false - description: 二次滴液开始滴液时机(false=正极片前, true=正极片后) + description: 二次滴液开始滴液时机 (False=正极片前, True=正极片后) type: boolean dual_drop_suction_timing: default: false - description: 二次滴液吸液时机(false=正常吸液, true=先吸液) + description: 二次滴液吸液时机 (False=正常吸液, True=先吸液) type: boolean elec_num: - description: 电解液瓶数,如果在workflow中已通过handles连接上游(create_orders的bottle_count输出),则此参数会自动从上游获取,无需手动填写;如果单独使用此函数(没有上游连接),则必须手动填写电解液瓶数 + description: 电解液瓶数 type: string elec_use_num: - description: 每瓶电解液组装电池数 + description: 每瓶电解液组装的电池数 type: string elec_vol: default: 50 - description: 电解液吸液量(μL) + description: 电解液吸液量 (μL) type: integer file_path: default: /Users/sml/work @@ -593,7 +599,7 @@ coincellassemblyworkstation_device: type: string fujipian_juzhendianwei: default: 0 - description: 负极片矩阵点位。盘位置从1开始计数,有效范围:1-8, 13-20 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2) + description: 负极片矩阵点位 type: integer fujipian_panshu: default: 0 @@ -601,7 +607,7 @@ coincellassemblyworkstation_device: type: integer gemo_juzhendianwei: default: 0 - description: 隔膜矩阵点位。盘位置从1开始计数,有效范围:1-8, 13-20 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2) + description: 隔膜矩阵点位 type: integer gemopanshu: default: 0 @@ -613,7 +619,7 @@ coincellassemblyworkstation_device: type: boolean qiangtou_juzhendianwei: default: 0 - description: 枪头盒矩阵点位。盘位置从1开始计数,有效范围:1-32, 64-96 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2) + description: 枪头盒矩阵点位 type: integer required: - elec_num diff --git a/unilabos/registry/devices/laiyu_liquid_test.yaml b/unilabos/registry/devices/laiyu_liquid_test.yaml index 6d87f4291..e3494cac1 100644 --- a/unilabos/registry/devices/laiyu_liquid_test.yaml +++ b/unilabos/registry/devices/laiyu_liquid_test.yaml @@ -18,6 +18,7 @@ xyz_stepper_controller: goal: properties: degrees: + description: 角度值 type: number required: - degrees @@ -44,6 +45,7 @@ xyz_stepper_controller: goal: properties: axis: + description: 电机轴 type: object required: - axis @@ -71,6 +73,7 @@ xyz_stepper_controller: properties: enable: default: true + description: True为使能,False为失能 type: boolean required: [] type: object @@ -99,9 +102,11 @@ xyz_stepper_controller: goal: properties: axis: + description: 电机轴 type: object enable: default: true + description: True为使能,False为失能 type: boolean required: - axis @@ -152,6 +157,7 @@ xyz_stepper_controller: goal: properties: axis: + description: 电机轴 type: object required: - axis @@ -183,16 +189,21 @@ xyz_stepper_controller: properties: acceleration: default: 1000 + description: 加速度(rpm/s) type: integer axis: + description: 电机轴 type: object position: + description: 目标位置(步数) type: integer precision: default: 100 + description: 到位精度 type: integer speed: default: 5000 + description: 运行速度(rpm) type: integer required: - axis @@ -225,16 +236,21 @@ xyz_stepper_controller: properties: acceleration: default: 1000 + description: 加速度 type: integer axis: + description: 电机轴 type: object degrees: + description: 目标角度(度) type: number precision: default: 100 + description: 精度 type: integer speed: default: 5000 + description: 移动速度 type: integer required: - axis @@ -267,16 +283,21 @@ xyz_stepper_controller: properties: acceleration: default: 1000 + description: 加速度 type: integer axis: + description: 电机轴 type: object precision: default: 100 + description: 精度 type: integer revolutions: + description: 目标圈数 type: number speed: default: 5000 + description: 移动速度 type: integer required: - axis @@ -309,15 +330,20 @@ xyz_stepper_controller: properties: acceleration: default: 1000 + description: 加速度 type: integer speed: default: 5000 + description: 运行速度 type: integer x: + description: X轴目标位置 type: integer y: + description: Y轴目标位置 type: integer z: + description: Z轴目标位置 type: integer required: [] type: object @@ -350,15 +376,20 @@ xyz_stepper_controller: properties: acceleration: default: 1000 + description: 加速度 type: integer speed: default: 5000 + description: 移动速度 type: integer x_deg: + description: X轴目标角度(度) type: number y_deg: + description: Y轴目标角度(度) type: number z_deg: + description: Z轴目标角度(度) type: number required: [] type: object @@ -391,15 +422,20 @@ xyz_stepper_controller: properties: acceleration: default: 1000 + description: 加速度 type: integer speed: default: 5000 + description: 移动速度 type: integer x_rev: + description: X轴目标圈数 type: number y_rev: + description: Y轴目标圈数 type: number z_rev: + description: Z轴目标圈数 type: number required: [] type: object @@ -427,6 +463,7 @@ xyz_stepper_controller: goal: properties: revolutions: + description: 圈数 type: number required: - revolutions @@ -456,10 +493,13 @@ xyz_stepper_controller: properties: acceleration: default: 1000 + description: 加速度(rpm/s) type: integer axis: + description: 电机轴 type: object speed: + description: 运行速度(rpm),正值正转,负值反转 type: integer required: - axis @@ -487,6 +527,7 @@ xyz_stepper_controller: goal: properties: steps: + description: 步数 type: integer required: - steps @@ -513,6 +554,7 @@ xyz_stepper_controller: goal: properties: steps: + description: 步数 type: integer required: - steps @@ -564,9 +606,11 @@ xyz_stepper_controller: goal: properties: axis: + description: 电机轴 type: object timeout: default: 30.0 + description: 超时时间(秒) type: number required: - axis @@ -591,11 +635,14 @@ xyz_stepper_controller: properties: baudrate: default: 115200 + description: 波特率 type: integer port: + description: 串口端口名 type: string timeout: default: 1.0 + description: 通信超时时间 type: number required: - port diff --git a/unilabos/registry/devices/liquid_handler.yaml b/unilabos/registry/devices/liquid_handler.yaml index cbf04aa7d..5cbd080e6 100644 --- a/unilabos/registry/devices/liquid_handler.yaml +++ b/unilabos/registry/devices/liquid_handler.yaml @@ -510,9 +510,11 @@ liquid_handler: goal: properties: msg: + description: information to be printed type: string seconds: default: 0 + description: seconds to wait type: string required: [] type: object @@ -2963,15 +2965,22 @@ liquid_handler: additionalProperties: false properties: channel: + description: int maximum: 2147483647 minimum: -2147483648 type: integer dis_to_top: + description: 'float + + Height in mm to move to relative to the well top.' maximum: 1.7976931348623157e+308 minimum: -1.7976931348623157e+308 type: number well: additionalProperties: false + description: 'Well + + The target well.' properties: category: type: string @@ -4829,11 +4838,13 @@ liquid_handler: config: properties: backend: + description: Backend to use. type: object channel_num: default: 8 type: integer deck: + description: Deck to use. type: object simulator: default: false @@ -4883,14 +4894,17 @@ liquid_handler.biomek: bind_parent_id: type: string liquid_input_slot: + description: 液体输入槽列表 items: type: integer type: array liquid_type: + description: 液体类型列表 items: type: string type: array liquid_volume: + description: 液体体积列表 items: type: integer type: array @@ -4901,6 +4915,7 @@ liquid_handler.biomek: type: object type: array slot_on_deck: + description: 甲板上的槽位 type: integer required: - resource_tracker @@ -5036,20 +5051,27 @@ liquid_handler.biomek: additionalProperties: false properties: none_keys: + description: 需要设置为None的键列表 items: type: string type: array protocol_author: + description: 协议作者 type: string protocol_date: + description: 协议日期 type: string protocol_description: + description: 协议描述 type: string protocol_name: + description: 协议名称 type: string protocol_type: + description: 协议类型 type: string protocol_version: + description: 协议版本 type: string title: LiquidHandlerProtocolCreation_Goal type: object diff --git a/unilabos/registry/devices/neware_battery_test_system.yaml b/unilabos/registry/devices/neware_battery_test_system.yaml index 4f3b972ad..63411b539 100644 --- a/unilabos/registry/devices/neware_battery_test_system.yaml +++ b/unilabos/registry/devices/neware_battery_test_system.yaml @@ -87,7 +87,7 @@ neware_battery_test_system: properties: filepath: default: bts_status.json - description: 输出JSON文件路径 + description: 输出文件路径 type: string required: [] type: object @@ -146,7 +146,7 @@ neware_battery_test_system: goal: properties: plate_num: - description: 盘号 (1 或 2),如果为null则返回所有盘的状态 + description: 盘号 (1 或 2),如果为None则返回所有盘的状态 type: integer required: [] type: object @@ -237,11 +237,11 @@ neware_battery_test_system: goal: properties: csv_path: - description: 输入CSV文件的绝对路径 + description: 输入CSV文件路径 type: string output_dir: default: . - description: 输出目录(用于存储XML和备份文件),默认当前目录 + description: 输出目录,用于存储XML文件和备份,默认当前目录 type: string required: - csv_path @@ -302,14 +302,14 @@ neware_battery_test_system: goal: properties: backup_dir: - description: 备份目录路径(默认使用最近一次submit_from_csv的backup_dir) + description: 备份目录路径,默认使用最近一次 submit_from_csv 的 backup_dir type: string file_pattern: default: '*' - description: 文件通配符模式,例如 *.csv 或 Battery_*.nda + description: 文件通配符模式,默认 "*" 上传所有文件(例如 "*.csv" 仅上传 CSV 文件) type: string oss_prefix: - description: OSS对象路径前缀(默认使用self.oss_prefix) + description: OSS 对象前缀,默认使用类初始化时的配置 type: string required: [] type: object @@ -336,19 +336,25 @@ neware_battery_test_system: config: properties: devtype: + description: 设备类型标识 type: string ip: + description: TCP服务器IP地址 type: string machine_id: default: 1 + description: 机器ID type: integer oss_prefix: default: neware_backup + description: OSS对象路径前缀,默认"neware_backup" type: string oss_upload_enabled: default: false + description: 是否启用OSS上传功能,默认False type: boolean port: + description: TCP端口 type: integer size_x: default: 50 @@ -360,6 +366,7 @@ neware_battery_test_system: default: 20 type: number timeout: + description: 通信超时时间(秒) type: integer required: [] type: object diff --git a/unilabos/registry/devices/organic_miscellaneous.yaml b/unilabos/registry/devices/organic_miscellaneous.yaml index c1290beae..dc81b6710 100644 --- a/unilabos/registry/devices/organic_miscellaneous.yaml +++ b/unilabos/registry/devices/organic_miscellaneous.yaml @@ -207,8 +207,12 @@ separator.homemade: goal: properties: condition: + description: The condition to be monitored, either 'delta' or 'time'. type: string value: + description: 'The threshold value for the condition. + + `delta > 0.05`, `time > 60`' type: string required: - condition @@ -305,12 +309,17 @@ separator.homemade: event: type: string settling_time: + description: The duration for which to settle after stirring, in + seconds. Defaults to 10. type: string stir_speed: + description: The speed of stirring, in RPM. Defaults to 300. maximum: 1.7976931348623157e+308 minimum: -1.7976931348623157e+308 type: number stir_time: + description: The duration for which to stir, in seconds. Defaults + to 10. maximum: 1.7976931348623157e+308 minimum: -1.7976931348623157e+308 type: number diff --git a/unilabos/registry/devices/pump_and_valve.yaml b/unilabos/registry/devices/pump_and_valve.yaml index 95a082d53..25d647f7c 100644 --- a/unilabos/registry/devices/pump_and_valve.yaml +++ b/unilabos/registry/devices/pump_and_valve.yaml @@ -456,6 +456,7 @@ syringe_pump_with_valve.runze.SY03B-T06: goal: properties: volume: + description: 'absolute position of the plunger, unit: mL' type: number required: - volume @@ -481,6 +482,7 @@ syringe_pump_with_valve.runze.SY03B-T06: goal: properties: volume: + description: 'absolute position of the plunger, unit: mL' type: number required: - volume @@ -687,8 +689,10 @@ syringe_pump_with_valve.runze.SY03B-T06: goal: properties: max_velocity: + description: 'maximum velocity of the plunger, unit: ml/s' type: number position: + description: 'absolute position of the plunger, unit: ml' type: number required: - position @@ -1003,6 +1007,7 @@ syringe_pump_with_valve.runze.SY03B-T08: goal: properties: volume: + description: 'absolute position of the plunger, unit: mL' type: number required: - volume @@ -1028,6 +1033,7 @@ syringe_pump_with_valve.runze.SY03B-T08: goal: properties: volume: + description: 'absolute position of the plunger, unit: mL' type: number required: - volume @@ -1234,8 +1240,10 @@ syringe_pump_with_valve.runze.SY03B-T08: goal: properties: max_velocity: + description: 'maximum velocity of the plunger, unit: ml/s' type: number position: + description: 'absolute position of the plunger, unit: ml' type: number required: - position diff --git a/unilabos/registry/devices/reaction_station_bioyond.yaml b/unilabos/registry/devices/reaction_station_bioyond.yaml index 1372140d2..7ab22df61 100644 --- a/unilabos/registry/devices/reaction_station_bioyond.yaml +++ b/unilabos/registry/devices/reaction_station_bioyond.yaml @@ -32,7 +32,7 @@ reaction_station.bioyond: type: integer end_point: default: 0 - description: 终点计时点 (Start=开始前, End=结束后) + description: 终点计时点 (Start=0, End=1) type: integer end_step_key: default: '' @@ -40,11 +40,11 @@ reaction_station.bioyond: type: string start_point: default: 0 - description: 起点计时点 (Start=开始前, End=结束后) + description: 起点计时点 (Start=0, End=1) type: integer start_step_key: default: '' - description: 起点步骤Key (例如 "feeding", "liquid", 可选, 默认为空则自动选择) + description: 起点步骤Key (可选, 默认为空则自动选择) type: string required: - duration @@ -91,6 +91,7 @@ reaction_station.bioyond: goal: properties: json_str: + description: 订单参数的JSON字符串 type: string required: - json_str @@ -117,6 +118,7 @@ reaction_station.bioyond: goal: properties: workflow_ids: + description: 要删除的工作流ID数组 items: type: string type: array @@ -145,6 +147,7 @@ reaction_station.bioyond: goal: properties: json_str: + description: 'JSON格式的字符串,包含:' type: string required: - json_str @@ -197,6 +200,7 @@ reaction_station.bioyond: goal: properties: web_workflow_json: + description: JSON 格式的网页工作流列表 type: string required: - web_workflow_json @@ -228,8 +232,10 @@ reaction_station.bioyond: goal: properties: reactor_id: + description: 反应器编号 (1-5) type: integer temperature: + description: 目标温度 (°C) type: number required: - reactor_id @@ -257,6 +263,7 @@ reaction_station.bioyond: goal: properties: preintake_id: + description: 通量ID type: string required: - preintake_id @@ -338,6 +345,7 @@ reaction_station.bioyond: goal: properties: value: + description: 工作流 ID 列表 items: type: string type: array @@ -365,6 +373,7 @@ reaction_station.bioyond: goal: properties: workflow_id: + description: 工作流ID type: string required: - workflow_id @@ -424,11 +433,11 @@ reaction_station.bioyond: goal: properties: assign_material_name: - description: 物料名称(不能为空) + description: 物料名称(液体种类) type: string temperature: default: 25.0 - description: 温度设定(°C) + description: 温度(C) type: number time: default: '90' @@ -436,14 +445,14 @@ reaction_station.bioyond: type: string titration_type: default: '1' - description: 是否滴定(NO=否, YES=是) + description: 是否滴定(NO=1, YES=2) type: string torque_variation: default: 2 - description: 是否观察 (NO=否, YES=是) + description: 是否观察(NO=1, YES=2) type: integer volume: - description: 分液公式(mL) + description: 分液量(μL) type: string required: - assign_material_name @@ -525,11 +534,11 @@ reaction_station.bioyond: properties: assign_material_name: default: BAPP - description: 物料名称 + description: 物料名称(试剂瓶位) type: string temperature: default: 25.0 - description: 温度设定(°C) + description: 温度设定(C) type: number time: default: '0' @@ -537,15 +546,15 @@ reaction_station.bioyond: type: string titration_type: default: '1' - description: 是否滴定(NO=否, YES=是) + description: 是否滴定(NO=1, YES=2) type: string torque_variation: default: 1 - description: 是否观察 (NO=否, YES=是) + description: 是否观察(int类型, 1=否, 2=是) type: integer volume: default: '350' - description: 分液公式(mL) + description: 分液质量(g) type: string required: [] type: object @@ -593,26 +602,28 @@ reaction_station.bioyond: description: 物料名称 type: string solvents: - description: '溶剂信息对象(可选),包含: additional_solvent(溶剂体积mL), total_liquid_volume(总液体体积mL)。如果提供,将自动计算volume' + description: '溶剂信息的字典或JSON字符串(可选),格式如下: + + {' type: string temperature: default: 25.0 - description: 温度设定(°C),默认25.00 + description: 温度设定(C) type: number time: default: '360' - description: 观察时间(分钟),默认360 + description: 观察时间(分钟) type: string titration_type: default: '1' - description: 是否滴定(NO=否, YES=是),默认NO + description: 是否滴定(NO=1, YES=2) type: string torque_variation: default: 2 - description: 是否观察 (NO=否, YES=是),默认YES + description: 是否观察(NO=1, YES=2) type: integer volume: - description: 分液量(mL)。可直接提供,或通过solvents参数自动计算 + description: 分液量(μL),直接指定体积(可选,如果提供solvents则自动计算) type: string required: - assign_material_name @@ -671,33 +682,32 @@ reaction_station.bioyond: description: 物料名称 type: string extracted_actuals: - description: 从报告提取的实际加料量JSON字符串,包含actualTargetWeigh(m二酐滴定)和actualVolume(V二酐滴定) + description: 从报告提取的实际加料量JSON字符串,包含actualTargetWeigh和actualVolume type: string feeding_order_data: - description: 'feeding_order JSON对象,用于获取m二酐值(type为main_anhydride的amount)。示例: - {"feeding_order": [{"type": "main_anhydride", "amount": 1.915}]}' + description: feeding_order JSON字符串或对象,用于获取m二酐值 type: string temperature: default: 25.0 - description: 温度设定(°C),默认25.00 + description: 温度(C) type: number time: default: '90' - description: 观察时间(分钟),默认90 + description: 观察时间(分钟) type: string titration_type: default: '2' - description: 是否滴定(NO=否, YES=是),默认YES + description: 是否滴定(NO=1, YES=2),默认2 type: string torque_variation: default: 2 - description: 是否观察 (NO=否, YES=是),默认YES + description: 是否观察(NO=1, YES=2) type: integer volume_formula: - description: 分液公式(mL)。可直接提供固定公式,或留空由系统根据x_value、feeding_order_data、extracted_actuals自动生成 + description: 分液公式(μL),如果提供则直接使用,否则自动计算 type: string x_value: - description: 公式中的x值,手工输入,格式为"{{1-2-3}}"(包含双花括号)。用于自动公式计算 + description: 手工输入的x值,格式如 "1-2-3" type: string required: - assign_material_name @@ -738,7 +748,7 @@ reaction_station.bioyond: type: string temperature: default: 25.0 - description: 温度设定(°C) + description: 温度(C) type: number time: default: '0' @@ -746,14 +756,14 @@ reaction_station.bioyond: type: string titration_type: default: '1' - description: 是否滴定(NO=否, YES=是) + description: 是否滴定(NO=1, YES=2) type: string torque_variation: default: 1 - description: 是否观察 (NO=否, YES=是) + description: 是否观察(NO=1, YES=2) type: integer volume_formula: - description: 分液公式(mL) + description: 分液公式(μL) type: string required: - volume_formula @@ -786,7 +796,7 @@ reaction_station.bioyond: description: 任务名称 type: string workflow_name: - description: 工作流名称 + description: 合并后的工作流名称 type: string required: - workflow_name @@ -819,15 +829,15 @@ reaction_station.bioyond: goal: properties: assign_material_name: - description: 物料名称 + description: 物料名称(不能为空) type: string cutoff: default: '900000' - description: 粘度上限 + description: 粘度上限(需为有效数字字符串,默认 "900000") type: string temperature: default: -10.0 - description: 温度设定(°C) + description: 温度设定(C,范围:-50.00 至 100.00) type: number required: - assign_material_name @@ -909,11 +919,11 @@ reaction_station.bioyond: description: 物料名称(用于获取试剂瓶位ID) type: string material_id: - description: 粉末类型ID,Salt=盐(21分钟),Flour=面粉(27分钟),BTDA=BTDA(38分钟) + description: 粉末类型ID, Salt=1, Flour=2, BTDA=3 type: string temperature: default: 25.0 - description: 温度设定(°C) + description: 温度设定(C) type: number time: default: '0' @@ -921,7 +931,7 @@ reaction_station.bioyond: type: string torque_variation: default: 1 - description: 是否观察 (NO=否, YES=是) + description: 是否观察(NO=1, YES=2) type: integer required: - material_id @@ -945,10 +955,13 @@ reaction_station.bioyond: config: properties: config: + description: 配置字典,应包含workflow_mappings等配置 type: object deck: + description: Deck对象 type: string protocol_type: + description: 协议类型(由ROS系统传递,此处忽略) type: string required: [] type: object diff --git a/unilabos/registry/devices/robot_arm.yaml b/unilabos/registry/devices/robot_arm.yaml index d48746772..b96e53412 100644 --- a/unilabos/registry/devices/robot_arm.yaml +++ b/unilabos/registry/devices/robot_arm.yaml @@ -198,6 +198,8 @@ robotic_arm.SCARA_with_slider.moveit.virtual: additionalProperties: false properties: command: + description: A JSON-formatted string that includes option, target, + speed, lift_height, mt_height type: string title: SendCmd_Goal type: object @@ -241,6 +243,8 @@ robotic_arm.SCARA_with_slider.moveit.virtual: additionalProperties: false properties: command: + description: A JSON-formatted string that includes quaternion, speed, + position type: string title: SendCmd_Goal type: object @@ -284,6 +288,7 @@ robotic_arm.SCARA_with_slider.moveit.virtual: additionalProperties: false properties: command: + description: A JSON-formatted string that includes speed type: string title: SendCmd_Goal type: object diff --git a/unilabos/registry/devices/robot_linear_motion.yaml b/unilabos/registry/devices/robot_linear_motion.yaml index 74b01e806..14539321d 100644 --- a/unilabos/registry/devices/robot_linear_motion.yaml +++ b/unilabos/registry/devices/robot_linear_motion.yaml @@ -709,6 +709,8 @@ linear_motion.toyo_xyz.sim: additionalProperties: false properties: command: + description: A JSON-formatted string that includes option, target, + speed, lift_height, mt_height type: string title: SendCmd_Goal type: object @@ -752,6 +754,8 @@ linear_motion.toyo_xyz.sim: additionalProperties: false properties: command: + description: A JSON-formatted string that includes quaternion, speed, + position type: string title: SendCmd_Goal type: object @@ -795,6 +799,7 @@ linear_motion.toyo_xyz.sim: additionalProperties: false properties: command: + description: A JSON-formatted string that includes speed type: string title: SendCmd_Goal type: object diff --git a/unilabos/registry/devices/virtual_device.yaml b/unilabos/registry/devices/virtual_device.yaml index b828c6d29..a34d6f556 100644 --- a/unilabos/registry/devices/virtual_device.yaml +++ b/unilabos/registry/devices/virtual_device.yaml @@ -2179,6 +2179,7 @@ virtual_multiway_valve: goal: properties: port_number: + description: 端口号 (1-8) type: integer required: - port_number @@ -2225,6 +2226,7 @@ virtual_multiway_valve: goal: properties: port_number: + description: 目标端口号 (1-8) type: integer required: - port_number @@ -2261,6 +2263,7 @@ virtual_multiway_valve: additionalProperties: false properties: command: + description: 目标位置 (0-8) 或位置字符串 type: string title: SendCmd_Goal type: object @@ -2304,6 +2307,7 @@ virtual_multiway_valve: additionalProperties: false properties: command: + description: 目标位置 (0-8) 或位置字符串 type: string title: SendCmd_Goal type: object @@ -4215,6 +4219,7 @@ virtual_solenoid_valve: additionalProperties: false properties: string: + description: '"ON"/"OFF" 或 "OPEN"/"CLOSED"' type: string title: StrSingleInput_Goal type: object @@ -4258,6 +4263,7 @@ virtual_solenoid_valve: additionalProperties: false properties: command: + description: '"OPEN"/"CLOSED" 或其他控制命令' type: string title: SendCmd_Goal type: object @@ -4418,16 +4424,20 @@ virtual_solid_dispenser: event: type: string mass: + description: 质量字符串 (如 "2.9 g") type: string mol: + description: 摩尔数字符串 (如 "0.12 mol") type: string purpose: + description: 添加目的 type: string rate_spec: type: string ratio: type: string reagent: + description: 试剂名称 type: string stir: type: boolean @@ -4439,6 +4449,7 @@ virtual_solid_dispenser: type: string vessel: additionalProperties: false + description: 目标容器 properties: category: type: string @@ -5568,8 +5579,10 @@ virtual_transfer_pump: goal: properties: velocity: + description: 拉取速度 (ml/s) type: number volume: + description: 要拉取的体积 (ml) type: number required: - volume @@ -5596,8 +5609,10 @@ virtual_transfer_pump: goal: properties: velocity: + description: 推出速度 (ml/s) type: number volume: + description: 要推出的体积 (ml) type: number required: - volume @@ -5693,10 +5708,12 @@ virtual_transfer_pump: additionalProperties: false properties: max_velocity: + description: 移动速度 (ml/s) maximum: 1.7976931348623157e+308 minimum: -1.7976931348623157e+308 type: number position: + description: 目标位置 (ml) maximum: 1.7976931348623157e+308 minimum: -1.7976931348623157e+308 type: number @@ -5845,8 +5862,10 @@ virtual_transfer_pump: config: properties: config: + description: 配置字典,包含max_volume, port等参数 type: object device_id: + description: 设备ID type: string required: [] type: object diff --git a/unilabos/registry/devices/xrd_d7mate.yaml b/unilabos/registry/devices/xrd_d7mate.yaml index 2b49ae552..38e31718a 100644 --- a/unilabos/registry/devices/xrd_d7mate.yaml +++ b/unilabos/registry/devices/xrd_d7mate.yaml @@ -409,11 +409,11 @@ xrd_d7mate: properties: end_theta: default: 80.0 - description: 结束角度(≥5.5°,且必须大于start_theta) + description: 结束角度(≥5.5°,且必须大于 start_theta) type: number exp_time: default: 0.1 - description: 曝光时间(0.1-5.0秒) + description: 曝光时间(0.1-5.0 秒) type: number increment: default: 0.05 @@ -421,7 +421,7 @@ xrd_d7mate: type: number sample_id: default: '' - description: 样品标识符 + description: 样品名称 type: string start_theta: default: 10.0 @@ -433,7 +433,7 @@ xrd_d7mate: type: string wait_minutes: default: 3.0 - description: 允许上样后等待分钟数 + description: 在允许上样后、发送样品准备完成前的等待分钟数(默认 3 分钟) type: number required: [] title: StartWorkflow_Goal @@ -492,12 +492,15 @@ xrd_d7mate: properties: host: default: 127.0.0.1 + description: 设备IP地址 type: string port: default: 6001 + description: 通信端口,默认6001 type: string timeout: default: 10.0 + description: 超时时间,单位秒 type: string required: [] type: object diff --git a/unilabos/registry/devices/zhida_gcms.yaml b/unilabos/registry/devices/zhida_gcms.yaml index 37adbd795..b10b29ad9 100644 --- a/unilabos/registry/devices/zhida_gcms.yaml +++ b/unilabos/registry/devices/zhida_gcms.yaml @@ -217,6 +217,7 @@ zhida_gcms: additionalProperties: false properties: string: + description: Base64编码的CSV数据(ROS2参数名) type: string title: StrSingleInput_Goal type: object @@ -257,6 +258,7 @@ zhida_gcms: additionalProperties: false properties: string: + description: CSV文件路径(ROS2参数名) type: string title: StrSingleInput_Goal type: object @@ -289,12 +291,15 @@ zhida_gcms: properties: host: default: 192.168.3.184 + description: 设备IP地址,本地部署时可使用'127.0.0.1' type: string port: default: 5792 + description: 通信端口,默认5792 type: string timeout: default: 10.0 + description: 超时时间,单位秒 type: string required: [] type: object diff --git a/unilabos/registry/registry.py b/unilabos/registry/registry.py index 173068102..75677b4f2 100644 --- a/unilabos/registry/registry.py +++ b/unilabos/registry/registry.py @@ -571,6 +571,7 @@ def _apply_docstring_param_metadata( schema: Dict[str, Any], doc_info: Dict[str, Any], field_to_param: Optional[Dict[str, str]] = None, + apply_defaults: bool = False, ) -> None: """Apply parsed docstring display names and descriptions to schema properties.""" if not schema or not doc_info: @@ -589,12 +590,20 @@ def _apply_docstring_param_metadata( if not isinstance(param_name, str): continue param_name = param_name.removesuffix("[]") - prop_schema["title"] = param_display_names.get(param_name, prop_schema.get("title") or field_name) - prop_schema["description"] = param_descs.get(param_name, prop_schema.get("description") or "") + if param_name in param_display_names: + prop_schema["title"] = param_display_names[param_name] + elif apply_defaults and not prop_schema.get("title"): + prop_schema["title"] = field_name + + if param_name in param_descs: + prop_schema["description"] = param_descs[param_name] + elif apply_defaults and "description" not in prop_schema: + prop_schema["description"] = "" def _generate_unilab_json_command_schema( self, method_args: list, docstring: Optional[str] = None, import_map: Optional[Dict[str, str]] = None, + apply_doc_defaults: bool = False, ) -> Dict[str, Any]: """根据方法参数和 docstring 生成 UniLabJsonCommand schema""" doc_info = parse_docstring(docstring) @@ -631,7 +640,7 @@ def _generate_unilab_json_command_schema( if param_required: schema["required"].append(param_name) - self._apply_docstring_param_metadata(schema, doc_info) + self._apply_docstring_param_metadata(schema, doc_info, apply_defaults=apply_doc_defaults) return schema def _generate_status_types_schema(self, status_methods: Dict[str, Any]) -> Dict[str, Any]: @@ -1038,6 +1047,7 @@ def _build_json_command_entry(method_name, method_info, action_args=None): goal_schema_for_docs, parse_docstring(method_info.get("docstring")), goal, + apply_defaults=True, ) action_value_mappings[action_name] = action_entry @@ -1127,7 +1137,7 @@ def _generate_schema_from_ast_params( if prequired: schema["required"].append(pname) - self._apply_docstring_param_metadata(schema, doc_info) + self._apply_docstring_param_metadata(schema, doc_info, apply_defaults=True) return schema def _generate_status_schema_from_ast( @@ -2211,10 +2221,6 @@ def obtain_registry_device_info(self): }, **schema["properties"]["goal"]["properties"], } - for field_name, field_schema in schema["properties"]["goal"]["properties"].items(): - if isinstance(field_schema, dict): - field_schema.setdefault("title", field_name) - field_schema.setdefault("description", "") # 将 placeholder_keys 信息添加到 schema 中 if "placeholder_keys" in action_config and action_config.get("schema", {}).get( "properties", {} diff --git a/unilabos/utils/import_manager.py b/unilabos/utils/import_manager.py index 7fe2f501e..8d0e8bf10 100644 --- a/unilabos/utils/import_manager.py +++ b/unilabos/utils/import_manager.py @@ -206,6 +206,7 @@ def get_enhanced_class_info(self, module_path: str, **_kwargs) -> Dict[str, Any] "ast_analysis_success": False, "import_map": {}, "init_params": [], + "init_docstring": None, "status_methods": {}, "action_methods": {}, } @@ -251,6 +252,7 @@ def get_enhanced_class_info(self, module_path: str, **_kwargs) -> Dict[str, Any] # 映射到统一字段名(与 registry.py complete_registry 消费端一致) result["init_params"] = body.get("init_params", []) + result["init_docstring"] = body.get("init_docstring") result["status_methods"] = body.get("status_properties", {}) result["action_methods"] = { k: { From af4fbd5b6be24ceb8928c3342eca0022a5a452c0 Mon Sep 17 00:00:00 2001 From: Junhan Chang Date: Thu, 7 May 2026 23:01:37 +0800 Subject: [PATCH 24/56] Update Skills --- .../skills/filter-workflow-by-tags/SKILL.md | 450 ++++++++++++++++++ .../scripts/filter_workflows.py | 191 ++++++++ 2 files changed, 641 insertions(+) create mode 100644 .cursor/skills/filter-workflow-by-tags/SKILL.md create mode 100755 .cursor/skills/filter-workflow-by-tags/scripts/filter_workflows.py diff --git a/.cursor/skills/filter-workflow-by-tags/SKILL.md b/.cursor/skills/filter-workflow-by-tags/SKILL.md new file mode 100644 index 000000000..b5c54a2d1 --- /dev/null +++ b/.cursor/skills/filter-workflow-by-tags/SKILL.md @@ -0,0 +1,450 @@ +--- +name: filter-workflow-by-tags +description: Query backend workflow list, aggregate all tags, and filter workflows by domain/scenario requirements using tags. Use when the user wants to search workflows, find workflows by tags, list available workflow tags, filter workflows by category/domain/scenario, or mentions 工作流筛选/标签查询/workflow tags/按领域查找工作流. +--- +# Uni-Lab 工作流标签筛选指南 + +通过 Uni-Lab 云端 API 查询工作流列表,汇总所有可用标签(tags),并根据领域和场景要求筛选工作流。 + +> **重要**:本指南中的 `Authorization: Lab ` 是 **Uni-Lab 平台专用的认证方式**,`Lab` 是 Uni-Lab 的 auth scheme 关键字,**不是** HTTP Basic 认证。请勿将其替换为 `Basic`。 + +## 使用模式识别 + +**用户可能一开始就给出场景目标**(如"我要做有机合成实验"、"找柱层析相关的 protocol")。此时: + +1. **识别场景关键词** → 映射到可能的 tags(如 synthesis、organic、chromatography、purification) +2. **直接执行完整流程**(获取 ak/sk/addr → 拉取所有工作流 → 汇总 tags → 按场景筛选) +3. **展示筛选结果** → 引导用户从候选 workflow 中**选择明确的实验 protocol** +4. **如果用户确认某个 workflow** → 记录 `workflow_uuid`,准备对接 `batch-submit-experiment` skill + +**如果用户未给场景目标**,则按标准 checklist 询问筛选条件。 + +--- + +## 前置条件 + +使用本指南前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。 + +### 1. ak / sk → AUTH + +询问用户的启动参数,从 `--ak` `--sk` 或 config.py 中获取。 + +生成 AUTH token: + +```bash +python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" +``` + +### 2. --addr → BASE URL + +| `--addr` 值 | BASE | +| ------------- | ------------------------------------- | +| `test` | `https://leap-lab.test.bohrium.com` | +| `uat` | `https://leap-lab.uat.bohrium.com` | +| `local` | `http://127.0.0.1:48197` | +| 不传(默认) | `https://leap-lab.bohrium.com` | + +确认后设置: + +```bash +BASE="<根据 addr 确定的 URL>" +AUTH="Authorization: Lab <上面命令输出的 token>" +``` + +### 3. lab_uuid(实验室 UUID) + +如果用户未提供 `lab_uuid`,通过以下 API 自动获取: + +```bash +curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH" +``` + +返回 `data.uuid` 即为 `lab_uuid`。 + +**三项全部就绪后才可开始。** + +## Session State + +在整个对话过程中,agent 需要记住以下状态: + +- `lab_uuid` — 实验室 UUID +- `all_workflows` — 完整工作流列表(分页获取后缓存到内存或临时文件) +- `all_tags` — 所有工作流的标签汇总 + +--- + +## API 端点 + +### 查询工作流列表(支持分页) + +``` +GET $BASE/api/v1/lab/workflow/owner/list?page=&page_size=&lab_uuid=$lab_uuid +``` + +**参数:** + +- `page` — 页码,从 1 开始 +- `page_size` — 每页数量,建议 1000 +- `lab_uuid` — 实验室 UUID + +**返回结构:** + +```json +{ + "code": 0, + "data": { + "has_more": true, + "data": [ + { + "uuid": "9661bba2-1b9f-4687-a63d-910245df174b", + "name": "Untitled", + "description": "", + "user_id": "114211", + "published": false, + "tags": null + }, + { + "uuid": "e0436638-190b-46bc-b1a1-2711d9602f6a", + "name": "Synthesis v2", + "user_id": "114211", + "published": true, + "tags": ["synthesis", "organic"] + } + ] + } +} +``` + +**字段说明:** + +- `has_more` — 若为 `true`,需要继续请求 `page+1` +- `tags` — 可能为 `null`、空数组或字符串数组;聚合时必须容忍 `null` + +### 启动工作流(直接运行) + +``` +POST $BASE/api/v1/lab/workflow//run +``` + +**用途:** 直接启动一个 workflow 的默认执行(使用模板中预设的参数),无需创建 notebook。适用于快速测试或无参数变化的重复执行。 + +**请求体:** 空 JSON `{}` 或省略 + +**返回:** + +```json +{ + "code": 0, + "data": "" +} +``` + +- `run_uuid` — 本次执行的唯一标识(不是 notebook UUID) + +**注意:** + +- 该接口会使用 workflow 模板中保存的默认参数直接执行 +- 如果 workflow 需要动态参数(如 CSV 路径、样品 UUID),应使用 `POST /lab/notebook` 创建 notebook 并传入 `node_params` +- 返回的 `run_uuid` 可直接传入下方「查询任务状态」接口查询实时进度 + +### 查询任务状态 + +``` +GET $BASE/api/v1/lab/mcp/task/ +``` + +**用途:** 查询由 `POST /lab/workflow//run` 返回的 `run_uuid`(即 task_uuid)的实时执行状态,包括整体状态和每个节点(JOS:Job On Station)的执行详情。 + +**路径参数:** + +- `task_uuid` — 等同于启动工作流接口返回的 `run_uuid` + +**返回:** + +```json +{ + "code": 0, + "data": { + "status": "running", + "jos_status": [ + { + "uuid": "d0e24bfe-8d99-450e-b19d-f25849dfbaad", + "node_name": "PRCXI_BioER_96_wellplate_slot_1", + "action_name": "create_resource", + "status": "success", + "return_info": { + "suc": true, + "error": "", + "return_value": { ... } + } + }, + { + "uuid": "...", + "node_name": "...", + "action_name": "transfer_liquid", + "status": "pending", + "return_info": null + } + ] + } +} +``` + +**字段说明:** + +- `data.status` — 整体任务状态 + - `running` — 正在执行(至少一个节点 pending 或 running) + - `success` — 全部节点成功 + - `failed` — 有节点失败 +- `data.jos_status[]` — 节点级执行列表(按执行顺序) + - `uuid` — 节点执行实例 UUID + - `node_name` — 节点名称(资源/设备名或工位名) + - `action_name` — 动作类型(`create_resource`、`transfer_liquid`、`centrifuge`、等) + - `status` — 节点状态:`success`、`pending`、`running`、`failed` + - `return_info` — 执行返回,失败时 `suc=false` 且 `error` 有错误信息 + +**注意:** + +- 此接口的 `task_uuid` **是** `POST /lab/workflow//run` 返回的 `run_uuid`,二者为同一个 ID 的不同称呼 +- **不要**把 notebook UUID(`POST /lab/notebook` 返回)传进来——那条路径用 `GET /lab/notebook/status` 查询 +- `jos_status` 数组按节点执行顺序给出;从 pending 数量可以估算剩余进度 +- 返回体可能较大(`return_info.return_value` 中可能包含完整 resource tree),可在脚本中只提取 `status` + `node_name` + `action_name` 做摘要 + +**状态轮询示例:** + +```bash +# 每 5 秒轮询一次直至完成 +TASK="b183d97e-d2b5-4b24-b14b-820df04d87c0" +while :; do + st=$(curl -s -X GET "$BASE/api/v1/lab/mcp/task/$TASK" -H "$AUTH" \ + | python3 -c "import json,sys; d=json.load(sys.stdin)['data']; \ + print(d['status'], '|', sum(1 for j in d['jos_status'] if j['status']=='success'), '/', len(d['jos_status']))") + echo "$(date +%H:%M:%S) $st" + [[ "$st" == success* || "$st" == failed* ]] && break + sleep 5 +done +``` + +--- + +## 完整工作流 Checklist + +``` +Task Progress: +- [ ] Step 0: 识别用户是否已给出场景目标(如"有机合成"、"柱层析") + - 若已给出 → 记录场景关键词,自动进入后续步骤 + - 若未给出 → 在 Step 6 询问用户 +- [ ] Step 1: 确认 ak/sk → 生成 AUTH token +- [ ] Step 2: 确认 --addr → 设置 BASE URL +- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid(如用户未提供) +- [ ] Step 4: 分页获取所有工作流(从 page=1 开始直到 has_more=false) +- [ ] Step 5: 汇总所有非空 tags → 生成 all_tags(去重、排序、附出现次数) +- [ ] Step 6: 根据场景关键词(Step 0 或新询问)在 all_tags 中做语义映射 → 确定候选 tags + - 若语义映射不唯一,列出候选 tags 让用户确认 +- [ ] Step 7: 按候选 tags 筛选工作流(默认 any 模式,召回优先) +- [ ] Step 8: 展示筛选结果(uuid、name、description、tags、published) +- [ ] Step 9: 引导用户从结果中选择**明确的实验 protocol** + - 若结果只有 1 条 → 直接确认该 workflow_uuid + - 若结果 2–10 条 → 让用户按编号选择 + - 若结果过多 → 提示收紧条件(加 tag、切换 all 模式、仅 published) + - 若结果为空 → 放宽条件(去掉最稀有 tag)或提示用户换关键词 +- [ ] Step 10: 记录用户选中的 workflow_uuid,并提示可交给 `batch-submit-experiment` skill 提交实验 +``` + +--- + +## 推荐路径:使用脚本 + +同目录下提供 `scripts/filter_workflows.py`,一次完成分页抓取、标签聚合与筛选: + +```bash +# 1. 仅汇总标签(不筛选) +python scripts/filter_workflows.py \ + --auth "" \ + --base "$BASE" \ + --lab-uuid "$lab_uuid" \ + --summary-only + +# 2. 按标签筛选(ANY 模式:包含任一) +python scripts/filter_workflows.py \ + --auth "" \ + --base "$BASE" \ + --lab-uuid "$lab_uuid" \ + --tags synthesis organic \ + --mode any + +# 3. 按标签筛选(ALL 模式:必须同时包含) +python scripts/filter_workflows.py \ + --auth "" \ + --base "$BASE" \ + --lab-uuid "$lab_uuid" \ + --tags synthesis organic \ + --mode all \ + --output filtered.json + +# 4. 仅筛选已发布 +python scripts/filter_workflows.py \ + --auth "" \ + --base "$BASE" \ + --lab-uuid "$lab_uuid" \ + --tags synthesis \ + --published-only +``` + +**`--auth` 参数说明**:传入 `Authorization` 头中 `Lab` 之后的 base64 token(不带 `Lab ` 前缀),脚本内部会自动补上 scheme。 + +**输出结构:** + +```json +{ + "total_workflows": 150, + "tag_counts": {"synthesis": 12, "organic": 8, "analysis": 5}, + "all_tags": ["analysis", "organic", "synthesis"], + "filter": {"tags": ["synthesis", "organic"], "mode": "any"}, + "filtered_workflows": [ + {"uuid": "...", "name": "...", "description": "...", "tags": [...], "published": true} + ] +} +``` + +--- + +## 手动路径:curl + jq + +如果脚本不可用或环境缺少 Python,可用 shell 实现。 + +### 1. 分页抓取(写入 `all_workflows.json`) + +```bash +page=1 +echo "[]" > all_workflows.json + +while :; do + resp=$(curl -s -X GET \ + "$BASE/api/v1/lab/workflow/owner/list?page=$page&page_size=1000&lab_uuid=$lab_uuid" \ + -H "$AUTH") + + page_data=$(echo "$resp" | jq -c '.data.data // []') + jq -c --argjson p "$page_data" '. + $p' all_workflows.json > _tmp.json && mv _tmp.json all_workflows.json + + has_more=$(echo "$resp" | jq -r '.data.has_more') + [ "$has_more" != "true" ] && break + page=$((page + 1)) +done + +echo "Total: $(jq 'length' all_workflows.json)" +``` + +### 2. 汇总所有标签(含出现次数) + +```bash +jq '[.[].tags // [] | .[]] | group_by(.) | map({tag: .[0], count: length}) | sort_by(-.count)' \ + all_workflows.json +``` + +### 3. 按标签筛选 + +```bash +# ANY:包含任一指定标签 +jq --argjson want '["synthesis","organic"]' \ + '[.[] | select((.tags // []) | any(. as $t | $want | index($t)))]' \ + all_workflows.json + +# ALL:同时包含所有指定标签 +jq --argjson want '["synthesis","organic"]' \ + '[.[] | select(($want | all(. as $w | (.tags // []) | index($w))))]' \ + all_workflows.json +``` + +--- + +## 筛选策略 + +agent 拿到用户的「领域 + 场景」自然语言描述时,按如下顺序选择 tag: + +1. **优先用户显式指定的 tags**:若用户明确给出标签词,直接精确匹配。 +2. **从 all_tags 中做语义映射**:若用户描述是自然语言(如"有机合成、柱层析"),在 all_tags 中找语义相关项(如 `synthesis`、`organic`、`chromatography`)。必要时展示候选 tag 让用户确认。 +3. **模式选择**: + - 默认 `any`(更多召回),给出 tag 集合的并集匹配 + - 用户强调"必须同时满足"时用 `all` +4. **空结果兜底**:如果筛选为空,放宽条件(去掉最稀有 tag、切换 any 模式),并提醒用户。 + +--- + +## 引导到明确的 Protocol + +筛选完成后,**最终目标是让用户确认一个具体的 workflow_uuid**,而不是停留在"一堆候选"上。按结果数量采取不同策略: + +| 结果数量 | 策略 | +| --------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| 0 条 | 放宽筛选(去掉最稀有 tag → 切换 any 模式 → 去掉 `--published-only`)。仍为空则提示换关键词,或列出 `all_tags` 让用户重新选。 | +| 1 条 | 直接确认:"找到唯一匹配:`` (uuid ``),是否用它?"用户确认后记录 `workflow_uuid`。 | +| 2–10 条 | 编号列表展示,让用户选编号。每项给出 name、tags、description 摘要、published 状态。 | +| 10–30 条 | 先展示 tag 分布帮助用户进一步收紧:列出匹配结果中最常见的子标签,提示"加一个 tag 可将结果缩小到 N 条"。 | +| >30 条 | 强制要求用户补充条件:仅 published、指定具体 tag 组合、或按名称关键词过滤。 | + +**确认 workflow 后**: + +1. 将 `workflow_uuid` 写入 session state +2. 提示用户下一步可用的 skill: + - 提交实验 → `batch-submit-experiment` + - 查看 workflow 详细节点 → `GET /api/v1/lab/workflow/template/detail/` +3. 若用户想换一个,回到筛选步骤。 + +--- + +## 展示结果 + +推荐格式(表格 + 汇总统计): + +``` +共 150 个工作流,其中 32 个匹配筛选条件 [tags: synthesis OR organic] + +| UUID (短) | 名称 | Tags | 已发布 | +|-----------|--------------------------|------------------------------|--------| +| e0436638 | Synthesis v2 | synthesis, organic | ✓ | +| 5b60dbb8 | Grignard Protocol | synthesis, organometallic | ✓ | +| ... | ... | ... | ... | + +所有可用标签(按频次): + synthesis (12), organic (8), analysis (5), purification (4), ... +``` + +如果用户下一步想执行某工作流 → 引导到 **batch-submit-experiment** skill。 + +--- + +## 常见问题 + +### Q: tags 为 null 的工作流要不要展示? + +默认**不展示**在筛选结果中(因为无法按 tag 匹配)。但在 `--summary-only` 或无筛选条件时,这些工作流仍会计入总数,并在输出中单独列出"未打标签"计数。 + +### Q: 如何按名称/描述做模糊匹配? + +脚本未内置,但可在 jq 中组合: + +```bash +jq '[.[] | select((.name + " " + (.description // "")) | test("organic"; "i"))]' all_workflows.json +``` + +### Q: `page_size=1000` 是否会被服务端限制? + +接口通常允许最大 1000;如果返回量少于 1000 且 `has_more=false`,说明已到末页。极端情况下若服务端返回错误,可降到 200 或 500 再试。 + +### Q: 工作流数量极大(>10k)怎么办? + +1. 先跑 `--summary-only` 了解 tag 分布 +2. 提示用户先限定 `--published-only` 或指定 tag +3. 考虑将 `all_workflows.json` 缓存到本地,下次直接复用 + +--- + +## 与其他 Skill 的协作 + +- 正常情况下,找到 workflow 之后可以直接用它提交实验(启动工作流的 api 端点 POST $BASE/api/v1/lab/workflow//run,不用别的 skill) +- **仅当需要进行多次实验时,使用 batch-submit-experiment** — 筛选到目标工作流后,`workflow_uuid` 直接用于实验提交 + +## 脚本依赖 + +`scripts/filter_workflows.py` 仅使用 Python 标准库(`urllib`、`json`、`argparse`),无需额外安装。 diff --git a/.cursor/skills/filter-workflow-by-tags/scripts/filter_workflows.py b/.cursor/skills/filter-workflow-by-tags/scripts/filter_workflows.py new file mode 100755 index 000000000..87bb0b1cf --- /dev/null +++ b/.cursor/skills/filter-workflow-by-tags/scripts/filter_workflows.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""分页拉取 Uni-Lab 工作流列表,汇总 tags 并按 tag 筛选。 + +使用示例: + python filter_workflows.py \ + --auth \ + --base https://leap-lab.test.bohrium.com \ + --lab-uuid a9059772-... \ + --tags synthesis organic --mode any + +仅依赖 Python 标准库。 +""" + +from __future__ import annotations + +import argparse +import json +import sys +import urllib.error +import urllib.parse +import urllib.request +from collections import Counter + + +def fetch_all_workflows(base: str, auth_token: str, lab_uuid: str, page_size: int = 1000) -> list[dict]: + """分页拉取所有 owner 工作流,直到 has_more=false。""" + workflows: list[dict] = [] + page = 1 + while True: + query = urllib.parse.urlencode( + {"page": page, "page_size": page_size, "lab_uuid": lab_uuid} + ) + url = f"{base.rstrip('/')}/api/v1/lab/workflow/owner/list?{query}" + req = urllib.request.Request( + url, + headers={ + "Authorization": f"Lab {auth_token}", + "Accept": "application/json", + }, + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + payload = json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + sys.exit(f"[ERROR] HTTP {e.code} on page {page}: {e.read().decode('utf-8', 'ignore')}") + except urllib.error.URLError as e: + sys.exit(f"[ERROR] URL error on page {page}: {e.reason}") + + if payload.get("code") != 0: + sys.exit(f"[ERROR] API returned non-zero code: {payload}") + + data = payload.get("data") or {} + page_items = data.get("data") or [] + workflows.extend(page_items) + + if not data.get("has_more"): + break + page += 1 + # 防御性兜底,避免接口异常导致无限循环 + if page > 1000: + print(f"[WARN] page count exceeded 1000, stopping early", file=sys.stderr) + break + + return workflows + + +def aggregate_tags(workflows: list[dict]) -> tuple[list[str], dict[str, int], int]: + """返回 (sorted_tags, tag_counts, untagged_count)。""" + counter: Counter[str] = Counter() + untagged = 0 + for wf in workflows: + tags = wf.get("tags") + if not tags: + untagged += 1 + continue + for t in tags: + if isinstance(t, str) and t.strip(): + counter[t.strip()] += 1 + return sorted(counter.keys()), dict(counter), untagged + + +def filter_workflows( + workflows: list[dict], + want_tags: list[str], + mode: str, + published_only: bool, +) -> list[dict]: + """按 tag 筛选。mode 取值 any / all。""" + want_set = {t.strip() for t in want_tags if t.strip()} + out: list[dict] = [] + for wf in workflows: + if published_only and not wf.get("published"): + continue + if not want_set: + out.append(wf) + continue + tags = wf.get("tags") or [] + tag_set = {t for t in tags if isinstance(t, str)} + if mode == "all": + if want_set.issubset(tag_set): + out.append(wf) + else: # any + if want_set & tag_set: + out.append(wf) + return out + + +def project_workflow(wf: dict) -> dict: + """精简输出字段。""" + return { + "uuid": wf.get("uuid"), + "name": wf.get("name"), + "description": wf.get("description", ""), + "tags": wf.get("tags") or [], + "published": bool(wf.get("published")), + "user_id": wf.get("user_id"), + } + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description="Fetch & filter Uni-Lab workflows by tags.") + p.add_argument("--auth", required=True, help="Base64 token (the part after `Lab `).") + p.add_argument("--base", required=True, help="Base URL, e.g. https://leap-lab.test.bohrium.com") + p.add_argument("--lab-uuid", required=True, help="Lab UUID.") + p.add_argument("--tags", nargs="*", default=[], help="Tags to filter by (space separated).") + p.add_argument( + "--mode", + choices=["any", "all"], + default="any", + help="any: workflow contains at least one tag; all: workflow contains every tag.", + ) + p.add_argument("--published-only", action="store_true", help="Only include published workflows.") + p.add_argument("--page-size", type=int, default=1000, help="Page size, default 1000.") + p.add_argument( + "--summary-only", + action="store_true", + help="Print tag summary without applying filter (still fetches everything).", + ) + p.add_argument("--output", help="Write JSON result to this path. If omitted, print to stdout.") + return p.parse_args() + + +def main() -> None: + args = parse_args() + workflows = fetch_all_workflows( + base=args.base, + auth_token=args.auth, + lab_uuid=args.lab_uuid, + page_size=args.page_size, + ) + sorted_tags, tag_counts, untagged = aggregate_tags(workflows) + + if args.summary_only: + result = { + "total_workflows": len(workflows), + "untagged_count": untagged, + "tag_counts": tag_counts, + "all_tags": sorted_tags, + } + else: + filtered = filter_workflows( + workflows, + want_tags=args.tags, + mode=args.mode, + published_only=args.published_only, + ) + result = { + "total_workflows": len(workflows), + "untagged_count": untagged, + "tag_counts": tag_counts, + "all_tags": sorted_tags, + "filter": { + "tags": args.tags, + "mode": args.mode, + "published_only": args.published_only, + }, + "matched_count": len(filtered), + "filtered_workflows": [project_workflow(wf) for wf in filtered], + } + + payload = json.dumps(result, ensure_ascii=False, indent=2) + if args.output: + with open(args.output, "w", encoding="utf-8") as f: + f.write(payload) + print(f"Wrote {len(workflows)} workflows summary → {args.output}", file=sys.stderr) + else: + print(payload) + + +if __name__ == "__main__": + main() From e3d2781cb6acddedb1a4e08b633337a905130a1b Mon Sep 17 00:00:00 2001 From: Junhan Chang Date: Fri, 8 May 2026 00:08:04 +0800 Subject: [PATCH 25/56] Update SKILL.md --- .cursor/skills/filter-workflow-by-tags/SKILL.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.cursor/skills/filter-workflow-by-tags/SKILL.md b/.cursor/skills/filter-workflow-by-tags/SKILL.md index b5c54a2d1..6cedd7c49 100644 --- a/.cursor/skills/filter-workflow-by-tags/SKILL.md +++ b/.cursor/skills/filter-workflow-by-tags/SKILL.md @@ -15,7 +15,7 @@ description: Query backend workflow list, aggregate all tags, and filter workflo 1. **识别场景关键词** → 映射到可能的 tags(如 synthesis、organic、chromatography、purification) 2. **直接执行完整流程**(获取 ak/sk/addr → 拉取所有工作流 → 汇总 tags → 按场景筛选) 3. **展示筛选结果** → 引导用户从候选 workflow 中**选择明确的实验 protocol** -4. **如果用户确认某个 workflow** → 记录 `workflow_uuid`,准备对接 `batch-submit-experiment` skill +4. **如果用户确认某个 workflow** → 记录 `workflow_uuid`,准备对接“与其他 Skill 的协作” **如果用户未给场景目标**,则按标准 checklist 询问筛选条件。 @@ -248,7 +248,7 @@ Task Progress: - 若结果 2–10 条 → 让用户按编号选择 - 若结果过多 → 提示收紧条件(加 tag、切换 all 模式、仅 published) - 若结果为空 → 放宽条件(去掉最稀有 tag)或提示用户换关键词 -- [ ] Step 10: 记录用户选中的 workflow_uuid,并提示可交给 `batch-submit-experiment` skill 提交实验 +- [ ] Step 10: 记录用户选中的 workflow_uuid,并提示提交实验或查看详情 ``` --- @@ -387,7 +387,7 @@ agent 拿到用户的「领域 + 场景」自然语言描述时,按如下顺 1. 将 `workflow_uuid` 写入 session state 2. 提示用户下一步可用的 skill: - - 提交实验 → `batch-submit-experiment` + - 提交实验 → 引导到“与其他 Skill 的协作” - 查看 workflow 详细节点 → `GET /api/v1/lab/workflow/template/detail/` 3. 若用户想换一个,回到筛选步骤。 @@ -410,7 +410,7 @@ agent 拿到用户的「领域 + 场景」自然语言描述时,按如下顺 synthesis (12), organic (8), analysis (5), purification (4), ... ``` -如果用户下一步想执行某工作流 → 引导到 **batch-submit-experiment** skill。 +如果用户下一步想执行某工作流 → 引导到“与其他 Skill 的协作”。 --- From 3dac0714505d3ba101999e3e3f54c2f152222d43 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Fri, 8 May 2026 23:49:32 +0800 Subject: [PATCH 26/56] fix pack build 1 --- .github/workflows/conda-pack-build.yml | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/.github/workflows/conda-pack-build.yml b/.github/workflows/conda-pack-build.yml index ed45db9d4..93c534089 100644 --- a/.github/workflows/conda-pack-build.yml +++ b/.github/workflows/conda-pack-build.yml @@ -89,10 +89,10 @@ jobs: echo Build full: ${{ github.event.inputs.build_full }} if "${{ github.event.inputs.build_full }}"=="true" ( echo Installing unilabos-full ^(complete package^)... - mamba install -n unilab uni-lab::unilabos-full conda-pack -c uni-lab -c robostack-staging -c conda-forge -y + mamba install -n unilab uni-lab::unilabos-full conda-pack zstandard -c uni-lab -c robostack-staging -c conda-forge -y ) else ( echo Installing unilabos ^(minimal package^)... - mamba install -n unilab uni-lab::unilabos conda-pack -c uni-lab -c robostack-staging -c conda-forge -y + mamba install -n unilab uni-lab::unilabos conda-pack zstandard -c uni-lab -c robostack-staging -c conda-forge -y ) - name: Install conda-pack, unilabos and dependencies (Unix) @@ -104,10 +104,10 @@ jobs: echo "Build full: ${{ github.event.inputs.build_full }}" if [[ "${{ github.event.inputs.build_full }}" == "true" ]]; then echo "Installing unilabos-full (complete package)..." - mamba install -n unilab uni-lab::unilabos-full conda-pack -c uni-lab -c robostack-staging -c conda-forge -y + mamba install -n unilab uni-lab::unilabos-full conda-pack zstandard -c uni-lab -c robostack-staging -c conda-forge -y else echo "Installing unilabos (minimal package)..." - mamba install -n unilab uni-lab::unilabos conda-pack -c uni-lab -c robostack-staging -c conda-forge -y + mamba install -n unilab uni-lab::unilabos conda-pack zstandard -c uni-lab -c robostack-staging -c conda-forge -y fi - name: Get latest ros-humble-unilabos-msgs version (Windows) @@ -226,7 +226,9 @@ jobs: if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64' run: | echo Packing unilab environment with conda-pack... - mamba activate unilab && conda pack -n unilab -o unilab-env-${{ matrix.platform }}.tar.gz --ignore-missing-files + for /f "delims=" %%i in ('mamba run -n unilab python -c "import os; print(os.environ['CONDA_PREFIX'])"') do set "UNILAB_PREFIX=%%i" + echo Packing environment at: %UNILAB_PREFIX% + mamba run -n unilab conda-pack -p "%UNILAB_PREFIX%" -o unilab-env-${{ matrix.platform }}.tar.gz --ignore-missing-files echo Pack file created: dir unilab-env-${{ matrix.platform }}.tar.gz @@ -235,8 +237,9 @@ jobs: shell: bash run: | echo "Packing unilab environment with conda-pack..." - mamba install conda-pack -c conda-forge -y - conda pack -n unilab -o unilab-env-${{ matrix.platform }}.tar.gz --ignore-missing-files + UNILAB_PREFIX="$(mamba run -n unilab python -c 'import os; print(os.environ["CONDA_PREFIX"])')" + echo "Packing environment at: $UNILAB_PREFIX" + mamba run -n unilab conda-pack -p "$UNILAB_PREFIX" -o unilab-env-${{ matrix.platform }}.tar.gz --ignore-missing-files echo "Pack file created:" ls -lh unilab-env-${{ matrix.platform }}.tar.gz From f96f3b82e362feb671df67b9519c27aa53b44555 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Fri, 8 May 2026 23:50:00 +0800 Subject: [PATCH 27/56] fix pip install & git install failed --- unilabos/utils/environment_check.py | 123 +++++++++++++++++++++------- 1 file changed, 92 insertions(+), 31 deletions(-) diff --git a/unilabos/utils/environment_check.py b/unilabos/utils/environment_check.py index 18b5f1583..e3631fa38 100644 --- a/unilabos/utils/environment_check.py +++ b/unilabos/utils/environment_check.py @@ -33,10 +33,76 @@ def _is_chinese_locale() -> bool: def _has_uv() -> bool: global _USE_UV if _USE_UV is None: - _USE_UV = shutil.which("uv") is not None + uv_path = shutil.which("uv") + if not uv_path: + _USE_UV = False + else: + try: + result = subprocess.run([uv_path, "--version"], capture_output=True, text=True, timeout=10) + _USE_UV = result.returncode == 0 + except Exception: + _USE_UV = False return _USE_UV +def _install_command(installer: str, package: str, upgrade: bool, is_chinese: bool) -> List[str]: + if installer == "uv": + cmd = ["uv", "pip", "install"] + if upgrade: + cmd.append("--upgrade") + cmd.append(package) + if is_chinese: + cmd.extend(["--index-url", "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"]) + return cmd + + cmd = [sys.executable, "-m", "pip", "install", "--disable-pip-version-check"] + if upgrade: + cmd.append("--upgrade") + cmd.append(package) + if is_chinese: + cmd.extend(["-i", "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"]) + return cmd + + +def _installer_candidates() -> List[str]: + installers: List[str] = [] + if _has_uv(): + installers.append("uv") + installers.append("pip") + return installers + + +def _git_url_from_requirement(requirement: str) -> Optional[str]: + if not requirement.startswith("git+"): + return None + return requirement[4:].split("#", 1)[0] + + +def _repo_dir_name(git_url: str) -> str: + repo_name = git_url.rstrip("/").rsplit("/", 1)[-1] + return repo_name[:-4] if repo_name.endswith(".git") else repo_name + + +def _print_manual_git_install_hint(requirement: str) -> None: + git_url = _git_url_from_requirement(requirement) + if not git_url: + return + + repo_dir = _repo_dir_name(git_url) + install_cmd = "uv pip install -e ." if _has_uv() else f"{sys.executable} -m pip install -e ." + if _is_chinese_locale() and not _has_uv(): + install_cmd += " -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" + + print_status("Git 依赖自动安装失败,通常是网络连接被重置或代码托管站点暂时不可达。", "warning") + print_status("可以手动拉取代码后在本地安装:", "warning") + print_status(f" git clone {git_url}", "warning") + print_status(f" cd {repo_dir}", "warning") + print_status(" git pull", "warning") + print_status(f" {install_cmd}", "warning") + print_status(f"如果目录 {repo_dir} 已存在,直接进入该目录执行 git pull 后再安装。", "warning") + print_status("如果 git clone 仍失败,请切换网络/代理,或从浏览器下载源码后进入源码目录执行本地安装命令。", "warning") + + def _install_packages( packages: List[str], upgrade: bool = False, @@ -53,7 +119,7 @@ def _install_packages( return True is_chinese = _is_chinese_locale() - use_uv = _has_uv() + installers = _installer_candidates() failed: List[str] = [] for pkg in packages: @@ -63,35 +129,30 @@ def _install_packages( else: print_status(f"正在{action_word} {pkg}...", "info") - if use_uv: - cmd = ["uv", "pip", "install"] - if upgrade: - cmd.append("--upgrade") - cmd.append(pkg) - if is_chinese: - cmd.extend(["--index-url", "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"]) - else: - cmd = [sys.executable, "-m", "pip", "install"] - if upgrade: - cmd.append("--upgrade") - cmd.append(pkg) - if is_chinese: - cmd.extend(["-i", "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"]) - - try: - result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) - if result.returncode == 0: - installer = "uv" if use_uv else "pip" - print_status(f"✓ {pkg} {action_word}成功 (via {installer})", "success") - else: - stderr_short = result.stderr.strip().split("\n")[-1] if result.stderr else "unknown error" - print_status(f"× {pkg} {action_word}失败: {stderr_short}", "error") - failed.append(pkg) - except subprocess.TimeoutExpired: - print_status(f"× {pkg} {action_word}超时 (300s)", "error") - failed.append(pkg) - except Exception as e: - print_status(f"× {pkg} {action_word}异常: {e}", "error") + pkg_installed = False + last_error = "unknown error" + + for installer in installers: + cmd = _install_command(installer, pkg, upgrade, is_chinese) + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + if result.returncode == 0: + print_status(f"✓ {pkg} {action_word}成功 (via {installer})", "success") + pkg_installed = True + break + + last_error = result.stderr.strip().split("\n")[-1] if result.stderr else "unknown error" + print_status(f"× {pkg} {action_word}失败 (via {installer}): {last_error}", "warning") + except subprocess.TimeoutExpired: + last_error = "timeout after 300s" + print_status(f"× {pkg} {action_word}超时 (via {installer}, 300s)", "warning") + except Exception as e: + last_error = str(e) + print_status(f"× {pkg} {action_word}异常 (via {installer}): {e}", "warning") + + if not pkg_installed: + print_status(f"× {pkg} {action_word}失败: {last_error}", "error") + _print_manual_git_install_hint(pkg) failed.append(pkg) if failed: From f3c9ef2df8884b394f83b8106572c580490f76ba Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Sat, 9 May 2026 01:22:42 +0800 Subject: [PATCH 28/56] fix pack install 2 --- .github/workflows/ci-check.yml | 2 +- .github/workflows/conda-pack-build.yml | 68 +++++++++++++--------- .github/workflows/deploy-docs.yml | 4 +- .github/workflows/multi-platform-build.yml | 22 +++++-- .github/workflows/unilabos-conda-build.yml | 57 ++++++++++-------- recipes/conda_build_config.yaml | 2 +- 6 files changed, 95 insertions(+), 60 deletions(-) diff --git a/.github/workflows/ci-check.yml b/.github/workflows/ci-check.yml index 402edc26f..698344bf7 100644 --- a/.github/workflows/ci-check.yml +++ b/.github/workflows/ci-check.yml @@ -38,7 +38,7 @@ jobs: - name: Install ROS dependencies, uv and unilabos-msgs run: | echo Installing ROS dependencies... - mamba install -n check-env conda-forge::uv conda-forge::opencv robostack-staging::ros-humble-ros-core robostack-staging::ros-humble-action-msgs robostack-staging::ros-humble-std-msgs robostack-staging::ros-humble-geometry-msgs robostack-staging::ros-humble-control-msgs robostack-staging::ros-humble-nav2-msgs uni-lab::ros-humble-unilabos-msgs robostack-staging::ros-humble-cv-bridge robostack-staging::ros-humble-vision-opencv robostack-staging::ros-humble-tf-transformations robostack-staging::ros-humble-moveit-msgs robostack-staging::ros-humble-tf2-ros robostack-staging::ros-humble-tf2-ros-py conda-forge::transforms3d -c robostack-staging -c conda-forge -c uni-lab -y + mamba install -n check-env --override-channels -c robostack-staging -c conda-forge -c uni-lab conda-forge::uv conda-forge::opencv robostack-staging::ros-humble-ros-core robostack-staging::ros-humble-action-msgs robostack-staging::ros-humble-std-msgs robostack-staging::ros-humble-geometry-msgs robostack-staging::ros-humble-control-msgs robostack-staging::ros-humble-nav2-msgs uni-lab::ros-humble-unilabos-msgs robostack-staging::ros-humble-cv-bridge robostack-staging::ros-humble-vision-opencv robostack-staging::ros-humble-tf-transformations robostack-staging::ros-humble-moveit-msgs robostack-staging::ros-humble-tf2-ros robostack-staging::ros-humble-tf2-ros-py conda-forge::transforms3d -y - name: Install pip dependencies and unilabos run: | diff --git a/.github/workflows/conda-pack-build.yml b/.github/workflows/conda-pack-build.yml index 93c534089..3da148dd6 100644 --- a/.github/workflows/conda-pack-build.yml +++ b/.github/workflows/conda-pack-build.yml @@ -1,6 +1,10 @@ name: Build Conda-Pack Environment on: + # 在 UniLabOS Conda Build 成功上传后自动构建非全量 conda-pack + workflow_run: + workflows: ["UniLabOS Conda Build"] + types: [completed] workflow_dispatch: inputs: branch: @@ -21,6 +25,16 @@ on: jobs: build-conda-pack: + if: | + github.event_name == 'workflow_dispatch' || + ( + github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'workflow_run' + ) + env: + BUILD_FULL: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' }} + PACKAGE_REF: ${{ github.event.inputs.branch || github.event.workflow_run.head_sha || github.ref_name }} strategy: fail-fast: false matrix: @@ -54,7 +68,9 @@ jobs: id: should_build shell: bash run: | - if [[ -z "${{ github.event.inputs.platforms }}" ]]; then + if [[ "${{ github.event_name }}" != "workflow_dispatch" ]]; then + echo "should_build=true" >> $GITHUB_OUTPUT + elif [[ -z "${{ github.event.inputs.platforms }}" ]]; then echo "should_build=true" >> $GITHUB_OUTPUT elif [[ "${{ github.event.inputs.platforms }}" == *"${{ matrix.platform }}"* ]]; then echo "should_build=true" >> $GITHUB_OUTPUT @@ -65,7 +81,7 @@ jobs: - uses: actions/checkout@v6 if: steps.should_build.outputs.should_build == 'true' with: - ref: ${{ github.event.inputs.branch }} + ref: ${{ github.event.inputs.branch || github.event.workflow_run.head_sha || github.ref }} fetch-depth: 0 - name: Setup Miniforge (with mamba) @@ -75,7 +91,7 @@ jobs: miniforge-version: latest use-mamba: true python-version: '3.11.14' - channels: conda-forge,robostack-staging,uni-lab,defaults + channels: conda-forge,robostack-staging,uni-lab channel-priority: flexible activate-environment: unilab auto-update-conda: false @@ -86,13 +102,13 @@ jobs: run: | echo Installing unilabos and dependencies to unilab environment... echo Using mamba for faster and more reliable dependency resolution... - echo Build full: ${{ github.event.inputs.build_full }} - if "${{ github.event.inputs.build_full }}"=="true" ( + echo Build full: ${{ env.BUILD_FULL }} + if "${{ env.BUILD_FULL }}"=="true" ( echo Installing unilabos-full ^(complete package^)... - mamba install -n unilab uni-lab::unilabos-full conda-pack zstandard -c uni-lab -c robostack-staging -c conda-forge -y + mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos-full conda-pack zstandard -y ) else ( echo Installing unilabos ^(minimal package^)... - mamba install -n unilab uni-lab::unilabos conda-pack zstandard -c uni-lab -c robostack-staging -c conda-forge -y + mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos conda-pack zstandard -y ) - name: Install conda-pack, unilabos and dependencies (Unix) @@ -101,13 +117,13 @@ jobs: run: | echo "Installing unilabos and dependencies to unilab environment..." echo "Using mamba for faster and more reliable dependency resolution..." - echo "Build full: ${{ github.event.inputs.build_full }}" - if [[ "${{ github.event.inputs.build_full }}" == "true" ]]; then + echo "Build full: ${{ env.BUILD_FULL }}" + if [[ "${{ env.BUILD_FULL }}" == "true" ]]; then echo "Installing unilabos-full (complete package)..." - mamba install -n unilab uni-lab::unilabos-full conda-pack zstandard -c uni-lab -c robostack-staging -c conda-forge -y + mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos-full conda-pack zstandard -y else echo "Installing unilabos (minimal package)..." - mamba install -n unilab uni-lab::unilabos conda-pack zstandard -c uni-lab -c robostack-staging -c conda-forge -y + mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos conda-pack zstandard -y fi - name: Get latest ros-humble-unilabos-msgs version (Windows) @@ -134,27 +150,27 @@ jobs: if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64' run: | echo Checking for available ros-humble-unilabos-msgs versions... - mamba search ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge || echo Search completed + mamba search --override-channels -c uni-lab -c robostack-staging -c conda-forge ros-humble-unilabos-msgs || echo Search completed echo. echo Updating ros-humble-unilabos-msgs to latest version... - mamba update -n unilab ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge -y || echo Already at latest version + mamba update -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge ros-humble-unilabos-msgs -y || echo Already at latest version - name: Check for newer ros-humble-unilabos-msgs (Unix) if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64' shell: bash run: | echo "Checking for available ros-humble-unilabos-msgs versions..." - mamba search ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge || echo "Search completed" + mamba search --override-channels -c uni-lab -c robostack-staging -c conda-forge ros-humble-unilabos-msgs || echo "Search completed" echo "" echo "Updating ros-humble-unilabos-msgs to latest version..." - mamba update -n unilab ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge -y || echo "Already at latest version" + mamba update -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge ros-humble-unilabos-msgs -y || echo "Already at latest version" - name: Install latest unilabos from source (Windows) if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64' run: | echo Uninstalling existing unilabos... mamba run -n unilab pip uninstall unilabos -y || echo unilabos not installed via pip - echo Installing unilabos from source (branch: ${{ github.event.inputs.branch }})... + echo Installing unilabos from source (ref: ${{ env.PACKAGE_REF }})... mamba run -n unilab pip install . echo Verifying installation... mamba run -n unilab pip show unilabos @@ -165,7 +181,7 @@ jobs: run: | echo "Uninstalling existing unilabos..." mamba run -n unilab pip uninstall unilabos -y || echo "unilabos not installed via pip" - echo "Installing unilabos from source (branch: ${{ github.event.inputs.branch }})..." + echo "Installing unilabos from source (ref: ${{ env.PACKAGE_REF }})..." mamba run -n unilab pip install . echo "Verifying installation..." mamba run -n unilab pip show unilabos @@ -270,7 +286,7 @@ jobs: rem Create README using Python script echo Creating: README.txt - python scripts\create_readme.py ${{ matrix.platform }} ${{ github.event.inputs.branch }} dist-package\README.txt + python scripts\create_readme.py ${{ matrix.platform }} ${{ env.PACKAGE_REF }} dist-package\README.txt echo. echo Distribution package contents: @@ -306,7 +322,7 @@ jobs: # Create README using Python script echo "Creating: README.txt" - python scripts/create_readme.py ${{ matrix.platform }} ${{ github.event.inputs.branch }} dist-package/README.txt + python scripts/create_readme.py ${{ matrix.platform }} ${{ env.PACKAGE_REF }} dist-package/README.txt echo "" echo "Distribution package contents:" @@ -317,7 +333,7 @@ jobs: if: steps.should_build.outputs.should_build == 'true' uses: actions/upload-artifact@v6 with: - name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }} + name: unilab-pack-${{ matrix.platform }}-${{ env.PACKAGE_REF }} path: dist-package/ retention-days: 90 if-no-files-found: error @@ -329,9 +345,9 @@ jobs: echo Build Summary echo ========================================== echo Platform: ${{ matrix.platform }} - echo Branch: ${{ github.event.inputs.branch }} + echo Branch: ${{ env.PACKAGE_REF }} echo Python version: 3.11.14 - if "${{ github.event.inputs.build_full }}"=="true" ( + if "${{ env.BUILD_FULL }}"=="true" ( echo Package: unilabos-full ^(complete^) ) else ( echo Package: unilabos ^(minimal^) @@ -340,7 +356,7 @@ jobs: echo Distribution package contents: dir dist-package echo. - echo Artifact name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }} + echo Artifact name: unilab-pack-${{ matrix.platform }}-${{ env.PACKAGE_REF }} echo. echo After download, extract the ZIP and run: echo install_unilab.bat @@ -354,9 +370,9 @@ jobs: echo "Build Summary" echo "==========================================" echo "Platform: ${{ matrix.platform }}" - echo "Branch: ${{ github.event.inputs.branch }}" + echo "Branch: ${{ env.PACKAGE_REF }}" echo "Python version: 3.11.14" - if [[ "${{ github.event.inputs.build_full }}" == "true" ]]; then + if [[ "${{ env.BUILD_FULL }}" == "true" ]]; then echo "Package: unilabos-full (complete)" else echo "Package: unilabos (minimal)" @@ -365,7 +381,7 @@ jobs: echo "Distribution package contents:" ls -lh dist-package/ echo "" - echo "Artifact name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }}" + echo "Artifact name: unilab-pack-${{ matrix.platform }}-${{ env.PACKAGE_REF }}" echo "" echo "After download:" echo " install_unilab.sh" diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index f3ac4d11f..a3ca64693 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -56,7 +56,7 @@ jobs: miniforge-version: latest use-mamba: true python-version: '3.11.14' - channels: conda-forge,robostack-staging,uni-lab,defaults + channels: conda-forge,robostack-staging,uni-lab channel-priority: flexible activate-environment: unilab auto-update-conda: false @@ -66,7 +66,7 @@ jobs: run: | echo "Installing unilabos and dependencies to unilab environment..." echo "Using mamba for faster and more reliable dependency resolution..." - mamba install -n unilab uni-lab::unilabos -c uni-lab -c robostack-staging -c conda-forge -y + mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos -y - name: Install latest unilabos from source run: | diff --git a/.github/workflows/multi-platform-build.yml b/.github/workflows/multi-platform-build.yml index 4e1cf4f7b..1c5dd7572 100644 --- a/.github/workflows/multi-platform-build.yml +++ b/.github/workflows/multi-platform-build.yml @@ -10,6 +10,9 @@ on: # 支持 tag 推送(不依赖 CI Check) push: tags: ['v*'] + # GitHub Release 发布时自动构建并上传 + release: + types: [published] # 手动触发 workflow_dispatch: inputs: @@ -80,7 +83,7 @@ jobs: - uses: actions/checkout@v6 with: # 如果是 workflow_run 触发,使用触发 CI Check 的 commit - ref: ${{ github.event.workflow_run.head_sha || github.ref }} + ref: ${{ github.event.workflow_run.head_sha || github.event.release.tag_name || github.ref }} fetch-depth: 0 - name: Check if platform should be built @@ -96,12 +99,13 @@ jobs: echo "should_build=false" >> $GITHUB_OUTPUT fi - - name: Setup Miniconda + - name: Setup Miniforge if: steps.should_build.outputs.should_build == 'true' uses: conda-incubator/setup-miniconda@v3 with: - miniconda-version: 'latest' - channels: conda-forge,robostack-staging,defaults + miniforge-version: latest + use-mamba: true + channels: conda-forge,robostack-staging channel-priority: strict activate-environment: build-env auto-update-conda: false @@ -110,7 +114,7 @@ jobs: - name: Install rattler-build and anaconda-client if: steps.should_build.outputs.should_build == 'true' run: | - conda install -c conda-forge rattler-build anaconda-client + mamba install --override-channels -c conda-forge rattler-build anaconda-client -y - name: Show environment info if: steps.should_build.outputs.should_build == 'true' @@ -157,7 +161,13 @@ jobs: retention-days: 30 - name: Upload to Anaconda.org (unilab organization) - if: steps.should_build.outputs.should_build == 'true' && github.event.inputs.upload_to_anaconda == 'true' + if: | + steps.should_build.outputs.should_build == 'true' && + ( + github.event_name == 'release' || + startsWith(github.ref, 'refs/tags/') || + github.event.inputs.upload_to_anaconda == 'true' + ) run: | for package in $(find ./output -name "*.conda"); do echo "Uploading $package to unilab organization..." diff --git a/.github/workflows/unilabos-conda-build.yml b/.github/workflows/unilabos-conda-build.yml index d116a67ee..11025543d 100644 --- a/.github/workflows/unilabos-conda-build.yml +++ b/.github/workflows/unilabos-conda-build.yml @@ -1,14 +1,10 @@ name: UniLabOS Conda Build on: - # 在 CI Check 成功后自动触发 + # 在 Multi-Platform Conda Build 成功上传 msgs 后自动触发 workflow_run: - workflows: ["CI Check"] + workflows: ["Multi-Platform Conda Build"] types: [completed] - branches: [main, dev] - # 标签推送时直接触发(发布版本) - push: - tags: ['v*'] # 手动触发 workflow_dispatch: inputs: @@ -33,30 +29,30 @@ on: type: boolean jobs: - # 等待 CI Check 完成的 job (仅用于 workflow_run 触发) - wait-for-ci: + # 等待上游 msgs 构建完成的 job (仅用于 workflow_run 触发) + wait-for-upstream: runs-on: ubuntu-latest if: github.event_name == 'workflow_run' outputs: should_continue: ${{ steps.check.outputs.should_continue }} steps: - - name: Check CI status + - name: Check upstream workflow status id: check run: | - if [[ "${{ github.event.workflow_run.conclusion }}" == "success" ]]; then + if [[ "${{ github.event.workflow_run.conclusion }}" == "success" && ( "${{ github.event.workflow_run.event }}" == "release" || "${{ github.event.workflow_run.event }}" == "push" ) ]]; then echo "should_continue=true" >> $GITHUB_OUTPUT - echo "CI Check passed, proceeding with build" + echo "Multi-Platform Conda Build passed for release/tag, proceeding with UniLabOS build" else echo "should_continue=false" >> $GITHUB_OUTPUT - echo "CI Check did not succeed (status: ${{ github.event.workflow_run.conclusion }}), skipping build" + echo "Upstream workflow is not a successful release/tag build (status: ${{ github.event.workflow_run.conclusion }}, event: ${{ github.event.workflow_run.event }}), skipping build" fi build: - needs: [wait-for-ci] - # 运行条件:workflow_run 触发且 CI 成功,或者其他触发方式 + needs: [wait-for-upstream] + # 运行条件:workflow_run 触发且上游成功,或者手动触发 if: | always() && - (needs.wait-for-ci.result == 'skipped' || needs.wait-for-ci.outputs.should_continue == 'true') + (needs.wait-for-upstream.result == 'skipped' || needs.wait-for-upstream.outputs.should_continue == 'true') strategy: fail-fast: false matrix: @@ -79,7 +75,7 @@ jobs: steps: - uses: actions/checkout@v6 with: - # 如果是 workflow_run 触发,使用触发 CI Check 的 commit + # 如果是 workflow_run 触发,使用上游 conda 包构建的 commit ref: ${{ github.event.workflow_run.head_sha || github.ref }} fetch-depth: 0 @@ -96,12 +92,13 @@ jobs: echo "should_build=false" >> $GITHUB_OUTPUT fi - - name: Setup Miniconda + - name: Setup Miniforge if: steps.should_build.outputs.should_build == 'true' uses: conda-incubator/setup-miniconda@v3 with: - miniconda-version: 'latest' - channels: conda-forge,robostack-staging,uni-lab,defaults + miniforge-version: latest + use-mamba: true + channels: conda-forge,robostack-staging,uni-lab channel-priority: strict activate-environment: build-env auto-update-conda: false @@ -110,7 +107,7 @@ jobs: - name: Install rattler-build and anaconda-client if: steps.should_build.outputs.should_build == 'true' run: | - conda install -c conda-forge rattler-build anaconda-client + mamba install --override-channels -c conda-forge rattler-build anaconda-client -y - name: Show environment info if: steps.should_build.outputs.should_build == 'true' @@ -119,11 +116,11 @@ jobs: conda list | grep -E "(rattler-build|anaconda-client)" echo "Platform: ${{ matrix.platform }}" echo "OS: ${{ matrix.os }}" - echo "Build full package: ${{ github.event.inputs.build_full || 'false' }}" + echo "Build full package: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' }}" echo "Building packages:" echo " - unilabos-env (environment dependencies)" echo " - unilabos (with pip package)" - if [[ "${{ github.event.inputs.build_full }}" == "true" ]]; then + if [[ "${{ github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' }}" == "true" ]]; then echo " - unilabos-full (complete package)" fi @@ -134,7 +131,12 @@ jobs: rattler-build build -r .conda/environment/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge - name: Upload unilabos-env to Anaconda.org (if enabled) - if: steps.should_build.outputs.should_build == 'true' && github.event.inputs.upload_to_anaconda == 'true' + if: | + steps.should_build.outputs.should_build == 'true' && + ( + github.event_name == 'workflow_run' || + github.event.inputs.upload_to_anaconda == 'true' + ) run: | echo "Uploading unilabos-env to uni-lab organization..." for package in $(find ./output -name "unilabos-env*.conda"); do @@ -149,7 +151,12 @@ jobs: rattler-build build -r .conda/base/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge --channel ./output - name: Upload unilabos to Anaconda.org (if enabled) - if: steps.should_build.outputs.should_build == 'true' && github.event.inputs.upload_to_anaconda == 'true' + if: | + steps.should_build.outputs.should_build == 'true' && + ( + github.event_name == 'workflow_run' || + github.event.inputs.upload_to_anaconda == 'true' + ) run: | echo "Uploading unilabos to uni-lab organization..." for package in $(find ./output -name "unilabos-0*.conda" -o -name "unilabos-[0-9]*.conda"); do @@ -159,6 +166,7 @@ jobs: - name: Build unilabos-full - Only when explicitly requested if: | steps.should_build.outputs.should_build == 'true' && + github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' run: | echo "Building unilabos-full package on ${{ matrix.platform }}..." @@ -167,6 +175,7 @@ jobs: - name: Upload unilabos-full to Anaconda.org (if enabled) if: | steps.should_build.outputs.should_build == 'true' && + github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' && github.event.inputs.upload_to_anaconda == 'true' run: | diff --git a/recipes/conda_build_config.yaml b/recipes/conda_build_config.yaml index 8e95491c8..c8915207a 100644 --- a/recipes/conda_build_config.yaml +++ b/recipes/conda_build_config.yaml @@ -1,5 +1,5 @@ channel_sources: - - robostack,robostack-staging,conda-forge,defaults + - robostack,robostack-staging,conda-forge gazebo: - '11' From a035547252abc174a4abb13aff7fee60f3a5cc26 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Thu, 14 May 2026 18:18:53 +0800 Subject: [PATCH 29/56] env installation fix --- unilabos/app/utils.py | 165 ++++++++++++++++++++++++++-- unilabos/utils/environment_check.py | 11 +- 2 files changed, 162 insertions(+), 14 deletions(-) diff --git a/unilabos/app/utils.py b/unilabos/app/utils.py index f6114a13c..a225e3ae3 100644 --- a/unilabos/app/utils.py +++ b/unilabos/app/utils.py @@ -10,29 +10,170 @@ import sys +_PATCH_MARKER = "# UniLabOS DLL Patch" +_PATCH_END_MARKER = "# End UniLabOS DLL Patch" + +# 75 = EX_TEMPFAIL: 临时失败、重试即可,避免与业务退出码冲突 +_RESTART_EXIT_CODE = 75 + + +def _build_dll_patch(lib_bin: str, preload_pyd: str = "") -> str: + """生成一段加在目标文件顶部的 DLL 加载补丁源码。 + + - 始终把 ``lib_bin`` 加入 DLL 搜索路径,并把 handle 挂在模块属性上, + 防止 GC 清掉搜索路径(``os.add_dll_directory`` 的句柄被回收时 + 目录会被移除)。 + - 可选地用 ``ctypes.CDLL`` 预加载一个 .pyd,把它的依赖 DLL 提前装入 + 进程内存,作为 ``rclpy._rclpy_pybind11`` 这类首次加载点的兜底。 + """ + # 用 repr() 序列化路径:Python 解析 repr 的结果会还原成原始字符串, + # 不需要也不能再叠加 raw-string 前缀(叠了反而会让 \\ 变成两个反斜杠)。 + lines = [ + _PATCH_MARKER, + "import os as _ulab_os", + f"_ulab_p = {lib_bin!r}", + 'if hasattr(_ulab_os, "add_dll_directory") and _ulab_os.path.isdir(_ulab_p):', + " try: _UNILAB_DLL_HANDLE = _ulab_os.add_dll_directory(_ulab_p)", + " except Exception: _UNILAB_DLL_HANDLE = None", + ] + if preload_pyd: + lines.extend( + [ + "import ctypes as _ulab_ctypes", + f"try: _ulab_ctypes.CDLL({preload_pyd!r})", + "except Exception: pass", + ] + ) + lines.append(_PATCH_END_MARKER) + return "\n".join(lines) + "\n" + + +def _apply_dll_patch(file_path: str, lib_bin: str, preload_pyd: str = "") -> bool: + """把 DLL 补丁前置到 ``file_path``。文件不存在或已打过补丁则返回 False。""" + if not os.path.isfile(file_path): + return False + with open(file_path, "r", encoding="utf-8") as f: + content = f.read() + if _PATCH_MARKER in content: + return False + shutil.copy2(file_path, file_path + ".bak") + with open(file_path, "w", encoding="utf-8") as f: + f.write(_build_dll_patch(lib_bin, preload_pyd) + content) + return True + + +def _print_restart_banner(patched_files): + """打印重启提示并以 EX_TEMPFAIL 退出。 + + - 不使用 ANSI 颜色码:Windows 旧版 cmd / PowerShell 5 默认不开 VT 处理, + 会把 ``\\033[1;33m`` 当做字面字符显示,反而让用户看不到正文。 + - 同时写入 stderr 与 stdout:某些上层 launcher / supervisor 只重定向 + 其中一路,写两遍能保证用户至少看到一份。 + - 写入前防御性把流切到 UTF-8 with replace:``main.py`` 里已经做过一次, + 但本模块也可能被绕过 ``main.py`` 的代码路径直接 import;reconfigure + 失败也只是退回 errors=replace,不影响整体流程。 + """ + if sys.platform == "win32": + for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined] + except (AttributeError, OSError): + pass + + bar = "#" * 78 + files_lines = [f"[UniLabOS] - {p}" for p in patched_files] + body = "\n".join( + [ + "", + bar, + bar, + "##", + "## [UniLabOS] Windows + conda 下检测到 DLL 加载失败,已自动打补丁。", + "## [UniLabOS] DLL load failure detected on Windows + conda;", + "## [UniLabOS] the following files have been auto-patched:", + "##", + *[f"## {line}" for line in files_lines], + "##", + "## [UniLabOS] 当前进程的 rclpy 状态已损坏,补丁需要在新进程才生效。", + "## [UniLabOS] The current process is unusable; the patch only takes", + "## [UniLabOS] effect on a fresh process.", + "##", + "## >>> 请重新运行刚才的命令 / Please re-run the same command. <<<", + "##", + bar, + bar, + "", + ] + ) + + for stream in (sys.stderr, sys.stdout): + try: + stream.write(body) + stream.flush() + except Exception: + try: + print(body, file=stream) + except Exception: + pass + + sys.exit(_RESTART_EXIT_CODE) + + def patch_rclpy_dll_windows(): - """在 Windows + conda 环境下为 rclpy 打 DLL 加载补丁""" + """在 Windows + conda 环境下修复 rclpy / rosidl typesupport 的 DLL 加载。 + + 背景:conda 安装的 ros 系列包,其原生扩展依赖 ``$CONDA_PREFIX/Library/bin`` + 下的 DLL;只有 conda 环境被正确激活、且 PATH 中含 ``Library/bin`` 时, + ``os.add_dll_directory`` 才能找到它们。当从快捷方式 / IDE / 子进程 / + 没激活的 shell 启动 ``unilab`` 时,会出现 ``DLL load failed``。 + + 本函数会: + 1) 修补 ``rclpy/impl/implementation_singleton.py`` —— rclpy 自身的 C 扩展入口; + 2) 修补 ``rpyutils/add_dll_directories.py`` —— 所有 ``*_s__rosidl_typesupport_c.pyd`` + (``geometry_msgs`` / ``std_msgs`` / ``sensor_msgs`` 等)的统一加载入口。 + + 打完补丁后**必须重启进程**才能生效(当前进程的 rclpy 已经发生过 + ``ImportError``,子模块仍处于损坏状态)。因此函数会主动退出,并在 + stdout/stderr 同时打印明显的重启提示,避免用户被后续报错淹没。 + """ if sys.platform != "win32" or not os.environ.get("CONDA_PREFIX"): return + try: - import rclpy + import rclpy # noqa: F401 return except ImportError as e: if not str(e).startswith("DLL load failed"): return + cp = os.environ["CONDA_PREFIX"] - impl = os.path.join(cp, "Lib", "site-packages", "rclpy", "impl", "implementation_singleton.py") - pyd = glob.glob(os.path.join(cp, "Lib", "site-packages", "rclpy", "_rclpy_pybind11*.pyd")) - if not os.path.exists(impl) or not pyd: + lib_bin = os.path.join(cp, "Library", "bin") + site_packages = os.path.join(cp, "Lib", "site-packages") + if not os.path.isdir(lib_bin): return - with open(impl, "r", encoding="utf-8") as f: - content = f.read() - lib_bin = os.path.join(cp, "Library", "bin").replace("\\", "/") - patch = f'# UniLabOS DLL Patch\nimport os,ctypes\nos.add_dll_directory("{lib_bin}") if hasattr(os,"add_dll_directory") else None\ntry: ctypes.CDLL("{pyd[0].replace(chr(92),"/")}")\nexcept: pass\n# End Patch\n' - shutil.copy2(impl, impl + ".bak") - with open(impl, "w", encoding="utf-8") as f: - f.write(patch + content) + + patched = [] + + # 1) rclpy 自身的入口 + rclpy_impl = os.path.join(site_packages, "rclpy", "impl", "implementation_singleton.py") + rclpy_pyd_matches = glob.glob(os.path.join(site_packages, "rclpy", "_rclpy_pybind11*.pyd")) + rclpy_pyd = rclpy_pyd_matches[0] if rclpy_pyd_matches else "" + if rclpy_pyd and _apply_dll_patch(rclpy_impl, lib_bin, preload_pyd=rclpy_pyd): + patched.append(rclpy_impl) + + # 2) rpyutils —— 所有 rosidl typesupport pyd 的加载点;放在 rclpy 之后 + # 例:geometry_msgs/geometry_msgs_s__rosidl_typesupport_c.pyd + rpyutils_dll = os.path.join(site_packages, "rpyutils", "add_dll_directories.py") + if _apply_dll_patch(rpyutils_dll, lib_bin): + patched.append(rpyutils_dll) + + if not patched: + # 已经打过补丁但 rclpy 仍然加载失败:原因不是缺 DLL 搜索路径, + # 不要再次打补丁污染文件,让上层看到真实的 ImportError。 + return + + _print_restart_banner(patched) patch_rclpy_dll_windows() diff --git a/unilabos/utils/environment_check.py b/unilabos/utils/environment_check.py index e3631fa38..5dcff22f9 100644 --- a/unilabos/utils/environment_check.py +++ b/unilabos/utils/environment_check.py @@ -47,7 +47,10 @@ def _has_uv() -> bool: def _install_command(installer: str, package: str, upgrade: bool, is_chinese: bool) -> List[str]: if installer == "uv": - cmd = ["uv", "pip", "install"] + # uv >= 0.5 默认要求虚拟环境,对 conda env 会报 "No virtual environment found"。 + # 显式 --python sys.executable 让 uv 把当前解释器(conda/venv/system 都行) + # 视为目标环境,绕开 venv 检测。 + cmd = ["uv", "pip", "install", "--python", sys.executable] if upgrade: cmd.append("--upgrade") cmd.append(package) @@ -89,7 +92,11 @@ def _print_manual_git_install_hint(requirement: str) -> None: return repo_dir = _repo_dir_name(git_url) - install_cmd = "uv pip install -e ." if _has_uv() else f"{sys.executable} -m pip install -e ." + install_cmd = ( + f'uv pip install --python "{sys.executable}" -e .' + if _has_uv() + else f"{sys.executable} -m pip install -e ." + ) if _is_chinese_locale() and not _has_uv(): install_cmd += " -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" From 490dc586b31dbb325ef5877e2168fa153e78c16a Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Thu, 14 May 2026 18:21:43 +0800 Subject: [PATCH 30/56] v0.11.2 (cherry picked from commit bcb179089739d6cd5a04ae23e43d32de271a98c9) --- .conda/base/recipe.yaml | 4 ++-- .conda/environment/recipe.yaml | 2 +- .conda/full/recipe.yaml | 4 ++-- recipes/msgs/recipe.yaml | 2 +- recipes/unilabos/recipe.yaml | 2 +- setup.py | 2 +- unilabos/__init__.py | 2 +- unilabos_msgs/package.xml | 2 +- 8 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.conda/base/recipe.yaml b/.conda/base/recipe.yaml index e37e3ab17..2916af79b 100644 --- a/.conda/base/recipe.yaml +++ b/.conda/base/recipe.yaml @@ -3,7 +3,7 @@ package: name: unilabos - version: 0.11.1 + version: 0.11.2 source: path: ../../unilabos @@ -54,7 +54,7 @@ requirements: - pymodbus - matplotlib - pylibftdi - - uni-lab::unilabos-env ==0.11.1 + - uni-lab::unilabos-env ==0.11.2 about: repository: https://github.com/deepmodeling/Uni-Lab-OS diff --git a/.conda/environment/recipe.yaml b/.conda/environment/recipe.yaml index 13ee9f88e..4ee6b75c7 100644 --- a/.conda/environment/recipe.yaml +++ b/.conda/environment/recipe.yaml @@ -2,7 +2,7 @@ package: name: unilabos-env - version: 0.11.1 + version: 0.11.2 build: noarch: generic diff --git a/.conda/full/recipe.yaml b/.conda/full/recipe.yaml index 7202ad9fe..6db0d77a4 100644 --- a/.conda/full/recipe.yaml +++ b/.conda/full/recipe.yaml @@ -3,7 +3,7 @@ package: name: unilabos-full - version: 0.11.1 + version: 0.11.2 build: noarch: generic @@ -11,7 +11,7 @@ build: requirements: run: # Base unilabos package (includes unilabos-env) - - uni-lab::unilabos ==0.11.1 + - uni-lab::unilabos ==0.11.2 # Documentation tools - sphinx - sphinx_rtd_theme diff --git a/recipes/msgs/recipe.yaml b/recipes/msgs/recipe.yaml index 0a59a2e97..5e6bbc85b 100644 --- a/recipes/msgs/recipe.yaml +++ b/recipes/msgs/recipe.yaml @@ -1,6 +1,6 @@ package: name: ros-humble-unilabos-msgs - version: 0.11.1 + version: 0.11.2 source: path: ../../unilabos_msgs target_directory: src diff --git a/recipes/unilabos/recipe.yaml b/recipes/unilabos/recipe.yaml index f54f1eb75..3a7e50dcb 100644 --- a/recipes/unilabos/recipe.yaml +++ b/recipes/unilabos/recipe.yaml @@ -1,6 +1,6 @@ package: name: unilabos - version: "0.11.1" + version: "0.11.2" source: path: ../.. diff --git a/setup.py b/setup.py index 4053388ea..201db6f4b 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ setup( name=package_name, - version='0.11.1', + version='0.11.2', packages=find_packages(), include_package_data=True, install_requires=['setuptools'], diff --git a/unilabos/__init__.py b/unilabos/__init__.py index fee46bd8c..e2bd0728a 100644 --- a/unilabos/__init__.py +++ b/unilabos/__init__.py @@ -1 +1 @@ -__version__ = "0.11.1" +__version__ = "0.11.2" diff --git a/unilabos_msgs/package.xml b/unilabos_msgs/package.xml index 175521172..a1ab4e856 100644 --- a/unilabos_msgs/package.xml +++ b/unilabos_msgs/package.xml @@ -2,7 +2,7 @@ unilabos_msgs - 0.11.1 + 0.11.2 ROS2 Messages package for unilabos devices Junhan Chang Xuwznln From 35f120b0311676d9ded7f5a7af0723ecaadbc342 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Thu, 14 May 2026 19:28:05 +0800 Subject: [PATCH 31/56] new build fix --- .github/workflows/multi-platform-build.yml | 13 ++++++++----- .github/workflows/unilabos-conda-build.yml | 19 +++++++++++-------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/.github/workflows/multi-platform-build.yml b/.github/workflows/multi-platform-build.yml index 1c5dd7572..fc52c472e 100644 --- a/.github/workflows/multi-platform-build.yml +++ b/.github/workflows/multi-platform-build.yml @@ -105,6 +105,7 @@ jobs: with: miniforge-version: latest use-mamba: true + python-version: '3.11.14' channels: conda-forge,robostack-staging channel-priority: strict activate-environment: build-env @@ -114,13 +115,15 @@ jobs: - name: Install rattler-build and anaconda-client if: steps.should_build.outputs.should_build == 'true' run: | - mamba install --override-channels -c conda-forge rattler-build anaconda-client -y + mamba install -n build-env --override-channels -c conda-forge rattler-build anaconda-client -y - name: Show environment info if: steps.should_build.outputs.should_build == 'true' run: | conda info - conda list | grep -E "(rattler-build|anaconda-client)" + conda list -n build-env | grep -E "(rattler-build|anaconda-client)" + conda run -n build-env rattler-build --version + conda run -n build-env anaconda --version echo "Platform: ${{ matrix.platform }}" echo "OS: ${{ matrix.os }}" @@ -128,9 +131,9 @@ jobs: if: steps.should_build.outputs.should_build == 'true' run: | if [[ "${{ matrix.platform }}" == "osx-arm64" ]]; then - rattler-build build -r ./recipes/msgs/recipe.yaml -c robostack -c robostack-staging -c conda-forge + conda run -n build-env rattler-build build -r ./recipes/msgs/recipe.yaml -c robostack -c robostack-staging -c conda-forge else - rattler-build build -r ./recipes/msgs/recipe.yaml -c robostack -c robostack-staging -c conda-forge + conda run -n build-env rattler-build build -r ./recipes/msgs/recipe.yaml -c robostack -c robostack-staging -c conda-forge fi - name: List built packages @@ -171,5 +174,5 @@ jobs: run: | for package in $(find ./output -name "*.conda"); do echo "Uploading $package to unilab organization..." - anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" + conda run -n build-env anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" done diff --git a/.github/workflows/unilabos-conda-build.yml b/.github/workflows/unilabos-conda-build.yml index 11025543d..21f65deb2 100644 --- a/.github/workflows/unilabos-conda-build.yml +++ b/.github/workflows/unilabos-conda-build.yml @@ -98,6 +98,7 @@ jobs: with: miniforge-version: latest use-mamba: true + python-version: '3.11.14' channels: conda-forge,robostack-staging,uni-lab channel-priority: strict activate-environment: build-env @@ -107,13 +108,15 @@ jobs: - name: Install rattler-build and anaconda-client if: steps.should_build.outputs.should_build == 'true' run: | - mamba install --override-channels -c conda-forge rattler-build anaconda-client -y + mamba install -n build-env --override-channels -c conda-forge rattler-build anaconda-client -y - name: Show environment info if: steps.should_build.outputs.should_build == 'true' run: | conda info - conda list | grep -E "(rattler-build|anaconda-client)" + conda list -n build-env | grep -E "(rattler-build|anaconda-client)" + conda run -n build-env rattler-build --version + conda run -n build-env anaconda --version echo "Platform: ${{ matrix.platform }}" echo "OS: ${{ matrix.os }}" echo "Build full package: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' }}" @@ -128,7 +131,7 @@ jobs: if: steps.should_build.outputs.should_build == 'true' run: | echo "Building unilabos-env (conda environment dependencies)..." - rattler-build build -r .conda/environment/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge + conda run -n build-env rattler-build build -r .conda/environment/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge - name: Upload unilabos-env to Anaconda.org (if enabled) if: | @@ -140,7 +143,7 @@ jobs: run: | echo "Uploading unilabos-env to uni-lab organization..." for package in $(find ./output -name "unilabos-env*.conda"); do - anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" + conda run -n build-env anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" done - name: Build unilabos (with pip package) @@ -148,7 +151,7 @@ jobs: run: | echo "Building unilabos package..." # 如果已上传到 Anaconda,从 uni-lab channel 获取 unilabos-env;否则从本地 output 获取 - rattler-build build -r .conda/base/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge --channel ./output + conda run -n build-env rattler-build build -r .conda/base/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge --channel ./output - name: Upload unilabos to Anaconda.org (if enabled) if: | @@ -160,7 +163,7 @@ jobs: run: | echo "Uploading unilabos to uni-lab organization..." for package in $(find ./output -name "unilabos-0*.conda" -o -name "unilabos-[0-9]*.conda"); do - anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" + conda run -n build-env anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" done - name: Build unilabos-full - Only when explicitly requested @@ -170,7 +173,7 @@ jobs: github.event.inputs.build_full == 'true' run: | echo "Building unilabos-full package on ${{ matrix.platform }}..." - rattler-build build -r .conda/full/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge --channel ./output + conda run -n build-env rattler-build build -r .conda/full/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge --channel ./output - name: Upload unilabos-full to Anaconda.org (if enabled) if: | @@ -181,7 +184,7 @@ jobs: run: | echo "Uploading unilabos-full to uni-lab organization..." for package in $(find ./output -name "unilabos-full*.conda"); do - anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" + conda run -n build-env anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" done - name: List built packages From c6a42eb2ef5618edc6db54547b6f81b5271fe737 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Wed, 20 May 2026 18:14:13 +0800 Subject: [PATCH 32/56] support notebook id --- unilabos/app/model.py | 1 + unilabos/app/web/controller.py | 1 + unilabos/app/ws_client.py | 56 +++++++++++++++++++++++++++++++--- 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/unilabos/app/model.py b/unilabos/app/model.py index f80ce35a0..3a031aaaf 100644 --- a/unilabos/app/model.py +++ b/unilabos/app/model.py @@ -59,6 +59,7 @@ class JobAddReq(BaseModel): task_id: str = Field(examples=["task_id"], description="task uuid (auto-generated if empty)", default="") job_id: str = Field(examples=["job_id"], description="goal uuid (auto-generated if empty)", default="") node_id: str = Field(examples=["node_id"], description="node uuid", default="") + notebook_id: str = Field(examples=["notebook_id"], description="notebook uuid", default="") server_info: dict = Field( examples=[{"send_timestamp": 1717000000.0}], description="server info (auto-generated if empty)", diff --git a/unilabos/app/web/controller.py b/unilabos/app/web/controller.py index 6a01645cd..147b4d207 100644 --- a/unilabos/app/web/controller.py +++ b/unilabos/app/web/controller.py @@ -320,6 +320,7 @@ def job_add(req: JobAddReq) -> JobData: action_name=action_name, task_id=task_id, job_id=job_id, + notebook_id=req.notebook_id, device_action_key=device_action_key, ) diff --git a/unilabos/app/ws_client.py b/unilabos/app/ws_client.py index 4823a2323..fbe19b43e 100644 --- a/unilabos/app/ws_client.py +++ b/unilabos/app/ws_client.py @@ -59,6 +59,7 @@ class QueueItem: action_name: str task_id: str job_id: str + notebook_id: str device_action_key: str next_run_time: float = 0 # 下次执行时间戳 retry_count: int = 0 # 重试次数 @@ -71,6 +72,7 @@ class JobInfo: job_id: str task_id: str device_id: str + notebook_id: str action_name: str device_action_key: str status: JobStatus @@ -539,7 +541,10 @@ async def _connection_handler(self): self.reconnect_count += 1 backoff = WSConfig.reconnect_interval logger.info( - f"[MessageProcessor] 即将在 {backoff} 秒后重连 (已尝试 {self.reconnect_count}/{WSConfig.max_reconnect_attempts})" + "[MessageProcessor] 即将在 %s 秒后重连 (已尝试 %s/%s)", + backoff, + self.reconnect_count, + WSConfig.max_reconnect_attempts, ) await asyncio.sleep(backoff) else: @@ -703,6 +708,7 @@ async def _handle_query_action_state(self, data: Dict[str, Any]): action_name = data.get("action_name", "") task_id = data.get("task_id", "") job_id = data.get("job_id", "") + notebook_id = data.get("notebook_id", "") if not all([device_id, action_name, task_id, job_id]): logger.error("[MessageProcessor] Missing required fields in query_action_state") @@ -718,6 +724,7 @@ async def _handle_query_action_state(self, data: Dict[str, Any]): job_id=job_id, task_id=task_id, device_id=device_id, + notebook_id=notebook_id, action_name=action_name, device_action_key=device_action_key, status=JobStatus.QUEUE, @@ -732,13 +739,27 @@ async def _handle_query_action_state(self, data: Dict[str, Any]): if can_start_immediately: # 可以立即开始 await self._send_action_state_response( - device_id, action_name, task_id, job_id, "query_action_status", True, 0 + device_id, + action_name, + task_id, + job_id, + "query_action_status", + True, + 0, + notebook_id=notebook_id, ) logger.trace(f"[MessageProcessor] Job {job_log} can start immediately") else: # 需要排队 await self._send_action_state_response( - device_id, action_name, task_id, job_id, "query_action_status", False, 10 + device_id, + action_name, + task_id, + job_id, + "query_action_status", + False, + 10, + notebook_id=notebook_id, ) logger.trace(f"[MessageProcessor] Job {job_log} queued") @@ -768,6 +789,7 @@ async def _handle_job_start(self, data: Dict[str, Any]): job_id=req.job_id, task_id=req.task_id, device_id=req.device_id, + notebook_id=req.notebook_id, action_name=action_name, device_action_key=device_action_key, status=JobStatus.QUEUE, @@ -775,11 +797,16 @@ async def _handle_job_start(self, data: Dict[str, Any]): always_free=True, ) self.device_manager.add_queue_request(job_info) + existing_job = job_info logger.info(f"[MessageProcessor] Job {job_log} always_free, auto-registered from direct job_start") else: logger.error(f"[MessageProcessor] Job {job_log} not registered (missing query_action_state)") return + if existing_job and req.notebook_id and not existing_job.notebook_id: + existing_job.notebook_id = req.notebook_id + notebook_id = req.notebook_id or (existing_job.notebook_id if existing_job else "") + success = self.device_manager.start_job(req.job_id) if not success: logger.error(f"[MessageProcessor] Failed to start job {job_log}") @@ -795,6 +822,7 @@ async def _handle_job_start(self, data: Dict[str, Any]): action_name=req.action, task_id=req.task_id, job_id=req.job_id, + notebook_id=notebook_id, device_action_key=device_action_key, ) @@ -834,6 +862,7 @@ async def _handle_job_start(self, data: Dict[str, Any]): "job_id": req.job_id, "task_id": req.task_id, "device_id": req.device_id, + "notebook_id": queue_item.notebook_id, "action_name": req.action, "status": "failed", "feedback_data": {}, @@ -855,6 +884,7 @@ async def _handle_job_start(self, data: Dict[str, Any]): "query_action_status", True, 0, + notebook_id=next_job.notebook_id, ) next_job_log = format_job_log( next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name @@ -1101,7 +1131,15 @@ def do_cleanup(): logger.info(f"[MessageProcessor] Restart cleanup scheduled") async def _send_action_state_response( - self, device_id: str, action_name: str, task_id: str, job_id: str, typ: str, free: bool, need_more: int + self, + device_id: str, + action_name: str, + task_id: str, + job_id: str, + typ: str, + free: bool, + need_more: int, + notebook_id: str = "", ): """发送动作状态响应""" message = { @@ -1112,6 +1150,7 @@ async def _send_action_state_response( "action_name": action_name, "task_id": task_id, "job_id": job_id, + "notebook_id": notebook_id, "free": free, "need_more": need_more + 1, }, @@ -1194,6 +1233,7 @@ def _run(self): action_name=timeout_job.action_name, task_id=timeout_job.task_id, job_id=timeout_job.job_id, + notebook_id=timeout_job.notebook_id, device_action_key=timeout_job.device_action_key, ) # 发布超时失败状态,这会触发正常的job完成流程 @@ -1252,6 +1292,7 @@ def _send_running_status(self): "action_name": job_info.action_name, "task_id": job_info.task_id, "job_id": job_info.job_id, + "notebook_id": job_info.notebook_id, "free": False, "need_more": 10 + 1, }, @@ -1291,6 +1332,7 @@ def _send_busy_status(self): "action_name": job_info.action_name, "task_id": job_info.task_id, "job_id": job_info.job_id, + "notebook_id": job_info.notebook_id, "free": False, "need_more": 10 + 1, }, @@ -1336,12 +1378,15 @@ def handle_job_completed(self, job_id: str, status: str) -> None: "action_name": next_job.action_name, "task_id": next_job.task_id, "job_id": next_job.job_id, + "notebook_id": next_job.notebook_id, "free": True, "need_more": 0, }, } self.message_processor.send_message(message) - # next_job_log = format_job_log(next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name) + # next_job_log = format_job_log( + # next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name + # ) # logger.debug(f"[QueueProcessor] Notified next job {next_job_log} can start") # 立即触发下一轮状态检查 @@ -1510,6 +1555,7 @@ def publish_job_status( "job_id": item.job_id, "task_id": item.task_id, "device_id": item.device_id, + "notebook_id": item.notebook_id, "action_name": item.action_name, "status": status, "feedback_data": feedback_data, From 14b87a793b9009d538e353c7b6c69ff55630f023 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Sat, 23 May 2026 21:36:37 +0800 Subject: [PATCH 33/56] fix macos x64 conda artifacts Ensure macOS x64 jobs run on an Intel runner and pass the matrix platform through to rattler-build so package metadata matches the uploaded artifact. Co-authored-by: Cursor --- .github/workflows/conda-pack-build.yml | 2 +- .github/workflows/multi-platform-build.yml | 8 ++------ .github/workflows/unilabos-conda-build.yml | 8 ++++---- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/.github/workflows/conda-pack-build.yml b/.github/workflows/conda-pack-build.yml index 3da148dd6..7f6f2a41d 100644 --- a/.github/workflows/conda-pack-build.yml +++ b/.github/workflows/conda-pack-build.yml @@ -43,7 +43,7 @@ jobs: platform: linux-64 env_file: unilabos-linux-64.yaml script_ext: sh - - os: macos-15 # Intel (via Rosetta) + - os: macos-15-intel # Intel x86_64 platform: osx-64 env_file: unilabos-osx-64.yaml script_ext: sh diff --git a/.github/workflows/multi-platform-build.yml b/.github/workflows/multi-platform-build.yml index fc52c472e..aa3666f5c 100644 --- a/.github/workflows/multi-platform-build.yml +++ b/.github/workflows/multi-platform-build.yml @@ -63,7 +63,7 @@ jobs: - os: ubuntu-latest platform: linux-64 env_file: unilabos-linux-64.yaml - - os: macos-15 # Intel (via Rosetta) + - os: macos-15-intel # Intel x86_64 platform: osx-64 env_file: unilabos-osx-64.yaml - os: macos-latest # ARM64 @@ -130,11 +130,7 @@ jobs: - name: Build conda package if: steps.should_build.outputs.should_build == 'true' run: | - if [[ "${{ matrix.platform }}" == "osx-arm64" ]]; then - conda run -n build-env rattler-build build -r ./recipes/msgs/recipe.yaml -c robostack -c robostack-staging -c conda-forge - else - conda run -n build-env rattler-build build -r ./recipes/msgs/recipe.yaml -c robostack -c robostack-staging -c conda-forge - fi + conda run -n build-env rattler-build build -r ./recipes/msgs/recipe.yaml --target-platform ${{ matrix.platform }} -c robostack -c robostack-staging -c conda-forge - name: List built packages if: steps.should_build.outputs.should_build == 'true' diff --git a/.github/workflows/unilabos-conda-build.yml b/.github/workflows/unilabos-conda-build.yml index 21f65deb2..cd652c99c 100644 --- a/.github/workflows/unilabos-conda-build.yml +++ b/.github/workflows/unilabos-conda-build.yml @@ -59,7 +59,7 @@ jobs: include: - os: ubuntu-latest platform: linux-64 - - os: macos-15 # Intel (via Rosetta) + - os: macos-15-intel # Intel x86_64 platform: osx-64 - os: macos-latest # ARM64 platform: osx-arm64 @@ -131,7 +131,7 @@ jobs: if: steps.should_build.outputs.should_build == 'true' run: | echo "Building unilabos-env (conda environment dependencies)..." - conda run -n build-env rattler-build build -r .conda/environment/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge + conda run -n build-env rattler-build build -r .conda/environment/recipe.yaml --target-platform ${{ matrix.platform }} -c uni-lab -c robostack-staging -c conda-forge - name: Upload unilabos-env to Anaconda.org (if enabled) if: | @@ -151,7 +151,7 @@ jobs: run: | echo "Building unilabos package..." # 如果已上传到 Anaconda,从 uni-lab channel 获取 unilabos-env;否则从本地 output 获取 - conda run -n build-env rattler-build build -r .conda/base/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge --channel ./output + conda run -n build-env rattler-build build -r .conda/base/recipe.yaml --target-platform ${{ matrix.platform }} -c uni-lab -c robostack-staging -c conda-forge --channel ./output - name: Upload unilabos to Anaconda.org (if enabled) if: | @@ -173,7 +173,7 @@ jobs: github.event.inputs.build_full == 'true' run: | echo "Building unilabos-full package on ${{ matrix.platform }}..." - conda run -n build-env rattler-build build -r .conda/full/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge --channel ./output + conda run -n build-env rattler-build build -r .conda/full/recipe.yaml --target-platform ${{ matrix.platform }} -c uni-lab -c robostack-staging -c conda-forge --channel ./output - name: Upload unilabos-full to Anaconda.org (if enabled) if: | From c4c1f07e123a47cdf4c28250dc50eac9f46a79b8 Mon Sep 17 00:00:00 2001 From: Roy Date: Sat, 23 May 2026 23:35:54 +0800 Subject: [PATCH 34/56] Add PLC communication guide (#264) * Add post process station and related resources - Created JSON configuration for post_process_station and its child post_process_deck. - Added YAML definitions for post_process_station, bottle carriers, bottles, and deck resources. - Implemented Python classes for bottle carriers, bottles, decks, and warehouses to manage resources in the post process. - Established a factory method for creating warehouses with customizable dimensions and layouts. - Defined the structure and behavior of the post_process_deck and its associated warehouses. * feat(post_process): add post_process_station and related warehouse functionality - Introduced post_process_station.json to define the post-processing station structure. - Implemented post_process_warehouse.py to create warehouse configurations with customizable layouts. - Added warehouses.py for specific warehouse configurations (4x3x1). - Updated post_process_station.yaml to reflect new module paths for OpcUaClient. - Refactored bottle carriers and bottles YAML files to point to the new module paths. - Adjusted deck.yaml to align with the new organizational structure for post_process_deck. * Add PLC communication guide for AI4M Add a comprehensive developer guide (docs/developer_guide/add_PLC.md) describing the PLC integration standard used by Uni-Lab for workstation devices, using the AI4M implementation as reference. Covers rationale for using OPC UA, the opcua_nodes_*.csv node-table format, communication base classes (BaseOpcUaClient / OpcUaClientWithSubscription), data types, and subscription/cache/reconnect behavior. Documents driver patterns for AI4MDevice, three handshake paradigms (pulse, parameter handshake, id-based), registry/graph configuration (YAML/JSON), debugging tips (KEPServerEX sim, standalone run), and a checklist for onboarding new PLC-controlled equipment. --- docs/developer_guide/add_PLC.md | 611 ++++++++++++++++++++++++++++++++ 1 file changed, 611 insertions(+) create mode 100644 docs/developer_guide/add_PLC.md diff --git a/docs/developer_guide/add_PLC.md b/docs/developer_guide/add_PLC.md new file mode 100644 index 000000000..e4f6bb6d7 --- /dev/null +++ b/docs/developer_guide/add_PLC.md @@ -0,0 +1,611 @@ +# PLC 通信标准与设备驱动编写指南(基于 AI4M 工站) + +> 本文档以 `unilabos/devices/workstation/AI4M`(水凝胶检测工站)为参考实现, +> 介绍如何将 PLC 控制的实验设备接入 Uni-Lab-OS:包含通信协议选型、节点表标准、 +> 通信基类、设备驱动、Registry 配置以及调试方法。 +> +> 阅读对象:负责现场调试与设备接入的同学。 + +--- + +## 0. 总览:一台 PLC 设备从硬件到云端的链路 + +``` + PLC(西门子 / 倍福 / 三菱 / 汇川 / 国产 PLC ...) + ▲ + │ 各家 PLC 私有协议(S7 / Modbus / EtherCAT ...) + │ + ┌──────────┴──────────┐ + │ OPC UA Server │ ← 统一在 PLC 侧或独立网关上配置 + │ (内置或 KEPServer)│ + └──────────┬──────────┘ + │ OPC UA over TCP(标准协议) + │ + ┌──────────┴──────────┐ + │ Uni-Lab 设备驱动 │ ← 本教程主体 + │ AI4MDevice │ + │ ├─ base_opcua_client.py 通信基类 + │ ├─ opcua_nodes_*.csv 节点表(标准) + │ └─ AI4M.py 动作函数 + └──────────┬──────────┘ + │ ROS2 Action / 云端 HTTP + ▼ + 实验记录本 / 云端调度 +``` + +**统一约定**:所有 PLC 设备**只暴露 OPC UA 接口**给 Uni-Lab,PC 端不直接处理 S7 / Modbus 等底层协议。 +这是 Uni-Lab 在工站类设备上的 PLC 通信标准。 + +--- + +## 1. 为什么选 OPC UA 作为标准? + +| 维度 | 自研 TCP/串口协议 | Modbus | **OPC UA** | +|---|---|---|---| +| 厂家无关 | ✗ | 部分 | **✓** | +| 自带类型系统 | ✗ | ✗(裸寄存器) | **✓(Boolean/Int16/Float...)** | +| 命名空间 / 节点树 | ✗ | ✗(地址=魔数) | **✓(带名字、可分组)** | +| 订阅推送 | ✗ | ✗ | **✓(DataChange Notification)** | +| 鉴权 / 加密 | 自己造 | ✗ | **✓** | +| 与 PLC 工程师沟通成本 | 高 | 中 | **低(按变量名沟通)** | + +实际接入时,PLC 工程师只需要在 PLC 侧把约定的"上位通讯变量"暴露到 OPC UA Server, +我们在 PC 侧就能用 `节点名 + 数据类型` 直接读写,不用管底层是 S7 还是 Modbus。 + +--- + +## 2. 节点表标准:`opcua_nodes_xxx.csv` + +PLC 侧暴露的所有变量统一**用一张 CSV 表**描述,这是 PC 端和 PLC 端**唯一的接口契约**。 +位置示例:`unilabos/devices/workstation/AI4M/opcua_nodes_AI4M.csv`。 + +### 2.1 列定义 + +| 列名 | 是否必填 | 说明 | +|---|---|---| +| `Name` | ✅ | 节点名(PLC 工程师在 PLC 项目中真实使用的变量名,通常是中文/原始名) | +| `EnglishName` | 推荐 | 英文别名,**PC 端代码全部用这个名字**调用 | +| `NodeType` | ✅ | `VARIABLE`(变量)或 `METHOD`(方法),AI4M 全部用变量 | +| `DataType` | ✅ | `BOOLEAN` / `INT16` / `INT32` / `FLOAT` / `DOUBLE` / `STRING` ... | +| `NodeLanguage` | 推荐 | `Chinese` / `English`,配合 `EnglishName` 做映射 | +| `NodeId` | ✅ | OPC UA 标准 NodeId,格式 `ns=;s=` 或 `ns=;i=` | + +### 2.2 真实样例(节选自 `opcua_nodes_AI4M.csv`) + +| Name | EnglishName | NodeType | DataType | NodeLanguage | NodeId | +|---|---|---|---|---|---| +| 机器人空闲 | `robot_ready` | VARIABLE | BOOLEAN | Chinese | `ns=4;s=上位通讯变量\|机器人空闲` | +| 机器人取烧杯编号 | `robot_pick_beaker_id` | VARIABLE | INT16 | Chinese | `ns=4;s=上位通讯变量\|机器人取烧杯编号` | +| 检测1请求参数 | `station_1_request_params` | VARIABLE | BOOLEAN | Chinese | `ns=4;s=上位通讯变量\|检测1请求参数` | +| 检测1工艺完成 | `station_1_process_complete` | VARIABLE | BOOLEAN | Chinese | `ns=4;s=上位通讯变量\|检测1工艺完成` | +| 磁力搅拌参数设置_C[0].搅拌速度 | `mag_stirrer_c0_stir_speed` | VARIABLE | INT16 | Chinese | `ns=4;s=上位通讯变量\|磁力搅拌参数设置_C[0].搅拌速度` | +| 报警复位 | `alarm_reset` | VARIABLE | BOOLEAN | Chinese | `ns=4;s=上位通讯变量\|报警复位` | + +### 2.3 设计规范(必读) + +1. **命名按"角色-编号-属性"分层**,便于代码批量寻址: + - `mag_stirrer_c{0..4}_stir_speed`(搅拌仪 0~4 的搅拌速度) + - `station_{1..3}_process_complete`(检测站 1~3 的完成信号) + - `robot_rack_pick_beaker_{1..5}_complete`(取烧杯 1~5 的完成信号) + + 这样在驱动里可以直接 `f"mag_stirrer_c{idx}_stir_speed"` 拼出节点名。 + +2. **数据类型与 PLC 侧严格一致**: + - `BOOL` → `BOOLEAN`,`INT/WORD` → `INT16/UINT16`,`DINT` → `INT32`,`REAL` → `FLOAT`。 + - 类型不一致会触发 `BadTypeMismatch`,写入失败。 + +3. **NodeId 必须从 PLC 工程或 OPC UA Server 中导出**,不要自己拼。 + 常见格式: + - 西门子 1500:`ns=4;s=上位通讯变量|<变量名>` + - 倍福 TwinCAT:`ns=4;s=PLC1.MAIN.<变量名>` + - KEPServerEX:`ns=2;s=Channel1.Device1.` + +4. **每个工站一个独立 CSV**,不要共用。 + AI4M 中真机用 `opcua_nodes_AI4M.csv`,仿真用 `opcua_nodes_AI4M_sim.csv`。 + +--- + +## 3. 通信基类架构 + +文件:`unilabos/devices/workstation/AI4M/base_opcua_client.py` + +整个通信层分两层: + +``` +BaseOpcUaClient # 最小可用:连接 + 节点注册 + 读写 + 方法调用 + ▲ + │ 继承 + │ +OpcUaClientWithSubscription # 生产可用:+ 订阅推送 + 缓存 + 自动重连 + ▲ + │ 继承 + │ +AI4MDevice # 业务驱动:在它之上写设备动作函数 +``` + +### 3.1 `BaseOpcUaClient` 核心能力 + +```python +class BaseOpcUaClient(UniversalDriver): + client: Optional[Client] = None + _node_registry: Dict[str, OpcUaNodeBase] = {} # name -> Variable/Method + _name_mapping: Dict[str, str] = {} # 英文名 -> 中文名 + _reverse_mapping: Dict[str, str] = {} # 中文名 -> 英文名 + _found_node_objects: Dict[str, Any] = {} # 缓存 ua.Node 用于订阅 + + @classmethod + def load_csv(cls, file_path) -> Tuple[List[OpcUaNode], dict, dict]: ... + def register_node_list(self, node_list) -> "BaseOpcUaClient": ... + def use_node(self, name) -> OpcUaNodeBase: ... + def read_node(self, node_name: str) -> str: ... # 返回 JSON + def write_node(self, json_input: str) -> str: ... + def call_method(self, node_name, *args) -> Tuple[Any, bool]: ... +``` + +它做的事情可以归纳为四步: + +1. **`load_csv`**:读取节点表,建立 `Name ↔ EnglishName` 双向映射。 +2. **`register_node_list`**:把节点登记进 `_variables_to_find` 待查找列表。 +3. **`_connect` → `_find_nodes`**:连上 OPC UA 后,按 `NodeId` 把每个节点解析成 `Variable` / `Method` 对象,放进 `_node_registry`。 +4. **`use_node(name)`**:业务代码取节点的唯一入口,**支持中英文混用**,找不到会自动重试一次。 + +### 3.2 `OpcUaClientWithSubscription` 增强能力 + +在 `BaseOpcUaClient` 基础上提供三个生产环境必备的能力: + +#### a) 订阅缓存(高频读零开销) + +```python +def _setup_subscriptions(self): + self._subscription = self.client.create_subscription( + self._subscription_interval, # 默认 500ms + SubscriptionHandler(self), + ) + for node_name, node in self._node_registry.items(): + if node.type == NodeType.VARIABLE and node.node_id: + handle = self._subscription.subscribe_data_change(ua_node) + self._subscription_handles[node_name] = handle +``` + +当 PLC 侧变量变化时,`datachange_notification` 回调会把新值写进 `self._node_values[name]`, +后续 `get_node_value` 优先读缓存——**业务代码可以放心地写 `while not self.get_node_value(...): time.sleep(1)` 而不用担心 OPC UA 频繁请求**。 + +#### b) 智能缓存的 `get_node_value` + +```python +def get_node_value(self, name, use_cache=True, force_read=False): + # 1. 中英文名归一化 + chinese_name = self._name_mapping.get(name, name) + + # 2. force_read=True 强制透传到 OPC UA Server + if force_read: ... + + # 3. 命中订阅推送 → 直接返回缓存 + # 4. 命中按需读 + 未过期(cache_timeout=5s)→ 返回缓存 + # 5. 否则发起 read 并更新缓存 +``` + +#### c) 连接监控 + 自动重连 + +后台线程每 30s 调一次 `client.get_namespace_array()` 探活,断线则自动 `disconnect → connect → 重新订阅`,最多重试 5 次。 + +### 3.3 数据类型 / 节点类型 + +`unilabos/device_comms/opcua_client/node/uniopcua.py`: + +```python +class DataType(Enum): + BOOLEAN = VariantType.Boolean + INT16 = VariantType.Int16 + INT32 = VariantType.Int32 + FLOAT = VariantType.Float + STRING = VariantType.String + # ... + +class NodeType(Enum): + VARIABLE = NodeClass.Variable + METHOD = NodeClass.Method + OBJECT = NodeClass.Object +``` + +`Variable.write()` 内部会按 `DataType` 做强制类型转换, +所以 CSV 里的 `DataType` 列就是"PC 端转换写入值的类型说明书"。 + +--- + +## 4. 编写设备驱动:以 `AI4MDevice` 为例 + +文件:`unilabos/devices/workstation/AI4M/AI4M.py` + +### 4.1 继承通信基类,最小骨架 + +```python +from typing import Optional +from unilabos.devices.workstation.AI4M.base_opcua_client import OpcUaClientWithSubscription + +class AI4MDevice(OpcUaClientWithSubscription): + def __init__( + self, + url: str, # opc.tcp://192.168.1.10:4840 + deck: Optional[AI4M_deck] = None, # 物料台面(资源树) + csv_path: str = None, # 节点表 CSV + username: str = None, + password: str = None, + use_subscription: bool = True, + cache_timeout: float = 5.0, + subscription_interval: int = 500, + *args, **kwargs, + ): + super().__init__( + url=url, username=username, password=password, + use_subscription=use_subscription, + cache_timeout=cache_timeout, + subscription_interval=subscription_interval, + *args, **kwargs, + ) + + # 物料台面初始化(见教程 4. 物料系统) + self.deck = deck or AI4M_deck(setup=True) + self._robot_lock = threading.Lock() + + # 关键:加载节点表 + if csv_path: + self.load_nodes_from_csv(csv_path) +``` + +`load_nodes_from_csv` 会一次性完成:解析 CSV → 注册节点 → 解析 NodeId → 建立订阅, +**之后整个驱动都通过 `self.get_node_value(name)` / `self.set_node_value(name, value)` 操作 PLC**。 + +### 4.2 PLC 通信的核心模式:握手协议(Handshake) + +PLC 编程的本质是"扫描周期 + 状态机",PC 端**绝对不能用 fire-and-forget 的方式发指令**。 +和 PLC 配合的标准模式是 **"PC 写指令 → PC 等待 PLC 回执 → PC 复位指令"**。 + +AI4M 中所有 `trigger_*` 函数都遵循以下三种握手范式之一: + +#### 范式 A:脉冲触发 + 完成信号(最常用) + +```python +def trigger_init(self) -> dict: + # ① 复位上一轮残留 + self.set_node_value("alarm_reset", True); time.sleep(1.0) + self.set_node_value("alarm_reset", False) + self.set_node_value("manual_auto_switch", False) + + # ② 等待 PLC 退出自动模式 + while self.get_node_value("auto_mode"): + time.sleep(1.0) + + # ③ 发起初始化脉冲(True → False) + self.set_node_value("initialize", True); time.sleep(1.0) + self.set_node_value("initialize", False) + + # ④ 等待 PLC 给出完成信号 + while not self.get_node_value("init finished"): + time.sleep(1.0) + + return {"message": "设备初始化完成"} +``` + +要点: +- **"PC 写一个 BOOL 拉高再拉低"** 模拟脉冲,PLC 用上升沿触发动作。 +- **`get_node_value` 要在 while 循环里轮询**,配合订阅缓存基本无压力。 +- **每个动作必须有"开始"和"完成"两个独立的 BOOL 节点**,不能复用。 + +#### 范式 B:参数下发 + 请求/已执行/完成 三步握手(带数据的工艺) + +```python +def trigger_station_process(self, station_id: int, mag_stir_speed: int, ...): + request_node = f"station_{station_id}_request_params" + params_received_node = f"station_{station_id}_params_received" + start_node = f"station_{station_id}_start" + complete_node = f"station_{station_id}_process_complete" + + # ① PC 复位三个状态位(避免上一轮影响) + self._reset_station_process_flags(station_id) + + # ② 等 PLC 主动请求参数(PLC 准备好了才接收) + while not self.get_node_value(request_node): + time.sleep(1.0) + + # ③ PC 下发参数(注意:PLC 内部数组是 0-based,PC 暴露给用户是 1-based) + station_idx = station_id - 1 + self.set_node_value(f"mag_stirrer_c{station_idx}_stir_speed", mag_stir_speed) + self.set_node_value(f"mag_stirrer_c{station_idx}_heat_temp", mag_stir_heat_temp) + self.set_node_value(f"mag_stirrer_c{station_idx}_time_set", mag_stir_time_set) + self.set_node_value(f"syringe_pump_{station_idx}_abs_position_set", syringe_pump_abs_pos) + + # ④ PC 通知 PLC "参数已就绪",等 PLC 回复"已执行" + self.set_node_value(start_node, True) + while not self.get_node_value(params_received_node): + time.sleep(1.0) + + # ⑤ 等 PLC 完成整个工艺 + while not self.get_node_value(complete_node): + time.sleep(5.0) + + self.set_node_value(start_node, False) # 复位,方便下一轮 + return {"station_id": station_id, "message": "..."} +``` + +四个状态位的语义: + +| 信号 | 方向 | 含义 | +|---|---|---| +| `station_X_request_params` | **PLC → PC** | "我准备好了,把参数给我" | +| `station_X_start` | **PC → PLC** | "参数我已经写好了,开干" | +| `station_X_params_received` | **PLC → PC** | "参数我已经吃下了" | +| `station_X_process_complete` | **PLC → PC** | "工艺已经做完" | + +**这是 PLC 通信教科书级别的标准范式**,所有带数据下发的动作都建议照抄。 + +#### 范式 C:编号下发 + 编号对应的完成信号(多目标互锁) + +```python +def trigger_robot_pick_beaker(self, pick_beaker_id: int, place_station_id: int = None, ...): + # ① 等机器人空闲(互锁) + while not self.get_node_value("robot_ready"): + time.sleep(1.0) + + # ② 阶段一:下发"取哪一杯"编号 + 等"取这一杯完成" + pick_complete_node = f"robot_rack_pick_beaker_{pick_beaker_id}_complete" + self.set_node_value("robot_pick_beaker_id", pick_beaker_id) + while not self.get_node_value(pick_complete_node): + time.sleep(1.0) + + # ③ 阶段二:下发"放到哪个工站"编号 + 等"放完成" + place_complete_node = f"robot_place_station_{place_station_id}_complete" + self._reset_station_process_flags(place_station_id) + self.set_node_value("robot_place_station_id", place_station_id) + while not self.get_node_value(place_complete_node): + time.sleep(1.0) +``` + +要点: +- **同一个动作的多个目标用"编号变量 + 编号对应的完成信号"实现**,不要每个目标都开一个开始位。 +- **配合 Python 端 `threading.Lock()` 做软互锁**,避免多个线程争抢机器人。 +- **每个阶段有独立的完成信号**,串行等待,不能合并。 + +### 4.3 一些容易踩坑的细节 + +1. **节点名映射** + `set_node_value("alarm_reset", True)` 实际写入的是 CSV 中文名 `报警复位`, + `get_node_value` 同理。**业务代码全部用 EnglishName**,不要直接用中文。 + +2. **PLC 数组索引和 PC 不一致** + AI4M 里 PC 暴露 `station_id ∈ {1, 2, 3}`,但 PLC 内部数组是 `C[0..2]`, + 驱动里要做 `station_idx = station_id - 1`,**这种映射只在驱动层做一次**, + 不要让上层(registry / 实验记录本)感知。 + +3. **订阅模式下 BOOL 节点的边沿同步** + 订阅有 ~500ms 延迟。如果你刚 `set_node_value(x, True)` 就立刻 `get_node_value(x)`, + 读到的可能还是 `False`(订阅还没推回来)。 + 解决方案:**写完后用 `force_read=True` 透传一次** 或加一段 `time.sleep`。 + +4. **永远不要忘记复位** + `start` 拉 True 后必须有地方拉回 False,否则下一轮 PLC 上升沿不触发。 + AI4M 在 `_reset_station_process_flags` 中统一做: + + ```python + def _reset_station_process_flags(self, station_id: int) -> None: + self.set_node_value(f"station_{station_id}_process_complete", False) + self.set_node_value(f"station_{station_id}_start", False) + self.set_node_value(f"station_{station_id}_params_received", False) + ``` + +5. **耗时长的等待 sleep 加大** + 工艺等待用 `time.sleep(5.0)`,机器人等待用 `time.sleep(1.0)`,初始化等待 `time.sleep(1.0)`, + 不要全部用 0.1s 轮询,会把日志刷爆。 + +--- + +## 5. 把驱动接到 Uni-Lab:Registry + Graph + +### 5.1 Registry YAML(动作 schema) + +文件:`unilabos/registry/devices/AI4M_station.yaml` + +```yaml +AI4M_station: + category: [AI4M_station] + class: + module: unilabos.devices.workstation.AI4M.AI4M:AI4MDevice # ← 入口类 + type: python + action_value_mappings: + auto-trigger_init: + schema: + description: 设备初始化... + properties: + goal: { properties: {}, required: [], type: object } + result: + properties: { message: { type: string } } + required: [message] + type: object + type: object + type: UniLabJsonCommand + + auto-trigger_station_process: + always_free: true + schema: + description: 执行检测工艺流程 + properties: + goal: + properties: + station_id: { type: integer, description: 检测编号 1-3 } + mag_stir_stir_speed: { type: integer } + mag_stir_heat_temp: { type: integer } + mag_stir_time_set: { type: integer } + syringe_pump_abs_position_set:{ type: integer } + required: [station_id, mag_stir_stir_speed, mag_stir_heat_temp, + mag_stir_time_set, syringe_pump_abs_position_set] + type: object + result: { ... } + type: UniLabJsonCommand + + init_param_schema: + config: + type: object + required: [url] + properties: + url: { type: string, description: OPC UA 服务器地址 } + csv_path: { type: string, description: 节点配置 CSV 路径 } + deck: { type: string, description: 资源树配置 } + username: { type: string } + password: { type: string } + use_subscription: { type: boolean, default: true } + cache_timeout: { type: number, default: 5.0 } + subscription_interval: { type: integer, default: 500 } +``` + +规则总结: +- `class.module` 指向驱动类(`module:ClassName`)。 +- `action_value_mappings` 中的 key 形如 `auto-<方法名>`,对应驱动里的同名 Python 方法。 +- `schema.goal` 自动转成 ROS2 Action 的 goal 消息,`schema.result` 转 result。 +- `init_param_schema.config` 对应 `__init__` 的入参,**所有需要现场改的参数都要列出来**(最重要的就是 `url` 和 `csv_path`)。 +- `always_free: true` 表示该动作不占用工站独占锁(多检测站可并发执行)。 + +### 5.2 Graph JSON(实例化) + +文件:`unilabos/devices/workstation/AI4M/AI4M.json` + +```json +{ + "nodes": [ + { + "id": "AI4M_station", + "name": "AI4M_station", + "type": "device", + "class": "AI4M_station", + "children": ["AI4M_deck"], + "parent": null, + "config": { + "url": "opc.tcp://192.168.1.10:4840", + "csv_path": "opcua_nodes_AI4M.csv", + "deck": { + "data": { + "_resource_child_name": "AI4M_deck", + "_resource_type": "unilabos.devices.workstation.AI4M.decks:AI4M_deck" + } + } + } + }, + { + "id": "AI4M_deck", + "type": "deck", + "class": "AI4M_deck", + "parent": "AI4M_station", + "config": { "type": "AI4M_deck" } + } + ] +} +``` + +要点: +- `class` 必须和 Registry YAML 的顶层 key 完全一致(`AI4M_station`)。 +- `config` 字段**逐字传给驱动 `__init__`**,所以 Graph JSON = "现场参数表"。 +- 多套相同设备时拷贝一份,把 `id` / `url` 改掉即可(参考 `AI4M002_station`)。 + +### 5.3 启动命令(来自 `start.md`) + +```cmd +# 真机 +python unilabos/app/main.py -g unilabos/devices/workstation/AI4M/AI4M.json ` + --ak --sk --upload_registry --addr --disable_browser + +# 仿真(KEPServerEX 跑在本机 49320 端口) +python unilabos/app/main.py -g unilabos/devices/workstation/AI4M/AI4Msim.json ` + --ak --sk --upload_registry --disable_browser +``` + +`--upload_registry` 会把 `AI4M_station.yaml` 的 schema 上传到云端, +之后实验记录本就能看到所有 `auto-*` 动作。 + +--- + +## 6. 调试方法 + +### 6.1 用 KEPServerEX 仿真 PLC + +不带 PLC 的开发机上,可以用 KEPServerEX(或 `python-opcua` 自建 server)模拟。 +AI4M 提供了一份仿真节点表 `opcua_nodes_AI4M_sim.csv`,**只改 NodeId 不改语义**, +所以驱动代码无需任何改动即可在本机调试。 + +### 6.2 单独跑驱动(不开 ROS) + +在驱动文件末尾的 `if __name__ == '__main__':` 段: + +```python +if __name__ == '__main__': + A4 = AI4MDevice( + url="opc.tcp://192.168.1.10:4840", + csv_path="opcua_nodes_AI4M.csv", + ) + A4.trigger_init() + print("初始化完成") + A4.trigger_robot_pick_beaker(1, 1) +``` + +**新动作上线前一定要在这里裸跑一遍**,确认握手时序正确,再往上接 ROS。 + +### 6.3 看日志判断卡在哪 + +`base_opcua_client.py` 的日志已经覆盖了所有关键节点: + +``` +✓ 客户端已连接! +✓ 找到变量节点: 'robot_ready', NodeId: ns=4;s=... +✓ 已订阅节点: robot_ready +✓ 节点查找完成:所有 142 个节点均已找到 +``` + +如果看到 `⚠ 以下 N 个节点未找到`,**99% 是 CSV 里的 NodeId 写错了**,回去对一下 PLC 工程导出的 NodeId。 + +### 6.4 检查节点是否能直接读写 + +```python +# 透传读,绕过订阅缓存 +A4.get_node_value("robot_ready", force_read=True) + +# 直接读 JSON 形式(适合从 HTTP/调试面板调) +A4.read_node("robot_ready") + +# 写 +A4.set_node_value("alarm_reset", True) +A4.write_node('{"node_name": "alarm_reset", "value": false}') +``` + +--- + +## 7. 接入新 PLC 设备的 Checklist + +接到一台新工站时,按下面顺序做就能保证不漏: + +- [ ] 1. 让 PLC 工程师把上位通讯变量整理到 OPC UA Server,导出 NodeId 清单。 +- [ ] 2. 在 `unilabos/devices/workstation/<设备名>/` 下新建目录,复制 `AI4M/base_opcua_client.py` 不动。 +- [ ] 3. 整理 `opcua_nodes_<设备名>.csv`,6 列填齐,并补上 `EnglishName`。 +- [ ] 4. 在该目录写设备驱动 `<设备名>.py`,继承 `OpcUaClientWithSubscription`: + - [ ] `__init__` 调用 `super().__init__` + `self.load_nodes_from_csv(csv_path)`。 + - [ ] 每个动作函数用范式 A/B/C 写握手协议。 + - [ ] 每个动作函数都返回 `dict`,至少含 `message` 字段。 +- [ ] 5. 在 `unilabos/registry/devices/` 下新建 `<设备名>_station.yaml`,配置 `init_param_schema` 和 `action_value_mappings`。 +- [ ] 6. 在该目录新建 `<设备名>.json`(Graph),填好 `url` 和 `csv_path`。 +- [ ] 7. 用 `if __name__ == '__main__':` 单独跑驱动确认握手 OK。 +- [ ] 8. 用 `python unilabos/app/main.py -g --upload_registry ...` 上线,到实验记录本下发动作回归。 + +--- + +## 8. 参考实现速查 + +| 关注点 | 在 AI4M 中看哪里 | +|---|---| +| OPC UA 通信基类 | `base_opcua_client.py` | +| 节点定义类型系统 | `unilabos/device_comms/opcua_client/node/uniopcua.py` | +| 节点表 CSV 标准 | `opcua_nodes_AI4M.csv` | +| 设备驱动入口类 | `AI4M.py: AI4MDevice` | +| 握手范式 A(脉冲+完成) | `AI4M.py: trigger_init` | +| 握手范式 B(请求/参数/完成) | `AI4M.py: trigger_station_process` | +| 握手范式 C(编号+完成) | `AI4M.py: trigger_robot_pick_beaker` | +| 自动模式批量参数下发 | `AI4M.py: download_auto_params` | +| Registry schema | `unilabos/registry/devices/AI4M_station.yaml` | +| Graph 实例化 | `AI4M.json` / `AI4Msim.json` | +| 启动命令 | `start.md` | From 450bbe7c58b22188064a1553b75ac39d2df43503 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 23 May 2026 23:36:49 +0800 Subject: [PATCH 35/56] ci(deps): bump conda-incubator/setup-miniconda from 3 to 4 (#261) Bumps [conda-incubator/setup-miniconda](https://github.com/conda-incubator/setup-miniconda) from 3 to 4. - [Release notes](https://github.com/conda-incubator/setup-miniconda/releases) - [Changelog](https://github.com/conda-incubator/setup-miniconda/blob/main/CHANGELOG.md) - [Commits](https://github.com/conda-incubator/setup-miniconda/compare/v3...v4) --- updated-dependencies: - dependency-name: conda-incubator/setup-miniconda dependency-version: '4' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-check.yml | 2 +- .github/workflows/conda-pack-build.yml | 2 +- .github/workflows/deploy-docs.yml | 2 +- .github/workflows/multi-platform-build.yml | 2 +- .github/workflows/unilabos-conda-build.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci-check.yml b/.github/workflows/ci-check.yml index 698344bf7..2b227db1e 100644 --- a/.github/workflows/ci-check.yml +++ b/.github/workflows/ci-check.yml @@ -25,7 +25,7 @@ jobs: fetch-depth: 0 - name: Setup Miniforge - uses: conda-incubator/setup-miniconda@v3 + uses: conda-incubator/setup-miniconda@v4 with: miniforge-version: latest use-mamba: true diff --git a/.github/workflows/conda-pack-build.yml b/.github/workflows/conda-pack-build.yml index 7f6f2a41d..2730ff4d1 100644 --- a/.github/workflows/conda-pack-build.yml +++ b/.github/workflows/conda-pack-build.yml @@ -86,7 +86,7 @@ jobs: - name: Setup Miniforge (with mamba) if: steps.should_build.outputs.should_build == 'true' - uses: conda-incubator/setup-miniconda@v3 + uses: conda-incubator/setup-miniconda@v4 with: miniforge-version: latest use-mamba: true diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index a3ca64693..4c36d532f 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -51,7 +51,7 @@ jobs: fetch-depth: 0 - name: Setup Miniforge (with mamba) - uses: conda-incubator/setup-miniconda@v3 + uses: conda-incubator/setup-miniconda@v4 with: miniforge-version: latest use-mamba: true diff --git a/.github/workflows/multi-platform-build.yml b/.github/workflows/multi-platform-build.yml index aa3666f5c..13f877c8c 100644 --- a/.github/workflows/multi-platform-build.yml +++ b/.github/workflows/multi-platform-build.yml @@ -101,7 +101,7 @@ jobs: - name: Setup Miniforge if: steps.should_build.outputs.should_build == 'true' - uses: conda-incubator/setup-miniconda@v3 + uses: conda-incubator/setup-miniconda@v4 with: miniforge-version: latest use-mamba: true diff --git a/.github/workflows/unilabos-conda-build.yml b/.github/workflows/unilabos-conda-build.yml index cd652c99c..0fc532b04 100644 --- a/.github/workflows/unilabos-conda-build.yml +++ b/.github/workflows/unilabos-conda-build.yml @@ -94,7 +94,7 @@ jobs: - name: Setup Miniforge if: steps.should_build.outputs.should_build == 'true' - uses: conda-incubator/setup-miniconda@v3 + uses: conda-incubator/setup-miniconda@v4 with: miniforge-version: latest use-mamba: true From 0bc56e897349e80b329d596de5560fd0c47deb25 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 23 May 2026 23:37:19 +0800 Subject: [PATCH 36/56] ci(deps): bump actions/upload-pages-artifact from 4 to 5 (#260) Bumps [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) from 4 to 5. - [Release notes](https://github.com/actions/upload-pages-artifact/releases) - [Commits](https://github.com/actions/upload-pages-artifact/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/upload-pages-artifact dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/deploy-docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 4c36d532f..4b3659950 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -105,7 +105,7 @@ jobs: test -f docs/_build/html/index.html && echo "✓ index.html exists" || echo "✗ index.html missing" - name: Upload build artifacts - uses: actions/upload-pages-artifact@v4 + uses: actions/upload-pages-artifact@v5 if: | github.event.workflow_run.head_branch == 'main' || (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true') From 240bfe42b83549371d67783381c4b869f9ec2096 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 23 May 2026 23:40:26 +0800 Subject: [PATCH 37/56] ci(deps): bump actions/configure-pages from 5 to 6 (#252) Bumps [actions/configure-pages](https://github.com/actions/configure-pages) from 5 to 6. - [Release notes](https://github.com/actions/configure-pages/releases) - [Commits](https://github.com/actions/configure-pages/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/configure-pages dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/deploy-docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 4b3659950..a53a3aab5 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -84,7 +84,7 @@ jobs: - name: Setup Pages id: pages - uses: actions/configure-pages@v5 + uses: actions/configure-pages@v6 if: | github.event.workflow_run.head_branch == 'main' || (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true') From c8c41edafc11ce61325dd5764e8378defa7f02c4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 23 May 2026 23:40:40 +0800 Subject: [PATCH 38/56] ci(deps): bump actions/deploy-pages from 4 to 5 (#251) Bumps [actions/deploy-pages](https://github.com/actions/deploy-pages) from 4 to 5. - [Release notes](https://github.com/actions/deploy-pages/releases) - [Commits](https://github.com/actions/deploy-pages/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/deploy-pages dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/deploy-docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index a53a3aab5..3e4b07ddf 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -125,4 +125,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 From 4925d5ef7853a20b15b63b824399ddedaabd731d Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Sat, 23 May 2026 23:43:17 +0800 Subject: [PATCH 39/56] v0.11.3 --- .conda/base/recipe.yaml | 4 +- .conda/environment/recipe.yaml | 2 +- .conda/full/recipe.yaml | 4 +- .cursor/skills/add-device/SKILL.md | 267 +++++++++++++++++- .cursor/skills/virtual-workbench/SKILL.md | 20 +- .../skills/virtual-workbench/action-index.md | 21 +- recipes/msgs/recipe.yaml | 2 +- recipes/unilabos/recipe.yaml | 2 +- setup.py | 2 +- unilabos/__init__.py | 2 +- unilabos/app/ws_client.py | 12 +- unilabos/ros/nodes/base_device_node.py | 36 ++- unilabos/ros/nodes/presets/host_node.py | 13 +- unilabos_msgs/package.xml | 2 +- 14 files changed, 352 insertions(+), 37 deletions(-) diff --git a/.conda/base/recipe.yaml b/.conda/base/recipe.yaml index 2916af79b..57318aae7 100644 --- a/.conda/base/recipe.yaml +++ b/.conda/base/recipe.yaml @@ -3,7 +3,7 @@ package: name: unilabos - version: 0.11.2 + version: 0.11.3 source: path: ../../unilabos @@ -54,7 +54,7 @@ requirements: - pymodbus - matplotlib - pylibftdi - - uni-lab::unilabos-env ==0.11.2 + - uni-lab::unilabos-env ==0.11.3 about: repository: https://github.com/deepmodeling/Uni-Lab-OS diff --git a/.conda/environment/recipe.yaml b/.conda/environment/recipe.yaml index 4ee6b75c7..c41dac2bb 100644 --- a/.conda/environment/recipe.yaml +++ b/.conda/environment/recipe.yaml @@ -2,7 +2,7 @@ package: name: unilabos-env - version: 0.11.2 + version: 0.11.3 build: noarch: generic diff --git a/.conda/full/recipe.yaml b/.conda/full/recipe.yaml index 6db0d77a4..b3a839908 100644 --- a/.conda/full/recipe.yaml +++ b/.conda/full/recipe.yaml @@ -3,7 +3,7 @@ package: name: unilabos-full - version: 0.11.2 + version: 0.11.3 build: noarch: generic @@ -11,7 +11,7 @@ build: requirements: run: # Base unilabos package (includes unilabos-env) - - uni-lab::unilabos ==0.11.2 + - uni-lab::unilabos ==0.11.3 # Documentation tools - sphinx - sphinx_rtd_theme diff --git a/.cursor/skills/add-device/SKILL.md b/.cursor/skills/add-device/SKILL.md index 522c05bf4..dc77c6b76 100644 --- a/.cursor/skills/add-device/SKILL.md +++ b/.cursor/skills/add-device/SKILL.md @@ -5,9 +5,98 @@ description: Guide for adding new devices to Uni-Lab-OS (接入新设备). Uses # 添加新设备到 Uni-Lab-OS -**第一步:** 使用 Read 工具读取 `docs/ai_guides/add_device.md`,获取完整的设备接入指南。 +本 Skill 是自包含的设备接入指南,不依赖外部文档。迁移给别人时,只复制 `.cursor/skills/add-device/SKILL.md` 即可获得核心规则、模板、验证方式和常见错误清单。 -该指南包含设备类别(物模型)列表、通信协议模板、常见错误检查清单等。搜索 `unilabos/devices/` 获取已有设备的实现参考。 +开始实现前,仍应搜索 `unilabos/devices/` 获取同类别已有设备的接口、参数名、状态字符串和返回值风格作为参考。 + +--- + +## 接入工作流 + +按下面顺序推进,并在工作中维护进度: + +```text +设备接入进度: +- [ ] 1. 确定设备类别(物模型)和对外单位 +- [ ] 2. 确定通信协议 +- [ ] 3. 收集指令协议(SDK、厂商文档、寄存器表、HTTP API、用户口述) +- [ ] 4. 对齐同类设备接口(搜索 unilabos/devices/) +- [ ] 5. 创建驱动 unilabos/devices//.py +- [ ] 6. 验证可导入、注册表扫描、启动测试 +- [ ] 7. 如需要,配置实验图文件 +``` + +## 设备类别(物模型) + +优先使用已有类别。只有确实无法归类时才使用 `custom`。 + +| 类别 ID | 说明 | 标准属性 | 标准动作 | +|---|---|---|---| +| `temperature` | 加热、冷却、温控 | `temp`, `temp_target`, `status` | `set_temperature`, `stop` | +| `pump_and_valve` | 泵、阀门、注射器 | 见子类型表 | 见子类型表 | +| `motor` | 电机、步进马达 | `position`, `status` | `enable`, `move_position`, `move_speed`, `stop` | +| `heaterstirrer` | 加热搅拌一体机 | `temp`, `stir_speed`, `status` | `set_temperature`, `stir`, `stop` | +| `balance` | 天平、称重 | `weight`, `unit`, `status` | `tare`, `read_weight` | +| `sensor` | 传感器(液位、温度等) | `value`, `level`, `status` | `read_value`, `set_threshold` | +| `liquid_handling` | 液体处理机器人 | `status`, `deck_state` | `transfer_liquid`, `aspirate`, `dispense` | +| `robot_arm` | 机械臂 | `arm_pose`, `arm_status` | `moveit_task`, `pick_and_place` | +| `workstation` | 工作站、组合设备 | `workflow_sequence`, `material_info` | `create_order`, `scheduler_start`, `scheduler_stop` | +| `virtual` | 虚拟、模拟设备 | 按模拟的真实设备定义 | 按模拟的真实设备定义 | +| `custom` | 不属于以上类别 | 用户自定义 | 用户自定义 | + +`pump_and_valve` 子类型: + +| 子类型 | 最小通用属性 | 最小通用动作 | 单位约定 | +|---|---|---|---| +| 注射泵(syringe pump) | `status`, `valve_position`, `position` | `initialize`, `set_valve_position`, `set_position`, `pull_plunger`, `push_plunger`, `stop_operation` | 体积=mL, 速度=mL/s | +| 电磁阀(solenoid valve) | `status`, `valve_position` | `open`, `close`, `set_valve_position` | 无 | +| 蠕动泵(peristaltic pump) | `status`, `speed` | `start`, `stop`, `set_speed` | 流速=mL/min | + +对外暴露的属性和动作参数必须使用用户友好的物理单位(mL、ul、degC、RPM 等),硬件原始值转换放在驱动内部。 + +## 通信协议和指令来源 + +先确认通信方式,再确认具体指令协议。物模型只定义设备“应该做什么”,不会告诉你硬件“具体发什么字节/请求”。 + +| 协议 | 常用 config 参数 | 常用依赖 | 现有抽象 | +|---|---|---|---| +| Serial (RS232/RS485) | `port`, `baudrate`, `timeout` | `pyserial` | 直接使用 `serial.Serial` | +| Modbus RTU | `port`, `baudrate`, `slave_id` | `pymodbus` | `device_comms/modbus_plc/` | +| Modbus TCP | `host`, `port`, `slave_id` | `pymodbus` | `device_comms/modbus_plc/` | +| TCP Socket | `host`, `port`, `timeout` | stdlib | 直接使用 `socket` | +| HTTP API | `url`, `token`, `timeout` | `requests` | `device_comms/rpc.py` | +| OPC UA | `url` | `opcua` | `device_comms/opcua_client/` | +| 无通信(虚拟) | 无 | 无 | 在动作中模拟行为 | + +必须从以下来源之一获得指令细节: + +| 来源 | 处理方式 | +|---|---| +| 现成 SDK/驱动代码 | 读取代码,提取指令逻辑,包装进 Uni-Lab-OS 类 | +| 协议文档/手册 | 解析命令、响应、校验、寄存器、错误码 | +| 用户口述 | 按描述实现指令编解码,标出不确定点 | +| 标准协议 | 使用标准实现,例如 Modbus 寄存器表、SCPI | +| 虚拟设备 | 跳过硬件通信,在动作方法中维护模拟状态 | + +## 对齐已有实现(强制) + +实现前必须搜索 `unilabos/devices/` 中同类别设备: + +- 参数名必须与已有设备保持一致;动作方法参数名是接口契约,不要随意改成 `volume_ml`、`target_temp_c` 这类新名字。 +- `status` 字符串值要和同类设备一致,优先使用英文稳定值,例如 `Idle`、`Running`、`Error`。 +- 状态属性用 `@property` + `@topic_config()` 明确声明。 +- 返回值使用结构化 dict,至少包含 `success`,需要给前端展示的信息放在 `message`、`data`、`error` 等字段。 + +## 架构选择 + +| 场景 | 推荐方式 | +|---|---| +| 简单设备 | 纯 Python 类 + `@device` | +| 工作站/组合设备 | `WorkstationBase` 或项目内已有工作站模式 | +| 液体处理 | `LiquidHandlerAbstract` / PyLabRobot 相关模式 | +| Modbus 设备 | 复用 `device_comms/modbus_plc/` 或项目内 Modbus 示例 | +| OPC UA 设备 | 复用 `device_comms/opcua_client/` | +| 外部独立包 | 使用 `create-device-package` skill | --- @@ -87,6 +176,29 @@ Args: - 如果只写 `param: 参数说明`,`title` 会兜底为字段名,`description` 使用参数说明。 - 如果没有写参数文档,生成器也会兜底补齐 `title=<字段名>` 和 `description=""`,但新设备应优先写清楚显示名和说明。 +### 特殊参数类型:ResourceSlot / DeviceSlot + +需要前端选择资源或设备时,用特殊类型注解,registry 会自动生成 `placeholder_keys`: + +```python +from typing import List +from unilabos.registry.placeholder_type import DeviceSlot, ResourceSlot + +@action(description="转移液体") +def transfer(self, source: ResourceSlot, target: ResourceSlot, volume_ul: float) -> dict: + """ + Args: + source[源资源]: 源容器或孔位。 + target[目标资源]: 目标容器或孔位。 + volume_ul[体积(ul)]: 转移体积。 + """ + return {"success": True} + +@action(description="同步设备") +def sync_devices(self, devices: List[DeviceSlot]) -> dict: + return {"success": True, "count": len(devices)} +``` + ### @topic_config — 状态属性配置 ```python @@ -194,3 +306,154 @@ class MyDevice: - `post_init` 用 `@not_action` 标记,参数类型标注为 `BaseROS2DeviceNode` - 运行时状态存储在 `self.data` 字典中 - 设备文件放在 `unilabos/devices//` 目录下 + +--- + +## 通信实现片段 + +Serial 文本指令: + +```python +def _send_command(self, cmd: str) -> str: + self.ser.write(f"{cmd}\r\n".encode()) + return self.ser.readline().decode().strip() +``` + +RS-485 响应解析要先定位帧头,不要用硬编码索引直接解析原始响应: + +```python +def _normalize_response(self, raw: str, start_marker: str = "/") -> str: + pos = raw.find(start_marker) + return raw[pos:] if pos >= 0 else raw +``` + +自定义二进制帧: + +```python +def _build_frame(self, func_code: int, data: bytes) -> bytes: + frame = bytearray([0xFE, func_code]) + bytearray(data) + checksum = sum(frame[1:]) % 256 + frame.append(checksum) + return bytes(frame) +``` + +Modbus 寄存器映射: + +```python +REGISTER_MAP = { + "temp_target": {"addr": 0x000B, "scale": 10}, +} + +def set_temperature(self, temp: float, **kwargs) -> bool: + reg = REGISTER_MAP["temp_target"] + value = int(float(temp) * reg["scale"]) & 0xFFFF + self.client.write_register(reg["addr"], value, slave=self.slave_id) + self.data["temp_target"] = temp + return True +``` + +HTTP API 映射: + +```python +API_MAP = { + "set_temperature": { + "method": "POST", + "endpoint": "/api/temperature", + "body_key": "target", + }, +} +``` + +SDK 封装: + +```python +from my_device_sdk import DeviceController + +class MyDevice: + def __init__(self, device_id=None, config=None, **kwargs): + self.config = config or {} + self.controller = DeviceController(port=self.config.get("port", "COM1")) +``` + +--- + +## 验证 + +无需手写注册表 YAML。`@device` 装饰器 + AST 扫描会在启动或检查时生成注册表条目。 + +```bash +# 1. 模块可导入 +python -c "from unilabos.devices.. import " + +# 2. 启动测试 +unilab -g .json + +# 3. 仅检查注册表 +unilab --check_mode --skip_env_check +``` + +仅在旧代码无 `@device`、需要覆盖特殊字段、或做 `--complete_registry` 旧设备补全时,才考虑 YAML。新设备默认不要手写 YAML。 + +## 图文件节点模板 + +实验图 JSON 中的 `class` 对应 `@device(id=...)`,`config` 会传入 `__init__` 的 `config` 字典: + +```json +{ + "id": "my_device_1", + "name": "我的设备", + "children": [], + "parent": null, + "type": "device", + "class": "my_device", + "position": {"x": 0, "y": 0, "z": 0}, + "config": { + "port": "/dev/ttyUSB0", + "baudrate": 9600 + }, + "data": {} +} +``` + +工作站需要同时配置 `deck` 和 `children`: + +```json +{ + "nodes": [ + { + "id": "my_station", + "type": "device", + "class": "my_workstation", + "children": ["my_deck"], + "config": {}, + "deck": { + "data": { + "_resource_child_name": "my_deck", + "_resource_type": "unilabos.resources.my_module:MyDeck" + } + } + }, + { + "id": "my_deck", + "type": "deck", + "class": "MyDeckClass", + "parent": "my_station", + "config": {"type": "MyDeckClass", "setup": true} + } + ] +} +``` + +--- + +## 常见错误清单 + +- 缺少 `@device`:设备不会被 AST 扫描发现。 +- 只有 `@property` 没有 `@topic_config()`:属性不会稳定广播到 `status_types`。 +- `post_init` 没有 `@not_action`:会被误暴露为动作。 +- `self.data = {}`:空字典会导致属性读取和 schema 初始数据不稳定,必须预填充每个状态键。 +- 动作参数重命名:不要把同类设备已有的 `volume` 改成 `volume_ml`,参数名是接口契约。 +- `status` 使用中文或临时文本:前端和工作流依赖稳定英文状态值。 +- async 方法中使用 `time.sleep()`:应使用 `await self._ros_node.sleep(seconds)`。 +- 硬编码串口响应索引:RS-485 响应前可能有噪声字节,应先定位帧头。 +- 把硬件寄存器单位暴露给用户:对外使用物理单位,驱动内部做 scale 转换。 diff --git a/.cursor/skills/virtual-workbench/SKILL.md b/.cursor/skills/virtual-workbench/SKILL.md index 8f7aa0fef..1c295ffd6 100644 --- a/.cursor/skills/virtual-workbench/SKILL.md +++ b/.cursor/skills/virtual-workbench/SKILL.md @@ -10,7 +10,8 @@ description: Operate Virtual Workbench via REST API — prepare materials, move - **device_id**: `virtual_workbench` - **Python 源码**: `unilabos/devices/virtual/workbench.py` - **设备类**: `VirtualWorkbench` -- **动作数**: 6(`auto-prepare_materials`, `auto-move_to_heating_station`, `auto-start_heating`, `auto-move_to_output`, `transfer`, `manual_confirm`) +- **当前纳入动作**: 5 个(`auto-prepare_materials`, `auto-move_to_heating_station`, `auto-start_heating`, `auto-move_to_output`, `transfer`) +- **暂跳过动作**: `manual_confirm`、扣电测试 `test`(需要启用时先从最新注册表重新提取 schema) - **设备描述**: 模拟工作台,包含 1 个机械臂(每次操作 2s,独占锁)和 3 个加热台(每次加热 60s,可并行) ### 典型工作流程 @@ -151,7 +152,8 @@ curl -s -X POST "$BASE/api/v1/lab/mcp/run/action" \ | `auto-start_heating` | `UniLabJsonCommand` | | `auto-move_to_output` | `UniLabJsonCommand` | | `transfer` | `UniLabJsonCommandAsync` | -| `manual_confirm` | `UniLabJsonCommand` | + +> `manual_confirm` 和扣电测试 `test` 当前不纳入本 skill 的推荐操作范围;不要基于历史 JSON 直接调用,需先重新生成并校验 schema。 ### 10. 查询任务状态 @@ -225,11 +227,9 @@ curl -s -X PUT "$BASE/api/v1/edge/material/node" \ | `transfer` | `resource` | ResourceSlot | 待转移物料数组 | | `transfer` | `target_device` | DeviceSlot | 目标设备路径 | | `transfer` | `mount_resource` | ResourceSlot | 目标孔位数组 | -| `manual_confirm` | `resource` | ResourceSlot | 确认用物料数组 | -| `manual_confirm` | `target_device` | DeviceSlot | 确认用目标设备 | -| `manual_confirm` | `mount_resource` | ResourceSlot | 确认用目标孔位数组 | > `prepare_materials`、`move_to_heating_station`、`start_heating`、`move_to_output` 这 4 个动作**无 Slot 字段**,参数为纯数值/整数。 +> `manual_confirm` 先跳过,不维护其 Slot 字段表。 --- @@ -270,3 +270,13 @@ prepare_materials (count=5) ``` 创建节点时,`prepare_materials` 的 5 个 output handle(`channel_1` ~ `channel_5`)分别连接到 5 个 `move_to_heating_station` 节点的 `material_input` handle。每个 `move_to_heating_station` 的 `heating_station_output` 和 `material_number_output` 连接到对应 `start_heating` 的 `station_id_input` 和 `material_number_input`。 + +`start_heating` 完成后还需要继续连接到 `move_to_output`,否则加热完成的物料不会移出加热台: + +| source action | source handle | target action | target handle | 传递参数 | +| ------------- | ------------- | ------------- | ------------- | -------- | +| `auto-prepare_materials` | `channel_N` | `auto-move_to_heating_station` | `material_input` | `material_number` | +| `auto-move_to_heating_station` | `heating_station_output` | `auto-start_heating` | `station_id_input` | `station_id` | +| `auto-move_to_heating_station` | `material_number_output` | `auto-start_heating` | `material_number_input` | `material_number` | +| `auto-start_heating` | `heating_done_station` | `auto-move_to_output` | `output_station_input` | `station_id` | +| `auto-start_heating` | `heating_done_material` | `auto-move_to_output` | `output_material_input` | `material_number` | diff --git a/.cursor/skills/virtual-workbench/action-index.md b/.cursor/skills/virtual-workbench/action-index.md index f67d9a917..7b3401fa3 100644 --- a/.cursor/skills/virtual-workbench/action-index.md +++ b/.cursor/skills/virtual-workbench/action-index.md @@ -1,6 +1,8 @@ # Action Index — virtual_workbench -6 个动作,按功能分类。每个动作的完整 JSON Schema 在 `actions/.json`。 +当前纳入 5 个动作,按功能分类。每个动作的完整 JSON Schema 在 `actions/.json`。 + +暂跳过:`manual_confirm`、扣电测试 `test`。这两个动作需要启用时,先从最新 `req_device_registry_upload.json` 重新提取 schema 并校验参数。 --- @@ -60,17 +62,18 @@ --- -## 人工确认 +## 暂跳过动作 ### `manual_confirm` -创建人工确认节点,等待用户手动确认后继续(含物料转移上下文) +创建人工确认节点,等待用户手动确认后继续(含物料转移上下文)。当前先不纳入推荐操作范围。 - **action_type**: `UniLabJsonCommand` - **Schema**: [`actions/manual_confirm.json`](actions/manual_confirm.json) -- **核心参数**: `resource`, `target_device`, `mount_resource`, `timeout_seconds`, `assignee_user_ids` -- **占位符字段**: - - `resource` — **ResourceSlot**,物料数组 - - `target_device` — **DeviceSlot**,目标设备路径 - - `mount_resource` — **ResourceSlot**,目标孔位数组 - - `assignee_user_ids` — `unilabos_manual_confirm` 类型 +- **状态**: 暂跳过。源码参数已包含扣电测试相关字段,历史 JSON 可能过期;需要启用时重新提取 schema。 + +### `test` + +启动扣电测试。当前先不纳入本 skill。 + +- **状态**: 暂跳过。需要启用时从注册表生成 `actions/test.json` 后再补充索引。 diff --git a/recipes/msgs/recipe.yaml b/recipes/msgs/recipe.yaml index 5e6bbc85b..f821c118d 100644 --- a/recipes/msgs/recipe.yaml +++ b/recipes/msgs/recipe.yaml @@ -1,6 +1,6 @@ package: name: ros-humble-unilabos-msgs - version: 0.11.2 + version: 0.11.3 source: path: ../../unilabos_msgs target_directory: src diff --git a/recipes/unilabos/recipe.yaml b/recipes/unilabos/recipe.yaml index 3a7e50dcb..18c724f1b 100644 --- a/recipes/unilabos/recipe.yaml +++ b/recipes/unilabos/recipe.yaml @@ -1,6 +1,6 @@ package: name: unilabos - version: "0.11.2" + version: "0.11.3" source: path: ../.. diff --git a/setup.py b/setup.py index 201db6f4b..8ada8c207 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ setup( name=package_name, - version='0.11.2', + version='0.11.3', packages=find_packages(), include_package_data=True, install_requires=['setuptools'], diff --git a/unilabos/__init__.py b/unilabos/__init__.py index e2bd0728a..1bebb74e8 100644 --- a/unilabos/__init__.py +++ b/unilabos/__init__.py @@ -1 +1 @@ -__version__ = "0.11.2" +__version__ = "0.11.3" diff --git a/unilabos/app/ws_client.py b/unilabos/app/ws_client.py index fbe19b43e..6e9b50f6c 100644 --- a/unilabos/app/ws_client.py +++ b/unilabos/app/ws_client.py @@ -1034,11 +1034,16 @@ def _notify_resource_tree(dev_id, act, item_list): success = host_node.notify_resource_tree_update(dev_id, act, item_list) - if success: + if success is True: logger.info( f"[MessageProcessor] Resource tree {act} completed for device {dev_id}, " f"items: {len(item_list)}" ) + elif success is None: + logger.info( + f"[MessageProcessor] Resource tree {act} skipped for device {dev_id}: " + "在线增加设备暂不支持" + ) else: logger.warning(f"[MessageProcessor] Resource tree {act} failed for device {dev_id}") @@ -1062,6 +1067,11 @@ async def _handle_device_manage(self, device_list: list[ResourceDictType], actio for item in device_list: target_node_id = item.get("target_node_id", "host_node") + if action == "add": + logger.info( + f"[DeviceManage] 在线增加设备暂不支持,跳过 add_device: {item.get('id', '')}" + ) + continue def _notify(target_id: str, act: str, cfg: ResourceDictType): try: diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index 72514b99e..8a732c803 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -45,6 +45,7 @@ ) from unilabos.resources.plr_additional_res_reg import register from unilabos.ros.msgs.message_converter import ( + String, convert_to_ros_msg, convert_from_ros_msg_with_mapping, convert_to_ros_msg_with_mapping, @@ -250,7 +251,8 @@ def __init__( ): self.node = node self.name = name - self.msg_type = msg_type + self.msg_type = self._normalize_msg_type(msg_type) + self.original_msg_type = msg_type self.get_method = get_method self.timer_period = initial_period self.print_publish = print_publish @@ -258,16 +260,36 @@ def __init__( self._value = None try: - self.publisher_ = node.create_publisher(msg_type, f"{name}", qos) + self.publisher_ = node.create_publisher(self.msg_type, f"{name}", qos) except Exception as e: self.node.lab_logger().error( - f"StatusError, DeviceId: {self.node.device_id} 创建发布者 {name} 失败,可能由于注册表有误,类型: {msg_type},错误: {e}" + f"StatusError, DeviceId: {self.node.device_id} 创建发布者 {name} 失败," + f"可能由于注册表有误,类型: {msg_type},错误: {e}" ) + self.msg_type = String + try: + self.publisher_ = node.create_publisher(self.msg_type, f"{name}", qos) + self.node.lab_logger().warning( + f"属性 {name} 的发布类型已降级为 String,原始类型: {msg_type}" + ) + except Exception: + self.publisher_ = None self.timer = node.create_timer(self.timer_period, self.publish_property) self.__loop = ROS2DeviceNode.get_asyncio_loop() - str_msg_type = str(msg_type)[8:-2] + str_msg_type = str(self.msg_type)[8:-2] self.node.lab_logger().trace(f"发布属性: {name}, 类型: {str_msg_type}, 周期: {initial_period}秒, QoS: {qos}") + @staticmethod + def _normalize_msg_type(msg_type): + if msg_type in (dict, list, tuple, set) or msg_type in ("dict", "list", "tuple", "set"): + return String + return msg_type + + def _normalize_value(self, value): + if self.msg_type is String and isinstance(value, (dict, list, tuple, set)): + return json.dumps(value, ensure_ascii=False, cls=TypeEncoder) + return value + def get_property(self): if asyncio.iscoroutinefunction(self.get_method): # 如果是异步函数,运行事件循环并等待结果 @@ -302,12 +324,16 @@ def publish_property(self): pass # self.node.lab_logger().trace(f"【.publish_property】发布 {self.msg_type}: {value}") if value is not None: + if self.publisher_ is None: + return + value = self._normalize_value(value) msg = convert_to_ros_msg(self.msg_type, value) self.publisher_.publish(msg) # self.node.lab_logger().trace(f"【.publish_property】属性 {self.name} 发布成功") except Exception as e: + topic = getattr(self.publisher_, "topic", self.name) self.node.lab_logger().error( - f"【.publish_property】发布属性 {self.publisher_.topic} 出错: {str(e)}\n{traceback.format_exc()}" + f"【.publish_property】发布属性 {topic} 出错: {str(e)}\n{traceback.format_exc()}" ) def change_frequency(self, period): diff --git a/unilabos/ros/nodes/presets/host_node.py b/unilabos/ros/nodes/presets/host_node.py index 26b925bb6..9e34c16b4 100644 --- a/unilabos/ros/nodes/presets/host_node.py +++ b/unilabos/ros/nodes/presets/host_node.py @@ -1691,7 +1691,9 @@ def handle_pong_response(self, pong_data: dict): else: self.lab_logger().warning("⚠️ 收到无效的Pong响应(缺少ping_id)") - def notify_resource_tree_update(self, device_id: str, action: str, resource_uuid_list: List[str]) -> bool: + def notify_resource_tree_update( + self, device_id: str, action: str, resource_uuid_list: List[str] + ) -> Optional[bool]: """ 通知设备节点更新资源树 @@ -1701,13 +1703,14 @@ def notify_resource_tree_update(self, device_id: str, action: str, resource_uuid resource_uuid_list: 资源UUIDs Returns: - bool: 操作是否成功 + True if the update completed, False if it failed, None if it was intentionally skipped. """ try: - # 检查设备是否存在 if device_id not in self.devices_names: - self.lab_logger().error(f"[Host Node-Resource] Device {device_id} not found in devices_names") - return False + self.lab_logger().info( + f"[Host Node-Resource] 在线增加设备暂不支持,跳过设备 {device_id} 的资源树 {action} 更新" + ) + return None namespace = self.devices_names[device_id] device_key = f"{namespace}/{device_id}" diff --git a/unilabos_msgs/package.xml b/unilabos_msgs/package.xml index a1ab4e856..3e49ed044 100644 --- a/unilabos_msgs/package.xml +++ b/unilabos_msgs/package.xml @@ -2,7 +2,7 @@ unilabos_msgs - 0.11.2 + 0.11.3 ROS2 Messages package for unilabos devices Junhan Chang Xuwznln From 25feb7e21364d828936e9b5aab3949493b15c837 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Mon, 25 May 2026 01:05:46 +0800 Subject: [PATCH 40/56] fix windows mamba install in conda workflows Use the Windows mamba.bat entrypoint from Git Bash so the setup-miniconda wrapper does not break after recent runner updates. --- .github/workflows/multi-platform-build.yml | 6 +++++- .github/workflows/unilabos-conda-build.yml | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/multi-platform-build.yml b/.github/workflows/multi-platform-build.yml index 13f877c8c..9187331a0 100644 --- a/.github/workflows/multi-platform-build.yml +++ b/.github/workflows/multi-platform-build.yml @@ -115,7 +115,11 @@ jobs: - name: Install rattler-build and anaconda-client if: steps.should_build.outputs.should_build == 'true' run: | - mamba install -n build-env --override-channels -c conda-forge rattler-build anaconda-client -y + if [[ "$RUNNER_OS" == "Windows" ]]; then + cmd //D //S //C "call \"%CONDA%\\condabin\\mamba.bat\" install -n build-env --override-channels -c conda-forge rattler-build anaconda-client -y" + else + mamba install -n build-env --override-channels -c conda-forge rattler-build anaconda-client -y + fi - name: Show environment info if: steps.should_build.outputs.should_build == 'true' diff --git a/.github/workflows/unilabos-conda-build.yml b/.github/workflows/unilabos-conda-build.yml index 0fc532b04..75ef3e480 100644 --- a/.github/workflows/unilabos-conda-build.yml +++ b/.github/workflows/unilabos-conda-build.yml @@ -108,7 +108,11 @@ jobs: - name: Install rattler-build and anaconda-client if: steps.should_build.outputs.should_build == 'true' run: | - mamba install -n build-env --override-channels -c conda-forge rattler-build anaconda-client -y + if [[ "$RUNNER_OS" == "Windows" ]]; then + cmd //D //S //C "call \"%CONDA%\\condabin\\mamba.bat\" install -n build-env --override-channels -c conda-forge rattler-build anaconda-client -y" + else + mamba install -n build-env --override-channels -c conda-forge rattler-build anaconda-client -y + fi - name: Show environment info if: steps.should_build.outputs.should_build == 'true' From e96a52c9b61e1c2d215a734e8ed6cecb4879161b Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Mon, 25 May 2026 01:29:23 +0800 Subject: [PATCH 41/56] fix windows mamba bat quoting in workflows Pass the mamba.bat command to cmd without nested escaped quotes so Git Bash does not produce an invalid program name. --- .github/workflows/multi-platform-build.yml | 2 +- .github/workflows/unilabos-conda-build.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/multi-platform-build.yml b/.github/workflows/multi-platform-build.yml index 9187331a0..cd9fadeb2 100644 --- a/.github/workflows/multi-platform-build.yml +++ b/.github/workflows/multi-platform-build.yml @@ -116,7 +116,7 @@ jobs: if: steps.should_build.outputs.should_build == 'true' run: | if [[ "$RUNNER_OS" == "Windows" ]]; then - cmd //D //S //C "call \"%CONDA%\\condabin\\mamba.bat\" install -n build-env --override-channels -c conda-forge rattler-build anaconda-client -y" + cmd //D //S //C '%CONDA%\condabin\mamba.bat install -n build-env --override-channels -c conda-forge rattler-build anaconda-client -y' else mamba install -n build-env --override-channels -c conda-forge rattler-build anaconda-client -y fi diff --git a/.github/workflows/unilabos-conda-build.yml b/.github/workflows/unilabos-conda-build.yml index 75ef3e480..ed94b366a 100644 --- a/.github/workflows/unilabos-conda-build.yml +++ b/.github/workflows/unilabos-conda-build.yml @@ -109,7 +109,7 @@ jobs: if: steps.should_build.outputs.should_build == 'true' run: | if [[ "$RUNNER_OS" == "Windows" ]]; then - cmd //D //S //C "call \"%CONDA%\\condabin\\mamba.bat\" install -n build-env --override-channels -c conda-forge rattler-build anaconda-client -y" + cmd //D //S //C '%CONDA%\condabin\mamba.bat install -n build-env --override-channels -c conda-forge rattler-build anaconda-client -y' else mamba install -n build-env --override-channels -c conda-forge rattler-build anaconda-client -y fi From 595d431f027da5b4fee2b2e05f48ad362131bbc0 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Mon, 25 May 2026 01:33:13 +0800 Subject: [PATCH 42/56] fix conda plugin crash in windows workflows Disable conda plugins in build jobs so anaconda-auth from anaconda-client does not break conda info or conda run on Windows. --- .github/workflows/multi-platform-build.yml | 2 ++ .github/workflows/unilabos-conda-build.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/multi-platform-build.yml b/.github/workflows/multi-platform-build.yml index cd9fadeb2..0fcdd9042 100644 --- a/.github/workflows/multi-platform-build.yml +++ b/.github/workflows/multi-platform-build.yml @@ -74,6 +74,8 @@ jobs: env_file: unilabos-win64.yaml runs-on: ${{ matrix.os }} + env: + CONDA_NO_PLUGINS: "true" defaults: run: diff --git a/.github/workflows/unilabos-conda-build.yml b/.github/workflows/unilabos-conda-build.yml index ed94b366a..389c888da 100644 --- a/.github/workflows/unilabos-conda-build.yml +++ b/.github/workflows/unilabos-conda-build.yml @@ -67,6 +67,8 @@ jobs: platform: win-64 runs-on: ${{ matrix.os }} + env: + CONDA_NO_PLUGINS: "true" defaults: run: From b55888d56460b7dba789a695331ff25744e8a8d7 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Mon, 25 May 2026 01:37:12 +0800 Subject: [PATCH 43/56] fix conda plugin timing in windows workflows Set CONDA_NO_PLUGINS only after mamba installs anaconda-client so setup-miniconda can initialize normally while later conda commands avoid the anaconda-auth plugin crash. --- .github/workflows/multi-platform-build.yml | 3 +-- .github/workflows/unilabos-conda-build.yml | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/multi-platform-build.yml b/.github/workflows/multi-platform-build.yml index 0fcdd9042..17ed346bd 100644 --- a/.github/workflows/multi-platform-build.yml +++ b/.github/workflows/multi-platform-build.yml @@ -74,8 +74,6 @@ jobs: env_file: unilabos-win64.yaml runs-on: ${{ matrix.os }} - env: - CONDA_NO_PLUGINS: "true" defaults: run: @@ -122,6 +120,7 @@ jobs: else mamba install -n build-env --override-channels -c conda-forge rattler-build anaconda-client -y fi + echo "CONDA_NO_PLUGINS=true" >> "$GITHUB_ENV" - name: Show environment info if: steps.should_build.outputs.should_build == 'true' diff --git a/.github/workflows/unilabos-conda-build.yml b/.github/workflows/unilabos-conda-build.yml index 389c888da..40ec9c63a 100644 --- a/.github/workflows/unilabos-conda-build.yml +++ b/.github/workflows/unilabos-conda-build.yml @@ -67,8 +67,6 @@ jobs: platform: win-64 runs-on: ${{ matrix.os }} - env: - CONDA_NO_PLUGINS: "true" defaults: run: @@ -115,6 +113,7 @@ jobs: else mamba install -n build-env --override-channels -c conda-forge rattler-build anaconda-client -y fi + echo "CONDA_NO_PLUGINS=true" >> "$GITHUB_ENV" - name: Show environment info if: steps.should_build.outputs.should_build == 'true' From eea0f0180f221650da6a4a4bcbc802be591732a8 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Mon, 25 May 2026 01:41:55 +0800 Subject: [PATCH 44/56] fix windows conda run encoding in workflows Enable UTF-8 for later conda run commands so anaconda-client version output does not fail under the Windows code page. --- .github/workflows/multi-platform-build.yml | 2 ++ .github/workflows/unilabos-conda-build.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/multi-platform-build.yml b/.github/workflows/multi-platform-build.yml index 17ed346bd..5d3496a08 100644 --- a/.github/workflows/multi-platform-build.yml +++ b/.github/workflows/multi-platform-build.yml @@ -121,6 +121,8 @@ jobs: mamba install -n build-env --override-channels -c conda-forge rattler-build anaconda-client -y fi echo "CONDA_NO_PLUGINS=true" >> "$GITHUB_ENV" + echo "PYTHONUTF8=1" >> "$GITHUB_ENV" + echo "PYTHONIOENCODING=utf-8" >> "$GITHUB_ENV" - name: Show environment info if: steps.should_build.outputs.should_build == 'true' diff --git a/.github/workflows/unilabos-conda-build.yml b/.github/workflows/unilabos-conda-build.yml index 40ec9c63a..f269606c3 100644 --- a/.github/workflows/unilabos-conda-build.yml +++ b/.github/workflows/unilabos-conda-build.yml @@ -114,6 +114,8 @@ jobs: mamba install -n build-env --override-channels -c conda-forge rattler-build anaconda-client -y fi echo "CONDA_NO_PLUGINS=true" >> "$GITHUB_ENV" + echo "PYTHONUTF8=1" >> "$GITHUB_ENV" + echo "PYTHONIOENCODING=utf-8" >> "$GITHUB_ENV" - name: Show environment info if: steps.should_build.outputs.should_build == 'true' From fe784c018dcb3e158faa031bf264197c64e7ed48 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Sat, 30 May 2026 18:12:34 +0800 Subject: [PATCH 45/56] display name + websocket cache + websocket logger --- .cursor/skills/add-device/SKILL.md | 45 ++++-- docs/developer_guide/add_device.md | 25 +++- unilabos/app/main.py | 93 ++++++++++-- unilabos/app/web/client.py | 28 ++++ unilabos/app/ws_client.py | 135 +++++++++++++++++- unilabos/registry/ast_registry_scanner.py | 24 +++- unilabos/registry/decorators.py | 23 ++- unilabos/utils/log.py | 166 ++++++++++++++++++---- 8 files changed, 471 insertions(+), 68 deletions(-) diff --git a/.cursor/skills/add-device/SKILL.md b/.cursor/skills/add-device/SKILL.md index dc77c6b76..b294dc18a 100644 --- a/.cursor/skills/add-device/SKILL.md +++ b/.cursor/skills/add-device/SKILL.md @@ -109,10 +109,10 @@ from unilabos.registry.decorators import device # 单设备 @device( - id="my_device.vendor", # 注册表唯一标识(必填) + id="my_device_vendor", # 注册表唯一标识(必填,只能包含英文、数字、下划线) category=["temperature"], # 分类标签列表(必填) description="设备描述", # 设备描述 - display_name="显示名称", # UI 显示名称(默认用 id) + displayname="显示名称", # UI 显示名称(默认用 id) icon="DeviceIcon.webp", # 图标文件名 version="1.0.0", # 版本号 device_type="python", # "python" 或 "ros2" @@ -123,15 +123,20 @@ from unilabos.registry.decorators import device # 多设备(同一个类注册多个设备 ID,各自有不同的 handles 等配置) @device( - ids=["pump.vendor.model_A", "pump.vendor.model_B"], + ids=["pump_vendor_model_A", "pump_vendor_model_B"], id_meta={ - "pump.vendor.model_A": {"handles": [...], "description": "型号 A"}, - "pump.vendor.model_B": {"handles": [...], "description": "型号 B"}, + "pump_vendor_model_A": {"handles": [...], "description": "型号 A", "displayname": "泵型号 A"}, + "pump_vendor_model_B": {"handles": [...], "description": "型号 B", "displayname": "泵型号 B"}, }, category=["pump_and_valve"], ) ``` +**ID 与显示名规则:** +- `id` / `ids` 是注册表稳定标识,只能包含英文大小写字母、数字、下划线,推荐格式为 `vendor_model` 或 `category_vendor_model`。 +- `id` / `ids` 不能包含中文、空格、短横线、点号或其他符号;不要把中文设备名放进 id。 +- 中文名、品牌型号展示名、UI 友好名称使用 `displayname`,不要塞进 `id`。 + ### @action — 动作方法装饰器 ```python @@ -239,23 +244,34 @@ from unilabos.registry.decorators import action, device, not_action, topic_confi id="my_device", category=["my_category"], description="设备描述", - display_name="设备显示名", + displayname="设备显示名", ) class MyDevice: """设备类说明。""" _ros_node: BaseROS2DeviceNode - def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs): + def __init__( + self, + device_id: Optional[str] = None, + port: str = "COM1", + baudrate: int = 9600, + timeout: float = 1.0, + **kwargs, + ): """ 初始化设备。 Args: device_id[设备ID]: 设备实例 ID,默认使用 my_device。 - config[设备配置]: 设备启动配置。 + port[串口]: 设备串口号,例如 COM1 或 /dev/ttyUSB0。 + baudrate[波特率]: 串口波特率。 + timeout[超时时间(s)]: 通信超时时间,单位秒。 """ self.device_id = device_id or "my_device" - self.config = config or {} + self.port = port + self.baudrate = baudrate + self.timeout = timeout self.logger = logging.getLogger(f"MyDevice.{self.device_id}") self.data: Dict[str, Any] = {"status": "Idle"} @@ -302,7 +318,8 @@ class MyDevice: ### 要点 - `_ros_node: BaseROS2DeviceNode` 类型标注放在类体顶部 -- `__init__` 签名固定为 `(self, device_id=None, config=None, **kwargs)` +- `__init__` 中需要现场配置的参数按基础类型显式展开,例如 `port: str`、`baudrate: int`、`timeout: float`、`enabled: bool`;不要把所有配置塞进单个 `config: dict` +- `__init__` 保留 `device_id` 和 `**kwargs` 兼容运行时注入,但不要把 `**kwargs` 当成主要配置入口 - `post_init` 用 `@not_action` 标记,参数类型标注为 `BaseROS2DeviceNode` - 运行时状态存储在 `self.data` 字典中 - 设备文件放在 `unilabos/devices//` 目录下 @@ -370,9 +387,8 @@ SDK 封装: from my_device_sdk import DeviceController class MyDevice: - def __init__(self, device_id=None, config=None, **kwargs): - self.config = config or {} - self.controller = DeviceController(port=self.config.get("port", "COM1")) + def __init__(self, device_id=None, port: str = "COM1", timeout: float = 1.0, **kwargs): + self.controller = DeviceController(port=port, timeout=timeout) ``` --- @@ -396,7 +412,7 @@ unilab --check_mode --skip_env_check ## 图文件节点模板 -实验图 JSON 中的 `class` 对应 `@device(id=...)`,`config` 会传入 `__init__` 的 `config` 字典: +实验图 JSON 中的 `class` 对应 `@device(id=...)`。`config` 中的字段应对应 `__init__` 的同名基础类型参数,不要只定义一个 `config: dict` 参数承载所有配置: ```json { @@ -449,6 +465,7 @@ unilab --check_mode --skip_env_check ## 常见错误清单 - 缺少 `@device`:设备不会被 AST 扫描发现。 +- `@device(id=...)` 使用中文、点号、短横线或空格:id 必须只包含英文、数字、下划线,显示名称用 `displayname`。 - 只有 `@property` 没有 `@topic_config()`:属性不会稳定广播到 `status_types`。 - `post_init` 没有 `@not_action`:会被误暴露为动作。 - `self.data = {}`:空字典会导致属性读取和 schema 初始数据不稳定,必须预填充每个状态键。 diff --git a/docs/developer_guide/add_device.md b/docs/developer_guide/add_device.md index 15ba4e087..c2c6d293a 100644 --- a/docs/developer_guide/add_device.md +++ b/docs/developer_guide/add_device.md @@ -17,7 +17,12 @@ Python 类设备驱动在完成注册表后可以直接在 Uni-Lab 中使用, ```python from unilabos.registry.decorators import device, topic_config -@device(id="mock_gripper", category=["gripper"], description="Mock Gripper") +@device( + id="mock_gripper", + category=["gripper"], + description="Mock Gripper", + displayname="模拟夹爪", +) class MockGripper: def __init__(self): self._position: float = 0.0 @@ -188,7 +193,12 @@ Uni-Lab 设备驱动是一个 Python 类,需要遵循以下结构: from typing import Dict, Any from unilabos.registry.decorators import device, topic_config -@device(id="my_device", category=["general"], description="My Device") +@device( + id="my_device", + category=["general"], + description="My Device", + displayname="我的设备", +) class MyDevice: """设备类文档字符串 @@ -929,14 +939,21 @@ class MyDevice: ```python from unilabos.registry.decorators import device -@device(id="my_device", category=["heating"], description="My Heating Device", icon="heater.webp") +@device( + id="my_heating_device", + category=["heating"], + description="My Heating Device", + displayname="加热设备", + icon="heater.webp", +) class MyDevice: ... ``` -- `id`:设备唯一标识符,用于注册表匹配 +- `id`:设备唯一标识符,用于注册表匹配;只能包含英文大小写字母、数字、下划线,不能包含中文、空格、短横线、点号或其他符号 - `category`:分类列表,前端用于分组显示 - `description`:设备描述 +- `displayname`:设备显示名称,用于 UI 展示中文名或更友好的名称;不要把显示名写进 `id` - `icon`:图标文件名(可选) ### 2. 使用 `@topic_config` 声明需要广播的状态 diff --git a/unilabos/app/main.py b/unilabos/app/main.py index 8de9a75ff..d51c0a522 100644 --- a/unilabos/app/main.py +++ b/unilabos/app/main.py @@ -1,5 +1,6 @@ import argparse import asyncio +import json import os import platform import shutil @@ -339,6 +340,29 @@ def parse_args(): return parser +def _resolve_graph_file_path(file_path: str | None) -> str | None: + if file_path is None: + return None + if os.path.isfile(file_path): + return file_path + temp_file_path = os.path.abspath(str(os.path.join(__file__, "..", "..", file_path))) + if os.path.isfile(temp_file_path): + print_status(f"使用相对路径{temp_file_path}", "info") + return temp_file_path + return file_path + + +def _load_graph_json_preview(file_path: str | None) -> Dict[str, Any] | None: + if not file_path or not file_path.endswith(".json") or not os.path.isfile(file_path): + return None + try: + with open(file_path, encoding="utf-8") as f: + return json.load(f) + except Exception as exc: + print_status(f"预读取 graph JSON 失败,跳过 community 包解析: {exc}", "warning") + return None + + def main(): """主函数""" # 解析命令行参数 @@ -436,7 +460,7 @@ def main(): load_config_from_file(config_path) # 根据配置重新设置日志级别 - from unilabos.utils.log import configure_logger, logger + from unilabos.utils.log import configure_logger, configure_comm_logger, logger if hasattr(BasicConfig, "log_level"): logger.info(f"Log level set to '{BasicConfig.log_level}' from config file.") @@ -444,6 +468,11 @@ def main(): if file_path is not None: logger.info(f"[LOG_FILE] {file_path}") + # 为服务端通信(WebSocket)配置独立日志,避免与主日志混在一起,便于排查通信机制 + comm_log_path = configure_comm_logger(loglevel=BasicConfig.log_level, working_dir=working_dir) + if comm_log_path is not None: + logger.info(f"[COMM_LOG_FILE] {comm_log_path}") + if args.addr != parser.get_default("addr"): if args.addr == "test": print_status("使用测试环境地址", "info") @@ -504,6 +533,52 @@ def main(): # 显示启动横幅 print_unilab_banner(args_dict) + # Step -1: 预读取 graph 中的 community.* class,并在 build_registry 前挂载社区设备包 + if not check_mode and not workflow_upload: + startup_json_preview = None + graph_file_path = _resolve_graph_file_path(args_dict.get("graph") or BasicConfig.startup_json_path) + args_dict["_graph_file_path"] = graph_file_path + graph_preview = _load_graph_json_preview(graph_file_path) + + http_client_for_community = None + if BasicConfig.ak and BasicConfig.sk: + from unilabos.app.web import http_client as _http_client_for_community + + http_client_for_community = _http_client_for_community + if graph_preview is None and graph_file_path is None: + startup_json_preview = http_client_for_community.request_startup_json() + args_dict["_startup_json"] = startup_json_preview + graph_preview = startup_json_preview + + if graph_preview: + from unilabos.app.community_packages import ( + CommunityPackageError, + apply_community_aliases, + prepare_community_packages, + ) + + try: + community_result = prepare_community_packages( + graph_preview, + working_dir=BasicConfig.working_dir, + http_client=http_client_for_community, + ) + except CommunityPackageError as exc: + print_status(str(exc), "error") + os._exit(1) + + if community_result.devices_dirs: + existing_devices_dirs = args_dict.get("devices") or [] + args_dict["devices"] = existing_devices_dirs + community_result.devices_dirs + if not skip_env_check: + from unilabos.utils.environment_check import check_device_package_requirements + + if not check_device_package_requirements(args_dict["devices"]): + print_status("community 设备包依赖检查失败,程序退出", "error") + os._exit(1) + args_dict["_community_aliases"] = community_result.aliases + args_dict["_apply_community_aliases"] = apply_community_aliases + # Step 0: AST 分析优先 + YAML 注册表加载 # check_mode 和 upload_registry 都会执行实际 import 验证 devices_dirs = args_dict.get("devices", None) @@ -517,6 +592,9 @@ def main(): complete_registry=complete_registry, external_only=external_only, ) + apply_community_aliases = args_dict.get("_apply_community_aliases") + if apply_community_aliases: + apply_community_aliases(lab_registry, args_dict.get("_community_aliases") or {}) # Check mode: 注册表验证完成后直接退出 if check_mode: @@ -567,9 +645,13 @@ def main(): graph: nx.Graph resource_tree_set: ResourceTreeSet resource_links: List[Dict[str, Any]] - request_startup_json = http_client.request_startup_json() + request_startup_json = args_dict.get("_startup_json") + if request_startup_json is None: + request_startup_json = http_client.request_startup_json() - file_path = args_dict.get("graph", BasicConfig.startup_json_path) + file_path = args_dict.get("_graph_file_path") + if file_path is None: + file_path = _resolve_graph_file_path(args_dict.get("graph") or BasicConfig.startup_json_path) if file_path is None: if not request_startup_json: print_status( @@ -580,11 +662,6 @@ def main(): print_status("联网获取设备加载文件成功", "info") graph, resource_tree_set, resource_links = read_node_link_json(request_startup_json) else: - if not os.path.isfile(file_path): - temp_file_path = os.path.abspath(str(os.path.join(__file__, "..", "..", file_path))) - if os.path.isfile(temp_file_path): - print_status(f"使用相对路径{temp_file_path}", "info") - file_path = temp_file_path if file_path.endswith(".json"): graph, resource_tree_set, resource_links = read_node_link_json(file_path) else: diff --git a/unilabos/app/web/client.py b/unilabos/app/web/client.py index 527b813ed..e9846bb3c 100644 --- a/unilabos/app/web/client.py +++ b/unilabos/app/web/client.py @@ -376,6 +376,34 @@ def request_startup_json(self) -> Optional[Dict[str, Any]]: logger.error(f"响应内容: {response.text}") return None + def resolve_community_packages( + self, + classes: List[str], + current_packages: Optional[List[Dict[str, Any]]] = None, + ) -> Dict[str, Any]: + """ + 根据 graph 中的 community.* class 解析需要加载的社区设备包。 + """ + payload = { + "classes": classes, + "machine_name": BasicConfig.machine_name, + "current_packages": current_packages or [], + } + req_path = os.path.join(BasicConfig.working_dir, "req_community_package_resolve.json") + with open(req_path, "w", encoding="utf-8") as f: + f.write(json.dumps(payload, ensure_ascii=False, indent=4)) + response = self._session.post( + f"{self.remote_addr}/lab/square/community-packages/resolve", + json=payload, + headers={"Authorization": f"Lab {self.auth}"}, + timeout=(5, 30), + ) + res_path = os.path.join(BasicConfig.working_dir, "res_community_package_resolve.json") + with open(res_path, "w", encoding="utf-8") as f: + f.write(f"{response.status_code}" + "\n" + response.text) + response.raise_for_status() + return response.json() + def workflow_import( self, name: str, diff --git a/unilabos/app/ws_client.py b/unilabos/app/ws_client.py index 6e9b50f6c..d80b6da48 100644 --- a/unilabos/app/ws_client.py +++ b/unilabos/app/ws_client.py @@ -17,9 +17,10 @@ import traceback import websockets import ssl as ssl_module +import copy from queue import Queue, Empty from dataclasses import dataclass, field -from typing import Optional, Dict, Any, List +from typing import Optional, Dict, Any, List, Tuple from urllib.parse import urlparse from enum import Enum @@ -31,7 +32,12 @@ from unilabos.utils.type_check import serialize_result_info from unilabos.app.communication import BaseCommunicationClient from unilabos.config.config import WSConfig, HTTPConfig, BasicConfig -from unilabos.utils import logger +from unilabos.utils.log import get_comm_logger + +# 服务端通信专用 logger:独立成文件(unilabos_data/logs/ws_comm_*.log), +# 全量 TRACE 落本地、微秒级时间戳 + 线程名,便于排查通信/queue 时序问题。 +# 未调用 configure_comm_logger 时安全回退到根 logger。 +logger = get_comm_logger() def format_job_log(job_id: str, task_id: str = "", device_id: str = "", action_name: str = "") -> str: @@ -103,6 +109,17 @@ class WebSocketMessage: timestamp: float = field(default_factory=time.time) +@dataclass +class JobStartCacheEntry: + """job_start幂等缓存项""" + + request_data: Dict[str, Any] + response_message: Optional[Dict[str, Any]] = None + response_status: str = "" + created_at: float = field(default_factory=time.time) + updated_at: float = field(default_factory=time.time) + + class WSResourceChatData(TypedDict): uuid: str device_uuid: str @@ -770,12 +787,25 @@ async def _handle_query_action_state(self, data: Dict[str, Any]): async def _handle_job_start(self, data: Dict[str, Any]): """处理job_start消息""" try: + data = dict(data or {}) if not data.get("sample_material"): data["sample_material"] = {} req = JobAddReq(**data) job_log = format_job_log(req.job_id, req.task_id, req.device_id, req.action) + if self.websocket_client: + is_new_request = self.websocket_client.register_job_start_request(data) + if not is_new_request: + replayed = self.websocket_client.replay_cached_job_start_response(req.job_id, req.task_id) + if replayed: + logger.info(f"[MessageProcessor] Replayed cached response for duplicate job_start {job_log}") + else: + logger.info( + f"[MessageProcessor] Duplicate job_start {job_log} received before response is cached" + ) + return + # 服务端对always_free动作可能跳过query_action_state直接发job_start, # 此时job尚未注册,需要自动补注册 existing_job = self.device_manager.get_job_info(req.job_id) @@ -1434,6 +1464,12 @@ def __init__(self): self._job_running_last_sent: Dict[str, tuple] = {} self._job_running_debounce_interval: float = 10.0 # 秒 + # job_start幂等缓存: {(task_id, job_id): JobStartCacheEntry} + self._job_start_cache: Dict[Tuple[str, str], JobStartCacheEntry] = {} + self._job_start_cache_lock = threading.RLock() + self._job_start_cache_ttl_seconds: float = 24 * 60 * 60 + self._job_start_cache_max_entries: int = 1024 + # 设置相互引用 self.message_processor.set_queue_processor(self.queue_processor) self.message_processor.set_websocket_client(self) @@ -1460,6 +1496,91 @@ def _build_websocket_url(self) -> Optional[str]: return url + @staticmethod + def _job_start_cache_key(job_id: str, task_id: str) -> Optional[Tuple[str, str]]: + if not job_id or not task_id: + return None + return task_id, job_id + + def _prune_job_start_cache_locked(self) -> None: + now = time.time() + expired_keys = [ + key + for key, entry in self._job_start_cache.items() + if now - entry.updated_at > self._job_start_cache_ttl_seconds + ] + for key in expired_keys: + self._job_start_cache.pop(key, None) + + overflow = len(self._job_start_cache) - self._job_start_cache_max_entries + if overflow <= 0: + return + + oldest_keys = sorted(self._job_start_cache, key=lambda key: self._job_start_cache[key].updated_at)[:overflow] + for key in oldest_keys: + self._job_start_cache.pop(key, None) + + def register_job_start_request(self, request_data: Dict[str, Any]) -> bool: + """登记job_start请求;返回False表示同一(task_id, job_id)已处理过。""" + key = self._job_start_cache_key(request_data.get("job_id", ""), request_data.get("task_id", "")) + if key is None: + return True + + with self._job_start_cache_lock: + self._prune_job_start_cache_locked() + cached = self._job_start_cache.get(key) + if cached is not None: + cached.updated_at = time.time() + if cached.request_data != request_data: + logger.warning( + "[WebSocketClient] Duplicate job_start has different payload for " + f"job={key[1][:8]}, task={key[0][:8]}" + ) + return False + + self._job_start_cache[key] = JobStartCacheEntry(request_data=copy.deepcopy(request_data)) + self._prune_job_start_cache_locked() + return True + + def cache_job_start_response(self, item: QueueItem, message: Dict[str, Any], status: str) -> None: + """缓存job_start对应的最新回复,供断链重发时回放。""" + key = self._job_start_cache_key(item.job_id, item.task_id) + if key is None: + return + + with self._job_start_cache_lock: + cached = self._job_start_cache.get(key) + if cached is None: + cached = JobStartCacheEntry(request_data={}) + self._job_start_cache[key] = cached + + cached.response_message = copy.deepcopy(message) + cached.response_status = status + cached.updated_at = time.time() + self._prune_job_start_cache_locked() + + def replay_cached_job_start_response(self, job_id: str, task_id: str) -> bool: + """回放同一job_start之前产生的最新回复。""" + key = self._job_start_cache_key(job_id, task_id) + if key is None: + return False + + with self._job_start_cache_lock: + cached = self._job_start_cache.get(key) + if cached is None or cached.response_message is None: + return False + message = copy.deepcopy(cached.response_message) + status = cached.response_status + cached.updated_at = time.time() + + sent = self.message_processor.send_message(message) + if sent: + logger.info( + "[WebSocketClient] Replayed cached job_start response " + f"job={job_id[:8]}, task={task_id[:8]}, status={status}" + ) + return sent + def start(self) -> None: """启动WebSocket客户端""" if self.is_disabled: @@ -1529,10 +1650,6 @@ def publish_job_status( self, feedback_data: dict, item: QueueItem, status: str, return_info: Optional[dict] = None ) -> None: """发布作业状态,拦截最终结果(给HostNode调用的接口)""" - if not self.is_connected(): - logger.debug(f"[WebSocketClient] Not connected, cannot publish job status for job_id: {item.job_id}") - return - job_log = format_job_log(item.job_id, item.task_id, item.device_id, item.action_name) # 拦截最终结果状态,与原版本逻辑一致 @@ -1573,6 +1690,12 @@ def publish_job_status( "timestamp": time.time(), }, } + self.cache_job_start_response(item, message, status) + + if not self.is_connected(): + logger.debug(f"[WebSocketClient] Not connected, cached job status for job {job_log} - {status}") + return + self.message_processor.send_message(message) logger.trace(f"[WebSocketClient] Job status published: {job_log} - {status}") diff --git a/unilabos/registry/ast_registry_scanner.py b/unilabos/registry/ast_registry_scanner.py index 530714994..eb4b5bf9b 100644 --- a/unilabos/registry/ast_registry_scanner.py +++ b/unilabos/registry/ast_registry_scanner.py @@ -20,6 +20,7 @@ import ast import hashlib import json +import re import time from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path @@ -32,12 +33,22 @@ MAX_SCAN_DEPTH = 10 # 最大目录递归深度 MAX_SCAN_FILES = 1000 # 最大扫描文件数量 -_CACHE_VERSION = 2 # 缓存格式版本号,格式变更时递增 +_CACHE_VERSION = 3 # 缓存格式版本号,格式变更时递增 +_DEVICE_ID_RE = re.compile(r"^[A-Za-z0-9_]+$") # 合法的装饰器来源模块 _REGISTRY_DECORATOR_MODULE = "unilabos.registry.decorators" +def _validate_device_ids(device_ids: List[str]) -> None: + invalid_ids = [device_id for device_id in device_ids if not _DEVICE_ID_RE.fullmatch(device_id)] + if invalid_ids: + raise ValueError( + "@device id 只能包含英文、数字、下划线: " + + ", ".join(repr(device_id) for device_id in invalid_ids) + ) + + # --------------------------------------------------------------------------- # File-level cache helpers # --------------------------------------------------------------------------- @@ -342,14 +353,16 @@ def _parse_file( did = device_args.get("id") or device_args.get("device_id") device_ids = [did] if did else [f"{module_path}:{node.name}"] + _validate_device_ids(device_ids) id_meta = device_args.get("id_meta") or {} + display_name = device_args.get("displayname") or device_args.get("display_name", "") base_meta = { "class_name": node.name, "module": f"{module_path}:{node.name}", "file_path": str(filepath).replace("\\", "/"), "category": device_args.get("category", []), "description": device_args.get("description", ""), - "display_name": device_args.get("display_name", ""), + "display_name": display_name, "icon": device_args.get("icon", ""), "version": device_args.get("version", "1.0.0"), "device_type": _detect_class_type(node, import_map), @@ -367,9 +380,12 @@ def _parse_file( meta = dict(base_meta) meta["device_id"] = did overrides = id_meta.get(did, {}) - for key in ("handles", "description", "icon", "model", "hardware_interface"): + for key in ("handles", "description", "display_name", "displayname", "icon", "model", "hardware_interface"): if key in overrides: - meta[key] = overrides[key] + if key == "displayname": + meta["display_name"] = overrides[key] + else: + meta[key] = overrides[key] devices.append(meta) # --- @resource on classes --- diff --git a/unilabos/registry/decorators.py b/unilabos/registry/decorators.py index 1dffe1697..927376e66 100644 --- a/unilabos/registry/decorators.py +++ b/unilabos/registry/decorators.py @@ -12,9 +12,10 @@ ) @device( - id="solenoid_valve.mock", + id="solenoid_valve_mock", category=["pump_and_valve"], description="模拟电磁阀设备", + displayname="模拟电磁阀", handles=[ InputHandle(key="in", data_type="fluid", label="in", side=Side.NORTH), OutputHandle(key="out", data_type="fluid", label="out", side=Side.SOUTH), @@ -46,11 +47,13 @@ def is_open(self): from enum import Enum from functools import wraps +import re from typing import Any, Callable, Dict, List, Optional, TypeVar from pydantic import BaseModel, ConfigDict, Field F = TypeVar("F", bound=Callable[..., Any]) +_DEVICE_ID_RE = re.compile(r"^[A-Za-z0-9_]+$") # --------------------------------------------------------------------------- # 枚举 @@ -248,6 +251,7 @@ def device( category: Optional[List[str]] = None, description: str = "", display_name: str = "", + displayname: str = "", icon: str = "", version: str = "1.0.0", handles: Optional[List[_DeviceHandleBase]] = None, @@ -270,7 +274,8 @@ def device( id_meta: 每个 device_id 的覆盖元数据 (handles/description/icon/model) category: 设备分类标签列表 (必填) description: 设备描述 - display_name: 人类可读的设备显示名称,缺失时默认使用 id + displayname: 人类可读的设备显示名称,缺失时默认使用 id + display_name: 兼容旧代码的显示名称参数;新代码优先使用 displayname icon: 图标路径 version: 版本号 handles: 设备端口列表 (单设备或 id_meta 未覆盖时使用) @@ -290,13 +295,21 @@ def device( else: raise ValueError("@device 必须提供 id 或 ids") + invalid_ids = [did for did in device_ids if not _DEVICE_ID_RE.fullmatch(did)] + if invalid_ids: + raise ValueError( + "@device id 只能包含英文、数字、下划线: " + + ", ".join(repr(did) for did in invalid_ids) + ) + if category is None: raise ValueError("@device category 必填") + resolved_display_name = displayname or display_name base_meta = { "category": category, "description": description, - "display_name": display_name, + "display_name": resolved_display_name, "icon": icon, "version": version, "handles": _device_handles_to_list(handles), @@ -505,12 +518,14 @@ def get_device_meta(cls, device_id: Optional[str] = None) -> Optional[Dict[str, overrides = id_meta[device_id] result = dict(base) result["device_id"] = device_id - for key in ["handles", "description", "icon", "model"]: + for key in ["handles", "description", "display_name", "displayname", "icon", "model"]: if key in overrides: val = overrides[key] if key == "handles" and isinstance(val, list): # handles 必须是 Handle 对象列表 result[key] = [h.to_registry_dict() for h in val] + elif key == "displayname": + result["display_name"] = val else: result[key] = val return result diff --git a/unilabos/utils/log.py b/unilabos/utils/log.py index da085f147..25fe99cb0 100644 --- a/unilabos/utils/log.py +++ b/unilabos/utils/log.py @@ -63,10 +63,31 @@ class ColoredFormatter(logging.Formatter): "DATE": "\033[37m", # 日期始终使用灰色 } - def __init__(self, use_colors=True): + def __init__(self, use_colors=True, microseconds=False, show_thread=False): super().__init__() # 强制启用颜色 self.use_colors = use_colors + # microseconds: 保留微秒级时间戳(默认毫秒),便于精确排查时序 + self.microseconds = microseconds + # show_thread: 输出线程名,便于区分 queue/收发等并发逻辑 + self.show_thread = show_thread + + def _format_datetime(self, record) -> str: + """构建时间戳字符串,可选微秒级精度""" + datetime_str = datetime.fromtimestamp(record.created).strftime("%y-%m-%d [%H:%M:%S,%f") + if not self.microseconds: + datetime_str = datetime_str[:-3] # 截断到毫秒 + return datetime_str + "]" + + def _format_right_info(self, record) -> str: + """构建右侧的线程/函数/模块定位信息""" + filename = record.filename.replace(".py", "").split("\\")[-1] # 提取文件名(不含路径和扩展名) + if "/" in filename: + filename = filename.split("/")[-1] + module_path = f"{record.name}.{filename}" + func_line = f"{record.funcName}:{record.lineno}" + thread_part = f" [{record.threadName}]" if self.show_thread else "" + return f"{thread_part} [{func_line}] [{module_path}]" def format(self, record): # 检查是否有自定义堆栈信息 @@ -87,15 +108,10 @@ def format(self, record): reset = self.COLORS["RESET"] # 日期格式 - datetime_str = datetime.fromtimestamp(record.created).strftime("%y-%m-%d [%H:%M:%S,%f")[:-3] + "]" + datetime_str = self._format_datetime(record) - # 模块和函数信息 - filename = record.filename.replace(".py", "").split("\\")[-1] # 提取文件名(不含路径和扩展名) - if "/" in filename: - filename = filename.split("/")[-1] - module_path = f"{record.name}.{filename}" - func_line = f"{record.funcName}:{record.lineno}" - right_info = f" [{func_line}] [{module_path}]" + # 线程、模块和函数信息 + right_info = self._format_right_info(record) # 主要消息 main_msg = record.getMessage() @@ -123,13 +139,8 @@ def format(self, record): def _format_basic(self, record): """基本格式化,不包含颜色""" - datetime_str = datetime.fromtimestamp(record.created).strftime("%y-%m-%d [%H:%M:%S,%f")[:-3] + "]" - filename = record.filename.replace(".py", "").split("\\")[-1] # 提取文件名(不含路径和扩展名) - if "/" in filename: - filename = filename.split("/")[-1] - module_path = f"{record.name}.{filename}" - func_line = f"{record.funcName}:{record.lineno}" - right_info = f" [{func_line}] [{module_path}]" + datetime_str = self._format_datetime(record) + right_info = self._format_right_info(record) formatted_message = f"{datetime_str} [{record.levelname}] {record.getMessage()}{right_info}" @@ -152,6 +163,26 @@ def formatException(self, exc_info): return formatted_exc +def _to_numeric_level(loglevel, default=logging.DEBUG) -> int: + """将日志级别(字符串/常量)统一转换为数字级别。 + + Args: + loglevel: 'TRACE'/'DEBUG'/'INFO'/... 字符串,或 logging 常量,或 None + default: 解析失败或为 None 时使用的默认级别 + """ + if loglevel is None: + return default + if isinstance(loglevel, str): + if loglevel.upper() == "TRACE": + return TRACE_LEVEL + numeric_level = getattr(logging, loglevel.upper(), None) + if not isinstance(numeric_level, int): + print(f"警告: 无效的日志级别 '{loglevel}',使用默认级别 DEBUG") + return default + return numeric_level + return loglevel + + # 配置日志处理器 def configure_logger(loglevel=None, working_dir=None): """配置日志记录器 @@ -164,18 +195,7 @@ def configure_logger(loglevel=None, working_dir=None): root_logger = logging.getLogger() root_logger.setLevel(TRACE_LEVEL) # 设置日志级别 - numeric_level = logging.DEBUG - if loglevel is not None: - if isinstance(loglevel, str): - # 将字符串转换为logging级别 - if loglevel.upper() == "TRACE": - numeric_level = TRACE_LEVEL - else: - numeric_level = getattr(logging, loglevel.upper(), None) - if not isinstance(numeric_level, int): - print(f"警告: 无效的日志级别 '{loglevel}',使用默认级别 DEBUG") - else: - numeric_level = loglevel + numeric_level = _to_numeric_level(loglevel) # 移除已存在的处理器 for handler in root_logger.handlers[:]: @@ -217,6 +237,96 @@ def configure_logger(loglevel=None, working_dir=None): return log_filepath +# ============================================================================ +# 服务端通信(WebSocket)独立日志 +# 单独成文件、全量保留到本地、微秒级时间戳 + 线程名,便于排查通信/queue 时序问题 +# ============================================================================ +COMM_LOGGER_NAME = "unilabos.comm" +_comm_file_handler: "logging.Handler | None" = None # 便于重启时清理 websockets 库 handler + + +def _attach_trace_method(target_logger: logging.Logger) -> logging.Logger: + """为指定 logger 附加 .trace 方法,行为与模块级 trace 一致。 + + 通过 stacklevel=2 跳过本包装函数,使日志定位到真实调用处而非此处。 + """ + if not hasattr(target_logger, "trace"): + def _trace(msg, *args, _lg=target_logger, **kwargs): + kwargs.setdefault("stacklevel", 2) + _lg.log(TRACE_LEVEL, msg, *args, **kwargs) + + target_logger.trace = _trace # type: ignore[attr-defined] + return target_logger + + +def get_comm_logger() -> logging.Logger: + """获取通信专用 logger。 + + 未调用 ``configure_comm_logger`` 之前,该 logger 没有独立 handler 且 + ``propagate=True``,会回退到根 logger,行为与现状一致(安全降级)。 + """ + return _attach_trace_method(logging.getLogger(COMM_LOGGER_NAME)) + + +def configure_comm_logger(working_dir=None, loglevel=None): + """为服务端通信(WebSocket)配置独立日志,复用 ``ColoredFormatter`` 逻辑。 + + - 独立文件:``/logs/ws_comm_<日期 时间>.log``,TRACE 全量落本地 + - 微秒级时间戳 + 线程名,便于排查 queue 机制、收发时序与并发标识 + - ``propagate=False``,与主日志解耦,避免日志混在一起 + - 控制台仍保留实时输出(级别与主控制台一致),不丢失现有可见性 + - 同步把 ``websockets`` 库自身的协议日志(握手/ping/pong/关闭)落到同一文件 + + Args: + working_dir: 工作目录(``unilabos_data``),None 时不写文件 + loglevel: 控制台日志级别,与主日志保持一致 + + Returns: + 日志文件绝对路径(未配置文件时为 None) + """ + global _comm_file_handler + + comm_logger = get_comm_logger() + comm_logger.setLevel(TRACE_LEVEL) + comm_logger.propagate = False # 与根 logger 解耦,单独成文件 + + # 移除旧 handler,支持重启重复调用 + for handler in comm_logger.handlers[:]: + comm_logger.removeHandler(handler) + handler.close() + + # 控制台 handler:保留实时可见性,带线程名便于现场观察 + console_handler = logging.StreamHandler() + console_handler.setLevel(_to_numeric_level(loglevel)) + console_handler.setFormatter(ColoredFormatter(use_colors=True, show_thread=True)) + comm_logger.addHandler(console_handler) + + log_filepath = None + if working_dir is not None: + logs_dir = os.path.join(working_dir, "logs") + os.makedirs(logs_dir, exist_ok=True) + + log_filename = "ws_comm_" + datetime.now().strftime("%Y-%m-%d %H-%M-%S") + ".log" + log_filepath = os.path.join(logs_dir, log_filename) + + file_handler = logging.FileHandler(log_filepath, encoding="utf-8") + file_handler.setLevel(TRACE_LEVEL) # 全量保留到本地 + # 文件不带颜色,开启微秒精度 + 线程名 + file_handler.setFormatter(ColoredFormatter(use_colors=False, microseconds=True, show_thread=True)) + comm_logger.addHandler(file_handler) + + # websockets 库自身日志(协议层)也归集到同一文件,方便排查链路问题; + # 保持其 propagate=True,不影响主日志原有行为。 + ws_lib_logger = logging.getLogger("websockets") + if _comm_file_handler is not None and _comm_file_handler in ws_lib_logger.handlers: + ws_lib_logger.removeHandler(_comm_file_handler) + ws_lib_logger.addHandler(file_handler) + _comm_file_handler = file_handler + + comm_logger.info(f"[CommLogger] 通信日志已初始化,文件: {log_filepath}") + return log_filepath + + # 配置日志系统 configure_logger() From d7821daa94ffb14beb7c770c70674378ff7dfacc Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Sat, 30 May 2026 18:24:09 +0800 Subject: [PATCH 46/56] community package manager --- unilabos/app/community_packages.py | 370 +++++++++++++++++++++++++++++ 1 file changed, 370 insertions(+) create mode 100644 unilabos/app/community_packages.py diff --git a/unilabos/app/community_packages.py b/unilabos/app/community_packages.py new file mode 100644 index 000000000..b8139af1c --- /dev/null +++ b/unilabos/app/community_packages.py @@ -0,0 +1,370 @@ +import hashlib +import json +import shutil +import tarfile +import tempfile +import zipfile +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional + +from unilabos.utils import logger +from unilabos.utils.banner_print import print_status + + +COMMUNITY_PREFIX = "community." +COMMUNITY_CACHE_DIR = "community_devices" +MANIFEST_FILENAME = "manifest.json" + + +class CommunityPackageError(RuntimeError): + """Raised when a graph references community packages that cannot be loaded.""" + + +@dataclass +class CommunityPackagePrepareResult: + devices_dirs: List[str] = field(default_factory=list) + aliases: Dict[str, str] = field(default_factory=dict) + classes: List[str] = field(default_factory=list) + + +def extract_community_classes(graph_data: Optional[Dict[str, Any]]) -> List[str]: + if not graph_data: + return [] + + result: List[str] = [] + for node in graph_data.get("nodes", []): + if not isinstance(node, dict): + continue + class_name = node.get("class") + if isinstance(class_name, str) and class_name.startswith(COMMUNITY_PREFIX): + result.append(class_name) + return sorted(set(result)) + + +def community_namespace(class_name: str) -> str: + parts = class_name.split(".") + if len(parts) < 2 or parts[0] != "community": + raise ValueError(f"Invalid community class: {class_name}") + return ".".join(parts[:2]) + + +def infer_alias_target(class_name: str) -> str: + namespace = community_namespace(class_name) + prefix = namespace + "." + if class_name.startswith(prefix) and len(class_name) > len(prefix): + return class_name[len(prefix):] + return class_name.rsplit(".", 1)[-1] + + +def load_manifest(working_dir: str | Path) -> Dict[str, Any]: + manifest_path = _manifest_path(working_dir) + if not manifest_path.is_file(): + return {"packages": {}} + try: + data = json.loads(manifest_path.read_text(encoding="utf-8")) + if isinstance(data, dict): + data.setdefault("packages", {}) + return data + except Exception as exc: + logger.warning(f"[CommunityPackage] manifest 读取失败: {exc}") + return {"packages": {}} + + +def save_manifest(working_dir: str | Path, manifest: Dict[str, Any]) -> None: + manifest_path = _manifest_path(working_dir) + manifest_path.parent.mkdir(parents=True, exist_ok=True) + tmp = manifest_path.with_suffix(".tmp") + tmp.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") + tmp.replace(manifest_path) + + +def prepare_community_packages( + graph_data: Optional[Dict[str, Any]], + working_dir: str | Path, + http_client: Any = None, +) -> CommunityPackagePrepareResult: + classes = extract_community_classes(graph_data) + if not classes: + return CommunityPackagePrepareResult() + + print_status(f"发现 community 设备引用: {', '.join(classes)}", "info") + manifest = load_manifest(working_dir) + packages = manifest.setdefault("packages", {}) + remote_items = _resolve_remote_packages(classes, manifest, http_client) + + devices_dirs: List[str] = [] + aliases: Dict[str, str] = {} + missing_namespaces = {community_namespace(class_name) for class_name in classes} + + for item in remote_items: + package_dir = _ensure_remote_item_cached(item, working_dir, manifest, http_client=http_client) + if package_dir: + devices_dirs.append(str(package_dir)) + + namespace = item.get("class_namespace") or (item.get("package_info") or {}).get("class_namespace") + if namespace: + missing_namespaces.discard(namespace) + aliases.update(_normalize_aliases(item, classes)) + + for namespace in list(missing_namespaces): + cached = packages.get(namespace) + if not cached: + continue + package_dir = Path(cached.get("package_dir", "")) + if package_dir.is_dir(): + devices_dirs.append(str(package_dir)) + missing_namespaces.discard(namespace) + cached_aliases = cached.get("aliases") or {} + aliases.update({str(k): str(v) for k, v in cached_aliases.items()}) + + for class_name in classes: + aliases.setdefault(class_name, infer_alias_target(class_name)) + + if missing_namespaces: + raise CommunityPackageError( + "无法加载 community 设备包: " + + ", ".join(sorted(missing_namespaces)) + + "。请检查网络、后端 resolve 接口或本地缓存。" + ) + + devices_dirs = _dedupe_existing_dirs(devices_dirs) + if devices_dirs: + print_status(f"community 设备包挂载目录: {', '.join(devices_dirs)}", "info") + + save_manifest(working_dir, manifest) + return CommunityPackagePrepareResult(devices_dirs=devices_dirs, aliases=aliases, classes=classes) + + +def apply_community_aliases(registry: Any, aliases: Dict[str, str]) -> None: + if not aliases: + return + + added: List[str] = [] + for alias, target in aliases.items(): + if alias in registry.device_type_registry or alias in registry.resource_type_registry: + continue + if target in registry.device_type_registry: + registry.device_type_registry[alias] = registry.device_type_registry[target] + added.append(alias) + elif target in registry.resource_type_registry: + registry.resource_type_registry[alias] = registry.resource_type_registry[target] + added.append(alias) + else: + logger.warning(f"[CommunityPackage] alias 目标不存在: {alias} -> {target}") + + if added: + print_status(f"已注册 community class alias: {', '.join(sorted(added))}", "info") + + +def _resolve_remote_packages(classes: List[str], manifest: Dict[str, Any], http_client: Any) -> List[Dict[str, Any]]: + if http_client is None: + return [] + try: + current_packages = [] + for namespace, info in (manifest.get("packages") or {}).items(): + current_packages.append( + { + "class_namespace": namespace, + "version": info.get("version"), + "sha256": info.get("sha256"), + } + ) + + response = http_client.resolve_community_packages(classes, current_packages=current_packages) + data = response.get("data", response) if isinstance(response, dict) else [] + if isinstance(data, list): + return [item for item in data if isinstance(item, dict)] + except Exception as exc: + logger.warning(f"[CommunityPackage] 远端 resolve 失败,将尝试本地缓存: {exc}") + return [] + + +def _ensure_remote_item_cached( + item: Dict[str, Any], + working_dir: str | Path, + manifest: Dict[str, Any], + http_client: Any = None, +) -> Optional[Path]: + package_info = item.get("package_info") or item + namespace = item.get("class_namespace") or package_info.get("class_namespace") + if not namespace: + return None + + packages = manifest.setdefault("packages", {}) + cached = packages.get(namespace) or {} + version = str(package_info.get("version") or cached.get("version") or "unknown") + sha256 = str(package_info.get("sha256") or cached.get("sha256") or "") + cached_dir = Path(cached.get("package_dir", "")) + if cached_dir.is_dir() and cached.get("version") == version and cached.get("sha256", "") == sha256: + return cached_dir + + download_url = package_info.get("download_url") + if not download_url: + if cached_dir.is_dir() and package_info.get("allow_cached_fallback"): + logger.warning(f"[CommunityPackage] {namespace} 无下载地址,使用旧缓存") + return cached_dir + raise CommunityPackageError(f"community package {namespace} 缺少 download_url") + + package_dir = _download_and_extract_package(download_url, working_dir, namespace, version, sha256, http_client) + pyproject = _find_pyproject(package_dir) + pyproject_meta = read_pyproject_metadata(pyproject) + aliases = _normalize_aliases(item, []) + + packages[namespace] = { + "class_namespace": namespace, + "version": version, + "sha256": sha256, + "download_url": download_url, + "package_dir": str(package_dir), + "pyproject": pyproject_meta, + "aliases": aliases, + } + (package_dir / "package_info.json").write_text( + json.dumps(package_info, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + return package_dir + + +def _download_and_extract_package( + download_url: str, + working_dir: str | Path, + namespace: str, + version: str, + expected_sha256: str = "", + http_client: Any = None, +) -> Path: + import requests + + normalized = _normalize_package_dir_name(namespace) + target_root = Path(working_dir) / COMMUNITY_CACHE_DIR / normalized / version + package_dir = target_root / "package" + tmp_root = Path(tempfile.mkdtemp(prefix=f"{normalized}-{version}-", dir=str(_cache_root(working_dir)))) + archive_path = tmp_root / "package.archive" + + try: + print_status(f"下载 community 设备包 {namespace}@{version}", "info") + requester = getattr(http_client, "_session", None) or requests + with requester.get(download_url, stream=True, timeout=(5, 120)) as response: + response.raise_for_status() + with archive_path.open("wb") as f: + for chunk in response.iter_content(chunk_size=1024 * 1024): + if chunk: + f.write(chunk) + + if expected_sha256: + actual = "sha256:" + _sha256_file(archive_path) + if actual != expected_sha256: + raise CommunityPackageError(f"{namespace}@{version} sha256 不匹配: {actual} != {expected_sha256}") + + extract_root = tmp_root / "extract" + extract_root.mkdir(parents=True, exist_ok=True) + _extract_archive(archive_path, extract_root) + pyproject = _find_pyproject(extract_root) + source_root = pyproject.parent + + if target_root.exists(): + shutil.rmtree(target_root) + target_root.mkdir(parents=True, exist_ok=True) + shutil.copytree(source_root, package_dir) + return package_dir + finally: + shutil.rmtree(tmp_root, ignore_errors=True) + + +def _normalize_aliases(item: Dict[str, Any], classes: Iterable[str]) -> Dict[str, str]: + raw_aliases = item.get("aliases") or {} + aliases = {str(k): str(v) for k, v in raw_aliases.items()} if isinstance(raw_aliases, dict) else {} + + namespace = item.get("class_namespace") or (item.get("package_info") or {}).get("class_namespace") + if namespace: + for class_name in classes: + if class_name.startswith(namespace + "."): + aliases.setdefault(class_name, infer_alias_target(class_name)) + return aliases + + +def read_pyproject_metadata(pyproject_path: Path) -> Dict[str, str]: + text = pyproject_path.read_text(encoding="utf-8") + result: Dict[str, str] = {} + in_project = False + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("[") and line.endswith("]"): + in_project = line == "[project]" + continue + if not in_project or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + if key in {"name", "version"}: + result[key] = value + return result + + +def _manifest_path(working_dir: str | Path) -> Path: + return _cache_root(working_dir) / MANIFEST_FILENAME + + +def _cache_root(working_dir: str | Path) -> Path: + root = Path(working_dir) / COMMUNITY_CACHE_DIR + root.mkdir(parents=True, exist_ok=True) + return root + + +def _normalize_package_dir_name(namespace: str) -> str: + return namespace.replace(COMMUNITY_PREFIX, "", 1).replace(".", "-").replace("_", "-") + + +def _dedupe_existing_dirs(paths: Iterable[str]) -> List[str]: + result: List[str] = [] + seen = set() + for path in paths: + resolved = str(Path(path).resolve()) + if resolved in seen or not Path(resolved).is_dir(): + continue + seen.add(resolved) + result.append(resolved) + return result + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _extract_archive(archive_path: Path, target_dir: Path) -> None: + if zipfile.is_zipfile(archive_path): + with zipfile.ZipFile(archive_path) as zf: + for member in zf.namelist(): + _assert_safe_archive_member(target_dir, member) + zf.extractall(target_dir) + return + if tarfile.is_tarfile(archive_path): + with tarfile.open(archive_path) as tf: + for member in tf.getmembers(): + _assert_safe_archive_member(target_dir, member.name) + tf.extractall(target_dir) + return + raise CommunityPackageError("community package 只支持 zip/tar/tar.gz 格式") + + +def _assert_safe_archive_member(target_dir: Path, member_name: str) -> None: + target_root = target_dir.resolve() + target_path = (target_dir / member_name).resolve() + if target_root != target_path and target_root not in target_path.parents: + raise CommunityPackageError(f"community package 包含非法路径: {member_name}") + + +def _find_pyproject(root: Path) -> Path: + candidates = sorted(root.rglob("pyproject.toml")) + if not candidates: + raise CommunityPackageError(f"community package 解压后未找到 pyproject.toml: {root}") + return candidates[0] From b81b7874c20602b01690106fe73e85b7904e55ca Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Sun, 31 May 2026 15:14:25 +0800 Subject: [PATCH 47/56] fix query state join queue --- unilabos/app/ws_client.py | 103 +++++++++++++++++++++++++++++++++++++- 1 file changed, 102 insertions(+), 1 deletion(-) diff --git a/unilabos/app/ws_client.py b/unilabos/app/ws_client.py index d80b6da48..d343e943f 100644 --- a/unilabos/app/ws_client.py +++ b/unilabos/app/ws_client.py @@ -144,6 +144,27 @@ def add_queue_request(self, job_info: JobInfo) -> bool: """ with self.lock: device_key = job_info.device_action_key + existing_job = self.all_jobs.get(job_info.job_id) + if existing_job is not None: + if job_info.task_id != existing_job.task_id: + logger.warning( + "[DeviceActionManager] Duplicate job_id has different task_id: " + f"{job_info.job_id[:8]} old={existing_job.task_id[:8]} new={job_info.task_id[:8]}" + ) + if job_info.notebook_id and not existing_job.notebook_id: + existing_job.notebook_id = job_info.notebook_id + existing_job.update_timestamp() + job_log = format_job_log( + existing_job.job_id, + existing_job.task_id, + existing_job.device_id, + existing_job.action_name, + ) + logger.info( + f"[DeviceActionManager] Duplicate queue request ignored for job {job_log}, " + f"status={existing_job.status}" + ) + return existing_job.status == JobStatus.READY # 总是将job添加到all_jobs中 self.all_jobs[job_info.job_id] = job_info @@ -731,6 +752,48 @@ async def _handle_query_action_state(self, data: Dict[str, Any]): logger.error("[MessageProcessor] Missing required fields in query_action_state") return + job_log = format_job_log(job_id, task_id, device_id, action_name) + existing_job = self.device_manager.get_job_info(job_id) + if existing_job and existing_job.task_id == task_id: + if existing_job.status == JobStatus.READY: + response_type, free, need_more = "query_action_status", True, 0 + elif existing_job.status == JobStatus.QUEUE: + response_type, free, need_more = "query_action_status", False, 10 + else: + response_type, free, need_more = "job_call_back_status", False, 10 + + await self._send_action_state_response( + existing_job.device_id, + existing_job.action_name, + existing_job.task_id, + existing_job.job_id, + response_type, + free, + need_more, + notebook_id=existing_job.notebook_id or notebook_id, + ) + logger.info( + f"[MessageProcessor] Returned existing job state for query_action_state {job_log}: " + f"{existing_job.status}" + ) + return + + if self.websocket_client and self.websocket_client.has_job_start_request(job_id, task_id): + replayed = self.websocket_client.replay_cached_job_start_response(job_id, task_id) + if not replayed: + await self._send_action_state_response( + device_id, + action_name, + task_id, + job_id, + "job_call_back_status", + False, + 10, + notebook_id=notebook_id, + ) + logger.info(f"[MessageProcessor] Returned cached/running state for completed query_action_state {job_log}") + return + device_action_key = f"/devices/{device_id}/{action_name}" # 检查action是否为always_free @@ -752,7 +815,6 @@ async def _handle_query_action_state(self, data: Dict[str, Any]): # 添加到设备管理器 can_start_immediately = self.device_manager.add_queue_request(job_info) - job_log = format_job_log(job_id, task_id, device_id, action_name) if can_start_immediately: # 可以立即开始 await self._send_action_state_response( @@ -1542,6 +1604,34 @@ def register_job_start_request(self, request_data: Dict[str, Any]) -> bool: self._prune_job_start_cache_locked() return True + def has_job_start_request(self, job_id: str, task_id: str) -> bool: + """判断同一(task_id, job_id)的job_start是否已被处理或已有结果缓存。""" + key = self._job_start_cache_key(job_id, task_id) + if key is None: + return False + + with self._job_start_cache_lock: + self._prune_job_start_cache_locked() + cached = self._job_start_cache.get(key) + if cached is None: + return False + cached.updated_at = time.time() + return True + + def get_cached_job_start_response_status(self, job_id: str, task_id: str) -> str: + """获取同一job_start已缓存的回复状态。""" + key = self._job_start_cache_key(job_id, task_id) + if key is None: + return "" + + with self._job_start_cache_lock: + self._prune_job_start_cache_locked() + cached = self._job_start_cache.get(key) + if cached is None: + return "" + cached.updated_at = time.time() + return cached.response_status + def cache_job_start_response(self, item: QueueItem, message: Dict[str, Any], status: str) -> None: """缓存job_start对应的最新回复,供断链重发时回放。""" key = self._job_start_cache_key(item.job_id, item.task_id) @@ -1665,6 +1755,17 @@ def publish_job_status( self.queue_processor.handle_job_completed(item.job_id, status) + cached_status = self.get_cached_job_start_response_status(item.job_id, item.task_id) + if cached_status in ["success", "failed"]: + # 断线重连时,旧 READY 占位可能在结果已回放后触发 timeout failed。 + # 已有终态时不允许重复终态覆盖缓存或再次发送,success 也不允许被 failed 降级。 + if cached_status == "success" or cached_status == status: + logger.warning( + f"[WebSocketClient] Skipped duplicate terminal job status for {job_log}: " + f"cached={cached_status}, incoming={status}" + ) + return + # running状态按job_id做debounce,内容变化时仍然上报 if status == "running": now = time.time() From 24f2522392ad8fe952216ebc595e0f9bd56c2785 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Sun, 31 May 2026 15:47:02 +0800 Subject: [PATCH 48/56] adjust ws ping time --- unilabos/app/ws_client.py | 6 ++++-- unilabos/config/config.py | 5 +++-- unilabos/config/example_config.py | 3 ++- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/unilabos/app/ws_client.py b/unilabos/app/ws_client.py index d343e943f..fa546bffa 100644 --- a/unilabos/app/ws_client.py +++ b/unilabos/app/ws_client.py @@ -519,7 +519,7 @@ async def _connection_handler(self): ssl=ssl_context, open_timeout=20, ping_interval=WSConfig.ping_interval, - ping_timeout=10, + ping_timeout=WSConfig.ping_timeout, close_timeout=5, additional_headers={ "Authorization": f"Lab {BasicConfig.auth_secret()}", @@ -602,6 +602,7 @@ async def _message_handler(self): async for message in self.websocket: try: + logger.trace(f"[WS_RECV] {message}") data = json.loads(message) message_type = data.get("action", "") message_data = data.get("data") @@ -653,9 +654,10 @@ async def _send_handler(self): try: message_str = json.dumps(msg, ensure_ascii=False) await self.websocket.send(message_str) - # logger.trace(f"[MessageProcessor] Message sent: {msg.get('action', 'unknown')}") # type: ignore # noqa: E501 + logger.trace(f"[WS_SEND] {message_str}") except Exception as e: logger.error(f"[MessageProcessor] Failed to send message: {str(e)}") + logger.error(f"[WS_SEND_FAILED] {msg}") logger.error(traceback.format_exc()) break diff --git a/unilabos/config/config.py b/unilabos/config/config.py index d8d000e25..e6862ebbb 100644 --- a/unilabos/config/config.py +++ b/unilabos/config/config.py @@ -41,7 +41,8 @@ def auth_secret(cls): class WSConfig: reconnect_interval = 5 # 重连间隔(秒) max_reconnect_attempts = 999 # 最大重连次数 - ping_interval = 20 # ping间隔(秒) + ping_interval = 5 # ping间隔(秒),对齐服务端 PingPeriod + ping_timeout = 8 # pong等待超时(秒),对齐服务端 PongWait # HTTP配置 @@ -77,7 +78,7 @@ def _update_config_from_env(): if not env_key.startswith(prefix): continue try: - key_path = env_key[len(prefix) :] # Remove UNILAB_ prefix + key_path = env_key[len(prefix):] # Remove UNILAB_ prefix class_field = key_path.upper().split("_", 1) if len(class_field) != 2: logger.warning(f"[ENV] 环境变量格式不正确:{env_key}") diff --git a/unilabos/config/example_config.py b/unilabos/config/example_config.py index b096e410c..3e597e957 100644 --- a/unilabos/config/example_config.py +++ b/unilabos/config/example_config.py @@ -9,4 +9,5 @@ class BasicConfig: class WSConfig: reconnect_interval = 5 # 重连间隔(秒) max_reconnect_attempts = 999 # 最大重连次数 - ping_interval = 30 # ping间隔(秒) \ No newline at end of file + ping_interval = 5 # ping间隔(秒),对齐服务端 PingPeriod + ping_timeout = 8 # pong等待超时(秒),对齐服务端 PongWait From 35bdcb0c289341b4b8f1bf4717b25b3a2ce64706 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Sun, 31 May 2026 18:49:44 +0800 Subject: [PATCH 49/56] fix sync exec & delay time according to llm --- unilabos/app/ws_client.py | 239 +++++++++++++++++++------ unilabos/config/config.py | 1 + unilabos/config/example_config.py | 1 + unilabos/ros/nodes/base_device_node.py | 10 +- 4 files changed, 191 insertions(+), 60 deletions(-) diff --git a/unilabos/app/ws_client.py b/unilabos/app/ws_client.py index fa546bffa..198878844 100644 --- a/unilabos/app/ws_client.py +++ b/unilabos/app/ws_client.py @@ -86,6 +86,7 @@ class JobInfo: last_update_time: float = field(default_factory=time.time) ready_timeout: Optional[float] = None # READY状态的超时时间 always_free: bool = False # 是否为永久闲置动作(不受排队限制) + ready_timeout_extension_applied: float = 0.0 # 已应用到该 job 的断链 READY 超时顺延总量 def update_timestamp(self): """更新最后更新时间""" @@ -136,6 +137,37 @@ def __init__(self): self.active_jobs: Dict[str, JobInfo] = {} # device_action_key -> active job self.all_jobs: Dict[str, JobInfo] = {} # job_id -> job_info self.lock = threading.RLock() + self.ready_timeout_extension_total: float = 0.0 + + def _apply_ready_timeout_grace_locked(self, job_info: JobInfo, reason: str = "") -> bool: + """给 READY job 应用尚未消费的断链超时顺延量。""" + if job_info.status != JobStatus.READY or job_info.ready_timeout is None: + return False + + extension_delta = self.ready_timeout_extension_total - job_info.ready_timeout_extension_applied + if extension_delta <= 0: + return False + + job_info.ready_timeout += extension_delta + job_info.ready_timeout_extension_applied = self.ready_timeout_extension_total + job_info.update_timestamp() + + job_log = format_job_log( + job_info.job_id, + job_info.task_id, + job_info.device_id, + job_info.action_name, + ) + logger.info( + "[DeviceActionManager] Applied READY timeout extension for job %s by %.1fs " + "(total_applied=%.1fs, timeout_at=%.3f)%s", + job_log, + extension_delta, + job_info.ready_timeout_extension_applied, + job_info.ready_timeout, + f" ({reason})" if reason else "", + ) + return True def add_queue_request(self, job_info: JobInfo) -> bool: """ @@ -168,12 +200,15 @@ def add_queue_request(self, job_info: JobInfo) -> bool: # 总是将job添加到all_jobs中 self.all_jobs[job_info.job_id] = job_info + # 新进入管理器的 job 不应消费历史断链顺延;之后发生的新断链才会影响它。 + job_info.ready_timeout_extension_applied = self.ready_timeout_extension_total # always_free的动作不受排队限制,直接设为READY if job_info.always_free: job_info.status = JobStatus.READY job_info.update_timestamp() job_info.set_ready_timeout(10) + self._apply_ready_timeout_grace_locked(job_info, reason="always_free ready") job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name) logger.trace(f"[DeviceActionManager] Job {job_log} always_free, start immediately") return True @@ -203,6 +238,7 @@ def add_queue_request(self, job_info: JobInfo) -> bool: job_info.status = JobStatus.READY job_info.update_timestamp() job_info.set_ready_timeout(10) # 设置10秒超时 + self._apply_ready_timeout_grace_locked(job_info, reason="ready") self.active_jobs[device_key] = job_info job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name) logger.trace(f"[DeviceActionManager] Job {job_log} can start immediately for {device_key}") @@ -286,6 +322,7 @@ def end_job(self, job_id: str) -> Optional[JobInfo]: next_job.status = JobStatus.READY next_job.update_timestamp() next_job.set_ready_timeout(10) # 设置10秒超时 + self._apply_ready_timeout_grace_locked(next_job, reason="next job ready") self.active_jobs[device_key] = next_job next_job_log = format_job_log( next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name @@ -353,6 +390,7 @@ def cancel_job(self, job_id: str) -> bool: next_job.status = JobStatus.READY next_job.update_timestamp() next_job.set_ready_timeout(10) + self._apply_ready_timeout_grace_locked(next_job, reason="next job ready after cancel") self.active_jobs[device_key] = next_job next_job_log = format_job_log( next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name @@ -404,6 +442,36 @@ def cancel_jobs_by_task_id(self, task_id: str) -> List[str]: return cancelled_job_ids + def extend_ready_timeouts(self, extension_seconds: float, reason: str = "") -> int: + """累计断链顺延时间,并应用到当前/后续 READY 状态任务。""" + if extension_seconds <= 0: + return 0 + + extended_count = 0 + + with self.lock: + self.ready_timeout_extension_total += extension_seconds + + ready_candidates = list(self.active_jobs.values()) + for job in self.all_jobs.values(): + if job.always_free and job.status == JobStatus.READY and job not in ready_candidates: + ready_candidates.append(job) + + for job_info in ready_candidates: + if self._apply_ready_timeout_grace_locked(job_info, reason=reason): + extended_count += 1 + + logger.info( + "[DeviceActionManager] Registered READY timeout extension %.1fs " + "(total=%.1fs); extended %s current READY job(s)%s", + extension_seconds, + self.ready_timeout_extension_total, + extended_count, + f" ({reason})" if reason else "", + ) + + return extended_count + def check_ready_timeouts(self) -> List[JobInfo]: """检查READY状态超时的任务,仅检测不处理""" timeout_jobs = [] @@ -505,6 +573,7 @@ def _run(self): async def _connection_handler(self): """处理WebSocket连接和重连逻辑""" while self.is_running: + was_connected = False try: # 构建SSL上下文 ssl_context = None @@ -529,6 +598,7 @@ async def _connection_handler(self): ) as websocket: self.websocket = websocket self.connected = True + was_connected = True self.reconnect_count = 0 logger.info(f"[MessageProcessor] 已连接到 {self.websocket_url}") @@ -536,8 +606,10 @@ async def _connection_handler(self): # 启动发送协程 send_task = asyncio.create_task(self._send_handler(), name="websocket-send_task") - # 每次连接(含重连)后重新向服务端注册, + # 每次连接(含重连)后尝试向服务端注册, # 否则服务端不知道客户端已上线,不会推送消息。 + # 注意:publish_host_ready 内部带就绪门禁——HostNode 未初始化完成时会自动延后, + # 首连若设备尚未就绪则不会在此发送,待 HostNode 初始化完成后由其回调补发。 if self.websocket_client: self.websocket_client.publish_host_ready() @@ -578,6 +650,11 @@ async def _connection_handler(self): if self.reconnect_count < WSConfig.max_reconnect_attempts: self.reconnect_count += 1 backoff = WSConfig.reconnect_interval + extension_seconds = getattr(WSConfig, "ready_timeout_extension", 20) + self.device_manager.extend_ready_timeouts( + extension_seconds, + reason="websocket reconnect window" if was_connected else "websocket connect retry", + ) logger.info( "[MessageProcessor] 即将在 %s 秒后重连 (已尝试 %s/%s)", backoff, @@ -755,15 +832,18 @@ async def _handle_query_action_state(self, data: Dict[str, Any]): return job_log = format_job_log(job_id, task_id, device_id, action_name) + + # 1) 该 job 仍在设备管理器中(READY/QUEUE/STARTED):返回真实当前状态。 + # 这是保证服务端 busy/running 节奏判断正确的关键,且不重复入队。 + # 完成后 end_job 会把 job 从管理器移除,故运行中的 job 一定能在此命中。 existing_job = self.device_manager.get_job_info(job_id) if existing_job and existing_job.task_id == task_id: if existing_job.status == JobStatus.READY: response_type, free, need_more = "query_action_status", True, 0 elif existing_job.status == JobStatus.QUEUE: response_type, free, need_more = "query_action_status", False, 10 - else: + else: # STARTED:正在执行 response_type, free, need_more = "job_call_back_status", False, 10 - await self._send_action_state_response( existing_job.device_id, existing_job.action_name, @@ -774,26 +854,28 @@ async def _handle_query_action_state(self, data: Dict[str, Any]): need_more, notebook_id=existing_job.notebook_id or notebook_id, ) - logger.info( - f"[MessageProcessor] Returned existing job state for query_action_state {job_log}: " - f"{existing_job.status}" + logger.trace( + f"[MessageProcessor] query_action_state {job_log} 返回当前状态 {existing_job.status}" ) return - if self.websocket_client and self.websocket_client.has_job_start_request(job_id, task_id): - replayed = self.websocket_client.replay_cached_job_start_response(job_id, task_id) - if not replayed: - await self._send_action_state_response( - device_id, - action_name, - task_id, - job_id, - "job_call_back_status", - False, - 10, - notebook_id=notebook_id, - ) - logger.info(f"[MessageProcessor] Returned cached/running state for completed query_action_state {job_log}") + # 2) 不在管理器、但已 job_start 过(已完成被移除,多为断线重连后服务端重查): + # 回复 free,让服务端继续走 job_start,真正结果由 _handle_job_start 命中缓存回放。 + if self.websocket_client and self.websocket_client.is_job_cached(job_id, task_id): + self.websocket_client.log_cached_job(job_id, task_id, source="query_action_state") + await self._send_action_state_response( + device_id, + action_name, + task_id, + job_id, + "query_action_status", + True, + 0, + notebook_id=notebook_id, + ) + logger.info( + f"[MessageProcessor] [缓存复用] query_action_state {job_log} 命中缓存(已完成),回复 free" + ) return device_action_key = f"/devices/{device_id}/{action_name}" @@ -859,14 +941,20 @@ async def _handle_job_start(self, data: Dict[str, Any]): job_log = format_job_log(req.job_id, req.task_id, req.device_id, req.action) if self.websocket_client: + # 幂等缓存:首次 job_start 登记缓存并真正执行; + # 重复的 (task_id, job_id) 则假装执行——直接回放之前缓存的结果,不再下发设备动作。 is_new_request = self.websocket_client.register_job_start_request(data) if not is_new_request: + self.websocket_client.log_cached_job(req.job_id, req.task_id, source="job_start") replayed = self.websocket_client.replay_cached_job_start_response(req.job_id, req.task_id) if replayed: - logger.info(f"[MessageProcessor] Replayed cached response for duplicate job_start {job_log}") + logger.info( + f"[MessageProcessor] [缓存复用] job_start {job_log} 命中缓存,假装执行并回放缓存结果" + ) else: logger.info( - f"[MessageProcessor] Duplicate job_start {job_log} received before response is cached" + f"[MessageProcessor] [缓存复用] job_start {job_log} 命中缓存但暂无结果" + f"(原任务仍在执行),跳过重复执行" ) return @@ -1606,8 +1694,8 @@ def register_job_start_request(self, request_data: Dict[str, Any]) -> bool: self._prune_job_start_cache_locked() return True - def has_job_start_request(self, job_id: str, task_id: str) -> bool: - """判断同一(task_id, job_id)的job_start是否已被处理或已有结果缓存。""" + def is_job_cached(self, job_id: str, task_id: str) -> bool: + """判断同一 (task_id, job_id) 是否已 job_start 过(已登记进幂等缓存)。""" key = self._job_start_cache_key(job_id, task_id) if key is None: return False @@ -1620,6 +1708,28 @@ def has_job_start_request(self, job_id: str, task_id: str) -> bool: cached.updated_at = time.time() return True + def log_cached_job(self, job_id: str, task_id: str, source: str = "") -> None: + """打印命中缓存的 job 内容(请求 + 已缓存结果),便于核对复用的数据。""" + key = self._job_start_cache_key(job_id, task_id) + if key is None: + return + + with self._job_start_cache_lock: + cached = self._job_start_cache.get(key) + if cached is None: + return + request_data = copy.deepcopy(cached.request_data) + response_message = copy.deepcopy(cached.response_message) + response_status = cached.response_status + + result_repr = json.dumps(response_message, ensure_ascii=False) if response_message else "none" + logger.info( + f"[WebSocketClient] [缓存复用] 命中缓存 source={source} job={job_id[:8]} task={task_id[:8]} " + f"status={response_status or 'none'} " + f"request={json.dumps(request_data, ensure_ascii=False)} " + f"result={result_repr}" + ) + def get_cached_job_start_response_status(self, job_id: str, task_id: str) -> str: """获取同一job_start已缓存的回复状态。""" key = self._job_start_cache_key(job_id, task_id) @@ -1635,7 +1745,7 @@ def get_cached_job_start_response_status(self, job_id: str, task_id: str) -> str return cached.response_status def cache_job_start_response(self, item: QueueItem, message: Dict[str, Any], status: str) -> None: - """缓存job_start对应的最新回复,供断链重发时回放。""" + """缓存同一 (task_id, job_id) 的 job 结果(最新 job_status),供重复请求复用回放。""" key = self._job_start_cache_key(item.job_id, item.task_id) if key is None: return @@ -1652,7 +1762,11 @@ def cache_job_start_response(self, item: QueueItem, message: Dict[str, Any], sta self._prune_job_start_cache_locked() def replay_cached_job_start_response(self, job_id: str, task_id: str) -> bool: - """回放同一job_start之前产生的最新回复。""" + """回放同一 (task_id, job_id) 已缓存的最终结果。 + + 仅当已缓存到 success/failed 的终态结果时才回放;若原任务仍在执行 + (只缓存了 running 中间态),返回 False,由调用方决定如何处理。 + """ key = self._job_start_cache_key(job_id, task_id) if key is None: return False @@ -1661,6 +1775,8 @@ def replay_cached_job_start_response(self, job_id: str, task_id: str) -> bool: cached = self._job_start_cache.get(key) if cached is None or cached.response_message is None: return False + if cached.response_status not in ("success", "failed"): + return False message = copy.deepcopy(cached.response_message) status = cached.response_status cached.updated_at = time.time() @@ -1668,8 +1784,8 @@ def replay_cached_job_start_response(self, job_id: str, task_id: str) -> bool: sent = self.message_processor.send_message(message) if sent: logger.info( - "[WebSocketClient] Replayed cached job_start response " - f"job={job_id[:8]}, task={task_id[:8]}, status={status}" + f"[WebSocketClient] [缓存复用] 回放缓存结果 job={job_id[:8]} task={task_id[:8]} " + f"status={status} payload={json.dumps(message, ensure_ascii=False)}" ) return sent @@ -1837,43 +1953,50 @@ def publish_host_ready(self) -> None: logger.debug("[WebSocketClient] Not connected, cannot publish host ready signal") return + # 仅在 HostNode 初始化完成(设备已就绪)后才向服务端注册。 + # get_instance(0) 在未就绪时立即返回 None;此时必须延后发送, + # 否则会发出 devices=[] 的空 host_ready,令服务端误判节点已就绪而过早调度, + # 进而触发 READY 超时与启动期频繁断链重连。 + host_node = HostNode.get_instance(0) + if host_node is None: + logger.info("[WebSocketClient] Host node 尚未就绪,延后发送 host_ready(待初始化完成后再注册)") + return + # 收集设备信息 devices = [] machine_name = BasicConfig.machine_name try: - host_node = HostNode.get_instance(0) - if host_node: - # 获取设备信息 - for device_id, namespace in host_node.devices_names.items(): - device_key = ( - f"{namespace}/{device_id}" if namespace.startswith("/") else f"/{namespace}/{device_id}" - ) - is_online = device_key in host_node._online_devices - - # 获取设备的动作信息 - actions = {} - for action_id, client in host_node._action_clients.items(): - # action_id 格式: /namespace/device_id/action_name - if device_id in action_id: - action_name = action_id.split("/")[-1] - actions[action_name] = { - "action_path": action_id, - "action_type": str(type(client).__name__), - } - - devices.append( - { - "device_id": device_id, - "namespace": namespace, - "device_key": device_key, - "is_online": is_online, - "machine_name": host_node.device_machine_names.get(device_id, machine_name), - "actions": actions, + # 获取设备信息 + for device_id, namespace in host_node.devices_names.items(): + device_key = ( + f"{namespace}/{device_id}" if namespace.startswith("/") else f"/{namespace}/{device_id}" + ) + is_online = device_key in host_node._online_devices + + # 获取设备的动作信息 + actions = {} + for action_id, client in host_node._action_clients.items(): + # action_id 格式: /namespace/device_id/action_name + if device_id in action_id: + action_name = action_id.split("/")[-1] + actions[action_name] = { + "action_path": action_id, + "action_type": str(type(client).__name__), } - ) - logger.info(f"[WebSocketClient] Collected {len(devices)} devices for host_ready") + devices.append( + { + "device_id": device_id, + "namespace": namespace, + "device_key": device_key, + "is_online": is_online, + "machine_name": host_node.device_machine_names.get(device_id, machine_name), + "actions": actions, + } + ) + + logger.info(f"[WebSocketClient] Collected {len(devices)} devices for host_ready") except Exception as e: logger.warning(f"[WebSocketClient] Error collecting device info: {e}") diff --git a/unilabos/config/config.py b/unilabos/config/config.py index e6862ebbb..bb24ee7bd 100644 --- a/unilabos/config/config.py +++ b/unilabos/config/config.py @@ -43,6 +43,7 @@ class WSConfig: max_reconnect_attempts = 999 # 最大重连次数 ping_interval = 5 # ping间隔(秒),对齐服务端 PingPeriod ping_timeout = 8 # pong等待超时(秒),对齐服务端 PongWait + ready_timeout_extension = 20 # 每次断链/重连为 READY job 增加的宽限时间(秒) # HTTP配置 diff --git a/unilabos/config/example_config.py b/unilabos/config/example_config.py index 3e597e957..af11b7fe8 100644 --- a/unilabos/config/example_config.py +++ b/unilabos/config/example_config.py @@ -11,3 +11,4 @@ class WSConfig: max_reconnect_attempts = 999 # 最大重连次数 ping_interval = 5 # ping间隔(秒),对齐服务端 PingPeriod ping_timeout = 8 # pong等待超时(秒),对齐服务端 PongWait + ready_timeout_extension = 20 # 每次断链/重连为 READY job 增加的宽限时间(秒) diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index 8a732c803..3177d61b4 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -1747,8 +1747,14 @@ def _publish_feedback(): _poll_future = Future() def _on_sync_done(fut): - if not _poll_future.done(): - _poll_future.set_result(None) + async def _wake(): + if not _poll_future.done(): + _poll_future.set_result(None) + + # ThreadPoolExecutor callbacks run outside the rclpy executor. + # Wake the awaiting action coroutine from the executor thread; + # otherwise it may only resume when the executor naturally wakes up. + rclpy.get_global_executor().create_task(_wake()) future.add_done_callback(_on_sync_done) await _poll_future From 65939f28cfb753d94d4f4292e1cdeb47aacfa62e Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:57:00 +0800 Subject: [PATCH 50/56] return free when running --- unilabos/app/ws_client.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/unilabos/app/ws_client.py b/unilabos/app/ws_client.py index 198878844..6113f7e63 100644 --- a/unilabos/app/ws_client.py +++ b/unilabos/app/ws_client.py @@ -183,6 +183,7 @@ def add_queue_request(self, job_info: JobInfo) -> bool: "[DeviceActionManager] Duplicate job_id has different task_id: " f"{job_info.job_id[:8]} old={existing_job.task_id[:8]} new={job_info.task_id[:8]}" ) + return False if job_info.notebook_id and not existing_job.notebook_id: existing_job.notebook_id = job_info.notebook_id existing_job.update_timestamp() @@ -833,16 +834,18 @@ async def _handle_query_action_state(self, data: Dict[str, Any]): job_log = format_job_log(job_id, task_id, device_id, action_name) - # 1) 该 job 仍在设备管理器中(READY/QUEUE/STARTED):返回真实当前状态。 - # 这是保证服务端 busy/running 节奏判断正确的关键,且不重复入队。 + # 1) 该 job 仍在设备管理器中(READY/QUEUE/STARTED):不重复入队。 + # READY/STARTED 表示同一个 job 已被本地接收/执行,断线重连后仍回复 free, + # 让服务端继续发送 job_start;重复 job_start 会被幂等缓存拦截或回放结果。 + # QUEUE 表示该 job 尚未轮到执行,仍回复 busy。 # 完成后 end_job 会把 job 从管理器移除,故运行中的 job 一定能在此命中。 existing_job = self.device_manager.get_job_info(job_id) - if existing_job and existing_job.task_id == task_id: - if existing_job.status == JobStatus.READY: + if existing_job and existing_job.job_id == job_id and existing_job.task_id == task_id: + if existing_job.status in (JobStatus.READY, JobStatus.STARTED): response_type, free, need_more = "query_action_status", True, 0 elif existing_job.status == JobStatus.QUEUE: response_type, free, need_more = "query_action_status", False, 10 - else: # STARTED:正在执行 + else: response_type, free, need_more = "job_call_back_status", False, 10 await self._send_action_state_response( existing_job.device_id, @@ -961,6 +964,15 @@ async def _handle_job_start(self, data: Dict[str, Any]): # 服务端对always_free动作可能跳过query_action_state直接发job_start, # 此时job尚未注册,需要自动补注册 existing_job = self.device_manager.get_job_info(req.job_id) + if existing_job and existing_job.task_id != req.task_id: + logger.warning( + "[MessageProcessor] job_start job_id matched but task_id mismatched, skip start: " + "job=%s old_task=%s new_task=%s", + req.job_id[:8], + existing_job.task_id[:8], + req.task_id[:8], + ) + return if not existing_job: action_name = req.action device_action_key = f"/devices/{req.device_id}/{action_name}" From e432ae463319fad9d43223d30fb8d3031d0249b6 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Mon, 1 Jun 2026 23:23:09 +0800 Subject: [PATCH 51/56] 10s dealy after reconn --- unilabos/app/ws_client.py | 117 ++++++++++++++---------------- unilabos/config/config.py | 1 - unilabos/config/example_config.py | 1 - 3 files changed, 56 insertions(+), 63 deletions(-) diff --git a/unilabos/app/ws_client.py b/unilabos/app/ws_client.py index 6113f7e63..ba997abe2 100644 --- a/unilabos/app/ws_client.py +++ b/unilabos/app/ws_client.py @@ -86,7 +86,6 @@ class JobInfo: last_update_time: float = field(default_factory=time.time) ready_timeout: Optional[float] = None # READY状态的超时时间 always_free: bool = False # 是否为永久闲置动作(不受排队限制) - ready_timeout_extension_applied: float = 0.0 # 已应用到该 job 的断链 READY 超时顺延总量 def update_timestamp(self): """更新最后更新时间""" @@ -137,37 +136,6 @@ def __init__(self): self.active_jobs: Dict[str, JobInfo] = {} # device_action_key -> active job self.all_jobs: Dict[str, JobInfo] = {} # job_id -> job_info self.lock = threading.RLock() - self.ready_timeout_extension_total: float = 0.0 - - def _apply_ready_timeout_grace_locked(self, job_info: JobInfo, reason: str = "") -> bool: - """给 READY job 应用尚未消费的断链超时顺延量。""" - if job_info.status != JobStatus.READY or job_info.ready_timeout is None: - return False - - extension_delta = self.ready_timeout_extension_total - job_info.ready_timeout_extension_applied - if extension_delta <= 0: - return False - - job_info.ready_timeout += extension_delta - job_info.ready_timeout_extension_applied = self.ready_timeout_extension_total - job_info.update_timestamp() - - job_log = format_job_log( - job_info.job_id, - job_info.task_id, - job_info.device_id, - job_info.action_name, - ) - logger.info( - "[DeviceActionManager] Applied READY timeout extension for job %s by %.1fs " - "(total_applied=%.1fs, timeout_at=%.3f)%s", - job_log, - extension_delta, - job_info.ready_timeout_extension_applied, - job_info.ready_timeout, - f" ({reason})" if reason else "", - ) - return True def add_queue_request(self, job_info: JobInfo) -> bool: """ @@ -201,15 +169,12 @@ def add_queue_request(self, job_info: JobInfo) -> bool: # 总是将job添加到all_jobs中 self.all_jobs[job_info.job_id] = job_info - # 新进入管理器的 job 不应消费历史断链顺延;之后发生的新断链才会影响它。 - job_info.ready_timeout_extension_applied = self.ready_timeout_extension_total # always_free的动作不受排队限制,直接设为READY if job_info.always_free: job_info.status = JobStatus.READY job_info.update_timestamp() job_info.set_ready_timeout(10) - self._apply_ready_timeout_grace_locked(job_info, reason="always_free ready") job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name) logger.trace(f"[DeviceActionManager] Job {job_log} always_free, start immediately") return True @@ -239,7 +204,6 @@ def add_queue_request(self, job_info: JobInfo) -> bool: job_info.status = JobStatus.READY job_info.update_timestamp() job_info.set_ready_timeout(10) # 设置10秒超时 - self._apply_ready_timeout_grace_locked(job_info, reason="ready") self.active_jobs[device_key] = job_info job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name) logger.trace(f"[DeviceActionManager] Job {job_log} can start immediately for {device_key}") @@ -323,7 +287,6 @@ def end_job(self, job_id: str) -> Optional[JobInfo]: next_job.status = JobStatus.READY next_job.update_timestamp() next_job.set_ready_timeout(10) # 设置10秒超时 - self._apply_ready_timeout_grace_locked(next_job, reason="next job ready") self.active_jobs[device_key] = next_job next_job_log = format_job_log( next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name @@ -391,7 +354,6 @@ def cancel_job(self, job_id: str) -> bool: next_job.status = JobStatus.READY next_job.update_timestamp() next_job.set_ready_timeout(10) - self._apply_ready_timeout_grace_locked(next_job, reason="next job ready after cancel") self.active_jobs[device_key] = next_job next_job_log = format_job_log( next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name @@ -443,37 +405,56 @@ def cancel_jobs_by_task_id(self, task_id: str) -> List[str]: return cancelled_job_ids - def extend_ready_timeouts(self, extension_seconds: float, reason: str = "") -> int: - """累计断链顺延时间,并应用到当前/后续 READY 状态任务。""" - if extension_seconds <= 0: + def refresh_ready_timeouts(self, timeout_seconds: float = 10, reason: str = "") -> int: + """将 READY 任务的超时时间刷新到至少 now + timeout_seconds。""" + if timeout_seconds <= 0: return 0 - extended_count = 0 + refreshed_count = 0 + now = time.time() + min_timeout = now + timeout_seconds with self.lock: - self.ready_timeout_extension_total += extension_seconds - ready_candidates = list(self.active_jobs.values()) for job in self.all_jobs.values(): if job.always_free and job.status == JobStatus.READY and job not in ready_candidates: ready_candidates.append(job) for job_info in ready_candidates: - if self._apply_ready_timeout_grace_locked(job_info, reason=reason): - extended_count += 1 + if job_info.status != JobStatus.READY or job_info.ready_timeout is None: + continue + if job_info.ready_timeout >= min_timeout: + continue + + old_timeout = job_info.ready_timeout + job_info.ready_timeout = min_timeout + job_info.update_timestamp() + refreshed_count += 1 + + job_log = format_job_log( + job_info.job_id, + job_info.task_id, + job_info.device_id, + job_info.action_name, + ) + logger.info( + "[DeviceActionManager] Refreshed READY timeout for job %s from %.3f to %.3f%s", + job_log, + old_timeout, + job_info.ready_timeout, + f" ({reason})" if reason else "", + ) logger.info( - "[DeviceActionManager] Registered READY timeout extension %.1fs " - "(total=%.1fs); extended %s current READY job(s)%s", - extension_seconds, - self.ready_timeout_extension_total, - extended_count, + "[DeviceActionManager] READY timeout refresh window %.1fs; refreshed %s READY job(s)%s", + timeout_seconds, + refreshed_count, f" ({reason})" if reason else "", ) - return extended_count + return refreshed_count - def check_ready_timeouts(self) -> List[JobInfo]: + def check_ready_timeouts(self, is_connected: bool = True) -> List[JobInfo]: """检查READY状态超时的任务,仅检测不处理""" timeout_jobs = [] @@ -490,6 +471,24 @@ def check_ready_timeouts(self) -> List[JobInfo]: # 找到所有超时的READY任务(只检测,不处理) for job_info in ready_candidates: + if job_info.status != JobStatus.READY: + continue + if not is_connected: + min_timeout = time.time() + 10 + if job_info.ready_timeout is not None and job_info.ready_timeout < min_timeout: + old_timeout = job_info.ready_timeout + job_info.ready_timeout = min_timeout + job_info.update_timestamp() + job_log = format_job_log( + job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name + ) + logger.info( + "[DeviceActionManager] WebSocket disconnected, keep READY job %s alive: %.3f -> %.3f", + job_log, + old_timeout, + job_info.ready_timeout, + ) + continue if job_info.is_ready_timeout(): timeout_jobs.append(job_info) job_log = format_job_log( @@ -574,7 +573,6 @@ def _run(self): async def _connection_handler(self): """处理WebSocket连接和重连逻辑""" while self.is_running: - was_connected = False try: # 构建SSL上下文 ssl_context = None @@ -599,10 +597,10 @@ async def _connection_handler(self): ) as websocket: self.websocket = websocket self.connected = True - was_connected = True self.reconnect_count = 0 logger.info(f"[MessageProcessor] 已连接到 {self.websocket_url}") + self.device_manager.refresh_ready_timeouts(10, reason="websocket connected") # 启动发送协程 send_task = asyncio.create_task(self._send_handler(), name="websocket-send_task") @@ -651,11 +649,6 @@ async def _connection_handler(self): if self.reconnect_count < WSConfig.max_reconnect_attempts: self.reconnect_count += 1 backoff = WSConfig.reconnect_interval - extension_seconds = getattr(WSConfig, "ready_timeout_extension", 20) - self.device_manager.extend_ready_timeouts( - extension_seconds, - reason="websocket reconnect window" if was_connected else "websocket connect retry", - ) logger.info( "[MessageProcessor] 即将在 %s 秒后重连 (已尝试 %s/%s)", backoff, @@ -1426,7 +1419,9 @@ def _run(self): while self.is_running: try: # 检查READY状态超时的任务 - timeout_jobs = self.device_manager.check_ready_timeouts() + timeout_jobs = self.device_manager.check_ready_timeouts( + is_connected=self.message_processor.is_connected() + ) if timeout_jobs: logger.info(f"[QueueProcessor] Found {len(timeout_jobs)} READY jobs that timed out") # 为超时的job发布失败状态,通过正常job完成流程处理 diff --git a/unilabos/config/config.py b/unilabos/config/config.py index bb24ee7bd..e6862ebbb 100644 --- a/unilabos/config/config.py +++ b/unilabos/config/config.py @@ -43,7 +43,6 @@ class WSConfig: max_reconnect_attempts = 999 # 最大重连次数 ping_interval = 5 # ping间隔(秒),对齐服务端 PingPeriod ping_timeout = 8 # pong等待超时(秒),对齐服务端 PongWait - ready_timeout_extension = 20 # 每次断链/重连为 READY job 增加的宽限时间(秒) # HTTP配置 diff --git a/unilabos/config/example_config.py b/unilabos/config/example_config.py index af11b7fe8..3e597e957 100644 --- a/unilabos/config/example_config.py +++ b/unilabos/config/example_config.py @@ -11,4 +11,3 @@ class WSConfig: max_reconnect_attempts = 999 # 最大重连次数 ping_interval = 5 # ping间隔(秒),对齐服务端 PingPeriod ping_timeout = 8 # pong等待超时(秒),对齐服务端 PongWait - ready_timeout_extension = 20 # 每次断链/重连为 READY job 增加的宽限时间(秒) From 7ffd8affb5113201ed4991baf0e8e29e86de1804 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:09:54 +0800 Subject: [PATCH 52/56] force change ws ping pong interval --- unilabos/app/ws_client.py | 95 ++++++++++++++++++------------- unilabos/config/config.py | 6 +- unilabos/config/example_config.py | 4 +- 3 files changed, 61 insertions(+), 44 deletions(-) diff --git a/unilabos/app/ws_client.py b/unilabos/app/ws_client.py index ba997abe2..c0ccdda20 100644 --- a/unilabos/app/ws_client.py +++ b/unilabos/app/ws_client.py @@ -586,8 +586,8 @@ async def _connection_handler(self): self.websocket_url, ssl=ssl_context, open_timeout=20, - ping_interval=WSConfig.ping_interval, - ping_timeout=WSConfig.ping_timeout, + ping_interval=WSConfig.ws_ping_interval, + ping_timeout=WSConfig.ws_ping_timeout, close_timeout=5, additional_headers={ "Authorization": f"Lab {BasicConfig.auth_secret()}", @@ -936,11 +936,21 @@ async def _handle_job_start(self, data: Dict[str, Any]): job_log = format_job_log(req.job_id, req.task_id, req.device_id, req.action) + existing_job = self.device_manager.get_job_info(req.job_id) + if existing_job and existing_job.task_id != req.task_id: + logger.warning( + "[MessageProcessor] job_start job_id matched but task_id mismatched, skip start: " + "job=%s old_task=%s new_task=%s", + req.job_id[:8], + existing_job.task_id[:8], + req.task_id[:8], + ) + return + if self.websocket_client: # 幂等缓存:首次 job_start 登记缓存并真正执行; # 重复的 (task_id, job_id) 则假装执行——直接回放之前缓存的结果,不再下发设备动作。 - is_new_request = self.websocket_client.register_job_start_request(data) - if not is_new_request: + if self.websocket_client.is_job_cached(req.job_id, req.task_id): self.websocket_client.log_cached_job(req.job_id, req.task_id, source="job_start") replayed = self.websocket_client.replay_cached_job_start_response(req.job_id, req.task_id) if replayed: @@ -954,46 +964,17 @@ async def _handle_job_start(self, data: Dict[str, Any]): ) return - # 服务端对always_free动作可能跳过query_action_state直接发job_start, - # 此时job尚未注册,需要自动补注册 - existing_job = self.device_manager.get_job_info(req.job_id) - if existing_job and existing_job.task_id != req.task_id: - logger.warning( - "[MessageProcessor] job_start job_id matched but task_id mismatched, skip start: " - "job=%s old_task=%s new_task=%s", - req.job_id[:8], - existing_job.task_id[:8], - req.task_id[:8], - ) - return if not existing_job: - action_name = req.action - device_action_key = f"/devices/{req.device_id}/{action_name}" - action_always_free = self._check_action_always_free(req.device_id, action_name) - - if action_always_free: - job_info = JobInfo( - job_id=req.job_id, - task_id=req.task_id, - device_id=req.device_id, - notebook_id=req.notebook_id, - action_name=action_name, - device_action_key=device_action_key, - status=JobStatus.QUEUE, - start_time=time.time(), - always_free=True, - ) - self.device_manager.add_queue_request(job_info) - existing_job = job_info - logger.info(f"[MessageProcessor] Job {job_log} always_free, auto-registered from direct job_start") - else: - logger.error(f"[MessageProcessor] Job {job_log} not registered (missing query_action_state)") - return + logger.error(f"[MessageProcessor] Job {job_log} not registered (missing query_action_state)") + return if existing_job and req.notebook_id and not existing_job.notebook_id: existing_job.notebook_id = req.notebook_id notebook_id = req.notebook_id or (existing_job.notebook_id if existing_job else "") + if self.websocket_client: + self.websocket_client.register_job_start_request(data) + success = self.device_manager.start_job(req.job_id) if not success: logger.error(f"[MessageProcessor] Failed to start job {job_log}") @@ -1345,8 +1326,8 @@ async def _send_action_state_response( "type": typ, "device_id": device_id, "action_name": action_name, - "task_id": task_id, - "job_id": job_id, + "task_id": task_id, # 服务端主动查才有 + "job_id": job_id, # 服务端主动查才有 "notebook_id": notebook_id, "free": free, "need_more": need_more + 1, @@ -1454,6 +1435,9 @@ def _run(self): # 发送正在执行任务的running状态 self._send_running_status() + # 周期性重发 READY 任务的 free 通知(防止 end_job 那一次性推送在链路抖动时丢失) + self._send_ready_status() + # 发送排队任务的busy状态 self._send_busy_status() @@ -1500,6 +1484,37 @@ def _send_running_status(self): job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name) logger.trace(f"[QueueProcessor] Sent running status for job {job_log}") # type: ignore + def _send_ready_status(self): + """周期性重发 READY 任务的 free 通知。 + + READY 的 free=true 原本只在 end_job 时一次性推送,若该消息恰好遇到链路抖动/掉线 + 而丢失,服务端将永远收不到“可执行”信号,导致该 job 在 10s 后被误判 READY 超时。 + 这里与 _send_running_status / _send_busy_status 对称,按周期补发,确保最终一致。 + """ + if not self.message_processor.is_connected(): + return + + for job_info in self.device_manager.get_active_jobs(): + if job_info.status != JobStatus.READY: + continue + + message = { + "action": "report_action_state", + "data": { + "type": "query_action_status", + "device_id": job_info.device_id, + "action_name": job_info.action_name, + "task_id": job_info.task_id, + "job_id": job_info.job_id, + "notebook_id": job_info.notebook_id, + "free": True, + "need_more": 0, + }, + } + self.message_processor.send_message(message) + job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name) + logger.trace(f"[QueueProcessor] Re-sent free for READY job {job_log}") # type: ignore + def _send_busy_status(self): """发送排队任务的busy状态""" if not self.message_processor.is_connected(): diff --git a/unilabos/config/config.py b/unilabos/config/config.py index e6862ebbb..f80f091a0 100644 --- a/unilabos/config/config.py +++ b/unilabos/config/config.py @@ -41,8 +41,10 @@ def auth_secret(cls): class WSConfig: reconnect_interval = 5 # 重连间隔(秒) max_reconnect_attempts = 999 # 最大重连次数 - ping_interval = 5 # ping间隔(秒),对齐服务端 PingPeriod - ping_timeout = 8 # pong等待超时(秒),对齐服务端 PongWait + # 注意:字段名带 ws_ 前缀,是为了让旧客户端遗留的 local_config 中旧字段(ping_interval/ping_timeout)失效, + # 从而强制采用下面的新默认值。请勿改回旧名。 + ws_ping_interval = 5 # ping间隔(秒),对齐服务端 PingPeriod + ws_ping_timeout = 8 # pong等待超时(秒),对齐服务端 PongWait # HTTP配置 diff --git a/unilabos/config/example_config.py b/unilabos/config/example_config.py index 3e597e957..d59d8b525 100644 --- a/unilabos/config/example_config.py +++ b/unilabos/config/example_config.py @@ -9,5 +9,5 @@ class BasicConfig: class WSConfig: reconnect_interval = 5 # 重连间隔(秒) max_reconnect_attempts = 999 # 最大重连次数 - ping_interval = 5 # ping间隔(秒),对齐服务端 PingPeriod - ping_timeout = 8 # pong等待超时(秒),对齐服务端 PongWait + ws_ping_interval = 5 # ping间隔(秒),对齐服务端 PingPeriod + ws_ping_timeout = 7 # pong等待超时(秒),对齐服务端 PongWait From 006068654d39f14cb266678160fb88a1587e9527 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:11:49 +0800 Subject: [PATCH 53/56] fix parallel crash --- unilabos/app/ws_client.py | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/unilabos/app/ws_client.py b/unilabos/app/ws_client.py index c0ccdda20..1bd2a252f 100644 --- a/unilabos/app/ws_client.py +++ b/unilabos/app/ws_client.py @@ -1397,6 +1397,9 @@ def _run(self): """运行队列处理主循环""" logger.trace("[QueueProcessor] Queue processor started") + # 首轮按"周期 tick"处理(做一次全量状态对齐)。 + woken_by_event = False + while self.is_running: try: # 检查READY状态超时的任务 @@ -1429,20 +1432,24 @@ def _run(self): serialize_result_info("Job READY state timeout after 10 seconds", False, {}), ) - # 立即触发状态更新 - self.notify_queue_update() - - # 发送正在执行任务的running状态 - self._send_running_status() - - # 周期性重发 READY 任务的 free 通知(防止 end_job 那一次性推送在链路抖动时丢失) - self._send_ready_status() - - # 发送排队任务的busy状态 - self._send_busy_status() - - # 等待10秒或者等待事件通知 - self.queue_update_event.wait(timeout=10) + # 注意:此处不再 notify 自己,超时失败已通过 publish_job_status->end_job + # 触发下一个任务的就绪通知;再 notify 只会让本循环空转。 + + # 仅在"周期 tick"(非事件唤醒)时做全量状态重广播。 + # 事件唤醒(如并发 query 入队)只用于及时检查超时,绝不在此重广播, + # 否则 N 个并发 query 会触发近 O(N^2) 条重复且 need_more 自相矛盾的帧, + # 形成突发风暴打爆服务端导致断连。每个 query 自身的响应已由 + # _handle_query_action_state 单独发送,周期 tick 负责丢包补发即可。 + if not woken_by_event: + # 发送正在执行任务的running状态 + self._send_running_status() + # 周期性重发 READY 任务的 free 通知(防止 end_job 那一次性推送在链路抖动时丢失) + self._send_ready_status() + # 发送排队任务的busy状态 + self._send_busy_status() + + # 等待10秒或者等待事件通知;返回 True 表示被事件唤醒,False 表示周期到点。 + woken_by_event = self.queue_update_event.wait(timeout=10) self.queue_update_event.clear() # 清除事件 except Exception as e: From b018df206d7dfcedaddd3acd3b4912598bd3ee68 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:12:20 +0800 Subject: [PATCH 54/56] schedule_addr --- unilabos/app/main.py | 16 +++++++++++++++- unilabos/config/config.py | 2 ++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/unilabos/app/main.py b/unilabos/app/main.py index d51c0a522..682df84d1 100644 --- a/unilabos/app/main.py +++ b/unilabos/app/main.py @@ -244,7 +244,16 @@ def parse_args(): "--addr", type=str, default="https://leap-lab.bohrium.com/api/v1", - help="Laboratory backend address", + help="Laboratory backend address (API)", + ) + parser.add_argument( + "--schedule_addr", + type=str, + default="", + help=( + "Schedule WebSocket address. If empty, derived from --addr: " + "port +1 when --addr has a port, otherwise the same host is used." + ), ) parser.add_argument( "--skip_env_check", @@ -486,6 +495,11 @@ def main(): else: HTTPConfig.remote_addr = args.addr + # schedule 通道地址:显式指定则直接使用,否则在连接时从 remote_addr 派生 + if args_dict.get("schedule_addr", ""): + HTTPConfig.schedule_addr = args_dict["schedule_addr"] + print_status(f"使用独立 schedule 地址: {HTTPConfig.schedule_addr}", "info") + # 设置BasicConfig参数 if args_dict.get("ak", ""): BasicConfig.ak = args_dict.get("ak", "") diff --git a/unilabos/config/config.py b/unilabos/config/config.py index f80f091a0..be1f54cea 100644 --- a/unilabos/config/config.py +++ b/unilabos/config/config.py @@ -50,6 +50,8 @@ class WSConfig: # HTTP配置 class HTTPConfig: remote_addr = "https://leap-lab.bohrium.com/api/v1" + # schedule 通道(WebSocket)地址;为空时从 remote_addr 派生:带端口则 +1,否则沿用原 netloc + schedule_addr = "" # ROS配置 From 70c99275ceaeba22b1c80abb064069dc8bec56f7 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:12:39 +0800 Subject: [PATCH 55/56] schedule_addr --- unilabos/app/ws_client.py | 64 ++++++++++++++++++++++----------------- 1 file changed, 36 insertions(+), 28 deletions(-) diff --git a/unilabos/app/ws_client.py b/unilabos/app/ws_client.py index 1bd2a252f..708751d3c 100644 --- a/unilabos/app/ws_client.py +++ b/unilabos/app/ws_client.py @@ -1397,8 +1397,10 @@ def _run(self): """运行队列处理主循环""" logger.trace("[QueueProcessor] Queue processor started") - # 首轮按"周期 tick"处理(做一次全量状态对齐)。 - woken_by_event = False + # 全量重广播按固定周期触发,与事件唤醒解耦。 + # 初值置为"上次广播在一个周期前",使首轮立即做一次全量状态对齐。 + broadcast_interval = 10.0 + last_broadcast = time.monotonic() - broadcast_interval while self.is_running: try: @@ -1435,21 +1437,22 @@ def _run(self): # 注意:此处不再 notify 自己,超时失败已通过 publish_job_status->end_job # 触发下一个任务的就绪通知;再 notify 只会让本循环空转。 - # 仅在"周期 tick"(非事件唤醒)时做全量状态重广播。 - # 事件唤醒(如并发 query 入队)只用于及时检查超时,绝不在此重广播, - # 否则 N 个并发 query 会触发近 O(N^2) 条重复且 need_more 自相矛盾的帧, - # 形成突发风暴打爆服务端导致断连。每个 query 自身的响应已由 - # _handle_query_action_state 单独发送,周期 tick 负责丢包补发即可。 - if not woken_by_event: - # 发送正在执行任务的running状态 - self._send_running_status() - # 周期性重发 READY 任务的 free 通知(防止 end_job 那一次性推送在链路抖动时丢失) - self._send_ready_status() - # 发送排队任务的busy状态 - self._send_busy_status() - - # 等待10秒或者等待事件通知;返回 True 表示被事件唤醒,False 表示周期到点。 - woken_by_event = self.queue_update_event.wait(timeout=10) + # 全量重广播只按固定 ~broadcast_interval 周期触发,与事件唤醒彻底解耦: + # - 并发 query 入队等事件只唤醒本循环做"及时超时检查",不在此重广播, + # 否则 N 个并发 query 会触发近 O(N^2) 条重复且 need_more 自相矛盾的帧, + # 形成突发风暴打爆服务端导致断连;每个 query 的响应已由 + # _handle_query_action_state 单独发送,无需重广播。 + # - 用时间戳判周期,保证即使持续有事件,丢包补发的重广播也不会被饿死, + # 且突发期内至多触发 1 次重广播(而非每个事件一次)。 + now = time.monotonic() + if now - last_broadcast >= broadcast_interval: + self._send_running_status() # 正在执行任务的 running 状态 + self._send_ready_status() # READY 任务的 free(防 end_job 一次性推送在抖动时丢失) + self._send_busy_status() # 排队任务的 busy 状态 + last_broadcast = now + + # 等待一个周期或被事件提前唤醒(事件仅用于尽快做超时检查)。 + self.queue_update_event.wait(timeout=broadcast_interval) self.queue_update_event.clear() # 清除事件 except Exception as e: @@ -1659,23 +1662,28 @@ def __init__(self): logger.info(f"[WebSocketClient] Client_id: {self.client_id}") def _build_websocket_url(self) -> Optional[str]: - """构建WebSocket连接URL""" + """构建 schedule 通道的 WebSocket 连接 URL + + 地址来源优先级: + 1. HTTPConfig.schedule_addr(--schedule_addr 显式指定)→ 直接使用,不做端口偏移 + 2. HTTPConfig.remote_addr(--addr)派生:带端口则 +1,否则沿用原 netloc + """ + # 1. 显式 schedule 地址 + if HTTPConfig.schedule_addr: + parsed = urlparse(HTTPConfig.schedule_addr) + scheme = "wss" if parsed.scheme in ("https", "wss") else "ws" + return f"{scheme}://{parsed.netloc}/api/v1/ws/schedule" + + # 2. 从 api 地址派生 if not HTTPConfig.remote_addr: return None parsed = urlparse(HTTPConfig.remote_addr) - - if parsed.scheme == "https": - scheme = "wss" - else: - scheme = "ws" + scheme = "wss" if parsed.scheme == "https" else "ws" if ":" in parsed.netloc and parsed.port is not None: - url = f"{scheme}://{parsed.hostname}:{parsed.port + 1}/api/v1/ws/schedule" - else: - url = f"{scheme}://{parsed.netloc}/api/v1/ws/schedule" - - return url + return f"{scheme}://{parsed.hostname}:{parsed.port + 1}/api/v1/ws/schedule" + return f"{scheme}://{parsed.netloc}/api/v1/ws/schedule" @staticmethod def _job_start_cache_key(job_id: str, task_id: str) -> Optional[Tuple[str, str]]: From 5f05b72e04865391226bdaaa7af047090c8f7384 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:13:16 +0800 Subject: [PATCH 56/56] scheduler revert --- unilabos/app/ws_client.py | 123 +++++++++++++++----------------------- 1 file changed, 49 insertions(+), 74 deletions(-) diff --git a/unilabos/app/ws_client.py b/unilabos/app/ws_client.py index 708751d3c..da39f5ce4 100644 --- a/unilabos/app/ws_client.py +++ b/unilabos/app/ws_client.py @@ -936,21 +936,11 @@ async def _handle_job_start(self, data: Dict[str, Any]): job_log = format_job_log(req.job_id, req.task_id, req.device_id, req.action) - existing_job = self.device_manager.get_job_info(req.job_id) - if existing_job and existing_job.task_id != req.task_id: - logger.warning( - "[MessageProcessor] job_start job_id matched but task_id mismatched, skip start: " - "job=%s old_task=%s new_task=%s", - req.job_id[:8], - existing_job.task_id[:8], - req.task_id[:8], - ) - return - if self.websocket_client: # 幂等缓存:首次 job_start 登记缓存并真正执行; # 重复的 (task_id, job_id) 则假装执行——直接回放之前缓存的结果,不再下发设备动作。 - if self.websocket_client.is_job_cached(req.job_id, req.task_id): + is_new_request = self.websocket_client.register_job_start_request(data) + if not is_new_request: self.websocket_client.log_cached_job(req.job_id, req.task_id, source="job_start") replayed = self.websocket_client.replay_cached_job_start_response(req.job_id, req.task_id) if replayed: @@ -964,17 +954,46 @@ async def _handle_job_start(self, data: Dict[str, Any]): ) return - if not existing_job: - logger.error(f"[MessageProcessor] Job {job_log} not registered (missing query_action_state)") + # 服务端对always_free动作可能跳过query_action_state直接发job_start, + # 此时job尚未注册,需要自动补注册 + existing_job = self.device_manager.get_job_info(req.job_id) + if existing_job and existing_job.task_id != req.task_id: + logger.warning( + "[MessageProcessor] job_start job_id matched but task_id mismatched, skip start: " + "job=%s old_task=%s new_task=%s", + req.job_id[:8], + existing_job.task_id[:8], + req.task_id[:8], + ) return + if not existing_job: + action_name = req.action + device_action_key = f"/devices/{req.device_id}/{action_name}" + action_always_free = self._check_action_always_free(req.device_id, action_name) + + if action_always_free: + job_info = JobInfo( + job_id=req.job_id, + task_id=req.task_id, + device_id=req.device_id, + notebook_id=req.notebook_id, + action_name=action_name, + device_action_key=device_action_key, + status=JobStatus.QUEUE, + start_time=time.time(), + always_free=True, + ) + self.device_manager.add_queue_request(job_info) + existing_job = job_info + logger.info(f"[MessageProcessor] Job {job_log} always_free, auto-registered from direct job_start") + else: + logger.error(f"[MessageProcessor] Job {job_log} not registered (missing query_action_state)") + return if existing_job and req.notebook_id and not existing_job.notebook_id: existing_job.notebook_id = req.notebook_id notebook_id = req.notebook_id or (existing_job.notebook_id if existing_job else "") - if self.websocket_client: - self.websocket_client.register_job_start_request(data) - success = self.device_manager.start_job(req.job_id) if not success: logger.error(f"[MessageProcessor] Failed to start job {job_log}") @@ -1326,8 +1345,8 @@ async def _send_action_state_response( "type": typ, "device_id": device_id, "action_name": action_name, - "task_id": task_id, # 服务端主动查才有 - "job_id": job_id, # 服务端主动查才有 + "task_id": task_id, + "job_id": job_id, "notebook_id": notebook_id, "free": free, "need_more": need_more + 1, @@ -1397,11 +1416,6 @@ def _run(self): """运行队列处理主循环""" logger.trace("[QueueProcessor] Queue processor started") - # 全量重广播按固定周期触发,与事件唤醒解耦。 - # 初值置为"上次广播在一个周期前",使首轮立即做一次全量状态对齐。 - broadcast_interval = 10.0 - last_broadcast = time.monotonic() - broadcast_interval - while self.is_running: try: # 检查READY状态超时的任务 @@ -1434,25 +1448,17 @@ def _run(self): serialize_result_info("Job READY state timeout after 10 seconds", False, {}), ) - # 注意:此处不再 notify 自己,超时失败已通过 publish_job_status->end_job - # 触发下一个任务的就绪通知;再 notify 只会让本循环空转。 - - # 全量重广播只按固定 ~broadcast_interval 周期触发,与事件唤醒彻底解耦: - # - 并发 query 入队等事件只唤醒本循环做"及时超时检查",不在此重广播, - # 否则 N 个并发 query 会触发近 O(N^2) 条重复且 need_more 自相矛盾的帧, - # 形成突发风暴打爆服务端导致断连;每个 query 的响应已由 - # _handle_query_action_state 单独发送,无需重广播。 - # - 用时间戳判周期,保证即使持续有事件,丢包补发的重广播也不会被饿死, - # 且突发期内至多触发 1 次重广播(而非每个事件一次)。 - now = time.monotonic() - if now - last_broadcast >= broadcast_interval: - self._send_running_status() # 正在执行任务的 running 状态 - self._send_ready_status() # READY 任务的 free(防 end_job 一次性推送在抖动时丢失) - self._send_busy_status() # 排队任务的 busy 状态 - last_broadcast = now - - # 等待一个周期或被事件提前唤醒(事件仅用于尽快做超时检查)。 - self.queue_update_event.wait(timeout=broadcast_interval) + # 立即触发状态更新 + self.notify_queue_update() + + # 发送正在执行任务的running状态 + self._send_running_status() + + # 发送排队任务的busy状态 + self._send_busy_status() + + # 等待10秒或者等待事件通知 + self.queue_update_event.wait(timeout=10) self.queue_update_event.clear() # 清除事件 except Exception as e: @@ -1494,37 +1500,6 @@ def _send_running_status(self): job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name) logger.trace(f"[QueueProcessor] Sent running status for job {job_log}") # type: ignore - def _send_ready_status(self): - """周期性重发 READY 任务的 free 通知。 - - READY 的 free=true 原本只在 end_job 时一次性推送,若该消息恰好遇到链路抖动/掉线 - 而丢失,服务端将永远收不到“可执行”信号,导致该 job 在 10s 后被误判 READY 超时。 - 这里与 _send_running_status / _send_busy_status 对称,按周期补发,确保最终一致。 - """ - if not self.message_processor.is_connected(): - return - - for job_info in self.device_manager.get_active_jobs(): - if job_info.status != JobStatus.READY: - continue - - message = { - "action": "report_action_state", - "data": { - "type": "query_action_status", - "device_id": job_info.device_id, - "action_name": job_info.action_name, - "task_id": job_info.task_id, - "job_id": job_info.job_id, - "notebook_id": job_info.notebook_id, - "free": True, - "need_more": 0, - }, - } - self.message_processor.send_message(message) - job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name) - logger.trace(f"[QueueProcessor] Re-sent free for READY job {job_log}") # type: ignore - def _send_busy_status(self): """发送排队任务的busy状态""" if not self.message_processor.is_connected():