Files
remoteturtle/webbridge.lua

534 lines
20 KiB
Lua

-- 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
)