feat(A1+B1): 引入站台多主机识别、音频线缆分组互斥令牌、本地Modem仲裁、线缆分组管理WebUI

服务端 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 提示反馈
This commit is contained in:
2026-08-17 15:32:20 +08:00
parent 6ac05d3a84
commit 4c7d94f2c9
2 changed files with 2080 additions and 12 deletions
+487 -12
View File
@@ -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)