diff --git a/monitorBillboard.lua b/monitorBillboard.lua new file mode 100644 index 0000000..0a2ced8 --- /dev/null +++ b/monitorBillboard.lua @@ -0,0 +1,618 @@ +-- 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()