2026-06-21 10:37:25 +08:00
|
|
|
local DEFAULT_SERVER_BASE = "http://ticket.fse-media.group"
|
|
|
|
|
local DEFAULT_SERVER_PATH = "/api/tickets/check"
|
|
|
|
|
local GATE_OPEN_SECONDS = 2
|
2026-06-28 16:30:17 +08:00
|
|
|
local VERSION = "v1.5.8"
|
|
|
|
|
local VERSION_CHECK_INTERVAL = 60
|
2026-06-21 10:37:25 +08:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
2026-06-28 16:30:17 +08:00
|
|
|
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
|
|
|
|
|
|
2026-06-21 10:37:25 +08:00
|
|
|
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
|
2026-06-28 16:30:17 +08:00
|
|
|
local expectedGateVersion = nil
|
|
|
|
|
local versionMismatch = nil
|
2026-06-21 10:37:25 +08:00
|
|
|
|
|
|
|
|
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
|
2026-06-28 16:30:17 +08:00
|
|
|
local markerColor = colors.yellow
|
|
|
|
|
if versionMismatch == true then
|
|
|
|
|
markerColor = colors.red
|
|
|
|
|
elseif versionMismatch == false then
|
|
|
|
|
markerColor = colors.lime
|
|
|
|
|
end
|
2026-06-21 10:37:25 +08:00
|
|
|
termDev.setBackgroundColor(colors.black)
|
|
|
|
|
termDev.setTextColor(colors.gray)
|
|
|
|
|
termDev.setCursorPos(1, 1)
|
|
|
|
|
termDev.write(s)
|
2026-06-28 16:30:17 +08:00
|
|
|
if w >= (#s + 1) then
|
|
|
|
|
termDev.setTextColor(markerColor)
|
|
|
|
|
termDev.write("*")
|
|
|
|
|
end
|
2026-06-21 10:37:25 +08:00
|
|
|
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
|
|
|
|
|
|
2026-06-28 16:30:17 +08:00
|
|
|
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
|
|
|
|
|
|
2026-06-21 10:37:25 +08:00
|
|
|
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)
|
|
|
|
|
|
2026-06-28 16:30:17 +08:00
|
|
|
pcall(function()
|
|
|
|
|
refreshRemoteLuaVersion(guessBaseFromStatusURL(serverURL))
|
|
|
|
|
end)
|
|
|
|
|
|
2026-06-21 10:37:25 +08:00
|
|
|
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
|
|
|
|
|
|
2026-06-28 16:30:17 +08:00
|
|
|
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()
|
|
|
|
|
|
2026-06-21 10:37:25 +08:00
|
|
|
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
|
|
|
|
|
|
2026-06-28 16:30:17 +08:00
|
|
|
local versionTimer = os.startTimer(VERSION_CHECK_INTERVAL)
|
|
|
|
|
|
2026-06-21 10:37:25 +08:00
|
|
|
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)
|
2026-06-28 16:30:17 +08:00
|
|
|
drawReadyScreen()
|
|
|
|
|
elseif ev[1] == "timer" and ev[2] == versionTimer then
|
|
|
|
|
pcall(function()
|
|
|
|
|
refreshRemoteLuaVersion(guessBaseFromStatusURL(serverURL))
|
|
|
|
|
end)
|
|
|
|
|
drawReadyScreen()
|
|
|
|
|
versionTimer = os.startTimer(VERSION_CHECK_INTERVAL)
|
2026-06-21 10:37:25 +08:00
|
|
|
end
|
|
|
|
|
end
|