1. 修复 `Utils.getStopTime` 和 `Utils.getNextStation` 会误匹配“非车站指令”(如修改最高速度等指令)中的文本的问题,现在仅在 `instruction.id == create:station` 或 `create:destination` 的条目中扫描,保证了 CC 电脑从 `create:track_station` 获取到的时刻表信息(包括计算出的停站时间)100% 准确。 2. 修复显示器(Display Board / NBT)在列车停站后依然显示“离站倒计时 ETA”,导致触发 `train_approaching` 的问题:如果当前列车已经在站内(`State.currentTrain` 存在且匹配),将强制忽略该列车的 `train_approaching` 触发逻辑,彻底解决列车停站时倒秒导致的重复播报。
3041 lines
110 KiB
Lua
3041 lines
110 KiB
Lua
|
|
-- 1. Configuration & Constants
|
|
local APP_VERSION_TEXT = "v1.0.55"
|
|
|
|
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
|
|
|
|
return type(wrapped.getStationName) == "function"
|
|
and type(wrapped.isTrainPresent) == "function"
|
|
and type(wrapped.getTrainName) == "function"
|
|
and type(wrapped.getSchedule) == "function"
|
|
end
|
|
|
|
function Peripherals.init()
|
|
local station = nil
|
|
local stationSide = nil
|
|
local checkedNames = {}
|
|
local preferredSides = { "back", "top", "bottom", "left", "right", "front" }
|
|
for _, side in ipairs(preferredSides) do
|
|
checkedNames[side] = true
|
|
if Peripherals.isTrainStationPeripheral(side) then
|
|
station = peripheral.wrap(side)
|
|
stationSide = side
|
|
break
|
|
end
|
|
end
|
|
if not station and peripheral.getNames then
|
|
for _, name in ipairs(peripheral.getNames()) do
|
|
if not checkedNames[name] and Peripherals.isTrainStationPeripheral(name) then
|
|
station = peripheral.wrap(name)
|
|
stationSide = name
|
|
break
|
|
end
|
|
end
|
|
end
|
|
Peripherals.station = station
|
|
State.hasStationPeripheral = station ~= nil
|
|
State.stationPeripheralSide = stationSide
|
|
|
|
local monitors = { peripheral.find("monitor") }
|
|
if #monitors > 0 then
|
|
Peripherals.primaryMonitor = monitors[1]
|
|
Peripherals.primaryMonitor.setTextScale(0.5)
|
|
State.hasPrimaryMonitor = true
|
|
else
|
|
Peripherals.primaryMonitor = nil
|
|
State.hasPrimaryMonitor = false
|
|
end
|
|
|
|
Peripherals.speakers = { peripheral.find("speaker") }
|
|
|
|
Peripherals.createTarget = peripheral.find("create_target")
|
|
if Peripherals.createTarget then
|
|
pcall(Peripherals.createTarget.resize, 80, 10)
|
|
end
|
|
|
|
local blockReader = nil
|
|
local typeCandidates = { "blockReader", "block_reader", "arBlockReader", "advancedperipherals:block_reader" }
|
|
for _, t in ipairs(typeCandidates) do
|
|
local p = peripheral.find(t)
|
|
if p then
|
|
blockReader = p
|
|
break
|
|
end
|
|
end
|
|
if not blockReader and peripheral.getNames then
|
|
for _, name in ipairs(peripheral.getNames()) do
|
|
local p = peripheral.wrap(name)
|
|
if p and type(p.getBlockData) == "function" then
|
|
blockReader = p
|
|
break
|
|
end
|
|
end
|
|
end
|
|
Peripherals.blockReader = blockReader
|
|
end
|
|
|
|
|
|
-- 4. Utility Functions
|
|
|
|
local Utils = {}
|
|
|
|
function Utils.log(level, message)
|
|
local timestamp = os.date("%H:%M:%S")
|
|
local entryText = string.format("[%s][%s] %s", timestamp, level, message)
|
|
local entry = { level = level, text = entryText }
|
|
table.insert(State.logs, 1, entry)
|
|
if #State.logs > 100 then table.remove(State.logs) end
|
|
|
|
if State.logScrollOffset > 0 then
|
|
State.logScrollOffset = math.min(State.logScrollOffset + 1, math.max(0, #State.logs - 1))
|
|
end
|
|
end
|
|
|
|
function Utils.trim(value)
|
|
return tostring(value or ""):match("^%s*(.-)%s*$")
|
|
end
|
|
|
|
function Utils.loadStationIdentity()
|
|
if not fs.exists(CONFIG.STATION_IDENTITY_FILE) then
|
|
return {}
|
|
end
|
|
|
|
local handle = fs.open(CONFIG.STATION_IDENTITY_FILE, "r")
|
|
if not handle then
|
|
return {}
|
|
end
|
|
|
|
local content = handle.readAll() or ""
|
|
handle.close()
|
|
|
|
local data = {}
|
|
for line in content:gmatch("[^\r\n]+") do
|
|
local clean = Utils.trim(line)
|
|
if clean ~= "" and not clean:match("^#") then
|
|
local key, value = clean:match("^([%w_]+)%s*=%s*(.-)%s*$")
|
|
if key then
|
|
data[key:lower()] = Utils.trim(value)
|
|
end
|
|
end
|
|
end
|
|
return data
|
|
end
|
|
|
|
function Utils.saveStationIdentity(stationCode, stationPlatform, departureBeep)
|
|
local handle = fs.open(CONFIG.STATION_IDENTITY_FILE, "w")
|
|
if not handle then
|
|
return false
|
|
end
|
|
handle.write(table.concat({
|
|
"# Station identity cache / override",
|
|
"station_code=" .. Utils.trim(stationCode or ""),
|
|
"station_platform=" .. Utils.trim(stationPlatform or ""),
|
|
"departure_beep=" .. Utils.trim(departureBeep or "fc1"),
|
|
""
|
|
}, "\n"))
|
|
handle.close()
|
|
return true
|
|
end
|
|
|
|
function Utils.hasUsableManualIdentity(data)
|
|
return data
|
|
and type(data) == "table"
|
|
and data.station_code and data.station_code ~= ""
|
|
and data.station_platform and data.station_platform ~= ""
|
|
end
|
|
|
|
function Utils.getAvailableDepartureBeeps()
|
|
local beeps = {"fc1"}
|
|
local ok, resp = pcall(http.get, CONFIG.API_URL .. "/api/files")
|
|
if ok and resp then
|
|
local raw = resp.readAll()
|
|
resp.close()
|
|
local found = {}
|
|
for path in raw:gmatch('"path"%s*:%s*"Audio/beep/([^"]+%.wav)"') do
|
|
local fc = path:match("^(fc.*)%.wav$")
|
|
if fc then
|
|
table.insert(found, fc)
|
|
end
|
|
end
|
|
if #found > 0 then
|
|
beeps = found
|
|
end
|
|
end
|
|
local unique = {}
|
|
local result = {}
|
|
for _, v in ipairs(beeps) do
|
|
if not unique[v] then
|
|
unique[v] = true
|
|
table.insert(result, v)
|
|
end
|
|
end
|
|
table.sort(result)
|
|
return result
|
|
end
|
|
|
|
function Utils.promptSlaveIdentity()
|
|
State.inConfig = true
|
|
while true do
|
|
local availBeeps = Utils.getAvailableDepartureBeeps()
|
|
local 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()
|
|
end
|
|
return nil
|
|
end
|
|
|
|
function Utils.getPlatformNumber(name, default)
|
|
if not name then return default end
|
|
local p = name:match("[Pp]latform%s*([%d]+[a-zA-Z]?)") or name:match("[Tt]rack%s*([%d]+[a-zA-Z]?)")
|
|
if p then return p end
|
|
return name:match("([%d]+[a-zA-Z]?)$") or default
|
|
end
|
|
|
|
function Utils.getBroadcastStationCode(name)
|
|
if not name then return "UNK" end
|
|
name = name:match("^%s*(.-)%s*$")
|
|
name = name:gsub("%s+", "_")
|
|
if name == "" then return "UNK" end
|
|
return name
|
|
end
|
|
|
|
function Utils.isSameTrain(id1, id2)
|
|
if not id1 or not id2 then return false end
|
|
if id1 == id2 then return true end
|
|
|
|
local function getConsist(id)
|
|
local clean = id:gsub("^%[.-%]%s*", "")
|
|
local p = {}
|
|
for s in clean:gmatch("%S+") do table.insert(p, s) end
|
|
if #p >= 2 then
|
|
if #p[1] <= 4 and tonumber(p[2]) then
|
|
return p[1] .. " " .. p[2]
|
|
end
|
|
end
|
|
return p[1] or clean
|
|
end
|
|
|
|
local c1 = getConsist(id1)
|
|
local c2 = getConsist(id2)
|
|
if c1 == c2 then return true end
|
|
|
|
|
|
local _, name1, num1 = Utils.parseTrainId(id1)
|
|
local _, name2, num2 = Utils.parseTrainId(id2)
|
|
|
|
|
|
if num1 ~= "" and num2 ~= "" then
|
|
return name1 == name2 and num1 == num2
|
|
end
|
|
|
|
return name1 == name2
|
|
end
|
|
|
|
function Utils.parseTrainId(trainId)
|
|
if not trainId then return "unknown", "" end
|
|
|
|
|
|
local typeStr, content = trainId:match("^%[(.-)%](.+)$")
|
|
|
|
if typeStr then
|
|
|
|
if typeStr == "Ltd.Exp" or typeStr == "Express" then
|
|
|
|
local name, num = content:match("^(.+) No%.(%d+)$")
|
|
if name then
|
|
return "express", name, num
|
|
else
|
|
return "express", content, ""
|
|
end
|
|
elseif typeStr == "Local" then
|
|
return "regular", "Local", content
|
|
else
|
|
return "regular", typeStr, content
|
|
end
|
|
end
|
|
|
|
return "unknown", "Unknown", trainId
|
|
end
|
|
|
|
function Utils.getDestination(schedule)
|
|
if not schedule then return "Unknown" end
|
|
local entries = schedule.entries or (type(schedule) == "table" and schedule)
|
|
if not entries or type(entries) ~= "table" then return "Unknown" end
|
|
|
|
|
|
if #entries == 0 then
|
|
local count = 0
|
|
for _ in pairs(entries) do count = count + 1 end
|
|
if count > 0 then
|
|
|
|
local list = {}
|
|
for k, v in pairs(entries) do
|
|
local n = tonumber(k)
|
|
if n then list[n] = v end
|
|
end
|
|
entries = list
|
|
end
|
|
end
|
|
|
|
local dest = "Unknown"
|
|
local maxIdx = 0
|
|
for k, _ in pairs(entries) do
|
|
if type(k) == "number" and k > maxIdx then maxIdx = k end
|
|
end
|
|
|
|
|
|
for i = 1, maxIdx do
|
|
local entry = entries[i]
|
|
if entry then
|
|
local inst = entry.instruction
|
|
if inst then
|
|
if inst.id == "create:destination" then
|
|
dest = (inst.data and inst.data.text) or inst.text or dest
|
|
end
|
|
|
|
if inst.id == "create:station" then
|
|
local sName = (inst.data and inst.data.text) or inst.text
|
|
if sName then
|
|
dest = sName
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
return dest
|
|
end
|
|
|
|
function Utils.getBaseName(name)
|
|
if not name then return "" end
|
|
name = tostring(name):lower()
|
|
name = name:gsub("%s*[Pp]latform%s*[%d%a]+", ""):gsub("%s*[Tt]rack%s*[%d%a]+", "")
|
|
name = name:gsub("%s*[%[%(].-[%]%)]%s*", " ")
|
|
name = name:gsub("%s+%d+$", "")
|
|
return name:match("^%s*(.-)%s*$")
|
|
end
|
|
|
|
function Utils.isFuzzyMatch(str, pattern)
|
|
if not str or not pattern then return false end
|
|
str = tostring(str):lower():match("^%s*(.-)%s*$")
|
|
pattern = tostring(pattern):lower():match("^%s*(.-)%s*$")
|
|
|
|
if str == pattern then return true end
|
|
|
|
-- 1. Handle Create Mod Wildcards
|
|
if pattern:find("*") then
|
|
local p = pattern:gsub("([%^%$%(%)%%%.%[%]%+%-%?])", "%%%1")
|
|
p = p:gsub("%%%*", ".*")
|
|
local ok, res = pcall(function() return str:match("^" .. p .. "$") end)
|
|
if ok and res then return true end
|
|
end
|
|
|
|
-- 2. Base name comparison
|
|
local b1 = Utils.getBaseName(str)
|
|
local b2 = Utils.getBaseName(pattern)
|
|
if b1 ~= "" and b2 ~= "" then
|
|
if b1 == b2 then return true end
|
|
if #b1 >= 4 and #b2 >= 4 then
|
|
if b1:find(b2, 1, true) or b2:find(b1, 1, true) then
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
|
|
-- 3. Alphanumeric only comparison
|
|
local a1 = str:gsub("[^%w%d]", "")
|
|
local a2 = pattern:gsub("[^%w%d]", "")
|
|
if a1 ~= "" and a2 ~= "" and (a1 == a2 or a1:find(a2, 1, true) or a2:find(a1, 1, true)) then
|
|
return true
|
|
end
|
|
|
|
return false
|
|
end
|
|
|
|
function Utils.getStopTime(schedule, stationName)
|
|
if not schedule or not schedule.entries or not stationName then return nil end
|
|
|
|
local target = tostring(stationName):lower():match("^%s*(.-)%s*$")
|
|
if target == "" then return nil end
|
|
|
|
local targetCode = Utils.getStationCode(target)
|
|
local targetBase = Utils.getBaseName(target)
|
|
local targetAlpha = target:gsub("[^%w%d]", "")
|
|
|
|
Utils.log("DEBUG", string.format("STATION TARGET: '%s' (Code: %s, Base: %s, Alpha: %s)", target, tostring(targetCode), targetBase, targetAlpha))
|
|
|
|
local function extractTime(data)
|
|
if not data then return nil end
|
|
local val = tonumber(data.value) or tonumber(data.seconds) or tonumber(data.s)
|
|
or tonumber(data.time) or tonumber(data.duration) or tonumber(data.delay)
|
|
local unit = tonumber(data.time_unit) or tonumber(data.unit) or 1
|
|
if val then
|
|
if unit == 0 then return val / 20
|
|
elseif unit == 1 then return val
|
|
elseif unit == 2 then return val * 60
|
|
end
|
|
return val
|
|
end
|
|
local ticks = tonumber(data.ticks) or tonumber(data.tick)
|
|
if ticks then return ticks / 20 end
|
|
return nil
|
|
end
|
|
|
|
local function findTimeInConditions(conditions)
|
|
if not conditions then return nil end
|
|
for i, item in ipairs(conditions) do
|
|
if type(item) == "table" then
|
|
local cid = tostring(item.id or ""):lower()
|
|
if cid:find("delay") or cid:find("wait") or cid:find("idle") or cid:find("pause") then
|
|
local s = extractTime(item.data)
|
|
if s and s > 0 then
|
|
Utils.log("DEBUG", string.format("Found Time in Condition [%d]: %.1fs (ID: %s)", i, s, cid))
|
|
return s
|
|
end
|
|
end
|
|
for k, sub in pairs(item) do
|
|
if type(sub) == "table" then
|
|
local s = findTimeInConditions({sub})
|
|
if s then return s end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local maxIdx = 0
|
|
if schedule.entries then
|
|
for k, _ in pairs(schedule.entries) do
|
|
if type(k) == "number" and k > maxIdx then maxIdx = k end
|
|
end
|
|
end
|
|
|
|
Utils.log("DEBUG", string.format("--- Schedule Scan Start (MaxIdx: %d) ---", maxIdx))
|
|
|
|
local matchFoundIndex = nil
|
|
|
|
for i = 1, maxIdx do
|
|
local entry = schedule.entries[i]
|
|
if entry then
|
|
local isStationEntry = false
|
|
if entry.instruction and (entry.instruction.id == "create:station" or entry.instruction.id == "create:destination") then
|
|
local text = (entry.instruction.data and entry.instruction.data.text) or entry.instruction.text
|
|
if type(text) == "string" then
|
|
local entryText = text:lower()
|
|
local entryCode = Utils.getStationCode(entryText)
|
|
local entryAlpha = entryText:gsub("[^%w%d]", "")
|
|
local entryBase = Utils.getBaseName(entryText)
|
|
|
|
Utils.log("DEBUG", string.format(" [%d] SCAN STATION: '%s' (Code: %s)", i, entryText, tostring(entryCode)))
|
|
|
|
local isMatch = (target == entryText)
|
|
or (targetCode and entryCode and targetCode == entryCode)
|
|
or (targetAlpha ~= "" and entryAlpha ~= "" and (targetAlpha == entryAlpha or targetAlpha:find(entryAlpha, 1, true) or entryAlpha:find(targetAlpha, 1, true)))
|
|
or (targetBase ~= "" and entryBase ~= "" and (targetBase == entryBase or targetBase:find(entryBase, 1, true) or entryBase:find(targetBase, 1, true)))
|
|
or Utils.isFuzzyMatch(target, entryText)
|
|
|
|
if isMatch then
|
|
Utils.log("DEBUG", string.format(" >>> MATCH FOUND AT ENTRY %d", i))
|
|
isStationEntry = true
|
|
end
|
|
end
|
|
end
|
|
|
|
if isStationEntry then
|
|
matchFoundIndex = i
|
|
local s = findTimeInConditions(entry.conditions)
|
|
if s then
|
|
Utils.log("DEBUG", string.format(" >>> TIME RESOLVED FROM CONDITIONS: %.1fs", s))
|
|
return s
|
|
end
|
|
|
|
if entry.instruction and entry.instruction.data then
|
|
local s = extractTime(entry.instruction.data)
|
|
if s then
|
|
Utils.log("DEBUG", string.format(" >>> TIME RESOLVED FROM INSTRUCTION: %.1fs", s))
|
|
return s
|
|
end
|
|
end
|
|
|
|
break
|
|
end
|
|
end
|
|
end
|
|
|
|
if matchFoundIndex then
|
|
Utils.log("DEBUG", string.format(" >>> Station matched at [%d], scanning ahead for time...", matchFoundIndex))
|
|
for i = matchFoundIndex + 1, maxIdx do
|
|
local entry = schedule.entries[i]
|
|
if entry then
|
|
local isOtherStation = false
|
|
if entry.instruction and (entry.instruction.id == "create:station" or entry.instruction.id == "create:destination") then
|
|
isOtherStation = true
|
|
end
|
|
if isOtherStation then break end
|
|
|
|
local s = findTimeInConditions(entry.conditions)
|
|
if s then
|
|
Utils.log("DEBUG", string.format(" >>> TIME FOUND AT LATER ENTRY [%d]: %.1fs", i, s))
|
|
return s
|
|
end
|
|
if entry.instruction and entry.instruction.data then
|
|
local s = extractTime(entry.instruction.data)
|
|
if s then
|
|
Utils.log("DEBUG", string.format(" >>> TIME FOUND AT LATER INSTRUCTION [%d]: %.1fs", i, s))
|
|
return s
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
Utils.log("DEBUG", "--- Scan End: No Time Resolved ---")
|
|
return nil
|
|
end
|
|
|
|
function Utils.getNextStation(schedule, currentStationName)
|
|
if not schedule or not schedule.entries or not currentStationName then return nil end
|
|
|
|
local target = tostring(currentStationName):lower():match("^%s*(.-)%s*$")
|
|
if target == "" then return nil end
|
|
|
|
local targetCode = Utils.getStationCode(target)
|
|
local targetBase = Utils.getBaseName(target)
|
|
local targetAlpha = target:gsub("[^%w%d]", "")
|
|
|
|
local maxIdx = 0
|
|
for k, _ in pairs(schedule.entries) do
|
|
if type(k) == "number" and k > maxIdx then maxIdx = k end
|
|
end
|
|
|
|
local matchFoundIndex = nil
|
|
|
|
for i = 1, maxIdx do
|
|
local entry = schedule.entries[i]
|
|
if entry then
|
|
local isStationEntry = false
|
|
if entry.instruction and (entry.instruction.id == "create:station" or entry.instruction.id == "create:destination") then
|
|
local text = (entry.instruction.data and entry.instruction.data.text) or entry.instruction.text
|
|
if type(text) == "string" then
|
|
local entryText = text:lower()
|
|
local entryCode = Utils.getStationCode(entryText)
|
|
local entryAlpha = entryText:gsub("[^%w%d]", "")
|
|
local entryBase = Utils.getBaseName(entryText)
|
|
|
|
local isMatch = (target == entryText)
|
|
or (targetCode and entryCode and targetCode == entryCode)
|
|
or (targetAlpha ~= "" and entryAlpha ~= "" and (targetAlpha == entryAlpha or targetAlpha:find(entryAlpha, 1, true) or entryAlpha:find(targetAlpha, 1, true)))
|
|
or (targetBase ~= "" and entryBase ~= "" and (targetBase == entryBase or targetBase:find(entryBase, 1, true) or entryBase:find(targetBase, 1, true)))
|
|
or Utils.isFuzzyMatch(target, entryText)
|
|
|
|
if isMatch then isStationEntry = true end
|
|
end
|
|
end
|
|
|
|
if isStationEntry then
|
|
matchFoundIndex = i
|
|
break
|
|
end
|
|
end
|
|
end
|
|
|
|
if matchFoundIndex then
|
|
-- Search forward
|
|
for i = matchFoundIndex + 1, maxIdx do
|
|
local entry = schedule.entries[i]
|
|
if entry and entry.instruction then
|
|
local inst = entry.instruction
|
|
if inst.id == "create:station" or inst.id == "create:destination" then
|
|
return (inst.data and inst.data.text) or inst.text
|
|
end
|
|
end
|
|
end
|
|
-- Loop around to beginning
|
|
for i = 1, matchFoundIndex - 1 do
|
|
local entry = schedule.entries[i]
|
|
if entry and entry.instruction then
|
|
local inst = entry.instruction
|
|
if inst.id == "create:station" or inst.id == "create:destination" then
|
|
return (inst.data and inst.data.text) or inst.text
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
return nil
|
|
end
|
|
|
|
|
|
-- 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
|
|
|
|
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
|
|
|
|
|
|
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
|
|
local digit, letter = tostring(val):match("^(%d+)([a-zA-Z])$")
|
|
if digit and letter then
|
|
local seq = Utils.getChineseNumberAudio(digit)
|
|
table.insert(seq, letter:upper())
|
|
return seq
|
|
end
|
|
return {tostring(val)}
|
|
end
|
|
end
|
|
|
|
function Utils.getStationAudio(code, langPrefix)
|
|
if not code then return nil end
|
|
local files = State.stationList[code]
|
|
if files then
|
|
local p = langPrefix:lower()
|
|
for _, f in ipairs(files) do
|
|
if f:lower():find("^" .. p) then
|
|
return f
|
|
end
|
|
end
|
|
end
|
|
|
|
return langPrefix .. code
|
|
end
|
|
|
|
local AutoUpdate = {}
|
|
|
|
function AutoUpdate.fetch(url)
|
|
if not http then
|
|
return nil, "HTTP API unavailable"
|
|
end
|
|
|
|
if http.checkURL then
|
|
local ok, err = http.checkURL(url)
|
|
if not ok then
|
|
return nil, err or "URL blocked"
|
|
end
|
|
end
|
|
|
|
local res, err = http.get(url)
|
|
if not res then
|
|
return nil, err or "request failed"
|
|
end
|
|
|
|
local body = res.readAll()
|
|
res.close()
|
|
|
|
if type(body) ~= "string" or #body == 0 then
|
|
return nil, "empty response"
|
|
end
|
|
|
|
return body
|
|
end
|
|
|
|
function AutoUpdate.writeFile(path, content)
|
|
local dir = fs.getDir(path)
|
|
if dir and dir ~= "" and not fs.exists(dir) then
|
|
fs.makeDir(dir)
|
|
end
|
|
|
|
local handle = fs.open(path, "w")
|
|
if not handle then
|
|
return nil, "cannot open " .. path
|
|
end
|
|
|
|
handle.write(content)
|
|
handle.close()
|
|
return true
|
|
end
|
|
|
|
function AutoUpdate.getStartupPath()
|
|
if shell and shell.resolve then
|
|
return shell.resolve(CONFIG.STARTUP_PATH)
|
|
end
|
|
return CONFIG.STARTUP_PATH
|
|
end
|
|
|
|
function AutoUpdate.getDownloadUrl(info)
|
|
if type(info) == "table" then
|
|
local direct = Utils.trim(info.downloadUrl)
|
|
if direct ~= "" then
|
|
return direct
|
|
end
|
|
|
|
local path = Utils.trim(info.downloadPath)
|
|
if path ~= "" then
|
|
return CONFIG.API_URL .. path
|
|
end
|
|
end
|
|
|
|
return CONFIG.API_URL .. "/downloads/station_broadcast.lua"
|
|
end
|
|
|
|
function AutoUpdate.fetchInfo()
|
|
local body, err = AutoUpdate.fetch(CONFIG.API_URL .. "/api/station-update")
|
|
if not body then
|
|
return nil, err
|
|
end
|
|
|
|
local ok, data = pcall(textutils.unserializeJSON, body)
|
|
if not ok or type(data) ~= "table" then
|
|
return nil, "invalid update response"
|
|
end
|
|
|
|
return data
|
|
end
|
|
|
|
function AutoUpdate.getTargetVersion(info)
|
|
if type(info) ~= "table" then
|
|
return ""
|
|
end
|
|
return Utils.trim(info.targetVersion or info.version)
|
|
end
|
|
|
|
function AutoUpdate.needsUpdate(info)
|
|
local targetVersion = AutoUpdate.getTargetVersion(info)
|
|
return targetVersion ~= "" and targetVersion ~= APP_VERSION_TEXT
|
|
end
|
|
|
|
function AutoUpdate.apply(info, reason)
|
|
if State.updateInProgress then
|
|
return false, "update already in progress"
|
|
end
|
|
|
|
local targetVersion = AutoUpdate.getTargetVersion(info)
|
|
if targetVersion == "" then
|
|
return false, "missing target version"
|
|
end
|
|
|
|
local downloadUrl = AutoUpdate.getDownloadUrl(info)
|
|
local startupPath = AutoUpdate.getStartupPath()
|
|
|
|
State.updateInProgress = true
|
|
Utils.log("WARN", "Applying update " .. targetVersion .. " via " .. tostring(reason or "manual"))
|
|
|
|
if Audio and Audio.stop then
|
|
pcall(Audio.stop, "all")
|
|
end
|
|
if State.ws then
|
|
pcall(State.ws.close)
|
|
State.ws = nil
|
|
end
|
|
|
|
local body, err = AutoUpdate.fetch(downloadUrl)
|
|
if not body then
|
|
State.updateInProgress = false
|
|
return false, err
|
|
end
|
|
|
|
local ok, writeErr = AutoUpdate.writeFile(startupPath, body)
|
|
if not ok then
|
|
State.updateInProgress = false
|
|
return false, writeErr
|
|
end
|
|
|
|
Utils.log("SUCCESS", "Updated startup to " .. targetVersion)
|
|
Utils.log("WARN", "Rebooting to finish update")
|
|
os.sleep(1)
|
|
os.reboot()
|
|
return true
|
|
end
|
|
|
|
function AutoUpdate.checkOnBoot()
|
|
local info, err = AutoUpdate.fetchInfo()
|
|
if not info then
|
|
Utils.log("WARN", "Update check skipped: " .. tostring(err))
|
|
return false
|
|
end
|
|
|
|
if not AutoUpdate.needsUpdate(info) then
|
|
return false
|
|
end
|
|
|
|
Utils.log("INFO", "Found published update " .. AutoUpdate.getTargetVersion(info) .. " on boot")
|
|
local ok, applyErr = AutoUpdate.apply(info, "boot")
|
|
if not ok then
|
|
Utils.log("ERROR", "Boot update failed: " .. tostring(applyErr))
|
|
end
|
|
return ok
|
|
end
|
|
|
|
-- ============================================================================
|
|
-- 6. Network System
|
|
-- ============================================================================
|
|
local Network = {}
|
|
|
|
function Network.connect()
|
|
if State.ws then return end
|
|
Utils.log("INFO", "Connecting to cloud...")
|
|
State.ws = http.websocket(CONFIG.CLOUD_URL)
|
|
if State.ws then
|
|
Utils.log("SUCCESS", "Connected")
|
|
Network.register()
|
|
else
|
|
Utils.log("ERROR", "Connection failed")
|
|
end
|
|
end
|
|
|
|
function Network.send(data)
|
|
if State.ws then
|
|
local ok, err = pcall(function()
|
|
State.ws.send(textutils.serializeJSON(data))
|
|
end)
|
|
if not ok then
|
|
Utils.log("ERROR", "Failed to send WS message: " .. tostring(err))
|
|
end
|
|
end
|
|
end
|
|
|
|
function Network.register()
|
|
Utils.refreshStationIdentity()
|
|
|
|
Network.send({
|
|
type = "station_register",
|
|
stationName = State.stationName,
|
|
stationCode = State.stationCode,
|
|
stationPlatform = State.stationPlatform,
|
|
stationVersion = APP_VERSION_TEXT
|
|
})
|
|
end
|
|
|
|
function Network.syncFiles()
|
|
while true do
|
|
local resp = http.get(CONFIG.API_URL .. "/list.txt")
|
|
if not resp then
|
|
resp = http.get(CONFIG.API_URL .. "/uploads/list.txt")
|
|
end
|
|
if resp then
|
|
local content = resp.readAll()
|
|
resp.close()
|
|
if content and #content > 0 then
|
|
local currentList = ""
|
|
if fs.exists("list.txt") then
|
|
local f = fs.open("list.txt", "r")
|
|
currentList = f.readAll()
|
|
f.close()
|
|
end
|
|
|
|
if currentList ~= content then
|
|
local f = fs.open("list.txt", "w")
|
|
f.write(content)
|
|
f.close()
|
|
Audio.loadStationList()
|
|
end
|
|
end
|
|
end
|
|
os.sleep(CONFIG.SYNC_INTERVAL)
|
|
end
|
|
end
|
|
|
|
function Network.messageLoop()
|
|
local function finishAudioRx()
|
|
if not State.audioRx then return end
|
|
local rx = State.audioRx
|
|
if rx.stream then
|
|
rx.stream.finish()
|
|
else
|
|
Audio.enqueueItem({
|
|
audioData = table.concat(rx.chunks or {}),
|
|
audioFormat = rx.audioFormat or "wav",
|
|
priority = rx.priority,
|
|
loop = rx.loop and true or false,
|
|
source = rx.source,
|
|
audioId = rx.audioId
|
|
})
|
|
end
|
|
State.audioRx = nil
|
|
end
|
|
|
|
while true do
|
|
local event, url, msg = os.pullEvent()
|
|
if event == "websocket_message" and url == CONFIG.CLOUD_URL then
|
|
local ok, data = pcall(textutils.unserializeJSON, msg)
|
|
if ok and type(data) == "table" then
|
|
if data.type == "audio_start" then
|
|
if State.audioRx and State.audioRx.stream then
|
|
pcall(State.audioRx.stream.finish)
|
|
end
|
|
local priority = tonumber(data.priority) or 0
|
|
local audioFormat = tostring(data.audioFormat or "dfpwm"):lower()
|
|
local streamUrl = type(data.streamUrl) == "string" and data.streamUrl or nil
|
|
local interruptMode = tostring(data.interruptMode or "")
|
|
if interruptMode == "" then
|
|
if priority >= 2 then interruptMode = "all"
|
|
elseif priority >= 1 then interruptMode = "ifLower"
|
|
else interruptMode = "none" end
|
|
end
|
|
|
|
if interruptMode == "all" then
|
|
Audio.stop("all")
|
|
elseif interruptMode == "ifLower" and priority > (State.currentPriority or -1) then
|
|
Audio.stop("current", priority)
|
|
end
|
|
|
|
State.audioRx = {
|
|
audioId = data.audioId,
|
|
expectedBytes = tonumber(data.byteLength) or 0,
|
|
receivedBytes = 0,
|
|
priority = priority,
|
|
loop = data.loop and true or false,
|
|
source = data.source or data.category,
|
|
audioFormat = audioFormat
|
|
}
|
|
|
|
if (audioFormat == "stream" or audioFormat == "hls") and streamUrl and streamUrl ~= "" then
|
|
Audio.enqueueItem({
|
|
audioData = streamUrl,
|
|
audioFormat = audioFormat,
|
|
priority = priority,
|
|
loop = State.audioRx.loop,
|
|
source = State.audioRx.source,
|
|
audioId = data.audioId
|
|
})
|
|
State.audioRx = nil
|
|
elseif audioFormat == "dfpwm" or audioFormat == "pcm" or audioFormat == "wav-stream" then
|
|
local stream = Audio.createStream()
|
|
State.audioRx.stream = stream
|
|
if audioFormat == "pcm" then
|
|
Utils.log("INFO", "Receiving live PCM audio stream")
|
|
elseif audioFormat == "wav-stream" then
|
|
Utils.log("INFO", "Receiving live WAV chunk stream")
|
|
end
|
|
Audio.enqueueItem({
|
|
audioData = stream,
|
|
audioFormat = audioFormat,
|
|
priority = priority,
|
|
source = State.audioRx.source,
|
|
audioId = data.audioId
|
|
})
|
|
else
|
|
State.audioRx.chunks = {}
|
|
end
|
|
elseif data.type == "audio_end" then
|
|
if State.audioRx and (not data.audioId or data.audioId == State.audioRx.audioId) then
|
|
finishAudioRx()
|
|
end
|
|
elseif data.type == "audio_stop" then
|
|
local scope = tostring(data.scope or "")
|
|
if scope == "music" then
|
|
local kept = {}
|
|
for _, it in ipairs(State.audioQueue) do
|
|
if tostring(it.source or "") ~= "music" then
|
|
table.insert(kept, it)
|
|
end
|
|
end
|
|
State.audioQueue = kept
|
|
|
|
local hasMusicRx = State.audioRx and tostring(State.audioRx.source or "") == "music"
|
|
local hasMusicCurrent = State.currentPlaying and tostring(State.currentPlaying.source or "") == "music"
|
|
if hasMusicRx or hasMusicCurrent then
|
|
Audio.stop("current")
|
|
end
|
|
else
|
|
local mode = tostring(data.mode or "current")
|
|
local flushBelow = data.flushBelow
|
|
Audio.stop(mode, flushBelow)
|
|
end
|
|
elseif data.type == "station_update" then
|
|
local targetVersion = AutoUpdate.getTargetVersion(data)
|
|
if targetVersion == "" then
|
|
Utils.log("WARN", "Ignored station update without version")
|
|
elseif targetVersion == APP_VERSION_TEXT then
|
|
Utils.log("INFO", "Received update notice for current version " .. targetVersion)
|
|
else
|
|
Utils.log("WARN", "Received published update " .. targetVersion)
|
|
local okUpdate, updateErr = AutoUpdate.apply({
|
|
targetVersion = targetVersion,
|
|
version = targetVersion,
|
|
releaseId = data.releaseId,
|
|
downloadPath = data.downloadPath,
|
|
downloadUrl = data.downloadUrl
|
|
}, "publish")
|
|
if not okUpdate then
|
|
Utils.log("ERROR", "Station update failed: " .. tostring(updateErr))
|
|
end
|
|
end
|
|
end
|
|
else
|
|
if State.audioRx then
|
|
-- Memory protection: limit to 10MB per audio item
|
|
local isLiveStream = State.audioRx.stream and (tonumber(State.audioRx.expectedBytes) or 0) <= 0
|
|
if (not isLiveStream) and State.audioRx.receivedBytes + #msg > 10 * 1024 * 1024 then
|
|
Utils.log("ERROR", "Audio item too large, discarding")
|
|
if State.audioRx.stream then
|
|
State.audioRx.stream.finish()
|
|
end
|
|
State.audioRx = nil
|
|
else
|
|
if State.audioRx.stream then
|
|
State.audioRx.stream.push(msg)
|
|
else
|
|
table.insert(State.audioRx.chunks, msg)
|
|
end
|
|
State.audioRx.receivedBytes = State.audioRx.receivedBytes + #msg
|
|
if State.audioRx.expectedBytes > 0 and State.audioRx.receivedBytes >= State.audioRx.expectedBytes then
|
|
finishAudioRx()
|
|
end
|
|
end
|
|
end
|
|
end
|
|
elseif event == "websocket_closed" then
|
|
Utils.log("WARN", "WS Closed")
|
|
State.ws = nil
|
|
State.audioRx = nil
|
|
os.sleep(5)
|
|
end
|
|
end
|
|
end
|
|
|
|
function Network.heartbeat()
|
|
while true do
|
|
if State.ws then
|
|
Utils.refreshStationIdentity()
|
|
Network.send({
|
|
type = "heartbeat",
|
|
stationName = State.stationName,
|
|
stationCode = State.stationCode,
|
|
stationPlatform = State.stationPlatform,
|
|
stationVersion = APP_VERSION_TEXT
|
|
})
|
|
end
|
|
os.sleep(10)
|
|
end
|
|
end
|
|
|
|
|
|
-- 7. Display System
|
|
|
|
local Display = {}
|
|
|
|
function Display.render()
|
|
local mon = Peripherals.primaryMonitor
|
|
if not mon then return end
|
|
local w, h = mon.getSize()
|
|
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
|
|
|
|
local _, monH = Peripherals.primaryMonitor.getSize()
|
|
local y = param3
|
|
if y >= monH - 3 then
|
|
if y == monH - 3 then
|
|
State.logScrollOffset = math.min(State.logScrollOffset + 1, math.max(0, #State.logs - 3))
|
|
elseif y == monH then
|
|
State.logScrollOffset = math.max(0, State.logScrollOffset - 1)
|
|
end
|
|
Display.renderTerminal()
|
|
Display.render()
|
|
end
|
|
elseif event == "mouse_click" then
|
|
local button, x, y = param1, param2, param3
|
|
if y == 1 then
|
|
Utils.promptConfig()
|
|
Display.renderTerminal()
|
|
Display.render()
|
|
end
|
|
elseif event == "key" then
|
|
if param1 == keys.up then
|
|
State.logScrollOffset = math.min(State.logScrollOffset + 1, math.max(0, #State.logs - maxLogs))
|
|
elseif param1 == keys.down then
|
|
State.logScrollOffset = math.max(0, State.logScrollOffset - 1)
|
|
elseif param1 == keys.pageUp then
|
|
State.logScrollOffset = math.min(State.logScrollOffset + 10, math.max(0, #State.logs - maxLogs))
|
|
elseif param1 == keys.pageDown then
|
|
State.logScrollOffset = math.max(0, State.logScrollOffset - 10)
|
|
elseif param1 == keys.home then
|
|
State.logScrollOffset = 0
|
|
elseif param1 == keys["end"] then
|
|
State.logScrollOffset = math.max(0, #State.logs - maxLogs)
|
|
elseif param1 == keys.c then
|
|
Utils.promptConfig()
|
|
end
|
|
Display.renderTerminal()
|
|
Display.render()
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
-- 8. Main Logic
|
|
|
|
local Main = {}
|
|
State.announcedApproaching = {}
|
|
State.announcedAlerts = {}
|
|
State.pendingAlerts = {}
|
|
State.pendingApproaching = {}
|
|
|
|
local function cleanupTimestampMap(map, now, maxAgeMs)
|
|
for k, v in pairs(map or {}) do
|
|
if now - v > maxAgeMs then
|
|
map[k] = nil
|
|
end
|
|
end
|
|
end
|
|
|
|
local function isPendingAlertReady(key, now)
|
|
local firstSeen = State.pendingAlerts[key]
|
|
if not firstSeen then
|
|
State.pendingAlerts[key] = now
|
|
return false
|
|
end
|
|
return (now - firstSeen) >= (tonumber(CONFIG.ALERT_CONFIRM_MS) or 60000)
|
|
end
|
|
|
|
|
|
|
|
local function normalizeUpcomingKey(trainName, destination)
|
|
local function clean(value)
|
|
value = tostring(value or "")
|
|
value = value:gsub("%s+", " ")
|
|
value = value:match("^%s*(.-)%s*$") or ""
|
|
return value
|
|
end
|
|
return clean(trainName) .. "_" .. clean(destination)
|
|
end
|
|
|
|
local function pickEarlierTick(a, b)
|
|
local ta = tonumber(a)
|
|
local tb = tonumber(b)
|
|
if ta and tb then
|
|
return math.min(ta, tb)
|
|
end
|
|
return ta or tb or 0
|
|
end
|
|
|
|
local function buildEtaLabel(etaSeconds, textEta)
|
|
if type(textEta) == "table" then
|
|
local value = tonumber(textEta.value)
|
|
local unit = tostring(textEta.unit or "")
|
|
if value and unit == "??" and value <= 10 then
|
|
return "~" .. tostring(value) .. "m"
|
|
end
|
|
end
|
|
local secs = math.max(0, tonumber(etaSeconds) or 0)
|
|
local whole = math.floor(secs + 0.0001)
|
|
if whole >= 60 then
|
|
return string.format("%02d:%02d", math.floor(whole / 60), whole % 60)
|
|
end
|
|
if secs >= 10 then
|
|
return string.format("%02ds", whole)
|
|
end
|
|
return string.format("%.1fs", secs)
|
|
end
|
|
|
|
local function mapTrainStatusReason(statusId)
|
|
local key = tostring(statusId or "")
|
|
if key == "" then
|
|
return nil
|
|
end
|
|
local tail = string.match(key, ":(.+)$") or key
|
|
local known = {
|
|
delay_from_previous_journey = "prev",
|
|
previous_train_delayed = "train",
|
|
priority_other_train = "prio",
|
|
red_signal = "signal",
|
|
track_closed = "track",
|
|
staff_shortage = "staff",
|
|
operational_disruption = "ops",
|
|
special_journey = "special",
|
|
out_of_service = "oos",
|
|
default_delay = "default_delay",
|
|
cancelled = "cancelled"
|
|
}
|
|
return known[tail] or tail
|
|
end
|
|
|
|
local function isStationDestination(destName)
|
|
if not destName then return false end
|
|
local blacklist = {
|
|
"速度", "speed",
|
|
"名称", "rename", "重命名",
|
|
"时刻表", "schedule",
|
|
"区段", "section",
|
|
"卸", "unload",
|
|
"装", "load",
|
|
"等待", "wait",
|
|
"条件", "condition",
|
|
"动力", "powered",
|
|
"物品", "item",
|
|
"流体", "fluid",
|
|
"玩家", "player",
|
|
"时间", "time",
|
|
"抛弃", "discard",
|
|
"限制", "limit",
|
|
"货物", "cargo",
|
|
"无载", "unloaded",
|
|
"空载", "empty"
|
|
}
|
|
local lowerDest = string.lower(destName)
|
|
for _, word in ipairs(blacklist) do
|
|
if string.find(lowerDest, word, 1, true) then
|
|
return false
|
|
end
|
|
end
|
|
return true
|
|
end
|
|
|
|
function Main.loop()
|
|
while true do
|
|
local now = os.epoch("utc")
|
|
local newUpcoming = {}
|
|
local etaTextByKey = {}
|
|
local delayTextByKey = {}
|
|
|
|
-- ETA come from NBT.
|
|
if Peripherals.createTarget then
|
|
local okLines, lines = pcall(Peripherals.createTarget.dump)
|
|
if okLines and type(lines) == "table" then
|
|
for _, lineText in pairs(lines) do
|
|
if lineText and lineText ~= "" and lineText:match("[%w%?%[]") then
|
|
local delayMatch = string.match(lineText, "%+%s*(%d+)")
|
|
local textDelay = tonumber(delayMatch) or 0
|
|
local cleanLine = string.gsub(lineText, "%s*%(?%+%s*%d+.-%)?", "")
|
|
local etaValue, etaUnit, tName, dest
|
|
etaValue, etaUnit, tName, dest = string.match(cleanLine, "^(%S+)%s+(.-)%s+(%[.-%][^%s]*)%s+(.*)$")
|
|
if not tName then
|
|
etaValue, etaUnit, tName, dest = string.match(cleanLine, "^([^%s]+)%s+(.-)%s+([^%s]+)%s+(.*)$")
|
|
end
|
|
if tName and etaValue then
|
|
if dest then
|
|
local textKey = normalizeUpcomingKey(tName, dest)
|
|
etaTextByKey[textKey] = {
|
|
value = etaValue,
|
|
unit = etaUnit,
|
|
raw = tostring(etaValue or "") .. ((etaUnit and etaUnit ~= "") and (" " .. tostring(etaUnit)) or "")
|
|
}
|
|
if textDelay > 0 then
|
|
delayTextByKey[textKey] = textDelay
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
if Peripherals.blockReader then
|
|
local ok, data = pcall(Peripherals.blockReader.getBlockData)
|
|
if ok and data and data.TrainStops then
|
|
if not State.lastRefreshed or State.lastRefreshed ~= data.LastRefreshed then
|
|
if State.lastRefreshed and State.lastRefreshedEpoch then
|
|
local deltaTicks = (data.LastRefreshed or 0) - (State.lastRefreshed or 0)
|
|
local deltaSeconds = (now - (State.lastRefreshedEpoch or now)) / 1000
|
|
if deltaTicks > 0 and deltaSeconds > 0 then
|
|
local rate = deltaTicks / deltaSeconds
|
|
if rate < 1 then rate = 1 end
|
|
if rate > 20 then rate = 20 end
|
|
State.tickRate = rate
|
|
end
|
|
end
|
|
State.lastRefreshed = data.LastRefreshed
|
|
State.lastRefreshedEpoch = now
|
|
end
|
|
|
|
local elapsedSeconds = (now - (State.lastRefreshedEpoch or now)) / 1000
|
|
local currentTick = (data.LastRefreshed or 0) + math.floor(elapsedSeconds * (State.tickRate or 20))
|
|
local seenAlertKeys = {}
|
|
local seenApproachKeys = {}
|
|
|
|
cleanupTimestampMap(State.announcedApproaching, now, tonumber(CONFIG.APPROACHING_COOLDOWN_MS) or 120000)
|
|
cleanupTimestampMap(State.announcedAlerts, now, tonumber(CONFIG.ALERT_COOLDOWN_MS) or 300000)
|
|
|
|
for i, stop in ipairs(data.TrainStops) do
|
|
-- [DEBUG] Dump first stop to file for inspection
|
|
if i == 1 and not State.dumpedStop then
|
|
State.dumpedStop = true
|
|
local f = fs.open("stop_dump.json", "w")
|
|
if f then
|
|
f.write(textutils.serializeJSON(stop))
|
|
f.close()
|
|
Utils.log("DEBUG", "Dumped stop structure to stop_dump.json")
|
|
end
|
|
end
|
|
local st = stop.Station
|
|
if st then
|
|
local trainName = st.TrainName or "Unknown"
|
|
local destination = st.Destination or "Unknown"
|
|
if type(stop.Stopovers) == "table" and #stop.Stopovers > 0 then
|
|
destination = stop.Stopovers[#stop.Stopovers] or destination
|
|
end
|
|
|
|
if isStationDestination(destination) then
|
|
local platform = "1"
|
|
if st.ScheduledStation and st.ScheduledStation.StationInfo and st.ScheduledStation.StationInfo.Platform then
|
|
platform = st.ScheduledStation.StationInfo.Platform
|
|
else
|
|
platform = State.stationPlatform or "1"
|
|
end
|
|
local cancelled = false
|
|
local reason = nil
|
|
local tr = stop.Train
|
|
if tr then
|
|
cancelled = tr.Cancelled == 1 or tr.Cancelled == true
|
|
local status = tr.Status
|
|
if type(status) == "table" then
|
|
for _, s in ipairs(status) do
|
|
if type(s) == "string" then
|
|
reason = mapTrainStatusReason(s) or reason
|
|
end
|
|
end
|
|
end
|
|
end
|
|
if cancelled then
|
|
reason = "cancelled"
|
|
end
|
|
|
|
local scheduledArrival = pickEarlierTick(st.ScheduledArrival, st.ScheduledDeparture)
|
|
local realArrival = pickEarlierTick(st.RealTimeArrival, st.RealTimeDeparture)
|
|
local etaTicks = realArrival - currentTick
|
|
local etaSeconds = etaTicks / (State.tickRate or 20)
|
|
if etaSeconds < 0 then etaSeconds = 0 end
|
|
local announceKey = normalizeUpcomingKey(trainName, destination)
|
|
local etaLabel = buildEtaLabel(etaSeconds, etaTextByKey[announceKey])
|
|
|
|
local delayTicks = realArrival - scheduledArrival
|
|
local delayMinutes = math.floor(delayTicks / ((State.tickRate or 20) * 60))
|
|
if delayMinutes < 0 then delayMinutes = 0 end
|
|
local displayDelayMinutes = math.max(delayMinutes, tonumber(delayTextByKey[announceKey]) or 0)
|
|
|
|
if etaSeconds <= 60 and not State.announcedApproaching[announceKey] then
|
|
Utils.log("DEBUG", string.format("Train: %s, ETA: %.1fs, Ticks: %d, Rate: %.1f", trainName, etaSeconds, etaTicks, State.tickRate or 20))
|
|
end
|
|
|
|
table.insert(newUpcoming, {
|
|
trainName = trainName,
|
|
dest = destination,
|
|
platform = platform,
|
|
eta = etaSeconds,
|
|
etaLabel = etaLabel,
|
|
delay = displayDelayMinutes,
|
|
cancelled = cancelled,
|
|
reason = reason
|
|
})
|
|
|
|
local alertType = cancelled and "cancelled" or ((displayDelayMinutes > 0) and "delayed" or nil)
|
|
local alertKey = alertType and (announceKey .. "::" .. alertType) or nil
|
|
if alertKey then
|
|
seenAlertKeys[alertKey] = true
|
|
if isPendingAlertReady(alertKey, now) and not State.announcedAlerts[alertKey] then
|
|
State.announcedAlerts[alertKey] = now
|
|
Utils.log("INFO", string.format("Train Alert: %s (ETA: %ds, Delay: %dm, Cancelled: %s, Reason: %s)", trainName, etaSeconds, displayDelayMinutes, tostring(cancelled), tostring(reason or "")))
|
|
Network.send({
|
|
type = "train_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
|
|
|
|
local isAtStation = State.currentTrain and State.currentTrain.id == trainName and State.currentTrain.dest == destination
|
|
if not isAtStation and etaSeconds > 0 and etaSeconds <= 45 then
|
|
if not seenApproachKeys[announceKey] then
|
|
seenApproachKeys[announceKey] = true
|
|
local pending = State.pendingApproaching[announceKey]
|
|
if not pending then
|
|
State.pendingApproaching[announceKey] = { since = now, lastEta = etaSeconds }
|
|
else
|
|
if etaSeconds > (tonumber(pending.lastEta) or etaSeconds) + 15 then
|
|
pending.since = now
|
|
end
|
|
pending.lastEta = etaSeconds
|
|
end
|
|
|
|
local stableMs = tonumber(CONFIG.APPROACHING_STABLE_MS) or 15000
|
|
local currentPending = State.pendingApproaching[announceKey]
|
|
if currentPending and (now - (currentPending.since or now)) >= stableMs and not State.announcedApproaching[announceKey] then
|
|
State.announcedApproaching[announceKey] = now
|
|
Utils.log("INFO", string.format("Train Approaching: %s (ETA: %.1fs, Delay: %dm)", trainName, etaSeconds, delayMinutes))
|
|
Network.send({
|
|
type = "train_approaching",
|
|
stationName = State.stationName,
|
|
stationCode = State.stationCode,
|
|
stationPlatform = platform,
|
|
trainId = trainName,
|
|
destination = destination,
|
|
eta = string.format("%.1fs", etaSeconds),
|
|
etaSeconds = etaSeconds,
|
|
delay = displayDelayMinutes,
|
|
cancelled = cancelled,
|
|
reason = reason
|
|
})
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
for k, _ in pairs(State.pendingAlerts) do
|
|
if not seenAlertKeys[k] then
|
|
State.pendingAlerts[k] = nil
|
|
end
|
|
end
|
|
for k, _ in pairs(State.pendingApproaching) do
|
|
if not seenApproachKeys[k] then
|
|
State.pendingApproaching[k] = nil
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
if not Peripherals.blockReader and Peripherals.createTarget then
|
|
local lines = Peripherals.createTarget.dump()
|
|
local seenAlertKeys = {}
|
|
local seenApproachKeys = {}
|
|
|
|
cleanupTimestampMap(State.announcedApproaching, now, tonumber(CONFIG.APPROACHING_COOLDOWN_MS) or 120000)
|
|
cleanupTimestampMap(State.announcedAlerts, now, tonumber(CONFIG.ALERT_COOLDOWN_MS) or 300000)
|
|
|
|
for _, lineText in pairs(lines) do
|
|
if lineText and lineText ~= "" and lineText:match("[%w%?%[]") then
|
|
local delayMatch = string.match(lineText, "%+%s*(%d+)")
|
|
local delay = tonumber(delayMatch) or 0
|
|
|
|
local cleanLine = string.gsub(lineText, "%s*%(?%+%s*%d+.-%)?", "")
|
|
|
|
local platform, etaStr, tName, dest
|
|
platform, etaStr, tName, dest = string.match(cleanLine, "^(%d+)%s+(.-)%s+(%[.-%][^%s]*)%s+(.*)$")
|
|
if not tName then
|
|
platform, etaStr, tName, dest = string.match(cleanLine, "^([^%s]+)%s+(.-)%s+(%[.-%][^%s]*)%s+(.*)$")
|
|
end
|
|
if not tName then
|
|
platform, etaStr, tName, dest = string.match(cleanLine, "^([^%s]+)%s+(.-)%s+([^%s]+)%s+(.*)$")
|
|
end
|
|
|
|
if tName and etaStr and isStationDestination(dest) then
|
|
local announceKey = normalizeUpcomingKey(tName, dest)
|
|
|
|
local etaSeconds = -1
|
|
local isNow = string.find(etaStr, "现在")
|
|
|
|
if isNow then
|
|
etaSeconds = 0
|
|
else
|
|
local sign_mm, mm, ss = string.match(etaStr, "^%s*(%-?)%s*(%d+)%s*:%s*(%d+)%s*$")
|
|
if mm and ss then
|
|
etaSeconds = tonumber(mm) * 60 + tonumber(ss)
|
|
if sign_mm == "-" then etaSeconds = -etaSeconds end
|
|
else
|
|
local sign, num, unit = string.match(etaStr, "^%s*(%-?)%s*(%d+)%s*(.*)")
|
|
if num then
|
|
num = tonumber(num)
|
|
if sign == "-" then num = -num end
|
|
|
|
if string.find(unit, "秒") or string.find(unit, "s") or string.find(unit, "sec") or (unit == "??" and num > 10) then
|
|
etaSeconds = num
|
|
else
|
|
etaSeconds = num * 60
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
table.insert(newUpcoming, {
|
|
trainName = tName,
|
|
dest = dest,
|
|
platform = platform or State.stationPlatform or "1",
|
|
eta = etaSeconds >= 0 and etaSeconds or 0,
|
|
etaLabel = buildEtaLabel(etaSeconds >= 0 and etaSeconds or 0, {
|
|
value = string.match(etaStr, "(%-?%d+)"),
|
|
unit = string.match(etaStr, "%-?%d+%s*(.*)")
|
|
}),
|
|
delay = delay,
|
|
cancelled = false,
|
|
reason = nil
|
|
})
|
|
|
|
local alertKey = announceKey .. "::delayed"
|
|
if delay > 0 then
|
|
seenAlertKeys[alertKey] = true
|
|
if isPendingAlertReady(alertKey, now) and not State.announcedAlerts[alertKey] then
|
|
State.announcedAlerts[alertKey] = now
|
|
Utils.log("INFO", string.format("Train Delay Alert: %s (ETA: %ds, Delay: %dm)", tName, etaSeconds, delay))
|
|
Network.send({
|
|
type = "train_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
|
|
|
|
local isAtStation = State.currentTrain and State.currentTrain.id == tName and State.currentTrain.dest == dest
|
|
if not isAtStation and etaSeconds > 0 and etaSeconds <= 45 then
|
|
if not seenApproachKeys[announceKey] then
|
|
seenApproachKeys[announceKey] = true
|
|
local pending = State.pendingApproaching[announceKey]
|
|
if not pending then
|
|
State.pendingApproaching[announceKey] = { since = now, lastEta = etaSeconds }
|
|
else
|
|
if etaSeconds > (tonumber(pending.lastEta) or etaSeconds) + 15 then
|
|
pending.since = now
|
|
end
|
|
pending.lastEta = etaSeconds
|
|
end
|
|
|
|
local stableMs = tonumber(CONFIG.APPROACHING_STABLE_MS) or 15000
|
|
local currentPending = State.pendingApproaching[announceKey]
|
|
if currentPending and (now - (currentPending.since or now)) >= stableMs and not State.announcedApproaching[announceKey] then
|
|
State.announcedApproaching[announceKey] = now
|
|
Utils.log("INFO", string.format("Train Approaching: %s (ETA: %ds, Delay: %dm)", tName, etaSeconds, delay))
|
|
Network.send({
|
|
type = "train_approaching",
|
|
stationName = State.stationName,
|
|
stationCode = State.stationCode,
|
|
stationPlatform = platform or State.stationPlatform or "1",
|
|
trainId = tName,
|
|
destination = dest,
|
|
eta = etaStr,
|
|
etaSeconds = etaSeconds,
|
|
delay = delay
|
|
})
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
for k, _ in pairs(State.pendingAlerts) do
|
|
if not seenAlertKeys[k] then
|
|
State.pendingAlerts[k] = nil
|
|
end
|
|
end
|
|
for k, _ in pairs(State.pendingApproaching) do
|
|
if not seenApproachKeys[k] then
|
|
State.pendingApproaching[k] = nil
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
State.upcomingTrains = newUpcoming
|
|
|
|
if not Peripherals.station then
|
|
State.currentTrain = nil
|
|
Display.render()
|
|
Display.renderTerminal()
|
|
os.sleep(0.5)
|
|
goto continue
|
|
end
|
|
|
|
local hasTrain = Peripherals.station.isTrainPresent()
|
|
|
|
if hasTrain then
|
|
if not State.currentTrain then
|
|
-- Train just arrived
|
|
local id = Peripherals.station.getTrainName()
|
|
local schedule = Peripherals.station.getSchedule()
|
|
local dest = Utils.getDestination(schedule)
|
|
local nextStation = Utils.getNextStation(schedule, State.stationName)
|
|
local now = os.epoch("utc") / 1000
|
|
local stationTime = Utils.getStopTime(schedule, State.stationName) or 60
|
|
|
|
State.currentTrain = {
|
|
id = id,
|
|
trainType = Utils.parseTrainId(id),
|
|
schedule = schedule,
|
|
dest = dest,
|
|
nextStation = nextStation,
|
|
arrivalTime = now,
|
|
departureTime = now + stationTime,
|
|
platform = Utils.getPlatformNumber(State.stationName, "1"),
|
|
departureSent = false
|
|
}
|
|
|
|
Network.send({
|
|
type = "train_arrival",
|
|
stationName = State.stationName,
|
|
stationCode = State.stationCode,
|
|
stationPlatform = State.stationPlatform,
|
|
trainId = id,
|
|
destination = dest,
|
|
nextStation = nextStation,
|
|
departureTime = State.currentTrain.departureTime
|
|
})
|
|
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
|
|
if Peripherals.speakers and #Peripherals.speakers > 0 then
|
|
Utils.log("INFO", "Speaker detected x" .. tostring(#Peripherals.speakers))
|
|
else
|
|
Utils.log("WARN", "No speaker detected")
|
|
end
|
|
|
|
parallel.waitForAny(
|
|
function()
|
|
while true do
|
|
if not State.ws then Network.connect() end
|
|
os.sleep(5)
|
|
end
|
|
end,
|
|
Audio.playerLoop,
|
|
Network.messageLoop,
|
|
Network.heartbeat,
|
|
Network.syncFiles,
|
|
Input.listener,
|
|
Main.loop
|
|
)
|
|
|