bacnet_service.py 13 KB

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