aboutsummaryrefslogtreecommitdiff
path: root/src/NeoRuntimeManager
diff options
context:
space:
mode:
Diffstat (limited to 'src/NeoRuntimeManager')
-rw-r--r--src/NeoRuntimeManager/IPC.js184
-rw-r--r--src/NeoRuntimeManager/RuntimeProcess.js103
-rw-r--r--src/NeoRuntimeManager/index.js311
3 files changed, 598 insertions, 0 deletions
diff --git a/src/NeoRuntimeManager/IPC.js b/src/NeoRuntimeManager/IPC.js
new file mode 100644
index 0000000..79ce7ea
--- /dev/null
+++ b/src/NeoRuntimeManager/IPC.js
@@ -0,0 +1,184 @@
+/**
+ * This module is used to communicate with a python NeoRuntime instance.
+ *
+ * @author jakobst1n.
+ * @since 3.10.2021
+ */
+
+const net = require("net");
+let logger = require(__appdir + "/src/Logger");
+
+/** @type {int} How long wait between each reconnection attempt */
+const RECONNECT_INTERVAL = 1000;
+/** @type {Object} ENUM-ish for command that can be sent to neoruntime */
+const COMMAND = Object.freeze({SET_GLOB : 0,
+ SET_VAR : 1,
+ SET_SEND_STRIP_BUF: 2});
+/** @type {Object} ENUM-ish for globvars */
+const GLOBVAR = Object.freeze({POWER_ON : 0,
+ BRIGHTNESS: 1});
+/** @type {Object} ENUM-ish for what type of data neoruntime sends */
+const DATATYPE = Object.freeze({STATES : 1,
+ STRIP_BUF: 2});
+
+/**
+ * class that will keep a active connection to a socket if possible, and
+ * automatically reconnect. It will emit events when data is received,
+ * and it will send commands to the process. */
+class IPC {
+
+ constructor(_socketFile, _eventEmitter) {
+ this.socketFile = _socketFile;
+ this.eventEmitter = _eventEmitter;
+
+ this.client;
+ this.connected = false;
+ this.reconnectInterval = false;
+
+ this.globvars = {};
+ this.variables = {};
+
+ this.reconnect();
+ }
+
+ /**
+ * If we are not already attempting to reconnect, this will start a
+ * interval that tries to reconnect. */
+ reconnect() {
+ if (this.reconnectInterval === false) {
+ this.reconnectInterval = setInterval(this.tryOpenSocketConnection.bind(this), RECONNECT_INTERVAL);
+ }
+ }
+
+ /**
+ * This will attempt to connect to the socket, and then setup all listeners
+ * if it succedes. */
+ tryOpenSocketConnection() {
+ // logger.info("Attempting to start IPC");
+
+ this.client = net.createConnection(this.socketFile)
+ .on('connect', () => {
+ clearInterval(this.reconnectInterval);
+ this.reconnectInterval = false;
+ // logger.info("IPC Connected.");
+ })
+ .on("ready", () => {
+ this.connected = true;
+ })
+ .on('data', (data) => {
+ switch (data[0]) {
+ case DATATYPE.STATES:
+ let json_data;
+ try {
+ json_data = JSON.parse(data.toString("ascii", 1));
+ } catch (e) {
+ logger.warning("Could not parse json data from neoruntime");
+ return;
+ }
+
+ if (json_data.hasOwnProperty("globvars")) {
+ forEachDiff(json_data["globvars"], this.globvars, (key, newVal) => {
+ this.eventEmitter.emit("change", key, newVal);
+ });
+ this.globvars = json_data["globvars"];
+ }
+ if (json_data.hasOwnProperty("variables")) {
+ forEachDiff(json_data["variables"], this.variables, (key, newVal) => {
+ this.eventEmitter.emit("change", `variable/${key}`, newVal);
+ });
+ this.variables = json_data["variables"];
+ }
+ break;
+
+ default:
+ logger.info(data);
+ }
+
+ })
+ .on("timeout", () => {
+ logger.info("IPC Timeout");
+ })
+ .on("close", (hadError) => {
+ // logger.info("IPC Close, hadError: ", hadError);
+ this.connected = false;
+ this.reconnect();
+ })
+ .on("end", () => {
+ // logger.info("IPC End");
+ this.connected = false;
+ })
+ .on('error', (data) => {
+ // logger.info('IPC Server not active.');
+ this.connected = false;
+ this.reconnect();
+ })
+ ;
+ }
+
+ /**
+ * Will send a command to the socket if we have a active connection,
+ * if not it will just drop the command. there is no queue implemented
+ * for such events. */
+ sendCommand(commandType, name, value) {
+ if (this.connected) {
+ let buf;
+
+ switch (commandType) {
+ case (COMMAND.SET_GLOB):
+ buf = Buffer.allocUnsafe(3);
+ buf[1] = name;
+ buf[2] = value;
+ break;
+
+ case (COMMAND.SET_VAR):
+ if (name.length > 32) { return {success: false, reason: "name too long", detail: "max size of name is 32 bytes"}; }
+ if (value.length > 93) { return {success: false, reason: "value too long", detail: "max size of value is 93 bytes"}; }
+ buf = Buffer.allocUnsafe(3 + name.length + value.length);
+ buf[1] = name.length;
+ buf[2] = value.length;
+ buf.write(name, 3, name.length, "ascii");
+ buf.write(value, 3+name.length, value.length, "ascii");
+ break;
+
+ case (COMMAND.SET_SEND_STRIP_BUF):
+ buf = Buffer.allocUnsafe(2);
+ buf[1] = (name) ? 1 : 0;
+ break;
+
+ default:
+ logger.warning(`IPC UNKNOWN COMMANDTYPE ${commandType}`)
+ return {success: false, reason: "ipc command unknown", detail: commandType};
+ }
+
+ buf[0] = commandType;
+ this.client.write(buf);
+ return {success: true}
+ }
+ return {success: false, reason: "socket not connected", detail: "This usually means the python script is not running"};
+ }
+
+}
+
+const isObject = v => v && typeof v === 'object';
+
+/**
+ * Will call callback on all the differences between the dicts
+ */
+function forEachDiff(dict1, dict2, callback) {
+ for (const key of new Set([...Object.keys(dict1), ...Object.keys(dict2)])) {
+ if (isObject(dict1[key]) && isObject(dict2[key])) {
+ if (dict1[key].value !== dict2[key].value) {
+ callback(key, dict1[key]);
+ }
+ } else if (dict1[key] !== dict2[key]) {
+ if (isObject(dict2[key]) && (dict1[key] == null)) {
+ dict2[key].value = null;
+ callback(key, dict2[key])
+ } else {
+ callback(key, dict1[key]);
+ }
+ }
+ }
+}
+
+module.exports = {IPC, COMMAND, GLOBVAR};
diff --git a/src/NeoRuntimeManager/RuntimeProcess.js b/src/NeoRuntimeManager/RuntimeProcess.js
new file mode 100644
index 0000000..c5c4749
--- /dev/null
+++ b/src/NeoRuntimeManager/RuntimeProcess.js
@@ -0,0 +1,103 @@
+let fs = require("fs-extra");
+let spawn = require("child_process");
+
+class RuntimeProcess {
+
+ constructor(_modePath, _eventEmitter) {
+ this.modePath = _modePath;
+ this.logfile = `${this.modePath}/mode.log`;
+ this.errfile = `${this.modePath}/mode.error`;
+
+ this.stdout = "";
+ this.stderr = "";
+
+ this.fl = false;
+ this.proc = null;
+
+ this.isRunning = false;
+ this.exitCode = null;
+
+ this.eventEmitter = _eventEmitter;
+ }
+
+ start() {
+ if (this.isRunning) {
+ console.log("PROCESS ALREADY RUNNING");
+ return;
+ }
+ this.isRunning = true;
+ this.proc = spawn.spawn(
+ `${__appdir}/NeoRuntime/Runtime/venv/bin/python`,
+ //"python",
+ [
+ "-u", // This makes us able to get real-time output
+ `${__appdir}/NeoRuntime/Runtime/neo_runtime.py`,
+ `--strip-config="${__configdir}/strip.ini"`,
+ `--mode-path="${this.modePath}"`,
+ `--mode-entry=script`
+ ]
+ );
+
+ this.proc.on('error', (err) => {
+ console.log(err);
+ });
+
+ fs.ensureFileSync(this.logfile);
+ fs.ensureFileSync(this.errfile);
+ this.eventEmitter.emit("proc:start");
+
+ this.proc.stdout.on('data', (_stdout) => {
+ let stdout_str = _stdout.toString();
+ fs.appendFile(this.logfile, `[${timestamp()}]: ` + stdout_str);
+ this.eventEmitter.emit("proc:stdout", stdout_str);
+ });
+
+ this.proc.stdout.on('end', () => {
+ fs.appendFile(this.logfile, "\n");
+ });
+
+ this.proc.stderr.on('data', (_stderr) => {
+ let stderr_str = _stderr.toString();
+ fs.appendFile(this.errfile, `[${timestamp()}]: ` + stderr_str);
+ this.eventEmitter.emit("proc:stderr", stderr_str);
+ });
+
+ this.proc.stderr.on('end', () => {
+ fs.appendFile(this.logfile, "\n");
+ });
+
+ this.proc.on('close', (code) => {
+ if (code) {
+ fs.appendFile(this.logfile, `[${timestamp()}]: ` + "Script exited with code " + code.toString());
+ }
+ this.eventEmitter.emit("proc:exit", 0);
+ this.isRunning = false;
+ this.exitCode = code;
+ });
+
+ }
+
+ stop(restart=false) {
+ try {
+ if (restart) {
+ this.proc.once("close", () => {
+ setTimeout(() => this.start(), 500);
+ });
+ }
+ this.proc.kill("SIGINT");
+ } catch (err) {
+ console.log(err);
+ }
+ }
+}
+
+/**
+ * Creates and returns a timestamp that can be used in logfiles.
+ *
+ * @return {string} timestamp
+ */
+function timestamp() {
+ return (new Date()).toISOString();
+}
+
+module.exports = RuntimeProcess;
diff --git a/src/NeoRuntimeManager/index.js b/src/NeoRuntimeManager/index.js
new file mode 100644
index 0000000..4377b8a
--- /dev/null
+++ b/src/NeoRuntimeManager/index.js
@@ -0,0 +1,311 @@
+/**
+ * This module is used to execute and communicate with a python NeoRuntime instance.
+ *
+ * @author jakobst1n.
+ * @since 19.12.2019
+ */
+
+const fs = require("fs");
+const fsPromises = fs.promises;
+const RuntimeProcess = require("./RuntimeProcess");
+const IPC = require("./IPC");
+const logger = require(__appdir + "/src/Logger");
+const EventEmitter = require('events');
+
+/** @type {object} this should be a pointer to a object referencing all neoModules (see app.js) */
+let neoModules;
+
+/** @type {string} Currently active mode */
+let modeId = null;
+/** @type {int} Last exit code of a mode */
+let modeExitCode = 0;
+/** @type {RuntimeProcess} This is the current RuntimeProcess instance */
+let runtimeProcess = null;
+/** @type {IPC} The IPC instance, used to communicate with the script */
+let ipc = null;
+/** @type {EventEmitter} This is used to emit events when things change */
+const eventEmitter = new EventEmitter();
+/** @type {boolean} If this is true, we will not do things the usual way */
+let modeDebuggerActive = false;
+/** @type {string} Should be the modeId the debugger is attached to */
+let modeDebuggerId = null;
+
+eventEmitter.on("proc:exit", (code) => modeExitCode = code);
+
+/**
+ * Check if a path id actually a mode (if it is a folder with a script.py file)
+ *
+ * @param {string} path - Path to check.
+ *
+ * @return {boolean} wether the path points to a valid mode.
+ */
+function isMode(path) {
+ if (!fs.existsSync(path)) { return false; }
+ let folderStat = fs.statSync(path);
+ if (!folderStat.isDirectory()) { return false; }
+ if (!fs.existsSync(path + "/script.py")) { return false; }
+ return true;
+}
+
+/**
+ * Get all ids of modes that can be set.
+ *
+ * @returns {array} All modeids
+ */
+function listModes() {
+ let modeDirs = [
+ ["builtin/", fs.readdirSync(__appdir + "/NeoRuntime/builtin")],
+ ["remote/", fs.readdirSync(__datadir + "/remoteCode")],
+ ["user/", fs.readdirSync(__datadir + "/userCode")]
+ ]
+ let validModes = [];
+ for (modeDir of modeDirs) {
+ for (modeName of modeDir[1]) {
+ let modeId = `${modeDir[0]}${modeName}`;
+ if (isMode(getModePath(modeId))) {
+ validModes.push(modeId);
+ }
+ }
+ }
+ return validModes;
+}
+
+/**
+ * Change mode, stop the old one and start the new one.
+ *
+ * @param {string} _modeId - Id of the mode to change to.
+ *
+ * @return {object} A standardform return object.
+ */
+function setMode(_modeId) {
+ if (modeDebuggerActive && (_modeId != modeDebuggerId)) {
+ return {success: false, reason: "debugger active", detail: "Cannot change mode when debugger is active."}
+ }
+ if (!isMode(getModePath(_modeId))) {
+ console.log(`Invalid mode "${_modeId}".`);
+ return {success: false, reason: "unknown modeId"};
+ }
+ logger.info(`Changing mode to "${_modeId}".`);
+
+ stopMode();
+
+ modeId = _modeId;
+ neoModules.userData.config.activeMode = modeId;
+ eventEmitter.emit("change", "mode", modeId);
+
+ runtimeProcess = new RuntimeProcess(getModePath(_modeId), eventEmitter);
+ startMode();
+
+ return {success: true}
+};
+
+/**
+ * Get current mode
+ *
+ * @return {string} current modeId
+ */
+function currentMode() {
+ return modeId;
+}
+
+/**
+ * Will attempt to stop current mode
+ *
+ * @return {object} A standardform return object.
+ */
+function stopMode(restart=false) {
+ if (modeRunning()) {
+ runtimeProcess.stop(restart);
+ }
+ return {success: true}
+};
+
+/**
+ * Will attempt to start current mode
+ *
+ * @return {object} A standardform return object.
+ */
+function startMode() {
+ if (runtimeProcess === null) { return {success: false, reason: "no runtimeprocess", detail: "Runtimeprocess not set, did you mean to call setMode?"}; }
+ runtimeProcess.start();
+ return {success: true}
+};
+
+/**
+ * Will attempt to restart current mode
+ *
+ * @return {object} A standardform return object.
+ */
+function restartMode() {
+ return stopMode(true);
+};
+
+/**
+ * Checks if mode is running currently
+ *
+ * @return {boolean} if mode is running
+ */
+function modeRunning() {
+ if (runtimeProcess === null) { return false; }
+ return runtimeProcess.isRunning;
+};
+
+/**
+ * Get the full system path to a mode
+ *
+ * @param {string} modeId
+ *
+ * @return {string} Full path of mode
+ */
+function getModePath(modeId) {
+ let path = modeId.split("/");
+ let location = path.splice(0, 1).toString();
+ if (location === "user") { path = __datadir + "/userCode/" + path.join("/"); }
+ if (location === "remote") { path = __datadir + "/remoteCode/" + path.join("/"); }
+ if (location === "builtin") { path = __appdir + "/NeoRuntime/builtin/" + path.join("/"); }
+ return path;
+}
+
+/**
+ * This should be called by RuntimeProcess when a variable changes in the mode
+ *
+ * @param {string} location - This is globvars/variables
+ * @param {string} name - Name of the variable
+ * @param {any} newValue - The new value of the variable
+ */
+function onVariableChange(location, name, newValue) {
+ if (location == "variables") {
+ eventEmitter.emit("change", `variable/${name}`, newValue)
+ } else if (location == "globvars") {
+ eventEmitter.emit("change", `${name}`, newValue)
+ }
+}
+
+/**
+ * Function that returns all globvars (brightness, power_on) as the values they
+ * had last time we heard from the python script.
+ *
+ * @return {object}
+ */
+function getGlobvars() {
+ if (!modeRunning()) { return {}; }
+ return ipc.globvars;
+}
+
+/**
+ * Sets value of a globvar power_on/brightness.
+ *
+ * @param {string} name - Name of the variable power_on/brightness
+ * @param {any} value - The value the variable should be set to
+ *
+ * @return {object} Standardform return object
+ */
+function setGlobvar(name, value) {
+ if (!modeRunning()) { return; }
+
+ switch(name) {
+ case "power_on":
+ return ipc.sendCommand(IPC.COMMAND.SET_GLOB, IPC.GLOBVAR.POWER_ON, (value) ? 1 : 0);
+ case "brightness":
+ return ipc.sendCommand(IPC.COMMAND.SET_GLOB, IPC.GLOBVAR.BRIGHTNESS, value);
+ default:
+ return {success: false, reason: "unknown globvar", detail: name};
+ }
+}
+
+/**
+ * Get all variables declared in mode
+ *
+ * @return {object}
+ */
+function getVariables() {
+ if (!modeRunning()) { return {}; }
+ return ipc.variables;
+}
+
+/**
+ * Sets value of a variable
+ *
+ * @param {string} name - Name of the variable
+ * @param {any} value - The value the variable should be set to
+ *
+ * @return {object} Standardform return object
+ */
+function setVariable(name, value) {
+ if (!modeRunning()) { return; }
+ return ipc.sendCommand(IPC.COMMAND.SET_VAR, name, value);
+}
+
+/**
+ * Start debugger for a mode
+ *
+ * @param {string} modeId - The mode to debug
+ *
+ * @return {object} Standardform return object
+ */
+function startDebugger(debuggerModeId) {
+ if (debuggerModeId.substr(0, 5) !== "user/") { return {success: false, reason: "not user mode"}; }
+ if (!isMode(getModePath(debuggerModeId))) { return {success: false, reason: "unknown modeId"}; }
+ if (modeDebuggerActive) { return {success: false, reason: "debugger already active"}; }
+ logger.info(`Starting debugger for ${debuggerModeId}`);
+ modeDebuggerActive = true;
+ modeDebuggerId = debuggerModeId;
+ if (debuggerModeId != modeId) {
+ setMode(debuggerModeId);
+ } else {
+ restartMode();
+ }
+ return {success: true, code: fs.readFileSync(getModePath(debuggerModeId) + "/script.py").toString()}
+}
+
+/**
+ * Save mode
+ */
+function saveModeCode(_modeId, code) {
+ if (!modeDebuggerActive) { return {success: false, reason: "debugger not active"}; };
+ if (_modeId != modeDebuggerId) { return {success: false, reason: "modeid not the same as debuggermodeid"}; };
+ fs.writeFileSync(getModePath(`${modeDebuggerId}/script.py`), code);
+ return {success: true};
+}
+
+/**
+ * Stop the active debugger
+ *
+ * @return {object} Standardform return object
+ */
+function stopDebugger() {
+ if (!modeDebuggerActive) { return {success: true, detail: "No debugger active"} }
+ logger.info(`Stopping debugger`);
+ modeDebuggerActive = false;
+ return {success: true}
+}
+
+module.exports = (_neoModules) => {
+ neoModules = _neoModules;
+ ipc = new IPC.IPC(neoModules.userData.config.neoRuntimeIPC.socketFile, eventEmitter);
+ return {
+ event: eventEmitter,
+ modes: listModes,
+ mode: {
+ current: currentMode,
+ set: (modeId) => setMode(modeId),
+ status: {
+ modeRunning: modeRunning(),
+ modeExitCode: modeExitCode
+ },
+ globvars: {
+ get: getGlobvars,
+ set: setGlobvar
+ },
+ variables: {
+ get: getVariables,
+ set: setVariable
+ }
+ },
+ getModePath,
+ isMode,
+ modeRunning,
+ startDebugger, stopDebugger, saveModeCode,
+ startMode, stopMode, restartMode
+ }
+};