diff --git a/.github/workflows/ci-check.yml b/.github/workflows/ci-check.yml index e4750875a..667f1e2f6 100644 --- a/.github/workflows/ci-check.yml +++ b/.github/workflows/ci-check.yml @@ -18,6 +18,7 @@ jobs: MPLBACKEND: Agg # 原生崩溃(0xC0000005 访问违例)时打印 Python 调用栈,便于定位是哪个设备模块 import 崩溃 PYTHONFAULTHANDLER: "1" + UNILABOS_README_EXAMPLES_ROOT: ${{ github.workspace }}/readme-examples defaults: run: @@ -28,6 +29,22 @@ jobs: with: fetch-depth: 0 + # README 中列出的外部设备示例也要随主仓库接口一起验证。 + # 固定到已验证提交,避免外部仓库更新让当前 PR 的 CI 无故变化。 + - name: Checkout README LAN device example + uses: actions/checkout@v6 + with: + repository: Xuwznln/LabDeviceLanDemo + ref: 2f98f55015b47816e0a08731ca115903d6ea5161 + path: readme-examples/LabDeviceLanDemo + + - name: Checkout README workstation device example + uses: actions/checkout@v6 + with: + repository: Xuwznln/LabDeviceWorkstationDemo + ref: ad5b43fc7c64ca02b0020f69aa2ed15bdb0e8089 + path: readme-examples/LabDeviceWorkstationDemo + - name: Setup Miniforge uses: conda-incubator/setup-miniconda@v4 with: @@ -68,12 +85,21 @@ jobs: uv pip install pytest uv pip install . + - name: Validate README example device packages + run: | + call conda activate check-env + call install\unilabos_msgs\setup.bat + echo Checking LabDeviceLanDemo registry... + python -m unilabos --check_mode --skip_env_check --devices "%GITHUB_WORKSPACE%\readme-examples\LabDeviceLanDemo\lan_demo" --external_devices_only + echo Checking LabDeviceWorkstationDemo registry... + python -m unilabos --check_mode --skip_env_check --devices "%GITHUB_WORKSPACE%\readme-examples\LabDeviceWorkstationDemo\workstation_demo" --external_devices_only + - name: Run HostLink networking tests run: | call conda activate check-env call install\unilabos_msgs\setup.bat echo Running HostLink, ROS2 domain and networking runtime tests... - python -m pytest -q tests\hostlink tests\networking tests\ros\test_domain_init.py -p no:launch_testing -p no:launch_ros + python -m pytest -q tests\hostlink tests\networking tests\basic tests\device_runtime tests\app\test_backend_selection.py tests\registry\test_backend_metadata.py tests\ros\test_domain_init.py tests\ros\test_device_node_contract.py -p no:launch_testing -p no:launch_ros - name: Run check mode (AST registry validation) # check_mode 会真实 import 所有设备类(连带 matplotlib/opencv 等原生库)。 diff --git a/AGENTS.md b/AGENTS.md index 996054755..e382896ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,8 +12,8 @@ pip install -e . uv pip install -r unilabos/utils/requirements.txt # Run with a device graph -unilab --graph --config --backend ros -unilab --graph --config --backend simple # no ROS2 needed +unilab --graph --config --backend ros2 +unilab --graph --config --backend basic # no ROS2 runtime # Common CLI flags unilab --app_bridges websocket fastapi # communication bridges @@ -36,7 +36,7 @@ pytest tests/resources/test_resourcetreeset.py::TestClassName::test_method # si ### Startup Flow -`unilab` CLI → `unilabos/app/main.py:main()` → loads config → builds registry → reads device graph (JSON/GraphML) → starts backend thread (ROS2/simple) → starts FastAPI web server + WebSocket client. +`unilab` CLI → `unilabos/app/main.py:main()` → loads config → builds registry → reads device graph (JSON/GraphML) → starts the selected backend (`basic`/`ros2`/`dora`) → starts only the bridges supported by that backend. ### Core Layers diff --git a/README.md b/README.md index d7b50a043..24a0a50f2 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,9 @@ with `--devices --external_devices_only`, and read it when writing your ow Each repository README ships a step-by-step launch tutorial with verified output. For the underlying communication-sharing mechanism see [Best Practice Guide §11.5](https://deepmodeling.github.io/Uni-Lab-OS/user_guide/best_practice.html); to write a new driver from scratch see [Add Device](https://deepmodeling.github.io/Uni-Lab-OS/developer_guide/add_device.html). +The main repository CI validates pinned revisions of both packages. It also runs the LAN demo shape +as separate HostLink Host/Slave processes over loopback and an available non-loopback LAN IPv4, +covering cross-device `@subscribe` followed by a remote device action. ## Message Format diff --git a/docs/advanced_usage/configuration.md b/docs/advanced_usage/configuration.md index 2392d6dbc..2d5275451 100644 --- a/docs/advanced_usage/configuration.md +++ b/docs/advanced_usage/configuration.md @@ -75,7 +75,7 @@ class WSConfig: class HTTPConfig: remote_addr = "https://leap-lab.bohrium.com/api/v1" # 远程服务器地址 -# Host/Slave ROS2 组网控制通道 +# Host/Slave 组网控制通道;hostlink backend 也承载设备动作、JSON Topic、状态和物料树同步 class HostLinkConfig: enable = True host = "" # Slave 的 HostNode IP;推荐用 --host-node-ip 覆盖 @@ -199,12 +199,12 @@ Uni-Lab 允许通过命令行参数覆盖配置文件中的设置,提供更灵 | `HostLinkConfig` | `heartbeat_interval` | `--hostlink-heartbeat-interval` | Slave 心跳周期 | | `HostLinkConfig` | `heartbeat_timeout` | `--hostlink-heartbeat-timeout` | Host 离线判定时间 | | `HostLinkConfig` | `connect_timeout` | `--hostlink-connect-timeout` | TCP 连接/握手超时 | -| `HostLinkConfig` | `request_timeout` | `--hostlink-request-timeout` | 控制请求超时 | +| `HostLinkConfig` | `request_timeout` | `--hostlink-request-timeout` | 控制请求/设备 RPC 超时 | | `HostLinkConfig` | `ros_domain_id`| `--ros-domain-id` | Host 发布或 Slave 本地兜底 domain | | `HostLinkConfig` | `ros_discovery_range` | `--ros-discovery-range` | ROS 自动发现范围 | | `HostLinkConfig` | `ros_static_peers` | `--ros-static-peers` | 分号分隔的静态对端 | | `HostLinkConfig` | `ros_discovery_server` | `--ros-discovery-server` | 外部 Fast DDS Server | -| `HostLinkConfig` | `ros_assist_apply` | `--no-ros-assist` | 不应用 Host 下发的 ROS 环境 | +| `HostLinkConfig` | `ros_assist_apply` | `--no-ros-assist` | ROS2 Slave 不应用 Host 下发的 ROS 环境 | ### 特殊命令行参数 diff --git a/docs/developer_guide/add_device.md b/docs/developer_guide/add_device.md index 4259b96b5..4f04eee25 100644 --- a/docs/developer_guide/add_device.md +++ b/docs/developer_guide/add_device.md @@ -318,8 +318,8 @@ async def async_operation(self, duration: float) -> Dict[str, Any]: Args: duration: 持续时间(秒) """ - # 使用 self.sleep 而不是 asyncio.sleep(ROS2 异步机制) - await self.sleep(duration) + # node 由 post_init 注入;该写法兼容 ROS 与 HostLink/simple backend + await self._node.sleep(duration) return {"success": True} ``` @@ -327,7 +327,7 @@ async def async_operation(self, duration: float) -> Dict[str, Any]: - 普通方法或 async 方法 - 返回 Dict 类型的结果 -- 自动注册为 ROS2 Action +- 自动注册为设备 Action;ROS backend 使用 ROS2,HostLink 使用 TCP RPC - 支持参数和返回值 ### 返回值设计指南 @@ -802,83 +802,57 @@ async def long_operation(self, duration: float) -> Dict[str, Any]: """长时间运行的操作""" self._status = "running" - # 使用 ROS2 提供的 sleep 方法(而不是 asyncio.sleep) - await self.sleep(duration) + # 使用通用 DeviceNode,驱动无需判断当前 backend + await self._node.sleep(duration) - # 可以在过程中发送feedback - # 需要配合ROS2 Action的feedback机制 + # ActionContext 可在 ROS 和 HostLink 中发送 feedback、接收取消 self._status = "idle" return {"success": True, "duration": duration} ``` -> **⚠️ 重要提示:ROS2 异步机制 vs Python asyncio** +> **异步驱动的兼容写法** > -> Uni-Lab 的设备驱动虽然使用 `async def` 语法,但**底层是 ROS2 的异步机制,而不是 Python 的 asyncio**。 +> 驱动通过 `post_init(node)` 保存通用 `DeviceNode`,不要继承或导入 +> `BaseROS2DeviceNode`。`node.sleep()`、`node.create_task()`、Topic 和设备 Action +> API 会由当前 backend 实现。 > -> **不能使用的 asyncio 功能:** -> -> - ❌ `asyncio.sleep()` - 会导致 ROS2 事件循环阻塞 -> - ❌ `asyncio.create_task()` - 任务不会被 ROS2 正确调度 -> - ❌ `asyncio.gather()` - 无法与 ROS2 集成 -> - ❌ 其他 asyncio 标准库函数 -> -> **应该使用的方法(继承自 BaseROS2DeviceNode):** -> -> - ✅ `await self.sleep(seconds)` - ROS2 兼容的睡眠 -> - ✅ `await self.create_task(func, **kwargs)` - ROS2 兼容的任务创建 -> - ✅ ROS2 的 Action/Service 回调机制 +> - HostLink/simple backend 使用标准 Python `asyncio` 事件循环,可使用 +> `asyncio.gather()`、`asyncio.wait_for()` 等原生能力。 +> - ROS backend 由 rclpy executor 调度。需要同时支持两类 backend 的驱动,优先使用 +> `DeviceNode` 提供的 `sleep()` 和 `create_task()`。 +> - 同步动作调用使用 `node.call_device_action(...)`;异步动作中使用 +> `await node.call_device_action_async(...)`,不要用同步调用阻塞当前事件循环。 > > **示例:** > > ```python > async def complex_operation(self, duration: float) -> Dict[str, Any]: -> """正确使用 ROS2 异步方法""" +> """同一份驱动可在 ROS 和 HostLink/simple backend 中运行。""" > self._status = "processing" > -> # ✅ 正确:使用 self.sleep -> await self.sleep(duration) +> await self._node.sleep(duration) > -> # ✅ 正确:创建并发任务 -> task = await self.create_task(self._background_work) -> -> # ❌ 错误:不要使用 asyncio -> # await asyncio.sleep(duration) # 这会导致问题! -> # task = asyncio.create_task(...) # 这也不行! +> result = await self._node.call_device_action_async( +> "heater-2", +> "set_temperature", +> {"temperature": 60.0}, +> ) > > self._status = "idle" -> return {"success": True} +> return {"success": True, "peer_result": result} > > async def _background_work(self): > """后台任务""" -> await self.sleep(1.0) -> self.lab_logger().info("Background work completed") +> await self._node.sleep(1.0) +> self._node.lab_logger().info("Background work completed") > ``` > -> **为什么不能混用?** -> -> ROS2 使用 `rclpy` 的事件循环来管理所有异步操作。如果使用 `asyncio` 的函数,这些操作会在不同的事件循环中运行,导致: -> -> - ROS2 回调无法正确执行 -> - 任务可能永远不会完成 -> - 程序可能死锁或崩溃 +> **后端差异:** > -> **参考实现:** -> -> `BaseROS2DeviceNode` 提供的方法定义(`base_device_node.py:563-572`): -> -> ```python -> async def sleep(self, rel_time: float, callback_group=None): -> """ROS2 兼容的异步睡眠""" -> if callback_group is None: -> callback_group = self.callback_group -> await ROS2DeviceNode.async_wait_for(self, rel_time, callback_group) -> -> @classmethod -> async def create_task(cls, func, trace_error=True, **kwargs) -> Task: -> """ROS2 兼容的任务创建""" -> return ROS2DeviceNode.run_async_func(func, trace_error, **kwargs) -> ``` +> HostLink/simple 为每个设备维护标准 Python 事件循环;ROS backend 使用 rclpy +> executor。只在 HostLink/simple 中运行的驱动可以直接使用 Python `asyncio`;需要在 +> 两类 backend 中运行时,通过 `DeviceNode` 调度即可,不需要在驱动中写 backend 判断。 ## 错误处理 diff --git a/docs/developer_guide/networking_overview.md b/docs/developer_guide/networking_overview.md index 32abd81ab..e52eab4f6 100644 --- a/docs/developer_guide/networking_overview.md +++ b/docs/developer_guide/networking_overview.md @@ -106,17 +106,52 @@ ros2 topic list ros2 action list ``` -### HostLink 组网控制通道 +### HostLink 组网控制通道与无 ROS backend -Host 运行 ROS backend 时会在 TCP `7302` 监听 HostLink。Slave 通过 -`--host-node-ip [:port]` 建立控制连接,在 `rclpy.init` 前完成两件事: +Host 运行 ROS2 或 HostLink backend 时会在 TCP `7302` 监听 HostLink。Slave 通过 +`--host-node-ip [:port]` 建立控制连接并完成: - 上报启动图中的设备 ID,供 Host 发现 Slave 及其设备归属; -- 接收并应用 Host 的 `ROS_DOMAIN_ID`、发现范围、静态对端和外部 Fast DDS - Discovery Server 地址。 +- ROS2 模式在 `rclpy.init` 前接收并应用 Host 的 `ROS_DOMAIN_ID`、发现范围、 + 静态对端和外部 Fast DDS Discovery Server 地址。 + +在 `--backend ros2` 下,HostLink 只辅助组网,设备 Action、节点注册和资源同步仍 +走 ROS2。`--backend hostlink` 则完全不导入 ROS:Host 与 Slave 都使用 BasicRuntime +加载本地纯 Python 驱动。Slave 在 HELLO 中发布设备动作、状态字段和设备 UUID;驱动 +通过通用节点发布的状态通知会立即发送,心跳还会定期补发完整状态。Host 与 Slave 可以双向调用设备动作, +动作带独立 ID,支持反馈和协作取消。通用节点还提供与 ROS 相同形状的 +`create_publisher(...).publish(...)` 和 `create_subscription(...)`:Basic 在本进程分发, +HostLink 由 Host 按绝对 Topic 名称转发,消息会转换为 JSON 可传输的 Python 值。 +Slave 启动时会把本地设备物料树同步给 Host, +后续 `update_resource` 和 `get_resource` 也由 Host 保存和查询,不要求启动 ROS service +或 Web API。 -HostLink 只辅助 ROS2 组网。设备 Action、节点注册和资源同步仍走现有 ROS2 -接口;本阶段没有通过 HostLink 提供物料查询或无 ROS backend。 +```bash +# 无 ROS Host +unilab -g host.json --backend hostlink --hostlink-port 7302 + +# 无 ROS Slave +unilab -g slave.json --backend hostlink --is-slave \ + --host-node-ip 192.168.1.10 --hostlink-port 7302 +``` + +驱动通过 `post_init(node)` 获得通用 `DeviceNode`,可使用日志、异步等待、任务调度、 +状态通知、Topic 发布/订阅、物料更新/查询和跨设备动作调用。相对 Topic 名称会按 +`/devices//` 解析;设备状态也会发布到这个路径。注册表可用 +`class.supported_backends: [basic, hostlink, ros2]` 明确声明可运行的 backend; +`class.type: ros2` 默认只允许 ROS2。注册表设备动作可以在 HostLink 上传递目标、反馈、取消和结果; +驱动调用时携带的 `action_type` 只作为兼容信息,实际按动作名和字典参数执行。 +直接操作外部 ROS 图的 MoveIt ActionClient、规划场景/图像等 ROS 专用 Topic, +以及工作站跨设备物料搬运仍使用 ROS2。这些驱动已标记为 `[ros2]`,HostLink 启动时会 +直接提示该驱动不支持,而不是在导入过程中报缺少 `rclpy`。 + +设备动作在每台设备内串行执行;不同 Slave/设备可以并行。取消是协作式的:驱动需 +接收 `ActionContext` 并在长操作中检查取消状态,已经进入的阻塞硬件调用不会被强制 +终止。连接断开时设备在 `heartbeat_timeout` 后离线,客户端会指数退避重连,但不会 +自动重放动作。HostLink 的物料树保存在 Host 进程内,目前不会自动上传云端。 + +当前 HostLink 是面向可信实验室局域网的明文 TCP 协议,尚未提供 TLS 或双方身份认证。 +部署时应通过防火墙限制 `7302` 的来源;跨不可信网络使用时应先接入 VPN/安全隧道。 #### 端口与前端归属 @@ -139,16 +174,16 @@ HostLink 只辅助 ROS2 组网。设备 Action、节点注册和资源同步仍 | `--hostlink-port` | Host + Slave | `7302` | HostLink TCP 监听/连接端口;优先于 `--host-node-ip` 中的端口 | | `--hostlink-bind` | Host | `0.0.0.0` | HostLink 监听网卡 | | `--hostlink-advertise-ip` | Host | 自动探测 | 多网卡时发布给 Slave 的可达 IP | -| `--disable-hostlink` | Host + Slave | 否 | 禁用 HostLink,回退原 ROS2 发现 | +| `--disable-hostlink` | Host + Slave | 否 | 仅 ROS2 可用:禁用 HostLink 并回退原 ROS2 发现;不能和 `--backend hostlink` 同用 | | `--hostlink-heartbeat-interval` | Slave | `5` 秒 | 心跳发送间隔 | | `--hostlink-heartbeat-timeout` | Host | `15` 秒 | Slave 离线判定时间 | | `--hostlink-connect-timeout` | Slave | `5` 秒 | 单次 TCP 连接和握手超时 | -| `--hostlink-request-timeout` | Slave | `10` 秒 | 控制请求超时 | +| `--hostlink-request-timeout` | Host + Slave | `10` 秒 | 控制请求/设备 RPC 超时 | | `--ros-domain-id` | Host + Slave | 环境值 | Host 下发给 Slave;Slave 本地值仅作连接前兜底 | | `--ros-discovery-range` | Host | 环境值 | `SYSTEM_DEFAULT/SUBNET/LOCALHOST/OFF` | | `--ros-static-peers` | Host | 自动加入 Host IP | 分号分隔的静态发现对端 | | `--ros-discovery-server` | Host | 环境值 | 外部 Fast DDS `host:port`;`off` 清除继承值 | -| `--no-ros-assist` | Slave | 否 | 保留 HostLink 心跳/设备发现,但不应用 Host ROS 参数 | +| `--no-ros-assist` | ROS2 Slave | 否 | 保留 HostLink 心跳/设备发现,但不应用 Host ROS 参数 | 本切片没有启动 Fast DDS Discovery Server 进程,因此没有 `--ros-discovery-port`;该参数应与托管 Discovery Server 功能一并引入,不能成为 @@ -434,8 +469,9 @@ unilab -g host.json --ros-domain-id 42 \ **建议做法**: HostLink 需要 Slave 能访问 Host 的 TCP `7302`(若在 `--host-node-ip` 中指定 -其他端口,则开放对应端口)。该端口只承载组网握手、心跳和设备 ID,不承载设备 -动作或物料数据。 +其他端口,则开放对应端口)。ROS2 backend 下该端口只承载组网控制;HostLink +backend 下还承载设备描述、状态、JSON Topic、动作 RPC 和物料树同步, +但不承载浏览器流量。 为了确保 ROS2 DDS 通信正常,建议直接关闭防火墙,而不是配置特定端口。ROS2 使用动态端口范围,配置特定端口可能导致通信问题。 diff --git a/docs/user_guide/installation.md b/docs/user_guide/installation.md index 0ee71eae5..5eb1eefeb 100644 --- a/docs/user_guide/installation.md +++ b/docs/user_guide/installation.md @@ -416,7 +416,7 @@ unilab --help ``` usage: unilab [-h] [-g GRAPH] [-c CONTROLLERS] [--registry_path REGISTRY_PATH] - [--working_dir WORKING_DIR] [--backend {ros,simple,automancer}] + [--working_dir WORKING_DIR] [--backend {basic,hostlink,ros2,dora}] ... ``` diff --git a/docs/user_guide/launch.md b/docs/user_guide/launch.md index f5faaadb2..33c7557b6 100644 --- a/docs/user_guide/launch.md +++ b/docs/user_guide/launch.md @@ -15,12 +15,15 @@ options: Path to the registry directory --working_dir WORKING_DIR Path to the working directory - --backend {ros,simple,automancer} - Choose the backend to run with: 'ros', 'simple', or 'automancer'. - --app_bridges APP_BRIDGES [APP_BRIDGES ...] - Bridges to connect to. Now support 'websocket' and 'fastapi'. - --is_slave Run the backend as slave node (without host privileges). - --slave_no_host Skip waiting for host service in slave mode + --backend {basic,hostlink,ros2,dora} + Runtime backend: basic (in-process), hostlink (distributed, + no ROS), ros2 (default), or dora. + --app_bridges [APP_BRIDGES ...] + Application bridges. Defaults depend on the selected backend. + --is_slave, --is-slave + Run the backend as slave node (without host privileges). + --slave_no_host, --slave-no-host + Skip waiting for host service in slave mode --upload_registry Upload registry information when starting unilab --config CONFIG Configuration file path, supports .py format Python config files --port_management PORT_MANAGEMENT, --port-management PORT_MANAGEMENT, --port PORT_MANAGEMENT @@ -136,26 +139,89 @@ unilab --config path/to/your/config.py ## 通信中间件 `--backend` -目前 Uni-Lab 支持以下通信中间件: +Uni-Lab 对外提供四个 backend 名称。名称、能力和实现入口由 +`unilabos.app.backend.BACKEND_PROFILES` 统一管理: -- **ros** (默认):基于 ROS2 的通信 -- **automancer**:Automancer 兼容模式 (实验性) +| Backend | 定位 | 默认 App bridges | Host/Slave | 可视化 | +|---|---|---|---|---| +| **basic** | 单进程直接加载纯 Python 设备驱动,不使用通信中间件;跳过工作站聚合节点 | 无 | 不支持 | 不支持 | +| **hostlink** | Basic 驱动通过 HostLink TCP 组网,不启动 rclpy/DDS;可加载 ROS message 包并以 JSON 传输;支持设备发现、双向动作调用、Topic、状态和物料树同步 | 无 | 支持 | 不支持 | +| **ros2**(默认) | 完整 ROS 2 分布式运行时 | `websocket fastapi` | 支持 | 支持 | +| **dora** | 独立 dora-rs dataflow 运行时 | 无 | 暂不支持 | 暂不支持 | + +典型启动命令: + +```bash +# 轻量本地驱动运行;不启动 WebSocket/FastAPI +unilab -g graph.json --backend basic + +# Python Link Host:监听 7302,并运行 host.json 中的本地驱动 +unilab -g host.json --backend hostlink --hostlink-port 7302 + +# Python Link Slave:连接 Host,并发布 slave.json 中的设备/状态 +unilab -g slave.json --backend hostlink --is-slave \ + --host-node-ip 192.168.1.10 --hostlink-port 7302 + +# 完整 ROS 2 运行时;不写 --backend 时也使用 ros2 +unilab -g graph.json --backend ros2 + +# Dora 独立运行时;不会同时启动 ROS 2 backend +unilab -g graph.json --backend dora +``` + +兼容期内,旧名称 `ros` 会映射到 `ros2`,`simple` 会映射到 `basic`,并输出弃用提示。 +原 `automancer` 只有不可运行的占位分支,现已从可选项移除。 + +### Dora 依赖 + +Dora 的 Python 包名是 `dora-rs`,导入名是 `dora`;命令行工具名是 `dora-cli`。 +Python 依赖与 Uni-Lab 默认环境隔离安装: + +```bash +pip install -e ".[dora]" +cargo install dora-cli + +dora --version +python -c "from dora import Node; import pyarrow" +``` + +也可以使用 [Dora 官方安装脚本](https://dora-rs.ai/dora/getting-started/quickstart)。 +Uni-Lab 会在 backend 线程启动前检查 CLI、Python API 和 PyArrow,缺失时直接给出错误。 ## 端云桥接 `--app_bridges` -目前 Uni-Lab 提供 WebSocket、FastAPI (http) 两种端云通信方式: +ROS2 backend 提供 WebSocket、FastAPI (HTTP) 两种端云通信方式: - **WebSocket**:负责实时通信和任务下发 - **FastAPI**:负责端对云物料更新和 HTTP API +`basic`、`hostlink` 和 `dora` 当前没有兼容的 HostNode bridge,因此默认不加载这些桥,也会拒绝 +显式传入不支持的组合。若要让 ROS2 也不启动桥,可以使用空参数: + +```bash +unilab -g graph.json --backend ros2 --app_bridges +``` + ## 分布式组网 -启动 Uni-Lab 时,加入 `--is_slave` 将作为从站,不加将作为主站: +Host/Slave 可选择 `ros2` 或不启动 DDS 的 `hostlink` backend。启动时加入 +`--is_slave` 将作为从站,不加将作为主站: - **主站 (host)**:持有物料修改权以及对云端的通信 - **从站 (slave)**:无主机权限,可选择跳过等待主机服务 (`--slave_no_host`) -局域网内分别启动的 Uni-Lab 主站/从站将自动组网,互相能访问所有设备状态、传感器信息并发送指令。 +`ros2` 使用 DDS 上的 ROS Action/Topic;`hostlink` 直接在 TCP 长连接上同步注册表声明的设备描述、 +状态,执行动作并转发 JSON Topic。设备代码可以继续使用通用节点的 +`create_publisher(...).publish(...)` 和 `create_subscription(...)` 写法。 + +HostLink 可以加载 `std_msgs`、`geometry_msgs`、`unilabos_msgs` 等 ROS message Python 包, +使用其中的消息类和字段定义做类型解析。Topic、Action 参数、结果、feedback 和状态在发送时都会 +递归转换为 UTF-8 JSON,因此消息类型本身不要求使用 DDS。驱动直接依赖 ROS graph、TF、RViz +插件或某个 rclpy Node/Service 时,仍需使用 `ros2`,或者先把该调用接入通用节点接口。 + +驱动需要在后台安排异步函数时,使用 `node.run_async_func(async_function, **kwargs)`;它会根据 +当前 backend 选择 ROS executor 或 Python asyncio loop。不要在驱动中直接引用 +`ROS2DeviceNode.run_async_func`。 推荐由 Host 统一发布 ROS2 domain,Slave 只指定 Host IP: @@ -178,9 +244,13 @@ unilab -g slave.json --is-slave \ - `--hostlink-bind` / `--hostlink-advertise-ip`:Host 监听地址与多网卡发布地址。 - `--ros-domain-id`:Host 下发给 Slave 的 ROS2 domain。 - `--ros-discovery-range` / `--ros-static-peers` / `--ros-discovery-server`:ROS2 发现策略。 -- `--no-ros-assist`:仅保留 HostLink 设备发现,不覆盖 Slave 的 ROS2 环境。 +- `--no-ros-assist`:仅用于 ROS2 backend;保留 HostLink 设备发现,不覆盖 Slave 的 ROS2 环境。 - `--disable-hostlink`:完全关闭 HostLink,使用原 ROS2 发现流程。 +选择 `--backend hostlink` 时不能使用 `--disable-hostlink`,Slave 也必须提供 +`--host-node-ip`。当前该 backend 不启动 `8002` 管理端或微前端;`7302` 只供 +Host/Slave 进程通信。需要 Web/API 时仍使用 `ros2` backend。 + 浏览器和主微前端访问管理端口(默认 `8002`),不会访问 HostLink 的 `7302`。 即使使用 `--disable-browser`,前端仍可手动访问 `http://<节点 IP>:8002`。 diff --git a/setup.py b/setup.py index b29064132..826d2a0eb 100644 --- a/setup.py +++ b/setup.py @@ -9,6 +9,10 @@ packages=find_packages(), include_package_data=True, install_requires=['setuptools'], + extras_require={ + # Dora 的 Python 包导入名为 ``dora``,并包含 PyArrow;CLI 需单独安装。 + 'dora': ['dora-rs'], + }, zip_safe=True, author="The unilabos developers", maintainer='Junhan Chang, Xuwznln', diff --git a/tests/app/test_backend_selection.py b/tests/app/test_backend_selection.py new file mode 100644 index 000000000..af5872bda --- /dev/null +++ b/tests/app/test_backend_selection.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import subprocess +import sys +import threading +from types import SimpleNamespace + +import pytest + +from unilabos.app import backend as backend_module +from unilabos.app.backend import ( + BACKEND_NAMES, + BackendConfigurationError, + normalize_backend_name, + resolve_driver_backends, + resolve_backend_selection, + start_backend, +) +from unilabos.app.main import parse_args +from unilabos.config.config import BasicConfig +from unilabos.dora import main_dora_run + + +def test_public_backend_names_include_hostlink() -> None: + assert BACKEND_NAMES == ("basic", "hostlink", "ros2", "dora") + assert BasicConfig.backend == "ros2" + + +@pytest.mark.parametrize( + ("value", "canonical"), + [ + ("basic", "basic"), + ("hostlink", "hostlink"), + ("simple", "basic"), + ("ros", "ros2"), + ("ros2", "ros2"), + ("dora", "dora"), + ], +) +def test_backend_names_and_legacy_aliases(value: str, canonical: str) -> None: + assert normalize_backend_name(value) == canonical + + +def test_automancer_placeholder_is_not_selectable() -> None: + with pytest.raises(BackendConfigurationError, match="从未实现"): + normalize_backend_name("automancer") + + +def test_backend_specific_bridge_defaults() -> None: + assert resolve_backend_selection("ros2").app_bridges == ( + "websocket", + "fastapi", + ) + assert resolve_backend_selection("basic").app_bridges == () + assert resolve_backend_selection("hostlink").app_bridges == () + assert resolve_backend_selection("dora").app_bridges == () + + +def test_backend_capability_validation() -> None: + with pytest.raises(BackendConfigurationError, match="不支持应用桥"): + resolve_backend_selection("dora", ["websocket"]) + with pytest.raises(BackendConfigurationError, match="不支持 --is_slave"): + resolve_backend_selection("basic", is_slave=True) + with pytest.raises(BackendConfigurationError, match="不支持 --visual"): + resolve_backend_selection("dora", visual="rviz") + assert resolve_backend_selection("hostlink", is_slave=True).name == "hostlink" + + +def test_registry_driver_backend_defaults_and_explicit_support() -> None: + assert resolve_driver_backends({"type": "python"}) == ( + "basic", + "hostlink", + "ros2", + ) + assert resolve_driver_backends({"type": "ros2"}) == ("ros2",) + assert resolve_driver_backends( + {"type": "python", "supported_backends": ["hostlink", "ros2"]} + ) == ("hostlink", "ros2") + with pytest.raises(BackendConfigurationError, match="未知 backend"): + resolve_driver_backends( + {"type": "python", "supported_backends": ["missing"]} + ) + + +def test_cli_shows_canonical_names_and_accepts_aliases() -> None: + parser = parse_args() + assert parser.parse_args(["--backend", "ros"]).backend == "ros2" + assert parser.parse_args(["--backend", "simple"]).backend == "basic" + assert parser.parse_args(["--backend", "dora"]).backend == "dora" + assert parser.parse_args(["--backend", "hostlink"]).backend == "hostlink" + help_text = parser.format_help() + assert "{basic,hostlink,ros2,dora}" in help_text + assert "automancer" not in help_text + + +def test_start_backend_imports_only_selected_profile(monkeypatch) -> None: + called = threading.Event() + received = [] + + def main(*args) -> None: + received.append(args) + called.set() + + fake_module = SimpleNamespace(main=main, slave=lambda *args: None) + imported = [] + + def fake_import(name: str): + imported.append(name) + return fake_module + + monkeypatch.setattr(backend_module.importlib, "import_module", fake_import) + thread = start_backend("basic", object(), object()) + thread.join(timeout=2) + + assert called.is_set() + assert imported == ["unilabos.basic.main_basic_run"] + assert thread.name == "backend-basic" + assert received[0][2] == [] + + +def test_dora_preflight_reports_optional_dependencies(monkeypatch) -> None: + monkeypatch.setattr(main_dora_run.runtime, "dora_binary", lambda: None) + monkeypatch.setattr(main_dora_run.importlib.util, "find_spec", lambda name: None) + + with pytest.raises(RuntimeError) as exc_info: + main_dora_run.validate_environment() + + message = str(exc_info.value) + assert "dora-cli" in message + assert "dora-rs" in message + assert "pyarrow" in message + + +def test_web_package_does_not_eagerly_import_ros_modules() -> None: + code = ( + "import sys; import unilabos.app.web; " + "assert not any(name.startswith('unilabos.ros') for name in sys.modules)" + ) + result = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + timeout=20, + ) + assert result.returncode == 0, result.stderr diff --git a/tests/basic/__init__.py b/tests/basic/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/basic/test_basic_runtime.py b/tests/basic/test_basic_runtime.py new file mode 100644 index 000000000..6e87302ee --- /dev/null +++ b/tests/basic/test_basic_runtime.py @@ -0,0 +1,373 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path + +import pytest +import yaml + +from unilabos.basic.runtime import ( + BasicDeviceNode, + BasicDriverSpec, + BasicRuntime, + instantiate_driver, +) +from unilabos.device_runtime import BackendCapabilityError +from unilabos.resources.resource_tracker import DeviceNodeResourceTracker + + +class ConfigDriver: + def __init__(self, device_id=None, config=None): + self.device_id = device_id + self.config = config + + +class FlatDriver: + def __init__(self, port, device_id=None): + self.port = port + self.device_id = device_id + + +class LiquidHandlerAbstract: + def __init__(self, backend): + self.backend = backend + self.setup_called = False + + def post_init(self, node) -> None: + self.node = node + + async def setup(self) -> None: + await self.node.sleep(0) + self.setup_called = True + + +class DeviceConfig: + children = [] + + +class AsyncDriver: + def __init__(self, device_id=None, config=None): + self.device_id = device_id + self.config = config + self.node = None + self.initialized = False + self.cleaned = False + self.ready = "idle" + + def post_init(self, node) -> None: + self.node = node + + async def initialize(self) -> bool: + await self.node.sleep(0) + self.initialized = True + return True + + async def add(self, left: int, right: int) -> int: + return left + right + + async def call_peer( + self, + target_device: str, + left: int, + right: int, + ) -> int: + return self.node.call_device_action( + target_device, + "add", + {"left": left, "right": right}, + ) + + async def call_peer_async( + self, + target_device: str, + left: int, + right: int, + ) -> int: + return await self.node.call_device_action_async( + target_device, + "add", + {"left": left, "right": right}, + ) + + async def cleanup(self) -> bool: + self.cleaned = True + return True + + +class RosGuardConditionDriver: + def __init__(self, device_id=None, config=None): + self.device_id = device_id + self.node = None + + def post_init(self, node) -> None: + self.node = node + + def create_guard(self) -> None: + self.node.create_guard_condition(lambda: None) + + +class AddService: + class Request: + def __init__(self, left=0, right=0): + self.left = left + self.right = right + + class Response: + def __init__(self): + self.total = 0 + + +class ServiceDriver: + def __init__(self, device_id=None, config=None): + self.device_id = device_id + self.provider = bool((config or {}).get("provider")) + + def post_init(self, node) -> None: + self.node = node + if self.provider: + node.create_service(AddService, "add", self.add) + + @staticmethod + def add(request, response): + response.total = request.left + request.right + return response + + async def call_add(self, left, right): + client = self.node.create_client(AddService, "/devices/provider/add") + response = await client.call_async(AddService.Request(left, right)) + return response.total + + +def test_driver_instantiation_supports_config_and_flat_styles() -> None: + config_driver = instantiate_driver(ConfigDriver, "config-1", {"port": "A"}) + assert config_driver.device_id == "config-1" + assert config_driver.config == {"port": "A"} + + flat_driver = instantiate_driver(FlatDriver, "flat-1", {"port": "B"}) + assert flat_driver.device_id == "flat-1" + assert flat_driver.port == "B" + + +def test_liquid_handlers_declare_python_backends_and_action_metadata() -> None: + registry = yaml.safe_load( + Path("unilabos/registry/devices/liquid_handler.yaml").read_text( + encoding="utf-8" + ) + ) + for name in ("liquid_handler", "liquid_handler.prcxi"): + class_config = registry[name]["class"] + assert class_config["supported_backends"] == ["basic", "hostlink", "ros2"] + first_action = next(iter(class_config["action_value_mappings"].values())) + assert first_action["type"] + assert first_action["schema"]["type"] == "object" + + +def test_basic_runtime_constructs_and_sets_up_pylabrobot_style_driver( + monkeypatch, +) -> None: + monkeypatch.setattr("unilabos.basic.runtime.register", lambda: None) + tracker = DeviceNodeResourceTracker() + driver = instantiate_driver( + LiquidHandlerAbstract, + "liquid-handler", + {"backend": "simulator"}, + device_config=DeviceConfig(), + resource_tracker=tracker, + ) + node = BasicDeviceNode( + driver, + "liquid-handler", + backend_name="hostlink", + resource_tracker=tracker, + ) + node.start() + try: + assert driver.backend == "simulator" + assert driver.node is node + assert driver.setup_called is True + assert node.resource_tracker is tracker + finally: + node.stop() + + +def test_basic_device_lifecycle_and_direct_action() -> None: + driver = AsyncDriver("dev-1", {}) + node = BasicDeviceNode(driver, "dev-1") + node.start() + try: + assert driver.node is node + assert driver.initialized is True + assert node.call_action("add", left=2, right=3) == 5 + finally: + node.stop() + assert driver.cleaned is True + + +def test_basic_runtime_owns_and_routes_devices() -> None: + runtime = BasicRuntime() + runtime.add_driver(BasicDriverSpec("dev-1", AsyncDriver, {"answer": 42})) + runtime.start() + try: + assert runtime.call_action("dev-1", "add", left=10, right=5) == 15 + finally: + runtime.stop() + assert runtime.wait(timeout=0) is True + + +def test_basic_runtime_routes_cross_device_actions() -> None: + runtime = BasicRuntime() + runtime.add_driver(BasicDriverSpec("caller", AsyncDriver, {})) + runtime.add_driver(BasicDriverSpec("target", AsyncDriver, {})) + runtime.start() + try: + assert ( + runtime.call_action( + "caller", + "call_peer", + target_device="target", + left=4, + right=6, + ) + == 10 + ) + finally: + runtime.stop() + + +def test_basic_runtime_awaits_cross_device_actions_natively() -> None: + runtime = BasicRuntime() + runtime.add_driver(BasicDriverSpec("caller", AsyncDriver, {})) + runtime.add_driver(BasicDriverSpec("target", AsyncDriver, {})) + runtime.start() + try: + result = asyncio.run( + runtime.call_action_async( + "caller", + "call_peer_async", + target_device="target", + left=7, + right=8, + ) + ) + assert result == 15 + finally: + runtime.stop() + + +def test_basic_runtime_exposes_registered_actions_and_status() -> None: + runtime = BasicRuntime() + runtime.add_driver( + BasicDriverSpec( + "dev-1", + AsyncDriver, + {}, + registry_name="async_driver", + display_name="Async Driver", + action_names=("auto-add",), + status_names=("ready",), + ) + ) + runtime.start() + try: + assert runtime.descriptors() == [ + { + "id": "dev-1", + "registry_name": "async_driver", + "display_name": "Async Driver", + "actions": ["auto-add"], + "status_fields": ["ready"], + } + ] + assert runtime.snapshot_states() == {"dev-1": {"ready": "idle"}} + assert runtime.call_action("dev-1", "auto-add", left=1, right=2) == 3 + finally: + runtime.stop() + + +def test_basic_runtime_supports_ros_shaped_timer_clock_and_parameters() -> None: + driver = AsyncDriver("dev-1", {}) + node = BasicDeviceNode(driver, "dev-1") + fired = [] + node.start() + try: + parameter = node.declare_parameter("speed", 3) + assert parameter.value == 3 + assert node.get_parameter("speed").get_parameter_value().integer_value == 3 + assert node.get_clock().now().nanoseconds > 0 + + timer = node.create_timer(0.01, lambda: fired.append(True)) + for _ in range(100): + if fired: + break + node.create_rate(1000).sleep() + assert fired + assert node.destroy_timer(timer) is True + finally: + node.stop() + + +def test_basic_runtime_routes_ros_shaped_services_between_devices() -> None: + runtime = BasicRuntime() + runtime.add_driver(BasicDriverSpec("provider", ServiceDriver, {"provider": True})) + runtime.add_driver( + BasicDriverSpec( + "caller", + ServiceDriver, + {}, + action_names=("call_add",), + ) + ) + runtime.start() + try: + assert runtime.call_action("caller", "call_add", left=7, right=8) == 15 + assert runtime.descriptors()[0]["services"] == ["/devices/provider/add"] + finally: + runtime.stop() + + +def test_basic_runtime_exposes_action_metadata() -> None: + runtime = BasicRuntime() + runtime.add_driver( + BasicDriverSpec( + "dev-1", + AsyncDriver, + {}, + action_names=("add",), + action_value_mappings={ + "add": { + "type": AddService, + "goal": {"left": "left", "right": "right"}, + "result": {"total": "total"}, + "schema": {"type": "object"}, + } + }, + ) + ) + descriptor = runtime.descriptors()[0] + assert descriptor["action_value_mappings"]["add"] == { + "type": f"{AddService.__module__}.{AddService.__qualname__}", + "goal": {"left": "left", "right": "right"}, + "result": {"total": "total"}, + "schema": {"type": "object"}, + } + + +def test_basic_runtime_reports_direct_ros_node_calls_clearly() -> None: + runtime = BasicRuntime("hostlink") + runtime.add_driver( + BasicDriverSpec( + "ros-guard", + RosGuardConditionDriver, + {}, + action_names=("create_guard",), + ) + ) + runtime.start() + try: + with pytest.raises( + BackendCapabilityError, + match="设备 'ros-guard'.*create_guard_condition.*DeviceNode", + ): + runtime.call_action("ros-guard", "create_guard") + finally: + runtime.stop() diff --git a/tests/device_runtime/__init__.py b/tests/device_runtime/__init__.py new file mode 100644 index 000000000..33d25165e --- /dev/null +++ b/tests/device_runtime/__init__.py @@ -0,0 +1 @@ +"""Tests for backend-neutral device runtime contracts.""" diff --git a/tests/device_runtime/test_node.py b/tests/device_runtime/test_node.py new file mode 100644 index 000000000..15ac0465f --- /dev/null +++ b/tests/device_runtime/test_node.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +import asyncio +import subprocess +import sys + +import pytest + +from unilabos.basic.runtime import BasicDeviceNode, BasicRuntime +from unilabos.device_runtime import ( + ActionCancelled, + ActionContext, + BackendCapabilityError, + DeviceNode, +) + + +class Driver: + pass + + +def test_basic_node_implements_backend_neutral_contract() -> None: + node = BasicDeviceNode(Driver(), "device-1", backend_name="hostlink") + + assert isinstance(node, DeviceNode) + assert node.backend_name == "hostlink" + assert node.identifier == "device-1" + + +def test_status_listeners_receive_backend_neutral_updates() -> None: + node = BasicDeviceNode(Driver(), "device-1") + received = [] + node.add_status_listener( + lambda device_id, name, value: received.append((device_id, name, value)) + ) + + node.emit_status("temperature", 25.0) + + assert received == [("device-1", "temperature", 25.0)] + assert node.latest_status() == {"temperature": 25.0} + + +def test_action_context_carries_feedback_and_cancellation() -> None: + received = [] + context = ActionContext( + action_id="action-1", + feedback_callback=lambda action_id, data: received.append((action_id, data)), + ) + + context.publish_feedback({"progress": 0.5}) + assert received == [("action-1", {"progress": 0.5})] + assert context.is_cancelled is False + + context.request_cancel() + assert context.is_cancelled is True + with pytest.raises(ActionCancelled, match="action-1"): + context.raise_if_cancelled() + + +def test_missing_resource_transport_fails_explicitly() -> None: + node = BasicDeviceNode(Driver(), "device-1", backend_name="hostlink") + + with pytest.raises(BackendCapabilityError, match="hostlink"): + asyncio.run(node.update_resource([])) + + +def test_runtime_propagates_selected_backend_to_nodes() -> None: + runtime = BasicRuntime(backend_name="hostlink") + assert runtime.backend_name == "hostlink" + + +def test_run_async_func_uses_current_backend_and_executes_once() -> None: + node = BasicDeviceNode(Driver(), "device-1", backend_name="hostlink") + calls = [] + traced = [] + + async def operation(value: int) -> int: + calls.append(value) + await asyncio.sleep(0) + return value * 2 + + node.start() + try: + future = node.run_async_func( + operation, + inner_trace_callback=traced.append, + value=21, + ) + assert future.result(timeout=1) == 42 + assert calls == [21] + assert traced == [42] + finally: + node.stop() + + +def test_run_async_func_propagates_error_to_future_and_trace_callback() -> None: + node = BasicDeviceNode(Driver(), "device-1", backend_name="hostlink") + traced = [] + + async def operation() -> None: + raise ValueError("expected failure") + + node.start() + try: + future = node.run_async_func( + operation, + trace_error=False, + inner_trace_callback=traced.append, + ) + with pytest.raises(ValueError, match="expected failure"): + future.result(timeout=1) + assert len(traced) == 1 + assert isinstance(traced[0], ValueError) + finally: + node.stop() + + +def test_migrated_virtual_driver_imports_without_ros() -> None: + code = ( + "import sys; " + "import unilabos.devices.virtual.virtual_centrifuge; " + "import unilabos.devices.virtual.workbench; " + "import unilabos.devices.neware_battery_test_system.neware_battery_test_system; " + "assert not any(name.startswith('unilabos.ros') for name in sys.modules)" + ) + result = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + timeout=20, + ) + assert result.returncode == 0, result.stderr + + +def test_liquid_handling_package_keeps_optional_rviz_ros_import_lazy() -> None: + code = ( + "import sys; " + "from unilabos.config.config import BasicConfig; " + "BasicConfig.backend = 'hostlink'; " + "import unilabos.devices.liquid_handling; " + "assert 'rclpy' not in sys.modules; " + "assert 'unilabos.ros' not in sys.modules" + ) + result = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + timeout=20, + ) + assert result.returncode == 0, result.stderr + + +def test_liquid_handler_drivers_import_in_hostlink_without_ros_runtime() -> None: + pytest.importorskip("pylibftdi") + code = ( + "import sys; " + "from unilabos.config.config import BasicConfig; " + "BasicConfig.backend = 'hostlink'; " + "from unilabos.devices.liquid_handling.liquid_handler_abstract " + "import LiquidHandlerAbstract; " + "from unilabos.devices.liquid_handling.prcxi.prcxi " + "import PRCXI9300Handler; " + "from unilabos.resources.plr_additional_res_reg import register; " + "register(); " + "assert LiquidHandlerAbstract and PRCXI9300Handler; " + "assert 'rclpy' not in sys.modules; " + "assert not any(name.startswith('unilabos.ros') for name in sys.modules)" + ) + result = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, result.stderr diff --git a/tests/device_runtime/test_resource.py b/tests/device_runtime/test_resource.py new file mode 100644 index 000000000..bd8bc07ab --- /dev/null +++ b/tests/device_runtime/test_resource.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import asyncio +from typing import Any + +from unilabos.basic.runtime import BasicDeviceNode +from unilabos.device_runtime.resource import LocalResourceService, ResourceStore +from unilabos.resources.resource_tracker import ResourceTreeSet + + +class Driver: + pass + + +def _resource( + resource_id: str, + resource_uuid: str, + *, + parent_uuid: str | None = None, + resource_type: str = "container", + data: dict[str, Any] | None = None, +) -> dict[str, Any]: + return { + "id": resource_id, + "uuid": resource_uuid, + "name": resource_id, + "parent_uuid": parent_uuid, + "type": resource_type, + "class": "", + "config": {}, + "data": dict(data or {}), + "extra": {}, + } + + +def test_resource_store_mounts_replaces_and_queries_subtrees() -> None: + initial = ResourceTreeSet.from_raw_dict_list( + [ + _resource("device", "device-uuid", resource_type="device"), + _resource("material", "material-uuid", parent_uuid="device-uuid"), + ] + ) + store = ResourceStore(initial) + replacement = ResourceTreeSet.from_raw_dict_list( + [ + _resource( + "material", + "material-uuid", + parent_uuid="device-uuid", + data={"volume": 10}, + ), + _resource("well", "well-uuid", parent_uuid="material-uuid"), + ] + ) + + mapping = store.apply_update(replacement) + + assert mapping == { + "material-uuid": "material-uuid", + "well-uuid": "well-uuid", + } + material = store.resources.find_by_uuid("material-uuid") + assert material is not None + assert material.res_content.data == {"volume": 10} + assert [child.res_content.uuid for child in material.children] == ["well-uuid"] + + complete = store.get_resources(["material-uuid"], with_children=True) + shallow = store.get_resources(["material-uuid"], with_children=False) + assert complete.all_nodes_uuid == ["material-uuid", "well-uuid"] + assert shallow.all_nodes_uuid == ["material-uuid"] + assert shallow.root_nodes[0].res_content.parent_uuid == "device-uuid" + + +def test_basic_device_node_uses_local_resource_service() -> None: + initial = ResourceTreeSet.from_raw_dict_list( + [_resource("device", "device-uuid", resource_type="device")] + ) + store = ResourceStore(initial) + node = BasicDeviceNode( + Driver(), + "device", + resource_uuid="device-uuid", + ) + node.set_resource_service(LocalResourceService(store)) + material = ResourceTreeSet.from_raw_dict_list( + [_resource("material", "material-uuid")] + ) + + asyncio.run(node.update_resource(material)) + result = asyncio.run(node.get_resource(["material-uuid"])) + + assert result.all_nodes_uuid == ["material-uuid"] + stored = store.resources.find_by_uuid("material-uuid") + assert stored is not None + assert stored.res_content.parent_uuid == "device-uuid" diff --git a/tests/device_runtime/test_topic.py b/tests/device_runtime/test_topic.py new file mode 100644 index 000000000..2a4481371 --- /dev/null +++ b/tests/device_runtime/test_topic.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import time + +from unilabos.basic.runtime import BasicDriverSpec, BasicRuntime +from unilabos.device_runtime import LocalTopicBus, TopicEvent +from unilabos.utils.decorator import subscribe + + +class RosLikeMessage: + def __init__(self, data: int) -> None: + self.data = data + + @staticmethod + def get_fields_and_field_types() -> dict[str, str]: + return {"data": "int32"} + + +class SourceDriver: + def __init__(self, device_id=None, config=None) -> None: + self.device_id = device_id + self.publisher = None + + def post_init(self, node) -> None: + self.publisher = node.create_publisher(RosLikeMessage, "value", 10) + + def send(self, value: int) -> int: + self.publisher.publish(RosLikeMessage(value)) + return value + + +class SinkDriver: + def __init__(self, device_id=None, config=None) -> None: + self.device_id = device_id + self.values: list[dict[str, int]] = [] + + def post_init(self, node) -> None: + node.create_subscription( + RosLikeMessage, + "/devices/source/value", + self.values.append, + 10, + trigger_when_change=True, + ) + + +class DecoratedSinkDriver: + def __init__(self, device_id=None, config=None) -> None: + self.device_id = device_id + self.values: list[dict[str, int]] = [] + + @subscribe( + device_id="source", + status_name="value", + trigger_when_change=True, + ) + def receive(self, value) -> None: + self.values.append(value) + + +def _wait_until(predicate, timeout: float = 1.0) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return True + time.sleep(0.01) + return bool(predicate()) + + +def test_basic_runtime_supports_ros_shaped_publish_and_subscribe() -> None: + runtime = BasicRuntime() + source = runtime.add_driver( + BasicDriverSpec( + device_id="source", + driver_class=SourceDriver, + config={}, + action_names=("send",), + ) + ) + sink = runtime.add_driver( + BasicDriverSpec( + device_id="sink", + driver_class=SinkDriver, + config={}, + ) + ) + decorated_sink = runtime.add_driver( + BasicDriverSpec( + device_id="decorated-sink", + driver_class=DecoratedSinkDriver, + config={}, + ) + ) + try: + runtime.start() + assert source.call_action("send", value=7) == 7 + source.call_action("send", value=7) + source.call_action("send", value=8) + assert _wait_until( + lambda: sink.driver.values == [{"data": 7}, {"data": 8}] + ) + assert _wait_until( + lambda: decorated_sink.driver.values + == [{"data": 7}, {"data": 8}] + ) + finally: + runtime.stop() + + +def test_retained_topic_replays_only_to_the_new_subscriber() -> None: + bus = LocalTopicBus() + first: list[int] = [] + second: list[int] = [] + bus.subscribe("/temperature", first.append) + bus.publish(TopicEvent.create("/temperature", 25, retain=True)) + + bus.subscribe("/temperature", second.append) + + assert first == [25] + assert second == [25] diff --git a/tests/hostlink/test_backend.py b/tests/hostlink/test_backend.py new file mode 100644 index 000000000..65556a09a --- /dev/null +++ b/tests/hostlink/test_backend.py @@ -0,0 +1,899 @@ +from __future__ import annotations + +import asyncio +from array import array +import subprocess +import sys +import threading +import time +from concurrent.futures import ThreadPoolExecutor + +import pytest + +from unilabos.basic.runtime import BasicDriverSpec, BasicRuntime +from unilabos.config.config import BasicConfig, HostLinkConfig +from unilabos.device_runtime import ActionCancelled, ActionContext +from unilabos.hostlink.backend import HostLinkBackendRuntime +from unilabos.hostlink.client import HostLinkClient +from unilabos.hostlink.protocol import ActionType, RemoteError +from unilabos.hostlink.server import HostLinkServer +from unilabos.resources.resource_tracker import ResourceTreeSet + + +def _wait_until(predicate, timeout: float = 2.0) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return True + time.sleep(0.01) + return bool(predicate()) + + +class CounterDriver: + def __init__(self, device_id=None, config=None): + self.device_id = device_id + self.count = int((config or {}).get("initial", 0)) + self.node = None + + def post_init(self, node) -> None: + self.node = node + + def increment(self, amount: int = 1) -> int: + self.count += amount + return self.count + + async def increment_async(self, amount: int = 1) -> int: + await self.node.sleep(0) + self.count += amount + return self.count + + def call_peer(self, target_device: str, amount: int) -> int: + return self.node.call_device_action( + target_device, + "increment", + {"amount": amount}, + ) + + def call_peer_with_action_type(self, target_device: str, amount: int) -> int: + return self.node.call_device_action( + target_device, + "increment", + {"amount": amount}, + action_type=object, + ) + + async def call_peer_async(self, target_device: str, amount: int) -> int: + return await self.node.call_device_action_async( + target_device, + "increment_async", + {"amount": amount}, + ) + + +class TopicDriver: + def __init__(self, device_id=None, config=None): + self.device_id = device_id + self.config = dict(config or {}) + self.node = None + self.publisher = None + self.received = [] + + def post_init(self, node) -> None: + self.node = node + if self.config.get("publish"): + self.publisher = node.create_publisher(dict, "value", 10) + subscribe_to = str(self.config.get("subscribe_to") or "") + if subscribe_to: + node.create_subscription( + dict, + f"/devices/{subscribe_to}/value", + self.received.append, + 10, + ) + + def send(self, value: int) -> int: + self.publisher.publish({"value": value}) + return value + + +class RosJsonMessage: + def __init__(self, name: str, samples: list[float]) -> None: + self.name = name + self.samples = array("f", samples) + + @staticmethod + def get_fields_and_field_types() -> dict[str, str]: + return {"name": "string", "samples": "sequence"} + + +class RosJsonDriver: + def __init__(self, device_id=None, config=None): + self.device_id = device_id + self.config = dict(config or {}) + self.node = None + self.publisher = None + self.received = [] + + def post_init(self, node) -> None: + self.node = node + if self.config.get("publish"): + self.publisher = node.create_publisher( + RosJsonMessage, + "ros_value", + 10, + ) + subscribe_to = str(self.config.get("subscribe_to") or "") + if subscribe_to: + node.create_subscription( + RosJsonMessage, + f"/devices/{subscribe_to}/ros_value", + self.received.append, + 10, + ) + + def send(self, name: str) -> str: + self.publisher.publish(RosJsonMessage(name, [1.25, 2.5])) + return name + + def echo(self, payload) -> object: + return payload + + +class FeedbackDriver: + def __init__(self, device_id=None, config=None): + self.device_id = device_id + self.node = None + self.progress = 0 + + def post_init(self, node) -> None: + self.node = node + + async def run_steps(self, steps: int, action_context: ActionContext) -> int: + for step in range(steps): + action_context.raise_if_cancelled() + self.progress = step + 1 + action_context.publish_feedback({"progress": self.progress}) + await self.node.sleep(0.02) + action_context.raise_if_cancelled() + return self.progress + + +class AddService: + class Request: + def __init__(self, left=0, right=0): + self.left = left + self.right = right + + class Response: + def __init__(self): + self.total = 0 + + +class ServiceDriver: + def __init__(self, device_id=None, config=None): + self.device_id = device_id + self.provide = bool((config or {}).get("provide")) + + def post_init(self, node) -> None: + self.node = node + if self.provide: + node.create_service(AddService, "add", self.add) + + @staticmethod + def add(request, response): + response.total = request.left + request.right + return response + + async def call_service(self, target_device: str, left: int, right: int) -> int: + client = self.node.create_client( + AddService, + f"/devices/{target_device}/add", + ) + response = await client.call_async(AddService.Request(left, right)) + return response.total + + +def _counter_runtime( + device_id: str, + initial: int = 0, + resource_uuid: str = "", +) -> BasicRuntime: + runtime = BasicRuntime() + runtime.add_driver( + BasicDriverSpec( + device_id=device_id, + driver_class=CounterDriver, + config={"initial": initial}, + registry_name="counter", + display_name="Counter", + action_names=( + "increment", + "increment_async", + "call_peer", + "call_peer_async", + "call_peer_with_action_type", + ), + action_value_mappings={ + "increment": { + "type": "unilabos_msgs/action/IntSingleInput", + "goal": {"amount": "value"}, + "result": {"return_info": "return_info"}, + "schema": { + "type": "object", + "properties": {"amount": {"type": "integer"}}, + }, + } + }, + status_names=("count",), + resource_uuid=resource_uuid, + ) + ) + return runtime + + +def _feedback_runtime(device_id: str) -> BasicRuntime: + runtime = BasicRuntime(backend_name="hostlink") + runtime.add_driver( + BasicDriverSpec( + device_id=device_id, + driver_class=FeedbackDriver, + config={}, + registry_name="feedback", + display_name="Feedback", + action_names=("run_steps",), + status_names=("progress",), + ) + ) + return runtime + + +def test_server_calls_slave_over_the_existing_control_connection() -> None: + server = HostLinkServer( + "127.0.0.1", + 0, + heartbeat_timeout=1, + request_timeout=0.5, + ).start() + state = {"count": 0} + client = HostLinkClient( + "127.0.0.1", + server.port, + device_descriptors=[ + { + "id": "counter-1", + "registry_name": "counter", + "display_name": "Counter", + "actions": ["increment"], + "status_fields": ["count"], + } + ], + heartbeat_interval=0.05, + request_timeout=0.5, + heartbeat_payload_provider=lambda: {"states": {"counter-1": dict(state)}}, + ) + + def call(data): + state["count"] += int(data["arguments"]["amount"]) + return {"result": state["count"], "state": dict(state)} + + client.register_handler(ActionType.DEVICE_CALL, call) + try: + assert client.connect_blocking(timeout=2) + response = server.call_device( + "counter-1", + "increment", + {"amount": 3}, + ) + assert response["result"] == 3 + assert server.devices()["counter-1"]["device"]["actions"] == ["increment"] + assert _wait_until( + lambda: server.devices()["counter-1"]["state"].get("count") == 3 + ) + finally: + client.close() + server.stop() + + +def test_hostlink_backend_routes_basic_driver_actions_without_ros( + monkeypatch, +) -> None: + monkeypatch.setattr(HostLinkConfig, "enable", True) + monkeypatch.setattr(HostLinkConfig, "bind", "127.0.0.1") + monkeypatch.setattr(HostLinkConfig, "host", "127.0.0.1") + monkeypatch.setattr(HostLinkConfig, "port", 0) + monkeypatch.setattr(HostLinkConfig, "heartbeat_interval", 0.05) + monkeypatch.setattr(HostLinkConfig, "heartbeat_timeout", 1.0) + monkeypatch.setattr(HostLinkConfig, "connect_timeout", 1.0) + monkeypatch.setattr(HostLinkConfig, "request_timeout", 0.5) + monkeypatch.setattr(BasicConfig, "machine_name", "slave-test") + monkeypatch.setattr(BasicConfig, "slave_no_host", False) + + host = HostLinkBackendRuntime(_counter_runtime("host-local"), is_slave=False) + slave = HostLinkBackendRuntime( + _counter_runtime("slave-counter", initial=1), + is_slave=True, + ) + host.start() + assert host.server is not None + HostLinkConfig.port = host.server.port + try: + slave.start() + assert host.call_action("host-local", "increment", amount=2) == 2 + assert host.call_action("slave-counter", "increment", amount=4) == 5 + assert ( + host.call_action( + "slave-counter", + "call_peer", + target_device="host-local", + amount=3, + ) + == 5 + ) + assert ( + host.call_action( + "slave-counter", + "call_peer_with_action_type", + target_device="host-local", + amount=2, + ) + == 7 + ) + assert _wait_until( + lambda: host.devices()["slave-counter"]["state"].get("count") == 5 + ) + assert host.devices()["slave-counter"]["location"] == "remote" + assert host.devices()["host-local"]["location"] == "local" + with pytest.raises(RemoteError, match="没有动作"): + host.server.call_device("slave-counter", "missing") + finally: + slave.stop() + host.stop() + + +def test_hostlink_awaits_device_tools_without_thread_fallback( + monkeypatch, +) -> None: + monkeypatch.setattr(HostLinkConfig, "enable", True) + monkeypatch.setattr(HostLinkConfig, "bind", "127.0.0.1") + monkeypatch.setattr(HostLinkConfig, "host", "127.0.0.1") + monkeypatch.setattr(HostLinkConfig, "port", 0) + monkeypatch.setattr(HostLinkConfig, "heartbeat_interval", 0.05) + monkeypatch.setattr(HostLinkConfig, "heartbeat_timeout", 1.0) + monkeypatch.setattr(HostLinkConfig, "connect_timeout", 1.0) + monkeypatch.setattr(HostLinkConfig, "request_timeout", 1.0) + monkeypatch.setattr(BasicConfig, "machine_name", "async-slave") + monkeypatch.setattr(BasicConfig, "slave_no_host", False) + + host = HostLinkBackendRuntime(_counter_runtime("host-target"), is_slave=False) + slave = HostLinkBackendRuntime( + _counter_runtime("slave-target", initial=1), + is_slave=True, + ) + host.start() + assert host.server is not None + HostLinkConfig.port = host.server.port + try: + slave.start() + + async def reject_thread_fallback(*_args, **_kwargs): + raise AssertionError("异步设备调用不应退回 asyncio.to_thread") + + monkeypatch.setattr(asyncio, "to_thread", reject_thread_fallback) + + async def scenario() -> tuple[int, int]: + host_to_slave = await host.call_action_async( + "slave-target", + "increment_async", + amount=4, + ) + slave_to_host = await slave.local.call_action_async( + "slave-target", + "call_peer_async", + target_device="host-target", + amount=3, + ) + return host_to_slave, slave_to_host + + assert asyncio.run(scenario()) == (5, 3) + assert _wait_until( + lambda: host.devices()["slave-target"]["state"].get("count") == 5 + ) + discovered = host.devices()["slave-target"] + assert "increment_async" in discovered["device"]["actions"] + assert discovered["device"]["status_fields"] == ["count"] + assert discovered["device"]["action_value_mappings"]["increment"]["goal"] == { + "amount": "value" + } + assert discovered["online"] is True + finally: + slave.stop() + host.stop() + + +def test_hostlink_routes_ros_shaped_services_in_both_directions( + monkeypatch, +) -> None: + monkeypatch.setattr(HostLinkConfig, "enable", True) + monkeypatch.setattr(HostLinkConfig, "bind", "127.0.0.1") + monkeypatch.setattr(HostLinkConfig, "host", "127.0.0.1") + monkeypatch.setattr(HostLinkConfig, "port", 0) + monkeypatch.setattr(HostLinkConfig, "heartbeat_interval", 0.05) + monkeypatch.setattr(HostLinkConfig, "heartbeat_timeout", 1.0) + monkeypatch.setattr(HostLinkConfig, "connect_timeout", 1.0) + monkeypatch.setattr(HostLinkConfig, "request_timeout", 1.0) + monkeypatch.setattr(BasicConfig, "machine_name", "service-slave") + monkeypatch.setattr(BasicConfig, "slave_no_host", False) + + host_local = BasicRuntime("hostlink") + host_local.add_driver( + BasicDriverSpec("host-service", ServiceDriver, {"provide": True}) + ) + host_local.add_driver( + BasicDriverSpec( + "host-caller", + ServiceDriver, + {}, + action_names=("call_service",), + ) + ) + slave_local = BasicRuntime("hostlink") + slave_local.add_driver( + BasicDriverSpec("slave-service", ServiceDriver, {"provide": True}) + ) + slave_local.add_driver( + BasicDriverSpec( + "slave-caller", + ServiceDriver, + {}, + action_names=("call_service",), + ) + ) + host = HostLinkBackendRuntime(host_local, is_slave=False) + slave = HostLinkBackendRuntime(slave_local, is_slave=True) + host.start() + assert host.server is not None + HostLinkConfig.port = host.server.port + try: + slave.start() + assert _wait_until(lambda: "slave-service" in host.devices()) + assert host.has_service("/devices/slave-service/add") + assert host.devices()["slave-service"]["device"]["services"] == [ + "/devices/slave-service/add" + ] + assert ( + host.call_action( + "host-caller", + "call_service", + target_device="slave-service", + left=4, + right=5, + ) + == 9 + ) + assert ( + host.call_action( + "slave-caller", + "call_service", + target_device="host-service", + left=7, + right=8, + ) + == 15 + ) + finally: + slave.stop() + host.stop() + + +def test_hostlink_routes_topics_in_both_directions(monkeypatch) -> None: + monkeypatch.setattr(HostLinkConfig, "enable", True) + monkeypatch.setattr(HostLinkConfig, "bind", "127.0.0.1") + monkeypatch.setattr(HostLinkConfig, "host", "127.0.0.1") + monkeypatch.setattr(HostLinkConfig, "port", 0) + monkeypatch.setattr(HostLinkConfig, "heartbeat_interval", 0.05) + monkeypatch.setattr(HostLinkConfig, "heartbeat_timeout", 1.0) + monkeypatch.setattr(HostLinkConfig, "connect_timeout", 1.0) + monkeypatch.setattr(HostLinkConfig, "request_timeout", 1.0) + monkeypatch.setattr(BasicConfig, "machine_name", "topic-slave") + monkeypatch.setattr(BasicConfig, "slave_no_host", False) + + host_local = BasicRuntime("hostlink") + host_source = host_local.add_driver( + BasicDriverSpec( + device_id="host-source", + driver_class=TopicDriver, + config={"publish": True}, + action_names=("send",), + ) + ) + host_sink = host_local.add_driver( + BasicDriverSpec( + device_id="host-sink", + driver_class=TopicDriver, + config={"subscribe_to": "slave-source"}, + ) + ) + slave_local = BasicRuntime("hostlink") + slave_source = slave_local.add_driver( + BasicDriverSpec( + device_id="slave-source", + driver_class=TopicDriver, + config={"publish": True}, + action_names=("send",), + ) + ) + slave_sink = slave_local.add_driver( + BasicDriverSpec( + device_id="slave-sink", + driver_class=TopicDriver, + config={"subscribe_to": "host-source"}, + ) + ) + host = HostLinkBackendRuntime(host_local, is_slave=False) + slave = HostLinkBackendRuntime(slave_local, is_slave=True) + host.start() + assert host.server is not None + HostLinkConfig.port = host.server.port + try: + slave.start() + assert _wait_until( + lambda: any( + "/devices/host-source/value" in topics + for topics in host._remote_topic_subscriptions.values() + ) + ) + + host_source.call_action("send", value=11) + assert _wait_until(lambda: slave_sink.driver.received == [{"value": 11}]) + + slave_source.call_action("send", value=22) + assert _wait_until(lambda: host_sink.driver.received == [{"value": 22}]) + + assert slave.client is not None + with host._remote_topic_lock: + host._remote_topic_subscriptions.clear() + slave.client._teardown_socket() + assert _wait_until(lambda: not slave.client.online) + assert _wait_until(lambda: slave.client.online, timeout=4) + assert _wait_until( + lambda: any( + "/devices/host-source/value" in topics + for topics in host._remote_topic_subscriptions.values() + ) + ) + host_source.call_action("send", value=33) + assert _wait_until( + lambda: slave_sink.driver.received == [{"value": 11}, {"value": 33}] + ) + finally: + slave.stop() + host.stop() + + +def test_hostlink_transports_ros_messages_as_json(monkeypatch) -> None: + monkeypatch.setattr(HostLinkConfig, "enable", True) + monkeypatch.setattr(HostLinkConfig, "bind", "127.0.0.1") + monkeypatch.setattr(HostLinkConfig, "host", "127.0.0.1") + monkeypatch.setattr(HostLinkConfig, "port", 0) + monkeypatch.setattr(HostLinkConfig, "heartbeat_interval", 0.05) + monkeypatch.setattr(HostLinkConfig, "heartbeat_timeout", 1.0) + monkeypatch.setattr(HostLinkConfig, "connect_timeout", 1.0) + monkeypatch.setattr(HostLinkConfig, "request_timeout", 1.0) + monkeypatch.setattr(BasicConfig, "machine_name", "ros-json-slave") + monkeypatch.setattr(BasicConfig, "slave_no_host", False) + + host_local = BasicRuntime("hostlink") + source = host_local.add_driver( + BasicDriverSpec( + device_id="ros-source", + driver_class=RosJsonDriver, + config={"publish": True}, + action_names=("send",), + ) + ) + slave_local = BasicRuntime("hostlink") + sink = slave_local.add_driver( + BasicDriverSpec( + device_id="ros-sink", + driver_class=RosJsonDriver, + config={"subscribe_to": "ros-source"}, + action_names=("echo",), + ) + ) + host = HostLinkBackendRuntime(host_local, is_slave=False) + slave = HostLinkBackendRuntime(slave_local, is_slave=True) + host.start() + assert host.server is not None + HostLinkConfig.port = host.server.port + try: + slave.start() + assert _wait_until( + lambda: any( + "/devices/ros-source/ros_value" in topics + for topics in host._remote_topic_subscriptions.values() + ) + ) + + source.call_action("send", name="中文状态") + assert _wait_until( + lambda: ( + sink.driver.received == [{"name": "中文状态", "samples": [1.25, 2.5]}] + ) + ) + + result = host.call_action( + "ros-sink", + "echo", + payload=RosJsonMessage("中文动作", [3.5, 4.75]), + ) + assert result == {"name": "中文动作", "samples": [3.5, 4.75]} + finally: + slave.stop() + host.stop() + + +def test_hostlink_backend_import_does_not_load_ros() -> None: + code = ( + "import sys; import unilabos.hostlink.main_hostlink_run; " + "assert 'rclpy' not in sys.modules; " + "assert not any(name.startswith('unilabos.ros') for name in sys.modules)" + ) + result = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + timeout=20, + ) + assert result.returncode == 0, result.stderr + + +def test_hostlink_action_feedback_and_cancel(monkeypatch) -> None: + monkeypatch.setattr(HostLinkConfig, "enable", True) + monkeypatch.setattr(HostLinkConfig, "bind", "127.0.0.1") + monkeypatch.setattr(HostLinkConfig, "host", "127.0.0.1") + monkeypatch.setattr(HostLinkConfig, "port", 0) + monkeypatch.setattr(HostLinkConfig, "heartbeat_interval", 0.05) + monkeypatch.setattr(HostLinkConfig, "heartbeat_timeout", 1.0) + monkeypatch.setattr(HostLinkConfig, "connect_timeout", 1.0) + monkeypatch.setattr(HostLinkConfig, "request_timeout", 2.0) + monkeypatch.setattr(BasicConfig, "machine_name", "feedback-slave") + monkeypatch.setattr(BasicConfig, "slave_no_host", False) + + host = HostLinkBackendRuntime(BasicRuntime("hostlink"), is_slave=False) + slave = HostLinkBackendRuntime( + _feedback_runtime("feedback-device"), + is_slave=True, + ) + host.start() + assert host.server is not None + HostLinkConfig.port = host.server.port + feedback_received = threading.Event() + feedback = [] + context = ActionContext( + action_id="cancel-me", + feedback_callback=lambda _action_id, data: ( + feedback.append(data), + feedback_received.set(), + ), + ) + executor = ThreadPoolExecutor(max_workers=1) + try: + slave.start() + future = executor.submit( + host.call_action, + "feedback-device", + "run_steps", + action_context=context, + steps=100, + ) + assert feedback_received.wait(timeout=2) + assert host.cancel_action(context.action_id) is True + with pytest.raises(ActionCancelled, match="cancel-me"): + future.result(timeout=2) + assert feedback + assert feedback[0]["progress"] >= 1 + finally: + executor.shutdown(wait=False, cancel_futures=True) + slave.stop() + host.stop() + + +def test_cancelling_async_call_forwards_to_remote_action(monkeypatch) -> None: + monkeypatch.setattr(HostLinkConfig, "enable", True) + monkeypatch.setattr(HostLinkConfig, "bind", "127.0.0.1") + monkeypatch.setattr(HostLinkConfig, "host", "127.0.0.1") + monkeypatch.setattr(HostLinkConfig, "port", 0) + monkeypatch.setattr(HostLinkConfig, "heartbeat_interval", 0.05) + monkeypatch.setattr(HostLinkConfig, "heartbeat_timeout", 1.0) + monkeypatch.setattr(HostLinkConfig, "connect_timeout", 1.0) + monkeypatch.setattr(HostLinkConfig, "request_timeout", 2.0) + monkeypatch.setattr(BasicConfig, "machine_name", "async-cancel-slave") + monkeypatch.setattr(BasicConfig, "slave_no_host", False) + + host = HostLinkBackendRuntime(BasicRuntime("hostlink"), is_slave=False) + slave = HostLinkBackendRuntime( + _feedback_runtime("feedback-device"), + is_slave=True, + ) + host.start() + assert host.server is not None + HostLinkConfig.port = host.server.port + feedback: list[dict] = [] + context = ActionContext( + action_id="async-cancel", + feedback_callback=lambda _action_id, data: feedback.append(data), + ) + try: + slave.start() + + async def scenario() -> None: + task = asyncio.create_task( + host.call_action_async( + "feedback-device", + "run_steps", + action_context=context, + steps=100, + ) + ) + deadline = asyncio.get_running_loop().time() + 2.0 + while not feedback and asyncio.get_running_loop().time() < deadline: + await asyncio.sleep(0.01) + assert feedback + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + deadline = asyncio.get_running_loop().time() + 2.0 + while slave._actions and asyncio.get_running_loop().time() < deadline: + await asyncio.sleep(0.01) + assert not slave._actions + + asyncio.run(scenario()) + assert context.is_cancelled is True + finally: + slave.stop() + host.stop() + + +def test_hostlink_routes_action_feedback_and_cancel_between_slaves( + monkeypatch, +) -> None: + monkeypatch.setattr(HostLinkConfig, "enable", True) + monkeypatch.setattr(HostLinkConfig, "bind", "127.0.0.1") + monkeypatch.setattr(HostLinkConfig, "host", "127.0.0.1") + monkeypatch.setattr(HostLinkConfig, "port", 0) + monkeypatch.setattr(HostLinkConfig, "heartbeat_interval", 0.05) + monkeypatch.setattr(HostLinkConfig, "heartbeat_timeout", 1.0) + monkeypatch.setattr(HostLinkConfig, "connect_timeout", 1.0) + monkeypatch.setattr(HostLinkConfig, "request_timeout", 2.0) + monkeypatch.setattr(BasicConfig, "slave_no_host", False) + + host = HostLinkBackendRuntime(BasicRuntime("hostlink"), is_slave=False) + caller = HostLinkBackendRuntime( + _counter_runtime("slave-caller"), + is_slave=True, + ) + target = HostLinkBackendRuntime( + _feedback_runtime("slave-target"), + is_slave=True, + ) + host.start() + assert host.server is not None + HostLinkConfig.port = host.server.port + feedback_received = threading.Event() + feedback = [] + context = ActionContext( + action_id="slave-to-slave-cancel", + feedback_callback=lambda _action_id, data: ( + feedback.append(data), + feedback_received.set(), + ), + ) + executor = ThreadPoolExecutor(max_workers=1) + try: + BasicConfig.machine_name = "caller-slave" + caller.start() + BasicConfig.machine_name = "target-slave" + target.start() + assert _wait_until(lambda: "slave-target" in host.devices()) + + future = executor.submit( + caller.route_action, + "slave-caller", + "slave-target", + "run_steps", + {"steps": 100}, + action_context=context, + ) + assert feedback_received.wait(timeout=2) + assert caller.cancel_action(context.action_id) is True + with pytest.raises( + ActionCancelled, + match="slave-to-slave-cancel", + ): + future.result(timeout=2) + assert feedback + assert feedback[0]["progress"] >= 1 + finally: + executor.shutdown(wait=False, cancel_futures=True) + target.stop() + caller.stop() + host.stop() + + +def _resource( + resource_id: str, + resource_uuid: str, + *, + parent_uuid: str | None = None, + resource_type: str = "container", +) -> dict: + return { + "id": resource_id, + "uuid": resource_uuid, + "name": resource_id, + "parent_uuid": parent_uuid, + "type": resource_type, + "class": "", + "config": {}, + "data": {}, + "extra": {}, + } + + +def test_hostlink_syncs_and_serves_slave_resources(monkeypatch) -> None: + monkeypatch.setattr(HostLinkConfig, "enable", True) + monkeypatch.setattr(HostLinkConfig, "bind", "127.0.0.1") + monkeypatch.setattr(HostLinkConfig, "host", "127.0.0.1") + monkeypatch.setattr(HostLinkConfig, "port", 0) + monkeypatch.setattr(HostLinkConfig, "heartbeat_interval", 0.05) + monkeypatch.setattr(HostLinkConfig, "heartbeat_timeout", 1.0) + monkeypatch.setattr(HostLinkConfig, "connect_timeout", 1.0) + monkeypatch.setattr(HostLinkConfig, "request_timeout", 2.0) + monkeypatch.setattr(BasicConfig, "machine_name", "resource-slave") + monkeypatch.setattr(BasicConfig, "slave_no_host", False) + + slave_resources = ResourceTreeSet.from_raw_dict_list( + [ + _resource( + "resource-device", + "device-uuid", + resource_type="device", + ), + _resource( + "existing-material", + "existing-uuid", + parent_uuid="device-uuid", + ), + ] + ) + host = HostLinkBackendRuntime(BasicRuntime("hostlink"), is_slave=False) + slave = HostLinkBackendRuntime( + _counter_runtime( + "resource-device", + resource_uuid="device-uuid", + ), + is_slave=True, + resources_config=slave_resources, + ) + host.start() + assert host.server is not None + HostLinkConfig.port = host.server.port + try: + slave.start() + assert host.resource_store.resources.find_by_uuid("existing-uuid") + + new_material = ResourceTreeSet.from_raw_dict_list( + [_resource("new-material", "new-uuid")] + ) + slave_node = slave.local.devices["resource-device"] + asyncio.run(slave_node.update_resource(new_material)) + queried = asyncio.run(slave_node.get_resource(["new-uuid"])) + + assert queried.all_nodes_uuid == ["new-uuid"] + stored = host.resource_store.resources.find_by_uuid("new-uuid") + assert stored is not None + assert stored.res_content.parent_uuid == "device-uuid" + finally: + slave.stop() + host.stop() diff --git a/tests/hostlink/test_cli.py b/tests/hostlink/test_cli.py index 1190fcad8..598b6dd88 100644 --- a/tests/hostlink/test_cli.py +++ b/tests/hostlink/test_cli.py @@ -51,6 +51,18 @@ def test_networking_cli_accepts_host_and_domain_aliases() -> None: assert args.ros_static_peers == "10.0.0.9;10.0.0.10" +@pytest.mark.parametrize("option", ["--is_slave", "--is-slave"]) +def test_slave_role_accepts_dash_and_underscore(option) -> None: + args = parse_args().parse_args([option]) + assert args.is_slave is True + + +@pytest.mark.parametrize("option", ["--slave_no_host", "--slave-no-host"]) +def test_slave_offline_mode_accepts_dash_and_underscore(option) -> None: + args = parse_args().parse_args([option]) + assert args.slave_no_host is True + + @pytest.mark.parametrize("domain_id", ["-1", "233"]) def test_networking_cli_domain_range_is_validated_at_startup(domain_id) -> None: args = parse_args().parse_args(["--ros-domain-id", domain_id]) diff --git a/tests/hostlink/test_link.py b/tests/hostlink/test_link.py index 4e5fb0d21..5abac6508 100644 --- a/tests/hostlink/test_link.py +++ b/tests/hostlink/test_link.py @@ -1,6 +1,10 @@ +import asyncio +import threading import time +from concurrent.futures import ThreadPoolExecutor from unilabos.hostlink.client import HostLinkClient +from unilabos.hostlink.protocol import ActionType from unilabos.hostlink.ros_assist import RosNetworkInfo from unilabos.hostlink.server import HostLinkServer @@ -63,3 +67,114 @@ def test_same_device_set_keeps_logical_identity_after_reconnect() -> None: first.close() second.close() server.stop() + + +def test_overlapping_device_set_keeps_identity_when_assignment_changes() -> None: + server = HostLinkServer("127.0.0.1", 0).start() + first = HostLinkClient( + "127.0.0.1", + server.port, + device_ids=["pump-1", "sensor-1"], + ) + changed = HostLinkClient( + "127.0.0.1", + server.port, + device_ids=["heater-1", "sensor-1"], + ) + try: + assert first.connect_blocking(timeout=2) + original_node_id = server.peers()[0]["node_id"] + first.close() + assert changed.connect_blocking(timeout=2) + assert _wait_until(lambda: len(server.peers()) == 1) + peer = server.peers()[0] + assert peer["node_id"] == original_node_id + assert peer["device_ids"] == ["heater-1", "sensor-1"] + assert peer["online"] is True + finally: + first.close() + changed.close() + server.stop() + + +def test_slow_request_does_not_block_ping_on_the_same_connection() -> None: + server = HostLinkServer( + "127.0.0.1", + 0, + heartbeat_timeout=1, + request_timeout=1, + ).start() + entered = threading.Event() + release = threading.Event() + + def slow(_data, _peer): + entered.set() + assert release.wait(timeout=2) + return {"done": True} + + server.register_handler("test.slow", slow) + client = HostLinkClient( + "127.0.0.1", + server.port, + heartbeat_interval=10, + request_timeout=1, + ) + executor = ThreadPoolExecutor(max_workers=1) + try: + assert client.connect_blocking(timeout=2) + future = executor.submit(client.request, "test.slow") + assert entered.wait(timeout=1) + assert client.request(ActionType.PING, timeout=0.5)["pong"] is True + release.set() + assert future.result(timeout=1) == {"done": True} + finally: + release.set() + executor.shutdown(wait=False, cancel_futures=True) + client.close() + server.stop() + + +def test_async_requests_work_in_both_directions() -> None: + server = HostLinkServer( + "127.0.0.1", + 0, + heartbeat_timeout=1, + request_timeout=1, + ).start() + server.register_handler( + "test.host_echo", + lambda data, _peer: {"host": data["value"]}, + ) + client = HostLinkClient( + "127.0.0.1", + server.port, + device_ids=["async-device"], + heartbeat_interval=10, + request_timeout=1, + ) + client.register_handler( + "test.slave_echo", + lambda data: {"slave": data["value"]}, + ) + try: + assert client.connect_blocking(timeout=2) + + async def scenario() -> tuple[dict, dict]: + return await asyncio.gather( + client.request_async( + "test.host_echo", + {"value": "to-host"}, + ), + server.request_device_async( + "async-device", + "test.slave_echo", + {"value": "to-slave"}, + ), + ) + + host_result, slave_result = asyncio.run(scenario()) + assert host_result == {"host": "to-host"} + assert slave_result == {"slave": "to-slave"} + finally: + client.close() + server.stop() diff --git a/tests/hostlink/test_protocol.py b/tests/hostlink/test_protocol.py index 8a15a591e..88f94145f 100644 --- a/tests/hostlink/test_protocol.py +++ b/tests/hostlink/test_protocol.py @@ -1,4 +1,5 @@ import io +from array import array import pytest @@ -17,6 +18,47 @@ def test_request_round_trip() -> None: assert read_message(io.BytesIO(encode_frame(request))) == request +class RosPoint: + def __init__(self, x: float, y: float, z: float) -> None: + self.x = x + self.y = y + self.z = z + + @staticmethod + def get_fields_and_field_types() -> dict[str, str]: + return {"x": "double", "y": "double", "z": "double"} + + +class RosPayload: + def __init__(self) -> None: + self.name = "中文设备" + self.point = RosPoint(1.0, 2.0, 3.0) + self.samples = array("f", [0.5, 1.5]) + + @staticmethod + def get_fields_and_field_types() -> dict[str, str]: + return { + "name": "string", + "point": "geometry_msgs/Point", + "samples": "sequence", + } + + +def test_ros_message_arguments_are_encoded_as_utf8_json() -> None: + request = new_request( + ActionType.DEVICE_CALL, + {"arguments": {"payload": RosPayload()}}, + ) + + decoded = read_message(io.BytesIO(encode_frame(request))) + + assert decoded["data"]["arguments"]["payload"] == { + "name": "中文设备", + "point": {"x": 1.0, "y": 2.0, "z": 3.0}, + "samples": pytest.approx([0.5, 1.5]), + } + + def test_truncated_frame_is_rejected() -> None: with pytest.raises(LinkError, match="truncated"): read_message(io.BytesIO(b'{"kind":"req"}')) diff --git a/tests/networking/hostlink_lan_virtual_devices.py b/tests/networking/hostlink_lan_virtual_devices.py new file mode 100644 index 000000000..cc3edc8d3 --- /dev/null +++ b/tests/networking/hostlink_lan_virtual_devices.py @@ -0,0 +1,212 @@ +"""README LAN demo adapted into spawn-safe virtual devices for CI.""" + +from __future__ import annotations + +import threading +import time +from typing import Any + +from unilabos.basic.runtime import BasicDriverSpec, BasicRuntime +from unilabos.config.config import BasicConfig, HostLinkConfig +from unilabos.hostlink.backend import HostLinkBackendRuntime +from unilabos.utils.decorator import subscribe + + +SUB_DEVICE_ID = "sub_reporter" + + +class VirtualLanHub: + """订阅远端计数器,并在达到阈值后调用远端停止动作。""" + + def __init__( + self, + device_id: str | None = None, + event_queue: Any = None, + terminate_after: int = 3, + **_kwargs: Any, + ) -> None: + self.device_id = device_id or "hub_node" + self._event_queue = event_queue + self._terminate_after = int(terminate_after) + self._node: Any = None + self._received_count = 0 + self._triggered = False + self._states: list[str] = [] + + def post_init(self, node: Any) -> None: + self._node = node + + @subscribe(device_id=SUB_DEVICE_ID, status_name="counter") + def on_sub_counter(self, value: Any) -> None: + counter = int(value) + if counter <= 0 or self._triggered: + return + self._received_count += 1 + self._event_queue.put( + ("hub_counter", {"value": counter, "count": self._received_count}) + ) + if self._received_count < self._terminate_after: + return + self._triggered = True + threading.Thread( + target=self._terminate_sub, + daemon=True, + name="virtual-lan-stop-action", + ).start() + + @subscribe( + device_id=SUB_DEVICE_ID, + status_name="state", + trigger_when_change=True, + ) + def on_sub_state(self, state: Any) -> None: + normalized = str(state) + self._states.append(normalized) + self._event_queue.put(("hub_state", normalized)) + + def _terminate_sub(self) -> None: + try: + result = self._node.call_device_action( + SUB_DEVICE_ID, + "stop_counting", + {}, + timeout=5.0, + ) + except Exception as exc: # noqa: BLE001 - 子进程需把错误送回 pytest + self._event_queue.put(("worker_error", f"hub action: {exc!r}")) + return + self._event_queue.put( + ( + "closed_loop", + { + "received_count": self._received_count, + "states": list(self._states), + "result": result, + }, + ) + ) + + +class VirtualLanReporter: + """周期状态由 Slave 心跳读取,并通过 HostLink Topic 发布。""" + + def __init__( + self, + device_id: str | None = None, + event_queue: Any = None, + count_rate: float = 100.0, + **_kwargs: Any, + ) -> None: + self.device_id = device_id or SUB_DEVICE_ID + self._event_queue = event_queue + self._count_rate = float(count_rate) + self._started_at = time.monotonic() + self._paused = False + + def post_init(self, _node: Any) -> None: + self._started_at = time.monotonic() + self._paused = False + + @property + def counter(self) -> int: + if self._paused: + return 0 + return max(1, int((time.monotonic() - self._started_at) * self._count_rate)) + + @property + def state(self) -> str: + return "paused" if self._paused else "running" + + def stop_counting(self) -> dict[str, Any]: + stopped_at = self.counter + self._paused = True + result = { + "success": True, + "stopped_at": stopped_at, + "device_id": self.device_id, + } + self._event_queue.put(("reporter_stopped", result)) + return result + + +def _configure_hostlink() -> None: + HostLinkConfig.enable = True + HostLinkConfig.heartbeat_interval = 0.05 + HostLinkConfig.heartbeat_timeout = 1.0 + HostLinkConfig.connect_timeout = 3.0 + HostLinkConfig.request_timeout = 5.0 + BasicConfig.slave_no_host = False + + +def run_virtual_lan_host(event_queue: Any, stop_event: Any) -> None: + """Start the virtual Hub in its own Host process.""" + + runtime: HostLinkBackendRuntime | None = None + try: + _configure_hostlink() + HostLinkConfig.bind = "0.0.0.0" + HostLinkConfig.port = 0 + BasicConfig.machine_name = "virtual-lan-host" + local = BasicRuntime("hostlink") + local.add_driver( + BasicDriverSpec( + device_id="hub_node", + driver_class=VirtualLanHub, + config={ + "event_queue": event_queue, + "terminate_after": 3, + }, + registry_name="hub_node_demo", + ) + ) + runtime = HostLinkBackendRuntime(local, is_slave=False) + runtime.start() + assert runtime.server is not None + event_queue.put(("host_ready", {"port": runtime.server.port})) + stop_event.wait(15.0) + except Exception as exc: # noqa: BLE001 - 子进程需把错误送回 pytest + event_queue.put(("worker_error", f"host: {exc!r}")) + raise + finally: + if runtime is not None: + runtime.stop() + + +def run_virtual_lan_slave( + host: str, + port: int, + event_queue: Any, + stop_event: Any, +) -> None: + """Start the virtual Reporter in a separate Slave process.""" + + runtime: HostLinkBackendRuntime | None = None + try: + _configure_hostlink() + HostLinkConfig.host = str(host) + HostLinkConfig.port = int(port) + BasicConfig.machine_name = "virtual-lan-slave" + local = BasicRuntime("hostlink") + local.add_driver( + BasicDriverSpec( + device_id=SUB_DEVICE_ID, + driver_class=VirtualLanReporter, + config={"event_queue": event_queue, "count_rate": 100.0}, + registry_name="status_reporter_demo", + action_names=("stop_counting",), + status_names=("counter", "state"), + ) + ) + runtime = HostLinkBackendRuntime(local, is_slave=True) + runtime.start() + event_queue.put(("slave_ready", {"host": host, "port": port})) + stop_event.wait(15.0) + except Exception as exc: # noqa: BLE001 - 子进程需把错误送回 pytest + event_queue.put(("worker_error", f"slave: {exc!r}")) + raise + finally: + if runtime is not None: + runtime.stop() + + +__all__ = ["run_virtual_lan_host", "run_virtual_lan_slave"] diff --git a/tests/networking/test_hostlink_lan_e2e.py b/tests/networking/test_hostlink_lan_e2e.py new file mode 100644 index 000000000..d58c30f5d --- /dev/null +++ b/tests/networking/test_hostlink_lan_e2e.py @@ -0,0 +1,248 @@ +"""Multi-process LAN test adapted from README's LabDeviceLanDemo.""" + +from __future__ import annotations + +import multiprocessing +import os +from pathlib import Path +from queue import Empty +import socket +import time +from typing import Any + +import pytest + +from unilabos.basic.runtime import BasicDriverSpec, BasicRuntime +from unilabos.config.config import BasicConfig, HostLinkConfig +from unilabos.hostlink.backend import HostLinkBackendRuntime + +from tests.networking.hostlink_lan_virtual_devices import ( + run_virtual_lan_host, + run_virtual_lan_slave, +) + + +def _primary_lan_ipv4() -> str | None: + """Return a local non-loopback IPv4 address without sending network data.""" + + candidates: list[str] = [] + probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + probe.connect(("192.0.2.1", 9)) + candidates.append(str(probe.getsockname()[0])) + except OSError: + pass + finally: + probe.close() + try: + candidates.extend( + str(item[4][0]) + for item in socket.getaddrinfo( + socket.gethostname(), + None, + socket.AF_INET, + socket.SOCK_STREAM, + ) + ) + except OSError: + pass + return next( + ( + address + for address in dict.fromkeys(candidates) + if address + and not address.startswith("127.") + and not address.startswith("169.254.") + and address != "0.0.0.0" + ), + None, + ) + + +def _wait_for_event( + event_queue: Any, + kind: str, + seen: list[tuple[str, Any]], + *, + timeout: float, +) -> Any: + for event_kind, payload in seen: + if event_kind == kind: + return payload + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + event_kind, payload = event_queue.get( + timeout=min(0.2, max(0.01, deadline - time.monotonic())) + ) + except Empty: + continue + seen.append((event_kind, payload)) + if event_kind == "worker_error": + pytest.fail(str(payload)) + if event_kind == kind: + return payload + pytest.fail(f"等待虚拟 LAN 事件超时:{kind};已收到:{seen}") + + +def _stop_process(process: multiprocessing.Process, stop_event: Any) -> None: + stop_event.set() + process.join(timeout=5.0) + if process.is_alive(): + process.terminate() + process.join(timeout=3.0) + + +_LAN_IPV4 = _primary_lan_ipv4() + + +@pytest.mark.parametrize( + ("connection_name", "host_address"), + [ + ("loopback", "127.0.0.1"), + pytest.param( + "lan", + _LAN_IPV4, + marks=pytest.mark.skipif( + _LAN_IPV4 is None, + reason="当前 runner 没有非回环 IPv4 地址", + ), + ), + ], +) +def test_virtual_devices_complete_subscribe_and_action_loop_over_hostlink( + connection_name: str, + host_address: str | None, +) -> None: + """Run a Hub and Reporter as separate processes over loopback and LAN.""" + + assert host_address is not None + context = multiprocessing.get_context("spawn") + event_queue = context.Queue() + stop_event = context.Event() + seen: list[tuple[str, Any]] = [] + host_process = context.Process( + target=run_virtual_lan_host, + args=(event_queue, stop_event), + name=f"hostlink-{connection_name}-host", + ) + slave_process: multiprocessing.Process | None = None + host_process.start() + try: + host_ready = _wait_for_event( + event_queue, + "host_ready", + seen, + timeout=8.0, + ) + slave_process = context.Process( + target=run_virtual_lan_slave, + args=(host_address, int(host_ready["port"]), event_queue, stop_event), + name=f"hostlink-{connection_name}-slave", + ) + slave_process.start() + slave_ready = _wait_for_event( + event_queue, + "slave_ready", + seen, + timeout=8.0, + ) + closed_loop = _wait_for_event( + event_queue, + "closed_loop", + seen, + timeout=8.0, + ) + reporter_stopped = _wait_for_event( + event_queue, + "reporter_stopped", + seen, + timeout=2.0, + ) + + assert slave_ready["host"] == host_address + assert closed_loop["received_count"] >= 3 + assert closed_loop["result"] == reporter_stopped + assert closed_loop["result"]["success"] is True + assert closed_loop["result"]["device_id"] == "sub_reporter" + assert any(kind == "hub_counter" for kind, _payload in seen) + assert ("hub_state", "running") in seen + finally: + if slave_process is not None: + _stop_process(slave_process, stop_event) + _stop_process(host_process, stop_event) + event_queue.close() + + assert host_process.exitcode == 0 + assert slave_process is not None and slave_process.exitcode == 0 + + +def test_readme_lan_demo_actual_drivers_close_the_hostlink_loop( + monkeypatch, +) -> None: + """Run the pinned LabDeviceLanDemo drivers when CI checked them out.""" + + examples_root = os.environ.get("UNILABOS_README_EXAMPLES_ROOT") + if not examples_root: + pytest.skip("README 外部设备包只在 CI 检出后运行") + package_root = Path(examples_root) / "LabDeviceLanDemo" + assert package_root.is_dir(), f"缺少 README LAN 示例仓库:{package_root}" + monkeypatch.syspath_prepend(str(package_root)) + + from lan_demo.hub_node import HubNodeDemo + from lan_demo.status_reporter import StatusReporterDemo + + monkeypatch.setattr(HostLinkConfig, "enable", True) + monkeypatch.setattr(HostLinkConfig, "bind", "127.0.0.1") + monkeypatch.setattr(HostLinkConfig, "host", "127.0.0.1") + monkeypatch.setattr(HostLinkConfig, "port", 0) + monkeypatch.setattr(HostLinkConfig, "heartbeat_interval", 0.05) + monkeypatch.setattr(HostLinkConfig, "heartbeat_timeout", 1.0) + monkeypatch.setattr(HostLinkConfig, "connect_timeout", 2.0) + monkeypatch.setattr(HostLinkConfig, "request_timeout", 2.0) + monkeypatch.setattr(BasicConfig, "machine_name", "readme-lan-demo") + monkeypatch.setattr(BasicConfig, "slave_no_host", False) + + host_local = BasicRuntime("hostlink") + hub = host_local.add_driver( + BasicDriverSpec( + device_id="hub_node", + driver_class=HubNodeDemo, + config={"sub_device": "sub_reporter", "terminate_after": 3}, + registry_name="hub_node_demo", + status_names=("received_count", "terminations", "last_action"), + ) + ) + slave_local = BasicRuntime("hostlink") + reporter = slave_local.add_driver( + BasicDriverSpec( + device_id="sub_reporter", + driver_class=StatusReporterDemo, + config={ + "count_rate": 100.0, + "cycle_pause": 60.0, + "auto_start": True, + }, + registry_name="status_reporter_demo", + action_names=("stop_counting", "start_counting", "echo"), + status_names=("counter", "heartbeat", "state"), + ) + ) + host = HostLinkBackendRuntime(host_local, is_slave=False) + slave = HostLinkBackendRuntime(slave_local, is_slave=True) + host.start() + assert host.server is not None + HostLinkConfig.port = host.server.port + try: + slave.start() + deadline = time.monotonic() + 6.0 + while time.monotonic() < deadline: + if hub.driver._terminations >= 1 and reporter.driver._paused: + break + time.sleep(0.05) + assert hub.driver._terminations >= 1 + assert reporter.driver._paused is True + assert not hub.driver._last_action.startswith("终止失败") + finally: + slave.stop() + host.stop() diff --git a/tests/registry/test_backend_metadata.py b/tests/registry/test_backend_metadata.py new file mode 100644 index 000000000..481346e17 --- /dev/null +++ b/tests/registry/test_backend_metadata.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor + +from unilabos.registry.ast_registry_scanner import scan_directory +from unilabos.registry.decorators import device, get_device_meta + + +def test_device_decorator_keeps_supported_backends() -> None: + @device( + id="backend_metadata_runtime_test", + category=["test"], + supported_backends=["hostlink", "ros2"], + ) + class RuntimeDriver: + pass + + metadata = get_device_meta(RuntimeDriver) + assert metadata is not None + assert metadata["supported_backends"] == ["hostlink", "ros2"] + + +def test_ast_scanner_keeps_supported_backends(tmp_path) -> None: + source = tmp_path / "driver.py" + source.write_text( + "\n".join( + [ + "from unilabos.registry.decorators import device", + "", + "@device(", + " id='backend_metadata_ast_test',", + " category=['test'],", + " supported_backends=['basic', 'hostlink', 'ros2'],", + ")", + "class Driver:", + " pass", + ] + ), + encoding="utf-8", + ) + + with ThreadPoolExecutor(max_workers=1) as executor: + result = scan_directory( + tmp_path, + python_path=tmp_path, + executor=executor, + ) + + metadata = result["devices"]["backend_metadata_ast_test"] + assert metadata["supported_backends"] == ["basic", "hostlink", "ros2"] diff --git a/tests/ros/test_device_node_contract.py b/tests/ros/test_device_node_contract.py new file mode 100644 index 000000000..322e0f64b --- /dev/null +++ b/tests/ros/test_device_node_contract.py @@ -0,0 +1,54 @@ +import asyncio +import inspect + +from unilabos.device_runtime import DeviceNode +from unilabos.ros.nodes import base_device_node +from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode + + +def test_ros2_node_implements_backend_neutral_contract() -> None: + assert issubclass(BaseROS2DeviceNode, DeviceNode) + assert BaseROS2DeviceNode.backend_name == "ros2" + assert not inspect.iscoroutinefunction(BaseROS2DeviceNode.create_task) + + +def test_ros2_create_task_accepts_common_coroutine_contract(monkeypatch) -> None: + captured = {} + + class Executor: + def create_task(self, coroutine): + captured["coroutine"] = coroutine + return "scheduled" + + monkeypatch.setattr( + base_device_node.rclpy, + "get_global_executor", + lambda: Executor(), + ) + + async def operation() -> int: + return 42 + + result = BaseROS2DeviceNode.create_task(object(), operation()) + + assert result == "scheduled" + assert asyncio.run(captured["coroutine"]) == 42 + + +def test_ros2_callable_create_task_uses_generic_run_async_func() -> None: + captured = {} + + class NodeAdapter: + def run_async_func(self, func, trace_error=True, **kwargs): + captured.update(func=func, trace_error=trace_error, kwargs=kwargs) + return "scheduled" + + async def operation() -> int: + return 42 + + result = BaseROS2DeviceNode.create_task(NodeAdapter(), operation) + + assert result == "scheduled" + assert captured["trace_error"] is True + assert captured["kwargs"] == {} + assert asyncio.run(captured["func"]()) == 42 diff --git a/unilabos/app/backend.py b/unilabos/app/backend.py index be38ee41b..cb1a4e802 100644 --- a/unilabos/app/backend.py +++ b/unilabos/app/backend.py @@ -1,53 +1,278 @@ +"""Backend 配置档与运行时分发。 + +公开名称直接说明使用的通信方式和运行模式,不跟内部包名绑定: + +``basic`` + 不使用通信中间件的进程内 Python 驱动运行时。 +``hostlink`` + Basic 驱动 + HostLink TCP 的 Python 分布式运行时;不启动 rclpy/DDS, + 但可以加载 ROS message 包用于字段解析和 JSON 转换。 +``ros2`` + 完整 ROS 2 运行时。 +``dora`` + dora-rs 数据流运行时。 + +旧 CLI 值 ``simple`` 和 ``ros`` 作为兼容别名继续接受。所有映射集中在本模块, +使 CLI、运行时、测试和文档共享同一份事实,并确保可选 backend 只在被选中时导入。 +""" + +from __future__ import annotations + +import importlib import threading +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Callable, Iterable, Optional -from unilabos.resources.resource_tracker import ResourceTreeSet from unilabos.utils import logger +if TYPE_CHECKING: + from unilabos.resources.resource_tracker import ResourceTreeSet + + +class BackendConfigurationError(ValueError): + """Backend 名称或参数组合不受支持。""" + + +@dataclass(frozen=True) +class BackendProfile: + """一个可选 backend 的静态元数据。""" + + name: str + display_name: str + module: str + description: str + default_app_bridges: tuple[str, ...] + supported_app_bridges: tuple[str, ...] + supports_slave: bool + supports_visualization: bool + + +@dataclass(frozen=True) +class BackendSelection: + """完成规范化和校验的启动选择。""" + + profile: BackendProfile + app_bridges: tuple[str, ...] + + @property + def name(self) -> str: + return self.profile.name + + +BACKEND_PROFILES: dict[str, BackendProfile] = { + "basic": BackendProfile( + name="basic", + display_name="Basic", + module="unilabos.basic.main_basic_run", + description="无中间件的单进程 Python 驱动运行时", + default_app_bridges=(), + supported_app_bridges=(), + supports_slave=False, + supports_visualization=False, + ), + "hostlink": BackendProfile( + name="hostlink", + display_name="HostLink", + module="unilabos.hostlink.main_hostlink_run", + description="HostLink TCP 分布式 Python 驱动运行时(不启动 rclpy/DDS)", + default_app_bridges=(), + supported_app_bridges=(), + supports_slave=True, + supports_visualization=False, + ), + "ros2": BackendProfile( + name="ros2", + display_name="ROS 2", + module="unilabos.ros.main_slave_run", + description="ROS 2 分布式设备运行时", + default_app_bridges=("websocket", "fastapi"), + supported_app_bridges=("websocket", "fastapi"), + supports_slave=True, + supports_visualization=True, + ), + "dora": BackendProfile( + name="dora", + display_name="Dora", + module="unilabos.dora.main_dora_run", + description="dora-rs 数据流运行时", + default_app_bridges=(), + supported_app_bridges=(), + supports_slave=False, + supports_visualization=False, + ), +} + +BACKEND_NAMES: tuple[str, ...] = tuple(BACKEND_PROFILES) +BACKEND_ALIASES: dict[str, str] = { + "simple": "basic", + "ros": "ros2", +} + +DEFAULT_PYTHON_DRIVER_BACKENDS = ("basic", "hostlink", "ros2") + +_REMOVED_BACKENDS: dict[str, str] = { + "automancer": "automancer 从未实现,现已移除", +} + + +def normalize_backend_name(value: str) -> str: + """返回规范 backend 名称,并接受已登记的旧别名。""" + + name = str(value or "").strip().lower() + if name in BACKEND_ALIASES: + canonical = BACKEND_ALIASES[name] + logger.warning( + "Backend 名称 '%s' 已弃用,请改用 '%s'。", + name, + canonical, + ) + return canonical + if name in _REMOVED_BACKENDS: + raise BackendConfigurationError(_REMOVED_BACKENDS[name]) + if name not in BACKEND_PROFILES: + supported = ", ".join(BACKEND_NAMES) + raise BackendConfigurationError( + f"不支持 backend {value!r};请选择:{supported}" + ) + return name + + +def backend_cli_value(value: str) -> str: + """供公开 CLI 使用的 ``argparse`` 类型适配器。""" + + return normalize_backend_name(value) + + +def resolve_driver_backends(class_config: dict[str, Any]) -> tuple[str, ...]: + """Return backends a registry driver declares it can run on.""" + + configured = class_config.get("supported_backends") + if configured is None: + if class_config.get("type") == "ros2": + return ("ros2",) + return DEFAULT_PYTHON_DRIVER_BACKENDS + if isinstance(configured, str): + configured = [configured] + if not isinstance(configured, (list, tuple)): + raise BackendConfigurationError( + "class.supported_backends 必须是 backend 名称列表" + ) + result = tuple( + dict.fromkeys(str(item).strip().lower() for item in configured if str(item).strip()) + ) + invalid = sorted(set(result) - set(BACKEND_NAMES)) + if invalid: + raise BackendConfigurationError( + f"class.supported_backends 包含未知 backend:{', '.join(invalid)}" + ) + if not result: + raise BackendConfigurationError("class.supported_backends 不能为空") + return result + + +def resolve_backend_selection( + backend: str, + app_bridges: Optional[Iterable[str]] = None, + *, + is_slave: bool = False, + visual: str = "disable", +) -> BackendSelection: + """规范化 backend 选择,并拒绝不支持的组合。 + + ``None`` 表示用户未指定 ``--app_bridges``,使用该 backend 的默认值; + 显式空序列表示关闭全部应用桥。 + """ + + name = normalize_backend_name(backend) + profile = BACKEND_PROFILES[name] + bridges = ( + profile.default_app_bridges + if app_bridges is None + else tuple(dict.fromkeys(str(item).strip().lower() for item in app_bridges)) + ) + bridges = tuple(item for item in bridges if item) + unsupported_bridges = sorted(set(bridges) - set(profile.supported_app_bridges)) + if unsupported_bridges: + unsupported = ", ".join(unsupported_bridges) + supported = ", ".join(profile.supported_app_bridges) or "无" + raise BackendConfigurationError( + f"backend '{name}' 不支持应用桥:{unsupported};支持项:{supported}" + ) + if is_slave and not profile.supports_slave: + raise BackendConfigurationError( + f"backend '{name}' 不支持 --is_slave;" + "请使用 backend 'hostlink' 或 'ros2'" + ) + if visual != "disable" and not profile.supports_visualization: + raise BackendConfigurationError( + f"backend '{name}' 不支持 --visual {visual};" + "请使用 --visual disable 或 backend 'ros2'" + ) + return BackendSelection(profile=profile, app_bridges=bridges) + + +def _load_entrypoint(profile: BackendProfile, is_slave: bool) -> Callable[..., None]: + """只导入选中的 backend,并返回已校验的入口。""" + + try: + module = importlib.import_module(profile.module) + except Exception as exc: + raise RuntimeError( + f"backend '{profile.name}' 不可用:导入 {profile.module} 失败:{exc}" + ) from exc + + validate_environment = getattr(module, "validate_environment", None) + if callable(validate_environment): + validate_environment() + + entrypoint_name = "slave" if is_slave else "main" + entrypoint = getattr(module, entrypoint_name, None) + if not callable(entrypoint): + raise RuntimeError( + f"backend '{profile.name}' 没有可调用的 {entrypoint_name} 入口" + ) + return entrypoint + -# 根据选择的 backend 启动相应的功能 def start_backend( backend: str, - devices_config: ResourceTreeSet, - resources_config: ResourceTreeSet, - resources_edge_config: list[dict] = [], - graph=None, - controllers_config: dict = {}, - bridges=[], + devices_config: "ResourceTreeSet", + resources_config: "ResourceTreeSet", + resources_edge_config: Optional[list[dict[str, Any]]] = None, + graph: Any = None, + controllers_config: Optional[dict[str, Any]] = None, + bridges: Optional[list[Any]] = None, is_slave: bool = False, - visual: str = "None", - resources_mesh_config: dict = {}, - **kwargs, -): - if backend == "ros": - # 假设 ros_main, simple_main, automancer_main 是不同 backend 的启动函数 - from unilabos.ros.main_slave_run import main, slave # 如果选择 'ros' 作为 backend - elif backend == "dora": - # dora-rs 通信中间件后端(Apache Arrow + 共享内存),无需 ROS2 - from unilabos.dora.main_dora_run import main, slave - elif backend == "simple": - # 这里假设 simple_backend 和 automancer_backend 是你定义的其他两个后端 - # from simple_backend import main as simple_main - pass - elif backend == "automancer": - # from automancer_backend import main as automancer_main - pass - else: - raise ValueError(f"Unsupported backend: {backend}") + visual: str = "disable", + resources_mesh_config: Optional[dict[str, Any]] = None, + **kwargs: Any, +) -> threading.Thread: + """在守护线程中启动选中的 backend,并返回该线程。""" + + name = normalize_backend_name(backend) + profile = BACKEND_PROFILES[name] + if is_slave and not profile.supports_slave: + raise BackendConfigurationError( + f"backend '{name}' 不支持 --is_slave;" + "请使用 backend 'hostlink' 或 'ros2'" + ) + entrypoint = _load_entrypoint(profile, is_slave) backend_thread = threading.Thread( - target=main if not is_slave else slave, + target=entrypoint, args=( devices_config, resources_config, - resources_edge_config, + resources_edge_config or [], graph, - controllers_config, - bridges, + controllers_config or {}, + bridges or [], visual, - resources_mesh_config, + resources_mesh_config or {}, ), - name="backend_thread", + name=f"backend-{name}", daemon=True, ) backend_thread.start() - logger.info(f"Backend {backend} started.") + logger.info("Backend %s(%s)已启动。", name, profile.display_name) + return backend_thread diff --git a/unilabos/app/main.py b/unilabos/app/main.py index 857c8894b..16c010232 100644 --- a/unilabos/app/main.py +++ b/unilabos/app/main.py @@ -36,9 +36,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 # noqa: E402 +from unilabos.utils.banner_print import print_status, print_unilab_banner # noqa: E402 +from unilabos.config.config import load_config, BasicConfig, HTTPConfig # noqa: E402 # Global restart flags (used by ws_client and web/server) _restart_requested: bool = False @@ -224,6 +224,8 @@ def convert_argv_dashes_to_underscores(args: argparse.ArgumentParser): def parse_args(): """解析命令行参数""" + from unilabos.app.backend import BACKEND_NAMES, backend_cli_value + parser = argparse.ArgumentParser(description="Start Uni-Lab Edge server.") subparsers = parser.add_subparsers(title="Valid subcommands", dest="command") @@ -251,18 +253,30 @@ def parse_args(): ) parser.add_argument( "--backend", - choices=["ros", "dora", "simple", "automancer"], - default="ros", - help="Choose the backend to run with: 'ros', 'dora', 'simple', or 'automancer'.", + type=backend_cli_value, + choices=BACKEND_NAMES, + default="ros2", + metavar="{basic,hostlink,ros2,dora}", + help=( + "Runtime backend: basic (in-process), hostlink (distributed, no " + "ROS), ros2 (default), or dora. " + "Legacy aliases 'simple' and 'ros' remain accepted." + ), ) parser.add_argument( "--app_bridges", - nargs="+", - default=["websocket", "fastapi"], - help="Bridges to connect to. Now support 'websocket' and 'fastapi'.", + nargs="*", + default=None, + help=( + "Application bridges. Defaults are backend-specific: ros2 enables " + "websocket and fastapi; basic/hostlink/dora enable none. Pass the flag with " + "no values to disable all bridges explicitly." + ), ) parser.add_argument( "--is_slave", + "--is-slave", + dest="is_slave", action="store_true", help="Run the backend as slave node (without host privileges).", ) @@ -303,7 +317,10 @@ def parse_args(): "--disable-hostlink", dest="disable_hostlink", action="store_true", - help="关闭 HostLink;ROS2 使用原有发现和注册流程。", + help=( + "关闭 HostLink;ROS2 使用原有发现和注册流程。" + "不能与 --backend hostlink 同时使用。" + ), ) parser.add_argument( "--hostlink_heartbeat_interval", @@ -377,10 +394,15 @@ def parse_args(): "--no-ros-assist", dest="no_ros_assist", action="store_true", - help="保留 HostLink 设备发现和心跳,但不应用 Host 下发的 ROS2 环境。", + help=( + "ROS2 backend:保留 HostLink 设备发现和心跳," + "但不应用 Host 下发的 ROS2 环境。" + ), ) parser.add_argument( "--slave_no_host", + "--slave-no-host", + dest="slave_no_host", action="store_true", help=( "允许 Slave 在 HostLink/Host ROS 服务离线时启动;" @@ -702,6 +724,23 @@ def main(): args = parser.parse_args() args_dict = vars(args) + from unilabos.app.backend import ( + BackendConfigurationError, + resolve_backend_selection, + ) + + try: + backend_selection = resolve_backend_selection( + args_dict["backend"], + args_dict.get("app_bridges"), + is_slave=args_dict.get("is_slave", False), + visual=args_dict.get("visual", "disable"), + ) + except BackendConfigurationError as exc: + parser.error(str(exc)) + args_dict["backend"] = backend_selection.name + args_dict["app_bridges"] = list(backend_selection.app_bridges) + # 处理 HTTP 客户端子命令(login, logout, whoami, config, lab, material, workflow) # 这些命令不需要加载完整的 UniLab-OS 环境,提前处理并退出 http_client_commands = ["login", "logout", "whoami", "config", "lab", "material", "workflow"] @@ -711,7 +750,6 @@ def main(): set_output_format, OutputFormat, print_error, - print_output, resolve_addr, ) from unilabos.app.cli.auth import cmd_login, cmd_logout, cmd_whoami @@ -859,7 +897,7 @@ def main(): config_path = candidate print_status(f"发现本地配置文件: {config_path}", "info") else: - print_status(f"未指定config路径,可通过 --config 传入 local_config.py 文件路径", "info") + print_status("未指定config路径,可通过 --config 传入 local_config.py 文件路径", "info") print_status(f"您是否为第一次使用?并将当前路径 {working_dir} 作为工作目录? (Y/n)", "info") if check_mode or input() != "n": os.makedirs(working_dir, exist_ok=True) @@ -939,11 +977,19 @@ def main(): workflow_upload = args_dict.get("command") in ("workflow_upload", "wf") - # HostLink is a ROS-only control channel in this slice. It exchanges the - # ROS domain/discovery policy and Slave device IDs; normal device actions, - # resources and backend APIs keep their existing transports. + # ROS2 backend 用 HostLink 辅助发现;hostlink backend 则在同一 TCP 长连接上 + # 直接同步设备描述/状态和执行设备动作,不导入 ROS。 is_slave = bool(args_dict.get("is_slave", False)) _apply_hostlink_cli(args_dict, is_slave=is_slave) + if args_dict["backend"] == "hostlink": + from unilabos.config.config import HostLinkConfig + + if not HostLinkConfig.enable: + parser.error("--backend hostlink 不能与 --disable-hostlink 同时使用") + if is_slave and not str(HostLinkConfig.host or "").strip(): + parser.error( + "--backend hostlink --is-slave 必须通过 --host-node-ip 指定 Host" + ) # 使用远程资源启动 if not workflow_upload and args_dict["use_remote_resource"]: @@ -973,7 +1019,11 @@ def main(): BasicConfig.extra_resource = args_dict.get("extra_resource", False) if BasicConfig.extra_resource: print_status("启用额外资源加载:将加载lab_开头的labware资源定义", "info") - BasicConfig.communication_protocol = "websocket" + BasicConfig.backend = args_dict["backend"] + BasicConfig.app_bridges = tuple(args_dict["app_bridges"]) + BasicConfig.communication_protocol = ( + "websocket" if "websocket" in BasicConfig.app_bridges else "" + ) machine_name = platform.node() machine_name = "".join([c if c.isalnum() or c == "_" else "_" for c in machine_name]) BasicConfig.machine_name = machine_name @@ -1072,7 +1122,6 @@ def main(): from unilabos.app.communication import get_communication_client from unilabos.app.backend import start_backend from unilabos.app.web import http_client - from unilabos.app.web import start_server from unilabos.app.register import register_devices_and_resources from unilabos.resources.resource_tracker import ResourceTreeSet, ResourceDict @@ -1187,8 +1236,8 @@ def main(): args_dict["bridges"].append(http_client) # 获取通信客户端(仅支持WebSocket) if BasicConfig.is_host_mode: - comm_client = get_communication_client() if "websocket" in args_dict["app_bridges"]: + comm_client = get_communication_client() args_dict["bridges"].append(comm_client) def _exit(signum, frame): @@ -1219,14 +1268,17 @@ def _exit(signum, frame): ) args_dict["resources_mesh_config"] = resource_visualization.resource_model start_backend(**args_dict) - server_thread = threading.Thread( - target=start_server, - kwargs=dict( - open_browser=not BasicConfig.disable_browser, - port=BasicConfig.port, - ), - ) - server_thread.start() + if "fastapi" in args_dict["app_bridges"]: + from unilabos.app.web import start_server + + server_thread = threading.Thread( + target=start_server, + kwargs=dict( + open_browser=not BasicConfig.disable_browser, + port=BasicConfig.port, + ), + ) + server_thread.start() asyncio.set_event_loop(asyncio.new_event_loop()) try: resource_visualization.start() @@ -1236,7 +1288,7 @@ def _exit(signum, frame): print_status( "建议解决方案:\n" "1. 激活Conda环境: conda activate unilab\n" - "2. 或使用 --backend simple 参数\n" + "2. 或使用 --backend basic 参数\n" "3. 或使用 --visual disable 参数禁用可视化", "info", ) @@ -1245,23 +1297,35 @@ def _exit(signum, frame): while True: time.sleep(1) else: - start_backend(**args_dict) - restart_requested = start_server( - open_browser=not BasicConfig.disable_browser, - port=BasicConfig.port, - ) + backend_thread = start_backend(**args_dict) + if "fastapi" in args_dict["app_bridges"]: + from unilabos.app.web import start_server + + restart_requested = start_server( + open_browser=not BasicConfig.disable_browser, + port=BasicConfig.port, + ) + else: + backend_thread.join() + restart_requested = False if restart_requested: print_status("[Main] Restart requested, cleaning up...", "info") cleanup_for_restart() return else: - start_backend(**args_dict) + backend_thread = start_backend(**args_dict) - # 启动服务器(默认支持WebSocket触发重启) - restart_requested = start_server( - open_browser=not BasicConfig.disable_browser, - port=BasicConfig.port, - ) + # 只有声明支持 FastAPI bridge 的 backend 才加载 ROS2 Web 状态面。 + if "fastapi" in args_dict["app_bridges"]: + from unilabos.app.web import start_server + + restart_requested = start_server( + open_browser=not BasicConfig.disable_browser, + port=BasicConfig.port, + ) + else: + backend_thread.join() + restart_requested = False if restart_requested: print_status("[Main] Restart requested, cleaning up...", "info") cleanup_for_restart() diff --git a/unilabos/app/web/__init__.py b/unilabos/app/web/__init__.py index 3fccdd7fa..8aa046997 100644 --- a/unilabos/app/web/__init__.py +++ b/unilabos/app/web/__init__.py @@ -1,18 +1,41 @@ -""" -Web UI 模块 +"""使用延迟导出的 Web UI 包。 -提供了UniLab系统的Web界面功能 +导入 :mod:`unilabos.app.web.client` 时不能连带加载 ROS2 专属状态页。该延迟门面保留 +原有 ``from unilabos.app.web import ...`` API,同时允许 basic 和 Dora 在不初始化 +ROS Web 模块的情况下使用 HTTP 数据访问。 """ -from unilabos.app.web.pages import setup_web_pages -from unilabos.app.web.server import setup_server, start_server -from unilabos.app.web.client import http_client -from unilabos.app.web.api import setup_api_routes +from __future__ import annotations + +from typing import Any __all__ = [ - "setup_web_pages", # 设置Web页面 - "setup_server", # 设置服务器 - "start_server", # 启动服务器 - "http_client", # HTTP客户端 - "setup_api_routes", # 设置API路由 + "setup_web_pages", + "setup_server", + "start_server", + "http_client", + "setup_api_routes", ] + + +def __getattr__(name: str) -> Any: + if name == "setup_web_pages": + from unilabos.app.web.pages import setup_web_pages + + value = setup_web_pages + elif name in {"setup_server", "start_server"}: + from unilabos.app.web.server import setup_server, start_server + + value = {"setup_server": setup_server, "start_server": start_server}[name] + elif name == "http_client": + from unilabos.app.web.client import http_client + + value = http_client + elif name == "setup_api_routes": + from unilabos.app.web.api import setup_api_routes + + value = setup_api_routes + else: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + globals()[name] = value + return value diff --git a/unilabos/basic/__init__.py b/unilabos/basic/__init__.py new file mode 100644 index 000000000..019e9963a --- /dev/null +++ b/unilabos/basic/__init__.py @@ -0,0 +1,9 @@ +"""Basic 进程内 backend。 + +该 backend 直接加载 Python 设备驱动,不提供分布式传输,用于本地开发和与中间件 +无关的驱动检查。 +""" + +from unilabos.basic.runtime import BasicDeviceNode, BasicRuntime + +__all__ = ["BasicDeviceNode", "BasicRuntime"] diff --git a/unilabos/basic/main_basic_run.py b/unilabos/basic/main_basic_run.py new file mode 100644 index 000000000..487b5f421 --- /dev/null +++ b/unilabos/basic/main_basic_run.py @@ -0,0 +1,123 @@ +"""无中间件 ``basic`` backend 的启动入口。""" + +from __future__ import annotations + +from typing import Any, Optional + +from unilabos.app.backend import resolve_driver_backends +from unilabos.basic.runtime import BasicDriverSpec, BasicRuntime +from unilabos.device_runtime.resource import LocalResourceService, ResourceStore +from unilabos.registry.init_enforce import merge_init_param_enforce +from unilabos.utils import logger +from unilabos.utils.import_manager import default_manager + + +_runtime: Optional[BasicRuntime] = None + + +def get_runtime() -> Optional[BasicRuntime]: + return _runtime + + +def build_runtime(devices_config: Any, backend_name: str = "basic") -> BasicRuntime: + from unilabos.registry.registry import lab_registry + + runtime = BasicRuntime(backend_name=backend_name) + if devices_config is None: + return runtime + + for node in devices_config.all_nodes: + resource = node.res_content + if getattr(resource, "type", None) != "device": + continue + device_id = str(resource.id) + registry_name = resource.klass + if not isinstance(registry_name, str): + raise ValueError(f"Basic 设备 {device_id!r} 的注册表 class 必须是字符串") + if not registry_name.strip(): + logger.debug("[Basic] 跳过没有驱动 class 的子节点:%s", device_id) + continue + try: + registry_entry = lab_registry.device_type_registry[registry_name] + except KeyError as exc: + raise ValueError( + f"Basic 设备 {device_id!r} 的 class {registry_name!r} 未注册" + ) from exc + categories = registry_entry.get("category") or [] + if isinstance(categories, str): + categories = [categories] + if "work_station" in categories: + logger.info("[Basic] 跳过工作站聚合节点:%s", device_id) + continue + class_config = registry_entry.get("class") or {} + supported_backends = resolve_driver_backends(class_config) + if backend_name not in supported_backends: + raise ValueError( + f"设备 {device_id!r} 不支持 backend '{backend_name}';" + f"该驱动支持:{', '.join(supported_backends)}" + ) + module_spec = class_config.get("module") + if not isinstance(module_spec, str) or ":" not in module_spec: + raise ValueError(f"Basic 设备 {device_id!r} 缺少有效的 module:Class 配置") + driver_class = default_manager.get_class(module_spec) + config = merge_init_param_enforce( + resource.config if isinstance(resource.config, dict) else {}, + registry_entry.get("init_param_enforce"), + ) + runtime.add_driver( + BasicDriverSpec( + device_id=device_id, + driver_class=driver_class, + config=config, + registry_name=registry_name, + display_name=str(registry_entry.get("displayname") or registry_name), + resource_uuid=str(resource.uuid or ""), + action_names=tuple( + (class_config.get("action_value_mappings") or {}).keys() + ), + action_value_mappings=dict( + class_config.get("action_value_mappings") or {} + ), + status_names=tuple((class_config.get("status_types") or {}).keys()), + device_config=node, + ) + ) + return runtime + + +# 保留内部旧名称,避免嵌入方在过渡期失效。 +_build_runtime = build_runtime + + +def main( + devices_config: Any, + resources_config: Any, + resources_edge_config: Optional[list[dict[str, Any]]] = None, + graph: Any = None, + controllers_config: Optional[dict[str, Any]] = None, + bridges: Optional[list[Any]] = None, + visual: str = "disable", + resources_mesh_config: Optional[dict[str, Any]] = None, + *args: Any, + **kwargs: Any, +) -> None: + """在进程内加载 Python 驱动,并保持运行直到进程退出。""" + + global _runtime + _runtime = build_runtime(devices_config) + _runtime.set_resource_service(LocalResourceService(ResourceStore(resources_config))) + _runtime.start() + logger.info( + "[Basic] 运行时已启动,共 %d 台设备:%s", + len(_runtime.devices), + sorted(_runtime.devices), + ) + try: + while not _runtime.wait(timeout=1.0): + pass + finally: + _runtime.stop() + + +def slave(*args: Any, **kwargs: Any) -> None: + raise RuntimeError("Basic backend 不支持 Slave 模式;请使用 ros2") diff --git a/unilabos/basic/runtime.py b/unilabos/basic/runtime.py new file mode 100644 index 000000000..95cb6b9d4 --- /dev/null +++ b/unilabos/basic/runtime.py @@ -0,0 +1,579 @@ +"""供 ``basic`` backend 使用的单进程运行时。""" + +from __future__ import annotations + +import asyncio +import inspect +import logging +import threading +from dataclasses import dataclass, field +from typing import Any, Awaitable, Callable, Coroutine, Dict, Iterable, Optional, Type + +from unilabos.device_runtime.action import ActionContext +from unilabos.device_runtime.driver_creator import ( + PyLabRobotCreator, + uses_pylabrobot_creator, +) +from unilabos.device_runtime.node import BackendCapabilityError, DeviceNode +from unilabos.device_runtime.resource import ResourceService +from unilabos.device_runtime.service import LocalServiceBus +from unilabos.device_runtime.topic import LocalTopicBus, message_to_value +from unilabos.resources.plr_additional_res_reg import register +from unilabos.resources.resource_tracker import DeviceNodeResourceTracker +from unilabos.utils.decorator import get_all_subscriptions + + +def instantiate_driver( + driver_class: Type[Any], + device_id: str, + config: Optional[Dict[str, Any]] = None, + *, + device_config: Any = None, + resource_tracker: Optional[DeviceNodeResourceTracker] = None, +) -> Any: + """使用 ``config`` 对象或展开参数实例化驱动。 + + 新驱动通常接收 ``device_id`` 和 ``config``,旧驱动则把配置项直接声明为构造参数。 + Basic 运行时不导入 ROS 设备包装器,同时兼容这两种形式。 + """ + + config = dict(config or {}) + if device_config is not None and uses_pylabrobot_creator(driver_class): + register() + creator = PyLabRobotCreator( + driver_class, + children=list(device_config.children), + resource_tracker=resource_tracker or DeviceNodeResourceTracker(), + ) + driver = creator.create_instance(config) + if driver is None: + raise RuntimeError(f"Basic 设备 {device_id!r} 的驱动实例创建失败") + return driver + signature = inspect.signature(driver_class.__init__) + parameters = { + name: parameter + for name, parameter in signature.parameters.items() + if name != "self" + } + accepts_kwargs = any( + parameter.kind is inspect.Parameter.VAR_KEYWORD + for parameter in parameters.values() + ) + + kwargs: Dict[str, Any] + if "config" in parameters: + kwargs = {"config": config} + else: + kwargs = dict(config) + + if "device_id" in parameters or accepts_kwargs: + kwargs.setdefault("device_id", device_id) + elif "id" in parameters: + kwargs.setdefault("id", device_id) + return driver_class(**kwargs) + + +class BasicDeviceNode(DeviceNode): + """向驱动提供异步辅助能力的轻量节点适配器。""" + + def __init__( + self, + driver: Any, + device_id: str, + *, + backend_name: str = "basic", + resource_uuid: str = "", + registry_name: str = "", + display_name: str = "", + action_names: Iterable[str] = (), + action_value_mappings: Optional[Dict[str, Any]] = None, + status_names: Iterable[str] = (), + resource_tracker: Optional[DeviceNodeResourceTracker] = None, + ) -> None: + self.driver = driver + self.device_id = device_id + self.backend_name = str(backend_name or "basic") + self.resource_uuid = str(resource_uuid or "") + self.registry_name = str(registry_name or "") + self.display_name = str(display_name or registry_name or device_id) + self.action_names = tuple( + sorted( + { + str(name).strip() + for name in action_names + if str(name).strip() and not str(name).startswith("_") + } + ) + ) + self.action_value_mappings = message_to_value(dict(action_value_mappings or {})) + self.status_names = tuple( + sorted({str(name).strip() for name in status_names if str(name).strip()}) + ) + self._logger = logging.getLogger(f"unilabos.basic.{device_id}") + self._loop = asyncio.new_event_loop() + self._loop_thread = threading.Thread( + target=self._run_loop, + name=f"basic-driver-{device_id}", + daemon=True, + ) + self._loop_ready = threading.Event() + self._started = False + self._action_lock: asyncio.Lock | None = None + self._status_lock = threading.Lock() + self._decorated_subscriptions: list[Any] = [] + self.resource_tracker = resource_tracker or DeviceNodeResourceTracker() + + _ROS2_RUNTIME_ATTRIBUTES = frozenset( + { + "create_guard_condition", + "create_action_server", + "create_action_client", + "get_publishers_info_by_topic", + "get_subscriptions_info_by_topic", + } + ) + + def _raise_backend_attribute_error(self, exc: AttributeError) -> None: + """把驱动对 ROS2 Node API 的直接调用转换成容易定位的错误。""" + + name = str(getattr(exc, "name", "") or "") + owner = getattr(exc, "obj", None) + if owner is self and name in self._ROS2_RUNTIME_ATTRIBUTES: + raise BackendCapabilityError( + f"设备 {self.device_id!r} 在 backend '{self.backend_name}' 中调用了 " + f"ROS2 Node 方法 {name!r};请改用通用 DeviceNode 接口,或把该设备的 " + "supported_backends 限制为 ros2" + ) from exc + raise exc + + def _run_loop(self) -> None: + asyncio.set_event_loop(self._loop) + self._action_lock = asyncio.Lock() + self._loop_ready.set() + self._loop.run_forever() + + async def sleep(self, rel_time: float, callback_group: Any = None) -> None: + await asyncio.sleep(rel_time) + + def lab_logger(self) -> logging.Logger: + return self._logger + + def create_task(self, coroutine: Coroutine[Any, Any, Any]): + return asyncio.run_coroutine_threadsafe(coroutine, self._loop) + + def _call( + self, + method: Callable[..., Any], + *args: Any, + _wait_timeout: Optional[float] = None, + **kwargs: Any, + ) -> Any: + result = method(*args, **kwargs) + if inspect.isawaitable(result): + + async def await_result(value: Awaitable[Any]) -> Any: + return await value + + return asyncio.run_coroutine_threadsafe( + await_result(result), self._loop + ).result(timeout=_wait_timeout) + return result + + def start(self) -> None: + if self._started: + return + self._loop_thread.start() + if not self._loop_ready.wait(timeout=5): + raise RuntimeError(f"Basic 设备 {self.device_id!r} 事件循环启动超时") + self._started = True + try: + if hasattr(self.driver, "post_init"): + self.driver.post_init(self) + self._setup_decorated_subscriptions() + setup = getattr(self.driver, "setup", None) + if callable(setup): + self._call(setup, _wait_timeout=30) + initialize = getattr(self.driver, "initialize", None) + if callable(initialize): + self._call(initialize, _wait_timeout=30) + except AttributeError as exc: + self.stop() + self._raise_backend_attribute_error(exc) + except Exception: + self.stop() + raise + self._logger.info("Basic 设备已就绪:%s", self.device_id) + + def _setup_decorated_subscriptions(self) -> None: + for _method_name, method, config in get_all_subscriptions(self.driver): + topic = config.get("topic") + target_device = config.get("device_id") + status_name = config.get("status_name") + if target_device or status_name: + if not target_device or not status_name: + raise ValueError("@subscribe 需要同时提供 device_id 和 status_name") + topic = f"/devices/{target_device}/{status_name}" + if not topic: + raise ValueError("@subscribe 缺少 topic") + self._decorated_subscriptions.append( + self.create_subscription( + config.get("msg_type"), + topic, + method, + config.get("qos", 10), + trigger_when_change=config.get("trigger_when_change", False), + ) + ) + + def _resolve_action( + self, + action_name: str, + *, + action_context: Optional[ActionContext] = None, + **kwargs: Any, + ) -> tuple[Callable[..., Any], ActionContext, Dict[str, Any]]: + if not self._started: + raise RuntimeError(f"Basic 设备 {self.device_id!r} 尚未启动") + action_name = str(action_name or "").strip() + if action_name.startswith("_") or ( + self.action_names and action_name not in self.action_names + ): + raise AttributeError( + f"Basic 设备 {self.device_id!r} 没有动作 {action_name!r}" + ) + method_name = action_name.removeprefix("auto-") + action = getattr(self.driver, method_name, None) + if not callable(action): + raise AttributeError( + f"Basic 设备 {self.device_id!r} 没有动作 {action_name!r}" + ) + context = action_context or ActionContext() + signature = inspect.signature(action) + if "action_context" in signature.parameters: + kwargs.setdefault("action_context", context) + return action, context, kwargs + + async def _execute_action( + self, + action: Callable[..., Any], + context: ActionContext, + kwargs: Dict[str, Any], + ) -> Any: + lock = self._action_lock + if lock is None: + raise RuntimeError(f"Basic 设备 {self.device_id!r} 事件循环尚未就绪") + try: + async with lock: + context.raise_if_cancelled() + if inspect.iscoroutinefunction(action): + result = await action(**kwargs) + else: + result = await asyncio.to_thread(action, **kwargs) + if inspect.isawaitable(result): + result = await result + context.raise_if_cancelled() + return result + except AttributeError as exc: + self._raise_backend_attribute_error(exc) + + def call_action( + self, + action_name: str, + *, + action_context: Optional[ActionContext] = None, + **kwargs: Any, + ) -> Any: + """Run an action synchronously while using the device event loop.""" + + action, context, call_kwargs = self._resolve_action( + action_name, + action_context=action_context, + **kwargs, + ) + future = asyncio.run_coroutine_threadsafe( + self._execute_action(action, context, call_kwargs), + self._loop, + ) + return future.result() + + async def call_action_async( + self, + action_name: str, + *, + action_context: Optional[ActionContext] = None, + **kwargs: Any, + ) -> Any: + """Await an action on this device's own Python event loop.""" + + action, context, call_kwargs = self._resolve_action( + action_name, + action_context=action_context, + **kwargs, + ) + try: + if asyncio.get_running_loop() is self._loop: + return await self._execute_action(action, context, call_kwargs) + future = asyncio.run_coroutine_threadsafe( + self._execute_action(action, context, call_kwargs), + self._loop, + ) + return await asyncio.wrap_future(future) + except asyncio.CancelledError: + context.request_cancel() + raise + + def snapshot_status(self) -> Dict[str, Any]: + """读取注册表声明的状态;单个状态失败不影响其他字段。""" + + result: Dict[str, Any] = {} + errors: Dict[str, str] = {} + with self._status_lock: + for name in self.status_names: + try: + getter = getattr(self.driver, f"get_{name}", None) + if callable(getter): + value = self._call(getter) + else: + value = getattr(self.driver, name) + if callable(value): + value = self._call(value) + result[name] = value + self.emit_status(name, value) + except Exception as exc: # noqa: BLE001 - 状态快照需部分成功 + errors[name] = str(exc) + if errors: + result["_errors"] = errors + return result + + def describe(self) -> Dict[str, Any]: + descriptor = { + "id": self.device_id, + "registry_name": self.registry_name, + "display_name": self.display_name, + "actions": list(self.action_names), + "status_fields": list(self.status_names), + } + if self.action_value_mappings: + descriptor["action_value_mappings"] = self.action_value_mappings + services = self.service_names() + if services: + descriptor["services"] = services + if self.resource_uuid: + descriptor["resource_uuid"] = self.resource_uuid + return descriptor + + def stop(self) -> None: + if not self._started: + return + for subscription in self._decorated_subscriptions: + subscription.destroy() + self._decorated_subscriptions.clear() + for timer in tuple(self.__dict__.get("_device_timers", [])): + self.destroy_timer(timer) + for service in tuple(self.__dict__.get("_device_services", [])): + self.destroy_service(service) + cleanup = getattr(self.driver, "cleanup", None) + if not callable(cleanup): + cleanup = getattr(self.driver, "stop", None) + if callable(cleanup): + try: + self._call(cleanup, _wait_timeout=10) + except Exception: + self._logger.exception("Basic 设备清理失败:%s", self.device_id) + self._loop.call_soon_threadsafe(self._loop.stop) + self._loop_thread.join(timeout=5) + self._loop.close() + self._started = False + + +@dataclass(frozen=True) +class BasicDriverSpec: + device_id: str + driver_class: Type[Any] + config: Dict[str, Any] + registry_name: str = "" + action_names: tuple[str, ...] = () + action_value_mappings: Dict[str, Any] = field(default_factory=dict) + status_names: tuple[str, ...] = () + display_name: str = "" + resource_uuid: str = "" + device_config: Any = None + + +class BasicRuntime: + """管理一个 Basic backend 进程内的全部驱动实例。""" + + def __init__(self, backend_name: str = "basic") -> None: + self.backend_name = str(backend_name or "basic") + self.devices: dict[str, BasicDeviceNode] = {} + self.topic_bus = LocalTopicBus() + self.service_bus = LocalServiceBus() + self._resource_service: ResourceService | None = None + self._stopped = threading.Event() + + def add_driver(self, spec: BasicDriverSpec) -> BasicDeviceNode: + if spec.device_id in self.devices: + raise ValueError(f"Basic 设备 ID 重复:{spec.device_id}") + resource_tracker = DeviceNodeResourceTracker() + driver = instantiate_driver( + spec.driver_class, + spec.device_id, + spec.config, + device_config=spec.device_config, + resource_tracker=resource_tracker, + ) + node = BasicDeviceNode( + driver, + spec.device_id, + backend_name=self.backend_name, + resource_uuid=spec.resource_uuid, + registry_name=spec.registry_name, + display_name=spec.display_name, + action_names=spec.action_names, + action_value_mappings=spec.action_value_mappings, + status_names=spec.status_names, + resource_tracker=resource_tracker, + ) + if self._resource_service is not None: + node.set_resource_service(self._resource_service) + node.set_action_router(self) + node.set_topic_bus(self.topic_bus) + node.set_service_bus(self.service_bus) + self.devices[spec.device_id] = node + return node + + def set_resource_service(self, service: ResourceService) -> None: + self._resource_service = service + for node in self.devices.values(): + node.set_resource_service(service) + + @staticmethod + def _normalize_device_id(device_id: str) -> str: + normalized = str(device_id or "").strip() + if normalized.startswith("/devices/"): + normalized = normalized[len("/devices/") :] + return normalized.lstrip("/") + + def route_action( + self, + caller_device_id: str, + device_id: str, + action_name: str, + arguments: Optional[Dict[str, Any]] = None, + **options: Any, + ) -> Any: + target = self._normalize_device_id(device_id) + if target == caller_device_id: + raise ValueError("跨设备动作不能回调当前设备自身") + context = options.get("action_context") + if context is None and ( + options.get("action_id") or options.get("feedback_callback") + ): + context = ActionContext( + action_id=str(options.get("action_id") or "") + or ActionContext().action_id, + feedback_callback=options.get("feedback_callback"), + ) + return self.call_action( + target, + action_name, + action_context=context, + **dict(arguments or {}), + ) + + async def route_action_async( + self, + caller_device_id: str, + device_id: str, + action_name: str, + arguments: Optional[Dict[str, Any]] = None, + **options: Any, + ) -> Any: + target = self._normalize_device_id(device_id) + if target == caller_device_id: + raise ValueError("跨设备动作不能回调当前设备自身") + context = options.get("action_context") + if context is None and ( + options.get("action_id") or options.get("feedback_callback") + ): + context = ActionContext( + action_id=str(options.get("action_id") or "") + or ActionContext().action_id, + feedback_callback=options.get("feedback_callback"), + ) + operation = self.call_action_async( + target, + action_name, + action_context=context, + **dict(arguments or {}), + ) + timeout = options.get("timeout") + if timeout is None: + return await operation + return await asyncio.wait_for(operation, float(timeout)) + + def start(self) -> None: + self._stopped.clear() + started: list[BasicDeviceNode] = [] + try: + for node in self.devices.values(): + node.start() + started.append(node) + except Exception: + for node in reversed(started): + node.stop() + raise + + def call_action( + self, + device_id: str, + action_name: str, + *, + action_context: Optional[ActionContext] = None, + **kwargs: Any, + ) -> Any: + try: + node = self.devices[device_id] + except KeyError as exc: + raise KeyError(f"未知 Basic 设备:{device_id}") from exc + return node.call_action( + action_name, + action_context=action_context, + **kwargs, + ) + + async def call_action_async( + self, + device_id: str, + action_name: str, + *, + action_context: Optional[ActionContext] = None, + **kwargs: Any, + ) -> Any: + try: + node = self.devices[device_id] + except KeyError as exc: + raise KeyError(f"未知 Basic 设备:{device_id}") from exc + return await node.call_action_async( + action_name, + action_context=action_context, + **kwargs, + ) + + def descriptors(self) -> list[Dict[str, Any]]: + return [node.describe() for node in self.devices.values()] + + def snapshot_states(self) -> Dict[str, Dict[str, Any]]: + return { + device_id: node.snapshot_status() + for device_id, node in self.devices.items() + } + + def wait(self, timeout: Optional[float] = None) -> bool: + return self._stopped.wait(timeout) + + def stop(self) -> None: + for node in reversed(tuple(self.devices.values())): + node.stop() + self.topic_bus.close() + self._stopped.set() diff --git a/unilabos/config/config.py b/unilabos/config/config.py index 56dc8545e..9b15eaeaf 100644 --- a/unilabos/config/config.py +++ b/unilabos/config/config.py @@ -2,11 +2,14 @@ import traceback import os import importlib.util -from typing import Optional, Literal +from typing import Literal from unilabos.utils import logger class BasicConfig: + # 运行时 backend 名称由 unilabos.app.backend 统一规范化。 + backend: Literal["basic", "hostlink", "ros2", "dora"] = "ros2" + app_bridges: tuple[str, ...] = ("websocket", "fastapi") ak = "" sk = "" working_dir = "" @@ -54,8 +57,8 @@ class HTTPConfig: schedule_addr = "" -# Host/Slave ROS2 组网控制通道。Host 在 ROS backend 启动时监听;Slave 只有在 -# ``host`` 非空(--host_node_ip)时连接,不接管物料、动作或微后端职责。 +# Host/Slave 控制通道。ROS2 backend 用它同步发现参数;hostlink backend 还会 +# 通过同一条长连接发布设备状态并执行远程动作。 class HostLinkConfig: enable = True host = "" # Slave 侧指定的 HostNode IP/主机名 @@ -133,11 +136,11 @@ def _update_config_from_env(): current_value = getattr(matched_cls, matched_field) attr_type = type(current_value) - if attr_type == bool: + if attr_type is bool: value = env_value.lower() in ("true", "1", "yes") - elif attr_type == int: + elif attr_type is int: value = int(env_value) - elif attr_type == float: + elif attr_type is float: value = float(env_value) else: value = env_value @@ -167,7 +170,7 @@ def load_config(config_path=None): _update_config_from_module(module) logger.info(f"[ENV] 配置文件 {config_path} 加载成功") _update_config_from_env() - except Exception as e: + except Exception: logger.error(f"[ENV] 加载配置文件 {config_path} 失败") traceback.print_exc() exit(1) diff --git a/unilabos/device_comms/rpc.py b/unilabos/device_comms/rpc.py index b818205b2..0db2be489 100644 --- a/unilabos/device_comms/rpc.py +++ b/unilabos/device_comms/rpc.py @@ -1,11 +1,12 @@ import json +import logging + import requests -from rclpy.logging import get_logger class BaseRequest: def __init__(self): - self._logger = get_logger(__name__) + self._logger = logging.getLogger(__name__) def get_logger(self): return self._logger diff --git a/unilabos/device_mesh/resource_visalization.py b/unilabos/device_mesh/resource_visalization.py index 62cda3551..d998285a6 100644 --- a/unilabos/device_mesh/resource_visalization.py +++ b/unilabos/device_mesh/resource_visalization.py @@ -219,7 +219,7 @@ def create_launch_description(self) -> LaunchDescription: "1. 已安装ROS 2 (推荐使用 ros-jazzy-desktop-full)\n" "2. 已激活Conda环境: conda activate unilab\n" "3. 或手动source ROS 2 setup文件: source /opt/ros/jazzy/setup.bash\n" - "4. 或者使用 --backend simple 参数跳过ROS依赖" + "4. 或者使用 --backend basic 参数跳过 ROS2 runtime" ) try: diff --git a/unilabos/device_runtime/__init__.py b/unilabos/device_runtime/__init__.py new file mode 100644 index 000000000..7d8e9a426 --- /dev/null +++ b/unilabos/device_runtime/__init__.py @@ -0,0 +1,69 @@ +"""Backend-neutral contracts shared by device drivers and runtime adapters.""" + +from unilabos.device_runtime.async_utils import schedule_async_func +from unilabos.device_runtime.action import ( + ActionCancelled, + ActionContext, + DeviceActionRouter, +) +from unilabos.device_runtime.node import ( + BackendCapabilityError, + DeviceNode, + StatusListener, +) +from unilabos.device_runtime.resource import ( + LocalResourceService, + ResourceService, + ResourceStore, +) +from unilabos.device_runtime.primitives import ( + DeviceClock, + DeviceParameter, + DeviceParameterValue, + DeviceRate, + DeviceTime, + DeviceTimer, + SetParametersResult, +) +from unilabos.device_runtime.service import ( + DeviceService, + DeviceServiceClient, + LocalServiceBus, + ServiceBus, +) +from unilabos.device_runtime.topic import ( + LocalTopicBus, + TopicBus, + TopicEvent, + TopicPublisher, + TopicSubscription, +) + +__all__ = [ + "ActionCancelled", + "ActionContext", + "BackendCapabilityError", + "DeviceNode", + "DeviceClock", + "DeviceParameter", + "DeviceParameterValue", + "DeviceRate", + "DeviceService", + "DeviceServiceClient", + "DeviceTime", + "DeviceTimer", + "DeviceActionRouter", + "LocalResourceService", + "LocalServiceBus", + "LocalTopicBus", + "ResourceService", + "ResourceStore", + "schedule_async_func", + "ServiceBus", + "SetParametersResult", + "StatusListener", + "TopicBus", + "TopicEvent", + "TopicPublisher", + "TopicSubscription", +] diff --git a/unilabos/device_runtime/action.py b/unilabos/device_runtime/action.py new file mode 100644 index 000000000..0d41054e7 --- /dev/null +++ b/unilabos/device_runtime/action.py @@ -0,0 +1,72 @@ +"""Backend-neutral action execution state.""" + +from __future__ import annotations + +import threading +import uuid +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, Optional, Protocol + +FeedbackCallback = Callable[[str, Dict[str, Any]], None] + + +class DeviceActionRouter(Protocol): + """Route a driver's cross-device call through the active backend.""" + + def route_action( + self, + caller_device_id: str, + device_id: str, + action_name: str, + arguments: Optional[Dict[str, Any]] = None, + **options: Any, + ) -> Any: ... + + async def route_action_async( + self, + caller_device_id: str, + device_id: str, + action_name: str, + arguments: Optional[Dict[str, Any]] = None, + **options: Any, + ) -> Any: ... + + +class ActionCancelled(RuntimeError): + """Raised when an action notices that cancellation was requested.""" + + +@dataclass +class ActionContext: + """Identify one action and carry feedback/cancellation across backends.""" + + action_id: str = field(default_factory=lambda: uuid.uuid4().hex) + feedback_callback: Optional[FeedbackCallback] = None + _cancelled: threading.Event = field( + default_factory=threading.Event, + init=False, + repr=False, + ) + + def publish_feedback(self, data: Optional[Dict[str, Any]] = None) -> None: + if self.feedback_callback is not None: + self.feedback_callback(self.action_id, dict(data or {})) + + def request_cancel(self) -> None: + self._cancelled.set() + + @property + def is_cancelled(self) -> bool: + return self._cancelled.is_set() + + def raise_if_cancelled(self) -> None: + if self.is_cancelled: + raise ActionCancelled(f"action cancelled: {self.action_id}") + + +__all__ = [ + "ActionCancelled", + "ActionContext", + "DeviceActionRouter", + "FeedbackCallback", +] diff --git a/unilabos/device_runtime/async_utils.py b/unilabos/device_runtime/async_utils.py new file mode 100644 index 000000000..bebfecfb8 --- /dev/null +++ b/unilabos/device_runtime/async_utils.py @@ -0,0 +1,74 @@ +"""Backend 无关的异步函数调度辅助。""" + +from __future__ import annotations + +import asyncio +from concurrent.futures import CancelledError as FutureCancelledError +import inspect +import traceback +from typing import Any, Awaitable, Callable, Optional + + +TaskScheduler = Callable[[Awaitable[Any]], Any] +TraceCallback = Callable[[Any], None] +ErrorCallback = Callable[[str], None] + + +def schedule_async_func( + scheduler: TaskScheduler, + func: Any, + trace_error: bool = True, + inner_trace_callback: Optional[TraceCallback] = None, + error_callback: Optional[ErrorCallback] = None, + **kwargs: Any, +) -> Any: + """用 backend 提供的 scheduler 执行函数或 awaitable,并返回其 Future。""" + + if not callable(func) and kwargs: + raise TypeError("awaitable 对象不能再接收额外关键字参数") + + task_name = str( + getattr(func, "__qualname__", "") + or getattr(func, "__name__", "") + or type(func).__name__ + ) + + async def invoke() -> Any: + try: + result = func(**kwargs) if callable(func) else func + if inspect.isawaitable(result): + result = await result + except BaseException as exc: + if inner_trace_callback is not None: + inner_trace_callback(exc) + raise + if inner_trace_callback is not None: + inner_trace_callback(result) + return result + + coroutine = invoke() + try: + future = scheduler(coroutine) + except BaseException: + coroutine.close() + raise + + if trace_error: + def report_error(done_future: Any) -> None: + try: + done_future.result() + except (asyncio.CancelledError, FutureCancelledError): + return + except BaseException as exc: + if error_callback is not None: + detail = "".join( + traceback.format_exception(type(exc), exc, exc.__traceback__) + ) + error_callback(f"异步任务 {task_name} 执行失败\n{detail}") + + future.add_done_callback(report_error) + + return future + + +__all__ = ["schedule_async_func"] diff --git a/unilabos/device_runtime/driver_creator.py b/unilabos/device_runtime/driver_creator.py new file mode 100644 index 000000000..52ce88dc5 --- /dev/null +++ b/unilabos/device_runtime/driver_creator.py @@ -0,0 +1,363 @@ +"""Backend-neutral device instance construction helpers. + +These creators understand Uni-Lab resource children and PyLabRobot resource +references. Backend adapters may optionally provide a task scheduler for +drivers whose ``setup`` method is asynchronous. +""" + +from __future__ import annotations + +import asyncio +import inspect +import traceback +from abc import abstractmethod +from typing import Any, Callable, Dict, Generic, List, Optional, Type, TypeVar + +from unilabos.device_runtime.async_utils import schedule_async_func +from unilabos.resources.resource_tracker import ( + DeviceNodeResourceTracker, + ResourceDictInstance, + ResourceTreeInstance, + ResourceTreeSet, +) +from unilabos.utils import logger +from unilabos.utils.cls_creator import create_instance_from_config + +T = TypeVar("T") +TaskScheduler = Callable[[Any], Any] + + +class ClassCreator(Generic[T]): + @abstractmethod + def create_instance(self, *args: Any, **kwargs: Any) -> T: + raise NotImplementedError + + +class DeviceClassCreator(Generic[T]): + """Create a Python driver and attach its non-device child resources.""" + + def __init__( + self, + cls: Type[T], + children: List[ResourceDictInstance], + resource_tracker: DeviceNodeResourceTracker, + ) -> None: + self.device_cls = cls + self.device_instance: Optional[T] = None + self.children = list(children) + self.resource_tracker = resource_tracker + + def attach_resource(self) -> None: + if self.device_instance is None: + return + for child in self.children: + if child.res_content.type != "device": + resource = ResourceTreeSet( + [ResourceTreeInstance(child)] + ).to_plr_resources()[0] + self.resource_tracker.add_resource(resource) + + def create_instance(self, data: Dict[str, Any]) -> T: + self.device_instance = create_instance_from_config( + { + "_cls": f"{self.device_cls.__module__}:{self.device_cls.__name__}", + "_params": dict(data), + } + ) + self.post_create() + self.attach_resource() + return self.device_instance + + def get_instance(self) -> Optional[T]: + return self.device_instance + + def post_create(self) -> None: + pass + + +class PyLabRobotCreator(DeviceClassCreator[T]): + """Create PyLabRobot-style drivers and resolve graph child references.""" + + def __init__( + self, + cls: Type[T], + children: List[ResourceDictInstance], + resource_tracker: DeviceNodeResourceTracker, + *, + task_scheduler: Optional[TaskScheduler] = None, + ) -> None: + super().__init__(cls, children, resource_tracker) + self.task_scheduler = task_scheduler + self.has_deserialize = hasattr(cls, "deserialize") and callable( + getattr(cls, "deserialize") + ) + if not self.has_deserialize: + logger.warning( + "类 %s 没有 deserialize 方法,将使用标准构造函数", cls.__name__ + ) + + def attach_resource(self) -> None: + # PyLabRobot resources are attached while references are resolved. + pass + + def _process_resource_references( + self, + data: Any, + processed_child_names: Dict[str, Any], + *, + to_dict: bool = False, + states: Optional[Dict[str, Any]] = None, + prefix_path: str = "", + name_to_uuid: Optional[Dict[str, str]] = None, + ) -> Any: + from pylabrobot.resources import Resource + + if states is None: + states = {} + if isinstance(data, dict): + if "_resource_child_name" in data: + child_name = str(data["_resource_child_name"]) + resource = next( + ( + child + for child in self.children + if child.res_content.name == child_name + ), + None, + ) + if resource is None: + logger.warning("找不到资源引用 %r,保持原值不变", child_name) + return data + if "_resource_type" not in data: + logger.debug( + "找不到资源类型,请补全 _resource_type %s %s", + self.device_cls.__name__, + data.keys(), + ) + return resource + try: + resource_instance: Resource = ResourceTreeSet( + [ResourceTreeInstance(resource)] + ).to_plr_resources()[0] + states[prefix_path] = resource_instance.serialize_all_state() + if to_dict: + return resource_instance.serialize() + processed_child_names[child_name] = resource_instance + self.resource_tracker.add_resource(resource_instance) + if name_to_uuid: + self.resource_tracker.loop_set_uuid( + resource_instance, + name_to_uuid, + ) + return resource_instance + except Exception as exc: # noqa: BLE001 - report the resource path + logger.warning( + "无法加载资源类型 %s: %s", + data.get("_resource_type"), + exc, + ) + logger.warning(traceback.format_exc()) + return resource + return { + key: self._process_resource_references( + value, + processed_child_names, + to_dict=to_dict, + states=states, + prefix_path=f"{prefix_path}.{key}" if prefix_path else key, + name_to_uuid=name_to_uuid, + ) + for key, value in data.items() + } + if isinstance(data, list): + return [ + self._process_resource_references( + item, + processed_child_names, + to_dict=to_dict, + states=states, + prefix_path=f"{prefix_path}[{index}]", + name_to_uuid=name_to_uuid, + ) + for index, item in enumerate(data) + ] + return data + + def _complete_resource_types(self, data: Dict[str, Any], callable_obj: Any) -> None: + parameters = inspect.signature(callable_obj).parameters + for name, value in data.items(): + if not ( + isinstance(value, dict) + and "_resource_child_name" in value + and "_resource_type" not in value + ): + continue + parameter = parameters.get(name) + annotation = getattr(parameter, "annotation", inspect.Parameter.empty) + if annotation is inspect.Parameter.empty: + continue + annotation_name = ( + annotation + if isinstance(annotation, str) + else getattr(annotation, "__name__", str(annotation)) + ) + value["_resource_type"] = f"{self.device_cls.__module__}:{annotation_name}" + logger.debug("自动补充 _resource_type: %s", value["_resource_type"]) + + def create_instance(self, data: Dict[str, Any]) -> Optional[T]: + data = dict(data) + deserialize_error: Optional[BaseException] = None + deserialize_stack = "" + + def collect_name_to_uuid( + children: List[ResourceDictInstance], + result: Dict[str, str], + ) -> None: + for child in children: + result[child.res_content.name] = child.res_content.uuid + collect_name_to_uuid(child.children, result) + + name_to_uuid: Dict[str, str] = {} + collect_name_to_uuid(self.children, name_to_uuid) + + if self.has_deserialize: + deserialize = getattr(self.device_cls, "deserialize") + self._complete_resource_types(data, deserialize) + states: Dict[str, Any] = {} + processed_data = self._process_resource_references( + data, + {}, + to_dict=True, + states=states, + name_to_uuid=name_to_uuid, + ) + try: + self.device_instance = deserialize(**processed_data) + self.resource_tracker.loop_set_uuid( + self.device_instance, + name_to_uuid, + ) + all_states = self.device_instance.serialize_all_state() + for state in states.values(): + for key, value in all_states.items(): + state.setdefault(key, value) + self.device_instance.load_all_state(state) + self.resource_tracker.add_resource(self.device_instance) + self.post_create() + return self.device_instance + except Exception as exc: # noqa: BLE001 - fallback to constructor + deserialize_error = exc + deserialize_stack = traceback.format_exc() + + try: + self._complete_resource_types(data, self.device_cls.__init__) + processed_children: Dict[str, Any] = {} + processed_data = self._process_resource_references( + data, + processed_children, + name_to_uuid=name_to_uuid, + ) + used_children = set(processed_children) + self.children = [ + child + for child in self.children + if child.res_content.name not in used_children + ] + return super().create_instance(processed_data) + except Exception as exc: # noqa: BLE001 - include both creation attempts + logger.error("PyLabRobot 创建实例失败: %s", exc) + logger.error("PyLabRobot 创建实例堆栈: %s", traceback.format_exc()) + if deserialize_error is not None: + logger.error("PyLabRobot 反序列化失败: %s", deserialize_error) + logger.error("PyLabRobot 反序列化堆栈: %s", deserialize_stack) + return None + + def post_create(self) -> None: + setup = getattr(self.device_instance, "setup", None) + if self.task_scheduler is None or not asyncio.iscoroutinefunction(setup): + return + + future = schedule_async_func( + self.task_scheduler, + setup, + error_callback=logger.error, + ) + + def setup_done(done_future: Any) -> None: + try: + done_future.result() + except BaseException: + return + from pylabrobot.resources import set_volume_tracking + + set_volume_tracking(enabled=True) + logger.debug("PyLabRobot 设备实例 %s 设置完成", self.device_instance) + from unilabos.config.config import BasicConfig + + if not BasicConfig.vis_2d_enable: + return + from pylabrobot.visualizer.visualizer import Visualizer + + visualizer = Visualizer(resource=self.device_instance, open_browser=True) + schedule_async_func( + self.task_scheduler, + visualizer.setup, + error_callback=logger.error, + ) + + future.add_done_callback(setup_done) + + +class WorkstationNodeCreator(DeviceClassCreator[T]): + """Create a workstation driver and its optional PyLabRobot deck.""" + + def __init__( + self, + cls: Type[T], + children: List[ResourceDictInstance], + resource_tracker: DeviceNodeResourceTracker, + *, + task_scheduler: Optional[TaskScheduler] = None, + ) -> None: + super().__init__(cls, children, resource_tracker) + self.task_scheduler = task_scheduler + + def create_instance(self, data: Dict[str, Any]) -> T: + params = dict(data) + params["children"] = self.children + deck_data = params.get("deck") + if deck_data: + from pylabrobot.resources import Deck + + params["deck"] = PyLabRobotCreator( + Deck, + self.children, + self.resource_tracker, + task_scheduler=self.task_scheduler, + ).create_instance(deck_data) + else: + params["deck"] = None + return super().create_instance(params) + + +def uses_pylabrobot_creator(driver_class: Type[Any]) -> bool: + """Return whether a driver needs graph child/resource resolution.""" + + return driver_class.__module__.startswith( + "pylabrobot" + ) or driver_class.__name__ in { + "LiquidHandlerAbstract", + "LiquidHandlerBiomek", + "PRCXI9300Handler", + "TransformXYZHandler", + "OpcUaClient", + } + + +__all__ = [ + "ClassCreator", + "DeviceClassCreator", + "PyLabRobotCreator", + "WorkstationNodeCreator", + "uses_pylabrobot_creator", +] diff --git a/unilabos/device_runtime/node.py b/unilabos/device_runtime/node.py new file mode 100644 index 000000000..a932a3373 --- /dev/null +++ b/unilabos/device_runtime/node.py @@ -0,0 +1,535 @@ +"""The runtime interface exposed to backend-independent device drivers.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +import inspect +from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Iterable, Optional + +from unilabos.device_runtime.async_utils import schedule_async_func +from unilabos.device_runtime.primitives import ( + DeviceClock, + DeviceParameter, + DeviceRate, + DeviceTimer, + SetParametersResult, +) + +if TYPE_CHECKING: + from unilabos.device_runtime.action import DeviceActionRouter + from unilabos.device_runtime.resource import ResourceService + from unilabos.device_runtime.service import ServiceBus + from unilabos.device_runtime.topic import ( + TopicBus, + TopicPublisher, + TopicSubscription, + ) + +StatusListener = Callable[[str, str, Any], None] + + +class BackendCapabilityError(RuntimeError): + """The selected backend does not implement a requested device operation.""" + + +class DeviceNode(ABC): + """Small backend-neutral API passed to ``driver.post_init``. + + Device actions and JSON-compatible topics are available on every backend. + ROS2 keeps using native DDS implementations through ``rclpy.node.Node``; + Basic and HostLink use the topic bus configured by their runtime. + """ + + backend_name = "unknown" + device_id: str + resource_uuid = "" + + @property + def identifier(self) -> str: + return self.device_id + + def get_name(self) -> str: + return self.device_id.strip("/").split("/")[-1] + + def get_namespace(self) -> str: + return f"/devices/{self.device_id.strip('/')}" + + def get_fully_qualified_name(self) -> str: + return f"{self.get_namespace()}/{self.get_name()}" + + def get_logger(self) -> Any: + return self.lab_logger() + + @abstractmethod + def lab_logger(self) -> Any: + """Return the logger associated with this device.""" + + @abstractmethod + async def sleep(self, rel_time: float, callback_group: Any = None) -> None: + """Sleep without blocking the backend executor.""" + + @abstractmethod + def create_task(self, coroutine: Awaitable[Any]) -> Any: + """Schedule an awaitable on the backend executor.""" + + def run_async_func( + self, + func: Any, + trace_error: bool = True, + inner_trace_callback: Optional[Callable[[Any], None]] = None, + **kwargs: Any, + ) -> Any: + """在当前 backend 的执行器上运行异步函数,并返回对应 Future。""" + + return schedule_async_func( + self.create_task, + func, + trace_error=trace_error, + inner_trace_callback=inner_trace_callback, + error_callback=self.lab_logger().error, + **kwargs, + ) + + def get_clock(self) -> DeviceClock: + clock = self.__dict__.get("_device_clock") + if clock is None: + clock = DeviceClock() + self.__dict__["_device_clock"] = clock + return clock + + def create_timer( + self, + timer_period_sec: float, + callback: Callable[[], Any], + callback_group: Any = None, + clock: Any = None, + autostart: bool = True, + ) -> DeviceTimer: + del callback_group, clock + timer = DeviceTimer( + self, + timer_period_sec, + callback, + autostart=autostart, + ) + self.__dict__.setdefault("_device_timers", []).append(timer) + return timer + + def destroy_timer(self, timer: DeviceTimer) -> bool: + timers = self.__dict__.setdefault("_device_timers", []) + timer.cancel() + if timer in timers: + timers.remove(timer) + return True + return False + + def create_rate(self, frequency: float, clock: Any = None) -> DeviceRate: + del clock + return DeviceRate(frequency) + + def declare_parameter( + self, + name: str, + value: Any = None, + descriptor: Any = None, + ignore_override: bool = False, + ) -> DeviceParameter: + del descriptor, ignore_override + parameters = self.__dict__.setdefault("_device_parameters", {}) + key = str(name) + if key in parameters: + raise ValueError(f"parameter 已声明:{key}") + parameters[key] = value + return DeviceParameter(key, value) + + def declare_parameters( + self, + namespace: str, + parameters: Iterable[Any], + ignore_override: bool = False, + ) -> list[DeviceParameter]: + prefix = str(namespace or "").strip(".") + declared = [] + for item in parameters: + if isinstance(item, DeviceParameter): + name, value = item.name, item.value + elif isinstance(item, (tuple, list)) and len(item) >= 2: + name, value = item[0], item[1] + else: + name, value = str(item), None + full_name = f"{prefix}.{name}" if prefix else str(name) + declared.append( + self.declare_parameter( + full_name, + value, + ignore_override=ignore_override, + ) + ) + return declared + + def has_parameter(self, name: str) -> bool: + return str(name) in self.__dict__.setdefault("_device_parameters", {}) + + def get_parameter(self, name: str) -> DeviceParameter: + key = str(name) + parameters = self.__dict__.setdefault("_device_parameters", {}) + if key not in parameters: + return DeviceParameter(key, None) + return DeviceParameter(key, parameters[key]) + + def get_parameters(self, names: Iterable[str]) -> list[DeviceParameter]: + return [self.get_parameter(name) for name in names] + + def get_parameter_or( + self, + name: str, + alternative_value: DeviceParameter, + ) -> DeviceParameter: + return ( + self.get_parameter(name) if self.has_parameter(name) else alternative_value + ) + + def set_parameters(self, parameters: Iterable[Any]) -> list[SetParametersResult]: + normalized = [ + item + if isinstance(item, DeviceParameter) + else DeviceParameter( + str(getattr(item, "name", "")), getattr(item, "value", None) + ) + for item in parameters + ] + callbacks = tuple(self.__dict__.setdefault("_device_parameter_callbacks", [])) + for callback in callbacks: + result = callback(normalized) + if getattr(result, "successful", True) is False: + reason = str(getattr(result, "reason", "parameter 被回调拒绝")) + return [SetParametersResult(False, reason) for _ in normalized] + storage = self.__dict__.setdefault("_device_parameters", {}) + for parameter in normalized: + storage[parameter.name] = parameter.value + return [SetParametersResult() for _ in normalized] + + def set_parameters_atomically( + self, parameters: Iterable[Any] + ) -> SetParametersResult: + results = self.set_parameters(parameters) + return next( + (result for result in results if not result.successful), + SetParametersResult(), + ) + + def undeclare_parameter(self, name: str) -> None: + self.__dict__.setdefault("_device_parameters", {}).pop(str(name), None) + + def add_on_set_parameters_callback(self, callback: Callable[[Any], Any]) -> Any: + callbacks = self.__dict__.setdefault("_device_parameter_callbacks", []) + callbacks.append(callback) + return callback + + def remove_on_set_parameters_callback(self, callback: Callable[[Any], Any]) -> None: + callbacks = self.__dict__.setdefault("_device_parameter_callbacks", []) + if callback in callbacks: + callbacks.remove(callback) + + async def update_resource(self, resources: Any) -> Any: + service = self.__dict__.get("_device_resource_service") + if service is None: + raise BackendCapabilityError( + f"backend '{self.backend_name}' 尚未实现设备物料更新" + ) + return await service.update_resources( + self.device_id, + self.resource_uuid, + resources, + ) + + async def get_resource( + self, + resources_uuid: list[str], + with_children: bool = True, + ) -> Any: + service = self.__dict__.get("_device_resource_service") + if service is None: + raise BackendCapabilityError( + f"backend '{self.backend_name}' 尚未实现设备物料查询" + ) + return await service.get_resources( + self.device_id, + resources_uuid, + with_children, + ) + + def set_resource_service(self, service: "ResourceService") -> None: + self.__dict__["_device_resource_service"] = service + + def set_service_bus(self, bus: "ServiceBus") -> None: + self.__dict__["_device_service_bus"] = bus + + def create_service( + self, + srv_type: Any, + srv_name: str, + callback: Callable[..., Any], + *, + qos_profile: Any = None, + callback_group: Any = None, + ) -> Any: + del qos_profile, callback_group + from unilabos.device_runtime.service import ( + DeviceService, + build_service_callback, + normalize_service_name, + ) + + bus = self.__dict__.get("_device_service_bus") + if bus is None: + raise BackendCapabilityError( + f"backend '{self.backend_name}' 尚未实现 service" + ) + name = normalize_service_name(srv_name, self.device_id) + bus.register_service( + name, + build_service_callback(self, srv_type, callback), + owner_device_id=self.device_id, + ) + service = DeviceService(bus, name, srv_type, self.device_id) + self.__dict__.setdefault("_device_services", []).append(service) + return service + + def destroy_service(self, service: Any) -> bool: + services = self.__dict__.setdefault("_device_services", []) + service.destroy() + if service in services: + services.remove(service) + return True + return False + + def create_client( + self, + srv_type: Any, + srv_name: str, + *, + qos_profile: Any = None, + callback_group: Any = None, + ) -> Any: + del qos_profile, callback_group + from unilabos.device_runtime.service import ( + DeviceServiceClient, + normalize_service_name, + ) + + bus = self.__dict__.get("_device_service_bus") + if bus is None: + raise BackendCapabilityError( + f"backend '{self.backend_name}' 尚未实现 service client" + ) + client = DeviceServiceClient( + bus, + normalize_service_name(srv_name, self.device_id), + srv_type, + self.device_id, + ) + self.__dict__.setdefault("_device_service_clients", []).append(client) + return client + + def destroy_client(self, client: Any) -> bool: + clients = self.__dict__.setdefault("_device_service_clients", []) + if client in clients: + clients.remove(client) + return True + return False + + def service_names(self) -> list[str]: + return sorted( + str(service.service_name) + for service in self.__dict__.setdefault("_device_services", []) + ) + + def call_device_action( + self, + device_id: str, + action_name: str, + arguments: Optional[Dict[str, Any]] = None, + **options: Any, + ) -> Any: + router = self.__dict__.get("_device_action_router") + if router is None: + raise BackendCapabilityError( + f"backend '{self.backend_name}' 尚未实现跨设备动作调用" + ) + return router.route_action( + self.device_id, + device_id, + action_name, + arguments, + **options, + ) + + async def call_device_action_async( + self, + device_id: str, + action_name: str, + arguments: Optional[Dict[str, Any]] = None, + **options: Any, + ) -> Any: + router = self.__dict__.get("_device_action_router") + if router is None: + raise BackendCapabilityError( + f"backend '{self.backend_name}' 尚未实现跨设备动作调用" + ) + return await router.route_action_async( + self.device_id, + device_id, + action_name, + arguments, + **options, + ) + + def set_action_router(self, router: "DeviceActionRouter") -> None: + self.__dict__["_device_action_router"] = router + + def set_topic_bus(self, bus: "TopicBus") -> None: + self.__dict__["_device_topic_bus"] = bus + + def resolve_topic_name(self, topic: str) -> str: + from unilabos.device_runtime.topic import normalize_topic + + return normalize_topic(topic, self.device_id) + + def create_publisher( + self, + msg_type: Any, + topic: str, + qos_profile: Any = 10, + **kwargs: Any, + ) -> "TopicPublisher": + """Create a Basic/HostLink publisher with the familiar ROS call shape.""" + + del qos_profile + from unilabos.device_runtime.topic import TopicPublisher + + bus = self.__dict__.get("_device_topic_bus") + if bus is None: + raise BackendCapabilityError( + f"backend '{self.backend_name}' 尚未实现消息发布" + ) + return TopicPublisher( + bus, + self.resolve_topic_name(topic), + self.device_id, + msg_type, + retain=bool(kwargs.get("retain", False)), + ) + + def create_subscription( + self, + msg_type: Any, + topic: str, + callback: Callable[[Any], Any], + qos_profile: Any = 10, + **kwargs: Any, + ) -> "TopicSubscription": + """Create a Basic/HostLink subscription and pass decoded Python data.""" + + del msg_type, qos_profile + bus = self.__dict__.get("_device_topic_bus") + if bus is None: + raise BackendCapabilityError( + f"backend '{self.backend_name}' 尚未实现消息订阅" + ) + + def invoke(value: Any) -> Any: + async def run_callback() -> Any: + result = callback(value) + if inspect.isawaitable(result): + return await result + return result + + return self.create_task(run_callback()) + + return bus.subscribe( + self.resolve_topic_name(topic), + invoke, + trigger_when_change=bool(kwargs.get("trigger_when_change", False)), + replay_retained=bool(kwargs.get("replay_retained", True)), + ) + + def destroy_subscription(self, subscription: "TopicSubscription") -> bool: + subscription.destroy() + return True + + def destroy_publisher(self, publisher: "TopicPublisher") -> bool: + del publisher + return True + + def publish_topic( + self, + topic: str, + value: Any, + *, + message_type: Any = None, + retain: bool = False, + ) -> None: + publisher = self.create_publisher( + message_type or type(value), + topic, + retain=retain, + ) + publisher.publish(value) + + def subscribe_topic( + self, + topic: str, + callback: Callable[[Any], Any], + *, + message_type: Any = None, + trigger_when_change: bool = False, + replay_retained: bool = True, + ) -> "TopicSubscription": + return self.create_subscription( + message_type, + topic, + callback, + trigger_when_change=trigger_when_change, + replay_retained=replay_retained, + ) + + async def transfer_resource_to_another( + self, + plr_resources: list[Any], + target_device_id: str, + target_resources: list[Any], + sites: list[Optional[str]], + ) -> Any: + del plr_resources, target_device_id, target_resources, sites + raise BackendCapabilityError( + f"backend '{self.backend_name}' 尚未实现跨设备物料转移" + ) + + def add_status_listener(self, listener: StatusListener) -> None: + listeners = self.__dict__.setdefault("_device_status_listeners", []) + if listener not in listeners: + listeners.append(listener) + + def remove_status_listener(self, listener: StatusListener) -> None: + listeners = self.__dict__.setdefault("_device_status_listeners", []) + if listener in listeners: + listeners.remove(listener) + + def emit_status(self, name: str, value: Any) -> None: + cache = self.__dict__.setdefault("_device_status_cache", {}) + cache[str(name)] = value + for listener in tuple(self.__dict__.setdefault("_device_status_listeners", [])): + listener(self.device_id, str(name), value) + if self.__dict__.get("_device_topic_bus") is not None: + self.publish_topic(str(name), value, retain=True) + + def latest_status(self) -> Dict[str, Any]: + return dict(self.__dict__.setdefault("_device_status_cache", {})) + + +__all__ = [ + "BackendCapabilityError", + "DeviceNode", + "StatusListener", +] diff --git a/unilabos/device_runtime/primitives.py b/unilabos/device_runtime/primitives.py new file mode 100644 index 000000000..12d0d3df2 --- /dev/null +++ b/unilabos/device_runtime/primitives.py @@ -0,0 +1,218 @@ +"""Small ROS-shaped runtime primitives implemented with the Python stdlib.""" + +from __future__ import annotations + +import asyncio +import inspect +import threading +import time +from dataclasses import dataclass +from typing import Any, Callable, Optional + + +@dataclass(frozen=True) +class TimeMessage: + sec: int + nanosec: int + + +class DeviceTime: + def __init__(self, *, nanoseconds: Optional[int] = None) -> None: + self.nanoseconds = int(time.time_ns() if nanoseconds is None else nanoseconds) + + def seconds_nanoseconds(self) -> tuple[int, int]: + return divmod(self.nanoseconds, 1_000_000_000) + + def to_msg(self) -> TimeMessage: + seconds, nanoseconds = self.seconds_nanoseconds() + return TimeMessage(sec=seconds, nanosec=nanoseconds) + + +class DeviceClock: + def now(self) -> DeviceTime: + return DeviceTime() + + +@dataclass(frozen=True) +class DeviceParameterValue: + value: Any + + @property + def bool_value(self) -> bool: + return bool(self.value) if isinstance(self.value, bool) else False + + @property + def integer_value(self) -> int: + return ( + int(self.value) + if isinstance(self.value, int) and not isinstance(self.value, bool) + else 0 + ) + + @property + def double_value(self) -> float: + return ( + float(self.value) + if isinstance(self.value, (int, float)) and not isinstance(self.value, bool) + else 0.0 + ) + + @property + def string_value(self) -> str: + return self.value if isinstance(self.value, str) else "" + + @property + def byte_array_value(self) -> list[int]: + return list(self.value) if isinstance(self.value, (bytes, bytearray)) else [] + + @property + def bool_array_value(self) -> list[bool]: + return ( + list(self.value) + if isinstance(self.value, list) + and all(isinstance(v, bool) for v in self.value) + else [] + ) + + @property + def integer_array_value(self) -> list[int]: + return ( + list(self.value) + if isinstance(self.value, list) + and all(isinstance(v, int) and not isinstance(v, bool) for v in self.value) + else [] + ) + + @property + def double_array_value(self) -> list[float]: + return ( + [float(v) for v in self.value] + if isinstance(self.value, list) + and all( + isinstance(v, (int, float)) and not isinstance(v, bool) + for v in self.value + ) + else [] + ) + + @property + def string_array_value(self) -> list[str]: + return ( + list(self.value) + if isinstance(self.value, list) + and all(isinstance(v, str) for v in self.value) + else [] + ) + + +@dataclass(frozen=True) +class DeviceParameter: + name: str + value: Any = None + + def get_parameter_value(self) -> DeviceParameterValue: + return DeviceParameterValue(self.value) + + +@dataclass(frozen=True) +class SetParametersResult: + successful: bool = True + reason: str = "" + + +class DeviceRate: + def __init__(self, frequency: float) -> None: + if float(frequency) <= 0: + raise ValueError("rate frequency 必须大于 0") + self._period = 1.0 / float(frequency) + + def sleep(self) -> None: + time.sleep(self._period) + + +class DeviceTimer: + """A repeating timer scheduled by a backend-neutral ``DeviceNode``.""" + + def __init__( + self, + node: Any, + period: float, + callback: Callable[[], Any], + *, + autostart: bool = True, + ) -> None: + if float(period) <= 0: + raise ValueError("timer period 必须大于 0") + self._node = node + self._period = float(period) + self._callback = callback + self._lock = threading.Lock() + self._future: Any = None + self._cancelled = True + self._last_call_ns = 0 + self._next_call_ns = 0 + if autostart: + self.reset() + + @property + def timer_period_ns(self) -> int: + return int(self._period * 1_000_000_000) + + def reset(self) -> None: + with self._lock: + previous = self._future + self._cancelled = False + self._next_call_ns = time.time_ns() + self.timer_period_ns + self._future = self._node.create_task(self._run()) + if previous is not None: + previous.cancel() + + def cancel(self) -> None: + with self._lock: + self._cancelled = True + future = self._future + self._future = None + if future is not None: + future.cancel() + + def is_canceled(self) -> bool: + return self._cancelled + + def is_ready(self) -> bool: + return not self._cancelled and time.time_ns() >= self._next_call_ns + + def time_since_last_call(self) -> Optional[int]: + if not self._last_call_ns: + return None + return time.time_ns() - self._last_call_ns + + def time_until_next_call(self) -> Optional[int]: + if self._cancelled: + return None + return max(0, self._next_call_ns - time.time_ns()) + + async def _run(self) -> None: + try: + while not self._cancelled: + await self._node.sleep(self._period) + if self._cancelled: + break + self._last_call_ns = time.time_ns() + self._next_call_ns = self._last_call_ns + self.timer_period_ns + result = self._callback() + if inspect.isawaitable(result): + await result + except asyncio.CancelledError: + pass + + +__all__ = [ + "DeviceClock", + "DeviceParameter", + "DeviceParameterValue", + "DeviceRate", + "DeviceTime", + "DeviceTimer", + "SetParametersResult", + "TimeMessage", +] diff --git a/unilabos/device_runtime/resource.py b/unilabos/device_runtime/resource.py new file mode 100644 index 000000000..2a338ad60 --- /dev/null +++ b/unilabos/device_runtime/resource.py @@ -0,0 +1,234 @@ +"""Backend-neutral resource storage and device resource operations.""" + +from __future__ import annotations + +import threading +from typing import Any, Iterable, Protocol + +from unilabos.resources.resource_tracker import ( + DeviceNodeResourceTracker, + ResourceDictInstance, + ResourceTreeInstance, + ResourceTreeSet, +) + + +class ResourceService(Protocol): + """Operations a backend provides to one device node.""" + + async def update_resources( + self, + device_id: str, + device_uuid: str, + resources: Any, + ) -> dict[str, str]: ... + + async def get_resources( + self, + device_id: str, + resources_uuid: list[str], + with_children: bool, + ) -> ResourceTreeSet: ... + + +def resources_to_tree_set( + resources: Any, + *, + device_id: str, + device_uuid: str, +) -> ResourceTreeSet: + """Normalize PLR resources or a ResourceTreeSet for backend transport.""" + + if isinstance(resources, ResourceTreeSet): + tree_set = ResourceTreeSet.load(resources.dump()) + else: + normalized = ( + list(resources) + if isinstance(resources, (list, tuple)) + else [resources] + ) + if not normalized or normalized == [None]: + raise ValueError("更新物料时至少需要一个资源") + tree_set = ResourceTreeSet.from_plr_resources(normalized) + + if device_id != "host_node": + for root in tree_set.root_nodes: + if not root.res_content.uuid_parent: + root.res_content.parent_uuid = device_uuid or device_id + return tree_set + + +def apply_uuid_mapping(resources: Any, uuid_mapping: dict[str, str]) -> None: + """Apply a Host-assigned UUID mapping back to caller-owned PLR objects.""" + + if not uuid_mapping or isinstance(resources, ResourceTreeSet): + return + DeviceNodeResourceTracker().loop_update_uuid(resources, uuid_mapping) + + +class ResourceStore: + """Thread-safe canonical resource tree used by non-ROS backends.""" + + def __init__(self, resources: ResourceTreeSet | None = None) -> None: + if resources is not None and not isinstance(resources, ResourceTreeSet): + raise TypeError("resources 必须是 ResourceTreeSet") + self._resources = resources if resources is not None else ResourceTreeSet([]) + self._lock = threading.RLock() + + @property + def resources(self) -> ResourceTreeSet: + return self._resources + + @staticmethod + def _find_parent( + root: ResourceDictInstance, + target_uuid: str, + ) -> ResourceDictInstance | None: + for child in root.children: + if child.res_content.uuid == target_uuid: + return root + parent = ResourceStore._find_parent(child, target_uuid) + if parent is not None: + return parent + return None + + def _detach(self, target_uuid: str) -> None: + for index, tree in enumerate(tuple(self._resources.trees)): + if tree.root_node.res_content.uuid == target_uuid: + self._resources.trees.pop(index) + return + parent = self._find_parent(tree.root_node, target_uuid) + if parent is None: + continue + parent.children = [ + child + for child in parent.children + if child.res_content.uuid != target_uuid + ] + return + + @staticmethod + def _subtree_uuids(root: ResourceDictInstance) -> set[str]: + result = {root.res_content.uuid} + for child in root.children: + result.update(ResourceStore._subtree_uuids(child)) + return result + + def apply_update(self, update: ResourceTreeSet) -> dict[str, str]: + """Replace matching subtrees or mount new subtrees by parent UUID.""" + + incoming = ResourceTreeSet.load(update.dump()) + uuid_mapping = { + node.res_content.uuid: node.res_content.uuid + for node in incoming.all_nodes + } + with self._lock: + for tree in incoming.trees: + root = tree.root_node + root_uuid = root.res_content.uuid + parent_uuid = root.res_content.uuid_parent + if parent_uuid and parent_uuid in self._subtree_uuids(root): + raise ValueError( + f"物料 {root_uuid!r} 不能挂载到自己的子节点 " + f"{parent_uuid!r}" + ) + + self._detach(root_uuid) + parent = ( + self._resources.find_by_uuid(parent_uuid) + if parent_uuid + else None + ) + if parent is not None: + root.res_content.parent = parent.res_content + root.res_content.parent_uuid = parent.res_content.uuid + parent.children.append(root) + else: + root.res_content.parent = None + self._resources.trees.append(ResourceTreeInstance(root)) + return uuid_mapping + + @staticmethod + def _serialize_subtree( + root: ResourceDictInstance, + *, + with_children: bool, + ) -> list[dict[str, Any]]: + result = [root.res_content.model_dump(by_alias=True)] + if with_children: + for child in root.children: + result.extend( + ResourceStore._serialize_subtree( + child, + with_children=True, + ) + ) + return result + + def get_resources( + self, + resources_uuid: Iterable[str], + *, + with_children: bool = True, + ) -> ResourceTreeSet: + """Return independent copies of requested resource subtrees.""" + + trees: list[ResourceTreeInstance] = [] + with self._lock: + for resource_uuid in resources_uuid: + node = self._resources.find_by_uuid(str(resource_uuid)) + if node is None: + continue + raw_nodes = self._serialize_subtree( + node, + with_children=with_children, + ) + trees.extend(ResourceTreeSet.from_raw_dict_list(raw_nodes).trees) + return ResourceTreeSet(trees) + + def snapshot(self) -> ResourceTreeSet: + with self._lock: + return ResourceTreeSet.load(self._resources.dump()) + + +class LocalResourceService: + """Connect DeviceNode resource calls directly to a local ResourceStore.""" + + def __init__(self, store: ResourceStore) -> None: + self.store = store + + async def update_resources( + self, + device_id: str, + device_uuid: str, + resources: Any, + ) -> dict[str, str]: + tree_set = resources_to_tree_set( + resources, + device_id=device_id, + device_uuid=device_uuid, + ) + uuid_mapping = self.store.apply_update(tree_set) + apply_uuid_mapping(resources, uuid_mapping) + return uuid_mapping + + async def get_resources( + self, + device_id: str, + resources_uuid: list[str], + with_children: bool, + ) -> ResourceTreeSet: + del device_id + return self.store.get_resources( + resources_uuid, + with_children=with_children, + ) + + +__all__ = [ + "LocalResourceService", + "ResourceService", + "ResourceStore", + "apply_uuid_mapping", + "resources_to_tree_set", +] diff --git a/unilabos/device_runtime/service.py b/unilabos/device_runtime/service.py new file mode 100644 index 000000000..7b335a331 --- /dev/null +++ b/unilabos/device_runtime/service.py @@ -0,0 +1,243 @@ +"""Backend-neutral ROS-shaped service and client helpers.""" + +from __future__ import annotations + +import asyncio +import inspect +import threading +import time +from dataclasses import dataclass +from typing import Any, Awaitable, Callable, Dict, Optional, Protocol + +from unilabos.device_runtime.topic import normalize_topic, value_to_message + +ServiceCallback = Callable[[Any], Awaitable[Any]] + + +def normalize_service_name(name: str, device_id: str = "") -> str: + return normalize_topic(name, device_id) + + +class ServiceBus(Protocol): + def register_service( + self, + name: str, + callback: ServiceCallback, + *, + owner_device_id: str, + ) -> None: ... + + def unregister_service(self, name: str, *, owner_device_id: str) -> None: ... + + def has_service(self, name: str) -> bool: ... + + async def call_service_async( + self, + name: str, + request: Any, + *, + caller_device_id: str, + timeout: Optional[float] = None, + ) -> Any: ... + + +@dataclass(frozen=True) +class _ServiceRecord: + callback: ServiceCallback + owner_device_id: str + + +class LocalServiceBus: + def __init__(self) -> None: + self._services: Dict[str, _ServiceRecord] = {} + self._lock = threading.RLock() + + def register_service( + self, + name: str, + callback: ServiceCallback, + *, + owner_device_id: str, + ) -> None: + normalized = normalize_service_name(name) + with self._lock: + if normalized in self._services: + raise ValueError(f"service 已存在:{normalized}") + self._services[normalized] = _ServiceRecord( + callback=callback, + owner_device_id=str(owner_device_id), + ) + + def unregister_service(self, name: str, *, owner_device_id: str) -> None: + normalized = normalize_service_name(name) + with self._lock: + record = self._services.get(normalized) + if record is not None and record.owner_device_id == str(owner_device_id): + self._services.pop(normalized, None) + + def has_service(self, name: str) -> bool: + with self._lock: + return normalize_service_name(name) in self._services + + def services(self, owner_device_id: str = "") -> list[str]: + with self._lock: + return sorted( + name + for name, record in self._services.items() + if not owner_device_id or record.owner_device_id == owner_device_id + ) + + async def call_service_async( + self, + name: str, + request: Any, + *, + caller_device_id: str = "", + timeout: Optional[float] = None, + ) -> Any: + del caller_device_id + normalized = normalize_service_name(name) + with self._lock: + record = self._services.get(normalized) + if record is None: + raise KeyError(f"未知 service:{normalized}") + operation = record.callback(request) + if timeout is None: + return await operation + return await asyncio.wait_for(operation, float(timeout)) + + def call_service( + self, + name: str, + request: Any, + *, + caller_device_id: str = "", + timeout: Optional[float] = None, + ) -> Any: + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run( + self.call_service_async( + name, + request, + caller_device_id=caller_device_id, + timeout=timeout, + ) + ) + raise RuntimeError("异步设备方法中请使用 client.call_async(request)") + + +class DeviceService: + def __init__( + self, + bus: ServiceBus, + service_name: str, + srv_type: Any, + owner_device_id: str, + ) -> None: + self._bus = bus + self.service_name = service_name + self.srv_type = srv_type + self.owner_device_id = owner_device_id + self._destroyed = False + + def destroy(self) -> None: + if self._destroyed: + return + self._destroyed = True + self._bus.unregister_service( + self.service_name, + owner_device_id=self.owner_device_id, + ) + + +class DeviceServiceClient: + def __init__( + self, + bus: ServiceBus, + service_name: str, + srv_type: Any, + caller_device_id: str, + ) -> None: + self._bus = bus + self.service_name = service_name + self.srv_type = srv_type + self.caller_device_id = caller_device_id + + def service_is_ready(self) -> bool: + return self._bus.has_service(self.service_name) + + def wait_for_service(self, timeout_sec: Optional[float] = None) -> bool: + deadline = None if timeout_sec is None else time.monotonic() + timeout_sec + while not self.service_is_ready(): + if deadline is not None and time.monotonic() >= deadline: + return False + time.sleep(0.02) + return True + + def call(self, request: Any, *, timeout: Optional[float] = None) -> Any: + call = getattr(self._bus, "call_service", None) + if callable(call): + result = call( + self.service_name, + request, + caller_device_id=self.caller_device_id, + timeout=timeout, + ) + return value_to_message(getattr(self.srv_type, "Response", None), result) + return asyncio.run(self.call_async(request, timeout=timeout)) + + async def call_async( + self, + request: Any, + *, + timeout: Optional[float] = None, + ) -> Any: + result = await self._bus.call_service_async( + self.service_name, + request, + caller_device_id=self.caller_device_id, + timeout=timeout, + ) + return value_to_message(getattr(self.srv_type, "Response", None), result) + + +def build_service_callback( + node: Any, + srv_type: Any, + callback: Callable[..., Any], +) -> ServiceCallback: + parameters = list(inspect.signature(callback).parameters.values()) + accepts_response = len(parameters) >= 2 or any( + parameter.kind is inspect.Parameter.VAR_POSITIONAL for parameter in parameters + ) + + async def invoke(request: Any) -> Any: + request = value_to_message(getattr(srv_type, "Request", None), request) + response_type = getattr(srv_type, "Response", None) + response = response_type() if callable(response_type) else None + result = callback(request, response) if accepts_response else callback(request) + if inspect.isawaitable(result): + result = await result + return response if result is None else result + + async def dispatch(request: Any) -> Any: + future = node.create_task(invoke(request)) + if inspect.isawaitable(future): + return await future + if hasattr(future, "__await__"): + return await future + return await asyncio.wrap_future(future) + + return dispatch + + +__all__ = [ + "DeviceService", + "DeviceServiceClient", + "LocalServiceBus", + "ServiceBus", + "build_service_callback", + "normalize_service_name", +] diff --git a/unilabos/device_runtime/topic.py b/unilabos/device_runtime/topic.py new file mode 100644 index 000000000..a9d8856ee --- /dev/null +++ b/unilabos/device_runtime/topic.py @@ -0,0 +1,393 @@ +"""Backend-neutral topic publishing and subscription helpers.""" + +from __future__ import annotations + +from array import array +import dataclasses +import logging +import threading +import time +import uuid +from dataclasses import dataclass +from enum import Enum +from typing import Any, Callable, Dict, Optional, Protocol + +TopicCallback = Callable[[Any], Any] +TopicEventListener = Callable[["TopicEvent"], None] +SubscriptionListener = Callable[[str, bool], None] + +_logger = logging.getLogger("unilabos.device_runtime.topic") + + +def normalize_topic(topic: str, device_id: str = "") -> str: + """Return one canonical absolute topic name. + + Relative names follow the existing ROS device namespace convention: + ``temperature`` on ``pump-1`` becomes ``/devices/pump-1/temperature``. + """ + + value = str(topic or "").strip().replace("\\", "/") + if not value: + raise ValueError("topic 不能为空") + if not value.startswith("/"): + owner = str(device_id or "").strip().strip("/") + value = f"/devices/{owner}/{value}" if owner else f"/{value}" + parts = [part for part in value.split("/") if part] + if not parts: + raise ValueError("topic 不能是根路径") + return "/" + "/".join(parts) + + +def message_type_name(message_type: Any) -> str: + """Describe a Python or ROS message class without importing ROS.""" + + if message_type is None: + return "" + if isinstance(message_type, str): + return message_type + module = str(getattr(message_type, "__module__", "") or "") + name = str( + getattr(message_type, "__qualname__", "") + or getattr(message_type, "__name__", "") + or type(message_type).__name__ + ) + return f"{module}.{name}" if module else name + + +def message_to_value(value: Any) -> Any: + """Convert a Python/Pydantic/ROS-like message into JSON-compatible data.""" + + if value is None or isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, type): + return message_type_name(value) + if isinstance(value, (bytes, bytearray, memoryview, array)): + return [message_to_value(item) for item in value] + if isinstance(value, Enum): + return message_to_value(value.value) + if dataclasses.is_dataclass(value) and not isinstance(value, type): + return message_to_value(dataclasses.asdict(value)) + model_dump = getattr(value, "model_dump", None) + if callable(model_dump): + try: + return message_to_value(model_dump(mode="json")) + except TypeError: + return message_to_value(model_dump()) + legacy_dict = getattr(value, "dict", None) + if callable(legacy_dict): + return message_to_value(legacy_dict()) + fields_getter = getattr(value, "get_fields_and_field_types", None) + if callable(fields_getter): + return { + str(name): message_to_value(getattr(value, str(name))) + for name in fields_getter() + } + if isinstance(value, dict): + return {str(key): message_to_value(item) for key, item in value.items()} + if isinstance(value, (list, tuple, set)): + return [message_to_value(item) for item in value] + attributes = getattr(value, "__dict__", None) + if isinstance(attributes, dict): + return { + str(name): message_to_value(item) + for name, item in attributes.items() + if not str(name).startswith("_") + } + # ROS 消息字段可能包含 numpy 数组;传输层本身不依赖 numpy,只识别其 tolist。 + # 限定模块名可以避免在任意驱动对象上调用同名方法。 + if type(value).__module__.split(".", 1)[0] == "numpy": + to_list = getattr(value, "tolist", None) + if callable(to_list): + return message_to_value(to_list()) + return repr(value) + + +def value_to_message(message_type: Any, value: Any) -> Any: + """Rebuild a Python/ROS-like message from JSON-compatible data.""" + + if message_type in (None, Any) or not isinstance(value, dict): + return value + if message_type is dict: + return value + try: + if isinstance(value, message_type): + return value + except TypeError: + return value + try: + return message_type(**value) + except (TypeError, ValueError): + try: + message = message_type() + except (TypeError, ValueError): + return value + for name, item in value.items(): + if not hasattr(message, name): + continue + current = getattr(message, name) + converted = value_to_message(type(current), item) + try: + setattr(message, name, converted) + except (AttributeError, TypeError, ValueError): + setattr(message, name, item) + return message + + +@dataclass(frozen=True) +class TopicEvent: + """One transport-independent topic publication.""" + + topic: str + value: Any + publisher_device_id: str = "" + message_type: str = "" + message_id: str = "" + published_at: float = 0.0 + retain: bool = False + + @classmethod + def create( + cls, + topic: str, + value: Any, + *, + publisher_device_id: str = "", + message_type: Any = None, + retain: bool = False, + ) -> "TopicEvent": + return cls( + topic=normalize_topic(topic), + value=message_to_value(value), + publisher_device_id=str(publisher_device_id or ""), + message_type=message_type_name(message_type), + message_id=uuid.uuid4().hex, + published_at=time.time(), + retain=bool(retain), + ) + + def to_wire(self) -> Dict[str, Any]: + return dataclasses.asdict(self) + + @classmethod + def from_wire(cls, data: Dict[str, Any]) -> "TopicEvent": + if not isinstance(data, dict): + raise TypeError("topic event 必须是对象") + return cls( + topic=normalize_topic(str(data.get("topic") or "")), + value=message_to_value(data.get("value")), + publisher_device_id=str(data.get("publisher_device_id") or ""), + message_type=str(data.get("message_type") or ""), + message_id=str(data.get("message_id") or "") or uuid.uuid4().hex, + published_at=float(data.get("published_at") or time.time()), + retain=bool(data.get("retain", False)), + ) + + +class TopicBus(Protocol): + def publish(self, event: TopicEvent, *, forward: bool = True) -> None: ... + + def subscribe( + self, + topic: str, + callback: TopicCallback, + *, + trigger_when_change: bool = False, + replay_retained: bool = True, + ) -> "TopicSubscription": ... + + +@dataclass +class _SubscriptionRecord: + topic: str + callback: TopicCallback + trigger_when_change: bool + has_value: bool = False + last_value: Any = None + + +class TopicPublisher: + """Small publisher handle with the same ``publish(value)`` shape as ROS.""" + + def __init__( + self, + bus: TopicBus, + topic: str, + publisher_device_id: str, + message_type: Any = None, + *, + retain: bool = False, + ) -> None: + self._bus = bus + self.topic = normalize_topic(topic) + self.topic_name = self.topic + self.publisher_device_id = str(publisher_device_id or "") + self.message_type = message_type_name(message_type) + self.retain = bool(retain) + + def publish(self, value: Any) -> None: + self._bus.publish( + TopicEvent.create( + self.topic, + value, + publisher_device_id=self.publisher_device_id, + message_type=self.message_type, + retain=self.retain, + ) + ) + + +class TopicSubscription: + """Destroyable topic subscription handle.""" + + def __init__(self, bus: "LocalTopicBus", token: str, topic: str) -> None: + self._bus = bus + self._token = token + self.topic = topic + self.topic_name = topic + self._destroyed = False + + def destroy(self) -> None: + if self._destroyed: + return + self._destroyed = True + self._bus.unsubscribe(self._token) + + close = destroy + + +class LocalTopicBus: + """Thread-safe exact-topic broker used by Basic and HostLink runtimes.""" + + def __init__(self) -> None: + self._lock = threading.RLock() + self._subscriptions: Dict[str, _SubscriptionRecord] = {} + self._topic_counts: Dict[str, int] = {} + self._retained: Dict[str, TopicEvent] = {} + self._outbound_listeners: list[TopicEventListener] = [] + self._subscription_listeners: list[SubscriptionListener] = [] + + def publish(self, event: TopicEvent, *, forward: bool = True) -> None: + if not isinstance(event, TopicEvent): + raise TypeError("publish 需要 TopicEvent") + callbacks: list[tuple[TopicCallback, Any]] = [] + with self._lock: + if event.retain: + self._retained[event.topic] = event + for record in self._subscriptions.values(): + if record.topic != event.topic: + continue + changed = (not record.has_value) or record.last_value != event.value + record.has_value = True + record.last_value = event.value + if record.trigger_when_change and not changed: + continue + callbacks.append((record.callback, event.value)) + outbound = tuple(self._outbound_listeners) if forward else () + for callback, value in callbacks: + try: + callback(value) + except Exception: # noqa: BLE001 - one subscriber must not block others + _logger.exception("topic subscriber failed: %s", event.topic) + for listener in outbound: + try: + listener(event) + except Exception: # noqa: BLE001 - transport failures are isolated + _logger.exception("topic outbound listener failed: %s", event.topic) + + def subscribe( + self, + topic: str, + callback: TopicCallback, + *, + trigger_when_change: bool = False, + replay_retained: bool = True, + ) -> TopicSubscription: + if not callable(callback): + raise TypeError("topic callback 必须可调用") + normalized = normalize_topic(topic) + token = uuid.uuid4().hex + retained: Optional[TopicEvent] = None + with self._lock: + first = self._topic_counts.get(normalized, 0) == 0 + self._subscriptions[token] = _SubscriptionRecord( + normalized, + callback, + bool(trigger_when_change), + ) + self._topic_counts[normalized] = self._topic_counts.get(normalized, 0) + 1 + listeners = tuple(self._subscription_listeners) if first else () + if replay_retained: + retained = self._retained.get(normalized) + for listener in listeners: + listener(normalized, True) + if retained is not None: + with self._lock: + record = self._subscriptions.get(token) + if record is not None: + record.has_value = True + record.last_value = retained.value + try: + callback(retained.value) + except Exception: # noqa: BLE001 - retained replay is isolated + _logger.exception("topic retained replay failed: %s", normalized) + return TopicSubscription(self, token, normalized) + + def unsubscribe(self, token: str) -> None: + with self._lock: + record = self._subscriptions.pop(str(token), None) + if record is None: + return + remaining = self._topic_counts.get(record.topic, 1) - 1 + if remaining > 0: + self._topic_counts[record.topic] = remaining + listeners: tuple[SubscriptionListener, ...] = () + else: + self._topic_counts.pop(record.topic, None) + listeners = tuple(self._subscription_listeners) + for listener in listeners: + listener(record.topic, False) + + def subscribed_topics(self) -> tuple[str, ...]: + with self._lock: + return tuple(sorted(self._topic_counts)) + + def add_outbound_listener(self, listener: TopicEventListener) -> None: + with self._lock: + if listener not in self._outbound_listeners: + self._outbound_listeners.append(listener) + + def remove_outbound_listener(self, listener: TopicEventListener) -> None: + with self._lock: + if listener in self._outbound_listeners: + self._outbound_listeners.remove(listener) + + def add_subscription_listener(self, listener: SubscriptionListener) -> None: + with self._lock: + if listener not in self._subscription_listeners: + self._subscription_listeners.append(listener) + + def remove_subscription_listener(self, listener: SubscriptionListener) -> None: + with self._lock: + if listener in self._subscription_listeners: + self._subscription_listeners.remove(listener) + + def close(self) -> None: + with self._lock: + self._subscriptions.clear() + self._topic_counts.clear() + self._retained.clear() + self._outbound_listeners.clear() + self._subscription_listeners.clear() + + +__all__ = [ + "LocalTopicBus", + "TopicBus", + "TopicEvent", + "TopicPublisher", + "TopicSubscription", + "message_to_value", + "value_to_message", + "message_type_name", + "normalize_topic", +] diff --git a/unilabos/devices/arm/elite_robot.py b/unilabos/devices/arm/elite_robot.py index 09eef5196..f0fe07d8e 100644 --- a/unilabos/devices/arm/elite_robot.py +++ b/unilabos/devices/arm/elite_robot.py @@ -1,14 +1,13 @@ import socket import re import time -from rclpy.node import Node from sensor_msgs.msg import JointState class EliteRobot: def __init__(self,device_id, host, **kwargs): self.host = host - self.node = Node(f"{device_id}") + self.node = None self.joint_state_msg = JointState() self.joint_state_msg.name = [f"{device_id}_shoulder_pan_joint", f"{device_id}_shoulder_lift_joint", @@ -18,7 +17,7 @@ def __init__(self,device_id, host, **kwargs): f"{device_id}_wrist_3_joint"] self.job_id = 0 - self.joint_state_pub = self.node.create_publisher(JointState, "/joint_states", 10) + self.joint_state_pub = None self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # 实现一个简单的Modbus TCP/IP协议客户端,端口为502 self.modbus_port = 502 @@ -35,6 +34,16 @@ def __init__(self,device_id, host, **kwargs): except Exception as e: print(f"连接到 {self.host}:{40011} 失败: {e}") + def post_init(self, node): + """使用当前 backend 提供的通用节点创建关节状态发布者。""" + + self.node = node + self.joint_state_pub = node.create_publisher( + JointState, + "/joint_states", + 10, + ) + def modbus_close(self): self.modbus_sock.close() @@ -187,8 +196,11 @@ def get_actual_joint_positions(self): if __name__ == "__main__": import rclpy + from rclpy.node import Node + rclpy.init() client = EliteRobot('aa',"192.168.1.200") + client.post_init(Node("aa")) print(client.parse_success_response(client.send_command("req 1 get_actual_joint_positions()\n"))) client.modbus_write_single_register(1, 256, 4) print(client.modbus_read_holding_registers(1, 257, 1)) diff --git a/unilabos/devices/battery/neware_battery_test_system.py b/unilabos/devices/battery/neware_battery_test_system.py index 317e8fe51..c609f5f66 100644 --- a/unilabos/devices/battery/neware_battery_test_system.py +++ b/unilabos/devices/battery/neware_battery_test_system.py @@ -22,8 +22,7 @@ from pylabrobot.resources import ResourceHolder, Coordinate, create_ordered_items_2d, Deck, Plate -from unilabos.ros.nodes.base_device_node import ROS2DeviceNode -from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode +from unilabos.device_runtime.node import DeviceNode # ======================== @@ -164,10 +163,10 @@ def __init__(self, self.timeout = timeout or self.TIMEOUT self._last_status_update = None self._cached_status = {} - self._ros_node: Optional[ROS2WorkstationNode] = None # ROS节点引用,由框架设置 + self._ros_node: Optional[DeviceNode] = None # 运行时节点引用,由框架设置 - def post_init(self, ros_node): + def post_init(self, ros_node: DeviceNode): """ ROS节点初始化后的回调方法,用于建立设备连接 @@ -211,9 +210,9 @@ def _setup_material_management(self): # 只有在真实ROS环境下才调用update_resource if hasattr(self._ros_node, 'update_resource') and callable(getattr(self._ros_node, 'update_resource')): try: - ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{ - "resources": [deck_main] - }) + self._ros_node.create_task( + self._ros_node.update_resource([deck_main]) + ) except Exception as e: if hasattr(self._ros_node, 'lab_logger'): self._ros_node.lab_logger().warning(f"更新资源失败: {e}") diff --git a/unilabos/devices/cnc/grbl_async.py b/unilabos/devices/cnc/grbl_async.py index 3ecd4ba8d..e733b39d6 100644 --- a/unilabos/devices/cnc/grbl_async.py +++ b/unilabos/devices/cnc/grbl_async.py @@ -12,7 +12,7 @@ from serial.serialutil import SerialException from unilabos.messages import Point3D -from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode +from unilabos.device_runtime.node import DeviceNode class GrblCNCConnectionError(Exception): @@ -33,7 +33,7 @@ def create(self): class GrblCNCAsync: _status: str = "Offline" _position: Point3D = Point3D(x=0.0, y=0.0, z=0.0) - _ros_node: BaseROS2DeviceNode + _ros_node: DeviceNode def __init__(self, port: str, address: str = "1", limits: tuple[int, int, int, int, int, int] = (-150, 150, -200, 0, 0, 60)): self.port = port @@ -60,7 +60,7 @@ def __init__(self, port: str, address: str = "1", limits: tuple[int, int, int, i self._run_future: Optional[Future[Any]] = None self._run_lock = Lock() - def post_init(self, ros_node: BaseROS2DeviceNode): + def post_init(self, ros_node: DeviceNode): self._ros_node = ros_node def _read_all(self): diff --git a/unilabos/devices/cnc/mock.py b/unilabos/devices/cnc/mock.py index ebe96833c..3ed71fab4 100644 --- a/unilabos/devices/cnc/mock.py +++ b/unilabos/devices/cnc/mock.py @@ -2,7 +2,7 @@ import asyncio from pydantic import BaseModel -from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode +from unilabos.device_runtime.node import DeviceNode class Point3D(BaseModel): @@ -16,7 +16,7 @@ def d(a: Point3D, b: Point3D) -> float: class MockCNCAsync: - _ros_node: BaseROS2DeviceNode["MockCNCAsync"] + _ros_node: DeviceNode def __init__(self): self._position: Point3D = Point3D(x=0.0, y=0.0, z=0.0) diff --git a/unilabos/devices/liquid_handling/__init__.py b/unilabos/devices/liquid_handling/__init__.py index e69de29bb..ce5ae5078 100644 --- a/unilabos/devices/liquid_handling/__init__.py +++ b/unilabos/devices/liquid_handling/__init__.py @@ -0,0 +1,38 @@ +"""Liquid-handling driver package. + +Some Uni-Lab PyLabRobot builds expose an RViz backend from the package +``__init__`` and therefore import ``rclpy`` even when RViz is not selected. +Keep that optional backend lazy so Chatterbox and hardware backends can run in +Basic/HostLink processes without ROS installed. +""" + +from __future__ import annotations + +import importlib.util +import sys +import types + +from unilabos.config.config import BasicConfig + + +def _install_optional_plr_rviz_stub() -> None: + if BasicConfig.backend == "ros2" and importlib.util.find_spec("rclpy") is not None: + return + module_name = "pylabrobot.liquid_handling.backends.rviz_backend" + if module_name in sys.modules: + return + module = types.ModuleType(module_name) + + class LiquidHandlerRvizBackend: # pragma: no cover - instantiated on misuse + def __init__(self, *_args, **_kwargs) -> None: + raise RuntimeError( + "LiquidHandlerRvizBackend 需要 ROS2;" + "Basic/HostLink 请使用硬件 backend 或 Chatterbox backend" + ) + + module.LiquidHandlerRvizBackend = LiquidHandlerRvizBackend + module.__all__ = ["LiquidHandlerRvizBackend"] + sys.modules[module_name] = module + + +_install_optional_plr_rviz_stub() diff --git a/unilabos/devices/liquid_handling/laiyu/laiyu.py b/unilabos/devices/liquid_handling/laiyu/laiyu.py index 0d7074a76..b45cc1f28 100644 --- a/unilabos/devices/liquid_handling/laiyu/laiyu.py +++ b/unilabos/devices/liquid_handling/laiyu/laiyu.py @@ -27,8 +27,6 @@ from unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract from unilabos.devices.liquid_handling.rviz_backend import UniLiquidHandlerRvizBackend -from unilabos.devices.liquid_handling.laiyu.backend.laiyu_v_backend import UniLiquidHandlerLaiyuBackend - class TransformXYZDeck(Deck): @@ -215,4 +213,3 @@ async def transfer_liquid( none_keys: List[str] = [], ): pass - \ No newline at end of file diff --git a/unilabos/devices/liquid_handling/liquid_handler_abstract.py b/unilabos/devices/liquid_handling/liquid_handler_abstract.py index ec936175a..c097ea4f8 100644 --- a/unilabos/devices/liquid_handling/liquid_handler_abstract.py +++ b/unilabos/devices/liquid_handling/liquid_handler_abstract.py @@ -25,6 +25,7 @@ ) from typing_extensions import TypedDict +from unilabos.device_runtime.node import DeviceNode from unilabos.devices.liquid_handling.rviz_backend import UniLiquidHandlerRvizBackend from unilabos.registry.placeholder_type import ResourceSlot from unilabos.resources.resource_tracker import ( @@ -33,7 +34,6 @@ EXTRA_SAMPLE_UUID, EXTRA_UNILABOS_SAMPLE_UUID, ) -from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode class SimpleReturn(TypedDict): @@ -608,7 +608,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): """Extended LiquidHandler with additional operations.""" support_touch_tip = True - _ros_node: BaseROS2DeviceNode + _ros_node: DeviceNode def __init__( self, @@ -665,7 +665,7 @@ def __init__( self.group_info = dict() super().__init__(backend_type, deck, simulator, channel_num) - def post_init(self, ros_node: BaseROS2DeviceNode): + def post_init(self, ros_node: DeviceNode): self._ros_node = ros_node @classmethod @@ -717,7 +717,7 @@ def set_liquid_from_plate( well.set_liquids([(liquid_name, volume)]) # type: ignore res_volumes.append(volume) - task = ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{"resources": wells}) + task = self._ros_node.create_task(self._ros_node.update_resource(wells)) submit_time = time.time() while not task.done(): if time.time() - submit_time > 10: diff --git a/unilabos/devices/liquid_handling/prcxi/prcxi.py b/unilabos/devices/liquid_handling/prcxi/prcxi.py index 8591ee07a..9856040b7 100644 --- a/unilabos/devices/liquid_handling/prcxi/prcxi.py +++ b/unilabos/devices/liquid_handling/prcxi/prcxi.py @@ -47,6 +47,7 @@ TubeRack, ) +from unilabos.device_runtime.node import DeviceNode from unilabos.devices.liquid_handling.liquid_handler_abstract import ( LiquidHandlerAbstract, SimpleReturn, @@ -56,7 +57,6 @@ ) from unilabos.registry.placeholder_type import ResourceSlot from unilabos.resources.resource_tracker import ResourceTreeSet -from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode class PRCXIError(RuntimeError): @@ -676,7 +676,7 @@ def __init__( ) super().__init__(backend=self._unilabos_backend, deck=deck, simulator=simulator, channel_num=channel_num) - def post_init(self, ros_node: BaseROS2DeviceNode): + def post_init(self, ros_node: DeviceNode): super().post_init(ros_node) self._unilabos_backend.post_init(ros_node) @@ -983,7 +983,7 @@ class PRCXI9300Backend(LiquidHandlerBackend): _num_channels = 8 # 默认通道数为 8 _is_reset_ok = False - _ros_node: BaseROS2DeviceNode + _ros_node: DeviceNode @property def is_reset_ok(self) -> bool: @@ -1061,7 +1061,7 @@ 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) - def post_init(self, ros_node: BaseROS2DeviceNode): + def post_init(self, ros_node: DeviceNode): self._ros_node = ros_node def create_protocol(self, protocol_name): diff --git a/unilabos/devices/liquid_handling/rviz_backend.py b/unilabos/devices/liquid_handling/rviz_backend.py index 3bd2c2f86..deccfb39c 100644 --- a/unilabos/devices/liquid_handling/rviz_backend.py +++ b/unilabos/devices/liquid_handling/rviz_backend.py @@ -23,16 +23,9 @@ ) 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.ros_dev.liquid_handler_joint_publisher_node import LiquidHandlerJointPublisher - class UniLiquidHandlerRvizBackend(LiquidHandlerBackend): """Chatter box backend for device-free testing. Prints out all operations.""" @@ -53,26 +46,35 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend): def __init__(self, num_channels: int = 8 , tip_length: float = 0 , total_height: float = 310, **kwargs): """Initialize a chatter box backend.""" + try: + import rclpy + except ModuleNotFoundError as exc: + raise RuntimeError( + "UniLiquidHandlerRvizBackend 需要 ROS2;Basic/HostLink 请改用非 RViz backend" + ) from exc super().__init__() + self._rclpy = rclpy self._num_channels = num_channels self.tip_length = tip_length self.total_height = total_height self.joint_config = kwargs.get("joint_config", None) self.lh_device_id = kwargs.get("lh_device_id", "lh_joint_publisher") - if not rclpy.ok(): - rclpy.init() + if not self._rclpy.ok(): + self._rclpy.init() self.joint_state_publisher = None self.executor = None self.executor_thread = None async def setup(self): + from unilabos.devices.ros_dev.liquid_handler_joint_publisher_node import LiquidHandlerJointPublisher + self.joint_state_publisher = LiquidHandlerJointPublisher( joint_config=self.joint_config, lh_device_id=self.lh_device_id, simulate_rviz=True) # 启动ROS executor - self.executor = rclpy.executors.MultiThreadedExecutor() + self.executor = self._rclpy.executors.MultiThreadedExecutor() self.executor.add_node(self.joint_state_publisher) self.executor_thread = threading.Thread(target=self.executor.spin, daemon=True) self.executor_thread.start() diff --git a/unilabos/devices/neware_battery_test_system/neware_battery_test_system.py b/unilabos/devices/neware_battery_test_system/neware_battery_test_system.py index 0a811458b..77f1ff66b 100644 --- a/unilabos/devices/neware_battery_test_system/neware_battery_test_system.py +++ b/unilabos/devices/neware_battery_test_system/neware_battery_test_system.py @@ -23,8 +23,7 @@ from typing import Any, Dict, List, Optional, TypedDict from pylabrobot.resources import ResourceHolder, Coordinate, create_ordered_items_2d, Deck, Plate -from unilabos.ros.nodes.base_device_node import ROS2DeviceNode -from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode +from unilabos.device_runtime.node import DeviceNode # ======================== # OSS 上传工具函数 @@ -351,10 +350,10 @@ def __init__(self, self._last_status_update = None self._cached_status = {} self._last_backup_dir = None # 记录最近一次的 backup_dir,供上传使用 - self._ros_node: Optional[ROS2WorkstationNode] = None # ROS节点引用,由框架设置 + self._ros_node: Optional[DeviceNode] = None # 运行时节点引用,由框架设置 - def post_init(self, ros_node): + def post_init(self, ros_node: DeviceNode): """ ROS节点初始化后的回调方法,用于建立设备连接 @@ -399,9 +398,9 @@ def _setup_material_management(self): # 只有在真实ROS环境下才调用update_resource if hasattr(self._ros_node, 'update_resource') and callable(getattr(self._ros_node, 'update_resource')): try: - ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{ - "resources": [deck_main] - }) + self._ros_node.create_task( + self._ros_node.update_resource([deck_main]) + ) except Exception as e: if hasattr(self._ros_node, 'lab_logger'): self._ros_node.lab_logger().warning(f"更新资源失败: {e}") @@ -621,9 +620,9 @@ def _update_plate_resources(self, subunits: Dict): if self._ros_node and hasattr(self._ros_node, 'lab_logger'): self._ros_node.lab_logger().debug(f"P2映射错误: subdev{subdev_id}/chl{chl_id} - {e}") continue - ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{ - "resources": list(self.station_resources.values()) - }) + self._ros_node.create_task( + self._ros_node.update_resource(list(self.station_resources.values())) + ) @property def connection_info(self) -> Dict[str, str]: diff --git a/unilabos/devices/pump_and_valve/runze_async.py b/unilabos/devices/pump_and_valve/runze_async.py index 7bc111556..4e5d9e11a 100644 --- a/unilabos/devices/pump_and_valve/runze_async.py +++ b/unilabos/devices/pump_and_valve/runze_async.py @@ -8,7 +8,7 @@ from serial import Serial from serial.serialutil import SerialException -from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode +from unilabos.device_runtime.node import DeviceNode class RunzeSyringePumpMode(Enum): @@ -79,7 +79,7 @@ def create(self): class RunzeSyringePumpAsync: - _ros_node: BaseROS2DeviceNode + _ros_node: DeviceNode def __init__(self, port: str, address: str = "1", volume: float = 25000, mode: RunzeSyringePumpMode = None): self.port = port @@ -106,7 +106,7 @@ def __init__(self, port: str, address: str = "1", volume: float = 25000, mode: R self._run_future: Optional[Future[Any]] = None self._run_lock = Lock() - def post_init(self, ros_node: BaseROS2DeviceNode): + def post_init(self, ros_node: DeviceNode): self._ros_node = ros_node def _adjust_total_steps(self): diff --git a/unilabos/devices/temperature/sensor_node.py b/unilabos/devices/temperature/sensor_node.py index cb3b175fd..69e8ff51c 100644 --- a/unilabos/devices/temperature/sensor_node.py +++ b/unilabos/devices/temperature/sensor_node.py @@ -32,9 +32,6 @@ import serial import struct -from rclpy.node import Node -import rclpy -import threading class TempSensorNode(): def __init__(self,port,warning,address,baudrate=9600): diff --git a/unilabos/devices/virtual/lctest_action.py b/unilabos/devices/virtual/lctest_action.py index a3c715589..ef004877b 100644 --- a/unilabos/devices/virtual/lctest_action.py +++ b/unilabos/devices/virtual/lctest_action.py @@ -23,7 +23,7 @@ from unilabos.registry.placeholder_type import DeviceSlot, ResourceSlot from unilabos.resources.resource_tracker import ResourceDict, ResourceTreeSet if TYPE_CHECKING: - from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode + from unilabos.device_runtime.node import DeviceNode class ResourceSummary(TypedDict): @@ -71,7 +71,7 @@ class TestResourceActionReturn(TypedDict): class TestActionDevice: """集中放置测试用动作的虚拟设备。""" - _ros_node: "BaseROS2DeviceNode" + _ros_node: "DeviceNode" def __init__(self, device_id: Optional[str] = None, **kwargs): """ @@ -94,8 +94,8 @@ def __init__(self, device_id: Optional[str] = None, **kwargs): } @not_action - def post_init(self, ros_node: "BaseROS2DeviceNode") -> None: - """保存 ROS 节点引用,供后续扩展跨设备调用使用。""" + def post_init(self, ros_node: "DeviceNode") -> None: + """保存运行节点引用,供后续扩展跨设备调用使用。""" self._ros_node = ros_node @property diff --git a/unilabos/devices/virtual/virtual_centrifuge.py b/unilabos/devices/virtual/virtual_centrifuge.py index a97e5fde0..099efc845 100644 --- a/unilabos/devices/virtual/virtual_centrifuge.py +++ b/unilabos/devices/virtual/virtual_centrifuge.py @@ -4,13 +4,13 @@ from typing import Dict, Any, Optional, TYPE_CHECKING if TYPE_CHECKING: - from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode + from unilabos.device_runtime.node import DeviceNode class VirtualCentrifuge: """Virtual centrifuge device - 简化版,只保留核心功能""" - _ros_node: "BaseROS2DeviceNode" + _ros_node: "DeviceNode" def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs): # 处理可能的不同调用方式 @@ -38,7 +38,7 @@ def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, A if key not in skip_keys and not hasattr(self, key): setattr(self, key, value) - def post_init(self, ros_node: "BaseROS2DeviceNode"): + def post_init(self, ros_node: "DeviceNode"): self._ros_node = ros_node async def initialize(self) -> bool: @@ -218,4 +218,4 @@ def progress(self) -> float: @property def message(self) -> str: - return self.data.get("message", "") \ No newline at end of file + return self.data.get("message", "") diff --git a/unilabos/devices/virtual/virtual_column.py b/unilabos/devices/virtual/virtual_column.py index 42e832137..05f37b32e 100644 --- a/unilabos/devices/virtual/virtual_column.py +++ b/unilabos/devices/virtual/virtual_column.py @@ -3,12 +3,12 @@ from typing import Dict, Any, Optional, TYPE_CHECKING if TYPE_CHECKING: - from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode + from unilabos.device_runtime.node import DeviceNode class VirtualColumn: """Virtual column device for RunColumn protocol 🏛️""" - _ros_node: "BaseROS2DeviceNode" + _ros_node: "DeviceNode" def __init__(self, device_id: str = None, config: Dict[str, Any] = None, **kwargs): # 处理可能的不同调用方式 @@ -33,7 +33,7 @@ def __init__(self, device_id: str = None, config: Dict[str, Any] = None, **kwarg print(f"🏛️ === 虚拟色谱柱 {self.device_id} 已创建 === ✨") print(f"📏 柱参数: 流速={self._max_flow_rate}mL/min | 长度={self._column_length}cm | 直径={self._column_diameter}cm 🔬") - def post_init(self, ros_node: "BaseROS2DeviceNode"): + def post_init(self, ros_node: "DeviceNode"): self._ros_node = ros_node async def initialize(self) -> bool: @@ -208,4 +208,4 @@ def current_phase(self) -> str: @property def final_volume(self) -> float: - return self.data.get("final_volume", 0.0) \ No newline at end of file + return self.data.get("final_volume", 0.0) diff --git a/unilabos/devices/virtual/virtual_filter.py b/unilabos/devices/virtual/virtual_filter.py index 08ca64627..8b5303164 100644 --- a/unilabos/devices/virtual/virtual_filter.py +++ b/unilabos/devices/virtual/virtual_filter.py @@ -5,13 +5,13 @@ from unilabos.compile.utils.vessel_parser import get_vessel if TYPE_CHECKING: - from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode + from unilabos.device_runtime.node import DeviceNode class VirtualFilter: """Virtual filter device - 完全按照 Filter.action 规范 🌊""" - _ros_node: "BaseROS2DeviceNode" + _ros_node: "DeviceNode" def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs): if device_id is None and "id" in kwargs: @@ -36,7 +36,7 @@ def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, A if key not in skip_keys and not hasattr(self, key): setattr(self, key, value) - def post_init(self, ros_node: "BaseROS2DeviceNode"): + def post_init(self, ros_node: "DeviceNode"): self._ros_node = ros_node async def initialize(self) -> bool: diff --git a/unilabos/devices/virtual/virtual_heatchill.py b/unilabos/devices/virtual/virtual_heatchill.py index 981640a56..fd2124d98 100644 --- a/unilabos/devices/virtual/virtual_heatchill.py +++ b/unilabos/devices/virtual/virtual_heatchill.py @@ -4,12 +4,12 @@ from typing import Dict, Any, TYPE_CHECKING if TYPE_CHECKING: - from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode + from unilabos.device_runtime.node import DeviceNode class VirtualHeatChill: """Virtual heat chill device for HeatChillProtocol testing 🌡️""" - _ros_node: "BaseROS2DeviceNode" + _ros_node: "DeviceNode" def __init__(self, device_id: str = None, config: Dict[str, Any] = None, **kwargs): # 处理可能的不同调用方式 @@ -40,7 +40,7 @@ def __init__(self, device_id: str = None, config: Dict[str, Any] = None, **kwarg print(f"🌡️ === 虚拟温控设备 {self.device_id} 已创建 === ✨") print(f"🔥 温度范围: {self._min_temp}°C ~ {self._max_temp}°C | 🌪️ 最大搅拌: {self._max_stir_speed} RPM") - def post_init(self, ros_node: "BaseROS2DeviceNode"): + def post_init(self, ros_node: "DeviceNode"): self._ros_node = ros_node async def initialize(self) -> bool: @@ -316,4 +316,4 @@ def min_temp(self) -> float: @property def max_stir_speed(self) -> float: - return self._max_stir_speed \ No newline at end of file + return self._max_stir_speed diff --git a/unilabos/devices/virtual/virtual_rotavap.py b/unilabos/devices/virtual/virtual_rotavap.py index f2a3b4a17..1e2fc1166 100644 --- a/unilabos/devices/virtual/virtual_rotavap.py +++ b/unilabos/devices/virtual/virtual_rotavap.py @@ -4,7 +4,7 @@ from typing import Dict, Any, Optional, TYPE_CHECKING if TYPE_CHECKING: - from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode + from unilabos.device_runtime.node import DeviceNode def debug_print(message): @@ -15,7 +15,7 @@ def debug_print(message): class VirtualRotavap: """Virtual rotary evaporator device - 简化版,只保留核心功能 🌪️""" - _ros_node: "BaseROS2DeviceNode" + _ros_node: "DeviceNode" def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs): # 处理可能的不同调用方式 @@ -45,7 +45,7 @@ def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, A print(f"🌪️ === 虚拟旋转蒸发仪 {self.device_id} 已创建 === ✨") print(f"🔥 温度范围: 10°C ~ {self._max_temp}°C | 🌀 转速范围: 10 ~ {self._max_rotation_speed} RPM") - def post_init(self, ros_node: "BaseROS2DeviceNode"): + def post_init(self, ros_node: "DeviceNode"): self._ros_node = ros_node async def initialize(self) -> bool: diff --git a/unilabos/devices/virtual/virtual_separator.py b/unilabos/devices/virtual/virtual_separator.py index 7b42a76ea..809f28b6e 100644 --- a/unilabos/devices/virtual/virtual_separator.py +++ b/unilabos/devices/virtual/virtual_separator.py @@ -3,13 +3,13 @@ from typing import Dict, Any, Optional, TYPE_CHECKING if TYPE_CHECKING: - from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode + from unilabos.device_runtime.node import DeviceNode class VirtualSeparator: """Virtual separator device for SeparateProtocol testing""" - _ros_node: "BaseROS2DeviceNode" + _ros_node: "DeviceNode" def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs): # 处理可能的不同调用方式 @@ -41,7 +41,7 @@ def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, A if key not in skip_keys and not hasattr(self, key): setattr(self, key, value) - def post_init(self, ros_node: "BaseROS2DeviceNode"): + def post_init(self, ros_node: "DeviceNode"): self._ros_node = ros_node async def initialize(self) -> bool: diff --git a/unilabos/devices/virtual/virtual_solenoid_valve.py b/unilabos/devices/virtual/virtual_solenoid_valve.py index 203819965..013029a2d 100644 --- a/unilabos/devices/virtual/virtual_solenoid_valve.py +++ b/unilabos/devices/virtual/virtual_solenoid_valve.py @@ -3,7 +3,7 @@ from typing import Union, TYPE_CHECKING if TYPE_CHECKING: - from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode + from unilabos.device_runtime.node import DeviceNode class VirtualSolenoidValve: @@ -11,7 +11,7 @@ class VirtualSolenoidValve: 虚拟电磁阀门 - 简单的开关型阀门,只有开启和关闭两个状态 """ - _ros_node: "BaseROS2DeviceNode" + _ros_node: "DeviceNode" def __init__(self, device_id: str = None, config: dict = None, **kwargs): # 从配置中获取参数,提供默认值 @@ -28,7 +28,7 @@ def __init__(self, device_id: str = None, config: dict = None, **kwargs): self._valve_state = "Closed" # "Open" or "Closed" self._is_open = False - def post_init(self, ros_node: "BaseROS2DeviceNode"): + def post_init(self, ros_node: "DeviceNode"): self._ros_node = ros_node async def initialize(self) -> bool: @@ -145,4 +145,4 @@ def is_closed(self) -> bool: async def reset(self): """重置阀门到关闭状态""" - return await self.close() \ No newline at end of file + return await self.close() diff --git a/unilabos/devices/virtual/virtual_solid_dispenser.py b/unilabos/devices/virtual/virtual_solid_dispenser.py index ba99f0d94..4872b85ea 100644 --- a/unilabos/devices/virtual/virtual_solid_dispenser.py +++ b/unilabos/devices/virtual/virtual_solid_dispenser.py @@ -4,7 +4,7 @@ from typing import Dict, Any, Optional, TYPE_CHECKING if TYPE_CHECKING: - from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode + from unilabos.device_runtime.node import DeviceNode class VirtualSolidDispenser: """ @@ -16,7 +16,7 @@ class VirtualSolidDispenser: - 简单反馈:成功/失败 + 消息 📊 """ - _ros_node: "BaseROS2DeviceNode" + _ros_node: "DeviceNode" def __init__(self, device_id: str = None, config: Dict[str, Any] = None, **kwargs): self.device_id = device_id or "virtual_solid_dispenser" @@ -37,7 +37,7 @@ def __init__(self, device_id: str = None, config: Dict[str, Any] = None, **kwarg print(f"⚗️ === 虚拟固体分配器 {self.device_id} 创建成功! === ✨") print(f"📊 设备规格: 最大容量 {self.max_capacity}g | 精度 {self.precision}g 🎯") - def post_init(self, ros_node: "BaseROS2DeviceNode"): + def post_init(self, ros_node: "DeviceNode"): self._ros_node = ros_node async def initialize(self) -> bool: @@ -377,4 +377,4 @@ async def test_solid_dispenser(): if __name__ == "__main__": - asyncio.run(test_solid_dispenser()) \ No newline at end of file + asyncio.run(test_solid_dispenser()) diff --git a/unilabos/devices/virtual/virtual_stirrer.py b/unilabos/devices/virtual/virtual_stirrer.py index 8cf2559ed..f7c752846 100644 --- a/unilabos/devices/virtual/virtual_stirrer.py +++ b/unilabos/devices/virtual/virtual_stirrer.py @@ -5,12 +5,12 @@ from unilabos.registry.decorators import topic_config if TYPE_CHECKING: - from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode + from unilabos.device_runtime.node import DeviceNode class VirtualStirrer: """Virtual stirrer device for StirProtocol testing - 功能完整版 🌪️""" - _ros_node: "BaseROS2DeviceNode" + _ros_node: "DeviceNode" def __init__(self, device_id: str = None, config: Dict[str, Any] = None, **kwargs): # 处理可能的不同调用方式 @@ -40,7 +40,7 @@ def __init__(self, device_id: str = None, config: Dict[str, Any] = None, **kwarg print(f"🌪️ === 虚拟搅拌器 {self.device_id} 已创建 === ✨") print(f"🔧 速度范围: {self._min_speed} ~ {self._max_speed} RPM | 📱 端口: {self.port}") - def post_init(self, ros_node: "BaseROS2DeviceNode"): + def post_init(self, ros_node: "DeviceNode"): self._ros_node = ros_node async def initialize(self) -> bool: @@ -334,4 +334,4 @@ def device_info(self) -> Dict[str, Any]: def __str__(self): status_emoji = "✅" if self.operation_mode == "Idle" else "🌪️" if self.operation_mode == "Stirring" else "🛑" if self.operation_mode == "Settling" else "❌" - return f"🌪️ VirtualStirrer({status_emoji} {self.device_id}: {self.operation_mode}, {self.current_speed} RPM)" \ No newline at end of file + return f"🌪️ VirtualStirrer({status_emoji} {self.device_id}: {self.operation_mode}, {self.current_speed} RPM)" diff --git a/unilabos/devices/virtual/virtual_transferpump.py b/unilabos/devices/virtual/virtual_transferpump.py index 41b23c67a..d94bd6c06 100644 --- a/unilabos/devices/virtual/virtual_transferpump.py +++ b/unilabos/devices/virtual/virtual_transferpump.py @@ -6,7 +6,7 @@ from unilabos.registry.decorators import topic_config if TYPE_CHECKING: - from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode + from unilabos.device_runtime.node import DeviceNode class VirtualPumpMode(Enum): @@ -18,7 +18,7 @@ class VirtualPumpMode(Enum): class VirtualTransferPump: """虚拟转移泵类 - 模拟泵的基本功能,无需实际硬件 🚰""" - _ros_node: "BaseROS2DeviceNode" + _ros_node: "DeviceNode" def __init__(self, device_id: str = None, config: dict = None, **kwargs): """ @@ -61,7 +61,7 @@ def __init__(self, device_id: str = None, config: dict = None, **kwargs): ) print(f"📊 最大容量: {self.max_volume}mL | 端口: {self.port}") - def post_init(self, ros_node: "BaseROS2DeviceNode"): + def post_init(self, ros_node: "DeviceNode"): self._ros_node = ros_node async def initialize(self) -> bool: diff --git a/unilabos/devices/virtual/workbench.py b/unilabos/devices/virtual/workbench.py index 3eb342d76..8ae969e9e 100644 --- a/unilabos/devices/virtual/workbench.py +++ b/unilabos/devices/virtual/workbench.py @@ -18,7 +18,7 @@ from dataclasses import dataclass from enum import Enum from threading import Lock, RLock -from typing import Any, Dict, List, Optional, cast, TYPE_CHECKING +from typing import Any, Dict, List, Optional, cast from typing_extensions import TypedDict @@ -32,9 +32,8 @@ not_action, topic_config, ) +from unilabos.device_runtime.node import DeviceNode from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot -if TYPE_CHECKING: - from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode from unilabos.resources.resource_tracker import ( SampleUUIDsType, LabSample, @@ -127,6 +126,7 @@ class HeatingStation: displayname="虚拟工作台", category=["virtual_device"], description="Virtual Workbench with 1 robotic arm and 3 heating stations for concurrent material processing", + supported_backends=["ros2"], ) class VirtualWorkbench: """ @@ -143,7 +143,7 @@ class VirtualWorkbench: 4. 加热完成后, 机械臂将物料移动到目标位置Cn """ - _ros_node: "BaseROS2DeviceNode" + _ros_node: DeviceNode # 配置常量 ARM_OPERATION_TIME: float = 2 # 机械臂操作时间(秒) @@ -217,7 +217,7 @@ def __init__( ) @not_action - def post_init(self, ros_node: "BaseROS2DeviceNode"): + def post_init(self, ros_node: DeviceNode): """ROS节点初始化后回调""" self._ros_node = ros_node @@ -553,18 +553,12 @@ async def transfer( 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), - }, + return await self._ros_node.transfer_resource_to_another( + plr_resources=resource, + target_device_id=target_device, + target_resources=mount_resource, + sites=[None] * len(mount_resource), ) - result = await future - return result @action( description="扣电测试启动", diff --git a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py index 333b7b28e..5f1ba71f9 100644 --- a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py +++ b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py @@ -1946,7 +1946,7 @@ def _to_number(value: Any, default: float = 0.0) -> float: "inbound_result": inbound_result, } def resource_tree_transfer(self, old_parent: ResourcePLR, plr_resource: ResourcePLR, parent_resource: ResourcePLR): - # ROS2DeviceNode.run_async_func(self._ros_node.resource_tree_transfer, True, **{ + # self._ros_node.run_async_func(self._ros_node.resource_tree_transfer, True, **{ # "old_parent": old_parent, # "plr_resource": plr_resource, # "parent_resource": parent_resource, diff --git a/unilabos/devices/workstation/bioyond_studio/dispensing_station/dispensing_station.py b/unilabos/devices/workstation/bioyond_studio/dispensing_station/dispensing_station.py index dc48487b2..d276d8958 100644 --- a/unilabos/devices/workstation/bioyond_studio/dispensing_station/dispensing_station.py +++ b/unilabos/devices/workstation/bioyond_studio/dispensing_station/dispensing_station.py @@ -9,7 +9,7 @@ from unilabos.devices.workstation.bioyond_studio.bioyond_rpc import BioyondException from unilabos.devices.workstation.bioyond_studio.station import BioyondWorkstation -from unilabos.ros.nodes.base_device_node import ROS2DeviceNode, BaseROS2DeviceNode +from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode import json import sys from pathlib import Path @@ -1820,7 +1820,7 @@ def transfer_materials_to_reaction_station( ) # 目标位点(包含UUID) - future = ROS2DeviceNode.run_async_func( + future = self._ros_node.run_async_func( self._ros_node.get_resource_with_dir, True, **{ diff --git a/unilabos/devices/workstation/bioyond_studio/station.py b/unilabos/devices/workstation/bioyond_studio/station.py index 327d8195c..7ec446e67 100644 --- a/unilabos/devices/workstation/bioyond_studio/station.py +++ b/unilabos/devices/workstation/bioyond_studio/station.py @@ -19,7 +19,7 @@ from unilabos.utils.log import logger from unilabos.resources.graphio import resource_bioyond_to_plr, resource_plr_to_bioyond -from unilabos.ros.nodes.base_device_node import ROS2DeviceNode, BaseROS2DeviceNode +from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode from unilabos.ros.msgs.message_converter import convert_to_ros_msg, Float64, String from pylabrobot.resources.resource import Resource as ResourcePLR @@ -833,7 +833,7 @@ def post_init(self, ros_node: ROS2WorkstationNode): # 注意:如果有从 Bioyond 同步的物料,它们已经被放置到 warehouse 中了 # 所以只需要上传 deck,物料会作为 warehouse 的 children 一起上传 logger.info("正在上传 deck(包括 warehouses 和物料)到云端...") - ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{ + self._ros_node.run_async_func(self._ros_node.update_resource, True, **{ "resources": [self.deck] }) @@ -843,7 +843,7 @@ def post_init(self, ros_node: ROS2WorkstationNode): self._synced_resources = [] def transfer_resource_to_another(self, resource: List[ResourceSlot], mount_resource: List[ResourceSlot], sites: List[str], mount_device_id: DeviceSlot): - future = ROS2DeviceNode.run_async_func(self._ros_node.transfer_resource_to_another, True, **{ + future = self._ros_node.run_async_func(self._ros_node.transfer_resource_to_another, True, **{ "plr_resources": resource, "target_device_id": mount_device_id, "target_resources": mount_resource, diff --git a/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py b/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py index 91efd45fb..4eea11c11 100644 --- a/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py +++ b/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py @@ -15,7 +15,7 @@ from unilabos.device_comms.modbus_plc.client import TCPClient, ModbusNode, PLCWorkflow, ModbusWorkflow, WorkflowAction, BaseClient from unilabos.device_comms.modbus_plc.modbus import DeviceType, Base as ModbusNodeBase, DataType, WorderOrder from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import * -from unilabos.ros.nodes.base_device_node import ROS2DeviceNode, BaseROS2DeviceNode +from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import CoincellDeck from unilabos.resources.graphio import convert_resources_to_type @@ -194,7 +194,7 @@ def __init__(self, def post_init(self, ros_node: ROS2WorkstationNode): self._ros_node = ros_node #self.deck = create_a_coin_cell_deck() - ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{ + self._ros_node.run_async_func(self._ros_node.update_resource, True, **{ "resources": [self.deck] }) @@ -1403,7 +1403,7 @@ def func_pack_get_msg_cmd(self, file_path: str="D:\\coin_cell_data") -> bool: raise #print(jipian2.parent) - ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{ + self._ros_node.run_async_func(self._ros_node.update_resource, True, **{ "resources": [self.deck] }) @@ -1934,7 +1934,7 @@ def fun_wuliao_test(self) -> bool: } liaopan3.children[i].assign_child_resource(battery, location=None) - ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{ + self._ros_node.run_async_func(self._ros_node.update_resource, True, **{ "resources": [self.deck] }) # for i in range(40): @@ -2149,4 +2149,3 @@ def data_tips_inventory(self) -> int: workstation.func_pack_device_start() workstation.func_pack_send_bottle_num(16) workstation.func_allpack_cmd(elec_num=16, elec_use_num=16, elec_vol=50, assembly_type=7, assembly_pressure=4200, file_path="/Users/calvincao/Desktop/work/Uni-Lab-OS-hhm") - \ No newline at end of file diff --git a/unilabos/devices/workstation/post_process/post_process.py b/unilabos/devices/workstation/post_process/post_process.py index b45cded27..bb6dbbe47 100644 --- a/unilabos/devices/workstation/post_process/post_process.py +++ b/unilabos/devices/workstation/post_process/post_process.py @@ -1715,8 +1715,7 @@ def post_init(self, ros_node): # 2. 上传云端 try: - from unilabos.ros.nodes.base_device_node import ROS2DeviceNode - ROS2DeviceNode.run_async_func( + ros_node.run_async_func( ros_node.update_resource, True, resources=[self.deck] diff --git a/unilabos/devices/workstation/workstation_base.py b/unilabos/devices/workstation/workstation_base.py index 75fd7ea89..c792b2a3c 100644 --- a/unilabos/devices/workstation/workstation_base.py +++ b/unilabos/devices/workstation/workstation_base.py @@ -8,17 +8,18 @@ import collections import time -from typing import Dict, Any, List, Optional, Union +from typing import TYPE_CHECKING, Dict, Any, List, Optional, Union from abc import ABC, abstractmethod from dataclasses import dataclass from enum import Enum from pylabrobot.resources import Deck, Plate, Resource as PLRResource from pylabrobot.resources.coordinate import Coordinate -from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode - from unilabos.utils.log import logger +if TYPE_CHECKING: + from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode + class WorkflowStatus(Enum): """工作流状态""" @@ -136,7 +137,7 @@ class WorkstationBase(ABC): 3. 简化的工作流管理 """ - _ros_node: ROS2WorkstationNode + _ros_node: "ROS2WorkstationNode" @property def _children(self) -> Dict[str, Any]: # 不要删除这个下划线,不然会自动导入注册表,后面改成装饰器识别 @@ -168,7 +169,7 @@ def __init__( # 支持的工作流(静态预定义) self.supported_workflows: Dict[str, WorkflowInfo] = {} - def post_init(self, ros_node: ROS2WorkstationNode) -> None: + def post_init(self, ros_node: "ROS2WorkstationNode") -> None: # 初始化物料系统 self._ros_node = ros_node diff --git a/unilabos/dora/main_dora_run.py b/unilabos/dora/main_dora_run.py index 9e1c0c4c2..bb4b01da0 100644 --- a/unilabos/dora/main_dora_run.py +++ b/unilabos/dora/main_dora_run.py @@ -6,7 +6,7 @@ from __future__ import annotations -import os +import importlib.util import tempfile import time from typing import Any, Dict, List, Optional @@ -16,6 +16,22 @@ from unilabos.utils import logger +def validate_environment() -> None: + """Dora 依赖缺失时,在 backend 线程启动前直接失败。""" + + missing = [] + if runtime.dora_binary() is None: + missing.append("dora CLI(执行 `cargo install dora-cli`)") + if importlib.util.find_spec("dora") is None: + missing.append("dora Python API(安装 `dora-rs`)") + if importlib.util.find_spec("pyarrow") is None: + missing.append("pyarrow") + if missing: + raise RuntimeError( + "Dora backend 缺少依赖:" + ",".join(missing) + ) + + def _resolve_devices(devices_config) -> List[Dict[str, Any]]: """从 ResourceTreeSet 解析出 dora 设备清单(含驱动 module:Class)。""" from unilabos.registry.registry import lab_registry diff --git a/unilabos/dora/runtime.py b/unilabos/dora/runtime.py index db25d43bf..3e0aa2e76 100644 --- a/unilabos/dora/runtime.py +++ b/unilabos/dora/runtime.py @@ -34,7 +34,10 @@ def dora_binary() -> Optional[str]: def _require_binary() -> str: binary = dora_binary() if binary is None: - raise RuntimeError("未找到 dora CLI,请在 unilab 环境执行 `pip install dora-rs-cli`。") + raise RuntimeError( + "未找到 dora CLI;请执行 `cargo install dora-cli`," + "或使用 Dora 官方平台安装脚本。" + ) return binary diff --git a/unilabos/hostlink/__init__.py b/unilabos/hostlink/__init__.py index 4611cfca4..0e66a435f 100644 --- a/unilabos/hostlink/__init__.py +++ b/unilabos/hostlink/__init__.py @@ -1,10 +1,12 @@ -"""Host/Slave ROS2 networking control channel.""" +"""Host/Slave control channel and optional no-ROS distributed backend.""" +from unilabos.hostlink.backend import HostLinkBackendRuntime from unilabos.hostlink.client import HostLinkClient, get_hostlink_client from unilabos.hostlink.server import HostLinkServer, get_hostlink_server __all__ = [ "HostLinkClient", + "HostLinkBackendRuntime", "HostLinkServer", "get_hostlink_client", "get_hostlink_server", diff --git a/unilabos/hostlink/backend.py b/unilabos/hostlink/backend.py new file mode 100644 index 000000000..c6619b329 --- /dev/null +++ b/unilabos/hostlink/backend.py @@ -0,0 +1,1171 @@ +"""由 Basic 驱动运行时与 HostLink 组成的无 ROS 分布式 backend。""" + +from __future__ import annotations + +import asyncio +import threading +from concurrent.futures import ThreadPoolExecutor +from typing import Any, Dict, Optional + +from unilabos.basic.runtime import BasicRuntime +from unilabos.config.config import BasicConfig, HostLinkConfig +from unilabos.device_runtime.action import ActionCancelled, ActionContext +from unilabos.device_runtime.resource import LocalResourceService, ResourceStore +from unilabos.device_runtime.service import normalize_service_name +from unilabos.device_runtime.topic import ( + TopicEvent, + message_to_value, + normalize_topic, +) +from unilabos.hostlink.client import HostLinkClient, set_hostlink_client +from unilabos.hostlink.protocol import ActionType, LinkError +from unilabos.hostlink.resource import HostLinkResourceService +from unilabos.hostlink.server import HostLinkServer, set_hostlink_server +from unilabos.resources.resource_tracker import ResourceTreeSet +from unilabos.utils import logger + + +def to_wire_value(value: Any) -> Any: + """Convert driver values, including ROS messages, for HostLink JSON.""" + + return message_to_value(value) + + +class HostLinkBackendRuntime: + """Run local Python drivers and expose Slave devices to one Host.""" + + def __init__( + self, + local: BasicRuntime, + *, + is_slave: bool, + resources_config: Optional[ResourceTreeSet] = None, + ) -> None: + self.local = local + self.is_slave = bool(is_slave) + self.resource_store = ResourceStore(resources_config) + self.server: Optional[HostLinkServer] = None + self.client: Optional[HostLinkClient] = None + self._started = False + self._actions: Dict[str, tuple[str, ActionContext]] = {} + self._action_callers: Dict[str, str] = {} + self._actions_lock = threading.Lock() + self._remote_topic_subscriptions: Dict[str, set[str]] = {} + self._remote_topic_lock = threading.Lock() + self._io_executor = ThreadPoolExecutor( + max_workers=2, + thread_name_prefix="hostlink-backend-io", + ) + self._topic_executor = ThreadPoolExecutor( + max_workers=1, + thread_name_prefix="hostlink-backend-topic", + ) + if not self.is_slave: + self.local.set_resource_service(LocalResourceService(self.resource_store)) + for node in self.local.devices.values(): + node.set_action_router(self) + node.set_service_bus(self) + self.local.topic_bus.add_outbound_listener(self._on_local_topic) + self.local.topic_bus.add_subscription_listener( + self._on_local_subscription_change + ) + + def start(self) -> None: + if self._started: + return + if not HostLinkConfig.enable: + raise ValueError("hostlink backend 不能与 --disable-hostlink 同时使用") + try: + if self.is_slave: + self._start_slave() + self.local.start() + if self.client is not None: + self.client.configure_device_descriptors(self.local.descriptors()) + self._connect_slave() + else: + self.local.start() + self._start_host() + except Exception: + self.stop() + raise + self._started = True + + def _start_host(self) -> None: + self.server = HostLinkServer( + bind=HostLinkConfig.bind, + port=HostLinkConfig.port, + heartbeat_timeout=HostLinkConfig.heartbeat_timeout, + request_timeout=HostLinkConfig.request_timeout, + ) + self.server.hello_payload = { + "backend": "hostlink", + "role": "host", + "devices": self.local.descriptors(), + } + self.server.register_handler( + ActionType.ACTION_FEEDBACK, + self._handle_action_feedback, + ) + self.server.register_handler( + ActionType.ACTION_CANCEL, + self._handle_peer_action_cancel, + ) + self.server.register_handler( + ActionType.RESOURCE_UPDATE, + self._handle_resource_update, + ) + self.server.register_handler( + ActionType.RESOURCE_GET, + self._handle_resource_get, + ) + self.server.register_handler( + ActionType.DEVICE_CALL, + self._handle_peer_device_call, + ) + self.server.register_handler( + ActionType.SERVICE_CALL, + self._handle_peer_service_call, + ) + self.server.register_handler( + ActionType.TOPIC_PUBLISH, + self._handle_topic_publish, + ) + self.server.register_handler( + ActionType.TOPIC_SUBSCRIBE, + self._handle_topic_subscribe, + ) + self.server.register_handler( + ActionType.TOPIC_UNSUBSCRIBE, + self._handle_topic_unsubscribe, + ) + self.server.start() + set_hostlink_server(self.server) + logger.info( + "[HostLink backend] Host 已启动:%s:%d,本地设备=%s", + HostLinkConfig.bind, + self.server.port, + sorted(self.local.devices), + ) + + def _start_slave(self) -> None: + host = str(HostLinkConfig.host or "").strip() + if not host: + raise ValueError( + "hostlink backend 的 Slave 必须通过 --host-node-ip 指定 Host" + ) + self.client = HostLinkClient( + host=host, + port=HostLinkConfig.port, + machine_name=BasicConfig.machine_name, + heartbeat_interval=HostLinkConfig.heartbeat_interval, + connect_timeout=HostLinkConfig.connect_timeout, + request_timeout=HostLinkConfig.request_timeout, + device_descriptors=self.local.descriptors(), + heartbeat_payload_provider=self._heartbeat_payload, + on_status_change=self._on_client_status_change, + ) + self.client.register_handler(ActionType.DEVICE_CALL, self._handle_device_call) + self.client.register_handler( + ActionType.SERVICE_CALL, + self._handle_service_call, + ) + self.client.register_handler( + ActionType.DEVICE_STATE, + self._handle_device_state, + ) + self.client.register_handler( + ActionType.ACTION_CANCEL, + self._handle_action_cancel, + ) + self.client.register_handler( + ActionType.ACTION_FEEDBACK, + self._handle_incoming_action_feedback, + ) + self.client.register_handler( + ActionType.TOPIC_DELIVER, + self._handle_topic_deliver, + ) + self.local.set_resource_service(HostLinkResourceService(self.client)) + for node in self.local.devices.values(): + node.add_status_listener(self._on_local_status) + set_hostlink_client(self.client) + + def _connect_slave(self) -> None: + client = self.client + if client is None: + raise RuntimeError("HostLink Slave client 尚未创建") + host = str(HostLinkConfig.host or "").strip() + if BasicConfig.slave_no_host: + client.start() + elif not client.connect_blocking(HostLinkConfig.connect_timeout): + raise LinkError(f"无法连接 HostLink Host:{host}:{HostLinkConfig.port}") + else: + self._sync_initial_resources() + logger.info( + "[HostLink backend] Slave 已启动:Host=%s:%d,本地设备=%s", + host, + HostLinkConfig.port, + sorted(self.local.devices), + ) + + def register_service( + self, + name: str, + callback: Any, + *, + owner_device_id: str, + ) -> None: + self.local.service_bus.register_service( + name, + callback, + owner_device_id=owner_device_id, + ) + + def unregister_service( + self, + name: str, + *, + owner_device_id: str, + ) -> None: + self.local.service_bus.unregister_service( + name, + owner_device_id=owner_device_id, + ) + + @staticmethod + def _service_target(name: str) -> str: + parts = normalize_service_name(name).strip("/").split("/") + if len(parts) >= 3 and parts[0] == "devices": + return parts[1] + return "" + + def has_service(self, name: str) -> bool: + normalized = normalize_service_name(name) + if self.local.service_bus.has_service(normalized): + return True + target = self._service_target(normalized) + if not target: + return False + if self.server is not None: + remote = self.server.devices(online_only=True).get(target) + descriptor = (remote or {}).get("device") or {} + return normalized in descriptor.get("services", []) + if self.client is not None: + for descriptor in self.client.hello_info.get("devices") or []: + if descriptor.get("id") == target: + return normalized in descriptor.get("services", []) + return False + + def call_service( + self, + name: str, + request: Any, + *, + caller_device_id: str = "", + timeout: Optional[float] = None, + ) -> Any: + normalized = normalize_service_name(name) + if self.local.service_bus.has_service(normalized): + return self.local.service_bus.call_service( + normalized, + request, + caller_device_id=caller_device_id, + timeout=timeout, + ) + target = self._service_target(normalized) + if not target: + raise KeyError(f"未知 HostLink service:{normalized}") + payload = { + "caller_device_id": str(caller_device_id), + "service": normalized, + "request": to_wire_value(request), + } + if self.server is not None: + response = self.server.request_device( + target, + ActionType.SERVICE_CALL, + payload, + timeout, + ) + elif self.client is not None: + response = self.client.request( + ActionType.SERVICE_CALL, + payload, + timeout, + ) + else: + raise KeyError(f"未知 HostLink service:{normalized}") + return response.get("response") if isinstance(response, dict) else response + + async def call_service_async( + self, + name: str, + request: Any, + *, + caller_device_id: str = "", + timeout: Optional[float] = None, + ) -> Any: + normalized = normalize_service_name(name) + if self.local.service_bus.has_service(normalized): + return await self.local.service_bus.call_service_async( + normalized, + request, + caller_device_id=caller_device_id, + timeout=timeout, + ) + target = self._service_target(normalized) + if not target: + raise KeyError(f"未知 HostLink service:{normalized}") + payload = { + "caller_device_id": str(caller_device_id), + "service": normalized, + "request": to_wire_value(request), + } + if self.server is not None: + response = await self.server.request_device_async( + target, + ActionType.SERVICE_CALL, + payload, + timeout, + ) + elif self.client is not None: + response = await self.client.request_async( + ActionType.SERVICE_CALL, + payload, + timeout, + ) + else: + raise KeyError(f"未知 HostLink service:{normalized}") + return response.get("response") if isinstance(response, dict) else response + + def _sync_initial_resources(self) -> None: + client = self.client + if client is None or not client.online: + return + device_ids = sorted(self.local.devices) + resource_uuids = [ + node.resource_uuid + for node in self.local.devices.values() + if node.resource_uuid + ] + resources = self.resource_store.get_resources(resource_uuids) + if not resources.trees: + return + client.request( + ActionType.RESOURCE_UPDATE, + { + "device_ids": device_ids, + "resources": resources.dump(), + "initial": True, + }, + ) + + def _heartbeat_payload(self) -> Dict[str, Any]: + return {"states": to_wire_value(self.local.snapshot_states())} + + def _on_client_status_change(self, online: bool) -> None: + if not online: + return + try: + self._topic_executor.submit(self._register_topic_subscriptions) + except RuntimeError: + pass + + def _register_topic_subscriptions(self) -> None: + client = self.client + if client is None or not client.online: + return + for topic in self.local.topic_bus.subscribed_topics(): + try: + client.request(ActionType.TOPIC_SUBSCRIBE, {"topic": topic}) + except LinkError: + return + + def _on_local_subscription_change(self, topic: str, subscribed: bool) -> None: + if not self.is_slave: + return + client = self.client + if client is None or not client.online: + return + action_type = ( + ActionType.TOPIC_SUBSCRIBE if subscribed else ActionType.TOPIC_UNSUBSCRIBE + ) + try: + self._topic_executor.submit( + client.request, + action_type, + {"topic": topic}, + ) + except RuntimeError: + pass + + def _on_local_topic(self, event: TopicEvent) -> None: + try: + if self.is_slave: + self._topic_executor.submit(self._send_topic_to_host, event) + else: + self._topic_executor.submit(self._forward_topic_to_slaves, event) + except RuntimeError: + pass + + def _send_topic_to_host(self, event: TopicEvent) -> None: + client = self.client + if client is None or not client.online: + return + try: + client.request(ActionType.TOPIC_PUBLISH, {"event": event.to_wire()}) + except LinkError: + logger.debug( + "[HostLink backend] topic publish failed while offline: %s", + event.topic, + ) + + def _forward_topic_to_slaves( + self, + event: TopicEvent, + *, + exclude_node_id: str = "", + ) -> None: + server = self.server + if server is None: + return + with self._remote_topic_lock: + interested = { + node_id + for node_id, topics in self._remote_topic_subscriptions.items() + if event.topic in topics and node_id != exclude_node_id + } + if not interested: + return + peers = { + str(peer.get("node_id") or ""): peer + for peer in server.peers() + if peer.get("online") + } + for node_id in interested: + peer = peers.get(node_id) + if peer is None: + continue + try: + server.request_peer( + str(peer["addr"]), + ActionType.TOPIC_DELIVER, + {"event": event.to_wire()}, + ) + except LinkError: + logger.debug( + "[HostLink backend] topic delivery failed: %s -> %s", + event.topic, + node_id, + ) + + @staticmethod + def _topic_from_data(data: Dict[str, Any]) -> str: + return normalize_topic(str(data.get("topic") or "")) + + def _handle_topic_subscribe( + self, + data: Dict[str, Any], + peer: Dict[str, Any], + ) -> Dict[str, Any]: + topic = self._topic_from_data(data) + node_id = str(peer.get("node_id") or "") + if not node_id: + raise PermissionError("HostLink topic subscriber 缺少 Slave 身份") + with self._remote_topic_lock: + self._remote_topic_subscriptions.setdefault(node_id, set()).add(topic) + return {"accepted": True, "topic": topic} + + def _handle_topic_unsubscribe( + self, + data: Dict[str, Any], + peer: Dict[str, Any], + ) -> Dict[str, Any]: + topic = self._topic_from_data(data) + node_id = str(peer.get("node_id") or "") + with self._remote_topic_lock: + topics = self._remote_topic_subscriptions.get(node_id) + if topics is not None: + topics.discard(topic) + if not topics: + self._remote_topic_subscriptions.pop(node_id, None) + return {"accepted": True, "topic": topic} + + def _handle_topic_publish( + self, + data: Dict[str, Any], + peer: Dict[str, Any], + ) -> Dict[str, Any]: + raw_event = data.get("event") + if not isinstance(raw_event, dict): + raise TypeError("topic.publish requires event") + event = TopicEvent.from_wire(raw_event) + owned = {str(item) for item in peer.get("device_ids") or []} + if event.publisher_device_id not in owned: + raise PermissionError( + f"Slave 未注册 topic 发布设备:{event.publisher_device_id!r}" + ) + self.local.topic_bus.publish(event, forward=False) + try: + self._topic_executor.submit( + self._forward_topic_to_slaves, + event, + exclude_node_id=str(peer.get("node_id") or ""), + ) + except RuntimeError: + pass + return { + "accepted": True, + "topic": event.topic, + "message_id": event.message_id, + } + + def _handle_topic_deliver(self, data: Dict[str, Any]) -> Dict[str, Any]: + raw_event = data.get("event") + if not isinstance(raw_event, dict): + raise TypeError("topic.deliver requires event") + event = TopicEvent.from_wire(raw_event) + self.local.topic_bus.publish(event, forward=False) + return { + "accepted": True, + "topic": event.topic, + "message_id": event.message_id, + } + + def _on_local_status(self, device_id: str, name: str, value: Any) -> None: + client = self.client + if client is None or not client.online: + return + try: + self._io_executor.submit( + self._publish_status, + client, + device_id, + name, + value, + ) + except RuntimeError: + pass + + @staticmethod + def _publish_status( + client: HostLinkClient, + device_id: str, + name: str, + value: Any, + ) -> None: + try: + client.request( + ActionType.DEVICE_STATE, + { + "device_id": device_id, + "state": {name: to_wire_value(value)}, + }, + ) + except LinkError: + # 心跳会在重连后补发完整状态,这里不重放单字段通知。 + pass + + def _handle_service_call(self, data: Dict[str, Any]) -> Dict[str, Any]: + name = normalize_service_name(str(data.get("service") or "")) + if not self.local.service_bus.has_service(name): + raise KeyError(f"当前 Slave 没有 service:{name}") + result = self.local.service_bus.call_service( + name, + data.get("request"), + caller_device_id=str(data.get("caller_device_id") or ""), + ) + return {"service": name, "response": to_wire_value(result)} + + def _handle_peer_service_call( + self, + data: Dict[str, Any], + peer: Dict[str, Any], + ) -> Dict[str, Any]: + caller_device_id = str(data.get("caller_device_id") or "").strip() + owned = {str(item) for item in peer.get("device_ids") or []} + if caller_device_id not in owned: + raise PermissionError( + f"Slave 未注册 service 调用设备:{caller_device_id!r}" + ) + name = normalize_service_name(str(data.get("service") or "")) + result = self.call_service( + name, + data.get("request"), + caller_device_id=caller_device_id, + ) + return {"service": name, "response": to_wire_value(result)} + + def _handle_device_call(self, data: Dict[str, Any]) -> Dict[str, Any]: + device_id = str(data.get("device_id") or "").strip() + action = str(data.get("action") or "").strip() + arguments = data.get("arguments") + if not device_id or not action: + raise ValueError("device.call requires device_id and action") + if arguments is None: + arguments = {} + if not isinstance(arguments, dict): + raise TypeError("device.call arguments must be an object") + context = ActionContext( + action_id=str(data.get("action_id") or "") or ActionContext().action_id, + feedback_callback=self._send_action_feedback, + ) + with self._actions_lock: + self._actions[context.action_id] = (device_id, context) + try: + result = self.local.call_action( + device_id, + action, + action_context=context, + **arguments, + ) + status = "succeeded" + except ActionCancelled: + result = None + status = "cancelled" + finally: + with self._actions_lock: + self._actions.pop(context.action_id, None) + return { + "device_id": device_id, + "action": action, + "action_id": context.action_id, + "status": status, + "result": to_wire_value(result), + "state": to_wire_value(self.local.devices[device_id].snapshot_status()), + } + + def _handle_peer_device_call( + self, + data: Dict[str, Any], + peer: Dict[str, Any], + ) -> Dict[str, Any]: + caller_device_id = str(data.get("caller_device_id") or "").strip() + owned = {str(item) for item in peer.get("device_ids") or []} + if caller_device_id not in owned: + raise PermissionError(f"Slave 未注册调用设备:{caller_device_id!r}") + device_id = str(data.get("device_id") or "").strip() + action = str(data.get("action") or "").strip() + arguments = data.get("arguments") + if not device_id or not action: + raise ValueError("device.call requires device_id and action") + if arguments is None: + arguments = {} + if not isinstance(arguments, dict): + raise TypeError("device.call arguments must be an object") + action_id = str(data.get("action_id") or "") or ActionContext().action_id + peer_addr = str(peer.get("addr") or "") + + def forward_feedback( + feedback_action_id: str, + feedback: Dict[str, Any], + ) -> None: + server = self.server + if server is None or not peer_addr: + return + try: + server.request_peer( + peer_addr, + ActionType.ACTION_FEEDBACK, + { + "action_id": feedback_action_id, + "device_id": device_id, + "feedback": to_wire_value(feedback), + }, + ) + except LinkError: + logger.warning( + "[HostLink backend] Action feedback 转发失败:%s", + feedback_action_id, + ) + + context = ActionContext( + action_id=action_id, + feedback_callback=forward_feedback, + ) + try: + result = self.call_action( + device_id, + action, + action_context=context, + **arguments, + ) + status = "succeeded" + except ActionCancelled: + result = None + status = "cancelled" + return { + "device_id": device_id, + "action": action, + "action_id": action_id, + "status": status, + "result": to_wire_value(result), + } + + def _send_action_feedback( + self, + action_id: str, + feedback: Dict[str, Any], + ) -> None: + client = self.client + if client is None: + return + with self._actions_lock: + active = self._actions.get(action_id) + device_id = active[0] if active is not None else "" + try: + client.request( + ActionType.ACTION_FEEDBACK, + { + "action_id": action_id, + "device_id": device_id, + "feedback": to_wire_value(feedback), + }, + ) + except LinkError: + logger.warning( + "[HostLink backend] Action feedback 发送失败:%s", + action_id, + ) + + def _handle_action_feedback( + self, + data: Dict[str, Any], + _peer: Dict[str, Any], + ) -> Dict[str, Any]: + return self._deliver_action_feedback(data) + + def _handle_incoming_action_feedback( + self, + data: Dict[str, Any], + ) -> Dict[str, Any]: + return self._deliver_action_feedback(data) + + def _deliver_action_feedback( + self, + data: Dict[str, Any], + ) -> Dict[str, Any]: + action_id = str(data.get("action_id") or "") + with self._actions_lock: + active = self._actions.get(action_id) + if active is None: + return {"accepted": False, "action_id": action_id} + feedback = data.get("feedback") + try: + active[1].publish_feedback(feedback if isinstance(feedback, dict) else {}) + except Exception: # noqa: BLE001 - feedback 回调不能中断远端动作 + logger.exception( + "[HostLink backend] Action feedback 回调失败:%s", + action_id, + ) + return {"accepted": True, "action_id": action_id} + + def _handle_action_cancel(self, data: Dict[str, Any]) -> Dict[str, Any]: + action_id = str(data.get("action_id") or "") + with self._actions_lock: + active = self._actions.get(action_id) + if active is None: + return {"accepted": False, "action_id": action_id} + active[1].request_cancel() + return {"accepted": True, "action_id": action_id} + + def _handle_peer_action_cancel( + self, + data: Dict[str, Any], + peer: Dict[str, Any], + ) -> Dict[str, Any]: + caller_device_id = str(data.get("caller_device_id") or "") + owned = {str(item) for item in peer.get("device_ids") or []} + if caller_device_id not in owned: + raise PermissionError( + f"Slave 未注册取消动作的调用设备:{caller_device_id!r}" + ) + action_id = str(data.get("action_id") or "") + return { + "accepted": self.cancel_action(action_id), + "action_id": action_id, + } + + @staticmethod + def _validate_resource_owner( + data: Dict[str, Any], + peer: Dict[str, Any], + ) -> None: + claimed = { + str(device_id) + for device_id in data.get("device_ids") or [] + if str(device_id) + } + device_id = str(data.get("device_id") or "").strip() + if device_id: + claimed.add(device_id) + owned = {str(item) for item in peer.get("device_ids") or []} + if not claimed or not claimed.issubset(owned): + raise PermissionError( + f"Slave 只能更新自己注册的设备物料:{sorted(claimed)}" + ) + + def _handle_resource_update( + self, + data: Dict[str, Any], + peer: Dict[str, Any], + ) -> Dict[str, Any]: + self._validate_resource_owner(data, peer) + raw_resources = data.get("resources") + if not isinstance(raw_resources, list): + raise TypeError("resource.update requires resources") + tree_set = ResourceTreeSet.load(raw_resources) + uuid_mapping = self.resource_store.apply_update(tree_set) + return { + "updated_trees": len(tree_set.trees), + "updated_nodes": len(tree_set.all_nodes), + "uuid_mapping": uuid_mapping, + } + + def _handle_resource_get( + self, + data: Dict[str, Any], + _peer: Dict[str, Any], + ) -> Dict[str, Any]: + raw_uuids = data.get("resources_uuid") + if not isinstance(raw_uuids, list): + raise TypeError("resource.get requires resources_uuid") + resources = self.resource_store.get_resources( + [str(item) for item in raw_uuids], + with_children=bool(data.get("with_children", True)), + ) + return {"resources": resources.dump()} + + def _handle_device_state(self, data: Dict[str, Any]) -> Dict[str, Any]: + device_id = str(data.get("device_id") or "").strip() + states = self.local.snapshot_states() + if not device_id: + return {"states": to_wire_value(states)} + if device_id not in states: + raise KeyError(f"未知 HostLink 设备:{device_id}") + return {"device_id": device_id, "state": to_wire_value(states[device_id])} + + def call_action( + self, + device_id: str, + action_name: str, + *, + action_context: Optional[ActionContext] = None, + **kwargs: Any, + ) -> Any: + """Call a local device, or route a Host call to an online Slave.""" + + device_id = str(device_id) + context = action_context or ActionContext() + if device_id in self.local.devices: + with self._actions_lock: + self._actions[context.action_id] = (device_id, context) + try: + return self.local.call_action( + device_id, + action_name, + action_context=context, + **kwargs, + ) + finally: + with self._actions_lock: + self._actions.pop(context.action_id, None) + if self.server is None: + raise KeyError(f"未知 HostLink 设备:{device_id}") + with self._actions_lock: + self._actions[context.action_id] = (device_id, context) + try: + response = self.server.call_device( + device_id, + action_name, + kwargs, + action_id=context.action_id, + ) + finally: + with self._actions_lock: + self._actions.pop(context.action_id, None) + if isinstance(response, dict) and response.get("status") == "cancelled": + context.request_cancel() + raise ActionCancelled(f"action cancelled: {context.action_id}") + if isinstance(response, dict) and "result" in response: + return response["result"] + return response + + async def call_action_async( + self, + device_id: str, + action_name: str, + *, + action_context: Optional[ActionContext] = None, + request_timeout: Optional[float] = None, + **kwargs: Any, + ) -> Any: + """Await a local or remote device action without blocking a thread.""" + + device_id = str(device_id) + context = action_context or ActionContext() + with self._actions_lock: + self._actions[context.action_id] = (device_id, context) + try: + if device_id in self.local.devices: + return await self.local.call_action_async( + device_id, + action_name, + action_context=context, + **kwargs, + ) + if self.server is None: + raise KeyError(f"未知 HostLink 设备:{device_id}") + response = await self.server.call_device_async( + device_id, + action_name, + kwargs, + timeout=request_timeout, + action_id=context.action_id, + ) + except asyncio.CancelledError: + context.request_cancel() + try: + await asyncio.shield(self.cancel_action_async(context.action_id)) + except Exception as exc: # noqa: BLE001 - cancellation must propagate + logger.warning( + "[HostLink backend] 异步动作取消转发失败:%s (%s)", + context.action_id, + exc, + ) + raise + finally: + with self._actions_lock: + self._actions.pop(context.action_id, None) + if isinstance(response, dict) and response.get("status") == "cancelled": + context.request_cancel() + raise ActionCancelled(f"action cancelled: {context.action_id}") + if isinstance(response, dict) and "result" in response: + return response["result"] + return response + + def route_action( + self, + caller_device_id: str, + device_id: str, + action_name: str, + arguments: Optional[Dict[str, Any]] = None, + **options: Any, + ) -> Any: + target = self.local._normalize_device_id(device_id) + if target == caller_device_id: + raise ValueError("跨设备动作不能回调当前设备自身") + context = options.get("action_context") + if context is None and ( + options.get("action_id") or options.get("feedback_callback") + ): + context = ActionContext( + action_id=str(options.get("action_id") or "") + or ActionContext().action_id, + feedback_callback=options.get("feedback_callback"), + ) + if target in self.local.devices or self.server is not None: + return self.call_action( + target, + action_name, + action_context=context, + **dict(arguments or {}), + ) + client = self.client + if client is None: + raise KeyError(f"未知 HostLink 设备:{target}") + context = context or ActionContext() + with self._actions_lock: + self._actions[context.action_id] = (target, context) + self._action_callers[context.action_id] = caller_device_id + try: + response = client.request( + ActionType.DEVICE_CALL, + { + "caller_device_id": caller_device_id, + "device_id": target, + "action": action_name, + "arguments": dict(arguments or {}), + "action_id": context.action_id, + }, + timeout=options.get("timeout"), + ) + finally: + with self._actions_lock: + self._actions.pop(context.action_id, None) + self._action_callers.pop(context.action_id, None) + if isinstance(response, dict) and response.get("status") == "cancelled": + context.request_cancel() + raise ActionCancelled(f"action cancelled: {context.action_id}") + if isinstance(response, dict) and "result" in response: + return response["result"] + return response + + async def route_action_async( + self, + caller_device_id: str, + device_id: str, + action_name: str, + arguments: Optional[Dict[str, Any]] = None, + **options: Any, + ) -> Any: + target = self.local._normalize_device_id(device_id) + if target == caller_device_id: + raise ValueError("跨设备动作不能回调当前设备自身") + context = options.get("action_context") + if context is None and ( + options.get("action_id") or options.get("feedback_callback") + ): + context = ActionContext( + action_id=str(options.get("action_id") or "") + or ActionContext().action_id, + feedback_callback=options.get("feedback_callback"), + ) + if target in self.local.devices or self.server is not None: + return await self.call_action_async( + target, + action_name, + action_context=context, + request_timeout=options.get("timeout"), + **dict(arguments or {}), + ) + client = self.client + if client is None: + raise KeyError(f"未知 HostLink 设备:{target}") + context = context or ActionContext() + with self._actions_lock: + self._actions[context.action_id] = (target, context) + self._action_callers[context.action_id] = caller_device_id + try: + response = await client.request_async( + ActionType.DEVICE_CALL, + { + "caller_device_id": caller_device_id, + "device_id": target, + "action": action_name, + "arguments": dict(arguments or {}), + "action_id": context.action_id, + }, + timeout=options.get("timeout"), + ) + except asyncio.CancelledError: + context.request_cancel() + try: + await asyncio.shield(self.cancel_action_async(context.action_id)) + except Exception as exc: # noqa: BLE001 - cancellation must propagate + logger.warning( + "[HostLink backend] 异步动作取消转发失败:%s (%s)", + context.action_id, + exc, + ) + raise + finally: + with self._actions_lock: + self._actions.pop(context.action_id, None) + self._action_callers.pop(context.action_id, None) + if isinstance(response, dict) and response.get("status") == "cancelled": + context.request_cancel() + raise ActionCancelled(f"action cancelled: {context.action_id}") + if isinstance(response, dict) and "result" in response: + return response["result"] + return response + + def cancel_action(self, action_id: str) -> bool: + with self._actions_lock: + active = self._actions.get(str(action_id)) + if active is None: + return False + device_id, context = active + context.request_cancel() + if self.server is not None and device_id not in self.local.devices: + response = self.server.cancel_device_action(device_id, context.action_id) + return bool((response or {}).get("accepted")) + client = self.client + if client is not None and device_id not in self.local.devices: + with self._actions_lock: + caller_device_id = self._action_callers.get(context.action_id, "") + response = client.request( + ActionType.ACTION_CANCEL, + { + "caller_device_id": caller_device_id, + "device_id": device_id, + "action_id": context.action_id, + }, + ) + return bool((response or {}).get("accepted")) + return True + + async def cancel_action_async(self, action_id: str) -> bool: + """Cancel an active action using the same non-blocking RPC path.""" + + with self._actions_lock: + active = self._actions.get(str(action_id)) + if active is None: + return False + device_id, context = active + context.request_cancel() + if self.server is not None and device_id not in self.local.devices: + response = await self.server.cancel_device_action_async( + device_id, + context.action_id, + ) + return bool((response or {}).get("accepted")) + client = self.client + if client is not None and device_id not in self.local.devices: + with self._actions_lock: + caller_device_id = self._action_callers.get( + context.action_id, + "", + ) + response = await client.request_async( + ActionType.ACTION_CANCEL, + { + "caller_device_id": caller_device_id, + "device_id": device_id, + "action_id": context.action_id, + }, + ) + return bool((response or {}).get("accepted")) + return True + + def devices(self, online_only: bool = True) -> Dict[str, Dict[str, Any]]: + result = { + item["id"]: { + "device": item, + "state": to_wire_value( + self.local.devices[item["id"]].snapshot_status() + ), + "location": "local", + "online": True, + } + for item in self.local.descriptors() + } + if self.server is not None: + for device_id, peer in self.server.devices(online_only).items(): + remote = dict(peer) + remote["location"] = "remote" + result.setdefault(device_id, remote) + return result + + def stop(self) -> None: + self.local.topic_bus.remove_outbound_listener(self._on_local_topic) + self.local.topic_bus.remove_subscription_listener( + self._on_local_subscription_change + ) + for node in self.local.devices.values(): + node.remove_status_listener(self._on_local_status) + client, self.client = self.client, None + if client is not None: + client.close() + set_hostlink_client(None) + server, self.server = self.server, None + if server is not None: + server.stop() + set_hostlink_server(None) + self.local.stop() + self._io_executor.shutdown(wait=False, cancel_futures=True) + self._topic_executor.shutdown(wait=False, cancel_futures=True) + self._started = False + + +__all__ = ["HostLinkBackendRuntime", "to_wire_value"] diff --git a/unilabos/hostlink/client.py b/unilabos/hostlink/client.py index 24e9ab164..00fb54521 100644 --- a/unilabos/hostlink/client.py +++ b/unilabos/hostlink/client.py @@ -1,11 +1,18 @@ -"""Slave-side HostLink client for discovery and ROS2 configuration sync.""" +"""Slave-side HostLink client for discovery, state sync and bidirectional RPC.""" from __future__ import annotations +import asyncio import socket import threading import time import uuid +from concurrent.futures import ( + Future, + InvalidStateError, + ThreadPoolExecutor, + TimeoutError as FutureTimeoutError, +) from typing import Any, Callable, Dict, Iterable, List, Optional from unilabos.hostlink.protocol import ( @@ -15,6 +22,7 @@ PROTOCOL_VERSION, RemoteError, new_request, + new_response, read_message, send_message, ) @@ -23,11 +31,30 @@ class _Pending: - __slots__ = ("event", "response") + """One response shared by blocking and asyncio callers.""" + + __slots__ = ("future",) def __init__(self) -> None: - self.event = threading.Event() - self.response: Optional[Dict[str, Any]] = None + self.future: Future[Dict[str, Any]] = Future() + + def resolve(self, response: Dict[str, Any]) -> None: + if self.future.done(): + return + try: + self.future.set_result(response) + except InvalidStateError: + # asyncio timeout/cancellation may win the race with the reader. + pass + + def wait(self, timeout: Optional[float]) -> Dict[str, Any]: + return self.future.result(timeout=timeout) + + async def wait_async(self, timeout: Optional[float]) -> Dict[str, Any]: + wrapped = asyncio.wrap_future(self.future) + if timeout is None: + return await wrapped + return await asyncio.wait_for(wrapped, timeout) class HostLinkClient: @@ -44,6 +71,8 @@ def __init__( request_timeout: float = 10.0, reconnect_max_backoff: float = 10.0, on_status_change: Optional[Callable[[bool], None]] = None, + device_descriptors: Optional[Iterable[Dict[str, Any]]] = None, + heartbeat_payload_provider: Optional[Callable[[], Dict[str, Any]]] = None, ) -> None: if not str(host or "").strip(): raise ValueError("HostLink host cannot be empty") @@ -55,11 +84,21 @@ def __init__( self.request_timeout = float(request_timeout) self.reconnect_max_backoff = float(reconnect_max_backoff) self.on_status_change = on_status_change + self.heartbeat_payload_provider = heartbeat_payload_provider self.node_id = self.machine_name or f"slave-{uuid.uuid4().hex}" self.device_ids: List[str] = [] + self.device_descriptors: List[Dict[str, Any]] = [] self.configure_device_ids(device_ids or []) - self.capabilities = ["device-discovery", "ros-assist"] + self.configure_device_descriptors(device_descriptors or []) + self.capabilities = [ + "device-discovery", + "ros-assist", + "device-rpc", + "service-rpc", + "topic-pubsub", + ] self.hello_info: Dict[str, Any] = {} + self.handlers: Dict[str, Callable[[Dict[str, Any]], Any]] = {} self._sock: Optional[socket.socket] = None self._manager_thread: Optional[threading.Thread] = None @@ -71,6 +110,10 @@ def __init__( self._connection_lost = threading.Event() self._online = threading.Event() self._status_condition = threading.Condition() + self._rpc_executor = ThreadPoolExecutor( + max_workers=8, + thread_name_prefix="hostlink-slave-rpc", + ) def start(self) -> "HostLinkClient": if self._manager_thread is not None and self._manager_thread.is_alive(): @@ -105,6 +148,7 @@ def close(self) -> None: if self._manager_thread is not None and self._manager_thread.is_alive(): self._manager_thread.join(timeout=3) self._manager_thread = None + self._rpc_executor.shutdown(wait=False, cancel_futures=True) @property def online(self) -> bool: @@ -122,6 +166,31 @@ def configure_device_ids(self, device_ids: Iterable[str]) -> None: self.device_ids = normalized self.node_id = f"device:{normalized[0]}" + def configure_device_descriptors( + self, + descriptors: Iterable[Dict[str, Any]], + ) -> None: + normalized: List[Dict[str, Any]] = [] + for descriptor in descriptors: + if not isinstance(descriptor, dict): + continue + device_id = str(descriptor.get("id") or "").strip() + if not device_id: + continue + item = dict(descriptor) + item["id"] = device_id + normalized.append(item) + self.device_descriptors = sorted(normalized, key=lambda item: item["id"]) + if self.device_descriptors: + self.configure_device_ids(item["id"] for item in self.device_descriptors) + + def register_handler( + self, + action_type: str, + handler: Callable[[Dict[str, Any]], Any], + ) -> None: + self.handlers[str(action_type)] = handler + def request( self, action_type: str, @@ -130,6 +199,21 @@ def request( ) -> Any: return self._request(action_type, data, timeout, require_online=True) + async def request_async( + self, + action_type: str, + data: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + ) -> Any: + """Send a request and await its response without blocking a worker.""" + + return await self._request_async( + action_type, + data, + timeout, + require_online=True, + ) + def ros_info(self, timeout: Optional[float] = None) -> RosNetworkInfo: data = self.request(ActionType.ROS_INFO, timeout=timeout) return RosNetworkInfo.from_dict((data or {}).get("ros") or data) @@ -145,6 +229,7 @@ def _identity_payload(self) -> Dict[str, Any]: "role": "slave", "protocol_version": PROTOCOL_VERSION, "capabilities": list(self.capabilities), + "devices": [dict(item) for item in self.device_descriptors], } def _request( @@ -155,6 +240,55 @@ def _request( *, require_online: bool, ) -> Any: + request_id, pending = self._begin_request( + action_type, + data, + require_online=require_online, + ) + wait_timeout = self.request_timeout if timeout is None else float(timeout) + try: + try: + response = pending.wait(wait_timeout) + except FutureTimeoutError as exc: + raise LinkError( + f"request timeout: {action_type} ({request_id[:8]})" + ) from exc + finally: + self._remove_pending(request_id, pending) + return self._response_data(response) + + async def _request_async( + self, + action_type: str, + data: Optional[Dict[str, Any]], + timeout: Optional[float], + *, + require_online: bool, + ) -> Any: + request_id, pending = self._begin_request( + action_type, + data, + require_online=require_online, + ) + wait_timeout = self.request_timeout if timeout is None else float(timeout) + try: + try: + response = await pending.wait_async(wait_timeout) + except TimeoutError as exc: + raise LinkError( + f"request timeout: {action_type} ({request_id[:8]})" + ) from exc + finally: + self._remove_pending(request_id, pending) + return self._response_data(response) + + def _begin_request( + self, + action_type: str, + data: Optional[Dict[str, Any]], + *, + require_online: bool, + ) -> tuple[str, _Pending]: sock = self._sock if sock is None or (require_online and not self.online): raise LinkError(f"hostlink offline ({self.host}:{self.port})") @@ -166,14 +300,21 @@ def _request( try: with self._write_lock: send_message(sock, message) - if not pending.event.wait(timeout or self.request_timeout): - raise LinkError(f"request timeout: {action_type} ({request_id[:8]})") except OSError as exc: + self._remove_pending(request_id, pending) raise LinkError(f"request send failed: {exc}") from exc - finally: - with self._pending_lock: + except Exception: + self._remove_pending(request_id, pending) + raise + return request_id, pending + + def _remove_pending(self, request_id: str, pending: _Pending) -> None: + with self._pending_lock: + if self._pending.get(request_id) is pending: self._pending.pop(request_id, None) - response = pending.response or {} + + @staticmethod + def _response_data(response: Dict[str, Any]) -> Any: if not response.get("ok"): raise RemoteError(str(response.get("error") or "remote error")) return response.get("data") @@ -225,7 +366,13 @@ def _heartbeat_loop(self) -> None: while not self._stop.wait(self.heartbeat_interval): if self._connection_lost.is_set(): raise LinkError("connection closed") - self.request(ActionType.PING, timeout=self.request_timeout) + payload: Optional[Dict[str, Any]] = None + if self.heartbeat_payload_provider is not None: + try: + payload = self.heartbeat_payload_provider() + except Exception: # noqa: BLE001 - 状态采集不能中断重连 + logger.exception("[HostLink] heartbeat payload collection failed") + self.request(ActionType.PING, data=payload, timeout=self.request_timeout) def _read_loop(self, sock: socket.socket) -> None: reader = LineReader(sock) @@ -234,14 +381,23 @@ def _read_loop(self, sock: socket.socket) -> None: message = read_message(reader) if message is None: break + if message.get("kind") == "req": + try: + self._rpc_executor.submit( + self._handle_incoming_request, + sock, + message, + ) + except RuntimeError: + break + continue if message.get("kind") != "resp": continue request_id = str(message.get("id") or "") with self._pending_lock: pending = self._pending.get(request_id) if pending is not None: - pending.response = message - pending.event.set() + pending.resolve(message) except (OSError, LinkError) as exc: logger.debug(f"[HostLink] reader stopped: {exc}") finally: @@ -251,9 +407,42 @@ def _read_loop(self, sock: socket.socket) -> None: with self._pending_lock: pending_items = list(self._pending.values()) for pending in pending_items: - if pending.response is None: - pending.response = {"ok": False, "error": "connection closed"} - pending.event.set() + pending.resolve({"ok": False, "error": "connection closed"}) + + def _handle_incoming_request( + self, + sock: socket.socket, + message: Dict[str, Any], + ) -> None: + request_id = str(message.get("id") or "") + if message.get("v") != PROTOCOL_VERSION: + response = new_response( + request_id, + False, + error=f"unsupported protocol version: {message.get('v')!r}", + ) + else: + action = str(message.get("action_type") or "") + handler = self.handlers.get(action) + if handler is None: + response = new_response( + request_id, + False, + error=f"unknown action: {action}", + ) + else: + raw_data = message.get("data") + data = raw_data if isinstance(raw_data, dict) else {} + try: + response = new_response(request_id, True, handler(data)) + except Exception as exc: # noqa: BLE001 - RPC 请求必须返回明确错误 + logger.warning(f"[HostLink] incoming {action} failed: {exc}") + response = new_response(request_id, False, error=str(exc)) + try: + with self._write_lock: + send_message(sock, response) + except OSError: + self._connection_lost.set() def _teardown_socket(self) -> None: sock, self._sock = self._sock, None diff --git a/unilabos/hostlink/main_hostlink_run.py b/unilabos/hostlink/main_hostlink_run.py new file mode 100644 index 000000000..fa1f1acb7 --- /dev/null +++ b/unilabos/hostlink/main_hostlink_run.py @@ -0,0 +1,92 @@ +"""``hostlink`` backend startup entrypoints.""" + +from __future__ import annotations + +from typing import Any, Optional + +from unilabos.basic.main_basic_run import build_runtime +from unilabos.hostlink.backend import HostLinkBackendRuntime + + +_runtime: Optional[HostLinkBackendRuntime] = None + + +def validate_environment() -> None: + """HostLink backend only depends on the Python driver runtime.""" + + +def get_runtime() -> Optional[HostLinkBackendRuntime]: + return _runtime + + +def _run( + devices_config: Any, + resources_config: Any, + *, + is_slave: bool, +) -> None: + global _runtime + _runtime = HostLinkBackendRuntime( + build_runtime(devices_config, backend_name="hostlink"), + is_slave=is_slave, + resources_config=resources_config, + ) + _runtime.start() + try: + while not _runtime.local.wait(timeout=1.0): + pass + finally: + _runtime.stop() + + +def main( + devices_config: Any, + resources_config: Any, + resources_edge_config: Optional[list[dict[str, Any]]] = None, + graph: Any = None, + controllers_config: Optional[dict[str, Any]] = None, + bridges: Optional[list[Any]] = None, + visual: str = "disable", + resources_mesh_config: Optional[dict[str, Any]] = None, + *args: Any, + **kwargs: Any, +) -> None: + del ( + resources_edge_config, + graph, + controllers_config, + bridges, + visual, + resources_mesh_config, + args, + kwargs, + ) + _run(devices_config, resources_config, is_slave=False) + + +def slave( + devices_config: Any, + resources_config: Any, + resources_edge_config: Optional[list[dict[str, Any]]] = None, + graph: Any = None, + controllers_config: Optional[dict[str, Any]] = None, + bridges: Optional[list[Any]] = None, + visual: str = "disable", + resources_mesh_config: Optional[dict[str, Any]] = None, + *args: Any, + **kwargs: Any, +) -> None: + del ( + resources_edge_config, + graph, + controllers_config, + bridges, + visual, + resources_mesh_config, + args, + kwargs, + ) + _run(devices_config, resources_config, is_slave=True) + + +__all__ = ["get_runtime", "main", "slave", "validate_environment"] diff --git a/unilabos/hostlink/protocol.py b/unilabos/hostlink/protocol.py index a9b956d5c..3de073e79 100644 --- a/unilabos/hostlink/protocol.py +++ b/unilabos/hostlink/protocol.py @@ -1,8 +1,7 @@ """HostLink wire protocol: newline-delimited JSON over TCP. -The first slice deliberately contains only networking control messages. Device -actions and material/resource queries continue to use the existing ROS2 and -HTTP paths. +ROS2 mode uses the control messages for assisted discovery. The standalone +HostLink backend additionally uses the same connection for device RPC/state. """ from __future__ import annotations @@ -12,8 +11,10 @@ import uuid from typing import Any, Dict, Optional +from unilabos.device_runtime.topic import message_to_value + PROTOCOL_VERSION = 1 -MAX_FRAME_BYTES = 1024 * 1024 +MAX_FRAME_BYTES = 8 * 1024 * 1024 class ActionType: @@ -22,6 +23,17 @@ class ActionType: HELLO = "hello" PING = "ping" ROS_INFO = "ros_info" + DEVICE_CALL = "device.call" + DEVICE_STATE = "device.state" + SERVICE_CALL = "service.call" + ACTION_FEEDBACK = "action.feedback" + ACTION_CANCEL = "action.cancel" + RESOURCE_UPDATE = "resource.update" + RESOURCE_GET = "resource.get" + TOPIC_PUBLISH = "topic.publish" + TOPIC_SUBSCRIBE = "topic.subscribe" + TOPIC_UNSUBSCRIBE = "topic.unsubscribe" + TOPIC_DELIVER = "topic.deliver" class LinkError(Exception): @@ -69,7 +81,12 @@ def new_response( def encode_frame(message: Dict[str, Any]) -> bytes: raw = ( - json.dumps(message, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + json.dumps( + message, + ensure_ascii=False, + separators=(",", ":"), + default=message_to_value, + ).encode("utf-8") + b"\n" ) if len(raw) > MAX_FRAME_BYTES: diff --git a/unilabos/hostlink/resource.py b/unilabos/hostlink/resource.py new file mode 100644 index 000000000..d8108cbc1 --- /dev/null +++ b/unilabos/hostlink/resource.py @@ -0,0 +1,72 @@ +"""Resource service carried over a HostLink client connection.""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from unilabos.device_runtime.resource import ( + apply_uuid_mapping, + resources_to_tree_set, +) +from unilabos.hostlink.client import HostLinkClient +from unilabos.hostlink.protocol import ActionType +from unilabos.resources.resource_tracker import ResourceTreeSet + + +class HostLinkResourceService: + """Forward a Slave driver's resource operations to the Host.""" + + def __init__(self, client: HostLinkClient) -> None: + self.client = client + + async def update_resources( + self, + device_id: str, + device_uuid: str, + resources: Any, + ) -> dict[str, str]: + tree_set = resources_to_tree_set( + resources, + device_id=device_id, + device_uuid=device_uuid, + ) + response = await asyncio.to_thread( + self.client.request, + ActionType.RESOURCE_UPDATE, + { + "device_id": device_id, + "resources": tree_set.dump(), + }, + ) + raw_mapping = (response or {}).get("uuid_mapping") + uuid_mapping = ( + {str(key): str(value) for key, value in raw_mapping.items()} + if isinstance(raw_mapping, dict) + else {} + ) + apply_uuid_mapping(resources, uuid_mapping) + return uuid_mapping + + async def get_resources( + self, + device_id: str, + resources_uuid: list[str], + with_children: bool, + ) -> ResourceTreeSet: + response = await asyncio.to_thread( + self.client.request, + ActionType.RESOURCE_GET, + { + "device_id": device_id, + "resources_uuid": list(resources_uuid), + "with_children": bool(with_children), + }, + ) + raw_resources = (response or {}).get("resources") + if not isinstance(raw_resources, list): + raise TypeError("HostLink Host 返回了无效的物料树") + return ResourceTreeSet.load(raw_resources) + + +__all__ = ["HostLinkResourceService"] diff --git a/unilabos/hostlink/server.py b/unilabos/hostlink/server.py index d54052a3b..ef96a3412 100644 --- a/unilabos/hostlink/server.py +++ b/unilabos/hostlink/server.py @@ -1,11 +1,18 @@ -"""Host-side HostLink listener for Slave discovery and ROS2 settings.""" +"""Host-side HostLink listener for discovery, policy sync and device RPC.""" from __future__ import annotations +import asyncio import socket import socketserver import threading import time +from concurrent.futures import ( + Future, + InvalidStateError, + ThreadPoolExecutor, + TimeoutError as FutureTimeoutError, +) from typing import Any, Callable, Dict, List, Optional from unilabos.hostlink.protocol import ( @@ -13,6 +20,8 @@ LineReader, LinkError, PROTOCOL_VERSION, + RemoteError, + new_request, new_response, read_message, send_message, @@ -22,6 +31,131 @@ Handler = Callable[[Dict[str, Any], Dict[str, Any]], Dict[str, Any]] +class _Pending: + """One response shared by blocking and asyncio callers.""" + + __slots__ = ("future",) + + def __init__(self) -> None: + self.future: Future[Dict[str, Any]] = Future() + + def resolve(self, response: Dict[str, Any]) -> None: + if self.future.done(): + return + try: + self.future.set_result(response) + except InvalidStateError: + pass + + def wait(self, timeout: Optional[float]) -> Dict[str, Any]: + return self.future.result(timeout=timeout) + + async def wait_async(self, timeout: Optional[float]) -> Dict[str, Any]: + wrapped = asyncio.wrap_future(self.future) + if timeout is None: + return await wrapped + return await asyncio.wait_for(wrapped, timeout) + + +class _PeerSession: + """A connected Slave socket that also accepts Host-initiated requests.""" + + def __init__(self, sock: socket.socket, request_timeout: float) -> None: + self.sock = sock + self.request_timeout = float(request_timeout) + self._write_lock = threading.Lock() + self._pending: Dict[str, _Pending] = {} + self._pending_lock = threading.Lock() + + def send(self, message: Dict[str, Any]) -> None: + with self._write_lock: + send_message(self.sock, message) + + def request( + self, + action_type: str, + data: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + ) -> Any: + request_id, pending = self._begin_request(action_type, data) + wait_timeout = self.request_timeout if timeout is None else float(timeout) + try: + try: + response = pending.wait(wait_timeout) + except FutureTimeoutError as exc: + raise LinkError( + f"request timeout: {action_type} ({request_id[:8]})" + ) from exc + finally: + self._remove_pending(request_id, pending) + return self._response_data(response) + + async def request_async( + self, + action_type: str, + data: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + ) -> Any: + """Await one response while the connection reader stays threaded.""" + + request_id, pending = self._begin_request(action_type, data) + wait_timeout = self.request_timeout if timeout is None else float(timeout) + try: + try: + response = await pending.wait_async(wait_timeout) + except TimeoutError as exc: + raise LinkError( + f"request timeout: {action_type} ({request_id[:8]})" + ) from exc + finally: + self._remove_pending(request_id, pending) + return self._response_data(response) + + def _begin_request( + self, + action_type: str, + data: Optional[Dict[str, Any]], + ) -> tuple[str, _Pending]: + message = new_request(action_type, data=data) + request_id = str(message["id"]) + pending = _Pending() + with self._pending_lock: + self._pending[request_id] = pending + try: + self.send(message) + except OSError as exc: + self._remove_pending(request_id, pending) + raise LinkError(f"request send failed: {exc}") from exc + except Exception: + self._remove_pending(request_id, pending) + raise + return request_id, pending + + def _remove_pending(self, request_id: str, pending: _Pending) -> None: + with self._pending_lock: + if self._pending.get(request_id) is pending: + self._pending.pop(request_id, None) + + @staticmethod + def _response_data(response: Dict[str, Any]) -> Any: + if not response.get("ok"): + raise RemoteError(str(response.get("error") or "remote error")) + return response.get("data") + + def resolve_response(self, message: Dict[str, Any]) -> None: + request_id = str(message.get("id") or "") + with self._pending_lock: + pending = self._pending.get(request_id) + if pending is not None: + pending.resolve(message) + + def close(self) -> None: + with self._pending_lock: + pending_items = list(self._pending.values()) + for pending in pending_items: + pending.resolve({"ok": False, "error": "connection closed"}) + + class _LinkTCPServer(socketserver.ThreadingTCPServer): allow_reuse_address = True daemon_threads = True @@ -36,6 +170,7 @@ def handle(self) -> None: sock.settimeout(link.socket_timeout) reader = LineReader(sock) peer_key = f"{self.client_address[0]}:{self.client_address[1]}" + session = link.register_session(peer_key, sock) try: while not link.stopping.is_set(): try: @@ -47,20 +182,21 @@ def handle(self) -> None: break if message is None: break + if message.get("kind") == "resp": + session.resolve_response(message) + continue if message.get("kind") != "req": continue - response = link.dispatch(message, peer_key) - try: - send_message(sock, response) - except OSError: + if not link.submit_request(session, message, peer_key): break finally: reader.close() + link.unregister_session(peer_key, session) link.mark_disconnected(peer_key) class HostLinkServer: - """Track Slave/device presence and publish the Host ROS2 network policy.""" + """Track Slave devices and make requests over their control connections.""" def __init__( self, @@ -68,22 +204,31 @@ def __init__( port: int = 7302, heartbeat_timeout: float = 15.0, socket_timeout: float = 1.0, + request_timeout: float = 10.0, ) -> None: self._bind = bind self._port = int(port) self.heartbeat_timeout = float(heartbeat_timeout) self.socket_timeout = float(socket_timeout) + self.request_timeout = float(request_timeout) self.handlers: Dict[str, Handler] = {} self.hello_payload: Dict[str, Any] = {} self.stopping = threading.Event() self._peers: Dict[str, Dict[str, Any]] = {} self._connection_nodes: Dict[str, str] = {} self._peers_lock = threading.Lock() + self._sessions: Dict[str, _PeerSession] = {} + self._sessions_lock = threading.Lock() self._tcp: Optional[_LinkTCPServer] = None self._thread: Optional[threading.Thread] = None + self._rpc_executor = ThreadPoolExecutor( + max_workers=16, + thread_name_prefix="hostlink-host-rpc", + ) self.register_handler(ActionType.HELLO, self._handle_hello) self.register_handler(ActionType.PING, self._handle_ping) self.register_handler(ActionType.ROS_INFO, self._handle_ros_info) + self.register_handler(ActionType.DEVICE_STATE, self._handle_device_state) def start(self) -> "HostLinkServer": if self._thread is not None and self._thread.is_alive(): @@ -110,6 +255,12 @@ def stop(self) -> None: if self._thread is not None and self._thread.is_alive(): self._thread.join(timeout=3) self._thread = None + with self._sessions_lock: + sessions = list(self._sessions.values()) + self._sessions.clear() + for session in sessions: + session.close() + self._rpc_executor.shutdown(wait=False, cancel_futures=True) @property def port(self) -> int: @@ -118,6 +269,173 @@ def port(self) -> int: def register_handler(self, action_type: str, handler: Handler) -> None: self.handlers[action_type] = handler + def register_session( + self, + peer_key: str, + sock: socket.socket, + ) -> _PeerSession: + session = _PeerSession(sock, self.request_timeout) + with self._sessions_lock: + old_session = self._sessions.get(peer_key) + self._sessions[peer_key] = session + if old_session is not None: + old_session.close() + return session + + def unregister_session( + self, + peer_key: str, + session: _PeerSession, + ) -> None: + with self._sessions_lock: + if self._sessions.get(peer_key) is session: + self._sessions.pop(peer_key, None) + session.close() + + def request_peer( + self, + peer_key: str, + action_type: str, + data: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + ) -> Any: + with self._sessions_lock: + session = self._sessions.get(str(peer_key)) + if session is None: + raise LinkError(f"hostlink peer offline: {peer_key}") + return session.request(action_type, data, timeout) + + async def request_peer_async( + self, + peer_key: str, + action_type: str, + data: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + ) -> Any: + with self._sessions_lock: + session = self._sessions.get(str(peer_key)) + if session is None: + raise LinkError(f"hostlink peer offline: {peer_key}") + return await session.request_async(action_type, data, timeout) + + def request_device( + self, + device_id: str, + action_type: str, + data: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + ) -> Any: + device_id = str(device_id) + peer = self.devices(online_only=True).get(device_id) + if peer is None: + raise LinkError(f"hostlink device offline: {device_id}") + return self.request_peer(str(peer["addr"]), action_type, data, timeout) + + async def request_device_async( + self, + device_id: str, + action_type: str, + data: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + ) -> Any: + device_id = str(device_id) + peer = self.devices(online_only=True).get(device_id) + if peer is None: + raise LinkError(f"hostlink device offline: {device_id}") + return await self.request_peer_async( + str(peer["addr"]), + action_type, + data, + timeout, + ) + + def submit_request( + self, + session: _PeerSession, + message: Dict[str, Any], + peer_key: str, + ) -> bool: + """Dispatch one Slave request without blocking reads on that connection.""" + + def serve() -> None: + response = self.dispatch(message, peer_key) + try: + session.send(response) + except OSError: + pass + + try: + self._rpc_executor.submit(serve) + except RuntimeError: + return False + return True + + def call_device( + self, + device_id: str, + action_name: str, + arguments: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + action_id: str = "", + ) -> Any: + return self.request_device( + device_id, + ActionType.DEVICE_CALL, + { + "device_id": str(device_id), + "action": str(action_name), + "arguments": dict(arguments or {}), + "action_id": str(action_id), + }, + timeout, + ) + + async def call_device_async( + self, + device_id: str, + action_name: str, + arguments: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + action_id: str = "", + ) -> Any: + return await self.request_device_async( + device_id, + ActionType.DEVICE_CALL, + { + "device_id": str(device_id), + "action": str(action_name), + "arguments": dict(arguments or {}), + "action_id": str(action_id), + }, + timeout, + ) + + def cancel_device_action( + self, + device_id: str, + action_id: str, + timeout: Optional[float] = None, + ) -> Any: + return self.request_device( + device_id, + ActionType.ACTION_CANCEL, + {"device_id": str(device_id), "action_id": str(action_id)}, + timeout, + ) + + async def cancel_device_action_async( + self, + device_id: str, + action_id: str, + timeout: Optional[float] = None, + ) -> Any: + return await self.request_device_async( + device_id, + ActionType.ACTION_CANCEL, + {"device_id": str(device_id), "action_id": str(action_id)}, + timeout, + ) + def dispatch(self, message: Dict[str, Any], peer_key: str) -> Dict[str, Any]: request_id = str(message.get("id") or "") if message.get("v") != PROTOCOL_VERSION: @@ -149,19 +467,35 @@ def _touch_peer( with self._peers_lock: known_node = self._connection_nodes.get(peer_key) if action == ActionType.HELLO: + devices: Dict[str, Dict[str, Any]] = {} + for item in data.get("devices") or []: + if not isinstance(item, dict): + continue + device_id = str(item.get("id") or "").strip() + if device_id: + descriptor = dict(item) + descriptor["id"] = device_id + devices[device_id] = descriptor device_ids = sorted( { str(device_id).strip() for device_id in data.get("device_ids") or [] if str(device_id).strip() } + | set(devices) ) machine_name = str(data.get("machine_name") or "").strip() node_id = str(data.get("node_id") or "").strip() if device_ids: - # A reconnect keeps the logical peer keyed by its first - # globally unique device id even though the TCP port changes. - node_id = f"device:{device_ids[0]}" + incoming_devices = set(device_ids) + node_id = "" + for known_node_id, known_peer in self._peers.items(): + known_devices = set(known_peer.get("device_ids") or []) + if incoming_devices.intersection(known_devices): + node_id = known_node_id + break + if not node_id: + node_id = f"device:{device_ids[0]}" node_id = node_id or machine_name or peer_key if known_node and known_node != node_id: temporary = self._peers.get(known_node) @@ -178,6 +512,7 @@ def _touch_peer( "machine_name": machine_name, "role": str(data.get("role") or "slave"), "device_ids": device_ids, + "devices": devices, "protocol_version": data.get("protocol_version"), "capabilities": [ str(item) @@ -203,6 +538,14 @@ def _touch_peer( ) if known_node and peer.get("addr") != peer_key: return dict(peer) + if action in (ActionType.PING, ActionType.DEVICE_STATE): + states = peer.setdefault("states", {}) + if isinstance(data.get("states"), dict): + states.update(data["states"]) + device_id = str(data.get("device_id") or "").strip() + state = data.get("state") + if device_id and isinstance(state, dict): + states[device_id] = dict(state) peer["last_seen"] = now peer["connected"] = True return dict(peer) @@ -238,7 +581,14 @@ def devices(self, online_only: bool = True) -> Dict[str, Dict[str, Any]]: if online_only and not peer.get("online"): continue for device_id in peer.get("device_ids") or []: - result[str(device_id)] = dict(peer) + device_id = str(device_id) + snapshot = dict(peer) + snapshot["device"] = dict( + (peer.get("devices") or {}).get(device_id) or {"id": device_id} + ) + state = (peer.get("states") or {}).get(device_id) + snapshot["state"] = dict(state) if isinstance(state, dict) else {} + result[device_id] = snapshot return result def has_device(self, device_id: str) -> bool: @@ -271,6 +621,16 @@ def _handle_ros_info( ) -> Dict[str, Any]: return {"ros": dict(self.hello_payload.get("ros") or {})} + def _handle_device_state( + self, + data: Dict[str, Any], + _peer: Dict[str, Any], + ) -> Dict[str, Any]: + return { + "accepted": True, + "device_id": str(data.get("device_id") or ""), + } + _server_lock = threading.Lock() _server: Optional[HostLinkServer] = None diff --git a/unilabos/registry/ast_registry_scanner.py b/unilabos/registry/ast_registry_scanner.py index 9b29fef2c..731d87686 100644 --- a/unilabos/registry/ast_registry_scanner.py +++ b/unilabos/registry/ast_registry_scanner.py @@ -21,7 +21,6 @@ import hashlib import json import re -import time from concurrent.futures import ThreadPoolExecutor, as_completed from functools import lru_cache from pathlib import Path @@ -36,7 +35,7 @@ MAX_SCAN_DEPTH = 10 # 最大目录递归深度 MAX_SCAN_FILES = 1000 # 最大扫描文件数量 -_CACHE_VERSION = 6 # 缓存格式版本号,格式变更时递增 +_CACHE_VERSION = 7 # 缓存格式版本号,格式变更时递增 _DEVICE_ID_RE = re.compile(r"^[A-Za-z0-9_]+$") # 合法的装饰器来源模块 @@ -398,7 +397,11 @@ def _parse_file( "displayname": displayname, "icon": device_args.get("icon", ""), "version": device_args.get("version", "1.0.0"), - "device_type": _detect_class_type(node, import_map), + "device_type": device_args.get("device_type") + or _detect_class_type(node, import_map), + "supported_backends": device_args.get( + "supported_backends" + ), "handles": device_args.get("handles", []), "model": device_args.get("model"), "hardware_interface": device_args.get("hardware_interface"), @@ -413,7 +416,15 @@ def _parse_file( meta = dict(base_meta) meta["device_id"] = did overrides = id_meta.get(did, {}) - for key in ("handles", "description", "displayname", "icon", "model", "hardware_interface"): + for key in ( + "handles", + "description", + "displayname", + "icon", + "model", + "hardware_interface", + "supported_backends", + ): if key in overrides: meta[key] = overrides[key] meta["displayname"] = resolve_registry_displayname(meta.get("displayname"), did) diff --git a/unilabos/registry/decorators.py b/unilabos/registry/decorators.py index b31d732f4..60b252a4b 100644 --- a/unilabos/registry/decorators.py +++ b/unilabos/registry/decorators.py @@ -257,6 +257,7 @@ def device( handles: Optional[List[_DeviceHandleBase]] = None, model: Optional[Dict[str, Any]] = None, device_type: str = "python", + supported_backends: Optional[List[str]] = None, hardware_interface: Optional[HardwareInterface] = None, ): """ @@ -281,6 +282,7 @@ def device( handles: 设备端口列表 (单设备或 id_meta 未覆盖时使用) model: 可选的 3D 模型配置 device_type: 设备实现类型 ("python" / "ros2") + supported_backends: 可运行该驱动的 backend 名称列表 hardware_interface: 硬件通信接口 (HardwareInterface) """ # Resolve device ids @@ -315,6 +317,7 @@ def device( "handles": _device_handles_to_list(handles), "model": model, "device_type": device_type, + "supported_backends": list(supported_backends or []), "hardware_interface": (hardware_interface.model_dump(exclude_none=True) if hardware_interface else None), } diff --git a/unilabos/registry/devices/liquid_handler.yaml b/unilabos/registry/devices/liquid_handler.yaml index 7e3eacf22..62f0ac409 100644 --- a/unilabos/registry/devices/liquid_handler.yaml +++ b/unilabos/registry/devices/liquid_handler.yaml @@ -4849,6 +4849,10 @@ liquid_handler: type: LiquidHandlerTransfer module: unilabos.devices.liquid_handling.liquid_handler_abstract:LiquidHandlerAbstract status_types: {} + supported_backends: + - basic + - hostlink + - ros2 type: python config_info: [] description: Liquid handler device controlled by pylabrobot @@ -10243,6 +10247,10 @@ liquid_handler.prcxi: module: unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Handler status_types: reset_ok: bool + supported_backends: + - basic + - hostlink + - ros2 type: python config_info: [] description: prcxi液体处理器设备,基于pylabrobot控制 diff --git a/unilabos/registry/devices/neware_battery_test_system.yaml b/unilabos/registry/devices/neware_battery_test_system.yaml index 28b47b95d..7c34d5096 100644 --- a/unilabos/registry/devices/neware_battery_test_system.yaml +++ b/unilabos/registry/devices/neware_battery_test_system.yaml @@ -327,6 +327,10 @@ neware_battery_test_system: device_summary: dict status: str total_channels: int + supported_backends: + - basic + - hostlink + - ros2 type: python config_info: [] description: 新威电池测试系统驱动,提供720个通道的电池测试状态监控、物料管理和CSV批量提交功能。支持TCP通信实现远程控制,包含完整的物料管理系统(2盘电池状态映射),以及从CSV文件批量提交测试任务的能力。 diff --git a/unilabos/registry/devices/robot_arm.yaml b/unilabos/registry/devices/robot_arm.yaml index 49bcd078a..119d851d7 100644 --- a/unilabos/registry/devices/robot_arm.yaml +++ b/unilabos/registry/devices/robot_arm.yaml @@ -308,6 +308,8 @@ robotic_arm.SCARA_with_slider.moveit.virtual: type: SendCmd module: unilabos.devices.ros_dev.moveit_interface:MoveitInterface status_types: {} + supported_backends: + - ros2 type: python config_info: [] description: 机械臂与滑块运动系统,基于MoveIt2运动规划框架的多自由度机械臂控制设备。该系统集成机械臂和线性滑块,通过ROS2和MoveIt2实现精确的轨迹规划和协调运动控制。支持笛卡尔空间和关节空间的运动规划、碰撞检测、逆运动学求解等功能。适用于复杂的pick-and-place操作、精密装配、多工位协作等需要高精度多轴协调运动的实验室自动化应用。 diff --git a/unilabos/registry/devices/robot_linear_motion.yaml b/unilabos/registry/devices/robot_linear_motion.yaml index 3257958c3..25255e229 100644 --- a/unilabos/registry/devices/robot_linear_motion.yaml +++ b/unilabos/registry/devices/robot_linear_motion.yaml @@ -824,6 +824,8 @@ linear_motion.toyo_xyz.sim: type: SendCmd module: unilabos.devices.ros_dev.moveit_interface:MoveitInterface status_types: {} + supported_backends: + - ros2 type: python config_info: [] description: 东洋XYZ三轴运动平台,基于MoveIt2运动规划框架的精密定位设备。该设备通过ROS2和MoveIt2实现三维空间的精确运动控制,支持复杂轨迹规划、多点定位、速度控制等功能。具备高精度定位、平稳运动、实时轨迹监控等特性。适用于精密加工、样品定位、检测扫描、自动化装配等需要高精度三维运动控制的实验室和工业应用场景。 diff --git a/unilabos/registry/devices/virtual_device.yaml b/unilabos/registry/devices/virtual_device.yaml index 3fb0cb5fa..dc49bb146 100644 --- a/unilabos/registry/devices/virtual_device.yaml +++ b/unilabos/registry/devices/virtual_device.yaml @@ -248,6 +248,10 @@ virtual_centrifuge: target_speed: float target_temp: float time_remaining: float + supported_backends: + - basic + - hostlink + - ros2 type: python config_info: [] description: Virtual Centrifuge for CentrifugeProtocol Testing @@ -674,6 +678,10 @@ virtual_column: processed_volume: float progress: float status: str + supported_backends: + - basic + - hostlink + - ros2 type: python config_info: [] description: Virtual Column Chromatography Device for RunColumn Protocol Testing @@ -1111,6 +1119,10 @@ virtual_filter: message: str progress: float status: str + supported_backends: + - basic + - hostlink + - ros2 type: python config_info: [] description: Virtual Filter for FilterProtocol Testing @@ -1976,6 +1988,10 @@ virtual_heatchill: remaining_time: float status: str stir_speed: float + supported_backends: + - basic + - hostlink + - ros2 type: python config_info: [] description: Virtual HeatChill for HeatChillProtocol Testing @@ -2744,6 +2760,10 @@ virtual_rotavap: rotavap_state: str status: str vacuum_pressure: float + supported_backends: + - basic + - hostlink + - ros2 type: python config_info: [] description: Virtual Rotary Evaporator for EvaporateProtocol Testing @@ -3960,6 +3980,10 @@ virtual_separator: status: str stir_speed: float volume: float + supported_backends: + - basic + - hostlink + - ros2 type: python config_info: [] description: Virtual Separator for SeparateProtocol Testing @@ -4305,6 +4329,10 @@ virtual_solenoid_valve: status: str valve_position: str valve_state: str + supported_backends: + - basic + - hostlink + - ros2 type: python config_info: [] description: Virtual Solenoid Valve for simple on/off flow control @@ -4709,6 +4737,10 @@ virtual_solid_dispenser: dispensed_amount: float status: str total_operations: int + supported_backends: + - basic + - hostlink + - ros2 type: python config_info: [] description: Virtual Solid Dispenser for Add Protocol Testing - supports mass and @@ -5334,6 +5366,10 @@ virtual_stirrer: operation_mode: str remaining_time: float status: str + supported_backends: + - basic + - hostlink + - ros2 type: python config_info: [] description: Virtual Stirrer for StirProtocol Testing @@ -5869,6 +5905,10 @@ virtual_transfer_pump: remaining_capacity: float status: str transfer_rate: float + supported_backends: + - basic + - hostlink + - ros2 type: python config_info: [] description: Virtual Transfer Pump for TransferProtocol Testing (Syringe-style) diff --git a/unilabos/registry/registry.py b/unilabos/registry/registry.py index 590ff45e2..d5d7b8140 100644 --- a/unilabos/registry/registry.py +++ b/unilabos/registry/registry.py @@ -1153,6 +1153,15 @@ def _build_json_command_entry(method_name, method_info, action_args=None): "status_types": status_types_str, "action_value_mappings": action_value_mappings, "type": ast_meta.get("device_type", "python"), + **( + { + "supported_backends": ast_meta[ + "supported_backends" + ] + } + if ast_meta.get("supported_backends") + else {} + ), }, "config_info": [], "description": ast_meta.get("description", ""), diff --git a/unilabos/resources/plr_additional_res_reg.py b/unilabos/resources/plr_additional_res_reg.py index 1c019dedf..f99941e86 100644 --- a/unilabos/resources/plr_additional_res_reg.py +++ b/unilabos/resources/plr_additional_res_reg.py @@ -16,5 +16,4 @@ def register(): from unilabos.devices.liquid_handling.laiyu.laiyu import TransformXYZContainer from unilabos.devices.liquid_handling.rviz_backend import UniLiquidHandlerRvizBackend - from unilabos.devices.liquid_handling.laiyu.backend.laiyu_v_backend import UniLiquidHandlerLaiyuBackend diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index 23e905d21..4cf1bc948 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -36,6 +36,8 @@ from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response from unilabos.config.config import BasicConfig +from unilabos.device_runtime.node import DeviceNode +from unilabos.device_runtime.async_utils import schedule_async_func from unilabos.registry.decorators import get_topic_config from unilabos.registry.placeholder_type import ResourceSlotRawInput from unilabos.utils.decorator import get_all_subscriptions @@ -73,7 +75,12 @@ PARAM_SAMPLE_UUIDS, JSON_UNILABOS_PARAM, ) -from unilabos.ros.utils.driver_creator import WorkstationNodeCreator, PyLabRobotCreator, DeviceClassCreator +from unilabos.device_runtime.driver_creator import ( + DeviceClassCreator, + PyLabRobotCreator, + WorkstationNodeCreator, + uses_pylabrobot_creator, +) from rclpy.task import Task, Future from unilabos.utils.import_manager import default_manager from unilabos.utils.log import info, debug, warning, error, critical, logger, trace @@ -335,6 +342,7 @@ def publish_property(self): try: # self.node.lab_logger().trace(f"【.publish_property】开始发布属性: {self.name}") value = self.get_property() + self.node.emit_status(self.name, value) if self.print_publish: pass # self.node.lab_logger().trace(f"【.publish_property】发布 {self.msg_type}: {value}") @@ -361,7 +369,7 @@ def change_frequency(self, period): self.timer = self.node.create_timer(self.timer_period, self.publish_property) -class BaseROS2DeviceNode(Node, Generic[T]): +class BaseROS2DeviceNode(Node, DeviceNode, Generic[T]): """ ROS2设备节点基类 @@ -380,6 +388,7 @@ def identifier(self): _time_remaining = 0.0 # 是否创建Action create_action_server = True + backend_name = "ros2" def __init__( self, @@ -761,9 +770,19 @@ async def sleep(self, rel_time: float, callback_group=None): callback_group = self.callback_group await ROS2DeviceNode.async_wait_for(self, rel_time, callback_group) - @classmethod - async def create_task(cls, func, trace_error=True, **kwargs) -> Task: - return ROS2DeviceNode.run_async_func(func, trace_error, **kwargs) + def create_task(self, coroutine, trace_error=True, **kwargs) -> Task: + """Schedule a coroutine while accepting the legacy async-function form.""" + + if callable(coroutine): + return self.run_async_func( + coroutine, + trace_error, + **kwargs, + ) + if kwargs: + raise TypeError("协程对象不能再接收额外关键字参数") + + return rclpy.get_global_executor().create_task(coroutine) async def update_resource(self, resources: List["ResourcePLR"]): r = SerialCommand.Request() @@ -2129,7 +2148,7 @@ def _handle_future_exception(fut: Future): f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}" ) - future = ROS2DeviceNode.run_async_func(ACTION, trace_error=False, **action_kwargs) + future = self.run_async_func(ACTION, trace_error=False, **action_kwargs) future.add_done_callback(_handle_future_exception) except Exception as e: execution_error = traceback.format_exc() @@ -2659,34 +2678,18 @@ class ROS2DeviceNode: def get_asyncio_loop(cls): return cls._asyncio_loop - @staticmethod - async def safe_task_wrapper(trace_callback, func, **kwargs): - try: - if callable(trace_callback): - trace_callback(await func(**kwargs)) - return await func(**kwargs) - except Exception as e: - if callable(trace_callback): - trace_callback(e) - return e - @classmethod def run_async_func(cls, func, trace_error=True, inner_trace_callback=None, **kwargs) -> Task: - def _handle_future_exception(fut: Future): - try: - ret = fut.result() - if isinstance(ret, BaseException): - raise ret - except Exception as e: - error(f"异步任务 {func.__name__} 获取结果失败") - error(traceback.format_exc()) - - future = rclpy.get_global_executor().create_task( - ROS2DeviceNode.safe_task_wrapper(inner_trace_callback, func, **kwargs) + """兼容旧调用;新驱动应使用当前 DeviceNode 实例的同名方法。""" + + return schedule_async_func( + rclpy.get_global_executor().create_task, + func, + trace_error=trace_error, + inner_trace_callback=inner_trace_callback, + error_callback=error, + **kwargs, ) - if trace_error: - future.add_done_callback(_handle_future_exception) - return future @classmethod async def async_wait_for(cls, node: Node, wait_time: float, callback_group=None): @@ -2751,14 +2754,7 @@ def __init__( self.resource_tracker = DeviceNodeResourceTracker() # use_pylabrobot_creator 使用 cls的包路径检测 - use_pylabrobot_creator = ( - driver_class.__module__.startswith("pylabrobot") - or driver_class.__name__ == "LiquidHandlerAbstract" - or driver_class.__name__ == "LiquidHandlerBiomek" - or driver_class.__name__ == "PRCXI9300Handler" - or driver_class.__name__ == "TransformXYZHandler" - or driver_class.__name__ == "OpcUaClient" - ) + use_pylabrobot_creator = uses_pylabrobot_creator(driver_class) # 创建设备类实例 if use_pylabrobot_creator: @@ -2766,7 +2762,10 @@ def __init__( # 在下方对于加载Deck等Resource要手动import register() self._driver_creator = PyLabRobotCreator( - driver_class, children=children, resource_tracker=self.resource_tracker + driver_class, + children=children, + resource_tracker=self.resource_tracker, + task_scheduler=rclpy.get_global_executor().create_task, ) else: from unilabos.devices.workstation.workstation_base import WorkstationBase @@ -2776,7 +2775,10 @@ def __init__( ): # 是WorkstationNode的子节点,就要调用WorkstationNodeCreator self.driver_is_workstation = True self._driver_creator = WorkstationNodeCreator( - driver_class, children=children, resource_tracker=self.resource_tracker + driver_class, + children=children, + resource_tracker=self.resource_tracker, + task_scheduler=rclpy.get_global_executor().create_task, ) else: self._driver_creator = DeviceClassCreator( diff --git a/unilabos/ros/utils/driver_creator.py b/unilabos/ros/utils/driver_creator.py index 47e7533ce..ffd4baffd 100644 --- a/unilabos/ros/utils/driver_creator.py +++ b/unilabos/ros/utils/driver_creator.py @@ -1,371 +1,22 @@ -""" -设备类实例创建工厂 +"""Compatibility imports for device creator classes. -这个模块包含用于创建设备类实例的工厂类。 -基础工厂类提供通用的实例创建方法,而特定工厂类提供针对特定设备类的创建方法。 +The implementation is backend-neutral and lives in ``device_runtime``. This +module remains so existing integrations importing the historical path continue +to work. """ -import asyncio -import inspect -import traceback -from abc import abstractmethod -from typing import Type, Any, Dict, Optional, TypeVar, Generic, List - -from unilabos.resources.resource_tracker import DeviceNodeResourceTracker, ResourceTreeSet, ResourceDictInstance, \ - ResourceTreeInstance -from unilabos.utils import logger -from unilabos.utils.cls_creator import create_instance_from_config - -# 定义泛型类型变量 -T = TypeVar("T") - - -class ClassCreator(Generic[T]): - @abstractmethod - def create_instance(self, *args, **kwargs) -> T: - pass - - -class DeviceClassCreator(Generic[T]): - """ - 设备类实例创建器基类 - - 这个类提供了从任意类创建实例的通用方法。 - """ - - def __init__(self, cls: Type[T], children: List[ResourceDictInstance], resource_tracker: DeviceNodeResourceTracker): - """ - 初始化设备类创建器 - - Args: - cls: 要创建实例的类 - """ - self.device_cls = cls - self.device_instance: Optional[T] = None - self.children = children - self.resource_tracker = resource_tracker - - def attach_resource(self): - """ - 附加资源到设备类实例 - """ - if self.device_instance is not None: - for c in self.children: - if c.res_content.type != "device": - res = ResourceTreeSet([ResourceTreeInstance(c)]).to_plr_resources()[0] - self.resource_tracker.add_resource(res) - - def create_instance(self, data: Dict[str, Any]) -> T: - """ - 创建设备类实例 - - Args: - - - Returns: - 设备类的实例 - """ - self.device_instance = create_instance_from_config( - { - "_cls": self.device_cls.__module__ + ":" + self.device_cls.__name__, - "_params": data, - } - ) - self.post_create() - self.attach_resource() - return self.device_instance - - def get_instance(self) -> Optional[T]: - """ - 获取当前实例 - - Returns: - 当前设备类实例,如果尚未创建则返回None - """ - return self.device_instance - - def post_create(self): - pass - - -class PyLabRobotCreator(DeviceClassCreator[T]): - """ - PyLabRobot设备类创建器 - - 这个类提供了针对PyLabRobot设备类的实例创建方法,特别处理deserialize方法。 - """ - - def __init__(self, cls: Type[T], children: List[ResourceDictInstance], resource_tracker: DeviceNodeResourceTracker): - """ - 初始化PyLabRobot设备类创建器 - - Args: - cls: PyLabRobot设备类 - children: 子资源字典,用于资源替换 - """ - super().__init__(cls, children, resource_tracker) - # 检查类是否具有deserialize方法 - self.has_deserialize = hasattr(cls, "deserialize") and callable(getattr(cls, "deserialize")) - if not self.has_deserialize: - logger.warning(f"类 {cls.__name__} 没有deserialize方法,将使用标准构造函数") - - def attach_resource(self): - pass # 只能增加实例化物料,原来默认物料仅为字典查询 - - # def _process_resource_mapping(self, resource, source_type): - # if source_type == dict: - # from pylabrobot.resources.resource import Resource - # - # return nested_dict_to_list(resource), Resource - # return resource, source_type - - def _process_resource_references( - self, data: Any, processed_child_names: Optional[Dict[str, Any]], to_dict=False, states=None, prefix_path="", name_to_uuid=None - ) -> Any: - """ - 递归处理资源引用,替换_resource_child_name对应的资源 - - Args: - data: 需要处理的数据,可能是字典、列表或其他类型 - to_dict: 是否返回字典形式的资源 - states: 用于保存所有资源状态 - prefix_path: 当前递归路径 - name_to_uuid: name到uuid的映射字典 - - Returns: - 处理后的数据 - """ - from pylabrobot.resources import Resource - - if states is None: - states = {} - - if isinstance(data, dict): - if "_resource_child_name" in data: - child_name = data["_resource_child_name"] - resource: Optional[ResourceDictInstance] = None - for child in self.children: - if child.res_content.name == child_name: - resource = child - if resource is not None: - if "_resource_type" in data: - type_path = data["_resource_type"] - try: - # target_type = import_manager.get_class(type_path) - # contain_model = not issubclass(target_type, Deck) - # resource, target_type = self._process_resource_mapping(resource, target_type) - res_tree = ResourceTreeInstance(resource) - res_tree_set = ResourceTreeSet([res_tree]) - resource_instance: Resource = res_tree_set.to_plr_resources()[0] - # resource_instance: Resource = resource_ulab_to_plr(resource, contain_model) # 带state - states[prefix_path] = resource_instance.serialize_all_state() - # 使用 prefix_path 作为 key 存储资源状态 - if to_dict: - serialized = resource_instance.serialize() - states[prefix_path] = resource_instance.serialize_all_state() - return serialized - else: - processed_child_names[child_name] = resource_instance - self.resource_tracker.add_resource(resource_instance) - # 立即设置UUID,state已经在resource_ulab_to_plr中处理过了 - if name_to_uuid: - self.resource_tracker.loop_set_uuid(resource_instance, name_to_uuid) - return resource_instance - except Exception as e: - logger.warning(f"无法导入资源类型 {type_path}: {e}") - logger.warning(traceback.format_exc()) - else: - logger.debug(f"找不到资源类型,请补全_resource_type {self.device_cls.__name__} {data.keys()}") - return resource - else: - logger.warning(f"找不到资源引用 '{child_name}',保持原值不变") - - # 递归处理每个键值 - result = {} - for key, value in data.items(): - new_prefix = f"{prefix_path}.{key}" if prefix_path else key - result[key] = self._process_resource_references(value, processed_child_names, to_dict, states, new_prefix, name_to_uuid) - return result - - elif isinstance(data, list): - return [ - self._process_resource_references(item, processed_child_names, to_dict, states, f"{prefix_path}[{i}]", name_to_uuid) - for i, item in enumerate(data) - ] - - else: - return data - - def create_instance(self, data: Dict[str, Any]) -> Optional[T]: - """ - 从数据创建PyLabRobot设备实例 - - Args: - data: 用于反序列化的数据 - - Returns: - PyLabRobot设备类实例 - """ - deserialize_error = None - stack = None - - # 递归遍历 children 构建 name_to_uuid 映射 - def collect_name_to_uuid(children_list: List[ResourceDictInstance], result: Dict[str, str]): - """递归遍历嵌套的 children 字典,收集 name 到 uuid 的映射""" - for child in children_list: - if isinstance(child, ResourceDictInstance): - result[child.res_content.name] = child.res_content.uuid - collect_name_to_uuid(child.children, result) - - name_to_uuid = {} - collect_name_to_uuid(self.children, name_to_uuid) - if self.has_deserialize: - deserialize_method = getattr(self.device_cls, "deserialize") - spect = inspect.signature(deserialize_method) - spec_args = spect.parameters - for param_name, param_value in data.copy().items(): - if ( - isinstance(param_value, dict) - and "_resource_child_name" in param_value - and "_resource_type" not in param_value - ): - arg_value = spec_args[param_name].annotation - data[param_name]["_resource_type"] = self.device_cls.__module__ + ":" + arg_value - logger.debug(f"自动补充 _resource_type: {data[param_name]['_resource_type']}") - - # 首先处理资源引用 - states = {} - processed_data = self._process_resource_references( - data, {}, to_dict=True, states=states, name_to_uuid=name_to_uuid - ) - - try: - from pylabrobot.resources import Resource - - self.device_instance: Resource = deserialize_method(**processed_data) - self.resource_tracker.loop_set_uuid(self.device_instance, name_to_uuid) - all_states = self.device_instance.serialize_all_state() - for k, v in states.items(): - logger.debug(f"PyLabRobot反序列化设置状态:{k}") - for kk, vv in all_states.items(): - if kk not in v: - v[kk] = vv - self.device_instance.load_all_state(v) - self.resource_tracker.add_resource(self.device_instance) - self.post_create() # 对应DeviceClassCreator进行调用 - return self.device_instance # type: ignore - except Exception as e: - # 先静默继续,尝试另外一种创建方法 - deserialize_error = e - stack = traceback.format_exc() - - if self.device_instance is None: - try: - spect = inspect.signature(self.device_cls.__init__) - spec_args = spect.parameters - for param_name, param_value in data.copy().items(): - if ( - isinstance(param_value, dict) - and "_resource_child_name" in param_value - and "_resource_type" not in param_value - ): - arg_value = spec_args[param_name].annotation - data[param_name]["_resource_type"] = self.device_cls.__module__ + ":" + arg_value - logger.debug(f"自动补充 _resource_type: {data[param_name]['_resource_type']}") - processed_child_names = {} - processed_data = self._process_resource_references(data, processed_child_names, to_dict=False, name_to_uuid=name_to_uuid) - for child_name, resource_instance in processed_data.items(): - for ind, name in enumerate([child.res_content.name for child in self.children]): - if name == child_name: - self.children.pop(ind) - self.device_instance = super(PyLabRobotCreator, self).create_instance(processed_data) # 补全变量后直接调用,调用的自身的attach_resource - except Exception as e: - logger.error(f"PyLabRobot创建实例失败: {e}") - logger.error(f"PyLabRobot创建实例堆栈: {traceback.format_exc()}") - finally: - if self.device_instance is None: - if deserialize_error: - logger.error(f"PyLabRobot反序列化失败: {deserialize_error}") - logger.error(f"PyLabRobot反序列化堆栈: {stack}") - - return self.device_instance - - def post_create(self): - if hasattr(self.device_instance, "setup") and asyncio.iscoroutinefunction( - getattr(self.device_instance, "setup") - ): - from unilabos.ros.nodes.base_device_node import ROS2DeviceNode - - def done_cb(*args): - from pylabrobot.resources import set_volume_tracking - - # from pylabrobot.resources import set_tip_tracking - set_volume_tracking(enabled=True) - # set_tip_tracking(enabled=True) # 序列化tip_spot has为False - logger.debug(f"PyLabRobot设备实例 {self.device_instance} 设置完成") - from unilabos.config.config import BasicConfig - - if BasicConfig.vis_2d_enable: - from pylabrobot.visualizer.visualizer import Visualizer - - vis = Visualizer(resource=self.device_instance, open_browser=True) - - def vis_done_cb(*args): - logger.info(f"PyLabRobot设备实例开启了Visualizer {self.device_instance}") - - ROS2DeviceNode.run_async_func(vis.setup).add_done_callback(vis_done_cb) - logger.debug(f"PyLabRobot设备实例提交开启Visualizer {self.device_instance}") - - ROS2DeviceNode.run_async_func(getattr(self.device_instance, "setup")).add_done_callback(done_cb) - - -class WorkstationNodeCreator(DeviceClassCreator[T]): - """ - WorkstationNode设备类创建器 - - 这个类提供了针对WorkstationNode设备类的实例创建方法,处理children参数。 - """ - - def __init__(self, cls: Type[T], children: List[ResourceDictInstance], resource_tracker: DeviceNodeResourceTracker): - """ - 初始化WorkstationNode设备类创建器 - - Args: - cls: WorkstationNode设备类 - children: 子资源字典,用于资源替换 - """ - super().__init__(cls, children, resource_tracker) - - def create_instance(self, data: Dict[str, Any]) -> T: - """ - 从数据创建WorkstationNode设备实例 - - Args: - data: 用于创建实例的数据 - - Returns: - WorkstationNode设备类实例 - """ - try: - # 创建实例,额外补充一个给protocol node的字段,后面考虑取消 - data["children"] = self.children - # super(WorkstationNodeCreator, self).create_instance(data)的时候会attach - # for child in self.children: - # if child.res_content.type != "device": - # self.resource_tracker.add_resource(child.get_plr_nested_dict()) - deck_dict = data.get("deck") - if deck_dict: - from pylabrobot.resources import Deck, Resource - - plrc = PyLabRobotCreator(Deck, self.children, self.resource_tracker) - deck = plrc.create_instance(deck_dict) - data["deck"] = deck - else: - data["deck"] = None - self.device_instance = super(WorkstationNodeCreator, self).create_instance(data) - self.post_create() - return self.device_instance - except Exception as e: - logger.error(f"WorkstationNode创建实例失败: {e}") - logger.error(f"WorkstationNode创建实例堆栈: {traceback.format_exc()}") - raise +from unilabos.device_runtime.driver_creator import ( + ClassCreator, + DeviceClassCreator, + PyLabRobotCreator, + WorkstationNodeCreator, + uses_pylabrobot_creator, +) + +__all__ = [ + "ClassCreator", + "DeviceClassCreator", + "PyLabRobotCreator", + "WorkstationNodeCreator", + "uses_pylabrobot_creator", +]