更新了自检、事件记录等功能
This commit is contained in:
parent
e3ee6e4fd0
commit
72f1ccae30
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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="获取网卡配置成功")
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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=[
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 读事件时序
|
||||
|
||||
|
||||
BIN
document/显示界面modbus协议寄存器定义.pdf
Normal file
BIN
document/显示界面modbus协议寄存器定义.pdf
Normal file
Binary file not shown.
@ -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实时弹窗+滚动显示
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
@ -187,4 +257,4 @@ onUnmounted(() => {
|
||||
font-size: 10px;
|
||||
color: #787878;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -47,6 +47,9 @@ export function getEventCodeName(eventType: number, eventCode: number): string {
|
||||
if (eventCode === 3) {
|
||||
return '频率越限'
|
||||
}
|
||||
if (eventCode === 99) {
|
||||
return '开机自检(通讯链路)异常'
|
||||
}
|
||||
return `报警事件${eventCode}`
|
||||
}
|
||||
|
||||
|
||||
@ -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) => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user