Add realtime gateway and simulator bridge

This commit is contained in:
2026-03-27 21:06:17 +08:00
parent 0703fd47a2
commit 2c0fd4c549
36 changed files with 6852 additions and 1 deletions

View File

@@ -1,13 +1,29 @@
const http = require('http')
const fs = require('fs')
const path = require('path')
const { WebSocketServer } = require('ws')
const WebSocket = require('ws')
const { WebSocketServer } = WebSocket
const HOST = '0.0.0.0'
const PORT = 17865
const WS_PATH = '/mock-gps'
const PROXY_PATH = '/proxy'
const BRIDGE_STATUS_PATH = '/bridge-status'
const BRIDGE_CONFIG_PATH = '/bridge-config'
const PUBLIC_DIR = path.join(__dirname, 'public')
const DEFAULT_GATEWAY_BRIDGE_URL = 'ws://127.0.0.1:18080/ws'
const INITIAL_BRIDGE_CONFIG = {
enabled: process.env.MOCK_SIM_GATEWAY_ENABLED === '1',
url: process.env.MOCK_SIM_GATEWAY_URL || DEFAULT_GATEWAY_BRIDGE_URL,
token: process.env.MOCK_SIM_GATEWAY_TOKEN || 'dev-producer-token',
channelId: process.env.MOCK_SIM_GATEWAY_CHANNEL_ID || '',
deviceId: process.env.MOCK_SIM_GATEWAY_DEVICE_ID || 'child-001',
groupId: process.env.MOCK_SIM_GATEWAY_GROUP_ID || '',
sourceId: process.env.MOCK_SIM_GATEWAY_SOURCE_ID || 'mock-gps-sim',
sourceMode: process.env.MOCK_SIM_GATEWAY_SOURCE_MODE || 'mock',
reconnectMs: Math.max(1000, Number(process.env.MOCK_SIM_GATEWAY_RECONNECT_MS) || 3000),
}
function getContentType(filePath) {
const ext = path.extname(filePath).toLowerCase()
@@ -29,6 +45,15 @@ function getContentType(filePath) {
return 'text/plain; charset=utf-8'
}
function respondJson(response, statusCode, payload) {
response.writeHead(statusCode, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
'Access-Control-Allow-Origin': '*',
})
response.end(JSON.stringify(payload))
}
function serveStatic(requestPath, response) {
const safePath = requestPath === '/' ? '/index.html' : requestPath
const resolvedPath = path.normalize(path.join(PUBLIC_DIR, safePath))
@@ -96,12 +121,379 @@ async function handleProxyRequest(request, response) {
}
}
async function readJsonBody(request) {
return new Promise((resolve, reject) => {
const chunks = []
request.on('data', (chunk) => {
chunks.push(chunk)
})
request.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf8').trim()
if (!raw) {
resolve({})
return
}
try {
resolve(JSON.parse(raw))
} catch (error) {
reject(error)
}
})
request.on('error', reject)
})
}
function normalizeBridgeConfig(input, currentConfig) {
const source = input || {}
const fallback = currentConfig || INITIAL_BRIDGE_CONFIG
return {
enabled: typeof source.enabled === 'boolean' ? source.enabled : fallback.enabled,
url: typeof source.url === 'string' && source.url.trim() ? source.url.trim() : fallback.url,
token: typeof source.token === 'string' ? source.token.trim() : fallback.token,
channelId: typeof source.channelId === 'string' ? source.channelId.trim() : fallback.channelId,
deviceId: typeof source.deviceId === 'string' && source.deviceId.trim() ? source.deviceId.trim() : fallback.deviceId,
groupId: typeof source.groupId === 'string' ? source.groupId.trim() : fallback.groupId,
sourceId: typeof source.sourceId === 'string' && source.sourceId.trim() ? source.sourceId.trim() : fallback.sourceId,
sourceMode: typeof source.sourceMode === 'string' && source.sourceMode.trim() ? source.sourceMode.trim() : fallback.sourceMode,
reconnectMs: Math.max(1000, Number(source.reconnectMs) || fallback.reconnectMs),
}
}
function createGatewayBridge() {
const bridgeState = {
config: { ...INITIAL_BRIDGE_CONFIG },
socket: null,
connecting: false,
connected: false,
authenticated: false,
reconnectTimer: 0,
lastError: '',
lastSentAt: 0,
lastSentTopic: '',
sentCount: 0,
droppedCount: 0,
}
function logBridge(message) {
console.log(`[gateway-bridge] ${message}`)
}
function clearReconnectTimer() {
if (!bridgeState.reconnectTimer) {
return
}
clearTimeout(bridgeState.reconnectTimer)
bridgeState.reconnectTimer = 0
}
function scheduleReconnect() {
if (!bridgeState.config.enabled || bridgeState.reconnectTimer) {
return
}
bridgeState.reconnectTimer = setTimeout(() => {
bridgeState.reconnectTimer = 0
connect()
}, bridgeState.config.reconnectMs)
}
function resetSocketState() {
bridgeState.socket = null
bridgeState.connecting = false
bridgeState.connected = false
bridgeState.authenticated = false
}
function handleGatewayMessage(rawMessage) {
let parsed
try {
parsed = JSON.parse(String(rawMessage))
} catch (_error) {
return
}
if (parsed.type === 'welcome') {
if (!bridgeState.socket || bridgeState.socket.readyState !== WebSocket.OPEN) {
return
}
if (bridgeState.config.channelId) {
bridgeState.socket.send(JSON.stringify({
type: 'join_channel',
role: 'producer',
channelId: bridgeState.config.channelId,
token: bridgeState.config.token,
}))
} else {
bridgeState.socket.send(JSON.stringify({
type: 'authenticate',
role: 'producer',
token: bridgeState.config.token,
}))
}
return
}
if (parsed.type === 'authenticated' || parsed.type === 'joined_channel') {
bridgeState.authenticated = true
bridgeState.lastError = ''
if (bridgeState.config.channelId) {
logBridge(`joined channel=${bridgeState.config.channelId}, device=${bridgeState.config.deviceId}, source=${bridgeState.config.sourceId}`)
} else {
logBridge(`authenticated, device=${bridgeState.config.deviceId}, source=${bridgeState.config.sourceId}`)
}
return
}
if (parsed.type === 'error') {
bridgeState.lastError = parsed.error || 'gateway error'
logBridge(`error: ${bridgeState.lastError}`)
}
}
function closeSocket() {
if (!bridgeState.socket) {
return
}
try {
bridgeState.socket.close()
} catch (_error) {
// noop
}
resetSocketState()
}
function connect() {
if (!bridgeState.config.enabled || bridgeState.connecting) {
return
}
if (bridgeState.socket && (bridgeState.socket.readyState === WebSocket.OPEN || bridgeState.socket.readyState === WebSocket.CONNECTING)) {
return
}
clearReconnectTimer()
bridgeState.connecting = true
bridgeState.lastError = ''
logBridge(`connecting to ${bridgeState.config.url}`)
const socket = new WebSocket(bridgeState.config.url)
bridgeState.socket = socket
socket.on('open', () => {
bridgeState.connecting = false
bridgeState.connected = true
logBridge('connected')
})
socket.on('message', handleGatewayMessage)
socket.on('close', () => {
const wasConnected = bridgeState.connected || bridgeState.authenticated
resetSocketState()
if (wasConnected) {
logBridge('disconnected')
}
scheduleReconnect()
})
socket.on('error', (error) => {
bridgeState.lastError = error && error.message ? error.message : 'gateway socket error'
logBridge(`socket error: ${bridgeState.lastError}`)
})
}
function toGatewayEnvelope(payload) {
if (isMockGpsPayload(payload)) {
return {
schemaVersion: 1,
messageId: `gps-${payload.timestamp}`,
timestamp: payload.timestamp,
topic: 'telemetry.location',
source: {
kind: 'producer',
id: bridgeState.config.sourceId,
mode: bridgeState.config.sourceMode,
},
target: {
channelId: bridgeState.config.channelId,
deviceId: bridgeState.config.deviceId,
groupId: bridgeState.config.groupId,
},
payload: {
lat: Number(payload.lat),
lng: Number(payload.lon),
speed: Number(payload.speedMps) || 0,
bearing: Number(payload.headingDeg) || 0,
accuracy: Number(payload.accuracyMeters) || 6,
coordSystem: 'GCJ02',
},
}
}
if (isMockHeartRatePayload(payload)) {
return {
schemaVersion: 1,
messageId: `hr-${payload.timestamp}`,
timestamp: payload.timestamp,
topic: 'telemetry.heart_rate',
source: {
kind: 'producer',
id: bridgeState.config.sourceId,
mode: bridgeState.config.sourceMode,
},
target: {
channelId: bridgeState.config.channelId,
deviceId: bridgeState.config.deviceId,
groupId: bridgeState.config.groupId,
},
payload: {
bpm: Math.max(1, Math.round(Number(payload.bpm))),
},
}
}
return null
}
function publish(payload) {
if (!bridgeState.config.enabled) {
return
}
if (!bridgeState.socket || bridgeState.socket.readyState !== WebSocket.OPEN || !bridgeState.authenticated) {
bridgeState.droppedCount += 1
connect()
return
}
const envelope = toGatewayEnvelope(payload)
if (!envelope) {
return
}
bridgeState.socket.send(JSON.stringify({
type: 'publish',
envelope,
}))
bridgeState.lastSentAt = Date.now()
bridgeState.lastSentTopic = envelope.topic
bridgeState.sentCount += 1
}
function updateConfig(nextConfigInput) {
const nextConfig = normalizeBridgeConfig(nextConfigInput, bridgeState.config)
const changed = JSON.stringify(nextConfig) !== JSON.stringify(bridgeState.config)
bridgeState.config = nextConfig
if (!changed) {
return getStatus()
}
bridgeState.lastError = ''
if (!bridgeState.config.enabled) {
clearReconnectTimer()
closeSocket()
logBridge('disabled')
return getStatus()
}
clearReconnectTimer()
closeSocket()
connect()
return getStatus()
}
function getConfig() {
return { ...bridgeState.config }
}
function getStatus() {
return {
enabled: bridgeState.config.enabled,
url: bridgeState.config.url,
connected: bridgeState.connected,
authenticated: bridgeState.authenticated,
channelId: bridgeState.config.channelId,
deviceId: bridgeState.config.deviceId,
groupId: bridgeState.config.groupId,
sourceId: bridgeState.config.sourceId,
sourceMode: bridgeState.config.sourceMode,
reconnectMs: bridgeState.config.reconnectMs,
hasToken: Boolean(bridgeState.config.token),
sentCount: bridgeState.sentCount,
droppedCount: bridgeState.droppedCount,
lastSentAt: bridgeState.lastSentAt,
lastSentTopic: bridgeState.lastSentTopic,
lastError: bridgeState.lastError,
}
}
if (bridgeState.config.enabled) {
connect()
}
return {
publish,
updateConfig,
getConfig,
getStatus,
}
}
const gatewayBridge = createGatewayBridge()
const server = http.createServer((request, response) => {
if (request.method === 'OPTIONS') {
response.writeHead(204, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
})
response.end()
return
}
if ((request.url || '').startsWith(PROXY_PATH)) {
handleProxyRequest(request, response)
return
}
if ((request.url || '').startsWith(BRIDGE_CONFIG_PATH)) {
if (request.method === 'GET') {
respondJson(response, 200, {
config: gatewayBridge.getConfig(),
status: gatewayBridge.getStatus(),
})
return
}
if (request.method === 'POST') {
readJsonBody(request)
.then((payload) => {
const status = gatewayBridge.updateConfig(payload)
respondJson(response, 200, {
config: gatewayBridge.getConfig(),
status,
})
})
.catch((error) => {
respondJson(response, 400, {
error: error && error.message ? error.message : 'Invalid JSON body',
})
})
return
}
respondJson(response, 405, {
error: 'Method Not Allowed',
})
return
}
if ((request.url || '').startsWith(BRIDGE_STATUS_PATH)) {
respondJson(response, 200, gatewayBridge.getStatus())
return
}
serveStatic(request.url || '/', response)
})
@@ -137,6 +529,8 @@ wss.on('connection', (socket) => {
bpm: Math.max(1, Math.round(Number(parsed.bpm))),
})
gatewayBridge.publish(JSON.parse(serialized))
wss.clients.forEach((client) => {
if (client.readyState === client.OPEN) {
client.send(serialized)
@@ -161,4 +555,12 @@ server.listen(PORT, HOST, () => {
console.log(` UI: http://127.0.0.1:${PORT}/`)
console.log(` WS: ws://127.0.0.1:${PORT}${WS_PATH}`)
console.log(` Proxy: http://127.0.0.1:${PORT}${PROXY_PATH}?url=<remote-url>`)
console.log(` Bridge status: http://127.0.0.1:${PORT}${BRIDGE_STATUS_PATH}`)
console.log(` Bridge config: http://127.0.0.1:${PORT}${BRIDGE_CONFIG_PATH}`)
if (INITIAL_BRIDGE_CONFIG.enabled) {
console.log(` Gateway bridge: enabled -> ${INITIAL_BRIDGE_CONFIG.url}`)
console.log(` Gateway target device: ${INITIAL_BRIDGE_CONFIG.deviceId}`)
} else {
console.log(` Gateway bridge: disabled`)
}
})