Files
CC-StationBroadcast/station_broadcast.lua
T
Henry_Du 1179130656 fix(cablemanager): 修复线缆分组详情编辑时成员被自动刷新打回且保存无效的问题
根因:
- cablemanager 每 900ms scheduleAutoReload() 通过 WS state_update 触发 reloadAll({silent:true}),之前 reloadAll 在 detail-view 场景下每次都会无条件把 detailTempMembers 从服务器读回、selectedAvailable/selectedMembers 清空,所以用户刚把某站台 » 加进成员,下一次被动刷新到了就立刻还原(视觉上就是「点过去又跳回来」)
- 保存 saveDetail() PUT 本身是能成功的(server PUT /api/audio-cable-groups/:id 的 stationNames 接口契约没问题),但保存之后 loadGroups() 拉完数据又立刻触发 broadcastUpdate()→WS state_update→scheduleAutoReload→又一次 reloadAll()→把刚保存的 detailTempMembers 用服务器值覆盖掉,用户看起来就是「保存无效」
修复 (cablemanager.html reloadAll):
1. 保存 refresh 前后的 prevGroups/prevStations 快照,只有 name/color/description 或站台列表真的变了(groupListChanged/ stationListChanged)才重绘列表和快选,避免心跳、lastSeen、tokenHolder 这些纯瞬时字段反复重绘
2. 区分 silent (被动自动刷) vs 非 silent (用户手动点刷新):
   - 非 silent:保持原语义,完全用服务器值覆盖所有本地编辑(用户主动点刷新就是要回到当前后台状态)
   - silent:
     * 只在 metaChanged 时同步 name/description/color(并且不抢焦点的 input,避免正在打字被重置)
     * 只在 membersChanged(后台 stationNames 真的跟上次不一样)时才同步成员到 detailTempMembers,否则保持用户未保存的成员编辑不变;同时清理 selected 中已不存在的名字
     * renderDualLists 仅在 membersChanged || stationListChanged || metaChanged 才调用,其余时间完全不重绘详情
3. 服务器 PUT 端再验证过 stationNames 接口契约没问题:前端 saveDetail 发 {name,color,description,stationNames} 对得上 server PUT handler,保存完 /api/audio-cable-groups 读回 stationNames 正确
APP_VERSION_TEXT bump 到 v1.0.111
2026-08-17 18:11:57 +08:00

4455 lines
166 KiB
Lua

-- 1. Configuration & Constants
local APP_VERSION_TEXT = "v1.0.111"
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/",
DISK_AUDIO_STATION = "disk/Audio/Station/",
STATION_IDENTITY_FILE = "station_identity.cfg",
AUDIO_VOLUME = 100,
AUDIO_STREAM_PREROLL_CHUNKS = 5,
ALERT_CONFIRM_MS = 15000,
ALERT_COOLDOWN_MS = 300000,
APPROACHING_COOLDOWN_MS = 120000,
APPROACHING_STABLE_MS = 5000,
DEBUG = false
}
local dfpwm = require "cc.audio.dfpwm"
local COLORS = {
INFO = colors.white,
WARN = colors.yellow,
WARNING = colors.yellow,
ERROR = colors.red,
DEBUG = colors.cyan,
SUCCESS = colors.green
}
-- 2. State Management
local State = {
ws = nil,
stationName = "Unknown",
stationCode = "UNK",
stationPlatform = "1",
stationIdentityMode = "auto",
hasStationPeripheral = false,
stationPeripheralSide = nil,
hasPrimaryMonitor = false,
registered = false,
stationList = {},
currentTrain = nil,
upcomingTrains = {},
logs = {},
logScrollOffset = 0,
audioRx = nil,
audioQueue = {},
currentPlaying = nil,
currentPriority = -1,
interruptSeq = 0,
tickRate = 20,
updateInProgress = false,
lastPeripheralScan = 0,
lastScheduleSync = 0,
lastIdentitySync = 0,
localCableGroup = ""
}
-- 3. Peripherals Setup
local Peripherals = {}
local Utils = {}
local Display = {}
local Audio = {}
local Network = {}
local Main = {}
function Peripherals.isTrainStationPeripheral(name)
if not name then
return false
end
local pType = peripheral.getType(name)
if pType == "train_station" then
return true
end
local wrapped = peripheral.wrap(name)
if not wrapped then
return false
end
return type(wrapped.getStationName) == "function"
and type(wrapped.isTrainPresent) == "function"
and type(wrapped.getTrainName) == "function"
and type(wrapped.getSchedule) == "function"
end
function Peripherals.init()
local station = nil
local stationSide = nil
local checkedNames = {}
local preferredSides = { "back", "top", "bottom", "left", "right", "front" }
for _, side in ipairs(preferredSides) do
checkedNames[side] = true
if Peripherals.isTrainStationPeripheral(side) then
station = peripheral.wrap(side)
stationSide = side
break
end
end
if not station and peripheral.getNames then
for _, name in ipairs(peripheral.getNames()) do
if not checkedNames[name] and Peripherals.isTrainStationPeripheral(name) then
station = peripheral.wrap(name)
stationSide = name
break
end
end
end
Peripherals.station = station
State.hasStationPeripheral = station ~= nil
State.stationPeripheralSide = stationSide
local monitors = { peripheral.find("monitor") }
if #monitors > 0 then
Peripherals.primaryMonitor = monitors[1]
Peripherals.primaryMonitor.setTextScale(0.5)
State.hasPrimaryMonitor = true
else
Peripherals.primaryMonitor = nil
State.hasPrimaryMonitor = false
end
Peripherals.speakers = { peripheral.find("speaker") }
Peripherals.createTarget = peripheral.find("create_target")
if Peripherals.createTarget then
pcall(Peripherals.createTarget.resize, 80, 10)
end
local blockReader = nil
local typeCandidates = { "blockReader", "block_reader", "arBlockReader", "advancedperipherals:block_reader" }
for _, t in ipairs(typeCandidates) do
local p = peripheral.find(t)
if p then
blockReader = p
break
end
end
if not blockReader and peripheral.getNames then
for _, name in ipairs(peripheral.getNames()) do
local p = peripheral.wrap(name)
if p and type(p.getBlockData) == "function" then
blockReader = p
break
end
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
-- 4. Utility Functions
function Utils.log(level, message)
if level == "DEBUG" and not CONFIG.DEBUG then return end
local timestamp = os.date("%H:%M:%S")
local entryText = string.format("[%s][%s] %s", timestamp, level, message)
local entry = { level = level, text = entryText }
table.insert(State.logs, 1, entry)
if #State.logs > 100 then table.remove(State.logs) end
if State.logScrollOffset > 0 then
State.logScrollOffset = math.min(State.logScrollOffset + 1, math.max(0, #State.logs - 1))
end
end
function Utils.trim(value)
return tostring(value or ""):match("^%s*(.-)%s*$")
end
function Utils.sanitizeDisplay(value)
local s = tostring(value or "")
if s == "" then return "" end
s = s:gsub("%?+", "")
s = s:gsub("%s+", " ")
s = Utils.trim(s)
if s == "" then return "-" end
return s
end
function Utils.loadStationIdentity()
if not fs.exists(CONFIG.STATION_IDENTITY_FILE) then
return {}
end
local handle = fs.open(CONFIG.STATION_IDENTITY_FILE, "r")
if not handle then
return {}
end
local content = handle.readAll() or ""
handle.close()
local data = {}
for line in content:gmatch("[^\r\n]+") do
local clean = Utils.trim(line)
if clean ~= "" and not clean:match("^#") then
local key, value = clean:match("^([%w_]+)%s*=%s*(.-)%s*$")
if key then
data[key:lower()] = Utils.trim(value)
end
end
end
return data
end
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
end
handle.write(table.concat({
"# Station identity cache / override",
"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()
return true
end
function Utils.hasUsableManualIdentity(data)
return data
and type(data) == "table"
and data.station_code and data.station_code ~= ""
and data.station_platform and data.station_platform ~= ""
end
function Utils.getAvailableDepartureBeeps()
local beeps = {"fc1"}
local ok, resp = pcall(http.get, CONFIG.API_URL .. "/api/files")
if ok and resp then
local raw = resp.readAll()
resp.close()
local found = {}
for path in raw:gmatch('"path"%s*:%s*"Audio/beep/([^"]+%.wav)"') do
local fc = path:match("^(fc.*)%.wav$")
if fc then
table.insert(found, fc)
end
end
if #found > 0 then
beeps = found
end
end
local unique = {}
local result = {}
for _, v in ipairs(beeps) do
if not unique[v] then
unique[v] = true
table.insert(result, v)
end
end
table.sort(result)
return result
end
function Utils.promptSlaveIdentity()
State.inConfig = true
while true do
local availBeeps = Utils.getAvailableDepartureBeeps()
local availLines = {}
local currentLine = ""
for i, beep in ipairs(availBeeps) do
if #currentLine + #beep + 2 > 48 then
table.insert(availLines, currentLine)
currentLine = beep .. ", "
else
currentLine = currentLine .. beep .. ", "
end
end
if #currentLine > 0 then
table.insert(availLines, currentLine:sub(1, -3))
end
term.setBackgroundColor(colors.black)
term.setTextColor(colors.white)
term.clear()
term.setCursorPos(1, 1)
term.setTextColor(colors.yellow)
term.write("Station Broadcast " .. APP_VERSION_TEXT)
term.setCursorPos(1, 3)
term.setTextColor(colors.white)
term.write("No train station peripheral found.")
term.setCursorPos(1, 4)
term.write("Configure this computer as a slave node.")
term.setCursorPos(1, 6)
term.write("Station code (e.g. cb-03): ")
term.setCursorPos(1, 8)
term.write("Platform number (e.g. 6): ")
term.setCursorPos(1, 10)
term.setTextColor(colors.gray)
term.write("Available beeps:")
local nextY = 11
for _, l in ipairs(availLines) do
term.setCursorPos(1, nextY)
term.write(l)
nextY = nextY + 1
end
term.setTextColor(colors.white)
term.setCursorPos(1, nextY)
term.write("Departure beep (e.g. fc1): ")
term.setCursorPos(30, 6)
term.setTextColor(colors.cyan)
local stationCode = Utils.trim(read() or ""):lower()
term.setCursorPos(31, 8)
local stationPlatform = Utils.trim(read() or "")
term.setCursorPos(32, nextY)
local departureBeep = Utils.trim(read() or "")
if departureBeep == "" then departureBeep = "fc1" end
if stationCode == "" or stationPlatform == "" then
term.setCursorPos(1, nextY + 2)
term.setTextColor(colors.red)
term.write("Both station code and platform are required.")
term.setCursorPos(1, nextY + 3)
term.setTextColor(colors.lightGray)
term.write("Press any key to retry...")
os.pullEvent("key")
else
local ok = Utils.saveStationIdentity(stationCode, stationPlatform, departureBeep)
if ok then
term.setCursorPos(1, nextY + 2)
term.setTextColor(colors.green)
term.write("Saved to " .. CONFIG.STATION_IDENTITY_FILE)
term.setCursorPos(1, nextY + 3)
term.setTextColor(colors.lightGray)
term.write("Press any key to continue...")
os.pullEvent("key")
term.setBackgroundColor(colors.black)
term.setTextColor(colors.white)
term.clear()
State.inConfig = false
return true
end
term.setCursorPos(1, nextY + 2)
term.setTextColor(colors.red)
term.write("Failed to save " .. CONFIG.STATION_IDENTITY_FILE)
term.setCursorPos(1, nextY + 3)
term.setTextColor(colors.lightGray)
term.write("Press any key to retry...")
os.pullEvent("key")
end
end
end
function Utils.promptConfig()
State.inConfig = true
local manual = Utils.loadStationIdentity()
local currentCode = manual.station_code or State.stationCode or ""
local currentPlatform = manual.station_platform or State.stationPlatform or "1"
local currentBeep = manual.departure_beep or State.departureBeep or "fc1"
while true do
local availBeeps = Utils.getAvailableDepartureBeeps()
local availLines = {}
local currentLine = ""
for i, beep in ipairs(availBeeps) do
if #currentLine + #beep + 2 > 48 then
table.insert(availLines, currentLine)
currentLine = beep .. ", "
else
currentLine = currentLine .. beep .. ", "
end
end
if #currentLine > 0 then
table.insert(availLines, currentLine:sub(1, -3))
end
term.setBackgroundColor(colors.black)
term.setTextColor(colors.white)
term.clear()
term.setCursorPos(1, 1)
term.setTextColor(colors.yellow)
term.write("Station Broadcast Configuration")
term.setCursorPos(1, 3)
term.setTextColor(colors.white)
if Peripherals.station then
term.write("Physical Station detected. Code/Platform are auto-synced.")
term.setCursorPos(1, 4)
term.write("You can override them here if needed.")
else
term.write("Slave node mode.")
end
term.setCursorPos(1, 6)
term.write("Station code [" .. currentCode .. "]: ")
term.setCursorPos(1, 8)
term.write("Platform number [" .. currentPlatform .. "]: ")
term.setCursorPos(1, 10)
term.setTextColor(colors.gray)
term.write("Available beeps:")
local nextY = 11
for _, l in ipairs(availLines) do
term.setCursorPos(1, nextY)
term.write(l)
nextY = nextY + 1
end
term.setTextColor(colors.white)
term.setCursorPos(1, nextY)
local promptBeepStr = "Departure beep [" .. currentBeep .. "]: "
term.write(promptBeepStr)
term.setCursorPos(30, 6)
term.setTextColor(colors.cyan)
local codeInput = Utils.trim(read() or ""):lower()
if codeInput == "" then codeInput = currentCode end
term.setCursorPos(31, 8)
local platformInput = Utils.trim(read() or "")
if platformInput == "" then platformInput = currentPlatform end
term.setCursorPos(#promptBeepStr + 1, nextY)
local beepInput = Utils.trim(read() or "")
if beepInput == "" then beepInput = currentBeep end
local ok = Utils.saveStationIdentity(codeInput, platformInput, beepInput)
if ok then
Utils.refreshStationIdentity()
term.setCursorPos(1, nextY + 2)
term.setTextColor(colors.green)
term.write("Configuration saved.")
term.setCursorPos(1, nextY + 3)
term.setTextColor(colors.lightGray)
term.write("Press any key to return...")
os.pullEvent("key")
term.setBackgroundColor(colors.black)
term.setTextColor(colors.white)
term.clear()
State.inConfig = false
break
else
term.setCursorPos(1, nextY + 2)
term.setTextColor(colors.red)
term.write("Failed to save configuration.")
term.setCursorPos(1, nextY + 3)
term.setTextColor(colors.lightGray)
term.write("Press any key to retry...")
os.pullEvent("key")
end
end
end
function Utils.ensureSlaveIdentityConfigured()
if Peripherals.station then return end
local manual = Utils.loadStationIdentity()
if Utils.hasUsableManualIdentity(manual) then return end
Utils.promptSlaveIdentity()
end
function Utils.resolveStationIdentity()
local manual = Utils.loadStationIdentity()
local physicalName = nil
if Peripherals.station and type(Peripherals.station.getStationName) == "function" then
physicalName = Utils.trim(Peripherals.station.getStationName() or "Unknown")
if physicalName == "" then physicalName = nil end
end
local stationCode = nil
local stationPlatform = nil
local stationName = physicalName
if physicalName then
stationCode = Utils.getStationCode(physicalName) or Utils.getBroadcastStationCode(physicalName)
stationPlatform = Utils.getPlatformNumber(physicalName, manual.station_platform or "1")
else
stationCode = manual.station_code
stationPlatform = manual.station_platform
end
if (not stationCode or stationCode == "") and manual.station_code and manual.station_code ~= "" then
stationCode = manual.station_code
end
if (not stationPlatform or stationPlatform == "") and manual.station_platform and manual.station_platform ~= "" then
stationPlatform = manual.station_platform
end
if (not stationCode or stationCode == "") and stationName and stationName ~= "" then
stationCode = Utils.getStationCode(stationName) or Utils.getBroadcastStationCode(stationName)
end
if not stationPlatform or stationPlatform == "" then
stationPlatform = Utils.getPlatformNumber(stationName or physicalName, "1")
end
if not stationName or stationName == "" then
if stationCode and stationCode ~= "" then
stationName = string.upper(stationCode) .. " Platform " .. tostring(stationPlatform or "1")
else
stationName = "Manual Station"
end
end
if not stationCode or stationCode == "" then
stationCode = Utils.getStationCode(stationName) or Utils.getBroadcastStationCode(stationName)
end
local hasManualIdentity = Utils.hasUsableManualIdentity(manual)
local identityMode = "auto"
if hasManualIdentity then
identityMode = "manual"
elseif not Peripherals.station then
identityMode = "manual-fallback"
end
local departureBeep = manual.departure_beep
if not departureBeep or departureBeep == "" then
departureBeep = "fc1"
end
return {
stationName = stationName,
stationCode = stationCode or "UNK",
stationPlatform = tostring(stationPlatform or "1"),
departureBeep = departureBeep,
mode = identityMode
}
end
function Utils.syncStationIdentityFromPeripheral()
if not Peripherals.station then
return false
end
local identity = Utils.resolveStationIdentity()
local stationCode = Utils.trim(identity.stationCode or "")
local stationPlatform = Utils.trim(identity.stationPlatform or "")
if stationCode == "" or stationCode == "UNK" or stationPlatform == "" then
return false
end
local manual = Utils.loadStationIdentity()
local oldCode = Utils.trim(manual.station_code or "")
local oldPlatform = Utils.trim(manual.station_platform or "")
if oldCode == stationCode and oldPlatform == stationPlatform then
return false
end
local ok = Utils.saveStationIdentity(stationCode, stationPlatform, identity.departureBeep)
if ok then
Utils.log("INFO", "Synced station_identity.cfg from physical station: " .. stationCode .. " / P" .. stationPlatform)
return true
end
Utils.log("WARN", "Failed to sync " .. CONFIG.STATION_IDENTITY_FILE .. " from physical station")
return false
end
function Utils.refreshStationIdentity()
local identity = Utils.resolveStationIdentity()
State.stationName = identity.stationName
State.stationCode = identity.stationCode
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)
if not name then return nil end
name = tostring(name)
local code = nil
local codeInParens = name:match("[%[%(]([%w%d%-%/%s]+)[%]%)]")
if codeInParens then
local firstPart = codeInParens:match("[^/]+")
if firstPart then
code = firstPart:match("^%s*(.-)%s*$")
end
end
if not code then
code = name:match("[%w%d]+%-[%w%d]+") or name:match("[%a]+[%d]+")
end
if not code then
local cleanName = name:match("^%s*(.-)%s*$"):lower()
if State.stationList and State.stationList[cleanName] then
code = cleanName
end
end
if code then
return tostring(code):lower()
end
return nil
end
function Utils.getPlatformNumber(name, default)
if not name then return default end
local p = name:match("[Pp]latform%s*([%d]+[a-zA-Z]?)") or name:match("[Tt]rack%s*([%d]+[a-zA-Z]?)")
if p then return p end
return name:match("([%d]+[a-zA-Z]?)$") or default
end
function Utils.getBroadcastStationCode(name)
if not name then return "UNK" end
name = name:match("^%s*(.-)%s*$")
name = name:gsub("%s+", "_")
if name == "" then return "UNK" end
return name
end
function Utils.isSameTrain(id1, id2)
if not id1 or not id2 then return false end
if id1 == id2 then return true end
local function getConsist(id)
local clean = id:gsub("^%[.-%]%s*", "")
local p = {}
for s in clean:gmatch("%S+") do table.insert(p, s) end
if #p >= 2 then
if #p[1] <= 4 and tonumber(p[2]) then
return p[1] .. " " .. p[2]
end
end
return p[1] or clean
end
local c1 = getConsist(id1)
local c2 = getConsist(id2)
if c1 == c2 then return true end
local _, name1, num1 = Utils.parseTrainId(id1)
local _, name2, num2 = Utils.parseTrainId(id2)
if num1 ~= "" and num2 ~= "" then
return name1 == name2 and num1 == num2
end
return name1 == name2
end
function Utils.parseTrainId(trainId)
if not trainId then return "unknown", "" end
local typeStr, content = trainId:match("^%[(.-)%](.+)$")
if typeStr then
if typeStr == "Ltd.Exp" or typeStr == "Express" then
local name, num = content:match("^(.+) No%.(%d+)$")
if name then
return "express", name, num
else
return "express", content, ""
end
elseif typeStr == "Local" then
return "regular", "Local", content
else
return "regular", typeStr, content
end
end
return "unknown", "Unknown", trainId
end
function Utils.getDestination(schedule)
if not schedule then return "Unknown" end
local entries = schedule.entries or (type(schedule) == "table" and schedule)
if not entries or type(entries) ~= "table" then return "Unknown" end
if #entries == 0 then
local count = 0
for _ in pairs(entries) do count = count + 1 end
if count > 0 then
local list = {}
for k, v in pairs(entries) do
local n = tonumber(k)
if n then list[n] = v end
end
entries = list
end
end
local dest = "Unknown"
local maxIdx = 0
for k, _ in pairs(entries) do
if type(k) == "number" and k > maxIdx then maxIdx = k end
end
for i = 1, maxIdx do
local entry = entries[i]
if entry then
local inst = entry.instruction
if inst then
if inst.id == "create:destination" then
dest = (inst.data and inst.data.text) or inst.text or dest
end
if inst.id == "create:station" then
local sName = (inst.data and inst.data.text) or inst.text
if sName then
dest = sName
end
end
end
end
end
return dest
end
function Utils.getBaseName(name)
if not name then return "" end
name = tostring(name):lower()
name = name:gsub("%s*[Pp]latform%s*[%d%a]+", ""):gsub("%s*[Tt]rack%s*[%d%a]+", "")
name = name:gsub("%s*[%[%(].-[%]%)]%s*", " ")
name = name:gsub("%s+%d+$", "")
return name:match("^%s*(.-)%s*$")
end
function Utils.getKeywords(text)
if not text then return {} end
local s = tostring(text):lower()
local keywords = {}
for code in s:gmatch("[%a%d]+%-[%a%d]+") do
keywords[code] = true
end
for code in s:gmatch("%a+%d+") do
keywords[code] = true
end
for zh in s:gmatch("[\128-\255]+") do
local cleanZh = zh:gsub("站台", ""):gsub("轨道", ""):gsub("号", ""):gsub("线", "")
if cleanZh and cleanZh ~= "" then
keywords[cleanZh] = true
end
end
local list = {}
for k, _ in pairs(keywords) do
table.insert(list, k)
end
return list
end
function Utils.isFuzzyMatch(str, pattern)
if not str or not pattern then return false end
str = tostring(str):lower():match("^%s*(.-)%s*$")
pattern = tostring(pattern):lower():match("^%s*(.-)%s*$")
if str == pattern then return true end
-- 1. Handle Create Mod Wildcards
if pattern:find("*") then
local p = pattern:gsub("([%^%$%(%)%%%.%[%]%+%-%?])", "%%%1")
p = p:gsub("%%%*", ".*")
local ok, res = pcall(function() return str:match("^" .. p .. "$") end)
if ok and res then return true end
end
-- 2. Base name comparison
local b1 = Utils.getBaseName(str)
local b2 = Utils.getBaseName(pattern)
if b1 ~= "" and b2 ~= "" then
if b1 == b2 then return true end
if #b1 >= 4 and #b2 >= 4 then
if b1:find(b2, 1, true) or b2:find(b1, 1, true) then
return true
end
end
end
-- 3. Alphanumeric only comparison
local a1 = str:gsub("[^%w%d]", "")
local a2 = pattern:gsub("[^%w%d]", "")
if a1 ~= "" and a2 ~= "" and (a1 == a2 or a1:find(a2, 1, true) or a2:find(a1, 1, true)) then
return true
end
-- 4. Extracted Keywords comparison (especially for Chinese + Code mixes)
local k1 = Utils.getKeywords(str)
local k2 = Utils.getKeywords(pattern)
for _, w1 in ipairs(k1) do
for _, w2 in ipairs(k2) do
if w1 == w2 or w1:find(w2, 1, true) or w2:find(w1, 1, true) then
return true
end
end
end
return false
end
function Utils.getStopTime(schedule, stationName)
if not schedule or not schedule.entries or not stationName then return nil end
local target = tostring(stationName):lower():match("^%s*(.-)%s*$")
if target == "" then return nil end
local targetCode = Utils.getStationCode(target)
local targetBase = Utils.getBaseName(target)
local targetAlpha = target:gsub("[^%w%d]", "")
Utils.log("DEBUG", string.format("STATION TARGET: '%s' (Code: %s, Base: %s, Alpha: %s)", target, tostring(targetCode), targetBase, targetAlpha))
local function extractTime(data)
if not data then return nil end
local val = tonumber(data.value) or tonumber(data.seconds) or tonumber(data.s)
or tonumber(data.time) or tonumber(data.duration) or tonumber(data.delay)
local unit = tonumber(data.time_unit) or tonumber(data.unit) or 1
if val then
if unit == 0 then return val / 20
elseif unit == 1 then return val
elseif unit == 2 then return val * 60
end
return val
end
local ticks = tonumber(data.ticks) or tonumber(data.tick)
if ticks then return ticks / 20 end
return nil
end
local function findTimeInConditions(conditions)
if not conditions then return nil end
for i, item in ipairs(conditions) do
if type(item) == "table" then
local cid = tostring(item.id or ""):lower()
if cid:find("delay") or cid:find("wait") or cid:find("idle") or cid:find("pause") then
local s = extractTime(item.data)
if s and s > 0 then
Utils.log("DEBUG", string.format("Found Time in Condition [%d]: %.1fs (ID: %s)", i, s, cid))
return s
end
end
for k, sub in pairs(item) do
if type(sub) == "table" then
local s = findTimeInConditions({sub})
if s then return s end
end
end
end
end
return nil
end
local maxIdx = 0
if schedule.entries then
for k, _ in pairs(schedule.entries) do
if type(k) == "number" and k > maxIdx then maxIdx = k end
end
end
Utils.log("DEBUG", string.format("--- Schedule Scan Start (MaxIdx: %d) ---", maxIdx))
local matchFoundIndex = nil
for i = 1, maxIdx do
local entry = schedule.entries[i]
if entry then
local isStationEntry = false
if entry.instruction and (entry.instruction.id == "create:station" or entry.instruction.id == "create:destination") then
local text = (entry.instruction.data and entry.instruction.data.text) or entry.instruction.text
if type(text) == "string" then
local entryText = text:lower()
local entryCode = Utils.getStationCode(entryText)
local entryAlpha = entryText:gsub("[^%w%d]", "")
local entryBase = Utils.getBaseName(entryText)
Utils.log("DEBUG", string.format(" [%d] SCAN STATION: '%s' (Code: %s)", i, entryText, tostring(entryCode)))
local isMatch = (target == entryText)
or (targetCode and entryCode and targetCode == entryCode)
or (targetAlpha ~= "" and entryAlpha ~= "" and (targetAlpha == entryAlpha or targetAlpha:find(entryAlpha, 1, true) or entryAlpha:find(targetAlpha, 1, true)))
or (targetBase ~= "" and entryBase ~= "" and (targetBase == entryBase or targetBase:find(entryBase, 1, true) or entryBase:find(targetBase, 1, true)))
or Utils.isFuzzyMatch(target, entryText)
if isMatch then
Utils.log("DEBUG", string.format(" >>> MATCH FOUND AT ENTRY %d", i))
isStationEntry = true
end
end
end
if isStationEntry then
matchFoundIndex = i
local s = findTimeInConditions(entry.conditions)
if s then
Utils.log("DEBUG", string.format(" >>> TIME RESOLVED FROM CONDITIONS: %.1fs", s))
return s
end
if entry.instruction and entry.instruction.data then
local s = extractTime(entry.instruction.data)
if s then
Utils.log("DEBUG", string.format(" >>> TIME RESOLVED FROM INSTRUCTION: %.1fs", s))
return s
end
end
break
end
end
end
if matchFoundIndex then
Utils.log("DEBUG", string.format(" >>> Station matched at [%d], scanning ahead for time...", matchFoundIndex))
for i = matchFoundIndex + 1, maxIdx do
local entry = schedule.entries[i]
if entry then
local isOtherStation = false
if entry.instruction and (entry.instruction.id == "create:station" or entry.instruction.id == "create:destination") then
isOtherStation = true
end
if isOtherStation then break end
local s = findTimeInConditions(entry.conditions)
if s then
Utils.log("DEBUG", string.format(" >>> TIME FOUND AT LATER ENTRY [%d]: %.1fs", i, s))
return s
end
if entry.instruction and entry.instruction.data then
local s = extractTime(entry.instruction.data)
if s then
Utils.log("DEBUG", string.format(" >>> TIME FOUND AT LATER INSTRUCTION [%d]: %.1fs", i, s))
return s
end
end
end
end
end
Utils.log("DEBUG", "--- Scan End: No Time Resolved ---")
return nil
end
function Utils.getNextStation(schedule, currentStationName)
if not schedule or not schedule.entries or not currentStationName then return nil end
local target = tostring(currentStationName):lower():match("^%s*(.-)%s*$")
if target == "" then return nil end
local targetCode = Utils.getStationCode(target)
local targetBase = Utils.getBaseName(target)
local targetAlpha = target:gsub("[^%w%d]", "")
local maxIdx = 0
for k, _ in pairs(schedule.entries) do
if type(k) == "number" and k > maxIdx then maxIdx = k end
end
local matchFoundIndex = nil
for i = 1, maxIdx do
local entry = schedule.entries[i]
if entry then
local isStationEntry = false
if entry.instruction and (entry.instruction.id == "create:station" or entry.instruction.id == "create:destination") then
local text = (entry.instruction.data and entry.instruction.data.text) or entry.instruction.text
if type(text) == "string" then
local entryText = text:lower()
local entryCode = Utils.getStationCode(entryText)
local entryAlpha = entryText:gsub("[^%w%d]", "")
local entryBase = Utils.getBaseName(entryText)
local isMatch = (target == entryText)
or (targetCode and entryCode and targetCode == entryCode)
or (targetAlpha ~= "" and entryAlpha ~= "" and (targetAlpha == entryAlpha or targetAlpha:find(entryAlpha, 1, true) or entryAlpha:find(targetAlpha, 1, true)))
or (targetBase ~= "" and entryBase ~= "" and (targetBase == entryBase or targetBase:find(entryBase, 1, true) or entryBase:find(targetBase, 1, true)))
or Utils.isFuzzyMatch(target, entryText)
if isMatch then isStationEntry = true end
end
end
if isStationEntry then
matchFoundIndex = i
break
end
end
end
if matchFoundIndex then
for i = matchFoundIndex + 1, maxIdx do
local entry = schedule.entries[i]
if entry and entry.instruction then
local inst = entry.instruction
if inst.id == "create:station" or inst.id == "create:destination" then
return (inst.data and inst.data.text) or inst.text
end
end
end
end
return nil
end
function Utils.extractAllStationsFromSchedule(schedule)
if not schedule then return {} end
local entries = schedule.entries or (type(schedule) == "table" and schedule)
if not entries or type(entries) ~= "table" then return {} end
local ordered = {}
local numericKeys = {}
for k, v in pairs(entries) do
if type(k) == "number" then
table.insert(numericKeys, k)
end
end
if #numericKeys > 0 then
table.sort(numericKeys)
for _, k in ipairs(numericKeys) do
table.insert(ordered, entries[k])
end
else
for _, v in pairs(entries) do
table.insert(ordered, v)
end
end
local stations = {}
for i, entry in ipairs(ordered) do
if entry and entry.instruction then
local inst = entry.instruction
if inst.id == "create:station" or inst.id == "create:destination" then
local text = (inst.data and inst.data.text) or inst.text
if type(text) == "string" and text ~= "" then
local lower = text:lower()
if not lower:find("nan") and text:find("%S") then
table.insert(stations, {
index = i,
name = text
})
end
end
end
end
end
Utils.log("DEBUG", string.format("ExtractStations: found %d stations from %d schedule entries", #stations, #ordered))
return stations
end
-- 5. Audio System
function Audio.createStream()
local chunks = {}
local finished = false
local stream = {}
stream.push = function(chunk)
if type(chunk) ~= "string" then return end
table.insert(chunks, chunk)
end
stream.finish = function()
finished = true
end
stream.read = function()
while #chunks == 0 do
if finished then return nil end
os.sleep(0.02)
end
return table.remove(chunks, 1)
end
stream.count = function()
return #chunks
end
stream.isFinished = function()
return finished
end
return stream
end
function Audio.loadStationList()
State.stationList = {}
if not fs.exists("list.txt") then
Utils.log("WARN", "list.txt not found")
return
end
local file = fs.open("list.txt", "r")
if file then
local count = 0
local line = file.readLine()
while line do
line = line:gsub("%s+$", "") -- Trim trailing \r\n and spaces
if line ~= "" then
local codes, files = line:match("^([^=]+)==(.+)$")
if codes and files then
local list = {}
for f in files:gmatch("[^,]+") do
local cleanName = f:match("^%s*(.-)%s*$")
:gsub("%.[Ww][Aa][Vv]$", "")
:gsub("%.[Mm][Pp]3$", "")
:gsub("%.[Oo][Gg][Gg]$", "")
:gsub("%.[Ff][Ll][Aa][Cc]$", "")
:gsub("%.[Mm]4[Aa]$", "")
:gsub("%.[Aa][Aa][Cc]$", "")
:gsub("%.[Oo][Pp][Uu][Ss]$", "")
:gsub("%.[Dd][Ff][Pp][Ww][Mm]$", "")
if cleanName ~= "" then
table.insert(list, cleanName)
end
end
-- Map all codes to this list
local allCodes = {}
for code in codes:gmatch("[^/]+") do
local cleanCode = code:match("^%s*(.-)%s*$"):lower()
if cleanCode ~= "" then
table.insert(allCodes, cleanCode)
State.stationList[cleanCode] = list
end
end
if #allCodes > 1 then
local fullRawCode = codes:match("^%s*(.-)%s*$"):lower()
State.stationList[fullRawCode] = list
local dashCode = table.concat(allCodes, "-"):lower()
State.stationList[dashCode] = list
end
count = count + 1
end
end
line = file.readLine()
end
file.close()
Utils.log("INFO", string.format("Station list loaded: %d entries", count))
end
end
function Audio.enqueueDfpwm(dfpwmData)
if type(dfpwmData) ~= "string" or #dfpwmData == 0 then return end
Audio.enqueueItem({ audioData = dfpwmData, audioFormat = "dfpwm", priority = 1 })
end
function Audio.enqueueItem(item)
if type(item) ~= "table" then return end
item.audioData = item.audioData or item.dfpwmData
item.audioFormat = tostring(item.audioFormat or (item.dfpwmData and "dfpwm") or "dfpwm"):lower()
if type(item.audioData) ~= "string" and type(item.audioData) ~= "table" then return end
item.priority = tonumber(item.priority) or 0
local idx = 1
while idx <= #State.audioQueue do
local p = tonumber(State.audioQueue[idx].priority) or 0
if item.priority > p then break end
idx = idx + 1
end
table.insert(State.audioQueue, idx, item)
end
function Audio.stop(mode, flushBelow)
State.interruptSeq = State.interruptSeq + 1
if State.audioRx and State.audioRx.stream then
pcall(State.audioRx.stream.finish)
end
State.audioRx = nil
if Peripherals.speakers then
for _, sp in ipairs(Peripherals.speakers) do
pcall(function()
if type(sp.speakStop) == "function" then sp.speakStop()
else sp.stop() end
end)
end
end
if mode == "all" then
State.audioQueue = {}
elseif flushBelow ~= nil then
local minP = tonumber(flushBelow) or 0
local kept = {}
for _, it in ipairs(State.audioQueue) do
local p = tonumber(it.priority) or 0
if p >= minP then table.insert(kept, it) end
end
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
local function shouldStop(playSeq)
return State.interruptSeq ~= playSeq
end
local function waitForInterruptibleDelay(playSeq, seconds)
local endTime = os.clock() + seconds
while os.clock() < endTime do
if shouldStop(playSeq) then
return false
end
local remaining = endTime - os.clock()
if remaining <= 0 then break end
local sleepTime = math.min(remaining, 0.02)
os.sleep(sleepTime)
end
return not shouldStop(playSeq)
end
local function waitForNativeSpeakersEmpty(speakers, playSeq)
local maxWait = os.clock() + 2.0
while os.clock() < maxWait do
if shouldStop(playSeq) then return false end
local allEmpty = true
for _, sp in ipairs(speakers or {}) do
local playing = false
local queued = 0
pcall(function()
if type(sp.speakIsPlaying) == "function" then
playing = sp.speakIsPlaying() and true or false
end
if type(sp.speakQueueSize) == "function" then
queued = tonumber(sp.speakQueueSize()) or 0
end
end)
if playing or queued > 0 then
allEmpty = false
break
end
end
if allEmpty then
return true
end
os.sleep(0.02)
end
Utils.log("WARN", "Timeout waiting for native speakers to empty (polling)")
return true
end
local function queueSamplesSynchronized(samples, volume, playSeq)
if not Peripherals.speakers or #Peripherals.speakers == 0 then return false end
if not waitForNativeSpeakersEmpty(Peripherals.speakers, playSeq) then
return false
end
local pending = {}
for _, sp in ipairs(Peripherals.speakers) do
local name = peripheral.getName(sp)
if name and type(sp.playAudio) == "function" then
pending[name] = sp
end
end
local maxAttempts = 400
while next(pending) do
if shouldStop(playSeq) then return false end
for name, sp in pairs(pending) do
local ok = false
pcall(function()
ok = sp.playAudio(samples, volume)
end)
if ok then
pending[name] = nil
end
end
if next(pending) then
maxAttempts = maxAttempts - 1
if maxAttempts <= 0 then
Utils.log("WARN", "Speaker buffer full or failed, skipping chunk (sync)")
break
end
os.sleep(0.05)
end
end
Utils.log("INFO", string.format("Audio samples queued in synchronized multi-speaker mode: samples=%d speakers=%d", #samples, #Peripherals.speakers))
return true
end
local function playChunkOnSpeakers(samples, volume, playSeq, waitForDrain)
if not Peripherals.speakers or #Peripherals.speakers == 0 then return false end
if waitForDrain == nil then waitForDrain = true end
local pending = {}
for _, sp in ipairs(Peripherals.speakers) do
local name = peripheral.getName(sp)
if name then pending[name] = sp end
end
local usedNativePath = false
local maxAttempts = 400
while next(pending) do
if shouldStop(playSeq) then return false end
for name, sp in pairs(pending) do
local ok = false
pcall(function()
if type(sp.playAudio) == "function" then
ok = sp.playAudio(samples, volume)
end
end)
if ok then
usedNativePath = true
pending[name] = nil
end
end
if next(pending) then
maxAttempts = maxAttempts - 1
if maxAttempts <= 0 then
Utils.log("WARN", "Speaker buffer full or failed, skipping chunk")
break
end
os.sleep(0.05)
end
end
if usedNativePath then
Utils.log("INFO", string.format("Audio samples queued via native speaker.playAudio: samples=%d", #samples))
else
local controller = Peripherals.speakers[1]
if controller and type(controller.playAudioAll) == "function" then
Utils.log("WARN", "Falling back to controller.playAudioAll for decoded samples")
local maxAttempts2 = 400
while true do
if shouldStop(playSeq) then return false end
local ok = false
pcall(function()
ok = controller.playAudioAll(samples, volume)
end)
if ok then break end
maxAttempts2 = maxAttempts2 - 1
if maxAttempts2 <= 0 then
Utils.log("WARN", "Controller buffer full or failed, skipping chunk")
break
end
os.sleep(0.05)
end
else
return false
end
end
if not waitForDrain then return true end
return waitForNativeSpeakersEmpty(Peripherals.speakers, playSeq)
end
local function decodePcmS8Chunk(chunk)
if type(chunk) ~= "string" or #chunk == 0 then return nil end
local startIdx = 1
if #chunk >= 44 and string.sub(chunk, 1, 4) == "RIFF" and string.sub(chunk, 9, 12) == "WAVE" then
local pos = 13
while pos + 7 <= #chunk do
local chunkId = string.sub(chunk, pos, pos + 3)
local a, b, c, d = string.byte(chunk, pos + 4, pos + 7)
local chunkSize = 0
if a and b and c and d then
chunkSize = a + b * 256 + c * 65536 + d * 16777216
end
if chunkId == "data" then
startIdx = pos + 8
break
end
pos = pos + 8 + chunkSize
end
if startIdx == 1 then
startIdx = 45 -- fallback
end
end
local samples = {}
local idx = 1
for i = startIdx, #chunk do
local b = string.byte(chunk, i)
if b >= 128 then
b = b - 256
end
samples[idx] = b
idx = idx + 1
end
return samples
end
local function decodePcmS16LEChunk(chunk)
if type(chunk) ~= "string" or #chunk < 2 then return nil end
local startIdx = 1
if #chunk >= 44 and string.sub(chunk, 1, 4) == "RIFF" and string.sub(chunk, 9, 12) == "WAVE" then
local pos = 13
while pos + 7 <= #chunk do
local chunkId = string.sub(chunk, pos, pos + 3)
local a, b, c, d = string.byte(chunk, pos + 4, pos + 7)
local chunkSize = 0
if a and b and c and d then
chunkSize = a + b * 256 + c * 65536 + d * 16777216
end
if chunkId == "data" then
startIdx = pos + 8
break
end
pos = pos + 8 + chunkSize
end
if startIdx == 1 then
startIdx = 45 -- fallback to 44 bytes header + 1
end
end
local samples = {}
local idx = 1
for i = startIdx, #chunk - 1, 2 do
local b = string.byte(chunk, i + 1)
if b >= 128 then
b = b - 256
end
samples[idx] = b
idx = idx + 1
end
return samples
end
local function readU16LE(data, offset)
local a, b = string.byte(data, offset, offset + 1)
if not a or not b then return nil end
return a + b * 256
end
local function readS16LE(data, offset)
local val = readU16LE(data, offset)
if not val then return nil end
if val >= 32768 then
val = val - 65536
end
return val
end
local function readU32LE(data, offset)
local a, b, c, d = string.byte(data, offset, offset + 3)
if not a or not b or not c or not d then return nil end
return a + b * 256 + c * 65536 + d * 16777216
end
local function clampSample8(val)
if val > 127 then return 127 end
if val < -128 then return -128 end
return val
end
local function decodeWavChunkToSamples(chunk)
if type(chunk) ~= "string" or #chunk < 44 then
return nil, "wav chunk too short"
end
if chunk:sub(1, 4) ~= "RIFF" or chunk:sub(9, 12) ~= "WAVE" then
return nil, "invalid wav header"
end
local offset = 13
local audioFormat, channels, sampleRate, bitsPerSample
local dataOffset, dataSize
while offset + 7 <= #chunk do
local chunkId = chunk:sub(offset, offset + 3)
local chunkSize = readU32LE(chunk, offset + 4)
if not chunkSize then break end
local payloadOffset = offset + 8
if chunkId == "fmt " and chunkSize >= 16 then
audioFormat = readU16LE(chunk, payloadOffset)
channels = readU16LE(chunk, payloadOffset + 2)
sampleRate = readU32LE(chunk, payloadOffset + 4)
bitsPerSample = readU16LE(chunk, payloadOffset + 14)
elseif chunkId == "data" then
dataOffset = payloadOffset
dataSize = chunkSize
break
end
offset = payloadOffset + chunkSize
if chunkSize % 2 == 1 then
offset = offset + 1
end
end
if audioFormat ~= 1 then
return nil, "unsupported wav format " .. tostring(audioFormat)
end
if channels ~= 1 and channels ~= 2 then
return nil, "unsupported channels " .. tostring(channels)
end
if bitsPerSample ~= 8 and bitsPerSample ~= 16 then
return nil, "unsupported bits " .. tostring(bitsPerSample)
end
if not dataOffset or not dataSize or dataOffset + dataSize - 1 > #chunk then
return nil, "invalid wav data chunk"
end
local frameSize = channels * (bitsPerSample / 8)
if frameSize <= 0 then
return nil, "invalid frame size"
end
local samples = {}
local sampleIndex = 1
local endOffset = dataOffset + dataSize - frameSize
for pos = dataOffset, endOffset, frameSize do
local mixed = 0
for ch = 0, channels - 1 do
local sampleOffset = pos + ch * (bitsPerSample / 8)
local sampleValue
if bitsPerSample == 8 then
local raw = string.byte(chunk, sampleOffset)
sampleValue = (raw or 128) - 128
else
sampleValue = readS16LE(chunk, sampleOffset) or 0
if sampleValue >= 0 then
sampleValue = math.floor(sampleValue / 256)
else
sampleValue = math.ceil(sampleValue / 256)
end
end
mixed = mixed + sampleValue
end
samples[sampleIndex] = clampSample8(math.floor(mixed / channels))
sampleIndex = sampleIndex + 1
end
return samples, nil, {
channels = channels,
bitsPerSample = bitsPerSample,
sampleRate = sampleRate or 0
}
end
local function areHqSpeakersBusy(speakers)
for _, sp in ipairs(speakers) do
local playing = false
local queued = 0
pcall(function()
if type(sp.speakIsPlaying) == "function" then
playing = sp.speakIsPlaying() and true or false
end
if type(sp.speakQueueSize) == "function" then
queued = tonumber(sp.speakQueueSize()) or 0
end
end)
if playing or queued > 0 then return true end
end
return false
end
local function downloadAudioBytes(url)
if type(url) ~= "string" or url == "" then return nil, nil, "empty url" end
local ok, resp = pcall(function()
return http.get(url, nil, true)
end)
if not ok or not resp then
return nil, nil, "http.get failed"
end
local body = resp.readAll()
resp.close()
if type(body) ~= "string" or #body == 0 then
return nil, nil, "empty response body"
end
local lower = url:lower()
local ext = lower:match("%.([%a%d]+)$")
return body, ext or "file", nil
end
local function playHqAudioItem(item)
if not Peripherals.speakers or #Peripherals.speakers == 0 then
Utils.log("WARN", "No speakers found; cannot play audio")
return false
end
local playSeq = State.interruptSeq
local payload = item.audioData
if type(payload) ~= "string" or #payload == 0 then return false end
local format = tostring(item.audioFormat or "file"):lower()
if format == "stream" then
local bytes, ext, err = downloadAudioBytes(payload)
if not bytes then
Utils.log("ERROR", "Failed to download stream audio: " .. tostring(err))
return false
end
Utils.log("INFO", "Downloaded stream audio: " .. tostring(#bytes) .. " bytes")
payload = bytes
if ext == "wav" then format = "wav"
elseif ext == "mp3" then format = "mp3"
elseif ext == "ogg" then format = "ogg"
else format = "file" end
item.audioData = payload
item.audioFormat = format
end
local controller = Peripherals.speakers[1]
local called = false
local loopEnabled = item.loop and true or false
if controller then
pcall(function()
if type(controller.setLoopingAll) == "function" then
controller.setLoopingAll(loopEnabled)
end
end)
local ok, result = pcall(function()
if format == "hls" and type(controller.speakHLSAll) == "function" then
return controller.speakHLSAll(payload, CONFIG.AUDIO_VOLUME)
elseif format == "wav" and type(controller.speakWavAll) == "function" then
return controller.speakWavAll(payload, CONFIG.AUDIO_VOLUME)
elseif format == "mp3" and type(controller.speakMp3All) == "function" then
return controller.speakMp3All(payload, CONFIG.AUDIO_VOLUME)
elseif format == "ogg" and type(controller.speakOggAll) == "function" then
return controller.speakOggAll(payload, CONFIG.AUDIO_VOLUME)
elseif type(controller.speakFileAll) == "function" then
return controller.speakFileAll(payload, CONFIG.AUDIO_VOLUME)
end
end)
if ok and result then
called = true
end
end
if not called then
for _, sp in ipairs(Peripherals.speakers) do
pcall(function()
if type(sp.setLooping) == "function" then
sp.setLooping(loopEnabled)
end
end)
local ok, result = pcall(function()
if format == "hls" and type(sp.speakHLS) == "function" then
return sp.speakHLS(payload, CONFIG.AUDIO_VOLUME)
elseif format == "wav" and type(sp.speakWav) == "function" then
return sp.speakWav(payload, CONFIG.AUDIO_VOLUME)
elseif format == "mp3" and type(sp.speakMp3) == "function" then
return sp.speakMp3(payload, CONFIG.AUDIO_VOLUME)
elseif format == "ogg" and type(sp.speakOgg) == "function" then
return sp.speakOgg(payload, CONFIG.AUDIO_VOLUME)
elseif type(sp.speakFile) == "function" then
return sp.speakFile(payload, CONFIG.AUDIO_VOLUME)
end
end)
if ok and result then
called = true
end
end
end
if not called then
Utils.log("ERROR", "HQ speaker did not accept audio for format " .. format)
return false
end
while areHqSpeakersBusy(Peripherals.speakers) do
if shouldStop(playSeq) then
Audio.stop("current")
return true
end
if not waitForInterruptibleDelay(playSeq, 0.1) then
Audio.stop("current")
return true
end
end
return true
end
local function pushWavChunkToSpeakers(payload, playSeq)
local controller = Peripherals.speakers and Peripherals.speakers[1] or nil
if controller and type(controller.speakWavAll) == "function" then
local retryCount = 0
while true do
if shouldStop(playSeq) then return false end
local ok = false
local queueSize = -1
local isPlaying = false
pcall(function()
ok = controller.speakWavAll(payload, CONFIG.AUDIO_VOLUME)
if type(controller.speakQueueSize) == "function" then
queueSize = tonumber(controller.speakQueueSize()) or -1
end
if type(controller.speakIsPlaying) == "function" then
isPlaying = controller.speakIsPlaying() and true or false
end
end)
if ok then
Utils.log("INFO", string.format("WAV chunk accepted: bytes=%d queue=%d playing=%s", #payload, queueSize, tostring(isPlaying)))
return true
end
retryCount = retryCount + 1
if retryCount == 1 or retryCount % 20 == 0 then
Utils.log("WARN", string.format("WAV chunk waiting: bytes=%d queue=%d playing=%s retries=%d", #payload, queueSize, tostring(isPlaying), retryCount))
end
if not waitForInterruptibleDelay(playSeq, 0.05) then
return false
end
end
end
local pending = {}
for _, sp in ipairs(Peripherals.speakers or {}) do
local name = peripheral.getName(sp)
if name then pending[name] = sp end
end
local retryCount = 0
while next(pending) do
if shouldStop(playSeq) then return false end
for name, sp in pairs(pending) do
local ok = false
pcall(function()
if type(sp.speakWav) == "function" then
ok = sp.speakWav(payload, CONFIG.AUDIO_VOLUME)
end
end)
if ok then pending[name] = nil end
end
if next(pending) then
retryCount = retryCount + 1
if retryCount == 1 or retryCount % 20 == 0 then
local pendingCount = 0
for _ in pairs(pending) do
pendingCount = pendingCount + 1
end
Utils.log("WARN", string.format("WAV chunk waiting on %d speakers: bytes=%d retries=%d", pendingCount, #payload, retryCount))
end
else
Utils.log("INFO", string.format("WAV chunk accepted by fallback speakers: bytes=%d", #payload))
end
if next(pending) and not waitForInterruptibleDelay(playSeq, 0.05) then
return false
end
end
return true
end
function Audio.playDfpwmItem(item)
if not Peripherals.speakers or #Peripherals.speakers == 0 then
Utils.log("WARN", "No speakers found; cannot play audio")
return
end
local playSeq = State.interruptSeq
local source = item.audioData
if type(source) == "table" and source.read then
source = source.read
end
if type(source) ~= "function" then
local data = source
source = function()
local chunk = data
data = nil
return chunk
end
end
local decoder = dfpwm.make_decoder()
while true do
if shouldStop(playSeq) then break end
local chunk = source()
if chunk == nil then break end
if type(chunk) == "string" and #chunk > 0 then
local samples = decoder(chunk)
if samples and not playChunkOnSpeakers(samples, CONFIG.AUDIO_VOLUME, playSeq, false) then
break
end
end
end
end
function Audio.playPcmItem(item)
if not Peripherals.speakers or #Peripherals.speakers == 0 then
Utils.log("WARN", "No speakers found; cannot play audio")
return
end
local playSeq = State.interruptSeq
if not waitForNativeSpeakersEmpty(Peripherals.speakers, playSeq) then
return
end
local source = item.audioData
local streamObj = nil
if type(source) == "table" and source.read then
streamObj = source
source = source.read
end
if type(source) ~= "function" then
local data = source
source = function()
local chunk = data
data = nil
return chunk
end
end
local prebufferChunks = math.max(0, tonumber(CONFIG.AUDIO_STREAM_PREROLL_CHUNKS) or 0)
if streamObj and prebufferChunks > 0 then
Utils.log("INFO", "Prebuffering live PCM8 stream: target chunks=" .. tostring(prebufferChunks))
while streamObj.count() < prebufferChunks and not streamObj.isFinished() do
if shouldStop(playSeq) then
Audio.stop("current")
return
end
if not waitForInterruptibleDelay(playSeq, 0.02) then
Audio.stop("current")
return
end
end
Utils.log("INFO", "Live PCM8 prebuffer ready: chunks=" .. tostring(streamObj.count()))
end
while true do
if shouldStop(playSeq) then
Audio.stop("current")
break
end
local chunk = source()
if chunk == nil then break end
if type(chunk) == "string" and #chunk > 0 then
local samples = decodePcmS8Chunk(chunk)
if samples and not playChunkOnSpeakers(samples, CONFIG.AUDIO_VOLUME, playSeq, false) then
Audio.stop("current")
break
end
end
end
end
function Audio.playPcmS16LEItem(item)
if not Peripherals.speakers or #Peripherals.speakers == 0 then
Utils.log("WARN", "No speakers found; cannot play audio")
return
end
local playSeq = State.interruptSeq
if not waitForNativeSpeakersEmpty(Peripherals.speakers, playSeq) then
return
end
local source = item.audioData
local streamObj = nil
if type(source) == "table" and source.read then
streamObj = source
source = source.read
end
if type(source) ~= "function" then
local data = source
source = function()
local chunk = data
data = nil
return chunk
end
end
local prebufferChunks = math.max(0, tonumber(CONFIG.AUDIO_STREAM_PREROLL_CHUNKS) or 0)
if streamObj and prebufferChunks > 0 then
Utils.log("INFO", "Prebuffering live PCM16 stream: target chunks=" .. tostring(prebufferChunks))
while streamObj.count() < prebufferChunks and not streamObj.isFinished() do
if shouldStop(playSeq) then
Audio.stop("current")
return
end
if not waitForInterruptibleDelay(playSeq, 0.02) then
Audio.stop("current")
return
end
end
Utils.log("INFO", "Live PCM16 prebuffer ready: chunks=" .. tostring(streamObj.count()))
end
while true do
if shouldStop(playSeq) then
Audio.stop("current")
break
end
local chunk = source()
if chunk == nil then break end
if type(chunk) == "string" and #chunk > 0 then
local samples = decodePcmS16LEChunk(chunk)
if samples and not playChunkOnSpeakers(samples, CONFIG.AUDIO_VOLUME, playSeq, false) then
Audio.stop("current")
break
end
end
end
end
function Audio.playWavStreamItem(item)
if not Peripherals.speakers or #Peripherals.speakers == 0 then
Utils.log("WARN", "No speakers found; cannot play audio")
return
end
local playSeq = State.interruptSeq
if not waitForNativeSpeakersEmpty(Peripherals.speakers, playSeq) then
return
end
local source = item.audioData
local streamObj = nil
if type(source) == "table" and source.read then
streamObj = source
source = source.read
end
if type(source) ~= "function" then
local data = source
source = function()
local chunk = data
data = nil
return chunk
end
end
local prebufferChunks = math.max(0, tonumber(CONFIG.AUDIO_STREAM_PREROLL_CHUNKS) or 0)
if streamObj and prebufferChunks > 0 then
Utils.log("INFO", "Prebuffering live WAV stream: target chunks=" .. tostring(prebufferChunks))
while streamObj.count() < prebufferChunks and not streamObj.isFinished() do
if shouldStop(playSeq) then
Audio.stop("current")
return
end
if not waitForInterruptibleDelay(playSeq, 0.02) then
Audio.stop("current")
return
end
end
Utils.log("INFO", "Live WAV prebuffer ready: chunks=" .. tostring(streamObj.count()))
end
while true do
if shouldStop(playSeq) then
Audio.stop("current")
return
end
local chunk = source()
if chunk == nil then break end
if type(chunk) == "string" and #chunk > 0 then
local samples, err, meta = decodeWavChunkToSamples(chunk)
if samples and #samples > 0 then
Utils.log("INFO", string.format(
"Decoded WAV chunk: bytes=%d samples=%d rate=%d ch=%d bits=%d",
#chunk,
#samples,
tonumber(meta and meta.sampleRate) or 0,
tonumber(meta and meta.channels) or 0,
tonumber(meta and meta.bitsPerSample) or 0
))
local playOk = playChunkOnSpeakers(samples, CONFIG.AUDIO_VOLUME, playSeq, false)
if not playOk then
Audio.stop("current")
return
end
else
Utils.log("WARN", "WAV chunk decode failed, falling back to HQ path: " .. tostring(err))
if not pushWavChunkToSpeakers(chunk, playSeq) then
Audio.stop("current")
return
end
end
end
end
if not waitForNativeSpeakersEmpty(Peripherals.speakers, playSeq) then
Audio.stop("current")
return
end
while areHqSpeakersBusy(Peripherals.speakers) do
if shouldStop(playSeq) then
Audio.stop("current")
return
end
if not waitForInterruptibleDelay(playSeq, 0.05) then
Audio.stop("current")
return
end
end
end
function Audio.playItem(item)
local format = tostring(item.audioFormat or "dfpwm"):lower()
if format == "wav" or format == "file" or format == "stream" or format == "hls" then
local success = playHqAudioItem(item)
if success == false then
format = tostring(item.audioFormat or "file"):lower()
Utils.log("WARN", "HQ speaker playback failed, falling back to default decoding for format: " .. format)
if format == "wav" or format == "file" or format == "stream" then
return Audio.playWavStreamItem(item)
elseif format == "dfpwm" then
return Audio.playDfpwmItem(item)
else
Utils.log("ERROR", "Cannot fallback for format: " .. format)
return false
end
end
return success
elseif format == "pcm" then
return Audio.playPcmItem(item)
elseif format == "wav-stream" or format == "wav-stream-live" then
return Audio.playWavStreamItem(item)
end
return Audio.playDfpwmItem(item)
end
function Audio.playerLoop()
while true do
if #State.audioQueue == 0 then
os.sleep(0.05)
else
local item = table.remove(State.audioQueue, 1)
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
end
end
end
function Utils.getChineseNumberAudio(val)
local num = tonumber(val)
if num then
local seq = {}
if num < 10 then
table.insert(seq, "ch-" .. num)
elseif num == 10 then
table.insert(seq, "ch-10")
elseif num > 10 and num < 20 then
table.insert(seq, "ch-10")
table.insert(seq, "ch-" .. (num % 10))
elseif num >= 20 and num < 100 then
local ten = math.floor(num / 10)
local unit = num % 10
table.insert(seq, "ch-" .. ten)
table.insert(seq, "ch-10")
if unit > 0 then
table.insert(seq, "ch-" .. unit)
end
else
table.insert(seq, "ch-" .. num)
end
return seq
else
-- Handle Alphanumeric
local digit, letter = tostring(val):match("^(%d+)([a-zA-Z])$")
if digit and letter then
local seq = Utils.getChineseNumberAudio(digit)
table.insert(seq, letter:upper())
return seq
end
return {tostring(val)}
end
end
function Utils.getStationAudio(code, langPrefix)
if not code then return nil end
local files = State.stationList[code]
if files then
local p = langPrefix:lower()
for _, f in ipairs(files) do
if f:lower():find("^" .. p) then
return f
end
end
end
return langPrefix .. code
end
local AutoUpdate = {}
function AutoUpdate.fetch(url)
if not http then
return nil, "HTTP API unavailable"
end
if http.checkURL then
local ok, err = http.checkURL(url)
if not ok then
return nil, err or "URL blocked"
end
end
local res, err = http.get(url)
if not res then
return nil, err or "request failed"
end
local body = res.readAll()
res.close()
if type(body) ~= "string" or #body == 0 then
return nil, "empty response"
end
return body
end
function AutoUpdate.writeFile(path, content)
local dir = fs.getDir(path)
if dir and dir ~= "" and not fs.exists(dir) then
fs.makeDir(dir)
end
local handle = fs.open(path, "w")
if not handle then
return nil, "cannot open " .. path
end
handle.write(content)
handle.close()
return true
end
function AutoUpdate.getStartupPath()
if shell and shell.resolve then
return shell.resolve(CONFIG.STARTUP_PATH)
end
return CONFIG.STARTUP_PATH
end
function AutoUpdate.getDownloadUrl(info)
if type(info) == "table" then
local direct = Utils.trim(info.downloadUrl)
if direct ~= "" then
return direct
end
local path = Utils.trim(info.downloadPath)
if path ~= "" then
return CONFIG.API_URL .. path
end
end
return CONFIG.API_URL .. "/downloads/station_broadcast.lua"
end
function AutoUpdate.fetchInfo()
local body, err = AutoUpdate.fetch(CONFIG.API_URL .. "/api/station-update")
if not body then
return nil, err
end
local ok, data = pcall(textutils.unserializeJSON, body)
if not ok or type(data) ~= "table" then
return nil, "invalid update response"
end
return data
end
function AutoUpdate.getTargetVersion(info)
if type(info) ~= "table" then
return ""
end
return Utils.trim(info.targetVersion or info.version)
end
function AutoUpdate.needsUpdate(info)
local targetVersion = AutoUpdate.getTargetVersion(info)
return targetVersion ~= "" and targetVersion ~= APP_VERSION_TEXT
end
function AutoUpdate.apply(info, reason)
if State.updateInProgress then
return false, "update already in progress"
end
local targetVersion = AutoUpdate.getTargetVersion(info)
if targetVersion == "" then
return false, "missing target version"
end
local downloadUrl = AutoUpdate.getDownloadUrl(info)
local startupPath = AutoUpdate.getStartupPath()
State.updateInProgress = true
Utils.log("WARN", "Applying update " .. targetVersion .. " via " .. tostring(reason or "manual"))
if Audio and Audio.stop then
pcall(Audio.stop, "all")
end
if State.ws then
pcall(State.ws.close)
State.ws = nil
end
local body, err = AutoUpdate.fetch(downloadUrl)
if not body then
State.updateInProgress = false
return false, err
end
local downloadedVersion = nil
do
local m = string.match(body, 'local%s+APP_VERSION_TEXT%s*=%s*["\']([^"\']+)["\']')
if m then downloadedVersion = Utils.trim(m) end
end
if downloadedVersion and downloadedVersion ~= targetVersion then
State.updateInProgress = false
Utils.log("ERROR", "Downloaded version mismatch: target=" .. tostring(targetVersion) .. " got=" .. tostring(downloadedVersion) .. " - aborting update to prevent reboot loop")
return false, "downloaded version mismatch (" .. tostring(downloadedVersion) .. " ~= " .. tostring(targetVersion) .. ")"
end
local ok, writeErr = AutoUpdate.writeFile(startupPath, body)
if not ok then
State.updateInProgress = false
return false, writeErr
end
Utils.log("SUCCESS", "Updated startup to " .. targetVersion)
Utils.log("WARN", "Rebooting to finish update")
os.sleep(1)
os.reboot()
return true
end
function AutoUpdate.checkOnBoot()
local info, err = AutoUpdate.fetchInfo()
if not info then
Utils.log("WARN", "Update check skipped: " .. tostring(err))
return false
end
if not AutoUpdate.needsUpdate(info) then
return false
end
Utils.log("INFO", "Found published update " .. AutoUpdate.getTargetVersion(info) .. " on boot")
local ok, applyErr = AutoUpdate.apply(info, "boot")
if not ok then
Utils.log("ERROR", "Boot update failed: " .. tostring(applyErr))
end
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
-- ============================================================================
function Network.connect()
if State.ws then return end
Utils.log("INFO", "Connecting to cloud...")
State.ws = http.websocket(CONFIG.CLOUD_URL)
if State.ws then
Utils.log("SUCCESS", "Connected")
Network.register()
else
Utils.log("ERROR", "Connection failed")
end
end
function Network.send(data)
if State.ws then
local ok, err = pcall(function()
State.ws.send(textutils.serializeJSON(data))
end)
if not ok then
Utils.log("ERROR", "Failed to send WS message: " .. tostring(err))
end
end
end
function Network.register()
Utils.refreshStationIdentity()
Network.send({
type = "station_register",
stationName = State.stationName,
stationCode = State.stationCode,
stationPlatform = State.stationPlatform,
stationVersion = APP_VERSION_TEXT,
cableGroup = tostring(State.localCableGroup or "")
})
end
function Network.syncFiles()
while true do
local resp = http.get(CONFIG.API_URL .. "/list.txt")
if not resp then
resp = http.get(CONFIG.API_URL .. "/uploads/list.txt")
end
if resp then
local content = resp.readAll()
resp.close()
if content and #content > 0 then
local currentList = ""
if fs.exists("list.txt") then
local f = fs.open("list.txt", "r")
currentList = f.readAll()
f.close()
end
if currentList ~= content then
local f = fs.open("list.txt", "w")
f.write(content)
f.close()
Audio.loadStationList()
end
end
end
os.sleep(CONFIG.SYNC_INTERVAL)
end
end
function Network.messageLoop()
local function finishAudioRx()
if not State.audioRx then return end
local rx = State.audioRx
if rx.stream then
rx.stream.finish()
else
Audio.enqueueItem({
audioData = table.concat(rx.chunks or {}),
audioFormat = rx.audioFormat or "wav",
priority = rx.priority,
loop = rx.loop and true or false,
source = rx.source,
audioId = rx.audioId
})
end
State.audioRx = nil
end
while true do
local event, url, msg = os.pullEvent()
if event == "websocket_message" and url == CONFIG.CLOUD_URL then
local ok, data = pcall(textutils.unserializeJSON, msg)
if ok and type(data) == "table" then
if data.type == "audio_start" then
if State.audioRx and State.audioRx.stream then
pcall(State.audioRx.stream.finish)
end
local priority = tonumber(data.priority) or 0
local audioFormat = tostring(data.audioFormat or "dfpwm"):lower()
local streamUrl = type(data.streamUrl) == "string" and data.streamUrl or nil
local interruptMode = tostring(data.interruptMode or "")
if interruptMode == "" then
if priority >= 2 then interruptMode = "all"
elseif priority >= 1 then interruptMode = "ifLower"
else interruptMode = "none" end
end
if interruptMode == "all" then
Audio.stop("all")
elseif interruptMode == "ifLower" and priority > (State.currentPriority or -1) then
Audio.stop("current", priority)
end
State.audioRx = {
audioId = data.audioId,
expectedBytes = tonumber(data.byteLength) or 0,
receivedBytes = 0,
priority = priority,
loop = data.loop and true or false,
source = data.source or data.category,
audioFormat = audioFormat
}
if (audioFormat == "stream" or audioFormat == "hls") and streamUrl and streamUrl ~= "" then
Audio.enqueueItem({
audioData = streamUrl,
audioFormat = audioFormat,
priority = priority,
loop = State.audioRx.loop,
source = State.audioRx.source,
audioId = data.audioId
})
State.audioRx = nil
elseif audioFormat == "dfpwm" or audioFormat == "pcm" or audioFormat == "wav-stream" or audioFormat == "wav-stream-live" then
local stream = Audio.createStream()
State.audioRx.stream = stream
if audioFormat == "pcm" then
Utils.log("INFO", "Receiving live PCM audio stream")
elseif audioFormat == "wav-stream" or audioFormat == "wav-stream-live" then
Utils.log("INFO", "Receiving live WAV chunk stream")
end
Audio.enqueueItem({
audioData = stream,
audioFormat = audioFormat,
priority = priority,
source = State.audioRx.source,
audioId = data.audioId
})
else
State.audioRx.chunks = {}
end
elseif data.type == "audio_end" then
if State.audioRx and (not data.audioId or data.audioId == State.audioRx.audioId) then
finishAudioRx()
end
elseif data.type == "audio_stop" then
local scope = tostring(data.scope or "")
if scope == "music" then
local kept = {}
for _, it in ipairs(State.audioQueue) do
if tostring(it.source or "") ~= "music" then
table.insert(kept, it)
end
end
State.audioQueue = kept
local hasMusicRx = State.audioRx and tostring(State.audioRx.source or "") == "music"
local hasMusicCurrent = State.currentPlaying and tostring(State.currentPlaying.source or "") == "music"
if hasMusicRx or hasMusicCurrent then
Audio.stop("current")
end
else
local mode = tostring(data.mode or "current")
local flushBelow = data.flushBelow
Audio.stop(mode, flushBelow)
end
elseif data.type == "train_arrival_ack" then
if State.currentTrain and State.currentTrain.id == data.trainId then
State.currentTrain.departureDuration = tonumber(data.departureDuration) or 10
Utils.log("INFO", "Got departure audio duration: " .. State.currentTrain.departureDuration .. "s")
end
elseif data.type == "station_update" then
local targetVersion = AutoUpdate.getTargetVersion(data)
if targetVersion == "" then
Utils.log("WARN", "Ignored station update without version")
elseif targetVersion == APP_VERSION_TEXT then
Utils.log("INFO", "Received update notice for current version " .. targetVersion)
else
Utils.log("WARN", "Received published update " .. targetVersion)
local okUpdate, updateErr = AutoUpdate.apply({
targetVersion = targetVersion,
version = targetVersion,
releaseId = data.releaseId,
downloadPath = data.downloadPath,
downloadUrl = data.downloadUrl
}, "publish")
if not okUpdate then
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
-- Memory protection: limit to 10MB per audio item
local isLiveStream = State.audioRx.stream and (tonumber(State.audioRx.expectedBytes) or 0) <= 0
if (not isLiveStream) and State.audioRx.receivedBytes + #msg > 10 * 1024 * 1024 then
Utils.log("ERROR", "Audio item too large, discarding")
if State.audioRx.stream then
State.audioRx.stream.finish()
end
State.audioRx = nil
else
if State.audioRx.stream then
State.audioRx.stream.push(msg)
else
table.insert(State.audioRx.chunks, msg)
end
State.audioRx.receivedBytes = State.audioRx.receivedBytes + #msg
if State.audioRx.expectedBytes > 0 and State.audioRx.receivedBytes >= State.audioRx.expectedBytes then
finishAudioRx()
end
end
end
end
elseif event == "websocket_closed" then
Utils.log("WARN", "WS Closed")
State.ws = nil
State.audioRx = nil
os.sleep(5)
end
end
end
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,
hasStationPeripheral = State.hasStationPeripheral,
stationPeripheralSide = State.stationPeripheralSide,
hasBlockReader = hasBlockReader,
hasCreateTarget = hasCreateTarget,
identityMode = State.stationIdentityMode,
upcomingTrains = upcomingSummary,
currentTrain = currentTrainInfo,
upcomingCount = #(State.upcomingTrains or {}),
cableGroup = tostring(State.localCableGroup or ""),
tickRate = State.tickRate
})
end
os.sleep(10)
end
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
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
function Display.render()
local mon = Peripherals.primaryMonitor
if not mon then return end
local w, h = mon.getSize()
mon.clear()
local hasSchedulePanel = State.currentTrain ~= nil
local leftW = w
local sepX = nil
local rightX = nil
local rightW = 0
if hasSchedulePanel and w >= 30 then
rightW = math.min(math.max(math.floor(w * 0.38), 18), 40)
leftW = w - rightW - 1
if leftW < 12 then
leftW = w
rightW = 0
else
sepX = leftW + 1
rightX = sepX + 1
end
end
Utils.log("DEBUG", string.format("Monitor size W=%d H=%d, schedulePanel=%s leftW=%d rightW=%d", w, h, tostring(rightX ~= nil), leftW, rightW))
local function classifyAudioSource(source, priority)
local s = tostring(source or ""):lower()
if s == "train" or s == "station" or s == "broadcast" then return "Station Announcement" end
if s ~= "" then return "Special Announcement(" .. tostring(source) .. ")" end
if (tonumber(priority) or 0) >= 2 then return "Special Announcement" end
return "Special Announcement"
end
local function truncL(str)
str = Utils.sanitizeDisplay(str)
if #str > leftW then return str:sub(1, leftW) end
return str
end
local function truncR(str)
str = Utils.sanitizeDisplay(str)
if #str > rightW then return str:sub(1, rightW) end
return str
end
local function drawLeft(y, text, color)
if y < 1 or y > h then return end
mon.setCursorPos(1, y)
mon.setTextColor(color or COLORS.INFO)
mon.write(truncL(text))
end
local function drawRight(y, text, color)
if not rightX or y < 1 or y > h then return end
mon.setCursorPos(rightX, y)
mon.setTextColor(color or COLORS.INFO)
mon.write(truncR(text))
end
if sepX then
for y = 1, h do
mon.setCursorPos(sepX, y)
mon.setTextColor(colors.gray)
mon.write("|")
end
end
drawLeft(1, "STATION: " .. State.stationName .. " " .. APP_VERSION_TEXT, COLORS.INFO)
drawLeft(2, "CODE: " .. tostring(State.stationCode) .. " P:" .. tostring(State.stationPlatform) .. " " .. tostring(State.stationIdentityMode), COLORS.INFO)
drawLeft(3, "TIME: " .. os.date("%H:%M:%S"), COLORS.INFO)
if State.currentPlaying then
local p = tonumber(State.currentPlaying.priority) or 0
local kind = classifyAudioSource(State.currentPlaying.source, p)
drawLeft(4, "AUDIO: " .. kind .. " Playing (P=" .. tostring(p) .. ", Q=" .. tostring(#State.audioQueue) .. ")", COLORS.SUCCESS)
elseif State.audioRx then
local p = tonumber(State.audioRx.priority) or 0
local kind = classifyAudioSource(State.audioRx.source, p)
local rx = tonumber(State.audioRx.receivedBytes) or 0
local total = tonumber(State.audioRx.expectedBytes) or 0
local pct = ""
if total > 0 then pct = " " .. tostring(math.floor((rx / total) * 100)) .. "%" end
drawLeft(4, "AUDIO: " .. kind .. " Receiving" .. pct .. " (Q=" .. tostring(#State.audioQueue) .. ")", COLORS.DEBUG)
else
drawLeft(4, "AUDIO: FREE (Q=" .. tostring(#State.audioQueue) .. ")", COLORS.INFO)
end
local contentBottom = 4
if State.currentTrain then
drawLeft(5, "CURRENT TRAIN:", COLORS.SUCCESS)
drawLeft(6, State.currentTrain.id, COLORS.INFO)
drawLeft(7, "Dest: " .. State.currentTrain.dest, COLORS.INFO)
if State.currentTrain.departureTime then
local remaining = math.max(0, State.currentTrain.departureTime - os.epoch("utc") / 1000)
if remaining > 0 then
drawLeft(9, string.format("Departing in: %02d:%02d", math.floor(remaining/60), math.floor(remaining%60)), COLORS.WARNING)
else
drawLeft(9, "Departing...", COLORS.ERROR)
end
contentBottom = 9
else
contentBottom = 7
end
if rightX then
drawRight(1, "SCHEDULE:", COLORS.SUCCESS)
drawRight(2, "Train: " .. State.currentTrain.id, COLORS.INFO)
local scheduleStations = Utils.extractAllStationsFromSchedule(State.currentTrain.schedule)
local stationLineY = 4
if State.currentTrain.schedule == nil then
drawRight(stationLineY, "Schedule: nil", COLORS.ERROR)
stationLineY = stationLineY + 1
elseif type(State.currentTrain.schedule) ~= "table" then
drawRight(stationLineY, "Schedule: invalid type", COLORS.ERROR)
stationLineY = stationLineY + 1
end
local totalStations = #scheduleStations
if totalStations == 0 then
drawRight(stationLineY, "(No station entries)", COLORS.WARNING)
else
local destName = Utils.getDestination(State.currentTrain.schedule)
local destBase = Utils.getBaseName(destName)
local destCode = Utils.getStationCode(destName)
local currentBase = Utils.getBaseName(State.stationName)
local currentCode = Utils.getStationCode(State.stationName)
local currentPlatform = tostring(State.stationPlatform or "")
local function matchPlatform(aName, bPlatform)
if not bPlatform or bPlatform == "" then return true end
local pA = Utils.getPlatformNumber(aName, "")
return pA == "" or tostring(pA) == tostring(bPlatform)
end
local currentIdx = nil
for idx, st in ipairs(scheduleStations) do
local stBase = Utils.getBaseName(st.name)
local stCode = Utils.getStationCode(st.name)
local isBaseMatch = (currentBase and currentBase ~= "" and stBase == currentBase)
or (currentCode and currentCode ~= "" and stCode == currentCode)
or Utils.isFuzzyMatch(State.stationName, st.name)
if isBaseMatch and matchPlatform(st.name, currentPlatform) then
currentIdx = idx
break
end
end
if currentIdx == nil then
for idx, st in ipairs(scheduleStations) do
local stBase = Utils.getBaseName(st.name)
local stCode = Utils.getStationCode(st.name)
if (currentBase and currentBase ~= "" and stBase == currentBase)
or (currentCode and currentCode ~= "" and stCode == currentCode)
or Utils.isFuzzyMatch(State.stationName, st.name) then
currentIdx = idx
break
end
end
end
local nextIdx = nil
if currentIdx and currentIdx < totalStations then
nextIdx = currentIdx + 1
end
local endIdx = nil
for idx = totalStations, 1, -1 do
local st = scheduleStations[idx]
local stBase = Utils.getBaseName(st.name)
local stCode = Utils.getStationCode(st.name)
if (destBase and destBase ~= "" and stBase == destBase)
or (destCode and destCode ~= "" and stCode == destCode)
or Utils.isFuzzyMatch(destName, st.name) then
endIdx = idx
break
end
end
if endIdx == nil then
endIdx = totalStations
end
for idx, st in ipairs(scheduleStations) do
if stationLineY > h then break end
local isCurrent = (idx == currentIdx)
local isNext = (idx == nextIdx)
local isEnd = (idx == endIdx)
local reachedCurrent = currentIdx ~= nil and idx < currentIdx
local prefix = " "
local lineColor = COLORS.INFO
if isCurrent then
prefix = "> "
lineColor = COLORS.SUCCESS
elseif isNext then
prefix = "~ "
lineColor = colors.orange
elseif isEnd then
prefix = " "
lineColor = COLORS.WARNING
elseif reachedCurrent then
prefix = " "
lineColor = colors.lightGray
else
prefix = "* "
lineColor = COLORS.INFO
end
local tag = ""
if isCurrent then
tag = " <<"
end
if isNext then
tag = tag .. " [NEXT]"
end
if isEnd then
tag = tag .. " [END]"
end
drawRight(stationLineY, prefix .. st.name .. tag, lineColor)
stationLineY = stationLineY + 1
end
end
end
else
if Peripherals.station then
drawLeft(5, "NO TRAIN AT PLATFORM", COLORS.WARNING)
else
drawLeft(5, "SLAVE NODE MODE", COLORS.WARNING)
end
contentBottom = 5
end
local logsStartY = h
local upcomingStartY = contentBottom + 1
if Peripherals.blockReader or Peripherals.createTarget then
local hasUpcoming = State.upcomingTrains and #State.upcomingTrains > 0
drawLeft(upcomingStartY, "UPCOMING TRAINS:", COLORS.DEBUG)
if hasUpcoming then
local visibleCount = math.min(3, #State.upcomingTrains)
for i = 1, visibleCount do
local lineY = upcomingStartY + i
if lineY > h then break end
local t = State.upcomingTrains[i]
local suffix = ""
if t.cancelled then
suffix = " [CANCEL]"
elseif (tonumber(t.delay) or 0) > 0 then
local reason = t.reason and (" " .. tostring(t.reason)) or ""
suffix = " [+" .. tostring(t.delay) .. "m" .. reason .. "]"
elseif t.reason then
suffix = " [" .. tostring(t.reason) .. "]"
end
local pfx = ""
if t.platform and tostring(t.platform) ~= "" then
pfx = "P" .. tostring(t.platform) .. " "
end
local etaLabel = tostring(t.etaLabel or (tostring(t.eta or 0) .. "s"))
local lineText = string.format("%s%s->%s ETA:%s%s", pfx, t.trainName, t.dest, etaLabel, suffix)
drawLeft(lineY, lineText, COLORS.INFO)
end
logsStartY = math.max(logsStartY, upcomingStartY + visibleCount + 1)
else
local msgY = upcomingStartY + 1
if msgY <= h then
drawLeft(msgY, "NONE / WAITING DATA", COLORS.WARNING)
logsStartY = math.max(logsStartY, msgY + 1)
end
end
end
local maxLogLines = math.min(3, math.max(0, h - logsStartY + 1))
for i = 1, maxLogLines do
local logIdx = i + State.logScrollOffset
local entry = State.logs[logIdx]
if entry then
local lineY = logsStartY + i - 1
drawLeft(lineY, Utils.sanitizeDisplay(entry.text), COLORS[entry.level] or colors.white)
end
end
end
function Display.renderTerminal()
if State.inConfig then return end
local w, h = term.getSize()
-- Header
term.setCursorPos(1, 1)
term.setBackgroundColor(colors.gray)
term.setTextColor(colors.white)
term.clearLine()
local title = " Station Broadcast (" .. State.stationName .. " " .. tostring(State.stationCode) .. "/P" .. tostring(State.stationPlatform) .. ") [C: Config] "
term.setCursorPos(math.floor((w - #title)/2) + 1, 1)
term.write(title)
term.setBackgroundColor(colors.black)
term.setCursorPos(1, 2)
term.setTextColor(colors.gray)
term.write(string.rep("-", w))
-- Logs
local logStart = 3
local maxLogs = h - logStart + 1
for i = 1, maxLogs do
local logIdx = i + State.logScrollOffset
term.setCursorPos(1, logStart + i - 1)
term.clearLine()
local entry = State.logs[logIdx]
if entry then
term.setTextColor(COLORS[entry.level] or colors.white)
term.write(Utils.sanitizeDisplay(entry.text):sub(1, w - 1))
end
end
-- Scroll Indicator
if State.logScrollOffset > 0 then
term.setCursorPos(w, logStart)
term.setTextColor(colors.yellow)
term.write("^")
end
if #State.logs > State.logScrollOffset + maxLogs then
term.setCursorPos(w, h)
term.setTextColor(colors.yellow)
term.write("v")
end
end
-- 7.5 Input Handling
local Input = {}
function Input.listener()
Utils.log("DEBUG", "Input listener started")
local monitorName = Peripherals.primaryMonitor and peripheral.getName(Peripherals.primaryMonitor) or nil
while true do
local event, param1, param2, param3 = os.pullEvent()
local _, h = term.getSize()
local maxLogs = h - 5 + 1
if event == "mouse_scroll" then
local direction = param1
if direction == -1 then -- Scroll Up
State.logScrollOffset = math.min(State.logScrollOffset + 1, math.max(0, #State.logs - maxLogs))
elseif direction == 1 then -- Scroll Down
State.logScrollOffset = math.max(0, State.logScrollOffset - 1)
end
Display.renderTerminal()
Display.render()
elseif event == "monitor_scroll" and param1 == monitorName then
local direction = param2
if direction == -1 then
State.logScrollOffset = math.min(State.logScrollOffset + 1, math.max(0, #State.logs - 3))
elseif direction == 1 then
State.logScrollOffset = math.max(0, State.logScrollOffset - 1)
end
Display.renderTerminal()
Display.render()
elseif event == "monitor_touch" and param1 == monitorName then
local _, monH = Peripherals.primaryMonitor.getSize()
local y = param3
if y >= monH - 3 then
if y == monH - 3 then
State.logScrollOffset = math.min(State.logScrollOffset + 1, math.max(0, #State.logs - 3))
elseif y == monH then
State.logScrollOffset = math.max(0, State.logScrollOffset - 1)
end
Display.renderTerminal()
Display.render()
end
elseif event == "mouse_click" then
local button, x, y = param1, param2, param3
if y == 1 then
Utils.promptConfig()
Display.renderTerminal()
Display.render()
end
elseif event == "key" then
if param1 == keys.up then
State.logScrollOffset = math.min(State.logScrollOffset + 1, math.max(0, #State.logs - maxLogs))
elseif param1 == keys.down then
State.logScrollOffset = math.max(0, State.logScrollOffset - 1)
elseif param1 == keys.pageUp then
State.logScrollOffset = math.min(State.logScrollOffset + 10, math.max(0, #State.logs - maxLogs))
elseif param1 == keys.pageDown then
State.logScrollOffset = math.max(0, State.logScrollOffset - 10)
elseif param1 == keys.home then
State.logScrollOffset = 0
elseif param1 == keys["end"] then
State.logScrollOffset = math.max(0, #State.logs - maxLogs)
elseif param1 == keys.c then
Utils.promptConfig()
end
Display.renderTerminal()
Display.render()
end
end
end
-- 8. Main Logic
State.announcedApproaching = {}
State.announcedAlerts = {}
State.pendingAlerts = {}
State.pendingApproaching = {}
local function cleanupTimestampMap(map, now, maxAgeMs)
for k, v in pairs(map or {}) do
if now - v > maxAgeMs then
map[k] = nil
end
end
end
local function isPendingAlertReady(key, now)
local firstSeen = State.pendingAlerts[key]
if not firstSeen then
State.pendingAlerts[key] = now
return false
end
return (now - firstSeen) >= (tonumber(CONFIG.ALERT_CONFIRM_MS) or 60000)
end
local function normalizeUpcomingKey(trainName, destination)
local function clean(value)
value = tostring(value or "")
value = value:gsub("%s+", " ")
value = value:match("^%s*(.-)%s*$") or ""
return value
end
return clean(trainName) .. "_" .. clean(destination)
end
local function pickEarlierTick(a, b)
local ta = tonumber(a)
local tb = tonumber(b)
if ta and tb then
return math.min(ta, tb)
end
return ta or tb or 0
end
local function buildEtaLabel(etaSeconds, textEta)
if type(textEta) == "table" then
local value = tonumber(textEta.value)
local unit = tostring(textEta.unit or "")
if value and unit == "??" and value <= 10 then
return "~" .. tostring(value) .. "m"
end
end
local secs = math.max(0, tonumber(etaSeconds) or 0)
local whole = math.floor(secs + 0.0001)
if whole >= 60 then
return string.format("%02d:%02d", math.floor(whole / 60), whole % 60)
end
if secs >= 10 then
return string.format("%02ds", whole)
end
return string.format("%.1fs", secs)
end
local function mapTrainStatusReason(statusId)
local key = tostring(statusId or "")
if key == "" then
return nil
end
local tail = string.match(key, ":(.+)$") or key
local known = {
delay_from_previous_journey = "prev",
previous_train_delayed = "train",
priority_other_train = "prio",
red_signal = "signal",
track_closed = "track",
staff_shortage = "staff",
operational_disruption = "ops",
special_journey = "special",
out_of_service = "oos",
default_delay = "default_delay",
cancelled = "cancelled"
}
return known[tail] or tail
end
local function isStationDestination(destName)
if not destName then return false end
local blacklist = {
"速度", "speed",
"名称", "rename", "重命名",
"时刻表", "schedule",
"区段", "section",
"卸", "unload",
"装", "load",
"等待", "wait",
"条件", "condition",
"动力", "powered",
"物品", "item",
"流体", "fluid",
"玩家", "player",
"时间", "time",
"抛弃", "discard",
"限制", "limit",
"货物", "cargo",
"无载", "unloaded",
"空载", "empty"
}
local lowerDest = string.lower(destName)
for _, word in ipairs(blacklist) do
if string.find(lowerDest, word, 1, true) then
return false
end
end
return true
end
local function unwrapNbtObject(obj)
if type(obj) ~= "table" then return obj end
if obj[1] == nil then return obj end
local inner = obj[1]
local isInnerUseful = false
if type(inner) == "table" then
for _ in pairs(inner) do isInnerUseful = true break end
elseif inner ~= nil then
isInnerUseful = true
end
if not isInnerUseful then return obj end
local outerKeyCount = 0
for _ in pairs(obj) do outerKeyCount = outerKeyCount + 1 end
if outerKeyCount == 1 then return inner end
return obj
end
local function extractAlphaNumCodes(text)
if not text then return {} end
local s = tostring(text)
local codes = {}
for code in string.gmatch(s, "[%a%d]+%-[%a%d]+") do
codes[code] = true
end
for code in string.gmatch(s, "%a+%d+") do
codes[code] = true
end
for code in string.gmatch(s, "%d+") do
codes[code] = true
end
local list = {}
for k, _ in pairs(codes) do
table.insert(list, k)
end
return list
end
local function hasSharedAlphaNumCode(a, b)
local codes1 = extractAlphaNumCodes(a)
local codes2 = extractAlphaNumCodes(b)
if #codes1 == 0 or #codes2 == 0 then return false end
for _, c1 in ipairs(codes1) do
for _, c2 in ipairs(codes2) do
if c1 == c2 then return true end
local lc1, lc2 = string.lower(c1), string.lower(c2)
if lc1 == lc2 then return true end
if #lc1 >= 3 and #lc2 >= 3 then
if string.find(lc1, lc2, 1, true) or string.find(lc2, lc1, 1, true) then
return true
end
end
end
end
return false
end
local function looksLikeValidDestinationText(text)
if not text then return false end
local s = tostring(text)
if s == "" then return false end
local lower = string.lower(s)
if lower == "unknown" then return false end
local questionMarkCount = 0
for i = 1, #s do
local c = string.sub(s, i, i)
if c == "?" then questionMarkCount = questionMarkCount + 1 end
end
if #s > 0 and questionMarkCount / #s >= 0.5 then
local codes = extractAlphaNumCodes(s)
if #codes == 0 then return false end
end
local hasRealChar = false
for i = 1, #s do
local b = string.byte(s, i)
if b and b >= 48 and b <= 57 then hasRealChar = true break end
if b and b >= 65 and b <= 90 then hasRealChar = true break end
if b and b >= 97 and b <= 122 then hasRealChar = true break end
if b and b >= 128 then hasRealChar = true break end
end
if not hasRealChar then return false end
return isStationDestination(s)
end
local function tryCorrectUpcomingEntryWithCurrentTrain(entry, etaSeconds)
if not State.currentTrain or not entry then return entry end
local ctId = State.currentTrain.id
local ctDest = State.currentTrain.dest
local ctPlatform = tostring(State.stationPlatform or State.currentTrain.platform or "1")
local entryPlatform = tostring(entry.platform or "1")
local samePlatform = (entryPlatform == ctPlatform)
local sameTrain = false
if ctId and entry.trainName then
sameTrain = Utils.isSameTrain(ctId, entry.trainName)
end
local platformNum = tonumber((string.gsub(entryPlatform, "%D", "")))
local ctPlatformNum = tonumber((string.gsub(ctPlatform, "%D", "")))
local nearbyPlatform = false
if platformNum and ctPlatformNum then
nearbyPlatform = (math.abs(platformNum - ctPlatformNum) <= 1)
end
local sharedCodeDest = hasSharedAlphaNumCode(entry.dest, ctDest)
local sharedCodeTrain = hasSharedAlphaNumCode(entry.trainName, ctId)
local etaAtStation = (type(etaSeconds) == "number" and etaSeconds <= 0.5)
local destInvalid = not looksLikeValidDestinationText(entry.dest)
local idInvalid = not looksLikeValidDestinationText(entry.trainName)
local shouldCorrect = samePlatform or sameTrain
or (sharedCodeDest and (nearbyPlatform or etaAtStation))
or (sharedCodeTrain and (nearbyPlatform or etaAtStation))
or ((destInvalid or idInvalid) and etaAtStation)
or (destInvalid and idInvalid and nearbyPlatform)
if not shouldCorrect then return entry end
local validId = looksLikeValidDestinationText(entry.trainName)
local validDest = looksLikeValidDestinationText(entry.dest)
if not validId and ctId and ctId ~= "" then
entry.trainName = ctId
end
if sameTrain and ctId and ctId ~= "" and tostring(entry.trainName or "") ~= tostring(ctId) then
if Utils.isFuzzyMatch(entry.trainName or "", ctId) or not validId or sharedCodeTrain then
entry.trainName = ctId
end
end
if ctId and ctId ~= "" and tostring(entry.trainName or "") ~= tostring(ctId) and sharedCodeTrain and (etaAtStation or samePlatform or idInvalid) then
entry.trainName = ctId
end
if not validDest and ctDest and ctDest ~= "" then
entry.dest = ctDest
end
if (sameTrain or samePlatform or sharedCodeDest) and ctDest and ctDest ~= "" and tostring(entry.dest or "") ~= tostring(ctDest) then
if Utils.isFuzzyMatch(entry.dest or "", ctDest) or not validDest or sharedCodeDest or etaAtStation then
entry.dest = ctDest
end
end
if ctDest and ctDest ~= "" and tostring(entry.dest or "") ~= tostring(ctDest) and sharedCodeDest and (etaAtStation or samePlatform or destInvalid) then
entry.dest = ctDest
end
return entry
end
local function checkIsAtStationWithCurrentTrain(trainName, destination, platform)
if not State.currentTrain then return false end
local ctId = State.currentTrain.id
local ctDest = State.currentTrain.dest
local ctPlatform = tostring(State.stationPlatform or State.currentTrain.platform or "1")
local trainPlatform = tostring(platform or "1")
if trainPlatform == ctPlatform then
return true
end
local sameTrain = false
if ctId and trainName then
if ctId == trainName then
sameTrain = true
else
local validCT = looksLikeValidDestinationText(ctId)
local validEntry = looksLikeValidDestinationText(trainName)
if validCT and validEntry then
sameTrain = Utils.isSameTrain(ctId, trainName)
elseif (not validEntry) and validCT then
sameTrain = Utils.isFuzzyMatch(ctDest or "", destination or "")
end
end
end
if not sameTrain and hasSharedAlphaNumCode(trainName, ctId) then
sameTrain = true
end
if not sameTrain and (not looksLikeValidDestinationText(trainName)) and (not looksLikeValidDestinationText(destination)) then
local pNum = tonumber((string.gsub(trainPlatform, "%D", "")))
local ctPNum = tonumber((string.gsub(ctPlatform, "%D", "")))
if pNum and ctPNum and math.abs(pNum - ctPNum) <= 1 then
sameTrain = true
end
end
if sameTrain then
local entryIdValid = looksLikeValidDestinationText(trainName)
if entryIdValid then
return true
else
if Utils.isFuzzyMatch(ctDest or "", destination or "") then
return true
end
if not looksLikeValidDestinationText(destination) then
return true
end
if hasSharedAlphaNumCode(destination, ctDest) then
return true
end
end
end
return false
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 = {}
-- ETA come from NBT.
if Peripherals.createTarget then
local okLines, lines = pcall(Peripherals.createTarget.dump)
if okLines and type(lines) == "table" then
for _, lineText in pairs(lines) do
if lineText and lineText ~= "" and lineText:match("[%w%?%[]") then
local delayMatch = string.match(lineText, "%+%s*(%d+)")
local textDelay = tonumber(delayMatch) or 0
local cleanLine = string.gsub(lineText, "%s*%(?%+%s*%d+.-%)?", "")
local etaValue, etaUnit, tName, dest
etaValue, etaUnit, tName, dest = string.match(cleanLine, "^(%S+)%s+(.-)%s+(%[.-%][^%s]*)%s+(.*)$")
if not tName then
etaValue, etaUnit, tName, dest = string.match(cleanLine, "^([^%s]+)%s+(.-)%s+([^%s]+)%s+(.*)$")
end
if tName and etaValue then
if dest then
local textKey = normalizeUpcomingKey(tName, dest)
etaTextByKey[textKey] = {
value = etaValue,
unit = etaUnit,
raw = tostring(etaValue or "") .. ((etaUnit and etaUnit ~= "") and (" " .. tostring(etaUnit)) or "")
}
if textDelay > 0 then
delayTextByKey[textKey] = textDelay
end
end
end
end
end
end
end
if Peripherals.blockReader then
local ok, data = pcall(Peripherals.blockReader.getBlockData)
if not ok then
Utils.log("WARN", "BlockReader.getBlockData() failed: " .. tostring(data))
data = nil
end
if ok and data and data.TrainStops then
if not State.lastRefreshed or State.lastRefreshed ~= data.LastRefreshed then
if State.lastRefreshed and State.lastRefreshedEpoch then
local deltaTicks = (data.LastRefreshed or 0) - (State.lastRefreshed or 0)
local deltaSeconds = (now - (State.lastRefreshedEpoch or now)) / 1000
if deltaTicks > 0 and deltaSeconds > 0 then
local rate = deltaTicks / deltaSeconds
if rate < 1 then rate = 1 end
if rate > 20 then rate = 20 end
State.tickRate = rate
end
end
State.lastRefreshed = data.LastRefreshed
State.lastRefreshedEpoch = now
end
local elapsedSeconds = (now - (State.lastRefreshedEpoch or now)) / 1000
local currentTick = (data.LastRefreshed or 0) + math.floor(elapsedSeconds * (State.tickRate or 20))
local seenAlertKeys = {}
local seenApproachKeys = {}
cleanupTimestampMap(State.announcedApproaching, now, tonumber(CONFIG.APPROACHING_COOLDOWN_MS) or 120000)
cleanupTimestampMap(State.announcedAlerts, now, tonumber(CONFIG.ALERT_COOLDOWN_MS) or 300000)
for i, stop in ipairs(data.TrainStops) do
local okStop, err = pcall(function()
local st = unwrapNbtObject(stop.Station)
if st then
local trainName = st.TrainName or "Unknown"
local rtStation = unwrapNbtObject(st.RealTimeStation)
local schStation = unwrapNbtObject(st.ScheduledStation)
local rtStationName = type(rtStation) == "table" and rtStation.StationName or nil
local schStationName = type(schStation) == "table" and schStation.StationName or nil
local isLast = (stop.IsLast == 1 or stop.IsLast == true)
local destination = st.Destination or "Unknown"
if type(stop.Stopovers) == "table" and #stop.Stopovers > 0 then
local lastStopover_raw = stop.Stopovers[#stop.Stopovers]
local lastStopover
if type(lastStopover_raw) == "table" then
local unwrapped = unwrapNbtObject(lastStopover_raw)
if type(unwrapped) == "table" then
lastStopover = unwrapped.StationName or unwrapped.Name or unwrapped[1] or lastStopover_raw
else
lastStopover = unwrapped or lastStopover_raw
end
else
lastStopover = lastStopover_raw
end
if looksLikeValidDestinationText(lastStopover) then
destination = lastStopover
elseif not looksLikeValidDestinationText(destination) then
destination = lastStopover or destination
end
end
if not looksLikeValidDestinationText(destination) and isLast then
if looksLikeValidDestinationText(rtStationName) then
destination = rtStationName
elseif looksLikeValidDestinationText(schStationName) then
destination = schStationName
end
end
if not looksLikeValidDestinationText(destination) then
if looksLikeValidDestinationText(rtStationName) then
destination = rtStationName
elseif looksLikeValidDestinationText(schStationName) then
destination = schStationName
end
end
local platform = "1"
local rtStationInfo = type(rtStation) == "table" and unwrapNbtObject(rtStation.StationInfo) or nil
local schStationInfo = type(schStation) == "table" and unwrapNbtObject(schStation.StationInfo) or nil
if type(schStationInfo) == "table" and schStationInfo.Platform then
platform = schStationInfo.Platform
elseif type(rtStationInfo) == "table" and rtStationInfo.Platform then
platform = rtStationInfo.Platform
else
platform = State.stationPlatform or "1"
end
if isStationDestination(destination) then
local cancelled = false
local reason = nil
local tr = unwrapNbtObject(stop.Train)
if type(tr) == "table" then
cancelled = tr.Cancelled == 1 or tr.Cancelled == true
local status = tr.Status
if type(status) == "table" then
for _, s in ipairs(status) do
local unwrapped_s = unwrapNbtObject(s)
if type(unwrapped_s) == "string" then
reason = mapTrainStatusReason(unwrapped_s) or reason
elseif type(s) == "string" then
reason = mapTrainStatusReason(s) or reason
end
end
end
end
if cancelled then
reason = "cancelled"
end
local scheduledArrival = pickEarlierTick(st.ScheduledArrival, st.ScheduledDeparture)
local realArrival = pickEarlierTick(st.RealTimeArrival, st.RealTimeDeparture)
local etaTicks = realArrival - currentTick
local etaSeconds = etaTicks / (State.tickRate or 20)
if etaSeconds < 0 then etaSeconds = 0 end
local preCorrected = tryCorrectUpcomingEntryWithCurrentTrain({
trainName = trainName,
dest = destination,
platform = platform
}, etaSeconds)
if preCorrected then
if preCorrected.trainName and tostring(preCorrected.trainName) ~= tostring(trainName) then
trainName = preCorrected.trainName
end
if preCorrected.dest and tostring(preCorrected.dest) ~= tostring(destination) then
destination = preCorrected.dest
end
end
local announceKey = normalizeUpcomingKey(trainName, destination)
local etaLabel = buildEtaLabel(etaSeconds, etaTextByKey[announceKey])
local delayTicks = realArrival - scheduledArrival
local delayMinutes = math.floor(delayTicks / ((State.tickRate or 20) * 60))
if delayMinutes < 0 then delayMinutes = 0 end
local displayDelayMinutes = math.max(delayMinutes, tonumber(delayTextByKey[announceKey]) or 0)
if etaSeconds <= 60 and not State.announcedApproaching[announceKey] then
Utils.log("DEBUG", string.format("Train: %s, ETA: %.1fs, Ticks: %d, Rate: %.1f", trainName, etaSeconds, etaTicks, State.tickRate or 20))
end
table.insert(newUpcoming, {
trainName = trainName,
dest = destination,
platform = platform,
eta = etaSeconds,
etaLabel = etaLabel,
delay = displayDelayMinutes,
cancelled = cancelled,
reason = reason
})
local alertType = cancelled and "cancelled" or ((displayDelayMinutes > 0) and "delayed" or nil)
local alertKey = alertType and (announceKey .. "::" .. alertType) or nil
if alertKey then
seenAlertKeys[alertKey] = true
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 "")))
if Utils.isLocalStationMaster() then
Network.send({
type = "train_alert",
stationName = State.stationName,
stationCode = State.stationCode,
stationPlatform = platform,
trainId = trainName,
destination = destination,
destCode = Utils.getStationCode(destination),
eta = tostring(etaSeconds) .. "s",
etaSeconds = etaSeconds,
delay = displayDelayMinutes,
cancelled = cancelled,
reason = reason
})
end
end
end
local isAtStation = checkIsAtStationWithCurrentTrain(trainName, destination, platform)
if not isAtStation and etaSeconds > 0 and etaSeconds <= 45 then
if not seenApproachKeys[announceKey] then
seenApproachKeys[announceKey] = true
local pending = State.pendingApproaching[announceKey]
if not pending then
State.pendingApproaching[announceKey] = { since = now, lastEta = etaSeconds }
else
if etaSeconds > (tonumber(pending.lastEta) or etaSeconds) + 15 then
pending.since = now
end
pending.lastEta = etaSeconds
end
local stableMs = tonumber(CONFIG.APPROACHING_STABLE_MS) or 15000
local currentPending = State.pendingApproaching[announceKey]
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))
if Utils.isLocalStationMaster() then
Network.send({
type = "train_approaching",
stationName = State.stationName,
stationCode = State.stationCode,
stationPlatform = platform,
trainId = trainName,
destination = destination,
destCode = Utils.getStationCode(destination),
eta = string.format("%.1fs", etaSeconds),
etaSeconds = etaSeconds,
delay = displayDelayMinutes,
cancelled = cancelled,
reason = reason
})
end
end
end
end
end
end
end)
if not okStop then
Utils.log("WARN", "TrainStop[" .. tostring(i) .. "] parse error: " .. tostring(err))
end
end
for k, _ in pairs(State.pendingAlerts) do
if not seenAlertKeys[k] then
State.pendingAlerts[k] = nil
end
end
for k, _ in pairs(State.pendingApproaching) do
if not seenApproachKeys[k] then
State.pendingApproaching[k] = nil
end
end
end
end
if not Peripherals.blockReader and Peripherals.createTarget then
local okCT, lines = pcall(Peripherals.createTarget.dump)
if not okCT or type(lines) ~= "table" then
lines = {}
end
local seenAlertKeys = {}
local seenApproachKeys = {}
cleanupTimestampMap(State.announcedApproaching, now, tonumber(CONFIG.APPROACHING_COOLDOWN_MS) or 120000)
cleanupTimestampMap(State.announcedAlerts, now, tonumber(CONFIG.ALERT_COOLDOWN_MS) or 300000)
for _, lineText in pairs(lines) do
if lineText and lineText ~= "" and lineText:match("[%w%?%[]") then
local delayMatch = string.match(lineText, "%+%s*(%d+)")
local delay = tonumber(delayMatch) or 0
local cleanLine = string.gsub(lineText, "%s*%(?%+%s*%d+.-%)?", "")
local platform, etaStr, tName, dest
platform, etaStr, tName, dest = string.match(cleanLine, "^(%d+)%s+(.-)%s+(%[.-%][^%s]*)%s+(.*)$")
if not tName then
platform, etaStr, tName, dest = string.match(cleanLine, "^([^%s]+)%s+(.-)%s+(%[.-%][^%s]*)%s+(.*)$")
end
if not tName then
platform, etaStr, tName, dest = string.match(cleanLine, "^([^%s]+)%s+(.-)%s+([^%s]+)%s+(.*)$")
end
if tName and etaStr and isStationDestination(dest) then
local etaSeconds = -1
local isNow = string.find(etaStr, "现在")
if isNow then
etaSeconds = 0
else
local sign_mm, mm, ss = string.match(etaStr, "^%s*(%-?)%s*(%d+)%s*:%s*(%d+)%s*$")
if mm and ss then
etaSeconds = tonumber(mm) * 60 + tonumber(ss)
if sign_mm == "-" then etaSeconds = -etaSeconds end
else
local sign, num, unit = string.match(etaStr, "^%s*(%-?)%s*(%d+)%s*(.*)")
if num then
num = tonumber(num)
if sign == "-" then num = -num end
if string.find(unit, "秒") or string.find(unit, "s") or string.find(unit, "sec") or (unit == "??" and num > 10) then
etaSeconds = num
else
etaSeconds = num * 60
end
end
end
end
local entryPlatform = platform or State.stationPlatform or "1"
local preCorrectedCT = tryCorrectUpcomingEntryWithCurrentTrain({
trainName = tName or "",
dest = dest or "",
platform = entryPlatform
}, etaSeconds >= 0 and etaSeconds or 0)
if preCorrectedCT then
if preCorrectedCT.trainName and tostring(preCorrectedCT.trainName) ~= tostring(tName or "") then
tName = preCorrectedCT.trainName
end
if preCorrectedCT.dest and tostring(preCorrectedCT.dest) ~= tostring(dest or "") then
dest = preCorrectedCT.dest
end
end
local announceKey = normalizeUpcomingKey(tName, dest)
table.insert(newUpcoming, {
trainName = tName,
dest = dest,
platform = platform or State.stationPlatform or "1",
eta = etaSeconds >= 0 and etaSeconds or 0,
etaLabel = buildEtaLabel(etaSeconds >= 0 and etaSeconds or 0, {
value = string.match(etaStr, "(%-?%d+)"),
unit = string.match(etaStr, "%-?%d+%s*(.*)")
}),
delay = delay,
cancelled = false,
reason = nil
})
local alertKey = announceKey .. "::delayed"
if delay > 0 then
seenAlertKeys[alertKey] = true
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))
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,
destCode = Utils.getStationCode(dest),
eta = etaStr,
etaSeconds = etaSeconds,
delay = delay
})
end
end
end
local isAtStation = checkIsAtStationWithCurrentTrain(tName, dest, platform or State.stationPlatform or "1")
if not isAtStation and etaSeconds > 0 and etaSeconds <= 45 then
if not seenApproachKeys[announceKey] then
seenApproachKeys[announceKey] = true
local pending = State.pendingApproaching[announceKey]
if not pending then
State.pendingApproaching[announceKey] = { since = now, lastEta = etaSeconds }
else
if etaSeconds > (tonumber(pending.lastEta) or etaSeconds) + 15 then
pending.since = now
end
pending.lastEta = etaSeconds
end
local stableMs = tonumber(CONFIG.APPROACHING_STABLE_MS) or 15000
local currentPending = State.pendingApproaching[announceKey]
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))
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,
destCode = Utils.getStationCode(dest),
eta = etaStr,
etaSeconds = etaSeconds,
delay = delay
})
end
end
end
end
end
end
end
for k, _ in pairs(State.pendingAlerts) do
if not seenAlertKeys[k] then
State.pendingAlerts[k] = nil
end
end
for k, _ in pairs(State.pendingApproaching) do
if not seenApproachKeys[k] then
State.pendingApproaching[k] = nil
end
end
end
for _, entry in ipairs(newUpcoming) do
tryCorrectUpcomingEntryWithCurrentTrain(entry, entry.eta)
end
State.upcomingTrains = newUpcoming
if not Peripherals.station then
State.currentTrain = nil
Display.render()
Display.renderTerminal()
os.sleep(0.5)
goto continue
end
local hasTrain = Peripherals.station.isTrainPresent()
if hasTrain then
if not State.currentTrain then
-- Train just arrived
local id = Peripherals.station.getTrainName()
local schedule = Peripherals.station.getSchedule()
local dest = Utils.getDestination(schedule)
local nextStation = Utils.getNextStation(schedule, State.stationName)
local now = os.epoch("utc") / 1000
local stationTime = Utils.getStopTime(schedule, State.stationName) or 60
local lineCode = Utils.getStationCode(State.stationCode or State.stationName)
if lineCode then
local dash = lineCode:find("-")
if dash then lineCode = lineCode:sub(1, dash - 1) end
end
local destCode = Utils.getStationCode(dest)
local nextCode = Utils.getStationCode(nextStation)
State.currentTrain = {
id = id,
trainType = Utils.parseTrainId(id),
schedule = schedule,
dest = dest,
nextStation = nextStation,
lineCode = lineCode,
destCode = destCode,
nextCode = nextCode,
arrivalTime = now,
departureTime = now + stationTime,
platform = Utils.getPlatformNumber(State.stationName, "1"),
departureSent = false
}
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
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
if State.currentTrain then
State.currentTrain = nil
end
end
if State.currentTrain then
local ctKey = normalizeUpcomingKey(State.currentTrain.id, State.currentTrain.dest)
local alreadyInUpcoming = false
for _, ut in ipairs(State.upcomingTrains) do
local utKey = normalizeUpcomingKey(ut.trainName, ut.dest)
if utKey == ctKey then
alreadyInUpcoming = true
break
end
end
if not alreadyInUpcoming then
local nowSec = os.epoch("utc") / 1000
local remaining = State.currentTrain.departureTime and math.max(0, State.currentTrain.departureTime - nowSec) or 0
table.insert(State.upcomingTrains, 1, {
trainName = State.currentTrain.id,
dest = State.currentTrain.dest,
platform = State.currentTrain.platform or State.stationPlatform or "1",
eta = 0,
etaLabel = buildEtaLabel(0, nil),
delay = 0,
cancelled = false,
reason = nil,
_atStation = true,
_departingIn = remaining
})
end
end
Display.render()
Display.renderTerminal()
os.sleep(0.5)
::continue::
end
end
-- 9. Bootstrap
AutoUpdate.checkOnBoot()
Peripherals.init()
Utils.ensureSlaveIdentityConfigured()
Audio.loadStationList()
Utils.refreshStationIdentity()
Utils.syncStationIdentityFromPeripheral()
Utils.refreshStationIdentity()
Utils.log("INFO", "StationBroadcast version " .. APP_VERSION_TEXT)
if Peripherals.station then
Utils.log("INFO", "Train station peripheral detected on " .. tostring(State.stationPeripheralSide or "unknown"))
else
Utils.log("WARN", "No train station peripheral detected; using manual slave mode")
Utils.log("INFO", "Edit " .. CONFIG.STATION_IDENTITY_FILE .. " to set station_code/station_platform")
end
if Peripherals.primaryMonitor then
Utils.log("INFO", "Primary monitor detected")
else
Utils.log("WARN", "No monitor detected; running in terminal-only mode")
end
if Peripherals.speakers and #Peripherals.speakers > 0 then
Utils.log("INFO", "Speaker detected x" .. tostring(#Peripherals.speakers))
else
Utils.log("WARN", "No speaker detected")
end
parallel.waitForAny(
function()
while true do
if not State.ws then Network.connect() end
os.sleep(5)
end
end,
Audio.playerLoop,
Network.messageLoop,
Network.heartbeat,
Network.syncFiles,
Input.listener,
Main.loop,
LocalBus.loop
)