Browse Source

新增modbus编辑设备和点位接口

Lu Xianghui 2 days ago
parent
commit
c2f6620fac

+ 158 - 0
data_collector_mcp/collector_api.py

@@ -44,6 +44,42 @@ def _normalize_modbus_device_payload(payload: dict[str, Any]) -> dict[str, Any]:
     return normalized
 
 
+def _normalize_modbus_device_edit_payload(payload: dict[str, Any]) -> dict[str, Any]:
+    normalized = dict(payload)
+
+    normalized["ori_id"] = _normalize_positive_int(_require_present(normalized, "ori_id"), "payload.ori_id")
+    normalized["name"] = _require_non_empty_text(normalized, "name")
+
+    if normalized.get("type") is None:
+        normalized["type"] = _require_present(normalized, "device_type")
+    try:
+        normalized["type"] = int(normalized["type"])
+    except Exception as exc:
+        raise ValueError("payload.type must be one of 1, 2, 3, 4, 5") from exc
+    if normalized["type"] not in {1, 2, 3, 4, 5}:
+        raise ValueError("payload.type must be one of 1, 2, 3, 4, 5")
+    normalized.pop("device_type", None)
+
+    if normalized["type"] == 2:
+        normalized["serial_port"] = _require_non_empty_text(normalized, "serial_port")
+    else:
+        normalized["ip"] = _require_non_empty_text(normalized, "ip")
+        _require_present(normalized, "port")
+
+    _require_present(normalized, "slave_id")
+    _require_present(normalized, "word_order")
+    _require_present(normalized, "byte_order")
+
+    if "address_base" in normalized:
+        normalized["address_offset"] = normalized["address_base"]
+        normalized.pop("address_base", None)
+    if "group_id" in normalized and "device_group_id" not in normalized:
+        normalized["device_group_id"] = normalized["group_id"]
+        normalized.pop("group_id", None)
+
+    return normalized
+
+
 def _normalize_modbus_point_payload(payload: dict[str, Any]) -> dict[str, Any]:
     normalized = dict(payload)
 
@@ -87,6 +123,13 @@ def _normalize_modbus_point_payload(payload: dict[str, Any]) -> dict[str, Any]:
     return normalized
 
 
+def _normalize_modbus_point_edit_payload(payload: dict[str, Any]) -> dict[str, Any]:
+    _require_present(payload, "ori_id")
+    normalized = _normalize_modbus_point_payload(payload)
+    normalized["ori_id"] = _normalize_positive_int(_require_present(normalized, "ori_id"), "payload.ori_id")
+    return normalized
+
+
 def _request_collector(
     project_key: str,
     method: str,
@@ -131,6 +174,39 @@ def create_modbus_point(project_key: str, payload: dict[str, Any]) -> dict[str,
     )
 
 
+def edit_modbus_device(project_key: str, payload: dict[str, Any]) -> dict[str, Any]:
+    return _request_collector(
+        project_key,
+        "POST",
+        "/api/collector/modbus/device/edit",
+        json_payload=_merge_defaults(
+            {
+                "timeout": 3,
+                "is_persistent": True,
+                "device_group_id": 0,
+                "alarm_interval": 90,
+                "collect_interval": 5,
+                "address_offset": 0,
+                "retry_times": 0,
+                "mode": 0,
+            },
+            _normalize_modbus_device_edit_payload(payload),
+        ),
+    )
+
+
+def edit_modbus_point(project_key: str, payload: dict[str, Any]) -> dict[str, Any]:
+    return _request_collector(
+        project_key,
+        "POST",
+        "/api/collector/modbus/point/edit_collect_point",
+        json_payload=_merge_defaults(
+            MODBUS_SPEC.point_defaults,
+            _normalize_modbus_point_edit_payload(payload),
+        ),
+    )
+
+
 def list_devices(project_key: str, num_points: bool = False) -> dict[str, Any]:
     num_points_text = "true" if num_points else "false"
     return _request_collector(
@@ -138,3 +214,85 @@ def list_devices(project_key: str, num_points: bool = False) -> dict[str, Any]:
         "GET",
         f"/api/collector/device?num_points={num_points_text}",
     )
+
+
+def connect_device(project_key: str, device_id: int, device_type: str = "modbus") -> dict[str, Any]:
+    return _set_device_connect_status(
+        project_key,
+        device_id=device_id,
+        device_type=device_type,
+        status=2,
+    )
+
+
+def disconnect_device(project_key: str, device_id: int, device_type: str = "modbus") -> dict[str, Any]:
+    return _set_device_connect_status(
+        project_key,
+        device_id=device_id,
+        device_type=device_type,
+        status=1,
+    )
+
+
+def list_device_points(
+    project_key: str,
+    device_id: int,
+    device_type: str = "modbus",
+    group_id: int = 0,
+) -> dict[str, Any]:
+    return _request_collector(
+        project_key,
+        "POST",
+        "/api/collector/common/device/get_collect_point",
+        json_payload={
+            "id": _normalize_positive_int(device_id, "device_id"),
+            "type": _normalize_device_type(device_type),
+            "group_id": _normalize_non_negative_int(group_id, "group_id"),
+        },
+    )
+
+
+def _set_device_connect_status(
+    project_key: str,
+    *,
+    device_id: int,
+    device_type: str,
+    status: int,
+) -> dict[str, Any]:
+    return _request_collector(
+        project_key,
+        "POST",
+        "/api/collector/common/device/set_connect_status",
+        json_payload={
+            "id": _normalize_positive_int(device_id, "device_id"),
+            "type": _normalize_device_type(device_type),
+            "status": status,
+        },
+    )
+
+
+def _normalize_device_type(device_type: str) -> str:
+    normalized = str(device_type or "").strip()
+    if not normalized:
+        raise ValueError("device_type is required")
+    return normalized
+
+
+def _normalize_positive_int(value: int, field_name: str) -> int:
+    try:
+        normalized = int(value)
+    except Exception as exc:
+        raise ValueError(f"{field_name} must be a positive integer") from exc
+    if normalized <= 0:
+        raise ValueError(f"{field_name} must be a positive integer")
+    return normalized
+
+
+def _normalize_non_negative_int(value: int, field_name: str) -> int:
+    try:
+        normalized = int(value)
+    except Exception as exc:
+        raise ValueError(f"{field_name} must be a non-negative integer") from exc
+    if normalized < 0:
+        raise ValueError(f"{field_name} must be a non-negative integer")
+    return normalized

+ 7 - 1
data_collector_mcp/db.py

@@ -3,7 +3,7 @@ from __future__ import annotations
 import os
 from typing import Any
 
-from sqlalchemy import Integer, JSON, String, create_engine, select
+from sqlalchemy import Integer, JSON, String, create_engine, select, text
 from sqlalchemy.engine import Engine
 from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
 
@@ -50,6 +50,12 @@ def sql_engine() -> Engine:
     return _ENGINE
 
 
+def check_database_connection() -> None:
+    engine = sql_engine()
+    with engine.connect() as connection:
+        connection.execute(text("SELECT 1"))
+
+
 def read_sys_config_value(config_key: str) -> Any | None:
     try:
         engine = sql_engine()

+ 1 - 0
data_collector_mcp/protocols/modbus.py

@@ -79,6 +79,7 @@ MODBUS_SPEC = ProtocolSpec(
         "retry_times": 0,
     },
     point_defaults={
+        "point_id": "",
         "scale_ratio": 1,
         "value_offset": 0,
         "group_id": 0,

+ 222 - 1
data_collector_mcp/server.py

@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import logging
 import os
 from typing import Any
 
@@ -8,10 +9,16 @@ from fastmcp import FastMCP
 
 from .auth import load_projects_config
 from .collector_api import (
+    connect_device as api_connect_device,
     create_modbus_device as api_create_modbus_device,
     create_modbus_point as api_create_modbus_point,
+    disconnect_device as api_disconnect_device,
+    edit_modbus_device as api_edit_modbus_device,
+    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
 
 
@@ -25,6 +32,7 @@ SERVER_INSTRUCTIONS = (
 
 
 mcp = FastMCP("data-collector-mcp", instructions=SERVER_INSTRUCTIONS)
+logger = logging.getLogger(__name__)
 
 
 @mcp.tool(
@@ -105,6 +113,76 @@ def collector_modbus_device_create(
     return api_create_modbus_device(project_key, payload)
 
 
+@mcp.tool(
+    name="collector.modbus_device_edit",
+    description=(
+        "汇采-编辑 Modbus 设备。调用 "
+        "{data_collector_base_url}/api/collector/modbus/device/edit。"
+        "这是 Modbus 专用旧编辑接口,必须传 ori_id 原设备 id、name、slave_id、"
+        "word_order、byte_order、device_type 连接类型。TCP/UDP 类设备还必须传 ip 和 port;"
+        "RTU 设备必须传 serial_port。"
+        "编辑前设备不能处于已连接状态;若已连接,请先调用 collector.device_disconnect。"
+        "该接口是全量更新语义,未传字段可能被默认值覆盖。"
+        "默认参数: ip='', port=0, serial_port='', timeout=3, is_persistent=true, "
+        "baud_rate=0, data_bit=0, parity=0, stop_bit=0, mode=0, address_offset=0, "
+        "retry_times=0, device_group_id=0, alarm_interval=90, collect_interval=5。"
+        "连接类型: 1=TCP, 2=RTU, 3=UDP, 4=RTU OVER TCP, 5=RTU OVER UDP。"
+        "byte_order: 1=Big Endian, 2=Small Endian;word_order: 1=Big Endian, 2=Small Endian。"
+        "响应透传上游 JSON,state=0 表示业务成功。"
+    ),
+)
+def collector_modbus_device_edit(
+    project_key: str,
+    ori_id: int,
+    name: str,
+    device_type: int,
+    slave_id: int,
+    byte_order: int,
+    word_order: int,
+    ip: str = "",
+    port: int = 0,
+    serial_port: str = "",
+    timeout: int = 3,
+    is_persistent: bool = True,
+    baud_rate: int = 0,
+    data_bit: int = 0,
+    parity: int = 0,
+    stop_bit: int = 0,
+    mode: int = 0,
+    address_offset: int = 0,
+    retry_times: int = 0,
+    device_group_id: int = 0,
+    alarm_interval: int = 90,
+    collect_interval: int = 5,
+) -> dict[str, Any]:
+    return api_edit_modbus_device(
+        project_key,
+        {
+            "ori_id": ori_id,
+            "name": name,
+            "device_type": device_type,
+            "ip": ip,
+            "port": port,
+            "slave_id": slave_id,
+            "byte_order": byte_order,
+            "word_order": word_order,
+            "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,
+            "address_offset": address_offset,
+            "retry_times": retry_times,
+            "device_group_id": device_group_id,
+            "alarm_interval": alarm_interval,
+            "collect_interval": collect_interval,
+        },
+    )
+
+
 @mcp.tool(
     name="collector.modbus_point_create",
     description=(
@@ -112,7 +190,7 @@ def collector_modbus_device_create(
         "{data_collector_base_url}/api/collector/modbus/point/add_collect_point。"
         "创建点位必须传 payload.name 名称、payload.address 寄存器地址、"
         "payload.type 数据类型,以及 payload.func_code 或 payload.register_type 寄存器类型。"
-        "payload 会补齐默认值: scale_ratio=1, value_offset=0, group_id=0, "
+        "payload 会补齐默认值: point_id='', scale_ratio=1, value_offset=0, group_id=0, "
         "invalid_values='', valid_range_start=null, valid_range_end=null, bit=0。"
         "func_code: 1=Read Coils/线圈,2=Read Discrete Inputs/离散输入,"
         "3=Read Holding Registers/保持寄存器,4=Read Input Registers/输入寄存器。"
@@ -130,10 +208,74 @@ def collector_modbus_point_create(
     return api_create_modbus_point(project_key, payload)
 
 
+@mcp.tool(
+    name="collector.modbus_point_edit",
+    description=(
+        "汇采-编辑 Modbus 采集点位。调用 "
+        "{data_collector_base_url}/api/collector/modbus/point/edit_collect_point。"
+        "必须传 ori_id 原点位 id、name 名称、address 寄存器地址、data_type 数据类型,"
+        "以及 func_code 或 register_type 寄存器类型。"
+        "ori_id 对应 collector.device_points 返回的 data.point[].id。"
+        "编辑点位不会迁移所属设备。"
+        "该接口是全量更新语义,未传字段可能被默认值覆盖。"
+        "默认参数: point_id='', scale_ratio=1, value_offset=0, group_id=0, "
+        "invalid_values='', valid_range_start=null, valid_range_end=null, bit=0。"
+        "func_code: 1=Read Coils/线圈,2=Read Discrete Inputs/离散输入,"
+        "3=Read Holding Registers/保持寄存器,4=Read Input Registers/输入寄存器。"
+        "register_type 可用 coil、discrete_input、holding_register、input_register。"
+        "数据类型应使用汇采类型: bool, int16, uint16, int32, uint32, int64, uint64, float32, float64。"
+        "常见点表类型映射: BOOL=>bool, SHORT=>int16, WORD=>uint16, LONG=>int32, "
+        "DWORD=>uint32, FLOAT/REAL=>float32, DOUBLE=>float64, LONGLONG=>int64, QWORD=>uint64。"
+        "响应透传上游 JSON,state=0 表示业务成功。"
+    ),
+)
+def collector_modbus_point_edit(
+    project_key: str,
+    ori_id: int,
+    name: str,
+    address: int,
+    data_type: str,
+    func_code: int = 0,
+    register_type: str = "",
+    point_id: str = "",
+    scale_ratio: float = 1,
+    value_offset: float = 0,
+    group_id: int = 0,
+    invalid_values: str = "",
+    valid_range_start: float | None = None,
+    valid_range_end: float | None = None,
+    bit: int = 0,
+    describe: str = "",
+) -> dict[str, Any]:
+    payload: dict[str, Any] = {
+        "ori_id": ori_id,
+        "name": name,
+        "address": address,
+        "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,
+        "bit": bit,
+        "describe": describe,
+    }
+    if func_code:
+        payload["func_code"] = func_code
+    else:
+        payload["register_type"] = register_type
+    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 表示业务成功。"
     ),
@@ -142,12 +284,91 @@ def collector_device_list(project_key: str, num_points: bool = False) -> dict[st
     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"

+ 523 - 21
docs/接口汇总.md

@@ -640,11 +640,56 @@ http://192.168.75.110:32080
 - 示例
 
 ```
-header = {
-	"Authorization": "TOKEN"
-}
+	header = {
+		"Authorization": "TOKEN"
+	}
 ```
 
+### 通用状态枚举
+
+以下状态枚举适用于汇采内的所有设备类型,不限于 Modbus。创建设备和创建点位时接口会写入初始状态,调用方通常不需要传;查询设备列表、连接设备、查询点位列表等接口会返回这些状态字段。
+
+设备 `status` 表示设备连接状态:
+
+| 值 | 英文状态 | 含义 | 典型场景 |
+|---:|---|---|---|
+| `1` | `disconnected` | 未连接 | 设备未执行连接、已断开,或连接失败后回到未连接状态。 |
+| `2` | `connected` | 已连接 | 设备连接成功。注意这不等同于正在采集。 |
+| `3` | `error` | 连接异常 | 设备连接过程或连接状态异常。 |
+
+设备 `running_status` 表示采集状态,不表示 TCP/串口等底层连接是否成功:
+
+| 值 | 英文状态 | 含义 | 典型场景 |
+|---:|---|---|---|
+| `0` | `idle` | 未采集 | 设备尚未开始采集、已停止采集,或刚连接但没有采集任务运行。 |
+| `1` | `running` | 采集中 | 设备正在执行采集任务。 |
+| `2` | `error` | 采集异常 | 采集过程中发生异常,例如读取点位失败、通信异常或采集任务报错。 |
+
+设备 `connect_status` 表示最近一次连接/通信检测状态:
+
+| 值 | 英文状态 | 含义 | 典型场景 |
+|---:|---|---|---|
+| `0` | `idle` | 未检测或空闲 | 尚未进行连接检测,或设备当前处于空闲状态。 |
+| `1` | `normal` | 连接正常 | 最近一次连接或通信状态正常。 |
+| `2` | `abnormal` | 连接异常 | 最近一次连接或通信状态异常。 |
+
+点位 `status` 表示点位采集状态:
+
+| 值 | 英文状态 | 含义 | 典型场景 |
+|---:|---|---|---|
+| `0` | `idle` | 未采集 | 点位尚未采集或设备未运行采集任务。 |
+| `1` | `working` | 采集正常 | 点位最近一次采集正常。 |
+| `2` | `error` | 采集异常 | 点位最近一次采集失败或转换失败。 |
+
+状态字段关系说明:
+
+| 字段 | 表示对象 | 说明 |
+|---|---|---|
+| `status` | 设备连接状态 | 判断设备是否已连接。 |
+| `running_status` | 设备采集状态 | 判断设备是否正在采集或采集是否异常。 |
+| `connect_status` | 最近连接/通信检测状态 | 判断最近一次连接或通信是否正常。 |
+| 点位 `status` | 单个点位采集状态 | 判断某个点位最近一次采集是否正常。 |
+
 ## Modbus 创建设备与创建点位接口文档
 
 ### 枚举汇总
@@ -730,22 +775,6 @@ header = {
 
 说明:`func_code` 为 `1` 或 `2` 时采集结果按布尔/0-1 处理;`func_code` 为 `3` 或 `4` 且 `type=bool` 时会从寄存器中按 `bit` 取某一位。
 
-- 状态枚举
-
-接口创建时会写入初始状态,调用方通常不需要传。
-
-| 字段 | 值 | 含义 |
-|---|---:|---|
-| 设备 `status` | 1 | disconnected,未连接 |
-| 设备 `status` | 2 | connected,已连接 |
-| 设备 `status` | 3 | error,异常 |
-| 设备 `running_status` | 0 | idle,未采集 |
-| 设备 `running_status` | 1 | running,采集中 |
-| 设备 `running_status` | 2 | error,运行异常 |
-| 点位 `status` | 0 | idle,未采集 |
-| 点位 `status` | 1 | working,采集正常 |
-| 点位 `status` | 2 | error,采集异常 |
-
 ### 1. 创建设备
 
 #### 基本信息
@@ -842,7 +871,123 @@ header = {
 |---|---|
 | Query 绑定失败、JSON 绑定失败、`type` 缺失、连接类型无效、底层客户端初始化失败、数据库写入失败等 | `{"state":2,"state_info":"失败"}` |
 
-### 2. 创建采集点位
+### 2. 编辑设备
+
+#### 基本信息
+
+- URL:`/api/collector/modbus/device/edit`
+- 方法:`POST`
+- 处理函数:`EditModbusDevice`
+- 请求结构:`ReqEditModbusDevice`
+- 主要用途:编辑已存在的 Modbus 设备连接参数、采集周期、设备分组和持久化配置。
+
+#### 重要说明
+
+- 该接口是 Modbus 专用旧接口,和通用创建设备接口 `/api/collector/device` 的字段名不完全一致。
+- 该接口是全量更新语义,未传字段会按 Go 零值写入,例如 `timeout` 变为 `0`、`is_persistent` 变为 `false`、`address_offset` 变为 `0`。编辑前建议先查询设备详情或设备列表,基于原配置修改后整包提交。
+- 编辑前设备不能处于已连接状态。若设备 `status=2`,接口返回 `先停止设备采集,再编辑设备`。应先调用连接/断开设备接口将设备断开。
+- 请求字段 `type` 在本接口中表示 Modbus 连接类型,取值与前文 Modbus 设备连接类型枚举一致;不是通用创建设备接口里的协议字符串 `type="modbus"`。
+- 编辑成功后接口会移除内存中的旧设备并重新加载设备对象,设备状态会回到未连接状态。
+
+#### 请求字段
+
+| 字段 | 类型 | 必填 | 默认/行为 | 含义 |
+|---|---|---|---|---|
+| `ori_id` | int | 是 | 缺失或为 `0` 返回 `设备不存在` | 要编辑的原设备 ID。 |
+| `type` | int | 是 | 缺失时为 `0`,后续连接可能因连接类型无效失败 | Modbus 连接类型。`1` TCP,`2` RTU,`3` UDP,`4` RTU OVER TCP,`5` RTU OVER UDP。 |
+| `name` | string | 是 | 绑定时必填;保存前会 `strings.TrimSpace` | 设备名称。 |
+| `ip` | string | TCP/UDP 类连接必填 | 会 `strings.TrimSpace` | 设备 IP。`type` 不为 `2` 时为空会返回 `选择Modbus TCP类型时,IP不能为空`。 |
+| `port` | int | TCP/UDP 类连接必填 | 缺失时为 `0` | 设备端口。`type` 为 `1`、`3`、`4`、`5` 时用于组装连接 URL。 |
+| `slave_id` | int | 是 | Go binding 的 `required` 对数字零值敏感,缺失或为 `0` 会绑定失败 | Modbus 从站 ID/Unit ID。 |
+| `serial_port` | string | RTU 必填 | 会 `strings.TrimSpace` | 串口名。`type=2` 时为空会返回 `选择Modbus RTU类型时,串口不能为空`。 |
+| `timeout` | int | 建议必填 | 缺失时会写入 `0`,编辑接口不自动补 `3` | 超时时间,单位秒。 |
+| `byte_order` | int | 建议必填 | 缺失时会写入 `0` | 字节序,见 `byte_order` 枚举。 |
+| `word_order` | int | 建议必填 | 缺失时会写入 `0` | 字序,见 `word_order` 枚举。 |
+| `is_persistent` | bool | 否 | 缺失时会写入 `false` | 是否持久化设备。 |
+| `baud_rate` | int | RTU 建议必填 | 缺失时会写入 `0` | 串口波特率,例如 `9600`。 |
+| `data_bit` | int | RTU 建议必填 | 缺失时会写入 `0` | 串口数据位,见 `data_bit` 枚举。 |
+| `parity` | int | RTU 建议必填 | 传 `0` 会按 Even 转换 | 串口校验位,见 `parity` 枚举。 |
+| `stop_bit` | int | RTU 建议必填 | 缺失时会写入 `0` | 串口停止位,见 `stop_bit` 枚举。 |
+| `mode` | int | 否 | 缺失时会写入 `0` | 模式字段,当前主要保存配置。 |
+| `address_offset` | int | 否 | 缺失时会写入 `0` | 地址偏移。实际读点时使用 `点位 address - 设备 address_offset`。 |
+| `retry_times` | int | 否 | 缺失时会写入 `0` | 读取失败重试次数。 |
+| `device_group_id` | int | 否 | 缺失时会写入 `0` | 父设备分组 ID。`0` 表示顶层设备。 |
+| `alarm_interval` | int | 否 | 缺失时会写入 `0` | 告警间隔。 |
+| `collect_interval` | int | 否 | 缺失时会写入 `0` | 采集周期。 |
+
+#### 请求示例:编辑 Modbus TCP 设备
+
+```json
+{
+  "ori_id": 1,
+  "type": 1,
+  "name": "modbus_tcp_1_edited",
+  "ip": "127.0.0.1",
+  "port": 5502,
+  "slave_id": 1,
+  "timeout": 3,
+  "byte_order": 1,
+  "word_order": 1,
+  "is_persistent": true,
+  "device_group_id": 0,
+  "alarm_interval": 0,
+  "collect_interval": 5,
+  "address_offset": 0,
+  "retry_times": 0,
+  "mode": 0
+}
+```
+
+#### 请求示例:编辑 Modbus RTU 设备
+
+```json
+{
+  "ori_id": 1,
+  "type": 2,
+  "name": "modbus_rtu_1_edited",
+  "serial_port": "COM3",
+  "slave_id": 1,
+  "timeout": 3,
+  "baud_rate": 9600,
+  "data_bit": 8,
+  "parity": 2,
+  "stop_bit": 1,
+  "byte_order": 1,
+  "word_order": 1,
+  "is_persistent": true,
+  "device_group_id": 0,
+  "alarm_interval": 0,
+  "collect_interval": 5,
+  "address_offset": 0,
+  "retry_times": 0,
+  "mode": 0
+}
+```
+
+#### 成功响应
+
+```json
+{
+  "state": 0,
+  "state_info": "操作成功",
+  "data": null
+}
+```
+
+#### 主要失败响应
+
+| 场景 | 响应 |
+|---|---|
+| JSON 绑定失败、`name` 缺失、`slave_id` 缺失或为零等 | `{"state":2,"state_info":"无效参数"}` |
+| `ori_id` 缺失或为 `0` | `{"state":2,"state_info":"设备不存在"}` |
+| RTU 设备未传 `serial_port` | `{"state":2,"state_info":"选择Modbus RTU类型时,串口不能为空"}` |
+| TCP/UDP 类设备未传 `ip` | `{"state":2,"state_info":"选择Modbus TCP类型时,IP不能为空"}` |
+| 查询原设备失败 | `{"state":2,"state_info":"检测设备状态失败"}` |
+| 原设备不存在 | `{"state":2,"state_info":"设备ID不合法"}` |
+| 设备仍处于已连接状态 | `{"state":2,"state_info":"先停止设备采集,再编辑设备"}` |
+| 数据库更新失败 | `{"state":2,"state_info":"编辑失败"}` |
+
+### 3. 创建采集点位
 
 #### 基本信息
 
@@ -955,12 +1100,369 @@ header = {
 | 同一设备下 `point_id` 重复 | `{"state":2,"state_info":"point_id 重复, point_id: {point_id}"}` |
 | 数据库插入失败 | `{"state":2,"state_info":"新增失败"}` |
 
+### 4. 编辑采集点位
+
+#### 基本信息
+
+- URL:`/api/collector/modbus/point/edit_collect_point`
+- 方法:`POST`
+- 处理函数:`modbusPointEdit`
+- 请求结构:`ReqModbusPointEdit`
+- 主要用途:编辑已存在的 Modbus 采集点位,包括点位名称、`point_id`、地址、功能码、数据类型、缩放和合法值范围。
+
+#### 重要说明
+
+- 该接口是全量更新语义,除 `ori_id` 外的点位配置应按完整点位对象提交。未传字段会按 Go 零值写入,例如 `group_id=0`、`value_offset=0`、`bit=0`、`invalid_values=""`。
+- `ori_id` 是要编辑的采集点位内部 ID,对应查询设备点位列表返回的 `data.point[].id`。
+- 编辑点位不会迁移点位所属设备。请求结构虽然继承了 `device_id` 字段,但处理逻辑会从原点位读取 `DeviceID`,并继续保存到原设备下。
+- `point_id` 允许为空字符串;非空时会检查同一设备下是否重复,检查时会排除当前 `ori_id` 对应的原点位。
+- 编辑成功后会更新数据库、设备内存点位缓存和通用点位缓存。点位状态会重置为未采集状态。
+
+#### 请求字段
+
+| 字段 | 类型 | 必填 | 默认/行为 | 含义 |
+|---|---|---|---|---|
+| `ori_id` | int | 是 | 无默认值 | 要编辑的原采集点位 ID。 |
+| `name` | string | 是 | 绑定时必填;保存前会 `strings.TrimSpace` | 点位名称。 |
+| `point_id` | string | 否 | 空字符串允许 | 外部点位标识。非空时,同一设备下不能重复。 |
+| `device_id` | int | 否 | 编辑逻辑不使用该字段 | 请求结构继承字段。编辑时点位仍归属原设备,不会按该字段迁移。 |
+| `scale_ratio` | float64 | 是 | Go binding 的 `required` 对数字零值敏感,建议传非 `0`,常用 `1` | 缩放系数。寄存器数值采集时先乘以该系数。 |
+| `value_offset` | float64 | 否 | Go 零值 `0` | 值偏移。寄存器采集结果乘 `scale_ratio` 后再加该偏移。 |
+| `describe` | string | 否 | 空字符串 | 点位描述,保存到 `description`。 |
+| `invalid_values` | string | 否 | 空字符串解析为空数组 | 无效值列表,逗号分隔,例如 `"-9999,9999"`。接口会逐项解析为 float64,无法解析则返回无效参数。 |
+| `valid_range_start` | number/null | 否 | `null` | 合法范围最小值。采集值小于该值时跳过写入。 |
+| `valid_range_end` | number/null | 否 | `null` | 合法范围最大值。采集值大于该值时跳过写入。 |
+| `type` | string | 否,但采集必须有效 | 无显式默认值 | 点位数据类型,见数据类型枚举。 |
+| `address` | int | 是 | Go 零值 `0` | Modbus 地址。实际读取地址为 `address - 设备 address_offset`。 |
+| `func_code` | int | 是 | Go 零值 `0` 会导致后续采集功能码无效 | Modbus 功能码,见 `func_code` 枚举。 |
+| `group_id` | int | 否 | Go 零值 `0` | 点位分组 ID。 |
+| `bit` | int | `type=bool` 且读寄存器时需要 | Go 零值 `0` | 位下标。`func_code=3/4` 且 `type=bool` 时,从寄存器值中取 `(value >> bit) & 1`。建议范围 `0` 到 `15`。 |
+
+#### 请求示例:编辑保持寄存器 uint16 点位
+
+```json
+{
+  "ori_id": 101,
+  "name": "holding_register_uint16_edited",
+  "point_id": "HR_UINT16_EDITED",
+  "describe": "编辑后的保持寄存器 uint16 示例",
+  "func_code": 3,
+  "address": 10,
+  "type": "uint16",
+  "scale_ratio": 1,
+  "value_offset": 0,
+  "group_id": 0,
+  "invalid_values": "",
+  "valid_range_start": null,
+  "valid_range_end": null,
+  "bit": 0
+}
+```
+
+#### 请求示例:编辑寄存器位点 bool
+
+```json
+{
+  "ori_id": 102,
+  "name": "alarm_bit_4",
+  "point_id": "ALARM_BIT_4",
+  "describe": "保持寄存器第 4 位报警",
+  "func_code": 3,
+  "address": 20,
+  "type": "bool",
+  "bit": 4,
+  "scale_ratio": 1,
+  "value_offset": 0,
+  "group_id": 0,
+  "invalid_values": "",
+  "valid_range_start": null,
+  "valid_range_end": null
+}
+```
+
+#### 成功响应
+
+```json
+{
+  "state": 0,
+  "state_info": "成功",
+  "data": null
+}
+```
+
+#### 主要失败响应
+
+| 场景 | 响应 |
+|---|---|
+| JSON 绑定失败、`ori_id` 缺失、`name` 缺失、`scale_ratio` 缺失或为零等 | `{"state":2,"state_info":"无效参数"}` |
+| `invalid_values` 中存在非数字项 | `{"state":2,"state_info":"无效参数"}` |
+| 原点位不存在或查询失败 | `{"state":2,"state_info":"校验失败"}` |
+| 同一设备下 `point_id` 重复 | `{"state":2,"state_info":"point_id 重复, point_id: {point_id}"}` |
+| 数据库更新失败 | `{"state":2,"state_info":"编辑失败"}` |
+
+## 连接/断开设备接口
+
+### 基本信息
+
+- URL:`/api/collector/common/device/set_connect_status`
+- 方法:`POST`
+- 请求体:`application/json`
+- 普通响应类型:JSON
+- 主要用途:连接或断开指定采集设备。该接口是通用设备接口,Modbus 设备调用时 `type` 传 `modbus`。
+
+### 请求字段
+
+| 字段 | 类型 | 必填 | 默认/行为 | 含义 |
+|---|---|---|---|---|
+| `id` | int | 是 | Go 零值 `0` | 设备 ID。接口会从采集器内存中按 ID 查找设备。 |
+| `status` | int | 是 | Go 零值 `0` 会进入非法状态逻辑 | 要设置的设备连接状态。`1` 表示断开,`2` 表示连接。 |
+| `type` | string | 建议传 | 当前处理逻辑不直接使用该字段 | 设备协议类型。Modbus 设备传 `modbus`,其他可传 `s7`、`bacnet`、`ethernet-ip`、`opc-ua`、`opc-da`、`snmp`、`iec104`。 |
+
+### status 取值
+
+| 值 | 含义 | 接口行为 |
+|---:|---|---|
+| `1` | disconnected,断开连接 | 调用设备断开逻辑,成功后设备 `status` 通常为 `1`,`running_status` 通常为 `0`。 |
+| `2` | connected,连接设备 | 调用设备连接逻辑,成功后设备 `status` 通常为 `2`,`running_status` 取决于设备采集状态。 |
+| 其他 | 非法状态 | 返回失败响应,通常为 `{"state":2,"state_info":"操作设备失败"}`。 |
+
+### 请求示例:连接 Modbus 设备
+
+```json
+{
+  "id": 1,
+  "type": "modbus",
+  "status": 2
+}
+```
+
+### 请求示例:断开 Modbus 设备
+
+```json
+{
+  "id": 1,
+  "type": "modbus",
+  "status": 1
+}
+```
+
+### 成功响应
+
+```json
+{
+  "state": 0,
+  "state_info": "",
+  "data": {
+    "status": 2,
+    "running_status": 0,
+    "msg": ""
+  }
+}
+```
+
+### 成功响应字段说明
+
+| 字段 | 类型 | 含义 |
+|---|---|---|
+| `state` | int | 业务状态码。成功时为 `0`。 |
+| `state_info` | string | 状态描述。该接口成功时通常为空字符串。 |
+| `data.status` | int | 操作后的设备连接状态,见设备 `status` 枚举。 |
+| `data.running_status` | int | 操作后的设备运行状态,见 `running_status` 枚举。 |
+| `data.msg` | string | 连接失败时可能返回底层错误消息;成功时通常为空字符串。 |
+
+### 失败响应示例
+
+请求体无法绑定或字段类型错误:
+
+```json
+{
+  "state": 2,
+  "state_info": "无效参数",
+  "data": null
+}
+```
+
+设备不存在或不在当前采集器内存中:
+
+```json
+{
+  "state": 2,
+  "state_info": "查询详情失败",
+  "data": null
+}
+```
+
+连接设备时发现点位标识与其他运行设备重复:
+
+```json
+{
+  "state": 2,
+  "state_info": "point_id: AI_TEMP_01 在运行的设备中已存在",
+  "data": null
+}
+```
+
+设备连接或断开失败:
+
+```json
+{
+  "state": 2,
+  "state_info": "操作设备失败",
+  "data": {
+    "status": 1,
+    "running_status": 0,
+    "msg": ""
+  }
+}
+```
+
+## 查询设备点位列表接口
+
+### 基本信息
+
+- URL:`/api/collector/common/device/get_collect_point`
+- 方法:`POST`
+- 请求体:`application/json`
+- 普通响应类型:JSON
+- 主要用途:查询指定设备当前内存中的采集点位列表。该接口是通用设备点位接口,Modbus 设备调用时 `type` 传 `modbus`。
+
+### 请求字段
+
+| 字段 | 类型 | 必填 | 默认/行为 | 含义 |
+|---|---|---|---|---|
+| `id` | int | 是 | Go 零值 `0` | 设备 ID。 |
+| `type` | string | 是 | 空字符串会返回无效参数 | 设备协议类型。查询 Modbus 点位固定传 `modbus`。 |
+| `group_id` | int | 否 | `0` | 点位分组 ID。为 `0` 时返回该设备下全部点位;非 `0` 时只返回该分组下点位。 |
+
+### type 取值
+
+| 值 | 含义 |
+|---|---|
+| `modbus` | Modbus 设备点位。 |
+| `s7` | S7 设备点位。 |
+| `bacnet` | BACnet 设备点位。 |
+| `ethernet-ip` | EtherNet/IP 设备点位。 |
+| `opc-ua` | OPC UA 设备点位。 |
+| `opc-da` | OPC DA 设备点位。 |
+| `snmp` | SNMP 设备点位。 |
+| `iec104` | IEC104 设备点位。 |
+
+### 请求示例:查询 Modbus 设备全部点位
+
+```json
+{
+  "id": 1,
+  "type": "modbus",
+  "group_id": 0
+}
+```
+
+### 请求示例:查询指定点位分组
+
+```json
+{
+  "id": 1,
+  "type": "modbus",
+  "group_id": 100
+}
+```
+
+### Modbus 成功响应示例
+
+```json
+{
+  "state": 0,
+  "state_info": "",
+  "data": {
+    "point": [
+      {
+        "id": 101,
+        "point_id": "HR_UINT16",
+        "name": "holding_register_uint16",
+        "address": 10,
+        "type": "uint16",
+        "device_id": 1,
+        "status": 1,
+        "describe": "保持寄存器 uint16 示例",
+        "function_code": 3,
+        "present_value": 123,
+        "scale_ratio": 1,
+        "value_offset": 0,
+        "group_id": 0,
+        "bit": 0,
+        "update_time": "2026-06-16T10:03:00+08:00",
+        "invalid_values": "",
+        "valid_range_start": null,
+        "valid_range_end": null
+      }
+    ],
+    "total": 1
+  }
+}
+```
+
+### Modbus 成功响应字段说明
+
+| 字段 | 类型 | 含义 |
+|---|---|---|
+| `state` | int | 业务状态码。成功时为 `0`。 |
+| `state_info` | string | 状态描述。该接口成功时通常为空字符串。 |
+| `data.point` | object[] | 点位列表。 |
+| `data.total` | int | 本次返回的点位数量。传 `group_id` 过滤时为过滤后的数量。 |
+| `data.point[].id` | int | 采集点位内部 ID。 |
+| `data.point[].point_id` | string | 外部点位标识。创建点位时未传则可能为空字符串。 |
+| `data.point[].name` | string | 点位名称。 |
+| `data.point[].address` | int | Modbus 点位地址,采集时实际读取地址为 `address - 设备 address_offset`。 |
+| `data.point[].type` | string | Modbus 点位数据类型,例如 `bool`、`uint16`、`float32`。 |
+| `data.point[].device_id` | int | 所属设备 ID。 |
+| `data.point[].status` | int | 点位采集状态,见点位 `status` 枚举。 |
+| `data.point[].describe` | string | 点位描述。 |
+| `data.point[].function_code` | int | Modbus 功能码,见 `func_code` 枚举。 |
+| `data.point[].present_value` | number | 当前内存中的点位最新值。设备未采集或未刷新时可能为 `0`。 |
+| `data.point[].scale_ratio` | number | 缩放系数。 |
+| `data.point[].value_offset` | number | 值偏移。 |
+| `data.point[].group_id` | int | 点位分组 ID。 |
+| `data.point[].bit` | int | 位下标。寄存器布尔点位使用;非位点通常为 `0`。 |
+| `data.point[].update_time` | string | 点位更新时间,Go JSON time/RFC3339 格式。 |
+| `data.point[].invalid_values` | string | 无效值列表字符串,多个值用逗号分隔;没有配置时为空字符串。 |
+| `data.point[].valid_range_start` | number/null | 合法范围最小值。 |
+| `data.point[].valid_range_end` | number/null | 合法范围最大值。 |
+
+### 失败响应示例
+
+请求体无法绑定或 `type` 不支持:
+
+```json
+{
+  "state": 2,
+  "state_info": "无效参数",
+  "data": null
+}
+```
+
+设备不存在或不在当前采集器内存中:
+
+```json
+{
+  "state": 2,
+  "state_info": "设备ID不合法",
+  "data": null
+}
+```
+
+### 注意事项
+
+- 该接口返回的是设备内存对象中的点位,不是每次直接查询数据库。
+- Modbus 点位响应字段中的 `function_code` 对应创建点位接口请求字段 `func_code`。
+- Modbus 点位响应字段中的 `type` 对应创建点位接口请求字段 `type`,底层内部字段名为 `data_type`。
+- `present_value` 是当前内存最新值;如果设备未连接、未采集或点位未刷新,不能仅凭该字段判断设备实时可用性。
+
 ## 查询设备列表接口
 
 ### 基本信息
 
 - URL:`/api/collector/device`
-- 方法:`GET
+- 方法:`GET`
 - 请求参数位置:Query String
 - 普通响应类型:JSON
 - 主要用途:查询当前采集器内存中的设备列表,并按设备分组、点位分组组织成树形结构。

+ 283 - 0
tests/test_collector_api.py

@@ -91,6 +91,128 @@ class CollectorApiTests(unittest.TestCase):
                 with self.assertRaisesRegex(ValueError, f"payload.{field_name} is required"):
                     collector_api.create_modbus_device("dev-01", payload)
 
+    def test_edit_modbus_device_maps_aliases_and_posts_to_legacy_endpoint(self) -> None:
+        self._patch_project()
+        response = {"state": 0, "state_info": "操作成功", "data": None}
+        with patch(
+            "data_collector_mcp.collector_api.request_json",
+            return_value=response,
+        ) as request_json:
+            result = collector_api.edit_modbus_device(
+                "dev-01",
+                {
+                    "ori_id": 1,
+                    "name": "modbus_tcp_edited",
+                    "device_type": 1,
+                    "ip": "127.0.0.1",
+                    "port": 5502,
+                    "slave_id": 1,
+                    "byte_order": 2,
+                    "word_order": 2,
+                    "address_base": 1,
+                    "group_id": 10,
+                },
+            )
+
+        self.assertEqual(result, response)
+        self.assertEqual(
+            request_json.call_args.args[:3],
+            (
+                "POST",
+                "http://collector.test/api/collector/modbus/device/edit",
+                "token",
+            ),
+        )
+        payload = request_json.call_args.kwargs["json_payload"]
+        self.assertEqual(payload["ori_id"], 1)
+        self.assertEqual(payload["type"], 1)
+        self.assertEqual(payload["address_offset"], 1)
+        self.assertEqual(payload["device_group_id"], 10)
+        self.assertEqual(payload["timeout"], 3)
+        self.assertEqual(payload["alarm_interval"], 90)
+        self.assertEqual(payload["collect_interval"], 5)
+        self.assertEqual(payload["retry_times"], 0)
+        self.assertEqual(payload["mode"], 0)
+        self.assertNotIn("device_type", payload)
+        self.assertNotIn("address_base", payload)
+        self.assertNotIn("group_id", payload)
+
+    def test_edit_modbus_device_supports_rtu_serial_port(self) -> None:
+        self._patch_project()
+        with patch(
+            "data_collector_mcp.collector_api.request_json",
+            return_value={"state": 0},
+        ) as request_json:
+            collector_api.edit_modbus_device(
+                "dev-01",
+                {
+                    "ori_id": 1,
+                    "name": "modbus_rtu_edited",
+                    "type": 2,
+                    "serial_port": "COM3",
+                    "slave_id": 1,
+                    "byte_order": 1,
+                    "word_order": 1,
+                },
+            )
+
+        payload = request_json.call_args.kwargs["json_payload"]
+        self.assertEqual(payload["type"], 2)
+        self.assertEqual(payload["serial_port"], "COM3")
+
+    def test_edit_modbus_device_requires_required_fields(self) -> None:
+        self._patch_project()
+        base_payload = {
+            "ori_id": 1,
+            "name": "modbus_tcp_edited",
+            "device_type": 1,
+            "ip": "127.0.0.1",
+            "port": 5502,
+            "slave_id": 1,
+            "byte_order": 1,
+            "word_order": 1,
+        }
+        required_fields = ["ori_id", "name", "device_type", "ip", "port", "slave_id", "byte_order", "word_order"]
+
+        for field_name in required_fields:
+            with self.subTest(field_name=field_name):
+                payload = dict(base_payload)
+                payload.pop(field_name)
+                with self.assertRaisesRegex(ValueError, f"payload.{field_name} is required"):
+                    collector_api.edit_modbus_device("dev-01", payload)
+
+    def test_edit_modbus_device_requires_serial_port_for_rtu(self) -> None:
+        self._patch_project()
+        with self.assertRaisesRegex(ValueError, "payload.serial_port is required"):
+            collector_api.edit_modbus_device(
+                "dev-01",
+                {
+                    "ori_id": 1,
+                    "name": "modbus_rtu_edited",
+                    "type": 2,
+                    "slave_id": 1,
+                    "byte_order": 1,
+                    "word_order": 1,
+                },
+            )
+
+    def test_edit_modbus_device_rejects_invalid_connection_type(self) -> None:
+        self._patch_project()
+        with self.assertRaisesRegex(ValueError, "payload.type must be one of 1, 2, 3, 4, 5"):
+            collector_api.edit_modbus_device(
+                "dev-01",
+                {
+                    "ori_id": 1,
+                    "name": "modbus_tcp_edited",
+                    "type": 0,
+                    "ip": "127.0.0.1",
+                    "port": 5502,
+                    "slave_id": 1,
+                    "byte_order": 1,
+                    "word_order": 1,
+                },
+            )
+
     def test_create_modbus_point_merges_defaults_and_posts_to_collector(self) -> None:
         self._patch_project()
         response = {"state": 0, "state_info": "成功", "data": None}
@@ -120,6 +242,7 @@ class CollectorApiTests(unittest.TestCase):
             ),
         )
         payload = request_json.call_args.kwargs["json_payload"]
+        self.assertEqual(payload["point_id"], "HR_UINT16")
         self.assertEqual(payload["scale_ratio"], 1)
         self.assertEqual(payload["value_offset"], 0)
         self.assertEqual(payload["group_id"], 0)
@@ -129,6 +252,26 @@ class CollectorApiTests(unittest.TestCase):
         self.assertEqual(payload["bit"], 0)
         self.assertEqual(payload["func_code"], 3)
 
+    def test_create_modbus_point_defaults_point_id_to_empty_string(self) -> None:
+        self._patch_project()
+        with patch(
+            "data_collector_mcp.collector_api.request_json",
+            return_value={"state": 0},
+        ) as request_json:
+            collector_api.create_modbus_point(
+                "dev-01",
+                {
+                    "device_id": 1,
+                    "name": "holding_register_uint16",
+                    "func_code": 3,
+                    "address": 10,
+                    "type": "uint16",
+                },
+            )
+
+        payload = request_json.call_args.kwargs["json_payload"]
+        self.assertEqual(payload["point_id"], "")
+
     def test_create_modbus_point_normalizes_type_alias_and_register_type(self) -> None:
         self._patch_project()
         with patch(
@@ -205,6 +348,81 @@ class CollectorApiTests(unittest.TestCase):
                 },
             )
 
+    def test_edit_modbus_point_merges_defaults_and_posts_to_collector(self) -> None:
+        self._patch_project()
+        response = {"state": 0, "state_info": "成功", "data": None}
+        with patch(
+            "data_collector_mcp.collector_api.request_json",
+            return_value=response,
+        ) as request_json:
+            result = collector_api.edit_modbus_point(
+                "dev-01",
+                {
+                    "ori_id": 101,
+                    "name": "holding_register_uint16_edited",
+                    "point_id": "HR_UINT16_EDITED",
+                    "register_type": "holding_register",
+                    "address": 10,
+                    "type": "WORD",
+                },
+            )
+
+        self.assertEqual(result, response)
+        self.assertEqual(
+            request_json.call_args.args[:3],
+            (
+                "POST",
+                "http://collector.test/api/collector/modbus/point/edit_collect_point",
+                "token",
+            ),
+        )
+        payload = request_json.call_args.kwargs["json_payload"]
+        self.assertEqual(payload["ori_id"], 101)
+        self.assertEqual(payload["point_id"], "HR_UINT16_EDITED")
+        self.assertEqual(payload["func_code"], 3)
+        self.assertEqual(payload["type"], "uint16")
+        self.assertEqual(payload["scale_ratio"], 1)
+        self.assertEqual(payload["value_offset"], 0)
+        self.assertEqual(payload["group_id"], 0)
+        self.assertEqual(payload["invalid_values"], "")
+        self.assertIsNone(payload["valid_range_start"])
+        self.assertIsNone(payload["valid_range_end"])
+        self.assertEqual(payload["bit"], 0)
+        self.assertNotIn("register_type", payload)
+
+    def test_edit_modbus_point_defaults_point_id_to_empty_string(self) -> None:
+        self._patch_project()
+        with patch(
+            "data_collector_mcp.collector_api.request_json",
+            return_value={"state": 0},
+        ) as request_json:
+            collector_api.edit_modbus_point(
+                "dev-01",
+                {
+                    "ori_id": 101,
+                    "name": "holding_register_uint16_edited",
+                    "func_code": 3,
+                    "address": 10,
+                    "type": "uint16",
+                },
+            )
+
+        payload = request_json.call_args.kwargs["json_payload"]
+        self.assertEqual(payload["point_id"], "")
+
+    def test_edit_modbus_point_requires_ori_id(self) -> None:
+        self._patch_project()
+        with self.assertRaisesRegex(ValueError, "payload.ori_id is required"):
+            collector_api.edit_modbus_point(
+                "dev-01",
+                {
+                    "name": "holding_register_uint16_edited",
+                    "func_code": 3,
+                    "address": 10,
+                    "type": "uint16",
+                },
+            )
+
     def test_list_devices_defaults_num_points_false(self) -> None:
         self._patch_project()
         response = {"state": 0, "devices": []}
@@ -235,6 +453,71 @@ class CollectorApiTests(unittest.TestCase):
             "http://collector.test/api/collector/device?num_points=true",
         )
 
+    def test_connect_device_posts_connected_status(self) -> None:
+        self._patch_project()
+        response = {"state": 0, "data": {"status": 2, "running_status": 0}}
+        with patch(
+            "data_collector_mcp.collector_api.request_json",
+            return_value=response,
+        ) as request_json:
+            result = collector_api.connect_device("dev-01", device_id=1, device_type="modbus")
+
+        self.assertEqual(result, response)
+        request_json.assert_called_once_with(
+            "POST",
+            "http://collector.test/api/collector/common/device/set_connect_status",
+            "token",
+            json_payload={"id": 1, "type": "modbus", "status": 2},
+        )
+
+    def test_disconnect_device_posts_disconnected_status(self) -> None:
+        self._patch_project()
+        response = {"state": 0, "data": {"status": 1, "running_status": 0}}
+        with patch(
+            "data_collector_mcp.collector_api.request_json",
+            return_value=response,
+        ) as request_json:
+            result = collector_api.disconnect_device("dev-01", device_id=1, device_type="modbus")
+
+        self.assertEqual(result, response)
+        request_json.assert_called_once_with(
+            "POST",
+            "http://collector.test/api/collector/common/device/set_connect_status",
+            "token",
+            json_payload={"id": 1, "type": "modbus", "status": 1},
+        )
+
+    def test_list_device_points_posts_device_and_group(self) -> None:
+        self._patch_project()
+        response = {"state": 0, "data": {"point": [], "total": 0}}
+        with patch(
+            "data_collector_mcp.collector_api.request_json",
+            return_value=response,
+        ) as request_json:
+            result = collector_api.list_device_points(
+                "dev-01",
+                device_id=1,
+                device_type="modbus",
+                group_id=100,
+            )
+
+        self.assertEqual(result, response)
+        request_json.assert_called_once_with(
+            "POST",
+            "http://collector.test/api/collector/common/device/get_collect_point",
+            "token",
+            json_payload={"id": 1, "type": "modbus", "group_id": 100},
+        )
+
+    def test_device_common_tools_validate_ids(self) -> None:
+        self._patch_project()
+        with self.assertRaisesRegex(ValueError, "device_id must be a positive integer"):
+            collector_api.connect_device("dev-01", device_id=0)
+        with self.assertRaisesRegex(ValueError, "group_id must be a non-negative integer"):
+            collector_api.list_device_points("dev-01", device_id=1, group_id=-1)
+        with self.assertRaisesRegex(ValueError, "device_type is required"):
+            collector_api.disconnect_device("dev-01", device_id=1, device_type="")
+
 
 if __name__ == "__main__":
     unittest.main()

+ 21 - 1
tests/test_db.py

@@ -1,7 +1,7 @@
 from __future__ import annotations
 
 import unittest
-from unittest.mock import patch
+from unittest.mock import MagicMock, patch
 
 from data_collector_mcp import db
 
@@ -22,6 +22,26 @@ class DatabaseTests(unittest.TestCase):
         with patch.dict("os.environ", {"DATABASE_URL": url}, clear=True):
             self.assertEqual(db.database_url(), url)
 
+    def test_check_database_connection_executes_select(self) -> None:
+        connection = MagicMock()
+        connect_context = MagicMock()
+        connect_context.__enter__.return_value = connection
+        engine = MagicMock()
+        engine.connect.return_value = connect_context
+
+        with patch.object(db, "sql_engine", return_value=engine):
+            db.check_database_connection()
+
+        connection.execute.assert_called_once()
+
+    def test_check_database_connection_propagates_errors(self) -> None:
+        engine = MagicMock()
+        engine.connect.side_effect = RuntimeError("connection failed")
+
+        with patch.object(db, "sql_engine", return_value=engine):
+            with self.assertRaisesRegex(RuntimeError, "connection failed"):
+                db.check_database_connection()
+
 
 if __name__ == "__main__":
     unittest.main()

+ 111 - 0
tests/test_server_tools.py

@@ -0,0 +1,111 @@
+from __future__ import annotations
+
+import inspect
+import unittest
+from unittest.mock import patch
+
+from data_collector_mcp import server
+
+
+class ServerToolTests(unittest.TestCase):
+    def test_modbus_device_edit_uses_explicit_parameters(self) -> None:
+        signature = inspect.signature(server.collector_modbus_device_edit)
+        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(
+                project_key="dev-01",
+                ori_id=1,
+                name="modbus_tcp_edited",
+                device_type=1,
+                ip="127.0.0.1",
+                port=5502,
+                slave_id=1,
+                byte_order=2,
+                word_order=2,
+                address_offset=1,
+                device_group_id=10,
+            )
+
+        self.assertEqual(result, {"state": 0})
+        api_edit.assert_called_once_with(
+            "dev-01",
+            {
+                "ori_id": 1,
+                "name": "modbus_tcp_edited",
+                "device_type": 1,
+                "ip": "127.0.0.1",
+                "port": 5502,
+                "slave_id": 1,
+                "byte_order": 2,
+                "word_order": 2,
+                "serial_port": "",
+                "timeout": 3,
+                "is_persistent": True,
+                "baud_rate": 0,
+                "data_bit": 0,
+                "parity": 0,
+                "stop_bit": 0,
+                "mode": 0,
+                "address_offset": 1,
+                "retry_times": 0,
+                "device_group_id": 10,
+                "alarm_interval": 90,
+                "collect_interval": 5,
+            },
+        )
+
+    def test_modbus_point_edit_uses_explicit_parameters_with_func_code(self) -> None:
+        signature = inspect.signature(server.collector_modbus_point_edit)
+        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(
+                project_key="dev-01",
+                ori_id=101,
+                name="holding_register_uint16_edited",
+                address=10,
+                data_type="uint16",
+                func_code=3,
+                point_id="HR_UINT16_EDITED",
+            )
+
+        self.assertEqual(result, {"state": 0})
+        api_edit.assert_called_once_with(
+            "dev-01",
+            {
+                "ori_id": 101,
+                "name": "holding_register_uint16_edited",
+                "address": 10,
+                "type": "uint16",
+                "point_id": "HR_UINT16_EDITED",
+                "scale_ratio": 1,
+                "value_offset": 0,
+                "group_id": 0,
+                "invalid_values": "",
+                "valid_range_start": None,
+                "valid_range_end": None,
+                "bit": 0,
+                "describe": "",
+                "func_code": 3,
+            },
+        )
+
+    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(
+                project_key="dev-01",
+                ori_id=101,
+                name="holding_register_uint16_edited",
+                address=10,
+                data_type="uint16",
+                register_type="holding_register",
+            )
+
+        payload = api_edit.call_args.args[1]
+        self.assertEqual(payload["register_type"], "holding_register")
+        self.assertNotIn("func_code", payload)
+
+
+if __name__ == "__main__":
+    unittest.main()