Initial commit

This commit is contained in:
root 2026-05-18 09:12:14 +08:00
commit b8974dce59
64 changed files with 6407 additions and 0 deletions

50
.gitignore vendored Normal file
View File

@ -0,0 +1,50 @@
# Python
.venv/
backend/.venv/
Python/
**/__pycache__/
*.py[cod]
*.pyd
*.so
*.dll
*.dylib
*.egg-info/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
coverage.xml
htmlcov/
# Python packaging / caches
backend/.deps-cache/
backend/.pip-cache/
backend/.pip-cache-py38/
backend/UNKNOWN.egg-info/
backend/emcp_backend.egg-info/
backend/.tmp_ws_check.py
# Runtime data
backend/data/
# Environment files
.env
.env.*
# Frontend
frontend/node_modules/
frontend/dist/
frontend/.vite/
*.tsbuildinfo
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# IDE / OS
.vscode/
.idea/
*.swp
Thumbs.db
.DS_Store

View File

@ -0,0 +1,454 @@
---
name: "python-fullstack-scaffold"
description: "为 FastAPI + WebSocket + Web 前端系统生成前后端开发框架。用户需要搭建或重构 RK3568 电气量测控平台的 Python 全栈代码结构时调用。"
---
# Python Fullstack Scaffold
## 适用场景
当用户出现以下意图时,调用此技能:
- 要根据需求文档搭建 `FastAPI + WebSocket + Web前端` 的项目骨架
- 要为 `RK3568 / Ubuntu 20.04 / Python 3.8` 平台设计前后端分层结构
- 要从接口文档反推后端路由、数据模型、配置存储和前端页面模块
- 要统一实时数据、设备状态、报警事件、参数配置的代码组织方式
- 要为嵌入式 C 程序与 Python 服务之间的适配层预留稳定接口
如果用户只是修一个接口、改一个组件、补一个测试,不要调用此技能。
## 技能目标
基于“电气量测控平台系统需求分析与架构设计技术方案”,输出一套可直接落地的前后端开发框架,覆盖:
- 后端目录结构
- 前端目录结构
- 核心领域模型
- RESTful API 路由分层
- WebSocket 推送框架
- JSON/SQLite 数据存储组织
- 嵌入式适配层抽象
- 开发顺序、验收点与最小可运行骨架
## 固定技术约束
在生成方案或代码时,默认遵循以下约束,除非用户明确要求变更:
- 平台:`RK3568`,系统为 `Ubuntu 20.04`
- 后端:`Python 3.8`、`FastAPI`、`Uvicorn`
- 前端Web 前端,优先采用当前项目已有技术栈;若项目未定型,则使用轻量、易部署的前端组织方式
- 通信:`HTTP RESTful API + WebSocket`
- 配置存储:`JSON`
- 报警存储:`SQLite`
- 实时数据:仅内存缓存,不落盘
- 实时刷新周期:`0.5 秒`
- 指令下发响应目标:`1 秒内`
- 系统需支持 `7 x 24` 连续运行
## 总体架构原则
始终按下面的职责边界组织代码:
1. 硬件层:`RK3568 + AI/AO + 开关量 + 网络/串口 + 显示屏`
2. 嵌入式 C 程序层:采集、控制执行、状态监测、报警生成
3. Python 服务层:接口、业务编排、缓存、存储、推送
4. Web 界面层:配置、状态、实时数据、报警、控制
禁止把以下职责混在一起:
- 路由层里直接写文件读写和数据库细节
- WebSocket 推送逻辑散落在多个业务模块中
- 前端页面组件里直接硬编码接口路径和协议细节
- C 程序适配逻辑与业务规则耦合
## 后端推荐目录
当用户要求搭建后端框架时,优先生成或对齐为如下结构:
```text
backend/
app/
main.py
core/
config.py
logging.py
security.py
response.py
exceptions.py
api/
deps.py
router.py
routes/
realtime.py
status.py
config_device.py
config_channel.py
config_alarm.py
config_system.py
alarms.py
control.py
schemas/
common.py
realtime.py
status.py
device_config.py
channel_config.py
alarm_setting.py
system_config.py
alarm_event.py
control.py
services/
realtime_service.py
status_service.py
config_service.py
alarm_service.py
control_service.py
sync_service.py
repositories/
json_config_repo.py
alarm_repo.py
adapters/
c_device_client.py
mock_device_client.py
protocol_models.py
ws/
manager.py
realtime_publisher.py
alarm_publisher.py
db/
sqlite.py
models.py
cache/
memory_store.py
tasks/
polling.py
utils/
time_utils.py
backup.py
config/
device.json
channel.json
setting.json
data/
alarm.db
tests/
```
## 前端推荐目录
当用户要求搭建前端框架时,优先生成或对齐为如下结构:
```text
frontend/
src/
main.ts
App.vue
router/
index.ts
api/
http.ts
realtime.ts
status.ts
config.ts
alarm.ts
control.ts
websocket/
realtime.ts
alarm.ts
reconnect.ts
stores/
realtime.ts
status.ts
alarm.ts
config.ts
views/
RealtimeView.vue
StatusView.vue
DeviceConfigView.vue
ChannelConfigView.vue
AlarmSettingView.vue
AlarmHistoryView.vue
ControlView.vue
SystemConfigView.vue
components/
status/
realtime/
alarm/
config/
control/
types/
api.ts
realtime.ts
status.ts
config.ts
alarm.ts
utils/
format.ts
validators.ts
```
如果仓库已有前端目录,则优先复用现有技术栈、构建工具和状态管理方式,不要无意义重建。
## 领域模型拆分
构建代码框架时,必须先把需求拆成以下核心模型:
### 1. 实时数据模型
- `line_list`
- `switch`
- `ai_collect`
其中 `line_list` 需要体现:
- `line_no`
- 一次值 `pri_val`
- 二次值 `sec_val`
### 2. 设备状态模型
- `self_check`
- `net1`
- `net2`
- `uart1`
- `uart2`
### 3. 设备基础配置模型
- 操作密码
- 硬件版本信息
- 软件版本信息
- 网口配置
- 串口配置
### 4. 通道配置模型
- `ai_channel`
- `ao_channel`
### 5. 定值与 AI 报警设置模型
- 线路告警阈值
- 故障告警设置
- AI 通道报警设置
### 6. 系统配置模型
- 对时设置
- 亮度设置
- 屏保时间
### 7. 报警事件模型
- `alarm_type`
- `time`
- `no`
- `type`
- `content`
- `level`
### 8. 控制指令模型
- `ch`
- `action`
## API 设计规则
生成后端接口时,统一遵守以下规范:
- 所有 HTTP 接口统一响应结构:`code`、`msg`、`data`
- 路由层仅负责参数校验、调用服务、返回标准响应
- `GET /api/real-time-data` 作为实时数据备用查询接口
- `GET /api/device-status` 用于设备状态读取
- `POST /api/config/device` 持久化设备基础配置并下发设备
- `POST /api/config/channel` 持久化 AI/AO 通道配置并下发设备
- `POST /api/config/line_alarm_setting` 持久化线路定值与故障告警设置
- `POST /api/config/ai_alarm_setting` 持久化 AI 告警设置
- `POST /api/config/system` 处理对时、亮度、屏保配置,并实时下发
- `GET /api/alarm/list` 分页查询报警历史
- `POST /api/control/switch` 下发开关量控制指令
如果用户要求生成代码优先先搭出路由、Schema、Service、Repository 的闭环,再补设备适配与前端页面。
## WebSocket 设计规则
必须把 WebSocket 当作一等公民设计,不要退化为轮询替代品。
### 实时数据通道
- 地址:`/ws/real-time`
- 推送周期:`0.5 秒`
- 推送内容:`real_time`
- 数据来源:内存缓存中的最新采样结果
### 报警通道
- 地址:`/ws/alarm`
- 触发方式:报警事件产生即推送
- 推送内容:单条报警事件或标准化事件消息
### WebSocket 实现要求
- 统一连接管理器
- 支持多客户端广播
- 支持断线清理
- 前端必须具备自动重连
- 推送数据结构必须和前端类型定义一致
## 存储设计规则
### JSON 配置
以下配置默认存放在 `config/`
- `device.json`
- `channel.json`
- `setting.json`
要求:
- 保存前先做 Schema 校验
- 写入时支持原子替换
- 修改后自动备份旧版本
- 关键字段变更后调用设备下发接口
### SQLite 报警库
报警事件单独存放在 SQLite 中,至少包含:
- `id`
- `alarm_type`
- `time`
- `no`
- `type`
- `content`
- `level`
要求:
- 写入逻辑集中在 Repository
- 查询支持分页
- 前端历史页调用分页接口,不直接访问数据库
### 内存缓存
以下数据只保存在内存中:
- 实时量测数据
- 实时设备状态
- 最近待推送报警缓冲
## 嵌入式适配层规则
当用户要落地与 C 程序交互的代码框架时,必须建立设备适配层,不要让业务层直接依赖具体协议细节。
至少抽象以下能力:
- 读取实时数据
- 读取设备状态
- 读取报警事件
- 下发基础配置
- 下发通道配置
- 下发系统设置
- 下发开关控制
推荐做法:
- 定义统一客户端接口,例如 `DeviceClient`
- 提供 `MockDeviceClient` 供联调和前端开发使用
- 提供 `CDeviceClient` 作为真实嵌入式对接实现
- 通过配置切换 mock / real 模式
## 安全与可靠性规则
必须纳入以下要求:
- 密码不得明文长期存储,至少做哈希处理
- 配置接口增加基本权限校验能力
- WebSocket 支持断线重连
- 配置写入支持备份
- 服务异常场景需易于接入守护或自动重启机制
## 推荐开发顺序
当用户要求“开始搭框架”时,按以下顺序推进:
1. 先梳理接口、数据模型、目录结构
2. 再生成后端基础工程:配置、日志、异常、统一响应
3. 再生成 Schema、Repository、Service、Router
4. 再搭建内存缓存、轮询任务、WebSocket 管理器
5. 再补 SQLite 与 JSON 持久化
6. 再抽象设备适配层和 mock 数据源
7. 最后生成前端 API 封装、状态管理、页面骨架与 WebSocket 客户端
## 输出要求
调用本技能后,输出内容应尽量包含以下几项:
### 如果用户要“方案”
输出:
- 建议目录结构
- 模块职责划分
- 核心数据模型清单
- API / WebSocket 对应关系
- 开发阶段拆分
### 如果用户要“直接写代码”
输出并执行:
- 先识别现有仓库中 `backend/`、`frontend/` 的当前结构
- 尽量增量修改,不要推倒重来
- 先落最小可运行骨架,再补细节
- 优先保证接口契约、模块边界、可测试性
## 生成代码时的验收清单
生成或修改后,至少检查:
- 后端是否存在统一应用入口
- 路由、服务、仓储、适配层是否职责分离
- 是否覆盖需求文档中的全部 API
- 是否存在 `/ws/real-time``/ws/alarm`
- 实时数据是否只走内存缓存
- 报警历史是否落 SQLite
- 配置是否落 JSON
- 前端是否拆出页面、API、WebSocket、类型定义
- 是否保留 mock 联调能力
## 常用提示模板
### 模板 1搭建完整框架
```text
请基于当前仓库和需求文档,搭建 RK3568 电气量测控平台的前后端代码框架。
后端使用 FastAPI前端沿用仓库现有技术栈。
要求包含目录结构、统一响应、配置读写、SQLite 告警存储、WebSocket 实时推送、设备适配层与 mock 数据源。
```
### 模板 2只搭后端骨架
```text
请按 python-fullstack-scaffold 技能,为该项目生成后端骨架。
要求覆盖 realtime、status、config、alarm、control 五类能力,并预留嵌入式 C 程序适配层。
```
### 模板 3只搭前端骨架
```text
请按 python-fullstack-scaffold 技能,为该项目生成前端页面骨架和 API/WebSocket 接入层。
要求覆盖实时数据、设备状态、基础配置、通道配置、报警历史和控制页面。
```
## 执行提醒
在实际执行前,先确认以下信息:
- 当前仓库是否已经存在 `backend/``frontend/`
- 前端当前使用的具体技术栈和构建工具
- 后端是否已有可复用入口、配置系统和测试结构
- 用户要的是“技能文件”、 “设计方案” 还是 “直接生成代码”
若信息不足,先补充上下文;若仓库已有实现,优先做兼容式演进,而不是重建。

16
backend/README.md Normal file
View File

@ -0,0 +1,16 @@
# EMCP Backend
## 启动
```bash
pip install -e .[dev]
uvicorn app.main:app --reload
```
## 说明
- 默认使用 `MockDeviceClient` 产生实时数据和模拟告警
- 需要鉴权的接口使用请求头 `X-API-Token: admin123`
- WebSocket 通道:
- `/ws/real-time`
- `/ws/alarm`

1
backend/app/__init__.py Normal file
View File

@ -0,0 +1 @@

View File

@ -0,0 +1,157 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from datetime import datetime
from random import Random
from typing import Any, Dict, List
from app.schemas.platform import (
AlarmEvent,
ChannelConfigIn,
DeviceConfigIn,
DeviceStatus,
LineAlarmSettingIn,
LineData,
RealtimeData,
SystemConfigIn,
SwitchControlIn,
ValueGroup,
)
class DeviceClient(ABC):
@abstractmethod
def read_realtime_data(self) -> RealtimeData:
raise NotImplementedError
@abstractmethod
def read_device_status(self) -> DeviceStatus:
raise NotImplementedError
@abstractmethod
def read_alarm_events(self) -> List[AlarmEvent]:
raise NotImplementedError
@abstractmethod
def send_device_config(self, payload: DeviceConfigIn) -> Dict[str, Any]:
raise NotImplementedError
@abstractmethod
def send_channel_config(self, payload: ChannelConfigIn) -> Dict[str, Any]:
raise NotImplementedError
@abstractmethod
def send_line_alarm_setting(self, payload: LineAlarmSettingIn) -> Dict[str, Any]:
raise NotImplementedError
@abstractmethod
def send_ai_alarm_setting(self, payload: List[Dict[str, Any]]) -> Dict[str, Any]:
raise NotImplementedError
@abstractmethod
def send_system_config(self, payload: SystemConfigIn) -> Dict[str, Any]:
raise NotImplementedError
@abstractmethod
def send_switch_control(self, payload: SwitchControlIn) -> Dict[str, Any]:
raise NotImplementedError
class MockDeviceClient(DeviceClient):
def __init__(self) -> None:
self._random = Random(3568)
self._tick = 0
def _value_group(self, base_u: float, base_i: float, base_p: float) -> ValueGroup:
delta = self._tick % 5
return ValueGroup(
Ua=base_u + delta,
Ub=base_u + 2 + delta,
Uc=base_u - 1 + delta,
Ia=base_i + 0.1 * delta,
Ib=base_i - 0.1 + 0.1 * delta,
Ic=base_i + 0.2 + 0.1 * delta,
Pa=base_p + delta,
Pb=base_p - 2 + delta,
Pc=base_p + 3 + delta,
Pt=base_p * 3 + delta,
Qa=12 + delta,
Qb=11 + delta,
Qc=13 + delta,
Qt=36 + delta,
Sa=base_p + 15 + delta,
Sb=base_p + 10 + delta,
Sc=base_p + 16 + delta,
St=base_p * 3 + 44 + delta,
PFa=0.98,
PFb=0.97,
PFc=0.99,
PFt=0.98,
Uab=base_u * 1.73,
Ubc=base_u * 1.72,
Uca=base_u * 1.73,
frq=50.0,
)
def read_realtime_data(self) -> RealtimeData:
self._tick += 1
line_list = [
LineData(line_no=1, pri_val=self._value_group(6000, 150, 820), sec_val=self._value_group(57.6, 1.2, 68)),
LineData(line_no=2, pri_val=self._value_group(5990, 148, 810), sec_val=self._value_group(57.2, 1.1, 66)),
LineData(line_no=3, pri_val=self._value_group(6010, 151, 830), sec_val=self._value_group(58.1, 1.3, 70)),
LineData(line_no=4, pri_val=self._value_group(6020, 149, 825), sec_val=self._value_group(58.4, 1.2, 69)),
]
switch = {f"di{i}": int((i + self._tick) % 2 == 0) for i in range(1, 13)}
switch.update({f"do{i}": int((i + self._tick + 1) % 2 == 0) for i in range(1, 13)})
ai_collect = {f"ai{i}": round(1 + self._random.random() * 5 + (self._tick % 3) * 0.1, 2) for i in range(1, 13)}
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 "断开",
)
def read_alarm_events(self) -> List[AlarmEvent]:
if self._tick % 6 != 0:
return []
return [
AlarmEvent(
alarm_type="line_alarm",
time=datetime.now(),
no=str((self._tick // 6) % 4 + 1),
type="电压",
content="模拟告警:线路电压越限",
level="",
)
]
def send_device_config(self, payload: DeviceConfigIn) -> Dict[str, Any]:
return {"send_status": "成功", "target": "device", "items": len(payload.net) + len(payload.uart)}
def send_channel_config(self, payload: ChannelConfigIn) -> Dict[str, Any]:
return {
"send_status": "成功",
"target": "channel",
"items": len(payload.ai_channel) + len(payload.ao_channel),
}
def send_line_alarm_setting(self, payload: LineAlarmSettingIn) -> Dict[str, Any]:
return {"send_status": "成功", "target": "line_alarm", "line_no": payload.line_no}
def send_ai_alarm_setting(self, payload: List[Dict[str, Any]]) -> Dict[str, Any]:
return {"send_status": "成功", "target": "ai_alarm", "items": len(payload)}
def send_system_config(self, payload: SystemConfigIn) -> Dict[str, Any]:
return {"send_status": "成功", "target": "system", "brightness": payload.brightness}
def send_switch_control(self, payload: SwitchControlIn) -> Dict[str, Any]:
action_text = "" if payload.action == 1 else ""
return {"control_status": f"执行成功: 开关{payload.ch}{action_text}"}
class CDeviceClient(MockDeviceClient):
"""真实 C 程序接入前先复用 mock 行为,后续在此类中替换协议实现。"""

View File

@ -0,0 +1 @@

12
backend/app/api/router.py Normal file
View File

@ -0,0 +1,12 @@
from fastapi import APIRouter
from app.api.routes import alarms, auth, config, control, realtime, status
api_router = APIRouter()
api_router.include_router(realtime.router)
api_router.include_router(status.router)
api_router.include_router(auth.router)
api_router.include_router(config.router)
api_router.include_router(alarms.router)
api_router.include_router(control.router)

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,13 @@
from fastapi import APIRouter, Query
from app.core.response import success_response
from app.services.platform_service import platform_service
router = APIRouter(prefix="/alarm", tags=["alarm"])
@router.get("/list")
def list_alarms(page: int = Query(default=1, ge=1), size: int = Query(default=20, ge=1, le=100)) -> dict:
data = platform_service.list_alarms(page=page, size=size)
return success_response(data, msg="获取报警历史成功")

View File

@ -0,0 +1,16 @@
from typing import Any, Dict
from fastapi import APIRouter
from app.core.response import success_response
from app.core.security import verify_access_password
from app.schemas.platform import PasswordVerifyIn
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/verify-password")
def verify_password_api(payload: PasswordVerifyIn) -> Dict[str, Any]:
is_valid = verify_access_password(payload.password)
return success_response(is_valid, msg="密码校验完成")

View File

@ -0,0 +1,36 @@
from typing import Any, Dict, List
from fastapi import APIRouter, Depends
from app.core.response import success_response
from app.core.security import verify_api_token
from app.schemas.platform import AiAlarmSettingIn, ChannelConfigIn, DeviceConfigIn, LineAlarmSettingIn, SystemConfigIn
from app.services.platform_service import platform_service
router = APIRouter(prefix="/config", tags=["config"])
@router.post("/device", dependencies=[Depends(verify_api_token)])
def save_device_config(payload: DeviceConfigIn) -> Dict[str, Any]:
return success_response(platform_service.save_device_config(payload))
@router.post("/channel", dependencies=[Depends(verify_api_token)])
def save_channel_config(payload: ChannelConfigIn) -> Dict[str, Any]:
return success_response(platform_service.save_channel_config(payload))
@router.post("/line_alarm_setting", dependencies=[Depends(verify_api_token)])
def save_line_alarm_setting(payload: LineAlarmSettingIn) -> Dict[str, Any]:
return success_response(platform_service.save_line_alarm_setting(payload))
@router.post("/ai_alarm_setting", dependencies=[Depends(verify_api_token)])
def save_ai_alarm_setting(payload: List[AiAlarmSettingIn]) -> Dict[str, Any]:
return success_response(platform_service.save_ai_alarm_setting(payload))
@router.post("/system", dependencies=[Depends(verify_api_token)])
def save_system_config(payload: SystemConfigIn) -> Dict[str, Any]:
return success_response(platform_service.save_system_config(payload))

View File

@ -0,0 +1,14 @@
from fastapi import APIRouter, Depends
from app.core.response import success_response
from app.core.security import verify_api_token
from app.schemas.platform import SwitchControlIn
from app.services.platform_service import platform_service
router = APIRouter(prefix="/control", tags=["control"])
@router.post("/switch", dependencies=[Depends(verify_api_token)])
def switch_control(payload: SwitchControlIn) -> dict:
return success_response(platform_service.switch_control(payload))

View File

@ -0,0 +1,13 @@
from fastapi import APIRouter
from app.core.response import success_response
from app.services.platform_service import platform_service
router = APIRouter(tags=["realtime"])
@router.get("/real-time-data")
def get_realtime_data() -> dict:
data = platform_service.get_realtime_data()
return success_response(data.model_dump(), msg="获取实时数据成功")

View File

@ -0,0 +1,13 @@
from fastapi import APIRouter
from app.core.response import success_response
from app.services.platform_service import platform_service
router = APIRouter(tags=["status"])
@router.get("/device-status")
def get_device_status() -> dict:
data = platform_service.get_device_status()
return success_response(data.model_dump(), msg="获取设备状态成功")

37
backend/app/cache/memory_store.py vendored Normal file
View File

@ -0,0 +1,37 @@
from collections import deque
from dataclasses import dataclass, field
from threading import Lock
from typing import Deque, Optional
from app.schemas.platform import AlarmEvent, DeviceStatus, RealtimeData
@dataclass
class MemoryStore:
realtime_data: Optional[RealtimeData] = None
device_status: Optional[DeviceStatus] = None
recent_alarms: Deque[AlarmEvent] = field(default_factory=lambda: deque(maxlen=100))
_lock: Lock = field(default_factory=Lock)
def set_realtime(self, data: RealtimeData) -> None:
with self._lock:
self.realtime_data = data
def get_realtime(self) -> Optional[RealtimeData]:
with self._lock:
return self.realtime_data
def set_status(self, status: DeviceStatus) -> None:
with self._lock:
self.device_status = status
def get_status(self) -> Optional[DeviceStatus]:
with self._lock:
return self.device_status
def push_alarm(self, alarm: AlarmEvent) -> None:
with self._lock:
self.recent_alarms.appendleft(alarm)
memory_store = MemoryStore()

View File

@ -0,0 +1,31 @@
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
BASE_DIR = Path(__file__).resolve().parents[2]
class Settings(BaseSettings):
app_name: str = "电气量测控平台"
app_version: str = "0.1.0"
api_prefix: str = "/api"
realtime_ws_path: str = "/ws/real-time"
alarm_ws_path: str = "/ws/alarm"
poll_interval_seconds: float = 0.5
auth_password: str = "admin123"
use_mock_device: bool = True
config_dir: Path = BASE_DIR / "config"
data_dir: Path = BASE_DIR / "data"
alarm_db_path: Path = BASE_DIR / "data" / "alarm.db"
backup_dir: Path = BASE_DIR / "data" / "backups"
model_config = SettingsConfigDict(
env_prefix="EMCP_",
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
settings = Settings()

View File

@ -0,0 +1,9 @@
from typing import Any, Dict
def success_response(data: Any = None, msg: str = "操作成功", code: int = 200) -> Dict[str, Any]:
return {
"code": code,
"msg": msg,
"data": data if data is not None else {},
}

View File

@ -0,0 +1,27 @@
import hashlib
import secrets
from typing import Optional
from fastapi import Header, HTTPException, status
from app.core.config import settings
def hash_password(password: str) -> str:
return hashlib.sha256(password.encode("utf-8")).hexdigest()
def verify_password(password: str, expected_hash: str) -> bool:
return hash_password(password) == expected_hash
def verify_access_password(password: str) -> bool:
return secrets.compare_digest(password, settings.auth_password)
def verify_api_token(x_api_token: Optional[str] = Header(default=None)) -> None:
if x_api_token != settings.auth_password:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的访问令牌",
)

33
backend/app/db/sqlite.py Normal file
View File

@ -0,0 +1,33 @@
from __future__ import annotations
import sqlite3
from pathlib import Path
from typing import Optional
from app.core.config import settings
def get_connection(db_path: Optional[Path] = None) -> sqlite3.Connection:
path = db_path or settings.alarm_db_path
path.parent.mkdir(parents=True, exist_ok=True)
connection = sqlite3.connect(path)
connection.row_factory = sqlite3.Row
return connection
def init_db() -> None:
with get_connection() as connection:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS alarm_event (
id INTEGER PRIMARY KEY AUTOINCREMENT,
alarm_type TEXT NOT NULL,
time TEXT NOT NULL,
no TEXT,
type TEXT,
content TEXT NOT NULL,
level TEXT NOT NULL
)
"""
)
connection.commit()

65
backend/app/main.py Normal file
View File

@ -0,0 +1,65 @@
from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from app.api.router import api_router
from app.core.config import settings
from app.db.sqlite import init_db
from app.tasks.polling import device_polling_loop, stop_task
from app.ws.manager import ws_manager
@asynccontextmanager
async def lifespan(_: FastAPI):
init_db()
poll_task = asyncio.create_task(device_polling_loop())
try:
yield
finally:
await stop_task(poll_task)
app = FastAPI(
title=settings.app_name,
version=settings.app_version,
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router, prefix=settings.api_prefix)
@app.get("/")
def healthcheck() -> dict:
return {"status": "ok", "service": settings.app_name}
@app.websocket(settings.realtime_ws_path)
async def realtime_ws(websocket: WebSocket) -> None:
await ws_manager.connect("real-time", websocket)
try:
while True:
await websocket.receive_text()
except WebSocketDisconnect:
ws_manager.disconnect("real-time", websocket)
@app.websocket(settings.alarm_ws_path)
async def alarm_ws(websocket: WebSocket) -> None:
await ws_manager.connect("alarm", websocket)
try:
while True:
await websocket.receive_text()
except WebSocketDisconnect:
ws_manager.disconnect("alarm", websocket)

View File

@ -0,0 +1,41 @@
from __future__ import annotations
from typing import Any, Dict, List
from app.db.sqlite import get_connection
from app.schemas.platform import AlarmEvent
class AlarmRepository:
def save_alarm(self, alarm: AlarmEvent) -> int:
with get_connection() as connection:
cursor = connection.execute(
"""
INSERT INTO alarm_event (alarm_type, time, no, type, content, level)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
alarm.alarm_type,
alarm.time.isoformat(sep=" ", timespec="seconds"),
alarm.no,
alarm.type,
alarm.content,
alarm.level,
),
)
connection.commit()
return int(cursor.lastrowid)
def list_alarms(self, page: int = 1, size: int = 20) -> List[Dict[str, Any]]:
offset = (page - 1) * size
with get_connection() as connection:
cursor = connection.execute(
"""
SELECT id, alarm_type, time, no, type, content, level
FROM alarm_event
ORDER BY id DESC
LIMIT ? OFFSET ?
""",
(size, offset),
)
return [dict(row) for row in cursor.fetchall()]

View File

@ -0,0 +1,39 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
from app.core.config import settings
from app.utils.backup import backup_file
class JsonConfigRepository:
def __init__(self, config_dir: Optional[Path] = None) -> None:
self.config_dir = config_dir or settings.config_dir
self.config_dir.mkdir(parents=True, exist_ok=True)
def _write_json(self, filename: str, payload: Union[Dict[str, Any], List[Any]]) -> Path:
path = self.config_dir / filename
backup_file(path, settings.backup_dir)
temp_path = path.with_suffix(".tmp")
with temp_path.open("w", encoding="utf-8") as file:
json.dump(payload, file, ensure_ascii=False, indent=2)
temp_path.replace(path)
return path
def write_device_config(self, payload: Dict[str, Any]) -> Path:
return self._write_json("device.json", payload)
def write_channel_config(self, payload: Dict[str, Any]) -> Path:
return self._write_json("channel.json", payload)
def write_setting_config(self, payload: Dict[str, Any]) -> Path:
return self._write_json("setting.json", payload)
def read_json(self, filename: str) -> Union[Dict[str, Any], List[Any]]:
path = self.config_dir / filename
if not path.exists():
return {}
with path.open("r", encoding="utf-8") as file:
return json.load(file)

View File

@ -0,0 +1,14 @@
from typing import Any
from pydantic import BaseModel, Field
class ApiResponse(BaseModel):
code: int = 200
msg: str = "操作成功"
data: Any = Field(default_factory=dict)
class PaginationQuery(BaseModel):
page: int = Field(default=1, ge=1)
size: int = Field(default=20, ge=1, le=100)

View File

@ -0,0 +1,158 @@
from datetime import datetime
from typing import Dict, List, Optional
from pydantic import BaseModel, Field
class ValueGroup(BaseModel):
Ua: float = 0.0
Ub: float = 0.0
Uc: float = 0.0
Ia: float = 0.0
Ib: float = 0.0
Ic: float = 0.0
Pa: float = 0.0
Pb: float = 0.0
Pc: float = 0.0
Pt: float = 0.0
Qa: float = 0.0
Qb: float = 0.0
Qc: float = 0.0
Qt: float = 0.0
Sa: float = 0.0
Sb: float = 0.0
Sc: float = 0.0
St: float = 0.0
PFa: float = 0.0
PFb: float = 0.0
PFc: float = 0.0
PFt: float = 0.0
Uab: float = 0.0
Ubc: float = 0.0
Uca: float = 0.0
frq: float = 50.0
class LineData(BaseModel):
line_no: int
pri_val: ValueGroup = Field(default_factory=ValueGroup)
sec_val: ValueGroup = Field(default_factory=ValueGroup)
class RealtimeData(BaseModel):
line_list: List[LineData]
switch: Dict[str, int]
ai_collect: Dict[str, float]
class RealtimeEnvelope(BaseModel):
type: str = "real_time"
data: RealtimeData
class DeviceStatus(BaseModel):
self_check: str = "正常"
net1: str = "正常"
net2: str = "正常"
uart1: str = "正常"
uart2: str = "断开"
class HardwareVersion(BaseModel):
board_version: str = "B001.001.001"
display_version: str = "S001.001.001"
other_version: str = "Y001.001.001"
class SoftwareVersion(BaseModel):
display_program: str = "001.001.001"
communication_program: str = "001.001.001"
measurement_program: str = "001.001.001"
class NetConfigItem(BaseModel):
nic: str
ip: str = ""
mask: str = ""
gateway: str = ""
protocol: str = ""
class UartConfigItem(BaseModel):
port: str
baud: int = 9600
parity: str = "NONE"
data_bits: int = 8
stop_bits: int = 1
protocol: str = ""
class DeviceConfigIn(BaseModel):
password: str = "123456"
hardware_version: HardwareVersion = Field(default_factory=HardwareVersion)
software_version: SoftwareVersion = Field(default_factory=SoftwareVersion)
net: List[NetConfigItem] = Field(default_factory=list)
uart: List[UartConfigItem] = Field(default_factory=list)
class ChannelItem(BaseModel):
ch: int
singal_type: str
line_no: int
type: str
limit_low: float
limit_high: float
class ChannelConfigIn(BaseModel):
ai_channel: List[ChannelItem] = Field(default_factory=list)
ao_channel: List[ChannelItem] = Field(default_factory=list)
class AlarmRule(BaseModel):
category: str
limit: Optional[float] = None
delay: int = 0
output_node: str = ""
enabled: bool = True
class LineAlarmSettingIn(BaseModel):
line_no: int
over_limit_alarm: List[AlarmRule] = Field(default_factory=list)
fault_alarm: List[AlarmRule] = Field(default_factory=list)
class AiAlarmSettingIn(BaseModel):
channel_no: int
singal_type: str
limit_low: float
limit_high: float
delay: int = 0
output_node: str = ""
enabled: bool = True
class SystemConfigIn(BaseModel):
time_sync: str = "auto"
brightness: int = Field(default=80, ge=0, le=100)
screen_saver: int = Field(default=60, ge=0)
class PasswordVerifyIn(BaseModel):
password: str
class AlarmEvent(BaseModel):
id: Optional[int] = None
alarm_type: str
time: datetime
no: str = ""
type: str = ""
content: str
level: str = "一般"
class SwitchControlIn(BaseModel):
ch: int = Field(ge=1)
action: int = Field(ge=0, le=1)

View File

@ -0,0 +1,105 @@
from __future__ import annotations
from typing import Any, Dict, List
from app.adapters.device_client import CDeviceClient, MockDeviceClient
from app.cache.memory_store import memory_store
from app.core.config import settings
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 (
AiAlarmSettingIn,
ChannelConfigIn,
DeviceConfigIn,
DeviceStatus,
LineAlarmSettingIn,
RealtimeData,
SwitchControlIn,
SystemConfigIn,
)
from app.ws.manager import ws_manager
class PlatformService:
def __init__(self) -> None:
self.config_repo = JsonConfigRepository()
self.alarm_repo = AlarmRepository()
self.device_client = MockDeviceClient() if settings.use_mock_device else CDeviceClient()
def get_realtime_data(self) -> RealtimeData:
cached = memory_store.get_realtime()
if cached is not None:
return cached
realtime = self.device_client.read_realtime_data()
memory_store.set_realtime(realtime)
return realtime
def get_device_status(self) -> DeviceStatus:
cached = memory_store.get_status()
if cached is not None:
return cached
status = self.device_client.read_device_status()
memory_store.set_status(status)
return status
def save_device_config(self, payload: DeviceConfigIn) -> Dict[str, Any]:
data = payload.model_dump()
data["password"] = hash_password(payload.password)
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}
def save_channel_config(self, payload: ChannelConfigIn) -> Dict[str, Any]:
path = self.config_repo.write_channel_config(payload.model_dump())
device_result = self.device_client.send_channel_config(payload)
return {"save_path": f"/config/{path.name}", **device_result}
def save_line_alarm_setting(self, payload: LineAlarmSettingIn) -> Dict[str, Any]:
current = self.config_repo.read_json("setting.json")
if not isinstance(current, dict):
current = {}
current["line_alarm_setting"] = payload.model_dump()
path = self.config_repo.write_setting_config(current)
device_result = self.device_client.send_line_alarm_setting(payload)
return {"save_path": f"/config/{path.name}", **device_result}
def save_ai_alarm_setting(self, payload: List[AiAlarmSettingIn]) -> Dict[str, Any]:
current = self.config_repo.read_json("setting.json")
if not isinstance(current, dict):
current = {}
current["ai_alarm_setting"] = [item.model_dump() for item in payload]
path = self.config_repo.write_setting_config(current)
device_result = self.device_client.send_ai_alarm_setting(current["ai_alarm_setting"])
return {"save_path": f"/config/{path.name}", **device_result}
def save_system_config(self, payload: SystemConfigIn) -> Dict[str, Any]:
current = self.config_repo.read_json("setting.json")
if not isinstance(current, dict):
current = {}
current["system_config"] = payload.model_dump()
self.config_repo.write_setting_config(current)
return self.device_client.send_system_config(payload)
def list_alarms(self, page: int, size: int) -> List[Dict[str, Any]]:
return self.alarm_repo.list_alarms(page=page, size=size)
def switch_control(self, payload: SwitchControlIn) -> Dict[str, Any]:
return self.device_client.send_switch_control(payload)
async def poll_device_once(self) -> None:
realtime = self.device_client.read_realtime_data()
status = self.device_client.read_device_status()
alarms = self.device_client.read_alarm_events()
memory_store.set_realtime(realtime)
memory_store.set_status(status)
await ws_manager.broadcast("real-time", {"type": "real_time", "data": realtime.model_dump()})
for alarm in alarms:
alarm.id = self.alarm_repo.save_alarm(alarm)
memory_store.push_alarm(alarm)
await ws_manager.broadcast("alarm", alarm.model_dump(mode="json"))
platform_service = PlatformService()

View File

@ -0,0 +1,22 @@
from __future__ import annotations
import asyncio
import contextlib
from typing import Optional
from app.core.config import settings
from app.services.platform_service import platform_service
async def device_polling_loop() -> None:
while True:
await platform_service.poll_device_once()
await asyncio.sleep(settings.poll_interval_seconds)
async def stop_task(task: Optional[asyncio.Task]) -> None:
if task is None:
return
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task

View File

@ -0,0 +1,14 @@
from __future__ import annotations
import shutil
from datetime import datetime
from pathlib import Path
def backup_file(source: Path, backup_dir: Path) -> None:
if not source.exists():
return
backup_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
target = backup_dir / f"{source.stem}_{timestamp}{source.suffix}"
shutil.copy2(source, target)

32
backend/app/ws/manager.py Normal file
View File

@ -0,0 +1,32 @@
from __future__ import annotations
import json
from collections import defaultdict
from typing import Any, DefaultDict, Dict, List, Set
from fastapi import WebSocket
class WebSocketManager:
def __init__(self) -> None:
self._connections: DefaultDict[str, Set[WebSocket]] = defaultdict(set)
async def connect(self, channel: str, websocket: WebSocket) -> None:
await websocket.accept()
self._connections[channel].add(websocket)
def disconnect(self, channel: str, websocket: WebSocket) -> None:
self._connections[channel].discard(websocket)
async def broadcast(self, channel: str, payload: Dict[str, Any]) -> None:
disconnected: List[WebSocket] = []
for websocket in self._connections[channel]:
try:
await websocket.send_text(json.dumps(payload, ensure_ascii=False, default=str))
except Exception:
disconnected.append(websocket)
for websocket in disconnected:
self.disconnect(channel, websocket)
ws_manager = WebSocketManager()

View File

@ -0,0 +1,22 @@
{
"ai_channel": [
{
"ch": 1,
"singal_type": "4-20mA22222",
"line_no": 1,
"type": "UA",
"limit_low": 0.0,
"limit_high": 20.0
}
],
"ao_channel": [
{
"ch": 1,
"singal_type": "1-5v",
"line_no": 2,
"type": "UA",
"limit_low": 0.0,
"limit_high": 20.0
}
]
}

View File

@ -0,0 +1,47 @@
{
"password": "8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92",
"hardware_version": {
"board_version": "B001.001.002",
"display_version": "S001.001.001",
"other_version": "Y001.001.001"
},
"software_version": {
"display_program": "001.001.001",
"communication_program": "001.001.001",
"measurement_program": "001.001.001"
},
"net": [
{
"nic": "网卡一",
"ip": "192.168.1.10",
"mask": "255.255.255.0",
"gateway": "192.168.1.1",
"protocol": "Modbus TCP"
},
{
"nic": "网卡二",
"ip": "192.168.1.56",
"mask": "255.255.255.255",
"gateway": "192.168.1.56",
"protocol": "Modbus TCP"
}
],
"uart": [
{
"port": "COM1",
"baud": 9600,
"parity": "NONE",
"data_bits": 8,
"stop_bits": 1,
"protocol": ""
},
{
"port": "COM2",
"baud": 115200,
"parity": "NONE",
"data_bits": 8,
"stop_bits": 1,
"protocol": "Modbus RTU"
}
]
}

View File

@ -0,0 +1,39 @@
{
"line_alarm_setting": {
"line_no": 1,
"over_limit_alarm": [
{
"category": "??",
"limit": 180.0,
"delay": 180,
"output_node": "??1",
"enabled": true
}
],
"fault_alarm": [
{
"category": "PT??",
"limit": null,
"delay": 180,
"output_node": "??1",
"enabled": true
}
]
},
"ai_alarm_setting": [
{
"channel_no": 1,
"singal_type": "4-20mA",
"limit_low": 0.0,
"limit_high": 20.0,
"delay": 180,
"output_node": "开出1",
"enabled": true
}
],
"system_config": {
"time_sync": "manual",
"brightness": 83,
"screen_saver": 60
}
}

26
backend/pyproject.toml Normal file
View File

@ -0,0 +1,26 @@
[project]
name = "emcp-backend"
version = "0.1.0"
description = "RK3568 电气量测控平台后端骨架"
readme = "README.md"
requires-python = ">=3.8"
dependencies = [
"fastapi>=0.115,<1.0",
"uvicorn[standard]>=0.30,<1.0",
"pydantic>=2.7,<3.0",
"pydantic-settings>=2.3,<3.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.2,<9.0",
"httpx>=0.27,<1.0",
]
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]

21
backend/setup.py Normal file
View File

@ -0,0 +1,21 @@
from setuptools import find_packages, setup
setup(
name="emcp-backend",
version="0.1.0",
description="RK3568 电气量测控平台后端骨架",
packages=find_packages(),
install_requires=[
"fastapi>=0.115,<1.0",
"uvicorn[standard]>=0.30,<1.0",
"pydantic>=2.7,<3.0",
"pydantic-settings>=2.3,<3.0",
],
extras_require={
"dev": [
"pytest>=8.2,<9.0",
"httpx>=0.27,<1.0",
]
},
)

20
backend/tests/test_api.py Normal file
View File

@ -0,0 +1,20 @@
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_healthcheck() -> None:
response = client.get("/")
assert response.status_code == 200
assert response.json()["status"] == "ok"
def test_realtime_endpoint() -> None:
response = client.get("/api/real-time-data")
assert response.status_code == 200
payload = response.json()
assert payload["code"] == 200
assert "line_list" in payload["data"]

207
document/gui.h Normal file
View File

@ -0,0 +1,207 @@
#ifndef __GUI_H
#define __GUI_H
#include "time.h"
#define LINE_TOTAL_NUM 4 //线路数量
#define ANALOG_CH_NUM 12 //AI、AO模拟量通道数
/*************上行数据*************************************************小核数据 -> 大核****************************************************************************/
//实时量
typedef struct
{
//三相电压
float Ua; //A相电压
float Ub; //B相电压
float Uc; //C相电压
//三相电流
float Ia; //A相电流
float Ib; //B相电流
float Ic; //C相电流
//有功功率
float Pa;
float Pb;
float Pc;
float Pt; //总有功功率
//无功功率
float Qa;
float Qb;
float Qc;
float Qt; //总无功功率
//视在功率
float Sa;
float Sb;
float Sc;
float St; //总视在功率
//功率因数
float PFa;
float PFb;
float PFc;
float PFt; //总功率因数
//线电压
float Uab;
float Ubc;
float Uca;
//频率
float frq;
}measure_unit_t;
//B码对时时间数据
typedef union
{
uint8_t b_code[6];
struct
{
uint8_t ascii_s_unit : 4;
uint8_t ascii_s_ten : 4;
uint8_t ascii_min_unit : 4;
uint8_t ascii_min_ten : 4;
uint8_t ascii_hour_unit : 4;
uint8_t ascii_hour_ten : 4;
uint8_t ascii_m_day_unit: 4;
uint8_t ascii_m_day_ten : 4;
uint8_t ascii_month_unit: 4; //4bit
uint8_t ascii_month_ten : 4; //4bit
uint8_t ascii_year_unit : 4; //4bit
uint8_t ascii_year_ten : 4; //4bit
}time;
}b_code_t;
//开入、开出
typedef struct
{
uint16_t in; //每一位对应一个通道状态 1 = 合, 0 = 分
uint16_t out; //每一位对应一个通道状态 1 = 合, 0 = 分
}d_io_t;
//模拟量:AI AO
typedef struct
{
float in[ANALOG_CH_NUM]; //模拟量输入
float out[ANALOG_CH_NUM]; //模拟量输出
}a_io_t;
//事件
typedef struct
{
struct tm t; //事件发生时间
uint32_t ms; //事件发生的毫秒时刻
uint8_t evt; //类型0 = 无事件, 1 = 事件发生
}event_t;
//波形数据
typedef struct
{ uint32_t stamp; //时标
int16_t ch[8]; //4条线路波形数据时8个通道ua、ub、uc, ia、ib、ic、频率系数、开入量
//AI采集通道波形数据时 8个通道对应各个输入点
}ch_data_t;
//波形数据
typedef struct
{
uint8_t line_index; //线路号 0 ~ 3 = 4路电压电流采样数据4 = AI采集数据
ch_data_t dot[64]; //64个点组成一个周波
}waveform_data_t;
//小核数据 -> 大核
typedef struct
{
measure_unit_t measure_unit[LINE_TOTAL_NUM];//总共4条线路按序排列
a_io_t d_io; //模拟量输入、输出
d_io_t a_io; //数字量,开入、开出
b_code_t b_code; //B码对时
event_t event[2]; //事件标志:event[0] = pt 断线, event[1] = CT断线
waveform_data_t waveform; //实时一周波数据
}gui_in_t;
/********下行数据******************************************************大核数据 -> 小核****************************************************************************/
//界面任一参数修改,即将整个参数表更新下发一次
//串口参数设置
typedef struct
{
uint32_t baud_tate; //波特率 9600 115200默认
uint8_t parity; //校验位 0 = 无校验, 1 = 奇校验, 2 = 偶校验
uint8_t data; //数据位 0 = 8位
uint8_t stop; //停止位 0 = 1位 1 = 2位
uint8_t protocol; //通讯协议 0 = 无协议1 = modbus
}com_param_t;
//模拟通道参数值
typedef struct
{
uint8_t sig_type;//信号类型 0 = 4~20ma信号 1 = 1~5V信号
uint8_t line_sub;//所属线路 1 = 线路1 2 = 线路2 3 =线路3 4 = 线路4 0 = 装置
uint8_t att; //类别 : 0 = UA, 1 = UB, 2 = UC, 3 = UAB, 4 = UBC, 5 = UCA, 6 = P, 7 = Q, 8 = F
uint8_t u_limit; //上限值 :
uint8_t d_limit; //下限值 :
}analog_ch_param_t;
//线路定值->开出设置
typedef struct
{
// uint8_t index; //线路号: 1 = 线路1, 2 = 线路2 3 = 线路3, 4 = 线路4
uint8_t limit; //限值:
uint8_t delay; //延时: [1 ~ 10秒]
uint8_t out_num; //输出节点: [1 = 开出1、 2 = 开出2、...]
uint8_t select; //投退: [0 = 不投, 1 = 投入]
}line_param_t;
typedef struct
{
line_param_t type[7]; //7个类别电压电流差流, 功率频率PT断线CT断线
}line_type_t;
//AI 报警-> 开出设置
typedef struct
{
uint8_t u_limit; //上限值 :
uint8_t d_limit; //下限值 :
uint8_t delay; //延时: [1 ~ 10秒]
uint8_t out_num; //输出节点: [1 = 开出1、 2 = 开出2、...]
uint8_t select; //投退: [0 = 不投, 1 = 投入]
}ai_alarm_param_t;
//大核数据 -> 小核
//设置模拟量,数字量输出
typedef struct
{
struct tm t; //时间设置
d_io_t a_io; //数字量,开入、开出
com_param_t com_param[4]; //串口参数设置
line_type_t line_param[4]; //线路定值设置: 4路定值一起下发
analog_ch_param_t a_in_param[12]; //12路AI模拟输入通道设置
analog_ch_param_t a_out_param[12]; //12路AO模拟输出通道设置
ai_alarm_param_t ai_alarm_param[12];//12路AI模拟输入报警参数设置
}gui_out_t;
#endif

View File

@ -0,0 +1,422 @@
# 电气量测控平台前后端启动说明
## 1. 文档说明
本文档用于说明 `EMCP` 项目前后端的开发环境准备、依赖安装、启动步骤、访问地址以及常见问题处理方式。
项目目录:
- 根目录:`d:\Trae_space\EMCP`
- 后端目录:`d:\Trae_space\EMCP\backend`
- 前端目录:`d:\Trae_space\EMCP\frontend`
- 文档目录:`d:\Trae_space\EMCP\document`
---
## 2. 当前项目技术栈
### 2.1 后端
- 语言:`Python 3.8`
- 当前虚拟环境版本:`Python 3.8.10`
- 框架:`FastAPI`
- 服务启动:`Uvicorn`
- 配置存储:`JSON`
- 报警存储:`SQLite`
- 实时通信:`WebSocket`
### 2.2 前端
- 框架:`Vue 3`
- 构建工具:`Vite`
- HTTP 请求:`Axios`
---
## 3. 启动前准备
### 3.1 后端准备
后端目录已创建虚拟环境:
- 虚拟环境路径:`d:\Trae_space\EMCP\backend\.venv`
如虚拟环境不存在,可在 `backend` 目录重新创建:
```powershell
cd d:\Trae_space\EMCP\backend
py -3.8 -m venv .venv
```
激活虚拟环境:
```powershell
cd d:\Trae_space\EMCP\backend
.\.venv\Scripts\Activate.ps1
```
### 3.2 前端准备
确保本机已安装:
- `Node.js`
- `npm`
进入前端目录后通过 `npm` 安装依赖。
---
## 4. 首次安装依赖
### 4.1 安装后端依赖
```powershell
cd d:\Trae_space\EMCP\backend
.\.venv\Scripts\Activate.ps1
pip install -e .[dev]
```
说明:
- 当前项目已包含 `setup.py``pyproject.toml`
- 若 `pip install -e .[dev]` 因环境兼容问题失败,可先升级 `pip` 后重试:
```powershell
python -m pip install --upgrade pip
pip install -e .[dev]
```
### 4.2 安装前端依赖
```powershell
cd d:\Trae_space\EMCP\frontend
npm install
```
---
## 5. 启动后端
### 5.1 启动命令
```powershell
cd d:\Trae_space\EMCP\backend
.\.venv\Scripts\Activate.ps1
.\.venv\Scripts\python.exe -m uvicorn app.main:app --reload
```
### 5.2 启动成功标志
终端出现类似信息表示启动成功:
```text
Uvicorn running on http://127.0.0.1:8000
```
### 5.3 后端访问地址
- 健康检查:`http://127.0.0.1:8000/`
- 接口文档:`http://127.0.0.1:8000/docs`
- OpenAPI`http://127.0.0.1:8000/openapi.json`
### 5.4 后端主要接口
- 实时数据:`GET /api/real-time-data`
- 设备状态:`GET /api/device-status`
- 设备配置:`POST /api/config/device`
- 通道配置:`POST /api/config/channel`
- 线路报警设置:`POST /api/config/line_alarm_setting`
- AI 报警设置:`POST /api/config/ai_alarm_setting`
- 系统设置:`POST /api/config/system`
- 报警历史:`GET /api/alarm/list`
- 控制指令:`POST /api/control/switch`
### 5.5 WebSocket 地址
- 实时数据:`ws://127.0.0.1:8000/ws/real-time`
- 报警推送:`ws://127.0.0.1:8000/ws/alarm`
### 5.6 后端 Debug 调试启动
开发阶段如果需要断点调试后端,推荐使用以下两种方式。
#### 方式一:在 IDE 中直接调试 `uvicorn`
适用于 `Trae / VS Code / Cursor` 一类支持 `launch.json` 的编辑器。
可在工作区创建或补充调试配置文件:
- `d:\Trae_space\EMCP\.vscode\launch.json`
示例配置:
```json
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug FastAPI Backend",
"type": "python",
"request": "launch",
"module": "uvicorn",
"cwd": "d:\\Trae_space\\EMCP\\backend",
"args": [
"app.main:app",
"--host",
"127.0.0.1",
"--port",
"8000",
"--reload"
],
"jinja": true,
"justMyCode": true
}
]
}
```
使用方式:
1. 打开项目根目录 `d:\Trae_space\EMCP`
2. 选择 `Debug FastAPI Backend`
3. 启动调试
4. 在 `backend/app/` 下设置断点
说明:
- `cwd` 必须指向 `backend`,否则 `app.main:app` 可能导入失败
- 调试时建议使用后端虚拟环境解释器:`d:\Trae_space\EMCP\backend\.venv\Scripts\python.exe`
- 如果 `--reload` 导致断点命中不稳定,可临时去掉 `--reload`
#### 方式二:使用 `debugpy` 启动后再附加调试
先安装调试依赖:
```powershell
cd d:\Trae_space\EMCP\backend
.\.venv\Scripts\Activate.ps1
pip install debugpy
```
然后使用以下命令启动:
```powershell
cd d:\Trae_space\EMCP\backend
.\.venv\Scripts\Activate.ps1
python -m debugpy --listen 5678 -m uvicorn app.main:app --host 127.0.0.1 --port 8000 --reload
```
启动后可在 IDE 中使用“附加到进程/Attach”方式连接
- 主机:`127.0.0.1`
- 端口:`5678`
如果不需要热重载,也可以使用更稳定的命令:
```powershell
cd d:\Trae_space\EMCP\backend
.\.venv\Scripts\Activate.ps1
python -m debugpy --listen 5678 -m uvicorn app.main:app --host 127.0.0.1 --port 8000
```
#### Debug 调试建议
- 需要稳定断点时,优先关闭 `--reload`
- 排查接口逻辑时,建议在以下位置打断点:
- `backend/app/api/routes/`
- `backend/app/services/platform_service.py`
- `backend/app/adapters/device_client.py`
- 如果要调试模拟采集链路,可重点查看:
- `backend/app/tasks/polling.py`
- `backend/app/cache/memory_store.py`
- `backend/app/ws/manager.py`
---
## 6. 启动前端
### 6.1 启动命令
```powershell
cd d:\Trae_space\EMCP\frontend
npm run dev -- --host 127.0.0.1 --port 5173
```
### 6.2 启动成功标志
终端出现类似地址表示启动成功:
```text
Local: http://127.0.0.1:5173/
```
### 6.3 前端访问地址
- 前端页面:`http://127.0.0.1:5173/`
---
## 7. 推荐启动顺序
建议按以下顺序启动:
1. 先启动后端
2. 再启动前端
3. 浏览器打开前端地址进行联调
原因:
- 前端默认请求后端接口:`http://127.0.0.1:8000/api`
- 前端默认连接后端 WebSocket
- `ws://127.0.0.1:8000/ws/real-time`
- `ws://127.0.0.1:8000/ws/alarm`
前端接口配置位置:
- `frontend/src/api/http.ts`
默认请求头中已携带:
- `X-API-Token: admin123`
---
## 8. 模拟系统说明
当前项目后端默认启用模拟设备采集。
后端配置位置:
- `backend/app/core/config.py`
当前关键配置:
```python
use_mock_device: bool = True
poll_interval_seconds: float = 0.5
```
说明:
- `use_mock_device = True` 表示使用模拟设备数据源
- 后端会按 `0.5` 秒周期采集模拟数据
- 前端可用于联调实时数据、设备状态、报警历史、控制指令、配置保存等功能
---
## 9. 当前已打通页面
当前前端页面均已可以进入,已完成联调的页面如下:
- 实时数据
- 设备状态
- 设备配置
- 通道配置
- 报警设置
- 报警历史
- 控制指令
- 系统设置
其中:
- `设备状态` 支持手动刷新
- `报警历史` 支持分页和刷新
- `设备配置 / 通道配置 / 报警设置` 已接通真实后端提交接口
---
## 10. 常用开发命令
### 10.1 后端测试
```powershell
cd d:\Trae_space\EMCP\backend
.\.venv\Scripts\Activate.ps1
python -m pytest
```
### 10.2 前端构建
```powershell
cd d:\Trae_space\EMCP\frontend
npm run build
```
---
## 11. 常见问题
### 11.1 后端启动失败,提示类型注解错误
如果出现类似报错:
```text
TypeError: 'type' object is not subscriptable
```
原因通常是 Python 版本不兼容。当前项目要求使用:
- `Python 3.8`
请确认虚拟环境版本是否正确。
### 11.2 前端页面打不开
请检查:
- 前端服务是否已启动
- 地址是否为 `http://127.0.0.1:5173/`
- `npm install` 是否已执行成功
### 11.3 前端有页面但接口不通
请检查:
- 后端服务是否已启动
- 后端地址是否为 `http://127.0.0.1:8000`
- `frontend/src/api/http.ts``baseURL` 是否正确
### 11.4 页面能打开但实时数据不更新
请检查:
- 后端是否正常运行
- 后端轮询任务是否已启动
- WebSocket 是否可连
- 若 WebSocket 未连通,前端当前会自动回退到 HTTP 轮询读取实时数据
### 11.5 保存接口返回 401 或 403
请检查请求头中是否包含:
```text
X-API-Token: admin123
```
---
## 12. 关闭服务
在各自终端中按:
```text
Ctrl + C
```
即可停止前端或后端服务。
---
## 13. 建议
日常开发建议保持两个独立终端:
- 终端 1后端 `uvicorn`
- 终端 2前端 `vite`
如果需要后续交付或部署,可在本说明基础上继续补充:
- 生产环境启动说明
- Windows 开机自启说明
- Ubuntu / RK3568 部署说明
- Nginx 反向代理说明

View File

@ -0,0 +1,579 @@
# RK3568 设备上系统部署及使用说明
## 1. 文档目的
本文档用于说明如何在 `RK3568 + Ubuntu 20.04` 设备上部署 `EMCP` 电气量测控平台的前后端系统,并完成以下目标:
- 部署后端 `FastAPI + Uvicorn` 服务
- 部署前端静态页面
- 配置前后端开机自动启动
- 登录桌面后自动打开浏览器并访问主页面
- 给出现场快速设置和日常使用说明
---
## 2. 适用范围
适用对象:
- 硬件平台:`RK3568`
- 操作系统:`Ubuntu 20.04`
- 部署模式:前后端均部署在同一台设备本机
当前工程实际情况:
- 后端运行端口:`8000`
- 前端访问地址建议使用:`http://127.0.0.1:5173/`
- 前端默认请求后端地址:`http://127.0.0.1:8000/api`
- 前端默认请求头中携带:`X-API-Token: admin123`
- 默认安全校验密码:`admin123`
- 后端默认启用模拟采集:`use_mock_device = True`
说明:
- 当前前端代码默认调用本机 `127.0.0.1:8000` 的后端接口,因此推荐前后端部署在同一台 RK3568 设备上
- 如果后续需要将前后端拆分到不同设备,需要同步调整前端接口配置
---
## 3. 部署建议
推荐目录规划如下:
```text
/opt/emcp/
├── backend/
├── frontend/
└── scripts/
```
建议使用以下方式部署:
- 后端:`systemd` 托管 `uvicorn`
- 前端:前端执行 `npm run build` 后,用轻量静态服务对 `dist/` 目录发布
- 浏览器自启动:使用桌面自动启动项 `~/.config/autostart/*.desktop`
---
## 4. 环境准备
### 4.1 安装系统依赖
在 RK3568 设备终端执行:
```bash
sudo apt update
sudo apt install -y python3.8 python3.8-venv python3-pip curl git
sudo apt install -y nodejs npm
```
说明:
- Ubuntu 20.04 默认可直接使用 `Python 3.8`
- 如果设备上的 `nodejs` 版本过低,建议升级到 `Node.js 18 LTS` 后再安装前端依赖
### 4.2 安装浏览器
如果设备带图形桌面,建议至少安装一个浏览器:
```bash
sudo apt install -y chromium-browser
```
如果系统仓库没有 `chromium-browser`,也可以使用:
```bash
sudo apt install -y firefox
```
### 4.3 拷贝项目代码
将工程目录拷贝到设备,例如:
```bash
sudo mkdir -p /opt/emcp
sudo chown -R $USER:$USER /opt/emcp
```
然后将项目复制到:
```text
/opt/emcp/backend
/opt/emcp/frontend
```
如果是通过 `git` 拉取,也可以在 `/opt/emcp` 下执行:
```bash
git clone <你的仓库地址> /opt/emcp
```
如果项目目录本身已经包含 `backend``frontend`,则以实际目录结构为准。
---
## 5. 后端部署
### 5.1 创建虚拟环境
```bash
cd /opt/emcp/backend
python3.8 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
```
### 5.2 安装后端依赖
```bash
cd /opt/emcp/backend
source .venv/bin/activate
pip install -e .
```
如需测试或调试工具,再安装:
```bash
pip install -e .[dev]
```
### 5.3 手动启动后端验证
```bash
cd /opt/emcp/backend
source .venv/bin/activate
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000
```
验证方式:
- 本机健康检查:`http://127.0.0.1:8000/`
- 接口文档:`http://127.0.0.1:8000/docs`
如果能正常返回服务信息,说明后端部署成功。
---
## 6. 前端部署
### 6.1 安装前端依赖
```bash
cd /opt/emcp/frontend
npm install
```
### 6.2 构建前端
```bash
cd /opt/emcp/frontend
npm run build
```
构建完成后会生成:
```text
/opt/emcp/frontend/dist
```
### 6.3 手动启动前端验证
推荐使用 Python 内置静态服务快速发布:
```bash
cd /opt/emcp/frontend
python3 -m http.server 5173 --directory dist --bind 0.0.0.0
```
验证地址:
- 前端页面:`http://127.0.0.1:5173/`
说明:
- 当前前端默认调用本机后端 `http://127.0.0.1:8000/api`
- 因此前端页面只要在设备本机打开,即可直接访问后端服务
---
## 7. 首次联调检查
建议按以下顺序进行:
1. 先启动后端
2. 再启动前端静态服务
3. 浏览器打开 `http://127.0.0.1:5173/`
4. 检查实时数据、设备状态、报警历史等页面是否能正常进入
当前项目已打通页面包括:
- 实时数据
- 设备状态
- 设备配置
- 通道配置
- 报警设置
- 报警历史
- 控制指令
- 系统设置
补充说明:
- `设备状态` 页面支持刷新
- `报警历史` 页面支持分页和刷新
- 配置保存前会弹出安全校验密码输入框
- 当前默认安全校验密码为 `admin123`
---
## 8. 配置开机自动启动
推荐使用 `systemd` 管理前后端服务。
### 8.1 配置后端服务
创建文件:
```text
/etc/systemd/system/emcp-backend.service
```
内容如下:
```ini
[Unit]
Description=EMCP Backend Service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=ubuntu
WorkingDirectory=/opt/emcp/backend
ExecStart=/opt/emcp/backend/.venv/bin/python -m uvicorn app.main:app --host 0.0.0.0 --port 8000
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
```
说明:
- `User=ubuntu` 需要替换为设备实际登录用户名
- 如需切换真实采集设备,可结合 `.env` 或配置文件调整后端参数
### 8.2 配置前端服务
创建文件:
```text
/etc/systemd/system/emcp-frontend.service
```
内容如下:
```ini
[Unit]
Description=EMCP Frontend Static Service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=ubuntu
WorkingDirectory=/opt/emcp/frontend
ExecStart=/usr/bin/python3 -m http.server 5173 --directory /opt/emcp/frontend/dist --bind 0.0.0.0
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
```
### 8.3 启用开机自启
执行:
```bash
sudo systemctl daemon-reload
sudo systemctl enable emcp-backend.service
sudo systemctl enable emcp-frontend.service
sudo systemctl start emcp-backend.service
sudo systemctl start emcp-frontend.service
```
查看状态:
```bash
sudo systemctl status emcp-backend.service
sudo systemctl status emcp-frontend.service
```
查看日志:
```bash
journalctl -u emcp-backend.service -f
journalctl -u emcp-frontend.service -f
```
---
## 9. 开机自动打开浏览器访问主页面
本功能依赖图形桌面环境。若设备只启动命令行、不进入桌面,则无法自动弹出浏览器。
### 9.1 前置条件
建议提前确认以下两项:
- 设备已安装浏览器
- 设备已启用桌面自动登录,或现场人员上电后会手动登录桌面
说明:
- 只有在用户登录图形桌面后,自动启动项才会执行
- 如果希望“上电后无需人工登录就自动打开页面”,需要同时开启系统自动登录
### 9.2 创建浏览器启动脚本
创建目录:
```bash
mkdir -p /opt/emcp/scripts
```
创建脚本文件:
```text
/opt/emcp/scripts/start-emcp-browser.sh
```
内容如下:
```bash
#!/bin/bash
sleep 8
URL="http://127.0.0.1:5173/"
if command -v chromium-browser >/dev/null 2>&1; then
chromium-browser --start-fullscreen "$URL" >/dev/null 2>&1 &
elif command -v firefox >/dev/null 2>&1; then
firefox "$URL" >/dev/null 2>&1 &
else
xdg-open "$URL" >/dev/null 2>&1 &
fi
```
赋予执行权限:
```bash
chmod +x /opt/emcp/scripts/start-emcp-browser.sh
```
说明:
- `sleep 8` 用于等待前后端服务启动完成
- 如果设备性能较低,可改为 `sleep 10``sleep 15`
- 如需全屏显示,优先使用 `chromium-browser --start-fullscreen`
### 9.3 配置桌面自动启动项
创建目录:
```bash
mkdir -p ~/.config/autostart
```
创建文件:
```text
~/.config/autostart/emcp-browser.desktop
```
内容如下:
```ini
[Desktop Entry]
Type=Application
Name=EMCP Browser Autostart
Exec=/opt/emcp/scripts/start-emcp-browser.sh
X-GNOME-Autostart-enabled=true
Terminal=false
```
配置完成后,用户每次登录桌面时都会自动打开浏览器并访问:
```text
http://127.0.0.1:5173/
```
---
## 10. 快捷设置说明
这一节适合现场交付时快速操作。
### 10.1 一次性完成部署
按以下顺序操作即可:
1. 拷贝项目到 `/opt/emcp`
2. 创建后端虚拟环境并安装依赖
3. 前端执行 `npm install``npm run build`
4. 创建两个 `systemd` 服务
5. 创建浏览器自动启动脚本
6. 创建 `~/.config/autostart/emcp-browser.desktop`
7. 执行 `systemctl enable` 完成开机自启
### 10.2 最常用检查命令
检查后端状态:
```bash
sudo systemctl status emcp-backend.service
```
检查前端状态:
```bash
sudo systemctl status emcp-frontend.service
```
重启后端:
```bash
sudo systemctl restart emcp-backend.service
```
重启前端:
```bash
sudo systemctl restart emcp-frontend.service
```
重新打开浏览器主页:
```bash
/opt/emcp/scripts/start-emcp-browser.sh
```
### 10.3 修改主页地址
如果前端端口改为其他端口,例如 `8080`,需要同步修改以下两处:
- `/opt/emcp/scripts/start-emcp-browser.sh` 中的 `URL`
- 前端静态服务端口或访问地址
### 10.4 修改登录后自动打开行为
如果不希望每次登录都自动打开浏览器,可以删除或重命名:
```text
~/.config/autostart/emcp-browser.desktop
```
---
## 11. 日常使用说明
### 11.1 启动后访问方式
当设备开机完成并进入桌面后,系统会自动:
- 启动后端服务
- 启动前端静态服务
- 打开浏览器访问主页
默认主页地址:
```text
http://127.0.0.1:5173/
```
### 11.2 页面使用提示
- 实时数据页:查看模拟或采集到的实时数据
- 设备状态页:支持手动刷新状态
- 报警历史页:支持分页和刷新
- 配置类页面:保存前需要输入安全校验密码
当前默认安全校验密码:
```text
admin123
```
建议交付后尽快修改为现场正式密码。
---
## 12. 常见问题
### 12.1 浏览器没有自动打开
请检查:
- 是否进入了图形桌面
- `~/.config/autostart/emcp-browser.desktop` 是否存在
- `/opt/emcp/scripts/start-emcp-browser.sh` 是否有执行权限
- 浏览器是否已安装
### 12.2 浏览器打开了,但页面访问失败
请检查:
- 前端服务是否启动成功
- `5173` 端口是否被其他程序占用
- `/opt/emcp/frontend/dist` 是否已经构建生成
### 12.3 页面能打开,但接口不通
请检查:
- 后端服务是否启动成功
- `8000` 端口是否正常监听
- 前端默认接口地址是否仍为 `http://127.0.0.1:8000/api`
### 12.4 页面保存时报权限或校验失败
请检查:
- 默认请求头 `X-API-Token` 是否被修改
- 输入的安全校验密码是否正确
- 当前默认密码是否仍为 `admin123`
### 12.5 修改代码后页面没有变化
请检查是否重新执行了前端构建:
```bash
cd /opt/emcp/frontend
npm run build
sudo systemctl restart emcp-frontend.service
```
后端代码修改后则需要重启后端服务:
```bash
sudo systemctl restart emcp-backend.service
```
---
## 13. 推荐交付方式
建议现场交付时采用以下方式:
- 设备开机自动进入桌面
- 后端和前端均使用 `systemd` 自启动
- 浏览器自动全屏打开主页
- 保留一个终端窗口或 SSH 方式用于维护
如需进一步增强生产部署能力,后续可继续补充:
- `Nginx` 托管前端静态资源
- 反向代理后端接口
- HTTPS 证书
- 真实硬件采集驱动接入
- 自动备份配置与报警数据库

View File

@ -0,0 +1,689 @@
# 电气量测控平台系统需求分析与架构设计技术方案
**适配平台RK3568 嵌入式平台ubuntu 22.04系统)**
**技术栈FastAPI + HTTP + WebSocket + Python + Web前端**
## 一、文档概述
### 1.1 文档目的
本文档基于《电气量测控平台显示界面设计和数据接口说明》、`gui.h`头文件及项目功能描述,明确**RK3568嵌入式平台**上Python编写、Web界面展示的电气量测控配置软件的功能、性能、接口、数据存储需求完成系统架构设计为软件开发、测试及交付提供技术依据。
### 1.2 适用范围
本文档适用于**RK3568嵌入式硬件**以Python为开发语言、FastAPI为服务框架、Web为交互界面的电气量测控配置软件涵盖参数配置、实时数据交互、状态监测、报警管理等全流程功能设计。
### 1.3 参考资料
1. 《电气量测控平台显示界面设计和数据接口说明》
2. `gui.h`嵌入式程序头文件
3. 项目功能需求描述
## 二、系统需求分析
### 2.1 运行环境需求
- **硬件环境**RK3568嵌入式开发板支持Linux系统、文件系统、SQLite数据库
- **软件环境**Linux操作系统、Python 3.8、**FastAPI**、Uvicorn ASGI服务、SQLite数据库、JSON文件存储支持
- **交互环境**Web浏览器支持WebSocket、动态数据渲染、按钮交互、表格/波形展示)
### 2.2 功能需求
#### 2.2.1 参数配置管理功能
实现测控设备全维度参数配置,配置项支持**JSON文件本地存储**关键参数通过接口下发至C语言嵌入式程序具体配置项如下
1. **基础设备配置**
- 设备软件版本信息展示与配置
- 通讯参数设置:串口、网口参数配置
- 操作密码设置支持密码修改、本地JSON文件存储
2. **装置通道配置**
- AI通道设置模拟量输入配置、信号类型、所属线路、数据阈值
- AO通道设置模拟量输出配置
- 定值设置:电压/电流/功率等超限限值、检测延时、报警输出配置
3. **系统管理配置**
- 对时设置:参数实时下发嵌入式程序
- 灯光设置:屏幕亮度、屏保时间,参数实时下发嵌入式程序
#### 2.2.2 实时数据动态显示功能
1. **数据读取周期**:间隔**0.5秒**从C语言控制系统读取实时数据
2. **数据类型**模拟量、开关量、AI采集数据
3. **推送方式****WebSocket主动推送**至Web界面动态刷新展示
4. **存储规则**:动态数据仅内存缓存,**不保存到本地**
#### 2.2.3 设备状态实时监测功能
从嵌入式系统动态读取设备状态Web界面实时展示
- 装置自检状态(正常/异常)
- 网络1状态、网络2状态
- 串口1状态、串口2状态
#### 2.2.4 报警事件管理功能
1. **报警数据采集**:实时获取嵌入式设备报警事件
2. **数据存储**:报警事件保存至**SQLite数据库**
3. **展示方式****WebSocket实时推送**报警至Web界面支持弹窗提醒+历史查询
### 2.3 数据交互需求
#### 2.3.1 上行数据嵌入式系统→Python服务
- 实时数据0.5秒/次模拟量、开关量、AI采集数据
- 状态数据:装置自检、网络、串口状态
- 报警数据:实时报警事件数据
#### 2.3.2 下行数据Python服务→嵌入式系统
- 配置数据:设备参数、通道配置、定值、对时、灯光参数
- 控制指令:开关量分/合控制信号
### 2.4 数据存储需求
1. **JSON文件存储**设备版本、通讯参数、密码、AI/AO配置、定值配置等**非实时参数**
2. **SQLite数据库**:报警事件数据(支持历史查询)
3. **动态数据**:仅内存缓存,不持久化存储
### 2.5 性能需求
1. **数据刷新率**实时数据读取与WebSocket推送间隔≤0.5秒
2. **响应速度**参数配置下发、开关控制指令响应时间≤1秒
3. **稳定性**7×24小时连续运行
4. **兼容性**适配RK3568硬件兼容嵌入式C程序接口
### 2.6 通信服务需求
1. **HTTP服务**基于FastAPI提供**RESTful API**,用于参数配置、状态查询、报警查询、控制指令
2. **WebSocket服务**基于FastAPI提供双工通信用于**实时数据、报警数据主动推送**至Web界面
## 三、系统架构设计
### 3.1 总体架构
系统采用**分层架构 + 前后端分离**设计,从下到上分为:
**硬件层 → 嵌入式C程序层 → Python FastAPI服务层 → Web界面层**
```
Web界面层HTML/CSS/JS
↓↑HTTP + WebSocket
Python FastAPI服务层RESTful接口 + WebSocket推送 + 数据处理)
↓↑(内部进程/核间通讯)
嵌入式C程序层数据采集 + 控制执行)
硬件层RK3568 + 测控硬件 + 通讯模块)
```
### 3.2 分层详细设计
#### 3.2.1 硬件层
- 核心硬件RK3568嵌入式开发板
- 外设模块AI/AO采集模块、开关量模块、网络/串口模块、显示屏
#### 3.2.2 嵌入式C程序层
- 数据采集模块20ms周期采集模拟量、开关量、AI数据
- 控制执行模块:接收参数与指令,执行配置、对时、灯光、开关控制
- 状态监测模块:自检、网口、串口状态实时上报
- 报警生成模块:异常事件生成并上传
- 通讯模块与Python服务双向数据交互
#### 3.2.3 Python FastAPI服务层核心
采用**FastAPI + Uvicorn**搭建,提供**HTTP接口**和**WebSocket推送**双服务:
1. **RESTful API模块**:处理配置提交、状态查询、报警查询、控制指令
2. **WebSocket推送模块**0.5秒推送实时数据、实时报警数据到Web端
3. **参数配置模块**JSON文件读写、配置下发C程序
4. **实时数据模块**定时读取C程序数据缓存并通过WebSocket推送
5. **报警管理模块**接收报警→存储SQLite→WebSocket推送前端
6. **设备状态模块**:采集并缓存设备运行状态
#### 3.2.4 Web界面层
- 菜单区:配置、状态、数据、报警、控制入口
- 状态区:自检、网络、串口实时状态
- 主显示区:实时数据表格、配置表单、控制按钮、报警列表
- 报警区WebSocket实时弹窗+滚动显示
### 3.3 数据流向设计
1. **实时/报警数据流向**
嵌入式C程序 → Python → WebSocket主动推送 → Web界面0.5秒刷新)
2. **参数配置流向**
Web界面 → HTTP POST → Python → JSON存储 + 下发C程序
3. **状态/查询流向**
Web界面 → HTTP GET → Python → 返回数据展示
## 四、接口详细设计RESTful + WebSocket
### 4.1 RESTful API 接口详情(完整参数+返回值)
所有接口统一返回格式:
```json
{
"code": 200, // 200=成功400=参数错误500=服务异常
"msg": "操作成功",
"data": {} // 业务数据
}
```
---
#### 1. GET /api/real-time-data
**作用**获取最新一次实时数据备用主用WebSocket
**参数**:无
**返回data**
```json
{
"code": 200,
"msg": "获取实时数据成功",
"data": {
"line_list": [
{
"line_no": 1,
"pri_val": {
"Ua": 6000.0,
"Ub": 6002.5,
"Uc": 5998.3,
"Ia": 150.2,
"Ib": 148.9,
"Ic": 151.6,
"Pa": 820.5,
"Pb": 815.3,
"Pc": 825.7,
"Pt": 2461.5,
"Qa": 120.3,
"Qb": 118.6,
"Qc": 122.9,
"Qt": 361.8,
"Sa": 835.2,
"Sb": 829.7,
"Sc": 840.3,
"St": 2505.2,
"PFa": 0.98,
"PFb": 0.97,
"PFc": 0.99,
"PFt": 0.98,
"Uab": 10400.1,
"Ubc": 10395.6,
"Uca": 10398.2,
"frq": 50.00
},
"sec_val": {
"Ua": 57.6,
"Ub": 57.7,
"Uc": 57.5,
"Ia": 1.2,
"Ib": 1.18,
"Ic": 1.22,
"Pa": 68.2,
"Pb": 67.8,
"Pc": 68.6,
"Pt": 204.6,
"Qa": 9.8,
"Qb": 9.6,
"Qc": 10.1,
"Qt": 29.5,
"Sa": 69.5,
"Sb": 68.9,
"Sc": 70.1,
"St": 208.5,
"PFa": 0.98,
"PFb": 0.97,
"PFc": 0.99,
"PFt": 0.98,
"Uab": 100.0,
"Ubc": 99.8,
"Uca": 99.9,
"frq": 50.00
}
},
{
"line_no": 2,
"pri_val": {},
"sec_val": {}
},
{
"line_no": 3,
"pri_val": {},
"sec_val": {}
},
{
"line_no": 4,
"pri_val": {},
"sec_val": {}
}
],
"switch": {
"di1": 1,
"di2": 0,
"di3": 1,
"di4": 0,
"di5": 1,
"di6": 0,
"di7": 1,
"di8": 0,
"di9": 1,
"di10": 0,
"di11": 1,
"di12": 0,
"do1": 1,
"do2": 0,
"do3": 1,
"do4": 0,
"do5": 1,
"do6": 0,
"do7": 1,
"do8": 0,
"do9": 1,
"do10": 0,
"do11": 1,
"do12": 0
},
"ai_collect": {
"ai1": 4.52,
"ai2": 3.86,
"ai3": 5.21,
"ai4": 2.99,
"ai5": 6.10,
"ai6": 1.75,
"ai7": 3.22,
"ai8": 4.91,
"ai9": 2.18,
"ai10": 5.47,
"ai11": 3.06,
"ai12": 4.33
}
}
}
```
---
#### 2. GET /api/device-status
**作用**:获取设备当前运行状态
**参数**:无
**返回data**
```json
{
"self_check": "正常",
"net1": "正常",
"net2": "正常",
"uart1": "正常",
"uart2": "断开"
}
```
---
#### 3. POST /api/config/device
**作用**:提交设备基础配置(版本、通讯、密码)
**请求body参数**
```json
{
"password": "123456",
"hardware_version": {
"board_version": "B001.001.001",
"display_version": "S001.001.001",
"other_version": "Y001.001.001"
},
"software_version": {
"display_program": "001.001.001",
"communication_program": "001.001.001",
"measurement_program": "001.001.001"
},
"net": [
{
"nic": "网卡一",
"ip": "",
"mask": "",
"gateway": "",
"protocol": ""
},
{
"nic": "网卡二",
"ip": "192.168.1.56",
"mask": "255.255.255.255",
"gateway": "192.168.1.56",
"protocol": "Modbus TCP"
}
],
"uart": [
{
"port": "COM1",
"baud": 0,
"parity": "",
"data_bits": 0,
"stop_bits": 0,
"protocol": ""
},
{
"port": "COM2",
"baud": 115200,
"parity": "NONE",
"data_bits": 8,
"stop_bits": 1,
"protocol": "Modbus RTU"
}
]
}
```
**返回data**`{"save_path":"/config/device.json","send_status":"成功"}`
---
#### 4. POST /api/config/channel
**作用**提交AI/AO通道配置(AI12通道AO12通道)
**请求body参数**
```json
{
"ai_channel": [{"ch":1,"singal_type":"4-20mA","line_no":1,"type":"UA","limit_low":0,"limit_high":20}],
"ao_channel": [{"ch":1,"singal_type":"1-5v","line_no":2,"type":"UA","limit_low":0,"limit_high":20}]
}
```
**返回data**`{"save_path":"/config/channel.json","send_status":"成功"}`
---
#### 5. POST /api/config/line_alarm_setting
**作用**:提交定值报警阈值配置
**请求body参数**
```json
{
"line_no": 1,
"over_limit_alarm": [
{
"category": "电压",
"limit": 180,
"delay": 180,
"output_node": "开出1",
"enabled": true
},
{
"category": "电流",
"limit": 180,
"delay": 180,
"output_node": "开出1",
"enabled": true
},
{
"category": "差流",
"limit": 180,
"delay": 180,
"output_node": "开出1",
"enabled": false
},
{
"category": "功率",
"limit": 180,
"delay": 180,
"output_node": "开出1",
"enabled": true
},
{
"category": "频率",
"limit": 180,
"delay": 180,
"output_node": "开出1",
"enabled": false
}
],
"fault_alarm": [
{
"category": "PT断线",
"delay": 180,
"output_node": "开出1",
"enabled": true
},
{
"category": "CT断线",
"delay": 180,
"output_node": "开出1",
"enabled": false
}
]
}
```
**返回data**`{"save_path":"/config/setting.json","send_status":"成功"}`
---
#### 5. POST /api/config/ai_alarm_setting
**作用**提交AI报警设置
**请求body参数**
```json
[
{
"channel_no": 1,
"singal_type": "4-20mA",
"limit_low":0,
"limit_high":20,
"delay": 180,
"output_node": "开出1",
"enabled": true
},
{
"channel_no": 2,
"singal_type": "1-5v",
"limit_low":0,
"limit_high":20,
"delay": 180,
"output_node": "开出1",
"enabled": true
},
]
```
**返回data**`{"save_path":"/config/setting.json","send_status":"成功"}`
#### 6. POST /api/config/system
**作用**:提交系统对时、灯光配置
**请求body参数**
```json
{
"time_sync": "auto",
"brightness": 80,
"screen_saver": 60
}
```
**返回data**`{"send_status":"成功"}`
---
#### 7. GET /api/alarm/list
**作用**:分页查询历史报警
**参数**`page=1&size=20`
**返回data**
```json
[
{
"id": 1,
"alarm_type": "operate_alarm",
"time": "2025-01-01 12:00:00",
"no": "",
"type": "",
"content": "告警内容",
"level": "告警等级"
}
]
```
---
#### 8. POST /api/control/switch
**作用**:下发开关量控制指令
**请求body参数**
```json
{"ch":1,"action":1} // 1=合0=分
```
**返回data**`{"control_status":"执行成功"}`
---
### 4.2 WebSocket 接口(主动推送)
#### 1. WebSocket 地址:`ws://ip:port/ws/real-time`
**推送内容**实时数据0.5秒/次)
```json
{
"type": "real_time",
"data": {
"line_list": [
{
"line_no": 1,
"pri_val": {
"Ua": 6000.0,
"Ub": 6002.5,
"Uc": 5998.3,
"Ia": 150.2,
"Ib": 148.9,
"Ic": 151.6,
"Pa": 820.5,
"Pb": 815.3,
"Pc": 825.7,
"Pt": 2461.5,
"Qa": 120.3,
"Qb": 118.6,
"Qc": 122.9,
"Qt": 361.8,
"Sa": 835.2,
"Sb": 829.7,
"Sc": 840.3,
"St": 2505.2,
"PFa": 0.98,
"PFb": 0.97,
"PFc": 0.99,
"PFt": 0.98,
"Uab": 10400.1,
"Ubc": 10395.6,
"Uca": 10398.2,
"frq": 50.00
},
"sec_val": {
"Ua": 57.6,
"Ub": 57.7,
"Uc": 57.5,
"Ia": 1.2,
"Ib": 1.18,
"Ic": 1.22,
"Pa": 68.2,
"Pb": 67.8,
"Pc": 68.6,
"Pt": 204.6,
"Qa": 9.8,
"Qb": 9.6,
"Qc": 10.1,
"Qt": 29.5,
"Sa": 69.5,
"Sb": 68.9,
"Sc": 70.1,
"St": 208.5,
"PFa": 0.98,
"PFb": 0.97,
"PFc": 0.99,
"PFt": 0.98,
"Uab": 100.0,
"Ubc": 99.8,
"Uca": 99.9,
"frq": 50.00
}
},
{
"line_no": 2,
"pri_val": {},
"sec_val": {}
},
{
"line_no": 3,
"pri_val": {},
"sec_val": {}
},
{
"line_no": 4,
"pri_val": {},
"sec_val": {}
}
],
"switch": {
"di1": 1,
"di2": 0,
"di3": 1,
"di4": 0,
"di5": 1,
"di6": 0,
"di7": 1,
"di8": 0,
"di9": 1,
"di10": 0,
"di11": 1,
"di12": 0,
"do1": 1,
"do2": 0,
"do3": 1,
"do4": 0,
"do5": 1,
"do6": 0,
"do7": 1,
"do8": 0,
"do9": 1,
"do10": 0,
"do11": 1,
"do12": 0
},
"ai_collect": {
"ai1": 4.52,
"ai2": 3.86,
"ai3": 5.21,
"ai4": 2.99,
"ai5": 6.10,
"ai6": 1.75,
"ai7": 3.22,
"ai8": 4.91,
"ai9": 2.18,
"ai10": 5.47,
"ai11": 3.06,
"ai12": 4.33
}
}
}
```
#### 2. WebSocket 地址:`ws://ip:port/ws/alarm`
**推送内容**:实时报警事件(触发即推)
```json
{
"alarm_type": "line_alarm",
"time": "2025-01-01 12:00:00",
"no": "1",
"type": "",
"content": "告警内容",
"level": "告警等级"
},
{
"alarm_type": "ai_alarm",
"time": "2025-01-01 12:00:00",
"no": "2",
"type": "",
"content": "告警内容",
"level": "告警等级"
},
{
"alarm_type": "operate_alarm",
"time": "2025-01-01 12:00:00",
"no": "3",
"type": "",
"content": "告警内容",
"level": "告警等级"
}
```
## 五、数据存储设计
### 5.1 JSON配置文件
- 路径:`/config/`
- 文件:`device.json`、`channel.json`、`setting.json`
### 5.2 SQLite报警数据库
表:`alarm_event`
| 字段 | 类型 | 说明 |
|------|------|------|
| id | INTEGER | 主键 |
| alarm_type | TEXT | 告警类型line_alarm,ai_alarm,operate_alarm |
| time | DATETIME | 事件发生时间 |
| no | TEXT | 线路号/通道号 |
| type | TEXT | 类型 |
| content | TEXT | 告警详情 |
| level | TEXT | 告警等级 |
## 六、安全与可靠性设计
1. **密码加密存储**,接口权限校验
2. WebSocket断线自动重连
3. 配置文件修改自动备份
4. 服务异常自动重启
## 七、总结
本方案基于**RK3568平台**,采用**FastAPI + HTTP + WebSocket**架构,满足电气量测控平台全部需求:
- RESTful API 负责配置、查询、控制
- WebSocket 负责实时数据、报警数据主动推送
- JSON 存储配置SQLite 存储报警
- 0.5秒实时刷新,界面无卡顿、数据不丢失
系统架构轻量化、高性能,完全适配嵌入式平台运行。
---

12
frontend/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>电气量测控平台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1832
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
frontend/package.json Normal file
View File

@ -0,0 +1,21 @@
{
"name": "emcp-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.2",
"vue": "^3.5.13"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "^5.7.3",
"vite": "^6.0.5",
"vue-tsc": "^2.2.0"
}
}

54
frontend/src/App.vue Normal file
View File

@ -0,0 +1,54 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { usePlatformStore } from './stores/platform'
import RealtimeView from './views/RealtimeView.vue'
import StatusView from './views/StatusView.vue'
import DeviceConfigView from './views/DeviceConfigView.vue'
import ChannelConfigView from './views/ChannelConfigView.vue'
import AlarmSettingView from './views/AlarmSettingView.vue'
import AlarmHistoryView from './views/AlarmHistoryView.vue'
import ControlView from './views/ControlView.vue'
import SystemConfigView from './views/SystemConfigView.vue'
const store = usePlatformStore()
const activeTab = ref('realtime')
const tabs = [
{ key: 'realtime', label: '实时数据', component: RealtimeView },
{ key: 'status', label: '设备状态', component: StatusView },
{ key: 'device', label: '设备配置', component: DeviceConfigView },
{ key: 'channel', label: '通道配置', component: ChannelConfigView },
{ key: 'alarm-setting', label: '报警设置', component: AlarmSettingView },
{ key: 'alarms', label: '报警历史', component: AlarmHistoryView },
{ key: 'control', label: '控制指令', component: ControlView },
{ key: 'system', label: '系统设置', component: SystemConfigView },
]
const currentComponent = computed(() => tabs.find((item) => item.key === activeTab.value)?.component ?? RealtimeView)
onMounted(() => {
void store.bootstrap()
})
</script>
<template>
<main class="app-shell">
<header class="page-header">
<h1>电气量测控平台</h1>
<p>RK3568 + FastAPI + WebSocket + Vue 的前后端开发框架骨架</p>
</header>
<section class="tabs">
<button
v-for="tab in tabs"
:key="tab.key"
:class="{ active: tab.key === activeTab }"
@click="activeTab = tab.key"
>
{{ tab.label }}
</button>
</section>
<component :is="currentComponent" :store="store.state" :actions="store" />
</main>
</template>

10
frontend/src/api/http.ts Normal file
View File

@ -0,0 +1,10 @@
import axios from 'axios'
export const http = axios.create({
baseURL: 'http://127.0.0.1:8000/api',
timeout: 5000,
headers: {
'Content-Type': 'application/json',
'X-API-Token': 'admin123',
},
})

View File

@ -0,0 +1,71 @@
import { http } from './http'
import type {
AiAlarmSettingItem,
AlarmEvent,
ApiResponse,
ChannelConfigPayload,
DeviceConfigPayload,
DeviceStatus,
LineAlarmSettingPayload,
RealtimeData,
SystemConfigPayload,
} from '../types/platform'
export async function fetchRealtimeData(): Promise<RealtimeData> {
const response = await http.get<ApiResponse<RealtimeData>>('/real-time-data')
return response.data.data
}
export async function fetchDeviceStatus(): Promise<DeviceStatus> {
const response = await http.get<ApiResponse<DeviceStatus>>('/device-status')
return response.data.data
}
export async function fetchAlarmHistory(page = 1, size = 20): Promise<AlarmEvent[]> {
const response = await http.get<ApiResponse<AlarmEvent[]>>(`/alarm/list?page=${page}&size=${size}`)
return response.data.data
}
export async function verifyAccessPassword(password: string): Promise<boolean> {
const response = await http.post<ApiResponse<boolean>>('/auth/verify-password', { password })
return response.data.data
}
export async function saveDeviceConfig(payload: DeviceConfigPayload): Promise<ApiResponse<{ save_path: string; send_status: string }>> {
const response = await http.post<ApiResponse<{ save_path: string; send_status: string }>>('/config/device', payload)
return response.data
}
export async function saveChannelConfig(
payload: ChannelConfigPayload,
): Promise<ApiResponse<{ save_path: string; send_status: string }>> {
const response = await http.post<ApiResponse<{ save_path: string; send_status: string }>>('/config/channel', payload)
return response.data
}
export async function saveLineAlarmSetting(
payload: LineAlarmSettingPayload,
): Promise<ApiResponse<{ save_path: string; send_status: string }>> {
const response = await http.post<ApiResponse<{ save_path: string; send_status: string }>>('/config/line_alarm_setting', payload)
return response.data
}
export async function saveAiAlarmSetting(
payload: AiAlarmSettingItem[],
): Promise<ApiResponse<{ save_path: string; send_status: string; target: string; items: number }>> {
const response = await http.post<ApiResponse<{ save_path: string; send_status: string; target: string; items: number }>>(
'/config/ai_alarm_setting',
payload,
)
return response.data
}
export async function controlSwitch(ch: number, action: number): Promise<ApiResponse<{ control_status: string }>> {
const response = await http.post<ApiResponse<{ control_status: string }>>('/control/switch', { ch, action })
return response.data
}
export async function saveSystemConfig(payload: SystemConfigPayload): Promise<ApiResponse<{ send_status: string }>> {
const response = await http.post<ApiResponse<{ send_status: string }>>('/config/system', payload)
return response.data
}

5
frontend/src/main.ts Normal file
View File

@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')

View File

@ -0,0 +1,142 @@
import { reactive } from 'vue'
import { fetchAlarmHistory, fetchDeviceStatus, fetchRealtimeData } from '../api/platform'
import { connectAlarm, connectRealtimeWithLifecycle } from '../websocket/client'
import type { AlarmEvent, DeviceStatus, RealtimeData } from '../types/platform'
interface PlatformState {
realtime: RealtimeData | null
status: DeviceStatus | null
alarms: AlarmEvent[]
alarmPage: number
alarmPageSize: number
alarmHasNext: boolean
alarmLoading: boolean
realtimeConnected: boolean
realtimeSource: string
realtimeUpdatedAt: string
statusLoading: boolean
statusUpdatedAt: string
alarmUpdatedAt: string
}
const state = reactive<PlatformState>({
realtime: null,
status: null,
alarms: [],
alarmPage: 1,
alarmPageSize: 20,
alarmHasNext: false,
alarmLoading: false,
realtimeConnected: false,
realtimeSource: 'init',
realtimeUpdatedAt: '',
statusLoading: false,
statusUpdatedAt: '',
alarmUpdatedAt: '',
})
let realtimeSocket: WebSocket | null = null
let alarmSocket: WebSocket | null = null
let realtimePollTimer: number | null = null
function markRealtime(data: RealtimeData, source: string) {
state.realtime = data
state.realtimeSource = source
state.realtimeUpdatedAt = new Date().toLocaleTimeString()
}
async function syncRealtimeByHttp(source = 'http-poll') {
const realtime = await fetchRealtimeData()
markRealtime(realtime, source)
}
async function refreshStatus() {
state.statusLoading = true
try {
state.status = await fetchDeviceStatus()
state.statusUpdatedAt = new Date().toLocaleTimeString()
} finally {
state.statusLoading = false
}
}
async function refreshAlarms(page = state.alarmPage) {
state.alarmLoading = true
try {
const alarms = await fetchAlarmHistory(page, state.alarmPageSize)
state.alarms = alarms
state.alarmPage = page
state.alarmHasNext = alarms.length === state.alarmPageSize
state.alarmUpdatedAt = new Date().toLocaleTimeString()
} finally {
state.alarmLoading = false
}
}
async function nextAlarmPage() {
if (!state.alarmHasNext) {
return
}
await refreshAlarms(state.alarmPage + 1)
}
async function prevAlarmPage() {
if (state.alarmPage <= 1) {
return
}
await refreshAlarms(state.alarmPage - 1)
}
async function bootstrap() {
await syncRealtimeByHttp('http-init')
await refreshStatus()
await refreshAlarms(1)
if (!realtimeSocket) {
realtimeSocket = connectRealtimeWithLifecycle(
(data) => {
state.realtimeConnected = true
markRealtime(data, 'websocket')
},
{
onOpen: () => {
state.realtimeConnected = true
},
onClose: () => {
state.realtimeConnected = false
},
onError: () => {
state.realtimeConnected = false
},
},
)
}
if (!alarmSocket) {
alarmSocket = connectAlarm((alarm) => {
if (state.alarmPage === 1) {
state.alarms = [alarm, ...state.alarms].slice(0, state.alarmPageSize)
state.alarmUpdatedAt = new Date().toLocaleTimeString()
}
})
}
if (!realtimePollTimer) {
realtimePollTimer = window.setInterval(() => {
if (!state.realtimeConnected) {
void syncRealtimeByHttp('http-fallback')
}
}, 500)
}
}
export function usePlatformStore() {
return {
state,
bootstrap,
refreshStatus,
refreshAlarms,
nextAlarmPage,
prevAlarmPage,
}
}

173
frontend/src/style.css Normal file
View File

@ -0,0 +1,173 @@
:root {
font-family: Arial, Helvetica, sans-serif;
color: #0f172a;
background: #f8fafc;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
button,
input,
select {
font: inherit;
}
.app-shell {
min-height: 100vh;
padding: 24px;
}
.page-header {
margin-bottom: 16px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
gap: 16px;
}
.panel {
background: #ffffff;
border: 1px solid #dbe3ef;
border-radius: 12px;
padding: 16px;
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.05);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.tabs button {
border: 1px solid #cbd5e1;
background: #ffffff;
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
}
.tabs button.active {
background: #2563eb;
color: #ffffff;
border-color: #2563eb;
}
.metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 12px;
}
.metric {
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 10px 12px;
background: #f8fafc;
}
.metric .label {
color: #475569;
font-size: 12px;
}
.metric .value {
font-size: 18px;
font-weight: 700;
margin-top: 4px;
}
.status-ok {
color: #15803d;
}
.status-warn {
color: #b45309;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.form-grid label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 14px;
}
.form-grid input,
.form-grid select {
padding: 8px 10px;
border: 1px solid #cbd5e1;
border-radius: 8px;
background: #fff;
}
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
border-bottom: 1px solid #e2e8f0;
padding: 8px 4px;
text-align: left;
font-size: 14px;
vertical-align: top;
}
.table input,
.table select {
width: 100%;
padding: 6px 8px;
border: 1px solid #cbd5e1;
border-radius: 8px;
background: #fff;
}
.actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.primary {
border: none;
background: #2563eb;
color: #fff;
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
}
.primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.hint {
color: #475569;
font-size: 13px;
margin-top: 12px;
}

View File

@ -0,0 +1,127 @@
export interface ValueGroup {
Ua: number
Ub: number
Uc: number
Ia: number
Ib: number
Ic: number
Pt: number
frq: number
}
export interface LineData {
line_no: number
pri_val: ValueGroup
sec_val: ValueGroup
}
export interface RealtimeData {
line_list: LineData[]
switch: Record<string, number>
ai_collect: Record<string, number>
}
export interface DeviceStatus {
self_check: string
net1: string
net2: string
uart1: string
uart2: string
}
export interface AlarmEvent {
id?: number
alarm_type: string
time: string
no: string
type: string
content: string
level: string
}
export interface ApiResponse<T> {
code: number
msg: string
data: T
}
export interface HardwareVersion {
board_version: string
display_version: string
other_version: string
}
export interface SoftwareVersion {
display_program: string
communication_program: string
measurement_program: string
}
export interface NetConfigItem {
nic: string
ip: string
mask: string
gateway: string
protocol: string
}
export interface UartConfigItem {
port: string
baud: number
parity: string
data_bits: number
stop_bits: number
protocol: string
}
export interface DeviceConfigPayload {
password: string
hardware_version: HardwareVersion
software_version: SoftwareVersion
net: NetConfigItem[]
uart: UartConfigItem[]
}
export interface ChannelItem {
ch: number
singal_type: string
line_no: number
type: string
limit_low: number
limit_high: number
}
export interface ChannelConfigPayload {
ai_channel: ChannelItem[]
ao_channel: ChannelItem[]
}
export interface AlarmRule {
category: string
limit?: number
delay: number
output_node: string
enabled: boolean
}
export interface LineAlarmSettingPayload {
line_no: number
over_limit_alarm: AlarmRule[]
fault_alarm: AlarmRule[]
}
export interface AiAlarmSettingItem {
channel_no: number
singal_type: string
limit_low: number
limit_high: number
delay: number
output_node: string
enabled: boolean
}
export interface SystemConfigPayload {
time_sync: string
brightness: number
screen_saver: number
}

View File

@ -0,0 +1,20 @@
import { verifyAccessPassword } from '../api/platform'
export async function ensureSaveAuthorized(): Promise<{ ok: boolean; message: string }> {
const password = window.prompt('请输入安全校验密码')
if (password === null) {
return { ok: false, message: '已取消安全校验' }
}
if (!password.trim()) {
return { ok: false, message: '安全校验密码不能为空' }
}
const verified = await verifyAccessPassword(password)
if (!verified) {
return { ok: false, message: '安全校验失败,密码错误' }
}
return { ok: true, message: '安全校验通过' }
}

View File

@ -0,0 +1,33 @@
<script setup lang="ts">
defineProps<{ store: any; actions: any }>()
</script>
<template>
<section class="panel">
<div class="section-header">
<h2>报警历史</h2>
<div class="actions">
<button class="primary" :disabled="store.alarmLoading" @click="actions.refreshAlarms()">
{{ store.alarmLoading ? '刷新中...' : '刷新列表' }}
</button>
</div>
</div>
<table class="table">
<thead><tr><th>类型</th><th>时间</th><th>编号</th><th>内容</th><th>级别</th></tr></thead>
<tbody>
<tr v-for="alarm in store.alarms" :key="`${alarm.time}-${alarm.no}-${alarm.content}`">
<td>{{ alarm.alarm_type }}</td>
<td>{{ alarm.time }}</td>
<td>{{ alarm.no }}</td>
<td>{{ alarm.content }}</td>
<td>{{ alarm.level }}</td>
</tr>
</tbody>
</table>
<div class="actions">
<button class="primary" :disabled="store.alarmLoading || store.alarmPage <= 1" @click="actions.prevAlarmPage()">上一页</button>
<button class="primary" :disabled="store.alarmLoading || !store.alarmHasNext" @click="actions.nextAlarmPage()">下一页</button>
</div>
<p class="hint">当前页{{ store.alarmPage }}最后刷新{{ store.alarmUpdatedAt || '--' }}</p>
</section>
</template>

View File

@ -0,0 +1,76 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { saveAiAlarmSetting, saveLineAlarmSetting } from '../api/platform'
import type { AiAlarmSettingItem, LineAlarmSettingPayload } from '../types/platform'
import { ensureSaveAuthorized } from '../utils/saveGuard'
defineProps<{ store: any; actions: any }>()
const lineAlarm = reactive<LineAlarmSettingPayload>({
line_no: 1,
over_limit_alarm: [
{ category: '电压', limit: 180, delay: 180, output_node: '开出1', enabled: true },
{ category: '电流', limit: 180, delay: 180, output_node: '开出1', enabled: true },
{ category: '差流', limit: 180, delay: 180, output_node: '开出1', enabled: false },
{ category: '频率', limit: 180, delay: 180, output_node: '开出1', enabled: false },
],
fault_alarm: [{ category: 'PT断线', delay: 180, output_node: '开出1', enabled: true }],
})
const aiAlarm = reactive<AiAlarmSettingItem[]>([
{
channel_no: 1,
singal_type: '4-20mA',
limit_low: 0,
limit_high: 20,
delay: 180,
output_node: '开出1',
enabled: true,
},
])
const result = ref('未保存')
const saving = ref(false)
async function save() {
saving.value = true
try {
const guard = await ensureSaveAuthorized()
if (!guard.ok) {
result.value = guard.message
return
}
const [lineResponse, aiResponse] = await Promise.all([saveLineAlarmSetting(lineAlarm), saveAiAlarmSetting(aiAlarm)])
result.value = `${lineResponse.msg} / ${aiResponse.msg}`
} catch (error) {
result.value = error instanceof Error ? error.message : '保存报警设置失败'
} finally {
saving.value = false
}
}
</script>
<template>
<section class="panel">
<h2>报警设置</h2>
<div class="form-grid">
<label>线路号<input v-model.number="lineAlarm.line_no" type="number" min="1" max="4" /></label>
<label>电压越限<input v-model.number="lineAlarm.over_limit_alarm[0].limit" type="number" /></label>
<label>电流越限<input v-model.number="lineAlarm.over_limit_alarm[1].limit" type="number" /></label>
<label>差流越限<input v-model.number="lineAlarm.over_limit_alarm[2].limit" type="number" /></label>
<label>频率越限<input v-model.number="lineAlarm.over_limit_alarm[3].limit" type="number" /></label>
<label>动作延时<input v-model.number="lineAlarm.over_limit_alarm[0].delay" type="number" /></label>
<label>输出节点<select v-model="lineAlarm.over_limit_alarm[0].output_node"><option>开出1</option><option>开出2</option></select></label>
<label>PT断线延时<input v-model.number="lineAlarm.fault_alarm[0].delay" type="number" /></label>
<label>AI通道号<input v-model.number="aiAlarm[0].channel_no" type="number" min="1" max="12" /></label>
<label>AI信号类型<input v-model="aiAlarm[0].singal_type" /></label>
<label>AI下限<input v-model.number="aiAlarm[0].limit_low" type="number" /></label>
<label>AI上限<input v-model.number="aiAlarm[0].limit_high" type="number" /></label>
</div>
<div class="actions">
<button class="primary" :disabled="saving" @click="save">{{ saving ? '保存中...' : '保存报警设置' }}</button>
</div>
<p>保存结果{{ result }}</p>
</section>
</template>

View File

@ -0,0 +1,67 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { saveChannelConfig } from '../api/platform'
import type { ChannelConfigPayload } from '../types/platform'
import { ensureSaveAuthorized } from '../utils/saveGuard'
defineProps<{ store: any; actions: any }>()
const form = reactive<ChannelConfigPayload>({
ai_channel: [{ ch: 1, singal_type: '4-20mA', line_no: 1, type: 'UA', limit_low: 0, limit_high: 20 }],
ao_channel: [{ ch: 1, singal_type: '1-5v', line_no: 2, type: 'UA', limit_low: 0, limit_high: 20 }],
})
const result = ref('未保存')
const saving = ref(false)
async function save() {
saving.value = true
try {
const guard = await ensureSaveAuthorized()
if (!guard.ok) {
result.value = guard.message
return
}
const response = await saveChannelConfig(form)
result.value = `${response.msg}${response.data.send_status}`
} catch (error) {
result.value = error instanceof Error ? error.message : '保存通道配置失败'
} finally {
saving.value = false
}
}
</script>
<template>
<section class="panel">
<h2>通道配置</h2>
<table class="table">
<thead>
<tr><th>通道</th><th>信号类型</th><th>线路</th><th>测量类型</th><th>下限</th><th>上限</th></tr>
</thead>
<tbody>
<tr>
<td>AI{{ form.ai_channel[0].ch }}</td>
<td><input v-model="form.ai_channel[0].singal_type" /></td>
<td><input v-model.number="form.ai_channel[0].line_no" type="number" min="1" max="4" /></td>
<td><input v-model="form.ai_channel[0].type" /></td>
<td><input v-model.number="form.ai_channel[0].limit_low" type="number" /></td>
<td><input v-model.number="form.ai_channel[0].limit_high" type="number" /></td>
</tr>
<tr>
<td>AO{{ form.ao_channel[0].ch }}</td>
<td><input v-model="form.ao_channel[0].singal_type" /></td>
<td><input v-model.number="form.ao_channel[0].line_no" type="number" min="1" max="4" /></td>
<td><input v-model="form.ao_channel[0].type" /></td>
<td><input v-model.number="form.ao_channel[0].limit_low" type="number" /></td>
<td><input v-model.number="form.ao_channel[0].limit_high" type="number" /></td>
</tr>
</tbody>
</table>
<div class="actions">
<button class="primary" :disabled="saving" @click="save">{{ saving ? '保存中...' : '保存通道配置' }}</button>
</div>
<p>保存结果{{ result }}</p>
</section>
</template>

View File

@ -0,0 +1,28 @@
<script setup lang="ts">
import { ref } from "vue"
import { controlSwitch } from "../api/platform"
defineProps<{ store: any; actions: any }>()
const channel = ref(1)
const result = ref("等待下发")
async function sendControl(action: number) {
const response = await controlSwitch(channel.value, action)
result.value = response.data.control_status
}
</script>
<template>
<section class="panel">
<h2>控制指令</h2>
<div class="form-grid">
<label>开关通道<input v-model="channel" type="number" min="1" max="12" /></label>
</div>
<div class="actions">
<button class="primary" @click="sendControl(1)">合闸</button>
<button class="primary" @click="sendControl(0)">分闸</button>
</div>
<p>执行结果{{ result }}</p>
</section>
</template>

View File

@ -0,0 +1,78 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { saveDeviceConfig } from '../api/platform'
import type { DeviceConfigPayload } from '../types/platform'
import { ensureSaveAuthorized } from '../utils/saveGuard'
defineProps<{ store: any; actions: any }>()
const form = reactive<DeviceConfigPayload>({
password: '123456',
hardware_version: {
board_version: 'B001.001.001',
display_version: 'S001.001.001',
other_version: 'Y001.001.001',
},
software_version: {
display_program: '001.001.001',
communication_program: '001.001.001',
measurement_program: '001.001.001',
},
net: [
{ nic: '网卡一', ip: '192.168.1.10', mask: '255.255.255.0', gateway: '192.168.1.1', protocol: 'Modbus TCP' },
{ nic: '网卡二', ip: '192.168.1.56', mask: '255.255.255.255', gateway: '192.168.1.56', protocol: 'Modbus TCP' },
],
uart: [
{ port: 'COM1', baud: 9600, parity: 'NONE', data_bits: 8, stop_bits: 1, protocol: '' },
{ port: 'COM2', baud: 115200, parity: 'NONE', data_bits: 8, stop_bits: 1, protocol: 'Modbus RTU' },
],
})
const result = ref('未保存')
const saving = ref(false)
async function save() {
saving.value = true
try {
const guard = await ensureSaveAuthorized()
if (!guard.ok) {
result.value = guard.message
return
}
const response = await saveDeviceConfig(form)
result.value = `${response.msg}${response.data.send_status}`
} catch (error) {
result.value = error instanceof Error ? error.message : '保存设备配置失败'
} finally {
saving.value = false
}
}
</script>
<template>
<section class="panel">
<h2>设备基础配置</h2>
<div class="form-grid">
<label>操作密码<input v-model="form.password" type="password" /></label>
<label>板卡版本<input v-model="form.hardware_version.board_version" /></label>
<label>显示版本<input v-model="form.hardware_version.display_version" /></label>
<label>其他版本<input v-model="form.hardware_version.other_version" /></label>
<label>显示程序版本<input v-model="form.software_version.display_program" /></label>
<label>通信程序版本<input v-model="form.software_version.communication_program" /></label>
<label>测量程序版本<input v-model="form.software_version.measurement_program" /></label>
<label>网卡一 IP<input v-model="form.net[0].ip" /></label>
<label>网卡一 掩码<input v-model="form.net[0].mask" /></label>
<label>网卡一 网关<input v-model="form.net[0].gateway" /></label>
<label>网卡二 IP<input v-model="form.net[1].ip" /></label>
<label>网卡二 协议<input v-model="form.net[1].protocol" /></label>
<label>串口一波特率<input v-model.number="form.uart[0].baud" type="number" /></label>
<label>串口二波特率<input v-model.number="form.uart[1].baud" type="number" /></label>
<label>串口二协议<input v-model="form.uart[1].protocol" /></label>
</div>
<div class="actions">
<button class="primary" :disabled="saving" @click="save">{{ saving ? '保存中...' : '保存设备配置' }}</button>
</div>
<p>保存结果{{ result }}</p>
</section>
</template>

View File

@ -0,0 +1,35 @@
<script setup lang="ts">
defineProps<{ store: any; actions: any }>()
</script>
<template>
<section class="panel">
<h2>实时数据</h2>
<div class="metrics" style="margin-bottom: 12px">
<div class="metric">
<div class="label">数据源</div>
<div class="value">{{ store.realtimeSource }}</div>
</div>
<div class="metric">
<div class="label">连接状态</div>
<div class="value">{{ store.realtimeConnected ? 'WebSocket 已连接' : 'HTTP 轮询回退' }}</div>
</div>
<div class="metric">
<div class="label">最后更新时间</div>
<div class="value">{{ store.realtimeUpdatedAt || '--' }}</div>
</div>
</div>
<div v-if="store.realtime" class="grid">
<div v-for="line in store.realtime.line_list" :key="line.line_no" class="panel">
<h3>线路 {{ line.line_no }}</h3>
<div class="metrics">
<div class="metric"><div class="label">一次电压 Ua</div><div class="value">{{ line.pri_val.Ua }}</div></div>
<div class="metric"><div class="label">一次电流 Ia</div><div class="value">{{ line.pri_val.Ia }}</div></div>
<div class="metric"><div class="label">总有功 Pt</div><div class="value">{{ line.pri_val.Pt }}</div></div>
<div class="metric"><div class="label">频率</div><div class="value">{{ line.pri_val.frq }}</div></div>
</div>
</div>
</div>
<p v-else>正在等待实时数据...</p>
</section>
</template>

View File

@ -0,0 +1,27 @@
<script setup lang="ts">
defineProps<{ store: any; actions: any }>()
</script>
<template>
<section class="panel">
<div class="section-header">
<h2>设备状态</h2>
<div class="actions">
<button class="primary" :disabled="store.statusLoading" @click="actions.refreshStatus()">
{{ store.statusLoading ? '刷新中...' : '刷新状态' }}
</button>
</div>
</div>
<template v-if="store.status">
<div class="metrics">
<div class="metric"><div class="label">自检</div><div class="value">{{ store.status.self_check }}</div></div>
<div class="metric"><div class="label">网口1</div><div class="value">{{ store.status.net1 }}</div></div>
<div class="metric"><div class="label">网口2</div><div class="value">{{ store.status.net2 }}</div></div>
<div class="metric"><div class="label">串口1</div><div class="value">{{ store.status.uart1 }}</div></div>
<div class="metric"><div class="label">串口2</div><div class="value">{{ store.status.uart2 }}</div></div>
</div>
<p class="hint">最后刷新{{ store.statusUpdatedAt || '--' }}</p>
</template>
<p v-else>正在加载状态...</p>
</section>
</template>

View File

@ -0,0 +1,38 @@
<script setup lang="ts">
import { reactive, ref } from "vue"
import { saveSystemConfig } from "../api/platform"
import { ensureSaveAuthorized } from "../utils/saveGuard"
defineProps<{ store: any; actions: any }>()
const form = reactive({
time_sync: "auto",
brightness: 80,
screen_saver: 60,
})
const result = ref("未保存")
async function save() {
const guard = await ensureSaveAuthorized()
if (!guard.ok) {
result.value = guard.message
return
}
const response = await saveSystemConfig(form)
result.value = response.msg
}
</script>
<template>
<section class="panel">
<h2>系统设置</h2>
<div class="form-grid">
<label>对时模式<select v-model="form.time_sync"><option value="auto">自动</option><option value="manual">手动</option></select></label>
<label>亮度<input v-model="form.brightness" type="number" min="0" max="100" /></label>
<label>屏保时间<input v-model="form.screen_saver" type="number" min="0" /></label>
</div>
<div class="actions"><button class="primary" @click="save">保存系统设置</button></div>
<p>保存结果{{ result }}</p>
</section>
</template>

View File

@ -0,0 +1,37 @@
import type { AlarmEvent, RealtimeData } from '../types/platform'
function buildWsUrl(path: string): string {
return `ws://127.0.0.1:8000${path}`
}
export function connectRealtime(onMessage: (data: RealtimeData) => void): WebSocket {
const socket = new WebSocket(buildWsUrl('/ws/real-time'))
socket.onmessage = (event) => {
const payload = JSON.parse(event.data)
onMessage(payload.data)
}
return socket
}
export function connectRealtimeWithLifecycle(
onMessage: (data: RealtimeData) => void,
handlers: {
onOpen?: () => void
onClose?: () => void
onError?: () => void
},
): WebSocket {
const socket = connectRealtime(onMessage)
socket.onopen = () => handlers.onOpen?.()
socket.onclose = () => handlers.onClose?.()
socket.onerror = () => handlers.onError?.()
return socket
}
export function connectAlarm(onMessage: (data: AlarmEvent) => void): WebSocket {
const socket = new WebSocket(buildWsUrl('/ws/alarm'))
socket.onmessage = (event) => {
onMessage(JSON.parse(event.data))
}
return socket
}

15
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2020", "DOM"],
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}

10
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
host: '0.0.0.0',
port: 5173,
},
})