Bladeren bron

新增s7接口

Lu Xianghui 1 maand geleden
bovenliggende
commit
bfafa197b5

+ 12 - 2
README.md

@@ -1,6 +1,6 @@
 # Data Collector Gateway
 
-基于 FastAPI 的 Modbus TCP HTTP 网关,用于通过 HTTP 接口读取 Modbus TCP 设备。
+基于 FastAPI 的数据采集 HTTP 网关,用于通过 HTTP 接口读取 Modbus TCP 和 Siemens S7 TCP 设备。
 
 ## 环境要求
 
@@ -17,6 +17,8 @@ uv sync
 - 原始 Modbus 读取,返回 Modbus Poll 风格的 `Tx/Rx` 通信报文
 - 点位读取,并支持常见寄存器类型的数据转换
 - 支持 Modbus 功能码 `1=线圈输入`、`2=离散量输入`、`3=保持寄存器`、`4=输入寄存器`
+- S7 原始字节读取,返回连接、读取、断开的语义化 `communication`
+- S7 点位读取和连接组合扫描
 
 ## 运行
 
@@ -33,6 +35,9 @@ uvicorn main:app --host 0.0.0.0 --port 8000
 | `GET` | `/health` | 健康检查 |
 | `POST` | `/modbus/read` | 原始 Modbus 读取,返回 `communication` 报文 |
 | `POST` | `/modbus/read_points` | 读取点位并返回转换后的值 |
+| `POST` | `/s7/read` | 原始 S7 字节读取,返回语义化 `communication` |
+| `POST` | `/s7/read_points` | 读取 S7 点位并返回转换后的值 |
+| `POST` | `/s7/connect_scan` | 扫描 S7 可用连接组合 |
 
 示例:
 
@@ -54,7 +59,7 @@ curl -X POST http://127.0.0.1:8000/api/dc-gateway/modbus/read \
   }'
 ```
 
-完整请求和响应约定见 [docs/modbus-http-api.md](docs/modbus-http-api.md)。
+完整请求和响应约定见 [docs/modbus-http-api.md](docs/modbus-http-api.md) 和 [docs/s7-http-api.md](docs/s7-http-api.md)
 
 ## 测试
 
@@ -68,8 +73,13 @@ python -m compileall app main.py
 
 ```bash
 python -m unittest discover -s intergration/modbus -p "test_*.py"
+python -m unittest discover -s intergration/s7 -p "test_*.py"
 ```
 
+Modbus API 集成测试会自动启动本项目 HTTP 服务,默认使用 `D:\Projects\BACnet-Client\autotest\Integration\modbus` 中相同的真实设备参数和点位地址。
+
+S7 测试会在本机启动 `python-snap7` 模拟 server,不依赖真实 PLC。
+
 环境变量:
 
 | 名称 | 默认值 |

+ 24 - 0
app/api/s7.py

@@ -0,0 +1,24 @@
+from typing import Any
+
+from fastapi import APIRouter
+
+from app.schemas.s7 import S7ConnectScanRequest, S7PointReadRequest, S7RawReadRequest
+from app.services.s7_service import connect_scan, read_points, read_raw
+
+
+router = APIRouter(tags=["s7"])
+
+
+@router.post("/s7/read")
+def s7_read(request: S7RawReadRequest) -> dict[str, Any]:
+    return read_raw(request)
+
+
+@router.post("/s7/read_points")
+def s7_read_points(request: S7PointReadRequest) -> dict[str, Any]:
+    return read_points(request)
+
+
+@router.post("/s7/connect_scan")
+def s7_connect_scan(request: S7ConnectScanRequest) -> dict[str, Any]:
+    return connect_scan(request)

+ 8 - 0
app/main.py

@@ -3,6 +3,7 @@ from fastapi.exceptions import RequestValidationError
 from fastapi.responses import JSONResponse
 
 from app.api.modbus import router as modbus_router
+from app.api.s7 import router as s7_router
 from app.response import response_payload
 
 
@@ -18,6 +19,7 @@ def validation_error_message(exc: RequestValidationError) -> str:
 def create_app() -> FastAPI:
     app = FastAPI(title="Data Collector Gateway")
     app.include_router(modbus_router, prefix="/api/dc-gateway")
+    app.include_router(s7_router, prefix="/api/dc-gateway")
 
     @app.exception_handler(RequestValidationError)
     async def request_validation_exception_handler(
@@ -29,6 +31,12 @@ def create_app() -> FastAPI:
             data = {"communication": []}
         elif request.url.path.endswith(("/modbus/read_points", "/modbus/read-points")):
             data = {"points": []}
+        elif request.url.path.endswith("/s7/read"):
+            data = {"communication": []}
+        elif request.url.path.endswith("/s7/read_points"):
+            data = {"points": []}
+        elif request.url.path.endswith("/s7/connect_scan"):
+            data = {"available": []}
         return JSONResponse(
             status_code=200,
             content=response_payload(1, validation_error_message(exc), data),

+ 112 - 0
app/schemas/s7.py

@@ -0,0 +1,112 @@
+from ipaddress import ip_address
+from typing import Literal
+
+from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
+
+
+SUPPORTED_POINT_TYPES = {
+    "bool",
+    "byte",
+    "int8",
+    "int16",
+    "uint16",
+    "int32",
+    "uint32",
+    "int64",
+    "uint64",
+    "float32",
+    "float64",
+}
+
+
+class S7IpRequest(BaseModel):
+    model_config = ConfigDict(extra="forbid")
+
+    ip: str = Field(min_length=1)
+
+    @field_validator("ip")
+    @classmethod
+    def validate_ip(cls, value: str) -> str:
+        try:
+            ip_address(value)
+        except ValueError as exc:
+            raise ValueError("ip must be a valid IP address") from exc
+        return value
+
+
+class S7BaseRequest(S7IpRequest):
+    device_type: Literal["S7TCP"] = "S7TCP"
+    port: int = Field(default=102, ge=1, le=65535)
+    rock: int = Field(ge=0, le=31)
+    slot: int = Field(ge=0, le=31)
+    tsap_conn_type: Literal["PG", "OP", "BASIC"] = "PG"
+
+    @field_validator("tsap_conn_type", mode="before")
+    @classmethod
+    def normalize_tsap_conn_type(cls, value: str) -> str:
+        return str(value).upper()
+
+
+class S7ReadSpec(BaseModel):
+    model_config = ConfigDict(extra="forbid")
+
+    area: Literal["DB", "M", "I", "Q"]
+    db: int = Field(default=0, ge=0)
+    start: int = Field(ge=0)
+    size: int = Field(ge=1, le=65535)
+
+    @field_validator("area", mode="before")
+    @classmethod
+    def normalize_area(cls, value: str) -> str:
+        return str(value).upper()
+
+    @model_validator(mode="after")
+    def validate_read(self) -> "S7ReadSpec":
+        if self.area == "DB" and self.db <= 0:
+            raise ValueError("db must be greater than 0 when area is DB")
+        return self
+
+
+class S7RawReadRequest(S7BaseRequest):
+    read: S7ReadSpec
+
+
+class S7PointSpec(BaseModel):
+    model_config = ConfigDict(extra="forbid")
+
+    area: Literal["DB", "M", "I", "Q"]
+    db: int = Field(default=0, ge=0)
+    start: int = Field(ge=0)
+    type: str
+    bit: int | None = Field(default=None, ge=0, le=7)
+
+    @field_validator("area", mode="before")
+    @classmethod
+    def normalize_area(cls, value: str) -> str:
+        return str(value).upper()
+
+    @field_validator("type")
+    @classmethod
+    def validate_type(cls, value: str) -> str:
+        normalized = value.lower()
+        if normalized not in SUPPORTED_POINT_TYPES:
+            raise ValueError(
+                "type must be one of bool, byte, int8, int16, uint16, int32, uint32, int64, uint64, float32, float64"
+            )
+        return normalized
+
+    @model_validator(mode="after")
+    def validate_point(self) -> "S7PointSpec":
+        if self.area == "DB" and self.db <= 0:
+            raise ValueError("db must be greater than 0 when area is DB")
+        if self.type != "bool" and self.bit is not None:
+            raise ValueError("bit is only supported for bool points")
+        return self
+
+
+class S7PointReadRequest(S7BaseRequest):
+    points: list[S7PointSpec] = Field(min_length=1)
+
+
+class S7ConnectScanRequest(S7IpRequest):
+    pass

+ 50 - 0
app/services/s7_codec.py

@@ -0,0 +1,50 @@
+from __future__ import annotations
+
+import struct
+from typing import Any
+
+
+def byte_size_for_type(point_type: str) -> int:
+    if point_type in {"bool", "byte", "int8"}:
+        return 1
+    if point_type in {"int16", "uint16"}:
+        return 2
+    if point_type in {"int64", "uint64", "float64"}:
+        return 8
+    return 4
+
+
+def bytes_to_hex(data: bytes | bytearray) -> str:
+    return " ".join(f"{value:02X}" for value in data)
+
+
+def convert_s7_value(data: bytes | bytearray, point_type: str, bit: int | None) -> Any:
+    payload = bytes(data)
+
+    if point_type == "bool":
+        value = payload[0]
+        if bit is None:
+            return value != 0
+        return ((value >> bit) & 1) == 1
+    if point_type == "byte":
+        return payload[0]
+    if point_type == "int8":
+        return int.from_bytes(payload, byteorder="big", signed=True)
+    if point_type == "int16":
+        return int.from_bytes(payload, byteorder="big", signed=True)
+    if point_type == "uint16":
+        return int.from_bytes(payload, byteorder="big", signed=False)
+    if point_type == "int32":
+        return int.from_bytes(payload, byteorder="big", signed=True)
+    if point_type == "uint32":
+        return int.from_bytes(payload, byteorder="big", signed=False)
+    if point_type == "int64":
+        return int.from_bytes(payload, byteorder="big", signed=True)
+    if point_type == "uint64":
+        return int.from_bytes(payload, byteorder="big", signed=False)
+    if point_type == "float32":
+        return struct.unpack(">f", payload)[0]
+    if point_type == "float64":
+        return struct.unpack(">d", payload)[0]
+
+    raise ValueError(f"unsupported point type: {point_type}")

+ 213 - 0
app/services/s7_service.py

@@ -0,0 +1,213 @@
+from __future__ import annotations
+
+from itertools import product
+from typing import Any
+
+from snap7.client import Client
+
+try:
+    from snap7.type import Area
+except ImportError:  # pragma: no cover - compatibility for older python-snap7 releases
+    Area = None
+
+from app.response import response_payload
+from app.schemas.s7 import S7ConnectScanRequest, S7PointReadRequest, S7PointSpec, S7RawReadRequest, S7ReadSpec
+from app.services.s7_codec import byte_size_for_type, bytes_to_hex, convert_s7_value
+
+
+CONNECTION_TYPES = {
+    "PG": 0x01,
+    "OP": 0x02,
+    "BASIC": 0x03,
+}
+
+AREA_CODES: dict[str, Any] = {
+    "I": 0x81,
+    "Q": 0x82,
+    "M": 0x83,
+    "DB": 0x84,
+}
+
+if Area is not None:
+    AREA_CODES.update({"I": Area.PE, "Q": Area.PA, "M": Area.MK, "DB": Area.DB})
+
+
+class S7CommunicationError(RuntimeError):
+    def __init__(self, message: str) -> None:
+        super().__init__(message)
+
+
+class S7CommunicationTrace:
+    def __init__(self) -> None:
+        self._messages: list[str] = []
+
+    def append(self, message: str) -> None:
+        self._messages.append(message)
+
+    def as_list(self) -> list[str]:
+        return list(self._messages)
+
+
+def device_payload(request: S7RawReadRequest | S7PointReadRequest) -> dict[str, Any]:
+    return {
+        "device_type": request.device_type,
+        "ip": request.ip,
+        "port": request.port,
+        "rock": request.rock,
+        "slot": request.slot,
+        "tsap_conn_type": request.tsap_conn_type,
+    }
+
+
+def connect_scan_device_payload(request: S7ConnectScanRequest) -> dict[str, Any]:
+    return {"device_type": "S7TCP", "ip": request.ip, "port": 102}
+
+
+def connect_client(ip: str, port: int, rock: int, slot: int, tsap_conn_type: str) -> Client:
+    client = Client()
+    try:
+        client.set_connection_type(CONNECTION_TYPES[tsap_conn_type])
+        client.connect(ip, rock, slot, tcp_port=port)
+    except TypeError:
+        try:
+            client.connect(ip, rock, slot, port)
+        except Exception:
+            close_client(client)
+            raise
+    except Exception:
+        close_client(client)
+        raise
+
+    if not client.get_connected():
+        close_client(client)
+        raise S7CommunicationError(f"failed to connect to {ip}:{port}")
+    return client
+
+
+def close_client(client: Client | None) -> None:
+    if client is None:
+        return
+    try:
+        if client.get_connected():
+            client.disconnect()
+    finally:
+        destroy = getattr(client, "destroy", None)
+        if callable(destroy):
+            destroy()
+
+
+def pdu_length_message(client: Client) -> str:
+    try:
+        return f" pdu_length={client.get_pdu_length()}"
+    except Exception:
+        return ""
+
+
+def read_s7_bytes(client: Client, read: S7ReadSpec | S7PointSpec, size: int) -> bytearray:
+    if read.area == "DB":
+        return client.db_read(read.db, read.start, size)
+    return client.read_area(AREA_CODES[read.area], 0, read.start, size)
+
+
+def trace_connect(trace: S7CommunicationTrace, request: S7RawReadRequest | S7PointReadRequest) -> None:
+    trace.append(
+        "Tx:S7_CONNECT "
+        f"ip={request.ip} port={request.port} rock={request.rock} slot={request.slot} "
+        f"tsap_conn_type={request.tsap_conn_type}"
+    )
+
+
+def trace_read(trace: S7CommunicationTrace, read: S7ReadSpec | S7PointSpec, size: int) -> None:
+    trace.append(f"Tx:S7_READ area={read.area} db={read.db} start={read.start} size={size}")
+
+
+def read_raw(request: S7RawReadRequest) -> dict[str, Any]:
+    trace = S7CommunicationTrace()
+    client: Client | None = None
+    error: Exception | None = None
+
+    try:
+        trace_connect(trace, request)
+        client = connect_client(request.ip, request.port, request.rock, request.slot, request.tsap_conn_type)
+        trace.append(f"Rx:S7_CONNECTED{pdu_length_message(client)}")
+
+        trace_read(trace, request.read, request.read.size)
+        payload = read_s7_bytes(client, request.read, request.read.size)
+        trace.append(f"Rx:{bytes_to_hex(payload)}")
+    except Exception as exc:
+        error = exc
+        trace.append(f"Rx:S7_ERROR message={exc}")
+    finally:
+        if client is not None:
+            trace.append("Tx:S7_DISCONNECT")
+            close_client(client)
+            trace.append("Rx:S7_DISCONNECTED")
+
+    if error is not None:
+        return response_payload(1, str(error), {"device": device_payload(request), "communication": trace.as_list()})
+    return response_payload(0, "success", {"device": device_payload(request), "communication": trace.as_list()})
+
+
+def read_points(request: S7PointReadRequest) -> dict[str, Any]:
+    trace = S7CommunicationTrace()
+    client: Client | None = None
+    points: list[dict[str, Any]] = []
+    error: Exception | None = None
+
+    try:
+        trace_connect(trace, request)
+        client = connect_client(request.ip, request.port, request.rock, request.slot, request.tsap_conn_type)
+        trace.append(f"Rx:S7_CONNECTED{pdu_length_message(client)}")
+
+        for point in request.points:
+            size = byte_size_for_type(point.type)
+            trace_read(trace, point, size)
+            payload = read_s7_bytes(client, point, size)
+            trace.append(f"Rx:{bytes_to_hex(payload)}")
+
+            result = {
+                "area": point.area,
+                "db": point.db,
+                "start": point.start,
+                "type": point.type,
+                "value": convert_s7_value(payload, point.type, point.bit),
+            }
+            if point.bit is not None:
+                result["bit"] = point.bit
+            points.append(result)
+    except Exception as exc:
+        error = exc
+        trace.append(f"Rx:S7_ERROR message={exc}")
+    finally:
+        if client is not None:
+            trace.append("Tx:S7_DISCONNECT")
+            close_client(client)
+            trace.append("Rx:S7_DISCONNECTED")
+
+    if error is not None:
+        return response_payload(
+            1,
+            str(error),
+            {"device": device_payload(request), "points": points},
+        )
+    return response_payload(
+        0,
+        "success",
+        {"device": device_payload(request), "points": points},
+    )
+
+
+def connect_scan(request: S7ConnectScanRequest) -> dict[str, Any]:
+    available: list[dict[str, Any]] = []
+
+    for rock, slot, tsap_conn_type in product(range(3), range(3), CONNECTION_TYPES):
+        client: Client | None = None
+        try:
+            client = connect_client(request.ip, 102, rock, slot, tsap_conn_type)
+            available.append({"rock": rock, "slot": slot, "tsap_conn_type": tsap_conn_type})
+        except Exception:
+            pass
+        finally:
+            close_client(client)
+
+    return response_payload(0, "success", {"device": connect_scan_device_payload(request), "available": available})

+ 648 - 0
docs/localhost-data-colllector-gateway.md

@@ -0,0 +1,648 @@
+# Data Collector Gateway HTTP API
+
+## 文档用途
+
+本文档用于说明 Data Collector Gateway 的 HTTP 接口,目标是让 AI 或自动化程序能够根据本文档直接构造请求、理解每个接口的用途、理解每个字段的含义,并正确解析返回结果。
+
+当前接口支持通过 HTTP 调用 Modbus TCP 和 Siemens S7 TCP 设备。S7 详细请求和响应约定见 [s7-http-api.md](s7-http-api.md)。
+
+## 服务器请求地址
+
+稳定环境服务器请求地址统一配置为:
+
+```text
+http://127.0.0.1:8000
+```
+
+接口完整请求地址拼接规则:
+
+```text
+完整请求地址 = 服务器请求地址 + 接口路径
+```
+
+示例:
+
+```text
+http://127.0.0.1:8000/api/dc-gateway/modbus/read
+```
+
+如果部署到其他服务器,只需要替换服务器请求地址,例如:
+
+```text
+http://<服务器IP或域名>:8000
+```
+
+## 通用约定
+
+### 请求格式
+
+所有 `POST` 接口都使用 JSON 请求体,并且请求头必须包含:
+
+```http
+Content-Type: application/json
+```
+
+### 返回格式
+
+除健康检查外,业务接口统一返回以下 JSON 结构:
+
+```json
+{
+  "code": 0,
+  "msg": "success",
+  "data": {}
+}
+```
+
+字段说明:
+
+| 字段 | 类型 | 必定返回 | 含义 |
+|---|---|---:|---|
+| `code` | integer | 是 | 业务状态码。`0` 表示成功,`1` 表示失败。调用方应优先用该字段判断业务是否成功。 |
+| `msg` | string | 是 | 业务消息。成功时通常为 `success`,失败时为错误原因。 |
+| `data` | object | 是 | 业务数据对象。不同接口的字段不同。 |
+
+### HTTP 状态码说明
+
+接口校验失败、设备连接失败、Modbus 通信失败时,通常仍返回 HTTP `200`,业务是否成功由响应体中的 `code` 判断。
+
+AI 调用接口时应按以下规则判断结果:
+
+1. HTTP 请求失败或超时:认为网关服务不可达。
+2. HTTP 返回成功但 JSON 中 `code = 0`:认为业务调用成功。
+3. HTTP 返回成功但 JSON 中 `code != 0`:认为业务调用失败,失败原因读取 `msg`。
+
+## 接口列表
+
+| 接口 | 方法 | 路径 | 用途 |
+|---|---|---|---|
+| 健康检查 | `GET` | `/api/dc-gateway/health` | 检查网关服务是否存活。 |
+| 原始 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` | 与 `/api/dc-gateway/modbus/read_points` 等价,建议优先使用下划线版本。 |
+| 原始 S7 读取 | `POST` | `/api/dc-gateway/s7/read` | 读取 S7 原始字节,只返回语义化 `communication`。 |
+| S7 点位读取并转换 | `POST` | `/api/dc-gateway/s7/read_points` | 按点位定义读取 S7 数据,并按数据类型转换为业务值。 |
+| S7 连接扫描 | `POST` | `/api/dc-gateway/s7/connect_scan` | 只传 IP,扫描可用的 `rock`、`slot`、`tsap_conn_type` 组合。 |
+
+## S7 接口快速说明
+
+S7 `/s7/read` 接口的 `communication` 是基于 `python-snap7` 调用过程生成的语义化记录,不是底层真实 `TPKT + COTP + S7Comm` 原始网络帧。`/s7/read_points` 不返回 `communication`。
+
+S7 通用设备字段:
+
+| 字段 | 类型 | 必填 | 默认值 | 允许值或范围 | 含义 |
+|---|---|---:|---|---|---|
+| `device_type` | string | 否 | `S7TCP` | 只能是 `S7TCP` | 设备协议类型。 |
+| `ip` | string | 是 | 无 | 合法 IPv4 或 IPv6 地址 | S7 设备 IP 地址。 |
+| `port` | integer | 否 | `102` | `1..65535` | S7 TCP 端口,通常固定为 `102`。 |
+| `rock` | integer | 是 | 无 | `0..31` | PLC rock。 |
+| `slot` | integer | 是 | 无 | `0..31` | PLC slot。 |
+| `tsap_conn_type` | string | 否 | `PG` | `PG`、`OP`、`BASIC` | Snap7 连接类型。 |
+
+S7 地址字段:
+
+| 字段 | 类型 | 必填 | 默认值 | 允许值或范围 | 含义 |
+|---|---|---:|---|---|---|
+| `area` | string | 是 | 无 | `DB`、`M`、`I`、`Q` | 读取区域。 |
+| `db` | integer | `area=DB` 时必填 | `0` | `>=0` | DB 块号,`area=DB` 时必须大于 `0`。 |
+| `start` | integer | 是 | 无 | `>=0` | 起始字节偏移。 |
+| `size` | integer | `/s7/read` 必填 | 无 | `1..65535` | 读取字节数。 |
+
+S7 连接扫描接口:
+
+```http
+POST /api/dc-gateway/s7/connect_scan
+```
+
+请求体:
+
+```json
+{
+  "ip": "192.168.1.10"
+}
+```
+
+返回只包含可用连接组合:
+
+```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"
+      }
+    ]
+  }
+}
+```
+
+## 通用设备字段
+
+以下字段用于描述要连接的 Modbus TCP 设备,出现在 `/modbus/read` 和 `/modbus/read_points` 请求体的顶层。
+
+| 字段 | 类型 | 必填 | 默认值 | 允许值或范围 | 含义 |
+|---|---|---:|---|---|---|
+| `device_type` | string | 否 | `ModbusTCP` | 只能是 `ModbusTCP` | 设备协议类型。当前仅支持 Modbus TCP。 |
+| `ip` | string | 是 | 无 | 合法 IPv4 或 IPv6 地址 | Modbus TCP 设备的 IP 地址,不是网关服务器地址。 |
+| `port` | integer | 是 | 无 | `1..65535` | Modbus TCP 设备监听端口。常见端口为 `502`,示例使用 `505`。 |
+| `word_byte_order` | string | 否 | `ABCD` | `ABCD`、`BADC`、`CDAB`、`DCBA` | 多寄存器数据的字节序和字序,用于 `int32`、`float32`、`int64`、`float64` 等寄存器值转换。 |
+| `address_base` | integer | 否 | `0` | `>= 0` | 地址偏移量。网关实际发送给 Modbus 协议的地址为 `address + address_base`。通常传 `0`。 |
+| `slave_id` | integer | 是 | 无 | `0..247` | Modbus 从站 ID,也称 Unit ID、Device ID 或 Slave Address。 |
+
+### 地址规则
+
+请求体中的 `address` 是调用方传入的逻辑地址。网关实际发送给 Modbus 协议的地址计算方式如下:
+
+```text
+protocol_address = address + address_base
+```
+
+如果设备文档给出的地址已经是从 `0` 开始的协议地址,则使用:
+
+```json
+"address_base": 0
+```
+
+如果设备文档用 `40001` 表示第一个保持寄存器,调用方需要先换算为协议地址 `0`,再请求接口。不要直接把 `40001` 当作 `address` 传入,除非明确需要这种偏移行为。
+
+### word_byte_order 说明
+
+`word_byte_order` 用于寄存器型多字节数据的转换。每个 Modbus 寄存器为 2 字节。
+
+| 值 | 含义 |
+|---|---|
+| `ABCD` | 默认顺序。寄存器内字节不反转,寄存器顺序不反转。 |
+| `BADC` | 每个寄存器内的两个字节反转,寄存器顺序不反转。 |
+| `CDAB` | 寄存器顺序反转,寄存器内字节不反转。 |
+| `DCBA` | 每个寄存器内字节反转,同时寄存器顺序反转。 |
+
+## Modbus 功能码
+
+| `function_code` | Modbus 名称 | 读取对象 | 返回原始类型 | 点位类型限制 |
+|---:|---|---|---|---|
+| `1` | Read Coils | 线圈 | bit/boolean | `/read_points` 中只能使用 `bool`。 |
+| `2` | Read Discrete Inputs | 离散输入 | bit/boolean | `/read_points` 中只能使用 `bool`。 |
+| `3` | Read Holding Registers | 保持寄存器 | 16 位 register | 可使用寄存器型数据类型,也可使用 `bool` 读取寄存器整体或指定位。 |
+| `4` | Read Input Registers | 输入寄存器 | 16 位 register | 可使用寄存器型数据类型,也可使用 `bool` 读取寄存器整体或指定位。 |
+
+## communication 字段格式
+
+`communication` 只在 `/api/dc-gateway/modbus/read` 返回,用于记录本次 HTTP 请求期间网关与 Modbus 设备之间的原始通信报文。
+
+示例:
+
+```text
+Tx:001-00 00 33 00 00 00 06 01 03 00 00 00 04
+Rx:002-00 00 33 00 00 00 0B 01 03 08 00 01 42 A8 00 00 40 F8
+```
+
+格式说明:
+
+| 片段 | 含义 |
+|---|---|
+| `Tx` | 网关发送给 Modbus 设备的 Modbus TCP ADU 报文。 |
+| `Rx` | Modbus 设备返回给网关的 Modbus TCP ADU 报文。 |
+| `001`、`002` | 当前 HTTP 请求内的报文序号,从 `001` 开始递增。 |
+| `-` 后内容 | 大写十六进制字节,字节之间使用空格分隔。 |
+
+补充说明:
+
+| 规则 | 说明 |
+|---|---|
+| trace 隔离 | 每个 HTTP 请求都有独立的 trace,并发请求不会互相清空或串包。 |
+| 重试报文 | 如果底层 Modbus 客户端发生重试,`communication` 中可能出现多组 `Tx` 或 `Rx`。 |
+| 失败场景 | 如果设备无响应,可能只有 `Tx` 没有 `Rx`。 |
+
+## 接口:健康检查
+
+### 基本信息
+
+| 项 | 值 |
+|---|---|
+| 方法 | `GET` |
+| 路径 | `/api/dc-gateway/health` |
+| 完整地址 | `http://127.0.0.1:8000/api/dc-gateway/health` |
+| 请求体 | 无 |
+| 用途 | 判断 Data Collector Gateway HTTP 服务是否启动。 |
+
+### 成功返回
+
+```json
+{
+  "status": "ok"
+}
+```
+
+返回字段说明:
+
+| 字段 | 类型 | 含义 |
+|---|---|---|
+| `status` | string | 服务状态。`ok` 表示 HTTP 服务存活。 |
+
+### 调用示例
+
+```bash
+curl http://127.0.0.1:8000/api/dc-gateway/health
+```
+
+## 接口:原始 Modbus 读取
+
+### 基本信息
+
+| 项 | 值 |
+|---|---|
+| 方法 | `POST` |
+| 路径 | `/api/dc-gateway/modbus/read` |
+| 完整地址 | `http://127.0.0.1:8000/api/dc-gateway/modbus/read` |
+| Content-Type | `application/json` |
+| 用途 | 向 Modbus TCP 设备发起一次读取请求,只返回通信报文,不返回解析后的业务值。 |
+
+### 请求体结构
+
+```json
+{
+  "device_type": "ModbusTCP",
+  "ip": "192.168.75.240",
+  "port": 505,
+  "word_byte_order": "ABCD",
+  "address_base": 0,
+  "slave_id": 1,
+  "read": {
+    "function_code": 3,
+    "address": 0,
+    "quantity": 4
+  }
+}
+```
+
+顶层字段说明见“通用设备字段”。
+
+`read` 字段说明:
+
+| 字段 | 类型 | 必填 | 默认值 | 允许值或范围 | 含义 |
+|---|---|---:|---|---|---|
+| `read` | object | 是 | 无 | 对象 | 本次读取的参数对象。 |
+| `read.function_code` | integer | 是 | 无 | `1`、`2`、`3`、`4` | Modbus 功能码,决定读取线圈、离散输入、保持寄存器或输入寄存器。 |
+| `read.address` | integer | 是 | 无 | `>= 0` | 起始地址。实际协议地址为 `read.address + address_base`。 |
+| `read.quantity` | integer | 是 | 无 | `1..125` | 读取数量。功能码 `1`、`2` 表示读取 bit 数量;功能码 `3`、`4` 表示读取 register 数量。 |
+
+### 成功返回示例
+
+```json
+{
+  "code": 0,
+  "msg": "success",
+  "data": {
+    "device": {
+      "device_type": "ModbusTCP",
+      "ip": "192.168.75.240",
+      "port": 505,
+      "word_byte_order": "ABCD",
+      "address_base": 0,
+      "slave_id": 1
+    },
+    "communication": [
+      "Tx:001-00 00 33 00 00 00 06 01 03 00 00 00 04",
+      "Rx:002-00 00 33 00 00 00 0B 01 03 08 00 01 42 A8 00 00 40 F8"
+    ]
+  }
+}
+```
+
+成功返回字段说明:
+
+| 字段 | 类型 | 含义 |
+|---|---|---|
+| `code` | integer | `0` 表示读取成功。 |
+| `msg` | string | 成功时为 `success`。 |
+| `data.device` | object | 本次请求实际使用的设备连接参数。 |
+| `data.device.device_type` | string | 设备类型,当前固定为 `ModbusTCP`。 |
+| `data.device.ip` | string | 目标 Modbus 设备 IP。 |
+| `data.device.port` | integer | 目标 Modbus 设备端口。 |
+| `data.device.word_byte_order` | string | 本次请求使用的字节序设置。原始读取不解析值,但仍会回显该参数。 |
+| `data.device.address_base` | integer | 本次请求使用的地址偏移。 |
+| `data.device.slave_id` | integer | 本次请求使用的 Modbus 从站 ID。 |
+| `data.communication` | string[] | 本次 Modbus TCP 原始 Tx/Rx 报文列表。 |
+
+### 失败返回示例
+
+设备无响应或通信失败:
+
+```json
+{
+  "code": 1,
+  "msg": "Modbus Error: [Input/Output] No response received after 3 retries, continue with next request",
+  "data": {
+    "device": {
+      "device_type": "ModbusTCP",
+      "ip": "192.168.75.240",
+      "port": 505,
+      "word_byte_order": "ABCD",
+      "address_base": 0,
+      "slave_id": 1
+    },
+    "communication": [
+      "Tx:001-00 00 33 00 00 00 06 01 03 00 00 00 04"
+    ]
+  }
+}
+```
+
+请求字段校验失败:
+
+```json
+{
+  "code": 1,
+  "msg": "read.quantity: Input should be less than or equal to 125",
+  "data": {
+    "communication": []
+  }
+}
+```
+
+失败返回字段说明:
+
+| 字段 | 类型 | 含义 |
+|---|---|---|
+| `code` | integer | `1` 表示失败。 |
+| `msg` | string | 失败原因,可能是连接失败、Modbus 错误或请求字段校验错误。 |
+| `data.device` | object | 如果请求已经通过基础校验,通常会回显设备参数。 |
+| `data.communication` | string[] | 失败前已经捕获到的通信报文。字段校验失败时通常为空数组。 |
+
+### 调用示例
+
+```bash
+curl -X POST http://127.0.0.1:8000/api/dc-gateway/modbus/read \
+  -H "Content-Type: application/json" \
+  -d '{
+    "device_type": "ModbusTCP",
+    "ip": "192.168.75.240",
+    "port": 505,
+    "word_byte_order": "ABCD",
+    "address_base": 0,
+    "slave_id": 1,
+    "read": {
+      "function_code": 3,
+      "address": 0,
+      "quantity": 4
+    }
+  }'
+```
+
+## 接口:点位读取并转换
+
+### 基本信息
+
+| 项 | 值 |
+|---|---|
+| 方法 | `POST` |
+| 推荐路径 | `/api/dc-gateway/modbus/read_points` |
+| 兼容路径 | `/api/dc-gateway/modbus/read-points` |
+| 完整地址 | `http://127.0.0.1:8000/api/dc-gateway/modbus/read_points` |
+| Content-Type | `application/json` |
+| 用途 | 按点位定义逐个读取 Modbus 数据,并把原始 bit/register 转换为 `bool`、`int16`、`float32` 等业务值。 |
+
+### 请求体结构
+
+```json
+{
+  "device_type": "ModbusTCP",
+  "ip": "192.168.75.240",
+  "port": 505,
+  "word_byte_order": "ABCD",
+  "address_base": 0,
+  "slave_id": 1,
+  "points": [
+    {
+      "function_code": 3,
+      "address": 7,
+      "type": "int16"
+    },
+    {
+      "function_code": 3,
+      "address": 1,
+      "type": "float32"
+    },
+    {
+      "function_code": 3,
+      "address": 10,
+      "type": "bool",
+      "bit": 2
+    },
+    {
+      "function_code": 1,
+      "address": 0,
+      "type": "bool"
+    }
+  ]
+}
+```
+
+顶层字段说明见“通用设备字段”。
+
+`points` 字段说明:
+
+| 字段 | 类型 | 必填 | 默认值 | 允许值或范围 | 含义 |
+|---|---|---:|---|---|---|
+| `points` | object[] | 是 | 无 | 至少 1 个元素 | 点位读取定义列表。网关会按数组顺序逐个读取。 |
+| `points[].function_code` | integer | 是 | 无 | `1`、`2`、`3`、`4` | 当前点位使用的 Modbus 功能码。 |
+| `points[].address` | integer | 是 | 无 | `>= 0` | 当前点位起始地址。实际协议地址为 `points[].address + address_base`。 |
+| `points[].type` | string | 是 | 无 | `bool`、`int16`、`uint16`、`int32`、`uint32`、`int64`、`uint64`、`float32`、`float64` | 当前点位的数据类型。接口会按该类型决定读取寄存器数量和转换方式。输入会被转换为小写。 |
+| `points[].bit` | integer 或 null | 否 | `null` | `0..15` | 仅寄存器点位支持。用于从一个 16 位寄存器中取某一位并返回布尔值。功能码 `1`、`2` 不允许传 `bit`。 |
+
+### 点位类型和读取长度
+
+| `type` | 读取长度 | 可用功能码 | 返回 JSON 类型 | 含义 |
+|---|---:|---|---|---|
+| `bool` | 功能码 `1`、`2` 为 1 bit;功能码 `3`、`4` 为 1 register | `1`、`2`、`3`、`4` | boolean | 布尔值。寄存器点位未传 `bit` 时,寄存器值非 `0` 返回 `true`;传 `bit` 时读取指定位。 |
+| `int16` | 1 register | `3`、`4` | integer | 有符号 16 位整数。 |
+| `uint16` | 1 register | `3`、`4` | integer | 无符号 16 位整数。 |
+| `int32` | 2 registers | `3`、`4` | integer | 有符号 32 位整数。 |
+| `uint32` | 2 registers | `3`、`4` | integer | 无符号 32 位整数。 |
+| `float32` | 2 registers | `3`、`4` | number | IEEE 754 单精度浮点数。 |
+| `int64` | 4 registers | `3`、`4` | integer | 有符号 64 位整数。 |
+| `uint64` | 4 registers | `3`、`4` | integer | 无符号 64 位整数。 |
+| `float64` | 4 registers | `3`、`4` | number | IEEE 754 双精度浮点数。 |
+
+### 点位校验规则
+
+| 规则 | 说明 |
+|---|---|
+| `function_code` 必须合法 | 只能是 `1`、`2`、`3`、`4`。 |
+| 线圈和离散输入只能读布尔值 | 当 `function_code` 为 `1` 或 `2` 时,`type` 必须是 `bool`。 |
+| 线圈和离散输入不能使用 `bit` | 当 `function_code` 为 `1` 或 `2` 时,不能传 `bit`。 |
+| 寄存器点位可以使用 `bit` | 当 `function_code` 为 `3` 或 `4` 且 `type` 为 `bool` 时,可用 `bit` 读取寄存器指定位。 |
+| 不允许额外字段 | 请求对象中出现未定义字段会校验失败。 |
+
+### 成功返回示例
+
+```json
+{
+  "code": 0,
+  "msg": "success",
+  "data": {
+    "device": {
+      "device_type": "ModbusTCP",
+      "ip": "192.168.75.240",
+      "port": 505,
+      "word_byte_order": "ABCD",
+      "address_base": 0,
+      "slave_id": 1
+    },
+    "points": [
+      {
+        "function_code": 3,
+        "address": 7,
+        "type": "int16",
+        "value": -321
+      },
+      {
+        "function_code": 3,
+        "address": 10,
+        "type": "bool",
+        "bit": 2,
+        "value": true
+      }
+    ]
+  }
+}
+```
+
+成功返回字段说明:
+
+| 字段 | 类型 | 含义 |
+|---|---|---|
+| `code` | integer | `0` 表示所有点位读取和转换成功。 |
+| `msg` | string | 成功时为 `success`。 |
+| `data.device` | object | 本次请求实际使用的设备连接参数。 |
+| `data.points` | object[] | 点位读取结果数组,顺序与请求体 `points` 一致。 |
+| `data.points[].function_code` | integer | 该点位使用的 Modbus 功能码。 |
+| `data.points[].address` | integer | 该点位请求中的逻辑地址,不包含 `address_base` 偏移。 |
+| `data.points[].type` | string | 该点位的数据类型,返回为小写。 |
+| `data.points[].bit` | integer | 仅当请求点位传入 `bit` 时返回。 |
+| `data.points[].value` | boolean、integer 或 number | 转换后的点位值。具体 JSON 类型由 `type` 决定。 |
+
+### 失败返回示例
+
+设备连接失败:
+
+```json
+{
+  "code": 1,
+  "msg": "failed to connect to 192.168.75.240:505",
+  "data": {
+    "device": {
+      "device_type": "ModbusTCP",
+      "ip": "192.168.75.240",
+      "port": 505,
+      "word_byte_order": "ABCD",
+      "address_base": 0,
+      "slave_id": 1
+    },
+    "points": []
+  }
+}
+```
+
+读取部分点位后失败:
+
+```json
+{
+  "code": 1,
+  "msg": "Modbus Error: [Input/Output] No response received after 3 retries, continue with next request",
+  "data": {
+    "device": {
+      "device_type": "ModbusTCP",
+      "ip": "192.168.75.240",
+      "port": 505,
+      "word_byte_order": "ABCD",
+      "address_base": 0,
+      "slave_id": 1
+    },
+    "points": [
+      {
+        "function_code": 3,
+        "address": 7,
+        "type": "int16",
+        "value": -321
+      }
+    ]
+  }
+}
+```
+
+失败返回字段说明:
+
+| 字段 | 类型 | 含义 |
+|---|---|---|
+| `code` | integer | `1` 表示失败。 |
+| `msg` | string | 失败原因,可能是连接失败、Modbus 错误或请求字段校验错误。 |
+| `data.device` | object | 如果请求已经通过基础校验,通常会回显设备参数。 |
+| `data.points` | object[] | 失败前已成功读取的点位。字段校验失败或连接失败时通常为空数组。 |
+
+### 调用示例
+
+```bash
+curl -X POST http://127.0.0.1:8000/api/dc-gateway/modbus/read_points \
+  -H "Content-Type: application/json" \
+  -d '{
+    "device_type": "ModbusTCP",
+    "ip": "192.168.75.240",
+    "port": 505,
+    "word_byte_order": "ABCD",
+    "address_base": 0,
+    "slave_id": 1,
+    "points": [
+      {
+        "function_code": 3,
+        "address": 7,
+        "type": "int16"
+      },
+      {
+        "function_code": 3,
+        "address": 1,
+        "type": "float32"
+      },
+      {
+        "function_code": 1,
+        "address": 0,
+        "type": "bool"
+      }
+    ]
+  }'
+```
+
+## AI 调用决策指南
+
+AI 或自动化程序选择接口时遵循以下规则:
+
+| 目标 | 应调用接口 | 原因 |
+|---|---|---|
+| 只想检查网关是否启动 | `GET /api/dc-gateway/health` | 不访问 Modbus 设备,只检查 HTTP 服务。 |
+| 需要查看原始 Modbus Tx/Rx 报文 | `POST /api/dc-gateway/modbus/read` | 返回 `communication`,适合调试通信、对比 Modbus Poll 报文。 |
+| 需要得到点位业务值 | `POST /api/dc-gateway/modbus/read_points` | 返回 `points[].value`,自动按类型转换。 |
+
+AI 构造请求时必须区分两个地址:
+
+| 地址 | 字段或配置 | 含义 |
+|---|---|---|
+| 网关服务器地址 | `http://127.0.0.1:8000` | HTTP API 服务地址,用于拼接请求 URL。 |
+| Modbus 设备地址 | 请求体 `ip` 和 `port` | 实际被读取的 Modbus TCP 设备地址。 |
+
+AI 构造 `/modbus/read_points` 点位时应按以下步骤:
+
+1. 根据设备点表选择 `function_code`。
+2. 将设备点表地址换算为从 `0` 开始的 Modbus 协议地址,填入 `address`。
+3. 如果需要统一偏移,再设置 `address_base`,否则保持 `0`。
+4. 根据点位数据类型设置 `type`。
+5. 如果读取保持寄存器或输入寄存器中的某一位,设置 `type` 为 `bool` 并填写 `bit`。
+6. 发送请求后检查响应体 `code`,不要只检查 HTTP 状态码。

+ 1 - 1
docs/modbus-http-api.md

@@ -47,7 +47,7 @@
 | `port` | 是 | `1 <= port <= 65535` |
 | `word_byte_order` | 否 | 默认 `ABCD`,可选 `ABCD`、`BADC`、`CDAB`、`DCBA` |
 | `address_base` | 否 | 默认 `0`,必须大于等于 `0` |
-| `slave_id` | 否 | 默认 `1`,范围 `0..247` |
+| `slave_id` | 是 | 范围 `0..247` |
 
 ## communication 格式
 

+ 377 - 0
docs/s7-http-api.md

@@ -0,0 +1,377 @@
+# S7 HTTP 接口说明
+
+## 目标
+
+本服务通过 HTTP 接口连接 Siemens S7 TCP 设备,支持连接组合扫描、原始字节读取、点位读取并转换。
+
+当前 S7 接口使用 `python-snap7` 实现。`/s7/read` 返回的 `communication` 是语义化通信记录,用于说明本次连接、读取和断开过程;它不是 Wireshark 中的 `TPKT + COTP + S7Comm` 原始网络帧。
+
+## 接口列表
+
+- `POST /api/dc-gateway/s7/read`:读取一段 S7 地址数据,只返回语义化 `communication`。
+- `POST /api/dc-gateway/s7/read_points`:按点位定义读取,并返回转换后的业务值。
+- `POST /api/dc-gateway/s7/connect_scan`:只传 `ip`,扫描可用的 `rock`、`slot`、`tsap_conn_type` 组合。
+
+## 通用返回约定
+
+业务接口统一返回 HTTP `200`,是否成功由 JSON 中的 `code` 判断。
+
+成功:
+
+```json
+{
+  "code": 0,
+  "msg": "success",
+  "data": {}
+}
+```
+
+失败:
+
+```json
+{
+  "code": 1,
+  "msg": "TCP : Unreachable peer",
+  "data": {}
+}
+```
+
+## S7 通用设备字段
+
+以下字段用于 `/s7/read` 和 `/s7/read_points`。
+
+| 字段 | 是否必传 | 默认值 | 校验 | 含义 |
+|---|---:|---|---|---|
+| `device_type` | 否 | `S7TCP` | 只能是 `S7TCP` | 设备协议类型。 |
+| `ip` | 是 | 无 | 合法 IP 地址 | S7 设备 IP 地址。 |
+| `port` | 否 | `102` | `1..65535` | S7 TCP 端口,通常固定为 `102`。 |
+| `rock` | 是 | 无 | `0..31` | PLC rock。 |
+| `slot` | 是 | 无 | `0..31` | PLC slot。 |
+| `tsap_conn_type` | 否 | `PG` | `PG`、`OP`、`BASIC` | Snap7 连接类型。 |
+
+`tsap_conn_type` 大小写不敏感,服务内部统一转为大写。
+
+连接类型映射:
+
+| tsap_conn_type | Snap7 值 |
+|---|---:|
+| `PG` | `0x01` |
+| `OP` | `0x02` |
+| `BASIC` | `0x03` |
+
+## S7 地址字段
+
+| 字段 | 是否必传 | 默认值 | 校验 | 含义 |
+|---|---:|---|---|---|
+| `area` | 是 | 无 | `DB`、`M`、`I`、`Q` | 读取区域。 |
+| `db` | `area=DB` 时必填 | `0` | `>=0` | DB 块号。`area=DB` 时必须大于 `0`。 |
+| `start` | 是 | 无 | `>=0` | 起始字节偏移。 |
+| `size` | `/s7/read` 必填 | 无 | `1..65535` | 读取字节数。 |
+
+区域说明:
+
+| area | 含义 |
+|---|---|
+| `DB` | 数据块 Data Block |
+| `M` | Merker 标志位区 |
+| `I` | 输入区 Inputs |
+| `Q` | 输出区 Outputs |
+
+## communication 格式
+
+`communication` 是字符串数组,记录连接、读取、断开过程。该字段只在 `/api/dc-gateway/s7/read` 返回,`/api/dc-gateway/s7/read_points` 不返回该字段。
+
+示例:
+
+```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 连接。 |
+| `Rx:S7_CONNECTED` | S7 连接成功,可能包含协商后的 `pdu_length`。 |
+| `Tx:S7_READ` | 网关准备读取指定区域、DB、起始偏移和字节数。 |
+| `Rx:11 22 33 44` | 读取返回的原始数据字节,十六进制大写,空格分隔。 |
+| `Tx:S7_DISCONNECT` | 网关准备断开连接。 |
+| `Rx:S7_DISCONNECTED` | 连接已断开。 |
+| `Rx:S7_ERROR` | 连接或读取失败,后面带错误消息。 |
+
+## 接口一:原始读取
+
+### URL
+
+```http
+POST /api/dc-gateway/s7/read
+```
+
+### 请求体
+
+```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
+  }
+}
+```
+
+### 成功返回
+
+```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"
+    ]
+  }
+}
+```
+
+## 接口二:点位读取并转换
+
+### URL
+
+```http
+POST /api/dc-gateway/s7/read_points
+```
+
+### 请求体
+
+```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
+    }
+  ]
+}
+```
+
+### 支持的数据类型
+
+| 类型 | 读取长度 | 说明 |
+|---|---:|---|
+| `bool` | 1 byte | 布尔值。传 `bit` 时读取指定 bit;不传 `bit` 时整个字节非 0 为 `true`。 |
+| `byte` | 1 byte | 无符号 8 位整数。 |
+| `int8` | 1 byte | 有符号 8 位整数。 |
+| `int16` | 2 bytes | 有符号 16 位整数。 |
+| `uint16` | 2 bytes | 无符号 16 位整数。 |
+| `int32` | 4 bytes | 有符号 32 位整数。 |
+| `uint32` | 4 bytes | 无符号 32 位整数。 |
+| `float32` | 4 bytes | IEEE 754 单精度浮点数。 |
+| `int64` | 8 bytes | 有符号 64 位整数。 |
+| `uint64` | 8 bytes | 无符号 64 位整数。 |
+| `float64` | 8 bytes | IEEE 754 双精度浮点数。 |
+
+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"
+    },
+    "points": [
+      {
+        "area": "DB",
+        "db": 1,
+        "start": 0,
+        "type": "float32",
+        "value": 12.34
+      },
+      {
+        "area": "DB",
+        "db": 1,
+        "start": 4,
+        "type": "bool",
+        "value": true,
+        "bit": 0
+      }
+    ]
+  }
+}
+```
+
+## 接口三:连接扫描
+
+### URL
+
+```http
+POST /api/dc-gateway/s7/connect_scan
+```
+
+### 请求体
+
+只需要传入设备 IP。端口固定使用 S7 TCP 默认端口 `102`。
+
+```json
+{
+  "ip": "192.168.1.10"
+}
+```
+
+### 扫描范围
+
+| 字段 | 扫描值 |
+|---|---|
+| `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"
+  }'
+```
+
+## 运行方式
+
+```bash
+uvicorn main:app --host 0.0.0.0 --port 8000
+```
+
+## 验证方式
+
+编译检查:
+
+```bash
+python -m compileall app main.py
+```
+
+S7 接口测试:
+
+```bash
+python -m unittest discover -s intergration/s7 -p "test_*.py"
+```
+
+S7 测试会在本机启动 `python-snap7` 模拟 server,用于验证 `/s7/read` 和 `/s7/read_points`;`/s7/connect_scan` 使用 mock 验证固定端口 `102` 和只返回可用组合的逻辑。

+ 62 - 2
intergration/modbus/common.py

@@ -1,11 +1,20 @@
+import atexit
 import json
 import os
 import re
+import socket
+import threading
+import time
 import urllib.error
 import urllib.request
+from contextlib import closing
 
+import uvicorn
 
-BASE_URL = os.getenv("DATA_COLLECTOR_BASE_URL", "http://127.0.0.1:8000").rstrip("/")
+from app.main import app
+
+
+CONFIGURED_BASE_URL = os.getenv("DATA_COLLECTOR_BASE_URL", "").rstrip("/")
 REQUEST_TIMEOUT = int(os.getenv("REQUEST_TIMEOUT", "20"))
 
 DEVICE_IP = os.getenv("MODBUS_DEVICE_IP", "192.168.75.240")
@@ -31,11 +40,62 @@ REGISTER_POINT_SAMPLES = [
 
 FRAME_PATTERN = re.compile(r"^(Tx|Rx):\d{3}-[0-9A-F]{2}(?: [0-9A-F]{2})*$")
 
+_LOCAL_API_SERVER = None
+
+
+def find_free_port() -> int:
+    with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock:
+        sock.bind(("127.0.0.1", 0))
+        return int(sock.getsockname()[1])
+
+
+class LocalApiServer:
+    def __init__(self) -> None:
+        self.port = find_free_port()
+        self.base_url = f"http://127.0.0.1:{self.port}"
+        config = uvicorn.Config(app, host="127.0.0.1", port=self.port, log_level="critical", access_log=False)
+        self.server = uvicorn.Server(config)
+        self.thread = threading.Thread(target=self.server.run, daemon=True)
+
+    def start(self) -> None:
+        self.thread.start()
+        deadline = time.time() + 10
+        while time.time() < deadline:
+            try:
+                with urllib.request.urlopen(f"{self.base_url}/api/dc-gateway/health", timeout=1) as response:
+                    if response.status == 200:
+                        return
+            except OSError:
+                time.sleep(0.05)
+        raise RuntimeError("local API test server did not start")
+
+    def stop(self) -> None:
+        self.server.should_exit = True
+        self.thread.join(timeout=5)
+
+
+def base_url() -> str:
+    global _LOCAL_API_SERVER
+    if CONFIGURED_BASE_URL:
+        return CONFIGURED_BASE_URL
+    if _LOCAL_API_SERVER is None:
+        _LOCAL_API_SERVER = LocalApiServer()
+        _LOCAL_API_SERVER.start()
+    return _LOCAL_API_SERVER.base_url
+
+
+def stop_local_api_server() -> None:
+    if _LOCAL_API_SERVER is not None:
+        _LOCAL_API_SERVER.stop()
+
+
+atexit.register(stop_local_api_server)
+
 
 def post_json(path: str, payload: dict) -> tuple[int, dict]:
     data = json.dumps(payload).encode("utf-8")
     request = urllib.request.Request(
-        f"{BASE_URL}{path}",
+        f"{base_url()}{path}",
         data=data,
         headers={"Content-Type": "application/json"},
         method="POST",

+ 143 - 0
intergration/s7/common.py

@@ -0,0 +1,143 @@
+import json
+import logging
+import socket
+import threading
+import time
+import urllib.error
+import urllib.request
+from contextlib import closing
+from unittest.mock import patch
+
+import uvicorn
+from snap7.server import Server
+from snap7.type import SrvArea
+
+from app.main import app
+
+
+REQUEST_TIMEOUT = 10
+
+logging.getLogger("snap7.server").setLevel(logging.CRITICAL)
+
+
+def find_free_port() -> int:
+    with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock:
+        sock.bind(("127.0.0.1", 0))
+        return int(sock.getsockname()[1])
+
+
+class ApiTestServer:
+    def __init__(self) -> None:
+        self.port = find_free_port()
+        self.base_url = f"http://127.0.0.1:{self.port}"
+        config = uvicorn.Config(app, host="127.0.0.1", port=self.port, log_level="critical", access_log=False)
+        self.server = uvicorn.Server(config)
+        self.thread = threading.Thread(target=self.server.run, daemon=True)
+
+    def __enter__(self) -> "ApiTestServer":
+        self.thread.start()
+        deadline = time.time() + 10
+        while time.time() < deadline:
+            try:
+                with urllib.request.urlopen(f"{self.base_url}/api/dc-gateway/health", timeout=1) as response:
+                    if response.status == 200:
+                        return self
+            except OSError:
+                time.sleep(0.05)
+        raise RuntimeError("API test server did not start")
+
+    def __exit__(self, exc_type, exc_value, traceback) -> None:
+        self.server.should_exit = True
+        self.thread.join(timeout=5)
+
+    def post_json(self, path: str, payload: dict) -> tuple[int, dict]:
+        data = json.dumps(payload).encode("utf-8")
+        request = urllib.request.Request(
+            f"{self.base_url}{path}",
+            data=data,
+            headers={"Content-Type": "application/json"},
+            method="POST",
+        )
+        try:
+            with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT) as response:
+                body = response.read().decode("utf-8")
+                return response.status, json.loads(body)
+        except urllib.error.HTTPError as exc:
+            body = exc.read().decode("utf-8")
+            return exc.code, json.loads(body)
+
+
+class Snap7TestServer:
+    def __init__(
+        self,
+        db_data: bytearray | None = None,
+        input_data: bytearray | None = None,
+        output_data: bytearray | None = None,
+        memory_data: bytearray | None = None,
+    ) -> None:
+        self.port = find_free_port()
+        self.db_data = db_data
+        self.input_data = input_data
+        self.output_data = output_data
+        self.memory_data = memory_data
+        self.server = Server()
+
+    def __enter__(self) -> "Snap7TestServer":
+        if self.db_data is not None:
+            self.server.register_area(SrvArea.DB, 1, self.db_data)
+        if self.input_data is not None:
+            self.server.register_area(SrvArea.PE, 0, self.input_data)
+        if self.output_data is not None:
+            self.server.register_area(SrvArea.PA, 0, self.output_data)
+        if self.memory_data is not None:
+            self.server.register_area(SrvArea.MK, 0, self.memory_data)
+        self.server.start(tcp_port=self.port)
+        time.sleep(0.2)
+        return self
+
+    def __exit__(self, exc_type, exc_value, traceback) -> None:
+        self.server.stop()
+        destroy = getattr(self.server, "destroy", None)
+        if callable(destroy):
+            destroy()
+
+
+class FakeSnap7Client:
+    def get_connected(self) -> bool:
+        return False
+
+    def disconnect(self) -> None:
+        pass
+
+    def destroy(self) -> None:
+        pass
+
+
+def patch_connect_scan(successful: set[tuple[int, int, str]]):
+    def fake_connect_client(ip: str, port: int, rock: int, slot: int, tsap_conn_type: str) -> FakeSnap7Client:
+        if port != 102:
+            raise AssertionError("connect_scan must use fixed S7 port 102")
+        if (rock, slot, tsap_conn_type) not in successful:
+            raise RuntimeError("connection failed")
+        return FakeSnap7Client()
+
+    return patch("app.services.s7_service.connect_client", side_effect=fake_connect_client)
+
+
+def s7_device_payload(port: int) -> dict:
+    return {
+        "device_type": "S7TCP",
+        "ip": "127.0.0.1",
+        "port": port,
+        "rock": 0,
+        "slot": 1,
+        "tsap_conn_type": "PG",
+    }
+
+
+def assert_response_contract(testcase, status: int, data: dict) -> None:
+    testcase.assertEqual(200, status, data)
+    testcase.assertIn("code", data)
+    testcase.assertIn("msg", data)
+    testcase.assertIn("data", data)
+    testcase.assertIsInstance(data["data"], dict)

+ 108 - 0
intergration/s7/test_s7_all_type_points.py

@@ -0,0 +1,108 @@
+import struct
+import unittest
+
+from common import ApiTestServer, Snap7TestServer, assert_response_contract, s7_device_payload
+
+
+AREA_SIZE = 88
+
+ALL_TYPE_SAMPLES = [
+    {"name": "bool", "start": 0, "type": "bool", "bit": 0, "value": True, "size": 1},
+    {"name": "usint", "start": 1, "type": "byte", "value": 124, "size": 1},
+    {"name": "sint", "start": 2, "type": "int8", "value": -100, "size": 1},
+    {"name": "uint", "start": 4, "type": "uint16", "value": 5423, "size": 2},
+    {"name": "int", "start": 6, "type": "int16", "value": 16372, "size": 2},
+    {"name": "udint", "start": 8, "type": "uint32", "value": 92651245, "size": 4},
+    {"name": "dint", "start": 12, "type": "int32", "value": 1827375, "size": 4},
+    {"name": "float", "start": 16, "type": "float32", "value": 2123.41, "size": 4},
+    {"name": "double", "start": 20, "type": "float64", "value": 5123.1, "size": 8},
+]
+
+
+def set_bool(memory: bytearray, byte_index: int, bit_index: int, value: bool) -> None:
+    if value:
+        memory[byte_index] |= 1 << bit_index
+    else:
+        memory[byte_index] &= ~(1 << bit_index)
+
+
+def build_all_type_memory() -> bytearray:
+    memory = bytearray(AREA_SIZE)
+    set_bool(memory, 0, 0, True)
+    memory[1:2] = struct.pack(">B", 124)
+    memory[2:3] = struct.pack(">b", -100)
+    memory[4:6] = struct.pack(">H", 5423)
+    memory[6:8] = struct.pack(">h", 16372)
+    memory[8:12] = struct.pack(">I", 92651245)
+    memory[12:16] = struct.pack(">i", 1827375)
+    memory[16:20] = struct.pack(">f", 2123.41)
+    memory[20:28] = struct.pack(">d", 5123.1)
+    return memory
+
+
+def point_specs_for_area(area: str) -> list[dict]:
+    specs = []
+    for sample in ALL_TYPE_SAMPLES:
+        spec = {"area": area, "db": 1 if area == "DB" else 0, "start": sample["start"], "type": sample["type"]}
+        if "bit" in sample:
+            spec["bit"] = sample["bit"]
+        specs.append(spec)
+    return specs
+
+
+class S7AllTypePointsIntegrationTest(unittest.TestCase):
+    def test_read_points_returns_all_reference_types_from_db_area(self):
+        memory = build_all_type_memory()
+        with Snap7TestServer(db_data=memory) as s7_server, ApiTestServer() as api_server:
+            payload = {**s7_device_payload(s7_server.port), "points": point_specs_for_area("DB")}
+
+            status, data = api_server.post_json("/api/dc-gateway/s7/read_points", payload)
+
+        self.assert_all_type_response(status, data, payload, "DB")
+
+    def test_read_points_returns_all_reference_types_from_i_q_m_areas(self):
+        memory = build_all_type_memory()
+        with Snap7TestServer(
+            input_data=bytearray(memory),
+            output_data=bytearray(memory),
+            memory_data=bytearray(memory),
+        ) as s7_server, ApiTestServer() as api_server:
+            payload = {
+                **s7_device_payload(s7_server.port),
+                "points": point_specs_for_area("I") + point_specs_for_area("Q") + point_specs_for_area("M"),
+            }
+
+            status, data = api_server.post_json("/api/dc-gateway/s7/read_points", payload)
+
+        assert_response_contract(self, status, data)
+        self.assertEqual(0, data["code"], data)
+        self.assertEqual(s7_device_payload(payload["port"]), data["data"]["device"])
+        self.assertEqual(len(payload["points"]), len(data["data"]["points"]))
+        for area in ["I", "Q", "M"]:
+            self.assert_area_values(data["data"]["points"], area)
+        self.assertNotIn("communication", data["data"])
+
+    def assert_all_type_response(self, status: int, data: dict, payload: dict, area: str) -> None:
+        assert_response_contract(self, status, data)
+        self.assertEqual(0, data["code"], data)
+        self.assertEqual("success", data["msg"])
+        self.assertEqual(s7_device_payload(payload["port"]), data["data"]["device"])
+        self.assertEqual(len(payload["points"]), len(data["data"]["points"]))
+        self.assert_area_values(data["data"]["points"], area)
+        self.assertNotIn("communication", data["data"])
+
+    def assert_area_values(self, points: list[dict], area: str) -> None:
+        by_key = {(point["area"], point["start"], point["type"], point.get("bit")): point for point in points}
+        for sample in ALL_TYPE_SAMPLES:
+            key = (area, sample["start"], sample["type"], sample.get("bit"))
+            self.assertIn(key, by_key)
+            actual = by_key[key]["value"]
+            if sample["type"] == "float32":
+                self.assertAlmostEqual(sample["value"], actual, places=2)
+            elif sample["type"] == "float64":
+                self.assertAlmostEqual(sample["value"], actual, places=6)
+            else:
+                self.assertEqual(sample["value"], actual)
+
+if __name__ == "__main__":
+    unittest.main()

+ 98 - 0
intergration/s7/test_s7_api.py

@@ -0,0 +1,98 @@
+import struct
+import unittest
+
+from common import ApiTestServer, Snap7TestServer, assert_response_contract, patch_connect_scan, s7_device_payload
+
+
+class S7ApiIntegrationTest(unittest.TestCase):
+    def test_raw_read_returns_semantic_communication_from_simulated_server(self):
+        db_data = bytearray([0x11, 0x22, 0x33, 0x44, 0x01])
+        with Snap7TestServer(db_data) as s7_server, ApiTestServer() as api_server:
+            payload = {
+                **s7_device_payload(s7_server.port),
+                "read": {"area": "DB", "db": 1, "start": 0, "size": 4},
+            }
+
+            status, data = api_server.post_json("/api/dc-gateway/s7/read", payload)
+
+        assert_response_contract(self, status, data)
+        self.assertEqual(0, data["code"], data)
+        self.assertEqual("success", data["msg"])
+        self.assertEqual(s7_device_payload(payload["port"]), data["data"]["device"])
+        self.assertNotIn("points", data["data"])
+
+        communication = data["data"]["communication"]
+        self.assertIn(
+            f"Tx:S7_CONNECT ip=127.0.0.1 port={payload['port']} rock=0 slot=1 tsap_conn_type=PG",
+            communication,
+        )
+        self.assertTrue(any(item.startswith("Rx:S7_CONNECTED") for item in communication), communication)
+        self.assertIn("Tx:S7_READ area=DB db=1 start=0 size=4", communication)
+        self.assertIn("Rx:11 22 33 44", communication)
+        self.assertIn("Tx:S7_DISCONNECT", communication)
+        self.assertIn("Rx:S7_DISCONNECTED", communication)
+
+    def test_read_points_returns_values_without_communication_from_simulated_server(self):
+        db_data = bytearray(struct.pack(">f", 12.34) + b"\x01" + (-123).to_bytes(2, "big", signed=True))
+        with Snap7TestServer(db_data) as s7_server, ApiTestServer() as api_server:
+            payload = {
+                **s7_device_payload(s7_server.port),
+                "points": [
+                    {"area": "DB", "db": 1, "start": 0, "type": "float32"},
+                    {"area": "DB", "db": 1, "start": 4, "type": "bool", "bit": 0},
+                    {"area": "DB", "db": 1, "start": 5, "type": "int16"},
+                ],
+            }
+
+            status, data = api_server.post_json("/api/dc-gateway/s7/read_points", payload)
+
+        assert_response_contract(self, status, data)
+        self.assertEqual(0, data["code"], data)
+        self.assertEqual(s7_device_payload(payload["port"]), data["data"]["device"])
+        self.assertNotIn("communication", data["data"])
+
+        points = data["data"]["points"]
+        self.assertEqual(3, len(points))
+        self.assertAlmostEqual(12.34, points[0]["value"], places=4)
+        self.assertIs(True, points[1]["value"])
+        self.assertEqual(0, points[1]["bit"])
+        self.assertEqual(-123, points[2]["value"])
+
+    def test_connect_scan_returns_only_available_connections(self):
+        successful = {(0, 1, "PG"), (0, 2, "BASIC")}
+        with patch_connect_scan(successful), ApiTestServer() as api_server:
+            status, data = api_server.post_json("/api/dc-gateway/s7/connect_scan", {"ip": "127.0.0.1"})
+
+        assert_response_contract(self, status, data)
+        self.assertEqual(0, data["code"], data)
+        self.assertEqual({"device_type": "S7TCP", "ip": "127.0.0.1", "port": 102}, data["data"]["device"])
+        self.assertEqual(
+            [
+                {"rock": 0, "slot": 1, "tsap_conn_type": "PG"},
+                {"rock": 0, "slot": 2, "tsap_conn_type": "BASIC"},
+            ],
+            data["data"]["available"],
+        )
+        self.assertNotIn("results", data["data"])
+
+    def test_validation_error_returns_http_200_contract(self):
+        with ApiTestServer() as api_server:
+            status, data = api_server.post_json(
+                "/api/dc-gateway/s7/read_points",
+                {
+                    "ip": "127.0.0.1",
+                    "rock": 0,
+                    "slot": 1,
+                    "points": [{"area": "DB", "db": 0, "start": 0, "type": "int16"}],
+                },
+            )
+
+        assert_response_contract(self, status, data)
+        self.assertEqual(1, data["code"], data)
+        self.assertIn("db", data["msg"])
+        self.assertEqual([], data["data"]["points"])
+        self.assertNotIn("communication", data["data"])
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 63 - 0
intergration/s7/test_s7_schema.py

@@ -0,0 +1,63 @@
+import unittest
+
+from pydantic import ValidationError
+
+from app.schemas.s7 import S7ConnectScanRequest, S7PointReadRequest, S7RawReadRequest
+
+
+class S7SchemaTest(unittest.TestCase):
+    def test_device_type_port_and_connection_type_defaults(self):
+        request = S7RawReadRequest(
+            ip="127.0.0.1",
+            rock=0,
+            slot=1,
+            read={"area": "db", "db": 1, "start": 0, "size": 4},
+        )
+
+        self.assertEqual("S7TCP", request.device_type)
+        self.assertEqual(102, request.port)
+        self.assertEqual("PG", request.tsap_conn_type)
+        self.assertEqual("DB", request.read.area)
+
+    def test_tsap_conn_type_is_case_insensitive(self):
+        request = S7RawReadRequest(
+            ip="127.0.0.1",
+            rock=0,
+            slot=1,
+            tsap_conn_type="basic",
+            read={"area": "m", "start": 0, "size": 1},
+        )
+
+        self.assertEqual("BASIC", request.tsap_conn_type)
+        self.assertEqual("M", request.read.area)
+
+    def test_db_area_requires_positive_db_number(self):
+        with self.assertRaises(ValidationError) as context:
+            S7RawReadRequest(
+                ip="127.0.0.1",
+                rock=0,
+                slot=1,
+                read={"area": "DB", "db": 0, "start": 0, "size": 1},
+            )
+
+        self.assertIn("db must be greater than 0", str(context.exception))
+
+    def test_bit_is_only_allowed_for_bool_points(self):
+        with self.assertRaises(ValidationError) as context:
+            S7PointReadRequest(
+                ip="127.0.0.1",
+                rock=0,
+                slot=1,
+                points=[{"area": "DB", "db": 1, "start": 0, "type": "int16", "bit": 0}],
+            )
+
+        self.assertIn("bit is only supported for bool points", str(context.exception))
+
+    def test_connect_scan_request_only_requires_ip(self):
+        request = S7ConnectScanRequest(ip="127.0.0.1")
+
+        self.assertEqual("127.0.0.1", request.ip)
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 2 - 1
pyproject.toml

@@ -8,6 +8,7 @@ dependencies = [
     "fastapi>=0.115.0",
     "pydantic>=2.13.4",
     "pymodbus>=3.13.1",
+    "python-snap7>=3.0.0",
     "uvicorn>=0.32.0",
 ]
 
@@ -16,4 +17,4 @@ data-collector-api = "main:main"
 
 [[tool.uv.index]]
 url = "https://pypi.tuna.tsinghua.edu.cn/simple"
-default = true
+default = true

+ 120 - 109
uv.lock

@@ -5,53 +5,53 @@ requires-python = ">=3.12"
 [[package]]
 name = "annotated-doc"
 version = "0.0.4"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
+source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
+sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
 ]
 
 [[package]]
 name = "annotated-types"
 version = "0.7.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
+source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
+sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
 ]
 
 [[package]]
 name = "anyio"
 version = "4.13.0"
-source = { registry = "https://pypi.org/simple" }
+source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
 dependencies = [
     { name = "idna" },
     { name = "typing-extensions", marker = "python_full_version < '3.13'" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
+sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
 ]
 
 [[package]]
 name = "click"
 version = "8.4.1"
-source = { registry = "https://pypi.org/simple" }
+source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
 dependencies = [
     { name = "colorama", marker = "sys_platform == 'win32'" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" }
+sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" },
 ]
 
 [[package]]
 name = "colorama"
 version = "0.4.6"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
+sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
 ]
 
 [[package]]
@@ -62,6 +62,7 @@ dependencies = [
     { name = "fastapi" },
     { name = "pydantic" },
     { name = "pymodbus" },
+    { name = "python-snap7" },
     { name = "uvicorn" },
 ]
 
@@ -70,13 +71,14 @@ requires-dist = [
     { name = "fastapi", specifier = ">=0.115.0" },
     { name = "pydantic", specifier = ">=2.13.4" },
     { name = "pymodbus", specifier = ">=3.13.1" },
+    { name = "python-snap7", specifier = ">=3.0.0" },
     { name = "uvicorn", specifier = ">=0.32.0" },
 ]
 
 [[package]]
 name = "fastapi"
 version = "0.137.0"
-source = { registry = "https://pypi.org/simple" }
+source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
 dependencies = [
     { name = "annotated-doc" },
     { name = "pydantic" },
@@ -84,171 +86,180 @@ dependencies = [
     { name = "typing-extensions" },
     { name = "typing-inspection" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/da/fe/fb25c287ff7e0f79fc6acf2e8b812725dad28d2a1446c0410bab1422ac90/fastapi-0.137.0.tar.gz", hash = "sha256:d0565d551f65a803ecff245390840867186f456ef98971f750724eed16e1541c", size = 408023, upload-time = "2026-06-14T12:51:30.672Z" }
+sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/da/fe/fb25c287ff7e0f79fc6acf2e8b812725dad28d2a1446c0410bab1422ac90/fastapi-0.137.0.tar.gz", hash = "sha256:d0565d551f65a803ecff245390840867186f456ef98971f750724eed16e1541c", size = 408023, upload-time = "2026-06-14T12:51:30.672Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/e7/f1/b38481428e50131e5345b535414d11d196f14990122fe69c9020c64e5683/fastapi-0.137.0-py3-none-any.whl", hash = "sha256:6dcbde8d464f92117c1accb9e42720f8e423fa9b86cb563b1f5862f785a06498", size = 121777, upload-time = "2026-06-14T12:51:29.067Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e7/f1/b38481428e50131e5345b535414d11d196f14990122fe69c9020c64e5683/fastapi-0.137.0-py3-none-any.whl", hash = "sha256:6dcbde8d464f92117c1accb9e42720f8e423fa9b86cb563b1f5862f785a06498", size = 121777, upload-time = "2026-06-14T12:51:29.067Z" },
 ]
 
 [[package]]
 name = "h11"
 version = "0.16.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
+source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
+sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
 ]
 
 [[package]]
 name = "idna"
 version = "3.18"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
+source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
+sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
 ]
 
 [[package]]
 name = "pydantic"
 version = "2.13.4"
-source = { registry = "https://pypi.org/simple" }
+source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
 dependencies = [
     { name = "annotated-types" },
     { name = "pydantic-core" },
     { name = "typing-extensions" },
     { name = "typing-inspection" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
+sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
 ]
 
 [[package]]
 name = "pydantic-core"
 version = "2.46.4"
-source = { registry = "https://pypi.org/simple" }
+source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
 dependencies = [
     { name = "typing-extensions" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
-    { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
-    { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
-    { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
-    { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
-    { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
-    { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
-    { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
-    { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
-    { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
-    { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
-    { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
-    { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
-    { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
-    { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
-    { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
-    { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
-    { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
-    { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
-    { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
-    { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
-    { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
-    { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
-    { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
-    { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
-    { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
-    { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
-    { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
-    { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
-    { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
-    { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
-    { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
-    { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
-    { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
-    { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
-    { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
-    { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
-    { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
-    { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
-    { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
-    { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
-    { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
-    { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
-    { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
-    { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
-    { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
-    { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
-    { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
-    { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
-    { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
-    { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
-    { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
-    { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
-    { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
-    { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
-    { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
-    { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
-    { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
-    { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
-    { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
-    { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
-    { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
-    { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
-    { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
+sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
+wheels = [
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
 ]
 
 [[package]]
 name = "pymodbus"
 version = "3.13.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/c2/e4/68aa328d8c07583b1fe382f231d614bc1ebf3a86aa09db1a9c045c33c1df/pymodbus-3.13.1.tar.gz", hash = "sha256:7a74ea0a4eb4895f518b34de32915ba4fde216576e09deaf735a279a9281af4f", size = 166178, upload-time = "2026-06-13T17:02:09.885Z" }
+source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
+sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c2/e4/68aa328d8c07583b1fe382f231d614bc1ebf3a86aa09db1a9c045c33c1df/pymodbus-3.13.1.tar.gz", hash = "sha256:7a74ea0a4eb4895f518b34de32915ba4fde216576e09deaf735a279a9281af4f", size = 166178, upload-time = "2026-06-13T17:02:09.885Z" }
+wheels = [
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/df/84/cdd3f6af834dac9e3bfaeca1f37c4dbfd305943305670c48499950e4e1cd/pymodbus-3.13.1-py3-none-any.whl", hash = "sha256:820167a9c6a13d698d7ff49e8420f8fbbdd8fec3f75aacd46a35f4ab9a000144", size = 166529, upload-time = "2026-06-13T17:02:08.374Z" },
+]
+
+[[package]]
+name = "python-snap7"
+version = "3.0.0"
+source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
+sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d3/1a/89e8057b11f8537ccb92bbe0c42cd94069cb79571a0073355942063c3bd9/python_snap7-3.0.0.tar.gz", hash = "sha256:035807be8464cb15ad6833abf69d003d91e816ef690351dafef12448867d5c69", size = 110338, upload-time = "2026-03-18T09:03:45.584Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/df/84/cdd3f6af834dac9e3bfaeca1f37c4dbfd305943305670c48499950e4e1cd/pymodbus-3.13.1-py3-none-any.whl", hash = "sha256:820167a9c6a13d698d7ff49e8420f8fbbdd8fec3f75aacd46a35f4ab9a000144", size = 166529, upload-time = "2026-06-13T17:02:08.374Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ed/e4/d5b65c8fe8a0a9f449f9ea41593d839f2b139ba2ae6db6e25e51f1e690fb/python_snap7-3.0.0-py3-none-any.whl", hash = "sha256:309d2c9cc217cc5b626394d971bfd92e2fc61b42d12c0857f65e3fa1c800ae9b", size = 85371, upload-time = "2026-03-18T09:03:43.934Z" },
 ]
 
 [[package]]
 name = "starlette"
 version = "1.3.1"
-source = { registry = "https://pypi.org/simple" }
+source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
 dependencies = [
     { name = "anyio" },
     { name = "typing-extensions", marker = "python_full_version < '3.13'" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" }
+sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
 ]
 
 [[package]]
 name = "typing-extensions"
 version = "4.15.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
+source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
+sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
 ]
 
 [[package]]
 name = "typing-inspection"
 version = "0.4.2"
-source = { registry = "https://pypi.org/simple" }
+source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
 dependencies = [
     { name = "typing-extensions" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
+sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
 ]
 
 [[package]]
 name = "uvicorn"
 version = "0.49.0"
-source = { registry = "https://pypi.org/simple" }
+source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
 dependencies = [
     { name = "click" },
     { name = "h11" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" }
+sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" },
+    { url = "https://pypi.tuna.tsinghua.edu.cn/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" },
 ]