Browse Source

新增bacnet 读取数据

Lu Xianghui 1 month ago
parent
commit
2b283cb824

+ 15 - 2
README.md

@@ -1,6 +1,6 @@
 # Data Collector Gateway
 
-基于 FastAPI 的数据采集 HTTP 网关,用于通过 HTTP 接口读取 Modbus TCP 和 Siemens S7 TCP 设备。
+基于 FastAPI 的数据采集 HTTP 网关,用于通过 HTTP 接口读取 Modbus TCP、Siemens S7 TCP 和 BACnet/IP 设备。
 
 ## 环境要求
 
@@ -19,6 +19,8 @@ uv sync
 - 支持 Modbus 功能码 `1=线圈输入`、`2=离散量输入`、`3=保持寄存器`、`4=输入寄存器`
 - S7 原始字节读取,返回连接、读取、断开的语义化 `communication`
 - S7 点位读取和连接组合扫描
+- BACnet/IP 点位读取,固定读取 `present-value`
+- BACnet/IP 点位搜索,读取设备 `object-list` 并返回点位基础信息和值
 
 ## 运行
 
@@ -38,6 +40,8 @@ uvicorn main:app --host 0.0.0.0 --port 8000
 | `POST` | `/s7/read` | 原始 S7 字节读取,返回语义化 `communication` |
 | `POST` | `/s7/read_points` | 读取 S7 点位并返回转换后的值 |
 | `POST` | `/s7/connect_scan` | 扫描 S7 可用连接组合 |
+| `POST` | `/bacnet/read_points` | 读取 BACnet 点位 `present-value` |
+| `POST` | `/bacnet/search_points` | 搜索 BACnet 设备点位 |
 
 示例:
 
@@ -59,7 +63,7 @@ curl -X POST http://127.0.0.1:8000/api/dc-gateway/modbus/read \
   }'
 ```
 
-完整请求和响应约定见 [docs/modbus-http-api.md](docs/modbus-http-api.md) 和 [docs/s7-http-api.md](docs/s7-http-api.md)。
+完整请求和响应约定见 [docs/modbus-http-api.md](docs/modbus-http-api.md)、[docs/s7-http-api.md](docs/s7-http-api.md) 和 [docs/bacnet-http-api.md](docs/bacnet-http-api.md)。
 
 ## 测试
 
@@ -74,12 +78,15 @@ python -m compileall app main.py
 ```bash
 python -m unittest discover -s intergration/modbus -p "test_*.py"
 python -m unittest discover -s intergration/s7 -p "test_*.py"
+python -m unittest discover -s intergration/bacnet -p "test_*.py"
 ```
 
 Modbus API 集成测试会自动启动本项目 HTTP 服务,默认使用 `D:\Projects\BACnet-Client\autotest\Integration\modbus` 中相同的真实设备参数和点位地址。
 
 S7 测试会在本机启动 `python-snap7` 模拟 server,不依赖真实 PLC。
 
+BACnet API 集成测试默认使用真实设备 `192.168.75.240:47808`,设备号 `12345`。
+
 环境变量:
 
 | 名称 | 默认值 |
@@ -88,4 +95,10 @@ S7 测试会在本机启动 `python-snap7` 模拟 server,不依赖真实 PLC
 | `MODBUS_DEVICE_IP` | `192.168.75.240` |
 | `MODBUS_TCP_PORT` | `505` |
 | `MODBUS_SLAVE_ID` | `1` |
+| `BACNET_DEVICE_IP` | `192.168.75.240` |
+| `BACNET_DEVICE_ID` | `12345` |
+| `BACNET_PORT` | `47808` |
+| `BACNET_LOCAL_IP` | 自动按到目标设备的路由推断 |
+| `BACNET_LOCAL_MASK` | `24` |
+| `BACNET_LOCAL_PORT` | 自动选择空闲 UDP 端口 |
 | `REQUEST_TIMEOUT` | `20` |

+ 19 - 0
app/api/bacnet.py

@@ -0,0 +1,19 @@
+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
+
+
+router = APIRouter(tags=["bacnet"])
+
+
+@router.post("/bacnet/read_points")
+def bacnet_read_points(request: BacnetPointReadRequest) -> dict[str, Any]:
+    return read_points(request)
+
+
+@router.post("/bacnet/search_points")
+def bacnet_search_points(request: BacnetPointSearchRequest) -> dict[str, Any]:
+    return search_points(request)

+ 4 - 0
app/main.py

@@ -2,6 +2,7 @@ from fastapi import FastAPI, Request
 from fastapi.exceptions import RequestValidationError
 from fastapi.responses import JSONResponse
 
+from app.api.bacnet import router as bacnet_router
 from app.api.modbus import router as modbus_router
 from app.api.s7 import router as s7_router
 from app.response import response_payload
@@ -20,6 +21,7 @@ def create_app() -> FastAPI:
     app = FastAPI(title="Data Collector Gateway")
     app.include_router(modbus_router, prefix="/api/dc-gateway")
     app.include_router(s7_router, prefix="/api/dc-gateway")
+    app.include_router(bacnet_router, prefix="/api/dc-gateway")
 
     @app.exception_handler(RequestValidationError)
     async def request_validation_exception_handler(
@@ -37,6 +39,8 @@ def create_app() -> FastAPI:
             data = {"points": []}
         elif request.url.path.endswith("/s7/connect_scan"):
             data = {"available": []}
+        elif request.url.path.endswith(("/bacnet/read_points", "/bacnet/search_points")):
+            data = {"points": []}
         return JSONResponse(
             status_code=200,
             content=response_payload(1, validation_error_message(exc), data),

+ 83 - 0
app/schemas/bacnet.py

@@ -0,0 +1,83 @@
+from ipaddress import ip_address
+import re
+
+from pydantic import BaseModel, ConfigDict, Field, field_validator
+
+
+OBJECT_ID_MAX = 4_194_303
+
+OBJECT_TYPE_ALIASES = {
+    "analoginput": ("AnalogInput", "analogInput"),
+    "analogoutput": ("AnalogOutput", "analogOutput"),
+    "analogvalue": ("AnalogValue", "analogValue"),
+    "binaryinput": ("BinaryInput", "binaryInput"),
+    "binaryoutput": ("BinaryOutput", "binaryOutput"),
+    "binaryvalue": ("BinaryValue", "binaryValue"),
+    "multistateinput": ("MultiStateInput", "multiStateInput"),
+    "multistateoutput": ("MultiStateOutput", "multiStateOutput"),
+    "multistatevalue": ("MultiStateValue", "multiStateValue"),
+    "device": ("Device", "device"),
+    "calendar": ("Calendar", "calendar"),
+    "schedule": ("Schedule", "schedule"),
+    "trendlog": ("TrendLog", "trendLog"),
+    "notificationclass": ("NotificationClass", "notificationClass"),
+}
+
+
+def object_type_key(value: str) -> str:
+    return re.sub(r"[^0-9a-z]", "", value.lower())
+
+
+def normalize_object_type(value: str) -> str:
+    key = object_type_key(value)
+    if key in OBJECT_TYPE_ALIASES:
+        return OBJECT_TYPE_ALIASES[key][0]
+    if not re.fullmatch(r"[A-Za-z][0-9A-Za-z_-]*", value):
+        raise ValueError("object_type must be a valid BACnet object type")
+    parts = re.split(r"[-_]", value)
+    return "".join(part[:1].upper() + part[1:] for part in parts if part)
+
+
+def bac0_object_type(value: str) -> str:
+    key = object_type_key(value)
+    if key in OBJECT_TYPE_ALIASES:
+        return OBJECT_TYPE_ALIASES[key][1]
+    normalized = normalize_object_type(value)
+    return normalized[:1].lower() + normalized[1:]
+
+
+class BacnetBaseRequest(BaseModel):
+    model_config = ConfigDict(extra="forbid")
+
+    ip: str = Field(min_length=1)
+    bacnet_device_id: int = Field(ge=0, le=OBJECT_ID_MAX)
+    port: int = Field(default=47808, ge=1, le=65535)
+
+    @field_validator("ip")
+    @classmethod
+    def validate_ip(cls, value: str) -> str:
+        try:
+            ip_address(value)
+        except ValueError as exc:
+            raise ValueError("ip must be a valid IP address") from exc
+        return value
+
+
+class BacnetPointSpec(BaseModel):
+    model_config = ConfigDict(extra="forbid")
+
+    object_type: str
+    object_id: int = Field(ge=0, le=OBJECT_ID_MAX)
+
+    @field_validator("object_type")
+    @classmethod
+    def validate_object_type(cls, value: str) -> str:
+        return normalize_object_type(value)
+
+
+class BacnetPointReadRequest(BacnetBaseRequest):
+    points: list[BacnetPointSpec] = Field(min_length=1)
+
+
+class BacnetPointSearchRequest(BacnetBaseRequest):
+    pass

+ 203 - 0
app/services/bacnet_service.py

@@ -0,0 +1,203 @@
+from __future__ import annotations
+
+import asyncio
+import os
+import socket
+import threading
+import warnings
+from contextlib import closing
+from typing import Any
+
+BAC0_REGISTERED_WARNING = r"object type .* for vendor identifier 842 already registered.*"
+
+warnings.filterwarnings("ignore", message=BAC0_REGISTERED_WARNING)
+
+import BAC0
+
+from app.response import response_payload
+from app.schemas.bacnet import (
+    BacnetPointReadRequest,
+    BacnetPointSearchRequest,
+    BacnetPointSpec,
+    bac0_object_type,
+    normalize_object_type,
+)
+
+
+POINT_OBJECT_TYPES = {
+    "AnalogInput",
+    "AnalogOutput",
+    "AnalogValue",
+    "BinaryInput",
+    "BinaryOutput",
+    "BinaryValue",
+    "MultiStateInput",
+    "MultiStateOutput",
+    "MultiStateValue",
+}
+
+_BACNET_LOCK = threading.Lock()
+
+
+try:
+    BAC0.log_level("silence")
+except Exception:
+    pass
+
+
+def device_payload(request: BacnetPointReadRequest | BacnetPointSearchRequest) -> dict[str, Any]:
+    return {
+        "device_type": "BACnet/IP",
+        "ip": request.ip,
+        "port": request.port,
+        "bacnet_device_id": request.bacnet_device_id,
+    }
+
+
+def bacnet_address(ip: str, port: int) -> str:
+    return f"{ip}:{port}"
+
+
+def property_read_args(address: str, object_type: str, object_id: int, property_name: str) -> str:
+    return f"{address} {bac0_object_type(object_type)} {object_id} {property_name}"
+
+
+def get_local_ip(remote_ip: str, remote_port: int) -> str:
+    configured_ip = os.getenv("BACNET_LOCAL_IP")
+    if configured_ip:
+        return configured_ip
+    with closing(socket.socket(socket.AF_INET, socket.SOCK_DGRAM)) as sock:
+        sock.connect((remote_ip, remote_port))
+        return str(sock.getsockname()[0])
+
+
+def get_free_udp_port() -> int:
+    configured_port = os.getenv("BACNET_LOCAL_PORT")
+    if configured_port:
+        return int(configured_port)
+    with closing(socket.socket(socket.AF_INET, socket.SOCK_DGRAM)) as sock:
+        sock.bind(("", 0))
+        return int(sock.getsockname()[1])
+
+
+def local_bacnet_ip(remote_ip: str, remote_port: int) -> str:
+    local_ip = get_local_ip(remote_ip, remote_port)
+    local_mask = int(os.getenv("BACNET_LOCAL_MASK", "24"))
+    return f"{local_ip}/{local_mask}"
+
+
+def json_value(value: Any) -> Any:
+    if value is None or isinstance(value, bool | int | float | str):
+        return value
+    if isinstance(value, list | tuple):
+        return [json_value(item) for item in value]
+    if isinstance(value, dict):
+        return {str(key): json_value(item) for key, item in value.items()}
+    if hasattr(value, "value") and isinstance(value.value, bool | int | float | str):
+        return value.value
+    return str(value)
+
+
+async def read_property_or_none(bacnet: Any, address: str, object_type: str, object_id: int, property_name: str) -> Any:
+    try:
+        value = await bacnet.read(property_read_args(address, object_type, object_id, property_name))
+    except Exception:
+        return None
+    return json_value(value)
+
+
+async def read_present_value(bacnet: Any, address: str, point: BacnetPointSpec) -> Any:
+    return json_value(await bacnet.read(property_read_args(address, point.object_type, point.object_id, "presentValue")))
+
+
+def split_object_identifier(value: Any) -> tuple[str, int] | None:
+    if isinstance(value, list | tuple) and len(value) == 2:
+        raw_type, raw_id = value
+        try:
+            return normalize_object_type(str(raw_type).replace("ObjectType.", "")), int(raw_id)
+        except (TypeError, ValueError):
+            return None
+
+    text = str(value).strip().strip("()")
+    if "," in text:
+        raw_type, raw_id = text.split(",", 1)
+    elif ":" in text:
+        raw_type, raw_id = text.split(":", 1)
+    else:
+        return None
+
+    raw_type = raw_type.strip().strip("'\"").replace("ObjectType.", "")
+    raw_id = raw_id.strip().strip("'\"")
+    try:
+        return normalize_object_type(raw_type), int(raw_id)
+    except (TypeError, ValueError):
+        return None
+
+
+async def read_points_async(request: BacnetPointReadRequest) -> list[dict[str, Any]]:
+    address = bacnet_address(request.ip, request.port)
+    local_ip = local_bacnet_ip(request.ip, request.port)
+    local_port = get_free_udp_port()
+    points: list[dict[str, Any]] = []
+
+    with warnings.catch_warnings():
+        warnings.filterwarnings("ignore", message=BAC0_REGISTERED_WARNING)
+        async with BAC0.start(ip=local_ip, port=local_port, ping=False) as bacnet:
+            for point in request.points:
+                points.append(
+                    {
+                        "object_type": point.object_type,
+                        "object_id": point.object_id,
+                        "present_value": await read_present_value(bacnet, address, point),
+                    }
+                )
+    return points
+
+
+async def search_points_async(request: BacnetPointSearchRequest) -> list[dict[str, Any]]:
+    address = bacnet_address(request.ip, request.port)
+    local_ip = local_bacnet_ip(request.ip, request.port)
+    local_port = get_free_udp_port()
+    points: list[dict[str, Any]] = []
+
+    with warnings.catch_warnings():
+        warnings.filterwarnings("ignore", message=BAC0_REGISTERED_WARNING)
+        async with BAC0.start(ip=local_ip, port=local_port, ping=False) as bacnet:
+            object_list = await bacnet.read(
+                property_read_args(address, "Device", request.bacnet_device_id, "objectList")
+            )
+            for item in object_list or []:
+                parsed = split_object_identifier(item)
+                if parsed is None:
+                    continue
+                object_type, object_id = parsed
+                if object_type not in POINT_OBJECT_TYPES:
+                    continue
+                points.append(
+                    {
+                        "name": await read_property_or_none(bacnet, address, object_type, object_id, "objectName"),
+                        "description": await read_property_or_none(bacnet, address, object_type, object_id, "description"),
+                        "object_type": object_type,
+                        "object_id": object_id,
+                        "present_value": await read_property_or_none(bacnet, address, object_type, object_id, "presentValue"),
+                    }
+                )
+    return points
+
+
+def read_points(request: BacnetPointReadRequest) -> dict[str, Any]:
+    try:
+        with _BACNET_LOCK:
+            points = asyncio.run(read_points_async(request))
+    except Exception as exc:
+        return response_payload(1, str(exc), {"device": device_payload(request), "points": []})
+    return response_payload(0, "success", {"device": device_payload(request), "points": points})
+
+
+def search_points(request: BacnetPointSearchRequest) -> dict[str, Any]:
+    try:
+        with _BACNET_LOCK:
+            points = asyncio.run(search_points_async(request))
+    except Exception as exc:
+        return response_payload(1, str(exc), {"device": device_payload(request), "points": []})
+    return response_payload(0, "success", {"device": device_payload(request), "points": points})

+ 190 - 0
docs/bacnet-http-api.md

@@ -0,0 +1,190 @@
+# BACnet HTTP 接口说明
+
+本服务通过 HTTP 接口连接 BACnet/IP 设备。BACnet 功能使用 `BAC0` 实现,当前只提供点位读取和点位搜索。
+
+## 通用响应
+
+所有接口成功或失败均返回 HTTP `200`,业务结果由响应体 `code` 判断。
+
+```json
+{
+  "code": 0,
+  "msg": "success",
+  "data": {}
+}
+```
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `code` | integer | `0` 表示成功,`1` 表示失败。 |
+| `msg` | string | 成功时为 `success`,失败时为错误信息。 |
+| `data` | object | 接口数据。 |
+
+## 设备字段
+
+以下字段用于 `/bacnet/read_points` 和 `/bacnet/search_points`。
+
+| 字段 | 类型 | 必填 | 默认值 | 说明 |
+|---|---|---|---|---|
+| `ip` | string | 是 | 无 | BACnet/IP 设备地址。 |
+| `bacnet_device_id` | integer | 是 | 无 | BACnet 设备对象实例号,范围 `0..4194303`。 |
+| `port` | integer | 否 | `47808` | BACnet/IP UDP 端口。 |
+
+响应中的设备信息固定包含:
+
+```json
+{
+  "device_type": "BACnet/IP",
+  "ip": "192.168.75.240",
+  "port": 47808,
+  "bacnet_device_id": 12345
+}
+```
+
+## 对象类型
+
+请求中的 `object_type` 支持常见写法,例如 `AnalogInput`、`analogInput`、`analog-input`。响应统一返回 `AnalogInput` 这类格式。
+
+常见点位对象类型:
+
+| 响应值 | BACnet 含义 |
+|---|---|
+| `AnalogInput` | 模拟输入 |
+| `AnalogOutput` | 模拟输出 |
+| `AnalogValue` | 模拟值 |
+| `BinaryInput` | 二进制输入 |
+| `BinaryOutput` | 二进制输出 |
+| `BinaryValue` | 二进制值 |
+| `MultiStateInput` | 多状态输入 |
+| `MultiStateOutput` | 多状态输出 |
+| `MultiStateValue` | 多状态值 |
+
+## 接口一:读取点位
+
+### URL
+
+```http
+POST /api/dc-gateway/bacnet/read_points
+```
+
+### 请求体
+
+`read_points` 固定读取每个对象的 `present-value`。
+
+```json
+{
+  "ip": "192.168.75.240",
+  "bacnet_device_id": 12345,
+  "port": 47808,
+  "points": [
+    {
+      "object_type": "AnalogInput",
+      "object_id": 1
+    }
+  ]
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `points` | object[] | 是 | 至少 1 个点位。 |
+| `points[].object_type` | string | 是 | BACnet 对象类型。 |
+| `points[].object_id` | integer | 是 | BACnet 对象实例号,范围 `0..4194303`。 |
+
+### 成功返回
+
+```json
+{
+  "code": 0,
+  "msg": "success",
+  "data": {
+    "device": {
+      "device_type": "BACnet/IP",
+      "ip": "192.168.75.240",
+      "port": 47808,
+      "bacnet_device_id": 12345
+    },
+    "points": [
+      {
+        "object_type": "AnalogInput",
+        "object_id": 1,
+        "present_value": 12.3
+      }
+    ]
+  }
+}
+```
+
+## 接口二:搜索点位
+
+### URL
+
+```http
+POST /api/dc-gateway/bacnet/search_points
+```
+
+### 请求体
+
+搜索点位只需要设备信息。服务会读取设备对象的 `object-list`,并过滤常见点位对象类型。
+
+```json
+{
+  "ip": "192.168.75.240",
+  "bacnet_device_id": 12345,
+  "port": 47808
+}
+```
+
+### 成功返回
+
+```json
+{
+  "code": 0,
+  "msg": "success",
+  "data": {
+    "device": {
+      "device_type": "BACnet/IP",
+      "ip": "192.168.75.240",
+      "port": 47808,
+      "bacnet_device_id": 12345
+    },
+    "points": [
+      {
+        "name": "Zone Temperature",
+        "description": "Room temperature",
+        "object_type": "AnalogInput",
+        "object_id": 1,
+        "present_value": 24.5
+      }
+    ]
+  }
+}
+```
+
+搜索每个点位时读取以下属性:
+
+| 返回字段 | BACnet 属性 |
+|---|---|
+| `name` | `object-name` |
+| `description` | `description` |
+| `present_value` | `present-value` |
+
+单个点位的 `description` 或 `present_value` 读取失败时,该字段返回 `null`,不会中断整个搜索。设备连接或 `object-list` 读取失败时,接口返回 `code=1`。
+
+## 测试默认设备
+
+BACnet 集成测试默认设备参数:
+
+| 字段 | 默认值 |
+|---|---|
+| `BACNET_DEVICE_IP` | `192.168.75.240` |
+| `BACNET_DEVICE_ID` | `12345` |
+| `BACNET_PORT` | `47808` |
+
+本地 BACnet 客户端绑定地址默认自动推断,可通过以下环境变量覆盖:
+
+| 字段 | 默认值 |
+|---|---|
+| `BACNET_LOCAL_IP` | 自动按到目标设备的路由推断 |
+| `BACNET_LOCAL_MASK` | `24` |
+| `BACNET_LOCAL_PORT` | 自动选择空闲 UDP 端口 |

+ 117 - 0
intergration/bacnet/common.py

@@ -0,0 +1,117 @@
+import atexit
+import json
+import os
+import socket
+import threading
+import time
+import urllib.error
+import urllib.request
+from contextlib import closing
+
+import uvicorn
+
+from app.main import app
+
+
+CONFIGURED_BASE_URL = os.getenv("DATA_COLLECTOR_BASE_URL", "").rstrip("/")
+REQUEST_TIMEOUT = int(os.getenv("REQUEST_TIMEOUT", "30"))
+
+BACNET_DEVICE_IP = os.getenv("BACNET_DEVICE_IP", "192.168.75.240")
+BACNET_DEVICE_ID = int(os.getenv("BACNET_DEVICE_ID", "12345"))
+BACNET_PORT = int(os.getenv("BACNET_PORT", "47808"))
+
+_LOCAL_API_SERVER = None
+
+
+def find_free_port() -> int:
+    with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock:
+        sock.bind(("127.0.0.1", 0))
+        return int(sock.getsockname()[1])
+
+
+class LocalApiServer:
+    def __init__(self) -> None:
+        self.port = find_free_port()
+        self.base_url = f"http://127.0.0.1:{self.port}"
+        config = uvicorn.Config(app, host="127.0.0.1", port=self.port, log_level="critical", access_log=False)
+        self.server = uvicorn.Server(config)
+        self.thread = threading.Thread(target=self.server.run, daemon=True)
+
+    def start(self) -> None:
+        self.thread.start()
+        deadline = time.time() + 10
+        while time.time() < deadline:
+            try:
+                with urllib.request.urlopen(f"{self.base_url}/api/dc-gateway/health", timeout=1) as response:
+                    if response.status == 200:
+                        return
+            except OSError:
+                time.sleep(0.05)
+        raise RuntimeError("local API test server did not start")
+
+    def stop(self) -> None:
+        self.server.should_exit = True
+        self.thread.join(timeout=5)
+
+
+def base_url() -> str:
+    global _LOCAL_API_SERVER
+    if CONFIGURED_BASE_URL:
+        return CONFIGURED_BASE_URL
+    if _LOCAL_API_SERVER is None:
+        _LOCAL_API_SERVER = LocalApiServer()
+        _LOCAL_API_SERVER.start()
+    return _LOCAL_API_SERVER.base_url
+
+
+def stop_local_api_server() -> None:
+    if _LOCAL_API_SERVER is not None:
+        _LOCAL_API_SERVER.stop()
+
+
+atexit.register(stop_local_api_server)
+
+
+def post_json(path: str, payload: dict) -> tuple[int, dict]:
+    data = json.dumps(payload).encode("utf-8")
+    request = urllib.request.Request(
+        f"{base_url()}{path}",
+        data=data,
+        headers={"Content-Type": "application/json"},
+        method="POST",
+    )
+    try:
+        with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT) as response:
+            body = response.read().decode("utf-8")
+            return response.status, json.loads(body)
+    except urllib.error.HTTPError as exc:
+        body = exc.read().decode("utf-8")
+        return exc.code, json.loads(body)
+
+
+def device_payload() -> dict:
+    return {
+        "ip": BACNET_DEVICE_IP,
+        "bacnet_device_id": BACNET_DEVICE_ID,
+        "port": BACNET_PORT,
+    }
+
+
+def assert_response_contract(testcase, status: int, data: dict) -> None:
+    testcase.assertEqual(200, status, data)
+    testcase.assertIn("code", data)
+    testcase.assertIn("msg", data)
+    testcase.assertIn("data", data)
+    testcase.assertIsInstance(data["data"], dict)
+
+
+def assert_bacnet_device(testcase, payload: dict, data: dict) -> None:
+    testcase.assertEqual(
+        {
+            "device_type": "BACnet/IP",
+            "ip": payload["ip"],
+            "port": payload["port"],
+            "bacnet_device_id": payload["bacnet_device_id"],
+        },
+        data["data"]["device"],
+    )

+ 68 - 0
intergration/bacnet/test_bacnet_api.py

@@ -0,0 +1,68 @@
+import unittest
+
+from common import assert_bacnet_device, assert_response_contract, device_payload, post_json
+
+
+class BacnetApiIntegrationTest(unittest.TestCase):
+    def test_validation_error_returns_http_200_contract(self):
+        status, data = post_json(
+            "/api/dc-gateway/bacnet/read_points",
+            {**device_payload(), "points": []},
+        )
+
+        assert_response_contract(self, status, data)
+        self.assertEqual(1, data["code"], data)
+        self.assertIn("points", data["msg"])
+        self.assertEqual([], data["data"]["points"])
+
+    def test_search_points_returns_bacnet_points(self):
+        payload = device_payload()
+
+        status, data = post_json("/api/dc-gateway/bacnet/search_points", payload)
+
+        assert_response_contract(self, status, data)
+        self.assertEqual(0, data["code"], data)
+        assert_bacnet_device(self, payload, data)
+        points = data["data"]["points"]
+        self.assertIsInstance(points, list)
+        self.assertTrue(points, data)
+        for point in points:
+            self.assertIn("name", point)
+            self.assertIn("description", point)
+            self.assertIn("object_type", point)
+            self.assertIn("object_id", point)
+            self.assertIn("present_value", point)
+
+    def test_read_points_uses_search_result(self):
+        payload = device_payload()
+        status, search_data = post_json("/api/dc-gateway/bacnet/search_points", payload)
+        assert_response_contract(self, status, search_data)
+        self.assertEqual(0, search_data["code"], search_data)
+        points = search_data["data"]["points"]
+        self.assertTrue(points, search_data)
+
+        source_point = next((point for point in points if point.get("present_value") is not None), points[0])
+        read_payload = {
+            **payload,
+            "points": [
+                {
+                    "object_type": source_point["object_type"],
+                    "object_id": source_point["object_id"],
+                }
+            ],
+        }
+
+        status, data = post_json("/api/dc-gateway/bacnet/read_points", read_payload)
+
+        assert_response_contract(self, status, data)
+        self.assertEqual(0, data["code"], data)
+        assert_bacnet_device(self, payload, data)
+        self.assertEqual(1, len(data["data"]["points"]))
+        point = data["data"]["points"][0]
+        self.assertEqual(source_point["object_type"], point["object_type"])
+        self.assertEqual(source_point["object_id"], point["object_id"])
+        self.assertIn("present_value", point)
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 50 - 0
intergration/bacnet/test_bacnet_schema.py

@@ -0,0 +1,50 @@
+import unittest
+
+from pydantic import ValidationError
+
+from app.schemas.bacnet import BacnetPointReadRequest, BacnetPointSearchRequest, bac0_object_type
+
+
+class BacnetSchemaTest(unittest.TestCase):
+    def test_port_defaults_to_bacnet_ip_port(self):
+        request = BacnetPointSearchRequest(ip="192.168.75.240", bacnet_device_id=12345)
+
+        self.assertEqual(47808, request.port)
+
+    def test_search_request_only_requires_device_fields(self):
+        request = BacnetPointSearchRequest(ip="192.168.75.240", bacnet_device_id=12345)
+
+        self.assertEqual("192.168.75.240", request.ip)
+        self.assertEqual(12345, request.bacnet_device_id)
+
+    def test_ip_must_be_valid_address(self):
+        with self.assertRaises(ValidationError) as context:
+            BacnetPointSearchRequest(ip="not-an-ip", bacnet_device_id=12345)
+
+        self.assertIn("ip must be a valid IP address", str(context.exception))
+
+    def test_device_id_range_is_validated(self):
+        with self.assertRaises(ValidationError) as context:
+            BacnetPointSearchRequest(ip="192.168.75.240", bacnet_device_id=4_194_304)
+
+        self.assertIn("bacnet_device_id", str(context.exception))
+
+    def test_points_must_not_be_empty(self):
+        with self.assertRaises(ValidationError) as context:
+            BacnetPointReadRequest(ip="192.168.75.240", bacnet_device_id=12345, points=[])
+
+        self.assertIn("points", str(context.exception))
+
+    def test_object_type_is_normalized(self):
+        request = BacnetPointReadRequest(
+            ip="192.168.75.240",
+            bacnet_device_id=12345,
+            points=[{"object_type": "analog-input", "object_id": 1}],
+        )
+
+        self.assertEqual("AnalogInput", request.points[0].object_type)
+        self.assertEqual("analogInput", bac0_object_type(request.points[0].object_type))
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 1 - 0
pyproject.toml

@@ -5,6 +5,7 @@ description = "Add your description here"
 readme = "README.md"
 requires-python = ">=3.12"
 dependencies = [
+    "bac0>=2025.9.15",
     "fastapi>=0.115.0",
     "pydantic>=2.13.4",
     "pymodbus>=3.13.1",

+ 54 - 0
uv.lock

@@ -2,6 +2,15 @@ version = 1
 revision = 2
 requires-python = ">=3.12"
 
+[[package]]
+name = "aiosqlite"
+version = "0.22.1"
+source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
+sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" }
+wheels = [
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" },
+]
+
 [[package]]
 name = "annotated-doc"
 version = "0.0.4"
@@ -33,6 +42,31 @@ wheels = [
     { url = "https://pypi.tuna.tsinghua.edu.cn/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
 ]
 
+[[package]]
+name = "bac0"
+version = "2025.9.15"
+source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
+dependencies = [
+    { name = "aiosqlite" },
+    { name = "bacpypes3" },
+    { name = "colorama" },
+    { name = "python-dotenv" },
+    { name = "pytz" },
+]
+sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8b/81/1cf98cfa12745c3416455b022901601b349140f550e267a6d58e0229842a/bac0-2025.9.15.tar.gz", hash = "sha256:e86a1dd18094000e3290d09e43597ebf34d68eaca5989e21bd1590abf1a7b38a", size = 97767, upload-time = "2025-09-19T20:01:06.526Z" }
+wheels = [
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e5/d2/35bc59646ce9119d1bbe05cdf7c84fd69a02ca720ba178c822e88c9cf985/bac0-2025.9.15-py3-none-any.whl", hash = "sha256:01288b4262b2b63c7ae84d6b5222ab542efbc66359498aece545e7e0276a4702", size = 120284, upload-time = "2025-09-19T20:01:05.207Z" },
+]
+
+[[package]]
+name = "bacpypes3"
+version = "0.0.106"
+source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
+sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3b/4a/9a822ab1de9cd77b67f2d17eccbb954010c36974e3b8acf603cd5ec03a2c/bacpypes3-0.0.106.tar.gz", hash = "sha256:01fdd7497bf93e6b61b153636cb736b87af0723cbab25fb30d419deb1620a95b", size = 234578, upload-time = "2026-03-17T20:07:09.928Z" }
+wheels = [
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fa/e5/5ac1bb14846f8b3086cd5478ba275bfecd8b65201be3c4b6afab231f1203/bacpypes3-0.0.106-py3-none-any.whl", hash = "sha256:5831ad139f5466fbbb64e8eb768034cfbea66a987a9df280185c39a44ffd0fec", size = 264144, upload-time = "2026-03-17T20:07:08.733Z" },
+]
+
 [[package]]
 name = "click"
 version = "8.4.1"
@@ -59,6 +93,7 @@ name = "data-collector-gateway"
 version = "0.1.0"
 source = { virtual = "." }
 dependencies = [
+    { name = "bac0" },
     { name = "fastapi" },
     { name = "pydantic" },
     { name = "pymodbus" },
@@ -68,6 +103,7 @@ dependencies = [
 
 [package.metadata]
 requires-dist = [
+    { name = "bac0", specifier = ">=2025.9.15" },
     { name = "fastapi", specifier = ">=0.115.0" },
     { name = "pydantic", specifier = ">=2.13.4" },
     { name = "pymodbus", specifier = ">=3.13.1" },
@@ -208,6 +244,15 @@ wheels = [
     { url = "https://pypi.tuna.tsinghua.edu.cn/packages/df/84/cdd3f6af834dac9e3bfaeca1f37c4dbfd305943305670c48499950e4e1cd/pymodbus-3.13.1-py3-none-any.whl", hash = "sha256:820167a9c6a13d698d7ff49e8420f8fbbdd8fec3f75aacd46a35f4ab9a000144", size = 166529, upload-time = "2026-06-13T17:02:08.374Z" },
 ]
 
+[[package]]
+name = "python-dotenv"
+version = "1.2.2"
+source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
+sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
+wheels = [
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
+]
+
 [[package]]
 name = "python-snap7"
 version = "3.0.0"
@@ -217,6 +262,15 @@ wheels = [
     { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ed/e4/d5b65c8fe8a0a9f449f9ea41593d839f2b139ba2ae6db6e25e51f1e690fb/python_snap7-3.0.0-py3-none-any.whl", hash = "sha256:309d2c9cc217cc5b626394d971bfd92e2fc61b42d12c0857f65e3fa1c800ae9b", size = 85371, upload-time = "2026-03-18T09:03:43.934Z" },
 ]
 
+[[package]]
+name = "pytz"
+version = "2026.2"
+source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
+sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" }
+wheels = [
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" },
+]
+
 [[package]]
 name = "starlette"
 version = "1.3.1"