From f0aba4802ea84676032da72b87aefc560f341a12 Mon Sep 17 00:00:00 2001
From: vuffiraa <>
Date: Thu, 31 Jan 2019 20:43:27 +0000
Subject: [PATCH] 70_BOTVAC.pm: initial release
git-svn-id: https://svn.fhem.de/fhem/trunk@18474 2b470e98-0d58-463d-a4d8-8e2adae1ed80
---
fhem/CHANGED | 1 +
fhem/FHEM/70_BOTVAC.pm | 2124 ++++++++++++++++++++++++++++++++++++++++
fhem/MAINTAINER.txt | 1 +
3 files changed, 2126 insertions(+)
create mode 100755 fhem/FHEM/70_BOTVAC.pm
diff --git a/fhem/CHANGED b/fhem/CHANGED
index 1256feca3..80bf8b6cb 100644
--- a/fhem/CHANGED
+++ b/fhem/CHANGED
@@ -1,5 +1,6 @@
# Add changes at the top of the list. Keep it in ASCII, and 80-char wide.
# Do not insert empty lines here, update check depends on it.
+ - new: 70_BOTVAC: initial release
- feature: 73_AutoShuttersControl: change blocking handle, add privacy drive
- bugfix: 49_SSCam: refresh snapgallery device if snap was done by itself
- feature: 49_SSCam: V8.7.0, send recordings by email is possible now
diff --git a/fhem/FHEM/70_BOTVAC.pm b/fhem/FHEM/70_BOTVAC.pm
new file mode 100755
index 000000000..828bf6524
--- /dev/null
+++ b/fhem/FHEM/70_BOTVAC.pm
@@ -0,0 +1,2124 @@
+# $Id$
+##############################################################################
+#
+# 70_BOTVAC.pm
+# An FHEM Perl module for controlling a Neato BotVacConnected.
+#
+# Copyright by Ulf von Mersewsky
+# e-mail: umersewsky at gmail.com
+#
+# This file is part of fhem.
+#
+# Fhem is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 2 of the License, or
+# (at your option) any later version.
+#
+# Fhem is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with fhem. If not, see .
+#
+##############################################################################
+
+package main;
+
+use strict;
+use warnings;
+
+
+sub BOTVAC_Initialize($) {
+ my ($hash) = @_;
+
+ $hash->{DefFn} = "BOTVAC::Define";
+ $hash->{GetFn} = "BOTVAC::Get";
+ $hash->{SetFn} = "BOTVAC::Set";
+ $hash->{UndefFn} = "BOTVAC::Undefine";
+ $hash->{DeleteFn} = "BOTVAC::Delete";
+ $hash->{ReadFn} = "BOTVAC::wsRead";
+ $hash->{ReadyFn} = "BOTVAC::wsReady";
+ $hash->{AttrFn} = "BOTVAC::Attr";
+ $hash->{AttrList} = "disable:0,1 " .
+ "actionInterval " .
+ "boundaries:textField-long " .
+ $::readingFnAttributes;
+}
+
+package BOTVAC;
+
+use strict;
+use warnings;
+use POSIX;
+
+use GPUtils qw(:all); # wird für den Import der FHEM Funktionen aus der fhem.pl benötigt
+
+use Time::HiRes qw(gettimeofday);
+use JSON qw(decode_json encode_json);
+#use IO::Socket::SSL::Utils qw(PEM_string2cert);
+use Digest::SHA qw(hmac_sha256_hex sha1_hex);
+use Encode qw(encode_utf8);
+use MIME::Base64;
+
+require "DevIo.pm";
+require "HttpUtils.pm";
+
+## Import der FHEM Funktionen
+BEGIN {
+ GP_Import(qw(
+ AttrVal
+ createUniqueId
+ FmtDateTime
+ FmtDateTimeRFC1123
+ fhemTzOffset
+ getKeyValue
+ setKeyValue
+ getUniqueId
+ InternalTimer
+ InternalVal
+ readingsSingleUpdate
+ readingsBulkUpdate
+ readingsBulkUpdateIfChanged
+ readingsBeginUpdate
+ readingsEndUpdate
+ ReadingsNum
+ ReadingsVal
+ RemoveInternalTimer
+ Log3
+ trim
+ ))
+};
+
+my %opcode = ( # Opcode interpretation of the ws "Payload data
+ 'continuation' => 0x00,
+ 'text' => 0x01,
+ 'binary' => 0x02,
+ 'close' => 0x08,
+ 'ping' => 0x09,
+ 'pong' => 0x0A
+);
+
+###################################
+sub Define($$) {
+ my ( $hash, $def ) = @_;
+ my @a = split( "[ \t][ \t]*", $def );
+ my $name = $hash->{NAME};
+
+ Log3($name, 5, "BOTVAC $name: called function Define()");
+
+ if ( int(@a) < 3 ) {
+ my $msg =
+ "Wrong syntax: define BOTVAC [] []";
+ Log3($name, 4, $msg);
+ return $msg;
+ }
+
+ $hash->{TYPE} = "BOTVAC";
+
+ my $email = $a[2];
+ $hash->{EMAIL} = $email;
+
+ # defaults
+ my $vendor = "neato";
+ my $interval = 85;
+
+ if (defined($a[3])) {
+ if ($a[3] =~ /^(neato|vorwerk)$/) {
+ $vendor = $a[3];
+ $interval = $a[4] if (defined($a[4]));
+ } elsif ($a[3] =~ /^[0-9]+$/ and not defined($a[4])) {
+ $interval = $a[3];
+ } else {
+ StorePassword($hash, $a[3]);
+ if (defined($a[4])) {
+ if ($a[4] =~ /^(neato|vorwerk)$/) {
+ $vendor = $a[4];
+ $interval = $a[5] if (defined($a[5]));
+ } else {
+ $interval = $a[5];
+ }
+ }
+ }
+ }
+ $hash->{VENDOR} = $vendor;
+ $hash->{INTERVAL} = $interval;
+
+ unless ( defined( AttrVal( $name, "webCmd", undef ) ) ) {
+ $::attr{$name}{webCmd} = 'startCleaning:stop:sendToBase';
+ }
+
+ # start the status update timer
+ RemoveInternalTimer($hash);
+ InternalTimer( gettimeofday() + 2, "BOTVAC::GetStatus", $hash, 1 );
+
+ AddExtension($name, "BOTVAC::GetMap", "BOTVAC/$name/map");
+
+ return;
+}
+
+#####################################
+sub GetStatus($;$) {
+ my ( $hash, $update ) = @_;
+ my $name = $hash->{NAME};
+ my $interval = $hash->{INTERVAL};
+ my @successor = ();
+
+ Log3($name, 5, "BOTVAC $name: called function GetStatus()");
+
+ # use actionInterval if state is busy or paused
+ $interval = AttrVal($name, "actionInterval", $interval) if (ReadingsVal($name, "stateId", "0") =~ /2|3/);
+
+ RemoveInternalTimer($hash);
+ InternalTimer( gettimeofday() + $interval, "BOTVAC::GetStatus", $hash, 0 );
+
+ return if ( AttrVal($name, "disable", 0) == 1 );
+
+ # check device availability
+ if (!$update) {
+ my @time = localtime();
+ my $secs = ($time[2] * 3600) + ($time[1] * 60) + $time[0];
+
+ # update once per day
+ push(@successor, ["dashboard", undef]) if ($secs <= $interval);
+
+ push(@successor, ["messages", "getSchedule"]);
+ push(@successor, ["messages", "getGeneralInfo"]) if (GetServiceVersion($hash, "generalInfo") =~ /.*-1/);
+
+ SendCommand($hash, "messages", "getRobotState", undef, @successor);
+ }
+
+ return;
+}
+
+###################################
+sub Get($@) {
+ my ( $hash, @a ) = @_;
+ my $name = $hash->{NAME};
+ my $what;
+
+ Log3($name, 5, "BOTVAC $name: called function Get()");
+
+ return "argument is missing" if ( int(@a) < 2 );
+
+ $what = $a[1];
+
+ if ( $what =~ /^(batteryPercent)$/ ) {
+ if ( defined( $hash->{READINGS}{$what}{VAL} ) ) {
+ return $hash->{READINGS}{$what}{VAL};
+ } else {
+ return "no such reading: $what";
+ }
+ } else {
+ return "Unknown argument $what, choose one of batteryPercent:noArg";
+ }
+}
+
+###################################
+sub Set($@) {
+ my ( $hash, @a ) = @_;
+ my $name = $hash->{NAME};
+
+ Log3($name, 5, "BOTVAC $name: called function Set()");
+
+ return "No Argument given" if ( !defined( $a[1] ) );
+
+ my $arg = $a[1];
+ $arg .= " ".$a[2] if (defined( $a[2] ));
+ $arg .= " ".$a[3] if (defined( $a[3] ));
+
+ my $houseCleaningSrv = GetServiceVersion($hash, "houseCleaning");
+ my $spotCleaningSrv = GetServiceVersion($hash, "spotCleaning");
+
+ my $usage = "Unknown argument " . $a[1] . ", choose one of";
+
+ $usage .= " password";
+ if ( ReadingsVal($name, ".start", "0") ) {
+ $usage .= " startCleaning:";
+ if ($houseCleaningSrv eq "basic-4") {
+ $usage .= "house,map,zone";
+ } elsif ($houseCleaningSrv eq "basic-3") {
+ $usage .= "house,map";
+ } else {
+ $usage .= "noArg";
+ }
+ $usage .= " startSpot:noArg";
+ }
+ $usage .= " stop:noArg" if ( ReadingsVal($name, ".stop", "0") );
+ $usage .= " pause:noArg" if ( ReadingsVal($name, ".pause", "0") );
+ $usage .= " pauseToBase:noArg" if ( ReadingsVal($name, ".pause", "0") and ReadingsVal($name, "dockHasBeenSeen", "0") );
+ $usage .= " resume:noArg" if ( ReadingsVal($name, ".resume", "0") );
+ $usage .= " sendToBase:noArg" if ( ReadingsVal($name, ".goToBase", "0") );
+ $usage .= " reloadMaps:noArg" if ( GetServiceVersion($hash, "maps") ne "" );
+ $usage .= " dismissCurrentAlert:noArg" if ( ReadingsVal($name, "alert", "") ne "" );
+ $usage .= " findMe:noArg" if ( GetServiceVersion($hash, "findMe") eq "basic-1" );
+ $usage .= " startManual:noArg" if ( GetServiceVersion($hash, "manualCleaning") ne "" );
+ $usage .= " statusRequest:noArg schedule:on,off syncRobots:noArg";
+
+ # house cleaning
+ $usage .= " nextCleaningMode:eco,turbo" if ($houseCleaningSrv =~ /basic-\d/);
+ $usage .= " nextCleaningNavigationMode:normal,extra#care" if ($houseCleaningSrv eq "minimal-2");
+ $usage .= " nextCleaningNavigationMode:normal,extra#care,deep" if ($houseCleaningSrv eq "basic-3" or $houseCleaningSrv eq "basic-4");
+ $usage .= " nextCleaningZone" if ($houseCleaningSrv eq "basic-4");
+
+ # spot cleaning
+ $usage .= " nextCleaningModifier:normal,double" if ($spotCleaningSrv eq "basic-1" or $spotCleaningSrv eq "minimal-2");
+ if ($spotCleaningSrv =~ /basic-\d/) {
+ $usage .= " nextCleaningSpotWidth:100,200,300,400";
+ $usage .= " nextCleaningSpotHeight:100,200,300,400";
+ }
+
+ # manual cleaning
+ if ($hash->{HELPER}{WEBSOCKETS}) {
+ $usage .= " wsCommand:brush-on,brush-off,eco-on,eco-off,turbo-on,turbo-off,vacuum-on,vacuum-off";
+ $usage .= " wsCombo:forward,back,stop,arc-left,arc-right,pivot-left,pivot-right";
+ }
+
+ my @robots;
+ if (defined($hash->{helper}{ROBOTS})) {
+ @robots = @{$hash->{helper}{ROBOTS}};
+ if (@robots > 1) {
+ $usage .= " setRobot:";
+ for (my $i = 0; $i < @robots; $i++) {
+ $usage .= "," if ($i > 0);
+ $usage .= $robots[$i]->{name};
+ }
+ }
+ }
+
+ if (GetServiceVersion($hash, "maps") eq "advanced-1" or
+ GetServiceVersion($hash, "maps") eq "basic-2" or
+ GetServiceVersion($hash, "maps") eq "macro-1") {
+ if (defined($hash->{helper}{BoundariesList})) {
+ my @Boundaries = @{$hash->{helper}{BoundariesList}};
+ my @names;
+ for (my $i = 0; $i < @Boundaries; $i++) {
+ my $name = $Boundaries[$i]->{name};
+ push @names,$name if (!(grep { $_ eq $name } @names) and ($name ne ""));
+ }
+ my $BoundariesList = @names ? join(",", @names) : "textField";
+ $usage .= " setBoundariesOnFloorplan_0:".$BoundariesList if (ReadingsVal($name, "floorplan_0_id" ,"") ne "");
+ $usage .= " setBoundariesOnFloorplan_1:".$BoundariesList if (ReadingsVal($name, "floorplan_1_id" ,"") ne "");
+ $usage .= " setBoundariesOnFloorplan_2:".$BoundariesList if (ReadingsVal($name, "floorplan_2_id" ,"") ne "");
+ }
+ else {
+ $usage .= " setBoundariesOnFloorplan_0:textField" if (ReadingsVal($name, "floorplan_0_id" ,"") ne "");
+ $usage .= " setBoundariesOnFloorplan_1:textField" if (ReadingsVal($name, "floorplan_1_id" ,"") ne "");
+ $usage .= " setBoundariesOnFloorplan_2:textField" if (ReadingsVal($name, "floorplan_2_id" ,"") ne "");
+ }
+ }
+
+ my $cmd = '';
+ my $result;
+
+
+ # house cleaning
+ if ( $a[1] eq "startCleaning" ) {
+ Log3($name, 2, "BOTVAC set $name $arg");
+
+ my $option = "house";
+ $option = $a[2] if (defined($a[2]));
+ SendCommand( $hash, "messages", "startCleaning", $option );
+ readingsSingleUpdate($hash, ".stop", "1", 0);
+ }
+
+ # spot cleaning
+ elsif ( $a[1] eq "startSpot" ) {
+ Log3($name, 2, "BOTVAC set $name $arg");
+
+ SendCommand( $hash, "messages", "startSpot" );
+ readingsSingleUpdate($hash, ".stop", "1", 0);
+ }
+
+ # manual cleaning
+ elsif ( $a[1] eq "startManual" ) {
+ Log3($name, 2, "BOTVAC set $name $arg");
+
+ SendCommand( $hash, "messages", "getRobotManualCleaningInfo" );
+ readingsSingleUpdate($hash, ".stop", "1", 0);
+ }
+
+ # stop
+ elsif ( $a[1] eq "stop" ) {
+ Log3($name, 2, "BOTVAC set $name $arg");
+
+ if ($hash->{HELPER}{WEBSOCKETS}) {
+ wsClose($hash);
+ } else {
+ SendCommand( $hash, "messages", "stopCleaning" );
+ }
+ }
+
+ # pause
+ elsif ( $a[1] eq "pause" ) {
+ Log3($name, 2, "BOTVAC set $name $arg");
+
+ SendCommand( $hash, "messages", "pauseCleaning" );
+ }
+
+ # pauseToBase
+ elsif ( $a[1] eq "pauseToBase" ) {
+ Log3($name, 2, "BOTVAC set $name $arg");
+
+ SendCommand( $hash, "messages", "pauseCleaning", undef, (["messages", "sendToBase"]) );
+ }
+
+ # resume
+ elsif ( $a[1] eq "resume" ) {
+ Log3($name, 2, "BOTVAC set $name $arg");
+
+ SendCommand( $hash, "messages", "resumeCleaning" );
+ }
+
+ # sendToBase
+ elsif ( $a[1] eq "sendToBase" ) {
+ Log3($name, 2, "BOTVAC set $name $arg");
+
+ SendCommand( $hash, "messages", "sendToBase" );
+ }
+
+ # dismissCurrentAlert
+ elsif ( $a[1] eq "dismissCurrentAlert" ) {
+ Log3($name, 2, "BOTVAC set $name $arg");
+
+ SendCommand( $hash, "messages", "dismissCurrentAlert" );
+ }
+
+ # findMe
+ elsif ( $a[1] eq "findMe" ) {
+ Log3($name, 2, "BOTVAC set $name $arg");
+
+ SendCommand( $hash, "messages", "findMe" );
+ }
+
+ # schedule
+ elsif ( $a[1] eq "schedule" ) {
+ Log3($name, 2, "BOTVAC set $name $arg");
+
+ return "No argument given" if ( !defined( $a[2] ) );
+
+ my $switch = $a[2];
+ if ($switch eq "on") {
+ SendCommand( $hash, "messages", "enableSchedule" );
+ } else {
+ SendCommand( $hash, "messages", "disableSchedule" );
+ }
+ }
+
+ # syncRobots
+ elsif ( $a[1] eq "syncRobots" ) {
+ Log3($name, 2, "BOTVAC set $name $arg");
+
+ SendCommand( $hash, "dashboard" );
+ }
+
+ # statusRequest
+ elsif ( $a[1] eq "statusRequest" ) {
+ Log3($name, 2, "BOTVAC set $name $arg");
+
+ SendCommand( $hash, "messages", "getRobotState", undef, ["messages", "getSchedule"] );
+ }
+
+ # setRobot
+ elsif ( $a[1] eq "setRobot" ) {
+ Log3($name, 2, "BOTVAC set $name $arg");
+
+ return "No argument given" if ( !defined( $a[2] ) );
+ if (@robots) {
+ my $robot = 0;
+ while($a[2] ne $robots[$robot]->{name} and $robot + 1 < @robots) {
+ $robot++;
+ }
+ readingsBeginUpdate($hash);
+ SetRobot($hash, $robot);
+ readingsEndUpdate( $hash, 1 );
+ } else {
+ Log3($name, 2, "BOTVAC Can't set robot, run 'syncRobots' before");
+ }
+ }
+
+ # reloadMaps
+ elsif ( $a[1] eq "reloadMaps" ) {
+ Log3($name, 2, "BOTVAC set $name $arg");
+
+ SendCommand( $hash, "robots", "maps");
+ }
+
+ # setBoundaries
+ elsif ( $a[1] =~ /^setBoundariesOnFloorplan_\d$/) {
+ my $floorplan = substr($a[1],25,1);
+ Log3($name, 2, "BOTVAC set $name $arg");
+
+ return "No argument given" if ( !defined( $a[2] ) );
+
+ my $setBoundaries = "";
+ if ($a[2] =~ /^\{.*\}/){
+ $setBoundaries = $a[2];
+ }
+ elsif (defined($hash->{helper}{BoundariesList})) {
+ my @names = split ",",$a[2];
+ my @Boundaries = @{$hash->{helper}{BoundariesList}};
+ for (my $i = 0; $i < @Boundaries; $i++) {
+ foreach my $name (@names) {
+ if ($Boundaries[$i]->{name} eq $name) {
+ $setBoundaries .= "," if ($setBoundaries =~ /^\{.*\}/);
+ $setBoundaries .= encode_json($Boundaries[$i]);
+ }
+ }
+ }
+ }
+ return "Argument of $a[1] is not a valid Boundarie name and also not a JSON string: \"$a[2]\"" if ($setBoundaries eq "");
+ Log3($name, 5, "BOTVAC set $name " . $a[1] . " " . $a[2] . " json: " . $setBoundaries);
+ my %params;
+ $params{"boundaries"} = "\[".$setBoundaries."\]";
+ $params{"mapId"} = "\"".ReadingsVal($name, "floorplan_".$floorplan."_id", "myHome")."\"";
+ SendCommand( $hash, "messages", "setMapBoundaries", \%params );
+ return;
+ }
+
+ # nextCleaning
+ elsif ( $a[1] =~ /nextCleaning/) {
+ Log3($name, 2, "BOTVAC set $name $arg");
+
+ return "No argument given" if ( !defined( $a[2] ) );
+
+ readingsSingleUpdate($hash, $a[1], $a[2], 0);
+ }
+
+ # wsCommand || wsCommand
+ elsif ( $a[1] =~ /wsCombo|wsCommand/) {
+ Log3($name, 2, "BOTVAC set $name $arg");
+
+ return "No argument given" if ( !defined( $a[2] ) );
+
+ my $cmd = ($a[1] eq "wsCombo" ? "combo" : "command");
+ wsEncode($hash, "{ \"$cmd\": \"$a[2]\" }");
+ }
+
+ # password
+ elsif ( $a[1] eq "password") {
+ Log3($name, 2, "BOTVAC set $name " . $a[1]);
+
+ return "No password given" if ( !defined( $a[2] ) );
+
+ StorePassword( $hash, $a[2] );
+ }
+
+ # return usage hint
+ else {
+ return $usage;
+ }
+
+ return;
+}
+
+###################################
+sub Undefine($$) {
+ my ( $hash, $arg ) = @_;
+ my $name = $hash->{NAME};
+
+ Log3($name, 5, "BOTVAC $name: called function Undefine()");
+
+ # Stop the internal GetStatus-Loop and exit
+ RemoveInternalTimer($hash);
+
+ RemoveExtension("BOTVAC/$name/map");
+
+ return;
+}
+
+###################################
+sub Delete($$) {
+ my ( $hash, $arg ) = @_;
+ my $name = $hash->{NAME};
+
+ Log3($name, 5, "BOTVAC $name: called function Delete()");
+
+ my $index = $hash->{TYPE}."_".$name."_passwd";
+ setKeyValue($index,undef);
+
+ return;
+}
+
+###################################
+sub Attr(@)
+{
+ my ($cmd,$name,$attr_name,$attr_value) = @_;
+ my $hash = $::defs{$name};
+ my $err;
+ if ($cmd eq "set") {
+ if ($attr_name eq "boundaries") {
+ if ($attr_value !~ /^\{.*\}/){
+ $err = "Invalid value $attr_value for attribute $attr_name. Must be a space separated list of JSON strings.";
+ } else {
+ my @boundaries = split " ",$attr_value;
+ my @areas;
+ if (@boundaries > 1) {
+ foreach my $area (@boundaries) {
+ push @areas,eval{decode_json $area};
+ }
+ } else {
+ push @areas,eval{decode_json $attr_value};
+ }
+ $hash->{helper}{BoundariesList} = \@areas;
+ }
+ }
+ } else {
+ delete $hash->{helper}{BoundariesList} if ($attr_name eq "boundaries");
+ }
+ return $err ? $err : undef;
+}
+
+############################################################################################################
+#
+# Begin of helper functions
+#
+############################################################################################################
+
+#########################
+sub AddExtension($$$) {
+ my ( $name, $func, $link ) = @_;
+
+ my $url = "/$link";
+ Log3($name, 2, "Registering BOTVAC $name for URL $url...");
+ $::data{FWEXT}{$url}{deviceName} = $name;
+ $::data{FWEXT}{$url}{FUNC} = $func;
+ $::data{FWEXT}{$url}{LINK} = $link;
+}
+
+#########################
+sub RemoveExtension($) {
+ my ($link) = @_;
+
+ my $url = "/$link";
+ my $name = $::data{FWEXT}{$url}{deviceName};
+ Log3($name, 2, "Unregistering BOTVAC $name for URL $url...");
+ delete $::data{FWEXT}{$url};
+}
+
+###################################
+sub SendCommand($$;$$@) {
+ my ( $hash, $service, $cmd, $option, @successor ) = @_;
+ my $name = $hash->{NAME};
+ my $email = $hash->{EMAIL};
+ my $password = ReadPassword($hash);
+ my $timestamp = gettimeofday();
+ my $timeout = 180;
+ my $header;
+ my $data;
+ my $reqId = 0;
+
+ Log3($name, 5, "BOTVAC $name: called function SendCommand()");
+
+ my $URL = "https://";
+ my $response;
+ my $return;
+
+ my %sslArgs;
+
+ if ($service ne "sessions" && $service ne "dashboard") {
+ return if (CheckRegistration($hash, $service, $cmd, $option, @successor));
+ }
+
+ if ( !defined($cmd) ) {
+ Log3($name, 4, "BOTVAC $name: REQ $service");
+ }
+ else {
+ Log3($name, 4, "BOTVAC $name: REQ $service/$cmd");
+ }
+ Log3($name, 4, "BOTVAC $name: REQ option $option") if (defined($option));
+ my $msg = "BOTVAC $name: REQ successors";
+ my @succ_item;
+ for (my $i = 0; $i < @successor; $i++) {
+ @succ_item = @{$successor[$i]};
+ $msg .= " $i: ";
+ $msg .= join(",", map { defined($_) ? $_ : '' } @succ_item);
+ }
+ Log3($name, 4, $msg);
+
+ $header = "Accept: application/vnd.neato.nucleo.v1";
+ $header .= "\r\nContent-Type: application/json";
+
+ if ($service eq "sessions") {
+ return if (!defined($password));
+ my $token = createUniqueId() . createUniqueId();
+ $URL .= GetBeehiveHost($hash->{VENDOR});
+ $URL .= "/sessions";
+ $data = "{\"platform\": \"ios\", \"email\": \"$email\", \"token\": \"$token\", \"password\": \"$password\"}";
+ %sslArgs = ( SSL_verify_mode => 0 );
+
+ } elsif ($service eq "dashboard") {
+ $header .= "\r\nAuthorization: Token token=".ReadingsVal($name, "accessToken", "");
+ $URL .= GetBeehiveHost($hash->{VENDOR});
+ $URL .= "/dashboard";
+ %sslArgs = ( SSL_verify_mode => 0 );
+
+ } elsif ($service eq "robots") {
+ my $serial = ReadingsVal($name, "serial", "");
+ return if ($serial eq "");
+
+ $header .= "\r\nAuthorization: Token token=".ReadingsVal($name, "accessToken", "");
+ $URL .= GetBeehiveHost($hash->{VENDOR});
+ $URL .= "/users/me/robots/$serial/";
+ $URL .= (defined($cmd) ? $cmd : "maps");
+ %sslArgs = ( SSL_verify_mode => 0 );
+
+ } elsif ($service eq "messages") {
+ my $serial = ReadingsVal($name, "serial", "");
+ return if ($serial eq "");
+
+ $URL = ReadingsVal($name, "nucleoUrl", "https://".GetNucleoHost($hash->{VENDOR}));
+ $URL .= "/vendors/";
+ $URL .= $hash->{VENDOR};
+ $URL .= "/robots/$serial/messages";
+
+ if (defined($option) and ref($option) eq "HASH" ) {
+ if (defined($option->{reqId})) {
+ $reqId = $option->{reqId};
+ }
+ }
+
+ $cmd .= "Events" if ($cmd eq "getSchedule" and GetServiceVersion($hash, "schedule") eq "basic-2");
+
+ $data = "{\"reqId\":\"$reqId\",\"cmd\":\"$cmd\"";
+ if ($cmd eq "startCleaning") {
+ $data .= ",\"params\":{";
+ my $version = GetServiceVersion($hash, "houseCleaning");
+ if ($version eq "basic-1") {
+ $data .= "\"category\":2";
+ $data .= ",\"mode\":";
+ $data .= (GetCleaningParameter($hash, "cleaningMode", "eco") eq "eco" ? "1" : "2");
+ $data .= ",\"modifier\":1";
+ } elsif ($version eq "minimal-2") {
+ $data .= "\"category\":2";
+ $data .= ",\"navigationMode\":";
+ $data .= (GetCleaningParameter($hash, "cleaningNavigationMode", "normal") eq "normal" ? "1" : "2");
+ } elsif ($version eq "basic-3" or $version eq "basic-4") {
+ $data .= "\"category\":";
+ $data .= (($option eq "map" or $option eq "zone") ? "4" : "2");
+ $data .= ",\"mode\":";
+ my $cleanMode = GetCleaningParameter($hash, "cleaningMode", "eco");
+ $data .= ($cleanMode eq "eco" ? "1" : "2");
+ $data .= ",\"navigationMode\":";
+ my $navMode = GetCleaningParameter($hash, "cleaningNavigationMode", "normal");
+ if ($navMode eq "deep" and $cleanMode = "turbo") {
+ $data .= "3";
+ } elsif ($navMode eq "extra care") {
+ $data .= "2";
+ } else {
+ $data .= "1";
+ }
+ if ($version eq "basic-4" and $option eq "zone") {
+ my $zone = GetCleaningParameter($hash, "cleaningZone", "");
+ $data .= ",\"boundaryId\":\"".$zone."\"" if ($zone ne "");
+ }
+ }
+ $data .= "}";
+ }
+ elsif ($cmd eq "startSpot") {
+ $data = "{\"reqId\":\"$reqId\",\"cmd\":\"startCleaning\"";
+ $data .= ",\"params\":{";
+ $data .= "\"category\":3";
+ my $version = GetServiceVersion($hash, "spotCleaning");
+ if ($version eq "basic-1") {
+ $data .= ",\"mode\":";
+ $data .= (GetCleaningParameter($hash, "cleaningMode", "eco") eq "eco" ? "1" : "2");
+ }
+ if ($version eq "basic-1" or $version eq "minimal-2") {
+ $data .= ",\"modifier\":";
+ $data .= (GetCleaningParameter($hash, "cleaningModifier", "normal") eq "normal" ? "1" : "2");
+ }
+ if ($version eq "micro-2" or $version eq "minimal-2") {
+ $data .= ",\"navigationMode\":";
+ $data .= (GetCleaningParameter($hash, "cleaningNavigationMode", "normal") eq "normal" ? "1" : "2");
+ }
+ if ($version eq "basic-1" or $version eq "basic-3") {
+ $data .= ",\"spotWidth\":";
+ $data .= GetCleaningParameter($hash, "cleaningSpotWidth", "200");
+ $data .= ",\"spotHeight\":";
+ $data .= GetCleaningParameter($hash, "cleaningSpotHeight", "200");
+ }
+ $data .= "}";
+ }
+ elsif ($cmd eq "setMapBoundaries" or $cmd eq "getMapBoundaries") {
+ if (defined($option) and ref($option) eq "HASH") {
+ $data .= ",\"params\":{";
+ foreach( keys %$option ) {
+ $data .= "\"$_\":$option->{$_}," if ($_ ne "reqId");
+ }
+ my $tmp = chop($data); #remove last ","
+ $data .= "}";
+ }
+ }
+
+ $data .= "}";
+
+ my $now = time();
+ my $date = FmtDateTimeRFC1123($now);
+ my $message = join("\n", (lc($serial), $date, $data));
+ my $hmac = hmac_sha256_hex($message, ReadingsVal($name, "secretKey", ""));
+
+ $header .= "\r\nDate: $date";
+ $header .= "\r\nAuthorization: NEATOAPP $hmac";
+
+ #%sslArgs = ( SSL_ca => [ GetCAKey( $hash ) ] );
+ %sslArgs = ( SSL_verify_mode => 0 );
+ } elsif ($service eq "loadmap") {
+ $URL = $cmd;
+ }
+
+ # send request via HTTP-POST method
+ Log3($name, 5, "BOTVAC $name: POST $URL (" . ::urlDecode($data) . ")")
+ if ( defined($data) );
+ Log3($name, 5, "BOTVAC $name: GET $URL")
+ if ( !defined($data) );
+ Log3($name, 5, "BOTVAC $name: header $header")
+ if ( defined($header) );
+
+ ::HttpUtils_NonblockingGet(
+ {
+ url => $URL,
+ timeout => $timeout,
+ noshutdown => 1,
+ header => $header,
+ data => $data,
+ hash => $hash,
+ service => $service,
+ cmd => $cmd,
+ successor => \@successor,
+ timestamp => $timestamp,
+ sslargs => { %sslArgs },
+ callback => \&ReceiveCommand,
+ }
+ );
+
+ return;
+}
+
+###################################
+sub ReceiveCommand($$$) {
+ my ( $param, $err, $data ) = @_;
+ my $hash = $param->{hash};
+ my $name = $hash->{NAME};
+ my $service = $param->{service};
+ my $cmd = $param->{cmd};
+ my @successor = @{$param->{successor}};
+
+ my $rc = ( $param->{buf} ) ? $param->{buf} : $param;
+
+ my $loadMap;
+ my $return;
+ my $reqId = 0;
+
+ Log3($name, 5, "BOTVAC $name: called function ReceiveCommand() rc: $rc err: $err data: $data ");
+
+ readingsBeginUpdate($hash);
+
+ # device not reachable
+ if ($err) {
+
+ if ( !defined($cmd) || $cmd eq "" ) {
+ Log3($name, 4, "BOTVAC $name:$service RCV $err");
+ } else {
+ Log3($name, 4, "BOTVAC $name:$service/$cmd RCV $err");
+ }
+
+ # keep last state
+ #readingsBulkUpdateIfChanged( $hash, "state", "Error" );
+ }
+
+ # data received
+ elsif ($data) {
+
+ if ( !defined($cmd) ) {
+ Log3($name, 4, "BOTVAC $name: RCV $service");
+ } else {
+ Log3($name, 4, "BOTVAC $name: RCV $service/$cmd");
+ }
+ my $msg = "BOTVAC $name: RCV successors";
+ my @succ_item;
+ for (my $i = 0; $i < @successor; $i++) {
+ @succ_item = @{$successor[$i]};
+ $msg .= " $i: ";
+ $msg .= join(",", map { defined($_) ? $_ : '' } @succ_item);
+ }
+ Log3($name, 4, $msg);
+
+ if ( $data ne "" ) {
+ if ( $service eq "loadmap" ) {
+ # use $data later
+ } elsif ( $data =~ /^{"message":"Could not find robot_serial for specified vendor_name"}$/ ) {
+ # currently no data available
+ readingsBulkUpdateIfChanged($hash, "state", "Couldn't find robot");
+ readingsEndUpdate( $hash, 1 );
+ return;
+ } elsif ( $data =~ /^{/ || $data =~ /^\[/ ) {
+ if ( !defined($cmd) || $cmd eq "" ) {
+ Log3($name, 4, "BOTVAC $name: RES $service - $data");
+ } else {
+ Log3($name, 4, "BOTVAC $name: RES $service/$cmd - $data");
+ }
+ $return = decode_json( encode_utf8($data) );
+ } else {
+ Log3($name, 5, "BOTVAC $name: RES ERROR $service\n" . $data);
+ if ( !defined($cmd) || $cmd eq "" ) {
+ Log3($name, 5, "BOTVAC $name: RES ERROR $service\n$data");
+ } else {
+ Log3($name, 5, "BOTVAC $name: RES ERROR $service/$cmd\n$data");
+ }
+ return undef;
+ }
+ }
+
+ # messages
+ if ( $service eq "messages" ) {
+ if ( $cmd =~ /Schedule/ ) {
+ # getSchedule, enableSchedule, disableSchedule
+ if ( ref($return->{data}) eq "HASH" ) {
+ my $scheduleData = $return->{data};
+ readingsBulkUpdateIfChanged($hash, "scheduleEnabled", $scheduleData->{enabled});
+ readingsBulkUpdateIfChanged($hash, "scheduleType", $scheduleData->{type})
+ if (defined($scheduleData->{type}));
+
+ my %currentEvents;
+ foreach ( keys %{ $hash->{READINGS} } ) {
+ $currentEvents{$_} = 1 if ( $_ =~ /^event\d.*/ );
+ }
+
+ if (ref($scheduleData->{events}) eq "ARRAY") {
+ my @events = @{$scheduleData->{events}};
+ for (my $i = 0; $i < @events; $i++) {
+ if (defined($events[$i]->{day})) {
+ readingsBulkUpdateIfChanged($hash, "event".$i."day", GetDayText($events[$i]->{day}));
+ delete $currentEvents{"event".$i."day"};
+ }
+ if (defined($events[$i]->{mode})) {
+ readingsBulkUpdateIfChanged($hash, "event".$i."mode", GetModeText($events[$i]->{mode}));
+ delete $currentEvents{"event".$i."mode"};
+ }
+ if (defined($events[$i]->{startTime})) {
+ readingsBulkUpdateIfChanged($hash, "event".$i."startTime", $events[$i]->{startTime});
+ delete $currentEvents{"event".$i."startTime"};
+ }
+ if (defined($events[$i]->{type})) {
+ readingsBulkUpdateIfChanged($hash, "event".$i."type", $events[$i]->{type});
+ delete $currentEvents{"event".$i."type"};
+ }
+ if (defined($events[$i]->{duration})) {
+ readingsBulkUpdateIfChanged($hash, "event".$i."duration", $events[$i]->{duration});
+ delete $currentEvents{"event".$i."duration"};
+ }
+ if (defined($events[$i]->{mapId})) {
+ readingsBulkUpdateIfChanged($hash, "event".$i."mapId", $events[$i]->{mapId});
+ delete $currentEvents{"event".$i."mapId"};
+ }
+ if ( ref($events[$i]->{boundary}) eq "HASH" ) {
+ my $boundary = $events[$i]->{boundary};
+ readingsBulkUpdateIfChanged($hash, "event".$i."boundaryId", $boundary->{id});
+ readingsBulkUpdateIfChanged($hash, "event".$i."boundaryName", $boundary->{name});
+ delete $currentEvents{"event".$i."boundaryId"};
+ delete $currentEvents{"event".$i."boundaryName"};
+ }
+ if ( ref($events[$i]->{recurring}) eq "HASH" ) {
+ my $recurring = $events[$i]->{recurring};
+ readingsBulkUpdateIfChanged($hash, "event".$i."end", $recurring->{end});
+ delete $currentEvents{"event".$i."end"};
+ if (ref($events[$i]->{days}) eq "ARRAY") {
+ my @days = @{$events[$i]->{days}};
+ my $days_str;
+ for (my $j = 0; $j < @days; $j++) {
+ $days_str .= "," if (defined($days_str));
+ $days_str .= GetDayText($days[$j]->{day});
+ }
+ readingsBulkUpdateIfChanged($hash, "event".$i."days", $days_str);
+ delete $currentEvents{"event".$i."days"};
+ }
+ }
+ if ( ref($events[$i]->{cmd}) eq "HASH" ) {
+ my $cmd = $events[$i]->{cmd};
+ my $cmd_str = $cmd->{name};
+ if ( ref($cmd->{params}) eq "HASH" ) {
+ $cmd_str .= ":".GetCategoryText($cmd->{category});
+ $cmd_str .= ",".GetModeText($cmd->{mode});
+ $cmd_str .= ",".GetModifierText($cmd->{modifier});
+ $cmd_str .= ",".GetNavigationModeText($cmd->{navigationMode}) if (defined($cmd->{navigationMode}));
+ }
+ readingsBulkUpdateIfChanged($hash, "event".$i."command", $cmd_str);
+ delete $currentEvents{"event".$i."command"};
+ }
+ }
+ }
+
+ #remove outdated calendar information
+ foreach ( keys %currentEvents ) {
+ delete( $hash->{READINGS}{$_} );
+ }
+ }
+ }
+ elsif ( $cmd eq "getMapBoundaries" ) {
+ if ( ref($return->{data}) eq "HASH" ) {
+ $reqId = $return->{reqId};
+ my $boundariesData = $return->{data};
+ if (ref($boundariesData->{boundaries}) eq "ARRAY") {
+ my @boundaries = @{$boundariesData->{boundaries}};
+ my $tmp = "";
+ my $boundariesList = "";
+ my $zonesList = "";
+ for (my $i = 0; $i < @boundaries; $i++) {
+ my $currentBoundary = "{";
+ $currentBoundary .= "\"id\":\"".$boundaries[$i]->{id}."\"," if ($boundaries[$i]->{type} eq "polygon");
+ $currentBoundary .= "\"type\":\"".$boundaries[$i]->{type}."\",";
+ if (ref($boundaries[$i]->{vertices}) eq "ARRAY") {
+ my @vertices = @{$boundaries[$i]->{vertices}};
+ $currentBoundary .= "\"vertices\":[";
+ for (my $e = 0; $e < @vertices; $e++) {
+ if (ref($vertices[$e]) eq "ARRAY") {
+ my @xy = @{$vertices[$e]};
+ $currentBoundary .= "[".$xy[0].",".$xy[1]."],";
+ }
+ }
+ $tmp = chop($currentBoundary); #remove last ","
+ $currentBoundary .= "],";
+ }
+ $currentBoundary .= "\"name\":\"".$boundaries[$i]->{name}."\",";
+ $currentBoundary .= "\"color\":\"".$boundaries[$i]->{color}."\",";
+ $tmp = $boundaries[$i]->{enabled} eq "1" ? "true" : "false";
+ $currentBoundary .= "\"enabled\":".$tmp.",";
+ $tmp = chop($currentBoundary); #remove last ","
+ $currentBoundary .= "},\n";
+ if ($boundaries[$i]->{type} eq "polygon") {
+ $zonesList .= $currentBoundary;
+ } else {
+ $boundariesList .= $currentBoundary;
+ }
+ }
+ $tmp = chomp($boundariesList); #remove last "\n"
+ $tmp = chomp($zonesList); #remove last "\n"
+ $tmp = chop($boundariesList); #remove last ","
+ $tmp = chop($zonesList); #remove last ","
+ readingsBulkUpdateIfChanged($hash, "floorplan_".$reqId."_boundaries", $boundariesList);
+ readingsBulkUpdateIfChanged($hash, "floorplan_".$reqId."_zones", $zonesList);
+ }
+ }
+ }
+ elsif ( $cmd eq "getGeneralInfo" ) {
+ if ( ref($return->{data}) eq "HASH" ) {
+ my $generalInfo = $return->{data};
+ if ( ref($generalInfo->{battery}) eq "HASH" ) {
+ my $batteryInfo = $generalInfo->{battery};
+ readingsBulkUpdateIfChanged($hash, "batteryTimeToEmpty", $batteryInfo->{timeToEmpty});
+ readingsBulkUpdateIfChanged($hash, "batteryTimeToFullCharge", $batteryInfo->{timeToFullCharge});
+ readingsBulkUpdateIfChanged($hash, "batteryTotalCharges", $batteryInfo->{totalCharges});
+ readingsBulkUpdateIfChanged($hash, "batteryManufacturingDate", $batteryInfo->{manufacturingDate});
+ readingsBulkUpdateIfChanged($hash, "batteryAuthorizationStatus", GetAuthStatusText($batteryInfo->{authorizationStatus}));
+ readingsBulkUpdateIfChanged($hash, "batteryVendor", $batteryInfo->{vendor});
+ }
+ }
+ }
+ else {
+ # getRobotState, startCleaning, pauseCleaning, stopCleaning, resumeCleaning,
+ # sendToBase, setMapBoundaries, getRobotManualCleaningInfo
+ if ( ref($return) eq "HASH" ) {
+ push(@successor , ["robots", "maps"])
+ if ($cmd eq "setMapBoundaries" or
+ (defined($return->{state}) and
+ ($return->{state} == 1 or $return->{state} == 4) and # Idle or Error
+ $return->{state} != ReadingsNum($name, "stateId", $return->{state})));
+
+ #readingsBulkUpdateIfChanged($hash, "version", $return->{version});
+ #readingsBulkUpdateIfChanged($hash, "data", $return->{data});
+ readingsBulkUpdateIfChanged($hash, "result", $return->{result}) if (defined($return->{result}));
+
+ if ($cmd eq "getRobotManualCleaningInfo") {
+ if ( ref($return->{data}) eq "HASH") {
+ my $data = $return->{data};
+ readingsBulkUpdateIfChanged($hash, "wlanIpAddress", $data->{ip_address});
+ readingsBulkUpdateIfChanged($hash, "wlanPort", $data->{port});
+ readingsBulkUpdateIfChanged($hash, "wlanSsid", $data->{ssid});
+ readingsBulkUpdateIfChanged($hash, "wlanToken", $data->{token}) if (defined($data->{token}));
+ readingsBulkUpdateIfChanged($hash, "wlanValidity", GetValidityEnd($data->{valid_for_seconds}))
+ if (defined($data->{valid_for_seconds}));
+ wsOpen($hash, $data->{ip_address}, $data->{port});
+ } elsif (ReadingsVal($name, "wlanValidity", "") ne "") {
+ readingsBulkUpdateIfChanged($hash, "wlanValidity", "unavailable");
+ }
+ }
+ if ( ref($return->{cleaning}) eq "HASH" ) {
+ my $cleaning = $return->{cleaning};
+ readingsBulkUpdateIfChanged($hash, "cleaningCategory", GetCategoryText($cleaning->{category}));
+ readingsBulkUpdateIfChanged($hash, "cleaningMode", GetModeText($cleaning->{mode}));
+ readingsBulkUpdateIfChanged($hash, "cleaningModifier", GetModifierText($cleaning->{modifier}));
+ readingsBulkUpdateIfChanged($hash, "cleaningNavigationMode", GetNavigationModeText($cleaning->{navigationMode}))
+ if (defined($cleaning->{navigationMode}));
+ readingsBulkUpdateIfChanged($hash, "cleaningSpotWidth", $cleaning->{spotWidth});
+ readingsBulkUpdateIfChanged($hash, "cleaningSpotHeight", $cleaning->{spotHeight});
+ }
+ if ( ref($return->{details}) eq "HASH" ) {
+ my $details = $return->{details};
+ readingsBulkUpdateIfChanged($hash, "isCharging", $details->{isCharging});
+ readingsBulkUpdateIfChanged($hash, "isDocked", $details->{isDocked});
+ readingsBulkUpdateIfChanged($hash, "isScheduleEnabled", $details->{isScheduleEnabled});
+ readingsBulkUpdateIfChanged($hash, "dockHasBeenSeen", $details->{dockHasBeenSeen});
+ readingsBulkUpdateIfChanged($hash, "batteryPercent", $details->{charge});
+ }
+ if ( ref($return->{availableCommands}) eq "HASH" ) {
+ my $availableCommands = $return->{availableCommands};
+ readingsBulkUpdateIfChanged($hash, ".start", $availableCommands->{start});
+ readingsBulkUpdateIfChanged($hash, ".pause", $availableCommands->{pause});
+ readingsBulkUpdateIfChanged($hash, ".resume", $availableCommands->{resume});
+ readingsBulkUpdateIfChanged($hash, ".goToBase", $availableCommands->{goToBase});
+ readingsBulkUpdateIfChanged($hash, ".stop", $availableCommands->{stop})
+ unless ($cmd =~ /start.*/ or $cmd eq "getRobotManualCleaningInfo");
+ }
+ if ( ref($return->{availableServices}) eq "HASH" ) {
+ SetServices($hash, $return->{availableServices});
+ }
+ if ( ref($return->{meta}) eq "HASH" ) {
+ my $meta = $return->{meta};
+ readingsBulkUpdateIfChanged($hash, "model", $meta->{modelName});
+ readingsBulkUpdateIfChanged($hash, "firmware", $meta->{firmware});
+ }
+ if (defined($return->{state})){ #State Response
+ my $error = ($return->{error}) ? $return->{error} : "";
+ readingsBulkUpdateIfChanged($hash, "error", $error);
+ my $alert = ($return->{alert}) ? $return->{alert} : "";
+ readingsBulkUpdateIfChanged($hash, "alert", $alert);
+ readingsBulkUpdateIfChanged($hash, "stateId", $return->{state});
+ readingsBulkUpdateIfChanged($hash, "action", $return->{action});
+ readingsBulkUpdateIfChanged(
+ $hash,
+ "state",
+ BuildState($hash, $return->{state}, $return->{action}, $return->{error}));
+ }
+ }
+ }
+ }
+
+ # Sessions
+ elsif ( $service eq "sessions" ) {
+ if ( ref($return) eq "HASH" and defined($return->{access_token})) {
+ readingsBulkUpdateIfChanged($hash, "accessToken", $return->{access_token});
+ }
+ }
+
+ # dashboard
+ elsif ( $service eq "dashboard" ) {
+ if ( ref($return) eq "HASH" ) {
+ if ( ref($return->{robots} ) eq "ARRAY" ) {
+ my @robotList = ();
+ my @robots = @{$return->{robots}};
+ for (my $i = 0; $i < @robots; $i++) {
+ my $r = {
+ "name" => $robots[$i]->{name},
+ "model" => $robots[$i]->{model},
+ "serial" => $robots[$i]->{serial},
+ "secretKey" => $robots[$i]->{secret_key},
+ "macAddr" => $robots[$i]->{mac_address},
+ "nucleoUrl" => $robots[$i]->{nucleo_url}
+ };
+ $r->{recentFirmware} = $return->{recent_firmwares}{$r->{model}}{version}
+ if ( ref($return->{recent_firmwares} ) eq "HASH" );
+
+ push(@robotList, $r);
+ }
+ $hash->{helper}{ROBOTS} = \@robotList;
+
+ SetRobot($hash, ReadingsNum($name, "robot", 0));
+
+ push(@successor , ["robots", "maps"]);
+ }
+ }
+ }
+
+ # robots
+ elsif ( $service eq "robots" ) {
+ if ( $cmd eq "maps" ) {
+ if ( ref($return) eq "HASH" ) {
+ if ( ref($return->{maps} ) eq "ARRAY" ) {
+ my @maps = @{$return->{maps}};
+ if (@maps) {
+ # take first - newest
+ my $map = $maps[0];
+ readingsBulkUpdateIfChanged($hash, "map_status", $map->{status});
+ readingsBulkUpdateIfChanged($hash, "map_id", $map->{id});
+ readingsBulkUpdateIfChanged($hash, "map_date", GetTimeFromString($map->{generated_at}));
+ readingsBulkUpdateIfChanged($hash, "map_area", $map->{cleaned_area});
+ readingsBulkUpdateIfChanged($hash, ".map_url", $map->{url});
+ $loadMap = 1;
+ # getPersistentMaps
+ push(@successor , ["robots", "persistent_maps"]);
+ }
+ }
+ }
+ }
+ elsif ( $cmd eq "persistent_maps" ) {
+ if ( ref($return) eq "ARRAY" ) {
+ my @persistent_maps = @{$return};
+ for (my $i = 0; $i < @persistent_maps; $i++) {
+ readingsBulkUpdateIfChanged($hash, "floorplan_".$i."_name", $persistent_maps[$i]->{name});
+ readingsBulkUpdateIfChanged($hash, "floorplan_".$i."_id", $persistent_maps[$i]->{id});
+ # getMapBoundaries
+ if (GetServiceVersion($hash, "maps") eq "advanced-1" or
+ GetServiceVersion($hash, "maps") eq "basic-2" or
+ GetServiceVersion($hash, "maps") eq "macro-1"){
+ my %params;
+ $params{"reqId"} = $i;
+ $params{"mapId"} = "\"".$persistent_maps[$i]->{id}."\"";
+ push(@successor , ["messages", "getMapBoundaries", \%params]);
+ }
+ }
+ }
+ }
+ }
+
+ # loadmap
+ elsif ( $service eq "loadmap" ) {
+ readingsBulkUpdate($hash, ".map_cache", $data)
+ }
+
+ # all other command results
+ else {
+ Log3($name, 2, "BOTVAC $name: ERROR: method to handle response of $service not implemented");
+ }
+
+ }
+
+ readingsEndUpdate( $hash, 1 );
+
+ if ($loadMap) {
+ my $url = ReadingsVal($name, ".map_url", "");
+ push(@successor , ["loadmap", $url]) if ($url ne "");
+ }
+
+ if (@successor) {
+ my @nextCmd = @{shift(@successor)};
+ my $cmdLength = @nextCmd;
+ my $cmdService = $nextCmd[0];
+ my $cmdCmd;
+ my $cmdOption;
+ $cmdCmd = $nextCmd[1] if ($cmdLength > 1);
+ $cmdOption = $nextCmd[2] if ($cmdLength > 2);
+
+ my $cmdReqId;
+ my $newReqId = "false";
+ if (defined($cmdOption) and ref($cmdOption) eq "HASH" ) {
+ if (defined($cmdOption->{reqId})) {
+ $cmdReqId = $cmdOption->{reqId};
+ $newReqId = "true" if ($reqId ne $cmdReqId);
+ }
+ }
+
+ SendCommand($hash, $cmdService, $cmdCmd, $cmdOption, @successor)
+ if (($service ne $cmdService) or ($cmd ne $cmdCmd) or ($newReqId = "true"));
+ }
+
+ return;
+}
+
+sub GetTimeFromString($) {
+ my ($timeStr) = @_;
+
+ eval {
+ use Time::Local;
+ if(defined($timeStr) and $timeStr =~ m/^(\d{4})-(\d{2})-(\d{2})T([0-2]\d):([0-5]\d):([0-5]\d)Z$/) {
+ my $time = timelocal($6, $5, $4, $3, $2 - 1, $1 - 1900);
+ return FmtDateTime($time + fhemTzOffset($time));
+ }
+ }
+}
+
+sub SetRobot($$) {
+ my ( $hash, $robot ) = @_;
+ my $name = $hash->{NAME};
+
+ Log3($name, 4, "BOTVAC $name: set active robot $robot");
+
+ my @robots = @{$hash->{helper}{ROBOTS}};
+ readingsBulkUpdateIfChanged($hash, "serial", $robots[$robot]->{serial});
+ readingsBulkUpdateIfChanged($hash, "name", $robots[$robot]->{name});
+ readingsBulkUpdateIfChanged($hash, "model", $robots[$robot]->{model});
+ readingsBulkUpdateIfChanged($hash, "firmwareLatest", $robots[$robot]->{recentFirmware})
+ if (defined($robots[$robot]->{recentFirmware}));
+ readingsBulkUpdateIfChanged($hash, "secretKey", $robots[$robot]->{secretKey});
+ readingsBulkUpdateIfChanged($hash, "macAddr", $robots[$robot]->{macAddr});
+ readingsBulkUpdateIfChanged($hash, "nucleoUrl", $robots[$robot]->{nucleoUrl});
+ readingsBulkUpdateIfChanged($hash, "robot", $robot);
+}
+
+sub GetCleaningParameter($$$) {
+ my ($hash, $param, $default) = @_;
+ my $name = $hash->{NAME};
+
+ my $nextReading = "next".ucfirst($param);
+ return ReadingsVal($name, $nextReading, ReadingsVal($name, $param, $default));
+}
+
+sub GetServiceVersion($$) {
+ my ($hash, $service) = @_;
+ my $name = $hash->{NAME};
+
+ my $serviceList = InternalVal($name, "SERVICES", "");
+ if ($serviceList =~ /$service:([^,]*)/) {
+ return $1;
+ }
+ return "";
+}
+
+sub SetServices {
+ my ($hash, $services) = @_;
+ my $name = $hash->{NAME};
+ my $serviceList = join(", ", map { "$_:$services->{$_}" } keys %$services);;
+
+ $hash->{SERVICES} = $serviceList if (!defined($hash->{SERVICES}) or $hash->{SERVICES} ne $serviceList);
+}
+
+sub StorePassword($$) {
+ my ($hash, $password) = @_;
+ my $index = $hash->{TYPE}."_".$hash->{NAME}."_passwd";
+ my $key = getUniqueId().$index;
+ my $enc_pwd = "";
+
+ if(eval "use Digest::MD5;1") {
+ $key = Digest::MD5::md5_hex(unpack "H*", $key);
+ $key .= Digest::MD5::md5_hex($key);
+ }
+
+ for my $char (split //, $password) {
+ my $encode=chop($key);
+ $enc_pwd.=sprintf("%.2x",ord($char)^ord($encode));
+ $key=$encode.$key;
+ }
+
+ my $err = setKeyValue($index, $enc_pwd);
+ return "error while saving the password - $err" if(defined($err));
+
+ return "password successfully saved";
+}
+
+sub ReadPassword($) {
+ my ($hash) = @_;
+ my $name = $hash->{NAME};
+ my $index = $hash->{TYPE}."_".$hash->{NAME}."_passwd";
+ my $key = getUniqueId().$index;
+ my ($password, $err);
+
+ Log3($name, 4, "BOTVAC $name: Read password from file");
+
+ ($err, $password) = getKeyValue($index);
+
+ if ( defined($err) ) {
+ Log3($name, 3, "BOTVAC $name: unable to read password from file: $err");
+ return undef;
+ }
+
+ if ( defined($password) ) {
+ if ( eval "use Digest::MD5;1" ) {
+ $key = Digest::MD5::md5_hex(unpack "H*", $key);
+ $key .= Digest::MD5::md5_hex($key);
+ }
+ my $dec_pwd = '';
+ for my $char (map { pack('C', hex($_)) } ($password =~ /(..)/g)) {
+ my $decode=chop($key);
+ $dec_pwd.=chr(ord($char)^ord($decode));
+ $key=$decode.$key;
+ }
+ return $dec_pwd;
+ } else {
+ Log3($name, 3, "BOTVAC $name: No password in file");
+ return undef;
+ }
+}
+
+sub CheckRegistration($$$$$) {
+ my ( $hash, $service, $cmd, $option, @successor ) = @_;
+ my $name = $hash->{NAME};
+
+ if (ReadingsVal($name, "secretKey", "") eq "") {
+ my @nextCmd = ($service, $cmd, $option);
+ unshift(@successor, [$service, $cmd, $option]);
+
+ my @succ_item;
+ my $msg = " successor:";
+ for (my $i = 0; $i < @successor; $i++) {
+ @succ_item = @{$successor[$i]};
+ $msg .= " $i: ";
+ $msg .= join(",", map { defined($_) ? $_ : '' } @succ_item);
+ }
+ Log3($name, 4, "BOTVAC created".$msg);
+
+ SendCommand($hash, "sessions", undef, undef, @successor) if (ReadingsVal($name, "accessToken", "") eq "");
+ SendCommand($hash, "dashboard", undef, undef, @successor) if (ReadingsVal($name, "accessToken", "") ne "");
+
+ return 1;
+ }
+
+ return;
+}
+
+sub BuildState($$$$) {
+ my ($hash,$state,$action,$error) = @_;
+ my $states = {
+ '0' => "Invalid",
+ '1' => "Idle",
+ '2' => "Busy",
+ '3' => "Paused",
+ '4' => "Error"
+ };
+
+ if (!defined($state)) {
+ return "Unknown";
+ } elsif ($state == 2) {
+ return GetActionText($action);
+ } elsif ($state == 3) {
+ return "Paused: ".GetActionText($action);
+ } elsif ($state == 4) {
+ return GetErrorText($error);
+ } elsif (defined( $states->{$state})) {
+ return $states->{$state};
+ } else {
+ return $state;
+ }
+}
+
+sub GetActionText($) {
+ my ($action) = @_;
+ my $actions = {
+ '0' => "Invalid",
+ '1' => "House Cleaning",
+ '2' => "Spot Cleaning",
+ '3' => "Manual Cleaning",
+ '4' => "Docking",
+ '5' => "User Menu Active",
+ '6' => "Suspended Cleaning",
+ '7' => "Updating",
+ '8' => "Copying Logs",
+ '9' => "Recovering Location",
+ '10' => "IEC Test",
+ '11' => "Map cleaning",
+ '12' => "Exploring map (creating a persistent map)",
+ '13' => "Acquiring Persistent Map IDs",
+ '14' => "Creating & Uploading Map",
+ '15' => "Suspended Exploration"
+ };
+
+ if (defined( $actions->{$action})) {
+ return $actions->{$action};
+ } else {
+ return $action;
+ }
+}
+
+sub GetErrorText($) {
+ my ($error) = @_;
+ my $errors = {
+ 'ui_alert_invalid' => 'Ok',
+ 'ui_alert_dust_bin_full' => 'Dust Bin Is Full!',
+ 'ui_alert_recovering_location' => 'I\'m Recovering My Location!',
+ 'ui_error_picked_up' => 'Picked Up!',
+ 'ui_error_brush_stuck' => 'Brush Stuck!',
+ 'ui_error_stuck' => 'I\'m Stuck!',
+ 'ui_error_dust_bin_emptied' => 'Dust Bin Has Been Emptied!',
+ 'ui_error_dust_bin_missing' => 'Dust Bin Is Missing!',
+ 'ui_error_navigation_falling' => 'Please Clear My Path!',
+ 'ui_error_navigation_noprogress' => 'Please Clear My Path!'
+ };
+
+ if (defined( $errors->{$error})) {
+ return $errors->{$error};
+ } else {
+ return $error;
+ }
+}
+
+sub GetDayText($) {
+ my ($day) = @_;
+ my $days = {
+ '0' => "Sunday",
+ '1' => "Monday",
+ '2' => "Tuesday",
+ '3' => "Wednesday",
+ '4' => "Thursday",
+ '5' => "Friday",
+ '6' => "Saturda"
+ };
+
+ if (defined( $days->{$day})) {
+ return $days->{$day};
+ } else {
+ return $day;
+ }
+}
+
+sub GetCategoryText($) {
+ my ($category) = @_;
+ my $categories = {
+ '1' => 'manual',
+ '2' => 'house',
+ '3' => 'spot',
+ '4' => 'map'
+ };
+
+ if (defined($category) && defined($categories->{$category})) {
+ return $categories->{$category};
+ } else {
+ return $category;
+ }
+}
+
+sub GetModeText($) {
+ my ($mode) = @_;
+ my $modes = {
+ '1' => 'eco',
+ '2' => 'turbo'
+ };
+
+ if (defined($mode) && defined($modes->{$mode})) {
+ return $modes->{$mode};
+ } else {
+ return $mode;
+ }
+}
+
+sub GetModifierText($) {
+ my ($modifier) = @_;
+ my $modifiers = {
+ '1' => 'normal',
+ '2' => 'double'
+ };
+
+ if (defined($modifier) && defined($modifiers->{$modifier})) {
+ return $modifiers->{$modifier};
+ } else {
+ return $modifier;
+ }
+}
+
+sub GetNavigationModeText($) {
+ my ($navMode) = @_;
+ my $navModes = {
+ '1' => 'normal',
+ '2' => 'extra care',
+ '3' => 'deep'
+ };
+
+ if (defined($navMode) && defined($navModes->{$navMode})) {
+ return $navModes->{$navMode};
+ } else {
+ return $navMode;
+ }
+}
+
+sub GetAuthStatusText($) {
+ my ($authStatus) = @_;
+ my $authStatusHash = {
+ '0' => 'not supported',
+ '1' => 'genuine',
+ '2' => 'not genuine'
+ };
+
+ if (defined($authStatus) && defined($authStatusHash->{$authStatus})) {
+ return $authStatusHash->{$authStatus};
+ } else {
+ return $authStatus;
+ }
+}
+
+sub GetBeehiveHost($) {
+ my ($vendor) = @_;
+ my $vendors = {
+ 'neato' => 'beehive.neatocloud.com',
+ 'vorwerk' => 'vorwerk-beehive-production.herokuapp.com',
+ };
+
+ if (defined( $vendors->{$vendor})) {
+ return $vendors->{$vendor};
+ } else {
+ return $vendors->{neato};
+ }
+}
+
+sub GetNucleoHost($) {
+ my ($vendor) = @_;
+ my $vendors = {
+ 'neato' => 'nucleo.neatocloud.com',
+ 'vorwerk' => 'nucleo.ksecosys.com',
+ };
+
+ if (defined( $vendors->{$vendor})) {
+ return $vendors->{$vendor};
+ } else {
+ return $vendors->{neato};
+ }
+}
+
+sub GetValidityEnd($) {
+ my ($validFor) = @_;
+ return ($validFor =~ /\d+/ ? FmtDateTime(time() + $validFor) : $validFor);
+}
+
+sub ShowMap($;$$) {
+ my ($name,$width,$height) = @_;
+
+ my $img = '
{NAME};
+
+ Log3($name, 4, "BOTVAC(ws) $name: Establishing socket connection");
+ $hash->{DeviceName} = join(':', $ip_address, $port);
+
+ ::DevIo_CloseDev($hash) if(::DevIo_IsOpen($hash));
+
+ if (::DevIo_OpenDev($hash, 0, "BOTVAC::wsHandshake")) {
+ Log3($name, 2, "BOTVAC(ws) $name: ERROR: Can't open websocket to $hash->{DeviceName}");
+ readingsSingleUpdate($hash,'result','ws_connect_error',1);
+ readingsSingleUpdate($hash,'result','ws_ko',1);
+ } else {
+ readingsSingleUpdate($hash,'result','ws_ok',1);
+ }
+}
+
+sub wsClose($) {
+ my $hash = shift;
+ my $name = $hash->{NAME};
+ my $normal_closure = pack("H*", "03e8"); #code 1000
+
+ Log3($name, 4, "BOTVAC(ws) $name: Closing socket connection");
+
+ wsEncode($hash, $normal_closure, "close");
+ delete $hash->{HELPER}{WEBSOCKETS};
+ delete $hash->{HELPER}{wsKey};
+ readingsSingleUpdate($hash,'state','ws_closed',1) if (::DevIo_CloseDev($hash))
+}
+
+sub wsHandshake($) {
+ my $hash = shift;
+ my $name = $hash->{NAME};
+ my $host = ReadingsVal($name, "wlanIpAddress", "");
+ my $port = ReadingsVal($name, "wlanPort", "");
+ my $path = "/drive";
+ my $wsKey = encode_base64(gettimeofday(), '');
+ my $serial = ReadingsVal($name, "serial", "");
+ my $now = time();
+ my $date = FmtDateTimeRFC1123($now);
+ my $message = lc($serial) . "\n" . $date . "\n";
+ my $hmac = hmac_sha256_hex($message, ReadingsVal($name, "secretKey", ""));
+
+ my $wsHandshakeCmd = "GET $path HTTP/1.1\r\n";
+ $wsHandshakeCmd .= "Host: $host:$port\r\n";
+ $wsHandshakeCmd .= "Sec-WebSocket-Key: $wsKey\r\n";
+ $wsHandshakeCmd .= "Sec-WebSocket-Version: 13\r\n";
+ $wsHandshakeCmd .= "Upgrade: websocket\r\n";
+ $wsHandshakeCmd .= "Origin: ws://$host:$port$path\r\n";
+ $wsHandshakeCmd .= "Date: $date\r\n";
+ $wsHandshakeCmd .= "Authorization: NEATOAPP $hmac\r\n";
+ $wsHandshakeCmd .= "Connection: Upgrade\r\n";
+ $wsHandshakeCmd .= "\r\n";
+
+ Log3($name, 4, "BOTVAC(ws) $name: Starting Websocket Handshake");
+ wsWrite($hash,$wsHandshakeCmd);
+
+ $hash->{HELPER}{wsKey} = $wsKey;
+
+ return undef;
+}
+
+sub wsCheckHandshake($$) {
+ my ($hash,$response) = @_;
+ my $name = $hash->{NAME};
+
+ # header in Hash wandeln
+ my %header = ();
+ foreach my $line (split("\r\n", $response)) {
+ my ($key,$value) = split( ": ", $line );
+ next if( !$value );
+ $value =~ s/^ //;
+ Log3($name, 4, "BOTVAC(ws) $name: headertohash |$key|$value|");
+ $header{lc($key)} = $value;
+ }
+
+ # check handshake
+ if( defined($header{'sec-websocket-accept'})) {
+ my $keyAccept = $header{'sec-websocket-accept'};
+ Log3($name, 5, "BOTVAC(ws) $name: keyAccept: $keyAccept");
+ my $wsKey = $hash->{HELPER}{wsKey};
+ my $expectedResponse = trim(encode_base64(pack('H*', sha1_hex(trim($wsKey)."258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))));
+ if ($keyAccept eq $expectedResponse) {
+ Log3($name, 4, "BOTVAC(ws) $name: Successful WS connection to $hash->{DeviceName}");
+ readingsSingleUpdate($hash,'state','ws_connected',1);
+ $hash->{HELPER}{WEBSOCKETS} = '1';
+ } else {
+ wsClose($hash);
+ Log3($name, 3, "BOTVAC(ws) $name: ERROR: Unsucessfull WS connection to $hash->{DeviceName}");
+ readingsSingleUpdate($hash,'state','ws_handshake-error',1);
+ }
+ }
+ return undef;
+}
+
+sub wsWrite($@) {
+ my ($hash,$string) = @_;
+ my $name = $hash->{NAME};
+
+ Log3($name, 4, "BOTVAC(ws) $name: WriteFn called:\n$string");
+ ::DevIo_SimpleWrite($hash, $string, 0);
+
+ return undef;
+}
+
+sub wsRead($) {
+ my $hash = shift;
+ my $name = $hash->{NAME};
+ my $buf;
+
+ Log3($name, 5, "ReadFn started");
+ $buf = ::DevIo_SimpleRead($hash);
+
+ return Log3($name, 3, "BOTVAC(ws) $name: no data received") unless( defined $buf);
+
+ if ($hash->{HELPER}{WEBSOCKETS}) {
+ Log3($name, 4, "BOTVAC(ws) $name: received data, start response processing:\n".sprintf("%v02X", $buf));
+ wsDecode($hash,$buf);
+ } elsif( $buf =~ /HTTP\/1.1 101 Switching Protocols/ ) {
+ Log3($name, 4, "BOTVAC(ws) $name: received HTTP data string, start response processing:\n$buf");
+ BOTVAC::wsCheckHandshake($hash,$buf);
+ } else {
+ Log3($name, 1, "BOTVAC(ws) $name: corrupted data found:\n$buf");
+ }
+}
+
+sub wsCallback(@) {
+ my ($param, $err, $data) = @_;
+ my $hash = $param->{hash};
+ my $name = $hash->{NAME};
+
+ if($err){
+ Log3($name, 3, "received callback with error:\n$err");
+ } elsif($data){
+ Log3($name, 5, "received callback with:\n$data");
+ my $parser = $param->{parser};
+ &$parser($hash, $data);
+ asyncOutput($hash->{HELPER}{CLCONF}, $data) if $hash->{HELPER}{CLCONF};
+ delete $hash->{HELPER}{CLCONF};
+ } else {
+ Log3($name, 2, "received callback without Data and Error String!!!");
+ }
+ return undef;
+}
+
+sub wsReady($) {
+ my ($hash) = @_;
+ return ::DevIo_OpenDev($hash, 1, "BOTVAC::wsHandshake") if ( $hash->{STATE} eq "disconnected" );
+}
+
+# 0 1 2 3
+# 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+# +-+-+-+-+-------+-+-------------+-------------------------------+
+# |F|R|R|R| opcode|M| Payload len | Extended payload length |
+# |I|S|S|S| (4) |A| (7) | (16/64) |
+# |N|V|V|V| |S| | (if payload len==126/127) |
+# | |1|2|3| |K| | |
+# +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
+# | Extended payload length continued, if payload len == 127 |
+# + - - - - - - - - - - - - - - - +-------------------------------+
+# | |Masking-key, if MASK set to 1 |
+# +-------------------------------+-------------------------------+
+## | Masking-key (continued) | Payload Data |
+# +-------------------------------- - - - - - - - - - - - - - - - +
+# : Payload Data continued ... :
+# + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
+# | Payload Data continued ... |
+# +---------------------------------------------------------------+
+# https://tools.ietf.org/html/draft-ietf-hybi-thewebsocketprotocol-17
+sub wsEncode($$;$$) {
+ my ($hash, $payload, $type, $masked) = @_;
+ my $name = $hash->{NAME};
+ $type //= "text";
+ $masked //= 1; # Mask If set to 1, a masking key is present in masking-key. 1 for all frames sent from client to server
+ my $RSV = 0;
+ my $FIN = 1; # FIN Indicates that this is the final fragment in a message. The first fragment MAY also be the final fragment.
+ my $MAX_PAYLOAD_SIZE = 65536;
+ my $wsString ='';
+ $wsString .= pack 'C', ($opcode{$type} | $RSV | ($FIN ? 128 : 0));
+ my $len = length($payload);
+
+ Log3($name, 3, "BOTVAC(ws) $name: wsEncode Payload: " . $payload);
+ return "payload to big" if ($len > $MAX_PAYLOAD_SIZE);
+
+ if ($len <= 125) {
+ $len |= 0x80 if $masked;
+ $wsString .= pack 'C', $len;
+ } elsif ($len <= 0xffff) {
+ $wsString .= pack 'C', 126 + ($masked ? 128 : 0);
+ $wsString .= pack 'n', $len;
+ } else {
+ $wsString .= pack 'C', 127 + ($masked ? 128 : 0);
+ $wsString .= pack 'N', $len >> 32;
+ $wsString .= pack 'N', ($len & 0xffffffff);
+ }
+ if ($masked) {
+ my $mask = pack 'N', int(rand(2**32));
+ $wsString .= $mask;
+ $wsString .= wsMasking($payload, $mask);
+ } else {
+ $wsString .= $payload;
+ }
+
+ Log3($name, 3, "BOTVAC(ws) $name: String: " . unpack('H*',$wsString));
+ wsWrite($hash, $wsString);
+}
+
+sub wsPong($) {
+ my $hash = shift;
+ my $name = $hash->{NAME};
+ Log3($name, 3, "BOTVAC(ws) $name: wsPong");
+ wsEncode($hash, undef, "pong");
+}
+
+sub wsDecode($$) {
+ my ($hash,$wsString) = @_;
+ my $name = $hash->{NAME};
+
+ Log3($name, 5, "BOTVAC(ws) $name: String:\n" . $wsString);
+
+ while (length $wsString) {
+ my $FIN = (ord(substr($wsString,0,1)) & 0b10000000) >> 7;
+ my $OPCODE = (ord(substr($wsString,0,1)) & 0b00001111);
+ my $masked = (ord(substr($wsString,1,1)) & 0b10000000) >> 7;
+ my $len = (ord(substr($wsString,1,1)) & 0b01111111);
+ Log3($name, 4, "BOTVAC(ws) $name: wsDecode FIN:$FIN OPCODE:$OPCODE MASKED:$masked LEN:$len");
+
+ my $offset = 2;
+ if ($len == 126) {
+ $len = unpack 'n', substr($wsString,$offset,2);
+ $offset += 2;
+ } elsif ($len == 127) {
+ $len = unpack 'q', substr($wsString,$offset,8);
+ $offset += 8;
+ }
+ my $mask;
+ if($masked) { # Mask auslesen falls Masked Bit gesetzt
+ $mask = substr($wsString,$offset,4);
+ $offset += 4;
+ }
+ #String kürzer als Längenangabe -> Zwischenspeichern?
+ if (length($wsString) < $offset + $len) {
+ Log3($name, 3, "BOTVAC(ws) $name: wsDecode Incomplete:\n" . $wsString);
+ return;
+ }
+ my $payload = substr($wsString, $offset, $len); # Daten aus String extrahieren
+ if ($masked) { # Daten demaskieren falls maskiert
+ $payload = Neuron_wsMasking($payload, $mask);
+ }
+ Log3($name, 5, "BOTVAC(ws) $name: wsDecode Payload:\n" . $payload);
+ $wsString = substr($wsString,$offset+$len); # ausgewerteten Stringteil entfernen
+ if ($FIN) {
+ wsPong($hash) if ($OPCODE == $opcode{"ping"});
+ }
+ }
+}
+
+sub wsMasking($$) {
+ my ($payload, $mask) = @_;
+ $mask = $mask x (int(length($payload) / 4) + 1);
+ $mask = substr($mask, 0, length($payload));
+ $payload = $payload ^ $mask;
+ return $payload;
+}
+
+1;
+=pod
+=item device
+=item summary Robot Vacuums
+=item summary_DE Staubsauger Roboter
+
+=begin html
+
+
+BOTVAC
+
+ This module controls Neato Botvac Connected and Vorwerk Robot Vacuums.
+ For issuing commands or retrieving Readings it's necessary to fetch the information from the NEATO/VORWERK Server.
+ In this way, it can happen, that it's not possible to send commands to the Robot until the corresponding Values are fetched.
+ This means, it can need some time until your Robot will react on your command.
+
+
+
+Define
+
+
+ define <name> BOTVAC <email> [NEATO|VORWERK] [<polling-interval>]
+
+ Example: define myNeato BOTVAC myemail@myprovider.com NEATO 300
+
+
+ After defining the Device, it's necessary to enter the password with "set <name> password <password>"
+ It is exactly the same Password as you use on the Website or inside the App.
+
+ Example: set NEATO passwort mySecretPassword
+
+
+
+
+Get
+
+
+ get <name> batteryPercent
+
+ requests the state of the battery from Robot
+
+
+
+
+Set
+
+
+ -
+
set <name> findMe
+
+ plays a sound and let the LED light for easier finding of a stuck robot
+
+
+ -
+
set <name> dismissCurrentAlert
+
+ reset an actual Warning (e.g. dustbin full)
+
+
+ -
+
set <name> nextCleaningMode
+
+ Depending on Model, there are Arguments available: eco/turbo
+
+
+ -
+
set <name> nextCleaningNavigationMode
+
+ The navigation mode is used for the next house cleaning.
+ Depending on Model, there are Arguments available: normal/extraCare/deep
+
+
+ -
+
set <name> nextCleaningNavigationModifier
+
+ The modifier is used for next spot cleaning.
+ Depending on Model, there are Arguments available: normal/double
+
+
+ -
+
set <name> nextCleaningZone
+
+ Depending on Model, the ID of the zone that will be used for the next zone cleaning can be set.
+
+
+ -
+
set <name> nextCleaningSpotHeight
+
+ Is defined as number between 100 - 400. The unit is cm.
+
+
+ -
+
set <name> nextCleaningSpotWidth
+
+ Is defined as number between 100 - 400. The unit is cm.
+
+
+ -
+
set <name> password <password>
+
+ set the password for the NEATO/VORWERK account
+
+
+ -
+
set <name> pause
+
+ interrupts the cleaning
+
+
+ -
+
set <name> pauseToBase
+
+ stops cleaning and returns to base
+
+
+ -
+
set <name> reloadMaps
+
+ load last map from server into the cache of the module. no file is stored!
+
+
+ -
+
set <name> resume
+
+ resume cleaning after pause
+
+
+ -
+
set <name> schedule
+
+ on and off, switch time control
+
+
+ -
+
set <name> sendToBase
+
+ send roboter back to base
+
+
+ -
+
set <name> setBoundariesOnFloorplan_<floor plan> <name|{JSON String}>
+
+ Set boundaries/nogo lines in the corresponding floor plan.
+ The paramter can either be a name, which is already defined by attribute "boundaries", or alternatively a JSON string.
+ (A comma-separated list of names is also possible.)
+ Description of syntax at https://developers.neatorobotics.com/api/robot-remote-protocol/maps
+
+ Examples:
+ set <name> setBoundariesOnFloorplan_0 Bad
+ set <name> setBoundariesOnFloorplan_0 Bad,Kueche
+ set <name> setBoundariesOnFloorplan_0 {"type":"polyline","vertices":[[0.710,0.6217],[0.710,0.6923]],
+ "name":"Bad","color":"#E54B1C","enabled":true}
+
+
+ -
+
set <name> setRobot
+
+ choose robot if more than one is registered at the used account
+
+
+ -
+
set <name> startCleaning ([house|map|zone])
+
+ start the Cleaning from the scratch.
+ If the robot supports boundaries/nogo lines/zones, the additional parameter can be used as:
+
+ house
- cleaning without a persisted map
+ map
- cleaning with a persisted map
+ zone
- cleaning in a specific zone, set zone with nextCleaningZone
+
+
+
+ -
+
set <name> startSpot
+
+ start spot-Cleaning from actual position.
+
+
+ -
+
set <name> startManual
+
+ start Manual Cleaning. This cleaning mode opens a direct websocket connection to the robot.
+ Therefore robot and FHEM installation has to reside in the same LAN.
+ Even though an internet connection is necessary as the initialization is triggered by a remote call.
+
+ Note: If the robot does not receive any messages for 30 seconds it will exit Manual Cleaning,
+ but it will not close the websocket connection automaticaly.
+
+
+ -
+
set <name> statusRequest
+
+ pull update of all readings. necessary because NEATO/VORWERK does not send updates at their own.
+
+
+ -
+
set <name> stop
+
+ stop cleaning and in case of manual cleaning mode close also the websocket connection
+
+
+ -
+
set <name> syncRobots
+
+ sync robot data with online account. Useful if one has more then one robot registered
+
+
+ -
+
set <name> wsCommand
+
+ Commands start or stop cleaning activities.
+
+ eco-on
+ eco-off
+ turbo-on
+ turbo-off
+ brush-on
+ brush-off
+ vacuum-on
+ vacuum-off
+
+
+
+ -
+
set <name> wsCombo
+
+ Combos specify a behavior on the robot. They need to be sent with less than 1Hz frequency.
+ If the robot doesn't receive a combo with the specified frequency it will stop moving.
+
+ forward
issues a continuous forward motion.
+ back
issues a discontinuous backward motion in ~30cm intervals as a safety measure since the robot has no sensors at the back.
+ arc-left
issues a 450 turn counter-clockwise while going forward.
+ arc-right
issues a 450 turn clockwise while going forward.
+ pivot-left
issues a 900 turn counter-clockwise.
+ pivot-right
issues a 900 turn clockwise.
+ stop
issues an immediate stop.
+
+ Also, if the robot does not receive any messages for 30 seconds it will exit Manual Cleaning.
+
+
+
+
+Attributes
+
+
+ -
+
actionInterval
+
+ time in seconds between status requests while Device is working
+
+
+ -
+
boundaries
+
+ Boundary entries separated by space in JSON format, e.g.
+ {"type":"polyline","vertices":[[0.710,0.6217],[0.710,0.6923]],"name":"Bad","color":"#E54B1C","enabled":true}
+ {"type":"polyline","vertices":[[0.7139,0.4101],[0.7135,0.4282],[0.4326,0.3322],[0.4326,0.2533],[0.3931,0.2533],
+ [0.3931,0.3426],[0.7452,0.4637],[0.7617,0.4196]],"name":"Kueche","color":"#000000","enabled":true}
+ For description of syntax see: https://developers.neatorobotics.com/api/robot-remote-protocol/maps
+ The value of paramter "name" is used as setListe for "setBoundariesOnFloorplan_<floor plan>".
+ It is also possible to save more than one boundary with the same name.
+ The command "setBoundariesOnFloorplan_<floor plan> <name>" sends all boundary with the same name.
+
+
+
+
+
+
+=end html
+=cut
diff --git a/fhem/MAINTAINER.txt b/fhem/MAINTAINER.txt
index d437b2731..e6c49a6ab 100644
--- a/fhem/MAINTAINER.txt
+++ b/fhem/MAINTAINER.txt
@@ -307,6 +307,7 @@ FHEM/63_EMGZ.pm rudolfkoenig SlowRF
FHEM/64_ESA2000.pm stromer-12 SlowRF
FHEM/66_ECMD.pm neubert Sonstige Systeme
FHEM/67_ECMDDevice.pm neubert Sonstige Systeme
+FHEM/70_BOTVAC.pm vuffiraa Sonstige Systeme
FHEM/70_BRAVIA.pm vuffiraa Multimedia
FHEM/70_DoorPi.pm pahenning Automatisierung
FHEM/70_EGPM.pm alexus Sonstiges