Explorar el Código

支持bacnet通过bbmd发送whois

Lu Xianghui hace 1 mes
padre
commit
c536f1aa3c

+ 6 - 0
README.md

@@ -21,6 +21,7 @@ uv sync
 - S7 点位读取和连接组合扫描
 - BACnet/IP 点位读取,固定读取 `present-value`
 - BACnet/IP 点位搜索,读取设备 `object-list` 并返回点位基础信息和值
+- BACnet/IP BBMD Who-Is 设备发现,支持通过 BBMD 查询跨网段 BACnet 设备列表
 
 ## 运行
 
@@ -42,6 +43,7 @@ uvicorn main:app --host 0.0.0.0 --port 8000
 | `POST` | `/s7/connect_scan` | 扫描 S7 可用连接组合 |
 | `POST` | `/bacnet/read_points` | 读取 BACnet 点位 `present-value` |
 | `POST` | `/bacnet/search_points` | 搜索 BACnet 设备点位 |
+| `POST` | `/bacnet/bbmd/whois` | 按环境变量配置通过 BBMD 发送 Who-Is 并返回 I-Am 设备列表 |
 
 示例:
 
@@ -98,6 +100,10 @@ BACnet API 集成测试默认使用真实设备 `192.168.75.240:47808`,设备
 | `BACNET_DEVICE_IP` | `192.168.75.240` |
 | `BACNET_DEVICE_ID` | `12345` |
 | `BACNET_PORT` | `47808` |
+| `BACNET_BBMD_IP` | 无,调用 `/bacnet/bbmd/whois` 时必填 |
+| `BACNET_BBMD_PORT` | `47808` |
+| `BACNET_BBMD_TTL` | `60` |
+| `BACNET_BBMD_WHOIS_TIMEOUT` | `5` |
 | `BACNET_LOCAL_IP` | 自动按到目标设备的路由推断 |
 | `BACNET_LOCAL_MASK` | `24` |
 | `BACNET_LOCAL_PORT` | 自动选择空闲 UDP 端口 |

+ 6 - 1
app/api/bacnet.py

@@ -3,7 +3,7 @@ from typing import Any
 from fastapi import APIRouter
 
 from app.schemas.bacnet import BacnetPointReadRequest, BacnetPointSearchRequest
-from app.services.bacnet_service import read_points, search_points
+from app.services.bacnet_service import bbmd_whois, read_points, search_points
 
 
 router = APIRouter(tags=["bacnet"])
@@ -17,3 +17,8 @@ def bacnet_read_points(request: BacnetPointReadRequest) -> dict[str, Any]:
 @router.post("/bacnet/search_points")
 def bacnet_search_points(request: BacnetPointSearchRequest) -> dict[str, Any]:
     return search_points(request)
+
+
+@router.post("/bacnet/bbmd/whois")
+def bacnet_bbmd_whois() -> dict[str, Any]:
+    return bbmd_whois()

+ 2 - 0
app/main.py

@@ -41,6 +41,8 @@ def create_app() -> FastAPI:
             data = {"available": []}
         elif request.url.path.endswith(("/bacnet/read_points", "/bacnet/search_points")):
             data = {"points": []}
+        elif request.url.path.endswith("/bacnet/bbmd/whois"):
+            data = {"devices": []}
         return JSONResponse(
             status_code=200,
             content=response_payload(1, validation_error_message(exc), data),

+ 34 - 1
app/schemas/bacnet.py

@@ -1,7 +1,7 @@
 from ipaddress import ip_address
 import re
 
-from pydantic import BaseModel, ConfigDict, Field, field_validator
+from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
 
 
 OBJECT_ID_MAX = 4_194_303
@@ -81,3 +81,36 @@ class BacnetPointReadRequest(BacnetBaseRequest):
 
 class BacnetPointSearchRequest(BacnetBaseRequest):
     pass
+
+
+class BacnetBBMDWhoIsRequest(BaseModel):
+    model_config = ConfigDict(extra="forbid")
+
+    bbmd_ip: str = Field(min_length=1)
+    bbmd_port: int = Field(default=47808, ge=1, le=65535)
+    local_ip: str | None = Field(default=None, min_length=1)
+    local_port: int | None = Field(default=None, ge=1, le=65535)
+    ttl: int = Field(default=60, ge=1, le=65535)
+    timeout: float = Field(default=5, gt=0, le=120)
+    low_limit: int | None = Field(default=0, ge=0, le=OBJECT_ID_MAX)
+    high_limit: int | None = Field(default=OBJECT_ID_MAX, ge=0, le=OBJECT_ID_MAX)
+    local_device_id: int | None = Field(default=None, ge=0, le=OBJECT_ID_MAX)
+
+    @field_validator("bbmd_ip", "local_ip")
+    @classmethod
+    def validate_optional_ip(cls, value: str | None) -> str | None:
+        if value is None:
+            return value
+        try:
+            ip_address(value)
+        except ValueError as exc:
+            raise ValueError("must be a valid IP address") from exc
+        return value
+
+    @model_validator(mode="after")
+    def validate_whois_range(self) -> "BacnetBBMDWhoIsRequest":
+        if (self.low_limit is None) != (self.high_limit is None):
+            raise ValueError("low_limit and high_limit must be provided together or both omitted")
+        if self.low_limit is not None and self.high_limit is not None and self.low_limit > self.high_limit:
+            raise ValueError("low_limit must be less than or equal to high_limit")
+        return self

+ 357 - 0
app/services/bacnet_service.py

@@ -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})

+ 84 - 2
docs/bacnet-http-api.md

@@ -2,7 +2,7 @@
 
 ## 目标
 
-本服务通过 HTTP 接口连接 BACnet/IP 设备。BACnet 功能使用 `BAC0` 实现,当前提供点位读取和点位搜索
+本服务通过 HTTP 接口连接 BACnet/IP 设备。点位读取和点位搜索使用 `BAC0` 实现,BBMD Who-Is 设备发现使用 BACnet/IP BVLC 报文实现
 
 当前仅支持 `BACnet/IP`。
 
@@ -10,6 +10,7 @@
 
 - `POST /api/dc-gateway/bacnet/read_points`:按点位对象读取,固定返回 `present-value`。
 - `POST /api/dc-gateway/bacnet/search_points`:读取设备 `object-list`,搜索常见 BACnet 点位对象并返回基础信息和值。
+- `POST /api/dc-gateway/bacnet/bbmd/whois`:按环境变量配置注册 BBMD Foreign Device,通过 BBMD 分发 `Who-Is` 并返回 `I-Am` 设备列表。
 - `GET /api/dc-gateway/health`:健康检查。
 
 ## 通用返回约定
@@ -46,7 +47,7 @@
 | `msg` | string | 成功时为 `success`,失败时为错误信息。 |
 | `data` | object | 接口数据。 |
 
-`data` 固定为对象。两个 BACnet 接口都会在 `data.device` 返回设备信息,点位列表返回在 `data.points`。
+`data` 固定为对象。点位接口会在 `data.device` 返回设备信息,点位列表返回在 `data.points`。BBMD Who-Is 接口会在 `data.bbmd` 返回 BBMD 请求信息,设备列表返回在 `data.devices`。
 
 ## 设备参数校验
 
@@ -253,6 +254,87 @@ POST /api/dc-gateway/bacnet/search_points
 }
 ```
 
+## 接口三:通过 BBMD 查询 BACnet 设备
+
+### URL
+
+```http
+POST /api/dc-gateway/bacnet/bbmd/whois
+```
+
+服务会先向 BBMD 发送 `Register-Foreign-Device`,注册成功后发送 `Distribute-Broadcast-To-Network` 包装的 `Who-Is`,并在配置的超时时间内收集 `I-Am` 响应。请求结束前会用 TTL=0 注销 Foreign Device。
+
+### 请求体
+
+不需要请求体,直接调用即可:
+
+```bash
+curl -X POST http://127.0.0.1:8000/api/dc-gateway/bacnet/bbmd/whois
+```
+
+配置环境变量:
+
+| 环境变量 | 是否必填 | 默认值 | 说明 |
+|---|---:|---|---|
+| `BACNET_BBMD_IP` | 是 | 无 | BBMD 地址。 |
+| `BACNET_BBMD_PORT` | 否 | `47808` | BBMD UDP 端口。 |
+| `BACNET_BBMD_TTL` | 否 | `60` | Foreign Device 注册 TTL,单位秒。 |
+| `BACNET_BBMD_WHOIS_TIMEOUT` | 否 | `5` | 收集 `I-Am` 的等待时间,单位秒。 |
+| `BACNET_BBMD_LOW_LIMIT` | 否 | `0` | Who-Is 设备号下限。 |
+| `BACNET_BBMD_HIGH_LIMIT` | 否 | `4194303` | Who-Is 设备号上限。 |
+| `BACNET_BBMD_LOCAL_DEVICE_ID` | 否 | 无 | 若收到同设备号的 `I-Am`,会作为本机设备忽略。 |
+| `BACNET_LOCAL_IP` | 否 | 自动按到 BBMD 的路由推断 | 本机 BACnet 源地址。 |
+| `BACNET_LOCAL_PORT` | 否 | 自动选择空闲端口 | 本机 UDP 绑定端口。 |
+
+### 成功返回
+
+```json
+{
+  "code": 0,
+  "msg": "success",
+  "data": {
+    "bbmd": {
+      "bbmd_ip": "192.168.1.1",
+      "bbmd_port": 47808,
+      "ttl": 60,
+      "timeout": 5,
+      "low_limit": 0,
+      "high_limit": 4194303
+    },
+    "devices": [
+      {
+        "bacnet_device_id": 12345,
+        "ip": "192.168.10.20",
+        "port": 47808,
+        "max_apdu": 1476,
+        "segmentation": "noSegmentation",
+        "vendor_id": 842
+      }
+    ]
+  }
+}
+```
+
+### 失败返回
+
+```json
+{
+  "code": 1,
+  "msg": "BBMD foreign device registration timeout",
+  "data": {
+    "bbmd": {
+      "bbmd_ip": "192.168.1.1",
+      "bbmd_port": 47808,
+      "ttl": 60,
+      "timeout": 5,
+      "low_limit": 0,
+      "high_limit": 4194303
+    },
+    "devices": []
+  }
+}
+```
+
 ## 本地 BACnet 客户端绑定
 
 网关会为每次 BACnet 请求启动本地 BACnet 客户端,并自动推断到目标设备的本机地址。可通过环境变量覆盖:

+ 15 - 1
intergration/bacnet/test_bacnet_schema.py

@@ -2,7 +2,7 @@ import unittest
 
 from pydantic import ValidationError
 
-from app.schemas.bacnet import BacnetPointReadRequest, BacnetPointSearchRequest, bac0_object_type
+from app.schemas.bacnet import BacnetBBMDWhoIsRequest, BacnetPointReadRequest, BacnetPointSearchRequest, bac0_object_type
 
 
 class BacnetSchemaTest(unittest.TestCase):
@@ -45,6 +45,20 @@ class BacnetSchemaTest(unittest.TestCase):
         self.assertEqual("AnalogInput", request.points[0].object_type)
         self.assertEqual("analogInput", bac0_object_type(request.points[0].object_type))
 
+    def test_bbmd_whois_defaults_to_full_device_range(self):
+        request = BacnetBBMDWhoIsRequest(bbmd_ip="192.168.1.1")
+
+        self.assertEqual(47808, request.bbmd_port)
+        self.assertEqual(60, request.ttl)
+        self.assertEqual(0, request.low_limit)
+        self.assertEqual(4_194_303, request.high_limit)
+
+    def test_bbmd_whois_range_must_be_ordered(self):
+        with self.assertRaises(ValidationError) as context:
+            BacnetBBMDWhoIsRequest(bbmd_ip="192.168.1.1", low_limit=10, high_limit=1)
+
+        self.assertIn("low_limit must be less than or equal to high_limit", str(context.exception))
+
 
 if __name__ == "__main__":
     unittest.main()

+ 58 - 0
intergration/bacnet/test_bacnet_service.py

@@ -1,5 +1,7 @@
 import asyncio
+import os
 import unittest
+from unittest.mock import patch
 
 from app.schemas.bacnet import BacnetPointReadRequest, BacnetPointSearchRequest
 from app.services import bacnet_service
@@ -118,6 +120,62 @@ class BacnetServiceTest(unittest.TestCase):
 
         self.assertFalse(loop.default_handler_called)
 
+    def test_builds_bbmd_whois_as_distributed_local_broadcast(self):
+        packet = bacnet_service.bbmd_whois_packet(0, 4_194_303)
+
+        self.assertEqual(0x81, packet[0])
+        self.assertEqual(0x09, packet[1])
+        self.assertEqual(len(packet), int.from_bytes(packet[2:4], "big"))
+        self.assertEqual(b"\x01\x00\x10\x08", packet[4:8])
+
+    def test_parses_forwarded_i_am_packet(self):
+        original_sender = b"\xc0\xa8\x01\x0a\xba\xc0"
+        object_id = ((8 << 22) | 12345).to_bytes(4, "big")
+        apdu = b"\x10\x00" + b"\xc4" + object_id + b"\x22\x05\xc4" + b"\x91\x03" + b"\x22\x03\x4a"
+        npdu = b"\x01\x00" + apdu
+        payload = original_sender + npdu
+        packet = b"\x81\x04" + (4 + len(payload)).to_bytes(2, "big") + payload
+
+        device = bacnet_service.parse_i_am_packet(packet, ("192.168.1.1", 47808))
+
+        self.assertEqual(
+            {
+                "bacnet_device_id": 12345,
+                "max_apdu": 1476,
+                "segmentation": "noSegmentation",
+                "vendor_id": 842,
+                "ip": "192.168.1.10",
+                "port": 47808,
+            },
+            device,
+        )
+
+    def test_bbmd_whois_requires_configured_bbmd_ip(self):
+        with patch.dict(os.environ, {}, clear=True):
+            response = bacnet_service.bbmd_whois()
+
+        self.assertEqual(1, response["code"])
+        self.assertIn("BACNET_BBMD_IP environment variable is required", response["msg"])
+        self.assertEqual([], response["data"]["devices"])
+
+    def test_builds_bbmd_whois_request_from_environment(self):
+        with patch.dict(
+            os.environ,
+            {
+                "BACNET_BBMD_IP": "192.168.1.1",
+                "BACNET_BBMD_PORT": "47809",
+                "BACNET_BBMD_TTL": "120",
+                "BACNET_BBMD_WHOIS_TIMEOUT": "7.5",
+            },
+            clear=True,
+        ):
+            request = bacnet_service.bbmd_whois_request_from_env()
+
+        self.assertEqual("192.168.1.1", request.bbmd_ip)
+        self.assertEqual(47809, request.bbmd_port)
+        self.assertEqual(120, request.ttl)
+        self.assertEqual(7.5, request.timeout)
+
 
 if __name__ == "__main__":
     unittest.main()