508 lines
19 KiB
Lua
508 lines
19 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.
|
|
|
|
local SERVER_URL = "http://beta:4200"
|
|
local WS_URL = "ws://beta:4200/ws/bridge"
|
|
local CHANNEL_RECEIVE = 101
|
|
local STATUS_CHANNEL = 102
|
|
local COMMAND_CHANNEL = 100
|
|
local POCKET_CHANNEL = 103
|
|
|
|
-- Find peripherals
|
|
local modem = peripheral.find("modem")
|
|
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
|
|
|
|
modem.open(CHANNEL_RECEIVE)
|
|
modem.open(STATUS_CHANNEL)
|
|
modem.open(POCKET_CHANNEL)
|
|
|
|
-- 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 Fallback Functions ==========
|
|
|
|
local function httpPost(path, data)
|
|
local success, result = pcall(function()
|
|
local response = http.post(SERVER_URL .. path, textutils.serializeJSON(data), { ["Content-Type"] = "application/json" })
|
|
if response then
|
|
local content = response.readAll()
|
|
response.close()
|
|
return textutils.unserializeJSON(content)
|
|
end
|
|
return nil
|
|
end)
|
|
return success and result or nil
|
|
end
|
|
|
|
local function httpGet(path)
|
|
local success, result = pcall(function()
|
|
local response = http.get(SERVER_URL .. path)
|
|
if response then
|
|
local content = response.readAll()
|
|
response.close()
|
|
return textutils.unserializeJSON(content)
|
|
end
|
|
return nil
|
|
end)
|
|
return success and result or 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
|
|
httpPost("/api/turtle/eval-response", { turtleID = turtleID, uuid = message.uuid, result = message.result, error = message.error })
|
|
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
|
|
) |