Die API des smart-home-control Servers wird von allen Gerätesteuerungen und Interfaces/Apps verwendet, die im Smart Home angebunden sind. Sie basiert auf Socket.IO, das selbst wiederum auf WebSockets aufbaut. Damit können, anders als bei HTTP, asynchron in beide Richtungen jederzeit Nachrichten geschickt werden.

Alle Nachrichten in Socket.IO werden mit socket.emit() verschickt. Das erste Argument ist die Art von Event/Nachricht, die geschickt werden soll. Danach folgen alle zugehörigen Argumente. Als letztes kann optional ein Callback gegeben werden, über den der Empfänger direkt auf den Sender antworten kann. Zum Beispiel:

socket.emit("cache:update", fname, url, callback)  // JS
socket.emit("cache:update", (fname, url), callback=callback)  # Python

Nachrichten gehen immer vom Client zum smart-home-control Server oder umgekehrt, nie direkt zwischen den Clients. Nachrichten vom Client zum Server können vom Server aber an einen oder mehrere Clients weitergeleitet werden.

Kanäle

Zur Trennung der Funktionen im Smart Home, ist ein wesentlicher Teil des smart-home-control Servers die Bereitstellung von Kanälen. Es gibt spezielle Kanäle des Server (z.B. status oder rollen), gerätespezifische Kanäle (z.B. mikrofon) und Plugin-Kanäle (z.B. sturzalarm).

An sich kann jeder Client Daten in einem Kanal lesen und schreiben, oder Event darin senden und empfangen. In der Praxis hat aber jeder Kanal einen (bzw. in Ausnahmefällen mehrere, z.B. beim Kühlschrank) Verantwortlichen. Das kann der Server selbst, eine Gerätesteuerung, oder ein Plugin sein. Der Verantwortliche schreibt bzw. updatet Daten im Kanal und stellt Events bereit. Alle anderen Clients lesen die Daten aus einem Kanal und lösen Events aus.

Konkret schaut das zum Beispiel so aus (als exemplarisches Beispiel):

Client 1:
// Der erste Client ist zum Beispiel eine
// Gerätesteuerung, die, wenn ein Event ausgelöst
// wird, eine Messung durchführt und dann die
// Daten in einen Kanal schreibt.
const { io } = require("socket.io-client");
var socket = io("<server address>");

// Kanäle müssen nicht registriert werden, aber
// um auf dem Kanal Events zu empfangen, müssen
// wir ihn abbonieren:
socket.emit("sub", "mein-kanal");

// Wir können bei Socket.IO ein Handler für
// das Ereignis "aktualisieren" im Kanal
// "mein-kanal" registrieren.
socket.on("mein-kanal:aktualisieren", () => {
    var messwert = messungDurchfuehren();
    socket.emit("put", "mein-kanal", {
        "messwert": messwert
    });
});
Client 2:
// Der zweite Client ist ein Display, das die
// Daten anzeigt, und wenn man auf "aktualisieren"
// klickt, eine neue Messung auslöst.

const { io } = require("socket.io-client");
var socket = io("<server address>");

// Auch der andere Client muss den Kanal abbonieren,
// allerdings nur um Datenupdates mitzubekommen,
// nicht um Events verschicken zu können:
socket.emit("sub", "mein-kanal");

// Beim Client gibt es einen Handler für neue Daten:
socket.on("mein-kanal", (daten) => {
    console.log(
        "Der neue Messwert ist: ",
        daten.messwert
    );
});

// Sollten beim Start von Client 2 schon Daten im
// Kanal sein, wird mit "sub" bereits einmal der
// aktuelle Stand geschickt.

// Abschließend können wir die Aktualisierung mit
// dem Event von Client 1 auslösen:
function onClickOnUpdate() {
    socket.emit("mein-kanal:aktualisieren");
}

Dokumentation

Die Dokumentation der API und Kanäle ist in zwei Teile aufgeteilt:

Beispiele

Steckdosen mit Sprachsteuerung umschalten

Ein häufiger Vorhang im Smart Home ist das Ein-/Ausschalten von Steckdosen. Gesteuert werden die Steckdosen von der Gerätesteuerung für alle Shelly Geräte. Um aber Priorierung zu ermöglichen (z.B.: Die Sprachsteuerung kann das automatische Ausschalten des Küchenlichts überschreiben, wenn man die Küche verlässt), gibt es ein eigenes Plugin, das die Steckdosen nochmal zusätzlich abstrahiert.

In diesem Beispiel geht es erstmal nur darum, wie die Sprachsteuerung das Küchenlicht über die zugehörige Steckdose einschalten kann. Der relevante Kanal zum Mikrofon ist mikrofon. Spricht man ins Mikrofon, wird der aktuell erkannte Text in Echtzeit in diesem Kanal geupdated:

{
  "text": "Schalte das",  // Text wird kontinierlich während des Sprechens geupdated
  "ausgefuehrt": false,
  // ... Weitere Felder, die uns hier nicht interessieren
}

Updates erfolgen mithilfe der API Funktion put, also:

socket.emit("put", "mikrofon", {"text": "Schalte das", ...})

Bei jedem Update werden alle Clients benachrichtigt, die diesen Kanal abboniert haben. Zum Abbonieren gibt es die API Funktion sub:

socket.emit("sub", "mikrofon");
socket.on("mikrofon", (data) => { /* Handler für geupdatete Mikrofondaten */ });

Im konrekten Beispiel ist ein Handler in der Konfiguration (config.js) gegeben. Einfache Wenn-Dann Verhalten können darin etwas einfacher deklariert werden. Zum Beispiel muss man sich dort nicht um das Abbonieren kümmern. Der Handler wird also bei jeder Änderung der Daten im Kanal mikrofon ausgeführt. Jedes mal überprüft er den Text auf Stichwörter. Kommen die Wörter "Küche" und "an" oder "ein" vor, wird das Küchenlicht angeschalten.

Dazu wird über die API das Event steckdosen:anschalten ausgelöst. Das Event ist wie eine Art Funktion des Steckdosen Plugins. Als Argumente werden die genaue Steckdose, sowie die Priorität angegeben (hier: Sprachsteuerung):

socket.emit("steckdosen:anschalten", "kueche-s1", "sprachsteuerung");

Der Server empfängt das Event, und leitet es an alle Clients, die den Kanal steckdosen abboniert haben, weiter. Dazu gehört das Steckdosen Plugin, das wie ein Client an das Smart Home angebunden ist. Es reagiert (als einziges) auf das Event, und schaltet über die Gerätesteuerung der Shellies die Steckdose an.

Gleichzeitig zum steckdosen:anschalten Event wird im obigen Handler aus der Konfigurationsdatei das Event mikrofon:ausgefuehrtSetzen ausgelöst, das zurück an die Gerätesteuerung des Mikrofons geht:

socket.emit("mikrofon:ausgefuehrtSetzen", data.satz_id)  // Die Satz-ID ist in den Daten gegeben

Daraufhin bleibt das Feld ausgefuehrt im Kanal mikrofon solange auf true, bis von der Sprachsteuerung der nächste Satz erkannt wurde. So wird sichergestellt, dass kein Sprachbefehl zweimal ausgeführt wird.

Sturzalarm auslösen

Als etwas umfangreicheres Beispiel, wie ein Ablauf im Smart Home aussieht und wie die Kommunikation über die API abläuft: Das Auslösen eines Sturzalarms. Das Beispiel soll verschiedene Arten von Vorgängen zeigen, die im Smart Home möglich sind. Es hat deshalb so viele Schritte, da mehrere Komponenten beteiligt sind, die miteinander nur über die API kommunizieren. Auf diese Art können einzelne Komponenten (wie z.B. die Push-API) auch in anderen Vorgängen wiederverwendet werden.

Verwendet werden drei Kanäle mit Daten: sensfloor und sturzkamera von den jeweiligen Gerätesteuerungen, und sturzalarm vom Sturzalarm Plugin. Zu Beginn sind folgende Daten für die Kanäle hinterlegt:

  • Kanal sensfloor:

    {
      "an": true,
      "aktivitaet": false,
      "sturzalarm": false,  // Dieses Feld interessiert uns, der Rest ist hier nicht relevant
      "bereiche": {
        "irgendwo": false,
        "kueche": false
      },
      "verbundenCareApi": true,
      "verbundenRoomApi": true
    }
  • Kanal sturzkamera:

    {
      "bild": "/cache/sturzkamera.jpg?1664182443968",  // Dieses Feld interessiert uns hier
      "letzter_timestamp": 1664182443847,
      "modus": "standby",
      "fehler": false
    }
  • Kanal sturzalarm:

    {
      "alarm": false,
      "bild": null
    }

Ablauf:

  1. Der SensFloor erkennt einen Sturzalarm. Über seine eigene API schickt er ein Event an die Gerätesteuerung des SensFloors, die als Node.JS Skript auf dem zentralen Raspberry Pi läuft.
  2. Die Gerätesteuerung setzt das Feld sturzalarm im Kanal sensfloor auf true: socket.emit("put", "sensfloor", {"sturzalarm": true})
  3. In der Smart Home Konfiguration (config.js) ist ein sehr einfacher Ereignishandler für den Fall, dass sich Daten im Kanal sensfloor verändern. Wird dort sturzalarm auf true gesetzt, wird das Event sturzalarm:ausloesen an den Server geschickt:
    socket.emit("sturzalarm:ausloesen")
  4. Der Server schickt das Event weiter an alle Clients, die im Kanal sturzalarm sind (anhand des Prefix sturzalarm:), unter anderem das Sturzalarm Plugin und die Sturzkamera.
  5. (A) Das Sturzalarm Plugin macht zwei Sachen:
    1. Es updated seine Daten im sturzalarm Kanal ¹: socket.emit("put", "sturzalarm", {"alarm": true})
    2. Es löst eine Push-Benachrichtigung aus, indem es das Event push-api:senden mit den Details als Argumenten auslöst.
      socket.emit("push-api:senden", msg)
      Dieses Event wird vom PushAPI Plugin abgefangen, das dann die Benachrichtigungen sendet.
  6. (B) Gleichzeitig macht die Sturzkamera ein Bild, was einen kurzen Moment dauert.
    1. Sobald das Bild gemacht wurde, wird es mit einer speziellen Funktion des Servers auf diesen übertragen. Der Schritt ist notwendig, damit die App von extern auch darauf zugreifen kann, ohne Zugriff auf das interne Smart Home Netzwerk zu haben. Außerdem könnte die direkte Verbindung zur Sturzkamera manchmal zu langsam sein:
      socket.emit("cache:update", fname, url, callback)
    2. Über den Callback benachrichtigt der Server die Sturzkamera, dass die Übertragung abgeschlossen wurde. Die Sturzkamera updated dann ihre Daten im Kanal mit einem Pfad, wo das Bild abgerufen werden kann:
      socket.emit("put", "sturzkamera", {"bild": "<pfad>", ...})
  7. Das Sturzalarm Plugin bekommt das neue Bild mit, weil es den Kanal von der Sturzkamera abboniert hat (socket.emit("sub", "sturzkamera"); socket.on("sturzkamera", () => {...});). Es updated das Feld bild in seinem eigenen Kanal, sodass auch die App das Bild erhalten kann.

¹ Der Grund warum die Information zum Sturzalarm doppelt im sensfloor und sturzalarm Kanal ist, ist dass man so die Zugriffsrechte besser einschränken kann. Die App braucht so nur Zugriff auf den sturzalarm Kanal. Das gleiche gilt auch für den sturzkamera Kanal.

Spezielle Abläufe

Sessions

Ein wesentliches Element, um die Komplexität etwas zu reduzieren, sind Sessions/Sitzungen. Damit können Client erkennen, wenn der smart-home-control Server neustartet, und sich dann selbst auch neustarten. Zum einen macht es das einfacher, das Smart Home zurückzusetzen. Zum anderen vereinfacht sich so das Synchronisierungsproblem beim Neustart eines Clients oder des Servers. So kann der Client davon ausgehen, dass nur zu seinem allerersten Verbindungsaufbau alle Daten zum Server synchronisiert werden müssen.

In der API ist das über session Nachrichten des Servers umgesetzt.

sequenceDiagram participant Server participant Client activate Server activate Client Note over Server, Client: Verbindungsaufbau Note left of Server: Bei jedem Verbindungsaufbau <br/> schickt der Server eine zufällige <br/> ID, die gleich bleibt solange der <br/> Server läuft. Server-)Client: "session", "123" Note right of Client: Client speichert die Session ID, <br/> um Neustarts des Server zu erkennen loop Nachrichten-<br>austausch Client->>Server: Server->>Client: end deactivate Client rect rgb(230, 230, 255) Note right of Client: Client startet neu activate Client Note over Server, Client: Neuer Verbindungsaufbau Server-)Client: "session", "123" end loop Nachrichten-<br>austausch Client->>Server: Server->>Client: end deactivate Server rect rgb(230, 230, 255) Note left of Server: Server startet neu,<br> generiert neue ID activate Server Note over Server, Client: Neuer Verbindungsaufbau Server-)Client: "session", "456" deactivate Client Note right of Client: Client erkennt neue Session ID <br> und startet neu activate Client Note over Server, Client: Neuer Verbindungsaufbau Server-)Client: "session", "456" Note right of Client: Client speichert neue Session ID end loop Nachrichten-<br>austausch Client->>Server: Server->>Client: end deactivate Client deactivate Server

Gerätesteuerungen

Einen zusätzlichen Schritt gegenüber anderen Clients beim Verbindungsaufbau haben die Gerätesteuerungen. Sie melden sich mit ihrer Geräte-ID beim Server an. Sie erhalten auf diese Art ihre Konfiguration (die zentral beim Server in einer Datei liegt), und außerdem weiß der Server so, dass sie verbunden sind (was im Gerätestatus angezeigt wird). Pro Geräte-ID kann nur eine Gerätesteuerung verbunden sein.

sequenceDiagram participant Client2 as Gerätesteuerung 2 participant Server participant Client as Gerätesteuerung 1 activate Server activate Client Note over Server, Client: Verbindungsaufbau Server-)Client: "session", "123" rect rgb(230, 230, 255) Client->>Server: "status:anmelden", "kamera" Note over Server: Server updated im Gerätestatus,<br> dass das Gerät verbunden ist Server-->>Client: callback("erfolgreich", config) end loop Nachrichten-<br>austausch Client->>Server: Server->>Client: end activate Client2 Note over Server, Client2: Verbindungsaufbau Server-)Client2: "session", "123" rect rgb(230, 230, 255) Client2->>Server: "status:anmelden", "kamera" Note over Server: Gerät mit dieser ID <br> schon verbunden! Server-->>Client2: callback("Fehler") end deactivate Client2 loop Nachrichten-<br>austausch Client->>Server: Server->>Client: end Note over Server, Client: Verbindungsabbruch deactivate Client Note over Server: Server updated im Gerätestatus,<br> dass das Gerät nicht mehr <br> verbunden ist deactivate Server