Files
Inventory-Manager-CC/inventoryWebBridge.lua

445 lines
16 KiB
Lua

-- Inventory Manager Web Bridge
-- Runs on a CC:Tweaked computer with a modem and HTTP access.
-- Listens to the inventory master's broadcasts via modem and
-- 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, 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')
-------------------------------------------------
-- Configuration (via platform)
-------------------------------------------------
local _baseDir = fs.getDir(shell.getRunningProgram())
local config, configSource = WebBridge.loadConfig({
serverUrl = "http://localhost",
pollInterval = 0.5,
stateInterval = 1,
apiKey = nil,
}, {
"usr/config/inventory-manager/.webbridge_config",
fs.combine(_baseDir, ".webbridge_config"),
})
local SERVER_URL = config.serverUrl
local POLL_INTERVAL = config.pollInterval
local STATE_INTERVAL = config.stateInterval
local API_KEY = config.apiKey
if configSource then
print("[CONFIG] Loaded from " .. configSource)
end
-- Channels from platform registry (matches inventoryManager.lua)
local BROADCAST_CHANNEL = Channels.get('inventory.broadcast')
local ORDER_CHANNEL = Channels.get('inventory.order')
local BRIDGE_REPLY_CHANNEL = Channels.get('inventory.bridge')
-------------------------------------------------
-- State
-------------------------------------------------
local latestState = nil -- last broadcast from master
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)
-------------------------------------------------
local function httpPost(path, body)
local result, err = WebBridge.httpPost(SERVER_URL .. path, body,
WebBridge.authHeaders(API_KEY))
if not result and err then
print(string.format("[ERR] HTTP POST %s: %s", path, tostring(err)))
end
return result
end
local function httpGet(path)
local rawBody, err = WebBridge.httpGet(SERVER_URL .. path,
WebBridge.authHeaders(API_KEY))
if not rawBody then
if err then
print(string.format("[ERR] HTTP GET %s: %s", path, tostring(err)))
end
return nil
end
return textutils.unserialiseJSON(rawBody)
end
-------------------------------------------------
-- Forward state to web server
-------------------------------------------------
local function forwardState()
if not latestState then return end
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
-------------------------------------------------
local function processCommand(cmd)
if not modem then return end
local action = cmd.action
if not action then return end
print(string.format("[CMD] %s", action))
if action == "order" then
modem.transmit(ORDER_CHANNEL, BRIDGE_REPLY_CHANNEL, {
type = "order",
commandId = cmd.commandId,
itemName = cmd.itemName,
amount = cmd.amount,
dropperName = cmd.dropperName,
})
elseif action == "scan" then
modem.transmit(ORDER_CHANNEL, BRIDGE_REPLY_CHANNEL, {
type = "scan",
commandId = cmd.commandId,
})
elseif action == "toggle_pause" then
modem.transmit(ORDER_CHANNEL, BRIDGE_REPLY_CHANNEL, {
type = "toggle_pause",
commandId = cmd.commandId,
})
elseif action == "toggle_recipe" then
modem.transmit(ORDER_CHANNEL, BRIDGE_REPLY_CHANNEL, {
type = "toggle_recipe",
commandId = cmd.commandId,
recipe = cmd.recipe,
})
elseif action == "enable_all" then
modem.transmit(ORDER_CHANNEL, BRIDGE_REPLY_CHANNEL, {
type = "enable_all",
commandId = cmd.commandId,
})
elseif action == "disable_all" then
modem.transmit(ORDER_CHANNEL, BRIDGE_REPLY_CHANNEL, {
type = "disable_all",
commandId = cmd.commandId,
})
elseif action == "sort_barrel" then
modem.transmit(ORDER_CHANNEL, BRIDGE_REPLY_CHANNEL, {
type = "sort_barrel",
commandId = cmd.commandId,
barrelName = cmd.barrelName,
})
elseif action == "craft" then
modem.transmit(ORDER_CHANNEL, BRIDGE_REPLY_CHANNEL, {
type = "craft",
commandId = cmd.commandId,
recipeIdx = cmd.recipeIdx,
})
elseif action == "recursive_craft" then
modem.transmit(ORDER_CHANNEL, BRIDGE_REPLY_CHANNEL, {
type = "recursive_craft",
commandId = cmd.commandId,
itemName = cmd.itemName,
count = cmd.count,
})
elseif action == "learn_crafting_recipe" then
modem.transmit(ORDER_CHANNEL, BRIDGE_REPLY_CHANNEL, {
type = "learn_crafting_recipe",
commandId = cmd.commandId,
output = cmd.output,
count = cmd.count,
grid = cmd.grid,
})
elseif action == "learn_smelting_recipe" then
modem.transmit(ORDER_CHANNEL, BRIDGE_REPLY_CHANNEL, {
type = "learn_smelting_recipe",
commandId = cmd.commandId,
input = cmd.input,
result = cmd.result,
furnaces = cmd.furnaces,
})
elseif action == "forget_recipe" then
modem.transmit(ORDER_CHANNEL, BRIDGE_REPLY_CHANNEL, {
type = "forget_recipe",
commandId = cmd.commandId,
recipe = cmd.recipe,
})
elseif action == "sync_disabled_recipes" then
modem.transmit(ORDER_CHANNEL, BRIDGE_REPLY_CHANNEL, {
type = "sync_disabled_recipes",
commandId = cmd.commandId,
disabledRecipes = cmd.disabledRecipes,
smeltingPaused = cmd.smeltingPaused,
})
elseif action == "reboot" then
modem.transmit(ORDER_CHANNEL, BRIDGE_REPLY_CHANNEL, {
type = "reboot",
commandId = cmd.commandId,
target = cmd.target or "all",
})
else
print("[CMD] Unknown action: " .. tostring(action))
end
end
-------------------------------------------------
-- Tasks
-------------------------------------------------
-- Task 1: Listen for modem broadcasts from master
local function modemListener()
while running do
local event, side, channel, replyChannel, message = os.pullEvent("modem_message")
if channel == BROADCAST_CHANNEL and type(message) == "table" then
if message.type == "state" then
latestState = message
end
elseif channel == BRIDGE_REPLY_CHANNEL and type(message) == "table" then
-- Forward command results back to web server
local resultType = message.type
if resultType == "order_result" or resultType == "craft_result"
or resultType == "recursive_craft_result" or resultType == "find_item_result" then
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,
})
-- 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
end
end
-- Task 2: Forward state to web server periodically
-- Uses WebSocket if connected; falls back to HTTP POST.
local function stateForwarder()
while running do
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 (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
-- 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
end
-- Acknowledge up to the highest processed ID
if maxId > lastProcessedId then
lastProcessedId = maxId
httpPost("/api/bridge/commands/ack", { lastProcessedId = lastProcessedId })
end
end
end)
if not ok then
print(string.format("[ERR] Command poll: %s", tostring(err)))
end
end
sleep(POLL_INTERVAL)
end
end
-- Task 4: Heartbeat / connection status display
local function heartbeat()
local connected = false
while running do
local ok, result = pcall(function()
return httpGet("/api/health")
end)
local nowConnected = ok and result ~= nil
if nowConnected ~= connected then
connected = nowConnected
if connected then
print("[WEB] Connected to web server")
else
print("[WEB] Disconnected from web server")
end
end
sleep(5)
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
-------------------------------------------------
local function main()
print("===================================")
print(" Inventory Manager - Web Bridge")
print("===================================")
print("")
-- Config already loaded at require-time via WebBridge.loadConfig above
modem, modemName = WebBridge.findModem()
if modem then
WebBridge.openChannels(modem,
{ 'inventory.broadcast', 'inventory.bridge' })
print("[OK] Modem: " .. modemName)
else
print("[WARN] No modem found! Bridge needs a modem.")
print(" Attach a modem and restart.")
return
end
print("[OK] Server URL: " .. SERVER_URL)
print("[OK] Poll interval: " .. POLL_INTERVAL .. "s")
print("[OK] State interval: " .. STATE_INTERVAL .. "s")
if API_KEY then
print("[OK] API key configured")
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)
print("")
parallel.waitForAny(
modemListener,
stateForwarder,
commandPoller,
heartbeat,
wsConnector
)
end
main()