#!/usr/bin/perl # -*- perl -*- =head1 NAME =encoding utf8 currentcost - Munin plugin to monitor a CurrentCost energy monitor =head1 APPLICABLE SYSTEMS Any system connected to a CurrentCost monitor. These can be purchased from L. =head1 CONFIGURATION This plugin requires the following Perl modules. Either fetch them from CPAN or your distribution. =over =item * XML::Simple =item * Device::SerialPort =item * YAML =item * Time::Local =back This configuration section shows the defaults of the plugin: [currentcost] env.device /dev/ttyUSB0 env.baud 2400 env.tick 6 env.currency £ env.rate1 13.9 env.rate1qty 900 env.rate2 8.2 env.nightrate 0 env.nighthours 23:30-06:30 env.standingcharge 0.0 The configuration can be broken down into the following subsections: =head2 DEVICE =over =item env.device Specfies the device node where the CurrentCost monitor can be found. You may find it useful to use a udev rule to symlink this somewhere permanent. =item env.baud Specifies the baud rate to use. CurrentCost devices may speak at 2400, 9600 or 57600 baud, depending on their age. =item env.tick How long, in seconds, to consider data valid for. CurrentCost monitors typically put out data every 6 or 10 seconds. If Munin does a data run less than C seconds after a config run, there's no need to wait for more data. =back =head2 COSTS =over =item env.currency The currency symbol to use on the cost graph. CurrentCost typically uses "E" or "E", but you may find "$" more to your taste. =item env.rate1 The primary rate in hundredths of a C per kWh. (i.e. pence/cents per kWh) =item env.rate1qty How many kWh per month are charged at C. Some tariffs charge one rate for the first so many units and then another rate for the remainder. If you are charged a flat rate per unit, set this to 0. =item env.rate2 The secondary rate in hundredths of a C per kWh. (i.e. pence/cents per kWh) =item env.nightrate The night rate in hundredths of a C per kWh. Some tariffs (such as Economy 7) charge differently during the night and typically require a meter capable of reading two rates. If you do not have such a tariff, set this to 0. =item env.nighthours The time period for which C applies. This should be of the form C and should span midnight. =item env.standingcharge The standing charge in hundreths of a C per month. If you do not have a standing charge, set this to 0. =back =head1 MAGIC MARKERS #%# family=manual contrib #%# capabilities=multigraph =head1 AUTHOR Paul Saunders L =cut use strict; use warnings; use utf8; use Munin::Plugin; use Data::Dump qw{pp}; need_multigraph(); my $device_node = $ENV{device} || "/dev/ttyUSB0"; my $baud_rate = $ENV{baud} || "2400"; # or 9600 or 57600 my $tick_rate = $ENV{tick} || "6"; # Tick_Rate is how long to consider data valid for (in seconds) # Costs my $currency = $ENV{currency} || "£"; # £ or € my $rate1 = $ENV{rate1} || "13.9"; # in pence/cents my $rate1qty = $ENV{rate1qty} || "900"; # in kWh, 0 to use fixed rate2 my $rate2 = $ENV{rate2} || "8.2"; # in pence/cents my $nightrate = $ENV{nightrate} || "0"; # 0 = disabled my $nighthours = $ENV{nighthours} || "23:30-06:30"; my $standingcharge = $ENV{standingcharge} || "0.0"; # pence/cents per month my $ret; if ( !eval "require XML::Simple;" ) { $ret .= "Could not load XML::Simple; "; } if ( !eval "require Device::SerialPort;" ) { $ret .= "Could not load Device::SerialPort; "; } if ( !eval "require YAML;" ) { $ret .= "Could not load YAML; "; } if ( !eval "require Time::Local;" ) { $ret .= "Could not load Time::Local; "; } if ( defined $ARGV[0] and $ARGV[0] eq 'autoconf' ) { # Shouldn't autoconfigure as there's no reliable way to detect # serial devices. print "no\n"; exit 0; } my @lastread; sub save_data { my @savedata = @_; # Do we need to save this data? if ( !@lastread or time >= Time::Local::timelocal(@lastread) + ($tick_rate) ) { @lastread = localtime(time); my @save_vector; push @save_vector, YAML::Dump(@lastread); foreach ( split /\n/, YAML::Dump(@savedata) ) { push @save_vector, $_; } save_state(@save_vector); } } sub load_data { # Bring the data back in my @save_vector = restore_state(); # Read the timestamp, Do we need to refresh the data? my @lastread = YAML::Load( shift @save_vector ); my $yamlstr = ''; foreach (@save_vector) { $yamlstr .= "$_\n"; } my @dataarray = YAML::Load($yamlstr); if ( !@lastread or time >= ( Time::Local::timelocal(@lastread) + ($tick_rate) ) ) { # Data is stale eval { # Fetch the XML my @temparray = collect_cc128_data( $device_node, $baud_rate ); # Read the time so we know whether to reset daily/monthly/yearly counters my @now = localtime(time); my %is_new; $is_new{daily} = ( $now[3] != $lastread[3] ) ? 1 : 0; $is_new{monthly} = ( $now[4] != $lastread[4] ) ? 1 : 0; $is_new{yearly} = ( $now[5] != $lastread[5] ) ? 1 : 0; for ( my $i = 0 ; $i <= $#temparray ; $i++ ) { my $datum = $temparray[$i]; for ( my $j = 0 ; $j <= $#{ $datum->{data} } ; $j++ ) { for my $period (qw(daily monthly yearly)) { $period = "n$period" if is_night_rate(); if ( defined( $dataarray[$i]->{data}[$j]->{$period} ) ) { # There's old data. Consider incrementing it if ( $is_new{$period} ) { # Start of a new period, reset the counter $temparray[$i]->{data}[$j]->{$period} = 0; } else { $temparray[$i]->{data}[$j]->{$period} = $dataarray[$i]->{data}[$j]->{$period} + $temparray[$i]->{data}[$j]->{value} / 12; } } else { # No old data. Set it. $temparray[$i]->{data}[$j]->{$period} = $temparray[$i]->{data}[$j]->{value} / 12; } } } } # If the above threw an error, we won't overwrite the old data @dataarray = @temparray; 1; } or do { print $@; } } return @dataarray; } sub is_night_rate { # Determine if we're on night rate return 0 if not $nightrate; my ( $nightstart, $nightstop ) = split /-/, $nighthours; my ( $start_h, $start_m ) = split /:/, $nightstart; my ( $stop_h, $stop_m ) = split /:/, $nightstop; my $start_time = $start_m + ( $start_h * 60 ); my $stop_time = $stop_m + ( $stop_h * 60 ); my @now = localtime(time); my $now_time = $now[1] + ( $now[2] * 60 ); if ( $now_time >= $start_time or $now_time <= $stop_time ) { return 1; } else { return 0; } } =head1 EXAMPLE INPUT The device will periodically output a string of XML. The string will be all on one line, they are expanded here for clarity. =head2 Classic format Note: I can't find an official spec for this format so, for now, this plugin doesn't support it. 00014
14 07 07
CC02 03280 1 0.07 00080 00000 00000 28.8 000.0 000.1 000.1 000.0 000.0 000.0 000.0 000.1 000.1 000.1 000.1 000.0 000.0 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000000 0000000 0000000 0000000
=head2 CC128 format For full definition, see L start of message CC128-v0.11 source and software version 00089 days since birth, ie days run 24 hour clock time as displayed 18.7 temperature as displayed 1 Appliance Number as displayed 01234 radio ID received from the sensor 1 sensor Type, "1" = electricity sensor channel 00345 data and units 02151 00000 end of message =cut sub collect_cc128_data { # Read data from the serial port until we see a repeated sensor my ( $port, $baud ) = @_; my $tty = Device::SerialPort->new($port) || die "Can't open $port: $!"; $tty->baudrate($baud) || die "Can't set serial baudrate"; $tty->parity("none") || die "Can't set serial parity"; $tty->databits(8) || die "Can't set serial databits"; $tty->handshake("none") || die "Can't set serial handshake"; $tty->write_settings || die "Can't set serial parameters"; open( my $ttydev, "<", $port ) || die "Can't open $port: $?"; my @cc_data_arr; my %seen_sensors; while (<$ttydev>) { if (m{(.*)}) { my $xmlref = XML::Simple::XMLin( $1, KeepRoot => 1 ); my $sensor = $xmlref->{msg}->{sensor}; next unless defined $sensor; if ( defined $seen_sensors{$sensor} ) { # We've seen this sensor before. # Time to stop reading data last; } $seen_sensors{$sensor} = 1; my $temphash; $temphash->{sensor} = $sensor; $temphash->{temp} = $xmlref->{msg}->{tmpr}; my @temparr; foreach my $key ( keys %{ $xmlref->{msg} } ) { if ( $key =~ /ch(\d+)/ ) { my $channel = $1; my $unit = ( keys %{ $xmlref->{msg}->{"ch$channel"} } )[0]; my $val = $xmlref->{msg}->{"ch$channel"}->{$unit}; push @temparr, { "channel" => $channel, "unit" => $unit, "value" => $val }; } } $temphash->{data} = \@temparr; push @cc_data_arr, $temphash; } } close($ttydev); return @cc_data_arr; } my @cc_data = load_data(); if ( defined $ARGV[0] and $ARGV[0] eq 'config' ) { if ($ret) { print $ret; exit 1; } for my $datum (@cc_data) { my $unit = ''; foreach my $key ( @{ $datum->{data} } ) { if ( $unit eq '' ) { $unit = $key->{unit}; } elsif ( $unit != $key->{unit} ) { print STDERR "Conflicting units ($unit and $key->{unit}) on sensor $datum->{sensor}"; } } if ( $datum->{sensor} == 0 ) { # Output the Root graph (being sensor 0) print <{data} } ) > 1 ) { print "graph_total Total\n"; } foreach my $channel ( @{ $datum->{data} } ) { my $fieldname = "ch" . $channel->{channel}; print <{channel} $fieldname.type GAUGE $fieldname.min 0 $fieldname.draw AREA ${fieldname}_t.label Channel $channel->{channel} Trend ${fieldname}_t.type GAUGE ${fieldname}_t.min 0 ${fieldname}_t.draw LINE2 EOF if ($nightrate) { print <{channel} Night ${fieldname}_n.type GAUGE ${fieldname}_n.min 0 ${fieldname}_t.cdef ${fieldname}_n,UN,${fieldname},${fieldname},IF,3600,TRENDNAN EOF } else { print "${fieldname}_t.cdef ${fieldname},3600,TRENDNAN\n"; } } # Output the Root cumulative graph print <{data} } ) { my $fieldname = "ch" . $channel->{channel}; print "${fieldname}=currentcost.$fieldname ${fieldname}_d "; $confstr .= <{channel} ${fieldname}.type GAUGE ${fieldname}.min 0 ${fieldname}.cdef PREV,${fieldname},12,/,ADDNAN ${fieldname}_d.label Channel $channel->{channel} Daily ${fieldname}_d.type GAUGE ${fieldname}_d.min 0 EOF } print "\n$confstr"; print <{data} } ) > 1 ) { print "graph_total Total\n"; } foreach my $channel ( @{ $datum->{data} } ) { my $fieldname = "ch" . $channel->{channel}; print <{channel} $fieldname.type GAUGE $fieldname.min 0 EOF } } else { # Output a subordinate graph (being an appliance) my $sensor = $datum->{sensor}; print <{data} } ) > 1 ) { print "graph_total Total\n"; } foreach my $channel ( @{ $datum->{data} } ) { my $fieldname = "ch" . $channel->{channel}; print <{channel} $fieldname.type GAUGE $fieldname.min 0 EOF if ($nightrate) { print <{channel} Night ${fieldname}_n.type GAUGE ${fieldname}_n.min 0 EOF } } # Output the subordinate cumulative graph print <{data} } ) { my $fieldname = "ch" . $channel->{channel}; print "$fieldname=currentcost.$fieldname "; $confstr .= <{channel} $fieldname.type GAUGE $fieldname.min 0 $fieldname.cdef PREV,$fieldname,12,/,ADDNAN EOF } print "\n$confstr"; # Output the subordinate Cost graph print <{data} } ) > 1 ) { print "graph_total Total\n"; } foreach my $channel ( @{ $datum->{data} } ) { my $fieldname = "ch" . $channel->{channel}; print <{channel} $fieldname.type GAUGE $fieldname.min 0 EOF } } } save_data(@cc_data); exit 0; } # Output the value data for my $datum (@cc_data) { if ( $datum->{sensor} == 0 ) { # Output the Root graph (being sensor 0) print "multigraph currentcost\n"; } else { my $sensor = $datum->{sensor}; print "multigraph currentcost.appliance$sensor\n"; } foreach my $channel ( @{ $datum->{data} } ) { my $fieldname = "ch" . $channel->{channel}; if ( is_night_rate() ) { print "${fieldname}_n.value " . $channel->{value} . "\n"; print "${fieldname}.value 0\n"; } else { print "${fieldname}.value " . $channel->{value} . "\n"; print "${fieldname}_n.value 0\n"; } } if ( $datum->{sensor} == 0 ) { # Output the Root graph (being sensor 0) print "multigraph currentcost_cumulative\n"; } else { my $sensor = $datum->{sensor}; print "multigraph currentcost_cumulative.appliance$sensor\n"; } foreach my $channel ( @{ $datum->{data} } ) { my $fieldname = "ch" . $channel->{channel}; my $value = $channel->{daily}; $value += $channel->{ndaily} if defined $channel->{ndaily}; print "${fieldname}_d.value $value\n"; } my @now = localtime(time); if ( $now[3] == 1 and $now[2] == 0 and $now[1] <= 5 ) { # First reading of month, reset. print "fudge.value 0\n"; } else { print "fudge.value 1\n"; } if ( $datum->{sensor} == 0 ) { # Output the Root Cost graph (being sensor 0) print "multigraph currentcost_cost\n"; } else { my $sensor = $datum->{sensor}; print "multigraph currentcost_cost.appliance$sensor\n"; } foreach my $channel ( @{ $datum->{data} } ) { my $fieldname = "ch" . $channel->{channel}; my $kWh = $channel->{monthly} / 1000; my $nightkWh = $channel->{nmonthly} / 1000 if defined $channel->{nmonthly}; my $cost = $standingcharge; if ( $nightrate and defined $nightkWh ) { $cost = $nightkWh * $nightrate; } if ( $kWh <= $rate1qty ) { $cost += $kWh * $rate1; } else { $cost += ( ( $kWh - $rate1qty ) * $rate2 ) + ( $rate1qty * $rate1 ); } $cost = $cost / 100; # Convert pence/cents into pounds/euros print "$fieldname.value $cost\n"; my $extinfo = sprintf( "Usage is %.3f kWh.", $kWh ); $extinfo .= sprintf( " Night usage is %.3f kWh.", $nightkWh ) if defined $nightkWh; print "$fieldname.extinfo $extinfo\n"; } } save_data(@cc_data); exit 0;