Compare commits

...

17 Commits

Author SHA1 Message Date
MayaTheShy
42fc9950f5 chore: mark main branch package as unstable 2026-03-29 15:50:39 -04:00
MayaTheShy
f0ca8b407e fix: ensure configuration is loaded during initialization 2026-03-29 15:10:00 -04:00
MayaTheShy
099f5aa287 fix: enhance WebSocket bridge connection to flush pending commands and improve initial sync 2026-03-29 14:42:47 -04:00
MayaTheShy
16544b59bd fix: implement initial WebSocket sync for reliable command processing 2026-03-29 14:42:40 -04:00
MayaTheShy
f24b288de3 fix: add modem type and channel diagnostics to bridge startup 2026-03-29 01:36:06 -04:00
MayaTheShy
3c40cf9ef4 fix: change log level from info to debug for network message queuing and processing 2026-03-29 01:12:09 -04:00
MayaTheShy
aa5f711fe4 fix: acknowledge duplicate commands to prevent sender retries 2026-03-29 01:11:31 -04:00
MayaTheShy
5a83d89509 fix: implement reliable modem command delivery with acknowledgment and retry mechanism 2026-03-29 01:11:24 -04:00
MayaTheShy
bb15c78ca9 fix: improve monitor alias detection and registration for better peripheral handling 2026-03-29 00:23:59 -04:00
MayaTheShy
4be2d7be8f fix: replace sleep(0) with os.pullEvent() + add diagnostic logging
sleep(0) wakes the processor only on timer ticks, causing a
1-tick delay. os.pullEvent() with no filter wakes the processor
on ANY event — including the modem_message that triggered the
capture — so items are processed in the same event cycle.

Added logging to capture and processor tasks:
- NET-CAP: logs each queued message with type and queue depth
- NET-PROC: logs each message being processed
- Both log on startup to confirm they're running
2026-03-29 00:02:07 -04:00
MayaTheShy
9396fbd81a fix: enhance monitor detection to handle adjacent peripherals correctly 2026-03-28 23:41:20 -04:00
MayaTheShy
ec1a681924 fix: replace custom event wake-up with polling, remove craftItem double-capture
Two robustness improvements to the Network capture/processor split:

1. Processor wake-up: replaced os.queueEvent('network_queued') /
   os.pullEvent('network_queued') with sleep(0) polling. Custom events
   can be consumed by other coroutines (e.g. craftItem's unfiltered
   os.pullEvent()) or swallowed by the OS event layer. Polling the
   shared queue every tick is simpler and guaranteed reliable.

2. craftItem: removed ORDER_CHANNEL message buffering and re-queuing.
   With the dedicated Network-capture task, ORDER_CHANNEL messages are
   already safely captured into networkQueue. The old buffering caused
   double-capture: capture task adds to queue, craftItem also buffers,
   then re-queues via os.queueEvent -> capture captures again -> dup.
   The commandId dedup caught these, but removing the source is cleaner.
2026-03-28 23:40:09 -04:00
MayaTheShy
36612ecc9f fix: split Network-listener into capture/processor to prevent modem_message loss
CC:Tweaked's parallel.waitForAny drops events when a coroutine's
filter doesn't match. The old Network-listener processed commands
inline, causing it to yield with filter 'task_complete' during
peripheral calls (pushItems, list, etc). Any modem_message arriving
during that window was consumed by the scheduler and lost.

Split into two coroutines:
- Network-capture: only ever yields for 'modem_message', inserts
  into a shared networkQueue table, queues 'network_queued' event
- Network-processor: drains the queue and runs all handler logic,
  safe to yield for peripheral calls without losing messages

This fixes the ~80% message loss rate on dispense requests.
2026-03-28 23:01:14 -04:00
MayaTheShy
badde91336 fix(docker): clone platform from git instead of additional_contexts
The additional_contexts approach required cc-platform-core to exist on
the Docker host at a relative path. This fails on servers where the
repo layout differs. Instead, use a multi-stage build: stage 1 clones
cc-platform-core from Gitea (depth 1), stage 2 copies server/ into the
app and rewrites the file: path. Fully self-contained — no host deps.
2026-03-28 22:37:57 -04:00
MayaTheShy
c3344288a8 fix(docker): resolve @cc-platform/server file: dep in container build
Use additional_contexts to copy platform server package into the Docker
build context. Rewrites the file: dependency path and removes the
lockfile so npm install can resolve the local package correctly.
2026-03-28 22:35:39 -04:00
MayaTheShy
021b351248 feat: replace hand-rolled WebSocketServer with createWebSocketManager for improved connection handling and state updates 2026-03-26 16:00:58 -04:00
MayaTheShy
b782d5c8f9 feat: add @cc-platform/server dependency and update package-lock.json 2026-03-26 16:00:53 -04:00
8 changed files with 448 additions and 311 deletions

View File

@@ -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/",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",

View File

@@ -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'); },
],