diff --git a/fhem/FHEM/31_Nello.pm b/fhem/FHEM/31_Nello.pm new file mode 100644 index 000000000..d79f955f9 --- /dev/null +++ b/fhem/FHEM/31_Nello.pm @@ -0,0 +1,649 @@ +############################################################################## +# $Id$ +# +# 31_Nello.pm +# +# 2017 Oskar Neumann +# oskar.neumann@me.com +# +############################################################################## + +# required packets +# libcrypt-pbkdf2-perl +# Net::MQTT + +package main; + +use strict; +use warnings; + +use JSON; + +use Crypt::PBKDF2; +use Digest::SHA qw(sha256); +use Date::Parse; + + +sub Nello_Initialize($) { + my ($hash) = @_; + + $hash->{DefFn} = 'Nello_Define'; + $hash->{NotifyFn} = 'Nello_Notify'; + $hash->{UndefFn} = 'Nello_Undefine'; + $hash->{SetFn} = 'Nello_Set'; + $hash->{GetFn} = 'Nello_Get'; + $hash->{AttrFn} = "Nello_Attr"; + $hash->{AttrList} = 'updateInterval disable:0,1 deviceID '; + $hash->{AttrList} .= $readingFnAttributes; + $hash->{NOTIFYDEV} = "global"; +} + +sub Nello_Define($) { + my ($hash, $def) = @_; + my $name = $hash->{NAME}; + my @a = split("[ \t][ \t]*", $def); + + Nello_loadInternals($hash) if($init_done); + + return undef; +} + +sub Nello_Undefine($$) { + my ($hash, $name) = @_; + RemoveInternalTimer($hash); + return undef; +} + +sub Nello_Notify($$) { + my ($own_hash, $dev_hash) = @_; + my $ownName = $own_hash->{NAME}; # own name / hash + + return "" if(IsDisabled($ownName)); # Return without any further action if the module is disabled + + my $devName = $dev_hash->{NAME}; # Device that created the events + my $events = deviceEvents($dev_hash, 1); + + if($devName eq "global" && grep(m/^INITIALIZED|REREADCFG$/, @{$events})) { + Nello_loadInternals($own_hash); + } + + my $deviceID = $attr{$ownName}{deviceID}; + if (defined $deviceID && $devName eq 'Nello_Events') { + my $events = deviceEvents( $dev_hash, 1 ); + return "" unless ($events); + + foreach ( @{$events} ) { + if(my ($action) = $_ =~ m/${deviceID}_(door|ring|tw):.*/) { + if($action eq 'ring') { + $own_hash->{helper}{last_ring} = time(); + Log3 "Nello", 3, $ownName . " ring"; + readingsSingleUpdate($own_hash, 'last_ring', time(), 1); + } + + if($action eq 'door' && (!defined $own_hash->{helper}{last_opened} || time() - $own_hash->{helper}{last_opened} > 3)) { + Log3 "Nello", 3, $ownName. " opened"; + readingsSingleUpdate($own_hash, 'last_user_open', time(), 1); + readingsSingleUpdate($own_hash, 'last_open', time(), 1); + } + + InternalTimer(gettimeofday()+1, "Nello_updateActivities", $own_hash); + InternalTimer(gettimeofday()+2, "Nello_updateActivities", $own_hash); + } + } + } +} + +sub Nello_Set($$@) { + my ($hash, $name, $cmd, @args) = @_; + + return "\"set $name\" needs at least one argument" unless(defined($cmd)); + + my $list = ''; + + if(!defined $hash->{helper}{session}) { + $list .= ' login recoverPassword'; + } else { + $list .= ' open:noArg update:noArg detectDeviceID:noArg'; + } + + return Nello_login($hash, $args[0], $args[1]) if($cmd eq 'login'); + return Nello_detectDeviceID($hash) if($cmd eq 'detectDeviceID'); + return Nello_recoverPassword($hash, $args[0], $args[1], $args[2]) if($cmd eq 'recoverPassword'); + if($cmd eq 'update') { + Nello_updateLocations($hash); + return Nello_updateActivities($hash); + } + return Nello_open($hash, $args[0]) if($cmd eq 'open'); + + return "Unknown argument $cmd, choose one of $list"; +} + +sub Nello_Get($$@) { + my ($hash, $name, $cmd, @args) = @_; + + my $list = ""; + + return "Unknown argument $cmd, choose one of $list"; +} + +sub Nello_Attr(@) { + my ($cmd, $name, $attrName, $attrValue) = @_; + + my $hash = $main::defs{$name}; + if($attrName eq 'updateInterval') { + RemoveInternalTimer($hash); + Nello_poll($hash); + } + + if($attrName eq 'deviceID' && $init_done) { + my $bridge = 'Nello_MQTT'; + if(!internalExists($bridge, "TYPE")) { + CommandDefine(undef, $bridge . ' MQTT ec2-35-157-44-19.eu-central-1.compute.amazonaws.com:1883'); + CommandAttr(undef, $bridge . ' room hidden'); + CommandSave(undef, undef); + } + + my $eventdevice = 'Nello_Events'; + if(!internalExists($eventdevice, 'TYPE')) { + CommandDefine(undef, $eventdevice . ' MQTT_DEVICE'); + CommandAttr(undef, $eventdevice . ' room hidden'); + CommandAttr(undef, $eventdevice . ' IODEV '. $bridge); + CommandAttr(undef, $eventdevice . ' subscribeReading_'. $attrValue .'_door /nello_one/'. $attrValue . '/door/'); + CommandAttr(undef, $eventdevice . ' subscribeReading_'. $attrValue .'_ring /nello_one/'. $attrValue . '/ring/'); + CommandAttr(undef, $eventdevice . ' subscribeReading_'. $attrValue .'_tw /nello_one/'. $attrValue . '/tw/'); + CommandSave(undef, undef); + } + } + + return undef; +} + +sub Nello_loadInternals($) { + my ($hash) = @_; + my $name = $hash->{NAME}; + $hash->{helper}{expires} = ReadingsVal($name, '.expires', undef); + $hash->{helper}{session} = ReadingsVal($name, '.session', undef); + + if(!defined(ReadingsVal($name, '.session', undef))) { + $hash->{STATE} = 'authorization pending'; + } else { + $hash->{STATE} = 'connected'; + $attr{$name}{webCmd} = 'open' if(!defined $attr{$name}{webCmd}); + + Nello_updateLocations($hash, 0); + Nello_poll($hash); + } +} + +sub Nello_login { + my ($hash, $username, $password) = @_; + my $name = $hash->{NAME}; + + return 'wrong syntax: set login ' if(!defined $username || !defined $password); + Nello_authenticate($hash, $username, Nello_passwordHash($username, $password)); + + return undef; +} + +sub Nello_passwordHash { + my ($username, $password) = @_; + my $salt = sha256($username . $password); + + my $pbkdf2 = Crypt::PBKDF2->new( + hash_class => 'HMACSHA1', + iterations => 4000, + output_len => 32 + ); + + return uc $pbkdf2->PBKDF2_hex($salt, $password); +} + +sub Nello_authenticate { + my ($hash, $username, $authhash) = @_; + my $name = $hash->{NAME}; + + $username = ReadingsVal($name, "username", undef) if(!defined $username); + $authhash = ReadingsVal($name, ".authtoken", undef) if(!defined $authhash); + + Nello_apiRequest($hash, 'login', {username => $username, password => $authhash}, 'POST', 0); + return undef; +} + +sub Nello_updateLocations { + my ($hash, $blocking) = @_; + Nello_apiRequest($hash, 'locations/', undef, 'GET', $blocking); + return undef; +} + +sub Nello_updateActivities { + my ($hash) = @_; + Nello_apiRequest($hash, 'locations/'. Nello_defaultLocationID($hash) . '/activity', undef, 'GET', 0); + return undef; +} + +sub Nello_apiRequest { + my ($hash, $path, $args, $method, $blocking) = @_; + + if(!defined $blocking || !$blocking) { + HttpUtils_NonblockingGet({ + url => "https://api.nello.io/$path", + method => $method, + hash => $hash, + apiPath => $path, + timeout => 15, + noshutdown => 1, + data => $method eq 'POST' && defined $args ? encode_json $args : $args, + header => "Cookie: session=". $hash->{helper}{session}, + callback => \&Nello_dispatch + }); + } else { + my ($err,$data) = HttpUtils_BlockingGet({ + url => "https://api.nello.io/$path", + method => $method, + hash => $hash, + apiPath => $path, + timeout => 15, + noshutdown => 1, + data => $method eq 'POST' && defined $args ? encode_json $args : $args, + header => "Cookie: session=". $hash->{helper}{session} + }); + return Nello_dispatch({hash => $hash, apiPath => $path, method => $method, data => $args}, $err, $data); + } +} + +sub Nello_dispatch($$$) { + my ($param, $err, $data) = @_; + my $hash = $param->{hash}; + my $name = $hash->{NAME}; + my ($path) = split('\?', $param->{apiPath}, 2); + my ($pathpt0, $pathpt1, $pathpt2, $pathpt3, $pathpt4) = split('/', $path, 5); + my $method = $param->{method}; + my $header = $param->{httpheader}; + my $args = $param->{data}; + delete $hash->{helper}{dispatch}; + + if(!defined($param->{hash})){ + Log3 "Nello", 2, 'Nello: dispatch fail (hash missing)'; + return undef; + } + + $args = eval { JSON->new->utf8(0)->decode($args) } if(defined $args); + + my $json = eval { JSON->new->utf8(0)->decode($data) }; + $hash->{helper}{dispatch}{json} = $json; + my $status = $json->{result}{status}; + my $successful = $status && ($status eq "200" || lc $status eq "ok"); + + #Log3 "Nello", 3, $header; + #Log3 $name, 3, $name . ' : ' . $hash . $data; + + if($path eq 'login') { + if(defined $json->{authentication} && $json->{authentication} && defined $header) { + Log3 "Nello", 3, "$name: login successful"; + + my ($session, $expires) = $header =~ m/:[^:]*session=([^;]*); Expires=([^;]*);/; + + readingsBeginUpdate($hash); + readingsBulkUpdateIfChanged($hash, 'username', $args->{username}); + readingsBulkUpdateIfChanged($hash, '.authtoken', $args->{password}); + readingsBulkUpdateIfChanged($hash, '.session', $session); + readingsBulkUpdate($hash, '.expires', $expires); + readingsBulkUpdateIfChanged($hash, 'user_id', $json->{user}{user_id}); + Nello_saveLocations($hash, $json->{user}{roles}, 0); + readingsEndUpdate($hash, 1); + + Nello_poll($hash) if($hash->{STATE} ne 'connected'); + $hash->{STATE} = 'connected'; + $hash->{helper}{session} = $session; + + my $failReq = $hash->{helper}{authfail}; + Nello_apiRequest($hash, $failReq->{path}, $failReq->{data}, $failReq->{method}, 0) if(defined $failReq); # repeat failed request + } else { + Log3 "Nello", 3, "$name: login failed"; + CommandDeleteReading(undef, "$name .*"); + $hash->{STATE} = 'authentication pending'; + delete $hash->{helper}{session}; + } + + delete $hash->{helper}{authfail}; + } + + if(defined $json->{result} && defined $json->{result}{status} && $json->{result}{status} eq "400") { + Nello_authenticate($hash); + $hash->{helper}{authfail} = {data => $args, path => $path, method => $method}; + } + + if($path eq 'locations/') { + Nello_saveLocations($hash, $json->{user}{roles}, 1); + Nello_open($hash) if(defined $hash->{helper}{retryopen}); + } + + if(defined $pathpt4 && $pathpt4 eq 'open') { + Log3 $name, 3, $name . ': ' . ($successful ? 'opened' : 'open failed'); + if(!defined $attr{$name}{deviceID}) { + Nello_updateActivities($hash); + InternalTimer(gettimeofday()+2, "Nello_updateActivities", $hash); + } + } + + if(defined $pathpt2 && $pathpt2 eq 'activity') { + my $last = ReadingsVal($name, '.last_activity', undef); + + if(defined $json->{activities} && @{$json->{activities}} > 0) { + if(defined $last) { + foreach my $activity (reverse @{$json->{activities}}) { + my $time = str2time($activity->{date}); + $time = round($time, 0) if(defined $time); + if($time > $last) { + my $didring = ($activity->{type} eq 'door.open.one.tw' || $activity->{type} =~ m/bell.ring/) && (!defined $hash->{helper}{last_ring} || time() - $hash->{helper}{last_ring} > 5); + delete $hash->{helper}{last_ring}; + + readingsBeginUpdate($hash); + readingsBulkUpdate($hash, 'activity', $activity->{type}); + readingsBulkUpdate($hash, 'activity_text', $activity->{description}); + readingsBulkUpdate($hash, 'activity_time', $time); + readingsBulkUpdate($hash, 'last_user_open', $time) if($activity->{type} eq 'door.open.one.user'); + readingsBulkUpdate($hash, 'last_timewindow_open', $time) if($activity->{type} eq 'door.open.one.tw'); + readingsBulkUpdate($hash, 'last_open', $time) if($activity->{type} =~ m/open/); + readingsBulkUpdate($hash, 'last_ring', $time) if($didring); + readingsBulkUpdate($hash, 'last_ring_denied', $time) if($activity->{type} eq 'bell.ring.denied'); + readingsEndUpdate($hash, 1); + + Log3 $name, 3, $name. ' ring' if($didring); + Log3 $name, 3, $name. ': '. $activity->{description}; + } + } + } + + my $next = $json->{activities}[0]; + $last = round(str2time($next->{date}), 0); + } else { + $last = time(); + } + + readingsSingleUpdate($hash, '.last_activity', $last, 1); + } + + if($path eq 'detectMQTT') { + if($successful) { + CommandAttr(undef, $name . " deviceID ". $json->{id}); + CommandSave(undef, undef); + Log3 $name, 3, $name . ": successfully detected device ID"; + } else { + delete $hash->{helper}{detectMQTT}; + if($status eq 'busy') { + Log3 $name, 3, $name . ": busy detecting device ID, trying again."; + InternalTimer(gettimeofday()+5, "Nello_detectDeviceID", $hash); + } else { + Log3 $name, 3, $name . ": failed to detect deviceID"; + } + + } + } + + if($path eq 'recover-password') { + Log3 $name, 3, $name .": " . $json->{result}{message}; + } + + return undef; +} + +sub Nello_saveLocations($$$) { + my ($hash, $locations, $beginUpdate) = @_; + my $name = $hash->{NAME}; + + CommandDeleteReading(undef, "$name location_.*"); + + readingsBeginUpdate($hash) if($beginUpdate); + + my $index = 0; + foreach my $location (@{$locations}) { + my $prefix = "location_". ($index+1); + readingsBulkUpdate($hash, $prefix. "_id", $location->{location_id}, 1); + readingsBulkUpdate($hash, $prefix. "_ssid", $location->{home_ssid}, 1); + readingsBulkUpdate($hash, $prefix. "_role", $location->{role}, 1); + readingsBulkUpdate($hash, $prefix. "_active", $location->{is_active} ? 1 : 0, 1); + $index++; + } + + readingsBulkUpdate($hash, "locations", $index, 1); + readingsEndUpdate($hash, 1) if($beginUpdate); +} + +sub Nello_open { + my ($hash, $location_id) = @_; + my $name = $hash->{NAME}; + + $location_id = ReadingsVal($name, 'location_'. $location_id . '_id', undef) if(defined $location_id); + $location_id = Nello_defaultLocationID($hash) if(!defined $location_id); + if(!defined $location_id && !defined $hash->{helper}{retryopen}) { + $hash->{helper}{retryopen} = 1; + return Nello_updateLocations($hash); + } + + delete $hash->{helper}{retryopen} if(defined $hash->{helper}{retryopen}); + return 'no location available' if(!defined $location_id); + + my $user_id = ReadingsVal($name, 'user_id', undef); + Nello_apiRequest($hash, "locations/$location_id/users/$user_id/open", {type => "swipe"}, 'POST', 0); + $hash->{helper}{last_opened} = time(); + + return undef; +} + +sub Nello_recoverPassword { + my ($hash, $username, $temppw) = @_; + return 'wrong syntax: set recoverPassword [ ]' if(!defined $username); + + return Nello_apiRequest($hash, 'recover-password', {username => $username}, 'POST', 0) if(!defined $temppw); + return Nello_authenticate($hash, $username, $temppw); +} + +sub Nello_poll { + my ($hash) = @_; + my $name = $hash->{NAME}; + return if(Nello_isDisabled($hash)); + + my $pollInterval = $attr{$name}{updateInterval}; + InternalTimer(gettimeofday()+(defined $pollInterval ? $pollInterval : (!defined $attr{$name}{deviceID} ? 15 : 15*60)), "Nello_poll", $hash); + Nello_updateActivities($hash); +} + +sub Nello_isDisabled($) { + my ($hash) = @_; + my $name = $hash->{NAME}; + return defined $attr{$name}{disable}; +} + +sub Nello_defaultLocationID { + my ($hash) = @_; + my $name = $hash->{NAME}; + return ReadingsVal($name, 'location_1_id', undef); +} + +sub Nello_detectDeviceID { + my ($hash) = @_; + HttpUtils_NonblockingGet({ + url => "http://nello.oskar.pw/detectMQTT.php", + method => 'GET', + hash => $hash, + apiPath => 'detectMQTT', + timeout => 15, + noshutdown => 1, + callback => \&Nello_dispatch + }); + + $hash->{helper}{detectMQTT} = 1; + InternalTimer(gettimeofday()+2, "Nello_detectExecute", $hash); + + return undef; +} + +sub Nello_detectExecute { + my ($hash) = @_; + my $name = $hash->{NAME}; + + return if(!defined $hash->{helper}{detectMQTT}); + + Log3 $name, 3, $name . ": opening door to detect device ID now."; + + Nello_open($hash); + Nello_open($hash); +} + +1; + +=pod +=item device +=item summary control your intercom with nello one +=item summary_DE Steuerung der Gegensprechanlage mit nello one +=begin html + + +

Nello

+
    + The Nello module enables you to control your intercom using the nello one module.
    + To set it up, you need to add a new user via the nello app just for use with fhem. You cannot use your main account since only one session at a time is possible.
    + After that, you can define the device and continue with login.
    + ATTENTION: If the new account was created on an iOS device you need to call the recover function and do the login through set <name> recoverPassword <username> <temporary_password>. + Android users can use the default login function.
    + Recommendation: To receive instant events, call the detectDeviceID function after login.
    +
    +

    Required Packages

    + + sudo apt-get install libcrypt-pbkdf2-perl
    + sudo cpan -i Net::MQTT::Simple +
    +
    +
    +
    + +

    Define

    +
      + define <name> Nello
      +
    +
    +
      + Example: define nello Nello
      +
    +
    +
    + +

    set <required> [ <optional> ]

    +
      +
    • + login <username> <password>
      + login to your created account +
    • +
    • + recoverPassword <username> [ <temporary_password> ]
      + recovers the password or executes the login using a temporary password +
    • + +
    • + detectDeviceID
      + detects your device ID by opening the door and creates MQTT helper (used for event hooks) +
    • +
    • + open [ <location_id> ]
      + opens the door for a given location (if the account has only access to one location the default one will be used automatically). +
    • +
    • + update
      + updates your locations and activities +
    • +
    +
    + +

    Get

    +
      + N/A +
    +
    + +

    Attributes

    +
      +
    • + updateInterval
      + the interval to fetch new activites in seconds
      + default: 900 (if deviceID is available), 15 otherwise +
    • +
    +
+ +=end html +=begin html_DE + + +

Nello

+
    + Das Nello Modul ermöglicht die Steuerung des nello one Chips.
    + Um es aufzusetzen, muss zunächst ein neuer Nutzer in der Nello-App angelegt werden, der nur für FHEM verwendet wird - eine Nutzung per App ist mit diesem Account dann nicht mehr möglich.
    + Anschließend kann das Gerät angelegt werden. Sobald das Gerät erstellt wurde, kann der Login durchgeführt werden.
    + ACHTUNG: Wurde der Account auf einem iOS-Gerät erstellt muss wegen eines Bugs das Passwort über die recoverPassword Funktion zurückgesetzt und anschließend der Login mit set <name> recoverPassword <username> <temporary_password> durchgeführt werden. + Ansonsten wird der Login fehlschlagen. Android-Nutzer können das normale login verwenden.
    + Dringend empfohlen: Für verzögerungsfreie Events die detectDeviceID Funktion nach dem Login aufrufen.
    +
    +

    Benötigte Pakete

    + + sudo apt-get install libcrypt-pbkdf2-perl
    + sudo cpan -i Net::MQTT::Simple +
    +
    +
    +
    + +

    Define

    +
      + define <name> Nello
      +
    +
    +
      + Beispiel: define nello Nello
      +
    +
    + +

    set <required> [ <optional> ]

    +
      +
    • + login <username> <password>
      + Login in einen Android-Account +
    • +
    • + recoverPassword <username> [ <temporary_password> ]
      + setzt das Passwort zurück oder führt den Login mit temporären Passwort durch +
    • + +
    • + detectDeviceID
      + erkennt die Geräte-ID des Nellos durch einmaliges Öffnen der Tür und erstellt MQTT-Helper-Geräte für verzögerungsfreie Ereignisse +
    • +
    • + open [ <location_id> ]
      + öffnet die Tür +
    • +
    • + update
      + aktualisiert Aktionen und Ereignisse +
    • +
    +
    + +

    Get

    +
      + N/A +
    +
    + +

    Attribute

    +
      +
    • + updateInterval
      + das Intervall in Sekunden, in dem Ereignisse gepollt werden (nur relevant, wenn deviceID nicht erkannt wurde)
      + default: 900 (wenn Geräte-ID erkannt wurde), ansonten 15 +
    • +
    +
+ +=end html_DE +=cut \ No newline at end of file