-- ============================================================================ -- 1. Configuration & Constants -- ============================================================================ local CONFIG = { CLOUD_URL = "ws://stabadmin.fse-media.group", API_URL = "http://stabadmin.fse-media.group", SYNC_INTERVAL = 30, AUDIO_BASE = "Audio/", AUDIO_STATION = "Audio/Station/", DISK_AUDIO_BASE = "disk/Audio/", DISK_AUDIO_STATION = "disk/Audio/Station/", AUDIO_VOLUME = 3, DEBUG = true } local dfpwm = require "cc.audio.dfpwm" local COLORS = { INFO = colors.white, WARN = colors.yellow, WARNING = colors.yellow, ERROR = colors.red, DEBUG = colors.cyan, SUCCESS = colors.green } -- ============================================================================ -- 2. State Management -- ============================================================================ local State = { ws = nil, stationName = "Unknown", stationCode = "UNK", stationPlatform = "1", registered = false, stationList = {}, currentTrain = nil, logs = {}, logScrollOffset = 0, departingPlayed = false, arrivalTonePlayed = false, shortStopMode = false, audioRx = nil, audioQueue = {}, currentPlaying = nil, currentPriority = -1, interruptSeq = 0, lastArrivalPlayAt = 0 } -- ============================================================================ -- 3. Peripherals Setup -- ============================================================================ local Peripherals = {} function Peripherals.init() -- Station Peripheral Peripherals.station = peripheral.wrap("back", "train_station") or error("Train station peripheral required on 'back'") -- Monitors local monitors = { peripheral.find("monitor") } if #monitors == 0 then error("At least one monitor is required") end -- Assign monitors Peripherals.primaryMonitor = monitors[1] Peripherals.primaryMonitor.setTextScale(0.5) -- Speakers Peripherals.speakers = { peripheral.find("speaker") } end -- ============================================================================ -- 4. Utility Functions -- ============================================================================ local Utils = {} function Utils.log(level, message) local timestamp = os.date("%H:%M:%S") local entryText = string.format("[%s][%s] %s", timestamp, level, message) local entry = { level = level, text = entryText } table.insert(State.logs, 1, entry) if #State.logs > 100 then table.remove(State.logs) end -- If user is scrolling history, increment offset to stay on same items if State.logScrollOffset > 0 then State.logScrollOffset = math.min(State.logScrollOffset + 1, math.max(0, #State.logs - 1)) end end function Utils.getStationCode(name) if not name then return nil end name = tostring(name) local code = nil local codeInParens = name:match("[%[%(]([%w%d%-%/%s]+)[%]%)]") if codeInParens then local firstPart = codeInParens:match("[^/]+") if firstPart then code = firstPart:match("^%s*(.-)%s*$") end end if not code then code = name:match("[%w%d]+%-[%w%d]+") or name:match("[%a]+[%d]+") end if not code then local cleanName = name:match("^%s*(.-)%s*$"):lower() if State.stationList and State.stationList[cleanName] then code = cleanName end end if code then return tostring(code):lower() -- Always return lowercase for consistent comparison end return nil end function Utils.getPlatformNumber(name, default) if not name then return default end local p = name:match("[Pp]latform%s*([%d]+[a-zA-Z]?)") or name:match("[Tt]rack%s*([%d]+[a-zA-Z]?)") if p then return p end return name:match("([%d]+[a-zA-Z]?)$") or default end function Utils.getBroadcastStationCode(name) if not name then return "UNK" end name = name:match("^%s*(.-)%s*$") name = name:gsub("%s+", "_") if name == "" then return "UNK" end return name end function Utils.isSameTrain(id1, id2) if not id1 or not id2 then return false end if id1 == id2 then return true end -- Consist Car Match: "CK 1 01" vs "CK 1 02" local function getConsist(id) local clean = id:gsub("^%[.-%]%s*", "") local p = {} for s in clean:gmatch("%S+") do table.insert(p, s) end if #p >= 2 then if #p[1] <= 4 and tonumber(p[2]) then return p[1] .. " " .. p[2] end end return p[1] or clean end local c1 = getConsist(id1) local c2 = getConsist(id2) if c1 == c2 then return true end -- Fuzzy match: remove type prefix and compare content local _, name1, num1 = Utils.parseTrainId(id1) local _, name2, num2 = Utils.parseTrainId(id2) -- If both have numbers, compare name and number if num1 ~= "" and num2 ~= "" then return name1 == name2 and num1 == num2 end -- Fallback to comparing just the main content/name return name1 == name2 end function Utils.parseTrainId(trainId) if not trainId then return "unknown", "" end -- Generic match: [Type]Content local typeStr, content = trainId:match("^%[(.-)%](.+)$") if typeStr then -- Check for Express types if typeStr == "Ltd.Exp" or typeStr == "Express" then local name, num = content:match("^(.+) No%.(%d+)$") if name then return "express", name, num else return "express", content, "" end elseif typeStr == "Local" then return "regular", "Local", content else return "regular", typeStr, content end end return "unknown", "Unknown", trainId end function Utils.getDestination(schedule) if not schedule then return "Unknown" end -- Handle both wrapped and unwrapped schedule structure local entries = schedule.entries or (type(schedule) == "table" and schedule) if not entries or type(entries) ~= "table" then return "Unknown" end if #entries == 0 then local count = 0 for _ in pairs(entries) do count = count + 1 end if count > 0 then local list = {} for k, v in pairs(entries) do local n = tonumber(k) if n then list[n] = v end end entries = list end end local dest = "Unknown" local maxIdx = 0 for k, _ in pairs(entries) do if type(k) == "number" and k > maxIdx then maxIdx = k end end for i = 1, maxIdx do local entry = entries[i] if entry then local inst = entry.instruction if inst then if inst.id == "create:destination" then dest = (inst.data and inst.data.text) or inst.text or dest end if inst.id == "create:station" then local sName = (inst.data and inst.data.text) or inst.text if sName then dest = sName end end end end end return dest end function Utils.getBaseName(name) if not name then return "" end name = tostring(name):lower() name = name:gsub("%s*[Pp]latform%s*[%d%a]+", ""):gsub("%s*[Tt]rack%s*[%d%a]+", "") name = name:gsub("%s*[%[%(].-[%]%)]%s*", " ") name = name:gsub("%s+%d+$", "") return name:match("^%s*(.-)%s*$") end function Utils.isFuzzyMatch(str, pattern) if not str or not pattern then return false end str = tostring(str):lower():match("^%s*(.-)%s*$") pattern = tostring(pattern):lower():match("^%s*(.-)%s*$") if str == pattern then return true end -- 1. Handle Create Mod Wildcards (*) if pattern:find("*") then local p = pattern:gsub("([%^%$%(%)%%%.%[%]%+%-%?])", "%%%1") p = p:gsub("%%%*", ".*") local ok, res = pcall(function() return str:match("^" .. p .. "$") end) if ok and res then return true end end -- 2. Base name comparison local b1 = Utils.getBaseName(str) local b2 = Utils.getBaseName(pattern) if b1 ~= "" and b2 ~= "" then if b1 == b2 then return true end -- Substring match for base names if #b1 >= 4 and #b2 >= 4 then if b1:find(b2, 1, true) or b2:find(b1, 1, true) then return true end end end -- 3. Alphanumeric only comparison local a1 = str:gsub("[^%w%d]", "") local a2 = pattern:gsub("[^%w%d]", "") if a1 ~= "" and a2 ~= "" and (a1 == a2 or a1:find(a2, 1, true) or a2:find(a1, 1, true)) then return true end return false end function Utils.getStopTime(schedule, stationName) if not schedule or not schedule.entries or not stationName then return nil end local target = tostring(stationName):lower():match("^%s*(.-)%s*$") if target == "" then return nil end local targetCode = Utils.getStationCode(target) local targetBase = Utils.getBaseName(target) local targetAlpha = target:gsub("[^%w%d]", "") Utils.log("DEBUG", string.format("STATION TARGET: '%s' (Code: %s, Base: %s, Alpha: %s)", target, tostring(targetCode), targetBase, targetAlpha)) local function extractTime(data) if not data then return nil end local val = tonumber(data.value) or tonumber(data.seconds) or tonumber(data.s) or tonumber(data.time) or tonumber(data.duration) or tonumber(data.delay) local unit = tonumber(data.time_unit) or tonumber(data.unit) or 1 if val then if unit == 0 then return val / 20 elseif unit == 1 then return val elseif unit == 2 then return val * 60 end return val end local ticks = tonumber(data.ticks) or tonumber(data.tick) if ticks then return ticks / 20 end return nil end local function findTimeInConditions(conditions) if not conditions then return nil end for i, item in ipairs(conditions) do if type(item) == "table" then local cid = tostring(item.id or ""):lower() if cid:find("delay") or cid:find("wait") or cid:find("idle") or cid:find("pause") then local s = extractTime(item.data) if s and s > 0 then Utils.log("DEBUG", string.format("Found Time in Condition [%d]: %.1fs (ID: %s)", i, s, cid)) return s end end for k, sub in pairs(item) do if type(sub) == "table" then local s = findTimeInConditions({sub}) if s then return s end end end end end return nil end local maxIdx = 0 if schedule.entries then for k, _ in pairs(schedule.entries) do if type(k) == "number" and k > maxIdx then maxIdx = k end end end Utils.log("DEBUG", string.format("--- Schedule Scan Start (MaxIdx: %d) ---", maxIdx)) local matchFoundIndex = nil for i = 1, maxIdx do local entry = schedule.entries[i] if entry then local function scanRecursive(obj, depth) if depth > 5 or not obj then return false end for k, v in pairs(obj) do if type(v) == "string" then local entryText = v:lower() local entryCode = Utils.getStationCode(entryText) local entryAlpha = entryText:gsub("[^%w%d]", "") local entryBase = Utils.getBaseName(entryText) Utils.log("DEBUG", string.format(" [%d] SCAN '%s': '%s' (Code: %s)", i, k, entryText, tostring(entryCode))) local isMatch = (target == entryText) or (targetCode and entryCode and targetCode == entryCode) or (targetAlpha ~= "" and entryAlpha ~= "" and (targetAlpha == entryAlpha or targetAlpha:find(entryAlpha, 1, true) or entryAlpha:find(targetAlpha, 1, true))) or (targetBase ~= "" and entryBase ~= "" and (targetBase == entryBase or targetBase:find(entryBase, 1, true) or entryBase:find(targetBase, 1, true))) or Utils.isFuzzyMatch(target, entryText) if isMatch then Utils.log("DEBUG", string.format(" >>> MATCH FOUND AT ENTRY %d (via field '%s')", i, k)) return true end elseif type(v) == "table" then if scanRecursive(v, depth + 1) then return true end end end return false end if scanRecursive(entry, 0) then matchFoundIndex = i local s = findTimeInConditions(entry.conditions) if s then Utils.log("DEBUG", string.format(" >>> TIME RESOLVED FROM CONDITIONS: %.1fs", s)) return s end if entry.instruction and entry.instruction.data then local s = extractTime(entry.instruction.data) if s then Utils.log("DEBUG", string.format(" >>> TIME RESOLVED FROM INSTRUCTION: %.1fs", s)) return s end end break end end end if matchFoundIndex then Utils.log("DEBUG", string.format(" >>> Station matched at [%d], scanning ahead for time...", matchFoundIndex)) for i = matchFoundIndex + 1, maxIdx do local entry = schedule.entries[i] if entry then local isOtherStation = false local function checkIsStation(obj, depth) if depth > 2 or not obj then return false end for k, v in pairs(obj) do if type(v) == "string" then local lk = tostring(k):lower() if lk:find("text") or lk:find("station") or lk:find("destination") then return true end elseif type(v) == "table" then if checkIsStation(v, depth + 1) then return true end end end return false end if checkIsStation(entry, 0) then break end local s = findTimeInConditions(entry.conditions) if s then Utils.log("DEBUG", string.format(" >>> TIME FOUND AT LATER ENTRY [%d]: %.1fs", i, s)) return s end if entry.instruction and entry.instruction.data then local s = extractTime(entry.instruction.data) if s then Utils.log("DEBUG", string.format(" >>> TIME FOUND AT LATER INSTRUCTION [%d]: %.1fs", i, s)) return s end end end end end Utils.log("DEBUG", "--- Scan End: No Time Resolved ---") return nil end -- ============================================================================ -- 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 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("%.wav$", ""):gsub("%.dfpwm$", "") 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 playChunkOnSpeakers(samples, volume, playSeq) if not Peripherals.speakers or #Peripherals.speakers == 0 then return false end local controller = Peripherals.speakers[1] if controller and type(controller.playAudioAll) == "function" then while true do if shouldStop(playSeq) then return false end local ok = false pcall(function() ok = controller.playAudioAll(samples, volume) end) if ok then break end local ev = { os.pullEvent() } if ev[1] == "audio_interrupt" and tonumber(ev[2]) and tonumber(ev[2]) >= playSeq then return false end end else local pending = {} for _, sp in ipairs(Peripherals.speakers) do local name = peripheral.getName(sp) if name then pending[name] = sp end end while next(pending) do if shouldStop(playSeq) then return false end for name, sp in pairs(pending) do local ok = false pcall(function() ok = sp.playAudio(samples, volume) end) if ok then pending[name] = nil end end if next(pending) then local ev = { os.pullEvent() } if ev[1] == "audio_interrupt" and tonumber(ev[2]) and tonumber(ev[2]) >= playSeq then return false end end end end local waiting = {} for _, sp in ipairs(Peripherals.speakers) do local name = peripheral.getName(sp) if name then waiting[name] = true end end while next(waiting) do if shouldStop(playSeq) then return false end local ev = { os.pullEvent() } if ev[1] == "audio_interrupt" and tonumber(ev[2]) and tonumber(ev[2]) >= playSeq then return false end if ev[1] == "speaker_audio_empty" and ev[2] and waiting[ev[2]] then waiting[ev[2]] = nil end end return true end local function decodePcmS8Chunk(chunk) if type(chunk) ~= "string" or #chunk == 0 then return nil end local samples = {} for i = 1, #chunk do local b = string.byte(chunk, i) if b >= 128 then b = b - 256 end samples[i] = b end return samples end local function areHqSpeakersBusy(speakers) for _, sp in ipairs(speakers) do local playing = false local queued = 0 pcall(function() if type(sp.speakIsPlaying) == "function" then playing = sp.speakIsPlaying() and true or false end if type(sp.speakQueueSize) == "function" then queued = tonumber(sp.speakQueueSize()) or 0 end end) if playing or queued > 0 then return true end end return false end local function downloadAudioBytes(url) if type(url) ~= "string" or url == "" then return nil, nil, "empty url" end local ok, resp = pcall(function() return http.get(url, nil, true) end) if not ok or not resp then return nil, nil, "http.get failed" end local body = resp.readAll() resp.close() if type(body) ~= "string" or #body == 0 then return nil, nil, "empty response body" end local lower = url:lower() local ext = lower:match("%.([%a%d]+)$") return body, ext or "file", nil end local function playHqAudioItem(item) if not Peripherals.speakers or #Peripherals.speakers == 0 then Utils.log("WARN", "No speakers found; cannot play audio") return end local playSeq = State.interruptSeq local payload = item.audioData if type(payload) ~= "string" or #payload == 0 then return end local format = tostring(item.audioFormat or "file"):lower() if format == "stream" then local bytes, ext, err = downloadAudioBytes(payload) if not bytes then Utils.log("ERROR", "Failed to download stream audio: " .. tostring(err)) return end Utils.log("INFO", "Downloaded stream audio: " .. tostring(#bytes) .. " bytes") payload = bytes if ext == "wav" then format = "wav" elseif ext == "mp3" then format = "mp3" elseif ext == "ogg" then format = "ogg" else format = "file" end end local controller = Peripherals.speakers[1] local called = false local loopEnabled = item.loop and true or false if controller then pcall(function() if type(controller.setLoopingAll) == "function" then controller.setLoopingAll(loopEnabled) end end) local ok, result = pcall(function() if format == "hls" and type(controller.speakHLSAll) == "function" then return controller.speakHLSAll(payload, CONFIG.AUDIO_VOLUME) elseif format == "wav" and type(controller.speakWavAll) == "function" then return controller.speakWavAll(payload, CONFIG.AUDIO_VOLUME) elseif format == "mp3" and type(controller.speakMp3All) == "function" then return controller.speakMp3All(payload, CONFIG.AUDIO_VOLUME) elseif format == "ogg" and type(controller.speakOggAll) == "function" then return controller.speakOggAll(payload, CONFIG.AUDIO_VOLUME) elseif type(controller.speakFileAll) == "function" then return controller.speakFileAll(payload, CONFIG.AUDIO_VOLUME) end end) if ok and result then called = true end end if not called then for _, sp in ipairs(Peripherals.speakers) do pcall(function() if type(sp.setLooping) == "function" then sp.setLooping(loopEnabled) end end) local ok, result = pcall(function() if format == "hls" and type(sp.speakHLS) == "function" then return sp.speakHLS(payload, CONFIG.AUDIO_VOLUME) elseif format == "wav" and type(sp.speakWav) == "function" then return sp.speakWav(payload, CONFIG.AUDIO_VOLUME) elseif format == "mp3" and type(sp.speakMp3) == "function" then return sp.speakMp3(payload, CONFIG.AUDIO_VOLUME) elseif format == "ogg" and type(sp.speakOgg) == "function" then return sp.speakOgg(payload, CONFIG.AUDIO_VOLUME) elseif type(sp.speakFile) == "function" then return sp.speakFile(payload, CONFIG.AUDIO_VOLUME) end end) if ok and result then called = true end end end if not called then Utils.log("ERROR", "HQ speaker did not accept audio for format " .. format) return end while areHqSpeakersBusy(Peripherals.speakers) do if shouldStop(playSeq) then Audio.stop("current") return end if not waitForInterruptibleDelay(playSeq, 0.1) then Audio.stop("current") return end end end function Audio.playDfpwmItem(item) if not Peripherals.speakers or #Peripherals.speakers == 0 then Utils.log("WARN", "No speakers found; cannot play audio") return end local playSeq = State.interruptSeq local source = item.audioData -- Support both static string data and streaming functions if type(source) == "table" and source.read then source = source.read end if type(source) ~= "function" then local data = source source = function() local chunk = data data = nil return chunk end end local decoder = dfpwm.make_decoder() while true do if shouldStop(playSeq) then break end local chunk = source() if chunk == nil then break end if type(chunk) == "string" and #chunk > 0 then local samples = decoder(chunk) if samples and not playChunkOnSpeakers(samples, CONFIG.AUDIO_VOLUME, playSeq) then break end end end end function Audio.playPcmItem(item) if not Peripherals.speakers or #Peripherals.speakers == 0 then Utils.log("WARN", "No speakers found; cannot play audio") return end local playSeq = State.interruptSeq local source = item.audioData if type(source) == "table" and source.read then source = source.read end if type(source) ~= "function" then local data = source source = function() local chunk = data data = nil return chunk end end while true do if shouldStop(playSeq) then break end local chunk = source() if chunk == nil then break end if type(chunk) == "string" and #chunk > 0 then local samples = decodePcmS8Chunk(chunk) if samples and not playChunkOnSpeakers(samples, CONFIG.AUDIO_VOLUME, playSeq) then break end end end end function Audio.playItem(item) local format = tostring(item.audioFormat or "dfpwm"):lower() if format == "wav" or format == "file" or format == "stream" or format == "hls" then return playHqAudioItem(item) elseif format == "pcm" then return Audio.playPcmItem(item) end return Audio.playDfpwmItem(item) end function Audio.playerLoop() while true do if #State.audioQueue == 0 then os.pullEvent("audio_queue") else local item = table.remove(State.audioQueue, 1) State.currentPlaying = item State.currentPriority = tonumber(item.priority) or 0 local ok, err = pcall(Audio.playItem, item) if not ok then Utils.log("ERROR", "Audio Playback Crash: " .. tostring(err)) end State.currentPlaying = nil State.currentPriority = -1 end end end function Utils.getChineseNumberAudio(val) local num = tonumber(val) if num then local seq = {} if num < 10 then table.insert(seq, "ch-" .. num) elseif num == 10 then table.insert(seq, "ch-10") elseif num > 10 and num < 20 then table.insert(seq, "ch-10") table.insert(seq, "ch-" .. (num % 10)) elseif num >= 20 and num < 100 then local ten = math.floor(num / 10) local unit = num % 10 table.insert(seq, "ch-" .. ten) table.insert(seq, "ch-10") if unit > 0 then table.insert(seq, "ch-" .. unit) end else table.insert(seq, "ch-" .. num) end return seq else -- Handle Alphanumeric (e.g., "1A", "2B") local digit, letter = tostring(val):match("^(%d+)([a-zA-Z])$") if digit and letter then local seq = Utils.getChineseNumberAudio(digit) table.insert(seq, letter:upper()) -- e.g. "A", "B" return seq end return {tostring(val)} end end function Utils.getStationAudio(code, langPrefix) if not code then return nil end local files = State.stationList[code] if files then local p = langPrefix:lower() for _, f in ipairs(files) do if f:lower():find("^" .. p) then return f end end end -- Fallback to hardcoded pattern return langPrefix .. code end function Audio.announceArrival(trainId, trainType, platform, dest, useAltJz3) local seq = {} Utils.log("DEBUG", string.format("Announcing Arrival: %s, Type: %s, Plat: %s, Dest: %s", tostring(trainId), tostring(trainType), tostring(platform), tostring(dest))) table.insert(seq, "ksts") -- Chime table.insert(seq, "__DELAY_1__") local destCode = Utils.getStationCode(dest) if (not destCode or destCode == "unknown") and dest ~= "Unknown" then -- Fallback to broadcast code if regular code failed destCode = Utils.getBroadcastStationCode(dest) Utils.log("DEBUG", "Station code fallback for " .. tostring(dest) .. ": " .. tostring(destCode)) end Utils.log("DEBUG", "Final destination code for " .. tostring(dest) .. ": " .. tostring(destCode)) local chJz3 = useAltJz3 and "ch-jz3-1" or "ch-jz3" local enJz3 = useAltJz3 and "en-jz3-1" or "en-jz3" -- Chinese table.insert(seq, "ch-jz1") if destCode then table.insert(seq, Utils.getStationAudio(destCode, "ch-")) end table.insert(seq, "ch-jz2") table.insert(seq, trainType == "express" and "ch-tj" or "ch-pt") table.insert(seq, chJz3) -- Platform Number (Chinese) local pAudio = Utils.getChineseNumberAudio(platform) for _, a in ipairs(pAudio) do table.insert(seq, a) end table.insert(seq, "ch-zt") table.insert(seq, "ch-jz4") -- English table.insert(seq, "en-jz1") table.insert(seq, trainType == "express" and "en-tj" or "en-pt") table.insert(seq, "en-jz2") if destCode then table.insert(seq, Utils.getStationAudio(destCode, "en-")) end table.insert(seq, enJz3) table.insert(seq, "en-zt") -- English Platform Number local pDigit, pLetter = tostring(platform):match("^(%d+)([a-zA-Z])$") if pDigit and pLetter then table.insert(seq, "en-" .. pDigit) table.insert(seq, pLetter:upper()) else table.insert(seq, "en-" .. platform) end table.insert(seq, "en-jz4") Audio.play(seq) end function Audio.announceDeparture(trainId, trainType, platform, dest, time) local seq = {} table.insert(seq, "fcts") table.insert(seq, "__DELAY_1__") local destCode = Utils.getStationCode(dest) -- Chinese -- Platform Number local pAudio = Utils.getChineseNumberAudio(platform) for _, a in ipairs(pAudio) do table.insert(seq, a) end table.insert(seq, "ch-zt") table.insert(seq, "ch-fc1") table.insert(seq, trainType == "express" and "ch-tj" or "ch-pt") table.insert(seq, "ch-fc2") if destCode then table.insert(seq, Utils.getStationAudio(destCode, "ch-")) end table.insert(seq, "ch-fc3") -- English table.insert(seq, "en-fc1") table.insert(seq, trainType == "express" and "en-tj" or "en-pt") table.insert(seq, "en-fc2") table.insert(seq, "en-zt") -- English Platform Number local pDigit, pLetter = tostring(platform):match("^(%d+)([a-zA-Z])$") if pDigit and pLetter then table.insert(seq, "en-" .. pDigit) table.insert(seq, pLetter:upper()) else table.insert(seq, "en-" .. platform) end table.insert(seq, "en-fc3") if destCode then table.insert(seq, Utils.getStationAudio(destCode, "en-")) end table.insert(seq, "en-fc4") Audio.play(seq) end -- ============================================================================ -- 6. Network System -- ============================================================================ local Network = {} function Audio.play(files) if not State.ws then Utils.log("WARN", "Cannot play audio: Not connected to server") return end Utils.log("INFO", "Requesting server to play sequence...") Network.send({ type = "play_sequence", stationName = State.stationName, seq = files, category = "train", priority = 1, requestId = tostring(os.epoch("utc")) .. "-" .. tostring(math.random(100000, 999999)) }) end 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() State.stationName = (Peripherals.station.getStationName() or "Unknown"):match("^%s*(.-)%s*$") -- Trim -- Try to get a clean code from name, fallback to broadcast format State.stationCode = Utils.getStationCode(State.stationName) or Utils.getBroadcastStationCode(State.stationName) State.stationPlatform = Utils.getPlatformNumber(State.stationName, "1") Network.send({ type = "station_register", stationName = State.stationName, stationCode = State.stationCode, stationPlatform = State.stationPlatform }) end function Network.syncFiles() -- Sync only config file (list.txt) periodically while true do local resp = http.get(CONFIG.API_URL .. "/list.txt") if not resp then resp = http.get(CONFIG.API_URL .. "/uploads/list.txt") end if resp then local content = resp.readAll() resp.close() if content and #content > 0 then local currentList = "" if fs.exists("list.txt") then local f = fs.open("list.txt", "r") currentList = f.readAll() f.close() end if currentList ~= content then local f = fs.open("list.txt", "w") f.write(content) f.close() Audio.loadStationList() end end end os.sleep(CONFIG.SYNC_INTERVAL) end end function Network.messageLoop() local function finishAudioRx() if not State.audioRx then return end local rx = State.audioRx if rx.stream then rx.stream.finish() else Audio.enqueueItem({ audioData = table.concat(rx.chunks or {}), audioFormat = rx.audioFormat or "wav", priority = rx.priority, loop = rx.loop and true or false, source = rx.source, audioId = rx.audioId }) end State.audioRx = nil end while true do local event, url, msg = os.pullEvent() if event == "websocket_message" and url == CONFIG.CLOUD_URL then local ok, data = pcall(textutils.unserializeJSON, msg) if ok and type(data) == "table" then if data.type == "audio_start" then if State.audioRx and State.audioRx.stream then pcall(State.audioRx.stream.finish) end local priority = tonumber(data.priority) or 0 local audioFormat = tostring(data.audioFormat or "dfpwm"):lower() local streamUrl = type(data.streamUrl) == "string" and data.streamUrl or nil local interruptMode = tostring(data.interruptMode or "") if interruptMode == "" then if priority >= 2 then interruptMode = "all" elseif priority >= 1 then interruptMode = "ifLower" else interruptMode = "none" end end if interruptMode == "all" then Audio.stop("all") elseif interruptMode == "ifLower" and priority > (State.currentPriority or -1) then Audio.stop("current", priority) end State.audioRx = { audioId = data.audioId, expectedBytes = tonumber(data.byteLength) or 0, receivedBytes = 0, priority = priority, loop = data.loop and true or false, source = data.source or data.category, audioFormat = audioFormat } if (audioFormat == "stream" or audioFormat == "hls") and streamUrl and streamUrl ~= "" then Audio.enqueueItem({ audioData = streamUrl, audioFormat = audioFormat, priority = priority, loop = State.audioRx.loop, source = State.audioRx.source, audioId = data.audioId }) State.audioRx = nil elseif audioFormat == "dfpwm" or audioFormat == "pcm" then local stream = Audio.createStream() State.audioRx.stream = stream if audioFormat == "pcm" then Utils.log("INFO", "Receiving live PCM audio 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 if State.audioRx and tostring(State.audioRx.source or "") == "music" then State.audioRx = nil end if State.currentPlaying and tostring(State.currentPlaying.source or "") == "music" then Audio.stop("current") end else local mode = tostring(data.mode or "current") local flushBelow = data.flushBelow Audio.stop(mode, flushBelow) end end else if State.audioRx then -- Memory protection: limit to 10MB per audio item local isLiveStream = State.audioRx.stream and (tonumber(State.audioRx.expectedBytes) or 0) <= 0 if (not isLiveStream) and State.audioRx.receivedBytes + #msg > 10 * 1024 * 1024 then Utils.log("ERROR", "Audio item too large, discarding") if State.audioRx.stream then State.audioRx.stream.finish() end State.audioRx = nil else if State.audioRx.stream then State.audioRx.stream.push(msg) else table.insert(State.audioRx.chunks, msg) end State.audioRx.receivedBytes = State.audioRx.receivedBytes + #msg if State.audioRx.expectedBytes > 0 and State.audioRx.receivedBytes >= State.audioRx.expectedBytes then finishAudioRx() end end end end elseif event == "websocket_closed" then Utils.log("WARN", "WS Closed") State.ws = nil State.audioRx = nil -- Clear partial download os.sleep(5) end end end function Network.heartbeat() while true do if State.ws then Network.send({ type = "heartbeat", stationName = State.stationName, stationCode = State.stationCode, stationPlatform = State.stationPlatform }) end os.sleep(10) end end -- ============================================================================ -- 7. Display System -- ============================================================================ local Display = {} function Display.render() local mon = Peripherals.primaryMonitor local w, h = mon.getSize() mon.clear() mon.setCursorPos(1,1) mon.setTextColor(COLORS.INFO) mon.write("STATION: " .. State.stationName) mon.setCursorPos(1,2) mon.write("TIME: " .. os.date("%H:%M:%S")) local function classifyAudioSource(source, priority) local s = tostring(source or ""):lower() if s == "train" or s == "station" or s == "broadcast" then return "Station Announcement" end if s ~= "" then return "Special Announcement(" .. tostring(source) .. ")" end if (tonumber(priority) or 0) >= 2 then return "Special Announcement" end return "Special Announcement" end local function trunc(str) str = tostring(str or "") if #str > w then return str:sub(1, w) end return str end mon.setCursorPos(1,3) if State.currentPlaying then local p = tonumber(State.currentPlaying.priority) or 0 local kind = classifyAudioSource(State.currentPlaying.source, p) mon.setTextColor(COLORS.SUCCESS) mon.write(trunc("AUDIO: " .. kind .. " Playing (P=" .. tostring(p) .. ", Q=" .. tostring(#State.audioQueue) .. ")")) elseif State.audioRx then local p = tonumber(State.audioRx.priority) or 0 local kind = classifyAudioSource(State.audioRx.source, p) local rx = tonumber(State.audioRx.receivedBytes) or 0 local total = tonumber(State.audioRx.expectedBytes) or 0 local pct = "" if total > 0 then pct = " " .. tostring(math.floor((rx / total) * 100)) .. "%" end mon.setTextColor(COLORS.DEBUG) mon.write(trunc("AUDIO: " .. kind .. " Receiving" .. pct .. " (Q=" .. tostring(#State.audioQueue) .. ")")) else mon.setTextColor(COLORS.INFO) mon.write(trunc("AUDIO: FREE (Q=" .. tostring(#State.audioQueue) .. ")")) end -- Current Train if State.currentTrain then mon.setCursorPos(1,4) mon.setTextColor(COLORS.SUCCESS) mon.write("CURRENT TRAIN:") mon.setTextColor(COLORS.INFO) mon.setCursorPos(1,5) mon.write(State.currentTrain.id) mon.setCursorPos(1,6) mon.write("Dest: " .. State.currentTrain.dest) -- Countdown if State.currentTrain.departureTime then local remaining = math.max(0, State.currentTrain.departureTime - os.epoch("utc") / 1000) mon.setCursorPos(1, 8) if remaining > 0 then mon.setTextColor(COLORS.WARNING) mon.write(string.format("Departing in: %02d:%02d", math.floor(remaining/60), math.floor(remaining%60))) else mon.setTextColor(COLORS.ERROR) mon.write("Departing...") end end else mon.setCursorPos(1,4) mon.setTextColor(COLORS.WARNING) mon.write("NO TRAIN AT PLATFORM") end -- Logs local y = h - 3 for i = 1, 3 do local logIdx = i + State.logScrollOffset local entry = State.logs[logIdx] if entry then mon.setCursorPos(1, y + i - 1) mon.setTextColor(COLORS[entry.level] or colors.white) mon.write(entry.text:sub(1, w)) end end end function Display.renderTerminal() local w, h = term.getSize() -- Header term.setCursorPos(1, 1) term.setBackgroundColor(colors.gray) term.setTextColor(colors.white) term.clearLine() local title = " Station Broadcast Console (" .. State.stationName .. ") " 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 = peripheral.getName(Peripherals.primaryMonitor) while true do local event, param1, param2, param3 = os.pullEvent() local _, h = term.getSize() local maxLogs = h - 5 + 1 if event == "mouse_scroll" then local direction = param1 if direction == -1 then -- Scroll Up State.logScrollOffset = math.min(State.logScrollOffset + 1, math.max(0, #State.logs - maxLogs)) elseif direction == 1 then -- Scroll Down State.logScrollOffset = math.max(0, State.logScrollOffset - 1) end Display.renderTerminal() Display.render() elseif event == "monitor_scroll" and param1 == monitorName then local direction = param2 if direction == -1 then State.logScrollOffset = math.min(State.logScrollOffset + 1, math.max(0, #State.logs - 3)) elseif direction == 1 then State.logScrollOffset = math.max(0, State.logScrollOffset - 1) end Display.renderTerminal() Display.render() elseif event == "monitor_touch" and param1 == monitorName then -- Simple touch to scroll: top half of log area scrolls up, bottom half scrolls down local _, monH = Peripherals.primaryMonitor.getSize() local y = param3 if y >= monH - 3 then if y == monH - 3 then -- Top line of log area State.logScrollOffset = math.min(State.logScrollOffset + 1, math.max(0, #State.logs - 3)) elseif y == monH then -- Bottom line of log area State.logScrollOffset = math.max(0, State.logScrollOffset - 1) end Display.renderTerminal() Display.render() end elseif event == "key" then if param1 == keys.up then State.logScrollOffset = math.min(State.logScrollOffset + 1, math.max(0, #State.logs - maxLogs)) elseif param1 == keys.down then State.logScrollOffset = math.max(0, State.logScrollOffset - 1) elseif param1 == keys.pageUp then State.logScrollOffset = math.min(State.logScrollOffset + 10, math.max(0, #State.logs - maxLogs)) elseif param1 == keys.pageDown then State.logScrollOffset = math.max(0, State.logScrollOffset - 10) elseif param1 == keys.home then State.logScrollOffset = 0 elseif param1 == keys["end"] then State.logScrollOffset = math.max(0, #State.logs - maxLogs) end Display.renderTerminal() Display.render() end end end -- ============================================================================ -- 8. Main Logic -- ============================================================================ local Main = {} function Main.loop() while true do 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 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, arrivalTime = now, departureTime = now + stationTime, platform = Utils.getPlatformNumber(State.stationName, "1") } Network.send({ type = "train_arrival", trainId = id, stationName = State.stationName, destination = dest }) if not State.arrivalTonePlayed then if stationTime < 50 then Utils.log("INFO", "Short stop (<50s) and no pre-arrival. Skipping arrival announcement.") State.shortStopMode = true else Utils.log("INFO", "Train arrived. Playing arrival announcement (alt).") Audio.announceArrival(State.currentTrain.id, State.currentTrain.trainType, State.currentTrain.platform, State.currentTrain.dest, true) end State.arrivalTonePlayed = true end else -- Train currently at station local now = os.epoch("utc") / 1000 local remaining = State.currentTrain.departureTime - now if State.shortStopMode then -- Play fcts.wav only in the last 10 seconds if remaining <= 10 and not State.departingPlayed then Utils.log("INFO", "Short stop mode: playing fcts.wav only.") Audio.play({"fcts"}) State.departingPlayed = true end else if remaining <= 50 and not State.departingPlayed then Audio.announceDeparture(State.currentTrain.id, State.currentTrain.trainType, State.currentTrain.platform, State.currentTrain.dest, 60) State.departingPlayed = true end end end else if State.currentTrain then -- Train just left Network.send({ type = "train_departure", trainId = State.currentTrain.id, stationName = State.stationName, destination = State.currentTrain.dest, schedule = State.currentTrain.schedule, stationTime = math.floor((os.epoch("utc") / 1000) - State.currentTrain.arrivalTime) }) State.currentTrain = nil State.arrivalTonePlayed = false State.departingPlayed = false State.shortStopMode = false end end Display.render() Display.renderTerminal() os.sleep(0.5) end end -- ============================================================================ -- 9. Bootstrap -- ============================================================================ Peripherals.init() Audio.loadStationList() 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 )