diff --git a/.trae/documents/2026-05-25-modbus-backend-refactor-plan.md b/.trae/documents/2026-05-25-modbus-backend-refactor-plan.md
new file mode 100644
index 0000000..e66036b
--- /dev/null
+++ b/.trae/documents/2026-05-25-modbus-backend-refactor-plan.md
@@ -0,0 +1,328 @@
+# Modbus 新协议后端改造说明
+
+## Summary
+
+本次改造目标是让后端完全按 [显示界面modbus协议寄存器定义.md](file:///d:/Trae_space/EMCP/document/%E6%98%BE%E7%A4%BA%E7%95%8C%E9%9D%A2modbus%E5%8D%8F%E8%AE%AE%E5%AF%84%E5%AD%98%E5%99%A8%E5%AE%9A%E4%B9%89.md) 的新版协议工作,并取消 `backend/config` 作为配置真值来源的角色。
+
+本次计划确认的边界如下:
+
+- 只调整后端代码,不修改前端代码。
+- 配置页面首次加载时,后端需先从下位机通过 Modbus 读取参数并返回。
+- 界面保存配置时,后端只动态写入下位机,不再把配置写入 `backend/config` 下的 `device.json`、`channel.json`、`setting.json`。
+- 协议文档中没有定义寄存器的现有配置接口,本次先保留,并继续沿用原本地配置逻辑作为过渡。
+- 先生成本地说明文档,用户确认后再开始改代码。
+
+---
+
+## Current State Analysis
+
+### 1. 当前接口层现状
+
+当前配置接口集中在 `backend/app/api/routes/config.py`:
+
+- 保留协议可能覆盖的接口:
+ - `GET/POST /api/config/channel`
+ - `GET/POST /api/config/line_alarm_setting`
+ - `GET/POST /api/config/ai_alarm_setting`
+ - `GET/POST /api/config/system`
+- 当前存在但新版协议未覆盖的接口:
+ - `GET/POST /api/config/device`
+ - `POST /api/config/device/password`
+ - `GET/POST /api/config/device/net`
+ - `GET/POST /api/config/device/uart`
+
+根据用户本轮决策,上述“协议未定义寄存器”的配置接口在本次后端改造先保留,并继续沿用原有本地配置文件逻辑。
+
+### 2. 当前业务层现状
+
+当前业务主要集中在 `backend/app/services/platform_service.py`,存在两个关键问题:
+
+- 配置读取默认来自本地 JSON:
+ - `get_device_config()`
+ - `get_channel_config()`
+ - `get_line_alarm_setting()`
+ - `get_ai_alarm_setting()`
+ - `get_system_config()`
+- 配置保存采用“先写本地 JSON,再下发设备”的双写模式:
+ - `save_device_config()`
+ - `save_channel_config()`
+ - `save_line_alarm_setting()`
+ - `save_ai_alarm_setting()`
+ - `save_system_config()`
+
+这与“首次从下位机读取显示、保存时只写下位机、不再落本地配置文件”的新需求直接冲突。
+
+此外,实时数据一次值计算当前仍依赖 `setting.json -> line_alarm_setting[].transformer_change[]` 中的 PT/CT 变比;如果配置文件取消真值角色,则这部分变比来源也必须切到设备回读数据。
+
+### 3. 当前设备适配层现状
+
+真实 Modbus 设备适配集中在 `backend/app/adapters/device_client.py`,当前实现与新版协议的差异点如下:
+
+- 已符合或基本可复用:
+ - 四路实时数据输入寄存器读取
+ - AI 输入 `0x00D0 ~ 0x00DE`
+ - B 码对时 `0x00E0 ~ 0x00E2`
+ - 开入读取 `0x00E3`
+ - 开出线圈写 `0x0F`
+ - 系统时间同步 `0x0000`
+- 明显不符合新版协议:
+ - 当前事件总寄存器仍按旧逻辑读取 `0x00E4`,新版应为 `0x00E5`
+ - 当前“读事件”按旧逻辑读保持寄存器 `0x0000 ~ 0x0003`,新版应改为读输入寄存器 `0x0100`,长度 `5 * n`
+ - 当前 AI/AO/AI 报警写入地址仍是旧版:
+ - AI 通道:`0x004C`,新版应为 `0x0054`
+ - AO 通道:`0x006A`,新版应为 `0x0072`
+ - AI 报警:`0x0088`,新版应为 `0x0090`
+ - 当前线路定值写入长度按 14 寄存器/每路处理,未包含 2 个变比参数;新版总长度为 64,并明确包含变比参数
+ - 当前没有“参数区回读”能力,只有写寄存器,没有从设备读取参数区并还原为 API 返回结构
+
+### 4. 当前配置仓储现状
+
+`backend/app/repositories/json_config_repo.py` 目前负责:
+
+- 写 `device.json`
+- 写 `channel.json`
+- 写 `setting.json`
+- 读上述 JSON 作为配置真值
+
+按照本次需求,`backend/config` 中的配置文件不再承担运行期配置真值角色;对应仓储将不再被配置接口和业务服务依赖,只能保留给极少数非配置场景,或在后续整理中弱化/下线。
+
+### 5. 当前测试现状
+
+- `backend/tests/test_api.py` 大量依赖本地 JSON 写入与读取断言
+- `backend/tests/test_modbus_client.py` 使用 FakeTransport 验证旧版寄存器地址、旧版事件读取方式和旧版参数写入地址
+
+这些测试在本次改造后会大面积失效,必须同步调整。
+
+---
+
+## Proposed Changes
+
+### 1. 调整配置接口边界
+
+#### 文件
+
+- `backend/app/api/routes/config.py`
+
+#### 改动内容
+
+- 保留并继续支持的接口分为两类:
+ - **按新版协议改造为纯 Modbus 真值**:
+ - `GET/POST /api/config/channel`
+ - `GET/POST /api/config/line_alarm_setting`
+ - `GET/POST /api/config/ai_alarm_setting`
+ - `GET/POST /api/config/system`
+ - **协议未定义寄存器、先保留原本地逻辑**:
+ - `GET/POST /api/config/device`
+ - `POST /api/config/device/password`
+ - `GET/POST /api/config/device/net`
+ - `GET/POST /api/config/device/uart`
+
+#### 原因
+
+- 新协议文档已覆盖的配置项,需切换为“设备寄存器即真值”
+- 新协议文档未覆盖的配置项,用户明确要求先保留接口,避免立即影响现有前端调用
+- 这些未定义项本轮继续沿用本地配置文件逻辑,待后续协议补齐后再迁移
+
+### 2. 配置读取改为“首次从下位机读”
+
+#### 文件
+
+- `backend/app/services/platform_service.py`
+- `backend/app/adapters/device_client.py`
+
+#### 改动内容
+
+- 移除 `get_channel_config()` 对 `channel.json` 的依赖,改为调用设备侧“参数区回读”能力
+- 移除 `get_line_alarm_setting()` 对 `setting.json` 的依赖,改为读取线路定值寄存器并按 `line_no` 解析
+- 移除 `get_ai_alarm_setting()` 对 `setting.json` 的依赖,改为读取 AI 报警寄存器并还原 API 结构
+- 移除 `get_system_config()` 对 `setting.json` 的依赖,改为从设备读取可回读的系统参数
+- PT/CT 变比不再从 `setting.json` 读取,而改为从线路定值寄存器回读结果中提取
+
+#### 原因
+
+- 配置页面首次加载必须从下位机读取显示
+- `backend/config` 不再保存配置真值
+
+### 3. 配置保存改为“只写下位机,不写本地 JSON”
+
+#### 文件
+
+- `backend/app/services/platform_service.py`
+- `backend/app/repositories/json_config_repo.py`
+
+#### 改动内容
+
+- 删除以下方法中的本地 JSON 写入逻辑:
+ - `save_channel_config()`
+ - `save_line_alarm_setting()`
+ - `save_ai_alarm_setting()`
+ - `save_system_config()`
+- 返回值改为仅描述 Modbus 写入结果,不再返回本地 `save_path`
+- 业务层不再把配置结果回写 `channel.json` / `setting.json`
+- `JsonConfigRepository` 不再作为上述“协议已定义项”的运行期依赖
+- `device/device password/device net/device uart` 相关接口暂不纳入此调整,仍继续使用本地配置文件逻辑
+
+#### 原因
+
+- 用户已明确要求取消本地配置文件落盘
+- 配置应以设备当前寄存器值为唯一真值
+
+### 4. 按新版协议更新寄存器地址和事件机制
+
+#### 文件
+
+- `backend/app/adapters/device_client.py`
+
+#### 改动内容
+
+- 更新常量地址:
+ - 事件总寄存器:由旧值改为 `0x00E5`
+ - 读事件起始地址:改为 `0x0100`
+ - AI 通道写入起始地址:改为 `0x0054`
+ - AO 通道写入起始地址:改为 `0x0072`
+ - AI 报警写入起始地址:改为 `0x0090`
+- 新增/修改事件读取实现:
+ - 先读 `0x00E5`
+ - 若值为 `n > 0`,按 `0x04 + 0x0100 + 5*n` 读取输入寄存器
+ - 解析事件结构:时间戳、毫秒、线路号、事件类型、事件代码、动作值
+ - 读完后通过 `0x03 + 0x0100 + n` 发送“读事件成功回复”
+- 新增/修改事件映射逻辑:
+ - 保护类
+ - 动作类
+ - 报警类
+ - 操作类
+ - AI 越限类
+
+#### 原因
+
+- 当前事件实现仍停留在旧协议,和最新文档不一致
+
+### 5. 补齐参数区回读能力
+
+#### 文件
+
+- `backend/app/adapters/device_client.py`
+- `backend/app/services/platform_service.py`
+- `backend/app/schemas/platform.py`
+
+#### 改动内容
+
+- 在 `device_client.py` 中新增参数回读方法,例如:
+ - 读取串口设置寄存器块
+ - 读取线路定值设置寄存器块
+ - 读取 AI 通道设置寄存器块
+ - 读取 AO 通道设置寄存器块
+ - 读取 AI 报警设置寄存器块
+ - 读取系统设置寄存器块(仅限协议已定义项)
+- 将回读寄存器块解析成后端当前接口使用的结构:
+ - `ChannelConfigIn`
+ - `LineAlarmSettingIn`
+ - `AiAlarmSettingIn`
+ - `SystemConfigIn` 的可支持子集
+- 根据新版协议更新线路定值寄存器长度与解析方式:
+ - 从原先 7 类规则扩展到包含 2 个变比参数
+ - PT/CT 变比参与一次值换算
+
+#### 原因
+
+- 没有回读能力,就无法满足“参数页首次加载先从下位机读取显示”
+
+### 6. 处理不再受协议支持的字段
+
+#### 文件
+
+- `backend/app/schemas/platform.py`
+- `backend/app/services/platform_service.py`
+- `backend/app/api/routes/config.py`
+
+#### 改动内容
+
+- 识别协议未覆盖字段并做“分层处理”
+- 对 `SystemConfigIn` 中协议未定义的字段(如 `brightness`、`screen_saver`)进行重新评估:
+ - 若协议无寄存器,则从“纯 Modbus 真值范围”中剥离
+ - `GET/POST /config/system` 需按协议实际可支持字段精简或拆分
+- 对历史仅本地保存的密码、网卡、版本号等结构:
+ - 本轮不从后端 API 中移除
+ - 继续保留 schema 与接口
+ - 继续沿用本地配置文件逻辑作为兼容过渡
+- 在计划与代码实现中明确区分两类配置:
+ - 协议已定义项:首次回读设备、保存只写设备、不落本地
+ - 协议未定义项:接口先保留,继续本地逻辑
+
+#### 原因
+
+- 用户已确认:协议未定义寄存器的配置接口先保留,不直接移除,并继续沿用原本地配置逻辑
+
+### 7. 重写测试,验证“纯设备真值”模式
+
+#### 文件
+
+- `backend/tests/test_modbus_client.py`
+- `backend/tests/test_api.py`
+
+#### 改动内容
+
+- 更新 `test_modbus_client.py`:
+ - 使用新版地址与长度断言
+ - 验证事件总寄存器 `0x00E5`
+ - 验证“读事件内容 `0x0100` + 成功回复 `0x0100`”完整链路
+ - 验证 AI/AO/AI报警新地址
+ - 验证线路定值包含变比参数
+- 更新 `test_api.py`:
+ - 删除“协议已定义项”对本地 JSON 写入/读取的依赖型断言
+ - 改成 Mock/Stub 设备回读数据断言
+ - 增加“首次 GET 来自设备回读、POST 只触发设备写入”的测试
+ - 保留“协议未定义接口”的现有本地逻辑测试,或按兼容过渡方式调整断言
+
+#### 原因
+
+- 当前测试体系大量基于本地 JSON 真值,必须同步迁移
+
+---
+
+## Assumptions & Decisions
+
+### 已确认决策
+
+- 只修改后端,前端本次不动
+- 按新版协议文档改后端 Modbus 行为
+- `backend/config` 不再保存配置参数真值
+- 参数页面首次加载时,后端先从下位机读取显示
+- 配置保存时,后端只动态写入下位机
+- 协议未定义寄存器的现有配置接口,本次先保留
+- 协议未定义寄存器的现有配置接口,本次继续沿用原本地配置逻辑
+
+### 当前计划中的实现假设
+
+1. `line_alarm_setting` 中的 2 个变比参数可映射到现有 `transformer_change` 结构,或需在 schema 中进一步显式化为 PT/CT 变比字段。
+2. `system` 配置若协议只定义时间同步,则该接口最终可能会缩减为仅时间设置相关能力。
+3. 实时数据一次值计算仍由后端负责,但 PT/CT 变比来源将变更为设备回读参数,而非本地 JSON。
+4. `json_config_repo.py` 会退出“协议已定义项”的配置主链路,但仍保留给协议未定义接口使用。
+
+---
+
+## Verification Steps
+
+1. 代码层验证
+ - 协议已定义项的配置 GET 接口都不再访问 `backend/config/*.json`
+ - 协议已定义项的配置 POST 接口都不再写本地 JSON
+ - `device_client.py` 的寄存器常量与新版文档一致
+
+2. 自动化测试验证
+ - `backend/tests/test_modbus_client.py` 通过
+ - `backend/tests/test_api.py` 通过
+ - 删除或更新“协议已定义项”依赖本地配置文件真值的旧断言
+ - 保留或改造“协议未定义项”的本地逻辑断言
+
+3. 联调验证
+ - 手工调用协议已定义项的配置 GET 接口时,能从下位机回读数据
+ - 手工调用协议已定义项的配置 POST 接口时,只触发 Modbus 写,不产生本地配置文件变更
+ - 手工调用协议未定义项接口时,仍保持当前本地逻辑可用
+ - 事件轮询流程符合:
+ - 轮询 `0x00E5`
+ - 读 `0x0100`
+ - 成功回复 `0x0100`
+
+4. 回归验证
+ - 实时数据、设备状态、报警历史、开出控制功能不因本次改造而退化
+ - WebSocket 轮询链路仍能持续推送数据
diff --git a/backend/app/adapters/device_client.py b/backend/app/adapters/device_client.py
index 1344f8e..1b0646a 100644
--- a/backend/app/adapters/device_client.py
+++ b/backend/app/adapters/device_client.py
@@ -18,6 +18,7 @@ from app.schemas.platform import (
RealtimeData,
SystemConfigIn,
SwitchControlIn,
+ TimeSyncConfigIn,
ValueGroup,
)
@@ -35,6 +36,26 @@ class DeviceClient(ABC):
def read_alarm_events(self) -> List[AlarmEvent]:
raise NotImplementedError
+ @abstractmethod
+ def read_channel_config(self) -> Dict[str, Any]:
+ raise NotImplementedError
+
+ @abstractmethod
+ def read_line_alarm_setting(self, line_no: int) -> Dict[str, Any]:
+ raise NotImplementedError
+
+ @abstractmethod
+ def read_ai_alarm_setting(self) -> List[Dict[str, Any]]:
+ raise NotImplementedError
+
+ @abstractmethod
+ def read_system_config(self) -> Dict[str, Any]:
+ raise NotImplementedError
+
+ @abstractmethod
+ def read_time_sync_config(self) -> Dict[str, str]:
+ raise NotImplementedError
+
@abstractmethod
def send_device_config(self, payload: DeviceConfigIn) -> Dict[str, Any]:
raise NotImplementedError
@@ -55,6 +76,10 @@ class DeviceClient(ABC):
def send_system_config(self, payload: SystemConfigIn) -> Dict[str, Any]:
raise NotImplementedError
+ @abstractmethod
+ def send_time_sync_config(self, payload: TimeSyncConfigIn) -> Dict[str, Any]:
+ raise NotImplementedError
+
@abstractmethod
def send_switch_control(self, payload: SwitchControlIn) -> Dict[str, Any]:
raise NotImplementedError
@@ -93,15 +118,17 @@ LINE_REGISTER_COUNT = 0x34
AI_INPUT_START_REGISTER = 0x00D0
AI_INPUT_COUNT = 8
DI_INPUT_REGISTER = 0x00E3
-EVENT_FLAG_REGISTER = 0x00E4
-EVENT_HOLDING_START_REGISTER = 0x0000
+DO_INPUT_REGISTER = 0x00E4
+EVENT_FLAG_REGISTER = 0x00E5
+EVENT_INPUT_START_REGISTER = 0x0100
+EVENT_SUCCESS_REPLY_REGISTER = 0x0100
UART_CONFIG_START_REGISTER = 0x0004
UART_CONFIG_REGISTER_COUNT = 16
LINE_ALARM_START_REGISTER = 0x0014
-LINE_ALARM_REGISTERS_PER_LINE = 14
-AI_CHANNEL_START_REGISTER = 0x004C
-AO_CHANNEL_START_REGISTER = 0x006A
-AI_ALARM_START_REGISTER = 0x0088
+LINE_ALARM_REGISTERS_PER_LINE = 16
+AI_CHANNEL_START_REGISTER = 0x0054
+AO_CHANNEL_START_REGISTER = 0x0072
+AI_ALARM_START_REGISTER = 0x0090
ANALOG_BLOCK_REGISTER_COUNT = 30
DO_STATE_REGISTER = 0x00A6
TIME_SYNC_START_REGISTER = 0x0000
@@ -262,6 +289,46 @@ class MockDeviceClient(DeviceClient):
def __init__(self) -> None:
self._random = Random(3568)
self._tick = 0
+ self._channel_config = {
+ "ai_channel": [
+ {"ch": 1, "singal_type": "4-20mA", "line_no": 1, "type": "UA", "limit_low": 0, "limit_high": 20}
+ ],
+ "ao_channel": [
+ {"ch": 1, "singal_type": "1~5V", "line_no": 2, "type": "Q", "limit_low": 0, "limit_high": 20}
+ ],
+ }
+ self._line_alarm_settings = {
+ 1: {
+ "line_no": 1,
+ "over_limit_alarm": [
+ {"category": "电压", "limit": 180, "delay": 180, "output_node": "开出1", "enabled": True},
+ {"category": "电流", "limit": 180, "delay": 180, "output_node": "开出1", "enabled": True},
+ {"category": "差流", "limit": 180, "delay": 180, "output_node": "开出1", "enabled": False},
+ {"category": "功率", "limit": 180, "delay": 180, "output_node": "开出1", "enabled": False},
+ {"category": "频率", "limit": 50, "delay": 180, "output_node": "开出1", "enabled": False},
+ ],
+ "fault_alarm": [
+ {"category": "PT断线", "limit": 0, "delay": 180, "output_node": "开出1", "enabled": True},
+ {"category": "CT断线", "limit": 0, "delay": 180, "output_node": "开出2", "enabled": False},
+ ],
+ "transformer_change": [
+ {"category": "PT变化", "value": 100.0, "enabled": True},
+ {"category": "CT变化", "value": 50.0, "enabled": True},
+ ],
+ }
+ }
+ self._ai_alarm_setting = [
+ {
+ "channel_no": 1,
+ "singal_type": "4-20mA",
+ "limit_low": 0,
+ "limit_high": 20,
+ "delay": 180,
+ "output_node": "开出1",
+ "enabled": True,
+ }
+ ]
+ self._system_config = {"time_sync": "2026-05-25 12:34:56", "brightness": 80, "screen_saver": 60}
def _value_group(self, base_u: float, base_i: float, base_p: float) -> ValueGroup:
delta = self._tick % 5
@@ -330,10 +397,42 @@ class MockDeviceClient(DeviceClient):
)
]
+ def read_channel_config(self) -> Dict[str, Any]:
+ return {
+ "ai_channel": [dict(item) for item in self._channel_config["ai_channel"]],
+ "ao_channel": [dict(item) for item in self._channel_config["ao_channel"]],
+ }
+
+ def read_line_alarm_setting(self, line_no: int) -> Dict[str, Any]:
+ if line_no in self._line_alarm_settings:
+ current = self._line_alarm_settings[line_no]
+ return {
+ "line_no": current["line_no"],
+ "over_limit_alarm": [dict(item) for item in current["over_limit_alarm"]],
+ "fault_alarm": [dict(item) for item in current["fault_alarm"]],
+ "transformer_change": [dict(item) for item in current["transformer_change"]],
+ }
+ return {
+ "line_no": line_no,
+ "over_limit_alarm": [],
+ "fault_alarm": [],
+ "transformer_change": [],
+ }
+
+ def read_ai_alarm_setting(self) -> List[Dict[str, Any]]:
+ return [dict(item) for item in self._ai_alarm_setting]
+
+ def read_system_config(self) -> Dict[str, Any]:
+ return dict(self._system_config)
+
+ def read_time_sync_config(self) -> Dict[str, str]:
+ return {"time_sync": str(self._system_config.get("time_sync", ""))}
+
def send_device_config(self, payload: DeviceConfigIn) -> Dict[str, Any]:
return {"send_status": "成功", "target": "device", "items": len(payload.net) + len(payload.uart)}
def send_channel_config(self, payload: ChannelConfigIn) -> Dict[str, Any]:
+ self._channel_config = payload.model_dump()
return {
"send_status": "成功",
"target": "channel",
@@ -341,14 +440,21 @@ class MockDeviceClient(DeviceClient):
}
def send_line_alarm_setting(self, payload: LineAlarmSettingIn) -> Dict[str, Any]:
+ self._line_alarm_settings[payload.line_no] = payload.model_dump()
return {"send_status": "成功", "target": "line_alarm", "line_no": payload.line_no}
def send_ai_alarm_setting(self, payload: List[Dict[str, Any]]) -> Dict[str, Any]:
+ self._ai_alarm_setting = [dict(item) for item in payload]
return {"send_status": "成功", "target": "ai_alarm", "items": len(payload)}
def send_system_config(self, payload: SystemConfigIn) -> Dict[str, Any]:
+ self._system_config.update(payload.model_dump())
return {"send_status": "成功", "target": "system", "brightness": payload.brightness}
+ def send_time_sync_config(self, payload: TimeSyncConfigIn) -> Dict[str, Any]:
+ self._system_config["time_sync"] = payload.time_sync
+ return {"send_status": "成功", "target": "time_sync", "time_sync": payload.time_sync}
+
def send_switch_control(self, payload: SwitchControlIn) -> Dict[str, Any]:
action_text = "合" if payload.action == 1 else "分"
return {"control_status": f"执行成功: 开关{payload.ch}{action_text}"}
@@ -392,6 +498,12 @@ class CDeviceClient(DeviceClient):
payload += b"\x00"
return [(payload[index] << 8) | payload[index + 1] for index in range(0, len(payload), 2)]
+ def _registers_to_bytes(self, registers: Sequence[int]) -> bytes:
+ raw = bytearray()
+ for register in registers:
+ raw.extend([(int(register) >> 8) & 0xFF, int(register) & 0xFF])
+ return bytes(raw)
+
def _build_value_group(self, registers: Sequence[int]) -> ValueGroup:
values: Dict[str, float] = {}
for index, field_name in enumerate(VALUE_GROUP_FIELDS):
@@ -424,12 +536,17 @@ class CDeviceClient(DeviceClient):
def _read_do_state(self) -> Dict[str, int]:
try:
- bits = self.transport.read_coils(0x0000, 12)
- if bits:
- for index, bit in enumerate(bits[:12], start=1):
- self._do_state[index] = int(bool(bit))
+ register = self.transport.read_input_registers(DO_INPUT_REGISTER, 1)[0]
+ for index in range(1, 13):
+ self._do_state[index] = int(bool(register & (1 << (index - 1))))
except Exception:
- pass
+ try:
+ bits = self.transport.read_coils(0x0000, 12)
+ if bits:
+ for index, bit in enumerate(bits[:12], start=1):
+ self._do_state[index] = int(bool(bit))
+ except Exception:
+ pass
return {f"do{index}": self._do_state.get(index, 0) for index in range(1, 13)}
def _build_status(self) -> DeviceStatus:
@@ -500,6 +617,134 @@ class CDeviceClient(DeviceClient):
]
)
+ def _channel_payload_bytes(self, items: List[Any]) -> bytes:
+ raw = bytearray()
+ channel_map = {int(item.ch): item for item in items}
+ for channel_no in range(1, 13):
+ item = channel_map.get(channel_no)
+ if item is None:
+ raw.extend(b"\x00" * 5)
+ continue
+ raw.extend(
+ bytes(
+ [
+ self._signal_type_code(item.singal_type),
+ _clamp_byte(item.line_no),
+ self._measure_type_code(item.type),
+ _clamp_byte(item.limit_high),
+ _clamp_byte(item.limit_low),
+ ]
+ )
+ )
+ return bytes(raw)
+
+ def _decode_signal_type(self, code: int) -> str:
+ return "4-20mA" if int(code) == 0 else "1~5V"
+
+ def _decode_measure_type(self, code: int) -> str:
+ return {
+ 0: "UA",
+ 1: "UB",
+ 2: "UC",
+ 3: "UAB",
+ 4: "UBC",
+ 5: "UCA",
+ 6: "P",
+ 7: "Q",
+ 8: "F",
+ }.get(int(code), "UA")
+
+ def _decode_output_node(self, code: int) -> str:
+ return "" if int(code) <= 0 else f"开出{int(code)}"
+
+ def _decode_channel_items(self, registers: Sequence[int]) -> List[Dict[str, Any]]:
+ payload = self._registers_to_bytes(registers)
+ items: List[Dict[str, Any]] = []
+ for channel_no in range(1, 13):
+ start = (channel_no - 1) * 5
+ chunk = payload[start : start + 5]
+ if len(chunk) < 5:
+ chunk = chunk + b"\x00" * (5 - len(chunk))
+ items.append(
+ {
+ "ch": channel_no,
+ "singal_type": self._decode_signal_type(chunk[0]),
+ "line_no": int(chunk[1]),
+ "type": self._decode_measure_type(chunk[2]),
+ "limit_low": float(chunk[4]),
+ "limit_high": float(chunk[3]),
+ }
+ )
+ return items
+
+ def _decode_ai_alarm_items(self, registers: Sequence[int]) -> List[Dict[str, Any]]:
+ payload = self._registers_to_bytes(registers)
+ items: List[Dict[str, Any]] = []
+ for channel_no in range(1, 13):
+ start = (channel_no - 1) * 5
+ chunk = payload[start : start + 5]
+ if len(chunk) < 5:
+ chunk = chunk + b"\x00" * (5 - len(chunk))
+ items.append(
+ {
+ "channel_no": channel_no,
+ "singal_type": "4-20mA",
+ "limit_low": float(chunk[1]),
+ "limit_high": float(chunk[0]),
+ "delay": int(chunk[2]),
+ "output_node": self._decode_output_node(chunk[3]),
+ "enabled": bool(chunk[4]),
+ }
+ )
+ return items
+
+ def _encode_transformer_value(self, value: Any) -> int:
+ try:
+ number = int(round(float(value)))
+ except (TypeError, ValueError):
+ number = 1
+ return max(1, min(0xFFFF, number))
+
+ def _decode_transformer_items(self, payload: bytes) -> List[Dict[str, Any]]:
+ pt_ratio = int.from_bytes(payload[28:30], "big", signed=False) if len(payload) >= 30 else 1
+ ct_ratio = int.from_bytes(payload[30:32], "big", signed=False) if len(payload) >= 32 else 1
+ return [
+ {"category": "PT变化", "value": float(pt_ratio or 1), "enabled": bool(pt_ratio)},
+ {"category": "CT变化", "value": float(ct_ratio or 1), "enabled": bool(ct_ratio)},
+ ]
+
+ def _decode_line_alarm_setting(self, line_no: int, registers: Sequence[int]) -> Dict[str, Any]:
+ payload = self._registers_to_bytes(registers)
+ categories = ["电压", "电流", "差流", "功率", "频率", "PT断线", "CT断线"]
+ over_limit_alarm: List[Dict[str, Any]] = []
+ fault_alarm: List[Dict[str, Any]] = []
+ for index, category in enumerate(categories):
+ start = index * 4
+ chunk = payload[start : start + 4]
+ if len(chunk) < 4:
+ chunk = chunk + b"\x00" * (4 - len(chunk))
+ rule = {
+ "category": category,
+ "limit": float(chunk[0]),
+ "delay": int(chunk[1]),
+ "output_node": self._decode_output_node(chunk[2]),
+ "enabled": bool(chunk[3]),
+ }
+ if category in {"PT断线", "CT断线"}:
+ fault_alarm.append(rule)
+ else:
+ over_limit_alarm.append(rule)
+ return {
+ "line_no": line_no,
+ "over_limit_alarm": over_limit_alarm,
+ "fault_alarm": fault_alarm,
+ "transformer_change": self._decode_transformer_items(payload),
+ }
+
+ def _read_line_alarm_registers(self, line_no: int) -> List[int]:
+ start_register = LINE_ALARM_START_REGISTER + max(0, line_no - 1) * LINE_ALARM_REGISTERS_PER_LINE
+ return self.transport.read_holding_registers(start_register, LINE_ALARM_REGISTERS_PER_LINE)
+
def read_realtime_data(self) -> RealtimeData:
line_list = [
self._read_line_data(line_no=index + 1, start_register=start_register)
@@ -515,37 +760,94 @@ class CDeviceClient(DeviceClient):
def read_alarm_events(self) -> List[AlarmEvent]:
event_flag = self.transport.read_input_registers(EVENT_FLAG_REGISTER, 1)[0]
- if event_flag == 0:
+ event_count = int(event_flag)
+ if event_count <= 0:
return []
- registers = self.transport.read_holding_registers(EVENT_HOLDING_START_REGISTER, 4)
- signature = tuple(int(value) for value in registers)
- if signature == self._last_event_signature:
- return []
- self._last_event_signature = signature
+ registers = self.transport.read_input_registers(EVENT_INPUT_START_REGISTER, event_count * 5)
+ alarms: List[AlarmEvent] = []
+ for index in range(event_count):
+ start = index * 5
+ event_registers = registers[start : start + 5]
+ if len(event_registers) < 5:
+ continue
+ signature = tuple(int(value) for value in event_registers[:4])
+ if signature == self._last_event_signature:
+ continue
+ self._last_event_signature = signature
- timestamp = int(registers[0]) | (int(registers[1]) << 16)
- milliseconds = int(registers[2]) & 0xFFFF
- line_and_type = int(registers[3]) & 0xFFFF
- line_no = (line_and_type >> 8) & 0xFF
- event_type = line_and_type & 0xFF
+ timestamp = int(event_registers[0]) | (int(event_registers[1]) << 16)
+ milliseconds = int(event_registers[2]) & 0xFFFF
+ encoded = int(event_registers[3]) & 0xFFFF
+ action_value = int(event_registers[4]) & 0xFFFF
+ line_no = (encoded >> 12) & 0x0F
+ event_type = (encoded >> 8) & 0x0F
+ event_code = encoded & 0xFF
- event_name = {
- 1: "PT断线",
- 2: "CT断线",
- }.get(event_type, f"事件{event_type}")
- event_time = datetime.fromtimestamp(timestamp).replace(microsecond=min(milliseconds, 999) * 1000)
- line_text = "总路" if line_no == 0 else f"线路{line_no}"
- return [
- AlarmEvent(
- alarm_type="modbus_event",
- time=event_time,
- no="" if line_no == 0 else str(line_no),
- type=event_name,
- content=f"{line_text}{event_name}",
- level="中",
+ event_name = self._decode_event_name(event_type, event_code)
+ event_time = datetime.fromtimestamp(timestamp).replace(microsecond=min(milliseconds, 999) * 1000)
+ line_text = "装置" if line_no == 0 else f"线路{line_no}"
+ alarms.append(
+ AlarmEvent(
+ alarm_type="modbus_event",
+ time=event_time,
+ no="" if line_no == 0 else str(line_no),
+ type=event_name,
+ content=f"{line_text}{event_name},动作值={action_value}",
+ level=self._decode_event_level(event_type),
+ )
)
- ]
+
+ if alarms:
+ # 协议文档将“读事件成功回复”描述为 03 读取保持寄存器,但该流程需要携带成功个数;
+ # 这里通过写保持寄存器把已成功读取的事件个数回告给设备。
+ self.transport.write_registers(EVENT_SUCCESS_REPLY_REGISTER, [len(alarms)])
+ return alarms
+
+ def _decode_event_name(self, event_type: int, event_code: int) -> str:
+ if event_type == 1:
+ return {1: "PT断线", 2: "CT断线"}.get(event_code, f"保护事件{event_code}")
+ if event_type == 2:
+ return f"动作事件{event_code}"
+ if event_type == 3:
+ return {1: "过压", 2: "过流", 3: "频率越限"}.get(event_code, f"报警事件{event_code}")
+ if event_type == 4:
+ return f"操作事件{event_code}"
+ if event_type == 5:
+ return f"AI越限{event_code}"
+ return f"事件{event_type}-{event_code}"
+
+ def _decode_event_level(self, event_type: int) -> str:
+ return {1: "严重", 2: "中", 3: "中", 4: "一般", 5: "一般"}.get(event_type, "一般")
+
+ def read_channel_config(self) -> Dict[str, Any]:
+ ai_registers = self.transport.read_holding_registers(AI_CHANNEL_START_REGISTER, ANALOG_BLOCK_REGISTER_COUNT)
+ ao_registers = self.transport.read_holding_registers(AO_CHANNEL_START_REGISTER, ANALOG_BLOCK_REGISTER_COUNT)
+ return {
+ "ai_channel": self._decode_channel_items(ai_registers),
+ "ao_channel": self._decode_channel_items(ao_registers),
+ }
+
+ def read_line_alarm_setting(self, line_no: int) -> Dict[str, Any]:
+ return self._decode_line_alarm_setting(line_no, self._read_line_alarm_registers(line_no))
+
+ def read_ai_alarm_setting(self) -> List[Dict[str, Any]]:
+ registers = self.transport.read_holding_registers(AI_ALARM_START_REGISTER, ANALOG_BLOCK_REGISTER_COUNT)
+ return self._decode_ai_alarm_items(registers)
+
+ def read_system_config(self) -> Dict[str, Any]:
+ registers = self.transport.read_holding_registers(TIME_SYNC_START_REGISTER, 2)
+ timestamp = int(registers[0]) | (int(registers[1]) << 16)
+ return {
+ "time_sync": datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S"),
+ "brightness": SystemConfigIn().brightness,
+ "screen_saver": SystemConfigIn().screen_saver,
+ }
+
+ def read_time_sync_config(self) -> Dict[str, str]:
+ registers = self.transport.read_holding_registers(TIME_SYNC_START_REGISTER, 2)
+ timestamp = int(registers[0]) | (int(registers[1]) << 16)
+ return {"time_sync": datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S")}
def send_device_config(self, payload: DeviceConfigIn) -> Dict[str, Any]:
uart_map = {item.port.upper(): item for item in payload.uart}
@@ -569,29 +871,8 @@ class CDeviceClient(DeviceClient):
}
def send_channel_config(self, payload: ChannelConfigIn) -> Dict[str, Any]:
- def build_channel_payload(items: List[Any]) -> List[int]:
- raw = bytearray()
- channel_map = {int(item.ch): item for item in items}
- for channel_no in range(1, 13):
- item = channel_map.get(channel_no)
- if item is None:
- raw.extend(b"\x00" * 5)
- continue
- raw.extend(
- bytes(
- [
- self._signal_type_code(item.singal_type),
- _clamp_byte(item.line_no),
- self._measure_type_code(item.type),
- _clamp_byte(item.limit_high),
- _clamp_byte(item.limit_low),
- ]
- )
- )
- return self._bytes_to_registers(bytes(raw))
-
- ai_registers = build_channel_payload(payload.ai_channel)
- ao_registers = build_channel_payload(payload.ao_channel)
+ ai_registers = self._bytes_to_registers(self._channel_payload_bytes(payload.ai_channel))
+ ao_registers = self._bytes_to_registers(self._channel_payload_bytes(payload.ao_channel))
self.transport.write_registers(AI_CHANNEL_START_REGISTER, ai_registers)
self.transport.write_registers(AO_CHANNEL_START_REGISTER, ao_registers)
return {
@@ -606,6 +887,7 @@ class CDeviceClient(DeviceClient):
def send_line_alarm_setting(self, payload: LineAlarmSettingIn) -> Dict[str, Any]:
over_limit_map = {rule.category: rule for rule in payload.over_limit_alarm}
fault_map = {rule.category: rule for rule in payload.fault_alarm}
+ transformer_map = {item.category: item for item in payload.transformer_change}
ordered_rules = [
over_limit_map.get("电压"),
@@ -624,6 +906,11 @@ class CDeviceClient(DeviceClient):
continue
raw.extend(self._line_rule_payload(rule.limit, rule.delay, rule.output_node, rule.enabled))
+ pt_item = transformer_map.get("PT变化") or transformer_map.get("PT变比")
+ ct_item = transformer_map.get("CT变化") or transformer_map.get("CT变比")
+ raw.extend(self._encode_transformer_value(getattr(pt_item, "value", 1.0)).to_bytes(2, "big"))
+ raw.extend(self._encode_transformer_value(getattr(ct_item, "value", 1.0)).to_bytes(2, "big"))
+
start_register = LINE_ALARM_START_REGISTER + max(0, payload.line_no - 1) * LINE_ALARM_REGISTERS_PER_LINE
registers = self._bytes_to_registers(bytes(raw))
self.transport.write_registers(start_register, registers)
@@ -681,6 +968,17 @@ class CDeviceClient(DeviceClient):
"ignored_fields": ["brightness", "screen_saver"],
}
+ def send_time_sync_config(self, payload: TimeSyncConfigIn) -> Dict[str, Any]:
+ timestamp = int(datetime.fromisoformat(payload.time_sync.strip()).timestamp())
+ registers = [timestamp & 0xFFFF, (timestamp >> 16) & 0xFFFF]
+ self.transport.write_registers(TIME_SYNC_START_REGISTER, registers)
+ return {
+ "send_status": "成功",
+ "target": "time_sync",
+ "time_sync": datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S"),
+ "modbus_written": {"time_registers": len(registers)},
+ }
+
def send_switch_control(self, payload: SwitchControlIn) -> Dict[str, Any]:
coil_address = max(0, payload.ch - 1)
coil_value = bool(payload.action)
diff --git a/backend/app/api/routes/config.py b/backend/app/api/routes/config.py
index aa14063..6190b0c 100644
--- a/backend/app/api/routes/config.py
+++ b/backend/app/api/routes/config.py
@@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, Query
from app.core.response import success_response
from app.core.security import verify_api_token
-from app.schemas.platform import AiAlarmSettingIn, ChannelConfigIn, DeviceConfigIn, DevicePasswordUpdateIn, LineAlarmSettingIn, NetConfigItem, SystemConfigIn, UartConfigItem
+from app.schemas.platform import AiAlarmSettingIn, ChannelConfigIn, DeviceConfigIn, DevicePasswordUpdateIn, LineAlarmSettingIn, NetConfigItem, SystemConfigIn, TimeSyncConfigIn, UartConfigItem
from app.services.platform_service import platform_service
@@ -84,3 +84,13 @@ def get_system_config() -> Dict[str, Any]:
@router.post("/system", dependencies=[Depends(verify_api_token)])
def save_system_config(payload: SystemConfigIn) -> Dict[str, Any]:
return success_response(platform_service.save_system_config(payload))
+
+
+@router.get("/time-sync", dependencies=[Depends(verify_api_token)])
+def get_time_sync_config() -> Dict[str, Any]:
+ return success_response(platform_service.get_time_sync_config(), msg="获取对时设置成功")
+
+
+@router.post("/time-sync", dependencies=[Depends(verify_api_token)])
+def save_time_sync_config(payload: TimeSyncConfigIn) -> Dict[str, Any]:
+ return success_response(platform_service.save_time_sync_config(payload), msg="设备对时成功")
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
index 7e177bc..d55473f 100644
--- a/backend/app/core/config.py
+++ b/backend/app/core/config.py
@@ -12,7 +12,9 @@ class Settings(BaseSettings):
api_prefix: str = "/api"
realtime_ws_path: str = "/ws/real-time"
alarm_ws_path: str = "/ws/alarm"
+ time_sync_ws_path: str = "/ws/time-sync"
poll_interval_seconds: float = 0.5
+ time_sync_poll_interval_seconds: float = 1.0
auth_password: str = "admin123"
use_mock_device: bool = True
modbus_transport: str = "rtu"
@@ -34,7 +36,7 @@ class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="EMCP_",
- env_file=".env",
+ env_file=BASE_DIR / ".env",
env_file_encoding="utf-8",
extra="ignore",
)
diff --git a/backend/app/main.py b/backend/app/main.py
index b108fff..c4a39b0 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -9,7 +9,7 @@ from fastapi.middleware.cors import CORSMiddleware
from app.api.router import api_router
from app.core.config import settings
from app.db.sqlite import init_db
-from app.tasks.polling import device_polling_loop, stop_task
+from app.tasks.polling import device_polling_loop, stop_task, time_sync_polling_loop
from app.ws.manager import ws_manager
@@ -17,10 +17,12 @@ from app.ws.manager import ws_manager
async def lifespan(_: FastAPI):
init_db()
poll_task = asyncio.create_task(device_polling_loop())
+ time_sync_task = asyncio.create_task(time_sync_polling_loop())
try:
yield
finally:
await stop_task(poll_task)
+ await stop_task(time_sync_task)
app = FastAPI(
@@ -63,3 +65,13 @@ async def alarm_ws(websocket: WebSocket) -> None:
await websocket.receive_text()
except WebSocketDisconnect:
ws_manager.disconnect("alarm", websocket)
+
+
+@app.websocket(settings.time_sync_ws_path)
+async def time_sync_ws(websocket: WebSocket) -> None:
+ await ws_manager.connect("time-sync", websocket)
+ try:
+ while True:
+ await websocket.receive_text()
+ except WebSocketDisconnect:
+ ws_manager.disconnect("time-sync", websocket)
diff --git a/backend/app/schemas/platform.py b/backend/app/schemas/platform.py
index 8df34d1..c42fbf8 100644
--- a/backend/app/schemas/platform.py
+++ b/backend/app/schemas/platform.py
@@ -146,6 +146,15 @@ class SystemConfigIn(BaseModel):
screen_saver: int = Field(default=60, ge=0)
+class TimeSyncConfigIn(BaseModel):
+ time_sync: str
+
+
+class TimeSyncEnvelope(BaseModel):
+ type: str = "time_sync"
+ data: Dict[str, str]
+
+
class PasswordVerifyIn(BaseModel):
password: str
diff --git a/backend/app/services/platform_service.py b/backend/app/services/platform_service.py
index e49ec93..5574b78 100644
--- a/backend/app/services/platform_service.py
+++ b/backend/app/services/platform_service.py
@@ -21,6 +21,8 @@ from app.schemas.platform import (
RealtimeData,
SwitchControlIn,
SystemConfigIn,
+ TimeSyncConfigIn,
+ ValueGroup,
ValueGroup,
UartConfigItem,
)
@@ -43,46 +45,42 @@ class PlatformService:
return float(primary) / float(secondary)
def _get_config_transformer_ratios(self, line_no: int) -> Dict[str, float]:
- """从配置文件读取 PT/CT 变比。
+ """从设备参数区读取 PT/CT 变比。
- 来源:setting.json -> line_alarm_setting[].transformer_change[]
- 当前约定:category 为“PT变化/CT变化”(兼容旧的“PT变比/CT变比”),取 value(兼容旧 limit)字段。
+ 来源:设备线路定值回读结果中的 transformer_change[]
+ 当前约定:category 为“PT变化/CT变化”(兼容旧的“PT变比/CT变比”),取 value 字段。
"""
try:
- current = self.config_repo.read_setting_section("line_alarm_setting")
+ current = self.device_client.read_line_alarm_setting(line_no)
except Exception:
- current = None
- line_alarm_list = self._normalize_line_alarm_settings(current)
- for item in line_alarm_list:
- if item.get("line_no") != line_no:
+ return {}
+
+ transformer_list = current.get("transformer_change")
+ if not isinstance(transformer_list, list):
+ return {}
+ pt_ratio: Optional[float] = None
+ ct_ratio: Optional[float] = None
+ for transformer in transformer_list:
+ if not isinstance(transformer, dict):
continue
- transformer_list = item.get("transformer_change")
- if not isinstance(transformer_list, list):
- return {}
- pt_ratio: Optional[float] = None
- ct_ratio: Optional[float] = None
- for transformer in transformer_list:
- if not isinstance(transformer, dict):
- continue
- category = str(transformer.get("category", "")).strip()
- raw_value = transformer.get("value", transformer.get("limit", 1.0))
- try:
- value = float(raw_value)
- except (TypeError, ValueError):
- value = 1.0
- if value <= 0:
- value = 1.0
- if category in {"PT变化", "PT变比"}:
- pt_ratio = value
- if category in {"CT变化", "CT变比"}:
- ct_ratio = value
- result: Dict[str, float] = {}
- if pt_ratio is not None:
- result["pt_ratio"] = pt_ratio
- if ct_ratio is not None:
- result["ct_ratio"] = ct_ratio
- return result
- return {}
+ category = str(transformer.get("category", "")).strip()
+ raw_value = transformer.get("value", transformer.get("limit", 1.0))
+ try:
+ value = float(raw_value)
+ except (TypeError, ValueError):
+ value = 1.0
+ if value <= 0:
+ value = 1.0
+ if category in {"PT变化", "PT变比"}:
+ pt_ratio = value
+ if category in {"CT变化", "CT变比"}:
+ ct_ratio = value
+ result: Dict[str, float] = {}
+ if pt_ratio is not None:
+ result["pt_ratio"] = pt_ratio
+ if ct_ratio is not None:
+ result["ct_ratio"] = ct_ratio
+ return result
def _get_line_transformer_ratios(self, line: LineData) -> Dict[str, float]:
"""确定当前线路使用的 PT/CT 变比。
@@ -378,18 +376,11 @@ class PlatformService:
return {"save_path": f"/config/{path.name}", "target": "uart", "port": payload.port, "send_status": "成功"}
def save_channel_config(self, payload: ChannelConfigIn) -> Dict[str, Any]:
- path = self.config_repo.write_channel_config(payload.model_dump())
device_result = self.device_client.send_channel_config(payload)
- return {"save_path": f"/config/{path.name}", **device_result}
+ return device_result
def get_channel_config(self) -> Dict[str, Any]:
- current = self.config_repo.read_channel_config()
- data = {
- "ai_channel": [{"ch": 1, "singal_type": "4-20mA", "line_no": 1, "type": "UA", "limit_low": 0, "limit_high": 20}],
- "ao_channel": [{"ch": 1, "singal_type": "1-5v", "line_no": 2, "type": "UA", "limit_low": 0, "limit_high": 20}],
- }
- data.update(current)
- return data
+ return self.device_client.read_channel_config()
def _default_line_alarm_setting(self, line_no: int) -> Dict[str, Any]:
return {
@@ -398,9 +389,13 @@ class PlatformService:
{"category": "电压", "limit": 180, "delay": 180, "output_node": "开出1", "enabled": True},
{"category": "电流", "limit": 180, "delay": 180, "output_node": "开出1", "enabled": True},
{"category": "差流", "limit": 180, "delay": 180, "output_node": "开出1", "enabled": False},
+ {"category": "功率", "limit": 180, "delay": 180, "output_node": "开出1", "enabled": False},
{"category": "频率", "limit": 180, "delay": 180, "output_node": "开出1", "enabled": False},
],
- "fault_alarm": [{"category": "PT断线", "delay": 180, "output_node": "开出1", "enabled": True}],
+ "fault_alarm": [
+ {"category": "PT断线", "limit": 0, "delay": 180, "output_node": "开出1", "enabled": True},
+ {"category": "CT断线", "limit": 0, "delay": 180, "output_node": "开出2", "enabled": False},
+ ],
"transformer_change": [
{"category": "PT变化", "value": 1.0, "enabled": False},
{"category": "CT变化", "value": 1.0, "enabled": False},
@@ -415,103 +410,47 @@ class PlatformService:
return []
def save_line_alarm_setting(self, payload: LineAlarmSettingIn) -> Dict[str, Any]:
- current = self.config_repo.read_json("setting.json")
- if not isinstance(current, dict):
- current = {}
- line_alarm_list = self._normalize_line_alarm_settings(current.get("line_alarm_setting"))
- payload_data = payload.model_dump(exclude_unset=True)
- payload_data["line_no"] = payload.line_no
-
- updated = False
- for index, item in enumerate(line_alarm_list):
- if item.get("line_no") == payload.line_no:
- merged = dict(item)
- merged.update(payload_data)
- default = self._default_line_alarm_setting(payload.line_no)
- for key in ("over_limit_alarm", "fault_alarm", "transformer_change"):
- if not isinstance(merged.get(key), list):
- merged[key] = default[key]
- line_alarm_list[index] = merged
- updated = True
- break
- if not updated:
- merged = self._default_line_alarm_setting(payload.line_no)
- merged.update(payload_data)
- for key in ("over_limit_alarm", "fault_alarm", "transformer_change"):
- if not isinstance(merged.get(key), list):
- merged[key] = self._default_line_alarm_setting(payload.line_no)[key]
- line_alarm_list.append(merged)
-
- current["line_alarm_setting"] = line_alarm_list
- path = self.config_repo.write_setting_config(current)
device_result = self.device_client.send_line_alarm_setting(payload)
- return {"save_path": f"/config/{path.name}", **device_result}
+ return device_result
def get_line_alarm_setting(self, line_no: int = 1) -> Dict[str, Any]:
- current = self.config_repo.read_setting_section("line_alarm_setting")
- line_alarm_list = self._normalize_line_alarm_settings(current)
- for item in line_alarm_list:
- if item.get("line_no") == line_no:
- merged = self._default_line_alarm_setting(line_no)
- merged.update(item)
- for key in ("over_limit_alarm", "fault_alarm", "transformer_change"):
- if not isinstance(merged.get(key), list):
- merged[key] = self._default_line_alarm_setting(line_no)[key]
- normalized_transformer: List[Dict[str, Any]] = []
- for transformer in merged.get("transformer_change", []):
- if not isinstance(transformer, dict):
- continue
- category = transformer.get("category", "")
- enabled = bool(transformer.get("enabled", False))
- raw_value = transformer.get("value", transformer.get("limit", 1.0))
- try:
- value = float(raw_value)
- except (TypeError, ValueError):
- value = 1.0
- normalized_transformer.append({"category": category, "value": value, "enabled": enabled})
- merged["transformer_change"] = normalized_transformer
- return merged
- return self._default_line_alarm_setting(line_no)
+ current = self.device_client.read_line_alarm_setting(line_no)
+ merged = self._default_line_alarm_setting(line_no)
+ if isinstance(current, dict):
+ merged.update(current)
+ normalized_transformer: List[Dict[str, Any]] = []
+ for transformer in merged.get("transformer_change", []):
+ if not isinstance(transformer, dict):
+ continue
+ category = transformer.get("category", "")
+ enabled = bool(transformer.get("enabled", False))
+ raw_value = transformer.get("value", transformer.get("limit", 1.0))
+ try:
+ value = float(raw_value)
+ except (TypeError, ValueError):
+ value = 1.0
+ normalized_transformer.append({"category": category, "value": value, "enabled": enabled})
+ merged["transformer_change"] = normalized_transformer
+ return merged
def save_ai_alarm_setting(self, payload: List[AiAlarmSettingIn]) -> Dict[str, Any]:
- current = self.config_repo.read_json("setting.json")
- if not isinstance(current, dict):
- current = {}
- current["ai_alarm_setting"] = [item.model_dump() for item in payload]
- path = self.config_repo.write_setting_config(current)
- device_result = self.device_client.send_ai_alarm_setting(current["ai_alarm_setting"])
- return {"save_path": f"/config/{path.name}", **device_result}
+ payload_data = [item.model_dump() for item in payload]
+ return self.device_client.send_ai_alarm_setting(payload_data)
def get_ai_alarm_setting(self) -> List[Dict[str, Any]]:
- current = self.config_repo.read_setting_section("ai_alarm_setting")
- if isinstance(current, list):
- return current
- return [
- {
- "channel_no": 1,
- "singal_type": "4-20mA",
- "limit_low": 0,
- "limit_high": 20,
- "delay": 180,
- "output_node": "开出1",
- "enabled": True,
- }
- ]
+ return self.device_client.read_ai_alarm_setting()
def save_system_config(self, payload: SystemConfigIn) -> Dict[str, Any]:
- current = self.config_repo.read_json("setting.json")
- if not isinstance(current, dict):
- current = {}
- current["system_config"] = payload.model_dump()
- self.config_repo.write_setting_config(current)
return self.device_client.send_system_config(payload)
def get_system_config(self) -> Dict[str, Any]:
- current = self.config_repo.read_setting_section("system_config")
- data = SystemConfigIn().model_dump()
- if isinstance(current, dict):
- data.update(current)
- return data
+ return self.device_client.read_system_config()
+
+ def save_time_sync_config(self, payload: TimeSyncConfigIn) -> Dict[str, Any]:
+ return self.device_client.send_time_sync_config(payload)
+
+ def get_time_sync_config(self) -> Dict[str, Any]:
+ return self.device_client.read_time_sync_config()
def list_alarms(
self,
@@ -534,6 +473,10 @@ class PlatformService:
def switch_control(self, payload: SwitchControlIn) -> Dict[str, Any]:
return self.device_client.send_switch_control(payload)
+ async def poll_time_sync_once(self) -> None:
+ time_sync = self.device_client.read_time_sync_config()
+ await ws_manager.broadcast("time-sync", {"type": "time_sync", "data": time_sync})
+
async def poll_device_once(self) -> None:
realtime = self._normalize_realtime_precision(self.device_client.read_realtime_data())
status = self.device_client.read_device_status()
diff --git a/backend/app/tasks/polling.py b/backend/app/tasks/polling.py
index 4e79807..168c3b7 100644
--- a/backend/app/tasks/polling.py
+++ b/backend/app/tasks/polling.py
@@ -14,6 +14,12 @@ async def device_polling_loop() -> None:
await asyncio.sleep(settings.poll_interval_seconds)
+async def time_sync_polling_loop() -> None:
+ while True:
+ await platform_service.poll_time_sync_once()
+ await asyncio.sleep(settings.time_sync_poll_interval_seconds)
+
+
async def stop_task(task: Optional[asyncio.Task]) -> None:
if task is None:
return
diff --git a/backend/config/channel.json b/backend/config/channel.json
index 4008c28..c9434f5 100644
--- a/backend/config/channel.json
+++ b/backend/config/channel.json
@@ -2,7 +2,7 @@
"ai_channel": [
{
"ch": 1,
- "singal_type": "1-5v",
+ "singal_type": "4~20mA",
"line_no": 2,
"type": "UA",
"limit_low": 10.0,
diff --git a/backend/config/setting.json b/backend/config/setting.json
index 723b452..15bf382 100644
--- a/backend/config/setting.json
+++ b/backend/config/setting.json
@@ -334,8 +334,8 @@
"ai_alarm_setting": [
{
"channel_no": 1,
- "singal_type": "4-20mA",
- "limit_low": 0.0,
+ "singal_type": "4~20mA",
+ "limit_low": 4.0,
"limit_high": 20.0,
"delay": 180,
"output_node": "开出1",
@@ -344,8 +344,8 @@
{
"channel_no": 2,
"singal_type": "1~5V",
- "limit_low": 0.0,
- "limit_high": 8.0,
+ "limit_low": 1.0,
+ "limit_high": 5.0,
"delay": 8,
"output_node": "",
"enabled": true
@@ -353,7 +353,7 @@
{
"channel_no": 3,
"singal_type": "4~20mA",
- "limit_low": 0.0,
+ "limit_low": 4.0,
"limit_high": 8.0,
"delay": 8,
"output_node": "",
@@ -362,8 +362,8 @@
{
"channel_no": 4,
"singal_type": "1~5V",
- "limit_low": 0.0,
- "limit_high": 8.0,
+ "limit_low": 1.0,
+ "limit_high": 4.0,
"delay": 8,
"output_node": "",
"enabled": false
@@ -371,7 +371,7 @@
{
"channel_no": 5,
"singal_type": "4~20mA",
- "limit_low": 0.0,
+ "limit_low": 4.0,
"limit_high": 8.0,
"delay": 8,
"output_node": "",
@@ -380,8 +380,8 @@
{
"channel_no": 6,
"singal_type": "1~5V",
- "limit_low": 0.0,
- "limit_high": 8.0,
+ "limit_low": 1.0,
+ "limit_high": 5.0,
"delay": 8,
"output_node": "",
"enabled": false
@@ -389,7 +389,7 @@
{
"channel_no": 7,
"singal_type": "4~20mA",
- "limit_low": 0.0,
+ "limit_low": 4.0,
"limit_high": 8.0,
"delay": 8,
"output_node": "",
@@ -398,8 +398,8 @@
{
"channel_no": 8,
"singal_type": "1~5V",
- "limit_low": 0.0,
- "limit_high": 8.0,
+ "limit_low": 1.0,
+ "limit_high": 5.0,
"delay": 8,
"output_node": "",
"enabled": false
@@ -407,7 +407,7 @@
{
"channel_no": 9,
"singal_type": "4~20mA",
- "limit_low": 0.0,
+ "limit_low": 4.0,
"limit_high": 8.0,
"delay": 8,
"output_node": "",
@@ -416,8 +416,8 @@
{
"channel_no": 10,
"singal_type": "1~5V",
- "limit_low": 0.0,
- "limit_high": 8.0,
+ "limit_low": 1.0,
+ "limit_high": 5.0,
"delay": 8,
"output_node": "",
"enabled": false
@@ -425,7 +425,7 @@
{
"channel_no": 11,
"singal_type": "4~20mA",
- "limit_low": 0.0,
+ "limit_low": 4.0,
"limit_high": 8.0,
"delay": 8,
"output_node": "",
@@ -433,8 +433,8 @@
},
{
"channel_no": 12,
- "singal_type": "1~5V",
- "limit_low": 0.0,
+ "singal_type": "4~20mA",
+ "limit_low": 4.0,
"limit_high": 8.0,
"delay": 8,
"output_node": "",
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
index 27e4734..5a19a74 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -2,6 +2,7 @@ from datetime import datetime
from fastapi.testclient import TestClient
+from app.adapters.device_client import MockDeviceClient
from app.core.config import settings
from app.core.security import hash_password
from app.db.sqlite import get_connection, init_db
@@ -130,7 +131,9 @@ def test_alarm_list_filters(tmp_path) -> None:
def test_config_query_endpoints(tmp_path) -> None:
old_repo = platform_service.config_repo
+ old_client = platform_service.device_client
platform_service.config_repo = JsonConfigRepository(tmp_path)
+ platform_service.device_client = MockDeviceClient()
try:
platform_service.config_repo.write_device_config(
@@ -154,22 +157,6 @@ def test_config_query_endpoints(tmp_path) -> None:
],
}
)
- platform_service.config_repo.write_channel_config(
- {
- "ai_channel": [{"ch": 1, "singal_type": "4-20mA", "line_no": 1, "type": "UA", "limit_low": 0, "limit_high": 20}],
- "ao_channel": [{"ch": 1, "singal_type": "1-5v", "line_no": 2, "type": "UA", "limit_low": 0, "limit_high": 20}],
- }
- )
- platform_service.config_repo.write_setting_config(
- {
- "line_alarm_setting": [
- {"line_no": 1, "over_limit_alarm": [{"category": "电压", "limit": 180, "delay": 180, "output_node": "开出1", "enabled": True}], "fault_alarm": []},
- {"line_no": 2, "over_limit_alarm": [{"category": "电流", "limit": 200, "delay": 120, "output_node": "开出2", "enabled": True}], "fault_alarm": []},
- ],
- "ai_alarm_setting": [{"channel_no": 1, "singal_type": "4-20mA", "limit_low": 0, "limit_high": 20, "delay": 180, "output_node": "开出1", "enabled": True}],
- "system_config": {"time_sync": "manual", "brightness": 83, "screen_saver": 60},
- }
- )
headers = {"X-API-Token": settings.auth_password}
@@ -177,9 +164,10 @@ def test_config_query_endpoints(tmp_path) -> None:
net_response = client.get("/api/config/device/net", headers=headers, params={"nic": "网卡一"})
uart_response = client.get("/api/config/device/uart", headers=headers, params={"port": "COM1"})
channel_response = client.get("/api/config/channel", headers=headers)
- line_alarm_response = client.get("/api/config/line_alarm_setting", headers=headers, params={"line_no": 2})
+ line_alarm_response = client.get("/api/config/line_alarm_setting", headers=headers, params={"line_no": 1})
ai_alarm_response = client.get("/api/config/ai_alarm_setting", headers=headers)
system_response = client.get("/api/config/system", headers=headers)
+ time_sync_response = client.get("/api/config/time-sync", headers=headers)
assert device_response.status_code == 200
assert net_response.status_code == 200
@@ -188,44 +176,93 @@ def test_config_query_endpoints(tmp_path) -> None:
assert line_alarm_response.status_code == 200
assert ai_alarm_response.status_code == 200
assert system_response.status_code == 200
+ assert time_sync_response.status_code == 200
assert device_response.json()["data"]["password"] == ""
assert device_response.json()["data"]["net"][0]["ip"] == "192.168.1.10"
assert net_response.json()["data"]["nic"] == "网卡一"
assert uart_response.json()["data"]["port"] == "COM1"
assert channel_response.json()["data"]["ai_channel"][0]["singal_type"] == "4-20mA"
- assert line_alarm_response.json()["data"]["line_no"] == 2
- assert line_alarm_response.json()["data"]["over_limit_alarm"][0]["category"] == "电流"
+ assert line_alarm_response.json()["data"]["line_no"] == 1
+ assert line_alarm_response.json()["data"]["transformer_change"][0]["category"] == "PT变化"
assert ai_alarm_response.json()["data"][0]["channel_no"] == 1
- assert system_response.json()["data"]["brightness"] == 83
+ assert system_response.json()["data"]["time_sync"] == "2026-05-25 12:34:56"
+ assert time_sync_response.json()["data"]["time_sync"] == "2026-05-25 12:34:56"
finally:
+ platform_service.device_client = old_client
platform_service.config_repo = old_repo
-def test_save_line_alarm_setting_stores_list(tmp_path) -> None:
+def test_protocol_config_endpoints_read_and_write_device_only(tmp_path) -> None:
old_repo = platform_service.config_repo
+ old_client = platform_service.device_client
platform_service.config_repo = JsonConfigRepository(tmp_path)
+ platform_service.device_client = MockDeviceClient()
try:
headers = {"X-API-Token": settings.auth_password}
- response = client.post(
+ channel_response = client.post(
+ "/api/config/channel",
+ headers=headers,
+ json={
+ "ai_channel": [{"ch": 1, "singal_type": "4-20mA", "line_no": 3, "type": "UA", "limit_low": 0, "limit_high": 20}],
+ "ao_channel": [{"ch": 2, "singal_type": "1~5V", "line_no": 2, "type": "Q", "limit_low": 1, "limit_high": 8}],
+ },
+ )
+ line_response = client.post(
"/api/config/line_alarm_setting",
headers=headers,
json={
"line_no": 3,
"over_limit_alarm": [{"category": "频率", "limit": 51.5, "delay": 30, "output_node": "开出1", "enabled": True}],
"fault_alarm": [{"category": "PT断线", "delay": 20, "output_node": "开出2", "enabled": False}],
+ "transformer_change": [{"category": "PT变化", "value": 20, "enabled": True}, {"category": "CT变化", "value": 30, "enabled": True}],
},
)
+ ai_alarm_response = client.post(
+ "/api/config/ai_alarm_setting",
+ headers=headers,
+ json=[
+ {
+ "channel_no": 1,
+ "singal_type": "4-20mA",
+ "limit_low": 0,
+ "limit_high": 20,
+ "delay": 30,
+ "output_node": "开出1",
+ "enabled": True,
+ }
+ ],
+ )
+ system_response = client.post(
+ "/api/config/system",
+ headers=headers,
+ json={"time_sync": "2026-05-25 13:00:00", "brightness": 88, "screen_saver": 90},
+ )
+ query_channel = client.get("/api/config/channel", headers=headers)
+ query_line = client.get("/api/config/line_alarm_setting", headers=headers, params={"line_no": 3})
+ query_ai_alarm = client.get("/api/config/ai_alarm_setting", headers=headers)
+ query_system = client.get("/api/config/system", headers=headers)
+ time_sync_response = client.post(
+ "/api/config/time-sync",
+ headers=headers,
+ json={"time_sync": "2026-05-25 14:00:00"},
+ )
+ query_time_sync = client.get("/api/config/time-sync", headers=headers)
- assert response.status_code == 200
-
- saved = platform_service.config_repo.read_setting_config()
- assert isinstance(saved["line_alarm_setting"], list)
- assert saved["line_alarm_setting"][0]["line_no"] == 3
- assert "transformer_change" in saved["line_alarm_setting"][0]
- assert len(saved["line_alarm_setting"][0]["transformer_change"]) == 2
+ assert channel_response.status_code == 200
+ assert line_response.status_code == 200
+ assert ai_alarm_response.status_code == 200
+ assert system_response.status_code == 200
+ assert time_sync_response.status_code == 200
+ assert query_channel.json()["data"]["ai_channel"][0]["line_no"] == 3
+ assert query_line.json()["data"]["transformer_change"][0]["value"] == 20.0
+ assert query_ai_alarm.json()["data"][0]["delay"] == 30
+ assert query_system.json()["data"]["brightness"] == 88
+ assert query_time_sync.json()["data"]["time_sync"] == "2026-05-25 14:00:00"
+ assert platform_service.config_repo.read_setting_config() == {}
finally:
+ platform_service.device_client = old_client
platform_service.config_repo = old_repo
diff --git a/backend/tests/test_modbus_client.py b/backend/tests/test_modbus_client.py
index 34f9787..1f1a2f2 100644
--- a/backend/tests/test_modbus_client.py
+++ b/backend/tests/test_modbus_client.py
@@ -11,9 +11,11 @@ from app.adapters.device_client import (
AO_CHANNEL_START_REGISTER,
CDeviceClient,
DI_INPUT_REGISTER,
+ DO_INPUT_REGISTER,
DO_STATE_REGISTER,
EVENT_FLAG_REGISTER,
- EVENT_HOLDING_START_REGISTER,
+ EVENT_INPUT_START_REGISTER,
+ EVENT_SUCCESS_REPLY_REGISTER,
LINE_ALARM_REGISTERS_PER_LINE,
LINE_ALARM_START_REGISTER,
LINE_START_REGISTERS,
@@ -35,6 +37,7 @@ from app.schemas.platform import (
HardwareVersion,
SwitchControlIn,
SystemConfigIn,
+ TimeSyncConfigIn,
UartConfigItem,
)
@@ -103,7 +106,7 @@ def test_modbus_client_reads_upstream_data() -> None:
ai_values.extend(client._float_to_registers(1.25 + index))
transport.set_input_block(AI_INPUT_START_REGISTER, ai_values)
transport.set_discrete_block(0x0000, [True, False, True, False, True, False, True, False, True, False, True, False])
- transport.set_coil_block(0x0000, [False, True, False, True, False, True, False, True, False, True, False, True])
+ transport.set_input_block(DO_INPUT_REGISTER, [0x0AAA])
transport.set_input_block(DI_INPUT_REGISTER, [0x0555])
realtime = client.read_realtime_data()
@@ -127,13 +130,14 @@ def test_modbus_client_reads_alarm_event_once() -> None:
timestamp = int(datetime(2026, 5, 25, 12, 0, 0).timestamp())
transport.set_input_block(EVENT_FLAG_REGISTER, [1])
- transport.set_holding_block(
- EVENT_HOLDING_START_REGISTER,
+ transport.set_input_block(
+ EVENT_INPUT_START_REGISTER,
[
timestamp & 0xFFFF,
(timestamp >> 16) & 0xFFFF,
123,
- (2 << 8) | 1,
+ (2 << 12) | (1 << 8) | 1,
+ 456,
],
)
@@ -145,12 +149,58 @@ def test_modbus_client_reads_alarm_event_once() -> None:
assert alarms[0].type == "PT断线"
assert "线路2PT断线" in alarms[0].content
assert duplicate == []
+ assert transport.written_registers[-1] == (EVENT_SUCCESS_REPLY_REGISTER, [1])
-def test_modbus_client_writes_downstream_registers() -> None:
+def test_modbus_client_reads_and_writes_downstream_registers() -> None:
transport = FakeTransport()
client = CDeviceClient(transport=transport)
+ ai_channel_payload = client._bytes_to_registers(
+ bytes(
+ [
+ 0, 1, 0, 20, 0,
+ 1, 2, 7, 8, 1,
+ ]
+ + [0] * 50
+ )
+ )
+ ao_channel_payload = client._bytes_to_registers(
+ bytes(
+ [
+ 1, 1, 6, 10, 2,
+ ]
+ + [0] * 55
+ )
+ )
+ line_alarm_payload_bytes = bytes(
+ [
+ 180, 5, 1, 1,
+ 200, 6, 2, 0,
+ 20, 7, 3, 1,
+ 30, 8, 4, 0,
+ 50, 9, 5, 1,
+ 0, 10, 6, 1,
+ 0, 11, 7, 0,
+ ]
+ ) + (10).to_bytes(2, "big") + (20).to_bytes(2, "big")
+ ai_alarm_payload_bytes = bytes(
+ [
+ 20, 0, 3, 1, 1,
+ ]
+ + [0] * 55
+ )
+ timestamp = int(datetime(2026, 5, 25, 12, 34, 56).timestamp())
+
+ transport.set_holding_block(AI_CHANNEL_START_REGISTER, ai_channel_payload)
+ transport.set_holding_block(AO_CHANNEL_START_REGISTER, ao_channel_payload)
+ transport.set_holding_block(
+ LINE_ALARM_START_REGISTER + LINE_ALARM_REGISTERS_PER_LINE,
+ client._bytes_to_registers(line_alarm_payload_bytes),
+ )
+ transport.set_holding_block(AI_ALARM_START_REGISTER, client._bytes_to_registers(ai_alarm_payload_bytes))
+ transport.set_holding_block(TIME_SYNC_START_REGISTER, [timestamp & 0xFFFF, (timestamp >> 16) & 0xFFFF])
+
device_payload = DeviceConfigIn(
password="",
hardware_version=HardwareVersion(),
@@ -193,23 +243,39 @@ def test_modbus_client_writes_downstream_registers() -> None:
system_payload = SystemConfigIn(time_sync="2026-05-25 12:34:56", brightness=80, screen_saver=60)
switch_payload = SwitchControlIn(ch=3, action=1)
+ read_channel = client.read_channel_config()
+ read_line_alarm = client.read_line_alarm_setting(2)
+ read_ai_alarm = client.read_ai_alarm_setting()
+ read_system = client.read_system_config()
+ read_time_sync = client.read_time_sync_config()
device_result = client.send_device_config(device_payload)
channel_result = client.send_channel_config(channel_payload)
line_result = client.send_line_alarm_setting(line_alarm_payload)
ai_alarm_result = client.send_ai_alarm_setting(ai_alarm_payload)
system_result = client.send_system_config(system_payload)
+ time_sync_result = client.send_time_sync_config(TimeSyncConfigIn(time_sync="2026-05-25 13:45:00"))
control_result = client.send_switch_control(switch_payload)
register_map = {address: values for address, values in transport.written_registers}
assert UART_CONFIG_START_REGISTER in register_map
assert len(register_map[UART_CONFIG_START_REGISTER]) == UART_CONFIG_REGISTER_COUNT
+ assert read_channel["ai_channel"][0]["singal_type"] == "4-20mA"
+ assert read_channel["ao_channel"][0]["type"] == "P"
+ assert read_line_alarm["line_no"] == 2
+ assert read_line_alarm["transformer_change"][0]["value"] == 10.0
+ assert read_line_alarm["transformer_change"][1]["value"] == 20.0
+ assert read_ai_alarm[0]["channel_no"] == 1
+ assert read_ai_alarm[0]["delay"] == 3
+ assert read_system["time_sync"] == "2026-05-25 12:34:56"
+ assert read_time_sync["time_sync"] == "2026-05-25 12:34:56"
+
assert AI_CHANNEL_START_REGISTER in register_map
assert len(register_map[AI_CHANNEL_START_REGISTER]) == 30
assert AO_CHANNEL_START_REGISTER in register_map
assert len(register_map[AO_CHANNEL_START_REGISTER]) == 30
assert LINE_ALARM_START_REGISTER + LINE_ALARM_REGISTERS_PER_LINE in register_map
- assert len(register_map[LINE_ALARM_START_REGISTER + LINE_ALARM_REGISTERS_PER_LINE]) == 14
+ assert len(register_map[LINE_ALARM_START_REGISTER + LINE_ALARM_REGISTERS_PER_LINE]) == 16
assert AI_ALARM_START_REGISTER in register_map
assert len(register_map[AI_ALARM_START_REGISTER]) == 30
assert TIME_SYNC_START_REGISTER in register_map
@@ -223,4 +289,7 @@ def test_modbus_client_writes_downstream_registers() -> None:
assert line_result["line_no"] == 2
assert ai_alarm_result["items"] == 1
assert system_result["send_status"] == "成功"
+ assert "brightness" in system_result["ignored_fields"]
+ assert time_sync_result["target"] == "time_sync"
+ assert time_sync_result["time_sync"] == "2026-05-25 13:45:00"
assert "执行成功" in control_result["control_status"]
diff --git a/document/~$界面modbus协议寄存器定义v1.1.doc b/document/~$界面modbus协议寄存器定义v1.1.doc
new file mode 100644
index 0000000..ebdf1dd
Binary files /dev/null and b/document/~$界面modbus协议寄存器定义v1.1.doc differ
diff --git a/document/显示界面modbus协议寄存器定义.md b/document/显示界面modbus协议寄存器定义.md
index d29e3c1..77ec0fa 100644
--- a/document/显示界面modbus协议寄存器定义.md
+++ b/document/显示界面modbus协议寄存器定义.md
@@ -1,212 +1,420 @@
# Modbus 协议寄存器定义文档
-## 一、协议基本配置
+## 一、协议概览
-- **协议类型**:Modbus RTU
-- **从机地址**:1
-- **串口配置**:
- - 波特率:115200
- - 数据位:8
- - 校验位:无
- - 停止位:1
+| 项目 | 配置 |
+| ---- | ---- |
+| 协议类型 | Modbus RTU |
+| 从机地址 | 1 |
+| 波特率 | 115200 |
+| 数据位 | 8 |
+| 校验位 | 无 |
+| 停止位 | 1 |
---
-## 二、读数据(功能码:04)
+## 二、寄存器总览
-### 1. 实时显示数据
+### 2.1 读数据区
-- **寄存器类型**:输入寄存器
-- **功能码**:0x04
-- **数据格式**:单精度浮点数(32位,占2个寄存器)
+| 功能 | 功能码 | 寄存器类型 | 起始地址 | 长度/范围 | 说明 |
+| ---- | ------ | ---------- | -------- | --------- | ---- |
+| 实时显示数据-线路1 | `0x04` | 输入寄存器 | `0x0000` | `0x0000 ~ 0x0032` | 单精度浮点数,32bit,2寄存器/点 |
+| 实时显示数据-线路2 | `0x04` | 输入寄存器 | `0x0034` | `0x0034 ~ 0x0066` | 参数顺序同线路1 |
+| 实时显示数据-线路3 | `0x04` | 输入寄存器 | `0x0068` | `0x0068 ~ 0x009A` | 参数顺序同线路1 |
+| 实时显示数据-线路4 | `0x04` | 输入寄存器 | `0x009C` | `0x009C ~ 0x00CE` | 参数顺序同线路1 |
+| AI输入 | `0x04` | 输入寄存器 | `0x00D0` | `0x00D0 ~ 0x00DE` | 8路 AI,单精度浮点数 |
+| B码对时 | `0x04` | 输入寄存器 | `0x00E0` | `0x00E0 ~ 0x00E2` | 秒/分、时/日、月/年 |
+| 开入状态 | `0x04` | 输入寄存器 | `0x00E3` | 1寄存器 | 16路,每 bit 一路 |
+| 开出状态 | `0x04` | 输入寄存器 | `0x00E4` | 1寄存器 | 16路,每 bit 一路 |
+| 事件总寄存器 | `0x04` | 输入寄存器 | `0x00E5` | 1寄存器 | 内容为 `n` 时表示有 `n` 个事件待读取 |
+| 读事件内容 | `0x04` | 输入寄存器 | `0x0100` | `5 * n` | 当 `0x00E5 != 0` 时读取 |
-#### 线路 #1
-- **寄存器地址范围**:0x0000 ~ 0x0032
+### 2.2 读/写参数区
+
+| 功能 | 功能码 | 寄存器类型 | 起始地址 | 寄存器长度 | 说明 |
+| ---- | ------ | ---------- | -------- | ---------- | ---- |
+| 时间设置 | `0x03 / 0x10` | 保持寄存器 | `0x0000` | 2 | 时间戳,32bit |
+| 串口设置 | `0x03 / 0x10` | 保持寄存器 | `0x0004` | 16 | 4路 × 8字节 |
+| 线路定值设置 | `0x03 / 0x10` | 保持寄存器 | `0x0014` | 64 | 4路 × (7类定值 + 2个变比参数) |
+| AI通道设置 | `0x03 / 0x10` | 保持寄存器 | `0x0054` | 30 | 12路整体下发 |
+| AO通道设置 | `0x03 / 0x10` | 保持寄存器 | `0x0072` | 30 | 12路整体下发 |
+| AI报警设置 | `0x03 / 0x10` | 保持寄存器 | `0x0090` | 30 | 12路整体下发 |
+| 读事件成功回复 | `0x03` | 保持寄存器 | `0x0100` | 1 | 内容为本次成功读取的事件个数 `n` |
+
+### 2.3 线圈区
+
+| 功能 | 功能码 | 寄存器类型 | 起始地址 | 长度 | 说明 |
+| ---- | ------ | ---------- | -------- | ---- | ---- |
+| 写开出(批量) | `0x0F` | 线圈 | `0x0000` | 12 | 写多个线圈,控制12路开出 |
+
+
+
+## 三、实时数据寄存器表
+
+### 3.1 线路1实时量
| 寄存器地址 | 参数名 | 寄存器地址 | 参数名 | 寄存器地址 | 参数名 |
| ---------- | ------ | ---------- | ------ | ---------- | ------ |
-| 0x0000 | Ua | 0x0012 | Pt | 0x0024 | PFa |
-| 0x0002 | Ub | 0x0014 | Qa | 0x0026 | PFb |
-| 0x0004 | Uc | 0x0016 | Qb | 0x0028 | PFc |
-| 0x0006 | Ia | 0x0018 | Qc | 0x002A | PFt |
-| 0x0008 | Ib | 0x001A | Qt | 0x002C | Uab |
-| 0x000A | Ic | 0x001C | Sa | 0x002E | Ubc |
-| 0x000C | Pa | 0x001E | Sb | 0x0030 | Uca |
-| 0x000E | Pb | 0x0020 | Sc | 0x0032 | Frq |
-| 0x0010 | Pc | 0x0022 | St | - | - |
+| `0x0000` | Ua | `0x0012` | Pt | `0x0024` | PFa |
+| `0x0002` | Ub | `0x0014` | Qa | `0x0026` | PFb |
+| `0x0004` | Uc | `0x0016` | Qb | `0x0028` | PFc |
+| `0x0006` | Ia | `0x0018` | Qc | `0x002A` | PFt |
+| `0x0008` | Ib | `0x001A` | Qt | `0x002C` | Uab |
+| `0x000A` | Ic | `0x001C` | Sa | `0x002E` | Ubc |
+| `0x000C` | Pa | `0x001E` | Sb | `0x0030` | Uca |
+| `0x000E` | Pb | `0x0020` | Sc | `0x0032` | Frq |
+| `0x0010` | Pc | `0x0022` | St | - | - |
-#### 线路 #2
-- **起始地址**:0x0034
-- **结束地址**:0x0066
-- **参数顺序**:同线路 #1
+### 3.2 线路2~线路4地址范围
-#### 线路 #3
-- **起始地址**:0x0068
-- **结束地址**:0x009A
-- **参数顺序**:同线路 #1
+| 线路 | 地址范围 | 说明 |
+| ---- | -------- | ---- |
+| 线路2 | `0x0034 ~ 0x0066` | 参数顺序同线路1 |
+| 线路3 | `0x0068 ~ 0x009A` | 参数顺序同线路1 |
+| 线路4 | `0x009C ~ 0x00CE` | 参数顺序同线路1 |
-#### 线路 #4
-- **起始地址**:0x009C
-- **结束地址**:0x00CE
-- **参数顺序**:同线路 #1
-
----
-
-### 2. 模拟量输入(AI)
-
-- **寄存器类型**:输入寄存器
-- **功能码**:0x04
-- **数据格式**:单精度浮点数(32位)
+### 3.3 AI输入寄存器表
| 寄存器地址 | 输入编号 |
| ---------- | -------- |
-| 0x00D0 | Ai1 |
-| 0x00D2 | Ai2 |
-| 0x00D4 | Ai3 |
-| 0x00D6 | Ai4 |
-| 0x00D8 | Ai5 |
-| 0x00DA | Ai6 |
-| 0x00DC | Ai7 |
-| 0x00DE | Ai8 |
+| `0x00D0` | Ai1 |
+| `0x00D2` | Ai2 |
+| `0x00D4` | Ai3 |
+| `0x00D6` | Ai4 |
+| `0x00D8` | Ai5 |
+| `0x00DA` | Ai6 |
+| `0x00DC` | Ai7 |
+| `0x00DE` | Ai8 |
+
+### 3.4 B码对时寄存器表
+
+| 寄存器地址 | 位分配 | 内容 |
+| ---------- | ------ | ---- |
+| `0x00E0` | `[15:8] = 秒`,`[7:0] = 分` | 秒/分 |
+| `0x00E1` | `[15:8] = 时`,`[7:0] = 日` | 时/日 |
+| `0x00E2` | `[15:8] = 月`,`[7:0] = 年` | 月/年 |
+
+### 3.5 状态寄存器表
+
+| 寄存器地址 | 含义 | 说明 |
+| ---------- | ---- | ---- |
+| `0x00E3` | 开入状态 | 16路输入,每 bit 一路 |
+| `0x00E4` | 开出状态 | 16路输出,每 bit 一路 |
+| `0x00E5` | 事件总寄存器 | 值为 `n` 表示有 `n` 个事件待读取 |
---
-### 3. B码对时
+## 四、事件寄存器定义
-- **寄存器类型**:输入寄存器
-- **功能码**:0x04
-- **数据格式**:无符号16位整型
+### 4.1 读事件内容
-| 寄存器地址 | 位分配 | 内容 |
-| ---------- | ------------------------- | ----- |
-| 0x00E0 | [15:8] = 秒
[7:0] = 分 | 秒/分 |
-| 0x00E1 | [15:8] = 时
[7:0] = 日 | 时/日 |
-| 0x00E2 | [15:8] = 月
[7:0] = 年 | 月/年 |
+| 项目 | 定义 |
+| ---- | ---- |
+| 触发条件 | 事件总寄存器 `0x00E5 != 0` |
+| 功能码 | `0x04` |
+| 寄存器类型 | 输入寄存器 |
+| 起始地址 | `0x0100` |
+| 读取长度 | `5 * n` |
+
+| 数据名 | 数据寄存器 | 格式 | 说明 |
+| ------ | ---------- | ---- | ---- |
+| 时间戳 | `0x0100 ~ 0x0101` | 32位无符号 | 事件发生时间戳 |
+| 毫秒计时 | `0x0102` | 16位无符号 | 事件发生毫秒值 |
+| 线路号 + 事件类型 | `0x0103` | 16位无符号 | 高4位为线路号,低4位为事件类型 |
+| 事件代码 | `0x0103` 低8位扩展语义 | 8位无符号 | 按事件类型解释具体代码 |
+| 动作值 | `0x0104` | 16位无符号 | 监测值或动作状态值 |
+
+### 4.2 字段取值说明
+
+#### 线路号
+
+| 值 | 含义 |
+| -- | ---- |
+| `0` | 装置 |
+| `1` | 线路1 |
+| `2` | 线路2 |
+| `3` | 线路3 |
+| `4` | 线路4 |
+
+#### 事件类型
+
+| 值 | 含义 |
+| -- | ---- |
+| `1` | 保护类 |
+| `2` | 动作类 |
+| `3` | 报警类 |
+| `4` | 操作类 |
+| `5` | AI越限类 |
+
+#### 事件代码
+
+| 类别 | 代码定义 |
+| ---- | -------- |
+| 保护类 | `1 = PT断线`,`2 = CT断线`,其余依次编号 |
+| 报警类 | `1 = 过压`,`2 = 过流`,`3 = 频率越限` 等 |
+| 动作类 | 开入序号,表示第 `n` 路发生状态变化 |
+| AI越限类 | AI通道号,表示第 `n` 通道发生越限 |
+| 操作类 | 由主站界面操作直接生成记录 |
+
+#### 动作值
+
+| 类别 | 含义 |
+| ---- | ---- |
+| 保护类 / 报警类 | 越限时的监测值 |
+| 动作类 | 开入/开出状态,`0 -> 分`,非 `0 -> 合` |
+
+### 4.3 读事件成功回复
+
+| 项目 | 定义 |
+| ---- | ---- |
+| 功能码 | `0x03` |
+| 寄存器类型 | 保持寄存器 |
+| 起始地址 | `0x0100` |
+| 数据长度 | 1 |
+| 内容 | 本次成功读取的事件个数 `n` |
---
-### 4. 数字量输入(开入)
+## 五、参数区寄存器说明
-- **寄存器类型**:输入寄存器
-- **功能码**:0x04
-- **寄存器地址**:0x00E3
-- **说明**:共16路,每个bit位表示一路开入量
+### 5.1 参数页读写规则
+
+- 参数页在开机及切换到参数类页面时,应先发送 `03H` 读取参数。
+- 参数发生修改后,应发送 `10H` 下发更新。
+- 主站应在参数修改成功后生成对应“参数修改”事件,并实时显示、存盘。
+- 对时功能除可在系统设置页统一下发外,也可通过上位机独立“对时设置”接口单独触发,底层仍写时间设置寄存器 `0x0000`。
+
+### 5.2 参数区表格
+
+| 参数类别 | 功能码 | 寄存器类型 | 起始地址 | 长度 | 说明 |
+| -------- | ------ | ---------- | -------- | ---- | ---- |
+| 时间设置 | `0x03 / 0x10` | 保持寄存器 | `0x0000` | 2 | 时间戳,32bit |
+| 串口设置 | `0x03 / 0x10` | 保持寄存器 | `0x0004` | 16 | 4路 × 8字节 |
+| 线路定值设置 | `0x03 / 0x10` | 保持寄存器 | `0x0014` | 64 | 含7类定值与2个变比参数 |
+| AI通道设置 | `0x03 / 0x10` | 保持寄存器 | `0x0054` | 30 | 12路整体下发 |
+| AO通道设置 | `0x03 / 0x10` | 保持寄存器 | `0x0072` | 30 | 12路整体下发 |
+| AI报警设置 | `0x03 / 0x10` | 保持寄存器 | `0x0090` | 30 | 12路整体下发 |
+| 开出状态字写入 | `0x10` | 保持寄存器 | `0x00A6` | 1 | 16位状态字,每 bit 一路开出 |
+
+### 5.2.1 时间设置 / 独立对时说明
+
+| 项目 | 定义 |
+| ---- | ---- |
+| 寄存器类型 | 保持寄存器 |
+| 功能码 | `0x03 / 0x10` |
+| 起始地址 | `0x0000` |
+| 寄存器长度 | 2 |
+| 数据格式 | 32 位时间戳,低 16 位在前,高 16 位在后 |
+
+说明:
+
+- 主站可在系统设置页统一读取/下发该寄存器。
+- 主站也可通过独立“对时设置”功能,单独向该寄存器写入目标时间。
+- 上位机后端可额外开放独立对时接口,例如:
+ - `GET /api/config/time-sync`
+ - `POST /api/config/time-sync`
+- 以上接口属于上位机软件封装,底层协议仍然只对应本节 `0x0000` 时间设置寄存器。
+
+### 5.3 通讯设置
+
+#### 5.3.1 基本定义
+
+| 项目 | 定义 |
+| ---- | ---- |
+| 寄存器类型 | 保持寄存器 |
+| 功能码 | `0x03 / 0x10` |
+| 起始地址 | `0x0004` |
+| 寄存器长度 | `16` |
+| 长度说明 | `4(路) × 4(8byte数据/2) = 16` |
+
+说明:
+
+- 对应界面中的“串口设置”区域。
+- 共支持 `4` 路串口参数读写。
+- 每一路串口参数占用 `8` 字节,即 `4` 个保持寄存器。
+- 主站进入参数页面时应先使用 `03H` 回读当前串口参数,界面修改后再使用 `10H` 整组下发。
+
+#### 5.3.2 单路串口参数组成
+
+单路串口参数结构对应 `com_param_t`,总长度 `8` 字节:
+
+| 字段 | 类型 | 字节数 | 说明 |
+| ---- | ---- | ------ | ---- |
+| `baud_rate` | `uint32_t` | 4 | 波特率 |
+| `parity` | `uint8_t` | 1 | 校验位 |
+| `data` | `uint8_t` | 1 | 数据位 |
+| `stop` | `uint8_t` | 1 | 停止位 |
+| `protocol` | `uint8_t` | 1 | 通讯协议 |
+
+#### 5.3.3 寄存器分配
+
+| 串口 | 寄存器范围 | 长度 | 说明 |
+| ---- | ---------- | ---- | ---- |
+| 串口1 | `0x0004 ~ 0x0007` | 4 | 对应界面中的一组串口参数 |
+| 串口2 | `0x0008 ~ 0x000B` | 4 | 对应界面中的一组串口参数 |
+| 串口3 | `0x000C ~ 0x000F` | 4 | 预留/扩展串口参数 |
+| 串口4 | `0x0010 ~ 0x0013` | 4 | 预留/扩展串口参数 |
+
+#### 5.3.4 界面字段对应关系
+
+对应界面中的典型字段如下:
+
+| 界面字段 | 协议字段 |
+| -------- | -------- |
+| 串口号 | 由寄存器块位置区分,不单独占用字段 |
+| 波特率 | `baud_rate` |
+| 校验位 | `parity` |
+| 数据位 | `data` |
+| 停止位 | `stop` |
+| 通讯协议 | `protocol` |
+
+### 5.4 常规配置 / 网卡设置
+
+#### 5.4.1 协议定义说明
+
+| 项目 | 定义 |
+| ---- | ---- |
+| 配置项 | 网卡号、IP地址、子网掩码、默认网关、通讯协议 |
+| 当前协议状态 | 本版 Modbus 协议**未定义对应寄存器** |
+| Modbus 读写支持 | 不支持 |
+| 处理方式 | 由上位机或系统本地配置逻辑单独处理,不纳入当前 Modbus 参数区 |
+
+说明:
+
+- 对应界面中的“常规配置 / 网卡设置”区域。
+- 当前文档已定义“串口设置”的 Modbus 参数区,但**未定义网卡设置的寄存器地址、长度和编码方式**。
+- 因此本版协议下,网卡参数**不能**按 `03H / 10H` 通过 Modbus 回读或下发。
+- 若后续需要将网卡参数纳入 Modbus 管理,建议在协议后续版本中补充:
+ - 寄存器类型
+ - 功能码
+ - 起始地址
+ - 寄存器长度
+ - IP / 掩码 / 网关 / 协议字段编码规则
+
+#### 5.4.2 界面字段范围
+
+对应界面中的典型字段如下:
+
+| 界面字段 | 当前协议状态 |
+| -------- | ------------ |
+| 网卡号 | 未定义寄存器 |
+| IP地址 | 未定义寄存器 |
+| 子网掩码 | 未定义寄存器 |
+| 默认网关 | 未定义寄存器 |
+| 通讯协议 | 未定义寄存器 |
---
-### 5. 事件总标记
+## 六、开出控制说明
-- **寄存器类型**:输入寄存器
-- **功能码**:0x04
-- **寄存器地址**:0x00E4
-- **说明**:当该位被置位时,表示有新事件,需要读取事件内容
+### 6.1 写保持寄存器方式
+
+| 项目 | 定义 |
+| ---- | ---- |
+| 功能码 | `0x10` |
+| 寄存器类型 | 保持寄存器 |
+| 起始地址 | `0x00A6` |
+| 长度 | 1 |
+| 说明 | 16位状态字,每 bit 表示一路开出 |
+
+### 6.2 写线圈方式
+
+| 项目 | 定义 |
+| ---- | ---- |
+| 功能码 | `0x0F` |
+| 寄存器类型 | 线圈 |
+| 起始地址 | `0x0000` |
+| 长度 | 12 |
+| 说明 | 写多个线圈,控制12路开出 |
---
-## 三、读事件(功能码:03)
+## 七、通讯时序说明
-- **触发条件**:事件总标记(0x00E4)被置位
-- **寄存器类型**:保持寄存器
-- **功能码**:0x03
-- **寄存器地址范围**:0x0000 ~ 0x0003
+### 7.1 开机初始化时序
-| 寄存器地址 | 数据名 | 格式 | 说明 |
-| ---------- | ---------------- | ---------- | ---------------------------- |
-| 0x0000 | 时间戳(低16位) | 32位无符号 | 占用0x0000~0x0001 |
-| 0x0001 | 时间戳(高16位) | | |
-| 0x0002 | 毫秒计时 | 16位无符号 | |
-| 0x0003 | 线路号+事件类型 | 16位无符号 | 高字节线路号,低字节事件类型 |
+1. 主站开机后,先通过 `03H` 读取参数区。
+2. 若参数读取失败,主站生成报警事件,可归类为“开机自检(通讯链路)”异常。
+3. 开机自检结束后,进入周期轮询阶段。
----
+### 7.2 周期轮询时序
-## 四、读输入数字量(开入)(功能码:02)
+1. 主站周期发送 `04H`,读取实时输入寄存器。
+2. 收到从站 `04H` 应答后,更新实时数据、状态数据。
+3. 随后读取事件总寄存器 `0x00E5`。
+4. 若 `0x00E5 = 0`,继续下一轮周期轮询。
+5. 若 `0x00E5 = n` 且 `n > 0`,立即进入读事件流程。
-- **寄存器类型**:离散输入
-- **功能码**:0x02
+补充说明:
-| 寄存器地址 | 说明 |
-| ---------- | --------------------- |
-| 0x0000 | 16路开入量,每bit一路 |
-| 0x0001 | 保留(16路) |
+- 若上位机需要在页面上动态显示设备当前时间,可额外按固定周期读取时间设置寄存器 `0x0000`。
+- 推荐上位机软件每 `1` 秒轮询一次,并通过独立时间推送通道发送给前端页面。
+- 推荐的软件推送通道示例:`/ws/time-sync`
----
+### 7.3 读事件时序
-## 五、写数据(功能码:10,写多个保持寄存器)
+1. 主站发送“读事件”指令:
+ - 功能码:`04H`
+ - 输入寄存器地址:`0x0100`
+ - 读取长度:`5 * n`
+2. 主站收到正确应答后,解析本批事件内容。
+3. 主站发送“读事件成功回复”:
+ - 功能码:`03H`
+ - 保持寄存器地址:`0x0100`
+ - 长度:`1`
+ - 内容:本次成功读取的事件个数 `n`
+4. 从站收到回复后,应将事件总寄存器 `0x00E5` 中的事件个数扣减 `n`。
+5. 若扣减后 `0x00E5` 仍非 0,主站应继续重复读事件流程,直到 `0x00E5 = 0`。
-### 1. 时间设置
+### 7.4 事件生成与保存
-- **寄存器类型**:保持寄存器
-- **功能码**:0x10
-- **起始地址**:0x0000
-- **寄存器长度**:2
-- **说明**:写入时间戳(32位)
+- 从站负责生成并暂存:
+ - 报警类事件
+ - 保护类事件
+ - 动作类事件
+- 主站负责生成:
+ - 操作类事件
----
+主站侧操作类事件至少包括:
-### 2. 串口设置
+| 代码 | 含义 |
+| ---- | ---- |
+| `1` | 控制分/合 |
+| `2` | 读定值失败 |
+| `3` | 线路定值修改 |
+| `4` | AI报警参数修改 |
+| `5` | AI通道参数修改 |
+| `6` | AO通道参数修改 |
+| `7` | 通讯参数修改 |
+| `8` | 密码修改 |
-- **寄存器类型**:保持寄存器
-- **功能码**:0x10
-- **起始地址**:0x0004
-- **寄存器长度**:16
-- **说明**:4路 × 4(8字节数据/2) = 16个寄存器
+统一要求:
----
+- 从站生成的事件与主站生成的事件,均保存在主站端。
+- 主站应形成历史事件记录文件。
+- 历史事件应支持按分类、时间段、线路号等条件检索。
-### 3. 线路定值设置
+### 7.5 时间推送建议
-- **寄存器类型**:保持寄存器
-- **功能码**:0x10
-- **起始地址**:0x0014
-- **寄存器长度**:56
-- **说明**:4路 × 7类别 × 2(4字节数据/2) = 56个寄存器
+为便于上位机页面显示设备当前时间,建议增加独立时间推送链路:
----
+1. 后台服务每 `1` 秒读取一次时间设置寄存器 `0x0000`。
+2. 将回读结果转换为标准时间字符串。
+3. 通过独立 WebSocket 通道推送给页面。
-### 4. AI通道设置
+推荐推送报文格式:
-- **寄存器类型**:保持寄存器
-- **功能码**:0x10
-- **起始地址**:0x004C
-- **寄存器长度**:30
-- **说明**:12路 × 5字节数据 / 2 = 30个寄存器(整体下发)
+```json
+{
+ "type": "time_sync",
+ "data": {
+ "time_sync": "2026-05-25 14:30:01"
+ }
+}
+```
----
+说明:
-### 5. AO通道设置
-
-- **寄存器类型**:保持寄存器
-- **功能码**:0x10
-- **起始地址**:0x006A
-- **寄存器长度**:30
-- **说明**:12路 × 5字节数据 / 2 = 30个寄存器(整体下发)
-
----
-
-### 6. AI报警设置
-
-- **寄存器类型**:保持寄存器
-- **功能码**:0x10
-- **起始地址**:0x0088
-- **寄存器长度**:30
-- **说明**:12路 × 5字节数据 / 2 = 30个寄存器(整体下发)
-
----
-
-### 7. 开出(写继电器输出)
-
-- **寄存器类型**:保持寄存器
-- **功能码**:0x10
-- **起始地址**:0x00A6
-- **寄存器长度**:1
-- **数据格式**:16位,每一位表示一路开出量
-
----
-
-## 六、写开出(线圈)(功能码:05)
-
-- **寄存器类型**:线圈
-- **功能码**:0x05
-- **起始地址**:0x0000
-- **寄存器长度**:12
-- **说明**:单路线圈写操作(12路分别操作)
\ No newline at end of file
+- 该推送属于上位机软件实现建议,不新增下位机寄存器。
+- 底层时间真值仍以 `0x0000` 时间设置寄存器为准。
diff --git a/document/显示界面modbus协议寄存器定义v1.1.doc b/document/显示界面modbus协议寄存器定义v1.1.doc
new file mode 100644
index 0000000..f3bd36a
Binary files /dev/null and b/document/显示界面modbus协议寄存器定义v1.1.doc differ
diff --git a/document/显示界面modbus协议寄存器定义v1.1.extracted.txt b/document/显示界面modbus协议寄存器定义v1.1.extracted.txt
new file mode 100644
index 0000000..1074ddf
--- /dev/null
+++ b/document/显示界面modbus协议寄存器定义v1.1.extracted.txt
@@ -0,0 +1,160 @@
+һʹЭ
+1 Modbus RTUЭ
+2 ӻַ1
+3 ã
+(1) ʣ115200
+(2) λ8
+(3) Уλ
+(4) ֹͣλ1
+
+1ʵʱʾݣ
+(1) ĴͣĴ
+(2) 룺04
+(3) ݸʽȸ32bit
+(4) ·#1:Ĵַ 0x0000 ~ 0x00321
+Ĵַ
Ĵ
Ĵַ
Ĵ
Ĵַ
Ĵ
0x0000
Ua
0x0012
Pt
0x0024
PFa
0x0002
Ub
0x0014
Qa
0x0026
PFb
0x0004
Uc
0x0016
Qb
0x0028
PFc
0x0006
Ia
0x0018
Qc
0x002a
PFt
0x0008
Ib
0x001a
Qt
0x002c
Uab
0x000a
Ic
0x001c
Sa
0x002e
Ubc
0x000c
Pa
0x001e
Sb
0x0030
Uca
0x000e
Pb
0x0020
Sc
0x0032
Frq
0x0010
Pc
0x0022
St
1
+
+
+
+
+
+
+
+
+
+
+
+(5) ·#2:Ĵַ 0x0034 ~ 0x0066 Ĵͬ1
+(6) ·#3:Ĵַ 0x0068 ~ 0x009a Ĵͬ1
+(7) ·#4:Ĵַ 0x009c ~ 0x00ce Ĵͬ1
+
+2ģ루AI
+(1) ĴͣĴ
+(2) 룺04
+(3) ݸʽȸ32bit
+(4) Ĵַ 0x00d0 ~ 0x00de
+
+Ĵַ
0x00d0
0x00d2
0x00d4
0x00d6
0x00d8
0x00da
0x00dc
0x00de
Ai1
Ai2
Ai3
Ai4
Ai5
Ai6
Ai7
Ai8
+
+
+
+3 Bʱ
+(1) ĴͣĴ
+(2) 룺04
+(3) ݸʽŶͣ16bit
+(4) Ĵַ 0x00e0 ~ 0x00e2
+
+Ĵַ
0x00e0
0x00e1
0x00e2
ַ
[15:8] [7:0]
[15:8]ʱ [7:0]
[15:8] [7:0]
+4 룩
+(1) ĴͣĴ
+(2) 룺04
+(3) Ĵַ 0x00e3 16·
+(4) ÿһbitλʾһ·
+
+5 ״̬
+(1) ĴͣĴ
+(2) 룺04
+(3) ʼĴַ0x00e416·
+(4) 1ÿһλʾһ·ǰ״̬
+
+6 ¼ܼĴ 0ʱ¼
+(1) ĴͣĴ
+(2) 룺04
+(3) Ĵַ 0x00e5
+
+ ¼
+ѯ¼ܼĴ0ʱ¼Ĵȡ¼ݡ
+(1) ĴͣĴ
+(2) 룺04
+(3) Ĵַ 0x0100
+(4) ȡ 5 *n 5 = Ĵn = ¼
+
ݼĴ
ʱ 32bit
0x0100
ʱ16bit
0x0102
·+¼ͣ8bit
0x0103
4λ
+·(0 = װã1 = ·12 =·2 3 = ·3 4 =· 4)
+4λ
+¼(1 = ࡢ2 = ࡢ3 = ࡢ4 = ࡢ5=AIԽ)
+
¼루8bit
ࣺ1 = pt ߣ 2 = CTߣ¼α
+ ࣺ1 = ѹ2 = 3 =ƵԽޣ4 =
+ ࣺţn·״̬仯
+AIԽࣺAIͨţnͨԽޣ
+ֱࣺɼ¼ʾ̼
ֵ16bit
0x0104
ࡢࣺԽʱļֵ
+ࣺ/״̬0֣ϣ
+
+
+
+
+
+
+¼ɹظվζȡ¼д뵽ĴУվյñıʾվȡ¼ɹ
+(1) ĴּͣĴ
+(2) 룺03
+(3) Ĵַ 0x0100
+(4) ȣ1
+(5) ݣ¼
+1000
+ġ 룩
+(1) Ĵͣɢ
+(2) 룺02
+(3) Ĵַ 0x0000 16·
+(4) Ĵַ0x000116·
+塢 ȡ
+д룺10H
+룺03H
+ʾڿлʱͨ03¶ȡ
+1 ʱãʱ
+(1) ĴּͣĴ
+(2) 룺03/10
+(3) Ĵַ0x0000
+(4) Ĵȣ2
+
+2 ͨѶ
+(1) ĴּͣĴ
+(2) 룺03/10
+(3) ʼַ0x0004
+(4) Ĵȣ4(·) * 4(8byte/2) = 16
+
+3 ·ֵ
+(1) ĴּͣĴ
+(2) 룺03/10
+(3) ʼĴַ0x0014
+(4) Ĵȣ4(·)*(7() *2(4byte/2) + 2()) = 64
+
+4 AIͨ
+(1) ĴּͣĴ
+(2) 룺03/10
+(3) ʼĴַ0x0054
+(4) Ĵȣ(12(·)*5byte)/2 = 30 Ϊ12··õIJض⣩
+
+5 AOͨ
+(1) ĴּͣĴ
+(2) 룺03/10
+(3) ʼĴַ0x0072
+(4) Ĵȣ(12(·)*5byte)/2 = 30 Ϊ12··õIJض⣩
+
+6 AI
+(1) ĴּͣĴ
+(2) 룺03/10
+(3) ʼĴַ0x0090
+(4) Ĵȣ(12(·)*5byte)/2 = 30 Ϊ12··õIJض⣩
+
+ дȦ
+(1) ĴͣȦ
+(2) 룺0FдȦ
+(3) ʼĴַ0x0000
+(4) Ĵȣ12
+
+ߡ ͨѶ̲˵
+(1) վȡ03룬ȡʧɱ¼ù̿ɹΪԼ죨ͨѶ·
+(2) ԼʼѯĴ04룩յվ04Ӧ֡վݣѯ¼ܼĴĴ0x00e5üĴ0վ¼ʱվӦ"¼"ָȷȡ¼ɹظٴη04Ĵѯ¼ܼĴĴ0x00e5üĴԷ0ٴη"¼"ֱָüĴΪ0
+¼ܼĴĴ0x00e5Ϊ0ʱѯĴ04룩
+(3) ¼ܼĴĴַ 0x00e5üĴΪnʱ0ʾվn¼վӦ"¼"ָ04Ĵַ0x0100ȡ 5 *n
+վȷյ"¼"ָӦ֡¼ɹظ03ּĴַ0x0100ݳ1n
+վյվ¼ɹظ֡¼ܼĴĴ0x00e5¼-n
+(4) ¼뱣
+* ¼ͣ1 = ࡢ2 = ࡢ3 = ࡢ4 =
+* վɣ""""""¼ݴ棻
+* վɣ""¼¼루1=Ʒ/ϣֵ0ֵ֡0ϣ2=ֵʧܡ3=·ֵġ4=AIġ5=AIͨġ6=AOͨġ7=ͨѶġ8=ģ
+* ¼棺վɵ¼վɵ¼ͳһվˣγʷ¼¼ļɷࡢʱΡ·ŵȼ鿴
+(5) ʾҳлҳʱӦ"03H"ʱ"д10H"վ"xx"¼ʵʱʾγʷ¼
+
+
+
diff --git a/document/显示界面modbus协议寄存器定义v1.1.filtered.html b/document/显示界面modbus协议寄存器定义v1.1.filtered.html
new file mode 100644
index 0000000..b779fb1
--- /dev/null
+++ b/document/显示界面modbus协议寄存器定义v1.1.filtered.html
@@ -0,0 +1,1354 @@
+
+
+
һʹЭ
+ +1Modbus RTUЭ
+ +2ӻַ1
+ +3ã
+ +(1) +ʣ115200
+ +(2) +λ8
+ +(3) +Уλ
+ +(4) +ֹͣλ1
+ ++ +
1ʵʱʾݣ
+ +(1) +ĴͣĴ
+ +(2) +룺04
+ +(3) +ݸʽȸ32bit
+ +(4) +·#1:Ĵַ 0x0000 ~ +0x00321
+ +|
+ Ĵַ + |
+
+ Ĵ + |
+
+ Ĵַ + |
+
+ Ĵ + |
+
+ Ĵַ + |
+
+ Ĵ + |
+
|
+ 0x0000 + |
+
+ Ua + |
+
+ 0x0012 + |
+
+ Pt + |
+
+ 0x0024 + |
+
+ PFa + |
+
|
+ 0x0002 + |
+
+ Ub + |
+
+ 0x0014 + |
+
+ Qa + |
+
+ 0x0026 + |
+
+ PFb + |
+
|
+ 0x0004 + |
+
+ Uc + |
+
+ 0x0016 + |
+
+ Qb + |
+
+ 0x0028 + |
+
+ PFc + |
+
|
+ 0x0006 + |
+
+ Ia + |
+
+ 0x0018 + |
+
+ Qc + |
+
+ 0x002a + |
+
+ PFt + |
+
|
+ 0x0008 + |
+
+ Ib + |
+
+ 0x001a + |
+
+ Qt + |
+
+ 0x002c + |
+
+ Uab + |
+
|
+ 0x000a + |
+
+ Ic + |
+
+ 0x001c + |
+
+ Sa + |
+
+ 0x002e + |
+
+ Ubc + |
+
|
+ 0x000c + |
+
+ Pa + |
+
+ 0x001e + |
+
+ Sb + |
+
+ 0x0030 + |
+
+ Uca + |
+
|
+ 0x000e + |
+
+ Pb + |
+
+ 0x0020 + |
+
+ Sc + |
+
+ 0x0032 + |
+
+ Frq + |
+
|
+ 0x0010 + |
+
+ Pc + |
+
+ 0x0022 + |
+
+ St + |
+
+ + |
+
+ + |
+
1
+ ++ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
(5) + ·#2:Ĵַ 0x0034 ~ 0x0066 Ĵͬ1
+ +(6) + ·#3:Ĵַ 0x0068 ~ 0x009a Ĵͬ1
+ +(7) + ·#4:Ĵַ 0x009c ~ 0x00ce Ĵͬ1
+ ++ +
2ģ루AI
+ +(1) +ĴͣĴ
+ +(2) +룺04
+ +(3) +ݸʽȸ32bit
+ +(4) +Ĵַ 0x00d0 ~ 0x00de
+ ++ +
|
+ Ĵַ + |
+
+ 0x00d0 + |
+
+ 0x00d2 + |
+
+ 0x00d4 + |
+
+ 0x00d6 + |
+
+ 0x00d8 + |
+
+ 0x00da + |
+
+ 0x00dc + |
+
+ 0x00de + |
+
|
+ + |
+
+ Ai1 + |
+
+ Ai2 + |
+
+ Ai3 + |
+
+ Ai4 + |
+
+ Ai5 + |
+
+ Ai6 + |
+
+ Ai7 + |
+
+ Ai8 + |
+
+ +
+ +
+ +
+ +
3Bʱ
+ +(1) +ĴͣĴ
+ +(2) +룺04
+ +(3) +ݸʽŶͣ16bit
+ +(4) +Ĵַ 0x00e0 ~ 0x00e2
+ ++ +
|
+ Ĵַ + |
+
+ 0x00e0 + |
+
+ 0x00e1 + |
+
+ 0x00e2 + |
+
|
+ ַ + |
+
+ [15:8] [7:0] + |
+
+ [15:8]ʱ [7:0] + |
+
+ [15:8] [7:0] + |
+
+ +
4 룩
+ + + +(2) +룺04
+ +(3) +Ĵַ 0x00e3 16·
+ +(4) +ÿһbitλʾһ·
+ ++ +
5״̬
+ +(1) +ĴͣĴ
+ +(2) +룺04
+ +(3) +ʼĴַ0x00e416·
+ +(4) +1ÿһλʾһ·ǰ״̬
+ ++ +
6¼ܼĴ 0ʱ¼
+ +(1) +ĴͣĴ
+ +(2) +룺04
+ +(3) +Ĵַ 0x00e5
+ ++ +
¼
+ +ѯ¼ܼĴ0ʱ¼Ĵȡ¼ݡ
+ +(1) +ĴͣĴ
+ +(2) +룺04
+ +(3) +Ĵַ 0x0100
+ +(4) +ȡ 5 *n 5 = Ĵn = ¼
+ +|
+ + |
+
+ ݼĴ + |
+
+ + |
+
|
+ ʱ 32bit + |
+
+ 0x0100 + |
+
+ + |
+
|
+ ʱ16bit + |
+
+ 0x0102 + |
+
+ + |
+
|
+ ·+¼ͣ8bit + |
+
+ 0x0103 + |
+
+ 4λ +·(0 = װã1 = ·12 =·2 3 = ·3 4 =· 4) +4λ +¼(1 = ࡢ2 = ࡢ3 = ࡢ4 = ࡢ5=AIԽ) ++ |
+
|
+ ¼루8bit + |
+
+ ࣺ1 = pt ߣ 2 = CTߣ¼α +ࣺ1 = ѹ2 = 3 =ƵԽޣ4 = +ࣺţn·״̬仯 +AIԽࣺAIͨţnͨԽޣ +ֱࣺɼ¼ʾ̼ + |
+ |
|
+ ֵ16bit + |
+
+ 0x0104 + |
+
+ ࡢࣺԽʱļֵ +ࣺ/״̬0֣ϣ + |
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
¼ɹظվζȡ¼д뵽ĴУվյñıʾվȡ¼ɹ
+ +(1) +ĴּͣĴ
+ +(2) +룺03
+ +(3) +Ĵַ 0x0100
+ +(4) +ȣ1
+ +(5) +ݣ¼
+ +1000
+ +ġ 룩
+ +(1) +Ĵͣɢ
+ +(2) +룺02
+ +(3) +Ĵַ 0x0000 16·
+ +(4) +Ĵַ0x000116·
+ +塢ȡ
+ +д룺10H
+ +룺03H
+ +ʾڿлʱͨ03¶ȡ
+ +1ʱãʱ
+ +(1) +ĴּͣĴ
+ +(2) +룺03/10
+ +(3) +Ĵַ0x0000
+ +(4) +Ĵȣ2
+ ++ +
2ͨѶ
+ +(1) +ĴּͣĴ
+ +(2) +룺03/10
+ +(3) +ʼַ0x0004
+ +(4) +Ĵȣ4(·) * 4(8byte/2) = 16
+ ++ +
3·ֵ
+ +(1) +ĴּͣĴ
+ +(2) +룺03/10
+ +(3) +ʼĴַ0x0014
+ +(4) +Ĵȣ4(·)*(7() *2(4byte/2) + 2()) = 64
+ ++ +
4AIͨ
+ +(1) +ĴּͣĴ
+ +(2) +룺03/10
+ +(3) +ʼĴַ0x0054
+ +(4) +Ĵȣ(12(·)*5byte)/2 = 30 Ϊ12··õIJض⣩
+ ++ +
5AOͨ
+ +(1) +ĴּͣĴ
+ +(2) +룺03/10
+ +(3) +ʼĴַ0x0072
+ +(4) +Ĵȣ(12(·)*5byte)/2 = 30 Ϊ12··õIJض⣩
+ ++ +
6AI
+ +(1) +ĴּͣĴ
+ +(2) +룺03/10
+ +(3) +ʼĴַ0x0090
+ +(4) +Ĵȣ(12(·)*5byte)/2 = 30 Ϊ12··õIJض⣩
+ ++ +
дȦ
+ +(1) +ĴͣȦ
+ +(2) +룺0FдȦ
+ +(3) +ʼĴַ0x0000
+ +(4) +Ĵȣ12
+ ++ +
ߡͨѶ̲˵
+ +(1) +վȡ03룬ȡʧɱ¼ù̿ɹΪԼ죨ͨѶ·
+ +(2) +ԼʼѯĴ04룩յվ04Ӧ֡վݣѯ¼ܼĴĴ0x00e5üĴ0վ¼ʱվӦ¼ָȷȡ¼ɹظٴη04Ĵѯ¼ܼĴĴ0x00e5üĴԷ0ٴη¼ֱָüĴΪ0
+ +¼ܼĴĴ0x00e5Ϊ0ʱѯĴ04룩
+ +(3) +¼ܼĴĴַ 0x00e5üĴΪnʱ0ʾվn¼վӦ¼ָ04Ĵַ0x0100ȡ 5 *n
+ +վȷյ¼ָӦ֡¼ɹظ03ּĴַ0x0100ݳ1n
+ +վյվ¼ɹظ֡¼ܼĴĴ0x00e5¼-n
+ +(4) +¼뱣
+ +l +¼ͣ1 = ࡢ2 = ࡢ3 = ࡢ4 =
+ +l +վɣ¼ݴ棻
+ +l +վɣ¼¼루1=Ʒ/ϣֵ0ֵ֡0ϣ2=ֵʧܡ3=·ֵġ4=AIġ5=AIͨġ6=AOͨġ7=ͨѶġ8=ģ
+ +l +¼棺վɵ¼վɵ¼ͳһվˣγʷ¼¼ļɷࡢʱΡ·ŵȼ鿴
+ +(5) +ʾҳлҳʱӦ03Hʱд10Hվɡxxġ¼ʵʱʾγʷ¼
+ ++ +
+ +
+ +