diff --git a/autorun/startup.lua b/autorun/startup.lua index 212879c..3f7ef28 100644 --- a/autorun/startup.lua +++ b/autorun/startup.lua @@ -28,8 +28,6 @@ elseif cfgExists('.webbridge_config') then role = 'bridge' elseif cfgExists('.miner_config') then role = 'miner' -elseif cfgExists('.billboard_config') then - role = 'billboard' elseif _G.turtle then role = 'turtle' end @@ -69,12 +67,11 @@ end ------------------------------------------------- local programs = { - manager = 'inventoryManager.lua', - client = 'inventoryClient.lua', - bridge = 'inventoryWebBridge.lua', - turtle = 'craftingTurtle.lua', - miner = 'miningTurtle.lua', - billboard = 'monitorBillboard.lua', + manager = 'inventoryManager.lua', + client = 'inventoryClient.lua', + bridge = 'inventoryWebBridge.lua', + turtle = 'craftingTurtle.lua', + miner = 'miningTurtle.lua', } local program = fs.combine(BASE, programs[role]) diff --git a/inventoryManager.lua b/inventoryManager.lua index 3f917a2..2932924 100644 --- a/inventoryManager.lua +++ b/inventoryManager.lua @@ -194,6 +194,15 @@ local function main() log.warn("INIT", "No smelter monitor on %s", cfg.SMELTER_MONITOR_SIDE) end + -- Billboard monitor (optional — set billboardMonitorSide in .manager_config) + if cfg.BILLBOARD_MONITOR_SIDE ~= "" then + if display.setupBillboardMonitor() then + log.info("INIT", "Billboard monitor: %s", display.billboardMonName) + else + log.warn("INIT", "Billboard monitor not found: %s", cfg.BILLBOARD_MONITOR_SIDE) + end + end + -- Find wired modem for client/turtle communication for _, name in ipairs(peripheral.getNames()) do if peripheral.getType(name) == "modem" then @@ -541,6 +550,23 @@ local function main() end end), + -- Task 8b: Billboard dashboard redraw (goals monitor) + resilient("Billboard", function() + if not display.billboardMon then + -- No billboard configured, sleep forever + while true do sleep(3600) end + end + state.billboardNeedsRedraw = true + while true do + if state.billboardNeedsRedraw then + state.billboardNeedsRedraw = false + local bok, berr = pcall(display.drawBillboard) + if not bok then log.error("DRAW", "Billboard: %s", tostring(berr)) end + end + sleep(0.5) + end + end), + -- Task 9: Touch event listener (both monitors) resilient("Touch-listener", function() while true do diff --git a/manager/config.lua b/manager/config.lua index e01619b..f48897f 100644 --- a/manager/config.lua +++ b/manager/config.lua @@ -34,6 +34,8 @@ C.COMPOST_INTERVAL = 3 C.ALERT_INTERVAL = 15 C.CACHE_FILE = _configPath(".inventory_cache") C.SMELTER_MONITOR_SIDE = "top" +C.BILLBOARD_MONITOR_SIDE = "" -- e.g. "monitor_0"; empty = disabled +C.BILLBOARD_TOP_ITEMS = 20 -- max items in billboard bar chart C.DISABLED_RECIPES_FILE = _configPath(".disabled_recipes") -- Network @@ -117,7 +119,9 @@ function C.loadConfig() if cfg.dropperName then C.DROPPER_NAME = cfg.dropperName end if cfg.barrelName then C.BARREL_NAME = cfg.barrelName end if cfg.monitorSide then C.MONITOR_SIDE = cfg.monitorSide end - if cfg.smelterMonitorSide then C.SMELTER_MONITOR_SIDE = cfg.smelterMonitorSide end + if cfg.smelterMonitorSide then C.SMELTER_MONITOR_SIDE = cfg.smelterMonitorSide end + if cfg.billboardMonitorSide then C.BILLBOARD_MONITOR_SIDE = cfg.billboardMonitorSide end + if cfg.billboardTopItems then C.BILLBOARD_TOP_ITEMS = cfg.billboardTopItems end if cfg.pollInterval then C.POLL_INTERVAL = cfg.pollInterval end if cfg.scanInterval then C.SCAN_INTERVAL = cfg.scanInterval end if cfg.smeltInterval then C.SMELT_INTERVAL = cfg.smeltInterval end diff --git a/manager/display.lua b/manager/display.lua index 5cf980a..a8a3bb3 100644 --- a/manager/display.lua +++ b/manager/display.lua @@ -26,6 +26,8 @@ D.mon = nil D.monName = nil D.smelterMon = nil D.smelterMonName = nil +D.billboardMon = nil +D.billboardMonName = nil -- Opus UI devices and pages local mainDevice = nil @@ -160,6 +162,29 @@ function D.setupSmelterMonitor() return true end +function D.setupBillboardMonitor() + if not cfg.BILLBOARD_MONITOR_SIDE or cfg.BILLBOARD_MONITOR_SIDE == "" then + return false + end + local mon = peripheral.wrap(cfg.BILLBOARD_MONITOR_SIDE) + if mon and mon.setTextScale then + D.billboardMon = mon + D.billboardMonName = cfg.BILLBOARD_MONITOR_SIDE + D.billboardMon.setTextScale(0.5) + return true + end + -- Fallback: try to find a monitor with that name on the network + for _, name in ipairs(peripheral.getNames()) do + if name == cfg.BILLBOARD_MONITOR_SIDE and peripheral.getType(name) == "monitor" then + D.billboardMon = peripheral.wrap(name) + D.billboardMonName = name + D.billboardMon.setTextScale(0.5) + return true + end + end + return false +end + ------------------------------------------------- -- Build main dashboard page ------------------------------------------------- @@ -1416,6 +1441,321 @@ function D.handleSmelterTouch(x, y) end end +------------------------------------------------- +-- Billboard rendering (raw monitor API, no Opus UI) +-- Read-only goals display: storage bar, top items +-- chart, stock alerts, activity indicators. +------------------------------------------------- + +local BB = {} -- billboard color theme +BB.bg = colors.black +BB.headerBg = colors.blue +BB.headerFg = colors.white +BB.border = colors.gray +BB.label = colors.lightGray +BB.value = colors.white +BB.barFull = colors.lime +BB.barEmpty = colors.gray +BB.barWarn = colors.yellow +BB.barCrit = colors.red +BB.alertOk = colors.lime +BB.alertLow = colors.red +BB.alertWarn = colors.orange +BB.activityOn = colors.lime +BB.activityOff = colors.gray +BB.graphBar = colors.cyan +BB.graphBarAlt = colors.lightBlue +BB.sectionHead = colors.yellow + +local function bbFormatNumber(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 bbPadRight(s, w) + if #s >= w then return s:sub(1, w) end + return s .. string.rep(" ", w - #s) +end + +local function bbPadLeft(s, w) + if #s >= w then return s:sub(1, w) end + return string.rep(" ", w - #s) .. s +end + +-- Billboard drawing primitives (operate on D.billboardMon) +local bbW, bbH = 0, 0 + +local function bbSetColors(fg, bg) + D.billboardMon.setTextColor(fg) + D.billboardMon.setBackgroundColor(bg) +end + +local function bbClearLine(y, bg) + D.billboardMon.setCursorPos(1, y) + D.billboardMon.setBackgroundColor(bg or BB.bg) + D.billboardMon.write(string.rep(" ", bbW)) +end + +local function bbWriteCentered(y, text, fg, bg) + bbClearLine(y, bg or BB.bg) + bbSetColors(fg or BB.value, bg or BB.bg) + D.billboardMon.setCursorPos(math.floor((bbW - #text) / 2) + 1, y) + D.billboardMon.write(text) +end + +local function bbWriteAt(x, y, text, fg, bg) + bbSetColors(fg or BB.value, bg or BB.bg) + D.billboardMon.setCursorPos(x, y) + D.billboardMon.write(text) +end + +local function bbHLine(y, char, fg, bg) + bbClearLine(y, bg or BB.bg) + bbSetColors(fg or BB.border, bg or BB.bg) + D.billboardMon.setCursorPos(1, y) + D.billboardMon.write(string.rep(char or "-", bbW)) +end + +local function bbDrawBar(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 + D.billboardMon.setCursorPos(x, y) + D.billboardMon.setBackgroundColor(fgColor or BB.barFull) + D.billboardMon.write(string.rep(" ", fillW)) + D.billboardMon.setBackgroundColor(bgColor or BB.barEmpty) + D.billboardMon.write(string.rep(" ", width - fillW)) + D.billboardMon.setBackgroundColor(BB.bg) +end + +-- Billboard section: header +local function bbDrawHeader(y) + bbClearLine(y, BB.headerBg) + bbSetColors(BB.headerFg, BB.headerBg) + local title = " INVENTORY GOALS BILLBOARD " + D.billboardMon.setCursorPos(math.floor((bbW - #title) / 2) + 1, y) + D.billboardMon.write(title) + return y + 1 +end + +-- Billboard section: storage capacity +local function bbDrawStorage(y) + bbHLine(y) + y = y + 1 + bbWriteAt(2, y, "> STORAGE", BB.sectionHead) + y = y + 1 + + local ratio = cache.usedRatio or 0 + local pct = math.floor(ratio * 100 + 0.5) + local barColor = BB.barFull + if ratio > 0.9 then barColor = BB.barCrit + elseif ratio > 0.75 then barColor = BB.barWarn end + + local barW = bbW - 10 + if barW < 10 then barW = 10 end + bbWriteAt(2, y, bbPadLeft(pct .. "%", 4), barColor) + bbDrawBar(7, y, barW, ratio, barColor, BB.barEmpty) + y = y + 1 + + local stats = string.format( + "Slots: %s/%s | Items: %s | Chests: %d", + bbFormatNumber(cache.usedSlots), + bbFormatNumber(cache.totalSlots), + bbFormatNumber(cache.grandTotal), + cache.chestCount + ) + bbWriteAt(2, y, stats, BB.label) + y = y + 1 + return y +end + +-- Billboard section: top items bar chart +local function bbDrawItems(y, maxRows) + bbHLine(y) + y = y + 1 + bbWriteAt(2, y, "> TOP ITEMS", BB.sectionHead) + y = y + 1 + + state.ensureItemList() + local items = cache.itemList + if not items or #items == 0 then + bbWriteAt(2, y, "No items in storage", BB.label) + return y + 1 + end + + 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) + + local count = math.min(#sorted, maxRows, cfg.BILLBOARD_TOP_ITEMS or 20) + if count < 1 then count = 1 end + + local maxVal = sorted[1].total + if maxVal < 1 then maxVal = 1 end + + local numW = 8 + local nameW = math.floor(bbW * 0.35) + if nameW < 12 then nameW = 12 end + if nameW > 25 then nameW = 25 end + local barW = bbW - nameW - numW - 5 + if barW < 5 then barW = 5 end + + for i = 1, count do + local item = sorted[i] + local name = shortName(item.name) + local num = bbFormatNumber(item.total) + local frac = item.total / maxVal + + bbClearLine(y) + bbWriteAt(1, y, bbPadLeft(tostring(i), 2), BB.border) + bbWriteAt(4, y, bbPadRight(name, nameW), BB.value) + + local barColor = (i % 2 == 0) and BB.graphBarAlt or BB.graphBar + local barStart = 4 + nameW + 1 + bbDrawBar(barStart, y, barW, frac, barColor, BB.barEmpty) + bbWriteAt(barStart + barW + 1, y, bbPadLeft(num, numW), BB.value) + + y = y + 1 + end + return y +end + +-- Billboard section: stock alerts +local function bbDrawAlerts(y, maxRows) + bbHLine(y) + y = y + 1 + bbWriteAt(2, y, "> STOCK ALERTS", BB.sectionHead) + y = y + 1 + + local alerts = state.activeAlerts + if not alerts or #alerts == 0 then + bbWriteAt(2, y, "* All stocks OK", BB.alertOk) + return y + 1 + end + + local colW = math.floor(bbW / 2) + local twoCol = (bbW >= 40) + local row = 0 + + for i, alert in ipairs(alerts) do + if row >= maxRows then + bbWriteAt(2, y, string.format(" ... +%d more", #alerts - i + 1), BB.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 + + local icon, color + if ratio < 0.5 then + icon, color = "!", BB.alertLow + else + icon, color = "!", BB.alertWarn + end + + local text = string.format("%s %s: %s/%s", icon, label, bbFormatNumber(current), bbFormatNumber(minVal)) + + if twoCol then + local col = ((i - 1) % 2 == 0) and 2 or (colW + 1) + if col == 2 then bbClearLine(y) end + bbWriteAt(col, y, bbPadRight(text, colW - 1), color) + if (i - 1) % 2 == 1 or i == #alerts then + y = y + 1 + row = row + 1 + end + else + bbClearLine(y) + bbWriteAt(2, y, text, color) + y = y + 1 + row = row + 1 + end + end + return y +end + +-- Billboard section: activity footer +local function bbDrawActivity(y) + bbHLine(y) + y = y + 1 + bbClearLine(y) + bbWriteAt(2, y, "ACTIVITY:", BB.sectionHead) + + 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" }, + } + + local x = 12 + local anyActive = false + for _, entry in ipairs(labels) do + if activity[entry.key] then + anyActive = true + if x + #entry.label + 2 > bbW then + y = y + 1 + bbClearLine(y) + x = 3 + end + bbWriteAt(x, y, entry.label, BB.activityOn) + x = x + #entry.label + 2 + end + end + + if not anyActive then + bbWriteAt(12, y, "IDLE", BB.activityOff) + end + return y + 1 +end + +-- Main billboard draw entry point +function D.drawBillboard() + if not D.billboardMon then return end + bbW, bbH = D.billboardMon.getSize() + + D.billboardMon.setBackgroundColor(BB.bg) + D.billboardMon.clear() + + local y = 1 + y = bbDrawHeader(y) + y = bbDrawStorage(y) + + -- Allocate vertical space: alerts ~3-8 lines, footer ~2 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 = 2 + local itemRows = bbH - y - alertSectionH - footerH + if itemRows < 3 then itemRows = 3 end + + y = bbDrawItems(y, itemRows) + + local alertMaxRows = bbH - y - footerH + if alertMaxRows < 2 then alertMaxRows = 2 end + y = bbDrawAlerts(y, alertMaxRows) + + -- Fill gap before footer + local footerY = bbH - 1 + while y < footerY do + bbClearLine(y) + y = y + 1 + end + bbDrawActivity(footerY) +end + return D end \ No newline at end of file diff --git a/manager/state.lua b/manager/state.lua index fd1089f..2425e2e 100644 --- a/manager/state.lua +++ b/manager/state.lua @@ -55,6 +55,7 @@ S.configDirty = true function S.bumpStateVersion() S.stateVersion = S.stateVersion + 1 + S.billboardNeedsRedraw = true end ------------------------------------------------- @@ -63,6 +64,7 @@ end S.needsRedraw = true S.smelterNeedsRedraw = true +S.billboardNeedsRedraw = true S.statusMessage = "" S.statusColor = colors.white S.statusTimer = 0 diff --git a/monitorBillboard.lua b/monitorBillboard.lua deleted file mode 100644 index 0a2ced8..0000000 --- a/monitorBillboard.lua +++ /dev/null @@ -1,618 +0,0 @@ --- 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()