#########################################################################
# $Id$
# fhem Modul which provides traffic details with Google Distance API
#
# 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 .
#
# versioning: MAJOR.MINOR.PATCH, increment the:
# MAJOR version when you make incompatible API changes
# - includes changing CLI options, changing log-messages
# MINOR version when you add functionality in a backwards-compatible manner
# - includes adding new features and log-messages (as long as they don't break anything existing)
# PATCH version when you make backwards-compatible bug fixes.
#
##############################################################################
# Changelog:
#
# 2016-07-26 initial release
# 2016-07-28 added eta, readings in minutes
# 2016-08-01 changed JSON decoding/encofing, added stateReading attribute, added outputReadings attribute
# 2016-08-02 added attribute includeReturn, round minutes & smart zero'ing, avoid negative values, added update burst
# 2016-08-05 fixed 3 perl warnings
# 2016-08-09 added auto-update if status returns UNKOWN_ERROR, added outputReading average
# 2016-09-25 bugfix Blocking, improved errormessage
# 2016-10-07 version 1.0, adding to SVN
# 2016-10-15 adding attribute updateSchedule to provide flexible updates, changed internal interval to INTERVAL
# 2016-12-13 adding travelMode, fixing stateReading with value 0
# 2016-12-15 adding reverseWaypoints attribute, adding weblink with auto create route via gmaps on verbose 5
# 2017-04-21 reduced log entries if verbose is not set, fixed JSON error, Map available through FHEM-Web-toggle, and direct link
# Map https, with APIKey, Traffic & customizable, new attributes GoogleMapsStyle,GoogleMapsSize,GoogleMapsLocation,GoogleMapsStroke,GoogleMapsDisableUI
# 2017-04-21 added buttons to save current map settings, renamed attribute GoogleMapsLocation to GoogleMapsCenter
# 2017-04-22 v1.3.2 stroke supports weight and opacity, minor fixes
# 2017-12-51 v1.3.3 catch JSON decode issue, addedn Dbog_splitFn, added reading summary, new attr GoogleMapsFixedMap, net attr alternatives, new reading alternatives, alternatives, lighter&thinner on map
# 2018-01-26 v1.3.4 fixed Dbog_splitFn, improved exception handling
# 2018-01-28 v1.3.5 fixed Dbog_splitFn again
# 2018-01-28 v1.3.6 removed perl warning on module load
# 2018-03-02 v1.3.7 fixed issue with special character in readings, updateschedule supports multiple timeframes per day
#
##############################################################################
package main;
use strict;
use warnings;
use Data::Dumper;
use Time::HiRes qw(gettimeofday);
use Time::Local;
use LWP::Simple qw($ua get);
use Blocking;
use POSIX;
use JSON;
die "MIME::Base64 missing!" unless(eval{require MIME::Base64});
die "JSON missing!" unless(eval{require JSON});
sub TRAFFIC_Initialize($);
sub TRAFFIC_Define($$);
sub TRAFFIC_Undef($$);
sub TRAFFIC_Set($@);
sub TRAFFIC_Attr(@);
sub TRAFFIC_GetUpdate($);
sub TRAFFIC_DbLog_split($);
my %TRcmds = (
'update' => 'noArg',
);
my $TRVersion = '1.3.7';
sub TRAFFIC_Initialize($){
my ($hash) = @_;
$hash->{DefFn} = "TRAFFIC_Define";
$hash->{UndefFn} = "TRAFFIC_Undef";
$hash->{SetFn} = "TRAFFIC_Set";
$hash->{AttrFn} = "TRAFFIC_Attr";
$hash->{AttrList} =
"disable:0,1 start_address end_address raw_data:0,1 language waypoints returnWaypoints stateReading outputReadings travelMode:driving,walking,bicycling,transit includeReturn:0,1 updateSchedule GoogleMapsStyle:default,silver,dark,night GoogleMapsSize GoogleMapsZoom GoogleMapsCenter GoogleMapsStroke GoogleMapsTrafficLayer:0,1 GoogleMapsDisableUI:0,1 GoogleMapsFixedMap:0,1 alternatives:0,1 " .
$readingFnAttributes;
$data{FWEXT}{"/TRAFFIC"}{FUNC} = "TRAFFIC";
$data{FWEXT}{"/TRAFFIC"}{FORKABLE} = 1;
$hash->{FW_detailFn} = "TRAFFIC_fhemwebFn";
$hash->{DbLog_splitFn} = "TRAFFIC_DbLog_split";
}
sub TRAFFIC_Define($$){
my ($hash, $allDefs) = @_;
my @deflines = split('\n',$allDefs);
my @apiDefs = split('[ \t]+', shift @deflines);
if(int(@apiDefs) < 3) {
return "too few parameters: 'define TRAFFIC '";
}
$hash->{NAME} = $apiDefs[0];
$hash->{APIKEY} = $apiDefs[2];
$hash->{VERSION} = $TRVersion;
delete($hash->{BURSTCOUNT}) if $hash->{BURSTCOUNT};
delete($hash->{BURSTINTERVAL}) if $hash->{BURSTINTERVAL};
my $name = $hash->{NAME};
#clear all readings
foreach my $clearReading ( keys %{$hash->{READINGS}}){
Log3 $hash, 5, "TRAFFIC: ($name) READING: $clearReading deleted";
delete($hash->{READINGS}{$clearReading});
}
#clear all helpers
foreach my $helperName ( keys %{$hash->{helper}}){
delete($hash->{helper}{$helperName});
}
# clear weblink
FW_fC("delete ".$name."_weblink");
# basic update INTERVAL
if(scalar(@apiDefs) > 3 && $apiDefs[3] =~ m/^\d+$/){
$hash->{INTERVAL} = $apiDefs[3];
}else{
$hash->{INTERVAL} = 3600;
}
Log3 $hash, 4, "TRAFFIC: ($name) defined ".$hash->{NAME}.' with interval set to '.$hash->{INTERVAL};
# put in default verbose level
$attr{$name}{"verbose"} = 1 if !$attr{$name}{"verbose"};
$attr{$name}{"outputReadings"} = "text" if !$attr{$name}{"outputReadings"};
readingsSingleUpdate( $hash, "state", "Initialized", 1 );
my $firstTrigger = gettimeofday() + 2;
$hash->{TRIGGERTIME} = $firstTrigger;
$hash->{TRIGGERTIME_FMT} = FmtDateTime($firstTrigger);
RemoveInternalTimer($hash);
InternalTimer($firstTrigger, "TRAFFIC_StartUpdate", $hash, 0);
Log3 $hash, 5, "TRAFFIC: ($name) InternalTimer set to call GetUpdate in 2 seconds for the first time";
return undef;
}
sub TRAFFIC_Undef($$){
my ( $hash, $arg ) = @_;
RemoveInternalTimer ($hash);
return undef;
}
sub TRAFFIC_fhemwebFn($$$$) {
my ($FW_wname, $device, $room, $pageHash) = @_; # pageHash is set for summaryFn.
my $name = $device;
my $hash = $defs{$name};
my $mapState = ReadingsVal($device,".map", "off") eq "on" ? "off" : "on";
my $web = "toggle Map ";
if (ReadingsVal($device,".map","off") eq "on") {
$web .= TRAFFIC_GetMap($device);
$web .= TRAFFIC_weblink($device);
$web .= "";
$web .= "";
}
return $web;
}
sub TRAFFIC_GetMap($@){
my $device = shift();
my $name = $device;
my $hash = $defs{$name};
my $debugPoly=1;
my @alternativesPoly = split(',',decode_base64($hash->{helper}{'Poly'}));
my $returnDebugPoly = $hash->{helper}{'return_Poly'};
my $GoogleMapsCenter = AttrVal($name, "GoogleMapsCenter", $hash->{helper}{'GoogleMapsCenter'});
if(!$debugPoly || !$GoogleMapsCenter){
return "
please update your device first
";
}
my%GoogleMapsStyles=(
'default' => "[]",
'silver' => '[{"elementType":"geometry","stylers":[{"color":"#f5f5f5"}]},{"elementType":"labels.icon","stylers":[{"visibility":"off"}]},{"elementType":"labels.text.fill","stylers":[{"color":"#616161"}]},{"elementType":"labels.text.stroke","stylers":[{"color":"#f5f5f5"}]},{"featureType":"administrative.land_parcel","elementType":"labels.text.fill","stylers":[{"color":"#bdbdbd"}]},{"featureType":"poi","elementType":"geometry","stylers":[{"color":"#eeeeee"}]},{"featureType":"poi","elementType":"labels.text.fill","stylers":[{"color":"#757575"}]},{"featureType":"poi.park","elementType":"geometry","stylers":[{"color":"#e5e5e5"}]},{"featureType":"poi.park","elementType":"labels.text.fill","stylers":[{"color":"#9e9e9e"}]},{"featureType":"road","elementType":"geometry","stylers":[{"color":"#ffffff"}]},{"featureType":"road.arterial","elementType":"labels.text.fill","stylers":[{"color":"#757575"}]},{"featureType":"road.highway","elementType":"geometry","stylers":[{"color":"#dadada"}]},{"featureType":"road.highway","elementType":"labels.text.fill","stylers":[{"color":"#616161"}]},{"featureType":"road.local","elementType":"labels.text.fill","stylers":[{"color":"#9e9e9e"}]},{"featureType":"transit.line","elementType":"geometry","stylers":[{"color":"#e5e5e5"}]},{"featureType":"transit.station","elementType":"geometry","stylers":[{"color":"#eeeeee"}]},{"featureType":"water","elementType":"geometry","stylers":[{"color":"#c9c9c9"}]},{"featureType":"water","elementType":"labels.text.fill","stylers":[{"color":"#9e9e9e"}]}]',
'dark' => '[{"elementType":"geometry","stylers":[{"color":"#212121"}]},{"elementType":"labels.icon","stylers":[{"visibility":"off"}]},{"elementType":"labels.text.fill","stylers":[{"color":"#757575"}]},{"elementType":"labels.text.stroke","stylers":[{"color":"#212121"}]},{"featureType":"administrative","elementType":"geometry","stylers":[{"color":"#757575"}]},{"featureType":"administrative.country","elementType":"labels.text.fill","stylers":[{"color":"#9e9e9e"}]},{"featureType":"administrative.land_parcel","stylers":[{"visibility":"off"}]},{"featureType":"administrative.locality","elementType":"labels.text.fill","stylers":[{"color":"#bdbdbd"}]},{"featureType":"poi","elementType":"labels.text.fill","stylers":[{"color":"#757575"}]},{"featureType":"poi.park","elementType":"geometry","stylers":[{"color":"#181818"}]},{"featureType":"poi.park","elementType":"labels.text.fill","stylers":[{"color":"#616161"}]},{"featureType":"poi.park","elementType":"labels.text.stroke","stylers":[{"color":"#1b1b1b"}]},{"featureType":"road","elementType":"geometry.fill","stylers":[{"color":"#2c2c2c"}]},{"featureType":"road","elementType":"labels.text.fill","stylers":[{"color":"#8a8a8a"}]},{"featureType":"road.arterial","elementType":"geometry","stylers":[{"color":"#373737"}]},{"featureType":"road.highway","elementType":"geometry","stylers":[{"color":"#3c3c3c"}]},{"featureType":"road.highway.controlled_access","elementType":"geometry","stylers":[{"color":"#4e4e4e"}]},{"featureType":"road.local","elementType":"labels.text.fill","stylers":[{"color":"#616161"}]},{"featureType":"transit","elementType":"labels.text.fill","stylers":[{"color":"#757575"}]},{"featureType":"water","elementType":"geometry","stylers":[{"color":"#000000"}]},{"featureType":"water","elementType":"labels.text.fill","stylers":[{"color":"#3d3d3d"}]}]',
'night' => '[{"elementType":"geometry","stylers":[{"color":"#242f3e"}]},{"elementType":"labels.text.fill","stylers":[{"color":"#746855"}]},{"elementType":"labels.text.stroke","stylers":[{"color":"#242f3e"}]},{"featureType":"administrative.locality","elementType":"labels.text.fill","stylers":[{"color":"#d59563"}]},{"featureType":"poi","elementType":"labels.text.fill","stylers":[{"color":"#d59563"}]},{"featureType":"poi.park","elementType":"geometry","stylers":[{"color":"#263c3f"}]},{"featureType":"poi.park","elementType":"labels.text.fill","stylers":[{"color":"#6b9a76"}]},{"featureType":"road","elementType":"geometry","stylers":[{"color":"#38414e"}]},{"featureType":"road","elementType":"geometry.stroke","stylers":[{"color":"#212a37"}]},{"featureType":"road","elementType":"labels.text.fill","stylers":[{"color":"#9ca5b3"}]},{"featureType":"road.highway","elementType":"geometry","stylers":[{"color":"#746855"}]},{"featureType":"road.highway","elementType":"geometry.stroke","stylers":[{"color":"#1f2835"}]},{"featureType":"road.highway","elementType":"labels.text.fill","stylers":[{"color":"#f3d19c"}]},{"featureType":"transit","elementType":"geometry","stylers":[{"color":"#2f3948"}]},{"featureType":"transit.station","elementType":"labels.text.fill","stylers":[{"color":"#d59563"}]},{"featureType":"water","elementType":"geometry","stylers":[{"color":"#17263c"}]},{"featureType":"water","elementType":"labels.text.fill","stylers":[{"color":"#515c6d"}]},{"featureType":"water","elementType":"labels.text.stroke","stylers":[{"color":"#17263c"}]}]',
);
my $selectedGoogleMapsStyle = $GoogleMapsStyles{ AttrVal($name, "GoogleMapsStyle", 'default' )};
if(!$selectedGoogleMapsStyle){$selectedGoogleMapsStyle = $GoogleMapsStyles{'default'}}; #catch attribute mistake here
# load map scale and zoom from attr, override if empty/na
my ( $GoogleMapsWidth, $GoogleMapsHeight ) = AttrVal($name, "GoogleMapsSize", '800,600') =~ m/(\d+),(\d+)/;
my ( $GoogleMapsZoom ) = AttrVal($name, "GoogleMapsZoom", '10');
my ( $GoogleMapsStroke1Color, $GoogleMapsStroke1Weight, $GoogleMapsStroke1Opacity, $GoogleMapsStroke2Color, $GoogleMapsStroke2Weight, $GoogleMapsStroke2Opacity ) = AttrVal($name, "GoogleMapsStroke", '#4cde44,6,100,#FF0000,1,100') =~ m/^(#[a-zA-z0-9]+),?(\d*),?(\d*),?(#[a-zA-z0-9]+)?,?(\d*),?(\d*)/;
# catch incomplete configuration here and put in defaults
$GoogleMapsStroke1Color = '#4cde44' if !$GoogleMapsStroke1Color;
$GoogleMapsStroke1Weight = '6' if !$GoogleMapsStroke1Weight;
$GoogleMapsStroke1Opacity = '100' if !$GoogleMapsStroke1Opacity;
$GoogleMapsStroke2Color = '#FF0000' if !$GoogleMapsStroke2Color;
$GoogleMapsStroke2Weight = '1' if !$GoogleMapsStroke2Weight;
$GoogleMapsStroke2Opacity = '100' if !$GoogleMapsStroke2Opacity;
# make percent value to 50 to 0.5 etc
$GoogleMapsStroke1Opacity = ($GoogleMapsStroke1Opacity / 100);
$GoogleMapsStroke2Opacity = ($GoogleMapsStroke2Opacity / 100);
# pregenerate the alternatives colors, bit darker and thinner than the primary route
my $GoogleMapsStrokeAColor = '#'.lightHex($GoogleMapsStroke1Color, '0.3');
my $GoogleMapsStrokeAWeight = int($GoogleMapsStroke1Weight - 3);
my $GoogleMapsStrokeAOpacity = $GoogleMapsStroke1Opacity;
my $GoogleMapsDisableUI = '';
$GoogleMapsDisableUI = "disableDefaultUI: true," if AttrVal($name, "GoogleMapsDisableUI", 0) eq 1;
my $GoogleMapsFixedMap = '';
$GoogleMapsFixedMap = "draggable: false," if AttrVal($name, "GoogleMapsFixedMap", 0) eq 1;
Log3 $hash, 4, "TRAFFIC: ($name) drawing map in style ".AttrVal($name, "GoogleMapsStyle", 'default' )." in $GoogleMapsWidth x $GoogleMapsHeight px";
my $map;
$map .= '
';
foreach my $polyIndex (0..$#alternativesPoly){
$map .= '';
}
$map .= '' if $returnDebugPoly && decode_base64($returnDebugPoly);
$map .= '
';
return $map;
}
#
# Attr command
#########################################################################
sub TRAFFIC_Attr(@){
my ($cmd,$name,$attrName,$attrValue) = @_;
# $cmd can be "del" or "set"
# $name is device name
my $hash = $defs{$name};
if ($cmd eq "set") {
addToDevAttrList($name, $attrName);
Log3 $hash, 4, "TRAFFIC: ($name) attrName $attrName set to attrValue $attrValue";
}
if($attrName eq "disable" && $attrValue eq "1"){
readingsSingleUpdate( $hash, "state", "disabled", 1 );
}
if($attrName eq "outputReadings" || $attrName eq "includeReturn" || $attrName eq "verbose"){
#clear all readings
foreach my $clearReading ( keys %{$hash->{READINGS}}){
Log3 $hash, 5, "TRAFFIC: ($name) READING: $clearReading deleted";
delete($hash->{READINGS}{$clearReading});
}
#clear all helpers
foreach my $helperName ( keys %{$hash->{helper}}){
delete($hash->{helper}{$helperName});
}
# start update
InternalTimer(gettimeofday() + 1, "TRAFFIC_StartUpdate", $hash, 0);
}
return undef;
}
sub TRAFFIC_Set($@){
my ($hash, @param) = @_;
return "\"set \" needs at least one argument: \n".join(" ",keys %TRcmds) if (int(@param) < 2);
my $name = shift @param;
my $set = shift @param;
$hash->{VERSION} = $TRVersion if $hash->{VERSION} ne $TRVersion;
if(AttrVal($name, "disable", 0 ) == 1){
readingsSingleUpdate( $hash, "state", "disabled", 1 );
Log3 $hash, 3, "TRAFFIC: ($name) is disabled, $set not set!";
return undef;
}else{
Log3 $hash, 5, "TRAFFIC: ($name) set $name $set";
}
my $validCmds = join("|",keys %TRcmds);
if($set !~ m/$validCmds/ ) {
return join(' ', keys %TRcmds);
}elsif($set =~ m/update/){
Log3 $hash, 5, "TRAFFIC: ($name) update command recieved";
# if update burst ist specified
if( (my $burstCount = shift @param) && (my $burstInterval = shift @param)){
Log3 $hash, 5, "TRAFFIC: ($name) update burst is set to $burstCount $burstInterval";
$hash->{BURSTCOUNT} = $burstCount;
$hash->{BURSTINTERVAL} = $burstInterval;
}else{
Log3 $hash, 5, "TRAFFIC: ($name) no update burst set";
}
# update internal timer and update NOW
my $updateTrigger = gettimeofday() + 1;
$hash->{TRIGGERTIME} = $updateTrigger;
$hash->{TRIGGERTIME_FMT} = FmtDateTime($updateTrigger);
RemoveInternalTimer($hash);
# start update
InternalTimer($updateTrigger, "TRAFFIC_StartUpdate", $hash, 0);
return undef;
}
}
sub TRAFFIC_StartUpdate($){
my ( $hash ) = @_;
my $name = $hash->{NAME};
my ($sec,$min,$hour,$dayn,$month,$year,$wday,$yday,$isdst) = localtime(time);
$wday=7 if $wday == 0; #sunday 0 -> sunday 7, monday 0 -> monday 1 ...
if(AttrVal($name, "disable", 0 ) == 1){
RemoveInternalTimer ($hash);
Log3 $hash, 3, "TRAFFIC: ($name) is disabled";
return undef;
}
if ( $hash->{INTERVAL}) {
RemoveInternalTimer ($hash);
delete($hash->{UPDATESCHEDULE});
my $nextTrigger = gettimeofday() + $hash->{INTERVAL};
if(defined(AttrVal($name, "updateSchedule", undef ))){
Log3 $hash, 5, "TRAFFIC: ($name) flexible update Schedule defined";
my ($sec,$min,$hour,$mday,$mon,$year,$wday,$yday,$isdst) = localtime(time);
my @updateScheduleDef = split('\|', AttrVal($name, "updateSchedule", undef ));
foreach my $upSched (@updateScheduleDef){
my ($upFrom, $upTo, $upDay, $upInterval ) = $upSched =~ m/(\d+)-(\d+)\s(\d{1,})\s?(\d{1,})?/;
if (!$upInterval){
$upInterval = $upDay;
$upDay='';
}
Log3 $hash, 5, "TRAFFIC: ($name) parsed schedule to upFrom $upFrom, upTo $upTo, upDay $upDay, upInterval $upInterval";
Log3 $hash, 2, "TRAFFIC: ($name) DEBUG parsed schedule to upFrom $upFrom, upTo $upTo, upDay $upDay, upInterval $upInterval";
if(!$upFrom || !$upTo || !$upInterval){
Log3 $hash, 1, "TRAFIC: ($name) updateSchedule $upSched not defined correctly";
}else{
if($hour >= $upFrom && $hour < $upTo){ #if we are INSIDE the updateSchedule
if(!$upDay || $upDay == $wday ){
$nextTrigger = gettimeofday() + $upInterval;
Log3 $hash, 4, "TRAFFIC: ($name) schedule $upSched matches ($upFrom to $upTo (on day $upDay) every $upInterval seconds), matches NOW (current hour $hour day $wday), nextTrigger set to $nextTrigger";
Log3 $hash, 2, "TRAFFIC: ($name) DEBUG schedule $upSched matches ($upFrom to $upTo (on day $upDay) every $upInterval seconds), matches NOW (current hour $hour day $wday), nextTrigger set to $nextTrigger";
$hash->{UPDATESCHEDULE} = $upSched;
last; # we have our next match, end the search
}else{
Log3 $hash, 4, "TRAFFIC: ($name) $upSched does match the time but not the day ($wday)";
Log3 $hash, 2, "TRAFFIC: ($name) DEBUG $upSched does match the time but not the day ($wday)";
}
}elsif($hour < $upFrom && ( $wday == $upDay || !$upDay) ){ #get the next upcoming updateSchedule for today
my $upcomingTrigger = timelocal(0,0,$upFrom,$mday,$mon,$year);
Log3 $hash, 2, "TRAFFIC: ($name) DEBUG $upcomingTrigger <= $nextTrigger";
if($upcomingTrigger <= $nextTrigger){
$nextTrigger = $upcomingTrigger;
Log3 $hash, 2, "TRAFFIC: ($name) DEBUG $upSched is the next upcoming updateSchedule, nextTrigger is generated to $nextTrigger";
}
}else{
Log3 $hash, 5, "TRAFFIC: ($name) schedule $upSched does not match hour ($hour)";
}
}
}
}
if(defined($hash->{BURSTCOUNT}) && $hash->{BURSTCOUNT} > 0){
$nextTrigger = gettimeofday() + $hash->{BURSTINTERVAL};
Log3 $hash, 3, "TRAFFIC: ($name) next update defined by burst";
$hash->{BURSTCOUNT}--;
}elsif(defined($hash->{BURSTCOUNT}) && $hash->{BURSTCOUNT} == 0){
delete($hash->{BURSTCOUNT});
delete($hash->{BURSTINTERVAL});
Log3 $hash, 4, "TRAFFIC: ($name) burst update is done";
}
$hash->{TRIGGERTIME} = $nextTrigger;
$hash->{TRIGGERTIME_FMT} = FmtDateTime($nextTrigger);
InternalTimer($nextTrigger, "TRAFFIC_StartUpdate", $hash, 0);
Log3 $hash, 4, "TRAFFIC: ($name) internal interval timer set to call StartUpdate again at " . $hash->{TRIGGERTIME_FMT};
}
if(defined(AttrVal($name, "start_address", undef )) && defined(AttrVal($name, "end_address", undef ))){
BlockingCall("TRAFFIC_DoUpdate",$hash->{NAME}.';;;normal',"TRAFFIC_FinishUpdate",60,"TRAFFIC_AbortUpdate",$hash);
if(defined(AttrVal($name, "includeReturn", undef )) && AttrVal($name, "includeReturn", undef ) eq 1){
BlockingCall("TRAFFIC_DoUpdate",$hash->{NAME}.';;;return',"TRAFFIC_FinishUpdate",60,"TRAFFIC_AbortUpdate",$hash);
}
}else{
readingsSingleUpdate( $hash, "state", "incomplete configuration", 1 );
Log3 $hash, 1, "TRAFFIC: ($name) is not configured correctly, please add start_address and end_address";
}
}
sub TRAFFIC_AbortUpdate($){
# doto
}
sub TRAFFIC_DoUpdate(){
my ($string) = @_;
my ($hName, $direction) = split(";;;", $string); # direction is normal or return
my $hash = $defs{$hName};
my $dotrigger = 1;
my $name = $hash->{NAME};
my ($sec,$min,$hour,$dayn,$month,$year,$wday,$yday,$isdst) = localtime(time);
Log3 $hash, 4, "TRAFFIC: ($name) TRAFFIC DoUpdate start";
if ( $hash->{INTERVAL}) {
RemoveInternalTimer ($hash);
my $nextTrigger = gettimeofday() + $hash->{INTERVAL};
$hash->{TRIGGERTIME} = $nextTrigger;
$hash->{TRIGGERTIME_FMT} = FmtDateTime($nextTrigger);
InternalTimer($nextTrigger, "TRAFFIC_StartUpdate", $hash, 0);
Log3 $hash, 4, "TRAFFIC: ($name) internal interval timer set to call GetUpdate again in " . int($hash->{INTERVAL}). " seconds";
}
my $returnJSON;
my $TRlanguage = '';
if(defined(AttrVal($name,"language",undef))){
$TRlanguage = '&language='.AttrVal($name,"language","");
}else{
Log3 $hash, 5, "TRAFFIC: ($name) no language specified";
}
my $TRwaypoints = '';
if(defined(AttrVal($name,"waypoints",undef))){
$TRwaypoints = '&waypoints=via:' . join('|via:', split('\|', AttrVal($name,"waypoints",undef)));
}else{
Log3 $hash, 4, "TRAFFIC: ($name) no waypoints specified";
}
if($direction eq "return"){
if(defined(AttrVal($name,"returnWaypoints",undef))){
$TRwaypoints = '&waypoints=via:' . join('|via:', split('\|', AttrVal($name,"returnWaypoints",undef)));
Log3 $hash, 4, "TRAFFIC: ($name) using returnWaypoints";
}elsif(defined(AttrVal($name,"waypoints",undef))){
$TRwaypoints = '&waypoints=via:' . join('|via:', reverse split('\|', AttrVal($name,"waypoints",undef)));
Log3 $hash, 4, "TRAFFIC: ($name) reversing waypoints";
}else{
Log3 $hash, 4, "TRAFFIC: ($name) no waypoints for return specified";
}
}
my $origin = AttrVal($name, "start_address", 0 );
my $destination = AttrVal($name, "end_address", 0 );
my $travelMode = AttrVal($name, "travelMode", 'driving' );
my $alternatives = 'false';
$alternatives = 'true' if (AttrVal($name, "alternatives", undef ));
if($direction eq "return"){
$origin = AttrVal($name, "end_address", 0 );
$destination = AttrVal($name, "start_address", 0 );
$alternatives = 'false';
}
my $url = 'https://maps.googleapis.com/maps/api/directions/json?origin='.$origin.'&destination='.$destination.'&mode='.$travelMode.$TRlanguage.'&departure_time=now'.$TRwaypoints.'&key='.$hash->{APIKEY}.'&alternatives='.$alternatives;
Log3 $hash, 4, "TRAFFIC: ($name) using $url";
my $ua = LWP::UserAgent->new( ssl_opts => { verify_hostname => 0 } );
$ua->default_header("HTTP_REFERER" => "www.google.de");
my $body = $ua->get($url);
# test json decode and catch error nicely
eval {
my $testJson = decode_json($body->decoded_content);
1;
};
if($@) {
my $e = $@;
Log3 $hash, 1, "TRAFFIC: ($name) decode_json on googles return failed, cant continue";
Log3 $hash, 5, "TRAFFIC: ($name) received: ".Dumper($body->decoded_content);
my %errorReturn = ('status' => 'API error','action' => 'retry');
return "$name;;;$direction;;;".encode_json(\%errorReturn);
};
my $json = JSON->new->utf8(0)->decode($body->decoded_content); #utf8 decoding to support special characters in return & readings
my $duration_sec = $json->{'routes'}[0]->{'legs'}[0]->{'duration'}->{'value'} ;
my $duration_in_traffic_sec = $json->{'routes'}[0]->{'legs'}[0]->{'duration_in_traffic'}->{'value'};
$returnJSON->{'READINGS'}->{'duration'} = $json->{'routes'}[0]->{'legs'}[0]->{'duration'}->{'text'} if AttrVal($name, "outputReadings", "" ) =~ m/text/;
$returnJSON->{'READINGS'}->{'duration_in_traffic'} = $json->{'routes'}[0]->{'legs'}[0]->{'duration_in_traffic'}->{'text'} if AttrVal($name, "outputReadings", "" ) =~ m/text/;
$returnJSON->{'READINGS'}->{'distance'} = $json->{'routes'}[0]->{'legs'}[0]->{'distance'}->{'text'} if AttrVal($name, "outputReadings", "" ) =~ m/text/;
$returnJSON->{'READINGS'}->{'state'} = $json->{'status'};
$returnJSON->{'READINGS'}->{'status'} = $json->{'status'};
$returnJSON->{'READINGS'}->{'eta'} = FmtTime( gettimeofday() + $duration_in_traffic_sec ) if defined($duration_in_traffic_sec);
$returnJSON->{'READINGS'}->{'summary'} = $json->{'routes'}[0]->{'summary'};
# handling alternatives
$returnJSON->{'READINGS'}->{'alternatives'} = join( ", ", map { $_->{summary}.' - '.$_->{'legs'}[0]->{'duration_in_traffic'}->{'text'} } @{$json->{'routes'}} );
$returnJSON->{'HELPER'}->{'Poly'} = encode_base64 (join(',', map{ $_->{overview_polyline}->{points} } @{$json->{'routes'}} ));
$returnJSON->{'HELPER'}->{'GoogleMapsCenter'} = $json->{'routes'}[0]->{'legs'}[0]->{start_location}->{lat}.','.$json->{'routes'}[0]->{'legs'}[0]->{start_location}->{lng};
if($duration_in_traffic_sec && $duration_sec){
$returnJSON->{'READINGS'}->{'delay'} = prettySeconds($duration_in_traffic_sec - $duration_sec) if AttrVal($name, "outputReadings", "" ) =~ m/text/;
Log3 $hash, 4, "TRAFFIC: ($name) delay in seconds = $duration_in_traffic_sec - $duration_sec";
if (AttrVal($name, "outputReadings", "" ) =~ m/min/ && defined($duration_in_traffic_sec) && defined($duration_sec)){
$returnJSON->{'READINGS'}->{'delay_min'} = int($duration_in_traffic_sec - $duration_sec);
}
if(defined($returnJSON->{'READINGS'}->{'delay_min'})){
if( ( $returnJSON->{'READINGS'}->{'delay_min'} && $returnJSON->{'READINGS'}->{'delay_min'} =~ m/^-/ ) || $returnJSON->{'READINGS'}->{'delay_min'} < 60){
Log3 $hash, 5, "TRAFFIC: ($name) delay_min was negative or less than 1min (".$returnJSON->{'READINGS'}->{'delay_min'}."), set to 0";
$returnJSON->{'READINGS'}->{'delay_min'} = 0;
}else{
$returnJSON->{'READINGS'}->{'delay_min'} = int($returnJSON->{'READINGS'}->{'delay_min'} / 60 + 0.5); #divide 60 and round
}
}
}else{
Log3 $hash, 1, "TRAFFIC: ($name) did not receive duration_in_traffic, not able to calculate delay";
}
# condition based values
$returnJSON->{'READINGS'}->{'error_message'} = $json->{'error_message'} if $json->{'error_message'};
# output readings
$returnJSON->{'READINGS'}->{'duration_min'} = int($duration_sec / 60 + 0.5) if AttrVal($name, "outputReadings", "" ) =~ m/min/ && defined($duration_sec);
$returnJSON->{'READINGS'}->{'duration_in_traffic_min'} = int($duration_in_traffic_sec / 60 + 0.5) if AttrVal($name, "outputReadings", "" ) =~ m/min/ && defined($duration_in_traffic_sec);
$returnJSON->{'READINGS'}->{'duration_sec'} = $duration_sec if AttrVal($name, "outputReadings", "" ) =~ m/sec/;
$returnJSON->{'READINGS'}->{'duration_in_traffic_sec'} = $duration_in_traffic_sec if AttrVal($name, "outputReadings", "" ) =~ m/sec/;
# raw data (seconds)
$returnJSON->{'READINGS'}->{'distance'} = $json->{'routes'}[0]->{'legs'}[0]->{'distance'}->{'value'} if AttrVal($name, "raw_data", 0);
# average readings
if(AttrVal($name, "outputReadings", "" ) =~ m/average/){
# calc average
$returnJSON->{'READINGS'}->{'average_duration_min'} = int($hash->{READINGS}{'average_duration_min'}{VAL} + $returnJSON->{'READINGS'}->{'duration_min'}) / 2 if $returnJSON->{'READINGS'}->{'duration_min'};
$returnJSON->{'READINGS'}->{'average_duration_in_traffic_min'} = int($hash->{READINGS}{'average_duration_in_traffic_min'}{VAL} + $returnJSON->{'READINGS'}->{'duration_in_traffic_min'}) / 2 if $returnJSON->{'READINGS'}->{'duration_in_traffic_min'};
$returnJSON->{'READINGS'}->{'average_delay_min'} = int($hash->{READINGS}{'average_delay_min'}{VAL} + $returnJSON->{'READINGS'}->{'delay_min'}) / 2 if $returnJSON->{'READINGS'}->{'delay_min'};
# override if this is the first average
$returnJSON->{'READINGS'}->{'average_duration_min'} = $returnJSON->{'READINGS'}->{'duration_min'} if !$hash->{READINGS}{'average_duration_min'}{VAL};
$returnJSON->{'READINGS'}->{'average_duration_in_traffic_min'} = $returnJSON->{'READINGS'}->{'duration_in_traffic_min'} if !$hash->{READINGS}{'average_duration_in_traffic_min'}{VAL};
$returnJSON->{'READINGS'}->{'average_delay_min'} = $returnJSON->{'READINGS'}->{'delay_min'} if !$hash->{READINGS}{'average_delay_min'}{VAL};
}
Log3 $hash, 5, "TRAFFIC: ($name) returning from TRAFFIC DoUpdate: ".encode_json($returnJSON);
Log3 $hash, 4, "TRAFFIC: ($name) TRAFFIC DoUpdate done";
return "$name;;;$direction;;;".encode_json($returnJSON);
}
sub TRAFFIC_FinishUpdate($){
my ($name,$direction,$rawJson) = split(/;;;/,shift);
my $hash = $defs{$name};
my %sensors;
my $dotrigger = 1;
my $json = decode_json($rawJson);
# before we update anything, check if the status contains error, if yes -> retry
if(defined($json->{'status'}) && $json->{'status'} =~ m/error/i){ # this handles potential JSON decode issues and retries
if ($json->{'action'} eq 'retry'){
Log3 $hash, 1, "TRAFFIC: ($name) TRAFFIC doUpdate returned an error \"".$json->{'status'}. "\" will schedule a retry in 5 seconds";
RemoveInternalTimer ($hash);
my $nextTrigger = gettimeofday() + 5;
$hash->{TRIGGERTIME} = $nextTrigger;
$hash->{TRIGGERTIME_FMT} = FmtDateTime($nextTrigger);
InternalTimer($nextTrigger, "TRAFFIC_StartUpdate", $hash, 0);
}else{
Log3 $hash, 1, "TRAFFIC: ($name) TRAFFIC doUpdate returned an error: ".$json->{'status'};
}
}else{ #JSON decode did not return an error, lets update the device
Log3 $hash, 4, "TRAFFIC: ($name) TRAFFIC_FinishUpdate start";
readingsBeginUpdate($hash);
my $readings = $json->{'READINGS'};
my $helper = $json->{'HELPER'};
foreach my $helperName (keys %{$helper}){
if($direction eq 'return'){
Log3 $hash, 4, "TRAFFIC: ($name) HelperUpdate: return_".$helperName." - ".$helper->{$helperName};
$hash->{helper}{'return_'.$helperName} = $helper->{$helperName}; #testme
}else{
Log3 $hash, 4, "TRAFFIC: ($name) HelperUpdate: $helperName - ".$helper->{$helperName};
$hash->{helper}{$helperName} = $helper->{$helperName}; #testme
}
}
foreach my $readingName (keys %{$readings}){
Log3 $hash, 4, "TRAFFIC: ($name) ReadingsUpdate: $readingName - ".$readings->{$readingName};
if($direction eq 'return'){
readingsBulkUpdate($hash,'return_'.$readingName,$readings->{$readingName});
}else{
readingsBulkUpdate($hash,$readingName,$readings->{$readingName});
}
}
if(my $stateReading = AttrVal($name,"stateReading",undef)){
Log3 $hash, 5, "TRAFFIC: ($name) stateReading defined, override state";
if(defined($json->{'READINGS'}->{$stateReading})){
readingsBulkUpdate($hash,'state',$json->{'READINGS'}->{$stateReading});
}else{
Log3 $hash, 1, "TRAFFIC: ($name) stateReading $stateReading not found";
}
}
# if Google returned an error, we gonna try again in 3 seconds
if(defined($json->{'READINGS'}->{'status'}) && $json->{'READINGS'}->{'status'} =~ m/error/i){ # UNKNOWN_ERROR indicates a directions request could not be processed due to a server error. The request may succeed if you try again.
Log3 $hash, 1, "TRAFFIC: ($name) auto-retry as Google returned an error: ".$json->{'READINGS'}->{'status'};
InternalTimer(gettimeofday() + 3, "TRAFFIC_StartUpdate", $hash, 0);
}elsif(defined($hash->{READINGS}->{'error_message'})){
Log3 $hash, 3, "TRAFFIC: ($name) removing reading error_message, status: ".$json->{'READINGS'}->{'status'};
fhem("deletereading $name error_message");
}
readingsEndUpdate($hash, $dotrigger);
Log3 $hash, 4, "TRAFFIC: ($name) TRAFFIC_FinishUpdate done";
Log3 $hash, 5, "TRAFFIC: ($name) Helper: ".Dumper($hash->{helper});
}# not an error
}
sub TRAFFIC_weblink{
my $name = shift();
return "$FW_ME/TRAFFIC?name=$name ";
}
sub TRAFFIC(){
my $name = $FW_webArgs{name};
return if(!defined($name));
$FW_RETTYPE = "text/html; charset=UTF-8";
$FW_RET="";
my $web .= TRAFFIC_GetMap($name);
FW_pO $web;
return ($FW_RETTYPE, $FW_RET);
}
sub TRAFFIC_DbLog_split($) {
my ($event, $device) = @_;
my $hash = $defs{$device};
Log3 $hash, 5, "TRAFFIC: ($device) TRAFFIC_DbLog_split received event $event on device $device";
my $readings; # this holds all possible readings and their units
$readings->{'update'} = 'text';
$readings->{'duration'} = 'text';
$readings->{'duration_in_traffic'} = 'text';
$readings->{'distance'} = 'text';
$readings->{'state'} = 'text';
$readings->{'status'} = 'text';
$readings->{'eta'} = 'time';
$readings->{'summary'} = 'text';
$readings->{'alternatives'} = 'text';
$readings->{'delay'} = 'text';
$readings->{'delay_min'} = 'min';
$readings->{'error_message'} = 'text';
$readings->{'duration_min'} = 'min';
$readings->{'duration_in_traffic_min'} = 'min';
$readings->{'duration_sec'} = 'sec';
$readings->{'duration_in_traffic_sec'} = 'sec';
$readings->{'distance'} = 'km';
$readings->{'average_duration_min'} = 'min';
$readings->{'average_duration_in_traffic_min'} = 'min';
$readings->{'average_delay_min'} = 'min';
$readings->{'average_duration_min'} = 'min';
$readings->{'average_duration_in_traffic_min'} = 'min';
$readings->{'average_delay_min'} = 'min';
my ($reading, $value, $unit) = "";
my @parts = split(/ /,$event);
$reading = shift @parts;
$reading =~ tr/://d;
my $alternativeReading = $reading;
$alternativeReading =~ s/^return_//;
$value = join(" ",@parts);
if($readings->{$reading}){
$unit = $readings->{$reading};
$value =~ s/$unit$//; #try to remove the unit from the value
}elsif($readings->{$alternativeReading}){
$unit = $readings->{$alternativeReading};
$value =~ s/$unit$//; #try to remove the unit from the value
}else{
Log3 $hash, 5, "TRAFFIC: ($device) TRAFFIC_DbLog_split auto detect unit for reading $reading value $value";
$unit = 'min' if ($reading) =~ m/_min$/;
$unit = 'sec' if ($reading) =~ m/_sec$/;
$unit = 'km' if ($reading) =~ m/_km$/;
}
Log3 $hash, 5, "TRAFFIC: ($device) TRAFFIC_DbLog_split returning $reading, $value, $unit";
return ($reading, $value, $unit);
}
sub prettySeconds {
my $time = shift;
if($time =~ m/^-/){
return "0 min";
}
my $days = int($time / 86400);
$time -= ($days * 86400);
my $hours = int($time / 3600);
$time -= ($hours * 3600);
my $minutes = int($time / 60);
my $seconds = $time % 60;
$days = $days < 1 ? '' : $days .' days ';
$hours = $hours < 1 ? '' : $hours .' hours ';
$minutes = $minutes < 1 ? '' : $minutes . ' min ';
$time = $days . $hours . $minutes;
if(!$time){
return "0 min";
}else{
return $time;
}
}
sub minHex{ $_[0]<$_[1] ? $_[0] : $_[1] }
sub degradeHex{
my ($rgb, $degr) = (hex(shift), pop);
$rgb -= minHex( $rgb&(0xff<<$_), $degr<<$_ ) for (0,8,16);
return '%06x', $rgb;
}
sub lightHex {
$_[0] =~ s/#//g;
return sprintf '%02x'x3,
map{ ($_ *= 1+$_[1]) > 0xff ? 0xff : $_ }
map hex, unpack 'A2'x3, $_[0];
}
1;
#======================================================================
#======================================================================
#
# HTML Documentation for help and commandref
#
#======================================================================
#======================================================================
=pod
=item device
=item summary provide traffic details with Google Distance API
=item summary_DE stellt Verkehrsdaten mittels Google Distance API bereit
=begin html
TRAFFIC
TRAFFIC - google maps directions module
This FHEM module collects and displays data obtained via the google maps directions api
requirements:
perl JSON module
perl LWP::SIMPLE module
perl MIME::Base64 module
Google maps API key
Features:
get distance between start and end location
get travel time for route
get travel time in traffic for route
define additional waypoints
calculate delay between travel-time and travel-time-in-traffic
choose default language
disable the device
5 log levels
get outputs in seconds / meter (raw_data)
state of google maps returned in error reading (i.e. The provided API key is invalid)
customize update interval (default 3600 seconds)
calculate ETA with localtime and delay
configure the output readings with attribute outputReadings, text, min sec
configure the state-reading
optionally display the same route in return
one-time-burst, specify the amount and interval between updates
different Travel Modes (driving, walking, bicycling and transit)
flexible update schedule
integrated Map to visualize configured route or embed to external GUI
"start_address" - Street, zipcode City (mandatory)
"end_address" - Street, zipcode City (mandatory)
"raw_data" - 0:1
"alternatives" - 0:1, include alternative routes into readings and Map
"language" - de, en etc.
"waypoints" - Lat, Long coordinates, separated by |
"returnWaypoints" - Lat, Long coordinates, separated by |
"disable" - 0:1
"stateReading" - name the reading which will be used in device state
"outputReadings" - define what kind of readings you want to get: text, min, sec, average
"updateSchedule" - define a flexible update schedule, syntax <starthour>-<endhour> [<day>] <seconds> , multiple entries by sparated by | example: 7-9 1 120 - Monday between 7 and 9 every 2minutes example: 17-19 120 - every Day between 17 and 19 every 2minutes example: 6-8 1 60|6-8 2 60|6-8 3 60|6-8 4 60|6-8 5 60 - Monday till Friday, 60 seconds between 6 and 8 am
"travelMode" - default: driving, options walking, bicycling or transit
"GoogleMapsStyle" - choose your colors from: default,silver,dark,night
"GoogleMapsSize" - Map size in pixel, <width>,<height>
"GoogleMapsCenter" - Lat, Long coordinates of your map center, spearated by ,
"GoogleMapsZoom" - sets your map zoom level
"GoogleMapsStroke" - customize your map poly-strokes in color, weight and opacity <hex-color-code>,[stroke-weight],[stroke-opacity],<hex-color-code-of-return>,[stroke-weight-of-return],[stroke-opacity-of-return] must beginn with #color of each stroke, weight and opacity is optional example: #019cdf,#ffeb19 example: #019cdf,20,#ffeb19 example: #019cdf,20,#ffeb19,15 example: #019cdf,#ffeb19,15 example: #019cdf,20,80,#ffeb19 example: #019cdf,#ffeb19,15,50 example: #019cdf,20,80 default: #4cde44,6,100,#FF0000,1,100
"GoogleMapsTrafficLayer" - enable the basic Google Maps Traffic Layer