Explorar el Código

新增s7设备和点位mcp

Lu Xianghui hace 1 mes
padre
commit
2e0f238511

+ 1 - 1
data_collector_mcp/__main__.py

@@ -1,4 +1,4 @@
-from .server import main
+from .app import main
 
 
 
 
 if __name__ == "__main__":
 if __name__ == "__main__":

+ 45 - 0
data_collector_mcp/app.py

@@ -0,0 +1,45 @@
+from __future__ import annotations
+
+import logging
+import os
+
+import uvicorn
+
+from .db import check_database_connection
+from .mcp_app import mcp
+
+# Import tool modules for registration side effects.
+from . import common_server as common_server  # noqa: F401
+from . import modbus_server as modbus_server  # noqa: F401
+from . import s7_server as s7_server  # noqa: F401
+
+
+logger = logging.getLogger(__name__)
+
+
+def build_mcp_http_app(path: str | None = None):
+    effective_path = str(path or os.getenv("MCP_PATH", "/mcp")).strip() or "/mcp"
+    return mcp.http_app(path=effective_path, transport="http", stateless_http=True)
+
+
+def main() -> None:
+    logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO"))
+    try:
+        check_database_connection()
+    except Exception:
+        logger.exception("Database startup check failed")
+        raise SystemExit(1)
+
+    host = os.getenv("MCP_HOST", "0.0.0.0").strip() or "0.0.0.0"
+    port = int(os.getenv("MCP_PORT", "8501"))
+    path = os.getenv("MCP_PATH", "/mcp").strip() or "/mcp"
+    app = build_mcp_http_app(path)
+    print(f"Using FastMCP app '{mcp.name}' on http://{host}:{port}{path}")
+    uvicorn.run(
+        app,
+        host=host,
+        port=port,
+        lifespan="on",
+        timeout_graceful_shutdown=2,
+        ws="websockets-sansio",
+    )

+ 145 - 1
data_collector_mcp/collector_api.py

@@ -4,8 +4,9 @@ from typing import Any
 
 
 from .auth import find_project_config, resolve_project_token
 from .auth import find_project_config, resolve_project_token
 from .http_client import request_json
 from .http_client import request_json
-from .protocols import MODBUS_SPEC
+from .protocols import MODBUS_SPEC, S7_SPEC
 from .protocols.modbus import MODBUS_POINT_TYPE_ALIASES, MODBUS_REGISTER_TYPE_ALIASES
 from .protocols.modbus import MODBUS_POINT_TYPE_ALIASES, MODBUS_REGISTER_TYPE_ALIASES
+from .protocols.s7 import S7_POINT_TYPE_ALIASES, S7_REGISTER_TYPE_ALIASES
 
 
 
 
 def _merge_defaults(defaults: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
 def _merge_defaults(defaults: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
@@ -130,6 +131,94 @@ def _normalize_modbus_point_edit_payload(payload: dict[str, Any]) -> dict[str, A
     return normalized
     return normalized
 
 
 
 
+def _normalize_s7_device_payload(payload: dict[str, Any]) -> dict[str, Any]:
+    normalized = dict(payload)
+
+    normalized["name"] = _require_non_empty_text(normalized, "name")
+    normalized["ip"] = _require_non_empty_text(normalized, "ip")
+    _require_present(normalized, "rock")
+    _require_present(normalized, "slot")
+    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:
+        normalized["tsap_conn_type"] = _normalize_s7_tsap_conn_type(normalized["tsap_conn_type"])
+
+    return normalized
+
+
+def _normalize_s7_device_edit_payload(payload: dict[str, Any]) -> dict[str, Any]:
+    normalized = _normalize_s7_device_payload(payload)
+    if normalized.get("id") is None:
+        normalized["id"] = _require_present(normalized, "ori_id")
+    normalized["id"] = _normalize_positive_int(normalized["id"], "payload.id")
+    normalized.pop("ori_id", None)
+    return normalized
+
+
+def _normalize_s7_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")
+    normalized["address"] = _require_non_empty_text(normalized, "address")
+    if require_device_id:
+        normalized["device_id"] = _normalize_positive_int(
+            _require_present(normalized, "device_id"),
+            "payload.device_id",
+        )
+
+    raw_type = normalized.get("data_type")
+    if raw_type is None:
+        raw_type = _require_non_empty_text(normalized, "type")
+    else:
+        raw_type = str(raw_type).strip()
+        if not raw_type:
+            raise ValueError("payload.data_type is required")
+    normalized_type = S7_POINT_TYPE_ALIASES.get(raw_type)
+    if normalized_type is None:
+        normalized_type = S7_POINT_TYPE_ALIASES.get(raw_type.lower())
+    if normalized_type is None:
+        raise ValueError(
+            "payload.data_type is invalid; use one of bool, uint8, int8, "
+            "uint16, int16, uint32, int32, float32, float64, or a documented "
+            "alias such as BOOL, BYTE, SINT, WORD, INT, DWORD, DINT, REAL, LREAL"
+        )
+    normalized["data_type"] = normalized_type
+    normalized.pop("type", None)
+
+    if normalized.get("register_type") is None:
+        register_area = _require_non_empty_text(normalized, "register_area")
+        register_type = S7_REGISTER_TYPE_ALIASES.get(register_area)
+        if register_type is None:
+            register_type = S7_REGISTER_TYPE_ALIASES.get(register_area.lower())
+        if register_type is None:
+            raise ValueError("payload.register_area is invalid; use I, Q, M, DB, V, AI, or register_type 1..6")
+        normalized["register_type"] = register_type
+    else:
+        try:
+            normalized["register_type"] = int(str(normalized["register_type"]).strip())
+        except Exception as exc:
+            raise ValueError("payload.register_type must be one of 1, 2, 3, 4, 5, 6") from exc
+        if normalized["register_type"] not in {1, 2, 3, 4, 5, 6}:
+            raise ValueError("payload.register_type must be one of 1, 2, 3, 4, 5, 6")
+    normalized.pop("register_area", None)
+
+    if "group_id" in normalized and "group_Id" not in normalized:
+        normalized["group_Id"] = normalized["group_id"]
+        normalized.pop("group_id", None)
+
+    return normalized
+
+
+def _normalize_s7_point_edit_payload(payload: dict[str, Any]) -> dict[str, Any]:
+    normalized = _normalize_s7_point_payload(payload)
+    if normalized.get("id") is None:
+        normalized["id"] = _require_present(normalized, "ori_id")
+    normalized["id"] = _normalize_positive_int(normalized["id"], "payload.id")
+    normalized.pop("ori_id", None)
+    return normalized
+
+
 def _request_collector(
 def _request_collector(
     project_key: str,
     project_key: str,
     method: str,
     method: str,
@@ -174,6 +263,30 @@ def create_modbus_point(project_key: str, payload: dict[str, Any]) -> dict[str,
     )
     )
 
 
 
 
+def create_s7_device(project_key: str, payload: dict[str, Any]) -> dict[str, Any]:
+    return _request_collector(
+        project_key,
+        "POST",
+        S7_SPEC.create_device_path,
+        json_payload=_merge_defaults(
+            S7_SPEC.device_defaults,
+            _normalize_s7_device_payload(payload),
+        ),
+    )
+
+
+def create_s7_point(project_key: str, payload: dict[str, Any]) -> dict[str, Any]:
+    return _request_collector(
+        project_key,
+        "POST",
+        S7_SPEC.create_point_path,
+        json_payload=_merge_defaults(
+            S7_SPEC.point_defaults,
+            _normalize_s7_point_payload(payload),
+        ),
+    )
+
+
 def edit_modbus_device(project_key: str, payload: dict[str, Any]) -> dict[str, Any]:
 def edit_modbus_device(project_key: str, payload: dict[str, Any]) -> dict[str, Any]:
     return _request_collector(
     return _request_collector(
         project_key,
         project_key,
@@ -207,6 +320,30 @@ def edit_modbus_point(project_key: str, payload: dict[str, Any]) -> dict[str, An
     )
     )
 
 
 
 
+def edit_s7_device(project_key: str, payload: dict[str, Any]) -> dict[str, Any]:
+    return _request_collector(
+        project_key,
+        "POST",
+        "/api/collector/s7/device/update",
+        json_payload=_merge_defaults(
+            S7_SPEC.device_defaults,
+            _normalize_s7_device_edit_payload(payload),
+        ),
+    )
+
+
+def edit_s7_point(project_key: str, payload: dict[str, Any]) -> dict[str, Any]:
+    return _request_collector(
+        project_key,
+        "POST",
+        "/api/collector/s7/point/update",
+        json_payload=_merge_defaults(
+            S7_SPEC.point_defaults,
+            _normalize_s7_point_edit_payload(payload),
+        ),
+    )
+
+
 def list_devices(project_key: str, num_points: bool = False) -> dict[str, Any]:
 def list_devices(project_key: str, num_points: bool = False) -> dict[str, Any]:
     num_points_text = "true" if num_points else "false"
     num_points_text = "true" if num_points else "false"
     return _request_collector(
     return _request_collector(
@@ -278,6 +415,13 @@ def _normalize_device_type(device_type: str) -> str:
     return normalized
     return normalized
 
 
 
 
+def _normalize_s7_tsap_conn_type(value: Any) -> str:
+    normalized = str(value or "").strip().upper()
+    if normalized not in {"PG", "OP", "BASIC"}:
+        raise ValueError("payload.tsap_conn_type must be one of PG, OP, BASIC")
+    return normalized
+
+
 def _normalize_positive_int(value: int, field_name: str) -> int:
 def _normalize_positive_int(value: int, field_name: str) -> int:
     try:
     try:
         normalized = int(value)
         normalized = int(value)

+ 127 - 0
data_collector_mcp/common_server.py

@@ -0,0 +1,127 @@
+from __future__ import annotations
+
+from typing import Any
+
+from .auth import load_projects_config
+from .collector_api import (
+    connect_device as api_connect_device,
+    disconnect_device as api_disconnect_device,
+    list_device_points as api_list_device_points,
+    list_devices as api_list_devices,
+)
+from .mcp_app import mcp
+
+
+@mcp.tool(
+    name="project.list",
+    title="Project List",
+    description=(
+        "List enabled 汇采 projects available to this MCP service, including "
+        "project_key, project_name, base_url, and data_collector_base_url. "
+        "Call this first to choose project_key."
+    ),
+    tags={"project", "list"},
+)
+def project_list() -> dict[str, Any]:
+    projects = load_projects_config()
+    result = [
+        {
+            "project_key": item["project_key"],
+            "project_name": item["project_name"],
+            "base_url": item["base_url"],
+            "data_collector_base_url": item["data_collector_base_url"],
+        }
+        for item in projects
+        if item["enabled"]
+    ]
+    result.sort(key=lambda item: item["project_key"])
+    return {"projects": result, "total": len(result)}
+
+
+@mcp.tool(
+    name="collector.device_list",
+    description=(
+        "汇采-查询设备列表。调用 {data_collector_base_url}/api/collector/device。"
+        "用于查看设备树、设备分组、设备连接状态、设备采集状态和点位数量;"
+        "不返回点位明细、点位采集状态或点位当前值。"
+        "如果目标是查看某个设备下点位的采集状态和值,请使用 collector.device_points。"
+        "num_points 默认 false;只有需要统计设备或点位分组下的点位数量时才传 true。"
+        "响应透传上游 JSON,state=0 表示业务成功。"
+    ),
+)
+def collector_device_list(project_key: str, num_points: bool = False) -> dict[str, Any]:
+    return api_list_devices(project_key, num_points=num_points)
+
+
+@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.status 1=未连接、2=已连接、3=连接异常;"
+        "data.running_status 0=未采集、1=采集中、2=采集异常。"
+    ),
+)
+def collector_device_connect(
+    project_key: str,
+    device_id: int,
+    device_type: str = "modbus",
+) -> dict[str, Any]:
+    return api_connect_device(project_key, device_id=device_id, device_type=device_type)
+
+
+@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 表示业务成功。常见状态:"
+        "data.status 1=未连接、2=已连接、3=连接异常;"
+        "data.running_status 0=未采集、1=采集中、2=采集异常。"
+    ),
+)
+def collector_device_disconnect(
+    project_key: str,
+    device_id: int,
+    device_type: str = "modbus",
+) -> dict[str, Any]:
+    return api_disconnect_device(project_key, device_id=device_id, device_type=device_type)
+
+
+@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。"
+    ),
+)
+def collector_device_points(
+    project_key: str,
+    device_id: int,
+    device_type: str = "modbus",
+    group_id: int = 0,
+) -> dict[str, Any]:
+    return api_list_device_points(
+        project_key,
+        device_id=device_id,
+        device_type=device_type,
+        group_id=group_id,
+    )

+ 66 - 13
data_collector_mcp/gateway_api.py

@@ -4,7 +4,22 @@ from typing import Any
 
 
 from .auth import find_project_config
 from .auth import find_project_config
 from .http_client import request_json
 from .http_client import request_json
-from .protocols import MODBUS_SPEC
+from .protocols import MODBUS_SPEC, S7_SPEC
+
+
+def _request_gateway(project_key: str, path: str | None, payload: dict[str, Any], protocol: str) -> dict[str, Any]:
+    project = find_project_config(project_key)
+    if not path:
+        raise ValueError(f"{protocol} gateway path is not configured")
+
+    response_payload = request_json(
+        "POST",
+        f"{project['base_url']}{path}",
+        json_payload=payload,
+    )
+    if not isinstance(response_payload, dict):
+        raise ValueError(f"gateway API returned invalid payload: {response_payload}")
+    return response_payload
 
 
 
 
 def modbus_point_collect_test(
 def modbus_point_collect_test(
@@ -18,10 +33,6 @@ def modbus_point_collect_test(
     word_byte_order: str = "ABCD",
     word_byte_order: str = "ABCD",
     address_base: int = 0,
     address_base: int = 0,
 ) -> dict[str, Any]:
 ) -> dict[str, Any]:
-    project = find_project_config(project_key)
-    if not MODBUS_SPEC.point_test_path:
-        raise ValueError("modbus point test path is not configured")
-
     payload = {
     payload = {
         "device_type": device_type,
         "device_type": device_type,
         "ip": ip,
         "ip": ip,
@@ -31,12 +42,54 @@ def modbus_point_collect_test(
         "slave_id": slave_id,
         "slave_id": slave_id,
         "points": points,
         "points": points,
     }
     }
+    return _request_gateway(project_key, MODBUS_SPEC.point_test_path, payload, "modbus point test")
 
 
-    response_payload = request_json(
-        "POST",
-        f"{project['base_url']}{MODBUS_SPEC.point_test_path}",
-        json_payload=payload,
-    )
-    if not isinstance(response_payload, dict):
-        raise ValueError(f"gateway API returned invalid payload: {response_payload}")
-    return response_payload
+
+def s7_raw_read(
+    project_key: str,
+    *,
+    ip: str,
+    rock: int,
+    slot: int,
+    read: dict[str, Any],
+    device_type: str = "S7TCP",
+    port: int = 102,
+    tsap_conn_type: str = "PG",
+) -> dict[str, Any]:
+    payload = {
+        "device_type": device_type,
+        "ip": ip,
+        "port": port,
+        "rock": rock,
+        "slot": slot,
+        "tsap_conn_type": tsap_conn_type,
+        "read": read,
+    }
+    return _request_gateway(project_key, S7_SPEC.raw_read_path, payload, "s7 raw read")
+
+
+def s7_point_collect_test(
+    project_key: str,
+    *,
+    ip: str,
+    rock: int,
+    slot: int,
+    points: list[dict[str, Any]],
+    device_type: str = "S7TCP",
+    port: int = 102,
+    tsap_conn_type: str = "PG",
+) -> dict[str, Any]:
+    payload = {
+        "device_type": device_type,
+        "ip": ip,
+        "port": port,
+        "rock": rock,
+        "slot": slot,
+        "tsap_conn_type": tsap_conn_type,
+        "points": points,
+    }
+    return _request_gateway(project_key, S7_SPEC.point_test_path, payload, "s7 point test")
+
+
+def s7_connect_scan(project_key: str, *, ip: str) -> dict[str, Any]:
+    return _request_gateway(project_key, S7_SPEC.connect_scan_path, {"ip": ip}, "s7 connect scan")

+ 15 - 0
data_collector_mcp/mcp_app.py

@@ -0,0 +1,15 @@
+from __future__ import annotations
+
+from fastmcp import FastMCP
+
+
+SERVER_INSTRUCTIONS = (
+    "Data collector tools. Use project.list first to choose a project_key. "
+    "base_url is used for login and gateway test APIs. data_collector_base_url "
+    "is required for collector management APIs and never falls back to base_url. "
+    "For Modbus, gateway word_byte_order values ABCD/BADC/CDAB/DCBA must be "
+    "mapped to collector byte_order/word_order before creating a device."
+)
+
+
+mcp = FastMCP("data-collector-mcp", instructions=SERVER_INSTRUCTIONS)

+ 52 - 168
data_collector_mcp/server.py → data_collector_mcp/modbus_server.py

@@ -1,58 +1,15 @@
 from __future__ import annotations
 from __future__ import annotations
 
 
-import logging
-import os
 from typing import Any
 from typing import Any
 
 
-import uvicorn
-from fastmcp import FastMCP
-
-from .auth import load_projects_config
 from .collector_api import (
 from .collector_api import (
-    connect_device as api_connect_device,
     create_modbus_device as api_create_modbus_device,
     create_modbus_device as api_create_modbus_device,
     create_modbus_point as api_create_modbus_point,
     create_modbus_point as api_create_modbus_point,
-    disconnect_device as api_disconnect_device,
     edit_modbus_device as api_edit_modbus_device,
     edit_modbus_device as api_edit_modbus_device,
     edit_modbus_point as api_edit_modbus_point,
     edit_modbus_point as api_edit_modbus_point,
-    list_device_points as api_list_device_points,
-    list_devices as api_list_devices,
 )
 )
-from .db import check_database_connection
 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
-
-
-SERVER_INSTRUCTIONS = (
-    "Data collector tools. Use project.list first to choose a project_key. "
-    "base_url is used for login and gateway test APIs. data_collector_base_url "
-    "is required for collector management APIs and never falls back to base_url. "
-    "For Modbus, gateway word_byte_order values ABCD/BADC/CDAB/DCBA must be "
-    "mapped to collector byte_order/word_order before creating a device."
-)
-
-
-mcp = FastMCP("data-collector-mcp", instructions=SERVER_INSTRUCTIONS)
-logger = logging.getLogger(__name__)
-
-
-@mcp.tool(
-    name="project.list",
-    title="Project List",
-    description="List enabled 汇采 projects available to this MCP service. Call this first to choose project_key.",
-    tags={"project", "list"},
-)
-def project_list() -> dict[str, Any]:
-    projects = load_projects_config()
-    result = [
-        {
-            "project_key": item["project_key"],
-            "project_name": item["project_name"],
-        }
-        for item in projects
-        if item["enabled"]
-    ]
-    result.sort(key=lambda item: item["project_key"])
-    return {"projects": result, "total": len(result)}
+from .mcp_app import mcp
 
 
 
 
 @mcp.tool(
 @mcp.tool(
@@ -93,11 +50,10 @@ def modbus_point_collect_test(
     name="collector.modbus_device_create",
     name="collector.modbus_device_create",
     description=(
     description=(
         "汇采-创建 Modbus 设备。调用 {data_collector_base_url}/api/collector/device,"
         "汇采-创建 Modbus 设备。调用 {data_collector_base_url}/api/collector/device,"
-        "需要登录后使用 Authorization。创建设备必须传 payload.device_type 协议类型、"
-        "payload.ip IP 地址、payload.port 端口号、payload.name 名称、payload.slave_id、"
-        "payload.word_order 字顺序、payload.byte_order 字节顺序、payload.address_base。"
-        "payload.address_base 会转换为汇采接口的 address_offset。"
-        "payload 会补齐默认值: type=modbus, timeout=3, is_persistent=true, group_id=0, "
+        "需要登录后使用 Authorization。创建设备必须传 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, "
         "alarm_interval=90, collect_interval=5, retry_times=0。"
         "alarm_interval=90, collect_interval=5, retry_times=0。"
         "注意 byte_order/word_order 是汇采枚举,不是网关 word_byte_order。"
         "注意 byte_order/word_order 是汇采枚举,不是网关 word_byte_order。"
         "byte_order: 1=Big Endian, 2=Small Endian。word_order: 1=Big Endian, 2=Small Endian。"
         "byte_order: 1=Big Endian, 2=Small Endian。word_order: 1=Big Endian, 2=Small Endian。"
@@ -108,9 +64,52 @@ def modbus_point_collect_test(
 )
 )
 def collector_modbus_device_create(
 def collector_modbus_device_create(
     project_key: str,
     project_key: str,
-    payload: dict[str, Any],
+    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,
 ) -> dict[str, Any]:
 ) -> dict[str, Any]:
-    return api_create_modbus_device(project_key, payload)
+    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,
+        },
+    )
 
 
 
 
 @mcp.tool(
 @mcp.tool(
@@ -123,7 +122,7 @@ def collector_modbus_device_create(
         "RTU 设备必须传 serial_port。"
         "RTU 设备必须传 serial_port。"
         "编辑前设备不能处于已连接状态;若已连接,请先调用 collector.device_disconnect。"
         "编辑前设备不能处于已连接状态;若已连接,请先调用 collector.device_disconnect。"
         "该接口是全量更新语义,未传字段可能被默认值覆盖。"
         "该接口是全量更新语义,未传字段可能被默认值覆盖。"
-        "默认参数: ip='', port=0, serial_port='', timeout=3, is_persistent=true, "
+        "默认参数: 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, "
         "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。"
         "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。"
         "连接类型: 1=TCP, 2=RTU, 3=UDP, 4=RTU OVER TCP, 5=RTU OVER UDP。"
@@ -143,7 +142,7 @@ def collector_modbus_device_edit(
     port: int = 0,
     port: int = 0,
     serial_port: str = "",
     serial_port: str = "",
     timeout: int = 3,
     timeout: int = 3,
-    is_persistent: bool = True,
+    is_persistent: bool = False,
     baud_rate: int = 0,
     baud_rate: int = 0,
     data_bit: int = 0,
     data_bit: int = 0,
     parity: int = 0,
     parity: int = 0,
@@ -267,118 +266,3 @@ def collector_modbus_point_edit(
     else:
     else:
         payload["register_type"] = register_type
         payload["register_type"] = register_type
     return api_edit_modbus_point(project_key, payload)
     return api_edit_modbus_point(project_key, payload)
-
-
-@mcp.tool(
-    name="collector.device_list",
-    description=(
-        "汇采-查询设备列表。调用 {data_collector_base_url}/api/collector/device。"
-        "用于查看设备树、设备分组、设备连接状态、设备采集状态和点位数量;"
-        "不返回点位明细、点位采集状态或点位当前值。"
-        "如果目标是查看某个设备下点位的采集状态和值,请使用 collector.device_points。"
-        "num_points 默认 false;只有需要统计设备或点位分组下的点位数量时才传 true。"
-        "响应透传上游 JSON,state=0 表示业务成功。"
-    ),
-)
-def collector_device_list(project_key: str, num_points: bool = False) -> dict[str, Any]:
-    return api_list_devices(project_key, num_points=num_points)
-
-
-@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.status 1=未连接、2=已连接、3=连接异常;"
-        "data.running_status 0=未采集、1=采集中、2=采集异常。"
-    ),
-)
-def collector_device_connect(
-    project_key: str,
-    device_id: int,
-    device_type: str = "modbus",
-) -> dict[str, Any]:
-    return api_connect_device(project_key, device_id=device_id, device_type=device_type)
-
-
-@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 表示业务成功。常见状态:"
-        "data.status 1=未连接、2=已连接、3=连接异常;"
-        "data.running_status 0=未采集、1=采集中、2=采集异常。"
-    ),
-)
-def collector_device_disconnect(
-    project_key: str,
-    device_id: int,
-    device_type: str = "modbus",
-) -> dict[str, Any]:
-    return api_disconnect_device(project_key, device_id=device_id, device_type=device_type)
-
-
-@mcp.tool(
-    name="collector.device_points",
-    description=(
-        "汇采-查询设备点位列表。调用 "
-        "{data_collector_base_url}/api/collector/common/device/get_collect_point。"
-        "主要用于查看某个设备下点位的采集状态和值:Modbus 返回 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。"
-    ),
-)
-def collector_device_points(
-    project_key: str,
-    device_id: int,
-    device_type: str = "modbus",
-    group_id: int = 0,
-) -> dict[str, Any]:
-    return api_list_device_points(
-        project_key,
-        device_id=device_id,
-        device_type=device_type,
-        group_id=group_id,
-    )
-
-
-def build_mcp_http_app(path: str | None = None):
-    effective_path = str(path or os.getenv("MCP_PATH", "/mcp")).strip() or "/mcp"
-    return mcp.http_app(path=effective_path, transport="http", stateless_http=True)
-
-
-def main() -> None:
-    logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO"))
-    try:
-        check_database_connection()
-    except Exception:
-        logger.exception("Database startup check failed")
-        raise SystemExit(1)
-
-    host = os.getenv("MCP_HOST", "0.0.0.0").strip() or "0.0.0.0"
-    port = int(os.getenv("MCP_PORT", "8501"))
-    path = os.getenv("MCP_PATH", "/mcp").strip() or "/mcp"
-    app = build_mcp_http_app(path)
-    print(f"Using FastMCP app '{mcp.name}' on http://{host}:{port}{path}")
-    uvicorn.run(
-        app,
-        host=host,
-        port=port,
-        lifespan="on",
-        timeout_graceful_shutdown=2,
-        ws="websockets-sansio",
-    )

+ 2 - 1
data_collector_mcp/protocols/__init__.py

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

+ 2 - 0
data_collector_mcp/protocols/base.py

@@ -10,5 +10,7 @@ class ProtocolSpec:
     create_device_path: str
     create_device_path: str
     create_point_path: str
     create_point_path: str
     point_test_path: str | None = None
     point_test_path: str | None = None
+    raw_read_path: str | None = None
+    connect_scan_path: str | None = None
     device_defaults: dict[str, Any] = field(default_factory=dict)
     device_defaults: dict[str, Any] = field(default_factory=dict)
     point_defaults: dict[str, Any] = field(default_factory=dict)
     point_defaults: dict[str, Any] = field(default_factory=dict)

+ 102 - 0
data_collector_mcp/protocols/s7.py

@@ -0,0 +1,102 @@
+from __future__ import annotations
+
+from .base import ProtocolSpec
+
+
+S7_REGISTER_TYPE_ALIASES = {
+    "i": 1,
+    "input": 1,
+    "输入": 1,
+    "q": 2,
+    "output": 2,
+    "输出": 2,
+    "m": 3,
+    "memory": 3,
+    "位存储": 3,
+    "db": 4,
+    "data_block": 4,
+    "数据块": 4,
+    "v": 5,
+    "v_memory": 5,
+    "v区": 5,
+    "ai": 6,
+    "analog_input": 6,
+    "模拟输入": 6,
+}
+
+
+S7_POINT_TYPE_ALIASES = {
+    "bool": "bool",
+    "boolean": "bool",
+    "BOOL": "bool",
+    "byte": "uint8",
+    "BYTE": "uint8",
+    "uint8": "uint8",
+    "UINT8": "uint8",
+    "sint": "int8",
+    "SINT": "int8",
+    "int8": "int8",
+    "INT8": "int8",
+    "word": "uint16",
+    "WORD": "uint16",
+    "uint16": "uint16",
+    "UINT16": "uint16",
+    "int": "int16",
+    "INT": "int16",
+    "short": "int16",
+    "SHORT": "int16",
+    "int16": "int16",
+    "INT16": "int16",
+    "dword": "uint32",
+    "DWORD": "uint32",
+    "uint32": "uint32",
+    "UINT32": "uint32",
+    "dint": "int32",
+    "DINT": "int32",
+    "long": "int32",
+    "LONG": "int32",
+    "int32": "int32",
+    "INT32": "int32",
+    "real": "float32",
+    "REAL": "float32",
+    "float": "float32",
+    "FLOAT": "float32",
+    "float32": "float32",
+    "FLOAT32": "float32",
+    "lreal": "float64",
+    "LREAL": "float64",
+    "double": "float64",
+    "DOUBLE": "float64",
+    "float64": "float64",
+    "FLOAT64": "float64",
+}
+
+
+S7_SPEC = ProtocolSpec(
+    protocol="s7",
+    create_device_path="/api/collector/device",
+    create_point_path="/api/collector/s7/point/add",
+    point_test_path="/api/dc-gateway/s7/read_points",
+    raw_read_path="/api/dc-gateway/s7/read",
+    connect_scan_path="/api/dc-gateway/s7/connect_scan",
+    device_defaults={
+        "type": "s7",
+        "device_type": 1,
+        "port": 102,
+        "tsap_conn_type": "PG",
+        "timeout": 3,
+        "is_persistent": True,
+        "device_group_id": 0,
+        "alarm_interval": 90,
+        "collect_interval": 5,
+    },
+    point_defaults={
+        "point_id": "",
+        "scale_ratio": 1,
+        "value_offset": 0,
+        "group_Id": 0,
+        "invalid_values": "",
+        "valid_range_start": None,
+        "valid_range_end": None,
+    },
+)

+ 271 - 0
data_collector_mcp/s7_server.py

@@ -0,0 +1,271 @@
+from __future__ import annotations
+
+from typing import Any
+
+from .collector_api import (
+    create_s7_device as api_create_s7_device,
+    create_s7_point as api_create_s7_point,
+    edit_s7_device as api_edit_s7_device,
+    edit_s7_point as api_edit_s7_point,
+)
+from .gateway_api import (
+    s7_connect_scan as api_s7_connect_scan,
+    s7_point_collect_test as api_s7_point_collect_test,
+    s7_raw_read as api_s7_raw_read,
+)
+from .mcp_app import mcp
+
+
+@mcp.tool(
+    name="s7.raw_read",
+    description=(
+        "通过采集网关对 Siemens S7 TCP 设备执行原始字节读取。调用 "
+        "{base_url}/api/dc-gateway/s7/read。请求字段使用网关/汇采一致命名:"
+        "ip 为 PLC 地址,port 默认 102,rock 为机架号,slot 为槽号,"
+        "tsap_conn_type 默认 PG,可选 PG、OP、BASIC。read 必须包含 area、start、size;"
+        "area 可用 DB、M、I、Q;area=DB 时 read.db 必须大于 0。"
+        "该接口不解析业务值,只返回语义化 communication:S7_CONNECT、S7_CONNECTED、"
+        "S7_READ、原始十六进制字节、S7_DISCONNECT 或 S7_ERROR。"
+        "响应透传上游 JSON,code=0 表示业务成功,code!=0 时 msg 为失败原因。"
+    ),
+)
+def s7_raw_read(
+    project_key: str,
+    ip: str,
+    read: dict[str, Any],
+    rock: int = 0,
+    slot: int = 1,
+    device_type: str = "S7TCP",
+    port: int = 102,
+    tsap_conn_type: str = "PG",
+) -> dict[str, Any]:
+    return api_s7_raw_read(
+        project_key,
+        ip=ip,
+        rock=rock,
+        slot=slot,
+        read=read,
+        device_type=device_type,
+        port=port,
+        tsap_conn_type=tsap_conn_type,
+    )
+
+
+@mcp.tool(
+    name="s7.point_collect_test",
+    description=(
+        "通过采集网关读取 Siemens S7 TCP 点位并转换为业务值。调用 "
+        "{base_url}/api/dc-gateway/s7/read_points。请求字段使用网关/汇采一致命名:"
+        "ip 为 PLC 地址,port 默认 102,rock 为机架号,slot 为槽号,"
+        "tsap_conn_type 默认 PG,可选 PG、OP、BASIC。points 为点位数组,每个点位必须包含 "
+        "area、start、type;area 可用 DB、M、I、Q;area=DB 时 db 必须大于 0。"
+        "type 可用 bool、byte、int8、int16、uint16、int32、uint32、int64、uint64、"
+        "float32、float64;bool 点可传 bit 读取指定 0..7 位,非 bool 点不能传 bit。"
+        "S7 多字节数值按大端解析,响应 data.points[].value 为转换后的业务值,"
+        "data.communication 为语义化连接/读取/断开记录。响应透传上游 JSON,code=0 表示业务成功。"
+    ),
+)
+def s7_point_collect_test(
+    project_key: str,
+    ip: str,
+    rock: int,
+    slot: int,
+    points: list[dict[str, Any]],
+    device_type: str = "S7TCP",
+    port: int = 102,
+    tsap_conn_type: str = "PG",
+) -> dict[str, Any]:
+    return api_s7_point_collect_test(
+        project_key,
+        ip=ip,
+        rock=rock,
+        slot=slot,
+        points=points,
+        device_type=device_type,
+        port=port,
+        tsap_conn_type=tsap_conn_type,
+    )
+
+
+@mcp.tool(
+    name="s7.connect_scan",
+    description=(
+        "通过采集网关扫描 Siemens S7 TCP 设备可用连接组合。调用 "
+        "{base_url}/api/dc-gateway/s7/connect_scan。只需要传 PLC 的 ip,网关固定使用端口 102,"
+        "扫描 rock=0/1/2、slot=0/1/2、tsap_conn_type=PG/OP/BASIC 共 27 种组合。"
+        "响应 data.available 只返回可连接成功的组合,每项包含 rock、slot、tsap_conn_type;"
+        "如果没有可用组合则 available 为空数组。响应透传上游 JSON,code=0 表示扫描完成。"
+    ),
+)
+def s7_connect_scan(project_key: str, ip: str) -> dict[str, Any]:
+    return api_s7_connect_scan(project_key, ip=ip)
+
+
+@mcp.tool(
+    name="collector.s7_device_create",
+    description=(
+        "汇采-创建 S7 设备。调用 {data_collector_base_url}/api/collector/device,"
+        "与 Modbus 创建设备共用地址,但请求体固定 type=s7,入参使用 S7 字段。"
+        "创建设备必须传 name 名称、ip IP 地址、rock 机架号(轨道号)、slot 槽号;"
+        "port 默认 102,device_type 默认 1,tsap_conn_type 默认 PG。"
+        "默认参数: 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 Smart 200。"
+        "tsap_conn_type 可用 PG、OP、BASIC。响应透传上游 JSON,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 = "PG",
+    is_persistent: bool = False,
+    device_group_id: int = 0,
+    timeout: int = 3,
+    alarm_interval: int = 90,
+    collect_interval: int = 5,
+) -> dict[str, Any]:
+    return api_create_s7_device(
+        project_key,
+        {
+            "name": name,
+            "ip": ip,
+            "rock": rock,
+            "slot": slot,
+            "port": port,
+            "device_type": device_type,
+            "tsap_conn_type": tsap_conn_type,
+            "is_persistent": is_persistent,
+            "device_group_id": device_group_id,
+            "timeout": timeout,
+            "alarm_interval": alarm_interval,
+            "collect_interval": collect_interval,
+        },
+    )
+
+
+@mcp.tool(
+    name="collector.s7_device_edit",
+    description=(
+        "汇采-编辑 S7 设备。调用 {data_collector_base_url}/api/collector/s7/device/update。"
+        "必须传 ori_id 原设备 id、name、ip、rock、slot;port 默认 102,device_type 默认 1,"
+        "tsap_conn_type 默认 PG。编辑前设备不能处于已连接状态;若已连接,请先调用 "
+        "collector.device_disconnect,并传 device_type=s7。该接口是全量更新语义。"
+        "默认参数: 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 Smart 200。"
+        "tsap_conn_type 可用 PG、OP、BASIC。响应透传上游 JSON,state=0 表示业务成功。"
+    ),
+)
+def collector_s7_device_edit(
+    project_key: str,
+    ori_id: int,
+    name: str,
+    ip: str,
+    rock: int,
+    slot: int,
+    port: int = 102,
+    device_type: int = 1,
+    tsap_conn_type: str = "PG",
+    is_persistent: bool = False,
+    device_group_id: int = 0,
+    timeout: int = 3,
+    alarm_interval: int = 90,
+    collect_interval: int = 5,
+) -> dict[str, Any]:
+    return api_edit_s7_device(
+        project_key,
+        {
+            "ori_id": ori_id,
+            "name": name,
+            "ip": ip,
+            "rock": rock,
+            "slot": slot,
+            "port": port,
+            "device_type": device_type,
+            "tsap_conn_type": tsap_conn_type,
+            "is_persistent": is_persistent,
+            "device_group_id": device_group_id,
+            "timeout": timeout,
+            "alarm_interval": alarm_interval,
+            "collect_interval": collect_interval,
+        },
+    )
+
+
+@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='', "
+        "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。"
+        "data_type 可用 bool, uint8, int8, uint16, int16, uint32, int32, float32, float64。"
+        "S7 地址格式:I/Q/M/V 布尔点通常为 byte.bit,如 10.2;DB 布尔点为 db.byte.bit,"
+        "如 1.10.2;非布尔点 I/Q/M/V 通常为 byte 地址,如 10,DB 为 db.byte,如 1.10。"
+        "响应透传上游 JSON,state=0 表示业务成功。"
+    ),
+)
+def collector_s7_point_create(
+    project_key: str,
+    payload: dict[str, Any],
+) -> dict[str, Any]:
+    return api_create_s7_point(project_key, payload)
+
+
+@mcp.tool(
+    name="collector.s7_point_edit",
+    description=(
+        "汇采-编辑 S7 采集点位。调用 {data_collector_base_url}/api/collector/s7/point/update。"
+        "必须传 ori_id 原点位 id、device_id 所属设备 id、name、address、data_type,"
+        "以及 register_type 或 register_area。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。register_type: 1=I, 2=Q, 3=M, 4=DB, 5=V, 6=AI。"
+        "data_type 可用 bool, uint8, int8, uint16, int16, uint32, int32, float32, float64。"
+        "响应透传上游 JSON,state=0 表示业务成功。"
+    ),
+)
+def collector_s7_point_edit(
+    project_key: str,
+    ori_id: int,
+    device_id: int,
+    name: str,
+    address: str,
+    data_type: str,
+    register_type: int = 0,
+    register_area: 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,
+    describe: str = "",
+) -> dict[str, Any]:
+    payload: dict[str, Any] = {
+        "ori_id": ori_id,
+        "device_id": device_id,
+        "name": name,
+        "address": address,
+        "data_type": data_type,
+        "point_id": point_id,
+        "scale_ratio": scale_ratio,
+        "value_offset": value_offset,
+        "group_id": group_id,
+        "invalid_values": invalid_values,
+        "valid_range_start": valid_range_start,
+        "valid_range_end": valid_range_end,
+        "describe": describe,
+    }
+    if register_type:
+        payload["register_type"] = register_type
+    else:
+        payload["register_area"] = register_area
+    return api_edit_s7_point(project_key, payload)

+ 823 - 17
docs/接口汇总.md

@@ -2,7 +2,7 @@
 
 
 # 1. 采集网关接口说明
 # 1. 采集网关接口说明
 
 
-当前接口仅支持通过 HTTP 调用 Modbus TCP 设备,暂不支持 Modbus RTU 或其他设备类型
+当前采集网关接口支持通过 HTTP 调用 Modbus TCP 和 Siemens S7 TCP 设备。Modbus RTU、S7 串口或其他协议设备不属于采集网关当前 HTTP 直连范围。
 
 
 ## 采集网关服务器请求地址
 ## 采集网关服务器请求地址
 
 
@@ -56,7 +56,7 @@ Content-Type: application/json
 
 
 ### HTTP 状态码说明
 ### HTTP 状态码说明
 
 
-接口校验失败、设备连接失败、Modbus 通信失败时,通常仍返回 HTTP `200`,业务是否成功由响应体中的 `code` 判断。
+接口校验失败、设备连接失败、Modbus/S7 通信失败时,通常仍返回 HTTP `200`,业务是否成功由响应体中的 `code` 判断。
 
 
 AI 调用接口时应按以下规则判断结果:
 AI 调用接口时应按以下规则判断结果:
 
 
@@ -72,8 +72,19 @@ AI 调用接口时应按以下规则判断结果:
 | 原始 Modbus 读取 | `POST` | `/api/dc-gateway/modbus/read` | 读取 Modbus 数据,但不解析为业务值,只返回 Tx/Rx 通信报文。 |
 | 原始 Modbus 读取 | `POST` | `/api/dc-gateway/modbus/read` | 读取 Modbus 数据,但不解析为业务值,只返回 Tx/Rx 通信报文。 |
 | 点位读取并转换 | `POST` | `/api/dc-gateway/modbus/read_points` | 按点位定义读取 Modbus 数据,并按数据类型转换为业务值。 |
 | 点位读取并转换 | `POST` | `/api/dc-gateway/modbus/read_points` | 按点位定义读取 Modbus 数据,并按数据类型转换为业务值。 |
 | 点位读取并转换别名 | `POST` | `/api/dc-gateway/modbus/read-points` | 与 `/api/dc-gateway/modbus/read_points` 等价,建议优先使用下划线版本。 |
 | 点位读取并转换别名 | `POST` | `/api/dc-gateway/modbus/read-points` | 与 `/api/dc-gateway/modbus/read_points` 等价,建议优先使用下划线版本。 |
+| 原始 S7 读取 | `POST` | `/api/dc-gateway/s7/read` | 读取一段 S7 地址数据,只返回语义化 `communication`。 |
+| S7 点位读取并转换 | `POST` | `/api/dc-gateway/s7/read_points` | 按点位定义读取 S7 数据,并返回转换后的业务值和 `communication`。 |
+| S7 连接扫描 | `POST` | `/api/dc-gateway/s7/connect_scan` | 只传 IP,扫描可用的 `rock`、`slot`、`tsap_conn_type` 组合。 |
 
 
-## 通用设备字段
+## 网关接口目录
+
+| 部分 | 内容 |
+|---|---|
+| 通用部分 | 请求格式、返回格式、HTTP 状态码、健康检查。 |
+| Modbus 网关接口 | Modbus TCP 通用设备字段、地址规则、功能码、原始读取、点位读取并转换。 |
+| S7 网关接口 | S7 TCP 通用设备字段、地址字段、语义化通信记录、原始读取、点位读取并转换、连接扫描。 |
+
+## Modbus 通用设备字段
 
 
 以下字段用于描述要连接的 Modbus TCP 设备,出现在 `/modbus/read` 和 `/modbus/read_points` 请求体的顶层。
 以下字段用于描述要连接的 Modbus TCP 设备,出现在 `/modbus/read` 和 `/modbus/read_points` 请求体的顶层。
 
 
@@ -182,9 +193,10 @@ Rx:002-00 00 33 00 00 00 0B 01 03 08 00 01 42 A8 00 00 40 F8
 curl http://127.0.0.1:8000/api/dc-gateway/health
 curl http://127.0.0.1:8000/api/dc-gateway/health
 ```
 ```
 
 
-## 接口:原始 Modbus 读取
+## Modbus 网关接口说明
+### 接口:原始 Modbus 读取
 
 
-### 基本信息
+#### 基本信息
 
 
 | 项 | 值 |
 | 项 | 值 |
 |---|---|
 |---|---|
@@ -194,7 +206,7 @@ curl http://127.0.0.1:8000/api/dc-gateway/health
 | Content-Type | `application/json` |
 | Content-Type | `application/json` |
 | 用途 | 向 Modbus TCP 设备发起一次读取请求,只返回通信报文,不返回解析后的业务值。 |
 | 用途 | 向 Modbus TCP 设备发起一次读取请求,只返回通信报文,不返回解析后的业务值。 |
 
 
-### 请求体结构
+#### 请求体结构
 
 
 ```json
 ```json
 {
 {
@@ -223,7 +235,7 @@ curl http://127.0.0.1:8000/api/dc-gateway/health
 | `read.address` | integer | 是 | 无 | `>= 0` | 起始地址。实际协议地址为 `read.address + address_base`。 |
 | `read.address` | integer | 是 | 无 | `>= 0` | 起始地址。实际协议地址为 `read.address + address_base`。 |
 | `read.quantity` | integer | 是 | 无 | `1..125` | 读取数量。功能码 `1`、`2` 表示读取 bit 数量;功能码 `3`、`4` 表示读取 register 数量。 |
 | `read.quantity` | integer | 是 | 无 | `1..125` | 读取数量。功能码 `1`、`2` 表示读取 bit 数量;功能码 `3`、`4` 表示读取 register 数量。 |
 
 
-### 成功返回示例
+#### 成功返回示例
 
 
 ```json
 ```json
 {
 {
@@ -261,7 +273,7 @@ curl http://127.0.0.1:8000/api/dc-gateway/health
 | `data.device.slave_id` | integer | 本次请求使用的 Modbus 从站 ID。 |
 | `data.device.slave_id` | integer | 本次请求使用的 Modbus 从站 ID。 |
 | `data.communication` | string[] | 本次 Modbus TCP 原始 Tx/Rx 报文列表。 |
 | `data.communication` | string[] | 本次 Modbus TCP 原始 Tx/Rx 报文列表。 |
 
 
-### 失败返回示例
+#### 失败返回示例
 
 
 设备无响应或通信失败:
 设备无响应或通信失败:
 
 
@@ -306,7 +318,7 @@ curl http://127.0.0.1:8000/api/dc-gateway/health
 | `data.device` | object | 如果请求已经通过基础校验,通常会回显设备参数。 |
 | `data.device` | object | 如果请求已经通过基础校验,通常会回显设备参数。 |
 | `data.communication` | string[] | 失败前已经捕获到的通信报文。字段校验失败时通常为空数组。 |
 | `data.communication` | string[] | 失败前已经捕获到的通信报文。字段校验失败时通常为空数组。 |
 
 
-### 调用示例
+#### 调用示例
 
 
 ```bash
 ```bash
 curl -X POST http://127.0.0.1:8000/api/dc-gateway/modbus/read \
 curl -X POST http://127.0.0.1:8000/api/dc-gateway/modbus/read \
@@ -326,9 +338,9 @@ curl -X POST http://127.0.0.1:8000/api/dc-gateway/modbus/read \
   }'
   }'
 ```
 ```
 
 
-## 接口:点位读取并转换
+### 接口:modbus点位读取并转换
 
 
-### 基本信息
+#### 基本信息
 
 
 | 项 | 值 |
 | 项 | 值 |
 |---|---|
 |---|---|
@@ -339,7 +351,7 @@ curl -X POST http://127.0.0.1:8000/api/dc-gateway/modbus/read \
 | Content-Type | `application/json` |
 | Content-Type | `application/json` |
 | 用途 | 按点位定义逐个读取 Modbus 数据,并把原始 bit/register 转换为 `bool`、`int16`、`float32` 等业务值。 |
 | 用途 | 按点位定义逐个读取 Modbus 数据,并把原始 bit/register 转换为 `bool`、`int16`、`float32` 等业务值。 |
 
 
-### 请求体结构
+#### 请求体结构
 
 
 ```json
 ```json
 {
 {
@@ -387,7 +399,7 @@ curl -X POST http://127.0.0.1:8000/api/dc-gateway/modbus/read \
 | `points[].type` | string | 是 | 无 | `bool`、`int16`、`uint16`、`int32`、`uint32`、`int64`、`uint64`、`float32`、`float64` | 当前点位的数据类型。接口会按该类型决定读取寄存器数量和转换方式。输入会被转换为小写。 |
 | `points[].type` | string | 是 | 无 | `bool`、`int16`、`uint16`、`int32`、`uint32`、`int64`、`uint64`、`float32`、`float64` | 当前点位的数据类型。接口会按该类型决定读取寄存器数量和转换方式。输入会被转换为小写。 |
 | `points[].bit` | integer 或 null | 否 | `null` | `0..15` | 仅寄存器点位支持。用于从一个 16 位寄存器中取某一位并返回布尔值。功能码 `1`、`2` 不允许传 `bit`。 |
 | `points[].bit` | integer 或 null | 否 | `null` | `0..15` | 仅寄存器点位支持。用于从一个 16 位寄存器中取某一位并返回布尔值。功能码 `1`、`2` 不允许传 `bit`。 |
 
 
-### 点位类型和读取长度
+#### 点位类型和读取长度
 
 
 | `type` | 读取长度 | 可用功能码 | 返回 JSON 类型 | 含义 |
 | `type` | 读取长度 | 可用功能码 | 返回 JSON 类型 | 含义 |
 |---|---:|---|---|---|
 |---|---:|---|---|---|
@@ -401,7 +413,7 @@ curl -X POST http://127.0.0.1:8000/api/dc-gateway/modbus/read \
 | `uint64` | 4 registers | `3`、`4` | integer | 无符号 64 位整数。 |
 | `uint64` | 4 registers | `3`、`4` | integer | 无符号 64 位整数。 |
 | `float64` | 4 registers | `3`、`4` | number | IEEE 754 双精度浮点数。 |
 | `float64` | 4 registers | `3`、`4` | number | IEEE 754 双精度浮点数。 |
 
 
-### 点位校验规则
+#### 点位校验规则
 
 
 | 规则 | 说明 |
 | 规则 | 说明 |
 |---|---|
 |---|---|
@@ -411,7 +423,7 @@ curl -X POST http://127.0.0.1:8000/api/dc-gateway/modbus/read \
 | 寄存器点位可以使用 `bit` | 当 `function_code` 为 `3` 或 `4` 且 `type` 为 `bool` 时,可用 `bit` 读取寄存器指定位。 |
 | 寄存器点位可以使用 `bit` | 当 `function_code` 为 `3` 或 `4` 且 `type` 为 `bool` 时,可用 `bit` 读取寄存器指定位。 |
 | 不允许额外字段 | 请求对象中出现未定义字段会校验失败。 |
 | 不允许额外字段 | 请求对象中出现未定义字段会校验失败。 |
 
 
-### 成功返回示例
+#### 成功返回示例
 
 
 ```json
 ```json
 {
 {
@@ -459,7 +471,7 @@ curl -X POST http://127.0.0.1:8000/api/dc-gateway/modbus/read \
 | `data.points[].bit` | integer | 仅当请求点位传入 `bit` 时返回。 |
 | `data.points[].bit` | integer | 仅当请求点位传入 `bit` 时返回。 |
 | `data.points[].value` | boolean、integer 或 number | 转换后的点位值。具体 JSON 类型由 `type` 决定。 |
 | `data.points[].value` | boolean、integer 或 number | 转换后的点位值。具体 JSON 类型由 `type` 决定。 |
 
 
-### 失败返回示例
+#### 失败返回示例
 
 
 设备连接失败:
 设备连接失败:
 
 
@@ -517,7 +529,7 @@ curl -X POST http://127.0.0.1:8000/api/dc-gateway/modbus/read \
 | `data.device` | object | 如果请求已经通过基础校验,通常会回显设备参数。 |
 | `data.device` | object | 如果请求已经通过基础校验,通常会回显设备参数。 |
 | `data.points` | object[] | 失败前已成功读取的点位。字段校验失败或连接失败时通常为空数组。 |
 | `data.points` | object[] | 失败前已成功读取的点位。字段校验失败或连接失败时通常为空数组。 |
 
 
-### 调用示例
+#### 调用示例
 
 
 ```bash
 ```bash
 curl -X POST http://127.0.0.1:8000/api/dc-gateway/modbus/read_points \
 curl -X POST http://127.0.0.1:8000/api/dc-gateway/modbus/read_points \
@@ -549,6 +561,428 @@ curl -X POST http://127.0.0.1:8000/api/dc-gateway/modbus/read_points \
   }'
   }'
 ```
 ```
 
 
+## S7 网关接口说明
+
+### S7 网关目标
+
+S7 网关接口通过 HTTP 连接 Siemens S7 TCP 设备,支持连接组合扫描、原始字节读取、点位读取并转换。
+
+当前 S7 接口使用 `python-snap7` 实现。返回的 `communication` 是语义化通信记录,用于说明本次连接、读取和断开过程;它不是 Wireshark 中的 `TPKT + COTP + S7Comm` 原始网络帧。
+
+### S7 通用设备字段
+
+以下字段用于 `/api/dc-gateway/s7/read` 和 `/api/dc-gateway/s7/read_points` 请求体顶层。
+
+| 字段 | 类型 | 必填 | 默认值 | 允许值或范围 | 含义 |
+|---|---|---:|---|---|---|
+| `device_type` | string | 否 | `S7TCP` | 只能是 `S7TCP` | 设备协议类型。 |
+| `ip` | string | 是 | 无 | 合法 IPv4 或 IPv6 地址 | S7 设备 IP 地址。 |
+| `port` | integer | 否 | `102` | `1..65535` | S7 TCP 端口,通常固定为 `102`。 |
+| `rock` | integer | 是 | 无 | `0..31` | PLC 机架号。字段名沿用汇采 S7 设备接口。 |
+| `slot` | integer | 是 | 无 | `0..31` | PLC 槽号。 |
+| `tsap_conn_type` | string | 否 | `PG` | `PG`、`OP`、`BASIC` | Snap7 连接类型。大小写不敏感,服务内部统一转为大写。 |
+
+连接类型映射:
+
+| `tsap_conn_type` | Snap7 值 |
+|---|---:|
+| `PG` | `0x01` |
+| `OP` | `0x02` |
+| `BASIC` | `0x03` |
+
+### S7 地址字段
+
+| 字段 | 适用位置 | 必填 | 默认值 | 允许值或范围 | 含义 |
+|---|---|---:|---|---|---|
+| `area` | `read`、`points[]` | 是 | 无 | `DB`、`M`、`I`、`Q` | 读取区域。 |
+| `db` | `read`、`points[]` | `area=DB` 时必填 | `0` | `>=0` | DB 块号。`area=DB` 时必须大于 `0`。 |
+| `start` | `read`、`points[]` | 是 | 无 | `>=0` | 起始字节偏移。 |
+| `size` | `read` | 是 | 无 | `1..65535` | 原始读取的字节数。 |
+| `type` | `points[]` | 是 | 无 | 见 S7 点位类型表 | 点位数据类型。 |
+| `bit` | `points[]` | 否 | `null` | `0..7` | 仅 `type=bool` 支持,用于读取指定 bit。 |
+
+区域说明:
+
+| `area` | 含义 |
+|---|---|
+| `DB` | 数据块 Data Block。 |
+| `M` | Merker 标志位区。 |
+| `I` | 输入区 Inputs。 |
+| `Q` | 输出区 Outputs。 |
+
+### S7 communication 格式
+
+`communication` 是字符串数组,记录连接、读取和断开过程。
+
+示例:
+
+```text
+Tx:S7_CONNECT ip=192.168.1.10 port=102 rock=0 slot=1 tsap_conn_type=PG
+Rx:S7_CONNECTED pdu_length=480
+Tx:S7_READ area=DB db=1 start=0 size=4
+Rx:11 22 33 44
+Tx:S7_DISCONNECT
+Rx:S7_DISCONNECTED
+```
+
+说明:
+
+| 片段 | 含义 |
+|---|---|
+| `Tx:S7_CONNECT` | 网关准备发起 S7 连接,包含 IP、端口、机架号、槽号和 TSAP 连接类型。 |
+| `Rx:S7_CONNECTED` | S7 连接成功,可能包含协商后的 `pdu_length`。 |
+| `Tx:S7_READ` | 网关准备读取指定区域、DB、起始偏移和字节数。 |
+| `Rx:11 22 33 44` | 读取返回的原始数据字节,十六进制大写,空格分隔。 |
+| `Tx:S7_DISCONNECT` | 网关准备断开连接。 |
+| `Rx:S7_DISCONNECTED` | 连接已断开。 |
+| `Rx:S7_ERROR` | 连接或读取失败,后面带错误消息。 |
+
+### 接口:原始 S7 读取
+
+#### 基本信息
+
+| 项 | 值 |
+|---|---|
+| 方法 | `POST` |
+| 路径 | `/api/dc-gateway/s7/read` |
+| 完整地址 | `http://127.0.0.1:8000/api/dc-gateway/s7/read` |
+| Content-Type | `application/json` |
+| 用途 | 向 S7 TCP 设备读取一段地址数据,只返回语义化 `communication` 和原始十六进制字节,不返回解析后的业务值。 |
+
+#### 请求体结构
+
+```json
+{
+  "device_type": "S7TCP",
+  "ip": "192.168.1.10",
+  "port": 102,
+  "rock": 0,
+  "slot": 1,
+  "tsap_conn_type": "PG",
+  "read": {
+    "area": "DB",
+    "db": 1,
+    "start": 0,
+    "size": 4
+  }
+}
+```
+
+顶层字段说明见“S7 通用设备字段”。`read` 字段说明见“S7 地址字段”。
+
+#### 成功返回示例
+
+```json
+{
+  "code": 0,
+  "msg": "success",
+  "data": {
+    "device": {
+      "device_type": "S7TCP",
+      "ip": "192.168.1.10",
+      "port": 102,
+      "rock": 0,
+      "slot": 1,
+      "tsap_conn_type": "PG"
+    },
+    "communication": [
+      "Tx:S7_CONNECT ip=192.168.1.10 port=102 rock=0 slot=1 tsap_conn_type=PG",
+      "Rx:S7_CONNECTED pdu_length=480",
+      "Tx:S7_READ area=DB db=1 start=0 size=4",
+      "Rx:11 22 33 44",
+      "Tx:S7_DISCONNECT",
+      "Rx:S7_DISCONNECTED"
+    ]
+  }
+}
+```
+
+#### 失败返回示例
+
+```json
+{
+  "code": 1,
+  "msg": "TCP : Unreachable peer",
+  "data": {
+    "device": {
+      "device_type": "S7TCP",
+      "ip": "192.168.1.10",
+      "port": 102,
+      "rock": 0,
+      "slot": 1,
+      "tsap_conn_type": "PG"
+    },
+    "communication": [
+      "Tx:S7_CONNECT ip=192.168.1.10 port=102 rock=0 slot=1 tsap_conn_type=PG",
+      "Rx:S7_ERROR message=TCP : Unreachable peer"
+    ]
+  }
+}
+```
+
+#### 调用示例
+
+```bash
+curl -X POST http://127.0.0.1:8000/api/dc-gateway/s7/read \
+  -H "Content-Type: application/json" \
+  -d '{
+    "device_type": "S7TCP",
+    "ip": "192.168.1.10",
+    "port": 102,
+    "rock": 0,
+    "slot": 1,
+    "tsap_conn_type": "PG",
+    "read": {
+      "area": "DB",
+      "db": 1,
+      "start": 0,
+      "size": 4
+    }
+  }'
+```
+
+### 接口:S7 点位读取并转换
+
+#### 基本信息
+
+| 项 | 值 |
+|---|---|
+| 方法 | `POST` |
+| 路径 | `/api/dc-gateway/s7/read_points` |
+| 完整地址 | `http://127.0.0.1:8000/api/dc-gateway/s7/read_points` |
+| Content-Type | `application/json` |
+| 用途 | 按点位定义逐个读取 S7 数据,并把原始字节转换为 `bool`、`int16`、`float32` 等业务值。 |
+
+#### 请求体结构
+
+```json
+{
+  "device_type": "S7TCP",
+  "ip": "192.168.1.10",
+  "port": 102,
+  "rock": 0,
+  "slot": 1,
+  "tsap_conn_type": "PG",
+  "points": [
+    {
+      "area": "DB",
+      "db": 1,
+      "start": 0,
+      "type": "float32"
+    },
+    {
+      "area": "DB",
+      "db": 1,
+      "start": 4,
+      "type": "bool",
+      "bit": 0
+    }
+  ]
+}
+```
+
+顶层字段说明见“S7 通用设备字段”。`points[]` 字段说明见“S7 地址字段”。
+
+#### 支持的数据类型
+
+| `type` | 读取长度 | 返回 JSON 类型 | 含义 |
+|---|---:|---|---|
+| `bool` | 1 byte | boolean | 布尔值。传 `bit` 时读取指定 bit;不传 `bit` 时整个字节非 0 为 `true`。 |
+| `byte` | 1 byte | integer | 无符号 8 位整数。 |
+| `int8` | 1 byte | integer | 有符号 8 位整数。 |
+| `int16` | 2 bytes | integer | 有符号 16 位整数。 |
+| `uint16` | 2 bytes | integer | 无符号 16 位整数。 |
+| `int32` | 4 bytes | integer | 有符号 32 位整数。 |
+| `uint32` | 4 bytes | integer | 无符号 32 位整数。 |
+| `float32` | 4 bytes | number | IEEE 754 单精度浮点数。 |
+| `int64` | 8 bytes | integer | 有符号 64 位整数。 |
+| `uint64` | 8 bytes | integer | 无符号 64 位整数。 |
+| `float64` | 8 bytes | number | IEEE 754 双精度浮点数。 |
+
+S7 多字节数值按大端字节序解析。
+
+#### 点位校验规则
+
+| 规则 | 说明 |
+|---|---|
+| `area` 必须合法 | 只能是 `DB`、`M`、`I`、`Q`,输入会转为大写。 |
+| DB 区必须传有效 DB 号 | 当 `area=DB` 时,`db` 必须大于 `0`。 |
+| `type` 必须合法 | 只能使用上表列出的 S7 网关点位类型。 |
+| `bit` 只支持布尔点 | `type=bool` 时可传 `bit`,非布尔点传 `bit` 会校验失败。 |
+| 不允许额外字段 | 请求对象中出现未定义字段会校验失败。 |
+
+#### 成功返回示例
+
+```json
+{
+  "code": 0,
+  "msg": "success",
+  "data": {
+    "device": {
+      "device_type": "S7TCP",
+      "ip": "192.168.1.10",
+      "port": 102,
+      "rock": 0,
+      "slot": 1,
+      "tsap_conn_type": "PG"
+    },
+    "points": [
+      {
+        "area": "DB",
+        "db": 1,
+        "start": 0,
+        "type": "float32",
+        "value": 12.34
+      },
+      {
+        "area": "DB",
+        "db": 1,
+        "start": 4,
+        "type": "bool",
+        "value": true,
+        "bit": 0
+      }
+    ]
+  }
+}
+```
+
+成功返回字段说明:
+
+| 字段 | 类型 | 含义 |
+|---|---|---|
+| `code` | integer | `0` 表示所有点位读取和转换成功。 |
+| `msg` | string | 成功时为 `success`。 |
+| `data.device` | object | 本次请求实际使用的设备连接参数。 |
+| `data.points` | object[] | 点位读取结果数组,顺序与请求体 `points` 一致。 |
+| `data.points[].area` | string | 点位读取区域。 |
+| `data.points[].db` | integer | DB 块号;非 DB 区通常为 `0`。 |
+| `data.points[].start` | integer | 起始字节偏移。 |
+| `data.points[].type` | string | 点位类型,返回为小写。 |
+| `data.points[].bit` | integer | 仅当请求点位传入 `bit` 时返回。 |
+| `data.points[].value` | boolean、integer 或 number | 转换后的点位值。具体 JSON 类型由 `type` 决定。 |
+
+#### 失败返回说明
+
+失败时 `code=1`,`msg` 为连接失败、读取失败或字段校验错误原因。若读取部分点位后失败,`data.points` 中会保留失败前已成功读取的点位,`data.communication` 中会包含失败前的语义化记录。
+
+#### 调用示例
+
+```bash
+curl -X POST http://127.0.0.1:8000/api/dc-gateway/s7/read_points \
+  -H "Content-Type: application/json" \
+  -d '{
+    "device_type": "S7TCP",
+    "ip": "192.168.1.10",
+    "port": 102,
+    "rock": 0,
+    "slot": 1,
+    "tsap_conn_type": "PG",
+    "points": [
+      {
+        "area": "DB",
+        "db": 1,
+        "start": 0,
+        "type": "float32"
+      },
+      {
+        "area": "DB",
+        "db": 1,
+        "start": 4,
+        "type": "bool",
+        "bit": 0
+      }
+    ]
+  }'
+```
+
+### 接口:S7 连接扫描
+
+#### 基本信息
+
+| 项 | 值 |
+|---|---|
+| 方法 | `POST` |
+| 路径 | `/api/dc-gateway/s7/connect_scan` |
+| 完整地址 | `http://127.0.0.1:8000/api/dc-gateway/s7/connect_scan` |
+| Content-Type | `application/json` |
+| 用途 | 只传设备 IP,扫描可连接成功的 `rock`、`slot`、`tsap_conn_type` 组合。 |
+
+#### 请求体结构
+
+```json
+{
+  "ip": "192.168.1.10"
+}
+```
+
+端口固定使用 S7 TCP 默认端口 `102`。
+
+#### 扫描范围
+
+| 字段 | 扫描值 |
+|---|---|
+| `rock` | `0`、`1`、`2` |
+| `slot` | `0`、`1`、`2` |
+| `tsap_conn_type` | `PG`、`OP`、`BASIC` |
+
+总计扫描 `27` 种组合。接口只返回可以连接成功的组合。
+
+#### 成功返回示例
+
+```json
+{
+  "code": 0,
+  "msg": "success",
+  "data": {
+    "device": {
+      "device_type": "S7TCP",
+      "ip": "192.168.1.10",
+      "port": 102
+    },
+    "available": [
+      {
+        "rock": 0,
+        "slot": 1,
+        "tsap_conn_type": "PG"
+      },
+      {
+        "rock": 0,
+        "slot": 1,
+        "tsap_conn_type": "OP"
+      }
+    ]
+  }
+}
+```
+
+如果没有任何组合可连接,`available` 为空数组:
+
+```json
+{
+  "code": 0,
+  "msg": "success",
+  "data": {
+    "device": {
+      "device_type": "S7TCP",
+      "ip": "192.168.1.10",
+      "port": 102
+    },
+    "available": []
+  }
+}
+```
+
+#### 调用示例
+
+```bash
+curl -X POST http://127.0.0.1:8000/api/dc-gateway/s7/connect_scan \
+  -H "Content-Type: application/json" \
+  -d '{
+    "ip": "192.168.1.10"
+  }'
+```
+
 ## AI 调用决策指南
 ## AI 调用决策指南
 
 
 AI 或自动化程序选择接口时遵循以下规则:
 AI 或自动化程序选择接口时遵循以下规则:
@@ -558,6 +992,9 @@ AI 或自动化程序选择接口时遵循以下规则:
 | 只想检查网关是否启动 | `GET /api/dc-gateway/health` | 不访问 Modbus 设备,只检查 HTTP 服务。 |
 | 只想检查网关是否启动 | `GET /api/dc-gateway/health` | 不访问 Modbus 设备,只检查 HTTP 服务。 |
 | 需要查看原始 Modbus Tx/Rx 报文 | `POST /api/dc-gateway/modbus/read` | 返回 `communication`,适合调试通信、对比 Modbus Poll 报文。 |
 | 需要查看原始 Modbus Tx/Rx 报文 | `POST /api/dc-gateway/modbus/read` | 返回 `communication`,适合调试通信、对比 Modbus Poll 报文。 |
 | 需要得到点位业务值 | `POST /api/dc-gateway/modbus/read_points` | 返回 `points[].value`,自动按类型转换。 |
 | 需要得到点位业务值 | `POST /api/dc-gateway/modbus/read_points` | 返回 `points[].value`,自动按类型转换。 |
+| 需要查看 S7 语义化连接和原始字节 | `POST /api/dc-gateway/s7/read` | 返回 S7 连接、读取、断开的 `communication`,不解析为业务值。 |
+| 需要得到 S7 点位业务值 | `POST /api/dc-gateway/s7/read_points` | 返回 `points[].value`,自动按 S7 类型转换。 |
+| 不确定 S7 连接参数 | `POST /api/dc-gateway/s7/connect_scan` | 扫描可用的 `rock`、`slot`、`tsap_conn_type` 组合。 |
 
 
 AI 构造请求时必须区分两个地址:
 AI 构造请求时必须区分两个地址:
 
 
@@ -565,6 +1002,7 @@ AI 构造请求时必须区分两个地址:
 |---|---|---|
 |---|---|---|
 | 网关服务器地址 | `http://127.0.0.1:8000` | HTTP API 服务地址,用于拼接请求 URL。 |
 | 网关服务器地址 | `http://127.0.0.1:8000` | HTTP API 服务地址,用于拼接请求 URL。 |
 | Modbus 设备地址 | 请求体 `ip` 和 `port` | 实际被读取的 Modbus TCP 设备地址。 |
 | Modbus 设备地址 | 请求体 `ip` 和 `port` | 实际被读取的 Modbus TCP 设备地址。 |
+| S7 设备地址 | 请求体 `ip`、`port`、`rock`、`slot`、`tsap_conn_type` | 实际被读取的 S7 TCP 设备连接参数。 |
 
 
 AI 构造 `/modbus/read_points` 点位时应按以下步骤:
 AI 构造 `/modbus/read_points` 点位时应按以下步骤:
 
 
@@ -575,6 +1013,15 @@ AI 构造 `/modbus/read_points` 点位时应按以下步骤:
 5. 如果读取保持寄存器或输入寄存器中的某一位,设置 `type` 为 `bool` 并填写 `bit`。
 5. 如果读取保持寄存器或输入寄存器中的某一位,设置 `type` 为 `bool` 并填写 `bit`。
 6. 发送请求后检查响应体 `code`,不要只检查 HTTP 状态码。
 6. 发送请求后检查响应体 `code`,不要只检查 HTTP 状态码。
 
 
+AI 构造 `/s7/read_points` 点位时应按以下步骤:
+
+1. 如果不确定 S7 连接参数,先调用 `/s7/connect_scan` 获取可用的 `rock`、`slot`、`tsap_conn_type`。
+2. 根据设备点表选择 `area`,DB 区传 `area=DB` 且填写大于 `0` 的 `db`。
+3. 将 S7 地址换算为字节偏移,填入 `start`。
+4. 根据点位数据类型设置 `type`,S7 网关类型与汇采 S7 点位类型不完全一致:网关 8 位无符号类型为 `byte`,汇采创建点位常用 `uint8`。
+5. 如果读取布尔位,设置 `type=bool` 并填写 `bit=0..7`;非布尔点不要传 `bit`。
+6. 发送请求后检查响应体 `code`,不要只检查 HTTP 状态码。
+
 
 
 
 
 # 2. 模方登录接口
 # 2. 模方登录接口
@@ -1200,6 +1647,365 @@ http://192.168.75.110:32080
 | 同一设备下 `point_id` 重复 | `{"state":2,"state_info":"point_id 重复, point_id: {point_id}"}` |
 | 同一设备下 `point_id` 重复 | `{"state":2,"state_info":"point_id 重复, point_id: {point_id}"}` |
 | 数据库更新失败 | `{"state":2,"state_info":"编辑失败"}` |
 | 数据库更新失败 | `{"state":2,"state_info":"编辑失败"}` |
 
 
+## S7 创建设备与创建点位接口文档
+
+### 枚举汇总
+
+- S7 设备类型 `device_type`
+
+| 值 | 含义 |
+|---:|---|
+| `1` | S7-1200 |
+| `2` | S7-1500 |
+| `3` | S7 Smart 200 |
+
+- S7 TSAP 连接类型 `tsap_conn_type`
+
+| 值 | 含义 |
+|---|---|
+| `PG` | PG 连接,未识别值在底层连接库中也会按 PG 处理。 |
+| `OP` | OP 连接。 |
+| `BASIC` | Basic 连接。 |
+
+- S7 点位寄存器区域 `register_type`
+
+| 值 | 区域 | 含义 |
+|---:|---|---|
+| `1` | `I` | 输入区。 |
+| `2` | `Q` | 输出区。 |
+| `3` | `M` | 位存储区。 |
+| `4` | `DB` | 数据块区。 |
+| `5` | `V` | V 区,底层按 DB1 访问。 |
+| `6` | `AI` | 模拟输入区。 |
+
+- S7 点位数据类型 `data_type`
+
+| 值 | 含义 | 读取字节数 |
+|---|---|---:|
+| `bool` | 布尔值 | 1 |
+| `uint8` | 8 位无符号整数 | 1 |
+| `int8` | 8 位有符号整数 | 1 |
+| `uint16` | 16 位无符号整数 | 2 |
+| `int16` | 16 位有符号整数 | 2 |
+| `uint32` | 32 位无符号整数 | 4 |
+| `int32` | 32 位有符号整数 | 4 |
+| `float32` | 32 位浮点数 | 4 |
+| `float64` | 64 位浮点数 | 8 |
+
+### S7 地址格式
+
+S7 点位 `address` 是字符串,不是纯数字。采集时会按 `register_type` 和 `data_type` 解析:
+
+| 区域 | 非 bool 地址格式 | bool 地址格式 | 示例 |
+|---|---|---|---|
+| `I`、`Q`、`M` | `byte` | `byte.bit` | `10`、`10.2` |
+| `DB` | `db.byte` | `db.byte.bit` | `1.10`、`1.10.2` |
+| `V` | `byte` | `byte.bit` | `10`、`10.2` |
+| `AI` | `byte` | 不建议使用 bool | `10` |
+
+`bool` 点位位下标范围为 `0..7`。DB 区必须包含 DB 号;例如 DB1 的第 10 字节第 2 位写为 `1.10.2`。
+
+### 1. 创建 S7 设备
+
+#### 基本信息
+
+- URL:`/api/collector/device`
+- 方法:`POST`
+- 处理函数:`AddS7Device`
+- 请求结构:`ReqAddS7Device`
+
+说明:S7 创建设备在 MCP 中与 Modbus 创建设备共用 `/api/collector/device` 地址,但请求体固定带 `type=s7`,其余字段仍使用 S7 入参。
+
+#### 请求字段
+
+| 字段 | 类型 | 必填 | 默认/行为 | 含义 |
+|---|---|---|---|---|
+| `type` | string | MCP 自动补齐 | 固定为 `s7` | 协议类型,用于在共用创建设备接口中区分 S7 设备。 |
+| `name` | string | 是 | 会 `strings.TrimSpace`;trim 后为空返回 `名称不能为空` | 设备名称。 |
+| `ip` | string | 是 | 会 `strings.TrimSpace`;为空返回 `IP地址不能为空` | S7 PLC IP 地址。 |
+| `port` | int | 建议传 | 保存到设备配置;底层 gos7 连接实际使用 `ip` 中端口或默认 `102` | 端口,常用 `102`。 |
+| `device_type` | int | 建议传 | 缺失时为 `0` | S7 设备类型,见枚举。 |
+| `rock` | int/null | 是 | nil 返回 `轨道号或槽号不能为空` | 机架号。字段名沿用实现中的 `rock`。常见 S7-1200/1500 为 `0`。 |
+| `slot` | int/null | 是 | nil 返回 `轨道号或槽号不能为空` | 槽号。常见 S7-1200/1500 为 `1`。 |
+| `tsap_conn_type` | string | 否 | 底层未识别值按 PG 处理 | TSAP 连接类型,可传 `PG`、`OP`、`BASIC`。 |
+| `is_persistent` | bool | 否 | Go 零值 `false` | 是否持久化写入点位数据。 |
+| `device_group_id` | int | 否 | Go 零值 `0` | 父设备分组 ID。`0` 表示顶层设备。 |
+| `timeout` | int | 否 | 创建接口请求结构包含该字段,但当前创建逻辑未写入内部配置 | 超时时间,单位秒。 |
+
+#### 请求示例
+
+```json
+{
+  "type": "s7",
+  "name": "s7_1200_1",
+  "ip": "127.0.0.1",
+  "port": 102,
+  "device_type": 1,
+  "rock": 0,
+  "slot": 1,
+  "tsap_conn_type": "PG",
+  "is_persistent": true,
+  "device_group_id": 0,
+  "timeout": 3
+}
+```
+
+#### 成功响应
+
+```json
+{
+  "state": 0,
+  "state_info": "成功"
+}
+```
+
+#### 主要失败响应
+
+| 场景 | 响应 |
+|---|---|
+| JSON 绑定失败、`name` 缺失等 | `{"state":2,"state_info":"无效参数"}` |
+| `ip` 为空 | `{"state":2,"state_info":"IP地址不能为空"}` |
+| `rock` 或 `slot` 为 null/缺失 | `{"state":2,"state_info":"轨道号或槽号不能为空"}` |
+| `name` trim 后为空 | `{"state":2,"state_info":"名称不能为空"}` |
+| 名称或 IP 已存在 | `{"state":101,"state_info":"名称或IP已被占用"}` |
+| 数据库插入失败 | `{"state":2,"state_info":"新增失败"}` |
+
+### 2. 编辑 S7 设备
+
+#### 基本信息
+
+- URL:`/api/collector/s7/device/update`
+- 方法:`POST`
+- 处理函数:`UpdateS7Device`
+- 请求结构:`ReqUpdateS7Device`
+
+#### 重要说明
+
+- 该接口是全量更新语义,未传字段会按 Go 零值写入,例如 `port=0`、`is_persistent=false`、`alarm_interval=0`、`collect_interval=0`。
+- 编辑前设备不能处于已连接或采集状态。若状态不允许编辑,会返回设备需先停止相关错误。
+- 编辑成功后会同步更新内存设备对象和数据库配置。
+
+#### 请求字段
+
+| 字段 | 类型 | 必填 | 默认/行为 | 含义 |
+|---|---|---|---|---|
+| `id` | int | 是 | 绑定时必填 | 要编辑的 S7 设备 ID。 |
+| `name` | string | 是 | 保存前 trim;为空返回 `名称不能为空` | 设备名称。 |
+| `ip` | string | 是 | trim 后为空返回 `IP地址不能为空` | S7 PLC IP 地址。 |
+| `port` | int | 建议传 | 缺失时写入 `0` | 设备端口,常用 `102`。 |
+| `device_type` | int | 建议传 | 缺失时写入 `0` | S7 设备类型。 |
+| `rock` | int/null | 是 | nil 返回 `轨道号或槽号不能为空` | 机架号。 |
+| `slot` | int/null | 是 | nil 返回 `轨道号或槽号不能为空` | 槽号。 |
+| `tsap_conn_type` | string | 否 | 空字符串会保存,底层连接按 PG 默认处理 | TSAP 连接类型。 |
+| `is_persistent` | bool | 否 | 缺失时写入 `false` | 是否持久化。 |
+| `device_group_id` | int | 否 | 缺失时写入 `0` | 父设备分组 ID。 |
+| `alarm_interval` | int | 否 | 缺失时写入 `0` | 告警间隔。 |
+| `collect_interval` | int | 否 | 缺失时写入 `0` | 采集周期。 |
+| `timeout` | int | 否 | 缺失时写入 `0` | 超时时间,单位秒。 |
+
+#### 请求示例
+
+```json
+{
+  "id": 1,
+  "name": "s7_1200_1_edited",
+  "ip": "127.0.0.1",
+  "port": 102,
+  "device_type": 1,
+  "rock": 0,
+  "slot": 1,
+  "tsap_conn_type": "PG",
+  "is_persistent": true,
+  "device_group_id": 0,
+  "alarm_interval": 90,
+  "collect_interval": 5,
+  "timeout": 3
+}
+```
+
+#### 成功响应
+
+```json
+{
+  "state": 0,
+  "state_info": "成功"
+}
+```
+
+#### 主要失败响应
+
+| 场景 | 响应 |
+|---|---|
+| JSON 绑定失败、`id` 缺失等 | `{"state":2,"state_info":"无效参数"}` |
+| `ip` 为空 | `{"state":2,"state_info":"IP地址不能为空"}` |
+| `rock` 或 `slot` 为 null/缺失 | `{"state":2,"state_info":"轨道号或槽号不能为空"}` |
+| `name` trim 后为空 | `{"state":2,"state_info":"名称不能为空"}` |
+| 设备不存在或数据库查询失败 | 通常返回设备不存在或未知错误响应 |
+| 设备名称已存在 | `{"state":2,"state_info":"设备名称已存在"}` |
+| 数据库更新失败 | `{"state":2,"state_info":"编辑失败"}` |
+
+### 3. 创建 S7 采集点位
+
+#### 基本信息
+
+- URL:`/api/collector/s7/point/add`
+- 方法:`POST`
+- 处理函数:`AddS7CollectPoint`
+- 请求结构:`ReqAddS7CollectPoint`
+
+#### 请求字段
+
+| 字段 | 类型 | 必填 | 默认/行为 | 含义 |
+|---|---|---|---|---|
+| `device_id` | int | 是 | 缺失时通常因找不到设备返回 `检测设备ID失败` | 所属 S7 设备 ID。 |
+| `name` | string | 是 | trim 后为空返回 `名称不能为空` | 点位名称。 |
+| `point_id` | string | 否 | 空字符串允许 | 外部点位标识。非空时,同一设备下不能重复。 |
+| `register_type` | int | 是 | 缺失时为 `0`,后续采集校验会失败 | S7 寄存器区域,见枚举。 |
+| `data_type` | string | 是 | 无显式默认值,后续采集校验会检查合法性 | S7 数据类型,见枚举。 |
+| `address` | string | 是 | binding 必填 | S7 地址字符串,见地址格式。 |
+| `scale_ratio` | float64 | 否 | Go 零值 `0`,采集/写值时缩放系数为 0 会导致异常;建议传 `1` | 缩放系数。 |
+| `value_offset` | float64 | 否 | Go 零值 `0` | 值偏移。 |
+| `describe` | string | 否 | 空字符串 | 点位描述。 |
+| `group_Id` | int | 否 | Go 零值 `0` | 点位分组 ID。注意当前请求 JSON 字段名是 `group_Id`。 |
+| `invalid_values` | string | 否 | 空字符串解析为空数组 | 无效值列表,逗号分隔。 |
+| `valid_range_start` | number/null | 否 | `null` | 合法范围最小值。 |
+| `valid_range_end` | number/null | 否 | `null` | 合法范围最大值。 |
+
+#### 请求示例:DB 区 float32
+
+```json
+{
+  "device_id": 1,
+  "name": "db1_real_10",
+  "point_id": "DB1_REAL_10",
+  "register_type": 4,
+  "data_type": "float32",
+  "address": "1.10",
+  "scale_ratio": 1,
+  "value_offset": 0,
+  "describe": "DB1.DBD10 real 示例",
+  "group_Id": 0,
+  "invalid_values": "",
+  "valid_range_start": null,
+  "valid_range_end": null
+}
+```
+
+#### 请求示例:M 区 bool
+
+```json
+{
+  "device_id": 1,
+  "name": "m10_2",
+  "point_id": "M10_2",
+  "register_type": 3,
+  "data_type": "bool",
+  "address": "10.2",
+  "scale_ratio": 1,
+  "value_offset": 0,
+  "group_Id": 0,
+  "invalid_values": "",
+  "valid_range_start": null,
+  "valid_range_end": null
+}
+```
+
+#### 成功响应
+
+```json
+{
+  "state": 0,
+  "state_info": "成功"
+}
+```
+
+#### 主要失败响应
+
+| 场景 | 响应 |
+|---|---|
+| JSON 绑定失败、`address` 缺失等 | `{"state":2,"state_info":"无效参数"}` |
+| `invalid_values` 中存在非数字项 | `{"state":2,"state_info":"无效参数"}` |
+| `name` trim 后为空 | `{"state":2,"state_info":"名称不能为空"}` |
+| 设备不存在或不在内存中 | `{"state":2,"state_info":"检测设备ID失败"}` |
+| 同一设备下 `point_id` 重复 | `{"state":2,"state_info":"point_id 重复, point_id: {point_id}"}` |
+| 数据库插入失败 | `{"state":2,"state_info":"新增失败"}` |
+
+### 4. 编辑 S7 采集点位
+
+#### 基本信息
+
+- URL:`/api/collector/s7/point/update`
+- 方法:`POST`
+- 处理函数:`s7PointEdit`
+- 请求结构:`ReqUpdateS7Point`
+
+#### 重要说明
+
+- 该接口是全量更新语义,除 `id` 外的点位配置应按完整点位对象提交。
+- `id` 是要编辑的采集点位内部 ID,对应查询设备点位列表返回的 `data.point[].id`。
+- 请求结构包含 `device_id`,编辑逻辑不会迁移点位所属设备,但会用请求中的 `device_id` 做重复 `point_id` 校验并刷新内存点位,因此应传原所属设备 ID。
+
+#### 请求字段
+
+| 字段 | 类型 | 必填 | 默认/行为 | 含义 |
+|---|---|---|---|---|
+| `id` | int | 是 | binding 必填 | 要编辑的原采集点位 ID。 |
+| `device_id` | int | 建议必填 | 缺失时可能导致重复校验和内存刷新异常 | 所属 S7 设备 ID。 |
+| `name` | string | 是 | trim 后为空返回 `名称不能为空` | 点位名称。 |
+| `point_id` | string | 否 | 空字符串允许 | 外部点位标识。 |
+| `register_type` | int | 是 | 缺失时写入 `0` | S7 寄存器区域。 |
+| `data_type` | string | 是 | 缺失时写入空字符串 | S7 数据类型。 |
+| `address` | string | 是 | 缺失时绑定失败 | S7 地址字符串。 |
+| `scale_ratio` | float64 | 否 | 缺失时写入 `0`,建议传 `1` | 缩放系数。 |
+| `value_offset` | float64 | 否 | 缺失时写入 `0` | 值偏移。 |
+| `describe` | string | 否 | 空字符串 | 点位描述。 |
+| `group_Id` | int | 否 | 缺失时写入 `0` | 点位分组 ID。 |
+| `invalid_values` | string | 否 | 空字符串解析为空数组 | 无效值列表。 |
+| `valid_range_start` | number/null | 否 | `null` | 合法范围最小值。 |
+| `valid_range_end` | number/null | 否 | `null` | 合法范围最大值。 |
+
+#### 请求示例
+
+```json
+{
+  "id": 101,
+  "device_id": 1,
+  "name": "db1_real_10_edited",
+  "point_id": "DB1_REAL_10_EDITED",
+  "register_type": 4,
+  "data_type": "float32",
+  "address": "1.10",
+  "scale_ratio": 1,
+  "value_offset": 0,
+  "describe": "编辑后的 DB1.DBD10 real 示例",
+  "group_Id": 0,
+  "invalid_values": "",
+  "valid_range_start": null,
+  "valid_range_end": null
+}
+```
+
+#### 成功响应
+
+```json
+{
+  "state": 0,
+  "state_info": "成功"
+}
+```
+
+#### 主要失败响应
+
+| 场景 | 响应 |
+|---|---|
+| JSON 绑定失败、`id` 缺失、`address` 缺失等 | `{"state":2,"state_info":"无效参数"}` |
+| `invalid_values` 中存在非数字项 | `{"state":2,"state_info":"无效参数"}` |
+| `name` trim 后为空 | `{"state":2,"state_info":"名称不能为空"}` |
+| 原点位查询失败 | `{"state":2,"state_info":"查询点位失败"}` |
+| 原点位不存在 | `{"state":2,"state_info":"当前设备不存在该点位"}` |
+| 同一设备下 `point_id` 重复 | `{"state":2,"state_info":"point_id 重复, point_id: {point_id}"}` |
+| 数据库更新失败 | `{"state":2,"state_info":"点位更新失败"}` |
+
 ## 连接/断开设备接口
 ## 连接/断开设备接口
 
 
 ### 基本信息
 ### 基本信息

+ 2 - 1
pyproject.toml

@@ -7,13 +7,14 @@ requires-python = ">=3.11,<3.14"
 dependencies = [
 dependencies = [
   "fastmcp>=2.0.0",
   "fastmcp>=2.0.0",
   "psycopg2-binary>=2.9.11",
   "psycopg2-binary>=2.9.11",
+  "pytest>=9.1.1",
   "requests>=2.31.0",
   "requests>=2.31.0",
   "sqlalchemy>=2.0.0",
   "sqlalchemy>=2.0.0",
   "uvicorn>=0.30.0",
   "uvicorn>=0.30.0",
 ]
 ]
 
 
 [project.scripts]
 [project.scripts]
-data-collector-mcp = "data_collector_mcp.server:main"
+data-collector-mcp = "data_collector_mcp.app:main"
 
 
 [build-system]
 [build-system]
 requires = ["setuptools>=68", "wheel"]
 requires = ["setuptools>=68", "wheel"]

+ 128 - 0
tests/test_collector_api.py

@@ -423,6 +423,134 @@ class CollectorApiTests(unittest.TestCase):
                 },
                 },
             )
             )
 
 
+    def test_create_s7_device_merges_defaults_and_posts_to_collector(self) -> None:
+        self._patch_project()
+        response = {"state": 0, "state_info": "成功"}
+        with patch(
+            "data_collector_mcp.collector_api.request_json",
+            return_value=response,
+        ) as request_json:
+            result = collector_api.create_s7_device(
+                "dev-01",
+                {
+                    "name": "s7_1200_1",
+                    "ip": "127.0.0.1",
+                    "rock": 0,
+                    "slot": 1,
+                    "group_id": 10,
+                },
+            )
+
+        self.assertEqual(result, response)
+        self.assertEqual(
+            request_json.call_args.args[:3],
+            (
+                "POST",
+                "http://collector.test/api/collector/device",
+                "token",
+            ),
+        )
+        payload = request_json.call_args.kwargs["json_payload"]
+        self.assertEqual(payload["type"], "s7")
+        self.assertEqual(payload["device_type"], 1)
+        self.assertEqual(payload["port"], 102)
+        self.assertEqual(payload["tsap_conn_type"], "PG")
+        self.assertEqual(payload["device_group_id"], 10)
+        self.assertEqual(payload["rock"], 0)
+        self.assertEqual(payload["slot"], 1)
+        self.assertNotIn("group_id", payload)
+
+    def test_edit_s7_device_maps_ori_id_and_posts_to_update_endpoint(self) -> None:
+        self._patch_project()
+        with patch(
+            "data_collector_mcp.collector_api.request_json",
+            return_value={"state": 0},
+        ) as request_json:
+            collector_api.edit_s7_device(
+                "dev-01",
+                {
+                    "ori_id": 3,
+                    "name": "s7_edited",
+                    "ip": "127.0.0.1",
+                    "rock": 0,
+                    "slot": 2,
+                    "tsap_conn_type": "op",
+                },
+            )
+
+        self.assertEqual(request_json.call_args.args[1], "http://collector.test/api/collector/s7/device/update")
+        payload = request_json.call_args.kwargs["json_payload"]
+        self.assertEqual(payload["id"], 3)
+        self.assertEqual(payload["tsap_conn_type"], "OP")
+        self.assertNotIn("ori_id", payload)
+
+    def test_create_s7_point_normalizes_aliases_and_posts_to_collector(self) -> None:
+        self._patch_project()
+        with patch(
+            "data_collector_mcp.collector_api.request_json",
+            return_value={"state": 0},
+        ) as request_json:
+            collector_api.create_s7_point(
+                "dev-01",
+                {
+                    "device_id": 3,
+                    "name": "db_real",
+                    "point_id": "DB_REAL",
+                    "register_area": "DB",
+                    "address": "1.10",
+                    "type": "REAL",
+                    "group_id": 5,
+                },
+            )
+
+        self.assertEqual(request_json.call_args.args[1], "http://collector.test/api/collector/s7/point/add")
+        payload = request_json.call_args.kwargs["json_payload"]
+        self.assertEqual(payload["register_type"], 4)
+        self.assertEqual(payload["data_type"], "float32")
+        self.assertEqual(payload["group_Id"], 5)
+        self.assertEqual(payload["scale_ratio"], 1)
+        self.assertNotIn("type", payload)
+        self.assertNotIn("register_area", payload)
+        self.assertNotIn("group_id", payload)
+
+    def test_edit_s7_point_maps_ori_id_and_requires_device_id(self) -> None:
+        self._patch_project()
+        with patch(
+            "data_collector_mcp.collector_api.request_json",
+            return_value={"state": 0},
+        ) as request_json:
+            collector_api.edit_s7_point(
+                "dev-01",
+                {
+                    "ori_id": 101,
+                    "device_id": 3,
+                    "name": "m_bool",
+                    "register_type": 3,
+                    "address": "10.2",
+                    "data_type": "BOOL",
+                },
+            )
+
+        self.assertEqual(request_json.call_args.args[1], "http://collector.test/api/collector/s7/point/update")
+        payload = request_json.call_args.kwargs["json_payload"]
+        self.assertEqual(payload["id"], 101)
+        self.assertEqual(payload["data_type"], "bool")
+        self.assertEqual(payload["register_type"], 3)
+        self.assertEqual(payload["group_Id"], 0)
+        self.assertNotIn("ori_id", payload)
+
+        with self.assertRaisesRegex(ValueError, "payload.device_id is required"):
+            collector_api.edit_s7_point(
+                "dev-01",
+                {
+                    "ori_id": 101,
+                    "name": "m_bool",
+                    "register_type": 3,
+                    "address": "10.2",
+                    "data_type": "bool",
+                },
+            )
+
     def test_list_devices_defaults_num_points_false(self) -> None:
     def test_list_devices_defaults_num_points_false(self) -> None:
         self._patch_project()
         self._patch_project()
         response = {"state": 0, "devices": []}
         response = {"state": 0, "devices": []}

+ 84 - 0
tests/test_gateway_api.py

@@ -61,6 +61,90 @@ class GatewayApiTests(unittest.TestCase):
         self.assertEqual(payload["word_byte_order"], "DCBA")
         self.assertEqual(payload["word_byte_order"], "DCBA")
         self.assertEqual(payload["address_base"], 1)
         self.assertEqual(payload["address_base"], 1)
 
 
+    def test_s7_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.s7_raw_read(
+                "dev-01",
+                ip="192.168.1.10",
+                rock=0,
+                slot=1,
+                read={"area": "DB", "db": 1, "start": 0, "size": 4},
+            )
+
+        self.assertEqual(result, response)
+        request_json.assert_called_once_with(
+            "POST",
+            "http://gateway.test/api/dc-gateway/s7/read",
+            json_payload={
+                "device_type": "S7TCP",
+                "ip": "192.168.1.10",
+                "port": 102,
+                "rock": 0,
+                "slot": 1,
+                "tsap_conn_type": "PG",
+                "read": {"area": "DB", "db": 1, "start": 0, "size": 4},
+            },
+        )
+
+    def test_s7_point_collect_test_posts_gateway_payload(self) -> None:
+        response = {"code": 0, "msg": "success", "data": {"points": []}}
+        with patch(
+            "data_collector_mcp.gateway_api.find_project_config",
+            return_value={"project_key": "dev-01", "base_url": "http://gateway.test"},
+        ), patch(
+            "data_collector_mcp.gateway_api.request_json",
+            return_value=response,
+        ) as request_json:
+            result = gateway_api.s7_point_collect_test(
+                "dev-01",
+                ip="192.168.1.10",
+                port=1102,
+                rock=0,
+                slot=1,
+                tsap_conn_type="OP",
+                points=[{"area": "DB", "db": 1, "start": 0, "type": "float32"}],
+            )
+
+        self.assertEqual(result, response)
+        request_json.assert_called_once_with(
+            "POST",
+            "http://gateway.test/api/dc-gateway/s7/read_points",
+            json_payload={
+                "device_type": "S7TCP",
+                "ip": "192.168.1.10",
+                "port": 1102,
+                "rock": 0,
+                "slot": 1,
+                "tsap_conn_type": "OP",
+                "points": [{"area": "DB", "db": 1, "start": 0, "type": "float32"}],
+            },
+        )
+
+    def test_s7_connect_scan_posts_ip_only(self) -> None:
+        response = {"code": 0, "msg": "success", "data": {"available": []}}
+        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.s7_connect_scan("dev-01", ip="192.168.1.10")
+
+        self.assertEqual(result, response)
+        request_json.assert_called_once_with(
+            "POST",
+            "http://gateway.test/api/dc-gateway/s7/connect_scan",
+            json_payload={"ip": "192.168.1.10"},
+        )
+
 
 
 if __name__ == "__main__":
 if __name__ == "__main__":
     unittest.main()
     unittest.main()

+ 302 - 9
tests/test_server_tools.py

@@ -1,19 +1,173 @@
 from __future__ import annotations
 from __future__ import annotations
 
 
+import asyncio
 import inspect
 import inspect
 import unittest
 import unittest
 from unittest.mock import patch
 from unittest.mock import patch
 
 
-from data_collector_mcp import server
+from data_collector_mcp import app, common_server, modbus_server, s7_server
 
 
 
 
 class ServerToolTests(unittest.TestCase):
 class ServerToolTests(unittest.TestCase):
+    def test_app_imports_tool_modules_for_registration(self) -> None:
+        self.assertIs(app.common_server, common_server)
+        self.assertIs(app.modbus_server, modbus_server)
+        self.assertIs(app.s7_server, s7_server)
+
+    def test_app_registers_expected_mcp_tools(self) -> None:
+        tools = asyncio.run(app.mcp.list_tools())
+        tool_names = {tool.name for tool in tools}
+
+        self.assertEqual(
+            tool_names,
+            {
+                "project.list",
+                "collector.device_list",
+                "collector.device_connect",
+                "collector.device_disconnect",
+                "collector.device_points",
+                "modbus.point_collect_test",
+                "s7.raw_read",
+                "s7.point_collect_test",
+                "s7.connect_scan",
+                "collector.modbus_device_create",
+                "collector.modbus_device_edit",
+                "collector.modbus_point_create",
+                "collector.modbus_point_edit",
+                "collector.s7_device_create",
+                "collector.s7_device_edit",
+                "collector.s7_point_create",
+                "collector.s7_point_edit",
+            },
+        )
+
+    def test_project_list_filters_enabled_projects_and_sorts(self) -> None:
+        with patch(
+            "data_collector_mcp.common_server.load_projects_config",
+            return_value=[
+                {
+                    "project_key": "z-prod",
+                    "project_name": "Prod",
+                    "base_url": "http://gateway.prod",
+                    "data_collector_base_url": "http://collector.prod",
+                    "enabled": True,
+                },
+                {
+                    "project_key": "disabled",
+                    "project_name": "Disabled",
+                    "base_url": "http://gateway.disabled",
+                    "data_collector_base_url": "http://collector.disabled",
+                    "enabled": False,
+                },
+                {
+                    "project_key": "a-dev",
+                    "project_name": "Dev",
+                    "base_url": "http://gateway.dev",
+                    "data_collector_base_url": "http://collector.dev",
+                    "enabled": True,
+                },
+            ],
+        ):
+            result = common_server.project_list()
+
+        self.assertEqual(
+            result,
+            {
+                "projects": [
+                    {
+                        "project_key": "a-dev",
+                        "project_name": "Dev",
+                        "base_url": "http://gateway.dev",
+                        "data_collector_base_url": "http://collector.dev",
+                    },
+                    {
+                        "project_key": "z-prod",
+                        "project_name": "Prod",
+                        "base_url": "http://gateway.prod",
+                        "data_collector_base_url": "http://collector.prod",
+                    },
+                ],
+                "total": 2,
+            },
+        )
+
+    def test_common_device_tools_forward_to_api(self) -> None:
+        with patch("data_collector_mcp.common_server.api_list_devices", return_value={"state": 0}) as list_devices:
+            self.assertEqual(common_server.collector_device_list("dev-01", num_points=True), {"state": 0})
+        list_devices.assert_called_once_with("dev-01", num_points=True)
+
+        with patch("data_collector_mcp.common_server.api_connect_device", return_value={"state": 0}) as connect:
+            self.assertEqual(
+                common_server.collector_device_connect("dev-01", device_id=1, device_type="s7"),
+                {"state": 0},
+            )
+        connect.assert_called_once_with("dev-01", device_id=1, device_type="s7")
+
+        with patch("data_collector_mcp.common_server.api_disconnect_device", return_value={"state": 0}) as disconnect:
+            self.assertEqual(
+                common_server.collector_device_disconnect("dev-01", device_id=1, device_type="modbus"),
+                {"state": 0},
+            )
+        disconnect.assert_called_once_with("dev-01", device_id=1, device_type="modbus")
+
+        with patch("data_collector_mcp.common_server.api_list_device_points", return_value={"state": 0}) as points:
+            self.assertEqual(
+                common_server.collector_device_points("dev-01", device_id=1, device_type="s7", group_id=2),
+                {"state": 0},
+            )
+        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:
+        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.assertEqual(result, {"state": 0})
+        api_create.assert_called_once_with(
+            "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,
+                "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,
+            },
+        )
+
     def test_modbus_device_edit_uses_explicit_parameters(self) -> None:
     def test_modbus_device_edit_uses_explicit_parameters(self) -> None:
-        signature = inspect.signature(server.collector_modbus_device_edit)
+        signature = inspect.signature(modbus_server.collector_modbus_device_edit)
         self.assertNotIn("payload", signature.parameters)
         self.assertNotIn("payload", signature.parameters)
 
 
-        with patch("data_collector_mcp.server.api_edit_modbus_device", return_value={"state": 0}) as api_edit:
-            result = server.collector_modbus_device_edit(
+        with patch("data_collector_mcp.modbus_server.api_edit_modbus_device", return_value={"state": 0}) as api_edit:
+            result = modbus_server.collector_modbus_device_edit(
                 project_key="dev-01",
                 project_key="dev-01",
                 ori_id=1,
                 ori_id=1,
                 name="modbus_tcp_edited",
                 name="modbus_tcp_edited",
@@ -56,11 +210,11 @@ class ServerToolTests(unittest.TestCase):
         )
         )
 
 
     def test_modbus_point_edit_uses_explicit_parameters_with_func_code(self) -> None:
     def test_modbus_point_edit_uses_explicit_parameters_with_func_code(self) -> None:
-        signature = inspect.signature(server.collector_modbus_point_edit)
+        signature = inspect.signature(modbus_server.collector_modbus_point_edit)
         self.assertNotIn("payload", signature.parameters)
         self.assertNotIn("payload", signature.parameters)
 
 
-        with patch("data_collector_mcp.server.api_edit_modbus_point", return_value={"state": 0}) as api_edit:
-            result = server.collector_modbus_point_edit(
+        with patch("data_collector_mcp.modbus_server.api_edit_modbus_point", return_value={"state": 0}) as api_edit:
+            result = modbus_server.collector_modbus_point_edit(
                 project_key="dev-01",
                 project_key="dev-01",
                 ori_id=101,
                 ori_id=101,
                 name="holding_register_uint16_edited",
                 name="holding_register_uint16_edited",
@@ -92,8 +246,8 @@ class ServerToolTests(unittest.TestCase):
         )
         )
 
 
     def test_modbus_point_edit_uses_register_type_when_func_code_is_zero(self) -> None:
     def test_modbus_point_edit_uses_register_type_when_func_code_is_zero(self) -> None:
-        with patch("data_collector_mcp.server.api_edit_modbus_point", return_value={"state": 0}) as api_edit:
-            server.collector_modbus_point_edit(
+        with patch("data_collector_mcp.modbus_server.api_edit_modbus_point", return_value={"state": 0}) as api_edit:
+            modbus_server.collector_modbus_point_edit(
                 project_key="dev-01",
                 project_key="dev-01",
                 ori_id=101,
                 ori_id=101,
                 name="holding_register_uint16_edited",
                 name="holding_register_uint16_edited",
@@ -106,6 +260,145 @@ class ServerToolTests(unittest.TestCase):
         self.assertEqual(payload["register_type"], "holding_register")
         self.assertEqual(payload["register_type"], "holding_register")
         self.assertNotIn("func_code", payload)
         self.assertNotIn("func_code", payload)
 
 
+    def test_s7_device_create_uses_explicit_parameters(self) -> None:
+        signature = inspect.signature(s7_server.collector_s7_device_create)
+        self.assertNotIn("payload", signature.parameters)
+
+        with patch("data_collector_mcp.s7_server.api_create_s7_device", 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",
+            )
+
+        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,
+            },
+        )
+
+    def test_s7_device_edit_uses_explicit_parameters(self) -> None:
+        signature = inspect.signature(s7_server.collector_s7_device_edit)
+        self.assertNotIn("payload", signature.parameters)
+
+        with patch("data_collector_mcp.s7_server.api_edit_s7_device", return_value={"state": 0}) as api_edit:
+            result = s7_server.collector_s7_device_edit(
+                project_key="dev-01",
+                ori_id=3,
+                name="s7_edited",
+                ip="127.0.0.1",
+                rock=0,
+                slot=1,
+                tsap_conn_type="OP",
+            )
+
+        self.assertEqual(result, {"state": 0})
+        api_edit.assert_called_once_with(
+            "dev-01",
+            {
+                "ori_id": 3,
+                "name": "s7_edited",
+                "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,
+            },
+        )
+
+    def test_s7_point_edit_uses_register_area_when_register_type_is_zero(self) -> None:
+        signature = inspect.signature(s7_server.collector_s7_point_edit)
+        self.assertNotIn("payload", signature.parameters)
+
+        with patch("data_collector_mcp.s7_server.api_edit_s7_point", return_value={"state": 0}) as api_edit:
+            s7_server.collector_s7_point_edit(
+                project_key="dev-01",
+                ori_id=101,
+                device_id=3,
+                name="db_real",
+                address="1.10",
+                data_type="float32",
+                register_area="DB",
+                point_id="DB_REAL",
+            )
+
+        payload = api_edit.call_args.args[1]
+        self.assertEqual(payload["register_area"], "DB")
+        self.assertNotIn("register_type", payload)
+
+    def test_s7_gateway_tools_forward_to_api(self) -> None:
+        with patch("data_collector_mcp.s7_server.api_s7_raw_read", return_value={"code": 0}) as raw_read:
+            self.assertEqual(
+                s7_server.s7_raw_read(
+                    project_key="dev-01",
+                    ip="192.168.1.10",
+                    rock=0,
+                    slot=1,
+                    read={"area": "DB", "db": 1, "start": 0, "size": 4},
+                ),
+                {"code": 0},
+            )
+        raw_read.assert_called_once_with(
+            "dev-01",
+            ip="192.168.1.10",
+            rock=0,
+            slot=1,
+            read={"area": "DB", "db": 1, "start": 0, "size": 4},
+            device_type="S7TCP",
+            port=102,
+            tsap_conn_type="PG",
+        )
+
+        with patch("data_collector_mcp.s7_server.api_s7_point_collect_test", return_value={"code": 0}) as point_test:
+            self.assertEqual(
+                s7_server.s7_point_collect_test(
+                    project_key="dev-01",
+                    ip="192.168.1.10",
+                    port=1102,
+                    rock=0,
+                    slot=1,
+                    tsap_conn_type="BASIC",
+                    points=[{"area": "M", "start": 0, "type": "bool"}],
+                ),
+                {"code": 0},
+            )
+        point_test.assert_called_once_with(
+            "dev-01",
+            ip="192.168.1.10",
+            rock=0,
+            slot=1,
+            points=[{"area": "M", "start": 0, "type": "bool"}],
+            device_type="S7TCP",
+            port=1102,
+            tsap_conn_type="BASIC",
+        )
+
+        with patch("data_collector_mcp.s7_server.api_s7_connect_scan", return_value={"code": 0}) as connect_scan:
+            self.assertEqual(s7_server.s7_connect_scan("dev-01", ip="192.168.1.10"), {"code": 0})
+        connect_scan.assert_called_once_with("dev-01", ip="192.168.1.10")
+
 
 
 if __name__ == "__main__":
 if __name__ == "__main__":
     unittest.main()
     unittest.main()

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 366 - 346
uv.lock


Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio