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