initial commit

This commit is contained in:
MayaTheShy
2026-03-26 15:00:49 -04:00
commit 6050c4e22b
999 changed files with 380489 additions and 0 deletions

181
apis/channels.lua Normal file
View 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
View 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
View 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
View 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