Преглед на файлове

修改modbus&s7 批量创建设备和点位接口

Lu Xianghui преди 1 месец
родител
ревизия
6832b794db
променени са 8 файла, в които са добавени 719 реда и са изтрити 224 реда
  1. 36 14
      README.md
  2. 296 19
      data_collector_mcp/collector_api.py
  3. 64 57
      data_collector_mcp/modbus_server.py
  4. 57 41
      data_collector_mcp/s7_server.py
  5. 51 38
      docs/data-collector-mcp-design.md
  6. 8 0
      docs/接口汇总.md
  7. 171 0
      tests/test_collector_api.py
  8. 36 55
      tests/test_server_tools.py

+ 36 - 14
README.md

@@ -8,6 +8,8 @@
 - `modbus.point_collect_test`
 - `collector.modbus_device_create`
 - `collector.modbus_point_create`
+- `collector.s7_device_create`
+- `collector.s7_point_create`
 - `collector.device_list`
 
 默认 MCP 地址:`http://127.0.0.1:8501/mcp`
@@ -108,23 +110,43 @@ $env:DATABASE_URL = "postgresql+psycopg2://postgres:password@127.0.0.1:5432/data
 }
 ```
 
-创建 Modbus 设备时必须传
+批量创建 Modbus 设备时传 `devices` 数组,每个设备必须包含
 
-- `payload.device_type`:协议类型,`1=TCP`、`2=RTU`、`3=UDP`、`4=RTU OVER TCP`、`5=RTU OVER UDP`
-- `payload.ip`:IP 地址
-- `payload.port`:端口号
-- `payload.name`:设备名称
-- `payload.slave_id`:Modbus 从站 ID
-- `payload.word_order`:字顺序,`1=Big Endian`、`2=Small Endian`
-- `payload.byte_order`:字节顺序,`1=Big Endian`、`2=Small Endian`
-- `payload.address_base`:地址基准,会转换为汇采接口的 `address_offset`
+- `devices[].device_type`:协议类型,`1=TCP`、`2=RTU`、`3=UDP`、`4=RTU OVER TCP`、`5=RTU OVER UDP`
+- `devices[].ip`:IP 地址
+- `devices[].port`:端口号
+- `devices[].name`:设备名称
+- `devices[].slave_id`:Modbus 从站 ID
+- `devices[].word_order`:字顺序,`1=Big Endian`、`2=Small Endian`
+- `devices[].byte_order`:字节顺序,`1=Big Endian`、`2=Small Endian`
+- `devices[].address_base`:地址基准,会转换为汇采接口的 `address_offset`
 
-创建 Modbus 点位时必须传:
+设备创建工具会依次调用汇采创建设备接口,全部创建调用完成后内部调用一次设备列表并匹配每个设备的 `device_id`;如果匹配到多个设备,选择 `id` 最大的。
 
-- `payload.name`:点位名称
-- `payload.address`:寄存器地址
-- `payload.type`:数据类型
-- `payload.func_code` 或 `payload.register_type`:寄存器类型
+批量创建 Modbus 点位时传 `points` 数组,每个点位必须包含:
+
+- `points[].device_id`:所属设备 ID
+- `points[].name`:点位名称
+- `points[].address`:寄存器地址
+- `points[].type`:数据类型
+- `points[].func_code` 或 `points[].register_type`:寄存器类型
+
+批量创建 S7 设备时传 `devices` 数组,每个设备必须包含:
+
+- `devices[].name`:设备名称
+- `devices[].ip`:IP 地址
+- `devices[].rock`:机架号
+- `devices[].slot`:槽号
+
+S7 设备可选默认值包括:`port=102`、`device_type=1`、`is_persistent=false`、`device_group_id=0`、`timeout=3`、`alarm_interval=90`、`collect_interval=5`。`tsap_conn_type` 不传时,S7-1200/S7-1500 默认 `PG`,S7-Smart200 默认 `OP`。
+
+批量创建 S7 点位时传 `points` 数组,每个点位必须包含:
+
+- `points[].device_id`:所属设备 ID
+- `points[].name`:点位名称
+- `points[].address`:S7 地址
+- `points[].data_type` 或 `points[].type`:数据类型
+- `points[].register_type` 或 `points[].register_area`:寄存器区域
 
 常见点表数据类型映射:
 

+ 296 - 19
data_collector_mcp/collector_api.py

@@ -15,6 +15,10 @@ def _merge_defaults(defaults: dict[str, Any], payload: dict[str, Any]) -> dict[s
     return merged
 
 
+def _success_response(response: dict[str, Any]) -> bool:
+    return response.get("state") == 0
+
+
 def _require_non_empty_text(payload: dict[str, Any], field_name: str) -> str:
     value = str(payload.get(field_name) or "").strip()
     if not value:
@@ -81,11 +85,16 @@ def _normalize_modbus_device_edit_payload(payload: dict[str, Any]) -> dict[str,
     return normalized
 
 
-def _normalize_modbus_point_payload(payload: dict[str, Any]) -> dict[str, Any]:
+def _normalize_modbus_point_payload(payload: dict[str, Any], *, require_device_id: bool = True) -> dict[str, Any]:
     normalized = dict(payload)
 
     normalized["name"] = _require_non_empty_text(normalized, "name")
     _require_present(normalized, "address")
+    if require_device_id:
+        normalized["device_id"] = _normalize_positive_int(
+            _require_present(normalized, "device_id"),
+            "payload.device_id",
+        )
 
     raw_type = _require_non_empty_text(normalized, "type")
     normalized_type = MODBUS_POINT_TYPE_ALIASES.get(raw_type)
@@ -126,7 +135,7 @@ def _normalize_modbus_point_payload(payload: dict[str, Any]) -> dict[str, Any]:
 
 def _normalize_modbus_point_edit_payload(payload: dict[str, Any]) -> dict[str, Any]:
     _require_present(payload, "ori_id")
-    normalized = _normalize_modbus_point_payload(payload)
+    normalized = _normalize_modbus_point_payload(payload, require_device_id=False)
     normalized["ori_id"] = _normalize_positive_int(_require_present(normalized, "ori_id"), "payload.ori_id")
     return normalized
 
@@ -141,8 +150,12 @@ def _normalize_s7_device_payload(payload: dict[str, Any]) -> dict[str, Any]:
     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 "tsap_conn_type" in normalized:
+    if "tsap_conn_type" in normalized and normalized["tsap_conn_type"] is not None:
         normalized["tsap_conn_type"] = _normalize_s7_tsap_conn_type(normalized["tsap_conn_type"])
+    elif int(normalized.get("device_type", 1)) == 3:
+        normalized["tsap_conn_type"] = "OP"
+    else:
+        normalized["tsap_conn_type"] = "PG"
 
     return normalized
 
@@ -239,15 +252,40 @@ def _request_collector(
     return response_payload
 
 
+def _build_modbus_device_create_payload(payload: dict[str, Any]) -> dict[str, Any]:
+    return _merge_defaults(
+        MODBUS_SPEC.device_defaults,
+        _normalize_modbus_device_payload(payload),
+    )
+
+
+def _build_modbus_point_create_payload(payload: dict[str, Any]) -> dict[str, Any]:
+    return _merge_defaults(
+        MODBUS_SPEC.point_defaults,
+        _normalize_modbus_point_payload(payload),
+    )
+
+
+def _build_s7_device_create_payload(payload: dict[str, Any]) -> dict[str, Any]:
+    return _merge_defaults(
+        S7_SPEC.device_defaults,
+        _normalize_s7_device_payload(payload),
+    )
+
+
+def _build_s7_point_create_payload(payload: dict[str, Any]) -> dict[str, Any]:
+    return _merge_defaults(
+        S7_SPEC.point_defaults,
+        _normalize_s7_point_payload(payload),
+    )
+
+
 def create_modbus_device(project_key: str, payload: dict[str, Any]) -> dict[str, Any]:
     return _request_collector(
         project_key,
         "POST",
         MODBUS_SPEC.create_device_path,
-        json_payload=_merge_defaults(
-            MODBUS_SPEC.device_defaults,
-            _normalize_modbus_device_payload(payload),
-        ),
+        json_payload=_build_modbus_device_create_payload(payload),
     )
 
 
@@ -256,10 +294,7 @@ def create_modbus_point(project_key: str, payload: dict[str, Any]) -> dict[str,
         project_key,
         "POST",
         MODBUS_SPEC.create_point_path,
-        json_payload=_merge_defaults(
-            MODBUS_SPEC.point_defaults,
-            _normalize_modbus_point_payload(payload),
-        ),
+        json_payload=_build_modbus_point_create_payload(payload),
     )
 
 
@@ -268,10 +303,7 @@ def create_s7_device(project_key: str, payload: dict[str, Any]) -> dict[str, Any
         project_key,
         "POST",
         S7_SPEC.create_device_path,
-        json_payload=_merge_defaults(
-            S7_SPEC.device_defaults,
-            _normalize_s7_device_payload(payload),
-        ),
+        json_payload=_build_s7_device_create_payload(payload),
     )
 
 
@@ -280,13 +312,258 @@ def create_s7_point(project_key: str, payload: dict[str, Any]) -> dict[str, Any]
         project_key,
         "POST",
         S7_SPEC.create_point_path,
-        json_payload=_merge_defaults(
-            S7_SPEC.point_defaults,
-            _normalize_s7_point_payload(payload),
-        ),
+        json_payload=_build_s7_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,
+        devices,
+        create_path=MODBUS_SPEC.create_device_path,
+        build_payload=_build_modbus_device_create_payload,
+        match_device=_match_modbus_device,
     )
 
 
+def create_modbus_points(project_key: str, points: list[dict[str, Any]]) -> dict[str, Any]:
+    return _create_points_batch(
+        project_key,
+        points,
+        create_path=MODBUS_SPEC.create_point_path,
+        build_payload=_build_modbus_point_create_payload,
+    )
+
+
+def create_s7_devices(project_key: str, devices: list[dict[str, Any]]) -> dict[str, Any]:
+    return _create_devices_batch(
+        project_key,
+        devices,
+        create_path=S7_SPEC.create_device_path,
+        build_payload=_build_s7_device_create_payload,
+        match_device=_match_s7_device,
+    )
+
+
+def create_s7_points(project_key: str, points: list[dict[str, Any]]) -> dict[str, Any]:
+    return _create_points_batch(
+        project_key,
+        points,
+        create_path=S7_SPEC.create_point_path,
+        build_payload=_build_s7_point_create_payload,
+    )
+
+
+def _create_devices_batch(
+    project_key: str,
+    devices: list[dict[str, Any]],
+    *,
+    create_path: str,
+    build_payload: Any,
+    match_device: Any,
+) -> dict[str, Any]:
+    results: list[dict[str, Any]] = []
+    errors: list[dict[str, Any]] = []
+    created: list[tuple[dict[str, Any], dict[str, Any]]] = []
+
+    for index, item in enumerate(devices):
+        if not isinstance(item, dict):
+            errors.append({"index": index, "stage": "create_device", "error": "device item must be an object"})
+            continue
+
+        result: dict[str, Any] = {
+            "index": index,
+            "name": item.get("name"),
+            "device_id": None,
+            "create_response": None,
+            "matched_device": None,
+        }
+        results.append(result)
+
+        try:
+            request_payload = build_payload(item)
+            response = _request_collector(project_key, "POST", create_path, json_payload=request_payload)
+        except Exception as exc:
+            errors.append({"index": index, "name": item.get("name"), "stage": "create_device", "error": str(exc)})
+            continue
+
+        result["create_response"] = response
+        if _success_response(response):
+            created.append((request_payload, result))
+        else:
+            errors.append(
+                {
+                    "index": index,
+                    "name": item.get("name"),
+                    "stage": "create_device",
+                    "error": response.get("state_info") or response.get("msg") or str(response),
+                }
+            )
+
+    if created:
+        try:
+            device_list = list_devices(project_key, num_points=False)
+            flattened_devices = _flatten_device_tree(device_list.get("devices", []))
+        except Exception as exc:
+            for _, result in created:
+                errors.append(
+                    {
+                        "index": result["index"],
+                        "name": result.get("name"),
+                        "stage": "match_device",
+                        "error": str(exc),
+                    }
+                )
+        else:
+            for request_payload, result in created:
+                candidates = [item for item in flattened_devices if match_device(item, request_payload)]
+                candidates = [item for item in candidates if _safe_int(item.get("id")) is not None]
+                if not candidates:
+                    errors.append(
+                        {
+                            "index": result["index"],
+                            "name": result.get("name"),
+                            "stage": "match_device",
+                            "error": "created device was not found in device list",
+                        }
+                    )
+                    continue
+                selected = max(candidates, key=lambda item: _safe_int(item.get("id")) or 0)
+                result["device_id"] = _safe_int(selected.get("id"))
+                result["matched_device"] = selected
+
+    matched = sum(1 for item in results if item.get("device_id") is not None)
+    return {
+        "state": 0 if not errors else 1,
+        "summary": {
+            "total": len(devices),
+            "created": len(created),
+            "matched": matched,
+            "failed": len(errors),
+        },
+        "results": results,
+        "errors": errors,
+    }
+
+
+def _create_points_batch(
+    project_key: str,
+    points: list[dict[str, Any]],
+    *,
+    create_path: str,
+    build_payload: Any,
+) -> dict[str, Any]:
+    results: list[dict[str, Any]] = []
+    errors: list[dict[str, Any]] = []
+
+    for index, item in enumerate(points):
+        if not isinstance(item, dict):
+            errors.append({"index": index, "stage": "create_point", "error": "point item must be an object"})
+            continue
+
+        result: dict[str, Any] = {
+            "index": index,
+            "name": item.get("name"),
+            "device_id": item.get("device_id"),
+            "response": None,
+        }
+        results.append(result)
+
+        try:
+            request_payload = build_payload(item)
+            response = _request_collector(project_key, "POST", create_path, json_payload=request_payload)
+        except Exception as exc:
+            errors.append({"index": index, "name": item.get("name"), "stage": "create_point", "error": str(exc)})
+            continue
+
+        result["response"] = response
+        if not _success_response(response):
+            errors.append(
+                {
+                    "index": index,
+                    "name": item.get("name"),
+                    "stage": "create_point",
+                    "error": response.get("state_info") or response.get("msg") or str(response),
+                }
+            )
+
+    success = sum(1 for item in results if isinstance(item.get("response"), dict) and _success_response(item["response"]))
+    return {
+        "state": 0 if not errors else 1,
+        "summary": {
+            "total": len(points),
+            "success": success,
+            "failed": len(errors),
+        },
+        "results": results,
+        "errors": errors,
+    }
+
+
+def _flatten_device_tree(items: list[Any]) -> list[dict[str, Any]]:
+    flattened: list[dict[str, Any]] = []
+    for item in items:
+        if not isinstance(item, dict):
+            continue
+        flattened.append(item)
+        groups = item.get("groups", [])
+        if isinstance(groups, list):
+            flattened.extend(_flatten_device_tree(groups))
+    return flattened
+
+
+def _match_modbus_device(device: dict[str, Any], expected: dict[str, Any]) -> bool:
+    if device.get("type") != "modbus":
+        return False
+    if not _same_text(device.get("name"), expected.get("name")):
+        return False
+    if not _same_int(device.get("device_type"), expected.get("device_type")):
+        return False
+    if not _same_int(device.get("slave_id"), expected.get("slave_id")):
+        return False
+    if not _same_int(device.get("group_id"), expected.get("group_id")):
+        return False
+    if not _same_int(device.get("address_offset"), expected.get("address_offset")):
+        return False
+
+    if _safe_int(expected.get("device_type")) == 2:
+        return _same_text(device.get("serial_port"), expected.get("serial_port"))
+    return _same_text(device.get("ip"), expected.get("ip")) and _same_int(device.get("port"), expected.get("port"))
+
+
+def _match_s7_device(device: dict[str, Any], expected: dict[str, Any]) -> bool:
+    if device.get("type") != "s7":
+        return False
+    actual_group_id = device.get("device_group_id", device.get("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("rock"), expected.get("rock"))
+        and _same_int(device.get("slot"), expected.get("slot"))
+        and _same_int(device.get("device_type"), expected.get("device_type"))
+        and _same_text(device.get("tsap_conn_type"), expected.get("tsap_conn_type"))
+        and _same_int(actual_group_id, expected.get("device_group_id"))
+    )
+
+
+def _same_text(left: Any, right: Any) -> bool:
+    return str(left or "").strip() == str(right or "").strip()
+
+
+def _same_int(left: Any, right: Any) -> bool:
+    normalized_left = _safe_int(left)
+    normalized_right = _safe_int(right)
+    return normalized_left is not None and normalized_left == normalized_right
+
+
+def _safe_int(value: Any) -> int | None:
+    try:
+        return int(value)
+    except Exception:
+        return None
+
+
 def edit_modbus_device(project_key: str, payload: dict[str, Any]) -> dict[str, Any]:
     return _request_collector(
         project_key,

+ 64 - 57
data_collector_mcp/modbus_server.py

@@ -1,10 +1,12 @@
 from __future__ import annotations
 
-from typing import Any
+from typing import Any, NotRequired, TypedDict
+
+from pydantic import BaseModel
 
 from .collector_api import (
-    create_modbus_device as api_create_modbus_device,
-    create_modbus_point as api_create_modbus_point,
+    create_modbus_devices as api_create_modbus_devices,
+    create_modbus_points as api_create_modbus_points,
     edit_modbus_device as api_edit_modbus_device,
     edit_modbus_point as api_edit_modbus_point,
 )
@@ -12,6 +14,47 @@ from .gateway_api import modbus_point_collect_test as api_modbus_point_collect_t
 from .mcp_app import mcp
 
 
+class ModbusDeviceCreateItem(BaseModel):
+    name: str
+    device_type: int
+    ip: str
+    port: int
+    slave_id: int
+    byte_order: int
+    word_order: int
+    address_base: int
+    serial_port: str = ""
+    timeout: int = 3
+    is_persistent: bool = False
+    baud_rate: int = 0
+    data_bit: int = 0
+    parity: int = 0
+    stop_bit: int = 0
+    mode: int = 0
+    retry_times: int = 0
+    group_id: int = 0
+    alarm_interval: int = 90
+    collect_interval: int = 5
+
+
+class ModbusPointCreateItem(TypedDict):
+    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]
+
+
 @mcp.tool(
     name="modbus.point_collect_test",
     description=(
@@ -49,8 +92,9 @@ def modbus_point_collect_test(
 @mcp.tool(
     name="collector.modbus_device_create",
     description=(
-        "汇采-创建 Modbus 设备。调用 {data_collector_base_url}/api/collector/device,"
-        "需要登录后使用 Authorization。创建设备必须传 device_type 协议类型、ip IP 地址、"
+        "汇采-批量创建 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。"
         "默认参数: type=modbus, timeout=3, is_persistent=false, group_id=0, "
@@ -59,57 +103,14 @@ def modbus_point_collect_test(
         "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。"
-        "响应透传上游 JSON,state=0 表示业务成功。"
+        "返回批量结果,state=0 表示全部创建并匹配成功。"
     ),
 )
 def collector_modbus_device_create(
     project_key: str,
-    name: str,
-    device_type: int,
-    ip: str,
-    port: int,
-    slave_id: int,
-    byte_order: int,
-    word_order: int,
-    address_base: int,
-    serial_port: str = "",
-    timeout: int = 3,
-    is_persistent: bool = False,
-    baud_rate: int = 0,
-    data_bit: int = 0,
-    parity: int = 0,
-    stop_bit: int = 0,
-    mode: int = 0,
-    retry_times: int = 0,
-    group_id: int = 0,
-    alarm_interval: int = 90,
-    collect_interval: int = 5,
+    devices: list[ModbusDeviceCreateItem],
 ) -> dict[str, Any]:
-    return api_create_modbus_device(
-        project_key,
-        {
-            "name": name,
-            "device_type": device_type,
-            "ip": ip,
-            "port": port,
-            "slave_id": slave_id,
-            "byte_order": byte_order,
-            "word_order": word_order,
-            "address_base": address_base,
-            "serial_port": serial_port,
-            "timeout": timeout,
-            "is_persistent": is_persistent,
-            "baud_rate": baud_rate,
-            "data_bit": data_bit,
-            "parity": parity,
-            "stop_bit": stop_bit,
-            "mode": mode,
-            "retry_times": retry_times,
-            "group_id": group_id,
-            "alarm_interval": alarm_interval,
-            "collect_interval": collect_interval,
-        },
-    )
+    return api_create_modbus_devices(project_key, [_dump_model_or_dict(item) for item in devices])
 
 
 @mcp.tool(
@@ -185,11 +186,11 @@ def collector_modbus_device_edit(
 @mcp.tool(
     name="collector.modbus_point_create",
     description=(
-        "汇采-创建 Modbus 采集点位。调用 "
+        "汇采-批量创建 Modbus 采集点位。依次调用 "
         "{data_collector_base_url}/api/collector/modbus/point/add_collect_point。"
-        "创建点位必须传 payload.name 名称、payload.address 寄存器地址、"
-        "payload.type 数据类型,以及 payload.func_code 或 payload.register_type 寄存器类型。"
-        "payload 会补齐默认值: point_id='', scale_ratio=1, value_offset=0, group_id=0, "
+        "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/输入寄存器。"
@@ -202,9 +203,15 @@ def collector_modbus_device_edit(
 )
 def collector_modbus_point_create(
     project_key: str,
-    payload: dict[str, Any],
+    points: list[ModbusPointCreateItem],
 ) -> dict[str, Any]:
-    return api_create_modbus_point(project_key, payload)
+    return api_create_modbus_points(project_key, points)
+
+
+def _dump_model_or_dict(item: Any) -> dict[str, Any]:
+    if isinstance(item, BaseModel):
+        return item.model_dump(exclude_none=True)
+    return dict(item)
 
 
 @mcp.tool(

+ 57 - 41
data_collector_mcp/s7_server.py

@@ -1,10 +1,12 @@
 from __future__ import annotations
 
-from typing import Any
+from typing import Any, NotRequired, TypedDict
+
+from pydantic import BaseModel
 
 from .collector_api import (
-    create_s7_device as api_create_s7_device,
-    create_s7_point as api_create_s7_point,
+    create_s7_devices as api_create_s7_devices,
+    create_s7_points as api_create_s7_points,
     edit_s7_device as api_edit_s7_device,
     edit_s7_point as api_edit_s7_point,
 )
@@ -16,6 +18,40 @@ from .gateway_api import (
 from .mcp_app import mcp
 
 
+class S7DeviceCreateItem(BaseModel):
+    name: str
+    ip: str
+    rock: int
+    slot: int
+    port: int = 102
+    device_type: int = 1
+    tsap_conn_type: str | None = None
+    is_persistent: bool = False
+    device_group_id: int = 0
+    group_id: int | None = None
+    timeout: int = 3
+    alarm_interval: int = 90
+    collect_interval: int = 5
+
+
+class S7PointCreateItem(TypedDict):
+    device_id: int
+    name: str
+    address: str
+    data_type: NotRequired[str]
+    type: NotRequired[str]
+    register_type: NotRequired[int]
+    register_area: 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]
+    describe: NotRequired[str]
+
+
 @mcp.tool(
     name="s7.raw_read",
     description=(
@@ -61,7 +97,7 @@ def s7_raw_read(
         "ip 为 PLC 地址,port 默认 102,rock 为机架号,slot 为槽号,"
         "device_type 为设备类型,默认 S7-1200,可传 S7-1200、S7-Smart200。"
         "tsap_conn_type 可选 PG、OP、BASIC;未传时 S7-1200 使用 PG,"
-        "device_type=S7-Smart200 时使用 OP。points 为点位数组,每个点位必须包含 "
+        "device_type=S7-Smart200 时,tsap_conn_type使用 OP。points 为点位数组,每个点位必须包含 "
         "area、start、type;area 可用 DB、M、I、Q,S7-Smart200 还支持 V;"
         "area=DB 时 db 必须大于 0。"
         "type 可用 bool、byte、int8、int16、uint16、int32、uint32、int64、uint64、"
@@ -118,49 +154,23 @@ def _resolve_collector_s7_tsap_conn_type(device_type: int, tsap_conn_type: str |
 @mcp.tool(
     name="collector.s7_device_create",
     description=(
-        "汇采-创建 S7 设备。调用 {data_collector_base_url}/api/collector/device,"
+        "汇采-批量创建 S7 设备。依次调用 {data_collector_base_url}/api/collector/device,"
         "与 Modbus 创建设备共用地址,但请求体固定 type=s7,入参使用 S7 字段。"
-        "创建设备必须传 name 名称、ip IP 地址、rock 机架号(轨道号)、slot 槽号;"
+        "全部设备创建调用完成后会内部调用设备列表并匹配每个设备 id;如果匹配到多个设备,"
+        "选择 id 最大的。devices 每项必须传 name 名称、ip IP 地址、rock 机架号(轨道号)、slot 槽号;"
         "port 默认 102,device_type 默认 1。tsap_conn_type 可选 PG、OP、BASIC;"
         "未传时 device_type=1 使用 PG,device_type=3 时使用 OP。"
         "默认参数: is_persistent=false, "
         "device_group_id=0, timeout=3, alarm_interval=90, collect_interval=5。"
         "device_type: 1=S7-1200, 2=S7-1500, 3=S7-Smart200。"
-        "tsap_conn_type 可用 PG、OP、BASIC。响应透传上游 JSON,state=0 表示业务成功。"
+        "tsap_conn_type 可用 PG、OP、BASIC。返回批量结果,state=0 表示全部创建并匹配成功。"
     ),
 )
 def collector_s7_device_create(
     project_key: str,
-    name: str,
-    ip: str,
-    rock: int,
-    slot: int,
-    port: int = 102,
-    device_type: int = 1,
-    tsap_conn_type: str | None = None,
-    is_persistent: bool = False,
-    device_group_id: int = 0,
-    timeout: int = 3,
-    alarm_interval: int = 90,
-    collect_interval: int = 5,
+    devices: list[S7DeviceCreateItem],
 ) -> dict[str, Any]:
-    return api_create_s7_device(
-        project_key,
-        {
-            "name": name,
-            "ip": ip,
-            "rock": rock,
-            "slot": slot,
-            "port": port,
-            "device_type": device_type,
-            "tsap_conn_type": _resolve_collector_s7_tsap_conn_type(device_type, tsap_conn_type),
-            "is_persistent": is_persistent,
-            "device_group_id": device_group_id,
-            "timeout": timeout,
-            "alarm_interval": alarm_interval,
-            "collect_interval": collect_interval,
-        },
-    )
+    return api_create_s7_devices(project_key, [_dump_model_or_dict(item) for item in devices])
 
 
 @mcp.tool(
@@ -215,9 +225,9 @@ def collector_s7_device_edit(
 @mcp.tool(
     name="collector.s7_point_create",
     description=(
-        "汇采-创建 S7 采集点位。调用 {data_collector_base_url}/api/collector/s7/point/add。"
-        "创建点位必须传 payload.device_id、payload.name、payload.address、payload.data_type 或 payload.type,"
-        "以及 payload.register_type 或 payload.register_area。payload 会补齐默认值: point_id='', "
+        "汇采-批量创建 S7 采集点位。依次调用 {data_collector_base_url}/api/collector/s7/point/add。"
+        "points 每项必须传 device_id、name、address、data_type 或 type,"
+        "以及 register_type 或 register_area。每个点位会补齐默认值: point_id='', "
         "scale_ratio=1, value_offset=0, group_Id=0, invalid_values='', valid_range_start=null, "
         "valid_range_end=null。register_type: 1=I输入区, 2=Q输出区, 3=M存储区, 4=DB数据块, "
         "5=V区, 6=AI模拟输入;register_area 可用 I、Q、M、DB、V、AI。"
@@ -229,9 +239,15 @@ def collector_s7_device_edit(
 )
 def collector_s7_point_create(
     project_key: str,
-    payload: dict[str, Any],
+    points: list[S7PointCreateItem],
 ) -> dict[str, Any]:
-    return api_create_s7_point(project_key, payload)
+    return api_create_s7_points(project_key, points)
+
+
+def _dump_model_or_dict(item: Any) -> dict[str, Any]:
+    if isinstance(item, BaseModel):
+        return item.model_dump(exclude_none=True)
+    return dict(item)
 
 
 @mcp.tool(

+ 51 - 38
docs/data-collector-mcp-design.md

@@ -387,9 +387,9 @@ POST {data_collector_base_url}/api/collector/device
 | `4` | RTU OVER TCP。 |
 | `5` | RTU OVER UDP。 |
 
-MCP 工具可以接收一个 `payload`,服务端校验必填项、补齐默认值后转发
+MCP 工具接收 `devices` 数组,服务端会按数组顺序逐个调用上游创建设备接口。全部创建调用完成后,服务端会调用一次 `collector.device_list`,递归展开设备树并为每个创建成功的输入设备匹配 `device_id`;如果匹配到多个设备,选择 `id` 最大的设备
 
-必填项:
+`devices[]` 必填项:
 
 - `device_type`:协议类型,`1=TCP`、`2=RTU`、`3=UDP`、`4=RTU OVER TCP`、`5=RTU OVER UDP`。
 - `ip`:IP 地址。
@@ -404,9 +404,15 @@ MCP 工具可以接收一个 `payload`,服务端校验必填项、补齐默认
 
 ```json
 {
-  "type": "modbus",
+  "devices[].type": "modbus",
+  "devices[].serial_port": "",
   "timeout": 3,
-  "is_persistent": true,
+  "is_persistent": false,
+  "baud_rate": 0,
+  "data_bit": 0,
+  "parity": 0,
+  "stop_bit": 0,
+  "mode": 0,
   "group_id": 0,
   "alarm_interval": 90,
   "collect_interval": 5,
@@ -419,16 +425,18 @@ MCP 工具可以接收一个 `payload`,服务端校验必填项、补齐默认
 ```json
 {
   "project_key": "dev-01",
-  "payload": {
-    "name": "modbus_tcp_1",
-    "device_type": 1,
-    "ip": "127.0.0.1",
-    "port": 5502,
-    "slave_id": 1,
-    "byte_order": 1,
-    "word_order": 1,
-    "address_base": 0
-  }
+  "devices": [
+    {
+      "name": "modbus_tcp_1",
+      "device_type": 1,
+      "ip": "127.0.0.1",
+      "port": 5502,
+      "slave_id": 1,
+      "byte_order": 1,
+      "word_order": 1,
+      "address_base": 0
+    }
+  ]
 }
 ```
 
@@ -445,7 +453,7 @@ MCP 工具可以接收一个 `payload`,服务端校验必填项、补齐默认
   "timeout": 3,
   "byte_order": 1,
   "word_order": 1,
-  "is_persistent": true,
+  "is_persistent": false,
   "group_id": 0,
   "alarm_interval": 90,
   "collect_interval": 5,
@@ -454,7 +462,7 @@ MCP 工具可以接收一个 `payload`,服务端校验必填项、补齐默认
 }
 ```
 
-响应透传上游返回。业务成功按响应体 `state == 0` 判断
+返回批量结果。`state == 0` 表示所有设备创建并匹配成功;单项结果中的 `device_id` 是从设备列表匹配得到的设备 ID
 
 ### `collector.modbus_point_create`
 
@@ -466,20 +474,21 @@ MCP 工具可以接收一个 `payload`,服务端校验必填项、补齐默认
 POST {data_collector_base_url}/api/collector/modbus/point/add_collect_point
 ```
 
-MCP 工具接收 `payload`,服务端做少量默认值补齐后转发
+MCP 工具接收 `points` 数组,服务端会按数组顺序逐个调用上游创建点位接口
 
-创建点位时必须传入以下字段:
+`points[]` 必须传入以下字段:
 
 | 字段 | 说明 |
 |---|---|
-| `payload.name` | 点位名称。 |
-| `payload.address` | 寄存器地址。 |
-| `payload.type` | 数据类型。 |
-| `payload.func_code` 或 `payload.register_type` | 寄存器类型。 |
+| `points[].device_id` | 所属 Modbus 设备 ID。 |
+| `points[].name` | 点位名称。 |
+| `points[].address` | 寄存器地址。 |
+| `points[].type` | 数据类型。 |
+| `points[].func_code` 或 `points[].register_type` | 寄存器类型。 |
 
-如果传入 `payload.register_type`,服务会自动转换为汇采接口要求的 `payload.func_code`。
+如果传入 `points[].register_type`,服务会自动转换为汇采接口要求的 `func_code`。
 
-`payload.register_type` 映射:
+`points[].register_type` 映射:
 
 | `register_type` | `func_code` | 含义 |
 |---|---:|---|
@@ -488,7 +497,7 @@ MCP 工具接收 `payload`,服务端做少量默认值补齐后转发。
 | `holding_register` | `3` | Read Holding Registers,保持寄存器。 |
 | `input_register` | `4` | Read Input Registers,输入寄存器。 |
 
-`payload.func_code` 含义:
+`points[].func_code` 含义:
 
 | 值 | 含义 |
 |---:|---|
@@ -497,7 +506,7 @@ MCP 工具接收 `payload`,服务端做少量默认值补齐后转发。
 | `3` | Read Holding Registers,保持寄存器。 |
 | `4` | Read Input Registers,输入寄存器。 |
 
-`payload.type` 支持:
+`points[].type` 支持:
 
 ```text
 bool, int16, uint16, int32, uint32, int64, uint64, float32, float64
@@ -536,15 +545,17 @@ bool, int16, uint16, int32, uint32, int64, uint64, float32, float64
 ```json
 {
   "project_key": "dev-01",
-  "payload": {
-    "device_id": 1,
-    "name": "holding_register_uint16",
-    "point_id": "HR_UINT16",
-    "describe": "保持寄存器 uint16 示例",
-    "register_type": "holding_register",
-    "address": 10,
-    "type": "WORD"
-  }
+  "points": [
+    {
+      "device_id": 1,
+      "name": "holding_register_uint16",
+      "point_id": "HR_UINT16",
+      "describe": "保持寄存器 uint16 示例",
+      "register_type": "holding_register",
+      "address": 10,
+      "type": "WORD"
+    }
+  ]
 }
 ```
 
@@ -569,7 +580,7 @@ bool, int16, uint16, int32, uint32, int64, uint64, float32, float64
 }
 ```
 
-响应透传上游返回。业务成功按响应体 `state == 0` 判断
+返回批量结果。`state == 0` 表示所有点位创建成功;每个点位必须自带 `device_id`
 
 ### `collector.device_list`
 
@@ -712,8 +723,10 @@ UPSTREAM_REQUEST_TIMEOUT
 - 登录 token 缓存命中。
 - 登录 token 过期后重新登录。
 - `modbus.point_collect_test` 请求 URL、请求体和透传响应。
-- `collector.modbus_device_create` 默认值合并、请求 URL、Authorization header 和透传响应。
-- `collector.modbus_point_create` 默认值合并、请求 URL、Authorization header 和透传响应。
+- `collector.modbus_device_create` 批量创建、默认值合并、设备列表匹配 `device_id` 和重复匹配取最大 `id`。
+- `collector.modbus_point_create` 批量创建、默认值合并、请求 URL、Authorization header 和逐项响应。
+- `collector.s7_device_create` 批量创建、默认值合并、设备列表匹配 `device_id` 和重复匹配取最大 `id`。
+- `collector.s7_point_create` 批量创建、默认值合并、请求 URL、Authorization header 和逐项响应。
 - `collector.device_list` 默认 `num_points=false`。
 
 ## 非目标

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

@@ -1260,6 +1260,8 @@ http://192.168.75.110:32080
 | `alarm_interval` | int | 否 | 缺失时为 `0` | 告警间隔。 |
 | `collect_interval` | int | 否 | 缺失时为 `0` | 采集周期。 |
 
+MCP 工具 `collector.modbus_device_create` 接收 `devices` 数组并逐个调用本接口。MCP 默认值包括 `serial_port=""`、`timeout=3`、`is_persistent=false`、`baud_rate=0`、`data_bit=0`、`parity=0`、`stop_bit=0`、`mode=0`、`retry_times=0`、`group_id=0`、`alarm_interval=90`、`collect_interval=5`;`address_base` 会转换为本接口的 `address_offset`。全部创建设备调用完成后,MCP 会调用设备列表接口匹配 `device_id`,匹配到多个时选择最大 `id`。
+
 #### 请求示例:Modbus TCP
 
 ```json
@@ -1466,6 +1468,8 @@ http://192.168.75.110:32080
 | `group_id` | int | 否 | Go 零值 `0` | 点位分组 ID;`0` 表示不归属具体分组或默认分组。 |
 | `bit` | int | `type=bool` 且读寄存器时需要 | Go 零值 `0` | 位下标。`func_code=3/4` 且 `type=bool` 时,从寄存器值中取 `(value >> bit) & 1`。建议范围 `0` 到 `15`。 |
 
+MCP 工具 `collector.modbus_point_create` 接收 `points` 数组并逐个调用本接口。每个点位必须自带 `device_id`;MCP 默认值包括 `point_id=""`、`scale_ratio=1`、`value_offset=0`、`group_id=0`、`invalid_values=""`、`valid_range_start=null`、`valid_range_end=null`、`bit=0`。可传 `register_type`,MCP 会转换为本接口的 `func_code`。
+
 #### 请求示例:保持寄存器 uint16
 
 ```json
@@ -1736,6 +1740,8 @@ S7 点位 `address` 是字符串,不是纯数字。采集时会按 `register_t
 | `device_group_id` | int | 否 | Go 零值 `0` | 父设备分组 ID。`0` 表示顶层设备。 |
 | `timeout` | int | 否 | 创建接口请求结构包含该字段,但当前创建逻辑未写入内部配置 | 超时时间,单位秒。 |
 
+MCP 工具 `collector.s7_device_create` 接收 `devices` 数组并逐个调用本接口。MCP 默认值包括 `port=102`、`device_type=1`、`tsap_conn_type=null`、`is_persistent=false`、`device_group_id=0`、`timeout=3`、`alarm_interval=90`、`collect_interval=5`;`tsap_conn_type` 不传或为 `null` 时,S7-1200/S7-1500 默认 `PG`,S7-Smart200 默认 `OP`。全部创建设备调用完成后,MCP 会调用设备列表接口匹配 `device_id`,匹配到多个时选择最大 `id`。
+
 #### 请求示例
 
 ```json
@@ -1875,6 +1881,8 @@ S7 点位 `address` 是字符串,不是纯数字。采集时会按 `register_t
 | `valid_range_start` | number/null | 否 | `null` | 合法范围最小值。 |
 | `valid_range_end` | number/null | 否 | `null` | 合法范围最大值。 |
 
+MCP 工具 `collector.s7_point_create` 接收 `points` 数组并逐个调用本接口。每个点位必须自带 `device_id`;MCP 默认值包括 `point_id=""`、`scale_ratio=1`、`value_offset=0`、`group_Id=0`、`invalid_values=""`、`valid_range_start=null`、`valid_range_end=null`。可传 `register_area`,MCP 会转换为本接口的 `register_type`。
+
 #### 请求示例:DB 区 float32
 
 ```json

+ 171 - 0
tests/test_collector_api.py

@@ -91,6 +91,108 @@ class CollectorApiTests(unittest.TestCase):
                 with self.assertRaisesRegex(ValueError, f"payload.{field_name} is required"):
                     collector_api.create_modbus_device("dev-01", payload)
 
+    def test_create_modbus_devices_batches_then_matches_largest_id(self) -> None:
+        self._patch_project()
+        devices = [
+            {
+                "name": "modbus_tcp_1",
+                "device_type": 1,
+                "ip": "127.0.0.1",
+                "port": 5502,
+                "slave_id": 1,
+                "byte_order": 1,
+                "word_order": 1,
+                "address_base": 0,
+                "group_id": 10,
+            },
+            {
+                "name": "modbus_tcp_2",
+                "device_type": 1,
+                "ip": "127.0.0.2",
+                "port": 5503,
+                "slave_id": 2,
+                "byte_order": 1,
+                "word_order": 1,
+                "address_base": 0,
+                "group_id": 10,
+            },
+        ]
+        device_list = {
+            "state": 0,
+            "devices": [
+                {
+                    "id": 10,
+                    "name": "group",
+                    "type": "devicegroup",
+                    "groups": [
+                        {
+                            "id": 3,
+                            "name": "modbus_tcp_1",
+                            "type": "modbus",
+                            "device_type": 1,
+                            "ip": "127.0.0.1",
+                            "port": 5502,
+                            "slave_id": 1,
+                            "group_id": 10,
+                            "address_offset": 0,
+                        },
+                        {
+                            "id": 9,
+                            "name": "modbus_tcp_1",
+                            "type": "modbus",
+                            "device_type": 1,
+                            "ip": "127.0.0.1",
+                            "port": 5502,
+                            "slave_id": 1,
+                            "group_id": 10,
+                            "address_offset": 0,
+                        },
+                        {
+                            "id": 11,
+                            "name": "modbus_tcp_2",
+                            "type": "modbus",
+                            "device_type": 1,
+                            "ip": "127.0.0.2",
+                            "port": 5503,
+                            "slave_id": 2,
+                            "group_id": 10,
+                            "address_offset": 0,
+                        },
+                    ],
+                }
+            ],
+        }
+        with patch(
+            "data_collector_mcp.collector_api.request_json",
+            side_effect=[{"state": 0}, {"state": 0}, device_list],
+        ) as request_json:
+            result = collector_api.create_modbus_devices("dev-01", devices)
+
+        self.assertEqual(result["state"], 0)
+        self.assertEqual(result["summary"], {"total": 2, "created": 2, "matched": 2, "failed": 0})
+        self.assertEqual(result["results"][0]["device_id"], 9)
+        self.assertEqual(result["results"][1]["device_id"], 11)
+        self.assertEqual(request_json.call_args_list[0].args[1], "http://collector.test/api/collector/device")
+        self.assertEqual(request_json.call_args_list[1].args[1], "http://collector.test/api/collector/device")
+        self.assertEqual(request_json.call_args_list[2].args[1], "http://collector.test/api/collector/device?num_points=false")
+
+    def test_create_modbus_points_batches_requests(self) -> None:
+        self._patch_project()
+        points = [
+            {"device_id": 9, "name": "temperature", "func_code": 3, "address": 10, "type": "uint16"},
+            {"device_id": 9, "name": "pressure", "func_code": 3, "address": 11, "type": "uint16"},
+        ]
+        with patch(
+            "data_collector_mcp.collector_api.request_json",
+            side_effect=[{"state": 0}, {"state": 0}],
+        ) as request_json:
+            result = collector_api.create_modbus_points("dev-01", points)
+
+        self.assertEqual(result["state"], 0)
+        self.assertEqual(result["summary"], {"total": 2, "success": 2, "failed": 0})
+        self.assertEqual(request_json.call_count, 2)
+        self.assertEqual(request_json.call_args_list[0].args[1], "http://collector.test/api/collector/modbus/point/add_collect_point")
+
     def test_edit_modbus_device_maps_aliases_and_posts_to_legacy_endpoint(self) -> None:
         self._patch_project()
         response = {"state": 0, "state_info": "操作成功", "data": None}
@@ -460,6 +562,75 @@ class CollectorApiTests(unittest.TestCase):
         self.assertEqual(payload["slot"], 1)
         self.assertNotIn("group_id", payload)
 
+    def test_create_s7_devices_batches_then_matches_largest_id(self) -> None:
+        self._patch_project()
+        devices = [
+            {
+                "name": "s7_1200_1",
+                "ip": "127.0.0.1",
+                "rock": 0,
+                "slot": 1,
+                "device_group_id": 10,
+            }
+        ]
+        device_list = {
+            "state": 0,
+            "devices": [
+                {
+                    "id": 5,
+                    "name": "s7_1200_1",
+                    "type": "s7",
+                    "ip": "127.0.0.1",
+                    "port": 102,
+                    "rock": 0,
+                    "slot": 1,
+                    "device_type": 1,
+                    "tsap_conn_type": "PG",
+                    "group_id": 10,
+                },
+                {
+                    "id": 8,
+                    "name": "s7_1200_1",
+                    "type": "s7",
+                    "ip": "127.0.0.1",
+                    "port": 102,
+                    "rock": 0,
+                    "slot": 1,
+                    "device_type": 1,
+                    "tsap_conn_type": "PG",
+                    "group_id": 10,
+                },
+            ],
+        }
+        with patch(
+            "data_collector_mcp.collector_api.request_json",
+            side_effect=[{"state": 0}, device_list],
+        ) as request_json:
+            result = collector_api.create_s7_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"], 8)
+        self.assertEqual(request_json.call_args_list[0].args[1], "http://collector.test/api/collector/device")
+        self.assertEqual(request_json.call_args_list[1].args[1], "http://collector.test/api/collector/device?num_points=false")
+
+    def test_create_s7_points_batches_requests(self) -> None:
+        self._patch_project()
+        points = [
+            {"device_id": 8, "name": "db_real", "register_area": "DB", "address": "1.10", "type": "REAL"},
+            {"device_id": 8, "name": "m_bool", "register_area": "M", "address": "10.2", "type": "BOOL"},
+        ]
+        with patch(
+            "data_collector_mcp.collector_api.request_json",
+            side_effect=[{"state": 0}, {"state": 0}],
+        ) as request_json:
+            result = collector_api.create_s7_points("dev-01", points)
+
+        self.assertEqual(result["state"], 0)
+        self.assertEqual(result["summary"], {"total": 2, "success": 2, "failed": 0})
+        self.assertEqual(request_json.call_count, 2)
+        self.assertEqual(request_json.call_args_list[0].args[1], "http://collector.test/api/collector/s7/point/add")
+
     def test_edit_s7_device_maps_ori_id_and_posts_to_update_endpoint(self) -> None:
         self._patch_project()
         with patch(

+ 36 - 55
tests/test_server_tools.py

@@ -117,27 +117,11 @@ class ServerToolTests(unittest.TestCase):
             )
         points.assert_called_once_with("dev-01", device_id=1, device_type="s7", group_id=2)
 
-    def test_modbus_device_create_uses_explicit_parameters(self) -> None:
+    def test_modbus_device_create_accepts_batch_devices(self) -> None:
         signature = inspect.signature(modbus_server.collector_modbus_device_create)
-        self.assertNotIn("payload", signature.parameters)
-
-        with patch("data_collector_mcp.modbus_server.api_create_modbus_device", return_value={"state": 0}) as api_create:
-            result = modbus_server.collector_modbus_device_create(
-                project_key="dev-01",
-                name="modbus_tcp_1",
-                device_type=1,
-                ip="127.0.0.1",
-                port=5502,
-                slave_id=1,
-                byte_order=1,
-                word_order=1,
-                address_base=0,
-                group_id=10,
-            )
+        self.assertIn("devices", signature.parameters)
 
-        self.assertEqual(result, {"state": 0})
-        api_create.assert_called_once_with(
-            "dev-01",
+        devices = [
             {
                 "name": "modbus_tcp_1",
                 "device_type": 1,
@@ -147,20 +131,25 @@ class ServerToolTests(unittest.TestCase):
                 "byte_order": 1,
                 "word_order": 1,
                 "address_base": 0,
-                "serial_port": "",
-                "timeout": 3,
-                "is_persistent": True,
-                "baud_rate": 0,
-                "data_bit": 0,
-                "parity": 0,
-                "stop_bit": 0,
-                "mode": 0,
-                "retry_times": 0,
                 "group_id": 10,
-                "alarm_interval": 90,
-                "collect_interval": 5,
-            },
-        )
+            }
+        ]
+        with patch("data_collector_mcp.modbus_server.api_create_modbus_devices", return_value={"state": 0}) as api_create:
+            result = modbus_server.collector_modbus_device_create(
+                project_key="dev-01",
+                devices=devices,
+            )
+
+        self.assertEqual(result, {"state": 0})
+        api_create.assert_called_once_with("dev-01", devices)
+
+    def test_modbus_point_create_accepts_batch_points(self) -> None:
+        points = [{"device_id": 1, "name": "temperature", "address": 10, "type": "uint16", "func_code": 3}]
+        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)
+
+        self.assertEqual(result, {"state": 0})
+        api_create.assert_called_once_with("dev-01", points)
 
     def test_modbus_device_edit_uses_explicit_parameters(self) -> None:
         signature = inspect.signature(modbus_server.collector_modbus_device_edit)
@@ -195,7 +184,7 @@ class ServerToolTests(unittest.TestCase):
                 "word_order": 2,
                 "serial_port": "",
                 "timeout": 3,
-                "is_persistent": True,
+                "is_persistent": False,
                 "baud_rate": 0,
                 "data_bit": 0,
                 "parity": 0,
@@ -260,39 +249,31 @@ class ServerToolTests(unittest.TestCase):
         self.assertEqual(payload["register_type"], "holding_register")
         self.assertNotIn("func_code", payload)
 
-    def test_s7_device_create_uses_explicit_parameters(self) -> None:
+    def test_s7_device_create_accepts_batch_devices(self) -> None:
         signature = inspect.signature(s7_server.collector_s7_device_create)
-        self.assertNotIn("payload", signature.parameters)
+        self.assertIn("devices", signature.parameters)
 
-        with patch("data_collector_mcp.s7_server.api_create_s7_device", return_value={"state": 0}) as api_create:
+        devices = [{"name": "s7_1200_1", "ip": "127.0.0.1", "rock": 0, "slot": 1, "tsap_conn_type": "OP"}]
+        with patch("data_collector_mcp.s7_server.api_create_s7_devices", return_value={"state": 0}) as api_create:
             result = s7_server.collector_s7_device_create(
                 project_key="dev-01",
-                name="s7_1200_1",
-                ip="127.0.0.1",
-                rock=0,
-                slot=1,
-                tsap_conn_type="OP",
+                devices=devices,
             )
 
         self.assertEqual(result, {"state": 0})
         api_create.assert_called_once_with(
             "dev-01",
-            {
-                "name": "s7_1200_1",
-                "ip": "127.0.0.1",
-                "rock": 0,
-                "slot": 1,
-                "port": 102,
-                "device_type": 1,
-                "tsap_conn_type": "OP",
-                "is_persistent": False,
-                "device_group_id": 0,
-                "timeout": 3,
-                "alarm_interval": 90,
-                "collect_interval": 5,
-            },
+            [{"name": "s7_1200_1", "ip": "127.0.0.1", "rock": 0, "slot": 1, "tsap_conn_type": "OP"}],
         )
 
+    def test_s7_point_create_accepts_batch_points(self) -> None:
+        points = [{"device_id": 3, "name": "db_real", "address": "1.10", "type": "REAL", "register_area": "DB"}]
+        with patch("data_collector_mcp.s7_server.api_create_s7_points", return_value={"state": 0}) as api_create:
+            result = s7_server.collector_s7_point_create(project_key="dev-01", points=points)
+
+        self.assertEqual(result, {"state": 0})
+        api_create.assert_called_once_with("dev-01", points)
+
     def test_s7_device_edit_uses_explicit_parameters(self) -> None:
         signature = inspect.signature(s7_server.collector_s7_device_edit)
         self.assertNotIn("payload", signature.parameters)