feat(server): 实施 A1 方案 - 多连接存储、站台分组 Master 选举、audioCableGroup 互斥令牌机制

- stations 存储改造:stations[name] = { name, code, platform, audioCableGroup, version, lastSeen, nodes: [] } 结构,每个 node 含 ws/hasStationPeripheral/stationPeripheralSide/hasBlockReader/hasCreateTarget/identityMode/nodeId/wsId/connectedAt
- 新增 audioCableGroups:{ id, name, color, description, stationNames: [] } 分组,及 audioCableTokens 互斥令牌 Map(cableGroupId -> { holderStationName, holderAudioId, holderPriority, acquiredAt, cancelToken })
- 新增核心函数:upsertStationNode(按 wsId 去重更新 node、聚合计时表级 schedule)、removeStationWs(WS 关闭时清理 node + 释放令牌 + 下发 group_state)、getStationMaster(hasStationPeripheral=1000加权 + lastSeen小数加权 两轮选举 OPEN ws)、getAllStationConnections、getGroupKeyForStation(纯 stationName 分组,不再 code@@platform)、acquireCableToken(无持者直给/同 holder 更新/高优先级 audio_preempt + 200ms 延迟抢占/低优先级拒绝+denied 消息)、releaseCableToken
- 发送策略改造:sendAudioControl 遍历组内所有节点 ws 全发(同名站同步 stop 等指令);sendAudioBufferToStation/sendLiveWavChunksToStation 只发送 master ws,发送前后 acquire/release token,durationSec 后 setTimeout 延迟释放,audio_start 消息新增 cableGroupId 字段
- enqueueStationAudioQueue 增加 cableGrp 兜底释放;loadData 兼容旧 stations 单值结构导入 nodes=[] 数组新结构 + audioCableGroups 双向回填;saveData 剥离 nodes 中 ws 对象后保存,新增 audioCableGroups 字段持久化
- WS 层改造:wss close 事件调用 removeStationWs;console_connect init_state 增加 audioCableGroups 数组;handleStationRegister/handleHeartbeat/handleScheduleSync 改为调用 upsertStationNode;station_register_ack 返回 nodeId/audioCableGroup
- broadcastUpdate state_update 增加 audioCableGroups 字段;新增 WS 下行 audio_preempt / cable_token_granted / cable_token_denied / station_group_state 四种消息类型
- getStationGroups 完全重写:按 stationName 精确分组,返回 members/nodes 数组(每个 node 带 nodeId/wsId/online/isMaster/connectedAt/lastSeen/hasStationPeripheral/hasBlockReader/hasCreateTarget/identityMode/tickRate/memberTags),增加 masterNodeId、audioCableGroup{id,name,color}、cableGroupId 字段;resolveStationGroup memberNames 返回 nodeId 列表
- REST API 新增 9 个路由:GET/POST/PUT/DELETE /api/audio-cable-groups,POST /api/audio-cable-groups/:id/stations(批量加站台,含 prev cableGroup 自动清理)、DELETE /api/audio-cable-groups/:id/stations/:stationName、PUT /api/stations/:name/cable-group(stations.audioCableGroup 与 group.stationNames 双向同步),stations DELETE 自动同步 cable group 清理
- 兼容性:旧 stations[name].ws 统一改为 getStationMaster(name).ws;TTS、HLS、temp-broadcast、音乐播放、version 管理等功能不受破坏;Lua 客户端版本号提升至 v1.0.99
This commit is contained in:
2026-08-17 15:15:23 +08:00
parent 55f0312314
commit 6ac05d3a84
2 changed files with 1027 additions and 333 deletions
+936 -256
View File
File diff suppressed because it is too large Load Diff
+91 -77
View File
@@ -1,6 +1,6 @@
-- 1. Configuration & Constants
local APP_VERSION_TEXT = "v1.0.98"
local APP_VERSION_TEXT = "v1.0.99"
local CONFIG = {
CLOUD_URL = "ws://stabadmin.fse-media.group",
@@ -2539,6 +2539,7 @@ end
function Network.sendScheduleSync()
if not State.ws then return end
if not Utils.isLocalStationMaster() then return end
local upcomingTrainsPayload = {}
if State.upcomingTrains and type(State.upcomingTrains) == "table" then
for _, t in ipairs(State.upcomingTrains) do
@@ -3555,19 +3556,21 @@ function Main.loop()
if isPendingAlertReady(alertKey, now) and not State.announcedAlerts[alertKey] then
State.announcedAlerts[alertKey] = now
Utils.log("INFO", string.format("Train Alert: %s (ETA: %ds, Delay: %dm, Cancelled: %s, Reason: %s)", trainName, etaSeconds, displayDelayMinutes, tostring(cancelled), tostring(reason or "")))
Network.send({
type = "train_alert",
stationName = State.stationName,
stationCode = State.stationCode,
stationPlatform = platform,
trainId = trainName,
destination = destination,
eta = tostring(etaSeconds) .. "s",
etaSeconds = etaSeconds,
delay = displayDelayMinutes,
cancelled = cancelled,
reason = reason
})
if Utils.isLocalStationMaster() then
Network.send({
type = "train_alert",
stationName = State.stationName,
stationCode = State.stationCode,
stationPlatform = platform,
trainId = trainName,
destination = destination,
eta = tostring(etaSeconds) .. "s",
etaSeconds = etaSeconds,
delay = displayDelayMinutes,
cancelled = cancelled,
reason = reason
})
end
end
end
@@ -3590,19 +3593,21 @@ function Main.loop()
if currentPending and (now - (currentPending.since or now)) >= stableMs and not State.announcedApproaching[announceKey] then
State.announcedApproaching[announceKey] = now
Utils.log("INFO", string.format("Train Approaching: %s (ETA: %.1fs, Delay: %dm)", trainName, etaSeconds, delayMinutes))
Network.send({
type = "train_approaching",
stationName = State.stationName,
stationCode = State.stationCode,
stationPlatform = platform,
trainId = trainName,
destination = destination,
eta = string.format("%.1fs", etaSeconds),
etaSeconds = etaSeconds,
delay = displayDelayMinutes,
cancelled = cancelled,
reason = reason
})
if Utils.isLocalStationMaster() then
Network.send({
type = "train_approaching",
stationName = State.stationName,
stationCode = State.stationCode,
stationPlatform = platform,
trainId = trainName,
destination = destination,
eta = string.format("%.1fs", etaSeconds),
etaSeconds = etaSeconds,
delay = displayDelayMinutes,
cancelled = cancelled,
reason = reason
})
end
end
end
end
@@ -3717,17 +3722,19 @@ function Main.loop()
if isPendingAlertReady(alertKey, now) and not State.announcedAlerts[alertKey] then
State.announcedAlerts[alertKey] = now
Utils.log("INFO", string.format("Train Delay Alert: %s (ETA: %ds, Delay: %dm)", tName, etaSeconds, delay))
Network.send({
type = "train_alert",
stationName = State.stationName,
stationCode = State.stationCode,
stationPlatform = platform or State.stationPlatform or "1",
trainId = tName,
destination = dest,
eta = etaStr,
etaSeconds = etaSeconds,
delay = delay
})
if Utils.isLocalStationMaster() then
Network.send({
type = "train_alert",
stationName = State.stationName,
stationCode = State.stationCode,
stationPlatform = platform or State.stationPlatform or "1",
trainId = tName,
destination = dest,
eta = etaStr,
etaSeconds = etaSeconds,
delay = delay
})
end
end
end
@@ -3750,17 +3757,19 @@ function Main.loop()
if currentPending and (now - (currentPending.since or now)) >= stableMs and not State.announcedApproaching[announceKey] then
State.announcedApproaching[announceKey] = now
Utils.log("INFO", string.format("Train Approaching: %s (ETA: %ds, Delay: %dm)", tName, etaSeconds, delay))
Network.send({
type = "train_approaching",
stationName = State.stationName,
stationCode = State.stationCode,
stationPlatform = platform or State.stationPlatform or "1",
trainId = tName,
destination = dest,
eta = etaStr,
etaSeconds = etaSeconds,
delay = delay
})
if Utils.isLocalStationMaster() then
Network.send({
type = "train_approaching",
stationName = State.stationName,
stationCode = State.stationCode,
stationPlatform = platform or State.stationPlatform or "1",
trainId = tName,
destination = dest,
eta = etaStr,
etaSeconds = etaSeconds,
delay = delay
})
end
end
end
end
@@ -3829,39 +3838,43 @@ function Main.loop()
departureSent = false
}
Network.send({
type = "train_arrival",
stationName = State.stationName,
stationCode = State.stationCode,
stationPlatform = State.stationPlatform,
trainId = id,
destination = dest,
destCode = destCode,
nextStation = nextStation,
nextCode = nextCode,
lineCode = lineCode,
departureTime = State.currentTrain.departureTime,
departureBeep = State.departureBeep
})
if Utils.isLocalStationMaster() then
Network.send({
type = "train_arrival",
stationName = State.stationName,
stationCode = State.stationCode,
stationPlatform = State.stationPlatform,
trainId = id,
destination = dest,
destCode = destCode,
nextStation = nextStation,
nextCode = nextCode,
lineCode = lineCode,
departureTime = State.currentTrain.departureTime,
departureBeep = State.departureBeep
})
end
else
local now = os.epoch("utc") / 1000
-- 增加 1.5 秒网络与预缓冲补偿,确保音频结束时列车刚好发车
local triggerOffset = (State.currentTrain.departureDuration or 10) + 1.5
if not State.currentTrain.departureSent and (State.currentTrain.departureTime - now) <= triggerOffset then
State.currentTrain.departureSent = true
Network.send({
type = "train_departure",
stationName = State.stationName,
stationCode = State.stationCode,
stationPlatform = State.stationPlatform,
trainId = State.currentTrain.id,
destination = State.currentTrain.dest,
destCode = State.currentTrain.destCode,
nextStation = State.currentTrain.nextStation,
nextCode = State.currentTrain.nextCode,
lineCode = State.currentTrain.lineCode,
departureBeep = State.departureBeep
})
if Utils.isLocalStationMaster() then
Network.send({
type = "train_departure",
stationName = State.stationName,
stationCode = State.stationCode,
stationPlatform = State.stationPlatform,
trainId = State.currentTrain.id,
destination = State.currentTrain.dest,
destCode = State.currentTrain.destCode,
nextStation = State.currentTrain.nextStation,
nextCode = State.currentTrain.nextCode,
lineCode = State.currentTrain.lineCode,
departureBeep = State.departureBeep
})
end
end
end
else
@@ -3945,6 +3958,7 @@ parallel.waitForAny(
Network.heartbeat,
Network.syncFiles,
Input.listener,
Main.loop
Main.loop,
LocalBus.loop
)