bacnet_service.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734
  1. from __future__ import annotations
  2. import asyncio
  3. import logging
  4. import os
  5. import socket
  6. import struct
  7. import threading
  8. import time
  9. import warnings
  10. from contextlib import closing
  11. from contextlib import contextmanager
  12. from typing import Any
  13. BAC0_REGISTERED_WARNING = r"object type .* for vendor identifier 842 already registered.*"
  14. warnings.filterwarnings("ignore", message=BAC0_REGISTERED_WARNING)
  15. import BAC0
  16. import BAC0.scripts.Base as bac0_base
  17. import bacpypes3.app as bacpypes_app
  18. from bacpypes3.apdu import ErrorRejectAbortNack
  19. from bacpypes3.basetypes import PropertyIdentifier
  20. from bacpypes3.comm import bind
  21. from bacpypes3.ipv4 import IPv4DatagramServer
  22. from bacpypes3.ipv4.bvll import BVLLCodec
  23. from bacpypes3.ipv4.service import BIPNormal, UDPMultiplexer
  24. from bacpypes3.pdu import Address
  25. from bacpypes3.primitivedata import ObjectIdentifier
  26. from app.response import response_payload
  27. from app.schemas.bacnet import (
  28. BacnetBBMDWhoIsRequest,
  29. BacnetPointReadRequest,
  30. BacnetPointSearchRequest,
  31. BacnetPointSpec,
  32. bac0_object_type,
  33. normalize_object_type,
  34. )
  35. POINT_OBJECT_TYPES = {
  36. "AnalogInput",
  37. "AnalogOutput",
  38. "AnalogValue",
  39. "BinaryInput",
  40. "BinaryOutput",
  41. "BinaryValue",
  42. "MultiStateInput",
  43. "MultiStateOutput",
  44. "MultiStateValue",
  45. }
  46. BVLC_TYPE_BACNET_IP = 0x81
  47. BVLC_RESULT = 0x00
  48. BVLC_FORWARDED_NPDU = 0x04
  49. BVLC_REGISTER_FOREIGN_DEVICE = 0x05
  50. BVLC_DISTRIBUTE_BROADCAST_TO_NETWORK = 0x09
  51. BVLC_ORIGINAL_UNICAST_NPDU = 0x0A
  52. BVLC_ORIGINAL_BROADCAST_NPDU = 0x0B
  53. BVLC_RESULT_SUCCESS = 0x0000
  54. APDU_UNCONFIRMED_REQUEST = 0x10
  55. SERVICE_UNCONFIRMED_I_AM = 0x00
  56. SERVICE_UNCONFIRMED_WHO_IS = 0x08
  57. OBJECT_TYPE_DEVICE = 8
  58. OBJECT_ID_INSTANCE_MASK = 0x3FFFFF
  59. SEGMENTATION_SUPPORT = {
  60. 0: "segmentedBoth",
  61. 1: "segmentedTransmit",
  62. 2: "segmentedReceive",
  63. 3: "noSegmentation",
  64. }
  65. _BACNET_LOCK = threading.Lock()
  66. logger = logging.getLogger("uvicorn.error")
  67. try:
  68. BAC0.log_level("silence")
  69. except Exception:
  70. pass
  71. class BacnetCommunicationError(RuntimeError):
  72. def __init__(self, message: str) -> None:
  73. super().__init__(message)
  74. def device_payload(request: BacnetPointReadRequest | BacnetPointSearchRequest) -> dict[str, Any]:
  75. return {
  76. "device_type": "BACnet/IP",
  77. "ip": request.ip,
  78. "port": request.port,
  79. "bacnet_device_id": request.bacnet_device_id,
  80. }
  81. def bbmd_payload(request: BacnetBBMDWhoIsRequest) -> dict[str, Any]:
  82. return {
  83. "bbmd_ip": request.bbmd_ip,
  84. "bbmd_port": request.bbmd_port,
  85. "ttl": request.ttl,
  86. "timeout": request.timeout,
  87. "low_limit": request.low_limit,
  88. "high_limit": request.high_limit,
  89. }
  90. def bbmd_error_payload(request: BacnetBBMDWhoIsRequest | None) -> dict[str, Any]:
  91. if request is not None:
  92. return bbmd_payload(request)
  93. return {
  94. "bbmd_ip": os.getenv("BACNET_BBMD_IP"),
  95. "bbmd_port": os.getenv("BACNET_BBMD_PORT", "47808"),
  96. }
  97. def env_int(name: str, default: int | None = None) -> int | None:
  98. value = os.getenv(name)
  99. if value is None or value == "":
  100. return default
  101. try:
  102. return int(value)
  103. except ValueError as exc:
  104. raise BacnetCommunicationError(f"{name} must be an integer") from exc
  105. def env_float(name: str, default: float) -> float:
  106. value = os.getenv(name)
  107. if value is None or value == "":
  108. return default
  109. try:
  110. return float(value)
  111. except ValueError as exc:
  112. raise BacnetCommunicationError(f"{name} must be a number") from exc
  113. def bbmd_whois_request_from_env() -> BacnetBBMDWhoIsRequest:
  114. bbmd_ip = os.getenv("BACNET_BBMD_IP")
  115. if not bbmd_ip:
  116. raise BacnetCommunicationError("BACNET_BBMD_IP environment variable is required")
  117. return BacnetBBMDWhoIsRequest(
  118. bbmd_ip=bbmd_ip,
  119. bbmd_port=env_int("BACNET_BBMD_PORT", 47808),
  120. ttl=env_int("BACNET_BBMD_TTL", 60),
  121. timeout=env_float("BACNET_BBMD_WHOIS_TIMEOUT", 5),
  122. low_limit=env_int("BACNET_BBMD_LOW_LIMIT", 0),
  123. high_limit=env_int("BACNET_BBMD_HIGH_LIMIT", OBJECT_ID_INSTANCE_MASK),
  124. local_device_id=env_int("BACNET_BBMD_LOCAL_DEVICE_ID"),
  125. )
  126. def bacnet_address(ip: str, port: int) -> str:
  127. return f"{ip}:{port}"
  128. async def read_property_direct(
  129. bacnet: Any,
  130. address: str,
  131. object_type: str,
  132. object_id: int,
  133. property_name: str,
  134. array_index: int | None = None,
  135. ) -> Any:
  136. logger.info(
  137. "BACnet ReadProperty target=%s object_type=%s object_id=%s property=%s array_index=%s",
  138. address,
  139. object_type,
  140. object_id,
  141. property_name,
  142. array_index,
  143. )
  144. value = await bacnet.this_application.app.read_property(
  145. Address(address),
  146. ObjectIdentifier((bac0_object_type(object_type), object_id)),
  147. PropertyIdentifier(property_name),
  148. array_index,
  149. )
  150. if isinstance(value, ErrorRejectAbortNack):
  151. logger.error(
  152. "BACnet ReadProperty returned error target=%s object_type=%s object_id=%s property=%s array_index=%s error=%s",
  153. address,
  154. object_type,
  155. object_id,
  156. property_name,
  157. array_index,
  158. value,
  159. )
  160. raise BacnetCommunicationError(str(value))
  161. return value
  162. def get_local_ip(remote_ip: str, remote_port: int) -> str:
  163. configured_ip = os.getenv("BACNET_LOCAL_IP")
  164. if configured_ip:
  165. return configured_ip
  166. with closing(socket.socket(socket.AF_INET, socket.SOCK_DGRAM)) as sock:
  167. sock.connect((remote_ip, remote_port))
  168. return str(sock.getsockname()[0])
  169. def get_free_udp_port() -> int:
  170. configured_port = os.getenv("BACNET_LOCAL_PORT")
  171. if configured_port:
  172. return int(configured_port)
  173. with closing(socket.socket(socket.AF_INET, socket.SOCK_DGRAM)) as sock:
  174. sock.bind(("", 0))
  175. return int(sock.getsockname()[1])
  176. def create_bbmd_socket(local_ip: str, local_port: int) -> socket.socket:
  177. bind_ip = os.getenv("BACNET_BIND_IP", local_ip)
  178. sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  179. sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  180. if hasattr(socket, "SO_REUSEPORT"):
  181. try:
  182. sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
  183. except OSError:
  184. pass
  185. sock.bind((bind_ip, local_port))
  186. return sock
  187. def bvlc_packet(function: int, payload: bytes) -> bytes:
  188. return struct.pack(">BBH", BVLC_TYPE_BACNET_IP, function, 4 + len(payload)) + payload
  189. def register_foreign_device_packet(ttl: int) -> bytes:
  190. return bvlc_packet(BVLC_REGISTER_FOREIGN_DEVICE, struct.pack(">H", ttl))
  191. def context_unsigned(tag: int, value: int) -> bytes:
  192. if not 0 <= tag <= 14:
  193. raise ValueError("BACnet context tag must be between 0 and 14")
  194. if not 0 <= value <= 0xFFFFFFFF:
  195. raise ValueError("BACnet unsigned context value out of range")
  196. if value <= 0xFF:
  197. data = value.to_bytes(1, "big")
  198. elif value <= 0xFFFF:
  199. data = value.to_bytes(2, "big")
  200. else:
  201. data = value.to_bytes(4, "big")
  202. return bytes([(tag << 4) | 0x08 | len(data)]) + data
  203. def whois_apdu(low_limit: int | None, high_limit: int | None) -> bytes:
  204. payload = bytearray([APDU_UNCONFIRMED_REQUEST, SERVICE_UNCONFIRMED_WHO_IS])
  205. if low_limit is not None and high_limit is not None:
  206. payload.extend(context_unsigned(0, low_limit))
  207. payload.extend(context_unsigned(1, high_limit))
  208. return bytes(payload)
  209. def bbmd_whois_packet(low_limit: int | None, high_limit: int | None) -> bytes:
  210. # Match bacpypes LocalBroadcast(): BIPForeign wraps this NPDU in Distribute-Broadcast-To-Network.
  211. npdu = b"\x01\x00" + whois_apdu(low_limit, high_limit)
  212. return bvlc_packet(BVLC_DISTRIBUTE_BROADCAST_TO_NETWORK, npdu)
  213. def parse_bvlc_result(packet: bytes) -> int | None:
  214. if len(packet) < 6 or packet[0] != BVLC_TYPE_BACNET_IP or packet[1] != BVLC_RESULT:
  215. return None
  216. return int.from_bytes(packet[4:6], "big")
  217. def udp_endpoint_from_bytes(data: bytes) -> tuple[str, int]:
  218. return socket.inet_ntoa(data[:4]), int.from_bytes(data[4:6], "big")
  219. def npdu_apdu(packet: bytes) -> bytes | None:
  220. if len(packet) < 2 or packet[0] != 0x01:
  221. return None
  222. control = packet[1]
  223. offset = 2
  224. has_destination = bool(control & 0x20)
  225. if has_destination:
  226. if len(packet) < offset + 3:
  227. return None
  228. dlen = packet[offset + 2]
  229. offset += 3 + dlen
  230. if control & 0x08:
  231. if len(packet) < offset + 3:
  232. return None
  233. slen = packet[offset + 2]
  234. offset += 3 + slen
  235. if has_destination:
  236. offset += 1
  237. if control & 0x80:
  238. return None
  239. if offset >= len(packet):
  240. return None
  241. return packet[offset:]
  242. def read_application_tag(packet: bytes, offset: int) -> tuple[int, bool, bytes, int] | None:
  243. if offset >= len(packet):
  244. return None
  245. first = packet[offset]
  246. offset += 1
  247. tag = first >> 4
  248. class_tag = bool(first & 0x08)
  249. length = first & 0x07
  250. if tag == 0x0F:
  251. if offset >= len(packet):
  252. return None
  253. tag = packet[offset]
  254. offset += 1
  255. if length == 5:
  256. if offset >= len(packet):
  257. return None
  258. length = packet[offset]
  259. offset += 1
  260. elif length in (6, 7):
  261. return None
  262. if len(packet) < offset + length:
  263. return None
  264. value = packet[offset : offset + length]
  265. return tag, class_tag, value, offset + length
  266. def parse_i_am_apdu(apdu: bytes) -> dict[str, Any] | None:
  267. if len(apdu) < 2 or apdu[0] != APDU_UNCONFIRMED_REQUEST or apdu[1] != SERVICE_UNCONFIRMED_I_AM:
  268. return None
  269. offset = 2
  270. object_id: int | None = None
  271. max_apdu: int | None = None
  272. segmentation: int | None = None
  273. vendor_id: int | None = None
  274. for index in range(4):
  275. tag = read_application_tag(apdu, offset)
  276. if tag is None:
  277. return None
  278. tag_number, class_tag, value, offset = tag
  279. if class_tag:
  280. return None
  281. if index == 0 and tag_number == 12 and len(value) == 4:
  282. raw_object_id = int.from_bytes(value, "big")
  283. if raw_object_id >> 22 != OBJECT_TYPE_DEVICE:
  284. return None
  285. object_id = raw_object_id & OBJECT_ID_INSTANCE_MASK
  286. elif index == 1 and tag_number == 2:
  287. max_apdu = int.from_bytes(value, "big")
  288. elif index == 2 and tag_number == 9:
  289. segmentation = int.from_bytes(value, "big")
  290. elif index == 3 and tag_number == 2:
  291. vendor_id = int.from_bytes(value, "big")
  292. else:
  293. return None
  294. if object_id is None or max_apdu is None or segmentation is None or vendor_id is None:
  295. return None
  296. return {
  297. "bacnet_device_id": object_id,
  298. "max_apdu": max_apdu,
  299. "segmentation": SEGMENTATION_SUPPORT.get(segmentation, str(segmentation)),
  300. "vendor_id": vendor_id,
  301. }
  302. def parse_i_am_packet(packet: bytes, source: tuple[str, int]) -> dict[str, Any] | None:
  303. if len(packet) < 4 or packet[0] != BVLC_TYPE_BACNET_IP:
  304. return None
  305. length = int.from_bytes(packet[2:4], "big")
  306. if length > len(packet):
  307. return None
  308. function = packet[1]
  309. payload = packet[4:length]
  310. device_ip, device_port = source
  311. if function == BVLC_FORWARDED_NPDU:
  312. if len(payload) < 6:
  313. return None
  314. device_ip, device_port = udp_endpoint_from_bytes(payload[:6])
  315. payload = payload[6:]
  316. elif function not in (BVLC_ORIGINAL_UNICAST_NPDU, BVLC_ORIGINAL_BROADCAST_NPDU):
  317. return None
  318. apdu = npdu_apdu(payload)
  319. if apdu is None:
  320. return None
  321. device = parse_i_am_apdu(apdu)
  322. if device is None:
  323. return None
  324. device["ip"] = device_ip
  325. device["port"] = device_port
  326. return device
  327. def wait_for_bbmd_registration(sock: socket.socket, timeout: float) -> None:
  328. deadline = time.monotonic() + timeout
  329. while True:
  330. remaining = deadline - time.monotonic()
  331. if remaining <= 0:
  332. raise BacnetCommunicationError("BBMD foreign device registration timeout")
  333. sock.settimeout(remaining)
  334. try:
  335. packet, _source = sock.recvfrom(2048)
  336. except socket.timeout as exc:
  337. raise BacnetCommunicationError("BBMD foreign device registration timeout") from exc
  338. result = parse_bvlc_result(packet)
  339. if result is None:
  340. continue
  341. if result != BVLC_RESULT_SUCCESS:
  342. raise BacnetCommunicationError(f"BBMD foreign device registration failed with code {result}")
  343. return
  344. def collect_bbmd_i_ams(
  345. sock: socket.socket,
  346. timeout: float,
  347. low_limit: int | None,
  348. high_limit: int | None,
  349. local_device_id: int | None,
  350. ) -> list[dict[str, Any]]:
  351. devices: dict[tuple[int, str, int], dict[str, Any]] = {}
  352. deadline = time.monotonic() + timeout
  353. while True:
  354. remaining = deadline - time.monotonic()
  355. if remaining <= 0:
  356. return list(devices.values())
  357. sock.settimeout(remaining)
  358. try:
  359. packet, source = sock.recvfrom(2048)
  360. except socket.timeout:
  361. return list(devices.values())
  362. device = parse_i_am_packet(packet, (source[0], source[1]))
  363. if device is None:
  364. continue
  365. device_id = device["bacnet_device_id"]
  366. if device_id == 0:
  367. continue
  368. if local_device_id is not None and device_id == local_device_id:
  369. continue
  370. if low_limit is not None and high_limit is not None and not low_limit <= device_id <= high_limit:
  371. continue
  372. devices[(device_id, device["ip"], device["port"])] = device
  373. def create_bacnet_bind_socket(port: int) -> socket.socket:
  374. bind_ip = os.getenv("BACNET_BIND_IP", "0.0.0.0")
  375. sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  376. sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  377. if hasattr(socket, "SO_REUSEPORT"):
  378. try:
  379. sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
  380. except OSError:
  381. pass
  382. sock.bind((bind_ip, port))
  383. logger.info("BACnet UDP socket bound bind_ip=%s bind_port=%s", bind_ip, port)
  384. return sock
  385. @contextmanager
  386. def go_style_bacnet_binding(port: int):
  387. bind_socket = create_bacnet_bind_socket(port)
  388. original_normal_link_layer = bacpypes_app.NormalLinkLayer_ipv4
  389. original_validate_ip_address = bac0_base.validate_ip_address
  390. socket_transferred = False
  391. class GoStyleNormalLinkLayer(BIPNormal):
  392. def __init__(self, local_address, **kwargs) -> None:
  393. nonlocal socket_transferred
  394. BIPNormal.__init__(self, **kwargs)
  395. self.codec = BVLLCodec()
  396. self.multiplexer = UDPMultiplexer()
  397. self.server = IPv4DatagramServer(local_address, no_broadcast=True, bind_socket=bind_socket)
  398. socket_transferred = True
  399. bind(self, self.codec, self.multiplexer.annexJ)
  400. bind(self.multiplexer, self.server)
  401. def close(self):
  402. self.server.close()
  403. try:
  404. bacpypes_app.NormalLinkLayer_ipv4 = GoStyleNormalLinkLayer
  405. bac0_base.validate_ip_address = lambda _ip: True
  406. yield
  407. finally:
  408. bacpypes_app.NormalLinkLayer_ipv4 = original_normal_link_layer
  409. bac0_base.validate_ip_address = original_validate_ip_address
  410. if not socket_transferred:
  411. bind_socket.close()
  412. def local_bacnet_ip(remote_ip: str, remote_port: int) -> str:
  413. local_ip = get_local_ip(remote_ip, remote_port)
  414. local_mask = int(os.getenv("BACNET_LOCAL_MASK", "24"))
  415. return f"{local_ip}/{local_mask}"
  416. def json_value(value: Any) -> Any:
  417. if value is None or isinstance(value, bool | int | float | str):
  418. return value
  419. if isinstance(value, list | tuple):
  420. return [json_value(item) for item in value]
  421. if isinstance(value, dict):
  422. return {str(key): json_value(item) for key, item in value.items()}
  423. if hasattr(value, "value") and isinstance(value.value, bool | int | float | str):
  424. return value.value
  425. return str(value)
  426. async def read_property_or_none(bacnet: Any, address: str, object_type: str, object_id: int, property_name: str) -> Any:
  427. try:
  428. value = await read_property_direct(bacnet, address, object_type, object_id, property_name)
  429. except Exception:
  430. return None
  431. return json_value(value)
  432. async def read_present_value(bacnet: Any, address: str, point: BacnetPointSpec) -> Any:
  433. try:
  434. value = await read_property_direct(bacnet, address, point.object_type, point.object_id, "presentValue")
  435. except Exception as exc:
  436. raise BacnetCommunicationError(
  437. f"no response from BACnet device {address} while reading "
  438. f"{point.object_type} {point.object_id} presentValue"
  439. ) from exc
  440. return json_value(value)
  441. def ignore_cancelled_bacnet_broadcast_endpoint(loop: asyncio.AbstractEventLoop, context: dict[str, Any]) -> None:
  442. message = str(context.get("message", ""))
  443. exception = context.get("exception")
  444. if isinstance(exception, asyncio.CancelledError) and "set_broadcast_transport_protocol" in message:
  445. return
  446. if isinstance(exception, RuntimeError) and str(exception) == "no broadcast":
  447. return
  448. loop.default_exception_handler(context)
  449. def run_bacnet(coro: Any) -> Any:
  450. loop = asyncio.new_event_loop()
  451. loop.set_exception_handler(ignore_cancelled_bacnet_broadcast_endpoint)
  452. try:
  453. asyncio.set_event_loop(loop)
  454. return loop.run_until_complete(coro)
  455. finally:
  456. pending = asyncio.all_tasks(loop)
  457. for task in pending:
  458. task.cancel()
  459. if pending:
  460. loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
  461. loop.close()
  462. asyncio.set_event_loop(None)
  463. def split_object_identifier(value: Any) -> tuple[str, int] | None:
  464. if isinstance(value, list | tuple) and len(value) == 2:
  465. raw_type, raw_id = value
  466. try:
  467. return normalize_object_type(str(raw_type).replace("ObjectType.", "")), int(raw_id)
  468. except (TypeError, ValueError):
  469. return None
  470. text = str(value).strip().strip("()")
  471. if "," in text:
  472. raw_type, raw_id = text.split(",", 1)
  473. elif ":" in text:
  474. raw_type, raw_id = text.split(":", 1)
  475. else:
  476. return None
  477. raw_type = raw_type.strip().strip("'\"").replace("ObjectType.", "")
  478. raw_id = raw_id.strip().strip("'\"")
  479. try:
  480. return normalize_object_type(raw_type), int(raw_id)
  481. except (TypeError, ValueError):
  482. return None
  483. async def read_points_async(request: BacnetPointReadRequest) -> list[dict[str, Any]]:
  484. address = bacnet_address(request.ip, request.port)
  485. local_ip = local_bacnet_ip(request.ip, request.port)
  486. local_port = get_free_udp_port()
  487. points: list[dict[str, Any]] = []
  488. logger.info(
  489. "BACnet read_points start target=%s local_ip=%s local_port=%s points=%s",
  490. address,
  491. local_ip,
  492. local_port,
  493. len(request.points),
  494. )
  495. with warnings.catch_warnings():
  496. warnings.filterwarnings("ignore", message=BAC0_REGISTERED_WARNING)
  497. with go_style_bacnet_binding(local_port):
  498. async with BAC0.start(ip=local_ip, port=local_port, ping=False) as bacnet:
  499. for point in request.points:
  500. points.append(
  501. {
  502. "object_type": point.object_type,
  503. "object_id": point.object_id,
  504. "present_value": await read_present_value(bacnet, address, point),
  505. }
  506. )
  507. return points
  508. async def search_points_async(request: BacnetPointSearchRequest) -> list[dict[str, Any]]:
  509. address = bacnet_address(request.ip, request.port)
  510. local_ip = local_bacnet_ip(request.ip, request.port)
  511. local_port = get_free_udp_port()
  512. points: list[dict[str, Any]] = []
  513. logger.info(
  514. "BACnet search_points start target=%s local_ip=%s local_port=%s device_id=%s",
  515. address,
  516. local_ip,
  517. local_port,
  518. request.bacnet_device_id,
  519. )
  520. with warnings.catch_warnings():
  521. warnings.filterwarnings("ignore", message=BAC0_REGISTERED_WARNING)
  522. with go_style_bacnet_binding(local_port):
  523. async with BAC0.start(ip=local_ip, port=local_port, ping=False) as bacnet:
  524. try:
  525. object_count = await read_property_direct(
  526. bacnet,
  527. address,
  528. "Device",
  529. request.bacnet_device_id,
  530. "objectList",
  531. array_index=0,
  532. )
  533. logger.info("BACnet objectList count target=%s count=%s", address, object_count)
  534. except Exception as exc:
  535. raise BacnetCommunicationError(
  536. f"no response from BACnet device {address} while reading "
  537. f"Device {request.bacnet_device_id} objectList"
  538. ) from exc
  539. for index in range(1, int(object_count) + 1):
  540. item = await read_property_direct(
  541. bacnet,
  542. address,
  543. "Device",
  544. request.bacnet_device_id,
  545. "objectList",
  546. array_index=index,
  547. )
  548. parsed = split_object_identifier(item)
  549. if parsed is None:
  550. continue
  551. object_type, object_id = parsed
  552. if object_type not in POINT_OBJECT_TYPES:
  553. continue
  554. points.append(
  555. {
  556. "name": await read_property_or_none(bacnet, address, object_type, object_id, "objectName"),
  557. "description": await read_property_or_none(bacnet, address, object_type, object_id, "description"),
  558. "object_type": object_type,
  559. "object_id": object_id,
  560. "present_value": await read_property_or_none(bacnet, address, object_type, object_id, "presentValue"),
  561. }
  562. )
  563. return points
  564. def bbmd_whois_devices(request: BacnetBBMDWhoIsRequest) -> list[dict[str, Any]]:
  565. local_ip = request.local_ip or get_local_ip(request.bbmd_ip, request.bbmd_port)
  566. local_port = request.local_port or get_free_udp_port()
  567. bbmd_address = (request.bbmd_ip, request.bbmd_port)
  568. logger.info(
  569. "BACnet BBMD whois start bbmd=%s:%s local_ip=%s local_port=%s ttl=%s timeout=%s",
  570. request.bbmd_ip,
  571. request.bbmd_port,
  572. local_ip,
  573. local_port,
  574. request.ttl,
  575. request.timeout,
  576. )
  577. with closing(create_bbmd_socket(local_ip, local_port)) as sock:
  578. sock.sendto(register_foreign_device_packet(request.ttl), bbmd_address)
  579. wait_for_bbmd_registration(sock, min(request.timeout, 5.0))
  580. try:
  581. sock.sendto(bbmd_whois_packet(request.low_limit, request.high_limit), bbmd_address)
  582. return collect_bbmd_i_ams(
  583. sock,
  584. request.timeout,
  585. request.low_limit,
  586. request.high_limit,
  587. request.local_device_id,
  588. )
  589. finally:
  590. try:
  591. sock.sendto(register_foreign_device_packet(0), bbmd_address)
  592. except OSError:
  593. logger.debug("BACnet BBMD unregister failed", exc_info=True)
  594. def read_points(request: BacnetPointReadRequest) -> dict[str, Any]:
  595. try:
  596. with _BACNET_LOCK:
  597. points = run_bacnet(read_points_async(request))
  598. except BacnetCommunicationError as exc:
  599. logger.exception("BACnet read_points communication failed device=%s", device_payload(request))
  600. return response_payload(1, str(exc), {"device": device_payload(request), "points": []})
  601. except Exception as exc:
  602. logger.exception("BACnet read_points failed device=%s", device_payload(request))
  603. return response_payload(1, str(exc), {"device": device_payload(request), "points": []})
  604. return response_payload(0, "success", {"device": device_payload(request), "points": points})
  605. def search_points(request: BacnetPointSearchRequest) -> dict[str, Any]:
  606. try:
  607. with _BACNET_LOCK:
  608. points = run_bacnet(search_points_async(request))
  609. except BacnetCommunicationError as exc:
  610. logger.exception("BACnet search_points communication failed device=%s", device_payload(request))
  611. return response_payload(1, str(exc), {"device": device_payload(request), "points": []})
  612. except Exception as exc:
  613. logger.exception("BACnet search_points failed device=%s", device_payload(request))
  614. return response_payload(1, str(exc), {"device": device_payload(request), "points": []})
  615. return response_payload(0, "success", {"device": device_payload(request), "points": points})
  616. def bbmd_whois(request: BacnetBBMDWhoIsRequest | None = None) -> dict[str, Any]:
  617. try:
  618. request = request or bbmd_whois_request_from_env()
  619. with _BACNET_LOCK:
  620. devices = bbmd_whois_devices(request)
  621. except BacnetCommunicationError as exc:
  622. logger.exception("BACnet BBMD whois communication failed bbmd=%s", bbmd_error_payload(request))
  623. return response_payload(1, str(exc), {"bbmd": bbmd_error_payload(request), "devices": []})
  624. except Exception as exc:
  625. logger.exception("BACnet BBMD whois failed bbmd=%s", bbmd_error_payload(request))
  626. return response_payload(1, str(exc), {"bbmd": bbmd_error_payload(request), "devices": []})
  627. return response_payload(0, "success", {"bbmd": bbmd_payload(request), "devices": devices})