Compare commits
46 Commits
641a317873
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42fc9950f5 | ||
|
|
f0ca8b407e | ||
|
|
099f5aa287 | ||
|
|
16544b59bd | ||
|
|
f24b288de3 | ||
|
|
3c40cf9ef4 | ||
|
|
aa5f711fe4 | ||
|
|
5a83d89509 | ||
|
|
bb15c78ca9 | ||
|
|
4be2d7be8f | ||
|
|
9396fbd81a | ||
|
|
ec1a681924 | ||
|
|
36612ecc9f | ||
|
|
badde91336 | ||
|
|
c3344288a8 | ||
|
|
021b351248 | ||
|
|
b782d5c8f9 | ||
|
|
45b264dbc4 | ||
|
|
24ce637f6e | ||
|
|
075d42ecab | ||
|
|
379d08b594 | ||
|
|
d54855bf19 | ||
|
|
5eca4b7155 | ||
|
|
e340368ef0 | ||
|
|
38ff3f61bd | ||
|
|
664741504f | ||
|
|
c830642dc8 | ||
|
|
29026175b7 | ||
|
|
fe6a6df6a3 | ||
|
|
3c4d76d4a9 | ||
|
|
c7a1b7066a | ||
|
|
8a50bc586d | ||
|
|
c1b1713699 | ||
|
|
c9d21bcfaa | ||
|
|
97983156c6 | ||
|
|
46f7b0c98a | ||
|
|
13b374d7f8 | ||
|
|
981e561c2d | ||
|
|
a1ff2433e4 | ||
|
|
a50a6e9697 | ||
|
|
c74898049a | ||
|
|
25623b735c | ||
|
|
1050ed84f2 | ||
|
|
4cf1e550b7 | ||
|
|
66ac81de65 | ||
|
|
ea29136f25 |
7
.package
7
.package
@@ -1,6 +1,9 @@
|
||||
{
|
||||
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.",
|
||||
required = {
|
||||
'platform',
|
||||
},
|
||||
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/",
|
||||
|
||||
@@ -8,7 +8,6 @@ return {
|
||||
{ name = "minecraft:coal_block", burn_time = 80 },
|
||||
{ name = "minecraft:blaze_rod", burn_time = 12 },
|
||||
{ name = "minecraft:dried_kelp_block", burn_time = 20 },
|
||||
{ name = "minecraft:lava_bucket", burn_time = 100 },
|
||||
{ name = "minecraft:oak_planks", burn_time = 1.5 },
|
||||
{ name = "minecraft:spruce_planks",burn_time = 1.5 },
|
||||
{ name = "minecraft:birch_planks", burn_time = 1.5 },
|
||||
|
||||
@@ -42,7 +42,6 @@ end
|
||||
|
||||
-- Override dofile to load modules into our _ENV so they inherit
|
||||
-- Opus's require/package (CC:Tweaked dofile uses _G instead).
|
||||
local _ccDofile = dofile
|
||||
local function dofile(path) -- luacheck: ignore
|
||||
local fn, err = loadfile(path, nil, _ENV)
|
||||
if fn then return fn()
|
||||
@@ -56,6 +55,7 @@ local CLIENT_CONFIG_FILE = _configPath(".client_config")
|
||||
-------------------------------------------------
|
||||
|
||||
local log = dofile(_path("lib/log.lua"))
|
||||
local ui = dofile(_path("lib/ui.lua"))
|
||||
|
||||
local function loadConfig()
|
||||
if not fs.exists(CLIENT_CONFIG_FILE) then return end
|
||||
@@ -184,46 +184,11 @@ local function getItemTotal(itemName)
|
||||
return 0
|
||||
end
|
||||
|
||||
function ops.getRecipeIngredients(recipe)
|
||||
local ingredients = {}
|
||||
for _, item in ipairs(recipe.grid) do
|
||||
if item then
|
||||
ingredients[item] = (ingredients[item] or 0) + 1
|
||||
end
|
||||
end
|
||||
return ingredients
|
||||
end
|
||||
|
||||
function ops.canCraftRecipe(recipe)
|
||||
local ingredients = ops.getRecipeIngredients(recipe)
|
||||
for itemName, needed in pairs(ingredients) do
|
||||
if (getItemTotal(itemName) or 0) < needed then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function ops.maxCraftBatches(recipe)
|
||||
local ingredients = ops.getRecipeIngredients(recipe)
|
||||
local minBatches = math.huge
|
||||
for itemName, needed in pairs(ingredients) do
|
||||
local batches = math.floor((getItemTotal(itemName) or 0) / needed)
|
||||
if batches < minBatches then minBatches = batches end
|
||||
end
|
||||
if minBatches == math.huge then return 0 end
|
||||
return minBatches
|
||||
end
|
||||
|
||||
function ops.getMissingIngredients(recipe)
|
||||
local ingredients = ops.getRecipeIngredients(recipe)
|
||||
local missing = {}
|
||||
for itemName, needed in pairs(ingredients) do
|
||||
local have = getItemTotal(itemName) or 0
|
||||
if have < needed then
|
||||
table.insert(missing, { name = itemName, have = have, need = needed })
|
||||
end
|
||||
end
|
||||
return missing
|
||||
end
|
||||
-- Delegate recipe helpers to shared lib/ui.lua with local stock lookup.
|
||||
function ops.getRecipeIngredients(recipe) return ui.getRecipeIngredients(recipe) end
|
||||
function ops.canCraftRecipe(recipe) return ui.canCraftRecipe(recipe, getItemTotal) end
|
||||
function ops.maxCraftBatches(recipe) return ui.maxCraftBatches(recipe, getItemTotal) end
|
||||
function ops.getMissingIngredients(recipe) return ui.getMissingIngredients(recipe, getItemTotal) end
|
||||
|
||||
function ops.orderItem(itemName, amount)
|
||||
sendToMaster({
|
||||
|
||||
@@ -39,7 +39,6 @@ local ok, err = xpcall(function()
|
||||
|
||||
-- Override dofile to load modules into our _ENV so they inherit
|
||||
-- Opus's require/package (CC:Tweaked dofile uses _G instead).
|
||||
local _ccDofile = dofile
|
||||
local function dofile(path) -- luacheck: ignore
|
||||
local fn, err = loadfile(path, nil, _ENV)
|
||||
if fn then return fn()
|
||||
@@ -58,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
|
||||
@@ -148,9 +148,12 @@ local function broadcastState()
|
||||
-- Keep ctx in sync so display.lua can check ctx.craftTurtleOk directly
|
||||
ctx.craftTurtleOk = payload.craftTurtleOk
|
||||
|
||||
payload.smeltable = cfg.SMELTABLE
|
||||
payload.craftable = cfg.CRAFTABLE
|
||||
state.configDirty = false
|
||||
-- Only include recipe tables when config has changed (they're large).
|
||||
if state.configDirty then
|
||||
payload.smeltable = cfg.SMELTABLE
|
||||
payload.craftable = cfg.CRAFTABLE
|
||||
state.configDirty = false
|
||||
end
|
||||
|
||||
ctx.networkModem.transmit(cfg.BROADCAST_CHANNEL, cfg.ORDER_CHANNEL, payload)
|
||||
state.lastBroadcastVersion = state.stateVersion
|
||||
@@ -192,6 +195,14 @@ local function main()
|
||||
log.warn("INIT", "No smelter monitor on %s", cfg.SMELTER_MONITOR_SIDE)
|
||||
end
|
||||
|
||||
-- Billboard monitor (optional — set billboardMonitorSide in .manager_config)
|
||||
-- Billboard monitor (auto-detects any 3rd monitor, or set billboardMonitor in .manager_config)
|
||||
if display.setupBillboardMonitor() then
|
||||
log.info("INIT", "Billboard monitor: %s", display.billboardMonName)
|
||||
else
|
||||
log.info("INIT", "No billboard monitor found (optional)")
|
||||
end
|
||||
|
||||
-- Find wired modem for client/turtle communication
|
||||
for _, name in ipairs(peripheral.getNames()) do
|
||||
if peripheral.getType(name) == "modem" then
|
||||
@@ -373,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()
|
||||
@@ -539,6 +555,23 @@ local function main()
|
||||
end
|
||||
end),
|
||||
|
||||
-- Task 8b: Billboard dashboard redraw (goals monitor)
|
||||
resilient("Billboard", function()
|
||||
if not display.billboardMon then
|
||||
-- No billboard configured, sleep forever
|
||||
while true do sleep(3600) end
|
||||
end
|
||||
state.billboardNeedsRedraw = true
|
||||
while true do
|
||||
if state.billboardNeedsRedraw then
|
||||
state.billboardNeedsRedraw = false
|
||||
local bok, berr = pcall(display.drawBillboard)
|
||||
if not bok then log.error("DRAW", "Billboard: %s", tostring(berr)) end
|
||||
end
|
||||
sleep(0.5)
|
||||
end
|
||||
end),
|
||||
|
||||
-- Task 9: Touch event listener (both monitors)
|
||||
resilient("Touch-listener", function()
|
||||
while true do
|
||||
@@ -603,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()
|
||||
@@ -648,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
|
||||
@@ -657,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
|
||||
@@ -670,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 = {}
|
||||
@@ -679,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
|
||||
@@ -690,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)
|
||||
@@ -787,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)
|
||||
@@ -796,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)
|
||||
@@ -805,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
|
||||
@@ -817,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
|
||||
@@ -861,7 +1000,7 @@ local function main()
|
||||
log.error("NET", "Handler error: %s", tostring(handlerErr))
|
||||
end
|
||||
end -- idempotency else
|
||||
end
|
||||
end -- queue loop
|
||||
end
|
||||
end)
|
||||
)
|
||||
|
||||
@@ -3,56 +3,51 @@
|
||||
-- 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
|
||||
-------------------------------------------------
|
||||
|
||||
-- Web server URL (change to your Docker host IP/hostname)
|
||||
local SERVER_URL = "http://localhost"
|
||||
local POLL_INTERVAL = 0.5 -- seconds between command polls
|
||||
local STATE_INTERVAL = 1 -- seconds between state forwards
|
||||
|
||||
-- Modem channels (must match inventoryManager.lua)
|
||||
local BROADCAST_CHANNEL = 4200
|
||||
local ORDER_CHANNEL = 4201
|
||||
local BRIDGE_REPLY_CHANNEL = 4206 -- dedicated reply channel for bridge
|
||||
|
||||
-------------------------------------------------
|
||||
-- Load config from file if present
|
||||
-- Configuration (via platform)
|
||||
-------------------------------------------------
|
||||
|
||||
local _baseDir = fs.getDir(shell.getRunningProgram())
|
||||
local function _path(rel) return fs.combine(_baseDir, rel) end
|
||||
|
||||
-- Persistent config path: survives Opus package updates
|
||||
local _PERSIST_DIR = "usr/config/inventory-manager"
|
||||
local function _configPath(rel)
|
||||
if fs.isDir(_PERSIST_DIR) or fs.isDir("packages/inventory-manager") then
|
||||
if not fs.isDir(_PERSIST_DIR) then fs.makeDir(_PERSIST_DIR) end
|
||||
return fs.combine(_PERSIST_DIR, rel)
|
||||
end
|
||||
return _path(rel)
|
||||
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
|
||||
|
||||
local CONFIG_FILE = _configPath(".webbridge_config")
|
||||
local API_KEY = nil -- optional API key for server auth
|
||||
|
||||
local function loadConfig()
|
||||
if fs.exists(CONFIG_FILE) then
|
||||
local f = fs.open(CONFIG_FILE, "r")
|
||||
local data = f.readAll()
|
||||
f.close()
|
||||
local ok, cfg = pcall(textutils.unserialiseJSON, data)
|
||||
if ok and cfg then
|
||||
if cfg.serverUrl then SERVER_URL = cfg.serverUrl end
|
||||
if cfg.pollInterval then POLL_INTERVAL = cfg.pollInterval end
|
||||
if cfg.stateInterval then STATE_INTERVAL = cfg.stateInterval end
|
||||
if cfg.apiKey then API_KEY = cfg.apiKey end
|
||||
print("[CONFIG] Loaded from " .. CONFIG_FILE)
|
||||
end
|
||||
end
|
||||
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
|
||||
@@ -63,65 +58,42 @@ local modem = nil
|
||||
local modemName = nil
|
||||
local running = true
|
||||
|
||||
-------------------------------------------------
|
||||
-- Find modem
|
||||
-------------------------------------------------
|
||||
-- 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
|
||||
|
||||
local function findModem()
|
||||
for _, name in ipairs(peripheral.getNames()) do
|
||||
if peripheral.getType(name) == "modem" then
|
||||
modem = peripheral.wrap(name)
|
||||
modemName = name
|
||||
modem.open(BROADCAST_CHANNEL)
|
||||
modem.open(BRIDGE_REPLY_CHANNEL)
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
-- 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
|
||||
-- HTTP helpers (thin wrappers around platform)
|
||||
-------------------------------------------------
|
||||
|
||||
local function httpPost(path, body)
|
||||
local url = SERVER_URL .. path
|
||||
local data = textutils.serialiseJSON(body)
|
||||
local headers = { ["Content-Type"] = "application/json" }
|
||||
if API_KEY then headers["Authorization"] = "Bearer " .. API_KEY end
|
||||
|
||||
local ok, result = pcall(function()
|
||||
local response = http.post(url, data, headers)
|
||||
if response then
|
||||
local responseData = response.readAll()
|
||||
response.close()
|
||||
return responseData
|
||||
end
|
||||
end)
|
||||
|
||||
if ok then
|
||||
return result
|
||||
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 nil
|
||||
return result
|
||||
end
|
||||
|
||||
local function httpGet(path)
|
||||
local url = SERVER_URL .. path
|
||||
local headers = nil
|
||||
if API_KEY then headers = { ["Authorization"] = "Bearer " .. API_KEY } end
|
||||
local ok, result = pcall(function()
|
||||
local response = http.get(url, headers)
|
||||
if response then
|
||||
local data = response.readAll()
|
||||
response.close()
|
||||
return textutils.unserialiseJSON(data)
|
||||
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
|
||||
end)
|
||||
|
||||
if ok then
|
||||
return result
|
||||
return nil
|
||||
end
|
||||
return nil
|
||||
return textutils.unserialiseJSON(rawBody)
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
@@ -133,6 +105,32 @@ local function forwardState()
|
||||
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
|
||||
-------------------------------------------------
|
||||
@@ -145,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
|
||||
|
||||
@@ -251,57 +208,94 @@ 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"
|
||||
or resultType == "recursive_craft_result" or resultType == "find_item_result" then
|
||||
pcall(httpPost, "/api/bridge/result", {
|
||||
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
|
||||
local ok, err = pcall(forwardState)
|
||||
if not ok then
|
||||
-- Connection error, will retry
|
||||
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
|
||||
-- Task 3: Poll web server for commands (HTTP fallback)
|
||||
-- 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
|
||||
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
|
||||
pcall(processCommand, cmd)
|
||||
if cmdId > maxId then maxId = cmdId end
|
||||
-- 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
|
||||
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
|
||||
-- Acknowledge up to the highest processed ID
|
||||
if maxId > lastProcessedId then
|
||||
lastProcessedId = maxId
|
||||
httpPost("/api/bridge/commands/ack", { lastProcessedId = lastProcessedId })
|
||||
end
|
||||
end)
|
||||
if not ok then
|
||||
print(string.format("[ERR] Command poll: %s", tostring(err)))
|
||||
end
|
||||
end)
|
||||
end
|
||||
sleep(POLL_INTERVAL)
|
||||
end
|
||||
end
|
||||
@@ -326,6 +320,91 @@ local function heartbeat()
|
||||
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)
|
||||
-- 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)
|
||||
elseif 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
|
||||
wsHasSynced = 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
|
||||
|
||||
-- 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
|
||||
-------------------------------------------------
|
||||
@@ -336,10 +415,16 @@ local function main()
|
||||
print("===================================")
|
||||
print("")
|
||||
|
||||
loadConfig()
|
||||
-- Config already loaded at require-time via WebBridge.loadConfig above
|
||||
|
||||
if findModem() then
|
||||
print("[OK] Modem: " .. modemName)
|
||||
modem, modemName = WebBridge.findModem()
|
||||
if modem then
|
||||
WebBridge.openChannels(modem,
|
||||
{ 'inventory.broadcast', 'inventory.bridge' })
|
||||
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.")
|
||||
@@ -354,6 +439,7 @@ local function main()
|
||||
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)
|
||||
@@ -363,7 +449,9 @@ local function main()
|
||||
modemListener,
|
||||
stateForwarder,
|
||||
commandPoller,
|
||||
heartbeat
|
||||
heartbeat,
|
||||
wsConnector,
|
||||
modemRetry
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@ C.COMPOST_INTERVAL = 3
|
||||
C.ALERT_INTERVAL = 15
|
||||
C.CACHE_FILE = _configPath(".inventory_cache")
|
||||
C.SMELTER_MONITOR_SIDE = "top"
|
||||
C.BILLBOARD_MONITOR = "" -- network name e.g. "monitor_0"; auto-detects if empty
|
||||
C.BILLBOARD_TOP_ITEMS = 20 -- max items in billboard bar chart
|
||||
C.BILLBOARD_TEXT_SCALE = 1 -- 0.5 = tiny, 1 = normal, 2 = large, 5 = huge
|
||||
C.DISABLED_RECIPES_FILE = _configPath(".disabled_recipes")
|
||||
|
||||
-- Network
|
||||
@@ -117,7 +120,10 @@ function C.loadConfig()
|
||||
if cfg.dropperName then C.DROPPER_NAME = cfg.dropperName end
|
||||
if cfg.barrelName then C.BARREL_NAME = cfg.barrelName end
|
||||
if cfg.monitorSide then C.MONITOR_SIDE = cfg.monitorSide end
|
||||
if cfg.smelterMonitorSide then C.SMELTER_MONITOR_SIDE = cfg.smelterMonitorSide end
|
||||
if cfg.smelterMonitorSide then C.SMELTER_MONITOR_SIDE = cfg.smelterMonitorSide end
|
||||
if cfg.billboardMonitor then C.BILLBOARD_MONITOR = cfg.billboardMonitor end
|
||||
if cfg.billboardTopItems then C.BILLBOARD_TOP_ITEMS = cfg.billboardTopItems end
|
||||
if cfg.billboardTextScale then C.BILLBOARD_TEXT_SCALE = cfg.billboardTextScale end
|
||||
if cfg.pollInterval then C.POLL_INTERVAL = cfg.pollInterval end
|
||||
if cfg.scanInterval then C.SCAN_INTERVAL = cfg.scanInterval end
|
||||
if cfg.smeltInterval then C.SMELT_INTERVAL = cfg.smeltInterval end
|
||||
|
||||
@@ -26,6 +26,8 @@ D.mon = nil
|
||||
D.monName = nil
|
||||
D.smelterMon = nil
|
||||
D.smelterMonName = nil
|
||||
D.billboardMon = nil
|
||||
D.billboardMonName = nil
|
||||
|
||||
-- Opus UI devices and pages
|
||||
local mainDevice = nil
|
||||
@@ -118,48 +120,142 @@ 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()
|
||||
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 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
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Build main dashboard page
|
||||
-------------------------------------------------
|
||||
@@ -1416,6 +1512,500 @@ function D.handleSmelterTouch(x, y)
|
||||
end
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Billboard rendering (raw monitor API, no Opus UI)
|
||||
-- Visual goals display with pie chart, storage
|
||||
-- gauge, stock alerts, and activity indicators.
|
||||
-------------------------------------------------
|
||||
|
||||
local BB = {} -- billboard color theme
|
||||
BB.bg = colors.black
|
||||
BB.headerBg = colors.blue
|
||||
BB.headerFg = colors.white
|
||||
BB.border = colors.gray
|
||||
BB.label = colors.lightGray
|
||||
BB.value = colors.white
|
||||
BB.barFull = colors.lime
|
||||
BB.barEmpty = colors.gray
|
||||
BB.barWarn = colors.yellow
|
||||
BB.barCrit = colors.red
|
||||
BB.alertOk = colors.lime
|
||||
BB.alertLow = colors.red
|
||||
BB.alertWarn = colors.orange
|
||||
BB.activityOn = colors.lime
|
||||
BB.activityOff = colors.gray
|
||||
BB.sectionHead = colors.yellow
|
||||
|
||||
-- Pie chart slice colors (12 distinct CC colors)
|
||||
local PIE_COLORS = {
|
||||
colors.red,
|
||||
colors.orange,
|
||||
colors.yellow,
|
||||
colors.lime,
|
||||
colors.green,
|
||||
colors.cyan,
|
||||
colors.lightBlue,
|
||||
colors.blue,
|
||||
colors.purple,
|
||||
colors.magenta,
|
||||
colors.pink,
|
||||
colors.brown,
|
||||
}
|
||||
|
||||
local function bbFormatNumber(n)
|
||||
if n >= 1000000 then
|
||||
return string.format("%.1fM", n / 1000000)
|
||||
elseif n >= 10000 then
|
||||
return string.format("%.1fK", n / 1000)
|
||||
elseif n >= 1000 then
|
||||
return string.format("%d,%03d", math.floor(n / 1000), n % 1000)
|
||||
end
|
||||
return tostring(n)
|
||||
end
|
||||
|
||||
local function bbPadRight(s, w)
|
||||
if #s >= w then return s:sub(1, w) end
|
||||
return s .. string.rep(" ", w - #s)
|
||||
end
|
||||
|
||||
local function bbPadLeft(s, w)
|
||||
if #s >= w then return s:sub(1, w) end
|
||||
return string.rep(" ", w - #s) .. s
|
||||
end
|
||||
|
||||
-- Billboard drawing primitives (operate on D.billboardMon)
|
||||
local bbW, bbH = 0, 0
|
||||
|
||||
local function bbSetColors(fg, bg)
|
||||
D.billboardMon.setTextColor(fg)
|
||||
D.billboardMon.setBackgroundColor(bg)
|
||||
end
|
||||
|
||||
local function bbClearLine(y, bg)
|
||||
D.billboardMon.setCursorPos(1, y)
|
||||
D.billboardMon.setBackgroundColor(bg or BB.bg)
|
||||
D.billboardMon.write(string.rep(" ", bbW))
|
||||
end
|
||||
|
||||
local function bbWriteAt(x, y, text, fg, bg)
|
||||
bbSetColors(fg or BB.value, bg or BB.bg)
|
||||
D.billboardMon.setCursorPos(x, y)
|
||||
D.billboardMon.write(text)
|
||||
end
|
||||
|
||||
local function bbHLine(y, fg)
|
||||
bbClearLine(y)
|
||||
bbSetColors(fg or BB.border, BB.bg)
|
||||
D.billboardMon.setCursorPos(1, y)
|
||||
D.billboardMon.write(string.rep("\x8c", bbW))
|
||||
end
|
||||
|
||||
local function bbDrawBar(x, y, width, filled, fgColor, bgColor)
|
||||
local fillW = math.floor(filled * width + 0.5)
|
||||
if fillW > width then fillW = width end
|
||||
if fillW < 0 then fillW = 0 end
|
||||
D.billboardMon.setCursorPos(x, y)
|
||||
D.billboardMon.setBackgroundColor(fgColor or BB.barFull)
|
||||
D.billboardMon.write(string.rep(" ", fillW))
|
||||
D.billboardMon.setBackgroundColor(bgColor or BB.barEmpty)
|
||||
D.billboardMon.write(string.rep(" ", width - fillW))
|
||||
D.billboardMon.setBackgroundColor(BB.bg)
|
||||
end
|
||||
|
||||
--- Draw a pie chart using colored character cells.
|
||||
--- slices = { { fraction=0.0-1.0, color=colors.X }, ... }
|
||||
--- Draws into the rectangle (x1,y1) to (x1+size-1, y1+rows-1)
|
||||
--- size = width in chars, rows = height in chars
|
||||
local function bbDrawPie(x1, y1, size, rows, slices)
|
||||
local cx = size / 2 -- center in char coords
|
||||
local cy = rows / 2
|
||||
local radius = math.min(cx, cy) - 0.5
|
||||
-- Character cells are ~1.5x taller than wide; squish Y
|
||||
local aspect = 1.5
|
||||
|
||||
-- Build cumulative angle boundaries
|
||||
local angles = {}
|
||||
local cumulative = 0
|
||||
for i, slice in ipairs(slices) do
|
||||
angles[i] = { start = cumulative, stop = cumulative + slice.fraction, color = slice.color }
|
||||
cumulative = cumulative + slice.fraction
|
||||
end
|
||||
|
||||
for row = 0, rows - 1 do
|
||||
D.billboardMon.setCursorPos(x1, y1 + row)
|
||||
for col = 0, size - 1 do
|
||||
local dx = (col + 0.5 - cx)
|
||||
local dy = (row + 0.5 - cy) * aspect
|
||||
local dist = math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
if dist <= radius then
|
||||
-- Compute angle 0-1 (0 = top, clockwise)
|
||||
local angle = math.atan2(dx, -dy) -- top = 0, clockwise
|
||||
if angle < 0 then angle = angle + 2 * math.pi end
|
||||
local frac = angle / (2 * math.pi)
|
||||
|
||||
-- Find which slice
|
||||
local cellColor = BB.bg
|
||||
for _, s in ipairs(angles) do
|
||||
if frac >= s.start and frac < s.stop then
|
||||
cellColor = s.color
|
||||
break
|
||||
end
|
||||
end
|
||||
-- Catch rounding at the very end
|
||||
if cellColor == BB.bg and #angles > 0 then
|
||||
cellColor = angles[#angles].color
|
||||
end
|
||||
|
||||
D.billboardMon.setBackgroundColor(cellColor)
|
||||
D.billboardMon.write(" ")
|
||||
else
|
||||
D.billboardMon.setBackgroundColor(BB.bg)
|
||||
D.billboardMon.write(" ")
|
||||
end
|
||||
end
|
||||
end
|
||||
D.billboardMon.setBackgroundColor(BB.bg)
|
||||
end
|
||||
|
||||
--- Draw a storage ring/donut gauge.
|
||||
--- Draws a ring showing used vs free with percentage in center.
|
||||
local function bbDrawStorageRing(x1, y1, size, rows)
|
||||
local cx = size / 2
|
||||
local cy = rows / 2
|
||||
local outerR = math.min(cx, cy) - 0.5
|
||||
local innerR = outerR * 0.55
|
||||
local aspect = 1.5
|
||||
local ratio = cache.usedRatio or 0
|
||||
|
||||
local usedColor = BB.barFull
|
||||
if ratio > 0.9 then usedColor = BB.barCrit
|
||||
elseif ratio > 0.75 then usedColor = BB.barWarn end
|
||||
|
||||
for row = 0, rows - 1 do
|
||||
D.billboardMon.setCursorPos(x1, y1 + row)
|
||||
for col = 0, size - 1 do
|
||||
local dx = (col + 0.5 - cx)
|
||||
local dy = (row + 0.5 - cy) * aspect
|
||||
local dist = math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
if dist <= outerR and dist >= innerR then
|
||||
-- In the ring — determine angle (top = 0, clockwise)
|
||||
local angle = math.atan2(dx, -dy)
|
||||
if angle < 0 then angle = angle + 2 * math.pi end
|
||||
local frac = angle / (2 * math.pi)
|
||||
|
||||
if frac < ratio then
|
||||
D.billboardMon.setBackgroundColor(usedColor)
|
||||
else
|
||||
D.billboardMon.setBackgroundColor(BB.barEmpty)
|
||||
end
|
||||
D.billboardMon.write(" ")
|
||||
else
|
||||
D.billboardMon.setBackgroundColor(BB.bg)
|
||||
D.billboardMon.write(" ")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Write percentage in center of ring
|
||||
local pct = tostring(math.floor(ratio * 100 + 0.5)) .. "%"
|
||||
local textY = y1 + math.floor(cy)
|
||||
local textX = x1 + math.floor(cx - #pct / 2)
|
||||
bbWriteAt(textX, textY, pct, usedColor)
|
||||
end
|
||||
|
||||
-- Billboard section: header
|
||||
local function bbDrawHeader(y)
|
||||
bbClearLine(y, BB.headerBg)
|
||||
bbSetColors(BB.headerFg, BB.headerBg)
|
||||
local title = " INVENTORY BILLBOARD "
|
||||
D.billboardMon.setCursorPos(math.floor((bbW - #title) / 2) + 1, y)
|
||||
D.billboardMon.write(title)
|
||||
return y + 1
|
||||
end
|
||||
|
||||
-- Billboard section: storage ring + stats (side by side)
|
||||
local function bbDrawStorageSection(y)
|
||||
bbHLine(y)
|
||||
y = y + 1
|
||||
|
||||
-- Ring takes up a square area
|
||||
local ringSize = math.min(math.floor(bbW * 0.35), bbH - y - 6)
|
||||
if ringSize < 5 then ringSize = 5 end
|
||||
local ringRows = math.floor(ringSize / 1.5) -- aspect correction
|
||||
if ringRows < 3 then ringRows = 3 end
|
||||
|
||||
-- Draw ring on the left
|
||||
bbDrawStorageRing(2, y, ringSize, ringRows)
|
||||
|
||||
-- Stats text to the right of the ring
|
||||
local statsX = ringSize + 4
|
||||
local statsY = y + 1
|
||||
|
||||
bbWriteAt(statsX, statsY, "STORAGE", BB.sectionHead)
|
||||
statsY = statsY + 1
|
||||
|
||||
local ratio = cache.usedRatio or 0
|
||||
local usedColor = BB.barFull
|
||||
if ratio > 0.9 then usedColor = BB.barCrit
|
||||
elseif ratio > 0.75 then usedColor = BB.barWarn end
|
||||
|
||||
bbWriteAt(statsX, statsY, string.format("%s / %s slots",
|
||||
bbFormatNumber(cache.usedSlots), bbFormatNumber(cache.totalSlots)), BB.value)
|
||||
statsY = statsY + 1
|
||||
|
||||
bbWriteAt(statsX, statsY, string.format("%s total items",
|
||||
bbFormatNumber(cache.grandTotal)), BB.label)
|
||||
statsY = statsY + 1
|
||||
|
||||
bbWriteAt(statsX, statsY, string.format("%d chests", cache.chestCount), BB.label)
|
||||
statsY = statsY + 1
|
||||
|
||||
-- Mini capacity bar
|
||||
local barW = bbW - statsX - 1
|
||||
if barW > 3 then
|
||||
bbWriteAt(statsX, statsY, "", BB.label)
|
||||
bbDrawBar(statsX, statsY, barW, ratio, usedColor, BB.barEmpty)
|
||||
end
|
||||
|
||||
return y + ringRows + 1
|
||||
end
|
||||
|
||||
-- Billboard section: pie chart + legend
|
||||
local function bbDrawPieSection(y, maxH)
|
||||
bbHLine(y)
|
||||
y = y + 1
|
||||
bbWriteAt(2, y, "ITEM DISTRIBUTION", BB.sectionHead)
|
||||
y = y + 1
|
||||
|
||||
state.ensureItemList()
|
||||
local items = cache.itemList
|
||||
if not items or #items == 0 then
|
||||
bbWriteAt(2, y, "No items in storage", BB.label)
|
||||
return y + 1
|
||||
end
|
||||
|
||||
-- Sort and pick top N for pie slices
|
||||
local sorted = {}
|
||||
for i, item in ipairs(items) do sorted[i] = item end
|
||||
table.sort(sorted, function(a, b) return a.total > b.total end)
|
||||
|
||||
local maxSlices = math.min(#PIE_COLORS, cfg.BILLBOARD_TOP_ITEMS or 12, #sorted)
|
||||
local topTotal = 0
|
||||
for i = 1, maxSlices do
|
||||
topTotal = topTotal + sorted[i].total
|
||||
end
|
||||
-- "Other" bucket for remaining items
|
||||
local otherTotal = (cache.grandTotal or 0) - topTotal
|
||||
local total = cache.grandTotal or 1
|
||||
if total < 1 then total = 1 end
|
||||
|
||||
-- Build slices
|
||||
local slices = {}
|
||||
local legendItems = {}
|
||||
for i = 1, maxSlices do
|
||||
local frac = sorted[i].total / total
|
||||
if frac < 0.005 then break end -- skip tiny slices
|
||||
table.insert(slices, { fraction = frac, color = PIE_COLORS[i] })
|
||||
table.insert(legendItems, {
|
||||
name = shortName(sorted[i].name),
|
||||
count = sorted[i].total,
|
||||
pct = math.floor(frac * 100 + 0.5),
|
||||
color = PIE_COLORS[i],
|
||||
})
|
||||
end
|
||||
if otherTotal > 0 then
|
||||
table.insert(slices, { fraction = otherTotal / total, color = BB.border })
|
||||
table.insert(legendItems, {
|
||||
name = "Other",
|
||||
count = otherTotal,
|
||||
pct = math.floor(otherTotal / total * 100 + 0.5),
|
||||
color = BB.border,
|
||||
})
|
||||
end
|
||||
|
||||
-- Layout: pie on left, legend on right
|
||||
local pieSize = math.min(math.floor(bbW * 0.4), maxH - 1)
|
||||
if pieSize < 5 then pieSize = 5 end
|
||||
local pieRows = math.floor(pieSize / 1.5)
|
||||
if pieRows < 3 then pieRows = 3 end
|
||||
if pieRows > maxH - 1 then pieRows = maxH - 1 end
|
||||
|
||||
-- Draw pie
|
||||
if #slices > 0 then
|
||||
bbDrawPie(2, y, pieSize, pieRows, slices)
|
||||
end
|
||||
|
||||
-- Draw legend to the right
|
||||
local legX = pieSize + 4
|
||||
local legY = y
|
||||
local legW = bbW - legX - 1
|
||||
|
||||
for i, item in ipairs(legendItems) do
|
||||
if legY >= y + pieRows then break end
|
||||
if legW < 10 then break end
|
||||
|
||||
-- Color swatch
|
||||
D.billboardMon.setCursorPos(legX, legY)
|
||||
D.billboardMon.setBackgroundColor(item.color)
|
||||
D.billboardMon.write(" ")
|
||||
D.billboardMon.setBackgroundColor(BB.bg)
|
||||
D.billboardMon.write(" ")
|
||||
|
||||
-- Name + count
|
||||
local label = item.name
|
||||
local countStr = bbFormatNumber(item.count)
|
||||
local pctStr = item.pct .. "%"
|
||||
local infoW = legW - 4 -- 2 swatch + 1 space + padding
|
||||
local detail = string.format("%s %s", pctStr, countStr)
|
||||
local nameW = infoW - #detail - 1
|
||||
if nameW < 4 then nameW = 4 end
|
||||
if #label > nameW then label = label:sub(1, nameW - 1) .. "." end
|
||||
|
||||
bbWriteAt(legX + 3, legY, bbPadRight(label, nameW), BB.value)
|
||||
bbWriteAt(legX + 3 + nameW + 1, legY, detail, BB.label)
|
||||
|
||||
legY = legY + 1
|
||||
end
|
||||
|
||||
return y + pieRows
|
||||
end
|
||||
|
||||
-- Billboard section: stock alerts (compact)
|
||||
local function bbDrawAlerts(y, maxRows)
|
||||
bbHLine(y)
|
||||
y = y + 1
|
||||
|
||||
local alerts = state.activeAlerts
|
||||
if not alerts or #alerts == 0 then
|
||||
bbWriteAt(2, y, "* All stocks OK", BB.alertOk)
|
||||
return y + 1
|
||||
end
|
||||
|
||||
bbWriteAt(2, y, "ALERTS", BB.sectionHead)
|
||||
local countStr = string.format("(%d)", #alerts)
|
||||
bbWriteAt(9, y, countStr, BB.alertWarn)
|
||||
y = y + 1
|
||||
|
||||
local colW = math.floor(bbW / 2)
|
||||
local twoCol = (bbW >= 30)
|
||||
local row = 0
|
||||
|
||||
for i, alert in ipairs(alerts) do
|
||||
if row >= maxRows then
|
||||
bbWriteAt(2, y, string.format(" +%d more", #alerts - i + 1), BB.alertWarn)
|
||||
y = y + 1
|
||||
break
|
||||
end
|
||||
|
||||
local label = alert.label or shortName(alert.name or "?")
|
||||
local current = alert.current or 0
|
||||
local minVal = alert.min or 0
|
||||
local ratio = minVal > 0 and (current / minVal) or 1
|
||||
|
||||
local color = ratio < 0.5 and BB.alertLow or BB.alertWarn
|
||||
local text = string.format("! %s %s/%s", label, bbFormatNumber(current), bbFormatNumber(minVal))
|
||||
|
||||
if twoCol then
|
||||
local col = ((i - 1) % 2 == 0) and 2 or (colW + 1)
|
||||
if col == 2 then bbClearLine(y) end
|
||||
bbWriteAt(col, y, bbPadRight(text, colW - 1), color)
|
||||
if (i - 1) % 2 == 1 or i == #alerts then
|
||||
y = y + 1
|
||||
row = row + 1
|
||||
end
|
||||
else
|
||||
bbClearLine(y)
|
||||
bbWriteAt(2, y, text, color)
|
||||
y = y + 1
|
||||
row = row + 1
|
||||
end
|
||||
end
|
||||
return y
|
||||
end
|
||||
|
||||
-- Billboard section: activity bar (single line)
|
||||
local function bbDrawActivityBar(y)
|
||||
bbClearLine(y, BB.headerBg)
|
||||
bbSetColors(BB.headerFg, BB.headerBg)
|
||||
|
||||
local labels = {
|
||||
{ key = "sorting", label = "SORT" },
|
||||
{ key = "scanning", label = "SCAN" },
|
||||
{ key = "smelting", label = "SMLT" },
|
||||
{ key = "dispensing", label = "DISP" },
|
||||
{ key = "defragging", label = "DEFR" },
|
||||
{ key = "composting", label = "COMP" },
|
||||
{ key = "crafting", label = "CRFT" },
|
||||
{ key = "autocrafting", label = "AUTO" },
|
||||
{ key = "discarding", label = "DISC" },
|
||||
}
|
||||
|
||||
local parts = {}
|
||||
for _, entry in ipairs(labels) do
|
||||
if activity[entry.key] then
|
||||
table.insert(parts, entry.label)
|
||||
end
|
||||
end
|
||||
|
||||
local text
|
||||
if #parts > 0 then
|
||||
text = " " .. table.concat(parts, " | ") .. " "
|
||||
else
|
||||
text = " IDLE "
|
||||
end
|
||||
|
||||
D.billboardMon.setCursorPos(math.floor((bbW - #text) / 2) + 1, y)
|
||||
if #parts > 0 then
|
||||
bbSetColors(colors.white, colors.green)
|
||||
else
|
||||
bbSetColors(BB.label, BB.headerBg)
|
||||
end
|
||||
D.billboardMon.write(text)
|
||||
|
||||
return y + 1
|
||||
end
|
||||
|
||||
-- Main billboard draw entry point
|
||||
function D.drawBillboard()
|
||||
if not D.billboardMon then return end
|
||||
bbW, bbH = D.billboardMon.getSize()
|
||||
|
||||
D.billboardMon.setBackgroundColor(BB.bg)
|
||||
D.billboardMon.clear()
|
||||
|
||||
local y = 1
|
||||
|
||||
-- Header bar
|
||||
y = bbDrawHeader(y)
|
||||
|
||||
-- Storage ring + stats
|
||||
y = bbDrawStorageSection(y)
|
||||
|
||||
-- Pie chart + legend (gets remaining space minus alerts + footer)
|
||||
local alertCount = state.activeAlerts and #state.activeAlerts or 0
|
||||
local alertH = math.max(2, math.min(5, math.ceil(alertCount / 2) + 2))
|
||||
local footerH = 1 -- activity bar
|
||||
local pieH = bbH - y - alertH - footerH
|
||||
if pieH < 5 then pieH = 5 end
|
||||
y = bbDrawPieSection(y, pieH)
|
||||
|
||||
-- Alerts
|
||||
local alertMaxRows = bbH - y - footerH - 1
|
||||
if alertMaxRows < 1 then alertMaxRows = 1 end
|
||||
y = bbDrawAlerts(y, alertMaxRows)
|
||||
|
||||
-- Fill gap before footer
|
||||
while y < bbH do
|
||||
bbClearLine(y)
|
||||
y = y + 1
|
||||
end
|
||||
|
||||
-- Activity bar at very bottom
|
||||
bbDrawActivityBar(bbH)
|
||||
end
|
||||
|
||||
return D
|
||||
|
||||
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
|
||||
|
||||
@@ -55,6 +55,7 @@ S.configDirty = true
|
||||
|
||||
function S.bumpStateVersion()
|
||||
S.stateVersion = S.stateVersion + 1
|
||||
S.billboardNeedsRedraw = true
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
@@ -63,6 +64,7 @@ end
|
||||
|
||||
S.needsRedraw = true
|
||||
S.smelterNeedsRedraw = true
|
||||
S.billboardNeedsRedraw = true
|
||||
S.statusMessage = ""
|
||||
S.statusColor = colors.white
|
||||
S.statusTimer = 0
|
||||
|
||||
@@ -2,274 +2,47 @@ import React, { useState } from 'react';
|
||||
import { getItemEmoji } from '../utils/itemUtils';
|
||||
import './ItemIcon.css';
|
||||
|
||||
// All textures are proxied & cached through our server
|
||||
const TEXTURE_PROXY_BASE = '/api/texture';
|
||||
// Server-side smart resolution endpoint — handles aliases, animated frames,
|
||||
// carpet→wool, wood→log, derivatives, CC:Tweaked, Create, and prefix matching.
|
||||
const RESOLVE_BASE = '/api/texture/resolve';
|
||||
|
||||
// Items whose texture file name differs from their registry name
|
||||
const TEXTURE_ALIASES = {
|
||||
// Renamed items in 1.20+
|
||||
grass: 'short_grass',
|
||||
scute: 'turtle_scute',
|
||||
};
|
||||
|
||||
// CC:Tweaked texture paths (registry name → actual file in the CC repo)
|
||||
const CC_TEXTURE_MAP = {
|
||||
turtle_normal: 'block/turtle_normal_front',
|
||||
turtle_advanced: 'block/turtle_advanced_front',
|
||||
computer_normal: 'block/computer_normal_front',
|
||||
computer_advanced: 'block/computer_advanced_front',
|
||||
computer_command: 'block/computer_command_front',
|
||||
monitor_normal: 'block/monitor_normal_0',
|
||||
monitor_advanced: 'block/monitor_advanced_0',
|
||||
wired_modem: 'block/wired_modem_face',
|
||||
wired_modem_full: 'block/wired_modem_face',
|
||||
wireless_modem_normal: 'block/wireless_modem_normal_face',
|
||||
wireless_modem_advanced: 'block/wireless_modem_advanced_face',
|
||||
speaker: 'block/speaker_front',
|
||||
disk_drive: 'block/disk_drive_front',
|
||||
printer: 'block/printer_front_empty',
|
||||
cable: 'block/cable_core',
|
||||
// CC item textures
|
||||
pocket_computer_normal: 'item/pocket_computer_normal',
|
||||
pocket_computer_advanced: 'item/pocket_computer_advanced',
|
||||
disk: 'item/disk_frame',
|
||||
printed_book: 'item/printed_book',
|
||||
printed_page: 'item/printed_page',
|
||||
printed_pages: 'item/printed_pages',
|
||||
};
|
||||
|
||||
// Items whose texture lives in the block/ folder instead of item/
|
||||
const BLOCK_TEXTURES = new Set([
|
||||
'stone', 'granite', 'polished_granite', 'diorite', 'polished_diorite', 'andesite',
|
||||
'polished_andesite', 'cobblestone', 'oak_planks', 'spruce_planks', 'birch_planks',
|
||||
'jungle_planks', 'acacia_planks', 'dark_oak_planks', 'mangrove_planks', 'cherry_planks',
|
||||
'bamboo_planks', 'crimson_planks', 'warped_planks', 'oak_log', 'spruce_log', 'birch_log',
|
||||
'jungle_log', 'acacia_log', 'dark_oak_log', 'mangrove_log', 'cherry_log', 'bamboo_block',
|
||||
'stripped_oak_log', 'stripped_spruce_log', 'stripped_birch_log', 'stripped_jungle_log',
|
||||
'stripped_acacia_log', 'stripped_dark_oak_log', 'stripped_mangrove_log', 'stripped_cherry_log',
|
||||
'sand', 'red_sand', 'gravel', 'dirt', 'coarse_dirt', 'rooted_dirt', 'mud',
|
||||
'cobblestone', 'mossy_cobblestone', 'obsidian', 'crying_obsidian',
|
||||
'netherrack', 'soul_sand', 'soul_soil', 'basalt', 'polished_basalt', 'smooth_basalt',
|
||||
'glowstone', 'glass', 'tinted_glass',
|
||||
|
||||
'bricks', 'stone_bricks', 'mossy_stone_bricks', 'cracked_stone_bricks',
|
||||
'chiseled_stone_bricks', 'deepslate_bricks', 'nether_bricks', 'red_nether_bricks',
|
||||
'bookshelf', 'clay', 'pumpkin', 'carved_pumpkin', 'jack_o_lantern', 'melon',
|
||||
'sponge', 'wet_sponge', 'sandstone', 'red_sandstone', 'prismarine', 'dark_prismarine',
|
||||
'sea_lantern', 'hay_block', 'terracotta', 'packed_ice', 'blue_ice',
|
||||
'snow_block', 'ice', 'mycelium', 'podzol', 'grass_block', 'moss_block',
|
||||
'deepslate', 'cobbled_deepslate', 'polished_deepslate', 'calcite', 'tuff', 'dripstone_block',
|
||||
'coal_ore', 'iron_ore', 'gold_ore', 'diamond_ore', 'emerald_ore', 'lapis_ore', 'redstone_ore',
|
||||
'copper_ore', 'nether_gold_ore', 'nether_quartz_ore', 'ancient_debris',
|
||||
'deepslate_coal_ore', 'deepslate_iron_ore', 'deepslate_gold_ore', 'deepslate_diamond_ore',
|
||||
'deepslate_emerald_ore', 'deepslate_lapis_ore', 'deepslate_redstone_ore', 'deepslate_copper_ore',
|
||||
'coal_block', 'iron_block', 'gold_block', 'diamond_block', 'emerald_block',
|
||||
'lapis_block', 'redstone_block', 'copper_block', 'raw_iron_block', 'raw_gold_block',
|
||||
'raw_copper_block', 'netherite_block', 'amethyst_block', 'quartz_block',
|
||||
'tnt', 'end_stone', 'end_stone_bricks', 'purpur_block', 'purpur_pillar',
|
||||
'magma_block', 'bone_block', 'dried_kelp_block', 'honeycomb_block',
|
||||
'slime_block', 'honey_block', 'note_block', 'jukebox',
|
||||
'white_wool', 'orange_wool', 'magenta_wool', 'light_blue_wool', 'yellow_wool',
|
||||
'lime_wool', 'pink_wool', 'gray_wool', 'light_gray_wool', 'cyan_wool',
|
||||
'purple_wool', 'blue_wool', 'brown_wool', 'green_wool', 'red_wool', 'black_wool',
|
||||
'white_concrete', 'orange_concrete', 'magenta_concrete', 'light_blue_concrete',
|
||||
'yellow_concrete', 'lime_concrete', 'pink_concrete', 'gray_concrete',
|
||||
'light_gray_concrete', 'cyan_concrete', 'purple_concrete', 'blue_concrete',
|
||||
'brown_concrete', 'green_concrete', 'red_concrete', 'black_concrete',
|
||||
'white_terracotta', 'orange_terracotta', 'magenta_terracotta', 'light_blue_terracotta',
|
||||
'yellow_terracotta', 'lime_terracotta', 'pink_terracotta', 'gray_terracotta',
|
||||
'light_gray_terracotta', 'cyan_terracotta', 'purple_terracotta', 'blue_terracotta',
|
||||
'brown_terracotta', 'green_terracotta', 'red_terracotta', 'black_terracotta',
|
||||
'white_glazed_terracotta', 'orange_glazed_terracotta', 'magenta_glazed_terracotta',
|
||||
'light_blue_glazed_terracotta', 'yellow_glazed_terracotta', 'lime_glazed_terracotta',
|
||||
'pink_glazed_terracotta', 'gray_glazed_terracotta', 'light_gray_glazed_terracotta',
|
||||
'cyan_glazed_terracotta', 'purple_glazed_terracotta', 'blue_glazed_terracotta',
|
||||
'brown_glazed_terracotta', 'green_glazed_terracotta', 'red_glazed_terracotta',
|
||||
'black_glazed_terracotta',
|
||||
'white_stained_glass', 'orange_stained_glass', 'magenta_stained_glass',
|
||||
'light_blue_stained_glass', 'yellow_stained_glass', 'lime_stained_glass',
|
||||
'pink_stained_glass', 'gray_stained_glass', 'light_gray_stained_glass',
|
||||
'cyan_stained_glass', 'purple_stained_glass', 'blue_stained_glass',
|
||||
'brown_stained_glass', 'green_stained_glass', 'red_stained_glass',
|
||||
'black_stained_glass',
|
||||
'crafting_table', 'furnace', 'blast_furnace', 'smoker', 'smithing_table',
|
||||
'fletching_table', 'cartography_table', 'loom', 'stonecutter', 'grindstone',
|
||||
'anvil', 'chipped_anvil', 'damaged_anvil', 'enchanting_table',
|
||||
'brewing_stand', 'cauldron', 'composter', 'barrel',
|
||||
'shulker_box', 'dispenser', 'dropper', 'hopper', 'observer',
|
||||
'piston', 'sticky_piston', 'redstone_lamp', 'target', 'lever',
|
||||
'beacon', 'conduit', 'lodestone', 'respawn_anchor',
|
||||
'cactus', 'sugar_cane', 'bamboo',
|
||||
'mushroom_stem', 'brown_mushroom_block', 'red_mushroom_block',
|
||||
'oak_leaves', 'spruce_leaves', 'birch_leaves', 'jungle_leaves',
|
||||
'acacia_leaves', 'dark_oak_leaves', 'mangrove_leaves', 'cherry_leaves', 'azalea_leaves',
|
||||
// Additional blocks commonly seen as items
|
||||
'smooth_stone', 'smooth_sandstone', 'smooth_red_sandstone', 'smooth_quartz',
|
||||
'chiseled_sandstone', 'cut_sandstone', 'chiseled_red_sandstone', 'cut_red_sandstone',
|
||||
'quartz_pillar', 'chiseled_quartz_block', 'quartz_bricks',
|
||||
'ladder', 'cobweb', 'torch', 'soul_torch', 'lantern', 'soul_lantern',
|
||||
'redstone_torch', 'chain',
|
||||
'rail', 'powered_rail', 'detector_rail', 'activator_rail',
|
||||
'brown_mushroom', 'red_mushroom',
|
||||
'oak_sapling', 'spruce_sapling', 'birch_sapling', 'jungle_sapling',
|
||||
'acacia_sapling', 'dark_oak_sapling', 'cherry_sapling',
|
||||
'dandelion', 'poppy', 'blue_orchid', 'allium', 'azure_bluet',
|
||||
'red_tulip', 'orange_tulip', 'white_tulip', 'pink_tulip',
|
||||
'oxeye_daisy', 'cornflower', 'lily_of_the_valley', 'sunflower',
|
||||
'lilac', 'rose_bush', 'peony', 'wither_rose', 'torchflower',
|
||||
'dead_bush', 'fern', 'short_grass', 'tall_grass', 'large_fern',
|
||||
'vine', 'lily_pad', 'seagrass', 'kelp', 'hanging_roots', 'spore_blossom',
|
||||
'tube_coral', 'brain_coral', 'bubble_coral', 'fire_coral', 'horn_coral',
|
||||
'tube_coral_block', 'brain_coral_block', 'bubble_coral_block', 'fire_coral_block', 'horn_coral_block',
|
||||
'tube_coral_fan', 'brain_coral_fan', 'bubble_coral_fan', 'fire_coral_fan', 'horn_coral_fan',
|
||||
'white_concrete_powder', 'orange_concrete_powder', 'magenta_concrete_powder',
|
||||
'light_blue_concrete_powder', 'yellow_concrete_powder', 'lime_concrete_powder',
|
||||
'pink_concrete_powder', 'gray_concrete_powder', 'light_gray_concrete_powder',
|
||||
'cyan_concrete_powder', 'purple_concrete_powder', 'blue_concrete_powder',
|
||||
'brown_concrete_powder', 'green_concrete_powder', 'red_concrete_powder', 'black_concrete_powder',
|
||||
'sculk', 'sculk_catalyst', 'sculk_shrieker', 'sculk_sensor', 'sculk_vein',
|
||||
'mud_bricks', 'packed_mud', 'muddy_mangrove_roots',
|
||||
'crimson_stem', 'warped_stem', 'stripped_crimson_stem', 'stripped_warped_stem',
|
||||
'crimson_nylium', 'warped_nylium', 'shroomlight', 'nether_wart_block', 'warped_wart_block',
|
||||
'crying_obsidian', 'blackstone', 'polished_blackstone', 'polished_blackstone_bricks',
|
||||
'chiseled_polished_blackstone', 'gilded_blackstone', 'cracked_polished_blackstone_bricks',
|
||||
'lodestone', 'respawn_anchor',
|
||||
'pointed_dripstone', 'moss_carpet', 'azalea', 'flowering_azalea',
|
||||
'powder_snow', 'mangrove_roots',
|
||||
'copper_block', 'exposed_copper', 'weathered_copper', 'oxidized_copper',
|
||||
'cut_copper', 'exposed_cut_copper', 'weathered_cut_copper', 'oxidized_cut_copper',
|
||||
'waxed_copper_block', 'waxed_exposed_copper', 'waxed_weathered_copper', 'waxed_oxidized_copper',
|
||||
// Items rendered as 3D entities in-game — no flat texture exists.
|
||||
// Skip to emoji immediately to avoid a wasted network request.
|
||||
const ENTITY_ONLY = new Set([
|
||||
'chest', 'ender_chest', 'trapped_chest', 'shield', 'conduit',
|
||||
'bell', 'decorated_pot', 'trident',
|
||||
]);
|
||||
|
||||
// Some blocks need a specific texture suffix (e.g. furnace_front, oak_log_top)
|
||||
// We use _front, _top, or _side variants for recognizable look
|
||||
const BLOCK_TEXTURE_SUFFIXES = {
|
||||
furnace: '_front',
|
||||
blast_furnace: '_front',
|
||||
smoker: '_front',
|
||||
dispenser: '_front',
|
||||
dropper: '_front',
|
||||
observer: '_front',
|
||||
piston: '_top',
|
||||
sticky_piston: '_top',
|
||||
barrel: '_top',
|
||||
crafting_table: '_top',
|
||||
cartography_table: '_top',
|
||||
fletching_table: '_top',
|
||||
smithing_table: '_top',
|
||||
grass_block: '_top',
|
||||
mycelium: '_top',
|
||||
podzol: '_top',
|
||||
pumpkin: '_side',
|
||||
carved_pumpkin: '_front',
|
||||
jack_o_lantern: '_front',
|
||||
jukebox: '_top',
|
||||
loom: '_front',
|
||||
bee_nest: '_front',
|
||||
beehive: '_front',
|
||||
respawn_anchor: '_top',
|
||||
bone_block: '_side',
|
||||
basalt: '_side',
|
||||
polished_basalt: '_side',
|
||||
quartz_pillar: '_side',
|
||||
purpur_pillar: '_side',
|
||||
tnt: '_side',
|
||||
composter: '_side',
|
||||
enchanting_table: '_top',
|
||||
};
|
||||
|
||||
// Resolve derivative blocks (stairs, slabs, fences, walls, buttons) to parent block texture
|
||||
const WOOD_TYPES = new Set([
|
||||
'oak', 'spruce', 'birch', 'jungle', 'acacia', 'dark_oak',
|
||||
'mangrove', 'cherry', 'bamboo', 'crimson', 'warped',
|
||||
const DYE_COLORS = new Set([
|
||||
'white', 'orange', 'magenta', 'light_blue', 'yellow', 'lime',
|
||||
'pink', 'gray', 'light_gray', 'cyan', 'purple', 'blue',
|
||||
'brown', 'green', 'red', 'black',
|
||||
]);
|
||||
const STONE_ALIASES = {
|
||||
brick: 'bricks', stone_brick: 'stone_bricks', mossy_stone_brick: 'mossy_stone_bricks',
|
||||
nether_brick: 'nether_bricks', red_nether_brick: 'red_nether_bricks',
|
||||
end_stone_brick: 'end_stone_bricks', deepslate_brick: 'deepslate_bricks',
|
||||
deepslate_tile: 'deepslate_tiles', polished_blackstone_brick: 'polished_blackstone_bricks',
|
||||
mud_brick: 'mud_bricks', quartz: 'quartz_block', purpur: 'purpur_block',
|
||||
smooth_stone: 'smooth_stone',
|
||||
};
|
||||
|
||||
function resolveDerivativeTexture(name) {
|
||||
const suffixes = ['_stairs', '_slab', '_fence_gate', '_fence', '_wall', '_button', '_pressure_plate'];
|
||||
for (const suffix of suffixes) {
|
||||
if (!name.endsWith(suffix)) continue;
|
||||
const base = name.slice(0, -suffix.length);
|
||||
if (BLOCK_TEXTURES.has(base)) return base;
|
||||
if (WOOD_TYPES.has(base)) return `${base}_planks`;
|
||||
if (STONE_ALIASES[base]) return STONE_ALIASES[base];
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
/**
|
||||
* Check if an item is known to have no flat texture (entity/model-only).
|
||||
*/
|
||||
function isEntityOnly(name) {
|
||||
if (ENTITY_ONLY.has(name)) return true;
|
||||
if (name.endsWith('_bed') && DYE_COLORS.has(name.slice(0, -'_bed'.length))) return true;
|
||||
if (name.endsWith('_banner') && DYE_COLORS.has(name.slice(0, -'_banner'.length))) return true;
|
||||
if (name.endsWith('_skull') || name.endsWith('_head')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate texture URLs to try in order.
|
||||
* - CC:Tweaked → curated texture map (1 request)
|
||||
* - Create → item/ then block/ (2 requests max)
|
||||
* - Unknown mods → empty (instant emoji, no wasted requests)
|
||||
* - Vanilla derivatives → parent block texture (1 request)
|
||||
* - Vanilla → block/ or item/ with fallback
|
||||
* Build the single resolve URL for an item.
|
||||
* The server handles all name resolution (aliases, suffixes, derivatives, etc.)
|
||||
*/
|
||||
function getTextureUrls(fullItemName) {
|
||||
function getTextureUrl(fullItemName) {
|
||||
const colonIdx = (fullItemName || '').indexOf(':');
|
||||
const namespace = colonIdx >= 0 ? fullItemName.substring(0, colonIdx) : 'minecraft';
|
||||
const shortName = colonIdx >= 0 ? fullItemName.substring(colonIdx + 1) : fullItemName;
|
||||
const urls = [];
|
||||
|
||||
// CC:Tweaked → use curated texture map
|
||||
if (namespace === 'computercraft') {
|
||||
const mapped = CC_TEXTURE_MAP[shortName];
|
||||
if (mapped) {
|
||||
urls.push(`${TEXTURE_PROXY_BASE}/computercraft/${mapped}.png`);
|
||||
} else {
|
||||
urls.push(`${TEXTURE_PROXY_BASE}/computercraft/item/${shortName}.png`);
|
||||
urls.push(`${TEXTURE_PROXY_BASE}/computercraft/block/${shortName}.png`);
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
// Entity-only items → instant emoji, no network request
|
||||
if (namespace === 'minecraft' && isEntityOnly(shortName)) return null;
|
||||
|
||||
// Create mod → item/ then block/
|
||||
if (namespace === 'create') {
|
||||
urls.push(`${TEXTURE_PROXY_BASE}/create/item/${shortName}.png`);
|
||||
urls.push(`${TEXTURE_PROXY_BASE}/create/block/${shortName}.png`);
|
||||
return urls;
|
||||
}
|
||||
|
||||
// Unknown mod namespace → no textures available, instant emoji fallback
|
||||
if (namespace !== 'minecraft') {
|
||||
return urls;
|
||||
}
|
||||
|
||||
// === Vanilla (minecraft) ===
|
||||
const alias = TEXTURE_ALIASES[shortName] || shortName;
|
||||
|
||||
// Derivative blocks (stairs, slabs, fences, walls, buttons) → parent texture
|
||||
const parent = resolveDerivativeTexture(alias);
|
||||
if (parent) {
|
||||
const suffix = BLOCK_TEXTURE_SUFFIXES[parent] || '';
|
||||
urls.push(`${TEXTURE_PROXY_BASE}/minecraft/block/${parent}${suffix}.png`);
|
||||
return urls;
|
||||
}
|
||||
|
||||
// Known block → try block/ first (with suffix if applicable)
|
||||
if (BLOCK_TEXTURES.has(alias)) {
|
||||
const suffix = BLOCK_TEXTURE_SUFFIXES[alias] || '';
|
||||
urls.push(`${TEXTURE_PROXY_BASE}/minecraft/block/${alias}${suffix}.png`);
|
||||
if (suffix) urls.push(`${TEXTURE_PROXY_BASE}/minecraft/block/${alias}.png`);
|
||||
}
|
||||
|
||||
// Try item/ texture
|
||||
urls.push(`${TEXTURE_PROXY_BASE}/minecraft/item/${alias}.png`);
|
||||
|
||||
// If not a known block, also try block/ as last resort
|
||||
if (!BLOCK_TEXTURES.has(alias)) {
|
||||
urls.push(`${TEXTURE_PROXY_BASE}/minecraft/block/${alias}.png`);
|
||||
}
|
||||
|
||||
return urls;
|
||||
return `${RESOLVE_BASE}/${namespace}/${shortName}.png`;
|
||||
}
|
||||
|
||||
// Cache of resolved icon URLs (avoid re-fetching on every render)
|
||||
@@ -277,21 +50,20 @@ const iconCache = new Map();
|
||||
|
||||
/**
|
||||
* Renders a Minecraft item icon using the official game textures.
|
||||
* Cascading fallback: mod texture → item texture → block texture → emoji
|
||||
* The server's /api/texture/resolve endpoint does all the smart matching.
|
||||
* Falls back to emoji when no texture is found.
|
||||
*/
|
||||
function ItemIcon({ itemName, size = 32 }) {
|
||||
const [urlIndex, setUrlIndex] = useState(0);
|
||||
const [allFailed, setAllFailed] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (!itemName) {
|
||||
return <span className="item-icon-emoji" style={{ fontSize: size * 0.7 }}>📦</span>;
|
||||
}
|
||||
|
||||
// Use full item name as cache key (handles mod namespaces correctly)
|
||||
const cacheKey = itemName.replace(/^minecraft:/, '');
|
||||
|
||||
// Check if we already know this item has no texture
|
||||
if (iconCache.get(cacheKey) === 'none' || allFailed) {
|
||||
if (iconCache.get(cacheKey) === 'none' || failed) {
|
||||
return (
|
||||
<span className="item-icon-emoji" style={{ fontSize: size * 0.7 }}>
|
||||
{getItemEmoji(itemName)}
|
||||
@@ -314,10 +86,9 @@ function ItemIcon({ itemName, size = 32 }) {
|
||||
);
|
||||
}
|
||||
|
||||
const urls = getTextureUrls(itemName);
|
||||
const currentUrl = urls[urlIndex];
|
||||
const url = getTextureUrl(itemName);
|
||||
|
||||
if (!currentUrl) {
|
||||
if (!url) {
|
||||
iconCache.set(cacheKey, 'none');
|
||||
return (
|
||||
<span className="item-icon-emoji" style={{ fontSize: size * 0.7 }}>
|
||||
@@ -329,21 +100,17 @@ function ItemIcon({ itemName, size = 32 }) {
|
||||
return (
|
||||
<img
|
||||
className="item-icon-img"
|
||||
src={currentUrl}
|
||||
src={url}
|
||||
alt={cacheKey}
|
||||
width={size}
|
||||
height={size}
|
||||
loading="lazy"
|
||||
onLoad={() => {
|
||||
iconCache.set(cacheKey, currentUrl);
|
||||
iconCache.set(cacheKey, url);
|
||||
}}
|
||||
onError={() => {
|
||||
if (urlIndex + 1 < urls.length) {
|
||||
setUrlIndex(urlIndex + 1);
|
||||
} else {
|
||||
iconCache.set(cacheKey, 'none');
|
||||
setAllFailed(true);
|
||||
}
|
||||
iconCache.set(cacheKey, 'none');
|
||||
setFailed(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
|
||||
|
||||
@@ -8,6 +8,16 @@ mkdir -p /data
|
||||
chown -R node:node /data
|
||||
echo "[entrypoint] /data permissions fixed"
|
||||
|
||||
# Download textures if cache is empty (first run)
|
||||
TEXTURE_DIR="/data/texture-cache/minecraft"
|
||||
if [ ! -d "$TEXTURE_DIR" ] || [ -z "$(ls -A "$TEXTURE_DIR" 2>/dev/null)" ]; then
|
||||
echo "[entrypoint] Downloading textures (first run)..."
|
||||
su-exec node node /app/download-textures.js /data/texture-cache
|
||||
echo "[entrypoint] Texture download complete"
|
||||
else
|
||||
echo "[entrypoint] Texture cache exists, skipping download"
|
||||
fi
|
||||
|
||||
# Drop privileges and exec the CMD
|
||||
echo "[entrypoint] Dropping to user 'node', running: $*"
|
||||
exec su-exec node "$@"
|
||||
|
||||
213
web/server/download-textures.js
Normal file
213
web/server/download-textures.js
Normal file
@@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* download-textures.js — Bulk-download item/block textures for all supported
|
||||
* mods into the local texture cache.
|
||||
*
|
||||
* Supported mods (Prominence Hasturian Era II modpack):
|
||||
* Minecraft, Create, CC:Tweaked, Mythic Metals, Farmer's Delight,
|
||||
* Ad Astra, BetterEnd, Applied Energistics 2, Twilight Forest
|
||||
*
|
||||
* Run once (or on container start) to pre-populate the cache so the proxy
|
||||
* never needs to hit upstream for known textures.
|
||||
*
|
||||
* Usage: node download-textures.js [cacheDir]
|
||||
* Default: /data/texture-cache (matches server.js)
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const CACHE_DIR = process.argv[2] || process.env.TEXTURE_CACHE_DIR || '/data/texture-cache';
|
||||
|
||||
// ── Upstream repos (same namespaces as TEXTURE_UPSTREAMS in server.js) ─────
|
||||
|
||||
const REPOS = {
|
||||
minecraft: {
|
||||
api: 'https://api.github.com/repos/InventivetalentDev/minecraft-assets/git/trees/1.21.4?recursive=1',
|
||||
raw: 'https://cdn.jsdelivr.net/gh/InventivetalentDev/minecraft-assets@1.21.4',
|
||||
prefix: 'assets/minecraft/textures/',
|
||||
folders: ['item/', 'block/'],
|
||||
},
|
||||
create: {
|
||||
api: 'https://api.github.com/repos/Creators-of-Create/Create/git/trees/mc1.20.1/dev?recursive=1',
|
||||
raw: 'https://raw.githubusercontent.com/Creators-of-Create/Create/mc1.20.1/dev',
|
||||
prefix: 'src/main/resources/assets/create/textures/',
|
||||
folders: ['item/', 'block/'],
|
||||
},
|
||||
computercraft: {
|
||||
api: 'https://api.github.com/repos/cc-tweaked/CC-Tweaked/git/trees/mc-1.20.x?recursive=1',
|
||||
raw: 'https://raw.githubusercontent.com/cc-tweaked/CC-Tweaked/mc-1.20.x',
|
||||
prefix: 'projects/common/src/main/resources/assets/computercraft/textures/',
|
||||
folders: ['item/', 'block/'],
|
||||
},
|
||||
mythicmetals: {
|
||||
api: 'https://api.github.com/repos/Noaaan/MythicMetals/git/trees/1.20?recursive=1',
|
||||
raw: 'https://raw.githubusercontent.com/Noaaan/MythicMetals/1.20',
|
||||
prefix: 'src/main/resources/assets/mythicmetals/textures/',
|
||||
folders: ['item/', 'block/'],
|
||||
},
|
||||
farmersdelight: {
|
||||
api: 'https://api.github.com/repos/vectorwing/FarmersDelight/git/trees/1.20?recursive=1',
|
||||
raw: 'https://raw.githubusercontent.com/vectorwing/FarmersDelight/1.20',
|
||||
prefix: 'src/main/resources/assets/farmersdelight/textures/',
|
||||
folders: ['item/', 'block/'],
|
||||
},
|
||||
ad_astra: {
|
||||
api: 'https://api.github.com/repos/terrarium-earth/Ad-Astra/git/trees/1.20.1?recursive=1',
|
||||
raw: 'https://raw.githubusercontent.com/terrarium-earth/Ad-Astra/1.20.1',
|
||||
prefix: 'common/src/main/resources/assets/ad_astra/textures/',
|
||||
folders: ['item/', 'block/'],
|
||||
},
|
||||
betterend: {
|
||||
api: 'https://api.github.com/repos/quiqueck/BetterEnd/git/trees/1.20?recursive=1',
|
||||
raw: 'https://raw.githubusercontent.com/quiqueck/BetterEnd/1.20',
|
||||
prefix: 'src/main/resources/assets/betterend/textures/',
|
||||
folders: ['item/', 'block/'],
|
||||
},
|
||||
ae2: {
|
||||
api: 'https://api.github.com/repos/AppliedEnergistics/Applied-Energistics-2/git/trees/fabric/1.20.1?recursive=1',
|
||||
raw: 'https://raw.githubusercontent.com/AppliedEnergistics/Applied-Energistics-2/fabric/1.20.1',
|
||||
prefix: 'src/main/resources/assets/ae2/textures/',
|
||||
folders: ['item/', 'block/'],
|
||||
},
|
||||
twilightforest: {
|
||||
api: 'https://api.github.com/repos/TeamTwilight/twilightforest/git/trees/1.20.1?recursive=1',
|
||||
raw: 'https://raw.githubusercontent.com/TeamTwilight/twilightforest/1.20.1',
|
||||
prefix: 'src/main/resources/assets/twilightforest/textures/',
|
||||
folders: ['item/', 'block/'],
|
||||
},
|
||||
};
|
||||
|
||||
// ── Rate-limit-friendly parallel downloader ────────────────────────────────
|
||||
|
||||
const CONCURRENCY = 10;
|
||||
const RETRY_DELAY = 1000;
|
||||
const MAX_RETRIES = 3;
|
||||
|
||||
async function downloadFile(url, dest, retries = 0) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (res.status === 404) return false; // genuinely missing
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.writeFileSync(dest, buffer);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (retries < MAX_RETRIES) {
|
||||
await new Promise(r => setTimeout(r, RETRY_DELAY * (retries + 1)));
|
||||
return downloadFile(url, dest, retries + 1);
|
||||
}
|
||||
console.error(` ✗ ${url}: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runPool(tasks) {
|
||||
let idx = 0;
|
||||
let ok = 0;
|
||||
let fail = 0;
|
||||
async function worker() {
|
||||
while (idx < tasks.length) {
|
||||
const task = tasks[idx++];
|
||||
const success = await downloadFile(task.url, task.dest);
|
||||
if (success) ok++;
|
||||
else fail++;
|
||||
}
|
||||
}
|
||||
const workers = Array.from({ length: Math.min(CONCURRENCY, tasks.length) }, () => worker());
|
||||
await Promise.all(workers);
|
||||
return { ok, fail };
|
||||
}
|
||||
|
||||
// ── Main ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function downloadNamespace(name, repo) {
|
||||
console.log(`\n⬇ ${name}: fetching file list…`);
|
||||
|
||||
const res = await fetch(repo.api, {
|
||||
headers: { 'User-Agent': 'inventory-manager-texture-dl' },
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(` ✗ GitHub API ${res.status}: ${await res.text()}`);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
|
||||
// Filter to only PNG files in the target folders under the prefix
|
||||
const tasks = [];
|
||||
for (const entry of data.tree || []) {
|
||||
if (entry.type !== 'blob') continue;
|
||||
if (!entry.path.startsWith(repo.prefix)) continue;
|
||||
if (!entry.path.endsWith('.png')) continue;
|
||||
|
||||
const relPath = entry.path.slice(repo.prefix.length); // e.g. "item/diamond.png"
|
||||
const inFolder = repo.folders.some(f => relPath.startsWith(f));
|
||||
if (!inFolder) continue;
|
||||
|
||||
const dest = path.join(CACHE_DIR, name, relPath);
|
||||
if (fs.existsSync(dest)) continue; // already cached
|
||||
|
||||
tasks.push({
|
||||
url: `${repo.raw}/${entry.path}`,
|
||||
dest,
|
||||
});
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
console.log(` ✓ ${name}: all textures already cached`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(` ↓ downloading ${tasks.length} textures…`);
|
||||
const { ok, fail } = await runPool(tasks);
|
||||
console.log(` ✓ ${name}: ${ok} downloaded, ${fail} failed`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`Texture cache dir: ${CACHE_DIR}`);
|
||||
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
||||
|
||||
for (const [name, repo] of Object.entries(REPOS)) {
|
||||
await downloadNamespace(name, repo);
|
||||
}
|
||||
|
||||
// Clean up any .miss files so newly-downloaded textures take effect
|
||||
let cleared = 0;
|
||||
function cleanMiss(dir) {
|
||||
try {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) cleanMiss(full);
|
||||
else if (entry.name.endsWith('.miss')) { fs.unlinkSync(full); cleared++; }
|
||||
}
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
cleanMiss(CACHE_DIR);
|
||||
if (cleared) console.log(`\n🗑 Cleared ${cleared} negative-cache entries`);
|
||||
|
||||
// Build and print index stats
|
||||
const stats = {};
|
||||
function countPngs(dir, ns) {
|
||||
try {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) countPngs(full, ns);
|
||||
else if (entry.name.endsWith('.png')) stats[ns] = (stats[ns] || 0) + 1;
|
||||
}
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
for (const ns of Object.keys(REPOS)) {
|
||||
countPngs(path.join(CACHE_DIR, ns), ns);
|
||||
}
|
||||
console.log('\n📊 Cache totals:');
|
||||
for (const [ns, count] of Object.entries(stats)) {
|
||||
console.log(` ${ns}: ${count} textures`);
|
||||
}
|
||||
console.log('\n✅ Done');
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Fatal:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
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",
|
||||
|
||||
@@ -7,10 +7,12 @@
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js",
|
||||
"download-textures": "node download-textures.js",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cc-platform/server": "file:../../../cc-platform-core/server",
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
|
||||
@@ -1,91 +1,152 @@
|
||||
import express from 'express';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { createServer } from 'http';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { createRequire } from 'module';
|
||||
import {
|
||||
loadFullState, saveFullState, recordItemHistory,
|
||||
saveItems, saveFurnaces, saveAlerts, saveState, loadState,
|
||||
getHistory, getHistorySummary, closeDb, flushPendingSave,
|
||||
} from './db.js';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const {
|
||||
createPlatformServer,
|
||||
createWebSocketManager,
|
||||
setupGracefulShutdown,
|
||||
createRateLimiter,
|
||||
createProxyEndpoint,
|
||||
} = require('@cc-platform/server');
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
|
||||
// CORS intentionally omitted — nginx reverse-proxy makes all requests same-origin.
|
||||
// If you need direct server access during dev, add: app.use(require('cors')())
|
||||
app.disable('x-powered-by');
|
||||
app.use(express.json({ limit: '5mb' }));
|
||||
|
||||
// ========== API Key Authentication ==========
|
||||
|
||||
const API_KEY = process.env.API_KEY || '';
|
||||
|
||||
// Validate bearer token from Authorization header or ?key= query param
|
||||
function extractApiKey(req) {
|
||||
const auth = req.headers.authorization || '';
|
||||
if (auth.startsWith('Bearer ')) return auth.slice(7);
|
||||
return req.query.key || '';
|
||||
}
|
||||
|
||||
// Middleware: require API key on mutating endpoints
|
||||
function requireAuth(req, res, next) {
|
||||
if (!API_KEY) return next(); // Auth disabled when no key configured
|
||||
const token = extractApiKey(req);
|
||||
if (token === API_KEY) return next();
|
||||
return res.status(401).json({ error: 'Unauthorized — invalid or missing API key' });
|
||||
}
|
||||
|
||||
// Apply auth to all POST/PUT/DELETE routes
|
||||
app.use((req, res, next) => {
|
||||
if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') {
|
||||
return next(); // Read-only endpoints stay open
|
||||
}
|
||||
return requireAuth(req, res, next);
|
||||
// ========== Platform Server Setup ==========
|
||||
// Provides Express app, HTTP server, auth middleware, and health endpoint.
|
||||
// CORS intentionally disabled — nginx reverse-proxy makes all requests same-origin.
|
||||
const { app, server, auth, start, port } = createPlatformServer({
|
||||
serviceName: 'inventory-manager',
|
||||
cors: false,
|
||||
rateLimit: false, // custom rate limiting below (excludes bridge endpoints)
|
||||
healthExtras: () => ({
|
||||
lastUpdate,
|
||||
bridgeConnected: bridgeClients.size > 0,
|
||||
webClients: webClients.size,
|
||||
}),
|
||||
});
|
||||
|
||||
// ========== Rate Limiting (in-memory, no external dependencies) ==========
|
||||
const { requireAuth } = auth;
|
||||
|
||||
function createRateLimiter(windowMs, max) {
|
||||
const hits = new Map();
|
||||
setInterval(() => {
|
||||
const cutoff = Date.now() - windowMs;
|
||||
for (const [key, entry] of hits) {
|
||||
if (entry.start < cutoff) hits.delete(key);
|
||||
}
|
||||
}, windowMs);
|
||||
return (req, res, next) => {
|
||||
const key = req.ip || req.socket.remoteAddress || 'unknown';
|
||||
const now = Date.now();
|
||||
const entry = hits.get(key);
|
||||
if (!entry || now - entry.start > windowMs) {
|
||||
hits.set(key, { start: now, count: 1 });
|
||||
return next();
|
||||
}
|
||||
entry.count++;
|
||||
if (entry.count > max) {
|
||||
res.set('Retry-After', String(Math.ceil((entry.start + windowMs - now) / 1000)));
|
||||
return res.status(429).json({ error: 'Too many requests — try again later' });
|
||||
}
|
||||
return next();
|
||||
};
|
||||
}
|
||||
// API key reference needed for WebSocket upgrade authentication
|
||||
const API_KEY = process.env.API_KEY || '';
|
||||
|
||||
// 30 mutating requests per minute per IP (excludes bridge state updates)
|
||||
const commandLimiter = createRateLimiter(60_000, 30);
|
||||
// Custom rate limiting: 30 mutating requests/min per IP, excluding bridge endpoints
|
||||
const commandLimiter = createRateLimiter({ windowMs: 60000, maxRequests: 30 });
|
||||
app.use((req, res, next) => {
|
||||
if (req.method !== 'POST' || req.path.startsWith('/api/bridge/')) return next();
|
||||
if (req.path.startsWith('/api/bridge/')) return 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...');
|
||||
@@ -138,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++;
|
||||
@@ -171,27 +226,266 @@ function pushCommandToBridge(command) {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== HTTP Server ==========
|
||||
const server = createServer(app);
|
||||
|
||||
// ========== Texture Proxy / Cache ==========
|
||||
const TEXTURE_CACHE_DIR = process.env.TEXTURE_CACHE_DIR || path.join('/data', 'texture-cache');
|
||||
const NEGATIVE_CACHE_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days for 404s
|
||||
|
||||
// Upstream CDN mapping
|
||||
// Upstream CDN mapping (Prominence Hasturian Era II modpack coverage)
|
||||
const TEXTURE_UPSTREAMS = {
|
||||
minecraft: 'https://cdn.jsdelivr.net/gh/InventivetalentDev/minecraft-assets@1.21.4/assets/minecraft/textures',
|
||||
computercraft: 'https://raw.githubusercontent.com/cc-tweaked/CC-Tweaked/mc-1.20.x/projects/common/src/main/resources/assets/computercraft/textures',
|
||||
create: 'https://raw.githubusercontent.com/Creators-of-Create/Create/mc1.20.1/dev/src/main/resources/assets/create/textures',
|
||||
mythicmetals: 'https://raw.githubusercontent.com/Noaaan/MythicMetals/1.20/src/main/resources/assets/mythicmetals/textures',
|
||||
farmersdelight: 'https://raw.githubusercontent.com/vectorwing/FarmersDelight/1.20/src/main/resources/assets/farmersdelight/textures',
|
||||
ad_astra: 'https://raw.githubusercontent.com/terrarium-earth/Ad-Astra/1.20.1/common/src/main/resources/assets/ad_astra/textures',
|
||||
betterend: 'https://raw.githubusercontent.com/quiqueck/BetterEnd/1.20/src/main/resources/assets/betterend/textures',
|
||||
ae2: 'https://raw.githubusercontent.com/AppliedEnergistics/Applied-Energistics-2/fabric/1.20.1/src/main/resources/assets/ae2/textures',
|
||||
twilightforest: 'https://raw.githubusercontent.com/TeamTwilight/twilightforest/1.20.1/src/main/resources/assets/twilightforest/textures',
|
||||
};
|
||||
|
||||
// Ensure cache directory exists
|
||||
fs.mkdirSync(TEXTURE_CACHE_DIR, { recursive: true });
|
||||
|
||||
// ── Texture index: maps base_name → relative path for smart resolution ────
|
||||
// Built on startup by scanning the texture cache. Key = short name (no ext),
|
||||
// Value = array of relative paths (e.g. ["item/diamond.png", "block/diamond_ore.png"])
|
||||
const textureIndex = {}; // { namespace: { baseName: [relPath, ...] } }
|
||||
|
||||
function buildTextureIndex() {
|
||||
for (const ns of Object.keys(TEXTURE_UPSTREAMS)) {
|
||||
textureIndex[ns] = {};
|
||||
const nsDir = path.join(TEXTURE_CACHE_DIR, ns);
|
||||
if (!fs.existsSync(nsDir)) continue;
|
||||
(function walk(dir, rel) {
|
||||
try {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) walk(path.join(dir, entry.name), childRel);
|
||||
else if (entry.name.endsWith('.png') && !entry.name.endsWith('.miss')) {
|
||||
const baseName = entry.name.slice(0, -4); // strip .png
|
||||
if (!textureIndex[ns][baseName]) textureIndex[ns][baseName] = [];
|
||||
textureIndex[ns][baseName].push(childRel);
|
||||
}
|
||||
}
|
||||
} catch (_) { /* ignore unreadable dirs */ }
|
||||
})(nsDir, '');
|
||||
}
|
||||
const total = Object.values(textureIndex).reduce(
|
||||
(sum, idx) => sum + Object.keys(idx).length, 0);
|
||||
console.log(`[Texture Index] Indexed ${total} unique texture names`);
|
||||
}
|
||||
|
||||
buildTextureIndex();
|
||||
|
||||
// ── Smart texture resolution ──────────────────────────────────────────────
|
||||
// Given a Minecraft item/block registry name, find the best matching texture.
|
||||
// Returns the relative cache path or null.
|
||||
|
||||
// Patterns for items whose textures use numbered animation frames
|
||||
const ANIMATED_ITEMS = new Set([
|
||||
'compass', 'recovery_compass', 'clock',
|
||||
'crossbow', // crossbow_standby, crossbow_pulling_0, etc.
|
||||
'light', // light_0 .. light_15
|
||||
]);
|
||||
|
||||
// Maps a registry short name to the actual texture base name
|
||||
const SERVER_ALIASES = {
|
||||
// Registry name differs from texture filename
|
||||
magma_block: 'magma',
|
||||
grass: 'short_grass',
|
||||
scute: 'turtle_scute',
|
||||
enchanted_golden_apple: 'golden_apple',
|
||||
};
|
||||
|
||||
// Preferred animation frame for animated items
|
||||
const ANIMATED_FRAME = {
|
||||
compass: 'compass_16',
|
||||
recovery_compass: 'recovery_compass_16',
|
||||
clock: 'clock_22',
|
||||
crossbow: 'crossbow_standby',
|
||||
light: 'light_15',
|
||||
};
|
||||
|
||||
// Dye color set for carpet/bed/banner resolution
|
||||
const DYE_COLORS = new Set([
|
||||
'white', 'orange', 'magenta', 'light_blue', 'yellow', 'lime',
|
||||
'pink', 'gray', 'light_gray', 'cyan', 'purple', 'blue',
|
||||
'brown', 'green', 'red', 'black',
|
||||
]);
|
||||
|
||||
const WOOD_TYPES = new Set([
|
||||
'oak', 'spruce', 'birch', 'jungle', 'acacia', 'dark_oak',
|
||||
'mangrove', 'cherry', 'bamboo', 'crimson', 'warped', 'pale_oak',
|
||||
]);
|
||||
|
||||
// Blocks whose texture uses a suffix: name_front, name_top, etc.
|
||||
const BLOCK_SUFFIXES = {
|
||||
furnace: '_front_on', blast_furnace: '_front_on', smoker: '_front_on',
|
||||
dispenser: '_front', dropper: '_front', observer: '_front',
|
||||
barrel: '_top', crafting_table: '_top', grass_block: '_top',
|
||||
mycelium: '_top', podzol: '_top', dirt_path: '_top',
|
||||
quartz_block: '_side',
|
||||
pumpkin: '_side', carved_pumpkin: '_front', jack_o_lantern: '_front',
|
||||
jukebox: '_top', loom: '_front', bee_nest: '_front', beehive: '_front',
|
||||
respawn_anchor: '_top', bone_block: '_side',
|
||||
basalt: '_side', polished_basalt: '_side',
|
||||
tnt: '_side', composter: '_side', enchanting_table: '_top',
|
||||
cartography_table: '_top', fletching_table: '_top', smithing_table: '_top',
|
||||
};
|
||||
|
||||
// Derivative block suffixes → resolve to parent block
|
||||
const DERIVATIVE_SUFFIXES = [
|
||||
'_stairs', '_slab', '_fence_gate', '_fence', '_wall',
|
||||
'_button', '_pressure_plate',
|
||||
];
|
||||
const STONE_ALIASES = {
|
||||
brick: 'bricks', stone_brick: 'stone_bricks', mossy_stone_brick: 'mossy_stone_bricks',
|
||||
nether_brick: 'nether_bricks', red_nether_brick: 'red_nether_bricks',
|
||||
end_stone_brick: 'end_stone_bricks', deepslate_brick: 'deepslate_bricks',
|
||||
deepslate_tile: 'deepslate_tiles', polished_blackstone_brick: 'polished_blackstone_bricks',
|
||||
mud_brick: 'mud_bricks', quartz: 'quartz_block', purpur: 'purpur_block',
|
||||
smooth_stone: 'smooth_stone',
|
||||
};
|
||||
// "smooth_" blocks use the parent's top/bottom face
|
||||
const SMOOTH_ALIASES = {
|
||||
smooth_sandstone: 'sandstone_top',
|
||||
smooth_red_sandstone: 'red_sandstone_top',
|
||||
smooth_quartz: 'quartz_block_bottom',
|
||||
};
|
||||
|
||||
function resolveTexturePath(ns, itemName) {
|
||||
const idx = textureIndex[ns];
|
||||
if (!idx) return null;
|
||||
|
||||
// Helper: check if a baseName exists and return preferred path
|
||||
function find(baseName, preferFolder) {
|
||||
const paths = idx[baseName];
|
||||
if (!paths || paths.length === 0) return null;
|
||||
if (preferFolder) {
|
||||
const match = paths.find(p => p.startsWith(preferFolder + '/'));
|
||||
if (match) return match;
|
||||
}
|
||||
return paths[0];
|
||||
}
|
||||
|
||||
// Helper: check if a baseName with a suffix exists in block/
|
||||
function findBlock(baseName) {
|
||||
const suffix = BLOCK_SUFFIXES[baseName];
|
||||
if (suffix) {
|
||||
const hit = find(baseName + suffix, 'block') || find(baseName, 'block');
|
||||
if (hit) return hit;
|
||||
}
|
||||
return find(baseName, 'block');
|
||||
}
|
||||
|
||||
// 1. Direct lookup — try item/ then block/
|
||||
let hit = find(itemName, 'item') || findBlock(itemName);
|
||||
if (hit) return hit;
|
||||
|
||||
// 2. Server-side alias
|
||||
if (SERVER_ALIASES[itemName]) {
|
||||
const alias = SERVER_ALIASES[itemName];
|
||||
hit = find(alias, 'item') || findBlock(alias);
|
||||
if (hit) return hit;
|
||||
}
|
||||
|
||||
// 3. Smooth blocks
|
||||
if (SMOOTH_ALIASES[itemName]) {
|
||||
hit = find(SMOOTH_ALIASES[itemName], 'block');
|
||||
if (hit) return hit;
|
||||
}
|
||||
|
||||
// 4. Animated items — pick a representative frame
|
||||
if (ANIMATED_FRAME[itemName]) {
|
||||
hit = find(ANIMATED_FRAME[itemName], 'item');
|
||||
if (hit) return hit;
|
||||
}
|
||||
|
||||
// 5. Carpets → wool texture
|
||||
if (itemName.endsWith('_carpet')) {
|
||||
const color = itemName.slice(0, -'_carpet'.length);
|
||||
if (DYE_COLORS.has(color)) {
|
||||
hit = find(color + '_wool', 'block');
|
||||
if (hit) return hit;
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Wood → log texture
|
||||
if (itemName.endsWith('_wood')) {
|
||||
const base = itemName.slice(0, -'_wood'.length);
|
||||
const stripped = base.startsWith('stripped_') ? base.slice('stripped_'.length) : null;
|
||||
const woodType = stripped || base;
|
||||
if (WOOD_TYPES.has(woodType)) {
|
||||
const logName = stripped ? `stripped_${woodType}_log` : `${woodType}_log`;
|
||||
hit = find(logName, 'block');
|
||||
if (hit) return hit;
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Derivative blocks (stairs, slabs, fences, walls…) → parent block
|
||||
for (const suffix of DERIVATIVE_SUFFIXES) {
|
||||
if (!itemName.endsWith(suffix)) continue;
|
||||
const base = itemName.slice(0, -suffix.length);
|
||||
// Try direct parent
|
||||
hit = findBlock(base);
|
||||
if (hit) return hit;
|
||||
// Wood → planks
|
||||
if (WOOD_TYPES.has(base)) {
|
||||
hit = findBlock(base + '_planks');
|
||||
if (hit) return hit;
|
||||
}
|
||||
// Stone aliases (brick → bricks, etc.)
|
||||
if (STONE_ALIASES[base]) {
|
||||
hit = findBlock(STONE_ALIASES[base]);
|
||||
if (hit) return hit;
|
||||
}
|
||||
break; // only one derivative suffix can match
|
||||
}
|
||||
|
||||
// 8. Prefix match — for items like "compass" try "compass_*"
|
||||
const prefixMatches = Object.keys(idx).filter(k => k.startsWith(itemName + '_'));
|
||||
if (prefixMatches.length > 0) {
|
||||
// Sort for deterministic result, prefer item/ folder
|
||||
prefixMatches.sort();
|
||||
for (const m of prefixMatches) {
|
||||
hit = find(m, 'item');
|
||||
if (hit) return hit;
|
||||
}
|
||||
hit = find(prefixMatches[0]);
|
||||
if (hit) return hit;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Smart resolution endpoint ─────────────────────────────────────────────
|
||||
// GET /api/texture/resolve/:namespace/:itemName
|
||||
// Returns the texture PNG for a Minecraft registry item name, using smart
|
||||
// matching (aliases, animation frames, carpet→wool, etc.)
|
||||
app.get('/api/texture/resolve/:namespace/:itemName', (req, res) => {
|
||||
const { namespace, itemName } = req.params;
|
||||
const name = itemName.replace(/\.png$/i, '');
|
||||
|
||||
const resolved = resolveTexturePath(namespace, name);
|
||||
if (!resolved) {
|
||||
return res.status(404).send('No texture found');
|
||||
}
|
||||
|
||||
const fullPath = path.join(TEXTURE_CACHE_DIR, namespace, resolved);
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
return res.status(404).send('Texture file missing');
|
||||
}
|
||||
|
||||
res.set('Content-Type', 'image/png');
|
||||
res.set('Cache-Control', 'public, max-age=604800');
|
||||
res.set('X-Texture-Resolved', resolved);
|
||||
return res.sendFile(fullPath);
|
||||
});
|
||||
|
||||
// ── Legacy texture proxy (fallback for cache misses) ──────────────────────
|
||||
app.get('/api/texture/:namespace/*', async (req, res) => {
|
||||
const { namespace } = req.params;
|
||||
const texturePath = req.params[0]; // e.g. "item/diamond.png"
|
||||
|
||||
const texturePath = req.params[0];
|
||||
const upstream = TEXTURE_UPSTREAMS[namespace];
|
||||
if (!upstream) {
|
||||
return res.status(404).send('Unknown namespace');
|
||||
@@ -282,16 +576,7 @@ app.delete('/api/texture-cache/negative', (req, res) => {
|
||||
res.json({ cleared });
|
||||
});
|
||||
|
||||
// Health check
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
lastUpdate,
|
||||
uptime: process.uptime(),
|
||||
bridgeConnected: bridgeClients.size > 0,
|
||||
webClients: webClients.size,
|
||||
});
|
||||
});
|
||||
// Health endpoint provided by platform (createPlatformServer) with custom extras
|
||||
|
||||
// Get current inventory state
|
||||
app.get('/api/inventory', (req, res) => {
|
||||
@@ -721,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 });
|
||||
@@ -901,187 +1182,19 @@ 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}`);
|
||||
console.log(`🔌 WebSocket Server: ws://localhost:${PORT}/ws`);
|
||||
console.log(`📡 HTTP Server: http://localhost:${port}`);
|
||||
console.log(`🔌 WebSocket Server: ws://localhost:${port}/ws`);
|
||||
if (API_KEY) {
|
||||
console.log('🔒 API key authentication enabled');
|
||||
} else {
|
||||
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
|
||||
|
||||
@@ -1141,53 +1254,24 @@ app.get('/api/integration/low-stock', (req, res) => {
|
||||
});
|
||||
|
||||
// Proxy to turtle server for combined dashboard info
|
||||
app.get('/api/integration/turtle-status', async (req, res) => {
|
||||
if (!TURTLE_SERVER_URL) {
|
||||
return res.json({ configured: false, message: 'TURTLE_SERVER_URL not configured' });
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(`${TURTLE_SERVER_URL}/api/turtles`);
|
||||
const data = await resp.json();
|
||||
res.json({ configured: true, ...data });
|
||||
} catch (err) {
|
||||
res.status(502).json({ configured: true, error: `Cannot reach turtle server: ${err.message}` });
|
||||
}
|
||||
});
|
||||
createProxyEndpoint(app, '/api/integration/turtle-status', 'TURTLE_SERVER_URL', '/api/turtles');
|
||||
|
||||
// ========== Start Server ==========
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.log(`✅ Inventory Manager Web Server ready on ${HOST}:${PORT}`);
|
||||
console.log(`\nBridge HTTP endpoint: http://localhost:${PORT}/api/bridge/state`);
|
||||
console.log(`Bridge WebSocket: ws://localhost:${PORT}/ws/bridge`);
|
||||
console.log(`Web client WebSocket: ws://localhost:${PORT}/ws`);
|
||||
setupGracefulShutdown({
|
||||
serviceName: 'inventory-manager',
|
||||
cleanup: [
|
||||
() => wsManager.close(),
|
||||
() => server.close(),
|
||||
() => { closeDb(); console.log('💾 Database closed'); },
|
||||
],
|
||||
});
|
||||
|
||||
start(() => {
|
||||
console.log(`\nBridge HTTP endpoint: http://localhost:${port}/api/bridge/state`);
|
||||
console.log(`Bridge WebSocket: ws://localhost:${port}/ws/bridge`);
|
||||
console.log(`Web client WebSocket: ws://localhost:${port}/ws`);
|
||||
if (TURTLE_SERVER_URL) {
|
||||
console.log(`🐢 Turtle server integration: ${TURTLE_SERVER_URL}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
function shutdown() {
|
||||
console.log('\n🛑 Shutting down server...');
|
||||
try {
|
||||
wss.close();
|
||||
server.close();
|
||||
closeDb();
|
||||
console.log('💾 Database closed');
|
||||
} catch (err) {
|
||||
console.error('❌ Error during shutdown:', err.message);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
|
||||
// Catch unhandled errors to prevent silent crashes
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
console.error('❌ Unhandled rejection:', reason);
|
||||
});
|
||||
process.on('uncaughtException', (err) => {
|
||||
console.error('❌ Uncaught exception:', err);
|
||||
shutdown();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user