Files
CC-StationBroadcast/station_broadcast.lua
T
Henry_Du 5fb81e3998 feat(audio): 实现流式WAV解码器并优化音频播放逻辑
将应用版本更新至v1.0.48;新增WAV头部解析与流式解码逻辑,替换原有的单块解码方案;添加扬声器speakWav兼容性检测,无支持设备时输出错误日志;优化WAV解码错误处理与日志输出,仅首次打印音频格式信息。
2026-08-03 09:37:00 +08:00

3160 lines
112 KiB
Lua

-- 1. Configuration & Constants
local APP_VERSION_TEXT = "v1.0.48"
local CONFIG = {
CLOUD_URL = "ws://stabadmin.fse-media.group",
API_URL = "http://stabadmin.fse-media.group",
STARTUP_PATH = "/startup",
SYNC_INTERVAL = 30,
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 = true
}
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,
logs = {},
logScrollOffset = 0,
audioRx = nil,
audioQueue = {},
currentPlaying = nil,
currentPriority = -1,
interruptSeq = 0,
tickRate = 20,
updateInProgress = false
}
-- 3. Peripherals Setup
local Peripherals = {}
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
end
-- 4. Utility Functions
local Utils = {}
function Utils.log(level, message)
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.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)
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"),
""
}, "\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 availStr = table.concat(availBeeps, ", ")
if #availStr > 40 then availStr = availStr:sub(1, 37) .. "..." 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: " .. availStr)
term.setTextColor(colors.white)
term.setCursorPos(1, 11)
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, 11)
local departureBeep = Utils.trim(read() or "")
if departureBeep == "" then departureBeep = "fc1" end
if stationCode == "" or stationPlatform == "" then
term.setCursorPos(1, 12)
term.setTextColor(colors.red)
term.write("Both station code and platform are required.")
term.setCursorPos(1, 14)
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, 12)
term.setTextColor(colors.green)
term.write("Saved to " .. CONFIG.STATION_IDENTITY_FILE)
term.setCursorPos(1, 14)
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, 12)
term.setTextColor(colors.red)
term.write("Failed to save " .. CONFIG.STATION_IDENTITY_FILE)
term.setCursorPos(1, 14)
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 availStr = table.concat(availBeeps, ", ")
if #availStr > 40 then availStr = availStr:sub(1, 37) .. "..." 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: " .. availStr)
term.setTextColor(colors.white)
term.setCursorPos(1, 11)
term.write("Departure beep [" .. currentBeep .. "]: ")
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(32, 11)
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, 12)
term.setTextColor(colors.green)
term.write("Configuration saved.")
term.setCursorPos(1, 14)
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, 12)
term.setTextColor(colors.red)
term.write("Failed to save configuration.")
term.setCursorPos(1, 14)
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
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.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
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 function scanRecursive(obj, depth)
if depth > 5 or not obj then return false end
for k, v in pairs(obj) do
if type(v) == "string" then
local entryText = v:lower()
local entryCode = Utils.getStationCode(entryText)
local entryAlpha = entryText:gsub("[^%w%d]", "")
local entryBase = Utils.getBaseName(entryText)
Utils.log("DEBUG", string.format(" [%d] SCAN '%s': '%s' (Code: %s)", i, k, 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 (via field '%s')", i, k))
return true
end
elseif type(v) == "table" then
if scanRecursive(v, depth + 1) then return true end
end
end
return false
end
if scanRecursive(entry, 0) 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
local function checkIsStation(obj, depth)
if depth > 2 or not obj then return false end
for k, v in pairs(obj) do
if type(v) == "string" then
local lk = tostring(k):lower()
if lk:find("text") or lk:find("station") or lk:find("destination") then return true end
elseif type(v) == "table" then
if checkIsStation(v, depth + 1) then return true end
end
end
return false
end
if checkIsStation(entry, 0) 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 function scanRecursive(obj, depth)
if depth > 5 or not obj then return false end
for k, v in pairs(obj) do
if type(v) == "string" then
local entryText = v: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 return true end
elseif type(v) == "table" then
if scanRecursive(v, depth + 1) then return true end
end
end
return false
end
if scanRecursive(entry, 0) then
matchFoundIndex = i
break
end
end
end
if matchFoundIndex then
-- Search forward
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
-- Loop around to beginning
for i = 1, matchFoundIndex - 1 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
-- 5. Audio System
local Audio = {}
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)
os.queueEvent("audio_chunk")
end
stream.finish = function()
finished = true
os.queueEvent("audio_chunk")
end
stream.read = function()
while #chunks == 0 do
if finished then return nil end
os.pullEvent("audio_chunk")
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)
os.queueEvent("audio_queue")
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
os.queueEvent("audio_interrupt", State.interruptSeq)
end
local function shouldStop(playSeq)
return State.interruptSeq ~= playSeq
end
local function waitForInterruptibleDelay(playSeq, seconds)
local timer = os.startTimer(seconds)
while true do
local ev = { os.pullEvent() }
if ev[1] == "audio_interrupt" and tonumber(ev[2]) and tonumber(ev[2]) >= playSeq then
return false
end
if ev[1] == "timer" and ev[2] == timer then
return true
end
end
end
local function waitForNativeSpeakersEmpty(speakers, playSeq)
local waiting = {}
for _, sp in ipairs(speakers or {}) do
local name = peripheral.getName(sp)
if name then waiting[name] = true end
end
while next(waiting) do
if shouldStop(playSeq) then return false end
local ev = { os.pullEvent() }
if ev[1] == "audio_interrupt" and tonumber(ev[2]) and tonumber(ev[2]) >= playSeq then
return false
end
if ev[1] == "speaker_audio_empty" and ev[2] and waiting[ev[2]] then
waiting[ev[2]] = nil
end
end
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
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
local ev = { os.pullEvent() }
if ev[1] == "audio_interrupt" and tonumber(ev[2]) and tonumber(ev[2]) >= playSeq then
return false
end
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
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
local ev = { os.pullEvent() }
if ev[1] == "audio_interrupt" and tonumber(ev[2]) and tonumber(ev[2]) >= playSeq then
return false
end
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")
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
local ev = { os.pullEvent() }
if ev[1] == "audio_interrupt" and tonumber(ev[2]) and tonumber(ev[2]) >= playSeq then
return false
end
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 samples = {}
for i = 1, #chunk do
local b = string.byte(chunk, i)
if b >= 128 then
b = b - 256
end
samples[i] = b
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 parseWavHeader(buf)
if type(buf) ~= "string" or #buf < 44 then
return nil, nil, "wav header too short"
end
if buf:sub(1, 4) ~= "RIFF" or buf:sub(9, 12) ~= "WAVE" then
return nil, nil, "invalid wav header"
end
local offset = 13
local audioFormat, channels, sampleRate, bitsPerSample
local dataOffset, dataSize
while offset + 7 <= #buf do
local chunkId = buf:sub(offset, offset + 3)
local chunkSize = readU32LE(buf, offset + 4)
if not chunkSize then break end
local payloadOffset = offset + 8
if chunkId == "fmt " and chunkSize >= 16 then
audioFormat = readU16LE(buf, payloadOffset)
channels = readU16LE(buf, payloadOffset + 2)
sampleRate = readU32LE(buf, payloadOffset + 4)
bitsPerSample = readU16LE(buf, 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, nil, "unsupported wav format " .. tostring(audioFormat)
end
if channels ~= 1 and channels ~= 2 then
return nil, nil, "unsupported channels " .. tostring(channels)
end
if bitsPerSample ~= 8 and bitsPerSample ~= 16 then
return nil, nil, "unsupported bits " .. tostring(bitsPerSample)
end
if not dataOffset or not dataSize then
return nil, nil, "wav missing data chunk"
end
return {
channels = channels,
bitsPerSample = bitsPerSample,
sampleRate = sampleRate or 0,
dataSize = dataSize
}, dataOffset, nil
end
local function decodePcmBytesToSamples(pcm, channels, bitsPerSample)
if type(pcm) ~= "string" or #pcm == 0 then return {} end
local frameSize = channels * (bitsPerSample / 8)
if frameSize <= 0 then return {} end
local usableLen = #pcm - (#pcm % frameSize)
if usableLen <= 0 then return {} end
local samples = {}
local sampleIndex = 1
local endOffset = usableLen - frameSize + 1
for pos = 1, 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(pcm, sampleOffset)
sampleValue = (raw or 128) - 128
else
sampleValue = readS16LE(pcm, 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
end
local function createWavStreamDecoder()
local state = {
header = nil,
headerParsed = false,
pending = "",
frameSize = 0,
logged = false
}
local function decode(chunk)
if type(chunk) ~= "string" or #chunk == 0 then return {}, nil, state.header end
local buf = state.pending .. chunk
state.pending = ""
if not state.headerParsed then
local header, dataOffset, err = parseWavHeader(buf)
if err then
state.pending = buf
if #state.pending > 128 * 1024 then
return nil, err, nil
end
return {}, "need header", nil
end
state.header = header
state.headerParsed = true
state.frameSize = (tonumber(header.channels) or 1) * ((tonumber(header.bitsPerSample) or 16) / 8)
buf = buf:sub(dataOffset)
end
local frameSize = tonumber(state.frameSize) or 0
if frameSize <= 0 then return nil, "invalid frame size", state.header end
local usableLen = #buf - (#buf % frameSize)
local usable = ""
if usableLen > 0 then
usable = buf:sub(1, usableLen)
end
local rest = buf:sub(usableLen + 1)
if rest and rest ~= "" then
state.pending = rest
end
local samples = decodePcmBytesToSamples(
usable,
tonumber(state.header and state.header.channels) or 1,
tonumber(state.header and state.header.bitsPerSample) or 16
)
return samples, nil, state.header
end
return { decode = decode }
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
end
local playSeq = State.interruptSeq
local payload = item.audioData
if type(payload) ~= "string" or #payload == 0 then return 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
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
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
end
while areHqSpeakersBusy(Peripherals.speakers) do
if shouldStop(playSeq) then
Audio.stop("current")
return
end
if not waitForInterruptibleDelay(playSeq, 0.1) then
Audio.stop("current")
return
end
end
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 hasFallback = false
for _, sp in ipairs(Peripherals.speakers or {}) do
if type(sp.speakWav) == "function" then
hasFallback = true
break
end
end
if not hasFallback then
Utils.log("ERROR", "No speaker supports speakWav/speakWavAll for WAV chunk fallback")
return false
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) 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
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
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 = decodePcmS8Chunk(chunk)
if samples and not playChunkOnSpeakers(samples, CONFIG.AUDIO_VOLUME, playSeq) then
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
local wavDecoder = createWavStreamDecoder()
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 = wavDecoder.decode(chunk)
if samples and #samples > 0 then
if meta and not wavDecoder.logged then
wavDecoder.logged = true
Utils.log("INFO", string.format(
"WAV stream header: rate=%d ch=%d bits=%d",
tonumber(meta and meta.sampleRate) or 0,
tonumber(meta and meta.channels) or 0,
tonumber(meta and meta.bitsPerSample) or 0
))
end
local playOk = playChunkOnSpeakers(samples, CONFIG.AUDIO_VOLUME, playSeq, false)
if not playOk then
Audio.stop("current")
return
end
elseif err and err ~= "need header" then
Utils.log("ERROR", "WAV stream decode failed: " .. tostring(err))
Audio.stop("current")
return
elseif err == "need header" then
if not waitForInterruptibleDelay(playSeq, 0.01) 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
return playHqAudioItem(item)
elseif format == "pcm" then
return Audio.playPcmItem(item)
elseif format == "wav-stream" then
return Audio.playWavStreamItem(item)
end
return Audio.playDfpwmItem(item)
end
function Audio.playerLoop()
while true do
if #State.audioQueue == 0 then
os.pullEvent("audio_queue")
else
local item = table.remove(State.audioQueue, 1)
State.currentPlaying = item
State.currentPriority = tonumber(item.priority) or 0
local ok, err = pcall(Audio.playItem, item)
if not ok then
Utils.log("ERROR", "Audio Playback Crash: " .. tostring(err))
end
State.currentPlaying = nil
State.currentPriority = -1
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 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
-- ============================================================================
-- 6. Network System
-- ============================================================================
local Network = {}
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
})
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" 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" 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 == "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
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()
Network.send({
type = "heartbeat",
stationName = State.stationName,
stationCode = State.stationCode,
stationPlatform = State.stationPlatform,
stationVersion = APP_VERSION_TEXT
})
end
os.sleep(10)
end
end
-- 7. Display System
local Display = {}
function Display.render()
local mon = Peripherals.primaryMonitor
if not mon then return end
local w, h = mon.getSize()
local contentBottom = 4
mon.clear()
mon.setCursorPos(1,1)
mon.setTextColor(COLORS.INFO)
mon.write("STATION: " .. State.stationName .. " " .. APP_VERSION_TEXT)
mon.setCursorPos(1,2)
mon.write("CODE: " .. tostring(State.stationCode) .. " P:" .. tostring(State.stationPlatform) .. " " .. tostring(State.stationIdentityMode))
mon.setCursorPos(1,3)
mon.write("TIME: " .. os.date("%H:%M:%S"))
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 trunc(str)
str = tostring(str or "")
if #str > w then return str:sub(1, w) end
return str
end
mon.setCursorPos(1,4)
if State.currentPlaying then
local p = tonumber(State.currentPlaying.priority) or 0
local kind = classifyAudioSource(State.currentPlaying.source, p)
mon.setTextColor(COLORS.SUCCESS)
mon.write(trunc("AUDIO: " .. kind .. " Playing (P=" .. tostring(p) .. ", Q=" .. tostring(#State.audioQueue) .. ")"))
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
mon.setTextColor(COLORS.DEBUG)
mon.write(trunc("AUDIO: " .. kind .. " Receiving" .. pct .. " (Q=" .. tostring(#State.audioQueue) .. ")"))
else
mon.setTextColor(COLORS.INFO)
mon.write(trunc("AUDIO: FREE (Q=" .. tostring(#State.audioQueue) .. ")"))
end
-- Current Train
if State.currentTrain then
mon.setCursorPos(1,5)
mon.setTextColor(COLORS.SUCCESS)
mon.write("CURRENT TRAIN:")
mon.setTextColor(COLORS.INFO)
mon.setCursorPos(1,6)
mon.write(State.currentTrain.id)
mon.setCursorPos(1,7)
mon.write("Dest: " .. State.currentTrain.dest)
-- Countdown
if State.currentTrain.departureTime then
local remaining = math.max(0, State.currentTrain.departureTime - os.epoch("utc") / 1000)
mon.setCursorPos(1, 9)
if remaining > 0 then
mon.setTextColor(COLORS.WARNING)
mon.write(string.format("Departing in: %02d:%02d", math.floor(remaining/60), math.floor(remaining%60)))
else
mon.setTextColor(COLORS.ERROR)
mon.write("Departing...")
end
contentBottom = 9
else
contentBottom = 7
end
else
mon.setCursorPos(1,5)
mon.setTextColor(COLORS.WARNING)
if Peripherals.station then
mon.write("NO TRAIN AT PLATFORM")
else
mon.write("SLAVE NODE MODE")
end
contentBottom = 5
end
-- Upcoming Trains
local logsStartY = h
local upcomingStartY = contentBottom + 1
if Peripherals.blockReader or Peripherals.createTarget then
local hasUpcoming = State.upcomingTrains and #State.upcomingTrains > 0
local maxUpcomingRows = math.max(0, h - upcomingStartY)
mon.setTextColor(COLORS.DEBUG)
mon.setCursorPos(1, upcomingStartY)
mon.write("UPCOMING TRAINS:")
if hasUpcoming then
mon.setTextColor(COLORS.INFO)
local visibleCount = math.min(3, #State.upcomingTrains, math.max(0, maxUpcomingRows - 1))
for i = 1, visibleCount do
local t = State.upcomingTrains[i]
mon.setCursorPos(1, upcomingStartY + 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)
if #lineText > w then lineText = lineText:sub(1, w) end
mon.write(lineText)
end
logsStartY = math.max(logsStartY, upcomingStartY + visibleCount + 1)
else
mon.setTextColor(COLORS.WARNING)
local msgY = upcomingStartY + 1
if msgY <= h then
mon.setCursorPos(1, msgY)
mon.write("NONE / WAITING DATA")
logsStartY = math.max(logsStartY, msgY + 1)
else
logsStartY = math.max(logsStartY, msgY)
end
end
end
-- Logs
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
mon.setCursorPos(1, logsStartY + i - 1)
mon.setTextColor(COLORS[entry.level] or colors.white)
mon.write(entry.text:sub(1, w))
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(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
local Main = {}
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
function Main.loop()
while true do
local now = os.epoch("utc")
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 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 st = stop.Station
if st then
local trainName = st.TrainName or "Unknown"
local destination = st.Destination or "Unknown"
if type(stop.Stopovers) == "table" and #stop.Stopovers > 0 then
destination = stop.Stopovers[#stop.Stopovers] or destination
end
local platform = stop.StationInfo and stop.StationInfo.Platform or State.stationPlatform or "1"
local cancelled = false
local reason = nil
local tr = stop.Train
if tr then
cancelled = tr.Cancelled == 1 or tr.Cancelled == true
local status = tr.Status
if type(status) == "table" then
for _, s in ipairs(status) do
if 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 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)
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 "")))
Network.send({
type = "train_approaching",
stationName = State.stationName,
stationCode = State.stationCode,
stationPlatform = platform,
trainId = trainName,
destination = destination,
eta = tostring(etaSeconds) .. "s",
etaSeconds = etaSeconds,
delay = displayDelayMinutes,
cancelled = cancelled,
reason = reason
})
end
end
if etaSeconds >= 0 and etaSeconds <= 45 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) + 3 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))
Network.send({
type = "train_approaching",
stationName = State.stationName,
stationCode = State.stationCode,
stationPlatform = platform,
trainId = trainName,
destination = destination,
eta = string.format("%.1fs", etaSeconds),
etaSeconds = etaSeconds,
delay = displayDelayMinutes,
cancelled = cancelled,
reason = reason
})
end
else
State.pendingApproaching[announceKey] = nil
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
end
if not Peripherals.blockReader and Peripherals.createTarget then
local lines = Peripherals.createTarget.dump()
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 then
local announceKey = tName .. "_" .. dest
local etaSeconds = -1
local isNow = string.find(etaStr, "现在") or string.find(etaStr, "0")
if isNow then
etaSeconds = 0
else
local num, unit = string.match(etaStr, "(%d+)%s*(.*)")
if num then
num = tonumber(num)
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
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))
Network.send({
type = "train_approaching",
stationName = State.stationName,
stationCode = State.stationCode,
stationPlatform = platform or State.stationPlatform or "1",
trainId = tName,
destination = dest,
eta = etaStr,
etaSeconds = etaSeconds,
delay = delay
})
end
end
if etaSeconds >= 0 and etaSeconds <= 45 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) + 3 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))
Network.send({
type = "train_approaching",
stationName = State.stationName,
stationCode = State.stationCode,
stationPlatform = platform or State.stationPlatform or "1",
trainId = tName,
destination = dest,
eta = etaStr,
etaSeconds = etaSeconds,
delay = delay
})
end
else
State.pendingApproaching[announceKey] = nil
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
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
State.currentTrain = {
id = id,
trainType = Utils.parseTrainId(id),
schedule = schedule,
dest = dest,
nextStation = nextStation,
arrivalTime = now,
departureTime = now + stationTime,
platform = Utils.getPlatformNumber(State.stationName, "1"),
departureSent = false
}
Network.send({
type = "train_arrival",
stationName = State.stationName,
stationCode = State.stationCode,
stationPlatform = State.stationPlatform,
trainId = id,
destination = dest,
nextStation = nextStation,
departureTime = State.currentTrain.departureTime
})
else
local now = os.epoch("utc") / 1000
if not State.currentTrain.departureSent and (State.currentTrain.departureTime - now) <= 10 then
State.currentTrain.departureSent = true
Network.send({
type = "train_departure",
stationName = State.stationName,
stationCode = State.stationCode,
stationPlatform = State.stationPlatform,
trainId = State.currentTrain.id,
destination = State.currentTrain.dest,
nextStation = State.currentTrain.nextStation,
departureBeep = State.departureBeep
})
end
end
else
if State.currentTrain then
State.currentTrain = nil
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
)