bacnet_service.py 13 KB

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