Skip to content

Instantly share code, notes, and snippets.

@LemonDouble
Last active July 28, 2026 13:44
Show Gist options
  • Select an option

  • Save LemonDouble/a82695621a9d9e6bf8312cff9a8ee2b5 to your computer and use it in GitHub Desktop.

Select an option

Save LemonDouble/a82695621a9d9e6bf8312cff9a8ee2b5 to your computer and use it in GitHub Desktop.
-- ae2craft.lua : AE2 autocrafting monitor
-- * live list of jobs currently crafting (elapsed time, stuck warning)
-- * network dashboard (power / item-fluid-chemical storage)
-- * last completed job on one line
-- * "Today" panel: per-item totals crafted today (persisted; rolls if long)
-- Needs: Advanced Peripherals ME Bridge + a Monitor attached to the computer.
-- Note: CC font is text-only (no Hangul / no icons); UI is English.
--------------------------- config ---------------------------
local TEXT_SCALE = 1.5 -- 0.5 (smallest) .. 5 (largest)
local REFRESH = 5 -- seconds between refreshes (also today-panel roll)
local WARN_SECS = 60 -- a job older than this turns orange
local STUCK_SECS = 300 -- a job older than this turns red
local DATA_FILE = "craft.dat"
local TZ_OFFSET_H = 9 -- day boundary timezone (KST = UTC+9)
--------------------------------------------------------------
-- 1.21.1+ exposes "me_bridge"; older versions used "meBridge"
local bridge = peripheral.find("me_bridge") or peripheral.find("meBridge")
local monitor = peripheral.find("monitor")
if not bridge then error("ME Bridge not found (is it attached to the computer?)", 0) end
if not monitor then error("Monitor not found", 0) end
monitor.setTextScale(TEXT_SCALE)
-- double-buffer: draw to an off-screen window, flush atomically -> no flicker
local mw, mh = monitor.getSize()
local screen = window.create(monitor, 1, 1, mw, mh)
-- clean label from a registry id (avoids wrapped displayNames): minecraft:iron_ingot -> "Iron Ingot"
local function nameLabel(id)
local base = (id or "?"):match("[^:]+$") or id
base = base:gsub("_", " ")
base = base:gsub("(%a)([%w']*)", function(a, b) return a:upper() .. b:lower() end)
if base == "" then base = id or "?" end
return base
end
local function fmtTime(sec)
sec = math.max(0, math.floor(sec))
local h = math.floor(sec / 3600)
local m = math.floor((sec % 3600) / 60)
local s = sec % 60
if h > 0 then return ("%d:%02d:%02d"):format(h, m, s) end
return ("%d:%02d"):format(m, s)
end
local function human(n)
n = tonumber(n) or 0
local u = {"", "k", "M", "G", "T", "P"}
local i = 1
while n >= 1000 and i < #u do n = n / 1000; i = i + 1 end
if i == 1 then return ("%d"):format(n) end
return ("%.1f%s"):format(n, u[i])
end
local function stat(fn)
local ok, v = pcall(fn)
if ok and type(v) == "number" then return v end
return nil
end
local function line(x, y, text, fg)
screen.setCursorPos(x, y)
screen.setTextColor(fg or colors.white)
screen.write(text)
end
-- job signature, stable across polls: registry name + total amount
local function keyOf(job)
local r = job.resource or {}
return (r.name or "?") .. "|" .. tostring(math.floor(tonumber(job.quantity) or 0))
end
-- Read crafting CPUs directly (catches every running job, incl. Quantum Computer).
local function activeJobs()
local cpus = bridge.getCraftingCPUs()
local out = {}
if type(cpus) ~= "table" then return out end
for _, c in ipairs(cpus) do
if c.isBusy and c.craftingJob then out[#out + 1] = { job = c.craftingJob } end
end
table.sort(out, function(a, b) return keyOf(a.job) < keyOf(b.job) end)
return out
end
local function storePct(usedFn, totalFn)
local used, total = stat(usedFn), stat(totalFn)
if used and total and total > 0 then return math.floor(used / total * 100 + 0.5) end
return nil
end
local function dashboard(w)
local parts = {}
local stored, usage = stat(bridge.getStoredEnergy), stat(bridge.getEnergyUsage)
if stored then
parts[#parts + 1] = "Power " .. human(stored) .. (usage and (" (-" .. human(usage) .. "/t)") or "")
end
local it = storePct(bridge.getUsedItemStorage, bridge.getTotalItemStorage)
local fl = storePct(bridge.getUsedFluidStorage, bridge.getTotalFluidStorage)
local ch = storePct(bridge.getUsedChemicalStorage, bridge.getTotalChemicalStorage)
if it then parts[#parts + 1] = ("Item %d%%"):format(it) end
if fl then parts[#parts + 1] = ("Fluid %d%%"):format(fl) end
if ch then parts[#parts + 1] = ("Chem %d%%"):format(ch) end
local text = table.concat(parts, " ")
if text == "" then text = "(no network stats)" end
line(1, 2, text:sub(1, w), colors.lightGray)
end
-- ---- state ----
local timers = {} -- key -> ascending list of start epochs (ms)
local metaCache = {} -- key -> { name, amount }
local lastDone = nil -- { name, amount, secs } most recent completion (not persisted)
-- daily tally (persisted): byItem[name] = total amount crafted today
local tally = { day = -1, jobs = 0, items = 0, byItem = {} }
local function loadTally()
if not fs.exists(DATA_FILE) then return end
local f = fs.open(DATA_FILE, "r"); if not f then return end
local d = f.readAll(); f.close()
local ok, t = pcall(textutils.unserialize, d)
if ok and type(t) == "table" then
tally.day = t.day or -1; tally.jobs = t.jobs or 0
tally.items = t.items or 0; tally.byItem = t.byItem or {}
end
end
local function saveTally()
local f = fs.open(DATA_FILE, "w"); if not f then return end
f.write(textutils.serialize(tally)); f.close()
end
loadTally()
local page = 0
while true do
local now = os.epoch("utc")
local day = math.floor((os.epoch("utc") + TZ_OFFSET_H * 3600000) / 86400000)
if tally.day ~= day then
tally.day = day; tally.jobs = 0; tally.items = 0; tally.byItem = {}; saveTally()
end
local w, h = screen.getSize()
screen.setVisible(false)
screen.setBackgroundColor(colors.black); screen.clear()
local curPages = 1
if not bridge.isOnline() then
line(1, 1, "== AE2 Autocraft ==", colors.yellow)
line(1, 3, "ME Bridge is NOT online.", colors.red)
line(1, 4, "Connect it to your ME network", colors.gray)
timers = {}
else
local jobs = activeJobs()
-- counts + meta
local counts = {}
for _, e in ipairs(jobs) do
local k = keyOf(e.job)
counts[k] = (counts[k] or 0) + 1
metaCache[k] = { name = (e.job.resource or {}).name or "?", amount = math.floor(tonumber(e.job.quantity) or 0) }
end
-- completions -> last done + today tally (per item)
local completed = false
for k, list in pairs(timers) do
local newCnt = counts[k] or 0
while #list > newCnt do
local start = table.remove(list)
local m = metaCache[k] or { name = "?", amount = 0 }
lastDone = { name = m.name, amount = m.amount, secs = (now - start) / 1000 }
tally.jobs = tally.jobs + 1
tally.items = tally.items + (m.amount or 0)
tally.byItem[m.name] = (tally.byItem[m.name] or 0) + (m.amount or 0)
completed = true
end
if #list == 0 then timers[k] = nil end
end
if completed then saveTally() end
-- start timers for new instances; drop gone meta
for k, cnt in pairs(counts) do
local list = timers[k] or {}
while #list < cnt do list[#list + 1] = now end
timers[k] = list
end
for k in pairs(metaCache) do if not counts[k] then metaCache[k] = nil end end
-- ---- draw ----
line(1, 1, ("== AE2 Autocraft == (%d crafting)"):format(#jobs), colors.yellow)
dashboard(w)
if lastDone then
line(1, 3, (("Last: %dx %s %s"):format(lastDone.amount, nameLabel(lastDone.name), fmtTime(lastDone.secs))):sub(1, w), colors.gray)
else
line(1, 3, "Last: -", colors.gray)
end
-- split the area below: ACTIVE on top (up to half), TODAY panel fills the rest
local top = 4
local avail = h - top + 1
local activeMax = math.max(1, math.floor(avail / 2))
-- ACTIVE jobs
local arow = top
if #jobs == 0 then
line(1, arow, "No active crafting jobs.", colors.gray); arow = arow + 1
else
local aLimit = (#jobs > activeMax) and (activeMax - 1) or math.min(#jobs, activeMax)
local cursor, drawn = {}, 0
for _, e in ipairs(jobs) do
if drawn >= aLimit then break end
local k = keyOf(e.job)
cursor[k] = (cursor[k] or 0) + 1
local start = (timers[k] or {})[cursor[k]] or now
local elapsed = (now - start) / 1000
local amount = math.floor(tonumber(e.job.quantity) or 0)
local text = ("%s x%d %s"):format(nameLabel((e.job.resource or {}).name), amount, fmtTime(elapsed))
local col = colors.white
if elapsed >= STUCK_SECS then col = colors.red
elseif elapsed >= WARN_SECS then col = colors.orange end
line(1, arow, text:sub(1, w), col)
arow, drawn = arow + 1, drawn + 1
end
if #jobs > drawn then
line(1, arow, ("... (+%d more)"):format(#jobs - drawn), colors.gray); arow = arow + 1
end
end
-- TODAY panel (per item), rolling
local dRow = arow
local items = {}
for name, cnt in pairs(tally.byItem) do items[#items + 1] = { name = name, cnt = cnt } end
table.sort(items, function(a, b) return a.cnt > b.cnt end)
local todayRows = math.max(0, h - dRow) -- rows dRow+1 .. h
local pages = (todayRows > 0) and math.max(1, math.ceil(#items / todayRows)) or 1
curPages = pages
local pg = page % pages
local tag = (pages > 1) and (" [%d/%d]"):format(pg + 1, pages) or ""
line(1, dRow, (("-- Today: %d jobs / %s items --%s"):format(tally.jobs, human(tally.items), tag)):sub(1, w), colors.cyan)
if #items == 0 then
if todayRows >= 1 then line(1, dRow + 1, "Nothing crafted yet today.", colors.gray) end
else
local startIdx = pg * todayRows
for i = 1, todayRows do
local e = items[startIdx + i]
if not e then break end
line(1, dRow + i, (("%-18s x%s"):format(nameLabel(e.name):sub(1, 18), human(e.cnt))):sub(1, w), colors.lime)
end
end
end
page = (page + 1) % math.max(1, curPages)
screen.setVisible(true)
sleep(REFRESH)
end
-- essence_monitor.lua : essence production tracker (buffer-drain method)
-- Production is measured by DRAINING a buffer container into the ME system:
-- farm -> buffer chest -> this program imports it into ME and counts exactly
-- what moved (importItem returns the moved amount). Auto-craft consumption in
-- ME never touches the buffer, so the production numbers are exact.
-- Stock (per essence) is read live from the ME system.
-- State (today/yesterday/rate) is saved to a file (survives reboot). Day = KST.
-- Setup: ME Bridge + Monitor on the computer; buffer container on the computer's
-- wired-modem network; farm output goes into the buffer (NOT into ME).
-- Note: CC font is text-only (no Hangul / no icons); UI is English.
--------------------------- config ---------------------------
local TEXT_SCALE = 1.5
local DRAIN_SECS = 0.05 -- how often to drain (0.05 = every tick; buffer only ever holds ~1 tick of output)
local UI_SECS = 3 -- how often to refresh the screen / roll pages
local NAME_W = 13
local RATE_WIN_M = 60 -- minutes for the /hr rolling rate
local TZ_OFFSET_H = 9 -- day boundary timezone (KST = UTC+9)
local DATA_FILE = "essence.dat"
--------------------------------------------------------------
local bridge = peripheral.find("me_bridge") or peripheral.find("meBridge")
local monitor = peripheral.find("monitor")
if not bridge then error("ME Bridge not found (attach it to the computer)", 0) end
if not monitor then error("Monitor not found", 0) end
-- find the buffer: the first inventory peripheral on the network (not bridge/monitor)
local buffer, bufferName
for _, n in ipairs(peripheral.getNames()) do
if peripheral.hasType(n, "inventory") then buffer = peripheral.wrap(n); bufferName = n; break end
end
if not buffer then error("No buffer container found (connect it via a wired modem)", 0) end
monitor.setTextScale(TEXT_SCALE)
local mw, mh = monitor.getSize()
local screen = window.create(monitor, 1, 1, mw, mh)
local function human(n)
n = tonumber(n) or 0
local u = {"", "k", "M", "G", "T"}
local i = 1
while n >= 1000 and i < #u do n = n / 1000; i = i + 1 end
if i == 1 then return ("%d"):format(n) end
return ("%.1f%s"):format(n, u[i])
end
local function humanDur(ms)
local sec = ms / 1000
if sec < 3600 then return ("%dm"):format(math.max(1, math.floor(sec / 60))) end
local hr = sec / 3600
if hr < 48 then return ("%.1fh"):format(hr) end
return ("%.1fd"):format(hr / 24)
end
local function stat(fn)
local ok, v = pcall(fn)
if ok and type(v) == "number" then return v end
return nil
end
local function essLabel(name)
local base = (name or "?"):match("[^:]+$") or name
base = base:gsub("_essence$", "")
base = base:gsub("_", " ")
base = base:gsub("(%a)([%w']*)", function(a, b) return a:upper() .. b:lower() end)
if base == "" then base = name or "?" end
return base
end
local function line(x, y, text, fg)
screen.setCursorPos(x, y)
screen.setTextColor(fg or colors.white)
screen.write(text)
end
-- Pace vs yesterday: project today to a full day, then compare. up=red, down=blue.
local function pctInfo(today, yest, frac)
if yest == 0 then
if today > 0 then return "NEW", colors.red end
return "", colors.lightGray
end
if not frac or frac < 0.1 then return "~?", colors.gray end
local p = (today / frac - yest) / yest * 100
local col = (p > 0 and colors.red) or (p < 0 and colors.blue) or colors.lightGray
return ("~%+d%%"):format(math.floor(p + 0.5)), col
end
local function stockOf(id)
local ok, it = pcall(bridge.getItem, { name = id })
if ok and type(it) == "table" then return it.count or 0 end
return 0
end
-- ---- persistent state ----
-- rate[name] = list of { m = minuteIndex, d = produced } buckets (last RATE_WIN_M min)
-- used[] = list of { t, u } used-item-storage samples for the "storage full" ETA
local state = { day = -1, today = {}, yest = {}, rate = {}, used = {}, usedMin = -1 }
local function loadState()
if not fs.exists(DATA_FILE) then return end
local f = fs.open(DATA_FILE, "r"); if not f then return end
local d = f.readAll(); f.close()
local ok, t = pcall(textutils.unserialize, d)
if ok and type(t) == "table" then
state.day = t.day or -1; state.today = t.today or {}; state.yest = t.yest or {}
state.rate = t.rate or {}; state.used = t.used or {}; state.usedMin = t.usedMin or -1
end
end
local function saveState()
local f = fs.open(DATA_FILE, "w"); if not f then return end
f.write(textutils.serialize(state)); f.close()
end
-- production in the last hour (== per-hour rate), from the minute buckets
local function rateOf(name)
local b = state.rate[name]
if not b then return 0 end
local cutoff = math.floor(os.epoch("utc") / 60000) - RATE_WIN_M
local s = 0
for _, e in ipairs(b) do if e.m > cutoff then s = s + e.d end end
return s
end
local freeCache = nil -- available item storage, refreshed once/min by sampleStorage
-- ETA until item storage is full, from the net fill rate over the window
local function etaText()
local free = freeCache
local u = state.used
if not free or #u < 2 then return nil end
local a, b = u[1], u[#u]
local dt = b.t - a.t
if dt <= 0 then return nil end
local rate = (b.u - a.u) / dt
if rate <= 0 then return "Storage: stable" end
return "Storage full ~" .. humanDur(free / rate)
end
-- drain the buffer into ME, counting exactly what moved as production
local function drain()
local ok, list = pcall(buffer.list)
if not ok or type(list) ~= "table" then return end
local types = {}
for _, it in pairs(list) do if it.name then types[it.name] = true end end
local m = math.floor(os.epoch("utc") / 60000)
for name in pairs(types) do
local ok2, moved = pcall(bridge.importItem, { name = name, count = 1000000 }, bufferName)
if ok2 and type(moved) == "number" and moved > 0 then
state.today[name] = (state.today[name] or 0) + moved
local b = state.rate[name] or {}
if #b > 0 and b[#b].m == m then b[#b].d = b[#b].d + moved
else b[#b + 1] = { m = m, d = moved } end
while #b > 0 and b[1].m < m - RATE_WIN_M do table.remove(b, 1) end
state.rate[name] = b
end
end
end
-- sample used item storage once a minute (for the storage-full ETA)
local function sampleStorage()
local m = math.floor(os.epoch("utc") / 60000)
if state.usedMin == m then return end
state.usedMin = m
freeCache = stat(bridge.getAvailableItemStorage)
local used = stat(bridge.getUsedItemStorage)
if not used then return end
local now = os.epoch("utc")
local u = state.used
u[#u + 1] = { t = now, u = used }
local cutoff = now - RATE_WIN_M * 60000
while #u > 0 and u[1].t < cutoff do table.remove(u, 1) end
end
local stockCache = {}
local function readStock()
local s = {}
local ok, all = pcall(bridge.getItems, {}) -- ONE network scan instead of one getItem per essence
if ok and type(all) == "table" then
local byName = {}
for _, it in ipairs(all) do
if it.name then byName[it.name] = (byName[it.name] or 0) + (it.count or 0) end
end
for name, t in pairs(state.today) do if t > 0 then s[name] = byName[name] or 0 end end
end
stockCache = s
end
local function draw(online, page)
local w, h = screen.getSize()
screen.setBackgroundColor(colors.black); screen.clear()
if not online then
line(1, 1, "== Essence Production ==", colors.yellow)
line(1, 3, "ME Bridge is NOT online.", colors.red)
return 1
end
local dayFrac = ((os.epoch("utc") + TZ_OFFSET_H * 3600000) % 86400000) / 86400000
local totT, totY, totR, rows = 0, 0, 0, {}
for _, y in pairs(state.yest) do totY = totY + y end
for name, prod in pairs(state.today) do
totT = totT + prod
if prod > 0 then
local r = rateOf(name)
totR = totR + r
rows[#rows + 1] = { name = name, today = prod, yest = state.yest[name] or 0,
rate = r, stock = stockCache[name] or 0 }
end
end
table.sort(rows, function(a, b) return a.today > b.today end)
local top = 4
local gridH = math.max(1, h - top)
local pages = math.max(1, math.ceil(#rows / gridH))
local pg = page % pages
local tag = (pages > 1) and (" [%d/%d]"):format(pg + 1, pages) or ""
line(1, 1, "== Essence Production ==" .. tag, colors.yellow)
local tText = ("Total %s / %s %s/hr "):format(human(totT), human(totY), human(totR))
line(1, 2, tText, colors.white)
local pt, pc = pctInfo(totT, totY, dayFrac)
local x2 = #tText + 1
if x2 <= w then line(x2, 2, pt, pc) end
local eta = etaText()
if eta then line(x2 + #pt + 2, 2, eta, colors.lightGray) end
local head = ("%-" .. NAME_W .. "s %7s %5s/%-5s %6s "):format("Name", "Stock", "Today", "Yest", "Rate")
line(1, 3, head, colors.lightBlue)
line(1 + #head, 3, "Pace", colors.lightBlue)
if #rows == 0 then
line(1, top, "No production yet today.", colors.gray)
return 1
end
local startIdx = pg * gridH
for slot = 0, gridH - 1 do
local e = rows[startIdx + slot + 1]
if not e then break end
local y = top + slot
local base = ("%-" .. NAME_W .. "s %7s %5s/%-5s %6s/hr "):format(
essLabel(e.name):sub(1, NAME_W), human(e.stock), human(e.today), human(e.yest), human(e.rate))
line(1, y, base, colors.white)
local ptext, pcol = pctInfo(e.today, e.yest, dayFrac)
line(1 + #base, y, ptext, pcol)
end
return pages
end
-- ---- main ----
-- Two loops in parallel: drain the buffer fast (keeps up with the farm) while the
-- screen refreshes on its own slower cadence.
loadState()
readStock()
local function drainLoop()
while true do
if bridge.isOnline() then drain() end
sleep(DRAIN_SECS)
end
end
local function uiLoop()
local page = 0
while true do
local online = bridge.isOnline()
if online then
local kd = math.floor((os.epoch("utc") + TZ_OFFSET_H * 3600000) / 86400000)
if state.day ~= kd then
state.day = kd; state.yest = state.today; state.today = {}
end
sampleStorage()
readStock()
saveState()
end
screen.setVisible(false)
local pages = draw(online, page)
screen.setVisible(true)
page = (pages > 1) and ((page + 1) % pages) or 0
sleep(UI_SECS)
end
end
parallel.waitForAll(drainLoop, uiLoop)
-- essence_monitor_startup.lua : bootstrap for the essence production monitor.
-- On boot: pull the latest essence_monitor.lua from the gist, then run it.
-- A changing cache-busting query defeats GitHub's CDN caching the "latest" raw URL.
-- Save this on the dashboard computer AS "startup".
local BASE = "https://gist.githubusercontent.com/LemonDouble/a82695621a9d9e6bf8312cff9a8ee2b5/raw/essence_monitor.lua"
local function fetch(url)
local ok, res = pcall(http.get, url)
if ok and res then
local data = res.readAll()
res.close()
if data and #data > 0 then return data end
end
return nil
end
if http then
local data = fetch(BASE .. "?cb=" .. os.epoch("utc")) or fetch(BASE)
if data then
local f = fs.open("essence_monitor", "w")
f.write(data)
f.close()
end
end
if fs.exists("essence_monitor") then
shell.run("essence_monitor")
else
print("essence_monitor not found and could not be downloaded.")
end
-- startup.lua : auto-run the AE2 autocraft monitor on boot.
-- Pulls the latest ae2craft.lua from the gist (cache-busted so GitHub's CDN can't
-- serve a stale copy), then runs it. If offline, runs the existing local copy.
local BASE = "https://gist.githubusercontent.com/LemonDouble/a82695621a9d9e6bf8312cff9a8ee2b5/raw/ae2craft.lua"
local function fetch(url)
local ok, res = pcall(http.get, url)
if ok and res then
local data = res.readAll()
res.close()
if data and #data > 0 then return data end
end
return nil
end
if http then
local data = fetch(BASE .. "?cb=" .. os.epoch("utc")) or fetch(BASE)
if data then
local f = fs.open("ae2craft", "w")
f.write(data)
f.close()
end
end
if fs.exists("ae2craft") then
shell.run("ae2craft")
else
print("ae2craft not found and could not be downloaded.")
end
-- stock_monitor.lua : low-stock watchlist dashboard (multi-user, no memory card)
-- The config chest defines everything, all in-game:
-- * WHICH items to watch = the item types you put in the chest
-- * the ALERT LEVEL = (items you put in) x ALERT_PER_ITEM
-- (e.g. 2 iron ingots x 1000 -> alert when ME stock of iron drops below 2000)
-- For each low item it also shows the net change rate and an ETA:
-- rising -> time to reach the alert level (recovering)
-- falling -> time to hit zero (draining)
-- Rate/ETA use rolling stock samples saved to a file (survive reboot).
-- Needs: ME Bridge + a Monitor + a Chest/Drawer (config), attached to the computer.
-- Note: CC font is text-only (no Hangul / no icons); UI is English.
--------------------------- config ---------------------------
local TEXT_SCALE = 1.5
local REFRESH = 30 -- seconds between polls
local ALERT_PER_ITEM = 1000 -- each item in the config chest = this much alert level
local RATE_WIN_MS = 300000 -- rate/ETA use the last 5 minutes of stock change
local DATA_FILE = "stock.dat"
--------------------------------------------------------------
local bridge = peripheral.find("me_bridge") or peripheral.find("meBridge")
local monitor = peripheral.find("monitor")
local chest = peripheral.find("inventory") -- the config chest (any container)
if not bridge then error("ME Bridge not found (attach it to the computer)", 0) end
if not monitor then error("Monitor not found", 0) end
monitor.setTextScale(TEXT_SCALE)
local mw, mh = monitor.getSize()
local screen = window.create(monitor, 1, 1, mw, mh)
local function human(n)
n = tonumber(n) or 0
local u = {"", "k", "M", "G", "T"}
local i = 1
while n >= 1000 and i < #u do n = n / 1000; i = i + 1 end
if i == 1 then return ("%d"):format(n) end
return ("%.1f%s"):format(n, u[i])
end
local function signHuman(n) -- +1.2k / -240
return (n >= 0 and "+" or "-") .. human(math.abs(n))
end
local function humanDur(ms)
local sec = ms / 1000
if sec < 3600 then return ("%dm"):format(math.max(1, math.floor(sec / 60))) end
local hr = sec / 3600
if hr < 48 then return ("%.1fh"):format(hr) end
return ("%.1fd"):format(hr / 24)
end
-- clean label from a registry id: allthemodium:vibranium_ingot -> "Vibranium Ingot"
local function label(id)
local base = (id or "?"):match("[^:]+$") or id
base = base:gsub("_", " ")
base = base:gsub("(%a)([%w']*)", function(a, b) return a:upper() .. b:lower() end)
if base == "" then base = id or "?" end
return base
end
local function line(x, y, text, fg)
screen.setCursorPos(x, y)
screen.setTextColor(fg or colors.white)
screen.write(text)
end
local function stockOf(id)
local ok, it = pcall(bridge.getItem, { name = id })
if ok and type(it) == "table" then return it.count or 0 end
return 0
end
-- ---- persistent rolling stock samples ----
local state = { samples = {} } -- samples[id] = { {t=ms, s=count}, ... }
local function loadState()
if not fs.exists(DATA_FILE) then return end
local f = fs.open(DATA_FILE, "r"); if not f then return end
local d = f.readAll(); f.close()
local ok, t = pcall(textutils.unserialize, d)
if ok and type(t) == "table" and type(t.samples) == "table" then state.samples = t.samples end
end
local function saveState()
local f = fs.open(DATA_FILE, "w"); if not f then return end
f.write(textutils.serialize(state)); f.close()
end
-- net rate (items per ms) over the window, or nil if not enough data
local function rateOf(id)
local sm = state.samples[id]
if not sm or #sm < 2 then return nil end
local a, b = sm[1], sm[#sm]
local dt = b.t - a.t
if dt <= 0 then return nil end
return (b.s - a.s) / dt
end
-- caches (filled by poll, read by draw). wl[id] = alert level
local wl, counts = {}, {}
local function poll()
local now = os.epoch("utc")
-- watchlist + thresholds from the config chest
local w = {}
if chest then
local ok, list = pcall(chest.list)
if ok and type(list) == "table" then
for _, it in pairs(list) do
if it.name then w[it.name] = (w[it.name] or 0) + (it.count or 0) * ALERT_PER_ITEM end
end
end
end
wl = w
-- one ME scan, then look up each watched item in Lua (vs one getItem per item)
local byName = {}
local okAll, all = pcall(bridge.getItems, {})
if okAll and type(all) == "table" then
for _, it in ipairs(all) do
if it.name then byName[it.name] = (byName[it.name] or 0) + (it.count or 0) end
end
end
-- current stocks + rolling samples
local snap = {}
local cutoff = now - RATE_WIN_MS
for id in pairs(wl) do
local s = byName[id] or 0
snap[id] = s
local sm = state.samples[id] or {}
sm[#sm + 1] = { t = now, s = s }
while #sm > 0 and sm[1].t < cutoff do table.remove(sm, 1) end
state.samples[id] = sm
end
counts = snap
for id in pairs(state.samples) do if not wl[id] then state.samples[id] = nil end end
saveState()
end
local function draw()
local w, h = screen.getSize()
screen.setBackgroundColor(colors.black); screen.clear()
local total, low = 0, {}
for id, th in pairs(wl) do
total = total + 1
local c = counts[id] or 0
if c < th then low[#low + 1] = { id = id, c = c, th = th } end
end
table.sort(low, function(a, b) return (a.c / a.th) < (b.c / b.th) end)
line(1, 1, ("== Stock Watch == watching %d (%d low)"):format(total, #low), colors.yellow)
if not chest then
line(1, 2, "No config chest attached.", colors.red)
line(1, 3, "Attach a chest to the computer.", colors.gray)
else
line(1, 2, "Config chest: drop items to watch.", colors.lightBlue)
line(1, 3, ("Each item = %d alert level."):format(ALERT_PER_ITEM), colors.lightBlue)
end
local top = 4
if not bridge.isOnline() then
line(1, top, "ME Bridge is NOT online.", colors.red)
elseif total == 0 then
line(1, top, "Config chest is empty - nothing watched.", colors.gray)
elseif #low == 0 then
line(1, top, "All watched items are stocked.", colors.lime)
else
local row = top
for i, e in ipairs(low) do
if row > h then
line(1, h, ("... (+%d more)"):format(#low - (i - 1)), colors.gray)
break
end
local base = ("%-15s %6s/%-6s "):format(label(e.id):sub(1, 15), human(e.c), human(e.th))
line(1, row, base, colors.white)
local rate = rateOf(e.id) -- items per ms
if rate then
local perHour = rate * 3600000
local rcol = (perHour > 0 and colors.lime) or (perHour < 0 and colors.red) or colors.gray
local rtxt = signHuman(perHour) .. "/h"
local etxt, ecol
if perHour > 0 then
etxt = "^" .. humanDur((e.th - e.c) / rate); ecol = colors.lime
elseif perHour < 0 then
etxt = "v" .. humanDur(e.c / -rate); ecol = colors.red
else
etxt, ecol = "flat", colors.gray
end
local x = 1 + #base
line(x, row, rtxt, rcol)
line(x + #rtxt + 1, row, etxt, ecol)
end
row = row + 1
end
end
end
-- ---- main ----
loadState()
while true do
if bridge.isOnline() then poll() end
screen.setVisible(false)
draw()
screen.setVisible(true)
sleep(REFRESH)
end
-- stock_monitor_startup.lua : bootstrap for the low-stock watchlist dashboard.
-- On boot: pull the latest stock_monitor.lua from the gist (cache-busted), run it.
-- Save this on the dashboard computer AS "startup".
local BASE = "https://gist.githubusercontent.com/LemonDouble/a82695621a9d9e6bf8312cff9a8ee2b5/raw/stock_monitor.lua"
local function fetch(url)
local ok, res = pcall(http.get, url)
if ok and res then
local data = res.readAll()
res.close()
if data and #data > 0 then return data end
end
return nil
end
if http then
local data = fetch(BASE .. "?cb=" .. os.epoch("utc")) or fetch(BASE)
if data then
local f = fs.open("stock_monitor", "w")
f.write(data)
f.close()
end
end
if fs.exists("stock_monitor") then
shell.run("stock_monitor")
else
print("stock_monitor not found and could not be downloaded.")
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment