bacnet_service.py 9.6 KB

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