#!/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
"Radiators | Low | Temp | High | Humid | Delta | Valve | Sensor | Set target |
\n";
for my $r ( sort( keys(%$mqtt_data) ) ) {
my $rd = $mqtt_data->{$r};
print "";
print
"", $r, " | ";
print "", $rd->{var1}, " | ";
print "",
$rd->{TEMPERATURE}, " | ";
print "", $rd->{var2}, " | ";
print "";
print $rd->{HUMIDITY} if exists $rd->{HUMIDITY};
print " | ";
print "", $rd->{DELTA}, " | ";
print "",
$rd->{state}, " | ";
if ( exists $rd->{failed_reads} and ( $rd->{failed_reads} > 5 ) ) {
print "Failed | ";
}
else {
print "Ok | ";
}
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 submit( -name => $r, -value => $t, -style => $s );
print " | ";
}
print "
\n";
}
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 "Boiler |
\n";
my $pump = $boiler->{0.1}->{values}->[0];
my $pump_colour = '#c0c0ff';
$pump_colour = '#ffc0c0' if $pump == 1;
print "CH Pump running: | ", $pump,
" |
\n";
my $dhw = $boiler->{0.2}->{values}->[0];
my $dhw_colour = '#c0c0ff';
$dhw_colour = '#ffc0c0' if $dhw == 1;
print "DHW running: | ", $dhw,
" |
\n";
print "Target water temp: | ",
fmt( $boiler->{257}->{values}->[0] ),
" |
\n";
print "Current water temp: | ",
fmt( $boiler->{25}->{values}->[0] ),
" |
\n";
print "Return water temp: | ",
fmt( $boiler->{28}->{values}->[0] ),
" |
\n";
print "Modulation level: | ",
fmt( $boiler->{17}->{values}->[0] ),
" |
\n";
print "CH Pressure: | ",
fmt2( $boiler->{18}->{values}->[0] ),
" |
\n";
print "Fault: | ",
$boiler->{0.0}->{values}->[0],
" |
";
print
"Outside temp: | ",
sprintf( "%.1f", $outside_temp ), " |
\n";
print "
\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();