|
|
@@ -4,7 +4,9 @@ import asyncio
|
|
|
import logging
|
|
|
import os
|
|
|
import socket
|
|
|
+import struct
|
|
|
import threading
|
|
|
+import time
|
|
|
import warnings
|
|
|
from contextlib import closing
|
|
|
from contextlib import contextmanager
|
|
|
@@ -28,6 +30,7 @@ from bacpypes3.primitivedata import ObjectIdentifier
|
|
|
|
|
|
from app.response import response_payload
|
|
|
from app.schemas.bacnet import (
|
|
|
+ BacnetBBMDWhoIsRequest,
|
|
|
BacnetPointReadRequest,
|
|
|
BacnetPointSearchRequest,
|
|
|
BacnetPointSpec,
|
|
|
@@ -48,6 +51,27 @@ POINT_OBJECT_TYPES = {
|
|
|
"MultiStateValue",
|
|
|
}
|
|
|
|
|
|
+BVLC_TYPE_BACNET_IP = 0x81
|
|
|
+BVLC_RESULT = 0x00
|
|
|
+BVLC_FORWARDED_NPDU = 0x04
|
|
|
+BVLC_REGISTER_FOREIGN_DEVICE = 0x05
|
|
|
+BVLC_DISTRIBUTE_BROADCAST_TO_NETWORK = 0x09
|
|
|
+BVLC_ORIGINAL_UNICAST_NPDU = 0x0A
|
|
|
+BVLC_ORIGINAL_BROADCAST_NPDU = 0x0B
|
|
|
+BVLC_RESULT_SUCCESS = 0x0000
|
|
|
+APDU_UNCONFIRMED_REQUEST = 0x10
|
|
|
+SERVICE_UNCONFIRMED_I_AM = 0x00
|
|
|
+SERVICE_UNCONFIRMED_WHO_IS = 0x08
|
|
|
+OBJECT_TYPE_DEVICE = 8
|
|
|
+OBJECT_ID_INSTANCE_MASK = 0x3FFFFF
|
|
|
+
|
|
|
+SEGMENTATION_SUPPORT = {
|
|
|
+ 0: "segmentedBoth",
|
|
|
+ 1: "segmentedTransmit",
|
|
|
+ 2: "segmentedReceive",
|
|
|
+ 3: "noSegmentation",
|
|
|
+}
|
|
|
+
|
|
|
_BACNET_LOCK = threading.Lock()
|
|
|
logger = logging.getLogger("uvicorn.error")
|
|
|
|
|
|
@@ -72,6 +96,61 @@ def device_payload(request: BacnetPointReadRequest | BacnetPointSearchRequest) -
|
|
|
}
|
|
|
|
|
|
|
|
|
+def bbmd_payload(request: BacnetBBMDWhoIsRequest) -> dict[str, Any]:
|
|
|
+ return {
|
|
|
+ "bbmd_ip": request.bbmd_ip,
|
|
|
+ "bbmd_port": request.bbmd_port,
|
|
|
+ "ttl": request.ttl,
|
|
|
+ "timeout": request.timeout,
|
|
|
+ "low_limit": request.low_limit,
|
|
|
+ "high_limit": request.high_limit,
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def bbmd_error_payload(request: BacnetBBMDWhoIsRequest | None) -> dict[str, Any]:
|
|
|
+ if request is not None:
|
|
|
+ return bbmd_payload(request)
|
|
|
+ return {
|
|
|
+ "bbmd_ip": os.getenv("BACNET_BBMD_IP"),
|
|
|
+ "bbmd_port": os.getenv("BACNET_BBMD_PORT", "47808"),
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def env_int(name: str, default: int | None = None) -> int | None:
|
|
|
+ value = os.getenv(name)
|
|
|
+ if value is None or value == "":
|
|
|
+ return default
|
|
|
+ try:
|
|
|
+ return int(value)
|
|
|
+ except ValueError as exc:
|
|
|
+ raise BacnetCommunicationError(f"{name} must be an integer") from exc
|
|
|
+
|
|
|
+
|
|
|
+def env_float(name: str, default: float) -> float:
|
|
|
+ value = os.getenv(name)
|
|
|
+ if value is None or value == "":
|
|
|
+ return default
|
|
|
+ try:
|
|
|
+ return float(value)
|
|
|
+ except ValueError as exc:
|
|
|
+ raise BacnetCommunicationError(f"{name} must be a number") from exc
|
|
|
+
|
|
|
+
|
|
|
+def bbmd_whois_request_from_env() -> BacnetBBMDWhoIsRequest:
|
|
|
+ bbmd_ip = os.getenv("BACNET_BBMD_IP")
|
|
|
+ if not bbmd_ip:
|
|
|
+ raise BacnetCommunicationError("BACNET_BBMD_IP environment variable is required")
|
|
|
+ return BacnetBBMDWhoIsRequest(
|
|
|
+ bbmd_ip=bbmd_ip,
|
|
|
+ bbmd_port=env_int("BACNET_BBMD_PORT", 47808),
|
|
|
+ ttl=env_int("BACNET_BBMD_TTL", 60),
|
|
|
+ timeout=env_float("BACNET_BBMD_WHOIS_TIMEOUT", 5),
|
|
|
+ low_limit=env_int("BACNET_BBMD_LOW_LIMIT", 0),
|
|
|
+ high_limit=env_int("BACNET_BBMD_HIGH_LIMIT", OBJECT_ID_INSTANCE_MASK),
|
|
|
+ local_device_id=env_int("BACNET_BBMD_LOCAL_DEVICE_ID"),
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
def bacnet_address(ip: str, port: int) -> str:
|
|
|
return f"{ip}:{port}"
|
|
|
|
|
|
@@ -130,6 +209,237 @@ def get_free_udp_port() -> int:
|
|
|
return int(sock.getsockname()[1])
|
|
|
|
|
|
|
|
|
+def create_bbmd_socket(local_ip: str, local_port: int) -> socket.socket:
|
|
|
+ bind_ip = os.getenv("BACNET_BIND_IP", local_ip)
|
|
|
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
|
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
|
+ if hasattr(socket, "SO_REUSEPORT"):
|
|
|
+ try:
|
|
|
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
|
|
|
+ except OSError:
|
|
|
+ pass
|
|
|
+ sock.bind((bind_ip, local_port))
|
|
|
+ return sock
|
|
|
+
|
|
|
+
|
|
|
+def bvlc_packet(function: int, payload: bytes) -> bytes:
|
|
|
+ return struct.pack(">BBH", BVLC_TYPE_BACNET_IP, function, 4 + len(payload)) + payload
|
|
|
+
|
|
|
+
|
|
|
+def register_foreign_device_packet(ttl: int) -> bytes:
|
|
|
+ return bvlc_packet(BVLC_REGISTER_FOREIGN_DEVICE, struct.pack(">H", ttl))
|
|
|
+
|
|
|
+
|
|
|
+def context_unsigned(tag: int, value: int) -> bytes:
|
|
|
+ if not 0 <= tag <= 14:
|
|
|
+ raise ValueError("BACnet context tag must be between 0 and 14")
|
|
|
+ if not 0 <= value <= 0xFFFFFFFF:
|
|
|
+ raise ValueError("BACnet unsigned context value out of range")
|
|
|
+ if value <= 0xFF:
|
|
|
+ data = value.to_bytes(1, "big")
|
|
|
+ elif value <= 0xFFFF:
|
|
|
+ data = value.to_bytes(2, "big")
|
|
|
+ else:
|
|
|
+ data = value.to_bytes(4, "big")
|
|
|
+ return bytes([(tag << 4) | 0x08 | len(data)]) + data
|
|
|
+
|
|
|
+
|
|
|
+def whois_apdu(low_limit: int | None, high_limit: int | None) -> bytes:
|
|
|
+ payload = bytearray([APDU_UNCONFIRMED_REQUEST, SERVICE_UNCONFIRMED_WHO_IS])
|
|
|
+ if low_limit is not None and high_limit is not None:
|
|
|
+ payload.extend(context_unsigned(0, low_limit))
|
|
|
+ payload.extend(context_unsigned(1, high_limit))
|
|
|
+ return bytes(payload)
|
|
|
+
|
|
|
+
|
|
|
+def bbmd_whois_packet(low_limit: int | None, high_limit: int | None) -> bytes:
|
|
|
+ # Match bacpypes LocalBroadcast(): BIPForeign wraps this NPDU in Distribute-Broadcast-To-Network.
|
|
|
+ npdu = b"\x01\x00" + whois_apdu(low_limit, high_limit)
|
|
|
+ return bvlc_packet(BVLC_DISTRIBUTE_BROADCAST_TO_NETWORK, npdu)
|
|
|
+
|
|
|
+
|
|
|
+def parse_bvlc_result(packet: bytes) -> int | None:
|
|
|
+ if len(packet) < 6 or packet[0] != BVLC_TYPE_BACNET_IP or packet[1] != BVLC_RESULT:
|
|
|
+ return None
|
|
|
+ return int.from_bytes(packet[4:6], "big")
|
|
|
+
|
|
|
+
|
|
|
+def udp_endpoint_from_bytes(data: bytes) -> tuple[str, int]:
|
|
|
+ return socket.inet_ntoa(data[:4]), int.from_bytes(data[4:6], "big")
|
|
|
+
|
|
|
+
|
|
|
+def npdu_apdu(packet: bytes) -> bytes | None:
|
|
|
+ if len(packet) < 2 or packet[0] != 0x01:
|
|
|
+ return None
|
|
|
+ control = packet[1]
|
|
|
+ offset = 2
|
|
|
+ has_destination = bool(control & 0x20)
|
|
|
+ if has_destination:
|
|
|
+ if len(packet) < offset + 3:
|
|
|
+ return None
|
|
|
+ dlen = packet[offset + 2]
|
|
|
+ offset += 3 + dlen
|
|
|
+ if control & 0x08:
|
|
|
+ if len(packet) < offset + 3:
|
|
|
+ return None
|
|
|
+ slen = packet[offset + 2]
|
|
|
+ offset += 3 + slen
|
|
|
+ if has_destination:
|
|
|
+ offset += 1
|
|
|
+ if control & 0x80:
|
|
|
+ return None
|
|
|
+ if offset >= len(packet):
|
|
|
+ return None
|
|
|
+ return packet[offset:]
|
|
|
+
|
|
|
+
|
|
|
+def read_application_tag(packet: bytes, offset: int) -> tuple[int, bool, bytes, int] | None:
|
|
|
+ if offset >= len(packet):
|
|
|
+ return None
|
|
|
+ first = packet[offset]
|
|
|
+ offset += 1
|
|
|
+ tag = first >> 4
|
|
|
+ class_tag = bool(first & 0x08)
|
|
|
+ length = first & 0x07
|
|
|
+ if tag == 0x0F:
|
|
|
+ if offset >= len(packet):
|
|
|
+ return None
|
|
|
+ tag = packet[offset]
|
|
|
+ offset += 1
|
|
|
+ if length == 5:
|
|
|
+ if offset >= len(packet):
|
|
|
+ return None
|
|
|
+ length = packet[offset]
|
|
|
+ offset += 1
|
|
|
+ elif length in (6, 7):
|
|
|
+ return None
|
|
|
+ if len(packet) < offset + length:
|
|
|
+ return None
|
|
|
+ value = packet[offset : offset + length]
|
|
|
+ return tag, class_tag, value, offset + length
|
|
|
+
|
|
|
+
|
|
|
+def parse_i_am_apdu(apdu: bytes) -> dict[str, Any] | None:
|
|
|
+ if len(apdu) < 2 or apdu[0] != APDU_UNCONFIRMED_REQUEST or apdu[1] != SERVICE_UNCONFIRMED_I_AM:
|
|
|
+ return None
|
|
|
+
|
|
|
+ offset = 2
|
|
|
+ object_id: int | None = None
|
|
|
+ max_apdu: int | None = None
|
|
|
+ segmentation: int | None = None
|
|
|
+ vendor_id: int | None = None
|
|
|
+
|
|
|
+ for index in range(4):
|
|
|
+ tag = read_application_tag(apdu, offset)
|
|
|
+ if tag is None:
|
|
|
+ return None
|
|
|
+ tag_number, class_tag, value, offset = tag
|
|
|
+ if class_tag:
|
|
|
+ return None
|
|
|
+ if index == 0 and tag_number == 12 and len(value) == 4:
|
|
|
+ raw_object_id = int.from_bytes(value, "big")
|
|
|
+ if raw_object_id >> 22 != OBJECT_TYPE_DEVICE:
|
|
|
+ return None
|
|
|
+ object_id = raw_object_id & OBJECT_ID_INSTANCE_MASK
|
|
|
+ elif index == 1 and tag_number == 2:
|
|
|
+ max_apdu = int.from_bytes(value, "big")
|
|
|
+ elif index == 2 and tag_number == 9:
|
|
|
+ segmentation = int.from_bytes(value, "big")
|
|
|
+ elif index == 3 and tag_number == 2:
|
|
|
+ vendor_id = int.from_bytes(value, "big")
|
|
|
+ else:
|
|
|
+ return None
|
|
|
+
|
|
|
+ if object_id is None or max_apdu is None or segmentation is None or vendor_id is None:
|
|
|
+ return None
|
|
|
+ return {
|
|
|
+ "bacnet_device_id": object_id,
|
|
|
+ "max_apdu": max_apdu,
|
|
|
+ "segmentation": SEGMENTATION_SUPPORT.get(segmentation, str(segmentation)),
|
|
|
+ "vendor_id": vendor_id,
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def parse_i_am_packet(packet: bytes, source: tuple[str, int]) -> dict[str, Any] | None:
|
|
|
+ if len(packet) < 4 or packet[0] != BVLC_TYPE_BACNET_IP:
|
|
|
+ return None
|
|
|
+ length = int.from_bytes(packet[2:4], "big")
|
|
|
+ if length > len(packet):
|
|
|
+ return None
|
|
|
+ function = packet[1]
|
|
|
+ payload = packet[4:length]
|
|
|
+ device_ip, device_port = source
|
|
|
+
|
|
|
+ if function == BVLC_FORWARDED_NPDU:
|
|
|
+ if len(payload) < 6:
|
|
|
+ return None
|
|
|
+ device_ip, device_port = udp_endpoint_from_bytes(payload[:6])
|
|
|
+ payload = payload[6:]
|
|
|
+ elif function not in (BVLC_ORIGINAL_UNICAST_NPDU, BVLC_ORIGINAL_BROADCAST_NPDU):
|
|
|
+ return None
|
|
|
+
|
|
|
+ apdu = npdu_apdu(payload)
|
|
|
+ if apdu is None:
|
|
|
+ return None
|
|
|
+ device = parse_i_am_apdu(apdu)
|
|
|
+ if device is None:
|
|
|
+ return None
|
|
|
+ device["ip"] = device_ip
|
|
|
+ device["port"] = device_port
|
|
|
+ return device
|
|
|
+
|
|
|
+
|
|
|
+def wait_for_bbmd_registration(sock: socket.socket, timeout: float) -> None:
|
|
|
+ deadline = time.monotonic() + timeout
|
|
|
+ while True:
|
|
|
+ remaining = deadline - time.monotonic()
|
|
|
+ if remaining <= 0:
|
|
|
+ raise BacnetCommunicationError("BBMD foreign device registration timeout")
|
|
|
+ sock.settimeout(remaining)
|
|
|
+ try:
|
|
|
+ packet, _source = sock.recvfrom(2048)
|
|
|
+ except socket.timeout as exc:
|
|
|
+ raise BacnetCommunicationError("BBMD foreign device registration timeout") from exc
|
|
|
+ result = parse_bvlc_result(packet)
|
|
|
+ if result is None:
|
|
|
+ continue
|
|
|
+ if result != BVLC_RESULT_SUCCESS:
|
|
|
+ raise BacnetCommunicationError(f"BBMD foreign device registration failed with code {result}")
|
|
|
+ return
|
|
|
+
|
|
|
+
|
|
|
+def collect_bbmd_i_ams(
|
|
|
+ sock: socket.socket,
|
|
|
+ timeout: float,
|
|
|
+ low_limit: int | None,
|
|
|
+ high_limit: int | None,
|
|
|
+ local_device_id: int | None,
|
|
|
+) -> list[dict[str, Any]]:
|
|
|
+ devices: dict[tuple[int, str, int], dict[str, Any]] = {}
|
|
|
+ deadline = time.monotonic() + timeout
|
|
|
+ while True:
|
|
|
+ remaining = deadline - time.monotonic()
|
|
|
+ if remaining <= 0:
|
|
|
+ return list(devices.values())
|
|
|
+ sock.settimeout(remaining)
|
|
|
+ try:
|
|
|
+ packet, source = sock.recvfrom(2048)
|
|
|
+ except socket.timeout:
|
|
|
+ return list(devices.values())
|
|
|
+
|
|
|
+ device = parse_i_am_packet(packet, (source[0], source[1]))
|
|
|
+ if device is None:
|
|
|
+ continue
|
|
|
+ device_id = device["bacnet_device_id"]
|
|
|
+ if device_id == 0:
|
|
|
+ continue
|
|
|
+ if local_device_id is not None and device_id == local_device_id:
|
|
|
+ continue
|
|
|
+ if low_limit is not None and high_limit is not None and not low_limit <= device_id <= high_limit:
|
|
|
+ continue
|
|
|
+ devices[(device_id, device["ip"], device["port"])] = device
|
|
|
+
|
|
|
+
|
|
|
def create_bacnet_bind_socket(port: int) -> socket.socket:
|
|
|
bind_ip = os.getenv("BACNET_BIND_IP", "0.0.0.0")
|
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
|
@@ -351,6 +661,39 @@ async def search_points_async(request: BacnetPointSearchRequest) -> list[dict[st
|
|
|
return points
|
|
|
|
|
|
|
|
|
+def bbmd_whois_devices(request: BacnetBBMDWhoIsRequest) -> list[dict[str, Any]]:
|
|
|
+ local_ip = request.local_ip or get_local_ip(request.bbmd_ip, request.bbmd_port)
|
|
|
+ local_port = request.local_port or get_free_udp_port()
|
|
|
+ bbmd_address = (request.bbmd_ip, request.bbmd_port)
|
|
|
+ logger.info(
|
|
|
+ "BACnet BBMD whois start bbmd=%s:%s local_ip=%s local_port=%s ttl=%s timeout=%s",
|
|
|
+ request.bbmd_ip,
|
|
|
+ request.bbmd_port,
|
|
|
+ local_ip,
|
|
|
+ local_port,
|
|
|
+ request.ttl,
|
|
|
+ request.timeout,
|
|
|
+ )
|
|
|
+
|
|
|
+ with closing(create_bbmd_socket(local_ip, local_port)) as sock:
|
|
|
+ sock.sendto(register_foreign_device_packet(request.ttl), bbmd_address)
|
|
|
+ wait_for_bbmd_registration(sock, min(request.timeout, 5.0))
|
|
|
+ try:
|
|
|
+ sock.sendto(bbmd_whois_packet(request.low_limit, request.high_limit), bbmd_address)
|
|
|
+ return collect_bbmd_i_ams(
|
|
|
+ sock,
|
|
|
+ request.timeout,
|
|
|
+ request.low_limit,
|
|
|
+ request.high_limit,
|
|
|
+ request.local_device_id,
|
|
|
+ )
|
|
|
+ finally:
|
|
|
+ try:
|
|
|
+ sock.sendto(register_foreign_device_packet(0), bbmd_address)
|
|
|
+ except OSError:
|
|
|
+ logger.debug("BACnet BBMD unregister failed", exc_info=True)
|
|
|
+
|
|
|
+
|
|
|
def read_points(request: BacnetPointReadRequest) -> dict[str, Any]:
|
|
|
try:
|
|
|
with _BACNET_LOCK:
|
|
|
@@ -375,3 +718,17 @@ def search_points(request: BacnetPointSearchRequest) -> dict[str, Any]:
|
|
|
logger.exception("BACnet search_points failed device=%s", device_payload(request))
|
|
|
return response_payload(1, str(exc), {"device": device_payload(request), "points": []})
|
|
|
return response_payload(0, "success", {"device": device_payload(request), "points": points})
|
|
|
+
|
|
|
+
|
|
|
+def bbmd_whois(request: BacnetBBMDWhoIsRequest | None = None) -> dict[str, Any]:
|
|
|
+ try:
|
|
|
+ request = request or bbmd_whois_request_from_env()
|
|
|
+ with _BACNET_LOCK:
|
|
|
+ devices = bbmd_whois_devices(request)
|
|
|
+ except BacnetCommunicationError as exc:
|
|
|
+ logger.exception("BACnet BBMD whois communication failed bbmd=%s", bbmd_error_payload(request))
|
|
|
+ return response_payload(1, str(exc), {"bbmd": bbmd_error_payload(request), "devices": []})
|
|
|
+ except Exception as exc:
|
|
|
+ logger.exception("BACnet BBMD whois failed bbmd=%s", bbmd_error_payload(request))
|
|
|
+ return response_payload(1, str(exc), {"bbmd": bbmd_error_payload(request), "devices": []})
|
|
|
+ return response_payload(0, "success", {"bbmd": bbmd_payload(request), "devices": devices})
|