543 lines
19 KiB
Lua
543 lines
19 KiB
Lua
-- 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()
|