Modul-Fingerübung: Spritpreis

Begonnen von pjakobs, 11 Januar 2017, 11:07:36

Vorheriges Thema - Nächstes Thema

pjakobs

Ich dachte mir, ich versuche mich mal an einem eigenen Modul und mach damit auch gleich was nützliches....

Es gibt ja schon ein paar Ansätze, um die vielen unterschiedlichen Benzinportale abzufragen, und mit tankerkoenig.de gibt es sogar ein Portal, das eine recht hübsche json API zur Verfügung stellt. Warum also nicht mal ein Modul schreiben, dass diese Schnittstelle nutzt und mir die Preise in meiner Gegend gibt.

Aktuell ist es auf das Tankerkoenig API zugeschnitten, aber ich denke, wenn ich die entsprechenden Funktionen um deine API ID erweitere, dann lassen sich leicht andere Quellen hinzufügen.

Takerkönig bietet die Preisabfrage entweder über Lokation und Radius oder über die IDs bestimmter Tankstellen.

Nachem ich hier nur schnell ein paar Zeilen zusammengeklöppelt habe (mir ging es mehr darum, zu verstehen, wie ein Modul from scratch entsteht), füllt es jetzt erstmal Readings mit den Werten nach Lokation/Radius. Der nächste Schritt ist dann aber die Funktion, die einfach nur für die Lokation und den Radius die IDs befüllt und danach nur noch per ID abfragt (die Last auf dem API ist, laut der Tankerkönig Doku) dann geringer.

Noch ist das hier sicher weit weg von tatsächlich nutzbar, aber vielleicht interessiert es ja den einen oder anderen und vielleicht gibt es auch noch ein paar Ideen, wie ich Dinge anders oder besser machen könnte. btw: mir ist bewusst, dass es gut dokumentierte Lösungen rund um den html Parser gibt, aber ich wollte es eben als Fingerübung für ein Modul nutzen.

72_Spritpreis.pm (ich habe die 72 gewählt, weil ein paar andere (fossile) Energie-Monitoring-Module da sind

##############################################
# $Id: 72_Spritpreis.pm 0 2017-01-10 12:00:00Z pjakobs $

package main;

use strict;
use warnings;

use Time::HiRes;
use Time::HiRes qw(usleep nanosleep);
use Time::HiRes qw(time);
use JSON::XS;
use Data::Dumper;
require "HttpUtils.pm";

$Data::Dumper::Indent = 1;
$Data::Dumper::Sortkeys = 1;

#####################################
#
# fhem skeleton functions
#
#####################################

sub
Spritpreis_Initialize(@) {
    my ($hash) = @_;

    $hash->{DefFn}          = 'Spritpreis_Define';
    $hash->{UndefFn}        = 'Spritpreis_Undef';
    $hash->{ShutdownFn}     = 'Spritpreis_Undef';
    $hash->{SetFn}          = 'Spritpreis_Set';
    $hash->{GetFn}          = 'Spritpreis_Get';
    $hash->{AttrFn}         = 'Spritpreis_Attr';
    $hash->{NotifyFn}       = 'Spritpreis_Notify';
    $hash->{ReadFn}         = 'Spritpreis_Read';
    $hash->{AttrList}       = "lat lon rad type sortby apikey"." $readingFnAttributes";
    return undef;
}

sub
Spritpreis_Define($$) {

    my ($hash, $def)=@_;
    my @parts=split("[ \t][ \t]*", $def);
    my $name=$parts[0];
    return undef;
}

sub
Spritpreis_Undef(@){
    return undef;
}

sub
Spritpreis_Set(@) {
    return undef;
}

sub
Spritpreis_Get(@) {
    my ($hash, $name, $cmd, @args) = @_;
    Spritpreis_Tankerkoenig_GetPricesForLocation($hash);
    # add price trigger here
    return $_;
}

sub
Spritpreis_Attr(@) {
    return undef;
}

sub
Spritpreis_Notify(@) {
    return undef;
}

sub
Spritpreis_Read(@) {
    return undef;
}

#####################################
#
# functions to create requests
#
#####################################

sub
Spritpreis_Tankerkoenig_GetIDsForLocation(@){
    my ($hash) = @_;
    my $lat=AttrVal($hash->{'NAME'}, "lat",0);
    my $lng=AttrVal($hash->{'NAME'}, "lon",0);
    my $rad=AttrVal($hash->{'NAME'}, "rad",5);
    my $type=AttrVal($hash->{'NAME'}, "type","diesel");
    my $sort=AttrVal($hash->{'NAME'}, "sortby","price");
    my $apikey=AttrVal($hash->{'NAME'}, "apikey","");

    if($apikey eq "") {
        Log3($hash,3,"$hash->{'NAME'}: please provide a valid apikey, you can get it from https://creativecommons.tankerkoenig.de/#register. This function can't work without it");
        return "err no APIKEY";
    }

    my $url="https://creativecommons.tankerkoenig.de/json/list.php?lat=$lat&lng=$lng&rad=$rad&type=$type&sort=$sort&apikey=$apikey";
    my $param = {
        url      => $url,
        timeout  => 2,
        hash     => $hash,
        method   => "GET",
        header   => "User-Agent: fhem\r\nAccept: application/json",
        parser   => \&Spritpreis_ParseIDsForLocation,
        callback => \&Spritpreis_callback
    };
    HttpUtils_NonblockingGet($param);

    return undef;
}

sub
Spritpreis_Tankerkoenig_GetPricesForIDs(@){
    my ($hash) = @_;

    return undef;
}

sub
Spritpreis_Tankerkoenig_GetPricesForLocation(@){
    my ($hash) = @_;

    my $lat=AttrVal($hash->{'NAME'}, "lat",0);
    my $lng=AttrVal($hash->{'NAME'}, "lon",0);
    my $rad=AttrVal($hash->{'NAME'}, "rad",5);
    my $type=AttrVal($hash->{'NAME'}, "type","diesel");
    my $sort=AttrVal($hash->{'NAME'}, "sortby","price");
    my $apikey=AttrVal($hash->{'NAME'}, "apikey","");

    if($apikey eq "") {
        Log3($hash,3,"$hash->{'NAME'}: please provide a valid apikey, you can get it from https://creativecommons.tankerkoenig.de/#register. This function can't work without it");
        return "err no APIKEY";
    }
    my $url="https://creativecommons.tankerkoenig.de/json/list.php?lat=$lat&lng=$lng&rad=$rad&type=$type&sort=$sort&apikey=$apikey";

    Log3($hash, 4,"$hash->{NAME}: sending request with url $url");
   
    my $param= {
        url      => $url,
        hash     => $hash,
        timeout  => 30,
        method   => "GET",
        header   => "User-Agent: fhem\r\nAccept: application/json",
        parser   => \&Spritpreis_ParsePricesForLocation,
        callback => \&Spritpreis_callback
     };
     HttpUtils_NonblockingGet($param);
     return undef;
}

#####################################
#
# functions to handle responses
#
#####################################

sub
Spritpreis_callback(@) {
     my ($param, $err, $data) = @_;
     my ($hash) = $param->{hash};

     # TODO generic error handling
     #Log3($hash, 5, "$hash->{NAME}: received callback with $data");
     # do the result-parser callback
     my $parser = $param->{parser};
     #Log3($hash, 4, "$hash->{NAME}: calling parser $parser with err $err and data $data");
     &$parser($hash, $err, $data);

     # Do readings update

     if( $err || $err ne ""){
         Log3 ($hash, 3, "$hash->{NAME} Readings NOT updated, received Error: ".$err);
     }
   return undef;
}

sub
Spritpreis_ParseIDsForLocation(@){
    return undef;
}

sub
Spritpreis_ParsePricesForLocation(@){
    my ($hash, $err, $data)=@_;
    my $result;

    Log3($hash,5,"$hash->{NAME}: ParsePricesForLocation has been called with err $err and data $data");

    if($err){
        Log3($hash, 4, "$hash->{NAME}: error fetching nformation");
    } elsif($data){
        Log3($hash, 4, "$hash->{NAME}: got PricesForLocation reply");
        Log3($hash, 5, "$hash->{NAME}: got data $data\n\n\n");

        eval {
            $result = JSON->new->utf8(1)->decode($data);
        };
        if ($@) {
            Log3 ($hash, 4, "$hash->{NAME}: error decoding response $@");
        } else {
            my ($stations) = $result->{stations};
            #Log3($hash, 5, "$hash->{NAME}: stations:".Dumper($stations));
            readingsBeginUpdate($hash);
            foreach (@{$stations}){
                (my $station)=$_;

                #Log3($hash, 5, "$hash->{NAME}: Station hash:".Dumper($station));
                Log3($hash, 2, "Name: $station->{name}, id: $station->{id}\n");
                my $number=0;
               
                # make sure we update a record with an existign id or create a new one for a new id
                while(ReadingsVal($hash->{NAME},$number."_id",$station->{id}) ne $station->{id})
                {
                    $number++;
                }
                readingsBulkUpdate($hash,$number."_name",$station->{name});
                readingsBulkUpdate($hash,$number."_price",$station->{price});
                readingsBulkUpdate($hash,$number."_place",$station->{place});
                readingsBulkUpdate($hash,$number."_street",$station->{street}." ".$station->{houseNumber});
                readingsBulkUpdate($hash,$number."_distance",$station->{dist});
                readingsBulkUpdate($hash,$number."_brand",$station->{brand});
                readingsBulkUpdate($hash,$number."_lat",$station->{lat});
                readingsBulkUpdate($hash,$number."_lon",$station->{lng});
                readingsBulkUpdate($hash,$number."_id",$station->{id});
                readingsBulkUpdate($hash,$number."_isOpen",$station->{isOpen});
            }
            readingsEndUpdate($hash,1);
        }         
    }else {
        Log3 ($hash, 4, "$hash->{NAME}: something's very odd");
    }
    return $data;
}

sub
Spritpreis_ParsePricesForIDs(@){
}

1;


die Moduldefinition braucht im Moment folgendes:

define tanke Spritpreis
attr tanke apikey <bitte eigenen key generieren>
attr tanke lat <latitude - siehe twilight>
attr tanke lon <longitude - siehe twilight>
attr tanke rad 5
attr tanke sortby price
attr tanke type e5

reibuehl

Tolle Idee! Wegen der Location: Würde es nicht Sinn machen, die "latitude" und "longitude" Werte aus dem "global" Device dafür zu nutzen?
Reiner.

pjakobs

Zitat von: reibuehl am 11 Januar 2017, 14:28:23
Tolle Idee! Wegen der Location: Würde es nicht Sinn machen, die "latitude" und "longitude" Werte aus dem "global" Device dafür zu nutzen?

als eine Option allemal, aber ich halte es auch für sinnvoll, ggf. mehrere Devices für unterschiedliche Lokationen zu haben (z.B. zuhause / Büro). Aber als Default, wenn nichts anderes angegeben ist, jap, das bau ich ein.

Danke

pj

pjakobs

so, ich hab's mal auf github geschoben, vielleicht interessiert's wen

alles noch Stückwerk und mir ist auch noch nicht ganz klar, wie ich damit umgehen will...

Eine Möglichkeit:
Ich implementiere eine "add location" Funktion, die eine Adresse übernimmt, die geokoordinaten bestimmt und die Tankstellen im Umkreis zur Liste hinzufügt. Damit könnte ein Gerät dann Tankstellen an mehreren Orten beinhalten.
Das Preisupdate würde dann immer nur die Liste der Tankstellen-IDs durchlaufen und für die schon bekannten neue Preise anfragen, egal, wo die sich befinden.
Tankstellen löschen ginge vermutlich genauso: Adresse oder geolokation und Radius übergeben, alle IDs die sich daraus ergeben werden aus den Readings geworfen.

Andere Möglichkeit:
die Lokation wird immer in den Attributen lat/lon gespeichert und die geolokations-Routine dient einfach nur dazu, die herauszufinden. Update wie oben. Wird keine Lokation angegeben, verwende ich die von $global, wird eine Adresse angegeben wird die aufgelöst und verwendet, aber nicht im Attribut gespeichert. Das heißt halt dann, dass zusätzlich zur Preisabfrage bei jedem Durchlauf (ich denke irgendwo zwischen alle 5 und 15 Minuten ist sinnvoll) eine Anfrage an Google Maps geschickt wird.

Was ich gerade noch nicht verstehe: wenn ich fhem neu starten muss (und das kommt ja leider manchmal vor, wenn man dicke Finger hat), dann sind alle Readings weg. Ich nehme an, ich muss die bei der Initialisierung irgendwoher lesen...?

pj


martins

Sehr gute Idee mit dem Modul. Ich ringe seit Monaten damit ein Modul für Tankerkönig zuschreiben, bin aber aus Zeitmangeln nicht dazu gekommen. Umso besser das du es jetzt gemacht hast. :) Werde dies heute Abend testen.

Als weitere Idee: Die Tankstellen nicht nach Geokoordianten suchen, sondern die ID's direkt in einem Attribut übergeben.

pjakobs

Zitat von: martins am 12 Januar 2017, 09:08:31
Sehr gute Idee mit dem Modul. Ich ringe seit Monaten damit ein Modul für Tankerkönig zuschreiben, bin aber aus Zeitmangeln nicht dazu gekommen. Umso besser das du es jetzt gemacht hast. :) Werde dies heute Abend testen.
erwarte bitte nix poliertes, es ist ziemlich "rough"
Zitat von: martins am 12 Januar 2017, 09:08:31
Als weitere Idee: Die Tankstellen nicht nach Geokoordianten suchen, sondern die ID's direkt in einem Attribut übergeben.
Ich bin noch am überlegen, wie ich das vom Bedienungsablauf machen.

Irgendwie muss der Anwender ja die IDs der Tankstellen bekommen und idealerweise sollte er das über das Modul können.
Dazu kann er entweder die Geookoordinaten nutzen, oder, jetzt neu, auch die Adresse.
Dann sollte er die interessanten Tankstellen auswählen können und danach werden nur noch die angefragt.

pj

Hollo

Tankstellen in einem Radius sind mM. vermutlich zu viele (wohne auf dem Dorf, also dicht dran 0 und bei größerem Radius sehr viele).

Interessant fände ich Tankstellen zwischen 2 Koordinaten und dann Auswahlmöglichkeit, welche man davon möchte.
Dann könnte man z.B. die sehen, die auf dem täglichen "Arbeitsweg" (Wohnung<->Arbeit oder so) liegen.
FHEM 6.x auf RPi 3B Buster
Protokolle: Homematic, Z-Wave, MQTT, Modbus
Temp/Feuchte: JeeLink-Clone und LGW mit LaCrosse/IT
sonstiges: Linux-Server, Dreambox, "RSS-Tablet"

CoolTux

Zitat von: pjakobs am 12 Januar 2017, 12:28:21
erwarte bitte nix poliertes, es ist ziemlich "rough"Ich bin noch am überlegen, wie ich das vom Bedienungsablauf machen.

Irgendwie muss der Anwender ja die IDs der Tankstellen bekommen und idealerweise sollte er das über das Modul können.
Dazu kann er entweder die Geookoordinaten nutzen, oder, jetzt neu, auch die Adresse.
Dann sollte er die interessanten Tankstellen auswählen können und danach werden nur noch die angefragt.

pj

Schau Dir hierfür mal das UWZ Modul an. Interessant für Dich sollten UWZSearch* sein. Sind zwei Funktionen welche über einen Get Befehl aufgerufen werden. In der Antwort enthalten sind gleich Links um automatisch den korrekten Wert an zu legen.



Grüße
Leon
Du musst nicht wissen wie es geht! Du musst nur wissen wo es steht, wie es geht.
Support me to buy new test hardware for development: https://www.paypal.com/paypalme/MOldenburg
My FHEM Git: https://git.cooltux.net/FHEM/
Das TuxNet Wiki:
https://www.cooltux.net

suppenesser

Die idee mit der Strecke von A nach B finde ich recht interessant, zusammen mit zb. der Standort Funktion in Telegram würde ich das als genial empfinden.

Strecke von A nach B im interval aktualisieren und, wenn von Telegram ein Standort kommt, davon im Umkreis von x km ermitteln. Das könnte man dann wiederum an Telegram schicken, wenn Bedarf!

Gesendet von meinem HUAWEI GRA-L09 mit Tapatalk

Raspberry PI B+ | HM-LAN-CFG | HM-LC-Sw1PBU-FM | HM-TC-WM-W-EU | DECT 200 | DHT22 | 1 Wire Temp.Sensoren

pjakobs

Zitat von: Hollo am 12 Januar 2017, 12:34:16
Tankstellen in einem Radius sind mM. vermutlich zu viele (wohne auf dem Dorf, also dicht dran 0 und bei größerem Radius sehr viele).

Interessant fände ich Tankstellen zwischen 2 Koordinaten und dann Auswahlmöglichkeit, welche man davon möchte.
Dann könnte man z.B. die sehen, die auf dem täglichen "Arbeitsweg" (Wohnung<->Arbeit oder so) liegen.
Jetzt wird's aber anspruchsvoll ;-)
Ich denke, das lässt sich in erster Näherung mal mit mehreren Suchen im Radius um Punkte auf der Strecke machen (ich würde jetzt ungern auch noch mit Routenwahl anfangen) und Du suchst Dir dann die aus, die sinnvoll liegen. Das Modul klappert dann die eingegebenen Tankstellen an.

Für die Navigation unterwegs gibt's doch genug Navis mit Tankstellenfinder.

pj

pjakobs

Zitat von: CoolTux am 12 Januar 2017, 12:47:29
Schau Dir hierfür mal das UWZ Modul an. Interessant für Dich sollten UWZSearch* sein. Sind zwei Funktionen welche über einen Get Befehl aufgerufen werden. In der Antwort enthalten sind gleich Links um automatisch den korrekten Wert an zu legen.



Grüße
Leon

Ah, ich sehe, das generiert eine html Tablelle aus der ich dann eine Zeile aussuchen kann.
d.h. ich kann, auf ähnliche Art, ein html Multiselect Form generieren und der Nutzer kann die gewünschten Tankstellen aussuchen. Cool! Danke :-) (vorausgesetzt, ich kann mit /fhem?cmd= " jedes beliebige fhem Kommando aufrufen. Was mich ja dann so Securitymäßig nervös macht. Aber das UI ist hier eh nicht abgesichert. Argh! Internet of Things und Security! Ich brauch'n Schnaps! ;-) )

pj

martins

Also erster Test sieht soweit mal gut aus und Ergebnisse werden angezeigt. Aber wie du schon selbst sagtes, es muss noch weiterentwickelt werden.

Die Return Werte return "err no APIKEY"; musste ich ändern in return undef; sonst ist mir FHEM jedesmal abgestürzt.

Das Update würde ich persönlich über ein set update machen.

pjakobs

Zitat von: martins am 12 Januar 2017, 22:49:13
Also erster Test sieht soweit mal gut aus und Ergebnisse werden angezeigt. Aber wie du schon selbst sagtes, es muss noch weiterentwickelt werden.

Die Return Werte return "err no APIKEY"; musste ich ändern in return undef; sonst ist mir FHEM jedesmal abgestürzt.

hmm... dann frag ich mich, wo darf ich eigentlich Strings zurückgeben und wo nicht.
Na ja, wie gesagt: mir ging es hier ja primär darum, zu lernen, wie Module from scratch gebaut werden müssen.

Zitat von: martins am 12 Januar 2017, 22:49:13
Das Update würde ich persönlich über ein set update machen.

Sicher, ich hab erstmal eine Routine geschireben, die überhaupt das API bedient und sie irgendwo eingebunden.
Gerade bin ich dabei, die Auswahl der Tankstellen zu ermöglichen, danach sehen wir weiter :-)

Danke für's Feedback

pj

CoolTux

Packe Deinen String in eine Variable und gebe diese zurück.
Du musst nicht wissen wie es geht! Du musst nur wissen wo es steht, wie es geht.
Support me to buy new test hardware for development: https://www.paypal.com/paypalme/MOldenburg
My FHEM Git: https://git.cooltux.net/FHEM/
Das TuxNet Wiki:
https://www.cooltux.net

pjakobs

Zitat von: CoolTux am 13 Januar 2017, 10:05:52
Packe Deinen String in eine Variable und gebe diese zurück.
Ernsthaft? Es return geht nicht mit string literals? :o

Ich hab übrigens mal die Sache mit der Tankstellenauswahl gebaut, das funktioniert ganz gut, aber wenn ich eine html Form zurückgebe, dann kann ich offenbar keinen submit Button einbauen bzw. der wird nicht angezeigt. Hast Du da ne Idee?

Grüße

pj