diff --git a/backend/.env b/backend/.env index d8cab6d..3ea4e48 100644 --- a/backend/.env +++ b/backend/.env @@ -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 diff --git a/backend/app/adapters/device_client.py b/backend/app/adapters/device_client.py index 4c2ae90..1eac5f1 100644 --- a/backend/app/adapters/device_client.py +++ b/backend/app/adapters/device_client.py @@ -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 diff --git a/backend/app/api/routes/config.py b/backend/app/api/routes/config.py index 56644bc..979fc5c 100644 --- a/backend/app/api/routes/config.py +++ b/backend/app/api/routes/config.py @@ -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="获取网卡配置成功") diff --git a/backend/app/core/config.py b/backend/app/core/config.py index d55473f..c72e4ce 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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" diff --git a/backend/app/main.py b/backend/app/main.py index c4a39b0..c73c160 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/schemas/platform.py b/backend/app/schemas/platform.py index 95571f1..f036291 100644 --- a/backend/app/schemas/platform.py +++ b/backend/app/schemas/platform.py @@ -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): diff --git a/backend/app/services/platform_service.py b/backend/app/services/platform_service.py index cfee0a1..1dbe564 100644 --- a/backend/app/services/platform_service.py +++ b/backend/app/services/platform_service.py @@ -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) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 4efffd8..689318b 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -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=[ diff --git a/backend/tests/test_modbus_client.py b/backend/tests/test_modbus_client.py index 1f0a9fc..ac1b180 100644 --- a/backend/tests/test_modbus_client.py +++ b/backend/tests/test_modbus_client.py @@ -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) diff --git a/document/显示界面modbus协议寄存器定义.md b/document/显示界面modbus协议寄存器定义.md index ed32e1c..43fa23f 100644 --- a/document/显示界面modbus协议寄存器定义.md +++ b/document/显示界面modbus协议寄存器定义.md @@ -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 读事件时序 diff --git a/document/显示界面modbus协议寄存器定义.pdf b/document/显示界面modbus协议寄存器定义.pdf new file mode 100644 index 0000000..474e92f Binary files /dev/null and b/document/显示界面modbus协议寄存器定义.pdf differ diff --git a/document/电气量测控平台系统需求分析及技术框架设计.md b/document/电气量测控平台系统需求分析及技术框架设计.md index 774f20e..fa65844 100644 --- a/document/电气量测控平台系统需求分析及技术框架设计.md +++ b/document/电气量测控平台系统需求分析及技术框架设计.md @@ -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实时弹窗+滚动显示 diff --git a/frontend/src/api/platform.ts b/frontend/src/api/platform.ts index 73990aa..bbb3cf9 100644 --- a/frontend/src/api/platform.ts +++ b/frontend/src/api/platform.ts @@ -22,6 +22,11 @@ export async function fetchDeviceStatus(): Promise { const response = await http.get>('/device-status') return response.data.data } + +export async function fetchStartupSelfCheck(): Promise<{ success: boolean; status: DeviceStatus }> { + const response = await http.post>('/config/device/self-check') + return response.data.data +} export async function fetchDeviceConfig(): Promise { const response = await http.get>('/config/device') return response.data.data diff --git a/frontend/src/layout/components/header.vue b/frontend/src/layout/components/header.vue index e6f9441..445d3e4 100644 --- a/frontend/src/layout/components/header.vue +++ b/frontend/src/layout/components/header.vue @@ -1,47 +1,101 @@ @@ -133,13 +196,14 @@ onUnmounted(() => { \ No newline at end of file + diff --git a/frontend/src/routes/index.ts b/frontend/src/routes/index.ts index 93c3832..de77dcc 100644 --- a/frontend/src/routes/index.ts +++ b/frontend/src/routes/index.ts @@ -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", diff --git a/frontend/src/types/platform.ts b/frontend/src/types/platform.ts index 1273f55..fb58dda 100644 --- a/frontend/src/types/platform.ts +++ b/frontend/src/types/platform.ts @@ -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 { diff --git a/frontend/src/utils/alarmEvent.ts b/frontend/src/utils/alarmEvent.ts index 0cbc9cb..27d47fe 100644 --- a/frontend/src/utils/alarmEvent.ts +++ b/frontend/src/utils/alarmEvent.ts @@ -47,6 +47,9 @@ export function getEventCodeName(eventType: number, eventCode: number): string { if (eventCode === 3) { return '频率越限' } + if (eventCode === 99) { + return '开机自检(通讯链路)异常' + } return `报警事件${eventCode}` } diff --git a/frontend/src/websocket/client.ts b/frontend/src/websocket/client.ts index 469fa2a..709f666 100644 --- a/frontend/src/websocket/client.ts +++ b/frontend/src/websocket/client.ts @@ -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) => {