Files
FSE-Ticket.sys/gate.lua
T
Henry_Du c52d4dac03 perf(gate): 移除显示器握手等待循环,直接设置参数后继续
- 版本号 v1.5.32 -> v1.5.33
- handshakeMonitor:改为单次设置 scale/颜色/清屏即返回,不再循环重试
- waitForPeripherals:移除 handshakeDone 等待分支与 'Waking monitor...' 提示
- 显示器一旦发现立即调用 handshakeMonitor 完成初始化,不做额外阻塞等待
- MONITOR_HANDSHAKE_RETRIES 置 0 作为占位常量
2026-08-15 22:55:17 +08:00

1858 lines
54 KiB
Lua

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.33"
local VERSION_CHECK_INTERVAL = 15
local PERIPHERAL_WAIT_INTERVAL = 0.2
local PERIPHERAL_WAIT_MAX_RETRIES = 20
local MONITOR_HANDSHAKE_RETRIES = 0
local STARTUP_REDRAW_DURATION = 3
local STARTUP_REDRAW_INTERVAL = 1
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
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
termDev = monitor
pcall(function() handshakeMonitor(monitor) end)
hadChange = 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 then return true end
local msg = nil
if not monitor then
msg = "Waiting for 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
safeCallMonitor(target, "setTextScale", 0.5)
safeCallMonitor(target, "setBackgroundColor", colors.black)
safeCallMonitor(target, "setTextColor", colors.white)
safeCallMonitor(target, "clear")
local w, h = safeCallTerm("getSize")
return (type(w) == "number" and type(h) == "number" and w >= 2 and h >= 2)
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()
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) < 200 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.1)
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 count = 0
while count < 3 do
sleep(0.5)
pcall(drawReadyScreen)
count = count + 1
end
while true do
sleep(600)
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)
pcall(function() refreshStationNameMap(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)