feat: TurboSu initial release - racing telemetry dashboard

This commit is contained in:
Yei.J. (AskaEth)
2026-07-25 15:23:40 +08:00
commit d61bb61a83
61 changed files with 5270 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
__pycache__/
*.pyc
venv/
.env
logs/*.log
data/
*.egg-info/
.DS_Store
+119
View File
@@ -0,0 +1,119 @@
# 贡献指南
感谢你对 TurboSu 的关注!本指南将帮助你参与项目开发。
## 贡献方式
### 提交游戏插件
如果你想让 TurboSu 支持一款新的赛车游戏,只需创建一个游戏插件:
1.`games/user/` 下新建文件夹 (以游戏 ID 命名)
2. 编写 `manifest.json` (元信息) 和 `parser.py` (数据解析)
3. 提交 Pull Request 到 `games/user/` 目录,或直接在设置页面通过 "导入插件" 安装
插件结构:
```
games/user/<game_id>/
├── manifest.json # 游戏元信息
└── parser.py # 解析逻辑
```
#### manifest.json 格式
```json
{
"id": "unique_game_id",
"name": "游戏名称",
"parser_type": "forza",
"telemetry_format": "fh5",
"description": "简短描述",
"author": "你的名字",
"version": "1.0.0",
"default_port": 20777
}
```
#### parser.py 规范
必须实现 `get_parser()` 函数,返回一个包含以下方法的对象:
```python
class MyParser:
def game_id(self) -> str:
"""返回与 manifest.json 一致的 game id"""
return "unique_game_id"
def parse(self, data: bytes, addr: tuple) -> TelemetryData:
"""
解析 UDP 数据包
data: 原始字节数据
addr: (host, port) 发送方地址
返回: server.telemetry.data.TelemetryData 对象
"""
```
#### TelemetryData 可用字段
| 字段 | 类型 | 说明 |
|------|------|------|
| speed_kmh | float | 速度 (km/h) |
| speed_mph | float | 速度 (mph) |
| rpm | float | 发动机转速 |
| max_rpm | float | 最大转速 |
| gear | int | 当前档位 (0=N, 1-8, -1=R) |
| throttle | float | 油门 (0-1) |
| brake | float | 刹车 (0-1) |
| clutch | float | 离合 (0-1) |
| handbrake | float | 手刹 (0-1) |
| steering | float | 转向 (-1 to 1) |
| lap_time | float | 当前圈速 (秒) |
| best_lap | float | 最佳圈速 (秒) |
| last_lap | float | 上一圈速 (秒) |
| lap_number | int | 圈数 |
| fuel | float | 燃油量 |
| boost | float | 涡轮增压值 |
| horsepower | float | 马力 |
| torque | float | 扭矩 |
| position_x/y/z | float | 车辆坐标 |
| engine_temp | float | 发动机温度 |
| oil_temp | float | 油温 |
| raw | dict | 原始数据 (自动记录) |
### 提交仪表盘主题
1. 在 TurboSu 设置页面创建/编辑仪表盘
2. 导出为 JSON 文件
3. 提交到 `dashboards/` 目录
### 报告 Bug
在 GitHub Issues 中提交,请包含:
- 操作系统和 Python 版本
- 错误日志 (logs/turbosu.log)
- 复现步骤
### 功能建议
欢迎提交 Issue 讨论新功能方向。
## 开发环境
```bash
cd Project/TurboSu
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python app.py
```
## 代码规范
- Python 代码遵循 PEP 8
- 使用 `utils.logger` 进行日志记录(不要用 print
- 前端 JS 使用模块化结构,文件放在对应目录下
## 许可证
贡献即表示同意你的代码在 Apache 2.0 许可下发布。
+200
View File
@@ -0,0 +1,200 @@
# TurboSu
赛车遥测仪表盘 — 基于 Web 的 B/S 架构外置仪表盘,支持多种赛车游戏遥测数据实时显示。
![License](https://img.shields.io/badge/license-Apache%202.0-blue)
![Python](https://img.shields.io/badge/python-3.10%2B-blue)
![Platform](https://img.shields.io/badge/platform-web%20%7C%20lan-brightgreen)
## 功能特性
- **多游戏支持** —— 插件化架构,内置支持 Forza Horizon 4/5、Forza Motorsport、Assetto Corsa/ACC、F1、iRacing
- **社区扩展** —— 游戏插件可独立导入导出,方便社区贡献小众游戏支持
- **实时仪表盘** —— 速度表、转速表、档位指示器、圈速计时器等
- **场景模式** —— 多仪表盘布局组合,支持多比例画布切换
- **局域网共享** —— 仪表盘/场景生成独立链接,手机/平板/笔记本均可全屏访问
- **跨端自适应** —— 响应式布局,桌面侧边栏 / 平板折叠 / 手机底部导航
- **比例约束渲染** —— 每个仪表盘可指定渲染比例(auto/16:9/4:3/1:1)和渲染模式
- **主题切换** —— Xiaomi HyperOS (Miuix) 风格,支持日间/夜间模式
- **导出导入** —— 仪表盘主题和场景独立配置文件,社区友好
## 快速开始
### 环境要求
- Python 3.10+
- pip
### 安装运行
```bash
# 克隆/下载项目
cd Project/TurboSu
# 创建虚拟环境
python3 -m venv venv
source venv/bin/activate
# 安装依赖
pip install -r requirements.txt
# 启动服务
python app.py
```
浏览器访问 `http://localhost:9527`
### 一键启动
```bash
chmod +x run.sh
./run.sh
```
## 使用指南
### 1. 选择游戏
侧边栏 → "选择游戏" 下拉 → 点击你要玩的游戏。
游戏列表来自 `games/builtin/` (内置) 和 `games/user/` (社区插件)。
### 2. 配置游戏遥测输出
在各游戏中设置 UDP 数据输出:
| 游戏 | 设置位置 | 端口 |
|------|----------|------|
| Forza Horizon 4/5 | 设置 → HUD与游戏 → 数据输出 | 20777 |
| Forza Motorsport | 设置 → 游戏玩法 → UDP 数据输出 | 20777 |
| Assetto Corsa | 内容管理器 → 设置 → 自定义UDP | 9996 |
| ACC | 设置 → 电子设备 → UDP | 20777 |
| F1 24 | 设置 → 遥测设置 → UDP | 20777 |
| iRacing | 选项 → 杂项 → 数据记录 | 20777 |
### 3. 浏览仪表盘
点击侧边栏 "仪表盘" → 浏览/筛选 → 点击卡片在新标签打开。
链接会自动复制,可在局域网设备(手机/平板)打开全屏显示。
### 4. 创建场景
侧边栏 → "场景" → 新建 → 编辑添加多个仪表盘 → 渲染。
场景支持多个画布比例(16:9/4:3等),渲染前选择。
### 5. 数据调试
侧边栏 → "数据测试" → 查看解析后的实时数据和原始数据流。
## 开发指南
### 创建自定义游戏插件
1.`games/user/` 下创建文件夹,如 `games/user/my_game/`
2. 创建 `manifest.json`:
```json
{
"id": "my_game",
"name": "我的游戏",
"parser_type": "forza",
"description": "自定义游戏遥测",
"author": "你的名字",
"version": "1.0.0",
"default_port": 20777
}
```
3. 创建 `parser.py`:
```python
import struct
import time
from server.telemetry.data import TelemetryData
def get_parser():
return MyGameParser()
class MyGameParser:
def game_id(self) -> str:
return "my_game"
def parse(self, data: bytes, addr: tuple) -> TelemetryData:
td = TelemetryData(game_id="my_game", timestamp=time.time())
td.raw = {"raw_hex": data.hex(), "length": len(data)}
# 解析你的游戏数据
# td.speed_kmh = ...
# td.rpm = ...
# td.gear = ...
return td
```
4. 重启 TurboSu 或点击设置 → 重新加载插件
### 创建自定义仪表盘主题
在设置页面的仪表盘管理中创建,或直接编写配置文件。
## 项目结构
```
TurboSu/
├── app.py # FastAPI 主入口
├── config/settings.py # 全局配置管理
├── server/
│ ├── api.py # REST API 路由
│ ├── websocket.py # WebSocket 实时推送
│ ├── game_manager.py # 游戏插件管理器
│ └── telemetry/
│ ├── data.py # 遥测数据结构
│ ├── listener.py # UDP 监听器
│ └── parsers/ # 内置解析器
├── games/
│ ├── builtin/ # 内置游戏插件 (7款)
│ └── user/ # 用户/社区插件
├── models/
│ ├── dashboard.py # 仪表盘数据模型
│ └── scene.py # 场景数据模型
├── dashboards/ # 内置仪表盘主题 (4款)
├── templates/
│ ├── index.html # 主 SPA 页面
│ ├── dashboard.html # 仪表盘渲染页
│ └── scene.html # 场景渲染页
├── static/
│ ├── css/
│ │ ├── miuix.css # HyperOS 样式框架
│ │ └── main.css # 布局样式
│ └── js/ # 前端 JavaScript 模块
├── utils/logger.py # 日志系统
├── data/ # 运行时数据存储
├── logs/ # 日志文件
├── requirements.txt
└── run.sh
```
## 仪表盘配置说明
每个仪表盘主题的 `config.json` 支持以下渲染配置:
```json
{
"aspect_ratio": "auto", // auto | 16:9 | 4:3 | 1:1 | 21:9
"render_mode": "contain", // contain(留黑边) | cover(裁切) | fill(拉伸) | center(居中)
"max_width": 1920, // center 模式最大宽度
"max_height": 1080 // center 模式最大高度
}
```
## 作者
**Yei.J. (AskaEth)**
## 许可证
Apache License 2.0
+110
View File
@@ -0,0 +1,110 @@
from __future__ import annotations
import json
import os
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, FileResponse
from config.settings import get_config
from server.api import router as api_router
from server.websocket import ws_manager
from server.telemetry.listener import telemetry_listener
from utils.logger import get_logger
logger = get_logger(__name__)
BASE_DIR = Path(__file__).resolve().parent
STATIC_DIR = BASE_DIR / "static"
TEMPLATES_DIR = BASE_DIR / "templates"
os.makedirs(BASE_DIR / "logs", exist_ok=True)
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("=" * 50)
logger.info(" TurboSu - Racing Telemetry Dashboard")
logger.info("=" * 50)
cfg = get_config()
logger.info("Server config: %s:%d", cfg.get("server_host", "0.0.0.0"), cfg.get("server_port", 9527))
await telemetry_listener.start()
logger.info("Telemetry listener auto-started")
yield
telemetry_listener.stop()
logger.info("TurboSu shutdown complete")
app = FastAPI(title="TurboSu", version="1.0.0", lifespan=lifespan)
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
app.include_router(api_router)
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
return templates.TemplateResponse("index.html", {"request": request})
@app.get("/dashboard/{theme_id}", response_class=HTMLResponse)
async def dashboard_render(request: Request, theme_id: str):
from models.dashboard import dashboard_manager
theme = dashboard_manager.get(theme_id)
template_html = dashboard_manager.get_template(theme_id)
return templates.TemplateResponse("dashboard.html", {
"request": request,
"theme": theme,
"template_html": template_html,
})
@app.get("/scene/{scene_id}", response_class=HTMLResponse)
async def scene_render(request: Request, scene_id: str):
from models.scene import scene_manager
scene = scene_manager.get(scene_id)
return templates.TemplateResponse("scene.html", {
"request": request,
"scene": scene,
})
@app.websocket("/ws")
async def websocket_endpoint(ws: WebSocket):
cid = await ws_manager.connect(ws)
try:
await ws.send_json({"type": "connected", "client_id": cid})
while True:
msg = await ws.receive_json()
if msg.get("type") == "ping":
await ws.send_json({"type": "pong"})
except WebSocketDisconnect:
pass
except Exception as e:
logger.error("WS error: %s", e)
finally:
ws_manager.disconnect(cid)
@app.get("/health")
async def health():
return {"status": "ok"}
if __name__ == "__main__":
import uvicorn
cfg = get_config()
uvicorn.run(
"app:app",
host=cfg.get("server_host", "0.0.0.0"),
port=cfg.get("server_port", 9527),
reload=False,
log_level="info",
)
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any
from utils.logger import get_logger
logger = get_logger(__name__)
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
CONFIG_FILE = DATA_DIR / "config.json"
DEFAULT_CONFIG: dict[str, Any] = {
"selected_game_id": None,
"theme": "dark",
"sidebar_collapsed": False,
"telemetry_port": 20777,
"telemetry_host": "0.0.0.0",
"server_host": "0.0.0.0",
"server_port": 9527,
}
def load_config() -> dict[str, Any]:
if CONFIG_FILE.exists():
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
cfg = json.load(f)
merged = {**DEFAULT_CONFIG, **cfg}
logger.info("Config loaded from %s", CONFIG_FILE)
return merged
except Exception as e:
logger.error("Failed to load config: %s", e)
logger.info("No config file found, using defaults")
return DEFAULT_CONFIG.copy()
def save_config(cfg: dict[str, Any]) -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
try:
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
logger.info("Config saved to %s", CONFIG_FILE)
except Exception as e:
logger.error("Failed to save config: %s", e)
_config_cache: dict[str, Any] | None = None
def get_config() -> dict[str, Any]:
global _config_cache
if _config_cache is None:
_config_cache = load_config()
return _config_cache
def update_config(key: str, value: Any) -> None:
cfg = get_config()
cfg[key] = value
save_config(cfg)
def reload_config() -> None:
global _config_cache
_config_cache = None
+13
View File
@@ -0,0 +1,13 @@
{
"id": "gear_indicator",
"name": "档位指示器",
"category": "basic",
"description": "醒目大圆档位显示,适合竖屏手机副屏",
"author": "Yei.J. (AskaEth)",
"version": "1.0.0",
"config": {
"icon": "⚙️",
"aspect_ratio": "1:1",
"render_mode": "contain"
}
}
+35
View File
@@ -0,0 +1,35 @@
<div style="display:flex;align-items:center;justify-content:center;height:100%;font-family:'Segoe UI',system-ui,sans-serif;background:linear-gradient(135deg,#0a0a14,#1a1a30);gap:20px;">
<div style="width:100px;height:100px;border-radius:50%;background:rgba(102,126,234,0.15);border:3px solid rgba(102,126,234,0.5);display:flex;align-items:center;justify-content:center;box-shadow:0 0 30px rgba(102,126,234,0.2);">
<span style="font-size:56px;font-weight:900;color:#fff;" data-bind="gear">N</span>
</div>
<div style="display:flex;flex-direction:column;gap:6px;">
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:12px;color:rgba(255,255,255,0.5);width:24px;">R</span>
<div style="width:80px;height:6px;background:rgba(255,255,255,0.1);border-radius:3px;"></div>
</div>
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:12px;color:rgba(255,255,255,0.5);width:24px;">1</span>
<div style="width:80px;height:6px;background:rgba(255,255,255,0.1);border-radius:3px;"></div>
</div>
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:12px;color:rgba(255,255,255,0.5);width:24px;">2</span>
<div style="width:80px;height:6px;background:rgba(255,255,255,0.1);border-radius:3px;"></div>
</div>
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:12px;color:rgba(255,255,255,0.5);width:24px;">3</span>
<div style="width:80px;height:6px;background:rgba(255,255,255,0.1);border-radius:3px;"></div>
</div>
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:12px;color:rgba(255,255,255,0.5);width:24px;">4</span>
<div style="width:80px;height:6px;background:rgba(255,255,255,0.1);border-radius:3px;"></div>
</div>
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:12px;color:rgba(255,255,255,0.5);width:24px;">5</span>
<div style="width:80px;height:6px;background:rgba(255,255,255,0.1);border-radius:3px;"></div>
</div>
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:12px;color:rgba(255,255,255,0.5);width:24px;">6</span>
<div style="width:80px;height:6px;background:rgba(255,255,255,0.1);border-radius:3px;"></div>
</div>
</div>
</div>
+13
View File
@@ -0,0 +1,13 @@
{
"id": "lap_timer",
"name": "圈速计时器",
"category": "timing",
"description": "当前圈速、最佳圈和上圈时间对比显示",
"author": "Yei.J. (AskaEth)",
"version": "1.0.0",
"config": {
"icon": "⏱️",
"aspect_ratio": "4:3",
"render_mode": "contain"
}
}
+20
View File
@@ -0,0 +1,20 @@
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;font-family:'Segoe UI',system-ui,sans-serif;background:linear-gradient(135deg,#0a0a14,#1a1a30);gap:16px;">
<div style="text-align:center;">
<div style="font-size:12px;color:rgba(255,255,255,0.4);letter-spacing:2px;margin-bottom:4px;">LAP</div>
<div style="font-size:14px;color:rgba(255,255,255,0.3);" data-bind="lap_number">0</div>
</div>
<div style="text-align:center;">
<div style="font-size:11px;color:rgba(255,255,255,0.4);letter-spacing:2px;margin-bottom:4px;">CURRENT</div>
<div style="font-size:48px;font-weight:900;color:#667eea;font-variant-numeric:tabular-nums;" data-bind="lap_time">00:00.000</div>
</div>
<div style="display:flex;gap:40px;">
<div style="text-align:center;">
<div style="font-size:11px;color:rgba(255,255,255,0.4);letter-spacing:1px;margin-bottom:4px;">LAST</div>
<div style="font-size:24px;font-weight:700;color:rgba(255,255,255,0.6);font-variant-numeric:tabular-nums;" data-bind="last_lap">00:00.000</div>
</div>
<div style="text-align:center;">
<div style="font-size:11px;color:rgba(255,255,255,0.4);letter-spacing:1px;margin-bottom:4px;">BEST</div>
<div style="font-size:24px;font-weight:700;color:#2ecc71;font-variant-numeric:tabular-nums;" data-bind="best_lap">00:00.000</div>
</div>
</div>
</div>
+15
View File
@@ -0,0 +1,15 @@
{
"id": "speedometer",
"name": "极速仪表 - 速度表",
"category": "speed",
"description": "大字号实时速度显示,支持km/h和mph,适合横屏全屏显示",
"author": "Yei.J. (AskaEth)",
"version": "1.0.0",
"config": {
"icon": "🏎️",
"unit": "kmh",
"show_unit": true,
"aspect_ratio": "auto",
"render_mode": "contain"
}
}
+7
View File
@@ -0,0 +1,7 @@
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;font-family:'Segoe UI',system-ui,sans-serif;background:linear-gradient(135deg,#0a0a14 0%,#1a1a30 100%);">
<div style="font-size:120px;font-weight:900;line-height:1;color:#fff;text-shadow:0 0 40px rgba(102,126,234,0.5);" data-bind="speed_kmh">0</div>
<div style="font-size:24px;color:rgba(255,255,255,0.6);margin-top:8px;letter-spacing:4px;">KM/H</div>
<div style="margin-top:20px;width:300px;height:6px;background:rgba(255,255,255,0.1);border-radius:3px;overflow:hidden;">
<div data-bind-speed style="height:100%;background:linear-gradient(90deg,#667eea,#764ba2);border-radius:3px;transition:width 0.1s ease;width:calc(var(--speed,0) / 400 * 100%);"></div>
</div>
</div>
+15
View File
@@ -0,0 +1,15 @@
{
"id": "tachometer",
"name": "转速仪表 - 转速表",
"category": "rpm",
"description": "赛车风格弧形转速表,支持红区换档提示,推荐横屏",
"author": "Yei.J. (AskaEth)",
"version": "1.0.0",
"config": {
"icon": "⏱️",
"max_rpm": 8000,
"redline": 7000,
"aspect_ratio": "16:9",
"render_mode": "contain"
}
}
+35
View File
@@ -0,0 +1,35 @@
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;font-family:'Segoe UI',system-ui,sans-serif;background:linear-gradient(135deg,#0a0a14,#1a1a30);">
<svg viewBox="0 0 300 200" width="90%" style="max-width:500px;">
<defs>
<linearGradient id="rpmGrad" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#667eea"/>
<stop offset="40%" stop-color="#667eea"/>
<stop offset="70%" stop-color="#f39c12"/>
<stop offset="100%" stop-color="#e74c3c"/>
</linearGradient>
</defs>
<path d="M 50,170 A 100,100 0 0,1 250,170" fill="none" stroke="rgba(255,255,255,0.08)" stroke-width="18" stroke-linecap="round"/>
<path id="rpm-arc" d="M 50,170 A 100,100 0 0,1 250,170" fill="none" stroke="url(#rpmGrad)" stroke-width="18" stroke-linecap="round"
stroke-dasharray="314" stroke-dashoffset="314" style="transition:stroke-dashoffset 0.1s ease;"/>
<text x="150" y="150" text-anchor="middle" fill="#fff" font-size="48" font-weight="900" data-bind="rpm">0</text>
<text x="150" y="180" text-anchor="middle" fill="rgba(255,255,255,0.5)" font-size="14">RPM</text>
<text x="42" y="185" text-anchor="middle" fill="rgba(255,255,255,0.3)" font-size="10">0</text>
<text x="258" y="185" text-anchor="middle" fill="rgba(255,255,255,0.3)" font-size="10">8k</text>
</svg>
<div data-bind-rpm style="display:none;">
<script>
(function() {
const arc = document.getElementById('rpm-arc');
const observer = new MutationObserver(() => {
const rpm = parseFloat(getComputedStyle(document.querySelector('[data-bind-rpm]')).getPropertyValue('--rpm')) || 0;
const max = parseFloat(getComputedStyle(document.querySelector('[data-bind-rpm]')).getPropertyValue('--rpm-max')) || 8000;
if (!isNaN(rpm) && arc) {
const pct = Math.min(rpm / max, 1);
arc.setAttribute('stroke-dashoffset', 314 - (314 * pct));
}
});
observer.observe(document.querySelector('[data-bind-rpm]'), { attributes: true, attributeFilter: ['style'] });
})();
</script>
</div>
</div>
+10
View File
@@ -0,0 +1,10 @@
{
"id": "assetto_corsa",
"name": "Assetto Corsa",
"parser_type": "ac",
"telemetry_format": "ac",
"description": "神力科莎 UDP 遥测数据输出",
"author": "Yei.J. (AskaEth)",
"version": "1.0.0",
"default_port": 9996
}
+33
View File
@@ -0,0 +1,33 @@
import time
from server.telemetry.data import TelemetryData
def get_parser():
return AssettoCorsaParser()
class AssettoCorsaParser:
def game_id(self) -> str:
return "assetto_corsa"
def parse(self, data: bytes, addr: tuple) -> TelemetryData:
td = TelemetryData(game_id="assetto_corsa", timestamp=time.time())
td.raw = {"raw_hex": data.hex(), "length": len(data), "addr": f"{addr[0]}:{addr[1]}"}
try:
text = data.decode("utf-8", errors="replace").rstrip("\r\n")
parts = text.split("\t")
if len(parts) < 10:
return td
td.speed_kmh = float(parts[0])
td.speed_mph = td.speed_kmh * 0.621371
td.rpm = float(parts[1])
td.gear = int(float(parts[2]))
td.throttle = float(parts[3])
td.brake = float(parts[4])
td.steering = float(parts[5])
td.fuel = float(parts[6])
except Exception:
pass
return td
@@ -0,0 +1,10 @@
{
"id": "assetto_corsa_competizione",
"name": "Assetto Corsa Competizione",
"parser_type": "acc",
"telemetry_format": "acc",
"description": "神力科莎:竞速 UDP 遥测数据输出",
"author": "Yei.J. (AskaEth)",
"version": "1.0.0",
"default_port": 20777
}
@@ -0,0 +1,34 @@
import struct
import time
from server.telemetry.data import TelemetryData
def get_parser():
return ACCParser()
class ACCParser:
def game_id(self) -> str:
return "assetto_corsa_competizione"
def parse(self, data: bytes, addr: tuple) -> TelemetryData:
td = TelemetryData(game_id="assetto_corsa_competizione", timestamp=time.time())
td.raw = {"raw_hex": data.hex(), "length": len(data), "addr": f"{addr[0]}:{addr[1]}"}
if len(data) < 200:
return td
try:
td.speed_kmh = struct.unpack_from("<f", data, 0)[0]
td.speed_mph = td.speed_kmh * 0.621371
td.rpm = struct.unpack_from("<f", data, 4)[0]
td.max_rpm = struct.unpack_from("<f", data, 8)[0]
td.gear = struct.unpack_from("<B", data, 12)[0]
td.throttle = struct.unpack_from("<f", data, 16)[0]
td.brake = struct.unpack_from("<f", data, 20)[0]
td.steering = struct.unpack_from("<f", data, 24)[0]
td.fuel = struct.unpack_from("<f", data, 28)[0]
except struct.error:
pass
return td
+10
View File
@@ -0,0 +1,10 @@
{
"id": "f1_24",
"name": "F1 24",
"parser_type": "f1",
"telemetry_format": "f124",
"description": "F1 24 UDP 遥测数据输出",
"author": "Yei.J. (AskaEth)",
"version": "1.0.0",
"default_port": 20777
}
+37
View File
@@ -0,0 +1,37 @@
import struct
import time
from server.telemetry.data import TelemetryData
def get_parser():
return F124Parser()
class F124Parser:
def game_id(self) -> str:
return "f1_24"
def parse(self, data: bytes, addr: tuple) -> TelemetryData:
td = TelemetryData(game_id="f1_24", timestamp=time.time())
td.raw = {"raw_hex": data.hex(), "length": len(data), "addr": f"{addr[0]}:{addr[1]}"}
if len(data) < 1289:
return td
try:
td.speed_kmh = struct.unpack_from("<f", data, 37)[0]
td.speed_mph = td.speed_kmh * 0.621371
td.rpm = struct.unpack_from("<H", data, 41)[0]
td.max_rpm = struct.unpack_from("<H", data, 43)[0]
td.gear = struct.unpack_from("<B", data, 46)[0] & 0x0F
td.throttle = struct.unpack_from("<f", data, 47)[0]
td.brake = struct.unpack_from("<f", data, 55)[0]
td.steering = struct.unpack_from("<B", data, 45)[0] / 127.0
td.lap_number = struct.unpack_from("<B", data, 262)[0]
td.lap_time = struct.unpack_from("<f", data, 63)[0]
td.best_lap = struct.unpack_from("<f", data, 268)[0]
td.fuel = struct.unpack_from("<f", data, 51)[0]
except struct.error:
pass
return td
@@ -0,0 +1,10 @@
{
"id": "forza_horizon_4",
"name": "Forza Horizon 4",
"parser_type": "forza",
"telemetry_format": "fh4",
"description": "极限竞速:地平线4 遥测数据输出",
"author": "Yei.J. (AskaEth)",
"version": "1.0.0",
"default_port": 20777
}
+52
View File
@@ -0,0 +1,52 @@
import struct
import time
from server.telemetry.data import TelemetryData
def get_parser():
return ForzaHorizon4Parser()
class ForzaHorizon4Parser:
def game_id(self) -> str:
return "forza_horizon_4"
def parse(self, data: bytes, addr: tuple) -> TelemetryData:
td = TelemetryData(game_id="forza_horizon_4", timestamp=time.time())
td.raw = {"raw_hex": data.hex(), "length": len(data), "addr": f"{addr[0]}:{addr[1]}"}
if len(data) < 323:
return td
try:
offset = 12
td.rpm = struct.unpack_from("<f", data, 8)[0]
td.max_rpm = struct.unpack_from("<f", data, 16)[0]
td.horsepower = struct.unpack_from("<f", data, 12)[0]
td.torque = struct.unpack_from("<f", data, 20)[0]
td.boost = struct.unpack_from("<f", data, 308)[0]
td.fuel = struct.unpack_from("<f", data, 312)[0]
td.oil_temp = struct.unpack_from("<f", data, 316)[0]
td.engine_temp = struct.unpack_from("<f", data, 320)[0]
td.speed_mph = struct.unpack_from("<f", data, 244)[0]
td.speed_kmh = td.speed_mph * 1.60934
td.gear = struct.unpack_from("<B", data, 264)[0]
td.best_lap = struct.unpack_from("<f", data, 268)[0]
td.last_lap = struct.unpack_from("<f", data, 276)[0]
td.lap_time = struct.unpack_from("<f", data, 284)[0]
td.lap_number = struct.unpack_from("<H", data, 292)[0]
td.position_x = struct.unpack_from("<f", data, 0)[0]
td.position_y = struct.unpack_from("<f", data, 4)[0]
td.position_z = struct.unpack_from("<f", data, 552)[0]
td.acceleration_x = struct.unpack_from("<f", data, 300)[0]
td.acceleration_y = struct.unpack_from("<f", data, 304)[0]
td.acceleration_z = struct.unpack_from("<f", data, 196)[0]
td.throttle = struct.unpack_from("<f", data, 228)[0]
td.brake = struct.unpack_from("<f", data, 232)[0]
td.steering = struct.unpack_from("<f", data, 204)[0]
td.clutch = struct.unpack_from("<f", data, 252)[0]
td.handbrake = struct.unpack_from("<f", data, 256)[0]
except struct.error:
pass
return td
@@ -0,0 +1,10 @@
{
"id": "forza_horizon_5",
"name": "Forza Horizon 5",
"parser_type": "forza",
"telemetry_format": "fh5",
"description": "极限竞速:地平线5 遥测数据输出",
"author": "Yei.J. (AskaEth)",
"version": "1.0.0",
"default_port": 20777
}
+51
View File
@@ -0,0 +1,51 @@
import struct
import time
from server.telemetry.data import TelemetryData
def get_parser():
return ForzaHorizon5Parser()
class ForzaHorizon5Parser:
def game_id(self) -> str:
return "forza_horizon_5"
def parse(self, data: bytes, addr: tuple) -> TelemetryData:
td = TelemetryData(game_id="forza_horizon_5", timestamp=time.time())
td.raw = {"raw_hex": data.hex(), "length": len(data), "addr": f"{addr[0]}:{addr[1]}"}
if len(data) < 323:
return td
try:
td.rpm = struct.unpack_from("<f", data, 8)[0]
td.max_rpm = struct.unpack_from("<f", data, 16)[0]
td.horsepower = struct.unpack_from("<f", data, 12)[0]
td.torque = struct.unpack_from("<f", data, 20)[0]
td.boost = struct.unpack_from("<f", data, 308)[0]
td.fuel = struct.unpack_from("<f", data, 312)[0]
td.oil_temp = struct.unpack_from("<f", data, 316)[0]
td.engine_temp = struct.unpack_from("<f", data, 320)[0]
td.speed_mph = struct.unpack_from("<f", data, 244)[0]
td.speed_kmh = td.speed_mph * 1.60934
td.gear = struct.unpack_from("<B", data, 264)[0]
td.best_lap = struct.unpack_from("<f", data, 268)[0]
td.last_lap = struct.unpack_from("<f", data, 276)[0]
td.lap_time = struct.unpack_from("<f", data, 284)[0]
td.lap_number = struct.unpack_from("<H", data, 292)[0]
td.position_x = struct.unpack_from("<f", data, 0)[0]
td.position_y = struct.unpack_from("<f", data, 4)[0]
td.position_z = struct.unpack_from("<f", data, 552)[0]
td.acceleration_x = struct.unpack_from("<f", data, 300)[0]
td.acceleration_y = struct.unpack_from("<f", data, 304)[0]
td.acceleration_z = struct.unpack_from("<f", data, 196)[0]
td.throttle = struct.unpack_from("<f", data, 228)[0]
td.brake = struct.unpack_from("<f", data, 232)[0]
td.steering = struct.unpack_from("<f", data, 204)[0]
td.clutch = struct.unpack_from("<f", data, 252)[0]
td.handbrake = struct.unpack_from("<f", data, 256)[0]
except struct.error:
pass
return td
@@ -0,0 +1,10 @@
{
"id": "forza_motorsport",
"name": "Forza Motorsport (2023)",
"parser_type": "forza",
"telemetry_format": "fm8",
"description": "极限竞速 Motorsport 遥测数据输出",
"author": "Yei.J. (AskaEth)",
"version": "1.0.0",
"default_port": 20777
}
+48
View File
@@ -0,0 +1,48 @@
import struct
import time
from server.telemetry.data import TelemetryData
def get_parser():
return ForzaMotorsportParser()
class ForzaMotorsportParser:
def game_id(self) -> str:
return "forza_motorsport"
def parse(self, data: bytes, addr: tuple) -> TelemetryData:
td = TelemetryData(game_id="forza_motorsport", timestamp=time.time())
td.raw = {"raw_hex": data.hex(), "length": len(data), "addr": f"{addr[0]}:{addr[1]}"}
if len(data) < 323:
return td
try:
td.rpm = struct.unpack_from("<f", data, 8)[0]
td.max_rpm = struct.unpack_from("<f", data, 16)[0]
td.horsepower = struct.unpack_from("<f", data, 12)[0]
td.torque = struct.unpack_from("<f", data, 20)[0]
td.boost = struct.unpack_from("<f", data, 308)[0]
td.fuel = struct.unpack_from("<f", data, 312)[0]
td.oil_temp = struct.unpack_from("<f", data, 316)[0]
td.engine_temp = struct.unpack_from("<f", data, 320)[0]
td.speed_mph = struct.unpack_from("<f", data, 244)[0]
td.speed_kmh = td.speed_mph * 1.60934
td.gear = struct.unpack_from("<B", data, 264)[0]
td.best_lap = struct.unpack_from("<f", data, 268)[0]
td.last_lap = struct.unpack_from("<f", data, 276)[0]
td.lap_time = struct.unpack_from("<f", data, 284)[0]
td.lap_number = struct.unpack_from("<H", data, 292)[0]
td.position_x = struct.unpack_from("<f", data, 0)[0]
td.position_y = struct.unpack_from("<f", data, 4)[0]
td.position_z = struct.unpack_from("<f", data, 552)[0]
td.throttle = struct.unpack_from("<f", data, 228)[0]
td.brake = struct.unpack_from("<f", data, 232)[0]
td.steering = struct.unpack_from("<f", data, 204)[0]
td.clutch = struct.unpack_from("<f", data, 252)[0]
td.handbrake = struct.unpack_from("<f", data, 256)[0]
except struct.error:
pass
return td
+10
View File
@@ -0,0 +1,10 @@
{
"id": "iracing",
"name": "iRacing",
"parser_type": "iracing",
"telemetry_format": "ir",
"description": "iRacing 遥测数据输出",
"author": "Yei.J. (AskaEth)",
"version": "1.0.0",
"default_port": 20777
}
+32
View File
@@ -0,0 +1,32 @@
import struct
import time
from server.telemetry.data import TelemetryData
def get_parser():
return IRacingParser()
class IRacingParser:
def game_id(self) -> str:
return "iracing"
def parse(self, data: bytes, addr: tuple) -> TelemetryData:
td = TelemetryData(game_id="iracing", timestamp=time.time())
td.raw = {"raw_hex": data.hex(), "length": len(data), "addr": f"{addr[0]}:{addr[1]}"}
if len(data) < 100:
return td
try:
td.speed_mph = struct.unpack_from("<f", data, 36)[0]
td.speed_kmh = td.speed_mph * 1.60934
td.rpm = struct.unpack_from("<f", data, 48)[0]
td.gear = struct.unpack_from("<i", data, 56)[0]
td.throttle = struct.unpack_from("<f", data, 4)[0]
td.brake = struct.unpack_from("<f", data, 8)[0]
td.steering = struct.unpack_from("<f", data, 0)[0]
except struct.error:
pass
return td
View File
+170
View File
@@ -0,0 +1,170 @@
from __future__ import annotations
import json
import os
import shutil
import uuid
from dataclasses import dataclass, field, asdict
from datetime import datetime
from pathlib import Path
from typing import Any
from utils.logger import get_logger
logger = get_logger(__name__)
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
DASHBOARDS_DIR = DATA_DIR / "dashboards"
BUILTIN_DASHBOARDS_DIR = Path(__file__).resolve().parent.parent / "dashboards"
@dataclass
class DashboardTheme:
id: str = field(default_factory=lambda: str(uuid.uuid4()))
name: str = ""
category: str = "basic"
description: str = ""
author: str = ""
version: str = "1.0.0"
preview: str = ""
config: dict[str, Any] = field(default_factory=dict)
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
is_builtin: bool = False
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@classmethod
def from_dict(cls, d: dict[str, Any]) -> DashboardTheme:
return cls(**{k: v for k, v in d.items() if k in cls.__dataclass_fields__})
class DashboardManager:
def __init__(self):
DASHBOARDS_DIR.mkdir(parents=True, exist_ok=True)
self._cache: dict[str, DashboardTheme] = {}
self._load_builtins()
def _load_builtins(self):
if not BUILTIN_DASHBOARDS_DIR.exists():
return
for item in BUILTIN_DASHBOARDS_DIR.iterdir():
if item.is_dir():
cfg_file = item / "config.json"
if cfg_file.exists():
try:
with open(cfg_file, "r", encoding="utf-8") as f:
data = json.load(f)
theme = DashboardTheme.from_dict(data)
theme.is_builtin = True
self._cache[theme.id] = theme
except Exception as e:
logger.error("Failed to load builtin dashboard %s: %s", item.name, e)
def _load_user_dashboards(self):
for f in DASHBOARDS_DIR.glob("*.json"):
try:
with open(f, "r", encoding="utf-8") as fp:
data = json.load(fp)
theme = DashboardTheme.from_dict(data)
self._cache[theme.id] = theme
except Exception as e:
logger.error("Failed to load dashboard %s: %s", f.name, e)
def list_all(self, category: str | None = None) -> list[dict[str, Any]]:
self._cache.clear()
self._load_builtins()
self._load_user_dashboards()
result = [t.to_dict() for t in self._cache.values()]
if category and category != "all":
result = [r for r in result if r.get("category") == category]
return result
def get(self, theme_id: str) -> dict[str, Any] | None:
self._cache.clear()
self._load_builtins()
self._load_user_dashboards()
theme = self._cache.get(theme_id)
return theme.to_dict() if theme else None
def save(self, theme: DashboardTheme) -> bool:
theme.updated_at = datetime.now().isoformat()
theme.is_builtin = False
filepath = DASHBOARDS_DIR / f"{theme.id}.json"
try:
with open(filepath, "w", encoding="utf-8") as f:
json.dump(theme.to_dict(), f, indent=2, ensure_ascii=False)
logger.info("Dashboard saved: %s", theme.id)
return True
except Exception as e:
logger.error("Failed to save dashboard %s: %s", theme.id, e)
return False
def delete(self, theme_id: str) -> bool:
self._cache.clear()
self._load_builtins()
self._load_user_dashboards()
theme = self._cache.get(theme_id)
if theme and theme.is_builtin:
logger.warning("Cannot delete builtin dashboard: %s", theme_id)
return False
filepath = DASHBOARDS_DIR / f"{theme_id}.json"
if filepath.exists():
filepath.unlink()
logger.info("Dashboard deleted: %s", theme_id)
return True
def export_theme(self, theme_id: str) -> dict[str, Any] | None:
theme = self.get(theme_id)
if not theme:
return None
result = {
"type": "dashboard_theme",
"version": "1.0",
"data": theme,
"html": self._read_template_file(theme["id"]),
}
return result
def import_theme(self, data: dict[str, Any]) -> bool:
if data.get("type") != "dashboard_theme":
return False
theme_data = data.get("data", {})
theme = DashboardTheme.from_dict(theme_data)
html_content = data.get("html", "")
if theme.id in self._cache:
theme.id = str(uuid.uuid4())
success = self.save(theme)
if success and html_content:
self._save_template_file(theme.id, html_content)
return success
def _read_template_file(self, theme_id: str) -> str:
for base in [DASHBOARDS_DIR, BUILTIN_DASHBOARDS_DIR]:
tmpl = base / theme_id / "template.html"
if tmpl.exists():
return tmpl.read_text(encoding="utf-8")
return ""
def _save_template_file(self, theme_id: str, content: str):
theme_dir = DASHBOARDS_DIR / theme_id
theme_dir.mkdir(parents=True, exist_ok=True)
tmpl = theme_dir / "template.html"
tmpl.write_text(content, encoding="utf-8")
def get_template(self, theme_id: str) -> str:
return self._read_template_file(theme_id)
def get_categories(self) -> list[str]:
self._cache.clear()
self._load_builtins()
self._load_user_dashboards()
cats = set()
for t in self._cache.values():
if t.category:
cats.add(t.category)
return sorted(cats)
dashboard_manager = DashboardManager()
+147
View File
@@ -0,0 +1,147 @@
from __future__ import annotations
import json
import uuid
from dataclasses import dataclass, field, asdict
from datetime import datetime
from pathlib import Path
from typing import Any
from utils.logger import get_logger
logger = get_logger(__name__)
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
SCENES_DIR = DATA_DIR / "scenes"
@dataclass
class DashboardPlacement:
dashboard_id: str = ""
x: float = 0.0
y: float = 0.0
width: int = 400
height: int = 300
z_index: int = 0
scale_x: float = 1.0
scale_y: float = 1.0
aspect_ratio: str = "auto"
render_mode: str = "contain"
custom_config: dict[str, Any] = field(default_factory=dict)
@dataclass
class SceneCanvas:
width: int = 1920
height: int = 1080
label: str = "16:9"
placements: list[DashboardPlacement] = field(default_factory=list)
@dataclass
class Scene:
id: str = field(default_factory=lambda: str(uuid.uuid4()))
name: str = "New Scene"
game_id: str = ""
description: str = ""
canvases: list[SceneCanvas] = field(default_factory=list)
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
def to_dict(self) -> dict[str, Any]:
d = asdict(self)
d["canvases"] = [asdict(c) for c in self.canvases]
return d
@classmethod
def from_dict(cls, d: dict[str, Any]) -> Scene:
canvases = []
for cd in d.get("canvases", []):
placements = [DashboardPlacement(**p) for p in cd.get("placements", [])]
canvases.append(SceneCanvas(
width=cd.get("width", 1920),
height=cd.get("height", 1080),
label=cd.get("label", ""),
placements=placements,
))
return cls(
id=d.get("id", str(uuid.uuid4())),
name=d.get("name", "New Scene"),
game_id=d.get("game_id", ""),
description=d.get("description", ""),
canvases=canvases,
created_at=d.get("created_at", datetime.now().isoformat()),
updated_at=d.get("updated_at", datetime.now().isoformat()),
)
class SceneManager:
def __init__(self):
SCENES_DIR.mkdir(parents=True, exist_ok=True)
def list_all(self, game_id: str | None = None) -> list[dict[str, Any]]:
scenes = []
for f in SCENES_DIR.glob("*.json"):
try:
with open(f, "r", encoding="utf-8") as fp:
data = json.load(fp)
scene = Scene.from_dict(data)
if game_id is None or scene.game_id == game_id:
scenes.append(scene.to_dict())
except Exception as e:
logger.error("Failed to load scene %s: %s", f.name, e)
return scenes
def get(self, scene_id: str) -> dict[str, Any] | None:
filepath = SCENES_DIR / f"{scene_id}.json"
if not filepath.exists():
return None
try:
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
return Scene.from_dict(data).to_dict()
except Exception as e:
logger.error("Failed to read scene %s: %s", scene_id, e)
return None
def save(self, scene: Scene) -> bool:
scene.updated_at = datetime.now().isoformat()
filepath = SCENES_DIR / f"{scene.id}.json"
try:
with open(filepath, "w", encoding="utf-8") as f:
json.dump(scene.to_dict(), f, indent=2, ensure_ascii=False)
logger.info("Scene saved: %s", scene.id)
return True
except Exception as e:
logger.error("Failed to save scene %s: %s", scene.id, e)
return False
def delete(self, scene_id: str) -> bool:
filepath = SCENES_DIR / f"{scene_id}.json"
if filepath.exists():
filepath.unlink()
logger.info("Scene deleted: %s", scene_id)
return True
return False
def export_scene(self, scene_id: str) -> dict[str, Any] | None:
scene = self.get(scene_id)
if not scene:
return None
return {
"type": "scene",
"version": "1.0",
"data": scene,
}
def import_scene(self, data: dict[str, Any]) -> bool:
if data.get("type") != "scene":
return False
scene_data = data.get("data", {})
if "id" not in scene_data:
scene_data["id"] = str(uuid.uuid4())
scene = Scene.from_dict(scene_data)
return self.save(scene)
scene_manager = SceneManager()
+6
View File
@@ -0,0 +1,6 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
websockets==14.1
aiofiles==24.1.0
jinja2==3.1.4
python-multipart==0.0.18
Executable
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"
if [ ! -d "venv" ]; then
echo "[TurboSu] Creating virtual environment..."
python3 -m venv venv
fi
source venv/bin/activate
echo "[TurboSu] Installing dependencies..."
pip install -q -r requirements.txt
echo "[TurboSu] Starting server..."
python app.py
View File
+279
View File
@@ -0,0 +1,279 @@
from __future__ import annotations
import json
import uuid
from typing import Any
from fastapi import APIRouter, HTTPException, Request, UploadFile, File
from fastapi.responses import JSONResponse
from config.settings import get_config, update_config
from models.dashboard import dashboard_manager, DashboardTheme
from models.scene import scene_manager, Scene, SceneCanvas, DashboardPlacement
from server.telemetry.listener import telemetry_listener
from server.websocket import ws_manager
from utils.logger import get_logger
logger = get_logger(__name__)
router = APIRouter(prefix="/api")
# ---- Status ----
@router.get("/status")
async def get_status():
latest = telemetry_listener.latest_data
cfg = get_config()
return {
"server_running": True,
"telemetry_running": telemetry_listener.is_running,
"ws_clients": ws_manager.client_count,
"packet_count": telemetry_listener.packet_count,
"last_packet_time": telemetry_listener.last_packet_time,
"selected_game_id": cfg.get("selected_game_id"),
"latest_data": latest.to_dict() if latest else None,
}
# ---- Config ----
@router.get("/config")
async def api_get_config():
return get_config()
@router.put("/config")
async def api_update_config(data: dict[str, Any]):
cfg = get_config()
for k, v in data.items():
cfg[k] = v
from config.settings import save_config
save_config(cfg)
if "selected_game_id" in data:
telemetry_listener.set_parser_for_game(data["selected_game_id"])
if "theme" in data:
cfg["theme"] = data["theme"]
return {"ok": True}
# ---- Telemetry ----
@router.post("/telemetry/start")
async def api_start_telemetry():
ok = await telemetry_listener.start()
return {"ok": ok, "running": telemetry_listener.is_running}
@router.post("/telemetry/stop")
async def api_stop_telemetry():
telemetry_listener.stop()
return {"ok": True, "running": telemetry_listener.is_running}
@router.get("/telemetry/latest")
async def api_latest_telemetry():
latest = telemetry_listener.latest_data
if latest:
return {
"data": latest.to_dict(),
"raw": latest.raw,
}
return {"data": None, "raw": None}
# ---- Dashboards ----
@router.get("/dashboards")
async def api_list_dashboards(category: str = "all"):
return dashboard_manager.list_all(category)
@router.get("/dashboards/categories")
async def api_dashboard_categories():
return dashboard_manager.get_categories()
@router.get("/dashboards/{theme_id}")
async def api_get_dashboard(theme_id: str):
theme = dashboard_manager.get(theme_id)
if not theme:
raise HTTPException(404, "Dashboard not found")
return theme
@router.get("/dashboards/{theme_id}/template")
async def api_get_dashboard_template(theme_id: str):
tmpl = dashboard_manager.get_template(theme_id)
return {"html": tmpl}
@router.post("/dashboards")
async def api_create_dashboard(data: dict[str, Any]):
theme = DashboardTheme(
name=data.get("name", "Untitled"),
category=data.get("category", "basic"),
description=data.get("description", ""),
author=data.get("author", ""),
config=data.get("config", {}),
)
dashboard_manager.save(theme)
if data.get("template_html"):
dashboard_manager._save_template_file(theme.id, data["template_html"])
return theme.to_dict()
@router.put("/dashboards/{theme_id}")
async def api_update_dashboard(theme_id: str, data: dict[str, Any]):
existing = dashboard_manager.get(theme_id)
if not existing:
raise HTTPException(404, "Dashboard not found")
theme = DashboardTheme.from_dict(existing)
for k in ["name", "category", "description", "author", "config"]:
if k in data:
setattr(theme, k, data[k])
dashboard_manager.save(theme)
if "template_html" in data:
dashboard_manager._save_template_file(theme.id, data["template_html"])
return theme.to_dict()
@router.delete("/dashboards/{theme_id}")
async def api_delete_dashboard(theme_id: str):
ok = dashboard_manager.delete(theme_id)
return {"ok": ok}
@router.get("/dashboards/{theme_id}/export")
async def api_export_dashboard(theme_id: str):
result = dashboard_manager.export_theme(theme_id)
if not result:
raise HTTPException(404, "Dashboard not found")
return result
@router.post("/dashboards/import")
async def api_import_dashboard(data: dict[str, Any]):
ok = dashboard_manager.import_theme(data)
return {"ok": ok}
# ---- Scenes ----
@router.get("/scenes")
async def api_list_scenes(game_id: str = ""):
return scene_manager.list_all(game_id or None)
@router.get("/scenes/{scene_id}")
async def api_get_scene(scene_id: str):
scene = scene_manager.get(scene_id)
if not scene:
raise HTTPException(404, "Scene not found")
return scene
@router.post("/scenes")
async def api_create_scene(data: dict[str, Any]):
scene = Scene(
name=data.get("name", "New Scene"),
game_id=data.get("game_id", ""),
description=data.get("description", ""),
canvases=[SceneCanvas(label="16:9", width=1920, height=1080)],
)
if data.get("canvases"):
scene.canvases = [
SceneCanvas(
width=c.get("width", 1920),
height=c.get("height", 1080),
label=c.get("label", ""),
placements=[DashboardPlacement(**p) for p in c.get("placements", [])],
)
for c in data["canvases"]
]
scene_manager.save(scene)
return scene.to_dict()
@router.put("/scenes/{scene_id}")
async def api_update_scene(scene_id: str, data: dict[str, Any]):
existing = scene_manager.get(scene_id)
if not existing:
raise HTTPException(404, "Scene not found")
scene = Scene.from_dict(existing)
for k in ["name", "game_id", "description"]:
if k in data:
setattr(scene, k, data[k])
if "canvases" in data:
scene.canvases = [
SceneCanvas(
width=c.get("width", 1920),
height=c.get("height", 1080),
label=c.get("label", "Custom"),
placements=[DashboardPlacement(**p) for p in c.get("placements", [])],
)
for c in data["canvases"]
]
scene_manager.save(scene)
return scene.to_dict()
@router.delete("/scenes/{scene_id}")
async def api_delete_scene(scene_id: str):
ok = scene_manager.delete(scene_id)
return {"ok": ok}
@router.get("/scenes/{scene_id}/export")
async def api_export_scene(scene_id: str):
result = scene_manager.export_scene(scene_id)
if not result:
raise HTTPException(404, "Scene not found")
return result
@router.post("/scenes/import")
async def api_import_scene(data: dict[str, Any]):
ok = scene_manager.import_scene(data)
return {"ok": ok}
# ---- Game Plugins ----
from server.game_manager import game_plugin_manager
@router.get("/games")
async def api_list_games():
return game_plugin_manager.list_all()
@router.get("/games/{plugin_id}")
async def api_get_game(plugin_id: str):
gp = game_plugin_manager.get(plugin_id)
if not gp:
raise HTTPException(404, "Game plugin not found")
return gp.to_dict()
@router.post("/games/install")
async def api_install_game_plugin(data: dict[str, Any]):
ok = game_plugin_manager.install_plugin(data)
return {"ok": ok}
@router.delete("/games/{plugin_id}")
async def api_remove_game_plugin(plugin_id: str):
ok = game_plugin_manager.remove_plugin(plugin_id)
return {"ok": ok}
@router.get("/games/{plugin_id}/export")
async def api_export_game_plugin(plugin_id: str):
result = game_plugin_manager.export_plugin(plugin_id)
if not result:
raise HTTPException(404, "Game plugin not found")
return result
@router.post("/games/reload")
async def api_reload_game_plugins():
game_plugin_manager.reload()
return {"ok": True, "count": len(game_plugin_manager.list_all())}
+191
View File
@@ -0,0 +1,191 @@
from __future__ import annotations
import json
import importlib.util
import sys
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import Any
from utils.logger import get_logger
logger = get_logger(__name__)
GAMES_DIR = Path(__file__).resolve().parent.parent / "games"
BUILTIN_DIR = GAMES_DIR / "builtin"
USER_DIR = GAMES_DIR / "user"
@dataclass
class GamePlugin:
id: str = ""
name: str = ""
parser_type: str = "forza"
telemetry_format: str = ""
description: str = ""
author: str = ""
version: str = "1.0.0"
default_port: int = 20777
icon: str = ""
is_builtin: bool = True
enabled: bool = True
manifest_path: str = ""
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@classmethod
def from_manifest(cls, path: Path, is_builtin: bool = True) -> GamePlugin | None:
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
gp = cls(
id=data.get("id", path.parent.name),
name=data.get("name", path.parent.name),
parser_type=data.get("parser_type", "forza"),
telemetry_format=data.get("telemetry_format", ""),
description=data.get("description", ""),
author=data.get("author", ""),
version=data.get("version", "1.0.0"),
default_port=data.get("default_port", 20777),
icon=data.get("icon", ""),
is_builtin=is_builtin,
enabled=True,
manifest_path=str(path.parent),
)
return gp
except Exception as e:
logger.error("Failed to load game plugin manifest %s: %s", path, e)
return None
class GamePluginManager:
def __init__(self):
BUILTIN_DIR.mkdir(parents=True, exist_ok=True)
USER_DIR.mkdir(parents=True, exist_ok=True)
self._plugins: dict[str, GamePlugin] = {}
self._parser_cache: dict[str, Any] = {}
self._discover()
def _discover(self):
self._plugins.clear()
for d in [BUILTIN_DIR, USER_DIR]:
if not d.exists():
continue
is_builtin = (d == BUILTIN_DIR)
for item in d.iterdir():
if item.is_dir():
manifest = item / "manifest.json"
if manifest.exists():
gp = GamePlugin.from_manifest(manifest, is_builtin)
if gp:
self._plugins[gp.id] = gp
logger.debug("Discovered game plugin: %s", gp.id)
def list_all(self) -> list[dict[str, Any]]:
self._discover()
return [p.to_dict() for p in self._plugins.values()]
def get(self, plugin_id: str) -> GamePlugin | None:
self._discover()
return self._plugins.get(plugin_id)
def get_parser(self, plugin_id: str) -> Any | None:
self._discover()
gp = self._plugins.get(plugin_id)
if not gp:
return None
cache_key = gp.id
if cache_key in self._parser_cache:
return self._parser_cache[cache_key]
parser_dir = Path(gp.manifest_path)
parser_file = parser_dir / "parser.py"
if not parser_file.exists():
return None
try:
module_name = f"turbosu_game_{gp.id}"
spec = importlib.util.spec_from_file_location(module_name, parser_file)
if spec and spec.loader:
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
if hasattr(module, "get_parser"):
parser = module.get_parser()
self._parser_cache[cache_key] = parser
logger.info("Loaded parser for game: %s", gp.name)
return parser
except Exception as e:
logger.error("Failed to load parser for %s: %s", gp.id, e)
return None
def reload(self):
self._discover()
self._parser_cache.clear()
def install_plugin(self, data: dict[str, Any]) -> bool:
plugin_id = data.get("id", "")
if not plugin_id:
return False
dest_dir = USER_DIR / plugin_id
dest_dir.mkdir(parents=True, exist_ok=True)
manifest = data.get("manifest", {})
with open(dest_dir / "manifest.json", "w", encoding="utf-8") as f:
json.dump(manifest, f, indent=2, ensure_ascii=False)
if data.get("parser_code"):
with open(dest_dir / "parser.py", "w", encoding="utf-8") as f:
f.write(data["parser_code"])
self._discover()
logger.info("Plugin installed: %s", plugin_id)
return True
def remove_plugin(self, plugin_id: str) -> bool:
gp = self._plugins.get(plugin_id)
if gp and gp.is_builtin:
logger.warning("Cannot remove builtin plugin: %s", plugin_id)
return False
import shutil
dest_dir = USER_DIR / plugin_id
if dest_dir.exists():
shutil.rmtree(dest_dir)
self._discover()
logger.info("Plugin removed: %s", plugin_id)
return True
return False
def export_plugin(self, plugin_id: str) -> dict[str, Any] | None:
gp = self._plugins.get(plugin_id)
if not gp:
return None
parser_dir = Path(gp.manifest_path)
manifest_file = parser_dir / "manifest.json"
parser_file = parser_dir / "parser.py"
result: dict[str, Any] = {
"type": "game_plugin",
"version": "1.0",
"manifest": {},
"parser_code": "",
}
if manifest_file.exists():
with open(manifest_file, "r", encoding="utf-8") as f:
result["manifest"] = json.load(f)
if parser_file.exists():
with open(parser_file, "r", encoding="utf-8") as f:
result["parser_code"] = f.read()
return result
game_plugin_manager = GamePluginManager()
View File
+85
View File
@@ -0,0 +1,85 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass
class TelemetryData:
game_id: str = ""
timestamp: float = 0.0
speed_kmh: float = 0.0
speed_mph: float = 0.0
rpm: float = 0.0
max_rpm: float = 8000.0
gear: int = 0
throttle: float = 0.0
brake: float = 0.0
clutch: float = 0.0
handbrake: float = 0.0
steering: float = 0.0
lap_time: float = 0.0
best_lap: float = 0.0
last_lap: float = 0.0
lap_number: int = 0
position_x: float = 0.0
position_y: float = 0.0
position_z: float = 0.0
acceleration_x: float = 0.0
acceleration_y: float = 0.0
acceleration_z: float = 0.0
engine_temp: float = 0.0
oil_temp: float = 0.0
fuel: float = 0.0
boost: float = 0.0
horsepower: float = 0.0
torque: float = 0.0
car_name: str = ""
car_class: str = ""
raw: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {
"game_id": self.game_id,
"timestamp": self.timestamp,
"speed_kmh": self.speed_kmh,
"speed_mph": self.speed_mph,
"rpm": self.rpm,
"max_rpm": self.max_rpm,
"gear": self.gear,
"throttle": self.throttle,
"brake": self.brake,
"clutch": self.clutch,
"handbrake": self.handbrake,
"steering": self.steering,
"lap_time": self.lap_time,
"best_lap": self.best_lap,
"last_lap": self.last_lap,
"lap_number": self.lap_number,
"position_x": self.position_x,
"position_y": self.position_y,
"position_z": self.position_z,
"acceleration_x": self.acceleration_x,
"acceleration_y": self.acceleration_y,
"acceleration_z": self.acceleration_z,
"engine_temp": self.engine_temp,
"oil_temp": self.oil_temp,
"fuel": self.fuel,
"boost": self.boost,
"horsepower": self.horsepower,
"torque": self.torque,
"car_name": self.car_name,
"car_class": self.car_class,
}
+151
View File
@@ -0,0 +1,151 @@
from __future__ import annotations
import asyncio
import time
from typing import Any, Callable
from config.settings import get_config
from server.telemetry.parsers import PARSER_MAP, BaseParser
from server.telemetry.data import TelemetryData
from server.game_manager import game_plugin_manager
from utils.logger import get_logger
logger = get_logger(__name__)
class TelemetryListener:
def __init__(self):
self._transport: asyncio.DatagramTransport | None = None
self._running = False
self._callbacks: list[Callable[[TelemetryData], None]] = []
self._parser: BaseParser | None = None
self._parser_cache: dict[str, BaseParser] = {}
self._latest_data: TelemetryData | None = None
self._last_packet_time: float = 0.0
self._packet_count: int = 0
@property
def is_running(self) -> bool:
return self._running
@property
def latest_data(self) -> TelemetryData | None:
return self._latest_data
@property
def packet_count(self) -> int:
return self._packet_count
@property
def last_packet_time(self) -> float:
return self._last_packet_time
def on_data(self, callback: Callable[[TelemetryData], None]):
self._callbacks.append(callback)
def remove_callback(self, callback: Callable[[TelemetryData], None]):
if callback in self._callbacks:
self._callbacks.remove(callback)
async def start(self) -> bool:
if self._running:
return True
cfg = get_config()
host = cfg.get("telemetry_host", "0.0.0.0")
port = cfg.get("telemetry_port", 20777)
selected_game = cfg.get("selected_game_id")
parser_key = None
if selected_game:
for game in cfg.get("games", []):
if game["id"] == selected_game:
parser_key = game.get("parser", "forza")
break
loop = asyncio.get_event_loop()
try:
self._transport, _ = await loop.create_datagram_endpoint(
lambda: _TelemetryProtocol(self),
local_addr=(host, port),
)
self._running = True
if parser_key and parser_key in PARSER_MAP:
self._parser = self._get_parser(parser_key)
logger.info("Telemetry listener started on %s:%d [parser=%s]", host, port, parser_key)
else:
self._parser = None
logger.info("Telemetry listener started on %s:%d [auto-detect]", host, port)
return True
except Exception as e:
logger.error("Failed to start telemetry listener: %s", e)
return False
def stop(self):
self._running = False
if self._transport:
self._transport.close()
self._transport = None
logger.info("Telemetry listener stopped")
def _get_parser(self, key: str) -> BaseParser:
if key not in self._parser_cache:
cls = PARSER_MAP.get(key)
if cls:
self._parser_cache[key] = cls()
return self._parser_cache.get(key, PARSER_MAP["forza"]())
def _handle_packet(self, data: bytes, addr: tuple[str, int]):
self._last_packet_time = time.time()
self._packet_count += 1
td = None
if self._parser:
td = self._parser.parse(data, addr)
else:
for parser_cls in PARSER_MAP.values():
p = parser_cls()
td = p.parse(data, addr)
if td and td.speed_kmh > 0:
break
if td is None:
td = TelemetryData(timestamp=time.time(), raw={"raw_hex": data.hex(), "length": len(data)})
self._latest_data = td
for cb in self._callbacks:
try:
cb(td)
except Exception as e:
logger.error("Callback error: %s", e)
def set_parser_for_game(self, game_id: str):
custom_parser = game_plugin_manager.get_parser(game_id)
if custom_parser:
self._parser = custom_parser
logger.info("Parser loaded from plugin for game: %s", game_id)
return
gp = game_plugin_manager.get(game_id)
if gp:
parser_key = gp.parser_type
if parser_key in PARSER_MAP:
self._parser = self._get_parser(parser_key)
logger.info("Parser set to %s for game %s", parser_key, game_id)
return
self._parser = None
class _TelemetryProtocol(asyncio.DatagramProtocol):
def __init__(self, listener: TelemetryListener):
self._listener = listener
def datagram_received(self, data: bytes, addr: tuple[str, int]):
self._listener._handle_packet(data, addr)
def connection_made(self, transport):
pass
telemetry_listener = TelemetryListener()
+241
View File
@@ -0,0 +1,241 @@
from __future__ import annotations
import struct
import time
from server.telemetry.data import TelemetryData
from server.telemetry.parsers.base import BaseParser
from utils.logger import get_logger
logger = get_logger(__name__)
class ForzaParser(BaseParser):
FORZA_FORMATS = {
"fh4": "Forza Horizon 4",
"fh5": "Forza Horizon 5",
"fm8": "Forza Motorsport",
}
def __init__(self, format_id: str = "fh5"):
self._format = format_id
def game_id(self) -> str:
return self._format
def parse(self, data: bytes, addr: tuple[str, int]) -> TelemetryData:
td = TelemetryData(game_id=self._format, timestamp=time.time())
td.raw = {"raw_hex": data.hex(), "length": len(data), "addr": f"{addr[0]}:{addr[1]}"}
if len(data) < 323:
logger.warning("Forza data too short: %d bytes", len(data))
return td
try:
if self._format == "fh4":
offset = 0
else:
offset = 0
td.rpm = struct.unpack_from("<f", data, 8)[0]
td.max_rpm = struct.unpack_from("<f", data, 16)[0]
td.horsepower = struct.unpack_from("<f", data, 12)[0]
td.torque = struct.unpack_from("<f", data, 20)[0]
td.boost = struct.unpack_from("<f", data, 308)[0]
td.fuel = struct.unpack_from("<f", data, 312)[0]
td.oil_temp = struct.unpack_from("<f", data, 316)[0]
td.engine_temp = struct.unpack_from("<f", data, 320)[0]
td.speed_mph = struct.unpack_from("<f", data, 244)
td.speed_kmh = td.speed_mph * 1.60934
td.gear = struct.unpack_from("<B", data, 264)[0]
td.best_lap = struct.unpack_from("<f", data, 268)[0]
td.last_lap = struct.unpack_from("<f", data, 276)[0]
td.lap_time = struct.unpack_from("<f", data, 284)[0]
td.lap_number = struct.unpack_from("<H", data, 292)[0]
td.position_x = struct.unpack_from("<f", data, 0)[0]
td.position_y = struct.unpack_from("<f", data, 4)[0]
td.position_z = struct.unpack_from("<f", data, 552)[0]
td.acceleration_x = struct.unpack_from("<f", data, 300)[0]
td.acceleration_y = struct.unpack_from("<f", data, 304)[0]
td.acceleration_z = struct.unpack_from("<f", data, 196)[0]
td.throttle = struct.unpack_from("<f", data, 228)[0]
td.brake = struct.unpack_from("<f", data, 232)[0]
td.steering = struct.unpack_from("<f", data, 204)[0]
td.clutch = struct.unpack_from("<f", data, 252)[0]
td.handbrake = struct.unpack_from("<f", data, 256)[0]
td.raw.update({
"speed_mph": td.speed_mph,
"speed_kmh": td.speed_kmh,
"rpm": td.rpm,
"max_rpm": td.max_rpm,
"gear": td.gear,
"throttle": td.throttle,
"brake": td.brake,
"steering": td.steering,
"boost": td.boost,
"horsepower": td.horsepower,
"torque": td.torque,
})
except struct.error as e:
logger.error("Forza parse error: %s", e)
return td
class ACCParser(BaseParser):
def game_id(self) -> str:
return "acc"
def parse(self, data: bytes, addr: tuple[str, int]) -> TelemetryData:
td = TelemetryData(game_id="acc", timestamp=time.time())
td.raw = {"raw_hex": data.hex(), "length": len(data), "addr": f"{addr[0]}:{addr[1]}"}
if len(data) < 200:
logger.warning("ACC data too short: %d bytes", len(data))
return td
try:
td.speed_kmh = struct.unpack_from("<f", data, 0)[0]
td.speed_mph = td.speed_kmh * 0.621371
td.rpm = struct.unpack_from("<f", data, 4)[0]
td.max_rpm = struct.unpack_from("<f", data, 8)[0]
td.gear = struct.unpack_from("<B", data, 12)[0]
td.throttle = struct.unpack_from("<f", data, 16)[0]
td.brake = struct.unpack_from("<f", data, 20)[0]
td.steering = struct.unpack_from("<f", data, 24)[0]
td.fuel = struct.unpack_from("<f", data, 28)[0]
td.raw.update({
"speed_kmh": td.speed_kmh,
"rpm": td.rpm,
"gear": td.gear,
"throttle": td.throttle,
"brake": td.brake,
})
except struct.error as e:
logger.error("ACC parse error: %s", e)
return td
class F1Parser(BaseParser):
def game_id(self) -> str:
return "f1"
def parse(self, data: bytes, addr: tuple[str, int]) -> TelemetryData:
td = TelemetryData(game_id="f1", timestamp=time.time())
td.raw = {"raw_hex": data.hex(), "length": len(data), "addr": f"{addr[0]}:{addr[1]}"}
if len(data) < 1289:
logger.warning("F1 data too short: %d bytes", len(data))
return td
try:
td.speed_kmh = struct.unpack_from("<f", data, 37)[0]
td.speed_mph = td.speed_kmh * 0.621371
td.rpm = struct.unpack_from("<H", data, 41)[0]
td.max_rpm = struct.unpack_from("<H", data, 43)[0]
td.gear = struct.unpack_from("<B", data, 46)[0] & 0x0F
td.throttle = struct.unpack_from("<f", data, 47)[0]
td.brake = struct.unpack_from("<f", data, 55)[0]
td.steering = struct.unpack_from("<B", data, 45)[0] / 127.0
td.lap_number = struct.unpack_from("<B", data, 262)[0]
td.lap_time = struct.unpack_from("<f", data, 63)[0]
td.best_lap = struct.unpack_from("<f", data, 268)[0]
td.fuel = struct.unpack_from("<f", data, 51)[0]
td.raw.update({
"speed_kmh": td.speed_kmh,
"rpm": td.rpm,
"gear": td.gear,
"throttle": td.throttle,
"brake": td.brake,
"lap_time": td.lap_time,
})
except struct.error as e:
logger.error("F1 parse error: %s", e)
return td
class IRacingParser(BaseParser):
def game_id(self) -> str:
return "iracing"
def parse(self, data: bytes, addr: tuple[str, int]) -> TelemetryData:
td = TelemetryData(game_id="iracing", timestamp=time.time())
td.raw = {"raw_hex": data.hex(), "length": len(data), "addr": f"{addr[0]}:{addr[1]}"}
if len(data) < 100:
logger.warning("iRacing data too short: %d bytes", len(data))
return td
try:
td.speed_mph = struct.unpack_from("<f", data, 36)[0]
td.speed_kmh = td.speed_mph * 1.60934
td.rpm = struct.unpack_from("<f", data, 48)[0]
td.gear = struct.unpack_from("<i", data, 56)[0]
td.throttle = struct.unpack_from("<f", data, 4)[0]
td.brake = struct.unpack_from("<f", data, 8)[0]
td.steering = struct.unpack_from("<f", data, 0)[0]
td.raw.update({
"speed_mph": td.speed_mph,
"rpm": td.rpm,
"gear": td.gear,
"throttle": td.throttle,
"brake": td.brake,
})
except struct.error as e:
logger.error("iRacing parse error: %s", e)
return td
class ACParser(BaseParser):
def game_id(self) -> str:
return "ac"
def parse(self, data: bytes, addr: tuple[str, int]) -> TelemetryData:
td = TelemetryData(game_id="ac", timestamp=time.time())
td.raw = {"raw_hex": data.hex(), "length": len(data), "addr": f"{addr[0]}:{addr[1]}"}
try:
parts = data.decode("utf-8", errors="replace").rstrip("\r\n").split("\t")
if len(parts) < 10:
return td
td.speed_kmh = float(parts[0])
td.speed_mph = td.speed_kmh * 0.621371
td.rpm = float(parts[1])
td.gear = int(float(parts[2]))
td.throttle = float(parts[3])
td.brake = float(parts[4])
td.steering = float(parts[5])
td.fuel = float(parts[6])
td.raw.update({
"speed_kmh": td.speed_kmh,
"rpm": td.rpm,
"gear": td.gear,
})
except Exception as e:
logger.error("AC parse error: %s", e)
return td
PARSER_MAP: dict[str, type[BaseParser]] = {
"forza": ForzaParser,
"ac": ACParser,
"acc": ACCParser,
"f1": F1Parser,
"iracing": IRacingParser,
}
+18
View File
@@ -0,0 +1,18 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from server.telemetry.data import TelemetryData
class BaseParser(ABC):
@abstractmethod
def parse(self, data: bytes, addr: tuple[str, int]) -> TelemetryData:
...
@abstractmethod
def game_id(self) -> str:
...
def supports(self, raw_data: bytes) -> bool:
return True
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
import asyncio
import json
import time
from typing import Any
from fastapi import WebSocket, WebSocketDisconnect
from server.telemetry.listener import telemetry_listener
from server.telemetry.data import TelemetryData
from utils.logger import get_logger
logger = get_logger(__name__)
class ConnectionManager:
def __init__(self):
self._connections: dict[str, WebSocket] = {}
self._counter = 0
self._broadcast_task: asyncio.Task | None = None
self._pending_data: TelemetryData | None = None
async def connect(self, ws: WebSocket) -> str:
await ws.accept()
self._counter += 1
cid = f"client_{self._counter}"
self._connections[cid] = ws
logger.info("WS client connected: %s (total: %d)", cid, len(self._connections))
if not self._broadcast_task or self._broadcast_task.done():
self._broadcast_task = asyncio.create_task(self._broadcast_loop())
return cid
def disconnect(self, cid: str):
self._connections.pop(cid, None)
logger.info("WS client disconnected: %s (total: %d)", cid, len(self._connections))
if not self._connections and self._broadcast_task:
self._broadcast_task.cancel()
self._broadcast_task = None
def push_telemetry(self, data: TelemetryData):
self._pending_data = data
async def broadcast(self, message: dict[str, Any]):
dead = []
for cid, ws in self._connections.items():
try:
await ws.send_json(message)
except Exception:
dead.append(cid)
for cid in dead:
self.disconnect(cid)
async def _broadcast_loop(self):
last_sent = 0.0
throttle_interval = 1.0 / 30.0
try:
while self._connections:
now = time.time()
if now - last_sent >= throttle_interval and self._pending_data:
data = self._pending_data
self._pending_data = None
last_sent = now
await self.broadcast({
"type": "telemetry",
"data": data.to_dict(),
})
await asyncio.sleep(0.01)
except asyncio.CancelledError:
pass
@property
def client_count(self) -> int:
return len(self._connections)
ws_manager = ConnectionManager()
def on_telemetry(data: TelemetryData):
ws_manager.push_telemetry(data)
telemetry_listener.on_data(on_telemetry)
+863
View File
@@ -0,0 +1,863 @@
/* TurboSu Main Layout - HyperOS Design System
* Layout: TopAppBar + NavigationRail (sidebar) + Content
*/
.app-layout {
display: flex;
width: 100%;
height: 100vh;
overflow: hidden;
}
/* ===== NavigationRail: Sidebar (HyperOS NavigationRail style) ===== */
.sidebar {
width: var(--sidebar-width);
height: 100vh;
background: var(--bg-secondary);
border-right: 1px solid var(--divider);
display: flex;
flex-direction: column;
transition: width var(--transition-slow) cubic-bezier(0.4, 0, 0.2, 1);
z-index: 100;
flex-shrink: 0;
overflow: hidden;
}
.sidebar.collapsed {
width: var(--sidebar-collapsed-width);
}
.sidebar.collapsed .nav-label,
.sidebar.collapsed .sidebar-title,
.sidebar.collapsed .collapse-arrow {
opacity: 0;
visibility: hidden;
transition: opacity var(--transition-fast), visibility var(--transition-fast);
}
.sidebar-header {
display: flex;
align-items: center;
padding: 16px 12px;
gap: 10px;
min-height: 60px;
}
.sidebar-logo {
flex-shrink: 0;
display: flex;
align-items: center;
}
.sidebar-title {
font-size: 17px;
font-weight: 600;
white-space: nowrap;
color: var(--text-primary);
transition: opacity var(--transition-fast);
}
.sidebar-toggle-btn {
margin-left: auto;
display: flex;
align-items: center;
justify-content: center;
width: 32px; height: 32px;
border-radius: var(--radius-sm);
color: var(--text-secondary);
cursor: pointer;
background: transparent;
flex-shrink: 0;
transition: all var(--transition-fast);
}
.sidebar-toggle-btn:hover {
background: rgba(128, 128, 128, 0.12);
color: var(--text-primary);
}
.sidebar-nav {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 4px 8px;
display: flex;
flex-direction: column;
gap: 1px;
}
/* Navigation items (HyperOS NavigationRail item style) */
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 11px 12px;
border-radius: var(--radius-md);
color: var(--text-secondary);
text-decoration: none;
transition: all var(--transition-fast);
white-space: nowrap;
cursor: pointer;
border: none;
background: none;
font-size: 14px;
font-weight: 400;
width: 100%;
text-align: left;
position: relative;
}
.nav-item:hover {
background: rgba(128, 128, 128, 0.08);
color: var(--text-primary);
}
.nav-item:active {
background: rgba(128, 128, 128, 0.14);
}
.nav-item.active {
background: var(--accent-container);
color: var(--accent-on-container);
font-weight: 500;
}
.nav-item svg {
flex-shrink: 0;
width: 20px;
height: 20px;
}
.nav-label {
transition: opacity var(--transition-fast);
}
.nav-section {
margin-top: 2px;
}
.collapsible-toggle .collapse-arrow {
margin-left: auto;
transition: transform var(--transition-normal), opacity var(--transition-fast);
}
.collapsible-toggle.collapsed .collapse-arrow {
transform: rotate(-90deg);
}
.nav-submenu {
overflow: hidden;
max-height: 600px;
transition: max-height var(--transition-slow);
padding-left: 6px;
}
.nav-submenu.collapsed {
max-height: 0;
}
.nav-submenu .nav-item {
padding: 9px 12px;
font-size: 13px;
}
.sidebar-bottom {
margin-top: auto;
border-top: 1px solid var(--divider);
padding: 4px 8px;
display: flex;
flex-direction: column;
gap: 1px;
}
/* ===== TopAppBar (HyperOS style) ===== */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
height: var(--topbar-height);
padding: 0 20px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--divider);
flex-shrink: 0;
transition: background var(--transition-normal);
}
.topbar-left {
display: flex;
align-items: center;
gap: 12px;
}
.topbar-logo-title {
display: flex;
align-items: center;
gap: 10px;
opacity: 1;
transition: opacity var(--transition-fast);
}
.topbar-logo-title.hidden {
opacity: 0;
pointer-events: none;
}
.topbar-title-text {
font-size: 17px;
font-weight: 600;
color: var(--text-primary);
}
.topbar-right {
display: flex;
align-items: center;
gap: 8px;
}
/* Connection status indicator */
.status-indicator {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: var(--radius-md);
background: var(--bg-glass);
backdrop-filter: var(--blur-small);
-webkit-backdrop-filter: var(--blur-small);
border: 1px solid var(--border-subtle);
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.status-dot {
width: 8px; height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.status-indicator.connected .status-dot {
background: var(--success);
box-shadow: 0 0 6px rgba(52, 199, 89, 0.4);
}
.status-indicator.disconnected .status-dot {
background: var(--danger);
box-shadow: 0 0 6px rgba(233, 70, 52, 0.4);
}
.theme-light .icon-sun { display: none; }
.theme-dark .icon-moon { display: none; }
/* ===== Main Content Area ===== */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
background: var(--bg-primary);
}
.page-container {
flex: 1;
overflow: hidden;
position: relative;
}
.page {
display: none;
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
overflow-y: auto;
padding: 20px 24px;
}
.page.active {
display: block;
}
/* ===== Page Header ===== */
.page-header {
margin-bottom: 20px;
}
.page-header h2 {
font-size: 22px;
font-weight: 600;
color: var(--text-primary);
}
.page-header p {
font-size: 13px;
color: var(--text-secondary);
margin-top: 4px;
}
/* ===== Home Page: Status Cards ===== */
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 14px;
margin-bottom: 20px;
}
.status-card {
padding: 18px 20px;
}
.status-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.status-card-title {
font-size: 12px;
color: var(--text-secondary);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.status-card-value {
font-size: 26px;
font-weight: 600;
color: var(--text-primary);
}
.status-card-detail {
font-size: 12px;
color: var(--text-tertiary);
margin-top: 2px;
}
/* ===== Dashboard Page (Browser) ===== */
.dashboard-layout {
display: flex;
height: 100%;
gap: 0;
}
.dashboard-sub-sidebar {
width: 170px;
flex-shrink: 0;
padding: 12px 8px;
border-right: 1px solid var(--divider);
overflow-y: auto;
}
.dashboard-sub-sidebar .category-item {
display: flex;
align-items: center;
padding: 9px 12px;
border-radius: var(--radius-md);
cursor: pointer;
color: var(--text-secondary);
transition: all var(--transition-fast);
font-size: 13px;
margin-bottom: 1px;
}
.dashboard-sub-sidebar .category-item:hover {
background: rgba(128, 128, 128, 0.08);
color: var(--text-primary);
}
.dashboard-sub-sidebar .category-item.active {
background: var(--accent-container);
color: var(--accent-on-container);
font-weight: 500;
}
.dashboard-main {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
}
.theme-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
gap: 14px;
}
.theme-card {
overflow: hidden;
cursor: pointer;
transition: all var(--transition-normal);
}
.theme-card:hover {
transform: translateY(-3px);
box-shadow: var(--shadow-lg);
}
.theme-card .theme-card-preview {
width: 100%;
height: 130px;
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
font-size: 44px;
color: var(--text-tertiary);
border-bottom: 1px solid var(--border-subtle);
}
.theme-card .theme-card-info {
padding: 12px 14px;
}
.theme-card .theme-card-name {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
}
.theme-card .theme-card-meta {
font-size: 11px;
color: var(--text-tertiary);
display: flex;
gap: 8px;
}
/* ===== Scene Page ===== */
.scene-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 20px;
}
.scene-header h2 {
font-size: 22px;
font-weight: 600;
}
.scene-actions {
display: flex;
gap: 8px;
}
.scene-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px;
}
.scene-card {
padding: 18px;
cursor: pointer;
transition: all var(--transition-normal);
}
.scene-card:hover {
transform: translateY(-3px);
box-shadow: var(--shadow-md);
}
.scene-card .scene-card-name {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
}
.scene-card .scene-card-game {
font-size: 12px;
color: var(--accent-on-container);
margin-bottom: 6px;
}
.scene-card .scene-card-meta {
font-size: 12px;
color: var(--text-tertiary);
display: flex;
justify-content: space-between;
}
.scene-canvas-list {
display: flex;
gap: 5px;
flex-wrap: wrap;
margin-top: 8px;
}
.scene-canvas-badge {
font-size: 11px;
padding: 3px 8px;
background: var(--bg-tertiary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
color: var(--text-secondary);
}
/* ===== Modal/Dialog (HyperOS OverlayDialog style) ===== */
.modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: var(--window-dimming);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.2s ease both;
}
.modal {
background: var(--bg-secondary);
border: 1px solid var(--border-normal);
border-radius: var(--radius-xl);
padding: 28px;
min-width: 620px;
max-width: 90vw;
max-height: 85vh;
overflow-y: auto;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.25);
animation: fadeInUp 0.25s cubic-bezier(0.4, 0, 0.2, 1) both;
}
.modal h3 {
font-size: 18px;
font-weight: 600;
margin-bottom: 20px;
color: var(--text-primary);
}
.modal-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid var(--divider);
}
/* ===== Scene Editor Canvas ===== */
.scene-editor-canvas {
position: relative;
margin: 16px 0;
border: 2px dashed var(--outline);
border-radius: var(--radius-md);
overflow: hidden;
background: var(--bg-tertiary);
}
.scene-editor-placement {
position: absolute;
border: 2px solid var(--accent);
border-radius: var(--radius-sm);
cursor: move;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: var(--text-primary);
background: var(--accent-container);
min-width: 80px;
min-height: 50px;
}
/* ===== Debug Page ===== */
.debug-container {
display: flex;
flex-direction: column;
height: 100%;
gap: 14px;
}
.debug-toolbar {
display: flex;
align-items: center;
gap: 10px;
}
.debug-data {
flex: 1;
overflow-y: auto;
padding: 14px 16px;
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', 'Consolas', monospace;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
background: var(--bg-tertiary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
color: var(--text-primary);
}
.debug-field {
margin-bottom: 10px;
padding: 10px 14px;
background: var(--bg-glass);
backdrop-filter: var(--blur-small);
-webkit-backdrop-filter: var(--blur-small);
border-radius: var(--radius-md);
border: 1px solid var(--border-subtle);
}
.debug-field .debug-field-name {
font-size: 11px;
color: var(--accent-on-container);
margin-bottom: 3px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.debug-field .debug-field-value {
font-size: 14px;
color: var(--text-primary);
font-family: 'JetBrains Mono', 'SF Mono', monospace;
}
/* ===== Settings Page ===== */
.settings-section {
margin-bottom: 28px;
}
.settings-section h3 {
font-size: 17px;
font-weight: 600;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--divider);
color: var(--text-primary);
}
.settings-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
gap: 16px;
}
.settings-label {
font-size: 14px;
color: var(--text-primary);
}
.settings-desc {
font-size: 12px;
color: var(--text-tertiary);
margin-top: 2px;
}
.settings-control {
flex-shrink: 0;
}
.settings-control input[type="number"] {
width: 100px;
}
.settings-control select {
min-width: 160px;
}
/* ===== Empty State ===== */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
color: var(--text-tertiary);
}
.empty-state svg {
margin-bottom: 16px;
opacity: 0.25;
}
.empty-state h4 {
font-size: 16px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 8px;
}
.empty-state p {
font-size: 13px;
max-width: 400px;
}
/* ===== Toast / Snackbar (HyperOS Snackbar style) ===== */
.toast-container {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 999;
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: none;
}
.toast {
padding: 12px 20px;
border-radius: var(--radius-md);
background: var(--bg-menu);
backdrop-filter: var(--blur-large);
-webkit-backdrop-filter: var(--blur-large);
border: 1px solid var(--border-normal);
color: var(--text-primary);
font-size: 13px;
font-weight: 500;
animation: fadeInUp 0.3s cubic-bezier(0.4, 0, 0.2, 1) both;
box-shadow: var(--shadow-lg);
pointer-events: auto;
}
.toast.success { border-left: 3px solid var(--success); }
.toast.error { border-left: 3px solid var(--danger); }
.toast.info { border-left: 3px solid var(--accent); }
/* ===== Form ===== */
.form-group {
margin-bottom: 14px;
}
.form-group label {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 6px;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
}
.form-group textarea {
min-height: 80px;
resize: vertical;
}
/* ===== Code inline ===== */
code {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-size: 12px;
background: var(--bg-tertiary);
padding: 2px 6px;
border-radius: var(--radius-xs);
color: var(--accent-on-container);
}
/* ===== Mobile Bottom Navigation Bar (HyperOS NavigationBar) ===== */
@media (max-width: 767px) {
.app-layout {
flex-direction: column;
}
.sidebar {
display: none;
}
.main-content {
padding-bottom: 64px;
}
.topbar {
height: 48px;
padding: 0 12px;
}
.topbar-logo-title.hidden {
opacity: 1;
pointer-events: auto;
}
.topbar-logo-title svg {
width: 24px;
height: 24px;
}
.topbar-title-text {
font-size: 15px;
}
.page {
padding: 12px 14px;
}
.status-grid {
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.status-card {
padding: 14px 16px;
}
.status-card-value {
font-size: 22px;
}
.theme-grid {
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.scene-grid {
grid-template-columns: 1fr;
}
.dashboard-layout {
flex-direction: column;
}
.dashboard-sub-sidebar {
width: 100%;
flex-direction: row;
overflow-x: auto;
border-right: none;
border-bottom: 1px solid var(--divider);
padding: 6px;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
}
.dashboard-sub-sidebar .category-item {
display: inline-flex;
flex-shrink: 0;
}
.modal {
min-width: auto;
max-width: 95vw;
margin: 12px;
padding: 20px;
border-radius: var(--radius-lg);
}
/* Mobile bottom nav */
.mobile-nav {
display: flex;
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 64px;
background: var(--bg-secondary);
border-top: 1px solid var(--divider);
z-index: 150;
backdrop-filter: var(--blur-large);
-webkit-backdrop-filter: var(--blur-large);
padding-bottom: env(safe-area-inset-bottom, 0);
}
.mobile-nav .nav-item {
flex: 1;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 4px;
padding: 6px 4px;
font-size: 10px;
border-radius: 0;
min-width: 0;
}
.mobile-nav .nav-item svg {
width: 22px;
height: 22px;
}
.mobile-nav .nav-label {
font-size: 10px;
opacity: 1;
visibility: visible;
}
.debug-container > div:last-child {
grid-template-columns: 1fr 1fr;
}
.settings-row {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.settings-control {
width: 100%;
}
.settings-control input[type="number"],
.settings-control select {
width: 100%;
}
}
/* ===== Tablet Layout ===== */
@media (min-width: 768px) and (max-width: 1023px) {
.sidebar {
width: var(--sidebar-collapsed-width);
}
.sidebar.expanded {
position: fixed;
width: var(--sidebar-width);
box-shadow: var(--shadow-lg);
z-index: 150;
}
.page {
padding: 16px 20px;
}
.status-grid {
grid-template-columns: repeat(3, 1fr);
}
.theme-grid {
grid-template-columns: repeat(3, 1fr);
}
}
/* ===== Desktop ===== */
@media (min-width: 1024px) {
.mobile-nav {
display: none;
}
.status-grid {
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
}
.theme-grid {
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
}
}
/* ===== High-DPI / Retina ===== */
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
.glass, .glass-light, .glass-card {
-webkit-font-smoothing: antialiased;
}
}
/* ===== Reduced Motion ===== */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
+332
View File
@@ -0,0 +1,332 @@
/* Miuix CSS - HyperOS Design System for Web
* Color tokens match Xiaomi HyperOS design spec
* Reference: https://compose-miuix-ui.github.io/miuix/zh_CN/guide/colors
*/
:root {
--accent: #3482FF;
--accent-hover: #277AF7;
--danger: #E94634;
--success: #34C759;
--warning: #FF9F0A;
--radius-xs: 4px;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--radius-xxl: 24px;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.08);
--shadow-md: 0 2px 8px rgba(0,0,0,0.10);
--shadow-lg: 0 4px 16px rgba(0,0,0,0.12);
--transition-fast: 0.15s cubic-bezier(0.4, 0, 0.2, 1);
--transition-normal: 0.25s cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 0.35s cubic-bezier(0.4, 0, 0.2, 1);
--sidebar-width: 260px;
--sidebar-collapsed-width: 60px;
--topbar-height: 56px;
--blur-small: blur(6px) saturate(140%);
--blur-medium: blur(12px) saturate(160%);
--blur-large: blur(20px) saturate(180%);
}
/* ===== Light Theme (HyperOS Light) ===== */
.theme-light {
--bg-primary: #FFFFFF;
--bg-secondary: #F7F7F7;
--bg-tertiary: #F0F0F0;
--bg-glass: rgba(255, 255, 255, 0.82);
--bg-glass-hover: rgba(255, 255, 255, 0.90);
--bg-glass-active: rgba(240, 240, 240, 0.88);
--bg-card: rgba(255, 255, 255, 0.78);
--bg-card-hover: rgba(255, 255, 255, 0.90);
--bg-input: rgba(248, 248, 248, 0.9);
--bg-input-focus: rgba(240, 245, 255, 0.95);
--bg-menu: rgba(255, 255, 255, 0.95);
--bg-tooltip: rgba(30, 30, 30, 0.92);
--text-primary: #000000;
--text-secondary: #8C93B0;
--text-tertiary: #959595;
--text-inverse: #FFFFFF;
--text-link: #3482FF;
--text-disabled: #B2B2B2;
--border-subtle: rgba(0, 0, 0, 0.04);
--border-normal: rgba(0, 0, 0, 0.08);
--border-strong: rgba(0, 0, 0, 0.12);
--outline: #D9D9D9;
--divider: #E0E0E0;
--accent-container: #EAF2FF;
--accent-on-container: #3482FF;
--error-container: #FDF6F4;
--error-on-container: #410002;
--scrollbar-bg: rgba(0, 0, 0, 0.04);
--scrollbar-thumb: rgba(0, 0, 0, 0.12);
}
/* ===== Dark Theme (HyperOS Dark) ===== */
.theme-dark {
--bg-primary: #000000;
--bg-secondary: #242424;
--bg-tertiary: #2D2D2D;
--bg-glass: rgba(36, 36, 36, 0.72);
--bg-glass-hover: rgba(45, 45, 45, 0.78);
--bg-glass-active: rgba(50, 50, 50, 0.82);
--bg-card: rgba(36, 36, 36, 0.65);
--bg-card-hover: rgba(45, 45, 45, 0.75);
--bg-input: rgba(30, 30, 30, 0.8);
--bg-input-focus: rgba(40, 40, 55, 0.9);
--bg-menu: rgba(36, 36, 36, 0.95);
--bg-tooltip: rgba(20, 20, 20, 0.92);
--text-primary: rgba(255, 255, 255, 0.90);
--text-secondary: rgba(255, 255, 255, 0.62);
--text-tertiary: rgba(255, 255, 255, 0.42);
--text-inverse: #000000;
--text-link: #277AF7;
--text-disabled: #666666;
--border-subtle: rgba(255, 255, 255, 0.04);
--border-normal: rgba(255, 255, 255, 0.08);
--border-strong: rgba(255, 255, 255, 0.12);
--outline: #404040;
--divider: #393939;
--accent-container: rgba(39, 122, 247, 0.12);
--accent-on-container: #277AF7;
--error-container: #2E0603;
--error-on-container: #FFDAD6;
--scrollbar-bg: rgba(255, 255, 255, 0.03);
--scrollbar-thumb: rgba(255, 255, 255, 0.10);
--window-dimming: rgba(0, 0, 0, 0.60);
}
/* ===== Global Reset ===== */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
width: 100%; height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'PingFang SC', 'MiSans', 'Microsoft YaHei', system-ui, sans-serif;
font-size: 14px;
line-height: 1.5;
color: var(--text-primary);
background: var(--bg-primary);
overflow: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
transition: background var(--transition-normal), color var(--transition-normal);
}
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-tertiary);
}
a { color: var(--text-link); text-decoration: none; }
button { cursor: pointer; border: none; outline: none; font-family: inherit; }
/* ===== Inputs ===== */
input, select, textarea {
font-family: inherit;
font-size: 14px;
padding: 10px 14px;
border: 1px solid var(--outline);
border-radius: var(--radius-md);
background: var(--bg-input);
color: var(--text-primary);
outline: none;
transition: border-color var(--transition-fast), background var(--transition-fast), box-shadow var(--transition-fast);
}
input:focus, select:focus, textarea:focus {
border-color: var(--accent);
background: var(--bg-input-focus);
box-shadow: 0 0 0 2px rgba(52, 130, 255, 0.15);
}
input::placeholder, textarea::placeholder {
color: var(--text-tertiary);
}
/* ===== Surface Components (HyperOS style) ===== */
.surface {
background: var(--bg-secondary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
transition: background var(--transition-normal);
}
.surface-high {
background: var(--bg-tertiary);
border: 1px solid var(--border-normal);
border-radius: var(--radius-lg);
}
.surface-highest {
background: var(--bg-tertiary);
border: 1px solid var(--border-strong);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
}
/* ===== Glass Surface (blur effect) ===== */
.glass {
background: var(--bg-glass);
backdrop-filter: var(--blur-large);
-webkit-backdrop-filter: var(--blur-large);
border: 1px solid var(--border-subtle);
}
.glass-light {
background: var(--bg-glass);
backdrop-filter: var(--blur-medium);
-webkit-backdrop-filter: var(--blur-medium);
border: 1px solid var(--border-subtle);
}
.glass-card {
background: var(--bg-card);
backdrop-filter: var(--blur-medium);
-webkit-backdrop-filter: var(--blur-medium);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
transition: background var(--transition-normal), box-shadow var(--transition-fast), transform var(--transition-fast);
}
.glass-card:hover {
background: var(--bg-card-hover);
box-shadow: var(--shadow-md);
}
.glass-card:active {
transform: scale(0.985);
transition: transform 0.1s ease;
}
/* ===== Buttons (HyperOS style) ===== */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 20px;
border-radius: var(--radius-md);
font-size: 14px;
font-weight: 500;
letter-spacing: 0.2px;
transition: all var(--transition-fast);
white-space: nowrap;
position: relative;
overflow: hidden;
}
.btn::after {
content: '';
position: absolute;
inset: 0;
background: transparent;
transition: background var(--transition-fast);
border-radius: inherit;
}
.btn:hover::after {
background: rgba(255, 255, 255, 0.08);
}
.btn:active {
transform: scale(0.97);
}
.btn-primary {
background: var(--accent);
color: #FFFFFF;
box-shadow: 0 2px 6px rgba(52, 130, 255, 0.25);
}
.btn-primary:hover {
background: var(--accent-hover);
box-shadow: 0 4px 12px rgba(52, 130, 255, 0.35);
}
.btn-secondary {
background: var(--bg-glass);
color: var(--text-primary);
border: 1px solid var(--border-normal);
}
.btn-secondary:hover {
background: var(--bg-glass-hover);
border-color: var(--border-strong);
}
.btn-danger {
background: var(--danger);
color: #FFFFFF;
}
.btn-danger:hover {
opacity: 0.88;
}
.btn-sm { padding: 6px 14px; font-size: 12px; border-radius: var(--radius-sm); }
.btn-lg { padding: 14px 28px; font-size: 16px; border-radius: var(--radius-md); }
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px; height: 36px;
border-radius: var(--radius-md);
background: transparent;
color: var(--text-secondary);
transition: all var(--transition-fast);
}
.icon-btn:hover {
background: var(--bg-glass-hover);
color: var(--text-primary);
}
.icon-btn:active {
background: var(--bg-glass-active);
}
/* ===== Badge ===== */
.badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
}
.badge-success { background: rgba(52, 199, 89, 0.15); color: var(--success); }
.badge-warning { background: rgba(255, 159, 10, 0.15); color: var(--warning); }
.badge-danger { background: rgba(233, 70, 52, 0.15); color: var(--danger); }
.badge-info { background: var(--accent-container); color: var(--accent-on-container); }
.badge-secondary { background: var(--bg-tertiary); color: var(--text-secondary); }
/* ===== Divider ===== */
.divider {
height: 1px;
background: var(--divider);
margin: 0;
}
/* ===== Animations ===== */
.animated {
animation: fadeInUp 0.35s cubic-bezier(0.4, 0, 0.2, 1) both;
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes pulse-ring {
0% { transform: scale(0.8); opacity: 1; }
100% { transform: scale(1.4); opacity: 0; }
}
+66
View File
@@ -0,0 +1,66 @@
const API = {
_base: '/api',
async get(path) {
const res = await fetch(`${this._base}${path}`);
return res.json();
},
async post(path, data) {
const res = await fetch(`${this._base}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return res.json();
},
async put(path, data) {
const res = await fetch(`${this._base}${path}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return res.json();
},
async del(path) {
const res = await fetch(`${this._base}${path}`, { method: 'DELETE' });
return res.json();
},
async getStatus() { return this.get('/status'); },
async getConfig() { return this.get('/config'); },
async updateConfig(data) { return this.put('/config', data); },
async getGames() { return this.get('/games'); },
async getDashboards(category) { return this.get(`/dashboards?category=${category || 'all'}`); },
async getCategories() { return this.get('/dashboards/categories'); },
async getDashboard(id) { return this.get(`/dashboards/${id}`); },
async getDashboardTemplate(id) { return this.get(`/dashboards/${id}/template`); },
async createDashboard(data) { return this.post('/dashboards', data); },
async updateDashboard(id, data) { return this.put(`/dashboards/${id}`, data); },
async deleteDashboard(id) { return this.del(`/dashboards/${id}`); },
async exportDashboard(id) { return this.get(`/dashboards/${id}/export`); },
async importDashboard(data) { return this.post('/dashboards/import', data); },
async getScenes(gameId) { return this.get(`/scenes?game_id=${gameId || ''}`); },
async getScene(id) { return this.get(`/scenes/${id}`); },
async createScene(data) { return this.post('/scenes', data); },
async updateScene(id, data) { return this.put(`/scenes/${id}`, data); },
async deleteScene(id) { return this.del(`/scenes/${id}`); },
async exportScene(id) { return this.get(`/scenes/${id}/export`); },
async importScene(data) { return this.post('/scenes/import', data); },
async startTelemetry() { return this.post('/telemetry/start'); },
async stopTelemetry() { return this.post('/telemetry/stop'); },
async getLatestTelemetry() { return this.get('/telemetry/latest'); },
async installGamePlugin(data) { return this.post('/games/install', data); },
async removeGamePlugin(id) { return this.del(`/games/${id}`); },
async exportGamePlugin(id) { return this.get(`/games/${id}/export`); },
async reloadGamePlugins() { return this.post('/games/reload'); },
};
window.API = API;
+26
View File
@@ -0,0 +1,26 @@
(function () {
Theme.init();
WS.init();
Sidebar.init();
Topbar.init();
PageHome.init();
PageDashboard.init();
PageScene.init();
PageDebug.init();
PageSettings.init();
Router.init();
document.getElementById('theme-toggle').addEventListener('click', () => {
Theme.toggle();
});
const savedTheme = localStorage.getItem('turbosu-theme');
if (savedTheme && savedTheme !== Theme.current) {
Theme._current = savedTheme;
Theme.apply();
}
console.log('%cTurboSu %cReady',
'color:#667eea;font-size:16px;font-weight:bold;',
'color:#a8a8c0;');
})();
+119
View File
@@ -0,0 +1,119 @@
const Sidebar = {
_el: null,
_gameListEl: null,
_gameToggleEl: null,
_gameLabelEl: null,
init() {
this._el = document.getElementById('sidebar');
this._gameListEl = document.getElementById('game-list');
this._gameToggleEl = document.getElementById('game-selector-toggle');
this._gameLabelEl = document.getElementById('game-selector-label');
this._topbarLogo = document.getElementById('topbar-logo-title');
document.getElementById('sidebar-toggle').addEventListener('click', () => this.toggle());
this._gameToggleEl.addEventListener('click', () => this._toggleGameList());
const saved = localStorage.getItem('turbosu-sidebar-collapsed');
if (saved === 'true') this.collapse(true);
this._loadGames();
},
toggle() {
this._el.classList.toggle('collapsed');
this._el.classList.toggle('expanded');
const isCollapsed = this._el.classList.contains('collapsed');
this._topbarLogo.classList.toggle('hidden', !isCollapsed);
localStorage.setItem('turbosu-sidebar-collapsed', isCollapsed);
},
collapse(silent) {
this._el.classList.add('collapsed');
this._el.classList.remove('expanded');
this._topbarLogo.classList.remove('hidden');
if (!silent) localStorage.setItem('turbosu-sidebar-collapsed', 'true');
},
expand(silent) {
this._el.classList.remove('collapsed');
this._el.classList.add('expanded');
this._topbarLogo.classList.add('hidden');
if (!silent) localStorage.setItem('turbosu-sidebar-collapsed', 'false');
},
async _loadGames() {
const games = await API.getGames();
this._gameListEl.innerHTML = '';
if (!Array.isArray(games) || games.length === 0) {
this._gameListEl.innerHTML = '<div style="padding:8px 12px;color:var(--text-tertiary);font-size:12px;">暂无可用的游戏插件</div>';
return;
}
const selectedId = (await API.getConfig()).selected_game_id;
games.forEach(game => {
const item = document.createElement('button');
item.className = 'nav-item';
if (selectedId === game.id) item.classList.add('active');
item.innerHTML = `
<span style="font-size:16px;flex-shrink:0;">&#x25C9;</span>
<span class="nav-label">${game.name}</span>
`;
item.addEventListener('click', () => this._selectGame(game.id));
this._gameListEl.appendChild(item);
});
this._updateGameLabel(selectedId, games);
},
_updateGameLabel(selectedId, games) {
if (selectedId && games) {
const game = games.find(g => g.id === selectedId);
if (game) {
this._gameLabelEl.textContent = game.name;
return;
}
}
this._gameLabelEl.textContent = '选择游戏';
},
async _selectGame(gameId) {
await API.updateConfig({ selected_game_id: gameId });
await API.startTelemetry();
this._gameListEl.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active'));
const items = this._gameListEl.querySelectorAll('.nav-item');
items.forEach(item => {
if (item.textContent.trim()) item.classList.add('active');
});
const games = await API.getGames();
this._updateGameLabel(gameId, games);
this._loadGames();
if (typeof Router !== 'undefined' && Router.currentPage === 'scene') {
Router.navigate('scene');
}
},
_toggleGameList() {
const toggle = this._gameToggleEl;
const list = this._gameListEl;
const isCollapsed = toggle.classList.contains('collapsed');
if (isCollapsed) {
toggle.classList.remove('collapsed');
list.classList.remove('collapsed');
} else {
toggle.classList.add('collapsed');
list.classList.add('collapsed');
}
},
setActive(route) {
this._el.querySelectorAll('.nav-item[data-route]').forEach(el => {
el.classList.toggle('active', el.getAttribute('data-route') === route);
});
}
};
window.Sidebar = Sidebar;
+32
View File
@@ -0,0 +1,32 @@
const Topbar = {
_statusEl: null,
init() {
this._statusEl = document.getElementById('connection-status');
document.getElementById('theme-toggle').addEventListener('click', () => {
Theme.toggle();
});
WS.on('status', (status) => {
this._statusEl.className = `status-indicator ${status}`;
this._statusEl.querySelector('.status-text').textContent =
status === 'connected' ? '已连接' :
status === 'error' ? '连接错误' : '未连接';
});
setInterval(() => this._refreshStatus(), 5000);
},
async _refreshStatus() {
try {
const status = await API.getStatus();
const wsStatus = status.ws_clients > 0 ? 'connected' : 'disconnected';
this._statusEl.className = `status-indicator ${wsStatus}`;
this._statusEl.querySelector('.status-text').textContent =
wsStatus === 'connected' ? `已连接 (${status.ws_clients})` : '未连接';
} catch (e) {}
}
};
window.Topbar = Topbar;
+132
View File
@@ -0,0 +1,132 @@
const PageDashboard = {
_el: null,
_currentCategory: 'all',
init() {
this._el = document.getElementById('page-dashboard');
},
async render() {
this._el.innerHTML = this._template();
await this._loadCategories();
await this._loadThemes();
this._bindEvents();
},
async _loadCategories() {
try {
const cats = await API.getCategories();
const listEl = document.getElementById('dash-category-list');
if (!listEl) return;
const allItem = document.createElement('div');
allItem.className = 'category-item active';
allItem.textContent = '全部';
allItem.dataset.cat = 'all';
listEl.appendChild(allItem);
cats.forEach(cat => {
const item = document.createElement('div');
item.className = 'category-item';
item.textContent = cat;
item.dataset.cat = cat;
listEl.appendChild(item);
});
} catch (e) {
console.error('Failed to load categories:', e);
}
},
async _loadThemes() {
const gridEl = document.getElementById('dash-theme-grid');
if (!gridEl) return;
gridEl.innerHTML = '<div style="text-align:center;padding:40px;color:var(--text-tertiary);">加载中...</div>';
try {
const themes = await API.getDashboards(this._currentCategory);
if (!themes || themes.length === 0) {
gridEl.innerHTML = `
<div class="empty-state" style="grid-column:1/-1;">
<svg viewBox="0 0 24 24" width="64" height="64"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm0-12c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4z" fill="currentColor"/></svg>
<h4>暂无仪表盘主题</h4>
<p>创建或导入你的第一个仪表盘主题</p>
</div>`;
return;
}
gridEl.innerHTML = themes.map(t => this._themeCardHtml(t)).join('');
} catch (e) {
console.error('Failed to load themes:', e);
gridEl.innerHTML = '<div style="text-align:center;padding:40px;color:var(--danger);">加载失败</div>';
}
},
_bindEvents() {
const categoryList = document.getElementById('dash-category-list');
if (categoryList) {
categoryList.addEventListener('click', (e) => {
const item = e.target.closest('.category-item');
if (!item) return;
categoryList.querySelectorAll('.category-item').forEach(el => el.classList.remove('active'));
item.classList.add('active');
this._currentCategory = item.dataset.cat;
this._loadThemes();
});
}
const gridEl = document.getElementById('dash-theme-grid');
if (gridEl) {
gridEl.addEventListener('click', (e) => {
const card = e.target.closest('.theme-card');
if (!card) return;
const themeId = card.dataset.themeId;
this._openDashboard(themeId);
});
}
},
_openDashboard(themeId) {
const url = `${location.origin}/dashboard/${themeId}`;
window.open(url, '_blank');
navigator.clipboard.writeText(url).then(() => {
Toast.show('链接已复制,可在局域网设备访问', 'success');
}).catch(() => {
Toast.show(`打开地址: ${url}`, 'info');
});
},
_themeCardHtml(theme) {
const icon = theme.config?.icon || '📊';
const aspect = theme.config?.aspect_ratio || 'auto';
return `
<div class="glass-card theme-card" data-theme-id="${theme.id}">
<div class="theme-card-preview">${icon}</div>
<div class="theme-card-info">
<div class="theme-card-name">${theme.name || 'Unnamed'}</div>
<div class="theme-card-meta">
<span>${theme.category || 'basic'}</span>
<span>${aspect}</span>
</div>
<div class="theme-card-meta" style="margin-top:4px;">
<span>${theme.author || ''}</span>
<span class="badge ${theme.is_builtin ? 'badge-info' : 'badge-success'}">${theme.is_builtin ? '内置' : '自定义'}</span>
</div>
</div>
</div>`;
},
_template() {
return `
<div class="dashboard-layout">
<div class="dashboard-sub-sidebar">
<div id="dash-category-list"></div>
</div>
<div class="dashboard-main">
<div id="dash-theme-grid" class="theme-grid"></div>
</div>
</div>`;
}
};
window.PageDashboard = PageDashboard;
+117
View File
@@ -0,0 +1,117 @@
const PageDebug = {
_el: null,
_autoScroll: true,
_paused: false,
_rawData: [],
_fieldData: {},
_maxRawLines: 200,
init() {
this._el = document.getElementById('page-debug');
},
render() {
this._el.innerHTML = this._template();
this._bindEvents();
WS.on('telemetry', (data) => this._onTelemetry(data));
},
_bindEvents() {
document.getElementById('debug-clear-btn')?.addEventListener('click', () => {
this._rawData = [];
this._fieldData = {};
this._renderFields();
this._renderRaw();
});
document.getElementById('debug-pause-btn')?.addEventListener('click', () => {
this._paused = !this._paused;
const btn = document.getElementById('debug-pause-btn');
btn.textContent = this._paused ? '▶ 继续' : '⏸ 暂停';
});
document.getElementById('debug-autoscroll')?.addEventListener('change', (e) => {
this._autoScroll = e.target.checked;
});
},
_onTelemetry(data) {
if (this._paused) return;
this._fieldData = data;
this._renderFields();
const timestamp = new Date().toISOString();
this._rawData.push({ timestamp, data: { ...data } });
if (this._rawData.length > this._maxRawLines) {
this._rawData.shift();
}
this._renderRaw();
},
_renderFields() {
const container = document.getElementById('debug-fields');
if (!container) return;
const keys = Object.keys(this._fieldData).filter(k => k !== 'raw');
container.innerHTML = keys.map(k => {
const val = this._fieldData[k];
const displayVal = typeof val === 'number' ? val.toFixed(3) : val;
return `
<div class="debug-field">
<div class="debug-field-name">${k}</div>
<div class="debug-field-value">${displayVal}</div>
</div>`;
}).join('') || '<div style="color:var(--text-tertiary);padding:20px;">等待数据...</div>';
},
_renderRaw() {
const el = document.getElementById('debug-raw');
if (!el) return;
const lines = this._rawData.map(entry => {
const ts = entry.timestamp.substring(11, 23);
const preview = JSON.stringify(entry.data).substring(0, 300);
return `<span style="color:var(--text-tertiary)">[${ts}]</span> <span style="color:var(--accent)">→</span> ${preview}`;
});
el.innerHTML = lines.join('\n') || '等待数据...';
if (this._autoScroll) {
el.scrollTop = el.scrollHeight;
}
},
cleanup() {
WS.off('telemetry', this._onTelemetry);
},
_template() {
return `
<div class="debug-container">
<div class="debug-toolbar animated">
<h2 style="font-size:20px;font-weight:700;">数据测试</h2>
<span style="flex:1;"></span>
<button id="debug-pause-btn" class="btn btn-sm btn-secondary"> 暂停</button>
<button id="debug-clear-btn" class="btn btn-sm btn-secondary">清空</button>
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-secondary);">
<input type="checkbox" id="debug-autoscroll" checked> 自动滚动
</label>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;flex:1;min-height:0;">
<div style="overflow-y:auto;">
<h4 style="font-size:14px;color:var(--text-secondary);margin-bottom:12px;">解析后的数据字段</h4>
<div id="debug-fields">
<div style="color:var(--text-tertiary);padding:20px;">等待数据...</div>
</div>
</div>
<div style="overflow-y:auto;">
<h4 style="font-size:14px;color:var(--text-secondary);margin-bottom:12px;">原始 JSON 数据流</h4>
<div id="debug-raw" class="debug-data"></div>
</div>
</div>
</div>`;
}
};
window.PageDebug = PageDebug;
+99
View File
@@ -0,0 +1,99 @@
const PageHome = {
_el: null,
_refreshTimer: null,
init() {
this._el = document.getElementById('page-home');
},
async render() {
this._el.innerHTML = this._template();
this._startRefresh();
await this._refreshCards();
},
async _refreshCards() {
try {
const status = await API.getStatus();
this._updateCard('card-server', status.server_running ? '运行中' : '已停止', status.server_running ? 'success' : 'danger');
this._updateCard('card-telemetry', status.telemetry_running ? '监听中' : '未启动', status.telemetry_running ? 'success' : 'warning');
this._updateCard('card-game', status.selected_game_id || '未选择', 'info');
this._updateCard('card-connections', `${status.ws_clients} 个客户端`, status.ws_clients > 0 ? 'success' : 'secondary');
this._updateCard('card-packets', `${status.packet_count}`, status.packet_count > 0 ? 'success' : 'secondary');
const lastTime = status.last_packet_time;
if (lastTime > 0) {
const ago = Math.round((Date.now() / 1000) - lastTime);
this._updateCard('card-last-packet', `${ago}秒前`, ago < 5 ? 'success' : 'warning');
} else {
this._updateCard('card-last-packet', '暂无数据', 'secondary');
}
const td = status.latest_data;
if (td) {
this._updateCard('card-speed', `${(td.speed_kmh || 0).toFixed(1)} km/h`, 'primary');
this._updateCard('card-rpm', `${(td.rpm || 0).toFixed(0)} RPM`, 'primary');
this._updateCard('card-gear', `档位 ${td.gear || 'N'}`, 'primary');
}
} catch (e) {
console.error('Failed to refresh status:', e);
}
},
_updateCard(id, value, type) {
const el = document.getElementById(id);
if (!el) return;
const valEl = el.querySelector('.status-card-value');
if (valEl) valEl.textContent = value;
const badgeEl = el.querySelector('.badge');
if (badgeEl) {
badgeEl.className = `badge badge-${type}`;
}
},
_startRefresh() {
if (this._refreshTimer) clearInterval(this._refreshTimer);
this._refreshTimer = setInterval(() => this._refreshCards(), 2000);
},
cleanup() {
if (this._refreshTimer) {
clearInterval(this._refreshTimer);
this._refreshTimer = null;
}
},
_template() {
return `
<div class="animated">
<h2 style="font-size:24px;font-weight:700;margin-bottom:8px;">系统概览</h2>
<p style="color:var(--text-secondary);margin-bottom:24px;">实时监控 TurboSu 运行状态与游戏遥测连接</p>
</div>
<div class="status-grid animated" style="animation-delay:0.1s">
${this._cardHtml('card-server', '服务器状态', '运行中', 'success')}
${this._cardHtml('card-telemetry', '遥测监听', '未启动', 'warning')}
${this._cardHtml('card-connections', 'WebSocket 连接', '0 个客户端', 'secondary')}
${this._cardHtml('card-packets', '数据包接收', '0 包', 'secondary')}
${this._cardHtml('card-last-packet', '最后数据包', '暂无数据', 'secondary')}
${this._cardHtml('card-game', '当前游戏', '未选择', 'info')}
</div>
<div class="status-grid animated" style="animation-delay:0.2s">
${this._cardHtml('card-speed', '实时速度', '-- km/h', 'primary')}
${this._cardHtml('card-rpm', '实时转速', '-- RPM', 'primary')}
${this._cardHtml('card-gear', '当前档位', 'N', 'primary')}
</div>`;
},
_cardHtml(id, title, value, type) {
return `
<div class="glass-card status-card" id="${id}">
<div class="status-card-header">
<span class="status-card-title">${title}</span>
<span class="badge badge-${type}"></span>
</div>
<div class="status-card-value">${value}</div>
</div>`;
}
};
window.PageHome = PageHome;
+187
View File
@@ -0,0 +1,187 @@
const PageScene = {
_el: null,
_currentGameId: null,
_currentGameName: '',
_editorSceneId: null,
_editorData: null,
init() {
this._el = document.getElementById('page-scene');
},
async render() {
const cfg = await API.getConfig();
this._currentGameId = cfg.selected_game_id;
if (this._currentGameId) {
const games = await API.getGames();
const game = games.find(g => g.id === this._currentGameId);
this._currentGameName = game ? game.name : this._currentGameId;
}
this._el.innerHTML = this._template();
await this._loadScenes();
this._bindEvents();
},
async _loadScenes() {
const grid = document.getElementById('scene-grid');
if (!grid) return;
try {
const scenes = await API.getScenes(this._currentGameId);
if (!scenes || scenes.length === 0) {
grid.innerHTML = `
<div class="empty-state" style="grid-column:1/-1;">
<svg viewBox="0 0 24 24" width="64" height="64"><path d="M3 3h8v8H3zm10 0h8v8h-8zM3 13h8v8H3zm10 0h8v8h-8z" fill="currentColor"/></svg>
<h4>暂无场景</h4>
<p>点击"新建场景"创建你的第一个场景布局</p>
</div>`;
return;
}
grid.innerHTML = scenes.map(s => this._sceneCardHtml(s)).join('');
} catch (e) {
console.error('Failed to load scenes:', e);
}
},
_bindEvents() {
document.getElementById('btn-new-scene')?.addEventListener('click', () => this._showEditor());
const grid = document.getElementById('scene-grid');
if (grid) {
grid.addEventListener('click', (e) => {
const card = e.target.closest('[data-scene-id]');
if (!card) return;
const action = card.dataset.action;
const sceneId = card.dataset.sceneId;
if (action === 'render') {
this._renderScene(sceneId);
} else if (action === 'edit') {
e.stopPropagation();
this._showEditor(sceneId);
} else if (action === 'delete') {
e.stopPropagation();
this._deleteScene(sceneId);
}
});
}
},
async _showEditor(sceneId) {
let scene = null;
if (sceneId) {
scene = await API.getScene(sceneId);
if (!scene) return;
}
this._editorSceneId = sceneId;
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.innerHTML = this._editorTemplate(scene);
document.body.appendChild(modal);
modal.querySelector('.modal-overlay, .modal-actions .btn-secondary')
?.addEventListener('click', (e) => {
if (e.target === modal || e.target.matches('.btn-secondary')) {
modal.remove();
}
});
modal.querySelector('.btn-primary')?.addEventListener('click', async () => {
const name = modal.querySelector('#edit-scene-name').value || 'New Scene';
const desc = modal.querySelector('#edit-scene-desc').value || '';
const gameId = this._currentGameId || '';
const data = { name, description: desc, game_id: gameId };
if (this._editorSceneId) {
await API.updateScene(this._editorSceneId, data);
} else {
await API.createScene(data);
}
modal.remove();
Toast.show('场景保存成功', 'success');
this.render();
});
},
async _renderScene(sceneId) {
const url = `${location.origin}/scene/${sceneId}`;
window.open(url, '_blank');
navigator.clipboard.writeText(url).then(() => {
Toast.show('场景链接已复制,可在局域网设备访问', 'success');
}).catch(() => {});
},
async _deleteScene(sceneId) {
if (!confirm('确定删除此场景?')) return;
await API.deleteScene(sceneId);
Toast.show('场景已删除', 'info');
this._loadScenes();
},
_sceneCardHtml(scene) {
const canvasCount = (scene.canvases || []).length;
return `
<div class="glass-card scene-card" data-scene-id="${scene.id}" data-action="render">
<div class="scene-card-name">${scene.name}</div>
<div class="scene-card-game" style="margin-bottom:6px;">${this._currentGameName || '未关联游戏'}</div>
<div class="scene-card-meta">
<span>${canvasCount} 个画布</span>
<span>${scene.updated_at ? new Date(scene.updated_at).toLocaleDateString() : ''}</span>
</div>
<div class="scene-canvas-list">
${(scene.canvases || []).map(c => `<span class="scene-canvas-badge">${c.label || c.width + 'x' + c.height}</span>`).join('')}
</div>
<div style="margin-top:12px;display:flex;gap:6px;">
<button class="btn btn-sm btn-primary" data-scene-id="${scene.id}" data-action="render">渲染</button>
<button class="btn btn-sm btn-secondary" data-scene-id="${scene.id}" data-action="edit">编辑</button>
<button class="btn btn-sm btn-danger" data-scene-id="${scene.id}" data-action="delete">删除</button>
</div>
</div>`;
},
_editorTemplate(scene) {
const name = scene ? scene.name : '';
const desc = scene ? scene.description : '';
return `
<div class="modal">
<h3>${scene ? '编辑场景' : '新建场景'}</h3>
<div class="form-group">
<label>场景名称</label>
<input id="edit-scene-name" type="text" value="${name}" placeholder="输入场景名称">
</div>
<div class="form-group">
<label>描述</label>
<textarea id="edit-scene-desc" placeholder="场景描述(可选)">${desc}</textarea>
</div>
<p style="font-size:12px;color:var(--text-tertiary);margin-top:8px;">
提示保存后可在场景编辑器中添加仪表盘调整布局和画布比例
</p>
<div class="modal-actions">
<button class="btn btn-secondary">取消</button>
<button class="btn btn-primary">保存</button>
</div>
</div>`;
},
_template() {
return `
<div class="scene-header animated">
<div>
<h2>场景管理</h2>
<p style="color:var(--text-secondary);font-size:14px;margin-top:4px;">
${this._currentGameId ? `当前游戏: ${this._currentGameName}` : '请先在侧边栏选择一个游戏'}
</p>
</div>
<button id="btn-new-scene" class="btn btn-primary" ${!this._currentGameId ? 'disabled' : ''}>
+ 新建场景
</button>
</div>
<div id="scene-grid" class="scene-grid animated"></div>`;
}
};
window.PageScene = PageScene;
+185
View File
@@ -0,0 +1,185 @@
const PageSettings = {
_el: null,
init() {
this._el = document.getElementById('page-settings');
},
async render() {
const cfg = await API.getConfig();
const games = await API.getGames();
this._el.innerHTML = this._template(cfg, games);
this._bindEvents();
},
_bindEvents() {
document.getElementById('settings-telemetry-port')?.addEventListener('change', async (e) => {
await API.updateConfig({ telemetry_port: parseInt(e.target.value) || 20777 });
Toast.show('遥测端口已更新,重启监听后生效', 'info');
});
document.getElementById('settings-server-port')?.addEventListener('change', async (e) => {
Toast.show('服务器端口修改后需要重启程序', 'warning');
});
document.getElementById('settings-restart-telemetry')?.addEventListener('click', async () => {
await API.stopTelemetry();
await API.startTelemetry();
Toast.show('遥测监听已重启', 'success');
});
document.getElementById('btn-import-plugin')?.addEventListener('click', () => this._importPlugin());
document.getElementById('btn-reload-plugins')?.addEventListener('click', async () => {
await API.reloadGamePlugins();
Toast.show('插件已重新加载', 'success');
this.render();
});
document.getElementById('settings-game-list')?.addEventListener('click', async (e) => {
const exportBtn = e.target.closest('.btn-export-plugin');
const removeBtn = e.target.closest('.btn-remove-plugin');
if (exportBtn) {
const pluginId = exportBtn.dataset.pluginId;
const data = await API.exportGamePlugin(pluginId);
if (data) {
this._downloadJson(`plugin_${pluginId}.json`, data);
Toast.show('插件已导出', 'success');
}
}
if (removeBtn) {
const pluginId = removeBtn.dataset.pluginId;
if (confirm('确定移除这个游戏插件?')) {
await API.removeGamePlugin(pluginId);
Toast.show('插件已移除', 'info');
this.render();
}
}
});
},
async _importPlugin() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const text = await file.text();
const data = JSON.parse(text);
if (data.type === 'game_plugin') {
await API.installGamePlugin(data);
Toast.show('插件安装成功', 'success');
this.render();
} else {
Toast.show('无效的插件文件格式', 'error');
}
} catch (err) {
Toast.show('文件解析失败: ' + err.message, 'error');
}
};
input.click();
},
_downloadJson(filename, data) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
},
_gamePluginListHtml(games) {
if (!games || games.length === 0) {
return '<p style="color:var(--text-tertiary);font-size:13px;">暂无游戏插件</p>';
}
return games.map(g => `
<div class="glass-card" style="padding:16px;margin-bottom:10px;display:flex;align-items:center;justify-content:space-between;">
<div>
<div style="font-weight:600;">${g.name}</div>
<div style="font-size:12px;color:var(--text-tertiary);">
${g.description || ''} | v${g.version} | ${g.author || ''}
<span class="badge ${g.is_builtin ? 'badge-info' : 'badge-success'}" style="margin-left:6px;">
${g.is_builtin ? '内置' : '社区'}
</span>
</div>
</div>
<div style="display:flex;gap:6px;">
<button class="btn btn-sm btn-secondary btn-export-plugin" data-plugin-id="${g.id}">导出</button>
${!g.is_builtin ? `<button class="btn btn-sm btn-danger btn-remove-plugin" data-plugin-id="${g.id}">移除</button>` : ''}
</div>
</div>
`).join('');
},
_template(cfg, games) {
return `
<div class="animated">
<h2 style="font-size:24px;font-weight:700;margin-bottom:24px;">设置</h2>
<div class="settings-section">
<h3>连接设置</h3>
<div class="settings-row">
<div>
<div class="settings-label">遥测监听端口</div>
<div class="settings-desc">游戏内设置的数据输出端口</div>
</div>
<div class="settings-control">
<input type="number" id="settings-telemetry-port" value="${cfg.telemetry_port || 20777}" min="1024" max="65535">
</div>
</div>
<div class="settings-row">
<div>
<div class="settings-label">Web 服务器端口</div>
<div class="settings-desc">Web UI 服务的端口号</div>
</div>
<div class="settings-control">
<input type="number" id="settings-server-port" value="${cfg.server_port || 9527}" min="80" max="65535" disabled>
</div>
</div>
<div class="settings-row">
<div>
<div class="settings-label">重启遥测监听</div>
<div class="settings-desc">修改端口或切换游戏后需要重启监听</div>
</div>
<div class="settings-control">
<button id="settings-restart-telemetry" class="btn btn-secondary btn-sm">重启监听</button>
</div>
</div>
</div>
<div class="settings-section">
<h3>游戏插件管理</h3>
<div style="display:flex;gap:8px;margin-bottom:16px;">
<button id="btn-import-plugin" class="btn btn-secondary btn-sm">📥 导入插件</button>
<button id="btn-reload-plugins" class="btn btn-secondary btn-sm">🔄 重新加载</button>
</div>
<div id="settings-game-list">
${this._gamePluginListHtml(games)}
</div>
<div class="glass-card" style="padding:16px;margin-top:12px;font-size:12px;color:var(--text-tertiary);line-height:1.6;">
<strong style="color:var(--text-secondary);">社区开发指南</strong><br>
1. 创建一个包含 <code>manifest.json</code> <code>parser.py</code> <br>
2. <code>manifest.json</code> <code>parser.py</code> <code>get_parser()</code> <br>
3. <code>get_parser()</code> <code>game_id()</code> <code>parse(data, addr)</code> <br>
4. 通过"导入插件"或放入 <code>games/user/</code> <br>
5. 导出你的插件分享给社区
</div>
</div>
<div class="settings-section">
<h3>关于</h3>
<div class="settings-row">
<div>
<div class="settings-label">TurboSu</div>
<div class="settings-desc">赛车遥测仪表盘 v1.0.0</div>
</div>
</div>
</div>
</div>`;
}
};
window.PageSettings = PageSettings;
+54
View File
@@ -0,0 +1,54 @@
const Router = {
_routes: {
home: PageHome,
dashboard: PageDashboard,
scene: PageScene,
debug: PageDebug,
settings: PageSettings,
},
_currentPage: 'home',
_currentPageInstance: null,
init() {
window.addEventListener('hashchange', () => this._handleRoute());
this._handleRoute();
document.querySelectorAll('.nav-item[data-route]').forEach(el => {
el.addEventListener('click', (e) => {
e.preventDefault();
const route = el.getAttribute('data-route');
if (route) this.navigate(route);
});
});
},
navigate(page) {
window.location.hash = `#/${page}`;
},
_handleRoute() {
const hash = window.location.hash || '#/home';
const page = hash.replace('#/', '') || 'home';
if (this._currentPageInstance && this._currentPageInstance.cleanup) {
this._currentPageInstance.cleanup();
}
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
const pageEl = document.getElementById(`page-${page}`);
if (pageEl) pageEl.classList.add('active');
Sidebar.setActive(page);
const route = this._routes[page];
if (route) {
this._currentPage = page;
this._currentPageInstance = route;
route.render();
}
},
get currentPage() { return this._currentPage; }
};
window.Router = Router;
+23
View File
@@ -0,0 +1,23 @@
const Theme = {
_current: 'dark',
init() {
this._current = localStorage.getItem('turbosu-theme') || 'dark';
this.apply();
},
toggle() {
this._current = this._current === 'dark' ? 'light' : 'dark';
this.apply();
},
apply() {
document.body.classList.remove('theme-dark', 'theme-light');
document.body.classList.add(`theme-${this._current}`);
localStorage.setItem('turbosu-theme', this._current);
},
get current() { return this._current; }
};
window.Theme = Theme;
+27
View File
@@ -0,0 +1,27 @@
const Toast = {
_container: null,
_init() {
if (this._container) return;
this._container = document.createElement('div');
this._container.className = 'toast-container';
document.body.appendChild(this._container);
},
show(message, type = 'info', duration = 3000) {
this._init();
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
this._container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateX(100%)';
toast.style.transition = 'all 0.3s ease';
setTimeout(() => toast.remove(), 300);
}, duration);
}
};
window.Toast = Toast;
+87
View File
@@ -0,0 +1,87 @@
const WS = {
_ws: null,
_url: '',
_reconnectTimer: null,
_reconnectDelay: 2000,
_handlers: {},
_connected: false,
init() {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
this._url = `${protocol}//${location.host}/ws`;
this._connect();
},
_connect() {
if (this._ws && this._ws.readyState === WebSocket.OPEN) return;
try {
this._ws = new WebSocket(this._url);
} catch (e) {
this._scheduleReconnect();
return;
}
this._ws.onopen = () => {
this._connected = true;
this._reconnectDelay = 2000;
this._emit('connection', true);
this._emit('status', 'connected');
};
this._ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
this._emit(msg.type || 'message', msg.data || msg);
if (msg.type === 'telemetry') {
this._emit('telemetry', msg.data);
}
} catch (e) {}
};
this._ws.onclose = () => {
this._connected = false;
this._emit('connection', false);
this._emit('status', 'disconnected');
this._scheduleReconnect();
};
this._ws.onerror = () => {
this._emit('status', 'error');
};
},
_scheduleReconnect() {
if (this._reconnectTimer) return;
this._reconnectTimer = setTimeout(() => {
this._reconnectTimer = null;
this._reconnectDelay = Math.min(this._reconnectDelay * 1.5, 10000);
this._connect();
}, this._reconnectDelay);
},
on(event, handler) {
if (!this._handlers[event]) this._handlers[event] = [];
this._handlers[event].push(handler);
},
off(event, handler) {
if (!this._handlers[event]) return;
this._handlers[event] = this._handlers[event].filter(h => h !== handler);
},
_emit(event, data) {
if (!this._handlers[event]) return;
this._handlers[event].forEach(h => h(data));
},
send(data) {
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
this._ws.send(JSON.stringify(data));
}
},
get connected() { return this._connected; }
};
window.WS = WS;
+221
View File
@@ -0,0 +1,221 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>TurboSu - {{ theme.name if theme else 'Dashboard' }}</title>
<style>
:root {
--aspect-ratio: {{ theme.config.aspect_ratio | default('auto') if theme else 'auto' }};
--render-mode: {{ theme.config.render_mode | default('contain') if theme else 'contain' }};
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%; height: 100%;
overflow: hidden;
background: #000;
color: #fff;
font-family: 'SF Pro Text', -apple-system, 'PingFang SC', 'MiSans', system-ui, sans-serif;
-webkit-tap-highlight-color: transparent;
}
#dashboard-root {
width: 100%; height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
#dashboard-letterbox {
position: relative;
overflow: hidden;
}
#dashboard-content {
width: 100%; height: 100%;
}
.loading {
display: flex; align-items: center; justify-content: center;
height: 100%; font-size: 24px; color: #888;
}
/* Aspect ratio constraint: "contain" mode */
#dashboard-letterbox.contain {
max-width: 100vw;
max-height: 100vh;
}
/* Aspect ratio constraint: "cover" mode */
#dashboard-letterbox.cover {
min-width: 100vw;
min-height: 100vh;
}
/* Aspect ratio constraint: "fill" mode */
#dashboard-letterbox.fill {
width: 100vw;
height: 100vh;
}
/* Aspect ratio constraint: "center" mode */
#dashboard-letterbox.center {
width: auto;
height: auto;
}
</style>
</head>
<body>
<div id="dashboard-root">
<div id="dashboard-letterbox" class="{{ theme.config.render_mode | default('contain') if theme else 'contain' }}">
<div id="dashboard-content">
{{ template_html | safe if template_html else '<div class="loading">加载仪表盘中...</div>' }}
</div>
</div>
</div>
<script>
const THEME_ID = "{{ theme.id if theme else '' }}";
const THEME_CONFIG = {{ theme.config | tojson if theme and theme.config else '{}' }};
</script>
<script>
(function() {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${location.host}/ws`;
const letterbox = document.getElementById('dashboard-letterbox');
const root = document.getElementById('dashboard-root');
let ws;
let reconnectTimer;
function parseAspectRatio(ratio) {
if (!ratio || ratio === 'auto') return null;
const parts = ratio.split(':');
if (parts.length === 2) {
const w = parseFloat(parts[0]);
const h = parseFloat(parts[1]);
if (w > 0 && h > 0) return w / h;
}
return null;
}
function applyAspectRatio() {
const ratio = parseAspectRatio(THEME_CONFIG.aspect_ratio || 'auto');
const mode = THEME_CONFIG.render_mode || 'contain';
if (!ratio) {
letterbox.style.width = '100vw';
letterbox.style.height = '100vh';
letterbox.classList.add('fill');
return;
}
const vw = window.innerWidth;
const vh = window.innerHeight;
const vr = vw / vh;
let cw, ch;
switch (mode) {
case 'contain':
if (vr > ratio) {
ch = vh;
cw = vh * ratio;
} else {
cw = vw;
ch = vw / ratio;
}
letterbox.classList.add('contain');
break;
case 'cover':
if (vr > ratio) {
cw = vw;
ch = vw / ratio;
} else {
ch = vh;
cw = vh * ratio;
}
letterbox.classList.add('cover');
break;
case 'fill':
letterbox.style.width = '100vw';
letterbox.style.height = '100vh';
letterbox.classList.add('fill');
return;
case 'center':
const maxW = THEME_CONFIG.max_width || 1920;
const maxH = THEME_CONFIG.max_height || 1080;
cw = Math.min(vw, maxW);
ch = cw / ratio;
if (ch > vh) {
ch = Math.min(vh, maxH);
cw = ch * ratio;
}
letterbox.classList.add('center');
break;
default:
if (vr > ratio) {
ch = vh;
cw = vh * ratio;
} else {
cw = vw;
ch = vw / ratio;
}
letterbox.classList.add('contain');
}
if (mode !== 'fill') {
letterbox.style.width = cw + 'px';
letterbox.style.height = ch + 'px';
}
}
function connect() {
ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('[Dashboard] WS connected');
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === 'telemetry') updateDashboard(msg.data);
} catch(e) {}
};
ws.onclose = () => {
console.log('[Dashboard] WS closed, reconnecting...');
reconnectTimer = setTimeout(connect, 2000);
};
ws.onerror = (e) => console.error('[Dashboard] WS error', e);
}
function updateDashboard(data) {
document.querySelectorAll('[data-bind]').forEach(el => {
const bind = el.getAttribute('data-bind');
const value = bind.split('.').reduce((o, k) => o?.[k], data);
if (value !== undefined && value !== null) {
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
el.value = typeof value === 'number' ? value.toFixed(1) : value;
} else {
el.textContent = typeof value === 'number' ? value.toFixed(1) : value;
}
}
});
document.querySelectorAll('[data-bind-rpm]').forEach(el => {
const value = data.rpm || 0;
const max = data.max_rpm || 8000;
const pct = Math.min(value / max, 1);
el.style.setProperty('--rpm-pct', pct);
el.style.setProperty('--rpm', value);
el.style.setProperty('--rpm-max', max);
});
document.querySelectorAll('[data-bind-speed]').forEach(el => {
el.style.setProperty('--speed', data.speed_kmh || 0);
});
document.querySelectorAll('[data-bind-gear]').forEach(el => {
const g = data.gear || 0;
el.style.setProperty('--gear', g);
});
}
applyAspectRatio();
window.addEventListener('resize', applyAspectRatio);
connect();
})();
</script>
</body>
</html>
+118
View File
@@ -0,0 +1,118 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TurboSu - 赛车遥测仪表盘</title>
<link rel="stylesheet" href="/static/css/miuix.css">
<link rel="stylesheet" href="/static/css/main.css">
</head>
<body class="theme-dark">
<div id="app" class="app-layout">
<aside id="sidebar" class="sidebar expanded">
<div class="sidebar-header">
<div class="sidebar-logo">
<svg viewBox="0 0 48 48" width="40" height="40">
<circle cx="24" cy="24" r="22" fill="none" stroke="var(--accent)" stroke-width="2.5"/>
<path d="M24 6 L30 20 L44 24 L30 28 L24 42 L18 28 L4 24 L18 20 Z" fill="var(--accent)"/>
<text x="24" y="30" text-anchor="middle" fill="#fff" font-size="16" font-weight="bold">S</text>
</svg>
</div>
<div class="sidebar-title">TurboSu</div>
<button id="sidebar-toggle" class="sidebar-toggle-btn" title="折叠侧边栏">
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M15.41 16.59L10.83 12l4.58-4.59L14 6l-6 6 6 6z" fill="currentColor"/></svg>
</button>
</div>
<nav class="sidebar-nav">
<a href="#/home" class="nav-item" data-route="home">
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" fill="currentColor"/></svg>
<span class="nav-label">首页</span>
</a>
<a href="#/dashboard" class="nav-item" data-route="dashboard">
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm0-12c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4z" fill="currentColor"/></svg>
<span class="nav-label">仪表盘</span>
</a>
<div id="game-selector-container" class="nav-section">
<button id="game-selector-toggle" class="nav-item collapsible-toggle collapsed">
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M21 6H3c-1.1 0-2 .9-2 2v8c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm0 10H3V8h18v8zM6 15h2v-2h2v-2H8V9H6v2H4v2h2z" fill="currentColor"/></svg>
<span class="nav-label" id="game-selector-label">选择游戏</span>
<svg class="collapse-arrow" viewBox="0 0 24 24" width="16" height="16"><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6z" fill="currentColor"/></svg>
</button>
<div id="game-list" class="nav-submenu collapsed">
</div>
</div>
<div id="scene-nav-container" class="nav-section">
<a href="#/scene" class="nav-item" data-route="scene">
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M3 3h8v8H3zm10 0h8v8h-8zM3 13h8v8H3zm10 0h8v8h-8z" fill="currentColor"/></svg>
<span class="nav-label">场景</span>
</a>
</div>
</nav>
<div class="sidebar-bottom">
<a href="#/debug" class="nav-item" data-route="debug">
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M20 8h-2.81c-.45-.78-1.07-1.45-1.82-1.96L17 4.41 15.59 3l-2.17 2.17C12.96 5.06 12.49 5 12 5s-.96.06-1.41.17L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8zm-6 8h-4v-2h4v2zm0-4h-4v-2h4v2z" fill="currentColor"/></svg>
<span class="nav-label">数据测试</span>
</a>
<a href="#/settings" class="nav-item" data-route="settings">
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.49.49 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 0 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" fill="currentColor"/></svg>
<span class="nav-label">设置</span>
</a>
</div>
</aside>
<main class="main-content">
<header id="topbar" class="topbar">
<div class="topbar-left">
<div id="topbar-logo-title" class="topbar-logo-title hidden">
<svg viewBox="0 0 48 48" width="28" height="28">
<circle cx="24" cy="24" r="22" fill="none" stroke="var(--accent)" stroke-width="2.5"/>
<path d="M24 6 L30 20 L44 24 L30 28 L24 42 L18 28 L4 24 L18 20 Z" fill="var(--accent)"/>
</svg>
<span class="topbar-title-text">TurboSu</span>
</div>
</div>
<div class="topbar-right">
<div id="connection-status" class="status-indicator disconnected">
<span class="status-dot"></span>
<span class="status-text">未连接</span>
</div>
<button id="theme-toggle" class="icon-btn" title="切换日夜间模式">
<svg class="icon-sun" viewBox="0 0 24 24" width="20" height="20"><path d="M6.76 4.84l-1.8-1.79-1.41 1.41 1.79 1.79zM4 10.5H1v2h3zm9-9.95h-2V3.5h2zm7.45 3.91l-1.41-1.41-1.79 1.79 1.41 1.41zm-3.21 13.7l1.79 1.8 1.41-1.41-1.8-1.79zM20 10.5v2h3v-2zm-8-5c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6zm-1 16.95h2V19.5h-2zm-7.45-3.91l1.41 1.41 1.79-1.8-1.41-1.41z" fill="currentColor"/></svg>
<svg class="icon-moon" viewBox="0 0 24 24" width="20" height="20"><path d="M9.37 5.51c-.18.64-.27 1.31-.27 1.99 0 4.08 3.32 7.4 7.4 7.4.68 0 1.35-.09 1.99-.27C17.45 17.19 14.93 19 12 19c-3.86 0-7-3.14-7-7 0-2.93 1.81-5.45 4.37-6.49z" fill="currentColor"/></svg>
</button>
</div>
</header>
<div id="page-container" class="page-container">
<div id="page-home" class="page active">
</div>
<div id="page-dashboard" class="page">
</div>
<div id="page-scene" class="page">
</div>
<div id="page-debug" class="page">
</div>
<div id="page-settings" class="page">
</div>
</div>
</main>
</div>
<script src="/static/js/utils/theme.js"></script>
<script src="/static/js/utils/toast.js"></script>
<script src="/static/js/api.js"></script>
<script src="/static/js/ws.js"></script>
<script src="/static/js/components/sidebar.js"></script>
<script src="/static/js/components/topbar.js"></script>
<script src="/static/js/pages/home.js"></script>
<script src="/static/js/pages/dashboard.js"></script>
<script src="/static/js/pages/scene.js"></script>
<script src="/static/js/pages/debug.js"></script>
<script src="/static/js/pages/settings.js"></script>
<script src="/static/js/router.js"></script>
<script src="/static/js/app.js"></script>
</body>
</html>
+138
View File
@@ -0,0 +1,138 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TurboSu - {{ scene.name if scene else 'Scene' }}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; font-family: 'Segoe UI', system-ui, sans-serif; }
#scene-root {
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
}
#scene-canvas {
position: relative;
overflow: hidden;
}
.scene-dashboard {
position: absolute;
overflow: hidden;
border: 1px solid rgba(255,255,255,0.08);
border-radius: 4px;
background: rgba(0,0,0,0.3);
}
.scene-dashboard iframe {
width: 100%; height: 100%; border: none;
}
.loading-overlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.9); z-index: 999;
flex-direction: column; gap: 16px;
}
.loading-overlay select {
padding: 10px 20px; font-size: 16px; border-radius: 8px;
background: rgba(255,255,255,0.1); color: #fff;
border: 1px solid rgba(255,255,255,0.3); cursor: pointer;
}
.loading-overlay button {
padding: 12px 40px; font-size: 18px; border-radius: 12px;
background: linear-gradient(135deg, #667eea, #764ba2);
color: #fff; border: none; cursor: pointer; font-weight: bold;
}
.loading-overlay button:hover {
opacity: 0.9;
}
</style>
</head>
<body>
<div id="scene-root">
<div id="loading-overlay" class="loading-overlay">
<h2 style="color:#fff;font-size:24px;margin-bottom:8px;">选择画布比例</h2>
<select id="canvas-select">
</select>
<button id="start-render-btn">开始渲染</button>
</div>
<div id="scene-canvas"></div>
</div>
<script>
const SCENE_DATA = {{ scene | tojson if scene else '{}' }};
</script>
<script>
(function() {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${location.host}/ws`;
const sceneRoot = document.getElementById('scene-root');
const sceneCanvas = document.getElementById('scene-canvas');
const loadingOverlay = document.getElementById('loading-overlay');
const canvasSelect = document.getElementById('canvas-select');
const startBtn = document.getElementById('start-render-btn');
const canvases = SCENE_DATA.canvases || [];
if (canvases.length === 0) {
loadingOverlay.innerHTML = '<p style="color:#f44;">该场景没有配置画布</p>';
} else if (canvases.length === 1) {
loadingOverlay.style.display = 'none';
setupCanvas(canvases[0]);
}
canvases.forEach((c, i) => {
const opt = document.createElement('option');
opt.value = i;
opt.textContent = `${c.label || 'Custom'} (${c.width}x${c.height})`;
canvasSelect.appendChild(opt);
});
startBtn.addEventListener('click', () => {
const idx = parseInt(canvasSelect.value);
if (idx >= 0 && idx < canvases.length) {
loadingOverlay.style.display = 'none';
setupCanvas(canvases[idx]);
}
});
function setupCanvas(canvas) {
const cw = canvas.width;
const ch = canvas.height;
const vw = window.innerWidth;
const vh = window.innerHeight;
const scaleX = vw / cw;
const scaleY = vh / ch;
const scale = Math.min(scaleX, scaleY);
sceneCanvas.style.width = (cw * scale) + 'px';
sceneCanvas.style.height = (ch * scale) + 'px';
const placements = canvas.placements || [];
placements.forEach(p => {
const el = document.createElement('div');
el.className = 'scene-dashboard';
el.style.left = (p.x * scale) + 'px';
el.style.top = (p.y * scale) + 'px';
el.style.width = (p.width * scale) + 'px';
el.style.height = (p.height * scale) + 'px';
el.style.zIndex = p.z_index || 0;
const iframe = document.createElement('iframe');
iframe.src = `/dashboard/${p.dashboard_id}`;
iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin');
el.appendChild(iframe);
sceneCanvas.appendChild(el);
});
}
window.addEventListener('resize', () => {
const cw = parseInt(sceneCanvas.style.width) / (parseInt(sceneCanvas.style.width) / 1920) || 1920;
const ch = parseInt(sceneCanvas.style.height) / (parseInt(sceneCanvas.style.height) / 1080) || 1080;
const vw = window.innerWidth;
const vh = window.innerHeight;
const scale = Math.min(vw / cw, vh / ch);
sceneCanvas.style.width = (cw * scale) + 'px';
sceneCanvas.style.height = (ch * scale) + 'px';
});
})();
</script>
</body>
</html>
View File
+39
View File
@@ -0,0 +1,39 @@
import logging
import os
import sys
from logging.handlers import RotatingFileHandler
from pathlib import Path
LOG_DIR = Path(__file__).resolve().parent.parent / "logs"
LOG_DIR.mkdir(exist_ok=True)
LOG_FILE = LOG_DIR / "turbosu.log"
LOG_FORMAT = logging.Formatter(
"[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
def setup_logger(name: str, level: int = logging.DEBUG) -> logging.Logger:
logger = logging.getLogger(name)
logger.setLevel(level)
logger.propagate = False
if not logger.handlers:
fh = RotatingFileHandler(
LOG_FILE, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8"
)
fh.setLevel(logging.DEBUG)
fh.setFormatter(LOG_FORMAT)
logger.addHandler(fh)
ch = logging.StreamHandler(sys.stdout)
ch.setLevel(logging.INFO)
ch.setFormatter(LOG_FORMAT)
logger.addHandler(ch)
return logger
def get_logger(name: str) -> logging.Logger:
return setup_logger(name)