20 Übungen
20.1 WarmUp Node-Red
Im Unterricht wird mit der Anwendung Node-Red von IBM gearbeitet. Als WarmUp für ein Grundverständnis dienen folgende Übungen.
Unter jeder Übung wird das Beispiel als JSON angeboten. Dieses kann in Node-RED direkt über “Import” selbst eingefügt werden.
- Node-RED lokal installieren
20.1.1 Datenfluss, MSG und “Hello World”
Node-RED ist eine Javascript Applikation und läuft demnach nach einem eventbasierten Prinzip. Es braucht immer ein Ereignis, um eine Aktion zu starten. Die Übermittlung zwischen Nodes erfolgt dabei ausschliesslich über das “msg”-Objekt. Dies ist quasi der vorgegebene Behälter - Dank Javascript kann aber jeglicher Inhalt in diesem Objekt integriert, modifiziert oder gelöscht werden.
20.1.1.1 Debug und Inject
Injectund Debug sind sehr wichtige Nodes. Während Inject einen Event triggert, dient Debug zur Ausgabe. Die Ausgabe sieht man rechts in der Seitenleiste.
In diesem Beispiel liefert der Inject Node die Textausgabe “Hello FHNW” und gibt dies als Debugmeldung aus. In den Einstellungen des Debug Node sieht man msg.payload. Payload ist das standard Property, in dem Information abgelegt wird. Das gleiche findet man im Inject-Node - Die Information “Hello FHNW” wird auf msg.payload geschrieben. Man könnte aber auch jedes andere Property verwenden.
[
{
"id": "7269be7ffde9b9de",
"type": "tab",
"label": "Flow 1",
"disabled": false,
"info": "",
"env": []
},
{
"id": "cfcabcb967783435",
"type": "inject",
"z": "7269be7ffde9b9de",
"name": "",
"props": [
{
"p": "payload"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": 0.1,
"topic": "",
"payload": "Hello FHNW",
"payloadType": "str",
"x": 270,
"y": 300,
"wires": [
[
"5c23a9685d47fc80"
]
]
},
{
"id": "5c23a9685d47fc80",
"type": "debug",
"z": "7269be7ffde9b9de",
"name": "Debug Output",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "payload",
"targetType": "msg",
"statusVal": "",
"statusType": "auto",
"x": 500,
"y": 300,
"wires": []
},
{
"id": "cba4087b76b9f584",
"type": "inject",
"z": "7269be7ffde9b9de",
"name": "",
"props": [
{
"p": "meinedaten",
"v": "Hello FHNW",
"vt": "str"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": 0.1,
"topic": "",
"x": 250,
"y": 400,
"wires": [
[
"2db7ed4b6aac420a"
]
]
},
{
"id": "2db7ed4b6aac420a",
"type": "debug",
"z": "7269be7ffde9b9de",
"name": "Debug Output",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "meinedaten",
"targetType": "msg",
"statusVal": "",
"statusType": "auto",
"x": 500,
"y": 400,
"wires": []
}
]20.1.1.2 Function
Der Function Node dient direkt zur Javascript Programmierung. Für gängige Situationen gibt es vordefinierte Nodes wie Switch als IF-Bedingung, Changezum setzen von Werten. Sehr oft möchte man aber noch irgendwelche kleine Anpassungen, weshalb dies oft direkt im Function Node gemacht wird.
In diesem Beispel wird zwischen den vorher erstellen Inject und Debug Node, ein Function Node integriert. Nun versuchen wir den Inhalt von Inject zu überschreiben und im Debug Node auszugeben:
msg.payload = 'Ich bin es!';
return msg[
{
"id": "a658563152738971",
"type": "tab",
"label": "Flow 1",
"disabled": false,
"info": "",
"env": []
},
{
"id": "1b727cb006448ea7",
"type": "inject",
"z": "a658563152738971",
"name": "",
"props": [
{
"p": "payload"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": 0.1,
"topic": "",
"payload": "Hello FHNW",
"payloadType": "str",
"x": 270,
"y": 300,
"wires": [
[
"4aa085a111c164ea"
]
]
},
{
"id": "bc03bca765855d6a",
"type": "debug",
"z": "a658563152738971",
"name": "Debug Output",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "payload",
"targetType": "msg",
"statusVal": "",
"statusType": "auto",
"x": 660,
"y": 300,
"wires": []
},
{
"id": "1e8c86874a13dd96",
"type": "inject",
"z": "a658563152738971",
"name": "",
"props": [
{
"p": "meinedaten",
"v": "Hello FHNW",
"vt": "str"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": 0.1,
"topic": "",
"x": 250,
"y": 400,
"wires": [
[
"a42ee7acae05f447"
]
]
},
{
"id": "a42ee7acae05f447",
"type": "debug",
"z": "a658563152738971",
"name": "Debug Output",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "meinedaten",
"targetType": "msg",
"statusVal": "",
"statusType": "auto",
"x": 500,
"y": 400,
"wires": []
},
{
"id": "4aa085a111c164ea",
"type": "function",
"z": "a658563152738971",
"name": "Overrite",
"func": "msg.payload = 'Ich bin es!';\nreturn msg;",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 460,
"y": 300,
"wires": [
[
"bc03bca765855d6a"
]
]
}
]20.1.2 Daten Zwischenspeichern
Zur Entkopplung von Prozessen kann es vorkommen, das man Daten zwischenspeichern muss. Als Beispiel: Über einen tcp in Node werden Messwerte empfangen. Immer nach 10 Messwerten soll der Durchschnitt gebildet werden. Am einfachsten speichert man demnach die Werte in einer Variable und liest sie aus, wenn sie benötigt werden.
Dafür stehen in Node-Red 3 Speicherbereiche zur Verfügung: - Node (Daten im Node speichern) - Flow (Daten im aktuellen Flow speichern) - Global (Daten flow übergreifend speichern)
In den meisten Nodes kann ausgewählt werden, ob die Daten aus msg oder aber auch aus flowoder auch global kommen. Als Beispiel kann in changedirekt .flow ausgewählt werden. Genauso kann aber auch in einem Skript darauf zugegriffen werden.
20.1.2.1 Daten ansehen
In der gleichen Seitenleiste wie Debug findet man Kontextdaten. Darin können die Daten jederzeit eingesehen werden.
20.1.2.2 Daten speichern
Beispiel zur Speicherung in einem function node. Der erste Wert ist der Variablenname und der zweite der Wert. :
flow.set('myname', 3)20.1.2.3 Daten auslesen
Beispiel um Daten aus einem flow mit einem function node auszulesen. Ist die Variable value verfügbar, wird diese genutzt, ansonsten der Wert 10.
let value = flow.get("value") ?? 10;20.1.3 Debug Log
Möchte man Code nachvollziehen kann das integrierte Log genutzt werden:
- node.log(“Ausgabe einer Infomeldung”)
- node.warn(“Ausgabe einer Warnung”)
- node.error(“Ausgabe eines Fehlers”)
node.log('Funktioniert!')Den Output sieht man im Terminalfenster von Node-RED oder in den Debug Tools des Webbrowsers (F12).
20.1.4 Objekte und JSON
JSON ist das String Datenformat von JavaScript um Daten auszutauschen. Das funktioniert in Javascript sehr einfach, wenn man mal ein Objekt hat. Aus diesem Grund wird in Node-RED empfohlen, Daten immer in ein Objekt zu packen. Alles andere geht dann sehr einfach. Mehr Details zu den Zusammenhängen Section 15.1.
Ein Javascript Objekt:
{
"Name" : "Peter",
1 : 55,
2: "Auto",
true : "xy"
}Verwendet man den json Node, wird daraus direkt: Ein JSON Objekt:
{
"Name" : "Peter",
"1" : 55,
"2" : "Auto",
"true" : "xy"
}20.1.5 Zusammen machts mehr Spass - TCP
In dieser Übung möchten wir eine einfache Kommunikationsübung machen. Ziel ist neben der Vertiefung von Node-RED auch die Kentnisse in der Netzwerktechnik aufzufrischen.
Node-RED starten und einen neuen Flow anlegen
Fügen wir einen
Inject Node(Sender) und einenDebug Node(Empfänger) hinzu(Nicht verbinden).Anpassen des
Inject Node, so dass ein Text an denDebug Nodegesendet wird.Nun möchten wir diese Verbindung über das Netzwerk machen. Dazu fügen wir einen
tcp out Node(Client) hinter demInject Nodehinzu und einentcp in Node(Server) vor denDebug Node.Konfigurieren wir dem Server den
7500und passen den Client entsprechend an.Deployen wir nun den Code, sehen wir unter den TCP Bausteinen ein “verbunden”
Klicken wir nun auf den
Inject Node, sollte die Nachricht über das Netzwerk, an denDebug Nodegeschickt werden.Voila! Machen sie das mit ihrem Nachbarn. Es soll möglich sein, Meldungen an den Nachbar und umgekehrt zu senden (Achtung: An die Firewall denken :)
TipFragen- Wann kommt die Firewall ins Spiel?
- Was genau war localhost?
- Was ist 127.0.0.1?
- Was muss ich bei den Ports bis 1024 aufpassen?
20.2 Warmup Docker
Kleines Warmup um mit Docker zu Arbeiten. Ein paar Beispiele mit Node-RED. Für Details bitte ins Kapitel “Docker” schauen.
Docker mit Container im Vordergrund - Nur Darstellung, kein Terminal.
docker run nodered/node-redDocker im Hintergrund mit
-d:docker run -d nodered/node-redDocker mit Container interaktiv mit Terminal.
docker run -it nodered/node-redContainer mit Port veröffentlichen
docker run -d -p 1880:1880 nodered/node-redContainer stoppen
docker stop <id> / <name>
20.3 PubSub und P2P
Im folgenden Übungen zu Publish & Subscribe und Point-to-Point.
20.3.1 Hello MQTT
Probieren geht über studieren! Versuchen wir MQTT zu nutzen.
- Nutzen wir einen MQTT-Client z.B. MqttExplorer
- Verbinden wir uns mit dem FHNW Broker (Host:
lab.dev.fhnw.ch, Port:50100) - Nutzen wir unseren Namen als Topic und versuchen etwas zu publizieren
- Sehen wir auch die Meldungen der anderen?
20.3.2 MQTT für jeden Zweck
MQTT ist sehr verbreitet und daher existiert für nahezu jede Programmiersprache eine Implementierung. Schauen wir es uns in Node-Red an.
- Importieren wir den nachfolgenden Flow
- Triggern wir den Inject Node erhalten wir Daten - Sehr einfach!
- Empfangen geht sehr einfach, aber wie könnten wir eine Meldung zurück an den Sensor schicken?
- Nehmen wir an, wir möchten per MQTT den Sensor Enablen or Disablen. Wie machen wir das?
[
{
"id": "0297458184f4e696",
"type": "tab",
"label": "Flow 2",
"disabled": false,
"info": "",
"env": []
},
{
"id": "ade12a2d2ad07183",
"type": "mqtt in",
"z": "0297458184f4e696",
"name": "",
"topic": "veri/sensor42/#",
"qos": "2",
"datatype": "auto-detect",
"broker": "bfeb88ed611c6ecd",
"nl": false,
"rap": true,
"rh": 0,
"inputs": 0,
"x": 240,
"y": 420,
"wires": [
[
"091333f9f3ad40a8"
]
]
},
{
"id": "5fd3dd1b1b81c218",
"type": "mqtt out",
"z": "0297458184f4e696",
"name": "",
"topic": "veri/sensor42",
"qos": "",
"retain": "",
"respTopic": "",
"contentType": "",
"userProps": "",
"correl": "",
"expiry": "",
"broker": "bfeb88ed611c6ecd",
"x": 580,
"y": 300,
"wires": []
},
{
"id": "091333f9f3ad40a8",
"type": "debug",
"z": "0297458184f4e696",
"name": "Empfang",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"statusVal": "",
"statusType": "auto",
"x": 460,
"y": 420,
"wires": []
},
{
"id": "2c43a37e717ef0f4",
"type": "inject",
"z": "0297458184f4e696",
"name": "Send",
"props": [],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": 0.1,
"topic": "",
"x": 230,
"y": 300,
"wires": [
[
"235feace5dac26b1"
]
]
},
{
"id": "235feace5dac26b1",
"type": "function",
"z": "0297458184f4e696",
"name": "SensorDaten",
"func": "msg.payload = {\n \"asset\" : \"TemperaturSensor\",\n \"value\" : 37,\n \"unit\" : \"°C\",\n \"quality\" : \"Good\",\n \"timestamp\" : \"...\"\n}\nreturn msg;",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 390,
"y": 300,
"wires": [
[
"5fd3dd1b1b81c218"
]
]
},
{
"id": "a3857741f5cab04f",
"type": "comment",
"z": "0297458184f4e696",
"name": "Simuliert einen Sensor",
"info": "",
"x": 260,
"y": 260,
"wires": []
},
{
"id": "0ff719c1e3351d58",
"type": "comment",
"z": "0297458184f4e696",
"name": "Empfang",
"info": "",
"x": 220,
"y": 380,
"wires": []
},
{
"id": "bfeb88ed611c6ecd",
"type": "mqtt-broker",
"name": "",
"broker": "lab.dev.fhnw.ch",
"port": "50100",
"clientid": "",
"autoConnect": true,
"usetls": false,
"protocolVersion": 4,
"keepalive": 60,
"cleansession": true,
"autoUnsubscribe": true,
"birthTopic": "",
"birthQos": "0",
"birthRetain": "false",
"birthPayload": "",
"birthMsg": {},
"closeTopic": "",
"closeQos": "0",
"closeRetain": "false",
"closePayload": "",
"closeMsg": {},
"willTopic": "",
"willQos": "0",
"willRetain": "false",
"willPayload": "",
"willMsg": {},
"userProps": "",
"sessionExpiry": ""
}
]20.3.3 OpcUA als Alternative
Schauen wir uns eine Möglichkeit mit OPC UA an. Weitere Node-Ids in der SensorHub Beschreibung
Installieren wir die Palette
node-red-contrib-opcua(Dauert einen Moment)Aktualisieren wir
veri-sensor_hub:docker pull cr.gitlab.fhnw.ch/ioflow/images/veri-sensor-hub:mainStarten wir das Image
veri-sensor-hub:docker run --rm -p 5001-5005:5001-5005 -p 4840:4840 cr.gitlab.fhnw.ch/ioflow/images/veri-sensor-hub:mainImportieren wir den nachfolgenden Flow - Dieser verbindet sich über
opc.tcp://127.0.0.1:4840mit dem OPC UA Server imveri-sensor-hub:Wir lesen den Distanzwert mit der Node-ID
ns=2;s=SensorHub.DistanceSensor.DistanceAusserdem können wir den Sensor Enablen und Disablen über die Node-ID
ns=2;s=SensorHub.DistanceSensor.EnabledEinzig die Node-ID’s müssen wir wissen - Damit können wir gekoppelt steuern und haben gleichzeitig Verbindungsüberwachung, Datentypen usw.
[
{
"id": "d2e711e931682cc6",
"type": "tab",
"label": "Flow 3",
"disabled": false,
"info": "",
"env": []
},
{
"id": "c9d4f2763665d3eb",
"type": "OpcUa-Client",
"z": "d2e711e931682cc6",
"endpoint": "67515c8d142e90e5",
"action": "write",
"deadbandtype": "a",
"deadbandvalue": 1,
"time": 10,
"timeUnit": "s",
"certificate": "n",
"localfile": "",
"localkeyfile": "",
"securitymode": "None",
"securitypolicy": "None",
"useTransport": false,
"maxChunkCount": 1,
"maxMessageSize": 8192,
"receiveBufferSize": 8192,
"sendBufferSize": 8192,
"setstatusandtime": false,
"keepsessionalive": false,
"name": "",
"applicationName": "",
"applicationUri": "",
"x": 520,
"y": 260,
"wires": [
[],
[],
[]
]
},
{
"id": "790227896bea31db",
"type": "OpcUa-Item",
"z": "d2e711e931682cc6",
"item": "ns=2;s=SensorHub.DistanceSensor.Enabled",
"datatype": "Boolean",
"value": "",
"name": "Enable",
"x": 320,
"y": 240,
"wires": [
[
"c9d4f2763665d3eb"
]
]
},
{
"id": "e8a2096cec05f57c",
"type": "inject",
"z": "d2e711e931682cc6",
"name": "Enable",
"props": [
{
"p": "payload"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": 0.1,
"topic": "",
"payload": "true",
"payloadType": "bool",
"x": 190,
"y": 240,
"wires": [
[
"790227896bea31db"
]
]
},
{
"id": "d322ce5eaca4c355",
"type": "OpcUa-Item",
"z": "d2e711e931682cc6",
"item": "ns=2;s=SensorHub.DistanceSensor.Enabled",
"datatype": "Boolean",
"value": "",
"name": "Disable",
"x": 320,
"y": 300,
"wires": [
[
"c9d4f2763665d3eb"
]
]
},
{
"id": "3b9978e48c935d2b",
"type": "inject",
"z": "d2e711e931682cc6",
"name": "Disable",
"props": [
{
"p": "payload"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": 0.1,
"topic": "",
"payload": "false",
"payloadType": "bool",
"x": 190,
"y": 300,
"wires": [
[
"d322ce5eaca4c355"
]
]
},
{
"id": "c27bf8d8a5b0607a",
"type": "OpcUa-Client",
"z": "d2e711e931682cc6",
"endpoint": "67515c8d142e90e5",
"action": "read",
"deadbandtype": "a",
"deadbandvalue": 1,
"time": 10,
"timeUnit": "s",
"certificate": "n",
"localfile": "",
"localkeyfile": "",
"securitymode": "None",
"securitypolicy": "None",
"useTransport": false,
"maxChunkCount": 1,
"maxMessageSize": 8192,
"receiveBufferSize": 8192,
"sendBufferSize": 8192,
"setstatusandtime": false,
"keepsessionalive": false,
"name": "",
"applicationName": "",
"applicationUri": "",
"x": 520,
"y": 160,
"wires": [
[
"3390a50ce9571765"
],
[],
[]
]
},
{
"id": "3390a50ce9571765",
"type": "debug",
"z": "d2e711e931682cc6",
"name": "Wert",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "payload",
"targetType": "msg",
"statusVal": "",
"statusType": "auto",
"x": 690,
"y": 160,
"wires": []
},
{
"id": "9e0b886343620f76",
"type": "inject",
"z": "d2e711e931682cc6",
"name": "Read",
"props": [],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": 0.1,
"topic": "",
"x": 190,
"y": 160,
"wires": [
[
"64ccda09b00fa6ac"
]
]
},
{
"id": "64ccda09b00fa6ac",
"type": "OpcUa-Item",
"z": "d2e711e931682cc6",
"item": "ns=2;s=SensorHub.DistanceSensor.Distance",
"datatype": "Int64",
"value": "",
"name": "Distance",
"x": 320,
"y": 160,
"wires": [
[
"c27bf8d8a5b0607a"
]
]
},
{
"id": "67515c8d142e90e5",
"type": "OpcUa-Endpoint",
"name": "",
"endpoint": "opc.tcp://127.0.0.1:4840",
"secpol": "None",
"secmode": "None",
"none": true,
"login": false,
"usercert": false,
"usercertificate": "",
"userprivatekey": ""
},
{
"id": "687580434e6413d3",
"type": "global-config",
"env": [],
"modules": {
"node-red-contrib-opcua": "0.2.355"
}
}
]20.4 Datenformate und UNS
Diese Übungen adressieren einzelne Teilschritte für einen Unified Namespace.
20.4.1 Erweiterte ISA-95 Struktur
ISA-95 ist eine aus den 90ger Jahren stammende Norm, die unter anderem eine sehr verbreitete Anlagenhierarchie definiert. Für die Einrichtung eines Unified Namespace, wird diese Struktur oft direkt in eine Topic Struktur überführt und mit fehlenden Erweiterungen ergänzt.
Wir orientieren uns an der bekannten Hierarchie <enterprise>/<site>/<area>/<work-center>/<work-unit>/ und definieren folgende Hauptstruktur:
fhnw/windisch/veri/meinname/raspyIn dieser Struktur repräsentiert raspy das Edge Device als Asset in meiner Anlage. Als Schaubild, dass wir auch Geräte hinter dem Edge abbilden können, wird direkt als zusätzliche Hierarchie Ebene die sps als device eingeführt.
fhnw/windisch/veri/meinname/raspy/spsDie SPS ist nun unsere Plattform an dem wir als Beispiel eine virtuelle Wägezelle anbinden. Wir entscheiden uns, diese auch direkt in der Topic Struktur abzubilden.
fhnw/windisch/veri/meinname/raspy/sps/scale/weight20.4.1.1 Node-Red
Integrieren wir in Node-RED.
Neuen Flow anlegen
Darin verbinden wir folgende Nodes:
inject nodemit einemfunction nodeund einemmqtt out nodeIm
mqtt out nodekonfigurieren wir den gemeinsamen Brokerlab.dev.fhnw.ch:50100und kein topicAus der Hilfe zum
mqtt out nodeentnehmen wir, dass wirmsg.payloadfür die Nutzdaten undmsg.topicselbst setzen können.Also setzen wir die Werte im
function nodeauf unser Topic und einen initialen Wert 42msg.payload = 42; msg.topic = fhnw/windisch/veri/meinname/raspy/sps/scale/weight; return msg;Führen wir den
inject nodeaus und prüfen mit einem MQTT Client die Ausgabe, sollten unsere Daten ersichtlich sein.
20.4.2 Daten -> Information
Unsere Waage liefert bereits einfache Gewichtsdaten als Nutzdaten. Ohne weitere Informationen, kann ein anderes System den Wert aber nicht einordnen. Aus diesem Grund werden nachfolgende Metadaten ergänzt. Als Quality könnten wir good für einen plausiblen, bad für einen ungültigen und uncertain für einen Wert ausserhalb des Nutzbereichs ansehen.
{
"value": 42,
"unit": "kg",
"timestamp": "2026-09-29T22:55:00Z",
"quality": "good"
}20.4.2.1 Node-RED
Integrieren wir in Node-RED.
Wir nutzen die vorherigen Node-RED flow und erweitern den
function nodeWir möchten
msg.payloadein ganzes Metadaten Objekt übergeben, anstatt nur einem Wertmsg.payload = 42 msg.payload = { value: msg.payload, unit: "kg", timestamp: new Date().toISOString(), quality: "good" };Testen wir ob unsere Nutzdaten nun Informationen auf dem Broker darstellen.
20.4.3 Informationen mit Zustand
Informationen sind eine gute Grundlage für einen UNS. Für eine Subscription ist aber auch die Gültigkeit unerlässlich. Wenn wir keine neuen Werte erhalten, wissen wir aktuell nicht, ob der Publisher offline ist oder sich der Wert nicht geändert hat. Also führen wir einen einfachen Status ein.
Wir erweitern unseren Edge Node um ein Topic
_statusAuf diesem Topic möchten wir eine Connect-Meldung publizieren mit Zeitstempel, wenn die Verbindung hergestellt wird und eine Disconnect-Meldung wenn wir die Verbindung verlieren.
{ "status": "online", "timestamp": "2026-09-29T22:55:00Z" }{ "status": "offline" }
20.4.3.1 Node-RED Integration
Integrieren wir das in Node-RED.
Wir verbinden einen neuen
inject nodemit einemfunction nodeund führen diesen auf den gleichenmqtt out nodeIm
inject nodeentfernen wir den Inhalt und konfigurieren, dass dieser automatisch einmalig nach deploy ausgeführt wird.Im
function nodekonfigurieren wirtopicundpayloadfür die Connect Meldungmsg.topic = "fhnw/windisch/veri/meinname/raspy/_status"; msg.retain = true; msg.payload = { status : "online", timestamp: new Date().toISOString() }; return msg;Für die Disconnect-Meldung nutzen wir den Last Will. Dafür konfigurieren wir im
mqtt outauf dembrokerdie Nachricht für einenunerwarteten Verbindungsabbruchmit dem gleichentopic, derpayload{ "status" : "offline" }und derretainOption.Nach dem Deploy sollten wir die Connect-Meldung auf dem Broker sehen. Für die Disconnect-Meldung muss aber der Prozess von Node-RED im Task Manager gestoppt werden. Ein schliessen mit Ctrl + C reicht nicht, da dann die MQTT Verbindung vorher sauber beendet wird.
20.4.4 Weiterleiten zwischen Brokern
Die Verbindung vom lokalen Broker zu einem übergeordneten Broker wird durch eine Subscription gelöst. Also Subscription auf dem lokalen Topic und publishen auf das definierte Topic. Es ist nicht die Idee den lokalen Broker anzupassen, da das auch in realen Anlagen so wäre.
Tipp: Es kann sinnvoll sein, die Hauptstruktur in einer Variable zu speichern, um diese nicht überall einfügen zu müssen.
flow.set("baseTopic", "fhnw/windisch/veri/max/edge");msg.topic = flow.get("baseTopic") + "/environment/temperature";20.5 Informationen über die Zeit
20.5.1 UNS Monitor
Haben wir einen UNS in unserem System integriert, haben wir Zugriff auf den aktuellen Betriebs- oder Systemzustand. Erstellen wir einen einfachen Monitor, sieht man auch direkt den Nutzen einer einheitlichen Informationsarchitektur. Zu diesem Zweck integrieren wir mit den folgenden Schritten einen einfachen Monitor in Node-Red.
In Node-RED einen neuen Flow erstellen
Die Palette
@flowfuse/node-red-dashboardintegrierenEinen
mqtt in node, mit einemfunctionundtemplatenode verbindenAls MQTT Topic geben wir den gemeinsamen UNS an z.B.
fhnw/windisch/veri/#Anschliessend speichern wir pro System die relevanten Daten im
funciton node. Inhalt im Anhang.Im Template Node definieren wir ein Dashboard mit Spaltenbreite 12 und angehängten Template.
```javascript
const prefix = "fhnw/windisch/veri/";
const topic = msg.topic || "";
// Prüfen ob Topic Passt
if (!topic.startsWith(prefix)) return null;
// Topic Level aufsplitten
const parts = topic.slice(prefix.length).split("/");
if (parts.length < 4) return null;
const [student, device, sensor, ...measurementParts] = parts;
if (!["edge", "raspy"].includes(device)) return null;
const measurement = measurementParts.join("/");
const now = Date.now();
let students = flow.get("unsStudents") || {};
if (!students[student]) students[student] = {};
if (!students[student][device]) {
students[student][device] = {
lastSeen: 0,
sensors: {}
};
}
let payload = msg.payload;
if (typeof payload === "string") {
try {
payload = JSON.parse(payload);
} catch (e) {
// String unverändert lassen
}
}
const value = payload !== null &&
typeof payload === "object" &&
Object.hasOwn(payload, "value")
? payload.value : payload;
const unit = payload !== null &&
typeof payload === "object"
? (payload.unit || "") : "";
const key = `${sensor}/${measurement}`;
students[student][device].lastSeen = now;
students[student][device].sensors[key] = {
value,
unit,
lastSeen: now
};
flow.set("unsStudents", students);
msg.payload = students;
return msg;
```
<template>
<div class="uns-monitor">
<div class="uns-header">
<h2>UNS Device Monitor</h2>
<span>{{ students.length }} Studierende</span>
</div>
<div v-if="!students.length" class="uns-empty">
Warte auf MQTT-Daten ...
</div>
<section v-for="student in students" :key="student.name" class="uns-student">
<h3>{{ student.name }}</h3>
<div class="uns-grid">
<div v-for="device in student.devices" :key="device.name" class="uns-device">
<div class="uns-device-header">
<strong>{{ device.name.toUpperCase() }}</strong>
<span :class="['uns-status', device.online ? 'online' : 'offline']">
● {{ device.online ? 'Aktiv' : 'Inaktiv' }}
</span>
</div>
<div v-for="sensor in device.sensors" :key="sensor.name" class="uns-sensor">
<span>{{ sensor.name }}</span>
<strong>{{ sensor.value }} {{ sensor.unit }}</strong>
</div>
<div v-if="!device.sensors.length" class="uns-empty">
Keine Sensorwerte
</div>
</div>
</div>
</section>
</div>
</template>
<script>
export default {
data() {
return {
devices: {},
now: Date.now(),
timer: null
}
},
watch: {
msg: {
handler(newMsg) {
if (newMsg?.payload &&
typeof newMsg.payload === "object") {
this.devices = newMsg.payload
}
},
immediate: true
}
},
computed: {
students() {
return Object.entries(this.devices).map(([name, devices]) => ({
name,
devices: Object.entries(devices).map(([deviceName, data]) => ({
name: deviceName,
online: this.now - data.lastSeen < 15000,
sensors: Object.entries(data.sensors || {}).map(([sensorName, s]) => ({
name: sensorName,
value: typeof s.value === "object"
? JSON.stringify(s.value) : s.value,
unit: s.unit
}))
})).sort((a, b) => a.name.localeCompare(b.name))
})).sort((a, b) => a.name.localeCompare(b.name))
}
},
mounted() {
this.timer = setInterval(() => {
this.now = Date.now()
}, 1000)
},
unmounted() {
clearInterval(this.timer)
}
}
</script>
<style>
.uns-monitor {
padding: 12px;
font-family: inherit;
}
.uns-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.uns-header h2 {
margin: 0 0 12px;
}
.uns-student {
margin-top: 20px;
}
.uns-student h3 {
margin-bottom: 10px;
}
.uns-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
gap: 12px;
}
.uns-device {
background: rgba(128, 128, 128, .08);
border: 1px solid rgba(128, 128, 128, .25);
border-radius: 10px;
padding: 14px;
min-width: 0;
}
.uns-device-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.uns-status {
font-size: 12px;
font-weight: 600;
}
.uns-status.online {
color: #20a36b;
}
.uns-status.offline {
color: #d45b5b;
}
.uns-sensor {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 7px 0;
border-top: 1px solid rgba(128, 128, 128, .18);
overflow-wrap: anywhere;
}
.uns-empty {
opacity: .6;
padding: 12px 0;
}
</style>[
{
"id": "69e239eed9f564f4",
"type": "tab",
"label": "UNS Monitor",
"disabled": false,
"info": "",
"env": []
},
{
"id": "9672583768c4820f",
"type": "ui-template",
"z": "69e239eed9f564f4",
"group": "8274d629d9387af7",
"page": "",
"ui": "",
"name": "",
"order": 1,
"width": 0,
"height": 0,
"head": "",
"format": "<template>\n <div class=\"uns-monitor\">\n <div class=\"uns-header\">\n <h2>UNS Device Monitor</h2>\n <span>{{ students.length }} Studierende</span>\n </div>\n\n <div v-if=\"!students.length\" class=\"uns-empty\">\n Warte auf MQTT-Daten ...\n </div>\n\n <section v-for=\"student in students\" :key=\"student.name\" class=\"uns-student\">\n <h3>{{ student.name }}</h3>\n\n <div class=\"uns-grid\">\n <div v-for=\"device in student.devices\" :key=\"device.name\" class=\"uns-device\">\n\n <div class=\"uns-device-header\">\n <strong>{{ device.name.toUpperCase() }}</strong>\n <span :class=\"['uns-status', device.online ? 'online' : 'offline']\">\n ● {{ device.online ? 'Aktiv' : 'Inaktiv' }}\n </span>\n </div>\n\n <div v-for=\"sensor in device.sensors\" :key=\"sensor.name\" class=\"uns-sensor\">\n <span>{{ sensor.name }}</span>\n <strong>{{ sensor.value }} {{ sensor.unit }}</strong>\n </div>\n\n <div v-if=\"!device.sensors.length\" class=\"uns-empty\">\n Keine Sensorwerte\n </div>\n </div>\n </div>\n </section>\n </div>\n</template>\n\n<script>\n export default {\n data() {\n return {\n devices: {},\n now: Date.now(),\n timer: null\n }\n },\n watch: {\n msg: {\n handler(newMsg) {\n if (newMsg?.payload &&\n typeof newMsg.payload === \"object\") {\n this.devices = newMsg.payload\n }\n },\n immediate: true\n }\n },\n computed: {\n students() {\n return Object.entries(this.devices).map(([name, devices]) => ({\n name,\n devices: Object.entries(devices).map(([deviceName, data]) => ({\n name: deviceName,\n online: this.now - data.lastSeen < 15000,\n sensors: Object.entries(data.sensors || {}).map(([sensorName, s]) => ({\n name: sensorName,\n value: typeof s.value === \"object\"\n ? JSON.stringify(s.value) : s.value,\n unit: s.unit\n }))\n })).sort((a, b) => a.name.localeCompare(b.name))\n })).sort((a, b) => a.name.localeCompare(b.name))\n }\n },\n mounted() {\n this.timer = setInterval(() => {\n this.now = Date.now()\n }, 1000)\n },\n unmounted() {\n clearInterval(this.timer)\n }\n}\n</script>\n\n<style>\n .uns-monitor {\n padding: 12px;\n font-family: inherit;\n }\n\n .uns-header {\n display: flex;\n justify-content: space-between;\n align-items: center;\n }\n\n .uns-header h2 {\n margin: 0 0 12px;\n }\n\n .uns-student {\n margin-top: 20px;\n }\n\n .uns-student h3 {\n margin-bottom: 10px;\n }\n\n .uns-grid {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));\n gap: 12px;\n }\n\n .uns-device {\n background: rgba(128, 128, 128, .08);\n border: 1px solid rgba(128, 128, 128, .25);\n border-radius: 10px;\n padding: 14px;\n min-width: 0;\n }\n\n .uns-device-header {\n display: flex;\n justify-content: space-between;\n align-items: center;\n margin-bottom: 12px;\n }\n\n .uns-status {\n font-size: 12px;\n font-weight: 600;\n }\n\n .uns-status.online {\n color: #20a36b;\n }\n\n .uns-status.offline {\n color: #d45b5b;\n }\n\n .uns-sensor {\n display: flex;\n justify-content: space-between;\n gap: 12px;\n padding: 7px 0;\n border-top: 1px solid rgba(128, 128, 128, .18);\n overflow-wrap: anywhere;\n }\n\n .uns-empty {\n opacity: .6;\n padding: 12px 0;\n }\n</style>",
"storeOutMessages": true,
"passthru": true,
"resendOnRefresh": true,
"templateScope": "local",
"className": "",
"x": 540,
"y": 160,
"wires": [
[]
]
},
{
"id": "e32e6955a47c37f1",
"type": "function",
"z": "69e239eed9f564f4",
"name": "Build Data",
"func": "const prefix = \"fhnw/windisch/veri/\";\nconst topic = msg.topic || \"\";\n\n// Prüfen ob Topic Passt\nif (!topic.startsWith(prefix)) return null;\n\n// Topic Level aufsplitten\nconst parts = topic.slice(prefix.length).split(\"/\");\nif (parts.length < 4) return null;\n\nconst [student, device, sensor, ...measurementParts] = parts;\n\nif (![\"edge\", \"raspy\"].includes(device)) return null;\n\nconst measurement = measurementParts.join(\"/\");\nconst now = Date.now();\n\nlet students = flow.get(\"unsStudents\") || {};\n\nif (!students[student]) students[student] = {};\n\nif (!students[student][device]) {\n students[student][device] = {\n lastSeen: 0,\n sensors: {}\n };\n}\n\nlet payload = msg.payload;\n\nif (typeof payload === \"string\") {\n try {\n payload = JSON.parse(payload);\n } catch (e) {\n // String unverändert lassen\n }\n}\n\nconst value = payload !== null &&\n typeof payload === \"object\" &&\n Object.hasOwn(payload, \"value\")\n ? payload.value : payload;\n\nconst unit = payload !== null &&\n typeof payload === \"object\"\n ? (payload.unit || \"\") : \"\";\n\nconst key = `${sensor}/${measurement}`;\n\nstudents[student][device].lastSeen = now;\nstudents[student][device].sensors[key] = {\n value,\n unit,\n lastSeen: now\n};\n\nflow.set(\"unsStudents\", students);\n\nmsg.payload = students;\nreturn msg;",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 370,
"y": 160,
"wires": [
[
"9672583768c4820f"
]
]
},
{
"id": "d1b94406d8aa595d",
"type": "mqtt in",
"z": "69e239eed9f564f4",
"name": "",
"topic": "fhnw/windisch/veri/#",
"qos": "0",
"datatype": "auto-detect",
"broker": "bfeb88ed611c6ecd",
"nl": false,
"rap": true,
"rh": 0,
"inputs": 0,
"x": 170,
"y": 160,
"wires": [
[
"e32e6955a47c37f1"
]
]
},
{
"id": "3dd08dbf76d33d81",
"type": "comment",
"z": "69e239eed9f564f4",
"name": "Einfacher Monitor UNS",
"info": "",
"x": 180,
"y": 100,
"wires": []
},
{
"id": "8274d629d9387af7",
"type": "ui-group",
"name": "Dashboard",
"page": "a515f642d9d7ab67",
"width": "12",
"height": 1,
"order": 1,
"showTitle": true,
"className": "",
"visible": "true",
"disabled": "false",
"groupType": "default",
"tab": ""
},
{
"id": "bfeb88ed611c6ecd",
"type": "mqtt-broker",
"name": "RaspyUNS",
"broker": "lab.dev.fhnw.ch",
"port": "50100",
"clientid": "",
"autoConnect": true,
"usetls": false,
"protocolVersion": 4,
"keepalive": "10",
"cleansession": true,
"autoUnsubscribe": true,
"birthTopic": "",
"birthQos": "0",
"birthRetain": "false",
"birthPayload": "",
"birthMsg": {},
"closeTopic": "",
"closeQos": "0",
"closeRetain": "false",
"closePayload": "",
"closeMsg": {},
"willTopic": "fhnw/windisch/veri/mibo/raspy/_status",
"willQos": "0",
"willRetain": "true",
"willPayload": "{ \"status\" : \"offline\" }",
"willMsg": {},
"userProps": "",
"sessionExpiry": ""
},
{
"id": "a515f642d9d7ab67",
"type": "ui-page",
"name": "Monitor",
"ui": "ad19136ccfb803ca",
"path": "/monitor",
"icon": "home",
"layout": "grid",
"theme": "c2385ac3e6000899",
"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"
}
],
"tabs": [],
"order": 1,
"className": "",
"visible": "true",
"disabled": "false"
},
{
"id": "ad19136ccfb803ca",
"type": "ui-base",
"name": "Mein Dashboard",
"path": "/dashboard",
"appIcon": "",
"includeClientData": true,
"acceptsClientConfig": [
"ui-notification",
"ui-control"
],
"showPathInSidebar": false,
"headerContent": "page",
"navigationStyle": "default",
"titleBarStyle": "default",
"showReconnectNotification": true,
"notificationDisplayTime": 1,
"showDisconnectNotification": true,
"allowInstall": false
},
{
"id": "c2385ac3e6000899",
"type": "ui-theme",
"name": "Standardthema",
"colors": {
"surface": "#fcfcfd",
"primary": "#0d74ce",
"bgPage": "#f9f9fb",
"groupBg": "#ffffff",
"groupOutline": "#d9d9e0"
},
"sizes": {
"density": "default",
"pagePadding": "12px",
"groupGap": "12px",
"groupBorderRadius": "4px",
"widgetGap": "12px"
}
},
{
"id": "e6350460679ade30",
"type": "global-config",
"env": [],
"modules": {
"@flowfuse/node-red-dashboard": "1.33.0"
}
}
]20.5.2 Quick, Grafana!
Ein kleines Dashboards mit Grafana - Basierend auf der InfluxDB aus EdgeConnect.
Wir starten direkt mit der Erweiterung unserer compose.yaml und ergänzen den Service.
grafana: image: grafana/grafana:latest restart: unless-stopped ports: - "3000:3000" volumes: - grafana_data:/var/lib/grafana depends_on: - influxdbZusätzlich müssen wir das definierte Volume
grafana_dataergänzen:volumes: grafana_data:Im Browser zum Login von Grafana http://127.0.0.1:3000 mit admin/admin
Danach eine neue Datenquelle anlegen
Connections --> Data Source --> InfluxDBIm Dialog
Query LanguageaufFluxstellenDie URL zum Container anpassen -
http://host.docker.internal:8086Weiter unten username, password und alle Infos zur InfluxDB angeben (Organisation, Bucket usw.)
Save & Test
Danach ein Dashbaord anlegen
Dashboards --> Create DashboardEin Panel hineinziehen und mit
Configure Visualizationnachfolgende Query einfügen:
from(bucket: "edgeconnect")
|> range(start: v.timeRangeStart, stop: v.timeRangeStop)
|> filter(fn: (r) => r._measurement == "environment")
|> filter(fn: (r) => r._field == "temperature")
|> aggregateWindow(
every: v.windowPeriod,
fn: mean,
createEmpty: false
)
|> yield(name: "mean")- Anschliessend
Applyund zurück zur Dashboard Übersicht. Voilà
20.6 Edge Connect
Dieses Basisprojekt bildet die Spielwiese in der Vertikalen Integration. In diesem Kapitel sind die Basisschritte aus dem Unterricht beschrieben.
Ausgangslage:
Eine bestehende Produktionsanlage soll besser überwacht werden. Die Anlage besitzt verschiedene Sensoren und Geräte, deren Daten bisher nur lokal verfügbar sind. Produktion, Instandhaltung und Prozessoptimierung sollen zukünftig auf ausgewählte Anlageninformationen zugreifen können.
20.6.1 Connect - Sensoren anbinden
Dies ist der erste Schritt in eine moderne Edge Architektur. Als Grundlage benötigen wir die effektiven Sensorwerte. Dafür liegt ein virtueller Sensorhub (veri-sensor-hub) vor. Darin enthalten sind ein Temperatur-, Distanz-, Umgebungs-, Machinen- und IMU-Sensor enthalten. Diese Sensoren werden angebunden, so dass alle Sensoren in einem gleichen Format vorliegen.
Starten von Node-RED und Docker
Starten des Sensorhub als Container:
docker run --rm -p 5001-5005:5001-5005 cr.gitlab.fhnw.ch/ioflow/images/veri-sensor-hub:mainDie Protokollbeschreibung und Ports der Sensoren können hier nachgelesen werden:
Sensor Protokoll BeschreibungBinden sie den ersten Temperatur-Sensor in Node-RED an. Nutzen sie dafür einen
tcp in Nodeund verbinden sie sich mit ihrem Sensor hublocalhost:5001.Nutzen sie einen Debug Node um die empfangenen Daten zu sehen.
Überlegen wir uns, was für Metadaten benötigt werden, um aus den Daten Information zu erstellen. Als Beispiel:
{ "asset" : "TemperaturSensor", "value" : 37, "unit" : "°C", "quality" : "Good", "timestamp" : "..." }Nutzen wir einen
function nodeund erstellen diese Information und geben sie als Debugmeldung aus.Perfekt! Sensor 1 geschafft, dann machen wir uns mal an alle anderen Sensoren. Viel Erfolg!
20.6.2 Docker Jumpstart
Der Funktionsumfang von EdgeConnect wird stetig ausgebaut. Aus diesem Grund möchten wir früh Docker Compose einführen, damit wir unser System systematisch erweitern können.
Zuerst legen wir einen Ordner “EdgeConnect” für unser System an
Im Ordner legen wir eine Datei “compose.yaml” an und fügen nachfolgenden Inhalt ein
name: edgeconnect services: sensor-hub: image: cr.gitlab.fhnw.ch/ioflow/images/veri-sensor-hub:latest restart: unless-stopped ports: - "5001:5001" # Temperature - "5002:5002" # Distance - "5003:5003" # Environment - "5004:5004" # Machine - "5005:5005" # IMU - "4840:4840" # OPC UA ServerDie Restart Policy im File steht auf unless-stopped. Damit wird das System immer automatisch gestartet, sobald die Docker Engine läuft. Möchte man das selbst steuern bietet sich auch on-failure an. Somit muss selbst gestartet werden, das System startet sich aber neu, falls es wegen eines Fehler abstürzt.
20.6.3 Einen eigenen MQTT Broker
EdgeConnect wird Informationen über MQTT in die IT-Welt übertragen. Für diese Gegenseite wird ein eigener Broker aufgesetzt. Als Applikation verwenden wir dafür das Image für den Mosquitto-Broker, was ein sehr verbreiteter Broker ist.
Sicherheitshinweis: Den Broker den wir für unser System aufsetzen wird lokal auf unserem System laufen. Die in diesem Beispiel genutzte Konfiguration ist daher sehr einfach und nicht für den professionellen Einsatz gedacht.
Wir starten direkt mit der Erweiterung unserer compose.yaml und ergänzen den Service.
mqtt: image: eclipse-mosquitto:2 restart: unless-stopped ports: - "1883:1883" volumes: - ./mosquitto/mosquitto.conf:/mosquitto/config/mosquitto.confIm Service haben wir bereits einen Pfad zu einem Konfigurationsfile angegeben. Also erstellen wir einen neuen Ordner mosquitto und legen darin ein Konfigurationsfile mosquitto.conf an. Darin definieren wir die folgenden, minimalen Parameter, so dass sich anonyme Clients von überall verbinden können.
listener 1883 allow_anonymous trueNun öffnen wir ein Terminal in unserem EdgeConnect Ordner und starten unser System (Auch falls es bereits läuft):
docker compose up -dTesten wir unseren Broker - Nutzen wir einen Client wie z.B. MqttExplorer und versuchen uns mit unserem Broker auf
127.0.0.1:1883zu verbinden. Voilà, unser eigener Broker!Zur Vollständigkeit das ganze compose.yaml:
name: veri-edge services: sensor-hub: image: cr.gitlab.fhnw.ch/ioflow/images/veri-sensor-hub:main restart: unless-stopped ports: - "5001:5001" # Temperature - "5002:5002" # Distance - "5003:5003" # Environment - "5004:5004" # Machine - "5005:5005" # IMU mqtt: image: eclipse-mosquitto:2 restart: unless-stopped ports: - "1883:1883" volumes: - ./mosquitto/mosquitto.conf:/mosquitto/config/mosquitto.conf
20.6.5 UNS
Der einfache und strukturierte Zugang zu Informationen innerhalb von Anlagen ist heute ein grundlegendes Element für moderne Anwendungen. Aus diesem Grund möchten wir in dieser Übung alle “EdgeConnect” Systeme in einem gemeinsamen Unified Namespace integrieren.
Wichtig: Ein vereinheitlichter Datenraum basiert auf strikten Regeln und Definitionen. Aus diesem Grund ist es zwingend die folgenden Punkte zu berücksichtigen.
20.6.5.1 Zentraler Broker
Als Protokoll wird MQTT und der folgende Broker als gemeinsame Drehscheibe verwendet.
Endpunkt: lab.dev.fhnw.ch:50100
20.6.5.2 Topic Struktur
Die verwendete Topic Struktur ist angelehnt an ISA-95 und erweitert für einen einfaches Handling in der Node-RED Umgebung. Generell sind alle Topic Level zwingend kleingeschrieben. Die Hauptstruktur + Erweiterungsstruktur ergibt das Topic.
Hauptstruktur: Diese Struktur gibt das Haupttopic vor. Name muss zwingend eineindeutig sein. Aus diesem Grund wird eine Kombination aus dem eigenen Vor- und Nachnamen vorgeschlagen. Zu bedenken ist aber, dass der Broker innerhalb des FHNW Netz für alle zugänglich ist.
fhnw/windisch/veri/`name`/edge/sensorhubErweiterungsstruktur: Für die Sensoren des veri-sensor-hub gelten folgende Erweiterungstopics als Suffix zur Hauptstruktur. Allfällig andere Sensoren sollen im gleichen Stil eingebaut werden. Alle Messwerte werden als retained versendet.
Temperatursensor:
/temperaturesensor/temperatureDistanzsensor:
/distancesensor/distanceUmgebungssensoren:
/environment/humidity/environment/temperature/environment/pressureMaschinensensoren:
/machinesensor/current/machinesensor/speed/machinesensor/stateIMU:
/imusensor/acceleration/x/imusensor/acceleration/y/imusensor/acceleration/z20.6.5.3 Zustand des Systems
Als einfaches Zustandshandling, wird pro Asset ein _status Topic eingeführt und als retained versendet. In unserem Fall also direkt nach edge - (Optional: pro Device)
Beim Verbinden wird folgende Meldung mit dem aktuellen Zeitstempel auf dem Topic publiziert.
{
"status": "online",
"timestamp": "2026-09-29T22:55:00Z"
}Als Last-Will wird beim Connecten im Broker folgende Meldung hinterlegt (Ohne Zeitstempel):
{
"status": "offline"
}20.6.5.4 Payload
Als Payloadstruktur wird nachfolgende JSON Struktur verwendet. Der Messwert wird durch value definiert. Die Einheit wird als String mit unitübergeben und timestamp ist die Zeit der Messwerterfassung. quality sagt ob der Messwert verlässlich ist und hat die Werte good | uncertain | bad. Einfachheitshalber wird dieser auf good belassen.
{
"value": 22.4,
"unit": "°C",
"timestamp": "2026-09-29T22:55:00Z",
"quality": "good"
}20.6.6 Eine Zeitserie-DB
Der eingerichtete UNS liefert uns den Betriebszustand unseres Systems. In diesem Schritt möchten wir einen Blick in die Vergangenheit ermöglichen können, in dem wir eine InfluxDB einrichten.
Wir starten direkt mit der Erweiterung unserer compose.yaml und ergänzen den Service.
influxdb: image: influxdb:2 restart: unless-stopped ports: - "8086:8086" volumes: - influxdb_data:/var/lib/influxdb2 environment: DOCKER_INFLUXDB_INIT_MODE: setup DOCKER_INFLUXDB_INIT_USERNAME: admin DOCKER_INFLUXDB_INIT_PASSWORD: admin123456 DOCKER_INFLUXDB_INIT_ORG: fhnw DOCKER_INFLUXDB_INIT_BUCKET: edgeconnect DOCKER_INFLUXDB_INIT_ADMIN_TOKEN: edgeconnect-tokenZusätzlich müssen wir das definierte Volume
influxdb_dataergänzen:volumes: influxdb_data:Nun öffnen wir ein Terminal in unserem EdgeConnect Ordner und starten unser System (Auch falls es bereits läuft):
docker compose up -dIntegrieren möchten wir die Datenbank in einem
neuen FlowinNode-RED. Dazu installieren wir die Palettenode-red-contrib-influxdbIm neuen Flow nutzen wir den
mqtt in nodeverbunden mit einerfunctionundinfluxdb out. Hinweis: Influxdb nutzen wir in der Version 2 - Alle nötigen Angaben für die Konfiguration, haben wir imcomposeFile angegeben.Beim
mqtt in nodeabonnieren wir nur unsere Station z.B:fhnw/windisch/veri/meinname/#Wir möchten pro Datenpunkt unsere ISA-95 Struktur als Tag zu den eigentlichen Messwerten (Fields) mitgeben. Desweiteren soll pro Sensor eine
Measurementangelegt werden. Der Inhalt für einefunctionkönnte wie folgt aussehen. Das erste Objekt sind die Fields (Können mehrere sein). Das zweite Objekt sind die Tags:
msg.measurement = "temperatursensor";
msg.payload = [
{
temperature : msg.payload
},
{
enterprise: "fhnw",
site: "windisch",
area: "veri",
station: "meinname",
asset: "edge"
}
];- Wenn wir es klevel anstellen, können wir anhand des eingehenden Topics das ganze Problem mit einer
functionlösen. Vielleicht etwas in diesem Stil?
// "fhnw/windisch/veri/meinname/edge/sensorhub/<sensor>"
const parts = msg.topic.split("/");
// Topic-Struktur prüfen
if (parts.length !== 8) {
return null;
}
const value = msg.payload?.value;
// Prüfen das es eine Zahl ist
if (typeof value !== "number" || !Number.isFinite(value)) {
return null;
}
msg.measurement = parts[6]; // environment
msg.payload = [
{
[parts[7]]: value
},
{
enterprise: parts[0],
site: parts[1],
area: parts[2],
station: parts[3],
asset: parts[4]
}
];
return msg;