chore: 初始化项目并添加 .gitignore
This commit is contained in:
commit
aa4b14181c
62
.gitignore
vendored
Normal file
62
.gitignore
vendored
Normal file
@ -0,0 +1,62 @@
|
||||
# ===== OS / Editor =====
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
*.swp
|
||||
*.swo
|
||||
*.tmp
|
||||
*.bak
|
||||
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# ===== Logs =====
|
||||
*.log
|
||||
|
||||
# ===== Python =====
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
coverage.xml
|
||||
htmlcov/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# ===== Backend (PyInstaller outputs) =====
|
||||
/backend/build/
|
||||
/backend/dist/
|
||||
/backend/venv/
|
||||
|
||||
# ===== Node / Frontend =====
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
/.turbo/
|
||||
/.cache/
|
||||
|
||||
/frontend/node_modules/
|
||||
/frontend/dist/
|
||||
/frontend/release/
|
||||
|
||||
# ===== Electron =====
|
||||
*.asar
|
||||
|
||||
61
.trae/skills/code-reviewer/SKILL.md
Normal file
61
.trae/skills/code-reviewer/SKILL.md
Normal file
@ -0,0 +1,61 @@
|
||||
---
|
||||
name: "code-reviewer"
|
||||
description: "审查代码的正确性、安全性、可维护性与一致性。用户要求“代码审查/Review”、准备合并、或你完成较大改动后调用。"
|
||||
---
|
||||
|
||||
# Code Reviewer(代码审查)
|
||||
|
||||
## 何时使用
|
||||
|
||||
- 用户明确提出“代码审查/Review/帮我看看这段代码/有没有坑”
|
||||
- 你完成了较大改动(多文件、涉及业务逻辑/安全/性能/并发/数据库/鉴权),准备交付前自检
|
||||
- 修复 bug 后,需要确认没有引入回归
|
||||
|
||||
## 输入要求(你需要我提供什么)
|
||||
|
||||
优先按可获得的信息审查,不强制全部具备:
|
||||
|
||||
- 需要审查的范围:文件路径/目录/提交 diff/粘贴代码片段
|
||||
- 目标与约束:功能期望、性能目标、兼容性、上线环境、风格偏好
|
||||
- 相关上下文:调用链入口、接口契约(请求/响应)、数据库表/索引、配置项
|
||||
|
||||
## 审查维度(检查清单)
|
||||
|
||||
### 正确性
|
||||
- 边界条件、空值、异常路径、重试/幂等、并发与竞态
|
||||
- 输入校验与错误信息一致性
|
||||
- 资源释放(文件句柄、连接、锁)、超时设置
|
||||
|
||||
### 安全性
|
||||
- 注入风险(SQL/命令/模板/路径穿越)、反序列化风险
|
||||
- 鉴权/鉴别:敏感操作是否校验权限,是否存在越权路径
|
||||
- 机密信息:日志/错误信息/返回值中是否泄露 token、密码、密钥、PII
|
||||
|
||||
### 可维护性
|
||||
- 代码结构是否清晰,职责是否单一,命名是否表达意图
|
||||
- 重复逻辑是否可抽取,是否遵循既有项目模式
|
||||
- 配置/常量是否集中管理,错误码/异常是否统一
|
||||
|
||||
### 性能与稳定性
|
||||
- 热路径复杂度、N+1、无界循环/递归、潜在阻塞 I/O
|
||||
- 缓存策略、批处理、分页、索引友好性
|
||||
- 观测性:日志粒度、指标点、错误上下文是否足够定位
|
||||
|
||||
### 风格与一致性
|
||||
- 与仓库既有格式、依赖、工具链一致(类型、lint、格式化、文件组织)
|
||||
- 公共工具与约定(例如 logger、配置、错误处理)是否复用而非自造
|
||||
|
||||
## 输出格式(你应如何给出审查结果)
|
||||
|
||||
按“可执行、可落地”的方式输出,默认用中文:
|
||||
|
||||
1. 结论摘要(风险等级:高/中/低)
|
||||
2. 必须修复(Blockers):每条包含 位置、问题、影响、建议修复
|
||||
3. 建议改进(Suggestions):同上,但不阻塞
|
||||
4. 可选优化(Nice-to-have)
|
||||
5. 若适用:给出最小改动的补丁方案(优先)或替代实现思路
|
||||
|
||||
## 约束
|
||||
|
||||
- 不凭空假设依赖已存在;若引入新依赖,先确认仓库已有或给出替代方案
|
||||
- 不输出或建议写入任何密钥/token
|
||||
86
.trae/skills/pr-reviewer/SKILL.md
Normal file
86
.trae/skills/pr-reviewer/SKILL.md
Normal file
@ -0,0 +1,86 @@
|
||||
---
|
||||
name: "pr-reviewer"
|
||||
description: "对变更/PR 做代码审查并给出可执行修改建议。用户要求代码评审、准备合并、或完成较大改动后自检时调用。"
|
||||
---
|
||||
|
||||
# PR Reviewer(变更/PR 代码审查)
|
||||
|
||||
## 目标
|
||||
|
||||
- 快速识别高风险缺陷(正确性/安全/稳定性/兼容性)
|
||||
- 以“可落地修改”为导向给出建议(位置、原因、影响、最小修复)
|
||||
- 在不引入不必要依赖的前提下,尽量复用仓库现有模式与工具
|
||||
|
||||
## 何时使用
|
||||
|
||||
- 用户要求“评审这个 PR/diff/改动”
|
||||
- 你完成了多文件或关键路径改动,准备合并前自检
|
||||
- 修复 bug 或安全问题后,需要确认无回归
|
||||
|
||||
## 你需要获取的信息(尽量自己补齐)
|
||||
|
||||
优先使用现有上下文完成评审,不强制用户补全:
|
||||
|
||||
- 变更范围:diff/文件列表/目录
|
||||
- 预期行为:功能目标、边界条件、失败语义
|
||||
- 运行环境:语言版本、部署方式、配置来源、数据库/外部依赖
|
||||
- 质量门槛:性能目标、SLA、兼容性、测试要求
|
||||
|
||||
## 审查方法(按优先级)
|
||||
|
||||
1. 先看变更入口与数据流:输入→处理→持久化/外部调用→输出
|
||||
2. 先找会导致事故的点:鉴权、注入、并发、资源泄露、回滚困难
|
||||
3. 再看可维护性:重复、耦合、命名、可读性、错误处理一致性
|
||||
4. 最后看体验与一致性:日志、可观测性、风格、文档与配置
|
||||
|
||||
## 检查清单
|
||||
|
||||
### 正确性
|
||||
|
||||
- 边界条件:空值/缺省、极端长度、时区与编码、浮点与精度
|
||||
- 错误路径:异常是否被吞、错误码与消息是否一致、重试/幂等性
|
||||
- 并发:竞态、锁粒度、事务边界、重复提交
|
||||
- 资源:连接/句柄/任务是否可控,是否有超时与取消
|
||||
|
||||
### 安全性
|
||||
|
||||
- 注入:SQL/命令/模板/路径穿越/正则 DoS
|
||||
- 鉴权:敏感操作是否校验权限与主体一致性,是否存在越权路径
|
||||
- 会话:token 处理是否安全(存储/传输/日志/错误信息)
|
||||
- 密钥:不得输出或建议写入任何密钥/token/密码/私钥/PII
|
||||
|
||||
### 稳定性与性能
|
||||
|
||||
- 热路径复杂度、N+1、无界队列/循环、阻塞 I/O
|
||||
- 缓存/分页/批处理是否合理,索引友好性
|
||||
- 外部依赖:熔断/退避/限流/超时/重试策略是否一致
|
||||
|
||||
### 可维护性与一致性
|
||||
|
||||
- 是否复用项目既有抽象(配置、logger、错误处理、领域模型)
|
||||
- 是否引入了不必要的新依赖;若必须引入,说明理由与替代方案
|
||||
- API/DTO/Schema 是否向后兼容;破坏性变更是否标注与迁移路径
|
||||
|
||||
### 测试与验证
|
||||
|
||||
- 是否覆盖关键分支与失败路径
|
||||
- 是否新增或更新了与变更一致的测试(单测/集成/端到端)
|
||||
- 是否需要补充回归用例(最小复现)
|
||||
|
||||
## 输出格式(必须可执行)
|
||||
|
||||
按以下结构输出,中文为默认:
|
||||
|
||||
1. 结论摘要(风险:高/中/低,是否建议合并:是/否/有条件)
|
||||
2. Blockers(必须修复):每条包含
|
||||
- 位置:文件 + 行号范围(若可获得)
|
||||
- 问题:一句话描述
|
||||
- 影响:会导致什么
|
||||
- 修复:最小改动建议(必要时给补丁)
|
||||
3. Suggestions(建议改进):同上,但不阻塞
|
||||
4. Tests(建议验证):列出要跑/要补的测试与覆盖点
|
||||
|
||||
## 约束
|
||||
|
||||
- 不凭空假设依赖或环境已存在;不确定时先在仓库中确认
|
||||
- 不输出或建议写入任何密钥/token
|
||||
38
DB_PERFORMANCE_ANALYSIS.md
Normal file
38
DB_PERFORMANCE_ANALYSIS.md
Normal file
@ -0,0 +1,38 @@
|
||||
# TimescaleDB 性能测试分析报告
|
||||
|
||||
基于 `test_db.py` 对 30 万条车辆仿真数据的写入与查询测试,本次性能评估结论如下:
|
||||
|
||||
## 1. 核心结论
|
||||
|
||||
**TimescaleDB 完全满足智能电动车数字孪生系统(SmartEDT)的实时采集与回放需求。**
|
||||
|
||||
- **写入能力**:单线程下 **3,500+ TPS** 的写入速度远超单车采集需求(通常单车高频信号仅需 50-100Hz,即 50-100 TPS)。即使并发接入 10-20 台车,当前配置也绰绰有余。
|
||||
- **查询响应**:**毫秒级(30ms)** 的时间切片查询能力,保证了“前端实时回放”与“大屏曲线刷新”的流畅度,不会出现卡顿。
|
||||
- **存储架构**:JSONB 混合存储方案在保持了极高灵活性的同时(0.2s 过滤查询),依然维持了优秀的查询性能,验证了“结构化字段(索引)+ 半结构化 Payload(JSONB)”建模方案的正确性。
|
||||
|
||||
## 2. 详细指标分析
|
||||
|
||||
### 2.1 写入性能 (Insertion)
|
||||
* **指标**:3,547.78 条/秒 (TPS)
|
||||
* **场景**:单连接、Batch=1000、Python `asyncpg` 驱动。
|
||||
* **分析**:
|
||||
* **满足度**:假设单车采集频率为 50Hz(即每秒 50 个数据包),当前性能理论上可支持 **70 台车同时在线采集**。
|
||||
* **瓶颈推测**:当前瓶颈主要在 Python 端的序列化与网络 IO 往返(RTT)。
|
||||
* **优化空间**:如果未来需要支持上千台车,改用 `COPY` 协议或增加并发写入 worker,吞吐量可轻松提升至 5-10 万 TPS 以上。
|
||||
|
||||
### 2.2 查询性能 (Query)
|
||||
|
||||
| 查询类型 | 耗时 | 业务场景 | 评价 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **最新数据 (Latest 1000)** | **0.0301s** | 实时大屏监控、轨迹回放 | **极优**。利用 TimescaleDB 的时间分区索引,无论历史数据多大,提取“最新 N 条”永远是毫秒级。 |
|
||||
| **全量计数 (Count)** | 0.0845s | 报表统计、数据完整性校验 | **优秀**。30万数据秒出,说明元数据管理高效。 |
|
||||
| **JSONB 内容过滤** | 0.2137s | 复杂故障诊断、特定工况筛选 | **良好**。在没有对 JSON 内部字段建索引的情况下,0.2s 扫描 30 万条 JSON 数据属于高性能表现。 |
|
||||
|
||||
## 3. 架构建议
|
||||
|
||||
基于测试结果,对后续开发提出以下建议:
|
||||
|
||||
1. **保持 JSONB 方案**:当前的表结构(`ts`, `simulation_id`, `signals(JSONB)`)在性能与灵活性之间取得了完美平衡,无需拆分更多列。
|
||||
2. **索引优化**:目前 `ts` 和 `simulation_id` 已有索引。如果未来经常需要按“车速”或“故障码”过滤,建议对 JSONB 内部高频查询字段建立 **GIN 索引**。
|
||||
* *示例 SQL*:`CREATE INDEX idx_speed ON vehicle_signals USING gin ((signals->'vehicle_speed_kmh'));`
|
||||
3. **保留策略**:建议配置 TimescaleDB 的 `retention_policy`,例如自动删除 30 天前的原始高频数据,只保留降采样后的聚合数据,以节省磁盘空间。
|
||||
64
INSTALL_DB.md
Normal file
64
INSTALL_DB.md
Normal file
@ -0,0 +1,64 @@
|
||||
# 数据库安装指南 (Windows)
|
||||
|
||||
由于本项目依赖 **TimescaleDB** 时序数据库插件,而该插件目前在 Windows 上**仅官方支持到 PostgreSQL 17**(暂未提供适配 PostgreSQL 18 的 Windows 安装包),因此我们需要安装 **PostgreSQL 17**。
|
||||
|
||||
## 方案一:使用 Docker(强烈推荐)
|
||||
|
||||
如果您已安装 Docker Desktop,这是最简单的方法,无需配置环境。
|
||||
|
||||
1. 确保 Docker Desktop 已启动。
|
||||
2. 在项目根目录打开终端,运行:
|
||||
```powershell
|
||||
docker-compose up -d
|
||||
```
|
||||
3. 完成!数据库已在端口 `5432` 启动,且已包含 TimescaleDB。
|
||||
|
||||
---
|
||||
|
||||
## 方案二:本机手动安装
|
||||
|
||||
如果您必须在 Windows 本机安装,请严格按照以下步骤操作。
|
||||
|
||||
### 第一步:安装 PostgreSQL 17
|
||||
|
||||
1. **下载**:[PostgreSQL 17.2 Windows x64 安装程序](https://get.enterprisedb.com/postgresql/postgresql-17.2-1-windows-x64.exe)
|
||||
- 或者访问官网:https://www.postgresql.org/download/windows/
|
||||
2. **安装**:
|
||||
- 运行安装程序。
|
||||
- **记住您设置的密码**(后续配置需要用到,建议设为 `postgres` 或修改项目配置)。
|
||||
- 端口保持默认 `5432`。
|
||||
- 安装目录建议保持默认(如 `C:\Program Files\PostgreSQL\17`)。
|
||||
- **Stack Builder**:安装结束后会询问是否运行 Stack Builder,**取消勾选**,我们不需要它。
|
||||
|
||||
### 第二步:安装 TimescaleDB 插件
|
||||
|
||||
1. **下载**:[TimescaleDB v2.23.0 for PostgreSQL 17 (Windows zip)](https://github.com/timescale/timescaledb/releases/download/2.23.0/timescaledb-postgresql-17-windows-amd64.zip)
|
||||
- 备用链接:访问 [GitHub Releases](https://github.com/timescale/timescaledb/releases),找到 `timescaledb-postgresql-17-windows-amd64.zip`。
|
||||
2. **解压**:
|
||||
- 将压缩包解压到一个临时文件夹。
|
||||
3. **安装**:
|
||||
- 在解压后的文件夹中找到 `setup.exe`。
|
||||
- **右键 -> 以管理员身份运行**。
|
||||
- 按照提示操作:
|
||||
- 输入 PostgreSQL 的安装路径(通常会自动检测)。
|
||||
- 输入 `postgres` 用户的密码。
|
||||
- 允许它修改 `postgresql.conf` 配置(输入 `y`)。
|
||||
4. **重启服务**:
|
||||
- 打开 Windows 服务管理器(Win+R -> `services.msc`)。
|
||||
- 找到 `postgresql-x64-17` 服务。
|
||||
- 右键 -> **重新启动**。
|
||||
|
||||
### 第三步:验证安装
|
||||
|
||||
打开项目目录下的 `tools/check_db.py`(如果不存在可手动创建测试),或者使用 `psql`:
|
||||
|
||||
```powershell
|
||||
psql -U postgres
|
||||
```
|
||||
|
||||
在 SQL 命令行中输入:
|
||||
```sql
|
||||
CREATE EXTENSION IF NOT EXISTS timescaledb;
|
||||
SELECT * FROM timescaledb_information.hypertables;
|
||||
```
|
||||
如果不报错,说明安装成功。
|
||||
66
README.md
Normal file
66
README.md
Normal file
@ -0,0 +1,66 @@
|
||||
# SmartEDT(智能电动车数字孪生系统)代码框架
|
||||
|
||||
本仓库提供一套可扩展的系统骨架代码,用于支撑《系统开发技术方案.md》中定义的后端采集处理与桌面端多屏显示能力。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
SmartEDT/
|
||||
backend/
|
||||
frontend/
|
||||
系统开发技术方案.md
|
||||
```
|
||||
|
||||
## 后端(Python 3.13.1 + FastAPI + WebSocket + PostgreSQL/TimescaleDB)
|
||||
|
||||
### 1) 环境准备
|
||||
|
||||
- 建议使用 venv:
|
||||
- `python -m venv venv`
|
||||
- `venv\Scripts\activate`
|
||||
- `pip install -r backend/requirements.txt`
|
||||
|
||||
### 2) 配置
|
||||
|
||||
- 复制示例配置:
|
||||
- `copy backend\\config.ini.example backend\\config.ini`
|
||||
- 按需修改 `[DATABASE].url`(示例密码为占位符 `CHANGE_ME`)。
|
||||
|
||||
也可以通过环境变量覆盖:
|
||||
- `SMARTEDT_DATABASE_URL`
|
||||
- `SMARTEDT_FILE_ROOT`
|
||||
- `SMARTEDT_HOST`
|
||||
- `SMARTEDT_PORT`
|
||||
|
||||
### 3) 数据库
|
||||
|
||||
需要 PostgreSQL 并安装 TimescaleDB 扩展(数据库侧操作)。后端启动时会尝试:
|
||||
- `CREATE EXTENSION IF NOT EXISTS timescaledb`
|
||||
- `create_hypertable('vehicle_signals', 'ts', if_not_exists => TRUE)`
|
||||
|
||||
### 4) 启动
|
||||
|
||||
- 开发启动:
|
||||
- `python backend/main.py --host 127.0.0.1 --port 5000 --debug`
|
||||
|
||||
接口:
|
||||
- `GET /health`
|
||||
- `POST /api/simulation/start`
|
||||
- `POST /api/simulation/{simulation_id}/stop`
|
||||
- `WS /ws`
|
||||
|
||||
## 前端(Electron + Vue)
|
||||
|
||||
### 1) 安装依赖
|
||||
|
||||
在 `frontend/` 目录下执行:
|
||||
- `npm install`
|
||||
|
||||
### 2) 开发启动(Vite + Electron)
|
||||
|
||||
- `npm run dev`
|
||||
|
||||
Electron 主进程会尝试启动后端:
|
||||
- 默认使用 `python backend/main.py`
|
||||
- 可通过 `SMARTEDT_PYTHON` 指定 Python 可执行文件路径
|
||||
|
||||
1
backend/__init__.py
Normal file
1
backend/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
1
backend/api/__init__.py
Normal file
1
backend/api/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
54
backend/api/routes.py
Normal file
54
backend/api/routes.py
Normal file
@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from backend.api.schemas import HealthResponse, SimulationStartRequest, SimulationStartResponse, SimulationStopResponse
|
||||
from backend.services.simulation_manager import SimulationManager
|
||||
from backend.utils import safe_join
|
||||
|
||||
|
||||
def get_router(simulation_manager: SimulationManager, file_root: Path) -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/health", response_model=HealthResponse)
|
||||
async def health() -> HealthResponse:
|
||||
return HealthResponse()
|
||||
|
||||
@router.get("/api/devices")
|
||||
async def devices():
|
||||
runtime = simulation_manager.current()
|
||||
return {
|
||||
"data": [
|
||||
{
|
||||
"device_id": "controlbox_01",
|
||||
"device_type": "mock_vehicle",
|
||||
"connected": bool(runtime and runtime.status == "running"),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@router.post("/api/simulation/start", response_model=SimulationStartResponse)
|
||||
async def start_simulation(body: SimulationStartRequest) -> SimulationStartResponse:
|
||||
simulation_id = await simulation_manager.start(body.model_dump())
|
||||
return SimulationStartResponse(simulation_id=simulation_id)
|
||||
|
||||
@router.post("/api/simulation/{simulation_id}/stop", response_model=SimulationStopResponse)
|
||||
async def stop_simulation(simulation_id: str) -> SimulationStopResponse:
|
||||
await simulation_manager.stop(simulation_id)
|
||||
return SimulationStopResponse(simulation_id=simulation_id, status="stopped")
|
||||
|
||||
@router.get("/files/{file_path:path}")
|
||||
async def files(file_path: str):
|
||||
try:
|
||||
resolved = safe_join(file_root, file_path)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="invalid path")
|
||||
if not resolved.exists() or not resolved.is_file():
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
return FileResponse(str(resolved))
|
||||
|
||||
return router
|
||||
|
||||
29
backend/api/schemas.py
Normal file
29
backend/api/schemas.py
Normal file
@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str = "ok"
|
||||
|
||||
|
||||
class SimulationStartRequest(BaseModel):
|
||||
scenario: str | None = None
|
||||
weather: str | None = None
|
||||
time_period: str | None = None
|
||||
max_speed_kmh: int | None = Field(default=None, ge=0, le=300)
|
||||
duration_minutes: int | None = Field(default=None, ge=1, le=360)
|
||||
driver: str | None = None
|
||||
extra: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SimulationStartResponse(BaseModel):
|
||||
simulation_id: str
|
||||
|
||||
|
||||
class SimulationStopResponse(BaseModel):
|
||||
simulation_id: str
|
||||
status: str
|
||||
|
||||
19
backend/api/ws.py
Normal file
19
backend/api/ws.py
Normal file
@ -0,0 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
|
||||
from backend.services.broadcaster import Broadcaster
|
||||
|
||||
|
||||
async def websocket_handler(ws: WebSocket, broadcaster: Broadcaster) -> None:
|
||||
await ws.accept()
|
||||
await broadcaster.add(ws)
|
||||
try:
|
||||
while True:
|
||||
await ws.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
# 客户端正常断开连接,无需打印堆栈信息
|
||||
pass
|
||||
finally:
|
||||
await broadcaster.remove(ws)
|
||||
|
||||
50
backend/build_backend.ps1
Normal file
50
backend/build_backend.ps1
Normal file
@ -0,0 +1,50 @@
|
||||
# Ensure we are in the backend directory
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
Set-Location $ScriptDir
|
||||
|
||||
Write-Host "Starting Backend Build Process..." -ForegroundColor Cyan
|
||||
|
||||
# Define paths
|
||||
$VenvPath = Join-Path $ScriptDir "venv"
|
||||
$PythonExe = Join-Path $VenvPath "Scripts\python.exe"
|
||||
$PipExe = Join-Path $VenvPath "Scripts\pip.exe"
|
||||
$PyInstallerExe = Join-Path $VenvPath "Scripts\pyinstaller.exe"
|
||||
$SpecFile = "smartedt_backend.spec"
|
||||
|
||||
# 1. Check/Create Virtual Environment
|
||||
if (-not (Test-Path $VenvPath)) {
|
||||
Write-Host "Virtual environment not found. Creating..." -ForegroundColor Yellow
|
||||
python -m venv venv
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Failed to create virtual environment."
|
||||
exit 1
|
||||
}
|
||||
} else {
|
||||
Write-Host "Using existing virtual environment." -ForegroundColor Green
|
||||
}
|
||||
|
||||
# 2. Install Dependencies
|
||||
Write-Host "Installing/Updating build dependencies..." -ForegroundColor Yellow
|
||||
& $PipExe install -r requirements.txt
|
||||
& $PipExe install -r requirements_build.txt
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Failed to install dependencies."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 3. Clean previous builds
|
||||
Write-Host "Cleaning up previous builds..." -ForegroundColor Yellow
|
||||
if (Test-Path "dist") { Remove-Item -Recurse -Force "dist" }
|
||||
if (Test-Path "build") { Remove-Item -Recurse -Force "build" }
|
||||
|
||||
# 4. Run PyInstaller
|
||||
Write-Host "Running PyInstaller..." -ForegroundColor Cyan
|
||||
& $PyInstallerExe --clean --noconfirm $SpecFile
|
||||
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host "`nBackend build successful!" -ForegroundColor Green
|
||||
Write-Host "Executable located at: $(Join-Path $ScriptDir 'dist\smartedt_backend\smartedt_backend.exe')" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Error "`nBackend build failed!"
|
||||
exit 1
|
||||
}
|
||||
1
backend/config/__init__.py
Normal file
1
backend/config/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
12
backend/config/config.ini
Normal file
12
backend/config/config.ini
Normal file
@ -0,0 +1,12 @@
|
||||
[SERVER]
|
||||
host = 0.0.0.0
|
||||
port = 5000
|
||||
debug = True
|
||||
|
||||
[FILEPATH]
|
||||
path = data
|
||||
|
||||
[DATABASE]
|
||||
url = postgresql+psycopg://smartedt:postgres@127.0.0.1:5432/smartedt
|
||||
timescaledb = True
|
||||
|
||||
101
backend/config/settings.py
Normal file
101
backend/config/settings.py
Normal file
@ -0,0 +1,101 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import configparser
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServerSettings:
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 5000
|
||||
debug: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FileSettings:
|
||||
root_path: Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DatabaseSettings:
|
||||
url: str
|
||||
timescaledb: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AppSettings:
|
||||
server: ServerSettings
|
||||
files: FileSettings
|
||||
database: DatabaseSettings
|
||||
|
||||
|
||||
import sys
|
||||
|
||||
def _find_config_file() -> Path | None:
|
||||
# Handle PyInstaller frozen state
|
||||
if getattr(sys, 'frozen', False):
|
||||
# If onefile, _MEIPASS. If onedir, executable dir or _internal
|
||||
# With onedir, the exe is in root, internal files in _internal.
|
||||
# But our spec put config in backend/config
|
||||
|
||||
# Check relative to executable
|
||||
exe_dir = Path(sys.executable).parent
|
||||
candidates = [
|
||||
exe_dir / "backend" / "config" / "config.ini",
|
||||
exe_dir / "_internal" / "backend" / "config" / "config.ini",
|
||||
exe_dir / "config.ini",
|
||||
]
|
||||
for path in candidates:
|
||||
if path.exists():
|
||||
return path
|
||||
|
||||
candidates = [
|
||||
Path(__file__).resolve().parent / "config.ini",
|
||||
Path(__file__).resolve().parents[1] / "config.ini",
|
||||
Path(__file__).resolve().parents[2] / "config.ini",
|
||||
]
|
||||
for path in candidates:
|
||||
if path.exists():
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def load_settings() -> AppSettings:
|
||||
config = configparser.ConfigParser()
|
||||
config_path = os.getenv("SMARTEDT_CONFIG")
|
||||
if config_path:
|
||||
config.read(config_path, encoding="utf-8")
|
||||
else:
|
||||
found = _find_config_file()
|
||||
if found:
|
||||
config.read(found, encoding="utf-8")
|
||||
|
||||
server = ServerSettings(
|
||||
host=config.get("SERVER", "host", fallback=os.getenv("SMARTEDT_HOST", "0.0.0.0")),
|
||||
port=config.getint("SERVER", "port", fallback=int(os.getenv("SMARTEDT_PORT", "5000"))),
|
||||
debug=config.getboolean("SERVER", "debug", fallback=os.getenv("SMARTEDT_DEBUG", "False").lower() == "true"),
|
||||
)
|
||||
|
||||
default_root = Path(os.getenv("SMARTEDT_FILE_ROOT", "data"))
|
||||
root_value = config.get("FILEPATH", "path", fallback=str(default_root))
|
||||
root_path = Path(root_value)
|
||||
if not root_path.is_absolute():
|
||||
root_path = (Path(__file__).resolve().parents[1] / root_path).resolve()
|
||||
|
||||
database_url = config.get("DATABASE", "url", fallback=os.getenv("SMARTEDT_DATABASE_URL", "")).strip()
|
||||
if not database_url:
|
||||
database_url = "postgresql+psycopg://smartedt:CHANGE_ME@127.0.0.1:5432/smartedt"
|
||||
timescaledb = config.getboolean(
|
||||
"DATABASE",
|
||||
"timescaledb",
|
||||
fallback=os.getenv("SMARTEDT_TIMESCALEDB", "True").lower() == "true",
|
||||
)
|
||||
|
||||
return AppSettings(
|
||||
server=server,
|
||||
files=FileSettings(root_path=root_path),
|
||||
database=DatabaseSettings(url=database_url, timescaledb=timescaledb),
|
||||
)
|
||||
|
||||
1
backend/database/__init__.py
Normal file
1
backend/database/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
66
backend/database/check_db.py
Normal file
66
backend/database/check_db.py
Normal file
@ -0,0 +1,66 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
def check_database():
|
||||
print("正在检查数据库连接...")
|
||||
|
||||
# 连接参数:通过环境变量覆盖(不在输出中打印密码)
|
||||
user = os.getenv("PG_USER", "smartedt")
|
||||
password = os.getenv("PG_PASSWORD", "postgres")
|
||||
host = os.getenv("PG_HOST", "127.0.0.1")
|
||||
port = os.getenv("PG_PORT", "5432")
|
||||
dbname = "smartedt"
|
||||
|
||||
try:
|
||||
import psycopg
|
||||
conn = psycopg.connect(
|
||||
dbname=dbname,
|
||||
user=user,
|
||||
password=password,
|
||||
host=host,
|
||||
port=port,
|
||||
autocommit=True,
|
||||
)
|
||||
server_version = getattr(conn.info, "server_version", None)
|
||||
print(f"✅ 成功连接到 PostgreSQL 数据库 '{dbname}' (用户: {user}, v{server_version})")
|
||||
|
||||
cur = conn.cursor()
|
||||
|
||||
# 检查 TimescaleDB 扩展是否已安装
|
||||
print("正在检查 TimescaleDB 扩展...")
|
||||
try:
|
||||
# 尝试创建扩展(如果不存在)
|
||||
cur.execute("CREATE EXTENSION IF NOT EXISTS timescaledb;")
|
||||
print("✅ TimescaleDB 扩展加载成功")
|
||||
|
||||
# 检查版本
|
||||
cur.execute("SELECT extversion FROM pg_extension WHERE extname = 'timescaledb';")
|
||||
version = cur.fetchone()
|
||||
if version:
|
||||
print(f"✅ TimescaleDB 版本: {version[0]}")
|
||||
else:
|
||||
print("❌ 未找到 TimescaleDB 版本信息")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ TimescaleDB 检查失败: {e}")
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 连接失败: {e}")
|
||||
print("\n可能有以下原因:")
|
||||
print("1. 数据库服务未启动 (请运行 'net start postgresql-x64-17')")
|
||||
print("2. 密码错误 (请设置环境变量 PG_PASSWORD)")
|
||||
print("3. 端口被占用或配置不同")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
import psycopg # noqa: F401
|
||||
except ImportError:
|
||||
print("缺少依赖 psycopg,请先安装后再运行该脚本。")
|
||||
raise
|
||||
|
||||
check_database()
|
||||
18
backend/database/engine.py
Normal file
18
backend/database/engine.py
Normal file
@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from backend.config.settings import DatabaseSettings
|
||||
|
||||
|
||||
def create_engine(settings: DatabaseSettings) -> AsyncEngine:
|
||||
return create_async_engine(
|
||||
settings.url,
|
||||
pool_pre_ping=True,
|
||||
future=True,
|
||||
)
|
||||
|
||||
|
||||
def create_session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
|
||||
return async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
224
backend/database/schema.py
Normal file
224
backend/database/schema.py
Normal file
@ -0,0 +1,224 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, BigInteger, Boolean, Column, DateTime, Float, ForeignKey, Index, String, Table, text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class Simulation(Base):
|
||||
__tablename__ = "simulations"
|
||||
|
||||
simulation_id: Mapped[str] = mapped_column(String(64), primary_key=True, comment="仿真 ID")
|
||||
status: Mapped[str] = mapped_column(String(32), index=True, comment="仿真状态(running/stopped 等)")
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True, comment="开始时间(UTC)")
|
||||
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True, comment="结束时间(UTC)")
|
||||
scenario_name: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True, comment="仿真场景名称")
|
||||
scenario_config: Mapped[dict] = mapped_column(JSON, default=dict, comment="仿真场景配置(JSON)")
|
||||
config_created_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True, comment="配置创建时间(UTC)")
|
||||
operator: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True, comment="仿真操作员")
|
||||
archived: Mapped[bool] = mapped_column(Boolean, default=False, comment="是否归档")
|
||||
|
||||
|
||||
vehicle_signals = Table(
|
||||
"sim_vehicle_signals",
|
||||
Base.metadata,
|
||||
Column("ts", DateTime(timezone=True), nullable=False, index=True, comment="信号采样时间(UTC)"),
|
||||
Column("simulation_id", String(64), nullable=False, index=True, comment="仿真 ID"),
|
||||
Column("device_id", String(64), nullable=False, index=True, comment="设备 ID"),
|
||||
Column("seq", BigInteger, nullable=False, comment="信号序列号(单仿真内递增)"),
|
||||
Column("signals", JSONB, nullable=False, comment="车辆信号载荷(JSONB)"),
|
||||
Index("idx_vehicle_signals_sim_ts", "simulation_id", "ts"),
|
||||
comment="车辆信号时序数据(TimescaleDB hypertable)",
|
||||
)
|
||||
|
||||
unity_vehicle_frames = Table(
|
||||
"sim_unity_vehicle_frames",
|
||||
Base.metadata,
|
||||
Column("ts", DateTime(timezone=True), nullable=False, index=True, comment="帧时间(UTC)"),
|
||||
Column("simulation_id", String(64), nullable=False, index=True, comment="仿真 ID"),
|
||||
Column("vehicle_id", String(64), nullable=False, index=True, comment="虚拟车辆 ID"),
|
||||
Column("seq", BigInteger, nullable=False, comment="帧序号(单仿真单车内递增)"),
|
||||
Column("pos_x", Float, nullable=False, comment="位置 X(世界坐标)"),
|
||||
Column("pos_y", Float, nullable=False, comment="位置 Y(世界坐标)"),
|
||||
Column("pos_z", Float, nullable=False, comment="位置 Z(世界坐标)"),
|
||||
Column("rot_x", Float, nullable=False, comment="旋转四元数 X"),
|
||||
Column("rot_y", Float, nullable=False, comment="旋转四元数 Y"),
|
||||
Column("rot_z", Float, nullable=False, comment="旋转四元数 Z"),
|
||||
Column("rot_w", Float, nullable=False, comment="旋转四元数 W"),
|
||||
Column("lin_vel_x", Float, nullable=True, comment="线速度 X(可选)"),
|
||||
Column("lin_vel_y", Float, nullable=True, comment="线速度 Y(可选)"),
|
||||
Column("lin_vel_z", Float, nullable=True, comment="线速度 Z(可选)"),
|
||||
Column("ang_vel_x", Float, nullable=True, comment="角速度 X(可选)"),
|
||||
Column("ang_vel_y", Float, nullable=True, comment="角速度 Y(可选)"),
|
||||
Column("ang_vel_z", Float, nullable=True, comment="角速度 Z(可选)"),
|
||||
Column("controls", JSONB, nullable=True, comment="控制量(油门/刹车/方向/档位等,JSONB)"),
|
||||
Column("extra", JSONB, nullable=True, comment="扩展字段(仿真引擎自定义,JSONB)"),
|
||||
Index("idx_unity_frames_sim_vehicle_ts", "simulation_id", "vehicle_id", "ts"),
|
||||
comment="虚拟车辆驱动仿真帧数据(用于 Unity 车辆模型运动与回放,TimescaleDB hypertable)",
|
||||
)
|
||||
|
||||
screen_recordings = Table(
|
||||
"sim_screen_videos",
|
||||
Base.metadata,
|
||||
Column("video_id", String(64), primary_key=True, comment="录制文件记录 ID"),
|
||||
Column("simulation_id", String(64), nullable=False, index=True, comment="仿真 ID"),
|
||||
Column("screen_type", String(32), nullable=False, index=True, comment="屏幕类型(big_screen/vehicle_screen 等)"),
|
||||
Column("source_name", String(64), nullable=True, index=True, comment="录制源名称(可选,如设备号/通道号)"),
|
||||
Column("status", String(32), nullable=False, index=True, comment="状态(recording/ready/failed 等)"),
|
||||
Column("relative_path", String(1024), nullable=False, comment="相对文件根目录的路径(用于下载/归档)"),
|
||||
Column("file_name", String(255), nullable=True, comment="文件名(可选)"),
|
||||
Column("format", String(32), nullable=True, comment="容器格式(mp4/mkv 等)"),
|
||||
Column("codec", String(64), nullable=True, comment="编码信息(H264/H265 等)"),
|
||||
Column("width", BigInteger, nullable=True, comment="视频宽度(像素)"),
|
||||
Column("height", BigInteger, nullable=True, comment="视频高度(像素)"),
|
||||
Column("fps", Float, nullable=True, comment="帧率(可选)"),
|
||||
Column("duration_ms", BigInteger, nullable=True, comment="时长(毫秒,可选)"),
|
||||
Column("size_bytes", BigInteger, nullable=True, comment="文件大小(字节,可选)"),
|
||||
Column("recorded_started_at", DateTime(timezone=True), nullable=True, index=True, comment="录制开始时间(UTC,可选)"),
|
||||
Column("recorded_ended_at", DateTime(timezone=True), nullable=True, index=True, comment="录制结束时间(UTC,可选)"),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=text("now()"), index=True, comment="记录创建时间(UTC)"),
|
||||
Column("extra", JSONB, nullable=True, comment="扩展信息(JSONB)"),
|
||||
Index("idx_screen_recordings_sim_screen_created", "simulation_id", "screen_type", "created_at"),
|
||||
Index("idx_screen_recordings_sim_screen_time", "simulation_id", "screen_type", "recorded_started_at"),
|
||||
comment="仿真过程屏幕录制文件元数据(显示大屏/车载屏等)",
|
||||
)
|
||||
|
||||
sys_role = Table(
|
||||
"sys_role",
|
||||
Base.metadata,
|
||||
Column("role_id", String(64), primary_key=True, comment="角色 ID"),
|
||||
Column("role_name", String(64), nullable=False, unique=True, index=True, comment="角色名称(唯一)"),
|
||||
Column("role_desc", String(255), nullable=True, comment="角色描述"),
|
||||
Column("is_active", Boolean, nullable=False, server_default=text("TRUE"), index=True, comment="是否启用"),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=text("now()"), index=True, comment="创建时间(UTC)"),
|
||||
Column("updated_at", DateTime(timezone=True), nullable=True, comment="更新时间(UTC)"),
|
||||
Column("extra", JSONB, nullable=True, comment="扩展信息(JSONB)"),
|
||||
comment="系统角色",
|
||||
)
|
||||
|
||||
sys_permission = Table(
|
||||
"sys_permission",
|
||||
Base.metadata,
|
||||
Column("perm_code", String(128), primary_key=True, comment="权限编码(唯一)"),
|
||||
Column("perm_name", String(128), nullable=False, index=True, comment="权限名称"),
|
||||
Column("perm_group", String(64), nullable=True, index=True, comment="权限分组(可选)"),
|
||||
Column("perm_desc", String(255), nullable=True, comment="权限描述"),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=text("now()"), index=True, comment="创建时间(UTC)"),
|
||||
comment="系统功能权限",
|
||||
)
|
||||
|
||||
sys_role_permission = Table(
|
||||
"sys_role_permission",
|
||||
Base.metadata,
|
||||
Column("role_id", String(64), ForeignKey("sys_role.role_id", ondelete="CASCADE"), primary_key=True, comment="角色 ID"),
|
||||
Column("perm_code", String(128), ForeignKey("sys_permission.perm_code", ondelete="CASCADE"), primary_key=True, comment="权限编码"),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=text("now()"), index=True, comment="创建时间(UTC)"),
|
||||
Index("idx_sys_role_permission_role", "role_id"),
|
||||
Index("idx_sys_role_permission_perm", "perm_code"),
|
||||
comment="角色功能权限关联表",
|
||||
)
|
||||
|
||||
sys_user = Table(
|
||||
"sys_user",
|
||||
Base.metadata,
|
||||
Column("user_id", String(64), primary_key=True, comment="用户 ID"),
|
||||
Column("username", String(64), nullable=False, unique=True, index=True, comment="登录名(唯一)"),
|
||||
Column("display_name", String(64), nullable=True, index=True, comment="显示名称"),
|
||||
Column("password_hash", String(255), nullable=False, comment="密码哈希"),
|
||||
Column("role_id", String(64), ForeignKey("sys_role.role_id"), nullable=False, index=True, comment="所属角色 ID"),
|
||||
Column("is_active", Boolean, nullable=False, server_default=text("TRUE"), index=True, comment="是否启用"),
|
||||
Column("last_login_at", DateTime(timezone=True), nullable=True, index=True, comment="最近登录时间(UTC)"),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=text("now()"), index=True, comment="创建时间(UTC)"),
|
||||
Column("updated_at", DateTime(timezone=True), nullable=True, comment="更新时间(UTC)"),
|
||||
Column("extra", JSONB, nullable=True, comment="扩展信息(JSONB)"),
|
||||
comment="系统用户(含所属角色)",
|
||||
)
|
||||
|
||||
sys_logs = Table(
|
||||
"sys_logs",
|
||||
Base.metadata,
|
||||
Column("log_id", BigInteger, primary_key=True, comment="日志 ID"),
|
||||
Column("ts", DateTime(timezone=True), nullable=False, server_default=text("now()"), index=True, comment="操作时间(UTC)"),
|
||||
Column("user_id", String(64), nullable=True, index=True, comment="用户 ID(可为空,如匿名)"),
|
||||
Column("username", String(64), nullable=True, index=True, comment="登录名快照(可选)"),
|
||||
Column("role_id", String(64), nullable=True, index=True, comment="角色 ID 快照(可选)"),
|
||||
Column("action", String(128), nullable=False, index=True, comment="操作动作(如 login/start_simulation)"),
|
||||
Column("resource", String(255), nullable=True, index=True, comment="资源标识(如 URL/对象 ID)"),
|
||||
Column("success", Boolean, nullable=False, server_default=text("TRUE"), index=True, comment="是否成功"),
|
||||
Column("ip", String(64), nullable=True, comment="客户端 IP(可选)"),
|
||||
Column("user_agent", String(512), nullable=True, comment="User-Agent(可选)"),
|
||||
Column("detail", JSONB, nullable=True, comment="操作明细(JSONB,可选)"),
|
||||
Index("idx_sys_logs_user_ts", "user_id", "ts"),
|
||||
Index("idx_sys_logs_action_ts", "action", "ts"),
|
||||
comment="系统操作日志",
|
||||
)
|
||||
|
||||
|
||||
server_metrics = Table(
|
||||
"server_metrics",
|
||||
Base.metadata,
|
||||
Column("ts", DateTime(timezone=True), nullable=False, index=True, comment="采样时间(UTC)"),
|
||||
Column("host_name", String(64), nullable=False, index=True, comment="主机名"),
|
||||
Column("cpu_usage_percent", JSONB, nullable=False, comment="CPU 使用率(百分比,JSONB)"),
|
||||
Column("memory_usage_bytes", JSONB, nullable=False, comment="内存使用情况(字节,JSONB)"),
|
||||
Column("disk_usage_bytes", JSONB, nullable=True, comment="磁盘使用情况(字节,JSONB)"),
|
||||
Index("idx_server_metrics_host_ts", "host_name", "ts"),
|
||||
comment="服务器监控指标时序数据(TimescaleDB hypertable)",
|
||||
)
|
||||
|
||||
|
||||
async def init_schema(engine) -> None:
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
if not isinstance(engine, AsyncEngine):
|
||||
raise TypeError("engine must be AsyncEngine")
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
await conn.execute(text("ALTER TABLE simulations ADD COLUMN IF NOT EXISTS scenario_name VARCHAR(255)"))
|
||||
await conn.execute(text("ALTER TABLE simulations ADD COLUMN IF NOT EXISTS config_created_at TIMESTAMPTZ"))
|
||||
await conn.execute(text("ALTER TABLE simulations ADD COLUMN IF NOT EXISTS operator VARCHAR(64)"))
|
||||
await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_simulations_scenario_name ON simulations (scenario_name)"))
|
||||
await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_simulations_config_created_at ON simulations (config_created_at)"))
|
||||
await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_simulations_operator ON simulations (operator)"))
|
||||
|
||||
|
||||
async def init_timescaledb(engine) -> None:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS timescaledb"))
|
||||
await conn.execute(
|
||||
text(
|
||||
"SELECT create_hypertable('sim_vehicle_signals', 'ts', if_not_exists => TRUE)"
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_vehicle_signals_sim_ts_desc ON sim_vehicle_signals (simulation_id, ts DESC)"
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"SELECT create_hypertable('server_metrics', 'ts', if_not_exists => TRUE)"
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_server_metrics_host_ts_desc ON server_metrics (host_name, ts DESC)"
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"SELECT create_hypertable('sim_unity_vehicle_frames', 'ts', if_not_exists => TRUE)"
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_unity_frames_sim_vehicle_ts_desc ON sim_unity_vehicle_frames (simulation_id, vehicle_id, ts DESC)"
|
||||
)
|
||||
)
|
||||
153
backend/database/test_db.py
Normal file
153
backend/database/test_db.py
Normal file
@ -0,0 +1,153 @@
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import random
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import insert, select, text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlalchemy.engine.url import make_url
|
||||
from backend.database.schema import vehicle_signals, Simulation, init_schema, init_timescaledb
|
||||
from backend.config.settings import load_settings
|
||||
|
||||
# 模拟数据生成
|
||||
def generate_payload():
|
||||
return {
|
||||
"steering_wheel_angle_deg": round(random.uniform(-450, 450), 1),
|
||||
"brake_pedal_travel_mm": round(random.uniform(0, 100), 1),
|
||||
"throttle_pedal_travel_mm": round(random.uniform(0, 100), 1),
|
||||
"gear": random.choice(["P", "N", "D", "R"]),
|
||||
"handbrake": random.choice([0, 1]),
|
||||
"vehicle_speed_kmh": round(random.uniform(0, 180), 1),
|
||||
"wheel_speed_rpm": {
|
||||
"FL": random.randint(0, 2000),
|
||||
"FR": random.randint(0, 2000),
|
||||
"RL": random.randint(0, 2000),
|
||||
"RR": random.randint(0, 2000)
|
||||
},
|
||||
"lights": {
|
||||
"left_turn": random.choice([0, 1]),
|
||||
"right_turn": random.choice([0, 1]),
|
||||
"hazard": random.choice([0, 1]),
|
||||
"brake": random.choice([0, 1])
|
||||
},
|
||||
"soc_percent": round(random.uniform(0, 100), 1),
|
||||
"voltage_v": round(random.uniform(300, 400), 1),
|
||||
"current_a": round(random.uniform(-50, 200), 1),
|
||||
"temperature_c": round(random.uniform(20, 80), 1)
|
||||
}
|
||||
|
||||
def _redact_url(url: str) -> str:
|
||||
try:
|
||||
parsed = make_url(url)
|
||||
if parsed.password:
|
||||
parsed = parsed.set(password="***")
|
||||
return str(parsed)
|
||||
except Exception:
|
||||
return url
|
||||
|
||||
async def run_test():
|
||||
settings = load_settings()
|
||||
|
||||
db_url = os.getenv("SMARTEDT_TEST_DATABASE_URL", settings.database.url).strip()
|
||||
print(f"Connecting to DB: {_redact_url(db_url)}")
|
||||
engine = create_async_engine(db_url, echo=False)
|
||||
|
||||
# 0. 初始化表结构 (如果不存在)
|
||||
print("Initializing schema...")
|
||||
await init_schema(engine)
|
||||
try:
|
||||
await init_timescaledb(engine)
|
||||
print("Schema and TimescaleDB initialized.")
|
||||
except Exception as e:
|
||||
print(f"TimescaleDB init warning (might already exist): {e}")
|
||||
|
||||
# 1. 准备测试数据
|
||||
total_records = int(os.getenv("SMARTEDT_TEST_RECORDS", "300000"))
|
||||
batch_size = int(os.getenv("SMARTEDT_TEST_BATCH_SIZE", "1000"))
|
||||
simulation_id = f"TEST_SIM_{int(time.time())}"
|
||||
device_id = "test_device_01"
|
||||
|
||||
print(f"Generating {total_records} records for simulation {simulation_id}...")
|
||||
|
||||
print("Starting insertion test...")
|
||||
|
||||
# 2. 插入性能测试
|
||||
insert_start_time = time.time()
|
||||
|
||||
async with engine.begin() as conn:
|
||||
# 分批插入
|
||||
for base_seq in range(0, total_records, batch_size):
|
||||
batch = []
|
||||
end_seq = min(base_seq + batch_size, total_records)
|
||||
for seq in range(base_seq, end_seq):
|
||||
batch.append(
|
||||
{
|
||||
"ts": datetime.now(timezone.utc),
|
||||
"simulation_id": simulation_id,
|
||||
"device_id": device_id,
|
||||
"seq": seq,
|
||||
"signals": generate_payload(),
|
||||
}
|
||||
)
|
||||
await conn.execute(insert(vehicle_signals), batch)
|
||||
if end_seq % 50000 == 0:
|
||||
print(f"Inserted {end_seq} records...")
|
||||
|
||||
insert_end_time = time.time()
|
||||
insert_duration = insert_end_time - insert_start_time
|
||||
print(f"\n✅ Insertion Test Complete:")
|
||||
print(f"Total Records: {total_records}")
|
||||
print(f"Time Taken: {insert_duration:.4f} seconds")
|
||||
print(f"Throughput: {total_records / insert_duration:.2f} records/sec")
|
||||
|
||||
# 3. 查询性能测试
|
||||
print("\nStarting query performance test...")
|
||||
|
||||
# 3.1 简单计数查询
|
||||
query_start = time.time()
|
||||
async with engine.connect() as conn:
|
||||
result = await conn.execute(
|
||||
select(text("count(*)")).select_from(vehicle_signals).where(vehicle_signals.c.simulation_id == simulation_id)
|
||||
)
|
||||
count = result.scalar()
|
||||
query_end = time.time()
|
||||
print(f"Query 1 (Count): Found {count} records in {query_end - query_start:.4f} seconds")
|
||||
|
||||
# 3.2 复杂 JSONB 查询 (查询车速 > 100 的记录数)
|
||||
# 注意:JSONB 查询语法取决于数据库和 SQLAlchemy 版本,这里使用 text() 以确保兼容性
|
||||
query_start = time.time()
|
||||
async with engine.connect() as conn:
|
||||
# 查询 signals->>'vehicle_speed_kmh' > 100
|
||||
stmt = text(
|
||||
"SELECT count(*) FROM vehicle_signals "
|
||||
"WHERE simulation_id = :sim_id "
|
||||
"AND (signals->>'vehicle_speed_kmh')::float > 100"
|
||||
)
|
||||
result = await conn.execute(stmt, {"sim_id": simulation_id})
|
||||
high_speed_count = result.scalar()
|
||||
query_end = time.time()
|
||||
print(f"Query 2 (JSONB Filter): Found {high_speed_count} records with speed > 100 in {query_end - query_start:.4f} seconds")
|
||||
|
||||
# 3.3 时间范围查询 (查询最近 1000 条)
|
||||
query_start = time.time()
|
||||
async with engine.connect() as conn:
|
||||
stmt = (
|
||||
select(vehicle_signals)
|
||||
.where(vehicle_signals.c.simulation_id == simulation_id)
|
||||
.order_by(vehicle_signals.c.ts.desc())
|
||||
.limit(1000)
|
||||
)
|
||||
result = await conn.execute(stmt)
|
||||
rows = result.fetchall()
|
||||
query_end = time.time()
|
||||
print(f"Query 3 (Time Range Limit): Retrieved {len(rows)} records in {query_end - query_start:.4f} seconds")
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 确保在 Windows 上正确运行 asyncio
|
||||
if hasattr(asyncio, 'WindowsSelectorEventLoopPolicy'):
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||||
|
||||
asyncio.run(run_test())
|
||||
1
backend/device/__init__.py
Normal file
1
backend/device/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
25
backend/device/base.py
Normal file
25
backend/device/base.py
Normal file
@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeviceInfo:
|
||||
device_id: str
|
||||
device_type: str
|
||||
connected: bool
|
||||
|
||||
|
||||
class DeviceAdapter:
|
||||
device_id: str
|
||||
device_type: str
|
||||
|
||||
async def connect(self) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def is_connected(self) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
89
backend/device/mock_vehicle.py
Normal file
89
backend/device/mock_vehicle.py
Normal file
@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
|
||||
from backend.device.base import DeviceAdapter
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VehicleSignalPayload:
|
||||
steering_wheel_angle_deg: float
|
||||
brake_pedal_travel_mm: float
|
||||
throttle_pedal_travel_mm: float
|
||||
gear: str
|
||||
handbrake: int
|
||||
vehicle_speed_kmh: float
|
||||
wheel_speed_rpm: dict
|
||||
lights: dict
|
||||
soc_percent: float
|
||||
voltage_v: float
|
||||
current_a: float
|
||||
temperature_c: float
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"steering_wheel_angle_deg": self.steering_wheel_angle_deg,
|
||||
"brake_pedal_travel_mm": self.brake_pedal_travel_mm,
|
||||
"throttle_pedal_travel_mm": self.throttle_pedal_travel_mm,
|
||||
"gear": self.gear,
|
||||
"handbrake": self.handbrake,
|
||||
"vehicle_speed_kmh": self.vehicle_speed_kmh,
|
||||
"wheel_speed_rpm": self.wheel_speed_rpm,
|
||||
"lights": self.lights,
|
||||
"soc_percent": self.soc_percent,
|
||||
"voltage_v": self.voltage_v,
|
||||
"current_a": self.current_a,
|
||||
"temperature_c": self.temperature_c,
|
||||
}
|
||||
|
||||
|
||||
class MockVehicleDevice(DeviceAdapter):
|
||||
def __init__(self, device_id: str = "controlbox_01") -> None:
|
||||
self.device_id = device_id
|
||||
self.device_type = "mock_vehicle"
|
||||
self._connected = False
|
||||
|
||||
async def connect(self) -> None:
|
||||
self._connected = True
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
self._connected = False
|
||||
|
||||
async def is_connected(self) -> bool:
|
||||
return self._connected
|
||||
|
||||
def sample(self) -> VehicleSignalPayload:
|
||||
steering = random.uniform(-180.0, 180.0)
|
||||
brake = max(0.0, random.gauss(2.0, 1.0))
|
||||
throttle = max(0.0, random.gauss(15.0, 5.0))
|
||||
gear = random.choice(["P", "N", "D", "S"])
|
||||
handbrake = 1 if gear == "P" else 0
|
||||
speed = max(0.0, random.gauss(40.0, 10.0)) if gear in {"D", "S"} else 0.0
|
||||
rpm = int(speed * 9 + random.uniform(-5, 5))
|
||||
wheel = {"FL": rpm, "FR": rpm, "RL": rpm, "RR": rpm}
|
||||
lights = {
|
||||
"left_turn": int(random.random() < 0.05),
|
||||
"right_turn": int(random.random() < 0.05),
|
||||
"hazard": int(random.random() < 0.02),
|
||||
"brake": int(brake > 5.0),
|
||||
}
|
||||
soc = max(0.0, min(100.0, 80.0 - random.random() * 0.1))
|
||||
voltage = 360.0 + random.uniform(-0.5, 0.5)
|
||||
current = max(0.0, random.gauss(15.0, 2.0))
|
||||
temp = 28.0 + random.uniform(-0.2, 0.2)
|
||||
return VehicleSignalPayload(
|
||||
steering_wheel_angle_deg=steering,
|
||||
brake_pedal_travel_mm=brake,
|
||||
throttle_pedal_travel_mm=throttle,
|
||||
gear=gear,
|
||||
handbrake=handbrake,
|
||||
vehicle_speed_kmh=speed,
|
||||
wheel_speed_rpm=wheel,
|
||||
lights=lights,
|
||||
soc_percent=soc,
|
||||
voltage_v=voltage,
|
||||
current_a=current,
|
||||
temperature_c=temp,
|
||||
)
|
||||
|
||||
149
backend/main.py
Normal file
149
backend/main.py
Normal file
@ -0,0 +1,149 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import multiprocessing
|
||||
import platform
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 修复 Windows 下 psycopg 异步连接的 Event Loop 问题
|
||||
if platform.system() == "Windows":
|
||||
# 1. 强制设置 SelectorEventLoopPolicy
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||||
|
||||
# 2. Monkeypatch 阻止 uvicorn 覆盖策略 (uvicorn 默认在 Windows 上强制使用 Proactor)
|
||||
# 这对 psycopg 3 (SQLAlchemy async) 是必须的,因为 Proactor 不支持 add_reader/add_writer
|
||||
original_set_policy = asyncio.set_event_loop_policy
|
||||
def patched_set_policy(policy):
|
||||
# 如果尝试设置 Proactor,则忽略并保持 Selector
|
||||
if isinstance(policy, asyncio.WindowsProactorEventLoopPolicy):
|
||||
return
|
||||
original_set_policy(policy)
|
||||
asyncio.set_event_loop_policy = patched_set_policy
|
||||
|
||||
import uvicorn
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI, WebSocket
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from backend.config.settings import load_settings
|
||||
from backend.database.engine import create_engine, create_session_factory
|
||||
from backend.database.schema import init_schema, init_timescaledb
|
||||
from backend.services.broadcaster import Broadcaster
|
||||
from backend.services.simulation_manager import SimulationManager
|
||||
from backend.services.server_monitor import ServerMonitorService
|
||||
from backend.device.mock_vehicle import MockVehicleDevice
|
||||
from backend.api import routes, ws
|
||||
from backend.utils import configure_logging
|
||||
|
||||
logger = logging.getLogger("backend")
|
||||
|
||||
def _default_backend_log_file() -> Path | None:
|
||||
if getattr(sys, "frozen", False):
|
||||
exe_dir = Path(sys.executable).resolve().parent
|
||||
return exe_dir / "logs" / "backend.log"
|
||||
return None
|
||||
|
||||
def _force_windows_selector_event_loop_for_uvicorn() -> None:
|
||||
if platform.system() != "Windows":
|
||||
return
|
||||
try:
|
||||
import uvicorn.loops.asyncio as uvicorn_asyncio_loop
|
||||
except Exception:
|
||||
return
|
||||
|
||||
def _selector_loop_factory(use_subprocess: bool = False):
|
||||
return asyncio.SelectorEventLoop
|
||||
|
||||
uvicorn_asyncio_loop.asyncio_loop_factory = _selector_loop_factory
|
||||
|
||||
# 全局单例容器(简单实现)
|
||||
class Container:
|
||||
def __init__(self):
|
||||
load_dotenv()
|
||||
self.settings = load_settings()
|
||||
configure_logging(
|
||||
"INFO" if not self.settings.server.debug else "DEBUG",
|
||||
log_file=_default_backend_log_file(),
|
||||
)
|
||||
|
||||
self.file_root = self.settings.files.root_path
|
||||
self.file_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.engine = create_engine(self.settings.database)
|
||||
self.session_factory = create_session_factory(self.engine)
|
||||
self.broadcaster = Broadcaster()
|
||||
|
||||
# 实例化服务
|
||||
self.simulation_manager = SimulationManager(
|
||||
self.session_factory,
|
||||
self.broadcaster
|
||||
)
|
||||
|
||||
# 实例化监控服务
|
||||
self.server_monitor = ServerMonitorService(
|
||||
self.session_factory,
|
||||
self.broadcaster
|
||||
)
|
||||
|
||||
container = Container()
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# 启动前初始化
|
||||
await init_schema(container.engine)
|
||||
if container.settings.database.timescaledb:
|
||||
try:
|
||||
await init_timescaledb(container.engine)
|
||||
except Exception as e:
|
||||
# 可能是已存在或权限问题,仅打印日志
|
||||
logger.warning("TimescaleDB init warning: %s", e)
|
||||
|
||||
# 注册默认设备
|
||||
mock_dev = MockVehicleDevice(device_id="mock_01")
|
||||
await container.simulation_manager.register_device(mock_dev)
|
||||
|
||||
# 启动监控服务
|
||||
await container.server_monitor.start()
|
||||
|
||||
yield
|
||||
|
||||
# 停机清理
|
||||
await container.server_monitor.stop()
|
||||
await container.simulation_manager.stop()
|
||||
await container.engine.dispose()
|
||||
|
||||
app = FastAPI(title="SmartEDT Backend", version="0.1.0", lifespan=lifespan)
|
||||
app.include_router(routes.get_router(simulation_manager=container.simulation_manager, file_root=container.file_root))
|
||||
|
||||
@app.websocket("/ws")
|
||||
async def ws_endpoint(websocket: WebSocket):
|
||||
await ws.websocket_handler(websocket, broadcaster=container.broadcaster)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--host", default=None)
|
||||
parser.add_argument("--port", type=int, default=None)
|
||||
parser.add_argument("--debug", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
settings = load_settings()
|
||||
host = args.host or settings.server.host
|
||||
port = args.port or settings.server.port
|
||||
debug = args.debug or settings.server.debug
|
||||
|
||||
_force_windows_selector_event_loop_for_uvicorn()
|
||||
|
||||
if getattr(sys, 'frozen', False):
|
||||
uvicorn.run(app, host=host, port=port, log_level="debug" if debug else "info")
|
||||
else:
|
||||
uvicorn.run("backend.main:app", host=host, port=port, reload=debug, log_level="debug" if debug else "info")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
multiprocessing.freeze_support()
|
||||
main()
|
||||
8
backend/requirements.txt
Normal file
8
backend/requirements.txt
Normal file
@ -0,0 +1,8 @@
|
||||
fastapi>=0.115.0
|
||||
uvicorn[standard]>=0.30.0
|
||||
sqlalchemy[asyncio]>=2.0.30
|
||||
psycopg[binary]>=3.2.0
|
||||
python-dotenv>=1.0.1
|
||||
pydantic>=2.8.0
|
||||
psutil>=5.9.0
|
||||
|
||||
2
backend/requirements_build.txt
Normal file
2
backend/requirements_build.txt
Normal file
@ -0,0 +1,2 @@
|
||||
pyinstaller>=6.9.0
|
||||
|
||||
1
backend/services/__init__.py
Normal file
1
backend/services/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
30
backend/services/broadcaster.py
Normal file
30
backend/services/broadcaster.py
Normal file
@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from starlette.websockets import WebSocket
|
||||
|
||||
|
||||
class Broadcaster:
|
||||
def __init__(self) -> None:
|
||||
self._clients: set[WebSocket] = set()
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def add(self, ws: WebSocket) -> None:
|
||||
async with self._lock:
|
||||
self._clients.add(ws)
|
||||
|
||||
async def remove(self, ws: WebSocket) -> None:
|
||||
async with self._lock:
|
||||
self._clients.discard(ws)
|
||||
|
||||
async def broadcast_json(self, message: dict[str, Any]) -> None:
|
||||
async with self._lock:
|
||||
clients = list(self._clients)
|
||||
for ws in clients:
|
||||
try:
|
||||
await ws.send_json(message)
|
||||
except Exception:
|
||||
await self.remove(ws)
|
||||
|
||||
127
backend/services/server_monitor.py
Normal file
127
backend/services/server_monitor.py
Normal file
@ -0,0 +1,127 @@
|
||||
import asyncio
|
||||
import time
|
||||
import logging
|
||||
import psutil
|
||||
import socket
|
||||
import platform
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import insert
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
from backend.database.schema import server_metrics
|
||||
from backend.services.broadcaster import Broadcaster
|
||||
|
||||
logger = logging.getLogger("backend.monitor")
|
||||
|
||||
class ServerMonitorService:
|
||||
def __init__(self, session_factory: async_sessionmaker, broadcaster: Broadcaster):
|
||||
self._session_factory = session_factory
|
||||
self._broadcaster = broadcaster
|
||||
self._host_name = socket.gethostname()
|
||||
self._running = False
|
||||
self._task = None
|
||||
self._sample_interval = 1.0 / 50.0 # 50Hz (20ms)
|
||||
self._report_interval = 1.0 / 10.0 # 10Hz (100ms)
|
||||
self._last_report_time = 0.0
|
||||
|
||||
# Buffer for downsampling
|
||||
self._buffer_cpu = []
|
||||
self._buffer_mem = []
|
||||
|
||||
async def start(self):
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._task = asyncio.create_task(self._run_loop())
|
||||
logger.info("ServerMonitorService started")
|
||||
|
||||
async def stop(self):
|
||||
self._running = False
|
||||
if self._task:
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
logger.info("ServerMonitorService stopped")
|
||||
|
||||
async def _run_loop(self):
|
||||
loop = asyncio.get_running_loop()
|
||||
next_time = loop.time()
|
||||
|
||||
while self._running:
|
||||
# High frequency sampling (50Hz)
|
||||
# psutil.cpu_percent(interval=None) is non-blocking
|
||||
cpu_percent = psutil.cpu_percent(interval=None)
|
||||
mem = psutil.virtual_memory()
|
||||
|
||||
self._buffer_cpu.append(cpu_percent)
|
||||
self._buffer_mem.append(mem)
|
||||
|
||||
current_time = loop.time()
|
||||
|
||||
# Check if it's time to report (10Hz)
|
||||
if current_time - self._last_report_time >= self._report_interval:
|
||||
await self._process_and_report()
|
||||
self._last_report_time = current_time
|
||||
self._buffer_cpu.clear()
|
||||
self._buffer_mem.clear()
|
||||
|
||||
# Precise timing control
|
||||
next_time += self._sample_interval
|
||||
sleep_time = next_time - loop.time()
|
||||
if sleep_time > 0:
|
||||
await asyncio.sleep(sleep_time)
|
||||
else:
|
||||
# If we are lagging, yield execution but don't sleep
|
||||
await asyncio.sleep(0)
|
||||
|
||||
async def _process_and_report(self):
|
||||
if not self._buffer_cpu:
|
||||
return
|
||||
|
||||
# Downsampling: Calculate average of buffered samples
|
||||
avg_cpu = sum(self._buffer_cpu) / len(self._buffer_cpu)
|
||||
|
||||
# Take the latest memory reading (memory doesn't fluctuate as fast as CPU)
|
||||
last_mem = self._buffer_mem[-1]
|
||||
|
||||
payload = {
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"host_name": self._host_name,
|
||||
"cpu_usage_percent": {
|
||||
"total": round(avg_cpu, 2),
|
||||
# Note: per-core usage is expensive to query at 50Hz, so we only track total here
|
||||
# or we could sample per-core at lower frequency
|
||||
},
|
||||
"memory_usage_bytes": {
|
||||
"total": last_mem.total,
|
||||
"available": last_mem.available,
|
||||
"used": last_mem.used,
|
||||
"percent": last_mem.percent
|
||||
},
|
||||
"disk_usage_bytes": {} # Optional: disk usage changes slowly, maybe check every 1s
|
||||
}
|
||||
|
||||
# 1. Broadcast via WebSocket (10Hz)
|
||||
await self._broadcaster.broadcast_json({
|
||||
"type": "server.metrics",
|
||||
"payload": payload
|
||||
})
|
||||
|
||||
# 2. Persist to Database (10Hz)
|
||||
# Note: In production, consider batching inserts further (e.g., every 1s)
|
||||
# to reduce DB load, but 10Hz single insert is manageable for TimescaleDB.
|
||||
async with self._session_factory() as session:
|
||||
try:
|
||||
stmt = insert(server_metrics).values(
|
||||
ts=datetime.fromisoformat(payload["ts"]),
|
||||
host_name=payload["host_name"],
|
||||
cpu_usage_percent=payload["cpu_usage_percent"],
|
||||
memory_usage_bytes=payload["memory_usage_bytes"],
|
||||
disk_usage_bytes=payload["disk_usage_bytes"]
|
||||
)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to persist server metrics: %s", e)
|
||||
# Don't raise, keep monitoring running
|
||||
144
backend/services/simulation_manager.py
Normal file
144
backend/services/simulation_manager.py
Normal file
@ -0,0 +1,144 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from backend.database.schema import Simulation, vehicle_signals
|
||||
from backend.device.mock_vehicle import MockVehicleDevice
|
||||
from backend.services.broadcaster import Broadcaster
|
||||
from backend.utils import utc_now
|
||||
|
||||
|
||||
logger = logging.getLogger("backend.simulation")
|
||||
|
||||
|
||||
@dataclass
|
||||
class SimulationRuntime:
|
||||
simulation_id: str
|
||||
status: str
|
||||
task: asyncio.Task | None = None
|
||||
|
||||
|
||||
class SimulationManager:
|
||||
def __init__(
|
||||
self,
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
broadcaster: Broadcaster,
|
||||
) -> None:
|
||||
self._session_factory = session_factory
|
||||
self._broadcaster = broadcaster
|
||||
self._runtime: SimulationRuntime | None = None
|
||||
self._device = MockVehicleDevice()
|
||||
self._seq = 0
|
||||
|
||||
def current(self) -> SimulationRuntime | None:
|
||||
return self._runtime
|
||||
|
||||
async def register_device(self, device: MockVehicleDevice) -> None:
|
||||
self._device = device
|
||||
|
||||
async def start(self, scenario_config: dict[str, Any]) -> str:
|
||||
if self._runtime and self._runtime.status == "running":
|
||||
return self._runtime.simulation_id
|
||||
|
||||
simulation_id = "SIM" + utc_now().strftime("%Y%m%d%H%M%S") + secrets.token_hex(2).upper()
|
||||
started_at = utc_now()
|
||||
scenario_name = scenario_config.get("scenario")
|
||||
operator = scenario_config.get("driver") or scenario_config.get("operator")
|
||||
config_created_at = started_at
|
||||
|
||||
async with self._session_factory() as session:
|
||||
session.add(
|
||||
Simulation(
|
||||
simulation_id=simulation_id,
|
||||
status="running",
|
||||
started_at=started_at,
|
||||
ended_at=None,
|
||||
scenario_name=scenario_name,
|
||||
scenario_config=scenario_config,
|
||||
config_created_at=config_created_at,
|
||||
operator=operator,
|
||||
archived=False,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
await self._device.connect()
|
||||
self._runtime = SimulationRuntime(simulation_id=simulation_id, status="running")
|
||||
self._runtime.task = asyncio.create_task(self._run_loop(simulation_id))
|
||||
await self._broadcaster.broadcast_json(
|
||||
{"type": "simulation.status", "ts": started_at.timestamp(), "simulation_id": simulation_id, "payload": {"status": "running"}}
|
||||
)
|
||||
return simulation_id
|
||||
|
||||
async def stop(self, simulation_id: str) -> None:
|
||||
runtime = self._runtime
|
||||
if not runtime or runtime.simulation_id != simulation_id:
|
||||
return
|
||||
|
||||
runtime.status = "stopping"
|
||||
if runtime.task:
|
||||
runtime.task.cancel()
|
||||
try:
|
||||
await runtime.task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
await self._device.disconnect()
|
||||
ended_at = utc_now()
|
||||
|
||||
async with self._session_factory() as session:
|
||||
sim = await session.get(Simulation, simulation_id)
|
||||
if sim:
|
||||
sim.status = "stopped"
|
||||
sim.ended_at = ended_at
|
||||
await session.commit()
|
||||
|
||||
await self._broadcaster.broadcast_json(
|
||||
{"type": "simulation.status", "ts": ended_at.timestamp(), "simulation_id": simulation_id, "payload": {"status": "stopped"}}
|
||||
)
|
||||
self._runtime = None
|
||||
|
||||
async def _run_loop(self, simulation_id: str) -> None:
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(0.05)
|
||||
if not await self._device.is_connected():
|
||||
continue
|
||||
|
||||
self._seq += 1
|
||||
ts = utc_now()
|
||||
payload = self._device.sample().to_dict()
|
||||
message = {
|
||||
"type": "vehicle.signal",
|
||||
"ts": ts.timestamp(),
|
||||
"simulation_id": simulation_id,
|
||||
"device_id": self._device.device_id,
|
||||
"seq": self._seq,
|
||||
"payload": payload,
|
||||
}
|
||||
await self._broadcaster.broadcast_json(message)
|
||||
await self._persist_signal(ts, simulation_id, self._device.device_id, self._seq, payload)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("simulation loop crashed")
|
||||
|
||||
async def _persist_signal(self, ts, simulation_id: str, device_id: str, seq: int, signals: dict[str, Any]) -> None:
|
||||
async with self._session_factory() as session:
|
||||
await session.execute(
|
||||
insert(vehicle_signals).values(
|
||||
ts=ts,
|
||||
simulation_id=simulation_id,
|
||||
device_id=device_id,
|
||||
seq=seq,
|
||||
signals=signals,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
95
backend/smartedt_backend.spec
Normal file
95
backend/smartedt_backend.spec
Normal file
@ -0,0 +1,95 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
|
||||
from PyInstaller.utils.hooks import collect_all
|
||||
|
||||
datas = [
|
||||
('config/config.ini', 'backend/config')
|
||||
]
|
||||
binaries = []
|
||||
hiddenimports = [
|
||||
'uvicorn.logging',
|
||||
'uvicorn.loops',
|
||||
'uvicorn.loops.auto',
|
||||
'uvicorn.protocols',
|
||||
'uvicorn.protocols.http',
|
||||
'uvicorn.protocols.http.auto',
|
||||
'uvicorn.protocols.websockets',
|
||||
'uvicorn.protocols.websockets.auto',
|
||||
'uvicorn.lifespan',
|
||||
'uvicorn.lifespan.on',
|
||||
'backend.api',
|
||||
'backend.api.routes',
|
||||
'backend.api.schemas',
|
||||
'backend.api.ws',
|
||||
'backend.config',
|
||||
'backend.config.settings',
|
||||
'backend.database',
|
||||
'backend.database.engine',
|
||||
'backend.database.schema',
|
||||
'backend.device',
|
||||
'backend.device.base',
|
||||
'backend.device.mock_vehicle',
|
||||
'backend.services',
|
||||
'backend.services.broadcaster',
|
||||
'backend.services.server_monitor',
|
||||
'backend.services.simulation_manager',
|
||||
'backend.utils',
|
||||
'psycopg',
|
||||
'psycopg_binary',
|
||||
'sqlalchemy.ext.asyncio',
|
||||
'sqlalchemy.dialects.postgresql',
|
||||
'asyncpg', # Just in case, though we use psycopg
|
||||
]
|
||||
|
||||
tmp_ret = collect_all('uvicorn')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
|
||||
tmp_ret = collect_all('psycopg')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
|
||||
block_cipher = None
|
||||
|
||||
a = Analysis(
|
||||
['main.py'],
|
||||
pathex=[],
|
||||
binaries=binaries,
|
||||
datas=datas,
|
||||
hiddenimports=hiddenimports,
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher,
|
||||
noarchive=False,
|
||||
)
|
||||
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
[],
|
||||
exclude_binaries=True,
|
||||
name='smartedt_backend',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
console=True,
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
)
|
||||
coll = COLLECT(
|
||||
exe,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
name='smartedt_backend',
|
||||
)
|
||||
54
backend/utils.py
Normal file
54
backend/utils.py
Normal file
@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from logging.handlers import TimedRotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def configure_logging(level: str, log_file: Path | None = None) -> None:
|
||||
level_value = getattr(logging, level.upper(), logging.INFO)
|
||||
logging_handlers: list[logging.Handler] = [logging.StreamHandler()]
|
||||
if log_file is not None:
|
||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
rotating_handler = TimedRotatingFileHandler(
|
||||
log_file,
|
||||
when="midnight",
|
||||
interval=1,
|
||||
backupCount=90,
|
||||
encoding="utf-8",
|
||||
delay=True,
|
||||
)
|
||||
rotating_handler.suffix = "%Y-%m-%d"
|
||||
logging_handlers.append(rotating_handler)
|
||||
logging.basicConfig(
|
||||
level=level_value,
|
||||
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||
handlers=logging_handlers,
|
||||
force=True,
|
||||
)
|
||||
for logger_name in ("uvicorn", "uvicorn.error", "uvicorn.access", "uvicorn.asgi"):
|
||||
logger = logging.getLogger(logger_name)
|
||||
logger.handlers = []
|
||||
logger.propagate = True
|
||||
|
||||
|
||||
def project_root() -> Path:
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def safe_join(root: Path, untrusted_path: str) -> Path:
|
||||
if untrusted_path.startswith(("\\\\", "//")):
|
||||
raise ValueError("UNC path is not allowed")
|
||||
if os.path.isabs(untrusted_path):
|
||||
raise ValueError("Absolute path is not allowed")
|
||||
candidate = (root / untrusted_path).resolve()
|
||||
root_resolved = root.resolve()
|
||||
if root_resolved not in candidate.parents and candidate != root_resolved:
|
||||
raise ValueError("Path traversal detected")
|
||||
return candidate
|
||||
65
frontend/build_frontend.ps1
Normal file
65
frontend/build_frontend.ps1
Normal file
@ -0,0 +1,65 @@
|
||||
# Ensure we are in the frontend directory
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
Set-Location $ScriptDir
|
||||
|
||||
# $ProjectRoot = Split-Path -Parent $ScriptDir
|
||||
# $BackendBuildScript = Join-Path $ProjectRoot "backend\build_backend.ps1"
|
||||
|
||||
# Write-Host "Starting Full Build Process..." -ForegroundColor Cyan
|
||||
|
||||
# # 1. Build Backend
|
||||
# Write-Host "Invoking Backend Build..." -ForegroundColor Yellow
|
||||
# & $BackendBuildScript
|
||||
# if ($LASTEXITCODE -ne 0) {
|
||||
# Write-Error "Backend build failed. Aborting."
|
||||
# exit 1
|
||||
# }
|
||||
|
||||
# 2. Build Frontend (Electron)
|
||||
Write-Host "`nBuilding Frontend (Electron)..." -ForegroundColor Yellow
|
||||
|
||||
# Ensure we are back in frontend directory (Backend script changes location)
|
||||
Set-Location $ScriptDir
|
||||
|
||||
try {
|
||||
Get-Process -Name "SmartEDT" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
} catch {}
|
||||
|
||||
# Clean release directory to prevent file lock issues
|
||||
if (Test-Path "release") {
|
||||
Write-Host "Cleaning release directory..." -ForegroundColor Yellow
|
||||
|
||||
# Try multiple times to remove the directory
|
||||
$maxRetries = 3
|
||||
$retryCount = 0
|
||||
while ($retryCount -lt $maxRetries) {
|
||||
try {
|
||||
Remove-Item -Recurse -Force "release" -ErrorAction Stop
|
||||
break
|
||||
} catch {
|
||||
Write-Host "Failed to clean release directory. Retrying in 2 seconds... ($($retryCount + 1)/$maxRetries)" -ForegroundColor Yellow
|
||||
Start-Sleep -Seconds 2
|
||||
try {
|
||||
Get-Process -Name "SmartEDT" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
} catch {}
|
||||
$retryCount++
|
||||
}
|
||||
}
|
||||
|
||||
if (Test-Path "release") {
|
||||
Write-Warning "Could not fully clean release directory. Attempting to proceed..."
|
||||
} else {
|
||||
# Wait a bit to ensure filesystem is ready
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
}
|
||||
|
||||
npm run dist
|
||||
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host "`nFull build successful!" -ForegroundColor Green
|
||||
Write-Host "Installer located at: $(Join-Path $ScriptDir 'release\SmartEDT Setup 0.1.0.exe')" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Error "`nFrontend build failed!"
|
||||
exit 1
|
||||
}
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SmartEDT</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
308
frontend/main/main.cjs
Normal file
308
frontend/main/main.cjs
Normal file
@ -0,0 +1,308 @@
|
||||
const { app, BrowserWindow, screen, Menu } = require("electron");
|
||||
const { spawn } = require("child_process");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const http = require("http");
|
||||
|
||||
// 简单的日志记录函数
|
||||
function log(message) {
|
||||
const logPath = path.join(app.getPath("userData"), "smartedt.log");
|
||||
const timestamp = new Date().toISOString();
|
||||
const logMessage = `[${timestamp}] ${message}\n`;
|
||||
try {
|
||||
fs.appendFileSync(logPath, logMessage);
|
||||
} catch (e) {
|
||||
console.error("Failed to write log:", e);
|
||||
}
|
||||
}
|
||||
|
||||
let backendProcess = null;
|
||||
let staticServer = null;
|
||||
let backendLogStream = null;
|
||||
|
||||
function resolveRepoRoot() {
|
||||
return path.resolve(__dirname, "..", "..");
|
||||
}
|
||||
|
||||
function getPythonPath(repoRoot) {
|
||||
if (process.env.SMARTEDT_PYTHON) {
|
||||
return process.env.SMARTEDT_PYTHON;
|
||||
}
|
||||
// Check for venv in backend (Windows)
|
||||
const venvPythonWin = path.join(repoRoot, "backend", "venv", "Scripts", "python.exe");
|
||||
if (fs.existsSync(venvPythonWin)) {
|
||||
return venvPythonWin;
|
||||
}
|
||||
// Check for venv in backend (Linux/Mac)
|
||||
const venvPythonUnix = path.join(repoRoot, "backend", "venv", "bin", "python");
|
||||
if (fs.existsSync(venvPythonUnix)) {
|
||||
return venvPythonUnix;
|
||||
}
|
||||
// Fallback to system python
|
||||
return "python";
|
||||
}
|
||||
|
||||
function startBackend() {
|
||||
log("Starting backend process...");
|
||||
if (backendLogStream) {
|
||||
try {
|
||||
backendLogStream.end();
|
||||
} catch (_) {}
|
||||
backendLogStream = null;
|
||||
}
|
||||
|
||||
const repoRoot = resolveRepoRoot();
|
||||
let backendCmd;
|
||||
let args;
|
||||
let cwd;
|
||||
|
||||
const safeTimestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const backendLogPath = path.join(app.getPath("userData"), `backend-${safeTimestamp}.log`);
|
||||
try {
|
||||
backendLogStream = fs.createWriteStream(backendLogPath, { flags: "a" });
|
||||
backendLogStream.write(`[${new Date().toISOString()}] Backend log start\n`);
|
||||
log(`Backend log file: ${backendLogPath}`);
|
||||
} catch (e) {
|
||||
backendLogStream = null;
|
||||
log(`Failed to open backend log file: ${e.message}`);
|
||||
}
|
||||
|
||||
if (app.isPackaged) {
|
||||
// In production, the backend is in resources/backend
|
||||
const backendDir = path.join(process.resourcesPath, "backend");
|
||||
backendCmd = path.join(backendDir, "smartedt_backend.exe");
|
||||
args = ["--host", "127.0.0.1", "--port", "5000"];
|
||||
cwd = backendDir;
|
||||
log(`Production mode. Backend cmd: ${backendCmd}`);
|
||||
log(`Backend cwd: ${cwd}`);
|
||||
} else {
|
||||
// In development
|
||||
const backendMain = path.join(repoRoot, "backend", "main.py");
|
||||
backendCmd = getPythonPath(repoRoot);
|
||||
args = [backendMain, "--host", "127.0.0.1", "--port", "5000"];
|
||||
cwd = repoRoot;
|
||||
log(`Development mode. Backend cmd: ${backendCmd}`);
|
||||
}
|
||||
|
||||
// 设置 PYTHONPATH 环境变量,确保能找到 backend 模块 (only for dev)
|
||||
const env = { ...process.env };
|
||||
if (!app.isPackaged) {
|
||||
env.PYTHONPATH = repoRoot;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(backendCmd) && app.isPackaged) {
|
||||
log(`ERROR: Backend executable not found at ${backendCmd}`);
|
||||
}
|
||||
|
||||
backendProcess = spawn(backendCmd, args, {
|
||||
cwd: cwd,
|
||||
env: env,
|
||||
stdio: "pipe", // Capture stdio
|
||||
windowsHide: true
|
||||
});
|
||||
|
||||
backendProcess.stdout.on("data", (data) => {
|
||||
const text = data.toString();
|
||||
log(`[Backend stdout] ${text.trim()}`);
|
||||
if (backendLogStream) {
|
||||
backendLogStream.write(text.endsWith("\n") ? text : `${text}\n`);
|
||||
}
|
||||
});
|
||||
|
||||
backendProcess.stderr.on("data", (data) => {
|
||||
const text = data.toString();
|
||||
log(`[Backend stderr] ${text.trim()}`);
|
||||
if (backendLogStream) {
|
||||
backendLogStream.write(text.endsWith("\n") ? text : `${text}\n`);
|
||||
}
|
||||
});
|
||||
|
||||
backendProcess.on("error", (err) => {
|
||||
log(`Backend failed to start: ${err.message}`);
|
||||
if (backendLogStream) {
|
||||
backendLogStream.write(`[${new Date().toISOString()}] Backend failed to start: ${err.message}\n`);
|
||||
}
|
||||
});
|
||||
|
||||
backendProcess.on("exit", (code) => {
|
||||
log(`Backend exited with code ${code}`);
|
||||
backendProcess = null;
|
||||
if (backendLogStream) {
|
||||
backendLogStream.write(`[${new Date().toISOString()}] Backend exited with code ${code}\n`);
|
||||
try {
|
||||
backendLogStream.end();
|
||||
} catch (_) {}
|
||||
backendLogStream = null;
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
log(`Exception starting backend: ${e.message}`);
|
||||
if (backendLogStream) {
|
||||
backendLogStream.write(`[${new Date().toISOString()}] Exception starting backend: ${e.message}\n`);
|
||||
try {
|
||||
backendLogStream.end();
|
||||
} catch (_) {}
|
||||
backendLogStream = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getMimeType(filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const mimeTypes = {
|
||||
'.html': 'text/html',
|
||||
'.js': 'text/javascript',
|
||||
'.css': 'text/css',
|
||||
'.json': 'application/json',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpg',
|
||||
'.gif': 'image/gif',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff': 'application/font-woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.ttf': 'application/font-ttf'
|
||||
};
|
||||
return mimeTypes[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
function startLocalServer(callback) {
|
||||
const distPath = path.join(__dirname, "..", "dist");
|
||||
log(`Starting local static server serving: ${distPath}`);
|
||||
|
||||
staticServer = http.createServer((req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url, `http://localhost`);
|
||||
let filePath = path.join(distPath, url.pathname);
|
||||
|
||||
// Security check
|
||||
if (!filePath.startsWith(distPath)) {
|
||||
res.statusCode = 403;
|
||||
res.end('Forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// Default to index.html for directories
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
|
||||
filePath = path.join(filePath, 'index.html');
|
||||
}
|
||||
|
||||
// SPA Fallback: if file not found and no extension, serve index.html
|
||||
if (!fs.existsSync(filePath)) {
|
||||
if (path.extname(filePath) === '') {
|
||||
filePath = path.join(distPath, 'index.html');
|
||||
} else {
|
||||
res.statusCode = 404;
|
||||
res.end('Not Found');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const data = fs.readFileSync(filePath);
|
||||
const mimeType = getMimeType(filePath);
|
||||
res.setHeader('Content-Type', mimeType);
|
||||
res.end(data);
|
||||
} catch (err) {
|
||||
log(`Server error: ${err.message}`);
|
||||
res.statusCode = 500;
|
||||
res.end(`Internal Server Error`);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen on a random available port
|
||||
staticServer.listen(0, '127.0.0.1', () => {
|
||||
const port = staticServer.address().port;
|
||||
const url = `http://127.0.0.1:${port}`;
|
||||
log(`Local static server running at ${url}`);
|
||||
callback(url);
|
||||
});
|
||||
|
||||
staticServer.on('error', (err) => {
|
||||
log(`Static server error: ${err.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
function createWindowForDisplay(display, routePath, baseUrl) {
|
||||
const bounds = display.bounds;
|
||||
const win = new BrowserWindow({
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
contextIsolation: true
|
||||
}
|
||||
});
|
||||
|
||||
win.setMenuBarVisibility(false);
|
||||
if (process.platform !== "darwin") {
|
||||
win.removeMenu();
|
||||
}
|
||||
|
||||
// Combine baseUrl with route hash
|
||||
// e.g. http://127.0.0.1:5173/#/control/config
|
||||
// or http://127.0.0.1:xxxxx/#/control/config
|
||||
const fullUrl = `${baseUrl}/#${routePath}`;
|
||||
|
||||
log(`Loading window URL: ${fullUrl}`);
|
||||
win.loadURL(fullUrl).catch(e => {
|
||||
log(`Failed to load URL ${fullUrl}: ${e.message}`);
|
||||
});
|
||||
|
||||
return win;
|
||||
}
|
||||
|
||||
function createWindows(baseUrl) {
|
||||
log(`Creating windows with base URL: ${baseUrl}`);
|
||||
const displays = screen.getAllDisplays();
|
||||
const primary = screen.getPrimaryDisplay();
|
||||
const others = displays.filter((d) => d.id !== primary.id);
|
||||
|
||||
createWindowForDisplay(primary, "/control/config", baseUrl);
|
||||
|
||||
if (others[0]) {
|
||||
createWindowForDisplay(others[0], "/big/dashboard", baseUrl);
|
||||
}
|
||||
if (others[1]) {
|
||||
createWindowForDisplay(others[1], "/car/sim", baseUrl);
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
log("App ready");
|
||||
if (process.platform !== "darwin") {
|
||||
Menu.setApplicationMenu(null);
|
||||
}
|
||||
startBackend();
|
||||
|
||||
if (app.isPackaged) {
|
||||
// Production: Start local static server
|
||||
startLocalServer((serverUrl) => {
|
||||
createWindows(serverUrl);
|
||||
});
|
||||
} else {
|
||||
// Development: Use Vite dev server
|
||||
const devServerUrl = process.env.VITE_DEV_SERVER_URL || "http://127.0.0.1:5173";
|
||||
createWindows(devServerUrl);
|
||||
}
|
||||
});
|
||||
|
||||
app.on("before-quit", () => {
|
||||
if (backendProcess) {
|
||||
backendProcess.kill();
|
||||
backendProcess = null;
|
||||
}
|
||||
if (backendLogStream) {
|
||||
try {
|
||||
backendLogStream.end();
|
||||
} catch (_) {}
|
||||
backendLogStream = null;
|
||||
}
|
||||
if (staticServer) {
|
||||
staticServer.close();
|
||||
staticServer = null;
|
||||
}
|
||||
});
|
||||
5699
frontend/package-lock.json
generated
Normal file
5699
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
60
frontend/package.json
Normal file
60
frontend/package.json
Normal file
@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "smartedt-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "main/main.cjs",
|
||||
"scripts": {
|
||||
"dev": "concurrently -k \"vite\" \"wait-on http://127.0.0.1:5173 && cross-env ELECTRON_OVERRIDE_DIST_PATH=d:\\electron-v39.2.7-win32-x64 electron .\"",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"pack": "npm run build && electron-builder --dir",
|
||||
"dist": "npm run build && electron-builder"
|
||||
},
|
||||
"dependencies": {
|
||||
"pinia": "^3.0.4",
|
||||
"uplot": "^1.6.32",
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^4.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.1.0",
|
||||
"concurrently": "^9.0.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "^27.3.11",
|
||||
"electron-builder": "^24.13.3",
|
||||
"electron-packager": "^17.1.2",
|
||||
"vite": "^5.4.0",
|
||||
"wait-on": "^7.2.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.smartedt.app",
|
||||
"productName": "SmartEDT",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis"
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true
|
||||
},
|
||||
"files": [
|
||||
"dist/**",
|
||||
"main/**",
|
||||
"package.json"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "../backend/dist/smartedt_backend",
|
||||
"to": "backend",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
1
frontend/query
Normal file
1
frontend/query
Normal file
@ -0,0 +1 @@
|
||||
postgresql-x64-17
|
||||
81
frontend/src/App.vue
Normal file
81
frontend/src/App.vue
Normal file
@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div class="app">
|
||||
<nav class="nav-bar">
|
||||
<div class="brand">SmartEDT</div>
|
||||
<div class="links">
|
||||
<router-link to="/control/config">配置</router-link>
|
||||
<router-link to="/control/operate">控制</router-link>
|
||||
<router-link to="/control/query">查询</router-link>
|
||||
<router-link to="/control/analysis">分析</router-link>
|
||||
<router-link to="/control/admin">管理</router-link>
|
||||
<router-link to="/control/server-monitor">服务器监控</router-link>
|
||||
</div>
|
||||
</nav>
|
||||
<router-view class="content" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from "vue";
|
||||
import { useSimulationStore } from "./stores/simulation";
|
||||
|
||||
const store = useSimulationStore();
|
||||
|
||||
onMounted(() => {
|
||||
store.connect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.app {
|
||||
height: 100%;
|
||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Microsoft YaHei", sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.nav-bar {
|
||||
background: #001529;
|
||||
color: #fff;
|
||||
padding: 0 24px;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.links a {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
text-decoration: none;
|
||||
font-size: 16px;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.links a:hover,
|
||||
.links a.router-link-active {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
26
frontend/src/lib/api.js
Normal file
26
frontend/src/lib/api.js
Normal file
@ -0,0 +1,26 @@
|
||||
export const API_BASE = "http://127.0.0.1:5000";
|
||||
|
||||
export async function getHealth() {
|
||||
const res = await fetch(`${API_BASE}/health`);
|
||||
if (!res.ok) throw new Error(`health failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function startSimulation(payload) {
|
||||
const res = await fetch(`${API_BASE}/api/simulation/start`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload || {})
|
||||
});
|
||||
if (!res.ok) throw new Error(`start failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function stopSimulation(simulationId) {
|
||||
const res = await fetch(`${API_BASE}/api/simulation/${encodeURIComponent(simulationId)}/stop`, {
|
||||
method: "POST"
|
||||
});
|
||||
if (!res.ok) throw new Error(`stop failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
15
frontend/src/lib/ws.js
Normal file
15
frontend/src/lib/ws.js
Normal file
@ -0,0 +1,15 @@
|
||||
export function connectWs(onMessage) {
|
||||
const ws = new WebSocket("ws://127.0.0.1:5000/ws");
|
||||
ws.onmessage = (evt) => {
|
||||
try {
|
||||
onMessage(JSON.parse(evt.data));
|
||||
} catch {
|
||||
onMessage({ type: "raw", payload: evt.data });
|
||||
}
|
||||
};
|
||||
ws.onopen = () => {
|
||||
ws.send("hello");
|
||||
};
|
||||
return ws;
|
||||
}
|
||||
|
||||
36
frontend/src/main.js
Normal file
36
frontend/src/main.js
Normal file
@ -0,0 +1,36 @@
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import { createRouter, createWebHashHistory } from "vue-router";
|
||||
import App from "./App.vue";
|
||||
import BigDashboard from "./pages/BigDashboard.vue";
|
||||
import BigSystemIntro from "./pages/BigSystemIntro.vue";
|
||||
import BigSimMonitor from "./pages/BigSimMonitor.vue";
|
||||
import CarSim from "./pages/CarSim.vue";
|
||||
import ControlConfig from "./pages/ControlConfig.vue";
|
||||
import ControlOperate from "./pages/ControlOperate.vue";
|
||||
import ControlQuery from "./pages/ControlQuery.vue";
|
||||
import ControlAnalysis from "./pages/ControlAnalysis.vue";
|
||||
import ControlAdmin from "./pages/ControlAdmin.vue";
|
||||
import ServerMonitor from "./pages/ServerMonitor.vue";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: [
|
||||
{ path: "/", redirect: "/control/config" },
|
||||
{ path: "/big/system-intro", component: BigSystemIntro },
|
||||
{ path: "/big/dashboard", component: BigDashboard },
|
||||
{ path: "/big/sim-monitor", component: BigSimMonitor },
|
||||
{ path: "/car/sim", component: CarSim },
|
||||
{ path: "/control/config", component: ControlConfig },
|
||||
{ path: "/control/operate", component: ControlOperate },
|
||||
{ path: "/control/query", component: ControlQuery },
|
||||
{ path: "/control/analysis", component: ControlAnalysis },
|
||||
{ path: "/control/admin", component: ControlAdmin },
|
||||
{ path: "/control/server-monitor", component: ServerMonitor }
|
||||
]
|
||||
});
|
||||
|
||||
const pinia = createPinia();
|
||||
createApp(App).use(router).use(pinia).mount("#app");
|
||||
|
||||
|
||||
54
frontend/src/pages/BigDashboard.vue
Normal file
54
frontend/src/pages/BigDashboard.vue
Normal file
@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h1>数据看板</h1>
|
||||
<div class="row">
|
||||
<div class="card">
|
||||
<div class="label">仿真记录</div>
|
||||
<div class="value">{{ simulationId || "-" }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">车速</div>
|
||||
<div class="value">{{ latest.vehicle_speed_kmh ?? "-" }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">SOC(%)</div>
|
||||
<div class="value">{{ latest.soc_percent ?? "-" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { useSimulationStore } from "../stores/simulation";
|
||||
|
||||
const store = useSimulationStore();
|
||||
const simulationId = computed(() => store.simulationId);
|
||||
const latest = computed(() => store.latestSignal);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 24px;
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
}
|
||||
.label {
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
}
|
||||
.value {
|
||||
margin-top: 8px;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
53
frontend/src/pages/BigSimMonitor.vue
Normal file
53
frontend/src/pages/BigSimMonitor.vue
Normal file
@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h1>仿真监控</h1>
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<div class="label">档位</div>
|
||||
<div class="value">{{ latest.gear ?? "-" }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">手刹</div>
|
||||
<div class="value">{{ latest.handbrake ?? "-" }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">方向盘角度</div>
|
||||
<div class="value">{{ latest.steering_wheel_angle_deg ?? "-" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { useSimulationStore } from "../stores/simulation";
|
||||
|
||||
const store = useSimulationStore();
|
||||
const latest = computed(() => store.latestSignal);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 24px;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
}
|
||||
.label {
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
}
|
||||
.value {
|
||||
margin-top: 8px;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
16
frontend/src/pages/BigSystemIntro.vue
Normal file
16
frontend/src/pages/BigSystemIntro.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h1>系统介绍</h1>
|
||||
<p>高精度数据采集、实时同步控制、三维虚拟仿真、算法验证与运维管理。</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 24px;
|
||||
}
|
||||
</style>
|
||||
|
||||
36
frontend/src/pages/CarSim.vue
Normal file
36
frontend/src/pages/CarSim.vue
Normal file
@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h1>车载屏</h1>
|
||||
<div class="status">
|
||||
<div>天气:{{ weather }}</div>
|
||||
<div>时段:{{ timePeriod }}</div>
|
||||
<div>状态:{{ simStatus }}</div>
|
||||
<div>车速:{{ latest.vehicle_speed_kmh ?? "-" }} km/h</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { useSimulationStore } from "../stores/simulation";
|
||||
|
||||
const store = useSimulationStore();
|
||||
|
||||
const weather = ref("晴");
|
||||
const timePeriod = ref("正午");
|
||||
const simStatus = computed(() => store.simulationStatus === "running" ? "仿真中" : "结束");
|
||||
const latest = computed(() => store.latestSignal);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 24px;
|
||||
}
|
||||
.status {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
font-size: 20px;
|
||||
}
|
||||
</style>
|
||||
|
||||
16
frontend/src/pages/ControlAdmin.vue
Normal file
16
frontend/src/pages/ControlAdmin.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h1>控制屏 - 管理</h1>
|
||||
<p>用于用户/角色与权限配置(后续扩展)。</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 24px;
|
||||
}
|
||||
</style>
|
||||
|
||||
16
frontend/src/pages/ControlAnalysis.vue
Normal file
16
frontend/src/pages/ControlAnalysis.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h1>控制屏 - 数据分析</h1>
|
||||
<p>用于车辆数据分析、故障模拟诊断与 CAN 数据解析(后续扩展)。</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 24px;
|
||||
}
|
||||
</style>
|
||||
|
||||
76
frontend/src/pages/ControlConfig.vue
Normal file
76
frontend/src/pages/ControlConfig.vue
Normal file
@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h1>控制屏 - 场景配置</h1>
|
||||
<div class="form">
|
||||
<label>
|
||||
场景
|
||||
<select v-model="scenario">
|
||||
<option value="city">城市道路</option>
|
||||
<option value="highway">高速公路</option>
|
||||
<option value="mountain">山区道路</option>
|
||||
<option value="village">乡村道路</option>
|
||||
<option value="campus">校园道路</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
气象
|
||||
<select v-model="weather">
|
||||
<option value="sunny">晴</option>
|
||||
<option value="rain">雨</option>
|
||||
<option value="snow">雪</option>
|
||||
<option value="fog">雾</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
时段
|
||||
<select v-model="timePeriod">
|
||||
<option value="dawn">黎明</option>
|
||||
<option value="noon">正午</option>
|
||||
<option value="dusk">黄昏</option>
|
||||
<option value="night">夜间</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
最高限速(km/h)
|
||||
<input v-model.number="maxSpeed" type="number" min="0" max="300" />
|
||||
</label>
|
||||
<label>
|
||||
仿真时长(min)
|
||||
<input v-model.number="durationMinutes" type="number" min="1" max="360" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<router-link to="/control/operate">进入仿真控制</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
|
||||
const scenario = ref("city");
|
||||
const weather = ref("sunny");
|
||||
const timePeriod = ref("noon");
|
||||
const maxSpeed = ref(140);
|
||||
const durationMinutes = ref(10);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 24px;
|
||||
}
|
||||
.form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
max-width: 900px;
|
||||
}
|
||||
label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.actions {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
51
frontend/src/pages/ControlOperate.vue
Normal file
51
frontend/src/pages/ControlOperate.vue
Normal file
@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h1>控制屏 - 仿真控制</h1>
|
||||
<div class="row">
|
||||
<button :disabled="running" @click="onStart">开始仿真</button>
|
||||
<button :disabled="!running" @click="onStop">结束仿真</button>
|
||||
<div class="meta">当前仿真记录:{{ simulationId || "-" }}</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<router-link to="/big/dashboard">打开显示大屏 - 数据看板</router-link>
|
||||
<router-link to="/big/sim-monitor">打开显示大屏 - 仿真监控</router-link>
|
||||
<router-link to="/car/sim">打开车载屏</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { startSimulation, stopSimulation } from "../lib/api";
|
||||
import { useSimulationStore } from "../stores/simulation";
|
||||
|
||||
const store = useSimulationStore();
|
||||
const running = computed(() => store.simulationStatus === "running");
|
||||
const simulationId = computed(() => store.simulationId);
|
||||
|
||||
async function onStart() {
|
||||
await startSimulation({ scenario: "city", weather: "sunny", time_period: "noon" });
|
||||
}
|
||||
|
||||
async function onStop() {
|
||||
if (!simulationId.value) return;
|
||||
await stopSimulation(simulationId.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 24px;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin: 12px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.meta {
|
||||
color: #333;
|
||||
}
|
||||
</style>
|
||||
|
||||
16
frontend/src/pages/ControlQuery.vue
Normal file
16
frontend/src/pages/ControlQuery.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h1>控制屏 - 数据查询</h1>
|
||||
<p>用于查询仿真记录、系统日志与回放数据。</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 24px;
|
||||
}
|
||||
</style>
|
||||
|
||||
191
frontend/src/pages/ServerMonitor.vue
Normal file
191
frontend/src/pages/ServerMonitor.vue
Normal file
@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<div class="monitor-page">
|
||||
<h1>服务器性能监控</h1>
|
||||
<div class="meta">
|
||||
<span>主机名: {{ hostname }}</span>
|
||||
<span>状态: {{ connected ? '🟢 已连接' : '🔴 断开' }}</span>
|
||||
<span>采样率: 50Hz (显示 10Hz)</span>
|
||||
</div>
|
||||
|
||||
<div class="charts-container">
|
||||
<div class="chart-box">
|
||||
<h3>CPU 使用率 (%)</h3>
|
||||
<div ref="cpuChartRef" class="chart"></div>
|
||||
<div class="value-overlay">{{ currentCpu }}%</div>
|
||||
</div>
|
||||
<div class="chart-box">
|
||||
<h3>内存使用率 (%)</h3>
|
||||
<div ref="memChartRef" class="chart"></div>
|
||||
<div class="value-overlay">{{ currentMem }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, watch } from "vue";
|
||||
import uPlot from "uplot";
|
||||
import "uplot/dist/uPlot.min.css";
|
||||
import { useSimulationStore } from "../stores/simulation";
|
||||
|
||||
const store = useSimulationStore();
|
||||
const hostname = ref("-");
|
||||
const connected = ref(false);
|
||||
const currentCpu = ref(0);
|
||||
const currentMem = ref(0);
|
||||
|
||||
const cpuChartRef = ref(null);
|
||||
const memChartRef = ref(null);
|
||||
|
||||
let cpuChart = null;
|
||||
let memChart = null;
|
||||
|
||||
// uPlot 配置
|
||||
const NOW = Math.floor(Date.now() / 1000);
|
||||
const TIME_WINDOW = 60; // 显示最近 60 秒
|
||||
|
||||
// 数据 buffer: [time[], value[]]
|
||||
const cpuData = [[], []];
|
||||
const memData = [[], []];
|
||||
|
||||
function createOpts(title, color, min, max, width = 600) {
|
||||
return {
|
||||
title: title,
|
||||
width: width,
|
||||
height: 300,
|
||||
series: [
|
||||
{},
|
||||
{
|
||||
stroke: color,
|
||||
width: 2,
|
||||
fill: color + "1A", // 10% opacity
|
||||
},
|
||||
],
|
||||
scales: {
|
||||
x: {
|
||||
time: true,
|
||||
},
|
||||
y: {
|
||||
range: [min, max],
|
||||
},
|
||||
},
|
||||
axes: [
|
||||
{
|
||||
space: 40,
|
||||
},
|
||||
{
|
||||
show: true,
|
||||
font: "12px Arial",
|
||||
}
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化图表
|
||||
// 初始宽度设为 100,稍后由 ResizeObserver 调整
|
||||
cpuChart = new uPlot(createOpts("", "red", 0, 100, 100), cpuData, cpuChartRef.value);
|
||||
memChart = new uPlot(createOpts("", "blue", 0, 100, 100), memData, memChartRef.value);
|
||||
|
||||
// 响应式调整图表宽度
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const width = entry.contentRect.width;
|
||||
if (entry.target === cpuChartRef.value) {
|
||||
cpuChart.setSize({ width, height: 300 });
|
||||
} else if (entry.target === memChartRef.value) {
|
||||
memChart.setSize({ width, height: 300 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (cpuChartRef.value) resizeObserver.observe(cpuChartRef.value);
|
||||
if (memChartRef.value) resizeObserver.observe(memChartRef.value);
|
||||
|
||||
// 监听 store 中的 serverMetrics
|
||||
watch(
|
||||
() => store.serverMetrics,
|
||||
(metrics) => {
|
||||
if (!metrics) return;
|
||||
|
||||
hostname.value = metrics.host_name;
|
||||
connected.value = true;
|
||||
currentCpu.value = metrics.cpu_usage_percent.total;
|
||||
currentMem.value = metrics.memory_usage_bytes.percent;
|
||||
|
||||
const now = Date.now() / 1000;
|
||||
|
||||
// 更新 CPU 数据
|
||||
cpuData[0].push(now);
|
||||
cpuData[1].push(currentCpu.value);
|
||||
|
||||
// 更新 Mem 数据
|
||||
memData[0].push(now);
|
||||
memData[1].push(currentMem.value);
|
||||
|
||||
// 保持时间窗口
|
||||
while (cpuData[0].length > 0 && cpuData[0][0] < now - TIME_WINDOW) {
|
||||
cpuData[0].shift();
|
||||
cpuData[1].shift();
|
||||
memData[0].shift();
|
||||
memData[1].shift();
|
||||
}
|
||||
|
||||
cpuChart.setData(cpuData);
|
||||
memChart.setData(memData);
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// 确保 WS 连接
|
||||
store.connect();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (cpuChart) cpuChart.destroy();
|
||||
if (memChart) memChart.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.monitor-page {
|
||||
padding: 5px;
|
||||
}
|
||||
.meta {
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
.charts-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
}
|
||||
.chart-box {
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
/* 100% 宽度,使其上下排列 */
|
||||
width: 100%;
|
||||
/* 确保不会缩得太小 */
|
||||
min-width: 400px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.chart-box h3 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
}
|
||||
.value-overlay {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
</style>
|
||||
76
frontend/src/stores/simulation.js
Normal file
76
frontend/src/stores/simulation.js
Normal file
@ -0,0 +1,76 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref, reactive } from "vue";
|
||||
|
||||
export const useSimulationStore = defineStore("simulation", () => {
|
||||
const ws = ref(null);
|
||||
const connected = ref(false);
|
||||
const simulationId = ref("");
|
||||
const simulationStatus = ref("stopped");
|
||||
const latestSignal = reactive({});
|
||||
const serverMetrics = ref(null); // 新增服务器监控状态
|
||||
|
||||
function connect() {
|
||||
if (ws.value) return;
|
||||
|
||||
ws.value = new WebSocket("ws://127.0.0.1:5000/ws");
|
||||
|
||||
ws.value.onopen = () => {
|
||||
connected.value = true;
|
||||
console.log("WS connected");
|
||||
};
|
||||
|
||||
ws.value.onclose = () => {
|
||||
connected.value = false;
|
||||
ws.value = null;
|
||||
console.log("WS disconnected");
|
||||
// Simple reconnect logic could be added here
|
||||
setTimeout(connect, 3000);
|
||||
};
|
||||
|
||||
ws.value.onmessage = (evt) => {
|
||||
try {
|
||||
const msg = JSON.parse(evt.data);
|
||||
handleMessage(msg);
|
||||
} catch (e) {
|
||||
console.error("WS parse error", e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function handleMessage(msg) {
|
||||
if (msg.type === "vehicle.signal") {
|
||||
if (msg.simulation_id) {
|
||||
simulationId.value = msg.simulation_id;
|
||||
}
|
||||
if (msg.payload) {
|
||||
Object.assign(latestSignal, msg.payload);
|
||||
}
|
||||
} else if (msg.type === "simulation.status") {
|
||||
if (msg.simulation_id) {
|
||||
simulationId.value = msg.simulation_id;
|
||||
}
|
||||
if (msg.payload && msg.payload.status) {
|
||||
simulationStatus.value = msg.payload.status;
|
||||
}
|
||||
} else if (msg.type === "server.metrics") {
|
||||
serverMetrics.value = msg.payload;
|
||||
}
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (ws.value) {
|
||||
ws.value.close();
|
||||
ws.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
connected,
|
||||
simulationId,
|
||||
simulationStatus,
|
||||
latestSignal,
|
||||
serverMetrics,
|
||||
connect,
|
||||
disconnect
|
||||
};
|
||||
});
|
||||
16
frontend/vite.config.js
Normal file
16
frontend/vite.config.js
Normal file
@ -0,0 +1,16 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
export default defineConfig({
|
||||
base: "./",
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
host: "127.0.0.1",
|
||||
port: 5173
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true
|
||||
}
|
||||
});
|
||||
|
||||
2
unity3D/unity开发的代码
Normal file
2
unity3D/unity开发的代码
Normal file
@ -0,0 +1,2 @@
|
||||
此目录放置unity开发的代码
|
||||
增了电池电量显示
|
||||
BIN
智能电动车数字孪生系统功能规划2026.pdf
Normal file
BIN
智能电动车数字孪生系统功能规划2026.pdf
Normal file
Binary file not shown.
416
系统开发技术方案.md
Normal file
416
系统开发技术方案.md
Normal file
@ -0,0 +1,416 @@
|
||||
# 智能电动汽车数字孪生系统开发技术方案(V1)
|
||||
智能电动车数字孪生系统软件通过集成高精度数据采集、实时同步控制、三维虚拟仿真、智能算法验证与系统运维管理等功能,构建了一个覆盖教学、科研与产业应用的智能电动汽车虚实融合实验平台。它不仅能实现真实驾驶环境的沉浸式再现与数据级同步,还能为科研人员提供精准可追溯的数据支持、灵活的算法验证接口和可扩展的数据分析工具,充分满足智能电动汽车领域对实验真实性、可靠性与创新性的高标准要求,推动智能驾驶与数字孪生技术的深度融合与发展。
|
||||
本文基于《智能电动车数字孪生系统功能规划(2026)》的目标,给出一套可落地的软件开发技术方案。Unity 三维驾驶仿真作为独立工程(独立代码库与发布包),通过标准接口与本系统对接。
|
||||
|
||||
|
||||
## 1. 建设目标与原则
|
||||
|
||||
### 1.1 建设目标
|
||||
- 实现多源设备数据采集、统一管理与实时可视化展示
|
||||
- 构建“实体车—数字孪生体”数据交互:状态同步、数据采集、事件回放
|
||||
- 支撑多场景驾驶仿真联动(Unity 独立运行),实现同屏/多屏展示与协同操作
|
||||
- 实现实验管理与记录:仿真记录创建、过程记录、视频录制、数据持久化、历史回放
|
||||
- 本地化部署与数据安全:本地存储、最小暴露接口、路径越界防护
|
||||
|
||||
### 1.2 设计原则
|
||||
- 分层解耦:设备接入、业务编排、数据服务、可视化展示相互独立
|
||||
- 实时优先:以事件/流式数据驱动为核心(WebSocket + HTTP)
|
||||
- 可扩展:设备类型、消息类型、算法模块、Unity 场景可插拔扩展
|
||||
- 可运维:统一配置、日志、诊断接口、打包发布与自动启动链路
|
||||
|
||||
## 2. 总体架构
|
||||
|
||||
### 2.1 架构分解
|
||||
系统由三类可独立发布、独立升级的可执行程序组成:
|
||||
|
||||
1) **后端数据与控制服务(Python EXE)**
|
||||
- 负责设备采集、数据处理、仿真记录管理、持久化、文件存储、对外 API
|
||||
- 对前端提供 HTTP REST + WebSocket(实时推送)
|
||||
- 对 Unity 提供联动接口(可选双向:状态推送/指令下发)
|
||||
|
||||
2) **桌面端可视化应用(Electron + Vue EXE)**
|
||||
- 负责 UI 展示、实验流程操作、多屏组织、外部程序(后端/Unity)启动与守护
|
||||
- 作为“应用入口”,统一呈现与统一运维
|
||||
|
||||
3) **车辆驾驶仿真(Unity EXE,外部项目)**
|
||||
- 负责 3D 场景、动力学/视觉化仿真、互动控制
|
||||
- 与后端通过约定协议交换数据(HTTP/WebSocket/本地端口均可)
|
||||
|
||||
### 2.2 数据流与控制流
|
||||
- **采集流**:设备/传感器 → 设备接入层 → 统一事件总线 → 处理/校准 → 入库/落盘 → WebSocket 推送
|
||||
- **控制流**:前端操作 → REST 指令 → 业务编排 → 下发到设备/控制柜,或同步给 Unity
|
||||
- **回放流**:历史仿真记录 → 查询元数据(数据库)→ 拉取文件(静态映射)→ 前端播放/曲线回放,或驱动 Unity 回放
|
||||
|
||||
### 2.3 系统两大部分划分
|
||||
系统整体可分为两大部分(对应团队分工与发布包边界):
|
||||
|
||||
1) **数据采集与处理(后端)**
|
||||
- 采集:方向盘/踏板/档位/手刹/车速轮速/灯光/电参/温度等
|
||||
- 处理:滤波、校准、融合、异常检测、指标计算
|
||||
- 管理:仿真记录、文件、数据库、权限(可选)
|
||||
- 服务:REST + WebSocket + 静态文件映射
|
||||
|
||||
2) **界面显示与交互(Electron + Vue)**
|
||||
- 显示大屏:系统介绍、仿真监控、数据看板
|
||||
- 车载屏(驾驶员):驾驶视角信息与仪表盘
|
||||
- 控制屏:场景配置、系统配置、用户/权限、数据查询、数据分析
|
||||
|
||||
### 2.4 推荐工程目录结构(本仓库)
|
||||
建议把“后端服务”和“前端桌面端”放在同一仓库便于协同开发;Unity 工程保持外部独立仓库,仅提交接口文档与发布包获取方式。
|
||||
|
||||
```
|
||||
SmartEDT/
|
||||
backend/ # Python:采集/处理/管理/API
|
||||
device/ # 传感器设备接入与驱动(方向盘/踏板/灯光/电参等)
|
||||
database/ # 数据存储(PostgreSQL/TimescaleDB)与数据访问层
|
||||
config/ # 配置(ini/环境变量)与配置加载
|
||||
main.py # 后端入口(启动 API/WS、初始化各模块)
|
||||
utils.py # 通用工具(时间/路径/校验/日志等)
|
||||
frontend/ # Electron + Vue:桌面端与多屏展示
|
||||
src/ # Vue 渲染进程(页面/组件/路由)
|
||||
main/ # Electron 主进程(多窗口、多屏、拉起后端/Unity)
|
||||
(配置文件) # package.json / vite.config.* / electron-builder 配置等
|
||||
docs/ # 方案、接口协议、运维文档
|
||||
data/ # 默认数据目录(开发态可用)
|
||||
release/ # 可选:打包产物临时输出目录(不提交版本库)
|
||||
```
|
||||
|
||||
## 3. 技术选型与理由
|
||||
|
||||
### 3.1 后端(Python)
|
||||
- **语言**:Python 3.13.1(配合硬件 SDK、算法生态与快速迭代)
|
||||
- **Web 框架**:FastAPI(异步友好、类型约束清晰、OpenAPI 自动生成)
|
||||
- **实时通讯**:WebSocket(FastAPI 原生或 Starlette WebSocket),用于状态/传感数据/事件推送
|
||||
- **进程内并发**:asyncio + 线程池/进程池(适配阻塞型硬件 SDK)
|
||||
- **持久化**:PostgreSQL18 + TimescaleDB(用于实时采集数据的时序存储)+ SQLAlchemy(ORM/迁移支持)
|
||||
- **文件存储**:本地文件系统(与数据库元数据关联)
|
||||
- **打包**:PyInstaller 打包为独立 EXE;开发环境使用 venv 隔离依赖
|
||||
|
||||
|
||||
### 3.2 前端(Electron + Vue)
|
||||
- **UI 框架**:Vue 3 + Vite(开发体验与生态成熟)
|
||||
- **桌面封装**:Electron(多窗口、多屏支持、可管理外部进程)
|
||||
- **本地通信**:HTTP + WebSocket 访问后端;可选 IPC(主进程与渲染进程)
|
||||
- **打包**:electron-builder 产出 Windows EXE 安装包/绿色包
|
||||
|
||||
### 3.3 Unity(外部)
|
||||
- **三维仿真**:Unity(高效构建交互式驾驶场景与渲染)
|
||||
- **对接方式**:建议 WebSocket(低延迟实时同步)+ HTTP(配置、仿真记录、资源拉取)
|
||||
- **进程管理**:由 Electron 主进程统一拉起与关闭(可在指定显示器全屏)
|
||||
|
||||
## 4. 后端模块设计(Python)
|
||||
|
||||
### 4.1 模块划分
|
||||
- **配置中心(Config)**
|
||||
- 统一读取 `config.ini`(就近优先原则:后端目录优先,其次项目根目录)
|
||||
- 管理文件根目录、数据库路径、端口、日志等级等
|
||||
- **设备接入层(Adapters/Drivers)**
|
||||
- 每类设备一个管理器:相机、IMU、压力/力传感、控制柜等
|
||||
- 统一生命周期:init/connect/start/stop/disconnect
|
||||
- 统一数据模型:采集原始数据 + 时间戳 + 设备元信息
|
||||
- **设备协调器(Coordinator)**
|
||||
- 负责多设备协同启动/停止、状态聚合、异常重连策略
|
||||
- 对上提供“仿真记录级操作”,对下调度各设备管理器
|
||||
- **数据处理层(Pipeline)**
|
||||
- 校准/滤波/坐标变换/融合(按需求插拔)
|
||||
- 统一输出“标准事件”,写入事件总线与持久化队列
|
||||
- **仿真实验管理(Simulation Experiment)**
|
||||
- 流程:场景配置 → 仿真启动 → 仿真执行 → 仿真结束
|
||||
- **数据存储(Storage)**
|
||||
- 元数据:PostgreSQL(仿真记录、设备、事件索引、文件索引)
|
||||
- 实时采集数据:TimescaleDB Hypertable(高频写入、按时间范围查询与聚合)
|
||||
- 典型能力:按时间分区、压缩、保留策略、连续聚合(按需要启用)
|
||||
- 大文件:视频、原始采样、导出报告等存本地文件系统
|
||||
- **录制与归档(Recording/Archiving)**
|
||||
- 仿真过程视频录制:显示大屏/车载屏/Unity 画面按需求录制(目标帧率 30fps)
|
||||
- 录制任务与仿真记录绑定:开始/停止随仿真流程联动,支持异常中止与补偿关闭
|
||||
- 文件索引入库:形成可回放、可导出的统一入口
|
||||
- **对外服务(API)**
|
||||
- REST:健康检查、设备管理、仿真记录控制、数据查询、导出
|
||||
- WebSocket:实时推送(状态、传感数据、告警、进度)
|
||||
- **静态文件映射(File Gateway)**
|
||||
- 按配置的根目录对外只读映射
|
||||
- 路径规范化、拒绝越界(拦截 `..`、绝对路径、UNC 路径)
|
||||
|
||||
### 4.2 采集数据范围与精度(本期基线)
|
||||
本系统“采集与处理”侧的基线采集字段如下(用于实时显示、联动仿真与历史回放);后续可按车型/传感器扩展。
|
||||
|
||||
- **转向与踏板**
|
||||
- 方向盘转角:左/右(单位建议:deg)
|
||||
- 刹车踏板行程:mm
|
||||
- 电门踏板行程:mm
|
||||
- **车辆状态**
|
||||
- 挡位:P / N / D / S
|
||||
- 手刹状态(手状态):0 / 1
|
||||
- 车速:单位建议 km/h
|
||||
- 车轮转速:单位建议 rpm(按轮位可扩展为 FL/FR/RL/RR)
|
||||
- **灯光状态**
|
||||
- 左转向灯 / 右转向灯
|
||||
- 双闪
|
||||
- 刹车灯
|
||||
- **电池与电参**
|
||||
- 电池剩余电量:%
|
||||
- 电压:测量误差 0.1V
|
||||
- 电流:测量误差 0.1A
|
||||
- 温度:测量误差 0.5℃
|
||||
|
||||
采集来源建议按两类接入:
|
||||
- **传感器/控制柜直采**:方向盘、踏板、档位、手刹、车速/轮速、电参等(串口/CAN/以太网,取决于硬件)
|
||||
- **视觉识别采集(可选)**:当部分状态仅存在于仪表或屏幕显示时,用相机 + 识别推断(需标注数据与容错策略)
|
||||
|
||||
|
||||
### 4.3 API 设计建议(摘要)
|
||||
- `GET /health`:健康检查(版本、时间、依赖状态)
|
||||
- `GET /api/devices`:设备列表与状态
|
||||
- `POST /api/simulation/start`:创建并启动一次三维仿真记录(返回 simulation_id)
|
||||
- `POST /api/simulation/{simulation_id}/stop`:结束一次三维仿真并落盘
|
||||
- `GET /api/simulation/{simulation_id}`:三维仿真记录元数据
|
||||
- `GET /api/simulation/{simulation_id}/events`:事件/曲线数据查询(分页/时间窗)
|
||||
- `GET /files/<path>`:静态文件访问映射(对标 README 的安全校验策略)
|
||||
- `WS /ws`:统一实时通道(按 topic/room 订阅)
|
||||
|
||||
### 4.4 实时消息模型(建议)
|
||||
采用统一 JSON Envelope,便于前端与 Unity 复用:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "vehicle.signal",
|
||||
"ts": 1737000000.123,
|
||||
"simulation_id": "SIM202601160001",
|
||||
"device_id": "controlbox_01",
|
||||
"seq": 1024,
|
||||
"payload": {
|
||||
"steering_wheel_angle_deg": 12.3,
|
||||
"brake_pedal_travel_mm": 5.2,
|
||||
"throttle_pedal_travel_mm": 18.0,
|
||||
"gear": "D",
|
||||
"handbrake": 0,
|
||||
"vehicle_speed_kmh": 36.5,
|
||||
"wheel_speed_rpm": { "FL": 320, "FR": 319, "RL": 318, "RR": 318 },
|
||||
"lights": { "left_turn": 0, "right_turn": 1, "hazard": 0, "brake": 0 },
|
||||
"soc_percent": 78.2,
|
||||
"voltage_v": 356.4,
|
||||
"current_a": 12.7,
|
||||
"temperature_c": 28.5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
建议补充字段:
|
||||
- `source`:device/backend/unity
|
||||
- `quality`:丢包/校准状态/信噪比等
|
||||
- `schema_version`:前后端与 Unity 协同升级
|
||||
|
||||
### 4.5 目录与文件存储规范(建议)
|
||||
与 README 的建议一致,采用“根目录 + 仿真记录分层”的稳定结构:
|
||||
|
||||
```
|
||||
<root>/
|
||||
<subject_id>/
|
||||
<simulation_id>/
|
||||
meta.json
|
||||
video/
|
||||
signals/
|
||||
exports/
|
||||
```
|
||||
|
||||
文件根目录通过 `[FILEPATH].path` 配置;当配置为相对路径时,开发环境相对后端工作目录,打包环境相对后端 EXE 同级目录(对标 README 的路径策略)。
|
||||
|
||||
存储类型建议(与规划大纲一致):
|
||||
- 传感器原始数据:生成数据文件(如 `signals/raw_*.dat`),按时间段或按设备分文件落盘
|
||||
- 传感器/识别后的结构化数据:实时写入 PostgreSQL/TimescaleDB(用于查询、统计、回放曲线)
|
||||
- 仿真记录视频:按窗口/视角分别落入 `video/`,并在数据库中建立索引与标签(视角、分辨率、帧率等)
|
||||
|
||||
### 4.6 TimescaleDB 时序数据建模建议
|
||||
为支持“高频写入 + 按时间范围回放/聚合”,建议将采集数据按时序表落库,并在 TimescaleDB 中转换为 Hypertable。
|
||||
|
||||
推荐思路:
|
||||
- 一张时序主表:按 `ts` 做 Hypertable 分区
|
||||
- 以 `simulation_id` 作为业务主维度,支持一次三维仿真记录的时间窗查询与回放
|
||||
|
||||
字段建议(可按性能需要做列化/半结构化折中):
|
||||
- `ts`:时间戳(TimescaleDB 分区键)
|
||||
- `simulation_id`:三维仿真记录 ID(用于回放与归档)
|
||||
- `device_id`:数据来源设备
|
||||
- `signals`:结构化信号(可选 JSONB,或将高频字段拆为列以提升聚合效率)
|
||||
|
||||
典型策略(按需启用):
|
||||
- 保留策略:只保留近 N 天原始点,长期保留用压缩或降采样结果
|
||||
- 连续聚合:生成 1s/100ms 粒度的统计序列用于大屏曲线与回放加速
|
||||
|
||||
## 5. 前端模块设计(Electron + Vue)
|
||||
|
||||
### 5.1 界面体系(显示大屏 / 车载屏 / 控制屏)
|
||||
界面显示功能分为三类屏幕(与规划大纲一致),由 Electron 统一创建窗口并在指定显示器呈现,Vue 负责页面实现。
|
||||
|
||||
1) **显示大屏(面向展示/观摩)**
|
||||
- **系统介绍**
|
||||
- 系统功能介绍:图文并茂;可扩展语音播报与 AI 问答导览
|
||||
- 车辆三维展示:外观/内饰动态展示;关键部件结构展示(转向机构、驱动机构等)
|
||||
- 系统原理展示:动力/转向/制动/灯光等子系统工作原理可视化
|
||||
- **数据看板**
|
||||
- 数据主屏:中间为车辆三维动态模型;周边展示车辆概况、车辆参数(示例:秦 Plus)、历次仿真记录、设备监控(CPU/内存)、模拟报警信息
|
||||
- 仿真回放屏:对单次仿真结果回放;三维仿真回放 + 采集数据动态图表回放;进度显示(总时长/当前进度)
|
||||
- **仿真监控**
|
||||
- 视角切换:主视角 / 驾驶员视角
|
||||
- 主视角:路线图(动态)+ 车辆尾随视角
|
||||
- 驾驶员视角:三维场景动态 + 仪表盘(车速、转速、档位、手刹、车灯、电量等)
|
||||
- 仿真视频录制:动态录制仿真视频过程屏幕(目标 30fps),并与仿真记录绑定
|
||||
|
||||
2) **车载屏(驾驶员视角)**
|
||||
- 仿真界面:三维仿真场景;天气与时段显示;车速显示;仿真状态标识(等待/仿真中/结束)
|
||||
- 数据记录:后台记录仿真过程记录,并动态录制车载屏仿真屏幕(目标 30fps)
|
||||
|
||||
3) **控制屏(操作与管理)**
|
||||
- 场景配置
|
||||
- 仿真场景:城市道路/高速公路/山区道路/乡村道路/校园道路
|
||||
- 气象:晴/雨/雪/雾等;时段:黎明/正午/黄昏/夜间
|
||||
- 车辆颜色:红/白/黑;最高限速:100–180 km/h
|
||||
- 仿真时长:5–60 分钟;驾驶员选择:人员/账号绑定
|
||||
- 车辆传感器校正:自检/校正
|
||||
- 场景推送:下发到车载屏与显示大屏
|
||||
- 仿真控制:开始仿真 / 结束仿真
|
||||
- 系统配置:功能介绍(文字)、端口与路径等基础配置入口
|
||||
- 用户配置:角色与权限配置;用户与角色选择
|
||||
- 数据查询:系统日志查询;仿真记录查询
|
||||
- 数据分析:车辆数据分析、故障模拟诊断、CAN 总线数据模拟解析
|
||||
|
||||
### 5.2 与后端的通信
|
||||
- REST:用于仿真记录控制、配置读写、历史查询、导出触发
|
||||
- WebSocket:订阅实时 topic(设备状态、采样数据、告警、录制进度)
|
||||
- 静态文件:通过后端安全映射路径访问视频与大文件(支持 Range)
|
||||
|
||||
### 5.3 前端路由与窗口映射(建议)
|
||||
为便于多屏稳定运行,建议把“窗口类型”与“路由入口”固化:
|
||||
- 显示大屏
|
||||
- `/big/system-intro`
|
||||
- `/big/dashboard`
|
||||
- `/big/sim-monitor`
|
||||
- 车载屏
|
||||
- `/car/sim`
|
||||
- 控制屏
|
||||
- `/control/config`
|
||||
- `/control/operate`
|
||||
- `/control/query`
|
||||
- `/control/analysis`
|
||||
- `/control/admin`
|
||||
|
||||
### 5.4 外部进程启动与守护(Electron 主进程)
|
||||
主进程负责统一拉起与管理:
|
||||
- 后端 EXE:启动时分配端口(固定端口或自动探测),轮询 `/health` 确认就绪
|
||||
- Unity EXE:按用户选择显示器启动(命令行传参:端口、simulation_id、模式等)
|
||||
- 异常策略:崩溃检测、重启次数限制、日志采集、用户提示
|
||||
|
||||
推荐机制:
|
||||
- 端口管理:优先读配置,冲突时自动寻找可用端口并回写运行态配置
|
||||
- 生命周期:Electron 退出时优雅关闭后端与 Unity(超时后强制)
|
||||
|
||||
## 6. Unity 联动方案(外部项目对接约定)
|
||||
|
||||
### 6.1 对接目标
|
||||
- 实时同步实体车/传感器状态到 Unity(姿态、速度、踏板、方向、报警等)
|
||||
- Unity 交互事件回传(场景切换、碰撞事件、驾驶行为、训练步骤等)
|
||||
- 支持“实时模式”和“回放模式”
|
||||
|
||||
### 6.2 建议协议
|
||||
建议使用同一套消息 Envelope:
|
||||
- **WebSocket(实时)**:订阅 `vehicle.signal`、`control.command` 等;Unity 回传 `unity.event.*`
|
||||
- **HTTP(控制/查询)**:加载仿真记录、读取配置、拉取资源、触发导出
|
||||
|
||||
### 6.3 同步与时钟策略
|
||||
- 后端为权威时间源(服务端时间戳),Unity 按 `ts` 对齐插值
|
||||
- 允许 Unity 维护环形缓冲(例如 100–300ms)平衡抖动与延迟
|
||||
|
||||
## 7. 配置与环境管理
|
||||
|
||||
### 7.1 venv 管理与依赖
|
||||
- 开发环境:`python -m venv venv`,使用 `requirements.txt` 固定依赖版本
|
||||
- 构建环境:单独 `requirements_build.txt`(包含 PyInstaller 等打包依赖)
|
||||
|
||||
### 7.2 配置文件(INI)
|
||||
建议统一使用以下段落(可按需扩展):
|
||||
|
||||
```ini
|
||||
[SERVER]
|
||||
host = 0.0.0.0
|
||||
port = 5000
|
||||
|
||||
[FILEPATH]
|
||||
path = data
|
||||
|
||||
[DATABASE]
|
||||
url = postgresql+psycopg://smartedt:CHANGE_ME@127.0.0.1:5432/smartedt
|
||||
timescaledb = True
|
||||
```
|
||||
|
||||
## 8. 打包发布与部署
|
||||
|
||||
### 8.1 后端打包(PyInstaller)
|
||||
产物目标:
|
||||
- `Backend.exe`(单文件或目录模式)
|
||||
- `config.ini`(可外置,便于现场修改)
|
||||
- `data/`(默认数据目录,可按配置指向其他盘符)
|
||||
|
||||
建议输出:
|
||||
- 内置 OpenAPI 文档(仅本机或受限网络可访问)
|
||||
- 日志写入到 `logs/`(按日期切分)
|
||||
|
||||
### 8.2 前端打包(Electron)
|
||||
产物目标:
|
||||
- `SmartEDT.exe`(安装包/绿色包)
|
||||
- `resources/backend/Backend.exe`(随包携带或首次运行下载)
|
||||
- 统一版本号与升级策略(可选增量更新)
|
||||
|
||||
### 8.3 一键启动链路
|
||||
1. 启动 `SmartEDT.exe`
|
||||
2. Electron 主进程读取配置并拉起 `Backend.exe`
|
||||
3. 后端健康检查通过后,渲染进程连接 REST/WS
|
||||
4. 用户选择“仿真模式”时,拉起 Unity EXE 并注入运行参数
|
||||
|
||||
## 9. 多屏显示方案
|
||||
|
||||
### 9.1 目标
|
||||
- 控制屏在主屏(操作与管理入口)
|
||||
- 显示大屏在副屏/大屏(系统介绍 / 数据看板 / 仿真监控)
|
||||
- 车载屏在驾驶员屏(或指定小屏)
|
||||
- Unity 全屏运行在指定显示器(可与显示大屏分离)
|
||||
|
||||
### 9.2 Electron 实现要点
|
||||
- 启动时枚举显示器,建立“显示器—窗口类型”映射
|
||||
- 针对不同显示器创建多个 BrowserWindow(固定 URL/路由)
|
||||
- 提供“显示器管理”设置页:拖拽绑定、保存配置、开机恢复
|
||||
- Unity 启动参数附带目标显示器索引(或由 Unity 自行选择)
|
||||
|
||||
建议的窗口类型:
|
||||
- BigScreenWindow:显示大屏(可切换 system-intro/dashboard/sim-monitor)
|
||||
- CarScreenWindow:车载屏(驾驶员视角)
|
||||
- ControlWindow:控制屏(配置、控制、查询、分析、管理)
|
||||
|
||||
## 10. 安全、可靠性与合规
|
||||
|
||||
### 10.1 本地数据安全
|
||||
- 默认仅本机访问(可通过配置开放局域网)
|
||||
- 静态文件映射做路径规范化与越界拦截(对标 README 的安全策略)
|
||||
- 敏感字段脱敏显示(subject_id 映射/匿名化)
|
||||
|
||||
### 10.2 稳定性策略
|
||||
- 设备断连:自动重连、指数退避、状态广播
|
||||
- 写盘保护:异步队列 + 批量提交;异常时保证仿真记录可关闭并可恢复
|
||||
- 资源管理:视频录制与采样队列限速,避免磁盘/CPU 被打满
|
||||
|
||||
## 11. 日志、诊断与监控
|
||||
- 分级日志:设备/仿真记录/API/存储
|
||||
- 诊断接口:`/health` 返回依赖与核心子系统状态(设备数量、队列长度等)
|
||||
- 现场排障:一键导出日志与仿真记录元数据(不含隐私或做脱敏)
|
||||
|
||||
## 12. 测试与质量保证
|
||||
- 单元测试:消息模型、路径安全、数据库 CRUD、仿真流程状态机
|
||||
- 集成测试:模拟设备数据源(Mock Adapter),验证 WS 推送与落盘
|
||||
- 性能测试:高频 IMU、视频录制并发、长时仿真稳定性(内存/磁盘增长)
|
||||
|
||||
## 13. 交付物清单(建议)
|
||||
- 后端:可执行程序、配置模板、API 文档、设备接入开发规范
|
||||
- 前端:桌面 EXE、显示器管理、实验流程 UI、日志导出
|
||||
- 对接:Unity 通讯协议文档(topic/消息体/端口/参数)、联调用例
|
||||
- 运维:安装说明、目录与数据管理说明、故障排查手册
|
||||
|
||||
66
系统框架结构评估.md
Normal file
66
系统框架结构评估.md
Normal file
@ -0,0 +1,66 @@
|
||||
# 系统框架结构评估与优化建议
|
||||
|
||||
本文档基于 `d:\Trae_space\SmartEDT\README.md` 与实际代码结构,对已生成的 SmartEDT 系统框架(后端 Python 3.13.1 + FastAPI + TimescaleDB,前端 Electron + Vue)进行评估与分析,并提出优化改进点。
|
||||
|
||||
## 1. 总体评估
|
||||
|
||||
**结论**:系统框架整体架构合理,层次清晰,符合《系统开发技术方案.md》的要求,能够支撑后续功能开发。
|
||||
|
||||
**亮点**:
|
||||
- **架构分层清晰**:后端按 `api`(接口)、`services`(业务逻辑)、`database`(存储)、`device`(硬件抽象)分层,职责单一。
|
||||
- **技术选型落地准确**:Python 3.13.1 + FastAPI + WebSocket + PostgreSQL/TimescaleDB 组合落地,前端 Electron + Vue 多窗口方案已具雏形。
|
||||
- **工程化细节完备**:包含 `config.ini` 配置加载、Mock 设备实现、Docker 友好的环境变量覆盖、静态文件安全映射、以及自动化的数据库 Schema 初始化。
|
||||
- **扩展性良好**:`SimulationManager` 封装了仿真生命周期,`MockVehicleDevice` 提供了设备接入的模板,便于后续替换为真实硬件驱动。
|
||||
|
||||
## 2. 不足与改进建议
|
||||
|
||||
尽管骨架已跑通,但在高频采集、健壮性与可维护性方面仍有优化空间:
|
||||
|
||||
### 2.1 后端(Backend)
|
||||
|
||||
1. **数据库连接池与异步写入优化**
|
||||
- **现状**:`SimulationManager._run_loop` 中每次采集都调用 `_persist_signal`,内部使用 `async with self._session_factory()` 创建新 Session 并提交。
|
||||
- **问题**:高频采集(如 50ms/20Hz)下,频繁创建/销毁 Session 会带来性能开销;且单条 Insert 效率较低。
|
||||
- **改进**:
|
||||
- 引入 **批量写入队列**(Batch Buffer),每 N 条或每 M 秒批量 `COPY` 或 `INSERT` 到 TimescaleDB。
|
||||
- 确保 `engine` 配置了合理的连接池大小(`pool_size`, `max_overflow`)。
|
||||
|
||||
2. **异常处理与优雅退出**
|
||||
- **现状**:`main.py` 的 `shutdown` 钩子中只处理了 `simulation_manager.stop`。
|
||||
- **问题**:如果 WebSocket 广播队列积压或数据库连接卡死,服务可能无法优雅退出。
|
||||
- **改进**:
|
||||
- 增加全局异常捕获(Global Exception Handler)。
|
||||
- 在 `Broadcaster` 中增加队列满或断连的防御性逻辑。
|
||||
|
||||
3. **配置热加载与验证**
|
||||
- **现状**:`settings.py` 仅在启动时加载一次。
|
||||
- **问题**:运行时无法动态调整日志级别或采集频率。
|
||||
- **改进**:虽然不需要完全热加载,但建议增加配置项的 Pydantic 校验(当前已用 `pydantic-settings`,符合预期,但可增强校验规则)。
|
||||
|
||||
4. **设备抽象层增强**
|
||||
- **现状**:`MockVehicleDevice` 直接返回字典。
|
||||
- **改进**:引入 Pydantic 模型或 TypedDict 定义设备数据协议,确保 `payload` 结构在代码层面有强类型约束。
|
||||
|
||||
### 2.2 前端(Frontend)
|
||||
|
||||
1. **状态管理缺失**
|
||||
- **现状**:各 `.vue` 页面独立通过 `useWebSocket` 订阅数据。
|
||||
- **问题**:多窗口间(如控制屏与大屏)可能需要共享某些全局状态(如当前仿真 ID、连接状态),各自维护连接可能造成资源浪费或状态不一致。
|
||||
- **改进**:引入 Pinia 进行全局状态管理(如 `useSimulationStore`),统一管理 WS 连接与消息分发。
|
||||
|
||||
2. **Electron 进程通信 (IPC) 封装**
|
||||
- **现状**:`main.cjs` 启动了后端,但未建立健壮的 IPC 通道来透传后端日志或状态给渲染进程。
|
||||
- **改进**:增加 `preload.js`,通过 `contextBridge` 暴露后端进程状态(PID、启动日志)给控制屏前端,便于运维排查。
|
||||
|
||||
3. **多屏窗口管理增强**
|
||||
- **现状**:`createWindows` 硬编码了 `others[0]`、`others[1]`。
|
||||
- **改进**:增加配置文件(`window-layout.json`)记录屏幕 ID 与窗口路由的绑定关系,实现“开机自动恢复上次布局”。
|
||||
|
||||
## 3. 优化计划
|
||||
|
||||
基于以上分析,将对代码进行以下针对性优化:
|
||||
|
||||
1. **后端**:优化 `SimulationManager` 的入库逻辑,改为 `Buffer` 批量写入模式。
|
||||
2. **后端**:增强 `device` 数据模型的类型定义。
|
||||
3. **前端**:引入 Pinia 并重构 WS 逻辑为 Store 模式。
|
||||
4. **工程**:补充 `docker-compose.yml` 以便快速拉起 PostgreSQL + TimescaleDB 环境。
|
||||
Loading…
Reference in New Issue
Block a user