2025-12-26 13:37:00 +01:00

290 lines
11 KiB
Lua

-- websocket/ws.lua
-- ComputerCraft / CC:Tweaked script
-- Opens a WebSocket to a server and sends aggregated item lists whenever the inventory changes.
-- Also listens for incoming messages from the server and prints them as:
-- from YYYY-MM-DD HH:MM:SS message
--
-- Configuration:
local url = "ws://10.0.1.1:3000" -- websocket URL (ws:// or wss://)
local inventorySide = "back" -- side to check first (e.g. "back", "left")
local requesterSide = "left" -- side to check second (e.g. "left", "right")
local pollInterval = 2 -- seconds between inventory scans
local fromName = os.getComputerLabel() -- identity included in outgoing payload
local computerID = os.getComputerID() -- identity included in outgoing payload
local reconnectBase = 2 -- seconds, base reconnect delay
local reconnectMax = 60 -- seconds, max reconnect delay
-- Helper: format timestamp (accepts seconds or ms)
local function format_ts(ts)
if not ts then return os.date("%Y-%m-%d %H:%M:%S") end
ts = tonumber(ts)
if not ts then return os.date("%Y-%m-%d %H:%M:%S") end
if ts > 1e11 then ts = ts / 1000 end -- treat large numbers as ms
return os.date("%Y-%m-%d %H:%M:%S", ts)
end
-- JSON serialization helper (prefer textutils.serializeJSON if available)
local function toJSON(tbl)
if type(textutils) == "table" and type(textutils.serializeJSON) == "function" then
local ok, s = pcall(textutils.serializeJSON, tbl)
if ok and type(s) == "string" then return s end
end
-- Simple fallback (escapes minimal characters)
local function esc(s)
s = tostring(s or "")
s = s:gsub("\\", "\\\\"):gsub('"', '\\"'):gsub("\n", "\\n"):gsub("\r", "\\r")
return s
end
local parts = { "{" }
local first = true
for k, v in pairs(tbl) do
if not first then table.insert(parts, ",") end
first = false
local key = esc(k)
if type(v) == "table" then
table.insert(parts, string.format('"%s":%s', key, toJSON(v)))
else
if type(v) == "string" then
table.insert(parts, string.format('"%s":"%s"', key, esc(v)))
else
table.insert(parts, string.format('"%s":%s', key, tostring(v)))
end
end
end
table.insert(parts, "}")
return table.concat(parts)
end
-- Peripheral discovery: try wrapping specified side
local ItemInventory = nil
local peripheralName = nil
if peripheral and peripheral.wrap then
ItemInventory = peripheral.wrap(inventorySide)
if ItemInventory then peripheralName = inventorySide end
end
if not ItemInventory then
print("No inventory peripheral found on '" .. inventorySide .. "'")
return
end
-- Peripheral discovery: try finding requester on specified side
local Requester = nil
if peripheral and peripheral.wrap then
Requester = peripheral.wrap(requesterSide)
end
if not Requester then
print("No requester peripheral found on '" .. requesterSide .. "'")
return
end
-- robust inventory read supporting list() or size()+getItemDetail()
local function readInventory()
local items = {}
-- try list()
if type(ItemInventory.list) == "function" then
local ok, res = pcall(ItemInventory.list, ItemInventory)
if ok and type(res) == "table" then
for slot, detail in pairs(res) do
if type(detail) == "table" then
local name = tostring(detail.name or detail.displayName or detail.id or "unknown")
local count = tonumber(detail.count or detail.qty or detail.size) or 1
table.insert(items, { slot = tonumber(slot) or slot, name = name, count = count })
else
local ok2, d2 = pcall(ItemInventory.getItemDetail, ItemInventory, slot)
if ok2 and type(d2) == "table" then
local name = tostring(d2.name or d2.displayName or d2.id or "unknown")
local count = tonumber(d2.count) or 1
table.insert(items, { slot = tonumber(slot) or slot, name = name, count = count })
end
end
end
return items
end
end
-- fallback: size() + getItemDetail()
if type(ItemInventory.size) == "function" and type(ItemInventory.getItemDetail) == "function" then
local ok, sz = pcall(ItemInventory.size, ItemInventory)
if ok and tonumber(sz) then
for i = 1, tonumber(sz) do
local ok2, d = pcall(ItemInventory.getItemDetail, ItemInventory, i)
if ok2 and type(d) == "table" then
local name = tostring(d.name or d.displayName or d.id or "unknown")
local count = tonumber(d.count) or 1
table.insert(items, { slot = i, name = name, count = count })
end
end
return items
end
end
return items
end
-- aggregate items by name (combine counts, DO NOT include slots)
local function aggregateItems(items)
local map = {}
for _, it in ipairs(items) do
local key = tostring(it.name)
local cnt = tonumber(it.count) or 0
if not map[key] then
map[key] = { name = key, count = cnt }
else
map[key].count = map[key].count + cnt
end
end
local out = {}
for _, v in pairs(map) do table.insert(out, v) end
table.sort(out, function(a, b) return a.name < b.name end)
return out
end
-- canonicalize aggregated list for easy change detection
local function canonicalize(items)
local agg = aggregateItems(items)
local out = {}
for _, it in ipairs(agg) do
table.insert(out, string.format("%s:%d", it.name, tonumber(it.count) or 0))
end
table.sort(out)
return table.concat(out, ";")
end
-- Format and print incoming payloads as: from timestamp message
local function handlePayload(payload)
local parsed = nil
local okJson, res = pcall(function() return textutils.unserializeJSON(payload) end)
if okJson then parsed = res end
print("Received payload:", textutils.serialize(parsed))
local from, text, ts, items, address, to
if type(parsed) == "table" then
if parsed.type == "request" then
items = parsed.items
address = tostring(parsed.address)
to = parsed.to
else
from = tostring(parsed.from or parsed.sender or parsed.user or "server")
text = tostring(parsed.text or parsed.message or parsed.msg or payload)
ts = parsed.ts or parsed.time
end
else
from = "server"
text = payload
ts = nil
type = "message"
end
if parsed and parsed.type == "request" and to == computerID then
print("Request: ", textutils.serialize(items), " from ", address, type(address))
print(Requester.request(items, address))
else
local when = format_ts(ts)
print(string.format("%s %s %s", from, when, text))
end
end
-- Send aggregated items via websocket (wsHandle is the websocket object)
local function sendItemsWs(wsHandle, items)
if not wsHandle then
print("Cannot send items: websocket not connected")
return false, "not-connected"
end
local agg = aggregateItems(items)
local payload = { from = fromName, id = computerID, ts = os.epoch("utc"), items = agg }
local s = toJSON(payload)
local ok, err = pcall(function() wsHandle.send(s) end)
if not ok then
print("Failed to send items:", err)
return false, err
end
print("Sent items (" .. tostring(#agg) .. " aggregated entries)")
return true
end
-- Open websocket with retries (returns ws handle or nil)
local function openWebsocket()
local attempt = 0
while true do
attempt = attempt + 1
local ok, wsOrErr = pcall(http.websocket, url)
if ok and wsOrErr then
print("Websocket opened to", url)
return wsOrErr
end
local err = wsOrErr or "unknown error"
print("Failed to open websocket:", err)
-- exponential backoff
local delay = math.min(reconnectMax, reconnectBase * (2 ^ (attempt - 1)))
-- add jitter
delay = delay + math.random() * math.min(3, delay)
print("Retrying in " .. string.format("%.1f", delay) .. "s...")
os.sleep(delay)
end
end
-- Main orchestrator: keep websocket open, poll inventory and send on change,
-- and handle incoming websocket events. Reconnect automatically.
local function run()
local prevKey = canonicalize(readInventory())
-- Outer loop to maintain connection
while true do
local ws = openWebsocket()
if not ws then
-- shouldn't happen as openWebsocket loops, but be safe: wait then continue
os.sleep(reconnectBase)
else
-- After connecting, send an initial snapshot right away
local initialItems = readInventory()
prevKey = canonicalize(initialItems)
pcall(sendItemsWs, ws, initialItems)
-- set up timer for polling
local timerId = os.startTimer(pollInterval)
local connected = true
-- Inner event loop: watch for timer ticks and websocket events
while connected do
local event, a, b = os.pullEvent()
if event == "timer" and a == timerId then
-- restart timer
timerId = os.startTimer(pollInterval)
local cur = readInventory()
local curKey = canonicalize(cur)
if curKey ~= prevKey then
prevKey = curKey
-- try sending; if send fails it's fine, the ws events will indicate closure
pcall(sendItemsWs, ws, cur)
end
elseif event == "websocket_message" and a == url then
-- b is payload
pcall(handlePayload, b)
elseif event == "websocket_request" and a == url then
pcall(handlePayload, b)
elseif event == "websocket_closed" and a == url then
print("Websocket closed:", tostring(b))
connected = false
elseif event == "websocket_failure" and a == url then
print("Websocket failure:", tostring(b))
connected = false
elseif event == "terminate" then
-- user requested termination (Ctrl+T)
print("Terminated by user. Closing websocket and exiting.")
pcall(function() if ws and type(ws.close) == "function" then ws.close() end end)
return
end
end
-- If we break out of inner loop, ensure websocket is closed then reconnect
pcall(function() if ws and type(ws.close) == "function" then ws.close() end end)
-- small pause before reconnect loop continues (openWebsocket has its own backoff)
os.sleep(0.5)
end
end
end
-- Start
math.randomseed(os.time() % 2 ^ 31)
print("Starting websocket inventory watcher")
print("Watching inventory on '" .. tostring(peripheralName or inventorySide) .. "' and sending updates to: " .. url)
run()