Explorar o código

新增bacnet mcp工具

Lu Xianghui hai 1 mes
pai
achega
4b9240855a

+ 44 - 0
README.md

@@ -6,10 +6,16 @@
 
 - `project.list`
 - `modbus.point_collect_test`
+- `bacnet.point_collect_test`
+- `bacnet.point_search`
 - `collector.modbus_device_create`
 - `collector.modbus_point_create`
 - `collector.s7_device_create`
 - `collector.s7_point_create`
+- `collector.bacnet_device_create`
+- `collector.bacnet_device_edit`
+- `collector.bacnet_point_create`
+- `collector.bacnet_point_edit`
 - `collector.device_list`
 
 默认 MCP 地址:`http://127.0.0.1:8501/mcp`
@@ -148,6 +154,23 @@ S7 设备可选默认值包括:`port=102`、`device_type=1`、`is_persistent=f
 - `points[].data_type` 或 `points[].type`:数据类型
 - `points[].register_type` 或 `points[].register_area`:寄存器区域
 
+批量创建 BACnet 设备时传 `devices` 数组,每个设备必须包含:
+
+- `devices[].name`:设备名称
+- `devices[].ip`:BACnet/IP 设备地址
+- `devices[].bacnet_device_id`:BACnet 设备对象实例号,范围 `0..4194303`
+
+BACnet 设备创建调用通用 `{data_collector_base_url}/api/collector/device`,MCP 固定传 `type=bacnet`、`device_type=1`。可选默认值包括:`port=47808`、`bacnet_net=0`、`asp_ip=""`、`timeout=3`、`is_persistent=false`、`group_id=0`、`alarm_interval=90`、`collect_interval=5`。
+
+批量创建 BACnet 点位时传 `points` 数组,每个点位必须包含:
+
+- `points[].device_id`:所属设备 ID
+- `points[].object_type`:BACnet 对象类型,如 `AnalogInput`
+- `points[].object_id`:BACnet 对象实例号,范围 `0..4194303`
+- `points[].name` 或 `points[].object_name`:点位名称或 BACnet 对象名
+
+BACnet 点位创建调用 `{data_collector_base_url}/api/collector/bacnet/point/add_collect_point`。MCP 会将每个点位包装成汇采接口要求的 `{device_id, points:[...]}`。可选默认值包括:`point_id=""`、`priority=null`、`units=""`、`value_type=0`、`group_id=0`、`scale_ratio=1`、`value_offset=0`、`describe=""`。
+
 常见点表数据类型映射:
 
 | 点表类型 | 汇采 `type` |
@@ -171,6 +194,27 @@ S7 设备可选默认值包括:`port=102`、`device_type=1`、`is_persistent=f
 | `holding_register` | `3` |
 | `input_register` | `4` |
 
+## BACnet 网关工具
+
+`bacnet.point_search` 用于搜索 BACnet/IP 设备点位,调用 `{base_url}/api/dc-gateway/bacnet/search_points`。参数:
+
+- `project_key`:项目 key,先通过 `project.list` 获取
+- `ip`:BACnet/IP 设备地址
+- `bacnet_device_id`:BACnet 设备对象实例号,范围 `0..4194303`
+- `port`:BACnet/IP UDP 端口,默认 `47808`
+
+`bacnet.point_collect_test` 用于读取指定 BACnet 点位的 `present-value`,调用 `{base_url}/api/dc-gateway/bacnet/read_points`。除设备字段外,还需要传 `points` 数组:
+
+- `points[].object_type`:BACnet 对象类型,如 `AnalogInput`、`analog-input`、`analogInput`
+- `points[].object_id`:BACnet 对象实例号,范围 `0..4194303`
+
+调用示例:
+
+```powershell
+& ".venv\Scripts\python.exe" tools\test_mcp_call.py --tool bacnet.point_search --args '{"project_key":"dev-01","ip":"192.168.75.240","bacnet_device_id":12345}'
+& ".venv\Scripts\python.exe" tools\test_mcp_call.py --tool bacnet.point_collect_test --args '{"project_key":"dev-01","ip":"192.168.75.240","bacnet_device_id":12345,"points":[{"object_type":"AnalogInput","object_id":1}]}'
+```
+
 调用其他 MCP URL:
 
 ```powershell

+ 1 - 0
data_collector_mcp/app.py

@@ -9,6 +9,7 @@ from .db import check_database_connection
 from .mcp_app import mcp
 
 # Import tool modules for registration side effects.
+from . import bacnet_server as bacnet_server  # noqa: F401
 from . import common_server as common_server  # noqa: F401
 from . import modbus_server as modbus_server  # noqa: F401
 from . import s7_server as s7_server  # noqa: F401

+ 251 - 0
data_collector_mcp/bacnet_server.py

@@ -0,0 +1,251 @@
+from __future__ import annotations
+
+from typing import Any, NotRequired, TypedDict
+
+from pydantic import BaseModel
+
+from .collector_api import (
+    create_bacnet_devices as api_create_bacnet_devices,
+    create_bacnet_points as api_create_bacnet_points,
+    edit_bacnet_device as api_edit_bacnet_device,
+    edit_bacnet_point as api_edit_bacnet_point,
+)
+from .gateway_api import (
+    bacnet_point_collect_test as api_bacnet_point_collect_test,
+    bacnet_point_search as api_bacnet_point_search,
+)
+from .mcp_app import mcp
+
+
+class BacnetDeviceCreateItem(BaseModel):
+    name: str
+    ip: str
+    bacnet_device_id: int
+    port: int = 47808
+    bacnet_net: int = 0
+    asp_ip: str = ""
+    timeout: int = 3
+    is_persistent: bool = False
+    group_id: int = 0
+    alarm_interval: int = 90
+    collect_interval: int = 5
+
+
+class BacnetPointSpec(TypedDict):
+    object_type: str
+    object_id: int
+    name: NotRequired[str]
+    description: NotRequired[str]
+
+
+class BacnetPointCreateItem(TypedDict):
+    device_id: int
+    object_type: str
+    object_id: int
+    name: NotRequired[str]
+    object_name: NotRequired[str]
+    point_id: NotRequired[str]
+    priority: NotRequired[int | None]
+    units: NotRequired[str]
+    value_type: NotRequired[int]
+    group_id: NotRequired[int]
+    scale_ratio: NotRequired[float]
+    value_offset: NotRequired[float]
+    describe: NotRequired[str]
+
+
+@mcp.tool(
+    name="bacnet.point_collect_test",
+    description=(
+        "通过采集网关读取 BACnet/IP 点位 present-value。调用 "
+        "{base_url}/api/dc-gateway/bacnet/read_points。ip 为 BACnet/IP 设备地址,"
+        "bacnet_device_id 为 BACnet 设备对象实例号,范围 0..4194303;port 默认 47808。"
+        "points 每项必须包含 object_type 和 object_id;object_type 支持 AnalogInput、"
+        "analog-input、analogInput 等常见写法,网关响应会规范为 AnalogInput 形式。"
+        "响应 data.points[].present_value 为当前值;响应透传上游 JSON,code=0 表示业务成功。"
+    ),
+)
+def bacnet_point_collect_test(
+    project_key: str,
+    ip: str,
+    bacnet_device_id: int,
+    points: list[BacnetPointSpec],
+    port: int = 47808,
+) -> dict[str, Any]:
+    return api_bacnet_point_collect_test(
+        project_key,
+        ip=ip,
+        bacnet_device_id=bacnet_device_id,
+        port=port,
+        points=points,
+    )
+
+
+@mcp.tool(
+    name="bacnet.point_search",
+    description=(
+        "通过采集网关搜索 BACnet/IP 设备点位。调用 "
+        "{base_url}/api/dc-gateway/bacnet/search_points。ip 为 BACnet/IP 设备地址,"
+        "bacnet_device_id 为 BACnet 设备对象实例号,范围 0..4194303;port 默认 47808。"
+        "网关读取设备对象的 object-list,并过滤常见点位对象类型:AnalogInput、AnalogOutput、"
+        "AnalogValue、BinaryInput、BinaryOutput、BinaryValue、MultiStateInput、MultiStateOutput、"
+        "MultiStateValue。响应 data.points[] 包含 name、description、object_type、object_id、"
+        "present_value;单点 description 或 present_value 读取失败时字段为 null。"
+        "响应透传上游 JSON,code=0 表示业务成功。"
+    ),
+)
+def bacnet_point_search(
+    project_key: str,
+    ip: str,
+    bacnet_device_id: int,
+    port: int = 47808,
+) -> dict[str, Any]:
+    return api_bacnet_point_search(
+        project_key,
+        ip=ip,
+        bacnet_device_id=bacnet_device_id,
+        port=port,
+    )
+
+
+@mcp.tool(
+    name="collector.bacnet_device_create",
+    description=(
+        "汇采-批量创建 BACnet 设备。依次调用 {data_collector_base_url}/api/collector/device,"
+        "请求体固定 type=bacnet 且 device_type=1(ASP 设备)。全部设备创建调用完成后会内部调用"
+        "设备列表并匹配每个设备 id;如果匹配到多个设备,选择 id 最大的。devices 每项必须传 "
+        "name、ip、bacnet_device_id;port 默认 47808,bacnet_net 默认 0,asp_ip 默认空字符串。"
+        "默认参数: timeout=3, is_persistent=false, group_id=0, alarm_interval=90, collect_interval=5。"
+        "响应返回批量结果,state=0 表示全部创建并匹配成功。"
+    ),
+)
+def collector_bacnet_device_create(
+    project_key: str,
+    devices: list[BacnetDeviceCreateItem],
+) -> dict[str, Any]:
+    return api_create_bacnet_devices(project_key, [_dump_model_or_dict(item) for item in devices])
+
+
+@mcp.tool(
+    name="collector.bacnet_device_edit",
+    description=(
+        "汇采-编辑 BACnet 设备。调用 {data_collector_base_url}/api/collector/bacnet/device/edit。"
+        "必须传 ori_id 原设备 id、name、ip、bacnet_device_id;MCP 会把 bacnet_device_id 映射为"
+        "汇采旧接口的 device_id 字符串,并固定 type=1(ASP 设备)。编辑前设备不能处于已连接状态;"
+        "若已连接,请先调用 collector.device_disconnect,并传 device_type=bacnet。该接口是全量更新语义。"
+        "默认参数: port=47808, net=0, asp_ip='', timeout=3, is_persistent=false, "
+        "device_group_id=0, alarm_interval=90, collect_interval=5。响应透传上游 JSON,state=0 表示业务成功。"
+    ),
+)
+def collector_bacnet_device_edit(
+    project_key: str,
+    ori_id: int,
+    name: str,
+    ip: str,
+    bacnet_device_id: int,
+    port: int = 47808,
+    bacnet_net: int = 0,
+    asp_ip: str = "",
+    is_persistent: bool = False,
+    device_group_id: int = 0,
+    timeout: int = 3,
+    alarm_interval: int = 90,
+    collect_interval: int = 5,
+) -> dict[str, Any]:
+    return api_edit_bacnet_device(
+        project_key,
+        {
+            "ori_id": ori_id,
+            "name": name,
+            "ip": ip,
+            "bacnet_device_id": bacnet_device_id,
+            "port": port,
+            "bacnet_net": bacnet_net,
+            "asp_ip": asp_ip,
+            "is_persistent": is_persistent,
+            "device_group_id": device_group_id,
+            "timeout": timeout,
+            "alarm_interval": alarm_interval,
+            "collect_interval": collect_interval,
+        },
+    )
+
+
+@mcp.tool(
+    name="collector.bacnet_point_create",
+    description=(
+        "汇采-批量创建 BACnet 采集点位。依次调用 "
+        "{data_collector_base_url}/api/collector/bacnet/point/add_collect_point。"
+        "points 每项必须传 device_id、object_type、object_id,并传 name 或 object_name;"
+        "MCP 会将单个点位包装成汇采接口要求的 {device_id, points:[...]}。"
+        "每个点位会补齐默认值: point_id='', priority=null, units='', value_type=0, "
+        "group_id=0, scale_ratio=1, value_offset=0, describe=''。"
+        "常见 value_type: 1=Boolean, 2=Unsigned, 3=Signed, 4=Real, 5=Double, 6=Enumerated。"
+        "响应返回批量结果,state=0 表示全部创建成功。"
+    ),
+)
+def collector_bacnet_point_create(
+    project_key: str,
+    points: list[BacnetPointCreateItem],
+) -> dict[str, Any]:
+    return api_create_bacnet_points(project_key, points)
+
+
+@mcp.tool(
+    name="collector.bacnet_point_edit",
+    description=(
+        "汇采-编辑 BACnet 采集点位。调用 {data_collector_base_url}/api/collector/bacnet/point/edit。"
+        "必须传 ori_id 原点位 id、object_type、object_id,并传 name 或 object_name;ori_id 对应 "
+        "collector.device_points 返回的 data.point[].id,MCP 会映射为汇采接口字段 id。"
+        "该接口是全量更新语义,未传字段可能被默认值覆盖。默认参数: point_id='', priority=null, "
+        "units='', value_type=0, group_id=0, scale_ratio=1, value_offset=0, describe='', "
+        "invalid_values='', valid_range_start=null, valid_range_end=null。"
+        "响应透传上游 JSON,state=0 表示业务成功。"
+    ),
+)
+def collector_bacnet_point_edit(
+    project_key: str,
+    ori_id: int,
+    object_type: str,
+    object_id: int,
+    name: str = "",
+    object_name: str = "",
+    point_id: str = "",
+    priority: int | None = None,
+    units: str = "",
+    value_type: int = 0,
+    group_id: int = 0,
+    scale_ratio: float = 1,
+    value_offset: float = 0,
+    describe: str = "",
+    invalid_values: str = "",
+    valid_range_start: float | None = None,
+    valid_range_end: float | None = None,
+) -> dict[str, Any]:
+    return api_edit_bacnet_point(
+        project_key,
+        {
+            "ori_id": ori_id,
+            "name": name,
+            "object_type": object_type,
+            "object_id": object_id,
+            "object_name": object_name,
+            "point_id": point_id,
+            "priority": priority,
+            "units": units,
+            "value_type": value_type,
+            "group_id": group_id,
+            "scale_ratio": scale_ratio,
+            "value_offset": value_offset,
+            "describe": describe,
+            "invalid_values": invalid_values,
+            "valid_range_start": valid_range_start,
+            "valid_range_end": valid_range_end,
+        },
+    )
+
+
+def _dump_model_or_dict(item: Any) -> dict[str, Any]:
+    if isinstance(item, BaseModel):
+        return item.model_dump(exclude_none=True)
+    return dict(item)

+ 202 - 1
data_collector_mcp/collector_api.py

@@ -4,11 +4,14 @@ from typing import Any
 
 from .auth import find_project_config, resolve_project_token
 from .http_client import request_json
-from .protocols import MODBUS_SPEC, S7_SPEC
+from .protocols import BACNET_SPEC, MODBUS_SPEC, S7_SPEC
 from .protocols.modbus import MODBUS_POINT_TYPE_ALIASES, MODBUS_REGISTER_TYPE_ALIASES
 from .protocols.s7 import S7_POINT_TYPE_ALIASES, S7_REGISTER_TYPE_ALIASES
 
 
+BACNET_OBJECT_ID_MAX = 4_194_303
+
+
 def _merge_defaults(defaults: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
     merged = dict(defaults)
     merged.update(payload)
@@ -232,6 +235,103 @@ def _normalize_s7_point_edit_payload(payload: dict[str, Any]) -> dict[str, Any]:
     return normalized
 
 
+def _normalize_bacnet_device_payload(payload: dict[str, Any]) -> dict[str, Any]:
+    normalized = dict(payload)
+
+    normalized["name"] = _require_non_empty_text(normalized, "name")
+    normalized["ip"] = _require_non_empty_text(normalized, "ip")
+    normalized["bacnet_device_id"] = _normalize_bacnet_id(
+        _require_present(normalized, "bacnet_device_id"),
+        "payload.bacnet_device_id",
+    )
+    normalized["device_type"] = 1
+    normalized["type"] = "bacnet"
+
+    if "device_id" in normalized:
+        normalized.pop("device_id", None)
+    if "device_group_id" in normalized and "group_id" not in normalized:
+        normalized["group_id"] = normalized["device_group_id"]
+    normalized.pop("device_group_id", None)
+    if "bacnet_net" in normalized:
+        normalized["bacnet_net"] = _normalize_non_negative_int(normalized["bacnet_net"], "payload.bacnet_net")
+    elif "net" in normalized:
+        normalized["bacnet_net"] = _normalize_non_negative_int(normalized["net"], "payload.net")
+    normalized.pop("net", None)
+
+    return normalized
+
+
+def _normalize_bacnet_device_edit_payload(payload: dict[str, Any]) -> dict[str, Any]:
+    normalized = dict(payload)
+    normalized["ori_id"] = _normalize_positive_int(_require_present(normalized, "ori_id"), "payload.ori_id")
+    normalized["name"] = _require_non_empty_text(normalized, "name")
+    normalized["ip"] = _require_non_empty_text(normalized, "ip")
+    bacnet_device_id = _normalize_bacnet_id(
+        _require_present(normalized, "bacnet_device_id"),
+        "payload.bacnet_device_id",
+    )
+    normalized["device_id"] = str(bacnet_device_id)
+    normalized["type"] = 1
+    normalized.pop("bacnet_device_id", None)
+    normalized.pop("device_type", None)
+
+    if "group_id" in normalized and "device_group_id" not in normalized:
+        normalized["device_group_id"] = normalized["group_id"]
+    normalized.pop("group_id", None)
+    if "bacnet_net" in normalized:
+        normalized["net"] = _normalize_non_negative_int(normalized["bacnet_net"], "payload.bacnet_net")
+    elif "net" in normalized:
+        normalized["net"] = _normalize_non_negative_int(normalized["net"], "payload.net")
+    normalized.pop("bacnet_net", None)
+
+    return normalized
+
+
+def _normalize_bacnet_point_payload(payload: dict[str, Any], *, require_device_id: bool = True) -> dict[str, Any]:
+    normalized = dict(payload)
+
+    if require_device_id:
+        normalized["device_id"] = _normalize_positive_int(
+            _require_present(normalized, "device_id"),
+            "payload.device_id",
+        )
+    normalized["object_type"] = _require_non_empty_text(normalized, "object_type")
+    normalized["object_id"] = _normalize_bacnet_id(_require_present(normalized, "object_id"), "payload.object_id")
+    object_name = str(normalized.get("object_name") or normalized.get("name") or "").strip()
+    if not object_name:
+        raise ValueError("payload.object_name is required")
+    normalized["object_name"] = object_name
+    normalized["name"] = str(normalized.get("name") or object_name).strip() or object_name
+
+    if normalized.get("priority") is not None:
+        normalized["priority"] = _normalize_positive_int(normalized["priority"], "payload.priority")
+
+    return normalized
+
+
+def _normalize_bacnet_point_create_payload(payload: dict[str, Any]) -> dict[str, Any]:
+    normalized = _merge_defaults(
+        BACNET_SPEC.point_defaults,
+        _normalize_bacnet_point_payload(payload),
+    )
+    device_id = normalized.pop("device_id")
+    normalized.pop("invalid_values", None)
+    normalized.pop("valid_range_start", None)
+    normalized.pop("valid_range_end", None)
+    return {"device_id": device_id, "points": [normalized]}
+
+
+def _normalize_bacnet_point_edit_payload(payload: dict[str, Any]) -> dict[str, Any]:
+    normalized = _merge_defaults(
+        BACNET_SPEC.point_defaults,
+        _normalize_bacnet_point_payload(payload, require_device_id=False),
+    )
+    normalized["id"] = _normalize_positive_int(_require_present(normalized, "ori_id"), "payload.ori_id")
+    normalized.pop("ori_id", None)
+    normalized.pop("device_id", None)
+    return normalized
+
+
 def _request_collector(
     project_key: str,
     method: str,
@@ -280,6 +380,17 @@ def _build_s7_point_create_payload(payload: dict[str, Any]) -> dict[str, Any]:
     )
 
 
+def _build_bacnet_device_create_payload(payload: dict[str, Any]) -> dict[str, Any]:
+    return _merge_defaults(
+        BACNET_SPEC.device_defaults,
+        _normalize_bacnet_device_payload(payload),
+    )
+
+
+def _build_bacnet_point_create_payload(payload: dict[str, Any]) -> dict[str, Any]:
+    return _normalize_bacnet_point_create_payload(payload)
+
+
 def create_modbus_device(project_key: str, payload: dict[str, Any]) -> dict[str, Any]:
     return _request_collector(
         project_key,
@@ -316,6 +427,24 @@ def create_s7_point(project_key: str, payload: dict[str, Any]) -> dict[str, Any]
     )
 
 
+def create_bacnet_device(project_key: str, payload: dict[str, Any]) -> dict[str, Any]:
+    return _request_collector(
+        project_key,
+        "POST",
+        BACNET_SPEC.create_device_path,
+        json_payload=_build_bacnet_device_create_payload(payload),
+    )
+
+
+def create_bacnet_point(project_key: str, payload: dict[str, Any]) -> dict[str, Any]:
+    return _request_collector(
+        project_key,
+        "POST",
+        BACNET_SPEC.create_point_path,
+        json_payload=_build_bacnet_point_create_payload(payload),
+    )
+
+
 def create_modbus_devices(project_key: str, devices: list[dict[str, Any]]) -> dict[str, Any]:
     return _create_devices_batch(
         project_key,
@@ -354,6 +483,25 @@ def create_s7_points(project_key: str, points: list[dict[str, Any]]) -> dict[str
     )
 
 
+def create_bacnet_devices(project_key: str, devices: list[dict[str, Any]]) -> dict[str, Any]:
+    return _create_devices_batch(
+        project_key,
+        devices,
+        create_path=BACNET_SPEC.create_device_path,
+        build_payload=_build_bacnet_device_create_payload,
+        match_device=_match_bacnet_device,
+    )
+
+
+def create_bacnet_points(project_key: str, points: list[dict[str, Any]]) -> dict[str, Any]:
+    return _create_points_batch(
+        project_key,
+        points,
+        create_path=BACNET_SPEC.create_point_path,
+        build_payload=_build_bacnet_point_create_payload,
+    )
+
+
 def _create_devices_batch(
     project_key: str,
     devices: list[dict[str, Any]],
@@ -547,6 +695,21 @@ def _match_s7_device(device: dict[str, Any], expected: dict[str, Any]) -> bool:
     )
 
 
+def _match_bacnet_device(device: dict[str, Any], expected: dict[str, Any]) -> bool:
+    if device.get("type") != "bacnet":
+        return False
+    actual_group_id = device.get("group_id", device.get("device_group_id"))
+    return (
+        _same_text(device.get("name"), expected.get("name"))
+        and _same_text(device.get("ip"), expected.get("ip"))
+        and _same_int(device.get("port"), expected.get("port"))
+        and _same_int(device.get("device_type"), expected.get("device_type"))
+        and _same_int(device.get("bacnet_device_id"), expected.get("bacnet_device_id"))
+        and _same_int(device.get("bacnet_net"), expected.get("bacnet_net"))
+        and _same_int(actual_group_id, expected.get("group_id"))
+    )
+
+
 def _same_text(left: Any, right: Any) -> bool:
     return str(left or "").strip() == str(right or "").strip()
 
@@ -621,6 +784,37 @@ def edit_s7_point(project_key: str, payload: dict[str, Any]) -> dict[str, Any]:
     )
 
 
+def edit_bacnet_device(project_key: str, payload: dict[str, Any]) -> dict[str, Any]:
+    return _request_collector(
+        project_key,
+        "POST",
+        "/api/collector/bacnet/device/edit",
+        json_payload=_merge_defaults(
+            {
+                "type": 1,
+                "port": 47808,
+                "net": 0,
+                "asp_ip": "",
+                "timeout": 3,
+                "is_persistent": False,
+                "device_group_id": 0,
+                "alarm_interval": 90,
+                "collect_interval": 5,
+            },
+            _normalize_bacnet_device_edit_payload(payload),
+        ),
+    )
+
+
+def edit_bacnet_point(project_key: str, payload: dict[str, Any]) -> dict[str, Any]:
+    return _request_collector(
+        project_key,
+        "POST",
+        "/api/collector/bacnet/point/edit",
+        json_payload=_normalize_bacnet_point_edit_payload(payload),
+    )
+
+
 def list_devices(project_key: str, num_points: bool = False) -> dict[str, Any]:
     num_points_text = "true" if num_points else "false"
     return _request_collector(
@@ -717,3 +911,10 @@ def _normalize_non_negative_int(value: int, field_name: str) -> int:
     if normalized < 0:
         raise ValueError(f"{field_name} must be a non-negative integer")
     return normalized
+
+
+def _normalize_bacnet_id(value: Any, field_name: str) -> int:
+    normalized = _normalize_non_negative_int(value, field_name)
+    if normalized > BACNET_OBJECT_ID_MAX:
+        raise ValueError(f"{field_name} must be between 0 and {BACNET_OBJECT_ID_MAX}")
+    return normalized

+ 33 - 1
data_collector_mcp/gateway_api.py

@@ -4,7 +4,7 @@ from typing import Any
 
 from .auth import find_project_config
 from .http_client import request_json
-from .protocols import MODBUS_SPEC, S7_SPEC
+from .protocols import BACNET_SPEC, MODBUS_SPEC, S7_SPEC
 
 
 def _request_gateway(project_key: str, path: str | None, payload: dict[str, Any], protocol: str) -> dict[str, Any]:
@@ -45,6 +45,38 @@ def modbus_point_collect_test(
     return _request_gateway(project_key, MODBUS_SPEC.point_test_path, payload, "modbus point test")
 
 
+def bacnet_point_collect_test(
+    project_key: str,
+    *,
+    ip: str,
+    bacnet_device_id: int,
+    points: list[dict[str, Any]],
+    port: int = 47808,
+) -> dict[str, Any]:
+    payload = {
+        "ip": ip,
+        "bacnet_device_id": bacnet_device_id,
+        "port": port,
+        "points": points,
+    }
+    return _request_gateway(project_key, BACNET_SPEC.point_test_path, payload, "bacnet point test")
+
+
+def bacnet_point_search(
+    project_key: str,
+    *,
+    ip: str,
+    bacnet_device_id: int,
+    port: int = 47808,
+) -> dict[str, Any]:
+    payload = {
+        "ip": ip,
+        "bacnet_device_id": bacnet_device_id,
+        "port": port,
+    }
+    return _request_gateway(project_key, BACNET_SPEC.search_points_path, payload, "bacnet point search")
+
+
 def s7_raw_read(
     project_key: str,
     *,

+ 2 - 1
data_collector_mcp/mcp_app.py

@@ -8,7 +8,8 @@ SERVER_INSTRUCTIONS = (
     "base_url is used for login and gateway test APIs. data_collector_base_url "
     "is required for collector management APIs and never falls back to base_url. "
     "For Modbus, gateway word_byte_order values ABCD/BADC/CDAB/DCBA must be "
-    "mapped to collector byte_order/word_order before creating a device."
+    "mapped to collector byte_order/word_order before creating a device. "
+    "For BACnet/IP, use bacnet.point_search first when object_type/object_id are unknown."
 )
 
 

+ 2 - 1
data_collector_mcp/protocols/__init__.py

@@ -1,4 +1,5 @@
+from .bacnet import BACNET_SPEC
 from .modbus import MODBUS_SPEC
 from .s7 import S7_SPEC
 
-__all__ = ["MODBUS_SPEC", "S7_SPEC"]
+__all__ = ["BACNET_SPEC", "MODBUS_SPEC", "S7_SPEC"]

+ 38 - 0
data_collector_mcp/protocols/bacnet.py

@@ -0,0 +1,38 @@
+from __future__ import annotations
+
+from .base import ProtocolSpec
+
+
+BACNET_SPEC = ProtocolSpec(
+    protocol="bacnet",
+    create_device_path="/api/collector/device",
+    create_point_path="/api/collector/bacnet/point/add_collect_point",
+    point_test_path="/api/dc-gateway/bacnet/read_points",
+    search_points_path="/api/dc-gateway/bacnet/search_points",
+    device_defaults={
+        "type": "bacnet",
+        "device_type": 1,
+        "port": 47808,
+        "bacnet_net": 0,
+        "asp_ip": "",
+        "timeout": 3,
+        "is_persistent": False,
+        "group_id": 0,
+        "alarm_interval": 90,
+        "collect_interval": 5,
+    },
+    point_defaults={
+        "point_id": "",
+        "priority": None,
+        "object_name": "",
+        "units": "",
+        "value_type": 0,
+        "group_id": 0,
+        "scale_ratio": 1,
+        "value_offset": 0,
+        "describe": "",
+        "invalid_values": "",
+        "valid_range_start": None,
+        "valid_range_end": None,
+    },
+)

+ 1 - 0
data_collector_mcp/protocols/base.py

@@ -12,5 +12,6 @@ class ProtocolSpec:
     point_test_path: str | None = None
     raw_read_path: str | None = None
     connect_scan_path: str | None = None
+    search_points_path: str | None = None
     device_defaults: dict[str, Any] = field(default_factory=dict)
     point_defaults: dict[str, Any] = field(default_factory=dict)

+ 455 - 0
docs/接口汇总.md

@@ -75,6 +75,8 @@ AI 调用接口时应按以下规则判断结果:
 | 原始 S7 读取 | `POST` | `/api/dc-gateway/s7/read` | 读取一段 S7 地址数据,只返回语义化 `communication`。 |
 | S7 点位读取并转换 | `POST` | `/api/dc-gateway/s7/read_points` | 按点位定义读取 S7 数据,并返回转换后的业务值和 `communication`。 |
 | S7 连接扫描 | `POST` | `/api/dc-gateway/s7/connect_scan` | 只传 IP,扫描可用的 `rock`、`slot`、`tsap_conn_type` 组合。 |
+| BACnet 点位读取 | `POST` | `/api/dc-gateway/bacnet/read_points` | 按 BACnet 对象读取 `present-value`。 |
+| BACnet 点位搜索 | `POST` | `/api/dc-gateway/bacnet/search_points` | 读取设备 `object-list`,返回常见点位对象信息和值。 |
 
 ## 网关接口目录
 
@@ -83,6 +85,7 @@ AI 调用接口时应按以下规则判断结果:
 | 通用部分 | 请求格式、返回格式、HTTP 状态码、健康检查。 |
 | Modbus 网关接口 | Modbus TCP 通用设备字段、地址规则、功能码、原始读取、点位读取并转换。 |
 | S7 网关接口 | S7 TCP 通用设备字段、地址字段、语义化通信记录、原始读取、点位读取并转换、连接扫描。 |
+| BACnet 网关接口 | BACnet/IP 通用设备字段、对象类型、点位读取、点位搜索。 |
 
 ## Modbus 通用设备字段
 
@@ -986,6 +989,203 @@ curl -X POST http://127.0.0.1:8000/api/dc-gateway/s7/connect_scan \
   }'
 ```
 
+## BACnet 网关接口说明
+
+### BACnet 网关目标
+
+BACnet 网关用于通过 HTTP 直连 BACnet/IP 设备,当前支持点位读取和点位搜索。底层使用 `BAC0`,读取点位时固定读取对象的 `present-value`。
+
+### BACnet 通用设备字段
+
+以下字段用于描述要连接的 BACnet/IP 设备,出现在 `/bacnet/read_points` 和 `/bacnet/search_points` 请求体的顶层。
+
+| 字段 | 类型 | 必填 | 默认值 | 允许值或范围 | 含义 |
+|---|---|---:|---|---|---|
+| `ip` | string | 是 | 无 | 合法 IPv4 或 IPv6 地址 | BACnet/IP 设备地址。 |
+| `bacnet_device_id` | integer | 是 | 无 | `0..4194303` | BACnet 设备对象实例号。 |
+| `port` | integer | 否 | `47808` | `1..65535` | BACnet/IP UDP 端口。 |
+
+响应中的设备信息固定包含:
+
+```json
+{
+  "device_type": "BACnet/IP",
+  "ip": "192.168.75.240",
+  "port": 47808,
+  "bacnet_device_id": 12345
+}
+```
+
+### BACnet 对象类型
+
+请求中的 `object_type` 支持常见写法,例如 `AnalogInput`、`analogInput`、`analog-input`。响应统一返回 `AnalogInput` 这类格式。
+
+常见点位对象类型:
+
+| 响应值 | BACnet 含义 |
+|---|---|
+| `AnalogInput` | 模拟输入 |
+| `AnalogOutput` | 模拟输出 |
+| `AnalogValue` | 模拟值 |
+| `BinaryInput` | 二进制输入 |
+| `BinaryOutput` | 二进制输出 |
+| `BinaryValue` | 二进制值 |
+| `MultiStateInput` | 多状态输入 |
+| `MultiStateOutput` | 多状态输出 |
+| `MultiStateValue` | 多状态值 |
+
+### 接口:BACnet 点位读取
+
+#### 基本信息
+
+| 项 | 值 |
+|---|---|
+| 方法 | `POST` |
+| 路径 | `/api/dc-gateway/bacnet/read_points` |
+| 完整地址 | `http://127.0.0.1:8000/api/dc-gateway/bacnet/read_points` |
+| Content-Type | `application/json` |
+| 用途 | 按 BACnet 对象读取 `present-value`。 |
+
+MCP 工具 `bacnet.point_collect_test` 会调用该接口并透传响应。
+
+#### 请求体结构
+
+```json
+{
+  "ip": "192.168.75.240",
+  "bacnet_device_id": 12345,
+  "port": 47808,
+  "points": [
+    {
+      "object_type": "AnalogInput",
+      "object_id": 1
+    }
+  ]
+}
+```
+
+`points` 字段说明:
+
+| 字段 | 类型 | 必填 | 默认值 | 允许值或范围 | 含义 |
+|---|---|---:|---|---|---|
+| `points` | object[] | 是 | 无 | 至少 1 个点位 | BACnet 点位对象列表。 |
+| `points[].object_type` | string | 是 | 无 | 合法 BACnet 对象类型写法 | BACnet 对象类型。 |
+| `points[].object_id` | integer | 是 | 无 | `0..4194303` | BACnet 对象实例号。 |
+
+#### 成功返回示例
+
+```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
+      }
+    ]
+  }
+}
+```
+
+#### 失败返回示例
+
+```json
+{
+  "code": 1,
+  "msg": "No response from BACnet device",
+  "data": {
+    "device": {
+      "device_type": "BACnet/IP",
+      "ip": "192.168.75.240",
+      "port": 47808,
+      "bacnet_device_id": 12345
+    },
+    "points": []
+  }
+}
+```
+
+### 接口:BACnet 点位搜索
+
+#### 基本信息
+
+| 项 | 值 |
+|---|---|
+| 方法 | `POST` |
+| 路径 | `/api/dc-gateway/bacnet/search_points` |
+| 完整地址 | `http://127.0.0.1:8000/api/dc-gateway/bacnet/search_points` |
+| Content-Type | `application/json` |
+| 用途 | 读取设备 `object-list`,返回常见点位对象基础信息和值。 |
+
+MCP 工具 `bacnet.point_search` 会调用该接口并透传响应。未知 `object_type` 和 `object_id` 时,建议先调用该工具搜索点位,再调用 `bacnet.point_collect_test` 读取目标点位。
+
+#### 请求体结构
+
+```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`。
+
+#### 调用示例
+
+```bash
+curl -X POST http://127.0.0.1:8000/api/dc-gateway/bacnet/search_points \
+  -H "Content-Type: application/json" \
+  -d '{
+    "ip": "192.168.75.240",
+    "bacnet_device_id": 12345,
+    "port": 47808
+  }'
+```
+
 ## AI 调用决策指南
 
 AI 或自动化程序选择接口时遵循以下规则:
@@ -998,6 +1198,8 @@ AI 或自动化程序选择接口时遵循以下规则:
 | 需要查看 S7 语义化连接和原始字节 | `POST /api/dc-gateway/s7/read` | 返回 S7 连接、读取、断开的 `communication`,不解析为业务值。 |
 | 需要得到 S7 点位业务值 | `POST /api/dc-gateway/s7/read_points` | 返回 `points[].value`,自动按 S7 类型转换。 |
 | 不确定 S7 连接参数 | `POST /api/dc-gateway/s7/connect_scan` | 扫描可用的 `rock`、`slot`、`tsap_conn_type` 组合。 |
+| 需要搜索 BACnet 设备点位 | `POST /api/dc-gateway/bacnet/search_points` | 读取设备 `object-list`,返回常见点位对象。 |
+| 需要读取 BACnet 点位当前值 | `POST /api/dc-gateway/bacnet/read_points` | 固定读取 BACnet 对象的 `present-value`。 |
 
 AI 构造请求时必须区分两个地址:
 
@@ -1006,6 +1208,7 @@ AI 构造请求时必须区分两个地址:
 | 网关服务器地址 | `http://127.0.0.1:8000` | HTTP API 服务地址,用于拼接请求 URL。 |
 | Modbus 设备地址 | 请求体 `ip` 和 `port` | 实际被读取的 Modbus TCP 设备地址。 |
 | S7 设备地址 | 请求体 `ip`、`port`、`rock`、`slot`、`tsap_conn_type` | 实际被读取的 S7 TCP 设备连接参数。 |
+| BACnet 设备地址 | 请求体 `ip`、`port`、`bacnet_device_id` | 实际被读取的 BACnet/IP 设备参数。 |
 
 AI 构造 `/modbus/read_points` 点位时应按以下步骤:
 
@@ -1026,6 +1229,13 @@ AI 构造 `/s7/read_points` 点位时应按以下步骤:
 6. 如果读取布尔位,设置 `type=bool` 并填写 `bit=0..7`;非布尔点不要传 `bit`。
 7. 发送请求后检查响应体 `code`,不要只检查 HTTP 状态码。
 
+AI 构造 `/bacnet/read_points` 点位时应按以下步骤:
+
+1. 如果不知道点位对象,先调用 `/bacnet/search_points` 或 MCP 工具 `bacnet.point_search` 获取 `object_type` 和 `object_id`。
+2. 设置设备字段 `ip`、`bacnet_device_id`,`port` 不传时默认为 `47808`。
+3. 在 `points` 中填入 `object_type` 和 `object_id`,`object_type` 可使用 `AnalogInput`、`analogInput`、`analog-input` 等写法。
+4. 发送请求后检查响应体 `code`,不要只检查 HTTP 状态码。
+
 
 
 # 2. 模方登录接口
@@ -2018,6 +2228,251 @@ MCP 工具 `collector.s7_point_create` 接收 `points` 数组并逐个调用本
 | 同一设备下 `point_id` 重复 | `{"state":2,"state_info":"point_id 重复, point_id: {point_id}"}` |
 | 数据库更新失败 | `{"state":2,"state_info":"点位更新失败"}` |
 
+## BACnet 创建设备与创建点位接口文档
+
+### 1. 创建 BACnet 设备
+
+#### 基本信息
+
+- URL:`/api/collector/device`
+- 方法:`POST`
+- 处理函数:`DeviceHandler.POST`
+- 请求结构:`handler.DeviceOrGroup`
+
+MCP 工具 `collector.bacnet_device_create` 接收 `devices` 数组并逐个调用本接口。MCP 固定传 `type=bacnet`、`device_type=1`,其中 `device_type=1` 表示 BACnet ASP 设备。全部创建设备调用完成后,MCP 会调用设备列表接口匹配 `device_id`,匹配到多个时选择最大 `id`。
+
+#### 请求字段
+
+| 字段 | 类型 | 必填 | 默认/行为 | 含义 |
+|---|---|---|---|---|
+| `type` | string | 是 | MCP 固定 `bacnet` | 设备协议类型。 |
+| `device_type` | int | 是 | MCP 固定 `1` | BACnet 设备类型,`1=ASP`。 |
+| `name` | string | 是 | 空字符串会导致创建失败 | 设备名称。 |
+| `ip` | string | 是 | 空字符串会导致创建失败 | BACnet/IP 设备地址。 |
+| `port` | int | 否 | MCP 默认 `47808` | BACnet/IP UDP 端口。 |
+| `bacnet_device_id` | int | 是 | `0..4194303` | BACnet 设备对象实例号。 |
+| `bacnet_net` | int | 否 | MCP 默认 `0` | BACnet 网络号。ASP 设备通常为 `0`。 |
+| `asp_ip` | string | 否 | MCP 默认空字符串 | RP 设备关联 ASP 地址;MCP 当前固定 ASP 设备,通常为空。 |
+| `group_id` | int | 否 | MCP 默认 `0` | 设备分组 ID。 |
+| `timeout` | int | 否 | MCP 默认 `3` | 超时时间。 |
+| `is_persistent` | bool | 否 | MCP 默认 `false` | 是否持久化。 |
+| `alarm_interval` | int | 否 | MCP 默认 `90` | 告警间隔。 |
+| `collect_interval` | int | 否 | MCP 默认 `5` | 采集周期。 |
+
+#### 请求示例
+
+```json
+{
+  "type": "bacnet",
+  "device_type": 1,
+  "name": "bacnet_1",
+  "ip": "192.168.75.240",
+  "port": 47808,
+  "bacnet_device_id": 12345,
+  "bacnet_net": 0,
+  "asp_ip": "",
+  "group_id": 0,
+  "timeout": 3,
+  "is_persistent": false,
+  "alarm_interval": 90,
+  "collect_interval": 5
+}
+```
+
+#### 成功响应
+
+```json
+{
+  "state": 0,
+  "state_info": "成功"
+}
+```
+
+### 2. 编辑 BACnet 设备
+
+#### 基本信息
+
+- URL:`/api/collector/bacnet/device/edit`
+- 方法:`POST`
+- 处理函数:`bacnetDeviceEdit`
+- 请求结构:`ReqEditDevice`
+
+MCP 工具 `collector.bacnet_device_edit` 会把入参 `bacnet_device_id` 映射为该旧接口的 `device_id` 字符串,并固定 `type=1`。编辑前设备不能处于已连接状态;若已连接,请先调用 `collector.device_disconnect`,并传 `device_type=bacnet`。
+
+#### 请求字段
+
+| 字段 | 类型 | 必填 | 默认/行为 | 含义 |
+|---|---|---|---|---|
+| `ori_id` | int | 是 | binding 必填 | 原设备 ID。 |
+| `type` | int | 是 | MCP 固定 `1` | BACnet 设备类型,`1=ASP`。 |
+| `name` | string | 是 | 空字符串会导致编辑失败 | 设备名称。 |
+| `ip` | string | 是 | 空字符串会导致编辑失败 | BACnet/IP 设备地址。 |
+| `device_id` | string | 是 | MCP 由 `bacnet_device_id` 转换 | BACnet 设备对象实例号。 |
+| `port` | int | 否 | MCP 默认 `47808` | BACnet/IP UDP 端口。 |
+| `net` | int | 否 | MCP 默认 `0` | BACnet 网络号。 |
+| `asp_ip` | string | 否 | MCP 默认空字符串 | RP 设备关联 ASP 地址。 |
+| `device_group_id` | int | 否 | MCP 默认 `0` | 设备分组 ID。 |
+| `timeout` | int | 否 | MCP 默认 `3` | 超时时间。 |
+| `is_persistent` | bool | 否 | MCP 默认 `false` | 是否持久化。 |
+| `alarm_interval` | int | 否 | MCP 默认 `90` | 告警间隔。 |
+| `collect_interval` | int | 否 | MCP 默认 `5` | 采集周期。 |
+
+#### 请求示例
+
+```json
+{
+  "ori_id": 1,
+  "type": 1,
+  "name": "bacnet_edited",
+  "ip": "192.168.75.241",
+  "device_id": "12345",
+  "port": 47808,
+  "net": 0,
+  "asp_ip": "",
+  "device_group_id": 0,
+  "timeout": 3,
+  "is_persistent": false,
+  "alarm_interval": 90,
+  "collect_interval": 5
+}
+```
+
+#### 成功响应
+
+```json
+{
+  "state": 0,
+  "state_info": "成功"
+}
+```
+
+### 3. 创建 BACnet 采集点位
+
+#### 基本信息
+
+- URL:`/api/collector/bacnet/point/add_collect_point`
+- 方法:`POST`
+- 处理函数:`bacnetPointAddBatch`
+- 请求结构:`ReqBacnetPointsAddBatch`
+
+MCP 工具 `collector.bacnet_point_create` 接收扁平 `points` 数组并逐个调用本接口。每个 MCP 点位必须自带 `device_id`,MCP 会包装为 `{device_id, points:[...]}`。
+
+#### 请求字段
+
+| 字段 | 类型 | 必填 | 默认/行为 | 含义 |
+|---|---|---|---|---|
+| `device_id` | int | 是 | 设备不存在返回失败 | 所属 BACnet 设备 ID。 |
+| `points` | object[] | 是 | 至少 1 个点位 | 要创建的点位数组。 |
+| `points[].object_type` | string | 是 | 空字符串会导致采集异常 | BACnet 对象类型,如 `AnalogInput`。 |
+| `points[].object_id` | int | 是 | `0..4194303` | BACnet 对象实例号。 |
+| `points[].name` | string | 建议传 | MCP 可用 `object_name` 补齐 | 点位名称。 |
+| `points[].object_name` | string | 是 | MCP 可用 `name` 补齐 | BACnet 对象名。 |
+| `points[].point_id` | string | 否 | MCP 默认空字符串 | 外部点位标识。 |
+| `points[].priority` | int/null | 否 | MCP 默认 `null` | 写值优先级。 |
+| `points[].units` | string | 否 | MCP 默认空字符串 | 单位。 |
+| `points[].value_type` | int | 否 | MCP 默认 `0` | BACnet PresentValue 类型。常见值:`1=Boolean`、`2=Unsigned`、`3=Signed`、`4=Real`、`5=Double`、`6=Enumerated`。 |
+| `points[].group_id` | int | 否 | MCP 默认 `0` | 点位分组 ID。 |
+| `points[].scale_ratio` | float64 | 否 | MCP 默认 `1` | 缩放系数。 |
+| `points[].value_offset` | float64 | 否 | MCP 默认 `0` | 值偏移。 |
+| `points[].describe` | string | 否 | MCP 默认空字符串 | 点位描述。 |
+
+#### 请求示例
+
+```json
+{
+  "device_id": 1,
+  "points": [
+    {
+      "object_type": "AnalogInput",
+      "object_id": 1,
+      "name": "zone_temperature",
+      "object_name": "zone_temperature",
+      "point_id": "AI_TEMP",
+      "priority": null,
+      "units": "degC",
+      "value_type": 4,
+      "group_id": 0,
+      "scale_ratio": 1,
+      "value_offset": 0,
+      "describe": "Zone temperature"
+    }
+  ]
+}
+```
+
+#### 成功响应
+
+```json
+{
+  "state": 0,
+  "state_info": "成功"
+}
+```
+
+### 4. 编辑 BACnet 采集点位
+
+#### 基本信息
+
+- URL:`/api/collector/bacnet/point/edit`
+- 方法:`POST`
+- 处理函数:`bacnetPointEdit`
+- 请求结构:`ReqBacnetPointEdit`
+
+MCP 工具 `collector.bacnet_point_edit` 使用 `ori_id` 表示原点位 ID,并映射为该接口的 `id` 字段。`ori_id` 对应 `collector.device_points` 返回的 `data.point[].id`。
+
+#### 请求字段
+
+| 字段 | 类型 | 必填 | 默认/行为 | 含义 |
+|---|---|---|---|---|
+| `id` | int | 是 | binding 必填 | 要编辑的原采集点位 ID。 |
+| `object_type` | string | 是 | 空字符串会导致采集异常 | BACnet 对象类型。 |
+| `object_id` | int | 是 | `0..4194303` | BACnet 对象实例号。 |
+| `name` | string | 建议传 | MCP 可用 `object_name` 补齐 | 点位名称。 |
+| `object_name` | string | 是 | MCP 可用 `name` 补齐 | BACnet 对象名。 |
+| `point_id` | string | 否 | MCP 默认空字符串 | 外部点位标识。 |
+| `priority` | int/null | 否 | MCP 默认 `null` | 写值优先级。 |
+| `units` | string | 否 | MCP 默认空字符串 | 单位。 |
+| `value_type` | int | 否 | MCP 默认 `0` | BACnet PresentValue 类型。 |
+| `group_id` | int | 否 | MCP 默认 `0` | 点位分组 ID。 |
+| `scale_ratio` | float64 | 否 | MCP 默认 `1` | 缩放系数。 |
+| `value_offset` | float64 | 否 | MCP 默认 `0` | 值偏移。 |
+| `describe` | string | 否 | MCP 默认空字符串 | 点位描述。 |
+| `invalid_values` | string | 否 | MCP 默认空字符串 | 无效值列表,多个值用逗号分隔。 |
+| `valid_range_start` | number/null | 否 | MCP 默认 `null` | 合法范围最小值。 |
+| `valid_range_end` | number/null | 否 | MCP 默认 `null` | 合法范围最大值。 |
+
+#### 请求示例
+
+```json
+{
+  "id": 101,
+  "object_type": "AnalogInput",
+  "object_id": 1,
+  "name": "zone_temperature_edited",
+  "object_name": "zone_temperature_edited",
+  "point_id": "AI_TEMP_EDITED",
+  "priority": null,
+  "units": "degC",
+  "value_type": 4,
+  "group_id": 0,
+  "scale_ratio": 1,
+  "value_offset": 0,
+  "describe": "Edited zone temperature",
+  "invalid_values": "",
+  "valid_range_start": null,
+  "valid_range_end": null
+}
+```
+
+#### 成功响应
+
+```json
+{
+  "state": 0,
+  "state_info": "成功"
+}
+```
+
 ## 连接/断开设备接口
 
 ### 基本信息

+ 194 - 0
tests/test_collector_api.py

@@ -817,6 +817,200 @@ class CollectorApiTests(unittest.TestCase):
         with self.assertRaisesRegex(ValueError, "device_type is required"):
             collector_api.disconnect_device("dev-01", device_id=1, device_type="")
 
+    def test_create_bacnet_device_merges_defaults_and_posts_to_collector(self) -> None:
+        self._patch_project()
+        response = {"state": 0, "state_info": "成功"}
+        with patch(
+            "data_collector_mcp.collector_api.request_json",
+            return_value=response,
+        ) as request_json:
+            result = collector_api.create_bacnet_device(
+                "dev-01",
+                {
+                    "name": "bacnet_1",
+                    "ip": "192.168.1.20",
+                    "bacnet_device_id": 12345,
+                },
+            )
+
+        self.assertEqual(result, response)
+        self.assertEqual(request_json.call_args.args[:3], ("POST", "http://collector.test/api/collector/device", "token"))
+        payload = request_json.call_args.kwargs["json_payload"]
+        self.assertEqual(payload["type"], "bacnet")
+        self.assertEqual(payload["device_type"], 1)
+        self.assertEqual(payload["port"], 47808)
+        self.assertEqual(payload["bacnet_net"], 0)
+        self.assertEqual(payload["asp_ip"], "")
+        self.assertEqual(payload["timeout"], 3)
+        self.assertFalse(payload["is_persistent"])
+        self.assertEqual(payload["group_id"], 0)
+        self.assertEqual(payload["alarm_interval"], 90)
+        self.assertEqual(payload["collect_interval"], 5)
+
+    def test_create_bacnet_devices_batches_then_matches_largest_id(self) -> None:
+        self._patch_project()
+        devices = [{"name": "bacnet_1", "ip": "192.168.1.20", "bacnet_device_id": 12345, "group_id": 10}]
+        device_list = {
+            "state": 0,
+            "devices": [
+                {
+                    "id": 1,
+                    "name": "group",
+                    "type": "devicegroup",
+                    "groups": [
+                        {
+                            "id": 3,
+                            "name": "bacnet_1",
+                            "type": "bacnet",
+                            "device_type": 1,
+                            "ip": "192.168.1.20",
+                            "port": 47808,
+                            "bacnet_device_id": 12345,
+                            "bacnet_net": 0,
+                            "group_id": 10,
+                        },
+                        {
+                            "id": 9,
+                            "name": "bacnet_1",
+                            "type": "bacnet",
+                            "device_type": 1,
+                            "ip": "192.168.1.20",
+                            "port": 47808,
+                            "bacnet_device_id": 12345,
+                            "bacnet_net": 0,
+                            "group_id": 10,
+                        },
+                    ],
+                }
+            ],
+        }
+        with patch(
+            "data_collector_mcp.collector_api.request_json",
+            side_effect=[{"state": 0}, device_list],
+        ):
+            result = collector_api.create_bacnet_devices("dev-01", devices)
+
+        self.assertEqual(result["state"], 0)
+        self.assertEqual(result["summary"], {"total": 1, "created": 1, "matched": 1, "failed": 0})
+        self.assertEqual(result["results"][0]["device_id"], 9)
+
+    def test_edit_bacnet_device_maps_to_legacy_endpoint(self) -> None:
+        self._patch_project()
+        response = {"state": 0, "state_info": "成功"}
+        with patch(
+            "data_collector_mcp.collector_api.request_json",
+            return_value=response,
+        ) as request_json:
+            result = collector_api.edit_bacnet_device(
+                "dev-01",
+                {
+                    "ori_id": 9,
+                    "name": "bacnet_edited",
+                    "ip": "192.168.1.21",
+                    "bacnet_device_id": 54321,
+                    "bacnet_net": 2,
+                    "device_group_id": 10,
+                },
+            )
+
+        self.assertEqual(result, response)
+        self.assertEqual(
+            request_json.call_args.args[:3],
+            ("POST", "http://collector.test/api/collector/bacnet/device/edit", "token"),
+        )
+        payload = request_json.call_args.kwargs["json_payload"]
+        self.assertEqual(payload["ori_id"], 9)
+        self.assertEqual(payload["type"], 1)
+        self.assertEqual(payload["device_id"], "54321")
+        self.assertEqual(payload["net"], 2)
+        self.assertEqual(payload["device_group_id"], 10)
+        self.assertNotIn("bacnet_device_id", payload)
+        self.assertNotIn("bacnet_net", payload)
+
+    def test_create_bacnet_point_wraps_single_point_batch_payload(self) -> None:
+        self._patch_project()
+        response = {"state": 0, "state_info": "成功"}
+        with patch(
+            "data_collector_mcp.collector_api.request_json",
+            return_value=response,
+        ) as request_json:
+            result = collector_api.create_bacnet_point(
+                "dev-01",
+                {
+                    "device_id": 9,
+                    "name": "zone_temperature",
+                    "object_type": "AnalogInput",
+                    "object_id": 1,
+                    "point_id": "AI_TEMP",
+                    "value_type": 4,
+                },
+            )
+
+        self.assertEqual(result, response)
+        self.assertEqual(
+            request_json.call_args.args[:3],
+            ("POST", "http://collector.test/api/collector/bacnet/point/add_collect_point", "token"),
+        )
+        payload = request_json.call_args.kwargs["json_payload"]
+        self.assertEqual(payload["device_id"], 9)
+        self.assertEqual(len(payload["points"]), 1)
+        point = payload["points"][0]
+        self.assertEqual(point["object_name"], "zone_temperature")
+        self.assertEqual(point["name"], "zone_temperature")
+        self.assertEqual(point["object_type"], "AnalogInput")
+        self.assertEqual(point["object_id"], 1)
+        self.assertEqual(point["point_id"], "AI_TEMP")
+        self.assertEqual(point["value_type"], 4)
+        self.assertEqual(point["scale_ratio"], 1)
+        self.assertNotIn("invalid_values", point)
+
+    def test_edit_bacnet_point_maps_ori_id_to_id(self) -> None:
+        self._patch_project()
+        response = {"state": 0, "state_info": "成功"}
+        with patch(
+            "data_collector_mcp.collector_api.request_json",
+            return_value=response,
+        ) as request_json:
+            result = collector_api.edit_bacnet_point(
+                "dev-01",
+                {
+                    "ori_id": 101,
+                    "name": "zone_temperature_edited",
+                    "object_type": "AnalogInput",
+                    "object_id": 1,
+                    "point_id": "AI_TEMP_EDITED",
+                    "invalid_values": "-9999",
+                },
+            )
+
+        self.assertEqual(result, response)
+        self.assertEqual(
+            request_json.call_args.args[:3],
+            ("POST", "http://collector.test/api/collector/bacnet/point/edit", "token"),
+        )
+        payload = request_json.call_args.kwargs["json_payload"]
+        self.assertEqual(payload["id"], 101)
+        self.assertEqual(payload["object_name"], "zone_temperature_edited")
+        self.assertEqual(payload["point_id"], "AI_TEMP_EDITED")
+        self.assertEqual(payload["invalid_values"], "-9999")
+        self.assertIsNone(payload["valid_range_start"])
+        self.assertNotIn("ori_id", payload)
+
+    def test_bacnet_payloads_validate_required_fields(self) -> None:
+        self._patch_project()
+        with self.assertRaisesRegex(ValueError, "payload.bacnet_device_id is required"):
+            collector_api.create_bacnet_device("dev-01", {"name": "bacnet_1", "ip": "192.168.1.20"})
+        with self.assertRaisesRegex(ValueError, "payload.object_name is required"):
+            collector_api.create_bacnet_point(
+                "dev-01",
+                {"device_id": 1, "object_type": "AnalogInput", "object_id": 1},
+            )
+        with self.assertRaisesRegex(ValueError, "payload.object_id must be between"):
+            collector_api.edit_bacnet_point(
+                "dev-01",
+                {"ori_id": 1, "name": "bad", "object_type": "AnalogInput", "object_id": 4_194_304},
+            )
+
 
 if __name__ == "__main__":
     unittest.main()

+ 55 - 0
tests/test_gateway_api.py

@@ -61,6 +61,61 @@ class GatewayApiTests(unittest.TestCase):
         self.assertEqual(payload["word_byte_order"], "DCBA")
         self.assertEqual(payload["address_base"], 1)
 
+    def test_bacnet_point_collect_test_posts_gateway_payload(self) -> None:
+        response = {"code": 0, "msg": "success", "data": {"points": []}}
+        with patch(
+            "data_collector_mcp.gateway_api.find_project_config",
+            return_value={"project_key": "dev-01", "base_url": "http://gateway.test"},
+        ), patch(
+            "data_collector_mcp.gateway_api.request_json",
+            return_value=response,
+        ) as request_json:
+            result = gateway_api.bacnet_point_collect_test(
+                "dev-01",
+                ip="192.168.1.20",
+                bacnet_device_id=12345,
+                points=[{"object_type": "AnalogInput", "object_id": 1}],
+            )
+
+        self.assertEqual(result, response)
+        request_json.assert_called_once_with(
+            "POST",
+            "http://gateway.test/api/dc-gateway/bacnet/read_points",
+            json_payload={
+                "ip": "192.168.1.20",
+                "bacnet_device_id": 12345,
+                "port": 47808,
+                "points": [{"object_type": "AnalogInput", "object_id": 1}],
+            },
+        )
+
+    def test_bacnet_point_search_posts_gateway_payload(self) -> None:
+        response = {"code": 0, "msg": "success", "data": {"points": []}}
+        with patch(
+            "data_collector_mcp.gateway_api.find_project_config",
+            return_value={"project_key": "dev-01", "base_url": "http://gateway.test"},
+        ), patch(
+            "data_collector_mcp.gateway_api.request_json",
+            return_value=response,
+        ) as request_json:
+            result = gateway_api.bacnet_point_search(
+                "dev-01",
+                ip="192.168.1.20",
+                bacnet_device_id=12345,
+                port=47809,
+            )
+
+        self.assertEqual(result, response)
+        request_json.assert_called_once_with(
+            "POST",
+            "http://gateway.test/api/dc-gateway/bacnet/search_points",
+            json_payload={
+                "ip": "192.168.1.20",
+                "bacnet_device_id": 12345,
+                "port": 47809,
+            },
+        )
+
     def test_s7_raw_read_posts_gateway_payload(self) -> None:
         response = {"code": 0, "msg": "success", "data": {"communication": []}}
         with patch(

+ 125 - 1
tests/test_server_tools.py

@@ -5,7 +5,7 @@ import inspect
 import unittest
 from unittest.mock import patch
 
-from data_collector_mcp import app, common_server, modbus_server, s7_server
+from data_collector_mcp import app, bacnet_server, common_server, modbus_server, s7_server
 
 
 class ServerToolTests(unittest.TestCase):
@@ -13,6 +13,7 @@ class ServerToolTests(unittest.TestCase):
         self.assertIs(app.common_server, common_server)
         self.assertIs(app.modbus_server, modbus_server)
         self.assertIs(app.s7_server, s7_server)
+        self.assertIs(app.bacnet_server, bacnet_server)
 
     def test_app_registers_expected_mcp_tools(self) -> None:
         tools = asyncio.run(app.mcp.list_tools())
@@ -27,6 +28,8 @@ class ServerToolTests(unittest.TestCase):
                 "collector.device_disconnect",
                 "collector.device_points",
                 "modbus.point_collect_test",
+                "bacnet.point_collect_test",
+                "bacnet.point_search",
                 "s7.raw_read",
                 "s7.point_collect_test",
                 "s7.connect_scan",
@@ -38,6 +41,10 @@ class ServerToolTests(unittest.TestCase):
                 "collector.s7_device_edit",
                 "collector.s7_point_create",
                 "collector.s7_point_edit",
+                "collector.bacnet_device_create",
+                "collector.bacnet_device_edit",
+                "collector.bacnet_point_create",
+                "collector.bacnet_point_edit",
             },
         )
 
@@ -380,6 +387,123 @@ class ServerToolTests(unittest.TestCase):
             self.assertEqual(s7_server.s7_connect_scan("dev-01", ip="192.168.1.10"), {"code": 0})
         connect_scan.assert_called_once_with("dev-01", ip="192.168.1.10")
 
+    def test_bacnet_gateway_tools_forward_to_api(self) -> None:
+        with patch(
+            "data_collector_mcp.bacnet_server.api_bacnet_point_collect_test",
+            return_value={"code": 0},
+        ) as point_test:
+            self.assertEqual(
+                bacnet_server.bacnet_point_collect_test(
+                    project_key="dev-01",
+                    ip="192.168.1.20",
+                    bacnet_device_id=12345,
+                    port=47809,
+                    points=[{"object_type": "AnalogInput", "object_id": 1}],
+                ),
+                {"code": 0},
+            )
+        point_test.assert_called_once_with(
+            "dev-01",
+            ip="192.168.1.20",
+            bacnet_device_id=12345,
+            port=47809,
+            points=[{"object_type": "AnalogInput", "object_id": 1}],
+        )
+
+        with patch(
+            "data_collector_mcp.bacnet_server.api_bacnet_point_search",
+            return_value={"code": 0},
+        ) as point_search:
+            self.assertEqual(
+                bacnet_server.bacnet_point_search(
+                    project_key="dev-01",
+                    ip="192.168.1.20",
+                    bacnet_device_id=12345,
+                ),
+                {"code": 0},
+            )
+        point_search.assert_called_once_with(
+            "dev-01",
+            ip="192.168.1.20",
+            bacnet_device_id=12345,
+            port=47808,
+        )
+
+    def test_bacnet_collector_tools_forward_to_api(self) -> None:
+        devices = [{"name": "bacnet_1", "ip": "192.168.1.20", "bacnet_device_id": 12345}]
+        with patch(
+            "data_collector_mcp.bacnet_server.api_create_bacnet_devices",
+            return_value={"state": 0},
+        ) as api_create:
+            result = bacnet_server.collector_bacnet_device_create(project_key="dev-01", devices=devices)
+
+        self.assertEqual(result, {"state": 0})
+        api_create.assert_called_once_with("dev-01", devices)
+
+        with patch(
+            "data_collector_mcp.bacnet_server.api_edit_bacnet_device",
+            return_value={"state": 0},
+        ) as api_edit:
+            result = bacnet_server.collector_bacnet_device_edit(
+                project_key="dev-01",
+                ori_id=9,
+                name="bacnet_edited",
+                ip="192.168.1.21",
+                bacnet_device_id=54321,
+                device_group_id=10,
+            )
+
+        self.assertEqual(result, {"state": 0})
+        api_edit.assert_called_once_with(
+            "dev-01",
+            {
+                "ori_id": 9,
+                "name": "bacnet_edited",
+                "ip": "192.168.1.21",
+                "bacnet_device_id": 54321,
+                "port": 47808,
+                "bacnet_net": 0,
+                "asp_ip": "",
+                "is_persistent": False,
+                "device_group_id": 10,
+                "timeout": 3,
+                "alarm_interval": 90,
+                "collect_interval": 5,
+            },
+        )
+
+        points = [{"device_id": 9, "name": "zone_temperature", "object_type": "AnalogInput", "object_id": 1}]
+        with patch(
+            "data_collector_mcp.bacnet_server.api_create_bacnet_points",
+            return_value={"state": 0},
+        ) as api_point_create:
+            result = bacnet_server.collector_bacnet_point_create(project_key="dev-01", points=points)
+
+        self.assertEqual(result, {"state": 0})
+        api_point_create.assert_called_once_with("dev-01", points)
+
+        with patch(
+            "data_collector_mcp.bacnet_server.api_edit_bacnet_point",
+            return_value={"state": 0},
+        ) as api_point_edit:
+            result = bacnet_server.collector_bacnet_point_edit(
+                project_key="dev-01",
+                ori_id=101,
+                name="zone_temperature_edited",
+                object_type="AnalogInput",
+                object_id=1,
+                point_id="AI_TEMP_EDITED",
+            )
+
+        self.assertEqual(result, {"state": 0})
+        payload = api_point_edit.call_args.args[1]
+        self.assertEqual(api_point_edit.call_args.args[0], "dev-01")
+        self.assertEqual(payload["ori_id"], 101)
+        self.assertEqual(payload["name"], "zone_temperature_edited")
+        self.assertEqual(payload["object_type"], "AnalogInput")
+        self.assertEqual(payload["object_id"], 1)
+        self.assertEqual(payload["point_id"], "AI_TEMP_EDITED")
+
 
 if __name__ == "__main__":
     unittest.main()