Slice realtime WAV audio into small self-contained chunks on the server and feed them into the HQ speaker WAV queue on stations so music can start playing before the full file finishes transferring.
1533 lines
53 KiB
Lua
1533 lines
53 KiB
Lua
|
|
-- ============================================================================
|
|
-- 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,
|
|
audioRx = nil,
|
|
audioQueue = {},
|
|
currentPlaying = nil,
|
|
currentPriority = -1,
|
|
interruptSeq = 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
|
|
|
|
local function pushWavChunkToSpeakers(payload, playSeq)
|
|
local controller = Peripherals.speakers and Peripherals.speakers[1] or nil
|
|
if controller and type(controller.speakWavAll) == "function" then
|
|
while true do
|
|
if shouldStop(playSeq) then return false end
|
|
local ok = false
|
|
pcall(function()
|
|
ok = controller.speakWavAll(payload, CONFIG.AUDIO_VOLUME)
|
|
end)
|
|
if ok then
|
|
return true
|
|
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
|
|
|
|
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) and not waitForInterruptibleDelay(playSeq, 0.05) then
|
|
return false
|
|
end
|
|
end
|
|
|
|
return true
|
|
end
|
|
|
|
function Audio.playDfpwmItem(item)
|
|
if not Peripherals.speakers or #Peripherals.speakers == 0 then
|
|
Utils.log("WARN", "No speakers found; cannot play audio")
|
|
return
|
|
end
|
|
|
|
local playSeq = State.interruptSeq
|
|
local source = item.audioData
|
|
|
|
-- Support both static string data and streaming functions
|
|
if type(source) == "table" and source.read then
|
|
source = source.read
|
|
end
|
|
|
|
if type(source) ~= "function" then
|
|
local data = source
|
|
source = function()
|
|
local chunk = data
|
|
data = nil
|
|
return chunk
|
|
end
|
|
end
|
|
|
|
local decoder = dfpwm.make_decoder()
|
|
while true do
|
|
if shouldStop(playSeq) then break end
|
|
local chunk = source()
|
|
if chunk == nil then break end
|
|
if type(chunk) == "string" and #chunk > 0 then
|
|
local samples = decoder(chunk)
|
|
if samples and not playChunkOnSpeakers(samples, CONFIG.AUDIO_VOLUME, playSeq) then
|
|
break
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
function Audio.playPcmItem(item)
|
|
if not Peripherals.speakers or #Peripherals.speakers == 0 then
|
|
Utils.log("WARN", "No speakers found; cannot play audio")
|
|
return
|
|
end
|
|
|
|
local playSeq = State.interruptSeq
|
|
local source = item.audioData
|
|
|
|
if type(source) == "table" and source.read then
|
|
source = source.read
|
|
end
|
|
|
|
if type(source) ~= "function" then
|
|
local data = source
|
|
source = function()
|
|
local chunk = data
|
|
data = nil
|
|
return chunk
|
|
end
|
|
end
|
|
|
|
while true do
|
|
if shouldStop(playSeq) then break end
|
|
local chunk = source()
|
|
if chunk == nil then break end
|
|
if type(chunk) == "string" and #chunk > 0 then
|
|
local samples = decodePcmS8Chunk(chunk)
|
|
if samples and not playChunkOnSpeakers(samples, CONFIG.AUDIO_VOLUME, playSeq) then
|
|
break
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
function Audio.playWavStreamItem(item)
|
|
if not Peripherals.speakers or #Peripherals.speakers == 0 then
|
|
Utils.log("WARN", "No speakers found; cannot play audio")
|
|
return
|
|
end
|
|
|
|
local playSeq = State.interruptSeq
|
|
local source = item.audioData
|
|
|
|
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
|
|
Audio.stop("current")
|
|
return
|
|
end
|
|
|
|
local chunk = source()
|
|
if chunk == nil then break end
|
|
if type(chunk) == "string" and #chunk > 0 then
|
|
if not pushWavChunkToSpeakers(chunk, playSeq) then
|
|
Audio.stop("current")
|
|
return
|
|
end
|
|
end
|
|
end
|
|
|
|
while areHqSpeakersBusy(Peripherals.speakers) do
|
|
if shouldStop(playSeq) then
|
|
Audio.stop("current")
|
|
return
|
|
end
|
|
if not waitForInterruptibleDelay(playSeq, 0.05) then
|
|
Audio.stop("current")
|
|
return
|
|
end
|
|
end
|
|
end
|
|
|
|
function Audio.playItem(item)
|
|
local format = tostring(item.audioFormat or "dfpwm"):lower()
|
|
if format == "wav" or format == "file" or format == "stream" or format == "hls" then
|
|
return playHqAudioItem(item)
|
|
elseif format == "pcm" then
|
|
return Audio.playPcmItem(item)
|
|
elseif format == "wav-stream" then
|
|
return Audio.playWavStreamItem(item)
|
|
end
|
|
return Audio.playDfpwmItem(item)
|
|
end
|
|
|
|
function Audio.playerLoop()
|
|
while true do
|
|
if #State.audioQueue == 0 then
|
|
os.pullEvent("audio_queue")
|
|
else
|
|
local item = table.remove(State.audioQueue, 1)
|
|
State.currentPlaying = item
|
|
State.currentPriority = tonumber(item.priority) or 0
|
|
|
|
local ok, err = pcall(Audio.playItem, item)
|
|
if not ok then
|
|
Utils.log("ERROR", "Audio Playback Crash: " .. tostring(err))
|
|
end
|
|
|
|
State.currentPlaying = nil
|
|
State.currentPriority = -1
|
|
end
|
|
end
|
|
end
|
|
|
|
function Utils.getChineseNumberAudio(val)
|
|
local num = tonumber(val)
|
|
if num then
|
|
local seq = {}
|
|
if num < 10 then
|
|
table.insert(seq, "ch-" .. num)
|
|
elseif num == 10 then
|
|
table.insert(seq, "ch-10")
|
|
elseif num > 10 and num < 20 then
|
|
table.insert(seq, "ch-10")
|
|
table.insert(seq, "ch-" .. (num % 10))
|
|
elseif num >= 20 and num < 100 then
|
|
local ten = math.floor(num / 10)
|
|
local unit = num % 10
|
|
table.insert(seq, "ch-" .. ten)
|
|
table.insert(seq, "ch-10")
|
|
if unit > 0 then
|
|
table.insert(seq, "ch-" .. unit)
|
|
end
|
|
else
|
|
table.insert(seq, "ch-" .. num)
|
|
end
|
|
return seq
|
|
else
|
|
-- Handle Alphanumeric (e.g., "1A", "2B")
|
|
local digit, letter = tostring(val):match("^(%d+)([a-zA-Z])$")
|
|
if digit and letter then
|
|
local seq = Utils.getChineseNumberAudio(digit)
|
|
table.insert(seq, letter:upper()) -- e.g. "A", "B"
|
|
return seq
|
|
end
|
|
return {tostring(val)}
|
|
end
|
|
end
|
|
|
|
function Utils.getStationAudio(code, langPrefix)
|
|
if not code then return nil end
|
|
local files = State.stationList[code]
|
|
if files then
|
|
local p = langPrefix:lower()
|
|
for _, f in ipairs(files) do
|
|
if f:lower():find("^" .. p) then
|
|
return f
|
|
end
|
|
end
|
|
end
|
|
-- Fallback to hardcoded pattern
|
|
return langPrefix .. code
|
|
end
|
|
|
|
-- ============================================================================
|
|
-- 6. Network System
|
|
-- ============================================================================
|
|
local Network = {}
|
|
|
|
function Network.connect()
|
|
if State.ws then return end
|
|
Utils.log("INFO", "Connecting to cloud...")
|
|
State.ws = http.websocket(CONFIG.CLOUD_URL)
|
|
if State.ws then
|
|
Utils.log("SUCCESS", "Connected")
|
|
Network.register()
|
|
else
|
|
Utils.log("ERROR", "Connection failed")
|
|
end
|
|
end
|
|
|
|
function Network.send(data)
|
|
if State.ws then
|
|
local ok, err = pcall(function()
|
|
State.ws.send(textutils.serializeJSON(data))
|
|
end)
|
|
if not ok then
|
|
Utils.log("ERROR", "Failed to send WS message: " .. tostring(err))
|
|
end
|
|
end
|
|
end
|
|
|
|
function Network.register()
|
|
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" 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
|
|
|
|
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")
|
|
}
|
|
end
|
|
else
|
|
if State.currentTrain then
|
|
State.currentTrain = nil
|
|
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
|
|
)
|