| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666 |
- const http = require('http')
- const fs = require('fs')
- const path = require('path')
- const WebSocket = require('ws')
- const { WebSocketServer } = WebSocket
- const HOST = '0.0.0.0'
- const PORT = 17865
- const GPS_WS_PATH = '/mock-gps'
- const HEART_RATE_WS_PATH = '/mock-hr'
- const DEBUG_LOG_WS_PATH = '/debug-log'
- 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()
- if (ext === '.html') {
- return 'text/html; charset=utf-8'
- }
- if (ext === '.css') {
- return 'text/css; charset=utf-8'
- }
- if (ext === '.js') {
- return 'application/javascript; charset=utf-8'
- }
- if (ext === '.json') {
- return 'application/json; charset=utf-8'
- }
- if (ext === '.svg') {
- return 'image/svg+xml'
- }
- 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) {
- let safePath = requestPath === '/' ? '/index.html' : requestPath
- if (safePath.endsWith('/')) {
- safePath = `${safePath}index.html`
- } else if (!path.extname(safePath)) {
- safePath = `${safePath}/index.html`
- }
- const resolvedPath = path.normalize(path.join(PUBLIC_DIR, safePath))
- if (!resolvedPath.startsWith(PUBLIC_DIR)) {
- response.writeHead(403)
- response.end('Forbidden')
- return
- }
- fs.readFile(resolvedPath, (error, content) => {
- if (error) {
- response.writeHead(404)
- response.end('Not Found')
- return
- }
- response.writeHead(200, {
- 'Content-Type': getContentType(resolvedPath),
- 'Cache-Control': 'no-store',
- })
- response.end(content)
- })
- }
- function isMockGpsPayload(payload) {
- return payload
- && payload.type === 'mock_gps'
- && Number.isFinite(payload.lat)
- && Number.isFinite(payload.lon)
- }
- function isMockHeartRatePayload(payload) {
- return payload
- && payload.type === 'mock_heart_rate'
- && Number.isFinite(payload.bpm)
- }
- function isDebugLogPayload(payload) {
- return payload
- && payload.type === 'debug-log'
- && typeof payload.scope === 'string'
- && typeof payload.level === 'string'
- && typeof payload.message === 'string'
- }
- function normalizeChannelId(value) {
- const trimmed = String(value || '').trim()
- return trimmed || 'default'
- }
- async function handleProxyRequest(request, response) {
- const requestUrl = new URL(request.url || '/', `http://127.0.0.1:${PORT}`)
- const targetUrl = requestUrl.searchParams.get('url')
- if (!targetUrl) {
- response.writeHead(400, {
- 'Content-Type': 'text/plain; charset=utf-8',
- 'Access-Control-Allow-Origin': '*',
- })
- response.end('Missing url')
- return
- }
- try {
- const upstream = await fetch(targetUrl)
- const body = Buffer.from(await upstream.arrayBuffer())
- response.writeHead(upstream.status, {
- 'Content-Type': upstream.headers.get('content-type') || 'application/octet-stream',
- 'Cache-Control': 'no-store',
- 'Access-Control-Allow-Origin': '*',
- })
- response.end(body)
- } catch (error) {
- response.writeHead(502, {
- 'Content-Type': 'text/plain; charset=utf-8',
- 'Access-Control-Allow-Origin': '*',
- })
- response.end(error && error.message ? error.message : 'Proxy request failed')
- }
- }
- 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)
- })
- const gpsWss = new WebSocketServer({ noServer: true })
- const heartRateWss = new WebSocketServer({ noServer: true })
- const debugLogWss = new WebSocketServer({ noServer: true })
- gpsWss.on('connection', (socket) => {
- socket.on('message', (rawMessage) => {
- const text = String(rawMessage)
- let parsed
- try {
- parsed = JSON.parse(text)
- } catch (_error) {
- return
- }
- if (!isMockGpsPayload(parsed)) {
- return
- }
- const outgoing = JSON.stringify({
- type: 'mock_gps',
- timestamp: Number.isFinite(parsed.timestamp) ? parsed.timestamp : Date.now(),
- channelId: normalizeChannelId(parsed.channelId),
- lat: Number(parsed.lat),
- lon: Number(parsed.lon),
- accuracyMeters: Number.isFinite(parsed.accuracyMeters) ? Number(parsed.accuracyMeters) : 6,
- speedMps: Number.isFinite(parsed.speedMps) ? Number(parsed.speedMps) : 0,
- headingDeg: Number.isFinite(parsed.headingDeg) ? Number(parsed.headingDeg) : 0,
- })
- gatewayBridge.publish(JSON.parse(outgoing))
- gpsWss.clients.forEach((client) => {
- if (client.readyState === client.OPEN) {
- client.send(outgoing)
- }
- })
- })
- })
- heartRateWss.on('connection', (socket) => {
- socket.on('message', (rawMessage) => {
- const text = String(rawMessage)
- let parsed
- try {
- parsed = JSON.parse(text)
- } catch (_error) {
- return
- }
- if (!isMockHeartRatePayload(parsed)) {
- return
- }
- const outgoing = JSON.stringify({
- type: 'mock_heart_rate',
- timestamp: Number.isFinite(parsed.timestamp) ? parsed.timestamp : Date.now(),
- channelId: normalizeChannelId(parsed.channelId),
- bpm: Math.max(1, Math.round(Number(parsed.bpm))),
- })
- gatewayBridge.publish(JSON.parse(outgoing))
- heartRateWss.clients.forEach((client) => {
- if (client.readyState === client.OPEN) {
- client.send(outgoing)
- }
- })
- })
- })
- debugLogWss.on('connection', (socket) => {
- socket.on('message', (rawMessage) => {
- const text = String(rawMessage)
- let parsed
- try {
- parsed = JSON.parse(text)
- } catch (_error) {
- return
- }
- if (!isDebugLogPayload(parsed)) {
- return
- }
- const outgoing = JSON.stringify({
- type: 'debug-log',
- timestamp: Number.isFinite(parsed.timestamp) ? parsed.timestamp : Date.now(),
- channelId: normalizeChannelId(parsed.channelId),
- scope: String(parsed.scope || 'app').slice(0, 64),
- level: parsed.level === 'warn' || parsed.level === 'error' ? parsed.level : 'info',
- message: String(parsed.message || '').slice(0, 400),
- ...(parsed.payload && typeof parsed.payload === 'object'
- ? { payload: parsed.payload }
- : {}),
- })
- debugLogWss.clients.forEach((client) => {
- if (client.readyState === client.OPEN) {
- client.send(outgoing)
- }
- })
- })
- })
- server.on('upgrade', (request, socket, head) => {
- const requestUrl = request.url || ''
- if (requestUrl.startsWith(GPS_WS_PATH)) {
- gpsWss.handleUpgrade(request, socket, head, (ws) => {
- gpsWss.emit('connection', ws, request)
- })
- return
- }
- if (requestUrl.startsWith(HEART_RATE_WS_PATH)) {
- heartRateWss.handleUpgrade(request, socket, head, (ws) => {
- heartRateWss.emit('connection', ws, request)
- })
- return
- }
- if (requestUrl.startsWith(DEBUG_LOG_WS_PATH)) {
- debugLogWss.handleUpgrade(request, socket, head, (ws) => {
- debugLogWss.emit('connection', ws, request)
- })
- return
- }
- if (!requestUrl) {
- socket.destroy()
- }
- socket.destroy()
- })
- server.listen(PORT, HOST, () => {
- console.log(`Mock GPS simulator running:`)
- console.log(` UI: http://127.0.0.1:${PORT}/`)
- console.log(` GPS WS: ws://127.0.0.1:${PORT}${GPS_WS_PATH}`)
- console.log(` HR WS: ws://127.0.0.1:${PORT}${HEART_RATE_WS_PATH}`)
- console.log(` Logger WS: ws://127.0.0.1:${PORT}${DEBUG_LOG_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`)
- }
- })
|