-- ============================================================================ -- 1. Configuration & Constants -- ============================================================================ local APP_VERSION_TEXT = "v1.0.16" 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.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() 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() 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() 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() 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() -- 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 (" .. 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 -- 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 == "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 = {} 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 )