-- -- facility.lua -- -- Shared comms + config layer for the base-wide control system: one coordinator -- and several RTUs over wireless rednet. Lightweight *management* traffic only - -- RTUs push status, the coordinator sends commands and watches link health. -- No auth, no session handshake (see FACILITY_PLAN.md for why). -- -- Install on EVERY device (coordinator + each RTU), the same way as mimic. -- -- RTU sketch: -- local facility = require("facility") -- local cfg = facility.load_config() -- reads /config.lua -- facility.open(cfg.modem) -- -- publish state (also the heartbeat): -- facility.broadcast_status(cfg, { ...subsystem state... }) -- -- handle a command: -- local id, pkt = facility.receive() -- if pkt then facility.handle_command(handlers, id, pkt) end -- -- Coordinator sketch: -- local facility = require("facility") -- local cfg = facility.load_config() -- facility.open(cfg.modem) -- local reg = facility.registry(30) -- units go offline after 30s silent -- local id, pkt = facility.receive() -- if pkt then reg.update(id, pkt) end -- facility.send_command(reg.rednet_id(3), "load_model", { model = "Blaze Data Model" }) -- local facility = {} facility.VERSION = 1 -- protocol version; bump on a breaking packet change facility.PROTOCOL = "facility_ctrl" -- rednet protocol string every device shares -- packet kinds facility.KIND = { STATUS = "status", -- RTU -> coordinator: periodic subsystem state (also the heartbeat) COMMAND = "command", -- coordinator -> RTU: do something REPLY = "reply", -- RTU -> coordinator: result of a command } -- -- config -- -- Load and validate a device's config file (a plain Lua table returned from -- /config.lua). The updater never touches this file, so per-device settings -- survive a re-install of the program. function facility.load_config(path) path = path or "/config.lua" if not fs.exists(path) then error("facility: missing config '" .. path .. "'. Copy config.example.lua to " .. path .. " and edit it for this device.", 0) end local chunk, lerr = loadfile(path) if not chunk then error("facility: config '" .. path .. "' failed to load: " .. tostring(lerr), 0) end local ok, cfg = pcall(chunk) if not ok then error("facility: config '" .. path .. "' errored: " .. tostring(cfg), 0) end if type(cfg) ~= "table" then error("facility: config '" .. path .. "' must return a table", 0) end if type(cfg.unit_id) ~= "number" then error("facility: config.unit_id must be a number", 0) end if type(cfg.unit_type) ~= "string" then error("facility: config.unit_type must be a string", 0) end if type(cfg.modem) ~= "string" then error("facility: config.modem must be a modem side/name string", 0) end return cfg end -- -- transport -- -- Open rednet on the configured modem. Accepts a side ("back") or a peripheral -- name ("modem_0"). Use an ender (wireless) modem so range is a non-issue. function facility.open(modem) if not peripheral.isPresent(modem) then error("facility: no peripheral on '" .. tostring(modem) .. "' (expected an ender modem)", 0) end if peripheral.getType(modem) ~= "modem" then error("facility: peripheral on '" .. tostring(modem) .. "' is not a modem", 0) end rednet.open(modem) end -- monotonically increasing per-device sequence number, so a coordinator can pair -- a REPLY with the COMMAND it answered local seq_counter = 0 local function next_seq() seq_counter = seq_counter + 1 return seq_counter end -- -- sending -- -- RTU: broadcast the current subsystem state. Doubles as the heartbeat, so the -- coordinator's watchdog stays fed just by these. function facility.broadcast_status(cfg, data) rednet.broadcast({ kind = facility.KIND.STATUS, proto = facility.VERSION, unit_id = cfg.unit_id, unit_type = cfg.unit_type, seq = next_seq(), data = data, }, facility.PROTOCOL) end -- Coordinator: send a command to one RTU by its rednet id (from the registry). -- Returns the packet's seq so the caller can match a REPLY if it waits for one. function facility.send_command(rednet_id, cmd, args) if rednet_id == nil then return nil, "unit not online" end local seq = next_seq() rednet.send(rednet_id, { kind = facility.KIND.COMMAND, proto = facility.VERSION, seq = seq, cmd = cmd, args = args, }, facility.PROTOCOL) return seq end -- RTU: reply to a received COMMAND packet. `ok` is a boolean; on success pass a -- result table (or nil), on failure pass an error string. function facility.reply(rednet_id, cmd_pkt, ok, result_or_err) rednet.send(rednet_id, { kind = facility.KIND.REPLY, proto = facility.VERSION, seq = cmd_pkt.seq, cmd = cmd_pkt.cmd, -- echo which command this answers (lets the coordinator label/ignore it) ok = ok and true or false, result = ok and result_or_err or nil, err = (not ok) and tostring(result_or_err) or nil, }, facility.PROTOCOL) end -- -- receiving -- -- Blocking receive of the next well-formed packet on our protocol. Returns -- (sender_rednet_id, packet), or nil on timeout / malformed / version-skew. -- Both sides must share facility.VERSION; mismatched packets are dropped. function facility.receive(timeout) local id, msg = rednet.receive(facility.PROTOCOL, timeout) if id == nil then return nil end if type(msg) ~= "table" or msg.kind == nil then return nil end if msg.proto ~= facility.VERSION then return nil end return id, msg end -- RTU convenience: dispatch a received packet against a table of command -- handlers and auto-reply. Each handler is `function(args) -> ok, result_or_err`. -- Returns true if the packet was a command (handled or rejected), false otherwise -- so the caller can treat non-commands however it likes. function facility.handle_command(handlers, id, pkt) if pkt.kind ~= facility.KIND.COMMAND then return false end local h = handlers[pkt.cmd] if not h then facility.reply(id, pkt, false, "unknown command: " .. tostring(pkt.cmd)) return true end local pok, hok, hres = pcall(h, pkt.args) if not pok then facility.reply(id, pkt, false, "handler error: " .. tostring(hok)) else facility.reply(id, pkt, hok, hres) end return true end -- -- coordinator-side unit registry + link watchdog -- -- Tracks every RTU we've heard from: type, rednet id, last-seen time, latest -- data, and online/offline. Fed by STATUS packets; call tick() periodically to -- age silent units out. function facility.registry(timeout) timeout = timeout or 15 -- seconds of silence before a unit is marked offline local units = {} -- unit_id -> record local reg = {} -- Fold a received STATUS packet into the registry. function reg.update(rednet_id, pkt) if type(pkt) ~= "table" or pkt.kind ~= facility.KIND.STATUS then return end local u = units[pkt.unit_id] if not u then u = { unit_id = pkt.unit_id } units[pkt.unit_id] = u end u.unit_type = pkt.unit_type u.rednet_id = rednet_id u.last_seen = os.clock() u.online = true u.data = pkt.data end -- Call periodically (e.g. once a second): flip units offline once they've -- gone silent past the timeout. function reg.tick() local now = os.clock() for _, u in pairs(units) do if u.online and (now - u.last_seen) > timeout then u.online = false end end end function reg.get(unit_id) return units[unit_id] end function reg.rednet_id(unit_id) local u = units[unit_id] return u and u.rednet_id or nil end -- All known units, optionally filtered by unit_type, sorted by unit_id. function reg.list(unit_type) local out = {} for _, u in pairs(units) do if unit_type == nil or u.unit_type == unit_type then out[#out + 1] = u end end table.sort(out, function (a, b) return a.unit_id < b.unit_id end) return out end return reg end return facility