local DEFAULT_SERVER_BASE = "https://ticket.fse-media.group" local DEFAULT_SERVER_PATH = "/api/tickets/check" local GATE_OPEN_SECONDS = 2 local VERSION = "v1.5.31" local VERSION_CHECK_INTERVAL = 5 local PERIPHERAL_WAIT_INTERVAL = 0.5 local PERIPHERAL_WAIT_MAX_RETRIES = 40 local MONITOR_HANDSHAKE_RETRIES = 20 local STARTUP_REDRAW_DURATION = 20 local STARTUP_REDRAW_INTERVAL = 0.5 local serverURL = nil local CONFIG_PATH = "gate_config.json" local function readFile(path) if not fs.exists(path) then return nil end local f = fs.open(path, "r") if not f then return nil end local c = f.readAll() f.close() return c end local function writeFile(path, content) local f = fs.open(path, "w") if not f then return false end f.write(content) f.close() return true end local function trim(s) return (tostring(s or ""):gsub("^%s+", ""):gsub("%s+$", "")) end local function normalizeVersionTag(v) local s = trim(v) if #s == 0 then return "" end if s:sub(1, 1):lower() ~= "v" then s = "v" .. s end return s:lower() end local function splitCsv(s) local out = {} s = trim(s) if #s == 0 then return out end for part in s:gmatch("[^,/%s]+") do local v = trim(part) if #v > 0 then table.insert(out, v) end end return out end local pack = table.pack or function(...) return { n = select("#", ...), ... } end local function loadConfig() local def = { mode = "entry", station_codes = {} } local raw = readFile(CONFIG_PATH) if not raw or #raw == 0 then return def end local ok, data = pcall(textutils.unserializeJSON, raw) if not ok or type(data) ~= "table" then return def end if type(data.mode) == "string" then def.mode = data.mode end if type(data.station_codes) == "table" then def.station_codes = data.station_codes end if type(data.side_modes) == "table" then def.side_modes = data.side_modes end if type(data.server_url) == "string" then def.server_url = data.server_url end if type(data.card_server_url) == "string" then def.card_server_url = data.card_server_url end if type(data.station_code) == "string" then def.station_code = data.station_code end if type(data.side_station_codes) == "table" then def.side_station_codes = data.side_station_codes end return def end local function stationSetFromList(list) local set = {} if type(list) ~= "table" then return set end for _, v in ipairs(list) do local c = trim(v) if #c > 0 then local parts = splitCsv(c) if #parts == 0 then set[c] = true else for _, p in ipairs(parts) do set[p] = true end end end end return set end local nativeTerm = (term and type(term.current) == "function" and term.current()) or term local monitor = nil local speaker = nil local inspection = nil local monitorSide = nil local function safeFindPeripheral(peripheralType, sideHint) if not peripheral or type(peripheral.find) ~= "function" then return nil, nil end if sideHint and type(peripheral.wrap) == "function" and type(peripheral.isPresent) == "function" then local okPres, pres = pcall(peripheral.isPresent, sideHint) if okPres and pres then local okW, wrapped = pcall(peripheral.wrap, sideHint) if okW and wrapped then return wrapped, sideHint end end end local foundName = nil local found = nil pcall(function() found = peripheral.find(peripheralType, function(name, wrapped) foundName = name return true end) end) if found then return found, foundName end return nil, nil end local function waitForPeripherals(requireInspection) local attempts = 0 local lastDrawMsg = nil local handshakeDone = false while attempts < PERIPHERAL_WAIT_MAX_RETRIES do local hadChange = false if not monitor then local m, ms = safeFindPeripheral("monitor", monitorSide) if m then monitor = m monitorSide = ms hadChange = true end end if monitor and (hadChange or not handshakeDone) then termDev = monitor local ok = false pcall(function() ok = handshakeMonitor(monitor) end) if ok then handshakeDone = true end end if not speaker then local s, _ = safeFindPeripheral("speaker") if s then speaker = s; hadChange = true end end if not inspection and requireInspection then local i, _ = safeFindPeripheral("ticket_inspection_machine") if i then inspection = i; hadChange = true end end local ok = true if not monitor then ok = false end if requireInspection and not inspection then ok = false end if ok and handshakeDone then return true end if ok and not handshakeDone then local ok2 = false pcall(function() ok2 = handshakeMonitor(monitor) end) if ok2 then handshakeDone = true; return true end end local msg = nil if not monitor then msg = "Waiting for monitor..." elseif not handshakeDone then msg = "Waking monitor..." elseif requireInspection and not inspection then msg = "Waiting for gate peripheral..." end if msg and msg ~= lastDrawMsg then lastDrawMsg = msg pcall(function() local tmpTerm = nativeTerm or term if tmpTerm and type(tmpTerm.getSize) == "function" then tmpTerm.setBackgroundColor(colors.black) tmpTerm.setTextColor(colors.yellow) tmpTerm.clear() tmpTerm.setCursorPos(1, 1) local w, _ = tmpTerm.getSize() local pad = string.rep(" ", math.max(0, math.floor((w - #VERSION) / 2))) tmpTerm.write(pad .. VERSION) local msg2 = tostring(msg) tmpTerm.setCursorPos(1, 3) local pad2 = string.rep(" ", math.max(0, math.floor((w - #msg2) / 2))) tmpTerm.write(pad2 .. msg2) tmpTerm.setCursorPos(1, 5) tmpTerm.setTextColor(colors.gray) local prog = "Attempt " .. tostring(attempts + 1) .. "/" .. tostring(PERIPHERAL_WAIT_MAX_RETRIES) local pad3 = string.rep(" ", math.max(0, math.floor((w - #prog) / 2))) tmpTerm.write(pad3 .. prog) end end) end sleep(PERIPHERAL_WAIT_INTERVAL) attempts = attempts + 1 end return not not monitor end pcall(function() waitForPeripherals(false) end) local serverConnected = nil local isUpdating = false local function nowMs() return (os.epoch and os.epoch("utc")) or (os.time() * 1000) end local function setServerConnected(ok) if serverConnected == ok then return end serverConnected = ok serverLastChangeTs = (os.epoch and os.epoch('utc')) or (os.time() * 1000) os.queueEvent('config_updated') end local function fetchHTTP(url) if not http then print('Error: HTTP API is disabled in ComputerCraft config!') setServerConnected(false) return nil end if http.get then local ok, res, err = pcall(http.get, url) if not ok or not res then if err then print('HTTP GET failed: ' .. tostring(err) .. ' url=' .. tostring(url)) end setServerConnected(false) return nil end local data = res.readAll() res.close() setServerConnected(true) return data end if not http.request then print('Error: HTTP API is disabled in ComputerCraft config!') return nil end local okReq, reqOkOrErr = pcall(http.request, { url = url, method = 'GET' }) if not okReq or not reqOkOrErr then print('HTTP request failed: ' .. tostring(reqOkOrErr) .. ' url=' .. tostring(url)) setServerConnected(false) return nil end while true do local ev, u, p1, p2 = os.pullEvent() if ev == 'http_success' and u == url then local res = p1 local data = res.readAll() res.close() setServerConnected(true) return data elseif ev == 'http_failure' and u == url then print('HTTP GET failed: ' .. tostring(p1 or p2 or 'http_failure') .. ' url=' .. tostring(url)) setServerConnected(false) return nil else os.queueEvent(ev, u, p1, p2) sleep(0) end end end local function jsonDecode(str) if not str then return nil end local ok, res = pcall(textutils.unserializeJSON, str) if ok then return res else return nil end end local termDev = nativeTerm if peripheral and type(peripheral.getName) == "function" and monitor then pcall(function() monitorSide = peripheral.getName(monitor) end) end local function safeCallTerm(method, ...) if not termDev or type(termDev[method]) ~= "function" then return nil end local ok, r1, r2, r3 = pcall(termDev[method], ...) if not ok then return nil end return r1, r2, r3 end local function safeCallMonitor(target, method, ...) if not target or type(target[method]) ~= "function" then return false end local ok, r1 = pcall(target[method], ...) if not ok then return false end return r1 ~= false end local function handshakeMonitor(target) if not target then return false end local scaleOk = false local getSizeOk = false local w, h = nil, nil for _ = 1, MONITOR_HANDSHAKE_RETRIES do if safeCallMonitor(target, "setTextScale", 0.5) then scaleOk = true end w, h = safeCallTerm("getSize") if type(w) == "number" and type(h) == "number" and w >= 2 and h >= 2 then getSizeOk = true end if scaleOk and getSizeOk then local cOk = safeCallMonitor(target, "setBackgroundColor", colors.black) local c2Ok = safeCallMonitor(target, "setTextColor", colors.white) local clearOk = safeCallMonitor(target, "clear") if clearOk then return true end end sleep(PERIPHERAL_WAIT_INTERVAL) local rewrapOk, rewrapped = pcall(function() if monitorSide and peripheral and type(peripheral.isPresent) == "function" and peripheral.isPresent(monitorSide) and type(peripheral.wrap) == "function" then local okW, wd = pcall(peripheral.wrap, monitorSide) if okW and wd then return wd end end if peripheral and type(peripheral.find) == "function" then return peripheral.find("monitor") end return nil end) if rewrapOk and rewrapped then target = rewrapped monitor = rewrapped termDev = rewrapped end end return scaleOk or getSizeOk end local function refreshDisplayTarget() local nextMonitor = nil local nextMonitorSide = monitorSide if peripheral then if nextMonitorSide and type(peripheral.isPresent) == "function" then local okPres, pres = pcall(peripheral.isPresent, nextMonitorSide) if okPres and pres and type(peripheral.wrap) == "function" then local okWrap, wrapped = pcall(peripheral.wrap, nextMonitorSide) if okWrap and wrapped then nextMonitor = wrapped end end end if not nextMonitor and type(peripheral.find) == "function" then local foundName = nil local found = nil pcall(function() found = peripheral.find("monitor", function(name, wrapped) foundName = name return true end) end) if found then nextMonitor = found nextMonitorSide = foundName else nextMonitorSide = nil end end end if nextMonitor and nextMonitor ~= monitor then monitor = nextMonitor monitorSide = nextMonitorSide pcall(function() handshakeMonitor(nextMonitor) end) elseif nextMonitor then monitor = nextMonitor monitorSide = nextMonitorSide end if monitor then if not safeCallMonitor(monitor, "setTextScale", 0.5) then pcall(function() handshakeMonitor(monitor) end) end termDev = monitor else termDev = nativeTerm or term end return termDev end local function clear() pcall(refreshDisplayTarget) if not termDev then return end safeCallTerm("setBackgroundColor", colors.black) safeCallTerm("setTextColor", colors.white) safeCallTerm("clear") safeCallTerm("setCursorPos", 1, 1) end local function centerText(y, text, color) if not termDev then return end local w = safeCallTerm("getSize") w = tonumber(w) or 0 if w < 2 then return end safeCallTerm("setTextColor", color or colors.white) local x = math.max(1, math.floor((w - #tostring(text or "")) / 2) + 1) safeCallTerm("setCursorPos", x, y) safeCallTerm("write", tostring(text or "")) end local lastStartupRedrawAt = 0 local function drawServerStatusIndicator(w) w = tonumber(w) or 0 if w < 2 then return end local col = colors.yellow if serverConnected == true then col = colors.lime elseif serverConnected == false then col = colors.red end safeCallTerm("setBackgroundColor", colors.black) safeCallTerm("setTextColor", col) safeCallTerm("setCursorPos", w - 1, 1) safeCallTerm("write", "S") safeCallTerm("setCursorPos", w, 1) safeCallTerm("write", "*") safeCallTerm("setTextColor", colors.white) end local lastUpdateError = nil local function drawVersionIndicator(w) w = tonumber(w) or 0 local s = tostring(VERSION or "") if #s == 0 or w < #s then return end local markerColor = colors.lime local markerText = "* " if isUpdating then markerColor = colors.yellow markerText = "^ing" elseif lastUpdateError then markerColor = colors.red markerText = "* " end safeCallTerm("setBackgroundColor", colors.black) safeCallTerm("setTextColor", colors.gray) safeCallTerm("setCursorPos", 1, 1) safeCallTerm("write", s) safeCallTerm("setTextColor", markerColor) safeCallTerm("write", markerText) if lastUpdateError then safeCallTerm("setCursorPos", 1, 2) safeCallTerm("setTextColor", colors.red) safeCallTerm("write", string.sub(tostring(lastUpdateError), 1, w)) end safeCallTerm("setTextColor", colors.white) end local _cachedDrawArgHash = nil local function splitDisplayLines(s) if s == nil then return {} end local str = tostring(s) if #trim(str) == 0 then return {} end local lines = {} for line in str:gmatch("[^\n]+") do table.insert(lines, line) end if #lines == 0 then table.insert(lines, str) end return lines end local function draw(statusLine1, statusLine2, statusColor) clear() if not termDev then return end local w, h = safeCallTerm("getSize") w = tonumber(w) or 0 h = tonumber(h) or 0 if w < 2 or h < 2 then return end centerText(2, "GATE", colors.cyan) drawVersionIndicator(w) drawServerStatusIndicator(w) local lines = {} for _, line in ipairs(splitDisplayLines(statusLine1)) do table.insert(lines, line) end for _, line in ipairs(splitDisplayLines(statusLine2)) do table.insert(lines, line) end if #lines > 0 then local maxBodyY = math.max(3, h - 1) local startY = math.max(3, math.floor((maxBodyY - #lines) / 2) + 1) for idx, line in ipairs(lines) do local y = startY + idx - 1 if y > maxBodyY then break end centerText(y, line, statusColor or colors.white) end end safeCallTerm("setCursorPos", 1, h) safeCallTerm("setTextColor", colors.gray) safeCallTerm("write", string.rep(" ", w)) end local function pulseLeftRedstone(seconds) seconds = tonumber(seconds) or 1 if not redstone or type(redstone.setOutput) ~= "function" then return end pcall(redstone.setOutput, "left", true) os.sleep(seconds) pcall(redstone.setOutput, "left", false) end pcall(function() if redstone and type(redstone.setOutput) == "function" then redstone.setOutput("left", false) end end) local function readApiEndpointFile(path) local s = trim(readFile(path) or "") if #s == 0 then return nil end return s end local function resolveServerURL(cfg) if type(cfg.server_url) == "string" and #trim(cfg.server_url) > 0 then local u = trim(cfg.server_url) u = u:gsub("/api/tickets/status%s*$", "/api/tickets/check") return u end local base = readApiEndpointFile("API_ENDPOINT_GATE.txt") or readApiEndpointFile("API_ENDPOINT.txt") if base and base:match("/api$") then base = base:sub(1, -5) end if base and #base > 0 then return base .. DEFAULT_SERVER_PATH end return DEFAULT_SERVER_BASE .. DEFAULT_SERVER_PATH end local function guessBaseFromStatusURL(url) url = trim(url or "") if #url == 0 then return DEFAULT_SERVER_BASE end local b = url:gsub("/api/tickets/check.*$", "") b = b:gsub("/api/.*$", "") b = trim(b) if #b == 0 then return DEFAULT_SERVER_BASE end return b end local function httpRequest(method, url, body, headers) if not http then setServerConnected(false) return false, "HTTP API disabled" end headers = headers or {} local okReq, err = pcall(function() http.request({ url = url, method = method, headers = headers, body = body, }) end) if not okReq then setServerConnected(false) return false, tostring(err) end while true do local ev, p1, p2, p3 = os.pullEvent() if ev == "http_success" and p1 == url then local res = p2 if type(res) == "table" and type(res.readAll) == "function" then local data = res.readAll() res.close() setServerConnected(true) return true, data end setServerConnected(false) return false, "invalid http response" end if ev == "http_failure" and p1 == url then local err = p2 local res = p3 if type(p2) == "table" and type(p2.readAll) == "function" then res = p2 err = p3 end if type(res) == "table" and type(res.readAll) == "function" then local data = res.readAll() res.close() setServerConnected(false) return false, data end setServerConnected(false) return false, tostring(err or "http_failure") end os.queueEvent(ev, p1, p2, p3) os.sleep(0) end end local function postCheck(url, payload) local okBody, body = pcall(textutils.serializeJSON, payload) if not okBody then return false end local ok, data = httpRequest("POST", url, body, { ["Content-Type"] = "application/json" }) if not ok then return false, data end local okJ, parsed = pcall(textutils.unserializeJSON, data or "") if not okJ then return false, data end return true, parsed end local function getJSON(url) local ok, data = httpRequest("GET", url) if not ok then return false, data end local okJ, parsed = pcall(textutils.unserializeJSON, data or "") if not okJ then return false, data end return true, parsed end local function resolveCardServerURL(cfg, ticketCheckURL) if type(cfg.card_server_url) == "string" and #trim(cfg.card_server_url) > 0 then return trim(cfg.card_server_url) end local base = guessBaseFromStatusURL(ticketCheckURL) return base:gsub("/+$", "") .. "/api/cards/check" end local function resolveCardSyncBaseURL(cfg, ticketCheckURL, cardCheckURL) if type(cfg.card_sync_url) == "string" and #trim(cfg.card_sync_url) > 0 then local raw = trim(cfg.card_sync_url) return raw:gsub("/+$", "") end local base = guessBaseFromStatusURL(cardCheckURL or ticketCheckURL) return base:gsub("/+$", "") .. "/api/ic-cards" end local function resolveFareQueryURL(ticketCheckURL) local base = guessBaseFromStatusURL(ticketCheckURL) return base:gsub("/+$", "") .. "/api/public/fares/query" end local function urlEncodeComponent(value) local s = tostring(value or "") return (s:gsub("([^%w%-_%.~])", function(c) return string.format("%%%02X", string.byte(c)) end)) end local function toMoney(v) local n = tonumber(v) if n == nil then return nil end return math.floor(n * 100 + 0.5) / 100 end local stationNameToCode = {} local function normKey(s) return trim(s):lower() end local function refreshStationNameMap(serverBase) serverBase = trim(serverBase or "") if #serverBase == 0 then return false end local url = serverBase:gsub("/+$", "") .. "/api/stations" local ok, data = httpRequest("GET", url) if not ok then return false end local okJ, parsed = pcall(textutils.unserializeJSON, data or "") if not okJ then return false end if type(parsed) == "table" and type(parsed.stations) == "table" then parsed = parsed.stations end if type(parsed) ~= "table" then return false end stationNameToCode = {} for _, st in ipairs(parsed) do if type(st) == "table" then local code = trim(st.code) if #code > 0 then local en = trim(st.en_name or st.en) if #en > 0 then stationNameToCode[normKey(en)] = code end local cn = trim(st.name) if #cn > 0 then stationNameToCode[normKey(cn)] = code end end end end return true end local function refreshRemoteLuaVersion(serverBase) serverBase = trim(serverBase or "") if #serverBase == 0 then return false end local url = serverBase:gsub("/+$", "") .. "/api/config?_t=" .. tostring(nowMs() + math.random(1, 999999)) local ok, parsed = getJSON(url) if not ok or type(parsed) ~= "table" then return false end _G.CFG = parsed local remote = normalizeVersionTag(type(parsed.lua_versions) == "table" and parsed.lua_versions.gate or nil) if #remote == 0 then versionMismatch = nil return true end versionMismatch = (remote ~= normalizeVersionTag(VERSION)) return true end local function inferStationCodeFromName(name) local key = normKey(name or "") if #key == 0 then return "" end return stationNameToCode[key] or "" end local function playDfpwm(path) if not speaker then return end if not fs.exists(path) then return end local okD, dfpwm = pcall(require, "cc.audio.dfpwm") if not okD or not dfpwm then return end local h = fs.open(path, "rb") if not h then return end local decoder = dfpwm.make_decoder() while true do local chunk = h.read(16 * 1024) if not chunk then break end local buf = decoder(chunk) while not speaker.playAudio(buf) do os.pullEvent("speaker_audio_empty") end end h.close() end local function normalizeTicketId(v) v = tostring(v or "") v = v:gsub("^%s+", ""):gsub("%s+$", "") v = v:gsub("%s+", "") if #v == 0 then return nil end local prefix, num = v:match("^([A-Za-z][A-Za-z])%-?([0-9]+)$") if prefix and num then prefix = prefix:upper() if #num < 8 then num = string.rep("0", 8 - #num) .. num elseif #num > 8 then num = num:sub(-8) end return prefix .. "-" .. num end return v:lower() end local function normalizeIcCardId(v) local s = tostring(v or "") s = s:gsub("%s+", ""):upper() if #s == 0 then return "" end local num = s:match("^IC%-?([0-9]+)$") if num then if #num < 6 then num = string.rep("0", 6 - #num) .. num elseif #num > 6 then num = num:sub(-6) end return "IC-" .. num end return s end local function collectScanTables(scan, includeTicket) local out = {} local seen = {} local function add(v) if type(v) ~= "table" or seen[v] then return end seen[v] = true table.insert(out, v) end if type(scan) ~= "table" then return out end add(scan) add(scan.data) add(scan.payload) add(scan.card) add(scan.ic_card) add(scan.wallet) add(scan.card_data) add(scan.media_data) if includeTicket then add(scan.ticket) add(scan.ticket_data) end return out end local function firstNonEmptyFromTables(tables, keys) if type(tables) ~= "table" or type(keys) ~= "table" then return "" end for _, t in ipairs(tables) do for _, key in ipairs(keys) do local v = t[key] if v ~= nil then local s = trim(v) if #s > 0 then return s end end end end return "" end local function firstNumberFromTables(tables, keys) if type(tables) ~= "table" or type(keys) ~= "table" then return nil end for _, t in ipairs(tables) do for _, key in ipairs(keys) do local n = tonumber(t[key]) if n ~= nil then return n end end end return nil end local function isTruthy(v) if v == true then return true end if type(v) == "number" then return v ~= 0 end if type(v) == "string" then local s = v:lower() return s == "true" or s == "1" or s == "yes" end return false end local function firstTruthyFromTables(tables, keys) if type(tables) ~= "table" or type(keys) ~= "table" then return false end for _, t in ipairs(tables) do for _, key in ipairs(keys) do if t[key] ~= nil and isTruthy(t[key]) then return true end end end return false end local function getTicketId(scan) local tables = collectScanTables(scan, true) if #tables == 0 then return nil end local raw = firstNonEmptyFromTables(tables, { "ticketId", "ticket_id", "id", "ticketNo", "ticket_no", "code" }) if #raw == 0 then return nil end return normalizeTicketId(raw) end local function getCardId(scan) local tables = collectScanTables(scan, false) if #tables == 0 then return "" end local raw = firstNonEmptyFromTables(tables, { "card_id", "cardId", "ic_card_id", "icCardId", "wallet_id", "walletId", "card_uid", "cardUid", "uid", "uuid", "serial", "serial_number", "serialNumber", "nfc_uid", "nfcUid", "rfid_uid", "rfidUid" }) if #raw == 0 then return "" end return normalizeIcCardId(raw) end local function getCardBalance(scan) local tables = collectScanTables(scan, false) return firstNumberFromTables(tables, { "balance", "stored_value", "storedValue", "wallet_balance", "walletBalance", "remaining_balance", "remainingBalance", "value", "amount" }) end local function isICCardScan(scan) local tables = collectScanTables(scan, false) if #tables == 0 then return false end if #getCardId(scan) > 0 then return true end if getCardBalance(scan) ~= nil then return true end if type(scan.card) == "table" or type(scan.ic_card) == "table" or type(scan.wallet) == "table" then return true end local media = firstNonEmptyFromTables(tables, { "media", "media_type", "mediaType", "product_type", "productType", "ticket_type", "ticketType", "kind", "type", "category" }):lower() if media:find("card", 1, true) or media:find("wallet", 1, true) then return true end if media:find("ic", 1, true) or media:find("nfc", 1, true) or media:find("rfid", 1, true) then return true end return false end local function getStartStation(scan) local tables = collectScanTables(scan, true) local id = firstNonEmptyFromTables(tables, { "entry", "start_station", "startStation", "start", "start_station_id", "start_station_code", "from_station", "from", "startStationId", "start_stationId", "entry_station", "entryStation" }) if #id > 0 then return id end return inferStationCodeFromName(firstNonEmptyFromTables(tables, { "start_name_en", "startNameEn", "start_name", "fromNameCnU", "fromNameCn", "entry_name", "entryName" })) end local function getTerminalStation(scan) local tables = collectScanTables(scan, true) local id = firstNonEmptyFromTables(tables, { "exit", "terminal_station", "terminalStation", "terminal", "end_station", "endStation", "terminal_station_id", "terminal_station_code", "to_station", "to", "endStationId", "end_stationId", "exit_station", "exitStation" }) if #id > 0 then return id end return inferStationCodeFromName(firstNonEmptyFromTables(tables, { "terminal_name_en", "terminalNameEn", "terminal_name", "toNameCnU", "toNameCn", "exit_name", "exitName" })) end local function saveLastScan(scan) if type(scan) ~= "table" then return end local t = {} for k, v in pairs(scan) do t[k] = v end local startStation = getStartStation(t) if #startStation > 0 and (t.start_station == nil or trim(t.start_station) == "") then t.start_station = startStation end local terminalStation = getTerminalStation(t) if #terminalStation > 0 and (t.terminal_station == nil or trim(t.terminal_station) == "") then t.terminal_station = terminalStation end local ok, s = pcall(textutils.serializeJSON, t) if not ok or type(s) ~= "string" then ok, s = pcall(textutils.serialize, t) end if ok and type(s) == "string" then writeFile("last_scan.json", s) end end local cfg = loadConfig() local stationSet = stationSetFromList(cfg.station_codes) serverURL = resolveServerURL(cfg) local cardServerURL = resolveCardServerURL(cfg, serverURL) local cardSyncBaseURL = resolveCardSyncBaseURL(cfg, serverURL, cardServerURL) local fareQueryURL = resolveFareQueryURL(serverURL) local mode = (trim(cfg.mode):lower() == "exit") and "exit" or "entry" local modeBySide = nil if type(cfg.side_modes) == "table" then local tmp = {} for side, m in pairs(cfg.side_modes) do if type(side) == "string" then local s = trim(side):lower() if #s > 0 then tmp[s] = (trim(m):lower() == "exit") and "exit" or "entry" end end end if next(tmp) ~= nil then modeBySide = tmp end end local sideStationCodeBySide = nil if type(cfg.side_station_codes) == "table" then local tmp = {} for side, code in pairs(cfg.side_station_codes) do if type(side) == "string" then local s = trim(side):lower() local c = trim(code) if #s > 0 and #c > 0 then tmp[s] = c end end end if next(tmp) ~= nil then sideStationCodeBySide = tmp end end pcall(function() refreshStationNameMap(guessBaseFromStatusURL(serverURL)) end) pcall(function() refreshRemoteLuaVersion(guessBaseFromStatusURL(serverURL)) end) pcall(function() if modeBySide == nil then waitForPeripherals(true) else for side, _ in pairs(modeBySide) do local dev = resolveInspection(side) local retryCount = 0 while not dev and retryCount < PERIPHERAL_WAIT_MAX_RETRIES do sleep(PERIPHERAL_WAIT_INTERVAL) dev = resolveInspection(side) retryCount = retryCount + 1 end end if not monitor then waitForPeripherals(false) end end end) if not inspection and modeBySide == nil then local okDev = false if peripheral and type(peripheral.find) == "function" then pcall(function() inspection = peripheral.find("ticket_inspection_machine") if inspection then okDev = true end end) end if not okDev then draw("Missing peripheral:", "ticket_inspection_machine", colors.red) error("ticket_inspection_machine not found") end end if next(stationSet) == nil then draw("No station codes set.", "Run installer first.", colors.red) error("No station codes configured") end local stationCodesPayload = {} for k, _ in pairs(stationSet) do table.insert(stationCodesPayload, k) end table.sort(stationCodesPayload) local function trimSide(s) s = trim(s or ""):lower() if #s == 0 then return nil end return s end local function defaultStationCode() local direct = trim(cfg.station_code or "") if #direct > 0 then return direct end return trim(stationCodesPayload[1] or "") end local function stationCodeForSide(side) side = trimSide(side) if side and type(sideStationCodeBySide) == "table" then local v = trim(sideStationCodeBySide[side] or "") if #v > 0 then return v end end return defaultStationCode() end local function displayStationCode(code) local s = trim(code) if #s == 0 then return s end if s:sub(1, 2):upper() == "ST" then s = s:sub(3):gsub("^[%s%-_]+", "") end return s end local function isInspectionPeripheral(p) return type(p) == "table" and ( type(p.getLastScanned) == "function" or type(p.updateICCard) == "function" or type(p.updateTicket) == "function" or type(p.destroyTicket) == "function" ) end local function resolveInspection(side) side = trimSide(side) if side and peripheral and type(peripheral.wrap) == "function" then local okW, p = pcall(peripheral.wrap, side) if okW and isInspectionPeripheral(p) then return p end return nil end if isInspectionPeripheral(inspection) then return inspection end return nil end local function validateBidirectional() if not modeBySide then return true end for side, _ in pairs(modeBySide) do if not resolveInspection(side) then draw("Missing peripheral:", "ticket_inspection_machine@" .. tostring(side), colors.red) error("ticket_inspection_machine not found on side: " .. tostring(side)) end end return true end validateBidirectional() local function inferSideFromScan(scan) if type(scan) ~= "table" then return nil end return trimSide( scan.side or scan.source_side or scan.reader_side or scan.peripheral_side or scan.peripheralSide or scan.device_side or scan.peripheral or scan.source or scan.reader or scan.name ) end local function isSideName(s) s = trimSide(s) if not s then return false end return s == "front" or s == "back" or s == "left" or s == "right" or s == "top" or s == "bottom" end local function parseTicketScannedArgsPacked(ev) local side = nil local scan = nil if type(ev) ~= "table" then return nil, nil end local n = tonumber(ev.n) or #ev for i = 2, n do local v = ev[i] if not scan and type(v) == "table" then scan = v elseif not side and type(v) == "string" and isSideName(v) then side = trimSide(v) end end if scan and not side then side = inferSideFromScan(scan) end return scan, side end local function actionForSide(side) if not modeBySide then return mode end side = trimSide(side) if not side then return mode end return modeBySide[side] or mode end local function shortModeLabel(v) v = trim(v):lower() if v == "exit" then return "OUT" end return "IN" end local function readyLine1() if not modeBySide then return "READY " .. shortModeLabel(mode) end return "F " .. shortModeLabel(modeBySide.front or "") .. " B " .. shortModeLabel(modeBySide.back or "") end local function readyLine2() local lines = {} local seen = {} local function addLine(line) line = trim(line) if #line == 0 then return end local key = line:lower() if seen[key] then return end seen[key] = true table.insert(lines, line) end if type(sideStationCodeBySide) == "table" and next(sideStationCodeBySide) ~= nil then local orderedSides = { "front", "back", "left", "right", "top", "bottom" } for _, side in ipairs(orderedSides) do local code = displayStationCode(sideStationCodeBySide[side] or "") if #code > 0 then addLine(string.upper(side:sub(1, 1)) .. " " .. code) end end end if #lines == 0 then for _, code in ipairs(stationCodesPayload) do addLine(displayStationCode(code)) end end if #lines == 0 then addLine(displayStationCode(stationCodeForSide(nil))) end return table.concat(lines, "\n") end local function drawReadyScreen() draw(readyLine1(), readyLine2(), colors.lime) end drawReadyScreen() local function collectInspectionDevices(side, modeBySideRef) local sideKnown = trimSide(side) ~= nil local inspectionDevs = {} local function addDev(dev) if not dev then return end table.insert(inspectionDevs, dev) end if sideKnown then addDev(resolveInspection(side)) elseif modeBySideRef then for s, _ in pairs(modeBySideRef) do addDev(resolveInspection(s)) end else addDev(resolveInspection(side)) end return inspectionDevs, sideKnown end local function collectInspectionBindings(side, modeBySideRef, fallbackInspection) local out = {} local seen = {} local function addBinding(sideName, dev) if not dev or seen[dev] then return end seen[dev] = true table.insert(out, { side = trimSide(sideName), dev = dev }) end side = trimSide(side) if side then addBinding(side, resolveInspection(side)) elseif modeBySideRef then for s, _ in pairs(modeBySideRef) do addBinding(s, resolveInspection(s)) end else addBinding(nil, fallbackInspection or resolveInspection(nil)) end return out end local function updateDeviceField(dev, key, value) if type(dev) ~= "table" or type(dev.updateTicket) ~= "function" then return end pcall(dev.updateTicket, key, value) end local function callTicketStateMethod(dev, methodName, ...) if type(dev) ~= "table" then return false end local fn = dev[methodName] if type(fn) ~= "function" then return false end local okCall, okRes = pcall(fn, ...) if not okCall then return false end return okRes ~= false end local function applyTicketDeviceState(dev, action, rides, stationCode) if type(dev) ~= "table" then return end if action == "entry" then if not callTicketStateMethod(dev, "markEntered", stationCode) then updateDeviceField(dev, "entered", true) updateDeviceField(dev, "exited", false) end elseif action == "exit" then if not callTicketStateMethod(dev, "markExited") then updateDeviceField(dev, "exited", true) updateDeviceField(dev, "entered", false) end elseif action == "reset" then if not callTicketStateMethod(dev, "resetTicketState") then updateDeviceField(dev, "entered", false) updateDeviceField(dev, "exited", false) end end if rides ~= nil then updateDeviceField(dev, "rides", rides) end end local function updateICCardField(dev, key, value) if type(dev) ~= "table" then return false end if type(dev.updateICCard) ~= "function" then return true end local okCall, okRes, detail = pcall(dev.updateICCard, key, value) if not okCall then return false, tostring(okRes) end if okRes == false then return false, tostring(detail or "update_failed") end return true end local function updateICCardFieldWithFallback(dev, keys, value) if type(keys) == "string" then return updateICCardField(dev, keys, value) end local firstErr = nil if type(keys) ~= "table" then return false, "invalid_keys" end for _, key in ipairs(keys) do local okField, errField = updateICCardField(dev, key, value) if okField then return true, key end if not firstErr then firstErr = tostring(key) .. ": " .. tostring(errField or "update_failed") end end return false, firstErr or "update_failed" end local function updateICCardFields(dev, patch) local allOk = true local firstErr = nil if type(patch) ~= "table" then return false end for key, value in pairs(patch) do local okField, errField = updateICCardField(dev, key, value) if not okField then allOk = false if not firstErr then firstErr = tostring(key) .. ": " .. tostring(errField or "update_failed") end end end return allOk, firstErr end local function readLastScanned(dev) if type(dev) ~= "table" or type(dev.getLastScanned) ~= "function" then return nil end local ok, scan = pcall(dev.getLastScanned) if not ok or type(scan) ~= "table" then return nil end return scan end local function getCardEntry(scan) return firstNonEmptyFromTables(collectScanTables(scan, false), { "entry", "entry_station", "entryStation", "start_station", "startStation" }) end local function getCardEntered(scan) if #getCardEntry(scan) > 0 then return true end return firstTruthyFromTables(collectScanTables(scan, false), { "entered", "is_entered", "in_station", "inside_station" }) end local function getCardExited(scan) if #getCardEntry(scan) > 0 then return false end return firstTruthyFromTables(collectScanTables(scan, false), { "exited", "is_exited", "out_station", "outside_station" }) end local function getCardOwnerName(scan) return firstNonEmptyFromTables(collectScanTables(scan, false), { "ownerName", "owner_name", "holder_name", "card_holder", "passenger" }) end local function queryFare(fromStation, toStation) fromStation = trim(fromStation) toStation = trim(toStation) if #fromStation == 0 or #toStation == 0 then return nil, "missing_station" end local url = fareQueryURL .. "?from=" .. urlEncodeComponent(fromStation) .. "&to=" .. urlEncodeComponent(toStation) local ok, resp = getJSON(url) if not ok or type(resp) ~= "table" then return nil, "net_error" end local fare = tonumber( resp.discounted_regular_fare or resp.discounted_regular or resp.regular_fare or resp["优惠后常规票价"] or resp["常规票价"] or resp.regular ) if fare == nil then return nil, tostring(resp.error or resp["错误"] or "fare_not_found") end return toMoney(fare), nil end local function triggerFaultLights(devs) local targets = {} if type(devs) == "table" then for _, d in ipairs(devs) do table.insert(targets, d) end end if inspection and not targets[1] then table.insert(targets, inspection) end if modeBySide then for side, _ in pairs(modeBySide) do local d = resolveInspection(side) if d then local found = false for _, t in ipairs(targets) do if t == d then found = true; break end end if not found then table.insert(targets, d) end end end end for _, dev in ipairs(targets) do if type(dev) == "table" and type(dev.markFault) == "function" then pcall(dev.markFault) end end end local function denyCard(reason, detail, inspectionDevs) draw("DENIED", tostring(detail or reason or "deny"), colors.red) parallel.waitForAll( function() playDfpwm("error.dfpwm") end, function() triggerFaultLights(inspectionDevs) end ) end local function deductICCardBalance(dev, amount) if type(dev) ~= "table" or type(dev.deductICCard) ~= "function" then return false, "unsupported_method" end local okCall, okRes, detail = pcall(dev.deductICCard, amount) if not okCall then return false, tostring(okRes) end if okRes == true then return true, tonumber(detail) end return false, tostring(detail or "deduct_failed") end local function currentDeviceId() local label = os.getComputerLabel() if label and label ~= "" then return label end return "#" .. tostring(os.getComputerID()) end local function syncICCardState(cardId, payload) local id = trim(cardId) if #id == 0 then return false, "missing_card_id" end local url = cardSyncBaseURL .. "/" .. urlEncodeComponent(id) .. "/sync" payload = payload or {} payload.card_id = id return postCheck(url, payload) end local function handleICCardScan(scan, side, scanDev) saveLastScan(scan) local inspectionDevs = scanDev and { scanDev } or select(1, collectInspectionDevices(side, modeBySide, inspection)) local sideKnown = trimSide(side) ~= nil local action = actionForSide(side) if modeBySide and not sideKnown then if getCardEntered(scan) and not getCardExited(scan) then action = "exit" else action = "entry" end end local cardId = getCardId(scan) if #cardId == 0 then draw("Invalid card.", "Missing card_id.", colors.red) parallel.waitForAll( function() playDfpwm("error.dfpwm") end, function() triggerFaultLights(inspectionDevs) end ) return end local balance = toMoney(getCardBalance(scan) or 0) or 0 local entryStation = trim(getCardEntry(scan)) local exitStation = stationCodeForSide(side) local fare = 0 local usedAction = action if #exitStation == 0 then denyCard("missing_station", "Missing gate station", inspectionDevs) return end if usedAction == "entry" then if getCardEntered(scan) and not getCardExited(scan) then denyCard("already_entered", "Already entered", inspectionDevs) return end local okWrite = true local writeErr = nil for _, dev in ipairs(inspectionDevs) do local okPatch, errPatch = updateICCardFieldWithFallback(dev, { "entry_station", "entry", "start_station" }, exitStation) okWrite = okPatch and okWrite if not okPatch and not writeErr then writeErr = errPatch end end if not okWrite then draw("WRITE ERROR", tostring(writeErr or "Failed to update card"), colors.red) parallel.waitForAll( function() playDfpwm("error.dfpwm") end, function() triggerFaultLights(inspectionDevs) end ) return end entryStation = exitStation else if not getCardEntered(scan) then denyCard("not_entered", "Not entered", inspectionDevs) return end if getCardExited(scan) then denyCard("already_exited", "Already exited", inspectionDevs) return end if #entryStation == 0 then denyCard("missing_entry_station", "Missing entry station", inspectionDevs) return end local fareValue, fareErr = queryFare(entryStation, exitStation) if fareValue == nil then if fareErr == "net_error" then draw("NET ERROR", "Fare lookup failed", colors.red) parallel.waitForAll( function() playDfpwm("error.dfpwm") end, function() triggerFaultLights(inspectionDevs) end ) else denyCard("fare_not_found", fareErr, inspectionDevs) end return end fare = fareValue if balance < fare then denyCard("insufficient_balance", "Fare: " .. tostring(fare) .. " Bal: " .. tostring(balance), inspectionDevs) return end local okWrite = true local writeErr = nil for _, dev in ipairs(inspectionDevs) do local okDeduct, newBalanceOrErr = deductICCardBalance(dev, fare) if okDeduct then balance = toMoney(newBalanceOrErr or (balance - fare)) or 0 local okClear, clearErr = updateICCardFieldWithFallback(dev, { "entry_station", "entry", "start_station" }, nil) local okFare, fareErr = updateICCardField(dev, "last_fare", fare) if not okClear or not okFare then okWrite = false if not okClear and not writeErr then writeErr = "entry: " .. tostring(clearErr or "update_failed") end if not okFare and not writeErr then writeErr = "last_fare: " .. tostring(fareErr or "update_failed") end end else okWrite = false if tostring(newBalanceOrErr) == "insufficient" then denyCard("insufficient_balance", "-" .. tostring(fare) .. " Left: " .. tostring(balance), inspectionDevs) else draw("WRITE ERROR", tostring(newBalanceOrErr or "deduct_failed"), colors.red) parallel.waitForAll( function() playDfpwm("error.dfpwm") end, function() triggerFaultLights(inspectionDevs) end ) end break end end if not okWrite then if writeErr then draw("WRITE ERROR", tostring(writeErr), colors.red) parallel.waitForAll( function() playDfpwm("error.dfpwm") end, function() triggerFaultLights(inspectionDevs) end ) end return end end for _, dev in ipairs(inspectionDevs) do applyTicketDeviceState(dev, usedAction, nil, exitStation) if usedAction == "entry" then if #entryStation > 0 then updateDeviceField(dev, "entry_station", entryStation) end else if #exitStation > 0 then updateDeviceField(dev, "exit_station", exitStation) end updateDeviceField(dev, "last_fare", fare) end if balance ~= nil then updateDeviceField(dev, "balance", balance) end end local syncTs = (os.epoch and os.epoch("utc")) or (os.time() * 1000) local okSync, syncResp = syncICCardState(cardId, { type = "check", action = usedAction, device = currentDeviceId(), ts = syncTs, station_code = exitStation, entry_station = entryStation, exit_station = usedAction == "exit" and exitStation or "", entered = (usedAction == "entry"), exited = (usedAction == "exit"), fare = fare, last_fare = fare, balance = balance, result = "pass" }) if okSync and type(syncResp) == "table" and type(syncResp.card) == "table" then balance = toMoney(syncResp.card.balance or balance) or balance end local line2 = nil if usedAction == "exit" then line2 = "-" .. tostring(fare) .. " Left: " .. tostring(balance or "?") else line2 = "Left: " .. tostring(balance or "?") end draw("PASS", line2, colors.lime) parallel.waitForAll( function() pulseLeftRedstone(GATE_OPEN_SECONDS) end, function() playDfpwm("pass.dfpwm") end ) end local function handleScan(scan, side, scanDev) saveLastScan(scan) local inspectionDevs = scanDev and { scanDev } or select(1, collectInspectionDevices(side, modeBySide, inspection)) local sideKnown = trimSide(side) ~= nil local action = actionForSide(side) if modeBySide and not sideKnown then if isTruthy(scan and scan.entered) and not isTruthy(scan and scan.exited) then action = "exit" else action = "entry" end end local ticketId = getTicketId(scan) if not ticketId then draw("Invalid ticket.", "Missing ticketId.", colors.red) parallel.waitForAll( function() playDfpwm("error.dfpwm") end, function() triggerFaultLights(inspectionDevs) end ) return end local hintTripsTotal = tonumber(scan.trips_total or scan.rides_total or scan.trips or scan.rides) local hintTripsRemaining = tonumber(scan.trips_remaining or scan.rides_remaining) local function doCheck(act) return postCheck(serverURL, { ticket_id = ticketId, action = act, station_codes = stationCodesPayload, station_code = stationCodeForSide(side), device = currentDeviceId(), ts = os.epoch("utc"), trips_total = hintTripsTotal, trips_remaining = hintTripsRemaining, }) end local ok, resp = doCheck(action) local usedAction = action if ok and type(resp) == "table" and resp.result ~= "pass" and tostring(resp.reason) == "wrong_station" and modeBySide and not sideKnown then local alt = (action == "entry") and "exit" or "entry" local ok2, resp2 = doCheck(alt) if ok2 and type(resp2) == "table" then if resp2.result == "pass" then ok, resp = ok2, resp2 usedAction = alt elseif tostring(resp2.reason) ~= "wrong_station" then ok, resp = ok2, resp2 usedAction = alt end end end if not ok or type(resp) ~= "table" then draw("NET ERROR", "Server check failed.", colors.red) parallel.waitForAll( function() playDfpwm("error.dfpwm") end, function() triggerFaultLights(inspectionDevs) end ) return end if resp.result ~= "pass" then draw("DENIED", tostring(resp.reason or "deny"), colors.red) parallel.waitForAll( function() playDfpwm("error.dfpwm") end, function() triggerFaultLights(inspectionDevs) end ) return end pcall(function() if #inspectionDevs == 0 then return end local newRides = tonumber(resp.trips_remaining) or tonumber(scan.trips_remaining or scan.rides_remaining) or tonumber(scan.rides) for _, dev in ipairs(inspectionDevs) do applyTicketDeviceState(dev, usedAction, newRides, stationCodeForSide(side)) end end) local remaining = tonumber(resp.trips_remaining) if usedAction == "exit" and isTruthy(resp.destroy_ticket) and remaining ~= nil and remaining <= 0 then for _, dev in ipairs(inspectionDevs) do applyTicketDeviceState(dev, "reset", remaining) if type(dev) == "table" and type(dev.destroyTicket) == "function" then pcall(dev.destroyTicket) end end end local msg = (usedAction == "exit") and ("Rides left: " .. tostring(resp.trips_remaining or "")) or "Welcome." draw("PASS", msg, colors.lime) parallel.waitForAll( function() pulseLeftRedstone(GATE_OPEN_SECONDS) end, function() playDfpwm("pass.dfpwm") end ) end local recentScans = {} local function buildScanKey(scan, side, eventName) side = trimSide(side) or "-" if eventName == "ic_card_scanned" or isICCardScan(scan) then return table.concat({ side, "ic", getCardId(scan), tostring(getCardBalance(scan) or ""), getCardEntry(scan), getCardOwnerName(scan), }, "|") end return table.concat({ side, "ticket", tostring(getTicketId(scan) or ""), tostring(scan.timestamp or scan.order_datetime or ""), tostring(scan.rides or scan.trips or ""), tostring(scan.entered or ""), tostring(scan.exited or ""), }, "|") end local function shouldProcessScan(scan, side, eventName) local key = buildScanKey(scan, side, eventName) local now = os.epoch("utc") local prev = recentScans[key] recentScans[key] = now if prev and (now - prev) < 500 then return false end return true end local function matchesEventType(scan, eventName) if eventName == "ic_card_scanned" then return isICCardScan(scan) end if eventName == "ticket_scanned" then return not isICCardScan(scan) end return true end local function processInspectionEvent(eventName, ev) local payloadScan, payloadSide = parseTicketScannedArgsPacked(ev) local bindings = collectInspectionBindings(payloadSide, modeBySide, inspection) local handled = false for _, binding in ipairs(bindings) do local scan = readLastScanned(binding.dev) if type(scan) == "table" and matchesEventType(scan, eventName) and shouldProcessScan(scan, binding.side, eventName) then if eventName == "ic_card_scanned" or isICCardScan(scan) then handleICCardScan(scan, binding.side, binding.dev) else handleScan(scan, binding.side, binding.dev) end handled = true end end if not handled and type(payloadScan) == "table" and shouldProcessScan(payloadScan, payloadSide, eventName) then local payloadDev = nil if payloadSide then payloadDev = resolveInspection(payloadSide) elseif #bindings == 1 then payloadDev = bindings[1].dev elseif not modeBySide then payloadDev = inspection end if eventName == "ic_card_scanned" or isICCardScan(payloadScan) then handleICCardScan(payloadScan, payloadSide, payloadDev) else handleScan(payloadScan, payloadSide, payloadDev) end end end local versionTimer = os.startTimer(VERSION_CHECK_INTERVAL) local function mainLoop() while true do local ev = pack(os.pullEvent()) if ev[1] == "ticket_scanned" or ev[1] == "ic_card_scanned" then processInspectionEvent(ev[1], ev) os.sleep(0.35) drawReadyScreen() elseif ev[1] == "peripheral" or ev[1] == "peripheral_detach" or ev[1] == "monitor_resize" or ev[1] == "config_updated" then pcall(drawReadyScreen) elseif ev[1] == "timer" then pcall(drawReadyScreen) end end end local function startupRedrawTask() local startTs = (os.epoch and os.epoch("utc")) or (os.time() * 3600000) local endTs = startTs + STARTUP_REDRAW_DURATION * 1000 local nextRedraw = startTs while true do local now = (os.epoch and os.epoch("utc")) or (os.time() * 3600000) if now <= endTs then pcall(drawReadyScreen) sleep(STARTUP_REDRAW_INTERVAL) else sleep(60) end end end local function checkAndApplyUpdate() local serverBase = guessBaseFromStatusURL(serverURL) local txt = fetchHTTP(serverBase .. '/api/check-update?_t=' .. tostring(os.epoch and os.epoch("utc") or math.random(1, 999999))) if not txt then return end local data = jsonDecode(txt) if not data or not data.update_counter then return end local remoteTs = tonumber(data.update_counter) if not remoteTs or remoteTs <= 0 then return end local attemptFile = ".last_update_ts2" if fs.exists(attemptFile) then local f = fs.open(attemptFile, "r") local tried = tonumber(f and f.readAll() or "0") or 0 if f then f.close() end if tried >= remoteTs then return end end isUpdating = true pcall(drawReadyScreen) sleep(0.5) local f = fs.open(attemptFile, "w") if f then f.write(tostring(remoteTs)); f.close() end local env = setmetatable({ AUTO_UPDATE_SILENT = true }, { __index = _G or getfenv() or _ENV }) local fn, err = loadfile("update_gate.lua", env) if fn then local ok, res = pcall(fn, "--silent") if not ok then lastUpdateError = string.sub(tostring(res), 1, 30) if term and term.setTextColor then term.setTextColor(colors.red) print("OTA Update Error: " .. tostring(res)) term.setTextColor(colors.white) end sleep(3) end else lastUpdateError = "loadfile failed: " .. tostring(err) if term and term.setTextColor then term.setTextColor(colors.red) print("OTA Update Error: " .. tostring(err)) term.setTextColor(colors.white) end sleep(3) end isUpdating = false pcall(drawReadyScreen) if not lastUpdateError then os.reboot() end end local function backgroundSyncTask() pcall(function() refreshRemoteLuaVersion(guessBaseFromStatusURL(serverURL)) end) while true do sleep(VERSION_CHECK_INTERVAL) pcall(function() refreshRemoteLuaVersion(guessBaseFromStatusURL(serverURL)) end) pcall(checkAndApplyUpdate) end end parallel.waitForAny(mainLoop, startupRedrawTask, backgroundSyncTask)