544 行
24 KiB
Python
544 行
24 KiB
Python
from abc import ABC, abstractmethod
|
||
from src.models import WaterSoundData
|
||
import os
|
||
import shutil
|
||
import tempfile
|
||
from typing import Callable, Dict, List, Any, Optional
|
||
|
||
class AlgorithmBridge(ABC):
|
||
"""桥接插件层抽象基类"""
|
||
def __init__(self, name: str, is_core: bool = False):
|
||
self.name = name
|
||
self.is_core = is_core # 核心算法标志,用于容错策略
|
||
|
||
@abstractmethod
|
||
def call_algo(self, water_data: WaterSoundData) -> WaterSoundData:
|
||
pass
|
||
|
||
class PythonBridge(AlgorithmBridge):
|
||
"""Python 桥接插件"""
|
||
def __init__(self, name: str, algo_func: Callable,
|
||
input_mapping: Dict[str, str],
|
||
output_mapping: Dict[str, str],
|
||
preprocessing: Optional[List[Dict[str, Any]]] = None,
|
||
is_core: bool = False,
|
||
requirements: Optional[Dict[str, Any]] = None,
|
||
output_specs: Optional[Dict[str, Any]] = None,
|
||
algo_type: str = "python"):
|
||
super().__init__(name, is_core)
|
||
self.algo_func = algo_func # Python算法核心函数
|
||
self.input_mapping = input_mapping # 输入映射 {algo_param: water_data_attr}
|
||
self.output_mapping = output_mapping# 输出映射 {algo_return_index: water_data_attr}
|
||
self.preprocessing = preprocessing or [] # 预处理算子 [{ "op": "norm", "params": {...} }]
|
||
self.requirements = requirements or {}
|
||
self.output_specs = output_specs or {}
|
||
self.algo_type = algo_type
|
||
|
||
def _apply_preprocessing(self, data: Any, ops: List[Dict[str, Any]]) -> Any:
|
||
"""应用轻量级预处理算子"""
|
||
import numpy as np
|
||
for op_config in ops:
|
||
op = op_config.get("op")
|
||
params = op_config.get("params", {})
|
||
if op == "normalize" and isinstance(data, np.ndarray):
|
||
# 简单归一化示例
|
||
if data.size == 0:
|
||
continue
|
||
min_val = params.get("min", 0)
|
||
max_val = params.get("max", 1)
|
||
data_min = float(data.min())
|
||
data_max = float(data.max())
|
||
denom = data_max - data_min
|
||
if denom == 0:
|
||
data = np.full_like(data, float(min_val), dtype=float)
|
||
else:
|
||
data = (data.astype(float) - data_min) / denom * (float(max_val) - float(min_val)) + float(min_val)
|
||
elif op == "scale" and isinstance(data, (int, float, np.ndarray)):
|
||
data = data * params.get("factor", 1.0)
|
||
return data
|
||
|
||
def call_algo(self, water_data: WaterSoundData) -> WaterSoundData:
|
||
try:
|
||
water_data.add_log(self.name, "Start Execution")
|
||
|
||
# 1. 提取并映射输入,同时应用预处理
|
||
algo_inputs = {}
|
||
for param, attr in self.input_mapping.items():
|
||
if hasattr(water_data, attr):
|
||
val = getattr(water_data, attr)
|
||
# 查找是否有针对该字段的预处理
|
||
relevant_ops = [op for op in self.preprocessing if op.get("field") == attr]
|
||
if relevant_ops:
|
||
val = self._apply_preprocessing(val, relevant_ops)
|
||
algo_inputs[param] = val
|
||
else:
|
||
raise AttributeError(f"Field {attr} not found in WaterSoundData")
|
||
|
||
# 2. 调用算法
|
||
results = self.algo_func(**algo_inputs)
|
||
|
||
# 3. 映射结果回 standardization 数据
|
||
if not isinstance(results, (list, tuple)):
|
||
results = [results]
|
||
|
||
for key, attr in self.output_mapping.items():
|
||
# key can be an index (int) or a key if results is dict
|
||
if isinstance(results, dict):
|
||
val = results[key]
|
||
else:
|
||
idx = int(key) if isinstance(key, str) and key.isdigit() else key
|
||
val = results[idx]
|
||
setattr(water_data, attr, val)
|
||
|
||
water_data.add_log(self.name, "Finished Execution")
|
||
|
||
except Exception as e:
|
||
water_data.set_error(f"Algorithm {self.name} failed: {str(e)}")
|
||
|
||
return water_data
|
||
|
||
import ctypes
|
||
import numpy as np
|
||
|
||
class CppBridge(AlgorithmBridge):
|
||
"""C++ 桥接插件 (ctypes 实现)"""
|
||
def __init__(self, name: str, lib_path: str, function_name: str,
|
||
input_mapping: Dict[str, str],
|
||
output_mapping: Dict[str, str],
|
||
preprocessing: Optional[List[Dict[str, Any]]] = None,
|
||
requirements: Optional[Dict[str, Any]] = None,
|
||
output_specs: Optional[Dict[str, Any]] = None,
|
||
expected_sha256: Optional[str] = None,
|
||
isolate: bool = False,
|
||
is_core: bool = False):
|
||
super().__init__(name, is_core)
|
||
self.lib_path = lib_path
|
||
self.function_name = function_name
|
||
self.input_mapping = input_mapping
|
||
self.output_mapping = output_mapping
|
||
self.preprocessing = preprocessing or []
|
||
self.requirements = requirements or {}
|
||
self.output_specs = output_specs or {}
|
||
self.algo_type = "cpp"
|
||
self._lib = None
|
||
self._func = None
|
||
self.expected_sha256 = expected_sha256
|
||
self.isolate = isolate
|
||
|
||
def _validate_path(self, path: str) -> str:
|
||
"""Security Whitelisting: Ensure path is within the project's 'src' or 'data' directory"""
|
||
base_dir = os.path.realpath(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # project root
|
||
abs_path = os.path.realpath(path if os.path.isabs(path) else os.path.join(base_dir, path))
|
||
allowed_dirs = [
|
||
os.path.join(base_dir, "src"),
|
||
os.path.join(base_dir, "data"),
|
||
]
|
||
if not any(os.path.commonpath([abs_path, d]) == d for d in allowed_dirs):
|
||
raise PermissionError(f"Security Block: Path {path} is outside allowed directories (src/data).")
|
||
return abs_path
|
||
|
||
def _load_lib(self):
|
||
"""延迟加载动态库并安全配置 ABI"""
|
||
if self._lib is None:
|
||
abs_lib_path = self._validate_path(self.lib_path)
|
||
if not os.path.exists(abs_lib_path):
|
||
raise FileNotFoundError(f"C++ library not found: {abs_lib_path}")
|
||
|
||
if self.expected_sha256:
|
||
from src.integrity import ensure_file_sha256
|
||
ensure_file_sha256(abs_lib_path, self.expected_sha256, label=f"cpp library {self.name}")
|
||
|
||
self._lib = ctypes.CDLL(abs_lib_path)
|
||
self._func = getattr(self._lib, self.function_name)
|
||
|
||
# Phase 5: ABI Hardening
|
||
# 默认签名(统一约定):void (double*, int, double*, int)
|
||
self._func.argtypes = [
|
||
ctypes.POINTER(ctypes.c_double),
|
||
ctypes.c_int,
|
||
ctypes.POINTER(ctypes.c_double),
|
||
ctypes.c_int,
|
||
]
|
||
self._func.restype = None
|
||
|
||
# 如果 requirements 中定义了签名,则应用它
|
||
if "argtypes" in self.requirements:
|
||
# 简单映射: 目前支持 "double_ptr", "int"
|
||
mapping = {"double_ptr": ctypes.POINTER(ctypes.c_double), "int": ctypes.c_int}
|
||
self._func.argtypes = [mapping.get(t, ctypes.c_void_p) for t in self.requirements["argtypes"]]
|
||
if "restype" in self.requirements:
|
||
mapping = {"int": ctypes.c_int, "void": None}
|
||
self._func.restype = mapping.get(self.requirements["restype"], None)
|
||
|
||
def _call_isolated(self, in_data: np.ndarray) -> np.ndarray:
|
||
import json
|
||
import subprocess
|
||
import sys
|
||
|
||
abs_lib_path = self._validate_path(self.lib_path)
|
||
if self.expected_sha256:
|
||
from src.integrity import ensure_file_sha256
|
||
ensure_file_sha256(abs_lib_path, self.expected_sha256, label=f"cpp library {self.name}")
|
||
|
||
cfg = {
|
||
"lib_path": abs_lib_path,
|
||
"function_name": self.function_name,
|
||
"expected_sha256": self.expected_sha256,
|
||
}
|
||
if isinstance(self.requirements, dict):
|
||
if isinstance(self.requirements.get("argtypes"), list):
|
||
cfg["argtypes"] = self.requirements.get("argtypes")
|
||
if isinstance(self.requirements.get("restype"), str):
|
||
cfg["restype"] = self.requirements.get("restype")
|
||
|
||
base_dir = os.path.realpath(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||
env = os.environ.copy()
|
||
env["PYTHONPATH"] = base_dir + os.pathsep + env.get("PYTHONPATH", "")
|
||
|
||
with tempfile.TemporaryDirectory(prefix="cpp_bridge_") as tmp_dir:
|
||
cfg_path = os.path.join(tmp_dir, "config.json")
|
||
in_path = os.path.join(tmp_dir, "in.npy")
|
||
out_path = os.path.join(tmp_dir, "out.npy")
|
||
|
||
with open(cfg_path, "w", encoding="utf-8") as f:
|
||
json.dump(cfg, f, ensure_ascii=False)
|
||
np.save(in_path, np.ascontiguousarray(in_data, dtype=np.float64), allow_pickle=False)
|
||
|
||
cmd = [sys.executable, "-m", "src.cpp_isolated_runner", "--config", cfg_path, "--input", in_path, "--output", out_path]
|
||
proc = subprocess.run(cmd, cwd=base_dir, env=env, capture_output=True, text=True)
|
||
if proc.returncode != 0:
|
||
stderr = (proc.stderr or "").strip()
|
||
stdout = (proc.stdout or "").strip()
|
||
details = stderr or stdout or f"returncode={proc.returncode}"
|
||
raise RuntimeError(f"isolated C++ call failed: {details}")
|
||
|
||
if not os.path.exists(out_path):
|
||
raise RuntimeError("isolated C++ call produced no output file")
|
||
return np.load(out_path, allow_pickle=False)
|
||
|
||
def call_algo(self, water_data: WaterSoundData) -> WaterSoundData:
|
||
try:
|
||
water_data.add_log(self.name, "Start C++ Execution")
|
||
|
||
# 1. 准备输入
|
||
# 为了简化 Phase 3.3,我们假设目前只支持 1进1出 (double array)
|
||
# 实际生产中会根据 input_mapping 构造更复杂的结构
|
||
in_attr = list(self.input_mapping.values())[0]
|
||
in_data = getattr(water_data, in_attr)
|
||
|
||
if not isinstance(in_data, np.ndarray):
|
||
raise TypeError(f"C++ bridge expected numpy array for {in_attr}")
|
||
|
||
# 确保数据连续且为 float64 (double)
|
||
in_data = np.ascontiguousarray(in_data, dtype=np.float64)
|
||
if self.isolate:
|
||
out_data = self._call_isolated(in_data)
|
||
else:
|
||
self._load_lib()
|
||
in_ptr = in_data.ctypes.data_as(ctypes.POINTER(ctypes.c_double))
|
||
in_len = ctypes.c_int(in_data.size)
|
||
|
||
# 2. 准备输出空间
|
||
# 同样简化为与输入等长
|
||
out_data = np.zeros_like(in_data)
|
||
out_ptr = out_data.ctypes.data_as(ctypes.POINTER(ctypes.c_double))
|
||
out_len = ctypes.c_int(out_data.size)
|
||
|
||
# 3. 调用 C++ 函数
|
||
# 默认签名: void (double*, int, double*, int)
|
||
self._func(in_ptr, in_len, out_ptr, out_len)
|
||
|
||
# 4. 映射结果回 standardization 数据
|
||
out_attr = list(self.output_mapping.values())[0]
|
||
setattr(water_data, out_attr, out_data)
|
||
|
||
water_data.add_log(self.name, "Finished C++ Execution")
|
||
|
||
except Exception as e:
|
||
water_data.set_error(f"C++ Algorithm {self.name} failed: {str(e)}")
|
||
|
||
return water_data
|
||
|
||
|
||
class MatlabBridge(AlgorithmBridge):
|
||
"""MATLAB/Octave 桥接插件 (支持 Engine/Oct2py/File 降级)"""
|
||
def __init__(self, name: str, m_path: str, function_name: str,
|
||
input_mapping: Dict[str, str],
|
||
output_mapping: Dict[str, str],
|
||
preprocessing: Optional[List[Dict[str, Any]]] = None,
|
||
requirements: Optional[Dict[str, Any]] = None,
|
||
output_specs: Optional[Dict[str, Any]] = None,
|
||
expected_sha256: Optional[str] = None,
|
||
is_core: bool = False):
|
||
super().__init__(name, is_core)
|
||
self.m_path = m_path # .m 文件所在目录
|
||
self.function_name = function_name
|
||
self.input_mapping = input_mapping
|
||
self.output_mapping = output_mapping
|
||
self.preprocessing = preprocessing or []
|
||
self.requirements = requirements or {}
|
||
self.output_specs = output_specs or {}
|
||
self.algo_type = "matlab"
|
||
self._engine = None
|
||
self.expected_sha256 = expected_sha256
|
||
|
||
def _validate_path(self, path: str) -> str:
|
||
"""Security Whitelisting: Ensure MATLAB path is within the project's 'src' or 'data' directory"""
|
||
base_dir = os.path.realpath(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # project root
|
||
abs_path = os.path.realpath(path if os.path.isabs(path) else os.path.join(base_dir, path))
|
||
allowed_dirs = [
|
||
os.path.join(base_dir, "src"),
|
||
os.path.join(base_dir, "data"),
|
||
]
|
||
if not any(os.path.commonpath([abs_path, d]) == d for d in allowed_dirs):
|
||
raise PermissionError(f"Security Block: MATLAB path {path} is outside allowed directories.")
|
||
return abs_path
|
||
|
||
def _verify_entrypoint_integrity(self):
|
||
if not self.expected_sha256:
|
||
return
|
||
|
||
m_dir = self._validate_path(self.m_path)
|
||
entry_file = self._validate_path(os.path.join(m_dir, f"{self.function_name}.m"))
|
||
if not os.path.exists(entry_file):
|
||
raise FileNotFoundError(f"MATLAB entry file not found: {entry_file}")
|
||
|
||
from src.integrity import ensure_file_sha256
|
||
ensure_file_sha256(entry_file, self.expected_sha256, label=f"matlab entry {self.name}")
|
||
|
||
def _call_via_engine(self, inputs: Dict[str, Any]) -> Any:
|
||
"""方式 1: 使用官方 matlab.engine"""
|
||
import matlab.engine
|
||
import matlab
|
||
m_dir = self._validate_path(self.m_path)
|
||
if self._engine is None:
|
||
self._engine = matlab.engine.start_matlab()
|
||
self._engine.addpath(m_dir)
|
||
|
||
func = getattr(self._engine, self.function_name)
|
||
# 将 numpy 数组转为 matlab 类型 (这里简化处理)
|
||
m_inputs = [matlab.double(v.tolist()) if isinstance(v, np.ndarray) else v for v in inputs.values()]
|
||
return func(*m_inputs, nargout=len(self.output_mapping))
|
||
|
||
def _call_via_oct2py(self, inputs: Dict[str, Any]) -> Any:
|
||
"""方式 2: 使用开源 oct2py (Octave)"""
|
||
from oct2py import octave
|
||
m_dir = self._validate_path(self.m_path)
|
||
octave.addpath(m_dir)
|
||
func = getattr(octave, self.function_name)
|
||
return func(*inputs.values())
|
||
|
||
def _call_via_file(self, inputs: Dict[str, Any]) -> Any:
|
||
"""方式 3: 文件兜底 (使用 .mat 文件交换)"""
|
||
from scipy.io import savemat, loadmat
|
||
import subprocess
|
||
m_dir = self._validate_path(self.m_path)
|
||
|
||
with tempfile.TemporaryDirectory(prefix="matlab_bridge_") as tmp_dir:
|
||
in_file = os.path.join(tmp_dir, "tmp_in.mat")
|
||
out_file = os.path.join(tmp_dir, "tmp_out.mat")
|
||
savemat(in_file, inputs)
|
||
|
||
# 构造执行脚本:加入 m_path,加载输入,调用函数,保存输出
|
||
m_path = os.path.abspath(m_dir).replace("'", "''")
|
||
in_file_escaped = in_file.replace("'", "''")
|
||
out_file_escaped = out_file.replace("'", "''")
|
||
arglist = ",".join(inputs.keys())
|
||
script = (
|
||
f"addpath('{m_path}'); "
|
||
f"load('{in_file_escaped}'); "
|
||
f"results = {self.function_name}({arglist}); "
|
||
f"save('-mat', '{out_file_escaped}', 'results'); "
|
||
"exit;"
|
||
)
|
||
|
||
octave = shutil.which("octave") or shutil.which("octave-cli")
|
||
matlab = shutil.which("matlab")
|
||
if octave:
|
||
cmd = [octave, "--quiet", "--eval", script]
|
||
elif matlab:
|
||
cmd = [matlab, "-batch", script]
|
||
else:
|
||
raise RuntimeError("No MATLAB/Octave CLI found for file-based execution.")
|
||
|
||
subprocess.run(cmd, check=True, capture_output=True, text=True)
|
||
|
||
res = loadmat(out_file)
|
||
return res.get("results")
|
||
|
||
def call_algo(self, water_data: WaterSoundData) -> WaterSoundData:
|
||
try:
|
||
water_data.add_log(self.name, "Start MATLAB Execution")
|
||
self._verify_entrypoint_integrity()
|
||
|
||
# 准备输入
|
||
algo_inputs = {}
|
||
for param, attr in self.input_mapping.items():
|
||
algo_inputs[param] = getattr(water_data, attr)
|
||
|
||
# 自动寻优/降级逻辑
|
||
from src.env_detect import collect_env_info
|
||
env = collect_env_info()
|
||
|
||
if env["matlab"]["engine_available"]:
|
||
results = self._call_via_engine(algo_inputs)
|
||
elif env["matlab"]["oct2py_available"]:
|
||
results = self._call_via_oct2py(algo_inputs)
|
||
elif env["matlab"]["octave_cli"]:
|
||
results = self._call_via_file(algo_inputs)
|
||
else:
|
||
raise RuntimeError("No MATLAB/Octave environment found for execution.")
|
||
|
||
# 结果回写 (支持单出/多出)
|
||
if not isinstance(results, (list, tuple)):
|
||
results = [results]
|
||
|
||
for key, attr in self.output_mapping.items():
|
||
idx = int(key) if key.isdigit() else 0
|
||
setattr(water_data, attr, results[idx])
|
||
|
||
water_data.add_log(self.name, "Finished MATLAB Execution")
|
||
|
||
except Exception as e:
|
||
water_data.set_error(f"MATLAB Algorithm {self.name} failed: {str(e)}")
|
||
|
||
return water_data
|
||
|
||
|
||
import requests
|
||
|
||
class WebBridge(AlgorithmBridge):
|
||
"""HTTP/HTTPS 远程服务桥接(通用工作流组件)"""
|
||
|
||
def __init__(self, name: str, endpoint_url: str,
|
||
input_mapping: Dict[str, str], # WSD 字段路径 -> HTTP 请求字段
|
||
output_mapping: Dict[str, str], # HTTP 响应字段 -> WSD 字段路径
|
||
method: str = "POST",
|
||
timeout_sec: int = 300,
|
||
slot_key: Optional[str] = None, # 资源槽位 Key(同 Key 串行执行)
|
||
fixed_params: Optional[Dict[str, Any]] = None, # 固定参数(如 modelType)
|
||
sanitize_output: bool = True, # 是否清洗输出参数
|
||
is_core: bool = False):
|
||
super().__init__(name, is_core)
|
||
self.endpoint_url = endpoint_url
|
||
self.input_mapping = input_mapping
|
||
self.output_mapping = output_mapping
|
||
self.method = method.upper()
|
||
self.timeout_sec = timeout_sec
|
||
self.slot_key = slot_key
|
||
self.fixed_params = fixed_params or {}
|
||
self.sanitize_output = sanitize_output
|
||
self.algo_type = "web"
|
||
|
||
def _get_nested(self, obj: Any, path: str) -> Any:
|
||
"""支持 'sea_env.modeling.freq_hz' 这种嵌套取值"""
|
||
for key in path.split('.'):
|
||
if obj is None:
|
||
return None
|
||
if isinstance(obj, dict):
|
||
obj = obj.get(key)
|
||
else:
|
||
obj = getattr(obj, key, None)
|
||
return obj
|
||
|
||
def _set_nested(self, obj: Any, path: str, value: Any) -> None:
|
||
"""支持 'vis_data.tl_result_path' 这种嵌套写入"""
|
||
keys = path.split('.')
|
||
for key in keys[:-1]:
|
||
if isinstance(obj, dict):
|
||
if key not in obj:
|
||
obj[key] = {}
|
||
obj = obj[key]
|
||
else:
|
||
if not hasattr(obj, key):
|
||
setattr(obj, key, {})
|
||
next_obj = getattr(obj, key)
|
||
if not isinstance(next_obj, dict):
|
||
setattr(obj, key, {})
|
||
next_obj = getattr(obj, key)
|
||
obj = next_obj
|
||
|
||
final_key = keys[-1]
|
||
if isinstance(obj, dict):
|
||
obj[final_key] = value
|
||
else:
|
||
setattr(obj, final_key, value)
|
||
|
||
def call_algo(self, water_data: WaterSoundData) -> WaterSoundData:
|
||
from src.resource_slots import slot_context
|
||
from src.security import (
|
||
validate_endpoint_host, sanitize_output_params,
|
||
generate_run_id, version_tracker, validate_critical_enums
|
||
)
|
||
|
||
try:
|
||
water_data.add_log(self.name, "Start Web Execution", f"Endpoint: {self.endpoint_url}")
|
||
|
||
# 1. 安全检查:Host 白名单
|
||
validate_endpoint_host(self.endpoint_url)
|
||
|
||
# 2. 构建请求参数
|
||
request_params = dict(self.fixed_params)
|
||
for http_field, wsd_path in self.input_mapping.items():
|
||
value = self._get_nested(water_data, wsd_path)
|
||
if value is not None:
|
||
request_params[http_field] = value
|
||
|
||
# 3. 业务逻辑检查:关键参数枚举校验
|
||
validate_critical_enums(request_params)
|
||
|
||
# 3. 清洗输出参数(生成唯一 run_id)
|
||
run_id = generate_run_id()
|
||
if self.sanitize_output:
|
||
request_params = sanitize_output_params(request_params, run_id)
|
||
|
||
water_data.add_log(self.name, "Request Prepared", f"run_id={run_id}")
|
||
|
||
# 4. 获取资源槽位锁并发起请求
|
||
with slot_context(self.slot_key):
|
||
if self.method == "POST":
|
||
response = requests.post(
|
||
self.endpoint_url,
|
||
json=request_params,
|
||
timeout=self.timeout_sec
|
||
)
|
||
elif self.method == "GET":
|
||
response = requests.get(
|
||
self.endpoint_url,
|
||
params=request_params,
|
||
timeout=self.timeout_sec
|
||
)
|
||
else:
|
||
raise ValueError(f"Unsupported HTTP method: {self.method}")
|
||
|
||
response.raise_for_status()
|
||
result = response.json()
|
||
|
||
# 5. 记录服务版本
|
||
if "version" in result:
|
||
version_tracker.record(self.endpoint_url, result["version"])
|
||
water_data.add_log(self.name, "Service Version", result["version"])
|
||
|
||
# 6. 检查执行结果
|
||
if not result.get("success", True):
|
||
error_msg = result.get("error", result.get("message", "Unknown error"))
|
||
raise RuntimeError(f"Remote service returned failure: {error_msg}")
|
||
|
||
# 7. 映射响应到 WSD
|
||
response_data = result.get("data", result)
|
||
for http_field, wsd_path in self.output_mapping.items():
|
||
value = self._get_nested(response_data, http_field)
|
||
if value is not None:
|
||
self._set_nested(water_data, wsd_path, value)
|
||
|
||
water_data.add_log(self.name, "Finished Web Execution")
|
||
|
||
except requests.exceptions.Timeout:
|
||
water_data.set_error(f"WebBridge {self.name} timeout after {self.timeout_sec}s")
|
||
except requests.exceptions.RequestException as e:
|
||
water_data.set_error(f"WebBridge {self.name} request failed: {str(e)}")
|
||
except Exception as e:
|
||
water_data.set_error(f"WebBridge {self.name} failed: {str(e)}")
|
||
|
||
return water_data
|