Files
CC-StationBroadcast/station_broadcast.lua
T

2194 lines
77 KiB
Lua

-- ============================================================================
-- 1. Configuration & Constants
-- ============================================================================
local APP_VERSION_TEXT = "v1.0.14"
local CONFIG = {
CLOUD_URL = "ws://stabadmin.fse-media.group",
API_URL = "http://stabadmin.fse-media.group",
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 = 3,
AUDIO_STREAM_PREROLL_CHUNKS = 2,
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
}
-- ============================================================================
-- 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
-- Create Wiki documents the station peripheral by its methods rather than a
-- guaranteed fixed type string. Match on the core method set so attached
-- stations are still detected across integration/version differences.
return type(wrapped.getStationName) == "function"
and type(wrapped.isTrainPresent) == "function"
and type(wrapped.getTrainName) == "function"
and type(wrapped.getSchedule) == "function"
end
function Peripherals.init()
-- Station Peripheral: prefer direct sides, then fall back to any discovered peripheral name.
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
-- Monitors
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
-- Speakers
Peripherals.speakers = { peripheral.find("speaker") }
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 user is scrolling history, increment offset to stay on same items
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.promptSlaveIdentity()
while true do
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(30, 6)
term.setTextColor(colors.cyan)
local stationCode = Utils.trim(read() or ""):lower()
term.setCursorPos(31, 8)
local stationPlatform = Utils.trim(read() or "")
if stationCode == "" or stationPlatform == "" then
term.setCursorPos(1, 10)
term.setTextColor(colors.red)
term.write("Both station code and platform are required.")
term.setCursorPos(1, 12)
term.setTextColor(colors.lightGray)
term.write("Press any key to retry...")
os.pullEvent("key")
else
local ok = Utils.saveStationIdentity(stationCode, stationPlatform, nil)
if ok then
term.setCursorPos(1, 10)
term.setTextColor(colors.green)
term.write("Saved to " .. CONFIG.STATION_IDENTITY_FILE)
term.setCursorPos(1, 12)
term.setTextColor(colors.lightGray)
term.write("Press any key to continue...")
os.pullEvent("key")
term.setBackgroundColor(colors.black)
term.setTextColor(colors.white)
term.clear()
return true
end
term.setCursorPos(1, 10)
term.setTextColor(colors.red)
term.write("Failed to save " .. CONFIG.STATION_IDENTITY_FILE)
term.setCursorPos(1, 12)
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() -- Always return lowercase for consistent comparison
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
-- Consist Car Match: "CK 1 01" vs "CK 1 02"
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
-- Fuzzy match: remove type prefix and compare content
local _, name1, num1 = Utils.parseTrainId(id1)
local _, name2, num2 = Utils.parseTrainId(id2)
-- If both have numbers, compare name and number
if num1 ~= "" and num2 ~= "" then
return name1 == name2 and num1 == num2
end
-- Fallback to comparing just the main content/name
return name1 == name2
end
function Utils.parseTrainId(trainId)
if not trainId then return "unknown", "" end
-- Generic match: [Type]Content
local typeStr, content = trainId:match("^%[(.-)%](.+)$")
if typeStr then
-- Check for Express types
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
-- Handle both wrapped and unwrapped schedule structure
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
-- Substring match for base names
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
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" 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 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 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
-- Support both static string data and streaming functions
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
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
if #Peripherals.speakers > 1 then
playOk = queueSamplesSynchronized(samples, CONFIG.AUDIO_VOLUME, playSeq)
else
playOk = playChunkOnSpeakers(samples, CONFIG.AUDIO_VOLUME, playSeq, false)
end
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
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 (e.g., "1A", "2B")
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()) -- e.g. "A", "B"
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
-- Fallback to hardcoded pattern
return langPrefix .. code
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()
-- Sync only config file (list.txt) periodically
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
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 -- Clear partial download
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()
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
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
end
-- Logs
local y = h - 3
for i = 1, 3 do
local logIdx = i + State.logScrollOffset
local entry = State.logs[logIdx]
if entry then
mon.setCursorPos(1, y + i - 1)
mon.setTextColor(COLORS[entry.level] or colors.white)
mon.write(entry.text:sub(1, w))
end
end
end
function Display.renderTerminal()
local w, h = term.getSize()
-- Header
term.setCursorPos(1, 1)
term.setBackgroundColor(colors.gray)
term.setTextColor(colors.white)
term.clearLine()
local title = " Station Broadcast Console (" .. State.stationName .. " " .. tostring(State.stationCode) .. "/P" .. tostring(State.stationPlatform) .. " / " .. APP_VERSION_TEXT .. ") "
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
-- Simple touch to scroll: top half of log area scrolls up, bottom half scrolls down
local _, monH = Peripherals.primaryMonitor.getSize()
local y = param3
if y >= monH - 3 then
if y == monH - 3 then -- Top line of log area
State.logScrollOffset = math.min(State.logScrollOffset + 1, math.max(0, #State.logs - 3))
elseif y == monH then -- Bottom line of log area
State.logScrollOffset = math.max(0, State.logScrollOffset - 1)
end
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)
end
Display.renderTerminal()
Display.render()
end
end
end
-- ============================================================================
-- 8. Main Logic
-- ============================================================================
local Main = {}
function Main.loop()
while true do
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
-- ============================================================================
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
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
)