From 45b264dbc4c2c733faa8dc4bfa3ef13cbe28f1b2 Mon Sep 17 00:00:00 2001 From: MayaTheShy Date: Thu, 26 Mar 2026 15:52:05 -0400 Subject: [PATCH] feat: implement WebSocket support for real-time command handling and state updates --- inventoryWebBridge.lua | 170 ++++++++++++++++++++++++++++++++++------- 1 file changed, 141 insertions(+), 29 deletions(-) diff --git a/inventoryWebBridge.lua b/inventoryWebBridge.lua index 055a4de..33fdfe7 100644 --- a/inventoryWebBridge.lua +++ b/inventoryWebBridge.lua @@ -4,8 +4,17 @@ -- 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). +-- 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') @@ -49,6 +58,10 @@ 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 + ------------------------------------------------- -- HTTP helpers (thin wrappers around platform) ------------------------------------------------- @@ -83,6 +96,32 @@ local function forwardState() 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 ------------------------------------------------- @@ -205,15 +244,28 @@ local function modemListener() local resultType = message.type if resultType == "order_result" or resultType == "craft_result" or resultType == "recursive_craft_result" or resultType == "find_item_result" then - local fwdOk, fwdErr = pcall(httpPost, "/api/bridge/result", { + 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, }) - if not fwdOk then - print(string.format("[ERR] Forward result %s: %s", resultType, tostring(fwdErr))) + -- 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 @@ -221,45 +273,56 @@ local function modemListener() end -- Task 2: Forward state to web server periodically +-- Uses WebSocket if connected; falls back to HTTP POST. local function stateForwarder() while running do - local ok, err = pcall(forwardState) - if not ok then - print(string.format("[ERR] State forward: %s", tostring(err))) + 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 +-- Task 3: Poll web server for commands (HTTP fallback) +-- Skipped when WebSocket is connected (commands arrive via WS push). local lastProcessedId = 0 -- track highest processed command ID for dedup local function commandPoller() while running do - 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))) + -- HTTP polling is a fallback; skip when WS is delivering commands + if not wsConnected 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 - if cmdId > maxId then maxId = cmdId end + end + -- Acknowledge up to the highest processed ID + if maxId > lastProcessedId then + lastProcessedId = maxId + httpPost("/api/bridge/commands/ack", { lastProcessedId = lastProcessedId }) end end - -- Acknowledge up to the highest processed ID - if maxId > lastProcessedId then - lastProcessedId = maxId - httpPost("/api/bridge/commands/ack", { lastProcessedId = lastProcessedId }) - end + end) + if not ok then + print(string.format("[ERR] Command poll: %s", tostring(err))) end - end) - if not ok then - print(string.format("[ERR] Command poll: %s", tostring(err))) end sleep(POLL_INTERVAL) end @@ -285,6 +348,53 @@ local function heartbeat() 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) + -- Server pushes commands via WebSocket (replaces HTTP polling) + if 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 + 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 + ------------------------------------------------- -- Main ------------------------------------------------- @@ -316,6 +426,7 @@ local function main() 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) @@ -325,7 +436,8 @@ local function main() modemListener, stateForwarder, commandPoller, - heartbeat + heartbeat, + wsConnector ) end