collector_api.py 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  1. from __future__ import annotations
  2. from typing import Any
  3. from .auth import find_project_config, resolve_project_token
  4. from .http_client import request_json
  5. from .protocols import MODBUS_SPEC
  6. from .protocols.modbus import MODBUS_POINT_TYPE_ALIASES, MODBUS_REGISTER_TYPE_ALIASES
  7. def _merge_defaults(defaults: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
  8. merged = dict(defaults)
  9. merged.update(payload)
  10. return merged
  11. def _require_non_empty_text(payload: dict[str, Any], field_name: str) -> str:
  12. value = str(payload.get(field_name) or "").strip()
  13. if not value:
  14. raise ValueError(f"payload.{field_name} is required")
  15. return value
  16. def _require_present(payload: dict[str, Any], field_name: str) -> Any:
  17. if field_name not in payload or payload.get(field_name) is None:
  18. raise ValueError(f"payload.{field_name} is required")
  19. return payload[field_name]
  20. def _normalize_modbus_device_payload(payload: dict[str, Any]) -> dict[str, Any]:
  21. normalized = dict(payload)
  22. normalized["name"] = _require_non_empty_text(normalized, "name")
  23. normalized["ip"] = _require_non_empty_text(normalized, "ip")
  24. _require_present(normalized, "device_type")
  25. _require_present(normalized, "port")
  26. _require_present(normalized, "slave_id")
  27. _require_present(normalized, "word_order")
  28. _require_present(normalized, "byte_order")
  29. address_base = _require_present(normalized, "address_base")
  30. normalized["address_offset"] = address_base
  31. normalized.pop("address_base", None)
  32. return normalized
  33. def _normalize_modbus_device_edit_payload(payload: dict[str, Any]) -> dict[str, Any]:
  34. normalized = dict(payload)
  35. normalized["ori_id"] = _normalize_positive_int(_require_present(normalized, "ori_id"), "payload.ori_id")
  36. normalized["name"] = _require_non_empty_text(normalized, "name")
  37. if normalized.get("type") is None:
  38. normalized["type"] = _require_present(normalized, "device_type")
  39. try:
  40. normalized["type"] = int(normalized["type"])
  41. except Exception as exc:
  42. raise ValueError("payload.type must be one of 1, 2, 3, 4, 5") from exc
  43. if normalized["type"] not in {1, 2, 3, 4, 5}:
  44. raise ValueError("payload.type must be one of 1, 2, 3, 4, 5")
  45. normalized.pop("device_type", None)
  46. if normalized["type"] == 2:
  47. normalized["serial_port"] = _require_non_empty_text(normalized, "serial_port")
  48. else:
  49. normalized["ip"] = _require_non_empty_text(normalized, "ip")
  50. _require_present(normalized, "port")
  51. _require_present(normalized, "slave_id")
  52. _require_present(normalized, "word_order")
  53. _require_present(normalized, "byte_order")
  54. if "address_base" in normalized:
  55. normalized["address_offset"] = normalized["address_base"]
  56. normalized.pop("address_base", None)
  57. if "group_id" in normalized and "device_group_id" not in normalized:
  58. normalized["device_group_id"] = normalized["group_id"]
  59. normalized.pop("group_id", None)
  60. return normalized
  61. def _normalize_modbus_point_payload(payload: dict[str, Any]) -> dict[str, Any]:
  62. normalized = dict(payload)
  63. normalized["name"] = _require_non_empty_text(normalized, "name")
  64. _require_present(normalized, "address")
  65. raw_type = _require_non_empty_text(normalized, "type")
  66. normalized_type = MODBUS_POINT_TYPE_ALIASES.get(raw_type)
  67. if normalized_type is None:
  68. normalized_type = MODBUS_POINT_TYPE_ALIASES.get(raw_type.lower())
  69. if normalized_type is None:
  70. raise ValueError(
  71. "payload.type is invalid; use one of bool, int16, uint16, int32, "
  72. "uint32, int64, uint64, float32, float64, or a documented alias "
  73. "such as SHORT, WORD, LONG, DWORD, FLOAT, DOUBLE"
  74. )
  75. normalized["type"] = normalized_type
  76. if normalized.get("func_code") is None:
  77. register_type = _require_non_empty_text(normalized, "register_type")
  78. func_code = MODBUS_REGISTER_TYPE_ALIASES.get(register_type)
  79. if func_code is None:
  80. func_code = MODBUS_REGISTER_TYPE_ALIASES.get(register_type.lower())
  81. if func_code is None:
  82. raise ValueError(
  83. "payload.register_type is invalid; use coil, discrete_input, "
  84. "holding_register, input_register, or func_code 1/2/3/4"
  85. )
  86. normalized["func_code"] = func_code
  87. else:
  88. try:
  89. normalized["func_code"] = int(str(normalized["func_code"]).strip())
  90. except Exception as exc:
  91. raise ValueError("payload.func_code must be one of 1, 2, 3, 4") from exc
  92. if normalized["func_code"] not in {1, 2, 3, 4}:
  93. raise ValueError("payload.func_code must be one of 1, 2, 3, 4")
  94. if "register_type" in normalized:
  95. normalized.pop("register_type")
  96. return normalized
  97. def _normalize_modbus_point_edit_payload(payload: dict[str, Any]) -> dict[str, Any]:
  98. _require_present(payload, "ori_id")
  99. normalized = _normalize_modbus_point_payload(payload)
  100. normalized["ori_id"] = _normalize_positive_int(_require_present(normalized, "ori_id"), "payload.ori_id")
  101. return normalized
  102. def _request_collector(
  103. project_key: str,
  104. method: str,
  105. path: str,
  106. *,
  107. json_payload: dict[str, Any] | None = None,
  108. ) -> dict[str, Any]:
  109. project = find_project_config(project_key)
  110. authorization = resolve_project_token(project)
  111. response_payload = request_json(
  112. method,
  113. f"{project['data_collector_base_url']}{path}",
  114. authorization,
  115. json_payload=json_payload,
  116. )
  117. if not isinstance(response_payload, dict):
  118. raise ValueError(f"collector API returned invalid payload for {path}: {response_payload}")
  119. return response_payload
  120. def create_modbus_device(project_key: str, payload: dict[str, Any]) -> dict[str, Any]:
  121. return _request_collector(
  122. project_key,
  123. "POST",
  124. MODBUS_SPEC.create_device_path,
  125. json_payload=_merge_defaults(
  126. MODBUS_SPEC.device_defaults,
  127. _normalize_modbus_device_payload(payload),
  128. ),
  129. )
  130. def create_modbus_point(project_key: str, payload: dict[str, Any]) -> dict[str, Any]:
  131. return _request_collector(
  132. project_key,
  133. "POST",
  134. MODBUS_SPEC.create_point_path,
  135. json_payload=_merge_defaults(
  136. MODBUS_SPEC.point_defaults,
  137. _normalize_modbus_point_payload(payload),
  138. ),
  139. )
  140. def edit_modbus_device(project_key: str, payload: dict[str, Any]) -> dict[str, Any]:
  141. return _request_collector(
  142. project_key,
  143. "POST",
  144. "/api/collector/modbus/device/edit",
  145. json_payload=_merge_defaults(
  146. {
  147. "timeout": 3,
  148. "is_persistent": True,
  149. "device_group_id": 0,
  150. "alarm_interval": 90,
  151. "collect_interval": 5,
  152. "address_offset": 0,
  153. "retry_times": 0,
  154. "mode": 0,
  155. },
  156. _normalize_modbus_device_edit_payload(payload),
  157. ),
  158. )
  159. def edit_modbus_point(project_key: str, payload: dict[str, Any]) -> dict[str, Any]:
  160. return _request_collector(
  161. project_key,
  162. "POST",
  163. "/api/collector/modbus/point/edit_collect_point",
  164. json_payload=_merge_defaults(
  165. MODBUS_SPEC.point_defaults,
  166. _normalize_modbus_point_edit_payload(payload),
  167. ),
  168. )
  169. def list_devices(project_key: str, num_points: bool = False) -> dict[str, Any]:
  170. num_points_text = "true" if num_points else "false"
  171. return _request_collector(
  172. project_key,
  173. "GET",
  174. f"/api/collector/device?num_points={num_points_text}",
  175. )
  176. def connect_device(project_key: str, device_id: int, device_type: str = "modbus") -> dict[str, Any]:
  177. return _set_device_connect_status(
  178. project_key,
  179. device_id=device_id,
  180. device_type=device_type,
  181. status=2,
  182. )
  183. def disconnect_device(project_key: str, device_id: int, device_type: str = "modbus") -> dict[str, Any]:
  184. return _set_device_connect_status(
  185. project_key,
  186. device_id=device_id,
  187. device_type=device_type,
  188. status=1,
  189. )
  190. def list_device_points(
  191. project_key: str,
  192. device_id: int,
  193. device_type: str = "modbus",
  194. group_id: int = 0,
  195. ) -> dict[str, Any]:
  196. return _request_collector(
  197. project_key,
  198. "POST",
  199. "/api/collector/common/device/get_collect_point",
  200. json_payload={
  201. "id": _normalize_positive_int(device_id, "device_id"),
  202. "type": _normalize_device_type(device_type),
  203. "group_id": _normalize_non_negative_int(group_id, "group_id"),
  204. },
  205. )
  206. def _set_device_connect_status(
  207. project_key: str,
  208. *,
  209. device_id: int,
  210. device_type: str,
  211. status: int,
  212. ) -> dict[str, Any]:
  213. return _request_collector(
  214. project_key,
  215. "POST",
  216. "/api/collector/common/device/set_connect_status",
  217. json_payload={
  218. "id": _normalize_positive_int(device_id, "device_id"),
  219. "type": _normalize_device_type(device_type),
  220. "status": status,
  221. },
  222. )
  223. def _normalize_device_type(device_type: str) -> str:
  224. normalized = str(device_type or "").strip()
  225. if not normalized:
  226. raise ValueError("device_type is required")
  227. return normalized
  228. def _normalize_positive_int(value: int, field_name: str) -> int:
  229. try:
  230. normalized = int(value)
  231. except Exception as exc:
  232. raise ValueError(f"{field_name} must be a positive integer") from exc
  233. if normalized <= 0:
  234. raise ValueError(f"{field_name} must be a positive integer")
  235. return normalized
  236. def _normalize_non_negative_int(value: int, field_name: str) -> int:
  237. try:
  238. normalized = int(value)
  239. except Exception as exc:
  240. raise ValueError(f"{field_name} must be a non-negative integer") from exc
  241. if normalized < 0:
  242. raise ValueError(f"{field_name} must be a non-negative integer")
  243. return normalized