initial commit
This commit is contained in:
181
apis/channels.lua
Normal file
181
apis/channels.lua
Normal file
@@ -0,0 +1,181 @@
|
||||
--[[
|
||||
platform.channels — Channel registry for cc-platform-core
|
||||
|
||||
Centralizes modem channel allocation across all platform services.
|
||||
Supports controlled migration between legacy and target channels
|
||||
via three modes: "current", "target", and "dual".
|
||||
|
||||
Usage:
|
||||
local Channels = require('platform.channels')
|
||||
|
||||
-- Get the active channel for a service endpoint
|
||||
local ch = Channels.get('inventory.broadcast') -- => 4200
|
||||
|
||||
-- During migration, listen on both old and new channels
|
||||
Channels.setMode('dual')
|
||||
local both = Channels.getBoth('remoteturtle.command') -- => {100, 4210}
|
||||
|
||||
-- Get all channels for a service
|
||||
local chs = Channels.forService('inventory')
|
||||
|
||||
-- Register a custom channel
|
||||
Channels.register('myservice.data', 5000)
|
||||
]]
|
||||
|
||||
local Config = require('opus.config')
|
||||
|
||||
local Channels = {}
|
||||
|
||||
--- Default channel allocations.
|
||||
-- Each entry maps a logical channel name to current and target channel numbers.
|
||||
-- During migration, 'current' is the legacy channel and 'target' is the new one.
|
||||
-- When current == target, no migration is needed for that channel.
|
||||
Channels.registry = {
|
||||
-- RemoteTurtle service (legacy: 100-103, target: 4210-4213)
|
||||
['remoteturtle.command'] = { current = 100, target = 4210 },
|
||||
['remoteturtle.response'] = { current = 101, target = 4211 },
|
||||
['remoteturtle.status'] = { current = 102, target = 4212 },
|
||||
['remoteturtle.pocket'] = { current = 103, target = 4213 },
|
||||
|
||||
-- Inventory Manager service (no migration needed — channels stay the same)
|
||||
['inventory.broadcast'] = { current = 4200, target = 4200 },
|
||||
['inventory.order'] = { current = 4201, target = 4201 },
|
||||
['inventory.client'] = { current = 4202, target = 4202 },
|
||||
['inventory.craft_tx'] = { current = 4203, target = 4203 },
|
||||
['inventory.craft_rx'] = { current = 4204, target = 4204 },
|
||||
['inventory.system'] = { current = 4205, target = 4205 },
|
||||
['inventory.bridge'] = { current = 4206, target = 4206 },
|
||||
|
||||
-- Platform-level shared channels (reserved range: 4250-4259)
|
||||
['platform.discovery'] = { current = 4250, target = 4250 },
|
||||
['platform.heartbeat'] = { current = 4251, target = 4251 },
|
||||
}
|
||||
|
||||
--- Channel mode controls which channel number is returned by get().
|
||||
-- "current" — use legacy channel numbers (default, safe)
|
||||
-- "target" — use new channel numbers (post-migration)
|
||||
-- "dual" — listeners open both; senders use target
|
||||
Channels.mode = 'current'
|
||||
|
||||
--- Load channel mode from persistent config if available.
|
||||
-- Called automatically on require; can be called again to reload.
|
||||
function Channels.loadConfig()
|
||||
local cfg = Config.load('platform', { channelMode = 'current' })
|
||||
if cfg.channelMode then
|
||||
Channels.mode = cfg.channelMode
|
||||
end
|
||||
-- Allow per-channel overrides from config
|
||||
if cfg.channels then
|
||||
for name, entry in pairs(cfg.channels) do
|
||||
if type(entry) == 'number' then
|
||||
Channels.registry[name] = { current = entry, target = entry }
|
||||
elseif type(entry) == 'table' then
|
||||
Channels.registry[name] = entry
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Get the active channel number for a logical channel name.
|
||||
-- In "current" mode, returns the legacy channel.
|
||||
-- In "target" mode, returns the new channel.
|
||||
-- In "dual" mode, returns the target channel (for sending).
|
||||
-- @param name string Logical channel name (e.g. 'inventory.broadcast')
|
||||
-- @return number Channel number
|
||||
function Channels.get(name)
|
||||
local entry = Channels.registry[name]
|
||||
if not entry then
|
||||
error('Unknown channel: ' .. tostring(name), 2)
|
||||
end
|
||||
if Channels.mode == 'current' then
|
||||
return entry.current
|
||||
end
|
||||
return entry.target
|
||||
end
|
||||
|
||||
--- Get both current and target channels for a logical name.
|
||||
-- Returns a deduplicated list. Used for opening channels in dual mode.
|
||||
-- @param name string Logical channel name
|
||||
-- @return table Array of channel numbers (1 or 2 entries)
|
||||
function Channels.getBoth(name)
|
||||
local entry = Channels.registry[name]
|
||||
if not entry then
|
||||
error('Unknown channel: ' .. tostring(name), 2)
|
||||
end
|
||||
if entry.current == entry.target then
|
||||
return { entry.current }
|
||||
end
|
||||
return { entry.current, entry.target }
|
||||
end
|
||||
|
||||
--- Set the channel migration mode.
|
||||
-- @param mode string One of: "current", "target", "dual"
|
||||
function Channels.setMode(mode)
|
||||
assert(mode == 'current' or mode == 'target' or mode == 'dual',
|
||||
'Invalid channel mode: ' .. tostring(mode))
|
||||
Channels.mode = mode
|
||||
end
|
||||
|
||||
--- Get all channels for a service prefix.
|
||||
-- @param prefix string Service prefix (e.g. 'inventory', 'remoteturtle')
|
||||
-- @return table Map of channel name → active channel number
|
||||
function Channels.forService(prefix)
|
||||
local result = {}
|
||||
local pattern = '^' .. prefix:gsub('%-', '%%-') .. '%.'
|
||||
for name, _ in pairs(Channels.registry) do
|
||||
if name:find(pattern) then
|
||||
result[name] = Channels.get(name)
|
||||
end
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
--- Get all channels that should be opened for a service (respects dual mode).
|
||||
-- @param prefix string Service prefix
|
||||
-- @return table Array of unique channel numbers
|
||||
function Channels.openListForService(prefix)
|
||||
local seen = {}
|
||||
local result = {}
|
||||
local pattern = '^' .. prefix:gsub('%-', '%%-') .. '%.'
|
||||
for name, _ in pairs(Channels.registry) do
|
||||
if name:find(pattern) then
|
||||
for _, ch in ipairs(Channels.getBoth(name)) do
|
||||
if not seen[ch] then
|
||||
seen[ch] = true
|
||||
result[#result + 1] = ch
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
table.sort(result)
|
||||
return result
|
||||
end
|
||||
|
||||
--- Register a custom channel (for extensions or new services).
|
||||
-- @param name string Logical channel name
|
||||
-- @param channel number|table Channel number or {current=N, target=N}
|
||||
function Channels.register(name, channel)
|
||||
if type(channel) == 'number' then
|
||||
channel = { current = channel, target = channel }
|
||||
end
|
||||
assert(type(channel) == 'table' and channel.current and channel.target,
|
||||
'Channel must be a number or {current=N, target=N}')
|
||||
Channels.registry[name] = channel
|
||||
end
|
||||
|
||||
--- Check whether a modem channel belongs to a known service.
|
||||
-- @param ch number Channel number
|
||||
-- @return string|nil Channel name, or nil if unknown
|
||||
function Channels.identify(ch)
|
||||
for name, entry in pairs(Channels.registry) do
|
||||
if entry.current == ch or entry.target == ch then
|
||||
return name
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Auto-load config on require
|
||||
pcall(Channels.loadConfig)
|
||||
|
||||
return Channels
|
||||
118
apis/protocol.lua
Normal file
118
apis/protocol.lua
Normal file
@@ -0,0 +1,118 @@
|
||||
--[[
|
||||
platform.protocol — Canonical message envelope for cc-platform-core
|
||||
|
||||
Provides a standard message format that is backward-compatible with
|
||||
existing modem messages. All messages retain the `type` field as the
|
||||
primary discriminator. New metadata fields (_v, _src, _ts, _id) are
|
||||
additive — legacy consumers ignore them.
|
||||
|
||||
Usage:
|
||||
local Protocol = require('platform.protocol')
|
||||
Protocol.setServiceId('inventory-manager')
|
||||
|
||||
-- Create a well-formed message
|
||||
local msg = Protocol.message('state', { items = {...}, total = 42 })
|
||||
-- => { type='state', items={...}, total=42, _v=1, _src='inventory-manager', _ts=... }
|
||||
|
||||
-- Parse incoming message (works with both legacy and versioned)
|
||||
local parsed = Protocol.parse(msg)
|
||||
-- => { type='state', version=1, source='inventory-manager', timestamp=..., data=msg }
|
||||
|
||||
-- Quick type check
|
||||
if Protocol.is(msg, 'status') then ... end
|
||||
]]
|
||||
|
||||
local os = _G.os
|
||||
|
||||
local Protocol = {
|
||||
VERSION = 1,
|
||||
serviceId = nil,
|
||||
}
|
||||
|
||||
--- Set the service identity for this process.
|
||||
-- All messages created after this call will include the service ID.
|
||||
-- @param id string Service identifier (e.g. 'remoteturtle', 'inventory-manager')
|
||||
function Protocol.setServiceId(id)
|
||||
Protocol.serviceId = id
|
||||
end
|
||||
|
||||
--- Create a well-formed platform message.
|
||||
-- Merges data fields into a new table and adds protocol metadata.
|
||||
-- The `type` field is always set from the first argument.
|
||||
-- @param msgType string Message type discriminator
|
||||
-- @param data table|nil Service-specific payload fields (merged into message)
|
||||
-- @param opts table|nil Options: { src=string, id=string, noMeta=bool }
|
||||
-- @return table The constructed message
|
||||
function Protocol.message(msgType, data, opts)
|
||||
opts = opts or {}
|
||||
|
||||
local msg = {}
|
||||
|
||||
-- Merge data fields into the message (flat, not nested)
|
||||
if data then
|
||||
for k, v in pairs(data) do
|
||||
msg[k] = v
|
||||
end
|
||||
end
|
||||
|
||||
-- Primary discriminator
|
||||
msg.type = msgType
|
||||
|
||||
-- Protocol metadata (additive — legacy consumers ignore these)
|
||||
if not opts.noMeta then
|
||||
msg._v = Protocol.VERSION
|
||||
msg._src = opts.src or Protocol.serviceId
|
||||
msg._ts = os.epoch and os.epoch('utc') or math.floor(os.clock() * 1000)
|
||||
if opts.id then
|
||||
msg._id = opts.id
|
||||
end
|
||||
end
|
||||
|
||||
return msg
|
||||
end
|
||||
|
||||
--- Parse a received message, normalizing both legacy and versioned formats.
|
||||
-- Returns nil if the input is not a valid message table.
|
||||
-- @param msg table Raw message from modem or WebSocket
|
||||
-- @return table|nil Normalized message descriptor
|
||||
function Protocol.parse(msg)
|
||||
if type(msg) ~= 'table' then
|
||||
return nil
|
||||
end
|
||||
if not msg.type then
|
||||
return nil
|
||||
end
|
||||
|
||||
return {
|
||||
type = msg.type,
|
||||
version = msg._v or 0,
|
||||
source = msg._src,
|
||||
timestamp = msg._ts,
|
||||
correlationId = msg._id,
|
||||
data = msg, -- full original message for backward-compat field access
|
||||
}
|
||||
end
|
||||
|
||||
--- Check whether a message matches a given type.
|
||||
-- Works on both raw messages and parsed descriptors.
|
||||
-- @param msg table Message or parsed descriptor
|
||||
-- @param msgType string Expected type
|
||||
-- @return boolean
|
||||
function Protocol.is(msg, msgType)
|
||||
if type(msg) ~= 'table' then
|
||||
return false
|
||||
end
|
||||
return msg.type == msgType
|
||||
end
|
||||
|
||||
--- Generate a correlation ID for request/response patterns.
|
||||
-- Uses a combination of computer ID, clock, and random to ensure uniqueness.
|
||||
-- @return string Unique correlation ID
|
||||
function Protocol.correlationId()
|
||||
local id = os.getComputerID and os.getComputerID() or 0
|
||||
local clock = os.epoch and os.epoch('utc') or os.clock()
|
||||
local rand = math.random(0, 0xFFFF)
|
||||
return string.format('%d-%d-%04x', id, clock, rand)
|
||||
end
|
||||
|
||||
return Protocol
|
||||
153
apis/service.lua
Normal file
153
apis/service.lua
Normal file
@@ -0,0 +1,153 @@
|
||||
--[[
|
||||
platform.service — Service lifecycle helpers for cc-platform-core
|
||||
|
||||
Provides a lightweight service context for Opus-based CC services.
|
||||
Handles modem discovery, channel management, config loading, and
|
||||
message sending using platform conventions.
|
||||
|
||||
Usage:
|
||||
local Service = require('platform.service')
|
||||
|
||||
local ctx = Service.create({
|
||||
name = 'inventory-manager',
|
||||
defaults = { scanInterval = 120 },
|
||||
})
|
||||
|
||||
Service.openChannels(ctx)
|
||||
|
||||
-- Send on a named channel
|
||||
Service.send(ctx, 'inventory.broadcast', 'state', { items = {...} })
|
||||
|
||||
-- Broadcast on both legacy and target channels during migration
|
||||
Service.broadcast(ctx, 'inventory.broadcast', 'state', { items = {...} })
|
||||
]]
|
||||
|
||||
local Config = require('opus.config')
|
||||
local Protocol = require('platform.protocol')
|
||||
local Channels = require('platform.channels')
|
||||
|
||||
local peripheral = _G.peripheral
|
||||
|
||||
local Service = {}
|
||||
|
||||
--- Create a new service context.
|
||||
-- Initializes the protocol identity, loads config, discovers modem.
|
||||
-- @param opts table Options:
|
||||
-- name string (required) Service identifier
|
||||
-- defaults table Default config values
|
||||
-- modem boolean Set to false to skip modem discovery
|
||||
-- channelPrefix string Override for channel name prefix (defaults to name)
|
||||
-- @return table Service context
|
||||
function Service.create(opts)
|
||||
assert(opts and opts.name, 'Service name required')
|
||||
|
||||
Protocol.setServiceId(opts.name)
|
||||
|
||||
local prefix = opts.channelPrefix or opts.name
|
||||
local ctx = {
|
||||
name = opts.name,
|
||||
config = Config.load(opts.name, opts.defaults or {}),
|
||||
prefix = prefix,
|
||||
channels = Channels.forService(prefix),
|
||||
modem = nil,
|
||||
}
|
||||
|
||||
-- Discover modem
|
||||
if opts.modem ~= false then
|
||||
-- Prefer Opus device registry
|
||||
if _G.device and _G.device.wireless_modem then
|
||||
ctx.modem = _G.device.wireless_modem
|
||||
else
|
||||
-- Fallback: scan peripherals (wired or wireless)
|
||||
for _, name in ipairs(peripheral.getNames()) do
|
||||
if peripheral.getType(name) == 'modem' then
|
||||
ctx.modem = peripheral.wrap(name)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return ctx
|
||||
end
|
||||
|
||||
--- Open all registered channels for this service on its modem.
|
||||
-- Respects dual-mode: opens both current and target channels when appropriate.
|
||||
-- @param ctx table Service context from Service.create()
|
||||
function Service.openChannels(ctx)
|
||||
if not ctx.modem then return end
|
||||
|
||||
local channelList = Channels.openListForService(ctx.prefix)
|
||||
for _, ch in ipairs(channelList) do
|
||||
ctx.modem.open(ch)
|
||||
end
|
||||
end
|
||||
|
||||
--- Close all registered channels for this service.
|
||||
-- @param ctx table Service context
|
||||
function Service.closeChannels(ctx)
|
||||
if not ctx.modem then return end
|
||||
|
||||
local channelList = Channels.openListForService(ctx.prefix)
|
||||
for _, ch in ipairs(channelList) do
|
||||
ctx.modem.close(ch)
|
||||
end
|
||||
end
|
||||
|
||||
--- Send a message on a single named channel.
|
||||
-- Uses Protocol.message() to construct a well-formed envelope.
|
||||
-- @param ctx table Service context
|
||||
-- @param channelName string Logical channel name (e.g. 'inventory.broadcast')
|
||||
-- @param msgType string Message type discriminator
|
||||
-- @param data table|nil Payload fields
|
||||
-- @param opts table|nil Protocol.message options (id, src, noMeta)
|
||||
-- @return boolean true if sent successfully
|
||||
function Service.send(ctx, channelName, msgType, data, opts)
|
||||
if not ctx.modem then return false end
|
||||
local ch = Channels.get(channelName)
|
||||
local msg = Protocol.message(msgType, data, opts)
|
||||
ctx.modem.transmit(ch, ch, msg)
|
||||
return true
|
||||
end
|
||||
|
||||
--- Broadcast a message, sending on both channels during dual-mode migration.
|
||||
-- In current or target mode, behaves identically to send().
|
||||
-- @param ctx table Service context
|
||||
-- @param channelName string Logical channel name
|
||||
-- @param msgType string Message type discriminator
|
||||
-- @param data table|nil Payload fields
|
||||
-- @param opts table|nil Protocol.message options
|
||||
-- @return boolean true if sent on at least one channel
|
||||
function Service.broadcast(ctx, channelName, msgType, data, opts)
|
||||
if not ctx.modem then return false end
|
||||
|
||||
local msg = Protocol.message(msgType, data, opts)
|
||||
|
||||
if Channels.mode == 'dual' then
|
||||
local sent = false
|
||||
for _, ch in ipairs(Channels.getBoth(channelName)) do
|
||||
ctx.modem.transmit(ch, ch, msg)
|
||||
sent = true
|
||||
end
|
||||
return sent
|
||||
else
|
||||
local ch = Channels.get(channelName)
|
||||
ctx.modem.transmit(ch, ch, msg)
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
--- Save the service config back to persistent storage.
|
||||
-- @param ctx table Service context
|
||||
function Service.saveConfig(ctx)
|
||||
Config.update(ctx.name, ctx.config)
|
||||
end
|
||||
|
||||
--- Reload the service config from persistent storage.
|
||||
-- @param ctx table Service context
|
||||
-- @param defaults table|nil Default values
|
||||
function Service.reloadConfig(ctx, defaults)
|
||||
ctx.config = Config.load(ctx.name, defaults or ctx.config)
|
||||
end
|
||||
|
||||
return Service
|
||||
332
apis/webbridge.lua
Normal file
332
apis/webbridge.lua
Normal file
@@ -0,0 +1,332 @@
|
||||
--[[
|
||||
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.
|
||||
-- @param preferWireless boolean|nil If true, prefer wireless modem
|
||||
-- @return table|nil Modem peripheral handle
|
||||
-- @return string|nil Peripheral name/side
|
||||
function WebBridge.findModem(preferWireless)
|
||||
-- Try Opus device registry first
|
||||
if _G.device then
|
||||
if preferWireless and _G.device.wireless_modem then
|
||||
return _G.device.wireless_modem, 'wireless_modem'
|
||||
end
|
||||
-- Find any modem in device registry
|
||||
for name, dev in pairs(_G.device) do
|
||||
if dev.type == 'modem' or dev.type == 'wireless_modem' or dev.type == 'wired_modem' then
|
||||
return dev, name
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Fallback: scan peripherals
|
||||
for _, name in ipairs(peripheral.getNames()) do
|
||||
if peripheral.getType(name) == 'modem' then
|
||||
return peripheral.wrap(name), name
|
||||
end
|
||||
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
|
||||
local ok, ws = pcall(http.websocket, wsUrl)
|
||||
|
||||
if ok and ws then
|
||||
if handlers.onConnect then
|
||||
handlers.onConnect(ws)
|
||||
end
|
||||
|
||||
-- Message receive loop
|
||||
while true do
|
||||
local rok, msg = pcall(ws.receive, receiveTimeout)
|
||||
|
||||
if not rok then
|
||||
-- Connection error
|
||||
break
|
||||
end
|
||||
|
||||
if not msg then
|
||||
-- Timeout — send keepalive ping
|
||||
local pok = pcall(ws.send, textutils.serialiseJSON({ type = 'ping' }))
|
||||
if not pok then break end
|
||||
else
|
||||
local data = textutils.unserialiseJSON(msg)
|
||||
if data then
|
||||
if data.type == 'pong' then
|
||||
-- Keepalive response, handled
|
||||
elseif handlers.onMessage then
|
||||
handlers.onMessage(ws, data)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
pcall(ws.close)
|
||||
|
||||
if handlers.onDisconnect then
|
||||
handlers.onDisconnect()
|
||||
end
|
||||
else
|
||||
if handlers.onError then
|
||||
handlers.onError(ws) -- ws contains the error string on failure
|
||||
end
|
||||
end
|
||||
|
||||
os.sleep(reconnectDelay)
|
||||
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
|
||||
Reference in New Issue
Block a user