#!/usr/bin/env perl use strict; use warnings; use Net::MQTT::Simple; use JSON::Parse; use CGI qw/:standard :cgi-lib/; use CGI::Carp qw(fatalsToBrowser); package Boiler { use IO::File; #0 Status => 0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0 #17 Relative modulation level => 0 #18 CH water pressure => 1.14453125 #25 Boiler water temperature => 53 #26 DHW temperature => 49 #28 Return water temperature => 48.375 #57 Max CH water setpoint => 80 #80 Supply inlet temperature => 21.25 #256 Status => 0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0 #257 Control setpoint => 7 #270 Maximum relative modulation level => 100 #272 Room setpoint => 18 #280 Room temperature => 26.7265625 #312 DHW setpoint => 60 my $ot_registers = { 0 => { format => ["flag16"], name => "Status", names => [ '', '', '', '', '', '', 'DHW enabled', 'CH enabled', '', 'diagnostic indication', '', '', 'Flame', 'DHW active', 'CH active', 'fault' ], warnings => [ undef, undef, undef, undef, undef, undef, undef, undef, undef, undef, undef, undef, undef, undef, undef, '1' ] }, 1 => { format => ["f8.8"], name => "Control setpoint", units => 'C', graphs => { '1' => '', '3' => '' } }, 2 => { format => [ "flag8", "u8" ], name => "Master configuration" }, 3 => { format => [ "flag8", "u8" ], name => "Slave configuration" }, 4 => { format => [ "u8", "u8" ], name => "Remote command" }, 5 => { format => [ "flag8", "u8" ], name => "Application-specific flags" }, 6 => { format => [ "flag8", "flag8" ], name => "Remote parameter flags" }, 7 => { format => ["f8.8"], name => "Cooling control signal" }, 8 => { format => ["f8.8"], name => "Control setpoint 2" }, 9 => { format => ["f8.8"], name => "Remote override room setpoint" }, 10 => { format => [ "u8", "nu" ], name => "Number of TSPs" }, 11 => { format => [ "u8", "u8" ], name => "TSP setting" }, 12 => { format => [ "u8", "nu" ], name => "Size of fault buffer" }, 13 => { format => [ "u8", "u8" ], name => "Fault buffer entry" }, 14 => { format => ["f8.8"], name => "Maximum relative modulation level", units => '%' }, 15 => { format => [ "u8", "u8" ], name => "Boiler capacity and modulation limits" }, 16 => { format => ["f8.8"], name => "Room setpoint", units => 'C', graphs => { '3' => '' } }, 17 => { format => ["f8.8"], name => "Relative modulation level", units => '%' }, 18 => { format => ["f8.8"], name => "CH water pressure", units => 'bar', warning => '0.9:2.6', critical => '0.8:2.8' }, 19 => { format => ["f8.8"], name => "DHW flow rate" }, 20 => { format => ["time"], name => "Day of week and time of day" }, 21 => { format => ["date"], name => "Date" }, 22 => { format => ["u16"], name => "Year" }, 23 => { format => ["f8.8"], name => "Room Setpoint CH2" }, 24 => { format => ["f8.8"], name => "Room temperature", units => 'C', graphs => { '3' => '' } }, 25 => { format => ["f8.8"], name => "Boiler water temperature", units => 'C', graphs => { '1' => '', '2' => '' } }, 26 => { format => ["f8.8"], name => "DHW temperature", units => 'C', graphs => { '2' => '' } }, 27 => { format => ["f8.8"], name => "Outside temperature" }, 28 => { format => ["f8.8"], name => "Return water temperature", units => 'C', graphs => { '1' => '' } }, 29 => { format => ["f8.8"], name => "Solar storage temperature" }, 30 => { format => ["f8.8"], name => "Solar collector temperature" }, 31 => { format => ["f8.8"], name => "Flow temperature CH2" }, 32 => { format => ["f8.8"], name => "DHW2 temperature" }, 33 => { format => ["s16"], name => "Exhaust temperature" }, 34 => { format => ["f8.8"], name => "Boiler heat exchanger temperature" }, 35 => { format => [ "u8", "u8" ], name => "Boiler fan speed and setpoint" }, 48 => { format => [ "s8", "s8" ], name => "DHW setpoint boundaries" }, 49 => { format => [ "s8", "s8" ], name => "Max CH setpoint boundaries" }, 50 => { format => [ "s8", "s8" ], name => "OTC heat curve ratio boundaries" }, 51 => { format => [ "s8", "s8" ], name => "Remote parameter 4 boundaries" }, 52 => { format => [ "s8", "s8" ], name => "Remote parameter 5 boundaries" }, 53 => { format => [ "s8", "s8" ], name => "Remote parameter 6 boundaries" }, 54 => { format => [ "s8", "s8" ], name => "Remote parameter 7 boundaries" }, 55 => { format => [ "s8", "s8" ], name => "Remote parameter 8 boundaries" }, 56 => { format => ["f8.8"], name => "DHW setpoint", units => 'C', graphs => { '2' => '' } }, 57 => { format => ["f8.8"], name => "Max CH water setpoint", units => 'C', graphs => { '1' => '' } }, 58 => { format => ["f8.8"], name => "OTC heat curve ratio" }, 59 => { format => ["f8.8"], name => "Remote parameter 4" }, 60 => { format => ["f8.8"], name => "Remote parameter 5" }, 61 => { format => ["f8.8"], name => "Remote parameter 6" }, 62 => { format => ["f8.8"], name => "Remote parameter 7" }, 63 => { format => ["f8.8"], name => "Remote parameter 8" }, 70 => { format => [ "flag8", "flag8" ], name => "Status V/H" }, 71 => { format => [ "nu", "u8" ], name => "Control setpoint V/H" }, 72 => { format => [ "flag8", "u8" ], name => "Fault flags/code V/H" }, 73 => { format => ["u16"], name => "OEM diagnostic code V/H" }, 74 => { format => [ "flag8", "u8" ], name => "Configuration/memberid V/H" }, 75 => { format => ["f8.8"], name => "OpenTherm version V/H" }, 76 => { format => [ "u8", "u8" ], name => "Product version V/H" }, 77 => { format => [ "nu", "u8" ], name => "Relative ventilation" }, 78 => { format => [ "u8", "u8" ], name => "Relative humidity exhaust air" }, 79 => { format => ["u16"], name => "CO2 level exhaust air" }, 80 => { format => ["f8.8"], name => "Supply inlet temperature", units => 'C', graphs => { '2' => '' } }, 81 => { format => ["f8.8"], name => "Supply outlet temperature" }, 82 => { format => ["f8.8"], name => "Exhaust inlet temperature" }, 83 => { format => ["f8.8"], name => "Exhaust outlet temperature" }, 84 => { format => ["u16"], name => "Exhaust fan speed" }, 85 => { format => ["u16"], name => "Inlet fan speed" }, 86 => { format => [ "flag8", "flag8" ], name => "Remote parameter settings V/H" }, 87 => { format => [ "u8", "nu" ], name => "Nominal ventilation value" }, 88 => { format => [ "u8", "nu" ], name => "Number of TSPs V/H" }, 89 => { format => [ "u8", "u8" ], name => "TSP setting V/H" }, 90 => { format => [ "u8", "nu" ], name => "Size of fault buffer V/H" }, 91 => { format => [ "u8", "u8" ], name => "Fault buffer entry V/H" }, 100 => { format => [ "nu", "flag8" ], name => "Remote override function" }, 101 => { format => [ "flag8", "flag8" ], name => "Solar storage mode and status" }, 102 => { format => [ "flag8", "u8" ], name => "Solar storage fault flags" }, 103 => { format => [ "flag8", "u8" ], name => "Solar storage config/memberid" }, 104 => { format => [ "u8", "u8" ], name => "Solar storage product version" }, 105 => { format => [ "u8", "nu" ], name => "Number of TSPs solar storage" }, 106 => { format => [ "u8", "u8" ], name => "TSP setting solar storage" }, 107 => { format => [ "u8", "u8" ], name => "Size of fault buffer solar storage" }, 108 => { format => [ "u8", "u8" ], name => "Fault buffer entry solar storage" }, 113 => { format => ["u16"], name => "Unsuccessful burner starts" }, 114 => { format => ["u16"], name => "Flame signal too low count" }, 115 => { format => ["u16"], name => "OEM diagnostic code" }, 116 => { format => ["u16"], name => "Burner starts" }, 117 => { format => ["u16"], name => "CH pump starts" }, 118 => { format => ["u16"], name => "DHW pump/valve starts" }, 119 => { format => ["u16"], name => "DHW burner starts" }, 120 => { format => ["u16"], name => "Burner operation hours" }, 121 => { format => ["u16"], name => "CH pump operation hours" }, 122 => { format => ["u16"], name => "DHW pump/valve operation hours" }, 123 => { format => ["u16"], name => "DHW burner operation hours" }, 124 => { format => ["f8.8"], name => "OpenTherm version Master" }, 125 => { format => ["f8.8"], name => "OpenTherm version Slave" }, 126 => { format => [ "u8", "u8" ], name => "Master product version" }, 127 => { format => [ "u8", "u8" ], name => "Slave product version" }, }; my $ot_types = { 0 => "read data", 1 => "write data", 2 => "invalidate data", 3 => "unknown 0x3", 4 => "read ack", 5 => "write ack", 6 => "data invalid", 7 => "unknown 0x7" }; sub fetch_file($) { my ($name) = @_; my $fh = new IO::File "<" . $name; return undef unless defined $fh; local $/; my $guts = $fh->getline; $fh->close; undef $fh; return $guts; } sub decode($$$) { my ( $dataref, $string, $offset ) = @_; my $b0 = hex( substr( $string, 0, 2 ) ); my $type = ( $b0 >> 4 ) & 7; my $id = hex( substr( $string, 2, 2 ) ); my $data = pack( 'H4', substr( $string, 4, 4 ) ); my $format = ["u16"]; my $ret = { id => $id, fake_id => $id + $offset, type => $type, type_str => $ot_types->{$type}, }; my $bits = undef; if ( exists $ot_registers->{$id} ) { $ret->{id_str} = $ot_registers->{$id}->{name}; $ret->{units} = $ot_registers->{$id}->{units}; $format = $ot_registers->{$id}->{format}; $bits = $ot_registers->{$id}->{names}; } $ret->{format} = join( ',', @$format ); $ret->{raw_hex} = substr( $string, 4, 4 ); $ret->{raw} = $data; $ret->{values} = []; for my $f (@$format) { if ( $f eq "u16" ) { push @{ $ret->{values} }, unpack( 'S>', $data ); $data = substr( $data, 2 ); } elsif ( $f eq 's16' ) { push @{ $ret->{values} }, unpack( 's>', $data ); $data = substr( $data, 2 ); } elsif ( $f eq 'u8' ) { push @{ $ret->{values} }, unpack( 'C', $data ); $data = substr( $data, 1 ); } elsif ( $f eq 's8' ) { push @{ $ret->{values} }, unpack( 'c', $data ); $data = substr( $data, 1 ); } elsif ( $f eq 'nu' ) { $data = substr( $data, 1 ); } elsif ( $f eq 'flag16' ) { push @{ $ret->{values} }, ( unpack( "(A)*", unpack( "B16", $data ) ) ); $data = substr( $data, 2 ); } elsif ( $f eq 'flag8' ) { push @{ $ret->{values} }, ( unpack( "(A)*", unpack( "B8", $data ) ) ); $data = substr( $data, 1 ); } elsif ( $f eq 'f8.8' ) { push @{ $ret->{values} }, unpack( 's>', $data ) / 256.0; } } $dataref->{ $id + $offset } = $ret; if ( defined $bits ) { my @vs = ( @{ $ret->{values} } ); my $bit = scalar( @{ $ret->{values} } ); for my $label (@$bits) { $bit--; my $v = shift(@vs); next unless length($label) > 0; my $key = sprintf( "%d.%d", $id + $offset, $bit ); $ret = {}; $ret->{id} = sprintf( "%d.%d", $id, $bit ); $ret->{fake_id} = $key; $ret->{type} = $type; $ret->{type_str} = $ot_types->{$type}; $ret->{id_str} = $label; $ret->{values} = [$v]; $dataref->{$key} = $ret; } } } sub get_units($) { my $id = shift; if ( $id =~ /^\d+$/ ) { return '' unless exists $ot_registers->{$id}; return '' unless exists $ot_registers->{$id}->{units}; return $ot_registers->{$id}->{units}; } elsif ( $id =~ /^(\d+)\.(\d+)$/ ) { my $base_id = $1; my $bit = $2; return '' unless exists $ot_registers->{$base_id}; return '' unless exists $ot_registers->{$base_id}->{unitss}; $bit = ( scalar( @{ $ot_registers->{$base_id}->{unitss} } ) - 1 ) - $bit; return $ot_registers->{$base_id}->{unitss}->[$bit]; } else { return ''; } } sub get_name($) { my $id = shift; if ( $id =~ /^\d+$/ ) { return "Unknown $id" unless exists $ot_registers->{$id}; return $ot_registers->{$id}->{name}; } elsif ( $id =~ /^(\d+)\.(\d+)$/ ) { my $base_id = $1; my $bit = $2; return "Unknown $id" unless exists $ot_registers->{$base_id}; return "Unknown $id" unless exists $ot_registers->{$base_id}->{names}; $bit = ( scalar( @{ $ot_registers->{$base_id}->{names} } ) - 1 ) - $bit; return $ot_registers->{$base_id}->{names}->[$bit]; } else { return undef; } } my $report = [ '0.0', '0.1', '0.2', '0.3', '0.6', '0.8', '0.9', '17', '18', '25', '26', '28', '57', '80', '257', '270', '272', '280', '312' ]; sub load($) { my $host = shift; my $path = "/var/run/boiler_" . $host; return undef unless -d $path; my $wanted = { map { my $q = $_; $q =~ s/\..*$//; $q => 1 } @$report }; my $data = {}; for my $t ( keys %$wanted ) { if ( $t < 0x100 ) { my $fn = sprintf( "%s/B4x%02X", $path, $t ); my $guts = fetch_file($fn); decode( $data, substr( $guts, 1 ), 0x0 ) if defined $guts; } else { my $fn = sprintf( "%s/T9x%02X", $path, $t - 0x100 ); my $guts = fetch_file($fn); decode( $data, substr( $guts, 1 ), 0x100 ) if defined $guts; } } return $data; } 1; } my $mqtt_host = "10.32.139.1"; my $mqtt_data = {}; my $outside_temp = ""; sub fmt($) { my $v = shift; return sprintf( "%.1f", $v ); } sub fmt2($) { my $v = shift; return sprintf( "%.2f", $v ); } sub mqtt_msg($$) { my ( $topic, $message ) = @_; $outside_temp = $message if $topic eq 'tele/weather/tempc'; return unless $topic =~ /^[^\/]+\/[^\/]+_radiator/; return unless $topic =~ /^[^\/]+\/[^\/]+_radiator/; return unless $topic =~ /^[^\/]+\/([^\/]+)\/(.+)$/; $mqtt_data->{$1}->{$2} = $message; } sub do_radiators() { my $mqtt = Net::MQTT::Simple->new($mqtt_host); $mqtt->subscribe( 'stat/+/+', \&mqtt_msg ); $mqtt->subscribe( 'tele/+/SENSOR', \&mqtt_msg ); $mqtt->subscribe( 'tele/weather/tempc', \&mqtt_msg ); my $then = time; $mqtt->tick(1) while ( time - $then ) < 3; $mqtt->disconnect(); for my $r ( keys(%$mqtt_data) ) { if ( exists $mqtt_data->{$r}->{SENSOR} ) { my $pj = JSON::Parse::parse_json( $mqtt_data->{$r}->{SENSOR} ); if ( exists $pj->{'SI7021'} ) { $mqtt_data->{$r}->{'TEMPERATURE'} = $pj->{'SI7021'}->{'Temperature'} if exists $pj->{'SI7021'}->{'Temperature'}; $mqtt_data->{$r}->{'HUMIDITY'} = $pj->{'SI7021'}->{'Humidity'} if exists $pj->{'SI7021'}->{'Humidity'}; } } if ( exists $mqtt_data->{$r}->{POWER} ) { if ( $mqtt_data->{$r}->{POWER} =~ /off/i ) { $mqtt_data->{$r}->{POWER} = 0; } else { $mqtt_data->{$r}->{POWER} = 1; } } if ( ( $mqtt_data->{$r}->{POWER} == 0 ) && ( $mqtt_data->{$r}->{OPEN} == 0 ) ) { $mqtt_data->{$r}->{state} = "closed"; $mqtt_data->{$r}->{state_colour} = "#c0c0ff"; } elsif (( $mqtt_data->{$r}->{POWER} == 1 ) && ( $mqtt_data->{$r}->{OPEN} == 0 ) ) { $mqtt_data->{$r}->{state} = "opening"; $mqtt_data->{$r}->{state_colour} = "#ffe0e0"; } elsif (( $mqtt_data->{$r}->{POWER} == 1 ) && ( $mqtt_data->{$r}->{OPEN} == 1 ) ) { $mqtt_data->{$r}->{state} = "open"; $mqtt_data->{$r}->{state_colour} = "#ffc0c0"; } elsif (( $mqtt_data->{$r}->{POWER} == 0 ) && ( $mqtt_data->{$r}->{OPEN} == 1 ) ) { $mqtt_data->{$r}->{state} = "closing"; $mqtt_data->{$r}->{state_colour} = "#e0e0ff"; } else { $mqtt_data->{$r}->{state} = "unknown"; $mqtt_data->{$r}->{state_colour} = "#ffffff"; } if ( $mqtt_data->{$r}->{TEMPERATURE} < $mqtt_data->{$r}->{var1} ) { $mqtt_data->{$r}->{temp_colour} = "#c0c0ff"; } elsif ( $mqtt_data->{$r}->{TEMPERATURE} > $mqtt_data->{$r}->{var2} ) { $mqtt_data->{$r}->{temp_colour} = "#ffc0c0"; } else { $mqtt_data->{$r}->{temp_colour} = "#ffffff"; } for my $t (qw(var1 var2 TEMPERATURE HUMIDITY DELTA)) { next unless exists $mqtt_data->{$r}->{$t}; $mqtt_data->{$r}->{$t} = fmt( $mqtt_data->{$r}->{$t} ); } } print "\n"; print "\n"; for my $r ( sort( keys(%$mqtt_data) ) ) { my $rd = $mqtt_data->{$r}; print ""; print ""; print ""; print ""; print ""; print ""; print ""; print ""; if ( exists $rd->{failed_reads} and ( $rd->{failed_reads} > 5 ) ) { print ""; } else { print ""; } for my $t (qw(10 15 18 19 20 21 22 23 24 25)) { my $s = ""; $s = "color: red" if $t == $rd->{var1}; print ""; } print "\n"; } print "
RadiatorsLowTempHighHumidDeltaValveSensorSet target
", $r, "", $rd->{var1}, "", $rd->{TEMPERATURE}, "", $rd->{var2}, ""; print $rd->{HUMIDITY} if exists $rd->{HUMIDITY}; print "", $rd->{DELTA}, "", $rd->{state}, "FailedOk"; print submit( -name => $r, -value => $t, -style => $s ); print "
\n"; } sub do_boiler($) { my $outside_temp = shift; my $boiler = Boiler::load("boiler-monster.prometheus.james.local"); return unless defined $boiler; my $url = 'http://munin.ourano.james.local/prometheus.james.local/boilermonster.prometheus.james.local/'; print "\n"; print "\n"; my $pump = $boiler->{0.1}->{values}->[0]; my $pump_colour = '#c0c0ff'; $pump_colour = '#ffc0c0' if $pump == 1; print "\n"; # my $dhw = $boiler->{0.2}->{values}->[0]; # my $dhw_colour = '#c0c0ff'; # $dhw_colour = '#ffc0c0' if $dhw == 1; # print "\n"; print "\n"; print "\n"; print "\n"; print "\n"; print "\n"; print ""; print "\n"; print "
Boiler
CH Pump running:", $pump, "
DHW running:", $dhw, # "
Target water temp:", fmt( $boiler->{257}->{values}->[0] ), "
Current water temp:", fmt( $boiler->{25}->{values}->[0] ), "
Return water temp:", fmt( $boiler->{28}->{values}->[0] ), "
Modulation level:", fmt( $boiler->{17}->{values}->[0] ), "
CH Pressure:", fmt2( $boiler->{18}->{values}->[0] ), "
Fault:", $boiler->{0.0}->{values}->[0], "
Outside temp:", sprintf( "%.1f", $outside_temp ), "
\n"; } sub make_preset($$) { my ($here,$map)=@_; my @wot=(); for my $k (keys %$map) { push @wot,$k."=".$map->{$k} ; } return $here."?".join('&',@wot); } my $css = <<'EOF'; tr:nth-child(odd) td { background-color: #ffffff } tr:nth-child(even) td { background-color: #c0ffc0 } EOF my $all_params = Vars(); my $here = $ENV{SCRIPT_NAME}; my $off=10; my $all_off={ '2fl_main_radiator' => $off, '2fl_stair_radiator' => $off, 'bathroom_radiator' => $off, 'bedroom_radiator' => $off, 'dd_radiator1' => $off, 'dd_radiator2' => $off, 'dd_radiator3' => $off, 'hall_radiator' => $off, 'kitchen_radiator' => $off, 'kstudy_radiator' => $off, 'laundry_radiator' => $off, 'spare_bedroom_radiator' => $off }; my $attic_bathroom_ground_floor={ '2fl_main_radiator' => 20, '2fl_stair_radiator' =>20, 'bathroom_radiator' => 19, 'dd_radiator1' => 18, 'dd_radiator2' => 18, 'dd_radiator3' => 18, 'hall_radiator' => 18, 'kitchen_radiator' => 18, 'laundry_radiator' => 18 }; print header( -type => 'text/html', -charset => 'utf-8', -refresh => '30; url=' . $here ); print start_html( -title => "heating", -head => "" ); print start_form(); do_radiators(); print end_form(); print "
Refresh   Heat Attic, Bathroom and Ground floor   all off
\n"; if ( scalar( keys %$all_params ) > 0 ) { print "

Updates

\n"; for my $param ( keys %$all_params ) { my $mqtt = Net::MQTT::Simple->new($mqtt_host); my $slop = 1; $slop = 2 if $param =~ /bathroom/; $mqtt->publish( "cmnd/" . $param . "/var1", $all_params->{$param} ); $mqtt->publish( "cmnd/" . $param . "/var2", $all_params->{$param} + $slop ); $mqtt->tick(1); $mqtt->disconnect(); print "Set $param to ", fmt( $all_params->{$param} ), "-", fmt( $all_params->{$param} + $slop ), "
"; } print ""; } print ""; do_boiler($outside_temp); print end_html();