-- Inventory Manager Web Bridge -- Runs on a CC:Tweaked computer with a modem and HTTP access. -- Listens to the inventory master's broadcasts via modem and -- forwards state to the web server via HTTP. -- Also polls the web server for commands and sends them to the master. -- -- Uses cc-platform-core for shared infrastructure (config, HTTP, modem, WS). -- Service-specific logic (command dispatch, state forwarding) remains here. -- -- Transport: WebSocket (primary) with HTTP polling (fallback). -- When a WS connection to /ws/bridge is active, commands arrive in -- real-time and state/results are pushed over the socket. If the WS -- drops, the bridge seamlessly falls back to HTTP polling until -- reconnection. -- -- Channel mode: 'current' by default (legacy channels active). -- Set channelMode = 'dual' or 'target' in platform config to migrate. local WebBridge = require('platform.webbridge') local Channels = require('platform.channels') ------------------------------------------------- -- Configuration (via platform) ------------------------------------------------- local _baseDir = fs.getDir(shell.getRunningProgram()) local config, configSource = WebBridge.loadConfig({ serverUrl = "http://localhost", pollInterval = 0.5, stateInterval = 1, apiKey = nil, }, { "usr/config/inventory-manager/.webbridge_config", fs.combine(_baseDir, ".webbridge_config"), }) local SERVER_URL = config.serverUrl local POLL_INTERVAL = config.pollInterval local STATE_INTERVAL = config.stateInterval local API_KEY = config.apiKey if configSource then print("[CONFIG] Loaded from " .. configSource) end -- Channels from platform registry (matches inventoryManager.lua) local BROADCAST_CHANNEL = Channels.get('inventory.broadcast') local ORDER_CHANNEL = Channels.get('inventory.order') local BRIDGE_REPLY_CHANNEL = Channels.get('inventory.bridge') ------------------------------------------------- -- State ------------------------------------------------- local latestState = nil -- last broadcast from master local modem = nil local modemName = nil local running = true -- WebSocket state (real-time transport, with HTTP polling fallback) local ws = nil -- active WebSocket handle (nil when not connected) local wsConnected = false -- gates WS vs HTTP transport selection local wsHasSynced = false -- true after first command_batch received from server -- Reliable modem delivery: pending commands awaiting manager acknowledgment. -- processCommand() inserts here; modemListener removes on result receipt. -- A retry task periodically re-transmits unacknowledged commands. local pendingModem = {} -- commandId -> { payload, channel, replyChannel, sent, retries } local MODEM_RETRY_INTERVAL = 2 -- seconds between retry sweeps local MODEM_RETRY_MAX = 5 -- max retransmissions before giving up local MODEM_RETRY_DELAY = 2 -- seconds before first retry (per command) ------------------------------------------------- -- HTTP helpers (thin wrappers around platform) ------------------------------------------------- local function httpPost(path, body) local result, err = WebBridge.httpPost(SERVER_URL .. path, body, WebBridge.authHeaders(API_KEY)) if not result and err then print(string.format("[ERR] HTTP POST %s: %s", path, tostring(err))) end return result end local function httpGet(path) local rawBody, err = WebBridge.httpGet(SERVER_URL .. path, WebBridge.authHeaders(API_KEY)) if not rawBody then if err then print(string.format("[ERR] HTTP GET %s: %s", path, tostring(err))) end return nil end return textutils.unserialiseJSON(rawBody) end ------------------------------------------------- -- Forward state to web server ------------------------------------------------- local function forwardState() if not latestState then return end httpPost("/api/bridge/state", latestState) end ------------------------------------------------- -- WebSocket helpers (real-time transport) ------------------------------------------------- --- Build the WebSocket bridge URL from server config. -- Converts http(s):// to ws(s):// and appends /ws/bridge path. -- @return string WebSocket URL with optional API key local function getWsUrl() local wsUrl = SERVER_URL:gsub("^http", "ws") .. "/ws/bridge" if API_KEY then wsUrl = wsUrl .. "?key=" .. textutils.urlEncode(API_KEY) end return wsUrl end --- Send a JSON message via WebSocket if connected. -- @param data table Data to send (serialized to JSON automatically) -- @return boolean true if sent successfully, false if WS unavailable local function wsSend(data) if ws and wsConnected then local ok = pcall(ws.send, textutils.serialiseJSON(data)) return ok end return false end ------------------------------------------------- -- Process commands from web server ------------------------------------------------- local function processCommand(cmd) if not modem then return end local action = cmd.action if not action then return end print(string.format("[CMD] %s", action)) -- Build the modem payload from the server command local payload if action == "order" then payload = { type = "order", commandId = cmd.commandId, itemName = cmd.itemName, amount = cmd.amount, dropperName = cmd.dropperName, } elseif action == "scan" then payload = { type = "scan", commandId = cmd.commandId } elseif action == "toggle_pause" then payload = { type = "toggle_pause", commandId = cmd.commandId } elseif action == "toggle_recipe" then payload = { type = "toggle_recipe", commandId = cmd.commandId, recipe = cmd.recipe } elseif action == "enable_all" then payload = { type = "enable_all", commandId = cmd.commandId } elseif action == "disable_all" then payload = { type = "disable_all", commandId = cmd.commandId } elseif action == "sort_barrel" then payload = { type = "sort_barrel", commandId = cmd.commandId, barrelName = cmd.barrelName } elseif action == "craft" then payload = { type = "craft", commandId = cmd.commandId, recipeIdx = cmd.recipeIdx } elseif action == "recursive_craft" then payload = { type = "recursive_craft", commandId = cmd.commandId, itemName = cmd.itemName, count = cmd.count } elseif action == "learn_crafting_recipe" then payload = { type = "learn_crafting_recipe", commandId = cmd.commandId, output = cmd.output, count = cmd.count, grid = cmd.grid } elseif action == "learn_smelting_recipe" then payload = { type = "learn_smelting_recipe", commandId = cmd.commandId, input = cmd.input, result = cmd.result, furnaces = cmd.furnaces } elseif action == "forget_recipe" then payload = { type = "forget_recipe", commandId = cmd.commandId, recipe = cmd.recipe } elseif action == "sync_disabled_recipes" then payload = { type = "sync_disabled_recipes", commandId = cmd.commandId, disabledRecipes = cmd.disabledRecipes, smeltingPaused = cmd.smeltingPaused } elseif action == "reboot" then payload = { type = "reboot", commandId = cmd.commandId, target = cmd.target or "all" } else print("[CMD] Unknown action: " .. tostring(action)) return end -- Transmit and track for reliable delivery modem.transmit(ORDER_CHANNEL, BRIDGE_REPLY_CHANNEL, payload) if payload.commandId then pendingModem[payload.commandId] = { payload = payload, sent = os.clock(), retries = 0, } end end ------------------------------------------------- -- Tasks ------------------------------------------------- -- Task 1: Listen for modem broadcasts from master local function modemListener() while running do local event, side, channel, replyChannel, message = os.pullEvent("modem_message") if channel == BROADCAST_CHANNEL and type(message) == "table" then if message.type == "state" then latestState = message end elseif channel == BRIDGE_REPLY_CHANNEL and type(message) == "table" then -- Clear pending retry on any response matching a commandId if message.commandId and pendingModem[message.commandId] then pendingModem[message.commandId] = nil end -- Forward command results back to web server local resultType = message.type if resultType == "order_result" or resultType == "craft_result" or resultType == "recursive_craft_result" or resultType == "find_item_result" then local resultPayload = { action = resultType, commandId = message.commandId, success = message.success, message = message.message, error = message.error, } -- WS-first: send as command_result via WebSocket if connected local sent = wsSend({ type = "command_result", action = resultPayload.action, commandId = resultPayload.commandId, success = resultPayload.success, message = resultPayload.message, error = resultPayload.error, }) -- HTTP fallback: POST to /api/bridge/result if WS unavailable if not sent then local fwdOk, fwdErr = pcall(httpPost, "/api/bridge/result", resultPayload) if not fwdOk then print(string.format("[ERR] Forward result %s: %s", resultType, tostring(fwdErr))) end end end end end end -- Task 2: Forward state to web server periodically -- Uses WebSocket if connected; falls back to HTTP POST. local function stateForwarder() while running do if latestState then -- WS-first: send state directly via WebSocket if not wsSend(latestState) then -- HTTP fallback: POST to /api/bridge/state local ok, err = pcall(forwardState) if not ok then print(string.format("[ERR] State forward: %s", tostring(err))) end end end sleep(STATE_INTERVAL) end end -- Task 3: Poll web server for commands (HTTP fallback) -- Active when WS is disconnected, or as safety net until first WS sync. local lastProcessedId = 0 -- track highest processed command ID for dedup local function commandPoller() while running do -- HTTP polling is a fallback; also runs until WS initial sync completes if not wsConnected or not wsHasSynced then local ok, err = pcall(function() local result = httpGet("/api/bridge/commands") if result and result.commands and #result.commands > 0 then local maxId = lastProcessedId -- Process each command, skipping already-processed ones for _, cmd in ipairs(result.commands) do local cmdId = cmd.id or 0 if cmdId > lastProcessedId then local cmdOk, cmdErr = pcall(processCommand, cmd) if not cmdOk then print(string.format("[ERR] Process cmd %s: %s", tostring(cmd.action), tostring(cmdErr))) end if cmdId > maxId then maxId = cmdId end end end -- Acknowledge up to the highest processed ID if maxId > lastProcessedId then lastProcessedId = maxId httpPost("/api/bridge/commands/ack", { lastProcessedId = lastProcessedId }) end end end) if not ok then print(string.format("[ERR] Command poll: %s", tostring(err))) end end sleep(POLL_INTERVAL) end end -- Task 4: Heartbeat / connection status display local function heartbeat() local connected = false while running do local ok, result = pcall(function() return httpGet("/api/health") end) local nowConnected = ok and result ~= nil if nowConnected ~= connected then connected = nowConnected if connected then print("[WEB] Connected to web server") else print("[WEB] Disconnected from web server") end end sleep(5) end end -- Task 5: WebSocket real-time connection (primary transport) -- Maintains a persistent WebSocket link to the server for: -- - Receiving commands in real-time (replaces HTTP polling when active) -- - Sending state updates and command results via wsSend() -- Reconnects automatically on failure; HTTP polling resumes as fallback. -- Channel mode: 'current' by default — dual/target configurable via platform. local function wsConnector() local wsUrl = getWsUrl() print("[WS] Connecting to " .. wsUrl) WebBridge.wsConnect(wsUrl, { onConnect = function(wsHandle) ws = wsHandle wsConnected = true print("[WS] Connected — real-time mode active") -- Push current state immediately on reconnect if latestState then wsSend(latestState) end end, onMessage = function(wsHandle, data) -- Initial sync: server sends pending commands as a batch on connect if data.type == 'command_batch' and data.commands then wsHasSynced = true for _, cmd in ipairs(data.commands) do local cmdOk, cmdErr = pcall(processCommand, cmd) if not cmdOk then print(string.format("[ERR] WS batch cmd %s: %s", tostring(cmd.action), tostring(cmdErr))) end end if #data.commands > 0 then print(string.format("[WS] Processed %d synced command(s)", #data.commands)) end -- Server pushes commands via WebSocket (replaces HTTP polling) elseif data.action then local cmdOk, cmdErr = pcall(processCommand, data) if not cmdOk then print(string.format("[ERR] WS cmd %s: %s", tostring(data.action), tostring(cmdErr))) end end end, onDisconnect = function() ws = nil wsConnected = false wsHasSynced = false print("[WS] Disconnected — HTTP polling fallback active") end, onError = function(err) print(string.format("[WS] Connection error: %s", tostring(err))) end, }, { reconnectDelay = 5, receiveTimeout = 30, }) end -- Task 6: Retry unacknowledged modem commands -- The manager deduplicates by commandId, so retransmits are safe. local function modemRetry() while running do local now = os.clock() for cmdId, entry in pairs(pendingModem) do if now - entry.sent >= MODEM_RETRY_DELAY then if entry.retries >= MODEM_RETRY_MAX then print(string.format("[RETRY] Giving up on %s after %d retries", tostring(cmdId), entry.retries)) pendingModem[cmdId] = nil else entry.retries = entry.retries + 1 entry.sent = now print(string.format("[RETRY] Re-transmit %s (attempt %d/%d)", tostring(cmdId), entry.retries, MODEM_RETRY_MAX)) pcall(modem.transmit, ORDER_CHANNEL, BRIDGE_REPLY_CHANNEL, entry.payload) end end end sleep(MODEM_RETRY_INTERVAL) end end ------------------------------------------------- -- Main ------------------------------------------------- local function main() print("===================================") print(" Inventory Manager - Web Bridge") print("===================================") print("") -- Config already loaded at require-time via WebBridge.loadConfig above modem, modemName = WebBridge.findModem() if modem then WebBridge.openChannels(modem, { 'inventory.broadcast', 'inventory.bridge' }) local modemType = modem.isWireless and (modem.isWireless() and "wireless" or "wired") or "unknown" print(string.format("[OK] Modem: %s (%s)", modemName, modemType)) print(string.format("[OK] TX ch %d, listen ch %d/%d", ORDER_CHANNEL, BROADCAST_CHANNEL, BRIDGE_REPLY_CHANNEL)) else print("[WARN] No modem found! Bridge needs a modem.") print(" Attach a modem and restart.") return end print("[OK] Server URL: " .. SERVER_URL) print("[OK] Poll interval: " .. POLL_INTERVAL .. "s") print("[OK] State interval: " .. STATE_INTERVAL .. "s") if API_KEY then print("[OK] API key configured") else print("[WARN] No API key set (open access)") end print("[OK] Transport: WebSocket (primary) + HTTP polling (fallback)") print("") print("Bridge is running. Press Ctrl+T to stop.") print("Listening for master broadcasts on ch " .. BROADCAST_CHANNEL) print("") parallel.waitForAny( modemListener, stateForwarder, commandPoller, heartbeat, wsConnector, modemRetry ) end main()