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)