Compare commits
17 Commits
45b264dbc4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42fc9950f5 | ||
|
|
f0ca8b407e | ||
|
|
099f5aa287 | ||
|
|
16544b59bd | ||
|
|
f24b288de3 | ||
|
|
3c40cf9ef4 | ||
|
|
aa5f711fe4 | ||
|
|
5a83d89509 | ||
|
|
bb15c78ca9 | ||
|
|
4be2d7be8f | ||
|
|
9396fbd81a | ||
|
|
ec1a681924 | ||
|
|
36612ecc9f | ||
|
|
badde91336 | ||
|
|
c3344288a8 | ||
|
|
021b351248 | ||
|
|
b782d5c8f9 |
4
.package
4
.package
@@ -2,8 +2,8 @@
|
||||
required = {
|
||||
'platform',
|
||||
},
|
||||
title = "Inventory Manager",
|
||||
description = "Automated inventory management system for CC:Tweaked. Tracks items across networked storage, crafting turtles, furnaces, and alerts. Includes web dashboard via bridge computer.",
|
||||
title = "Inventory Manager (Unstable)",
|
||||
description = "UNSTABLE/DEV — Automated inventory management system for CC:Tweaked. Uses cc-platform-core. May have breaking changes. Install 'inventory-manager' for the stable version.",
|
||||
repository = "gitea://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/main/",
|
||||
exclude = {
|
||||
"^web/", "^__tests__/", "^startup/",
|
||||
|
||||
@@ -57,6 +57,7 @@ local ui = dofile(_path("lib/ui.lua"))
|
||||
-------------------------------------------------
|
||||
|
||||
local cfg = dofile(_path("manager/config.lua"))(log, _path)
|
||||
cfg.loadConfig()
|
||||
local state = dofile(_path("manager/state.lua"))()
|
||||
|
||||
-- Shared context table (Lua tables are by-reference, so all
|
||||
@@ -383,6 +384,11 @@ local function main()
|
||||
-- Parallel tasks
|
||||
-----------------------------------------------
|
||||
|
||||
-- Shared queue: capture task writes here, processor task reads.
|
||||
-- This ensures modem_message events are never lost while the
|
||||
-- processor yields for peripheral calls (pushItems, list, etc).
|
||||
local networkQueue = {}
|
||||
|
||||
parallel.waitForAny(
|
||||
-- Task 1: Background inventory scanner
|
||||
resilient("Scanner", function()
|
||||
@@ -630,16 +636,52 @@ local function main()
|
||||
end
|
||||
end),
|
||||
|
||||
-- Task 13: Network order/command listener
|
||||
resilient("Network-listener", function()
|
||||
-- Task 13a: Network message capture (fast — never yields to peripheral calls)
|
||||
-- This coroutine's filter is ALWAYS "modem_message", so it can never
|
||||
-- miss events while other tasks yield for "task_complete" etc.
|
||||
resilient("Network-capture", function()
|
||||
if not ctx.networkModem then
|
||||
log.warn("NET-CAP", "No modem — capture task idle")
|
||||
while true do sleep(3600) end
|
||||
end
|
||||
log.info("NET-CAP", "Capture task started, listening on ch %d", cfg.ORDER_CHANNEL)
|
||||
while true do
|
||||
local event, side, channel, replyChannel, message, distance = os.pullEvent("modem_message")
|
||||
if channel == cfg.ORDER_CHANNEL and type(message) == "table" then
|
||||
table.insert(networkQueue, { replyChannel = replyChannel, message = message })
|
||||
log.debug("NET-CAP", "Queued: type=%s queue=%d", tostring(message.type), #networkQueue)
|
||||
end
|
||||
end
|
||||
end),
|
||||
|
||||
-- Task 13b: Network message processor (drains queue — safe to yield)
|
||||
resilient("Network-processor", function()
|
||||
if not ctx.networkModem then
|
||||
log.warn("NET-PROC", "No modem — processor task idle")
|
||||
while true do sleep(3600) end
|
||||
end
|
||||
log.info("NET-PROC", "Processor task started")
|
||||
while true do
|
||||
if #networkQueue == 0 then
|
||||
os.pullEvent()
|
||||
end
|
||||
while #networkQueue > 0 do
|
||||
local entry = table.remove(networkQueue, 1)
|
||||
local message = entry.message
|
||||
local replyChannel = entry.replyChannel
|
||||
log.debug("NET-PROC", "Processing: type=%s id=%s queue=%d",
|
||||
tostring(message.type), tostring(message.commandId), #networkQueue)
|
||||
|
||||
if isCommandDuplicate(message.commandId) then
|
||||
log.debug("NET", "Duplicate command skipped: %s", tostring(message.commandId))
|
||||
-- Still ACK so the sender stops retrying
|
||||
pcall(function()
|
||||
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
|
||||
type = "command_ack",
|
||||
commandId = message.commandId,
|
||||
success = true,
|
||||
})
|
||||
end)
|
||||
else
|
||||
recordCommandId(message.commandId)
|
||||
cleanupCommandIds()
|
||||
@@ -675,6 +717,13 @@ local function main()
|
||||
state.needsRedraw = true
|
||||
state.smelterNeedsRedraw = true
|
||||
pcall(broadcastState)
|
||||
pcall(function()
|
||||
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
|
||||
type = "command_ack",
|
||||
commandId = message.commandId,
|
||||
success = true,
|
||||
})
|
||||
end)
|
||||
|
||||
elseif message.type == "toggle_pause" then
|
||||
state.smeltingPaused = not state.smeltingPaused
|
||||
@@ -684,6 +733,13 @@ local function main()
|
||||
state.smelterNeedsRedraw = true
|
||||
state.needsRedraw = true
|
||||
pcall(broadcastState)
|
||||
pcall(function()
|
||||
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
|
||||
type = "command_ack",
|
||||
commandId = message.commandId,
|
||||
success = true,
|
||||
})
|
||||
end)
|
||||
|
||||
elseif message.type == "toggle_recipe" and message.recipe then
|
||||
if state.disabledRecipes[message.recipe] then
|
||||
@@ -697,6 +753,13 @@ local function main()
|
||||
state.bumpStateVersion()
|
||||
state.smelterNeedsRedraw = true
|
||||
pcall(broadcastState)
|
||||
pcall(function()
|
||||
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
|
||||
type = "command_ack",
|
||||
commandId = message.commandId,
|
||||
success = true,
|
||||
})
|
||||
end)
|
||||
|
||||
elseif message.type == "enable_all" then
|
||||
state.disabledRecipes = {}
|
||||
@@ -706,6 +769,13 @@ local function main()
|
||||
state.bumpStateVersion()
|
||||
state.smelterNeedsRedraw = true
|
||||
pcall(broadcastState)
|
||||
pcall(function()
|
||||
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
|
||||
type = "command_ack",
|
||||
commandId = message.commandId,
|
||||
success = true,
|
||||
})
|
||||
end)
|
||||
|
||||
elseif message.type == "disable_all" then
|
||||
for inputName in pairs(cfg.SMELTABLE) do
|
||||
@@ -717,11 +787,25 @@ local function main()
|
||||
state.bumpStateVersion()
|
||||
state.smelterNeedsRedraw = true
|
||||
pcall(broadcastState)
|
||||
pcall(function()
|
||||
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
|
||||
type = "command_ack",
|
||||
commandId = message.commandId,
|
||||
success = true,
|
||||
})
|
||||
end)
|
||||
|
||||
elseif message.type == "sort_barrel" and message.barrelName then
|
||||
log.info("NET", "Sort barrel: %s", message.barrelName)
|
||||
pcall(ops.sortBarrel, message.barrelName)
|
||||
pcall(broadcastState)
|
||||
pcall(function()
|
||||
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
|
||||
type = "command_ack",
|
||||
commandId = message.commandId,
|
||||
success = true,
|
||||
})
|
||||
end)
|
||||
|
||||
elseif message.type == "register_droppers" and message.clientId and message.droppers then
|
||||
local cid = tostring(message.clientId)
|
||||
@@ -814,6 +898,13 @@ local function main()
|
||||
state.smelterNeedsRedraw = true
|
||||
state.needsRedraw = true
|
||||
pcall(broadcastState)
|
||||
pcall(function()
|
||||
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
|
||||
type = "command_ack",
|
||||
commandId = message.commandId,
|
||||
success = true,
|
||||
})
|
||||
end)
|
||||
|
||||
elseif message.type == "learn_crafting_recipe" and message.output and message.count and message.grid then
|
||||
cfg.recipeBook.learnCraftingRecipe(message.output, message.count, message.grid)
|
||||
@@ -823,6 +914,13 @@ local function main()
|
||||
state.configDirty = true
|
||||
state.bumpStateVersion()
|
||||
pcall(broadcastState)
|
||||
pcall(function()
|
||||
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
|
||||
type = "command_ack",
|
||||
commandId = message.commandId,
|
||||
success = true,
|
||||
})
|
||||
end)
|
||||
|
||||
elseif message.type == "learn_smelting_recipe" and message.input and message.result then
|
||||
cfg.recipeBook.learnSmeltingRecipe(message.input, message.result, message.furnaces)
|
||||
@@ -832,6 +930,13 @@ local function main()
|
||||
state.configDirty = true
|
||||
state.bumpStateVersion()
|
||||
pcall(broadcastState)
|
||||
pcall(function()
|
||||
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
|
||||
type = "command_ack",
|
||||
commandId = message.commandId,
|
||||
success = true,
|
||||
})
|
||||
end)
|
||||
|
||||
elseif message.type == "forget_recipe" and message.recipe then
|
||||
local forgot = cfg.recipeBook.forgetCraftingRecipe(message.recipe) or
|
||||
@@ -844,6 +949,13 @@ local function main()
|
||||
state.bumpStateVersion()
|
||||
end
|
||||
pcall(broadcastState)
|
||||
pcall(function()
|
||||
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
|
||||
type = "command_ack",
|
||||
commandId = message.commandId,
|
||||
success = true,
|
||||
})
|
||||
end)
|
||||
|
||||
elseif message.type == "find_item" and message.items then
|
||||
-- Return chest+slot locations for the first matching item
|
||||
@@ -888,7 +1000,7 @@ local function main()
|
||||
log.error("NET", "Handler error: %s", tostring(handlerErr))
|
||||
end
|
||||
end -- idempotency else
|
||||
end
|
||||
end -- queue loop
|
||||
end
|
||||
end)
|
||||
)
|
||||
|
||||
@@ -61,6 +61,15 @@ 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
|
||||
local wsHasSynced = false -- true after first command_batch received from server
|
||||
|
||||
-- Reliable modem delivery: pending commands awaiting manager acknowledgment.
|
||||
-- processCommand() inserts here; modemListener removes on result receipt.
|
||||
-- A retry task periodically re-transmits unacknowledged commands.
|
||||
local pendingModem = {} -- commandId -> { payload, channel, replyChannel, sent, retries }
|
||||
local MODEM_RETRY_INTERVAL = 2 -- seconds between retry sweeps
|
||||
local MODEM_RETRY_MAX = 5 -- max retransmissions before giving up
|
||||
local MODEM_RETRY_DELAY = 2 -- seconds before first retry (per command)
|
||||
|
||||
-------------------------------------------------
|
||||
-- HTTP helpers (thin wrappers around platform)
|
||||
@@ -134,96 +143,55 @@ local function processCommand(cmd)
|
||||
|
||||
print(string.format("[CMD] %s", action))
|
||||
|
||||
-- Build the modem payload from the server command
|
||||
local payload
|
||||
if action == "order" then
|
||||
modem.transmit(ORDER_CHANNEL, BRIDGE_REPLY_CHANNEL, {
|
||||
payload = {
|
||||
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,
|
||||
})
|
||||
payload = { type = "scan", commandId = cmd.commandId }
|
||||
elseif action == "toggle_pause" then
|
||||
modem.transmit(ORDER_CHANNEL, BRIDGE_REPLY_CHANNEL, {
|
||||
type = "toggle_pause",
|
||||
commandId = cmd.commandId,
|
||||
})
|
||||
payload = { 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,
|
||||
})
|
||||
payload = { 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,
|
||||
})
|
||||
payload = { type = "enable_all", commandId = cmd.commandId }
|
||||
elseif action == "disable_all" then
|
||||
modem.transmit(ORDER_CHANNEL, BRIDGE_REPLY_CHANNEL, {
|
||||
type = "disable_all",
|
||||
commandId = cmd.commandId,
|
||||
})
|
||||
payload = { 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,
|
||||
})
|
||||
payload = { 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,
|
||||
})
|
||||
payload = { 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,
|
||||
})
|
||||
payload = { 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,
|
||||
})
|
||||
payload = { 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,
|
||||
})
|
||||
payload = { 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,
|
||||
})
|
||||
payload = { 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,
|
||||
})
|
||||
payload = { 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",
|
||||
})
|
||||
payload = { type = "reboot", commandId = cmd.commandId, target = cmd.target or "all" }
|
||||
else
|
||||
print("[CMD] Unknown action: " .. tostring(action))
|
||||
return
|
||||
end
|
||||
|
||||
-- Transmit and track for reliable delivery
|
||||
modem.transmit(ORDER_CHANNEL, BRIDGE_REPLY_CHANNEL, payload)
|
||||
if payload.commandId then
|
||||
pendingModem[payload.commandId] = {
|
||||
payload = payload,
|
||||
sent = os.clock(),
|
||||
retries = 0,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -240,6 +208,10 @@ local function modemListener()
|
||||
latestState = message
|
||||
end
|
||||
elseif channel == BRIDGE_REPLY_CHANNEL and type(message) == "table" then
|
||||
-- Clear pending retry on any response matching a commandId
|
||||
if message.commandId and pendingModem[message.commandId] then
|
||||
pendingModem[message.commandId] = nil
|
||||
end
|
||||
-- Forward command results back to web server
|
||||
local resultType = message.type
|
||||
if resultType == "order_result" or resultType == "craft_result"
|
||||
@@ -291,13 +263,13 @@ local function stateForwarder()
|
||||
end
|
||||
|
||||
-- Task 3: Poll web server for commands (HTTP fallback)
|
||||
-- Skipped when WebSocket is connected (commands arrive via WS push).
|
||||
-- Active when WS is disconnected, or as safety net until first WS sync.
|
||||
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
|
||||
-- HTTP polling is a fallback; also runs until WS initial sync completes
|
||||
if not wsConnected or not wsHasSynced then
|
||||
local ok, err = pcall(function()
|
||||
local result = httpGet("/api/bridge/commands")
|
||||
if result and result.commands and #result.commands > 0 then
|
||||
@@ -370,8 +342,21 @@ local function wsConnector()
|
||||
end,
|
||||
|
||||
onMessage = function(wsHandle, data)
|
||||
-- Initial sync: server sends pending commands as a batch on connect
|
||||
if data.type == 'command_batch' and data.commands then
|
||||
wsHasSynced = true
|
||||
for _, cmd in ipairs(data.commands) do
|
||||
local cmdOk, cmdErr = pcall(processCommand, cmd)
|
||||
if not cmdOk then
|
||||
print(string.format("[ERR] WS batch cmd %s: %s",
|
||||
tostring(cmd.action), tostring(cmdErr)))
|
||||
end
|
||||
end
|
||||
if #data.commands > 0 then
|
||||
print(string.format("[WS] Processed %d synced command(s)", #data.commands))
|
||||
end
|
||||
-- Server pushes commands via WebSocket (replaces HTTP polling)
|
||||
if data.action then
|
||||
elseif data.action then
|
||||
local cmdOk, cmdErr = pcall(processCommand, data)
|
||||
if not cmdOk then
|
||||
print(string.format("[ERR] WS cmd %s: %s",
|
||||
@@ -383,6 +368,7 @@ local function wsConnector()
|
||||
onDisconnect = function()
|
||||
ws = nil
|
||||
wsConnected = false
|
||||
wsHasSynced = false
|
||||
print("[WS] Disconnected — HTTP polling fallback active")
|
||||
end,
|
||||
|
||||
@@ -395,6 +381,30 @@ local function wsConnector()
|
||||
})
|
||||
end
|
||||
|
||||
-- Task 6: Retry unacknowledged modem commands
|
||||
-- The manager deduplicates by commandId, so retransmits are safe.
|
||||
local function modemRetry()
|
||||
while running do
|
||||
local now = os.clock()
|
||||
for cmdId, entry in pairs(pendingModem) do
|
||||
if now - entry.sent >= MODEM_RETRY_DELAY then
|
||||
if entry.retries >= MODEM_RETRY_MAX then
|
||||
print(string.format("[RETRY] Giving up on %s after %d retries",
|
||||
tostring(cmdId), entry.retries))
|
||||
pendingModem[cmdId] = nil
|
||||
else
|
||||
entry.retries = entry.retries + 1
|
||||
entry.sent = now
|
||||
print(string.format("[RETRY] Re-transmit %s (attempt %d/%d)",
|
||||
tostring(cmdId), entry.retries, MODEM_RETRY_MAX))
|
||||
pcall(modem.transmit, ORDER_CHANNEL, BRIDGE_REPLY_CHANNEL, entry.payload)
|
||||
end
|
||||
end
|
||||
end
|
||||
sleep(MODEM_RETRY_INTERVAL)
|
||||
end
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Main
|
||||
-------------------------------------------------
|
||||
@@ -411,7 +421,10 @@ local function main()
|
||||
if modem then
|
||||
WebBridge.openChannels(modem,
|
||||
{ 'inventory.broadcast', 'inventory.bridge' })
|
||||
print("[OK] Modem: " .. modemName)
|
||||
local modemType = modem.isWireless and (modem.isWireless() and "wireless" or "wired") or "unknown"
|
||||
print(string.format("[OK] Modem: %s (%s)", modemName, modemType))
|
||||
print(string.format("[OK] TX ch %d, listen ch %d/%d",
|
||||
ORDER_CHANNEL, BROADCAST_CHANNEL, BRIDGE_REPLY_CHANNEL))
|
||||
else
|
||||
print("[WARN] No modem found! Bridge needs a modem.")
|
||||
print(" Attach a modem and restart.")
|
||||
@@ -437,7 +450,8 @@ local function main()
|
||||
stateForwarder,
|
||||
commandPoller,
|
||||
heartbeat,
|
||||
wsConnector
|
||||
wsConnector,
|
||||
modemRetry
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -120,71 +120,135 @@ end
|
||||
-- Monitor setup
|
||||
-------------------------------------------------
|
||||
|
||||
local function findMonitor(side, excludeSide)
|
||||
local mon = peripheral.wrap(side)
|
||||
local monName
|
||||
if mon and mon.setTextScale then
|
||||
monName = side
|
||||
else
|
||||
mon = nil
|
||||
end
|
||||
if not mon then
|
||||
for _, name in ipairs(peripheral.getNames()) do
|
||||
if peripheral.getType(name) == "monitor" and name ~= excludeSide then
|
||||
mon = peripheral.wrap(name)
|
||||
monName = name
|
||||
break
|
||||
--- Track peripheral names already assigned to a role.
|
||||
-- A single physical monitor can appear under multiple names (e.g. "left"
|
||||
-- AND "monitor_0") when it is both side-attached and on a wired modem.
|
||||
-- We detect aliases by mutating text scale on one name and checking
|
||||
-- whether a known monitor's getSize() changes.
|
||||
D._usedMonitorNames = {} -- set of names known to be taken
|
||||
|
||||
--- Detect whether 'candidateName' is the same physical block as 'knownMon'
|
||||
-- (a wrapped peripheral table). We temporarily set the candidate's text
|
||||
-- scale to an extreme value and check whether the known monitor reports a
|
||||
-- size change. If it does, they share the same hardware.
|
||||
local function isMonitorAlias(candidateName, knownMon)
|
||||
if not candidateName or not knownMon then return false end
|
||||
local refW, refH = knownMon.getSize()
|
||||
-- Save candidate's current scale (getTextScale available CC:T 1.94+)
|
||||
local ok, origScale = pcall(peripheral.call, candidateName, "getTextScale")
|
||||
if not ok then origScale = 1 end
|
||||
-- Pick a test scale far from the current one
|
||||
local testScale = (origScale >= 3) and 0.5 or 5
|
||||
pcall(peripheral.call, candidateName, "setTextScale", testScale)
|
||||
local newW, newH = knownMon.getSize()
|
||||
-- Restore
|
||||
pcall(peripheral.call, candidateName, "setTextScale", origScale)
|
||||
return newW ~= refW or newH ~= refH
|
||||
end
|
||||
|
||||
--- Register all peripheral names that refer to the same physical block as
|
||||
-- 'knownName' / 'knownMon'. This populates D._usedMonitorNames so that
|
||||
-- later auto-detection can skip aliases by simple table lookup.
|
||||
local function registerMonitorAliases(knownName, knownMon)
|
||||
D._usedMonitorNames[knownName] = true
|
||||
for _, name in ipairs(peripheral.getNames()) do
|
||||
if name ~= knownName and peripheral.getType(name) == "monitor" then
|
||||
if isMonitorAlias(name, knownMon) then
|
||||
D._usedMonitorNames[name] = true
|
||||
log.debug("DISPLAY", "Monitor alias: %s => %s", name, knownName)
|
||||
end
|
||||
end
|
||||
end
|
||||
return mon, monName
|
||||
end
|
||||
|
||||
function D.setupMonitor()
|
||||
D.mon, D.monName = findMonitor(cfg.MONITOR_SIDE, cfg.SMELTER_MONITOR_SIDE)
|
||||
local mon = peripheral.wrap(cfg.MONITOR_SIDE)
|
||||
if not mon or not mon.setTextScale then
|
||||
-- Fallback: find any monitor
|
||||
for _, name in ipairs(peripheral.getNames()) do
|
||||
if peripheral.getType(name) == "monitor" then
|
||||
mon = peripheral.wrap(name)
|
||||
if mon and mon.setTextScale then
|
||||
D.mon = mon
|
||||
D.monName = name
|
||||
break
|
||||
end
|
||||
mon = nil
|
||||
end
|
||||
end
|
||||
else
|
||||
D.mon = mon
|
||||
D.monName = cfg.MONITOR_SIDE
|
||||
end
|
||||
if not D.mon then return false end
|
||||
|
||||
mainDevice = UI.Device({
|
||||
device = D.mon,
|
||||
textScale = 0.5,
|
||||
})
|
||||
|
||||
-- Register this monitor and all its aliases as taken
|
||||
registerMonitorAliases(D.monName, D.mon)
|
||||
return true
|
||||
end
|
||||
|
||||
function D.setupSmelterMonitor()
|
||||
D.smelterMon, D.smelterMonName = findMonitor(cfg.SMELTER_MONITOR_SIDE, D.monName)
|
||||
if not D.smelterMon then return false end
|
||||
-- Try configured side first
|
||||
local mon = peripheral.wrap(cfg.SMELTER_MONITOR_SIDE)
|
||||
local monName = cfg.SMELTER_MONITOR_SIDE
|
||||
if not mon or not mon.setTextScale or D._usedMonitorNames[monName] then
|
||||
mon = nil
|
||||
monName = nil
|
||||
-- Fallback: find any unused monitor
|
||||
for _, name in ipairs(peripheral.getNames()) do
|
||||
if peripheral.getType(name) == "monitor" and not D._usedMonitorNames[name] then
|
||||
mon = peripheral.wrap(name)
|
||||
if mon and mon.setTextScale then
|
||||
monName = name
|
||||
break
|
||||
end
|
||||
mon = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
if not mon then return false end
|
||||
|
||||
D.smelterMon = mon
|
||||
D.smelterMonName = monName
|
||||
|
||||
smelterDevice = UI.Device({
|
||||
device = D.smelterMon,
|
||||
textScale = 0.5,
|
||||
})
|
||||
|
||||
-- Register this monitor and all its aliases as taken
|
||||
registerMonitorAliases(D.smelterMonName, D.smelterMon)
|
||||
return true
|
||||
end
|
||||
|
||||
function D.setupBillboardMonitor()
|
||||
-- If explicitly configured, use that name
|
||||
local scale = cfg.BILLBOARD_TEXT_SCALE or 1
|
||||
-- If explicitly configured, use that name
|
||||
if cfg.BILLBOARD_MONITOR and cfg.BILLBOARD_MONITOR ~= "" then
|
||||
local mon = peripheral.wrap(cfg.BILLBOARD_MONITOR)
|
||||
if mon and mon.setTextScale then
|
||||
D.billboardMon = mon
|
||||
D.billboardMonName = cfg.BILLBOARD_MONITOR
|
||||
D.billboardMon.setTextScale(scale)
|
||||
registerMonitorAliases(D.billboardMonName, D.billboardMon)
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
-- Auto-detect: find any monitor not already used by main/smelter
|
||||
for _, name in ipairs(peripheral.getNames()) do
|
||||
if peripheral.getType(name) == "monitor"
|
||||
and name ~= D.monName
|
||||
and name ~= D.smelterMonName then
|
||||
if peripheral.getType(name) == "monitor" and not D._usedMonitorNames[name] then
|
||||
local mon = peripheral.wrap(name)
|
||||
if mon and mon.setTextScale then
|
||||
D.billboardMon = mon
|
||||
D.billboardMonName = name
|
||||
D.billboardMon.setTextScale(scale)
|
||||
registerMonitorAliases(D.billboardMonName, D.billboardMon)
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1338,7 +1338,6 @@ function O.craftItem(recipeIdx, batches)
|
||||
log.info("CRAFT", "Waiting for turtle reply (timeout: %ds)...", cfg.CRAFT_TIMEOUT)
|
||||
local deadline = os.clock() + cfg.CRAFT_TIMEOUT
|
||||
local result = nil
|
||||
local bufferedMessages = {}
|
||||
|
||||
while os.clock() < deadline do
|
||||
local timerId = os.startTimer(math.max(0.1, deadline - os.clock()))
|
||||
@@ -1350,18 +1349,13 @@ function O.craftItem(recipeIdx, batches)
|
||||
if channel == cfg.CRAFT_REPLY_CHANNEL and type(message) == "table" and message.type == "craft_result" then
|
||||
result = message
|
||||
break
|
||||
elseif channel == cfg.ORDER_CHANNEL then
|
||||
table.insert(bufferedMessages, {event, p1, p2, p3, p4, p5})
|
||||
end
|
||||
-- ORDER_CHANNEL messages are captured by the Network-capture task
|
||||
elseif event == "timer" and p1 == timerId then
|
||||
-- Timeout tick
|
||||
end
|
||||
end
|
||||
|
||||
for _, msg in ipairs(bufferedMessages) do
|
||||
os.queueEvent(table.unpack(msg))
|
||||
end
|
||||
|
||||
activity.crafting = false
|
||||
state.needsRedraw = true
|
||||
state.smelterNeedsRedraw = true
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
# Node.js backend
|
||||
# Stage 1: Fetch platform server package from git
|
||||
FROM alpine:3.20 AS platform
|
||||
RUN apk add --no-cache git
|
||||
ARG PLATFORM_REPO=https://git.spatulaa.com/MayaTheShy/cc-platform-core.git
|
||||
ARG PLATFORM_BRANCH=master
|
||||
RUN git clone --depth 1 --branch "$PLATFORM_BRANCH" "$PLATFORM_REPO" /src \
|
||||
&& rm -rf /src/server/node_modules /src/.git
|
||||
|
||||
# Stage 2: Node.js backend
|
||||
FROM node:20-alpine
|
||||
|
||||
# Build tools needed for better-sqlite3 native compilation
|
||||
@@ -8,8 +16,15 @@ RUN apk add --no-cache python3 make g++ su-exec libstdc++
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy platform server package from the git-clone stage
|
||||
COPY --from=platform /src/server /app/platform-server/
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
# Rewrite file: dependency to use the local copy inside the container
|
||||
RUN sed -i 's|file:../../../cc-platform-core/server|file:./platform-server|' package.json \
|
||||
&& rm -f package-lock.json
|
||||
|
||||
RUN npm install --omit=dev
|
||||
|
||||
# Remove build tools after install to keep image small
|
||||
|
||||
18
web/server/package-lock.json
generated
18
web/server/package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "inventory-manager-server",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@cc-platform/server": "file:../../../cc-platform-core/server",
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
@@ -18,6 +19,23 @@
|
||||
"vitest": "^3.2.1"
|
||||
}
|
||||
},
|
||||
"../../../cc-platform-core/server": {
|
||||
"name": "@cc-platform/server",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.0.0",
|
||||
"express": "^4.21.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cc-platform/server": {
|
||||
"resolved": "../../../cc-platform-core/server",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { WebSocketServer } from 'ws';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
const require = createRequire(import.meta.url);
|
||||
const {
|
||||
createPlatformServer,
|
||||
createWebSocketManager,
|
||||
setupGracefulShutdown,
|
||||
createRateLimiter,
|
||||
createProxyEndpoint,
|
||||
@@ -46,9 +46,107 @@ app.use((req, res, next) => {
|
||||
return commandLimiter(req, res, next);
|
||||
});
|
||||
|
||||
// ========== WebSocket Manager (platform-managed) ==========
|
||||
// Replaces hand-rolled WebSocketServer with createWebSocketManager() from
|
||||
// @cc-platform/server. Handles bridge/client URL routing, API key auth on
|
||||
// upgrade, ping/pong keepalive with stale-connection cleanup.
|
||||
//
|
||||
// Bridge path: /ws/bridge — receives state/results, pushes commands real-time
|
||||
// Client path: /ws — receives initial_state on connect, sends commands
|
||||
//
|
||||
// HTTP polling fallback preserved via /api/bridge/* endpoints until WS
|
||||
// adoption is fully verified.
|
||||
const wsManager = createWebSocketManager(server, {
|
||||
apiKey: API_KEY,
|
||||
|
||||
// ---- Bridge lifecycle ----
|
||||
onBridgeConnect: (ws) => {
|
||||
console.log('\u{1F309} CC:Tweaked bridge connected via WebSocket');
|
||||
wsManager.broadcastToClients({ type: 'state_update', bridgeConnected: true });
|
||||
|
||||
// Flush any pending commands queued while no WS bridge was connected.
|
||||
// Replaces the implicit sync that HTTP polling previously provided.
|
||||
const pending = getPendingCommands();
|
||||
if (pending.length > 0) {
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: 'command_batch', commands: pending }));
|
||||
console.log(`[Bridge] Flushed ${pending.length} pending command(s) via WS`);
|
||||
} catch (e) {
|
||||
console.error('[Bridge] Failed to flush pending commands:', e.message);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onBridgeMessage: (ws, data) => {
|
||||
if (data.type === 'state') {
|
||||
// Full state update from bridge (same path as POST /api/bridge/state)
|
||||
updateStateFromBridge(data);
|
||||
} else if (data.type === 'command_result') {
|
||||
// Command result from bridge — forward to all web clients
|
||||
wsManager.broadcastToClients({
|
||||
type: 'command_result',
|
||||
commandId: data.commandId,
|
||||
action: data.action,
|
||||
success: data.success,
|
||||
message: data.message,
|
||||
error: data.error,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
onBridgeDisconnect: () => {
|
||||
console.log('\u{1F309} CC:Tweaked bridge disconnected');
|
||||
wsManager.broadcastToClients({
|
||||
type: 'state_update',
|
||||
bridgeConnected: wsManager.bridgeClients.size > 0,
|
||||
});
|
||||
},
|
||||
|
||||
// ---- Web client lifecycle ----
|
||||
onClientConnect: (ws) => {
|
||||
console.log('\u{1F310} New web client connected');
|
||||
// Send full current state to newly connected dashboard
|
||||
ws.send(JSON.stringify({
|
||||
type: 'initial_state',
|
||||
inventory: inventoryState,
|
||||
activity: activityState,
|
||||
alerts: alertsState,
|
||||
smeltingPaused,
|
||||
disabledRecipes,
|
||||
smeltable: smeltableRecipes,
|
||||
craftable: craftableRecipes,
|
||||
craftTurtleOk,
|
||||
lastUpdate,
|
||||
bridgeConnected: wsManager.bridgeClients.size > 0,
|
||||
dropperNicknames,
|
||||
}));
|
||||
},
|
||||
|
||||
onClientMessage: (ws, data) => {
|
||||
if (data.type === 'command') {
|
||||
// Idempotency check for WS commands
|
||||
const cached = checkIdempotent(data.commandId);
|
||||
if (cached) {
|
||||
ws.send(JSON.stringify({ type: 'command_result', commandId: data.commandId, ...cached }));
|
||||
return;
|
||||
}
|
||||
// Forward command to bridge (WS push or HTTP poll queue)
|
||||
pushCommandToBridge(data);
|
||||
if (data.commandId) {
|
||||
recordCommand(data.commandId, { success: true, commandId: data.commandId });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onClientDisconnect: () => {
|
||||
console.log('\u{1F44B} Web client disconnected');
|
||||
},
|
||||
});
|
||||
|
||||
// Aliases — backward compatibility for code referencing these Sets directly
|
||||
const { bridgeClients, webClients } = wsManager;
|
||||
|
||||
// ========== State ==========
|
||||
const webClients = new Set();
|
||||
const bridgeClients = new Set();
|
||||
|
||||
// Load persisted state from SQLite on startup
|
||||
console.log('💾 Loading persisted state from database...');
|
||||
@@ -101,31 +199,25 @@ function recordCommand(commandId, result) {
|
||||
|
||||
// ========== Helpers ==========
|
||||
|
||||
// Broadcasts data to all connected web dashboard clients.
|
||||
// Delegates to platform WS manager (handles JSON serialization, error handling).
|
||||
function broadcastToClients(data) {
|
||||
const message = JSON.stringify(data);
|
||||
webClients.forEach((client) => {
|
||||
if (client.readyState === 1) {
|
||||
try {
|
||||
client.send(message);
|
||||
} catch (err) {
|
||||
console.error('❌ WS send error (client):', err.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
wsManager.broadcastToClients(data);
|
||||
}
|
||||
|
||||
// Returns a filtered copy of pending commands (clears expired entries).
|
||||
// Used by both the HTTP polling endpoint and WS initial sync.
|
||||
function getPendingCommands() {
|
||||
const now = Date.now();
|
||||
pendingCommands = pendingCommands.filter(cmd => (now - cmd.timestamp) < 30000);
|
||||
return [...pendingCommands];
|
||||
}
|
||||
|
||||
// Pushes a command to the CC:Tweaked bridge.
|
||||
// Primary: real-time push via WebSocket (wsManager.sendToBridge).
|
||||
// Fallback: queues for HTTP polling if no WS bridge is connected.
|
||||
function pushCommandToBridge(command) {
|
||||
let sent = false;
|
||||
for (const bridge of bridgeClients) {
|
||||
if (bridge.readyState === 1) {
|
||||
try {
|
||||
bridge.send(JSON.stringify(command));
|
||||
sent = true;
|
||||
} catch (err) {
|
||||
console.error('❌ WS send error (bridge):', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
const sent = wsManager.sendToBridge(command);
|
||||
if (!sent) {
|
||||
// Fallback: queue for HTTP polling with monotonic ID
|
||||
const id = nextCommandId++;
|
||||
@@ -914,11 +1006,7 @@ app.post('/api/bridge/state', (req, res) => {
|
||||
// Bridge polls for pending commands (auth required — contains operational data)
|
||||
app.get('/api/bridge/commands', requireAuth, (req, res) => {
|
||||
try {
|
||||
const now = Date.now();
|
||||
// Clear old commands (>30s)
|
||||
pendingCommands = pendingCommands.filter(cmd => (now - cmd.timestamp) < 30000);
|
||||
|
||||
const commands = [...pendingCommands];
|
||||
const commands = getPendingCommands();
|
||||
res.json({ commands });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
@@ -1094,9 +1182,9 @@ function updateStateFromBridge(data) {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== WebSocket Server ==========
|
||||
|
||||
const wss = new WebSocketServer({ noServer: true, maxPayload: 1 * 1024 * 1024 /* 1 MB */ });
|
||||
// ========== Server Startup Info ==========
|
||||
// WebSocket management is handled by createWebSocketManager() (initialized above).
|
||||
// Bridge: /ws/bridge | Client: /ws | Keepalive: 25s ping/pong (platform default)
|
||||
|
||||
console.log(`🚀 Inventory Manager Web Server starting...`);
|
||||
console.log(`📡 HTTP Server: http://localhost:${port}`);
|
||||
@@ -1107,174 +1195,6 @@ if (API_KEY) {
|
||||
console.log('⚠️ No API_KEY set \u2014 authentication disabled (open access)');
|
||||
}
|
||||
|
||||
// Authenticate WebSocket upgrades
|
||||
server.on('upgrade', (req, socket, head) => {
|
||||
if (API_KEY) {
|
||||
// Extract key from query string: /ws?key=... or /ws/bridge?key=...
|
||||
const urlObj = new URL(req.url, `http://${req.headers.host}`);
|
||||
const token = urlObj.searchParams.get('key') || '';
|
||||
if (token !== API_KEY) {
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
}
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
wss.emit('connection', ws, req);
|
||||
});
|
||||
});
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
const url = req.url || '';
|
||||
|
||||
// ---- Bridge WebSocket connection ----
|
||||
if (url.startsWith('/ws/bridge')) {
|
||||
console.log('🌉 CC:Tweaked bridge connected via WebSocket');
|
||||
bridgeClients.add(ws);
|
||||
ws.isAlive = true;
|
||||
|
||||
// Notify web clients that the bridge is now connected
|
||||
broadcastToClients({
|
||||
type: 'state_update',
|
||||
bridgeConnected: true,
|
||||
});
|
||||
|
||||
ws.on('message', (raw) => {
|
||||
try {
|
||||
const data = JSON.parse(raw);
|
||||
|
||||
if (data.type === 'ping') {
|
||||
ws.send(JSON.stringify({ type: 'pong' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === 'state') {
|
||||
// Full state update from bridge
|
||||
updateStateFromBridge(data);
|
||||
} else if (data.type === 'command_result') {
|
||||
// Command result from bridge — include commandId
|
||||
broadcastToClients({
|
||||
type: 'command_result',
|
||||
commandId: data.commandId,
|
||||
action: data.action,
|
||||
success: data.success,
|
||||
message: data.message,
|
||||
error: data.error,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Bridge WS message error:', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('🌉 CC:Tweaked bridge disconnected');
|
||||
bridgeClients.delete(ws);
|
||||
|
||||
// Notify web clients that the bridge may be disconnected
|
||||
broadcastToClients({
|
||||
type: 'state_update',
|
||||
bridgeConnected: bridgeClients.size > 0,
|
||||
});
|
||||
});
|
||||
|
||||
ws.on('pong', () => { ws.isAlive = true; });
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('❌ Bridge WS error:', error);
|
||||
bridgeClients.delete(ws);
|
||||
|
||||
broadcastToClients({
|
||||
type: 'state_update',
|
||||
bridgeConnected: bridgeClients.size > 0,
|
||||
});
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- Web client WebSocket connection ----
|
||||
console.log('🌐 New web client connected');
|
||||
webClients.add(ws);
|
||||
ws.isAlive = true;
|
||||
|
||||
// Send current state to new client
|
||||
ws.send(JSON.stringify({
|
||||
type: 'initial_state',
|
||||
inventory: inventoryState,
|
||||
activity: activityState,
|
||||
alerts: alertsState,
|
||||
smeltingPaused,
|
||||
disabledRecipes,
|
||||
smeltable: smeltableRecipes,
|
||||
craftable: craftableRecipes,
|
||||
craftTurtleOk,
|
||||
lastUpdate,
|
||||
bridgeConnected: bridgeClients.size > 0,
|
||||
dropperNicknames,
|
||||
}));
|
||||
|
||||
ws.on('pong', () => { ws.isAlive = true; });
|
||||
|
||||
ws.on('message', (message) => {
|
||||
try {
|
||||
const data = JSON.parse(message);
|
||||
|
||||
if (data.type === 'command') {
|
||||
// Idempotency check for WS commands
|
||||
const cached = checkIdempotent(data.commandId);
|
||||
if (cached) {
|
||||
ws.send(JSON.stringify({ type: 'command_result', commandId: data.commandId, ...cached }));
|
||||
return;
|
||||
}
|
||||
// Forward command to bridge
|
||||
pushCommandToBridge(data);
|
||||
if (data.commandId) {
|
||||
recordCommand(data.commandId, { success: true, commandId: data.commandId });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error processing web client message:', error);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('👋 Web client disconnected');
|
||||
webClients.delete(ws);
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('❌ WebSocket error:', error);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== WebSocket Keep-Alive ==========
|
||||
// Ping all web clients and bridge connections every 25s to keep connections alive
|
||||
const WS_PING_INTERVAL = setInterval(() => {
|
||||
webClients.forEach((ws) => {
|
||||
if (!ws.isAlive) {
|
||||
webClients.delete(ws);
|
||||
return ws.terminate();
|
||||
}
|
||||
ws.isAlive = false;
|
||||
ws.ping();
|
||||
});
|
||||
bridgeClients.forEach((ws) => {
|
||||
if (!ws.isAlive) {
|
||||
console.log('🌉 Bridge connection stale — terminating');
|
||||
bridgeClients.delete(ws);
|
||||
broadcastToClients({ type: 'state_update', bridgeConnected: bridgeClients.size > 0 });
|
||||
return ws.terminate();
|
||||
}
|
||||
ws.isAlive = false;
|
||||
ws.ping();
|
||||
});
|
||||
}, 25000);
|
||||
|
||||
wss.on('close', () => {
|
||||
clearInterval(WS_PING_INTERVAL);
|
||||
});
|
||||
|
||||
// ========== Cross-Project Integration API ==========
|
||||
// These endpoints allow the RemoteTurtle system to query inventory state
|
||||
|
||||
@@ -1341,7 +1261,7 @@ createProxyEndpoint(app, '/api/integration/turtle-status', 'TURTLE_SERVER_URL',
|
||||
setupGracefulShutdown({
|
||||
serviceName: 'inventory-manager',
|
||||
cleanup: [
|
||||
() => wss.close(),
|
||||
() => wsManager.close(),
|
||||
() => server.close(),
|
||||
() => { closeDb(); console.log('💾 Database closed'); },
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user