Files
Henry_Du 07e4200c17 feat(web,backend,lua,installer): 新增Lua脚本版本管理功能及相关优化
- 升级售票机、检票机内置Lua脚本版本至v1.5.8
- 新增后端配置的lua_versions字段,统一管理售票机、检票机的Lua脚本版本
- 前端新增版本管理配置页面,支持版本号配置和一键补丁升级
- 为售票机、检票机添加远程版本检测功能,屏幕显示版本匹配状态标记
- 简化installer配置交互流程,优化站点代码输入方式
- 重构后端配置规范化处理逻辑,统一配置初始化与存储流程
- 优化售票机外设检测、支付检测逻辑,修复部分已知问题
2026-06-28 16:30:17 +08:00

1359 lines
38 KiB
Lua

local DEFAULT_SERVER_BASE = "http://ticket.fse-media.group"
local DEFAULT_SERVER_PATH = "/api/tickets/check"
local GATE_OPEN_SECONDS = 2
local VERSION = "v1.5.8"
local VERSION_CHECK_INTERVAL = 60
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 monitor = peripheral.find("monitor")
local speaker = peripheral.find("speaker")
local inspection = peripheral.find("ticket_inspection_machine")
local serverConnected = nil
local serverLastChangeTs = 0
local expectedGateVersion = nil
local versionMismatch = nil
local function setServerConnected(ok)
if serverConnected == ok then return end
serverConnected = ok
serverLastChangeTs = os.epoch("utc")
end
local termDev = term
if monitor then
pcall(monitor.setTextScale, 0.5)
termDev = monitor
end
local function clear()
termDev.setBackgroundColor(colors.black)
termDev.setTextColor(colors.white)
termDev.clear()
termDev.setCursorPos(1, 1)
end
local function centerText(y, text, color)
local w = termDev.getSize()
termDev.setTextColor(color or colors.white)
local x = math.max(1, math.floor((w - #text) / 2) + 1)
termDev.setCursorPos(x, y)
termDev.write(text)
end
local function drawServerStatusIndicator(w)
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
termDev.setBackgroundColor(colors.black)
termDev.setTextColor(col)
termDev.setCursorPos(w - 1, 1)
termDev.write("S")
termDev.setCursorPos(w, 1)
termDev.write("*")
termDev.setTextColor(colors.white)
end
local function drawVersionIndicator(w)
local s = tostring(VERSION or "")
if #s == 0 then return end
if w < #s then return end
local markerColor = colors.yellow
if versionMismatch == true then
markerColor = colors.red
elseif versionMismatch == false then
markerColor = colors.lime
end
termDev.setBackgroundColor(colors.black)
termDev.setTextColor(colors.gray)
termDev.setCursorPos(1, 1)
termDev.write(s)
if w >= (#s + 1) then
termDev.setTextColor(markerColor)
termDev.write("*")
end
termDev.setTextColor(colors.white)
end
local function draw(statusLine1, statusLine2, statusColor)
clear()
local w, h = termDev.getSize()
centerText(1, "GATE", colors.cyan)
drawVersionIndicator(w)
drawServerStatusIndicator(w)
if statusLine1 and #statusLine1 > 0 then
centerText(math.max(2, math.floor(h / 2)), statusLine1, statusColor or colors.white)
end
if statusLine2 and #statusLine2 > 0 then
centerText(math.min(h, math.max(3, math.floor(h / 2) + 1)), statusLine2, statusColor or colors.white)
end
termDev.setCursorPos(1, h)
termDev.setTextColor(colors.gray)
termDev.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/public/config"
local ok, parsed = getJSON(url)
if not ok or type(parsed) ~= "table" then return false end
local remote = normalizeVersionTag(type(parsed.lua_versions) == "table" and parsed.lua_versions.gate or nil)
if #remote == 0 then
expectedGateVersion = nil
versionMismatch = nil
return true
end
expectedGateVersion = remote
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)
local 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)
if not inspection then
if modeBySide == nil 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 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()
if not modeBySide then
return "ST " .. stationCodeForSide(nil)
end
local frontCode = stationCodeForSide("front")
local backCode = stationCodeForSide("back")
if frontCode == backCode then
return "ST " .. frontCode
end
return "F " .. frontCode .. " B " .. backCode
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 updateICCardField(dev, key, value)
if type(dev) ~= "table" or type(dev.updateICCard) ~= "function" then return false 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 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 denyCard(reason, detail)
draw("DENIED", tostring(detail or reason or "deny"), colors.red)
playDfpwm("error.dfpwm")
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 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)
playDfpwm("error.dfpwm")
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")
return
end
if usedAction == "entry" then
if getCardEntered(scan) and not getCardExited(scan) then
denyCard("already_entered", "Already entered")
return
end
local okWrite = true
local writeErr = nil
for _, dev in ipairs(inspectionDevs) do
local okPatch, errPatch = updateICCardFields(dev, {
entry = 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)
playDfpwm("error.dfpwm")
return
end
entryStation = exitStation
else
if not getCardEntered(scan) then
denyCard("not_entered", "Not entered")
return
end
if getCardExited(scan) then
denyCard("already_exited", "Already exited")
return
end
if #entryStation == 0 then
denyCard("missing_entry_station", "Missing entry station")
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)
playDfpwm("error.dfpwm")
else
denyCard("fare_not_found", fareErr)
end
return
end
fare = fareValue
if balance < fare then
denyCard("insufficient_balance", "Fare: " .. tostring(fare) .. " Bal: " .. tostring(balance))
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 = updateICCardField(dev, "entry", 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))
else
draw("WRITE ERROR", tostring(newBalanceOrErr or "deduct_failed"), colors.red)
playDfpwm("error.dfpwm")
end
break
end
end
if not okWrite then
if writeErr then
draw("WRITE ERROR", tostring(writeErr), colors.red)
playDfpwm("error.dfpwm")
end
return
end
end
for _, dev in ipairs(inspectionDevs) do
if usedAction == "entry" then
updateDeviceField(dev, "entered", true)
updateDeviceField(dev, "exited", false)
if #entryStation > 0 then updateDeviceField(dev, "entry_station", entryStation) end
else
updateDeviceField(dev, "exited", true)
updateDeviceField(dev, "entered", false)
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 = "gate",
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)
playDfpwm("error.dfpwm")
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 = "gate",
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)
playDfpwm("error.dfpwm")
return
end
if resp.result ~= "pass" then
draw("DENIED", tostring(resp.reason or "deny"), colors.red)
playDfpwm("error.dfpwm")
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
if type(dev) == "table" and type(dev.updateTicket) == "function" then
if usedAction == "entry" then
dev.updateTicket("entered", true)
dev.updateTicket("exited", false)
if newRides ~= nil then dev.updateTicket("rides", newRides) end
else
dev.updateTicket("exited", true)
dev.updateTicket("entered", false)
if newRides ~= nil then dev.updateTicket("rides", newRides) end
end
end
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
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)
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] == "timer" and ev[2] == versionTimer then
pcall(function()
refreshRemoteLuaVersion(guessBaseFromStatusURL(serverURL))
end)
drawReadyScreen()
versionTimer = os.startTimer(VERSION_CHECK_INTERVAL)
end
end