更新了自检、事件记录等功能

This commit is contained in:
root 2026-06-29 08:45:20 +08:00
parent e3ee6e4fd0
commit 72f1ccae30
18 changed files with 771 additions and 109 deletions

View File

@ -4,10 +4,10 @@ EMCP_BACKUP_DIR=/userdata/emcp/data/backups
# true: use MockDeviceClient
# false: use real Modbus client
EMCP_USE_MOCK_DEVICE=true
EMCP_USE_MOCK_DEVICE=True
# mock | rtu | tcp
EMCP_MODBUS_TRANSPORT=mock
# | rtu | tcp
EMCP_MODBUS_TRANSPORT=rtu
EMCP_MODBUS_SLAVE_ID=1
EMCP_MODBUS_TIMEOUT_SECONDS=1.0

View File

@ -94,6 +94,17 @@ class DeviceClient(ABC):
def send_switch_control(self, payload: SwitchControlIn) -> Dict[str, Any]:
raise NotImplementedError
@abstractmethod
def startup_self_check(self) -> bool:
"""开机自检:通过 03H 读取参数区,验证通讯链路是否正常。
返回值
----
bool
True 表示通讯正常False 表示通讯失败
"""
raise NotImplementedError
VALUE_GROUP_FIELDS = [
"Ua",
@ -130,6 +141,8 @@ AI_INPUT_COUNT = 8
DI_INPUT_REGISTER = 0x00E3
DO_INPUT_REGISTER = 0x00E4
EVENT_FLAG_REGISTER = 0x00E5
STATUS_WORD_00E6_REGISTER = 0x00E6
STATUS_WORD_00E7_REGISTER = 0x00E7
EVENT_INPUT_START_REGISTER = 0x0100
EVENT_SUCCESS_REPLY_REGISTER = 0x0100
UART_CONFIG_START_REGISTER = 0x0004
@ -147,6 +160,30 @@ NET_ITEM_REGISTER_COUNT = 7
TIME_SYNC_START_REGISTER = 0x0000
def _status_label(is_abnormal: bool, abnormal_label: str) -> str:
return abnormal_label if is_abnormal else "正常"
def _build_device_status(event_count: int, status_word_00e6: int, status_word_00e7: int) -> DeviceStatus:
return DeviceStatus(
self_check=_status_label(bool(status_word_00e6 & (1 << 15)), "异常"),
net1=_status_label(bool(status_word_00e6 & (1 << 8)), "断开"),
net2=_status_label(bool(status_word_00e6 & (1 << 9)), "断开"),
net3=_status_label(bool(status_word_00e6 & (1 << 10)), "断开"),
net4=_status_label(bool(status_word_00e6 & (1 << 11)), "断开"),
uart1=_status_label(bool(status_word_00e6 & (1 << 0)), "断开"),
uart2=_status_label(bool(status_word_00e6 & (1 << 1)), "断开"),
uart3=_status_label(bool(status_word_00e6 & (1 << 2)), "断开"),
uart4=_status_label(bool(status_word_00e6 & (1 << 3)), "断开"),
frequency_limit=_status_label(bool(status_word_00e7 & (1 << 3)), "越限"),
ct_break=_status_label(bool(status_word_00e7 & (1 << 2)), "断线"),
pt_break=_status_label(bool(status_word_00e7 & (1 << 1)), "断线"),
event_count=max(0, int(event_count)),
status_word_00e6=int(status_word_00e6) & 0xFFFF,
status_word_00e7=int(status_word_00e7) & 0xFFFF,
)
def _clamp_byte(value: Any) -> int:
try:
number = int(round(float(value)))
@ -394,13 +431,38 @@ class MockDeviceClient(DeviceClient):
return RealtimeData(line_list=line_list, switch=switch, ai_collect=ai_collect)
def read_device_status(self) -> DeviceStatus:
return DeviceStatus(
self_check="正常",
net1="正常",
net2="正常",
uart1="正常",
uart2="正常" if self._tick % 4 else "断开",
)
current_tick = max(self._tick, 1)
event_count = 1 if current_tick % 6 == 0 else 0
status_word_00e6 = 0
if current_tick % 10 == 0:
status_word_00e6 |= 1 << 15
if current_tick % 7 == 0:
status_word_00e6 |= 1 << 8
if current_tick % 9 == 0:
status_word_00e6 |= 1 << 9
if current_tick % 11 == 0:
status_word_00e6 |= 1 << 10
if current_tick % 13 == 0:
status_word_00e6 |= 1 << 11
if current_tick % 8 == 0:
status_word_00e6 |= 1 << 0
if current_tick % 12 == 0:
status_word_00e6 |= 1 << 1
if current_tick % 14 == 0:
status_word_00e6 |= 1 << 2
if current_tick % 4 == 0:
status_word_00e6 |= 1 << 3
status_word_00e7 = 0
if current_tick % 15 == 0:
status_word_00e7 |= 1 << 3
if current_tick % 16 == 0:
status_word_00e7 |= 1 << 2
if current_tick % 18 == 0:
status_word_00e7 |= 1 << 1
return _build_device_status(event_count, status_word_00e6, status_word_00e7)
def read_alarm_events(self) -> List[AlarmEvent]:
if self._tick % 6 != 0:
@ -496,6 +558,10 @@ class MockDeviceClient(DeviceClient):
action_text = "" if payload.action == 1 else ""
return {"control_status": f"执行成功: 开关{payload.ch}{action_text}"}
def startup_self_check(self) -> bool:
"""Mock 实现:始终返回通讯正常。"""
return True
class CDeviceClient(DeviceClient):
"""基于 Modbus 协议的真实设备客户端。"""
@ -586,12 +652,6 @@ class CDeviceClient(DeviceClient):
pass
return {f"do{index}": self._do_state.get(index, 0) for index in range(1, 13)}
def _build_status(self) -> DeviceStatus:
transport = settings.modbus_transport.strip().lower()
if transport == "tcp":
return DeviceStatus(self_check="正常", net1="正常", net2="正常", uart1="断开", uart2="断开")
return DeviceStatus(self_check="正常", net1="断开", net2="断开", uart1="正常", uart2="正常")
def _category_map(self, category: str) -> str:
normalized = category.strip().upper()
return {
@ -853,53 +913,63 @@ class CDeviceClient(DeviceClient):
return RealtimeData(line_list=line_list, switch=switch, ai_collect=self._read_ai_collect())
def read_device_status(self) -> DeviceStatus:
self.transport.read_input_registers(EVENT_FLAG_REGISTER, 1)
return self._build_status()
registers = self.transport.read_input_registers(EVENT_FLAG_REGISTER, 3)
event_count = int(registers[0]) if len(registers) >= 1 else 0
status_word_00e6 = int(registers[1]) if len(registers) >= 2 else 0
status_word_00e7 = int(registers[2]) if len(registers) >= 3 else 0
return _build_device_status(event_count, status_word_00e6, status_word_00e7)
def read_alarm_events(self) -> List[AlarmEvent]:
event_flag = self.transport.read_input_registers(EVENT_FLAG_REGISTER, 1)[0]
event_count = int(event_flag)
if event_count <= 0:
return []
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
remaining_guard = 32
while remaining_guard > 0:
remaining_guard -= 1
event_flag = self.transport.read_input_registers(EVENT_FLAG_REGISTER, 1)[0]
event_count = int(event_flag)
if event_count <= 0:
break
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
registers = self.transport.read_input_registers(EVENT_INPUT_START_REGISTER, event_count * 5)
batch_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
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(
event_time=event_time,
line_code=line_no,
event_type=event_type,
event_code=event_code,
event_value=action_value,
content=f"{line_text}{event_name},动作值={action_value}",
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 = 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}"
batch_alarms.append(
AlarmEvent(
event_time=event_time,
line_code=line_no,
event_type=event_type,
event_code=event_code,
event_value=action_value,
content=f"{line_text}{event_name},动作值={action_value}",
)
)
)
if alarms:
if not batch_alarms:
break
# 协议文档将“读事件成功回复”描述为 03 读取保持寄存器,但该流程需要携带成功个数;
# 这里通过写保持寄存器把已成功读取的事件个数回告给设备。
self.transport.write_registers(EVENT_SUCCESS_REPLY_REGISTER, [len(alarms)])
self.transport.write_registers(EVENT_SUCCESS_REPLY_REGISTER, [len(batch_alarms)])
alarms.extend(batch_alarms)
return alarms
def _decode_event_name(self, event_type: int, event_code: int) -> str:
@ -1116,3 +1186,14 @@ class CDeviceClient(DeviceClient):
"control_status": f"执行成功: 开关{payload.ch}{action_text}",
"modbus_written": {"coil_address": coil_address, "state_register": DO_STATE_REGISTER},
}
def startup_self_check(self) -> bool:
"""开机自检:通过 03H 读取参数区(时间同步保持寄存器),验证通讯链路。
若读取成功通讯链路正常若抛出异常通讯失败
"""
try:
self.transport.read_holding_registers(TIME_SYNC_START_REGISTER, 2)
return True
except Exception:
return False

View File

@ -26,6 +26,14 @@ def save_device_password(payload: DevicePasswordUpdateIn) -> Dict[str, Any]:
return success_response(platform_service.save_device_password(payload), msg="保存设备密码成功")
@router.post("/device/self-check", dependencies=[Depends(verify_api_token)])
def startup_self_check() -> Dict[str, Any]:
result = platform_service.startup_self_check()
if result["success"]:
return success_response(result, msg="开机自检通过")
return success_response(result, msg="开机自检(通讯链路)异常")
@router.get("/device/net", dependencies=[Depends(verify_api_token)])
def get_net_config(nic: str = Query(..., min_length=1)) -> Dict[str, Any]:
return success_response(platform_service.get_net_config(nic=nic), msg="获取网卡配置成功")

View File

@ -11,12 +11,13 @@ class Settings(BaseSettings):
app_version: str = "0.1.0"
api_prefix: str = "/api"
realtime_ws_path: str = "/ws/real-time"
status_ws_path: str = "/ws/status"
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
use_mock_device: bool = False
modbus_transport: str = "rtu"
modbus_slave_id: int = 1
modbus_tcp_host: str = "127.0.0.1"

View File

@ -57,6 +57,16 @@ async def realtime_ws(websocket: WebSocket) -> None:
ws_manager.disconnect("real-time", websocket)
@app.websocket(settings.status_ws_path)
async def status_ws(websocket: WebSocket) -> None:
await ws_manager.connect("status", websocket)
try:
while True:
await websocket.receive_text()
except WebSocketDisconnect:
ws_manager.disconnect("status", websocket)
@app.websocket(settings.alarm_ws_path)
async def alarm_ws(websocket: WebSocket) -> None:
await ws_manager.connect("alarm", websocket)

View File

@ -54,8 +54,18 @@ class DeviceStatus(BaseModel):
self_check: str = "正常"
net1: str = "正常"
net2: str = "正常"
net3: str = "正常"
net4: str = "正常"
uart1: str = "正常"
uart2: str = "断开"
uart2: str = "正常"
uart3: str = "正常"
uart4: str = "正常"
frequency_limit: str = "正常"
ct_break: str = "正常"
pt_break: str = "正常"
event_count: int = 0
status_word_00e6: int = 0
status_word_00e7: int = 0
class HardwareVersion(BaseModel):

View File

@ -10,6 +10,7 @@ from app.core.security import hash_password
from app.repositories.alarm_repo import AlarmRepository
from app.repositories.json_config_repo import JsonConfigRepository
from app.schemas.platform import (
AlarmEvent,
AiAlarmSettingIn,
ChannelConfigIn,
DeviceConfigIn,
@ -23,11 +24,69 @@ from app.schemas.platform import (
SystemConfigIn,
TimeSyncConfigIn,
ValueGroup,
ValueGroup,
UartConfigItem,
)
from app.ws.manager import ws_manager
# 主站侧操作类事件代码
OPERATION_EVENT_CODE = {
1: "控制分/合",
2: "读定值失败",
3: "线路定值修改",
4: "AI报警参数修改",
5: "AI通道参数修改",
6: "AO通道参数修改",
7: "通讯参数修改",
8: "密码修改",
}
# 操作类事件 event_type
EVENT_TYPE_OPERATION = 4
# 开机自检异常归入报警类,避免前端事件报表出现未知类型
EVENT_TYPE_SELF_CHECK = 3
SELF_CHECK_COMMUNICATION_ERROR_CODE = 99
def _generate_operation_event_content(event_code: int, line_code: int = 0, detail: str = "") -> str:
"""根据操作事件代码生成事件内容描述文字。
参数
----
event_code : int
操作事件代码 (1~8)
line_code : int
关联线路编号0 表示装置级
detail : str
附加描述信息如网卡名端口号等
"""
label = OPERATION_EVENT_CODE.get(event_code, f"未知操作事件(code={event_code})")
parts = [label]
if line_code > 0:
parts.append(f"线路{line_code}")
if detail:
parts.append(detail)
return "".join(parts)
def _collect_startup_status_abnormalities(status: DeviceStatus) -> List[str]:
abnormal_items: List[str] = []
if status.self_check != "正常":
abnormal_items.append(f"装置自检={status.self_check}")
for label, value in (
("网口1", status.net1),
("网口2", status.net2),
("网口3", status.net3),
("网口4", status.net4),
("串口1", status.uart1),
("串口2", status.uart2),
("串口3", status.uart3),
("串口4", status.uart4),
):
if value != "正常":
abnormal_items.append(f"{label}={value}")
return abnormal_items
class PlatformService:
def __init__(self) -> None:
@ -239,6 +298,65 @@ class PlatformService:
memory_store.set_status(status)
return status
def startup_self_check(self) -> Dict[str, Any]:
"""开机自检:通过 03H 读取参数区,验证通讯链路。
若通讯失败生成"开机自检(通讯链路)异常"报警事件
并将设备状态置为"断开"网络/串口状态清空
返回值
----
dict
{"success": bool, "status": DeviceStatus}
"""
success = self.device_client.startup_self_check()
if success:
status = self.device_client.read_device_status()
memory_store.set_status(status)
abnormal_items = _collect_startup_status_abnormalities(status)
if abnormal_items:
alarm = AlarmEvent(
event_time=datetime.now(),
line_code=0,
event_type=EVENT_TYPE_SELF_CHECK,
event_code=SELF_CHECK_COMMUNICATION_ERROR_CODE,
event_value=len(abnormal_items),
content=f"开机自检状态异常:{', '.join(abnormal_items)}",
)
alarm.id = self.alarm_repo.save_alarm(alarm)
memory_store.push_alarm(alarm)
return {"success": True, "status": status.model_dump()}
# 通讯失败:生成报警事件
alarm = AlarmEvent(
event_time=datetime.now(),
line_code=0,
event_type=EVENT_TYPE_SELF_CHECK,
event_code=SELF_CHECK_COMMUNICATION_ERROR_CODE,
event_value=0,
content="开机自检(通讯链路)异常",
)
alarm.id = self.alarm_repo.save_alarm(alarm)
memory_store.push_alarm(alarm)
# 设置状态为断开
status = DeviceStatus(
self_check="断开",
net1="断开",
net2="断开",
net3="断开",
net4="断开",
uart1="断开",
uart2="断开",
uart3="断开",
uart4="断开",
frequency_limit="未知",
ct_break="未知",
pt_break="未知",
)
memory_store.set_status(status)
return {"success": False, "status": status.model_dump()}
def _default_device_config(self) -> Dict[str, Any]:
return {
"password": "",
@ -333,6 +451,13 @@ class PlatformService:
data["uart"] = self._merge_keyed_items(data["uart"], payload_data["uart"], "port")
if payload.password.strip():
data["password"] = hash_password(payload.password)
self._save_operation_event(event_code=8, detail="设备密码")
if payload_data.get("net"):
nics = [item.get("nic", "") for item in payload_data["net"] if isinstance(item, dict)]
self._save_operation_event(event_code=7, detail=f"网卡: {', '.join(nics)}")
if payload_data.get("uart"):
ports = [item.get("port", "") for item in payload_data["uart"] if isinstance(item, dict)]
self._save_operation_event(event_code=7, detail=f"串口: {', '.join(ports)}")
path = self.config_repo.write_device_config(data)
device_result = self.device_client.send_device_config(payload)
return {"save_path": f"/config/{path.name}", **device_result}
@ -341,6 +466,7 @@ class PlatformService:
data = self._load_device_config_with_defaults()
data["password"] = hash_password(payload.password)
path = self.config_repo.write_device_config(data)
self._save_operation_event(event_code=8, detail="设备密码")
return {"save_path": f"/config/{path.name}", "target": "password", "send_status": "成功"}
def get_device_config(self) -> Dict[str, Any]:
@ -354,9 +480,13 @@ class PlatformService:
def save_net_config(self, payload: NetConfigItem | List[NetConfigItem]) -> Dict[str, Any]:
if isinstance(payload, list):
nics = [item.nic for item in payload]
results = [self.device_client.send_net_config(item) for item in payload]
self._save_operation_event(event_code=7, detail=f"网卡: {', '.join(nics)}")
return {"send_status": "成功", "target": "net_batch", "items": len(results), "results": results}
return self.device_client.send_net_config(payload)
result = self.device_client.send_net_config(payload)
self._save_operation_event(event_code=7, detail=f"网卡: {payload.nic}")
return result
def get_uart_config(self, port: str) -> Dict[str, Any]:
device_config = self._load_device_config_with_defaults()
@ -369,10 +499,13 @@ class PlatformService:
device_config = self._load_device_config_with_defaults()
if isinstance(payload, list):
results = []
ports = []
for item in payload:
device_config["uart"] = self._upsert_keyed_item(device_config["uart"], item.model_dump(), "port")
ports.append(item.port)
results.append({"target": "uart", "port": item.port, "send_status": "成功"})
path = self.config_repo.write_device_config(device_config)
self._save_operation_event(event_code=7, detail=f"串口: {', '.join(ports)}")
return {
"save_path": f"/config/{path.name}",
"target": "uart_batch",
@ -382,10 +515,17 @@ class PlatformService:
}
device_config["uart"] = self._upsert_keyed_item(device_config["uart"], payload.model_dump(), "port")
path = self.config_repo.write_device_config(device_config)
self._save_operation_event(event_code=7, detail=f"串口: {payload.port}")
return {"save_path": f"/config/{path.name}", "target": "uart", "port": payload.port, "send_status": "成功"}
def save_channel_config(self, payload: ChannelConfigIn) -> Dict[str, Any]:
device_result = self.device_client.send_channel_config(payload)
if payload.ai_channel:
ai_chs = [str(item.ch) for item in payload.ai_channel]
self._save_operation_event(event_code=5, detail=f"通道: {', '.join(ai_chs)}")
if payload.ao_channel:
ao_chs = [str(item.ch) for item in payload.ao_channel]
self._save_operation_event(event_code=6, detail=f"通道: {', '.join(ao_chs)}")
return device_result
def get_channel_config(self) -> Dict[str, Any]:
@ -420,11 +560,16 @@ class PlatformService:
def save_line_alarm_setting(self, payload: LineAlarmSettingIn) -> Dict[str, Any]:
device_result = self.device_client.send_line_alarm_setting(payload)
self._save_operation_event(event_code=3, line_code=payload.line_no)
return device_result
def get_line_alarm_setting(self, line_no: int = 1) -> Dict[str, Any]:
current = self.device_client.read_line_alarm_setting(line_no)
merged = self._default_line_alarm_setting(line_no)
try:
current = self.device_client.read_line_alarm_setting(line_no)
except Exception as exc:
self._save_operation_event(event_code=2, line_code=line_no, detail=f"线路{line_no}{exc}")
current = None
if isinstance(current, dict):
merged.update(current)
normalized_transformer: List[Dict[str, Any]] = []
@ -444,7 +589,10 @@ class PlatformService:
def save_ai_alarm_setting(self, payload: List[AiAlarmSettingIn]) -> Dict[str, Any]:
payload_data = [item.model_dump() for item in payload]
return self.device_client.send_ai_alarm_setting(payload_data)
result = self.device_client.send_ai_alarm_setting(payload_data)
chs = [str(item.channel_no) for item in payload]
self._save_operation_event(event_code=4, detail=f"通道: {', '.join(chs)}")
return result
def get_ai_alarm_setting(self) -> List[Dict[str, Any]]:
return self.device_client.read_ai_alarm_setting()
@ -479,8 +627,56 @@ class PlatformService:
end_time=end_time,
)
def _save_operation_event(
self,
event_code: int,
line_code: int = 0,
event_value: int = 0,
detail: str = "",
) -> AlarmEvent:
"""通用操作事件生成保存方法(同步)。
将主站侧操作类事件保存到 SQLite 并推入内存缓存
返回保存后的 AlarmEvent 对象含数据库自增 id调用方可进一步广播
参数
----
event_code : int
操作事件代码取值 1~8对应含义见 OPERATION_EVENT_CODE
line_code : int
关联线路编号0 表示装置级
event_value : int
事件附带数值如分合闸通道号
detail : str
附加描述如网卡名端口号参数名等
返回值
----
AlarmEvent
已保存的事件对象id 已回填
"""
content = _generate_operation_event_content(event_code, line_code, detail)
alarm = AlarmEvent(
event_time=datetime.now(),
line_code=line_code,
event_type=EVENT_TYPE_OPERATION,
event_code=event_code,
event_value=event_value,
content=content,
)
alarm.id = self.alarm_repo.save_alarm(alarm)
memory_store.push_alarm(alarm)
return alarm
def switch_control(self, payload: SwitchControlIn) -> Dict[str, Any]:
return self.device_client.send_switch_control(payload)
result = self.device_client.send_switch_control(payload)
action_label = "合闸" if payload.action == 1 else "分闸"
self._save_operation_event(
event_code=1,
event_value=payload.ch,
detail=f"CH{payload.ch} {action_label}",
)
return result
async def poll_time_sync_once(self) -> None:
time_sync = self.device_client.read_time_sync_config()
@ -494,6 +690,7 @@ class PlatformService:
memory_store.set_realtime(realtime)
memory_store.set_status(status)
await ws_manager.broadcast("real-time", {"type": "real_time", "data": realtime.model_dump()})
await ws_manager.broadcast("status", {"type": "status", "data": status.model_dump()})
for alarm in alarms:
alarm.id = self.alarm_repo.save_alarm(alarm)

View File

@ -10,11 +10,14 @@ from app.db.sqlite import get_connection, init_db
from app.main import app
from app.repositories.alarm_repo import AlarmRepository
from app.repositories.json_config_repo import JsonConfigRepository
from app.schemas.platform import AlarmEvent, LineData, RealtimeData, ValueGroup
from app.schemas.platform import AlarmEvent, DeviceStatus, LineData, RealtimeData, ValueGroup
from app.services.platform_service import platform_service
from app.ws.manager import ws_manager
# 测试固定使用 Mock避免受本地 .env 中 RTU/TCP 真机配置影响。
platform_service.device_client = MockDeviceClient()
client = TestClient(app)
@ -29,6 +32,21 @@ def test_healthcheck() -> None:
assert response.json()["status"] == "ok"
def test_device_status_endpoint() -> None:
response = client.get("/api/device-status")
assert response.status_code == 200
payload = response.json()
assert payload["code"] == 200
assert "self_check" in payload["data"]
assert "net4" in payload["data"]
assert "uart4" in payload["data"]
assert "frequency_limit" in payload["data"]
assert "ct_break" in payload["data"]
assert "pt_break" in payload["data"]
assert "status_word_00e6" in payload["data"]
assert "status_word_00e7" in payload["data"]
def test_realtime_endpoint() -> None:
response = client.get("/api/real-time-data")
assert response.status_code == 200
@ -37,6 +55,92 @@ def test_realtime_endpoint() -> None:
assert "line_list" in payload["data"]
def test_status_websocket_receives_payload() -> None:
with client.websocket_connect("/ws/status") as websocket:
asyncio.run(platform_service.poll_device_once())
payload = websocket.receive_json()
assert payload["type"] == "status"
assert "self_check" in payload["data"]
assert "net4" in payload["data"]
assert "uart4" in payload["data"]
assert "frequency_limit" in payload["data"]
def test_startup_self_check_saves_alarm_when_status_is_abnormal(tmp_path) -> None:
class AbnormalStatusMockClient(MockDeviceClient):
def startup_self_check(self) -> bool:
return True
def read_device_status(self) -> DeviceStatus:
return DeviceStatus(
self_check="异常",
net1="断开",
net2="正常",
net3="正常",
net4="正常",
uart1="正常",
uart2="断开",
uart3="正常",
uart4="正常",
)
old_db_path = settings.alarm_db_path
old_client = platform_service.device_client
settings.alarm_db_path = tmp_path / "alarm.db"
init_db()
with get_connection() as connection:
connection.execute("DELETE FROM alarm_event")
connection.commit()
platform_service.device_client = AbnormalStatusMockClient()
try:
result = platform_service.startup_self_check()
alarms = AlarmRepository().list_alarms(page=1, size=20)
assert result["success"] is True
assert result["status"]["self_check"] == "异常"
assert len(alarms) == 1
assert alarms[0]["event_type"] == 3
assert alarms[0]["event_code"] == 99
assert "装置自检=异常" in alarms[0]["content"]
assert "网口1=断开" in alarms[0]["content"]
assert "串口2=断开" in alarms[0]["content"]
finally:
platform_service.device_client = old_client
settings.alarm_db_path = old_db_path
def test_get_line_alarm_setting_saves_read_failure_event(tmp_path) -> None:
class FailingLineAlarmClient(MockDeviceClient):
def read_line_alarm_setting(self, line_no: int) -> dict:
raise RuntimeError("线路定值回读失败")
old_db_path = settings.alarm_db_path
old_client = platform_service.device_client
settings.alarm_db_path = tmp_path / "alarm.db"
init_db()
with get_connection() as connection:
connection.execute("DELETE FROM alarm_event")
connection.commit()
platform_service.device_client = FailingLineAlarmClient()
try:
result = platform_service.get_line_alarm_setting(2)
alarms = AlarmRepository().list_alarms(page=1, size=20)
assert result["line_no"] == 2
assert len(alarms) == 1
assert alarms[0]["event_type"] == 4
assert alarms[0]["event_code"] == 2
assert alarms[0]["line_code"] == 2
assert "读定值失败" in alarms[0]["content"]
assert "线路定值回读失败" in alarms[0]["content"]
finally:
platform_service.device_client = old_client
settings.alarm_db_path = old_db_path
def test_realtime_precision_normalization() -> None:
realtime = RealtimeData(
line_list=[

View File

@ -16,6 +16,8 @@ from app.adapters.device_client import (
EVENT_FLAG_REGISTER,
EVENT_INPUT_START_REGISTER,
EVENT_SUCCESS_REPLY_REGISTER,
STATUS_WORD_00E6_REGISTER,
STATUS_WORD_00E7_REGISTER,
NET_CONFIG_REGISTER_COUNT,
NET_CONFIG_START_REGISTER,
LINE_ALARM_REGISTERS_PER_LINE,
@ -156,6 +158,87 @@ def test_modbus_client_reads_alarm_event_once() -> None:
assert transport.written_registers[-1] == (EVENT_SUCCESS_REPLY_REGISTER, [1])
def test_modbus_client_reads_alarm_events_until_event_flag_clears() -> None:
class CyclingTransport(FakeTransport):
def __init__(self, batches: List[List[int]]) -> None:
super().__init__()
self._batches = [list(batch) for batch in batches]
def read_input_registers(self, address: int, count: int) -> List[int]:
if address == EVENT_FLAG_REGISTER and count == 1:
return [len(self._batches[0]) // 5 if self._batches else 0]
if address == EVENT_INPUT_START_REGISTER:
return list(self._batches[0])[:count] if self._batches else [0] * count
return super().read_input_registers(address, count)
def write_registers(self, address: int, values: Sequence[int]) -> None:
super().write_registers(address, values)
if address == EVENT_SUCCESS_REPLY_REGISTER and self._batches:
self._batches.pop(0)
timestamp1 = int(datetime(2026, 5, 25, 12, 0, 0).timestamp())
timestamp2 = int(datetime(2026, 5, 25, 12, 0, 1).timestamp())
transport = CyclingTransport(
batches=[
[
timestamp1 & 0xFFFF,
(timestamp1 >> 16) & 0xFFFF,
111,
(1 << 12) | (3 << 8) | 1,
230,
],
[
timestamp2 & 0xFFFF,
(timestamp2 >> 16) & 0xFFFF,
222,
(2 << 12) | (2 << 8) | 7,
1,
],
]
)
client = CDeviceClient(transport=transport)
alarms = client.read_alarm_events()
assert len(alarms) == 2
assert alarms[0].line_code == 1
assert alarms[0].event_type == 3
assert alarms[1].line_code == 2
assert alarms[1].event_type == 2
assert transport.written_registers[-2:] == [
(EVENT_SUCCESS_REPLY_REGISTER, [1]),
(EVENT_SUCCESS_REPLY_REGISTER, [1]),
]
assert client.read_alarm_events() == []
def test_modbus_client_reads_status_words() -> None:
transport = FakeTransport()
client = CDeviceClient(transport=transport)
transport.set_input_block(EVENT_FLAG_REGISTER, [3])
transport.set_input_block(STATUS_WORD_00E6_REGISTER, [0x8A05])
transport.set_input_block(STATUS_WORD_00E7_REGISTER, [0x000E])
status = client.read_device_status()
assert status.event_count == 3
assert status.self_check == "异常"
assert status.net1 == "正常"
assert status.net2 == "断开"
assert status.net3 == "正常"
assert status.net4 == "断开"
assert status.uart1 == "断开"
assert status.uart2 == "正常"
assert status.uart3 == "断开"
assert status.uart4 == "正常"
assert status.frequency_limit == "越限"
assert status.ct_break == "断线"
assert status.pt_break == "断线"
assert status.status_word_00e6 == 0x8A05
assert status.status_word_00e7 == 0x000E
def test_modbus_client_reads_and_writes_downstream_registers() -> None:
transport = FakeTransport()
client = CDeviceClient(transport=transport)

View File

@ -28,6 +28,8 @@
| 开入状态 | `0x04` | 输入寄存器 | `0x00E3` | 1寄存器 | 16路每 bit 一路 |
| 开出状态 | `0x04` | 输入寄存器 | `0x00E4` | 1寄存器 | 16路每 bit 一路 |
| 事件总寄存器 | `0x04` | 输入寄存器 | `0x00E5` | 1寄存器 | 内容为 `n` 时表示有 `n` 个事件待读取 |
| 自检 / 串口 / 网口状态 | `0x04` | 输入寄存器 | `0x00E6` | 1寄存器 | `bit15` 自检状态,`bit11 ~ bit8` 为网口1~4状态`bit3 ~ bit0` 为串口1~4状态 |
| 遥信状态 | `0x04` | 输入寄存器 | `0x00E7` | 1寄存器 | `bit3` 频率越限,`bit2` CT断线`bit1` PT断线 |
| 读事件内容 | `0x04` | 输入寄存器 | `0x0100` | `5 * n` | 当 `0x00E5 != 0` 时读取 |
### 2.2 读/写参数区
@ -102,6 +104,69 @@
| `0x00E3` | 开入状态 | 16路输入每 bit 一路 |
| `0x00E4` | 开出状态 | 16路输出每 bit 一路 |
| `0x00E5` | 事件总寄存器 | 值为 `n` 表示有 `n` 个事件待读取 |
| `0x00E6` | 自检 / 串口 / 网口状态 | `bit15` 表示自检状态,`bit11 ~ bit8` 表示网口1~4状态`bit3 ~ bit0` 表示串口1~4状态 |
| `0x00E7` | 遥信状态 | `bit3` 频率越限,`bit2` CT断线`bit1` PT断线其余位保留 |
### 3.6 自检 / 串口 / 网口状态寄存器
#### 3.6.1 寄存器 `0x00E6`
| 项目 | 定义 |
| ---- | ---- |
| 寄存器类型 | 输入寄存器 |
| 功能码 | `0x04` |
| 地址 | `0x00E6` |
| 说明 | 用于监控设备开机自检、4路串口、4路网口状态 |
| 位位置 | 功能 | 含义说明 |
| ------ | ---- | -------- |
| `Bit15` | 自检状态 | `1 = 自检异常 / 未完成``0 = 自检正常完成` |
| `Bit11` | Net4 网口状态 | `1 = 网口4异常 / 断开``0 = 网口4正常连接` |
| `Bit10` | Net3 网口状态 | `1 = 网口3异常 / 断开``0 = 网口3正常连接` |
| `Bit9` | Net2 网口状态 | `1 = 网口2异常 / 断开``0 = 网口2正常连接` |
| `Bit8` | Net1 网口状态 | `1 = 网口1异常 / 断开``0 = 网口1正常连接` |
| `Bit3` | Com4 串口状态 | `1 = 串口4异常 / 无数据``0 = 串口4正常` |
| `Bit2` | Com3 串口状态 | `1 = 串口3异常 / 无数据``0 = 串口3正常` |
| `Bit1` | Com2 串口状态 | `1 = 串口2异常 / 无数据``0 = 串口2正常` |
| `Bit0` | Com1 串口状态 | `1 = 串口1异常 / 无数据``0 = 串口1正常` |
| 其余位 | 保留 | 未使用,读为 `0` |
#### 3.6.2 寄存器 `0x00E7`
| 项目 | 定义 |
| ---- | ---- |
| 寄存器类型 | 输入寄存器 |
| 功能码 | `0x04` |
| 地址 | `0x00E7` |
| 说明 | 用于反馈开机自检及运行过程中的遥信类异常状态 |
| 位位置 | 功能 | 含义说明 |
| ------ | ---- | -------- |
| `Bit3` | 频率越限 | `1 = 频率超出阈值``0 = 频率正常` |
| `Bit2` | CT 断线 | `1 = CT电流互感器回路断线``0 = CT 回路正常` |
| `Bit1` | PT 断线 | `1 = PT电压互感器回路断线``0 = PT 回路正常` |
| 其余位 | 保留 | 未使用,读为 `0` |
#### 3.6.3 使用场景与解读示例
- 事件总寄存器 `0x00E5` 用于表示当前待处理事件个数,值非 `0` 时表示存在待读取事件,值为 `0` 表示无事件。
- 状态寄存器 `0x00E6`、`0x00E7` 用于快速反馈开机自检、通信链路和遥信告警状态,便于上位机快速诊断设备健康状况。
读取 `0x00E6` 时,若返回值为 `0x800F`(二进制 `1000 0000 0000 1111`),表示:
- `Bit15 = 1`,自检异常或未完成
- `Bit3 ~ Bit0 = 1111``Com1 ~ Com4` 全部异常
读取 `0x00E7` 时,若返回值为 `0x0006`(二进制 `0000 0000 0000 0110`),表示:
- `Bit3 = 0`,频率正常
- `Bit2 = 1`CT 断线
- `Bit1 = 1`PT 断线
读取 `0x00E5 ~ 0x00E7``3` 个寄存器的 Modbus RTU 报文示例:
- 主机请求:`[从站地址] 04 00 E5 00 03 [CRC16]`
- 从站响应:`[从站地址] 04 06 [00E5值] [00E6值] [00E7值] [CRC16]`
---
@ -364,14 +429,22 @@ typedef struct
### 7.1 开机初始化时序
1. 主站开机后,先通过 `03H` 读取参数区。
2. 若参数读取失败,主站生成报警事件,可归类为“开机自检(通讯链路)”异常。
3. 开机自检结束后,进入周期轮询阶段。
2. 参数读取成功后,主站应通过 `04H` 读取状态寄存器 `0x00E6`、`0x00E7`,确认开机自检、串口、网口及遥信异常状态。
3. 若 `0x00E6.Bit15 = 1`,表示设备自检异常或尚未完成;若对应串口 / 网口状态位为 `1`,表示对应通信接口异常。
4. 若 `0x00E7``Bit3 / Bit2 / Bit1` 置位则表示频率越限、CT断线、PT断线等遥信异常已出现。
5. 若参数读取失败,或开机自检状态异常,主站生成报警事件,可归类为“开机自检(通讯链路)”异常。
6. 开机自检结束后,进入周期轮询阶段。
### 7.2 周期轮询时序
1. 主站周期发送 `04H`,读取实时输入寄存器。
2. 收到从站 `04H` 应答后,更新实时数据、状态数据。
3. 随后读取事件总寄存器 `0x00E5`
3. 状态数据至少应覆盖 `0x00E3 ~ 0x00E7`,其中:
- `0x00E3` 表示开入状态
- `0x00E4` 表示开出状态
- `0x00E5` 表示事件总数
- `0x00E6` 表示自检 / 串口 / 网口状态
- `0x00E7` 表示遥信异常状态
4. 若 `0x00E5 = 0`,继续下一轮周期轮询。
5. 若 `0x00E5 = n``n > 0`,立即进入读事件流程。
@ -380,6 +453,7 @@ typedef struct
- 若上位机需要在页面上动态显示设备当前时间,可额外按固定周期读取时间设置寄存器 `0x0000`
- 推荐上位机软件每 `1` 秒轮询一次,并通过独立时间推送通道发送给前端页面。
- 推荐的软件推送通道示例:`/ws/time-sync`
- 若需快速巡检设备状态,主站可直接读取 `0x00E5 ~ 0x00E7``3` 个寄存器,以同时获得事件数量、自检 / 通信链路状态及遥信告警状态。
### 7.3 读事件时序

Binary file not shown.

View File

@ -65,8 +65,11 @@
从嵌入式系统动态读取设备状态Web界面实时展示
- 装置自检状态(正常/异常)
- 网络1状态、网络2状态
- 串口1状态、串口2状态
- 网络1状态、网络2状态、网络3状态、网络4状态
- 串口1状态、串口2状态、串口3状态、串口4状态
- 频率越限、CT断线、PT断线状态
- 状态真值来源于输入寄存器 `0x00E5 ~ 0x00E7`
- 后端按 `poll_interval_seconds` 周期轮询状态寄存器,并通过独立 WebSocket 通道 `/ws/status` 推送到前端页面
#### 2.2.4 报警事件管理功能
@ -82,7 +85,7 @@
#### 2.3.1 上行数据嵌入式系统→Python服务
- 实时数据0.5秒/次模拟量、开关量、AI采集数据
- 状态数据:装置自检、网络、串口状态
- 状态数据:装置自检、网络、串口、频率越限、CT/PT断线状态
- 报警数据:实时报警事件数据
#### 2.3.2 下行数据Python服务→嵌入式系统
@ -109,7 +112,7 @@
### 2.6 通信服务需求
1. **HTTP服务**基于FastAPI提供**RESTful API**,用于参数配置、状态查询、报警查询、控制指令
2. **WebSocket服务**基于FastAPI提供双工通信用于**实时数据、报警数据、设备时间主动推送**至Web界面
2. **WebSocket服务**基于FastAPI提供双工通信用于**实时数据、设备状态、报警数据、设备时间主动推送**至Web界面
## 三、系统架构设计
@ -150,17 +153,18 @@ Python FastAPI服务层RESTful接口 + WebSocket推送 + 数据处理)
1. **RESTful API模块**:处理配置提交、状态查询、报警查询、控制指令
2. **WebSocket推送模块**
- `0.5` 秒推送实时数据
- 按 `poll_interval_seconds` 推送设备状态到 `/ws/status`
- 实时推送报警数据
- `1` 秒推送设备当前时间到独立时间通道
3. **参数配置模块**:按协议区分“设备寄存器回读/回写”和“本地 JSON 兼容逻辑”
4. **实时数据模块**定时读取C程序数据缓存并通过WebSocket推送
5. **报警管理模块**接收报警→存储SQLite→WebSocket推送前端
6. **设备状态模块**采集并缓存设备运行状态
6. **设备状态模块**读取输入寄存器 `0x00E5 ~ 0x00E7`,解码并缓存设备运行状态,再通过 `/ws/status` 主动推送
#### 3.2.4 Web界面层
- 菜单区:配置、状态、数据、报警、控制入口
- 状态区:自检、网络、串口实时状态
- 状态区:自检、网络、串口、频率越限、CT/PT断线实时状态
- 主显示区:实时数据表格、配置表单、控制按钮、报警列表
- 报警区WebSocket实时弹窗+滚动显示

View File

@ -22,6 +22,11 @@ export async function fetchDeviceStatus(): Promise<DeviceStatus> {
const response = await http.get<ApiResponse<DeviceStatus>>('/device-status')
return response.data.data
}
export async function fetchStartupSelfCheck(): Promise<{ success: boolean; status: DeviceStatus }> {
const response = await http.post<ApiResponse<{ success: boolean; status: DeviceStatus }>>('/config/device/self-check')
return response.data.data
}
export async function fetchDeviceConfig(): Promise<DeviceConfigPayload> {
const response = await http.get<ApiResponse<DeviceConfigPayload>>('/config/device')
return response.data.data

View File

@ -1,47 +1,101 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { fetchDeviceStatus } from '@/api/platform'
import { connectTimeSync } from '@/websocket/client'
import { fetchStartupSelfCheck, fetchDeviceStatus } from '@/api/platform'
import { connectStatus, connectTimeSync } from '@/websocket/client'
const time = ref('')
const date = ref('')
const day = ref('')
const weekDays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
const centerItems = ref([
{
label: '装置自检状态',
label: '装置自检',
status: '--',
code: 'self-check'
code: 'self_check'
},
{
label: '网络1状态',
label: '网口1',
status: '--',
code: 'network1'
code: 'net1'
},
{
label: '网络2状态',
label: '网口2',
status: '--',
code: 'network2'
code: 'net2'
},
{
label: '串口1',
status: '--',
code: 'uart1'
},
{
label: '串口1状态',
label: '串口2',
status: '--',
code: 'serial1'
code: 'uart2'
},
{
label: '串口2状态',
label: '频率',
status: '--',
code: 'serial2'
code: 'frequency_limit'
},
{
label: 'CT',
status: '--',
code: 'ct_break'
},
{
label: 'PT',
status: '--',
code: 'pt_break'
}
])
// WebSocket
let ws: WebSocket | null = null
let timeSyncWs: WebSocket | null = null
let statusWs: WebSocket | null = null
/** 根据后端返回的 DeviceStatus 更新 header 状态显示 */
function applyStatus(status: Record<string, string>) {
centerItems.value.forEach(item => {
const value = status[item.code]
if (value !== undefined && value !== null) {
item.status = value
} else {
item.status = '正常'
}
})
}
/** 开机自检:首页加载时先走 03H 读参数区,失败则标记通讯链路异常 */
async function startupSelfCheck() {
try {
const result = await fetchStartupSelfCheck()
applyStatus(result.status)
} catch {
//
applyStatus({
self_check: '断开',
net1: '断开',
net2: '断开',
net3: '断开',
net4: '断开',
uart1: '断开',
uart2: '断开',
uart3: '断开',
uart4: '断开',
frequency_limit: '未知',
ct_break: '未知',
pt_break: '未知',
})
}
}
/** 普通轮询刷新状态 */
async function refreshStatus() {
try {
const status:any = await fetchDeviceStatus()
centerItems.value.forEach(item => {
item.status = status[item.code] || '正常'
})
} catch (error) {
const status = await fetchDeviceStatus()
applyStatus(status as unknown as Record<string, string>)
} catch {
//
}
}
//
@ -92,20 +146,29 @@ onMounted(() => {
// day.value = weekDays[now.getDay()]
// WebSocket
ws = connectTimeSync((event) => {
timeSyncWs = connectTimeSync((event) => {
const serverTimeStr = event.data.time_sync
lastServerTime = new Date(serverTimeStr.replace(' ', 'T'))
updateTimeFromServer(serverTimeStr)
//
startLocalTimer()
})
refreshStatus()
statusWs = connectStatus((status) => {
applyStatus(status as unknown as Record<string, string>)
})
// 03H
startupSelfCheck()
void refreshStatus()
})
onUnmounted(() => {
if (localTimer) clearInterval(localTimer)
if (ws) {
ws.close()
ws = null
if (timeSyncWs) {
timeSyncWs.close()
timeSyncWs = null
}
if (statusWs) {
statusWs.close()
statusWs = null
}
})
</script>
@ -133,13 +196,14 @@ onUnmounted(() => {
<style scoped lang="scss">
.header-container {
width: 100%;
height: 60px;
min-height: 60px;
height: auto;
background-color: #ffffff;
border-radius: 5px 5px 0px 0px;
color: rgb(31, 41, 55);
display: flex;
align-items: center;
padding: 0 20px;
padding: 8px 20px;
gap: 20px;
box-shadow: 0px 2px 8px rgb(219, 225, 236);
position: relative;
@ -159,11 +223,17 @@ onUnmounted(() => {
.header-container-center {
display: flex;
align-items: center;
align-content: center;
flex-wrap: wrap;
width: calc(100% - 300px);
gap: 20px;
gap: 6px 16px;
font-weight: 400;
color: #505050;
font-size: 14px;
font-size: 12px;
}
.header-container-center>div {
min-width: 82px;
}
.header-container-right-time {

View File

@ -33,14 +33,7 @@ const routes: RouteRecordRaw[] = [
// },
{
path: "/dashboard",
name: "Dashboard",
component: () => import("@/views/dashboard/index.vue"),
meta: {
title: "系统总览",
requiresAuth: false,
icon: "dashboard",
keepAlive: true,
},
redirect: "/analogQuantity",
},
{
path: "/",
@ -51,7 +44,7 @@ const routes: RouteRecordRaw[] = [
children: [
{
path: "",
redirect: "/dashboard",
redirect: "/analogQuantity",
},
{
path: "analogQuantity",

View File

@ -43,8 +43,18 @@ export interface DeviceStatus {
self_check: string
net1: string
net2: string
net3: string
net4: string
uart1: string
uart2: string
uart3: string
uart4: string
frequency_limit: string
ct_break: string
pt_break: string
event_count: number
status_word_00e6: number
status_word_00e7: number
}
export interface AlarmEvent {

View File

@ -47,6 +47,9 @@ export function getEventCodeName(eventType: number, eventCode: number): string {
if (eventCode === 3) {
return '频率越限'
}
if (eventCode === 99) {
return '开机自检(通讯链路)异常'
}
return `报警事件${eventCode}`
}

View File

@ -1,4 +1,4 @@
import type { AlarmEvent, RealtimeData } from '../types/platform'
import type { AlarmEvent, DeviceStatus, RealtimeData } from '../types/platform'
type TimeSyncPayload = {
time_sync: string
@ -40,6 +40,15 @@ export function connectAlarm(onMessage: (data: AlarmEvent) => void): WebSocket {
return socket
}
export function connectStatus(onMessage: (data: DeviceStatus) => void): WebSocket {
const socket = new WebSocket(buildWsUrl('/ws/status'))
socket.onmessage = (event) => {
const payload = JSON.parse(event.data)
onMessage(payload.data)
}
return socket
}
export function connectTimeSync(onMessage: (data: { data: TimeSyncPayload }) => void): WebSocket {
const socket = new WebSocket(buildWsUrl('/ws/time-sync'))
socket.onmessage = (event) => {