Skip to content

Latest commit

 

History

History
330 lines (250 loc) · 12.1 KB

NODE-RED.md

File metadata and controls

330 lines (250 loc) · 12.1 KB

Writing reusable code for Node-RED

Node-RED is an attractive automation environment, including for interfacing with Home Assistant.

Some of it's pros are:

  • A rich set of visual tools that are a significant aid in managing your automations.
  • Beats the heck out of writing Home Assistant automations, especially when you can include Function Nodes written in javascript.
  • Ability to run the latest version of javascript inside of Function Nodes
  • A quality code editor within the function node editor
  • Extensibility with custom nodes from many contibutors
  • Good documentation of the basics and for most details

A few cons are:

  • Poor granularity for use with source control, throwing everything into a single flow.json file.
  • No sophisticated VS Code like way to manage your javascript code
  • No Typescript support
  • No code completion for modules you've loaded
  • Poor documentation and norms for doing more than just a little bit with javascript, including writing custom nodes

Thus it takes a bit of figuring out, and management work, to write and manage your javascript.

Here we document a few strategies for including reusable code in a Node-RED deployment, and also with Home Assistant.

The easiest solution is to write your code in javascript within a Function Node. But maybe you want to reuse your code across function nodes. Or include third party modules. Or maybe you want to write your code in TypeScript and still use it within Node-RED. Or even write your own custom Node-RED node. We look at all these options here.

ES6 and Typescript Compatibility

First let's recall we have CommonJS modules, loaded using require, and newer ES6 modules, loaded using import.

You cannot write Typescript within a Function Node. And Node-RED is not able to directly import ES6 modules. But we will show you how you can work around this limitation using dynamic imports. And without having to create CommonJS modules for any of the modules you author.

For your own modules, you should probably you use the Typescript compiler. If you are thinking of using Bun, Bun v1.0 has not been a good alternative because it cannot create modules that can be nested and loaded under a parent module that is also created by Bun.

Adding reusable code directly to Node-RED

As stated, the easiest solution is to just include reusable code in Node-RED by adding it to a Function Node. But you can make is reusable by adding it to the global context, then retrieving that context from other Function Nodes.

To do this you might wish to create a new Library flow tab where you place all your resusable javascript code. You can use an Inject Node to call a Function Node that then initializes your code. The inject node waits a few seconds, to allow dynamic imports to finish loading.

Global Functions

The function node defines global functions as follows:

const global_functions = {

	googleDate: function (jsDate) {
		const d = new Date(jsDate);
    // the starting value for Google
		const tNull = new Date(Date.UTC(1899, 11, 30, 0, 0, 0, 0)); 
		return ((d.getTime() - tNull.getTime()) / 60000 - d.getTimezoneOffset()) / 1440;
	}
}
global.set("global_functions", global_functions);

Then, to use this code in other function nodes:

const g = global.get("global_functions");
node.warn(g.googleDate(new Date()));

As another example, for reusable classes, it is perhaps easiest to add a factory method that instantiates the class:

function newHA(options) {
  return new HA(options);
}

class HA {
  constructor(options) {
    this._ha = options.global.get('homeassistant');
  }

  get ha() {
    return this._ha;
  }
}

global.set('newHA',newHA)
const newHA = global.get('newHA);
const gHA = newHA({})

Cons of this approach are that restricts you to editing code within the Node-RED editor, you can't use Typescript, and you cannot run unit tests in an ordinary sense.

Loading External Modules

We discuss loading modules such as those found in the NPM repository.

Locally referencing a module within a Function node

The Edit function node > Setup tab allows you to add modules that can be used within a particular function node. You do not need to restart the Node-RED add-on to use this code. It will be imported when you deploy.

Moment

The module can then be easily referenced from your code:

let m = moment(new Date()).format('YYYY-MM-DD')

Drawbacks of this approach are that you will need to replicate this procedure in every function node where the module is used. And whether you have configured this import is not visible from within the code editor.

For published packages this process is straight forward, as shown above in the UI.

Referencing private packages works the same way. However you need to be careful how the module is referenced using a git URL, and there may be limitations:

  • You can't use SSH URLs from within the Node-RED Home Assistant add-on. This is due to SSH authentication issues.

  • With Home Assistant, version and tag specifiers don't seem to work with the Home Assistant add-on. TODO: Spend more time to confirm this behavior.

  • Only CommonJS modules can be loaded. TODO: Confirm

Global access to modules

Modules can be imported by Node-RED by including them in package.json. How you do this differs depending on whether you have access to the Node-RED settings folder or are using the Home Assistant Add-on. Once installed and loaded, these modules will be available to all Function Nodes, and not just to the ones that are configured as described above.

Standalone Node-RED, not coupled with Home Assistant

When you run Node-RED on your local machine, Node-RED will by the default instructions, be installed globally and, when run using the node-red command, will create and use the folder $HOME/.node-red for it's setup. Refer to the Node-RED documentation for details.

In this situation it is probably easiest to manually install packages using npm and package.json, and to load the packages at node-RED launch time using $HOME/.node-red/settings.json.

There are some tricks to using settings.json and these will be discussed below under Loading modules using settings.json.

Using the Home Assistant Node-RED Add-on

When using the Node-RED Addon for Home Assistant, Node-RED is (I believe) running in a container where you don't have access to the tools needed (git, yarn, ssh keys). The best way to add packages is to do so on the Home Assistant Settings > Addons > Node-RED > Configuration page. Make sure to use this syntax, which includes git+https:// at the beginning for any unpublished packages.

npm_packages:
  - git+https://jpravetz@github.com/jpravetz/epdoc-node-red-utils

The package configuration mechanism used by the Node-RED addon has a number of significant limitations, including that both the Node-RED and Node-RED addon documentation are not transparent about what happens with updates. I need to do further investation by looking at the code.

Be cautious of package updates, using unpublished packages, and of how no-longer-used modules can still be loaded even if you think you've dereferenced them. You will be tempted to directly edit package.json, and will want to edit settings.json if you want to enable global module loading. I hope to obtain clarification on these limitations.

Ugly workarounds are required.

What I did at one ugly point in time was to just copy the entire text contents of updated files from my dev editor, open the same file in Home Assistant (using the Studio Code Server addon) and paste/overwrite the file contents of the old file.

Another solution is to scp your files across and hand edit the package.json and package-lock.json files to reflect the new commit values. I did this a couple of times with success.

Eventually I published the code to npm, referenced it from the package.json file, and restarted Node-RED within Home Assistant (this is done from the Settings > Add-ons > Node-RED page). For updates, I delete the appropriate subfolder underneath node_modules, and restart Node-RED.

However there are still problems with this approach, as I've discussed here.

Loading modules using settings.json

Once you have a package installed, you will also need to tell Node-RED to load the package, and then make the package available to any Function Nodes.

CommonJS modules can be loaded using require. ES6 modules must be dynamically imported. The code below shows how to modify settings.json to accomplish both.

let settings = {
  // other stuff, not shown here

  functionGlobalContext: {
    "moment": require('moment')   // moment can use require
  }
}

async function loadModules() {
  // The ES6 modules to be loaded
  const names = [
    '@epdoc/typeutil',
    '@epdoc/timeutil',
    '@epdoc/node-red-hassio', // contains a custom node
    '@epdoc/node-red-hautil',
  ];
  let jobs = [];
  names.forEach((name) => {
    let job = import(name);
    jobs.push(job);
  });
  return Promise.all(jobs).then((resp) => {
    if (Array.isArray(resp) && resp.length === names.length) {
      for (let idx = 0; idx < names.length; ++idx) {
        settings.functionGlobalContext[names[idx]] = resp[idx];
      }
    }
  });
}

loadModules();

module.exports = settings;

Using these external modules

As part of making your modules available to Function Nodes, you may with to follow the strategy of attaching them to the global context, as shown below. Otherwise you are repeating setup to add your packages to every Function Node.

// My resuable modules of library code
modules = {
  typeutil: '@epdoc/typeutil',
  timeutil: '@epdoc/timeutil',
  hautil: '@epdoc/node-red-hautil'
};
const lib = loadModules(global, modules);
if (lib.load_errors.length) {
  node.warn(`Error loading modules ${lib.load_errors.join(', ')}`);
}
global.set('lib', lib);

function loadModules(global, modules) {
  const lib = {
    load_errors: []
  };
  const fail = [];
  Object.keys(modules).forEach((key) => {
    const pkgName = modules[key];
    lib[key] = global.get(pkgName);
    if (!lib[key]) {
      lib.load_errors.push(pkgName);
    }
  });
  lib.haFactory = lib.hautil.newHAFactory(global);
  lib.util = {
    date: lib.timeutil.dateUtil,
    duration: lib.timeutil.durationUtil,
    ha: lib.hautil,
    type: lib.typeutil
  };
  return lib;
}

Then, to use this code in a Function Node, it's again a matter of accessing the global context:

const t0 = new Date();
const lib = global.get('lib');
node.warn( `It has been ${lib.util.duration(new Date() - t0)} since we loaded this Function Node` );

Unfortunately, when editing code within the Function Node, there is no editor code completion.

Custom Nodes

Rather than making your code available as libraries, you may want to create your own custom node. This is a rather complicated process without enough supporting examples or documentation. But we're still running our first custom node experiment over here.

Resources