-- Monitor Billboard: Large goals display -- Connects to the inventoryManager via wired modem and renders -- a read-only billboard on an attached monitor showing: -- - Storage capacity bar -- - Top items bar chart -- - Stock alert status (from alerts.lua) -- - Current activity indicators -- -- Create .billboard_config for auto-launch. Example: -- { "monitorSide": "monitor_0", "topItemCount": 20 } ------------------------------------------------- -- Default configuration ------------------------------------------------- local BROADCAST_CHANNEL = 4200 local MONITOR_SIDE = "monitor_0" local TOP_ITEM_COUNT = 20 -- max items in bar chart (auto-shrinks to fit) local REFRESH_INTERVAL = 0.5 -- seconds between redraws (throttle) -- Color theme local THEME = { bg = colors.black, headerBg = colors.blue, headerFg = colors.white, border = colors.gray, label = colors.lightGray, value = colors.white, barFull = colors.lime, barEmpty = colors.gray, barWarn = colors.yellow, barCrit = colors.red, alertOk = colors.lime, alertLow = colors.red, alertWarn = colors.orange, activityOn = colors.lime, activityOff = colors.gray, graphBar = colors.cyan, graphBarAlt = colors.lightBlue, sectionBg = colors.black, sectionHead = colors.yellow, } ------------------------------------------------- -- Load config from file if present ------------------------------------------------- local fs = _G.fs local shell = _ENV.shell or _G.shell local _baseDir = fs.getDir(shell.getRunningProgram()) local function _path(rel) return fs.combine(_baseDir, rel) end 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 for Opus _ENV inheritance 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 log = dofile(_path("lib/log.lua")) local CONFIG_FILE = _configPath(".billboard_config") local function loadConfig() if not fs.exists(CONFIG_FILE) then return end local f = fs.open(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", CONFIG_FILE) return end if cfg.broadcastChannel then BROADCAST_CHANNEL = cfg.broadcastChannel end if cfg.monitorSide then MONITOR_SIDE = cfg.monitorSide end if cfg.topItemCount then TOP_ITEM_COUNT = cfg.topItemCount end if cfg.refreshInterval then REFRESH_INTERVAL = cfg.refreshInterval end if cfg.logLevel then log.setLevel(cfg.logLevel) end log.info("CONFIG", "Loaded from %s", CONFIG_FILE) end loadConfig() ------------------------------------------------- -- State (mirrored from manager broadcasts) ------------------------------------------------- local state = { itemList = {}, grandTotal = 0, chestCount = 0, totalSlots = 0, usedSlots = 0, freeSlots = 0, usedRatio = 0, activeAlerts = {}, activity = {}, connected = false, lastUpdate = 0, needsRedraw = true, } ------------------------------------------------- -- Helpers ------------------------------------------------- local function shortName(fullName) -- Strip mod namespace and prettify local s = fullName:gsub("^[%w_]+:", ""):gsub("_", " ") return s:sub(1, 1):upper() .. s:sub(2) end local function formatNumber(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 padRight(s, w) if #s >= w then return s:sub(1, w) end return s .. string.rep(" ", w - #s) end local function padLeft(s, w) if #s >= w then return s:sub(1, w) end return string.rep(" ", w - #s) .. s end local function centerText(s, w) if #s >= w then return s:sub(1, w) end local pad = math.floor((w - #s) / 2) return string.rep(" ", pad) .. s .. string.rep(" ", w - #s - pad) end ------------------------------------------------- -- Drawing primitives ------------------------------------------------- local mon -- monitor peripheral local monW, monH -- monitor dimensions local function setColors(fg, bg) mon.setTextColor(fg) mon.setBackgroundColor(bg) end local function clearLine(y, bg) mon.setCursorPos(1, y) mon.setBackgroundColor(bg or THEME.bg) mon.write(string.rep(" ", monW)) end local function writeCentered(y, text, fg, bg) clearLine(y, bg or THEME.bg) setColors(fg or THEME.value, bg or THEME.bg) mon.setCursorPos(math.floor((monW - #text) / 2) + 1, y) mon.write(text) end local function writeAt(x, y, text, fg, bg) setColors(fg or THEME.value, bg or THEME.bg) mon.setCursorPos(x, y) mon.write(text) end local function drawHLine(y, char, fg, bg) clearLine(y, bg or THEME.bg) setColors(fg or THEME.border, bg or THEME.bg) mon.setCursorPos(1, y) mon.write(string.rep(char or "\x8c", monW)) end --- Draw a horizontal bar at (x, y) with given width. --- filled = 0.0 to 1.0 local function drawBar(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 mon.setCursorPos(x, y) mon.setBackgroundColor(fgColor or THEME.barFull) mon.write(string.rep(" ", fillW)) mon.setBackgroundColor(bgColor or THEME.barEmpty) mon.write(string.rep(" ", width - fillW)) mon.setBackgroundColor(THEME.bg) end ------------------------------------------------- -- Section renderers ------------------------------------------------- --- Header: title bar local function drawHeader(y) clearLine(y, THEME.headerBg) setColors(THEME.headerFg, THEME.headerBg) local title = "\x04 INVENTORY GOALS BILLBOARD \x04" mon.setCursorPos(math.floor((monW - #title) / 2) + 1, y) mon.write(title) return y + 1 end --- Storage capacity section local function drawStorageBar(y) drawHLine(y, "\x8c", THEME.border) y = y + 1 -- Section header writeAt(2, y, "\x10 STORAGE", THEME.sectionHead) y = y + 1 local ratio = state.usedRatio or 0 local pct = math.floor(ratio * 100 + 0.5) -- Choose bar color based on fullness local barColor = THEME.barFull if ratio > 0.9 then barColor = THEME.barCrit elseif ratio > 0.75 then barColor = THEME.barWarn end -- Capacity bar local labelW = 6 -- "100% " local barW = monW - 4 - labelW if barW < 10 then barW = 10 end writeAt(2, y, padLeft(pct .. "%", 4), barColor) writeAt(7, y, " ", THEME.label) drawBar(7, y, barW, ratio, barColor, THEME.barEmpty) y = y + 1 -- Stats line local stats = string.format( "Slots: %s/%s | Items: %s | Chests: %d", formatNumber(state.usedSlots), formatNumber(state.totalSlots), formatNumber(state.grandTotal), state.chestCount ) writeAt(2, y, stats, THEME.label) y = y + 1 return y end --- Top items bar chart local function drawItemChart(y, maxRows) drawHLine(y, "\x8c", THEME.border) y = y + 1 writeAt(2, y, "\x10 TOP ITEMS", THEME.sectionHead) y = y + 1 local items = state.itemList if not items or #items == 0 then writeAt(2, y, "No items in storage", THEME.label) return y + 1 end -- Sort by total descending (should already be sorted, but ensure it) 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) -- Determine how many to show local count = math.min(#sorted, maxRows, TOP_ITEM_COUNT) if count < 1 then count = 1 end -- Find max value for scaling bars local maxVal = sorted[1].total if maxVal < 1 then maxVal = 1 end -- Column layout local numW = 8 -- right-aligned count local nameW = math.floor(monW * 0.35) if nameW < 12 then nameW = 12 end if nameW > 25 then nameW = 25 end local barW = monW - nameW - numW - 5 -- 2 left pad + 2 gaps + 1 right pad if barW < 5 then barW = 5 end for i = 1, count do local item = sorted[i] local name = shortName(item.name) local num = formatNumber(item.total) local frac = item.total / maxVal clearLine(y) -- Row number local idx = padLeft(tostring(i), 2) writeAt(1, y, idx, THEME.border) -- Item name writeAt(4, y, padRight(name, nameW), THEME.value) -- Bar local barColor = (i % 2 == 0) and THEME.graphBarAlt or THEME.graphBar local barStart = 4 + nameW + 1 drawBar(barStart, y, barW, frac, barColor, THEME.barEmpty) -- Count writeAt(barStart + barW + 1, y, padLeft(num, numW), THEME.value) y = y + 1 end return y end --- Alert status section local function drawAlerts(y, maxRows) drawHLine(y, "\x8c", THEME.border) y = y + 1 writeAt(2, y, "\x10 STOCK ALERTS", THEME.sectionHead) y = y + 1 local alerts = state.activeAlerts if not alerts or #alerts == 0 then writeAt(2, y, "\x04 All stocks OK", THEME.alertOk) return y + 1 end -- Show alerts, 2 per row if monitor is wide enough local colW = math.floor(monW / 2) local twoCol = (monW >= 40) local row = 0 for i, alert in ipairs(alerts) do if row >= maxRows then writeAt(2, y, string.format(" ... +%d more alerts", #alerts - i + 1), THEME.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 -- Icon + color based on severity local icon, color if current == 0 then icon = "\x07" color = THEME.alertLow elseif ratio < 0.5 then icon = "\x07" color = THEME.alertLow else icon = "!" color = THEME.alertWarn end local text = string.format("%s %s: %s/%s", icon, label, formatNumber(current), formatNumber(minVal)) if twoCol then local col = ((i - 1) % 2 == 0) and 2 or (colW + 1) if col == 2 then clearLine(y) end writeAt(col, y, padRight(text, colW - 1), color) if (i - 1) % 2 == 1 or i == #alerts then y = y + 1 row = row + 1 end else clearLine(y) writeAt(2, y, text, color) y = y + 1 row = row + 1 end end return y end --- Activity status footer local function drawActivity(y) drawHLine(y, "\x8c", THEME.border) y = y + 1 local act = state.activity local labels = { { key = "sorting", label = "SORT" }, { key = "scanning", label = "SCAN" }, { key = "smelting", label = "SMELT" }, { key = "dispensing", label = "DISPENSE" }, { key = "defragging", label = "DEFRAG" }, { key = "composting", label = "COMPOST" }, { key = "crafting", label = "CRAFT" }, { key = "autocrafting", label = "AUTOCRAFT" }, { key = "discarding", label = "DISCARD" }, } clearLine(y) writeAt(2, y, "ACTIVITY:", THEME.sectionHead) local x = 12 local anyActive = false for _, entry in ipairs(labels) do local active = act[entry.key] if active then anyActive = true local color = THEME.activityOn if x + #entry.label + 2 > monW then y = y + 1 clearLine(y) x = 3 end writeAt(x, y, entry.label, color) x = x + #entry.label + 2 end end if not anyActive then writeAt(12, y, "IDLE", THEME.activityOff) end y = y + 1 -- Connection status + timestamp clearLine(y) if state.connected then local ago = math.floor((os.epoch("utc") - state.lastUpdate) / 1000) local status if ago <= 2 then status = "\x04 Connected | Live" else status = string.format("\x04 Connected | %ds ago", ago) end writeAt(2, y, status, THEME.alertOk) else writeAt(2, y, "\x07 Waiting for manager broadcast...", THEME.alertLow) end return y + 1 end ------------------------------------------------- -- Main draw function ------------------------------------------------- local function drawBillboard() if not mon then return end monW, monH = mon.getSize() mon.setBackgroundColor(THEME.bg) mon.clear() local y = 1 -- Header y = drawHeader(y) -- Storage bar (takes 4 lines) y = drawStorageBar(y) -- Calculate remaining space for items vs alerts -- Reserve: alerts section ~5-8 lines, activity footer ~3 lines local alertLines = state.activeAlerts and #state.activeAlerts or 0 local alertSectionH = math.max(3, math.min(6, math.ceil(alertLines / 2) + 2)) local footerH = 3 -- divider + activity + connection local itemRows = monH - y - alertSectionH - footerH if itemRows < 3 then itemRows = 3 end -- Top items chart y = drawItemChart(y, itemRows) -- Alerts local alertMaxRows = monH - y - footerH if alertMaxRows < 2 then alertMaxRows = 2 end y = drawAlerts(y, alertMaxRows) -- Fill gap before footer (if monitor is taller than content) local footerY = monH - 2 while y < footerY do clearLine(y) y = y + 1 end -- Activity footer (always at bottom) drawActivity(footerY) end ------------------------------------------------- -- Main ------------------------------------------------- local function main() print("===================================") print(" Inventory Billboard (Goals View)") print("===================================") print("") -- Find monitor mon = peripheral.wrap(MONITOR_SIDE) if not mon then -- Try to find any connected monitor mon = peripheral.find("monitor") if mon then log.info("INIT", "Auto-detected monitor via peripheral.find") end end if not mon then log.error("INIT", "No monitor found on '%s'!", MONITOR_SIDE) print(" Attach a monitor and restart.") print(" Set monitorSide in .billboard_config") return end mon.setTextScale(0.5) -- small text for maximum real estate monW, monH = mon.getSize() log.info("INIT", "Monitor: %s (%dx%d)", MONITOR_SIDE, monW, monH) -- Find modem local networkModem = nil for _, name in ipairs(peripheral.getNames()) do if peripheral.getType(name) == "modem" then networkModem = peripheral.wrap(name) networkModem.open(BROADCAST_CHANNEL) log.info("INIT", "Modem: %s (ch %d)", name, BROADCAST_CHANNEL) break end end if not networkModem then log.error("INIT", "No modem found! Cannot receive data from manager.") print(" Attach a wired modem and restart.") return end -- Draw initial waiting screen mon.setBackgroundColor(THEME.bg) mon.clear() writeCentered(math.floor(monH / 2) - 1, "\x04 INVENTORY BILLBOARD \x04", THEME.headerFg, THEME.headerBg) writeCentered(math.floor(monH / 2) + 1, "Waiting for manager broadcast...", THEME.label) writeCentered(math.floor(monH / 2) + 2, "Channel " .. BROADCAST_CHANNEL, THEME.border) print("Listening on channel " .. BROADCAST_CHANNEL) print("Monitor: " .. monW .. "x" .. monH) print("") parallel.waitForAny( -- Task 1: Modem receiver function() while true do local _, _, channel, _, message = os.pullEvent("modem_message") if channel == BROADCAST_CHANNEL and type(message) == "table" and message.type == "state" then -- Update state from broadcast if message.cache then local c = message.cache state.itemList = c.itemList or state.itemList state.grandTotal = c.grandTotal ~= nil and c.grandTotal or state.grandTotal state.chestCount = c.chestCount ~= nil and c.chestCount or state.chestCount state.totalSlots = c.totalSlots ~= nil and c.totalSlots or state.totalSlots state.usedSlots = c.usedSlots ~= nil and c.usedSlots or state.usedSlots state.freeSlots = c.freeSlots ~= nil and c.freeSlots or state.freeSlots state.usedRatio = c.usedRatio ~= nil and c.usedRatio or state.usedRatio end if message.activity then state.activity = message.activity end if message.alerts then state.activeAlerts = message.alerts end if not state.connected then state.connected = true log.info("NET", "Connected to manager!") end state.lastUpdate = os.epoch("utc") state.needsRedraw = true end end end, -- Task 2: Redraw loop (throttled) function() while true do if state.needsRedraw then state.needsRedraw = false local ok, err = pcall(drawBillboard) if not ok then log.error("DRAW", "Render error: %s", tostring(err)) end end sleep(REFRESH_INTERVAL) end end, -- Task 3: Periodic full redraw (connection staleness check) function() while true do sleep(5) -- Force redraw to update "Xs ago" timestamp state.needsRedraw = true end end ) end main()