Heimautomatisierung mit Alexa und Raspberry Pi — Teil 5

Joachim Baumann
Digital Frontiers — Das Blog
19 min readMay 27, 2019

Die Lambda Funktion, die alles zusammenfügt

Einleitung

Wir haben in den bisherigen Teilen alle Voraussetzung für die Kommunikation zwischen Alexa und unserem Raspberry Pi geschaffen haben (siehe den Überblick in Teil 1 der Reihe), beginnend bei der Anbindung der AWS-MQTT-Instanz an unseren Raspberry (Teil 2) über den prinzipiellen Zugriff einer Lambda-Funktion auf diese MQTT-Instanz (Teil 3) bis zur Erzeugung eines Alexa-Skills inklusive Account-Linking (Teil 4), beschäftigen wir uns in diesem Artikel mit der eigentlichen Lambda-Funktion, die die Aufrufe von Alexa entgegennimmt, verarbeitet und bei Bedarf an unseren Raspberry weiterleitet.

Voraussetzungen

Die Voraussetzungen sind die gleichen wie in den vorhergehenden Teilen. Wir benötigen einen AWS-Account für die Erstellung des benötigten Sicherheitsprofils (für das Account Linking) und des Skills selbst. Optional sollten wir bereits die MQTT-Instanz und unseren Raspberry konfiguriert haben, so dass spätere Aufrufe auch tatsächlich beobachtet werden können.

Auch wenn dies eine Wiederholung ist: Für die Interaktion mit Alexa ist (zumindest zum Zeitpunkt des Schreibens) wichtig, in der AWS-Region “EU-West-1 (Ireland)” zu arbeiten. Dies ist die AWS-Region, in der die Alexa-Skills für Deutschland laufen und in denen wir damit auch die zugehörige MQTT-Instanz benutzen bzw. die Lambda-Funktionen platzieren, um Kommunikationsaufwände zu minimieren.

Die Grundlagen

Grundsätzlich gibt es bei Smart-Home-Skills wie dem, den wir im letzten Artikel dieser Serie erzeugt haben, zwei große Klassen von Direktiven (Aufrufe durch Alexa), die zu einem Aufruf unserer Lambda-Funktion führen. Die eine Klasse sind die Discovery-Direktiven, die erzeugt werden wenn wir Alexa auffordern, nach neuen Geräten zu suchen (“Alexa, suche neue Geräte”). Auf diese Aufrufe müssen wir reagieren, indem wir JSON-Objekte zurückliefern (die Steuerungsendpunkte), die unsere Geräte und ihre Fähigkeiten beschreiben. Erst nachdem wir Alexa diese Geräte bekannt gemacht haben, können wir diese steuern. Tatsächlich können die Endpunkte nicht nur physische Geräte, sondern auch virtuelle Geräte und ganze Szenen bezeichnen, dies ist aber für uns zweitrangig.

Die zweite große Klasse sind die Direktiven, die der eigentlichen Steuerung dieser Geräte dienen. Da wir wissen, dass wir nur Direktiven für Geräte bekommen, die wir vorher über einen Discovery-Event deklariert haben, gibt es hier keine Überraschungen. Allerdings müssen wir sicherstellen, dass wir Alexa innerhalb von 5–8 Sekunden eine Antwort über Erfolg oder Misserfolg liefern, da ansonsten von einem Fehler ausgegangen wird (allerdings sollte eine Antwort weniger als 1–2 Sekunden benötigen, sonst wird der Benutzer ungeduldig). Wir werden in unserem Skill einfach eine Nachricht in die MQTT-Instanz schreiben und direkt den Erfolg melden. Da wir die volle Kontrolle über alle Teile der Kommunikationsstrecke haben, können wir für den seltenen Fehlerfall trotzdem mögliche Probleme nachvollziehen, und für den Regelfall ist die Programmierung deutlich einfacher.

Für das Schreiben in die MQTT-Instanz können wir entweder verschiedene Topics wählen, um zwischen verschiedenen Geräteklassen oder Geräten zu differenzieren, oder aber diese Information im Payload mit übertragen. Im ersten Fall müssen wir sie auf unserem Raspberry in Node-Red nicht mehr unterscheiden, da dies bereits über die Topics passiert, im zweiten Fall haben wir in Node-Red die vollständige Flexibilität. Für unseren Skill wählen wir die zweite Variante.

Tatsächlich gibt es auch noch die Möglichkeit, proaktiv Nachrichten an Alexa zu senden, zum Beispiel um Änderungen in Messwerten weiterzureichen (von Temperatur-, Licht- oder Bewegungssensoren. Diese Möglichkeit ignorieren wir aber für unseren einfachen Skill.

Der Alexa-Skill und die Lambda-Funktion

Wir legen eine neue Lambda-Funktion namens myHomeSkill an. Hierzu gehen wir vor wie bei der Erzeugung unserer vorherigen Lambda-Funktion.

Aus der AWS-Management-Console (der Einstiegsseite bei AWS) wechseln wir zu AWS Lambda.Wir gehen über das Menü “Funktionen” und wählen “Funktion erstellen”. Für unsere Funktion benötigen wir keine Vorlage, wir wählen also “Ohne Vorgabe erstellen” aus, als Namen vergeben wir “myHomeSkill”. Wir schreiben auch diese Funktion in Node.js, wählen die neueste Version der Laufzeitumgebung aus (zum Zeitpunkt des Schreibens v8.10) und selektieren die existierende Zugriffsrichtlinie “IOTAccessRole”, die wir bereits für unsere erste Lambda-Funktion zum Ansprechen der MQTT-Instanz definiert hatten (siehe Teil 2 dieser Serie).

Bild 1: Erstellung unserer Lambda-Funktion

In der nun folgenden Konfiguration der Funktion wählen wir den Auslöser “Alexa Smart Home” und können danach im unteren Bereich einen Auslöser konfigurieren.

Bild 2: Konfiguration unserer Lambda-Funktion

Wir öffnen jetzt parallel unseren Alexa-Skill (aus Teil 3 dieser Serie) im Alexa-Entwicklerportal. Auf der ersten Seite “Smart Home” finden wir unter Punkt 2 “Smart Home service endpoint” die ID unseres Skills, die wir mit einem Klick auf “Copy to Clipboard” kopieren. Zurück in der Konfiguration unserer Lambda-Funktion tragen wir genau diese ID bei “Alexa Smart Home” ein und klicken auf “Speichern”. Damit hat unser Skill die Erlaubnis, die Lambda-Funktion aufzurufen.

Im nächsten Schritt kopieren wir die ARN unserer Lambda-Funktion (auf der gleichen Seite oben rechts), wechseln zurück zu unserem Skill (im Alexa-Entwicklerportal) und tragen diese ARN unter dem Punkt “Default Endpoint” ein (direkt unter der ID unseres Skills). Mit einem Klick auf den Button “Save” (rechts oben auf der Seite) speichern wir den Eintrag.

Damit ist die grundlegende Konfiguration erledigt. Der einzige verbleibende Schritt ist die Aktivierung unseres Skills in der Alexa-App. Dabei wird dann auch das Account-Linking durchgeführt, das wir im letzten Artikel konfiguriert hatten.

Der Aufruf durch Alexa

Sowohl die Aufrufe von Alexa (die Direktiven) als auch unsere Antworten (Events genannt) haben die gleiche grundsätzliche Struktur. Dies macht unser Leben einfacher, da wir viele Werte für unsere Antwort direkt aus der Direktive in unsere Antwort kopieren können. Grundsätzlich enthalten sowohl die Directive-Objekte, die beim Aufruf im Request übergeben werden als auch die Response-Objekte, die wir als Antwort zurückgeben, folgende Informationen:

  • Header: Wichtige Informationen im Header-Objekt sind der Namespace (der den Typ des Endpunkts enthält), der Name (die eigentliche Direktive), die MessageID (zur eindeutigen Identifizierung der Nachricht) und das CorrelationToken(muss in der Antwort verwendet werden, um den Bezug zum Aufruf herzustellen).
  • Endpoint: Dieses Objekt enthält als für uns wichtigste Information die ID des Endpunkts. Außerdem wird an dieser Stelle auch ein Authentifizierungs-Token mitgeliefert (BearerToken im Scope), das wir aber nicht benutzen, da wir ja kein echtes Account-Linking verwenden.
  • Payload: Hier wird abhängig von der Art der Nachricht weitere Information übergeben.

Beim Aufruf unserer Lambda-Funktion wird zusätzlich noch ein Context-Objekt übergeben. Dieses bietet uns vor allem zwei Funktionen succeed() und error(), die wir nutzen, um Erfolg oder Misserfolg des Aufrufs zurückzumelden.

Die grundlegende Lambda-Funktion

Bevor wir in die Details der einzelnen Varianten gehen, mit denen unser Skill die Lambda-Funktion aufrufen kann, legen wir die grundlegende Struktur der Lambda-Funktion wie folgt fest:

Listing 1: Die grundlegende Struktur unserer Lambda-Funktion

Wir beginnen wieder mit dem Wechsel zum “Strict Mode”, der uns deutlich mehr Warnungen bei der Programmierung gibt (die Anweisung “use strict”). Im allgemeinen Bereich, in dem wir später noch weitere Funktionen und Objekte definieren werden, platzieren wir aktuell eine Funktion log(), die uns Ausgaben erleichtert.

Dann definieren wir die Funktion, die wir als Lambda-Funktion zur Verfügung stellen wollen (mit den beiden Parametern request und context wie im vorherigen Kapitel beschrieben) und weisen diese der Variablen exports.handler zu (unter diesem Namen wird die Lambda-Funktion von der Umgebung erwartet).

Diese grundlegende Gerüst werden wir jetzt für die Reaktion auf die verschiedenen Direktiven von Alexa verfeinern und erweitern.

Discovery

Die Discovery-Direktive (ausgelöst durch z.B. “Alexa, suche neue Geräte”) erkennen wir daran, dass sowohl der Namespace im Header des Directive-Objekts im Request auf den Wert “Alexa.Discovery” gesetzt ist als auch der Name des Directive-Objekts den Wert “Discover” enthält.

Listing 2: Prüfung auf die Discovery-Direktive

Bei erfolgreicher Prüfung rufen wir eine Methode handleDiscovery() auf, der wir Request- und Context-Objekt übergeben. In allen anderen Fällen rufen wir die Methode handleControl() auf, um die Steuerungsdirektiven von Alexa zu behandeln.

In der Methode handleDiscovery() verwenden wir eine Liste von Endpunkten zur Beschreibung der unterstützten Funktionalität der Geräte, die wir steuern wollen und liefern diese zurück.

Listing 3: Der Aufbau unserer Funktion handleDiscovery()

Wir laden zuerst die benötigte Struktur aus weiteren Dateien mit dem Aufruf der require()-Funktion (die genaue Funktionalität betrachten wir im nächsten Kapitel). In der Funktion kopieren wir die vollständige Header-Struktur aus dem Request, setzen den Namen auf den Wert “Discover.Response” und verwenden die Methode succeed() des Context-Objekts, um Header-Struktur und die Liste der Endpunkte zurückzugeben.

Tatsächlich gibt es einen weiteren Schritt, den wir ignorieren. Im Payload-Objekt des Requests wird ein Scope-Objekt mitgeliefert, das die Authentifizierung des Request beinhaltet. Da unser Skill aber niemals das Entwicklungsstadium verlassen wird, können wir sicher sein, dass wir der einzige mögliche Aufrufer sind und verzichten auf die eigentlich notwendige Überprüfung.

Definition der Gerätefunktionalität

Die Struktur unserer Geräteeinträge

In der Antwort auf eine Discovery-Direktive erwartet Alexa ein Feld von Endpoint-Einträgen, die die unterstützten Geräte enthalten (diese innerhalb eines assoziativen Felds in einem Eintrag endpoints, aber das ist ein Implementierungsdetail).

Wir kapseln die Definition unserer Endpunkte in einem Unterverzeichnis endpoints, das für jeden Endpunkt eine eigene Datei enthält, die durch eine Datei all.js zusammengefasst werden, die wie folgt aussieht:

Listing 4: Die Datei all.js

Diese Datei enthält eine Modul-Export-Definition, die wiederum alle weiteren Endpunkt-Definitionen referenziert (die Zuweisung eines Feldes zu dem Eintrag endpoints in module.exports sorgt für die oben beschriebene Struktur). Wir beginnen mit der Definition eines ersten Endpoints “demo” in der Datei demo.js im gleichen Unterverzeichnis endpoints.

Wenn wir weitere Endpunkt-Definitionen benötigen, legen wir eine neue Datei mit der Definition des Endpunkts an und referenzieren sie hier (anstelle der drei Punkte, auch mit einem require()-Aufruf und dem Dateinamen). Damit landet sie automatisch an der richtigen Stelle in der Antwort an Alexa bei der nächsten Gerätesuche. Die dadurch entstehende Verzeichnis- und Dateistruktur sieht wie folgt aus:

Bild 3: Die Verzeichnisstruktur für die Definition unserer Endpunkte

Der Aufbau der Endpunkt-Definition

Wir beginnen wieder mit der Verwendung des ‘strict mode’, um uns auf möglichst viele Fehler aufmerksam machen zu lassen. Dann folgt die Definition des Endpunkts. Da wir die Endpunkt-Definitionen in den Dateien durch die von Node.js zur Verfügung gestellt require()-Funktion laden, stellen wir die jeweilige Definition über module.exports zur Verfügung.

Wir beginnen mit der allgemeinen Beschreibung unseres Endpunkts. Der erste Eintrag, die Definition der EndpointId, ist wichtig, da sie unseren Endpunkt eindeutig bezeichnet. Diese ID wird später auch bei den eigentlichen Steuerdirektiven durch Alexa mitgeliefert, so dass wir in der Lage sind, das zu steuernde Gerät eindeutig zu identifizieren. Die maximale Länge der ID ist 256 Zeichen, erlaubt sind Buchstaben, Zahlen, Leerzeichen und die Sonderzeichen “ _ — = # ; : ? @ &”. Da diese ID nur im Rahmen unserer Skill-Definition eindeutig sein muss, haben wir die Möglichkeit, sehr sprechende Namen zu verwenden, sollten dies aber auch tun.

Es folgen der Herstellername (manufacturerName), eine Bezeichnung (friendlyName) und eine Beschreibung (description), die Alexa später in der Auflistung der Geräte anzeigt. Jedes dieser Felder darf maximal 128 Zeichen lang sein.

Listing 5: Der prinzipielle Aufbau der Datei demo.js

Nun folgt die Definition der displayCategories für Alexa (nutzen Sie aktuell bitte diese Schreibweise der Liste, ansonsten macht Alexa Fehler in der Erkennung). Diese bestimmt, in welchen Kategorien das aktuell definierte Gerät eingeordnet wird (es können mehrere aufgelistet werden).. Es gibt eine ganze Liste von Kategorien unter dieser URL, die wichtigsten für unsere einfachen Zwecke sind LIGHT, SMARTPLUG und SWITCH. Darüber hinaus bietet Alexa noch Kategorien für die Temperaturregelung (TEMPERATURE_SENSOR,THERMOSTAT), Heimkino (SPEAKER, TV) und Sicherheit (CONTACT_SENSOR, DOORBELL, DOOR, SMARTLOCK). Es gibt sogar eine eigene Kategorie für Mikrowellen (MICROWAVE), wobei sich mir der Sinn dieser letzten Kategorie nicht wirklich erschließt. Wichtig: Diese Kategorien dienen nur der Einordnung unseres Endpunktes in der Alexa-App, sie haben keine direkte funktionale Auswirkung. Es gibt allerdings eine Ausnahme: Der Befehl “Alexa, Licht an/aus” bezieht sich auf alle Endpunkte, die die Kategorie LIGHT aufweisen. Wenn wir also unseren Endpunkt hier einbezogen haben möchten, müssen wir diese Kategorie hinzufügen.

Nun folgt der für die Funktionalität unseres Endpunktes wichtigste Teil, die Liste der Fähigkeiten (Capabilities). Diese Liste enthält die Definition aller Fähigkeiten in der immer gleichen Weise. Sowohl Typ als auch Version werden wie dargestellt definiert (dies gilt durchgehend bis auf wenige Ausnahmen). Dann kommt die Interface-Definition für diese Fähigkeit und zugehörige Eigenschaften.

Im gezeigten Fall wird mit dem Wert “Alexa” unsere erste Fähigkeit ohne Eigenschaften definiert (bei allen anderen Fähigkeit werden diese in einem assoziativen Feld properties definiert). Diese beinhaltet Direktiven und Ereignisse, die Zustands- und Fehlermeldungen erlauben. Zwar definiert jeder Endpunkt diese Fähigkeit implizit, es macht aber trotzdem Sinn, diese Definition in unsere Endpunktdefinition mit aufzunehmen. Zum einen erinnern wir uns damit daran, dass diese Fähigkeit existiert, wann immer wir die Definition betrachten, zum anderen halten wir damit explizit die unterstützte Version der Schnittstelle fest und können damit nicht später durch nicht erwartete Direktiven überrascht werden.

Die Fähigkeiten unseres Endpunktes

Wir beschränken uns in unserer Implementierung auf zwei Bereiche, zum einen Licht, optional mit Helligkeit, zum anderen Schalter- beziehungsweise Steckdosensteuerung. Prinzipiell funktionieren aber andere Funktionen sehr ähnlich und können ohne großen zusätzlichen Aufwand implementiert werden.

Um diese Funktionen steuern zu können, benötigen wir spezifische Interfaces für unsere Geräteeigenschaften. Für unsere Zwecke sind dies der Alexa.PowerController und der Alexa.BrightnessController.

Es gibt auch allgemeinere Interfaces für das prozentuale Setzen eines Endpunkts. Das Alexa.PowerLevelController-Interface beschreibt die Möglichkeit, einen Endpunkt zu dimmen, und das Alexa.PercentageController-Interface beschreibt das ganz allgemeine prozentuale Setzen eines Endpunkts. Eine Grundregel hierbei ist, dass wir das spezifischste Interface wählen, das die Funktionalität unseres Endpunkts beschreibt.

Wenn wir ein Interface für einen Endpunkt definieren, dann müssen wir auch die unterstützten Eigenschaften des Interfaces definieren. Für den PowerController heißt die Eigenschaft powerState, für den BrightnessController ist der Name der Eigenschaft brightness. Eine Liste aller Eigenschaften findet sich unter dieser URL.

In der folgenden Tabelle finden sich die Funktion des jeweiligen Interfaces, die zugehörige Eigenschaft, und die zugeordnete Direktive von Alexa (die wir im nächsten Kapitel benötigen werden).

Tabelle 1: Unsere ersten Interfaces und ihre Eigenschaften

Mit diesem Wissen können wir jetzt unsere erste Fähigkeit (Capability) mit dem Alexa.PowerController definieren, die uns das Ein- und Ausschalten unseres Endpunktes erlaubt:

Listing 6: Unsere erste Fähigkeit für einen Endpunkt, der Alexa.PowerController

Die ersten zwei Zeilen sind wie bisher, als Interface definieren wir jetzt den Alexa.PowerController. Nun definieren wir zuerst die unterstützten Eigenschaften, in diesem Fall powerState (die Syntax ist vorgeben als eine Liste von Name-Value-Paaren in der Eigenschaft properties.supported). Zusätzlich definieren wir, dass die definierten Eigenschaften nicht abgefragt werden können, indem wir “retrievable” auf den Wert false setzen (damit werden Abfragen wie “Alexa, wie ist das Testlicht angeschaltet?” abgelehnt). Dies ist zwar die Voreinstellung, weshalb wir die Zeile nicht unbedingt bräuchten, aber zum einen erinnert uns dies an die Einstellung, zum anderen bleibt sie damit auch dann erhalten, wenn irgendwann in der Zukunft die Voreinstellung geändert wird.

Unser zweite Fähigkeit definieren wir mit dem Alexa.BrightnessController, die uns das Regulieren der Helligkeit unseres Endpunktes erlaubt. Dies macht natürlich nur dann Sinn, wenn wir eine Beleuchtung regeln.

Listing 7: Unsere zweite Fähigkeit für einen Endpunkt, der Alexa.BrightnessController

Warum diese komplizierte Art, die unterstützten Eigenschaften der Controller zu exponieren, wenn diese sowieso schon von Alexa vorgegeben sind? Es gibt Controller, die mehrere Eigenschaften zur Verwendung anbieten. Wenn wir diese aber nicht vollständig unterstützen wollen, sondern nur eine Teilmenge verwenden, dann müssen wir das mitteilen. Und dieser Mechanismus ist eine einfache Art, das zu tun.

Wenn wir nun alles zusammenfügen, erhalten wir die endgültige Definition unseres Endpunktes mit der ID demo_id und dem Namen Testlicht, der sowohl an- und ausgeschaltet als auch in der Helligkeit verändert werden kann. Wenn Sie zuerst mit einer intelligenten Steckdose oder ähnlichem beginnen möchten, dann lassen Sie einfach die zweite Fähigkeit mit dem Alexa.BrightnessController weg.

Listing 8: Die vollständige Definition unseres ersten Endpoints in der Datei demo.js

Steuerung der Geräte durch Alexa

Nachdem Alexa jetzt unsere Endpunkte und ihre Fähigkeiten kennt, können wir an die eigentliche Implementierung der Steuerung gehen. Prinzipiell ist diese sehr einfach: Alexa schickt uns einen Aufruf, wir leiten den Inhalt an unsere MQTT-Instanz weiter und schicken Alexa die Antwort, die sie erwartet.

Dieser letzte Schritt stellt allerdings eine kleine Herausforderung dar, da Alexa je nach Interface unterschiedliche Werte in unterschiedlichen Bereichen der Antwort erwartet. Zudem muss die Antwort einige der Werte aus dem Request inklusive des CorrelationTokens enthalten, damit Alexa die Antwort zuordnen kann und mit dem Ergebnis zufrieden ist.

Kommunikation mit der MQTT-Instanz

Zuerst betrachten wir die prinzipielle Behandlung einer Steuerungsdirektive von Alexa (siehe folgendes Listing 9).

Listing 9: Die Kommunikation unserer Lambda-Funktion mit der MQTT-Instanz

In Teil 3 unserer Artikelserie haben wir eine Lambda-Funktion geschrieben, die mit der MQTT-Instanz kommuniziert. Genau wie dort definieren wir einen AWS-Kommunikationsendpunkt zur Kommunikation mit MQTT.

Nachdem wir in der Funktion handleControl() für diejenigen Werte aus dem Request-Objekt, die wir später noch benötigen, lokale Variablen definiert haben (requestMethod, nameSpace, payload), erzeugen wir ein Objekt iotPayload, das die Daten enthält, die wir an unseren Raspberry übermitteln wollen. Dann definieren wir ein Objekt iotParams, das ein Topic “alexa” und das iotPayload-Objekt enthält, und rufen die Methode publish() unseres Kommunikationsobjekts auf (hier könnten wir alternativ das Topic entsprechend der übermittelten Daten modifizieren).

Dies ist analog zur Funktion sendToIOT(), die wir gleichfalls aus Teil 3 der Artikelserie kennen. Wir fügen jetzt noch zwei Funktionen für Erfolg (‘succeed’) und Misserfolg(‘error”) hinzu (mit on(…)) und rufen die Methode send() auf, die das eigentliche Senden zur MQTT-Instanz durchführt.

Die Success-Funktion werden wir Folge betrachten, bei Misserfolg verwenden wir die durch das Context-Objekt zur Verfügung gestellte Methode error(), um einen Fehler zurückzumelden.

Wenn wir diese Implementierung unserer Funktion testweise verwenden, dann empfängt unser Raspberry bereits die von Alexa übermittelten Daten. Allerdings meldet Alexa jedes Mal einen Fehler zurück. Um im letzten Schritt Alexa glücklich zu machen, müssen wir die korrekte Rückmeldung an Alexa schicken.

Rückmeldung zu Alexa

Alexa erwartet in der Antwort, dem Response-Objekt, ein Context-Objekt, das den Zustand des Endpunktes reflektiert. Wenn dieser Zustand nicht dem von der Alexa-Direktive gewünschten Zustand entspricht, dann gibt es eine Fehlermeldung. Da wir aber durch die asynchrone Kommunikation über MQTT nicht ohne weiteres den echten aktuellen Zustand zurückliefern wollen/können, liefern wir einfach einen zurück, den Alexa erwartet.

Unsere Antwort setzen wir aus verschiedenen Informationen zusammen. Das eigentliche Ergebnis der Direktive wird in einem Context-Objekt erwartet, das wir im nächsten Kapitel betrachten. Den Rest des Response-Objektes, das wir an Alexa zurückliefern müssen, können wir relativ simpel aus den Informationen des Request-Objekts zusammensetzen. Die Funktion assembleAlexaResponse() nimmt als Parameter das Request- und ein Context-Objekt und erzeugt daraus ein korrektes Response-Objekt.

Listing 10: Die Erzeugung des Response-Objekts

Wir nutzen das Header-Objekt aus dem Request, um unser Header-Objekt für die Response zu erzeugen, und überschreiben nur die drei Werte namespace, name, und messageId (wir hängen der Original-ID einfach die Zeichenkette “-R” an, um sie eindeutig zu machen). Die anderen Werte, correlationID und payloadVersion, können und müssen wir unverändert lassen.

Nun erzeugen wir das eigentliche Response-Objekt, in das wir das Context-Objekt einhängen und im Event-Objekt unser Header-Objekt und das Endpoint-Objekt aus dem Request-Objekt, für das diese Direktive gedacht war, einhängen.

Die Erzeugung des Context-Objekts

Das Context-Objekt wird allgemein benutzt, um den Zustand eines Endpoint-Objekts zu übermitteln. Dazu enthält es eine Liste aller berichtbaren bzw. geänderten Eigenschaften des Endpunktes. Offiziell soll dieses Context-Objekt alle Änderungen inklusive aller Seiteneffekte beinhalten, die durch das Ändern der Eigenschaften entstehen (mit anderen Worten, das Context-Objekt und damit die Antwort soll erst nach der eigentlichen Änderung geschickt werden). Wir schreiben aber keinen Skill, der jemals der Allgemeinheit zugänglich sein wird, sondern nur uns selbst, und wir kümmern uns um die Fehlersuche und -behebung auch selbst (sprich in unserem Raspberry oder von Hand).

Damit übermitteln wir nicht die tatsächlich geänderten Eigenschaften mit ihren Werten, sondern welche, die aus der Sicht von Alexa plausibel sind.

Die Struktur des Context-Objekts sieht wie folgt aus:

Listing 11: Das Format des Context-Objekts

Als einzig relevante Eigenschaft hat das Context-Objekt eine Liste von Objekten mit dem Namen properties. Wir werden hier immer nur ein Element verwenden, da wir der Einfachheit halber nur Alexa-Interfaces mit einer Eigenschaft verwenden. Jeder Eintrag enthält Werte für namespace (das verwendete Alexa-Interface), name (der Name der geänderten Eigenschaft), und value (der Wert dieser Eigenschaft nach der Änderung). Die folgenden zwei Werte sind für Alexa notwendig und geben den Zeitpunkt der Änderung sowie die Genauigkeit dieses Zeitpunktes an, wobei bei Antworten auf Direktiven diese Werte nicht weiter verwendet werden (interessanter sind diese Werte, wenn Endgeräte eigenständig Veränderungen wie z.B. Temperatur melden). Wir nehmen einfach den aktuellen Zeitpunkt und eine Genauigkeit von 200ms.

Den Namen des verwendeten Alexa-Interfaces bekommen wir aus dem Request-Objekt. Den Namen der Eigenschaft könnten wir prinzipiell aus der Liste der Endpunkte herauslesen, die wir bei der Discovery-Direktive übergeben haben. Wir wählen aber den einfacheren Weg und definieren ein assoziatives Feld, das eine Abbildung von Interface auf Name der Eigenschaft vornimmt.

Listing 12: Abbildung von Interface auf Name der veränderten Eigenschaft

Wir tragen für die beiden Interfaces, die wir nutzen, den PowerController und den BrightnessController, die zugehörigen Eigenschaftsnamen ein. Sollten wir ein zusätzliches, neues Interface für unsere Endpunkte benutzen wollen, müssen wir dieses Feld entsprechend unserer Endpunktdefinition erweitern.

Damit haben wir das erste der beiden verbleibenden Felder, das Feld name, und es fehlt nur noch das zweite Feld value.

Für dieses zweite Feld müssen wir zwei verschiedene Fälle unterscheiden (siehe Tabelle 2 “Daten im Payload”).

Tabelle 2: Unsere ersten Interfaces, ihre Eigenschaften und zugeordnete Direktiven

Es gibt Direktiven, bei denen kein weiterer Wert übermittelt wird. Ein Beispiel sind die Direktiven unseres Alexa.PowerController “TurnOn” und “TurnOff”, bei denen Alexa als Antwort auf die Direktive im value die Werte “ON” respektive “OFF” erwartet. Der zweite Fall sind Direktiven, bei denen ein zusätzlicher Wert benötigt wird, wie zum Beispiel bei den Direktiven “SetBrightness” und “AdjustBrightness” unseres Alexa.BrightnessControllers. Hier wird im Payload der Direktive ein Schlüssel-Wert-Paar (Key-Value) übermittelt, bei dem der Wert die neue Einstellung reflektiert. Dies kann entweder direkt der Fall sein oder indirekt. Bei der Direktive SetBrightness hat das Schlüssel-Wert-Paar die Form “brightness: <wert>”, hier können wir den Wert direkt extrahieren und in unser Context-Objekt übertragen. Im Falle der Direktive AdjustBrightness wird ein Deltawert übermittelt in der Form “brightnessDelta: <wert>”.

Um dies abzubilden, definieren wir ein zweites assoziatives Feld, das für jede Zeile von Tabelle 2, in der wir keine direkte Übernahme machen können, einen Eintrag erhält. Der Wert dieses Eintrags ist ein von Alexa erwarteter, möglicher Antwortwert. Für die Direktive AdjustBrightness nehmen wir einfach einen Wert in der Mitte des möglichen Zahlenbereichs.

Listing 13: Abbildung von Direktiven auf von Alexa akzeptierte Antwortwerte

Mit diesen beiden Feldern wird jetzt die Bestimmung des Werts für die Antwort vergleichsweise einfach (siehe Listing 14).

Listing 14: Die Funktion, die bei Erfolg der MQTT-Interaktion die Antwort an Alexa erzeugt

Wir verwenden die in Listing 9 definierten lokalen Variablen und bestimmen im ersten Schritt den Namen unserer Eigenschaft aus dem Interface-Namen. Dann verwenden wir den Namen der Alexa-Direktive (in requestMethod), um ein mögliche Antwort zu erhalten (in der Variablen result). Für die Fälle, in denen wir keinen Eintrag in unserem Feld acceptableValues haben, extrahieren wir den Wert für die Antwort aus dem Payload-Eintrag.

Wir bauen wie bereits beschrieben das Context-Objekt zusammen, rufen die Funktion assembleAlexaResponse() auf (Listing 10), die unser Antwortobjekt erzeugt, und reichen dieses zurück an Alexa.

Die vollständige Funktion

Auch wenn die Beschreibung jetzt einigermaßen langwierig war, hat doch die gesamte Funktion nur ca. 50 Zeilen (60 mit den Feldern) und ist damit immer noch leicht verständlich. Hier nochmal die gesamte Funktion:

Listing 15: Die gesamte Funktion zur Behandlung von Kontrollrequests durch Alexa

Unser Ansatz funktioniert ohne Probleme, solange wir nur ein Ergebnis an Alexa zurückliefern müssen. Für die Alexa.ThermostatController-, Alexa.Speaker- und die Alexa.Cooking-Interfaces müssen wir allerdings jeweils mehr als einen Wert zurückgeben und brauchen deshalb, wenn wir sie nutzen wollen, eine eigene Implementierung (zumindest wenn wir in unserer Endpunktdefinition mehrere Eigenschaften erlauben). Dies sollte aber mit dem erworbenen Wissen eine Fingerübung sein. Und für alle anderen aktuellen Interfaces brauchen wir nur minimale Erweiterungen in den beiden Feldern nameSpaceMapping und requestMethodMapping, um sie nutzen zu können.

Der Lebenszyklus einer Lambda-Funktion

Wir haben zwar bereits eine Lambda-Funktion geschrieben, uns aber bisher noch keine Gedanken über den Lebenszyklus im Umfeld von AWS gemacht. Prinzipiell ist es so, dass wir keine Erwartungen daran stellen können, dass eine exportierte Funktion (die der speziellen Variable exports zugewiesen wird) und die Funktionen und Objekte, die mit ihr zusammen definiert wurden, über das Ende ihrer Ausführung hinweg existiert. Zwei Aspekte spielen aber für uns: Zum einen wird die Umgebung, in der die Funktion läuft, für eine gewisse Zeit aufbewahrt (gecached), um bei einem erneuten Aufruf wieder verwendet zu werden, zum anderen hält auch der Mechanismus, der unsere Funktion exportiert (über die von Node.js zur Verfügung gestellte Funktion require()), diese in einem eigenen Cache. Es wird also nicht garantiert, dass unsere Umgebung zwischen zwei Aufrufen erhalten bleibt (es kann ja aufgrund der in AWS existierenden Dynamik sogar so sein, dass mehrere unserer Umgebungen parallel existieren), aber wir können darauf hoffen, dass dies passiert, und damit auch darauf, dass die Ergebnisse teurer Operationen zwischen Aufrufen erhalten bleiben. Dies können wir nutzen, indem wir Objekte, die über mehrere Aufrufe hinweg unverändert bleiben, außerhalb des exports-Statements definieren, um potenziell die Kosten bzw. die Zeit für die Erzeugung dieser Objekte zu reduzieren. Wir dürfen uns allerdings in keinem Fall darauf verlassen, Zustandsinformation von einem Aufruf zum nächsten in diesen Objekten zu retten.

Verarbeitung in Node-Red

Nachdem jetzt die gesamte Kommunikation implementiert ist, sollte bei jedem Kommando, das wir Alexa zu einem von unseren Endpunkten geben, eine entsprechende Nachricht bei unserem Raspberry ankommen. Dies lässt sich am einfachsten mit einem Debug-Node prüfen, den wir an den Ausgang unseres MQTT-Nodes hängen.

Wenn wir aber die Daten weiterverarbeiten wollen, dann ist ein einfacher Weg die Implementierung mehrerer Nodes, die jeder eine kleine Aufgabe übernehmen (siehe Bild 4). In unserem Beispiel steuern wir ein Espurna-Device über REST, aber natürlich sind andere Geräte ähnlich einfach zu steuern, und anstelle des HTTP-Requests können wir auch eine Nachricht an eine lokale MQTT-Instanz absetzen, an der unsere Geräte lauschen.

Bild 4: Node-Red-Verarbeitung unserer Alexa-Ereignisse

Auf der rechten Seite des Bildes sehen wir zwei Debug-Ausgaben für das An- und Ausschalten unseres Gerätes mit der EndpointID “demo_id”, die direkt nach dem Node JSON_From_String gemacht wurden (wir könnten auch den json-Node nehmen, aber dies Vorgehen illustriert die Einfachheit des Umgangs mit Node-Red). Dieser Node ist sehr einfach und enthält nur eine Konvertierung der empfangenen Zeichenkette in ein Javascript-Objekt, so dass wir einfacher auf die Elemente zugreifen können (siehe Listing 16).

Listing 16: Der Inhalt des Nodes JSON_From_String

Im nächsten Schritt verwenden wir einen Switch-Node, um anhand der EndpointID unterschiedliche Folgepfade zu gehen (siehe Bild 5). Da wir nur ein Gerät definiert haben, findet sich zu Illustrationszwecken ein zweites mit der EndpointID “blafasel”.

Bild 5: Konfiguration des Switch-Nodes

Auch der nächste Schritt ist wieder sehr einfach und übersetzt die Alexa-Direktive in Werte, die unser Endgerät (ein Espurna-Device) verstehen kann. Das Ergebnis weisen wir dem Payload unserer Nachricht zu (siehe Listing 17).

Listing 17: Der Inhalt des Nodes Translate

Danach generieren wir den eigentlichen Aufruf zur Steuerung unseres Geräts im nächsten Node. Wir setzen unseren URL zusammen aus dem Steuer-URL für unser Gerät, fügen den für Espurna notwendigen API-Key als Parameter hinzu und den im vorherigen Node bestimmten Steuerwert und liefern als Ergebnis den fertigen Steuer-URL zurück.

Listing 18: Der Node Espurna-Device, der den URL zur Steuerung erzeugt

Anstelle des Translate- und des Device-Nodes könnten wir bei komplexeren Setups auch einen weiteren Switch-Node gefolgt von Trigger-Nodes mit den spezifischen URLs und Kommandos für komplexe Kommandos definieren.

Der finale Node HTTP-Req ist ein unkonfigurierter Node (die Zugriffsmethode ist voreingestellt GET, der URL wird vom vorherigen Node geliefert). Er verwendet einfach den URL, der ihm übergeben wird und führt einen Zugriff durch. Damit wird die eigentlich Aktion in unserem Endgerät ausgelöst. Prinzipiell könnten wir auch das Ergebnis des Aufrufs noch verarbeiten, aber das sparen wir uns an dieser Stelle.

Damit sind wir aber fertig, und sobald diese Konfiguration deployed ist, reagiert unser Gerät auf die Alexa-Kommandos.

Mögliche Erweiterungen

Betrachten wir die Erweiterung unserer Lambda-Funktion, so dass wir zwei weitere Controller unterstützen. Eine nach Klassen unterteilte Liste der aktuell unterstützten Interfaces findet sich in der Payload-Beschreibung der Smart-Home-Skill-API. Wir wählen den Alexa.Colorcontroller und den Alexa.ColorTemperatureController, um weitergehende Steuerungsmöglichkeiten für unsere Beleuchtung zu erhalten. Die relevanten, aus der Beschreibung stammenden Informationen sind in Tabelle 3 zusammengefasst.

Tabelle 3: Weitere Interfaces, die wir unterstützen können

Mit diesen Informationen können wir unsere beiden Felder so erweitern, dass diese Interfaces korrekt behandelt werden.

Listing 19: Die Konfiguration der neuen Interfaces

Jetzt können wir diese Interfaces bei der Konfiguration der Fähigkeiten unserer Geräte verwenden. Hier die zugehörigen Definitionen:

Listing 20: Die Definition der neu unterstützten Fähigkeiten

Ausblick

Uns fehlt noch der Umgang mit Szenen, das eigenständige, proaktive Übertragen von Werten und die Übertragung mehrerer Werte in unserer Antwort. Aber dies sind Themen für einen anderen Tag.

Zusammenfassung

Wir haben in der Artikelserie einen langen Weg beschritten, um Alexa und unseren Raspberry miteinander reden zu lassen. Jetzt, wo diese Weg vollendet ist, funktioniert die Steuerung des Raspberry (und damit unserer Endgeräte) aber schnell und zuverlässig, und wir haben zudem sehr einfache Möglichkeiten, neue Funktionalität und neue Geräte hinzuzufügen. Damit und mit der weitreichenden Endgeräteunterstützung durch Alexa sind unseren Möglichkeiten zur Sprachsteuerung unseres Zuhauses kaum noch Grenzen gesteckt.

Vielen Dank fürs Lesen und ich freue mich auf Feedback. Die weiteren Artikel dieser Serie, genau wie andere interessante Artikel, erscheinen im Blog der Digital Frontiers und werden auch auf unserem Twitter-Account angekündigt.

--

--