diff --git a/gate.lua b/gate.lua index 20d2b45..0a847d2 100644 --- a/gate.lua +++ b/gate.lua @@ -1,7 +1,8 @@ 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.7" +local VERSION = "v1.5.8" +local VERSION_CHECK_INTERVAL = 60 local CONFIG_PATH = "gate_config.json" @@ -26,6 +27,15 @@ 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) @@ -80,6 +90,8 @@ 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 @@ -126,10 +138,20 @@ 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 @@ -337,6 +359,23 @@ local function refreshStationNameMap(serverBase) 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 @@ -655,6 +694,10 @@ 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) @@ -667,18 +710,6 @@ if next(stationSet) == nil then error("No station codes configured") end -local stationListText = table.concat(cfg.station_codes, ",") -local function readyLine1() - if not modeBySide then - return "Ready (" .. mode:upper() .. ")" - end - local f = modeBySide.front and modeBySide.front:upper() or "-" - local b = modeBySide.back and modeBySide.back:upper() or "-" - return "Ready (BI) F:" .. f .. " B:" .. b -end - -draw(readyLine1(), "Station: " .. stationListText, colors.lime) - local stationCodesPayload = {} for k, _ in pairs(stationSet) do table.insert(stationCodesPayload, k) end table.sort(stationCodesPayload) @@ -785,6 +816,37 @@ local function actionForSide(side) 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 = {} @@ -1278,11 +1340,19 @@ local function processInspectionEvent(eventName, ev) 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) - draw(readyLine1(), "Station: " .. stationListText, colors.lime) + 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 diff --git a/installer.lua b/installer.lua index 143b52b..c471720 100644 --- a/installer.lua +++ b/installer.lua @@ -35,13 +35,6 @@ local function prompt(label) return trim(read() or "") end -local function promptOptionalUrl(label) - local raw = prompt(label) - raw = trim(raw) - if #raw == 0 then return nil end - return raw -end - local function httpGet(url) if not http then return false, "HTTP API disabled" end local okReq, err = pcall(function() @@ -96,27 +89,15 @@ term.setCursorPos(1, 1) print("Ticket Gate Installer") print("") -local stationsRaw = prompt("Station codes (comma or slash): ") -local stationCodes = splitCsv(stationsRaw) -if #stationCodes == 0 then - print("No station codes provided.") +local stationCode = trim(prompt("Station code: ")) +if #stationCode == 0 then + print("No station code provided.") return end local modeRaw = prompt("Gate mode (entry/exit): ") local mode = (trim(modeRaw):lower() == "exit") and "exit" or "entry" -local stationCode = prompt("Gate station code (default first code): ") -stationCode = trim(stationCode) -if #stationCode == 0 then stationCode = stationCodes[1] end - -print("") -print("Optional server config for ticket / IC card checks.") -local ticketServerUrl = promptOptionalUrl("Ticket check URL (blank=auto): ") -local cardServerUrl = promptOptionalUrl("IC card check URL (blank=same server): ") - -local cfg = { mode = mode, station_codes = stationCodes, station_code = stationCode } -if ticketServerUrl then cfg.server_url = ticketServerUrl end -if cardServerUrl then cfg.card_server_url = cardServerUrl end +local cfg = { mode = mode, station_codes = { stationCode }, station_code = stationCode } local okCfg, cfgJson = pcall(textutils.serializeJSON, cfg) if not okCfg then print("Config serialize failed.") diff --git a/installer_bi.lua b/installer_bi.lua index 9e0f9f4..5584933 100644 --- a/installer_bi.lua +++ b/installer_bi.lua @@ -34,13 +34,6 @@ local function prompt(label) return trim(read() or "") end -local function promptOptionalUrl(label) - local raw = prompt(label) - raw = trim(raw) - if #raw == 0 then return nil end - return raw -end - local function httpGet(url) if not http then return false, "HTTP API disabled" end local okReq, err = pcall(function() @@ -99,10 +92,9 @@ term.setCursorPos(1, 1) print("Bidirectional Ticket Gate Installer") print("") -local stationsRaw = prompt("Station codes (comma or slash): ") -local stationCodes = splitCsv(stationsRaw) -if #stationCodes == 0 then - print("No station codes provided.") +local stationCode = trim(prompt("Station code: ")) +if #stationCode == 0 then + print("No station code provided.") return end @@ -112,30 +104,19 @@ local frontRaw = prompt("Front mode (entry/exit): ") local backRaw = prompt("Back mode (entry/exit): ") local frontMode = normalizeMode(frontRaw) local backMode = normalizeMode(backRaw) -local frontStationCode = trim(prompt("Front station code (default first code): ")) -local backStationCode = trim(prompt("Back station code (default first code): ")) -if #frontStationCode == 0 then frontStationCode = stationCodes[1] end -if #backStationCode == 0 then backStationCode = stationCodes[1] end - -print("") -print("Optional server config for ticket / IC card checks.") -local ticketServerUrl = promptOptionalUrl("Ticket check URL (blank=auto): ") -local cardServerUrl = promptOptionalUrl("IC card check URL (blank=same server): ") local cfg = { - station_codes = stationCodes, - station_code = stationCodes[1], + station_codes = { stationCode }, + station_code = stationCode, side_modes = { front = frontMode, back = backMode, }, side_station_codes = { - front = frontStationCode, - back = backStationCode, + front = stationCode, + back = stationCode, } } -if ticketServerUrl then cfg.server_url = ticketServerUrl end -if cardServerUrl then cfg.card_server_url = cardServerUrl end local okCfg, cfgJson = pcall(textutils.serializeJSON, cfg) if not okCfg then print("Config serialize failed.") diff --git a/server/routes/api.js b/server/routes/api.js index 3e9da7e..7625085 100644 --- a/server/routes/api.js +++ b/server/routes/api.js @@ -148,14 +148,16 @@ const resolveCurrentStationCode = (body, resolveStation) => { // Config router.get('/config', (req, res) => { + const cfg = DataService.getConfig(); res.json({ - api_base: DataService.getConfig().api_base, - current_station: DataService.getConfig().current_station, + api_base: cfg.api_base, + current_station: cfg.current_station, stations: DataService.getStations(), lines: DataService.getLines(), fares: DataService.getFares(), - transfers: DataService.getConfig().transfers || [], - promotion: DataService.getConfig().promotion || { name: '', discount: 1 } + transfers: cfg.transfers || [], + promotion: cfg.promotion || { name: '', discount: 1 }, + lua_versions: cfg.lua_versions || {} }); }); @@ -219,6 +221,12 @@ router.put('/config', async (req, res) => { if (!(d >= 0 && d <= 1)) return res.status(400).json({ ok: false, error: 'promotion.discount must be between 0 and 1' }); cfg.promotion = { name: String(p.name || ''), discount: d }; } + if (incoming.lua_versions && typeof incoming.lua_versions === 'object') { + cfg.lua_versions = { + ...(cfg.lua_versions || {}), + ...(incoming.lua_versions || {}) + }; + } await DataService.saveConfig(cfg); appendReqLog(req, { category: 'admin', type: 'update_config_generic', detail: incoming }); io.emit('config:updated', cfg); diff --git a/server/routes/public.js b/server/routes/public.js index abda02d..7862d83 100644 --- a/server/routes/public.js +++ b/server/routes/public.js @@ -221,7 +221,8 @@ router.get('/fares/query', (req, res) => { router.get('/config', (req, res) => { const cfg = DataService.getConfig(); res.json({ - promotion: { name: cfg.promotion?.name || '', discount: cfg.promotion?.discount ?? 1 } + promotion: { name: cfg.promotion?.name || '', discount: cfg.promotion?.discount ?? 1 }, + lua_versions: cfg.lua_versions || {} }); }); diff --git a/server/services/data.js b/server/services/data.js index 51b2871..78ac335 100644 --- a/server/services/data.js +++ b/server/services/data.js @@ -21,14 +21,39 @@ const pool = mysql.createPool({ queueLimit: 0 }); +const DEFAULT_LUA_VERSIONS = { + ticketmachine: 'v1.5.8', + gate: 'v1.5.8' +}; + +function normalizeLuaVersions(input) { + const src = (input && typeof input === 'object') ? input : {}; + return { + ticketmachine: String(src.ticketmachine || DEFAULT_LUA_VERSIONS.ticketmachine), + gate: String(src.gate || DEFAULT_LUA_VERSIONS.gate) + }; +} + +function normalizeConfig(input) { + const src = (input && typeof input === 'object') ? input : {}; + return { + ...src, + api_base: String(src.api_base || 'http://127.0.0.1:23333/api'), + current_station: (src.current_station && typeof src.current_station === 'object') + ? src.current_station + : { name: 'Station1', code: '01-01' }, + transfers: Array.isArray(src.transfers) ? src.transfers : [], + promotion: { + name: String(src?.promotion?.name || ''), + discount: Number(src?.promotion?.discount ?? 1) + }, + lua_versions: normalizeLuaVersions(src.lua_versions) + }; +} + // In-memory cache for synchronous read access const cache = { - config: { - api_base: 'http://127.0.0.1:23333/api', - current_station: { name: 'Station1', code: '01-01' }, - transfers: [], - promotion: { name: '', discount: 1 } - }, + config: normalizeConfig({}), stations: [], lines: [], fares: [], @@ -66,7 +91,7 @@ const DataService = { // Load Cache const [configs] = await conn.query('SELECT v FROM kv_store WHERE k = ?', ['config']); - if (configs.length > 0) cache.config = configs[0].v; + if (configs.length > 0) cache.config = normalizeConfig(configs[0].v); else await conn.query('INSERT INTO kv_store (k, v) VALUES (?, ?)', ['config', JSON.stringify(cache.config)]); const [stations] = await conn.query('SELECT data FROM stations'); @@ -114,8 +139,8 @@ const DataService = { // Config getConfig: () => cache.config, saveConfig: async (cfg) => { - cache.config = cfg; - await pool.query('INSERT INTO kv_store (k, v) VALUES (?, ?) ON DUPLICATE KEY UPDATE v = ?', ['config', JSON.stringify(cfg), JSON.stringify(cfg)]); + cache.config = normalizeConfig(cfg); + await pool.query('INSERT INTO kv_store (k, v) VALUES (?, ?) ON DUPLICATE KEY UPDATE v = ?', ['config', JSON.stringify(cache.config), JSON.stringify(cache.config)]); }, // Stations diff --git a/ticketmachine.lua b/ticketmachine.lua index 71c555c..18c3cfa 100644 --- a/ticketmachine.lua +++ b/ticketmachine.lua @@ -1,6 +1,6 @@ local CURRENT_STATION_CODE = 'Ticket-Machine' local API_BASE = 'http://ticket.fse-media.group/api' -local VERSION = 'v1.5.7' +local VERSION = 'v1.5.8' -- ########################### -- Core HTTP & JSON Utilities @@ -13,6 +13,17 @@ end local serverConnected = nil local serverLastChangeTs = 0 +local expectedMachineVersion = nil +local versionMismatch = nil + +local function normalizeVersionTag(v) + local s = tostring(v or ''):gsub('^%s+', ''):gsub('%s+$', '') + if #s == 0 then return '' end + if s:sub(1, 1):lower() ~= 'v' then + s = 'v' .. s + end + return s:lower() +end local function setServerConnected(ok) if serverConnected == ok then return end @@ -314,22 +325,93 @@ end -- ########################### -- Peripheral discovery -- ########################### -local monitor = peripheral.find('monitor') -local ticketVendingMachine = peripheral.find('ticket_vending_machine') -local speaker = peripheral.find('speaker') +local SIDE_PRIORITY = { top = 1, bottom = 2, left = 3, right = 4, front = 5, back = 6 } +local REDSTONE_SIDES = { 'right', 'left', 'top', 'bottom', 'front', 'back' } +local monitor = nil +local monitorName = nil +local ticketVendingMachine = nil +local ticketVendingMachineName = nil +local speaker = nil +local speakerName = nil +local detectedPaymentSide = nil local MOD_DEBUG = true pcall(math.randomseed, (os.epoch and os.epoch('utc')) or os.time()) -local function safe(term) - if monitor then return peripheral.wrap(peripheral.getName(monitor)) end - return term +local function comparePeripheralName(a, b) + local pa = SIDE_PRIORITY[tostring(a or '')] or 99 + local pb = SIDE_PRIORITY[tostring(b or '')] or 99 + if pa ~= pb then return pa < pb end + return tostring(a or '') < tostring(b or '') end -local termDev = safe(term) -if monitor then pcall(monitor.setTextScale, 0.5) end +local function peripheralTypeMatches(name, typeName) + if not peripheral or type(peripheral.getType) ~= 'function' then return false end + local got = peripheral.getType(name) + if type(got) == 'string' then return got == typeName end + if type(got) == 'table' then + for _, item in ipairs(got) do + if item == typeName then return true end + end + end + return false +end + +local function findPeripheralByType(typeName) + if not peripheral then return nil, nil end + if type(peripheral.getNames) == 'function' and type(peripheral.wrap) == 'function' then + local names = peripheral.getNames() or {} + table.sort(names, comparePeripheralName) + for _, name in ipairs(names) do + if peripheralTypeMatches(name, typeName) then + local okWrap, dev = pcall(peripheral.wrap, name) + if okWrap and type(dev) == 'table' then + return dev, name + end + end + end + end + if type(peripheral.find) == 'function' then + local dev = peripheral.find(typeName) + if type(dev) == 'table' then + local name = nil + if type(peripheral.getName) == 'function' then + local okName, gotName = pcall(peripheral.getName, dev) + if okName then name = gotName end + end + return dev, name + end + end + return nil, nil +end + +local termDev = term local w, h = termDev.getSize() +local function refreshDevices() + local prevSignature = table.concat({ + tostring(monitorName or ''), + tostring(ticketVendingMachineName or ''), + tostring(speakerName or '') + }, '|') + monitor, monitorName = findPeripheralByType('monitor') + ticketVendingMachine, ticketVendingMachineName = findPeripheralByType('ticket_vending_machine') + speaker, speakerName = findPeripheralByType('speaker') + termDev = monitor or term + if monitor then pcall(monitor.setTextScale, 0.5) end + w, h = termDev.getSize() + local nextSignature = table.concat({ + tostring(monitorName or ''), + tostring(ticketVendingMachineName or ''), + tostring(speakerName or '') + }, '|') + if prevSignature ~= nextSignature then + os.queueEvent('config_updated') + end +end + +refreshDevices() + local function saveCardIssueSnapshot(cardData) pcall(function() ensureDir('logs/last_card_issue.json') @@ -356,6 +438,14 @@ local function peripheralCallSucceeded(r1) return r1 ~= nil and r1 ~= false end +local function getTicketVendingMachine() + refreshDevices() + if type(ticketVendingMachine) == 'table' then + return ticketVendingMachine + end + return nil +end + local function callPeripheralMethods(dev, methodNames, variants) if type(dev) ~= 'table' then return false, 'peripheral_unavailable' end for _, methodName in ipairs(methodNames) do @@ -373,7 +463,7 @@ local function callPeripheralMethods(dev, methodNames, variants) end local function issueBlankICCard(holderName, initialBalance) - local dev = ticketVendingMachine + local dev = getTicketVendingMachine() if type(dev) ~= 'table' then return false, '', 'peripheral_unavailable' end local safeHolderName = firstString(holderName, 'CARD USER') local safeInitialBalance = math.max(0, math.floor(tonumber(initialBalance) or 0)) @@ -395,8 +485,9 @@ local function issueBlankICCard(holderName, initialBalance) return false, '', 'unsupported_method' end -local function writeICCard(cardData) - local dev = ticketVendingMachine +local function writeICCard(cardData, opts) + local dev = getTicketVendingMachine() + local options = opts or {} local payload = {} for k, v in pairs(cardData or {}) do payload[k] = v end payload.media = payload.media or 'ic_card' @@ -409,7 +500,9 @@ local function writeICCard(cardData) saveCardIssueSnapshot(payload) local okWrite, methodName, r1, r2, r3 = callPeripheralMethods(dev, - { 'issueCard', 'writeCard', 'writeICCard', 'issueTicketData', 'writeTicketData', 'issueICCard' }, + options.writeOnly + and { 'writeCard', 'writeICCard', 'writeTicketData', 'issueTicketData' } + or { 'issueCard', 'writeCard', 'writeICCard', 'issueTicketData', 'writeTicketData', 'issueICCard' }, { { payload }, { tostring(payload.holder_name or ''), tonumber(payload.initial_balance) or 0 }, @@ -430,6 +523,75 @@ local function submitCardOpen(payload) return postJSON(API_BASE .. '/cards/open', payload) end +local function buildFinalCardData(payload, respData) + local data = (type(respData) == 'table') and respData or {} + return { + card_id = firstString(data.card_id, data.id, payload.card_id), + holder_name = payload.holder_name, + balance = firstNumber(data.balance, data.stored_value, payload.balance) or payload.balance, + deposit = firstNumber(data.deposit, payload.deposit) or payload.deposit, + topup = firstNumber(data.topup, data.first_topup, payload.topup) or payload.topup, + station_code = payload.station_code, + device = payload.device, + voucher_code = payload.voucher_code, + media = 'ic_card', + product_type = 'stored_value', + order_value = payload.order_value, + initial_balance = firstNumber(data.first_topup, data.topup, payload.topup, payload.balance) or payload.topup or payload.balance or 0 + } +end + +local function generateCardId() + local num = string.format('%06d', math.random(0, 999999)) + return 'IC-' .. num +end + +local function issueTicketFromPeripheral(fromNameEnArg, toNameEnArg, apiType, rides, cost, startStationArg, terminalStationArg, fromNameCnUArg, toNameCnUArg, fallbackTicketId) + local dev = getTicketVendingMachine() + if type(dev) ~= 'table' then + return false, '', 'peripheral_unavailable' + end + local fn = dev.issueTicket + if type(fn) ~= 'function' then + return false, '', 'unsupported_method' + end + + local function normalizeIssuedTicketId(id) + if id == nil then return '' end + local s = tostring(id):gsub('%s+', '') + if #s == 0 then return '' end + local prefix, num = s: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 + end + return prefix .. '-' .. num + end + return s + end + + local function tryIssue(...) + local okCall, r1, r2, r3 = pcall(fn, ...) + if not (okCall and peripheralCallSucceeded(r1)) then + return false, '', okCall and 'issue_failed' or 'issue_call_failed' + end + local issuedId = extractPeripheralId(r2, r3, r1, fallbackTicketId) + local normalizedId = normalizeIssuedTicketId(issuedId) + if #normalizedId == 0 then + return false, '', 'invalid_ticket_id' + end + return true, normalizedId, 'issueTicket' + end + + local okIssue, ticketId, issueErr = tryIssue(fromNameEnArg, toNameEnArg, apiType, rides, cost, startStationArg, terminalStationArg, fromNameCnUArg, toNameCnUArg) + if okIssue then + return true, ticketId, 'issueTicket' + end + + return tryIssue(fromNameEnArg, toNameEnArg, apiType, rides, cost, startStationArg, terminalStationArg) +end + -- ########################### -- Audio Utilities & Playback -- ########################### @@ -489,6 +651,15 @@ local function backgroundSyncTask() end end +local function backgroundPeripheralTask() + while true do + local ev = os.pullEvent() + if ev == 'peripheral' or ev == 'peripheral_detach' then + pcall(refreshDevices) + end + end +end + local function backgroundTicketUploadTask() loadPendingUploadsOnce() local backoff = 2 @@ -516,6 +687,17 @@ local stationByCode = {} local adjacency_regular, adjacency_express = {}, {} local transferGroupByCode = {} +local function updateVersionStateFromConfig() + local remote = normalizeVersionTag(type(CFG.lua_versions) == 'table' and CFG.lua_versions.ticketmachine or nil) + if #remote == 0 then + expectedMachineVersion = nil + versionMismatch = nil + return + end + expectedMachineVersion = remote + versionMismatch = (remote ~= normalizeVersionTag(VERSION)) +end + local function normalizeCode(s) s = tostring(s or '') s = s:gsub('[\239\187\191]', ''):gsub('%s+', '') @@ -644,6 +826,7 @@ local function refreshConfigOnce() if f then f.write(textutils.serializeJSON(cfg)); f.close() end CFG = cfg rebuildMaps() + updateVersionStateFromConfig() os.queueEvent('config_updated') return true end @@ -665,6 +848,7 @@ end CFG = loadConfig() or CFG rebuildMaps() +updateVersionStateFromConfig() -- ########################### @@ -889,10 +1073,20 @@ end local function drawVersionIndicator() if w < 1 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(tostring(VERSION)) + if w >= (#tostring(VERSION) + 1) then + termDev.setTextColor(markerColor) + termDev.write('*') + end termDev.setTextColor(colors.white) end @@ -1457,6 +1651,33 @@ local function computeCost(src, dst, trainType) return nil end +local function paymentHintText() + if detectedPaymentSide and #tostring(detectedPaymentSide) > 0 then + return 'Payment side: ' .. tostring(detectedPaymentSide):upper() + end + return 'Insert payment on any side' +end + +local function snapshotPaymentInputs() + local states = {} + if not redstone or type(redstone.getInput) ~= 'function' then return states end + for _, side in ipairs(REDSTONE_SIDES) do + states[side] = redstone.getInput(side) and true or false + end + return states +end + +local function detectPaymentPulse(prevStates) + local nowStates = snapshotPaymentInputs() + for _, side in ipairs(REDSTONE_SIDES) do + if nowStates[side] and not prevStates[side] then + detectedPaymentSide = side + return true, side, nowStates + end + end + return false, nil, nowStates +end + local function drawOrder() if state.productMode == 'card' then local cardSub = (state.cardMode == 'redeem') and 'Redeem IC card order' or 'Open new stored-value card' @@ -1536,7 +1757,7 @@ local function drawOrder() if total <= 0 then centerText(statusY + 1, 'Ready to confirm', colors.lightGray) else - centerText(statusY + 1, 'Insert payment on RIGHT side', colors.lightGray) + centerText(statusY + 1, paymentHintText(), colors.lightGray) end end @@ -1603,6 +1824,8 @@ local function showOrderAndAudio() local reuseBlankCardId = firstString(state.pendingBlankCardId) if #reuseBlankCardId > 0 then payload.card_id = reuseBlankCardId + else + payload.card_id = generateCardId() end local okIssueBlank, blankCardId, issueMethod = false, '', 'reuse_pending' if #reuseBlankCardId == 0 then @@ -1616,13 +1839,19 @@ local function showOrderAndAudio() local okReq, code, parsed, err = submitCardOpen(payload) if okReq then local respData = (type(parsed) == 'table' and (parsed.data or parsed.card or parsed)) or {} - state.card_id = firstString(respData.card_id, respData.id, payload.card_id) - state.cardBalance = firstNumber(respData.balance, respData.stored_value, payload.balance) or payload.balance - state.card_server_data = respData - state.pendingBlankCardId = nil - confirmed = true - statusMsg, statusCol = 'Card ready', colors.green - if not state.doneAudioPlayed then playConfirmTicketMelody(); state.doneAudioPlayed = true end + local finalCard = buildFinalCardData(payload, respData) + local okWrite, writtenCard, writeMethod = writeICCard(finalCard, { writeOnly = true }) + if okWrite then + state.card_id = firstString(writtenCard.card_id, finalCard.card_id) + state.cardBalance = tonumber(writtenCard.balance) or finalCard.balance + state.card_server_data = respData + state.pendingBlankCardId = nil + confirmed = true + statusMsg, statusCol = 'Card ready', colors.green + if not state.doneAudioPlayed then playConfirmTicketMelody(); state.doneAudioPlayed = true end + else + statusMsg, statusCol = 'Write failed: ' .. tostring(writeMethod), colors.red + end else local errorMsg = 'Card API Err' if code == 409 then @@ -1640,20 +1869,7 @@ local function showOrderAndAudio() local okReq, code, parsed, err = submitCardOpen(payload) if okReq then local respData = (type(parsed) == 'table' and (parsed.data or parsed.card or parsed)) or {} - local finalCard = { - card_id = firstString(respData.card_id, respData.id, payload.card_id), - holder_name = payload.holder_name, - balance = firstNumber(respData.balance, respData.stored_value, payload.balance) or payload.balance, - deposit = firstNumber(respData.deposit, payload.deposit) or payload.deposit, - topup = firstNumber(respData.topup, respData.first_topup, payload.topup) or payload.topup, - station_code = payload.station_code, - device = payload.device, - voucher_code = payload.voucher_code, - media = 'ic_card', - product_type = 'stored_value', - order_value = payload.order_value, - initial_balance = payload.topup - } + local finalCard = buildFinalCardData(payload, respData) local okWrite, writtenCard, writeMethod = writeICCard(finalCard) if okWrite then state.card_id = firstString(writtenCard.card_id, finalCard.card_id) @@ -1736,12 +1952,12 @@ local function showOrderAndAudio() confirmAction() end - local prev = redstone.getInput('right') + local prevInputs = snapshotPaymentInputs() while state.page == 'order' do local ev, p1, p2, p3 = os.pullEvent() if ev == 'redstone' then - local now = redstone.getInput('right') - if now and not prev then + local pulsed, _, nextInputs = detectPaymentPulse(prevInputs) + if pulsed then playNote('hat', 20, 1, 0.01) state.paid = (state.paid or 0) + 1; render() if state.paid >= (state.cost or 0) then @@ -1752,7 +1968,8 @@ local function showOrderAndAudio() sleep(0.5) -- Wait for UI/Audio slightly confirmAction() end - end; prev = now + end + prevInputs = nextInputs elseif ev == 'mouse_click' or ev == 'monitor_touch' then -- For mouse_click: p1=button, p2=x, p3=y -- For monitor_touch: p1=side, p2=x, p3=y @@ -1794,9 +2011,10 @@ local function generateTicketId() end local function ensureTicketIdFormat(id) - if id == nil then return generateTicketId() end + if id == nil then return '' end local s = tostring(id) s = s:gsub('%s+', '') + if #s == 0 then return '' end local prefix, num = s:match('^([A-Za-z][A-Za-z])%-?([0-9]+)$') if prefix and num then prefix = prefix:upper() @@ -1890,22 +2108,35 @@ local function showDone() start_name = startObj and unicodeEscape(startObj.name) or nil, terminal_name = terminalObj and unicodeEscape(terminalObj.name) or nil, start_name_en = fromNameEn, - terminal_name_en = toNameEn + terminal_name_en = toNameEn, + ts = (os.epoch and os.epoch('utc')) or (os.time() * 1000) } - if ticketVendingMachine and ticketVendingMachine.issueTicket then - local apiType = (state.trainType == 'Express') and 'limited_express' or 'local' - local okCall, okIssue, ticketId = pcall(ticketVendingMachine.issueTicket, fromNameEnArg, toNameEnArg, apiType, rides, cost, startStationArg, terminalStationArg, fromNameCnUArg, toNameCnUArg) - if not (okCall and okIssue and ticketId) then - okCall, okIssue, ticketId = pcall(ticketVendingMachine.issueTicket, fromNameEnArg, toNameEnArg, apiType, rides, cost, startStationArg, terminalStationArg) - end - if okCall and okIssue and ticketId then - state.ticket_id = ensureTicketIdFormat(ticketId) - issueSource = 'ticket_vending_machine' - else - state.ticket_id = generateTicketId() - end + + local apiType = (state.trainType == 'Express') and 'limited_express' or 'local' + local localGeneratedTicketId = generateTicketId() + local okIssueTicket, issuedTicketId, issueMethod = issueTicketFromPeripheral( + fromNameEnArg, + toNameEnArg, + apiType, + rides, + cost, + startStationArg, + terminalStationArg, + fromNameCnUArg, + toNameCnUArg, + localGeneratedTicketId + ) + if okIssueTicket then + state.ticket_id = issuedTicketId + issueSource = 'ticket_vending_machine' else - state.ticket_id = generateTicketId() + local issueError = tostring(issueMethod or 'ticket_issue_failed') + print('Ticket issue failed: ' .. issueError) + _G.TICKET_MACHINE_LAST_TICKET.ticket_issue_error = issueError + showAlert('Ticket issue failed') + resetTicketFlow() + state.page = 'home' + return end pcall(function() @@ -2074,7 +2305,6 @@ local function showOnlineVoucher() elseif ev == 'key' and p1 == keys.backspace then code = code:sub(1, -2) elseif ev == 'key' and (p1 == keys.enter or p1 == keys.numPadEnter) then submitCode() elseif ev == 'config_updated' then - -- Config updated in background, continue to redraw end end end @@ -2101,4 +2331,4 @@ local function mainPageLoop() end end -parallel.waitForAny(mainPageLoop, backgroundSyncTask, backgroundTicketUploadTask) +parallel.waitForAny(mainPageLoop, backgroundSyncTask, backgroundTicketUploadTask, backgroundPeripheralTask) diff --git a/web/index.html b/web/index.html index 7a94b75..0623236 100644 --- a/web/index.html +++ b/web/index.html @@ -1,4 +1,4 @@ - + @@ -841,6 +841,27 @@ +
+ +
+
+ 售票机 + + +
+
+ 检票机 + + +
+
+ 每次发布新的 Lua 脚本后,在这里手动提升一次版本号;设备检测到不一致时会在左上角版本号旁显示更新标记。 +
+
+ +
+
+

数据管理

diff --git a/web/index.js b/web/index.js index 3af1dcb..478e717 100644 --- a/web/index.js +++ b/web/index.js @@ -44,7 +44,11 @@ createApp({ const fares = ref([]); const tickets = ref([]); const stats = reactive({ sold_tickets: 0, revenue: 0 }); - const config = reactive({ api_base: '', promotion: { name: '', discount: 1 } }); + const config = reactive({ + api_base: '', + promotion: { name: '', discount: 1 }, + lua_versions: { ticketmachine: 'v1.5.8', gate: 'v1.5.8' } + }); const logs = ref([]); const logCategory = ref(''); const logTypeFilter = ref(''); @@ -224,6 +228,20 @@ createApp({ } }; + const normalizeLuaVersion = (value) => { + let text = String(value || '').trim(); + if (!text) text = 'v1.0.0'; + if (!/^v/i.test(text)) text = `v${text}`; + return text; + }; + + const bumpPatchVersion = (value) => { + const normalized = normalizeLuaVersion(value); + const matched = /^v?(\d+)\.(\d+)\.(\d+)$/i.exec(normalized); + if (!matched) return normalized; + return `v${matched[1]}.${matched[2]}.${Number(matched[3]) + 1}`; + }; + // Methods const formatTime = (ts) => { if (ts == null || ts === '') return '---'; @@ -1344,10 +1362,18 @@ createApp({ const saveConfig = async () => { await runMutation(async () => { + config.lua_versions.ticketmachine = normalizeLuaVersion(config.lua_versions.ticketmachine); + config.lua_versions.gate = normalizeLuaVersion(config.lua_versions.gate); await requestJson('/api/config', { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(config) }, { expectOk: true }); }, { successMessage: '保存成功' }); }; + const bumpLuaVersion = async (device) => { + if (!config.lua_versions[device]) config.lua_versions[device] = 'v1.0.0'; + config.lua_versions[device] = bumpPatchVersion(config.lua_versions[device]); + await saveConfig(); + }; + const exportData = () => { window.open('/api/export', '_blank'); }; @@ -1390,6 +1416,7 @@ createApp({ }); socket.on('config:updated', (data) => { Object.assign(config, data); + if (!config.lua_versions) config.lua_versions = { ticketmachine: 'v1.5.8', gate: 'v1.5.8' }; coreLoaded = true; fareMapLoaded = false; if (currentView.value === 'faremap') { @@ -1579,7 +1606,7 @@ createApp({ saveCurrentFare, deleteCurrentFare, closeFareModal, - saveConfig, exportData, exportFareMap, + saveConfig, bumpLuaVersion, exportData, exportFareMap, formatTime, formatLogDetail, getStationName, getStationInfo, loadFareMap, fetchData, refreshData: fetchData, fareMapScale, fareMapLoading, fareMapError, zoomFareMapIn, zoomFareMapOut, zoomFareMapReset, isTransferStation, getTransferTitleSuffix, getTransferLineBadges