6.5 KiB
6.5 KiB
仪表盘开发指南
每个仪表盘是 dashboards/ (内置) 或 data/dashboards/ (用户) 下的独立文件夹,启动时自动扫描。
文件夹结构
dashboards/my_dashboard/
├── manifest.json # 元信息
├── index.html # 完整独立页面
└── preview.png # 预览图 (可选)
manifest.json 格式
{
"id": "my_dashboard",
"name": "我的仪表盘",
"category": "custom",
"description": "简短描述",
"author": "你的名字",
"version": "1.0.0",
"config": {
"icon": "🏎️",
"aspect_ratio": "16:9",
"render_mode": "contain"
}
}
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
id |
string | 是 | 唯一标识,= 文件夹名 |
name |
string | 是 | 显示名称 |
category |
string | 否 | 分类 (speed/rpm/basic/timing/custom) |
description |
string | 否 | 描述文字 |
author |
string | 否 | 作者 |
version |
string | 否 | 版本号 |
config.icon |
string | 否 | 预览图标 emoji |
config.aspect_ratio |
string | 否 | 渲染比例: auto / 16:9 / 4:3 / 1:1 / 21:9 |
config.render_mode |
string | 否 | 渲染模式: contain / cover / fill / center |
渲染模式说明
| 模式 | 行为 |
|---|---|
contain |
保持比例完整显示,不足处留黑边 (推荐) |
cover |
保持比例铺满屏幕,超出部分裁切 |
fill |
拉伸填满视口,忽略比例 |
center |
居中显示,不超过 max_width × max_height |
index.html 规范
index.html 是完整的独立 HTML 页面,直接通过 /dashboard/<id> 访问。
必须包含
- WebSocket 连接 — 连接到
ws://<host>/ws接收遥测数据
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${proto}//${location.host}/ws`;
const ws = new WebSocket(wsUrl);
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'telemetry') {
// msg.data 包含所有遥测字段
updateUI(msg.data);
}
};
ws.onclose = () => {
// 断线重连
setTimeout(connect, 2000);
};
- 数据绑定 — 使用
data-bind属性自动绑定
<div data-bind="speed_kmh">0</div>
<span data-bind="rpm">0</span>
- 比例约束渲染 — 处理不同设备屏幕比例
const CONFIG = { aspect_ratio: "16:9", render_mode: "contain" };
function applyAspectRatio() {
const ratio = parseRatio(CONFIG.aspect_ratio);
const vw = window.innerWidth, vh = window.innerHeight;
if (CONFIG.render_mode === 'contain') {
// 缩放以完整显示,不足处留黑边
const scale = Math.min(vw / refW, vh / refH);
canvas.style.width = (refW * scale) + 'px';
canvas.style.height = (refH * scale) + 'px';
}
// ... 其他模式
}
可绑定的遥测字段
| data-bind | 字段 | 类型 | 说明 |
|---|---|---|---|
speed_kmh |
speed_kmh | float | 速度 (km/h) |
speed_mph |
speed_mph | float | 速度 (mph) |
rpm |
rpm | float | 发动机转速 |
max_rpm |
max_rpm | float | 最大转速 |
gear |
gear | int | 档位 (0=N, -1=R, 1~8) |
throttle |
throttle | float | 油门 (0~1) |
brake |
brake | float | 刹车 (0~1) |
clutch |
clutch | float | 离合 (0~1) |
steering |
steering | float | 转向 (-1~1) |
lap_time |
lap_time | float | 当前圈速 (秒) |
best_lap |
best_lap | float | 最佳圈速 (秒) |
last_lap |
last_lap | float | 上圈时间 (秒) |
lap_number |
lap_number | int | 圈数 |
fuel |
fuel | float | 燃油量 |
boost |
boost | float | 涡轮增压 |
horsepower |
horsepower | float | 马力 |
torque |
torque | float | 扭矩 |
engine_temp |
engine_temp | float | 发动机温度 |
oil_temp |
oil_temp | float | 油温 |
特殊自定义属性:
| 属性 | 说明 |
|---|---|
data-bind-rpm |
CSS 变量 --rpm / --rpm-pct / --rpm-max |
data-bind-speed |
CSS 变量 --speed |
data-bind-gear |
CSS 变量 --gear |
快速模板
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0,viewport-fit=cover">
<title>TurboSu - My Dashboard</title>
<style>
*,*::before,*::after{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',sans-serif}
#root{width:100%;height:100%;display:flex;align-items:center;justify-content:center}
#box{position:relative;overflow:hidden}
#box.contain{max-width:100vw;max-height:100vh}
#box.cover{min-width:100vw;min-height:100vh}
#box.fill{width:100vw;height:100vh}
</style>
</head>
<body>
<div id="root"><div id="box" class="contain"><div id="content">
<div data-bind="speed_kmh" style="font-size:80px;font-weight:900;">0</div>
<div style="font-size:24px;color:rgba(255,255,255,.5);">KM/H</div>
</div></div></div>
<script>
const M={aspect_ratio:"auto",render_mode:"contain"};
(function(){
const box=document.getElementById('box');
const proto=location.protocol==='https:'?'wss:':'ws:';
let ws,rt;
function parseRatio(r){
if(!r||r==='auto')return null;
const p=r.split(':');if(p.length===2){const w=+p[0],h=+p[1];if(w>0&&h>0)return w/h}
return null
}
function apply(){
const ratio=parseRatio(M.aspect_ratio),mode=M.render_mode;
if(!ratio){box.style.width='100vw';box.style.height='100vh';box.className='fill';return}
const vw=window.innerWidth,vh=window.innerHeight,vr=vw/vh;let cw,ch;
if(mode==='cover'){if(vr>ratio){cw=vw;ch=vw/ratio}else{ch=vh;cw=vh*ratio};box.className='cover'}
else{box.className='contain';if(vr>ratio){ch=vh;cw=vh*ratio}else{cw=vw;ch=vw/ratio}}
box.style.width=cw+'px';box.style.height=ch+'px'
}
function connect(){
ws=new WebSocket(proto+'//'+location.host+'/ws');
ws.onopen=()=>{if(rt){clearTimeout(rt);rt=null}};
ws.onmessage=(e)=>{
try{const m=JSON.parse(e.data);if(m.type==='telemetry')
document.querySelectorAll('[data-bind]').forEach(el=>{
const v=m.data[el.getAttribute('data-bind')];
if(v!==undefined)el.textContent=typeof v==='number'?v.toFixed(1):v
})
}catch(e){}
};
ws.onclose=()=>{rt=setTimeout(connect,2000)}
}
apply();window.addEventListener('resize',apply);connect()
})();
</script>
</body>
</html>
打包分发
将整个文件夹压缩为 zip,改后缀为 .tsd,即可在 TurboSu 仪表盘页面导入或在社区分享。
zip -r my_dashboard.tsd my_dashboard/