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.

NoteBenötigte Vorbereitung

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.

Figure 20.1: Übungsaufbau
  1. Node-RED starten und einen neuen Flow anlegen

  2. Fügen wir einen Inject Node (Sender) und einen Debug Node (Empfänger) hinzu(Nicht verbinden).

  3. Anpassen des Inject Node, so dass ein Text an den Debug Node gesendet wird.

  4. Nun möchten wir diese Verbindung über das Netzwerk machen. Dazu fügen wir einen tcp out Node(Client) hinter dem Inject Node hinzu und einen tcp in Node(Server) vor den Debug Node.

  5. Konfigurieren wir dem Server den 7500 und passen den Client entsprechend an.

  6. Deployen wir nun den Code, sehen wir unter den TCP Bausteinen ein “verbunden”

  7. Klicken wir nun auf den Inject Node, sollte die Nachricht über das Netzwerk, an den Debug Node geschickt werden.

  8. 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.

  1. Docker mit Container im Vordergrund - Nur Darstellung, kein Terminal.

     docker run nodered/node-red
  2. Docker im Hintergrund mit -d:

     docker run -d nodered/node-red
  3. Docker mit Container interaktiv mit Terminal.

     docker run -it nodered/node-red
  4. Container mit Port veröffentlichen

     docker run -d -p 1880:1880 nodered/node-red
  5. Container 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.

  1. Nutzen wir einen MQTT-Client z.B. MqttExplorer
  2. Verbinden wir uns mit dem FHNW Broker (Host: lab.dev.fhnw.ch, Port: 50100)
  3. Nutzen wir unseren Namen als Topic und versuchen etwas zu publizieren
  4. 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.

  1. Importieren wir den nachfolgenden Flow
  2. Triggern wir den Inject Node erhalten wir Daten - Sehr einfach!
  3. Empfangen geht sehr einfach, aber wie könnten wir eine Meldung zurück an den Sensor schicken?
  4. 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

  1. Installieren wir die Palette node-red-contrib-opcua (Dauert einen Moment)

  2. Aktualisieren wir veri-sensor_hub:

    docker pull cr.gitlab.fhnw.ch/ioflow/images/veri-sensor-hub:main
  3. Starten 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:main
  4. Importieren wir den nachfolgenden Flow - Dieser verbindet sich über opc.tcp://127.0.0.1:4840 mit dem OPC UA Server im veri-sensor-hub:

  5. Wir lesen den Distanzwert mit der Node-ID ns=2;s=SensorHub.DistanceSensor.Distance

  6. Ausserdem können wir den Sensor Enablen und Disablen über die Node-ID ns=2;s=SensorHub.DistanceSensor.Enabled

  7. Einzig 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/raspy

In 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/sps

Die 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/weight

20.4.1.1 Node-Red

Integrieren wir in Node-RED.

  1. Neuen Flow anlegen

  2. Darin verbinden wir folgende Nodes: inject node mit einem function node und einem mqtt out node

  3. Im mqtt out node konfigurieren wir den gemeinsamen Broker lab.dev.fhnw.ch:50100 und kein topic

  4. Aus der Hilfe zum mqtt out node entnehmen wir, dass wir msg.payload für die Nutzdaten und msg.topic selbst setzen können.

  5. Also setzen wir die Werte im function node auf unser Topic und einen initialen Wert 42

    msg.payload = 42;
    msg.topic = fhnw/windisch/veri/meinname/raspy/sps/scale/weight;
    return msg;
  6. Führen wir den inject node aus 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.

  1. Wir nutzen die vorherigen Node-RED flow und erweitern den function node

  2. Wir möchten msg.payload ein ganzes Metadaten Objekt übergeben, anstatt nur einem Wert

    msg.payload = 42
    
    msg.payload = {
      value: msg.payload,
      unit: "kg",
      timestamp: new Date().toISOString(),
      quality: "good"
    };
  3. 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.

  1. Wir erweitern unseren Edge Node um ein Topic _status

  2. Auf 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.

  1. Wir verbinden einen neuen inject node mit einem function node und führen diesen auf den gleichen mqtt out node

  2. Im inject node entfernen wir den Inhalt und konfigurieren, dass dieser automatisch einmalig nach deploy ausgeführt wird.

  3. Im function node konfigurieren wir topic und payload für die Connect Meldung

    msg.topic = "fhnw/windisch/veri/meinname/raspy/_status";
    msg.retain = true;
    msg.payload = {
      status : "online",
      timestamp: new Date().toISOString()
    };
    
    return msg;
  4. Für die Disconnect-Meldung nutzen wir den Last Will. Dafür konfigurieren wir im mqtt out auf dem broker die Nachricht für einen unerwarteten Verbindungsabbruch mit dem gleichen topic, der payload { "status" : "offline" } und der retain Option.

  5. 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.

  1. In Node-RED einen neuen Flow erstellen

  2. Die Palette @flowfuse/node-red-dashboard integrieren

  3. Einen mqtt in node, mit einem function und template node verbinden

  4. Als MQTT Topic geben wir den gemeinsamen UNS an z.B. fhnw/windisch/veri/#

  5. Anschliessend speichern wir pro System die relevanten Daten im funciton node. Inhalt im Anhang.

  6. 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.

  1. 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:
        - influxdb
  2. Zusätzlich müssen wir das definierte Volume grafana_data ergänzen:

    volumes:
      grafana_data:
  3. Im Browser zum Login von Grafana http://127.0.0.1:3000 mit admin/admin

  4. Danach eine neue Datenquelle anlegen Connections --> Data Source --> InfluxDB

  5. Im Dialog Query Language auf Flux stellen

  6. Die URL zum Container anpassen - http://host.docker.internal:8086

  7. Weiter unten username, password und alle Infos zur InfluxDB angeben (Organisation, Bucket usw.)

  8. Save & Test

  9. Danach ein Dashbaord anlegen Dashboards --> Create Dashboard

  10. Ein Panel hineinziehen und mit Configure Visualization nachfolgende 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")
  1. Anschliessend Apply und 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.

NoteBenötigte Vorbereitung

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.

  1. Starten von Node-RED und Docker

  2. Starten des Sensorhub als Container:

    docker run --rm -p 5001-5005:5001-5005 cr.gitlab.fhnw.ch/ioflow/images/veri-sensor-hub:main
  3. Die Protokollbeschreibung und Ports der Sensoren können hier nachgelesen werden:
    Sensor Protokoll Beschreibung

  4. Binden sie den ersten Temperatur-Sensor in Node-RED an. Nutzen sie dafür einen tcp in Node und verbinden sie sich mit ihrem Sensor hub localhost:5001.

  5. Nutzen sie einen Debug Node um die empfangenen Daten zu sehen.

  6. Ü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" : "..."
    }
  7. Nutzen wir einen function node und erstellen diese Information und geben sie als Debugmeldung aus.

  8. 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.

  1. Zuerst legen wir einen Ordner “EdgeConnect” für unser System an

  2. 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 Server
  3. Die 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.

  1. 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.conf
  2. Im 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 true
  3. Nun öffnen wir ein Terminal in unserem EdgeConnect Ordner und starten unser System (Auch falls es bereits läuft):

     docker compose up -d
  4. Testen wir unseren Broker - Nutzen wir einen Client wie z.B. MqttExplorer und versuchen uns mit unserem Broker auf 127.0.0.1:1883 zu verbinden. Voilà, unser eigener Broker!

  5. 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.4 DataShare - MQTT

Für unser EdgeConnect System möchten wir den nächsten Schritt machen. Bei Section 20.6.1 haben wir in Node-RED mit der Integration unserer Sensoren begonnen. Alle Sensoren die bereits auf ein standardisiertes Format gebracht wurden, sollen nun über MQTT an unseren eigenen Broker publiziert werden.

  1. Überlegen sie sich eine MQTT Topic Struktur, wie sie ihre 5 Sensoren anbieten möchten. Es sollen alle relevanten Metadaten enthalten sein, die andere Systeme benötigen würden.
  2. Machen sie einen Test Flow in Node-RED, in dem sie sich mit ihrem eigenen Broker verbinden.
  3. Publizieren sie ihre Sensordaten
  4. Nutzen sie einen MQTT Client z.B. MqttExplorer und prüfen sie, ob ihre Sensorwerte ankommen.
  5. Versuchen sie einen neuen Flow in Node-RED in dem sie selbst die Daten abonnieren. Können sie mit Hilfe von MQTT Wildcards ein generelles Topic definieren, um nur die Sensorinformationen zu erhalten? Viel Erfolg!

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/sensorhub

Erweiterungsstruktur: 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/temperature

Distanzsensor:

/distancesensor/distance

Umgebungssensoren:

/environment/humidity
/environment/temperature
/environment/pressure

Maschinensensoren:

/machinesensor/current
/machinesensor/speed
/machinesensor/state

IMU:

/imusensor/acceleration/x
/imusensor/acceleration/y
/imusensor/acceleration/z

20.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.

  1. 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-token
  2. Zusätzlich müssen wir das definierte Volume influxdb_data ergänzen:

    volumes:
      influxdb_data:
  3. Nun öffnen wir ein Terminal in unserem EdgeConnect Ordner und starten unser System (Auch falls es bereits läuft):

     docker compose up -d
  4. Integrieren möchten wir die Datenbank in einem neuen Flow in Node-RED. Dazu installieren wir die Palette node-red-contrib-influxdb

  5. Im neuen Flow nutzen wir den mqtt in node verbunden mit einer function und influxdb out. Hinweis: Influxdb nutzen wir in der Version 2 - Alle nötigen Angaben für die Konfiguration, haben wir im compose File angegeben.

  6. Beim mqtt in node abonnieren wir nur unsere Station z.B: fhnw/windisch/veri/meinname/#

  7. Wir möchten pro Datenpunkt unsere ISA-95 Struktur als Tag zu den eigentlichen Messwerten (Fields) mitgeben. Desweiteren soll pro Sensor eine Measurement angelegt werden. Der Inhalt für eine function kö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"
    }
];
  1. Wenn wir es klevel anstellen, können wir anhand des eingehenden Topics das ganze Problem mit einer function lö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;