-- Inventory Client: Display-only dashboard -- Connects to the master inventoryManager via wired modem. -- Shows the same dashboard but runs NO automation tasks. -- Orders and smelter controls are sent to the master. -- -- Reuses the same Opus UI display module (manager/display.lua) -- as the server, with an ops shim that sends network commands. ------------------------------------------------- -- Default configuration (overridden by .client_config) ------------------------------------------------- local BROADCAST_CHANNEL = 4200 -- master sends state on this channel local ORDER_CHANNEL = 4201 -- master listens for commands here local CLIENT_CHANNEL = 4202 -- client listens for replies here local MONITOR_SIDE = "left" local SMELTER_MONITOR_SIDE = "top" -- Remote station: set these to use a different dropper/barrel than the master. -- Leave empty ("") to use the master's default dropper/barrel. local CLIENT_DROPPER_NAME = "" -- e.g. "minecraft:dropper_5" local CLIENT_BARREL_NAME = "" -- e.g. "minecraft:barrel_3" local DROPPER_ANNOUNCE_INTERVAL = 30 -- seconds between dropper announcements ------------------------------------------------- -- Load config from file if present ------------------------------------------------- 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) 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() else error(err, 2) end end local CLIENT_CONFIG_FILE = _configPath(".client_config") ------------------------------------------------- -- Modules ------------------------------------------------- local log = dofile(_path("lib/log.lua")) local function loadConfig() if not fs.exists(CLIENT_CONFIG_FILE) then return end local f = fs.open(CLIENT_CONFIG_FILE, "r") local data = f.readAll() f.close() local ok, cfg = pcall(textutils.unserialiseJSON, data) if not ok or not cfg then log.warn("CONFIG", "Failed to parse %s", CLIENT_CONFIG_FILE) return end -- Channels if cfg.broadcastChannel then BROADCAST_CHANNEL = cfg.broadcastChannel end if cfg.orderChannel then ORDER_CHANNEL = cfg.orderChannel end if cfg.clientChannel then CLIENT_CHANNEL = cfg.clientChannel end -- Peripherals if cfg.monitorSide then MONITOR_SIDE = cfg.monitorSide end if cfg.smelterMonitorSide then SMELTER_MONITOR_SIDE = cfg.smelterMonitorSide end if cfg.dropperName then CLIENT_DROPPER_NAME = cfg.dropperName end if cfg.barrelName then CLIENT_BARREL_NAME = cfg.barrelName end -- Timing if cfg.dropperAnnounceInterval then DROPPER_ANNOUNCE_INTERVAL = cfg.dropperAnnounceInterval end if cfg.logLevel then log.setLevel(cfg.logLevel) end log.info("CONFIG", "Loaded from %s", CLIENT_CONFIG_FILE) end loadConfig() ------------------------------------------------- -- Network modem ------------------------------------------------- local networkModem = nil ------------------------------------------------- -- Command ID generation (idempotency) ------------------------------------------------- local cmdCounter = 0 local function newCommandId() cmdCounter = cmdCounter + 1 return string.format("client_%d_%d_%d", os.getComputerID(), os.epoch("utc"), cmdCounter) end ------------------------------------------------- -- Send command to master ------------------------------------------------- local function sendToMaster(message) if not networkModem then return end if not message.commandId then message.commandId = newCommandId() end networkModem.transmit(ORDER_CHANNEL, CLIENT_CHANNEL, message) end ------------------------------------------------- -- State — mirrors the manager's state module interface -- so display.lua can read from it transparently. ------------------------------------------------- local state = {} state.cache = { catalogue = {}, itemList = {}, itemListDirty = false, grandTotal = 0, chestCount = 0, totalSlots = 0, usedSlots = 0, freeSlots = 0, usedRatio = 0, dropperOk = false, barrelOk = false, furnaceCount = 0, furnaceStatus = {}, } state.activity = { sorting = false, dispensing = false, scanning = false, smelting = false, defragging = false, composting = false, crafting = false, } state.needsRedraw = true state.smelterNeedsRedraw = true state.statusMessage = "" state.statusColor = colors.white state.statusTimer = 0 state.activeAlerts = {} state.smeltingPaused = false state.disabledRecipes = {} -- Client receives itemList pre-built from master; no-op here. function state.ensureItemList() end ------------------------------------------------- -- Config — mirrors cfg interface for display.lua ------------------------------------------------- local cfg = { MONITOR_SIDE = MONITOR_SIDE, SMELTER_MONITOR_SIDE = SMELTER_MONITOR_SIDE, SMELTABLE = {}, -- populated from master broadcast CRAFTABLE = {}, -- populated from master broadcast } ------------------------------------------------- -- Ops shim — sends commands to master instead -- of executing locally. Exposes the same API -- that display.lua calls on ctx.ops. ------------------------------------------------- local ops = {} --- Get stock total for an item from the received itemList. local function getItemTotal(itemName) for _, item in ipairs(state.cache.itemList) do if item.name == itemName then return item.total end end 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 function ops.orderItem(itemName, amount) sendToMaster({ type = "order", itemName = itemName, amount = amount, dropperName = CLIENT_DROPPER_NAME ~= "" and CLIENT_DROPPER_NAME or nil, }) log.info("ORDER", "Sent to master: %s x%d", itemName, amount) end function ops.saveDisabledRecipes() -- Sync full smelting state to master so it persists and applies. sendToMaster({ type = "sync_disabled_recipes", disabledRecipes = state.disabledRecipes, smeltingPaused = state.smeltingPaused, }) end function ops.craftItem(recipeIdx) sendToMaster({ type = "craft", recipeIdx = recipeIdx }) local recipe = cfg.CRAFTABLE[recipeIdx] if recipe then log.info("CRAFT", "Craft request sent: %s", recipe.output) end -- Return "pending" — the actual result arrives asynchronously return true, "Sent to master" end ------------------------------------------------- -- Connected flag (shown as "waiting" screen) ------------------------------------------------- local connected = false local craftTurtleOk = false ------------------------------------------------- -- Build context for display.lua (same interface -- that the manager passes to it). ------------------------------------------------- local ctx = { cfg = cfg, state = state, log = log, ops = ops, craftTurtleName = nil, -- updated from master broadcasts } -- Load the shared Opus UI display module — same one the manager uses. local D = dofile(_path("manager/display.lua"))(ctx) ------------------------------------------------- -- Client dropper discovery ------------------------------------------------- local function discoverLocalDroppers() local droppers = {} -- Check configured dropper first if CLIENT_DROPPER_NAME ~= "" then local exists = peripheral.wrap(CLIENT_DROPPER_NAME) ~= nil if exists then table.insert(droppers, { name = CLIENT_DROPPER_NAME, isDefault = false, clientId = os.getComputerID() }) end end -- Also discover any other droppers on client's local network for _, name in ipairs(peripheral.getNames()) do local isDropper = name:match("^minecraft:dropper_") if not isDropper and peripheral.hasType then isDropper = peripheral.hasType(name, "minecraft:dropper") end if not isDropper then isDropper = peripheral.getType(name) == "minecraft:dropper" end if isDropper then -- Avoid duplicates with configured dropper local isDuplicate = false for _, d in ipairs(droppers) do if d.name == name then isDuplicate = true; break end end if not isDuplicate then table.insert(droppers, { name = name, isDefault = false, clientId = os.getComputerID() }) end end end return droppers end local function announceDroppers() local droppers = discoverLocalDroppers() if #droppers > 0 then sendToMaster({ type = "register_droppers", clientId = os.getComputerID(), droppers = droppers, }) end end ------------------------------------------------- -- Main ------------------------------------------------- local function main() print("=================================") print(" Inventory Client (Display Only)") print("=================================") print("") if D.setupMonitor() then log.info("INIT", "Monitor: %s", D.monName or MONITOR_SIDE) else log.warn("INIT", "No monitor on %s", MONITOR_SIDE) end if D.setupSmelterMonitor() then log.info("INIT", "Smelter monitor: %s", D.smelterMonName or SMELTER_MONITOR_SIDE) else log.warn("INIT", "No smelter monitor on %s", SMELTER_MONITOR_SIDE) end -- Find modem for _, name in ipairs(peripheral.getNames()) do if peripheral.getType(name) == "modem" then networkModem = peripheral.wrap(name) networkModem.open(BROADCAST_CHANNEL) networkModem.open(CLIENT_CHANNEL) log.info("INIT", "Modem: %s", name) break end end if not networkModem then log.error("INIT", "No modem found! Cannot receive data from master.") print(" Attach a wired modem and restart.") return end print("") print("Listening for master broadcasts on channel " .. BROADCAST_CHANNEL) print("Console shows log. Use the monitors to interact.") print("") -- Draw initial "waiting" screen state.needsRedraw = true state.smelterNeedsRedraw = true parallel.waitForAny( -- Task 1: Modem receiver (state updates + order replies) function() while true do local event, side, channel, replyChannel, message, distance = os.pullEvent("modem_message") if channel == BROADCAST_CHANNEL and type(message) == "table" and message.type == "state" then -- Update all state from master local c = state.cache if message.cache then c.itemList = message.cache.itemList or c.itemList c.grandTotal = message.cache.grandTotal ~= nil and message.cache.grandTotal or c.grandTotal c.chestCount = message.cache.chestCount ~= nil and message.cache.chestCount or c.chestCount c.totalSlots = message.cache.totalSlots ~= nil and message.cache.totalSlots or c.totalSlots c.usedSlots = message.cache.usedSlots ~= nil and message.cache.usedSlots or c.usedSlots c.freeSlots = message.cache.freeSlots ~= nil and message.cache.freeSlots or c.freeSlots c.usedRatio = message.cache.usedRatio ~= nil and message.cache.usedRatio or c.usedRatio c.dropperOk = message.cache.dropperOk c.barrelOk = message.cache.barrelOk c.furnaceCount = message.cache.furnaceCount ~= nil and message.cache.furnaceCount or c.furnaceCount c.furnaceStatus = message.cache.furnaceStatus or c.furnaceStatus -- Also build catalogue from itemList so display.lua -- smelter tab can look up stock by item name if message.cache.catalogue then c.catalogue = message.cache.catalogue end end if message.activity then for k, v in pairs(message.activity) do state.activity[k] = v end end if message.alerts then state.activeAlerts = message.alerts end if message.smeltingPaused ~= nil then state.smeltingPaused = message.smeltingPaused end if message.disabledRecipes then state.disabledRecipes = message.disabledRecipes end if message.smeltable then cfg.SMELTABLE = message.smeltable end if message.craftable then cfg.CRAFTABLE = message.craftable end if message.craftTurtleOk ~= nil then craftTurtleOk = message.craftTurtleOk -- display.lua supports ctx.craftTurtleOk as an override -- so remote clients don't need local peripheral access. ctx.craftTurtleOk = craftTurtleOk end if not connected then connected = true log.info("NET", "Connected to master!") end state.needsRedraw = true state.smelterNeedsRedraw = true elseif channel == CLIENT_CHANNEL and type(message) == "table" and message.type == "order_result" then -- Order result from master state.statusMessage = message.message or "" state.statusColor = message.color or (message.success and colors.lime or colors.red) state.statusTimer = 5 state.needsRedraw = true if message.success then log.info("ORDER", "%s", state.statusMessage) else log.warn("ORDER", "%s", state.statusMessage) end elseif channel == CLIENT_CHANNEL and type(message) == "table" and message.type == "craft_result" then -- Craft result from master state.statusMessage = message.error or (message.success and "Craft complete" or "Craft failed") state.statusColor = message.success and colors.lime or colors.red state.statusTimer = 5 state.smelterNeedsRedraw = true if message.success then log.info("CRAFT", "%s", state.statusMessage) else log.warn("CRAFT", "%s", state.statusMessage) end end end end, -- Task 2: Inventory dashboard redraw function() state.needsRedraw = true while true do if state.needsRedraw then state.needsRedraw = false pcall(D.drawDashboard) end if state.statusTimer > 0 then state.statusTimer = state.statusTimer - 0.1 if state.statusTimer <= 0 then state.statusTimer = 0 state.needsRedraw = true end end if #state.activeAlerts > 0 then state.needsRedraw = true end sleep(0.1) end end, -- Task 3: Smelter dashboard redraw function() state.smelterNeedsRedraw = true while true do if state.smelterNeedsRedraw then state.smelterNeedsRedraw = false pcall(D.drawSmelterDashboard) end sleep(0.1) end end, -- Task 4: Announce local droppers to master periodically function() -- Initial announcement after short delay sleep(3) pcall(announceDroppers) while true do sleep(DROPPER_ANNOUNCE_INTERVAL) pcall(announceDroppers) end end, -- Task 5: Client barrel auto-sort (sends to master only when barrel has items) function() if CLIENT_BARREL_NAME == "" then while true do sleep(3600) end end log.info("INIT", "Client barrel: %s", CLIENT_BARREL_NAME) while true do local barrel = peripheral.wrap(CLIENT_BARREL_NAME) if barrel then local contents = barrel.list() if contents and next(contents) then sendToMaster({ type = "sort_barrel", barrelName = CLIENT_BARREL_NAME }) end end sleep(2) end end, -- Task 6: Touch event listener (both monitors) function() while true do local event, side, x, y = os.pullEvent("monitor_touch") if D.smelterMonName and side == D.smelterMonName then log.debug("TOUCH", "x=%d y=%d", x, y) D.handleSmelterTouch(x, y) else log.debug("TOUCH", "x=%d y=%d", x, y) D.handleTouch(x, y) end end end ) end main()