Websocket: Unterschied zwischen den Versionen

Aus FHEMWiki
K (Sichtung der letzten Änderungen; kleinere Korrekturen (z.B. Verwendung von syntaxhighlight))
(Erweiterung um ein Beispiel für Fronius Wattpilot)
Zeile 328: Zeile 328:
}
}
attr WS websocketURL ws:192.168.123.123:3688
attr WS websocketURL ws:192.168.123.123:3688
</syntaxhighlight>
===Fronius Wattpilot===
Die Wallbox "Fronius Wattpilot" stellt lokal im eigenen Netzwerk Informationen über Websocket bereit. Im folgenden ein Beispiel um drei wichtige Attribute auszulesen und Readings zuzuweisen, nämlich die derzeitige Ladeleistung, den Gesamtzählerstand und den aktuellen Betriebsstatus. Es sind viele weitere Attribute lesend verfügbar, siehe dazu die API Parameter Beschreibung (URL im Kommentar im Code). Auch ein steuernder Zugriff ist grundsätzlich möglich.
Zur Konfiguration sind nur drei Attribute anzupassen: websocketURL (Adresse im eigenen Netzwerk), serial (Seriennummer des Wattpiloten) und password (bei der Einrichtung vergeben).
Für die Berechnung des Password-Hash muss das Perl Crypto-Modul PBKDF2 installiert sein, also z.B. mit
<code>sudo cpan install Crypt:PBKDF2</code>
<syntaxhighlight lang="perl" line="1">
defmod Wattpilot dummy
attr Wattpilot websocketURL ws:wattpilot.home:80/ws
attr Wattpilot serial MYWATTPILOTSERIALNUMBER
attr Wattpilot password MYWATTPILOTPASSWORD
attr Wattpilot userattr websocketURL password serial
attr Wattpilot devStateIcon opened:general_ok:stop disconnected:general_aus@red:start
attr Wattpilot event-min-interval EnergyTotal:900,TotalPower:900
attr Wattpilot event-on-change-reading EnergyTotal:0.1,TotalPower:100,.*
attr Wattpilot event-on-update-reading state,cmd
attr Wattpilot eventMap /cmd connect:start/cmd disconnect:stop/
attr Wattpilot icon electric_car_icon
attr Wattpilot readingList cmd
attr Wattpilot webCmd start:stop
attr Wattpilot websocketURL ws:wattpilot.home:80/ws
attr Wattpilot userReadings hashed_password:cmd:.connect { \
    # convert Wattpilot password to token \
    use Crypt::PBKDF2;; \
    my $pwd = AttrVal($name, "password", undef);; \
\
    if (!defined($pwd) || $pwd eq "") { \
        return ReadingsVal($name, "hashed_password", "");; \
    } \
\
    # use serial number as salt for encryption \
    my $salt = AttrVal($name, "serial", "");; \
\
    # initialize PBKDF2 Object (hash_class=HMACSHA512, 100000 iterations, 256 bytes output) \
    my $h_args = { sha_size => 512 };; \
    my $pbkdf2_obj = Crypt::PBKDF2->new( hash_class => 'HMACSHA2', hash_args => $h_args, iterations => 100000, output_len => 256 );; \
\
    # calculate raw PBKDF2 (results in 256 Bytes binary) \
    # signature: PBKDF2 ($salt, $password) \
    my $dk = $pbkdf2_obj->PBKDF2($salt, $pwd);; \
\
    # Base64-Encoding and cut to 32 Bytes \
    return substr(MIME::Base64::encode_base64($dk, ""), 0, 32);; \
},\
connect:cmd:.connect {\
    my $hash = $defs{$name};;\
    my $devState = DevIo_IsOpen($hash);;\
    return if (defined($devState));; \
\
    $hash->{DeviceName} = AttrVal($name, "websocketURL", "");; \
    $hash->{header}{'User-Agent'} = 'FHEM';; \
\
    # register callback function \
    $hash->{directReadFn} = sub () { \
\
        my $hash = $defs{$name};; \
        my $buf = DevIo_SimpleRead($hash);; \
\
        if (!defined($buf)) { DevIo_CloseDev($hash);; return;; } \
        return if ($buf eq "");; \
        Log3($name, 5, "$name: WS-buffer received: >>>$buf<<<");; \
\
        # split websocket buffer into single JSON messages if buffer contains more than one JSON \
        $buf =~ s/}\s*{/}\n{/sg;; \
        my @messages = split /\n/, $buf;; \
\
        my $processed = 0;; \
\
        foreach my $msg (@messages) { \
            next if ($msg =~ /^\s*$/);; # skip blanks \
            $processed++;; \
            my $data = eval { decode_json($msg) };; \
            if ($@) { \
                Log3($name, 1, "$name: JSON-Error: $@");; \
                return;; \
            } \
            Log3($name, 4, "$name: Decoded JSON ($processed): $msg");; \
\
            if ($data->{type} eq "hello") { \
                Log3($name, 4, "$name: Hello-message received from device.");; \
                next;; \
            } \
            # --- logic for authentication --- \
            elsif ($data->{type} eq "authRequired") { \
                my $password_hash = ReadingsVal($name, "hashed_password", "");; \
                my $token1 = $data->{token1} // "";; \
                my $token2 = $data->{token2} // "";; \
\
                if (!$password_hash || !$token1 || !$token2) { \
                    Log3($name, 1, "$name: Error: Token missing.");; \
                    DevIo_CloseDev($hash);; \
                    next;; \
                } \
\
                # generate token3 (nonce) \
                my $random_bytes = '';; \
                for (my $i = 0;; $i < 16;; $i++) { $random_bytes .= chr(int(rand(256)));; } \
                my $token3 = unpack 'H*', $random_bytes;; \
\
                # calculate Hash1 = SHA256(token1_bytes + password_hash_bytes) \
                my $hash1_input_bytes = $token1 . $password_hash;; \
                my $hash1 = Digest::SHA::sha256_hex($hash1_input_bytes);; \
\
                # calculate final hash = SHA256(token3 + token2 + hash1) \
                my $hash_input_str = $token3 . $token2 . $hash1;; \
                my $final_hash = Digest::SHA::sha256_hex($hash_input_str);; \
\
                # send JSON for authentication \
                my $response = { type => "auth", token3 => $token3, hash => $final_hash };; \
                my $response_json = to_json($response);; \
                Log3($name, 4, "$name: Authentication sent: $response_json");; \
                DevIo_SimpleWrite($hash, $response_json, 2);; \
                next;; \
            } \
\
            elsif ($data->{type} eq "authSuccess") { \
                Log3($name, 3, "$name: Authentication sucessful.");; \
                next;; \
            } \
\
            elsif ($data->{type} eq "authError") { \
                Log3($name, 1, "$name: Authentication Error: $msg");; \
                DevIo_CloseDev($hash);; \
                next;; \
            } \
            # parse relevant values out of regular status messages and convert to readings \
            # for help see API parameter description https://github.com/joscha82/wattpilot/blob/main/API.md \
            elsif ($data->{type} eq "fullStatus" || $data->{type} eq "deltaStatus") { \
                readingsBeginUpdate($hash);; \
\
                # Current Total Power Charging Energy in W - how much is the car charging\
                my $TotalPower = $data->{status}->{nrg}->[11] // "";; \
                readingsBulkUpdate($hash, "TotalPower", int($TotalPower)) if ($TotalPower ne "");; \
\
                # Sum of Total Energy in W (converted to kWh) measured by this device ever (Powermeter) \
                my $EnergyTotal = $data->{status}->{eto} // "";; \
                readingsBulkUpdate($hash, "EnergyTotal", $EnergyTotal / 1000) if ($EnergyTotal ne "");; \
\
                # Car State - is a car currently connected and is it charging \
                my $CarStateNum = $data->{status}->{car} // "";; \
                if ($CarStateNum ne "") { \
                    my %CarStateMap = (0 => 'Unknown', 1 => 'Idle', 2 => 'Charging', 3 => 'WaitCar', 4 => 'Complete', 5 => 'Error');; \
                    my $CarStateLabel = $CarStateMap{int($CarStateNum)} // "Unknown";; \
                    readingsBulkUpdate($hash, "CarState", $CarStateLabel);; \
                } \
\
                readingsEndUpdate($hash,1);;\
                next;; \
            } \
            \
        } \
\
        if ($processed == 0) { \
            Log3($name, 4, "$name: No JSON found in buffer.");; \
        } \
    };; \
\
    # open DevIo websocket \
    DevIo_OpenDev($hash, 0, undef, sub(){ \
        my ($hash, $error) = @_;; \
        return "$error" if ($error);; \
    });;\
    return;; \
},\
disconnect:cmd:.disconnect { \
    my $hash = $defs{$name};;\
    DevIo_CloseDev($hash);; \
\
    readingsBeginUpdate($hash);; \
    readingsBulkUpdate($hash, "state", "disconnected", 1);; \
    readingsBulkUpdate($hash, "TotalPower", 0, 1);; \
    readingsBulkUpdate($hash, "CarState", "Unknown", 1);; \
    readingsEndUpdate($hash, 1);;\
\
    Log3($name, 3, "$name: Disconnected");; \
    return;; \
}
defmod Wattpilot_Reconnect at +*00:01:00 { \
    # Check Wattpilot websocket every minute and reconnect if connection is lost or FHEM was restarted\
    # Check also if timestamp of current power reading was within last hour, otherwise reconnect if data is too old\
    # If last cmd was not "disconnect" (paused manually by user) and websocket not open then set cmd = "connect" \
    my $device_name = "Wattpilot";; \
    my $hash = $defs{$device_name};; \
    my $devState = DevIo_IsOpen($hash);; \
    if (defined($devState)) {\
        return if (ReadingsAge($device_name,"TotalPower",9999) < 3600);;\
    }\
\
    my $mycmd = ReadingsVal($device_name, "cmd", "???");; \
    return if ($mycmd eq "disconnect");; \
\
    Log3($device_name, 3, "$device_name: Reconnect");;\
    DevIo_CloseDev($hash);;\
    readingsSingleUpdate($hash, "cmd", "connect", 1);;\
}
</syntaxhighlight>
</syntaxhighlight>

Version vom 1. Dezember 2025, 23:11 Uhr

FHEM kann mit Websockets kommunizieren indem DevIo genutzt wird (Ankündigung des Supports in diesem Forenbeitrag). Bisher werden Websockets nur mit Perl-Befehlen angesprochen.

Schritte:

  1. Setzen der Parameter:
    • Setzen des Kommunikationsendpunktes: $hash->{DeviceName} = "wss:echo.websocket.org:443/pfad"; Wobei die Portnummer zwingend hinzugefügt werden muss, wenn ein Pfad spezifiziert wird.
    • Optional: Setzen von speziellen Headerangaben: $hash->{header}{'Sec-WebSocket-Protocol'} = 'graphql-transport-ws';
  2. Setzen einer CallBack Funktion:
    • Wenn Daten von der Websocket empfangen werden, werden diese an eine Funktion übergeben. Diese macht man mit der directReadFn bekannt: $hash->{directReadFn}
  3. Starten der Kommunikation: DevIo_OpenDev(...);
  4. Ist die Verbindung aufgebaut, kann man Daten mit der Funktion DevIo_SimpleWrite(...) senden. Die Beispiele zu "Tibber" und "Owntone" senden jeweils Daten beim Aufbau der Verbindung als ASCII String.

Nutzername und Passwort setzen

Will man den Nutzernamen und das Passwort setzen, weil der Server zum Beispiel HTTP-Basic-Auth verlangt, muss man den Header dazu selbst erzeugen und mitgeben (Beispiel aus diesem Forenbeitrag):

 $hash->{DeviceName} = AttrVal($name, "URL", "wss:ntfy.sh:443/FreundlichenGruesseAnAlleFHEMNutzer/ws");
 $hash->{DeviceName} =~ m,^(ws:|wss:)?([^/:]+):([0-9]+)(.*?)$,;
 $hash->{header}{'Host'} = $2;
 $hash->{header}{'User-Agent'} = 'FHEM';
 my $user = AttrVal($name, "username", "???");
 my $pwd  = AttrVal($name, "password", "???");
 if ($user ne "???" && $pwd ne "???") {
 	my $encoded_auth = MIME::Base64::encode_base64("$user:$pwd", "");
 	$hash->{header}{'Authorization'} = "Basic $encoded_auth";
 }

Beispiele

Tibber Live-Messdaten auslesen

Screenshot vom Tibber Websocket und weitere Tibber-Devices

Folgendes Beispiel liest Strommesswerte des Anbieters Tibber aus. Mit

set Tibber.ws start

wird die Verbindung aufgebaut und mit

set Tibber.ws stop

wieder gestoppt. Siehe auch das zugehörige Forenthema ab diesem Beitrag.

defmod Tibber.ws dummy
attr Tibber.ws userattr websocketURL homeId token myId minInterval
attr Tibber.ws alias Tibber Websocket
attr Tibber.ws event-on-change-reading .*
attr Tibber.ws eventMap /cmd connect:start/cmd disconnect:stop/
attr Tibber.ws homeId 96a14971-525a-4420-aae9-e5aedaa129ff
attr Tibber.ws icon hue_filled_plug
attr Tibber.ws minInterval 30
attr Tibber.ws myId TorxgewindeID
attr Tibber.ws readingList cmd
attr Tibber.ws setList cmd
attr Tibber.ws stateFormat payload_data_liveMeasurement_accumulatedCost payload_data_liveMeasurement_currency (payload_data_liveMeasurement_power W, Import: payload_data_liveMeasurement_accumulatedConsumption kWh, Export: payload_data_liveMeasurement_accumulatedProduction kWh)
attr Tibber.ws token 5K4MVS-OjfWhK_4yrjOlFe1F6kJXPVf7eQYggo8ebAE
attr Tibber.ws userReadings connect:cmd:.connect {\
	my $hash = $defs{$name};;\
	my $devState = DevIo_IsOpen($hash);;\
	return "Device already open" if (defined($devState));;\
	\
	# 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");;\
	\
	# 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);;\
		}\
	};;\
	\
	# 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);;\
	});;\
	readingsBulkUpdate($hash, "websocketData", "");;\
		\
	return POSIX::strftime("%H:%M:%S",localtime(time()));;\
},\
disconnect:cmd:.disconnect {\
	my $hash = $defs{$name};;\
	RemoveInternalTimer($hash);;\
	DevIo_SimpleRead($hash);;\
	DevIo_CloseDev($hash);;\
	\
	return POSIX::strftime("%H:%M:%S",localtime(time()));;\
},\
onDisconnect {\
	my $myState = ReadingsVal($name, "state", "???");;\
	my $myData = ReadingsVal($name, "websocketData", "???");;\
	return if ($myState ne "disconnected" and $myData ne "not_connected");;\
	\
	## timer callback function, called after a few seconds to initiate a reconnect\
	my $timerFunction = sub() {\
		my ($arg) = @_;;\
		my $hash = $defs{$name};;\
		my $devState = DevIo_IsOpen($hash);;\
		\
		# only re-connect if device is not connected\
		readingsSingleUpdate($hash, "cmd", "connect", 1) if (!defined($devState));;\
	};;\
	RemoveInternalTimer($name.$reading.'Timer');;\
	\
	# wait a random time before reconnect (exponential backoff TBD):\
	my $rwait = int(rand(200)) + 30;;\
	InternalTimer(gettimeofday() + $rwait, $timerFunction, $name.$reading.'Timer');;\
	\
	#set cmd to a new value, informs user and allows to retrigger when timer expires\
	my $hash = $defs{$name};;\
	readingsBulkUpdate($hash, "cmd", "reconnect attempt in $rwait seconds");;\
	\
	return POSIX::strftime("%H:%M:%S",localtime(time()));;\
},\
onTimeout:websocketData:.* {\
	#re-establish websocket connection if no data received in the past ten minutes\
	#but only if our reading "cmd" was not set to the value "disconnect"\
	\
	#timeout in seconds when the connection is considered dead\
	my $timeoutTime = 600;;\
	\
	# function to execute when timeout expired\
	# defining the function here in the userReading, allows us to insert variables directly\
	my $timerFunction = sub() {\
		my ($arg) = @_;;\
		my $hash = $defs{$name};;\
		my $rCmd = ReadingsVal($name, "cmd", "???");;\
		my $age  = ReadingsAge($name, "websocketData", 0);;\
		\
		Log(3, "$name: onTimeoutTimer triggered >>$arg<<");;\
		\
		#do not do anything further if disconnect is on purpose\
		if ( $rCmd eq "disconnect" ) {\
			Log(3, "$name: cmd was set to disconnect");;\
			return;;\
		}\
		\
		# for whatever reason, we triggered to soon (80%)\
		if ( $age < $timeoutTime*0.8 ) {\
			Log(3, "$name: websocketData is not outdated");;\
			return;;\
		}\
		\
		DevIo_CloseDev($hash);;\
		Log(3, "$name: onTimeoutTimer closed DevIo...");;\
		\
		readingsSingleUpdate($hash, "cmd", "connect", 1);;\
		Log(3, "$name: onTimeoutTimer set cmd to value 'connect'");;\
	};;\
	\
	#remove/cancel previous timers, because we got fresh data and countdown starts again\
	RemoveInternalTimer($name.$reading.'Timer');;\
	\
	#set timer to expire and execute function defined above, give special arg as identifier\
	InternalTimer(gettimeofday() + $timeoutTime, $timerFunction, $name.$reading.'Timer');;\
	\
	return POSIX::strftime("%H:%M:%S",localtime(time()));;\
},\
onConnectionAck:websocketData:.*connection_ack.* {\
	#websocketData contains the string "connection_ack"\
	Log(3, "$name:$reading: got connection ack");;\
	\
	# do not proceed if connection is lost\
	my $hash = $defs{$name};;\
	my $devState = DevIo_IsOpen($hash);;\
	return "Device not open" if (!defined($devState));;\
	\
	readingsBulkUpdate($hash, "cmd", "got connection ack");;\
	\
	my $homeId = AttrVal($name, "homeId", "???");;\
	my $myId = AttrVal($name, "myId", "???");;\
	\
	# build the query, do it in pieces, the comma at the end caused perl errors\
	# so we put it together in this not very elegant way\
	my $json = '{ "id":"'. $myId .'", "type":"subscribe"'.", ";;\
	$json .= '"payload":{';;\
	$json .= '"variables":{}'.", ";;\
	$json .= '"extensions":{}'.", ";;\
	$json .= '"query":"subscription { liveMeasurement( homeId: \"'.$homeId.'\" ) ';;\
	#$json .= '{ timestamp power accumulatedConsumption accumulatedCost currency minPower averagePower maxPower signalStrength }}"';;\
	$json .= '{ timestamp power lastMeterConsumption accumulatedConsumption accumulatedProduction ';;\
	$json .= 'accumulatedProductionLastHour accumulatedCost accumulatedReward currency minPower averagePower maxPower ';;\
	$json .= 'powerProduction powerReactive powerProductionReactive minPowerProduction maxPowerProduction lastMeterProduction ';;\
	$json .= 'powerFactor voltagePhase1 voltagePhase2 voltagePhase3 signalStrength }}"';;\
	$json .= '}}';;\
	\
	#send the string via websocket as ASCII\
	Log(3, "$name:$reading: sending JSON: >>>$json<<<");;\
	DevIo_SimpleWrite($hash, $json, 2);;\
		\
	return POSIX::strftime("%H:%M:%S",localtime(time()));;\
},\
onNextLiveMeasurement:websocketData:.*next.*payload.*data.*liveMeasurement.* {\
	#websocketData contains next-live-measurement-data\
	my $val = ReadingsVal($name, "websocketData", "{}");;\
	my %res = %{json2nameValue($val, undef, undef, "payload_data_liveMeasurement.*")};;\
	\
	my $ret = "got values for:\n";;\
	foreach my $k (sort keys %res) {\
		$ret .= "$k\n";;\
		readingsBulkUpdate($hash, makeReadingName($k), $res{$k});;\
	}\
	return $ret;;\
}
attr Tibber.ws webCmd start:stop
attr Tibber.ws websocketURL wss:websocket-api.tibber.com:443/v1-beta/gql/subscriptions

Owntone (ehemals ForkedDaapd)

Screenshot von dem OwnTone Device und der Websocket.png

Der Musikserver Owntone kann mit einer Websocket Informationen bereitstellen. Diese kann man ebenfalls mit einem einfachen Device auswerten (siehe auch Beitrag und das zugehörige Owntone-Device im Thema ):

defmod WS dummy
attr WS userattr websocketURL
attr WS alias Owntone Websocket
attr WS devStateIcon opened:general_ok@green:stop disconnected:rc_STOP@red:start
attr WS eventMap /wert connect:start/wert disconnect:stop/
attr WS icon hue_filled_plug
attr WS readingList wert
attr WS setList wert
attr WS userReadings connect:wert:.connect {\
	my $hash = $defs{$name};;\
	my $devState = DevIo_IsOpen($hash);;\
	return "Device already open" if (defined($devState));;\
	\
	$hash->{DeviceName} = AttrVal($name, "websocketURL", "ws:echo.websocket.org:443");;\
	\
	# special headers needed for Owntone\
	# https://owntone.github.io/owntone-server/json-api/#push-notifications\
	$hash->{header}{'Sec-WebSocket-Protocol'} = 'notify';;\
	$hash->{header}{'Host'} = 'localhost:3688';;\
	$hash->{header}{'Origin'} = 'http://localhost:3688';;\
	\
	# 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};;\
		readingsBeginUpdate($hash);;\
		\
		# we can read without closing the DevIo, because select signalled data\
		my $buf = DevIo_SimpleRead($hash);;\
		\
		if(!defined($buf)) {\
			DevIo_CloseDev($hash);;\
			$buf = "not connected";;\
		}\
		\
		# only update our reading if buffer is not empty\
		readingsBulkUpdate($hash, "websocketData", "$buf") if ($buf ne "");;\
		readingsEndUpdate($hash, 1);;\
	};;\
	\
	# open DevIo websocket\
	DevIo_OpenDev($hash, 0, undef, sub(){\
		my ($hash, $error) = @_;;\
		return "$error" if ($error);;\
		\
		#immediately send Owntone what we would like to be notified for (here we selected everything)\
		DevIo_SimpleWrite($hash, '{"notify":["update","database","player","options","outputs","volume","queue","spotify","lastfm","pairing"]}', 2);;\
	});;\
	\
	return POSIX::strftime("%H:%M:%S",localtime(time()));;\
},\
disconnect:wert:.disconnect {\
	my $hash = $defs{$name};;\
	RemoveInternalTimer($hash);;\
	DevIo_SimpleRead($hash);;\
	DevIo_CloseDev($hash);;\
	\
	return POSIX::strftime("%H:%M:%S",localtime(time()));;\
},\
onDisconnect {\
	my $myState = ReadingsVal($name, "state", "???");;\
	return if ($myState ne "disconnected");;\
	\
	# timer callback function, called after a few seconds to initiate a reconnect\
	my $timerFunction = sub() {\
		my ($arg) = @_;;\
		my $hash = $defs{$name};;\
		my $devState = DevIo_IsOpen($hash);;\
		readingsSingleUpdate($hash, "wert", "connect", 1) if (!defined($devState));;\
	};;\
	\
	RemoveInternalTimer($name.$reading.'Timer');;\
	InternalTimer(gettimeofday() + 10, $timerFunction, $name.$reading.'Timer');;\
	\
	return POSIX::strftime("%H:%M:%S",localtime(time()));;\
},\
onPlayer:websocketData:.*player.* {\
	fhem("set Owntone.device reread");;\
	return POSIX::strftime("%H:%M:%S",localtime(time()));;\
},\
onOutputs:websocketData:.*outputs.* {\
	fhem("get Owntone.device outputs");;\
	return POSIX::strftime("%H:%M:%S",localtime(time()));;\
},\
onVolume:websocketData:.*volume.* {\
	fhem("get Owntone.device volume");;\
	return POSIX::strftime("%H:%M:%S",localtime(time()));;\
},\
onQueue:websocketData:.*queue.* {\
	fhem("get Owntone.device queue");;\
	return POSIX::strftime("%H:%M:%S",localtime(time()));;\
}
attr WS websocketURL ws:192.168.123.123:3688


Fronius Wattpilot

Die Wallbox "Fronius Wattpilot" stellt lokal im eigenen Netzwerk Informationen über Websocket bereit. Im folgenden ein Beispiel um drei wichtige Attribute auszulesen und Readings zuzuweisen, nämlich die derzeitige Ladeleistung, den Gesamtzählerstand und den aktuellen Betriebsstatus. Es sind viele weitere Attribute lesend verfügbar, siehe dazu die API Parameter Beschreibung (URL im Kommentar im Code). Auch ein steuernder Zugriff ist grundsätzlich möglich.

Zur Konfiguration sind nur drei Attribute anzupassen: websocketURL (Adresse im eigenen Netzwerk), serial (Seriennummer des Wattpiloten) und password (bei der Einrichtung vergeben).

Für die Berechnung des Password-Hash muss das Perl Crypto-Modul PBKDF2 installiert sein, also z.B. mit

sudo cpan install Crypt:PBKDF2

defmod Wattpilot dummy
attr Wattpilot websocketURL ws:wattpilot.home:80/ws
attr Wattpilot serial MYWATTPILOTSERIALNUMBER
attr Wattpilot password MYWATTPILOTPASSWORD
attr Wattpilot userattr websocketURL password serial
attr Wattpilot devStateIcon opened:general_ok:stop disconnected:general_aus@red:start
attr Wattpilot event-min-interval EnergyTotal:900,TotalPower:900
attr Wattpilot event-on-change-reading EnergyTotal:0.1,TotalPower:100,.*
attr Wattpilot event-on-update-reading state,cmd
attr Wattpilot eventMap /cmd connect:start/cmd disconnect:stop/
attr Wattpilot icon electric_car_icon
attr Wattpilot readingList cmd
attr Wattpilot webCmd start:stop
attr Wattpilot websocketURL ws:wattpilot.home:80/ws
attr Wattpilot userReadings hashed_password:cmd:.connect { \
    # convert Wattpilot password to token \
    use Crypt::PBKDF2;; \
    my $pwd = AttrVal($name, "password", undef);; \
\
    if (!defined($pwd) || $pwd eq "") { \
        return ReadingsVal($name, "hashed_password", "");; \
    } \
\
    # use serial number as salt for encryption \
    my $salt = AttrVal($name, "serial", "");; \
\
    # initialize PBKDF2 Object (hash_class=HMACSHA512, 100000 iterations, 256 bytes output) \
    my $h_args = { sha_size => 512 };; \
    my $pbkdf2_obj = Crypt::PBKDF2->new( hash_class => 'HMACSHA2', hash_args => $h_args, iterations => 100000, output_len => 256 );; \
\
    # calculate raw PBKDF2 (results in 256 Bytes binary) \
    # signature: PBKDF2 ($salt, $password) \
    my $dk = $pbkdf2_obj->PBKDF2($salt, $pwd);; \
\
    # Base64-Encoding and cut to 32 Bytes \
    return substr(MIME::Base64::encode_base64($dk, ""), 0, 32);; \
},\
connect:cmd:.connect {\
    my $hash = $defs{$name};;\
    my $devState = DevIo_IsOpen($hash);;\
    return if (defined($devState));; \
\
    $hash->{DeviceName} = AttrVal($name, "websocketURL", "");; \
    $hash->{header}{'User-Agent'} = 'FHEM';; \
\
    # register callback function \
    $hash->{directReadFn} = sub () { \
\
        my $hash = $defs{$name};; \
        my $buf = DevIo_SimpleRead($hash);; \
\
        if (!defined($buf)) { DevIo_CloseDev($hash);; return;; } \
        return if ($buf eq "");; \
        Log3($name, 5, "$name: WS-buffer received: >>>$buf<<<");; \
\
        # split websocket buffer into single JSON messages if buffer contains more than one JSON \
        $buf =~ s/}\s*{/}\n{/sg;; \
        my @messages = split /\n/, $buf;; \
\
        my $processed = 0;; \
\
        foreach my $msg (@messages) { \
            next if ($msg =~ /^\s*$/);; # skip blanks \
            $processed++;; \
            my $data = eval { decode_json($msg) };; \
            if ($@) { \
                Log3($name, 1, "$name: JSON-Error: $@");; \
                return;; \
            } \
            Log3($name, 4, "$name: Decoded JSON ($processed): $msg");; \
\
            if ($data->{type} eq "hello") { \
                Log3($name, 4, "$name: Hello-message received from device.");; \
                next;; \
            } \
            # --- logic for authentication --- \
            elsif ($data->{type} eq "authRequired") { \
                my $password_hash = ReadingsVal($name, "hashed_password", "");; \
                my $token1 = $data->{token1} // "";; \
                my $token2 = $data->{token2} // "";; \
\
                if (!$password_hash || !$token1 || !$token2) { \
                    Log3($name, 1, "$name: Error: Token missing.");; \
                    DevIo_CloseDev($hash);; \
                    next;; \
                } \
\
                # generate token3 (nonce) \
                my $random_bytes = '';; \
                for (my $i = 0;; $i < 16;; $i++) { $random_bytes .= chr(int(rand(256)));; } \
                my $token3 = unpack 'H*', $random_bytes;; \
\
                # calculate Hash1 = SHA256(token1_bytes + password_hash_bytes) \
                my $hash1_input_bytes = $token1 . $password_hash;; \
                my $hash1 = Digest::SHA::sha256_hex($hash1_input_bytes);; \
\
                # calculate final hash = SHA256(token3 + token2 + hash1) \
                my $hash_input_str = $token3 . $token2 . $hash1;; \
                my $final_hash = Digest::SHA::sha256_hex($hash_input_str);; \
\
                # send JSON for authentication \
                my $response = { type => "auth", token3 => $token3, hash => $final_hash };; \
                my $response_json = to_json($response);; \
                Log3($name, 4, "$name: Authentication sent: $response_json");; \
                DevIo_SimpleWrite($hash, $response_json, 2);; \
                next;; \
            } \
\
            elsif ($data->{type} eq "authSuccess") { \
                Log3($name, 3, "$name: Authentication sucessful.");; \
                next;; \
            } \
\
            elsif ($data->{type} eq "authError") { \
                Log3($name, 1, "$name: Authentication Error: $msg");; \
                DevIo_CloseDev($hash);; \
                next;; \
            } \
            # parse relevant values out of regular status messages and convert to readings \
            # for help see API parameter description https://github.com/joscha82/wattpilot/blob/main/API.md \
            elsif ($data->{type} eq "fullStatus" || $data->{type} eq "deltaStatus") { \
                readingsBeginUpdate($hash);; \
\
                # Current Total Power Charging Energy in W - how much is the car charging\
                my $TotalPower = $data->{status}->{nrg}->[11] // "";; \
                readingsBulkUpdate($hash, "TotalPower", int($TotalPower)) if ($TotalPower ne "");; \
\
                # Sum of Total Energy in W (converted to kWh) measured by this device ever (Powermeter) \
                my $EnergyTotal = $data->{status}->{eto} // "";; \
                readingsBulkUpdate($hash, "EnergyTotal", $EnergyTotal / 1000) if ($EnergyTotal ne "");; \
\
                # Car State - is a car currently connected and is it charging \
                my $CarStateNum = $data->{status}->{car} // "";; \
                if ($CarStateNum ne "") { \
                    my %CarStateMap = (0 => 'Unknown', 1 => 'Idle', 2 => 'Charging', 3 => 'WaitCar', 4 => 'Complete', 5 => 'Error');; \
                    my $CarStateLabel = $CarStateMap{int($CarStateNum)} // "Unknown";; \
                    readingsBulkUpdate($hash, "CarState", $CarStateLabel);; \
                } \
\
                readingsEndUpdate($hash,1);;\
                next;; \
            } \
            \
        } \
\
        if ($processed == 0) { \
            Log3($name, 4, "$name: No JSON found in buffer.");; \
        } \
    };; \
\
    # open DevIo websocket \
    DevIo_OpenDev($hash, 0, undef, sub(){ \
        my ($hash, $error) = @_;; \
        return "$error" if ($error);; \
    });;\
    return;; \
},\
disconnect:cmd:.disconnect { \
    my $hash = $defs{$name};;\
    DevIo_CloseDev($hash);; \
\
    readingsBeginUpdate($hash);; \
    readingsBulkUpdate($hash, "state", "disconnected", 1);; \
    readingsBulkUpdate($hash, "TotalPower", 0, 1);; \
    readingsBulkUpdate($hash, "CarState", "Unknown", 1);; \
    readingsEndUpdate($hash, 1);;\
\
    Log3($name, 3, "$name: Disconnected");; \
    return;; \
}

defmod Wattpilot_Reconnect at +*00:01:00 { \
    # Check Wattpilot websocket every minute and reconnect if connection is lost or FHEM was restarted\
    # Check also if timestamp of current power reading was within last hour, otherwise reconnect if data is too old\
    # If last cmd was not "disconnect" (paused manually by user) and websocket not open then set cmd = "connect" \
    my $device_name = "Wattpilot";; \
    my $hash = $defs{$device_name};; \
    my $devState = DevIo_IsOpen($hash);; \
    if (defined($devState)) {\
        return if (ReadingsAge($device_name,"TotalPower",9999) < 3600);;\
    }\
\
    my $mycmd = ReadingsVal($device_name, "cmd", "???");; \
    return if ($mycmd eq "disconnect");; \
\
    Log3($device_name, 3, "$device_name: Reconnect");;\
    DevIo_CloseDev($hash);;\
    readingsSingleUpdate($hash, "cmd", "connect", 1);;\
}