bacnet_service.py 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. from __future__ import annotations
  2. import asyncio
  3. import os
  4. import socket
  5. import threading
  6. import warnings
  7. from contextlib import closing
  8. from typing import Any
  9. BAC0_REGISTERED_WARNING = r"object type .* for vendor identifier 842 already registered.*"
  10. warnings.filterwarnings("ignore", message=BAC0_REGISTERED_WARNING)
  11. import BAC0
  12. from app.response import response_payload
  13. from app.schemas.bacnet import (
  14. BacnetPointReadRequest,
  15. BacnetPointSearchRequest,
  16. BacnetPointSpec,
  17. bac0_object_type,
  18. normalize_object_type,
  19. )
  20. POINT_OBJECT_TYPES = {
  21. "AnalogInput",
  22. "AnalogOutput",
  23. "AnalogValue",
  24. "BinaryInput",
  25. "BinaryOutput",
  26. "BinaryValue",
  27. "MultiStateInput",
  28. "MultiStateOutput",
  29. "MultiStateValue",
  30. }
  31. _BACNET_LOCK = threading.Lock()
  32. try:
  33. BAC0.log_level("silence")
  34. except Exception:
  35. pass
  36. def device_payload(request: BacnetPointReadRequest | BacnetPointSearchRequest) -> dict[str, Any]:
  37. return {
  38. "device_type": "BACnet/IP",
  39. "ip": request.ip,
  40. "port": request.port,
  41. "bacnet_device_id": request.bacnet_device_id,
  42. }
  43. def bacnet_address(ip: str, port: int) -> str:
  44. return f"{ip}:{port}"
  45. def property_read_args(address: str, object_type: str, object_id: int, property_name: str) -> str:
  46. return f"{address} {bac0_object_type(object_type)} {object_id} {property_name}"
  47. def get_local_ip(remote_ip: str, remote_port: int) -> str:
  48. configured_ip = os.getenv("BACNET_LOCAL_IP")
  49. if configured_ip:
  50. return configured_ip
  51. with closing(socket.socket(socket.AF_INET, socket.SOCK_DGRAM)) as sock:
  52. sock.connect((remote_ip, remote_port))
  53. return str(sock.getsockname()[0])
  54. def get_free_udp_port() -> int:
  55. configured_port = os.getenv("BACNET_LOCAL_PORT")
  56. if configured_port:
  57. return int(configured_port)
  58. with closing(socket.socket(socket.AF_INET, socket.SOCK_DGRAM)) as sock:
  59. sock.bind(("", 0))
  60. return int(sock.getsockname()[1])
  61. def local_bacnet_ip(remote_ip: str, remote_port: int) -> str:
  62. local_ip = get_local_ip(remote_ip, remote_port)
  63. local_mask = int(os.getenv("BACNET_LOCAL_MASK", "24"))
  64. return f"{local_ip}/{local_mask}"
  65. def json_value(value: Any) -> Any:
  66. if value is None or isinstance(value, bool | int | float | str):
  67. return value
  68. if isinstance(value, list | tuple):
  69. return [json_value(item) for item in value]
  70. if isinstance(value, dict):
  71. return {str(key): json_value(item) for key, item in value.items()}
  72. if hasattr(value, "value") and isinstance(value.value, bool | int | float | str):
  73. return value.value
  74. return str(value)
  75. async def read_property_or_none(bacnet: Any, address: str, object_type: str, object_id: int, property_name: str) -> Any:
  76. try:
  77. value = await bacnet.read(property_read_args(address, object_type, object_id, property_name))
  78. except Exception:
  79. return None
  80. return json_value(value)
  81. async def read_present_value(bacnet: Any, address: str, point: BacnetPointSpec) -> Any:
  82. return json_value(await bacnet.read(property_read_args(address, point.object_type, point.object_id, "presentValue")))
  83. def split_object_identifier(value: Any) -> tuple[str, int] | None:
  84. if isinstance(value, list | tuple) and len(value) == 2:
  85. raw_type, raw_id = value
  86. try:
  87. return normalize_object_type(str(raw_type).replace("ObjectType.", "")), int(raw_id)
  88. except (TypeError, ValueError):
  89. return None
  90. text = str(value).strip().strip("()")
  91. if "," in text:
  92. raw_type, raw_id = text.split(",", 1)
  93. elif ":" in text:
  94. raw_type, raw_id = text.split(":", 1)
  95. else:
  96. return None
  97. raw_type = raw_type.strip().strip("'\"").replace("ObjectType.", "")
  98. raw_id = raw_id.strip().strip("'\"")
  99. try:
  100. return normalize_object_type(raw_type), int(raw_id)
  101. except (TypeError, ValueError):
  102. return None
  103. async def read_points_async(request: BacnetPointReadRequest) -> list[dict[str, Any]]:
  104. address = bacnet_address(request.ip, request.port)
  105. local_ip = local_bacnet_ip(request.ip, request.port)
  106. local_port = get_free_udp_port()
  107. points: list[dict[str, Any]] = []
  108. with warnings.catch_warnings():
  109. warnings.filterwarnings("ignore", message=BAC0_REGISTERED_WARNING)
  110. async with BAC0.start(ip=local_ip, port=local_port, ping=False) as bacnet:
  111. for point in request.points:
  112. points.append(
  113. {
  114. "object_type": point.object_type,
  115. "object_id": point.object_id,
  116. "present_value": await read_present_value(bacnet, address, point),
  117. }
  118. )
  119. return points
  120. async def search_points_async(request: BacnetPointSearchRequest) -> list[dict[str, Any]]:
  121. address = bacnet_address(request.ip, request.port)
  122. local_ip = local_bacnet_ip(request.ip, request.port)
  123. local_port = get_free_udp_port()
  124. points: list[dict[str, Any]] = []
  125. with warnings.catch_warnings():
  126. warnings.filterwarnings("ignore", message=BAC0_REGISTERED_WARNING)
  127. async with BAC0.start(ip=local_ip, port=local_port, ping=False) as bacnet:
  128. object_list = await bacnet.read(
  129. property_read_args(address, "Device", request.bacnet_device_id, "objectList")
  130. )
  131. for item in object_list or []:
  132. parsed = split_object_identifier(item)
  133. if parsed is None:
  134. continue
  135. object_type, object_id = parsed
  136. if object_type not in POINT_OBJECT_TYPES:
  137. continue
  138. points.append(
  139. {
  140. "name": await read_property_or_none(bacnet, address, object_type, object_id, "objectName"),
  141. "description": await read_property_or_none(bacnet, address, object_type, object_id, "description"),
  142. "object_type": object_type,
  143. "object_id": object_id,
  144. "present_value": await read_property_or_none(bacnet, address, object_type, object_id, "presentValue"),
  145. }
  146. )
  147. return points
  148. def read_points(request: BacnetPointReadRequest) -> dict[str, Any]:
  149. try:
  150. with _BACNET_LOCK:
  151. points = asyncio.run(read_points_async(request))
  152. except Exception as exc:
  153. return response_payload(1, str(exc), {"device": device_payload(request), "points": []})
  154. return response_payload(0, "success", {"device": device_payload(request), "points": points})
  155. def search_points(request: BacnetPointSearchRequest) -> dict[str, Any]:
  156. try:
  157. with _BACNET_LOCK:
  158. points = asyncio.run(search_points_async(request))
  159. except Exception as exc:
  160. return response_payload(1, str(exc), {"device": device_payload(request), "points": []})
  161. return response_payload(0, "success", {"device": device_payload(request), "points": points})