bacnet_service.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  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. class BacnetCommunicationError(RuntimeError):
  37. def __init__(self, message: str) -> None:
  38. super().__init__(message)
  39. def device_payload(request: BacnetPointReadRequest | BacnetPointSearchRequest) -> dict[str, Any]:
  40. return {
  41. "device_type": "BACnet/IP",
  42. "ip": request.ip,
  43. "port": request.port,
  44. "bacnet_device_id": request.bacnet_device_id,
  45. }
  46. def bacnet_address(ip: str, port: int) -> str:
  47. return f"{ip}:{port}"
  48. def property_read_args(address: str, object_type: str, object_id: int, property_name: str) -> str:
  49. return f"{address} {bac0_object_type(object_type)} {object_id} {property_name}"
  50. def get_local_ip(remote_ip: str, remote_port: int) -> str:
  51. configured_ip = os.getenv("BACNET_LOCAL_IP")
  52. if configured_ip:
  53. return configured_ip
  54. with closing(socket.socket(socket.AF_INET, socket.SOCK_DGRAM)) as sock:
  55. sock.connect((remote_ip, remote_port))
  56. return str(sock.getsockname()[0])
  57. def get_free_udp_port() -> int:
  58. configured_port = os.getenv("BACNET_LOCAL_PORT")
  59. if configured_port:
  60. return int(configured_port)
  61. with closing(socket.socket(socket.AF_INET, socket.SOCK_DGRAM)) as sock:
  62. sock.bind(("", 0))
  63. return int(sock.getsockname()[1])
  64. def local_bacnet_ip(remote_ip: str, remote_port: int) -> str:
  65. local_ip = get_local_ip(remote_ip, remote_port)
  66. local_mask = int(os.getenv("BACNET_LOCAL_MASK", "24"))
  67. return f"{local_ip}/{local_mask}"
  68. def json_value(value: Any) -> Any:
  69. if value is None or isinstance(value, bool | int | float | str):
  70. return value
  71. if isinstance(value, list | tuple):
  72. return [json_value(item) for item in value]
  73. if isinstance(value, dict):
  74. return {str(key): json_value(item) for key, item in value.items()}
  75. if hasattr(value, "value") and isinstance(value.value, bool | int | float | str):
  76. return value.value
  77. return str(value)
  78. async def read_property_or_none(bacnet: Any, address: str, object_type: str, object_id: int, property_name: str) -> Any:
  79. try:
  80. value = await bacnet.read(property_read_args(address, object_type, object_id, property_name))
  81. except Exception:
  82. return None
  83. return json_value(value)
  84. async def read_present_value(bacnet: Any, address: str, point: BacnetPointSpec) -> Any:
  85. try:
  86. value = await bacnet.read(property_read_args(address, point.object_type, point.object_id, "presentValue"))
  87. except Exception as exc:
  88. raise BacnetCommunicationError(
  89. f"no response from BACnet device {address} while reading "
  90. f"{point.object_type} {point.object_id} presentValue"
  91. ) from exc
  92. return json_value(value)
  93. def ignore_cancelled_bacnet_broadcast_endpoint(loop: asyncio.AbstractEventLoop, context: dict[str, Any]) -> None:
  94. message = str(context.get("message", ""))
  95. exception = context.get("exception")
  96. if isinstance(exception, asyncio.CancelledError) and "set_broadcast_transport_protocol" in message:
  97. return
  98. loop.default_exception_handler(context)
  99. def run_bacnet(coro: Any) -> Any:
  100. loop = asyncio.new_event_loop()
  101. loop.set_exception_handler(ignore_cancelled_bacnet_broadcast_endpoint)
  102. try:
  103. asyncio.set_event_loop(loop)
  104. return loop.run_until_complete(coro)
  105. finally:
  106. pending = asyncio.all_tasks(loop)
  107. for task in pending:
  108. task.cancel()
  109. if pending:
  110. loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
  111. loop.close()
  112. asyncio.set_event_loop(None)
  113. def split_object_identifier(value: Any) -> tuple[str, int] | None:
  114. if isinstance(value, list | tuple) and len(value) == 2:
  115. raw_type, raw_id = value
  116. try:
  117. return normalize_object_type(str(raw_type).replace("ObjectType.", "")), int(raw_id)
  118. except (TypeError, ValueError):
  119. return None
  120. text = str(value).strip().strip("()")
  121. if "," in text:
  122. raw_type, raw_id = text.split(",", 1)
  123. elif ":" in text:
  124. raw_type, raw_id = text.split(":", 1)
  125. else:
  126. return None
  127. raw_type = raw_type.strip().strip("'\"").replace("ObjectType.", "")
  128. raw_id = raw_id.strip().strip("'\"")
  129. try:
  130. return normalize_object_type(raw_type), int(raw_id)
  131. except (TypeError, ValueError):
  132. return None
  133. async def read_points_async(request: BacnetPointReadRequest) -> list[dict[str, Any]]:
  134. address = bacnet_address(request.ip, request.port)
  135. local_ip = local_bacnet_ip(request.ip, request.port)
  136. local_port = get_free_udp_port()
  137. points: list[dict[str, Any]] = []
  138. with warnings.catch_warnings():
  139. warnings.filterwarnings("ignore", message=BAC0_REGISTERED_WARNING)
  140. async with BAC0.start(ip=local_ip, port=local_port, ping=False) as bacnet:
  141. for point in request.points:
  142. points.append(
  143. {
  144. "object_type": point.object_type,
  145. "object_id": point.object_id,
  146. "present_value": await read_present_value(bacnet, address, point),
  147. }
  148. )
  149. return points
  150. async def search_points_async(request: BacnetPointSearchRequest) -> list[dict[str, Any]]:
  151. address = bacnet_address(request.ip, request.port)
  152. local_ip = local_bacnet_ip(request.ip, request.port)
  153. local_port = get_free_udp_port()
  154. points: list[dict[str, Any]] = []
  155. with warnings.catch_warnings():
  156. warnings.filterwarnings("ignore", message=BAC0_REGISTERED_WARNING)
  157. async with BAC0.start(ip=local_ip, port=local_port, ping=False) as bacnet:
  158. try:
  159. object_list = await bacnet.read(
  160. property_read_args(address, "Device", request.bacnet_device_id, "objectList")
  161. )
  162. except Exception as exc:
  163. raise BacnetCommunicationError(
  164. f"no response from BACnet device {address} while reading "
  165. f"Device {request.bacnet_device_id} objectList"
  166. ) from exc
  167. for item in object_list or []:
  168. parsed = split_object_identifier(item)
  169. if parsed is None:
  170. continue
  171. object_type, object_id = parsed
  172. if object_type not in POINT_OBJECT_TYPES:
  173. continue
  174. points.append(
  175. {
  176. "name": await read_property_or_none(bacnet, address, object_type, object_id, "objectName"),
  177. "description": await read_property_or_none(bacnet, address, object_type, object_id, "description"),
  178. "object_type": object_type,
  179. "object_id": object_id,
  180. "present_value": await read_property_or_none(bacnet, address, object_type, object_id, "presentValue"),
  181. }
  182. )
  183. return points
  184. def read_points(request: BacnetPointReadRequest) -> dict[str, Any]:
  185. try:
  186. with _BACNET_LOCK:
  187. points = run_bacnet(read_points_async(request))
  188. except BacnetCommunicationError as exc:
  189. return response_payload(1, str(exc), {"device": device_payload(request), "points": []})
  190. except Exception as exc:
  191. return response_payload(1, str(exc), {"device": device_payload(request), "points": []})
  192. return response_payload(0, "success", {"device": device_payload(request), "points": points})
  193. def search_points(request: BacnetPointSearchRequest) -> dict[str, Any]:
  194. try:
  195. with _BACNET_LOCK:
  196. points = run_bacnet(search_points_async(request))
  197. except BacnetCommunicationError as exc:
  198. return response_payload(1, str(exc), {"device": device_payload(request), "points": []})
  199. except Exception as exc:
  200. return response_payload(1, str(exc), {"device": device_payload(request), "points": []})
  201. return response_payload(0, "success", {"device": device_payload(request), "points": points})