Last active
July 28, 2026 13:44
-
-
Save LemonDouble/a82695621a9d9e6bf8312cff9a8ee2b5 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| -- 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 | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| -- 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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| -- 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| -- 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| -- 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 | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| -- 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