Skip to content

Commit b551e69

Browse files
committed
no opcua installation on macos
fix possible crash fix deck & host_node set liquid with tube add test_resource_schema fix test resource schema registry update & workflow update add test mode support description & tags upload fix config load fix log add registry name & add always free correct config organic synthesis Adapt to new scheduler, sampels, and edge upload format (#230) * add sample_material * adapt to new samples sys * fix pump transfer. fix resource update when protocol & ros callback * Adapt to new scheduler. Feat/samples (#229) * add sample_material * adapt to new samples sys adapt to new samples sys adapt to new edge format workflow upload & prcxi transfer liquid lh liquid speed up registry load workflow upload & set liquid fix & add set liquid with plate fix upload workflow json
1 parent 5179a7e commit b551e69

41 files changed

Lines changed: 2668 additions & 744 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.conda/base/recipe.yaml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ requirements:
4646
- jinja2
4747
- requests
4848
- uvicorn
49-
- opcua
49+
- if: not osx
50+
then:
51+
- opcua
5052
- pyserial
5153
- pandas
5254
- pymodbus

docs/user_guide/best_practice.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -452,8 +452,9 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
452452
**操作步骤:**
453453

454454
1. 将两个 `container` 拖拽到 `workstation`
455-
2.`virtual_transfer_pump` 拖拽到 `workstation`
456-
3. 在画布上连接它们(建立父子关系)
455+
2.`virtual_multiway_valve` 拖拽到 `workstation`
456+
3.`virtual_transfer_pump` 拖拽到 `workstation`
457+
4. 在画布上连接它们(建立父子关系)
457458

458459
![设备连接](image/links.png)
459460

docs/user_guide/image/links.png

140 KB
Loading

unilabos/app/main.py

Lines changed: 66 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,12 @@ def parse_args():
171171
action="store_true",
172172
help="Disable sending update feedback to server",
173173
)
174+
parser.add_argument(
175+
"--test_mode",
176+
action="store_true",
177+
default=False,
178+
help="Test mode: all actions simulate execution and return mock results without running real hardware",
179+
)
174180
# workflow upload subcommand
175181
workflow_parser = subparsers.add_parser(
176182
"workflow_upload",
@@ -204,6 +210,12 @@ def parse_args():
204210
default=False,
205211
help="Whether to publish the workflow (default: False)",
206212
)
213+
workflow_parser.add_argument(
214+
"--description",
215+
type=str,
216+
default="",
217+
help="Workflow description, used when publishing the workflow",
218+
)
207219
return parser
208220

209221

@@ -231,52 +243,60 @@ def main():
231243
# 加载配置文件,优先加载config,然后从env读取
232244
config_path = args_dict.get("config")
233245

234-
if check_mode:
235-
args_dict["working_dir"] = os.path.abspath(os.getcwd())
236-
# 当 skip_env_check 时,默认使用当前目录作为 working_dir
237-
if skip_env_check and not args_dict.get("working_dir") and not config_path:
246+
# === 解析 working_dir ===
247+
# 规则1: working_dir 传入 → 检测 unilabos_data 子目录,已是则不修改
248+
# 规则2: 仅 config_path 传入 → 用其父目录作为 working_dir
249+
# 规则4: 两者都传入 → 各用各的,但 working_dir 仍做 unilabos_data 子目录检测
250+
raw_working_dir = args_dict.get("working_dir")
251+
if raw_working_dir:
252+
working_dir = os.path.abspath(raw_working_dir)
253+
elif config_path and os.path.exists(config_path):
254+
working_dir = os.path.dirname(os.path.abspath(config_path))
255+
else:
238256
working_dir = os.path.abspath(os.getcwd())
239-
print_status(f"跳过环境检查模式:使用当前目录作为工作目录 {working_dir}", "info")
240-
# 检查当前目录是否有 local_config.py
241-
local_config_in_cwd = os.path.join(working_dir, "local_config.py")
242-
if os.path.exists(local_config_in_cwd):
243-
config_path = local_config_in_cwd
257+
258+
# unilabos_data 子目录自动检测
259+
if os.path.basename(working_dir) != "unilabos_data":
260+
unilabos_data_sub = os.path.join(working_dir, "unilabos_data")
261+
if os.path.isdir(unilabos_data_sub):
262+
working_dir = unilabos_data_sub
263+
elif not raw_working_dir and not (config_path and os.path.exists(config_path)):
264+
# 未显式指定路径,默认使用 cwd/unilabos_data
265+
working_dir = os.path.abspath(os.path.join(os.getcwd(), "unilabos_data"))
266+
267+
# === 解析 config_path ===
268+
if config_path and not os.path.exists(config_path):
269+
# config_path 传入但不存在,尝试在 working_dir 中查找
270+
candidate = os.path.join(working_dir, "local_config.py")
271+
if os.path.exists(candidate):
272+
config_path = candidate
273+
print_status(f"在工作目录中发现配置文件: {config_path}", "info")
274+
else:
275+
print_status(
276+
f"配置文件 {config_path} 不存在,工作目录 {working_dir} 中也未找到 local_config.py,"
277+
f"请通过 --config 传入 local_config.py 文件路径",
278+
"error",
279+
)
280+
os._exit(1)
281+
elif not config_path:
282+
# 规则3: 未传入 config_path,尝试 working_dir/local_config.py
283+
candidate = os.path.join(working_dir, "local_config.py")
284+
if os.path.exists(candidate):
285+
config_path = candidate
244286
print_status(f"发现本地配置文件: {config_path}", "info")
245287
else:
246288
print_status(f"未指定config路径,可通过 --config 传入 local_config.py 文件路径", "info")
247-
elif os.getcwd().endswith("unilabos_data"):
248-
working_dir = os.path.abspath(os.getcwd())
249-
else:
250-
working_dir = os.path.abspath(os.path.join(os.getcwd(), "unilabos_data"))
251-
252-
if args_dict.get("working_dir"):
253-
working_dir = args_dict.get("working_dir", "")
254-
if config_path and not os.path.exists(config_path):
255-
config_path = os.path.join(working_dir, "local_config.py")
256-
if not os.path.exists(config_path):
257-
print_status(
258-
f"当前工作目录 {working_dir} 未找到local_config.py,请通过 --config 传入 local_config.py 文件路径",
259-
"error",
289+
print_status(f"您是否为第一次使用?并将当前路径 {working_dir} 作为工作目录? (Y/n)", "info")
290+
if check_mode or input() != "n":
291+
os.makedirs(working_dir, exist_ok=True)
292+
config_path = os.path.join(working_dir, "local_config.py")
293+
shutil.copy(
294+
os.path.join(os.path.dirname(os.path.dirname(__file__)), "config", "example_config.py"),
295+
config_path,
260296
)
297+
print_status(f"已创建 local_config.py 路径: {config_path}", "info")
298+
else:
261299
os._exit(1)
262-
elif config_path and os.path.exists(config_path):
263-
working_dir = os.path.dirname(config_path)
264-
elif os.path.exists(working_dir) and os.path.exists(os.path.join(working_dir, "local_config.py")):
265-
config_path = os.path.join(working_dir, "local_config.py")
266-
elif not skip_env_check and not config_path and (
267-
not os.path.exists(working_dir) or not os.path.exists(os.path.join(working_dir, "local_config.py"))
268-
):
269-
print_status(f"未指定config路径,可通过 --config 传入 local_config.py 文件路径", "info")
270-
print_status(f"您是否为第一次使用?并将当前路径 {working_dir} 作为工作目录? (Y/n)", "info")
271-
if input() != "n":
272-
os.makedirs(working_dir, exist_ok=True)
273-
config_path = os.path.join(working_dir, "local_config.py")
274-
shutil.copy(
275-
os.path.join(os.path.dirname(os.path.dirname(__file__)), "config", "example_config.py"), config_path
276-
)
277-
print_status(f"已创建 local_config.py 路径: {config_path}", "info")
278-
else:
279-
os._exit(1)
280300

281301
# 加载配置文件 (check_mode 跳过)
282302
print_status(f"当前工作目录为 {working_dir}", "info")
@@ -288,7 +308,9 @@ def main():
288308

289309
if hasattr(BasicConfig, "log_level"):
290310
logger.info(f"Log level set to '{BasicConfig.log_level}' from config file.")
291-
configure_logger(loglevel=BasicConfig.log_level, working_dir=working_dir)
311+
file_path = configure_logger(loglevel=BasicConfig.log_level, working_dir=working_dir)
312+
if file_path is not None:
313+
logger.info(f"[LOG_FILE] {file_path}")
292314

293315
if args.addr != parser.get_default("addr"):
294316
if args.addr == "test":
@@ -332,6 +354,9 @@ def main():
332354
BasicConfig.slave_no_host = args_dict.get("slave_no_host", False)
333355
BasicConfig.upload_registry = args_dict.get("upload_registry", False)
334356
BasicConfig.no_update_feedback = args_dict.get("no_update_feedback", False)
357+
BasicConfig.test_mode = args_dict.get("test_mode", False)
358+
if BasicConfig.test_mode:
359+
print_status("启用测试模式:所有动作将模拟执行,不调用真实硬件", "warning")
335360
BasicConfig.communication_protocol = "websocket"
336361
machine_name = os.popen("hostname").read().strip()
337362
machine_name = "".join([c if c.isalnum() or c == "_" else "_" for c in machine_name])

unilabos/app/model.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ class JobAddReq(BaseModel):
5454
action_type: str = Field(
5555
examples=["unilabos_msgs.action._str_single_input.StrSingleInput"], description="action type", default=""
5656
)
57+
sample_material: dict = Field(examples=[{"string": "string"}], description="sample uuid to material uuid")
5758
action_args: dict = Field(examples=[{"string": "string"}], description="action arguments", default_factory=dict)
5859
task_id: str = Field(examples=["task_id"], description="task uuid (auto-generated if empty)", default="")
5960
job_id: str = Field(examples=["job_id"], description="goal uuid (auto-generated if empty)", default="")

unilabos/app/register.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,9 @@ def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[
3838
response = http_client.resource_registry({"resources": list(devices_to_register.values())})
3939
cost_time = time.time() - start_time
4040
if response.status_code in [200, 201]:
41-
logger.info(f"[UniLab Register] 成功注册 {len(devices_to_register)} 个设备 {cost_time}ms")
41+
logger.info(f"[UniLab Register] 成功注册 {len(devices_to_register)} 个设备 {cost_time}s")
4242
else:
43-
logger.error(f"[UniLab Register] 设备注册失败: {response.status_code}, {response.text} {cost_time}ms")
43+
logger.error(f"[UniLab Register] 设备注册失败: {response.status_code}, {response.text} {cost_time}s")
4444
except Exception as e:
4545
logger.error(f"[UniLab Register] 设备注册异常: {e}")
4646

@@ -51,9 +51,9 @@ def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[
5151
response = http_client.resource_registry({"resources": list(resources_to_register.values())})
5252
cost_time = time.time() - start_time
5353
if response.status_code in [200, 201]:
54-
logger.info(f"[UniLab Register] 成功注册 {len(resources_to_register)} 个资源 {cost_time}ms")
54+
logger.info(f"[UniLab Register] 成功注册 {len(resources_to_register)} 个资源 {cost_time}s")
5555
else:
56-
logger.error(f"[UniLab Register] 资源注册失败: {response.status_code}, {response.text} {cost_time}ms")
56+
logger.error(f"[UniLab Register] 资源注册失败: {response.status_code}, {response.text} {cost_time}s")
5757
except Exception as e:
5858
logger.error(f"[UniLab Register] 资源注册异常: {e}")
5959

unilabos/app/web/client.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -343,9 +343,10 @@ def workflow_import(
343343
edges: List[Dict[str, Any]],
344344
tags: Optional[List[str]] = None,
345345
published: bool = False,
346+
description: str = "",
346347
) -> Dict[str, Any]:
347348
"""
348-
导入工作流到服务器
349+
导入工作流到服务器,如果 published 为 True,则额外发起发布请求
349350
350351
Args:
351352
name: 工作流名称(顶层)
@@ -355,6 +356,7 @@ def workflow_import(
355356
edges: 工作流边列表
356357
tags: 工作流标签列表,默认为空列表
357358
published: 是否发布工作流,默认为False
359+
description: 工作流描述,发布时使用
358360
359361
Returns:
360362
Dict: API响应数据,包含 code 和 data (uuid, name)
@@ -367,7 +369,6 @@ def workflow_import(
367369
"nodes": nodes,
368370
"edges": edges,
369371
"tags": tags if tags is not None else [],
370-
"published": published,
371372
},
372373
}
373374
# 保存请求到文件
@@ -388,11 +389,51 @@ def workflow_import(
388389
res = response.json()
389390
if "code" in res and res["code"] != 0:
390391
logger.error(f"导入工作流失败: {response.text}")
392+
return res
393+
# 导入成功后,如果需要发布则额外发起发布请求
394+
if published:
395+
imported_uuid = res.get("data", {}).get("uuid", workflow_uuid)
396+
publish_res = self.workflow_publish(imported_uuid, description)
397+
res["publish_result"] = publish_res
391398
return res
392399
else:
393400
logger.error(f"导入工作流失败: {response.status_code}, {response.text}")
394401
return {"code": response.status_code, "message": response.text}
395402

403+
def workflow_publish(self, workflow_uuid: str, description: str = "") -> Dict[str, Any]:
404+
"""
405+
发布工作流
406+
407+
Args:
408+
workflow_uuid: 工作流UUID
409+
description: 工作流描述
410+
411+
Returns:
412+
Dict: API响应数据
413+
"""
414+
payload = {
415+
"uuid": workflow_uuid,
416+
"description": description,
417+
"published": True,
418+
}
419+
logger.info(f"正在发布工作流: {workflow_uuid}")
420+
response = requests.patch(
421+
f"{self.remote_addr}/lab/workflow/owner",
422+
json=payload,
423+
headers={"Authorization": f"Lab {self.auth}"},
424+
timeout=60,
425+
)
426+
if response.status_code == 200:
427+
res = response.json()
428+
if "code" in res and res["code"] != 0:
429+
logger.error(f"发布工作流失败: {response.text}")
430+
else:
431+
logger.info(f"工作流发布成功: {workflow_uuid}")
432+
return res
433+
else:
434+
logger.error(f"发布工作流失败: {response.status_code}, {response.text}")
435+
return {"code": response.status_code, "message": response.text}
436+
396437

397438
# 创建默认客户端实例
398439
http_client = HTTPClient()

unilabos/app/web/controller.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,7 @@ def job_add(req: JobAddReq) -> JobData:
327327
queue_item,
328328
action_type=action_type,
329329
action_kwargs=action_args,
330+
sample_material=req.sample_material,
330331
server_info=server_info,
331332
)
332333

0 commit comments

Comments
 (0)