-- Web Bridge v2 (WebSocket Protocol) -- Connects to server via WebSocket for instant bidirectional communication. -- Forwards turtle modem messages to server and server commands to turtles. -- Falls back to HTTP polling if WebSocket is unavailable. -- -- Uses cc-platform-core for shared infrastructure (config, HTTP, modem, channels). 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:4200", wsUrl = nil, -- derived from serverUrl if not set }, { fs.combine(_baseDir, ".webbridge_config"), }) local SERVER_URL = config.serverUrl local WS_URL = config.wsUrl or SERVER_URL:gsub("^http", "ws") .. "/ws/bridge" if configSource then print("[CONFIG] Loaded from " .. configSource) end -- Channels from platform registry local COMMAND_CHANNEL = Channels.get('remoteturtle.command') local CHANNEL_RECEIVE = Channels.get('remoteturtle.response') local STATUS_CHANNEL = Channels.get('remoteturtle.status') local POCKET_CHANNEL = Channels.get('remoteturtle.pocket') ------------------------------------------------- -- Peripherals ------------------------------------------------- local modem, modemSide = WebBridge.findModem(true) -- prefer wireless local monitor = peripheral.find("monitor") if not modem then error("No wireless modem found!") end local hasMonitor = monitor ~= nil if hasMonitor then monitor.setTextScale(0.5) end -- Open channels via platform (respects dual-mode migration) WebBridge.openChannels(modem, { 'remoteturtle.response', 'remoteturtle.status', 'remoteturtle.pocket', }) -- Track turtles and stats local turtles = {} local stats = { messagesReceived = 0, commandsSent = 0, serverUpdates = 0, errors = 0, startTime = os.epoch("utc") } local activityLog = {} local wsHandle = nil local wsConnected = false -- ========== Dashboard Drawing ========== local function centerText(y, text, fg, bg) if not hasMonitor then return end local w = monitor.getSize() monitor.setCursorPos(math.floor((w - #text) / 2) + 1, y) monitor.setTextColor(fg or colors.white) monitor.setBackgroundColor(bg or colors.black) monitor.write(text) end local function getStatusColor(turtle) if not turtle.lastSeen then return colors.red end local timeSince = os.epoch("utc") - turtle.lastSeen if timeSince < 10000 then return colors.lime elseif timeSince < 30000 then return colors.yellow else return colors.red end end local function formatTime(ms) local seconds = math.floor(ms / 1000) local minutes = math.floor(seconds / 60) local hours = math.floor(minutes / 60) if hours > 0 then return string.format("%dh %dm", hours, minutes % 60) elseif minutes > 0 then return string.format("%dm %ds", minutes, seconds % 60) else return string.format("%ds", seconds) end end local function drawDashboard() if not hasMonitor then return end local w, h = monitor.getSize() monitor.setBackgroundColor(colors.black) monitor.clear() -- Count turtles local turtleCount = 0 for _ in pairs(turtles) do turtleCount = turtleCount + 1 end -- Header local connIcon = wsConnected and "WS" or "HTTP" monitor.setBackgroundColor(colors.blue) monitor.setCursorPos(1, 1) monitor.clearLine() centerText(1, "TURTLE BRIDGE [" .. connIcon .. "] - " .. turtleCount .. " ONLINE", colors.white, colors.blue) -- Status line monitor.setBackgroundColor(colors.gray) monitor.setCursorPos(1, 2) monitor.clearLine() monitor.setTextColor(colors.white) monitor.setCursorPos(2, 2) monitor.write("MSG:" .. stats.messagesReceived .. " CMD:" .. stats.commandsSent .. " ERR:" .. stats.errors) -- Turtle list monitor.setBackgroundColor(colors.black) local startY = 4 if turtleCount == 0 then monitor.setTextColor(colors.gray) monitor.setCursorPos(2, startY) monitor.write("No turtles connected. Waiting...") else local y = startY for id, turtle in pairs(turtles) do if y >= h - 5 then break end local statusColor = getStatusColor(turtle) local timeSince = 0 if turtle.lastSeen then timeSince = math.floor((os.epoch("utc") - turtle.lastSeen) / 1000) end monitor.setCursorPos(2, y) monitor.setTextColor(statusColor) monitor.write("\7") monitor.setTextColor(colors.white) monitor.write(string.format(" T#%-3d", id)) monitor.setTextColor(colors.lightGray) local tState = (turtle.mode or "IDLE"):upper() if #tState > 10 then tState = tState:sub(1, 10) end monitor.write(" " .. tState) if turtle.position then monitor.setTextColor(colors.gray) monitor.write(string.format(" [%d,%d,%d]", turtle.position.x or 0, turtle.position.y or 0, turtle.position.z or 0)) end monitor.setTextColor(colors.gray) monitor.write(string.format(" %ds", timeSince)) y = y + 1 end end -- Activity log local logY = h - 4 monitor.setBackgroundColor(colors.gray) monitor.setCursorPos(1, logY) monitor.clearLine() monitor.setTextColor(colors.yellow) monitor.setCursorPos(2, logY) monitor.write("ACTIVITY LOG") logY = logY + 1 monitor.setBackgroundColor(colors.black) for i = 1, math.min(3, #activityLog) do local entry = activityLog[i] monitor.setCursorPos(1, logY) monitor.clearLine() monitor.setTextColor(colors.gray) monitor.write(os.date("%H:%M:%S", (entry.time or 0) / 1000) .. " ") monitor.setTextColor(entry.color or colors.white) local maxWidth = w - 10 local text = entry.text if #text > maxWidth then text = text:sub(1, maxWidth - 3) .. "..." end monitor.write(text) logY = logY + 1 end end local function addLog(text, color) table.insert(activityLog, 1, { text = text, color = color or colors.white, time = os.epoch("utc") }) if #activityLog > 35 then table.remove(activityLog) end if not hasMonitor then print(text) end end -- ========== WebSocket Send Helper ========== local function wsSend(data) if wsHandle and wsConnected then local ok = pcall(function() wsHandle.send(textutils.serializeJSON(data)) end) return ok end return false end -- ========== HTTP Helpers (via platform) ========== local function httpPost(path, data) local result = WebBridge.httpPost(SERVER_URL .. path, data) if result then local ok, parsed = pcall(textutils.unserialiseJSON, result) if ok then return parsed end end return nil end local function httpGet(path) local result = WebBridge.httpGet(SERVER_URL .. path) if result then local ok, parsed = pcall(textutils.unserialiseJSON, result) if ok then return parsed end end return nil end -- ========== Forward Turtle Modem Message to Server ========== local function forwardToServer(message) if not message or type(message) ~= "table" then return end local msgType = message.type local turtleID = message.turtleID if msgType == "status" then turtles[turtleID] = message turtles[turtleID].lastSeen = os.epoch("utc") addLog("T#" .. turtleID .. " status", colors.lightBlue) if wsSend(message) then stats.serverUpdates = stats.serverUpdates + 1 else if httpPost("/api/turtle/update", message) then stats.serverUpdates = stats.serverUpdates + 1 else stats.errors = stats.errors + 1 addLog(" -> Server error", colors.red) end end elseif msgType == "eval_response" then addLog("Eval resp T#" .. turtleID .. " " .. (message.uuid or "?"):sub(1, 8), colors.cyan) if not wsSend({ type = "eval_response", turtleID = turtleID, uuid = message.uuid, result = message.result, error = message.error }) then local result = httpPost("/api/turtle/eval-response", { turtleID = turtleID, uuid = message.uuid, result = message.result, error = message.error }) if not result then addLog(" -> Eval resp FAILED to send!", colors.red) stats.errors = stats.errors + 1 end end elseif msgType == "request_home" then addLog("T#" .. turtleID .. " requesting home", colors.cyan) if wsConnected then wsSend({ type = "request_home", turtleID = turtleID }) else local result = httpGet("/api/turtle/" .. turtleID .. "/home") modem.transmit(COMMAND_CHANNEL, CHANNEL_RECEIVE, { type = "home_position", turtleID = turtleID, homePosition = result and result.homePosition or nil }) end elseif msgType == "set_home" then addLog("T#" .. turtleID .. " setting home", colors.cyan) if wsConnected then wsSend({ type = "set_home", turtleID = turtleID, position = message.position }) else local result = httpPost("/api/turtle/" .. turtleID .. "/home", { position = message.position }) modem.transmit(COMMAND_CHANNEL, CHANNEL_RECEIVE, { type = "home_set_confirm", turtleID = turtleID, homePosition = (result and result.success) and result.homePosition or message.position }) end elseif msgType == "blocks_discovered" then local blocks = message.blocks or {} if #blocks > 0 then addLog("T#" .. turtleID .. " discovered " .. #blocks .. " blocks", colors.cyan) if not wsSend({ type = "blocks_discovered", turtleID = turtleID, blocks = blocks }) then for _, block in ipairs(blocks) do httpPost("/api/world/blocks", { x = block.x, y = block.y, z = block.z, blockName = block.name, metadata = block.metadata or 0, discoveredBy = turtleID }) end end end elseif msgType == "inventory_update" then addLog("T#" .. turtleID .. " inventory", colors.cyan) if not wsSend({ type = "event", turtleID = turtleID, eventType = "INVENTORY_UPDATE", message = message.inventory }) then httpPost("/api/turtle/event", { turtleID = turtleID, type = "INVENTORY_UPDATE", message = message.inventory }) end elseif msgType == "peripheral_attached" then addLog("T#" .. turtleID .. " peripheral attached", colors.cyan) if not wsSend({ type = "event", turtleID = turtleID, eventType = "PERIPHERAL_ATTACHED", message = message.peripherals }) then httpPost("/api/turtle/event", { turtleID = turtleID, type = "PERIPHERAL_ATTACHED", message = message.peripherals }) end elseif msgType == "peripheral_detached" then addLog("T#" .. turtleID .. " peripheral detached: " .. (message.side or "?"), colors.cyan) if not wsSend({ type = "event", turtleID = turtleID, eventType = "PERIPHERAL_DETACHED", message = message.side }) then httpPost("/api/turtle/event", { turtleID = turtleID, type = "PERIPHERAL_DETACHED", message = message.side }) end end end -- ========== Handle Server Commands (from WS or HTTP poll) ========== local function handleServerCommand(data) if data.type == "command" then local turtleID = data.turtleID local cmd = data.command if cmd and cmd.type == "eval" then addLog("EVAL -> T#" .. turtleID .. " " .. (cmd.uuid or "?"):sub(1, 8), colors.yellow) stats.commandsSent = stats.commandsSent + 1 local commandPacket = { type = "eval", uuid = cmd.uuid, code = cmd.code, target = turtleID, } -- Send twice for reliability over modem for i = 1, 2 do modem.transmit(COMMAND_CHANNEL, CHANNEL_RECEIVE, commandPacket) os.sleep(0.05) end end elseif data.type == "home_position" then modem.transmit(COMMAND_CHANNEL, CHANNEL_RECEIVE, { type = "home_position", turtleID = data.turtleID, homePosition = data.homePosition }) addLog("Home -> T#" .. data.turtleID, colors.lime) elseif data.type == "home_set_confirm" then modem.transmit(COMMAND_CHANNEL, CHANNEL_RECEIVE, { type = "home_set_confirm", turtleID = data.turtleID, homePosition = data.homePosition }) addLog("Home confirmed T#" .. data.turtleID, colors.lime) elseif data.type == "init" then local ids = data.turtleIDs or {} addLog("Server knows " .. #ids .. " turtles", colors.lime) end end -- ========== Handle Pocket Computer Messages ========== local function handlePocketMessage(message) if type(message) ~= "table" or not message.from then return end local pocketID = message.from if message.type == "turtle_command" then addLog("Pocket #" .. pocketID .. " -> T#" .. message.turtleID .. ": " .. message.command, colors.magenta) modem.transmit(COMMAND_CHANNEL, CHANNEL_RECEIVE, { command = message.command, param = message.param, target = message.turtleID }) modem.transmit(POCKET_CHANNEL, POCKET_CHANNEL, { type = "command_ack", to = pocketID }) elseif message.type == "player_position" then addLog("Pocket #" .. pocketID .. " GPS", colors.cyan) if not wsSend({ type = "player_update", playerID = message.playerID, position = message.position, label = message.label }) then httpPost("/api/player/update", { playerID = message.playerID, position = message.position, label = message.label, timestamp = message.timestamp }) end elseif message.type == "server_stats_request" then addLog("Pocket #" .. pocketID .. " stats", colors.yellow) local result = httpGet("/api/stats") modem.transmit(POCKET_CHANNEL, POCKET_CHANNEL, { type = result and "server_stats" or "error", to = pocketID, data = result, error = not result and "Failed to fetch" or nil }) elseif message.type == "webbridge_control" then addLog("Pocket #" .. pocketID .. " " .. message.command, colors.orange) if message.command == "ping" then modem.transmit(POCKET_CHANNEL, POCKET_CHANNEL, { type = "webbridge_log", to = pocketID, text = "Pong! (" .. (wsConnected and "WS" or "HTTP") .. ")" }) elseif message.command == "status" then local tc = 0 for _ in pairs(turtles) do tc = tc + 1 end modem.transmit(POCKET_CHANNEL, POCKET_CHANNEL, { type = "webbridge_status", to = pocketID, data = { messages = stats.messagesReceived, commands = stats.commandsSent, turtles = tc, uptime = os.epoch("utc") - stats.startTime, wsConnected = wsConnected } }) elseif message.command == "restart" then modem.transmit(POCKET_CHANNEL, POCKET_CHANNEL, { type = "webbridge_log", to = pocketID, text = "Restarting..." }) sleep(1) os.reboot() elseif message.command == "logs" then for i = math.max(1, #activityLog - 5), #activityLog do if activityLog[i] then modem.transmit(POCKET_CHANNEL, POCKET_CHANNEL, { type = "webbridge_log", to = pocketID, text = activityLog[i].text }) end end end end end -- ========== Initialization ========== print("Web Bridge v2 (WebSocket Protocol)") print("Server: " .. SERVER_URL) print("WS: " .. WS_URL) print("Channels: RX=" .. CHANNEL_RECEIVE .. " STATUS=" .. STATUS_CHANNEL .. " CMD=" .. COMMAND_CHANNEL) if hasMonitor then drawDashboard() addLog("System initialized", colors.lime) end -- ========== Main Loop ========== parallel.waitForAny( -- WebSocket connection manager (reconnects automatically) function() while true do addLog("Connecting WebSocket...", colors.yellow) local ok, handle = pcall(http.websocket, WS_URL) if ok and handle then wsHandle = handle wsConnected = true addLog("WebSocket connected!", colors.lime) -- Read messages from WebSocket while true do local readOk, msg = pcall(function() return wsHandle.receive(30) end) if not readOk then addLog("WS read error, reconnecting...", colors.red) break end if msg == nil then -- Timeout, send keepalive local pingOk = pcall(function() wsHandle.send('{"type":"ping"}') end) if not pingOk then addLog("WS ping failed, reconnecting...", colors.red) break end else local parseOk, data = pcall(textutils.unserializeJSON, msg) if parseOk and data then handleServerCommand(data) end end end -- Clean up pcall(function() wsHandle.close() end) wsHandle = nil wsConnected = false addLog("WebSocket disconnected", colors.orange) else addLog("WS connect failed, retry in 5s", colors.red) stats.errors = stats.errors + 1 end sleep(5) end end, -- Modem message handler (turtles -> bridge -> server) function() while true do local event, side, channel, replyChannel, message, distance = os.pullEvent("modem_message") stats.messagesReceived = stats.messagesReceived + 1 if channel == STATUS_CHANNEL or channel == CHANNEL_RECEIVE then if type(message) == "table" then forwardToServer(message) end elseif channel == POCKET_CHANNEL then if type(message) == "table" then handlePocketMessage(message) end end end end, -- HTTP fallback polling (only active when WS is down) function() while true do if not wsConnected then for turtleID, _ in pairs(turtles) do local result = httpGet("/api/turtle/" .. turtleID .. "/commands") if result and result.commands and #result.commands > 0 then addLog("HTTP poll: " .. #result.commands .. " cmd(s) for T#" .. turtleID, colors.cyan) for _, cmd in ipairs(result.commands) do handleServerCommand({ type = "command", turtleID = turtleID, command = cmd }) end httpPost("/api/turtle/" .. turtleID .. "/commands/ack", {}) end end end sleep(wsConnected and 10 or 1) end end, -- Dashboard refresh function() while true do sleep(2) if hasMonitor then drawDashboard() end end end )