With the retirement of Dash1.0, I wanted to continue seeing the RTSP feed from my IP cameras in Dash2.0. While waiting for an "official" node from @kevigodell, I propose a Template node (DB2) which can stream my local RTSP IP cameras.
You need to install Kevin's nodes:
(or use the Palette).
[
{
"id": "022949e53279a741",
"type": "inject",
"z": "fe2c7bc6.bd49c8",
"name": "stop default",
"props": [
{
"p": "action",
"v": "{\"command\":\"stop\"}",
"vt": "json"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": 0.1,
"topic": "",
"x": 5840,
"y": 5050,
"wires": [
[
"d4b659133c509c6b"
]
]
},
{
"id": "561cfc07f7e0c0da",
"type": "ui-button",
"z": "fe2c7bc6.bd49c8",
"group": "5496b19a4e084472",
"name": "refresh noire",
"label": "",
"order": 1,
"width": "1",
"height": "1",
"emulateClick": false,
"tooltip": "",
"color": "",
"bgcolor": "",
"className": "",
"icon": "refresh",
"iconPosition": "left",
"payload": "",
"payloadType": "str",
"topic": "",
"topicType": "str",
"buttonColor": "",
"textColor": "",
"iconColor": "",
"enableClick": true,
"enablePointerdown": false,
"pointerdownPayload": "",
"pointerdownPayloadType": "str",
"enablePointerup": false,
"pointerupPayload": "",
"pointerupPayloadType": "str",
"x": 5820,
"y": 5080,
"wires": [
[
"3fedb0188197880b"
]
]
},
{
"id": "e11dd7afe2f06243",
"type": "inject",
"z": "fe2c7bc6.bd49c8",
"name": "restart",
"props": [
{
"p": "payload"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": 0.1,
"topic": "",
"payload": "",
"payloadType": "date",
"x": 5820,
"y": 5110,
"wires": [
[
"3fedb0188197880b"
]
]
},
{
"id": "3fedb0188197880b",
"type": "change",
"z": "fe2c7bc6.bd49c8",
"name": "restart noire",
"rules": [
{
"t": "set",
"p": "action",
"pt": "msg",
"to": "{\"command\":\"restart\",\"args\":[\"-loglevel\",\"quiet\",\"-rtsp_transport\",\"tcp\",\"-i\",\"rtsp://192.168.1.12:554/12\",\"-c:a\",\"aac\",\"-c:v\",\"copy\",\"-f\",\"mp4\",\"-movflags\",\"+frag_every_frame+empty_moov+default_base_moof\",\"-min_frag_duration\",\"500000\",\"pipe:1\",\"-progress\",\"pipe:2\"]}",
"tot": "json"
}
],
"action": "",
"property": "",
"from": "",
"to": "",
"reg": false,
"x": 5960,
"y": 5110,
"wires": [
[
"d4b659133c509c6b"
]
]
},
{
"id": "d4b659133c509c6b",
"type": "ffmpeg",
"z": "fe2c7bc6.bd49c8",
"name": "",
"outputs": 3,
"cmdPath": "ffmpeg",
"cmdArgs": "[\"-version\"]",
"cmdOutputs": 2,
"killSignal": "SIGTERM",
"x": 6080,
"y": 5070,
"wires": [
[
"85f4d4cff74d42d3"
],
[
"85f4d4cff74d42d3"
],
[
"6b4334e0a2e9017a"
]
]
},
{
"id": "85f4d4cff74d42d3",
"type": "mp4frag",
"z": "fe2c7bc6.bd49c8",
"name": "testdash2noire",
"outputs": 2,
"basePath": "testdash2noire",
"serveHttp": "true",
"serveIo": "true",
"hlsPlaylistSize": "3",
"hlsPlaylistExtra": "1",
"bufferPool": 0,
"autoStart": "false",
"preBuffer": 1,
"timeLimit": "2000",
"repeated": "false",
"statusData": "playlist",
"x": 6260,
"y": 5030,
"wires": [
[
"13352f64ced5b279"
],
[]
]
},
{
"id": "6b4334e0a2e9017a",
"type": "function",
"z": "fe2c7bc6.bd49c8",
"name": "progress",
"func": "const props = msg.payload.toString().split('\\n');\nconst progress = {};\nprops.forEach(item => {\n const [name, value] = item.split('=');\n if (name && value) {\n progress[name] = value;\n }\n});\n\nconst color = progress['progress'] === 'continue' ? 'green' : 'red';\nconst message = `fps: ${progress['fps']}, bitrate: ${progress['bitrate']}`;\nnode.status({ fill: color, shape: 'dot', text: message });\nreturn { payload: progress };",
"outputs": 1,
"timeout": "",
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 6260,
"y": 5080,
"wires": [
[
"6f546b3baf7c9379"
]
]
},
{
"id": "6f546b3baf7c9379",
"type": "change",
"z": "fe2c7bc6.bd49c8",
"name": "",
"rules": [
{
"t": "change",
"p": "payload.bitrate",
"pt": "msg",
"from": "kbits/s",
"fromt": "str",
"to": "",
"tot": "str"
},
{
"t": "change",
"p": "payload.bitrate",
"pt": "msg",
"from": " ",
"fromt": "str",
"to": "",
"tot": "str"
},
{
"t": "set",
"p": "payload.bitrate",
"pt": "msg",
"to": "$number(payload.bitrate)",
"tot": "jsonata"
},
{
"t": "set",
"p": "payload.bitrate",
"pt": "msg",
"to": "$round(payload.bitrate/8,1)",
"tot": "jsonata"
}
],
"action": "",
"property": "",
"from": "",
"to": "",
"reg": false,
"x": 6375,
"y": 5080,
"wires": [
[
"3f174e338ae9c1fa"
]
],
"l": false
},
{
"id": "3f174e338ae9c1fa",
"type": "function",
"z": "fe2c7bc6.bd49c8",
"name": "adapt text //1",
"func": "//1.corbug erreur au demarrage\n\n// 0. SÉCURITÉ : Si payload n'existe pas ou n'est pas un objet, on quitte pour éviter le crash au Ctrl+F5\nif (!msg || !msg.payload || typeof msg.payload !== 'object') {\n return null; // Stoppe le traitement de ce message fantôme\n}\n\n// 1. Récupérer et formater la date/heure\nconst timestamp = new Date();\nconst heureLocale = timestamp.toLocaleString('fr-FR', {\n timeZone: 'Europe/Paris',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n day: '2-digit',\n month: '2-digit'\n});\nconst [date, heure] = heureLocale.split(' ');\nconst datetime = `${heure}<br>${date}`;\n\n// 2. Gestion du LABEL (Partie gauche - Corrigé ici)\nconst labelText = msg.payload.bitrate !== undefined ? msg.payload.bitrate : 0;\nmsg.ui_update = {\n label: `<div style='font-size: 12px; line-height: 1.2; color: #EEE;'>${labelText} KB/s<br><span style='color: grey;'>${datetime}</span></div>`,\n fontSize: \"16px\"\n};\n\n// 3. Gestion du PAYLOAD (Partie droite)\nconst valeurBrute = msg.payload.fps !== undefined ? msg.payload.fps : 0;\nmsg.payload = `<div style=\"color: #0da3d5; font-weight: bold; font-size: 15px; text-align: right;\">\n ${valeurBrute} \n <span style=\\\"font-size: 10px; color: white;\\\">fps</span>\n</div>`;\n\nreturn msg;",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 6480,
"y": 5080,
"wires": [
[
"6c1db2e2510998ec"
]
]
},
{
"id": "13352f64ced5b279",
"type": "ui-template",
"z": "fe2c7bc6.bd49c8",
"group": "5496b19a4e084472",
"page": "",
"ui": "",
"name": "noire socket.io //2",
"order": 3,
"width": "6",
"height": "6",
"head": "",
"format": "\n\n<template>\n <div ref=\"containerEl\"\n style=\"width:100%; height:100%; background:#000; position:relative; overflow:hidden; border-radius:4px; display:flex; align-items:center; justify-content:center;\">\n <video ref=\"videoEl\" autoplay muted playsinline\n style=\"width:100%; display:block; max-height:100%;\"></video>\n\n \n <div class=\"ptz-custom-controls\">\n \n <button @click.stop=\"toggleMute\" class=\"ptz-btn\" :title=\"isMuted ? 'Activer le son' : 'Couper le son'\">\n <svg v-if=\"isMuted\" viewBox=\"0 0 24 24\" class=\"ptz-icon\"><path fill=\"currentColor\" d=\"M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.21.05-.42.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z\"/></svg>\n <svg v-else viewBox=\"0 0 24 24\" class=\"ptz-icon\"><path fill=\"currentColor\" d=\"M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z\"/></svg>\n </button>\n\n \n <button @click.stop=\"togglePiP\" class=\"ptz-btn\" title=\"Mode incrustation (PiP)\">\n <svg viewBox=\"0 0 24 24\" class=\"ptz-icon\"><path fill=\"currentColor\" d=\"M19 11h-8v6h8v-6zm4 8V5c0-1.1-.9-2-2-2H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2zm-2 0H3V5h18v14z\"/></svg>\n </button>\n\n \n <button @click.stop=\"toggleFullscreen\" class=\"ptz-btn\" title=\"Plein écran\">\n <svg viewBox=\"0 0 24 24\" class=\"ptz-icon\"><path fill=\"currentColor\" d=\"M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z\"/></svg>\n </button>\n </div>\n\n <div v-if=\"shouldShowStatus\" :style=\"statusStyle\">\n {{ status }}\n </div>\n </div>\n</template>\n\n<style>\n .ptz-custom-controls {\n position: absolute;\n bottom: 8px;\n right: 8px;\n display: flex;\n gap: 6px;\n z-index: 20;\n opacity: 0.2;\n transition: opacity 0.3s ease;\n }\n\n .ptz-custom-controls:hover {\n opacity: 1;\n }\n\n .ptz-btn {\n background: rgba(0, 0, 0, 0.75);\n border: 1px solid rgba(255, 255, 255, 0.2);\n color: #ffffff;\n border-radius: 6px;\n width: 32px;\n height: 32px;\n cursor: pointer;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 0;\n transition: background 0.2s, border-color 0.2s;\n }\n\n .ptz-btn:hover {\n background: rgba(13, 163, 213, 0.9);\n border-color: #0da3d5;\n }\n\n .ptz-icon {\n width: 18px;\n height: 18px;\n }\n</style>\n\n<script>\nexport default {\n name: 'UiMp4FragSocketIoFixe',\n\n data() {\n return {\n status: 'Initialisation...',\n isError: false,\n isStoppedVoluntarily: false,\n isPlayingNormally: false,\n isMuted: true,\n socketIoConfig: null\n }\n },\n\n computed: {\n shouldShowStatus() {\n return !this.isPlayingNormally;\n },\n\n statusStyle() {\n return {\n position: 'absolute', top: '8px', left: '8px',\n color: '#ffffff', fontSize: '11px',\n fontFamily: 'monospace', pointerEvents: 'none',\n zIndex: 10\n }\n }\n },\n\n watch: {\n msg: {\n handler(newMsg) {\n if (!newMsg) return;\n if (newMsg.status === 'reset' || newMsg.payload === '') {\n this.handleVoluntaryStop();\n } else if (newMsg.status === 'playlist' && newMsg.payload?.socketIo) {\n this.socketIoConfig = newMsg.payload.socketIo;\n this.isStoppedVoluntarily = false;\n this.initStream();\n }\n },\n immediate: true,\n deep: true\n }\n },\n\n mounted() {\n this.$nextTick(() => {\n this.startVisibilityWatcher();\n });\n },\n\n unmounted() {\n this.cleanup();\n if (this._onVisibilityChange) {\n document.removeEventListener('visibilitychange', this._onVisibilityChange);\n }\n if (this._intersectionObserver) {\n this._intersectionObserver.disconnect();\n }\n },\n\n methods: {\n // --- BOUTONS ---\n toggleMute() {\n const video = this.$refs.videoEl;\n if (!video) return;\n video.muted = !video.muted;\n this.isMuted = video.muted;\n },\n\n toggleFullscreen() {\n const container = this.$refs.containerEl;\n if (!container) return;\n if (!document.fullscreenElement && !document.webkitFullscreenElement) {\n (container.requestFullscreen || container.webkitRequestFullscreen).call(container);\n } else {\n (document.exitFullscreen || document.webkitExitFullscreen).call(document);\n }\n },\n\n togglePiP() {\n const video = this.$refs.videoEl;\n if (!video) return;\n if (document.pictureInPictureElement) document.exitPictureInPicture();\n else if (video.requestPictureInPicture) video.requestPictureInPicture();\n },\n\n // --- STREAM SOCKET.IO ---\n initStream() {\n if (this.isStoppedVoluntarily || !this.socketIoConfig) return;\n const video = this.$refs.videoEl;\n if (!video) return;\n\n if (window.io) {\n this.setupSocketPlayer(video);\n } else if (!document.getElementById('socketio-cdn-script')) {\n const script = document.createElement('script');\n script.id = 'socketio-cdn-script';\n script.src = 'https://cdn.socket.io/4.7.5/socket.io.min.js';\n script.onload = () => this.setupSocketPlayer(video);\n document.head.appendChild(script);\n }\n },\n\n setupSocketPlayer(video) {\n this.cleanup();\n this.status = 'Connexion...';\n this.isPlayingNormally = false;\n\n const { path, namespace, key } = this.socketIoConfig;\n\n const socket = window.io(window.location.origin + namespace, {\n path: path,\n transports: ['websocket'],\n forceNew: true,\n auth: { key: key }\n });\n this._socket = socket;\n\n let sourceBuffer = null;\n let lastTimestamp = -1;\n const queue = [];\n\n const appendNext = () => {\n if (sourceBuffer && !sourceBuffer.updating && queue.length > 0) {\n try { sourceBuffer.appendBuffer(queue.shift()); }\n catch(e) { console.warn('appendBuffer:', e); }\n }\n };\n\n socket.on('auth', (authorized) => {\n if (!authorized) return;\n socket.emit('mime');\n socket.emit('auth', { key: key });\n });\n\n socket.on('mime', (mimeCodecs) => {\n if (!this._mediaSource) return;\n const trySetup = () => {\n if (this._mediaSource.readyState === 'open') {\n try {\n sourceBuffer = this._mediaSource.addSourceBuffer(mimeCodecs);\n sourceBuffer.mode = 'sequence';\n sourceBuffer.addEventListener('updateend', appendNext);\n socket.emit('initialization');\n } catch(e) { console.error('SourceBuffer:', e); }\n } else {\n setTimeout(trySetup, 50);\n }\n };\n trySetup();\n });\n\n socket.on('initialization', (data) => {\n const buf = data instanceof ArrayBuffer ? data\n : data?.segment ? new Uint8Array(data.segment).buffer\n : new Uint8Array(data).buffer;\n queue.push(buf);\n appendNext();\n socket.emit('segment', { buffered: false, all: false });\n });\n\n socket.on('segment', (data) => {\n const seg = data instanceof ArrayBuffer ? data\n : data?.segment ? new Uint8Array(data.segment).buffer\n : null;\n\n if (data?.timestamp !== undefined) lastTimestamp = data.timestamp;\n\n if (seg) {\n queue.push(seg);\n appendNext();\n }\n\n if (!this.isPlayingNormally) {\n video.muted = this.isMuted;\n video.play().then(() => {\n this.isPlayingNormally = true;\n this.isError = false;\n }).catch(e => {});\n }\n\n socket.emit('segment', { timestamp: lastTimestamp });\n });\n\n socket.on('mp4frag_error', (err) => {\n this.isError = true;\n this.status = 'Erreur : ' + (err?.message || err);\n });\n\n socket.on('connect', () => { this.isError = false; });\n\n socket.on('connect_error', (err) => {\n this.isError = true;\n this.status = 'Erreur socket';\n });\n\n socket.on('disconnect', () => {\n if (!this.isStoppedVoluntarily) {\n this.isPlayingNormally = false;\n this.status = 'Reconnexion...';\n }\n });\n\n const mediaSource = new MediaSource();\n this._mediaSource = mediaSource;\n const objectUrl = URL.createObjectURL(mediaSource);\n video.src = objectUrl;\n mediaSource.addEventListener('sourceopen', () => URL.revokeObjectURL(objectUrl));\n },\n\n handleVoluntaryStop() {\n this.isStoppedVoluntarily = true;\n this.isPlayingNormally = false;\n this.isError = false;\n this.status = 'Flux arrêté';\n this.cleanup();\n },\n\n cleanup() {\n if (this._socket) {\n this._socket.disconnect();\n this._socket = null;\n }\n if (this._mediaSource && this._mediaSource.readyState === 'open') {\n try { this._mediaSource.endOfStream(); } catch(e) {}\n }\n this._mediaSource = null;\n const video = this.$refs.videoEl;\n if (video) {\n video.pause();\n video.src = '';\n video.load();\n }\n },\n\n // --- VISIBILITY WATCHER ---\n startVisibilityWatcher() {\n this._onVisibilityChange = () => {\n if (document.hidden) this._pauseForVisibility();\n else this._resumeForVisibility();\n };\n document.addEventListener('visibilitychange', this._onVisibilityChange);\n\n const video = this.$refs.videoEl;\n if (video) {\n this._intersectionObserver = new IntersectionObserver(\n (entries) => {\n if (entries[0].intersectionRatio < 0.5) this._pauseForVisibility();\n else this._resumeForVisibility();\n },\n { threshold: [0, 0.5, 1.0] }\n );\n this._intersectionObserver.observe(video);\n }\n },\n\n _pauseForVisibility() {\n if (this.isStoppedVoluntarily || this._pausedForVisibility) return;\n this._pausedForVisibility = true;\n const video = this.$refs.videoEl;\n if (video) video.pause();\n this.isPlayingNormally = false;\n this.status = 'Pause...';\n },\n\n _resumeForVisibility() {\n if (!this._pausedForVisibility || this.isStoppedVoluntarily) return;\n this._pausedForVisibility = false;\n const video = this.$refs.videoEl;\n if (!video) return;\n video.play().then(() => {\n this.isPlayingNormally = true;\n }).catch(e => console.log('Resume:', e));\n }\n }\n}\n</script>",
"storeOutMessages": true,
"passthru": false,
"resendOnRefresh": true,
"templateScope": "local",
"className": "",
"x": 6590,
"y": 5050,
"wires": [
[]
]
},
{
"id": "6c1db2e2510998ec",
"type": "ui-text",
"z": "fe2c7bc6.bd49c8",
"group": "5496b19a4e084472",
"order": 4,
"width": "3",
"height": "1",
"name": "noire fps",
"label": "",
"format": "{{msg.payload}}",
"layout": "row-left",
"style": false,
"font": "Arial,Arial,Helvetica,sans-serif",
"fontSize": "12",
"color": "#ffffff",
"wrapText": false,
"className": "",
"value": "payload",
"valueType": "msg",
"x": 6620,
"y": 5080,
"wires": []
},
{
"id": "e862467bbd11be70",
"type": "ui-button",
"z": "fe2c7bc6.bd49c8",
"group": "5496b19a4e084472",
"name": "stop noire",
"label": "",
"order": 2,
"width": "1",
"height": "1",
"emulateClick": false,
"tooltip": "",
"color": "",
"bgcolor": "",
"className": "",
"icon": "stop",
"iconPosition": "left",
"payload": "",
"payloadType": "str",
"topic": "",
"topicType": "str",
"buttonColor": "",
"textColor": "",
"iconColor": "",
"enableClick": true,
"enablePointerdown": false,
"pointerdownPayload": "",
"pointerdownPayloadType": "str",
"enablePointerup": false,
"pointerupPayload": "",
"pointerupPayloadType": "str",
"x": 5810,
"y": 5150,
"wires": [
[
"1cc4a0af2f697617"
]
]
},
{
"id": "1cc4a0af2f697617",
"type": "change",
"z": "fe2c7bc6.bd49c8",
"name": "stop noire",
"rules": [
{
"t": "set",
"p": "action",
"pt": "msg",
"to": "{\"command\":\"stop\"}",
"tot": "json"
}
],
"action": "",
"property": "",
"from": "",
"to": "",
"reg": false,
"x": 5970,
"y": 5150,
"wires": [
[
"d4b659133c509c6b"
]
]
},
{
"id": "5496b19a4e084472",
"type": "ui-group",
"name": "noire",
"page": "5e4ae4ec6d0fef71",
"width": 6,
"height": 1,
"order": 1,
"showTitle": true,
"className": "",
"visible": "true",
"disabled": "false",
"groupType": "default"
},
{
"id": "5e4ae4ec6d0fef71",
"type": "ui-page",
"name": "test",
"ui": "3dcb17d3fbb77cb1",
"path": "/page8",
"icon": "home",
"layout": "grid",
"theme": "2da75e915972c00d",
"breakpoints": [
{
"name": "Default",
"px": "0",
"cols": "3"
},
{
"name": "Tablet",
"px": "576",
"cols": "6"
},
{
"name": "Small Desktop",
"px": "768",
"cols": "9"
},
{
"name": "Desktop",
"px": "1024",
"cols": "12"
}
],
"order": 8,
"className": "",
"visible": "true",
"disabled": "false"
},
{
"id": "3dcb17d3fbb77cb1",
"type": "ui-base",
"name": "Page flowfuse",
"path": "/dashboard",
"appIcon": "",
"includeClientData": true,
"acceptsClientConfig": [
"ui-control",
"ui-notification"
],
"showPathInSidebar": false,
"headerContent": "page",
"navigationStyle": "temporary",
"titleBarStyle": "default",
"showReconnectNotification": true,
"notificationDisplayTime": 5,
"showDisconnectNotification": true,
"allowInstall": true
},
{
"id": "2da75e915972c00d",
"type": "ui-theme",
"name": "Theme black",
"colors": {
"surface": "#177278",
"primary": "#0da3d5",
"bgPage": "#111111",
"groupBg": "#333333",
"groupOutline": "#787878"
},
"sizes": {
"density": "default",
"pagePadding": "1px",
"groupGap": "4px",
"groupBorderRadius": "2px",
"widgetGap": "3px"
}
},
{
"id": "3dce48901209b9e9",
"type": "global-config",
"env": [],
"modules": {
"@flowfuse/node-red-dashboard": "1.30.2",
"@kevingodell/node-red-ffmpeg": "0.1.1-beta.3",
"@kevingodell/node-red-mp4frag": "0.1.1-beta.2"
}
}
]
My camera is called "noire" and the dashboard is in the page: test and the group: noire. Personalize to your liking.
In "Restart noire" at the line: "rtsp://192.168.1.12:554/12",
replace what is in the quotes with the address of your IP camera.
The result:
- a stream from the camera with integrated buttons: mute, picture in picture and large screen.
- A stream start/restart and stop button.
- And optional flow rate indications.
As I am not a programmer, but I have ideas
, Obviously, I asked the AI ​​so there are surely imperfections in the Template, but I apologize in advance for not being able to resolve the bugs if there are any.
This Flow works well, I don't have many bugs. It consumes very few resources thanks to the performance of the original nodes from @kevingodell, on a Raspberry PI 3B+, and NR 4.1.11, Raspbian BUSTER, nodejs v18.20.2.
TO DO: upgrade to NR 5.0.1 and nodejs 24, with Raspberry Pi OS (64-bit) (3B compatible)
Enjoy

