feat: UDP data forwarding to multiple downstream devices
- DataForwarder class in listener: sends raw packets to all enabled targets - REST API: GET/PUT /api/forward for managing targets - Settings page: add/remove/edit forward targets with host/port/name/enabled - Status endpoint includes forward_active count - Config persisted in data/config.json via forward_targets array
This commit is contained in:
@@ -20,6 +20,7 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
||||
"telemetry_host": "0.0.0.0",
|
||||
"server_host": "0.0.0.0",
|
||||
"server_port": 9527,
|
||||
"forward_targets": [],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ async def get_status():
|
||||
"packet_count": telemetry_listener.packet_count,
|
||||
"last_packet_time": telemetry_listener.last_packet_time,
|
||||
"selected_game_id": cfg.get("selected_game_id"),
|
||||
"forward_active": telemetry_listener.forwarder.active,
|
||||
"latest_data": latest.to_dict() if latest else None,
|
||||
}
|
||||
|
||||
@@ -310,3 +311,21 @@ async def api_export_game_plugin(plugin_id: str):
|
||||
async def api_reload_game_plugins():
|
||||
game_plugin_manager.reload()
|
||||
return {"ok": True, "count": len(game_plugin_manager.list_all())}
|
||||
|
||||
|
||||
# ---- Data Forwarding ----
|
||||
@router.get("/forward")
|
||||
async def api_list_forward_targets():
|
||||
cfg = get_config()
|
||||
return cfg.get("forward_targets", [])
|
||||
|
||||
|
||||
@router.put("/forward")
|
||||
async def api_update_forward_targets(data: dict[str, Any]):
|
||||
targets = data.get("targets", [])
|
||||
cfg = get_config()
|
||||
cfg["forward_targets"] = targets
|
||||
from config.settings import save_config
|
||||
save_config(cfg)
|
||||
telemetry_listener.forwarder.reload_targets()
|
||||
return {"ok": True, "active": telemetry_listener.forwarder.active}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import socket
|
||||
import time
|
||||
from typing import Any, Callable
|
||||
|
||||
@@ -13,6 +14,39 @@ from utils.logger import get_logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class DataForwarder:
|
||||
def __init__(self):
|
||||
self._sock: socket.socket | None = None
|
||||
self._targets: list[dict[str, Any]] = []
|
||||
|
||||
def reload_targets(self):
|
||||
cfg = get_config()
|
||||
self._targets = [
|
||||
t for t in cfg.get("forward_targets", [])
|
||||
if t.get("enabled", True) and t.get("host") and t.get("port")
|
||||
]
|
||||
if self._targets and not self._sock:
|
||||
self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
elif not self._targets and self._sock:
|
||||
self._sock.close()
|
||||
self._sock = None
|
||||
logger.info("Forwarder reloaded: %d targets", len(self._targets))
|
||||
|
||||
def forward(self, data: bytes):
|
||||
if not self._sock or not self._targets:
|
||||
return
|
||||
for t in self._targets:
|
||||
try:
|
||||
self._sock.sendto(data, (t["host"], t["port"]))
|
||||
except Exception as e:
|
||||
logger.error("Forward to %s:%d failed: %s", t["host"], t["port"], e)
|
||||
|
||||
@property
|
||||
def active(self) -> int:
|
||||
return len(self._targets)
|
||||
|
||||
|
||||
class TelemetryListener:
|
||||
def __init__(self):
|
||||
self._transport: asyncio.DatagramTransport | None = None
|
||||
@@ -23,6 +57,7 @@ class TelemetryListener:
|
||||
self._latest_data: TelemetryData | None = None
|
||||
self._last_packet_time: float = 0.0
|
||||
self._packet_count: int = 0
|
||||
self.forwarder = DataForwarder()
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
@@ -71,6 +106,8 @@ class TelemetryListener:
|
||||
)
|
||||
self._running = True
|
||||
|
||||
self.forwarder.reload_targets()
|
||||
|
||||
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)
|
||||
@@ -101,6 +138,8 @@ class TelemetryListener:
|
||||
self._last_packet_time = time.time()
|
||||
self._packet_count += 1
|
||||
|
||||
self.forwarder.forward(data)
|
||||
|
||||
td = None
|
||||
if self._parser:
|
||||
td = self._parser.parse(data, addr)
|
||||
|
||||
+77
-128
@@ -8,10 +8,20 @@ const PageSettings = {
|
||||
async render() {
|
||||
const cfg = await API.getConfig();
|
||||
const games = await API.getGames();
|
||||
this._el.innerHTML = this._template(cfg, games);
|
||||
const forwards = await this._getForwards();
|
||||
this._el.innerHTML = this._template(cfg, games, forwards);
|
||||
this._bindEvents();
|
||||
},
|
||||
|
||||
async _getForwards() {
|
||||
try { const res = await fetch('/api/forward'); return await res.json(); }
|
||||
catch (e) { return []; }
|
||||
},
|
||||
|
||||
async _saveForwards(targets) {
|
||||
await fetch('/api/forward', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ targets }) });
|
||||
},
|
||||
|
||||
_bindEvents() {
|
||||
document.getElementById('settings-telemetry-port')?.addEventListener('change', async (e) => {
|
||||
await API.updateConfig({ telemetry_port: parseInt(e.target.value) || 20777 });
|
||||
@@ -19,166 +29,105 @@ const PageSettings = {
|
||||
});
|
||||
|
||||
document.getElementById('settings-restart-telemetry')?.addEventListener('click', async () => {
|
||||
await API.stopTelemetry();
|
||||
await API.startTelemetry();
|
||||
await API.stopTelemetry(); await API.startTelemetry();
|
||||
Toast.show('遥测监听已重启', 'success');
|
||||
});
|
||||
|
||||
document.getElementById('btn-add-forward')?.addEventListener('click', () => this._addForward());
|
||||
document.getElementById('forward-list')?.addEventListener('click', async (e) => {
|
||||
if (e.target.closest('.btn-del-forward')) {
|
||||
e.target.closest('.forward-row').remove();
|
||||
this._collectAndSave();
|
||||
}
|
||||
});
|
||||
document.getElementById('btn-save-forwards')?.addEventListener('click', () => this._collectAndSave());
|
||||
|
||||
document.getElementById('btn-import-plugin')?.addEventListener('click', () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.tsp';
|
||||
const input = document.createElement('input'); input.type = 'file'; input.accept = '.tsp';
|
||||
input.onchange = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
try {
|
||||
const res = await fetch('/api/games/install', { method: 'POST', body: formData });
|
||||
const file = e.target.files[0]; if (!file) return;
|
||||
const fd = new FormData(); fd.append('file', file);
|
||||
const res = await fetch('/api/games/install', { method: 'POST', body: fd });
|
||||
const data = await res.json();
|
||||
if (data.ok) { Toast.show('游戏插件安装成功', 'success'); this.render(); }
|
||||
if (data.ok) { Toast.show('插件安装成功', 'success'); this.render(); }
|
||||
else Toast.show('安装失败', 'error');
|
||||
} catch (err) { Toast.show('安装失败', 'error'); }
|
||||
};
|
||||
input.click();
|
||||
}; input.click();
|
||||
});
|
||||
|
||||
document.getElementById('btn-reload-plugins')?.addEventListener('click', async () => {
|
||||
await API.reloadGamePlugins();
|
||||
Toast.show('插件已重新加载', 'success');
|
||||
this.render();
|
||||
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 a = document.createElement('a');
|
||||
a.href = `/api/games/${pluginId}/export`;
|
||||
a.download = `${pluginId}.tsp`;
|
||||
a.click();
|
||||
Toast.show('正在下载 .tsp 文件...', 'info');
|
||||
const pid = exportBtn.dataset.pluginId;
|
||||
const a = document.createElement('a'); a.href = '/api/games/' + pid + '/export'; a.download = pid + '.tsp'; a.click();
|
||||
}
|
||||
if (removeBtn) {
|
||||
const pluginId = removeBtn.dataset.pluginId;
|
||||
if (confirm('确定移除这个游戏插件?')) {
|
||||
await API.removeGamePlugin(pluginId);
|
||||
Toast.show('插件已移除', 'info');
|
||||
this.render();
|
||||
}
|
||||
if (confirm('移除?')) { await API.removeGamePlugin(removeBtn.dataset.pluginId); Toast.show('已移除', 'info'); this.render(); }
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('btn-import-dashboard')?.addEventListener('click', () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.tsd';
|
||||
const input = document.createElement('input'); input.type = 'file'; input.accept = '.tsd';
|
||||
input.onchange = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
try {
|
||||
const res = await fetch('/api/dashboards/import', { method: 'POST', body: formData });
|
||||
const file = e.target.files[0]; if (!file) return;
|
||||
const fd = new FormData(); fd.append('file', file);
|
||||
const res = await fetch('/api/dashboards/import', { method: 'POST', body: fd });
|
||||
const data = await res.json();
|
||||
if (data.ok) Toast.show('仪表盘导入成功', 'success');
|
||||
else Toast.show('导入失败', 'error');
|
||||
} catch (err) { Toast.show('导入失败', 'error'); }
|
||||
};
|
||||
input.click();
|
||||
Toast.show(data.ok ? '导入成功' : '导入失败', data.ok ? 'success' : 'error');
|
||||
}; input.click();
|
||||
});
|
||||
},
|
||||
|
||||
_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}">导出 .tsp</button>
|
||||
${!g.is_builtin ? `<button class="btn btn-sm btn-danger btn-remove-plugin" data-plugin-id="${g.id}">移除</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
_addForward() {
|
||||
const list = document.getElementById('forward-list');
|
||||
const row = document.createElement('div');
|
||||
row.className = 'forward-row glass-card';
|
||||
row.style.cssText = 'padding:12px;margin-bottom:8px;display:flex;gap:8px;align-items:center;flex-wrap:wrap';
|
||||
row.innerHTML = '<input class="fw-host" placeholder="IP/域名" style="flex:2;min-width:120px"><input class="fw-port" type="number" placeholder="端口" style="flex:1;min-width:70px" min="1" max="65535"><input class="fw-name" placeholder="名称" style="flex:1;min-width:80px"><label style="display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-secondary);white-space:nowrap"><input type="checkbox" class="forward-toggle" checked> 启用</label><button class="btn btn-sm btn-danger btn-del-forward">×</button>';
|
||||
list.appendChild(row);
|
||||
},
|
||||
|
||||
_template(cfg, games) {
|
||||
return `
|
||||
<div class="animated">
|
||||
<h2 style="font-size:24px;font-weight:700;margin-bottom:24px;">设置</h2>
|
||||
_collectAndSave() {
|
||||
const rows = document.querySelectorAll('#forward-list .forward-row');
|
||||
const targets = [];
|
||||
rows.forEach(r => {
|
||||
const host = r.querySelector('.fw-host')?.value?.trim();
|
||||
const port = parseInt(r.querySelector('.fw-port')?.value) || 0;
|
||||
const name = r.querySelector('.fw-name')?.value?.trim() || '';
|
||||
const enabled = r.querySelector('.forward-toggle')?.checked !== false;
|
||||
if (host && port > 0) targets.push({ host, port, name, enabled });
|
||||
});
|
||||
this._saveForwards(targets).then(() => Toast.show('已保存 ' + targets.length + ' 个转发目标', 'success'));
|
||||
},
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>连接设置</h3>
|
||||
<div class="settings-row">
|
||||
<div>
|
||||
<div class="settings-label">遥测监听端口</div>
|
||||
<div class="settings-desc">游戏内设置的数据输出端口 (默认 20777)</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">重启遥测监听</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>
|
||||
_forwardListHtml(forwards) {
|
||||
if (!forwards || forwards.length === 0) return '<p style="color:var(--text-tertiary);font-size:13px;padding:8px 0">暂无转发目标,点击下方添加</p>';
|
||||
return forwards.map(f => '<div class="forward-row glass-card" style="padding:12px;margin-bottom:8px;display:flex;gap:8px;align-items:center;flex-wrap:wrap"><input class="fw-host" placeholder="IP/域名" style="flex:2;min-width:120px" value="' + (f.host||'') + '"><input class="fw-port" type="number" placeholder="端口" style="flex:1;min-width:70px" value="' + (f.port||'') + '" min="1" max="65535"><input class="fw-name" placeholder="名称" style="flex:1;min-width:80px" value="' + (f.name||'') + '"><label style="display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-secondary);white-space:nowrap"><input type="checkbox" class="forward-toggle" ' + (f.enabled!==false?'checked':'') + '> 启用</label><button class="btn btn-sm btn-danger btn-del-forward">×</button></div>').join('');
|
||||
},
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>仪表盘管理</h3>
|
||||
<div style="display:flex;gap:8px;margin-bottom:16px;">
|
||||
<button id="btn-import-dashboard" class="btn btn-secondary btn-sm">导入 .tsd</button>
|
||||
<span style="font-size:12px;color:var(--text-tertiary);display:flex;align-items:center;">
|
||||
右键点击仪表盘卡片即可导出
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
_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 + '">导出 .tsp</button>' + (g.is_builtin?'':'<button class="btn btn-sm btn-danger btn-remove-plugin" data-plugin-id="' + g.id + '">移除</button>') + '</div></div>').join('');
|
||||
},
|
||||
|
||||
<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">导入 .tsp</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>game_id()</code> 和 <code>parse(data, addr)</code> 方法<br>
|
||||
4. 打包时只需要 zip 这两个文件,重命名后缀为 <code>.tsp</code> 即可导入<br>
|
||||
5. 完整的 <code>TelemetryData</code> 字段参考见 <code>server/telemetry/data.py</code>
|
||||
</div>
|
||||
</div>
|
||||
_template(cfg, games, forwards) {
|
||||
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">TurboSu</div>
|
||||
<div class="settings-desc">赛车遥测仪表盘 v1.0.0 · Yei.J. (AskaEth)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
'<div class="settings-section"><h3>数据转发</h3><p style="font-size:12px;color:var(--text-tertiary);margin-bottom:12px">将收到的游戏原始 UDP 数据包完整转发到下游物理外设</p><div id="forward-list">' + this._forwardListHtml(forwards) + '</div><div style="display:flex;gap:8px;margin-top:8px"><button id="btn-add-forward" class="btn btn-sm btn-secondary">+ 添加目标</button><button id="btn-save-forwards" class="btn btn-sm btn-primary">保存并生效</button></div></div>' +
|
||||
|
||||
'<div class="settings-section"><h3>连接设置</h3><div class="settings-row"><div><div class="settings-label">遥测监听端口</div><div class="settings-desc">游戏内 UDP 输出端口 (默认 20777)</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">重启遥测监听</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-dashboard" class="btn btn-secondary btn-sm">导入 .tsd</button><span style="font-size:12px;color:var(--text-tertiary);display:flex;align-items:center">右键卡片导出</span></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">导入 .tsp</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. 创建包含 manifest.json + parser.py 的文件夹<br>2. manifest.json 定义元信息,parser.py 实现 get_parser() 函数<br>3. 返回对象实现 game_id() 和 parse(data, addr) 方法<br>4. 打包 zip 改后缀 .tsp 即可导入<br>5. TelemetryData 字段参考 server/telemetry/data.py</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 · Yei.J. (AskaEth)</div></div></div></div>' +
|
||||
|
||||
'</div>';
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user