Developing custom nodes

Hi,

what is the most efficient and recommended way to develop custom nodes for NodeRED?
Is it also possible to recycle Python functions in custom nodes?

No, not directly. Though I believe there are web tools that will try to convert Python to JavaScript. There is also a web assembly version of Python that you might be able to use if you really wanted to.

I don't know that there really is one. Since most node developers probably only create a few nodes, there isn't always a lot of incentive to do anything other than copy the example simple node from the Node-RED docs.

Personally, I dislike the example as it follows some VERY old code and JavaScript. I have my own templates that I tend to copy to a new node as needed. You can see how I personally do things in my Node-RED-Testbed repo. This separates the various parts of the code into files and some common code in a plugin. It also flattens the code in the main runtime for each node which I think makes a lot more sense and seems to me to be easier to parse. Others may well disagree.

1 Like

Thanks for your feedback. I will look into you example tomorrow.

With regard to the Python question:
I have implemented few serial based device protocols in Python.
Either I
-implement some code snippets e.g. open/close serial device, read/write data using the Python NodeRED Plugin or
-I will write serial data as JSON to file and use read file node or
-publish/subscribe my device serial data via MQTT from my Python Service

Some of this should be available as nodes though so check that first before trying to roll-your-own would be my advice. Node-RED started life as an IoT demonstrator platform so generally has good capabilities for handling such things. You probably already know but just in case not, serial port access is often exclusive so if you are still running a Python script that has grabbed a port, it may not be available for Node-RED to use.

I did some examples using the NodeRED "Custom Node Guide".
How to use VS Code for writing, debugging NodeRED Custom Nodes and setup auto reload of node-red application?

I opened a remote SSH session from my host PC to my RPi4 with Debian Trixie.

The easiest way is to use the inspector Node. That lets you turn on node.js debugging via dev tools. When turned on, open dev tools in your browser and spot the green Node.js icon top left, click on that to get the node.js dev tools window.

Instead, you could set up node-red to run with inspector turned on using node.js's --inspect switch.

To get auto-reload, I find the easiest way is to run node-red via PM2 on my dev PC, PM2 has a watch capability that will let you auto-restart node-red. It also simplifies delivery of logs on Windows at least. I have a Windows Terminal configuration that starts PM2 which starts node-red, I auto-open it a few seconds after logging in after a reboot. You will need to add your node package folder to the PM2 watch as well as node-red's package.json file.

For development of a node, I create the package repo in GitHub first, then clone that to my PC using GitHub desktop (which is a link on the GitHub site for your repo). Then you manually install it into your dev instance of Node-RED by going to the userDir folder (normally ~/.node-red) and running npm install /path/to/your/repo-clone. This creates a filing system link from the main folder to your node-red instance. You must also manually install any dependencies your node has in the repo folder, not in node-red.

For everything else, I recommend a good set of dev dependencies for your node package. Here are the main ones I use in UIBUILDER. I have some others but they are more specific to UIBUILDER than generally useful:

 "devDependencies": {
    "@eslint/js": "^9.20.0",
    "@stylistic/eslint-plugin": "^5.2.2",
    "@types/jquery": "^3.5.25",
    "@types/jqueryui": "^1.12.19",
    "@types/node": "^18.19.24",
    "@types/node-red": "*",
    "browserslist": "^4.24.4",
    "esbuild": "^0.27.1",
    "eslint": "^9.20.1",
    "eslint-plugin-import": "^2.31.0",
    "eslint-plugin-jsdoc": "^61.4.1",
    "eslint-plugin-n": "^17.15.1",
    "eslint-plugin-promise": "^7.2.1",
    "globals": "^16.0.0",
    "lightningcss": "^1.28.2",
    "stylelint": "^16.11.0",
    "stylelint-gamut": "^1.3.4"

ESLINT, since about v8 or so, has become a nightmare to configure now it only uses a single file. This is problematic for Node-RED development since you cannot cleanly separate back-end from front-end files in separate folders. This is my eslint.config.mjs file:

/* eslint-disable jsdoc/valid-types */
/* eslint-disable n/no-unpublished-import */
/* eslint-disable import/no-unresolved */
// @ts-nocheck
/**
 * https://www.npmjs.com/search?q=eslint-config
 * https://www.npmjs.com/search?q=keywords:eslint
 *
 * npm init @eslint/config@latest -- --config eslint-config-standard
 * https://eslint.org/docs/latest/rules
 *
 * npx @eslint/config-inspector@latest
 * npx eslint --debug somefile.js
 * npx eslint --print-config file.js
 */

import { defineConfig } from 'eslint/config'
import globals from 'globals' // https://www.npmjs.com/package/globals
// @ts-ignore
import pluginImport from 'eslint-plugin-import' // https://www.npmjs.com/package/eslint-plugin-import
import pluginPromise from 'eslint-plugin-promise' // https://www.npmjs.com/package/eslint-plugin-promise
import jsdoc from 'eslint-plugin-jsdoc'// https://github.com/gajus/eslint-plugin-jsdoc
import node from 'eslint-plugin-n' // https://www.npmjs.com/package/eslint-plugin-n, node.js only
import stylistic from '@stylistic/eslint-plugin' // https://eslint.style
import js from '@eslint/js'

// Folder/file lists - eslint flat config is WEIRD!
// You have to override the top-level config (e.g. **/*.js) and then exclude the folders/files you don't want.

// Shared rules
const jsdocRules = {
    'jsdoc/check-alignment': 'off',
    // "jsdoc/check-indentation": ["warn", {"excludeTags":['example', 'description']}],
    'jsdoc/check-indentation': 'off',
    'jsdoc/check-param-names': 'warn',
    'jsdoc/check-tag-names': ['warn', {
        definedTags: ['typicalname', 'element', 'memberOf', 'slot', 'csspart'],
    }],
    'jsdoc/multiline-blocks': ['error', {
        noZeroLineText: false,
    }],
    'jsdoc/no-multi-asterisk': 'off',
    'jsdoc/no-undefined-types': ['error', {
        definedTypes: ['JQuery', 'NodeListOf', 'ProxyHandler'],
    }],
    'jsdoc/reject-any-type': 'off',
    'jsdoc/reject-function-type': 'off',
    'jsdoc/tag-lines': 'off',
}
const stylisticRules = {
    '@stylistic/brace-style': ['error', '1tbs', { allowSingleLine: true, }],
    '@stylistic/comma-dangle': ['error', {
        arrays: 'only-multiline',
        objects: 'always',
        imports: 'never',
        exports: 'always-multiline',
        functions: 'never',
        importAttributes: 'never',
        dynamicImports: 'never',
    }],
    '@stylistic/eol-last': ['error', 'always'],
    '@stylistic/indent': ['error', 4, {
        SwitchCase: 1,
    }],
    '@stylistic/indent-binary-ops': ['error', 4],
    '@stylistic/linebreak-style': ['error', 'unix'],
    '@stylistic/lines-between-class-members': 'off',
    '@stylistic/newline-per-chained-call': ['error', {
        ignoreChainWithDepth: 2,
    }],
    '@stylistic/no-confusing-arrow': 'error',
    '@stylistic/no-extra-semi': 'error',
    '@stylistic/no-mixed-spaces-and-tabs': 'error',
    '@stylistic/no-trailing-spaces': 'error',
    '@stylistic/semi': ['error', 'never'],
    '@stylistic/space-before-function-paren': 'off',
    '@stylistic/spaced-comment': ['error', 'always', {
        line: {
            exceptions: ['*', '#region', '#endregion'],
        },
        block: {
            exceptions: ['*'],
        },
    }],
    '@stylistic/space-in-parens': 'off',
    '@stylistic/quotes': ['error', 'single', {
        avoidEscape: true,
        allowTemplateLiterals: 'always',
    }],
}
const generalRules = {
    'new-cap': 'error',
    'no-else-return': 'error',
    'no-empty': ['error', {
        allowEmptyCatch: true,
    }],
    'no-unused-vars': 'off',
    'no-useless-escape': 'off',
    'no-var': 'warn',
    'prefer-const': 'error',
}

/** @type {import('eslint').Linter.Config[]} */
export default defineConfig([

    // Browser (ES2019) script, no-build
    {
        files: ['**/*.{js,cjs}'],
        ignores: ['nodes/**/*.*', 'gulpfile.js'],
        languageOptions: {
            sourceType: 'script',
            ecmaVersion: 2019,
            globals: {
                ...globals.browser,
                // window: 'writable', // allow setting window global properties
                jQuery: 'readonly',
                RED: 'readonly',
                uibuilder: 'writable',
                $: 'readonly',
                $$: 'readonly',
                // console: 'readonly',
            },
        },
        linterOptions: {
            reportUnusedInlineConfigs: 'error',
        },
        plugins: {
            'js': js,
            'pluginPromise': pluginPromise,
            'jsdoc': jsdoc,
            '@stylistic': stylistic,
        },
        extends: [
            js.configs.recommended,
            jsdoc.configs['flat/recommended'],
            stylistic.configs.recommended,
            pluginPromise.configs['flat/recommended'],
        ],
        settings: {
            jsdoc: { mode: 'jsdoc', },
        },
        rules: {
            ...jsdocRules,
            ...stylisticRules,
            ...generalRules,
            // 'no-empty': ['error', { 'allowEmptyCatch': true }],
        },
    },

    // Browser (Latest) ESM, ESBUILD
    {
        files: ['**/*.mjs'],
        ignores: ['nodes/**/*.*', 'stylelint.config.mjs'],
        // ...pluginImport.flatConfigs.recommended,
        languageOptions: {
            sourceType: 'module',
            ecmaVersion: 'latest',
            globals: {
                ...globals.browser,
                // window: 'writable', // allow setting window global properties
                jQuery: 'readonly',
                RED: 'readonly',
                uibuilder: 'writable',
                $: 'readonly',
                $$: 'readonly',
                // console: 'readonly',
            },
        },
        linterOptions: {
            reportUnusedInlineConfigs: 'error',
        },
        plugins: {
            'js': js,
            'pluginPromise': pluginPromise,
            'pluginImport': pluginImport,
            'jsdoc': jsdoc,
            '@stylistic': stylistic,
        },
        extends: [
            js.configs.recommended,
            jsdoc.configs['flat/recommended'],
            stylistic.configs.recommended,
            pluginPromise.configs['flat/recommended'],
            pluginImport.flatConfigs.recommended,
        ],
        settings: {
            jsdoc: { mode: 'jsdoc', },
        },
        rules: {
            ...jsdocRules,
            ...stylisticRules,
            ...generalRules,
            // 'no-empty': ['error', { 'allowEmptyCatch': true }],
        },

    },

    // Node.js (v18) CommonJS, no-build
    {
        // files: nodeCJS,
        files: ['**/*.{js,cjs}'],
        ignores: ['resources/*.*', 'src/front-end-modules/**/*.*', 'src/components/**/*.*'],
        languageOptions: {
            sourceType: 'commonjs',
            // Will be overridden by the n plugin which detects the correct node.js version from package.json
            ecmaVersion: 'latest',
            // Node.js globals are provided by the n plugin
            // globals: globals.browser,
        },
        linterOptions: {
            reportUnusedInlineConfigs: 'error',
        },
        plugins: {
            'js': js,
            'pluginImport': pluginImport,
            'pluginPromise': pluginPromise,
            'jsdoc': jsdoc,
            '@stylistic': stylistic,
            'n': node, // <= n/node
        },
        extends: [
            js.configs.recommended,
            jsdoc.configs['flat/recommended'],
            stylistic.configs.recommended,
            pluginPromise.configs['flat/recommended'],
            node.configs['flat/recommended-script'], // <= script/commonjs
        ],
        settings: {
            jsdoc: { mode: 'jsdoc', },
            // Better to pick up from package.json unless needing to override
            // package.json is restricted to >=v18 to match Node-RED. We want at least v18.4
            node: { version: '18.4.0', },
        },
        rules: {
            ...jsdocRules,
            ...stylisticRules,
            ...generalRules,
        },
    },

    // Node.js (LTS) ESM, ESBUILD
    {
        // files: nodeMJS,
        files: ['**/*.mjs'],
        ignores: ['resources/*.{js,cjs,mjs}', 'src/front-end-module/**/*.*', 'src/components/**/*.*'],
        languageOptions: {
            sourceType: 'module',
            // Will be overridden by the n plugin which detects the correct node.js version from package.json
            ecmaVersion: 'latest',
            // Node.js globals are provided by the n plugin
            // globals: globals.browser,
        },
        linterOptions: {
            reportUnusedInlineConfigs: 'error',
        },
        plugins: {
            'js': js,
            'pluginImport': pluginImport,
            'pluginPromise': pluginPromise,
            'jsdoc': jsdoc,
            '@stylistic': stylistic,
            'n': node, // <= n/node
        },
        extends: [
            js.configs.recommended,
            jsdoc.configs['flat/recommended'],
            stylistic.configs.recommended,
            pluginPromise.configs['flat/recommended'],
            pluginImport.flatConfigs.recommended,
            node.configs['flat/recommended-module'], // <= module/ESM
        ],
        settings: {
            jsdoc: { mode: 'jsdoc', },
            // Override for node.js current LTS (assuming the use of ESBUILD)
            // Better to pick up from package.json unless needing to override
            node: { version: 'lts', },
        },
        rules: {
            ...jsdocRules,
            ...stylisticRules,
            ...generalRules,
            'n/no-unsupported-features/es-syntax': 'off', // Allow all modern ESM features
            'n/no-missing-import': 'error',
            'n/no-process-exit': 'warn',
        },
    },
])

You will need to adjust for your own use of course.

You might want to look in the uibuilder GitHub repo to see some other stuff because this is already rather long. Notable is the use of various .d.ts files even though I don't use (or personally like) TypeScript, they are still useful for taming TS error messages.

In particular, I have a typedefs.js file where I use JSDoc style object definitions for my node properties.

You don't have to do all this at once of course, I've built this up over a decade now. It is probably more complex than it needs to be.

Some other things I would recommend, especially if you are getting into more complex nodes:

  • Name js files as *.cjs or *.mjs as appropriate. This helps a lot in taming ESLINT and will also help as node-red eventually starts to migrate to ES Modules as must happen eventually.
  • I find working in a node's html file with everything bundled together as per the node-red example and many nodes, to be a nightmare. And it really isn't necessary any more. See uibuilder or my experimental nodes to see how. Basically, the Editor help goes into a locale file, the JavaScript goes into a file in the resources folder that node-red automatically mounts and is simply linked in the html file. That leaves just the HTML for the Editor config panel which is manageable. This is much easier as your nodes start to get more complex.
  • I already mentioned some other stuff such as loading common code/styles using plugins.

OK, that's more than enough waffle I think. I probably should write all of this up somewhere. :smiley:

Thanks for your feedback.
Yesterday I implemented a simple Pyhton node ( background404/node-red-contrib-python-venv) demo which leverage my Python libs with Serial/UART support.
However for me it's not clear how assign an object to the message payload msg['payload'] = device.dp_info

async def test_read_project_info(): 
    proto = MyProtocol()
    serial_transport = SerialSenderReceiverTransport(port="/dev/ttyUSB0", baudrate=19200, rx_timeout=5)

    device = Thing(address=0x06, transport=serial_transport, protocol=proto, name="SOFC1")
    await device.open()


    await device.read_project_description()
    await device.read_dp_info()
    
    msg['payload'] = device.dp_info
    await device.close()

#Execute async function
asyncio.run(test_sofc_read_project_info())

According to the readme there are examples included, which you should be able to import using Import > Examples. However, looking at the readme it appears that anything that you print is sent via msg.payload.

I tried to convert my device.dp_info object to JSON (using jsons.dump) however every obj is printed as string type.
how to pass msg data as object rather than string?

You probably can't as, I assume, print can only print strings. Just add a json node after it to convert it to an object.

thanks for the hint with the json node!
I have to dump the string this way before print:

def return_msg():
    print(json.dumps(msg["payload"]))

Works now!