Tibber Pulse (API): Unterschied zwischen den Versionen

Aus TippvomTibb
Zur Navigation springen Zur Suche springen
Zeile 12: Zeile 12:
 
Die App find ich ziemlich verquer. Sobald Rechnungsanschrift und Installationsorte unterschiedlich sind, geht das Chaos los. Auch der Support tut sich dann schwer. Ich traue mich gar nicht so richtig weitere Vertraege abzuschlieszen.
 
Die App find ich ziemlich verquer. Sobald Rechnungsanschrift und Installationsorte unterschiedlich sind, geht das Chaos los. Auch der Support tut sich dann schwer. Ich traue mich gar nicht so richtig weitere Vertraege abzuschlieszen.
 
Alles ueber die App machen zu wollen/muessen ist ... Das gehoert fuer mich ins WebUserPortal.
 
Alles ueber die App machen zu wollen/muessen ist ... Das gehoert fuer mich ins WebUserPortal.
 +
 +
Was echt nervt ist, dass sich gefühlt jeden Monat das LookandFeel aendert. Schon die dritte oder vierte Designaenderung des Homescreens (Zuhause).
  
 
=API=
 
=API=

Version vom 15. März 2024, 18:25 Uhr

Allgemeines

Da ich aktuell vom Energieversorger einen zweiten Hausanschluss (40kW :-() gelegt bekommen habe und ich für die Wallboxen zur Foerderung einen 100% Oekostrom haben musste, habe ich kurzerhand einen Vertrag mit Tibber mal zum Testen abgeschlossen.

Installation

Pulse IR und Bridge

Tibber Pulse IR und Bridge

Eigentlich verlief die Installation problemlos. Fuer mich war allerdings der Assistent in der Tibber-App zu kindisch. Das ist eher was für Apple-User. Ich haette mir an der ein oder anderen Stelle mehr Hardcore-Infos gewuenscht. Da der mehrstufige Prozess (WLAN-Bridge-Pulse-Zaehler) doch einige Solperfalle enthaelt ist es fuer mich eher frustrierend jedesmal bei einem Fehler zurueck auf Los geschickt zu werden. Das geht definitiv besser. Ist aber wieder ein Beispiel, dass die Informatiker-Jobs heute nur noch durch 5-jaehrige Schimpansen (ungelernte ;-)) besetzt werden.

Bei mir gab es letztendlich nur einen gravierenden Fehler. Von den mitgelieferten Akkus des Pulse war einer defekt. Bei mir sind das zwei AA-Zellen auf LiBasis! Ein Nachladen mit einem Lithium-1.5V-Lader hat keinen Erfolg gebracht. Erst der Austausch gegen 2 neue Zellen war erfolgreich.

App

Die App find ich ziemlich verquer. Sobald Rechnungsanschrift und Installationsorte unterschiedlich sind, geht das Chaos los. Auch der Support tut sich dann schwer. Ich traue mich gar nicht so richtig weitere Vertraege abzuschlieszen. Alles ueber die App machen zu wollen/muessen ist ... Das gehoert fuer mich ins WebUserPortal.

Was echt nervt ist, dass sich gefühlt jeden Monat das LookandFeel aendert. Schon die dritte oder vierte Designaenderung des Homescreens (Zuhause).

API

Da die App eher suboptimal ist, habe ich recht zuegig damit angefangen mir die notwendigen Infos ueber die API auf eine Infoseite im Intranet zu holen. Ein Integration in FHEM steht noch aus.

Erste Schritte

Zu Einstieg nutze ich den Api Explorer im TibberDev. Nachdem man sich dort einen Personal Token angelegt hat kanns direkt losgehen. Der GraphiQL hat auch eine Autovervollstaendigung. Sehr praktisch.

Die Root-Types sind

  • query: Query
  • mutation: RootMutation
  • subscription: RootSubscription
  • fragment feht irgendwie in der Doc

Der Aufbau ist recht simpel und das Doc in knapp einer Stunde durchprobiert. So dass ich dann schnell an dem Punkt angelangt bin, dass ich ein Programm (PHP oder Python) brauche welches die fuer mich interessanten Daten in meine MySQL-DB uebertraegt. Ob ich zuerst anzeige und dann speichere, oder umgekehrt muss ich noch festlegen.


query Datenabfrage (read)

query->viewer

Zuerst mit homes die Zaehlerplatz-iD holen und dan mit home (id: "<ID>") nur die data dazu holen.

Hello World;-) {

 viewer {
   login
   name
   userId
   accountType
   home (id: "1cdd8b6d-956c-447f-9b95-9a57801eaf4c") {
     id
     features {
       realTimeConsumptionEnabled
     }
     subscriptions {
       id
     }
     currentSubscription {
       id
     }
     meteringPointData {
       consumptionEan
       gridCompany
       gridAreaCode
       priceAreaCode
       productionEan
       energyTaxType
       vatType
       estimatedAnnualConsumption
     }
     owner {
       id
     }
     mainFuseSize
   }
   websocketSubscriptionUrl
 }

}


Interessant fand ich z.B.

         "priceAreaCode": "Amprion",
         "estimatedAnnualConsumption": 500
         "mainFuseSize": null

und was da noch alles an Daten einzutragen ist.

size: Int
The size of the home in square meters


mutation Datenveraenderung (write)

Mutationen gibt es nur 3, wobei die erste bei mir entfaellt.

sendMeterReading(input: MeterReadingInput!): MeterReadingResponse!
Send meter reading for home (only available for Norwegian users) 

updateHome(input: UpdateHomeInput!): Home!
Update home information

sendPushNotification(input: PushNotificationInput!): PushNotificationResponse!
Send notification to Tibber app on registered devices


subscription

Subscription hat nur 2 Felder, wobei dauerhaft nur das esrte interessant ist.

liveMeasurement(homeId: ID!): LiveMeasurement
Subscribe to real-time measurement stream from Pulse or Watty device
testMeasurement(homeId: ID!): LiveMeasurement
Subscribe to test stream

subscription{

 liveMeasurement(homeId: "<homeID>"){
   timestamp
   power
   lastMeterConsumption
   accumulatedConsumption
   accumulatedProduction
   accumulatedConsumptionLastHour
   accumulatedProductionLastHour
   accumulatedCost
   accumulatedReward
   currency
   minPower
   averagePower
   maxPower
   powerProduction
   powerReactive
   powerProductionReactive
   minPowerProduction
   maxPowerProduction
   lastMeterProduction
   powerFactor
   voltagePhase1
   voltagePhase2
   voltagePhase3
   currentL1
   currentL2
   currentL3
   signalStrength
 }

}

fragment

?

Dazu habe ich bisher keine weiteren Infos gefunden.


Programmierung

Query/Mutation

Die Abfragen und Aenderungen zu programmieren sind schnell erledigt.

Der Einstieg auf der Konsole gelingt mit curl recht gut.

Subscription

Die Subscriptions sind schon ein wenig anspruchsvoller zu programmieren als Query und Mutation (QuM). Ist QuM eine simple http-Anfrage mit einen Antwort im JSON-Format, ist es bei den Subscriptions ein Websocket, der einmal geoeffnet in unregelmaeszigen Abstaenden JSON-Pakete leiefert.

In PHP habe ich wenig bis gar nichts gefunden. Ich wollte mir auch einfach die Nutzung einer GraphQL-Library ersparen. Python habe ich erst einmal an den Schluss verschoben und einem Integration in FHEM den Vorzug gegeben.

Hier der Code der tatsaechlich funktioniert, obwohl er an ein paar Stellen nicht so vorgeht wie ich aus den Tibber-Docs erwartet hatte.

HINWEIS: Dies ist der "nackte" Perl-Code. In der "Origonaldatei" sind noch die "FHEM-Escapes" doppelter Strichpunkt und Zeilenfolgezeichen \ enthalten. Eine Eingabe ueber die FHEM-Kmmandozeile hat bei mir nicht funktioniert. Erst das direkte Einkopieren in die fhem.conf zeigte das nachfolgende, funktionierende Ergebnis.

  1 connect:cmd:.connect {
  2 	my $hash = $defs{$name};
  3 	my $devState = DevIo_IsOpen($hash);
  4 	return "Device already open" if (defined($devState));
  5 	
  6 	# establish connection to websocket
  7 	# format must also include portnumber if a path is to be specified
  8 	$hash->{DeviceName} = AttrVal($name, "websocketURL", "wss:echo.websocket.org:443");
  9 	
 10 	# special headers needed for Tibber, see also Developer Tools in Browser
 11 	$hash->{header}{'Sec-WebSocket-Protocol'} = 'graphql-transport-ws';
 12 	$hash->{header}{'Host'} = 'websocket-api.tibber.com';
 13 	$hash->{header}{'Origin'} = 'https://developer.tibber.com';
 14 	
 15 	# callback function when "select()" signals data for us
 16 	# websocket Ping/Pongs are treated in DevIo but still call this function
 17 	$hash->{directReadFn} = sub () {
 18 		my $hash = $defs{$name};
 19 		
 20 		# we can read without closing the DevIo, because select() signalled data
 21 		my $buf = DevIo_SimpleRead($hash);
 22 		
 23 		# if read fails, close device
 24 		if(!defined($buf)) {
 25 			DevIo_CloseDev($hash);
 26 			$buf = "not_connected";
 27 		}
 28 		
 29 		#Log(3, "$name:$reading: websocket data: >>>$buf<<<");
 30 		
 31 		# only update our reading if buffer is not empty and if last update is older than minInterval
 32 		if ($buf ne "") {
 33 			my $websocketDataAge = ReadingsAge($name, "websocketData", 3600);
 34 			my $minInterval = AttrVal($name, "minInterval", 0);
 35 			my $isNext = ($buf =~ /.*id.*type.*next.*payload.*data.*liveMeasurement.*/s);
 36 			
 37 			readingsBeginUpdate($hash);
 38 			readingsBulkUpdate($hash, "websocketData", "$buf") if ($isNext && $websocketDataAge > $minInterval);
 39 			readingsBulkUpdate($hash, "websocketData", "$buf") if (!$isNext);
 40 			readingsEndUpdate($hash, 1);
 41 		}
 42 	};
 43 	
 44 	# open DevIo websocket
 45 	DevIo_OpenDev($hash, 0, undef, sub(){
 46 		my ($hash, $error) = @_;
 47 		return "$error" if ($error);
 48 		
 49 		my $token = AttrVal($name, "token", "???");
 50 		
 51 		DevIo_SimpleWrite($hash, '{"type":"connection_init","payload":{"token":"'.$token.'"}}', 2);
 52 	});
 53 	readingsBulkUpdate($hash, "websocketData", "");
 54 	
 55 	return POSIX::strftime("%H:%M:%S",localtime(time()));
 56 },
 57 disconnect:cmd:.disconnect {
 58 	my $hash = $defs{$name};
 59 	RemoveInternalTimer($hash);
 60 	DevIo_SimpleRead($hash);
 61 	DevIo_CloseDev($hash);
 62 	
 63 	return POSIX::strftime("%H:%M:%S",localtime(time()));
 64 },
 65 onDisconnect {
 66 	my $myState = ReadingsVal($name, "state", "???");
 67 	my $myData = ReadingsVal($name, "websocketData", "???");
 68 	return if ($myState ne "disconnected" and $myData ne "not_connected");
 69 	
 70 	## timer callback function, called after a few seconds to initiate a reconnect
 71 	my $timerFunction = sub() {
 72 		my ($hash) = @_;
 73 		my $devState = DevIo_IsOpen($hash);
 74 		
 75 		# only re-connect if device is not connected
 76 		readingsSingleUpdate($hash, "cmd", "connect", 1) if (!defined($devState));
 77 	};
 78 	my $hash = $defs{$name};
 79 	RemoveInternalTimer($hash, $timerFunction);
 80 	
 81 	# wait a random time before reconnect (exponential backoff TBD):
 82 	my $rwait = int(rand(200)) + 30;
 83 	InternalTimer(gettimeofday() + $rwait, $timerFunction, $hash);
 84 	readingsBulkUpdate($hash, "cmd", "reconnect attempt in $rwait seconds");
 85 	
 86 	return POSIX::strftime("%H:%M:%S",localtime(time()));
 87 },
 88 onConnectionAck:websocketData:.*connection_ack.* {
 89 	#websocketData contains the string "connection_ack"
 90 	Log(3, "$name:$reading: got connection ack");
 91 	
 92 	# do not proceed if connection is lost
 93 	my $hash = $defs{$name};
 94 	my $devState = DevIo_IsOpen($hash);
 95 	return "Device not open" if (!defined($devState));
 96 	
 97 	readingsBulkUpdate($hash, "cmd", "got connection ack");
 98 	
 99 	my $homeId = AttrVal($name, "homeId", "???");
100 	my $myId = AttrVal($name, "myId", "???");
101 	
102 	# build the query, do it in pieces, the comma at the end caused perl errors
103 	# so we put it together in this not very elegant way
104 	my $json = '{ "id":"'. $myId .'", "type":"subscribe"'.", ";
105 	$json .= '"payload":{';
106 	$json .= '"variables":{}'.", ";
107 	$json .= '"extensions":{}'.", ";
108 	$json .= '"query":"subscription { liveMeasurement( homeId: \"'.$homeId.'\" ) ';
109 	$json .= '{ timestamp power lastMeterConsumption accumulatedConsumption accumulatedProduction ';
110 	$json .= 'accumulatedProductionLastHour accumulatedCost accumulatedReward currency minPower averagePower maxPower ';
111 	$json .= 'powerProduction powerReactive powerProductionReactive minPowerProduction maxPowerProduction lastMeterProduction ';
112 	$json .= 'powerFactor voltagePhase1 voltagePhase2 voltagePhase3 signalStrength }}"';
113 	$json .= '}}';
114 	
115 	#send the string via websocket as ASCII
116 	Log(3, "$name:$reading: sending JSON: >>>$json<<<");
117 	DevIo_SimpleWrite($hash, $json, 2);
118 	
119 	return POSIX::strftime("%H:%M:%S",localtime(time()));
120 },
121 onNextLiveMeasurement:websocketData:.*next.*payload.*data.*liveMeasurement.* {
122 	#websocketData contains next-live-measurement-data
123 	my $val = ReadingsVal($name, "websocketData", "{}");
124 	my %res = %{json2nameValue($val, undef, undef, "payload_data_liveMeasurement.*")};
125 	
126 	my $ret = "got values for:\n";
127 	foreach my $k (sort keys %res) {
128 		$ret .= "$k\n";
129 		readingsBulkUpdate($hash, makeReadingName($k), $res{$k});
130 	}
131 	return $ret;
132 }

Code Analyse

Es gibt 5 Eintrittspunkte:

  • connect
  • disconnect
  • onDisconnect
  • onConnectionAck
  • onNextLiveMeasurement

Die ersten beiden legen die Befehlsfolgen für ein Start-Kommando (set cmd connect, bzw. set cmd disconnect) oder Stop-Kommando fest. Die letzten 3 reagieren auf die entsprechenden Events.

connect

Lexikalische Variablen mittels my, bzw. my()

Jede Variable, die mit our deklariert oder auch "einfach so" ohne eine Deklaration verwendet wird, wird in die Symboltabelle des jeweils aktuellen Packages aufgenommen.
Deklariert man dagegen eine Variable mit dem Operator my, so wird die entsprechende Variable in einer anderen Tabelle abgelegt, auf die kein expliziter Zugriff möglich ist. 
Neben der Tatsache, daß my-Variablen in einer eigenen Tabelle verwaltet werden, ist von besonderer Bedeutung, daß sie nur einen recht beschränkten Gültigkeitsbereich besitzen. Eine durch my erzeugte Variable steht nur in dem aktuellen Block (definiert durch geschweifte Klammern "{...}"), der aktuellen Datei oder innerhalb eines Arguments von eval() zur Verfügung. Außerhalb davon existieren diese Variablen nicht, es ist also (im Gegensatz zu our-Variablen) auch mit Hilfe des Package-Namens dort kein Zugriff möglich
Es kann in einem Package durchaus zwei Variablen gleichen Namens geben: eine in der Symboltabelle und eine, die durch einen Aufruf von my entstanden ist. In einem solchen Falle wird bei einfacher Verwendung des Bezeichners auf die my-Variable zugegriffen. 

In $defs stehen alle Defines drin. In $hash wird also der Name des des aufrufenden Devices lokal hinterlegt und danach mit DevIO_IsOpen() der Status abgefragt. $defs ist ein Hash. Ein Hash ist ein anderer Namen fuer ein assoziatives Array. Also eine ungeordnete Liste von Skalaren (Zahl oder String) die ueber einen Stringwert angesprochen (Lookup) werden koennen.

FHEM-Spezial:

In $defs findet man alle Devices, z.b. $defs{"Rolladen_Tuere"} beinhaltet das Device.
my @alle = keys %defs;
my @teilmenge = defInfo('TYPE=notify','NAME'); liefert ein array mit allen notify devices.
z.B. my @teilmenge = defInfo('NAME=Rollladen.*','NAME');
return "Device already open" if (defined($devState));

Sollte also das Device bereits im State "offen" sein, wird die connect-Routine abgebrochen. Dann erscheint in den Readings nicht Datum/Uhrzeit des Connects sondern die Meldung "Device already open".

# establish connection to websocket
# format must also include portnumber if a path is to be specified
$hash->{DeviceName} = AttrVal($name, "websocketURL", "wss:echo.websocket.org:443");
HASH-Operationen (Beispiel):
$href ={APR => 4,AUG => 8}; #anonymous hash
$el = $href->{APR}; $el = %{$href}{APR}; #access element of hash
$href2 = {%{$href1}}; #copy hash
if (ref($r) eq "HASH") {} #checks if $r points to hash

Dem Internal "DeviceName" wird das Ergebnis von AttrVal() zugewiesen. Bei mir ergab das:

DeviceName wss:websocket-api.tibber.com:443/v1-beta/gql/subscriptions
FHEM-Referenz:
AttrVal(<devicename>,<attribute>,<defaultvalue>)
Gibt das entsprechende Attribut des Gerätes zurück
{ Value("wz") }
{ OldValue("wz") }
{ time_str2num(OldTimestamp("wz")) }
{ ReadingsVal("wz", "measured-temp", "20")+0 }
{ ReadingsTimestamp("wz", "measured-temp", 0)}
{ AttrVal("wz", "room", "none") }

Das Attribut websocketURL ist bei mir wss:websocket-api.tibber.com:443/v1-beta/gql/subscriptions. Somit erklaert sich auch der Eintrag bei DeviceName. "wss:echo.websocket.org:443" ist nur der Defaultwert.

Man haette zum Testen auch die folgenden beiden verwenden/eintragen koennen.

There are free test servers that we can use to experiment with a WebSocket client, such as:
   Hoppscotch – wss://echo-websocket.hoppscotch.io (GUI)
   λ if else – wss://ws.ifelse.io (GUI)
Both provide a GUI for testing via a browser. The former sends a timestamp to the client every second but doesn’t respond to client messages. The latter is a standard echo service that sends a copy of each received message back to the client. Both make sense in testing.

Da curl nicht nativ websocket-Kommunikation unterstuetzt kann man auf folgende Tool ausweichen. Auch chrome bietet ein Websocket-Client-Plugin.

  • wssh3
  • websocat
  • wscat
# special headers needed for Tibber, see also Developer Tools in Browser
$hash->{header}{'Sec-WebSocket-Protocol'} = 'graphql-transport-ws';
$hash->{header}{'Host'} = 'websocket-api.tibber.com';
$hash->{header}{'Origin'} = 'https://developer.tibber.com';
# callback function when "select()" signals data for us
# websocket Ping/Pongs are treated in DevIo but still call this function

$hash->{directReadFn} = sub () { my $hash = $defs{$name};

# we can read without closing the DevIo, because select() signalled data my $buf = DevIo_SimpleRead($hash);

# if read fails, close device if(!defined($buf)) { DevIo_CloseDev($hash); $buf = "not_connected"; }

#Log(3, "$name:$reading: websocket data: >>>$buf<<<");

# only update our reading if buffer is not empty and if last update is older than minInterval if ($buf ne "") { my $websocketDataAge = ReadingsAge($name, "websocketData", 3600); my $minInterval = AttrVal($name, "minInterval", 0); my $isNext = ($buf =~ /.*id.*type.*next.*payload.*data.*liveMeasurement.*/s);

readingsBeginUpdate($hash); readingsBulkUpdate($hash, "websocketData", "$buf") if ($isNext && $websocketDataAge > $minInterval); readingsBulkUpdate($hash, "websocketData", "$buf") if (!$isNext); readingsEndUpdate($hash, 1); } };

Die Callback-Funktion wird direkt, ohne Namen, in das/den (?) Hash unter directReadFn abgelegt. Die Callback-Funktion: Hier wird mit Hilfe der Funktion DevIo_SimpleRead() die Rueckmeldung des Server eingelesen. Siehe [1] Hinweis zum Logging in FHEM: [2] Die eigentliche Aktivitaet geschieht in den sieben Zeilen am Ende der Callback-Fkt.

ReadingsAge(<devicename>,<reading>,<defaultvalue>) #gibt das Alter des Readings in Sekunden zurück. 
AttrVal hatten wir schon.
isNext bekommt seinen Wert aus dem durch einen REGEX geliefert Wert des Buffers. Beispiel ($match) = ($dirty =~ /^(.*)$/s); 
Die Funktion readingsBeginUpdate() bereitet die Definition mit dem Hash $hash auf ein Update von Readings vor. Dies betrifft insbesondere das Setzen von Umgebungsvariablen sowie dem aktuellen Zeitstempel als Änderungszeitpunkt. Der Aufruf dieser Funktion ist notwendig um eigentliche Updates mit der Funktion readingsBulkUpdate() auf der gewünschten Definition durchführen zu können. [3]

Die Funktion readingsEndUpdate() beendet den Bulk-Update Prozess durch die Funktionen readingsBeginUpdate() & readingsBulkUpdate() und triggert optional die entsprechenden Events sämtlicher erzeugter Readings für die Definition $hash. Desweiteren werden nachgelagerte Tasks wie bspw. die Erzeugung von User-Readings (Attribut: userReadings), sowie die Erzeugung des STATE aufgrund des Attributs stateFormat durchgeführt. Sofern $do_trigger gesetzt ist, werden alle anstehenden Events nach Abschluss getriggert.

# open DevIo websocket DevIo_OpenDev($hash, 0, undef, sub(){ my ($hash, $error) = @_; return "$error" if ($error);

my $token = AttrVal($name, "token", "???");

DevIo_SimpleWrite($hash, '{"type":"connection_init","payload":{"token":"'.$token.'"}}', 2); });

Das ist die eigentliche Kontaktaufnahme.

Dies dient vermutlich dazu, bis zum ersten Empfang von Daten, das Reading websocketDate zu leeren.

return POSIX::strftime("%H:%M:%S",localtime(time()));

Zum Abschluss ercheint dann im Reading connect die aktuelle Zeit.

readingsBulkUpdate($hash, "websocketData", "");

disconnect

1  disconnect:cmd:.disconnect {
2 	my $hash = $defs{$name};
3 	RemoveInternalTimer($hash);
4 	DevIo_SimpleRead($hash);
5 	DevIo_CloseDev($hash);
6 	return POSIX::strftime("%H:%M:%S",localtime(time()));

Erlaeuterung:

  • Die Funktion RemoveInternalTimer löscht möglicherweise noch anstehende Timer welche mit dem Übergabeparameter $arg gescheduled sind. Optional kann man zusätzlich die Suche auf eine bestimmte Funktion $functionName weiter einschränken. [4]
  • Die Funktion DevIo_SimpleRead() liest anstehende Daten für die Verbindung von $hash ein und gibt diese zurück. [5]
  • Die Funktion DevIo_CloseDev() schließt eine evtl. geöffnete Verbindung für die Definition $hash. [6]

onDisconnect

TODO

onConnectionAck

TODO


onNextLiveMeasurement

TODO


Tests

Siehe hierzu auch den Beitrag Websocket-Tools.

PHP/Python

TODO