From 4c7d94f2c97a6b82c5e6481f9c1481b7da97c7a7 Mon Sep 17 00:00:00 2001 From: HenryDu8133 <813367384@qq.com> Date: Mon, 17 Aug 2026 15:32:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(A1+B1):=20=E5=BC=95=E5=85=A5=E7=AB=99?= =?UTF-8?q?=E5=8F=B0=E5=A4=9A=E4=B8=BB=E6=9C=BA=E8=AF=86=E5=88=AB=E3=80=81?= =?UTF-8?q?=E9=9F=B3=E9=A2=91=E7=BA=BF=E7=BC=86=E5=88=86=E7=BB=84=E4=BA=92?= =?UTF-8?q?=E6=96=A5=E4=BB=A4=E7=89=8C=E3=80=81=E6=9C=AC=E5=9C=B0Modem?= =?UTF-8?q?=E4=BB=B2=E8=A3=81=E3=80=81=E7=BA=BF=E7=BC=86=E5=88=86=E7=BB=84?= =?UTF-8?q?=E7=AE=A1=E7=90=86WebUI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 服务端 A1 改动(CloudServer/server.js): - stations 改为 nodes[] 多连接存储,同站台名的多台 CC 主机归入同一组(纯 stationName 精确分组) - 新增 getStationMaster():hasStationPeripheral + lastSeen 加权选 Master,音频只下发给 Master - 新增 audioCableGroups 数据结构(持久化到 server-data.json) - 新增 acquireCableToken/releaseCableToken:同线缆组全局互斥,高优先级发送 audio_preempt 抢占 - 新增 7 路由 REST API:/api/audio-cable-groups CRUD + 批量/单个站台成员管理 + PUT /stations/:name/cable-group 直接改组 - sendAudioControl 给同站组所有节点全量下发(stop/状态同步) - init_state / state_update 携带 audioCableGroups 给前端 - WS close 自动 removeStationWs 清理节点 + 释放令牌 客户端 B1 改动(station_broadcast.lua v1.0.99): - Peripherals 新增 wiredModems 扫描(排除无线),自动 rednet.open - 新增 LocalBus 命名空间: - station_hello 每 10s 广播 + 30s 超时清理邻居 - 同站台名自动组 stationGroup,按 hasStationPeripheral + nodeId 升序选 Master - 新增 Utils.isLocalStationMaster(),所有列车上报(arrival/approaching/alert/departure/schedule_sync)仅 Master 执行,彻底解决重复广播 - acquireLocalToken:audio_probe -> 80ms 收响应 -> 必要时 audio_preempt 抢占 -> token_grant - 令牌持有期间每 2s 广播 audio_playing 心跳,30s 超时自动判死回收 - Audio.playerLoop 在 playItem 前获取本地令牌,失败则跳过该音频 - Audio.stop() 自动释放本地令牌 - WS 新增下行处理:audio_preempt / cable_token_denied / station_group_state - station_identity.cfg 支持 audio_cable_group= 字段,saveStationIdentity 写回 - Network.register/heartbeat 携带 cableGroup 供服务端回填分组 Web 管理端(CloudServer/public/cablemanager.html,单文件无外部依赖): - 极简硬边风格,对齐 listmanager.html 配色和字体 - 分组网格卡片:色标/名称/描述/成员数/当前令牌持有者 + 编辑删除 - 新增/编辑 Modal:名称+描述+8色预设色板+自定义#RRGGBB - 分组详情双栏 Dual List:左栏全部站台(搜索+在线状态+在其他组标签)/ 右栏成员,中间批量添加移除按钮 - 顶部快捷操作:站台下拉+组下拉+确认,直接 PUT /stations/:name/cable-group - WebSocket 监听 state_update 实时刷新,Toast 提示反馈 --- CloudServer/public/cablemanager.html | 1593 ++++++++++++++++++++++++++ station_broadcast.lua | 499 +++++++- 2 files changed, 2080 insertions(+), 12 deletions(-) create mode 100644 CloudServer/public/cablemanager.html diff --git a/CloudServer/public/cablemanager.html b/CloudServer/public/cablemanager.html new file mode 100644 index 0000000..d3c5a6a --- /dev/null +++ b/CloudServer/public/cablemanager.html @@ -0,0 +1,1593 @@ + + + + + + 音频线缆分组管理器 + + + + +
+
+

音频线缆分组管理器

+
+ 控制台 +
+
+ +
+
+
+ 管理音频线缆分组,将站台按物理线缆连接分组,支持令牌传递控制 +
+
+ 快捷设置站台分组: + + + +
+
+
+
+ +
+ +
+
+
+
+
+
加载中...
+
+
+
+
+ +
+
+
+
+ +
+

分组详情

+
+
+ + +
+
+ +
+
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+
+ +
+
+
+ 全部站台 + 0 +
+ +
+
+ +
+ + + + +
+ +
+
+ 当前组成员 + 0 +
+ +
+
+
+
+
+
+ +
+ +
+
+
+
+ + + +
+ + + + diff --git a/station_broadcast.lua b/station_broadcast.lua index 3d7f55a..fb53ba6 100644 --- a/station_broadcast.lua +++ b/station_broadcast.lua @@ -153,6 +153,38 @@ function Peripherals.init() end end Peripherals.blockReader = blockReader + + Peripherals.wiredModems = {} + pcall(function() + local sides = { "back", "top", "bottom", "left", "right", "front" } + for _, side in ipairs(sides) do + if peripheral.isPresent(side) and peripheral.getType(side) == "modem" then + local m = peripheral.wrap(side) + if m and not m.isWireless() then + table.insert(Peripherals.wiredModems, { side = side, modem = m }) + pcall(function() rednet.open(side) end) + end + end + end + if peripheral.getNames then + for _, name in ipairs(peripheral.getNames()) do + local already = false + for _, wm in ipairs(Peripherals.wiredModems) do + if wm.side == name then already = true; break end + end + if not already and peripheral.getType(name) == "modem" then + local m = peripheral.wrap(name) + if m and type(m.isWireless) == "function" and not m.isWireless() then + table.insert(Peripherals.wiredModems, { side = name, modem = m }) + pcall(function() rednet.open(name) end) + end + end + end + end + end) + if #Peripherals.wiredModems > 0 then + Utils.log("INFO", "Wired modem(s) detected for LocalBus: x" .. tostring(#Peripherals.wiredModems)) + end end @@ -203,7 +235,17 @@ function Utils.loadStationIdentity() return data end -function Utils.saveStationIdentity(stationCode, stationPlatform, departureBeep) +function Utils.saveStationIdentity(stationCode, stationPlatform, departureBeep, cableGroupOverride) + local existing = Utils.loadStationIdentity() or {} + local cableGroup = cableGroupOverride + if cableGroup == nil then cableGroup = existing.audio_cable_group or "" end + if type(stationCode) == "table" then + local tbl = stationCode + stationCode = tbl.station_code or tbl.stationCode + stationPlatform = tbl.station_platform or tbl.stationPlatform + departureBeep = tbl.departure_beep or tbl.departureBeep + cableGroup = tbl.audio_cable_group or tbl.cableGroup or cableGroup + end local handle = fs.open(CONFIG.STATION_IDENTITY_FILE, "w") if not handle then return false @@ -213,6 +255,7 @@ function Utils.saveStationIdentity(stationCode, stationPlatform, departureBeep) "station_code=" .. Utils.trim(stationCode or ""), "station_platform=" .. Utils.trim(stationPlatform or ""), "departure_beep=" .. Utils.trim(departureBeep or "fc1"), + "audio_cable_group=" .. Utils.trim(cableGroup or ""), "" }, "\n")) handle.close() @@ -555,6 +598,15 @@ function Utils.refreshStationIdentity() State.stationPlatform = identity.stationPlatform State.departureBeep = identity.departureBeep State.stationIdentityMode = identity.mode + local manual = Utils.loadStationIdentity() or {} + State.localCableGroup = Utils.trim(manual.audio_cable_group or "") +end + +function Utils.isLocalStationMaster() + if not LocalBus or not LocalBus.stationGroup or not LocalBus.stationGroup.masterNodeId then + return true + end + return LocalBus.stationGroup.masterNodeId == LocalBus.nodeId end function Utils.getStationCode(name) @@ -1184,6 +1236,13 @@ function Audio.stop(mode, flushBelow) State.audioQueue = kept end + if LocalBus and LocalBus.tokenHolder then + local cg = LocalBus.tokenHolder.cableGroup + local sn = LocalBus.tokenHolder.stationName + local aid = LocalBus.tokenHolder.audioId + LocalBus.releaseLocalToken(cg, sn, aid) + end + os.queueEvent("audio_interrupt", State.interruptSeq) end @@ -2017,16 +2076,38 @@ function Audio.playerLoop() os.sleep(0.05) else local item = table.remove(State.audioQueue, 1) - State.currentPlaying = item - State.currentPriority = tonumber(item.priority) or 0 - - local ok, err = pcall(Audio.playItem, item) - if not ok then - Utils.log("ERROR", "Audio Playback Crash: " .. tostring(err)) + local cableGroup = tostring(State.localCableGroup or "") + local audioId = tostring(item.audioId or ("local-" .. tostring(os.clock()))) + local priority = tonumber(item.priority) or 0 + + local tokenOk = true + if LocalBus and LocalBus.enabled and cableGroup ~= "" then + tokenOk = LocalBus.acquireLocalToken(cableGroup, State.stationName, audioId, priority, 1500) + end + + if not tokenOk then + Utils.log("WARN", "Audio playback skipped: local cable token denied for audioId=" .. audioId) + State.currentPlaying = nil + State.currentPriority = -1 + if LocalBus and LocalBus.tokenHolder then + LocalBus.releaseLocalToken(cableGroup, State.stationName, audioId) + end + else + State.currentPlaying = item + State.currentPriority = priority + + local ok, err = pcall(Audio.playItem, item) + if not ok then + Utils.log("ERROR", "Audio Playback Crash: " .. tostring(err)) + end + + State.currentPlaying = nil + State.currentPriority = -1 + + if LocalBus and LocalBus.tokenHolder then + LocalBus.releaseLocalToken(cableGroup, State.stationName, audioId) + end end - - State.currentPlaying = nil - State.currentPriority = -1 end end end @@ -2249,6 +2330,381 @@ function AutoUpdate.checkOnBoot() return ok end +-- ============================================================================ +-- 5.5 LocalBus (Wired Modem / RedNet local arbitration & station group) +-- ============================================================================ +local LocalBus = {} +LocalBus.nodeId = tostring(os.getComputerID() or math.floor(math.random() * 1e9)) +LocalBus.enabled = false +LocalBus.neighbors = {} +LocalBus.stationGroup = { members = {}, masterNodeId = nil, groupKey = nil } +LocalBus.tokenHolder = nil +LocalBus.tokenQueue = {} +LocalBus.lastHelloSentAt = 0 +LocalBus.lastPlayingAt = 0 +LocalBus.pendingProbeResponses = {} + +function LocalBus._hasWiredModem() + return Peripherals.wiredModems and #Peripherals.wiredModems > 0 +end + +function LocalBus._broadcast(msg) + if not msg or type(msg) ~= "table" then return end + msg.nodeId = LocalBus.nodeId + msg.ts = msg.ts or os.epoch("utc") + local raw = textutils.serialize(msg) + if LocalBus._hasWiredModem() then + pcall(function() + if rednet then rednet.broadcast(raw, "station_bus") end + end) + for _, wm in ipairs(Peripherals.wiredModems) do + pcall(function() + if wm.modem and type(wm.modem.transmit) == "function" then + wm.modem.transmit(7420, 7420, raw) + end + end) + end + end +end + +function LocalBus.init() + LocalBus.enabled = LocalBus._hasWiredModem() + Utils.refreshStationIdentity() + LocalBus.stationGroup = { + members = { { nodeId = LocalBus.nodeId, self = true, stationName = State.stationName, hasStationPeripheral = State.hasStationPeripheral } }, + masterNodeId = LocalBus.nodeId, + groupKey = nil + } + if LocalBus.enabled then + Utils.log("INFO", "LocalBus enabled via wired modem(s): nodeId=" .. LocalBus.nodeId .. " cableGroup=" .. tostring(State.localCableGroup or "(none)")) + end +end + +function LocalBus._sendHello() + LocalBus._broadcast({ + type = "station_hello", + stationName = State.stationName, + stationCode = State.stationCode, + stationPlatform = State.stationPlatform, + cableGroup = tostring(State.localCableGroup or ""), + hasStationPeripheral = not not State.hasStationPeripheral, + isMaster = Utils.isLocalStationMaster() + }) +end + +function LocalBus._cleanupNeighbors(now) + local cutoff = (now or os.epoch("utc")) - 30000 + local changed = false + for nid, nb in pairs(LocalBus.neighbors) do + if (nb.ts or 0) < cutoff then + LocalBus.neighbors[nid] = nil + changed = true + end + end + if changed then LocalBus._rebuildStationGroup() end +end + +function LocalBus._rebuildStationGroup() + local myName = Utils.trim(State.stationName or "") + local members = { { + nodeId = LocalBus.nodeId, + self = true, + stationName = myName, + hasStationPeripheral = not not State.hasStationPeripheral, + cableGroup = tostring(State.localCableGroup or ""), + lastSeen = os.epoch("utc") + } } + for _, nb in pairs(LocalBus.neighbors) do + if nb and type(nb) == "table" and Utils.trim(nb.stationName or "") == myName then + table.insert(members, { + nodeId = nb.nodeId, + self = false, + stationName = nb.stationName, + hasStationPeripheral = not not nb.hasStationPeripheral, + cableGroup = nb.cableGroup or "", + lastSeen = nb.ts or 0 + }) + end + end + table.sort(members, function(a, b) + if a.hasStationPeripheral ~= b.hasStationPeripheral then + return a.hasStationPeripheral and true or false + end + return tostring(a.nodeId) < tostring(b.nodeId) + end) + local master = members[1] + LocalBus.stationGroup = { + members = members, + masterNodeId = master and master.nodeId or LocalBus.nodeId, + groupKey = myName, + memberCount = #members + } +end + +function LocalBus._sameStation(msg) + if not msg or not msg.stationName then return false end + local a = Utils.trim(State.stationName or "") + local b = Utils.trim(msg.stationName or "") + return a ~= "" and a == b +end + +function LocalBus._sameGroup(msg) + if not msg then return false end + local a = Utils.trim(State.localCableGroup or "") + local b = Utils.trim(msg.cableGroup or "") + if a == "" or b == "" then return false end + return a == b +end + +function LocalBus.releaseLocalToken(cableGroup, stationName, audioId) + if not LocalBus.tokenHolder then return end + local match = true + if cableGroup and cableGroup ~= "" and LocalBus.tokenHolder.cableGroup ~= cableGroup then match = false end + if audioId and LocalBus.tokenHolder.audioId and LocalBus.tokenHolder.audioId ~= audioId then match = false end + if match then + LocalBus._broadcast({ + type = "audio_release", + stationName = State.stationName, + cableGroup = LocalBus.tokenHolder.cableGroup, + audioId = LocalBus.tokenHolder.audioId + }) + LocalBus.tokenHolder = nil + end +end + +function LocalBus._sendPlayingHeartbeat(force) + if not LocalBus.tokenHolder then return end + local now = os.epoch("utc") + if force or (now - LocalBus.lastPlayingAt) >= 2000 then + LocalBus._broadcast({ + type = "audio_playing", + stationName = State.stationName, + cableGroup = LocalBus.tokenHolder.cableGroup, + audioId = LocalBus.tokenHolder.audioId, + priority = LocalBus.tokenHolder.priority, + startedAt = LocalBus.tokenHolder.startedAt + }) + LocalBus.lastPlayingAt = now + end +end + +function LocalBus._collectProbeResponses(cableGroup, audioId, waitMs) + LocalBus.pendingProbeResponses = { playing = {}, requests = {} } + local started = os.clock() + local timeout = started + (waitMs / 1000) + while os.clock() < timeout do + local sid, msg, proto + if rednet then + pcall(function() sid, msg, proto = rednet.receive("station_bus", 0.02) end) + end + if type(msg) == "string" then + local ok, tbl = pcall(textutils.unserialize, msg) + if ok and type(tbl) == "table" then LocalBus._handleIncoming(tbl) end + end + os.sleep(0.01) + end + return LocalBus.pendingProbeResponses +end + +function LocalBus._handleIncoming(tbl) + if not tbl or type(tbl) ~= "table" or not tbl.type then return end + if tbl.nodeId == LocalBus.nodeId then return end + if tbl.type == "station_hello" then + LocalBus.neighbors[tostring(tbl.nodeId)] = tbl + LocalBus._rebuildStationGroup() + elseif tbl.type == "audio_probe" or tbl.type == "audio_playing" or tbl.type == "audio_token_grant" then + if LocalBus._sameGroup(tbl) or LocalBus._sameStation(tbl) then + if tbl.type == "audio_playing" then + table.insert(LocalBus.pendingProbeResponses.playing, tbl) + elseif tbl.type == "audio_probe" then + table.insert(LocalBus.pendingProbeResponses.requests, tbl) + if LocalBus.tokenHolder then + LocalBus._broadcast({ + type = "audio_playing", + stationName = State.stationName, + cableGroup = LocalBus.tokenHolder.cableGroup, + audioId = LocalBus.tokenHolder.audioId, + priority = LocalBus.tokenHolder.priority, + startedAt = LocalBus.tokenHolder.startedAt, + targetProbeId = tostring(tbl.nodeId) + }) + end + end + end + elseif tbl.type == "audio_preempt" then + if tostring(tbl.targetNodeId or "") == LocalBus.nodeId and LocalBus.tokenHolder then + local newP = tonumber(tbl.newPriority) or 0 + Utils.log("WARN", "LocalBus audio preempted by node " .. tostring(tbl.nodeId) .. " reason=" .. tostring(tbl.reason or "")) + Audio.stop("current", newP) + LocalBus.releaseLocalToken(nil, nil, nil) + end + elseif tbl.type == "audio_release" then + if LocalBus._sameGroup(tbl) then + LocalBus.tokenQueue = {} + end + end +end + +function LocalBus.acquireLocalToken(cableGroup, stationName, audioId, priority, timeoutMs) + if not LocalBus.enabled then return true end + cableGroup = Utils.trim(cableGroup or "") + priority = tonumber(priority) or 0 + if cableGroup == "" then return true end + + local attempts = 0 + while attempts < 2 do + attempts = attempts + 1 + LocalBus._broadcast({ + type = "audio_probe", + stationName = stationName or State.stationName, + cableGroup = cableGroup, + audioId = audioId, + priority = priority + }) + local responses = LocalBus._collectProbeResponses(cableGroup, audioId, 80) + local conflictHolder = nil + for _, p in ipairs(responses.playing or {}) do + local holderPrio = tonumber(p.priority) or 0 + if holderPrio >= priority then + if not conflictHolder or holderPrio > (tonumber(conflictHolder.priority) or 0) then + conflictHolder = p + end + end + end + for _, r in ipairs(responses.requests or {}) do + if LocalBus._sameStation(r) then + local rPrio = tonumber(r.priority) or 0 + local myId = tostring(LocalBus.nodeId) + local otherId = tostring(r.nodeId) + if rPrio >= priority and otherId < myId then + conflictHolder = r + end + end + end + if not conflictHolder then + LocalBus.tokenHolder = { + cableGroup = cableGroup, + stationName = stationName or State.stationName, + audioId = audioId, + priority = priority, + startedAt = os.epoch("utc"), + attempts = attempts + } + LocalBus._broadcast({ + type = "audio_token_grant", + requesterNodeId = LocalBus.nodeId, + stationName = State.stationName, + cableGroup = cableGroup, + audioId = audioId, + priority = priority + }) + LocalBus._sendPlayingHeartbeat(true) + return true + else + local holderPrio = tonumber(conflictHolder.priority) or 0 + if priority > holderPrio then + LocalBus._broadcast({ + type = "audio_preempt", + targetNodeId = tostring(conflictHolder.nodeId), + stationName = State.stationName, + cableGroup = cableGroup, + audioId = audioId, + reason = "higher_priority", + newPriority = priority + }) + os.sleep(0.15) + else + local waited = 0 + while waited < (timeoutMs or 1500) / 1000 do + if not LocalBus.tokenHolder then break end + os.sleep(0.05) + waited = waited + 0.05 + end + if not LocalBus.tokenHolder then break end + end + end + end + if attempts >= 2 then + Utils.log("WARN", "LocalBus token acquire failed: cableGroup=" .. cableGroup .. " audioId=" .. tostring(audioId)) + return false + end + LocalBus.tokenHolder = { + cableGroup = cableGroup, + stationName = stationName or State.stationName, + audioId = audioId, + priority = priority, + startedAt = os.epoch("utc"), + attempts = attempts + } + LocalBus._sendPlayingHeartbeat(true) + return true +end + +function LocalBus._cleanupExpiredTokens(now) + now = now or os.epoch("utc") + if LocalBus.tokenHolder then + local elapsed = now - (LocalBus.tokenHolder.startedAt or now) + if elapsed > 30000 then + Utils.log("WARN", "LocalBus token expired (30s), releasing: " .. tostring(LocalBus.tokenHolder.audioId)) + LocalBus.releaseLocalToken(nil, nil, nil) + end + end +end + +function LocalBus._pullOnce(timeoutSec) + timeoutSec = timeoutSec or 0.02 + if LocalBus.enabled and rednet then + local sid, msg, proto + pcall(function() sid, msg, proto = rednet.receive("station_bus", timeoutSec) end) + if type(msg) == "string" then + local ok, tbl = pcall(textutils.unserialize, msg) + if ok and type(tbl) == "table" then + LocalBus._handleIncoming(tbl) + return true + end + end + end + for _, wm in ipairs(Peripherals.wiredModems or {}) do + local got, side, ch, repCh, payload + pcall(function() + if wm.modem and type(os.pullEvent) == "function" then + local ev = { os.pullEvent("modem_message", timeoutSec / 2) } + if ev and ev[1] == "modem_message" then + side, ch, repCh, payload = ev[2], ev[3], ev[4], ev[5] + got = (side == wm.side and ch == 7420) + end + end + end) + if got and type(payload) == "string" then + local ok2, tbl2 = pcall(textutils.unserialize, payload) + if ok2 and type(tbl2) == "table" then + LocalBus._handleIncoming(tbl2) + return true + end + end + end + return false +end + +function LocalBus.loop() + LocalBus.init() + LocalBus._sendHello() + LocalBus.lastHelloSentAt = os.epoch("utc") + + while true do + local now = os.epoch("utc") + if (now - LocalBus.lastHelloSentAt) >= 10000 then + LocalBus._sendHello() + LocalBus.lastHelloSentAt = now + end + LocalBus._cleanupNeighbors(now) + LocalBus._cleanupExpiredTokens(now) + LocalBus._sendPlayingHeartbeat(false) + pcall(function() LocalBus._pullOnce(0.05) end) + os.sleep(0.05) + end +end + -- ============================================================================ -- 6. Network System -- ============================================================================ @@ -2285,7 +2741,8 @@ function Network.register() stationName = State.stationName, stationCode = State.stationCode, stationPlatform = State.stationPlatform, - stationVersion = APP_VERSION_TEXT + stationVersion = APP_VERSION_TEXT, + cableGroup = tostring(State.localCableGroup or "") }) end @@ -2449,6 +2906,22 @@ function Network.messageLoop() Utils.log("ERROR", "Station update failed: " .. tostring(updateErr)) end end + elseif data.type == "audio_preempt" then + local flushBelow = tonumber(data.priority) or 0 + local mode = tostring(data.mode or "current") + local reason = tostring(data.reason or "server_preempt") + Utils.log("WARN", "Audio preempted by server: reason=" .. reason .. " priority>=" .. tostring(flushBelow)) + Audio.stop(mode, flushBelow) + if LocalBus and LocalBus.tokenHolder then + LocalBus.releaseLocalToken(nil, nil, nil) + end + elseif data.type == "cable_token_denied" then + Utils.log("WARN", "Server cable token denied: group=" .. tostring(data.cableGroupId or "") .. " holder=" .. tostring(data.holderStationName or "") .. " reason=" .. tostring(data.reason or "")) + elseif data.type == "station_group_state" then + if data.masterNodeId and LocalBus and not LocalBus.enabled then + LocalBus.stationGroup = LocalBus.stationGroup or {} + LocalBus.stationGroup.masterNodeId = tostring(data.masterNodeId) + end end else if State.audioRx then @@ -2530,7 +3003,9 @@ function Network.heartbeat() identityMode = State.stationIdentityMode, upcomingTrains = upcomingSummary, currentTrain = currentTrainInfo, - upcomingCount = #(State.upcomingTrains or {}) + upcomingCount = #(State.upcomingTrains or {}), + cableGroup = tostring(State.localCableGroup or ""), + tickRate = State.tickRate }) end os.sleep(10)