feat: implement billboard monitor support and remove legacy code
This commit is contained in:
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user