Publish the latest station script from the console so online hosts download and reboot immediately while offline hosts update on their next load.
3057 lines
112 KiB
Lua
3057 lines
112 KiB
Lua
|
|
-- ============================================================================
|
|
-- 1. Configuration & Constants
|
|
-- ============================================================================
|
|
local APP_VERSION_TEXT = "v1.0.45"
|
|
|
|
local CONFIG = {
|
|
CLOUD_URL = "ws://stabadmin.fse-media.group",
|
|
API_URL = "http://stabadmin.fse-media.group",
|
|
STARTUP_PATH = "/startup",
|
|
SYNC_INTERVAL = 30,
|
|
AUDIO_BASE = "Audio/",
|
|
AUDIO_STATION = "Audio/Station/",
|
|
DISK_AUDIO_BASE = "disk/Audio/",
|
|
DISK_AUDIO_STATION = "disk/Audio/Station/",
|
|
STATION_IDENTITY_FILE = "station_identity.cfg",
|
|
AUDIO_VOLUME = 100,
|
|
AUDIO_STREAM_PREROLL_CHUNKS = 5,
|
|
ALERT_CONFIRM_MS = 15000,
|
|
ALERT_COOLDOWN_MS = 300000,
|
|
APPROACHING_COOLDOWN_MS = 120000,
|
|
APPROACHING_STABLE_MS = 5000,
|
|
DEBUG = 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,
|
|
updateInProgress = false
|
|
}
|
|
|
|
-- ============================================================================
|
|
-- 3. Peripherals Setup
|
|
-- ============================================================================
|
|
local Peripherals = {}
|
|
|
|
function Peripherals.isTrainStationPeripheral(name)
|
|
if not name then
|
|
return false
|
|
end
|
|
|
|
local pType = peripheral.getType(name)
|
|
if pType == "train_station" then
|
|
return true
|
|
end
|
|
|
|
local wrapped = peripheral.wrap(name)
|
|
if not wrapped then
|
|
return false
|
|
end
|
|
|
|
-- 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
|
|
|
|
local AutoUpdate = {}
|
|
|
|
function AutoUpdate.fetch(url)
|
|
if not http then
|
|
return nil, "HTTP API unavailable"
|
|
end
|
|
|
|
if http.checkURL then
|
|
local ok, err = http.checkURL(url)
|
|
if not ok then
|
|
return nil, err or "URL blocked"
|
|
end
|
|
end
|
|
|
|
local res, err = http.get(url)
|
|
if not res then
|
|
return nil, err or "request failed"
|
|
end
|
|
|
|
local body = res.readAll()
|
|
res.close()
|
|
|
|
if type(body) ~= "string" or #body == 0 then
|
|
return nil, "empty response"
|
|
end
|
|
|
|
return body
|
|
end
|
|
|
|
function AutoUpdate.writeFile(path, content)
|
|
local dir = fs.getDir(path)
|
|
if dir and dir ~= "" and not fs.exists(dir) then
|
|
fs.makeDir(dir)
|
|
end
|
|
|
|
local handle = fs.open(path, "w")
|
|
if not handle then
|
|
return nil, "cannot open " .. path
|
|
end
|
|
|
|
handle.write(content)
|
|
handle.close()
|
|
return true
|
|
end
|
|
|
|
function AutoUpdate.getStartupPath()
|
|
if shell and shell.resolve then
|
|
return shell.resolve(CONFIG.STARTUP_PATH)
|
|
end
|
|
return CONFIG.STARTUP_PATH
|
|
end
|
|
|
|
function AutoUpdate.getDownloadUrl(info)
|
|
if type(info) == "table" then
|
|
local direct = Utils.trim(info.downloadUrl)
|
|
if direct ~= "" then
|
|
return direct
|
|
end
|
|
|
|
local path = Utils.trim(info.downloadPath)
|
|
if path ~= "" then
|
|
return CONFIG.API_URL .. path
|
|
end
|
|
end
|
|
|
|
return CONFIG.API_URL .. "/downloads/station_broadcast.lua"
|
|
end
|
|
|
|
function AutoUpdate.fetchInfo()
|
|
local body, err = AutoUpdate.fetch(CONFIG.API_URL .. "/api/station-update")
|
|
if not body then
|
|
return nil, err
|
|
end
|
|
|
|
local ok, data = pcall(textutils.unserializeJSON, body)
|
|
if not ok or type(data) ~= "table" then
|
|
return nil, "invalid update response"
|
|
end
|
|
|
|
return data
|
|
end
|
|
|
|
function AutoUpdate.getTargetVersion(info)
|
|
if type(info) ~= "table" then
|
|
return ""
|
|
end
|
|
return Utils.trim(info.targetVersion or info.version)
|
|
end
|
|
|
|
function AutoUpdate.needsUpdate(info)
|
|
local targetVersion = AutoUpdate.getTargetVersion(info)
|
|
return targetVersion ~= "" and targetVersion ~= APP_VERSION_TEXT
|
|
end
|
|
|
|
function AutoUpdate.apply(info, reason)
|
|
if State.updateInProgress then
|
|
return false, "update already in progress"
|
|
end
|
|
|
|
local targetVersion = AutoUpdate.getTargetVersion(info)
|
|
if targetVersion == "" then
|
|
return false, "missing target version"
|
|
end
|
|
|
|
local downloadUrl = AutoUpdate.getDownloadUrl(info)
|
|
local startupPath = AutoUpdate.getStartupPath()
|
|
|
|
State.updateInProgress = true
|
|
Utils.log("WARN", "Applying update " .. targetVersion .. " via " .. tostring(reason or "manual"))
|
|
|
|
if Audio and Audio.stop then
|
|
pcall(Audio.stop, "all")
|
|
end
|
|
if State.ws then
|
|
pcall(State.ws.close)
|
|
State.ws = nil
|
|
end
|
|
|
|
local body, err = AutoUpdate.fetch(downloadUrl)
|
|
if not body then
|
|
State.updateInProgress = false
|
|
return false, err
|
|
end
|
|
|
|
local ok, writeErr = AutoUpdate.writeFile(startupPath, body)
|
|
if not ok then
|
|
State.updateInProgress = false
|
|
return false, writeErr
|
|
end
|
|
|
|
Utils.log("SUCCESS", "Updated startup to " .. targetVersion)
|
|
Utils.log("WARN", "Rebooting to finish update")
|
|
os.sleep(1)
|
|
os.reboot()
|
|
return true
|
|
end
|
|
|
|
function AutoUpdate.checkOnBoot()
|
|
local info, err = AutoUpdate.fetchInfo()
|
|
if not info then
|
|
Utils.log("WARN", "Update check skipped: " .. tostring(err))
|
|
return false
|
|
end
|
|
|
|
if not AutoUpdate.needsUpdate(info) then
|
|
return false
|
|
end
|
|
|
|
Utils.log("INFO", "Found published update " .. AutoUpdate.getTargetVersion(info) .. " on boot")
|
|
local ok, applyErr = AutoUpdate.apply(info, "boot")
|
|
if not ok then
|
|
Utils.log("ERROR", "Boot update failed: " .. tostring(applyErr))
|
|
end
|
|
return ok
|
|
end
|
|
|
|
-- ============================================================================
|
|
-- 6. Network System
|
|
-- ============================================================================
|
|
local Network = {}
|
|
|
|
function Network.connect()
|
|
if State.ws then return end
|
|
Utils.log("INFO", "Connecting to cloud...")
|
|
State.ws = http.websocket(CONFIG.CLOUD_URL)
|
|
if State.ws then
|
|
Utils.log("SUCCESS", "Connected")
|
|
Network.register()
|
|
else
|
|
Utils.log("ERROR", "Connection failed")
|
|
end
|
|
end
|
|
|
|
function Network.send(data)
|
|
if State.ws then
|
|
local ok, err = pcall(function()
|
|
State.ws.send(textutils.serializeJSON(data))
|
|
end)
|
|
if not ok then
|
|
Utils.log("ERROR", "Failed to send WS message: " .. tostring(err))
|
|
end
|
|
end
|
|
end
|
|
|
|
function Network.register()
|
|
Utils.refreshStationIdentity()
|
|
|
|
Network.send({
|
|
type = "station_register",
|
|
stationName = State.stationName,
|
|
stationCode = State.stationCode,
|
|
stationPlatform = State.stationPlatform,
|
|
stationVersion = APP_VERSION_TEXT
|
|
})
|
|
end
|
|
|
|
function Network.syncFiles()
|
|
-- 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
|
|
elseif data.type == "station_update" then
|
|
local targetVersion = AutoUpdate.getTargetVersion(data)
|
|
if targetVersion == "" then
|
|
Utils.log("WARN", "Ignored station update without version")
|
|
elseif targetVersion == APP_VERSION_TEXT then
|
|
Utils.log("INFO", "Received update notice for current version " .. targetVersion)
|
|
else
|
|
Utils.log("WARN", "Received published update " .. targetVersion)
|
|
local okUpdate, updateErr = AutoUpdate.apply({
|
|
targetVersion = targetVersion,
|
|
version = targetVersion,
|
|
releaseId = data.releaseId,
|
|
downloadPath = data.downloadPath,
|
|
downloadUrl = data.downloadUrl
|
|
}, "publish")
|
|
if not okUpdate then
|
|
Utils.log("ERROR", "Station update failed: " .. tostring(updateErr))
|
|
end
|
|
end
|
|
end
|
|
else
|
|
if State.audioRx then
|
|
-- Memory protection: limit to 10MB per audio item
|
|
local isLiveStream = State.audioRx.stream and (tonumber(State.audioRx.expectedBytes) or 0) <= 0
|
|
if (not isLiveStream) and State.audioRx.receivedBytes + #msg > 10 * 1024 * 1024 then
|
|
Utils.log("ERROR", "Audio item too large, discarding")
|
|
if State.audioRx.stream then
|
|
State.audioRx.stream.finish()
|
|
end
|
|
State.audioRx = nil
|
|
else
|
|
if State.audioRx.stream then
|
|
State.audioRx.stream.push(msg)
|
|
else
|
|
table.insert(State.audioRx.chunks, msg)
|
|
end
|
|
State.audioRx.receivedBytes = State.audioRx.receivedBytes + #msg
|
|
if State.audioRx.expectedBytes > 0 and State.audioRx.receivedBytes >= State.audioRx.expectedBytes then
|
|
finishAudioRx()
|
|
end
|
|
end
|
|
end
|
|
end
|
|
elseif event == "websocket_closed" then
|
|
Utils.log("WARN", "WS Closed")
|
|
State.ws = nil
|
|
State.audioRx = nil -- 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.announcedAlerts = {}
|
|
State.pendingAlerts = {}
|
|
State.pendingApproaching = {}
|
|
|
|
local function cleanupTimestampMap(map, now, maxAgeMs)
|
|
for k, v in pairs(map or {}) do
|
|
if now - v > maxAgeMs then
|
|
map[k] = nil
|
|
end
|
|
end
|
|
end
|
|
|
|
local function isPendingAlertReady(key, now)
|
|
local firstSeen = State.pendingAlerts[key]
|
|
if not firstSeen then
|
|
State.pendingAlerts[key] = now
|
|
return false
|
|
end
|
|
return (now - firstSeen) >= (tonumber(CONFIG.ALERT_CONFIRM_MS) or 60000)
|
|
end
|
|
|
|
local function 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
|
|
local secs = math.max(0, tonumber(etaSeconds) or 0)
|
|
local whole = math.floor(secs + 0.0001)
|
|
if whole >= 60 then
|
|
return string.format("%02d:%02d", math.floor(whole / 60), whole % 60)
|
|
end
|
|
if secs >= 10 then
|
|
return string.format("%02ds", whole)
|
|
end
|
|
return string.format("%.1fs", secs)
|
|
end
|
|
|
|
local function mapTrainStatusReason(statusId)
|
|
local key = tostring(statusId or "")
|
|
if key == "" then
|
|
return nil
|
|
end
|
|
local tail = string.match(key, ":(.+)$") or key
|
|
local known = {
|
|
delay_from_previous_journey = "prev",
|
|
previous_train_delayed = "train",
|
|
priority_other_train = "prio",
|
|
red_signal = "signal",
|
|
track_closed = "track",
|
|
staff_shortage = "staff",
|
|
operational_disruption = "ops",
|
|
special_journey = "special",
|
|
out_of_service = "oos",
|
|
default_delay = "default_delay",
|
|
cancelled = "cancelled"
|
|
}
|
|
return known[tail] or tail
|
|
end
|
|
|
|
function Main.loop()
|
|
while true do
|
|
local now = os.epoch("utc")
|
|
local newUpcoming = {}
|
|
local etaTextByKey = {}
|
|
local delayTextByKey = {}
|
|
|
|
-- 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 delayMatch = string.match(lineText, "%+%s*(%d+)")
|
|
local textDelay = tonumber(delayMatch) or 0
|
|
local cleanLine = string.gsub(lineText, "%s*%(?%+%s*%d+.-%)?", "")
|
|
local etaValue, etaUnit, tName, dest
|
|
etaValue, etaUnit, tName, dest = string.match(cleanLine, "^(%S+)%s+(.-)%s+(%[.-%][^%s]*)%s+(.*)$")
|
|
if not tName then
|
|
etaValue, etaUnit, tName, dest = string.match(cleanLine, "^([^%s]+)%s+(.-)%s+([^%s]+)%s+(.*)$")
|
|
end
|
|
if tName and etaValue then
|
|
if dest then
|
|
local textKey = normalizeUpcomingKey(tName, dest)
|
|
etaTextByKey[textKey] = {
|
|
value = etaValue,
|
|
unit = etaUnit,
|
|
raw = tostring(etaValue or "") .. ((etaUnit and etaUnit ~= "") and (" " .. tostring(etaUnit)) or "")
|
|
}
|
|
if textDelay > 0 then
|
|
delayTextByKey[textKey] = textDelay
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- 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))
|
|
local seenAlertKeys = {}
|
|
local seenApproachKeys = {}
|
|
|
|
cleanupTimestampMap(State.announcedApproaching, now, tonumber(CONFIG.APPROACHING_COOLDOWN_MS) or 120000)
|
|
cleanupTimestampMap(State.announcedAlerts, now, tonumber(CONFIG.ALERT_COOLDOWN_MS) or 300000)
|
|
|
|
for i, stop in ipairs(data.TrainStops) do
|
|
local st = stop.Station
|
|
if st then
|
|
local trainName = st.TrainName or "Unknown"
|
|
local destination = st.Destination or "Unknown"
|
|
if type(stop.Stopovers) == "table" and #stop.Stopovers > 0 then
|
|
destination = stop.Stopovers[#stop.Stopovers] or destination
|
|
end
|
|
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 = 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
|
|
local displayDelayMinutes = math.max(delayMinutes, tonumber(delayTextByKey[announceKey]) or 0)
|
|
|
|
table.insert(newUpcoming, {
|
|
trainName = trainName,
|
|
dest = destination,
|
|
platform = platform,
|
|
eta = etaSeconds,
|
|
etaLabel = etaLabel,
|
|
delay = displayDelayMinutes,
|
|
cancelled = cancelled,
|
|
reason = reason
|
|
})
|
|
|
|
local alertType = cancelled and "cancelled" or ((displayDelayMinutes > 0) and "delayed" or nil)
|
|
local alertKey = alertType and (announceKey .. "::" .. alertType) or nil
|
|
if alertKey then
|
|
seenAlertKeys[alertKey] = true
|
|
if isPendingAlertReady(alertKey, now) and not State.announcedAlerts[alertKey] then
|
|
State.announcedAlerts[alertKey] = now
|
|
Utils.log("INFO", string.format("Train Alert: %s (ETA: %ds, Delay: %dm, Cancelled: %s, Reason: %s)", trainName, etaSeconds, displayDelayMinutes, tostring(cancelled), tostring(reason or "")))
|
|
Network.send({
|
|
type = "train_approaching",
|
|
stationName = State.stationName,
|
|
stationCode = State.stationCode,
|
|
stationPlatform = platform,
|
|
trainId = trainName,
|
|
destination = destination,
|
|
eta = tostring(etaSeconds) .. "s",
|
|
etaSeconds = etaSeconds,
|
|
delay = displayDelayMinutes,
|
|
cancelled = cancelled,
|
|
reason = reason
|
|
})
|
|
end
|
|
end
|
|
|
|
if etaSeconds >= 0 and etaSeconds <= 45 then
|
|
seenApproachKeys[announceKey] = true
|
|
local pending = State.pendingApproaching[announceKey]
|
|
if not pending then
|
|
State.pendingApproaching[announceKey] = { since = now, lastEta = etaSeconds }
|
|
else
|
|
if etaSeconds > (tonumber(pending.lastEta) or etaSeconds) + 3 then
|
|
pending.since = now
|
|
end
|
|
pending.lastEta = etaSeconds
|
|
end
|
|
|
|
local stableMs = tonumber(CONFIG.APPROACHING_STABLE_MS) or 15000
|
|
local currentPending = State.pendingApproaching[announceKey]
|
|
if currentPending and (now - (currentPending.since or now)) >= stableMs and not State.announcedApproaching[announceKey] then
|
|
State.announcedApproaching[announceKey] = now
|
|
Utils.log("INFO", string.format("Train Approaching: %s (ETA: %.1fs, Delay: %dm)", trainName, etaSeconds, delayMinutes))
|
|
Network.send({
|
|
type = "train_approaching",
|
|
stationName = State.stationName,
|
|
stationCode = State.stationCode,
|
|
stationPlatform = platform,
|
|
trainId = trainName,
|
|
destination = destination,
|
|
eta = string.format("%.1fs", etaSeconds),
|
|
etaSeconds = etaSeconds,
|
|
delay = displayDelayMinutes,
|
|
cancelled = cancelled,
|
|
reason = reason
|
|
})
|
|
end
|
|
else
|
|
State.pendingApproaching[announceKey] = nil
|
|
end
|
|
end
|
|
end
|
|
|
|
for k, _ in pairs(State.pendingAlerts) do
|
|
if not seenAlertKeys[k] then
|
|
State.pendingAlerts[k] = nil
|
|
end
|
|
end
|
|
for k, _ in pairs(State.pendingApproaching) do
|
|
if not seenApproachKeys[k] then
|
|
State.pendingApproaching[k] = nil
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- 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()
|
|
local seenAlertKeys = {}
|
|
local seenApproachKeys = {}
|
|
|
|
cleanupTimestampMap(State.announcedApproaching, now, tonumber(CONFIG.APPROACHING_COOLDOWN_MS) or 120000)
|
|
cleanupTimestampMap(State.announcedAlerts, now, tonumber(CONFIG.ALERT_COOLDOWN_MS) or 300000)
|
|
|
|
for _, lineText in pairs(lines) do
|
|
if lineText and lineText ~= "" and lineText:match("[%w%?%[]") then
|
|
local delayMatch = string.match(lineText, "%+%s*(%d+)")
|
|
local delay = tonumber(delayMatch) or 0
|
|
|
|
-- 从文本中移除延误相关的字眼,防止干扰后续的正则表达式匹配
|
|
local cleanLine = string.gsub(lineText, "%s*%(?%+%s*%d+.-%)?", "")
|
|
|
|
local platform, etaStr, tName, dest
|
|
platform, etaStr, tName, dest = string.match(cleanLine, "^(%d+)%s+(.-)%s+(%[.-%][^%s]*)%s+(.*)$")
|
|
if not tName then
|
|
platform, etaStr, tName, dest = string.match(cleanLine, "^([^%s]+)%s+(.-)%s+(%[.-%][^%s]*)%s+(.*)$")
|
|
end
|
|
if not tName then
|
|
platform, etaStr, tName, dest = string.match(cleanLine, "^([^%s]+)%s+(.-)%s+([^%s]+)%s+(.*)$")
|
|
end
|
|
|
|
if tName and etaStr 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
|
|
})
|
|
|
|
local alertKey = announceKey .. "::delayed"
|
|
if delay > 0 then
|
|
seenAlertKeys[alertKey] = true
|
|
if isPendingAlertReady(alertKey, now) and not State.announcedAlerts[alertKey] then
|
|
State.announcedAlerts[alertKey] = now
|
|
Utils.log("INFO", string.format("Train Delay Alert: %s (ETA: %ds, Delay: %dm)", tName, etaSeconds, delay))
|
|
Network.send({
|
|
type = "train_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
|
|
|
|
-- 触发逻辑:当 ETA 已经明确不大于45秒,且之前没有播报过
|
|
if etaSeconds >= 0 and etaSeconds <= 45 then
|
|
seenApproachKeys[announceKey] = true
|
|
local pending = State.pendingApproaching[announceKey]
|
|
if not pending then
|
|
State.pendingApproaching[announceKey] = { since = now, lastEta = etaSeconds }
|
|
else
|
|
if etaSeconds > (tonumber(pending.lastEta) or etaSeconds) + 3 then
|
|
pending.since = now
|
|
end
|
|
pending.lastEta = etaSeconds
|
|
end
|
|
|
|
local stableMs = tonumber(CONFIG.APPROACHING_STABLE_MS) or 15000
|
|
local currentPending = State.pendingApproaching[announceKey]
|
|
if currentPending and (now - (currentPending.since or now)) >= stableMs and not State.announcedApproaching[announceKey] then
|
|
State.announcedApproaching[announceKey] = now
|
|
Utils.log("INFO", string.format("Train Approaching: %s (ETA: %ds, Delay: %dm)", tName, etaSeconds, delay))
|
|
Network.send({
|
|
type = "train_approaching",
|
|
stationName = State.stationName,
|
|
stationCode = State.stationCode,
|
|
stationPlatform = platform or State.stationPlatform or "1",
|
|
trainId = tName,
|
|
destination = dest,
|
|
eta = etaStr,
|
|
etaSeconds = etaSeconds,
|
|
delay = delay
|
|
})
|
|
end
|
|
else
|
|
State.pendingApproaching[announceKey] = nil
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
for k, _ in pairs(State.pendingAlerts) do
|
|
if not seenAlertKeys[k] then
|
|
State.pendingAlerts[k] = nil
|
|
end
|
|
end
|
|
for k, _ in pairs(State.pendingApproaching) do
|
|
if not seenApproachKeys[k] then
|
|
State.pendingApproaching[k] = nil
|
|
end
|
|
end
|
|
end
|
|
|
|
-- 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
|
|
-- ============================================================================
|
|
AutoUpdate.checkOnBoot()
|
|
Peripherals.init()
|
|
Utils.ensureSlaveIdentityConfigured()
|
|
Audio.loadStationList()
|
|
Utils.refreshStationIdentity()
|
|
Utils.syncStationIdentityFromPeripheral()
|
|
Utils.refreshStationIdentity()
|
|
Utils.log("INFO", "StationBroadcast version " .. APP_VERSION_TEXT)
|
|
if Peripherals.station then
|
|
Utils.log("INFO", "Train station peripheral detected on " .. tostring(State.stationPeripheralSide or "unknown"))
|
|
else
|
|
Utils.log("WARN", "No train station peripheral detected; using manual slave mode")
|
|
Utils.log("INFO", "Edit " .. CONFIG.STATION_IDENTITY_FILE .. " to set station_code/station_platform")
|
|
end
|
|
if Peripherals.primaryMonitor then
|
|
Utils.log("INFO", "Primary monitor detected")
|
|
else
|
|
Utils.log("WARN", "No monitor detected; running in terminal-only mode")
|
|
end
|
|
|
|
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
|
|
)
|
|
|