Browse Source

统一bacnet的入参格式

Lu Xianghui 1 month ago
parent
commit
81a93489ad

+ 2 - 2
README.md

@@ -170,7 +170,7 @@ BACnet 设备创建调用通用 `{data_collector_base_url}/api/collector/device`
 批量创建 BACnet 点位时传 `points` 数组,每个点位必须包含:
 
 - `points[].device_id`:所属设备 ID
-- `points[].object_type`:BACnet 对象类型,如 `AnalogInput`
+- `points[].object_type`:BACnet 对象类型,如 `AnalogInput`;也支持 `analog-input`、`analogInput` 等常见写法,MCP 会在调用上游前规范为 `AnalogInput` 这类格式
 - `points[].object_id`:BACnet 对象实例号,范围 `0..4194303`
 - `points[].name` 或 `points[].object_name`:点位名称或 BACnet 对象名
 
@@ -210,7 +210,7 @@ BACnet 点位创建调用 `{data_collector_base_url}/api/collector/bacnet/point/
 
 `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_type`:BACnet 对象类型,如 `AnalogInput`、`analog-input`、`analogInput`;MCP 会统一规范为 `AnalogInput` 这类格式后再读取
 - `points[].object_id`:BACnet 对象实例号,范围 `0..4194303`
 
 调用示例:

+ 7 - 2
data_collector_mcp/bacnet_server.py

@@ -60,8 +60,9 @@ class BacnetPointCreateItem(TypedDict):
         "通过采集网关读取 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 形式。"
+        "points 每项必须包含 object_type 和 object_id;object_type 支持 AnalogInput 这类格式。"
+        "如果传入 analogInput、analog-input 等常见写法,需要先转换为 AnalogInput 这类格式;"
+        "网关响应也会规范为 AnalogInput 形式。"
         "响应 data.points[].present_value 为当前值;响应透传上游 JSON,code=0 表示业务成功。"
     ),
 )
@@ -177,6 +178,8 @@ def collector_bacnet_device_edit(
         "汇采-批量创建 BACnet 采集点位。依次调用 "
         "{data_collector_base_url}/api/collector/bacnet/point/add_collect_point。"
         "points 每项必须传 device_id、object_type、object_id,并传 name 或 object_name;"
+        "object_type 支持 AnalogInput 这类格式;如果传入 analogInput、analog-input 等常见写法,"
+        "需要先转换为 AnalogInput 这类格式;"
         "MCP 会将单个点位包装成汇采接口要求的 {device_id, points:[...]}。"
         "每个点位会补齐默认值: point_id='', priority=null, units='', value_type=0, "
         "group_id=0, scale_ratio=1, value_offset=0, describe=''。"
@@ -197,6 +200,8 @@ def collector_bacnet_point_create(
         "汇采-编辑 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。"
+        "object_type 支持 AnalogInput 这类格式;如果传入 analogInput、analog-input 等常见写法,"
+        "MCP 会先转换为 AnalogInput 这类格式再调用汇采接口。"
         "该接口是全量更新语义,未传字段可能被默认值覆盖。默认参数: 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。"

+ 2 - 1
data_collector_mcp/collector_api.py

@@ -5,6 +5,7 @@ from typing import Any
 from .auth import find_project_config, resolve_project_token
 from .http_client import request_json
 from .protocols import BACNET_SPEC, MODBUS_SPEC, S7_SPEC
+from .protocols.bacnet import normalize_bacnet_object_type
 from .protocols.modbus import MODBUS_POINT_TYPE_ALIASES, MODBUS_REGISTER_TYPE_ALIASES
 from .protocols.s7 import S7_POINT_TYPE_ALIASES, S7_REGISTER_TYPE_ALIASES
 
@@ -293,7 +294,7 @@ def _normalize_bacnet_point_payload(payload: dict[str, Any], *, require_device_i
             _require_present(normalized, "device_id"),
             "payload.device_id",
         )
-    normalized["object_type"] = _require_non_empty_text(normalized, "object_type")
+    normalized["object_type"] = normalize_bacnet_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:

+ 7 - 1
data_collector_mcp/gateway_api.py

@@ -5,6 +5,7 @@ from typing import Any
 from .auth import find_project_config
 from .http_client import request_json
 from .protocols import BACNET_SPEC, MODBUS_SPEC, S7_SPEC
+from .protocols.bacnet import normalize_bacnet_object_type
 
 
 def _request_gateway(project_key: str, path: str | None, payload: dict[str, Any], protocol: str) -> dict[str, Any]:
@@ -53,11 +54,16 @@ def bacnet_point_collect_test(
     points: list[dict[str, Any]],
     port: int = 47808,
 ) -> dict[str, Any]:
+    normalized_points = []
+    for point in points:
+        normalized_point = dict(point)
+        normalized_point["object_type"] = normalize_bacnet_object_type(normalized_point.get("object_type"))
+        normalized_points.append(normalized_point)
     payload = {
         "ip": ip,
         "bacnet_device_id": bacnet_device_id,
         "port": port,
-        "points": points,
+        "points": normalized_points,
     }
     return _request_gateway(project_key, BACNET_SPEC.point_test_path, payload, "bacnet point test")
 

+ 23 - 0
data_collector_mcp/protocols/bacnet.py

@@ -1,8 +1,31 @@
 from __future__ import annotations
 
+from typing import Any
+
 from .base import ProtocolSpec
 
 
+BACNET_OBJECT_TYPE_ALIASES = {
+    "analoginput": "AnalogInput",
+    "analogoutput": "AnalogOutput",
+    "analogvalue": "AnalogValue",
+    "binaryinput": "BinaryInput",
+    "binaryoutput": "BinaryOutput",
+    "binaryvalue": "BinaryValue",
+    "multistateinput": "MultiStateInput",
+    "multistateoutput": "MultiStateOutput",
+    "multistatevalue": "MultiStateValue",
+}
+
+
+def normalize_bacnet_object_type(value: Any) -> str:
+    raw_value = str(value or "").strip()
+    if not raw_value:
+        raise ValueError("payload.object_type is required")
+    lookup_key = "".join(char for char in raw_value if char.isalnum()).lower()
+    return BACNET_OBJECT_TYPE_ALIASES.get(lookup_key, raw_value)
+
+
 BACNET_SPEC = ProtocolSpec(
     protocol="bacnet",
     create_device_path="/api/collector/device",

+ 7 - 7
docs/接口汇总.md

@@ -1018,7 +1018,7 @@ BACnet 网关用于通过 HTTP 直连 BACnet/IP 设备,当前支持点位读
 
 ### BACnet 对象类型
 
-请求中的 `object_type` 支持常见写法,例如 `AnalogInput`、`analogInput`、`analog-input`。响应统一返回 `AnalogInput` 这类格式。
+MCP/网关请求中的 `object_type` 支持常见写法,例如 `AnalogInput`、`analogInput`、`analog-input`;调用底层 BACnet 读取前会统一规范为 `AnalogInput` 这类格式,避免把 `analog-input` 原样传给底层导致采集不到数据。响应统一返回 `AnalogInput` 这类格式。
 
 常见点位对象类型:
 
@@ -1069,7 +1069,7 @@ MCP 工具 `bacnet.point_collect_test` 会调用该接口并透传响应。
 | 字段 | 类型 | 必填 | 默认值 | 允许值或范围 | 含义 |
 |---|---|---:|---|---|---|
 | `points` | object[] | 是 | 无 | 至少 1 个点位 | BACnet 点位对象列表。 |
-| `points[].object_type` | string | 是 | 无 | 合法 BACnet 对象类型写法 | BACnet 对象类型。 |
+| `points[].object_type` | string | 是 | 无 | 合法 BACnet 对象类型写法 | BACnet 对象类型;如 `analog-input` 会先规范为 `AnalogInput`。 |
 | `points[].object_id` | integer | 是 | 无 | `0..4194303` | BACnet 对象实例号。 |
 
 #### 成功返回示例
@@ -1233,7 +1233,7 @@ 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` 等写法。
+3. 在 `points` 中填入 `object_type` 和 `object_id`,`object_type` 可使用 `AnalogInput`、`analogInput`、`analog-input` 等写法,MCP/网关会统一规范为 `AnalogInput` 这类格式后再读取
 4. 发送请求后检查响应体 `code`,不要只检查 HTTP 状态码。
 
 
@@ -2367,7 +2367,7 @@ MCP 工具 `collector.bacnet_device_edit` 会把入参 `bacnet_device_id` 映射
 - 处理函数:`bacnetPointAddBatch`
 - 请求结构:`ReqBacnetPointsAddBatch`
 
-MCP 工具 `collector.bacnet_point_create` 接收扁平 `points` 数组并逐个调用本接口。每个 MCP 点位必须自带 `device_id`,MCP 会包装为 `{device_id, points:[...]}`。
+MCP 工具 `collector.bacnet_point_create` 接收扁平 `points` 数组并逐个调用本接口。每个 MCP 点位必须自带 `device_id`,MCP 会包装为 `{device_id, points:[...]}`。MCP 入参可使用 `AnalogInput`、`analogInput`、`analog-input` 等常见写法,但调用汇采接口前会统一转换为 `AnalogInput` 这类格式;直调汇采接口时应使用转换后的格式。
 
 #### 请求字段
 
@@ -2375,7 +2375,7 @@ MCP 工具 `collector.bacnet_point_create` 接收扁平 `points` 数组并逐个
 |---|---|---|---|---|
 | `device_id` | int | 是 | 设备不存在返回失败 | 所属 BACnet 设备 ID。 |
 | `points` | object[] | 是 | 至少 1 个点位 | 要创建的点位数组。 |
-| `points[].object_type` | string | 是 | 空字符串会导致采集异常 | BACnet 对象类型,如 `AnalogInput`。 |
+| `points[].object_type` | string | 是 | MCP 会规范为 `AnalogInput` 这类格式 | BACnet 对象类型,如 `AnalogInput`;不要把 `analog-input` 原样传给汇采。 |
 | `points[].object_id` | int | 是 | `0..4194303` | BACnet 对象实例号。 |
 | `points[].name` | string | 建议传 | MCP 可用 `object_name` 补齐 | 点位名称。 |
 | `points[].object_name` | string | 是 | MCP 可用 `name` 补齐 | BACnet 对象名。 |
@@ -2430,14 +2430,14 @@ MCP 工具 `collector.bacnet_point_create` 接收扁平 `points` 数组并逐个
 - 处理函数:`bacnetPointEdit`
 - 请求结构:`ReqBacnetPointEdit`
 
-MCP 工具 `collector.bacnet_point_edit` 使用 `ori_id` 表示原点位 ID,并映射为该接口的 `id` 字段。`ori_id` 对应 `collector.device_points` 返回的 `data.point[].id`。
+MCP 工具 `collector.bacnet_point_edit` 使用 `ori_id` 表示原点位 ID,并映射为该接口的 `id` 字段。`ori_id` 对应 `collector.device_points` 返回的 `data.point[].id`。MCP 会将 `analog-input`、`analogInput` 等常见写法统一转换为 `AnalogInput` 这类格式后再调用汇采接口。
 
 #### 请求字段
 
 | 字段 | 类型 | 必填 | 默认/行为 | 含义 |
 |---|---|---|---|---|
 | `id` | int | 是 | binding 必填 | 要编辑的原采集点位 ID。 |
-| `object_type` | string | 是 | 空字符串会导致采集异常 | BACnet 对象类型。 |
+| `object_type` | string | 是 | MCP 会规范为 `AnalogInput` 这类格式 | BACnet 对象类型;不要把 `analog-input` 原样传给汇采。 |
 | `object_id` | int | 是 | `0..4194303` | BACnet 对象实例号。 |
 | `name` | string | 建议传 | MCP 可用 `object_name` 补齐 | 点位名称。 |
 | `object_name` | string | 是 | MCP 可用 `name` 补齐 | BACnet 对象名。 |

+ 36 - 0
tests/test_collector_api.py

@@ -985,6 +985,42 @@ class CollectorApiTests(unittest.TestCase):
         self.assertEqual(point["scale_ratio"], 1)
         self.assertNotIn("invalid_values", point)
 
+    def test_bacnet_point_payloads_normalize_object_type(self) -> None:
+        self._patch_project()
+        with patch(
+            "data_collector_mcp.collector_api.request_json",
+            return_value={"state": 0},
+        ) as request_json:
+            collector_api.create_bacnet_point(
+                "dev-01",
+                {
+                    "device_id": 9,
+                    "name": "zone_temperature",
+                    "object_type": "analog-input",
+                    "object_id": 1,
+                },
+            )
+
+        payload = request_json.call_args.kwargs["json_payload"]
+        self.assertEqual(payload["points"][0]["object_type"], "AnalogInput")
+
+        with patch(
+            "data_collector_mcp.collector_api.request_json",
+            return_value={"state": 0},
+        ) as request_json:
+            collector_api.edit_bacnet_point(
+                "dev-01",
+                {
+                    "ori_id": 101,
+                    "name": "zone_temperature_edited",
+                    "object_type": "analogInput",
+                    "object_id": 1,
+                },
+            )
+
+        payload = request_json.call_args.kwargs["json_payload"]
+        self.assertEqual(payload["object_type"], "AnalogInput")
+
     def test_edit_bacnet_point_maps_ori_id_to_id(self) -> None:
         self._patch_project()
         response = {"state": 0, "state_info": "成功"}

+ 18 - 0
tests/test_gateway_api.py

@@ -89,6 +89,24 @@ class GatewayApiTests(unittest.TestCase):
             },
         )
 
+    def test_bacnet_point_collect_test_normalizes_object_type(self) -> None:
+        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={"code": 0},
+        ) as request_json:
+            gateway_api.bacnet_point_collect_test(
+                "dev-01",
+                ip="192.168.1.20",
+                bacnet_device_id=12345,
+                points=[{"object_type": "analog-input", "object_id": 1}],
+            )
+
+        payload = request_json.call_args.kwargs["json_payload"]
+        self.assertEqual(payload["points"][0]["object_type"], "AnalogInput")
+
     def test_bacnet_point_search_posts_gateway_payload(self) -> None:
         response = {"code": 0, "msg": "success", "data": {"points": []}}
         with patch(