feat: implement billboard monitor support and remove legacy code

This commit is contained in:
MayaTheShy
2026-03-26 14:16:23 -04:00
parent c1b1713699
commit 8a50bc586d
6 changed files with 378 additions and 627 deletions

View File

@@ -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
@@ -74,7 +72,6 @@ local programs = {
bridge = 'inventoryWebBridge.lua',
turtle = 'craftingTurtle.lua',
miner = 'miningTurtle.lua',
billboard = 'monitorBillboard.lua',
}
local program = fs.combine(BASE, programs[role])

View File

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

View File

@@ -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
@@ -118,6 +120,8 @@ function C.loadConfig()
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.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

View File

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

View File

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

View File

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