Files
CC-StationBroadcast/station_broadcast.lua
T

2746 lines
100 KiB
Lua

-- ============================================================================
-- 1. Configuration & Constants
-- ============================================================================
local APP_VERSION_TEXT = "v1.0.35"
local CONFIG = {
CLOUD_URL = "ws://stabadmin.fse-media.group",
API_URL = "http://stabadmin.fse-media.group",
SYNC_INTERVAL = 30,
AUDIO_BASE = "Audio/",
AUDIO_STATION = "Audio/Station/",
DISK_AUDIO_BASE = "disk/Audio/",
DISK_AUDIO_STATION = "disk/Audio/Station/",
STATION_IDENTITY_FILE = "station_identity.cfg",
AUDIO_VOLUME = 100,
AUDIO_STREAM_PREROLL_CHUNKS = 5,
DEBUG = true
}
local dfpwm = require "cc.audio.dfpwm"
local COLORS = {
INFO = colors.white,
WARN = colors.yellow,
WARNING = colors.yellow,
ERROR = colors.red,
DEBUG = colors.cyan,
SUCCESS = colors.green
}
-- ============================================================================
-- 2. State Management
-- ============================================================================
local State = {
ws = nil,
stationName = "Unknown",
stationCode = "UNK",
stationPlatform = "1",
stationIdentityMode = "auto",
hasStationPeripheral = false,
stationPeripheralSide = nil,
hasPrimaryMonitor = false,
registered = false,
stationList = {},
currentTrain = nil,
logs = {},
logScrollOffset = 0,
audioRx = nil,
audioQueue = {},
currentPlaying = nil,
currentPriority = -1,
interruptSeq = 0,
tickRate = 20
}
-- ============================================================================
-- 3. Peripherals Setup
-- ============================================================================
local Peripherals = {}
function Peripherals.isTrainStationPeripheral(name)
if not name then
return false
end
local pType = peripheral.getType(name)
if pType == "train_station" then
return true
end
local wrapped = peripheral.wrap(name)
if not wrapped then
return false
end
-- Create Wiki documents the station peripheral by its methods rather than a
-- guaranteed fixed type string. Match on the core method set so attached
-- stations are still detected across integration/version differences.
return type(wrapped.getStationName) == "function"
and type(wrapped.isTrainPresent) == "function"
and type(wrapped.getTrainName) == "function"
and type(wrapped.getSchedule) == "function"
end
function Peripherals.init()
-- Station Peripheral: prefer direct sides, then fall back to any discovered peripheral name.
local station = nil
local stationSide = nil
local checkedNames = {}
local preferredSides = { "back", "top", "bottom", "left", "right", "front" }
for _, side in ipairs(preferredSides) do
checkedNames[side] = true
if Peripherals.isTrainStationPeripheral(side) then
station = peripheral.wrap(side)
stationSide = side
break
end
end
if not station and peripheral.getNames then
for _, name in ipairs(peripheral.getNames()) do
if not checkedNames[name] and Peripherals.isTrainStationPeripheral(name) then
station = peripheral.wrap(name)
stationSide = name
break
end
end
end
Peripherals.station = station
State.hasStationPeripheral = station ~= nil
State.stationPeripheralSide = stationSide
-- monitors
local monitors = { peripheral.find("monitor") }
if #monitors > 0 then
Peripherals.primaryMonitor = monitors[1]
Peripherals.primaryMonitor.setTextScale(0.5)
State.hasPrimaryMonitor = true
else
Peripherals.primaryMonitor = nil
State.hasPrimaryMonitor = false
end
-- Speakers
Peripherals.speakers = { peripheral.find("speaker") }
-- Create Target Block (CRN)
Peripherals.createTarget = peripheral.find("create_target")
if Peripherals.createTarget then
pcall(Peripherals.createTarget.resize, 80, 10)
end
-- Block Reader (Advanced Peripherals)
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)
local timestamp = os.date("%H:%M:%S")
local entryText = string.format("[%s][%s] %s", timestamp, level, message)
local entry = { level = level, text = entryText }
table.insert(State.logs, 1, entry)
if #State.logs > 100 then table.remove(State.logs) end
-- If user is scrolling history, increment offset to stay on same items
if State.logScrollOffset > 0 then
State.logScrollOffset = math.min(State.logScrollOffset + 1, math.max(0, #State.logs - 1))
end
end
function Utils.trim(value)
return tostring(value or ""):match("^%s*(.-)%s*$")
end
function Utils.loadStationIdentity()
if not fs.exists(CONFIG.STATION_IDENTITY_FILE) then
return {}
end
local handle = fs.open(CONFIG.STATION_IDENTITY_FILE, "r")
if not handle then
return {}
end
local content = handle.readAll() or ""
handle.close()
local data = {}
for line in content:gmatch("[^\r\n]+") do
local clean = Utils.trim(line)
if clean ~= "" and not clean:match("^#") then
local key, value = clean:match("^([%w_]+)%s*=%s*(.-)%s*$")
if key then
data[key:lower()] = Utils.trim(value)
end
end
end
return data
end
function Utils.saveStationIdentity(stationCode, stationPlatform, departureBeep)
local handle = fs.open(CONFIG.STATION_IDENTITY_FILE, "w")
if not handle then
return false
end
handle.write(table.concat({
"# Station identity cache / override",
"station_code=" .. Utils.trim(stationCode or ""),
"station_platform=" .. Utils.trim(stationPlatform or ""),
"departure_beep=" .. Utils.trim(departureBeep or "fc1"),
""
}, "\n"))
handle.close()
return true
end
function Utils.hasUsableManualIdentity(data)
return data
and type(data) == "table"
and data.station_code and data.station_code ~= ""
and data.station_platform and data.station_platform ~= ""
end
function Utils.getAvailableDepartureBeeps()
local beeps = {"fc1"}
local ok, resp = pcall(http.get, CONFIG.API_URL .. "/api/files")
if ok and resp then
local raw = resp.readAll()
resp.close()
local found = {}
for path in raw:gmatch('"path"%s*:%s*"Audio/beep/([^"]+%.wav)"') do
local fc = path:match("^(fc.*)%.wav$")
if fc then
table.insert(found, fc)
end
end
if #found > 0 then
beeps = found
end
end
local unique = {}
local result = {}
for _, v in ipairs(beeps) do
if not unique[v] then
unique[v] = true
table.insert(result, v)
end
end
table.sort(result)
return result
end
function Utils.promptSlaveIdentity()
State.inConfig = true
while true do
local availBeeps = Utils.getAvailableDepartureBeeps()
local availStr = table.concat(availBeeps, ", ")
if #availStr > 40 then availStr = availStr:sub(1, 37) .. "..." end
term.setBackgroundColor(colors.black)
term.setTextColor(colors.white)
term.clear()
term.setCursorPos(1, 1)
term.setTextColor(colors.yellow)
term.write("Station Broadcast " .. APP_VERSION_TEXT)
term.setCursorPos(1, 3)
term.setTextColor(colors.white)
term.write("No train station peripheral found.")
term.setCursorPos(1, 4)
term.write("Configure this computer as a slave node.")
term.setCursorPos(1, 6)
term.write("Station code (e.g. cb-03): ")
term.setCursorPos(1, 8)
term.write("Platform number (e.g. 6): ")
term.setCursorPos(1, 10)
term.setTextColor(colors.gray)
term.write("Available beeps: " .. availStr)
term.setTextColor(colors.white)
term.setCursorPos(1, 11)
term.write("Departure beep (e.g. fc1): ")
term.setCursorPos(30, 6)
term.setTextColor(colors.cyan)
local stationCode = Utils.trim(read() or ""):lower()
term.setCursorPos(31, 8)
local stationPlatform = Utils.trim(read() or "")
term.setCursorPos(32, 11)
local departureBeep = Utils.trim(read() or "")
if departureBeep == "" then departureBeep = "fc1" end
if stationCode == "" or stationPlatform == "" then
term.setCursorPos(1, 12)
term.setTextColor(colors.red)
term.write("Both station code and platform are required.")
term.setCursorPos(1, 14)
term.setTextColor(colors.lightGray)
term.write("Press any key to retry...")
os.pullEvent("key")
else
local ok = Utils.saveStationIdentity(stationCode, stationPlatform, departureBeep)
if ok then
term.setCursorPos(1, 12)
term.setTextColor(colors.green)
term.write("Saved to " .. CONFIG.STATION_IDENTITY_FILE)
term.setCursorPos(1, 14)
term.setTextColor(colors.lightGray)
term.write("Press any key to continue...")
os.pullEvent("key")
term.setBackgroundColor(colors.black)
term.setTextColor(colors.white)
term.clear()
State.inConfig = false
return true
end
term.setCursorPos(1, 12)
term.setTextColor(colors.red)
term.write("Failed to save " .. CONFIG.STATION_IDENTITY_FILE)
term.setCursorPos(1, 14)
term.setTextColor(colors.lightGray)
term.write("Press any key to retry...")
os.pullEvent("key")
end
end
end
function Utils.promptConfig()
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 availStr = table.concat(availBeeps, ", ")
if #availStr > 40 then availStr = availStr:sub(1, 37) .. "..." end
term.setBackgroundColor(colors.black)
term.setTextColor(colors.white)
term.clear()
term.setCursorPos(1, 1)
term.setTextColor(colors.yellow)
term.write("Station Broadcast Configuration")
term.setCursorPos(1, 3)
term.setTextColor(colors.white)
if Peripherals.station then
term.write("Physical Station detected. Code/Platform are auto-synced.")
term.setCursorPos(1, 4)
term.write("You can override them here if needed.")
else
term.write("Slave node mode.")
end
term.setCursorPos(1, 6)
term.write("Station code [" .. currentCode .. "]: ")
term.setCursorPos(1, 8)
term.write("Platform number [" .. currentPlatform .. "]: ")
term.setCursorPos(1, 10)
term.setTextColor(colors.gray)
term.write("Available beeps: " .. availStr)
term.setTextColor(colors.white)
term.setCursorPos(1, 11)
term.write("Departure beep [" .. currentBeep .. "]: ")
term.setCursorPos(30, 6)
term.setTextColor(colors.cyan)
local codeInput = Utils.trim(read() or ""):lower()
if codeInput == "" then codeInput = currentCode end
term.setCursorPos(31, 8)
local platformInput = Utils.trim(read() or "")
if platformInput == "" then platformInput = currentPlatform end
term.setCursorPos(32, 11)
local beepInput = Utils.trim(read() or "")
if beepInput == "" then beepInput = currentBeep end
local ok = Utils.saveStationIdentity(codeInput, platformInput, beepInput)
if ok then
Utils.refreshStationIdentity()
term.setCursorPos(1, 12)
term.setTextColor(colors.green)
term.write("Configuration saved.")
term.setCursorPos(1, 14)
term.setTextColor(colors.lightGray)
term.write("Press any key to return...")
os.pullEvent("key")
term.setBackgroundColor(colors.black)
term.setTextColor(colors.white)
term.clear()
State.inConfig = false
break
else
term.setCursorPos(1, 12)
term.setTextColor(colors.red)
term.write("Failed to save configuration.")
term.setCursorPos(1, 14)
term.setTextColor(colors.lightGray)
term.write("Press any key to retry...")
os.pullEvent("key")
end
end
end
function Utils.ensureSlaveIdentityConfigured()
if Peripherals.station then return end
local manual = Utils.loadStationIdentity()
if Utils.hasUsableManualIdentity(manual) then return end
Utils.promptSlaveIdentity()
end
function Utils.resolveStationIdentity()
local manual = Utils.loadStationIdentity()
local physicalName = nil
if Peripherals.station and type(Peripherals.station.getStationName) == "function" then
physicalName = Utils.trim(Peripherals.station.getStationName() or "Unknown")
if physicalName == "" then physicalName = nil end
end
local stationCode = nil
local stationPlatform = nil
local stationName = physicalName
if physicalName then
stationCode = Utils.getStationCode(physicalName) or Utils.getBroadcastStationCode(physicalName)
stationPlatform = Utils.getPlatformNumber(physicalName, manual.station_platform or "1")
else
stationCode = manual.station_code
stationPlatform = manual.station_platform
end
if (not stationCode or stationCode == "") and manual.station_code and manual.station_code ~= "" then
stationCode = manual.station_code
end
if (not stationPlatform or stationPlatform == "") and manual.station_platform and manual.station_platform ~= "" then
stationPlatform = manual.station_platform
end
if (not stationCode or stationCode == "") and stationName and stationName ~= "" then
stationCode = Utils.getStationCode(stationName) or Utils.getBroadcastStationCode(stationName)
end
if not stationPlatform or stationPlatform == "" then
stationPlatform = Utils.getPlatformNumber(stationName or physicalName, "1")
end
if not stationName or stationName == "" then
if stationCode and stationCode ~= "" then
stationName = string.upper(stationCode) .. " Platform " .. tostring(stationPlatform or "1")
else
stationName = "Manual Station"
end
end
if not stationCode or stationCode == "" then
stationCode = Utils.getStationCode(stationName) or Utils.getBroadcastStationCode(stationName)
end
local hasManualIdentity = Utils.hasUsableManualIdentity(manual)
local identityMode = "auto"
if hasManualIdentity then
identityMode = "manual"
elseif not Peripherals.station then
identityMode = "manual-fallback"
end
local departureBeep = manual.departure_beep
if not departureBeep or departureBeep == "" then
departureBeep = "fc1"
end
return {
stationName = stationName,
stationCode = stationCode or "UNK",
stationPlatform = tostring(stationPlatform or "1"),
departureBeep = departureBeep,
mode = identityMode
}
end
function Utils.syncStationIdentityFromPeripheral()
if not Peripherals.station then
return false
end
local identity = Utils.resolveStationIdentity()
local stationCode = Utils.trim(identity.stationCode or "")
local stationPlatform = Utils.trim(identity.stationPlatform or "")
if stationCode == "" or stationCode == "UNK" or stationPlatform == "" then
return false
end
local manual = Utils.loadStationIdentity()
local oldCode = Utils.trim(manual.station_code or "")
local oldPlatform = Utils.trim(manual.station_platform or "")
if oldCode == stationCode and oldPlatform == stationPlatform then
return false
end
local ok = Utils.saveStationIdentity(stationCode, stationPlatform, identity.departureBeep)
if ok then
Utils.log("INFO", "Synced station_identity.cfg from physical station: " .. stationCode .. " / P" .. stationPlatform)
return true
end
Utils.log("WARN", "Failed to sync " .. CONFIG.STATION_IDENTITY_FILE .. " from physical station")
return false
end
function Utils.refreshStationIdentity()
local identity = Utils.resolveStationIdentity()
State.stationName = identity.stationName
State.stationCode = identity.stationCode
State.stationPlatform = identity.stationPlatform
State.departureBeep = identity.departureBeep
State.stationIdentityMode = identity.mode
end
function Utils.getStationCode(name)
if not name then return nil end
name = tostring(name)
local code = nil
local codeInParens = name:match("[%[%(]([%w%d%-%/%s]+)[%]%)]")
if codeInParens then
local firstPart = codeInParens:match("[^/]+")
if firstPart then
code = firstPart:match("^%s*(.-)%s*$")
end
end
if not code then
code = name:match("[%w%d]+%-[%w%d]+") or name:match("[%a]+[%d]+")
end
if not code then
local cleanName = name:match("^%s*(.-)%s*$"):lower()
if State.stationList and State.stationList[cleanName] then
code = cleanName
end
end
if code then
return tostring(code):lower() -- Always return lowercase for consistent comparison
end
return nil
end
function Utils.getPlatformNumber(name, default)
if not name then return default end
local p = name:match("[Pp]latform%s*([%d]+[a-zA-Z]?)") or name:match("[Tt]rack%s*([%d]+[a-zA-Z]?)")
if p then return p end
return name:match("([%d]+[a-zA-Z]?)$") or default
end
function Utils.getBroadcastStationCode(name)
if not name then return "UNK" end
name = name:match("^%s*(.-)%s*$")
name = name:gsub("%s+", "_")
if name == "" then return "UNK" end
return name
end
function Utils.isSameTrain(id1, id2)
if not id1 or not id2 then return false end
if id1 == id2 then return true end
-- Consist Car Match: "CK 1 01" vs "CK 1 02"
local function getConsist(id)
local clean = id:gsub("^%[.-%]%s*", "")
local p = {}
for s in clean:gmatch("%S+") do table.insert(p, s) end
if #p >= 2 then
if #p[1] <= 4 and tonumber(p[2]) then
return p[1] .. " " .. p[2]
end
end
return p[1] or clean
end
local c1 = getConsist(id1)
local c2 = getConsist(id2)
if c1 == c2 then return true end
-- Fuzzy match: remove type prefix and compare content
local _, name1, num1 = Utils.parseTrainId(id1)
local _, name2, num2 = Utils.parseTrainId(id2)
-- If both have numbers, compare name and number
if num1 ~= "" and num2 ~= "" then
return name1 == name2 and num1 == num2
end
-- Fallback to comparing just the main content/name
return name1 == name2
end
function Utils.parseTrainId(trainId)
if not trainId then return "unknown", "" end
-- Generic match: [Type]Content
local typeStr, content = trainId:match("^%[(.-)%](.+)$")
if typeStr then
-- Check for Express types
if typeStr == "Ltd.Exp" or typeStr == "Express" then
local name, num = content:match("^(.+) No%.(%d+)$")
if name then
return "express", name, num
else
return "express", content, ""
end
elseif typeStr == "Local" then
return "regular", "Local", content
else
return "regular", typeStr, content
end
end
return "unknown", "Unknown", trainId
end
function Utils.getDestination(schedule)
if not schedule then return "Unknown" end
-- Handle both wrapped and unwrapped schedule structure
local entries = schedule.entries or (type(schedule) == "table" and schedule)
if not entries or type(entries) ~= "table" then return "Unknown" end
if #entries == 0 then
local count = 0
for _ in pairs(entries) do count = count + 1 end
if count > 0 then
local list = {}
for k, v in pairs(entries) do
local n = tonumber(k)
if n then list[n] = v end
end
entries = list
end
end
local dest = "Unknown"
local maxIdx = 0
for k, _ in pairs(entries) do
if type(k) == "number" and k > maxIdx then maxIdx = k end
end
for i = 1, maxIdx do
local entry = entries[i]
if entry then
local inst = entry.instruction
if inst then
if inst.id == "create:destination" then
dest = (inst.data and inst.data.text) or inst.text or dest
end
if inst.id == "create:station" then
local sName = (inst.data and inst.data.text) or inst.text
if sName then
dest = sName
end
end
end
end
end
return dest
end
function Utils.getBaseName(name)
if not name then return "" end
name = tostring(name):lower()
name = name:gsub("%s*[Pp]latform%s*[%d%a]+", ""):gsub("%s*[Tt]rack%s*[%d%a]+", "")
name = name:gsub("%s*[%[%(].-[%]%)]%s*", " ")
name = name:gsub("%s+%d+$", "")
return name:match("^%s*(.-)%s*$")
end
function Utils.isFuzzyMatch(str, pattern)
if not str or not pattern then return false end
str = tostring(str):lower():match("^%s*(.-)%s*$")
pattern = tostring(pattern):lower():match("^%s*(.-)%s*$")
if str == pattern then return true end
-- 1. Handle Create Mod Wildcards (*)
if pattern:find("*") then
local p = pattern:gsub("([%^%$%(%)%%%.%[%]%+%-%?])", "%%%1")
p = p:gsub("%%%*", ".*")
local ok, res = pcall(function() return str:match("^" .. p .. "$") end)
if ok and res then return true end
end
-- 2. Base name comparison
local b1 = Utils.getBaseName(str)
local b2 = Utils.getBaseName(pattern)
if b1 ~= "" and b2 ~= "" then
if b1 == b2 then return true end
-- Substring match for base names
if #b1 >= 4 and #b2 >= 4 then
if b1:find(b2, 1, true) or b2:find(b1, 1, true) then
return true
end
end
end
-- 3. Alphanumeric only comparison
local a1 = str:gsub("[^%w%d]", "")
local a2 = pattern:gsub("[^%w%d]", "")
if a1 ~= "" and a2 ~= "" and (a1 == a2 or a1:find(a2, 1, true) or a2:find(a1, 1, true)) then
return true
end
return false
end
function Utils.getStopTime(schedule, stationName)
if not schedule or not schedule.entries or not stationName then return nil end
local target = tostring(stationName):lower():match("^%s*(.-)%s*$")
if target == "" then return nil end
local targetCode = Utils.getStationCode(target)
local targetBase = Utils.getBaseName(target)
local targetAlpha = target:gsub("[^%w%d]", "")
Utils.log("DEBUG", string.format("STATION TARGET: '%s' (Code: %s, Base: %s, Alpha: %s)", target, tostring(targetCode), targetBase, targetAlpha))
local function extractTime(data)
if not data then return nil end
local val = tonumber(data.value) or tonumber(data.seconds) or tonumber(data.s)
or tonumber(data.time) or tonumber(data.duration) or tonumber(data.delay)
local unit = tonumber(data.time_unit) or tonumber(data.unit) or 1
if val then
if unit == 0 then return val / 20
elseif unit == 1 then return val
elseif unit == 2 then return val * 60
end
return val
end
local ticks = tonumber(data.ticks) or tonumber(data.tick)
if ticks then return ticks / 20 end
return nil
end
local function findTimeInConditions(conditions)
if not conditions then return nil end
for i, item in ipairs(conditions) do
if type(item) == "table" then
local cid = tostring(item.id or ""):lower()
if cid:find("delay") or cid:find("wait") or cid:find("idle") or cid:find("pause") then
local s = extractTime(item.data)
if s and s > 0 then
Utils.log("DEBUG", string.format("Found Time in Condition [%d]: %.1fs (ID: %s)", i, s, cid))
return s
end
end
for k, sub in pairs(item) do
if type(sub) == "table" then
local s = findTimeInConditions({sub})
if s then return s end
end
end
end
end
return nil
end
local maxIdx = 0
if schedule.entries then
for k, _ in pairs(schedule.entries) do
if type(k) == "number" and k > maxIdx then maxIdx = k end
end
end
Utils.log("DEBUG", string.format("--- Schedule Scan Start (MaxIdx: %d) ---", maxIdx))
local matchFoundIndex = nil
for i = 1, maxIdx do
local entry = schedule.entries[i]
if entry then
local function scanRecursive(obj, depth)
if depth > 5 or not obj then return false end
for k, v in pairs(obj) do
if type(v) == "string" then
local entryText = v:lower()
local entryCode = Utils.getStationCode(entryText)
local entryAlpha = entryText:gsub("[^%w%d]", "")
local entryBase = Utils.getBaseName(entryText)
Utils.log("DEBUG", string.format(" [%d] SCAN '%s': '%s' (Code: %s)", i, k, entryText, tostring(entryCode)))
local isMatch = (target == entryText)
or (targetCode and entryCode and targetCode == entryCode)
or (targetAlpha ~= "" and entryAlpha ~= "" and (targetAlpha == entryAlpha or targetAlpha:find(entryAlpha, 1, true) or entryAlpha:find(targetAlpha, 1, true)))
or (targetBase ~= "" and entryBase ~= "" and (targetBase == entryBase or targetBase:find(entryBase, 1, true) or entryBase:find(targetBase, 1, true)))
or Utils.isFuzzyMatch(target, entryText)
if isMatch then
Utils.log("DEBUG", string.format(" >>> MATCH FOUND AT ENTRY %d (via field '%s')", i, k))
return true
end
elseif type(v) == "table" then
if scanRecursive(v, depth + 1) then return true end
end
end
return false
end
if scanRecursive(entry, 0) then
matchFoundIndex = i
local s = findTimeInConditions(entry.conditions)
if s then
Utils.log("DEBUG", string.format(" >>> TIME RESOLVED FROM CONDITIONS: %.1fs", s))
return s
end
if entry.instruction and entry.instruction.data then
local s = extractTime(entry.instruction.data)
if s then
Utils.log("DEBUG", string.format(" >>> TIME RESOLVED FROM INSTRUCTION: %.1fs", s))
return s
end
end
break
end
end
end
if matchFoundIndex then
Utils.log("DEBUG", string.format(" >>> Station matched at [%d], scanning ahead for time...", matchFoundIndex))
for i = matchFoundIndex + 1, maxIdx do
local entry = schedule.entries[i]
if entry then
local isOtherStation = false
local function checkIsStation(obj, depth)
if depth > 2 or not obj then return false end
for k, v in pairs(obj) do
if type(v) == "string" then
local lk = tostring(k):lower()
if lk:find("text") or lk:find("station") or lk:find("destination") then return true end
elseif type(v) == "table" then
if checkIsStation(v, depth + 1) then return true end
end
end
return false
end
if checkIsStation(entry, 0) then break end
local s = findTimeInConditions(entry.conditions)
if s then
Utils.log("DEBUG", string.format(" >>> TIME FOUND AT LATER ENTRY [%d]: %.1fs", i, s))
return s
end
if entry.instruction and entry.instruction.data then
local s = extractTime(entry.instruction.data)
if s then
Utils.log("DEBUG", string.format(" >>> TIME FOUND AT LATER INSTRUCTION [%d]: %.1fs", i, s))
return s
end
end
end
end
end
Utils.log("DEBUG", "--- Scan End: No Time Resolved ---")
return nil
end
function Utils.getNextStation(schedule, currentStationName)
if not schedule or not schedule.entries or not currentStationName then return nil end
local target = tostring(currentStationName):lower():match("^%s*(.-)%s*$")
if target == "" then return nil end
local targetCode = Utils.getStationCode(target)
local targetBase = Utils.getBaseName(target)
local targetAlpha = target:gsub("[^%w%d]", "")
local maxIdx = 0
for k, _ in pairs(schedule.entries) do
if type(k) == "number" and k > maxIdx then maxIdx = k end
end
local matchFoundIndex = nil
for i = 1, maxIdx do
local entry = schedule.entries[i]
if entry then
local function scanRecursive(obj, depth)
if depth > 5 or not obj then return false end
for k, v in pairs(obj) do
if type(v) == "string" then
local entryText = v:lower()
local entryCode = Utils.getStationCode(entryText)
local entryAlpha = entryText:gsub("[^%w%d]", "")
local entryBase = Utils.getBaseName(entryText)
local isMatch = (target == entryText)
or (targetCode and entryCode and targetCode == entryCode)
or (targetAlpha ~= "" and entryAlpha ~= "" and (targetAlpha == entryAlpha or targetAlpha:find(entryAlpha, 1, true) or entryAlpha:find(targetAlpha, 1, true)))
or (targetBase ~= "" and entryBase ~= "" and (targetBase == entryBase or targetBase:find(entryBase, 1, true) or entryBase:find(targetBase, 1, true)))
or Utils.isFuzzyMatch(target, entryText)
if isMatch then return true end
elseif type(v) == "table" then
if scanRecursive(v, depth + 1) then return true end
end
end
return false
end
if scanRecursive(entry, 0) then
matchFoundIndex = i
break
end
end
end
if matchFoundIndex then
-- 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
-- ============================================================================
-- 5. Audio System
-- ============================================================================
local Audio = {}
function Audio.createStream()
local chunks = {}
local finished = false
local stream = {}
stream.push = function(chunk)
if type(chunk) ~= "string" then return end
table.insert(chunks, chunk)
os.queueEvent("audio_chunk")
end
stream.finish = function()
finished = true
os.queueEvent("audio_chunk")
end
stream.read = function()
while #chunks == 0 do
if finished then return nil end
os.pullEvent("audio_chunk")
end
return table.remove(chunks, 1)
end
stream.count = function()
return #chunks
end
stream.isFinished = function()
return finished
end
return stream
end
function Audio.loadStationList()
State.stationList = {}
if not fs.exists("list.txt") then
Utils.log("WARN", "list.txt not found")
return
end
local file = fs.open("list.txt", "r")
if file then
local count = 0
local line = file.readLine()
while line do
line = line:gsub("%s+$", "") -- Trim trailing \r\n and spaces
if line ~= "" then
local codes, files = line:match("^([^=]+)==(.+)$")
if codes and files then
local list = {}
for f in files:gmatch("[^,]+") do
local cleanName = f:match("^%s*(.-)%s*$")
:gsub("%.[Ww][Aa][Vv]$", "")
:gsub("%.[Mm][Pp]3$", "")
:gsub("%.[Oo][Gg][Gg]$", "")
:gsub("%.[Ff][Ll][Aa][Cc]$", "")
:gsub("%.[Mm]4[Aa]$", "")
:gsub("%.[Aa][Aa][Cc]$", "")
:gsub("%.[Oo][Pp][Uu][Ss]$", "")
:gsub("%.[Dd][Ff][Pp][Ww][Mm]$", "")
if cleanName ~= "" then
table.insert(list, cleanName)
end
end
-- Map all codes to this list
local allCodes = {}
for code in codes:gmatch("[^/]+") do
local cleanCode = code:match("^%s*(.-)%s*$"):lower()
if cleanCode ~= "" then
table.insert(allCodes, cleanCode)
State.stationList[cleanCode] = list
end
end
if #allCodes > 1 then
local fullRawCode = codes:match("^%s*(.-)%s*$"):lower()
State.stationList[fullRawCode] = list
local dashCode = table.concat(allCodes, "-"):lower()
State.stationList[dashCode] = list
end
count = count + 1
end
end
line = file.readLine()
end
file.close()
Utils.log("INFO", string.format("Station list loaded: %d entries", count))
end
end
function Audio.enqueueDfpwm(dfpwmData)
if type(dfpwmData) ~= "string" or #dfpwmData == 0 then return end
Audio.enqueueItem({ audioData = dfpwmData, audioFormat = "dfpwm", priority = 1 })
end
function Audio.enqueueItem(item)
if type(item) ~= "table" then return end
item.audioData = item.audioData or item.dfpwmData
item.audioFormat = tostring(item.audioFormat or (item.dfpwmData and "dfpwm") or "dfpwm"):lower()
if type(item.audioData) ~= "string" and type(item.audioData) ~= "table" then return end
item.priority = tonumber(item.priority) or 0
local idx = 1
while idx <= #State.audioQueue do
local p = tonumber(State.audioQueue[idx].priority) or 0
if item.priority > p then break end
idx = idx + 1
end
table.insert(State.audioQueue, idx, item)
os.queueEvent("audio_queue")
end
function Audio.stop(mode, flushBelow)
State.interruptSeq = State.interruptSeq + 1
if State.audioRx and State.audioRx.stream then
pcall(State.audioRx.stream.finish)
end
State.audioRx = nil
if Peripherals.speakers then
for _, sp in ipairs(Peripherals.speakers) do
pcall(function()
if type(sp.speakStop) == "function" then sp.speakStop()
else sp.stop() end
end)
end
end
if mode == "all" then
State.audioQueue = {}
elseif flushBelow ~= nil then
local minP = tonumber(flushBelow) or 0
local kept = {}
for _, it in ipairs(State.audioQueue) do
local p = tonumber(it.priority) or 0
if p >= minP then table.insert(kept, it) end
end
State.audioQueue = kept
end
os.queueEvent("audio_interrupt", State.interruptSeq)
end
local function shouldStop(playSeq)
return State.interruptSeq ~= playSeq
end
local function waitForInterruptibleDelay(playSeq, seconds)
local timer = os.startTimer(seconds)
while true do
local ev = { os.pullEvent() }
if ev[1] == "audio_interrupt" and tonumber(ev[2]) and tonumber(ev[2]) >= playSeq then
return false
end
if ev[1] == "timer" and ev[2] == timer then
return true
end
end
end
local function waitForNativeSpeakersEmpty(speakers, playSeq)
local waiting = {}
for _, sp in ipairs(speakers or {}) do
local name = peripheral.getName(sp)
if name then waiting[name] = true end
end
while next(waiting) do
if shouldStop(playSeq) then return false end
local ev = { os.pullEvent() }
if ev[1] == "audio_interrupt" and tonumber(ev[2]) and tonumber(ev[2]) >= playSeq then
return false
end
if ev[1] == "speaker_audio_empty" and ev[2] and waiting[ev[2]] then
waiting[ev[2]] = nil
end
end
return true
end
local function queueSamplesSynchronized(samples, volume, playSeq)
if not Peripherals.speakers or #Peripherals.speakers == 0 then return false end
if not waitForNativeSpeakersEmpty(Peripherals.speakers, playSeq) then
return false
end
local pending = {}
for _, sp in ipairs(Peripherals.speakers) do
local name = peripheral.getName(sp)
if name and type(sp.playAudio) == "function" then
pending[name] = sp
end
end
while next(pending) do
if shouldStop(playSeq) then return false end
for name, sp in pairs(pending) do
local ok = false
pcall(function()
ok = sp.playAudio(samples, volume)
end)
if ok then
pending[name] = nil
end
end
if next(pending) then
local ev = { os.pullEvent() }
if ev[1] == "audio_interrupt" and tonumber(ev[2]) and tonumber(ev[2]) >= playSeq then
return false
end
end
end
Utils.log("INFO", string.format("Audio samples queued in synchronized multi-speaker mode: samples=%d speakers=%d", #samples, #Peripherals.speakers))
return true
end
local function playChunkOnSpeakers(samples, volume, playSeq, waitForDrain)
if not Peripherals.speakers or #Peripherals.speakers == 0 then return false end
if waitForDrain == nil then waitForDrain = true end
local pending = {}
for _, sp in ipairs(Peripherals.speakers) do
local name = peripheral.getName(sp)
if name then pending[name] = sp end
end
local usedNativePath = false
while next(pending) do
if shouldStop(playSeq) then return false end
for name, sp in pairs(pending) do
local ok = false
pcall(function()
if type(sp.playAudio) == "function" then
ok = sp.playAudio(samples, volume)
end
end)
if ok then
usedNativePath = true
pending[name] = nil
end
end
if next(pending) then
local ev = { os.pullEvent() }
if ev[1] == "audio_interrupt" and tonumber(ev[2]) and tonumber(ev[2]) >= playSeq then
return false
end
end
end
if usedNativePath then
Utils.log("INFO", string.format("Audio samples queued via native speaker.playAudio: samples=%d", #samples))
else
local controller = Peripherals.speakers[1]
if controller and type(controller.playAudioAll) == "function" then
Utils.log("WARN", "Falling back to controller.playAudioAll for decoded samples")
while true do
if shouldStop(playSeq) then return false end
local ok = false
pcall(function()
ok = controller.playAudioAll(samples, volume)
end)
if ok then break end
local ev = { os.pullEvent() }
if ev[1] == "audio_interrupt" and tonumber(ev[2]) and tonumber(ev[2]) >= playSeq then
return false
end
end
else
return false
end
end
if not waitForDrain then return true end
return waitForNativeSpeakersEmpty(Peripherals.speakers, playSeq)
end
local function decodePcmS8Chunk(chunk)
if type(chunk) ~= "string" or #chunk == 0 then return nil end
local samples = {}
for i = 1, #chunk do
local b = string.byte(chunk, i)
if b >= 128 then
b = b - 256
end
samples[i] = b
end
return samples
end
local function readU16LE(data, offset)
local a, b = string.byte(data, offset, offset + 1)
if not a or not b then return nil end
return a + b * 256
end
local function readS16LE(data, offset)
local val = readU16LE(data, offset)
if not val then return nil end
if val >= 32768 then
val = val - 65536
end
return val
end
local function readU32LE(data, offset)
local a, b, c, d = string.byte(data, offset, offset + 3)
if not a or not b or not c or not d then return nil end
return a + b * 256 + c * 65536 + d * 16777216
end
local function clampSample8(val)
if val > 127 then return 127 end
if val < -128 then return -128 end
return val
end
local function decodeWavChunkToSamples(chunk)
if type(chunk) ~= "string" or #chunk < 44 then
return nil, "wav chunk too short"
end
if chunk:sub(1, 4) ~= "RIFF" or chunk:sub(9, 12) ~= "WAVE" then
return nil, "invalid wav header"
end
local offset = 13
local audioFormat, channels, sampleRate, bitsPerSample
local dataOffset, dataSize
while offset + 7 <= #chunk do
local chunkId = chunk:sub(offset, offset + 3)
local chunkSize = readU32LE(chunk, offset + 4)
if not chunkSize then break end
local payloadOffset = offset + 8
if chunkId == "fmt " and chunkSize >= 16 then
audioFormat = readU16LE(chunk, payloadOffset)
channels = readU16LE(chunk, payloadOffset + 2)
sampleRate = readU32LE(chunk, payloadOffset + 4)
bitsPerSample = readU16LE(chunk, payloadOffset + 14)
elseif chunkId == "data" then
dataOffset = payloadOffset
dataSize = chunkSize
break
end
offset = payloadOffset + chunkSize
if chunkSize % 2 == 1 then
offset = offset + 1
end
end
if audioFormat ~= 1 then
return nil, "unsupported wav format " .. tostring(audioFormat)
end
if channels ~= 1 and channels ~= 2 then
return nil, "unsupported channels " .. tostring(channels)
end
if bitsPerSample ~= 8 and bitsPerSample ~= 16 then
return nil, "unsupported bits " .. tostring(bitsPerSample)
end
if not dataOffset or not dataSize or dataOffset + dataSize - 1 > #chunk then
return nil, "invalid wav data chunk"
end
local frameSize = channels * (bitsPerSample / 8)
if frameSize <= 0 then
return nil, "invalid frame size"
end
local samples = {}
local sampleIndex = 1
local endOffset = dataOffset + dataSize - frameSize
for pos = dataOffset, endOffset, frameSize do
local mixed = 0
for ch = 0, channels - 1 do
local sampleOffset = pos + ch * (bitsPerSample / 8)
local sampleValue
if bitsPerSample == 8 then
local raw = string.byte(chunk, sampleOffset)
sampleValue = (raw or 128) - 128
else
sampleValue = readS16LE(chunk, sampleOffset) or 0
if sampleValue >= 0 then
sampleValue = math.floor(sampleValue / 256)
else
sampleValue = math.ceil(sampleValue / 256)
end
end
mixed = mixed + sampleValue
end
samples[sampleIndex] = clampSample8(math.floor(mixed / channels))
sampleIndex = sampleIndex + 1
end
return samples, nil, {
channels = channels,
bitsPerSample = bitsPerSample,
sampleRate = sampleRate or 0
}
end
local function areHqSpeakersBusy(speakers)
for _, sp in ipairs(speakers) do
local playing = false
local queued = 0
pcall(function()
if type(sp.speakIsPlaying) == "function" then
playing = sp.speakIsPlaying() and true or false
end
if type(sp.speakQueueSize) == "function" then
queued = tonumber(sp.speakQueueSize()) or 0
end
end)
if playing or queued > 0 then return true end
end
return false
end
local function downloadAudioBytes(url)
if type(url) ~= "string" or url == "" then return nil, nil, "empty url" end
local ok, resp = pcall(function()
return http.get(url, nil, true)
end)
if not ok or not resp then
return nil, nil, "http.get failed"
end
local body = resp.readAll()
resp.close()
if type(body) ~= "string" or #body == 0 then
return nil, nil, "empty response body"
end
local lower = url:lower()
local ext = lower:match("%.([%a%d]+)$")
return body, ext or "file", nil
end
local function playHqAudioItem(item)
if not Peripherals.speakers or #Peripherals.speakers == 0 then
Utils.log("WARN", "No speakers found; cannot play audio")
return
end
local playSeq = State.interruptSeq
local payload = item.audioData
if type(payload) ~= "string" or #payload == 0 then return end
local format = tostring(item.audioFormat or "file"):lower()
if format == "stream" then
local bytes, ext, err = downloadAudioBytes(payload)
if not bytes then
Utils.log("ERROR", "Failed to download stream audio: " .. tostring(err))
return
end
Utils.log("INFO", "Downloaded stream audio: " .. tostring(#bytes) .. " bytes")
payload = bytes
if ext == "wav" then format = "wav"
elseif ext == "mp3" then format = "mp3"
elseif ext == "ogg" then format = "ogg"
else format = "file" end
end
local controller = Peripherals.speakers[1]
local called = false
local loopEnabled = item.loop and true or false
if controller then
pcall(function()
if type(controller.setLoopingAll) == "function" then
controller.setLoopingAll(loopEnabled)
end
end)
local ok, result = pcall(function()
if format == "hls" and type(controller.speakHLSAll) == "function" then
return controller.speakHLSAll(payload, CONFIG.AUDIO_VOLUME)
elseif format == "wav" and type(controller.speakWavAll) == "function" then
return controller.speakWavAll(payload, CONFIG.AUDIO_VOLUME)
elseif format == "mp3" and type(controller.speakMp3All) == "function" then
return controller.speakMp3All(payload, CONFIG.AUDIO_VOLUME)
elseif format == "ogg" and type(controller.speakOggAll) == "function" then
return controller.speakOggAll(payload, CONFIG.AUDIO_VOLUME)
elseif type(controller.speakFileAll) == "function" then
return controller.speakFileAll(payload, CONFIG.AUDIO_VOLUME)
end
end)
if ok and result then
called = true
end
end
if not called then
for _, sp in ipairs(Peripherals.speakers) do
pcall(function()
if type(sp.setLooping) == "function" then
sp.setLooping(loopEnabled)
end
end)
local ok, result = pcall(function()
if format == "hls" and type(sp.speakHLS) == "function" then
return sp.speakHLS(payload, CONFIG.AUDIO_VOLUME)
elseif format == "wav" and type(sp.speakWav) == "function" then
return sp.speakWav(payload, CONFIG.AUDIO_VOLUME)
elseif format == "mp3" and type(sp.speakMp3) == "function" then
return sp.speakMp3(payload, CONFIG.AUDIO_VOLUME)
elseif format == "ogg" and type(sp.speakOgg) == "function" then
return sp.speakOgg(payload, CONFIG.AUDIO_VOLUME)
elseif type(sp.speakFile) == "function" then
return sp.speakFile(payload, CONFIG.AUDIO_VOLUME)
end
end)
if ok and result then
called = true
end
end
end
if not called then
Utils.log("ERROR", "HQ speaker did not accept audio for format " .. format)
return
end
while areHqSpeakersBusy(Peripherals.speakers) do
if shouldStop(playSeq) then
Audio.stop("current")
return
end
if not waitForInterruptibleDelay(playSeq, 0.1) then
Audio.stop("current")
return
end
end
end
local function pushWavChunkToSpeakers(payload, playSeq)
local controller = Peripherals.speakers and Peripherals.speakers[1] or nil
if controller and type(controller.speakWavAll) == "function" then
local retryCount = 0
while true do
if shouldStop(playSeq) then return false end
local ok = false
local queueSize = -1
local isPlaying = false
pcall(function()
ok = controller.speakWavAll(payload, CONFIG.AUDIO_VOLUME)
if type(controller.speakQueueSize) == "function" then
queueSize = tonumber(controller.speakQueueSize()) or -1
end
if type(controller.speakIsPlaying) == "function" then
isPlaying = controller.speakIsPlaying() and true or false
end
end)
if ok then
Utils.log("INFO", string.format("WAV chunk accepted: bytes=%d queue=%d playing=%s", #payload, queueSize, tostring(isPlaying)))
return true
end
retryCount = retryCount + 1
if retryCount == 1 or retryCount % 20 == 0 then
Utils.log("WARN", string.format("WAV chunk waiting: bytes=%d queue=%d playing=%s retries=%d", #payload, queueSize, tostring(isPlaying), retryCount))
end
if not waitForInterruptibleDelay(playSeq, 0.05) then
return false
end
end
end
local pending = {}
for _, sp in ipairs(Peripherals.speakers or {}) do
local name = peripheral.getName(sp)
if name then pending[name] = sp end
end
local retryCount = 0
while next(pending) do
if shouldStop(playSeq) then return false end
for name, sp in pairs(pending) do
local ok = false
pcall(function()
if type(sp.speakWav) == "function" then
ok = sp.speakWav(payload, CONFIG.AUDIO_VOLUME)
end
end)
if ok then pending[name] = nil end
end
if next(pending) then
retryCount = retryCount + 1
if retryCount == 1 or retryCount % 20 == 0 then
local pendingCount = 0
for _ in pairs(pending) do
pendingCount = pendingCount + 1
end
Utils.log("WARN", string.format("WAV chunk waiting on %d speakers: bytes=%d retries=%d", pendingCount, #payload, retryCount))
end
else
Utils.log("INFO", string.format("WAV chunk accepted by fallback speakers: bytes=%d", #payload))
end
if next(pending) and not waitForInterruptibleDelay(playSeq, 0.05) then
return false
end
end
return true
end
function Audio.playDfpwmItem(item)
if not Peripherals.speakers or #Peripherals.speakers == 0 then
Utils.log("WARN", "No speakers found; cannot play audio")
return
end
local playSeq = State.interruptSeq
local source = item.audioData
-- Support both static string data and streaming functions
if type(source) == "table" and source.read then
source = source.read
end
if type(source) ~= "function" then
local data = source
source = function()
local chunk = data
data = nil
return chunk
end
end
local decoder = dfpwm.make_decoder()
while true do
if shouldStop(playSeq) then break end
local chunk = source()
if chunk == nil then break end
if type(chunk) == "string" and #chunk > 0 then
local samples = decoder(chunk)
if samples and not playChunkOnSpeakers(samples, CONFIG.AUDIO_VOLUME, playSeq) then
break
end
end
end
end
function Audio.playPcmItem(item)
if not Peripherals.speakers or #Peripherals.speakers == 0 then
Utils.log("WARN", "No speakers found; cannot play audio")
return
end
local playSeq = State.interruptSeq
local source = item.audioData
if type(source) == "table" and source.read then
source = source.read
end
if type(source) ~= "function" then
local data = source
source = function()
local chunk = data
data = nil
return chunk
end
end
while true do
if shouldStop(playSeq) then break end
local chunk = source()
if chunk == nil then break end
if type(chunk) == "string" and #chunk > 0 then
local samples = decodePcmS8Chunk(chunk)
if samples and not playChunkOnSpeakers(samples, CONFIG.AUDIO_VOLUME, playSeq) then
break
end
end
end
end
function Audio.playWavStreamItem(item)
if not Peripherals.speakers or #Peripherals.speakers == 0 then
Utils.log("WARN", "No speakers found; cannot play audio")
return
end
local playSeq = State.interruptSeq
-- Ensure all speakers are completely empty and synced before starting the new stream
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
return playHqAudioItem(item)
elseif format == "pcm" then
return Audio.playPcmItem(item)
elseif format == "wav-stream" then
return Audio.playWavStreamItem(item)
end
return Audio.playDfpwmItem(item)
end
function Audio.playerLoop()
while true do
if #State.audioQueue == 0 then
os.pullEvent("audio_queue")
else
local item = table.remove(State.audioQueue, 1)
State.currentPlaying = item
State.currentPriority = tonumber(item.priority) or 0
local ok, err = pcall(Audio.playItem, item)
if not ok then
Utils.log("ERROR", "Audio Playback Crash: " .. tostring(err))
end
State.currentPlaying = nil
State.currentPriority = -1
end
end
end
function Utils.getChineseNumberAudio(val)
local num = tonumber(val)
if num then
local seq = {}
if num < 10 then
table.insert(seq, "ch-" .. num)
elseif num == 10 then
table.insert(seq, "ch-10")
elseif num > 10 and num < 20 then
table.insert(seq, "ch-10")
table.insert(seq, "ch-" .. (num % 10))
elseif num >= 20 and num < 100 then
local ten = math.floor(num / 10)
local unit = num % 10
table.insert(seq, "ch-" .. ten)
table.insert(seq, "ch-10")
if unit > 0 then
table.insert(seq, "ch-" .. unit)
end
else
table.insert(seq, "ch-" .. num)
end
return seq
else
-- Handle Alphanumeric (e.g., "1A", "2B")
local digit, letter = tostring(val):match("^(%d+)([a-zA-Z])$")
if digit and letter then
local seq = Utils.getChineseNumberAudio(digit)
table.insert(seq, letter:upper()) -- e.g. "A", "B"
return seq
end
return {tostring(val)}
end
end
function Utils.getStationAudio(code, langPrefix)
if not code then return nil end
local files = State.stationList[code]
if files then
local p = langPrefix:lower()
for _, f in ipairs(files) do
if f:lower():find("^" .. p) then
return f
end
end
end
-- Fallback to hardcoded pattern
return langPrefix .. code
end
-- ============================================================================
-- 6. Network System
-- ============================================================================
local Network = {}
function Network.connect()
if State.ws then return end
Utils.log("INFO", "Connecting to cloud...")
State.ws = http.websocket(CONFIG.CLOUD_URL)
if State.ws then
Utils.log("SUCCESS", "Connected")
Network.register()
else
Utils.log("ERROR", "Connection failed")
end
end
function Network.send(data)
if State.ws then
local ok, err = pcall(function()
State.ws.send(textutils.serializeJSON(data))
end)
if not ok then
Utils.log("ERROR", "Failed to send WS message: " .. tostring(err))
end
end
end
function Network.register()
Utils.refreshStationIdentity()
Network.send({
type = "station_register",
stationName = State.stationName,
stationCode = State.stationCode,
stationPlatform = State.stationPlatform,
stationVersion = APP_VERSION_TEXT
})
end
function Network.syncFiles()
-- Sync only config file (list.txt) periodically
while true do
local resp = http.get(CONFIG.API_URL .. "/list.txt")
if not resp then
resp = http.get(CONFIG.API_URL .. "/uploads/list.txt")
end
if resp then
local content = resp.readAll()
resp.close()
if content and #content > 0 then
local currentList = ""
if fs.exists("list.txt") then
local f = fs.open("list.txt", "r")
currentList = f.readAll()
f.close()
end
if currentList ~= content then
local f = fs.open("list.txt", "w")
f.write(content)
f.close()
Audio.loadStationList()
end
end
end
os.sleep(CONFIG.SYNC_INTERVAL)
end
end
function Network.messageLoop()
local function finishAudioRx()
if not State.audioRx then return end
local rx = State.audioRx
if rx.stream then
rx.stream.finish()
else
Audio.enqueueItem({
audioData = table.concat(rx.chunks or {}),
audioFormat = rx.audioFormat or "wav",
priority = rx.priority,
loop = rx.loop and true or false,
source = rx.source,
audioId = rx.audioId
})
end
State.audioRx = nil
end
while true do
local event, url, msg = os.pullEvent()
if event == "websocket_message" and url == CONFIG.CLOUD_URL then
local ok, data = pcall(textutils.unserializeJSON, msg)
if ok and type(data) == "table" then
if data.type == "audio_start" then
if State.audioRx and State.audioRx.stream then
pcall(State.audioRx.stream.finish)
end
local priority = tonumber(data.priority) or 0
local audioFormat = tostring(data.audioFormat or "dfpwm"):lower()
local streamUrl = type(data.streamUrl) == "string" and data.streamUrl or nil
local interruptMode = tostring(data.interruptMode or "")
if interruptMode == "" then
if priority >= 2 then interruptMode = "all"
elseif priority >= 1 then interruptMode = "ifLower"
else interruptMode = "none" end
end
if interruptMode == "all" then
Audio.stop("all")
elseif interruptMode == "ifLower" and priority > (State.currentPriority or -1) then
Audio.stop("current", priority)
end
State.audioRx = {
audioId = data.audioId,
expectedBytes = tonumber(data.byteLength) or 0,
receivedBytes = 0,
priority = priority,
loop = data.loop and true or false,
source = data.source or data.category,
audioFormat = audioFormat
}
if (audioFormat == "stream" or audioFormat == "hls") and streamUrl and streamUrl ~= "" then
Audio.enqueueItem({
audioData = streamUrl,
audioFormat = audioFormat,
priority = priority,
loop = State.audioRx.loop,
source = State.audioRx.source,
audioId = data.audioId
})
State.audioRx = nil
elseif audioFormat == "dfpwm" or audioFormat == "pcm" or audioFormat == "wav-stream" then
local stream = Audio.createStream()
State.audioRx.stream = stream
if audioFormat == "pcm" then
Utils.log("INFO", "Receiving live PCM audio stream")
elseif audioFormat == "wav-stream" then
Utils.log("INFO", "Receiving live WAV chunk stream")
end
Audio.enqueueItem({
audioData = stream,
audioFormat = audioFormat,
priority = priority,
source = State.audioRx.source,
audioId = data.audioId
})
else
State.audioRx.chunks = {}
end
elseif data.type == "audio_end" then
if State.audioRx and (not data.audioId or data.audioId == State.audioRx.audioId) then
finishAudioRx()
end
elseif data.type == "audio_stop" then
local scope = tostring(data.scope or "")
if scope == "music" then
local kept = {}
for _, it in ipairs(State.audioQueue) do
if tostring(it.source or "") ~= "music" then
table.insert(kept, it)
end
end
State.audioQueue = kept
local hasMusicRx = State.audioRx and tostring(State.audioRx.source or "") == "music"
local hasMusicCurrent = State.currentPlaying and tostring(State.currentPlaying.source or "") == "music"
if hasMusicRx or hasMusicCurrent then
Audio.stop("current")
end
else
local mode = tostring(data.mode or "current")
local flushBelow = data.flushBelow
Audio.stop(mode, flushBelow)
end
end
else
if State.audioRx then
-- Memory protection: limit to 10MB per audio item
local isLiveStream = State.audioRx.stream and (tonumber(State.audioRx.expectedBytes) or 0) <= 0
if (not isLiveStream) and State.audioRx.receivedBytes + #msg > 10 * 1024 * 1024 then
Utils.log("ERROR", "Audio item too large, discarding")
if State.audioRx.stream then
State.audioRx.stream.finish()
end
State.audioRx = nil
else
if State.audioRx.stream then
State.audioRx.stream.push(msg)
else
table.insert(State.audioRx.chunks, msg)
end
State.audioRx.receivedBytes = State.audioRx.receivedBytes + #msg
if State.audioRx.expectedBytes > 0 and State.audioRx.receivedBytes >= State.audioRx.expectedBytes then
finishAudioRx()
end
end
end
end
elseif event == "websocket_closed" then
Utils.log("WARN", "WS Closed")
State.ws = nil
State.audioRx = nil -- Clear partial download
os.sleep(5)
end
end
end
function Network.heartbeat()
while true do
if State.ws then
Utils.refreshStationIdentity()
Network.send({
type = "heartbeat",
stationName = State.stationName,
stationCode = State.stationCode,
stationPlatform = State.stationPlatform,
stationVersion = APP_VERSION_TEXT
})
end
os.sleep(10)
end
end
-- ============================================================================
-- 7. Display System
-- ============================================================================
local Display = {}
function Display.render()
local mon = Peripherals.primaryMonitor
if not mon then return end
local w, h = mon.getSize()
local contentBottom = 4
mon.clear()
mon.setCursorPos(1,1)
mon.setTextColor(COLORS.INFO)
mon.write("STATION: " .. State.stationName .. " " .. APP_VERSION_TEXT)
mon.setCursorPos(1,2)
mon.write("CODE: " .. tostring(State.stationCode) .. " P:" .. tostring(State.stationPlatform) .. " " .. tostring(State.stationIdentityMode))
mon.setCursorPos(1,3)
mon.write("TIME: " .. os.date("%H:%M:%S"))
local function classifyAudioSource(source, priority)
local s = tostring(source or ""):lower()
if s == "train" or s == "station" or s == "broadcast" then return "Station Announcement" end
if s ~= "" then return "Special Announcement(" .. tostring(source) .. ")" end
if (tonumber(priority) or 0) >= 2 then return "Special Announcement" end
return "Special Announcement"
end
local function trunc(str)
str = tostring(str or "")
if #str > w then return str:sub(1, w) end
return str
end
mon.setCursorPos(1,4)
if State.currentPlaying then
local p = tonumber(State.currentPlaying.priority) or 0
local kind = classifyAudioSource(State.currentPlaying.source, p)
mon.setTextColor(COLORS.SUCCESS)
mon.write(trunc("AUDIO: " .. kind .. " Playing (P=" .. tostring(p) .. ", Q=" .. tostring(#State.audioQueue) .. ")"))
elseif State.audioRx then
local p = tonumber(State.audioRx.priority) or 0
local kind = classifyAudioSource(State.audioRx.source, p)
local rx = tonumber(State.audioRx.receivedBytes) or 0
local total = tonumber(State.audioRx.expectedBytes) or 0
local pct = ""
if total > 0 then pct = " " .. tostring(math.floor((rx / total) * 100)) .. "%" end
mon.setTextColor(COLORS.DEBUG)
mon.write(trunc("AUDIO: " .. kind .. " Receiving" .. pct .. " (Q=" .. tostring(#State.audioQueue) .. ")"))
else
mon.setTextColor(COLORS.INFO)
mon.write(trunc("AUDIO: FREE (Q=" .. tostring(#State.audioQueue) .. ")"))
end
-- Current Train
if State.currentTrain then
mon.setCursorPos(1,5)
mon.setTextColor(COLORS.SUCCESS)
mon.write("CURRENT TRAIN:")
mon.setTextColor(COLORS.INFO)
mon.setCursorPos(1,6)
mon.write(State.currentTrain.id)
mon.setCursorPos(1,7)
mon.write("Dest: " .. State.currentTrain.dest)
-- Countdown
if State.currentTrain.departureTime then
local remaining = math.max(0, State.currentTrain.departureTime - os.epoch("utc") / 1000)
mon.setCursorPos(1, 9)
if remaining > 0 then
mon.setTextColor(COLORS.WARNING)
mon.write(string.format("Departing in: %02d:%02d", math.floor(remaining/60), math.floor(remaining%60)))
else
mon.setTextColor(COLORS.ERROR)
mon.write("Departing...")
end
contentBottom = 9
else
contentBottom = 7
end
else
mon.setCursorPos(1,5)
mon.setTextColor(COLORS.WARNING)
if Peripherals.station then
mon.write("NO TRAIN AT PLATFORM")
else
mon.write("SLAVE NODE MODE")
end
contentBottom = 5
end
-- Upcoming Trains
local logsStartY = h
local upcomingStartY = contentBottom + 1
if Peripherals.blockReader or Peripherals.createTarget then
local hasUpcoming = State.upcomingTrains and #State.upcomingTrains > 0
local maxUpcomingRows = math.max(0, h - upcomingStartY)
mon.setTextColor(COLORS.DEBUG)
mon.setCursorPos(1, upcomingStartY)
mon.write("UPCOMING TRAINS:")
if hasUpcoming then
mon.setTextColor(COLORS.INFO)
local visibleCount = math.min(3, #State.upcomingTrains, math.max(0, maxUpcomingRows - 1))
for i = 1, visibleCount do
local t = State.upcomingTrains[i]
mon.setCursorPos(1, upcomingStartY + 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)
if #lineText > w then lineText = lineText:sub(1, w) end
mon.write(lineText)
end
logsStartY = math.max(logsStartY, upcomingStartY + visibleCount + 1)
else
mon.setTextColor(COLORS.WARNING)
local msgY = upcomingStartY + 1
if msgY <= h then
mon.setCursorPos(1, msgY)
mon.write("NONE / WAITING DATA")
logsStartY = math.max(logsStartY, msgY + 1)
else
logsStartY = math.max(logsStartY, msgY)
end
end
end
-- Logs
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
mon.setCursorPos(1, logsStartY + i - 1)
mon.setTextColor(COLORS[entry.level] or colors.white)
mon.write(entry.text:sub(1, w))
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
-- Simple touch to scroll: top half of log area scrolls up, bottom half scrolls down
local _, monH = Peripherals.primaryMonitor.getSize()
local y = param3
if y >= monH - 3 then
if y == monH - 3 then -- Top line of log area
State.logScrollOffset = math.min(State.logScrollOffset + 1, math.max(0, #State.logs - 3))
elseif y == monH then -- Bottom line of log area
State.logScrollOffset = math.max(0, State.logScrollOffset - 1)
end
Display.renderTerminal()
Display.render()
end
elseif event == "mouse_click" then
local button, x, y = param1, param2, param3
if y == 1 then
Utils.promptConfig()
Display.renderTerminal()
Display.render()
end
elseif event == "key" then
if param1 == keys.up then
State.logScrollOffset = math.min(State.logScrollOffset + 1, math.max(0, #State.logs - maxLogs))
elseif param1 == keys.down then
State.logScrollOffset = math.max(0, State.logScrollOffset - 1)
elseif param1 == keys.pageUp then
State.logScrollOffset = math.min(State.logScrollOffset + 10, math.max(0, #State.logs - maxLogs))
elseif param1 == keys.pageDown then
State.logScrollOffset = math.max(0, State.logScrollOffset - 10)
elseif param1 == keys.home then
State.logScrollOffset = 0
elseif param1 == keys["end"] then
State.logScrollOffset = math.max(0, #State.logs - maxLogs)
elseif param1 == keys.c then
Utils.promptConfig()
end
Display.renderTerminal()
Display.render()
end
end
end
-- ============================================================================
-- 8. Main Logic
-- ============================================================================
local Main = {}
State.announcedApproaching = {}
State.pendingApproaching = {}
local function parseEtaText(etaStr)
etaStr = tostring(etaStr or "")
if etaStr == "" then return -1 end
if string.find(etaStr, "现在", 1, true)
or string.find(etaStr, "到达", 1, true)
or string.find(etaStr, "^0+$")
then
return 0
end
local mm, ss = string.match(etaStr, "(%d+)%s*:%s*(%d+)")
if mm and ss then
return tonumber(mm) * 60 + tonumber(ss)
end
local min, sec = string.match(etaStr, "(%d+)%s*分.-(%d+)%s*秒")
if min and sec then
return tonumber(min) * 60 + tonumber(sec)
end
local secOnly = string.match(etaStr, "(%d+)%s*秒")
if secOnly then
return tonumber(secOnly)
end
local minOnly = string.match(etaStr, "(%d+)%s*分")
if minOnly then
return tonumber(minOnly) * 60
end
local num, unit = string.match(etaStr, "(%d+)%s*(.*)")
if num then
num = tonumber(num)
unit = tostring(unit or ""):lower()
if unit == "" then
return num
end
if string.find(unit, "秒", 1, true) or string.find(unit, "s", 1, true) or string.find(unit, "sec", 1, true) or (unit == "??" and num > 10) then
return num
end
return num * 60
end
return -1
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
return tostring(math.max(0, tonumber(etaSeconds) or 0)) .. "s"
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 = "delay",
cancelled = "cancel"
}
return known[tail] or tail
end
function Main.loop()
while true do
local now = os.epoch("utc")
local newUpcoming = {}
local etaTextByKey = {}
-- Read CRN text ETA only for display wording.
-- Timing decisions and numeric ETA still 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 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 "")
}
end
end
end
end
end
end
-- Check Block Reader (Advanced Peripherals) first for precise ETA and Delay
if Peripherals.blockReader then
local ok, data = pcall(Peripherals.blockReader.getBlockData)
if ok and data and data.TrainStops then
-- Real-time interpolation: estimate actual tick rate (TPS) from CRN refreshes
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))
for k, v in pairs(State.announcedApproaching) do
if now - v > 300000 then
State.announcedApproaching[k] = nil
end
end
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
local platform = stop.StationInfo and stop.StationInfo.Platform or State.stationPlatform or "1"
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
-- Calculate ETA in seconds using interpolated currentTick
local scheduledArrival = pickEarlierTick(st.ScheduledArrival, st.ScheduledDeparture)
local realArrival = pickEarlierTick(st.RealTimeArrival, st.RealTimeDeparture)
local etaTicks = realArrival - currentTick
local etaSeconds = math.floor(etaTicks / (State.tickRate or 20))
if etaSeconds < 0 then etaSeconds = 0 end
local announceKey = normalizeUpcomingKey(trainName, destination)
local etaLabel = buildEtaLabel(etaSeconds, etaTextByKey[announceKey])
-- Calculate Delay in minutes
local delayTicks = realArrival - scheduledArrival
local delayMinutes = math.floor(delayTicks / ((State.tickRate or 20) * 60))
if delayMinutes < 0 then delayMinutes = 0 end
table.insert(newUpcoming, {
trainName = trainName,
dest = destination,
platform = platform,
eta = etaSeconds,
etaLabel = etaLabel,
delay = delayMinutes,
cancelled = cancelled,
reason = reason
})
if etaSeconds >= 0 and etaSeconds <= 45 then
if not State.announcedApproaching[announceKey] then
State.announcedApproaching[announceKey] = now
Utils.log("INFO", string.format("Train Approaching: %s (ETA: %ds, Delay: %dm)", trainName, etaSeconds, delayMinutes))
Network.send({
type = "train_approaching",
stationName = State.stationName,
stationCode = State.stationCode,
stationPlatform = platform,
trainId = trainName,
destination = destination,
eta = tostring(etaSeconds) .. "s",
etaSeconds = etaSeconds,
delay = delayMinutes
})
end
end
end
end
end
end
-- Try CC:C Bridge Display Link (Legacy Text Parsing) if Block Reader is not available
if not Peripherals.blockReader and Peripherals.createTarget then
local lines = Peripherals.createTarget.dump()
for k, v in pairs(State.announcedApproaching) do
if now - v > 300000 then
State.announcedApproaching[k] = nil
end
end
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 then
local announceKey = tName .. "_" .. dest
-- 将 ETA 转换为秒数进行精准判断
local etaSeconds = -1
local isNow = string.find(etaStr, "现在") or string.find(etaStr, "0")
if isNow then
etaSeconds = 0
else
local num, unit = string.match(etaStr, "(%d+)%s*(.*)")
if num then
num = tonumber(num)
-- 如果单位包含秒的字眼,或者是乱码但数值>10(如 45 ??, 30 ??),则认为是秒
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
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
})
-- 触发逻辑:当 ETA 已经明确 <= 45秒(包括30秒、15秒、现在等),且之前没有播报过
if etaSeconds >= 0 and etaSeconds <= 45 then
if 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
-- Update Upcoming Trains globally for the monitor
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
})
else
local now = os.epoch("utc") / 1000
if not State.currentTrain.departureSent and (State.currentTrain.departureTime - now) <= 10 then
State.currentTrain.departureSent = true
Network.send({
type = "train_departure",
stationName = State.stationName,
stationCode = State.stationCode,
stationPlatform = State.stationPlatform,
trainId = State.currentTrain.id,
destination = State.currentTrain.dest,
nextStation = State.currentTrain.nextStation,
departureBeep = State.departureBeep
})
end
end
else
if State.currentTrain then
State.currentTrain = nil
end
end
Display.render()
Display.renderTerminal()
os.sleep(0.5)
::continue::
end
end
-- ============================================================================
-- 9. Bootstrap
-- ============================================================================
Peripherals.init()
Utils.ensureSlaveIdentityConfigured()
Audio.loadStationList()
Utils.refreshStationIdentity()
Utils.syncStationIdentityFromPeripheral()
Utils.refreshStationIdentity()
Utils.log("INFO", "StationBroadcast version " .. APP_VERSION_TEXT)
if Peripherals.station then
Utils.log("INFO", "Train station peripheral detected on " .. tostring(State.stationPeripheralSide or "unknown"))
else
Utils.log("WARN", "No train station peripheral detected; using manual slave mode")
Utils.log("INFO", "Edit " .. CONFIG.STATION_IDENTITY_FILE .. " to set station_code/station_platform")
end
if Peripherals.primaryMonitor then
Utils.log("INFO", "Primary monitor detected")
else
Utils.log("WARN", "No monitor detected; running in terminal-only mode")
end
parallel.waitForAny(
function()
while true do
if not State.ws then Network.connect() end
os.sleep(5)
end
end,
Audio.playerLoop,
Network.messageLoop,
Network.heartbeat,
Network.syncFiles,
Input.listener,
Main.loop
)