2105 lines
76 KiB
Lua
2105 lines
76 KiB
Lua
|
|
local CURRENT_STATION_CODE = 'Ticket-Machine'
|
||
|
|
local API_BASE = 'http://ticket.fse-media.group/api'
|
||
|
|
local VERSION = 'v1.5.7'
|
||
|
|
|
||
|
|
-- ###########################
|
||
|
|
-- Core HTTP & JSON Utilities
|
||
|
|
-- ###########################
|
||
|
|
local function jsonDecode(str)
|
||
|
|
if not str then return nil end
|
||
|
|
local ok, res = pcall(textutils.unserializeJSON, str)
|
||
|
|
if ok then return res else return nil end
|
||
|
|
end
|
||
|
|
|
||
|
|
local serverConnected = nil
|
||
|
|
local serverLastChangeTs = 0
|
||
|
|
|
||
|
|
local function setServerConnected(ok)
|
||
|
|
if serverConnected == ok then return end
|
||
|
|
serverConnected = ok
|
||
|
|
serverLastChangeTs = (os.epoch and os.epoch('utc')) or (os.time() * 1000)
|
||
|
|
os.queueEvent('config_updated')
|
||
|
|
end
|
||
|
|
|
||
|
|
local function fetchHTTP(url)
|
||
|
|
if not http then
|
||
|
|
print('Error: HTTP API is disabled in ComputerCraft config!')
|
||
|
|
setServerConnected(false)
|
||
|
|
return nil
|
||
|
|
end
|
||
|
|
|
||
|
|
if http.get then
|
||
|
|
local ok, res, err = pcall(http.get, url)
|
||
|
|
if not ok or not res then
|
||
|
|
if err then print('HTTP GET failed: ' .. tostring(err) .. ' url=' .. tostring(url)) end
|
||
|
|
setServerConnected(false)
|
||
|
|
return nil
|
||
|
|
end
|
||
|
|
local data = res.readAll()
|
||
|
|
res.close()
|
||
|
|
setServerConnected(true)
|
||
|
|
return data
|
||
|
|
end
|
||
|
|
|
||
|
|
if not http.request then
|
||
|
|
print('Error: HTTP API is disabled in ComputerCraft config!')
|
||
|
|
return nil
|
||
|
|
end
|
||
|
|
|
||
|
|
local okReq, reqOkOrErr = pcall(http.request, { url = url, method = 'GET' })
|
||
|
|
if not okReq or not reqOkOrErr then
|
||
|
|
print('HTTP request failed: ' .. tostring(reqOkOrErr) .. ' url=' .. tostring(url))
|
||
|
|
setServerConnected(false)
|
||
|
|
return nil
|
||
|
|
end
|
||
|
|
|
||
|
|
while true do
|
||
|
|
local ev, u, p1, p2 = os.pullEvent()
|
||
|
|
if ev == 'http_success' and u == url then
|
||
|
|
local res = p1
|
||
|
|
local data = res.readAll()
|
||
|
|
res.close()
|
||
|
|
setServerConnected(true)
|
||
|
|
return data
|
||
|
|
elseif ev == 'http_failure' and u == url then
|
||
|
|
print('HTTP GET failed: ' .. tostring(p1 or p2 or 'http_failure') .. ' url=' .. tostring(url))
|
||
|
|
setServerConnected(false)
|
||
|
|
return nil
|
||
|
|
else
|
||
|
|
os.queueEvent(ev, u, p1, p2)
|
||
|
|
sleep(0)
|
||
|
|
end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
local function fetchJSON(url)
|
||
|
|
local txt = fetchHTTP(url)
|
||
|
|
if not txt then return nil end
|
||
|
|
return jsonDecode(txt)
|
||
|
|
end
|
||
|
|
|
||
|
|
local function ensureDir(path)
|
||
|
|
local dir = path:match('^(.+)/[^/]+$')
|
||
|
|
if dir and not fs.exists(dir) then pcall(fs.makeDir, dir) end
|
||
|
|
end
|
||
|
|
|
||
|
|
local function postJSONResult(url, dataTable)
|
||
|
|
if not http then
|
||
|
|
setServerConnected(false)
|
||
|
|
return false, nil, nil, 'HTTP API disabled'
|
||
|
|
end
|
||
|
|
local okBody, body = pcall(textutils.serializeJSON, dataTable)
|
||
|
|
if not okBody or type(body) ~= 'string' then return false, nil, nil, 'serializeJSON failed' end
|
||
|
|
|
||
|
|
local headers = { ['Content-Type'] = 'application/json' }
|
||
|
|
|
||
|
|
if http.post then
|
||
|
|
local ok, res, err = pcall(http.post, url, body, headers)
|
||
|
|
if not ok or not res then
|
||
|
|
setServerConnected(false)
|
||
|
|
return false, nil, nil, tostring(err or res or 'http.post error')
|
||
|
|
end
|
||
|
|
local code = res.getResponseCode and res.getResponseCode() or nil
|
||
|
|
local respBody = res.readAll and res.readAll() or nil
|
||
|
|
res.close()
|
||
|
|
setServerConnected(true)
|
||
|
|
return true, code, respBody, nil
|
||
|
|
end
|
||
|
|
|
||
|
|
if not http.request then
|
||
|
|
setServerConnected(false)
|
||
|
|
return false, nil, nil, 'HTTP API disabled'
|
||
|
|
end
|
||
|
|
|
||
|
|
local okReq, reqOkOrErr = pcall(http.request, { url = url, method = 'POST', body = body, headers = headers })
|
||
|
|
if not okReq or not reqOkOrErr then
|
||
|
|
setServerConnected(false)
|
||
|
|
return false, nil, nil, tostring(reqOkOrErr or 'http.request failed')
|
||
|
|
end
|
||
|
|
|
||
|
|
while true do
|
||
|
|
local ev, u, p1, p2 = os.pullEvent()
|
||
|
|
if ev == 'http_success' and u == url then
|
||
|
|
local res = p1
|
||
|
|
local code = res.getResponseCode and res.getResponseCode() or nil
|
||
|
|
local respBody = res.readAll and res.readAll() or nil
|
||
|
|
res.close()
|
||
|
|
setServerConnected(true)
|
||
|
|
return true, code, respBody, nil
|
||
|
|
elseif ev == 'http_failure' and u == url then
|
||
|
|
setServerConnected(false)
|
||
|
|
return false, nil, nil, tostring(p1 or p2 or 'http_failure')
|
||
|
|
else
|
||
|
|
os.queueEvent(ev, u, p1, p2)
|
||
|
|
sleep(0)
|
||
|
|
end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
local function postJSON(url, dataTable)
|
||
|
|
local ok, code, body, err = postJSONResult(url, dataTable)
|
||
|
|
if not ok or not code then
|
||
|
|
return false, code, nil, tostring(err or 'request error')
|
||
|
|
end
|
||
|
|
local parsed = jsonDecode(body)
|
||
|
|
if code < 200 or code >= 300 then
|
||
|
|
local msg = nil
|
||
|
|
if type(parsed) == 'table' then
|
||
|
|
msg = parsed.error or parsed.message or parsed.reason
|
||
|
|
end
|
||
|
|
if not msg then msg = tostring(body or ('HTTP ' .. tostring(code))) end
|
||
|
|
return false, code, parsed, msg
|
||
|
|
end
|
||
|
|
return true, code, parsed, nil
|
||
|
|
end
|
||
|
|
|
||
|
|
local function unicodeEscape(str)
|
||
|
|
if type(str) ~= 'string' then return str end
|
||
|
|
local out = {}
|
||
|
|
local okUtf8 = type(utf8) == 'table' and type(utf8.codes) == 'function'
|
||
|
|
if okUtf8 then
|
||
|
|
for _, cp in utf8.codes(str) do
|
||
|
|
if cp <= 0x7F then
|
||
|
|
out[#out+1] = string.char(cp)
|
||
|
|
elseif cp <= 0xFFFF then
|
||
|
|
out[#out+1] = string.format("\\u%04x", cp)
|
||
|
|
else
|
||
|
|
local v = cp - 0x10000
|
||
|
|
local hi = 0xD800 + math.floor(v / 0x400)
|
||
|
|
local lo = 0xDC00 + (v % 0x400)
|
||
|
|
out[#out+1] = string.format("\\u%04x\\u%04x", hi, lo)
|
||
|
|
end
|
||
|
|
end
|
||
|
|
return table.concat(out)
|
||
|
|
end
|
||
|
|
local i = 1
|
||
|
|
while i <= #str do
|
||
|
|
local b1 = string.byte(str, i)
|
||
|
|
if not b1 then break end
|
||
|
|
if b1 <= 0x7F then
|
||
|
|
out[#out+1] = string.char(b1)
|
||
|
|
i = i + 1
|
||
|
|
elseif b1 >= 0xC0 and b1 <= 0xDF then
|
||
|
|
local b2 = string.byte(str, i + 1) or 0
|
||
|
|
local cp = (b1 % 0x20) * 0x40 + (b2 % 0x40)
|
||
|
|
out[#out+1] = string.format("\\u%04x", cp)
|
||
|
|
i = i + 2
|
||
|
|
elseif b1 >= 0xE0 and b1 <= 0xEF then
|
||
|
|
local b2 = string.byte(str, i + 1) or 0
|
||
|
|
local b3 = string.byte(str, i + 2) or 0
|
||
|
|
local cp = (b1 % 0x10) * 0x1000 + (b2 % 0x40) * 0x40 + (b3 % 0x40)
|
||
|
|
out[#out+1] = string.format("\\u%04x", cp)
|
||
|
|
i = i + 3
|
||
|
|
else
|
||
|
|
local b2 = string.byte(str, i + 1) or 0
|
||
|
|
local b3 = string.byte(str, i + 2) or 0
|
||
|
|
local b4 = string.byte(str, i + 3) or 0
|
||
|
|
local cp = (b1 % 0x08) * 0x40000 + (b2 % 0x40) * 0x1000 + (b3 % 0x40) * 0x40 + (b4 % 0x40)
|
||
|
|
local v = cp - 0x10000
|
||
|
|
local hi = 0xD800 + math.floor(v / 0x400)
|
||
|
|
local lo = 0xDC00 + (v % 0x400)
|
||
|
|
out[#out+1] = string.format("\\u%04x\\u%04x", hi, lo)
|
||
|
|
i = i + 4
|
||
|
|
end
|
||
|
|
end
|
||
|
|
return table.concat(out)
|
||
|
|
end
|
||
|
|
|
||
|
|
local function firstString(...)
|
||
|
|
for i = 1, select('#', ...) do
|
||
|
|
local v = select(i, ...)
|
||
|
|
if v ~= nil then
|
||
|
|
local s = tostring(v):gsub('^%s+', ''):gsub('%s+$', '')
|
||
|
|
if #s > 0 then return s end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
return ''
|
||
|
|
end
|
||
|
|
|
||
|
|
local function firstNumber(...)
|
||
|
|
for i = 1, select('#', ...) do
|
||
|
|
local v = select(i, ...)
|
||
|
|
if v ~= nil then
|
||
|
|
local n = tonumber(v)
|
||
|
|
if n ~= nil then return n end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
return nil
|
||
|
|
end
|
||
|
|
|
||
|
|
local function currentDeviceId()
|
||
|
|
local dev = nil
|
||
|
|
if os.getComputerLabel then dev = os.getComputerLabel() end
|
||
|
|
if not dev and os.getComputerID then dev = tostring(os.getComputerID()) end
|
||
|
|
return tostring(dev or 'unknown')
|
||
|
|
end
|
||
|
|
|
||
|
|
local function normalizeTrainTypeLabel(v)
|
||
|
|
local s = tostring(v or ''):lower()
|
||
|
|
if s == 'express' or s == 'limited_express' or s == 'limited express' then
|
||
|
|
return 'Express'
|
||
|
|
end
|
||
|
|
return 'Local'
|
||
|
|
end
|
||
|
|
|
||
|
|
local PENDING_UPLOAD_PATH = 'logs/pending_ticket_upload.jsonl'
|
||
|
|
local pendingUploads = {}
|
||
|
|
local pendingUploadsLoaded = false
|
||
|
|
|
||
|
|
local function loadPendingUploadsOnce()
|
||
|
|
if pendingUploadsLoaded then return end
|
||
|
|
pendingUploadsLoaded = true
|
||
|
|
if not fs.exists(PENDING_UPLOAD_PATH) then return end
|
||
|
|
for line in io.lines(PENDING_UPLOAD_PATH) do
|
||
|
|
local obj = jsonDecode(line)
|
||
|
|
if type(obj) == 'table' then table.insert(pendingUploads, obj) end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
local function savePendingUploads()
|
||
|
|
ensureDir(PENDING_UPLOAD_PATH)
|
||
|
|
local f = fs.open(PENDING_UPLOAD_PATH, 'w')
|
||
|
|
if not f then
|
||
|
|
print('Save pending upload failed: cannot open ' .. tostring(PENDING_UPLOAD_PATH))
|
||
|
|
return false
|
||
|
|
end
|
||
|
|
for _, obj in ipairs(pendingUploads) do
|
||
|
|
local okSer, s = pcall(textutils.serializeJSON, obj)
|
||
|
|
if okSer and type(s) == 'string' then
|
||
|
|
f.write(s .. "\n")
|
||
|
|
end
|
||
|
|
end
|
||
|
|
f.close()
|
||
|
|
return true
|
||
|
|
end
|
||
|
|
|
||
|
|
local function uploadTicketRecord(ticketData)
|
||
|
|
local url = API_BASE .. '/tickets/sale'
|
||
|
|
local payload = {
|
||
|
|
ticket_id = tostring((type(ticketData) == 'table' and (ticketData.ticket_id or ticketData.id)) or ''),
|
||
|
|
start = tostring((type(ticketData) == 'table' and (ticketData.start or ticketData.start_station_id or ticketData.start_station)) or ''),
|
||
|
|
terminal = tostring((type(ticketData) == 'table' and (ticketData.terminal or ticketData.terminal_station_id or ticketData.end_station)) or ''),
|
||
|
|
train_type = tostring((type(ticketData) == 'table' and (ticketData.train_type or ticketData.trainType or ticketData.type)) or ''),
|
||
|
|
cost = (type(ticketData) == 'table' and tonumber(ticketData.cost)) or 0,
|
||
|
|
station_code = tostring((type(ticketData) == 'table' and (ticketData.station_code or ticketData.stationCode)) or CURRENT_STATION_CODE or ''),
|
||
|
|
device = tostring((type(ticketData) == 'table' and (ticketData.device or ticketData.device_id or ticketData.deviceId)) or 'unknown'),
|
||
|
|
trips_total = (type(ticketData) == 'table' and (ticketData.trips_total or ticketData.rides_total or ticketData.rides)) or nil,
|
||
|
|
trips_remaining = (type(ticketData) == 'table' and (ticketData.trips_remaining or ticketData.rides_remaining or ticketData.rides)) or nil
|
||
|
|
}
|
||
|
|
local ok, code, body, err = postJSONResult(url, payload)
|
||
|
|
if not ok or not code then
|
||
|
|
print('Upload ticket failed: ' .. tostring(err or 'request error') .. ' url=' .. tostring(url))
|
||
|
|
return false
|
||
|
|
end
|
||
|
|
if code < 200 or code >= 300 then
|
||
|
|
print('Upload ticket failed: HTTP ' .. tostring(code) .. ' ' .. tostring(body or '') .. ' url=' .. tostring(url))
|
||
|
|
return false
|
||
|
|
end
|
||
|
|
return true
|
||
|
|
end
|
||
|
|
|
||
|
|
local function enqueueTicketUpload(ticketData)
|
||
|
|
loadPendingUploadsOnce()
|
||
|
|
if uploadTicketRecord(ticketData) then return true end
|
||
|
|
table.insert(pendingUploads, ticketData)
|
||
|
|
local okSave = savePendingUploads()
|
||
|
|
if okSave then
|
||
|
|
print('Ticket upload queued. pending=' .. tostring(#pendingUploads))
|
||
|
|
else
|
||
|
|
print('Ticket upload queued in memory only. pending=' .. tostring(#pendingUploads))
|
||
|
|
end
|
||
|
|
return false
|
||
|
|
end
|
||
|
|
|
||
|
|
-- ###########################
|
||
|
|
-- Peripheral discovery
|
||
|
|
-- ###########################
|
||
|
|
local monitor = peripheral.find('monitor')
|
||
|
|
local ticketVendingMachine = peripheral.find('ticket_vending_machine')
|
||
|
|
local speaker = peripheral.find('speaker')
|
||
|
|
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
|
||
|
|
end
|
||
|
|
|
||
|
|
local termDev = safe(term)
|
||
|
|
if monitor then pcall(monitor.setTextScale, 0.5) end
|
||
|
|
local w, h = termDev.getSize()
|
||
|
|
|
||
|
|
local function saveCardIssueSnapshot(cardData)
|
||
|
|
pcall(function()
|
||
|
|
ensureDir('logs/last_card_issue.json')
|
||
|
|
local okSer, s = pcall(textutils.serializeJSON, cardData)
|
||
|
|
if okSer and type(s) == 'string' then
|
||
|
|
local f = fs.open('logs/last_card_issue.json', 'w')
|
||
|
|
if f then f.write(s); f.close() end
|
||
|
|
end
|
||
|
|
end)
|
||
|
|
end
|
||
|
|
|
||
|
|
local function extractPeripheralId(...)
|
||
|
|
for i = 1, select('#', ...) do
|
||
|
|
local v = select(i, ...)
|
||
|
|
if type(v) == 'string' or type(v) == 'number' then
|
||
|
|
local s = tostring(v):gsub('^%s+', ''):gsub('%s+$', '')
|
||
|
|
if #s > 0 then return s end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
return ''
|
||
|
|
end
|
||
|
|
|
||
|
|
local function peripheralCallSucceeded(r1)
|
||
|
|
return r1 ~= nil and r1 ~= false
|
||
|
|
end
|
||
|
|
|
||
|
|
local function callPeripheralMethods(dev, methodNames, variants)
|
||
|
|
if type(dev) ~= 'table' then return false, 'peripheral_unavailable' end
|
||
|
|
for _, methodName in ipairs(methodNames) do
|
||
|
|
local fn = dev[methodName]
|
||
|
|
if type(fn) == 'function' then
|
||
|
|
for _, args in ipairs(variants) do
|
||
|
|
local okCall, r1, r2, r3 = pcall(fn, table.unpack(args))
|
||
|
|
if okCall and peripheralCallSucceeded(r1) then
|
||
|
|
return true, methodName, r1, r2, r3
|
||
|
|
end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
return false, 'unsupported_method'
|
||
|
|
end
|
||
|
|
|
||
|
|
local function issueBlankICCard(holderName, initialBalance)
|
||
|
|
local dev = ticketVendingMachine
|
||
|
|
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))
|
||
|
|
for _, methodName in ipairs({ 'issueICCard', 'issueCard' }) do
|
||
|
|
local fn = dev[methodName]
|
||
|
|
if type(fn) == 'function' then
|
||
|
|
local okCall, r1, r2, r3 = pcall(fn, safeHolderName, safeInitialBalance)
|
||
|
|
if not (okCall and peripheralCallSucceeded(r1)) then
|
||
|
|
okCall, r1, r2, r3 = pcall(fn, safeHolderName)
|
||
|
|
end
|
||
|
|
if not (okCall and peripheralCallSucceeded(r1)) then
|
||
|
|
okCall, r1, r2, r3 = pcall(fn)
|
||
|
|
end
|
||
|
|
if okCall and peripheralCallSucceeded(r1) then
|
||
|
|
return true, extractPeripheralId(r1, r2, r3), methodName
|
||
|
|
end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
return false, '', 'unsupported_method'
|
||
|
|
end
|
||
|
|
|
||
|
|
local function writeICCard(cardData)
|
||
|
|
local dev = ticketVendingMachine
|
||
|
|
local payload = {}
|
||
|
|
for k, v in pairs(cardData or {}) do payload[k] = v end
|
||
|
|
payload.media = payload.media or 'ic_card'
|
||
|
|
payload.product_type = payload.product_type or 'stored_value'
|
||
|
|
payload.device = payload.device or currentDeviceId()
|
||
|
|
payload.initial_balance = payload.initial_balance or payload.topup or payload.balance or 0
|
||
|
|
payload.owner = payload.owner or payload.holder_name or ''
|
||
|
|
payload.card_holder = payload.card_holder or payload.holder_name or ''
|
||
|
|
_G.TICKET_MACHINE_LAST_TICKET = payload
|
||
|
|
saveCardIssueSnapshot(payload)
|
||
|
|
|
||
|
|
local okWrite, methodName, r1, r2, r3 = callPeripheralMethods(dev,
|
||
|
|
{ 'issueCard', 'writeCard', 'writeICCard', 'issueTicketData', 'writeTicketData', 'issueICCard' },
|
||
|
|
{
|
||
|
|
{ payload },
|
||
|
|
{ tostring(payload.holder_name or ''), tonumber(payload.initial_balance) or 0 },
|
||
|
|
{ tostring(payload.holder_name or '') },
|
||
|
|
{ tostring(payload.card_id or ''), tostring(payload.holder_name or ''), tonumber(payload.balance) or 0, tonumber(payload.deposit) or 0, tostring(payload.station_code or ''), tostring(payload.product_type or 'stored_value') },
|
||
|
|
{ tostring(payload.holder_name or ''), tonumber(payload.balance) or 0, tonumber(payload.deposit) or 0 },
|
||
|
|
{}
|
||
|
|
})
|
||
|
|
if okWrite then
|
||
|
|
local issuedId = extractPeripheralId(r1, r2, r3, payload.card_id)
|
||
|
|
if #issuedId > 0 then payload.card_id = issuedId end
|
||
|
|
return true, payload, methodName
|
||
|
|
end
|
||
|
|
return false, payload, methodName
|
||
|
|
end
|
||
|
|
|
||
|
|
local function submitCardOpen(payload)
|
||
|
|
return postJSON(API_BASE .. '/cards/open', payload)
|
||
|
|
end
|
||
|
|
|
||
|
|
-- ###########################
|
||
|
|
-- Audio Utilities & Playback
|
||
|
|
-- ###########################
|
||
|
|
local function stopAudio() end
|
||
|
|
|
||
|
|
local function waitAudioComplete() end
|
||
|
|
|
||
|
|
local function playNote(instrument, pitch, volume, dur)
|
||
|
|
if not speaker then return end
|
||
|
|
instrument = tostring(instrument or 'pling')
|
||
|
|
local p = tonumber(pitch)
|
||
|
|
if p == nil then p = 12 end
|
||
|
|
p = math.max(0, math.min(24, math.floor(p)))
|
||
|
|
local v = tonumber(volume)
|
||
|
|
if v == nil then v = 1 end
|
||
|
|
if v < 0 then v = 0 end
|
||
|
|
local ok = pcall(function() speaker.playNote(instrument, v, p) end)
|
||
|
|
if ok and dur and dur > 0 then sleep(dur) end
|
||
|
|
end
|
||
|
|
|
||
|
|
local function playMelody(instrument, notes, volume, noteDur, gap)
|
||
|
|
if not speaker then return end
|
||
|
|
instrument = tostring(instrument or 'harp')
|
||
|
|
local v = tonumber(volume); if v == nil then v = 1 end
|
||
|
|
local nd = tonumber(noteDur); if nd == nil then nd = 0.04 end
|
||
|
|
local g = tonumber(gap); if g == nil then g = 0.02 end
|
||
|
|
if type(notes) ~= 'table' then return end
|
||
|
|
for i = 1, #notes do
|
||
|
|
playNote(instrument, notes[i], v, nd)
|
||
|
|
if g > 0 then sleep(g) end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
|
||
|
|
local function playConfirmTicketMelody()
|
||
|
|
playMelody('bell', { 19, 22, 24, 22, 19 }, 1, 0.04, 0.02)
|
||
|
|
end
|
||
|
|
|
||
|
|
local function playAudioFile(path)
|
||
|
|
local p = tostring(path or ''):lower()
|
||
|
|
if p:find('welcome') then
|
||
|
|
playMelody('chime', { 12, 16, 19, 24 }, 1, 0.05, 0.02)
|
||
|
|
return
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
local function clickSound() end
|
||
|
|
|
||
|
|
|
||
|
|
-- ###########################
|
||
|
|
-- Background Tasks
|
||
|
|
-- ###########################
|
||
|
|
local function backgroundSyncTask()
|
||
|
|
while true do
|
||
|
|
sleep(5)
|
||
|
|
pcall(refreshConfigOnce)
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
local function backgroundTicketUploadTask()
|
||
|
|
loadPendingUploadsOnce()
|
||
|
|
local backoff = 2
|
||
|
|
while true do
|
||
|
|
if #pendingUploads > 0 then
|
||
|
|
if uploadTicketRecord(pendingUploads[1]) then
|
||
|
|
table.remove(pendingUploads, 1)
|
||
|
|
savePendingUploads()
|
||
|
|
backoff = 2
|
||
|
|
else
|
||
|
|
backoff = math.min(60, math.floor(backoff * 1.5))
|
||
|
|
end
|
||
|
|
end
|
||
|
|
sleep(backoff)
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
|
||
|
|
|
||
|
|
-- ###########################
|
||
|
|
-- Data logic & Config
|
||
|
|
-- ###########################
|
||
|
|
local CFG = { stations = {}, lines = {}, fares = {}, transfers = {} }
|
||
|
|
local stationByCode = {}
|
||
|
|
local adjacency_regular, adjacency_express = {}, {}
|
||
|
|
local transferGroupByCode = {}
|
||
|
|
|
||
|
|
local function normalizeCode(s)
|
||
|
|
s = tostring(s or '')
|
||
|
|
s = s:gsub('[\239\187\191]', ''):gsub('%s+', '')
|
||
|
|
return s
|
||
|
|
end
|
||
|
|
|
||
|
|
local function rebuildMaps()
|
||
|
|
stationByCode = {}
|
||
|
|
for _, s in ipairs(CFG.stations or {}) do
|
||
|
|
if type(s) == 'table' then
|
||
|
|
local code = normalizeCode(s.code or s.id)
|
||
|
|
if #code > 0 then
|
||
|
|
s.code = code
|
||
|
|
if s.en_name == nil then s.en_name = s.en or s.enName or s.name_en or s.english_name end
|
||
|
|
stationByCode[code] = s
|
||
|
|
end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
adjacency_regular, adjacency_express = {}, {}
|
||
|
|
local transferAdj = {}
|
||
|
|
local function addTransfer(a, b)
|
||
|
|
a = normalizeCode(a)
|
||
|
|
b = normalizeCode(b)
|
||
|
|
if #a == 0 or #b == 0 or a == b then return end
|
||
|
|
adjacency_regular[a] = adjacency_regular[a] or {}; adjacency_regular[a][b] = 0
|
||
|
|
adjacency_regular[b] = adjacency_regular[b] or {}; adjacency_regular[b][a] = 0
|
||
|
|
adjacency_express[a] = adjacency_express[a] or {}; adjacency_express[a][b] = 0
|
||
|
|
adjacency_express[b] = adjacency_express[b] or {}; adjacency_express[b][a] = 0
|
||
|
|
transferAdj[a] = transferAdj[a] or {}; transferAdj[a][b] = true
|
||
|
|
transferAdj[b] = transferAdj[b] or {}; transferAdj[b][a] = true
|
||
|
|
end
|
||
|
|
for _, e in ipairs(CFG.fares or {}) do
|
||
|
|
local cr = e.cost_regular or e.cost or 0
|
||
|
|
local ce = e.cost_express or e.cost or 0
|
||
|
|
adjacency_regular[e.from] = adjacency_regular[e.from] or {}
|
||
|
|
adjacency_regular[e.from][e.to] = cr
|
||
|
|
adjacency_regular[e.to] = adjacency_regular[e.to] or {}
|
||
|
|
adjacency_regular[e.to][e.from] = cr
|
||
|
|
adjacency_express[e.from] = adjacency_express[e.from] or {}
|
||
|
|
adjacency_express[e.from][e.to] = ce
|
||
|
|
adjacency_express[e.to] = adjacency_express[e.to] or {}
|
||
|
|
adjacency_express[e.to][e.from] = ce
|
||
|
|
end
|
||
|
|
for _, p in ipairs(CFG.transfers or {}) do
|
||
|
|
addTransfer(p[1], p[2])
|
||
|
|
end
|
||
|
|
for _, s in ipairs(CFG.stations or {}) do
|
||
|
|
if type(s) == 'table' and s.transfer_enabled and type(s.transfer_to) == 'table' then
|
||
|
|
local from = normalizeCode(s.code or s.id)
|
||
|
|
for _, t in ipairs(s.transfer_to) do
|
||
|
|
local to = t
|
||
|
|
if type(t) == 'table' then to = t.code or t.station or t.id or t[1] end
|
||
|
|
addTransfer(from, to)
|
||
|
|
end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
local groups = {}
|
||
|
|
for _, s in ipairs(CFG.stations or {}) do
|
||
|
|
if type(s) == 'table' then
|
||
|
|
local cn = tostring(s.name or s.cn_name or ''):gsub('%s+', '')
|
||
|
|
local en = tostring(s.en_name or s.en or s.enName or ''):lower():gsub('%s+', '')
|
||
|
|
local code = normalizeCode(s.code or s.id)
|
||
|
|
if #cn > 0 and #en > 0 and #code > 0 then
|
||
|
|
local k = cn .. '|' .. en
|
||
|
|
groups[k] = groups[k] or {}
|
||
|
|
table.insert(groups[k], code)
|
||
|
|
end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
for _, arr in pairs(groups) do
|
||
|
|
if #arr >= 2 then
|
||
|
|
for i = 1, #arr do
|
||
|
|
for j = i + 1, #arr do
|
||
|
|
local a, b = tostring(arr[i] or ''), tostring(arr[j] or '')
|
||
|
|
if #a > 0 and #b > 0 and a ~= b then
|
||
|
|
addTransfer(a, b)
|
||
|
|
end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
transferGroupByCode = {}
|
||
|
|
local visited = {}
|
||
|
|
local groupId = 0
|
||
|
|
local function flood(start)
|
||
|
|
groupId = groupId + 1
|
||
|
|
local q = { start }
|
||
|
|
visited[start] = true
|
||
|
|
while #q > 0 do
|
||
|
|
local u = table.remove(q, 1)
|
||
|
|
transferGroupByCode[u] = groupId
|
||
|
|
for v, _ in pairs(transferAdj[u] or {}) do
|
||
|
|
if not visited[v] then
|
||
|
|
visited[v] = true
|
||
|
|
table.insert(q, v)
|
||
|
|
end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
for code, _ in pairs(stationByCode) do
|
||
|
|
if not visited[code] then flood(code) end
|
||
|
|
end
|
||
|
|
for code, _ in pairs(transferAdj) do
|
||
|
|
if not visited[code] then flood(code) end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
local function sameLogicalStation(a, b)
|
||
|
|
a = normalizeCode(a)
|
||
|
|
b = normalizeCode(b)
|
||
|
|
if a == b then return true end
|
||
|
|
local ga = transferGroupByCode[a]
|
||
|
|
local gb = transferGroupByCode[b]
|
||
|
|
return ga ~= nil and ga == gb
|
||
|
|
end
|
||
|
|
|
||
|
|
local function fetchConfig()
|
||
|
|
local txt = fetchHTTP(API_BASE .. '/config')
|
||
|
|
return txt and jsonDecode(txt) or nil
|
||
|
|
end
|
||
|
|
|
||
|
|
local function refreshConfigOnce()
|
||
|
|
local cfg = fetchConfig()
|
||
|
|
if not cfg then return false end
|
||
|
|
local f = fs.open('config.json', 'w')
|
||
|
|
if f then f.write(textutils.serializeJSON(cfg)); f.close() end
|
||
|
|
CFG = cfg
|
||
|
|
rebuildMaps()
|
||
|
|
os.queueEvent('config_updated')
|
||
|
|
return true
|
||
|
|
end
|
||
|
|
|
||
|
|
local function loadConfig()
|
||
|
|
local cfg = fetchConfig()
|
||
|
|
if cfg then
|
||
|
|
local f = fs.open('config.json', 'w')
|
||
|
|
if f then f.write(textutils.serializeJSON(cfg)); f.close() end
|
||
|
|
return cfg
|
||
|
|
end
|
||
|
|
if fs.exists('config.json') then
|
||
|
|
local f = fs.open('config.json', 'r')
|
||
|
|
local c = f.readAll(); f.close()
|
||
|
|
return jsonDecode(c)
|
||
|
|
end
|
||
|
|
return nil
|
||
|
|
end
|
||
|
|
|
||
|
|
CFG = loadConfig() or CFG
|
||
|
|
rebuildMaps()
|
||
|
|
|
||
|
|
|
||
|
|
-- ###########################
|
||
|
|
-- UI Helpers
|
||
|
|
-- ###########################
|
||
|
|
local CC_PALETTE = {
|
||
|
|
{name='white', val=colors.white, rgb={0xF2,0xF2,0xF2}},
|
||
|
|
{name='orange', val=colors.orange, rgb={0xF2,0xB2,0x33}},
|
||
|
|
{name='magenta', val=colors.magenta, rgb={0xE5,0x7F,0xD8}},
|
||
|
|
{name='lightBlue', val=colors.lightBlue, rgb={0x99,0xB2,0xF2}},
|
||
|
|
{name='yellow', val=colors.yellow, rgb={0xDE,0xDE,0x6C}},
|
||
|
|
{name='lime', val=colors.lime, rgb={0x7F,0xCC,0x19}},
|
||
|
|
{name='pink', val=colors.pink, rgb={0xF2,0xB2,0xCC}},
|
||
|
|
{name='gray', val=colors.gray, rgb={0x4C,0x4C,0x4C}},
|
||
|
|
{name='lightGray', val=colors.lightGray, rgb={0x99,0x99,0x99}},
|
||
|
|
{name='cyan', val=colors.cyan, rgb={0x4C,0x99,0xB2}},
|
||
|
|
{name='purple', val=colors.purple, rgb={0xB2,0x66,0xE5}},
|
||
|
|
{name='blue', val=colors.blue, rgb={0x33,0x66,0xCC}},
|
||
|
|
{name='brown', val=colors.brown, rgb={0x7F,0x66,0x4C}},
|
||
|
|
{name='green', val=colors.green, rgb={0x57,0xA6,0x4E}},
|
||
|
|
{name='red', val=colors.red, rgb={0xCC,0x4C,0x4C}},
|
||
|
|
{name='black', val=colors.black, rgb={0x11,0x11,0x11}},
|
||
|
|
}
|
||
|
|
|
||
|
|
local function nearestCCColor(val)
|
||
|
|
if type(val) ~= 'string' then return colors.gray end
|
||
|
|
if val:sub(1,1) == '#' then
|
||
|
|
local hex = val:sub(2)
|
||
|
|
local r, g, b = tonumber(hex:sub(1,2), 16), tonumber(hex:sub(3,4), 16), tonumber(hex:sub(5,6), 16)
|
||
|
|
if not r then return colors.gray end
|
||
|
|
local bestD, bestV = math.huge, colors.gray
|
||
|
|
for _,c in ipairs(CC_PALETTE) do
|
||
|
|
local d = (r-c.rgb[1])^2 + (g-c.rgb[2])^2 + (b-c.rgb[3])^2
|
||
|
|
if d < bestD then bestD, bestV = d, c.val end
|
||
|
|
end
|
||
|
|
return bestV
|
||
|
|
end
|
||
|
|
for _,c in ipairs(CC_PALETTE) do if c.name == val then return c.val end end
|
||
|
|
return colors.gray
|
||
|
|
end
|
||
|
|
|
||
|
|
local function clear()
|
||
|
|
termDev.setBackgroundColor(colors.black)
|
||
|
|
termDev.clear()
|
||
|
|
termDev.setCursorPos(1,1)
|
||
|
|
end
|
||
|
|
|
||
|
|
local function centerText(y, text, color)
|
||
|
|
termDev.setTextColor(color or colors.white)
|
||
|
|
local x = math.max(1, math.floor((w - #text) / 2))
|
||
|
|
termDev.setCursorPos(x, y)
|
||
|
|
termDev.write(text)
|
||
|
|
end
|
||
|
|
|
||
|
|
local function drawRainbowLabelRow(y, text, fg)
|
||
|
|
local palette = { colors.red, colors.orange, colors.yellow, colors.lime, colors.green, colors.cyan, colors.blue, colors.purple, colors.magenta }
|
||
|
|
local x = math.max(1, math.floor((w - #text) / 2))
|
||
|
|
termDev.setTextColor(fg or colors.white)
|
||
|
|
for i = 1, #text do
|
||
|
|
termDev.setBackgroundColor(palette[((i - 1) % #palette) + 1])
|
||
|
|
termDev.setCursorPos(x + i - 1, y)
|
||
|
|
termDev.write(text:sub(i, i))
|
||
|
|
end
|
||
|
|
termDev.setBackgroundColor(colors.black)
|
||
|
|
end
|
||
|
|
|
||
|
|
local Buttons = {}
|
||
|
|
local function addButton(x, y, label, wBtn, hBtn, colorsPair, onClick)
|
||
|
|
table.insert(Buttons, { x=x, y=y, w=wBtn, h=hBtn, onClick=onClick })
|
||
|
|
termDev.setBackgroundColor(colorsPair[1]); termDev.setTextColor(colorsPair[2])
|
||
|
|
for i=0,hBtn-1 do termDev.setCursorPos(x, y+i); termDev.write(string.rep(' ', wBtn)) end
|
||
|
|
termDev.setCursorPos(x + math.floor((wBtn - #label)/2), y + math.floor(hBtn/2))
|
||
|
|
termDev.write(label)
|
||
|
|
end
|
||
|
|
|
||
|
|
local function inRect(btn, px, py)
|
||
|
|
return px >= btn.x and px <= (btn.x + btn.w - 1) and py >= btn.y and py <= (btn.y + btn.h - 1)
|
||
|
|
end
|
||
|
|
|
||
|
|
local function waitButtons()
|
||
|
|
while true do
|
||
|
|
local ev, p1, p2, p3 = os.pullEvent()
|
||
|
|
if 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
|
||
|
|
for _, b in ipairs(Buttons) do
|
||
|
|
if inRect(b, p2, p3) then
|
||
|
|
clickSound(); if b.onClick then b.onClick() end; return
|
||
|
|
end
|
||
|
|
end
|
||
|
|
elseif ev == 'config_updated' then
|
||
|
|
-- If config was updated in background, return to force UI re-render
|
||
|
|
return
|
||
|
|
end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
-- ###########################
|
||
|
|
-- Pages & Navigation
|
||
|
|
-- ###########################
|
||
|
|
local state = {
|
||
|
|
page = 'home',
|
||
|
|
stationName = (CFG.current_station and (CFG.current_station.en_name or CFG.current_station.name)) or 'Station',
|
||
|
|
stationCode = (CFG.current_station and CFG.current_station.code) or CURRENT_STATION_CODE,
|
||
|
|
departure = nil, terminal = nil, trainType = nil, trips = 1, cost = 0, paid = 0, doneAudioPlayed = false,
|
||
|
|
productMode = 'ticket', cardMode = nil, cardPaymentMode = 'local',
|
||
|
|
holderName = '', cardDeposit = 0, cardTopup = 0, cardBalance = 0, cardOrderValue = 0,
|
||
|
|
card_id = nil, card_server_data = nil, pendingBlankCardId = nil
|
||
|
|
}
|
||
|
|
|
||
|
|
local function getCardConfig()
|
||
|
|
local cfg = CFG.card or CFG.ic_card or {}
|
||
|
|
local minTopup = math.max(1, math.floor(firstNumber(cfg.first_topup_min, cfg.min_first_topup, cfg.min_topup, 10) or 10))
|
||
|
|
local quickSrc = type(cfg.quick_amounts) == 'table' and cfg.quick_amounts or { 10, 20, 50, 100 }
|
||
|
|
local quick = {}
|
||
|
|
for _, v in ipairs(quickSrc) do
|
||
|
|
local n = tonumber(v)
|
||
|
|
if n and n > 0 then quick[#quick + 1] = math.floor(n) end
|
||
|
|
end
|
||
|
|
if #quick == 0 then quick = { 10, 20, 50, 100 } end
|
||
|
|
return {
|
||
|
|
deposit = 0,
|
||
|
|
min_topup = minTopup,
|
||
|
|
quick_amounts = quick
|
||
|
|
}
|
||
|
|
end
|
||
|
|
|
||
|
|
local function resetTicketFlow()
|
||
|
|
state.productMode = 'ticket'
|
||
|
|
state.cardMode = nil
|
||
|
|
state.cardPaymentMode = 'local'
|
||
|
|
state.departure, state.terminal, state.trainType = nil, nil, nil
|
||
|
|
state.trips, state.cost, state.paid, state.doneAudioPlayed = 1, 0, 0, false
|
||
|
|
state.voucher_code = nil
|
||
|
|
state.holderName = ''
|
||
|
|
state.cardDeposit = 0
|
||
|
|
state.cardTopup = 0
|
||
|
|
state.cardBalance = 0
|
||
|
|
state.cardOrderValue = 0
|
||
|
|
state.card_id = nil
|
||
|
|
state.card_server_data = nil
|
||
|
|
state.pendingBlankCardId = nil
|
||
|
|
end
|
||
|
|
|
||
|
|
local function resetCardFlow(mode)
|
||
|
|
local cardCfg = getCardConfig()
|
||
|
|
state.productMode = 'card'
|
||
|
|
state.cardMode = mode or 'open'
|
||
|
|
state.cardPaymentMode = 'local'
|
||
|
|
state.departure, state.terminal, state.trainType = nil, nil, nil
|
||
|
|
state.trips, state.paid, state.doneAudioPlayed = 1, 0, 0, false
|
||
|
|
state.voucher_code = nil
|
||
|
|
state.holderName = ''
|
||
|
|
state.cardDeposit = cardCfg.deposit
|
||
|
|
state.cardTopup = cardCfg.min_topup
|
||
|
|
state.cardBalance = cardCfg.min_topup
|
||
|
|
state.cardOrderValue = state.cardTopup
|
||
|
|
state.cost = state.cardOrderValue
|
||
|
|
state.card_id = nil
|
||
|
|
state.card_server_data = nil
|
||
|
|
state.pendingBlankCardId = nil
|
||
|
|
end
|
||
|
|
|
||
|
|
local function isCardOrderLike(data)
|
||
|
|
if type(data) ~= 'table' then return false end
|
||
|
|
local kind = firstString(
|
||
|
|
data.order_type, data.type, data.kind, data.media, data.media_type, data.product,
|
||
|
|
data.product_type, data.ticket_type, data.service_type
|
||
|
|
):lower()
|
||
|
|
if kind:find('card', 1, true) or kind:find('stored', 1, true) or kind:find('wallet', 1, true) then
|
||
|
|
return true
|
||
|
|
end
|
||
|
|
if kind:find('ic', 1, true) then return true end
|
||
|
|
if type(data.card) == 'table' or type(data.ic_card) == 'table' then return true end
|
||
|
|
return firstString(data.holder_name, data.holderName, data.passenger_name, data.passengerName) ~= ''
|
||
|
|
end
|
||
|
|
|
||
|
|
local function buildCardOrderState(data, voucherCode)
|
||
|
|
if not isCardOrderLike(data) then return nil end
|
||
|
|
local cardCfg = getCardConfig()
|
||
|
|
local totalValue = math.max(0, math.floor(firstNumber(
|
||
|
|
data.order_value, data.total_value, data.price, data.cost, data.amount,
|
||
|
|
type(data.card) == 'table' and data.card.order_value or nil,
|
||
|
|
type(data.ic_card) == 'table' and data.ic_card.order_value or nil,
|
||
|
|
cardCfg.min_topup
|
||
|
|
) or cardCfg.min_topup))
|
||
|
|
local topup = math.max(0, math.floor(firstNumber(
|
||
|
|
data.topup, data.first_topup, data.recharge, data.initial_balance,
|
||
|
|
type(data.card) == 'table' and data.card.topup or nil,
|
||
|
|
type(data.ic_card) == 'table' and data.ic_card.topup or nil,
|
||
|
|
totalValue
|
||
|
|
) or totalValue))
|
||
|
|
local balance = math.max(0, math.floor(firstNumber(
|
||
|
|
data.balance, data.stored_value, data.wallet_balance,
|
||
|
|
type(data.card) == 'table' and data.card.balance or nil,
|
||
|
|
type(data.ic_card) == 'table' and data.ic_card.balance or nil,
|
||
|
|
topup
|
||
|
|
) or topup))
|
||
|
|
return {
|
||
|
|
holderName = firstString(
|
||
|
|
data.holder_name, data.holderName, data.passenger_name, data.passengerName,
|
||
|
|
type(data.card) == 'table' and data.card.holder_name or nil,
|
||
|
|
type(data.ic_card) == 'table' and data.ic_card.holder_name or nil,
|
||
|
|
'CARD USER'
|
||
|
|
),
|
||
|
|
deposit = 0,
|
||
|
|
topup = topup,
|
||
|
|
balance = balance,
|
||
|
|
orderValue = totalValue,
|
||
|
|
voucher = voucherCode,
|
||
|
|
raw = data
|
||
|
|
}
|
||
|
|
end
|
||
|
|
|
||
|
|
local function stationDisplay(code)
|
||
|
|
code = normalizeCode(code)
|
||
|
|
if #code == 0 then return '' end
|
||
|
|
local st = stationByCode[code]
|
||
|
|
if st then return tostring(st.en_name or st.name or code) .. ' ' .. code end
|
||
|
|
return code
|
||
|
|
end
|
||
|
|
|
||
|
|
local function drawVersionIndicator()
|
||
|
|
if w < 1 then return end
|
||
|
|
termDev.setBackgroundColor(colors.black)
|
||
|
|
termDev.setTextColor(colors.gray)
|
||
|
|
termDev.setCursorPos(1, 1)
|
||
|
|
termDev.write(tostring(VERSION))
|
||
|
|
termDev.setTextColor(colors.white)
|
||
|
|
end
|
||
|
|
|
||
|
|
local function drawServerStatusIndicator()
|
||
|
|
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 drawHeader(title, sub, hideStationLabel)
|
||
|
|
clear()
|
||
|
|
drawVersionIndicator()
|
||
|
|
drawServerStatusIndicator()
|
||
|
|
centerText(2, title, colors.white)
|
||
|
|
if sub and #tostring(sub) > 0 then centerText(3, tostring(sub), colors.lightBlue) end
|
||
|
|
end
|
||
|
|
|
||
|
|
local ui_cancel_request, ui_cancel_confirmed = false, false
|
||
|
|
local function renderConfirmCancel()
|
||
|
|
local boxW, boxH = math.max(24, math.min(32, w-4)), 6
|
||
|
|
local bx, by = math.floor((w-boxW)/2)+1, math.floor((h-boxH)/2)+1
|
||
|
|
termDev.setBackgroundColor(colors.gray)
|
||
|
|
for i=0,boxH-1 do termDev.setCursorPos(bx, by+i); termDev.write(string.rep(' ', boxW)) end
|
||
|
|
local msg = 'Cancel this purchase?'
|
||
|
|
if #msg > boxW-4 then msg = msg:sub(1, boxW-4) end
|
||
|
|
termDev.setTextColor(colors.white); termDev.setCursorPos(bx+2, by+2); termDev.write(msg)
|
||
|
|
Buttons = {}
|
||
|
|
local bw = math.floor((boxW - 6) / 2)
|
||
|
|
addButton(bx+2, by+4, 'YES', bw, 1, {colors.red, colors.white}, function() ui_cancel_confirmed = true; ui_cancel_request = false end)
|
||
|
|
addButton(bx+4+bw, by+4, 'NO', bw, 1, {colors.green, colors.white}, function() ui_cancel_confirmed = false; ui_cancel_request = false end)
|
||
|
|
end
|
||
|
|
|
||
|
|
local function showAlert(msg)
|
||
|
|
local boxW, boxH = math.max(24, math.min(32, w-4)), 6
|
||
|
|
local bx, by = math.floor((w-boxW)/2)+1, math.floor((h-boxH)/2)+1
|
||
|
|
termDev.setBackgroundColor(colors.gray)
|
||
|
|
for i=0,boxH-1 do termDev.setCursorPos(bx, by+i); termDev.write(string.rep(' ', boxW)) end
|
||
|
|
msg = tostring(msg or '')
|
||
|
|
if #msg > boxW-4 then msg = msg:sub(1, boxW-4) end
|
||
|
|
termDev.setTextColor(colors.white); termDev.setCursorPos(bx+2, by+2); termDev.write(msg)
|
||
|
|
Buttons = {}
|
||
|
|
addButton(bx+math.floor((boxW-6)/2), by+4, 'OK', 6, 1, {colors.green, colors.white}, function() end)
|
||
|
|
waitButtons()
|
||
|
|
end
|
||
|
|
|
||
|
|
local function addCancelButton()
|
||
|
|
addButton(2, h, 'CANCEL', 8, 1, {colors.red, colors.white}, function() ui_cancel_request = true end)
|
||
|
|
end
|
||
|
|
|
||
|
|
local function showHome()
|
||
|
|
state.stationName = (CFG.current_station and (CFG.current_station.en_name or CFG.current_station.name)) or 'Station'
|
||
|
|
state.stationCode = (CFG.current_station and CFG.current_station.code) or CURRENT_STATION_CODE
|
||
|
|
drawHeader('FSE Ticket Machine', 'Select Mode', true)
|
||
|
|
Buttons = {}
|
||
|
|
local btnW, btnH = 12, 3
|
||
|
|
local y = math.floor(h/2) - 2
|
||
|
|
local x1, x2, x3 = math.floor(w/2) - btnW - 2, math.floor(w/2) + 3, math.floor(w/2) - math.floor(btnW / 2)
|
||
|
|
if x1 < 2 or (x2 + btnW) > w-1 then
|
||
|
|
local cx = math.floor((w - btnW) / 2) + 1
|
||
|
|
addButton(cx, y-2, 'NEW', btnW, btnH, {colors.green, colors.white}, function()
|
||
|
|
resetTicketFlow()
|
||
|
|
stopAudio(); playAudioFile('Audio/welcome.wav'); waitAudioComplete()
|
||
|
|
state.page = 'departure'
|
||
|
|
end)
|
||
|
|
addButton(cx, y+2, 'CARD', btnW, btnH, {colors.orange, colors.white}, function()
|
||
|
|
resetCardFlow('open')
|
||
|
|
stopAudio(); state.page = 'card_home'
|
||
|
|
end)
|
||
|
|
addButton(cx, y+6, 'ONLINE', btnW, btnH, {colors.cyan, colors.white}, function()
|
||
|
|
resetTicketFlow()
|
||
|
|
stopAudio(); state.page = 'online'
|
||
|
|
end)
|
||
|
|
else
|
||
|
|
addButton(x1, y, 'NEW', btnW, btnH, {colors.green, colors.white}, function()
|
||
|
|
resetTicketFlow()
|
||
|
|
stopAudio(); playAudioFile('Audio/welcome.wav'); waitAudioComplete()
|
||
|
|
state.page = 'departure'
|
||
|
|
end)
|
||
|
|
addButton(x2, y, 'ONLINE', btnW, btnH, {colors.cyan, colors.white}, function()
|
||
|
|
resetTicketFlow()
|
||
|
|
stopAudio(); state.page = 'online'
|
||
|
|
end)
|
||
|
|
addButton(x3, y+4, 'CARD', btnW, btnH, {colors.orange, colors.white}, function()
|
||
|
|
resetCardFlow('open')
|
||
|
|
stopAudio(); state.page = 'card_home'
|
||
|
|
end)
|
||
|
|
end
|
||
|
|
waitButtons()
|
||
|
|
end
|
||
|
|
|
||
|
|
local function showCardHome()
|
||
|
|
while state.page == 'card_home' do
|
||
|
|
drawHeader('IC Card Service', 'Open card or redeem online order')
|
||
|
|
Buttons = {}
|
||
|
|
local btnW, btnH = 16, 3
|
||
|
|
local cx = math.floor((w - btnW) / 2) + 1
|
||
|
|
local y = math.floor(h / 2) - 3
|
||
|
|
addButton(cx, y, 'OPEN CARD', btnW, btnH, {colors.orange, colors.white}, function()
|
||
|
|
resetCardFlow('open')
|
||
|
|
state.page = 'card_name'
|
||
|
|
end)
|
||
|
|
addButton(cx, y + 4, 'ONLINE REDEEM', btnW, btnH, {colors.cyan, colors.white}, function()
|
||
|
|
resetCardFlow('redeem')
|
||
|
|
state.cardPaymentMode = 'online'
|
||
|
|
state.page = 'card_online'
|
||
|
|
end)
|
||
|
|
addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'home' end)
|
||
|
|
waitButtons()
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
local function showCardNameInput()
|
||
|
|
local name = firstString(state.holderName)
|
||
|
|
local msg, msgCol = '', colors.red
|
||
|
|
local maxNameLen = 24
|
||
|
|
local keyboardMode = 'alpha'
|
||
|
|
local alphaRows = {
|
||
|
|
{'Q','W','E','R','T','Y','U','I','O','P'},
|
||
|
|
{'A','S','D','F','G','H','J','K','L'},
|
||
|
|
{'Z','X','C','V','B','N','M'}
|
||
|
|
}
|
||
|
|
local symbolRows = {
|
||
|
|
{'-','_','.','\'',','},
|
||
|
|
{'(',')','&','@','/'},
|
||
|
|
{'+'}
|
||
|
|
}
|
||
|
|
|
||
|
|
local function isAllowedHolderChar(ch)
|
||
|
|
if type(ch) ~= 'string' or #ch ~= 1 then return false end
|
||
|
|
if ch:match('[A-Za-z ]') then return true end
|
||
|
|
return ch == '.' or ch == ',' or ch == '\'' or ch == '(' or ch == ')' or ch == '&' or ch == '@' or ch == '/' or ch == '_' or ch == '-' or ch == '+'
|
||
|
|
end
|
||
|
|
|
||
|
|
local function appendHolderChar(ch)
|
||
|
|
if not isAllowedHolderChar(ch) or #name >= maxNameLen then return end
|
||
|
|
if ch:match('[A-Za-z]') then ch = ch:upper() end
|
||
|
|
if ch == ' ' and (#name == 0 or name:sub(-1) == ' ') then return end
|
||
|
|
name = name .. ch
|
||
|
|
end
|
||
|
|
|
||
|
|
while state.page == 'card_name' do
|
||
|
|
local placeholder = name
|
||
|
|
if #placeholder == 0 then
|
||
|
|
placeholder = 'ENTER NAME'
|
||
|
|
elseif #placeholder > (w - 8) then
|
||
|
|
placeholder = '...' .. placeholder:sub(-(w - 11))
|
||
|
|
end
|
||
|
|
local rows = (keyboardMode == 'symbol') and symbolRows or alphaRows
|
||
|
|
drawHeader('Enter Holder Name', (keyboardMode == 'symbol') and 'Symbol keyboard' or 'Letters / symbols / space')
|
||
|
|
centerText(4, '[' .. placeholder .. ']', colors.yellow)
|
||
|
|
if msg and #msg > 0 then centerText(5, msg, msgCol) end
|
||
|
|
Buttons = {}
|
||
|
|
local keyW, keyH = (w < 44 and 2 or 3), 2
|
||
|
|
local sY = 7
|
||
|
|
for rIdx, row in ipairs(rows) do
|
||
|
|
local y = sY + (rIdx-1) * (keyH + 1)
|
||
|
|
local rowW = math.max(0, #row * (keyW + 1) - 1)
|
||
|
|
local x = math.max(1, math.floor((w - rowW) / 2) + 1)
|
||
|
|
for _, ch in ipairs(row) do
|
||
|
|
addButton(x, y, ch, keyW, keyH, {colors.black, colors.white}, function()
|
||
|
|
appendHolderChar(ch)
|
||
|
|
end)
|
||
|
|
x = x + keyW + 1
|
||
|
|
end
|
||
|
|
end
|
||
|
|
addButton(math.max(1, math.floor((w - 12) / 2) + 1), sY + 10, 'SPACE', 12, 2, {colors.gray, colors.white}, function()
|
||
|
|
appendHolderChar(' ')
|
||
|
|
end)
|
||
|
|
addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'card_home' end)
|
||
|
|
addButton(12, h-3, (keyboardMode == 'symbol') and 'ABC' or 'SYM', 8, 3, {colors.black, colors.orange}, function()
|
||
|
|
keyboardMode = (keyboardMode == 'symbol') and 'alpha' or 'symbol'
|
||
|
|
end)
|
||
|
|
addButton(w-31, h-3, 'BKSP', 8, 3, {colors.black, colors.red}, function() name = name:sub(1, -2) end)
|
||
|
|
addButton(w-21, h-3, 'CLEAR', 8, 3, {colors.black, colors.red}, function() name = '' end)
|
||
|
|
addButton(w-11, h-3, 'NEXT->', 10, 3, {colors.black, colors.green}, function()
|
||
|
|
local clean = firstString(name)
|
||
|
|
if #clean < 2 then
|
||
|
|
msg, msgCol = 'Name too short', colors.red
|
||
|
|
return
|
||
|
|
end
|
||
|
|
state.holderName = clean
|
||
|
|
state.page = 'card_topup'
|
||
|
|
end)
|
||
|
|
local ev, p1, p2, p3 = os.pullEvent()
|
||
|
|
if ev == 'mouse_click' or ev == 'monitor_touch' then
|
||
|
|
for _, b in ipairs(Buttons) do
|
||
|
|
if inRect(b, p2, p3) then clickSound(); if b.onClick then b.onClick() end; break end
|
||
|
|
end
|
||
|
|
elseif ev == 'char' then
|
||
|
|
appendHolderChar(tostring(p1 or ''))
|
||
|
|
elseif ev == 'key' and p1 == keys.backspace then
|
||
|
|
name = name:sub(1, -2)
|
||
|
|
elseif ev == 'key' and (p1 == keys.enter or p1 == keys.numPadEnter) then
|
||
|
|
local clean = firstString(name)
|
||
|
|
if #clean >= 2 then
|
||
|
|
state.holderName = clean
|
||
|
|
state.page = 'card_topup'
|
||
|
|
else
|
||
|
|
msg, msgCol = 'Name too short', colors.red
|
||
|
|
end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
local function showCardTopup()
|
||
|
|
while state.page == 'card_topup' do
|
||
|
|
local cardCfg = getCardConfig()
|
||
|
|
state.cardTopup = math.max(cardCfg.min_topup, tonumber(state.cardTopup) or cardCfg.min_topup)
|
||
|
|
state.cardBalance = state.cardTopup
|
||
|
|
state.cardDeposit = 0
|
||
|
|
state.cardOrderValue = state.cardTopup
|
||
|
|
state.cost = state.cardOrderValue
|
||
|
|
drawHeader('First Recharge', 'Holder: ' .. firstString(state.holderName, 'CARD USER'))
|
||
|
|
Buttons = {}
|
||
|
|
centerText(6, 'First Recharge: ' .. tostring(state.cardTopup), colors.yellow)
|
||
|
|
centerText(8, 'Need Pay: ' .. tostring(state.cardOrderValue), colors.red)
|
||
|
|
local colCount = 2
|
||
|
|
local btnW, btnH = 12, 3
|
||
|
|
local gap = 2
|
||
|
|
local startX = math.max(2, math.floor((w - (colCount * btnW + (colCount - 1) * gap)) / 2) + 1)
|
||
|
|
local startY = 11
|
||
|
|
for idx, amount in ipairs(cardCfg.quick_amounts) do
|
||
|
|
local row = math.floor((idx - 1) / colCount)
|
||
|
|
local col = (idx - 1) % colCount
|
||
|
|
addButton(startX + col * (btnW + gap), startY + row * (btnH + 1), tostring(amount), btnW, btnH,
|
||
|
|
{state.cardTopup == amount and colors.green or colors.gray, colors.white},
|
||
|
|
function() state.cardTopup = amount end)
|
||
|
|
end
|
||
|
|
addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'card_name' end)
|
||
|
|
addButton(math.max(12, math.floor(w / 2) - 7), h-3, '-10', 6, 3, {colors.black, colors.red}, function()
|
||
|
|
state.cardTopup = math.max(cardCfg.min_topup, state.cardTopup - 10)
|
||
|
|
end)
|
||
|
|
addButton(math.max(20, math.floor(w / 2)), h-3, '+10', 6, 3, {colors.black, colors.green}, function()
|
||
|
|
state.cardTopup = math.min(999, state.cardTopup + 10)
|
||
|
|
end)
|
||
|
|
addButton(w-11, h-3, 'NEXT->', 10, 3, {colors.black, colors.green}, function()
|
||
|
|
state.cardPaymentMode = 'local'
|
||
|
|
state.page = 'order'
|
||
|
|
end)
|
||
|
|
waitButtons()
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
local function showCardOnlineRedeem()
|
||
|
|
local code = ''
|
||
|
|
local msg, msgCol = '', colors.red
|
||
|
|
local rows = {
|
||
|
|
{'1','2','3','4','5','6','7','8','9','0'},
|
||
|
|
{'Q','W','E','R','T','Y','U','I','O','P'},
|
||
|
|
{'A','S','D','F','G','H','J','K','L'},
|
||
|
|
{'Z','X','C','V','B','N','M'}
|
||
|
|
}
|
||
|
|
|
||
|
|
local function submitCode()
|
||
|
|
if #code ~= 5 then
|
||
|
|
msg, msgCol = 'Need 5 chars', colors.red
|
||
|
|
return
|
||
|
|
end
|
||
|
|
local res = fetchJSON(API_BASE .. '/public/ic-cards/orders/' .. code)
|
||
|
|
if not (res and res.ok) then
|
||
|
|
res = fetchJSON(API_BASE .. '/public/orders/' .. code)
|
||
|
|
end
|
||
|
|
if not (res and res.ok) then
|
||
|
|
msg, msgCol = 'Voucher Invalid', colors.red
|
||
|
|
return
|
||
|
|
end
|
||
|
|
local d = res.data or res
|
||
|
|
local cardStatus = tostring(d.status or ''):lower()
|
||
|
|
if d.consumed or (cardStatus ~= '' and cardStatus ~= 'pending_pickup' and isCardOrderLike(d)) then
|
||
|
|
msg, msgCol = 'Already Used!', colors.red
|
||
|
|
return
|
||
|
|
end
|
||
|
|
local cardOrder = buildCardOrderState(d, code)
|
||
|
|
if not cardOrder then
|
||
|
|
msg, msgCol = 'Not a card order', colors.red
|
||
|
|
return
|
||
|
|
end
|
||
|
|
resetCardFlow('redeem')
|
||
|
|
state.holderName = cardOrder.holderName
|
||
|
|
state.cardDeposit = cardOrder.deposit
|
||
|
|
state.cardTopup = cardOrder.topup
|
||
|
|
state.cardBalance = cardOrder.balance
|
||
|
|
state.cardOrderValue = cardOrder.orderValue
|
||
|
|
state.cost = state.cardOrderValue
|
||
|
|
state.paid = 0
|
||
|
|
state.voucher_code = code
|
||
|
|
state.cardPaymentMode = 'local'
|
||
|
|
state.card_server_data = cardOrder.raw
|
||
|
|
state.page = 'order'
|
||
|
|
end
|
||
|
|
|
||
|
|
while state.page == 'card_online' do
|
||
|
|
local placeholder = code .. string.rep('_', math.max(0, 5-#code))
|
||
|
|
drawHeader('Redeem IC Card', 'Type 5 chars then OK')
|
||
|
|
centerText(4, '[' .. placeholder .. ']', colors.yellow)
|
||
|
|
if msg and #msg > 0 then centerText(5, msg, msgCol) end
|
||
|
|
Buttons = {}
|
||
|
|
local keyW, keyH = (w < 44 and 2 or 3), 2
|
||
|
|
local kbW = 10 * (keyW + 1)
|
||
|
|
local sX, sY = math.max(1, math.floor((w - kbW) / 2) + 1), 7
|
||
|
|
for rIdx, row in ipairs(rows) do
|
||
|
|
local y, x = sY + (rIdx-1) * (keyH + 1), sX + (rIdx-1)
|
||
|
|
for _, ch in ipairs(row) do
|
||
|
|
addButton(x, y, ch, keyW, keyH, {colors.black, colors.white}, function()
|
||
|
|
if #code < 5 then code = code .. ch end
|
||
|
|
end)
|
||
|
|
x = x + keyW + 1
|
||
|
|
end
|
||
|
|
end
|
||
|
|
addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'card_home' end)
|
||
|
|
addButton(w-31, h-3, 'BKSP', 8, 3, {colors.black, colors.red}, function() code = code:sub(1, -2) end)
|
||
|
|
addButton(w-21, h-3, 'CLEAR', 8, 3, {colors.black, colors.red}, function() code = '' end)
|
||
|
|
addButton(w-11, h-3, 'OK', 10, 3, {colors.black, colors.green}, submitCode)
|
||
|
|
local ev, p1, p2, p3 = os.pullEvent()
|
||
|
|
if ev == 'mouse_click' or ev == 'monitor_touch' then
|
||
|
|
for _, b in ipairs(Buttons) do
|
||
|
|
if inRect(b, p2, p3) then clickSound(); if b.onClick then b.onClick() end; break end
|
||
|
|
end
|
||
|
|
elseif ev == 'char' and #code < 5 then
|
||
|
|
code = code .. tostring(p1 or ''):upper()
|
||
|
|
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()
|
||
|
|
end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
local ui_selected_code, ui_scroll_offset = nil, 0
|
||
|
|
local function renderLinesSelection(title, selectedCode, backPage, nextPage, infoLine)
|
||
|
|
drawHeader(title, infoLine or 'Tap a station to select')
|
||
|
|
Buttons = {}; local startY, endY = 5, h - 4
|
||
|
|
local sbX = w - 3
|
||
|
|
|
||
|
|
local function computeHeight()
|
||
|
|
local y = startY
|
||
|
|
for _, l in ipairs(CFG.lines) do
|
||
|
|
y = y + 2; local x = 2
|
||
|
|
for _, sc in ipairs((type(l) == 'table' and type(l.stations) == 'table' and l.stations) or {}) do
|
||
|
|
local c = normalizeCode(sc)
|
||
|
|
local st = stationByCode[c]
|
||
|
|
local label = (st and (st.en_name or st.name)) or c
|
||
|
|
local bw = #label + 1
|
||
|
|
if x + bw > sbX - 1 then y = y + 4; x = 2 end
|
||
|
|
x = x + bw + 2
|
||
|
|
end
|
||
|
|
y = y + 4
|
||
|
|
end
|
||
|
|
return y - startY
|
||
|
|
end
|
||
|
|
|
||
|
|
local ch = computeHeight(); local maxO = math.max(0, ch - (endY-startY+1))
|
||
|
|
ui_scroll_offset = math.min(ui_scroll_offset, maxO)
|
||
|
|
|
||
|
|
addButton(sbX, startY-1, '^', 3, 1, {colors.black, colors.white}, function() ui_scroll_offset = math.max(0, ui_scroll_offset-4) end)
|
||
|
|
addButton(sbX, endY+1, 'v', 3, 1, {colors.black, colors.white}, function() ui_scroll_offset = math.min(maxO, ui_scroll_offset+4) end)
|
||
|
|
|
||
|
|
local y = startY
|
||
|
|
for _, l in ipairs(CFG.lines) do
|
||
|
|
local py = y - ui_scroll_offset
|
||
|
|
if py >= startY and py <= endY then
|
||
|
|
termDev.setTextColor(nearestCCColor(type(l) == 'table' and l.color or nil)); termDev.setCursorPos(2, py); termDev.write(tostring(type(l) == 'table' and l.en_name or ''))
|
||
|
|
end
|
||
|
|
y = y + 1
|
||
|
|
local by = y - ui_scroll_offset
|
||
|
|
if by >= startY and by <= endY then
|
||
|
|
local cc = nearestCCColor(type(l) == 'table' and l.color or nil); termDev.setBackgroundColor(cc); termDev.setCursorPos(2, by); termDev.write(string.rep(' ', sbX-4))
|
||
|
|
termDev.setBackgroundColor(colors.black)
|
||
|
|
end
|
||
|
|
y = y + 1; local x = 2
|
||
|
|
for _, sc in ipairs((type(l) == 'table' and type(l.stations) == 'table' and l.stations) or {}) do
|
||
|
|
local c = normalizeCode(sc)
|
||
|
|
local st = stationByCode[c]
|
||
|
|
local label = (st and (st.en_name or st.name)) or c
|
||
|
|
local bw = #label + 1
|
||
|
|
if x + bw > sbX - 1 then y = y + 4; x = 2 end
|
||
|
|
local ry = y - ui_scroll_offset
|
||
|
|
if ry >= startY and ry+2 <= endY then
|
||
|
|
addButton(x, ry, label, bw, 3, {selectedCode == c and colors.green or colors.gray, colors.white}, function() ui_selected_code = c end)
|
||
|
|
end
|
||
|
|
x = x + bw + 2
|
||
|
|
end
|
||
|
|
y = y + 4
|
||
|
|
end
|
||
|
|
|
||
|
|
addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = backPage end)
|
||
|
|
if selectedCode then
|
||
|
|
addButton(w-15, h-3, 'NEXT->', 10, 3, {colors.black, colors.green}, function() state.page = nextPage end)
|
||
|
|
end
|
||
|
|
return selectedCode
|
||
|
|
end
|
||
|
|
|
||
|
|
local function showDeparture()
|
||
|
|
local sel; stopAudio(); playAudioFile('Audio/xzqd.wav')
|
||
|
|
while state.page == 'departure' do
|
||
|
|
sel = renderLinesSelection('Select Departure', sel, 'home', 'terminal', 'From: ' .. stationDisplay(sel)); addCancelButton()
|
||
|
|
waitButtons()
|
||
|
|
if ui_selected_code then
|
||
|
|
sel = ui_selected_code
|
||
|
|
ui_selected_code = nil
|
||
|
|
sel = normalizeCode(sel)
|
||
|
|
state.departure = sel
|
||
|
|
end
|
||
|
|
if ui_cancel_request then
|
||
|
|
renderConfirmCancel()
|
||
|
|
waitButtons()
|
||
|
|
if ui_cancel_confirmed then stopAudio(); state.page = 'home'; ui_cancel_confirmed = false end
|
||
|
|
ui_cancel_request = false
|
||
|
|
end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
local function showTerminal()
|
||
|
|
local sel; stopAudio(); playAudioFile('Audio/xzzd.wav')
|
||
|
|
local invalidUntil = 0
|
||
|
|
while state.page == 'terminal' do
|
||
|
|
local info = 'From: ' .. stationDisplay(state.departure) .. ' To: ' .. stationDisplay(sel)
|
||
|
|
if invalidUntil > os.epoch('utc') then
|
||
|
|
info = info .. ' (Invalid: same station)'
|
||
|
|
end
|
||
|
|
sel = renderLinesSelection('Select Terminal', sel, 'departure', 'type', info); addCancelButton()
|
||
|
|
waitButtons()
|
||
|
|
if ui_selected_code then
|
||
|
|
sel = ui_selected_code
|
||
|
|
ui_selected_code = nil
|
||
|
|
sel = normalizeCode(sel)
|
||
|
|
if sameLogicalStation(sel, state.departure) then
|
||
|
|
invalidUntil = os.epoch('utc') + 1500
|
||
|
|
sel = nil
|
||
|
|
state.terminal = nil
|
||
|
|
showAlert('Same station')
|
||
|
|
else
|
||
|
|
state.terminal = sel
|
||
|
|
end
|
||
|
|
end
|
||
|
|
if ui_cancel_request then
|
||
|
|
renderConfirmCancel()
|
||
|
|
waitButtons()
|
||
|
|
if ui_cancel_confirmed then stopAudio(); state.page = 'home'; ui_cancel_confirmed = false end
|
||
|
|
ui_cancel_request = false
|
||
|
|
end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
local function showType()
|
||
|
|
stopAudio(); playAudioFile('Audio/xzlc.wav')
|
||
|
|
while state.page == 'type' do
|
||
|
|
drawHeader('Select Train Type', 'From: ' .. stationDisplay(state.departure) .. ' To: ' .. stationDisplay(state.terminal))
|
||
|
|
Buttons = {}
|
||
|
|
local btnW, btnH = 12, 3
|
||
|
|
local y = math.floor(h/2) - 2
|
||
|
|
local x1, x2 = math.floor(w/2) - btnW - 2, math.floor(w/2) + 3
|
||
|
|
if x1 < 2 or (x2 + btnW) > w-1 then
|
||
|
|
local cx = math.floor((w - btnW) / 2) + 1
|
||
|
|
addButton(cx, y-2, 'Local', btnW, btnH, {state.trainType == 'Local' and colors.green or colors.gray, colors.white}, function() state.trainType = 'Local' end)
|
||
|
|
addButton(cx, y+2, 'Express', btnW, btnH, {state.trainType == 'Express' and colors.blue or colors.gray, colors.white}, function() state.trainType = 'Express' end)
|
||
|
|
else
|
||
|
|
addButton(x1, y, 'Local', btnW, btnH, {state.trainType == 'Local' and colors.green or colors.gray, colors.white}, function() state.trainType = 'Local' end)
|
||
|
|
addButton(x2, y, 'Express', btnW, btnH, {state.trainType == 'Express' and colors.blue or colors.gray, colors.white}, function() state.trainType = 'Express' end)
|
||
|
|
end
|
||
|
|
addCancelButton()
|
||
|
|
addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'terminal' end)
|
||
|
|
if state.trainType then addButton(w-9, h-3, 'NEXT->', 8, 3, {colors.black, colors.green}, function() state.page = 'trips' end) end
|
||
|
|
waitButtons()
|
||
|
|
if ui_cancel_request then
|
||
|
|
renderConfirmCancel()
|
||
|
|
waitButtons()
|
||
|
|
if ui_cancel_confirmed then stopAudio(); state.page = 'home'; ui_cancel_confirmed = false end
|
||
|
|
ui_cancel_request = false
|
||
|
|
end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
local function showTrips()
|
||
|
|
while state.page == 'trips' do
|
||
|
|
drawHeader('Select Trips', 'From: ' .. stationDisplay(state.departure) .. ' To: ' .. stationDisplay(state.terminal))
|
||
|
|
Buttons = {}
|
||
|
|
local y = math.floor(h/2) - 2
|
||
|
|
local midW = 14
|
||
|
|
local xMid = math.floor((w - midW) / 2) + 1
|
||
|
|
addButton(xMid, y, tostring(state.trips) .. (state.trips == 1 and ' TRIP' or ' TRIPS'), midW, 3, {colors.gray, colors.white}, function() end)
|
||
|
|
addButton(xMid-5, y, '+', 4, 3, {colors.black, colors.green}, function() state.trips = math.min(99, (tonumber(state.trips) or 1) + 1) end)
|
||
|
|
addButton(xMid+midW+1, y, '-', 4, 3, {colors.black, colors.red}, function() state.trips = math.max(1, (tonumber(state.trips) or 1) - 1) end)
|
||
|
|
addCancelButton()
|
||
|
|
addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'type' end)
|
||
|
|
addButton(w-9, h-3, 'NEXT->', 8, 3, {colors.black, colors.green}, function() state.page = 'order' end)
|
||
|
|
waitButtons()
|
||
|
|
if ui_cancel_request then
|
||
|
|
renderConfirmCancel()
|
||
|
|
waitButtons()
|
||
|
|
if ui_cancel_confirmed then stopAudio(); state.page = 'home'; ui_cancel_confirmed = false end
|
||
|
|
ui_cancel_request = false
|
||
|
|
end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
local function computeCost(src, dst, trainType)
|
||
|
|
src = normalizeCode(src)
|
||
|
|
dst = normalizeCode(dst)
|
||
|
|
if sameLogicalStation(src, dst) then return 0 end
|
||
|
|
local adj = trainType == 'Express' and adjacency_express or adjacency_regular
|
||
|
|
|
||
|
|
local heapN, heapD = {}, {}
|
||
|
|
local function heapPush(n, d)
|
||
|
|
local i = #heapN + 1
|
||
|
|
heapN[i] = n; heapD[i] = d
|
||
|
|
while i > 1 do
|
||
|
|
local p = math.floor(i / 2)
|
||
|
|
if heapD[p] <= heapD[i] then break end
|
||
|
|
heapN[p], heapN[i] = heapN[i], heapN[p]
|
||
|
|
heapD[p], heapD[i] = heapD[i], heapD[p]
|
||
|
|
i = p
|
||
|
|
end
|
||
|
|
end
|
||
|
|
local function heapPop()
|
||
|
|
if #heapN == 0 then return nil end
|
||
|
|
local n, d = heapN[1], heapD[1]
|
||
|
|
local ln, ld = heapN[#heapN], heapD[#heapD]
|
||
|
|
heapN[#heapN], heapD[#heapD] = nil, nil
|
||
|
|
if #heapN > 0 then
|
||
|
|
heapN[1], heapD[1] = ln, ld
|
||
|
|
local i = 1
|
||
|
|
while true do
|
||
|
|
local l = i * 2
|
||
|
|
local r = l + 1
|
||
|
|
if l > #heapN then break end
|
||
|
|
local m = l
|
||
|
|
if r <= #heapN and heapD[r] < heapD[l] then m = r end
|
||
|
|
if heapD[i] <= heapD[m] then break end
|
||
|
|
heapN[i], heapN[m] = heapN[m], heapN[i]
|
||
|
|
heapD[i], heapD[m] = heapD[m], heapD[i]
|
||
|
|
i = m
|
||
|
|
end
|
||
|
|
end
|
||
|
|
return n, d
|
||
|
|
end
|
||
|
|
|
||
|
|
local dist = { [src] = 0 }
|
||
|
|
heapPush(src, 0)
|
||
|
|
while true do
|
||
|
|
local u, du = heapPop()
|
||
|
|
if not u then break end
|
||
|
|
if du == dist[u] then
|
||
|
|
if u == dst then return du end
|
||
|
|
for v, w in pairs(adj[u] or {}) do
|
||
|
|
local nd = du + (tonumber(w) or 0)
|
||
|
|
if nd < (dist[v] or math.huge) then
|
||
|
|
dist[v] = nd
|
||
|
|
heapPush(v, nd)
|
||
|
|
end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
return nil
|
||
|
|
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'
|
||
|
|
drawHeader('Confirm Card', cardSub)
|
||
|
|
else
|
||
|
|
local depLine = stationDisplay(state.departure)
|
||
|
|
local terLine = stationDisplay(state.terminal)
|
||
|
|
drawHeader('Confirm Order', 'From: ' .. depLine .. ' To: ' .. terLine)
|
||
|
|
end
|
||
|
|
local y = 5
|
||
|
|
local function L(label, value, col)
|
||
|
|
termDev.setTextColor(colors.white)
|
||
|
|
termDev.setCursorPos(2, y); termDev.write(label .. ': ')
|
||
|
|
termDev.setTextColor(col or colors.lightBlue)
|
||
|
|
termDev.write(tostring(value or 'N/A'))
|
||
|
|
y = y + 2
|
||
|
|
end
|
||
|
|
|
||
|
|
local cost = tonumber(state.cost)
|
||
|
|
local paid = tonumber(state.paid) or 0
|
||
|
|
local total = tonumber(state.cost)
|
||
|
|
if state.productMode == 'card' then
|
||
|
|
L('Action', (state.cardMode == 'redeem') and 'Online Redeem' or 'Open Card', colors.lightBlue)
|
||
|
|
L('Name', firstString(state.holderName, 'CARD USER'), colors.yellow)
|
||
|
|
L('Top-up', tonumber(state.cardTopup) or 0, colors.lightBlue)
|
||
|
|
L('Balance', tonumber(state.cardBalance) or 0, colors.cyan)
|
||
|
|
L('Order', tonumber(state.cardOrderValue) or 0, colors.red)
|
||
|
|
if state.cardPaymentMode == 'online' then
|
||
|
|
L('Payment', 'Paid Online', colors.green)
|
||
|
|
else
|
||
|
|
L('Pay Now', total or 0, colors.red)
|
||
|
|
end
|
||
|
|
if state.voucher_code then
|
||
|
|
L('Voucher', tostring(state.voucher_code), colors.cyan)
|
||
|
|
end
|
||
|
|
else
|
||
|
|
L('Type', state.trainType or 'N/A', colors.lightBlue)
|
||
|
|
local rides = tonumber(state.trips) or 1
|
||
|
|
if cost == nil then
|
||
|
|
local unit = computeCost(state.departure or '', state.terminal or '', state.trainType or 'Local')
|
||
|
|
cost = unit and (unit * rides) or nil
|
||
|
|
end
|
||
|
|
L('Trips', rides)
|
||
|
|
L('Cost', cost, colors.red)
|
||
|
|
if state.voucher_code then
|
||
|
|
L('Voucher', tostring(state.voucher_code), colors.cyan)
|
||
|
|
end
|
||
|
|
local discount = (CFG.promotion and CFG.promotion.discount) or 1
|
||
|
|
if discount > 0 and discount < 1 then
|
||
|
|
local promoName = (CFG.promotion and CFG.promotion.name) or 'Promo'
|
||
|
|
local perc = math.floor(discount * 100)
|
||
|
|
local promoText = ('[PROMO] %s • Discount: %d%%'):format(promoName, perc)
|
||
|
|
drawRainbowLabelRow(y, promoText, colors.black)
|
||
|
|
y = y + 2
|
||
|
|
end
|
||
|
|
end
|
||
|
|
if total == nil then total = cost or 0 end
|
||
|
|
local remain = math.max(0, total - paid)
|
||
|
|
local statusY = math.max(1, h - 6)
|
||
|
|
local barW = math.max(10, w - 10)
|
||
|
|
local ratio = (total <= 0) and 1 or math.min(1, paid / math.max(1, total))
|
||
|
|
local fill = math.floor(barW * ratio)
|
||
|
|
local barX = math.max(1, math.floor((w - barW) / 2) + 1)
|
||
|
|
termDev.setCursorPos(barX, statusY)
|
||
|
|
termDev.setTextColor(colors.green)
|
||
|
|
termDev.write(string.rep('=', fill))
|
||
|
|
termDev.setTextColor(colors.gray)
|
||
|
|
termDev.write(string.rep('-', math.max(0, barW - fill)))
|
||
|
|
statusY = statusY + 1
|
||
|
|
if remain <= 0 then
|
||
|
|
termDev.setTextColor(colors.green)
|
||
|
|
centerText(statusY, ('Paid: %d / %d (OK)'):format(paid, total), colors.green)
|
||
|
|
else
|
||
|
|
termDev.setTextColor(colors.red)
|
||
|
|
centerText(statusY, ('Paid: %d / %d Remaining: %d'):format(paid, total, remain), colors.red)
|
||
|
|
end
|
||
|
|
if total <= 0 then
|
||
|
|
centerText(statusY + 1, 'Ready to confirm', colors.lightGray)
|
||
|
|
else
|
||
|
|
centerText(statusY + 1, 'Insert payment on RIGHT side', colors.lightGray)
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
local function showOrderAndAudio()
|
||
|
|
state.paid = 0; state.doneAudioPlayed = false; state.order_datetime = os.date('%Y/%m/%d %H:%M:%S')
|
||
|
|
|
||
|
|
-- Recalculate cost immediately to avoid stale data
|
||
|
|
if state.productMode == 'card' then
|
||
|
|
state.cardDeposit = 0
|
||
|
|
state.cardOrderValue = tonumber(state.cardOrderValue) or (tonumber(state.cardTopup) or 0)
|
||
|
|
state.cardBalance = tonumber(state.cardBalance) or tonumber(state.cardTopup) or 0
|
||
|
|
if state.cardPaymentMode ~= 'online' then
|
||
|
|
state.cost = state.cardOrderValue
|
||
|
|
elseif state.cost == nil then
|
||
|
|
state.cost = 0
|
||
|
|
end
|
||
|
|
elseif not state.voucher_code then
|
||
|
|
local unit = computeCost(state.departure or '', state.terminal or '', state.trainType or 'Local')
|
||
|
|
if not unit then
|
||
|
|
showAlert('No route')
|
||
|
|
state.page = 'terminal'
|
||
|
|
return
|
||
|
|
end
|
||
|
|
state.cost = unit * (state.trips or 1)
|
||
|
|
else
|
||
|
|
if state.cost == nil then state.cost = 0 end
|
||
|
|
end
|
||
|
|
|
||
|
|
if state.productMode ~= 'card' and sameLogicalStation(state.departure, state.terminal) then
|
||
|
|
showAlert('Same station')
|
||
|
|
state.page = state.voucher_code and 'online' or 'terminal'
|
||
|
|
return
|
||
|
|
end
|
||
|
|
|
||
|
|
local confirmed = false
|
||
|
|
local processing = false
|
||
|
|
local statusMsg, statusCol = '', colors.red
|
||
|
|
local render = nil
|
||
|
|
|
||
|
|
local function confirmAction()
|
||
|
|
if processing or confirmed then return end
|
||
|
|
if state.productMode ~= 'card' and sameLogicalStation(state.departure, state.terminal) then
|
||
|
|
showAlert('Same station')
|
||
|
|
if render then render() end
|
||
|
|
return
|
||
|
|
end
|
||
|
|
processing = true
|
||
|
|
statusMsg, statusCol = 'Processing...', colors.yellow
|
||
|
|
if render then render() end
|
||
|
|
if state.productMode == 'card' then
|
||
|
|
local payload = {
|
||
|
|
holder_name = firstString(state.holderName, 'CARD USER'),
|
||
|
|
deposit = tonumber(state.cardDeposit) or 0,
|
||
|
|
topup = tonumber(state.cardTopup) or 0,
|
||
|
|
balance = tonumber(state.cardBalance) or 0,
|
||
|
|
order_value = tonumber(state.cardOrderValue) or 0,
|
||
|
|
voucher_code = state.voucher_code,
|
||
|
|
station_code = state.stationCode or CURRENT_STATION_CODE,
|
||
|
|
device = currentDeviceId(),
|
||
|
|
card_mode = state.cardMode or 'open',
|
||
|
|
payment_mode = state.cardPaymentMode or 'local',
|
||
|
|
amount_paid = tonumber(state.paid) or 0
|
||
|
|
}
|
||
|
|
local reuseBlankCardId = firstString(state.pendingBlankCardId)
|
||
|
|
if #reuseBlankCardId > 0 then
|
||
|
|
payload.card_id = reuseBlankCardId
|
||
|
|
end
|
||
|
|
local okIssueBlank, blankCardId, issueMethod = false, '', 'reuse_pending'
|
||
|
|
if #reuseBlankCardId == 0 then
|
||
|
|
okIssueBlank, blankCardId, issueMethod = issueBlankICCard(payload.holder_name, payload.balance)
|
||
|
|
else
|
||
|
|
okIssueBlank, blankCardId = true, reuseBlankCardId
|
||
|
|
end
|
||
|
|
if okIssueBlank and #blankCardId > 0 then
|
||
|
|
payload.card_id = blankCardId
|
||
|
|
state.pendingBlankCardId = blankCardId
|
||
|
|
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
|
||
|
|
else
|
||
|
|
local errorMsg = 'Card API Err'
|
||
|
|
if code == 409 then
|
||
|
|
errorMsg = 'Already Used!'
|
||
|
|
elseif err and #tostring(err) > 0 then
|
||
|
|
errorMsg = tostring(err)
|
||
|
|
elseif code and type(code) == 'number' then
|
||
|
|
errorMsg = 'HTTP ' .. tostring(code)
|
||
|
|
end
|
||
|
|
statusMsg, statusCol = 'Issued, retry sync: ' .. errorMsg, colors.red
|
||
|
|
end
|
||
|
|
elseif okIssueBlank then
|
||
|
|
statusMsg, statusCol = 'Card issued without ID', colors.red
|
||
|
|
else
|
||
|
|
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 okWrite, writtenCard, writeMethod = writeICCard(finalCard)
|
||
|
|
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
|
||
|
|
errorMsg = 'Already Used!'
|
||
|
|
elseif err and #tostring(err) > 0 then
|
||
|
|
errorMsg = tostring(err)
|
||
|
|
elseif code and type(code) == 'number' then
|
||
|
|
errorMsg = 'HTTP ' .. tostring(code)
|
||
|
|
end
|
||
|
|
statusMsg, statusCol = errorMsg, colors.red
|
||
|
|
end
|
||
|
|
end
|
||
|
|
elseif state.voucher_code then
|
||
|
|
local url = API_BASE .. '/public/orders/' .. state.voucher_code .. '/consume'
|
||
|
|
local okReq, code = postJSONResult(url, {})
|
||
|
|
if okReq and code and code >= 200 and code < 300 then
|
||
|
|
confirmed = true
|
||
|
|
if not state.doneAudioPlayed then playConfirmTicketMelody(); state.doneAudioPlayed = true end
|
||
|
|
else
|
||
|
|
local errorMsg = 'NetErr'
|
||
|
|
if code == 409 then
|
||
|
|
errorMsg = 'Already Used!'
|
||
|
|
elseif code and type(code) == 'number' then
|
||
|
|
errorMsg = 'HTTP ' .. tostring(code)
|
||
|
|
end
|
||
|
|
statusMsg, statusCol = errorMsg, colors.red
|
||
|
|
end
|
||
|
|
else
|
||
|
|
confirmed = true
|
||
|
|
local totalCost = tonumber(state.cost) or 0
|
||
|
|
if totalCost <= 0 and not state.doneAudioPlayed then playConfirmTicketMelody(); state.doneAudioPlayed = true end
|
||
|
|
end
|
||
|
|
processing = false
|
||
|
|
if render and not confirmed then render() end
|
||
|
|
end
|
||
|
|
|
||
|
|
render = function()
|
||
|
|
Buttons = {}
|
||
|
|
drawOrder()
|
||
|
|
addCancelButton()
|
||
|
|
local same = (state.productMode ~= 'card') and sameLogicalStation(state.departure, state.terminal)
|
||
|
|
local costV = tonumber(state.cost)
|
||
|
|
local paidV = tonumber(state.paid) or 0
|
||
|
|
local canConfirm = (not same) and ((costV or 0) <= 0 or (costV ~= nil and paidV >= costV))
|
||
|
|
local msgY = h - 2
|
||
|
|
if statusMsg and #statusMsg > 0 then centerText(msgY, statusMsg, statusCol) end
|
||
|
|
addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function()
|
||
|
|
stopAudio()
|
||
|
|
if state.productMode == 'card' then
|
||
|
|
state.page = state.voucher_code and 'card_online' or 'card_topup'
|
||
|
|
else
|
||
|
|
state.page = state.voucher_code and 'online' or 'trips'
|
||
|
|
end
|
||
|
|
end)
|
||
|
|
if canConfirm then
|
||
|
|
if processing then
|
||
|
|
addButton(w-12, h-3, 'WAIT', 10, 3, {colors.gray, colors.white}, function() end)
|
||
|
|
else
|
||
|
|
addButton(w-12, h-3, 'CONFIRM', 10, 3, {colors.green, colors.black}, confirmAction)
|
||
|
|
end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
render()
|
||
|
|
|
||
|
|
-- If cost is zero, auto-confirm immediately after a brief delay for audio
|
||
|
|
if ((state.productMode == 'card') or (not sameLogicalStation(state.departure, state.terminal))) and ((tonumber(state.cost) or 0) <= 0) then
|
||
|
|
sleep(0.5)
|
||
|
|
confirmAction()
|
||
|
|
end
|
||
|
|
|
||
|
|
local prev = redstone.getInput('right')
|
||
|
|
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
|
||
|
|
playNote('hat', 20, 1, 0.01)
|
||
|
|
state.paid = (state.paid or 0) + 1; render()
|
||
|
|
if state.paid >= (state.cost or 0) then
|
||
|
|
if not state.doneAudioPlayed then
|
||
|
|
playConfirmTicketMelody(); state.doneAudioPlayed = true
|
||
|
|
end
|
||
|
|
-- Auto-confirm when paid enough
|
||
|
|
sleep(0.5) -- Wait for UI/Audio slightly
|
||
|
|
confirmAction()
|
||
|
|
end
|
||
|
|
end; prev = now
|
||
|
|
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
|
||
|
|
for _, b in ipairs(Buttons) do
|
||
|
|
if inRect(b, p2, p3) then
|
||
|
|
clickSound()
|
||
|
|
if b.onClick then b.onClick() end;
|
||
|
|
render()
|
||
|
|
break
|
||
|
|
end
|
||
|
|
end
|
||
|
|
if ui_cancel_request then
|
||
|
|
renderConfirmCancel()
|
||
|
|
waitButtons()
|
||
|
|
if ui_cancel_confirmed then stopAudio(); state.page = 'home'; ui_cancel_confirmed = false; break end
|
||
|
|
ui_cancel_request = false
|
||
|
|
render()
|
||
|
|
end
|
||
|
|
elseif ev == 'config_updated' then
|
||
|
|
render()
|
||
|
|
end
|
||
|
|
if confirmed then state.page = 'done'; break end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
local function showPrePrintCheck()
|
||
|
|
while state.page == 'preprint' do
|
||
|
|
state.page = 'done'
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
local function generateTicketId()
|
||
|
|
local function randLetter()
|
||
|
|
return string.char(string.byte('A') + math.random(0, 25))
|
||
|
|
end
|
||
|
|
local prefix = randLetter() .. randLetter()
|
||
|
|
local num = string.format('%08d', math.random(0, 99999999))
|
||
|
|
return prefix .. '-' .. num
|
||
|
|
end
|
||
|
|
|
||
|
|
local function ensureTicketIdFormat(id)
|
||
|
|
if id == nil then return generateTicketId() end
|
||
|
|
local s = tostring(id)
|
||
|
|
s = s:gsub('%s+', '')
|
||
|
|
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
|
||
|
|
elseif #num > 8 then
|
||
|
|
num = num:sub(-8)
|
||
|
|
end
|
||
|
|
return prefix .. '-' .. num
|
||
|
|
end
|
||
|
|
return s:lower()
|
||
|
|
end
|
||
|
|
|
||
|
|
|
||
|
|
|
||
|
|
local function showDone()
|
||
|
|
if state.productMode == 'card' then
|
||
|
|
drawHeader('Card Ready', 'Please take your IC card')
|
||
|
|
local y = 5
|
||
|
|
local function line(label, value, col)
|
||
|
|
termDev.setTextColor(colors.white)
|
||
|
|
termDev.setCursorPos(2, y); termDev.write(label .. ': ')
|
||
|
|
termDev.setTextColor(col or colors.lightBlue)
|
||
|
|
termDev.write(tostring(value or ''))
|
||
|
|
y = y + 2
|
||
|
|
end
|
||
|
|
line('Name', firstString(state.holderName, 'CARD USER'), colors.yellow)
|
||
|
|
line('Deposit', tonumber(state.cardDeposit) or 0, colors.lightBlue)
|
||
|
|
line('Top-up', tonumber(state.cardTopup) or 0, colors.lightBlue)
|
||
|
|
line('Balance', tonumber(state.cardBalance) or 0, colors.green)
|
||
|
|
line('Card ID', firstString(state.card_id, 'PENDING'), colors.cyan)
|
||
|
|
if state.voucher_code then line('Voucher', state.voucher_code, colors.cyan) end
|
||
|
|
saveCardIssueSnapshot({
|
||
|
|
card_id = state.card_id,
|
||
|
|
holder_name = state.holderName,
|
||
|
|
deposit = state.cardDeposit,
|
||
|
|
topup = state.cardTopup,
|
||
|
|
balance = state.cardBalance,
|
||
|
|
voucher_code = state.voucher_code,
|
||
|
|
station_code = state.stationCode or CURRENT_STATION_CODE,
|
||
|
|
ts = (os.epoch and os.epoch('utc')) or (os.time() * 1000)
|
||
|
|
})
|
||
|
|
for i = 5, 1, -1 do
|
||
|
|
centerText(h-4, 'Returning to Home: ' .. i .. 's', colors.red)
|
||
|
|
sleep(1)
|
||
|
|
end
|
||
|
|
resetTicketFlow()
|
||
|
|
state.page = 'home'
|
||
|
|
return
|
||
|
|
end
|
||
|
|
local rides = tonumber(state.trips) or 1
|
||
|
|
local orderDatetime = state.order_datetime or os.date('%Y/%m/%d %H:%M:%S')
|
||
|
|
local rawCost = tonumber(state.cost)
|
||
|
|
if (not state.voucher_code) and rawCost == nil then
|
||
|
|
local unit = computeCost(state.departure or '', state.terminal or '', state.trainType or 'Local')
|
||
|
|
rawCost = unit and (unit * rides) or 0
|
||
|
|
state.cost = rawCost
|
||
|
|
end
|
||
|
|
local cost = rawCost or 0
|
||
|
|
local issueSource = 'local'
|
||
|
|
local startCode = normalizeCode(state.departure)
|
||
|
|
local terminalCode = normalizeCode(state.terminal)
|
||
|
|
local startObj = stationByCode[startCode]
|
||
|
|
local terminalObj = stationByCode[terminalCode]
|
||
|
|
local fromNameEn = (startObj and (startObj.en_name or startObj.name)) or state.departure
|
||
|
|
local toNameEn = (terminalObj and (terminalObj.en_name or terminalObj.name)) or state.terminal
|
||
|
|
local fromNameCnU = (startObj and unicodeEscape(startObj.name)) or ''
|
||
|
|
local toNameCnU = (terminalObj and unicodeEscape(terminalObj.name)) or ''
|
||
|
|
local startStationArg = tostring(startCode or '')
|
||
|
|
local terminalStationArg = tostring(terminalCode or '')
|
||
|
|
local fromNameEnArg = tostring(fromNameEn or '???')
|
||
|
|
local toNameEnArg = tostring(toNameEn or '???')
|
||
|
|
local fromNameCnUArg = tostring(fromNameCnU or '')
|
||
|
|
local toNameCnUArg = tostring(toNameCnU or '')
|
||
|
|
local issueArgs = {
|
||
|
|
start_name_en = fromNameEnArg,
|
||
|
|
terminal_name_en = toNameEnArg,
|
||
|
|
type = (state.trainType == 'Express') and 'limited_express' or 'local',
|
||
|
|
rides = rides,
|
||
|
|
cost = cost,
|
||
|
|
start_station = startStationArg,
|
||
|
|
terminal_station = terminalStationArg,
|
||
|
|
fromNameCnU = fromNameCnUArg,
|
||
|
|
toNameCnU = toNameCnUArg,
|
||
|
|
}
|
||
|
|
_G.TICKET_MACHINE_LAST_TICKET = {
|
||
|
|
start_station_id = startCode,
|
||
|
|
terminal_station_id = terminalCode,
|
||
|
|
start_station = startCode,
|
||
|
|
terminal_station = terminalCode,
|
||
|
|
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
|
||
|
|
}
|
||
|
|
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
|
||
|
|
else
|
||
|
|
state.ticket_id = generateTicketId()
|
||
|
|
end
|
||
|
|
|
||
|
|
pcall(function()
|
||
|
|
ensureDir('logs/last_ticket_issue.json')
|
||
|
|
issueArgs.ticket_id = state.ticket_id
|
||
|
|
issueArgs.issue_source = issueSource
|
||
|
|
issueArgs.ts = (os.epoch and os.epoch('utc')) or (os.time() * 1000)
|
||
|
|
local okSer, s = pcall(textutils.serializeJSON, issueArgs)
|
||
|
|
if okSer and type(s) == 'string' then
|
||
|
|
local f = fs.open('logs/last_ticket_issue.json', 'w')
|
||
|
|
if f then f.write(s); f.close() end
|
||
|
|
end
|
||
|
|
end)
|
||
|
|
|
||
|
|
local typeStr = (state.trainType == 'Express') and 'Limited Express' or 'Ordinary'
|
||
|
|
local ticketData = {
|
||
|
|
start_station = startCode,
|
||
|
|
terminal_station = terminalCode,
|
||
|
|
end_station = terminalCode,
|
||
|
|
type = typeStr,
|
||
|
|
entered = false,
|
||
|
|
exited = false,
|
||
|
|
id = state.ticket_id,
|
||
|
|
ticket_id = state.ticket_id,
|
||
|
|
trips_total = rides,
|
||
|
|
trips_remaining = rides,
|
||
|
|
cost = cost,
|
||
|
|
order_datetime = orderDatetime,
|
||
|
|
timestamp = (os.epoch and os.epoch('utc')) or (os.time() * 1000)
|
||
|
|
}
|
||
|
|
|
||
|
|
local ticketDataMod = {}
|
||
|
|
for k, v in pairs(ticketData) do ticketDataMod[k] = v end
|
||
|
|
ticketDataMod.start_station_id = startCode
|
||
|
|
ticketDataMod.terminal_station_id = terminalCode
|
||
|
|
ticketDataMod.start_station = startCode
|
||
|
|
ticketDataMod.terminal_station = terminalCode
|
||
|
|
ticketDataMod.start_name = startObj and unicodeEscape(startObj.name) or nil
|
||
|
|
ticketDataMod.terminal_name = terminalObj and unicodeEscape(terminalObj.name) or nil
|
||
|
|
ticketDataMod.start_name_en = fromNameEn
|
||
|
|
ticketDataMod.terminal_name_en = toNameEn
|
||
|
|
ticketDataMod.issue_source = issueSource
|
||
|
|
ticketDataMod.ticket_id = state.ticket_id
|
||
|
|
ticketDataMod.train_type = state.trainType or 'Local'
|
||
|
|
ticketDataMod.station_code = state.stationCode or CURRENT_STATION_CODE
|
||
|
|
ticketDataMod.device = currentDeviceId()
|
||
|
|
_G.TICKET_MACHINE_LAST_TICKET = ticketDataMod
|
||
|
|
|
||
|
|
if MOD_DEBUG then
|
||
|
|
local t = _G.TICKET_MACHINE_LAST_TICKET
|
||
|
|
print("DEBUG start_name_en=" .. tostring(t.start_name_en))
|
||
|
|
print("DEBUG terminal_name_en=" .. tostring(t.terminal_name_en))
|
||
|
|
print("DEBUG cost=" .. tostring(t.cost))
|
||
|
|
end
|
||
|
|
|
||
|
|
local okUpload, uploadRes = pcall(enqueueTicketUpload, ticketDataMod)
|
||
|
|
if not okUpload then
|
||
|
|
print('enqueueTicketUpload error: ' .. tostring(uploadRes))
|
||
|
|
end
|
||
|
|
drawHeader('Purchase Complete', 'Please take your ticket')
|
||
|
|
local y = 5
|
||
|
|
local function line(label, value, col)
|
||
|
|
termDev.setTextColor(colors.white)
|
||
|
|
termDev.setCursorPos(2, y); termDev.write(label .. ': ')
|
||
|
|
termDev.setTextColor(col or colors.lightBlue)
|
||
|
|
termDev.write(tostring(value or ''))
|
||
|
|
y = y + 2
|
||
|
|
end
|
||
|
|
line('From', fromNameEn, colors.yellow)
|
||
|
|
line('To', toNameEn, colors.yellow)
|
||
|
|
line('Type', typeStr, colors.lightBlue)
|
||
|
|
line('Trips', rides, colors.lightBlue)
|
||
|
|
line('Cost', cost, colors.red)
|
||
|
|
line('ID', state.ticket_id or '', colors.cyan)
|
||
|
|
|
||
|
|
|
||
|
|
|
||
|
|
for i = 5, 1, -1 do
|
||
|
|
centerText(h-4, 'Returning to Home: ' .. i .. 's', colors.red)
|
||
|
|
sleep(1)
|
||
|
|
end
|
||
|
|
resetTicketFlow()
|
||
|
|
state.page = 'home'
|
||
|
|
end
|
||
|
|
|
||
|
|
local function showOnlineVoucher()
|
||
|
|
local code = ''
|
||
|
|
local msg, msgCol = '', colors.red
|
||
|
|
local rows = {
|
||
|
|
{'1','2','3','4','5','6','7','8','9','0'},
|
||
|
|
{'Q','W','E','R','T','Y','U','I','O','P'},
|
||
|
|
{'A','S','D','F','G','H','J','K','L'},
|
||
|
|
{'Z','X','C','V','B','N','M'}
|
||
|
|
}
|
||
|
|
|
||
|
|
local function submitCode()
|
||
|
|
if #code == 5 then
|
||
|
|
local res = fetchJSON(API_BASE .. '/public/orders/' .. code)
|
||
|
|
if res and res.ok then
|
||
|
|
local d = res.data or res
|
||
|
|
|
||
|
|
-- Check if already consumed
|
||
|
|
if d.consumed then
|
||
|
|
msg, msgCol = 'Already Used!', colors.red
|
||
|
|
return
|
||
|
|
end
|
||
|
|
|
||
|
|
state.departure = d.start or d.from or d.departure
|
||
|
|
state.terminal = d.terminal or d.to or d.destination
|
||
|
|
state.trainType = normalizeTrainTypeLabel(d.train_type or d.type or 'Local')
|
||
|
|
state.trips = tonumber(d.trips or d.count) or 1
|
||
|
|
state.cost = tonumber(d.price) or tonumber(d.cost) or 0
|
||
|
|
state.voucher_code = code
|
||
|
|
msg = ''
|
||
|
|
state.page = 'order'
|
||
|
|
else
|
||
|
|
msg, msgCol = 'Voucher Invalid', colors.red
|
||
|
|
end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
while state.page == 'online' do
|
||
|
|
local placeholder = code .. string.rep('_', math.max(0, 5-#code))
|
||
|
|
drawHeader('Enter Voucher', 'Type 5 chars then OK')
|
||
|
|
centerText(4, '[' .. placeholder .. ']', colors.yellow)
|
||
|
|
if msg and #msg > 0 then centerText(5, msg, msgCol) end
|
||
|
|
Buttons = {}
|
||
|
|
|
||
|
|
local keyW, keyH = (w < 44 and 2 or 3), 2
|
||
|
|
local kbW = 10 * (keyW + 1)
|
||
|
|
local sX, sY = math.max(1, math.floor((w - kbW) / 2) + 1), 7
|
||
|
|
|
||
|
|
for rIdx, row in ipairs(rows) do
|
||
|
|
local y, x = sY + (rIdx-1) * (keyH + 1), sX + (rIdx-1)
|
||
|
|
for _, ch in ipairs(row) do
|
||
|
|
addButton(x, y, ch, keyW, keyH, {colors.black, colors.white}, function() if #code < 5 then code = code .. ch end end)
|
||
|
|
x = x + keyW + 1
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
local actY = h - 6
|
||
|
|
local bw, cw, ow = 10, 8, 8
|
||
|
|
local gap = 2
|
||
|
|
local total = bw + gap + cw + gap + ow
|
||
|
|
local ax = math.floor((w - total) / 2) + 1
|
||
|
|
if ax < 1 then
|
||
|
|
bw, cw, ow = 6, 6, 6
|
||
|
|
total = bw + 1 + cw + 1 + ow
|
||
|
|
ax = math.floor((w - total) / 2) + 1
|
||
|
|
addButton(math.max(1, ax), actY, 'BKSP', bw, 3, {colors.black, colors.red}, function() code = code:sub(1, -2) end)
|
||
|
|
addButton(math.max(1, ax) + bw + 1, actY, 'CLEAR', cw, 3, {colors.black, colors.red}, function() code = '' end)
|
||
|
|
addButton(math.max(1, ax) + bw + 1 + cw + 1, actY, 'OK', ow, 3, {colors.black, colors.green}, submitCode)
|
||
|
|
else
|
||
|
|
addButton(ax, actY, 'Backspace', bw, 3, {colors.black, colors.red}, function() code = code:sub(1, -2) end)
|
||
|
|
addButton(ax + bw + gap, actY, 'Clear', cw, 3, {colors.black, colors.red}, function() code = '' end)
|
||
|
|
addButton(ax + bw + gap + cw + gap, actY, 'OK', ow, 3, {colors.black, colors.green}, submitCode)
|
||
|
|
end
|
||
|
|
addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'home' end)
|
||
|
|
|
||
|
|
local ev, p1, p2, p3 = os.pullEvent()
|
||
|
|
if 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
|
||
|
|
for _, b in ipairs(Buttons) do if inRect(b, p2, p3) then clickSound(); if b.onClick then b.onClick() end; break end end
|
||
|
|
elseif ev == 'char' and #code < 5 then code = code .. p1:upper()
|
||
|
|
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
|
||
|
|
|
||
|
|
-- ###########################
|
||
|
|
-- Main entry point
|
||
|
|
-- ###########################
|
||
|
|
local function mainPageLoop()
|
||
|
|
while true do
|
||
|
|
if state.page == 'home' then showHome()
|
||
|
|
elseif state.page == 'card_home' then showCardHome()
|
||
|
|
elseif state.page == 'card_name' then showCardNameInput()
|
||
|
|
elseif state.page == 'card_topup' then showCardTopup()
|
||
|
|
elseif state.page == 'card_online' then showCardOnlineRedeem()
|
||
|
|
elseif state.page == 'departure' then showDeparture()
|
||
|
|
elseif state.page == 'terminal' then showTerminal()
|
||
|
|
elseif state.page == 'type' then showType()
|
||
|
|
elseif state.page == 'trips' then showTrips()
|
||
|
|
elseif state.page == 'order' then showOrderAndAudio()
|
||
|
|
elseif state.page == 'preprint' then showPrePrintCheck()
|
||
|
|
elseif state.page == 'done' then showDone()
|
||
|
|
elseif state.page == 'online' then showOnlineVoucher()
|
||
|
|
else state.page = 'home'; sleep(0.5) end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
parallel.waitForAny(mainPageLoop, backgroundSyncTask, backgroundTicketUploadTask)
|