Merge remote-tracking branch 'origin/dev' into feature/organic-extraction

# Conflicts:
#	.cursor/skills/add-workstation/SKILL.md
#	.cursor/skills/add-workstation/reference.md
This commit is contained in:
ZiWei
2026-03-27 11:49:30 +08:00
17 changed files with 2048 additions and 812 deletions

View File

@@ -754,6 +754,32 @@ class MessageProcessor:
req = JobAddReq(**data)
job_log = format_job_log(req.job_id, req.task_id, req.device_id, req.action)
# 服务端对always_free动作可能跳过query_action_state直接发job_start
# 此时job尚未注册需要自动补注册
existing_job = self.device_manager.get_job_info(req.job_id)
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,
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)
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
success = self.device_manager.start_job(req.job_id)
if not success:
logger.error(f"[MessageProcessor] Failed to start job {job_log}")

View File

@@ -57,7 +57,7 @@ class VirtualSampleDemo:
readings.append(round(random.uniform(0.1, 1.0), 4))
samples.append(idx)
return {"volumes": out_volumes, "readings": readings, "samples": samples}
return {"volumes": out_volumes, "readings": readings, "unilabos_samples": samples}
# ------------------------------------------------------------------
# Action 3: 入参和出参都带 samples 列(不等长)
@@ -78,7 +78,7 @@ class VirtualSampleDemo:
scores.append(score)
passed.append(r >= threshold)
return {"scores": scores, "passed": passed, "samples": samples}
return {"scores": scores, "passed": passed, "unilabos_samples": samples}
# ------------------------------------------------------------------
# 状态属性

View File

@@ -679,14 +679,17 @@ def _resolve_name(name: str, import_map: Dict[str, str]) -> str:
return name
_DECORATOR_ENUM_CLASSES = frozenset({"Side", "DataSource", "NodeType"})
def _resolve_attribute(node: ast.Attribute, import_map: Dict[str, str]) -> str:
"""
Resolve an attribute access like Side.NORTH or DataSource.HANDLE.
Returns a string like "NORTH" for enum values, or
"module.path:Class.attr" for imported references.
对于来自 ``unilabos.registry.decorators`` 的枚举类 (Side / DataSource / NodeType)
直接返回枚举成员名 (如 ``"NORTH"`` / ``"HANDLE"`` / ``"MANUAL_CONFIRM"``)
省去消费端二次 rsplit 解析。其它 import 仍返回完整模块路径。
"""
# Get the full dotted path
parts = []
current = node
while isinstance(current, ast.Attribute):
@@ -696,21 +699,20 @@ def _resolve_attribute(node: ast.Attribute, import_map: Dict[str, str]) -> str:
parts.append(current.id)
parts.reverse()
# parts = ["Side", "NORTH"] or ["DataSource", "HANDLE"]
# parts = ["Side", "NORTH"] or ["DataSource", "HANDLE"] or ["NodeType", "MANUAL_CONFIRM"]
if len(parts) >= 2:
base = parts[0]
attr = ".".join(parts[1:])
# If the base is an imported name, resolve it
if base in _DECORATOR_ENUM_CLASSES:
source = import_map.get(base, "")
if not source or _REGISTRY_DECORATOR_MODULE in source:
return parts[-1]
if base in import_map:
return f"{import_map[base]}.{attr}"
# For known enum-like patterns, return just the value
# e.g. Side.NORTH -> "NORTH"
if base in ("Side", "DataSource"):
return parts[-1]
return ".".join(parts)

View File

@@ -8,7 +8,7 @@ Usage:
device, action, resource,
InputHandle, OutputHandle,
ActionInputHandle, ActionOutputHandle,
HardwareInterface, Side, DataSource,
HardwareInterface, Side, DataSource, NodeType,
)
@device(
@@ -73,6 +73,13 @@ class DataSource(str, Enum):
EXECUTOR = "executor" # 从执行器输出数据 (用于 OutputHandle)
class NodeType(str, Enum):
"""动作的节点类型(用于区分 ILab 节点和人工确认节点等)"""
ILAB = "ILab"
MANUAL_CONFIRM = "manual_confirm"
# ---------------------------------------------------------------------------
# Device / Resource Handle (设备/资源级别端口, 序列化时包含 io_type)
# ---------------------------------------------------------------------------
@@ -335,6 +342,7 @@ def action(
description: str = "",
auto_prefix: bool = False,
parent: bool = False,
node_type: Optional["NodeType"] = None,
):
"""
动作方法装饰器
@@ -365,6 +373,8 @@ def action(
description: 动作描述
auto_prefix: 若为 True动作名使用 auto-{method_name} 形式(与无 @action 时一致)
parent: 若为 True当方法参数为空 (*args, **kwargs) 时,通过 MRO 从父类获取真实方法参数
node_type: 动作的节点类型 (NodeType.ILAB / NodeType.MANUAL_CONFIRM)。
不填写时不写入注册表。
"""
def decorator(func: F) -> F:
@@ -389,6 +399,8 @@ def action(
"auto_prefix": auto_prefix,
"parent": parent,
}
if node_type is not None:
meta["node_type"] = node_type.value if isinstance(node_type, NodeType) else str(node_type)
wrapper._action_registry_meta = meta # type: ignore[attr-defined]
# 设置 _is_always_free 保持与旧 @always_free 装饰器兼容
@@ -515,6 +527,38 @@ def clear_registry():
_registered_resources.clear()
# ---------------------------------------------------------------------------
# 枚举值归一化
# ---------------------------------------------------------------------------
def normalize_enum_value(raw: Any, enum_cls) -> Optional[str]:
"""将 AST 提取的枚举成员名 / YAML 值字符串 / 旧格式长路径统一归一化为枚举值。
适用于 Side、DataSource、NodeType 等继承自 ``str, Enum`` 的装饰器枚举。
处理以下格式:
- "MANUAL_CONFIRM" → NodeType["MANUAL_CONFIRM"].value = "manual_confirm"
- "manual_confirm" → NodeType("manual_confirm").value = "manual_confirm"
- "HANDLE" → DataSource["HANDLE"].value = "handle"
- "NORTH" → Side["NORTH"].value = "NORTH"
- 旧缓存长路径 "unilabos...NodeType.MANUAL_CONFIRM" → 先 rsplit 再查找
"""
if not raw:
return None
raw_str = str(raw)
if "." in raw_str:
raw_str = raw_str.rsplit(".", 1)[-1]
try:
return enum_cls[raw_str].value
except KeyError:
pass
try:
return enum_cls(raw_str).value
except ValueError:
return raw_str
# ---------------------------------------------------------------------------
# topic_config / not_action / always_free 装饰器
# ---------------------------------------------------------------------------

View File

@@ -2815,8 +2815,8 @@ virtual_sample_demo:
readings: readings
samples: samples
goal_default:
readings: []
samples: []
readings: null
samples: null
handles:
input:
- data_key: readings
@@ -2846,18 +2846,12 @@ virtual_sample_demo:
handler_key: samples_result_out
label: 样品索引
placeholder_keys: {}
result:
passed: passed
samples: samples
scores: scores
result: {}
schema:
description: 对 split_and_measure 输出做二次分析,入参和出参都带 samples 列
properties:
feedback:
properties: {}
required: []
title: AnalyzeReadings_Feedback
type: object
goal:
properties:
readings:
@@ -2876,52 +2870,11 @@ virtual_sample_demo:
title: AnalyzeReadings_Goal
type: object
result:
properties:
passed:
description: 是否通过阈值
items:
type: boolean
type: array
samples:
description: 每行归属的输入样品 index (0-based)
items:
type: integer
type: array
scores:
description: 分析得分
items:
type: number
type: array
required:
- scores
- passed
- samples
title: AnalyzeReadings_Result
type: object
required:
- goal
title: AnalyzeReadings
type: object
type: UniLabJsonCommandAsync
auto-cleanup:
feedback: {}
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: cleanup的参数schema
properties:
feedback: {}
goal:
properties: {}
required: []
type: object
result: {}
required:
- goal
title: cleanup参数
title: analyze_readings参数
type: object
type: UniLabJsonCommandAsync
measure_samples:
@@ -2929,7 +2882,7 @@ virtual_sample_demo:
goal:
concentrations: concentrations
goal_default:
concentrations: []
concentrations: null
handles:
output:
- data_key: concentrations
@@ -2943,17 +2896,12 @@ virtual_sample_demo:
handler_key: absorbance_out
label: 吸光度列表
placeholder_keys: {}
result:
absorbance: absorbance
concentrations: concentrations
result: {}
schema:
description: 模拟光度测量,入参出参等长
properties:
feedback:
properties: {}
required: []
title: MeasureSamples_Feedback
type: object
goal:
properties:
concentrations:
@@ -2966,25 +2914,11 @@ virtual_sample_demo:
title: MeasureSamples_Goal
type: object
result:
properties:
absorbance:
description: 吸光度列表(与浓度等长)
items:
type: number
type: array
concentrations:
description: 原始浓度列表
items:
type: number
type: array
required:
- concentrations
- absorbance
title: MeasureSamples_Result
type: object
required:
- goal
title: MeasureSamples
title: measure_samples参数
type: object
type: UniLabJsonCommandAsync
split_and_measure:
@@ -2994,7 +2928,7 @@ virtual_sample_demo:
volumes: volumes
goal_default:
split_count: 3
volumes: []
volumes: null
handles:
output:
- data_key: readings
@@ -3013,21 +2947,16 @@ virtual_sample_demo:
handler_key: volumes_out
label: 均分体积
placeholder_keys: {}
result:
readings: readings
samples: samples
volumes: volumes
result: {}
schema:
description: 均分样品后逐份测量,输出带 samples 列标注归属
properties:
feedback:
properties: {}
required: []
title: SplitAndMeasure_Feedback
type: object
goal:
properties:
split_count:
default: 3
description: 每个样品均分的份数
type: integer
volumes:
@@ -3040,31 +2969,11 @@ virtual_sample_demo:
title: SplitAndMeasure_Goal
type: object
result:
properties:
readings:
description: 测量读数
items:
type: number
type: array
samples:
description: 每行归属的输入样品 index (0-based)
items:
type: integer
type: array
volumes:
description: 均分后的体积列表
items:
type: number
type: array
required:
- volumes
- readings
- samples
title: SplitAndMeasure_Result
type: object
required:
- goal
title: SplitAndMeasure
title: split_and_measure参数
type: object
type: UniLabJsonCommandAsync
module: unilabos.devices.virtual.virtual_sample_demo:VirtualSampleDemo
@@ -3079,7 +2988,7 @@ virtual_sample_demo:
config:
properties:
config:
type: string
type: object
device_id:
type: string
required: []

View File

@@ -33,6 +33,8 @@ from unilabos.registry.decorators import (
is_not_action,
is_always_free,
get_topic_config,
NodeType,
normalize_enum_value,
)
from unilabos.registry.utils import (
ROSMsgNotFound,
@@ -159,9 +161,10 @@ class Registry:
ast_entry = self.device_type_registry.get("host_node", {})
ast_actions = ast_entry.get("class", {}).get("action_value_mappings", {})
# 取出 AST 生成的 auto-method entries, 补充特定覆写
# 取出 AST 生成的 action entries, 补充特定覆写
test_latency_action = ast_actions.get("auto-test_latency", {})
test_resource_action = ast_actions.get("auto-test_resource", {})
manual_confirm_action = ast_actions.get("manual_confirm", {})
test_resource_action["handles"] = {
"input": [
{
@@ -234,9 +237,11 @@ class Registry:
"parent": "unilabos_nodes",
"class_name": "unilabos_class",
},
"always_free": True,
},
"test_latency": test_latency_action,
"auto-test_resource": test_resource_action,
"manual_confirm": manual_confirm_action,
},
"init_params": {},
},
@@ -847,6 +852,9 @@ class Registry:
}
if (action_args or {}).get("always_free") or method_info.get("always_free"):
entry["always_free"] = True
nt = normalize_enum_value((action_args or {}).get("node_type"), NodeType)
if nt:
entry["node_type"] = nt
return action_name, entry
# 1) auto- actions
@@ -971,6 +979,9 @@ class Registry:
}
if action_args.get("always_free") or method_info.get("always_free"):
action_entry["always_free"] = True
nt = normalize_enum_value(action_args.get("node_type"), NodeType)
if nt:
action_entry["node_type"] = nt
action_value_mappings[action_name] = action_entry
action_value_mappings = dict(sorted(action_value_mappings.items()))
@@ -1153,7 +1164,7 @@ class Registry:
return Path(BasicConfig.working_dir) / "registry_cache.pkl"
return None
_CACHE_VERSION = 3
_CACHE_VERSION = 4
def _load_config_cache(self) -> dict:
import pickle
@@ -1878,6 +1889,9 @@ class Registry:
}
if v.get("always_free"):
entry["always_free"] = True
old_node_type = old_cfg.get("node_type")
if old_node_type in [NodeType.ILAB.value, NodeType.MANUAL_CONFIRM.value]:
entry["node_type"] = old_node_type
device_config["class"]["action_value_mappings"][action_key] = entry
device_config["init_param_schema"] = {}

View File

@@ -17,6 +17,7 @@ from typing import Any, Dict, List, Optional, Tuple, Union
from msgcenterpy.instances.typed_dict_instance import TypedDictMessageInstance
from unilabos.utils.cls_creator import import_class
from unilabos.registry.decorators import Side, DataSource, normalize_enum_value
_logger = logging.getLogger(__name__)
@@ -487,10 +488,7 @@ def normalize_ast_handles(handles_raw: Any) -> List[Dict[str, Any]]:
}
side = h.get("side")
if side:
if isinstance(side, str) and "." in side:
val = side.rsplit(".", 1)[-1]
side = val.lower() if val in ("LEFT", "RIGHT", "TOP", "BOTTOM") else val
entry["side"] = side
entry["side"] = normalize_enum_value(side, Side) or side
label = h.get("label")
if label:
entry["label"] = label
@@ -499,10 +497,7 @@ def normalize_ast_handles(handles_raw: Any) -> List[Dict[str, Any]]:
entry["data_key"] = data_key
data_source = h.get("data_source")
if data_source:
if isinstance(data_source, str) and "." in data_source:
val = data_source.rsplit(".", 1)[-1]
data_source = val.lower() if val in ("HANDLE", "EXECUTOR") else val
entry["data_source"] = data_source
entry["data_source"] = normalize_enum_value(data_source, DataSource) or data_source
description = h.get("description")
if description:
entry["description"] = description
@@ -537,17 +532,12 @@ def normalize_ast_action_handles(handles_raw: Any) -> Dict[str, Any]:
"data_type": h.get("data_type", ""),
"label": h.get("label", ""),
}
_FIELD_ENUM_MAP = {"side": Side, "data_source": DataSource}
for opt_key in ("side", "data_key", "data_source", "description", "io_type"):
val = h.get(opt_key)
if val is not None:
# Only resolve enum-style refs (e.g. DataSource.HANDLE -> handle) for data_source/side
# data_key values like "wells.@flatten", "@this.0@@@plate" must be preserved as-is
if (
isinstance(val, str)
and "." in val
and opt_key not in ("io_type", "data_key")
):
val = val.rsplit(".", 1)[-1].lower()
if opt_key in _FIELD_ENUM_MAP:
val = normalize_enum_value(val, _FIELD_ENUM_MAP[opt_key]) or val
entry[opt_key] = val
# io_type: only add when explicitly set; do not default output to "sink" (YAML convention omits it)

View File

@@ -24,7 +24,7 @@ from unilabos_msgs.srv import (
from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response
from unique_identifier_msgs.msg import UUID
from unilabos.registry.decorators import device
from unilabos.registry.decorators import device, action, NodeType
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
from unilabos.registry.registry import lab_registry
from unilabos.resources.container import RegularContainer
@@ -313,7 +313,9 @@ class HostNode(BaseROS2DeviceNode):
callback_group=self.callback_group,
),
} # 用来存储多个ActionClient实例
self._action_value_mappings: Dict[str, Dict] = {} # device_id -> action_value_mappings(本地+远程设备统一存储)
self._action_value_mappings: Dict[str, Dict] = {
device_id: self._action_value_mappings
} # device_id -> action_value_mappings(本地+远程设备统一存储)
self._slave_registry_configs: Dict[str, Dict] = {} # registry_name -> registry_config(含action_value_mappings)
self._goals: Dict[str, Any] = {} # 用来存储多个目标的状态
self._online_devices: Set[str] = {f"{self.namespace}/{device_id}"} # 用于跟踪在线设备
@@ -1621,6 +1623,18 @@ class HostNode(BaseROS2DeviceNode):
}
return res
@action(always_free=True, node_type=NodeType.MANUAL_CONFIRM, placeholder_keys={
"assignee_user_ids": "unilabos_manual_confirm"
}, goal_default={
"timeout_seconds": 3600,
"assignee_user_ids": []
})
def manual_confirm(self, timeout_seconds: int, assignee_user_ids: list[str], **kwargs) -> dict:
"""
timeout_seconds: 超时时间默认3600秒
"""
return kwargs
def test_resource(
self,
sample_uuids: SampleUUIDsType,

View File

@@ -80,11 +80,12 @@ def get_result_info_str(error: str, suc: bool, return_value=None) -> str:
Returns:
JSON字符串格式的结果信息
"""
samples = None
if isinstance(return_value, dict):
if "samples" in return_value and type(return_value["samples"]) in [list, tuple] and type(return_value["samples"][0]) == dict:
samples = return_value.pop("samples")
result_info = {"error": error, "suc": suc, "return_value": return_value, "samples": samples}
# 请在返回的字典中使用 unilabos_samples进行返回
# samples = None
# if isinstance(return_value, dict):
# if "samples" in return_value and type(return_value["samples"]) in [list, tuple] and type(return_value["samples"][0]) == dict:
# samples = return_value.pop("samples")
result_info = {"error": error, "suc": suc, "return_value": return_value}
return json.dumps(result_info, ensure_ascii=False, cls=ResultInfoEncoder)