From 2937add51fb4629097157b9d98842052ef239b43 Mon Sep 17 00:00:00 2001 From: Junhan Chang Date: Wed, 17 Jun 2026 14:16:12 +0800 Subject: [PATCH 01/16] =?UTF-8?q?feat(cli):=20=E9=9B=86=E6=88=90=20HTTP=20?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=E4=B8=8E=E5=8C=85=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../skills/filter-workflow-by-tags/SKILL.md | 2 - .../examples/workstation_architecture.md | 16 +- unilabos/app/cli/__init__.py | 28 +++ unilabos/app/cli/_client_factory.py | 40 ++++ unilabos/app/cli/auth.py | 95 ++++++++++ unilabos/app/cli/auth_resolver.py | 112 +++++++++++ unilabos/app/cli/config.py | 45 +++++ unilabos/app/cli/lab.py | 41 ++++ unilabos/app/cli/material.py | 40 ++++ unilabos/app/cli/workflow.py | 70 +++++++ unilabos/app/main.py | 149 ++++++++++++++- unilabos/client/__init__.py | 52 +++++ unilabos/client/envelope.py | 81 ++++++++ unilabos/client/http.py | 121 ++++++++++++ unilabos/client/output.py | 177 ++++++++++++++++++ unilabos/client/session.py | 153 +++++++++++++++ unilabos/utils/requirements.txt | 1 + 17 files changed, 1205 insertions(+), 18 deletions(-) create mode 100644 unilabos/app/cli/__init__.py create mode 100644 unilabos/app/cli/_client_factory.py create mode 100644 unilabos/app/cli/auth.py create mode 100644 unilabos/app/cli/auth_resolver.py create mode 100644 unilabos/app/cli/config.py create mode 100644 unilabos/app/cli/lab.py create mode 100644 unilabos/app/cli/material.py create mode 100644 unilabos/app/cli/workflow.py create mode 100644 unilabos/client/__init__.py create mode 100644 unilabos/client/envelope.py create mode 100644 unilabos/client/http.py create mode 100644 unilabos/client/output.py create mode 100644 unilabos/client/session.py diff --git a/.cursor/skills/filter-workflow-by-tags/SKILL.md b/.cursor/skills/filter-workflow-by-tags/SKILL.md index 6cedd7c49..e692e76a7 100644 --- a/.cursor/skills/filter-workflow-by-tags/SKILL.md +++ b/.cursor/skills/filter-workflow-by-tags/SKILL.md @@ -144,7 +144,6 @@ POST $BASE/api/v1/lab/workflow//run **注意:** - 该接口会使用 workflow 模板中保存的默认参数直接执行 -- 如果 workflow 需要动态参数(如 CSV 路径、样品 UUID),应使用 `POST /lab/notebook` 创建 notebook 并传入 `node_params` - 返回的 `run_uuid` 可直接传入下方「查询任务状态」接口查询实时进度 ### 查询任务状态 @@ -206,7 +205,6 @@ GET $BASE/api/v1/lab/mcp/task/ **注意:** - 此接口的 `task_uuid` **是** `POST /lab/workflow//run` 返回的 `run_uuid`,二者为同一个 ID 的不同称呼 -- **不要**把 notebook UUID(`POST /lab/notebook` 返回)传进来——那条路径用 `GET /lab/notebook/status` 查询 - `jos_status` 数组按节点执行顺序给出;从 pending 数量可以估算剩余进度 - 返回体可能较大(`return_info.return_value` 中可能包含完整 resource tree),可在脚本中只提取 `status` + `node_name` + `action_name` 做摘要 diff --git a/docs/developer_guide/examples/workstation_architecture.md b/docs/developer_guide/examples/workstation_architecture.md index fddfd95c1..6c511fe38 100644 --- a/docs/developer_guide/examples/workstation_architecture.md +++ b/docs/developer_guide/examples/workstation_architecture.md @@ -42,7 +42,7 @@ ### 1.1 工作站核心架构 -```{mermaid} +```mermaid graph TB subgraph "工作站模板组成" WB[WorkstationBase
工作流状态管理] @@ -77,7 +77,7 @@ graph TB ### 1.2 外部系统对接关系 -```{mermaid} +```mermaid graph LR subgraph "Uni-Lab-OS工作站" WS[WorkstationBase + ROS2WorkstationNode] @@ -118,7 +118,7 @@ graph LR ### 1.3 具体实现示例 -```{mermaid} +```mermaid graph TB subgraph "工作站基类" BASE[WorkstationBase
抽象基类] @@ -164,7 +164,7 @@ graph TB ## 2. 类关系图 -```{mermaid} +```mermaid classDiagram class WorkstationBase { <> @@ -307,7 +307,7 @@ classDiagram ## 3. 工作站启动时序图 -```{mermaid} +```mermaid sequenceDiagram participant APP as Application participant WS as WorkstationBase @@ -360,7 +360,7 @@ sequenceDiagram ## 4. 工作流执行时序图(Protocol 模式) -```{mermaid} +```mermaid sequenceDiagram participant CLIENT as 客户端 participant ROS as ROS2WorkstationNode @@ -412,7 +412,7 @@ sequenceDiagram ## 5. HTTP 报送处理时序图 -```{mermaid} +```mermaid sequenceDiagram participant EXT as 外部工作站/LIMS participant HTTP as HTTPService @@ -455,7 +455,7 @@ sequenceDiagram ## 6. 错误处理时序图 -```{mermaid} +```mermaid sequenceDiagram participant DEV as 子设备/外部系统 participant ROS as ROS2WorkstationNode diff --git a/unilabos/app/cli/__init__.py b/unilabos/app/cli/__init__.py new file mode 100644 index 000000000..825be3b4e --- /dev/null +++ b/unilabos/app/cli/__init__.py @@ -0,0 +1,28 @@ +"""CLI 子命令模块 + +提供 HTTP 客户端相关的子命令: +- auth: 认证管理(login, logout, whoami) +- auth_resolver: 凭据多源解析(CLI / session / local_config.py) +- config: 配置管理(config show) +- lab: 实验室管理 +- material: 物料管理 +- workflow: 工作流管理 +""" + +from .auth import cmd_login, cmd_logout, cmd_whoami +from .auth_resolver import resolve_effective_auth +from .config import cmd_config_show +from .lab import cmd_lab_list +from .material import cmd_material_list +from .workflow import cmd_workflow_upload + +__all__ = [ + "cmd_login", + "cmd_logout", + "cmd_whoami", + "resolve_effective_auth", + "cmd_config_show", + "cmd_lab_list", + "cmd_material_list", + "cmd_workflow_upload", +] diff --git a/unilabos/app/cli/_client_factory.py b/unilabos/app/cli/_client_factory.py new file mode 100644 index 000000000..d17d217f5 --- /dev/null +++ b/unilabos/app/cli/_client_factory.py @@ -0,0 +1,40 @@ +"""已认证 HTTP 客户端工厂 + +从 args + SessionManager 构造一个已注入 ak/sk 鉴权的 client.HTTPClient。 +凭据按 resolve_effective_auth 的优先级解析。 + +需要在 SessionManager 上下文管理器内调用。 +""" + +import sys +from typing import Any, Tuple + +from unilabos.client import HTTPClient, HTTPClientConfig, SessionManager, print_error + +from .auth_resolver import resolve_effective_auth + + +def make_authenticated_client( + args: Any, session_manager: SessionManager +) -> Tuple[HTTPClient, str]: + """构造已认证的 HTTPClient + + Returns: + (client, base_url)。如果凭据缺失,打印错误并 sys.exit(1)。 + """ + effective = resolve_effective_auth(args, session_manager) + if not effective["ak"] or not effective["sk"]: + print_error( + "未找到 ak/sk。请通过以下方式之一配置:\n" + " 1. unilab login --ak --sk \n" + " 2. 命令行传入 --ak --sk \n" + " 3. 在 local_config.py 中设置 BasicConfig.ak/sk" + ) + sys.exit(1) + + import base64 + secret = base64.b64encode(f"{effective['ak']}:{effective['sk']}".encode("utf-8")).decode("utf-8") + + config = HTTPClientConfig(base_url=effective["base_url"]) + client = HTTPClient(config, get_auth_secret=lambda: secret) + return client, effective["base_url"] diff --git a/unilabos/app/cli/auth.py b/unilabos/app/cli/auth.py new file mode 100644 index 000000000..37fda1942 --- /dev/null +++ b/unilabos/app/cli/auth.py @@ -0,0 +1,95 @@ +"""认证命令模块 + +基于 ak/sk 的认证: +- login: 保存 ak/sk 到会话文件 +- logout: 清除本地 ak/sk +- whoami: 显示当前有效的 ak/sk 来源(CLI / session.json / local_config.py) + +由于后端没有 whoami 端点,验证发生在实际 API 调用时(如 lab list)返回 401。 +""" + +import sys + +from unilabos.client import ( + SessionManager, + print_error, + print_output, + print_success, +) + + +class AuthError(Exception): + """认证错误""" + pass + + +def cmd_login(args, session_manager: SessionManager): + """login 命令处理 — 保存 ak/sk 到会话文件 + + 如果同时传了 --addr,会一并持久化 base_url。 + """ + try: + with session_manager: + state = session_manager.get_state() + + addr = getattr(args, "addr_resolved", None) + if addr: + state.base_url = addr + + state.auth.ak = args.ak + state.auth.sk = args.sk + + print_success(f"已保存 ak/sk 到 {session_manager.session_file}") + print_output({ + "base_url": state.base_url, + "ak_prefix": args.ak[:8] + "..." if len(args.ak) > 8 else args.ak, + }) + except Exception as e: + print_error(f"登录失败: {e}") + sys.exit(1) + + +def cmd_logout(args, session_manager: SessionManager): + """logout 命令处理 — 清除本地 ak/sk""" + try: + with session_manager: + state = session_manager.get_state() + state.auth.ak = "" + state.auth.sk = "" + state.auth.user_name = "" + print_success("已登出") + except Exception as e: + print_error(f"登出失败: {e}") + sys.exit(1) + + +def cmd_whoami(args, session_manager: SessionManager): + """whoami 命令处理 — 显示当前有效的 ak/sk 来源 + + 优先级:CLI 参数 > session.json > local_config.py 中的 BasicConfig.ak/sk + """ + from unilabos.app.cli.auth_resolver import resolve_effective_auth + + try: + with session_manager: + effective = resolve_effective_auth(args, session_manager) + + if not effective["ak"] or not effective["sk"]: + print_error( + "未找到 ak/sk。请通过以下方式之一配置:\n" + " 1. unilab login --ak --sk \n" + " 2. 命令行传入 --ak --sk \n" + " 3. 在 local_config.py 中设置 BasicConfig.ak/sk" + ) + sys.exit(1) + + print_output({ + "ak_prefix": effective["ak"][:8] + "..." if len(effective["ak"]) > 8 else effective["ak"], + "ak_source": effective["ak_source"], + "sk_source": effective["sk_source"], + "base_url": effective["base_url"], + "base_url_source": effective["base_url_source"], + }) + except Exception as e: + print_error(f"操作失败: {e}") + sys.exit(1) diff --git a/unilabos/app/cli/auth_resolver.py b/unilabos/app/cli/auth_resolver.py new file mode 100644 index 000000000..a568aaac7 --- /dev/null +++ b/unilabos/app/cli/auth_resolver.py @@ -0,0 +1,112 @@ +"""有效凭据解析 + +按优先级聚合来自不同来源的 ak/sk 与 base_url: + 1. CLI 参数 (--ak / --sk / --addr) + 2. 会话文件 (working_dir/session.json) + 3. 本地配置 (working_dir/local_config.py 的 BasicConfig.ak/sk + HTTPConfig.remote_addr) + +""" + +import os +from typing import Any, Dict, Optional + +from unilabos.client import SessionManager, DEFAULT_BASE_URL + + +def _try_load_local_config(working_dir: str) -> Optional[Dict[str, str]]: + """尝试从 working_dir/local_config.py 读取 BasicConfig.ak/sk + HTTPConfig.remote_addr + + 返回 None 表示文件不存在或加载失败;返回 dict 时只包含真实存在的字段。 + """ + config_path = os.path.join(working_dir, "local_config.py") + if not os.path.isfile(config_path): + return None + + try: + import importlib.util + spec = importlib.util.spec_from_file_location("_unilab_local_config", config_path) + if spec is None or spec.loader is None: + return None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + except Exception: + return None + + out: Dict[str, str] = {} + basic = getattr(module, "BasicConfig", None) + if basic is not None: + ak = getattr(basic, "ak", "") + sk = getattr(basic, "sk", "") + if ak: + out["ak"] = ak + if sk: + out["sk"] = sk + http = getattr(module, "HTTPConfig", None) + if http is not None: + addr = getattr(http, "remote_addr", "") + if addr: + out["base_url"] = addr + return out + + +def resolve_effective_auth(args: Any, session_manager: SessionManager) -> Dict[str, str]: + """解析当前有效的 ak/sk + base_url,并标注每个值的来源 + + 必须在 SessionManager 上下文管理器内调用(已加载 state)。 + + Returns: + { + "ak": str, "ak_source": "cli|session|config|none", + "sk": str, "sk_source": "cli|session|config|none", + "base_url": str, "base_url_source": "cli|session|config|default", + } + """ + state = session_manager.get_state() + + cli_ak = getattr(args, "ak", "") or "" + cli_sk = getattr(args, "sk", "") or "" + cli_addr = getattr(args, "addr_resolved", None) + + config_data = _try_load_local_config(str(session_manager.working_dir)) + cfg_ak = (config_data or {}).get("ak", "") + cfg_sk = (config_data or {}).get("sk", "") + cfg_base_url = (config_data or {}).get("base_url", "") + + # ak: CLI > session > config + if cli_ak: + ak, ak_source = cli_ak, "cli" + elif state.auth.ak: + ak, ak_source = state.auth.ak, "session" + elif cfg_ak: + ak, ak_source = cfg_ak, "config" + else: + ak, ak_source = "", "none" + + # sk: 同上 + if cli_sk: + sk, sk_source = cli_sk, "cli" + elif state.auth.sk: + sk, sk_source = state.auth.sk, "session" + elif cfg_sk: + sk, sk_source = cfg_sk, "config" + else: + sk, sk_source = "", "none" + + # base_url: CLI > session(非默认值) > config > default + if cli_addr: + base_url, base_url_source = cli_addr, "cli" + elif state.base_url and state.base_url != DEFAULT_BASE_URL: + base_url, base_url_source = state.base_url, "session" + elif cfg_base_url: + base_url, base_url_source = cfg_base_url, "config" + else: + base_url, base_url_source = DEFAULT_BASE_URL, "default" + + return { + "ak": ak, + "ak_source": ak_source, + "sk": sk, + "sk_source": sk_source, + "base_url": base_url, + "base_url_source": base_url_source, + } diff --git a/unilabos/app/cli/config.py b/unilabos/app/cli/config.py new file mode 100644 index 000000000..4db6e2e8d --- /dev/null +++ b/unilabos/app/cli/config.py @@ -0,0 +1,45 @@ +"""配置命令模块 + +提供 config 子命令: +- config show: 显示当前会话配置(base_url, ak/sk 来源, context) +""" + +import sys + +from unilabos.client import ( + SessionManager, + print_error, + print_output, +) + + +def cmd_config_show(args, session_manager: SessionManager): + """config show 命令处理 — 显示当前会话配置""" + from unilabos.app.cli.auth_resolver import resolve_effective_auth + + try: + with session_manager: + state = session_manager.get_state() + effective = resolve_effective_auth(args, session_manager) + + output = { + "base_url": effective["base_url"], + "base_url_source": effective["base_url_source"], + "ak_prefix": effective["ak"][:8] + "..." if effective["ak"] and len(effective["ak"]) > 8 else effective["ak"] or "(未设置)", + "ak_source": effective["ak_source"], + "sk_source": effective["sk_source"], + "context": { + "lab_uuid": state.context.lab_uuid or "(未设置)", + "project_uuid": state.context.project_uuid or "(未设置)", + }, + } + + # 如果传了 --addr 但与会话中的不同,显示覆盖提示 + addr_override = getattr(args, "addr_resolved", None) + if addr_override and addr_override != state.base_url: + output["addr_override"] = addr_override + + print_output(output) + except Exception as e: + print_error(f"操作失败: {e}") + sys.exit(1) diff --git a/unilabos/app/cli/lab.py b/unilabos/app/cli/lab.py new file mode 100644 index 000000000..5c3486922 --- /dev/null +++ b/unilabos/app/cli/lab.py @@ -0,0 +1,41 @@ +"""实验室命令模块 + +提供 lab 子命令: +- lab list: 获取当前用户的所有实验室(GET /lab/list) +""" + +import sys + +from unilabos.client import ( + EnvelopeError, + SessionManager, + print_error, + print_output, +) + +from ._client_factory import make_authenticated_client + + +def cmd_lab_list(args, session_manager: SessionManager): + """lab list 命令处理 — GET /lab/list?page=&page_size=""" + try: + with session_manager: + client, _ = make_authenticated_client(args, session_manager) + + try: + data = client.get( + "/lab/list", + params={"page": args.page, "page_size": args.page_size}, + ) + print_output(data) + except EnvelopeError as e: + print_error(f"获取实验室列表失败: {e.error}") + sys.exit(1) + except Exception as e: + print_error(f"获取实验室列表失败: {e}") + sys.exit(1) + except SystemExit: + raise + except Exception as e: + print_error(f"操作失败: {e}") + sys.exit(1) diff --git a/unilabos/app/cli/material.py b/unilabos/app/cli/material.py new file mode 100644 index 000000000..f0db0eaa0 --- /dev/null +++ b/unilabos/app/cli/material.py @@ -0,0 +1,40 @@ +"""物料命令模块 + +提供 material 子命令: +- material list: 查询实验室物料(GET /lab/material?id=&with_children=) +""" + +import sys + +from unilabos.client import ( + EnvelopeError, + SessionManager, + print_error, + print_output, +) + +from ._client_factory import make_authenticated_client + + +def cmd_material_list(args, session_manager: SessionManager): + """material list 命令处理 — GET /lab/material?id=&with_children=""" + try: + with session_manager: + client, _ = make_authenticated_client(args, session_manager) + + params = {"id": args.lab_uuid, "with_children": str(args.with_children).lower()} + + try: + data = client.get("/lab/material", params=params) + print_output(data) + except EnvelopeError as e: + print_error(f"获取物料列表失败: {e.error}") + sys.exit(1) + except Exception as e: + print_error(f"获取物料列表失败: {e}") + sys.exit(1) + except SystemExit: + raise + except Exception as e: + print_error(f"操作失败: {e}") + sys.exit(1) diff --git a/unilabos/app/cli/workflow.py b/unilabos/app/cli/workflow.py new file mode 100644 index 000000000..0911ec7b2 --- /dev/null +++ b/unilabos/app/cli/workflow.py @@ -0,0 +1,70 @@ +"""工作流命令模块 + +提供 workflow 子命令: +- workflow upload: 上传工作流文件(迁移自 workflow_upload) + +通过 resolve_effective_auth 注入凭据到 BasicConfig / HTTPConfig, +然后委托给现有的 handle_workflow_upload_command 实现。 +""" + +import sys +from typing import Any, Dict + +from unilabos.client import ( + SessionManager, + print_error, + print_success, +) + + +def _inject_credentials(args: Any, session_manager: SessionManager) -> bool: + """将解析后的 ak/sk + base_url 注入到 BasicConfig / HTTPConfig + + Returns: + 是否成功注入(凭据完整时返回 True) + """ + from unilabos.app.cli.auth_resolver import resolve_effective_auth + from unilabos.config.config import BasicConfig, HTTPConfig + + effective = resolve_effective_auth(args, session_manager) + + if not effective["ak"] or not effective["sk"]: + print_error( + "未找到 ak/sk。请通过以下方式之一配置:\n" + " 1. unilab login --ak --sk \n" + " 2. 命令行传入 --ak --sk \n" + " 3. 在 local_config.py 中设置 BasicConfig.ak/sk" + ) + return False + + BasicConfig.ak = effective["ak"] + BasicConfig.sk = effective["sk"] + BasicConfig.working_dir = str(session_manager.working_dir) + HTTPConfig.remote_addr = effective["base_url"] + return True + + +def cmd_workflow_upload(args, session_manager: SessionManager): + """workflow upload 命令处理""" + try: + with session_manager: + if not _inject_credentials(args, session_manager): + sys.exit(1) + + # 注意:handle_workflow_upload_command 期待 args_dict 形式 + from unilabos.workflow.wf_utils import handle_workflow_upload_command + + args_dict: Dict[str, Any] = { + "workflow_file": args.workflow_file, + "workflow_name": args.workflow_name, + "tags": args.tags or [], + "published": args.published, + "description": args.description or "", + } + handle_workflow_upload_command(args_dict) + print_success("工作流上传完成") + except SystemExit: + raise + except Exception as e: + print_error(f"上传失败: {e}") + sys.exit(1) diff --git a/unilabos/app/main.py b/unilabos/app/main.py index 8f9adbfaf..b88d09df9 100644 --- a/unilabos/app/main.py +++ b/unilabos/app/main.py @@ -409,6 +409,57 @@ def parse_args(): action="store_true", help="Skip post-install @device scan / device listing", ) + + # HTTP 客户端子命令(与现有 --ak/--sk/--addr 复用) + parser.add_argument( + "--json", + action="store_true", + help="Output in JSON format (for AI agent consumption)", + ) + + # login: 保存 ak/sk 到会话文件 + login_parser = subparsers.add_parser("login", help="Save ak/sk to session file") + login_parser.add_argument("--ak", type=str, required=True, help="Access key") + login_parser.add_argument("--sk", type=str, required=True, help="Secret key") + + subparsers.add_parser("logout", help="Clear local ak/sk") + subparsers.add_parser("whoami", help="Show current user information") + + # config show: 查看当前会话配置 + config_parser = subparsers.add_parser("config", help="Show session configuration") + config_subparsers = config_parser.add_subparsers(title="config subcommands", dest="config_command") + config_subparsers.add_parser("show", help="Show current session configuration") + + # lab 命令组 + lab_grp_parser = subparsers.add_parser("lab", help="Laboratory management") + lab_grp_subparsers = lab_grp_parser.add_subparsers(title="lab subcommands", dest="lab_command") + lab_list_parser = lab_grp_subparsers.add_parser("list", help="List laboratories") + lab_list_parser.add_argument("--page", type=int, default=1, help="Page number") + lab_list_parser.add_argument("--page_size", type=int, default=20, help="Page size") + + # material 命令组 + material_grp_parser = subparsers.add_parser("material", help="Material management") + material_grp_subparsers = material_grp_parser.add_subparsers( + title="material subcommands", dest="material_command" + ) + material_list_parser = material_grp_subparsers.add_parser("list", help="List materials in a lab") + material_list_parser.add_argument("--lab_uuid", type=str, required=True, help="Lab UUID") + material_list_parser.add_argument( + "--with_children", action="store_true", default=False, help="Include child resources" + ) + + # workflow 命令组 + workflow_grp_parser = subparsers.add_parser("workflow", help="Workflow management") + workflow_grp_subparsers = workflow_grp_parser.add_subparsers( + title="workflow subcommands", dest="workflow_command" + ) + wf_upload_parser = workflow_grp_subparsers.add_parser("upload", help="Upload workflow file") + wf_upload_parser.add_argument("-f", "--workflow_file", type=str, required=True, help="Workflow file (JSON)") + wf_upload_parser.add_argument("-n", "--workflow_name", type=str, default=None, help="Workflow name") + wf_upload_parser.add_argument("--tags", type=str, nargs="*", default=[], help="Tags (space-separated)") + wf_upload_parser.add_argument("--published", action="store_true", default=False, help="Publish after upload") + wf_upload_parser.add_argument("--description", type=str, default="", help="Workflow description") + return parser @@ -443,6 +494,93 @@ def main(): args = parser.parse_args() args_dict = vars(args) + # 处理 HTTP 客户端子命令(login, logout, whoami, config, lab, material, workflow) + # 这些命令不需要加载完整的 UniLab-OS 环境,提前处理并退出 + http_client_commands = ["login", "logout", "whoami", "config", "lab", "material", "workflow"] + if args_dict.get("command") in http_client_commands: + from unilabos.client import ( + SessionManager, + set_output_format, + OutputFormat, + print_error, + print_output, + resolve_addr, + ) + from unilabos.app.cli.auth import cmd_login, cmd_logout, cmd_whoami + from unilabos.app.cli.config import cmd_config_show + from unilabos.app.cli.lab import cmd_lab_list + from unilabos.app.cli.material import cmd_material_list + from unilabos.app.cli.workflow import cmd_workflow_upload + + # 设置输出格式 + if args_dict.get("json", False): + set_output_format(OutputFormat.JSON) + + # 解析 working_dir:与设备控制模式逻辑一致(cwd 或 cwd/unilabos_data) + raw_working_dir = args_dict.get("working_dir") + if raw_working_dir: + wd = os.path.abspath(raw_working_dir) + else: + wd = os.path.abspath(os.getcwd()) + if os.path.basename(wd) != "unilabos_data": + sub = os.path.join(wd, "unilabos_data") + if os.path.isdir(sub): + wd = sub + + # 解析 --addr(支持 test/uat/local/prod 别名) + addr_arg = args_dict.get("addr") + if addr_arg and addr_arg != parser.get_default("addr"): + args.addr_resolved = resolve_addr(addr_arg) + else: + args.addr_resolved = None + + # 创建会话管理器 + session_manager = SessionManager(working_dir=wd) + + # 路由到对应的命令处理函数 + command = args_dict.get("command") + if command == "login": + cmd_login(args, session_manager) + elif command == "logout": + cmd_logout(args, session_manager) + elif command == "whoami": + cmd_whoami(args, session_manager) + elif command == "config": + config_command = args_dict.get("config_command") + if config_command == "show": + cmd_config_show(args, session_manager) + else: + print_error("config 子命令需要指定: show") + sys.exit(1) + elif command == "lab": + lab_command = args_dict.get("lab_command") + if lab_command == "list": + cmd_lab_list(args, session_manager) + else: + print_error("lab 子命令需要指定: list") + sys.exit(1) + elif command == "material": + material_command = args_dict.get("material_command") + if material_command == "list": + cmd_material_list(args, session_manager) + else: + print_error("material 子命令需要指定: list") + sys.exit(1) + elif command == "workflow": + workflow_command = args_dict.get("workflow_command") + if workflow_command == "upload": + cmd_workflow_upload(args, session_manager) + else: + print_error("workflow 子命令需要指定: upload") + sys.exit(1) + else: + print_error(f"{command} 命令暂未实现") + sys.exit(1) + + sys.stdout.flush() + sys.stderr.flush() + os._exit(0) + # Supervisor mode: spawn child processes and monitor for restart if args_dict.get("restart_mode", False): _run_as_supervisor(args_dict.get("auto_restart_count", 5)) @@ -737,15 +875,10 @@ def main(): else: print_status("本次启动注册表不报送云端,如果您需要联网调试,请在启动命令增加--upload_registry", "warning") - # 处理 workflow_upload 子命令 - if workflow_upload: - from unilabos.workflow.wf_utils import handle_workflow_upload_command - - handle_workflow_upload_command(args_dict) - print_status("工作流上传完成,程序退出", "info") - os._exit(0) + workflow_upload = args_dict.get("command") in ("workflow_upload", "wf") - if not BasicConfig.ak or not BasicConfig.sk: + # 使用远程资源启动 + if not workflow_upload and args_dict["use_remote_resource"]: print_status("后续运行必须拥有一个实验室,请前往 https://leap-lab.bohrium.com 注册实验室!", "warning") os._exit(1) graph: nx.Graph diff --git a/unilabos/client/__init__.py b/unilabos/client/__init__.py new file mode 100644 index 000000000..d05ba490f --- /dev/null +++ b/unilabos/client/__init__.py @@ -0,0 +1,52 @@ +"""HTTP 客户端模块 + +提供与 uni-lab-backend HTTP API 通信的能力: +- HTTPClient: 基于 httpx 的 HTTP 客户端(ak/sk 鉴权) +- SessionManager: 会话状态管理 +- 响应信封解析 +- 输出格式化 +""" + +from .envelope import Envelope, EnvelopeError, parse_envelope, unwrap_envelope +from .http import HTTPClient, HTTPClientConfig +from .session import ( + SessionManager, + SessionState, + AuthInfo, + ContextInfo, + DEFAULT_BASE_URL, + resolve_addr, +) +from .output import ( + OutputFormat, + OutputFormatter, + set_output_format, + get_formatter, + print_output, + print_success, + print_error, + print_warning, +) + +__all__ = [ + "Envelope", + "EnvelopeError", + "parse_envelope", + "unwrap_envelope", + "HTTPClient", + "HTTPClientConfig", + "SessionManager", + "SessionState", + "AuthInfo", + "ContextInfo", + "DEFAULT_BASE_URL", + "resolve_addr", + "OutputFormat", + "OutputFormatter", + "set_output_format", + "get_formatter", + "print_output", + "print_success", + "print_error", + "print_warning", +] diff --git a/unilabos/client/envelope.py b/unilabos/client/envelope.py new file mode 100644 index 000000000..d610395bd --- /dev/null +++ b/unilabos/client/envelope.py @@ -0,0 +1,81 @@ +"""响应信封解析 + +uni-lab-backend 的 HTTP 响应格式: +{ + "code": 0, # 0 表示成功,非 0 表示错误 + "error": "", # 错误信息 + "data": {...}, # 实际数据 + "timestamp": 1234567890 +} +""" + +from typing import Any, Dict, Optional +from dataclasses import dataclass + + +@dataclass +class Envelope: + """响应信封""" + code: int + error: str + data: Any + timestamp: int + + def is_success(self) -> bool: + """是否成功""" + return self.code == 0 + + +class EnvelopeError(Exception): + """信封错误""" + def __init__(self, code: int, error: str): + self.code = code + self.error = error + super().__init__(f"[{code}] {error}") + + +def parse_envelope(response_json: Dict[str, Any]) -> Envelope: + """解析响应信封 + + Args: + response_json: HTTP 响应的 JSON 数据 + + Returns: + Envelope 对象 + + Raises: + ValueError: 响应格式不正确 + """ + if not isinstance(response_json, dict): + raise ValueError("响应必须是 JSON 对象") + + if "code" not in response_json: + raise ValueError("响应缺少 code 字段") + + return Envelope( + code=response_json.get("code", -1), + error=response_json.get("error", ""), + data=response_json.get("data"), + timestamp=response_json.get("timestamp", 0), + ) + + +def unwrap_envelope(response_json: Dict[str, Any]) -> Any: + """解析响应信封并提取数据 + + Args: + response_json: HTTP 响应的 JSON 数据 + + Returns: + data 字段的内容 + + Raises: + EnvelopeError: 响应 code 非 0 + ValueError: 响应格式不正确 + """ + envelope = parse_envelope(response_json) + + if not envelope.is_success(): + raise EnvelopeError(envelope.code, envelope.error) + + return envelope.data diff --git a/unilabos/client/http.py b/unilabos/client/http.py new file mode 100644 index 000000000..435ddd8e0 --- /dev/null +++ b/unilabos/client/http.py @@ -0,0 +1,121 @@ +"""HTTP 客户端 + +基于 httpx 的 HTTP 客户端,提供: +- ak/sk 认证(Authorization: Lab ) +- 重试机制(指数退避,仅对 5xx 和网络错误) +- 响应信封解析 +""" + +import time +from dataclasses import dataclass +from typing import Any, Callable, Dict, Optional + +import httpx + +from .envelope import EnvelopeError, unwrap_envelope + + +@dataclass +class HTTPClientConfig: + """HTTP 客户端配置""" + base_url: str + timeout: float = 30.0 + max_retries: int = 3 + retry_backoff: float = 1.0 + + +class HTTPClient: + """HTTP 客户端 + + 使用示例: + config = HTTPClientConfig(base_url="https://leap-lab.bohrium.com/api/v1") + client = HTTPClient(config, get_auth_secret=lambda: "base64_ak_sk") + data = client.get("/labs") + data = client.post("/labs", json={"name": "Lab1"}) + """ + + def __init__( + self, + config: HTTPClientConfig, + get_auth_secret: Optional[Callable[[], str]] = None, + ): + """初始化 HTTP 客户端 + + Args: + config: 客户端配置 + get_auth_secret: 获取 base64(ak:sk) 的回调函数 + """ + self.config = config + self.get_auth_secret = get_auth_secret + self._client = httpx.Client( + base_url=config.base_url, + timeout=config.timeout, + ) + + def _get_headers(self) -> Dict[str, str]: + headers = {"Content-Type": "application/json"} + if self.get_auth_secret: + secret = self.get_auth_secret() + if secret: + headers["Authorization"] = f"Lab {secret}" + return headers + + def _request(self, method: str, path: str, **kwargs) -> Any: + """发送 HTTP 请求 + + Returns: + 响应数据(已解析信封) + + Raises: + EnvelopeError: 业务错误(code != 0) + httpx.HTTPError: HTTP 错误 + """ + headers = self._get_headers() + kwargs.setdefault("headers", {}).update(headers) + + retries = 0 + last_error = None + + while retries <= self.config.max_retries: + try: + response = self._client.request(method, path, **kwargs) + response.raise_for_status() + return unwrap_envelope(response.json()) + + except (httpx.HTTPError, EnvelopeError) as e: + last_error = e + + # 业务错误和 4xx 不重试 + if isinstance(e, EnvelopeError): + raise + if isinstance(e, httpx.HTTPStatusError) and e.response.status_code < 500: + raise + + retries += 1 + if retries <= self.config.max_retries: + sleep_time = self.config.retry_backoff * (2 ** (retries - 1)) + time.sleep(sleep_time) + + raise last_error + + def get(self, path: str, **kwargs) -> Any: + return self._request("GET", path, **kwargs) + + def post(self, path: str, **kwargs) -> Any: + return self._request("POST", path, **kwargs) + + def put(self, path: str, **kwargs) -> Any: + return self._request("PUT", path, **kwargs) + + def delete(self, path: str, **kwargs) -> Any: + return self._request("DELETE", path, **kwargs) + + def close(self): + self._client.close() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + return False diff --git a/unilabos/client/output.py b/unilabos/client/output.py new file mode 100644 index 000000000..12b97212b --- /dev/null +++ b/unilabos/client/output.py @@ -0,0 +1,177 @@ +"""输出格式化 + +支持两种输出格式: +- HUMAN: 人类可读的格式(表格、彩色文本) +- JSON: 机器可读的 JSON 格式(供 AI agent 使用) +""" + +import json +import sys +from enum import Enum +from typing import Any, Dict, List, Optional + + +class OutputFormat(Enum): + """输出格式""" + HUMAN = "human" + JSON = "json" + + +class OutputFormatter: + """输出格式化器""" + + def __init__(self, format: OutputFormat = OutputFormat.HUMAN): + self.format = format + + def format_output(self, data: Any, message: Optional[str] = None) -> str: + """格式化输出数据 + + Args: + data: 要输出的数据 + message: 可选的消息 + + Returns: + 格式化后的字符串 + """ + if self.format == OutputFormat.JSON: + return self._format_json(data, message) + else: + return self._format_human(data, message) + + def _format_json(self, data: Any, message: Optional[str] = None) -> str: + """JSON 格式""" + output = {"data": data} + if message: + output["message"] = message + return json.dumps(output, ensure_ascii=False, indent=2) + + def _format_human(self, data: Any, message: Optional[str] = None) -> str: + """人类可读格式""" + lines = [] + + if message: + lines.append(message) + lines.append("") + + if isinstance(data, dict): + lines.extend(self._format_dict(data)) + elif isinstance(data, list): + lines.extend(self._format_list(data)) + else: + lines.append(str(data)) + + return "\n".join(lines) + + def _format_dict(self, data: Dict[str, Any]) -> List[str]: + """格式化字典""" + lines = [] + max_key_len = max(len(str(k)) for k in data.keys()) if data else 0 + + for key, value in data.items(): + key_str = str(key).ljust(max_key_len) + if isinstance(value, (dict, list)): + value_str = json.dumps(value, ensure_ascii=False) + else: + value_str = str(value) + lines.append(f"{key_str} : {value_str}") + + return lines + + def _format_list(self, data: List[Any]) -> List[str]: + """格式化列表""" + if not data: + return ["(空列表)"] + + # 如果是字典列表,尝试表格格式 + if all(isinstance(item, dict) for item in data): + return self._format_table(data) + + # 否则逐行输出 + lines = [] + for i, item in enumerate(data, 1): + if isinstance(item, dict): + lines.append(f"[{i}]") + lines.extend(f" {line}" for line in self._format_dict(item)) + else: + lines.append(f"[{i}] {item}") + + return lines + + def _format_table(self, data: List[Dict[str, Any]]) -> List[str]: + """格式化表格""" + if not data: + return [] + + # 获取所有列 + columns = list(data[0].keys()) + + # 计算列宽 + col_widths = {} + for col in columns: + col_widths[col] = len(col) + for row in data: + value = str(row.get(col, "")) + col_widths[col] = max(col_widths[col], len(value)) + + # 生成表格 + lines = [] + + # 表头 + header = " | ".join(col.ljust(col_widths[col]) for col in columns) + lines.append(header) + lines.append("-" * len(header)) + + # 数据行 + for row in data: + line = " | ".join( + str(row.get(col, "")).ljust(col_widths[col]) + for col in columns + ) + lines.append(line) + + return lines + + +# 全局格式化器 +_formatter = OutputFormatter() + + +def set_output_format(format: OutputFormat): + """设置全局输出格式""" + global _formatter + _formatter = OutputFormatter(format) + + +def get_formatter() -> OutputFormatter: + """获取全局格式化器""" + return _formatter + + +def print_output(data: Any, message: Optional[str] = None): + """打印输出数据""" + output = _formatter.format_output(data, message) + print(output) + + +def print_success(message: str): + """打印成功消息""" + if _formatter.format == OutputFormat.JSON: + print(json.dumps({"status": "success", "message": message}, ensure_ascii=False)) + else: + print(f"✓ {message}") + + +def print_error(message: str): + """打印错误消息""" + if _formatter.format == OutputFormat.JSON: + print(json.dumps({"status": "error", "message": message}, ensure_ascii=False), file=sys.stderr) + else: + print(f"✗ {message}", file=sys.stderr) + + +def print_warning(message: str): + """打印警告消息""" + if _formatter.format == OutputFormat.JSON: + print(json.dumps({"status": "warning", "message": message}, ensure_ascii=False)) + else: + print(f"⚠ {message}") diff --git a/unilabos/client/session.py b/unilabos/client/session.py new file mode 100644 index 000000000..52841a35b --- /dev/null +++ b/unilabos/client/session.py @@ -0,0 +1,153 @@ +"""会话状态管理 + +管理 HTTP 客户端会话状态,包括: +- 认证信息(ak/sk) +- 后端地址(base_url) +- 上下文信息(当前实验室、项目) + +会话文件存储在 /session.json +使用文件锁确保并发安全 +""" + +import base64 +import json +import os +import fcntl +from pathlib import Path +from typing import Optional, Dict, Any +from dataclasses import dataclass, field, asdict + + +DEFAULT_BASE_URL = "https://leap-lab.bohrium.com/api/v1" + + +@dataclass +class AuthInfo: + """认证信息(基于 ak/sk)""" + ak: str = "" + sk: str = "" + user_name: str = "" + + def is_valid(self) -> bool: + """检查是否已配置 ak/sk""" + return bool(self.ak and self.sk) + + def auth_secret(self) -> str: + """生成 base64(ak:sk) 用作 Authorization header""" + if not self.is_valid(): + return "" + target = f"{self.ak}:{self.sk}" + return base64.b64encode(target.encode("utf-8")).decode("utf-8") + + +@dataclass +class ContextInfo: + """上下文信息""" + lab_uuid: str = "" + project_uuid: str = "" + + +@dataclass +class SessionState: + """会话状态""" + base_url: str = DEFAULT_BASE_URL + auth: AuthInfo = field(default_factory=AuthInfo) + context: ContextInfo = field(default_factory=ContextInfo) + + def to_dict(self) -> Dict[str, Any]: + return { + "base_url": self.base_url, + "auth": asdict(self.auth), + "context": asdict(self.context), + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "SessionState": + return cls( + base_url=data.get("base_url", DEFAULT_BASE_URL), + auth=AuthInfo(**data.get("auth", {})), + context=ContextInfo(**data.get("context", {})), + ) + + +def resolve_addr(addr: str) -> str: + """解析 --addr 参数 + + 支持别名: + test → https://leap-lab.test.bohrium.com/api/v1 + uat → https://leap-lab.uat.bohrium.com/api/v1 + local → http://127.0.0.1:48197/api/v1 + 其他 → 直接作为 URL 使用 + """ + aliases = { + "test": "https://leap-lab.test.bohrium.com/api/v1", + "uat": "https://leap-lab.uat.bohrium.com/api/v1", + "local": "http://127.0.0.1:48197/api/v1", + "prod": DEFAULT_BASE_URL, + } + return aliases.get(addr, addr) + + +class SessionManager: + """会话管理器 + + 使用文件锁确保并发安全,支持上下文管理器: + + with SessionManager(working_dir="/path/to/wd") as manager: + state = manager.get_state() + state.auth.ak = "..." + # 退出时自动保存 + """ + + def __init__(self, working_dir: Optional[str] = None): + if working_dir: + self.working_dir = Path(working_dir) + else: + self.working_dir = Path.cwd() + self.session_file = self.working_dir / "session.json" + self._lock_file: Optional[Any] = None + self._state: Optional[SessionState] = None + + def __enter__(self): + self._acquire_lock() + self._state = self._load_state() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + if exc_type is None: + self._save_state() + self._release_lock() + return False + + def _acquire_lock(self): + self.working_dir.mkdir(parents=True, exist_ok=True) + lock_file_path = self.working_dir / "session.lock" + self._lock_file = open(lock_file_path, "w") + fcntl.flock(self._lock_file.fileno(), fcntl.LOCK_EX) + + def _release_lock(self): + if self._lock_file: + fcntl.flock(self._lock_file.fileno(), fcntl.LOCK_UN) + self._lock_file.close() + self._lock_file = None + + def _load_state(self) -> SessionState: + if self.session_file.exists(): + try: + with open(self.session_file, "r") as f: + return SessionState.from_dict(json.load(f)) + except (json.JSONDecodeError, KeyError): + pass + return SessionState() + + def _save_state(self): + if self._state is None: + return + self.working_dir.mkdir(parents=True, exist_ok=True) + with open(self.session_file, "w") as f: + json.dump(self._state.to_dict(), f, indent=2) + + def get_state(self) -> SessionState: + if self._state is None: + raise RuntimeError("必须在上下文管理器中使用 SessionManager") + return self._state diff --git a/unilabos/utils/requirements.txt b/unilabos/utils/requirements.txt index 105d387d7..75ed4ca8a 100644 --- a/unilabos/utils/requirements.txt +++ b/unilabos/utils/requirements.txt @@ -8,6 +8,7 @@ pint fastapi jinja2 requests +httpx>=0.27.0 uvicorn pyautogui opcua From f84234b85fc5350dcfe9a0486e73128e393e6f34 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Sat, 27 Jun 2026 03:32:15 +0800 Subject: [PATCH 02/16] materials --- .github/workflows/ci-check.yml | 22 ++ .github/workflows/multi-platform-build.yml | 7 +- unilabos/registry/registry.py | 4 + unilabos/ros/nodes/presets/host_node.py | 251 +++++++++++++++++++-- 4 files changed, 267 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci-check.yml b/.github/workflows/ci-check.yml index 2b227db1e..5773da553 100644 --- a/.github/workflows/ci-check.yml +++ b/.github/workflows/ci-check.yml @@ -14,6 +14,10 @@ jobs: # Fix Unicode encoding issue on Windows runner (cp1252 -> utf-8) PYTHONIOENCODING: utf-8 PYTHONUTF8: 1 + # 强制 matplotlib 使用无头后端,避免在无显示的 Windows runner 上加载 GUI 后端 DLL 导致原生崩溃 + MPLBACKEND: Agg + # 原生崩溃(0xC0000005 访问违例)时打印 Python 调用栈,便于定位是哪个设备模块 import 崩溃 + PYTHONFAULTHANDLER: "1" defaults: run: @@ -50,10 +54,28 @@ jobs: uv pip install . - name: Run check mode (AST registry validation) + # check_mode 会真实 import 所有设备类(连带 matplotlib/opencv 等原生库)。 + # Windows runner 上偶发原生崩溃(退出码 -1073741819 = 0xC0000005 访问违例), + # 这是环境/DLL 层面的 flaky 崩溃,与注册表内容无关,因此对该步做有限次重试。 + # 注意:段错误退出码为负数,必须用 "%ERRORLEVEL% EQU 0" 显式判断, + # 不能用 "if errorlevel 1"(它对负数退出码会误判为成功)。 run: | call conda activate check-env echo Running check mode... + set ATTEMPTS=4 + set COUNT=0 + :retry + set /a COUNT+=1 + echo === Check attempt %COUNT% of %ATTEMPTS% === python -m unilabos --check_mode --skip_env_check + if %ERRORLEVEL% EQU 0 ( + echo Check passed on attempt %COUNT% + exit /b 0 + ) + echo Attempt %COUNT% failed with exit code %ERRORLEVEL% + if %COUNT% LSS %ATTEMPTS% goto retry + echo All %ATTEMPTS% attempts failed + exit /b 1 - name: Check for uncommitted changes shell: bash diff --git a/.github/workflows/multi-platform-build.yml b/.github/workflows/multi-platform-build.yml index 5d3496a08..45e0872c4 100644 --- a/.github/workflows/multi-platform-build.yml +++ b/.github/workflows/multi-platform-build.yml @@ -123,6 +123,10 @@ jobs: echo "CONDA_NO_PLUGINS=true" >> "$GITHUB_ENV" echo "PYTHONUTF8=1" >> "$GITHUB_ENV" echo "PYTHONIOENCODING=utf-8" >> "$GITHUB_ENV" + # anaconda-client 1.13+ 把 `anaconda` 入口迁移到插件化的 anaconda-cli-base, + # 新版顶层移除了 `--version`,且会改变 `anaconda upload` 的命名空间。 + # 强制使用独立(standalone)CLI 以恢复完全向后兼容的行为(--version 与 upload 均可用)。 + echo "ANACONDA_CLIENT_FORCE_STANDALONE=1" >> "$GITHUB_ENV" - name: Show environment info if: steps.should_build.outputs.should_build == 'true' @@ -130,7 +134,8 @@ jobs: conda info conda list -n build-env | grep -E "(rattler-build|anaconda-client)" conda run -n build-env rattler-build --version - conda run -n build-env anaconda --version + # 用 binstar_client 模块入口查看版本,无论 anaconda CLI 处于 standalone 还是 plugin 模式都可用 + conda run -n build-env python -m binstar_client.scripts.cli --version echo "Platform: ${{ matrix.platform }}" echo "OS: ${{ matrix.os }}" diff --git a/unilabos/registry/registry.py b/unilabos/registry/registry.py index 94a437f50..820712524 100644 --- a/unilabos/registry/registry.py +++ b/unilabos/registry/registry.py @@ -190,6 +190,8 @@ def _setup_host_node(self): apply_deduct_resource_action = ast_actions.get("apply_deduct_resource", {}) set_substance_action = ast_actions.get("set_substance", {}) discard_resource_action = ast_actions.get("discard_resource", {}) + transfer_resource_action = ast_actions.get("transfer_resource", {}) + transfer_manual_action = ast_actions.get("transfer_manual", {}) test_resource_action["handles"] = { "input": [ { @@ -271,6 +273,8 @@ def _setup_host_node(self): "apply_deduct_resource": apply_deduct_resource_action, "set_substance": set_substance_action, "discard_resource": discard_resource_action, + "transfer_resource": transfer_resource_action, + "transfer_manual": transfer_manual_action, }, "init_params": {}, }, diff --git a/unilabos/ros/nodes/presets/host_node.py b/unilabos/ros/nodes/presets/host_node.py index bcc255ead..cbc4c1e18 100644 --- a/unilabos/ros/nodes/presets/host_node.py +++ b/unilabos/ros/nodes/presets/host_node.py @@ -94,6 +94,22 @@ class DeductResourceReturn(CreateResourceReturn): mount_resource: List[List[ResourceDictType]] +class TransferResourceReturn(TypedDict): + """transfer_resource 返回值:透传被转移物料与目标孔位,便于下游引用。""" + + resource: List[List[ResourceDictType]] + mount_resource: List[List[ResourceDictType]] + result: Any + + +class TransferManualReturn(TypedDict): + """transfer_manual 返回值:人工搬运闸门,仅透传物料/目标设备/目标孔位,不做系统转移。""" + + resource: List[List[ResourceDictType]] + mount_resource: List[List[ResourceDictType]] + target_device: str + + class TestLatencyReturn(TypedDict): """test_latency方法的返回值类型""" @@ -1698,53 +1714,73 @@ def manual_confirm(self, timeout_seconds: int, assignee_user_ids: list[str], **k async def apply_deduct_resource( self, resource: ResourceSlot, - device_id: DeviceSlot, - mount_resource: ResourceSlot, - bind_locations: Point, + device_id: DeviceSlot = "", + mount_resource: ResourceSlot = None, + bind_locations: Point = None, slot_on_deck: str = "", ) -> DeductResourceReturn: """ - 申请扣减物料并挂载到目标设备的目标物料上。 + 申请扣减物料,并可选挂载到目标设备的目标物料上。 - 服务端已完成扣减并回传实际物料(resource,框架在 send_goal 已解析为单个 PLR 实例); - 本动作复用 create_resource_detailed → append_resource 流程,把该已存在物料挂载到所选 - 设备的挂载目标(mount_resource)上。 + 服务端已完成扣减并回传实际物料(resource,框架在 send_goal 已解析为单个 PLR 实例)。 + + 两种用法: + - 仅登记/透传(不传 device_id 或 mount_resource):只校验并把已扣减物料经 labware 输出, + 方便后续 set_substance 设置内容物、再由 transfer_resource / transfer_manual 派发。 + - 扣减并挂载(device_id + mount_resource 都给):复用 create_resource_detailed → + append_resource,把该已存在物料挂到所选设备的挂载目标(相当于从仓库放到仓储设备上)。 与 create_resource 的区别:资源不是按 class+name 新建,而是直接 dump 已扣减实例作为 挂载载荷(initialize_full=False,不重建)。 - 输出 handle:labware = 创建/挂载得到的物料树;mount_resource = 实际挂载到的目标物料树, - 便于下游节点继续引用挂载位置。 + 输出 handle:labware = 已扣减/挂载得到的物料树;mount_resource = 实际挂载到的目标物料树 + (未挂载时为空),便于下游节点继续引用挂载位置。 Args: resource[扣减物料]: 已扣减的单个根物料(前端用扣减选择器选择)。 - device_id[目标设备]: 挂载到的边缘设备 id(可由图 handle 连入)。 - mount_resource[挂载目标]: 实际挂载到的目标物料/父节点(名称用于边缘侧 figure_resource,可由图 handle 连入)。 - bind_locations[挂载位置]: 挂载目标坐标系下的挂载坐标。 + device_id[目标设备]: 挂载到的边缘设备 id(可选;不传则仅登记/透传,可由图 handle 连入)。 + mount_resource[挂载目标]: 实际挂载到的目标物料/父节点(可选;不传则仅登记/透传,可由图 handle 连入)。 + bind_locations[挂载位置]: 挂载目标坐标系下的挂载坐标(挂载时使用)。 slot_on_deck[Deck槽位]: 挂载目标为 Deck 时按槽位挂载(可选)。 """ if resource is None: raise ValueError("申请扣减失败:未接收到已扣减物料") if getattr(resource, "unilabos_uuid", None) is None: - raise ValueError(f"物料 {getattr(resource, 'name', resource)} 缺少 unilabos_uuid,无法挂载") - # 已存在的扣减物料:dump 现有实例作为挂载载荷(不重新 initialize),单根取 [0] 的扁平节点列表 + raise ValueError(f"物料 {getattr(resource, 'name', resource)} 缺少 unilabos_uuid,无法处理") + # 已存在的扣减物料:dump 现有实例(不重新 initialize),单根取 [0] 的扁平节点列表 dumped = ResourceTreeSet.from_plr_resources([resource]).dump() if not dumped: - raise ValueError(f"物料 {getattr(resource, 'name', resource)} 序列化为空,无法挂载") + raise ValueError(f"物料 {getattr(resource, 'name', resource)} 序列化为空") flatten_nodes: List[Dict[str, Any]] = dumped[0] barcode = flatten_nodes[0].get("barcode", "") if flatten_nodes else "" + # 是否执行挂载:device_id 与 mount_resource 都给齐才挂载,否则仅登记/透传 + do_mount = bool(str(device_id)) and mount_resource is not None and not ( + isinstance(mount_resource, str) and not mount_resource + ) + if not do_mount: + self.lab_logger().info( + f"[apply_deduct_resource] 仅登记/透传物料 name={getattr(resource, 'name', '')} " + f"barcode={barcode}(未指定 device_id/mount_resource,不挂载)" + ) + return { + "created_resource_tree": dumped, + "liquid_input_resource_tree": [], + "mount_resource": [], + } mount_name = mount_resource.name if hasattr(mount_resource, "name") else str(mount_resource).split("/")[-1] self.lab_logger().info( f"[apply_deduct_resource] 挂载物料 name={getattr(resource, 'name', '')} " f"barcode={barcode} -> device={device_id} mount_resource={mount_name}" ) - # 挂载坐标归一化:@action 路径可能传 dict,ROS 路径为 Point + # 挂载坐标归一化:@action 路径可能传 dict,ROS 路径为 Point;缺省取原点 if isinstance(bind_locations, dict): point = Point( x=float(bind_locations.get("x", 0.0)), y=float(bind_locations.get("y", 0.0)), z=float(bind_locations.get("z", 0.0)), ) + elif bind_locations is None: + point = Point(x=0.0, y=0.0, z=0.0) else: point = bind_locations other_calling_param = json.dumps({"initialize_full": False, "slot": slot_on_deck}) @@ -1891,6 +1927,189 @@ async def discard_resource(self, resource: ResourceSlot, device_id: DeviceSlot) ) return {"code": 0, "uuids": [res_uuid], "device_id": edge_id} + async def _do_transfer_resource( + self, + resource: List["ResourceSlot"], + target_device: DeviceSlot, + mount_resource: List["ResourceSlot"], + ) -> TransferResourceReturn: + """transfer_resource / transfer_manual 共用的转移核心:把已物理就位的物料在系统中改挂到目标设备孔位。 + + 复用 base_device_node.transfer_resource_to_another(移除来源 → 云端改父 → 增加到目标)。 + transfer 只负责"系统记账",物理搬运由前序节点(manual_confirm/机械臂 pick+place)保证。 + + 注意:底层按"运行该动作的节点"作为来源执行本地移除,host 运行时来源即 host(根节点)。 + 若物料此前已被 apply_deduct_resource 挂到某边缘设备,该设备的本地副本不会在此处被移除, + 需依赖下次同步对齐(详见 cursor_docs 记录的源设备移除限制)。 + """ + if not resource: + raise ValueError("转移失败:未接收到待转移物料") + if not mount_resource: + raise ValueError("转移失败:未指定挂载目标孔位") + plr_resources = list(resource) + target_resources = list(mount_resource) + target_id = str(target_device).split("/")[-1] + result = await self.transfer_resource_to_another( + plr_resources, target_id, target_resources, [None] * len(target_resources) + ) + return { + "resource": ResourceTreeSet.from_plr_resources(plr_resources).dump(), + "mount_resource": ResourceTreeSet.from_plr_resources(target_resources).dump(), + "result": result, + } + + @action( + description="转移物料(系统派发):把已物理就位的物料在系统中改挂到目标设备的目标孔位(人工/机械臂工作流的统一末步)", + always_free=True, + placeholder_keys={ + "target_device": PLACEHOLDER_DEVICES, + "mount_resource": PLACEHOLDER_NODES, + }, + handles=[ + ActionInputHandle( + key="resource", + data_type="resource", + label="待转移物料", + data_key="resource", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="target_device", + data_type="device_id", + label="目标设备", + data_key="target_device", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="mount_resource", + data_type="resource", + label="目标孔位", + data_key="mount_resource", + data_source=DataSource.HANDLE, + ), + ActionOutputHandle( + key="resource", + data_type="resource", + label="已转移物料", + data_key="resource.@flatten", + data_source=DataSource.EXECUTOR, + ), + ActionOutputHandle( + key="mount_resource", + data_type="resource", + label="目标孔位", + data_key="mount_resource.@flatten", + data_source=DataSource.EXECUTOR, + ), + ], + ) + async def transfer_resource( + self, + resource: List[ResourceSlot], + target_device: DeviceSlot, + mount_resource: List[ResourceSlot], + ) -> TransferResourceReturn: + """ + 转移物料到目标设备的目标孔位(系统记账,不含物理搬运)。物理搬运由前序节点保证: + - 人工:apply_deduct_resource → transfer_manual → transfer_manual → transfer_resource + - 机械臂:apply_deduct_resource → 机械臂 pick → 机械臂 place → transfer_resource + + Args: + resource[待转移物料]: 待转移的物料列表(须带 unilabos_uuid,可由图 handle 连入)。 + target_device[目标设备]: 接收物料的目标设备 id。 + mount_resource[目标孔位]: 目标设备上的挂载孔位列表。 + """ + return await self._do_transfer_resource(resource, target_device, mount_resource) + + @action( + description="人工搬运闸门:到该步暂停等人工确认(人工把物料搬运到位),仅透传物料,不做系统转移(人工工作流中间步,对应机械臂 pick/place)", + always_free=True, + node_type=NodeType.MANUAL_CONFIRM, + placeholder_keys={ + "assignee_user_ids": PLACEHOLDER_MANUAL_CONFIRM, + "target_device": PLACEHOLDER_DEVICES, + "mount_resource": PLACEHOLDER_NODES, + }, + goal_default={ + "timeout_seconds": 3600, + "assignee_user_ids": [], + }, + handles=[ + ActionInputHandle( + key="resource", + data_type="resource", + label="待搬运物料", + data_key="resource", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="target_device", + data_type="device_id", + label="目标设备", + data_key="target_device", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="mount_resource", + data_type="resource", + label="目标孔位", + data_key="mount_resource", + data_source=DataSource.HANDLE, + ), + ActionOutputHandle( + key="resource", + data_type="resource", + label="待搬运物料", + data_key="resource.@flatten", + data_source=DataSource.EXECUTOR, + ), + ActionOutputHandle( + key="target_device", + data_type="device_id", + label="目标设备", + data_key="target_device", + data_source=DataSource.EXECUTOR, + ), + ActionOutputHandle( + key="mount_resource", + data_type="resource", + label="目标孔位", + data_key="mount_resource.@flatten", + data_source=DataSource.EXECUTOR, + ), + ], + ) + async def transfer_manual( + self, + resource: List[ResourceSlot], + target_device: DeviceSlot, + mount_resource: List[ResourceSlot], + timeout_seconds: int, + assignee_user_ids: list[str], + ) -> TransferManualReturn: + """ + 人工搬运闸门:工作流执行到本节点时暂停、等待人工确认(确认即表示人工已把物料搬运到位), + 本身**只透传**物料/目标设备/目标孔位,不做任何系统转移——它是机械臂 pick/place 的人工对应物。 + + 实际的系统转移(记账)由工作流末步 transfer_resource 统一完成(两条流一致): + - 人工:apply_deduct_resource → transfer_manual → transfer_manual → transfer_resource + - 机械臂:apply_deduct_resource → 机械臂 pick → 机械臂 place → transfer_resource + + Args: + resource[待搬运物料]: 待人工搬运的物料列表(须带 unilabos_uuid,可由图 handle 连入并透传)。 + target_device[目标设备]: 物料要搬到的目标设备 id(透传给下游)。 + mount_resource[目标孔位]: 目标设备上的目标孔位(透传给下游)。 + timeout_seconds[超时时间]: 人工确认超时时间,单位秒,默认 3600。 + assignee_user_ids[确认人]: 指定处理人工确认的用户 id 列表。 + """ + return { + "resource": (ResourceTreeSet.from_plr_resources(list(resource)).dump() if resource else []), + "mount_resource": ( + ResourceTreeSet.from_plr_resources(list(mount_resource)).dump() if mount_resource else [] + ), + "target_device": str(target_device) if target_device is not None else "", + } + def test_resource( self, sample_uuids: SampleUUIDsType, From 6a4dfe5935cae6214deef0fd54894cc2464e8164 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Sat, 27 Jun 2026 22:31:23 +0800 Subject: [PATCH 03/16] resolve_site --- unilabos/registry/placeholder_type.py | 21 ++++- unilabos/resources/liquids.py | 32 +++++++ unilabos/ros/nodes/base_device_node.py | 82 +++++++++++----- unilabos/ros/nodes/presets/host_node.py | 120 +++++++++++++++++++----- 4 files changed, 203 insertions(+), 52 deletions(-) diff --git a/unilabos/registry/placeholder_type.py b/unilabos/registry/placeholder_type.py index a03f5efa3..f2a6d8502 100644 --- a/unilabos/registry/placeholder_type.py +++ b/unilabos/registry/placeholder_type.py @@ -1,9 +1,21 @@ -from typing import Any +from typing import Any, Dict, List, Union from pylabrobot.resources import Resource class ResourceSlot(Resource): + """物料槽位类型——分两层语义,使用时务必区分: + + 1. 作为 @action 函数参数类型(**暴露给外部调用的契约**):表示「**一个完整的物料**」。 + 框架已在 send_goal 把原始入参解析为单个 PLR ``Resource`` 实例,函数体内拿到的就是它, + 直接当普通 Resource 用即可,无需关心 list/dict。 + 2. 作为原始入参(**仅框架内部解析阶段**):见 ``ResourceSlotRawInput``,可能是 + list(一棵树的扁平节点组,handle ``@flatten`` 运行期形态)或 dict(资源引用,前端 schema 填写形态)。 + + pydantic schema 固定为 object(dict)——前端只按「单物料 dict」展示/填写;list 仅出现在 handle + 连线的运行期,不进入 schema。 + """ + @classmethod def __get_pydantic_core_schema__(cls, source_type: Any, handler: Any) -> Any: # pydantic 无法内省 pylabrobot Resource 子类,会导致包含 ResourceSlot 的 @@ -13,6 +25,13 @@ def __get_pydantic_core_schema__(cls, source_type: Any, handler: Any) -> Any: return core_schema.dict_schema() +# 单 ResourceSlot 的「原始入参形态」——**仅框架内部解析阶段**使用,用于标注解析前的 raw 值: +# - dict:资源引用 {id, uuid}(前端 schema 填写形态,object)→ 按 uuid with_children 拉取; +# - list:一棵树的扁平节点组(handle @flatten 运行期形态)→ 装配成一个物料(须恰好单根)。 +# 解析完成后,@action 函数签名拿到的是「一个完整的 Resource 实例」(见 ResourceSlot 文档第 1 条)。 +ResourceSlotRawInput = Union[List[Dict[str, Any]], Dict[str, Any]] + + class DeviceSlot(str): @classmethod def __get_pydantic_core_schema__(cls, source_type: Any, handler: Any) -> Any: diff --git a/unilabos/resources/liquids.py b/unilabos/resources/liquids.py index f980040a6..5c2ec7038 100644 --- a/unilabos/resources/liquids.py +++ b/unilabos/resources/liquids.py @@ -92,6 +92,38 @@ def resolve_substance_targets(material: Any, slots: Optional[Sequence[Any]]) -> return targets +def resolve_site_spot(parent: Any, site: Any) -> Optional[int]: + """把 site 标识解析成父级 ``_ordering`` 上的 spot 索引(供 ``assign_child_resource(spot=...)`` 用)。 + + 与 set_substance **复用同一套 slot/site 标识解析**(``resolve_substance_targets``):支持 + int 索引 / 数字串 / "A1" 标签 / 名称匹配。空 site 返回 None(由父级默认排布)。 + + - 直接是 int / 数字串 → 当作 spot 索引返回。 + - 命中 ``_ordering`` 的 key(最常见,保持旧行为)→ 返回其位置索引。 + - 其余 → 复用 ``resolve_substance_targets`` 定位子目标,再回填其在 ``_ordering`` 的位置。 + - 无法解析 → None(交回调用方按原始 site / 默认排布处理)。 + """ + if site is None or (isinstance(site, str) and not site): + return None + if isinstance(site, int): + return site + if isinstance(site, str) and site.isdigit(): + return int(site) + ordering = getattr(parent, "_ordering", None) + keys = list(ordering.keys()) if ordering else [] + if site in keys: + return keys.index(site) + try: + target = resolve_substance_targets(parent, [site])[0] + tname = getattr(target, "name", None) + for i, k in enumerate(keys): + if tname and (tname == k or tname.endswith(f"_{k}")): + return i + except Exception: + pass + return None + + def apply_substances( material: Any, names: Sequence[str], diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index c1f6650b0..339b33e87 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -37,10 +37,11 @@ from unilabos.config.config import BasicConfig from unilabos.registry.decorators import get_topic_config +from unilabos.registry.placeholder_type import ResourceSlotRawInput from unilabos.utils.decorator import get_all_subscriptions from unilabos.resources.container import RegularContainer -from unilabos.resources.liquids import apply_substances +from unilabos.resources.liquids import apply_substances, resolve_site_spot from unilabos.resources.graphio import ( initialize_resources, ) @@ -863,10 +864,10 @@ def transfer_to_new_resource( site = additional_add_params.get("site", None) spec = inspect.signature(parent_resource.assign_child_resource) if "spot" in spec.parameters: - ordering_dict: Dict[str, Any] = getattr(parent_resource, "_ordering") - if ordering_dict: - site = list(ordering_dict.keys()).index(site) - additional_params["spot"] = site + # 复用 set_substance 的同一套 slot/site 解析(int 索引 / "A1" 标签 / 名称匹配); + # 解析不出(含 site 为空)则回退原始 site,由父级默认排布。 + spot = resolve_site_spot(parent_resource, site) + additional_params["spot"] = spot if spot is not None else site old_parent = plr_resource.parent if old_parent is not None: # plr并不支持同一个deck的加载和卸载 @@ -2310,17 +2311,22 @@ def _execute_driver_command(self, string: str): self.lab_logger().debug(f"[JsonCommand] 注入 {PARAM_SAMPLE_UUIDS}: {resolved_sample_uuids}") continue - # 处理单个 ResourceSlot + # 处理单个 ResourceSlot(单物料两种入参形态: + # dict = 资源引用,按 uuid 重新 with_children 拉取; + # list = 一棵树的扁平节点组(上游 handle 的 @flatten),就地装配成一个物料) if arg_type == "unilabos.registry.placeholder_type:ResourceSlot": - resource_data = function_args[arg_name] - if isinstance(resource_data, dict) and "id" in resource_data: - try: + # 内部解析层:raw 值可能是 list(@flatten 节点组)或 dict(资源引用) + resource_data: ResourceSlotRawInput = function_args[arg_name] + try: + if isinstance(resource_data, list): + function_args[arg_name] = self._assemble_single_resource(resource_data) + elif isinstance(resource_data, dict) and "id" in resource_data: function_args[arg_name] = self._convert_resources_sync(resource_data["uuid"])[0] - except Exception as e: - self.lab_logger().error( - f"转换ResourceSlot参数 {arg_name} 失败: {e}\n{traceback.format_exc()}" - ) - raise JsonCommandInitError(f"ResourceSlot参数转换失败: {arg_name}") + except Exception as e: + self.lab_logger().error( + f"转换ResourceSlot参数 {arg_name} 失败: {e}\n{traceback.format_exc()}" + ) + raise JsonCommandInitError(f"ResourceSlot参数转换失败: {arg_name}") # 处理 ResourceSlot 列表 elif isinstance(arg_type, tuple) and len(arg_type) == 2: @@ -2418,6 +2424,28 @@ def _convert_resources_sync(self, *uuids: str) -> List["ResourcePLR"]: return mapped_plr_resources + def _assemble_single_resource(self, raw_nodes: List[Dict[str, Any]]) -> "ResourcePLR": + """把一组扁平节点 dict 装配成「单个物料」(单 ResourceSlot 的 list 输入形态)。 + + list 输入形态:通常来自上游 handle 的 `xxx.@flatten`(一棵树的扁平节点列表,root + children), + 必须**恰好一个根** → 装配成一个物料;多根视为非法(一组必须变成一个物料)。 + 与 dict 形态(按 uuid 重新 with_children 拉取)相对:此处直接就地装配,不回服务端拉取。 + """ + if not raw_nodes: + raise ValueError("单物料 list 输入为空") + tree_set = ResourceTreeSet.from_raw_dict_list(raw_nodes) + if len(tree_set.trees) != 1: + names = [t.root_node.res_content.name for t in tree_set.trees] + raise ValueError(f"单物料输入要求恰好一个根物料,实际得到 {len(tree_set.trees)} 个根:{names}") + plr = tree_set.to_plr_resources()[0] + res = self.resource_tracker.figure_resource(plr, try_mode=True) + if len(res) == 1: + return res[0] + if len(res) > 1: + raise ValueError(f"单物料输入索引到多个本地实例:{res}") + self.lab_logger().warning(f"单物料 list 输入未索引到本地实例,使用装配实例:{getattr(plr, 'name', plr)}") + return plr + async def _execute_driver_command_async(self, string: str): try: target = json.loads(string) @@ -2469,19 +2497,23 @@ async def _execute_driver_command_async(self, string: str): ) continue - # 处理单个 ResourceSlot + # 处理单个 ResourceSlot(单物料两种入参形态: + # dict = 资源引用,按 uuid 重新 with_children 拉取; + # list = 一棵树的扁平节点组(上游 handle 的 @flatten),就地装配成一个物料) _is_resource_slot = isinstance(arg_type, str) and arg_type.endswith(":ResourceSlot") if _is_resource_slot: - resource_data = function_args[arg_name] - if isinstance(resource_data, dict) and "id" in resource_data: - try: - converted_resource = await self._convert_resource_async(resource_data) - function_args[arg_name] = converted_resource - except Exception as e: - self.lab_logger().error( - f"转换ResourceSlot参数 {arg_name} 失败: {e}\n{traceback.format_exc()}" - ) - raise JsonCommandInitError(f"ResourceSlot参数转换失败: {arg_name}") + # 内部解析层:raw 值可能是 list(@flatten 节点组)或 dict(资源引用) + resource_data: ResourceSlotRawInput = function_args[arg_name] + try: + if isinstance(resource_data, list): + function_args[arg_name] = self._assemble_single_resource(resource_data) + elif isinstance(resource_data, dict) and "id" in resource_data: + function_args[arg_name] = await self._convert_resource_async(resource_data) + except Exception as e: + self.lab_logger().error( + f"转换ResourceSlot参数 {arg_name} 失败: {e}\n{traceback.format_exc()}" + ) + raise JsonCommandInitError(f"ResourceSlot参数转换失败: {arg_name}") # 处理 ResourceSlot 列表 elif isinstance(arg_type, tuple) and len(arg_type) == 2: diff --git a/unilabos/ros/nodes/presets/host_node.py b/unilabos/ros/nodes/presets/host_node.py index cbc4c1e18..710299b85 100644 --- a/unilabos/ros/nodes/presets/host_node.py +++ b/unilabos/ros/nodes/presets/host_node.py @@ -95,19 +95,28 @@ class DeductResourceReturn(CreateResourceReturn): class TransferResourceReturn(TypedDict): - """transfer_resource 返回值:透传被转移物料与目标孔位,便于下游引用。""" + """transfer_resource 返回值:透传被转移物料、目标孔位与槽位,便于下游引用。 + + resource / mount_resource 均为「单个物料」的扁平节点形态(list[list[ResourceDict]],单根, + 经 @flatten 后即一棵树的扁平节点 list),与 apply_deduct 输出一致、可直接连到下游单物料输入。 + """ resource: List[List[ResourceDictType]] mount_resource: List[List[ResourceDictType]] + site: str result: Any class TransferManualReturn(TypedDict): - """transfer_manual 返回值:人工搬运闸门,仅透传物料/目标设备/目标孔位,不做系统转移。""" + """transfer_manual 返回值:人工搬运闸门,仅透传物料/目标设备/目标孔位/槽位,不做系统转移。 + + resource / mount_resource 均为「单个物料」的扁平节点形态(list[list[ResourceDict]],单根)。 + """ resource: List[List[ResourceDictType]] mount_resource: List[List[ResourceDictType]] target_device: str + site: str class TestLatencyReturn(TypedDict): @@ -1722,7 +1731,11 @@ async def apply_deduct_resource( """ 申请扣减物料,并可选挂载到目标设备的目标物料上。 - 服务端已完成扣减并回传实际物料(resource,框架在 send_goal 已解析为单个 PLR 实例)。 + 与 transfer_resource / transfer_manual 同构:resource / mount_resource 均为**单个物料** + (单 ResourceSlot)。服务端已完成扣减并回传实际物料,框架在 send_goal 把以下两种入参形态 + 解析为单个 PLR 实例: + - list:一棵树的扁平节点组(上游 handle 的 @flatten)→ 装配成一个物料(这一组必须只有一个根)。 + - dict:资源引用 → 按 uuid with_children 拉取一个物料。 两种用法: - 仅登记/透传(不传 device_id 或 mount_resource):只校验并把已扣减物料经 labware 输出, @@ -1737,9 +1750,9 @@ async def apply_deduct_resource( (未挂载时为空),便于下游节点继续引用挂载位置。 Args: - resource[扣减物料]: 已扣减的单个根物料(前端用扣减选择器选择)。 + resource[扣减物料]: 已扣减的单个根物料(前端用扣减选择器选择,dict/list 两形态均解析为一个物料)。 device_id[目标设备]: 挂载到的边缘设备 id(可选;不传则仅登记/透传,可由图 handle 连入)。 - mount_resource[挂载目标]: 实际挂载到的目标物料/父节点(可选;不传则仅登记/透传,可由图 handle 连入)。 + mount_resource[挂载目标]: 实际挂载到的单个目标物料/父节点(可选;不传则仅登记/透传,可由图 handle 连入,dict/list 两形态)。 bind_locations[挂载位置]: 挂载目标坐标系下的挂载坐标(挂载时使用)。 slot_on_deck[Deck槽位]: 挂载目标为 Deck 时按槽位挂载(可选)。 """ @@ -1929,32 +1942,41 @@ async def discard_resource(self, resource: ResourceSlot, device_id: DeviceSlot) async def _do_transfer_resource( self, - resource: List["ResourceSlot"], + resource: "ResourceSlot", target_device: DeviceSlot, - mount_resource: List["ResourceSlot"], + mount_resource: "ResourceSlot", + site: str = "", ) -> TransferResourceReturn: """transfer_resource / transfer_manual 共用的转移核心:把已物理就位的物料在系统中改挂到目标设备孔位。 + 与 apply_deduct_resource 一致:入参均为「单个物料」(单 ResourceSlot),框架在 send_goal 已把 + list(一棵树扁平节点组→装配成一个物料)或 dict(资源引用→with_children 拉取)解析为单个 PLR 实例。 + 复用 base_device_node.transfer_resource_to_another(移除来源 → 云端改父 → 增加到目标)。 transfer 只负责"系统记账",物理搬运由前序节点(manual_confirm/机械臂 pick+place)保证。 + site:目标父级(carrier/deck/plate 等带 _ordering 的容器)上的槽位名,显式指定物料落在哪个槽位; + 目标端通过 resolve_site_spot(与 set_substance 同一套 slot/site 解析:int 索引 / "A1" 标签 / + 名称匹配)换算成 assign_child_resource 的 spot。空串视作不指定(由父级默认排布)。注意:若物料 extra + 里带了前端隐式写入的 update_resource_site,目标端会用 extra 的值覆盖此处显式 site + (见 base_device_node.transfer_to_new_resource)。 + 注意:底层按"运行该动作的节点"作为来源执行本地移除,host 运行时来源即 host(根节点)。 若物料此前已被 apply_deduct_resource 挂到某边缘设备,该设备的本地副本不会在此处被移除, 需依赖下次同步对齐(详见 cursor_docs 记录的源设备移除限制)。 """ - if not resource: + if resource is None: raise ValueError("转移失败:未接收到待转移物料") - if not mount_resource: + if mount_resource is None: raise ValueError("转移失败:未指定挂载目标孔位") - plr_resources = list(resource) - target_resources = list(mount_resource) target_id = str(target_device).split("/")[-1] result = await self.transfer_resource_to_another( - plr_resources, target_id, target_resources, [None] * len(target_resources) + [resource], target_id, [mount_resource], [site if site else None] ) return { - "resource": ResourceTreeSet.from_plr_resources(plr_resources).dump(), - "mount_resource": ResourceTreeSet.from_plr_resources(target_resources).dump(), + "resource": ResourceTreeSet.from_plr_resources([resource]).dump(), + "mount_resource": ResourceTreeSet.from_plr_resources([mount_resource]).dump(), + "site": site, "result": result, } @@ -1987,6 +2009,13 @@ async def _do_transfer_resource( data_key="mount_resource", data_source=DataSource.HANDLE, ), + ActionInputHandle( + key="site", + data_type="site", + label="目标槽位", + data_key="site", + data_source=DataSource.HANDLE, + ), ActionOutputHandle( key="resource", data_type="resource", @@ -2001,25 +2030,40 @@ async def _do_transfer_resource( data_key="mount_resource.@flatten", data_source=DataSource.EXECUTOR, ), + ActionOutputHandle( + key="site", + data_type="site", + label="目标槽位", + data_key="site", + data_source=DataSource.EXECUTOR, + ), ], ) async def transfer_resource( self, - resource: List[ResourceSlot], + resource: ResourceSlot, target_device: DeviceSlot, - mount_resource: List[ResourceSlot], + mount_resource: ResourceSlot, + site: str = "", ) -> TransferResourceReturn: """ 转移物料到目标设备的目标孔位(系统记账,不含物理搬运)。物理搬运由前序节点保证: - 人工:apply_deduct_resource → transfer_manual → transfer_manual → transfer_resource - 机械臂:apply_deduct_resource → 机械臂 pick → 机械臂 place → transfer_resource + 与 apply_deduct_resource 同构:resource / mount_resource 均为**单个物料**(单 ResourceSlot)。 + 单物料有两种入参形态,框架在 send_goal 自动解析为一个 PLR 实例: + - list:一棵树的扁平节点组(上游 handle 的 @flatten)→ 装配成一个物料(这一组必须只有一个根)。 + - dict:资源引用 → 按 uuid with_children 拉取一个物料。 + Args: - resource[待转移物料]: 待转移的物料列表(须带 unilabos_uuid,可由图 handle 连入)。 + resource[待转移物料]: 待转移的单个物料(须带 unilabos_uuid,可由图 handle 连入,list/dict 两形态)。 target_device[目标设备]: 接收物料的目标设备 id。 - mount_resource[目标孔位]: 目标设备上的挂载孔位列表。 + mount_resource[目标孔位]: 目标设备上的单个挂载孔位/父物料(list/dict 两形态)。 + site[目标槽位]: 目标父级容器上的槽位名,显式指定物料落在哪个槽位(carrier/deck/plate 等按 + _ordering 换算成 spot);不传则由父级默认排布。 """ - return await self._do_transfer_resource(resource, target_device, mount_resource) + return await self._do_transfer_resource(resource, target_device, mount_resource, site) @action( description="人工搬运闸门:到该步暂停等人工确认(人工把物料搬运到位),仅透传物料,不做系统转移(人工工作流中间步,对应机械臂 pick/place)", @@ -2056,6 +2100,13 @@ async def transfer_resource( data_key="mount_resource", data_source=DataSource.HANDLE, ), + ActionInputHandle( + key="site", + data_type="site", + label="目标槽位", + data_key="site", + data_source=DataSource.HANDLE, + ), ActionOutputHandle( key="resource", data_type="resource", @@ -2077,37 +2128,54 @@ async def transfer_resource( data_key="mount_resource.@flatten", data_source=DataSource.EXECUTOR, ), + ActionOutputHandle( + key="site", + data_type="site", + label="目标槽位", + data_key="site", + data_source=DataSource.EXECUTOR, + ), ], ) async def transfer_manual( self, - resource: List[ResourceSlot], + resource: ResourceSlot, target_device: DeviceSlot, - mount_resource: List[ResourceSlot], + mount_resource: ResourceSlot, timeout_seconds: int, assignee_user_ids: list[str], + site: str = "", ) -> TransferManualReturn: """ 人工搬运闸门:工作流执行到本节点时暂停、等待人工确认(确认即表示人工已把物料搬运到位), - 本身**只透传**物料/目标设备/目标孔位,不做任何系统转移——它是机械臂 pick/place 的人工对应物。 + 本身**只透传**物料/目标设备/目标孔位/槽位,不做任何系统转移——它是机械臂 pick/place 的人工对应物。 实际的系统转移(记账)由工作流末步 transfer_resource 统一完成(两条流一致): - 人工:apply_deduct_resource → transfer_manual → transfer_manual → transfer_resource - 机械臂:apply_deduct_resource → 机械臂 pick → 机械臂 place → transfer_resource + 与 apply_deduct_resource / transfer_resource 同构:resource / mount_resource 均为**单个物料** + (单 ResourceSlot),框架在 send_goal 自动把 list(一棵树扁平节点组→装配成一个物料)或 + dict(资源引用→with_children 拉取)解析为一个 PLR 实例。 + + site 在此显式指定/透传,避免只能依赖前端隐式写入物料 extra(update_resource_site); + 透传到末步 transfer_resource 后据此把物料落到目标父级的对应槽位。 + Args: - resource[待搬运物料]: 待人工搬运的物料列表(须带 unilabos_uuid,可由图 handle 连入并透传)。 + resource[待搬运物料]: 待人工搬运的单个物料(须带 unilabos_uuid,可由图 handle 连入并透传,list/dict 两形态)。 target_device[目标设备]: 物料要搬到的目标设备 id(透传给下游)。 - mount_resource[目标孔位]: 目标设备上的目标孔位(透传给下游)。 + mount_resource[目标孔位]: 目标设备上的单个目标孔位/父物料(透传给下游,list/dict 两形态)。 timeout_seconds[超时时间]: 人工确认超时时间,单位秒,默认 3600。 assignee_user_ids[确认人]: 指定处理人工确认的用户 id 列表。 + site[目标槽位]: 目标父级容器上的槽位名,显式指定物料落在哪个槽位(透传给下游)。 """ return { - "resource": (ResourceTreeSet.from_plr_resources(list(resource)).dump() if resource else []), + "resource": (ResourceTreeSet.from_plr_resources([resource]).dump() if resource is not None else []), "mount_resource": ( - ResourceTreeSet.from_plr_resources(list(mount_resource)).dump() if mount_resource else [] + ResourceTreeSet.from_plr_resources([mount_resource]).dump() if mount_resource is not None else [] ), "target_device": str(target_device) if target_device is not None else "", + "site": site, } def test_resource( From 9324abb8f5534a58bc3da92aa948bc83cf89481f Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:21:56 +0800 Subject: [PATCH 04/16] fix root resource with its own parent resource update --- unilabos/ros/nodes/base_device_node.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index 339b33e87..a3db01e88 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -1015,10 +1015,15 @@ def _handle_update( original_parent_resource = original_instance.parent original_parent_resource_uuid = getattr(original_parent_resource, "unilabos_uuid", None) target_parent_resource_uuid = tree.root_node.res_content.uuid_parent - not_same_parent = ( - original_parent_resource_uuid != target_parent_resource_uuid - and original_parent_resource is not None - ) + if target_parent_resource_uuid == self.uuid: + not_same_parent = False + original_parent_resource = None + original_parent_resource_uuid = self.uuid + else: + not_same_parent = ( + original_parent_resource_uuid != target_parent_resource_uuid + and original_parent_resource is not None + ) old_name = original_instance.name new_name = plr_resource.name parent_appended = False @@ -1055,13 +1060,13 @@ def _handle_update( # 判断是否变更了resource_site,重新登记 target_site = original_instance.unilabos_extra.get("update_resource_site") sites = ( - original_instance.parent.sites - if original_instance.parent is not None and hasattr(original_instance.parent, "sites") + original_parent_resource.sites + if original_parent_resource is not None and hasattr(original_parent_resource, "sites") else None ) site_names = ( - list(original_instance.parent._ordering.keys()) - if original_instance.parent is not None and hasattr(original_instance.parent, "sites") + list(original_parent_resource._ordering.keys()) + if original_parent_resource is not None and hasattr(original_parent_resource, "sites") else [] ) if target_site is not None and sites is not None and site_names is not None: From 69d9c7dc5f30190d995f0ea0d65ecb0cfdc49836 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:03:36 +0800 Subject: [PATCH 05/16] fix add & update resource turn conflict --- unilabos/ros/nodes/base_device_node.py | 193 +++++++++++++++---------- 1 file changed, 120 insertions(+), 73 deletions(-) diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index a3db01e88..b632ceadc 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -101,9 +101,9 @@ def __init__(self, name: str = ""): async def acquire(self, node: "BaseROS2DeviceNode", tag: str = ""): """获取锁。如果已被占用,则异步等待直到锁释放。""" - # t0 = time.time() + t0 = time.time() with self._lock: - # qlen = len(self._queue) + qlen = len(self._queue) if not self._acquired: self._acquired = True self._holder = tag @@ -111,28 +111,40 @@ async def acquire(self, node: "BaseROS2DeviceNode", tag: str = ""): # f"[Mutex:{self._name}] 获取锁 tag={tag} (无等待, queue=0)" # ) return + holder = self._holder waiter = Future() self._queue.append(waiter) - # node.lab_logger().info( - # f"[Mutex:{self._name}] 等待锁 tag={tag} " - # f"(holder={self._holder}, queue={qlen + 1})" - # ) - await waiter - # wait_ms = (time.time() - t0) * 1000 + node.lab_logger().trace( + f"[Mutex:{self._name}] 进入锁等待队列 tag={tag} holder={holder} queue={qlen + 1}" + ) + try: + await waiter + except BaseException: + with self._lock: + if waiter in self._queue: + self._queue.remove(waiter) + node.lab_logger().debug( + f"[Mutex:{self._name}] 取消锁等待 tag={tag} queue={len(self._queue)}" + ) + raise + wait_ms = (time.time() - t0) * 1000 self._holder = tag - # node.lab_logger().info( - # f"[Mutex:{self._name}] 获取锁 tag={tag} (等了 {wait_ms:.0f}ms)" - # ) + node.lab_logger().trace(f"[Mutex:{self._name}] 队列继续执行 tag={tag} waited={wait_ms:.0f}ms") def release(self, node: "BaseROS2DeviceNode"): """释放锁,通过 executor task 唤醒下一个等待者。""" with self._lock: - # old_holder = self._holder - if self._queue: + old_holder = self._holder + next_waiter = None + while self._queue: next_waiter = self._queue.pop(0) - # node.lab_logger().debug( - # f"[Mutex:{self._name}] 释放锁 holder={old_holder} → 唤醒下一个 (剩余 queue={len(self._queue)})" - # ) + if not next_waiter.done(): + break + next_waiter = None + if next_waiter is not None: + node.lab_logger().trace( + f"[Mutex:{self._name}] 释放锁 holder={old_holder} 唤醒队列下一个 queue={len(self._queue)}" + ) async def _wake(): if not next_waiter.done(): @@ -457,6 +469,8 @@ def __init__( ) self._append_resource_lock = RclpyAsyncMutex(name=f"AR:{device_id}") + self._resource_tree_uuid_locks: Dict[str, RclpyAsyncMutex] = {} + self._resource_tree_uuid_locks_guard = threading.Lock() # 创建资源管理客户端 self._resource_clients: Dict[str, Client] = { @@ -901,6 +915,29 @@ def transfer_to_new_resource( f"物料{plr_resource}请求挂载{tree.root_node.res_content.name}的父节点{parent_resource}[{parent_uuid}]失败!\n{traceback.format_exc()}" ) + async def _acquire_resource_tree_uuid_locks(self, uuids: List[str], tag: str = "") -> List[RclpyAsyncMutex]: + """按资源 UUID 获取本节点实例内的资源树锁。""" + lock_keys = sorted({str(uid) for uid in uuids if uid}) + with self._resource_tree_uuid_locks_guard: + locks = [ + self._resource_tree_uuid_locks.setdefault(uid, RclpyAsyncMutex(name=f"RT:{self.device_id}:{uid}")) + for uid in lock_keys + ] + + acquired_locks = [] + try: + for lock in locks: + await lock.acquire(self, tag=tag) + acquired_locks.append(lock) + except BaseException: + self._release_resource_tree_uuid_locks(acquired_locks) + raise + return acquired_locks + + def _release_resource_tree_uuid_locks(self, locks: List[RclpyAsyncMutex]): + for lock in reversed(locks): + lock.release(self) + async def s2c_resource_tree(self, req: SerialCommand_Request, res: SerialCommand_Response): """ 处理资源树更新请求 @@ -1127,56 +1164,38 @@ def _handle_update( for i in data: action = i.get("action") # remove, add, update - resources_uuid: List[str] = i.get("data") # 资源数据 + resources_uuid: List[str] = i.get("data") or [] # 资源数据 + if not isinstance(resources_uuid, list): + resources_uuid = [resources_uuid] additional_add_params = i.get("additional_add_params", {}) # 额外参数 self.lab_logger().trace(f"[资源同步] 处理 {action}, " f"resources count: {len(resources_uuid)}") - tree_set = None - if action in ["add", "update"]: - tree_set = await self.get_resource( - resources_uuid=resources_uuid, with_children=True if action == "add" else False - ) + # NOTE: 当前仅按请求中显式传入的 uuid 加锁。 + # 如果后续操作会修改未出现在请求里的父节点或子节点,需要把这些关联 uuid 一并纳入锁集合。 + resource_locks = await self._acquire_resource_tree_uuid_locks( + resources_uuid, tag=f"{action}:{','.join(map(str, resources_uuid))}" + ) try: - if action == "add": - if tree_set is None: - raise ValueError("tree_set不能为None") - plr_resources = tree_set.to_plr_resources() - result, parents = _handle_add(plr_resources, tree_set, additional_add_params) - parents: List[Optional["ResourcePLR"]] = [i for i in parents if i is not None] - # de_dupe_parents = list(set(parents)) - # Fix unhashable type error for WareHouse - de_dupe_parents = [] - _seen_ids = set() - for p in parents: - if id(p) not in _seen_ids: - _seen_ids.add(id(p)) - de_dupe_parents.append(p) - new_tree_set = ResourceTreeSet.from_plr_resources(de_dupe_parents) # 去重 - for tree in new_tree_set.trees: - if tree.root_node.res_content.uuid_parent is None and self.node_name != "host_node": - tree.root_node.res_content.parent_uuid = self.uuid - r = SerialCommand.Request() - r.command = json.dumps( - {"data": {"data": new_tree_set.dump()}, "action": "update"} - ) # 和Update Resource一致 - response: SerialCommand_Response = await self._resource_clients[ - "c2s_update_resource_tree" - ].call_async( - r - ) # type: ignore - self.lab_logger().trace(f"确认资源云端 Add 结果: {response.response}") - results.append(result) - elif action == "update": - if tree_set is None: - raise ValueError("tree_set不能为None") - plr_resources = [] - for tree in tree_set.trees: - if tree.root_node.res_content.type == "device": - plr_resources.append(tree.root_node) - else: - plr_resources.append(ResourceTreeSet([tree]).to_plr_resources()[0]) - result, original_instances = _handle_update(plr_resources, tree_set, additional_add_params) - if not BasicConfig.no_update_feedback: - new_tree_set = ResourceTreeSet.from_plr_resources(original_instances) # 去重 + tree_set = None + if action in ["add", "update"]: + tree_set = await self.get_resource( + resources_uuid=resources_uuid, with_children=True if action == "add" else False + ) + try: + if action == "add": + if tree_set is None: + raise ValueError("tree_set不能为None") + plr_resources = tree_set.to_plr_resources() + result, parents = _handle_add(plr_resources, tree_set, additional_add_params) + parents: List[Optional["ResourcePLR"]] = [i for i in parents if i is not None] + # de_dupe_parents = list(set(parents)) + # Fix unhashable type error for WareHouse + de_dupe_parents = [] + _seen_ids = set() + for p in parents: + if id(p) not in _seen_ids: + _seen_ids.add(id(p)) + de_dupe_parents.append(p) + new_tree_set = ResourceTreeSet.from_plr_resources(de_dupe_parents) # 去重 for tree in new_tree_set.trees: if tree.root_node.res_content.uuid_parent is None and self.node_name != "host_node": tree.root_node.res_content.parent_uuid = self.uuid @@ -1189,16 +1208,44 @@ def _handle_update( ].call_async( r ) # type: ignore - self.lab_logger().trace(f"确认资源云端 Update 结果: {response.response}") - results.append(result) - elif action == "remove": - result = _handle_remove(resources_uuid) - results.append(result) - except Exception as e: - error_msg = f"Error processing {action} operation: {str(e)}" - self.lab_logger().error(f"[Resource Tree Update] {error_msg}") - self.lab_logger().error(traceback.format_exc()) - results.append({"success": False, "action": action, "error": error_msg}) + self.lab_logger().trace(f"确认资源云端 Add 结果: {response.response}") + results.append(result) + elif action == "update": + if tree_set is None: + raise ValueError("tree_set不能为None") + plr_resources = [] + for tree in tree_set.trees: + if tree.root_node.res_content.type == "device": + plr_resources.append(tree.root_node) + else: + plr_resources.append(ResourceTreeSet([tree]).to_plr_resources()[0]) + result, original_instances = _handle_update(plr_resources, tree_set, additional_add_params) + if not BasicConfig.no_update_feedback: + new_tree_set = ResourceTreeSet.from_plr_resources(original_instances) # 去重 + for tree in new_tree_set.trees: + if tree.root_node.res_content.uuid_parent is None and self.node_name != "host_node": + tree.root_node.res_content.parent_uuid = self.uuid + r = SerialCommand.Request() + r.command = json.dumps( + {"data": {"data": new_tree_set.dump()}, "action": "update"} + ) # 和Update Resource一致 + response: SerialCommand_Response = await self._resource_clients[ + "c2s_update_resource_tree" + ].call_async( + r + ) # type: ignore + self.lab_logger().trace(f"确认资源云端 Update 结果: {response.response}") + results.append(result) + elif action == "remove": + result = _handle_remove(resources_uuid) + results.append(result) + except Exception as e: + error_msg = f"Error processing {action} operation: {str(e)}" + self.lab_logger().error(f"[Resource Tree Update] {error_msg}") + self.lab_logger().error(traceback.format_exc()) + results.append({"success": False, "action": action, "error": error_msg}) + finally: + self._release_resource_tree_uuid_locks(resource_locks) # 返回处理结果 result_json = {"results": results, "total": len(data)} From b005dc3e540896707a33a4f36e137bc5e7d2918c Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:55:07 +0800 Subject: [PATCH 06/16] re-design pr 291 --- unilabos/app/main.py | 24 ---- unilabos/app/package_cli.py | 49 +++++++- unilabos/registry/community_alias.py | 33 ------ .../registry/external_registry_discovery.py | 99 ---------------- unilabos/registry/init_enforce.py | 100 ++++++++++++++++ unilabos/registry/initializer.py | 110 ------------------ unilabos/registry/registry.py | 30 +++-- unilabos/ros/initialize_device.py | 48 ++++---- unilabos/ros/nodes/base_device_node.py | 4 +- 9 files changed, 184 insertions(+), 313 deletions(-) delete mode 100644 unilabos/registry/community_alias.py delete mode 100644 unilabos/registry/external_registry_discovery.py create mode 100644 unilabos/registry/init_enforce.py delete mode 100644 unilabos/registry/initializer.py diff --git a/unilabos/app/main.py b/unilabos/app/main.py index 977890b0b..6e3fea236 100644 --- a/unilabos/app/main.py +++ b/unilabos/app/main.py @@ -830,29 +830,6 @@ def main(): # 社区包设备直接以 community.. 注册(扫描期命名空间化),不做 alias 桥接 args_dict["_community_namespaces"] = community_result.namespaces - # Plan 09 Task 5: 发现外源包的 unilabos_registry/ 目录,并入 build_registry。 - # 来源:device dirs(社区包 + 显式 --devices)各自的包根 + `unilabos.registry` entry points。 - try: - from pathlib import Path as _Path - - from unilabos.registry.external_registry_discovery import ( - discover_registry_paths_from_entry_points, - discover_registry_paths_from_project, - ) - - _ext: list = [] - for _d in args_dict.get("devices") or []: - _dp = _Path(_d) - _ext.extend(discover_registry_paths_from_project(_dp)) - _ext.extend(discover_registry_paths_from_project(_dp.parent)) - _ext.extend(discover_registry_paths_from_entry_points()) - _ext_str = list(dict.fromkeys(str(p) for p in _ext)) - if _ext_str: - args_dict["_external_registry_paths"] = _ext_str - print_status(f"发现 {len(_ext_str)} 个外源 registry 目录", "info") - except Exception as _ext_exc: # noqa: BLE001 - logger.warning(f"[ext-registry] 外源 registry 发现跳过: {_ext_exc}") - # Step 0: AST 分析优先 + YAML 注册表加载 # check_mode 和 upload_registry 都会执行实际 import 验证 devices_dirs = args_dict.get("devices", None) @@ -866,7 +843,6 @@ def main(): check_mode=check_mode, complete_registry=complete_registry, external_only=external_only, - external_registry_paths=args_dict.get("_external_registry_paths"), ) # Check mode: 注册表验证完成后直接退出 diff --git a/unilabos/app/package_cli.py b/unilabos/app/package_cli.py index bfff2e22b..6847bdc82 100644 --- a/unilabos/app/package_cli.py +++ b/unilabos/app/package_cli.py @@ -13,6 +13,7 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Tuple +from unilabos.registry.init_enforce import validate_init_param_enforce from unilabos.utils import logger from unilabos.utils.banner_print import print_status @@ -53,6 +54,44 @@ def resolve_class_namespace(project_name: str, namespace: Optional[str]) -> str: return COMMUNITY_PREFIX + normalize_name(project_name) +def discover_registry_paths_from_project(project_root: Path | str) -> List[Path]: + """从包根推导目录化注册表路径。 + + ``[tool.unilabos.registry].paths`` 相对包含 ``pyproject.toml`` 的包根解析; + 未声明时回退到包根下的 ``unilabos_registry/``。 + """ + root = Path(project_root).resolve() + pyproject_paths = _read_pyproject_registry_paths(root) + if pyproject_paths: + return pyproject_paths + + fallback = root / "unilabos_registry" + if fallback.is_dir(): + return [fallback] + return [] + + +def _read_pyproject_registry_paths(project_root: Path) -> List[Path]: + pyproject = project_root / "pyproject.toml" + if not pyproject.is_file(): + return [] + + data = _load_toml(pyproject) + registry_config = data.get("tool", {}).get("unilabos", {}).get("registry", {}) + raw_paths = registry_config.get("paths", []) + if not isinstance(raw_paths, list): + return [] + + paths: List[Path] = [] + for raw_path in raw_paths: + if not isinstance(raw_path, str): + continue + registry_path = (project_root / raw_path).resolve() + if registry_path.is_dir(): + paths.append(registry_path) + return paths + + def read_pyproject(pkg_dir: Path) -> Dict[str, Any]: """读取 pyproject.toml 的 [project] 表,返回 name/version/summary/license/homepage/dependencies 等。""" pyproject_path = pkg_dir / "pyproject.toml" @@ -163,7 +202,6 @@ def read_external_registry_devices(pkg_dir: Path) -> Dict[str, Dict[str, Any]]: logger.warning("[package] 未安装 pyyaml,跳过外部注册表读取") return {} - from unilabos.registry.external_registry_discovery import discover_registry_paths_from_project from unilabos.registry.yaml_ref import resolve_yaml_refs registry_roots = discover_registry_paths_from_project(pkg_dir) @@ -333,6 +371,13 @@ def build_resources_from_registry( for device_id, entry in entries.items(): cls = entry.get("class") if isinstance(entry.get("class"), dict) else {} init_schema = entry.get("init_param_schema") if isinstance(entry.get("init_param_schema"), dict) else None + init_enforce = entry.get("init_param_enforce") + validate_init_param_enforce( + device_id, + init_schema, + init_enforce, + error_factory=PackageCLIError, + ) category = entry.get("category") or entry.get("tags") or [] if isinstance(category, str): category = [category] @@ -360,6 +405,8 @@ def build_resources_from_registry( } if init_schema is not None: resource["init_param_schema"] = init_schema + if "init_param_enforce" in entry: + resource["init_param_enforce"] = init_enforce resources.append(resource) return resources diff --git a/unilabos/registry/community_alias.py b/unilabos/registry/community_alias.py deleted file mode 100644 index 8a69fc129..000000000 --- a/unilabos/registry/community_alias.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Community registry alias resolution (Plan 09 Task 7). - -Graph ``class`` may reference a community variant id as ``community.``. After -the community package is downloaded/mounted, the registry holds the local id -````; this module strips the prefix and validates the registry entry exists. -Complements the existing ``community_packages.apply_community_aliases`` (which -mutates the registry); these helpers are pure and used at graph->device lookup. -""" - -from __future__ import annotations - -from typing import Any - -COMMUNITY_PREFIX = "community." - - -class CommunityAliasError(ValueError): - pass - - -def normalize_community_class(class_name: str) -> str: - if class_name.startswith(COMMUNITY_PREFIX): - return class_name[len(COMMUNITY_PREFIX):] - return class_name - - -def resolve_community_alias(class_name: str, device_registry: dict[str, Any]) -> str: - normalized = normalize_community_class(class_name) - if normalized not in device_registry: - raise CommunityAliasError( - f"Community class '{class_name}' resolved to '{normalized}', but no registry entry exists" - ) - return normalized diff --git a/unilabos/registry/external_registry_discovery.py b/unilabos/registry/external_registry_discovery.py deleted file mode 100644 index 26dfcc5ac..000000000 --- a/unilabos/registry/external_registry_discovery.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Discover external package registry directories (Plan 09 Task 1). - -An external package may expose its Uni-Lab-OS registry YAML via: -1. ``pyproject.toml`` ``[tool.unilabos.registry] paths = [...]`` -2. (future) entry point group ``unilabos.registry`` -3. fallback ``unilabos_registry/`` directory at the package/repo root. -""" - -from __future__ import annotations - -import logging -from importlib.metadata import entry_points -from pathlib import Path -from typing import Any - -try: - import tomllib -except ModuleNotFoundError: # pragma: no cover - py<3.11 - import tomli as tomllib - -logger = logging.getLogger(__name__) - -ENTRY_POINT_GROUP = "unilabos.registry" - - -def discover_registry_paths_from_project(project_root: Path | str) -> list[Path]: - root = Path(project_root).resolve() - pyproject_paths = _read_pyproject_registry_paths(root) - if pyproject_paths: - return pyproject_paths - - fallback = root / "unilabos_registry" - if fallback.is_dir(): - return [fallback] - - return [] - - -def discover_registry_paths_from_entry_points() -> list[Path]: - """Discover registry dirs from installed packages declaring the ``unilabos.registry`` - entry point group (Plan 09 §2.3 #2). - - Each entry point resolves to a callable returning a path or list of paths (e.g. - ``my_package.unilabos_registry:registry_paths``); a non-callable path value is also - accepted. Per-entry failures are isolated. - """ - paths: list[Path] = [] - try: - eps = entry_points(group=ENTRY_POINT_GROUP) - except TypeError: # pragma: no cover - importlib.metadata < 3.10 API - eps = entry_points().get(ENTRY_POINT_GROUP, []) # type: ignore[attr-defined] - for ep in eps: - try: - obj = ep.load() - result = obj() if callable(obj) else obj - items = result if isinstance(result, (list, tuple)) else [result] - for item in items: - candidate = Path(item).resolve() - if candidate.is_dir(): - paths.append(candidate) - except Exception as exc: # noqa: BLE001 - one bad package must not abort discovery - logger.warning("failed to load registry entry point %r: %s", getattr(ep, "name", ep), exc) - # de-duplicate, order-preserving - seen: set[Path] = set() - unique: list[Path] = [] - for p in paths: - if p not in seen: - seen.add(p) - unique.append(p) - return unique - - -def _read_pyproject_registry_paths(project_root: Path) -> list[Path]: - pyproject = project_root / "pyproject.toml" - if not pyproject.is_file(): - return [] - - data = _load_toml(pyproject) - registry_config = data.get("tool", {}).get("unilabos", {}).get("registry", {}) - raw_paths = registry_config.get("paths", []) - if not isinstance(raw_paths, list): - return [] - - paths: list[Path] = [] - for raw_path in raw_paths: - if not isinstance(raw_path, str): - continue - registry_path = (project_root / raw_path).resolve() - if registry_path.is_dir(): - paths.append(registry_path) - return paths - - -def _load_toml(path: Path) -> dict[str, Any]: - with path.open("rb") as file: - data = tomllib.load(file) - if not isinstance(data, dict): - return {} - return data diff --git a/unilabos/registry/init_enforce.py b/unilabos/registry/init_enforce.py new file mode 100644 index 000000000..1bee698b6 --- /dev/null +++ b/unilabos/registry/init_enforce.py @@ -0,0 +1,100 @@ +"""Validation and merge helpers for enforced registry init params.""" + +import copy +from typing import Any, Callable, Dict, Optional + + +def merge_init_param_enforce(config: Any, init_enforce: Any) -> Dict[str, Any]: + """Merge runtime config with enforced registry params, letting registry win. + + Runtime config may still provide fields not owned by the device model, but + every field present in ``init_param_enforce`` is applied last and therefore + cannot be overridden by instance-level config. + """ + base = copy.deepcopy(config) if isinstance(config, dict) else {} + if not isinstance(init_enforce, dict): + return base + return _merge_dict_with_enforce(base, init_enforce) + + +def _merge_dict_with_enforce(config: Dict[str, Any], init_enforce: Dict[str, Any]) -> Dict[str, Any]: + merged = copy.deepcopy(config) + for key, enforced_value in init_enforce.items(): + current_value = merged.get(key) + if isinstance(current_value, dict) and isinstance(enforced_value, dict): + merged[key] = _merge_dict_with_enforce(current_value, enforced_value) + else: + merged[key] = copy.deepcopy(enforced_value) + return merged + + +def validate_init_param_enforce( + device_id: str, + init_schema: Optional[Dict[str, Any]], + init_enforce: Any, + error_factory: Callable[[str], Exception] = ValueError, +) -> None: + """Validate the JSON enforced config paired with ``init_param_schema``. + + ``init_param_enforce`` is a plain JSON config object. It must not reintroduce + the old ``class.init`` object factory DSL; drivers should construct rich + objects from JSON-friendly type strings and params inside ``__init__``. + """ + if not isinstance(init_schema, dict): + return + + config_schema = init_schema.get("config") + if not isinstance(config_schema, dict): + return + + required = config_schema.get("required") + required_fields = [str(field) for field in required] if isinstance(required, list) else [] + properties = config_schema.get("properties") + has_config_contract = bool(required_fields) or ( + isinstance(properties, dict) and bool(properties) + ) + if not has_config_contract: + return + + if not isinstance(init_enforce, dict): + raise error_factory( + f"{device_id}: init_param_schema.config 已声明参数,必须提供对象形式的 init_param_enforce" + ) + + missing = [field for field in required_fields if field not in init_enforce] + if missing: + raise error_factory( + f"{device_id}: init_param_enforce 缺少 required 字段: {', '.join(missing)}" + ) + + _reject_legacy_init_enforce( + init_enforce, + f"{device_id}.init_param_enforce", + error_factory, + ) + + +def _reject_legacy_init_enforce( + value: Any, + path: str, + error_factory: Callable[[str], Exception], +) -> None: + if isinstance(value, str): + if "${" in value and "}" in value: + raise error_factory(f"{path}: init_param_enforce 不支持 ${{...}} 模板,动态值应来自实例 config") + return + + if isinstance(value, list): + for index, item in enumerate(value): + _reject_legacy_init_enforce(item, f"{path}[{index}]", error_factory) + return + + if not isinstance(value, dict): + return + + keys = set(value) + if "factory" in keys or "args" in keys or "kwargs" in keys or keys == {"value"}: + raise error_factory(f"{path}: init_param_enforce 不支持 class.init 的 factory/args/kwargs/value DSL") + + for key, item in value.items(): + _reject_legacy_init_enforce(item, f"{path}.{key}", error_factory) diff --git a/unilabos/registry/initializer.py b/unilabos/registry/initializer.py deleted file mode 100644 index b1cc9e09e..000000000 --- a/unilabos/registry/initializer.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Construct device instances from registry ``class.init`` specs (Plan 09 Task 3). - -Lets multiple registry entries share one Python class but pass different init -parameters (e.g. different ``backend`` factory). Value rules: -- scalars pass through -- ``${config.x}`` / ``${node.id}`` / ``${node.name}`` inject from node/config -- ``factory: module:Callable`` builds the value via that callable + its args/kwargs -- ``value: ...`` passes an explicit constant (disambiguates from factory) -""" - -from __future__ import annotations - -import importlib -import re -from typing import Any, Callable - -_PLACEHOLDER_PATTERN = re.compile(r"^\$\{([^}]+)\}$") - - -class RegistryInitializerError(ValueError): - pass - - -def build_instance_from_registry_entry(entry: dict[str, Any], node: dict[str, Any], config: dict[str, Any]) -> Any: - class_config = entry.get("class", {}) - class_ref = class_config.get("module") - if not isinstance(class_ref, str) or not class_ref: - raise RegistryInitializerError("Registry entry class.module is required") - - target = import_ref(class_ref) - init_config = class_config.get("init", {}) or {} - args = [_resolve_value(value, node=node, config=config) for value in init_config.get("args", [])] - kwargs = { - key: _resolve_value(value, node=node, config=config) - for key, value in init_config.get("kwargs", {}).items() - } - return target(*args, **kwargs) - - -def resolve_init_kwargs(entry: dict[str, Any], node: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]: - """Resolve ``class.init`` into concrete args/kwargs WITHOUT instantiating the - device class itself (Plan 09 Task 6). - - Used by the ROS device construction path: the resolved kwargs (with factory - objects built and ${config.*}/${node.*} injected) are merged into driver_params - so the existing ROS2DeviceNode wrapper / creator still builds the device. - """ - init_config = (entry.get("class", {}) or {}).get("init", {}) or {} - args = [_resolve_value(value, node=node, config=config) for value in init_config.get("args", [])] - kwargs = { - key: _resolve_value(value, node=node, config=config) - for key, value in init_config.get("kwargs", {}).items() - } - return {"args": args, "kwargs": kwargs} - - -def import_ref(ref: str) -> Callable[..., Any] | type: - if ":" not in ref: - raise RegistryInitializerError(f"Import ref must use 'module:attr' format: {ref}") - module_name, attr_name = ref.split(":", 1) - module = importlib.import_module(module_name) - current: Any = module - for part in attr_name.split("."): - current = getattr(current, part) - return current - - -def _resolve_value(value: Any, node: dict[str, Any], config: dict[str, Any]) -> Any: - if isinstance(value, str): - return _resolve_string(value, node=node, config=config) - - if isinstance(value, list): - return [_resolve_value(item, node=node, config=config) for item in value] - - if isinstance(value, dict): - if "value" in value and set(value.keys()) == {"value"}: - return value["value"] - if "factory" in value: - factory = import_ref(value["factory"]) - args = [_resolve_value(item, node=node, config=config) for item in value.get("args", [])] - kwargs = { - key: _resolve_value(item, node=node, config=config) - for key, item in value.get("kwargs", {}).items() - } - return factory(*args, **kwargs) - return {key: _resolve_value(item, node=node, config=config) for key, item in value.items()} - - return value - - -def _resolve_string(value: str, node: dict[str, Any], config: dict[str, Any]) -> Any: - match = _PLACEHOLDER_PATTERN.match(value) - if not match: - return value - - expression = match.group(1) - if expression.startswith("config."): - return _select_path(config, expression.removeprefix("config.")) - if expression.startswith("node."): - return _select_path(node, expression.removeprefix("node.")) - raise RegistryInitializerError(f"Unsupported initializer placeholder: {value}") - - -def _select_path(data: dict[str, Any], path: str) -> Any: - current: Any = data - for part in path.split("."): - if not isinstance(current, dict) or part not in current: - raise RegistryInitializerError(f"Missing initializer value: {path}") - current = current[part] - return current diff --git a/unilabos/registry/registry.py b/unilabos/registry/registry.py index fe6b260f2..47b52ce5b 100644 --- a/unilabos/registry/registry.py +++ b/unilabos/registry/registry.py @@ -36,6 +36,7 @@ NodeType, normalize_enum_value, ) +from unilabos.registry.init_enforce import validate_init_param_enforce from unilabos.registry.yaml_ref import resolve_yaml_refs from unilabos.registry.utils import ( ROSMsgNotFound, @@ -123,22 +124,12 @@ def setup( complete_registry=False, external_only=False, community_namespaces=None, - external_registry_paths=None, ): - """统一构建注册表入口。 - - external_registry_paths: 外源包发现到的 registry 目录(Plan 09),会并入 YAML 加载路径。 - """ + """统一构建注册表入口。""" if self._setup_called: logger.critical("[UniLab Registry] setup方法已被调用过,不允许多次调用") return - if external_registry_paths: - for _p in external_registry_paths: - _pp = Path(_p) - if _pp not in self.registry_paths: - self.registry_paths.append(_pp) - self._startup_executor = ThreadPoolExecutor( max_workers=8, thread_name_prefix="RegistryStartup" ) @@ -1403,7 +1394,9 @@ def _import_and_dump(resource_id: str, module_str: str): res_class = import_class(module_str) if callable(res_class) and not isinstance(res_class, type): res_instance = res_class(res_class.__name__) - tree_set = ResourceTreeSet.from_plr_resources([res_instance], known_newly_created=True, old_size=True) + tree_set = ResourceTreeSet.from_plr_resources( + [res_instance], known_newly_created=True, old_size=True + ) dumped = tree_set.dump(old_position=True) return resource_id, dumped[0] if dumped else [] except Exception as e: @@ -1907,6 +1900,11 @@ def _load_single_device_file( continue # --- 正常 YAML 处理 --- + validate_init_param_enforce( + device_id, + device_config.get("init_param_schema"), + device_config.get("init_param_enforce"), + ) if "status_types" not in device_config["class"] or device_config["class"]["status_types"] is None: device_config["class"]["status_types"] = {} if ( @@ -2351,16 +2349,18 @@ def _find_package_root(start: Path, max_up: int = 6) -> Optional[Path]: def _registry_root_candidates(self, base: Path) -> List[Path]: """给定一个 --devices 目录,推导其内嵌注册表根的候选目录。 - 约定 registry 与 unilabos/registry 同构(ROOT/registry/{devices,resources,device_comms}/*.yaml): + 约定 registry 与 unilabos/registry 同构(ROOT/{devices,resources,device_comms}/*.yaml): - /registry:--devices 指向包根(社区包)或 registry 直接放在 --devices 下; - <包根>/registry:--devices 指向 python 子包时(如模板的 device_package_example), 向上按 pyproject.toml 锚定 ROOT 再取 ROOT/registry; + - /unilabos_registry 或 <包根>/unilabos_registry:目录化设备包的固定注册表目录; - 本身:--devices 直接指向 registry 根的兜底。 """ - candidates = [base / "registry"] + candidates = [base / "registry", base / "unilabos_registry"] pkg_root = self._find_package_root(base) if pkg_root is not None: candidates.append(pkg_root / "registry") + candidates.append(pkg_root / "unilabos_registry") candidates.append(base) return candidates @@ -2513,7 +2513,6 @@ def build_registry( complete_registry=False, external_only=False, community_namespaces=None, - external_registry_paths=None, ): """ 构建或获取Registry单例实例 @@ -2534,7 +2533,6 @@ def build_registry( complete_registry=complete_registry, external_only=external_only, community_namespaces=community_namespaces, - external_registry_paths=external_registry_paths, ) # 将 AST 扫描的字符串类型替换为实际 ROS2 消息类(仅查找 ROS2 类型,不 import 设备模块) diff --git a/unilabos/ros/initialize_device.py b/unilabos/ros/initialize_device.py index 9d6113fe2..4672a05e0 100644 --- a/unilabos/ros/initialize_device.py +++ b/unilabos/ros/initialize_device.py @@ -1,5 +1,6 @@ from typing import Optional +from unilabos.registry.init_enforce import merge_init_param_enforce from unilabos.registry.registry import lab_registry from unilabos.ros.device_node_wrapper import ros2_device_node from unilabos.ros.nodes.base_device_node import ROS2DeviceNode, DeviceInitError @@ -9,39 +10,37 @@ from unilabos.utils.import_manager import default_manager - def initialize_device_from_dict(device_id, device_config: ResourceDictInstance) -> Optional[ROS2DeviceNode]: """Initializes a device based on its configuration. - This function dynamically imports the appropriate device class and creates an instance of it using the provided device configuration. + This function dynamically imports the appropriate device class and creates an + instance of it using the provided device configuration. It also sets up action clients for the device based on its action value mappings. Args: device_id (str): The unique identifier for the device. - device_config (dict): The configuration dictionary for the device, which includes the class type and other parameters. + device_config (dict): The configuration dictionary for the device. Returns: None """ d = None device_class_config = device_config.res_content.klass + registry_entry = {} uid = device_config.res_content.uuid if isinstance(device_class_config, str): # 如果是字符串,则直接去lab_registry中查找,获取class if len(device_class_config) == 0: raise DeviceClassInvalid(f"Device [{device_id}] class cannot be an empty string. {device_config}") if device_class_config not in lab_registry.device_type_registry: - # Plan 09 Task 7: graph 可能引用 community 变体 id(community.); - # 若带前缀的 id 未注册,回退到归一化后的本地 id。 - from unilabos.registry.community_alias import normalize_community_class - - normalized = normalize_community_class(device_class_config) - if normalized in lab_registry.device_type_registry: - device_class_config = normalized - else: - raise DeviceClassInvalid(f"Device [{device_id}] class {device_class_config} not found. {device_config}") - device_class_config = lab_registry.device_type_registry[device_class_config]["class"] + raise DeviceClassInvalid( + f"Device [{device_id}] class {device_class_config} not found. {device_config}" + ) + registry_entry = lab_registry.device_type_registry[device_class_config] + device_class_config = registry_entry["class"] elif isinstance(device_class_config, dict): - raise DeviceClassInvalid(f"Device [{device_id}] class config should be type 'str' but 'dict' got. {device_config}") + raise DeviceClassInvalid( + f"Device [{device_id}] class config should be type 'str' but 'dict' got. {device_config}" + ) if isinstance(device_class_config, dict): DEVICE = default_manager.get_class(device_class_config["module"]) # 不管是ros2的实例,还是python的,都必须包一次,除了HostNode @@ -55,24 +54,19 @@ def initialize_device_from_dict(device_id, device_config: ResourceDictInstance) {"name": "hardware_interface", "write": "send_command", "read": "read_data", "extra_info": []}, ) ) - effective_params = device_config.res_content.config - # Plan 09 Task 6: external variant registry entries declare class.init; resolve it - # (build factory objects + inject ${config.*}/${node.*}) and merge into driver_params, - # keeping the existing ROS2DeviceNode wrapper/creator construction path. - if device_class_config.get("init"): - from unilabos.registry.initializer import resolve_init_kwargs - - node_meta = {"id": device_id, "name": getattr(device_config.res_content, "name", device_id)} - # class.init fully defines the constructor kwargs; the raw config is only the - # source for ${config.*} placeholders, so it replaces (not merges into) params. - resolved = resolve_init_kwargs({"class": device_class_config}, node=node_meta, config=effective_params or {}) - effective_params = resolved["kwargs"] try: + runtime_config = device_config.res_content.config + if not isinstance(runtime_config, dict): + runtime_config = {} + driver_params = merge_init_param_enforce( + runtime_config, + registry_entry.get("init_param_enforce"), + ) d = DEVICE( device_id=device_id, device_uuid=uid, driver_is_ros=device_class_config["type"] == "ros2", - driver_params=effective_params, + driver_params=driver_params, ) except DeviceInitError as ex: return d diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index b632ceadc..c5294798a 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -234,14 +234,12 @@ def init_wrapper( action_value_mappings: Dict[str, Any], hardware_interface: Dict[str, Any], print_publish: bool, - driver_params: Optional[Dict[str, Any]] = None, + driver_params: Dict[str, Any], driver_is_ros: bool = False, *args, **kwargs, ): """初始化设备节点的包装函数,和ROS2DeviceNode初始化保持一致""" - if driver_params is None: - driver_params = kwargs.copy() kwargs["device_id"] = device_id kwargs["device_uuid"] = device_uuid kwargs["driver_class"] = driver_class From 6514e9355fc154ebc9550e4d12a7cceaeb01b453 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:33:59 +0800 Subject: [PATCH 07/16] fix registry init enforce defaults Normalize missing init_param_enforce to an empty object so registry schema contracts do not prevent devices from loading. Co-authored-by: Cursor --- unilabos/app/package_cli.py | 8 +++----- unilabos/registry/init_enforce.py | 32 +++++++++---------------------- unilabos/registry/registry.py | 2 +- 3 files changed, 13 insertions(+), 29 deletions(-) diff --git a/unilabos/app/package_cli.py b/unilabos/app/package_cli.py index 6847bdc82..658dccf60 100644 --- a/unilabos/app/package_cli.py +++ b/unilabos/app/package_cli.py @@ -371,11 +371,10 @@ def build_resources_from_registry( for device_id, entry in entries.items(): cls = entry.get("class") if isinstance(entry.get("class"), dict) else {} init_schema = entry.get("init_param_schema") if isinstance(entry.get("init_param_schema"), dict) else None - init_enforce = entry.get("init_param_enforce") - validate_init_param_enforce( + init_enforce = validate_init_param_enforce( device_id, init_schema, - init_enforce, + entry.get("init_param_enforce"), error_factory=PackageCLIError, ) category = entry.get("category") or entry.get("tags") or [] @@ -405,8 +404,7 @@ def build_resources_from_registry( } if init_schema is not None: resource["init_param_schema"] = init_schema - if "init_param_enforce" in entry: - resource["init_param_enforce"] = init_enforce + resource["init_param_enforce"] = init_enforce resources.append(resource) return resources diff --git a/unilabos/registry/init_enforce.py b/unilabos/registry/init_enforce.py index 1bee698b6..cce986833 100644 --- a/unilabos/registry/init_enforce.py +++ b/unilabos/registry/init_enforce.py @@ -33,38 +33,23 @@ def validate_init_param_enforce( init_schema: Optional[Dict[str, Any]], init_enforce: Any, error_factory: Callable[[str], Exception] = ValueError, -) -> None: +) -> Dict[str, Any]: """Validate the JSON enforced config paired with ``init_param_schema``. ``init_param_enforce`` is a plain JSON config object. It must not reintroduce the old ``class.init`` object factory DSL; drivers should construct rich objects from JSON-friendly type strings and params inside ``__init__``. - """ - if not isinstance(init_schema, dict): - return - config_schema = init_schema.get("config") - if not isinstance(config_schema, dict): - return - - required = config_schema.get("required") - required_fields = [str(field) for field in required] if isinstance(required, list) else [] - properties = config_schema.get("properties") - has_config_contract = bool(required_fields) or ( - isinstance(properties, dict) and bool(properties) - ) - if not has_config_contract: - return + Missing or empty YAML values are normalized to an empty object because the + schema describes runtime config, while ``init_param_enforce`` only declares + registry-owned overrides. + """ + if init_enforce is None: + return {} if not isinstance(init_enforce, dict): raise error_factory( - f"{device_id}: init_param_schema.config 已声明参数,必须提供对象形式的 init_param_enforce" - ) - - missing = [field for field in required_fields if field not in init_enforce] - if missing: - raise error_factory( - f"{device_id}: init_param_enforce 缺少 required 字段: {', '.join(missing)}" + f"{device_id}: init_param_enforce 必须是对象形式" ) _reject_legacy_init_enforce( @@ -72,6 +57,7 @@ def validate_init_param_enforce( f"{device_id}.init_param_enforce", error_factory, ) + return copy.deepcopy(init_enforce) def _reject_legacy_init_enforce( diff --git a/unilabos/registry/registry.py b/unilabos/registry/registry.py index 47b52ce5b..590ff45e2 100644 --- a/unilabos/registry/registry.py +++ b/unilabos/registry/registry.py @@ -1900,7 +1900,7 @@ def _load_single_device_file( continue # --- 正常 YAML 处理 --- - validate_init_param_enforce( + device_config["init_param_enforce"] = validate_init_param_enforce( device_id, device_config.get("init_param_schema"), device_config.get("init_param_enforce"), From 6afc64be3ae7bf41c0baa66809278d1a4bb5b536 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:19:29 +0800 Subject: [PATCH 08/16] site should update intime --- .../devices/liquid_handling/prcxi/prcxi.py | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/unilabos/devices/liquid_handling/prcxi/prcxi.py b/unilabos/devices/liquid_handling/prcxi/prcxi.py index 47b213add..8591ee07a 100644 --- a/unilabos/devices/liquid_handling/prcxi/prcxi.py +++ b/unilabos/devices/liquid_handling/prcxi/prcxi.py @@ -101,15 +101,30 @@ class PRCXI9300Deck(Deck): _DEFAULT_SITE_SIZE = {"width": 128.0, "height": 86, "depth": 0} _DEFAULT_CONTENT_TYPE = ["plate", "tip_rack", "plates", "tip_racks", "tube_rack", "adaptor"] + @property + def sites(self): + sites_out = [] + for i, site in enumerate(self._sites): + occupied = self._get_site_resource(i) + sites_out.append({ + "label": site["label"], + "visible": site.get("visible", True), + "occupied_by": occupied.name if occupied is not None else None, + "position": site["position"], + "size": site["size"], + "content_type": site["content_type"], + }) + return sites_out + def __init__(self, name: str, size_x: float, size_y: float, size_z: float, sites: Optional[List[Dict[str, Any]]] = None, **kwargs): super().__init__(size_x, size_y, size_z, name) if sites is not None: - self.sites: List[Dict[str, Any]] = [dict(s) for s in sites] + self._sites: List[Dict[str, Any]] = [dict(s) for s in sites] else: - self.sites = [] + self._sites = [] for i, (x, y, z) in enumerate(self._DEFAULT_SITE_POSITIONS): - self.sites.append({ + self._sites.append({ "label": f"T{i + 1}", "visible": True, "position": {"x": x, "y": y, "z": z}, @@ -122,7 +137,7 @@ def __init__(self, name: str, size_x: float, size_y: float, size_z: float, ) def _get_site_location(self, idx: int) -> Coordinate: - pos = self.sites[idx]["position"] + pos = self._sites[idx]["position"] return Coordinate(pos["x"], pos["y"], pos["z"]) def _get_site_resource(self, idx: int) -> Optional[Resource]: @@ -172,18 +187,7 @@ def assign_child_at_slot(self, resource: Resource, slot: int, reassign: bool = F def serialize(self) -> dict: data = super().serialize() - sites_out = [] - for i, site in enumerate(self.sites): - occupied = self._get_site_resource(i) - sites_out.append({ - "label": site["label"], - "visible": site.get("visible", True), - "occupied_by": occupied.name if occupied is not None else None, - "position": site["position"], - "size": site["size"], - "content_type": site["content_type"], - }) - data["sites"] = sites_out + data["sites"] = self.sites return data From 107b0b8f0d3141ce59ae533b20ee30d3698885bc Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Wed, 3 Jun 2026 10:48:52 +0800 Subject: [PATCH 09/16] active action state --- unilabos/app/communication.py | 20 + unilabos/app/ws_client.py | 793 +++++++++--------------- unilabos/ros/nodes/presets/host_node.py | 47 +- 3 files changed, 336 insertions(+), 524 deletions(-) diff --git a/unilabos/app/communication.py b/unilabos/app/communication.py index 700065dc5..695e21509 100644 --- a/unilabos/app/communication.py +++ b/unilabos/app/communication.py @@ -76,6 +76,26 @@ def send_ping(self, ping_id: str, timestamp: float) -> None: """ pass + def publish_action_lock(self, device_id: str, action_name: str, free: bool) -> None: + """ + 主动上报单个 device+action 的锁(可用性)状态(默认空实现) + + Args: + device_id: 设备ID + action_name: 动作名称 + free: 是否空闲(True 空闲, False 占用) + """ + pass + + def publish_action_locks(self, locks: list) -> None: + """ + 批量主动上报 device+action 的锁(可用性)状态(默认空实现) + + Args: + locks: [{"device_id": str, "action_name": str, "free": bool}, ...] + """ + pass + def setup_pong_subscription(self) -> None: """ 设置pong消息订阅(可选实现) diff --git a/unilabos/app/ws_client.py b/unilabos/app/ws_client.py index da39f5ce4..12955d1c5 100644 --- a/unilabos/app/ws_client.py +++ b/unilabos/app/ws_client.py @@ -51,7 +51,6 @@ class JobStatus(Enum): """任务状态枚举""" QUEUE = "queue" # 排队中 - READY = "ready" # 已获得执行许可,等待开始 STARTED = "started" # 执行中 ENDED = "ended" # 已结束 @@ -84,21 +83,17 @@ class JobInfo: status: JobStatus start_time: float last_update_time: float = field(default_factory=time.time) - ready_timeout: Optional[float] = None # READY状态的超时时间 always_free: bool = False # 是否为永久闲置动作(不受排队限制) + # 执行载荷:排队的 job 在出队时由客户端自行启动,需保存原始 job_start 参数 + action_type: str = "" + action_args: Dict[str, Any] = field(default_factory=dict) + sample_material: Dict[str, Any] = field(default_factory=dict) + server_info: Optional[Dict[str, Any]] = None def update_timestamp(self): """更新最后更新时间""" self.last_update_time = time.time() - def set_ready_timeout(self, timeout_seconds: int = 10): - """设置READY状态超时时间""" - self.ready_timeout = time.time() + timeout_seconds - - def is_ready_timeout(self) -> bool: - """检查READY状态是否超时""" - return self.status == JobStatus.READY and self.ready_timeout is not None and time.time() > self.ready_timeout - @dataclass class WebSocketMessage: @@ -137,10 +132,13 @@ def __init__(self): self.all_jobs: Dict[str, JobInfo] = {} # job_id -> job_info self.lock = threading.RLock() - def add_queue_request(self, job_info: JobInfo) -> bool: + def enqueue_job(self, job_info: JobInfo) -> Tuple[bool, bool]: """ - 添加队列请求 - 返回True表示可以立即执行(free),False表示需要排队(busy) + 服务端直接下发的 job 入队/直发。 + + 返回 (should_start_now, lock_became_busy): + should_start_now: 该 job 是否应立即启动(调用方负责 send_goal) + lock_became_busy: 该 device+action 是否发生 free->busy 翻转(需上报 busy 锁) """ with self.lock: device_key = job_info.device_action_key @@ -151,7 +149,7 @@ def add_queue_request(self, job_info: JobInfo) -> bool: "[DeviceActionManager] Duplicate job_id has different task_id: " f"{job_info.job_id[:8]} old={existing_job.task_id[:8]} new={job_info.task_id[:8]}" ) - return False + return False, False if job_info.notebook_id and not existing_job.notebook_id: existing_job.notebook_id = job_info.notebook_id existing_job.update_timestamp() @@ -162,139 +160,88 @@ def add_queue_request(self, job_info: JobInfo) -> bool: existing_job.action_name, ) logger.info( - f"[DeviceActionManager] Duplicate queue request ignored for job {job_log}, " + f"[DeviceActionManager] Duplicate job request ignored for job {job_log}, " f"status={existing_job.status}" ) - return existing_job.status == JobStatus.READY + return False, False # 总是将job添加到all_jobs中 self.all_jobs[job_info.job_id] = job_info - # always_free的动作不受排队限制,直接设为READY + # always_free的动作不受排队限制,直接并发执行,不占用设备锁 if job_info.always_free: - job_info.status = JobStatus.READY + job_info.status = JobStatus.STARTED job_info.update_timestamp() - job_info.set_ready_timeout(10) job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name) logger.trace(f"[DeviceActionManager] Job {job_log} always_free, start immediately") - return True + return True, False - # 检查是否有正在执行或准备执行的任务 - if device_key in self.active_jobs: - # 有正在执行或准备执行的任务,加入队列 + # 设备上已有占用(正在执行)或已有排队任务 -> 入队 + if device_key in self.active_jobs or self.device_queues.get(device_key): if device_key not in self.device_queues: self.device_queues[device_key] = [] job_info.status = JobStatus.QUEUE self.device_queues[device_key].append(job_info) job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name) logger.info(f"[DeviceActionManager] Job {job_log} queued for {device_key}") - return False + return False, False - # 检查是否有排队的任务 - if device_key in self.device_queues and self.device_queues[device_key]: - # 有排队的任务,加入队列末尾 - job_info.status = JobStatus.QUEUE - self.device_queues[device_key].append(job_info) - job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name) - logger.info(f"[DeviceActionManager] Job {job_log} queued for {device_key}") - return False - - # 没有正在执行或排队的任务,可以立即执行 - # 将其状态设为READY并占位,防止后续job也被判断为free - job_info.status = JobStatus.READY + # 设备空闲 -> 立即执行并占用设备锁(free->busy 翻转) + job_info.status = JobStatus.STARTED job_info.update_timestamp() - job_info.set_ready_timeout(10) # 设置10秒超时 self.active_jobs[device_key] = job_info job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name) - logger.trace(f"[DeviceActionManager] Job {job_log} can start immediately for {device_key}") - return True + logger.trace(f"[DeviceActionManager] Job {job_log} start immediately for {device_key}") + return True, True - def start_job(self, job_id: str) -> bool: - """ - 开始执行任务 - 返回True表示成功开始,False表示失败 + def end_job(self, job_id: str) -> Tuple[Optional[JobInfo], bool]: """ - with self.lock: - if job_id not in self.all_jobs: - logger.error(f"[DeviceActionManager] Job {job_id[:4]} not found for start") - return False + 结束任务。 - job_info = self.all_jobs[job_id] - device_key = job_info.device_action_key - - # 检查job的状态是否正确 - if job_info.status != JobStatus.READY: - job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name) - logger.error(f"[DeviceActionManager] Job {job_log} is not in READY status, current: {job_info.status}") - return False - - # always_free的job不需要检查active_jobs - if not job_info.always_free: - # 检查设备上是否是这个job - if device_key not in self.active_jobs or self.active_jobs[device_key].job_id != job_id: - job_log = format_job_log( - job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name - ) - logger.error(f"[DeviceActionManager] Job {job_log} is not the active job for {device_key}") - return False - - # 开始执行任务,将状态从READY转换为STARTED - job_info.status = JobStatus.STARTED - job_info.update_timestamp() - job_info.ready_timeout = None # 清除超时时间 - - job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name) - logger.info(f"[DeviceActionManager] Job {job_log} started for {device_key}") - return True - - def end_job(self, job_id: str) -> Optional[JobInfo]: - """ - 结束任务,返回下一个可以执行的任务(如果有的话) + 返回 (next_job, lock_became_free): + next_job: 下一个应启动的任务(调用方负责 send_goal),无则 None + lock_became_free: 该 device+action 是否发生 busy->free 翻转(需上报 free 锁) """ with self.lock: if job_id not in self.all_jobs: logger.warning(f"[DeviceActionManager] Job {job_id[:4]} not found for end") - return None + return None, False job_info = self.all_jobs[job_id] device_key = job_info.device_action_key - # always_free的job直接清理,不影响队列 + # always_free的job直接清理,不影响队列/锁 if job_info.always_free: job_info.status = JobStatus.ENDED job_info.update_timestamp() del self.all_jobs[job_id] - return None + return None, False # 移除活跃任务 - if device_key in self.active_jobs and self.active_jobs[device_key].job_id == job_id: + was_active = device_key in self.active_jobs and self.active_jobs[device_key].job_id == job_id + if was_active: del self.active_jobs[device_key] - job_info.status = JobStatus.ENDED - job_info.update_timestamp() - # 从all_jobs中移除已结束的job - del self.all_jobs[job_id] - # job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name) - # logger.debug(f"[DeviceActionManager] Job {job_log} ended for {device_key}") - pass else: job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name) logger.warning(f"[DeviceActionManager] Job {job_log} was not active for {device_key}") + job_info.status = JobStatus.ENDED + job_info.update_timestamp() + del self.all_jobs[job_id] - # 检查队列中是否有等待的任务 + # 检查队列中是否有等待的任务 -> 直接置 STARTED 并占用,锁保持 busy if device_key in self.device_queues and self.device_queues[device_key]: next_job = self.device_queues[device_key].pop(0) # FIFO - # 将下一个job设置为READY状态并放入active_jobs - next_job.status = JobStatus.READY + next_job.status = JobStatus.STARTED next_job.update_timestamp() - next_job.set_ready_timeout(10) # 设置10秒超时 self.active_jobs[device_key] = next_job next_job_log = format_job_log( next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name ) - logger.trace(f"[DeviceActionManager] Next job {next_job_log} can start for {device_key}") - return next_job + logger.trace(f"[DeviceActionManager] Next job {next_job_log} starts for {device_key}") + return next_job, False - return None + # 队列已空:若刚释放了活跃任务,则 busy->free 翻转 + return None, was_active def get_active_jobs(self) -> List[JobInfo]: """获取所有正在执行的任务(含active_jobs和always_free的STARTED job)""" @@ -319,184 +266,116 @@ def get_job_info(self, job_id: str) -> Optional[JobInfo]: with self.lock: return self.all_jobs.get(job_id) - def cancel_job(self, job_id: str) -> bool: - """取消单个任务""" + def is_action_busy(self, device_action_key: str) -> bool: + """该 device+action 是否被占用(有正在执行或排队的非 always_free 任务)。""" + with self.lock: + if device_action_key in self.active_jobs: + return True + return bool(self.device_queues.get(device_action_key)) + + def cancel_job(self, job_id: str) -> Tuple[bool, Optional[JobInfo], bool]: + """ + 取消单个任务。 + + 返回 (success, next_job, lock_became_free): + success: 是否成功取消 + next_job: 取消活跃任务后被提升应启动的下一个任务(调用方负责 send_goal),无则 None + lock_became_free: 该 device+action 是否发生 busy->free 翻转(需上报 free 锁) + """ with self.lock: if job_id not in self.all_jobs: logger.warning(f"[DeviceActionManager] Job {job_id[:4]} not found for cancel") - return False + return False, None, False job_info = self.all_jobs[job_id] device_key = job_info.device_action_key - # always_free的job直接清理 + # always_free的job直接清理,不影响锁 if job_info.always_free: job_info.status = JobStatus.ENDED del self.all_jobs[job_id] job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name) logger.trace(f"[DeviceActionManager] Always-free job {job_log} cancelled") - return True + return True, None, False # 如果是正在执行的任务 if device_key in self.active_jobs and self.active_jobs[device_key].job_id == job_id: - # 清理active job状态 del self.active_jobs[device_key] job_info.status = JobStatus.ENDED - # 从all_jobs中移除 del self.all_jobs[job_id] job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name) logger.trace(f"[DeviceActionManager] Active job {job_log} cancelled for {device_key}") - # 启动下一个任务 + # 队列中有等待任务 -> 提升并占用,锁保持 busy if device_key in self.device_queues and self.device_queues[device_key]: next_job = self.device_queues[device_key].pop(0) - # 将下一个job设置为READY状态并放入active_jobs - next_job.status = JobStatus.READY + next_job.status = JobStatus.STARTED next_job.update_timestamp() - next_job.set_ready_timeout(10) self.active_jobs[device_key] = next_job next_job_log = format_job_log( next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name ) - logger.trace(f"[DeviceActionManager] Next job {next_job_log} can start after cancel") - return True + logger.trace(f"[DeviceActionManager] Next job {next_job_log} starts after cancel") + return True, next_job, False + # 队列已空 -> busy->free 翻转 + return True, None, True - # 如果是排队中的任务 + # 如果是排队中的任务(取消不影响锁) elif device_key in self.device_queues: original_length = len(self.device_queues[device_key]) self.device_queues[device_key] = [j for j in self.device_queues[device_key] if j.job_id != job_id] if len(self.device_queues[device_key]) < original_length: job_info.status = JobStatus.ENDED - # 从all_jobs中移除 del self.all_jobs[job_id] job_log = format_job_log( job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name ) logger.trace(f"[DeviceActionManager] Queued job {job_log} cancelled for {device_key}") - return True + return True, None, False job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name) logger.warning(f"[DeviceActionManager] Job {job_log} not found in active or queued jobs") - return False + return False, None, False + + def cancel_jobs_by_task_id(self, task_id: str) -> Tuple[List[str], List[JobInfo], List[Tuple[str, str]]]: + """ + 按task_id取消所有相关任务。 - def cancel_jobs_by_task_id(self, task_id: str) -> List[str]: - """按task_id取消所有相关任务,返回被取消的job_id列表""" - cancelled_job_ids = [] + 返回 (cancelled_job_ids, next_jobs_to_start, freed_locks): + cancelled_job_ids: 被取消的 job_id 列表 + next_jobs_to_start: 因取消而被提升应启动的任务列表(调用方负责 send_goal) + freed_locks: 发生 busy->free 翻转的 (device_id, action_name) 列表(需上报 free 锁) + """ + cancelled_job_ids: List[str] = [] + next_jobs_to_start: List[JobInfo] = [] + freed_locks: List[Tuple[str, str]] = [] # 首先找到所有属于该task_id的job - jobs_to_cancel = [] with self.lock: jobs_to_cancel = [job_info for job_info in self.all_jobs.values() if job_info.task_id == task_id] if not jobs_to_cancel: logger.warning(f"[DeviceActionManager] No jobs found for task_id: {task_id}") - return cancelled_job_ids + return cancelled_job_ids, next_jobs_to_start, freed_locks logger.info(f"[DeviceActionManager] Found {len(jobs_to_cancel)} jobs to cancel for task_id: {task_id}") # 逐个取消job for job_info in jobs_to_cancel: - if self.cancel_job(job_info.job_id): + success, next_job, lock_became_free = self.cancel_job(job_info.job_id) + if success: cancelled_job_ids.append(job_info.job_id) + # 被提升的下一个任务若也属于本 task_id,会在后续循环中被取消,无需启动 + if next_job is not None and next_job.task_id != task_id: + next_jobs_to_start.append(next_job) + if lock_became_free: + freed_locks.append((job_info.device_id, job_info.action_name)) logger.info( f"[DeviceActionManager] Successfully cancelled {len(cancelled_job_ids)} " f"jobs for task_id: {task_id}" ) - return cancelled_job_ids - - def refresh_ready_timeouts(self, timeout_seconds: float = 10, reason: str = "") -> int: - """将 READY 任务的超时时间刷新到至少 now + timeout_seconds。""" - if timeout_seconds <= 0: - return 0 - - refreshed_count = 0 - now = time.time() - min_timeout = now + timeout_seconds - - with self.lock: - ready_candidates = list(self.active_jobs.values()) - for job in self.all_jobs.values(): - if job.always_free and job.status == JobStatus.READY and job not in ready_candidates: - ready_candidates.append(job) - - for job_info in ready_candidates: - if job_info.status != JobStatus.READY or job_info.ready_timeout is None: - continue - if job_info.ready_timeout >= min_timeout: - continue - - old_timeout = job_info.ready_timeout - job_info.ready_timeout = min_timeout - job_info.update_timestamp() - refreshed_count += 1 - - job_log = format_job_log( - job_info.job_id, - job_info.task_id, - job_info.device_id, - job_info.action_name, - ) - logger.info( - "[DeviceActionManager] Refreshed READY timeout for job %s from %.3f to %.3f%s", - job_log, - old_timeout, - job_info.ready_timeout, - f" ({reason})" if reason else "", - ) - - logger.info( - "[DeviceActionManager] READY timeout refresh window %.1fs; refreshed %s READY job(s)%s", - timeout_seconds, - refreshed_count, - f" ({reason})" if reason else "", - ) - - return refreshed_count - - def check_ready_timeouts(self, is_connected: bool = True) -> List[JobInfo]: - """检查READY状态超时的任务,仅检测不处理""" - timeout_jobs = [] - - with self.lock: - # 收集所有需要检查的 READY 任务(active_jobs + always_free READY jobs) - ready_candidates = list(self.active_jobs.values()) - for job in self.all_jobs.values(): - if job.always_free and job.status == JobStatus.READY and job not in ready_candidates: - ready_candidates.append(job) - - ready_jobs_count = sum(1 for job in ready_candidates if job.status == JobStatus.READY) - if ready_jobs_count > 0: - logger.trace(f"[DeviceActionManager] Checking {ready_jobs_count} READY jobs for timeout") # type: ignore # noqa: E501 - - # 找到所有超时的READY任务(只检测,不处理) - for job_info in ready_candidates: - if job_info.status != JobStatus.READY: - continue - if not is_connected: - min_timeout = time.time() + 10 - if job_info.ready_timeout is not None and job_info.ready_timeout < min_timeout: - old_timeout = job_info.ready_timeout - job_info.ready_timeout = min_timeout - job_info.update_timestamp() - job_log = format_job_log( - job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name - ) - logger.info( - "[DeviceActionManager] WebSocket disconnected, keep READY job %s alive: %.3f -> %.3f", - job_log, - old_timeout, - job_info.ready_timeout, - ) - continue - if job_info.is_ready_timeout(): - timeout_jobs.append(job_info) - job_log = format_job_log( - job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name - ) - logger.warning(f"[DeviceActionManager] Job {job_log} READY timeout detected") - - return timeout_jobs + return cancelled_job_ids, next_jobs_to_start, freed_locks class MessageProcessor: @@ -600,7 +479,6 @@ async def _connection_handler(self): self.reconnect_count = 0 logger.info(f"[MessageProcessor] 已连接到 {self.websocket_url}") - self.device_manager.refresh_ready_timeouts(10, reason="websocket connected") # 启动发送协程 send_task = asyncio.create_task(self._send_handler(), name="websocket-send_task") @@ -813,9 +691,8 @@ def _check_action_always_free(self, device_id: str, action_name: str) -> bool: return False async def _handle_query_action_state(self, data: Dict[str, Any]): - """处理query_action_state消息""" + """处理query_action_state消息:纯被动查询,只回复当前状态,不入队、无副作用。""" device_id = data.get("device_id", "") - device_uuid = data.get("device_uuid", "") action_name = data.get("action_name", "") task_id = data.get("task_id", "") job_id = data.get("job_id", "") @@ -827,107 +704,44 @@ async def _handle_query_action_state(self, data: Dict[str, Any]): job_log = format_job_log(job_id, task_id, device_id, action_name) - # 1) 该 job 仍在设备管理器中(READY/QUEUE/STARTED):不重复入队。 - # READY/STARTED 表示同一个 job 已被本地接收/执行,断线重连后仍回复 free, - # 让服务端继续发送 job_start;重复 job_start 会被幂等缓存拦截或回放结果。 - # QUEUE 表示该 job 尚未轮到执行,仍回复 busy。 - # 完成后 end_job 会把 job 从管理器移除,故运行中的 job 一定能在此命中。 + # 该 (task_id, job_id) 仍在设备管理器中(QUEUE/STARTED) -> 正在处理,回复 busy。 existing_job = self.device_manager.get_job_info(job_id) - if existing_job and existing_job.job_id == job_id and existing_job.task_id == task_id: - if existing_job.status in (JobStatus.READY, JobStatus.STARTED): - response_type, free, need_more = "query_action_status", True, 0 - elif existing_job.status == JobStatus.QUEUE: - response_type, free, need_more = "query_action_status", False, 10 - else: - response_type, free, need_more = "job_call_back_status", False, 10 + if existing_job and existing_job.task_id == task_id and existing_job.status in ( + JobStatus.QUEUE, + JobStatus.STARTED, + ): await self._send_action_state_response( existing_job.device_id, existing_job.action_name, existing_job.task_id, existing_job.job_id, - response_type, - free, - need_more, + "query_action_status", + False, + 10, notebook_id=existing_job.notebook_id or notebook_id, ) logger.trace( - f"[MessageProcessor] query_action_state {job_log} 返回当前状态 {existing_job.status}" + f"[MessageProcessor] query_action_state {job_log} 返回当前状态 {existing_job.status} (busy)" ) return - # 2) 不在管理器、但已 job_start 过(已完成被移除,多为断线重连后服务端重查): - # 回复 free,让服务端继续走 job_start,真正结果由 _handle_job_start 命中缓存回放。 + # 不在管理器中 -> 已完成/未知,回复 free(仅状态回报,不触发任何执行/入队)。 if self.websocket_client and self.websocket_client.is_job_cached(job_id, task_id): self.websocket_client.log_cached_job(job_id, task_id, source="query_action_state") - await self._send_action_state_response( - device_id, - action_name, - task_id, - job_id, - "query_action_status", - True, - 0, - notebook_id=notebook_id, - ) - logger.info( - f"[MessageProcessor] [缓存复用] query_action_state {job_log} 命中缓存(已完成),回复 free" - ) - return - - device_action_key = f"/devices/{device_id}/{action_name}" - - # 检查action是否为always_free - action_always_free = self._check_action_always_free(device_id, action_name) - - # 创建任务信息 - job_info = JobInfo( - job_id=job_id, - task_id=task_id, - device_id=device_id, + await self._send_action_state_response( + device_id, + action_name, + task_id, + job_id, + "query_action_status", + True, + 0, notebook_id=notebook_id, - action_name=action_name, - device_action_key=device_action_key, - status=JobStatus.QUEUE, - start_time=time.time(), - always_free=action_always_free, ) - - # 添加到设备管理器 - can_start_immediately = self.device_manager.add_queue_request(job_info) - - if can_start_immediately: - # 可以立即开始 - await self._send_action_state_response( - device_id, - action_name, - task_id, - job_id, - "query_action_status", - True, - 0, - notebook_id=notebook_id, - ) - logger.trace(f"[MessageProcessor] Job {job_log} can start immediately") - else: - # 需要排队 - await self._send_action_state_response( - device_id, - action_name, - task_id, - job_id, - "query_action_status", - False, - 10, - notebook_id=notebook_id, - ) - logger.trace(f"[MessageProcessor] Job {job_log} queued") - - # 通知QueueProcessor有新的队列更新 - if self.queue_processor: - self.queue_processor.notify_queue_update() + logger.trace(f"[MessageProcessor] query_action_state {job_log} 返回当前状态 free") async def _handle_job_start(self, data: Dict[str, Any]): - """处理job_start消息""" + """处理job_start消息:服务端直接下发,本地直跑或排队(不再要求先 query_action_state)。""" try: data = dict(data or {}) if not data.get("sample_material"): @@ -954,55 +768,40 @@ async def _handle_job_start(self, data: Dict[str, Any]): ) return - # 服务端对always_free动作可能跳过query_action_state直接发job_start, - # 此时job尚未注册,需要自动补注册 - existing_job = self.device_manager.get_job_info(req.job_id) - if existing_job and existing_job.task_id != req.task_id: - logger.warning( - "[MessageProcessor] job_start job_id matched but task_id mismatched, skip start: " - "job=%s old_task=%s new_task=%s", - req.job_id[:8], - existing_job.task_id[:8], - req.task_id[:8], - ) - return - if not existing_job: - action_name = req.action - device_action_key = f"/devices/{req.device_id}/{action_name}" - action_always_free = self._check_action_always_free(req.device_id, action_name) - - if action_always_free: - job_info = JobInfo( - job_id=req.job_id, - task_id=req.task_id, - device_id=req.device_id, - notebook_id=req.notebook_id, - action_name=action_name, - device_action_key=device_action_key, - status=JobStatus.QUEUE, - start_time=time.time(), - always_free=True, - ) - self.device_manager.add_queue_request(job_info) - existing_job = job_info - logger.info(f"[MessageProcessor] Job {job_log} always_free, auto-registered from direct job_start") - else: - logger.error(f"[MessageProcessor] Job {job_log} not registered (missing query_action_state)") - return + notebook_id = req.notebook_id or "" + device_action_key = f"/devices/{req.device_id}/{req.action}" + action_always_free = self._check_action_always_free(req.device_id, req.action) + + # 构造 JobInfo 并入队/直发;排队的 job 在出队时由客户端用保存的载荷启动 + job_info = JobInfo( + job_id=req.job_id, + task_id=req.task_id, + device_id=req.device_id, + notebook_id=notebook_id, + action_name=req.action, + device_action_key=device_action_key, + status=JobStatus.QUEUE, + start_time=time.time(), + always_free=action_always_free, + action_type=req.action_type, + action_args=req.action_args, + sample_material=req.sample_material, + server_info=req.server_info, + ) + + should_start_now, lock_became_busy = self.device_manager.enqueue_job(job_info) - if existing_job and req.notebook_id and not existing_job.notebook_id: - existing_job.notebook_id = req.notebook_id - notebook_id = req.notebook_id or (existing_job.notebook_id if existing_job else "") + # free->busy 翻转:主动上报该 device+action 的锁被占用 + if lock_became_busy and self.websocket_client: + self.websocket_client.publish_action_lock(req.device_id, req.action, free=False) - success = self.device_manager.start_job(req.job_id) - if not success: - logger.error(f"[MessageProcessor] Failed to start job {job_log}") + if not should_start_now: + logger.info(f"[MessageProcessor] Job {job_log} queued, waiting for device") return logger.info(f"[MessageProcessor] Starting job {job_log}") # 创建HostNode任务 - device_action_key = f"/devices/{req.device_id}/{req.action}" queue_item = QueueItem( task_type="job_call_back_status", device_id=req.device_id, @@ -1059,28 +858,15 @@ async def _handle_job_start(self, data: Dict[str, Any]): } self.send_message(message) - # 手动调用job结束逻辑 - next_job = self.device_manager.end_job(req.job_id) - if next_job: - # 通知下一个任务可以开始 - await self._send_action_state_response( - next_job.device_id, - next_job.action_name, - next_job.task_id, - next_job.job_id, - "query_action_status", - True, - 0, - notebook_id=next_job.notebook_id, - ) + # 手动调用job结束逻辑:出队下一个任务由客户端自行启动 + # (该 fallback 分支无 websocket_client,无法上报锁,故忽略 lock_became_free) + next_job, _lock_became_free = self.device_manager.end_job(req.job_id) + if next_job and self.queue_processor: + self.queue_processor.enqueue_pending_start(next_job) next_job_log = format_job_log( next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name ) - logger.info(f"[MessageProcessor] Started next job {next_job_log} after error") - - # 通知QueueProcessor有队列更新 - if self.queue_processor: - self.queue_processor.notify_queue_update() + logger.info(f"[MessageProcessor] Queued next job {next_job_log} for start after error") else: logger.warning("[MessageProcessor] Failed to publish job error status - missing req or queue_item") @@ -1114,10 +900,21 @@ async def _handle_cancel_action(self, data: Dict[str, Any]): ) # 按job_id取消单个job(清理状态机) - success = self.device_manager.cancel_job(job_id) + success, next_job, lock_became_free = self.device_manager.cancel_job(job_id) if success: logger.info(f"[MessageProcessor] Job {job_log} cancelled from queue/active list") + # 取消活跃任务后,出队下一个由客户端自行启动 + if next_job and self.queue_processor: + self.queue_processor.enqueue_pending_start(next_job) + # busy->free 翻转,主动上报锁释放 + elif lock_became_free and self.websocket_client: + self.websocket_client.publish_action_lock( + job_info.device_id if job_info else "", + job_info.action_name if job_info else "", + free=True, + ) + # 通知QueueProcessor有队列更新 if self.queue_processor: self.queue_processor.notify_queue_update() @@ -1144,10 +941,19 @@ async def _handle_cancel_action(self, data: Dict[str, Any]): ) # 按task_id取消所有相关job(清理状态机) - cancelled_job_ids = self.device_manager.cancel_jobs_by_task_id(task_id) + cancelled_job_ids, next_jobs_to_start, freed_locks = self.device_manager.cancel_jobs_by_task_id(task_id) if cancelled_job_ids: logger.info(f"[MessageProcessor] Cancelled {len(cancelled_job_ids)} jobs for task_id: {task_id}") + # 出队被提升的任务由客户端自行启动 + for next_job in next_jobs_to_start: + if self.queue_processor: + self.queue_processor.enqueue_pending_start(next_job) + # busy->free 翻转,主动上报锁释放 + if self.websocket_client: + for dev_id, act_name in freed_locks: + self.websocket_client.publish_action_lock(dev_id, act_name, free=True) + # 通知QueueProcessor有队列更新 if self.queue_processor: self.queue_processor.notify_queue_update() @@ -1387,6 +1193,10 @@ def __init__(self, device_manager: DeviceActionManager, message_processor: Messa # 事件通知机制 self.queue_update_event = threading.Event() + # 待启动队列:出队/取消提升的任务由本线程统一调用 send_goal, + # 避免在 ROS 结果回调线程里直接 send_goal(wait_for_server) 阻塞 executor。 + self.pending_starts: "Queue[JobInfo]" = Queue() + logger.trace("[QueueProcessor] Initialized") def set_websocket_client(self, websocket_client: "WebSocketClient"): @@ -1413,51 +1223,14 @@ def stop(self) -> None: logger.info("[QueueProcessor] Stopped") def _run(self): - """运行队列处理主循环""" + """运行队列处理主循环:消费待启动队列,由本线程统一启动出队/取消提升的任务。""" logger.trace("[QueueProcessor] Queue processor started") while self.is_running: try: - # 检查READY状态超时的任务 - timeout_jobs = self.device_manager.check_ready_timeouts( - is_connected=self.message_processor.is_connected() - ) - if timeout_jobs: - logger.info(f"[QueueProcessor] Found {len(timeout_jobs)} READY jobs that timed out") - # 为超时的job发布失败状态,通过正常job完成流程处理 - for timeout_job in timeout_jobs: - timeout_item = QueueItem( - task_type="job_call_back_status", - device_id=timeout_job.device_id, - action_name=timeout_job.action_name, - task_id=timeout_job.task_id, - job_id=timeout_job.job_id, - notebook_id=timeout_job.notebook_id, - device_action_key=timeout_job.device_action_key, - ) - # 发布超时失败状态,这会触发正常的job完成流程 - if self.websocket_client: - job_log = format_job_log( - timeout_job.job_id, timeout_job.task_id, timeout_job.device_id, timeout_job.action_name - ) - logger.info(f"[QueueProcessor] Publishing timeout failure for job {job_log}") - self.websocket_client.publish_job_status( - {}, - timeout_item, - "failed", - serialize_result_info("Job READY state timeout after 10 seconds", False, {}), - ) - - # 立即触发状态更新 - self.notify_queue_update() - - # 发送正在执行任务的running状态 - self._send_running_status() - - # 发送排队任务的busy状态 - self._send_busy_status() - - # 等待10秒或者等待事件通知 + self._drain_pending_starts() + + # 无 READY 超时/周期上报负担,事件驱动为主,10s 兜底唤醒 self.queue_update_event.wait(timeout=10) self.queue_update_event.clear() # 清除事件 @@ -1472,80 +1245,66 @@ def notify_queue_update(self): """通知队列有更新,触发立即检查""" self.queue_update_event.set() - def _send_running_status(self): - """发送正在执行任务的running状态""" - if not self.message_processor.is_connected(): - return - - active_jobs = self.device_manager.get_active_jobs() - for job_info in active_jobs: - # 只给真正在执行的job发送running状态,READY状态的job不需要 - if job_info.status != JobStatus.STARTED: - continue - - message = { - "action": "report_action_state", - "data": { - "type": "job_call_back_status", - "device_id": job_info.device_id, - "action_name": job_info.action_name, - "task_id": job_info.task_id, - "job_id": job_info.job_id, - "notebook_id": job_info.notebook_id, - "free": False, - "need_more": 10 + 1, - }, - } - self.message_processor.send_message(message) - job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name) - logger.trace(f"[QueueProcessor] Sent running status for job {job_log}") # type: ignore - - def _send_busy_status(self): - """发送排队任务的busy状态""" - if not self.message_processor.is_connected(): - return - - queued_jobs = self.device_manager.get_queued_jobs() - if not queued_jobs: + def enqueue_pending_start(self, job_info: JobInfo) -> None: + """登记一个待启动任务(出队/取消提升),由 QueueProcessor 线程统一 send_goal。""" + try: + self.pending_starts.put_nowait(job_info) + except Exception: + logger.warning("[QueueProcessor] pending_starts queue full, dropping job") return + self.notify_queue_update() - queue_summary = {} - for j in queued_jobs: - key = f"{j.device_id}/{j.action_name}" - queue_summary[key] = queue_summary.get(key, 0) + 1 - logger.debug( - f"[QueueProcessor] Sending busy status for {len(queued_jobs)} queued jobs: {queue_summary}" + def _drain_pending_starts(self) -> None: + """消费待启动队列,逐个启动。""" + while True: + try: + job = self.pending_starts.get_nowait() + except Empty: + break + self._start_job_goal(job) + + def _start_job_goal(self, job: JobInfo) -> None: + """用 JobInfo 保存的载荷向 HostNode 下发 goal。""" + job_log = format_job_log(job.job_id, job.task_id, job.device_id, job.action_name) + queue_item = QueueItem( + task_type="job_call_back_status", + device_id=job.device_id, + action_name=job.action_name, + task_id=job.task_id, + job_id=job.job_id, + notebook_id=job.notebook_id, + device_action_key=job.device_action_key, ) - for job_info in queued_jobs: - # 快照可能已过期:在遍历过程中 end_job() 可能已将此 job 移至 READY, - # 此时不应再发送 busy/need_more,否则会覆盖已发出的 free=True 通知 - if job_info.status != JobStatus.QUEUE: - continue + host_node = HostNode.get_instance(0) + if not host_node: + logger.error(f"[QueueProcessor] HostNode unavailable, fail dequeued job {job_log}") + if self.websocket_client: + self.websocket_client.publish_job_status( + {}, queue_item, "failed", serialize_result_info("HostNode instance not available", False, {}) + ) + return - message = { - "action": "report_action_state", - "data": { - "type": "query_action_status", - "device_id": job_info.device_id, - "action_name": job_info.action_name, - "task_id": job_info.task_id, - "job_id": job_info.job_id, - "notebook_id": job_info.notebook_id, - "free": False, - "need_more": 10 + 1, - }, - } - success = self.message_processor.send_message(message) - job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name) - if success: - logger.trace(f"[QueueProcessor] Sent busy/need_more for queued job {job_log}") - else: - logger.warning(f"[QueueProcessor] Failed to send busy status for job {job_log}") + try: + host_node.send_goal( + queue_item, + action_type=job.action_type, + action_kwargs=job.action_args, + sample_material=job.sample_material, + server_info=job.server_info, + ) + logger.info(f"[QueueProcessor] Started dequeued job {job_log}") + except Exception as e: + logger.error(f"[QueueProcessor] Failed to start dequeued job {job_log}: {e}") + logger.error(traceback.format_exc()) + if self.websocket_client: + self.websocket_client.publish_job_status( + {}, queue_item, "failed", serialize_result_info(traceback.format_exc(), False, {}) + ) def handle_job_completed(self, job_id: str, status: str) -> None: - """处理任务完成""" - # 获取job信息用于日志 + """处理任务完成:出队下一个任务由本类自行启动;队列清空则上报 free 锁。""" + # 获取job信息用于日志(end_job 会将其移除,需提前取出 device/action) job_info = self.device_manager.get_job_info(job_id) # 如果job不存在,说明可能已被手动取消 @@ -1555,41 +1314,21 @@ def handle_job_completed(self, job_id: str, status: str) -> None: ) return - job_log = format_job_log( - job_id, - job_info.task_id, - job_info.device_id, - job_info.action_name, - ) + device_id = job_info.device_id + action_name = job_info.action_name + job_log = format_job_log(job_id, job_info.task_id, device_id, action_name) logger.trace(f"[QueueProcessor] Job {job_log} completed with status: {status}") - # 结束任务,获取下一个可执行的任务 - next_job = self.device_manager.end_job(job_id) + # 结束任务,获取下一个可执行的任务及锁翻转 + next_job, lock_became_free = self.device_manager.end_job(job_id) - if next_job and self.message_processor.is_connected(): - # 通知下一个任务可以开始 - message = { - "action": "report_action_state", - "data": { - "type": "query_action_status", - "device_id": next_job.device_id, - "action_name": next_job.action_name, - "task_id": next_job.task_id, - "job_id": next_job.job_id, - "notebook_id": next_job.notebook_id, - "free": True, - "need_more": 0, - }, - } - self.message_processor.send_message(message) - # next_job_log = format_job_log( - # next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name - # ) - # logger.debug(f"[QueueProcessor] Notified next job {next_job_log} can start") - - # 立即触发下一轮状态检查 - self.notify_queue_update() + if next_job: + # 锁保持 busy,客户端自行启动下一个任务 + self.enqueue_pending_start(next_job) + elif lock_became_free and self.websocket_client: + # busy->free 翻转,主动上报锁释放 + self.websocket_client.publish_action_lock(device_id, action_name, free=True) class WebSocketClient(BaseCommunicationClient): @@ -1945,20 +1684,55 @@ def cancel_goal(self, job_id: str) -> None: """取消指定的任务""" # 获取job信息用于日志 job_info = self.device_manager.get_job_info(job_id) + device_id = job_info.device_id if job_info else "" + action_name = job_info.action_name if job_info else "" job_log = format_job_log( job_id, job_info.task_id if job_info else "", - job_info.device_id if job_info else "", - job_info.action_name if job_info else "", + device_id, + action_name, ) logger.debug(f"[WebSocketClient] Cancel goal request for job: {job_log}") - success = self.device_manager.cancel_job(job_id) + success, next_job, lock_became_free = self.device_manager.cancel_job(job_id) if success: logger.info(f"[WebSocketClient] Job {job_log} cancelled successfully") + if next_job: + self.queue_processor.enqueue_pending_start(next_job) + elif lock_became_free: + self.publish_action_lock(device_id, action_name, free=True) else: logger.warning(f"[WebSocketClient] Failed to cancel job {job_log}") + def publish_action_lock(self, device_id: str, action_name: str, free: bool) -> None: + """主动上报单个 device+action 的锁(可用性)状态。""" + self.publish_action_locks([{"device_id": device_id, "action_name": action_name, "free": free}]) + + def publish_action_locks(self, locks: List[Dict[str, Any]]) -> None: + """批量主动上报 device+action 的锁(可用性)状态。 + + report_action_lock 不带 job_id/task_id,仅表达每个 device+action 当前是否空闲。 + 单次锁翻转 locks 长度为 1,host_ready/重连时为全量快照。 + """ + if self.is_disabled or not locks: + return + # 未连接时不发送:重连后由 publish_host_ready 的全量快照重新对齐真实锁状态, + # 避免把断链期间产生的中间态当作稳定状态推给服务端。 + if not self.is_connected(): + logger.debug(f"[WebSocketClient] Not connected, skip report_action_lock for {len(locks)} action(s)") + return + + message = { + "action": "report_action_lock", + "data": { + "locks": locks, + "machine_name": BasicConfig.machine_name, + "timestamp": time.time(), + }, + } + self.message_processor.send_message(message) + logger.info(f"[WebSocketClient] report_action_lock sent for {len(locks)} action(s)") + def publish_host_ready(self) -> None: """发布host_node ready信号,包含设备和动作信息""" if self.is_disabled or not self.is_connected(): @@ -2023,3 +1797,14 @@ def publish_host_ready(self) -> None: } self.message_processor.send_message(message) logger.info(f"[WebSocketClient] Host node ready signal published with {len(devices)} devices") + + # 紧随 host_ready 发送一条全量锁快照:启动时全部 free, + # 重连时按 DeviceActionManager 反映正在运行/排队的 busy,实现锁状态对齐。 + locks = [] + for dev in devices: + device_id = dev["device_id"] + for action_name in dev.get("actions", {}).keys(): + device_action_key = f"/devices/{device_id}/{action_name}" + free = not self.device_manager.is_action_busy(device_action_key) + locks.append({"device_id": device_id, "action_name": action_name, "free": free}) + self.publish_action_locks(locks) diff --git a/unilabos/ros/nodes/presets/host_node.py b/unilabos/ros/nodes/presets/host_node.py index 710299b85..418be9c6f 100644 --- a/unilabos/ros/nodes/presets/host_node.py +++ b/unilabos/ros/nodes/presets/host_node.py @@ -7,7 +7,7 @@ from unilabos.utils.tools import fast_dumps_str as _fast_dumps_str, fast_loads as _fast_loads from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Optional, Dict, Any, List, ClassVar, Set, Union +from typing import TYPE_CHECKING, Optional, Dict, Any, List, ClassVar, Set, Tuple, Union from action_msgs.msg import GoalStatus from geometry_msgs.msg import Point @@ -527,6 +527,22 @@ def _discovery_devices_callback(self) -> None: else: self.lab_logger().debug("[Host Node] Device discovery already in progress, skipping.") + def _report_action_locks_free(self, action_pairs: List[Tuple[str, str]]) -> None: + """向所有桥接器主动上报新发现 action 的锁状态为 free(report_action_lock)。 + + 服务端直接下发 job 模式下,需要在发现新设备/新 action 时主动告知其可用, + 而不再依赖 query_action_state。 + """ + if not action_pairs: + return + locks = [{"device_id": dev, "action_name": act, "free": True} for dev, act in action_pairs] + for bridge in self.bridges: + if hasattr(bridge, "publish_action_locks"): + try: + bridge.publish_action_locks(locks) + except Exception as e: + self.lab_logger().warning(f"[Host Node] publish_action_locks failed: {e}") + def _create_action_clients_for_device(self, device_id: str, namespace: str) -> None: """ 为设备创建所有必要的ActionClient @@ -535,6 +551,8 @@ def _create_action_clients_for_device(self, device_id: str, namespace: str) -> N device_id: 设备ID namespace: 设备命名空间 """ + new_action_pairs: List[Tuple[str, str]] = [] + edge_device_id = namespace[9:] for action_id, action_types in get_action_server_names_and_types_by_node(self, device_id, namespace): if action_id not in self._action_clients: try: @@ -544,19 +562,13 @@ def _create_action_clients_for_device(self, device_id: str, namespace: str) -> N ) self.lab_logger().trace(f"[Host Node] Created ActionClient (Discovery): {action_id}") action_name = action_id[len(namespace) + 1 :] - edge_device_id = namespace[9:] - # from unilabos.app.comm_factory import get_communication_client - # comm_client = get_communication_client() - # info_with_schema = ros_action_to_json_schema(action_type) - # comm_client.publish_actions(action_name, { - # "device_id": edge_device_id, - # "device_type": "", - # "action_name": action_name, - # "schema": info_with_schema, - # }) + new_action_pairs.append((edge_device_id, action_name)) except Exception as e: self.lab_logger().error(f"[Host Node] Failed to create ActionClient for {action_id}: {str(e)}") + # 发现新 action 后主动上报其 free 锁状态 + self._report_action_locks_free(new_action_pairs) + async def create_resource_detailed( self, resources: list[Union[list["Resource"], "Resource"]], @@ -688,6 +700,7 @@ def initialize_device(self, device_id: str, device_config: ResourceDictInstance) self.devices_instances[device_id] = d # noinspection PyProtectedMember self._action_value_mappings[device_id] = d._ros_node._action_value_mappings + new_action_pairs: List[Tuple[str, str]] = [] # noinspection PyProtectedMember for action_name, action_value_mapping in d._ros_node._action_value_mappings.items(): if action_name.startswith("auto-") or str(action_value_mapping.get("type", "")).startswith( @@ -706,20 +719,14 @@ def initialize_device(self, device_id: str, device_config: ResourceDictInstance) self.lab_logger().trace( f"[Host Node] Created ActionClient (Local): {action_id}" ) # 子设备再创建用的是Discover发现的 - # from unilabos.app.comm_factory import get_communication_client - # comm_client = get_communication_client() - # info_with_schema = ros_action_to_json_schema(action_type) - # comm_client.publish_actions(action_name, { - # "device_id": device_id, - # "device_type": device_config["class"], - # "action_name": action_name, - # "schema": info_with_schema, - # }) + new_action_pairs.append((device_id, action_name)) else: self.lab_logger().warning(f"[Host Node] ActionClient {action_id} already exists.") device_key = f"{self.devices_names[device_id]}/{device_id}" # 这里不涉及二级device_id # 添加到在线设备列表 self._online_devices.add(device_key) + # 新注册本地设备 action 后主动上报其 free 锁状态 + self._report_action_locks_free(new_action_pairs) def update_device_status_subscriptions(self) -> None: """ From ae258adcc118baa60178b9ee62944a61579e1d4c Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Wed, 3 Jun 2026 14:36:19 +0800 Subject: [PATCH 10/16] support query action log --- unilabos/app/ws_client.py | 48 ++++++++++++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/unilabos/app/ws_client.py b/unilabos/app/ws_client.py index 12955d1c5..018d5f633 100644 --- a/unilabos/app/ws_client.py +++ b/unilabos/app/ws_client.py @@ -637,6 +637,8 @@ async def _process_message(self, message_type: str, message_data: Dict[str, Any] self._handle_pong(message_data) elif message_type == "query_action_state": await self._handle_query_action_state(message_data) + elif message_type == "query_action_lock": + await self._handle_query_action_lock(message_data) elif message_type == "job_start": await self._handle_job_start(message_data) elif message_type == "cancel_action" or message_type == "cancel_task": @@ -740,6 +742,17 @@ async def _handle_query_action_state(self, data: Dict[str, Any]): ) logger.trace(f"[MessageProcessor] query_action_state {job_log} 返回当前状态 free") + async def _handle_query_action_lock(self, data: Dict[str, Any]): + """处理 query_action_lock:服务端要求客户端重新上报当前全量锁(每个 device+action 的忙闲)。 + + 与 query_action_state(查询单个 job) 不同,这里是全量锁快照重传,用于服务端侧状态重新对齐。 + """ + if not self.websocket_client: + logger.warning("[MessageProcessor] query_action_lock received but websocket_client unavailable") + return + self.websocket_client.report_all_action_locks() + logger.trace("[MessageProcessor] query_action_lock: re-reported all action locks") + async def _handle_job_start(self, data: Dict[str, Any]): """处理job_start消息:服务端直接下发,本地直跑或排队(不再要求先 query_action_state)。""" try: @@ -1733,6 +1746,32 @@ def publish_action_locks(self, locks: List[Dict[str, Any]]) -> None: self.message_processor.send_message(message) logger.info(f"[WebSocketClient] report_action_lock sent for {len(locks)} action(s)") + def report_all_action_locks(self) -> None: + """重新上报全量锁快照:遍历所有 device+action,按 DeviceActionManager 的忙闲状态上报 free/busy。 + + 用于 host_ready/重连后的锁对齐,以及响应服务端主动下发的 query_action_lock。 + """ + if self.is_disabled or not self.is_connected(): + logger.debug("[WebSocketClient] Not connected, skip report_all_action_locks") + return + + host_node = HostNode.get_instance(0) + if host_node is None: + logger.debug("[WebSocketClient] Host node 尚未就绪,跳过全量锁上报") + return + + locks: List[Dict[str, Any]] = [] + for device_id in host_node.devices_names.keys(): + action_names = set() + for action_id in host_node._action_clients.keys(): + if device_id in action_id: + action_names.add(action_id.split("/")[-1]) + for action_name in action_names: + device_action_key = f"/devices/{device_id}/{action_name}" + free = not self.device_manager.is_action_busy(device_action_key) + locks.append({"device_id": device_id, "action_name": action_name, "free": free}) + self.publish_action_locks(locks) + def publish_host_ready(self) -> None: """发布host_node ready信号,包含设备和动作信息""" if self.is_disabled or not self.is_connected(): @@ -1800,11 +1839,4 @@ def publish_host_ready(self) -> None: # 紧随 host_ready 发送一条全量锁快照:启动时全部 free, # 重连时按 DeviceActionManager 反映正在运行/排队的 busy,实现锁状态对齐。 - locks = [] - for dev in devices: - device_id = dev["device_id"] - for action_name in dev.get("actions", {}).keys(): - device_action_key = f"/devices/{device_id}/{action_name}" - free = not self.device_manager.is_action_busy(device_action_key) - locks.append({"device_id": device_id, "action_name": action_name, "free": free}) - self.publish_action_locks(locks) + self.report_all_action_locks() From 60193245766f47598c2c4d9542238e9e11f380b2 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Fri, 12 Jun 2026 05:34:10 +0800 Subject: [PATCH 11/16] fix(host): report action locks (incl JsonCommand) at startup and on device discovery Co-authored-by: Cursor --- unilabos/app/ws_client.py | 19 ++++++++++++------- unilabos/ros/nodes/presets/host_node.py | 10 ++++++++++ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/unilabos/app/ws_client.py b/unilabos/app/ws_client.py index 018d5f633..4e23959c8 100644 --- a/unilabos/app/ws_client.py +++ b/unilabos/app/ws_client.py @@ -1763,9 +1763,14 @@ def report_all_action_locks(self) -> None: locks: List[Dict[str, Any]] = [] for device_id in host_node.devices_names.keys(): action_names = set() - for action_id in host_node._action_clients.keys(): - if device_id in action_id: - action_names.add(action_id.split("/")[-1]) + # 从全量动作映射(_action_value_mappings)取动作名,而非仅 _action_clients。 + # UniLabJsonCommand 类型的动作不会建独立 ROS ActionServer、不进 _action_clients, + # 但仍是可经 _execute_driver_command 调用的能力(如 virtual_workbench 的全部动作), + # 必须纳入锁快照;"auto-" 前缀为异步/自动变体,跳过以避免与规范动作名重复。 + for action_name in host_node._action_value_mappings.get(device_id, {}).keys(): + if action_name.startswith("auto-"): + continue + action_names.add(action_name) for action_name in action_names: device_action_key = f"/devices/{device_id}/{action_name}" free = not self.device_manager.is_action_busy(device_action_key) @@ -1834,9 +1839,9 @@ def publish_host_ready(self) -> None: "devices": devices, }, } + # 先上报全量锁快照,再发 host_ready:借助发送队列 FIFO 顺序, + # 服务端会先收到 report_action_lock、再收到 host_node_ready。 + # 启动时全部 free,重连时按 DeviceActionManager 反映正在运行/排队的 busy,实现锁状态对齐。 + self.report_all_action_locks() self.message_processor.send_message(message) logger.info(f"[WebSocketClient] Host node ready signal published with {len(devices)} devices") - - # 紧随 host_ready 发送一条全量锁快照:启动时全部 free, - # 重连时按 DeviceActionManager 反映正在运行/排队的 busy,实现锁状态对齐。 - self.report_all_action_locks() diff --git a/unilabos/ros/nodes/presets/host_node.py b/unilabos/ros/nodes/presets/host_node.py index 418be9c6f..c56a32d32 100644 --- a/unilabos/ros/nodes/presets/host_node.py +++ b/unilabos/ros/nodes/presets/host_node.py @@ -566,6 +566,16 @@ def _create_action_clients_for_device(self, device_id: str, namespace: str) -> N except Exception as e: self.lab_logger().error(f"[Host Node] Failed to create ActionClient for {action_id}: {str(e)}") + # 补充 UniLabJsonCommand 类型动作:这类动作不建独立 ROS ActionServer, + # 不会出现在 get_action_server_names_and_types_by_node 的结果里,但仍是可经 + # _execute_driver_command 调用的能力(如 virtual_workbench 的全部动作)。 + # 发现新设备时同样要补报其 free 锁,否则服务端永远感知不到这些动作。 + already = {action_name for _, action_name in new_action_pairs} + for action_name in self._action_value_mappings.get(edge_device_id, {}).keys(): + if action_name.startswith("auto-") or action_name in already: + continue + new_action_pairs.append((edge_device_id, action_name)) + # 发现新 action 后主动上报其 free 锁状态 self._report_action_locks_free(new_action_pairs) From 82ac5af543ed30bf371d48cf13bf890eb9d340c7 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:59:45 +0800 Subject: [PATCH 12/16] fix(host): report auto_prefix actions and skip _execute_driver_command in action locks Action locks now report the full invokable action set from _action_value_mappings, including @action(auto_prefix=True) 'auto-*' actions (e.g. workbench prepare_materials/heating/move) that were previously dropped by the 'auto-' filter. Only the generic _execute_driver_command[_async] command channels are excluded. Applied across all three lock-report paths: report_all_action_locks (host_ready full snapshot), _create_action_clients_for_device (device discovery), and initialize_device (local device startup, which previously reported zero locks for JsonCommand/auto- only devices like workbench). Co-authored-by: Cursor --- unilabos/app/ws_client.py | 12 +++++---- unilabos/ros/nodes/presets/host_node.py | 34 ++++++++++++++++++++----- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/unilabos/app/ws_client.py b/unilabos/app/ws_client.py index 4e23959c8..864e753fd 100644 --- a/unilabos/app/ws_client.py +++ b/unilabos/app/ws_client.py @@ -1763,12 +1763,14 @@ def report_all_action_locks(self) -> None: locks: List[Dict[str, Any]] = [] for device_id in host_node.devices_names.keys(): action_names = set() - # 从全量动作映射(_action_value_mappings)取动作名,而非仅 _action_clients。 - # UniLabJsonCommand 类型的动作不会建独立 ROS ActionServer、不进 _action_clients, - # 但仍是可经 _execute_driver_command 调用的能力(如 virtual_workbench 的全部动作), - # 必须纳入锁快照;"auto-" 前缀为异步/自动变体,跳过以避免与规范动作名重复。 + # 从全量动作映射(_action_value_mappings)取动作名,而非仅 _action_clients: + # 它是设备“可调用动作”的权威清单,需全量上报,包含 + # - 建独立 ROS ActionServer 的动作; + # - UniLabJsonCommand 动作(不建 ActionServer、经 _execute_driver_command 调用); + # - @action(auto_prefix=True) 注册成的 "auto-" 动作(如 workbench 的 prepare_materials 等)。 + # 仅跳过 _execute_driver_command[_async]:它是上述动作的通用调用通道本身,并非具体业务动作。 for action_name in host_node._action_value_mappings.get(device_id, {}).keys(): - if action_name.startswith("auto-"): + if action_name.startswith("_execute_driver_command"): continue action_names.add(action_name) for action_name in action_names: diff --git a/unilabos/ros/nodes/presets/host_node.py b/unilabos/ros/nodes/presets/host_node.py index c56a32d32..ffa5698af 100644 --- a/unilabos/ros/nodes/presets/host_node.py +++ b/unilabos/ros/nodes/presets/host_node.py @@ -535,7 +535,15 @@ def _report_action_locks_free(self, action_pairs: List[Tuple[str, str]]) -> None """ if not action_pairs: return - locks = [{"device_id": dev, "action_name": act, "free": True} for dev, act in action_pairs] + # _execute_driver_command[_async] 是通用驱动命令入口,并非具体业务动作, + # 不作为锁上报(与 WebSocketClient.report_all_action_locks 的过滤保持一致)。 + locks = [ + {"device_id": dev, "action_name": act, "free": True} + for dev, act in action_pairs + if not act.startswith("_execute_driver_command") + ] + if not locks: + return for bridge in self.bridges: if hasattr(bridge, "publish_action_locks"): try: @@ -566,13 +574,15 @@ def _create_action_clients_for_device(self, device_id: str, namespace: str) -> N except Exception as e: self.lab_logger().error(f"[Host Node] Failed to create ActionClient for {action_id}: {str(e)}") - # 补充 UniLabJsonCommand 类型动作:这类动作不建独立 ROS ActionServer, - # 不会出现在 get_action_server_names_and_types_by_node 的结果里,但仍是可经 - # _execute_driver_command 调用的能力(如 virtual_workbench 的全部动作)。 - # 发现新设备时同样要补报其 free 锁,否则服务端永远感知不到这些动作。 + # 补充 _action_value_mappings 中其余动作:UniLabJsonCommand 类型动作不建独立 + # ROS ActionServer,不会出现在 get_action_server_names_and_types_by_node 的结果里; + # @action(auto_prefix=True) 注册成的 "auto-" 动作(如 workbench 的 prepare_materials 等) + # 同理。它们仍是可经 _execute_driver_command 调用的能力,发现新设备时必须全量补报其 + # free 锁,否则服务端永远感知不到这些动作。_execute_driver_command[_async] 由 + # _report_action_locks_free 统一过滤,不在此处特判。 already = {action_name for _, action_name in new_action_pairs} for action_name in self._action_value_mappings.get(edge_device_id, {}).keys(): - if action_name.startswith("auto-") or action_name in already: + if action_name in already: continue new_action_pairs.append((edge_device_id, action_name)) @@ -711,6 +721,8 @@ def initialize_device(self, device_id: str, device_config: ResourceDictInstance) # noinspection PyProtectedMember self._action_value_mappings[device_id] = d._ros_node._action_value_mappings new_action_pairs: List[Tuple[str, str]] = [] + # 仅为建独立 ROS ActionServer 的动作创建 ActionClient: + # auto-/UniLabJsonCommand 动作无 ROS action server,无法也无需建 ActionClient。 # noinspection PyProtectedMember for action_name, action_value_mapping in d._ros_node._action_value_mappings.items(): if action_name.startswith("auto-") or str(action_value_mapping.get("type", "")).startswith( @@ -732,6 +744,16 @@ def initialize_device(self, device_id: str, device_config: ResourceDictInstance) new_action_pairs.append((device_id, action_name)) else: self.lab_logger().warning(f"[Host Node] ActionClient {action_id} already exists.") + # 锁上报需全量:auto-/UniLabJsonCommand 动作虽不建 ActionClient,但仍是可经 + # _execute_driver_command 调用的能力(如 workbench 的 prepare_materials 等),必须一并 + # 上报 free 锁,与 report_all_action_locks 的全量快照保持一致。_execute_driver_command + # [_async] 由 _report_action_locks_free 统一过滤。 + # noinspection PyProtectedMember + already = {action_name for _, action_name in new_action_pairs} + for action_name in d._ros_node._action_value_mappings.keys(): + if action_name in already: + continue + new_action_pairs.append((device_id, action_name)) device_key = f"{self.devices_names[device_id]}/{device_id}" # 这里不涉及二级device_id # 添加到在线设备列表 self._online_devices.add(device_key) From ead2e6701e905938e3c9bc759bd9000a6859b182 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:47:23 +0800 Subject: [PATCH 13/16] fix(ws): stop reporting actions in host ready Use action lock reports as the source of callable actions so host readiness does not publish mismatched action sets. Co-authored-by: Cursor --- unilabos/app/ws_client.py | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/unilabos/app/ws_client.py b/unilabos/app/ws_client.py index 864e753fd..3aa08d707 100644 --- a/unilabos/app/ws_client.py +++ b/unilabos/app/ws_client.py @@ -1799,24 +1799,15 @@ def publish_host_ready(self) -> None: machine_name = BasicConfig.machine_name try: - # 获取设备信息 + # host_node_ready 不再上报 actions:设备可调用动作以 report_action_lock 为准 + # (来源 _action_value_mappings,覆盖 auto-/UniLabJsonCommand 等全量动作,且借 FIFO + # 在本消息之前先行发送)。host_ready 仅声明设备在线与归属,避免与锁口径不一致。 for device_id, namespace in host_node.devices_names.items(): device_key = ( f"{namespace}/{device_id}" if namespace.startswith("/") else f"/{namespace}/{device_id}" ) is_online = device_key in host_node._online_devices - # 获取设备的动作信息 - actions = {} - for action_id, client in host_node._action_clients.items(): - # action_id 格式: /namespace/device_id/action_name - if device_id in action_id: - action_name = action_id.split("/")[-1] - actions[action_name] = { - "action_path": action_id, - "action_type": str(type(client).__name__), - } - devices.append( { "device_id": device_id, @@ -1824,7 +1815,6 @@ def publish_host_ready(self) -> None: "device_key": device_key, "is_online": is_online, "machine_name": host_node.device_machine_names.get(device_id, machine_name), - "actions": actions, } ) From 0e681867d123a7725457c83649d5b57e3e124f5e Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:15:02 +0800 Subject: [PATCH 14/16] fix(resource): match sites by occupied_by only Default resources land at 000 and falsely match by position; rely on occupied_by instead. Co-authored-by: Cursor --- unilabos/ros/nodes/base_device_node.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index c5294798a..23e905d21 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -1116,9 +1116,10 @@ def _handle_update( if original_instance.name == site["occupied_by"]: site_index = idx break - elif (original_instance.location.x == site["position"]["x"] and original_instance.location.y == site["position"]["y"] and original_instance.location.z == site["position"]["z"]): - site_index = idx - break + # 默认资源会放到000,导致匹配site,后面严格按照occupied_by来匹配 + # elif (original_instance.location.x == site["position"]["x"] and original_instance.location.y == site["position"]["y"] and original_instance.location.z == site["position"]["z"]): + # site_index = idx + # break if site_index is None: site_name = None else: From 4145a0d0e5229fb7e24917ea48794cda9f8c270f Mon Sep 17 00:00:00 2001 From: Skyzuo9 <3101065459@qq.com> Date: Thu, 9 Jul 2026 13:39:05 +0800 Subject: [PATCH 15/16] =?UTF-8?q?fix(viz):=20=E8=A1=A5=E9=BD=90=E7=BC=BA?= =?UTF-8?q?=E5=A4=B1=E7=9A=84=20view=5Frobot=5Flite.rviz(b11acadb=20?= =?UTF-8?q?=E5=BC=95=E7=94=A8=E4=BD=86=E6=9C=AA=E5=88=9B=E5=BB=BA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 非 moveit 设备(STX110/PF400)的 --visual rviz 走 view_robot_lite.rviz, 但该文件从未创建 -> rviz2 -d 缺失文件回退默认配置(Fixed Frame=map/无 RobotModel)。 补一个含 Grid+TF+RobotModel(topic /robot_description)、Fixed Frame=world 的精简配置, 使单条 --visual rviz 命令即可显示模型。 Co-authored-by: Cursor --- unilabos/device_mesh/view_robot_lite.rviz | 108 ++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 unilabos/device_mesh/view_robot_lite.rviz diff --git a/unilabos/device_mesh/view_robot_lite.rviz b/unilabos/device_mesh/view_robot_lite.rviz new file mode 100644 index 000000000..04881f63a --- /dev/null +++ b/unilabos/device_mesh/view_robot_lite.rviz @@ -0,0 +1,108 @@ +Panels: + - Class: rviz_common/Displays + Help Height: 78 + Name: Displays + Property Tree Widget: + Expanded: + - /Global Options1 + - /RobotModel1 + Splitter Ratio: 0.5 + Tree Height: 617 +Visualization Manager: + Class: "" + Displays: + - Alpha: 0.5 + Cell Size: 1 + Class: rviz_default_plugins/Grid + Color: 160; 160; 164 + Enabled: true + Line Style: + Line Width: 0.029999999329447746 + Value: Lines + Name: Grid + Normal Cell Count: 0 + Offset: + X: 0 + Y: 0 + Z: 0 + Plane: XY + Plane Cell Count: 10 + Reference Frame: + Value: true + - Class: rviz_default_plugins/TF + Enabled: true + Frame Timeout: 15 + Marker Scale: 0.30000001192092896 + Name: TF + Show Arrows: true + Show Axes: true + Show Names: false + Update Interval: 0 + Value: true + - Alpha: 1 + Class: rviz_default_plugins/RobotModel + Collision Enabled: false + Description Source: Topic + Description Topic: + Depth: 5 + Durability Policy: Volatile + History Policy: Keep Last + Reliability Policy: Reliable + Value: /robot_description + Enabled: true + Mass Properties: + Inertia: false + Mass: false + Name: RobotModel + TF Prefix: "" + Update Interval: 0 + Value: true + Visual Enabled: true + Enabled: true + Global Options: + Background Color: 48; 48; 48 + Fixed Frame: world + Frame Rate: 30 + Name: root + Tools: + - Class: rviz_default_plugins/MoveCamera + - Class: rviz_default_plugins/Select + - Class: rviz_default_plugins/FocusCamera + - Class: rviz_default_plugins/Measure + Line color: 128; 128; 0 + Transformation: + Current: + Class: rviz_default_plugins/TF + Value: true + Views: + Current: + Class: rviz_default_plugins/Orbit + Distance: 3 + Enable Stereo Rendering: + Stereo Eye Separation: 0.05999999865889549 + Stereo Focal Distance: 1 + Swap Stereo Eyes: false + Value: false + Focal Point: + X: 0 + Y: 0 + Z: 0.30000001192092896 + Focal Shape Fixed Size: true + Focal Shape Size: 0.05000000074505806 + Invert Z Axis: false + Name: Current View + Near Clip Distance: 0.009999999776482582 + Pitch: 0.4000000059604645 + Target Frame: + Value: Orbit (rviz) + Yaw: 0.7853981852531433 + Saved: ~ +Window Geometry: + Displays: + collapsed: false + Height: 846 + Hide Left Dock: false + Hide Right Dock: false + Width: 1200 + X: 60 + Y: 60 From 134f42e7f62686243bdeaca3243346e826306024 Mon Sep 17 00:00:00 2001 From: Skyzuo9 <3101065459@qq.com> Date: Thu, 9 Jul 2026 14:27:52 +0800 Subject: [PATCH 16/16] =?UTF-8?q?fix(registry):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E7=BC=BA=E5=A4=B1=E7=9A=84=20community=5Falias=20=E6=A8=A1?= =?UTF-8?q?=E5=9D=97(Plan09=20Task7=20dangling=20import)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit initialize_device._lookup_registry_class 在 class 不在注册表时 import unilabos.registry.community_alias.normalize_community_class,但该模块从未创建 (lab_2/dev 均无)-> 社区/别名设备(如 community.pylabrobot_unilab.liconic_stx110 经短别名 liconic_stx110 引用)初始化时 ModuleNotFoundError 崩溃。 补上双向归一化(短别名<->community..)。 Co-authored-by: Cursor --- unilabos/registry/community_alias.py | 52 ++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 unilabos/registry/community_alias.py diff --git a/unilabos/registry/community_alias.py b/unilabos/registry/community_alias.py new file mode 100644 index 000000000..0488e8034 --- /dev/null +++ b/unilabos/registry/community_alias.py @@ -0,0 +1,52 @@ +"""社区设备 class 名归一化(Plan 09 Task 7)。 + +社区包设备在扫描期被命名空间化为 ``community..`` 作为注册表实体 key +(见 registry.setup 的 community_namespaces 处理)。但 graph / 云端图里可能引用**短别名** +(如 ``liconic_stx110``)或反过来引用带前缀的全名。本模块把二者互相归一到注册表里实际存在的 key。 + +``initialize_device._lookup_registry_class`` 在 class_name 不在注册表时调用本函数: +若返回值命中注册表则采用,否则报 DeviceClassInvalid。 +""" + +from __future__ import annotations + +from typing import Optional + + +def normalize_community_class(class_name: str, registry=None) -> str: + """把社区 class 名归一化为注册表中实际存在的 key。 + + 双向处理: + - 短别名 ```` -> 唯一的 ``community..``(若注册表里有且仅有一个匹配); + - 全名 ``community..`` -> 短 ````(若短名已在注册表)。 + + 找不到唯一匹配时原样返回(交由调用方判定 DeviceClassInvalid)。 + """ + if not class_name: + return class_name + if registry is None: + try: + from unilabos.registry.registry import lab_registry as registry + except Exception: + return class_name + try: + reg = registry.device_type_registry + except Exception: + return class_name + + if class_name in reg: + return class_name + + # 全名 community.. -> 短名 + if class_name.startswith("community."): + short = class_name.rsplit(".", 1)[-1] + if short in reg: + return short + + # 短别名 -> 唯一 community.. + suffix = f".{class_name}" + candidates = [k for k in reg if k.startswith("community.") and k.endswith(suffix)] + if len(candidates) == 1: + return candidates[0] + + return class_name