# $Id$ ############################################################################## # # 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; use POSIX qw(strftime); use vars qw(%defs); use vars qw(%attr); sub Log($$); sub Log3($$$); use vars qw($FW_ME); use vars qw($FW_wname); use vars qw($FW_subdir); use vars qw(%FW_hiddenroom); use vars qw(%FW_visibleDeviceHash); use vars qw(%FW_webArgs); # all arguments specified in the GET my $readingsHistory_hasJSON = 0; my $readingsHistory_hasDataDumper = 1; sub readingsHistory_Initialize($) { my ($hash) = @_; $hash->{DefFn} = "readingsHistory_Define"; $hash->{NotifyFn} = "readingsHistory_Notify"; $hash->{NotifyOrderPrefix}= "51-"; $hash->{UndefFn} = "readingsHistory_Undefine"; $hash->{SetFn} = "readingsHistory_Set"; $hash->{GetFn} = "readingsHistory_Get"; $hash->{AttrFn} = "readingsHistory_Attr"; $hash->{AttrList} = "disable:1,2,3 mapping style timestampFormat valueFormat noheading:1 nohtml:1 nolinks:1 notime:1 nostate:1 alwaysTrigger:1 rows"; $hash->{FW_detailFn} = "readingsHistory_detailFn"; $hash->{FW_summaryFn} = "readingsHistory_detailFn"; $data{FWEXT}{"readingsHistory"}{SCRIPT} = "fhemweb_readingsHistory.js"; $hash->{FW_atPageEnd} = 1; eval "use Data::Dumper"; $readingsHistory_hasDataDumper = 0 if($@); } sub readingsHistory_updateDevices($) { my ($hash) = @_; my %list; my @devices; my $def = $hash->{DEF}; $def = "" if( !$def ); my @params = split(" ", $def); while (@params) { my $param = shift(@params); my @device = split(":", $param); if($device[0] =~ m/(.*)=(.*)/) { my ($lattr,$re) = ($1, $2); foreach my $d (sort keys %defs) { next if( IsIgnored($d) ); next if( !defined($defs{$d}{$lattr}) ); next if( $defs{$d}{$lattr} !~ m/^$re$/); $list{$d} = 1; push @devices, [$d,$device[1]]; } } elsif($device[0] =~ m/(.*)&(.*)/) { my ($lattr,$re) = ($1, $2); foreach my $d (sort keys %attr) { next if( IsIgnored($d) ); next if( !defined($attr{$d}{$lattr}) ); next if( $attr{$d}{$lattr} !~ m/^$re$/); $list{$d} = 1; push @devices, [$d,$device[1]]; } } elsif( defined($defs{$device[0]}) ) { $list{$device[0]} = 1; push @devices, [@device]; } else { foreach my $d (sort keys %defs) { next if( IsIgnored($d) ); eval { $d =~ m/^$device[0]$/ }; if( $@ ) { Log3 $hash->{NAME}, 3, $hash->{NAME} .": ". $device[0] .": ". $@; push @devices, ["<>"]; last; } next if( $d !~ m/^$device[0]$/); $list{$d} = 1; push @devices, [$d,$device[1]]; } } } $hash->{CONTENT} = \%list; $hash->{DEVICES} = \@devices; delete( $hash->{NOTIFYDEV} ); if( scalar keys %list == 0 ) { $hash->{NOTIFYDEV} = "global"; } $hash->{fhem}->{last_update} = gettimeofday(); } sub readingsHistory_Define($$) { my ($hash, $def) = @_; my @args = split("[ \t]+", $def); return "Usage: define readingsHistory [[:]]+" if(@args < 2); my $name = shift(@args); my $type = shift(@args); $hash->{HAS_DataDumper} = $readingsHistory_hasDataDumper; $hash->{fhem}{history} = [] if( !defined($hash->{fhem}{history}) ); readingsHistory_updateDevices($hash); $hash->{STATE} = 'Initialized'; return undef; } sub readingsHistory_lookup($$$$$$$$) { my($mapping,$name,$alias,$reading,$value,$room,$group,$default) = @_; if( $mapping ) { if( !ref($mapping) && $mapping =~ m/^{.*}$/) { my $DEVICE = $name; my $READING = $reading; my $VALUE = $value; my $m = eval $mapping; if( $@ ) { Log 2, $@ if( $@ ); } else { $mapping = $m; } } if( ref($mapping) eq 'HASH' ) { $default = $mapping->{$name} if( defined($mapping->{$name}) ); $default = $mapping->{$reading} if( defined($mapping->{$reading}) ); $default = $mapping->{$name.".".$reading} if( defined($mapping->{$name.".".$reading}) ); $default = $mapping->{$reading.".".$value} if( defined($mapping->{$reading.".".$value}) ); } else { $default = $mapping; } if( !ref($default) && $default =~ m/^{.*}$/) { my $DEVICE = $name; my $READING = $reading; my $VALUE = $value; $default = eval $default; $default = "" if( $@ ); Log 2, $@ if( $@ ); } return $default if( !defined($default) ); $default =~ s/\%ALIAS/$alias/g; $default =~ s/\%DEVICE/$name/g; $default =~ s/\%READING/$reading/g; $default =~ s/\%VALUE/$value/g; $default =~ s/\%ROOM/$room/g; $default =~ s/\%GROUP/$group/g; $default =~ s/\$ALIAS/$alias/g; $default =~ s/\$DEVICE/$name/g; $default =~ s/\$READING/$reading/g; $default =~ s/\$VALUE/$value/g; $default =~ s/\$ROOM/$room/g; $default =~ s/\$GROUP/$group/g; } return $default; } sub readingsHistory_lookup2($$$$) { my($lookup,$name,$reading,$value) = @_; return $lookup if( !$lookup ); if( !ref($lookup) && $lookup =~ m/^{.*}$/) { my $DEVICE = $name; my $READING = $reading; my $VALUE = $value; my $l = eval $lookup; if( $@ ) { Log 2, $@ if( $@ ); } else { $lookup = $l; } } if( ref($lookup) eq 'HASH' ) { my $vf = ""; $vf = $lookup->{$reading} if( defined($reading) && exists($lookup->{$reading}) ); $vf = $lookup->{$name.".".$reading} if( defined($reading) && exists($lookup->{$name.".".$reading}) ); $vf = $lookup->{$reading.".".$value} if( defined($value) && exists($lookup->{$reading.".".$value}) ); $lookup = $vf; } return $lookup if( !defined($lookup) ); if( !ref($lookup) && $lookup =~ m/^{.*}$/) { my $DEVICE = $name; my $READING = $reading; my $VALUE = $value; $lookup = eval $lookup; $lookup = "" if( $@ ); Log 2, $@ if( $@ ); } return $lookup if( !defined($lookup) ); $lookup =~ s/\%DEVICE/$name/g; $lookup =~ s/\%READING/$reading/g; $lookup =~ s/\%VALUE/$value/g; $lookup =~ s/\$DEVICE/$name/g; $lookup =~ s/\$READING/$reading/g; $lookup =~ s/\$VALUE/$value/g; return $lookup; } sub readingsHistory_Undefine($$) { my ($hash,$arg) = @_; return undef; } sub readingsHistory_2html($) { my($hash) = @_; $hash = $defs{$hash} if( ref($hash) ne 'HASH' ); return undef if( !$hash ); if( $hash->{DEF} && $hash->{DEF} =~ m/=/ ) { if( !$hash->{fhem}->{last_update} || gettimeofday() - $hash->{fhem}->{last_update} > 600 ) { readingsHistory_updateDevices($hash); } } my $d = $hash->{NAME}; my $show_heading = !AttrVal( $d, "noheading", "0" ); my $show_links = !AttrVal( $d, "nolinks", "0" ); $show_links = 0 if($FW_hiddenroom{detail}); my $disable = AttrVal($d,"disable", 0); if( AttrVal($d,"disable", 0) > 2 ) { return undef; } elsif( AttrVal($d,"disable", 0) > 1 ) { my $ret; $ret .= ""; my $txt = AttrVal($d, "alias", $d); $txt = "$txt" if( $show_links ); $ret .= "" if( $show_heading ); $ret .= ""; $ret .= "
$txt
"; #$ret .= ""; $ret .= ""; $ret .= "
disabled
"; return $ret; } my $show_time = !AttrVal( $d, "notime", "0" ); my $style = AttrVal( $d, "style", "" ); my $devices = $hash->{DEVICES}; my $row = 1; my $ret; $ret .= ""; my $txt = AttrVal($d, "alias", $d); $txt = "$txt" if( $show_links ); $ret .= "" if( $show_heading ); $ret .= ""; $ret .= "
$txt
"; $ret .= "" if( $disable > 0 ); my $rows = AttrVal($d,"rows", 5 ); $rows = 1 if( $rows < 1 ); my $lines = ""; for (my $i = 0; $i < $rows; $i++) { my $line = $hash->{fhem}{history}[$i]; if( ref($line) eq 'ARRAY' ) { $lines .= $line->[3] if( $line ); } else { $lines .= $line if( $line ); } $lines .= "
"; } $ret .= sprintf("", ($row&1)?"odd":"even"); $row++; $ret .= ""; $ret .= sprintf("", ($row&1)?"odd":"even"); $ret .= "" if( $disable > 0 ); $ret .= "
updates disabled
$lines
updates disabled
"; return $ret; } sub readingsHistory_detailFn() { my ($FW_wname, $d, $room, $pageHash) = @_; # pageHash is set for summaryFn. my $hash = $defs{$d}; $hash->{mayBeVisible} = 1; return readingsHistory_2html($d); } # In newer perl versions (>=5.22) POSIX::strftime() returns special chars in ISO-8859 instead of active locale (see: https://forum.fhem.de/index.php/topic,85132.msg777667.html#msg777667 ) sub readingsHistory_strftime(@) { my $string = POSIX::strftime(@_); $string =~ s/\xe4/ä/g; $string =~ s/\xc4/Ä/g; $string =~ s/\xf6/ö/g; $string =~ s/\xd6/Ö/g; $string =~ s/\xfc/ü/g; $string =~ s/\xdc/Ü/g; $string =~ s/\xdf/ß/g; $string =~ s/\xdf/ß/g; $string =~ s/\xe1/á/g; $string =~ s/\xe9/é/g; $string =~ s/\xc1/Á/g; $string =~ s/\xc9/É/g; return $string; } sub readingsHistory_makeTimestamp($$) { my ($t, $timestampFormat) = @_; my @lt = localtime($t); return readingsHistory_strftime( $timestampFormat, @lt ) if( $timestampFormat ); return sprintf("%02d:%02d:%02d", $lt[2], $lt[1], $lt[0] ); } sub readingsHistory_Notify($$) { my ($hash,$dev) = @_; my $name = $hash->{NAME}; if( grep(m/^INITIALIZED$/, @{$dev->{CHANGED}}) ) { readingsHistory_updateDevices($hash); readingsHistory_Load($hash); return undef; } elsif( grep(m/^REREADCFG$/, @{$dev->{CHANGED}}) ) { readingsHistory_updateDevices($hash); readingsHistory_Load($hash); return undef; } elsif( grep(m/^SAVE$/, @{$dev->{CHANGED}}) ) { readingsHistory_Save(); } return if( AttrVal($name,"disable", 0) > 0 ); return if($dev->{TYPE} eq $hash->{TYPE}); #return if($dev->{NAME} eq $name); my $devices = $hash->{DEVICES}; my $max = int(@{$dev->{CHANGED}}); for (my $i = 0; $i < $max; $i++) { my $s = $dev->{CHANGED}[$i]; $s = "" if(!defined($s)); if( $dev->{NAME} eq "global" && $s =~ m/^RENAMED ([^ ]*) ([^ ]*)$/) { my ($old, $new) = ($1, $2); if( defined($hash->{CONTENT}{$old}) ) { $hash->{DEF} =~ s/(^|\s+)$old((:\S+)?\s*)/$1$new$2/g; } readingsHistory_updateDevices($hash); } elsif( $dev->{NAME} eq "global" && $s =~ m/^DELETED ([^ ]*)$/) { my ($name) = ($1); if( defined($hash->{CONTENT}{$name}) ) { $hash->{DEF} =~ s/(^|\s+)$name((:\S+)?\s*)/ /g; $hash->{DEF} =~ s/^ //; $hash->{DEF} =~ s/ $//; } readingsHistory_updateDevices($hash); } elsif( $dev->{NAME} eq "global" && $s =~ m/^DEFINED ([^ ]*)$/) { readingsHistory_updateDevices($hash); } else { next if(AttrVal($name,"disable", undef)); next if (!$hash->{CONTENT}->{$dev->{NAME}}); if( $hash->{alwaysTrigger} ) { $hash->{mayBeVisible} = 1; } elsif( !defined($hash->{mayBeVisible}) ) { Log3 $name, 5, "$name: not on any display, don't trigger"; } else { if( defined($FW_visibleDeviceHash{$name}) ) { } else { Log3 $name, 5, "$name: no longer visible, don't trigger"; delete( $hash->{mayBeVisible} ); } } my ($reading,$value) = split(": ", $s, 2); $reading = "" if( !defined($reading) ); next if( $reading =~ m/^\./); $value = "" if( !defined($value) ); my $show_state = 1; if( $value eq "" ) { next if AttrVal( $name, "nostate", "0" ); $reading = "state"; $value = $s; } my $no_html = AttrVal( $name, "nohtml", "0" ); my $spacing = "  "; $spacing = " " if $no_html; my $mapping = AttrVal( $name, "mapping", ""); $mapping = eval $mapping if( $mapping =~ m/^{.*}$/ ); #$mapping = undef if( ref($mapping) ne 'HASH' ); my $timestampFormat = AttrVal( $name, "timestampFormat", undef); my $value_format = AttrVal( $name, "valueFormat", "" ); if( $value_format =~ m/^{.*}$/ ) { my $vf = eval $value_format; $value_format = $vf if( $vf ); } foreach my $device (@{$devices}) { my $item = 0; my $h = $defs{@{$device}[0]}; next if( !$h ); next if( $dev->{NAME} ne $h->{NAME} ); my $n = $h->{NAME}; my $regex = @{$device}[1]; my @list = (undef); @list = split(",",$regex) if( $regex ); foreach my $regex (@list) { next if( $reading eq "state" && !$show_state && (!defined($regex) || $regex ne "state") ); next if( defined($regex) && $reading !~ m/^$regex$/); my $msg = $s; my $value_format = readingsHistory_lookup2($value_format,$n,$reading,$value); next if( !defined($value_format) ); if( $value_format =~ m/%/ ) { $msg = sprintf( $value_format, $value ); } elsif( $value_format ) { $msg = $value_format; } my $t = time; my $tm = readingsHistory_makeTimestamp( $t, $timestampFormat ); my $show_links = !AttrVal( $name, "nolinks", "0" ); $show_links = 0 if($FW_hiddenroom{detail}); $show_links = 0 if(!defined($FW_ME)); $show_links = 0 if($no_html); my $a = AttrVal($n, "alias", $n); my $m = $a; if( $mapping ) { my $room = AttrVal($n, "room", ""); my $group = AttrVal($n, "group", ""); $m = readingsHistory_lookup($mapping,$n,$a,$reading,$value,$room,$group,$m); } if( $show_links ) { $msg = "$tm$spacing$m $msg"; } else { $msg = "$tm$spacing$m $msg"; } my $entry = [$t, $n, $s,$msg]; #my $entry = [$t, $n, "$reading: $value", $msg]; while( @{$hash->{fhem}{history}} >= AttrVal($name,"rows", 5 ) ) { pop @{$hash->{fhem}{history}}; } unshift( @{$hash->{fhem}{history}}, $entry ); DoTrigger( "$name", "history: $msg" ) if( $hash->{mayBeVisible} ); } } } } return undef; } sub readingsHistory_StatefileName() { my $statefile = $attr{global}{statefile}; $statefile = substr $statefile,0,rindex($statefile,'/')+1; #return $statefile ."readingsHistorys.save" if( $readingsHistory_hasJSON ); return $statefile ."readingsHistorys.dd.save" if( $readingsHistory_hasDataDumper ); } my $readingsHistory_LastSaveTime=""; sub readingsHistory_Save() { my $time_now = TimeNow(); return if( $time_now eq $readingsHistory_LastSaveTime); $readingsHistory_LastSaveTime = $time_now; return "No statefile specified" if(!$attr{global}{statefile}); my $statefile = readingsHistory_StatefileName(); my $hash; for my $d (keys %defs) { next if($defs{$d}{TYPE} ne "readingsHistory"); next if( !defined($defs{$d}{fhem}{history}) ); $hash->{$d} = $defs{$d}{fhem}{history}; } if(open(FH, ">$statefile")) { my $t = localtime; print FH "#$t\n"; if( $readingsHistory_hasJSON ) { print FH encode_json($hash) if( defined($hash) ); } elsif( $readingsHistory_hasDataDumper ) { my $dumper = Data::Dumper->new([]); $dumper->Terse(1); $dumper->Values([$hash]); print FH $dumper->Dump; } close(FH); } else { my $msg = "readingsHistory_Save: Cannot open $statefile: $!"; Log3 undef, 1, $msg; } return undef; } sub readingsHistory_Load($) { my ($hash) = @_; return "No statefile specified" if(!$attr{global}{statefile}); my $statefile = readingsHistory_StatefileName(); if(open(FH, "<$statefile")) { my $encoded; while (my $line = ) { chomp $line; next if($line =~ m/^#.*$/); $encoded .= $line; } close(FH); return if( !defined($encoded) ); my $decoded; if( $readingsHistory_hasJSON ) { $decoded = decode_json( $encoded ); } elsif( $readingsHistory_hasDataDumper ) { $decoded = eval $encoded; } $hash->{fhem}{history} = $decoded->{$hash->{NAME}} if( defined($decoded->{$hash->{NAME}}) ); } else { my $msg = "readingsHistory_Load: Cannot open $statefile: $!"; Log3 undef, 1, $msg; } return undef; } sub readingsHistory_Set($@) { my ($hash, $name, $cmd, $param, @a) = @_; my $list = "add clear:noArg"; my $no_html = AttrVal( $name, "nohtml", "0" ); my $spacing = "  "; $spacing = " " if $no_html; if( $cmd eq "clear" ) { $hash->{fhem}{history} = []; my $t = time; my $tm = readingsHistory_makeTimestamp( time, AttrVal( $name, "timestampFormat", undef) ); my $msg = "$tm$spacing--clear--"; my $entry = [$t,"", $cmd, $msg]; unshift( @{$hash->{fhem}{history}}, $entry ); DoTrigger( "$name", "clear: $msg" ) if( $hash->{mayBeVisible} ); return undef; } elsif ( $cmd eq "add" ) { my $t = time; my $tm = readingsHistory_makeTimestamp( time, AttrVal( $name, "timestampFormat", undef) ); my $msg = "$tm$spacing$param ". join( " ", @a ); my $entry = [$t,"", "$param ". join( " ", @a ),$msg]; while( @{$hash->{fhem}{history}} >= AttrVal($name,"rows", 5 ) ) { pop @{$hash->{fhem}{history}}; } unshift( @{$hash->{fhem}{history}}, $entry ); DoTrigger( "$name", "history: $msg" ) if( $hash->{mayBeVisible} ); return undef; } return "Unknown argument $cmd, choose one of $list"; } sub readingsHistory_Get($@) { my ($hash, @a) = @_; my $list = "history:noArg html:noArg"; my $name = $a[0]; return "$name: get needs at least one parameter" if(@a < 2); my $cmd= $a[1]; my $ret = ""; if( $cmd eq "html" ) { return readingsHistory_2html($hash); } elsif( $cmd eq "history" ) { my $rows = AttrVal($name,"rows", 5 ); $rows = 1 if( $rows < 1 ); my $timestampFormat = AttrVal( $name, "timestampFormat", undef); for (my $i = 0; $i < $rows; $i++) { my $line = $hash->{fhem}{history}[$i]; if( ref($line) eq 'ARRAY' ) { my $tm = readingsHistory_makeTimestamp( $line->[0], $timestampFormat ); $ret .= "$tm\t$line->[1]\t$line->[2]\t$line->[3]" if( $line ); } else { $ret .= $line if( $line ); } $ret .= "\n"; } return $ret; } return "Unknown argument $cmd, choose one of $list"; } sub readingsHistory_Attr($$$) { my ($cmd, $name, $attrName, $attrVal) = @_; my $orig = $attrVal; if( $attrName eq "alwaysTrigger" ) { my $hash = $defs{$name}; $attrVal = 1 if($attrVal); if( $cmd eq "set" ) { $hash->{alwaysTrigger} = $attrVal; delete( $hash->{helper}->{myDisplay} ) if( $hash->{alwaysTrigger} ); } else { delete $hash->{alwaysTrigger}; } } if( $cmd eq "set" ) { if( $orig ne $attrVal ) { $attr{$name}{$attrName} = $attrVal; return $attrName ." set to ". $attrVal; } } return; } 1; =pod =item helper =item summary display a history of readings from on or more devices =item summary_DE Anzeige der Historie von Readings eines oder mehrerer Geräte =begin html

readingsHistory

    Displays a history of readings from on or more devices.

    Define
      define <name> readingsHistory [<device>[:regex] [<device-2>[:regex-2] ... [<device-n>[:regex-n]]]]

      Notes:
      • <device> can be of the form INTERNAL=VALUE where INTERNAL is the name of an internal value and VALUE is a regex.
      • If regex is a comma separatet list it will be used as an enumeration of allowed readings.
      • if no device/reading argument is given only lines with 'set <device> add ...' are displayed.

      Examples:

    Set
    • add ...
      directly add text as new line to history.
    • clear
      clear the history.

    Get
    • history
      list history

    Attributes
    • alwaysTrigger
      1 -> alwaysTrigger update events. even if not visible.
    • disable
      1 -> disable notify processing and longpoll updates. Notice: this also disables rename and delete handling.
      2 -> also disable html table creation
      3 -> also disable html creation completely
    • noheading
      If set to 1 the readings table will have no heading.
    • nolinks
      Disables the html links from the heading and the reading names.
    • notime
      If set to 1 the reading timestamp is not displayed.
    • mapping
      Can be a simple string or a perl expression enclosed in {} that returns a hash that maps device names to the displayed name. The keys can be either the name of the reading or <device>.<reading>. %DEVICE, %ALIAS, %ROOM and %GROUP are replaced by the device name, device alias, room attribute and group attribute respectively. You can also prefix these keywords with $ instead of %.
    • style
      Specify an HTML style for the readings table, e.g.:
      attr history style style="font-size:20px"
    • timestampFormat
      POSIX strftime compatible string for used as the timestamp for each line.
    • valueFormat
      Specify an sprintf style format string used to display the reading values. If the format string is undef this reading will be skipped. Can be given as a string, a perl expression returning a hash or a perl expression returning a string, e.g.:
      attr history valueFormat %.1f °C
      attr history valueFormat { temperature => "%.1f °C", humidity => "%.1f %" }
      attr history valueFormat { ($READING eq 'temperature')?"%.1f °C":undef }
    • rows
      Number of history rows to show.
=end html =cut