#!/usr/bin/perl # # Copyright 2001-2005 Daniel Klein, dan@klein.com. All Rights Reserved. # You may make unlimited copies of this code provided: # 1) That this and other entire copyright notices are retained. # 2) That the code or any derivative code is not sold for any reason. If # you want to sell it or a derivative work, email me and we'll work out # a royalty fee structure. # 3) You let me know that you're using it (I'm just curious about how # it is being used!) # # COMPLETE DOCUMENTATION IS AT THE END OF THIS FILE, IN POD FORMAT # use constant VERSION => '$Id: thermd,v 1.77 2006/01/06 00:28:05 www Exp www $'; use 5.004; use strict; use POSIX ':termios_h'; use Fcntl ':flock'; use FileHandle; use Sys::Hostname; #+ # You will want to change these eleven variables for your particular system #+ my $therm = "/dev/cuaa0"; # The serial line that the QK145 is on my $sendmail = "/usr/sbin/sendmail"; # Where the binary for sendmail is my $daemon = 'root@' . hostname(); # Who alarm messages come from my $config_file = "/etc/thermd.conf"; my $pidfile = "/var/run/thermd.pid"; # At my site, I run an asymmetric system, which means that the # machine that reads from the QK145 is on a different machine # than the web server. So I provide two variables - one to # indicate where to write the log data (which is used by the # daemon) and one to indicate where to read the log data (which # is used by the CGI script). If you have the daemon and the # CGI script on the same machine, you probably want to have the # two variables be the same - if you have my setup, you might # need to have different values (the variable $ENV{SCRIPT_FILENAME} # is the location of the CGI script, returned by the Apache server). # NOTE that the -d runtime argument can override either value. my $log_write = "/var/log/thermd"; #my $log_read = $ENV{SCRIPT_FILENAME} || $log_write; my $log_read = $log_write; # The $rss_dir is where the XML files are actually written to, while # the $rss_url is the URL that is listed *in* the XML files. Note # that if $rss_dir is the empty string (i.e., ""), we won't generate # an RSS feed. my $rss_dir = "/var/www/KLEIN/thermd"; my $rss_url = "http://www.klein.com/thermd/"; my $rss_webmaster = 'dan@klein.com'; # If the timezone string is two letters long, it will be printed # as-is. If it is three letters long, the script will replace the # middle letter with 'D' if daylight savings time is in effect. # I would have loved to not use this variable, but there is no neat # cross platform way of finding out your current timezone (that I # know of - please tell me if there is). my $timezone = "EST"; #- # End configuration variables #- use constant DESC => 0; use constant ADJUST => 1; use constant MIN => 2; use constant MAX => 3; use constant EMAIL => 4; sub usage { warn @_ if @_; die <<"==END=="; Usage - can run either as a logging daemon, or as a reporting script, or as a CGI script Daemon: $0 [-d logdir] [-r rssdir] [-D [-v|-V]] -d logdir Where logs get stored (default = /var/log/thermd) -D Don't run as daemon (i.e., don't fork to background) -r rssdir Where RSS-2.0 feed gets stored (default is in config variable \$rss_dir in the source code) -v Also print 10 minute output to STDOUT -V Also print all output to STDOUT Report: $0 [-d logdir] [{-l | -L}] [-m hours] [-p type [-N] [-M]] [-e time] [-s line] -c type Plot CSV results to STDOUT (usually with -p or -l) -d logdir Where logs get stored (default = /var/log/thermd) -l Complete listing for lines (see -s) -N Show current temp when plotting (only valid with -p) -e time End time (only meaningful with -p or -l, missing -e means graph ends at "now") -s line{,line} Show only line N (only meaningful with -p), 0 == all -L List last 30 measurements (see -s) -m hours List min/max temps in text form -M Show min/max when plotting (only meaningful with -p) -p type Plot results to STDOUT as a PNG file (type can be day, week, month, year, yhl [year hi-lo]) -S {C|F} Scale plot in celsius or fahrenheit (override default) -t Plot TSV results to STDOUT (usually with -p or -l) -u When printing time, print Unix time(2), not human time ==END== } use vars qw(@conf $opt_c $opt_D $opt_d $opt_e $opt_f $opt_L $opt_l $opt_M $opt_m $opt_N $opt_p $opt_r $opt_s $opt_t $opt_U $opt_u $opt_V $opt_v $hi_lo $maxline @fd @current); read_conf(); my ($units_read, $units_plot, $loc_str, $loc_coord, $loc_href) = @{ $conf[0] }; use constant RECLEN => 19; # e.g. "1000056600\t0085.27\n" use constant INCR => 600; # 10 minute resolution use constant DAY => 86400; use constant WEEK => 86400*7; use constant MONTH => 86400*30; use constant YEAR => 86400*365; use constant NOW => time; use constant WIDTH => 750; # Width of graph use constant HEIGHT => 300; # Height of graph my @mon = qw(Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec); my @day = qw(Sun Mon Tue Wed Thu Fri Sat); ############################################################################## # Initialization: If we are called as a CGI script, load CGI.pm and # either output a form or output a graph. If we are called from the # shell, the act on the command-line switches. NOTE: When we output # a graph from a CGI script, we output a MIME header and then fake # things up to look like we were called from the shell. ############################################################################## if ($ENV{REQUEST_METHOD}) { eval { require CGI; import CGI ':standard', ':netscape'; }; import_names(); $opt_d = $log_read; $opt_U = $Q::units || $units_plot; if ($Q::plot || $Q::type =~ /[ct]sv[ru]/) { if ($Q::type =~ /([ct])sv([ru])/) { print header(-content_type => "text/plain", -expires => "+10m"); $opt_c = $1 eq 'c'; $opt_t = $1 eq 't'; $opt_u = $2 eq 'u'; } else { # Default is a graph print header(-content_type => "image/png", -expires => "+10m"); } $opt_e = $Q::endtime || NOW; $opt_p = $Q::zoom; $opt_s = join(",", sort {$a <=> $b} @Q::show) || $Q::show; $opt_N = ! $Q::hide_current; $opt_M = ! $Q::hide_minmax; # # The image/CSV/TSV is generated later, because we pretend that we're # actually being called from command line and graph to STDOUT... # # We DO NOT EXIT, but fall through...to the post-options code } elsif ($Q::pleasewait) { # # The Please Wait banner is generated in-line. Note that the # image is a fixed size, which is fine since it will be scaled # by the client browser (whereas the graph size is a configurable # option above). # print header(-content_type => "image/png", -expires => "now"); require GD; my $im = new GD::Image(130,60); my $white = $im->colorAllocate(255,255,255); my $blue = $im->colorAllocate(0,0,255); $im->transparent($white); $im->string(GD::Font->Large,0,5,"Generating Graph",$blue); $im->string(GD::Font->Large,0,35,"Please Wait...",$blue); print $im->png; exit; } elsif ($Q::docs) { print header(), `pod2html $ENV{SCRIPT_FILENAME}`; exit; } else { local $, = " "; my ($oldscale, $newscale, $zero); my $me = url(); my %show_labels = ( # a hash for checkbox_group() 1 => $conf[1]->[DESC], 2 => $conf[2]->[DESC], 3 => $conf[3]->[DESC], 4 => $conf[4]->[DESC]); my %type_labels = ( # a hash for radio_group() graph => "Graphical", csvr => "CSV Human Time", csvu => "CSV UNIX Epoch Time", tsvr => "TSV Human", tsvu => "TSV Epoch", ); use constant LEFT => 40; # Left edge of graph use constant RIGHT => WIDTH-22; # Right edge of graph SWITCH: for ($Q::oldzoom) { /^day$/i && do { $oldscale = DAY; last; }; /^week$/i && do { $oldscale = WEEK; last; }; /^month$/i && do { $oldscale = MONTH; last; }; /^year$/i && do { $oldscale = YEAR; last; }; /^yhl$/i && do { $oldscale = YEAR; $hi_lo++; last; }; /^$/ && do { $oldscale = DAY; last; }; die "Bogus old zoom value '$Q::oldzoom'"; } SWITCH: for ($Q::zoom) { /^day$/i && do { $newscale = DAY; last; }; /^week$/i && do { $newscale = WEEK; last; }; /^month$/i && do { $newscale = MONTH; last; }; /^year$/i && do { $newscale = YEAR; last; }; /^yhl$/i && do { $newscale = YEAR; $hi_lo++; last; }; /^$/ && do { $newscale = DAY; last; }; die "Bogus old zoom value '$Q::zoom'"; } # # In case the graph being shown (e.g., year) has less than scale # units (e.g., year of days) in it, adjust the scale accordingly. We # read one line from the file, and then seek back to the beginning. # $zero = NOW; for my $line (1..$maxline) { next unless -e "$opt_d/$line"; openlock($line, 0); my $fd = $fd[$line]; # This is necessary bullshit my ($time, $val) = split /\t/, <$fd>; seek($fd, 0, 0); $zero = $time if $time < $zero; } # # If the graph is clicked on, that is our new center point. # Otherwise, the centerpoint doesn't change # my ($click, $jump); $Q::oldend ||= NOW; $Q::endtime ||= NOW; $Q::zoom ||= "day"; $Q::show = join(",", sort {$a <=> $b} @Q::show) || $Q::show; $oldscale = $Q::oldend - $zero if $Q::oldend - $oldscale < $zero; if (param("pix.x")) { if (param("pix.x") < LEFT) { $jump = -1; param("pix.x",LEFT); # Not really needed any more } elsif (param("pix.x") > RIGHT) { $jump = 1; param("pix.x",RIGHT); # Not really needed any more } $click = param("pix.x"); } else { $click = (LEFT + RIGHT) / 2; } if ($Q::today) { $Q::endtime = NOW; } else { if ($jump == -1) { $Q::endtime = $Q::oldend - $oldscale; } elsif ($jump == 1) { $Q::endtime = $Q::oldend + $oldscale; } else { $Q::endtime = int(((($click - LEFT) / (RIGHT - LEFT)) * $oldscale) + ($Q::oldend - $oldscale) + 0.5 * $newscale); } if ($Q::endtime - $Q::scale < $zero) { $Q::endtime = $zero + $Q::scale + 1; } } $Q::endtime = NOW if $Q::endtime > NOW; open CURRENT, "$opt_d/current" or warn "Can't open $opt_d/current - $!\n"; @current = ; close CURRENT; $, = "\n"; print header(-refresh => 3600), start_html(-bgcolor => "white", -title => "Temperature in $loc_str"), h1("The temperature in and around $loc_str"), h2(a({href => $loc_href}, $loc_coord)), p(), table(TR(th({colspan=>4},"Last Update: $current[0]")), TR(td(font({color=>'red'}, " $current[1] ")), td(font({color=>'green'}, " $current[2] ")), td(font({color=>'blue'}, " $current[3] ")), td(font({color=>'purple'}, " $current[4] ")))), #+++ # Feel free to delete this if you don't want to advertize for QKits p(), "I bought an inexpensive", a({href => "http://www.qkits.com/serv/qkits/diy/pages/QK145.asp", target => "_blank"}, "serial interface temperature sensor kit"), "(about US\$30) from", a({href => "http://www.qkits.com", target => "_blank"}, "QKits"), "and after I built the kit, I also purchased an extra 3 Dallas", "Semiconductor sensors (another US\$18). It is a very easy kit", "to build (it doesn't even need a power supply, it derives power", "from the RS-232 line it talks on), and the", a({href => "http://www.qkits.com", target => "_blank"}, "QKits"), "folks are pleasant people to deal with. I also wrote a nice", "Perl-based thermometer logging daemon, log dumper, and CGI script", "(one program does all three functions!) available with both", a({href => "http://www.klein.com/thermd/thermd", target => "_blank"}, "source code"), "and", a({href => "$me?docs=1", target => "_blank"}, "documentation."), # #--- p(), "Clicking on the graph will center the graph on that area, and", "clicking outside the graph will jump forward or back the", "selected time unit.", p(), start_form(), image_button(-name => "pix", -border => 0, -height => HEIGHT, -width => WIDTH, -lowsrc => "$me?pleasewait=1", -src => "$me?plot=1&endtime=$Q::endtime&zoom=$Q::zoom&show=$Q::show&hide_minmax=$Q::hide_minmax&hide_current=$Q::hide_current&units=$Q::units"), hidden(-name => "oldzoom", -default => $Q::zoom, -override => 1), hidden(-name => "oldend", -default => $Q::endtime, -override => 1), br(), br(), table({width => RIGHT}, TR( td({align => "left"}, submit(-name => "today", -value => "Show latest")), td({align => "middle"}, radio_group(-name => "units", -values => [qw(C F)], -default => $units_plot)), td({align => "right"}, b("Zoom: "), radio_group(-name => "zoom", -values => [qw(day week month year yhl)], -labels => {day => "day", week => "week", month =>"month", year => "year", yhl => "year (hi-lo)"}, -default => "day"), submit("Redraw"))), TR( td(" "), td({colspan => 2, align => "middle"}, b("Plot only:"), checkbox_group(-name => "show", -values => [ sort keys %show_labels ], -labels => \%show_labels, -default => [1..$maxline]))), TR( td(" "), td({colspan => 2, align => "middle"}, checkbox(-name => "hide_current", -label => "Hide Current Values", -checked => 0), checkbox(-name => "hide_minmax", -label => "Hide Min/Max Values", -checked => 0))), TR( td(" "), td({colspan => 2, align => "middle"}, radio_group(-name => "type", -values => [ qw(graph csvr csvu tsvr tsvu) ], -labels => \%type_labels, -default => "graph"))), ), hidden("endtime"), end_form(); # # NOTE: If you delete these lines, you will be in violation of my # copyright, and that will make me mad (and litigious)... # print address("Thermometer Daemon", a({href => 'http://www.klein.com/thermd/thermd', target => "_blank"}, "source code"), "and", a({href => "$me?docs=1", target => "_blank"}, "documentation."), "(V@{[(split(/\s+/, VERSION))[2,3]]}) Copyright © 2001-2005", a({href => 'mailto:dan@klein.com'}, "Daniel V. Klein"), br(), a({href => "$rss_url/index.xml"}, "RSS-2.0 Feed")), end_html(); exit; } } else { require Getopt::Std; Getopt::Std::getopts('b:cd:De:lLm:MNp:r:s:S:tTuvV') || usage(); $opt_d ||= $log_write; usage("Log directory $opt_d does not exist") unless -d $opt_d; $opt_r ||= $rss_dir; usage("RSS directory $opt_r does not exist") if $opt_r && ! -d $opt_r; usage("RSS directory $opt_r not writeable") if $opt_r && ! -w $opt_r; usage() if @ARGV; $opt_e ||= NOW; $opt_v ||= $opt_V; $opt_v = $opt_V = 0 unless $opt_D; } # # On locally administered systems (like http://www.klein.com/thermd), the # most recent data is pretty close to "now". But on remote systems (like # http://www.schmitt.org/lg) the most recent data is sent at a fixed time # (e.g., 03:00), and "now" may be pretty far off from the most recent data. # So, adjust $opt_e to be whatever the most recent data is by scanning # through all of the log files. # my $latest; for my $line (1..$maxline) { my ($time, $temp, $rec); next unless -e "$opt_d/$line"; openlock($line, 0); my $fd = $fd[$line]; # This is necessary bullshit # # As of the Unix millenium (when the clock went past 1000000000 on # Sat Sep 8 21:46:40 2001), we are printing fixed length records in # the logfile that are RECLEN characters long. So seek back 2 records # (just in case we've got a short one), read one and throw it out, and # then read the last record - that's the most recent time. # seek ($fd, -2*RECLEN, 2); $rec = <$fd>; # Discard one line $rec = <$fd>; # Read last line chomp $rec; ($time, $temp) = split /\t/, $rec; close $fd; # We don't necessarily need it any more undef $fd[$line]; # So open's will be called later... $latest = $time if $time > $latest; } $opt_e = $latest if $opt_e > $latest; $opt_U ||= $units_plot; $opt_f ||= 0; if ($opt_L) { dump_recent(); exit; } elsif ($opt_l) { dump_range(time); # And "time()" is an infinity of hours in the past exit; } elsif ($opt_m) { dump_trend($opt_m); exit; } elsif ($opt_p && !$opt_t && !$opt_c) { require GD::Graph::mixed; my $graph = new GD::Graph::mixed( WIDTH, HEIGHT ); my @graph = ([]); # Element 0 is the labels, element 1..N are lines my @legend; SWITCH: for ($opt_p) { /^d(ay?)?$/i && do { my ($d, $m, $y, $w) = (localtime($opt_e - DAY))[3..6]; my $start = sprintf "Starting %s %s %d %d", $day[$w], $mon[$m], $d, $y+1900; if ((localtime($opt_e))[3] != (localtime($opt_e - DAY))[3]) { $start = substr($start, 9); ($d, $m, $y, $w) = (localtime($opt_e))[3..6]; substr($start,-4) = sprintf "- %s %s %d %d", $day[$w], $mon[$m], $d, $y+1900; } $opt_p = DAY; $graph->set( title => "Thermometer Daemon - 24 hours, $start", x_label_skip => 6*3, # That is, one every 3 hours ); last; }; /^w(e(ek?)?)?$/i && do { my ($m, $h, $d, $m, $y) = (localtime($opt_e - WEEK))[1..5]; my $start = sprintf "%02d:%02d %s %d %d", $h, $m, $mon[$m], $d, $y+1900; $opt_p = WEEK; $graph->set( title => "Thermometer Daemon - Week Starting $start", x_label_skip => 6*24, # One a day ); last; }; /^m(o(n(th?)?)?)?$/i && do { $opt_p = MONTH; $graph->set( title => "Thermometer Daemon - 30 Days", x_label_skip => 24*4, # With avging, one every 4 days ); last; }; /^ye(ar?)?$/i && do { $opt_p = YEAR; $graph->set( title => "Thermometer Daemon - 12 Months", x_label_skip => 24*30, # With annual, one per month ); last; }; /^y(hl?)?$/i && do { $opt_p = YEAR; $hi_lo++; $graph->set( title => "Thermometer Daemon - 12 Month Highs & Lows", x_label_skip => 30, # With min/max, one per month ); last; }; die "Unknown plot type $_ (must be one of: day, week, month, year)\n"; } for my $line (1..$maxline) { next unless defined $conf[$line]; if (($opt_s == 0 || $opt_s =~ /\b$line\b/) && -e "$opt_d/$line") { push @legend, $conf[$line]->[DESC]; my ($graph, $labels) = plot($opt_p, $line); # # Push latest row into rows array. If latest row is longer than # what is in there now, replace the labels and prepend undefs at # the beginning of each other row to make up for it. Also check # for the converse, where the latest row is shorter. # push @graph, $graph; if (@{$graph[0]} < @$labels) { $graph[0] = $labels; for my $row (1..$#graph) { splice @{$graph[$row]}, 0, 0, (undef) x (@$labels - @{$graph[$row]}); } } elsif (@{$graph[0]} > @$labels) { splice @{$graph[-1]}, 0, 0, (undef) x (@{$graph[0]} - @$labels); } } } # # Calculate the graph-wide min and max. If indicated, put out the # current and/or minmax datapoint values for lines 1..$maxline, with # markers derived from pseudo-lines $maxline..2*$maxline # my @values = ([]); # Element 0 is x-labels, 1..$maxline filled in below my ($overall_min, $overall_max) = (100, -100); if ($opt_N) { for my $line (1..$maxline) { next unless defined $conf[$line]; $values[$line]->[@{$graph[0]} - 1] = $graph[$line]->[-1]; $graph[$line+4]->[@{$graph[0]} - 1] = $graph[$line]->[-1]; } } for my $line (1..$maxline) { my ($min, $max, $minloc, $maxloc, $loc) = (100, -100, 0, 0, 0); for my $val (@{$graph[$line]}) { do { $minloc = $loc; $min = $val } if defined $val && $val < $min; do { $maxloc = $loc; $max = $val } if defined $val && $val > $max; $loc++; } if ($opt_M) { $values[$line]->[$minloc] = $graph[$line]->[$minloc]; $values[$line]->[$maxloc] = $graph[$line]->[$maxloc]; $graph[$line+$maxline]->[$minloc] = $graph[$line]->[$minloc]; $graph[$line+$maxline]->[$maxloc] = $graph[$line]->[$maxloc]; } $overall_min = $min if $min < $overall_min; $overall_max = $max if $max > $overall_max; } $overall_min -= 10 if $overall_min < 0; # To properly round negatives $overall_min = int(abs($overall_min) / 10) * 10 * sign($overall_min); $overall_max -= 10 if $overall_max < 0; # To properly round negatives $overall_max = (int(abs($overall_max) / 10) + 1) * 10 * sign($overall_max); if ($opt_N || $opt_M) { $graph->set( show_values => \@values, values_format => "%.1f", labelclr => "black", ); } # my $nplot = scalar(split /,/, $opt_s) || $maxline; # These values are common for all graphs (adjust the color of the lines # according to how many we're showing). # $graph->set( y_label => "Temperature (deg $opt_U)", r_margin => 20, y_min_value => $overall_min, y_max_value => $overall_max, y_long_ticks => 1, y_tick_number => (($overall_max - $overall_min) <= 20 ? 10 : ($overall_max - $overall_min) / 10), types => [ ("lines") x $nplot, ("points") x $nplot ], markers => [ 5 ], marker_size => 3, ); my @color_idxs = map {$_ - 1} split /,/, ($opt_s ||= join(",", 1..$maxline)); $graph->set(dclrs => [ (qw(#ff0000 green blue purple))[@color_idxs], (qw(#cc4444 #44cc44 #4444cc #cc44cc))[@color_idxs], ]); # # With averaging, we have one datapoint per hour. If there's more than # a month's worth of data, it gets too dense, so turn the wiggly lines # into a min and a max line (which wiggle far less). This will look a # little strange when a datafile doesn't have more than a month's data # in it, but tough :-) # if ($opt_p == YEAR && $hi_lo) { my (@min_max); for (my $i = 12; $i <= $#{ $graph[0] }; $i += 24) { push @{ $min_max[0] }, $graph[0]->[$i]; } for my $line (1..$maxline) { my ($min, $max, $loc) = (100, -100, 0); unless (@{$graph[$line]}) { $min_max[$line] = []; next; } for my $val (@{$graph[$line]}) { $min = $val if $val < $min; $max = $val if $val > $max; if (++$loc % 24 == 0) { push @{$min_max[$line]}, $min; push @{$min_max[$line+$maxline]}, $max; $min = 100; $max = -100; } } } # # Year graphs are min/max graphs, and so have more lines on them # splice(@graph, 0, 5, @min_max); $graph->set( types => [ ("lines") x ($nplot*2), ("points") x $nplot ], line_types => [ (3) x $nplot, (1) x $nplot], ); } $graph->set_legend(@legend); # # Since the colors and line types have been set according to the number # of actual lines we're plotting, but the graphs themselves have been # inserted into the @graph array by index, cull out the empty plots # in the array, to make the array match the colors and types arrays. # for (reverse 0..12) { splice @graph, $_, 1 unless ref $graph[$_] && @{$graph[$_]}; } print $graph->plot(\@graph)->png; exit; } elsif ($opt_p && ($opt_t || $opt_c)) { SWITCH: for ($opt_p) { /^d(ay?)?$/i && do { dump_range(DAY); exit; }; /^w(e(ek?)?)?$/i && do { dump_range(WEEK); exit; }; /^m(o(n(th?)?)?)?$/i && do { dump_range(MONTH); exit; }; /^y(e(ar?)?)?$/i && do { dump_range(YEAR); exit; }; /^y(hl?)?$/i && do { $hi_lo++; dump_range(YEAR); exit; }; die "Unknown plot type $_ (must be one of: day, week, month, year)\n"; } } ############################################################################## # Support routines ############################################################################## my $kicked; $SIG{HUP} = $SIG{INT} = $SIG{TERM} = sub { $kicked = shift }; sub openlock { my ($line, $lock) = @_; my $flags = $lock ? (O_RDWR | O_CREAT) : O_RDONLY; print "Opening log for $line (lock=$lock)\n" if $opt_v; $fd[$line] = new FileHandle; sysopen($fd[$line], "$opt_d/$line", $flags, 0644) or warn("Can't open $opt_d/$line - $!"); $fd[$line]->autoflush(1); if ($lock) { flock($fd[$line], LOCK_EX|LOCK_NB) or die "Can't lock db #$line - $!\n"; } } sub read_conf { my ($line, $desc); print "Opening config file $config_file\n" if $opt_v; open CONF, $config_file or die "Cannot read configuration file $config_file.\nYou must create one, see sample supplied with this software\n"; while () { chomp; s/#.*//; next if /^\s*$/; if (s/\\$//) { # Handle continued lines my $continuation = ; $continuation =~ s/^\s+//; $_ .= $continuation; redo; } ($line, $desc) = /^(\d)\s+(.*)/; warn "*** Bad config line $, in $config_file\n" unless defined $line && $desc; $conf[$line] = [ split /\|/, $desc ]; warn "*** Missing alarm email in $config_file for $conf[$line]->[DESC]\n" if $line != 0 && @{$conf[$line]} > 2 && @{$conf[$line]} < 5; } $conf[0] ||= ["F", "F", "Undisclosed location"]; $maxline = $#conf; if ($maxline < 1) { die "No sensors specified in configuration file $config_file\n"; } elsif ($maxline > 4) { warn "Line number $maxline too large (QK145 only supports 4 lines)\n"; $maxline = 4; } close CONF; } sub round { int(shift() * 10 + 0.5) / 10; } sub sign { shift() < 0 ? -1 : 1 } sub dump_trend { my $hours = shift; my $str; for my $line (1..$maxline) { next unless defined $conf[$line]; if (($opt_s == 0 || $opt_s =~ /\b$line\b/) && -e "$opt_d/$line") { $str = "Line $line: $conf[$line]->[DESC]"; print "$str\n", "=" x length($str), "\n"; openlock($line, 0); # # We are printing fixed length records in the logfile that are # RECLEN characters long. So seek back 30 records, and then # read until EOF. # my $fd = $fd[$line]; # This is necessary bullshit seek ($fd, -(3600/INCR)*$hours*RECLEN, 2); my $min = 200; my $max = -200; my $trend = '=' x 8; my @tick = qw(= + -); # Hack - idx -1 is '-', idx +1 is '-' my ($sum, $cnt, $when, $str, $last, $rec); while (defined($rec = <$fd>)) { chomp $rec; my ($time, $temp) = split /\t/, $rec; $when = localtime $time unless length $when; $min = $temp < $min ? $temp : $min; $max = $temp > $max ? $temp : $max; $sum += $temp; $trend = substr($trend,1,7) . $tick[round($temp) <=> round($last)]; $last = $temp; $cnt++; } printf "Since %s:\n\tMin => %.2f, Max => %.2f, Avg => %.2f, Trend %s, Cur => %.2f\n\n", $when, $min, $max, $sum / $cnt, $trend, $last; } } } sub dump_recent { my ($rec, @collect, $time, $temp); for my $line (1..$maxline) { next unless defined $conf[$line]; if (($opt_s == 0 || $opt_s =~ /\b$line\b/) && -e "$opt_d/$line") { openlock($line, 0); # # We are printing fixed length records in the logfile that are # RECLEN characters long. So seek back 30 records, and then # read until EOF. # my $fd = $fd[$line]; # This is necessary bullshit seek ($fd, -30*RECLEN, 2); while (defined($rec = <$fd>)) { next unless length $rec == RECLEN; chomp $rec; ($time, $temp) = split /\t/, $rec; next unless $time; $collect[$line]->{$time} = $temp; } } } print_collected(\@collect); } sub dump_range { my $from = $opt_e - shift; my ($rec, @collect, $time, $temp); for my $line (1..$maxline) { next unless defined $conf[$line]; if (($opt_s == 0 || $opt_s =~ /\b$line\b/) && -e "$opt_d/$line") { openlock($line, 0); my $fd = $fd[$line]; # This is necessary bullshit while (defined($rec = <$fd>)) { next unless length $rec == RECLEN; chomp $rec; ($time, $temp) = split /\t/, $rec; next unless $time && $time > $from; last if $time > $opt_e; $collect[$line]->{$time} = $temp; } } } print_collected(\@collect); } # # The assorted temperatures are collected in an array of hashes. The times # stored in each may differ, so make them a single set, and then run through # that list. # sub print_collected { my $coll = shift; my (%times, @lines); for my $line (1..$maxline) { next unless defined $conf[$line]; next unless $coll->[$line]; $lines[$line]=1; for my $time (keys %{$coll->[$line]}) { $times{$time} = 1; } } print "Time"; for my $line (1..$maxline) { next unless defined $conf[$line]; print $opt_c ? qq(,"$conf[$line]->[DESC]") : qq(\t$conf[$line]->[DESC]) if $lines[$line]; } print "\n"; for my $time (sort {$a <=> $b} keys %times) { print $opt_u ? $time : (($opt_c ? '"' : '') . scalar(localtime($time)) . ($opt_c ? '"' : '')); for my $line (1..$maxline) { next unless defined $conf[$line]; print $opt_c ? "," : "\t", $coll->[$line]->{$time} if $lines[$line]; } print "\n"; } } sub plot { my ($length, $line) = @_; my (@graph, @labels, $mcnt, $ycnt, $msum, $ysum, $rec); openlock($line, 0); my $fd = $fd[$line]; # This is necessary bullshit while (defined($rec = <$fd>)) { chomp $rec; my ($time, $temp) = split /\t/, $rec; if ($units_read eq 'F' && $opt_U eq 'C') { $, = "\n"; $temp = ($temp - 32) * 5/9; } elsif ($units_read eq 'C' && $opt_U eq 'F') { $temp = $temp * 9/5 + 32; } if ($length == DAY && $time > $opt_e - DAY && $time <= $opt_e) { push @labels, sprintf "%02d:%02d", (localtime($time))[2,1]; push @graph, $temp; } elsif ($length == WEEK && $time > $opt_e - WEEK && $time <= $opt_e) { push @labels, sprintf "%s %d:%02d", $day[(localtime($time))[6]], (localtime($time))[2,1]; push @graph, $temp; } elsif ($length == MONTH && $time > $opt_e - MONTH && $time <= $opt_e) { # # To keep the month graphs sane, average every 6 readings (1 hr) # $msum += $temp; if (++$mcnt == 6) { my @cal = (localtime($time))[3,4,6]; push @labels, sprintf "%s %d/%d", $day[$cal[2]], $cal[1]+1, $cal[0]; push @graph, $msum / 6; $msum = $mcnt = 0; } } elsif ($length == YEAR && $time > $opt_e - YEAR && $time <= $opt_e) { # # To keep the year graphs sane, average every 6 readings (1 hr). # Later on, we'll rescan the graphs and possibly turn them into a # min and a max curve for each sensor. # $ysum += $temp; if (++$ycnt == 6) { push @labels, sprintf "%s %d", $mon[(localtime($time))[4]], (localtime($time))[3]; push @graph, $ysum / 6; $ysum = $ycnt = 0; } } } return \@graph, \@labels; } sub send_alarm { my ($line, $limit, $temp) = @_; return unless $conf[$line]->[EMAIL]; # Cain't do nothin! open MAIL, "| $sendmail -oi -t" or return; # Cain't do nothin! printf MAIL<<"==X==", $temp, ($limit == MIN ? "below" : "exceeded"); To: $conf[$line]->[EMAIL] From: Thermometer Daemon <$daemon> Subject: Temperature Alert $conf[$line]->[DESC] is %.2f degrees & is %s threshold value of $conf[$line]->[$limit] deg. Sent @{[scalar localtime]} This is an automated warning message from the Thermometer Daemon. TEMPERATURE ALERT WARNING TEMPERATURE ALERT ==X== close MAIL; } sub generate_rss { my @temp = @_; my $tz = $timezone; my ($temp, $now, $now_l); return unless $opt_r !~ /^\s*$/; # Must have a directory specified if (length $tz == 3 && (localtime)[8]) { substr($tz, 1, 1) = 'D'; } $now = sprintf "%s, %02d %s %4d %s GMT", (split /\s+/, gmtime)[0,2,1,4,3]; $now_l = sprintf "%s, %02d %s %4d %s %s", (split /\s+/, localtime)[0,2,1,4,3], $tz; system "$0 -p day -N -M > $opt_r/day.png.new"; rename "$opt_r/day.png.new", "$opt_r/day.png"; system "$0 -p week -N -M > $opt_r/week.png.new"; rename "$opt_r/week.png.new", "$opt_r/week.png"; system "$0 -p month -N -M > $opt_r/month.png.new"; rename "$opt_r/month.png.new", "$opt_r/month.png"; open RSS, "> $opt_r/index.xml.new"; print RSS <<"==END=="; Thermd - $loc_str $rss_url The temperatures in and around $loc_str $now @{[INCR / 60]} thermd V@{[(split(/\s+/, VERSION))[2,3]]} $rss_webmaster Copyright 2001-2005 Daniel V. Klein Current temperatures $rss_url $now $now_l
==END== for my $line (1..$maxline) { next unless defined $conf[$line]; next unless defined $temp[$line]; if ($units_read eq 'C' && $opt_U eq 'F') { $temp = sprintf "%.2f", ($temp[$line]*9/5)+32, $conf[$line]->[DESC]; } elsif ($units_read eq 'F' && $opt_U eq 'C') { $temp = sprintf "%.2f", ($temp[$line]-32)*5/9, $conf[$line]->[DESC]; } else { $temp = sprintf "%.2f", $temp[$line], $conf[$line]->[DESC]; } print RSS <<"==END=="; $conf[$line]->[DESC]: $temp °$opt_U
==END== } print RSS <<"==END=="; Temperature data is updated every 10 minutes
$loc_coord
]]>
Graphs $rss_url $now $now_l

Graphs are updated every 10 minutes
$loc_coord
]]> ==END== close RSS; rename "$opt_r/index.xml.new", "$opt_r/index.xml"; } ############################################################################## # # As of now, we're running as a logging daemon... # ############################################################################## openlock(1, 1); # So some errors happen quickly... print "Opening $opt_d/current\n" if $opt_v; open CURRENT, ">$opt_d/current" or warn "Can't open $opt_d/current - $!"; CURRENT->autoflush(1); ############################################################################## # Fork and detach... ############################################################################## unless ($opt_D) { print "Forking and backgrounding...\n" if $opt_v; my $pid = fork; exit if $pid; die "Couldn't fork - $!\n" unless defined $pid; POSIX::setsid() or die "Couldn't start new session\n"; open PID, "> $pidfile" or die "Can't open PID file - $!\n"; print PID "$$\n"; close PID or die "Can't close PID file - $!\n"; } ############################################################################## # # Open the sensor, and start recording... # # System dependent? # 1) Open thermomter non-blocking, but then reset that flag (has to do with # DTR not being asserted) # 2) Set baud rate to 2400 (QK145 uses that speed) # 3) Ignore CR (the QK145 sends \n\r [not \r\n as we'd expect], ignore the \r) # and ignore partity errors # 4) Enable reader, don't monitor DTR, etc, 8 bits, 2 stop bits # 5) Reopen thermometer, blocking # ############################################################################## print "Opening thermometer $therm\n" if $opt_v; my $serial = new FileHandle("$therm", O_RDWR | O_NDELAY | O_NOCTTY) || die "Can't open $therm $!\n"; my $cflag= CS8 | HUPCL | CREAD | CLOCAL; my $lflag= 0; my $iflag= IGNBRK | IGNPAR | IXON | IXOFF; my $oflag= 0; my $termios = POSIX::Termios->new(); print "Getting flags\n" if $opt_v; $termios->getattr($serial->fileno()) || die "getattr: $!\n"; $termios->setcflag($cflag); $termios->setlflag($lflag); $termios->setiflag($iflag); $termios->setoflag($oflag); $termios->setattr($serial->fileno(),TCSANOW) || die "setattr 1: $!\n"; $termios->setospeed(POSIX::B2400) || die "setospeed: \$!\n"; $termios->setispeed(POSIX::B2400) || die "setispeed: \$!\n"; print "Setting flags\n" if $opt_v; $termios->setattr($serial->fileno(),TCSANOW) || die "setattr 2: $!\n"; # This gets rid of all the special characters.. print "Getting flags (again)\n" if $opt_v; $termios->getattr($serial->fileno()) || die "getattr: $!\n"; print "Reopening thermometer $therm\n" if $opt_v; my $serial = new FileHandle("$therm", O_RDWR | O_NOCTTY) || die "Can't open $therm $!\n"; ############################################################################## # Main daemon loop ############################################################################## my (@alarm, $now, @now, @sum, @count, @last, $restart_counter, $lastwrite, @avg); while (<$serial>) { last if $kicked; print if $opt_V; # Primarily for debugging s/^\s+//; # Better than chomp because of funky s/\s+$//; # \n\r output next unless /^\d/; # Skip non-temps (e.g. status line) my ($line, $temp) = split; # Temp lines look like "1 0081.95" next unless $temp; # There weren't two fields (?) unless ($fd[$line]) { openlock($line, 1); } $temp += $conf[$line]->[ADJUST]; # Tweak the temperature $now[$line] = $temp; # For "right now" measurements $sum[$line] += $temp; # Also keep sum of temperatures... $count[$line]++; # ...so we can average them out # # Only write once every ten minutes # @avg = (); if (($now = time) % INCR == 0 && $lastwrite != $now) { @alarm = (); print "$now ", scalar localtime $now, "\n" if $opt_v; for my $line (1..$maxline) { # But write everything that's saved next unless defined $conf[$line]; my $fd = $fd[$line]; # This is necessary bullshit print "\tLine $line, count=$count[$line], " if $opt_v; if ($count[$line]) { seek ($fd, 0, 2); # Append to file! $avg[$line] = $temp = $sum[$line] / $count[$line]; my $str = sprintf "%010d\t%07.2f", $now, $temp; print $str if $opt_v; print $fd "$str\n"; # # Scream if the temp has crossed the lower or upper threshhold # if (defined $last[$line] && defined $conf[$line]->[MIN] && $last[$line] >= $conf[$line]->[MIN] && $temp < $conf[$line]->[MIN]) { send_alarm($line, MIN, $temp); } if (defined $last[$line] && defined $conf[$line]->[MAX] && $last[$line] <= $conf[$line]->[MAX] && $temp > $conf[$line]->[MAX]) { send_alarm($line, MAX, $temp); } $last[$line] = $temp; } $sum[$line] = $count[$line] = 0; print "\n" if $opt_v; } generate_rss(@avg); $lastwrite = $now; # # For sanity's sake, restart once a week (*after* writing out data) # if ($restart_counter++ > WEEK / INCR) { $kicked = "HUP"; last; } } seek CURRENT, 0, 0; truncate CURRENT, 0; print CURRENT scalar localtime, "\n"; for my $line (1..$maxline) { next unless defined $conf[$line]; if ($units_read eq 'C' && $opt_U eq 'F') { printf CURRENT "%.2f %s\n", ($now[$line]*9/5)+32, $conf[$line]->[DESC] if defined $now[$line]; } elsif ($units_read eq 'F' && $opt_U eq 'C') { printf CURRENT "%.2f %s\n", ($now[$line]-32)*5/9, $conf[$line]->[DESC] if defined $now[$line]; } else { printf CURRENT "%.2f %s\n", $now[$line], $conf[$line]->[DESC] if defined $now[$line]; } } } # # We only get here if we're $kicked # for my $fd (@fd[1..$maxline]) { eval { close $fd }; } if ($kicked eq "HUP") { exec "$0" or die "Cannot restart $0\n"; } __END__ =head1 NAME thermd - thermometer daemon using QK145 =head1 DESCRIPTION This is a four-function program to work with the QK145 thermometer kit from QKits. The four modes are: =over 4 =item 1 Logging daemon The program will sample temperature readings and log them to a data file (up to four sensors, one log file per sensor). =item 2 Reporting program The program will also read the contents of the log file and report recent data, trends, and min/max values =item 3 Plotting program The program will also create Portable Network Graphic (PNG) files which plot temperature readings. =item 4 CGI script The program will act as a CGI script to interactively plot data, and allow a surfer to step forwards and backwards in the logged data. =back =head1 OPERATION When run from the command line, I can run either as a logging daemon, or as a plotting/reporting script =head2 Logging daemon thermd [-d logdir] [-r rssdir] [-D [-v|-V]] -d logdir Where logs get stored (default = /var/log/thermd) -D Don't run as daemon (i.e., don't fork to background) -r rssdir Where RSS-2.0 feed gets stored (default is in configuration variable $rss_dir in the source code) -v Also print 10 minute output to STDOUT -V Also print all output to STDOUT Sending a C signal to the daemon will cause it to restart and re-read it's configuration file. The daemon automatically restarts every week to avoid possible memory leaks. =head2 Plotting program thermd -p type [-N] [-M] [-d logdir] [-e time] [-s line] [{-c|-t}] [-u] -d logdir Where logs get stored (default = /var/log/thermd) -N Show current temp when plotting (only valid with -p) -e time End time (missing -e means graph ends at "now") -s line{,line} Show only line N, 0 == all -M Show min/max when plotting (only valid with -p) -p type Plot PNG file to STDOUT (type can be day, week, month, year) -S {C|F} Scale plot in celsius or fahrenheit (override default) -c Plot CSV file (instead of PNG) to STDOUT -t Plot TSV file (instead of PNG) to STDOUT -u Show Unix Epoch time, not readable time =head2 Reporting program thermd [-d logdir] [{-l | -L}] [-m hours] [-e time] [-u] -c Plot CSV file to STDOUT -d logdir Where logs get stored (default = /var/log/thermd) -l Complete listing for lines (see -s) -e time End time (missing -e means list ends at "now") -t Plot TSV file to STDOUT -L List last 30 measurements for lines (see -s) -m hours List min/max temps in text form -s line{,line} Show only line N, 0 == all -u Show Unix Epoch time, not readable time -S {C|F} Scale plot in celsius or fahrenheit (override default) =head1 CONFIGURATION FILE The configuration file (located in F by default, and specified by the C<$config_file> variable in the code). =head2 Contents (overview) =over 4 =item * The configuration file is read once at program startup. =item * There is one entry per "line". Whitespace separates the LHS from the RHS. Within the RHS, each entry consists of one or more items separated with a vertical bar (C<|>). =item * Comments start with a pound sign (C<#>) and continue until the end of the line =item * Lines may be continued by terminating them with a backslash character (C<\>). Comments are stripped first, so a line containing C<\# text> will be considered a continued line (but a backslash inside of a comment is just part of the comment). =item * Blank lines are ignored. Erroneous lines will generate warnings, but C will I abort. =item * The LHS is either the number 0 (indicating global configuration parameters), or a line number between 1..4 (indicating a sensor number). =back =head2 Global Configuration Fields (line 0 specification) =over 4 =item * Field 0 is the degree units as sent from the C (F or C, jumper selectable in hardware) =item * Field 1 is the default degree units as shown on the graph (F or C, also radio button selectable in CGI application) =item * Field 2 is the printable string of the C monitor =item * Field 3 is the coordinates of the C monitor =item * Field 4 is the URL of a map corresponding to coordinates in field 1 =back =head2 Sensor Line Fields (lines 1..4 specifications) =over 4 =item * Field 0 is the printable string of the sensor =item * Field 1 is the adjustment value for the sensor. This value will be added into (or for negative values, subtracted fromthe raw temperature values read from the sensor. This field may be left blank (and no adjustment will be performed). =item * Field 2 is the low-value alarm for the sensor. When this low threshhold is crossed, a warning email will be sent to the addresses listed in field 4. This field may be left blank (and no low-temperature alarm emails will be sent). =item * Field 3 is the high-value alarm for the sensor. When this high threshhold is crossed, a warning email will be sent to the addresses listed in field 4. This field may be left blank (and no high-temperature alarm emails will be sent). =item * Field 4 is the email address to send alarms to. This field may be left blank if fields 2 and 3 are also blank. If either field 2 or 3 is present, then field 4 must also be present. =back =head2 Sample configuration file # # This is the configuration file used at http://www.klein.com/thermd # 0 F|F|Klone's House|\ N40°26'27.4" W79°55'38.7" (WGS84)|\ http://www.topozone.com/map.asp?z=17&n=4477221&e=590977&s=25 1 Computer exhaust|||85|myname@klein.com,yourname@schmitt.org 2 Basement|+4|40|80|myname@klein.com,yourname@schmitt.org 3 Outside 1st floor|+6|40||myname@klein.com 4 Inside 3rd floor|+3 =head3 Minimal configuration file # # If you are really lazy (and say you have just 2 sensors), you can just do # something like this (defaults to Fahrenheit and an "Undisclosed location") # 1 My thermometer 2 Another sensor =head1 FILES =over 1 =item /etc/thermd.conf Configuration file for thermometer program =item /var/log/thermd/current Current temperatures (changes every few seconds) =item /var/log/thermd/[1-4] Log files for sensors. Entries are written as: epoch-time I temp. New entries are written every 10 minutes (configurable in the code by the constant C). =item /var/run/thermd.pid Process ID of the logging daemon, if daemon is running. =item /var/log/thermd/index.xml RSS feed (if RSS is enabled) =item /var/log/thermd/{day,week,month}.png Most recent graph of data (if RSS is enabled) =item GD::Graph::mixed.pm Graphing modules (must be installed for graphing functions to operate). =back =head1 AUTHOR This program was written by Daniel V. Klein, and is Copyright 2001-2005. All rights reserved - this program may be freely distributed so long as all copyright claims are preserved. Neither this program nor any derived programs may be sold without express written agreement by Daniel V. Klein, C. =head1 VERSION $Revision: 1.77 $ $Date: 2006/01/06 00:28:05 $ =head1 CHANGELOG $Log: thermd,v $ Revision 1.77 2006/01/06 00:28:05 www Some asymmetric logging errors Revision 1.76 2006/01/02 23:17:29 dvk Sorted checkboxes to match display order Revision 1.75 2005/12/15 18:11:21 dvk Added warning message for debugging Revision 1.74 2005/12/15 14:35:44 dvk fixed source code link, cleaned up swear words in log Revision 1.73 2005/10/22 21:35:50 dvk Fixed color-sorting bug reported by Roland Colberg Revision 1.72 2005/10/03 22:04:51 dvk Enhanced documentation Revision 1.71 2005/10/03 22:01:34 dvk Grr.. typos Revision 1.70 2005/10/03 22:00:07 dvk Updated documentation Revision 1.69 2005/10/03 21:56:54 dvk Slightly stricter requirements on cnfiguration files, and a bit more error checking. Revision 1.68 2005/04/20 12:43:24 dvk Fixed hardwiring of $rss_url, other small typo Revision 1.67 2005/01/16 18:34:20 dvk RSS guids are not what I hoped they'd be (at least in NetNewsWire Lite) Revision 1.66 2005/01/16 18:27:04 dvk You know, I really ought to test these things fully before checking them in Revision 1.65 2005/01/16 18:24:45 dvk Grr - fricking XML/RSS Revision 1.64 2005/01/16 18:20:32 dvk A lot of changes - it is nicer now :-) Revision 1.63 2005/01/16 04:35:06 dvk XML is really fricking horrible - fixed something that was illegal XML that some XML parsers liked anyway... Revision 1.62 2005/01/14 03:27:02 dvk Oops - forgot to update documentation Revision 1.61 2005/01/14 01:14:05 dvk *** empty log message *** Revision 1.60 2005/01/14 01:03:59 dvk *** empty log message *** Revision 1.59 2005/01/13 23:51:02 dvk Small bug in open/write code Revision 1.58 2005/01/13 23:43:15 dvk Added RSS information Revision 1.57 2004/12/20 14:00:15 dvk Added annual w/o hi-lo Revision 1.55 2003/11/06 06:02:07 dvk Finally fixed "open" bug, also some F/C stuff thanks to Clay Fandre Revision 1.54 2003/04/17 22:05:56 dvk Made daemon a little neater, nothing major Revision 1.53 2003/03/10 23:51:09 dvk Added $log_read and $log_write variables, to give finer control of the location of the log files (read the changes to the comments in the configuration area for more details). Revision 1.52 2003/03/08 16:25:46 dvk Alas, Perl 5.8 fatally complains about trying to do $serial->input_record_separator("\n\r"); so we fall back on good old $/ Revision 1.51 2003/03/04 21:40:42 dvk *** empty log message *** Revision 1.50 2003/03/04 21:31:18 dvk Folded a very helpful patch from Clay Fandre , which works a lot better on Linux systems for doing the TermIOs setattrs. Turned on strict, and all that entails... Revision 1.49 2003/03/04 20:27:31 dvk Enlarged graph a bit, fixed eval of unused modules (it used to do an eval { use CGI; }; instead of an eval { require CGI; import CGI; }; like it was supposed to. Also put ()'s after many CGI routines that didn't have them (and now need them). Added changelog to documentation. Revision: 1.47 2002/05/29 02:46:18 dvk More changes - added textual dumping for real and from web. Fixed docs and switches Revision: 1.46 2002/05/28 22:26:01 dvk Lots of changes - fixed funny opt_e errors for SCHMITT, added rudiments of textual plotting, added ability to select which lines (not just one, but two, three or four). Revision: 1.44 2002/05/13 17:55:24 dvk Logging change to fixed record format Revision: 1.42 2001/12/30 19:37:30 dvk Added "please wait" to graphical output Revision: 1.41 2001/12/30 19:35:54 dvk Added Celsius and Fahrenheit conversion