-- 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 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 ui = dofile(_path("lib/ui.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 = { 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 -- 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({ 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 if message.cache.droppers then c.droppers = message.cache.droppers 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()