diff --git a/.github/workflows/ci-check.yml b/.github/workflows/ci-check.yml index f62676e..6341c27 100644 --- a/.github/workflows/ci-check.yml +++ b/.github/workflows/ci-check.yml @@ -36,21 +36,61 @@ jobs: # Install all packages together for proper dependency resolution # Use mamba for faster and more reliable solving mamba install -n check-env \ - python=3.11.11 \ + python=3.11.14 \ robostack-staging::ros-humble-ros-core \ robostack-staging::ros-humble-action-msgs \ robostack-staging::ros-humble-std-msgs \ robostack-staging::ros-humble-geometry-msgs \ robostack-staging::ros-humble-control-msgs \ + robostack-staging::ros-humble-nav2-msgs \ uni-lab::ros-humble-unilabos-msgs \ + robostack-staging::ros-humble-cv-bridge \ + robostack-staging::ros-humble-vision-opencv \ + robostack-staging::ros-humble-tf-transformations \ + robostack-staging::ros-humble-moveit-msgs \ + robostack-staging::ros-humble-tf2-ros \ + robostack-staging::ros-humble-tf2-ros-py \ + conda-forge::transforms3d \ -c robostack-staging -c conda-forge -c uni-lab -y - - name: Install unilabos project + - name: Install pip dependencies and unilabos run: | + # Activate the environment + conda activate check-env + + # Core dependencies for devices + pip install uv + uv pip install networkx \ + typing_extensions \ + websockets \ + msgcenterpy \ + opentrons_shared_data \ + pint \ + fastapi \ + jinja2 \ + requests \ + uvicorn \ + git+https://github.com/Xuwznln/pylabrobot.git \ + opencv-python \ + pyautogui \ + opcua \ + pyserial \ + pandas \ + crcmod-plus \ + pymodbus \ + pywinauto_recorder \ + matplotlib \ + + + # PyLabRobot (custom fork) + pip install + + # Install unilabos in editable mode pip install -e . - name: Run check mode (complete_registry) run: | + conda activate check-env python -m unilabos --check_mode --skip_env_check - name: Check for uncommitted changes diff --git a/recipes/msgs/recipe.yaml b/recipes/msgs/recipe.yaml index 6d32908..168f427 100644 --- a/recipes/msgs/recipe.yaml +++ b/recipes/msgs/recipe.yaml @@ -1,6 +1,6 @@ package: name: ros-humble-unilabos-msgs - version: 0.10.15 + version: 0.10.16 source: path: ../../unilabos_msgs target_directory: src @@ -25,7 +25,7 @@ requirements: build: - ${{ compiler('cxx') }} - ${{ compiler('c') }} - - python ==3.11.11 + - python ==3.11.14 - numpy - if: build_platform != target_platform then: @@ -63,14 +63,14 @@ requirements: - robostack-staging::ros-humble-rosidl-default-generators - robostack-staging::ros-humble-std-msgs - robostack-staging::ros-humble-geometry-msgs - - robostack-staging::ros2-distro-mutex=0.6 + - robostack-staging::ros2-distro-mutex=0.7 run: - robostack-staging::ros-humble-action-msgs - robostack-staging::ros-humble-ros-workspace - robostack-staging::ros-humble-rosidl-default-runtime - robostack-staging::ros-humble-std-msgs - robostack-staging::ros-humble-geometry-msgs - - robostack-staging::ros2-distro-mutex=0.6 + - robostack-staging::ros2-distro-mutex=0.7 - if: osx and x86_64 then: - __osx >=${{ MACOSX_DEPLOYMENT_TARGET|default('10.14') }} diff --git a/recipes/unilabos/recipe.yaml b/recipes/unilabos/recipe.yaml index be3f1a1..641d5b1 100644 --- a/recipes/unilabos/recipe.yaml +++ b/recipes/unilabos/recipe.yaml @@ -1,6 +1,6 @@ package: name: unilabos - version: "0.10.15" + version: "0.10.16" source: path: ../.. diff --git a/setup.py b/setup.py index b6ae5ed..d286cf8 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ package_name = 'unilabos' setup( name=package_name, - version='0.10.15', + version='0.10.16', packages=find_packages(), include_package_data=True, install_requires=['setuptools'], diff --git a/unilabos/__init__.py b/unilabos/__init__.py index d5ac10a..c69a94b 100644 --- a/unilabos/__init__.py +++ b/unilabos/__init__.py @@ -1 +1 @@ -__version__ = "0.10.15" +__version__ = "0.10.16" diff --git a/unilabos/__main__.py b/unilabos/__main__.py new file mode 100644 index 0000000..6483226 --- /dev/null +++ b/unilabos/__main__.py @@ -0,0 +1,6 @@ +"""Entry point for `python -m unilabos`.""" + +from unilabos.app.main import main + +if __name__ == "__main__": + main() diff --git a/unilabos/app/ws_client.py b/unilabos/app/ws_client.py index 17d69ac..8644353 100644 --- a/unilabos/app/ws_client.py +++ b/unilabos/app/ws_client.py @@ -23,7 +23,7 @@ from typing import Optional, Dict, Any, List from urllib.parse import urlparse from enum import Enum -from jedi.inference.gradual.typing import TypedDict +from typing_extensions import TypedDict from unilabos.app.model import JobAddReq from unilabos.ros.nodes.presets.host_node import HostNode @@ -495,8 +495,12 @@ class MessageProcessor: await self._process_message(message_type, message_data) else: if message_type.endswith("_material"): - logger.trace(f"[MessageProcessor] 收到一条归属 {data.get('edge_session')} 的旧消息:{data}") - logger.debug(f"[MessageProcessor] 跳过了一条归属 {data.get('edge_session')} 的旧消息: {data.get('action')}") + logger.trace( + f"[MessageProcessor] 收到一条归属 {data.get('edge_session')} 的旧消息:{data}" + ) + logger.debug( + f"[MessageProcessor] 跳过了一条归属 {data.get('edge_session')} 的旧消息: {data.get('action')}" + ) else: await self._process_message(message_type, message_data) except json.JSONDecodeError: @@ -848,9 +852,7 @@ class MessageProcessor: device_action_groups[key_add] = [] device_action_groups[key_add].append(item["uuid"]) - logger.info( - f"[资源同步] 跨站Transfer: {item['uuid'][:8]} from {device_old_id} to {device_id}" - ) + logger.info(f"[资源同步] 跨站Transfer: {item['uuid'][:8]} from {device_old_id} to {device_id}") else: # 正常update key = (device_id, "update") @@ -864,7 +866,9 @@ class MessageProcessor: device_action_groups[key] = [] device_action_groups[key].append(item["uuid"]) - logger.trace(f"[资源同步] 动作 {action} 分组数量: {len(device_action_groups)}, 总数量: {len(resource_uuid_list)}") + logger.trace( + f"[资源同步] 动作 {action} 分组数量: {len(device_action_groups)}, 总数量: {len(resource_uuid_list)}" + ) # 为每个(device_id, action)创建独立的更新线程 for (device_id, actual_action), items in device_action_groups.items(): @@ -912,13 +916,13 @@ class MessageProcessor: # 发送确认消息 if self.websocket_client: - await self.websocket_client.send_message({ - "action": "restart_acknowledged", - "data": {"reason": reason, "delay": delay} - }) + await self.websocket_client.send_message( + {"action": "restart_acknowledged", "data": {"reason": reason, "delay": delay}} + ) # 设置全局重启标志 import unilabos.app.main as main_module + main_module._restart_requested = True main_module._restart_reason = reason @@ -928,10 +932,12 @@ class MessageProcessor: # 在新线程中执行清理,避免阻塞当前事件循环 def do_cleanup(): import time + time.sleep(0.5) # 给当前消息处理完成的时间 logger.info(f"[MessageProcessor] Starting cleanup for restart, reason: {reason}") try: from unilabos.app.utils import cleanup_for_restart + if cleanup_for_restart(): logger.info("[MessageProcessor] Cleanup successful, main() will restart") else: @@ -1382,7 +1388,9 @@ class WebSocketClient(BaseCommunicationClient): if host_node: # 获取设备信息 for device_id, namespace in host_node.devices_names.items(): - device_key = f"{namespace}/{device_id}" if namespace.startswith("/") else f"/{namespace}/{device_id}" + device_key = ( + f"{namespace}/{device_id}" if namespace.startswith("/") else f"/{namespace}/{device_id}" + ) is_online = device_key in host_node._online_devices # 获取设备的动作信息 @@ -1396,14 +1404,16 @@ class WebSocketClient(BaseCommunicationClient): "action_type": str(type(client).__name__), } - devices.append({ - "device_id": device_id, - "namespace": namespace, - "device_key": device_key, - "is_online": is_online, - "machine_name": host_node.device_machine_names.get(device_id, machine_name), - "actions": actions, - }) + devices.append( + { + "device_id": device_id, + "namespace": namespace, + "device_key": device_key, + "is_online": is_online, + "machine_name": host_node.device_machine_names.get(device_id, machine_name), + "actions": actions, + } + ) logger.info(f"[WebSocketClient] Collected {len(devices)} devices for host_ready") except Exception as e: diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index 737167a..95fc075 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -49,7 +49,6 @@ from unilabos.resources.resource_tracker import ( ResourceTreeInstance, ResourceDictInstance, ) -from unilabos.ros.x.rclpyx import get_event_loop from unilabos.ros.utils.driver_creator import WorkstationNodeCreator, PyLabRobotCreator, DeviceClassCreator from rclpy.task import Task, Future from unilabos.utils.import_manager import default_manager @@ -185,7 +184,7 @@ class PropertyPublisher: f"创建发布者 {name} 失败,可能由于注册表有误,类型: {msg_type},错误: {ex}\n{traceback.format_exc()}" ) self.timer = node.create_timer(self.timer_period, self.publish_property) - self.__loop = get_event_loop() + self.__loop = ROS2DeviceNode.get_asyncio_loop() str_msg_type = str(msg_type)[8:-2] self.node.lab_logger().trace(f"发布属性: {name}, 类型: {str_msg_type}, 周期: {initial_period}秒, QoS: {qos}") @@ -1757,6 +1756,15 @@ class ROS2DeviceNode: 它不继承设备类,而是通过代理模式访问设备类的属性和方法。 """ + # 类变量,用于循环管理 + _asyncio_loop = None + _asyncio_loop_running = False + _asyncio_loop_thread = None + + @classmethod + def get_asyncio_loop(cls): + return cls._asyncio_loop + @staticmethod async def safe_task_wrapper(trace_callback, func, **kwargs): try: @@ -1833,6 +1841,11 @@ class ROS2DeviceNode: print_publish: 是否打印发布信息 driver_is_ros: """ + # 在初始化时检查循环状态 + if ROS2DeviceNode._asyncio_loop_running and ROS2DeviceNode._asyncio_loop_thread is not None: + pass + elif ROS2DeviceNode._asyncio_loop_thread is None: + self._start_loop() # 保存设备类是否支持异步上下文 self._has_async_context = hasattr(driver_class, "__aenter__") and hasattr(driver_class, "__aexit__") @@ -1924,6 +1937,17 @@ class ROS2DeviceNode: except Exception as e: self._ros_node.lab_logger().error(f"设备后初始化失败: {e}") + def _start_loop(self): + def run_event_loop(): + loop = asyncio.new_event_loop() + ROS2DeviceNode._asyncio_loop = loop + asyncio.set_event_loop(loop) + loop.run_forever() + + ROS2DeviceNode._asyncio_loop_thread = threading.Thread(target=run_event_loop, daemon=True, name="ROS2DeviceNode") + ROS2DeviceNode._asyncio_loop_thread.start() + logger.info(f"循环线程已启动") + class DeviceInfoType(TypedDict): id: str diff --git a/unilabos/ros/x/__init__.py b/unilabos/ros/x/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/unilabos/ros/x/rclpyx.py b/unilabos/ros/x/rclpyx.py deleted file mode 100644 index a723922..0000000 --- a/unilabos/ros/x/rclpyx.py +++ /dev/null @@ -1,182 +0,0 @@ -import asyncio -from asyncio import events -import threading - -import rclpy -from rclpy.impl.implementation_singleton import rclpy_implementation as _rclpy -from rclpy.executors import await_or_execute, Executor -from rclpy.action import ActionClient, ActionServer -from rclpy.action.server import ServerGoalHandle, GoalResponse, GoalInfo, GoalStatus -from std_msgs.msg import String -from action_tutorials_interfaces.action import Fibonacci - - -loop = None - -def get_event_loop(): - global loop - return loop - - -async def default_handle_accepted_callback_async(goal_handle): - """Execute the goal.""" - await goal_handle.execute() - - -class ServerGoalHandleX(ServerGoalHandle): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - async def execute(self, execute_callback=None): - # It's possible that there has been a request to cancel the goal prior to executing. - # In this case we want to avoid the illegal state transition to EXECUTING - # but still call the users execute callback to let them handle canceling the goal. - if not self.is_cancel_requested: - self._update_state(_rclpy.GoalEvent.EXECUTE) - await self._action_server.notify_execute_async(self, execute_callback) - - -class ActionServerX(ActionServer): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.register_handle_accepted_callback(default_handle_accepted_callback_async) - - async def _execute_goal_request(self, request_header_and_message): - request_header, goal_request = request_header_and_message - goal_uuid = goal_request.goal_id - goal_info = GoalInfo() - goal_info.goal_id = goal_uuid - - self._node.get_logger().debug('New goal request with ID: {0}'.format(goal_uuid.uuid)) - - # Check if goal ID is already being tracked by this action server - with self._lock: - goal_id_exists = self._handle.goal_exists(goal_info) - - accepted = False - if not goal_id_exists: - # Call user goal callback - response = await await_or_execute(self._goal_callback, goal_request.goal) - if not isinstance(response, GoalResponse): - self._node.get_logger().warning( - 'Goal request callback did not return a GoalResponse type. Rejecting goal.') - else: - accepted = GoalResponse.ACCEPT == response - - if accepted: - # Stamp time of acceptance - goal_info.stamp = self._node.get_clock().now().to_msg() - - # Create a goal handle - try: - with self._lock: - goal_handle = ServerGoalHandleX(self, goal_info, goal_request.goal) - except RuntimeError as e: - self._node.get_logger().error( - 'Failed to accept new goal with ID {0}: {1}'.format(goal_uuid.uuid, e)) - accepted = False - else: - self._goal_handles[bytes(goal_uuid.uuid)] = goal_handle - - # Send response - response_msg = self._action_type.Impl.SendGoalService.Response() - response_msg.accepted = accepted - response_msg.stamp = goal_info.stamp - self._handle.send_goal_response(request_header, response_msg) - - if not accepted: - self._node.get_logger().debug('New goal rejected: {0}'.format(goal_uuid.uuid)) - return - - self._node.get_logger().debug('New goal accepted: {0}'.format(goal_uuid.uuid)) - - # Provide the user a reference to the goal handle - # await await_or_execute(self._handle_accepted_callback, goal_handle) - asyncio.create_task(self._handle_accepted_callback(goal_handle)) - - async def notify_execute_async(self, goal_handle, execute_callback): - # Use provided callback, defaulting to a previously registered callback - if execute_callback is None: - if self._execute_callback is None: - return - execute_callback = self._execute_callback - - # Schedule user callback for execution - self._node.get_logger().info(f"{events.get_running_loop()}") - asyncio.create_task(self._execute_goal(execute_callback, goal_handle)) - # loop = asyncio.new_event_loop() - # asyncio.set_event_loop(loop) - # task = loop.create_task(self._execute_goal(execute_callback, goal_handle)) - # await task - - -class ActionClientX(ActionClient): - feedback_queue = asyncio.Queue() - - async def feedback_cb(self, msg): - await self.feedback_queue.put(msg) - - async def send_goal_async(self, goal_msg): - goal_future = super().send_goal_async( - goal_msg, - feedback_callback=self.feedback_cb - ) - client_goal_handle = await asyncio.ensure_future(goal_future) - if not client_goal_handle.accepted: - raise Exception("Goal rejected.") - result_future = client_goal_handle.get_result_async() - while True: - feedback_future = asyncio.ensure_future(self.feedback_queue.get()) - tasks = [result_future, feedback_future] - await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) - if result_future.done(): - result = result_future.result().result - yield (None, result) - break - else: - feedback = feedback_future.result().feedback - yield (feedback, None) - - -async def main(node): - print('Node started.') - action_client = ActionClientX(node, Fibonacci, 'fibonacci') - goal_msg = Fibonacci.Goal() - goal_msg.order = 10 - async for (feedback, result) in action_client.send_goal_async(goal_msg): - if feedback: - print(f'Feedback: {feedback}') - else: - print(f'Result: {result}') - print('Finished.') - - -async def ros_loop_node(node): - while rclpy.ok(): - rclpy.spin_once(node, timeout_sec=0) - await asyncio.sleep(1e-4) - - -async def ros_loop(executor: Executor): - while rclpy.ok(): - executor.spin_once(timeout_sec=0) - await asyncio.sleep(1e-4) - - -def run_event_loop(): - global loop - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - loop.run_forever() - - -def run_event_loop_in_thread(): - thread = threading.Thread(target=run_event_loop, args=()) - thread.start() - - -if __name__ == "__main__": - rclpy.init() - node = rclpy.create_node('async_subscriber') - future = asyncio.wait([ros_loop(node), main()]) - asyncio.get_event_loop().run_until_complete(future) \ No newline at end of file diff --git a/unilabos/utils/pywinauto_util.py b/unilabos/utils/pywinauto_util.py index 3b78632..70eeb96 100644 --- a/unilabos/utils/pywinauto_util.py +++ b/unilabos/utils/pywinauto_util.py @@ -1,7 +1,11 @@ import psutil import pywinauto -from pywinauto_recorder import UIApplication -from pywinauto_recorder.player import UIPath, click, focus_on_application, exists, find, get_wrapper_path +try: + from pywinauto_recorder import UIApplication + from pywinauto_recorder.player import UIPath, click, focus_on_application, exists, find, get_wrapper_path +except ImportError: + print("未安装pywinauto_recorder,部分功能无法使用,安装时注意enum") + pass from pywinauto.controls.uiawrapper import UIAWrapper from pywinauto.application import WindowSpecification from pywinauto import findbestmatch diff --git a/unilabos_msgs/package.xml b/unilabos_msgs/package.xml index b9c2632..42d295a 100644 --- a/unilabos_msgs/package.xml +++ b/unilabos_msgs/package.xml @@ -2,7 +2,7 @@ unilabos_msgs - 0.10.15 + 0.10.16 ROS2 Messages package for unilabos devices Junhan Chang Xuwznln