瀏覽代碼

Fist commit

Lu Xianghui 3 天之前
當前提交
58d3f89f36
共有 51 個文件被更改,包括 2869 次插入0 次删除
  1. 3 0
      .gitignore
  2. 24 0
      Dockerfile
  3. 47 0
      README.md
  4. 二進制
      __pycache__/app_config.cpython-310.pyc
  5. 二進制
      __pycache__/constants.cpython-310.pyc
  6. 二進制
      __pycache__/db.cpython-310.pyc
  7. 二進制
      __pycache__/http_value_provider.cpython-310.pyc
  8. 二進制
      __pycache__/logging_config.cpython-310.pyc
  9. 二進制
      __pycache__/main.cpython-310.pyc
  10. 二進制
      __pycache__/modbus_codec.cpython-310.pyc
  11. 二進制
      __pycache__/modbus_context.cpython-310.pyc
  12. 二進制
      __pycache__/modbus_server.cpython-310.pyc
  13. 二進制
      __pycache__/point_loader.cpython-310.pyc
  14. 二進制
      __pycache__/point_model.cpython-310.pyc
  15. 二進制
      __pycache__/register_store.cpython-310.pyc
  16. 二進制
      __pycache__/value_provider.cpython-310.pyc
  17. 二進制
      __pycache__/value_refresh.cpython-310.pyc
  18. 100 0
      app_config.py
  19. 20 0
      config.yaml
  20. 4 0
      constants.py
  21. 15 0
      db.py
  22. 23 0
      http_value_provider.py
  23. 28 0
      logging_config.py
  24. 111 0
      main.py
  25. 19 0
      modbus_codec.py
  26. 38 0
      modbus_context.py
  27. 54 0
      modbus_server.py
  28. 423 0
      poetry.lock
  29. 81 0
      point_loader.py
  30. 20 0
      point_model.py
  31. 22 0
      pyproject.toml
  32. 70 0
      register_store.py
  33. 二進制
      tests/__pycache__/stress_modbus_clients.cpython-310.pyc
  34. 二進制
      tests/__pycache__/test_app_config.cpython-310.pyc
  35. 二進制
      tests/__pycache__/test_modbus_codec.cpython-310.pyc
  36. 二進制
      tests/__pycache__/test_point_loader.cpython-310.pyc
  37. 二進制
      tests/__pycache__/test_register_store_context.cpython-310.pyc
  38. 二進制
      tests/__pycache__/test_value_refresh.cpython-310.pyc
  39. 256 0
      tests/compare_modbus_http_client.py
  40. 161 0
      tests/import_modbus_server_points.py
  41. 242 0
      tests/stress_modbus_clients.py
  42. 56 0
      tests/test_app_config.py
  43. 23 0
      tests/test_modbus_codec.py
  44. 104 0
      tests/test_point_loader.py
  45. 44 0
      tests/test_register_store_context.py
  46. 89 0
      tests/test_value_refresh.py
  47. 9 0
      value_provider.py
  48. 76 0
      value_refresh.py
  49. 19 0
      wiki/init.sql
  50. 二進制
      wiki/mock_modbus_server_points.xlsx
  51. 688 0
      wiki/modbus_server_design_and_implementation.md

+ 3 - 0
.gitignore

@@ -0,0 +1,3 @@
+.venv
+.idea
+logs

+ 24 - 0
Dockerfile

@@ -0,0 +1,24 @@
+FROM python:3.10-alpine
+
+WORKDIR /root/workspace/
+COPY ./pyproject.toml ./
+
+RUN MAIN_VERSION=$(cat /etc/alpine-release | cut -d '.' -f 0-2) \
+        && mv /etc/apk/repositories /etc/apk/repositories-bak \
+        && { echo "https://mirrors.aliyun.com/alpine/v${MAIN_VERSION}/main"; \
+        echo "https://mirrors.aliyun.com/alpine/v${MAIN_VERSION}/community"; } >> /etc/apk/repositories \
+        && apk add --update --no-cache tzdata gcc build-base libffi-dev curl && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
+        && mkdir ~/.pip \
+        && printf '[global]\nindex-url=https://mirrors.aliyun.com/pypi/simple/' > ~/.pip/pip.conf \
+        && python3 -m pip install --upgrade pip \
+        && curl -o poetry-install.py -SL https://install.python-poetry.org \
+        && python3 -u poetry-install.py \
+        && rm poetry-install.py \
+        && export PATH="/root/.local/bin:$PATH" \
+        && poetry config virtualenvs.create false --local \
+        && poetry install
+
+COPY . .
+
+
+CMD ["python", "-u", "main.py"]

+ 47 - 0
README.md

@@ -0,0 +1,47 @@
+# modbus-server-nd
+
+只读 Modbus TCP Server。启动时从 `modbus_server_point` 加载全部点位,校验 `pt_point` 中点位是否存在,校验保持寄存器地址是否重叠,并通过实时值接口初始化和周期刷新 Holding Register。
+
+## 启动
+
+```bash
+python main.py
+```
+
+默认读取当前目录下的 `config.yaml`。
+
+不要使用旧的 `modbus-server-nd` 命令启动服务;当前项目按根目录下的 `main.py` 启动。
+
+## 测试
+
+```bash
+python -m unittest discover -s tests
+```
+
+## Modbus/HTTP 对比
+
+服务启动后,对比 Modbus TCP 读取值和实时值 HTTP 接口返回值:
+
+```bash
+python tests/compare_modbus_http_client.py
+```
+
+如果只想验证当前代码的寄存器写入和 Modbus 读取逻辑,可以让脚本从数据库加载点位并自启动一个临时 Modbus Server:
+
+```bash
+python tests/compare_modbus_http_client.py --self-start --modbus-port 15024
+```
+
+## Modbus 压力测试
+
+服务启动后,测试 4 个和 8 个 client 同时读取全部点位:
+
+```bash
+python tests/stress_modbus_clients.py --client-counts 4,8
+```
+
+可以增加重复次数:
+
+```bash
+python tests/stress_modbus_clients.py --client-counts 4,8 --repeat 3
+```

二進制
__pycache__/app_config.cpython-310.pyc


二進制
__pycache__/constants.cpython-310.pyc


二進制
__pycache__/db.cpython-310.pyc


二進制
__pycache__/http_value_provider.cpython-310.pyc


二進制
__pycache__/logging_config.cpython-310.pyc


二進制
__pycache__/main.cpython-310.pyc


二進制
__pycache__/modbus_codec.cpython-310.pyc


二進制
__pycache__/modbus_context.cpython-310.pyc


二進制
__pycache__/modbus_server.cpython-310.pyc


二進制
__pycache__/point_loader.cpython-310.pyc


二進制
__pycache__/point_model.cpython-310.pyc


二進制
__pycache__/register_store.cpython-310.pyc


二進制
__pycache__/value_provider.cpython-310.pyc


二進制
__pycache__/value_refresh.cpython-310.pyc


+ 100 - 0
app_config.py

@@ -0,0 +1,100 @@
+"""YAML configuration loading."""
+
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+import yaml
+
+
+@dataclass(frozen=True)
+class DbConfig:
+    host: str
+    port: int
+    database: str
+    user: str
+    password: str
+
+
+@dataclass(frozen=True)
+class ModbusConfig:
+    host: str = "0.0.0.0"
+    port: int = 502
+    interval: int = 5
+
+
+@dataclass(frozen=True)
+class HttpProviderConfig:
+    url: str = "http://192.168.1.109:18503/data/get_points_real_value"
+    timeout_seconds: int = 5
+
+
+@dataclass(frozen=True)
+class LoggingConfig:
+    dir: str = "logs"
+    retention_days: int = 3
+    level: str = "INFO"
+
+
+@dataclass(frozen=True)
+class AppConfig:
+    db: DbConfig
+    modbus: ModbusConfig
+    http_provider: HttpProviderConfig
+    logging: LoggingConfig
+
+
+def load_config(path: str | Path) -> AppConfig:
+    config_path = Path(path)
+    with config_path.open("r", encoding="utf-8") as file:
+        raw = yaml.safe_load(file) or {}
+
+    db = _require_mapping(raw, "db")
+    return AppConfig(
+        db=DbConfig(
+            host=str(_require(db, "host")),
+            port=int(_require(db, "port")),
+            database=str(_require(db, "database")),
+            user=str(_require(db, "user")),
+            password=str(_require(db, "password")),
+        ),
+        modbus=_load_modbus(raw.get("modbus") or {}),
+        http_provider=_load_http_provider(raw.get("http_provider") or {}),
+        logging=_load_logging(raw.get("logging") or {}),
+    )
+
+
+def _load_modbus(raw: dict[str, Any]) -> ModbusConfig:
+    return ModbusConfig(
+        host=str(raw.get("host", "0.0.0.0")),
+        port=int(raw.get("port", 502)),
+        interval=int(raw.get("interval", 5)),
+    )
+
+
+def _load_http_provider(raw: dict[str, Any]) -> HttpProviderConfig:
+    return HttpProviderConfig(
+        url=str(raw.get("url", "http://192.168.1.109:18503/data/get_points_real_value")),
+        timeout_seconds=int(raw.get("timeout_seconds", 5)),
+    )
+
+
+def _load_logging(raw: dict[str, Any]) -> LoggingConfig:
+    return LoggingConfig(
+        dir=str(raw.get("dir", "logs")),
+        retention_days=int(raw.get("retention_days", 3)),
+        level=str(raw.get("level", "INFO")),
+    )
+
+
+def _require_mapping(raw: dict[str, Any], key: str) -> dict[str, Any]:
+    value = raw.get(key)
+    if not isinstance(value, dict):
+        raise ValueError(f"配置缺失或格式错误: {key}")
+    return value
+
+
+def _require(raw: dict[str, Any], key: str) -> Any:
+    if key not in raw or raw[key] is None:
+        raise ValueError(f"配置缺失: {key}")
+    return raw[key]

+ 20 - 0
config.yaml

@@ -0,0 +1,20 @@
+db:
+  host: 192.168.1.109
+  port: 48324
+  database: proj_dev2024_config
+  user: postgres
+  password: aragronprod
+
+modbus:
+  host: 0.0.0.0
+  port: 502
+  interval: 5
+
+http_provider:
+  url: http://192.168.1.109:18503/data/get_points_real_value
+  timeout_seconds: 3
+
+logging:
+  dir: logs
+  retention_days: 3
+  level: INFO

+ 4 - 0
constants.py

@@ -0,0 +1,4 @@
+"""Application constants."""
+
+DEFAULT_BATCH_SIZE = 200
+SUPPORTED_DATA_TYPES = {"int16", "int32", "float32"}

+ 15 - 0
db.py

@@ -0,0 +1,15 @@
+"""Database helpers."""
+
+import psycopg2
+
+from app_config import DbConfig
+
+
+def create_connection(config: DbConfig):
+    return psycopg2.connect(
+        host=config.host,
+        port=config.port,
+        dbname=config.database,
+        user=config.user,
+        password=config.password,
+    )

+ 23 - 0
http_value_provider.py

@@ -0,0 +1,23 @@
+"""HTTP realtime value provider."""
+
+import requests
+
+from value_provider import ValueProvider
+
+
+class HttpValueProvider(ValueProvider):
+    def __init__(self, url: str, timeout_seconds: int):
+        self.url = url
+        self.timeout_seconds = timeout_seconds
+
+    def fetch_values(self, point_ids: list[str]) -> dict[str, object]:
+        response = requests.post(
+            self.url,
+            json={"point_ids": point_ids},
+            timeout=self.timeout_seconds,
+        )
+        response.raise_for_status()
+        payload = response.json()
+        if payload.get("state") != 0:
+            raise RuntimeError(f"realtime api failed: {payload}")
+        return {item["point_id"]: item.get("value") for item in payload.get("data", [])}

+ 28 - 0
logging_config.py

@@ -0,0 +1,28 @@
+"""Logging configuration."""
+
+import logging
+from logging.handlers import TimedRotatingFileHandler
+from pathlib import Path
+
+
+def setup_logging(log_dir: str, retention_days: int, level: str) -> None:
+    Path(log_dir).mkdir(parents=True, exist_ok=True)
+    formatter = logging.Formatter("%(asctime)s %(levelname)s [%(name)s] [%(threadName)s] %(message)s")
+
+    file_handler = TimedRotatingFileHandler(
+        filename=str(Path(log_dir) / "modbus-server.log"),
+        when="midnight",
+        interval=1,
+        backupCount=retention_days,
+        encoding="utf-8",
+    )
+    file_handler.setFormatter(formatter)
+
+    console_handler = logging.StreamHandler()
+    console_handler.setFormatter(formatter)
+
+    root_logger = logging.getLogger()
+    root_logger.setLevel(getattr(logging, level.upper(), logging.INFO))
+    root_logger.handlers.clear()
+    root_logger.addHandler(file_handler)
+    root_logger.addHandler(console_handler)

+ 111 - 0
main.py

@@ -0,0 +1,111 @@
+"""Application entrypoint."""
+
+import logging
+import sys
+
+from app_config import load_config
+from constants import DEFAULT_BATCH_SIZE
+from db import create_connection
+from http_value_provider import HttpValueProvider
+from logging_config import setup_logging
+from modbus_context import ReadonlyHoldingRegisterContext
+from modbus_server import run_modbus_server
+from point_loader import check_point_exists, load_points, validate_address_overlaps, validate_data_types
+from register_store import RegisterStore, initialize_register_store
+from value_refresh import ValueRefreshWorker
+
+logger = logging.getLogger(__name__)
+
+
+def main(config_path: str = "config.yaml") -> int:
+    config = load_config(config_path)
+    setup_logging(config.logging.dir, config.logging.retention_days, config.logging.level)
+
+    logger.info("正在启动Modbus Server")
+    logger.info("日志系统初始化完成")
+    logger.info("配置文件加载完成")
+    logger.info(
+        "运行配置: 数据库=%s:%s/%s, Modbus监听=%s:%s, 刷新周期=%s秒, 批量大小=%s",
+        config.db.host,
+        config.db.port,
+        config.db.database,
+        config.modbus.host,
+        config.modbus.port,
+        config.modbus.interval,
+        DEFAULT_BATCH_SIZE,
+    )
+
+    try:
+        points = _load_and_validate_points(config)
+    except Exception:
+        logger.exception("初始化失败")
+        return 1
+
+    store = RegisterStore()
+    initialize_register_store(points, store)
+    logger.info("寄存器存储初始化完成,%s", store.describe())
+
+    provider = HttpValueProvider(config.http_provider.url, config.http_provider.timeout_seconds)
+    logger.info("实时值Provider初始化完成,类型=http")
+
+    worker = ValueRefreshWorker(points, provider, store, config.modbus.interval)
+    logger.info("开始请求初始化实时值")
+    try:
+        worker.refresh_once(initial=True)
+    except Exception:
+        logger.exception("初始化实时值失败")
+        return 1
+    worker.start()
+
+    context = ReadonlyHoldingRegisterContext(store)
+    logger.info("上下文初始化完成(Modbus)")
+
+    try:
+        run_modbus_server(context, config.modbus.host, config.modbus.port)
+    except Exception:
+        logger.exception("Modbus TCP服务启动或运行失败")
+        return 1
+    return 0
+
+
+def _load_and_validate_points(config) -> list:
+    conn = create_connection(config.db)
+    try:
+        logger.info("数据库连接成功")
+        logger.info("开始从modbus_server_point加载全部点位")
+        points = load_points(conn)
+        logger.info("点位加载完成,数量=%s", len(points))
+
+        if not points:
+            logger.warning("数据表modbus_server_point没有点位,将启动空Modbus Server")
+
+        logger.info("开始校验点位data_type")
+        type_errors = validate_data_types(points)
+        if type_errors:
+            for error in type_errors:
+                logger.error("点位data_type非法: %s", error)
+            raise RuntimeError("点位data_type校验失败")
+
+        logger.info("开始校验Modbus地址重叠")
+        overlap_errors = validate_address_overlaps(points)
+        if overlap_errors:
+            for error in overlap_errors:
+                logger.error(error)
+            raise RuntimeError("Modbus地址重叠校验失败")
+
+        logger.info("开始校验pt_point点位是否存在,批量大小=%s", DEFAULT_BATCH_SIZE)
+        missing_point_ids = check_point_exists(conn, [point.point_id for point in points])
+        if missing_point_ids:
+            logger.error("数据表pt_point中缺失以下point_id: %s", missing_point_ids)
+            raise RuntimeError("pt_point点位存在性校验失败")
+        return points
+    finally:
+        conn.close()
+
+
+def cli() -> None:
+    raise SystemExit(main(sys.argv[1] if len(sys.argv) > 1 else "config.yaml"))
+
+
+if __name__ == "__main__":
+    cli()

+ 19 - 0
modbus_codec.py

@@ -0,0 +1,19 @@
+"""Encode point values to Modbus registers."""
+
+import struct
+
+
+def encode_registers(value: object, data_type: str) -> list[int]:
+    if value is None:
+        value = 0
+
+    if data_type == "int16":
+        packed = struct.pack(">h", int(value))
+    elif data_type == "int32":
+        packed = struct.pack(">i", int(value))
+    elif data_type == "float32":
+        packed = struct.pack(">f", float(value))
+    else:
+        raise ValueError(f"unsupported data_type: {data_type}")
+
+    return [int.from_bytes(packed[index:index + 2], "big") for index in range(0, len(packed), 2)]

+ 38 - 0
modbus_context.py

@@ -0,0 +1,38 @@
+"""Readonly Modbus server context."""
+
+import logging
+
+from pymodbus.constants import ExcCodes
+from pymodbus.datastore import ModbusServerContext
+
+from register_store import RegisterStore
+
+logger = logging.getLogger(__name__)
+
+
+class ReadonlyHoldingRegisterContext(ModbusServerContext):
+    def __init__(self, store: RegisterStore):
+        self.simdevices = []
+        self.store = store
+        self._illegal_address_log_count = 0
+
+    def device_ids(self) -> list[int]:
+        return sorted(self.store._registers.keys())
+
+    async def async_getValues(self, device_id: int, func_code: int, address: int, count: int = 1):
+        if func_code != 3:
+            return ExcCodes.ILLEGAL_FUNCTION
+        result = self.store.read_holding_registers(device_id, address, count)
+        if result == ExcCodes.ILLEGAL_ADDRESS and self._illegal_address_log_count < 20:
+            self._illegal_address_log_count += 1
+            logger.warning(
+                "Modbus读取非法地址,slave_id=%s,address=%s,count=%s,寄存器概况=%s",
+                device_id,
+                address,
+                count,
+                self.store.describe(),
+            )
+        return result
+
+    async def async_setValues(self, device_id: int, func_code: int, address: int, values: list[int]):
+        return ExcCodes.ILLEGAL_FUNCTION

+ 54 - 0
modbus_server.py

@@ -0,0 +1,54 @@
+"""Modbus TCP server helpers."""
+
+import asyncio
+import logging
+
+from pymodbus.server.requesthandler import ServerRequestHandler
+from pymodbus.server.server import ModbusTcpServer
+
+logger = logging.getLogger(__name__)
+
+
+class LoggingServerRequestHandler(ServerRequestHandler):
+    def _client_addr(self) -> str:
+        if not self.transport:
+            return "unknown"
+        peer = self.transport.get_extra_info("peername")
+        return "%s:%s" % peer if peer else "unknown"
+
+    def callback_connected(self) -> None:
+        super().callback_connected()
+        logger.info("客户端已连接(Modbus): %s", self._client_addr())
+
+    def callback_disconnected(self, exc: Exception | None) -> None:
+        client_addr = self._client_addr()
+        super().callback_disconnected(exc)
+        if exc:
+            logger.info("客户端已断开(Modbus): %s, 原因=%s", client_addr, exc)
+        else:
+            logger.info("客户端已断开(Modbus): %s", client_addr)
+
+
+class LoggingModbusTcpServer(ModbusTcpServer):
+    def callback_new_connection(self):
+        return LoggingServerRequestHandler(
+            self,
+            self.trace_packet,
+            self.trace_pdu,
+            self.trace_connect,
+        )
+
+
+async def start_modbus_server(context, host: str, port: int) -> None:
+    server = LoggingModbusTcpServer(
+        context,
+        address=(host, port),
+        ignore_missing_devices=False,
+        broadcast_enable=False,
+    )
+    logger.info("服务已启动监听(Modbus TCP),地址=%s:%s", host, port)
+    await server.serve_forever()
+
+
+def run_modbus_server(context, host: str, port: int) -> None:
+    asyncio.run(start_modbus_server(context, host, port))

+ 423 - 0
poetry.lock

@@ -0,0 +1,423 @@
+# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand.
+
+[[package]]
+name = "certifi"
+version = "2026.5.20"
+description = "Python package for providing Mozilla's CA Bundle."
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897"},
+    {file = "certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d"},
+]
+
+[package.source]
+type = "legacy"
+url = "https://mirrors.aliyun.com/pypi/simple"
+reference = "ali"
+
+[[package]]
+name = "charset-normalizer"
+version = "3.4.7"
+description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"},
+    {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"},
+    {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790"},
+    {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc"},
+    {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393"},
+    {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153"},
+    {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af"},
+    {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34"},
+    {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1"},
+    {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752"},
+    {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53"},
+    {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616"},
+    {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a"},
+    {file = "charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374"},
+    {file = "charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943"},
+    {file = "charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008"},
+    {file = "charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7"},
+    {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7"},
+    {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e"},
+    {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c"},
+    {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df"},
+    {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265"},
+    {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4"},
+    {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e"},
+    {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38"},
+    {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c"},
+    {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b"},
+    {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c"},
+    {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d"},
+    {file = "charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad"},
+    {file = "charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00"},
+    {file = "charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1"},
+    {file = "charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46"},
+    {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2"},
+    {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b"},
+    {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a"},
+    {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116"},
+    {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb"},
+    {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1"},
+    {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15"},
+    {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5"},
+    {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d"},
+    {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7"},
+    {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464"},
+    {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49"},
+    {file = "charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c"},
+    {file = "charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6"},
+    {file = "charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d"},
+    {file = "charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063"},
+    {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c"},
+    {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66"},
+    {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18"},
+    {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd"},
+    {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215"},
+    {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859"},
+    {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8"},
+    {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5"},
+    {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832"},
+    {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6"},
+    {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48"},
+    {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a"},
+    {file = "charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e"},
+    {file = "charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110"},
+    {file = "charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b"},
+    {file = "charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0"},
+    {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a"},
+    {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b"},
+    {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41"},
+    {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e"},
+    {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae"},
+    {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18"},
+    {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b"},
+    {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356"},
+    {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab"},
+    {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46"},
+    {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44"},
+    {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72"},
+    {file = "charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10"},
+    {file = "charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f"},
+    {file = "charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246"},
+    {file = "charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24"},
+    {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79"},
+    {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960"},
+    {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4"},
+    {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e"},
+    {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1"},
+    {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44"},
+    {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e"},
+    {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3"},
+    {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0"},
+    {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e"},
+    {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb"},
+    {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe"},
+    {file = "charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0"},
+    {file = "charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c"},
+    {file = "charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d"},
+    {file = "charset_normalizer-3.4.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259"},
+    {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01"},
+    {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3"},
+    {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30"},
+    {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734"},
+    {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60"},
+    {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0"},
+    {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545"},
+    {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5"},
+    {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f"},
+    {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686"},
+    {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706"},
+    {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7"},
+    {file = "charset_normalizer-3.4.7-cp38-cp38-win32.whl", hash = "sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207"},
+    {file = "charset_normalizer-3.4.7-cp38-cp38-win_amd64.whl", hash = "sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83"},
+    {file = "charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217"},
+    {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5"},
+    {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9"},
+    {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a"},
+    {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc"},
+    {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00"},
+    {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776"},
+    {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319"},
+    {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24"},
+    {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42"},
+    {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4"},
+    {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67"},
+    {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274"},
+    {file = "charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366"},
+    {file = "charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444"},
+    {file = "charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c"},
+    {file = "charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d"},
+    {file = "charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5"},
+]
+
+[package.source]
+type = "legacy"
+url = "https://mirrors.aliyun.com/pypi/simple"
+reference = "ali"
+
+[[package]]
+name = "idna"
+version = "3.18"
+description = "Internationalized Domain Names in Applications (IDNA)"
+optional = false
+python-versions = ">=3.9"
+files = [
+    {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"},
+    {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"},
+]
+
+[package.extras]
+all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
+
+[package.source]
+type = "legacy"
+url = "https://mirrors.aliyun.com/pypi/simple"
+reference = "ali"
+
+[[package]]
+name = "psycopg2-binary"
+version = "2.9.12"
+description = "psycopg2 - Python-PostgreSQL Database Adapter"
+optional = false
+python-versions = ">=3.9"
+files = [
+    {file = "psycopg2_binary-2.9.12-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b818ceff717f98851a64bffd4c5eb5b3059ae280276dcecc52ac658dcf006a4"},
+    {file = "psycopg2_binary-2.9.12-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d2fa0d7caca8635c56e373055094eeda3208d901d55dd0ff5abc1d4e47f82b56"},
+    {file = "psycopg2_binary-2.9.12-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:864c261b3690e1207d14bbfe0a61e27567981b80c47a778561e49f676f7ce433"},
+    {file = "psycopg2_binary-2.9.12-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c5ee5213445dd45312459029b8c4c0a695461eb517b753d2582315bd07995f5e"},
+    {file = "psycopg2_binary-2.9.12-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6f9cae1f848779b5b01f417e762c40d026ea93eb0648249a604728cda991dde3"},
+    {file = "psycopg2_binary-2.9.12-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:63a3ebbd543d3d1eda088ac99164e8c5bac15293ee91f20281fd17d050aee1c4"},
+    {file = "psycopg2_binary-2.9.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d6fcbba8c9fed08a73b8ac61ea79e4821e45b1e92bb466230c5e746bbf3d5256"},
+    {file = "psycopg2_binary-2.9.12-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:36512911ebb2b60a0c3e44d0bb5048c1980aced91235d133b7874f3d1d93487c"},
+    {file = "psycopg2_binary-2.9.12-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:8ffdb59fe88f99589e34354a130217aa1fd2d615612402d6edc8b3dbc7a44463"},
+    {file = "psycopg2_binary-2.9.12-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a46fe069b65255df410f856d842bc235f90e22ffdf532dda625fd4213d3fd9b1"},
+    {file = "psycopg2_binary-2.9.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab29414b25dcb698bf26bf213e3348abdcd07bbd5de032a5bec15bd75b298b03"},
+    {file = "psycopg2_binary-2.9.12-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5c8ce6c61bd1b1f6b9c24ee32211599f6166af2c55abb19456090a21fd16554b"},
+    {file = "psycopg2_binary-2.9.12-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b4a9eaa6e7f4ff91bec10aa3fb296878e75187bced5cc4bafe17dc40915e1326"},
+    {file = "psycopg2_binary-2.9.12-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c6528cefc8e50fcc6f4a107e27a672058b36cc5736d665476aeb413ba88dbb06"},
+    {file = "psycopg2_binary-2.9.12-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e4e184b1fb6072bf05388aa41c697e1b2d01b3473f107e7ec44f186a32cfd0b8"},
+    {file = "psycopg2_binary-2.9.12-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4766ab678563054d3f1d064a4db19cc4b5f9e3a8d9018592a8285cf200c248f3"},
+    {file = "psycopg2_binary-2.9.12-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5a0253224780c978746cb9be55a946bcdaf40fe3519c0f622924cdabdafe2c39"},
+    {file = "psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0dc9228d47c46bda253d2ecd6bb93b56a9f2d7ad33b684a1fa3622bf74ffe30c"},
+    {file = "psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f921f3cd87035ef7df233383011d7a53ea1d346224752c1385f1edfd790ceb6a"},
+    {file = "psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3d999bd982a723113c1a45b55a7a6a90d64d0ed2278020ed625c490ff7bef96c"},
+    {file = "psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:29d4d134bd0ab46ffb04e94aa3c5fa3ef582e9026609165e2f758ff76fc3a3be"},
+    {file = "psycopg2_binary-2.9.12-cp311-cp311-win_amd64.whl", hash = "sha256:cb4a1dacdd48077150dc762a9e5ddbf32c256d66cb46f80839391aa458774936"},
+    {file = "psycopg2_binary-2.9.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5cdc05117180c5fa9c40eea8ea559ce64d73824c39d928b7da9fb5f6a9392433"},
+    {file = "psycopg2_binary-2.9.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3227a3bc228c10d21011a99245edca923e4e8bf461857e869a507d9a41fe9f6"},
+    {file = "psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:995ce929eede89db6254b50827e2b7fd61e50d11f0b116b29fffe4a2e53c4580"},
+    {file = "psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9fe06d93e72f1c048e731a2e3e7854a5bfaa58fc736068df90b352cefe66f03f"},
+    {file = "psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40e7b28b63aaf737cb3a1edc3a9bbc9a9f4ad3dcb7152e8c1130e4050eddcb7d"},
+    {file = "psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89d19a9f7899e8eb0656a2b3a08e0da04c720a06db6e0033eab5928aabe60fa9"},
+    {file = "psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:612b965daee295ae2da8f8218ce1d274645dc76ef3f1abf6a0a94fd57eff876d"},
+    {file = "psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b9a339b79d37c1b45f3235265f07cdeb0cb5ad7acd2ac7720a5920989c17c24e"},
+    {file = "psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3471336e1acfd9c7fe507b8bad5af9317b6a89294f9eb37bd9a030bb7bebcdc6"},
+    {file = "psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7af18183109e23502c8b2ae7f6926c0882766f35b5175a4cd737ad825e4d7a1b"},
+    {file = "psycopg2_binary-2.9.12-cp312-cp312-win_amd64.whl", hash = "sha256:398fcd4db988c7d7d3713e2b8e18939776fd3fb447052daae4f24fa39daede4c"},
+    {file = "psycopg2_binary-2.9.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7c729a73c7b1b84de3582f73cdd27d905121dc2c531f3d9a3c32a3011033b965"},
+    {file = "psycopg2_binary-2.9.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4413d0caef93c5cf50b96863df4c2efe8c269bf2267df353225595e7e15e8df7"},
+    {file = "psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4dfcf8e45ebb0c663be34a3442f65e17311f3367089cd4e5e3a3e8e62c978777"},
+    {file = "psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c41321a14dd74aceb6a9a643b9253a334521babfa763fa873e33d89cfa122fb5"},
+    {file = "psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83946ba43979ebfdc99a3cd0ee775c89f221df026984ba19d46133d8d75d3cd9"},
+    {file = "psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:411e85815652d13560fbe731878daa5d92378c4995a22302071890ec3397d019"},
+    {file = "psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c8ad4c08e00f7679559eaed7aff1edfffc60c086b976f93972f686384a95e2c"},
+    {file = "psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:00814e40fa23c2b37ef0a1e3c749d89982c73a9cb5046137f0752a22d432e82f"},
+    {file = "psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:98062447aebc20ed20add1f547a364fd0ef8933640d5372ff1873f8deb9b61be"},
+    {file = "psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:66a7685d7e548f10fb4ce32fb01a7b7f4aa702134de92a292c7bd9e0d3dbd290"},
+    {file = "psycopg2_binary-2.9.12-cp313-cp313-win_amd64.whl", hash = "sha256:b6937f5fe4e180aeee87de907a2fa982ded6f7f15d7218f78a083e4e1d68f2a0"},
+    {file = "psycopg2_binary-2.9.12-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2"},
+    {file = "psycopg2_binary-2.9.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2"},
+    {file = "psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f"},
+    {file = "psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:863f5d12241ebe1c76a72a04c2113b6dc905f90b9cef0e9be0efd994affd9354"},
+    {file = "psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a99eaab34a9010f1a086b126de467466620a750634d114d20455f3a824aae033"},
+    {file = "psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffdd7dc5463ccd61845ac37b7012d0f35a1548df9febe14f8dd549be4a0bc81e"},
+    {file = "psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54a0dfecab1b48731f934e06139dfe11e24219fb6d0ceb32177cf0375f14c7b5"},
+    {file = "psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:96937c9c5d891f772430f418a7a8b4691a90c3e6b93cf72b5bd7cad8cbca32a5"},
+    {file = "psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:77b348775efd4cdab410ec6609d81ccecd1139c90265fa583a7255c8064bc03d"},
+    {file = "psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:527e6342b3e44c2f0544f6b8e927d60de7f163f5723b8f1dfa7d2a84298738cd"},
+    {file = "psycopg2_binary-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980"},
+    {file = "psycopg2_binary-2.9.12-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ee2d84ef5eb6c04702d2e9c372ad557fb027f26a5d82804f749dfb14c7fdd2ab"},
+    {file = "psycopg2_binary-2.9.12-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cfa2517c94ea3af6deb46f81e1bbd884faa63e28481eb2f889989dd8d95e5f03"},
+    {file = "psycopg2_binary-2.9.12-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:ba3df2fc42a1cfa45b72cf096d4acb2b885937eedc61461081d53538d4a82a86"},
+    {file = "psycopg2_binary-2.9.12-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:718e1fc18edf573b02cb8aea868de8d8d33f99ce9620206aa9144b67b0985e94"},
+    {file = "psycopg2_binary-2.9.12-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5c7cb4cbf894a1d36c720d713de507952c7c58f66d30834708f03dbe5c822ccf"},
+    {file = "psycopg2_binary-2.9.12-cp39-cp39-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:049366c6d884bdcd65d66e6ca1fdbebe670b56c6c9ba46f164e6667e90881964"},
+    {file = "psycopg2_binary-2.9.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fb1828cf3da68f99e45ebce1355d65d2d12b6a78fb5dfb16247aad6bdef5f5d2"},
+    {file = "psycopg2_binary-2.9.12-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:127467c6e476dd876634f17c3d870530e73ff454ff99bff73d36e80af28e1115"},
+    {file = "psycopg2_binary-2.9.12-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:ace94261f43850e9e79f6c56636c5e0147978ab79eda5e5e5ebf13ae146fc8fe"},
+    {file = "psycopg2_binary-2.9.12-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a7e39a65b7d2a20e4ba2e0aaad1960b61cc2888d6ab047769f8347bd3c9ad915"},
+    {file = "psycopg2_binary-2.9.12-cp39-cp39-win_amd64.whl", hash = "sha256:f625abb7020e4af3432d95342daa1aa0db3fa369eed19807aa596367ba791b10"},
+    {file = "psycopg2_binary-2.9.12.tar.gz", hash = "sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c"},
+]
+
+[package.source]
+type = "legacy"
+url = "https://mirrors.aliyun.com/pypi/simple"
+reference = "ali"
+
+[[package]]
+name = "pymodbus"
+version = "3.13.1"
+description = "A fully featured modbus protocol stack in python"
+optional = false
+python-versions = ">=3.10.0"
+files = [
+    {file = "pymodbus-3.13.1-py3-none-any.whl", hash = "sha256:820167a9c6a13d698d7ff49e8420f8fbbdd8fec3f75aacd46a35f4ab9a000144"},
+    {file = "pymodbus-3.13.1.tar.gz", hash = "sha256:7a74ea0a4eb4895f518b34de32915ba4fde216576e09deaf735a279a9281af4f"},
+]
+
+[package.extras]
+all = ["pymodbus[development,documentation,serial,simulator]"]
+development = ["build (>=1.4.0)", "codespell (>=2.3.0)", "coverage (>=7.13.3)", "pylint (>=4.0.4)", "pytest (>=9.0.2)", "pytest-aiohttp (>=1.0.5)", "pytest-asyncio (>=1.2.0)", "pytest-cov (>=7.0.0)", "pytest-profiling (>=1.7.0)", "pytest-timeout (>=2.3.1)", "pytest-xdist (>=3.6.1)", "ruff (>=0.15.0)", "twine (>=6.2.0)", "types-Pygments", "types-pyserial", "zuban (>=0.4.2)"]
+documentation = ["Sphinx (>=7.3.7)", "recommonmark (>=0.7.1)", "sphinx-rtd-theme (>=2.0.0)"]
+serial = ["pyserial (>=3.5)"]
+simulator = ["aiohttp (>=3.13.2)", "aiohttp (>=3.8.6)"]
+
+[package.source]
+type = "legacy"
+url = "https://mirrors.aliyun.com/pypi/simple"
+reference = "ali"
+
+[[package]]
+name = "pyyaml"
+version = "6.0.3"
+description = "YAML parser and emitter for Python"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"},
+    {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"},
+    {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"},
+    {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"},
+    {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"},
+    {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"},
+    {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"},
+    {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"},
+    {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"},
+    {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"},
+    {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"},
+    {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"},
+    {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"},
+    {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"},
+    {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"},
+    {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"},
+    {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"},
+    {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"},
+    {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"},
+    {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"},
+    {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"},
+    {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"},
+    {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"},
+    {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"},
+    {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"},
+    {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"},
+    {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"},
+    {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"},
+    {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"},
+    {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"},
+    {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"},
+    {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"},
+    {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"},
+    {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"},
+    {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"},
+    {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"},
+    {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"},
+    {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"},
+    {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"},
+    {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"},
+    {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"},
+    {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"},
+    {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"},
+    {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"},
+    {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"},
+    {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"},
+    {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"},
+    {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"},
+    {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"},
+    {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"},
+    {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"},
+    {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"},
+    {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"},
+    {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"},
+    {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"},
+    {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"},
+    {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"},
+    {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"},
+    {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"},
+    {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"},
+    {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"},
+    {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"},
+    {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"},
+    {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"},
+    {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"},
+    {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"},
+    {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"},
+    {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"},
+    {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"},
+    {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"},
+    {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"},
+    {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"},
+    {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"},
+]
+
+[package.source]
+type = "legacy"
+url = "https://mirrors.aliyun.com/pypi/simple"
+reference = "ali"
+
+[[package]]
+name = "requests"
+version = "2.34.2"
+description = "Python HTTP for Humans."
+optional = false
+python-versions = ">=3.10"
+files = [
+    {file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"},
+    {file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"},
+]
+
+[package.dependencies]
+certifi = ">=2023.5.7"
+charset_normalizer = ">=2,<4"
+idna = ">=2.5,<4"
+urllib3 = ">=1.26,<3"
+
+[package.extras]
+socks = ["PySocks (>=1.5.6,!=1.5.7)"]
+use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"]
+
+[package.source]
+type = "legacy"
+url = "https://mirrors.aliyun.com/pypi/simple"
+reference = "ali"
+
+[[package]]
+name = "urllib3"
+version = "2.7.0"
+description = "HTTP library with thread-safe connection pooling, file post, and more."
+optional = false
+python-versions = ">=3.10"
+files = [
+    {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"},
+    {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"},
+]
+
+[package.extras]
+brotli = ["brotli (>=1.2.0)", "brotlicffi (>=1.2.0.0)"]
+h2 = ["h2 (>=4,<5)"]
+socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
+zstd = ["backports-zstd (>=1.0.0)"]
+
+[package.source]
+type = "legacy"
+url = "https://mirrors.aliyun.com/pypi/simple"
+reference = "ali"
+
+[metadata]
+lock-version = "2.0"
+python-versions = "^3.10"
+content-hash = "ec48bcee0c8b060a4c7ee2390b7613774b2248e10193100171d834c8a9f0e9b2"

+ 81 - 0
point_loader.py

@@ -0,0 +1,81 @@
+"""Load and validate Modbus point configuration."""
+
+import logging
+
+from constants import DEFAULT_BATCH_SIZE, SUPPORTED_DATA_TYPES
+from point_model import ModbusPoint
+
+logger = logging.getLogger(__name__)
+
+
+def load_points(conn) -> list[ModbusPoint]:
+    sql = """
+        SELECT point_id, name, data_type, slave_id, address
+        FROM modbus_server_point
+        ORDER BY slave_id, address, point_id
+    """
+    with conn.cursor() as cursor:
+        cursor.execute(sql)
+        rows = cursor.fetchall()
+    return [
+        ModbusPoint(
+            point_id=str(row[0]),
+            name=str(row[1]),
+            data_type=str(row[2]),
+            slave_id=int(row[3]),
+            address=int(row[4]),
+        )
+        for row in rows
+    ]
+
+
+def validate_data_types(points: list[ModbusPoint]) -> list[str]:
+    errors = [
+        f"point_id={point.point_id}, data_type={point.data_type}"
+        for point in points
+        if point.data_type not in SUPPORTED_DATA_TYPES
+    ]
+    return errors
+
+
+def validate_address_overlaps(points: list[ModbusPoint]) -> list[str]:
+    errors: list[str] = []
+    by_slave: dict[int, list[ModbusPoint]] = {}
+    for point in points:
+        by_slave.setdefault(point.slave_id, []).append(point)
+
+    for slave_id, slave_points in by_slave.items():
+        previous: ModbusPoint | None = None
+        for current in sorted(slave_points, key=lambda item: item.address):
+            if previous and current.address <= previous.end_address:
+                errors.append(
+                    "从站=%s 地址重叠: %s(%s) 范围=%s-%s, %s(%s) 范围=%s-%s"
+                    % (
+                        slave_id,
+                        previous.point_id,
+                        previous.data_type,
+                        previous.address,
+                        previous.end_address,
+                        current.point_id,
+                        current.data_type,
+                        current.address,
+                        current.end_address,
+                    )
+                )
+            if previous is None or current.end_address > previous.end_address:
+                previous = current
+    return errors
+
+
+def check_point_exists(conn, point_ids: list[str]) -> list[str]:
+    if not point_ids:
+        return []
+
+    existing: set[str] = set()
+    with conn.cursor() as cursor:
+        for start in range(0, len(point_ids), DEFAULT_BATCH_SIZE):
+            batch = point_ids[start:start + DEFAULT_BATCH_SIZE]
+            cursor.execute("SELECT point_id FROM pt_point WHERE point_id = ANY(%s)", (batch,))
+            existing.update(str(row[0]) for row in cursor.fetchall())
+        logger.info("校验pt_point点位完成,数量=%d", len(point_ids))
+    return sorted(set(point_ids) - existing)

+ 20 - 0
point_model.py

@@ -0,0 +1,20 @@
+"""Point data models."""
+
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class ModbusPoint:
+    point_id: str
+    name: str
+    data_type: str
+    slave_id: int
+    address: int
+
+    @property
+    def register_count(self) -> int:
+        return 1 if self.data_type == "int16" else 2
+
+    @property
+    def end_address(self) -> int:
+        return self.address + self.register_count - 1

+ 22 - 0
pyproject.toml

@@ -0,0 +1,22 @@
+[tool.poetry]
+name = "modbus-server-nd"
+version = "0.1.0"
+description = ""
+authors = ["Lu Xianghui <xianghui.lu@data-turing.com>"]
+package-mode = false
+
+[tool.poetry.dependencies]
+python = "^3.10"
+pymodbus = "^3.13.1"
+requests = "^2.34.2"
+psycopg2-binary = "^2.9.12"
+pyyaml = "^6.0.3"
+
+[build-system]
+requires = ["poetry-core"]
+build-backend = "poetry.core.masonry.api"
+
+[[tool.poetry.source]]
+name = "ali"
+url = "https://mirrors.aliyun.com/pypi/simple/"
+priority = "primary"

+ 70 - 0
register_store.py

@@ -0,0 +1,70 @@
+"""Thread-safe Modbus register storage."""
+
+from threading import RLock
+
+from pymodbus.constants import ExcCodes
+
+from modbus_codec import encode_registers
+from point_model import ModbusPoint
+
+
+class RegisterStore:
+    def __init__(self):
+        self._lock = RLock()
+        self._registers: dict[int, dict[int, int]] = {}
+        self._valid_addresses: dict[int, set[int]] = {}
+
+    def initialize_slave(self, slave_id: int, registers: dict[int, int]) -> None:
+        with self._lock:
+            self._registers[slave_id] = dict(registers)
+            self._valid_addresses[slave_id] = set(registers)
+
+    def read_holding_registers(self, slave_id: int, address: int, count: int):
+        with self._lock:
+            slave_registers = self._registers.get(slave_id)
+            valid_addresses = self._valid_addresses.get(slave_id)
+            if not slave_registers or not valid_addresses:
+                return ExcCodes.ILLEGAL_ADDRESS
+            addresses = range(address, address + count)
+            if any(item not in valid_addresses for item in addresses):
+                return ExcCodes.ILLEGAL_ADDRESS
+            return [slave_registers[item] for item in addresses]
+
+    def write_internal(self, slave_id: int, address: int, values: list[int]) -> None:
+        with self._lock:
+            slave_registers = self._registers.get(slave_id)
+            valid_addresses = self._valid_addresses.get(slave_id)
+            if slave_registers is None or valid_addresses is None:
+                raise KeyError(f"slave_id is not initialized: {slave_id}")
+            for offset, register in enumerate(values):
+                register_address = address + offset
+                if register_address not in valid_addresses:
+                    raise KeyError(f"address is not configured: slave_id={slave_id}, address={register_address}")
+                slave_registers[register_address] = register
+
+    def describe(self) -> str:
+        with self._lock:
+            if not self._valid_addresses:
+                return "无从站"
+            parts = []
+            for slave_id in sorted(self._valid_addresses):
+                addresses = self._valid_addresses[slave_id]
+                if not addresses:
+                    parts.append(f"slave_id={slave_id}: 无地址")
+                    continue
+                parts.append(
+                    f"slave_id={slave_id}: 地址范围={min(addresses)}-{max(addresses)}, 寄存器数量={len(addresses)}"
+                )
+            return "; ".join(parts)
+
+
+def initialize_register_store(points: list[ModbusPoint], store: RegisterStore) -> None:
+    by_slave: dict[int, dict[int, int]] = {}
+    for point in points:
+        registers = encode_registers(0, point.data_type)
+        slave_registers = by_slave.setdefault(point.slave_id, {})
+        for offset, register in enumerate(registers):
+            slave_registers[point.address + offset] = register
+
+    for slave_id, registers in by_slave.items():
+        store.initialize_slave(slave_id, registers)

二進制
tests/__pycache__/stress_modbus_clients.cpython-310.pyc


二進制
tests/__pycache__/test_app_config.cpython-310.pyc


二進制
tests/__pycache__/test_modbus_codec.cpython-310.pyc


二進制
tests/__pycache__/test_point_loader.cpython-310.pyc


二進制
tests/__pycache__/test_register_store_context.cpython-310.pyc


二進制
tests/__pycache__/test_value_refresh.cpython-310.pyc


+ 256 - 0
tests/compare_modbus_http_client.py

@@ -0,0 +1,256 @@
+"""Compare Modbus TCP point values with realtime HTTP API values.
+
+Run this while the Modbus server is already running.
+"""
+
+import argparse
+import socket
+import sys
+import threading
+import time
+from pathlib import Path
+
+from pymodbus.client import ModbusTcpClient
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+from app_config import load_config  # noqa: E402
+from constants import DEFAULT_BATCH_SIZE  # noqa: E402
+from db import create_connection  # noqa: E402
+from http_value_provider import HttpValueProvider  # noqa: E402
+from modbus_context import ReadonlyHoldingRegisterContext  # noqa: E402
+from modbus_codec import encode_registers  # noqa: E402
+from modbus_server import run_modbus_server  # noqa: E402
+from point_loader import load_points  # noqa: E402
+from point_model import ModbusPoint  # noqa: E402
+from register_store import RegisterStore, initialize_register_store  # noqa: E402
+from value_refresh import ValueRefreshWorker  # noqa: E402
+
+
+def parse_args() -> argparse.Namespace:
+    parser = argparse.ArgumentParser(
+        description="Read points by pymodbus, read the same points by HTTP API, then compare registers."
+    )
+    parser.add_argument("--config", default="config.yaml", help="config file path")
+    parser.add_argument("--modbus-host", help="Modbus TCP host. Defaults to config host, 0.0.0.0 becomes 127.0.0.1")
+    parser.add_argument("--modbus-port", type=int, help="Modbus TCP port. Defaults to config port, or 15020 with --self-start")
+    parser.add_argument("--timeout", type=float, default=3, help="Modbus client timeout seconds")
+    parser.add_argument("--limit", type=int, default=0, help="limit point count after filtering; 0 means no limit")
+    parser.add_argument("--point-id", action="append", default=[], help="point_id to compare; can be repeated or comma-separated")
+    parser.add_argument("--max-details", type=int, default=20, help="max mismatch/error details to print")
+    parser.add_argument("--self-start", action="store_true", help="start an in-process Modbus server from DB points before comparing")
+    return parser.parse_args()
+
+
+def main() -> int:
+    args = parse_args()
+    config = load_config(args.config)
+    points = _load_points(config)
+    points = _filter_points(points, args.point_id, args.limit)
+    if not points:
+        print("没有可比较的点位")
+        return 2
+
+    host = args.modbus_host or _client_host(config.modbus.host)
+    port = args.modbus_port or (15020 if args.self_start else config.modbus.port)
+    provider = HttpValueProvider(config.http_provider.url, config.http_provider.timeout_seconds)
+    if args.self_start:
+        host = args.modbus_host or "127.0.0.1"
+        print(f"开始读取HTTP实时值接口快照,url={config.http_provider.url}, 点位数={len(points)}")
+        http_values = _read_http_values(provider, [point.point_id for point in points])
+        _start_embedded_modbus_server(host, port, points, http_values, config.modbus.interval, args.timeout)
+    else:
+        http_values = None
+
+    print(f"开始读取Modbus点位,host={host}, port={port}, 点位数={len(points)}")
+    modbus_results = _read_modbus_values(host, port, args.timeout, points)
+
+    if http_values is None:
+        print(f"开始读取HTTP实时值接口,url={config.http_provider.url}, 点位数={len(points)}")
+        http_values = _read_http_values(provider, [point.point_id for point in points])
+
+    return _compare(points, modbus_results, http_values, args.max_details)
+
+
+def _load_points(config) -> list[ModbusPoint]:
+    conn = create_connection(config.db)
+    try:
+        return load_points(conn)
+    finally:
+        conn.close()
+
+
+def _filter_points(points: list[ModbusPoint], raw_point_ids: list[str], limit: int) -> list[ModbusPoint]:
+    point_ids = {
+        point_id.strip()
+        for item in raw_point_ids
+        for point_id in item.split(",")
+        if point_id.strip()
+    }
+    if point_ids:
+        points = [point for point in points if point.point_id in point_ids]
+    if limit > 0:
+        points = points[:limit]
+    return points
+
+
+def _client_host(host: str) -> str:
+    return "127.0.0.1" if host in {"", "0.0.0.0", "::"} else host
+
+
+def _start_embedded_modbus_server(
+    host: str,
+    port: int,
+    points: list[ModbusPoint],
+    http_values: dict[str, object],
+    interval_seconds: int,
+    timeout_seconds: float,
+) -> None:
+    store = RegisterStore()
+    initialize_register_store(points, store)
+    ValueRefreshWorker(points, SnapshotProvider(http_values), store, interval_seconds).refresh_once(initial=True)
+    context = ReadonlyHoldingRegisterContext(store)
+    thread = threading.Thread(
+        target=run_modbus_server,
+        args=(context, host, port),
+        name="embedded-modbus-server",
+        daemon=True,
+    )
+    thread.start()
+    _wait_for_tcp(host, port, timeout_seconds)
+    print(f"已启动内置Modbus Server,host={host}, port={port}, {store.describe()}")
+
+
+class SnapshotProvider:
+    def __init__(self, values: dict[str, object]):
+        self.values = values
+
+    def fetch_values(self, point_ids: list[str]) -> dict[str, object]:
+        return {point_id: self.values[point_id] for point_id in point_ids if point_id in self.values}
+
+
+def _wait_for_tcp(host: str, port: int, timeout_seconds: float) -> None:
+    deadline = time.monotonic() + timeout_seconds
+    last_error = None
+    while time.monotonic() < deadline:
+        try:
+            with socket.create_connection((host, port), timeout=0.2):
+                return
+        except OSError as exc:
+            last_error = exc
+            time.sleep(0.1)
+    raise RuntimeError(f"等待内置Modbus Server启动超时: {host}:{port}, last_error={last_error}")
+
+
+def _read_modbus_values(host: str, port: int, timeout: float, points: list[ModbusPoint]) -> dict[str, list[int] | str]:
+    client = ModbusTcpClient(host, port=port, timeout=timeout)
+    if not client.connect():
+        raise RuntimeError(f"Modbus连接失败: {host}:{port}")
+
+    try:
+        results: dict[str, list[int] | str] = {}
+        for point in points:
+            response = client.read_holding_registers(
+                point.address,
+                count=point.register_count,
+                device_id=point.slave_id,
+            )
+            if response.isError():
+                results[point.point_id] = str(response)
+            else:
+                results[point.point_id] = list(response.registers)
+        return results
+    finally:
+        client.close()
+
+
+def _read_http_values(provider: HttpValueProvider, point_ids: list[str]) -> dict[str, object]:
+    values: dict[str, object] = {}
+    for start in range(0, len(point_ids), DEFAULT_BATCH_SIZE):
+        batch = point_ids[start:start + DEFAULT_BATCH_SIZE]
+        values.update(provider.fetch_values(batch))
+    return values
+
+
+def _compare(
+    points: list[ModbusPoint],
+    modbus_results: dict[str, list[int] | str],
+    http_values: dict[str, object],
+    max_details: int,
+) -> int:
+    matched = 0
+    modbus_errors = []
+    http_missing = []
+    mismatches = []
+
+    for point in points:
+        modbus_value = modbus_results.get(point.point_id)
+        if not isinstance(modbus_value, list):
+            modbus_errors.append((point, modbus_value))
+            continue
+        if point.point_id not in http_values:
+            http_missing.append(point)
+            continue
+
+        http_value = http_values[point.point_id]
+        try:
+            expected_registers = encode_registers(http_value, point.data_type)
+        except Exception as exc:
+            mismatches.append((point, modbus_value, f"HTTP值编码失败: {exc}", http_value))
+            continue
+
+        if modbus_value == expected_registers:
+            matched += 1
+        else:
+            mismatches.append((point, modbus_value, expected_registers, http_value))
+
+    print(
+        "比较完成: "
+        f"点位总数={len(points)}, 一致={matched}, 不一致={len(mismatches)}, "
+        f"Modbus读取失败={len(modbus_errors)}, HTTP缺失={len(http_missing)}"
+    )
+    if len(modbus_errors) == len(points):
+        print(
+            "诊断: 所有点位都 Modbus 读取失败,本次没有完成有效比较。"
+            "请确认连接的是当前启动的 Modbus Server,并检查服务端日志里的寄存器存储初始化范围和非法地址日志。"
+        )
+
+    _print_modbus_errors(modbus_errors, max_details)
+    _print_http_missing(http_missing, max_details)
+    _print_mismatches(mismatches, max_details)
+    return 0 if not modbus_errors and not http_missing and not mismatches else 1
+
+
+def _point_label(point: ModbusPoint) -> str:
+    return (
+        f"point_id={point.point_id}, name={point.name}, data_type={point.data_type}, "
+        f"slave_id={point.slave_id}, address={point.address}"
+    )
+
+
+def _print_modbus_errors(errors, max_details: int) -> None:
+    for point, error in errors[:max_details]:
+        print(f"Modbus读取失败: {_point_label(point)}, error={error}")
+    if len(errors) > max_details:
+        print(f"Modbus读取失败还有 {len(errors) - max_details} 条未显示")
+
+
+def _print_http_missing(points: list[ModbusPoint], max_details: int) -> None:
+    for point in points[:max_details]:
+        print(f"HTTP缺失: {_point_label(point)}")
+    if len(points) > max_details:
+        print(f"HTTP缺失还有 {len(points) - max_details} 条未显示")
+
+
+def _print_mismatches(mismatches, max_details: int) -> None:
+    for point, modbus_value, expected_registers, http_value in mismatches[:max_details]:
+        print(
+            f"不一致: {_point_label(point)}, "
+            f"modbus_registers={modbus_value}, http_value={http_value}, expected_registers={expected_registers}"
+        )
+    if len(mismatches) > max_details:
+        print(f"不一致还有 {len(mismatches) - max_details} 条未显示")
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 161 - 0
tests/import_modbus_server_points.py

@@ -0,0 +1,161 @@
+"""One-off script to import mock Modbus server points from Excel into PostgreSQL."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+
+import psycopg2
+from openpyxl import load_workbook
+from psycopg2.extras import execute_values
+
+
+BASE_DIR = Path(__file__).resolve().parent
+EXCEL_PATH = BASE_DIR / "mock_modbus_server_points.xlsx"
+
+DB_CONFIG = {
+    "host": "192.168.1.109",
+    "port": 48324,
+    "dbname": "proj_dev2024_config",
+    "user": "postgres",
+    "password": "aragronprod",
+}
+
+TABLE_NAME = "modbus_server_point"
+BATCH_SIZE = 200
+EXPECTED_HEADERS = ["point_id", "name", "data_type", "slave_id", "address"]
+SUPPORTED_DATA_TYPES = {"int16", "int32", "float32"}
+
+
+@dataclass(frozen=True)
+class ModbusPoint:
+    point_id: str
+    name: str
+    data_type: str
+    slave_id: int
+    address: int
+
+
+def main() -> None:
+    points = load_points_from_xlsx(EXCEL_PATH)
+    validate_points(points)
+    insert_points(points)
+    print(f"Inserted {len(points)} rows into {TABLE_NAME}")
+
+
+def load_points_from_xlsx(path: Path) -> list[ModbusPoint]:
+    if not path.exists():
+        raise FileNotFoundError(path)
+
+    workbook = load_workbook(path, read_only=True, data_only=True)
+    worksheet = workbook.active
+
+    try:
+        rows = worksheet.iter_rows(values_only=True)
+        headers = [normalize_header(value) for value in next(rows)]
+        if headers != EXPECTED_HEADERS:
+            raise ValueError(f"Excel headers must be {EXPECTED_HEADERS}, got {headers}")
+
+        points: list[ModbusPoint] = []
+        for row_number, row in enumerate(rows, start=2):
+            if not any(value is not None and str(value).strip() for value in row):
+                continue
+
+            values = pad_row(list(row), len(EXPECTED_HEADERS))
+            points.append(
+                ModbusPoint(
+                    point_id=require_text(values[0], row_number, "point_id"),
+                    name=require_text(values[1], row_number, "name"),
+                    data_type=require_text(values[2], row_number, "data_type"),
+                    slave_id=require_int(values[3], row_number, "slave_id"),
+                    address=require_int(values[4], row_number, "address"),
+                )
+            )
+        return points
+    finally:
+        workbook.close()
+
+
+def normalize_header(value: object) -> str:
+    return "" if value is None else str(value).strip()
+
+
+def pad_row(row: list[object], length: int) -> list[object | None]:
+    return row[:length] + [None] * max(0, length - len(row))
+
+
+def require_text(value: object, row_number: int, field: str) -> str:
+    if value is None or str(value).strip() == "":
+        raise ValueError(f"row {row_number}: {field} is required")
+    return str(value).strip()
+
+
+def require_int(value: object, row_number: int, field: str) -> int:
+    if isinstance(value, bool):
+        raise ValueError(f"row {row_number}: {field} must be an integer")
+    if isinstance(value, int):
+        return value
+    if isinstance(value, float) and value.is_integer():
+        return int(value)
+    if isinstance(value, str) and value.strip().isdigit():
+        return int(value.strip())
+    raise ValueError(f"row {row_number}: {field} must be an integer")
+
+
+def validate_points(points: list[ModbusPoint]) -> None:
+    if not points:
+        raise ValueError("Excel has no data rows")
+
+    errors: list[str] = []
+    seen_point_ids: set[str] = set()
+    seen_slave_addresses: set[tuple[int, int]] = set()
+
+    for point in points:
+        if len(point.point_id) > 128:
+            errors.append(f"point_id too long: {point.point_id}")
+        if len(point.name) > 128:
+            errors.append(f"name too long: {point.point_id}")
+        if point.data_type not in SUPPORTED_DATA_TYPES:
+            errors.append(f"unsupported data_type: {point.point_id}={point.data_type}")
+        if not 1 <= point.slave_id <= 247:
+            errors.append(f"slave_id out of range: {point.point_id}={point.slave_id}")
+        if point.address < 0:
+            errors.append(f"address out of range: {point.point_id}={point.address}")
+        if point.point_id in seen_point_ids:
+            errors.append(f"duplicate point_id: {point.point_id}")
+
+        slave_address = (point.slave_id, point.address)
+        if slave_address in seen_slave_addresses:
+            errors.append(f"duplicate slave_id/address: {point.slave_id}/{point.address}")
+
+        seen_point_ids.add(point.point_id)
+        seen_slave_addresses.add(slave_address)
+
+    if errors:
+        preview = "\n".join(errors[:50])
+        suffix = "" if len(errors) <= 50 else f"\n... total errors={len(errors)}"
+        raise ValueError(f"Point validation failed:\n{preview}{suffix}")
+
+
+def insert_points(points: list[ModbusPoint]) -> None:
+    sql = f"""
+        INSERT INTO {TABLE_NAME} (point_id, name, data_type, slave_id, address)
+        VALUES %s
+    """
+    rows = [(point.point_id, point.name, point.data_type, point.slave_id, point.address) for point in points]
+
+    conn = psycopg2.connect(**DB_CONFIG)
+    try:
+        with conn.cursor() as cursor:
+            for start in range(0, len(rows), BATCH_SIZE):
+                execute_values(cursor, sql, rows[start:start + BATCH_SIZE])
+        conn.commit()
+    except Exception:
+        conn.rollback()
+        raise
+    finally:
+        conn.close()
+
+
+if __name__ == "__main__":
+    main()

+ 242 - 0
tests/stress_modbus_clients.py

@@ -0,0 +1,242 @@
+"""Stress test Modbus TCP reads with multiple concurrent clients.
+
+Run this while the Modbus server is already running.
+"""
+
+import argparse
+import statistics
+import sys
+import threading
+import time
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from dataclasses import dataclass
+from pathlib import Path
+
+from pymodbus.client import ModbusTcpClient
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+from app_config import load_config  # noqa: E402
+from db import create_connection  # noqa: E402
+from point_loader import load_points  # noqa: E402
+from point_model import ModbusPoint  # noqa: E402
+
+
+@dataclass(frozen=True)
+class ClientResult:
+    client_index: int
+    elapsed_seconds: float
+    success_points: int
+    failed_points: int
+    success_registers: int
+    latencies_seconds: list[float]
+    errors: list[str]
+
+
+def parse_args() -> argparse.Namespace:
+    parser = argparse.ArgumentParser(description="Stress test concurrent pymodbus clients reading all configured points.")
+    parser.add_argument("--config", default="config.yaml", help="config file path")
+    parser.add_argument("--modbus-host", help="Modbus TCP host. Defaults to config host, 0.0.0.0 becomes 127.0.0.1")
+    parser.add_argument("--modbus-port", type=int, help="Modbus TCP port. Defaults to config port")
+    parser.add_argument("--client-counts", default="4,8", help="comma-separated concurrent client counts")
+    parser.add_argument("--repeat", type=int, default=1, help="repeat count for each client count")
+    parser.add_argument("--duration", type=float, default=0, help="seconds to keep reading; 0 means read all points once")
+    parser.add_argument("--timeout", type=float, default=3, help="Modbus client timeout seconds")
+    parser.add_argument("--limit", type=int, default=0, help="limit point count; 0 means all points")
+    parser.add_argument("--max-errors", type=int, default=20, help="max error samples to print per run")
+    return parser.parse_args()
+
+
+def main() -> int:
+    args = parse_args()
+    config = load_config(args.config)
+    points = _load_points(config)
+    if args.limit > 0:
+        points = points[:args.limit]
+    if not points:
+        print("没有可读取的点位")
+        return 2
+
+    host = args.modbus_host or _client_host(config.modbus.host)
+    port = args.modbus_port or config.modbus.port
+    client_counts = _parse_client_counts(args.client_counts)
+
+    print(
+        f"压力测试开始: host={host}, port={port}, 点位数={len(points)}, "
+        f"client并发={client_counts}, repeat={args.repeat}, duration={args.duration}s, timeout={args.timeout}s"
+    )
+
+    exit_code = 0
+    for client_count in client_counts:
+        for run_index in range(1, args.repeat + 1):
+            results = _run_once(host, port, args.timeout, args.duration, points, client_count)
+            if _print_run_summary(client_count, run_index, args.duration, points, results, args.max_errors) != 0:
+                exit_code = 1
+    return exit_code
+
+
+def _load_points(config) -> list[ModbusPoint]:
+    conn = create_connection(config.db)
+    try:
+        return load_points(conn)
+    finally:
+        conn.close()
+
+
+def _client_host(host: str) -> str:
+    return "127.0.0.1" if host in {"", "0.0.0.0", "::"} else host
+
+
+def _parse_client_counts(raw: str) -> list[int]:
+    client_counts = [int(item.strip()) for item in raw.split(",") if item.strip()]
+    if not client_counts or any(item <= 0 for item in client_counts):
+        raise ValueError("--client-counts 必须是正整数列表,例如 4,8")
+    return client_counts
+
+
+def _run_once(
+    host: str,
+    port: int,
+    timeout: float,
+    duration: float,
+    points: list[ModbusPoint],
+    client_count: int,
+) -> list[ClientResult]:
+    start_barrier = threading.Barrier(client_count)
+    with ThreadPoolExecutor(max_workers=client_count) as executor:
+        futures = [
+            executor.submit(_read_all_points, index, host, port, timeout, duration, points, start_barrier)
+            for index in range(1, client_count + 1)
+        ]
+        return [future.result() for future in as_completed(futures)]
+
+
+def _read_all_points(
+    client_index: int,
+    host: str,
+    port: int,
+    timeout: float,
+    duration: float,
+    points: list[ModbusPoint],
+    start_barrier: threading.Barrier,
+) -> ClientResult:
+    errors: list[str] = []
+    success_points = 0
+    failed_points = 0
+    success_registers = 0
+    latencies_seconds: list[float] = []
+
+    start_barrier.wait(timeout + 10)
+    started_at = time.perf_counter()
+    client = ModbusTcpClient(host, port=port, timeout=timeout)
+    try:
+        if not client.connect():
+            return ClientResult(client_index, time.perf_counter() - started_at, 0, len(points), 0, [], ["连接失败"])
+
+        deadline = started_at + duration if duration > 0 else None
+        while True:
+            for point in points:
+                if deadline is not None and time.perf_counter() >= deadline:
+                    break
+                request_started_at = time.perf_counter()
+                try:
+                    response = client.read_holding_registers(
+                        point.address,
+                        count=point.register_count,
+                        device_id=point.slave_id,
+                    )
+                except Exception as exc:
+                    latencies_seconds.append(time.perf_counter() - request_started_at)
+                    failed_points += 1
+                    errors.append(f"{point.point_id}: exception={exc}")
+                    continue
+
+                latencies_seconds.append(time.perf_counter() - request_started_at)
+                if response.isError():
+                    failed_points += 1
+                    errors.append(f"{point.point_id}: response={response}")
+                    continue
+
+                success_points += 1
+                success_registers += len(response.registers)
+            if deadline is None or time.perf_counter() >= deadline:
+                break
+    finally:
+        client.close()
+
+    return ClientResult(
+        client_index=client_index,
+        elapsed_seconds=time.perf_counter() - started_at,
+        success_points=success_points,
+        failed_points=failed_points,
+        success_registers=success_registers,
+        latencies_seconds=latencies_seconds,
+        errors=errors,
+    )
+
+
+def _print_run_summary(
+    client_count: int,
+    run_index: int,
+    duration: float,
+    points: list[ModbusPoint],
+    results: list[ClientResult],
+    max_errors: int,
+) -> int:
+    durations = [result.elapsed_seconds for result in results]
+    wall_seconds = max(durations) if durations else 0
+    total_success_points = sum(result.success_points for result in results)
+    total_failed_points = sum(result.failed_points for result in results)
+    total_success_registers = sum(result.success_registers for result in results)
+    expected_points = None if duration > 0 else client_count * len(points)
+    point_reads_per_second = total_success_points / wall_seconds if wall_seconds else 0
+    register_reads_per_second = total_success_registers / wall_seconds if wall_seconds else 0
+    latencies = [latency for result in results for latency in result.latencies_seconds]
+
+    expected_text = "持续读取" if expected_points is None else f"预期点位读取={expected_points}"
+    print(
+        f"\n并发client={client_count}, run={run_index}: "
+        f"总耗时={wall_seconds:.3f}s, {expected_text}, "
+        f"成功点位读取={total_success_points}, 失败点位读取={total_failed_points}, "
+        f"点位吞吐={point_reads_per_second:.2f}/s, 寄存器吞吐={register_reads_per_second:.2f}/s"
+    )
+    print(
+        f"client耗时: min={min(durations):.3f}s, avg={statistics.mean(durations):.3f}s, "
+        f"p95={_percentile(durations, 95):.3f}s, max={max(durations):.3f}s"
+    )
+    if latencies:
+        print(
+            "单次请求响应耗时: "
+            f"min={min(latencies) * 1000:.2f}ms, avg={statistics.mean(latencies) * 1000:.2f}ms, "
+            f"p50={_percentile(latencies, 50) * 1000:.2f}ms, "
+            f"p95={_percentile(latencies, 95) * 1000:.2f}ms, "
+            f"p99={_percentile(latencies, 99) * 1000:.2f}ms, max={max(latencies) * 1000:.2f}ms"
+        )
+
+    for result in sorted(results, key=lambda item: item.client_index):
+        print(
+            f"client#{result.client_index}: 耗时={result.elapsed_seconds:.3f}s, "
+            f"成功={result.success_points}, 失败={result.failed_points}, 寄存器={result.success_registers}"
+        )
+
+    error_samples = [error for result in results for error in result.errors[:max_errors]]
+    for error in error_samples[:max_errors]:
+        print(f"错误样例: {error}")
+    if len(error_samples) > max_errors:
+        print(f"错误样例还有 {len(error_samples) - max_errors} 条未显示")
+
+    if expected_points is None:
+        return 0 if total_success_points > 0 and total_failed_points == 0 else 1
+    return 0 if total_success_points == expected_points and total_failed_points == 0 else 1
+
+
+def _percentile(values: list[float], percentile: int) -> float:
+    if not values:
+        return 0
+    ordered = sorted(values)
+    index = round((len(ordered) - 1) * percentile / 100)
+    return ordered[index]
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 56 - 0
tests/test_app_config.py

@@ -0,0 +1,56 @@
+import tempfile
+import unittest
+from pathlib import Path
+
+from app_config import load_config
+
+
+class AppConfigTest(unittest.TestCase):
+    def test_load_config_with_defaults(self):
+        with tempfile.TemporaryDirectory() as tmp_dir:
+            path = Path(tmp_dir) / "config.yaml"
+            path.write_text(
+                """
+db:
+  host: 127.0.0.1
+  port: 5432
+  database: test_db
+  user: postgres
+  password: secret
+""".strip(),
+                encoding="utf-8",
+            )
+
+            config = load_config(path)
+
+        self.assertEqual(config.db.host, "127.0.0.1")
+        self.assertEqual(config.db.port, 5432)
+        self.assertEqual(config.modbus.host, "0.0.0.0")
+        self.assertEqual(config.modbus.port, 502)
+        self.assertEqual(config.modbus.interval, 5)
+        self.assertEqual(config.logging.retention_days, 3)
+
+    def test_load_modbus_interval(self):
+        with tempfile.TemporaryDirectory() as tmp_dir:
+            path = Path(tmp_dir) / "config.yaml"
+            path.write_text(
+                """
+db:
+  host: 127.0.0.1
+  port: 5432
+  database: test_db
+  user: postgres
+  password: secret
+modbus:
+  interval: 10
+""".strip(),
+                encoding="utf-8",
+            )
+
+            config = load_config(path)
+
+        self.assertEqual(config.modbus.interval, 10)
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 23 - 0
tests/test_modbus_codec.py

@@ -0,0 +1,23 @@
+import unittest
+
+from modbus_codec import encode_registers
+
+
+class ModbusCodecTest(unittest.TestCase):
+    def test_encode_int16(self):
+        self.assertEqual(encode_registers(1, "int16"), [1])
+        self.assertEqual(encode_registers(-1, "int16"), [0xFFFF])
+
+    def test_encode_int32_abcd(self):
+        self.assertEqual(encode_registers(0x12345678, "int32"), [0x1234, 0x5678])
+
+    def test_encode_float32_abcd(self):
+        self.assertEqual(encode_registers(1.0, "float32"), [0x3F80, 0x0000])
+
+    def test_encode_invalid_type(self):
+        with self.assertRaises(ValueError):
+            encode_registers(1, "uint16")
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 104 - 0
tests/test_point_loader.py

@@ -0,0 +1,104 @@
+import unittest
+
+from point_loader import check_point_exists, load_points, validate_address_overlaps, validate_data_types
+from point_model import ModbusPoint
+
+
+class FakeCursor:
+    def __init__(self, existing):
+        self.existing = set(existing)
+        self.batch_sizes = []
+        self.current_batch = []
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, exc_type, exc, tb):
+        return False
+
+    def execute(self, sql, params=None):
+        self.current_batch = list(params[0])
+        self.batch_sizes.append(len(self.current_batch))
+
+    def fetchall(self):
+        return [(point_id,) for point_id in self.current_batch if point_id in self.existing]
+
+
+class FakeConnection:
+    def __init__(self, existing):
+        self.cursor_obj = FakeCursor(existing)
+
+    def cursor(self):
+        return self.cursor_obj
+
+
+class LoadPointsCursor:
+    def __init__(self):
+        self.sql = ""
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, exc_type, exc, tb):
+        return False
+
+    def execute(self, sql, params=None):
+        self.sql = sql
+
+    def fetchall(self):
+        return [("p1", "P1", "int16", 1, 0)]
+
+
+class LoadPointsConnection:
+    def __init__(self):
+        self.cursor_obj = LoadPointsCursor()
+
+    def cursor(self):
+        return self.cursor_obj
+
+
+class PointLoaderTest(unittest.TestCase):
+    def test_load_points_does_not_filter_enabled(self):
+        conn = LoadPointsConnection()
+
+        points = load_points(conn)
+
+        self.assertEqual(points[0].point_id, "p1")
+        self.assertNotIn("enabled", conn.cursor_obj.sql.lower())
+
+    def test_validate_address_overlaps(self):
+        points = [
+            ModbusPoint("a", "A", "float32", 1, 0),
+            ModbusPoint("b", "B", "int16", 1, 1),
+            ModbusPoint("c", "C", "int16", 2, 1),
+        ]
+
+        errors = validate_address_overlaps(points)
+
+        self.assertEqual(len(errors), 1)
+        self.assertIn("地址重叠", errors[0])
+        self.assertIn("a", errors[0])
+        self.assertIn("b", errors[0])
+
+    def test_validate_data_types(self):
+        points = [
+            ModbusPoint("a", "A", "float32", 1, 0),
+            ModbusPoint("b", "B", "bad", 1, 2),
+        ]
+
+        errors = validate_data_types(points)
+
+        self.assertEqual(errors, ["point_id=b, data_type=bad"])
+
+    def test_check_point_exists_batches_by_200(self):
+        point_ids = [f"p{i}" for i in range(205)]
+        conn = FakeConnection(existing=point_ids[:203])
+
+        missing = check_point_exists(conn, point_ids)
+
+        self.assertEqual(missing, ["p203", "p204"])
+        self.assertEqual(conn.cursor_obj.batch_sizes, [200, 5])
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 44 - 0
tests/test_register_store_context.py

@@ -0,0 +1,44 @@
+import asyncio
+import unittest
+
+from pymodbus.constants import ExcCodes
+
+from modbus_context import ReadonlyHoldingRegisterContext
+from point_model import ModbusPoint
+from register_store import RegisterStore, initialize_register_store
+
+
+class RegisterStoreContextTest(unittest.TestCase):
+    def test_store_read_write_and_invalid_address(self):
+        store = RegisterStore()
+        points = [ModbusPoint("a", "A", "float32", 1, 0)]
+        initialize_register_store(points, store)
+
+        self.assertEqual(store.read_holding_registers(1, 0, 2), [0, 0])
+        store.write_internal(1, 0, [0x3F80, 0x0000])
+        self.assertEqual(store.read_holding_registers(1, 0, 2), [0x3F80, 0x0000])
+        self.assertEqual(store.read_holding_registers(1, 1, 2), ExcCodes.ILLEGAL_ADDRESS)
+
+        with self.assertRaises(KeyError):
+            store.write_internal(1, 2, [1])
+
+    def test_context_is_readonly_holding_register_only(self):
+        store = RegisterStore()
+        initialize_register_store([ModbusPoint("a", "A", "int16", 1, 10)], store)
+        context = ReadonlyHoldingRegisterContext(store)
+
+        async def run():
+            read_values = await context.async_getValues(1, 3, 10, 1)
+            input_read = await context.async_getValues(1, 4, 10, 1)
+            write_result = await context.async_setValues(1, 6, 10, [1])
+            return read_values, input_read, write_result
+
+        read_values, input_read, write_result = asyncio.run(run())
+
+        self.assertEqual(read_values, [0])
+        self.assertEqual(input_read, ExcCodes.ILLEGAL_FUNCTION)
+        self.assertEqual(write_result, ExcCodes.ILLEGAL_FUNCTION)
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 89 - 0
tests/test_value_refresh.py

@@ -0,0 +1,89 @@
+import logging
+import unittest
+
+from point_model import ModbusPoint
+from register_store import RegisterStore, initialize_register_store
+from value_provider import ValueProvider
+from value_refresh import ValueRefreshWorker
+
+
+class FakeProvider(ValueProvider):
+    def __init__(self, values):
+        self.values = values
+
+    def fetch_values(self, point_ids):
+        return {point_id: self.values[point_id] for point_id in point_ids if point_id in self.values}
+
+
+class FailingProvider(ValueProvider):
+    def fetch_values(self, point_ids):
+        raise RuntimeError("boom")
+
+
+class FailingMiddleBatchProvider(ValueProvider):
+    def __init__(self):
+        self.called_batches = []
+
+    def fetch_values(self, point_ids):
+        self.called_batches.append(point_ids)
+        if point_ids[0] == "p200":
+            raise RuntimeError("boom")
+        return {point_id: int(point_id[1:]) for point_id in point_ids}
+
+
+class ValueRefreshTest(unittest.TestCase):
+    def setUp(self):
+        logging.disable(logging.CRITICAL)
+
+    def tearDown(self):
+        logging.disable(logging.NOTSET)
+
+    def test_initial_missing_value_raises(self):
+        points = [
+            ModbusPoint("a", "A", "int16", 1, 0),
+            ModbusPoint("b", "B", "int16", 1, 1),
+        ]
+        store = RegisterStore()
+        initialize_register_store(points, store)
+        worker = ValueRefreshWorker(points, FakeProvider({"a": 12}), store, 5)
+
+        with self.assertRaises(RuntimeError):
+            worker.refresh_once(initial=True)
+
+        self.assertEqual(store.read_holding_registers(1, 0, 2), [12, 0])
+
+    def test_periodic_missing_value_keeps_old_value(self):
+        points = [ModbusPoint("a", "A", "int16", 1, 0)]
+        store = RegisterStore()
+        initialize_register_store(points, store)
+        ValueRefreshWorker(points, FakeProvider({"a": 5}), store, 5).refresh_once(initial=True)
+        ValueRefreshWorker(points, FakeProvider({}), store, 5).refresh_once(initial=False)
+
+        self.assertEqual(store.read_holding_registers(1, 0, 1), [5])
+
+    def test_initial_provider_failure_raises(self):
+        points = [ModbusPoint("a", "A", "int16", 1, 0)]
+        store = RegisterStore()
+        initialize_register_store(points, store)
+
+        with self.assertRaises(RuntimeError):
+            ValueRefreshWorker(points, FailingProvider(), store, 5).refresh_once(initial=True)
+
+        self.assertEqual(store.read_holding_registers(1, 0, 1), [0])
+
+    def test_periodic_provider_failure_continues_next_batch(self):
+        points = [ModbusPoint(f"p{i}", f"P{i}", "int16", 1, i) for i in range(401)]
+        store = RegisterStore()
+        initialize_register_store(points, store)
+        provider = FailingMiddleBatchProvider()
+
+        ValueRefreshWorker(points, provider, store, 5).refresh_once(initial=False)
+
+        self.assertEqual(len(provider.called_batches), 3)
+        self.assertEqual(store.read_holding_registers(1, 0, 1), [0])
+        self.assertEqual(store.read_holding_registers(1, 199, 3), [199, 0, 0])
+        self.assertEqual(store.read_holding_registers(1, 400, 1), [400])
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 9 - 0
value_provider.py

@@ -0,0 +1,9 @@
+"""Realtime value provider abstraction."""
+
+from abc import ABC, abstractmethod
+
+
+class ValueProvider(ABC):
+    @abstractmethod
+    def fetch_values(self, point_ids: list[str]) -> dict[str, object]:
+        """Return point_id to value mapping."""

+ 76 - 0
value_refresh.py

@@ -0,0 +1,76 @@
+"""Refresh realtime values into the register store."""
+
+import logging
+import threading
+import time
+
+from constants import DEFAULT_BATCH_SIZE
+from modbus_codec import encode_registers
+
+logger = logging.getLogger(__name__)
+
+
+class ValueRefreshWorker(threading.Thread):
+    def __init__(self, points, provider, store, interval_seconds: int):
+        super().__init__(name="value-refresh-worker", daemon=True)
+        self.points = points
+        self.provider = provider
+        self.store = store
+        self.interval_seconds = interval_seconds
+
+    def run(self) -> None:
+        logger.info("实时值刷新线程已启动,刷新周期=%s秒", self.interval_seconds)
+        while True:
+            try:
+                self.refresh_once(initial=False)
+            except Exception:
+                logger.exception("实时值刷新失败")
+            time.sleep(self.interval_seconds)
+
+    def refresh_once(self, initial: bool) -> None:
+        start_time = time.perf_counter()
+        point_by_id = {point.point_id: point for point in self.points}
+        point_ids = list(point_by_id)
+        total_batches = 0
+        failed_batches = 0
+        try:
+            for start in range(0, len(point_ids), DEFAULT_BATCH_SIZE):
+                total_batches += 1
+                batch = point_ids[start:start + DEFAULT_BATCH_SIZE]
+                try:
+                    values = self.provider.fetch_values(batch)
+                except Exception:
+                    failed_batches += 1
+                    if not initial:
+                        logger.exception("周期刷新实时值请求失败,跳过当前批次,起始序号=%d,数量=%d", start, len(batch))
+                        continue
+                    logger.exception("初始化实时值请求失败,起始序号=%d,数量=%d", start, len(batch))
+                    raise
+                missing_point_ids = []
+                for point_id in batch:
+                    point = point_by_id[point_id]
+                    if point_id not in values:
+                        if initial:
+                            logger.error("初始化实时值缺失,point_id=%s", point_id)
+                            missing_point_ids.append(point_id)
+                            continue
+                        else:
+                            logger.warning("周期刷新实时值缺失,point_id=%s,保持旧值", point_id)
+                            continue
+                    else:
+                        value = values[point_id]
+                    registers = encode_registers(value, point.data_type)
+                    self.store.write_internal(point.slave_id, point.address, registers)
+                if missing_point_ids:
+                    raise RuntimeError(f"初始化实时值缺失,数量={len(missing_point_ids)}")
+        finally:
+            elapsed_ms = (time.perf_counter() - start_time) * 1000
+            phase = "初始化" if initial else "周期刷新"
+            logger.info(
+                "%s实时值HTTP请求完成,点位总数=%d,批次数=%d,失败批次数=%d,耗时=%.2fms",
+                phase,
+                len(point_ids),
+                total_batches,
+                failed_batches,
+                elapsed_ms,
+            )

+ 19 - 0
wiki/init.sql

@@ -0,0 +1,19 @@
+CREATE TABLE modbus_server_point (
+    id           SERIAL PRIMARY KEY,
+
+    point_id     VARCHAR(128) NOT NULL,
+    name         VARCHAR(128) NOT NULL,
+
+    data_type    VARCHAR(16) NOT NULL CHECK (
+        data_type IN ('int16', 'int32', 'float32')
+    ),
+
+    slave_id     INTEGER NOT NULL CHECK (slave_id BETWEEN 1 AND 247),
+    address      INTEGER NOT NULL CHECK (address >= 0),
+
+    create_time  TIMESTAMPTZ NOT NULL DEFAULT now(),
+    update_time  TIMESTAMPTZ NOT NULL DEFAULT now(),
+
+    CONSTRAINT uk_modbus_point_point_id UNIQUE (point_id),
+    CONSTRAINT uk_modbus_point_slave_address UNIQUE (slave_id, address)
+);

二進制
wiki/mock_modbus_server_points.xlsx


+ 688 - 0
wiki/modbus_server_design_and_implementation.md

@@ -0,0 +1,688 @@
+# Modbus Server 设计与实现
+
+## 目标
+
+实现一个只读 Modbus TCP Server。程序启动时从 `modbus_server_point` 加载点位配置,校验点位是否存在于 `pt_point`,校验 Modbus 保持寄存器地址是否重叠,然后通过实时值 Provider 初始化寄存器数据。启动完成后,后台线程周期性刷新点位值并更新寄存器。
+
+当前实时值来源为 HTTP 接口,后续可以扩展为其他来源,例如 MQTT、数据库、消息队列等。
+
+## 技术栈
+
+- Python 3.10
+- pymodbus 3.13.1
+- requests
+- psycopg2-binary
+- PyYAML
+- logging 标准库
+
+## 约束规则
+
+- 所有点位映射到 Holding Register。
+- Modbus Client 只能读取,不允许写入。
+- 默认 Modbus TCP 端口为 `502`,可通过配置文件修改。
+- `modbus_server_point.address` 就是保持寄存器起始地址,不做额外偏移。
+- 字节顺序固定为 `ABCD`。
+- HTTP 响应不处理 `quality` 字段,只根据 `data_type` 转换 `value`。
+- 更新周期默认 `5` 秒。
+- 日志默认保留最近 `3` 天。
+- Client 连接和断开需要打印日志,但不记录连接数量。
+- 初始化过程中的每一步都必须打印中文日志。
+
+## 数据类型与地址占用
+
+| data_type | 寄存器数量 | 编码 |
+| --- | ---: | --- |
+| int16 | 1 | signed int16, big-endian |
+| int32 | 2 | signed int32, ABCD |
+| float32 | 2 | IEEE754 float32, ABCD |
+
+地址范围计算:
+
+```text
+int16   address ~ address
+int32   address ~ address + 1
+float32 address ~ address + 1
+```
+
+例如:
+
+```text
+float32 point_a address=0,占用 0,1
+int16   point_b address=1,占用 1
+```
+
+这属于地址重叠,程序启动时打印错误日志并退出。
+
+## 数据库配置
+
+```yaml
+db:
+  host: 192.168.1.109
+  port: 48324
+  database: proj_dev2024_config
+  user: postgres
+  password: aragronprod
+```
+
+## 完整配置示例
+
+配置文件使用 `config.yaml`。Python 标准库不支持 YAML 解析,工程需要增加 `PyYAML` 依赖。
+
+```yaml
+db:
+  host: 192.168.1.109
+  port: 48324
+  database: proj_dev2024_config
+  user: postgres
+  password: aragronprod
+
+modbus:
+  host: 0.0.0.0
+  port: 502
+  interval: 5
+
+http_provider:
+  url: http://192.168.1.109:18503/data/get_points_real_value
+  timeout_seconds: 5
+
+logging:
+  dir: logs
+  retention_days: 3
+  level: INFO
+```
+
+## 初始化流程
+
+```text
+1. 读取 config.yaml
+2. 初始化日志
+3. 打印启动日志和关键配置
+4. 查询 modbus_server_point 中的全部点位
+5. 如果表内没有点位,打印 warning,不退出,启动空 Modbus Server
+6. 校验 data_type 是否为 int16/int32/float32
+7. 校验同一 slave_id 下地址是否重叠
+8. 如果有地址重叠,统一打印并写日志,然后退出
+9. 分批查询 pt_point,确认 point_id 存在
+10. 如果有缺失 point_id,全部批次查询完成后统一打印并写日志,然后退出
+11. 通过 ValueProvider 获取初始实时值
+12. 初始化阶段缺失实时值的点位写入默认值 0,并打印 warning
+13. 初始化只读 Holding Register 存储
+14. 启动后台刷新线程
+15. 启动 Modbus TCP Server
+```
+
+## 异常处理策略
+
+| 场景 | 处理 |
+| --- | --- |
+| `modbus_server_point` 无点位 | 打印 warning,不退出,启动空 Server |
+| `data_type` 非法 | 打印 error,退出 |
+| Modbus 地址重叠 | 打印 error,退出 |
+| `pt_point` 缺失 point_id | 全部批次查完后统一打印 error,退出 |
+| HTTP 初始化缺失实时值 | 写默认值 0,打印 warning,不退出 |
+| HTTP 周期刷新缺失实时值 | 保持旧值,打印 warning,不退出 |
+| HTTP 请求失败 | 打印 error,本轮跳过,不退出 |
+| Client 写寄存器 | 返回 Modbus 异常,不修改数据 |
+
+## 模块划分
+
+建议目录结构:
+
+```text
+modbus_server_nd/
+  main.py
+  app_config.py
+  constants.py
+  logging_config.py
+  db.py
+  point_model.py
+  point_loader.py
+  value_provider.py
+  http_value_provider.py
+  modbus_codec.py
+  register_store.py
+  modbus_context.py
+  modbus_server.py
+config.yaml
+```
+
+模块职责:
+
+| 模块 | 职责 |
+| --- | --- |
+| main.py | 程序入口,编排初始化和启动 |
+| app_config.py | 加载配置和默认值 |
+| constants.py | 代码常量,例如 `DEFAULT_BATCH_SIZE = 200` |
+| logging_config.py | 初始化日志轮转 |
+| db.py | PostgreSQL 连接 |
+| point_model.py | 点位数据结构 |
+| point_loader.py | 加载点位、校验地址、校验 pt_point |
+| value_provider.py | 实时值 Provider 抽象接口 |
+| http_value_provider.py | HTTP 实时值 Provider 实现 |
+| modbus_codec.py | value 到寄存器的编码转换 |
+| register_store.py | 线程安全寄存器存储 |
+| modbus_context.py | pymodbus 自定义只读 context |
+| modbus_server.py | 启动 Modbus TCP Server 和后台刷新 |
+
+## 扩展性设计
+
+实时值来源通过接口隔离。
+
+```python
+from abc import ABC, abstractmethod
+
+
+class ValueProvider(ABC):
+    @abstractmethod
+    def fetch_values(self, point_ids: list[str]) -> dict[str, object]:
+        """返回 point_id -> value。"""
+```
+
+当前 HTTP 实现:
+
+```python
+import requests
+
+
+class HttpValueProvider(ValueProvider):
+    def __init__(self, url: str, timeout_seconds: int):
+        self.url = url
+        self.timeout_seconds = timeout_seconds
+
+    def fetch_values(self, point_ids: list[str]) -> dict[str, object]:
+        response = requests.post(
+            self.url,
+            json={"point_ids": point_ids},
+            timeout=self.timeout_seconds,
+        )
+        response.raise_for_status()
+        payload = response.json()
+        if payload.get("state") != 0:
+            raise RuntimeError(f"realtime api failed: {payload}")
+        return {item["point_id"]: item.get("value") for item in payload.get("data", [])}
+```
+
+后续如果改成其他数据源,只新增一个 `ValueProvider` 实现即可。
+
+## 点位模型
+
+```python
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class ModbusPoint:
+    point_id: str
+    name: str
+    data_type: str
+    slave_id: int
+    address: int
+
+    @property
+    def register_count(self) -> int:
+        return 1 if self.data_type == "int16" else 2
+
+    @property
+    def end_address(self) -> int:
+        return self.address + self.register_count - 1
+```
+
+## 地址重叠校验
+
+按 `slave_id` 分组,每个点位展开成地址范围,检查范围是否相交。
+
+```python
+def validate_address_overlaps(points: list[ModbusPoint]) -> list[str]:
+    errors: list[str] = []
+    by_slave: dict[int, list[ModbusPoint]] = {}
+    for point in points:
+        by_slave.setdefault(point.slave_id, []).append(point)
+
+    for slave_id, slave_points in by_slave.items():
+        sorted_points = sorted(slave_points, key=lambda item: item.address)
+        previous: ModbusPoint | None = None
+        for current in sorted_points:
+            if previous and current.address <= previous.end_address:
+                errors.append(
+                    "从站=%s 地址重叠: %s(%s) 范围=%s-%s, %s(%s) 范围=%s-%s"
+                    % (
+                        slave_id,
+                        previous.point_id,
+                        previous.data_type,
+                        previous.address,
+                        previous.end_address,
+                        current.point_id,
+                        current.data_type,
+                        current.address,
+                        current.end_address,
+                    )
+                )
+            if previous is None or current.end_address > previous.end_address:
+                previous = current
+    return errors
+```
+
+## pt_point 分批校验
+
+只查 `point_id` 是否存在,不全表扫描。
+
+批量大小不放入配置文件,代码中固定默认值为 `200`,建议放在 `constants.py`。
+
+```python
+DEFAULT_BATCH_SIZE = 200
+
+
+def check_point_exists(conn, point_ids: list[str]) -> list[str]:
+    existing: set[str] = set()
+    with conn.cursor() as cursor:
+        for start in range(0, len(point_ids), DEFAULT_BATCH_SIZE):
+            batch = point_ids[start:start + DEFAULT_BATCH_SIZE]
+            cursor.execute(
+                "SELECT point_id FROM pt_point WHERE point_id = ANY(%s)",
+                (batch,),
+            )
+            existing.update(row[0] for row in cursor.fetchall())
+    return sorted(set(point_ids) - existing)
+```
+
+缺失点位需要等全部批次查完后再统一打印并退出。
+
+## ABCD 编码
+
+```python
+import struct
+
+
+def encode_registers(value: object, data_type: str) -> list[int]:
+    if value is None:
+        value = 0
+
+    if data_type == "int16":
+        packed = struct.pack(">h", int(value))
+    elif data_type == "int32":
+        packed = struct.pack(">i", int(value))
+    elif data_type == "float32":
+        packed = struct.pack(">f", float(value))
+    else:
+        raise ValueError(f"unsupported data_type: {data_type}")
+
+    return [int.from_bytes(packed[index:index + 2], "big") for index in range(0, len(packed), 2)]
+```
+
+## 线程安全寄存器存储
+
+后台刷新线程写入,pymodbus 请求线程读取,因此需要加锁。
+
+```python
+from threading import RLock
+from pymodbus.constants import ExcCodes
+
+
+class RegisterStore:
+    def __init__(self):
+        self._lock = RLock()
+        self._registers: dict[int, dict[int, int]] = {}
+        self._valid_addresses: dict[int, set[int]] = {}
+
+    def initialize_slave(self, slave_id: int, registers: dict[int, int]) -> None:
+        with self._lock:
+            self._registers[slave_id] = dict(registers)
+            self._valid_addresses[slave_id] = set(registers)
+
+    def read_holding_registers(self, slave_id: int, address: int, count: int):
+        with self._lock:
+            slave_registers = self._registers.get(slave_id)
+            valid_addresses = self._valid_addresses.get(slave_id)
+            if not slave_registers or not valid_addresses:
+                return ExcCodes.ILLEGAL_ADDRESS
+            addresses = range(address, address + count)
+            if any(item not in valid_addresses for item in addresses):
+                return ExcCodes.ILLEGAL_ADDRESS
+            return [slave_registers[item] for item in addresses]
+
+    def write_internal(self, slave_id: int, address: int, values: list[int]) -> None:
+        with self._lock:
+            slave_registers = self._registers.get(slave_id)
+            valid_addresses = self._valid_addresses.get(slave_id)
+            if slave_registers is None or valid_addresses is None:
+                raise KeyError(f"slave_id is not initialized: {slave_id}")
+            for offset, register in enumerate(values):
+                register_address = address + offset
+                if register_address not in valid_addresses:
+                    raise KeyError(f"address is not configured: slave_id={slave_id}, address={register_address}")
+                slave_registers[register_address] = register
+```
+
+初始化寄存器存储时,先为所有已配置点位写入默认值 `0`,这样 HTTP 初始化缺失实时值时,地址仍然有效,寄存器值为 `0`。
+
+```python
+def initialize_register_store(points: list[ModbusPoint], store: RegisterStore) -> None:
+    by_slave: dict[int, dict[int, int]] = {}
+    for point in points:
+        registers = encode_registers(0, point.data_type)
+        slave_registers = by_slave.setdefault(point.slave_id, {})
+        for offset, register in enumerate(registers):
+            slave_registers[point.address + offset] = register
+
+    for slave_id, registers in by_slave.items():
+        store.initialize_slave(slave_id, registers)
+```
+
+## pymodbus 自定义只读 Context
+
+`pymodbus 3.13.1` 中旧版 `ModbusDeviceContext/ModbusServerContext` 已废弃,且旧式示例不适合动态更新。建议实现自定义 context,继承 `ModbusServerContext` 以满足 server 类型判断,但实际读写由 `RegisterStore` 完成。
+
+```python
+from pymodbus.constants import ExcCodes
+from pymodbus.datastore import ModbusServerContext
+
+
+class ReadonlyHoldingRegisterContext(ModbusServerContext):
+    def __init__(self, store: RegisterStore):
+        self.simdevices = []
+        self.store = store
+
+    def device_ids(self) -> list[int]:
+        return sorted(self.store._registers.keys())
+
+    async def async_getValues(self, device_id: int, func_code: int, address: int, count: int = 1):
+        if func_code != 3:
+            return ExcCodes.ILLEGAL_FUNCTION
+        return self.store.read_holding_registers(device_id, address, count)
+
+    async def async_setValues(self, device_id: int, func_code: int, address: int, values: list[int]):
+        return ExcCodes.ILLEGAL_ADDRESS
+```
+
+说明:
+
+- 功能码 `03` 允许读取 Holding Register。
+- 其他读取类型返回 `ILLEGAL_FUNCTION`。
+- 功能码 `06/16/22/23` 等写保持寄存器返回异常,不修改数据。
+- 后台刷新线程不走 `async_setValues`,而是调用 `RegisterStore.write_internal`。
+
+## Client 连接与断开日志
+
+`pymodbus` 的 `trace_connect` 只能拿到连接或断开的布尔值,拿不到 client 地址。为了打印更有用的日志,可以自定义 request handler,从 transport 中读取 `peername`。
+
+不记录连接数量。
+
+```python
+import logging
+from pymodbus.server.requesthandler import ServerRequestHandler
+from pymodbus.server.server import ModbusTcpServer
+
+logger = logging.getLogger(__name__)
+
+
+class LoggingServerRequestHandler(ServerRequestHandler):
+    def _client_addr(self) -> str:
+        if not self.transport:
+            return "unknown"
+        peer = self.transport.get_extra_info("peername")
+        return "%s:%s" % peer if peer else "unknown"
+
+    def callback_connected(self) -> None:
+        super().callback_connected()
+        logger.info("客户端已连接(Modbus): %s", self._client_addr())
+
+    def callback_disconnected(self, exc: Exception | None) -> None:
+        client_addr = self._client_addr()
+        super().callback_disconnected(exc)
+        if exc:
+            logger.info("客户端已断开(Modbus): %s, 原因=%s", client_addr, exc)
+        else:
+            logger.info("客户端已断开(Modbus): %s", client_addr)
+
+
+class LoggingModbusTcpServer(ModbusTcpServer):
+    def callback_new_connection(self):
+        return LoggingServerRequestHandler(
+            self,
+            self.trace_packet,
+            self.trace_pdu,
+            self.trace_connect,
+        )
+```
+
+## 后台刷新线程
+
+```python
+import logging
+import threading
+import time
+
+logger = logging.getLogger(__name__)
+
+
+class ValueRefreshWorker(threading.Thread):
+    def __init__(self, points, provider, store, interval_seconds: int):
+        super().__init__(name="value-refresh-worker", daemon=True)
+        self.points = points
+        self.provider = provider
+        self.store = store
+        self.interval_seconds = interval_seconds
+
+    def run(self) -> None:
+        logger.info("实时值刷新线程已启动,刷新周期=%s秒", self.interval_seconds)
+        while True:
+            try:
+                self.refresh_once(initial=False)
+            except Exception:
+                logger.exception("实时值刷新失败")
+            time.sleep(self.interval_seconds)
+
+    def refresh_once(self, initial: bool) -> None:
+        point_by_id = {point.point_id: point for point in self.points}
+        point_ids = list(point_by_id)
+        for start in range(0, len(point_ids), DEFAULT_BATCH_SIZE):
+            batch = point_ids[start:start + DEFAULT_BATCH_SIZE]
+            values = self.provider.fetch_values(batch)
+            for point_id in batch:
+                point = point_by_id[point_id]
+                if point_id not in values:
+                    if initial:
+                        logger.warning("初始化实时值缺失,point_id=%s,使用默认值0", point_id)
+                        value = 0
+                    else:
+                        logger.warning("周期刷新实时值缺失,point_id=%s,保持旧值", point_id)
+                        continue
+                else:
+                    value = values[point_id]
+                registers = encode_registers(value, point.data_type)
+                self.store.write_internal(point.slave_id, point.address, registers)
+```
+
+## 启动 Modbus TCP Server
+
+```python
+import asyncio
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+async def start_modbus_server(context, host: str, port: int) -> None:
+    server = LoggingModbusTcpServer(
+        context,
+        address=(host, port),
+        ignore_missing_devices=False,
+        broadcast_enable=False,
+    )
+    logger.info("服务已启动监听(Modbus TCP),地址=%s:%s", host, port)
+    await server.serve_forever()
+
+
+def run_modbus_server(context, host: str, port: int) -> None:
+    asyncio.run(start_modbus_server(context, host, port))
+```
+
+## 日志设计
+
+使用 `TimedRotatingFileHandler` 按天切分日志,默认保留 3 天。
+
+```python
+import logging
+from logging.handlers import TimedRotatingFileHandler
+from pathlib import Path
+
+
+def setup_logging(log_dir: str, retention_days: int, level: str) -> None:
+    Path(log_dir).mkdir(parents=True, exist_ok=True)
+    formatter = logging.Formatter(
+        "%(asctime)s %(levelname)s [%(name)s] %(message)s"
+    )
+
+    file_handler = TimedRotatingFileHandler(
+        filename=str(Path(log_dir) / "modbus-server.log"),
+        when="midnight",
+        interval=1,
+        backupCount=retention_days,
+        encoding="utf-8",
+    )
+    file_handler.setFormatter(formatter)
+
+    console_handler = logging.StreamHandler()
+    console_handler.setFormatter(formatter)
+
+    root_logger = logging.getLogger()
+    root_logger.setLevel(getattr(logging, level.upper(), logging.INFO))
+    root_logger.handlers.clear()
+    root_logger.addHandler(file_handler)
+    root_logger.addHandler(console_handler)
+```
+
+每个初始化过程都需要打印日志,至少包括:
+
+```text
+正在启动Modbus Server
+日志系统初始化完成
+配置文件加载完成
+运行配置: 数据库=host:port/database, Modbus监听=host:port, 刷新周期=5秒, 批量大小=200
+数据库连接成功
+开始从modbus_server_point加载全部点位
+点位加载完成,数量=...
+数据表modbus_server_point没有点位,将启动空Modbus Server
+开始校验点位data_type
+开始校验Modbus地址重叠
+开始校验pt_point点位是否存在,批量大小=200
+开始请求初始化实时值
+初始化实时值缺失,point_id=...,使用默认值0
+寄存器存储初始化完成
+实时值Provider初始化完成,类型=http
+实时值刷新线程已启动,刷新周期=5秒
+上下文初始化完成(Modbus)
+服务已启动监听(Modbus TCP),地址=host:port
+客户端已连接(Modbus): ip:port
+客户端已断开(Modbus): ip:port
+```
+
+## main.py 编排逻辑
+
+```python
+def main() -> int:
+    config = load_config("config.yaml")
+    setup_logging(
+        config.logging.dir,
+        config.logging.retention_days,
+        config.logging.level,
+    )
+
+    logger.info("正在启动Modbus Server")
+    logger.info("日志系统初始化完成")
+    logger.info("配置文件加载完成")
+    logger.info(
+        "运行配置: 数据库=%s:%s/%s, Modbus监听=%s:%s, 刷新周期=%s秒, 批量大小=%s",
+        config.db.host,
+        config.db.port,
+        config.db.database,
+        config.modbus.host,
+        config.modbus.port,
+        config.modbus.interval,
+        DEFAULT_BATCH_SIZE,
+    )
+
+    conn = create_connection(config.db)
+    logger.info("数据库连接成功")
+
+    logger.info("开始从modbus_server_point加载全部点位")
+    points = load_points(conn)
+    logger.info("点位加载完成,数量=%s", len(points))
+
+    if not points:
+        logger.warning("数据表modbus_server_point没有点位,将启动空Modbus Server")
+
+    logger.info("开始校验点位data_type")
+    validate_data_types(points)
+
+    logger.info("开始校验Modbus地址重叠")
+    overlap_errors = validate_address_overlaps(points)
+    if overlap_errors:
+        for error in overlap_errors:
+            logger.error(error)
+        return 1
+
+    logger.info("开始校验pt_point点位是否存在,批量大小=%s", DEFAULT_BATCH_SIZE)
+    missing_point_ids = check_point_exists(conn, [point.point_id for point in points])
+    if missing_point_ids:
+        logger.error("数据表pt_point中缺失以下point_id: %s", missing_point_ids)
+        return 1
+
+    store = RegisterStore()
+    initialize_register_store(points, store)
+    logger.info("寄存器存储初始化完成")
+
+    provider = HttpValueProvider(
+        config.http_provider.url,
+        config.http_provider.timeout_seconds,
+    )
+    logger.info("实时值Provider初始化完成,类型=http")
+
+    worker = ValueRefreshWorker(
+        points,
+        provider,
+        store,
+        config.modbus.interval,
+    )
+    logger.info("开始请求初始化实时值")
+    worker.refresh_once(initial=True)
+    worker.start()
+
+    context = ReadonlyHoldingRegisterContext(store)
+    logger.info("上下文初始化完成(Modbus)")
+    run_modbus_server(context, config.modbus.host, config.modbus.port)
+    return 0
+```
+
+## 空 Server 行为
+
+当 `modbus_server_point` 没有任何点位时:
+
+- 程序打印 warning。
+- 不退出。
+- 启动空 Modbus Server。
+- Client 读取任意 Holding Register 地址时返回 `ILLEGAL_ADDRESS`。
+
+## 数据库 SQL
+
+加载点位:
+
+```sql
+SELECT point_id, name, data_type, slave_id, address
+FROM modbus_server_point
+ORDER BY slave_id, address, point_id;
+```
+
+分批校验 `pt_point`:
+
+```sql
+SELECT point_id
+FROM pt_point
+WHERE point_id = ANY(%s);
+```
+
+## 启动注意事项
+
+- 默认端口 `502` 在 Linux 上通常需要 root 权限或端口授权。
+- 如果端口绑定失败,程序需要打印 error 并退出。
+- 如果实际部署不希望 root 运行,可以在配置文件中改为 `5020`,再由防火墙或代理转发。