############################################## # $Id$ # Written by Matthias Gehre, M.Gehre@gmx.de, 2012-2013 # package main; use strict; use warnings; use MIME::Base64; use MaxCommon; sub MAX_Define($$); sub MAX_Undef($$); sub MAX_Initialize($); sub MAX_Parse($$); sub MAX_Set($@); sub MAX_MD15Cmd($$$); sub MAX_DateTime2Internal($); sub MAX_DbLog_splitFn($); my @ctrl_modes = ( "auto", "manual", "temporary", "boost" ); my %boost_durations = (0 => 0, 1 => 5, 2 => 10, 3 => 15, 4 => 20, 5 => 25, 6 => 30, 7 => 60); my %boost_durationsInv = reverse %boost_durations; my %decalcDays = (0 => "Sat", 1 => "Sun", 2 => "Mon", 3 => "Tue", 4 => "Wed", 5 => "Thu", 6 => "Fri"); my @weekDays = ("Sat", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri"); my %decalcDaysInv = reverse %decalcDays; sub validWindowOpenDuration { return $_[0] =~ /^\d+$/ && $_[0] >= 0 && $_[0] <= 60; } sub validMeasurementOffset { return $_[0] =~ /^-?\d+(\.[05])?$/ && $_[0] >= -3.5 && $_[0] <= 3.5; } sub validBoostDuration { return $_[0] =~ /^\d+$/ && exists($boost_durationsInv{$_[0]}); } sub validValveposition { return $_[0] =~ /^\d+$/ && $_[0] >= 0 && $_[0] <= 100; } sub validDecalcification { my ($decalcDay, $decalcHour) = ($_[0] =~ /^(...) (\d{1,2}):00$/); return defined($decalcDay) && defined($decalcHour) && exists($decalcDaysInv{$decalcDay}) && 0 <= $decalcHour && $decalcHour < 24; } sub validWeekProfile { return length($_[0]) == 4*13*7; } sub validGroupid { return $_[0] =~ /^\d+$/ && $_[0] >= 0 && $_[0] <= 255; } my %readingDef = ( #min/max/default "maximumTemperature" => [ \&validTemperature, "on"], "minimumTemperature" => [ \&validTemperature, "off"], "comfortTemperature" => [ \&validTemperature, 21], "ecoTemperature" => [ \&validTemperature, 17], "windowOpenTemperature" => [ \&validTemperature, 12], "windowOpenDuration" => [ \&validWindowOpenDuration, 15], "measurementOffset" => [ \&validMeasurementOffset, 0], "boostDuration" => [ \&validBoostDuration, 5 ], "boostValveposition" => [ \&validValveposition, 80 ], "decalcification" => [ \&validDecalcification, "Sat 12:00" ], "maxValveSetting" => [ \&validValveposition, 100 ], "valveOffset" => [ \&validValveposition, 00 ], "groupid" => [ \&validGroupid, 0 ], ".weekProfile" => [ \&validWeekProfile, $defaultWeekProfile ], ); my %interfaces = ( "Cube" => undef, "HeatingThermostat" => "thermostat;battery;temperature", "HeatingThermostatPlus" => "thermostat;battery;temperature", "WallMountedThermostat" => "thermostat;temperature;battery", "ShutterContact" => "switch_active;battery", "PushButton" => "switch_passive;battery" ); sub MAX_Initialize($) { my ($hash) = @_; Log3 $hash, 5, "Calling MAX_Initialize"; $hash->{Match} = "^MAX"; $hash->{DefFn} = "MAX_Define"; $hash->{UndefFn} = "MAX_Undef"; $hash->{ParseFn} = "MAX_Parse"; $hash->{SetFn} = "MAX_Set"; $hash->{AttrList} = "IODev do_not_notify:1,0 ignore:0,1 dummy:0,1 " . "showtime:1,0 keepAuto:0,1 scanTemp:0,1 ". $readingFnAttributes; $hash->{DbLog_splitFn} = "MAX_DbLog_splitFn"; return undef; } ############################# sub MAX_Define($$) { my ($hash, $def) = @_; my @a = split("[ \t][ \t]*", $def); my $name = $hash->{NAME}; return "name \"$name\" is reserved for internal use" if($name eq "fakeWallThermostat" or $name eq "fakeShutterContact"); return "wrong syntax: define MAX type addr" if(int(@a)!=4 || $a[3] !~ m/^[A-F0-9]{6}$/i); my $type = $a[2]; my $addr = lc($a[3]); #all addr should be lowercase if(exists($modules{MAX}{defptr}{$addr})) { my $msg = "MAX_Define: Device with addr $addr is already defined"; Log3 $hash, 1, $msg; return $msg; } if($type eq "Cube") { my $msg = "MAX_Define: Device type 'Cube' is deprecated. All properties have been moved to the MAXLAN device."; Log3 $hash, 1, $msg; return $msg; } Log3 $hash, 5, "Max_define $type with addr $addr "; $hash->{type} = $type; $hash->{addr} = $addr; $modules{MAX}{defptr}{$addr} = $hash; $hash->{internals}{interfaces} = $interfaces{$type}; AssignIoPort($hash); return undef; } sub MAX_Undef($$) { my ($hash,$name) = @_; delete($modules{MAX}{defptr}{$hash->{addr}}); return undef; } sub MAX_DateTime2Internal($) { my($day, $month, $year, $hour, $min) = ($_[0] =~ /^(\d{2}).(\d{2})\.(\d{4}) (\d{2}):(\d{2})$/); return (($month&0xE) << 20) | ($day << 16) | (($month&1) << 15) | (($year-2000) << 8) | ($hour*2 + int($min/30)); } sub MAX_TypeToTypeId($) { foreach (keys %device_types) { return $_ if($_[0] eq $device_types{$_}); } Log 1, "MAX_TypeToTypeId: Invalid type $_[0]"; return 0; } sub MAX_CheckIODev($) { my $hash = shift; return !defined($hash->{IODev}) || ($hash->{IODev}{TYPE} ne "MAXLAN" && $hash->{IODev}{TYPE} ne "CUL_MAX"); } # Print number in format "0.0", pass "on" and "off" verbatim, convert 30.5 and 4.5 to "on" and "off" # Used for "desiredTemperature", "ecoTemperature" etc. but not "temperature" sub MAX_SerializeTemperature($) { if($_[0] eq "on" or $_[0] eq "off") { return $_[0]; } elsif($_[0] == 4.5) { return "off"; } elsif($_[0] == 30.5) { return "on"; } else { return sprintf("%2.1f",$_[0]); } } sub MAX_Validate(@) { my ($name,$val) = @_; return 1 if(!exists($readingDef{$name})); return $readingDef{$name}[0]->($val); } #Get a reading, validating it's current value (maybe forcing to the default if invalid) #"on" and "off" are converted to their numeric values sub MAX_ReadingsVal(@) { my ($hash,$name) = @_; my $val = ReadingsVal($hash->{NAME},$name,""); #$readingDef{$name} array is [validatingFunc, defaultValue] if(exists($readingDef{$name}) and !$readingDef{$name}[0]->($val)) { #Error: invalid value Log3 $hash, 2, "MAX: Invalid value $val for READING $name on $hash->{NAME}. Forcing to $readingDef{$name}[1]"; $val = $readingDef{$name}[1]; #Save default value to READINGS if(exists($hash->{".updateTimestamp"})) { readingsBulkUpdate($hash,$name,$val); } else { readingsSingleUpdate($hash,$name,$val,0); } } return MAX_ParseTemperature($val); } sub MAX_ParseWeekProfile(@) { my ($hash ) = @_; # Format of weekprofile: 16 bit integer (high byte first) for every control point, 13 control points for every day # each 16 bit integer value is parsed as # int time = (value & 0x1FF) * 5; # int hour = (time / 60) % 24; # int minute = time % 60; # int temperature = ((value >> 9) & 0x3F) / 2; my $curWeekProfile = MAX_ReadingsVal($hash, ".weekProfile"); #parse weekprofiles for each day for (my $i=0;$i<7;$i++) { my (@time_prof, @temp_prof); for(my $j=0;$j<13;$j++) { $time_prof[$j] = (hex(substr($curWeekProfile,($i*52)+ 4*$j,4))& 0x1FF) * 5; $temp_prof[$j] = (hex(substr($curWeekProfile,($i*52)+ 4*$j,4))>> 9 & 0x3F ) / 2; } my @hours; my @minutes; my $j; for($j=0;$j<13;$j++) { $hours[$j] = ($time_prof[$j] / 60 % 24); $minutes[$j] = ($time_prof[$j]%60); #if 00:00 reached, last point in profile was found last if(int($hours[$j])==0 && int($minutes[$j])==0 ); } my $time_prof_str = "00:00"; my $temp_prof_str; for (my $k=0;$k<=$j;$k++) { $time_prof_str .= sprintf("-%02d:%02d", $hours[$k], $minutes[$k]); $temp_prof_str .= sprintf("%2.1f °C",$temp_prof[$k]); if ($k < $j) { $time_prof_str .= " / " . sprintf("%02d:%02d", $hours[$k], $minutes[$k]); $temp_prof_str .= " / "; } } readingsBulkUpdate($hash, "weekprofile-$i-$decalcDays{$i}-time", $time_prof_str ); readingsBulkUpdate($hash, "weekprofile-$i-$decalcDays{$i}-temp", $temp_prof_str ); } } ############################# sub MAX_WakeUp($) { my $hash = $_[0]; #3F corresponds to 31 seconds wakeup (so its probably the lower 5 bits) return ($hash->{IODev}{Send})->($hash->{IODev},"WakeUp",$hash->{addr}, "3F", callbackParam => "31" ); } sub MAX_Set($@) { my ($hash, $devname, @a) = @_; my ($setting, @args) = @a; return "Invalid IODev" if(MAX_CheckIODev($hash)); if($setting eq "desiredTemperature" and $hash->{type} =~ /.*Thermostat.*/) { return "missing a value" if(@args == 0); my $temperature; my $until = undef; my $ctrlmode = 1; #0=auto, 1=manual; 2=temporary if($args[0] eq "auto") { #This enables the automatic/schedule mode where the thermostat follows the weekly program #There can be a temperature supplied, which will be kept until the next switch point of the weekly program if(@args == 2) { if($args[1] eq "eco") { $temperature = MAX_ReadingsVal($hash,"ecoTemperature"); } elsif($args[1] eq "comfort") { $temperature = MAX_ReadingsVal($hash,"comfortTemperature"); } else { $temperature = MAX_ParseTemperature($args[1]); } } elsif(@args == 1) { $temperature = 0; #use temperature from weekly program } else { return "Too many parameters: desiredTemperature auto []"; } $ctrlmode = 0; #auto } elsif($args[0] eq "boost") { return "Too many parameters: desiredTemperature boost" if(@args > 1); $temperature = 0; $ctrlmode = 3; #TODO: auto mode with temperature is also possible } else { if($args[0] eq "manual") { #User explicitly asked for manual mode $ctrlmode = 1; #manual, possibly overwriting keepAuto shift @args; return "Not enough parameters after 'desiredTemperature manual'" if(@args == 0); } elsif(AttrVal($hash->{NAME},"keepAuto","0") ne "0" && MAX_ReadingsVal($hash,"mode") eq "auto") { #User did not ask for any mode explicitly, but has keepAuto Log3 $hash, 5, "MAX_Set: staying in auto mode"; $ctrlmode = 0; #auto } if($args[0] eq "eco") { $temperature = MAX_ReadingsVal($hash,"ecoTemperature"); } elsif($args[0] eq "comfort") { $temperature = MAX_ReadingsVal($hash,"comfortTemperature"); } else { $temperature = MAX_ParseTemperature($args[0]); } if(@args > 1) { #@args == 3 and $args[1] == "until" return "Second parameter must be 'until'" if($args[1] ne "until"); return "Not enough parameters: desiredTemperature [manual] [until