chore: 初始化项目并添加 .gitignore

This commit is contained in:
root 2026-01-19 14:27:41 +08:00
commit aa4b14181c
58 changed files with 9237 additions and 0 deletions

62
.gitignore vendored Normal file
View 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

View 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

View 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

View 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 过滤查询),依然维持了优秀的查询性能,验证了“结构化字段(索引)+ 半结构化 PayloadJSONB”建模方案的正确性。
## 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
View 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
View 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
View File

@ -0,0 +1 @@

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

@ -0,0 +1 @@

54
backend/api/routes.py Normal file
View 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
View 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
View 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
View 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
}

View File

@ -0,0 +1 @@

12
backend/config/config.ini Normal file
View 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
View 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),
)

View File

@ -0,0 +1 @@

View 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()

View 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
View 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
View 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())

View File

@ -0,0 +1 @@

25
backend/device/base.py Normal file
View 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

View 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
View 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
View 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

View File

@ -0,0 +1,2 @@
pyinstaller>=6.9.0

View File

@ -0,0 +1 @@

View 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)

View 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

View 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()

View 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
View 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

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

60
frontend/package.json Normal file
View 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
View File

@ -0,0 +1 @@
postgresql-x64-17

81
frontend/src/App.vue Normal file
View 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
View 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
View 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
View 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");

View 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>

View 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>

View File

@ -0,0 +1,16 @@
<template>
<div class="page">
<h1>系统介绍</h1>
<p>高精度数据采集实时同步控制三维虚拟仿真算法验证与运维管理</p>
</div>
</template>
<script setup>
</script>
<style scoped>
.page {
padding: 24px;
}
</style>

View 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>

View File

@ -0,0 +1,16 @@
<template>
<div class="page">
<h1>控制屏 - 管理</h1>
<p>用于用户/角色与权限配置后续扩展</p>
</div>
</template>
<script setup>
</script>
<style scoped>
.page {
padding: 24px;
}
</style>

View 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>

View 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>

View 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>

View File

@ -0,0 +1,16 @@
<template>
<div class="page">
<h1>控制屏 - 数据查询</h1>
<p>用于查询仿真记录系统日志与回放数据</p>
</div>
</template>
<script setup>
</script>
<style scoped>
.page {
padding: 24px;
}
</style>

View 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>

View 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
View 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
}
});

View File

@ -0,0 +1,2 @@
此目录放置unity开发的代码
增了电池电量显示

Binary file not shown.

416
系统开发技术方案.md Normal file
View 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 自动生成)
- **实时通讯**WebSocketFastAPI 原生或 Starlette WebSocket用于状态/传感数据/事件推送
- **进程内并发**asyncio + 线程池/进程池(适配阻塞型硬件 SDK
- **持久化**PostgreSQL18 + TimescaleDB用于实时采集数据的时序存储+ SQLAlchemyORM/迁移支持)
- **文件存储**:本地文件系统(与数据库元数据关联)
- **打包**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) **控制屏(操作与管理)**
- 场景配置
- 仿真场景:城市道路/高速公路/山区道路/乡村道路/校园道路
- 气象:晴/雨/雪/雾等;时段:黎明/正午/黄昏/夜间
- 车辆颜色:红/白/黑最高限速100180 km/h
- 仿真时长560 分钟;驾驶员选择:人员/账号绑定
- 车辆传感器校正:自检/校正
- 场景推送:下发到车载屏与显示大屏
- 仿真控制:开始仿真 / 结束仿真
- 系统配置:功能介绍(文字)、端口与路径等基础配置入口
- 用户配置:角色与权限配置;用户与角色选择
- 数据查询:系统日志查询;仿真记录查询
- 数据分析车辆数据分析、故障模拟诊断、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 维护环形缓冲(例如 100300ms平衡抖动与延迟
## 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/消息体/端口/参数)、联调用例
- 运维:安装说明、目录与数据管理说明、故障排查手册

View 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 环境。