mirror of
https://github.com/dptech-corp/Uni-Lab-OS.git
synced 2026-02-06 15:05:13 +00:00
修复可能的web template找不到的问题
新增联网获取json启动 删除非-g传入启动json的方式 兼容传参参数名短横线与下划线
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
recursive-include unilabos/registry *.yaml
|
recursive-include unilabos/registry *.yaml
|
||||||
recursive-include unilabos/app/web *.html
|
recursive-include unilabos/app/static *
|
||||||
recursive-include unilabos/app/web *.css
|
recursive-include unilabos/app/templates *
|
||||||
recursive-include unilabos/device_mesh/devices *
|
recursive-include unilabos/device_mesh/devices *
|
||||||
recursive-include unilabos/device_mesh/resources *
|
recursive-include unilabos/device_mesh/resources *
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
@@ -10,7 +9,7 @@ from copy import deepcopy
|
|||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from unilabos.resources.graphio import tree_to_list, modify_to_backend_format
|
from unilabos.resources.graphio import modify_to_backend_format
|
||||||
|
|
||||||
# 首先添加项目根目录到路径
|
# 首先添加项目根目录到路径
|
||||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
@@ -18,7 +17,7 @@ unilabos_dir = os.path.dirname(os.path.dirname(current_dir))
|
|||||||
if unilabos_dir not in sys.path:
|
if unilabos_dir not in sys.path:
|
||||||
sys.path.append(unilabos_dir)
|
sys.path.append(unilabos_dir)
|
||||||
|
|
||||||
from unilabos.config.config import load_config, BasicConfig, _update_config_from_env
|
from unilabos.config.config import load_config, BasicConfig
|
||||||
from unilabos.utils.banner_print import print_status, print_unilab_banner
|
from unilabos.utils.banner_print import print_status, print_unilab_banner
|
||||||
|
|
||||||
|
|
||||||
@@ -37,12 +36,22 @@ def load_config_from_file(config_path, override_labid=None):
|
|||||||
load_config(config_path, override_labid)
|
load_config(config_path, override_labid)
|
||||||
|
|
||||||
|
|
||||||
|
def convert_argv_dashes_to_underscores(args: argparse.ArgumentParser):
|
||||||
|
# easier for user input, easier for dev search code
|
||||||
|
option_strings = list(args._option_string_actions.keys())
|
||||||
|
for i, arg in enumerate(sys.argv):
|
||||||
|
for option_string in option_strings:
|
||||||
|
if arg.startswith(option_string):
|
||||||
|
new_arg = arg[:2] + arg[2:len(option_string)].replace("-", "_") + arg[len(option_string):]
|
||||||
|
sys.argv[i] = new_arg
|
||||||
|
break
|
||||||
|
|
||||||
def parse_args():
|
def parse_args():
|
||||||
"""解析命令行参数"""
|
"""解析命令行参数"""
|
||||||
parser = argparse.ArgumentParser(description="Start Uni-Lab Edge server.")
|
parser = argparse.ArgumentParser(description="Start Uni-Lab Edge server.")
|
||||||
parser.add_argument("-g", "--graph", help="Physical setup graph.")
|
parser.add_argument("-g", "--graph", help="Physical setup graph.")
|
||||||
parser.add_argument("-d", "--devices", help="Devices config file.")
|
# parser.add_argument("-d", "--devices", help="Devices config file.")
|
||||||
parser.add_argument("-r", "--resources", help="Resources config file.")
|
# parser.add_argument("-r", "--resources", help="Resources config file.")
|
||||||
parser.add_argument("-c", "--controllers", default=None, help="Controllers config file.")
|
parser.add_argument("-c", "--controllers", default=None, help="Controllers config file.")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--registry_path",
|
"--registry_path",
|
||||||
@@ -51,6 +60,12 @@ def parse_args():
|
|||||||
action="append",
|
action="append",
|
||||||
help="Path to the registry",
|
help="Path to the registry",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--working_dir",
|
||||||
|
type=str,
|
||||||
|
default=None,
|
||||||
|
help="Path to the working directory",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--backend",
|
"--backend",
|
||||||
choices=["ros", "simple", "automancer"],
|
choices=["ros", "simple", "automancer"],
|
||||||
@@ -92,12 +107,12 @@ def parse_args():
|
|||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--disable_browser",
|
"--disable_browser",
|
||||||
action='store_true',
|
action="store_true",
|
||||||
help="是否在启动时关闭信息页",
|
help="是否在启动时关闭信息页",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--2d_vis",
|
"--2d_vis",
|
||||||
action='store_true',
|
action="store_true",
|
||||||
help="是否在pylabrobot实例启动时,同时启动可视化",
|
help="是否在pylabrobot实例启动时,同时启动可视化",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -112,14 +127,15 @@ def parse_args():
|
|||||||
default="",
|
default="",
|
||||||
help="实验室唯一ID,也可通过环境变量 UNILABOS.MQCONFIG.LABID 设置或传入--config设置",
|
help="实验室唯一ID,也可通过环境变量 UNILABOS.MQCONFIG.LABID 设置或传入--config设置",
|
||||||
)
|
)
|
||||||
return parser.parse_args()
|
return parser
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""主函数"""
|
"""主函数"""
|
||||||
# 解析命令行参数
|
# 解析命令行参数
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
args_dict = vars(args)
|
convert_argv_dashes_to_underscores(args)
|
||||||
|
args_dict = vars(args.parse_args())
|
||||||
|
|
||||||
# 加载配置文件,优先加载config,然后从env读取
|
# 加载配置文件,优先加载config,然后从env读取
|
||||||
config_path = args_dict.get("config")
|
config_path = args_dict.get("config")
|
||||||
@@ -152,30 +168,30 @@ def main():
|
|||||||
|
|
||||||
# 注册表
|
# 注册表
|
||||||
build_registry(args_dict["registry_path"])
|
build_registry(args_dict["registry_path"])
|
||||||
resource_edge_info = []
|
if args_dict["graph"] is None:
|
||||||
devices_and_resources = None
|
request_startup_json = http_client.request_startup_json()
|
||||||
if args_dict["graph"] is not None:
|
if not request_startup_json:
|
||||||
import unilabos.resources.graphio as graph_res
|
print_status(
|
||||||
|
"未指定设备加载文件路径,尝试从HTTP获取失败,请检查网络或者使用-g参数指定设备加载文件路径", "error"
|
||||||
|
)
|
||||||
|
os._exit(1)
|
||||||
|
else:
|
||||||
|
print_status("联网获取设备加载文件成功", "info")
|
||||||
|
graph, data = read_node_link_json(request_startup_json)
|
||||||
|
else:
|
||||||
if args_dict["graph"].endswith(".json"):
|
if args_dict["graph"].endswith(".json"):
|
||||||
graph, data = read_node_link_json(args_dict["graph"])
|
graph, data = read_node_link_json(args_dict["graph"])
|
||||||
else:
|
else:
|
||||||
graph, data = read_graphml(args_dict["graph"])
|
graph, data = read_graphml(args_dict["graph"])
|
||||||
graph_res.physical_setup_graph = graph
|
import unilabos.resources.graphio as graph_res
|
||||||
resource_edge_info = modify_to_backend_format(data["links"])
|
|
||||||
devices_and_resources = dict_from_graph(graph_res.physical_setup_graph)
|
graph_res.physical_setup_graph = graph
|
||||||
# args_dict["resources_config"] = initialize_resources(list(deepcopy(devices_and_resources).values()))
|
resource_edge_info = modify_to_backend_format(data["links"])
|
||||||
args_dict["resources_config"] = list(devices_and_resources.values())
|
devices_and_resources = dict_from_graph(graph_res.physical_setup_graph)
|
||||||
args_dict["devices_config"] = dict_to_nested_dict(deepcopy(devices_and_resources), devices_only=False)
|
# args_dict["resources_config"] = initialize_resources(list(deepcopy(devices_and_resources).values()))
|
||||||
args_dict["graph"] = graph_res.physical_setup_graph
|
args_dict["resources_config"] = list(devices_and_resources.values())
|
||||||
else:
|
args_dict["devices_config"] = dict_to_nested_dict(deepcopy(devices_and_resources), devices_only=False)
|
||||||
if args_dict["devices"] is None or args_dict["resources"] is None:
|
args_dict["graph"] = graph_res.physical_setup_graph
|
||||||
print_status("Either graph or devices and resources must be provided.", "error")
|
|
||||||
sys.exit(1)
|
|
||||||
args_dict["devices_config"] = json.load(open(args_dict["devices"], encoding="utf-8"))
|
|
||||||
# args_dict["resources_config"] = initialize_resources(
|
|
||||||
# list(json.load(open(args_dict["resources"], encoding="utf-8")).values())
|
|
||||||
# )
|
|
||||||
args_dict["resources_config"] = list(json.load(open(args_dict["resources"], encoding="utf-8")).values())
|
|
||||||
|
|
||||||
print_status(f"{len(args_dict['resources_config'])} Resources loaded:", "info")
|
print_status(f"{len(args_dict['resources_config'])} Resources loaded:", "info")
|
||||||
for i in args_dict["resources_config"]:
|
for i in args_dict["resources_config"]:
|
||||||
@@ -207,13 +223,22 @@ def main():
|
|||||||
if args_dict["visual"] != "disable":
|
if args_dict["visual"] != "disable":
|
||||||
enable_rviz = args_dict["visual"] == "rviz"
|
enable_rviz = args_dict["visual"] == "rviz"
|
||||||
if devices_and_resources is not None:
|
if devices_and_resources is not None:
|
||||||
from unilabos.device_mesh.resource_visalization import ResourceVisualization # 此处开启后,logger会变更为INFO,有需要请调整
|
from unilabos.device_mesh.resource_visalization import (
|
||||||
resource_visualization = ResourceVisualization(devices_and_resources, args_dict["resources_config"] ,enable_rviz=enable_rviz)
|
ResourceVisualization,
|
||||||
|
) # 此处开启后,logger会变更为INFO,有需要请调整
|
||||||
|
|
||||||
|
resource_visualization = ResourceVisualization(
|
||||||
|
devices_and_resources, args_dict["resources_config"], enable_rviz=enable_rviz
|
||||||
|
)
|
||||||
args_dict["resources_mesh_config"] = resource_visualization.resource_model
|
args_dict["resources_mesh_config"] = resource_visualization.resource_model
|
||||||
start_backend(**args_dict)
|
start_backend(**args_dict)
|
||||||
server_thread = threading.Thread(target=start_server, kwargs=dict(
|
server_thread = threading.Thread(
|
||||||
open_browser=not args_dict["disable_browser"], port=args_dict["port"],
|
target=start_server,
|
||||||
))
|
kwargs=dict(
|
||||||
|
open_browser=not args_dict["disable_browser"],
|
||||||
|
port=args_dict["port"],
|
||||||
|
),
|
||||||
|
)
|
||||||
server_thread.start()
|
server_thread.start()
|
||||||
asyncio.set_event_loop(asyncio.new_event_loop())
|
asyncio.set_event_loop(asyncio.new_event_loop())
|
||||||
resource_visualization.start()
|
resource_visualization.start()
|
||||||
@@ -221,10 +246,16 @@ def main():
|
|||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
else:
|
else:
|
||||||
start_backend(**args_dict)
|
start_backend(**args_dict)
|
||||||
start_server(open_browser=not args_dict["disable_browser"], port=args_dict["port"],)
|
start_server(
|
||||||
|
open_browser=not args_dict["disable_browser"],
|
||||||
|
port=args_dict["port"],
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
start_backend(**args_dict)
|
start_backend(**args_dict)
|
||||||
start_server(open_browser=not args_dict["disable_browser"], port=args_dict["port"],)
|
start_server(
|
||||||
|
open_browser=not args_dict["disable_browser"],
|
||||||
|
port=args_dict["port"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ HTTP客户端模块
|
|||||||
|
|
||||||
提供与远程服务器通信的客户端功能,只有host需要用
|
提供与远程服务器通信的客户端功能,只有host需要用
|
||||||
"""
|
"""
|
||||||
|
import json
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
@@ -170,6 +170,36 @@ class HTTPClient:
|
|||||||
logger.error(f"注册资源失败: {response.status_code}, {response.text}")
|
logger.error(f"注册资源失败: {response.status_code}, {response.text}")
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
def request_startup_json(self) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
请求启动配置
|
||||||
|
|
||||||
|
Args:
|
||||||
|
startup_json: 启动配置JSON数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response: API响应对象
|
||||||
|
"""
|
||||||
|
response = requests.get(
|
||||||
|
f"{self.remote_addr}/lab/resource/graph_info/",
|
||||||
|
headers={"Authorization": f"lab {self.auth}"},
|
||||||
|
timeout=(3, 30),
|
||||||
|
)
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.error(f"请求启动配置失败: {response.status_code}, {response.text}")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
with open("startup_config.json", "w", encoding="utf-8") as f:
|
||||||
|
f.write(response.text)
|
||||||
|
target_dict = json.loads(response.text)
|
||||||
|
if "data" in target_dict:
|
||||||
|
target_dict = target_dict["data"]
|
||||||
|
return target_dict
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.error(f"解析启动配置JSON失败: {str(e.args)}\n响应内容: {response.text}")
|
||||||
|
logger.error(f"响应内容: {response.text}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# 创建默认客户端实例
|
# 创建默认客户端实例
|
||||||
http_client = HTTPClient()
|
http_client = HTTPClient()
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class BasicConfig:
|
|||||||
upload_registry = False
|
upload_registry = False
|
||||||
machine_name = "undefined"
|
machine_name = "undefined"
|
||||||
vis_2d_enable = False
|
vis_2d_enable = False
|
||||||
|
enable_resource_load = True
|
||||||
|
|
||||||
|
|
||||||
# MQTT配置
|
# MQTT配置
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from typing import Any, Dict, List
|
|||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
from unilabos.config.config import BasicConfig
|
||||||
from unilabos.resources.graphio import resource_plr_to_ulab, tree_to_list
|
from unilabos.resources.graphio import resource_plr_to_ulab, tree_to_list
|
||||||
from unilabos.ros.msgs.message_converter import msg_converter_manager, ros_action_to_json_schema, String
|
from unilabos.ros.msgs.message_converter import msg_converter_manager, ros_action_to_json_schema, String
|
||||||
from unilabos.utils import logger
|
from unilabos.utils import logger
|
||||||
@@ -46,6 +47,7 @@ class Registry:
|
|||||||
)
|
)
|
||||||
self.EmptyIn = self._replace_type_with_class("EmptyIn", "host_node", f"")
|
self.EmptyIn = self._replace_type_with_class("EmptyIn", "host_node", f"")
|
||||||
self.device_type_registry = {}
|
self.device_type_registry = {}
|
||||||
|
self.device_module_to_registry = {}
|
||||||
self.resource_type_registry = {}
|
self.resource_type_registry = {}
|
||||||
self._setup_called = False # 跟踪setup是否已调用
|
self._setup_called = False # 跟踪setup是否已调用
|
||||||
# 其他状态变量
|
# 其他状态变量
|
||||||
@@ -157,7 +159,10 @@ class Registry:
|
|||||||
logger.debug(f"[UniLab Registry] Path {i+1}/{len(self.registry_paths)}: {sys_path}")
|
logger.debug(f"[UniLab Registry] Path {i+1}/{len(self.registry_paths)}: {sys_path}")
|
||||||
sys.path.append(str(sys_path))
|
sys.path.append(str(sys_path))
|
||||||
self.load_device_types(path, complete_registry)
|
self.load_device_types(path, complete_registry)
|
||||||
self.load_resource_types(path, complete_registry)
|
if BasicConfig.enable_resource_load:
|
||||||
|
self.load_resource_types(path, complete_registry)
|
||||||
|
else:
|
||||||
|
logger.warning("跳过了资源注册表加载!")
|
||||||
logger.info("[UniLab Registry] 注册表设置完成")
|
logger.info("[UniLab Registry] 注册表设置完成")
|
||||||
# 标记setup已被调用
|
# 标记setup已被调用
|
||||||
self._setup_called = True
|
self._setup_called = True
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import importlib
|
import importlib
|
||||||
import inspect
|
import inspect
|
||||||
import json
|
import json
|
||||||
from typing import Union, Any
|
from typing import Union, Any, Dict
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import networkx as nx
|
import networkx as nx
|
||||||
from unilabos_msgs.msg import Resource
|
from unilabos_msgs.msg import Resource
|
||||||
@@ -150,10 +150,12 @@ def handle_communications(G: nx.Graph):
|
|||||||
G.nodes[device]["config"]["io_device_port"] = int(edata["port"][device_comm])
|
G.nodes[device]["config"]["io_device_port"] = int(edata["port"][device_comm])
|
||||||
|
|
||||||
|
|
||||||
def read_node_link_json(json_file):
|
def read_node_link_json(json_info: Union[str, Dict[str, Any]]) -> tuple[nx.Graph, dict]:
|
||||||
global physical_setup_graph
|
global physical_setup_graph
|
||||||
|
if isinstance(json_info, str):
|
||||||
data = json.load(open(json_file, encoding="utf-8"))
|
data = json.load(open(json_info, encoding="utf-8"))
|
||||||
|
else:
|
||||||
|
data = json_info
|
||||||
data = canonicalize_nodes_data(data)
|
data = canonicalize_nodes_data(data)
|
||||||
data = canonicalize_links_ports(data)
|
data = canonicalize_links_ports(data)
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ from unilabos.registry.registry import lab_registry
|
|||||||
from unilabos.ros.device_node_wrapper import ros2_device_node
|
from unilabos.ros.device_node_wrapper import ros2_device_node
|
||||||
from unilabos.ros.nodes.base_device_node import ROS2DeviceNode, DeviceInitError
|
from unilabos.ros.nodes.base_device_node import ROS2DeviceNode, DeviceInitError
|
||||||
from unilabos.utils import logger
|
from unilabos.utils import logger
|
||||||
|
from unilabos.utils.exception import DeviceClassInvalid
|
||||||
from unilabos.utils.import_manager import default_manager
|
from unilabos.utils.import_manager import default_manager
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def initialize_device_from_dict(device_id, device_config) -> Optional[ROS2DeviceNode]:
|
def initialize_device_from_dict(device_id, device_config) -> Optional[ROS2DeviceNode]:
|
||||||
"""Initializes a device based on its configuration.
|
"""Initializes a device based on its configuration.
|
||||||
|
|
||||||
@@ -25,11 +27,13 @@ def initialize_device_from_dict(device_id, device_config) -> Optional[ROS2Device
|
|||||||
original_device_config = copy.deepcopy(device_config)
|
original_device_config = copy.deepcopy(device_config)
|
||||||
device_class_config = device_config["class"]
|
device_class_config = device_config["class"]
|
||||||
if isinstance(device_class_config, str): # 如果是字符串,则直接去lab_registry中查找,获取class
|
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:
|
if device_class_config not in lab_registry.device_type_registry:
|
||||||
raise ValueError(f"Device class {device_class_config} not found.")
|
raise DeviceClassInvalid(f"Device [{device_id}] class {device_class_config} not found. {device_config}")
|
||||||
device_class_config = device_config["class"] = lab_registry.device_type_registry[device_class_config]["class"]
|
device_class_config = device_config["class"] = lab_registry.device_type_registry[device_class_config]["class"]
|
||||||
else:
|
elif isinstance(device_class_config, dict):
|
||||||
raise ValueError("不再支持class为字典传入,class必须为注册表中已经提供的设备,您可以新增注册表并通过--registry传入")
|
raise DeviceClassInvalid(f"Device [{device_id}] class config should be type 'str' but 'dict' got. {device_config}")
|
||||||
if isinstance(device_class_config, dict):
|
if isinstance(device_class_config, dict):
|
||||||
DEVICE = default_manager.get_class(device_class_config["module"])
|
DEVICE = default_manager.get_class(device_class_config["module"])
|
||||||
# 不管是ros2的实例,还是python的,都必须包一次,除了HostNode
|
# 不管是ros2的实例,还是python的,都必须包一次,除了HostNode
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ from unilabos.ros.msgs.message_converter import (
|
|||||||
)
|
)
|
||||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode, DeviceNodeResourceTracker
|
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode, DeviceNodeResourceTracker
|
||||||
from unilabos.ros.nodes.presets.controller_node import ControllerNode
|
from unilabos.ros.nodes.presets.controller_node import ControllerNode
|
||||||
|
from unilabos.utils.exception import DeviceClassInvalid
|
||||||
|
|
||||||
|
|
||||||
class HostNode(BaseROS2DeviceNode):
|
class HostNode(BaseROS2DeviceNode):
|
||||||
@@ -459,7 +460,11 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
self.lab_logger().info(f"[Host Node] Initializing device: {device_id}")
|
self.lab_logger().info(f"[Host Node] Initializing device: {device_id}")
|
||||||
|
|
||||||
device_config_copy = copy.deepcopy(device_config)
|
device_config_copy = copy.deepcopy(device_config)
|
||||||
d = initialize_device_from_dict(device_id, device_config_copy)
|
try:
|
||||||
|
d = initialize_device_from_dict(device_id, device_config_copy)
|
||||||
|
except DeviceClassInvalid as e:
|
||||||
|
self.lab_logger().error(f"[Host Node] Device class invalid: {e}")
|
||||||
|
d = None
|
||||||
if d is None:
|
if d is None:
|
||||||
return
|
return
|
||||||
# noinspection PyProtectedMember
|
# noinspection PyProtectedMember
|
||||||
|
|||||||
3
unilabos/utils/exception.py
Normal file
3
unilabos/utils/exception.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
|
||||||
|
class DeviceClassInvalid(Exception):
|
||||||
|
pass
|
||||||
Reference in New Issue
Block a user