-- -- coordinator.lua -- -- The facility control computer: aggregates every RTU over rednet, is the single -- source of truth, sends commands, and drives two GUIs from one mimic event loop: -- * terminal - status-computer overview: every unit, online/offline, health -- * monitor - chambers dashboard: per-chamber table + bulk controls -- -- Pings known units so their local screens read "COORD OK", and runs a link -- watchdog that ages silent units to OFFLINE. -- -- coordinator -- -- Needs mimic + facility installed, and a /config.lua with unit_type="coordinator", -- modem, and optionally chambers_monitor / link_timeout / ping_interval. -- require("/initenv").init_env() local mimic = require("mimic") local m = require("mimic.elements") local psil = require("scada-common.psil") local tcd = require("scada-common.tcd") local facility = require("facility") local style = mimic.style local cpair = mimic.cpair local ALIGN = mimic.ALIGN -- -- config + comms + registry -- local cfg = facility.load_config() facility.open(cfg.modem) local PING_INTERVAL = cfg.ping_interval or 3 local reg = facility.registry(cfg.link_timeout or 15) local ps = psil.create() -- shared bits of coordinator state local last_msg = "" -- first sim_chamber unit we know about (the pilot has one) local function chamberUnit() return reg.list("sim_chamber")[1] end local function sendChamberCmd(cmd, args) local u = chamberUnit() if not u then last_msg = "no chamber RTU online"; return end facility.send_command(u.rednet_id, cmd, args) last_msg = "sent: " .. cmd end -- per-chamber model-picker state (coordinator-driven, on the monitor) local selected = 1 local board_picker_open = false local openBoardPicker -- forward-declared; built in the board block local function chamberCount() local cu = chamberUnit() return (cu and cu.data and cu.data.summary and cu.data.summary.total) or 0 end local function clampSelected() local n = chamberCount() if selected < 1 then selected = 1 end if n > 0 and selected > n then selected = n end end -- any-picker-open counter (across every board); gates the shared clock so it -- doesn't repaint a board's top bar through an open overlay local pickers_open = 0 -- per-data-center monitor boards, built from cfg.dc_monitors local dc_boards = {} local function sendDcCmd(uid, cmd, args) local u = reg.get(uid) if not u or not u.online then last_msg = "DC " .. uid .. " offline"; return end facility.send_command(u.rednet_id, cmd, args) last_msg = "DC " .. uid .. ": " .. cmd end -- -- display 1: terminal - status overview of every unit -- local panel = mimic.init{ps=ps} local PW = panel.get_width() m.TextBox{parent=panel, x=1, y=1, width=PW-9, text="FACILITY STATUS", alignment=ALIGN.CENTER, fg_bg=style.theme.header} m.TextBox{parent=panel, x=PW-8, y=1, width=8, text="", alignment=ALIGN.RIGHT, fg_bg=style.theme.header, bind="clock"} m.DataIndicator{parent=panel, x=2, y=2, label="Units online:", unit="", format="%3.0f", value=0, width=24, fg_bg=style.text_colors, lu_colors=style.lu_colors, bind="units_online"} local over_tbl = mimic.Table{parent=panel, x=2, y=4, width=PW-2, height=panel.get_height()-5, columns={ { name="ID", width=3 }, { name="TYPE", width=13 }, { name="LINK", width=8 }, { name="INFO", width=20 }, }} m.TextBox{parent=panel, x=2, y=panel.get_height(), width=PW-2, text="", fg_bg=style.label, bind="msg"} -- -- display 2: monitor - chambers dashboard (optional; only if a monitor is found) -- -- Stop two boards from landing on the same monitor. The big one: when no -- chambers_monitor is set, auto-detect must NOT grab a monitor already assigned -- to a DC/ME board - otherwise both displays sit on it, display_for_monitor -- returns the first, and touches route to the wrong (invisible) board. local reserved_monitors = {} if cfg.me_monitor then reserved_monitors[cfg.me_monitor] = true end for _, mon in pairs(cfg.dc_monitors or {}) do reserved_monitors[mon] = true end local claimed_monitors = {} local function claim_monitor(name) if not name or claimed_monitors[name] then return false end claimed_monitors[name] = true return true end local function findMonitor() if cfg.chambers_monitor then return (not reserved_monitors[cfg.chambers_monitor]) and cfg.chambers_monitor or nil end for _, name in ipairs(peripheral.getNames()) do if peripheral.getType(name) == "monitor" and not reserved_monitors[name] then return name end end end local board, chamber_tbl local mon = findMonitor() if mon and peripheral.isPresent(mon) and peripheral.getType(mon) == "monitor" and claim_monitor(mon) then board = mimic.add_display{monitor=mon, ps=ps} local BW, BH = board.get_width(), board.get_height() if BW < 40 or BH < 18 then m.TextBox{parent=board, x=2, y=2, width=BW-2, height=5, fg_bg=cpair(colors.red, style.theme.bg), text="Chambers monitor is " .. BW .. "x" .. BH .. ".\nNeeds at least 40x18."} board = nil end end if board then local BW = board.get_width() m.TextBox{parent=board, x=1, y=1, width=BW-9, text="SIM CHAMBERS", alignment=ALIGN.CENTER, fg_bg=style.theme.header} m.TextBox{parent=board, x=BW-8, y=1, width=8, text="", alignment=ALIGN.RIGHT, fg_bg=style.theme.header, bind="clock"} m.StateIndicator{parent=board, x=2, y=3, min_width=14, value=2, states={ { color=cpair(colors.black, colors.green), text="RTU ONLINE" }, { color=cpair(colors.white, colors.red), text="RTU OFFLINE" }, }, bind="c_link"} -- summary in two columns so it fits a modest (40-wide) monitor local function di(x, y, label, key) m.DataIndicator{parent=board, x=x, y=y, label=label, format="%4.0f", value=0, width=18, fg_bg=style.text_colors, lu_colors=style.lu_colors, bind=key} end di(2, 4, "Chambers:", "c_total") di(2, 5, "Ready:", "c_ready") di(2, 6, "Low:", "c_low") di(22, 4, "Models:", "cb_models") di(22, 5, "Catalyst:", "cb_catalyst") di(22, 6, "No model:", "c_nomodel") -- bulk controls: fire commands at the chamber RTU m.PushButton{parent=board, x=2, y=8, text="LOAD ALL EMPTY", min_width=18, fg_bg=style.wh_gray, active_fg_bg=style.ind_yel, callback=function () sendChamberCmd("load_all_empty") end} m.PushButton{parent=board, x=22, y=8, text="STOCK ALL LOW", min_width=18, fg_bg=style.wh_gray, active_fg_bg=style.ind_yel, callback=function () sendChamberCmd("stock_all_low") end} -- per-chamber selector + open the model picker for it m.PushButton{parent=board, x=2, y=9, text="<", min_width=3, fg_bg=style.wh_gray, active_fg_bg=style.wh_gray, callback=function () selected = selected - 1; clampSelected() end} m.TextBox{parent=board, x=6, y=9, width=7, text="CH 01", fg_bg=style.label, bind="sel_label"} m.PushButton{parent=board, x=13, y=9, text=">", min_width=3, fg_bg=style.wh_gray, active_fg_bg=style.wh_gray, callback=function () selected = selected + 1; clampSelected() end} m.PushButton{parent=board, x=17, y=9, text="LOAD MODEL", min_width=13, fg_bg=style.wh_gray, active_fg_bg=style.ind_grn, callback=function () openBoardPicker() end} local TB_Y = 11 chamber_tbl = mimic.Table{parent=board, x=2, y=TB_Y, width=BW-2, height=board.get_height()-TB_Y, columns={ { name="ID", width=3 }, { name="STATE", width=11 }, { name="CATALYST", width=9, align=ALIGN.RIGHT }, { name="ENERGY", width=10, align=ALIGN.RIGHT }, }} -- -- model picker overlay (topmost on the board; opened by LOAD MODEL). Reads -- the model list straight from the chamber RTU's last status broadcast, and -- LOAD sends a load_model command targeting the selected chamber. -- local BH = board.get_height() local bp_body local board_picker_div = m.Div{parent=board, x=1, y=1, width=BW, height=BH, hidden=true, fg_bg=style.theme.highlight_box} m.TextBox{parent=board_picker_div, x=2, y=1, width=BW-11, text="LOAD MODEL", fg_bg=style.theme.header, bind="bp_title"} local function closeBoardPicker() board_picker_div.hide(true) board_picker_open = false pickers_open = pickers_open - 1 end m.PushButton{parent=board_picker_div, x=BW-8, y=1, text="BACK", min_width=8, fg_bg=style.wh_gray, active_fg_bg=style.ind_yel, callback=closeBoardPicker} local function buildBoardModelList() if bp_body then bp_body.delete(); bp_body = nil end local cu = chamberUnit() local models = (cu and cu.data and cu.data.models) or {} if #models == 0 then bp_body = m.TextBox{parent=board_picker_div, x=2, y=3, width=BW-2, height=3, text="No spare models (or chamber RTU offline).", fg_bg=style.label} return end local pgn = mimic.Paginator{parent=board_picker_div, x=1, y=3, width=BW, height=BH-2, items=models, per_page=8, cols=1, accent=colors.cyan, render=function (slot, model, index) local sw = slot.get_width() m.PushButton{parent=slot, x=1, y=1, text="LOAD", min_width=6, fg_bg=style.wh_gray, active_fg_bg=style.ind_grn, callback=function () sendChamberCmd("load_model", { chamber = selected, model = model.disp }) closeBoardPicker() end} m.TextBox{parent=slot, x=9, y=1, width=sw-18, text=model.disp, fg_bg=style.text_colors} m.DataIndicator{parent=slot, x=sw-8, y=1, label="x", format="%3.0f", value=model.count, width=8, fg_bg=style.text_colors, lu_colors=style.lu_colors} end} bp_body = pgn.element end openBoardPicker = function () clampSelected() buildBoardModelList() ps.publish("bp_title", string.format("LOAD -> Chamber %02d", selected)) board_picker_open = true pickers_open = pickers_open + 1 board_picker_div.show() end end -- -- display 3: monitor - ME network dashboard (optional; needs cfg.me_monitor) -- local function meUnit() return reg.list("me_network")[1] end local me_board if cfg.me_monitor and peripheral.isPresent(cfg.me_monitor) and peripheral.getType(cfg.me_monitor) == "monitor" and claim_monitor(cfg.me_monitor) then me_board = mimic.add_display{monitor=cfg.me_monitor, ps=ps} local MW, MH = me_board.get_width(), me_board.get_height() if MW < 30 or MH < 12 then m.TextBox{parent=me_board, x=2, y=2, width=MW-2, height=4, fg_bg=cpair(colors.red, style.theme.bg), text="ME monitor is " .. MW .. "x" .. MH .. ".\nNeeds at least 30x12."} me_board = nil end end if me_board then local MW = me_board.get_width() m.TextBox{parent=me_board, x=1, y=1, width=MW-9, text="ME NETWORK", alignment=ALIGN.CENTER, fg_bg=style.theme.header} m.TextBox{parent=me_board, x=MW-8, y=1, width=8, text="", alignment=ALIGN.RIGHT, fg_bg=style.theme.header, bind="clock"} m.StateIndicator{parent=me_board, x=2, y=3, min_width=14, value=2, states={ { color=cpair(colors.black, colors.green), text="RTU ONLINE" }, { color=cpair(colors.white, colors.red), text="RTU OFFLINE" }, }, bind="me_link"} mimic.Gauge{parent=me_board, x=2, y=5, width=MW-4, label="POWER ", bind="me_epwr", color=cpair(colors.green, style.ind_bkg)} mimic.Gauge{parent=me_board, x=2, y=6, width=MW-4, label="CELLS ", bind="me_ecell", color=cpair(colors.cyan, style.ind_bkg)} local function mdi(y, label, unit, key) m.DataIndicator{parent=me_board, x=2, y=y, label=label, unit=unit, format="%9.0f", value=0, width=MW-2, fg_bg=style.text_colors, lu_colors=style.lu_colors, bind=key} end mdi(8, "AE stored:", "AE", "me_stored") mdi(9, "Power draw:", "AE/t", "me_usage") mdi(10, "Cells used:", "", "me_iused") mdi(11, "Cells free:", "", "me_avail") mdi(12, "CPUs busy:", "", "me_cpubusy") end -- -- displays 4..N: one 4x4 monitor per data center (control-room wall). Each is a -- self-contained dashboard for one DC unit_id, with its own load-model picker. -- Configured via cfg.dc_monitors = { [unit_id] = "monitor_name", ... }. -- local function buildDcBoard(uid, monitorName) if not (peripheral.isPresent(monitorName) and peripheral.getType(monitorName) == "monitor") then return end if not claim_monitor(monitorName) then return end -- monitor already used by another board -- 0.5 text scale so a 4x4 monitor gives a roomy character grid pcall(function () peripheral.wrap(monitorName).setTextScale(0.5) end) local board = mimic.add_display{monitor=monitorName, ps=ps} local BW, BH = board.get_width(), board.get_height() if BW < 40 or BH < 16 then m.TextBox{parent=board, x=2, y=2, width=BW-2, height=4, fg_bg=cpair(colors.red, style.theme.bg), text="DC #" .. uid .. " monitor is " .. BW .. "x" .. BH .. ".\nNeeds at least 40x16."} return end local K = "dc" .. uid .. "_" -- unique bind-key prefix for this board local picker_open = false local last_available = {} -- models loadable from the barrel (from status) m.TextBox{parent=board, x=1, y=1, width=BW-9, text=string.format("DATA CENTER #%02d", uid), alignment=ALIGN.CENTER, fg_bg=style.theme.header} m.TextBox{parent=board, x=BW-8, y=1, width=8, text="", alignment=ALIGN.RIGHT, fg_bg=style.theme.header, bind="clock"} m.StateIndicator{parent=board, x=2, y=3, min_width=12, value=2, states={ { color=cpair(colors.black, colors.green), text="RTU ONLINE" }, { color=cpair(colors.white, colors.red), text="RTU OFFLINE" }, }, bind=K .. "link"} m.StateIndicator{parent=board, x=16, y=3, min_width=10, value=2, states={ { color=cpair(colors.black, colors.green), text="LOADED" }, { color=cpair(colors.white, colors.gray), text="EMPTY" }, }, bind=K .. "run"} local function di(y, label, key) m.DataIndicator{parent=board, x=2, y=y, label=label, format="%4.0f", value=0, width=22, fg_bg=style.text_colors, lu_colors=style.lu_colors, bind=K .. key} end di(5, "Models loaded:", "mdl") di(6, "Inputs waiting:", "inp") di(7, "Output waiting:", "out") m.TextBox{parent=board, x=2, y=9, width=BW-2, text="", fg_bg=style.label, bind=K .. "models"} local openPicker -- no active_fg_bg here: a PushButton's 0.25s "unpress" redraw fires AFTER the -- overlay opens and would paint this button through it m.PushButton{parent=board, x=2, y=11, text="LOAD MODEL", min_width=13, fg_bg=style.wh_gray, callback=function () openPicker() end} m.PushButton{parent=board, x=17, y=11, text="FEED", min_width=8, fg_bg=style.wh_gray, active_fg_bg=style.ind_yel, callback=function () sendDcCmd(uid, "feed_input") end} m.PushButton{parent=board, x=27, y=11, text="PULL OUT", min_width=10, fg_bg=style.wh_gray, active_fg_bg=style.ind_yel, callback=function () sendDcCmd(uid, "pull_output") end} -- picker overlay (topmost on this board) local bp_body local pdiv = m.Div{parent=board, x=1, y=1, width=BW, height=BH, hidden=true, fg_bg=style.theme.highlight_box} -- solid backdrop so the board underneath can't bleed through the overlay m.Rectangle{parent=pdiv, x=1, y=1, width=BW, height=BH, fg_bg=style.theme.highlight_box} m.TextBox{parent=pdiv, x=2, y=1, width=BW-11, text=string.format("LOAD -> DC #%02d", uid), fg_bg=style.theme.header} local function closePicker() pdiv.hide(true) picker_open = false pickers_open = pickers_open - 1 end m.PushButton{parent=pdiv, x=BW-8, y=1, text="BACK", min_width=8, fg_bg=style.wh_gray, active_fg_bg=style.ind_yel, callback=closePicker} local function buildList() if bp_body then bp_body.delete(); bp_body = nil end local items = last_available if #items == 0 then bp_body = m.TextBox{parent=pdiv, x=2, y=3, width=BW-2, height=3, text="No models available in the barrel.", fg_bg=style.label} return end -- pack tightly: 2 rows per model, only as tall as needed (don't stretch -- the list down the whole monitor), capped to the board height local pp = math.min(#items, math.max(1, math.floor((BH - 4) / 2))) local pgn = mimic.Paginator{parent=pdiv, x=1, y=3, width=BW, height=(pp * 2) + 1, items=items, per_page=pp, cols=1, accent=colors.cyan, render=function (slot, mo, index) local sw = slot.get_width() m.PushButton{parent=slot, x=1, y=1, text="LOAD", min_width=6, fg_bg=style.wh_gray, active_fg_bg=style.ind_grn, callback=function () sendDcCmd(uid, "load_model", { model = mo.disp }) closePicker() end} m.TextBox{parent=slot, x=9, y=1, width=sw-18, text=mo.disp, fg_bg=style.text_colors} m.DataIndicator{parent=slot, x=sw-8, y=1, label="x", format="%3.0f", value=mo.count, width=8, fg_bg=style.text_colors, lu_colors=style.lu_colors} end} bp_body = pgn.element end openPicker = function () buildList() picker_open = true pickers_open = pickers_open + 1 pdiv.show() end -- publish this board's data from the unit's latest status (skip while its -- own picker is up, or the repaint bleeds through) local function update(u) if picker_open then return end if not (u and u.online and u.data) then ps.publish(K .. "link", 2) return end local d = u.data last_available = d.available or {} local r = d.roles or {} ps.publish(K .. "link", 1) ps.publish(K .. "run", (d.totals.models > 0) and 1 or 2) ps.publish(K .. "mdl", (r.model and r.model.models) or d.totals.models) ps.publish(K .. "inp", (r.input and r.input.items) or 0) ps.publish(K .. "out", (r.output and r.output.items) or 0) local parts = {} for _, mo in ipairs(d.models) do parts[#parts + 1] = mo.disp .. " x" .. mo.count end ps.publish(K .. "models", #parts > 0 and table.concat(parts, ", ") or "(no models loaded)") end dc_boards[#dc_boards + 1] = { uid = uid, update = update } end for uid, monitorName in pairs(cfg.dc_monitors or {}) do buildDcBoard(uid, monitorName) end -- -- One event loop: mimic.run() owns everything so monitor touches actually reach -- the boards. Incoming rednet packets come through mimic's on_event callback, and -- the periodic watchdog/publish work runs on tcd timers (fired by mimic's loop). -- The coordinator's periodic work is all in-memory + rednet.send (never slow -- peripheral I/O), so it's safe to run inside the event loop - unlike the RTUs, -- which keep parallel for their heavy polling. -- -- rednet packets arrive as events; validate like facility.receive would local function onEvent(event, p1, p2, p3) if event ~= "rednet_message" then return end local sender, msg, proto = p1, p2, p3 if proto ~= facility.PROTOCOL then return end if type(msg) ~= "table" or msg.proto ~= facility.VERSION then return end if msg.kind == facility.KIND.STATUS then reg.update(sender, msg) elseif msg.kind == facility.KIND.REPLY then -- pings fire constantly; only surface replies to real commands if msg.cmd ~= "ping" then last_msg = (msg.cmd or "cmd") .. ": " .. (msg.ok and "ok" or ("err " .. tostring(msg.err))) end end end local function watchdogTick() reg.tick() -- ping everyone we know so their local link light reads COORD OK for _, u in ipairs(reg.list()) do facility.send_command(u.rednet_id, "ping") end tcd.dispatch_unique(PING_INTERVAL, watchdogTick) end local function publishTick() local units = reg.list() -- terminal overview local online = 0 over_tbl.clear() for _, u in ipairs(units) do if u.online then online = online + 1 end local info = (u.data and u.data.headline) or "-" over_tbl.add_row{ tostring(u.unit_id), u.unit_type, u.online and "ONLINE" or "OFFLINE", info } end ps.publish("units_online", online) ps.publish("msg", last_msg) -- chambers monitor - skip entirely while the picker overlay is up, or its -- repaint would bleed through (no z-buffer) if board and chamber_tbl and not board_picker_open then local cu = chamberUnit() if cu and cu.data then local d = cu.data clampSelected() ps.publish("sel_label", string.format("CH %02d", selected)) ps.publish("c_link", cu.online and 1 or 2) ps.publish("c_total", d.summary.total) ps.publish("c_ready", d.summary.ready) ps.publish("c_low", d.summary.low) ps.publish("c_nomodel", d.summary.no_model) ps.publish("cb_models", d.barrel.models) ps.publish("cb_catalyst", d.barrel.catalyst) chamber_tbl.clear() for _, c in ipairs(d.chambers) do chamber_tbl.add_row{ string.format("%02d", c.id), c.state, tostring(c.catalyst), c.energy .. "%" } end else ps.publish("c_link", 2) end end -- ME monitor (independent display; unaffected by the chambers picker) if me_board then local mu = meUnit() if mu and mu.data then local d = mu.data ps.publish("me_link", mu.online and 1 or 2) ps.publish("me_epwr", d.power.max > 0 and d.power.stored / d.power.max or 0) ps.publish("me_ecell", d.items.total > 0 and d.items.used / d.items.total or 0) ps.publish("me_stored", d.power.stored) ps.publish("me_usage", d.power.usage) ps.publish("me_iused", d.items.used) ps.publish("me_avail", d.items.available) ps.publish("me_cpubusy", d.cpus.busy) else ps.publish("me_link", 2) end end -- per-data-center monitors for _, b in ipairs(dc_boards) do b.update(reg.get(b.uid)) end -- clock is shared by every display; hold it while ANY picker overlay is -- up so it doesn't repaint that board's top bar through the overlay if pickers_open == 0 then ps.publish("clock", os.date("%H:%M:%S")) end tcd.dispatch_unique(1, publishTick) end -- kick off the periodic timers, then hand the single event loop to mimic watchdogTick() publishTick() mimic.run(onEvent)