|
@@ -4,11 +4,14 @@ from typing import Any
|
|
|
|
|
|
|
|
from .auth import find_project_config, resolve_project_token
|
|
from .auth import find_project_config, resolve_project_token
|
|
|
from .http_client import request_json
|
|
from .http_client import request_json
|
|
|
-from .protocols import MODBUS_SPEC, S7_SPEC
|
|
|
|
|
|
|
+from .protocols import BACNET_SPEC, MODBUS_SPEC, S7_SPEC
|
|
|
from .protocols.modbus import MODBUS_POINT_TYPE_ALIASES, MODBUS_REGISTER_TYPE_ALIASES
|
|
from .protocols.modbus import MODBUS_POINT_TYPE_ALIASES, MODBUS_REGISTER_TYPE_ALIASES
|
|
|
from .protocols.s7 import S7_POINT_TYPE_ALIASES, S7_REGISTER_TYPE_ALIASES
|
|
from .protocols.s7 import S7_POINT_TYPE_ALIASES, S7_REGISTER_TYPE_ALIASES
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+BACNET_OBJECT_ID_MAX = 4_194_303
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def _merge_defaults(defaults: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
|
|
def _merge_defaults(defaults: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
|
|
|
merged = dict(defaults)
|
|
merged = dict(defaults)
|
|
|
merged.update(payload)
|
|
merged.update(payload)
|
|
@@ -232,6 +235,103 @@ def _normalize_s7_point_edit_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
|
|
return normalized
|
|
return normalized
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def _normalize_bacnet_device_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
+ normalized = dict(payload)
|
|
|
|
|
+
|
|
|
|
|
+ normalized["name"] = _require_non_empty_text(normalized, "name")
|
|
|
|
|
+ normalized["ip"] = _require_non_empty_text(normalized, "ip")
|
|
|
|
|
+ normalized["bacnet_device_id"] = _normalize_bacnet_id(
|
|
|
|
|
+ _require_present(normalized, "bacnet_device_id"),
|
|
|
|
|
+ "payload.bacnet_device_id",
|
|
|
|
|
+ )
|
|
|
|
|
+ normalized["device_type"] = 1
|
|
|
|
|
+ normalized["type"] = "bacnet"
|
|
|
|
|
+
|
|
|
|
|
+ if "device_id" in normalized:
|
|
|
|
|
+ normalized.pop("device_id", None)
|
|
|
|
|
+ if "device_group_id" in normalized and "group_id" not in normalized:
|
|
|
|
|
+ normalized["group_id"] = normalized["device_group_id"]
|
|
|
|
|
+ normalized.pop("device_group_id", None)
|
|
|
|
|
+ if "bacnet_net" in normalized:
|
|
|
|
|
+ normalized["bacnet_net"] = _normalize_non_negative_int(normalized["bacnet_net"], "payload.bacnet_net")
|
|
|
|
|
+ elif "net" in normalized:
|
|
|
|
|
+ normalized["bacnet_net"] = _normalize_non_negative_int(normalized["net"], "payload.net")
|
|
|
|
|
+ normalized.pop("net", None)
|
|
|
|
|
+
|
|
|
|
|
+ return normalized
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _normalize_bacnet_device_edit_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
+ normalized = dict(payload)
|
|
|
|
|
+ normalized["ori_id"] = _normalize_positive_int(_require_present(normalized, "ori_id"), "payload.ori_id")
|
|
|
|
|
+ normalized["name"] = _require_non_empty_text(normalized, "name")
|
|
|
|
|
+ normalized["ip"] = _require_non_empty_text(normalized, "ip")
|
|
|
|
|
+ bacnet_device_id = _normalize_bacnet_id(
|
|
|
|
|
+ _require_present(normalized, "bacnet_device_id"),
|
|
|
|
|
+ "payload.bacnet_device_id",
|
|
|
|
|
+ )
|
|
|
|
|
+ normalized["device_id"] = str(bacnet_device_id)
|
|
|
|
|
+ normalized["type"] = 1
|
|
|
|
|
+ normalized.pop("bacnet_device_id", None)
|
|
|
|
|
+ normalized.pop("device_type", None)
|
|
|
|
|
+
|
|
|
|
|
+ if "group_id" in normalized and "device_group_id" not in normalized:
|
|
|
|
|
+ normalized["device_group_id"] = normalized["group_id"]
|
|
|
|
|
+ normalized.pop("group_id", None)
|
|
|
|
|
+ if "bacnet_net" in normalized:
|
|
|
|
|
+ normalized["net"] = _normalize_non_negative_int(normalized["bacnet_net"], "payload.bacnet_net")
|
|
|
|
|
+ elif "net" in normalized:
|
|
|
|
|
+ normalized["net"] = _normalize_non_negative_int(normalized["net"], "payload.net")
|
|
|
|
|
+ normalized.pop("bacnet_net", None)
|
|
|
|
|
+
|
|
|
|
|
+ return normalized
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _normalize_bacnet_point_payload(payload: dict[str, Any], *, require_device_id: bool = True) -> dict[str, Any]:
|
|
|
|
|
+ normalized = dict(payload)
|
|
|
|
|
+
|
|
|
|
|
+ if require_device_id:
|
|
|
|
|
+ normalized["device_id"] = _normalize_positive_int(
|
|
|
|
|
+ _require_present(normalized, "device_id"),
|
|
|
|
|
+ "payload.device_id",
|
|
|
|
|
+ )
|
|
|
|
|
+ normalized["object_type"] = _require_non_empty_text(normalized, "object_type")
|
|
|
|
|
+ normalized["object_id"] = _normalize_bacnet_id(_require_present(normalized, "object_id"), "payload.object_id")
|
|
|
|
|
+ object_name = str(normalized.get("object_name") or normalized.get("name") or "").strip()
|
|
|
|
|
+ if not object_name:
|
|
|
|
|
+ raise ValueError("payload.object_name is required")
|
|
|
|
|
+ normalized["object_name"] = object_name
|
|
|
|
|
+ normalized["name"] = str(normalized.get("name") or object_name).strip() or object_name
|
|
|
|
|
+
|
|
|
|
|
+ if normalized.get("priority") is not None:
|
|
|
|
|
+ normalized["priority"] = _normalize_positive_int(normalized["priority"], "payload.priority")
|
|
|
|
|
+
|
|
|
|
|
+ return normalized
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _normalize_bacnet_point_create_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
+ normalized = _merge_defaults(
|
|
|
|
|
+ BACNET_SPEC.point_defaults,
|
|
|
|
|
+ _normalize_bacnet_point_payload(payload),
|
|
|
|
|
+ )
|
|
|
|
|
+ device_id = normalized.pop("device_id")
|
|
|
|
|
+ normalized.pop("invalid_values", None)
|
|
|
|
|
+ normalized.pop("valid_range_start", None)
|
|
|
|
|
+ normalized.pop("valid_range_end", None)
|
|
|
|
|
+ return {"device_id": device_id, "points": [normalized]}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _normalize_bacnet_point_edit_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
+ normalized = _merge_defaults(
|
|
|
|
|
+ BACNET_SPEC.point_defaults,
|
|
|
|
|
+ _normalize_bacnet_point_payload(payload, require_device_id=False),
|
|
|
|
|
+ )
|
|
|
|
|
+ normalized["id"] = _normalize_positive_int(_require_present(normalized, "ori_id"), "payload.ori_id")
|
|
|
|
|
+ normalized.pop("ori_id", None)
|
|
|
|
|
+ normalized.pop("device_id", None)
|
|
|
|
|
+ return normalized
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def _request_collector(
|
|
def _request_collector(
|
|
|
project_key: str,
|
|
project_key: str,
|
|
|
method: str,
|
|
method: str,
|
|
@@ -280,6 +380,17 @@ def _build_s7_point_create_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def _build_bacnet_device_create_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
+ return _merge_defaults(
|
|
|
|
|
+ BACNET_SPEC.device_defaults,
|
|
|
|
|
+ _normalize_bacnet_device_payload(payload),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _build_bacnet_point_create_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
+ return _normalize_bacnet_point_create_payload(payload)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def create_modbus_device(project_key: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
def create_modbus_device(project_key: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
|
return _request_collector(
|
|
return _request_collector(
|
|
|
project_key,
|
|
project_key,
|
|
@@ -316,6 +427,24 @@ def create_s7_point(project_key: str, payload: dict[str, Any]) -> dict[str, Any]
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def create_bacnet_device(project_key: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
+ return _request_collector(
|
|
|
|
|
+ project_key,
|
|
|
|
|
+ "POST",
|
|
|
|
|
+ BACNET_SPEC.create_device_path,
|
|
|
|
|
+ json_payload=_build_bacnet_device_create_payload(payload),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def create_bacnet_point(project_key: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
+ return _request_collector(
|
|
|
|
|
+ project_key,
|
|
|
|
|
+ "POST",
|
|
|
|
|
+ BACNET_SPEC.create_point_path,
|
|
|
|
|
+ json_payload=_build_bacnet_point_create_payload(payload),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def create_modbus_devices(project_key: str, devices: list[dict[str, Any]]) -> dict[str, Any]:
|
|
def create_modbus_devices(project_key: str, devices: list[dict[str, Any]]) -> dict[str, Any]:
|
|
|
return _create_devices_batch(
|
|
return _create_devices_batch(
|
|
|
project_key,
|
|
project_key,
|
|
@@ -354,6 +483,25 @@ def create_s7_points(project_key: str, points: list[dict[str, Any]]) -> dict[str
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def create_bacnet_devices(project_key: str, devices: list[dict[str, Any]]) -> dict[str, Any]:
|
|
|
|
|
+ return _create_devices_batch(
|
|
|
|
|
+ project_key,
|
|
|
|
|
+ devices,
|
|
|
|
|
+ create_path=BACNET_SPEC.create_device_path,
|
|
|
|
|
+ build_payload=_build_bacnet_device_create_payload,
|
|
|
|
|
+ match_device=_match_bacnet_device,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def create_bacnet_points(project_key: str, points: list[dict[str, Any]]) -> dict[str, Any]:
|
|
|
|
|
+ return _create_points_batch(
|
|
|
|
|
+ project_key,
|
|
|
|
|
+ points,
|
|
|
|
|
+ create_path=BACNET_SPEC.create_point_path,
|
|
|
|
|
+ build_payload=_build_bacnet_point_create_payload,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def _create_devices_batch(
|
|
def _create_devices_batch(
|
|
|
project_key: str,
|
|
project_key: str,
|
|
|
devices: list[dict[str, Any]],
|
|
devices: list[dict[str, Any]],
|
|
@@ -547,6 +695,21 @@ def _match_s7_device(device: dict[str, Any], expected: dict[str, Any]) -> bool:
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def _match_bacnet_device(device: dict[str, Any], expected: dict[str, Any]) -> bool:
|
|
|
|
|
+ if device.get("type") != "bacnet":
|
|
|
|
|
+ return False
|
|
|
|
|
+ actual_group_id = device.get("group_id", device.get("device_group_id"))
|
|
|
|
|
+ return (
|
|
|
|
|
+ _same_text(device.get("name"), expected.get("name"))
|
|
|
|
|
+ and _same_text(device.get("ip"), expected.get("ip"))
|
|
|
|
|
+ and _same_int(device.get("port"), expected.get("port"))
|
|
|
|
|
+ and _same_int(device.get("device_type"), expected.get("device_type"))
|
|
|
|
|
+ and _same_int(device.get("bacnet_device_id"), expected.get("bacnet_device_id"))
|
|
|
|
|
+ and _same_int(device.get("bacnet_net"), expected.get("bacnet_net"))
|
|
|
|
|
+ and _same_int(actual_group_id, expected.get("group_id"))
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def _same_text(left: Any, right: Any) -> bool:
|
|
def _same_text(left: Any, right: Any) -> bool:
|
|
|
return str(left or "").strip() == str(right or "").strip()
|
|
return str(left or "").strip() == str(right or "").strip()
|
|
|
|
|
|
|
@@ -621,6 +784,37 @@ def edit_s7_point(project_key: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def edit_bacnet_device(project_key: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
+ return _request_collector(
|
|
|
|
|
+ project_key,
|
|
|
|
|
+ "POST",
|
|
|
|
|
+ "/api/collector/bacnet/device/edit",
|
|
|
|
|
+ json_payload=_merge_defaults(
|
|
|
|
|
+ {
|
|
|
|
|
+ "type": 1,
|
|
|
|
|
+ "port": 47808,
|
|
|
|
|
+ "net": 0,
|
|
|
|
|
+ "asp_ip": "",
|
|
|
|
|
+ "timeout": 3,
|
|
|
|
|
+ "is_persistent": False,
|
|
|
|
|
+ "device_group_id": 0,
|
|
|
|
|
+ "alarm_interval": 90,
|
|
|
|
|
+ "collect_interval": 5,
|
|
|
|
|
+ },
|
|
|
|
|
+ _normalize_bacnet_device_edit_payload(payload),
|
|
|
|
|
+ ),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def edit_bacnet_point(project_key: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
+ return _request_collector(
|
|
|
|
|
+ project_key,
|
|
|
|
|
+ "POST",
|
|
|
|
|
+ "/api/collector/bacnet/point/edit",
|
|
|
|
|
+ json_payload=_normalize_bacnet_point_edit_payload(payload),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def list_devices(project_key: str, num_points: bool = False) -> dict[str, Any]:
|
|
def list_devices(project_key: str, num_points: bool = False) -> dict[str, Any]:
|
|
|
num_points_text = "true" if num_points else "false"
|
|
num_points_text = "true" if num_points else "false"
|
|
|
return _request_collector(
|
|
return _request_collector(
|
|
@@ -717,3 +911,10 @@ def _normalize_non_negative_int(value: int, field_name: str) -> int:
|
|
|
if normalized < 0:
|
|
if normalized < 0:
|
|
|
raise ValueError(f"{field_name} must be a non-negative integer")
|
|
raise ValueError(f"{field_name} must be a non-negative integer")
|
|
|
return normalized
|
|
return normalized
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _normalize_bacnet_id(value: Any, field_name: str) -> int:
|
|
|
|
|
+ normalized = _normalize_non_negative_int(value, field_name)
|
|
|
|
|
+ if normalized > BACNET_OBJECT_ID_MAX:
|
|
|
|
|
+ raise ValueError(f"{field_name} must be between 0 and {BACNET_OBJECT_ID_MAX}")
|
|
|
|
|
+ return normalized
|