Remove station_name from the slave identity config and prompt for station code and platform on startup when no train station peripheral and no usable identity file are present, so slave nodes can be configured interactively on first boot.
1970 lines
68 KiB
Lua
1970 lines
68 KiB
Lua
|
|
-- ============================================================================
|
|
-- 1. Configuration & Constants
|
|
-- ============================================================================
|
|
local APP_VERSION_TEXT = "v1.0.8"
|
|
|
|
local CONFIG = {
|
|
CLOUD_URL = "ws://stabadmin.fse-media.group",
|
|
API_URL = "http://stabadmin.fse-media.group",
|
|
SYNC_INTERVAL = 30,
|
|
AUDIO_BASE = "Audio/",
|
|
AUDIO_STATION = "Audio/Station/",
|
|
DISK_AUDIO_BASE = "disk/Audio/",
|
|
DISK_AUDIO_STATION = "disk/Audio/Station/",
|
|
STATION_IDENTITY_FILE = "station_identity.cfg",
|
|
AUDIO_VOLUME = 3,
|
|
DEBUG = true
|
|
}
|
|
|
|
local dfpwm = require "cc.audio.dfpwm"
|
|
|
|
local COLORS = {
|
|
INFO = colors.white,
|
|
WARN = colors.yellow,
|
|
WARNING = colors.yellow,
|
|
ERROR = colors.red,
|
|
DEBUG = colors.cyan,
|
|
SUCCESS = colors.green
|
|
}
|
|
|
|
-- ============================================================================
|
|
-- 2. State Management
|
|
-- ============================================================================
|
|
local State = {
|
|
ws = nil,
|
|
stationName = "Unknown",
|
|
stationCode = "UNK",
|
|
stationPlatform = "1",
|
|
stationIdentityMode = "auto",
|
|
hasStationPeripheral = false,
|
|
registered = false,
|
|
stationList = {},
|
|
currentTrain = nil,
|
|
logs = {},
|
|
logScrollOffset = 0,
|
|
audioRx = nil,
|
|
audioQueue = {},
|
|
currentPlaying = nil,
|
|
currentPriority = -1,
|
|
interruptSeq = 0
|
|
}
|
|
|
|
-- ============================================================================
|
|
-- 3. Peripherals Setup
|
|
-- ============================================================================
|
|
local Peripherals = {}
|
|
|
|
function Peripherals.init()
|
|
-- Station Peripheral
|
|
local back = peripheral.wrap("back")
|
|
if back and peripheral.getType("back") == "train_station" then
|
|
Peripherals.station = back
|
|
State.hasStationPeripheral = true
|
|
else
|
|
Peripherals.station = nil
|
|
State.hasStationPeripheral = false
|
|
end
|
|
-- Monitors
|
|
local monitors = { peripheral.find("monitor") }
|
|
if #monitors == 0 then error("At least one monitor is required") end
|
|
-- Assign monitors
|
|
Peripherals.primaryMonitor = monitors[1]
|
|
Peripherals.primaryMonitor.setTextScale(0.5)
|
|
-- Speakers
|
|
Peripherals.speakers = { peripheral.find("speaker") }
|
|
end
|
|
|
|
-- ============================================================================
|
|
-- 4. Utility Functions
|
|
-- ============================================================================
|
|
local Utils = {}
|
|
|
|
function Utils.log(level, message)
|
|
local timestamp = os.date("%H:%M:%S")
|
|
local entryText = string.format("[%s][%s] %s", timestamp, level, message)
|
|
local entry = { level = level, text = entryText }
|
|
table.insert(State.logs, 1, entry)
|
|
if #State.logs > 100 then table.remove(State.logs) end
|
|
|
|
-- If user is scrolling history, increment offset to stay on same items
|
|
if State.logScrollOffset > 0 then
|
|
State.logScrollOffset = math.min(State.logScrollOffset + 1, math.max(0, #State.logs - 1))
|
|
end
|
|
end
|
|
|
|
function Utils.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)
|
|
local handle = fs.open(CONFIG.STATION_IDENTITY_FILE, "w")
|
|
if not handle then
|
|
return false
|
|
end
|
|
handle.write(table.concat({
|
|
"# Manual station identity override for slave nodes",
|
|
"station_code=" .. Utils.trim(stationCode or ""),
|
|
"station_platform=" .. Utils.trim(stationPlatform or ""),
|
|
""
|
|
}, "\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.promptSlaveIdentity()
|
|
while true do
|
|
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(30, 6)
|
|
term.setTextColor(colors.cyan)
|
|
local stationCode = Utils.trim(read() or ""):lower()
|
|
|
|
term.setCursorPos(31, 8)
|
|
local stationPlatform = Utils.trim(read() or "")
|
|
|
|
if stationCode == "" or stationPlatform == "" then
|
|
term.setCursorPos(1, 10)
|
|
term.setTextColor(colors.red)
|
|
term.write("Both station code and platform are required.")
|
|
term.setCursorPos(1, 12)
|
|
term.setTextColor(colors.lightGray)
|
|
term.write("Press any key to retry...")
|
|
os.pullEvent("key")
|
|
else
|
|
local ok = Utils.saveStationIdentity(stationCode, stationPlatform)
|
|
if ok then
|
|
term.setCursorPos(1, 10)
|
|
term.setTextColor(colors.green)
|
|
term.write("Saved to " .. CONFIG.STATION_IDENTITY_FILE)
|
|
term.setCursorPos(1, 12)
|
|
term.setTextColor(colors.lightGray)
|
|
term.write("Press any key to continue...")
|
|
os.pullEvent("key")
|
|
term.setBackgroundColor(colors.black)
|
|
term.setTextColor(colors.white)
|
|
term.clear()
|
|
return true
|
|
end
|
|
term.setCursorPos(1, 10)
|
|
term.setTextColor(colors.red)
|
|
term.write("Failed to save " .. CONFIG.STATION_IDENTITY_FILE)
|
|
term.setCursorPos(1, 12)
|
|
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 = manual.station_code
|
|
local stationPlatform = manual.station_platform
|
|
local stationName = physicalName
|
|
|
|
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
|
|
|
|
return {
|
|
stationName = stationName,
|
|
stationCode = stationCode or "UNK",
|
|
stationPlatform = tostring(stationPlatform or "1"),
|
|
mode = identityMode
|
|
}
|
|
end
|
|
|
|
function Utils.refreshStationIdentity()
|
|
local identity = Utils.resolveStationIdentity()
|
|
State.stationName = identity.stationName
|
|
State.stationCode = identity.stationCode
|
|
State.stationPlatform = identity.stationPlatform
|
|
State.stationIdentityMode = identity.mode
|
|
end
|
|
|
|
function Utils.getStationCode(name)
|
|
if not name then return nil end
|
|
name = tostring(name)
|
|
|
|
local code = nil
|
|
local codeInParens = name:match("[%[%(]([%w%d%-%/%s]+)[%]%)]")
|
|
if codeInParens then
|
|
local firstPart = codeInParens:match("[^/]+")
|
|
if firstPart then
|
|
code = firstPart:match("^%s*(.-)%s*$")
|
|
end
|
|
end
|
|
|
|
if not code then
|
|
code = name:match("[%w%d]+%-[%w%d]+") or name:match("[%a]+[%d]+")
|
|
end
|
|
|
|
if not code then
|
|
local cleanName = name:match("^%s*(.-)%s*$"):lower()
|
|
if State.stationList and State.stationList[cleanName] then
|
|
code = cleanName
|
|
end
|
|
end
|
|
|
|
if code then
|
|
return tostring(code):lower() -- Always return lowercase for consistent comparison
|
|
end
|
|
return nil
|
|
end
|
|
|
|
function Utils.getPlatformNumber(name, default)
|
|
if not name then return default end
|
|
local p = name:match("[Pp]latform%s*([%d]+[a-zA-Z]?)") or name:match("[Tt]rack%s*([%d]+[a-zA-Z]?)")
|
|
if p then return p end
|
|
return name:match("([%d]+[a-zA-Z]?)$") or default
|
|
end
|
|
|
|
function Utils.getBroadcastStationCode(name)
|
|
if not name then return "UNK" end
|
|
name = name:match("^%s*(.-)%s*$")
|
|
name = name:gsub("%s+", "_")
|
|
if name == "" then return "UNK" end
|
|
return name
|
|
end
|
|
|
|
function Utils.isSameTrain(id1, id2)
|
|
if not id1 or not id2 then return false end
|
|
if id1 == id2 then return true end
|
|
|
|
-- Consist Car Match: "CK 1 01" vs "CK 1 02"
|
|
local function getConsist(id)
|
|
local clean = id:gsub("^%[.-%]%s*", "")
|
|
local p = {}
|
|
for s in clean:gmatch("%S+") do table.insert(p, s) end
|
|
if #p >= 2 then
|
|
if #p[1] <= 4 and tonumber(p[2]) then
|
|
return p[1] .. " " .. p[2]
|
|
end
|
|
end
|
|
return p[1] or clean
|
|
end
|
|
|
|
local c1 = getConsist(id1)
|
|
local c2 = getConsist(id2)
|
|
if c1 == c2 then return true end
|
|
|
|
-- Fuzzy match: remove type prefix and compare content
|
|
local _, name1, num1 = Utils.parseTrainId(id1)
|
|
local _, name2, num2 = Utils.parseTrainId(id2)
|
|
|
|
-- If both have numbers, compare name and number
|
|
if num1 ~= "" and num2 ~= "" then
|
|
return name1 == name2 and num1 == num2
|
|
end
|
|
|
|
-- Fallback to comparing just the main content/name
|
|
return name1 == name2
|
|
end
|
|
|
|
function Utils.parseTrainId(trainId)
|
|
if not trainId then return "unknown", "" end
|
|
|
|
-- Generic match: [Type]Content
|
|
local typeStr, content = trainId:match("^%[(.-)%](.+)$")
|
|
|
|
if typeStr then
|
|
-- Check for Express types
|
|
if typeStr == "Ltd.Exp" or typeStr == "Express" then
|
|
|
|
local name, num = content:match("^(.+) No%.(%d+)$")
|
|
if name then
|
|
return "express", name, num
|
|
else
|
|
return "express", content, ""
|
|
end
|
|
elseif typeStr == "Local" then
|
|
return "regular", "Local", content
|
|
else
|
|
return "regular", typeStr, content
|
|
end
|
|
end
|
|
|
|
return "unknown", "Unknown", trainId
|
|
end
|
|
|
|
function Utils.getDestination(schedule)
|
|
if not schedule then return "Unknown" end
|
|
-- Handle both wrapped and unwrapped schedule structure
|
|
local entries = schedule.entries or (type(schedule) == "table" and schedule)
|
|
if not entries or type(entries) ~= "table" then return "Unknown" end
|
|
|
|
|
|
if #entries == 0 then
|
|
local count = 0
|
|
for _ in pairs(entries) do count = count + 1 end
|
|
if count > 0 then
|
|
|
|
local list = {}
|
|
for k, v in pairs(entries) do
|
|
local n = tonumber(k)
|
|
if n then list[n] = v end
|
|
end
|
|
entries = list
|
|
end
|
|
end
|
|
|
|
local dest = "Unknown"
|
|
local maxIdx = 0
|
|
for k, _ in pairs(entries) do
|
|
if type(k) == "number" and k > maxIdx then maxIdx = k end
|
|
end
|
|
|
|
|
|
for i = 1, maxIdx do
|
|
local entry = entries[i]
|
|
if entry then
|
|
local inst = entry.instruction
|
|
if inst then
|
|
if inst.id == "create:destination" then
|
|
dest = (inst.data and inst.data.text) or inst.text or dest
|
|
end
|
|
|
|
if inst.id == "create:station" then
|
|
local sName = (inst.data and inst.data.text) or inst.text
|
|
if sName then
|
|
dest = sName
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
return dest
|
|
end
|
|
|
|
function Utils.getBaseName(name)
|
|
if not name then return "" end
|
|
name = tostring(name):lower()
|
|
name = name:gsub("%s*[Pp]latform%s*[%d%a]+", ""):gsub("%s*[Tt]rack%s*[%d%a]+", "")
|
|
name = name:gsub("%s*[%[%(].-[%]%)]%s*", " ")
|
|
name = name:gsub("%s+%d+$", "")
|
|
return name:match("^%s*(.-)%s*$")
|
|
end
|
|
|
|
function Utils.isFuzzyMatch(str, pattern)
|
|
if not str or not pattern then return false end
|
|
str = tostring(str):lower():match("^%s*(.-)%s*$")
|
|
pattern = tostring(pattern):lower():match("^%s*(.-)%s*$")
|
|
|
|
if str == pattern then return true end
|
|
|
|
-- 1. Handle Create Mod Wildcards (*)
|
|
if pattern:find("*") then
|
|
local p = pattern:gsub("([%^%$%(%)%%%.%[%]%+%-%?])", "%%%1")
|
|
p = p:gsub("%%%*", ".*")
|
|
local ok, res = pcall(function() return str:match("^" .. p .. "$") end)
|
|
if ok and res then return true end
|
|
end
|
|
|
|
-- 2. Base name comparison
|
|
local b1 = Utils.getBaseName(str)
|
|
local b2 = Utils.getBaseName(pattern)
|
|
if b1 ~= "" and b2 ~= "" then
|
|
if b1 == b2 then return true end
|
|
-- Substring match for base names
|
|
if #b1 >= 4 and #b2 >= 4 then
|
|
if b1:find(b2, 1, true) or b2:find(b1, 1, true) then
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
|
|
-- 3. Alphanumeric only comparison
|
|
local a1 = str:gsub("[^%w%d]", "")
|
|
local a2 = pattern:gsub("[^%w%d]", "")
|
|
if a1 ~= "" and a2 ~= "" and (a1 == a2 or a1:find(a2, 1, true) or a2:find(a1, 1, true)) then
|
|
return true
|
|
end
|
|
|
|
return false
|
|
end
|
|
|
|
function Utils.getStopTime(schedule, stationName)
|
|
if not schedule or not schedule.entries or not stationName then return nil end
|
|
|
|
local target = tostring(stationName):lower():match("^%s*(.-)%s*$")
|
|
if target == "" then return nil end
|
|
|
|
local targetCode = Utils.getStationCode(target)
|
|
local targetBase = Utils.getBaseName(target)
|
|
local targetAlpha = target:gsub("[^%w%d]", "")
|
|
|
|
Utils.log("DEBUG", string.format("STATION TARGET: '%s' (Code: %s, Base: %s, Alpha: %s)", target, tostring(targetCode), targetBase, targetAlpha))
|
|
|
|
local function extractTime(data)
|
|
if not data then return nil end
|
|
local val = tonumber(data.value) or tonumber(data.seconds) or tonumber(data.s)
|
|
or tonumber(data.time) or tonumber(data.duration) or tonumber(data.delay)
|
|
local unit = tonumber(data.time_unit) or tonumber(data.unit) or 1
|
|
if val then
|
|
if unit == 0 then return val / 20
|
|
elseif unit == 1 then return val
|
|
elseif unit == 2 then return val * 60
|
|
end
|
|
return val
|
|
end
|
|
local ticks = tonumber(data.ticks) or tonumber(data.tick)
|
|
if ticks then return ticks / 20 end
|
|
return nil
|
|
end
|
|
|
|
local function findTimeInConditions(conditions)
|
|
if not conditions then return nil end
|
|
for i, item in ipairs(conditions) do
|
|
if type(item) == "table" then
|
|
local cid = tostring(item.id or ""):lower()
|
|
if cid:find("delay") or cid:find("wait") or cid:find("idle") or cid:find("pause") then
|
|
local s = extractTime(item.data)
|
|
if s and s > 0 then
|
|
Utils.log("DEBUG", string.format("Found Time in Condition [%d]: %.1fs (ID: %s)", i, s, cid))
|
|
return s
|
|
end
|
|
end
|
|
for k, sub in pairs(item) do
|
|
if type(sub) == "table" then
|
|
local s = findTimeInConditions({sub})
|
|
if s then return s end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local maxIdx = 0
|
|
if schedule.entries then
|
|
for k, _ in pairs(schedule.entries) do
|
|
if type(k) == "number" and k > maxIdx then maxIdx = k end
|
|
end
|
|
end
|
|
|
|
Utils.log("DEBUG", string.format("--- Schedule Scan Start (MaxIdx: %d) ---", maxIdx))
|
|
|
|
local matchFoundIndex = nil
|
|
|
|
for i = 1, maxIdx do
|
|
local entry = schedule.entries[i]
|
|
if entry then
|
|
local function scanRecursive(obj, depth)
|
|
if depth > 5 or not obj then return false end
|
|
for k, v in pairs(obj) do
|
|
if type(v) == "string" then
|
|
local entryText = v:lower()
|
|
local entryCode = Utils.getStationCode(entryText)
|
|
local entryAlpha = entryText:gsub("[^%w%d]", "")
|
|
local entryBase = Utils.getBaseName(entryText)
|
|
|
|
Utils.log("DEBUG", string.format(" [%d] SCAN '%s': '%s' (Code: %s)", i, k, entryText, tostring(entryCode)))
|
|
|
|
local isMatch = (target == entryText)
|
|
or (targetCode and entryCode and targetCode == entryCode)
|
|
or (targetAlpha ~= "" and entryAlpha ~= "" and (targetAlpha == entryAlpha or targetAlpha:find(entryAlpha, 1, true) or entryAlpha:find(targetAlpha, 1, true)))
|
|
or (targetBase ~= "" and entryBase ~= "" and (targetBase == entryBase or targetBase:find(entryBase, 1, true) or entryBase:find(targetBase, 1, true)))
|
|
or Utils.isFuzzyMatch(target, entryText)
|
|
|
|
if isMatch then
|
|
Utils.log("DEBUG", string.format(" >>> MATCH FOUND AT ENTRY %d (via field '%s')", i, k))
|
|
return true
|
|
end
|
|
elseif type(v) == "table" then
|
|
if scanRecursive(v, depth + 1) then return true end
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
if scanRecursive(entry, 0) then
|
|
matchFoundIndex = i
|
|
local s = findTimeInConditions(entry.conditions)
|
|
if s then
|
|
Utils.log("DEBUG", string.format(" >>> TIME RESOLVED FROM CONDITIONS: %.1fs", s))
|
|
return s
|
|
end
|
|
|
|
if entry.instruction and entry.instruction.data then
|
|
local s = extractTime(entry.instruction.data)
|
|
if s then
|
|
Utils.log("DEBUG", string.format(" >>> TIME RESOLVED FROM INSTRUCTION: %.1fs", s))
|
|
return s
|
|
end
|
|
end
|
|
|
|
break
|
|
end
|
|
end
|
|
end
|
|
|
|
if matchFoundIndex then
|
|
Utils.log("DEBUG", string.format(" >>> Station matched at [%d], scanning ahead for time...", matchFoundIndex))
|
|
for i = matchFoundIndex + 1, maxIdx do
|
|
local entry = schedule.entries[i]
|
|
if entry then
|
|
local isOtherStation = false
|
|
local function checkIsStation(obj, depth)
|
|
if depth > 2 or not obj then return false end
|
|
for k, v in pairs(obj) do
|
|
if type(v) == "string" then
|
|
local lk = tostring(k):lower()
|
|
if lk:find("text") or lk:find("station") or lk:find("destination") then return true end
|
|
elseif type(v) == "table" then
|
|
if checkIsStation(v, depth + 1) then return true end
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
if checkIsStation(entry, 0) then break end
|
|
|
|
local s = findTimeInConditions(entry.conditions)
|
|
if s then
|
|
Utils.log("DEBUG", string.format(" >>> TIME FOUND AT LATER ENTRY [%d]: %.1fs", i, s))
|
|
return s
|
|
end
|
|
if entry.instruction and entry.instruction.data then
|
|
local s = extractTime(entry.instruction.data)
|
|
if s then
|
|
Utils.log("DEBUG", string.format(" >>> TIME FOUND AT LATER INSTRUCTION [%d]: %.1fs", i, s))
|
|
return s
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
Utils.log("DEBUG", "--- Scan End: No Time Resolved ---")
|
|
return nil
|
|
end
|
|
|
|
-- ============================================================================
|
|
-- 5. Audio System
|
|
-- ============================================================================
|
|
local Audio = {}
|
|
|
|
function Audio.createStream()
|
|
local chunks = {}
|
|
local finished = false
|
|
local stream = {}
|
|
|
|
stream.push = function(chunk)
|
|
if type(chunk) ~= "string" then return end
|
|
table.insert(chunks, chunk)
|
|
os.queueEvent("audio_chunk")
|
|
end
|
|
|
|
stream.finish = function()
|
|
finished = true
|
|
os.queueEvent("audio_chunk")
|
|
end
|
|
|
|
stream.read = function()
|
|
while #chunks == 0 do
|
|
if finished then return nil end
|
|
os.pullEvent("audio_chunk")
|
|
end
|
|
return table.remove(chunks, 1)
|
|
end
|
|
|
|
return stream
|
|
end
|
|
|
|
function Audio.loadStationList()
|
|
State.stationList = {}
|
|
if not fs.exists("list.txt") then
|
|
Utils.log("WARN", "list.txt not found")
|
|
return
|
|
end
|
|
|
|
local file = fs.open("list.txt", "r")
|
|
if file then
|
|
local count = 0
|
|
local line = file.readLine()
|
|
while line do
|
|
line = line:gsub("%s+$", "") -- Trim trailing \r\n and spaces
|
|
if line ~= "" then
|
|
local codes, files = line:match("^([^=]+)==(.+)$")
|
|
if codes and files then
|
|
local list = {}
|
|
for f in files:gmatch("[^,]+") do
|
|
local cleanName = f:match("^%s*(.-)%s*$")
|
|
:gsub("%.[Ww][Aa][Vv]$", "")
|
|
:gsub("%.[Mm][Pp]3$", "")
|
|
:gsub("%.[Oo][Gg][Gg]$", "")
|
|
:gsub("%.[Ff][Ll][Aa][Cc]$", "")
|
|
:gsub("%.[Mm]4[Aa]$", "")
|
|
:gsub("%.[Aa][Aa][Cc]$", "")
|
|
:gsub("%.[Oo][Pp][Uu][Ss]$", "")
|
|
:gsub("%.[Dd][Ff][Pp][Ww][Mm]$", "")
|
|
if cleanName ~= "" then
|
|
table.insert(list, cleanName)
|
|
end
|
|
end
|
|
|
|
-- Map all codes to this list
|
|
local allCodes = {}
|
|
for code in codes:gmatch("[^/]+") do
|
|
local cleanCode = code:match("^%s*(.-)%s*$"):lower()
|
|
if cleanCode ~= "" then
|
|
table.insert(allCodes, cleanCode)
|
|
State.stationList[cleanCode] = list
|
|
end
|
|
end
|
|
|
|
if #allCodes > 1 then
|
|
local fullRawCode = codes:match("^%s*(.-)%s*$"):lower()
|
|
State.stationList[fullRawCode] = list
|
|
local dashCode = table.concat(allCodes, "-"):lower()
|
|
State.stationList[dashCode] = list
|
|
end
|
|
count = count + 1
|
|
end
|
|
end
|
|
line = file.readLine()
|
|
end
|
|
file.close()
|
|
Utils.log("INFO", string.format("Station list loaded: %d entries", count))
|
|
end
|
|
end
|
|
|
|
function Audio.enqueueDfpwm(dfpwmData)
|
|
if type(dfpwmData) ~= "string" or #dfpwmData == 0 then return end
|
|
Audio.enqueueItem({ audioData = dfpwmData, audioFormat = "dfpwm", priority = 1 })
|
|
end
|
|
|
|
function Audio.enqueueItem(item)
|
|
if type(item) ~= "table" then return end
|
|
item.audioData = item.audioData or item.dfpwmData
|
|
item.audioFormat = tostring(item.audioFormat or (item.dfpwmData and "dfpwm") or "dfpwm"):lower()
|
|
if type(item.audioData) ~= "string" and type(item.audioData) ~= "table" then return end
|
|
item.priority = tonumber(item.priority) or 0
|
|
local idx = 1
|
|
while idx <= #State.audioQueue do
|
|
local p = tonumber(State.audioQueue[idx].priority) or 0
|
|
if item.priority > p then break end
|
|
idx = idx + 1
|
|
end
|
|
table.insert(State.audioQueue, idx, item)
|
|
os.queueEvent("audio_queue")
|
|
end
|
|
|
|
function Audio.stop(mode, flushBelow)
|
|
State.interruptSeq = State.interruptSeq + 1
|
|
if State.audioRx and State.audioRx.stream then
|
|
pcall(State.audioRx.stream.finish)
|
|
end
|
|
State.audioRx = nil
|
|
if Peripherals.speakers then
|
|
for _, sp in ipairs(Peripherals.speakers) do
|
|
pcall(function()
|
|
if type(sp.speakStop) == "function" then sp.speakStop()
|
|
else sp.stop() end
|
|
end)
|
|
end
|
|
end
|
|
|
|
if mode == "all" then
|
|
State.audioQueue = {}
|
|
elseif flushBelow ~= nil then
|
|
local minP = tonumber(flushBelow) or 0
|
|
local kept = {}
|
|
for _, it in ipairs(State.audioQueue) do
|
|
local p = tonumber(it.priority) or 0
|
|
if p >= minP then table.insert(kept, it) end
|
|
end
|
|
State.audioQueue = kept
|
|
end
|
|
|
|
os.queueEvent("audio_interrupt", State.interruptSeq)
|
|
end
|
|
|
|
local function shouldStop(playSeq)
|
|
return State.interruptSeq ~= playSeq
|
|
end
|
|
|
|
local function waitForInterruptibleDelay(playSeq, seconds)
|
|
local timer = os.startTimer(seconds)
|
|
while true do
|
|
local ev = { os.pullEvent() }
|
|
if ev[1] == "audio_interrupt" and tonumber(ev[2]) and tonumber(ev[2]) >= playSeq then
|
|
return false
|
|
end
|
|
if ev[1] == "timer" and ev[2] == timer then
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
|
|
local function waitForNativeSpeakersEmpty(speakers, playSeq)
|
|
local waiting = {}
|
|
for _, sp in ipairs(speakers or {}) do
|
|
local name = peripheral.getName(sp)
|
|
if name then waiting[name] = true end
|
|
end
|
|
|
|
while next(waiting) do
|
|
if shouldStop(playSeq) then return false end
|
|
local ev = { os.pullEvent() }
|
|
if ev[1] == "audio_interrupt" and tonumber(ev[2]) and tonumber(ev[2]) >= playSeq then
|
|
return false
|
|
end
|
|
if ev[1] == "speaker_audio_empty" and ev[2] and waiting[ev[2]] then
|
|
waiting[ev[2]] = nil
|
|
end
|
|
end
|
|
|
|
return true
|
|
end
|
|
|
|
local function queueSamplesSynchronized(samples, volume, playSeq)
|
|
if not Peripherals.speakers or #Peripherals.speakers == 0 then return false end
|
|
if not waitForNativeSpeakersEmpty(Peripherals.speakers, playSeq) then
|
|
return false
|
|
end
|
|
|
|
local pending = {}
|
|
for _, sp in ipairs(Peripherals.speakers) do
|
|
local name = peripheral.getName(sp)
|
|
if name and type(sp.playAudio) == "function" then
|
|
pending[name] = sp
|
|
end
|
|
end
|
|
|
|
while next(pending) do
|
|
if shouldStop(playSeq) then return false end
|
|
for name, sp in pairs(pending) do
|
|
local ok = false
|
|
pcall(function()
|
|
ok = sp.playAudio(samples, volume)
|
|
end)
|
|
if ok then
|
|
pending[name] = nil
|
|
end
|
|
end
|
|
if next(pending) then
|
|
local ev = { os.pullEvent() }
|
|
if ev[1] == "audio_interrupt" and tonumber(ev[2]) and tonumber(ev[2]) >= playSeq then
|
|
return false
|
|
end
|
|
end
|
|
end
|
|
|
|
Utils.log("INFO", string.format("Audio samples queued in synchronized multi-speaker mode: samples=%d speakers=%d", #samples, #Peripherals.speakers))
|
|
return true
|
|
end
|
|
|
|
local function playChunkOnSpeakers(samples, volume, playSeq, waitForDrain)
|
|
if not Peripherals.speakers or #Peripherals.speakers == 0 then return false end
|
|
if waitForDrain == nil then waitForDrain = true end
|
|
local pending = {}
|
|
for _, sp in ipairs(Peripherals.speakers) do
|
|
local name = peripheral.getName(sp)
|
|
if name then pending[name] = sp end
|
|
end
|
|
|
|
local usedNativePath = false
|
|
while next(pending) do
|
|
if shouldStop(playSeq) then return false end
|
|
for name, sp in pairs(pending) do
|
|
local ok = false
|
|
pcall(function()
|
|
if type(sp.playAudio) == "function" then
|
|
ok = sp.playAudio(samples, volume)
|
|
end
|
|
end)
|
|
if ok then
|
|
usedNativePath = true
|
|
pending[name] = nil
|
|
end
|
|
end
|
|
if next(pending) then
|
|
local ev = { os.pullEvent() }
|
|
if ev[1] == "audio_interrupt" and tonumber(ev[2]) and tonumber(ev[2]) >= playSeq then
|
|
return false
|
|
end
|
|
end
|
|
end
|
|
|
|
if usedNativePath then
|
|
Utils.log("INFO", string.format("Audio samples queued via native speaker.playAudio: samples=%d", #samples))
|
|
else
|
|
local controller = Peripherals.speakers[1]
|
|
if controller and type(controller.playAudioAll) == "function" then
|
|
Utils.log("WARN", "Falling back to controller.playAudioAll for decoded samples")
|
|
while true do
|
|
if shouldStop(playSeq) then return false end
|
|
local ok = false
|
|
pcall(function()
|
|
ok = controller.playAudioAll(samples, volume)
|
|
end)
|
|
if ok then break end
|
|
local ev = { os.pullEvent() }
|
|
if ev[1] == "audio_interrupt" and tonumber(ev[2]) and tonumber(ev[2]) >= playSeq then
|
|
return false
|
|
end
|
|
end
|
|
else
|
|
return false
|
|
end
|
|
end
|
|
|
|
if not waitForDrain then return true end
|
|
return waitForNativeSpeakersEmpty(Peripherals.speakers, playSeq)
|
|
end
|
|
|
|
local function decodePcmS8Chunk(chunk)
|
|
if type(chunk) ~= "string" or #chunk == 0 then return nil end
|
|
local samples = {}
|
|
for i = 1, #chunk do
|
|
local b = string.byte(chunk, i)
|
|
if b >= 128 then
|
|
b = b - 256
|
|
end
|
|
samples[i] = b
|
|
end
|
|
return samples
|
|
end
|
|
|
|
local function readU16LE(data, offset)
|
|
local a, b = string.byte(data, offset, offset + 1)
|
|
if not a or not b then return nil end
|
|
return a + b * 256
|
|
end
|
|
|
|
local function readS16LE(data, offset)
|
|
local val = readU16LE(data, offset)
|
|
if not val then return nil end
|
|
if val >= 32768 then
|
|
val = val - 65536
|
|
end
|
|
return val
|
|
end
|
|
|
|
local function readU32LE(data, offset)
|
|
local a, b, c, d = string.byte(data, offset, offset + 3)
|
|
if not a or not b or not c or not d then return nil end
|
|
return a + b * 256 + c * 65536 + d * 16777216
|
|
end
|
|
|
|
local function clampSample8(val)
|
|
if val > 127 then return 127 end
|
|
if val < -128 then return -128 end
|
|
return val
|
|
end
|
|
|
|
local function decodeWavChunkToSamples(chunk)
|
|
if type(chunk) ~= "string" or #chunk < 44 then
|
|
return nil, "wav chunk too short"
|
|
end
|
|
if chunk:sub(1, 4) ~= "RIFF" or chunk:sub(9, 12) ~= "WAVE" then
|
|
return nil, "invalid wav header"
|
|
end
|
|
|
|
local offset = 13
|
|
local audioFormat, channels, sampleRate, bitsPerSample
|
|
local dataOffset, dataSize
|
|
|
|
while offset + 7 <= #chunk do
|
|
local chunkId = chunk:sub(offset, offset + 3)
|
|
local chunkSize = readU32LE(chunk, offset + 4)
|
|
if not chunkSize then break end
|
|
local payloadOffset = offset + 8
|
|
if chunkId == "fmt " and chunkSize >= 16 then
|
|
audioFormat = readU16LE(chunk, payloadOffset)
|
|
channels = readU16LE(chunk, payloadOffset + 2)
|
|
sampleRate = readU32LE(chunk, payloadOffset + 4)
|
|
bitsPerSample = readU16LE(chunk, payloadOffset + 14)
|
|
elseif chunkId == "data" then
|
|
dataOffset = payloadOffset
|
|
dataSize = chunkSize
|
|
break
|
|
end
|
|
offset = payloadOffset + chunkSize
|
|
if chunkSize % 2 == 1 then
|
|
offset = offset + 1
|
|
end
|
|
end
|
|
|
|
if audioFormat ~= 1 then
|
|
return nil, "unsupported wav format " .. tostring(audioFormat)
|
|
end
|
|
if channels ~= 1 and channels ~= 2 then
|
|
return nil, "unsupported channels " .. tostring(channels)
|
|
end
|
|
if bitsPerSample ~= 8 and bitsPerSample ~= 16 then
|
|
return nil, "unsupported bits " .. tostring(bitsPerSample)
|
|
end
|
|
if not dataOffset or not dataSize or dataOffset + dataSize - 1 > #chunk then
|
|
return nil, "invalid wav data chunk"
|
|
end
|
|
|
|
local frameSize = channels * (bitsPerSample / 8)
|
|
if frameSize <= 0 then
|
|
return nil, "invalid frame size"
|
|
end
|
|
|
|
local samples = {}
|
|
local sampleIndex = 1
|
|
local endOffset = dataOffset + dataSize - frameSize
|
|
|
|
for pos = dataOffset, endOffset, frameSize do
|
|
local mixed = 0
|
|
for ch = 0, channels - 1 do
|
|
local sampleOffset = pos + ch * (bitsPerSample / 8)
|
|
local sampleValue
|
|
if bitsPerSample == 8 then
|
|
local raw = string.byte(chunk, sampleOffset)
|
|
sampleValue = (raw or 128) - 128
|
|
else
|
|
sampleValue = readS16LE(chunk, sampleOffset) or 0
|
|
if sampleValue >= 0 then
|
|
sampleValue = math.floor(sampleValue / 256)
|
|
else
|
|
sampleValue = math.ceil(sampleValue / 256)
|
|
end
|
|
end
|
|
mixed = mixed + sampleValue
|
|
end
|
|
samples[sampleIndex] = clampSample8(math.floor(mixed / channels))
|
|
sampleIndex = sampleIndex + 1
|
|
end
|
|
|
|
return samples, nil, {
|
|
channels = channels,
|
|
bitsPerSample = bitsPerSample,
|
|
sampleRate = sampleRate or 0
|
|
}
|
|
end
|
|
|
|
local function areHqSpeakersBusy(speakers)
|
|
for _, sp in ipairs(speakers) do
|
|
local playing = false
|
|
local queued = 0
|
|
pcall(function()
|
|
if type(sp.speakIsPlaying) == "function" then
|
|
playing = sp.speakIsPlaying() and true or false
|
|
end
|
|
if type(sp.speakQueueSize) == "function" then
|
|
queued = tonumber(sp.speakQueueSize()) or 0
|
|
end
|
|
end)
|
|
if playing or queued > 0 then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function downloadAudioBytes(url)
|
|
if type(url) ~= "string" or url == "" then return nil, nil, "empty url" end
|
|
local ok, resp = pcall(function()
|
|
return http.get(url, nil, true)
|
|
end)
|
|
if not ok or not resp then
|
|
return nil, nil, "http.get failed"
|
|
end
|
|
|
|
local body = resp.readAll()
|
|
resp.close()
|
|
if type(body) ~= "string" or #body == 0 then
|
|
return nil, nil, "empty response body"
|
|
end
|
|
|
|
local lower = url:lower()
|
|
local ext = lower:match("%.([%a%d]+)$")
|
|
return body, ext or "file", nil
|
|
end
|
|
|
|
local function playHqAudioItem(item)
|
|
if not Peripherals.speakers or #Peripherals.speakers == 0 then
|
|
Utils.log("WARN", "No speakers found; cannot play audio")
|
|
return
|
|
end
|
|
|
|
local playSeq = State.interruptSeq
|
|
local payload = item.audioData
|
|
if type(payload) ~= "string" or #payload == 0 then return end
|
|
|
|
local format = tostring(item.audioFormat or "file"):lower()
|
|
if format == "stream" then
|
|
local bytes, ext, err = downloadAudioBytes(payload)
|
|
if not bytes then
|
|
Utils.log("ERROR", "Failed to download stream audio: " .. tostring(err))
|
|
return
|
|
end
|
|
Utils.log("INFO", "Downloaded stream audio: " .. tostring(#bytes) .. " bytes")
|
|
payload = bytes
|
|
if ext == "wav" then format = "wav"
|
|
elseif ext == "mp3" then format = "mp3"
|
|
elseif ext == "ogg" then format = "ogg"
|
|
else format = "file" end
|
|
end
|
|
|
|
local controller = Peripherals.speakers[1]
|
|
local called = false
|
|
local loopEnabled = item.loop and true or false
|
|
|
|
if controller then
|
|
pcall(function()
|
|
if type(controller.setLoopingAll) == "function" then
|
|
controller.setLoopingAll(loopEnabled)
|
|
end
|
|
end)
|
|
local ok, result = pcall(function()
|
|
if format == "hls" and type(controller.speakHLSAll) == "function" then
|
|
return controller.speakHLSAll(payload, CONFIG.AUDIO_VOLUME)
|
|
elseif format == "wav" and type(controller.speakWavAll) == "function" then
|
|
return controller.speakWavAll(payload, CONFIG.AUDIO_VOLUME)
|
|
elseif format == "mp3" and type(controller.speakMp3All) == "function" then
|
|
return controller.speakMp3All(payload, CONFIG.AUDIO_VOLUME)
|
|
elseif format == "ogg" and type(controller.speakOggAll) == "function" then
|
|
return controller.speakOggAll(payload, CONFIG.AUDIO_VOLUME)
|
|
elseif type(controller.speakFileAll) == "function" then
|
|
return controller.speakFileAll(payload, CONFIG.AUDIO_VOLUME)
|
|
end
|
|
end)
|
|
if ok and result then
|
|
called = true
|
|
end
|
|
end
|
|
|
|
if not called then
|
|
for _, sp in ipairs(Peripherals.speakers) do
|
|
pcall(function()
|
|
if type(sp.setLooping) == "function" then
|
|
sp.setLooping(loopEnabled)
|
|
end
|
|
end)
|
|
local ok, result = pcall(function()
|
|
if format == "hls" and type(sp.speakHLS) == "function" then
|
|
return sp.speakHLS(payload, CONFIG.AUDIO_VOLUME)
|
|
elseif format == "wav" and type(sp.speakWav) == "function" then
|
|
return sp.speakWav(payload, CONFIG.AUDIO_VOLUME)
|
|
elseif format == "mp3" and type(sp.speakMp3) == "function" then
|
|
return sp.speakMp3(payload, CONFIG.AUDIO_VOLUME)
|
|
elseif format == "ogg" and type(sp.speakOgg) == "function" then
|
|
return sp.speakOgg(payload, CONFIG.AUDIO_VOLUME)
|
|
elseif type(sp.speakFile) == "function" then
|
|
return sp.speakFile(payload, CONFIG.AUDIO_VOLUME)
|
|
end
|
|
end)
|
|
if ok and result then
|
|
called = true
|
|
end
|
|
end
|
|
end
|
|
|
|
if not called then
|
|
Utils.log("ERROR", "HQ speaker did not accept audio for format " .. format)
|
|
return
|
|
end
|
|
|
|
while areHqSpeakersBusy(Peripherals.speakers) do
|
|
if shouldStop(playSeq) then
|
|
Audio.stop("current")
|
|
return
|
|
end
|
|
if not waitForInterruptibleDelay(playSeq, 0.1) then
|
|
Audio.stop("current")
|
|
return
|
|
end
|
|
end
|
|
end
|
|
|
|
local function pushWavChunkToSpeakers(payload, playSeq)
|
|
local controller = Peripherals.speakers and Peripherals.speakers[1] or nil
|
|
if controller and type(controller.speakWavAll) == "function" then
|
|
local retryCount = 0
|
|
while true do
|
|
if shouldStop(playSeq) then return false end
|
|
local ok = false
|
|
local queueSize = -1
|
|
local isPlaying = false
|
|
pcall(function()
|
|
ok = controller.speakWavAll(payload, CONFIG.AUDIO_VOLUME)
|
|
if type(controller.speakQueueSize) == "function" then
|
|
queueSize = tonumber(controller.speakQueueSize()) or -1
|
|
end
|
|
if type(controller.speakIsPlaying) == "function" then
|
|
isPlaying = controller.speakIsPlaying() and true or false
|
|
end
|
|
end)
|
|
if ok then
|
|
Utils.log("INFO", string.format("WAV chunk accepted: bytes=%d queue=%d playing=%s", #payload, queueSize, tostring(isPlaying)))
|
|
return true
|
|
end
|
|
retryCount = retryCount + 1
|
|
if retryCount == 1 or retryCount % 20 == 0 then
|
|
Utils.log("WARN", string.format("WAV chunk waiting: bytes=%d queue=%d playing=%s retries=%d", #payload, queueSize, tostring(isPlaying), retryCount))
|
|
end
|
|
if not waitForInterruptibleDelay(playSeq, 0.05) then
|
|
return false
|
|
end
|
|
end
|
|
end
|
|
|
|
local pending = {}
|
|
for _, sp in ipairs(Peripherals.speakers or {}) do
|
|
local name = peripheral.getName(sp)
|
|
if name then pending[name] = sp end
|
|
end
|
|
|
|
local retryCount = 0
|
|
while next(pending) do
|
|
if shouldStop(playSeq) then return false end
|
|
for name, sp in pairs(pending) do
|
|
local ok = false
|
|
pcall(function()
|
|
if type(sp.speakWav) == "function" then
|
|
ok = sp.speakWav(payload, CONFIG.AUDIO_VOLUME)
|
|
end
|
|
end)
|
|
if ok then pending[name] = nil end
|
|
end
|
|
if next(pending) then
|
|
retryCount = retryCount + 1
|
|
if retryCount == 1 or retryCount % 20 == 0 then
|
|
local pendingCount = 0
|
|
for _ in pairs(pending) do
|
|
pendingCount = pendingCount + 1
|
|
end
|
|
Utils.log("WARN", string.format("WAV chunk waiting on %d speakers: bytes=%d retries=%d", pendingCount, #payload, retryCount))
|
|
end
|
|
else
|
|
Utils.log("INFO", string.format("WAV chunk accepted by fallback speakers: bytes=%d", #payload))
|
|
end
|
|
if next(pending) and not waitForInterruptibleDelay(playSeq, 0.05) then
|
|
return false
|
|
end
|
|
end
|
|
|
|
return true
|
|
end
|
|
|
|
function Audio.playDfpwmItem(item)
|
|
if not Peripherals.speakers or #Peripherals.speakers == 0 then
|
|
Utils.log("WARN", "No speakers found; cannot play audio")
|
|
return
|
|
end
|
|
|
|
local playSeq = State.interruptSeq
|
|
local source = item.audioData
|
|
|
|
-- Support both static string data and streaming functions
|
|
if type(source) == "table" and source.read then
|
|
source = source.read
|
|
end
|
|
|
|
if type(source) ~= "function" then
|
|
local data = source
|
|
source = function()
|
|
local chunk = data
|
|
data = nil
|
|
return chunk
|
|
end
|
|
end
|
|
|
|
local decoder = dfpwm.make_decoder()
|
|
while true do
|
|
if shouldStop(playSeq) then break end
|
|
local chunk = source()
|
|
if chunk == nil then break end
|
|
if type(chunk) == "string" and #chunk > 0 then
|
|
local samples = decoder(chunk)
|
|
if samples and not playChunkOnSpeakers(samples, CONFIG.AUDIO_VOLUME, playSeq) then
|
|
break
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
function Audio.playPcmItem(item)
|
|
if not Peripherals.speakers or #Peripherals.speakers == 0 then
|
|
Utils.log("WARN", "No speakers found; cannot play audio")
|
|
return
|
|
end
|
|
|
|
local playSeq = State.interruptSeq
|
|
local source = item.audioData
|
|
|
|
if type(source) == "table" and source.read then
|
|
source = source.read
|
|
end
|
|
|
|
if type(source) ~= "function" then
|
|
local data = source
|
|
source = function()
|
|
local chunk = data
|
|
data = nil
|
|
return chunk
|
|
end
|
|
end
|
|
|
|
while true do
|
|
if shouldStop(playSeq) then break end
|
|
local chunk = source()
|
|
if chunk == nil then break end
|
|
if type(chunk) == "string" and #chunk > 0 then
|
|
local samples = decodePcmS8Chunk(chunk)
|
|
if samples and not playChunkOnSpeakers(samples, CONFIG.AUDIO_VOLUME, playSeq) then
|
|
break
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
function Audio.playWavStreamItem(item)
|
|
if not Peripherals.speakers or #Peripherals.speakers == 0 then
|
|
Utils.log("WARN", "No speakers found; cannot play audio")
|
|
return
|
|
end
|
|
|
|
local playSeq = State.interruptSeq
|
|
local source = item.audioData
|
|
|
|
if type(source) == "table" and source.read then
|
|
source = source.read
|
|
end
|
|
|
|
if type(source) ~= "function" then
|
|
local data = source
|
|
source = function()
|
|
local chunk = data
|
|
data = nil
|
|
return chunk
|
|
end
|
|
end
|
|
|
|
while true do
|
|
if shouldStop(playSeq) then
|
|
Audio.stop("current")
|
|
return
|
|
end
|
|
|
|
local chunk = source()
|
|
if chunk == nil then break end
|
|
if type(chunk) == "string" and #chunk > 0 then
|
|
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
|
|
if #Peripherals.speakers > 1 then
|
|
playOk = queueSamplesSynchronized(samples, CONFIG.AUDIO_VOLUME, playSeq)
|
|
else
|
|
playOk = playChunkOnSpeakers(samples, CONFIG.AUDIO_VOLUME, playSeq, false)
|
|
end
|
|
if not playOk then
|
|
Audio.stop("current")
|
|
return
|
|
end
|
|
else
|
|
Utils.log("WARN", "WAV chunk decode failed, falling back to HQ path: " .. tostring(err))
|
|
if not pushWavChunkToSpeakers(chunk, playSeq) then
|
|
Audio.stop("current")
|
|
return
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
if not waitForNativeSpeakersEmpty(Peripherals.speakers, playSeq) then
|
|
Audio.stop("current")
|
|
return
|
|
end
|
|
|
|
while areHqSpeakersBusy(Peripherals.speakers) do
|
|
if shouldStop(playSeq) then
|
|
Audio.stop("current")
|
|
return
|
|
end
|
|
if not waitForInterruptibleDelay(playSeq, 0.05) then
|
|
Audio.stop("current")
|
|
return
|
|
end
|
|
end
|
|
end
|
|
|
|
function Audio.playItem(item)
|
|
local format = tostring(item.audioFormat or "dfpwm"):lower()
|
|
if format == "wav" or format == "file" or format == "stream" or format == "hls" then
|
|
return playHqAudioItem(item)
|
|
elseif format == "pcm" then
|
|
return Audio.playPcmItem(item)
|
|
elseif format == "wav-stream" then
|
|
return Audio.playWavStreamItem(item)
|
|
end
|
|
return Audio.playDfpwmItem(item)
|
|
end
|
|
|
|
function Audio.playerLoop()
|
|
while true do
|
|
if #State.audioQueue == 0 then
|
|
os.pullEvent("audio_queue")
|
|
else
|
|
local item = table.remove(State.audioQueue, 1)
|
|
State.currentPlaying = item
|
|
State.currentPriority = tonumber(item.priority) or 0
|
|
|
|
local ok, err = pcall(Audio.playItem, item)
|
|
if not ok then
|
|
Utils.log("ERROR", "Audio Playback Crash: " .. tostring(err))
|
|
end
|
|
|
|
State.currentPlaying = nil
|
|
State.currentPriority = -1
|
|
end
|
|
end
|
|
end
|
|
|
|
function Utils.getChineseNumberAudio(val)
|
|
local num = tonumber(val)
|
|
if num then
|
|
local seq = {}
|
|
if num < 10 then
|
|
table.insert(seq, "ch-" .. num)
|
|
elseif num == 10 then
|
|
table.insert(seq, "ch-10")
|
|
elseif num > 10 and num < 20 then
|
|
table.insert(seq, "ch-10")
|
|
table.insert(seq, "ch-" .. (num % 10))
|
|
elseif num >= 20 and num < 100 then
|
|
local ten = math.floor(num / 10)
|
|
local unit = num % 10
|
|
table.insert(seq, "ch-" .. ten)
|
|
table.insert(seq, "ch-10")
|
|
if unit > 0 then
|
|
table.insert(seq, "ch-" .. unit)
|
|
end
|
|
else
|
|
table.insert(seq, "ch-" .. num)
|
|
end
|
|
return seq
|
|
else
|
|
-- Handle Alphanumeric (e.g., "1A", "2B")
|
|
local digit, letter = tostring(val):match("^(%d+)([a-zA-Z])$")
|
|
if digit and letter then
|
|
local seq = Utils.getChineseNumberAudio(digit)
|
|
table.insert(seq, letter:upper()) -- e.g. "A", "B"
|
|
return seq
|
|
end
|
|
return {tostring(val)}
|
|
end
|
|
end
|
|
|
|
function Utils.getStationAudio(code, langPrefix)
|
|
if not code then return nil end
|
|
local files = State.stationList[code]
|
|
if files then
|
|
local p = langPrefix:lower()
|
|
for _, f in ipairs(files) do
|
|
if f:lower():find("^" .. p) then
|
|
return f
|
|
end
|
|
end
|
|
end
|
|
-- Fallback to hardcoded pattern
|
|
return langPrefix .. code
|
|
end
|
|
|
|
-- ============================================================================
|
|
-- 6. Network System
|
|
-- ============================================================================
|
|
local Network = {}
|
|
|
|
function Network.connect()
|
|
if State.ws then return end
|
|
Utils.log("INFO", "Connecting to cloud...")
|
|
State.ws = http.websocket(CONFIG.CLOUD_URL)
|
|
if State.ws then
|
|
Utils.log("SUCCESS", "Connected")
|
|
Network.register()
|
|
else
|
|
Utils.log("ERROR", "Connection failed")
|
|
end
|
|
end
|
|
|
|
function Network.send(data)
|
|
if State.ws then
|
|
local ok, err = pcall(function()
|
|
State.ws.send(textutils.serializeJSON(data))
|
|
end)
|
|
if not ok then
|
|
Utils.log("ERROR", "Failed to send WS message: " .. tostring(err))
|
|
end
|
|
end
|
|
end
|
|
|
|
function Network.register()
|
|
Utils.refreshStationIdentity()
|
|
|
|
Network.send({
|
|
type = "station_register",
|
|
stationName = State.stationName,
|
|
stationCode = State.stationCode,
|
|
stationPlatform = State.stationPlatform,
|
|
stationVersion = APP_VERSION_TEXT
|
|
})
|
|
end
|
|
|
|
function Network.syncFiles()
|
|
-- Sync only config file (list.txt) periodically
|
|
while true do
|
|
local resp = http.get(CONFIG.API_URL .. "/list.txt")
|
|
if not resp then
|
|
resp = http.get(CONFIG.API_URL .. "/uploads/list.txt")
|
|
end
|
|
if resp then
|
|
local content = resp.readAll()
|
|
resp.close()
|
|
if content and #content > 0 then
|
|
local currentList = ""
|
|
if fs.exists("list.txt") then
|
|
local f = fs.open("list.txt", "r")
|
|
currentList = f.readAll()
|
|
f.close()
|
|
end
|
|
|
|
if currentList ~= content then
|
|
local f = fs.open("list.txt", "w")
|
|
f.write(content)
|
|
f.close()
|
|
Audio.loadStationList()
|
|
end
|
|
end
|
|
end
|
|
os.sleep(CONFIG.SYNC_INTERVAL)
|
|
end
|
|
end
|
|
|
|
function Network.messageLoop()
|
|
local function finishAudioRx()
|
|
if not State.audioRx then return end
|
|
local rx = State.audioRx
|
|
if rx.stream then
|
|
rx.stream.finish()
|
|
else
|
|
Audio.enqueueItem({
|
|
audioData = table.concat(rx.chunks or {}),
|
|
audioFormat = rx.audioFormat or "wav",
|
|
priority = rx.priority,
|
|
loop = rx.loop and true or false,
|
|
source = rx.source,
|
|
audioId = rx.audioId
|
|
})
|
|
end
|
|
State.audioRx = nil
|
|
end
|
|
|
|
while true do
|
|
local event, url, msg = os.pullEvent()
|
|
if event == "websocket_message" and url == CONFIG.CLOUD_URL then
|
|
local ok, data = pcall(textutils.unserializeJSON, msg)
|
|
if ok and type(data) == "table" then
|
|
if data.type == "audio_start" then
|
|
if State.audioRx and State.audioRx.stream then
|
|
pcall(State.audioRx.stream.finish)
|
|
end
|
|
local priority = tonumber(data.priority) or 0
|
|
local audioFormat = tostring(data.audioFormat or "dfpwm"):lower()
|
|
local streamUrl = type(data.streamUrl) == "string" and data.streamUrl or nil
|
|
local interruptMode = tostring(data.interruptMode or "")
|
|
if interruptMode == "" then
|
|
if priority >= 2 then interruptMode = "all"
|
|
elseif priority >= 1 then interruptMode = "ifLower"
|
|
else interruptMode = "none" end
|
|
end
|
|
|
|
if interruptMode == "all" then
|
|
Audio.stop("all")
|
|
elseif interruptMode == "ifLower" and priority > (State.currentPriority or -1) then
|
|
Audio.stop("current", priority)
|
|
end
|
|
|
|
State.audioRx = {
|
|
audioId = data.audioId,
|
|
expectedBytes = tonumber(data.byteLength) or 0,
|
|
receivedBytes = 0,
|
|
priority = priority,
|
|
loop = data.loop and true or false,
|
|
source = data.source or data.category,
|
|
audioFormat = audioFormat
|
|
}
|
|
|
|
if (audioFormat == "stream" or audioFormat == "hls") and streamUrl and streamUrl ~= "" then
|
|
Audio.enqueueItem({
|
|
audioData = streamUrl,
|
|
audioFormat = audioFormat,
|
|
priority = priority,
|
|
loop = State.audioRx.loop,
|
|
source = State.audioRx.source,
|
|
audioId = data.audioId
|
|
})
|
|
State.audioRx = nil
|
|
elseif audioFormat == "dfpwm" or audioFormat == "pcm" or audioFormat == "wav-stream" then
|
|
local stream = Audio.createStream()
|
|
State.audioRx.stream = stream
|
|
if audioFormat == "pcm" then
|
|
Utils.log("INFO", "Receiving live PCM audio stream")
|
|
elseif audioFormat == "wav-stream" then
|
|
Utils.log("INFO", "Receiving live WAV chunk stream")
|
|
end
|
|
Audio.enqueueItem({
|
|
audioData = stream,
|
|
audioFormat = audioFormat,
|
|
priority = priority,
|
|
source = State.audioRx.source,
|
|
audioId = data.audioId
|
|
})
|
|
else
|
|
State.audioRx.chunks = {}
|
|
end
|
|
elseif data.type == "audio_end" then
|
|
if State.audioRx and (not data.audioId or data.audioId == State.audioRx.audioId) then
|
|
finishAudioRx()
|
|
end
|
|
elseif data.type == "audio_stop" then
|
|
local scope = tostring(data.scope or "")
|
|
if scope == "music" then
|
|
local kept = {}
|
|
for _, it in ipairs(State.audioQueue) do
|
|
if tostring(it.source or "") ~= "music" then
|
|
table.insert(kept, it)
|
|
end
|
|
end
|
|
State.audioQueue = kept
|
|
|
|
if State.audioRx and tostring(State.audioRx.source or "") == "music" then
|
|
State.audioRx = nil
|
|
end
|
|
|
|
if State.currentPlaying and tostring(State.currentPlaying.source or "") == "music" then
|
|
Audio.stop("current")
|
|
end
|
|
else
|
|
local mode = tostring(data.mode or "current")
|
|
local flushBelow = data.flushBelow
|
|
Audio.stop(mode, flushBelow)
|
|
end
|
|
end
|
|
else
|
|
if State.audioRx then
|
|
-- Memory protection: limit to 10MB per audio item
|
|
local isLiveStream = State.audioRx.stream and (tonumber(State.audioRx.expectedBytes) or 0) <= 0
|
|
if (not isLiveStream) and State.audioRx.receivedBytes + #msg > 10 * 1024 * 1024 then
|
|
Utils.log("ERROR", "Audio item too large, discarding")
|
|
if State.audioRx.stream then
|
|
State.audioRx.stream.finish()
|
|
end
|
|
State.audioRx = nil
|
|
else
|
|
if State.audioRx.stream then
|
|
State.audioRx.stream.push(msg)
|
|
else
|
|
table.insert(State.audioRx.chunks, msg)
|
|
end
|
|
State.audioRx.receivedBytes = State.audioRx.receivedBytes + #msg
|
|
if State.audioRx.expectedBytes > 0 and State.audioRx.receivedBytes >= State.audioRx.expectedBytes then
|
|
finishAudioRx()
|
|
end
|
|
end
|
|
end
|
|
end
|
|
elseif event == "websocket_closed" then
|
|
Utils.log("WARN", "WS Closed")
|
|
State.ws = nil
|
|
State.audioRx = nil -- Clear partial download
|
|
os.sleep(5)
|
|
end
|
|
end
|
|
end
|
|
|
|
function Network.heartbeat()
|
|
while true do
|
|
if State.ws then
|
|
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
|
|
local w, h = mon.getSize()
|
|
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
|
|
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
|
|
end
|
|
|
|
-- Logs
|
|
local y = h - 3
|
|
for i = 1, 3 do
|
|
local logIdx = i + State.logScrollOffset
|
|
local entry = State.logs[logIdx]
|
|
if entry then
|
|
mon.setCursorPos(1, y + i - 1)
|
|
mon.setTextColor(COLORS[entry.level] or colors.white)
|
|
mon.write(entry.text:sub(1, w))
|
|
end
|
|
end
|
|
end
|
|
|
|
function Display.renderTerminal()
|
|
local w, h = term.getSize()
|
|
|
|
-- Header
|
|
term.setCursorPos(1, 1)
|
|
term.setBackgroundColor(colors.gray)
|
|
term.setTextColor(colors.white)
|
|
term.clearLine()
|
|
local title = " Station Broadcast Console (" .. State.stationName .. " " .. tostring(State.stationCode) .. "/P" .. tostring(State.stationPlatform) .. " / " .. APP_VERSION_TEXT .. ") "
|
|
term.setCursorPos(math.floor((w - #title)/2) + 1, 1)
|
|
term.write(title)
|
|
term.setBackgroundColor(colors.black)
|
|
term.setCursorPos(1, 2)
|
|
term.setTextColor(colors.gray)
|
|
term.write(string.rep("-", w))
|
|
|
|
-- Logs
|
|
local logStart = 3
|
|
local maxLogs = h - logStart + 1
|
|
for i = 1, maxLogs do
|
|
local logIdx = i + State.logScrollOffset
|
|
term.setCursorPos(1, logStart + i - 1)
|
|
term.clearLine()
|
|
local entry = State.logs[logIdx]
|
|
if entry then
|
|
term.setTextColor(COLORS[entry.level] or colors.white)
|
|
term.write(entry.text:sub(1, w - 1))
|
|
end
|
|
end
|
|
|
|
-- Scroll Indicator
|
|
if State.logScrollOffset > 0 then
|
|
term.setCursorPos(w, logStart)
|
|
term.setTextColor(colors.yellow)
|
|
term.write("^")
|
|
end
|
|
if #State.logs > State.logScrollOffset + maxLogs then
|
|
term.setCursorPos(w, h)
|
|
term.setTextColor(colors.yellow)
|
|
term.write("v")
|
|
end
|
|
end
|
|
|
|
-- ============================================================================
|
|
-- 7.5 Input Handling
|
|
-- ============================================================================
|
|
local Input = {}
|
|
|
|
function Input.listener()
|
|
Utils.log("DEBUG", "Input listener started")
|
|
local monitorName = peripheral.getName(Peripherals.primaryMonitor)
|
|
|
|
while true do
|
|
local event, param1, param2, param3 = os.pullEvent()
|
|
local _, h = term.getSize()
|
|
local maxLogs = h - 5 + 1
|
|
|
|
if event == "mouse_scroll" then
|
|
local direction = param1
|
|
if direction == -1 then -- Scroll Up
|
|
State.logScrollOffset = math.min(State.logScrollOffset + 1, math.max(0, #State.logs - maxLogs))
|
|
elseif direction == 1 then -- Scroll Down
|
|
State.logScrollOffset = math.max(0, State.logScrollOffset - 1)
|
|
end
|
|
Display.renderTerminal()
|
|
Display.render()
|
|
elseif event == "monitor_scroll" and param1 == monitorName then
|
|
local direction = param2
|
|
if direction == -1 then
|
|
State.logScrollOffset = math.min(State.logScrollOffset + 1, math.max(0, #State.logs - 3))
|
|
elseif direction == 1 then
|
|
State.logScrollOffset = math.max(0, State.logScrollOffset - 1)
|
|
end
|
|
Display.renderTerminal()
|
|
Display.render()
|
|
elseif event == "monitor_touch" and param1 == monitorName then
|
|
-- Simple touch to scroll: top half of log area scrolls up, bottom half scrolls down
|
|
local _, monH = Peripherals.primaryMonitor.getSize()
|
|
local y = param3
|
|
if y >= monH - 3 then
|
|
if y == monH - 3 then -- Top line of log area
|
|
State.logScrollOffset = math.min(State.logScrollOffset + 1, math.max(0, #State.logs - 3))
|
|
elseif y == monH then -- Bottom line of log area
|
|
State.logScrollOffset = math.max(0, State.logScrollOffset - 1)
|
|
end
|
|
Display.renderTerminal()
|
|
Display.render()
|
|
end
|
|
elseif event == "key" then
|
|
if param1 == keys.up then
|
|
State.logScrollOffset = math.min(State.logScrollOffset + 1, math.max(0, #State.logs - maxLogs))
|
|
elseif param1 == keys.down then
|
|
State.logScrollOffset = math.max(0, State.logScrollOffset - 1)
|
|
elseif param1 == keys.pageUp then
|
|
State.logScrollOffset = math.min(State.logScrollOffset + 10, math.max(0, #State.logs - maxLogs))
|
|
elseif param1 == keys.pageDown then
|
|
State.logScrollOffset = math.max(0, State.logScrollOffset - 10)
|
|
elseif param1 == keys.home then
|
|
State.logScrollOffset = 0
|
|
elseif param1 == keys["end"] then
|
|
State.logScrollOffset = math.max(0, #State.logs - maxLogs)
|
|
end
|
|
Display.renderTerminal()
|
|
Display.render()
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ============================================================================
|
|
-- 8. Main Logic
|
|
-- ============================================================================
|
|
local Main = {}
|
|
|
|
function Main.loop()
|
|
while true do
|
|
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 now = os.epoch("utc") / 1000
|
|
local stationTime = Utils.getStopTime(schedule, State.stationName) or 60
|
|
|
|
State.currentTrain = {
|
|
id = id,
|
|
trainType = Utils.parseTrainId(id),
|
|
schedule = schedule,
|
|
dest = dest,
|
|
arrivalTime = now,
|
|
departureTime = now + stationTime,
|
|
platform = Utils.getPlatformNumber(State.stationName, "1")
|
|
}
|
|
end
|
|
else
|
|
if State.currentTrain then
|
|
State.currentTrain = nil
|
|
end
|
|
end
|
|
|
|
Display.render()
|
|
Display.renderTerminal()
|
|
os.sleep(0.5)
|
|
::continue::
|
|
end
|
|
end
|
|
|
|
-- ============================================================================
|
|
-- 9. Bootstrap
|
|
-- ============================================================================
|
|
Peripherals.init()
|
|
Utils.ensureSlaveIdentityConfigured()
|
|
Audio.loadStationList()
|
|
Utils.refreshStationIdentity()
|
|
Utils.log("INFO", "StationBroadcast version " .. APP_VERSION_TEXT)
|
|
if Peripherals.station then
|
|
Utils.log("INFO", "Train station peripheral detected on back")
|
|
else
|
|
Utils.log("WARN", "No train station peripheral on back; using manual slave mode")
|
|
Utils.log("INFO", "Edit " .. CONFIG.STATION_IDENTITY_FILE .. " to set station_code/station_platform")
|
|
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
|
|
)
|