bacnet_service.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  1. from __future__ import annotations
  2. import asyncio
  3. import logging
  4. import os
  5. import socket
  6. import threading
  7. import warnings
  8. from contextlib import closing
  9. from typing import Any
  10. BAC0_REGISTERED_WARNING = r"object type .* for vendor identifier 842 already registered.*"
  11. warnings.filterwarnings("ignore", message=BAC0_REGISTERED_WARNING)
  12. import BAC0
  13. from bacpypes3.apdu import ErrorRejectAbortNack
  14. from bacpypes3.basetypes import PropertyIdentifier
  15. from bacpypes3.pdu import Address
  16. from bacpypes3.primitivedata import ObjectIdentifier
  17. from app.response import response_payload
  18. from app.schemas.bacnet import (
  19. BacnetPointReadRequest,
  20. BacnetPointSearchRequest,
  21. BacnetPointSpec,
  22. bac0_object_type,
  23. normalize_object_type,
  24. )
  25. POINT_OBJECT_TYPES = {
  26. "AnalogInput",
  27. "AnalogOutput",
  28. "AnalogValue",
  29. "BinaryInput",
  30. "BinaryOutput",
  31. "BinaryValue",
  32. "MultiStateInput",
  33. "MultiStateOutput",
  34. "MultiStateValue",
  35. }
  36. _BACNET_LOCK = threading.Lock()
  37. logger = logging.getLogger("uvicorn.error")
  38. try:
  39. BAC0.log_level("silence")
  40. except Exception:
  41. pass
  42. class BacnetCommunicationError(RuntimeError):
  43. def __init__(self, message: str) -> None:
  44. super().__init__(message)
  45. def device_payload(request: BacnetPointReadRequest | BacnetPointSearchRequest) -> dict[str, Any]:
  46. return {
  47. "device_type": "BACnet/IP",
  48. "ip": request.ip,
  49. "port": request.port,
  50. "bacnet_device_id": request.bacnet_device_id,
  51. }
  52. def bacnet_address(ip: str, port: int) -> str:
  53. return f"{ip}:{port}"
  54. async def read_property_direct(
  55. bacnet: Any,
  56. address: str,
  57. object_type: str,
  58. object_id: int,
  59. property_name: str,
  60. array_index: int | None = None,
  61. ) -> Any:
  62. logger.info(
  63. "BACnet ReadProperty target=%s object_type=%s object_id=%s property=%s array_index=%s",
  64. address,
  65. object_type,
  66. object_id,
  67. property_name,
  68. array_index,
  69. )
  70. value = await bacnet.this_application.app.read_property(
  71. Address(address),
  72. ObjectIdentifier((bac0_object_type(object_type), object_id)),
  73. PropertyIdentifier(property_name),
  74. array_index,
  75. )
  76. if isinstance(value, ErrorRejectAbortNack):
  77. logger.error(
  78. "BACnet ReadProperty returned error target=%s object_type=%s object_id=%s property=%s array_index=%s error=%s",
  79. address,
  80. object_type,
  81. object_id,
  82. property_name,
  83. array_index,
  84. value,
  85. )
  86. raise BacnetCommunicationError(str(value))
  87. return value
  88. def get_local_ip(remote_ip: str, remote_port: int) -> str:
  89. configured_ip = os.getenv("BACNET_LOCAL_IP")
  90. if configured_ip:
  91. return configured_ip
  92. with closing(socket.socket(socket.AF_INET, socket.SOCK_DGRAM)) as sock:
  93. sock.connect((remote_ip, remote_port))
  94. return str(sock.getsockname()[0])
  95. def get_free_udp_port() -> int:
  96. configured_port = os.getenv("BACNET_LOCAL_PORT")
  97. if configured_port:
  98. return int(configured_port)
  99. with closing(socket.socket(socket.AF_INET, socket.SOCK_DGRAM)) as sock:
  100. sock.bind(("", 0))
  101. return int(sock.getsockname()[1])
  102. def local_bacnet_ip(remote_ip: str, remote_port: int) -> str:
  103. local_ip = get_local_ip(remote_ip, remote_port)
  104. local_mask = int(os.getenv("BACNET_LOCAL_MASK", "24"))
  105. return f"{local_ip}/{local_mask}"
  106. def json_value(value: Any) -> Any:
  107. if value is None or isinstance(value, bool | int | float | str):
  108. return value
  109. if isinstance(value, list | tuple):
  110. return [json_value(item) for item in value]
  111. if isinstance(value, dict):
  112. return {str(key): json_value(item) for key, item in value.items()}
  113. if hasattr(value, "value") and isinstance(value.value, bool | int | float | str):
  114. return value.value
  115. return str(value)
  116. async def read_property_or_none(bacnet: Any, address: str, object_type: str, object_id: int, property_name: str) -> Any:
  117. try:
  118. value = await read_property_direct(bacnet, address, object_type, object_id, property_name)
  119. except Exception:
  120. return None
  121. return json_value(value)
  122. async def read_present_value(bacnet: Any, address: str, point: BacnetPointSpec) -> Any:
  123. try:
  124. value = await read_property_direct(bacnet, address, point.object_type, point.object_id, "presentValue")
  125. except Exception as exc:
  126. raise BacnetCommunicationError(
  127. f"no response from BACnet device {address} while reading "
  128. f"{point.object_type} {point.object_id} presentValue"
  129. ) from exc
  130. return json_value(value)
  131. def ignore_cancelled_bacnet_broadcast_endpoint(loop: asyncio.AbstractEventLoop, context: dict[str, Any]) -> None:
  132. message = str(context.get("message", ""))
  133. exception = context.get("exception")
  134. if isinstance(exception, asyncio.CancelledError) and "set_broadcast_transport_protocol" in message:
  135. return
  136. loop.default_exception_handler(context)
  137. def run_bacnet(coro: Any) -> Any:
  138. loop = asyncio.new_event_loop()
  139. loop.set_exception_handler(ignore_cancelled_bacnet_broadcast_endpoint)
  140. try:
  141. asyncio.set_event_loop(loop)
  142. return loop.run_until_complete(coro)
  143. finally:
  144. pending = asyncio.all_tasks(loop)
  145. for task in pending:
  146. task.cancel()
  147. if pending:
  148. loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
  149. loop.close()
  150. asyncio.set_event_loop(None)
  151. def split_object_identifier(value: Any) -> tuple[str, int] | None:
  152. if isinstance(value, list | tuple) and len(value) == 2:
  153. raw_type, raw_id = value
  154. try:
  155. return normalize_object_type(str(raw_type).replace("ObjectType.", "")), int(raw_id)
  156. except (TypeError, ValueError):
  157. return None
  158. text = str(value).strip().strip("()")
  159. if "," in text:
  160. raw_type, raw_id = text.split(",", 1)
  161. elif ":" in text:
  162. raw_type, raw_id = text.split(":", 1)
  163. else:
  164. return None
  165. raw_type = raw_type.strip().strip("'\"").replace("ObjectType.", "")
  166. raw_id = raw_id.strip().strip("'\"")
  167. try:
  168. return normalize_object_type(raw_type), int(raw_id)
  169. except (TypeError, ValueError):
  170. return None
  171. async def read_points_async(request: BacnetPointReadRequest) -> list[dict[str, Any]]:
  172. address = bacnet_address(request.ip, request.port)
  173. local_ip = local_bacnet_ip(request.ip, request.port)
  174. local_port = get_free_udp_port()
  175. points: list[dict[str, Any]] = []
  176. logger.info(
  177. "BACnet read_points start target=%s local_ip=%s local_port=%s points=%s",
  178. address,
  179. local_ip,
  180. local_port,
  181. len(request.points),
  182. )
  183. with warnings.catch_warnings():
  184. warnings.filterwarnings("ignore", message=BAC0_REGISTERED_WARNING)
  185. async with BAC0.start(ip=local_ip, port=local_port, ping=False) as bacnet:
  186. for point in request.points:
  187. points.append(
  188. {
  189. "object_type": point.object_type,
  190. "object_id": point.object_id,
  191. "present_value": await read_present_value(bacnet, address, point),
  192. }
  193. )
  194. return points
  195. async def search_points_async(request: BacnetPointSearchRequest) -> list[dict[str, Any]]:
  196. address = bacnet_address(request.ip, request.port)
  197. local_ip = local_bacnet_ip(request.ip, request.port)
  198. local_port = get_free_udp_port()
  199. points: list[dict[str, Any]] = []
  200. logger.info(
  201. "BACnet search_points start target=%s local_ip=%s local_port=%s device_id=%s",
  202. address,
  203. local_ip,
  204. local_port,
  205. request.bacnet_device_id,
  206. )
  207. with warnings.catch_warnings():
  208. warnings.filterwarnings("ignore", message=BAC0_REGISTERED_WARNING)
  209. async with BAC0.start(ip=local_ip, port=local_port, ping=False) as bacnet:
  210. try:
  211. object_count = await read_property_direct(
  212. bacnet,
  213. address,
  214. "Device",
  215. request.bacnet_device_id,
  216. "objectList",
  217. array_index=0,
  218. )
  219. logger.info("BACnet objectList count target=%s count=%s", address, object_count)
  220. except Exception as exc:
  221. raise BacnetCommunicationError(
  222. f"no response from BACnet device {address} while reading "
  223. f"Device {request.bacnet_device_id} objectList"
  224. ) from exc
  225. for index in range(1, int(object_count) + 1):
  226. item = await read_property_direct(
  227. bacnet,
  228. address,
  229. "Device",
  230. request.bacnet_device_id,
  231. "objectList",
  232. array_index=index,
  233. )
  234. parsed = split_object_identifier(item)
  235. if parsed is None:
  236. continue
  237. object_type, object_id = parsed
  238. if object_type not in POINT_OBJECT_TYPES:
  239. continue
  240. points.append(
  241. {
  242. "name": await read_property_or_none(bacnet, address, object_type, object_id, "objectName"),
  243. "description": await read_property_or_none(bacnet, address, object_type, object_id, "description"),
  244. "object_type": object_type,
  245. "object_id": object_id,
  246. "present_value": await read_property_or_none(bacnet, address, object_type, object_id, "presentValue"),
  247. }
  248. )
  249. return points
  250. def read_points(request: BacnetPointReadRequest) -> dict[str, Any]:
  251. try:
  252. with _BACNET_LOCK:
  253. points = run_bacnet(read_points_async(request))
  254. except BacnetCommunicationError as exc:
  255. logger.exception("BACnet read_points communication failed device=%s", device_payload(request))
  256. return response_payload(1, str(exc), {"device": device_payload(request), "points": []})
  257. except Exception as exc:
  258. logger.exception("BACnet read_points failed device=%s", device_payload(request))
  259. return response_payload(1, str(exc), {"device": device_payload(request), "points": []})
  260. return response_payload(0, "success", {"device": device_payload(request), "points": points})
  261. def search_points(request: BacnetPointSearchRequest) -> dict[str, Any]:
  262. try:
  263. with _BACNET_LOCK:
  264. points = run_bacnet(search_points_async(request))
  265. except BacnetCommunicationError as exc:
  266. logger.exception("BACnet search_points communication failed device=%s", device_payload(request))
  267. return response_payload(1, str(exc), {"device": device_payload(request), "points": []})
  268. except Exception as exc:
  269. logger.exception("BACnet search_points failed device=%s", device_payload(request))
  270. return response_payload(1, str(exc), {"device": device_payload(request), "points": []})
  271. return response_payload(0, "success", {"device": device_payload(request), "points": points})