配置参数修改为从设备上同步后,再修改。

This commit is contained in:
root 2026-06-26 16:36:02 +08:00
parent 226b788afc
commit 978d5c5378
18 changed files with 3010 additions and 437 deletions

View File

@ -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 轮询链路仍能持续推送数据

View File

@ -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)

View File

@ -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="设备对时成功")

View File

@ -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",
)

View File

@ -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)

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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,

View File

@ -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": "",

View File

@ -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

View File

@ -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"]

Binary file not shown.

View File

@ -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` | 单精度浮点数32bit2寄存器/点 |
| 实时显示数据-线路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] = 秒<br>[7:0] = 分 | 秒/分 |
| 0x00E1 | [15:8] = 时<br>[7:0] = 日 | 时/日 |
| 0x00E2 | [15:8] = 月<br>[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路 × 48字节数据/2 = 16个寄存器
统一要求:
---
- 从站生成的事件与主站生成的事件,均保存在主站端。
- 主站应形成历史事件记录文件。
- 历史事件应支持按分类、时间段、线路号等条件检索。
### 3. 线路定值设置
### 7.5 时间推送建议
- **寄存器类型**:保持寄存器
- **功能码**0x10
- **起始地址**0x0014
- **寄存器长度**56
- **说明**4路 × 7类别 × 24字节数据/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路分别操作
- 该推送属于上位机软件实现建议,不新增下位机寄存器。
- 底层时间真值仍以 `0x0000` 时间设置寄存器为准。

View File

@ -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 ~ 0x0032如表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 表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路整体下发不用担心参数截断问题
  5、 AO通道设置
(1) 寄存器类型:保持寄存器
(2) 功能码03/10
(3) 起始寄存器地址0x0072
(4) 寄存器长度:(12(路)*5byte数据)/2 = 30 参数设计为12路整体下发不用担心参数截断问题
  6、 AI报警设置
(1) 寄存器类型:保持寄存器
(2) 功能码03/10
(3) 起始寄存器地址0x0090
(4) 寄存器长度:(12(路)*5byte数据)/2 = 30 参数设计为12路整体下发不用担心参数截断问题
六、 写开出(线圈)
(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数据长度1内容n
  从站收到主站读事件成功回复帧事件总寄存器输入寄存器0x00e5中事件个数-n。
(4) 关于事件生成与保存
* 事件类型1 = 保护类、2 = 动作类、3 = 报警类、4 = 操作类
* 从站生成:"报警""保护""动作"类事件,并暂存;
* 主站生成:"操作"类事件事件代码1=控制分/合动作值0→分、动作值非0→合、2=读定值失败、3=线路定值修改、4=AI报警参数修改、5=AI通道参数修改、6=AO通道参数修改、7=通讯参数修改、8=密码修改);
* 事件保存:从站生成的事件、主站生成的事件统一保存在主站端,并形成历史事件记录文件,并可分类、按时间段、线路号等检索查看。
(5) 显示页面切换到参数页时,应当发送"读参数功能码03H",当参数发生更改时,发送"写参数功能码10H",并由主站生成"xx参数修改"事件,实时显示并存盘形成历史事件。

File diff suppressed because it is too large Load Diff

View File

@ -31,7 +31,12 @@
#### 2.2.1 参数配置管理功能
实现测控设备全维度参数配置,配置项支持**JSON文件本地存储**关键参数通过接口下发至C语言嵌入式程序具体配置项如下
实现测控设备全维度参数配置。结合当前 Modbus 协议与后端实现,配置管理分为两类:
- **协议已定义寄存器的配置项**:以**下位机寄存器值为唯一真值**,页面首次加载时由后端通过 Modbus 回读显示;保存时后端只动态写入下位机,不再落本地 JSON 文件。
- **协议暂未定义寄存器的配置项**:接口先保留,继续使用本地 JSON 文件逻辑作为过渡,待后续协议补齐后再迁移到纯 Modbus 模式。
具体配置项如下:
1. **基础设备配置**
- 设备软件版本信息展示与配置
@ -42,11 +47,11 @@
- AO通道设置模拟量输出配置
- 定值设置:电压/电流/功率等超限限值、检测延时、报警输出配置
3. **系统管理配置**
- 对时设置:参数实时下发嵌入式程序
- 灯光设置:屏幕亮度、屏保时间,参数实时下发嵌入式程序
- 对时设置:按 Modbus 协议实时下发嵌入式程序
- 灯光设置:屏幕亮度、屏保时间字段当前仅保留接口兼容性,因协议暂未定义寄存器,本轮后端不通过 Modbus 下发
4. **配置回显要求**
- 设备网络配置、通道配置、报警设置、系统设置页面在打开时需先读取已保存参数
- 已保存配置从本地JSON文件读取并通过HTTP查询接口返回给Web界面显示
- 通道配置、报警设置、系统设置页面在打开时,需先从下位机读取当前寄存器参数后显示
- 设备/网络/密码等协议未定义寄存器的配置项,当前仍从本地 JSON 文件读取并通过 HTTP 接口返回
#### 2.2.2 实时数据动态显示功能
@ -65,7 +70,10 @@
#### 2.2.4 报警事件管理功能
1. **报警数据采集**:实时获取嵌入式设备报警事件
1. **报警数据采集**:实时获取嵌入式设备报警事件,事件读取按新版 Modbus 协议执行:
- 轮询事件总寄存器 `0x00E5`
- 当事件个数 `n > 0` 时,读取输入寄存器 `0x0100` 开始的 `5 * n` 个事件寄存器
- 事件解析完成后,由后端回告本次成功读取数量
2. **数据存储**:报警事件保存至**SQLite数据库**
3. **展示方式****WebSocket实时推送**报警至Web界面支持弹窗提醒+历史查询
@ -79,14 +87,17 @@
#### 2.3.2 下行数据Python服务→嵌入式系统
- 配置数据:设备参数、通道配置、定值、对时、灯光参数
- 配置数据:
- 通过 Modbus 下发:通道配置、线路定值/变比、AI 报警、系统对时、开出控制
- 本地过渡存储:设备基础参数、网卡、串口、密码等协议未定义项
- 控制指令:开关量分/合控制信号
### 2.4 数据存储需求
1. **JSON文件存储**设备版本、通讯参数、密码、AI/AO配置、定值配置等**非实时参数**
2. **SQLite数据库**:报警事件数据(支持历史查询)
3. **动态数据**:仅内存缓存,不持久化存储
1. **设备寄存器真值存储**通道配置、线路定值、AI 报警、系统对时等协议已定义项,以 Modbus 设备寄存器值为真值来源
2. **JSON文件过渡存储**:设备版本、网卡、串口、密码等协议未定义项,当前仍使用本地 JSON 作为兼容过渡
3. **SQLite数据库**:报警事件数据(支持历史查询)
4. **动态数据**:仅内存缓存,不持久化存储
### 2.5 性能需求
@ -98,7 +109,7 @@
### 2.6 通信服务需求
1. **HTTP服务**基于FastAPI提供**RESTful API**,用于参数配置、状态查询、报警查询、控制指令
2. **WebSocket服务**基于FastAPI提供双工通信用于**实时数据、报警数据主动推送**至Web界面
2. **WebSocket服务**基于FastAPI提供双工通信用于**实时数据、报警数据、设备时间主动推送**至Web界面
## 三、系统架构设计
@ -137,8 +148,11 @@ Python FastAPI服务层RESTful接口 + WebSocket推送 + 数据处理)
采用**FastAPI + Uvicorn**搭建,提供**HTTP接口**和**WebSocket推送**双服务:
1. **RESTful API模块**:处理配置提交、状态查询、报警查询、控制指令
2. **WebSocket推送模块**0.5秒推送实时数据、实时报警数据到Web端
3. **参数配置模块**JSON文件读写、配置下发C程序
2. **WebSocket推送模块**
- `0.5` 秒推送实时数据
- 实时推送报警数据
- `1` 秒推送设备当前时间到独立时间通道
3. **参数配置模块**:按协议区分“设备寄存器回读/回写”和“本地 JSON 兼容逻辑”
4. **实时数据模块**定时读取C程序数据缓存并通过WebSocket推送
5. **报警管理模块**接收报警→存储SQLite→WebSocket推送前端
6. **设备状态模块**:采集并缓存设备运行状态
@ -155,11 +169,11 @@ Python FastAPI服务层RESTful接口 + WebSocket推送 + 数据处理)
1. **实时/报警数据流向**
嵌入式C程序 → Python → WebSocket主动推送 → Web界面0.5秒刷新)
2. **参数配置流向**
Web界面 → HTTP POST → Python → JSON存储 + 下发C程序
Web界面 → HTTP POST → Python → Modbus写下位机协议已定义项/ 本地JSON保存协议未定义项
3. **状态/查询流向**
Web界面 → HTTP GET → Python → 返回数据展示
4. **配置回显流向**
Web界面进入设置页 → HTTP GET配置查询接口 → Python读取JSON配置 → 返回当前配置数据 → Web界面显示
Web界面进入设置页 → HTTP GET配置查询接口 → Python通过 Modbus 回读设备参数或读取本地 JSON 兼容配置 → 返回当前配置数据 → Web界面显示
## 四、接口详细设计RESTful + WebSocket
@ -332,6 +346,7 @@ Python FastAPI服务层RESTful接口 + WebSocket推送 + 数据处理)
**作用**:读取设备基础配置,用于设备/网络配置界面打开时回显当前参数
**参数**:无
**当前数据来源**:本地 `device.json`(协议未定义寄存器,作为兼容过渡保留)
**返回data**
```json
@ -379,6 +394,7 @@ Python FastAPI服务层RESTful接口 + WebSocket推送 + 数据处理)
#### 4. POST /api/config/device
**作用**:提交设备基础配置(版本、通讯、密码)
**当前保存方式**:写本地 `device.json`(协议未定义寄存器,作为兼容过渡保留)
**请求body参数**
```json
@ -443,6 +459,7 @@ Python FastAPI服务层RESTful接口 + WebSocket推送 + 数据处理)
#### 5. GET /api/config/device/net
**作用**:按网卡标识读取单个网卡配置
**当前数据来源**:本地 `device.json`
**参数**
- `nic=网卡一`:按 `nic` 查询对应网卡信息
**返回data**
@ -462,6 +479,7 @@ Python FastAPI服务层RESTful接口 + WebSocket推送 + 数据处理)
#### 6. POST /api/config/device/net
**作用**:按网卡标识保存单个网卡配置
**当前保存方式**:写本地 `device.json`
**请求body参数**
```json
@ -485,6 +503,7 @@ Python FastAPI服务层RESTful接口 + WebSocket推送 + 数据处理)
#### 7. GET /api/config/device/uart
**作用**:按串口标识读取单个串口配置
**当前数据来源**:本地 `device.json`
**参数**
- `port=COM1`:按 `port` 查询对应串口信息
**返回data**
@ -505,6 +524,7 @@ Python FastAPI服务层RESTful接口 + WebSocket推送 + 数据处理)
#### 8. POST /api/config/device/uart
**作用**:按串口标识保存单个串口配置
**当前保存方式**:写本地 `device.json`
**请求body参数**
```json
@ -530,6 +550,7 @@ Python FastAPI服务层RESTful接口 + WebSocket推送 + 数据处理)
**作用**:读取通道配置,用于通道配置界面打开时回显当前参数
**参数**:无
**当前数据来源**:后端通过 Modbus 从下位机回读保持寄存器参数区,不再读取 `channel.json`
**返回data**
```json
@ -544,6 +565,7 @@ Python FastAPI服务层RESTful接口 + WebSocket推送 + 数据处理)
#### 10. POST /api/config/channel
**作用**提交AI/AO通道配置(AI12通道AO12通道)
**当前保存方式**:后端仅通过 Modbus 写下位机,不再写 `channel.json`
**请求body参数**
```json
@ -553,7 +575,7 @@ Python FastAPI服务层RESTful接口 + WebSocket推送 + 数据处理)
}
```
**返回data**`{"save_path":"/config/channel.json","send_status":"成功"}`
**返回data**`{"send_status":"成功","target":"channel","modbus_written":{"ai_registers":30,"ao_registers":30}}`
***
@ -562,6 +584,7 @@ Python FastAPI服务层RESTful接口 + WebSocket推送 + 数据处理)
**作用**:读取线路报警设置,用于报警设置界面打开时回显当前线路参数
**参数**
- `line_no=1`:按线路号读取对应线路的报警设置
**当前数据来源**:后端通过 Modbus 从线路定值寄存器区回读,不再读取 `setting.json`
**返回data**
```json
@ -579,9 +602,29 @@ Python FastAPI服务层RESTful接口 + WebSocket推送 + 数据处理)
"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
}
]
}
@ -592,6 +635,7 @@ Python FastAPI服务层RESTful接口 + WebSocket推送 + 数据处理)
#### 12. POST /api/config/line\_alarm\_setting
**作用**:提交定值报警阈值配置
**当前保存方式**:后端仅通过 Modbus 写下位机,不再写 `setting.json`
**请求body参数**
```json
@ -637,26 +681,39 @@ Python FastAPI服务层RESTful接口 + WebSocket推送 + 数据处理)
"fault_alarm": [
{
"category": "PT断线",
"limit": 0,
"delay": 180,
"output_node": "开出1",
"enabled": true
},
{
"category": "CT断线",
"limit": 0,
"delay": 180,
"output_node": "开出1",
"enabled": false
}
],
"transformer_change": [
{
"category": "PT变化",
"value": 20,
"enabled": true
},
{
"category": "CT变化",
"value": 30,
"enabled": true
}
]
}
```
**返回data**`{"save_path":"/config/setting.json","send_status":"成功"}`
**返回data**`{"send_status":"成功","target":"line_alarm","line_no":1,"modbus_written":16}`
说明:
- `setting.json` 中的 `line_alarm_setting` 按数组对象存储
- 保存接口按单条线路配置提交
- 服务端会根据 `line_no` 对数组中的对应线路进行覆盖更新,不存在时追加
- `transformer_change` 中的 `PT变化`、`CT变化` 参与一次值换算
- 每条线路按 16 个寄存器编码7 类规则 + 2 个变比参数
***
@ -664,6 +721,7 @@ Python FastAPI服务层RESTful接口 + WebSocket推送 + 数据处理)
**作用**读取AI报警设置用于报警设置界面打开时回显当前AI报警参数
**参数**:无
**当前数据来源**:后端通过 Modbus 从 AI 报警寄存器区回读,不再读取 `setting.json`
**返回data**
```json
@ -685,6 +743,7 @@ Python FastAPI服务层RESTful接口 + WebSocket推送 + 数据处理)
#### 14. POST /api/config/ai\_alarm\_setting
**作用**提交AI报警设置
**当前保存方式**:后端仅通过 Modbus 写下位机,不再写 `setting.json`
**请求body参数**
```json
@ -710,17 +769,18 @@ Python FastAPI服务层RESTful接口 + WebSocket推送 + 数据处理)
]
```
**返回data**`{"save_path":"/config/setting.json","send_status":"成功"}`
**返回data**`{"send_status":"成功","target":"ai_alarm","items":2,"modbus_written":30}`
#### 15. GET /api/config/system
**作用**:读取系统设置,用于系统设置界面打开时回显当前参数
**参数**:无
**当前数据来源**:对时参数从设备回读;`brightness`、`screen_saver` 当前仅保留接口兼容字段
**返回data**
```json
{
"time_sync": "auto",
"time_sync": "2026-05-25 12:34:56",
"brightness": 80,
"screen_saver": 60
}
@ -731,21 +791,68 @@ Python FastAPI服务层RESTful接口 + WebSocket推送 + 数据处理)
#### 16. POST /api/config/system
**作用**:提交系统对时、灯光配置
**当前保存方式**:仅 `time_sync` 通过 Modbus 下发;`brightness`、`screen_saver` 当前无协议寄存器,作为兼容字段保留但不下发
**请求body参数**
```json
{
"time_sync": "auto",
"brightness": 80,
"screen_saver": 60
}
```
**返回data**`{"send_status":"成功"}`
**返回data**`{"send_status":"成功","target":"system","ignored_fields":["brightness","screen_saver"]}`
***
#### 17. GET /api/alarm/list
#### 17. GET /api/config/time-sync
**作用**:读取设备当前时间,用于对时页面或时间显示模块初始化回显
**参数**:无
**当前数据来源**:后端通过 Modbus 从设备时间寄存器回读
**返回data**
```json
{
"time_sync": "2026-05-25 14:30:00"
}
```
***
#### 18. POST /api/config/time-sync
**作用**:单独调用设备对时接口,将指定时间写入下位机
**请求body参数**
```json
{
"time_sync": "2026-05-25 14:30:00"
}
```
**返回data**
```json
{
"send_status": "成功",
"target": "time_sync",
"time_sync": "2026-05-25 14:30:00",
"modbus_written": {
"time_registers": 2
}
}
```
说明:
- 该接口与 `/api/config/system` 并行存在,职责更单一,只负责设备对时。
- 写入目标为时间设置寄存器 `0x0000` 开始的 `2` 个保持寄存器。
***
#### 19. GET /api/alarm/list
**作用**:分页查询历史报警
**参数**
@ -787,7 +894,7 @@ GET /api/alarm/list?page=1&size=20&no=L1&type=current&start_time=2026-05-16T10:3
***
#### 18. POST /api/control/switch
#### 20. POST /api/control/switch
**作用**:下发开关量控制指令
**请求body参数**
@ -961,12 +1068,41 @@ GET /api/alarm/list?page=1&size=20&no=L1&type=current&start_time=2026-05-16T10:3
}
```
#### 3. WebSocket 地址:`ws://ip:port/ws/time-sync`
**推送内容**:设备当前时间(`1` 秒/次)
```json
{
"type": "time_sync",
"data": {
"time_sync": "2026-05-25 14:30:01"
}
}
```
说明:
- 后台服务每 `1` 秒轮询一次设备时间寄存器。
- 轮询结果通过独立通道 `/ws/time-sync` 推送。
- 该通道与 `/ws/real-time`、`/ws/alarm` 相互独立,便于前端单独订阅设备时间。
## 五、数据存储设计
### 5.1 JSON配置文件
### 5.1 配置真值与本地配置文件
- 路径:`/config/`
- 文件:`device.json`、`channel.json`、`setting.json`
- **设备寄存器真值**
- `channel`
- `line_alarm_setting`
- `ai_alarm_setting`
- `system.time_sync`
- **本地 JSON 兼容配置**
- 路径:`/config/`
- 文件:`device.json`、`channel.json`、`setting.json`
- 当前仅协议未定义寄存器的接口继续依赖本地 JSON例如设备基础参数、网卡、串口、密码等
- **说明**
- `channel.json`、`setting.json` 不再作为协议已定义配置项的运行期真值来源
- 协议已定义项由下位机寄存器回读结果决定,保存时也只写下位机
### 5.2 SQLite报警数据库
@ -994,8 +1130,9 @@ GET /api/alarm/list?page=1&size=20&no=L1&type=current&start_time=2026-05-16T10:3
本方案基于**RK3568平台**,采用**FastAPI + HTTP + WebSocket**架构,满足电气量测控平台全部需求:
- RESTful API 负责配置、查询、控制
- WebSocket 负责实时数据、报警数据主动推送
- JSON 存储配置SQLite 存储报警
- WebSocket 负责实时数据、报警数据、设备时间主动推送
- 协议已定义配置项以设备寄存器为真值,协议未定义项暂用本地 JSON 兼容
- SQLite 存储报警
- 0.5秒实时刷新,界面无卡顿、数据不丢失
系统架构轻量化、高性能,完全适配嵌入式平台运行。