-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
8 changed files
with
509 additions
and
2 deletions.
There are no files selected for viewing
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,81 @@ | ||
#!/usr/bin/env node | ||
import FileMonad from "../lib/FileMonad"; | ||
import CommandProcessor from "../lib/CLP"; | ||
import { defaultConfig } from "../index"; | ||
const helpString = [ | ||
"Goose Belt CLI syntax:", | ||
"\t$ gbelt <command> <...operands>", | ||
"Commands:", | ||
"List all devices:\n\tlist", | ||
"Set polling interval:\n\tpoll <seconds>", | ||
"Add a device:\n\tadd <nickname> <host>", | ||
"Remove a device:\n\tremove <nickname>" | ||
].join("\n"); | ||
const confPath = process.env[(process.platform === 'win32') ? 'USERPROFILE' : 'HOME'] + "/.flock.json"; | ||
function missingOp() { | ||
process.exitCode = 1; | ||
return "Missing input operand"; | ||
} | ||
async function cliReactor() { | ||
const configFile = await new FileMonad(confPath, JSON.stringify(defaultConfig)); | ||
const config = JSON.parse(await configFile.read()); | ||
const reactor = { | ||
_default: () => { | ||
return helpString; | ||
}, | ||
help: () => { | ||
return helpString; | ||
}, | ||
list: (nickname) => { | ||
return "HTTP polling interval: " + config.pollrate + " seconds."; | ||
// TODO: console.table(config.devices); | ||
}, | ||
poll: async (pollrate) => { | ||
config.pollrate = Number(pollrate); | ||
await configFile.write(config); | ||
}, | ||
add: async (nickname, host) => { | ||
config.devices[nickname] = host; | ||
await configFile.write(config); | ||
}, | ||
remove: async (nickname) => { | ||
if (delete config.devices[nickname]) | ||
await configFile.write(config); | ||
} | ||
}; | ||
const args = { | ||
list: ["nickname?"], | ||
poll: ["pollrate"], | ||
add: ["nickname", "host"], | ||
remove: ["nickname"] | ||
}; | ||
// Read/write to the configuration file via the command-line | ||
const cli = new CommandProcessor(reactor, args); | ||
const output = await cli.exec(process.argv[2], process.argv[3], process.argv[4]) ?? "\n"; | ||
process.stdout.write(output); | ||
} | ||
/* | ||
async function clp(command, op1, op2) { | ||
const configFile = await new FileMonad(confPath, JSON.stringify(defaultConfig)); | ||
const config = JSON.parse(await configFile.read()); | ||
if (!command) return helpString; | ||
else if (command === "list") { | ||
return "HTTP polling interval: " + config.pollrate + " seconds."; | ||
// TODO: console.table(config.devices); | ||
} else if (command === "poll") { | ||
if (!op1) return missingOp(); | ||
config.pollrate = op1; | ||
await configFile.write(config); | ||
} else if (command === "add") { | ||
if (!op1 || !op2) return missingOp(); | ||
config.devices[op1] = op2; | ||
await configFile.write(confPath, config); | ||
} else if (command === "remove") { | ||
if (!op1) return missingOp(); | ||
if (delete config.devices[op1]) | ||
await configFile.write(confPath, config); | ||
} else { | ||
process.exitCode = 1; | ||
return `Unrecognized command \"${command}\".`; | ||
} | ||
}*/ |
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,163 @@ | ||
#!/usr/bin/env node | ||
// SMS Alert Agent for ITWatchDogs MicroGoose | ||
// Pop quiz: What is the sound made by a goose? | ||
// Answer: Various loud honks, barks, and cackles. Also some hisses. | ||
// The MicroGoose happens to speak XML, which sounds quite similar. | ||
import xmlParser from "xml-js"; | ||
import FileMonad from "./lib/FileMonad.js"; | ||
import SyncMonad from "./lib/SyncMonad.js"; | ||
const isProd = process.env.NODE_ENV === "production"; | ||
const isTTY = process.stdout.isTTY; | ||
const confPath = process.env[(process.platform === 'win32') ? 'USERPROFILE' : 'HOME'] + "/.flock.json"; | ||
const smsURL = "https://textbelt.com/text"; | ||
// Looks like cleanup on Aisle Four. | ||
class HTTPError extends Error { | ||
constructor(message) { | ||
if (message instanceof Error) | ||
super(message.message); | ||
else | ||
super(message); | ||
this.name = "HTTPError"; | ||
} | ||
} | ||
; | ||
async function httpFetch(url, opts) { | ||
try { | ||
const res = await fetch(url, opts); | ||
// Throw an error if the HTTP Response is not acceptable. | ||
if (!res.ok) | ||
throw new HTTPError(`${res.status} ${res.statusText}`); | ||
return res; | ||
} | ||
catch (err) { | ||
throw new HTTPError(err.message); | ||
} | ||
} | ||
// Send a HTTP POST request to the TextBelt API. | ||
function httpSms(message) { | ||
const smsOpts = { | ||
key: process.env.smsKey, | ||
phone: process.env.smsNum, | ||
message: message, | ||
}; | ||
const httpOpts = { | ||
method: "post", | ||
headers: { "Content-Type": "application/json" }, | ||
body: JSON.stringify(smsOpts) | ||
}; | ||
return httpFetch(smsURL, httpOpts); | ||
} | ||
async function notify(message) { | ||
if (!isProd && isTTY) | ||
console.log(message); | ||
try { | ||
await httpSms(message); | ||
} | ||
catch (err) { | ||
console.error(err); | ||
} | ||
} | ||
function alarmToString(alarm) { | ||
return [ | ||
alarm["alarm-num"], | ||
alarm["device-id"], | ||
alarm.field, | ||
alarm.limtype, | ||
alarm.limit, | ||
alarm.delay, | ||
alarm.repeat, | ||
alarm.email, | ||
alarm.actions | ||
].join(""); | ||
} | ||
// Poll one goose at a time | ||
const parserOptions = { compact: true }; | ||
async function pollGoose(host) { | ||
// It speaks to us... That's one smart goose. | ||
const res = await httpFetch("http://" + host + "/data.xml"); | ||
// This goose speaks XML, so we'll convert it to a JS object tree. | ||
// "No way. Not possible," they said. But it's true. | ||
const gooseData = xmlParser.xml2js(await res.text(), parserOptions); | ||
// It's climate data and alarm data! | ||
const data = { | ||
devices: gooseData.server.devices.device, | ||
alarms: gooseData.server.alarms.alarm | ||
}; | ||
if (!Array.isArray(data.alarms)) | ||
data.alarms = [data.alarms]; | ||
if (!Array.isArray(data.devices)) | ||
data.devices = [data.devices]; | ||
return data; | ||
} | ||
export const defaultConfig = { | ||
pollrate: 300, | ||
devices: {} | ||
}; | ||
// Hello! Come, come and bring your goose, and read with me. | ||
export async function agent() { | ||
// There is a configuration file, .flock.json. | ||
// It will be inside the current user's home/userprofile folder. | ||
// We will safely synchronize configuration with filesystem via a JSON state monad. | ||
const confFile = new FileMonad(confPath, JSON.stringify(defaultConfig)); | ||
const state = new SyncMonad(confFile); | ||
await state.syncState(); | ||
// The active Set records all currently tripped alarms as unique strings! | ||
// Important to remember: The user might change the alarms mid-flight! | ||
const active = new Set(); | ||
// poller sends 1 HTTP request to each MicroGoose device, | ||
// and it expects XML in the responses. | ||
(async function poller() { | ||
// Get the most recent version of the configuration file. | ||
const config = JSON.parse(state.getState()); | ||
for (const gooseName in config.devices) | ||
try { | ||
const host = config.devices[gooseName]; | ||
const data = await pollGoose(host); | ||
for (const node of data.alarms) { | ||
// Let's break these out for each alarm. | ||
const alarm = node._attributes; | ||
// Network topology is not known. Be careful. | ||
const device = data.devices.find(device => device.id === alarm["device-id"]); | ||
if (!device) | ||
continue; | ||
// TODO: Innards of device could all be undefined: | ||
// Here be dragons | ||
// We serialize the alarm into a unique string. | ||
const alarmStr = alarmToString(alarm); | ||
// We correlate the alarm with the device's climate data. | ||
const curField = device.field.find((field) => field._attributes.key === alarm.field)._attributes; | ||
// What is the name of your goose? | ||
const nickname = `MicroGoose ${gooseName}`; | ||
// We now have a description of an alarm, a sensor, and untripped/tripped status. | ||
const statusStr = `${alarm.limtype} ${curField.niceName}꞉ ${curField.value} ⁄ ${alarm.limit}`; | ||
const tripped = alarm.status === "Tripped"; | ||
if (active.has(alarmStr)) { | ||
// The alarm is in the active Set. | ||
// If the alarm is still in tripped state, | ||
// then the user was already notified. | ||
// Avoid double-notification. | ||
// TODO: Maybe make this tweakable.. | ||
if (tripped) | ||
continue; | ||
// The alarm changed to untripped status. | ||
// Remove it from active, and notify the user. | ||
active.delete(alarmStr); | ||
notify(`✅CLEAR✅${nickname} UNTRIPPED ${statusStr}`); | ||
} | ||
else if (tripped) { | ||
// The alarm was not in tripped status before, but is now, | ||
// add it to active, and notify the user. | ||
active.add(alarmStr); | ||
notify(`⚠️ALERT⚠️${nickname} TRIPPED ${statusStr}`); | ||
} | ||
} | ||
} | ||
catch (err) { | ||
console.error(err); | ||
} | ||
// Convert user's pollrate/seconds to miliseconds. | ||
// Set timer for next HTTP poller attempt. | ||
setTimeout(poller, config.pollrate * 1000); | ||
})(); | ||
} | ||
; |
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,60 @@ | ||
// Guns don’t kill mutants, I kill mutants. | ||
function missingOps(operands) { | ||
return () => `Command is missing input operands: "${operands.join(`", "`)}"`; | ||
} | ||
function invalidCmd(command) { | ||
return () => `Command not recognized: "${String(command)}"`; | ||
} | ||
const _noCmd = () => `No command was given.`; | ||
function noCmd() { | ||
return _noCmd; | ||
} | ||
class Dispatcher { | ||
constructor(init, args) { | ||
this.executors = {}; | ||
this.args = {}; | ||
this.add(init, args); | ||
} | ||
add(init, args) { | ||
for (const cmdStr in init) | ||
if (args && cmdStr in args) | ||
this.addCommand(cmdStr, init[cmdStr], args[cmdStr]); | ||
else | ||
this.addCommand(cmdStr, init[cmdStr]); | ||
} | ||
addCommand(cmdStr, executor, args) { | ||
this.executors[cmdStr] = executor; | ||
if (Array.isArray(args) && args.length) | ||
this.args[cmdStr] = args; | ||
else | ||
this.args[cmdStr] = []; | ||
} | ||
// Find a matching executor for a given command: | ||
parse(command, operands) { | ||
if (!command) | ||
if ("_default" in this.executors) | ||
return this.executors._default; | ||
else | ||
return noCmd(); | ||
if (!(command in this.executors)) | ||
return invalidCmd(command); | ||
const expOps = this.args[command]; | ||
if (expOps.length) { | ||
if (!operands.length) | ||
return missingOps(expOps); | ||
if (operands.length !== expOps.length) { | ||
const missing = expOps.slice(operands.length - 1, this.args[command].length - 1); | ||
const required = missing.filter(op => op[op.length - 1] !== "?"); | ||
if (required.length) | ||
return missingOps(required); | ||
} | ||
} | ||
return this.executors[command]; | ||
} | ||
// Execute a command immediately or return "false" if no executor: | ||
async exec(command, ...operands) { | ||
const executor = this.parse(command, operands); | ||
return await executor(...operands); | ||
} | ||
} | ||
export default Dispatcher; |
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,55 @@ | ||
// If you're reading this, then who is tending to the soufflé!? | ||
import { constants as fsNumbers, promises as fs, watch as fsWatch } from "fs"; | ||
const FR = fsNumbers.F_OK | fsNumbers.R_OK; | ||
const read = async function readFile(path, encoding) { | ||
return await fs.readFile(path, { encoding }); | ||
}; | ||
// Mm. Smells like someone cut the fromage. | ||
const ensure = async function ensureFile(path, data, encoding) { | ||
try { | ||
return await fs.access(path, FR); | ||
} | ||
catch (err) { | ||
if (err.code === "ENOENT") { | ||
return await fs.writeFile(path, data, { encoding }); | ||
} | ||
else | ||
throw err; | ||
} | ||
}; | ||
const write = function writeFile(path, data, encoding) { | ||
return fs.writeFile(path, data, { encoding }); | ||
}; | ||
const watchOptions = { | ||
persistent: false | ||
}; | ||
// Provide an easy hook into fs.watch | ||
const watch = function watchFile(path, targetFile, callback) { | ||
fsWatch(path, watchOptions, async (type) => { | ||
if (type !== "change") | ||
return; | ||
callback(targetFile); | ||
}); | ||
}; | ||
// File reader, writer, and synchronizer. | ||
// Someone's gonna ruin the soufflé. | ||
class FileMonad { | ||
constructor(path, data, encoding = "utf8") { | ||
this.path = path; | ||
this.read = async () => { | ||
await ensure(path, data, encoding); | ||
return await read(path, encoding); | ||
}; | ||
this.ensure = (dData = data) => ensure(path, dData, encoding); | ||
this.write = data => write(path, data, encoding); | ||
this.watch = async (callback) => { | ||
await ensure(path, data, encoding); | ||
watch(path, this, callback); | ||
}; | ||
} | ||
get [Symbol.toStringTag]() { | ||
return this.path; | ||
} | ||
} | ||
export default FileMonad; | ||
export { FileMonad }; |
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
class StateMonad { | ||
constructor(state) { | ||
this.getState = () => state; | ||
this.setState = (newState) => { state = newState; }; | ||
if (state instanceof StateMonad) | ||
this.setState(state.getState()); | ||
else | ||
this.setState(state); | ||
} | ||
} | ||
export default StateMonad; | ||
export { StateMonad }; |
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
import { StateMonad } from "./StateMonad.js"; | ||
// State monad which synchronizes with a file monad | ||
class SyncMonad extends StateMonad { | ||
constructor(file, init) { | ||
super(init instanceof StateMonad ? init.getState() : init); | ||
this.syncState = async () => { | ||
this.setState(await file.read()); | ||
}; | ||
// Synchronize with the monad | ||
file.watch(this.syncState); | ||
} | ||
} | ||
export default SyncMonad; |
Oops, something went wrong.