feat: add monitor billboard for displaying inventory goals and alerts

This commit is contained in:
MayaTheShy
2026-03-26 14:08:14 -04:00
parent 97983156c6
commit c9d21bcfaa

618
monitorBillboard.lua Normal file
View File

@@ -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()