WebBridge.findModem() iterated _G.device with pairs() which has non-deterministic order. On computers with both wired and wireless modems, it could return the wireless modem while the manager uses a wired modem - messages never cross the wired/wireless boundary. Now uses two-pass iteration: first looks for wired modems, then falls back to any modem. Peripheral scan fallback also prefers wired. Backward-compatible: boolean arg (preferWireless) still works.
385 lines
12 KiB
Lua
385 lines
12 KiB
Lua
--[[
|
|
platform.webbridge — Shared webbridge infrastructure for cc-platform-core
|
|
|
|
Provides the common infrastructure pattern used by all services that
|
|
bridge modem communication to an external web server. Extracted from
|
|
the structurally identical patterns in remoteturtle/webbridge.lua and
|
|
Inventory-Manager-CC/inventoryWebBridge.lua.
|
|
|
|
Components:
|
|
- Config loading (JSON file with path resolution)
|
|
- HTTP helpers (POST/GET with error handling)
|
|
- WebSocket connection manager (connect, reconnect, keepalive)
|
|
- HTTP poll→process→ack cycle (fallback transport)
|
|
- Modem channel management
|
|
- parallel.waitForAny main loop skeleton
|
|
|
|
Usage:
|
|
local WebBridge = require('platform.webbridge')
|
|
|
|
local config = WebBridge.loadConfig({
|
|
serverUrl = 'http://localhost:3001',
|
|
}, {
|
|
'usr/config/myservice/.webbridge_config',
|
|
'.webbridge_config',
|
|
})
|
|
|
|
-- HTTP helpers
|
|
local body = WebBridge.httpGet(url, headers)
|
|
local resp = WebBridge.httpPost(url, data, headers)
|
|
|
|
-- WebSocket with auto-reconnect
|
|
WebBridge.wsConnect(wsUrl, {
|
|
onConnect = function(ws) ... end,
|
|
onMessage = function(ws, data) ... end,
|
|
onDisconnect = function() ... end,
|
|
})
|
|
]]
|
|
|
|
local textutils = _G.textutils
|
|
local http = _G.http
|
|
local fs = _G.fs
|
|
local os = _G.os
|
|
local peripheral = _G.peripheral
|
|
|
|
local Channels = require('platform.channels')
|
|
|
|
local WebBridge = {}
|
|
|
|
---------------------------------------------------------------------------
|
|
-- Config loading
|
|
---------------------------------------------------------------------------
|
|
|
|
--- Load a JSON config file, trying multiple paths in order.
|
|
-- Merges found config over the provided defaults.
|
|
-- @param defaults table Default config values
|
|
-- @param configPaths table Array of file paths to try (first match wins)
|
|
-- @return table Merged config
|
|
-- @return string|nil Path of the file that was loaded, or nil if none found
|
|
function WebBridge.loadConfig(defaults, configPaths)
|
|
defaults = defaults or {}
|
|
configPaths = configPaths or {}
|
|
|
|
for _, path in ipairs(configPaths) do
|
|
if fs.exists(path) then
|
|
local f = fs.open(path, 'r')
|
|
if f then
|
|
local raw = f.readAll()
|
|
f.close()
|
|
local ok, cfg = pcall(textutils.unserialiseJSON, raw)
|
|
if ok and type(cfg) == 'table' then
|
|
for k, v in pairs(cfg) do
|
|
defaults[k] = v
|
|
end
|
|
return defaults, path
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
return defaults, nil
|
|
end
|
|
|
|
--- Save a config table to a JSON file.
|
|
-- Creates parent directories if needed.
|
|
-- @param path string File path
|
|
-- @param config table Config to save
|
|
-- @return boolean true on success
|
|
function WebBridge.saveConfig(path, config)
|
|
local dir = fs.getDir(path)
|
|
if dir and dir ~= '' and not fs.isDir(dir) then
|
|
fs.makeDir(dir)
|
|
end
|
|
|
|
local ok = pcall(function()
|
|
local f = fs.open(path, 'w')
|
|
f.write(textutils.serialiseJSON(config))
|
|
f.close()
|
|
end)
|
|
|
|
return ok
|
|
end
|
|
|
|
---------------------------------------------------------------------------
|
|
-- HTTP helpers
|
|
---------------------------------------------------------------------------
|
|
|
|
--- Perform an HTTP POST with JSON body.
|
|
-- @param url string Target URL
|
|
-- @param data table Data to serialize as JSON
|
|
-- @param headers table|nil Additional headers
|
|
-- @return string|nil Response body, or nil on error
|
|
-- @return string|nil Error message on failure
|
|
function WebBridge.httpPost(url, data, headers)
|
|
headers = headers or {}
|
|
headers['Content-Type'] = headers['Content-Type'] or 'application/json'
|
|
|
|
local body = textutils.serialiseJSON(data)
|
|
local ok, result = pcall(function()
|
|
local response = http.post(url, body, headers)
|
|
if response then
|
|
local responseBody = response.readAll()
|
|
response.close()
|
|
return responseBody
|
|
end
|
|
return nil
|
|
end)
|
|
|
|
if not ok then
|
|
return nil, tostring(result)
|
|
end
|
|
return result
|
|
end
|
|
|
|
--- Perform an HTTP GET request.
|
|
-- @param url string Target URL
|
|
-- @param headers table|nil Additional headers
|
|
-- @return string|nil Response body, or nil on error
|
|
-- @return string|nil Error message on failure
|
|
function WebBridge.httpGet(url, headers)
|
|
local ok, result = pcall(function()
|
|
local response = http.get(url, headers)
|
|
if response then
|
|
local body = response.readAll()
|
|
response.close()
|
|
return body
|
|
end
|
|
return nil
|
|
end)
|
|
|
|
if not ok then
|
|
return nil, tostring(result)
|
|
end
|
|
return result
|
|
end
|
|
|
|
--- Build authorization headers for API key authentication.
|
|
-- Returns an empty table if no key is provided.
|
|
-- @param apiKey string|nil API key
|
|
-- @return table Headers table
|
|
function WebBridge.authHeaders(apiKey)
|
|
if apiKey and #apiKey > 0 then
|
|
return { ['Authorization'] = 'Bearer ' .. apiKey }
|
|
end
|
|
return {}
|
|
end
|
|
|
|
---------------------------------------------------------------------------
|
|
-- Modem management
|
|
---------------------------------------------------------------------------
|
|
|
|
--- Find a modem peripheral.
|
|
-- Prefers the Opus device registry, falls back to peripheral scan.
|
|
-- By default prefers wired modems (for reliable LAN communication).
|
|
-- @param opts table|boolean|nil Options table { preferWireless = bool },
|
|
-- or legacy boolean (true = prefer wireless)
|
|
-- @return table|nil Modem peripheral handle
|
|
-- @return string|nil Peripheral name/side
|
|
function WebBridge.findModem(opts)
|
|
-- Legacy support: boolean arg means preferWireless
|
|
if type(opts) == 'boolean' then
|
|
opts = { preferWireless = opts }
|
|
end
|
|
opts = opts or {}
|
|
|
|
-- Try Opus device registry first
|
|
if _G.device then
|
|
if opts.preferWireless and _G.device.wireless_modem then
|
|
return _G.device.wireless_modem, 'wireless_modem'
|
|
end
|
|
|
|
-- Two-pass: prefer wired modems first, then any modem
|
|
-- (pairs() order is non-deterministic; without two passes,
|
|
-- a wireless modem might be returned even when wired is available)
|
|
local fallback, fallbackName
|
|
for name, dev in pairs(_G.device) do
|
|
if dev.type == 'wired_modem' then
|
|
return dev, name
|
|
elseif dev.type == 'modem' or dev.type == 'wireless_modem' then
|
|
if not fallback then
|
|
fallback, fallbackName = dev, name
|
|
end
|
|
end
|
|
end
|
|
if fallback then
|
|
return fallback, fallbackName
|
|
end
|
|
end
|
|
|
|
-- Fallback: scan peripherals (prefer wired)
|
|
local wirelessDev, wirelessName
|
|
for _, name in ipairs(peripheral.getNames()) do
|
|
if peripheral.getType(name) == 'modem' then
|
|
local dev = peripheral.wrap(name)
|
|
if dev.isWireless and not dev.isWireless() then
|
|
return dev, name -- wired modem found
|
|
elseif not wirelessDev then
|
|
wirelessDev, wirelessName = dev, name
|
|
end
|
|
end
|
|
end
|
|
if wirelessDev then
|
|
return wirelessDev, wirelessName
|
|
end
|
|
|
|
return nil
|
|
end
|
|
|
|
--- Open a list of named channels on a modem.
|
|
-- Respects dual-mode migration (opens both current and target channels).
|
|
-- @param modem table Modem peripheral handle
|
|
-- @param channelNames table Array of logical channel names
|
|
function WebBridge.openChannels(modem, channelNames)
|
|
for _, name in ipairs(channelNames) do
|
|
for _, ch in ipairs(Channels.getBoth(name)) do
|
|
modem.open(ch)
|
|
end
|
|
end
|
|
end
|
|
|
|
---------------------------------------------------------------------------
|
|
-- WebSocket connection manager
|
|
---------------------------------------------------------------------------
|
|
|
|
--- Manage a persistent WebSocket connection with auto-reconnect.
|
|
-- Blocks indefinitely, reconnecting on failure with a configurable delay.
|
|
-- Handles ping/pong keepalive automatically.
|
|
-- @param wsUrl string WebSocket URL
|
|
-- @param handlers table Callbacks:
|
|
-- onConnect(ws) Called when connection is established
|
|
-- onMessage(ws, data) Called for each parsed JSON message (except pong)
|
|
-- onDisconnect() Called when connection drops
|
|
-- onError(err) Called on connection error (optional)
|
|
-- @param opts table|nil Options:
|
|
-- reconnectDelay number Seconds between reconnect attempts (default 5)
|
|
-- receiveTimeout number Seconds before ping on idle (default 30)
|
|
function WebBridge.wsConnect(wsUrl, handlers, opts)
|
|
handlers = handlers or {}
|
|
opts = opts or {}
|
|
local reconnectDelay = opts.reconnectDelay or 5
|
|
local receiveTimeout = opts.receiveTimeout or 30
|
|
|
|
while true do
|
|
-- Use async API to avoid unfiltered os.pullEvent() in http.websocket()
|
|
local aok, aerr = http.websocketAsync(wsUrl)
|
|
if not aok then
|
|
if handlers.onError then handlers.onError(aerr) end
|
|
os.sleep(reconnectDelay)
|
|
else
|
|
-- Wait for websocket_success or websocket_failure (filtered)
|
|
local ws = nil
|
|
while true do
|
|
local event, url, param = os.pullEvent()
|
|
if event == 'websocket_success' and url == wsUrl then
|
|
ws = param
|
|
break
|
|
elseif event == 'websocket_failure' and url == wsUrl then
|
|
if handlers.onError then handlers.onError(param) end
|
|
break
|
|
end
|
|
end
|
|
|
|
if ws then
|
|
if handlers.onConnect then
|
|
handlers.onConnect(ws)
|
|
end
|
|
|
|
-- Event-based message loop — uses os.pullEvent() with awareness
|
|
-- of websocket_message, websocket_closed, and timer events.
|
|
-- Unlike ws.receive(), this loop does NOT swallow unrelated events
|
|
-- because CC:Tweaked's parallel scheduler delivers each event to
|
|
-- all coroutines whose filter matches (or filter is nil).
|
|
local keepaliveTimer = os.startTimer(receiveTimeout)
|
|
|
|
while true do
|
|
local event, p1, p2, p3 = os.pullEvent()
|
|
|
|
if event == 'websocket_message' and p1 == wsUrl then
|
|
-- Reset keepalive timer on each message
|
|
os.cancelTimer(keepaliveTimer)
|
|
keepaliveTimer = os.startTimer(receiveTimeout)
|
|
|
|
local data = textutils.unserialiseJSON(p2)
|
|
if data then
|
|
if data.type == 'pong' then
|
|
-- Keepalive response, handled
|
|
elseif handlers.onMessage then
|
|
handlers.onMessage(ws, data)
|
|
end
|
|
end
|
|
|
|
elseif event == 'websocket_closed' and p1 == wsUrl then
|
|
break
|
|
|
|
elseif event == 'timer' and p1 == keepaliveTimer then
|
|
-- No message received within timeout — send keepalive ping
|
|
local pok = pcall(ws.send, textutils.serialiseJSON({ type = 'ping' }))
|
|
if not pok then break end
|
|
keepaliveTimer = os.startTimer(receiveTimeout)
|
|
end
|
|
end
|
|
|
|
pcall(ws.close)
|
|
|
|
if handlers.onDisconnect then
|
|
handlers.onDisconnect()
|
|
end
|
|
end
|
|
|
|
os.sleep(reconnectDelay)
|
|
end
|
|
end
|
|
end
|
|
|
|
---------------------------------------------------------------------------
|
|
-- HTTP poll→process→ack cycle (WebSocket fallback transport)
|
|
---------------------------------------------------------------------------
|
|
|
|
--- Run a poll→process→ack loop for HTTP-based command polling.
|
|
-- Blocks indefinitely, polling at the specified interval.
|
|
-- Uses monotonic ID-based acknowledgment for deduplication safety.
|
|
-- @param opts table Options:
|
|
-- pollUrl string URL to GET pending commands
|
|
-- ackUrl string URL to POST acknowledgment
|
|
-- headers table Auth/custom headers
|
|
-- interval number Poll interval in seconds (default 1)
|
|
-- onCommand function Called for each received command
|
|
-- shouldPoll function Optional gate — return false to skip this cycle
|
|
function WebBridge.pollCommands(opts)
|
|
local pollUrl = opts.pollUrl
|
|
local ackUrl = opts.ackUrl
|
|
local headers = opts.headers or {}
|
|
local interval = opts.interval or 1
|
|
local onCommand = opts.onCommand
|
|
local shouldPoll = opts.shouldPoll
|
|
local lastId = 0
|
|
|
|
while true do
|
|
if not shouldPoll or shouldPoll() then
|
|
local body = WebBridge.httpGet(pollUrl, headers)
|
|
if body then
|
|
local ok, commands = pcall(textutils.unserialiseJSON, body)
|
|
if ok and type(commands) == 'table' then
|
|
for _, cmd in ipairs(commands) do
|
|
if onCommand then
|
|
onCommand(cmd)
|
|
end
|
|
-- Track highest processed ID for dedup-safe ack
|
|
if cmd.id and cmd.id > lastId then
|
|
lastId = cmd.id
|
|
end
|
|
end
|
|
|
|
if #commands > 0 and ackUrl then
|
|
WebBridge.httpPost(ackUrl, { lastProcessedId = lastId }, headers)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
os.sleep(interval)
|
|
end
|
|
end
|
|
|
|
return WebBridge
|