Lu Xianghui пре 1 месец
родитељ
комит
f1c9b94e8e

+ 18 - 7
README.md

@@ -5,6 +5,7 @@
 首期提供:
 
 - `project.list`
+- `modbus.raw_read`
 - `modbus.point_collect_test`
 - `bacnet.point_collect_test`
 - `bacnet.point_search`
@@ -120,6 +121,16 @@ $env:DATABASE_URL = "postgresql+psycopg2://postgres:password@127.0.0.1:5432/data
 }
 ```
 
+## Modbus 网关工具
+
+`modbus.raw_read` 用于执行原始 Modbus TCP 读取,调用 `{base_url}/api/dc-gateway/modbus/read`。参数包括 `project_key`、`ip`、`port`、`slave_id`、`read`;可选默认值包括 `device_type=ModbusTCP`、`word_byte_order=ABCD`、`address_base=0`。`read` 必须包含 `function_code`、`address`、`quantity`。该工具不解析业务值,只返回 `data.communication[]` 中的 Tx/Rx 原始报文;如需转换后的点位值,请使用 `modbus.point_collect_test`。
+
+调用示例:
+
+```powershell
+& ".venv\Scripts\python.exe" tools\test_mcp_call.py --tool modbus.raw_read --args '{"project_key":"dev-01","ip":"192.168.75.240","port":502,"slave_id":1,"read":{"function_code":3,"address":0,"quantity":4}}'
+```
+
 批量创建 Modbus 设备时传 `devices` 数组,每个设备必须包含:
 
 - `devices[].device_type`:协议类型,`1=TCP`、`2=RTU`、`3=UDP`、`4=RTU OVER TCP`、`5=RTU OVER UDP`
@@ -166,16 +177,16 @@ S7 点位创建使用汇采格式,不是网关读取格式;不要把网关
 - `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 设备创建可选传 `port`、`bacnet_net`、`asp_ip` 等连接参数;不传时使用采集器默认值。创建结果会返回匹配到的设备 ID
 
 批量创建 BACnet 点位时传 `points` 数组,每个点位必须包含:
 
 - `points[].device_id`:所属设备 ID
-- `points[].object_type`:BACnet 对象类型,如 `AnalogInput`;也支持 `analog-input`、`analogInput` 等常见写法,MCP 会在调用上游前规范为 `AnalogInput` 这类格式
+- `points[].object_type`:BACnet 对象类型,如 `AnalogInput`;也支持 `analog-input`、`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=""`。
+BACnet 点位创建可选传 `point_id`、`priority`、`units`、`value_type`、`group_id`、`scale_ratio`、`value_offset`、`describe` 等字段。常见 `value_type`:`1=Boolean`、`2=Unsigned`、`3=Signed`、`4=Real`、`5=Double`、`6=Enumerated`。
 
 常见点表数据类型映射:
 
@@ -202,19 +213,19 @@ BACnet 点位创建调用 `{data_collector_base_url}/api/collector/bacnet/point/
 
 ## BACnet 网关工具
 
-`bacnet.point_search` 用于搜索 BACnet/IP 设备点位,调用 `{base_url}/api/dc-gateway/bacnet/search_points`。参数:
+`bacnet.point_search` 用于搜索 BACnet/IP 设备点位。参数:
 
 - `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` 数组:
+`bacnet.point_collect_test` 用于读取指定 BACnet 点位的 `present-value`。除设备字段外,还需要传 `points` 数组:
 
-- `points[].object_type`:BACnet 对象类型,如 `AnalogInput`、`analog-input`、`analogInput`;MCP 会统一规范为 `AnalogInput` 这类格式后再读取
+- `points[].object_type`:BACnet 对象类型,如 `AnalogInput`、`analog-input`、`analogInput`,会自动规范化
 - `points[].object_id`:BACnet 对象实例号,范围 `0..4194303`
 
-`bacnet.bbmd_whois` 用于通过 BBMD 执行 BACnet/IP Who-Is 设备发现,调用 `{base_url}/api/dc-gateway/bacnet/bbmd/whois`。参数只有 `project_key`,MCP 不发送请求体。BBMD 地址、端口、TTL、Who-Is 等待超时、设备号范围和本机绑定地址由采集网关环境变量配置,包括 `BACNET_BBMD_IP`、`BACNET_BBMD_PORT`、`BACNET_BBMD_TTL`、`BACNET_BBMD_WHOIS_TIMEOUT`、`BACNET_BBMD_LOW_LIMIT`、`BACNET_BBMD_HIGH_LIMIT`、`BACNET_BBMD_LOCAL_DEVICE_ID`、`BACNET_LOCAL_IP`、`BACNET_LOCAL_PORT`。响应透传网关 JSON,`code=0` 表示成功,`data.bbmd` 为本次 BBMD 请求配置,`data.devices[]` 为发现的 `I-Am` 设备列表,常见字段包括 `bacnet_device_id`、`ip`、`port`、`max_apdu`、`segmentation`、`vendor_id`。
+`bacnet.bbmd_whois` 用于通过 BBMD 执行 BACnet/IP Who-Is 设备发现。参数只有 `project_key`;BBMD 连接参数由采集网关配置。响应透传网关 JSON,`code=0` 表示成功,`data.devices[]` 为发现的 BACnet 设备列表,常见字段包括 `bacnet_device_id`、`ip`、`port`、`vendor_id`。
 
 调用示例:
 

+ 37 - 72
data_collector_mcp/bacnet_server.py

@@ -39,32 +39,33 @@ class BacnetPointSpec(TypedDict):
     description: NotRequired[str]
 
 
-class BacnetPointCreateItem(TypedDict):
+class BacnetPointCreateItem(BaseModel):
     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]
+    name: str = ""
+    object_name: str = ""
+    point_id: str = ""
+    priority: int | None = None
+    units: str = ""
+    value_type: int = 0
+    scale_ratio: float = 1
+    value_offset: float = 0
+    group_id: int = 0
+    invalid_values: str = ""
+    valid_range_start: float | None = None
+    valid_range_end: float | None = None
+    describe: 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 这类格式。"
-        "如果传入 analogInput、analog-input 等常见写法,需要先转换为 AnalogInput 这类格式;"
-        "网关响应也会规范为 AnalogInput 形式。"
-        "响应 data.points[].present_value 为当前值;响应透传上游 JSON,code=0 表示业务成功。"
+        "采集网关-读取 BACnet/IP 设备点位的值,只需传 project_key、ip、"
+        "bacnet_device_id 和 points;port 默认 47808。points 每项包含 object_type、object_id;"
+        "object_type 支持 AnalogInput、analogInput、analog-input"
+        "不需要传name字段"
+        "响应 data.points[].present_value 为当前值,code=0 表示成功。"
     ),
 )
 def bacnet_point_collect_test(
@@ -86,14 +87,7 @@ def bacnet_point_collect_test(
 @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 表示业务成功。"
+        "采集网关-搜索 BACnet/IP 设备点位。必须传 project_key、ip、bacnet_device_id;port 默认 47808。"
     ),
 )
 def bacnet_point_search(
@@ -113,16 +107,8 @@ def bacnet_point_search(
 @mcp.tool(
     name="bacnet.bbmd_whois",
     description=(
-        "通过采集网关执行 BACnet/IP BBMD Who-Is 设备发现。调用 "
-        "{base_url}/api/dc-gateway/bacnet/bbmd/whois,MCP 不发送请求体;"
-        "BBMD 地址、端口、TTL、Who-Is 等待超时、设备号范围、本机绑定地址等参数由采集网关环境变量配置,"
-        "包括 BACNET_BBMD_IP、BACNET_BBMD_PORT、BACNET_BBMD_TTL、"
-        "BACNET_BBMD_WHOIS_TIMEOUT、BACNET_BBMD_LOW_LIMIT、BACNET_BBMD_HIGH_LIMIT、"
-        "BACNET_BBMD_LOCAL_DEVICE_ID、BACNET_LOCAL_IP、BACNET_LOCAL_PORT。"
-        "网关会注册 BBMD Foreign Device,分发 Who-Is,收集 I-Am 响应并在结束前注销;"
-        "响应 data.bbmd 为本次 BBMD 请求配置,data.devices[] 为发现的 BACnet 设备,"
-        "常见字段包括 bacnet_device_id、ip、port、max_apdu、segmentation、vendor_id;"
-        "响应透传上游 JSON,code=0 表示业务成功。"
+        "采集网关-查询设备列表,通过 BBMD 执行 BACnet/IP Who-Is 设备发现。只需传 project_key。"
+        "响应 data.devices 为发现的 BACnet 设备,"
     ),
 )
 def bacnet_bbmd_whois(project_key: str) -> dict[str, Any]:
@@ -132,12 +118,8 @@ def bacnet_bbmd_whois(project_key: str) -> dict[str, Any]:
 @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 表示全部创建并匹配成功。"
+        "汇采-批量创建 BACnet 设备。devices 每项必须传 name、ip、bacnet_device_id;"
+        "返回批量创建结果和对应的设备 id"
     ),
 )
 def collector_bacnet_device_create(
@@ -150,12 +132,10 @@ def collector_bacnet_device_create(
 @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 表示业务成功。"
+        "汇采-编辑 BACnet 设备。必须传 ori_id、name、ip、bacnet_device_id;"
+        "ori_id是设备id,bacnet_device_id是设备号"
+        "编辑前设备不能处于已连接状态,"
+        "若已连接请先调用 collector.device_disconnect 且 device_type=bacnet。"
     ),
 )
 def collector_bacnet_device_edit(
@@ -195,37 +175,22 @@ def collector_bacnet_device_edit(
 @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;"
-        "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=''。"
-        "常见 value_type: 1=Boolean, 2=Unsigned, 3=Signed, 4=Real, 5=Double, 6=Enumerated。"
-        "响应返回批量结果,state=0 表示全部创建成功。"
+        "汇采-批量创建 BACnet 采集点位。points 每项必须传 device_id、object_type、object_id, name,"
+        "object_type 支持 AnalogInput、analogInput、analog-input"
     ),
 )
 def collector_bacnet_point_create(
     project_key: str,
     points: list[BacnetPointCreateItem],
 ) -> dict[str, Any]:
-    return api_create_bacnet_points(project_key, points)
+    return api_create_bacnet_points(project_key, [_dump_model_or_dict(item) for item in 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。"
-        "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。"
-        "响应透传上游 JSON,state=0 表示业务成功。"
+        "汇采-编辑 BACnet 采集点位。必须传 ori_id、object_type、object_id,name"
+        "ori_id 是设备id,object_type 支持 AnalogInput、analogInput、analog-input"
     ),
 )
 def collector_bacnet_point_edit(
@@ -239,13 +204,13 @@ def collector_bacnet_point_edit(
     priority: int | None = None,
     units: str = "",
     value_type: int = 0,
-    group_id: int = 0,
     scale_ratio: float = 1,
     value_offset: float = 0,
-    describe: str = "",
+    group_id: int = 0,
     invalid_values: str = "",
     valid_range_start: float | None = None,
     valid_range_end: float | None = None,
+    describe: str = "",
 ) -> dict[str, Any]:
     return api_edit_bacnet_point(
         project_key,
@@ -259,13 +224,13 @@ def collector_bacnet_point_edit(
             "priority": priority,
             "units": units,
             "value_type": value_type,
-            "group_id": group_id,
             "scale_ratio": scale_ratio,
             "value_offset": value_offset,
-            "describe": describe,
+            "group_id": group_id,
             "invalid_values": invalid_values,
             "valid_range_start": valid_range_start,
             "valid_range_end": valid_range_end,
+            "describe": describe,
         },
     )
 

+ 1 - 6
data_collector_mcp/collector_api.py

@@ -131,9 +131,7 @@ def _normalize_modbus_point_payload(payload: dict[str, Any], *, require_device_i
         if normalized["func_code"] not in {1, 2, 3, 4}:
             raise ValueError("payload.func_code must be one of 1, 2, 3, 4")
 
-    if "register_type" in normalized:
-        normalized.pop("register_type")
-
+    normalized.pop("register_type", None)
     return normalized
 
 
@@ -314,9 +312,6 @@ def _normalize_bacnet_point_create_payload(payload: dict[str, Any]) -> dict[str,
         _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]}
 
 

+ 14 - 34
data_collector_mcp/common_server.py

@@ -41,12 +41,11 @@ def project_list() -> dict[str, Any]:
 @mcp.tool(
     name="collector.device_list",
     description=(
-        "汇采-查询设备列表。调用 {data_collector_base_url}/api/collector/device。"
-        "用于查看设备树、设备分组、设备连接状态、设备采集状态和点位数量;"
-        "不返回点位明细、点位采集状态或点位当前值。"
-        "如果目标是查看某个设备下点位的采集状态和值,请使用 collector.device_points。"
+        "汇采-返回目前所有设备及其详情信息。返回内容中的设备字段包括不限于 name 设备名、"
+        "type 设备类型、num_points 点位数量、running_status 运行状态:"
+        "0 已停止,1 正常,2 异常。type 为 devicegroup 时表示设备分组,"
+        "需要继续检查 groups 下的设备。"
         "num_points 默认 false;只有需要统计设备或点位分组下的点位数量时才传 true。"
-        "响应透传上游 JSON,state=0 表示业务成功。"
     ),
 )
 def collector_device_list(project_key: str, num_points: bool = False) -> dict[str, Any]:
@@ -56,13 +55,9 @@ def collector_device_list(project_key: str, num_points: bool = False) -> dict[st
 @mcp.tool(
     name="collector.device_connect",
     description=(
-        "汇采-连接设备。调用 "
-        "{data_collector_base_url}/api/collector/common/device/set_connect_status,"
-        "请求 status=2。用于让指定设备进入已连接状态;连接成功不代表正在采集,"
-        "采集状态请看响应 data.running_status 或后续查询设备/点位状态。"
-        "device_id 是设备列表中的设备 id;device_type 默认 modbus,其他设备类型可传 "
-        "s7、bacnet、ethernet-ip、opc-ua、opc-da、snmp、iec104。"
-        "响应透传上游 JSON,state=0 表示业务成功。常见状态:"
+        "汇采-连接设备。连接成功不代表正在采集,设备采集状态请看响应 data.running_status"
+        "device_id 是设备列表中的设备 id;device_type 可传modbus、s7、bacnet、ethernet-ip、opc-ua、opc-da、snmp、iec104。"
+        "响应data字段下常见状态:"
         "data.status 1=未连接、2=已连接、3=连接异常;"
         "data.running_status 0=未采集、1=采集中、2=采集异常。"
     ),
@@ -70,7 +65,7 @@ def collector_device_list(project_key: str, num_points: bool = False) -> dict[st
 def collector_device_connect(
     project_key: str,
     device_id: int,
-    device_type: str = "modbus",
+    device_type: str,
 ) -> dict[str, Any]:
     return api_connect_device(project_key, device_id=device_id, device_type=device_type)
 
@@ -78,12 +73,9 @@ def collector_device_connect(
 @mcp.tool(
     name="collector.device_disconnect",
     description=(
-        "汇采-断开设备。调用 "
-        "{data_collector_base_url}/api/collector/common/device/set_connect_status,"
-        "请求 status=1。用于停止指定设备连接/采集相关状态,使设备回到未连接或空闲状态。"
-        "device_id 是设备列表中的设备 id;device_type 默认 modbus,其他设备类型可传 "
-        "s7、bacnet、ethernet-ip、opc-ua、opc-da、snmp、iec104。"
-        "响应透传上游 JSON,state=0 表示业务成功。常见状态:"
+        "汇采-断开设备。用于停止指定设备连接/采集相关状态,使设备回到未连接或空闲状态"
+        "device_id 是设备列表中的设备 id;device_type 可传modbus、s7、bacnet、ethernet-ip、opc-ua、opc-da、snmp、iec104。"
+        "响应data字段下常见状态:"
         "data.status 1=未连接、2=已连接、3=连接异常;"
         "data.running_status 0=未采集、1=采集中、2=采集异常。"
     ),
@@ -99,29 +91,17 @@ def collector_device_disconnect(
 @mcp.tool(
     name="collector.device_points",
     description=(
-        "汇采-查询设备点位列表。调用 "
-        "{data_collector_base_url}/api/collector/common/device/get_collect_point。"
-        "主要用于查看某个设备下点位的采集状态和值:Modbus/S7 返回 data.point[].status "
-        "和 data.point[].present_value,status 0=未采集、1=采集正常、2=采集异常;"
-        "present_value 是当前内存中的点位最新值。group_id 默认 0 表示查询全部点位,"
-        "传具体点位分组 id 时只返回该分组下点位。device_type 默认 modbus,其他设备类型可传 "
-        "s7、bacnet、ethernet-ip、opc-ua、opc-da、snmp、iec104。"
-        "响应透传上游 JSON,state=0 表示业务成功。Modbus 点位常见字段包括 id、point_id、"
-        "name、address、type、device_id、status、function_code、present_value、scale_ratio、"
-        "value_offset、group_id、bit、update_time;S7 点位常见字段包括 id、point_id、name、"
-        "address、data_type、register_type、device_id、status、present_value、scale_ratio、"
-        "value_offset、group_id、update_time。"
+        "汇采-查询设备下的所有点位。device_id 是设备id, device_type 可传modbus、s7、bacnet、ethernet-ip、opc-ua、opc-da、snmp、iec104。"
+        "status 0=未采集、1=采集正常、2=采集异常, present_value 是当前值"
     ),
 )
 def collector_device_points(
     project_key: str,
     device_id: int,
-    device_type: str = "modbus",
-    group_id: int = 0,
+    device_type: str,
 ) -> dict[str, Any]:
     return api_list_device_points(
         project_key,
         device_id=device_id,
         device_type=device_type,
-        group_id=group_id,
     )

+ 23 - 0
data_collector_mcp/gateway_api.py

@@ -46,6 +46,29 @@ def modbus_point_collect_test(
     return _request_gateway(project_key, MODBUS_SPEC.point_test_path, payload, "modbus point test")
 
 
+def modbus_raw_read(
+    project_key: str,
+    *,
+    ip: str,
+    port: int,
+    slave_id: int,
+    read: dict[str, Any],
+    device_type: str = "ModbusTCP",
+    word_byte_order: str = "ABCD",
+    address_base: int = 0,
+) -> dict[str, Any]:
+    payload = {
+        "device_type": device_type,
+        "ip": ip,
+        "port": port,
+        "word_byte_order": word_byte_order,
+        "address_base": address_base,
+        "slave_id": slave_id,
+        "read": read,
+    }
+    return _request_gateway(project_key, MODBUS_SPEC.raw_read_path, payload, "modbus raw read")
+
+
 def bacnet_point_collect_test(
     project_key: str,
     *,

+ 76 - 56
data_collector_mcp/modbus_server.py

@@ -10,13 +10,15 @@ from .collector_api import (
     edit_modbus_device as api_edit_modbus_device,
     edit_modbus_point as api_edit_modbus_point,
 )
-from .gateway_api import modbus_point_collect_test as api_modbus_point_collect_test
+from .gateway_api import (
+    modbus_point_collect_test as api_modbus_point_collect_test,
+    modbus_raw_read as api_modbus_raw_read,
+)
 from .mcp_app import mcp
 
 
 class ModbusDeviceCreateItem(BaseModel):
     name: str
-    device_type: int
     ip: str
     port: int
     slave_id: int
@@ -24,6 +26,7 @@ class ModbusDeviceCreateItem(BaseModel):
     word_order: int
     address_base: int
     serial_port: str = ""
+    device_type: int = 1
     timeout: int = 3
     is_persistent: bool = False
     baud_rate: int = 0
@@ -37,34 +40,72 @@ class ModbusDeviceCreateItem(BaseModel):
     collect_interval: int = 5
 
 
-class ModbusPointCreateItem(TypedDict):
+class ModbusPointCreateItem(BaseModel):
     device_id: int
     name: str
     address: int
     type: str
-    func_code: NotRequired[int]
-    register_type: NotRequired[str]
-    point_id: NotRequired[str]
-    scale_ratio: NotRequired[float]
-    value_offset: NotRequired[float]
-    group_id: NotRequired[int]
-    invalid_values: NotRequired[str]
-    valid_range_start: NotRequired[float | None]
-    valid_range_end: NotRequired[float | None]
-    bit: NotRequired[int]
-    describe: NotRequired[str]
+    func_code: int
+    register_type: str = ""
+    point_id: str = ""
+    scale_ratio: float = 1
+    value_offset: float = 0
+    group_id: int = 0
+    invalid_values: str = ""
+    valid_range_start: float | None = None
+    valid_range_end: float | None = None
+    bit: int = 0
+    describe: str = ""
+
+
+class ModbusRawReadSpec(TypedDict):
+    function_code: int
+    address: int
+    quantity: int
 
 
 @mcp.tool(
-    name="modbus.point_collect_test",
+    name="modbus.raw_read",
     description=(
-        "通过采集网关读取 Modbus TCP 点位并转换为业务值。调用 "
-        "{base_url}/api/dc-gateway/modbus/read_points。function_code: "
+        "采集网关-通过Modbus TCP 读取原始数据。"
+        "device_type 设备类型,默认 ModbusTCP;word_byte_order 默认 ABCD,可选 ABCD、BADC、CDAB、DCBA;"
+        "address_base 地址偏移,默认 0。"
+        "read 必须包含 function_code、address、quantity;function_code: "
         "1=Read Coils/线圈,2=Read Discrete Inputs/离散输入,"
-        "3=Read Holding Registers/保持寄存器,4=Read Input Registers/输入寄存器。"
-        "word_byte_order 可选 ABCD、BADC、CDAB、DCBA。若读取成功后要创建汇采设备,"
-        "映射为 byte_order/word_order: ABCD=>1/1, BADC=>2/1, CDAB=>1/2, DCBA=>2/2。"
-        "响应透传上游 JSON,code=0 表示业务成功。"
+        "3=Read Holding Registers/保持寄存器,4=Read Input Registers/输入寄存器;"
+        "quantity 范围 1..125。"
+        "不要传name字段"
+    ),
+)
+def modbus_raw_read(
+    project_key: str,
+    ip: str,
+    port: int,
+    slave_id: int,
+    read: ModbusRawReadSpec,
+    device_type: str = "ModbusTCP",
+    word_byte_order: str = "ABCD",
+    address_base: int = 0,
+) -> dict[str, Any]:
+    return api_modbus_raw_read(
+        project_key,
+        ip=ip,
+        port=port,
+        slave_id=slave_id,
+        read=dict(read),
+        device_type=device_type,
+        word_byte_order=word_byte_order,
+        address_base=address_base,
+    )
+
+
+@mcp.tool(
+    name="modbus.point_collect_test",
+    description=(
+        "通过采集网关读取 Modbus TCP 点位的数据"
+        "寄存器类型可选:1=Read Coils/线圈/0x,2=Read Discrete Inputs/离散输入/1x,"
+        "3=Read Holding Registers/保持寄存器/4x,4=Read Input Registers/输入寄存器/3x。"
+        "word_byte_order 可选 ABCD、BADC、CDAB、DCBA"
     ),
 )
 def modbus_point_collect_test(
@@ -92,18 +133,12 @@ def modbus_point_collect_test(
 @mcp.tool(
     name="collector.modbus_device_create",
     description=(
-        "汇采-批量创建 Modbus 设备。依次调用 {data_collector_base_url}/api/collector/device,"
-        "全部设备创建调用完成后会内部调用设备列表并匹配每个设备 id;如果匹配到多个设备,"
-        "选择 id 最大的。devices 每项必须传 device_type 协议类型、ip IP 地址、"
-        "port 端口号、name 名称、slave_id、word_order 字顺序、byte_order 字节顺序、"
-        "address_base 地址基准。address_base 会转换为汇采接口的 address_offset。"
+        "汇采-批量创建 Modbus 设备。"
         "默认参数: type=modbus, timeout=3, is_persistent=false, group_id=0, "
         "alarm_interval=90, collect_interval=5, retry_times=0。"
-        "注意 byte_order/word_order 是汇采枚举,不是网关 word_byte_order。"
+        "address_base 地址偏移/address_offset。"
         "byte_order: 1=Big Endian, 2=Small Endian。word_order: 1=Big Endian, 2=Small Endian。"
-        "device_type: 1=TCP, 2=RTU, 3=UDP, 4=RTU OVER TCP, 5=RTU OVER UDP。"
-        "采集网关 word_byte_order 映射: ABCD=>1/1, BADC=>2/1, CDAB=>1/2, DCBA=>2/2。"
-        "返回批量结果,state=0 表示全部创建并匹配成功。"
+        "word_byte_order 映射: ABCD=>1/1, BADC=>2/1, CDAB=>1/2, DCBA=>2/2。"
     ),
 )
 def collector_modbus_device_create(
@@ -116,19 +151,13 @@ def collector_modbus_device_create(
 @mcp.tool(
     name="collector.modbus_device_edit",
     description=(
-        "汇采-编辑 Modbus 设备。调用 "
-        "{data_collector_base_url}/api/collector/modbus/device/edit。"
-        "这是 Modbus 专用旧编辑接口,必须传 ori_id 原设备 id、name、slave_id、"
-        "word_order、byte_order、device_type 连接类型。TCP/UDP 类设备还必须传 ip 和 port;"
-        "RTU 设备必须传 serial_port。"
+        "汇采-编辑 Modbus 设备。必须传 ori_id 原设备 id、name、slave_id、"
+        "word_order、byte_order、ip 和 port;"
         "编辑前设备不能处于已连接状态;若已连接,请先调用 collector.device_disconnect。"
-        "该接口是全量更新语义,未传字段可能被默认值覆盖。"
         "默认参数: ip='', port=0, serial_port='', timeout=3, is_persistent=false, "
         "baud_rate=0, data_bit=0, parity=0, stop_bit=0, mode=0, address_offset=0, "
         "retry_times=0, device_group_id=0, alarm_interval=90, collect_interval=5。"
-        "连接类型: 1=TCP, 2=RTU, 3=UDP, 4=RTU OVER TCP, 5=RTU OVER UDP。"
         "byte_order: 1=Big Endian, 2=Small Endian;word_order: 1=Big Endian, 2=Small Endian。"
-        "响应透传上游 JSON,state=0 表示业务成功。"
     ),
 )
 def collector_modbus_device_edit(
@@ -186,26 +215,21 @@ def collector_modbus_device_edit(
 @mcp.tool(
     name="collector.modbus_point_create",
     description=(
-        "汇采-批量创建 Modbus 采集点位。依次调用 "
-        "{data_collector_base_url}/api/collector/modbus/point/add_collect_point。"
+        "汇采-批量创建 Modbus 采集点位。"
         "points 每项必须传 device_id、name 名称、address 寄存器地址、"
-        "type 数据类型,以及 func_code 或 register_type 寄存器类型。"
-        "每个点位会补齐默认值: point_id='', scale_ratio=1, value_offset=0, group_id=0, "
-        "invalid_values='', valid_range_start=null, valid_range_end=null, bit=0。"
-        "func_code: 1=Read Coils/线圈,2=Read Discrete Inputs/离散输入,"
-        "3=Read Holding Registers/保持寄存器,4=Read Input Registers/输入寄存器。"
-        "register_type 可用 coil、discrete_input、holding_register、input_register。"
+        "type 数据类型,以及 func_code 寄存器类型。"
+        "func_code: 1=Read Coils/线圈/0x,2=Read Discrete Inputs/离散输入/1x,"
+        "3=Read Holding Registers/保持寄存器/4x,4=Read Input Registers/输入寄存器/3x。"
         "数据类型应使用汇采类型: bool, int16, uint16, int32, uint32, int64, uint64, float32, float64。"
         "常见点表类型映射: BOOL=>bool, SHORT=>int16, WORD=>uint16, LONG=>int32, "
         "DWORD=>uint32, FLOAT/REAL=>float32, DOUBLE=>float64, LONGLONG=>int64, QWORD=>uint64。"
-        "响应透传上游 JSON,state=0 表示业务成功。"
     ),
 )
 def collector_modbus_point_create(
     project_key: str,
     points: list[ModbusPointCreateItem],
 ) -> dict[str, Any]:
-    return api_create_modbus_points(project_key, points)
+    return api_create_modbus_points(project_key, [_dump_model_or_dict(item) for item in points])
 
 
 def _dump_model_or_dict(item: Any) -> dict[str, Any]:
@@ -218,21 +242,17 @@ def _dump_model_or_dict(item: Any) -> dict[str, Any]:
     name="collector.modbus_point_edit",
     description=(
         "汇采-编辑 Modbus 采集点位。调用 "
-        "{data_collector_base_url}/api/collector/modbus/point/edit_collect_point。"
-        "必须传 ori_id 原点位 id、name 名称、address 寄存器地址、data_type 数据类型,"
-        "以及 func_code 或 register_type 寄存器类型。"
+        "必须传 ori_id 原点位 id、name 名称、address 寄存器地址、type 数据类型,"
+        "以及 func_code 寄存器类型。"
         "ori_id 对应 collector.device_points 返回的 data.point[].id。"
-        "编辑点位不会迁移所属设备。"
-        "该接口是全量更新语义,未传字段可能被默认值覆盖。"
         "默认参数: point_id='', scale_ratio=1, value_offset=0, group_id=0, "
         "invalid_values='', valid_range_start=null, valid_range_end=null, bit=0。"
-        "func_code: 1=Read Coils/线圈,2=Read Discrete Inputs/离散输入,"
-        "3=Read Holding Registers/保持寄存器,4=Read Input Registers/输入寄存器。"
+        "func_code: 1=Read Coils/线圈/0x,2=Read Discrete Inputs/离散输入/1x,"
+        "3=Read Holding Registers/保持寄存器/4x,4=Read Input Registers/输入寄存器/3x。"
         "register_type 可用 coil、discrete_input、holding_register、input_register。"
         "数据类型应使用汇采类型: bool, int16, uint16, int32, uint32, int64, uint64, float32, float64。"
         "常见点表类型映射: BOOL=>bool, SHORT=>int16, WORD=>uint16, LONG=>int32, "
         "DWORD=>uint32, FLOAT/REAL=>float32, DOUBLE=>float64, LONGLONG=>int64, QWORD=>uint64。"
-        "响应透传上游 JSON,state=0 表示业务成功。"
     ),
 )
 def collector_modbus_point_edit(

+ 1 - 0
data_collector_mcp/protocols/modbus.py

@@ -69,6 +69,7 @@ MODBUS_SPEC = ProtocolSpec(
     create_device_path="/api/collector/device",
     create_point_path="/api/collector/modbus/point/add_collect_point",
     point_test_path="/api/dc-gateway/modbus/read_points",
+    raw_read_path="/api/dc-gateway/modbus/read",
     device_defaults={
         "type": "modbus",
         "timeout": 3,

+ 3 - 1
docs/接口汇总.md

@@ -210,6 +210,8 @@ curl http://127.0.0.1:8000/api/dc-gateway/health
 | Content-Type | `application/json` |
 | 用途 | 向 Modbus TCP 设备发起一次读取请求,只返回通信报文,不返回解析后的业务值。 |
 
+MCP 工具 `modbus.raw_read` 会调用该接口并透传响应。该工具只返回 `data.communication[]` 中的 Tx/Rx 原始报文;如需转换后的点位值,请使用 `modbus.point_collect_test`。
+
 #### 请求体结构
 
 ```json
@@ -1199,7 +1201,7 @@ curl -X POST http://127.0.0.1:8000/api/dc-gateway/bacnet/search_points \
 | Content-Type | 不需要请求体 |
 | 用途 | 注册 BBMD Foreign Device,通过 BBMD 分发 `Who-Is`,收集 `I-Am` 响应并返回 BACnet 设备列表。 |
 
-MCP 工具 `bacnet.bbmd_whois` 会调用该接口并透传响应。该工具只需要传 `project_key`,不发送请求体;BBMD 地址、端口、TTL、Who-Is 等待超时、设备号范围、本机绑定地址等参数由采集网关环境变量配置
+MCP 工具 `bacnet.bbmd_whois` 只需要传 `project_key`;BBMD 连接参数由采集网关配置,工具会透传发现结果
 
 #### 请求体结构
 

+ 6 - 1
tests/test_collector_api.py

@@ -964,6 +964,9 @@ class CollectorApiTests(unittest.TestCase):
                     "object_id": 1,
                     "point_id": "AI_TEMP",
                     "value_type": 4,
+                    "invalid_values": "-9999",
+                    "valid_range_start": 0,
+                    "valid_range_end": 100,
                 },
             )
 
@@ -983,7 +986,9 @@ class CollectorApiTests(unittest.TestCase):
         self.assertEqual(point["point_id"], "AI_TEMP")
         self.assertEqual(point["value_type"], 4)
         self.assertEqual(point["scale_ratio"], 1)
-        self.assertNotIn("invalid_values", point)
+        self.assertEqual(point["invalid_values"], "-9999")
+        self.assertEqual(point["valid_range_start"], 0)
+        self.assertEqual(point["valid_range_end"], 100)
 
     def test_bacnet_point_payloads_normalize_object_type(self) -> None:
         self._patch_project()

+ 32 - 0
tests/test_gateway_api.py

@@ -7,6 +7,38 @@ from data_collector_mcp import gateway_api
 
 
 class GatewayApiTests(unittest.TestCase):
+    def test_modbus_raw_read_posts_gateway_payload(self) -> None:
+        response = {"code": 0, "msg": "success", "data": {"communication": []}}
+        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.modbus_raw_read(
+                "dev-01",
+                ip="192.168.1.10",
+                port=502,
+                slave_id=1,
+                read={"function_code": 3, "address": 0, "quantity": 4},
+            )
+
+        self.assertEqual(result, response)
+        request_json.assert_called_once_with(
+            "POST",
+            "http://gateway.test/api/dc-gateway/modbus/read",
+            json_payload={
+                "device_type": "ModbusTCP",
+                "ip": "192.168.1.10",
+                "port": 502,
+                "word_byte_order": "ABCD",
+                "address_base": 0,
+                "slave_id": 1,
+                "read": {"function_code": 3, "address": 0, "quantity": 4},
+            },
+        )
+
     def test_modbus_point_collect_test_posts_gateway_payload(self) -> None:
         response = {"code": 0, "msg": "success", "data": {"points": []}}
         with patch(

+ 93 - 8
tests/test_server_tools.py

@@ -27,6 +27,7 @@ class ServerToolTests(unittest.TestCase):
                 "collector.device_connect",
                 "collector.device_disconnect",
                 "collector.device_points",
+                "modbus.raw_read",
                 "modbus.point_collect_test",
                 "bacnet.point_collect_test",
                 "bacnet.point_search",
@@ -151,8 +152,47 @@ class ServerToolTests(unittest.TestCase):
         self.assertEqual(result, {"state": 0})
         api_create.assert_called_once_with("dev-01", devices)
 
+    def test_modbus_raw_read_forwards_to_api(self) -> None:
+        read = {"function_code": 3, "address": 0, "quantity": 4}
+        with patch("data_collector_mcp.modbus_server.api_modbus_raw_read", return_value={"code": 0}) as raw_read:
+            result = modbus_server.modbus_raw_read(
+                project_key="dev-01",
+                ip="192.168.1.10",
+                port=502,
+                slave_id=1,
+                read=read,
+                word_byte_order="DCBA",
+                address_base=1,
+            )
+
+        self.assertEqual(result, {"code": 0})
+        raw_read.assert_called_once_with(
+            "dev-01",
+            ip="192.168.1.10",
+            port=502,
+            slave_id=1,
+            read=read,
+            device_type="ModbusTCP",
+            word_byte_order="DCBA",
+            address_base=1,
+        )
+
     def test_modbus_point_create_accepts_batch_points(self) -> None:
-        points = [{"device_id": 1, "name": "temperature", "address": 10, "type": "uint16", "func_code": 3}]
+        points = [
+            {
+                "device_id": 1,
+                "name": "temperature",
+                "address": 10,
+                "type": "uint16",
+                "func_code": 3,
+                "scale_ratio": 2,
+                "value_offset": 1,
+                "group_id": 5,
+                "invalid_values": "-9999",
+                "valid_range_start": 0,
+                "valid_range_end": 100,
+            }
+        ]
         with patch("data_collector_mcp.modbus_server.api_create_modbus_points", return_value={"state": 0}) as api_create:
             result = modbus_server.collector_modbus_point_create(project_key="dev-01", points=points)
 
@@ -219,6 +259,12 @@ class ServerToolTests(unittest.TestCase):
                 data_type="uint16",
                 func_code=3,
                 point_id="HR_UINT16_EDITED",
+                scale_ratio=2,
+                value_offset=1,
+                group_id=5,
+                invalid_values="-9999",
+                valid_range_start=0,
+                valid_range_end=100,
             )
 
         self.assertEqual(result, {"state": 0})
@@ -230,12 +276,12 @@ class ServerToolTests(unittest.TestCase):
                 "address": 10,
                 "type": "uint16",
                 "point_id": "HR_UINT16_EDITED",
-                "scale_ratio": 1,
-                "value_offset": 0,
-                "group_id": 0,
-                "invalid_values": "",
-                "valid_range_start": None,
-                "valid_range_end": None,
+                "scale_ratio": 2,
+                "value_offset": 1,
+                "group_id": 5,
+                "invalid_values": "-9999",
+                "valid_range_start": 0,
+                "valid_range_end": 100,
                 "bit": 0,
                 "describe": "",
                 "func_code": 3,
@@ -480,7 +526,20 @@ class ServerToolTests(unittest.TestCase):
             },
         )
 
-        points = [{"device_id": 9, "name": "zone_temperature", "object_type": "AnalogInput", "object_id": 1}]
+        points = [
+            {
+                "device_id": 9,
+                "name": "zone_temperature",
+                "object_type": "AnalogInput",
+                "object_id": 1,
+                "scale_ratio": 2,
+                "value_offset": 1,
+                "group_id": 5,
+                "invalid_values": "-9999",
+                "valid_range_start": 0,
+                "valid_range_end": 100,
+            }
+        ]
         with patch(
             "data_collector_mcp.bacnet_server.api_create_bacnet_points",
             return_value={"state": 0},
@@ -512,6 +571,32 @@ class ServerToolTests(unittest.TestCase):
         self.assertEqual(payload["object_id"], 1)
         self.assertEqual(payload["point_id"], "AI_TEMP_EDITED")
 
+        with patch(
+            "data_collector_mcp.bacnet_server.api_edit_bacnet_point",
+            return_value={"state": 0},
+        ) as api_point_edit:
+            bacnet_server.collector_bacnet_point_edit(
+                project_key="dev-01",
+                ori_id=101,
+                name="zone_temperature_edited",
+                object_type="AnalogInput",
+                object_id=1,
+                scale_ratio=2,
+                value_offset=1,
+                group_id=5,
+                invalid_values="-9999",
+                valid_range_start=0,
+                valid_range_end=100,
+            )
+
+        payload = api_point_edit.call_args.args[1]
+        self.assertEqual(payload["scale_ratio"], 2)
+        self.assertEqual(payload["value_offset"], 1)
+        self.assertEqual(payload["group_id"], 5)
+        self.assertEqual(payload["invalid_values"], "-9999")
+        self.assertEqual(payload["valid_range_start"], 0)
+        self.assertEqual(payload["valid_range_end"], 100)
+
 
 if __name__ == "__main__":
     unittest.main()