From ae213a84a13ac4225984e37e7675696706d2450e Mon Sep 17 00:00:00 2001 From: HenryDu8133 <813367384@qq.com> Date: Sun, 16 Aug 2026 13:33:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BB=8E=E6=9C=BA=E7=AB=99=E5=8F=B0?= =?UTF-8?q?=E5=90=B8=E9=99=84=E8=AF=86=E5=88=AB=E4=B8=8E=E6=97=B6=E5=88=BB?= =?UTF-8?q?=E8=A1=A8=E8=87=AA=E5=8A=A8=E5=90=8C=E6=AD=A5=20v1.0.73?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Lua端: 新增定期外设重扫描(PERIPHERAL_SCAN_INTERVAL=15s),支持热插拔站台方块 - Lua端: 新增定期身份同步,站台外设热插/热拔时自动刷新身份并重新register - Lua端: 新增独立的schedule sync coroutine与station_schedule_sync消息类型(SCHEDULE_SYNC_INTERVAL=10s) - Lua端: 扩展heartbeat携带hasStationPeripheral/stationPeripheralSide/hasBlockReader/hasCreateTarget/identityMode/upcomingTrains/currentTrain等字段 - 从机若吸附有站台方块(train_station外设),则自动进入主机逻辑: 识别到发、停靠列车检测,数据通过WS推送到服务器 - server端: 新增assignDeviceFields工具函数统一写入设备字段与时刻表 - server端: 新增handleScheduleSync处理station_schedule_sync消息,数据写入stations[name]并broadcast - server端: 扩展handleHeartbeat与handleStationRegister,均调用assignDeviceFields存储设备状态和时刻表摘要 - server端: 扩展getStationGroups分组视图,新增hostCount/slaveCount/hasAnyStationPeripheral/upcomingTrains/currentTrain/members详细信息 - 时刻表合并: 同一站台组多节点的upcomingTrains按trainName@@dest@@platform去重合并; currentTrain优先主机数据 - 版本升级: v1.0.72 -> v1.0.73 --- CloudServer/server.js | 143 ++++++++++++++++++++++++++++++++++++++---- station_broadcast.lua | 141 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 269 insertions(+), 15 deletions(-) diff --git a/CloudServer/server.js b/CloudServer/server.js index 0c9f809..6be9baf 100644 --- a/CloudServer/server.js +++ b/CloudServer/server.js @@ -408,7 +408,31 @@ function getStationGroups() { const key = getStationGroupKey(st); if (!key) continue; + const memberTags = []; + if (st.hasStationPeripheral) memberTags.push('STATION'); + if (st.hasBlockReader) memberTags.push('BLOCKREADER'); + if (st.hasCreateTarget) memberTags.push('TARGET'); + const isHost = Boolean(st.hasStationPeripheral); + + const memberInfo = { + name: st.name, + code, + platform: st.platform || platform, + version: st.version || '', + lastSeen: st.lastSeen || 0, + hasStationPeripheral: isHost, + stationPeripheralSide: st.stationPeripheralSide || '', + hasBlockReader: Boolean(st.hasBlockReader), + hasCreateTarget: Boolean(st.hasCreateTarget), + identityMode: st.identityMode || '', + upcomingCount: Number(st.upcomingCount) || 0, + memberTags + }; + if (!groups.has(key)) { + const mergedTags = ['CC']; + if (isHost) mergedTags.push('HOST'); + else mergedTags.push('SLAVE'); groups.set(key, { key, target: getStationGroupTarget(st), @@ -418,29 +442,72 @@ function getStationGroups() { lastSeen: st.lastSeen || 0, version: st.version || '', source: 'cc', - tags: ['CC'], + tags: mergedTags, memberNames: [st.name], memberCount: 1, - primaryName: st.name + primaryName: st.name, + hasStationPeripheral: isHost, + hasBlockReader: Boolean(st.hasBlockReader), + hasCreateTarget: Boolean(st.hasCreateTarget), + hasAnyStationPeripheral: isHost, + hostCount: isHost ? 1 : 0, + slaveCount: isHost ? 0 : 1, + upcomingCount: Number(st.upcomingCount) || 0, + upcomingTrains: Array.isArray(st.upcomingTrains) ? st.upcomingTrains.slice() : [], + currentTrain: st.currentTrain || null, + members: [memberInfo] }); continue; } const group = groups.get(key); group.memberNames.push(st.name); + group.members.push(memberInfo); group.memberCount = group.memberNames.length; + if (isHost) group.hostCount = (group.hostCount || 0) + 1; + else group.slaveCount = (group.slaveCount || 0) + 1; + if (!group.hasAnyStationPeripheral && isHost) group.hasAnyStationPeripheral = true; + if (!group.hasBlockReader && st.hasBlockReader) group.hasBlockReader = true; + if (!group.hasCreateTarget && st.hasCreateTarget) group.hasCreateTarget = true; + if (!group.hasStationPeripheral && isHost) { + group.hasStationPeripheral = true; + group.primaryName = st.name; + group.name = st.name; + } if ((st.lastSeen || 0) > (group.lastSeen || 0)) { group.lastSeen = st.lastSeen || 0; group.version = st.version || group.version || ''; } else if (!group.version && st.version) { group.version = st.version; } + const existingKeys = new Set((group.upcomingTrains || []).map(t => + String(t.trainName || '') + '@@' + String(t.dest || '') + '@@' + Number(t.platform || 0) + )); + for (const t of (st.upcomingTrains || [])) { + const tk = String(t.trainName || '') + '@@' + String(t.dest || '') + '@@' + Number(t.platform || 0); + if (!existingKeys.has(tk)) { + if (!group.upcomingTrains) group.upcomingTrains = []; + group.upcomingTrains.push(t); + existingKeys.add(tk); + } + } + group.upcomingCount = (group.upcomingTrains || []).length; + if (!group.currentTrain && st.currentTrain) group.currentTrain = st.currentTrain; } - return Array.from(groups.values()).map(group => ({ - ...group, - tags: group.memberCount > 1 ? ['CC', `GROUP:${group.memberCount}`] : ['CC'] - })); + return Array.from(groups.values()).map(group => { + const mergedTags = ['CC']; + if ((group.hostCount || 0) > 0) mergedTags.push(`HOST:${group.hostCount}`); + if ((group.slaveCount || 0) > 0) mergedTags.push(`SLAVE:${group.slaveCount}`); + if (group.memberCount > 1) mergedTags.push(`GROUP:${group.memberCount}`); + if (group.hasAnyStationPeripheral) mergedTags.push('HAS_STATION'); + if (group.hasBlockReader) mergedTags.push('HAS_BLOCKREADER'); + if (group.hasCreateTarget) mergedTags.push('HAS_TARGET'); + return { + ...group, + tags: mergedTags + }; + }); } function resolveStationGroup(selector) { @@ -1857,6 +1924,9 @@ function handleMessage(ws, data) { case 'heartbeat': handleHeartbeat(ws, data); break; + case 'station_schedule_sync': + handleScheduleSync(ws, data); + break; default: log('WARNING', `Unknown message type: ${data.type}`); } @@ -1871,11 +1941,24 @@ function getCorrectStationName(name) { return cleanName; } +function assignDeviceFields(target, data) { + if (!target || !data) return; + if (data.hasStationPeripheral !== undefined) target.hasStationPeripheral = Boolean(data.hasStationPeripheral); + if (data.stationPeripheralSide !== undefined) target.stationPeripheralSide = String(data.stationPeripheralSide || ''); + if (data.hasBlockReader !== undefined) target.hasBlockReader = Boolean(data.hasBlockReader); + if (data.hasCreateTarget !== undefined) target.hasCreateTarget = Boolean(data.hasCreateTarget); + if (data.identityMode !== undefined) target.identityMode = String(data.identityMode || ''); + if (data.upcomingTrains !== undefined) target.upcomingTrains = Array.isArray(data.upcomingTrains) ? data.upcomingTrains : []; + if (data.upcomingCount !== undefined) target.upcomingCount = Number(data.upcomingCount) || 0; + if (data.currentTrain !== undefined) target.currentTrain = data.currentTrain || null; + if (data.tickRate !== undefined) target.tickRate = Number(data.tickRate) || 20; + if (data.syncedAt !== undefined) target.syncedAt = Number(data.syncedAt) || Date.now(); +} + function handleHeartbeat(ws, data) { if (data.stationName) { const cleanName = data.stationName.trim(); if (!stations[cleanName]) { - // Auto-register if missing (e.g. server restarted) stations[cleanName] = { name: cleanName, code: data.stationCode || '', @@ -1884,8 +1967,13 @@ function handleHeartbeat(ws, data) { lastSeen: Date.now(), ws: ws }; + assignDeviceFields(stations[cleanName], data); normalizeStationRecord(stations[cleanName]); - log('INFO', `Station auto-registered via heartbeat: "${cleanName}"${data.stationVersion ? ` ${data.stationVersion}` : ''}`); + const tags = []; + if (stations[cleanName].hasStationPeripheral) tags.push('HAS_STATION'); + if (stations[cleanName].hasBlockReader) tags.push('HAS_BLOCKREADER'); + if (stations[cleanName].hasCreateTarget) tags.push('HAS_TARGET'); + log('INFO', `Station auto-registered via heartbeat: "${cleanName}"${data.stationVersion ? ` ${data.stationVersion}` : ''}${tags.length ? ' [' + tags.join(',') + ']' : ''}`); maybeSendStationUpdate(ws, cleanName, data.stationVersion || stations[cleanName].version); broadcastUpdate(); } else { @@ -1893,10 +1981,14 @@ function handleHeartbeat(ws, data) { if (data.stationCode) stations[cleanName].code = data.stationCode; if (data.stationPlatform) stations[cleanName].platform = data.stationPlatform; if (data.stationVersion) stations[cleanName].version = data.stationVersion; - if (stations[cleanName].ws !== ws) { - stations[cleanName].ws = ws; // Update WS in case of reconnect + const oldWS = stations[cleanName].ws; + assignDeviceFields(stations[cleanName], data); + if (oldWS !== ws) { + stations[cleanName].ws = ws; log('INFO', `Station WebSocket updated via heartbeat: "${cleanName}"${data.stationVersion ? ` ${data.stationVersion}` : ''}`); broadcastUpdate(); + } else if (data.upcomingTrains || data.currentTrain || data.hasStationPeripheral !== undefined) { + broadcastUpdate(); } normalizeStationRecord(stations[cleanName]); maybeSendStationUpdate(ws, cleanName, data.stationVersion || stations[cleanName].version); @@ -1904,6 +1996,33 @@ function handleHeartbeat(ws, data) { } } +function handleScheduleSync(ws, data) { + if (!data.stationName) return; + const cleanName = String(data.stationName || '').trim(); + if (!stations[cleanName]) { + stations[cleanName] = { + name: cleanName, + code: data.stationCode || '', + platform: data.stationPlatform || getPlatformNumberFromName(cleanName), + version: '', + lastSeen: Date.now(), + ws: ws + }; + assignDeviceFields(stations[cleanName], data); + normalizeStationRecord(stations[cleanName]); + log('INFO', `Station auto-registered via schedule sync: "${cleanName}" (upcoming: ${Number(data.upcomingCount) || 0})`); + broadcastUpdate(); + return; + } + stations[cleanName].lastSeen = Date.now(); + if (data.stationCode) stations[cleanName].code = data.stationCode; + if (data.stationPlatform) stations[cleanName].platform = data.stationPlatform; + if (stations[cleanName].ws !== ws) stations[cleanName].ws = ws; + assignDeviceFields(stations[cleanName], data); + normalizeStationRecord(stations[cleanName]); + broadcastUpdate(); +} + function handleStationRegister(ws, data) { const { stationName, stationCode, stationPlatform, stationVersion } = data; const cleanName = stationName.trim(); @@ -1914,13 +2033,13 @@ function handleStationRegister(ws, data) { platform: stationPlatform || getPlatformNumberFromName(cleanName), version: stationVersion || '', lastSeen: Date.now(), - ws: ws // Keep reference if we need to send back + ws: ws }; + assignDeviceFields(stations[cleanName], data); normalizeStationRecord(stations[cleanName]); log('SUCCESS', `Station registered: "${cleanName}" (${stationCode})${stationVersion ? ` ${stationVersion}` : ''}`); - // Send ack ws.send(JSON.stringify({ type: 'station_register_ack', stationName: cleanName diff --git a/station_broadcast.lua b/station_broadcast.lua index 724beef..7ceacf2 100644 --- a/station_broadcast.lua +++ b/station_broadcast.lua @@ -1,12 +1,14 @@ -- 1. Configuration & Constants -local APP_VERSION_TEXT = "v1.0.72" +local APP_VERSION_TEXT = "v1.0.73" local CONFIG = { CLOUD_URL = "ws://stabadmin.fse-media.group", API_URL = "http://stabadmin.fse-media.group", STARTUP_PATH = "/startup", SYNC_INTERVAL = 30, + PERIPHERAL_SCAN_INTERVAL = 15, + SCHEDULE_SYNC_INTERVAL = 10, AUDIO_BASE = "Audio/", AUDIO_STATION = "Audio/Station/", DISK_AUDIO_BASE = "disk/Audio/", @@ -47,6 +49,7 @@ local State = { registered = false, stationList = {}, currentTrain = nil, + upcomingTrains = {}, logs = {}, logScrollOffset = 0, audioRx = nil, @@ -55,7 +58,10 @@ local State = { currentPriority = -1, interruptSeq = 0, tickRate = 20, - updateInProgress = false + updateInProgress = false, + lastPeripheralScan = 0, + lastScheduleSync = 0, + lastIdentitySync = 0 } @@ -2501,18 +2507,111 @@ function Network.heartbeat() while true do if State.ws then Utils.refreshStationIdentity() + local hasBlockReader = Peripherals.blockReader ~= nil + local hasCreateTarget = Peripherals.createTarget ~= nil + local upcomingSummary = {} + if State.upcomingTrains and type(State.upcomingTrains) == "table" then + local maxN = math.min(#State.upcomingTrains, 5) + for i = 1, maxN do + local t = State.upcomingTrains[i] + if t then + table.insert(upcomingSummary, { + trainName = t.trainName, + dest = t.dest, + platform = t.platform, + eta = tonumber(t.eta) or 0, + etaLabel = t.etaLabel, + delay = tonumber(t.delay) or 0, + cancelled = not not t.cancelled, + reason = t.reason + }) + end + end + end + local currentTrainInfo = nil + if State.currentTrain then + currentTrainInfo = { + id = State.currentTrain.id, + dest = State.currentTrain.dest, + nextStation = State.currentTrain.nextStation, + platform = State.currentTrain.platform, + departureTime = State.currentTrain.departureTime + } + end Network.send({ type = "heartbeat", stationName = State.stationName, stationCode = State.stationCode, stationPlatform = State.stationPlatform, - stationVersion = APP_VERSION_TEXT + stationVersion = APP_VERSION_TEXT, + hasStationPeripheral = State.hasStationPeripheral, + stationPeripheralSide = State.stationPeripheralSide, + hasBlockReader = hasBlockReader, + hasCreateTarget = hasCreateTarget, + identityMode = State.stationIdentityMode, + upcomingTrains = upcomingSummary, + currentTrain = currentTrainInfo, + upcomingCount = #(State.upcomingTrains or {}) }) end os.sleep(10) end end +function Network.sendScheduleSync() + if not State.ws then return end + local upcomingTrainsPayload = {} + if State.upcomingTrains and type(State.upcomingTrains) == "table" then + for _, t in ipairs(State.upcomingTrains) do + table.insert(upcomingTrainsPayload, { + trainName = t.trainName, + dest = t.dest, + platform = t.platform, + eta = tonumber(t.eta) or 0, + etaLabel = t.etaLabel, + delay = tonumber(t.delay) or 0, + cancelled = not not t.cancelled, + reason = t.reason, + atStation = not not t._atStation, + departingIn = tonumber(t._departingIn) or nil + }) + end + end + local currentTrainPayload = nil + if State.currentTrain then + local ct = State.currentTrain + currentTrainPayload = { + id = ct.id, + trainType = ct.trainType, + dest = ct.dest, + nextStation = ct.nextStation, + lineCode = ct.lineCode, + destCode = ct.destCode, + nextCode = ct.nextCode, + arrivalTime = ct.arrivalTime, + departureTime = ct.departureTime, + platform = ct.platform, + departureSent = not not ct.departureSent + } + end + Network.send({ + type = "station_schedule_sync", + stationName = State.stationName, + stationCode = State.stationCode, + stationPlatform = State.stationPlatform, + hasStationPeripheral = State.hasStationPeripheral, + stationPeripheralSide = State.stationPeripheralSide, + hasBlockReader = Peripherals.blockReader ~= nil, + hasCreateTarget = Peripherals.createTarget ~= nil, + identityMode = State.stationIdentityMode, + upcomingTrains = upcomingTrainsPayload, + upcomingCount = #upcomingTrainsPayload, + currentTrain = currentTrainPayload, + tickRate = State.tickRate, + syncedAt = os.epoch("utc") + }) +end + -- 7. Display System @@ -3227,6 +3326,42 @@ end function Main.loop() while true do local now = os.epoch("utc") + + local scanInterval = tonumber(CONFIG.PERIPHERAL_SCAN_INTERVAL) or 15 + if (now - State.lastPeripheralScan) >= scanInterval * 1000 then + State.lastPeripheralScan = now + local oldHasStation = State.hasStationPeripheral + local oldStationSide = State.stationPeripheralSide + pcall(function() Peripherals.init() end) + Utils.refreshStationIdentity() + if oldHasStation ~= State.hasStationPeripheral or oldStationSide ~= State.stationPeripheralSide then + if State.hasStationPeripheral then + Utils.log("INFO", "Station peripheral detected at runtime: " .. tostring(State.stationPeripheralSide or "unknown")) + pcall(function() Utils.syncStationIdentityFromPeripheral() end) + Utils.refreshStationIdentity() + if State.ws then pcall(function() Network.register() end) end + else + Utils.log("WARN", "Station peripheral removed at runtime") + end + end + end + + local identityInterval = tonumber(CONFIG.PERIPHERAL_SCAN_INTERVAL) or 15 + if State.hasStationPeripheral and (now - State.lastIdentitySync) >= identityInterval * 1000 then + State.lastIdentitySync = now + pcall(function() + if Utils.syncStationIdentityFromPeripheral() then + Utils.refreshStationIdentity() + end + end) + end + + local scheduleInterval = tonumber(CONFIG.SCHEDULE_SYNC_INTERVAL) or 10 + if (now - State.lastScheduleSync) >= scheduleInterval * 1000 then + State.lastScheduleSync = now + pcall(function() Network.sendScheduleSync() end) + end + local newUpcoming = {} local etaTextByKey = {} local delayTextByKey = {}