From: Martin Mares Date: Mon, 2 Jan 2012 08:51:07 +0000 (+0100) Subject: A web interface X-Git-Tag: v1.2~18 X-Git-Url: http://mj.ucw.cz/gitweb/?a=commitdiff_plain;h=463341b93624ecebf73e521c407d3b3f6777e049;p=arexx.git A web interface --- diff --git a/web/UCW/CGI.pm b/web/UCW/CGI.pm new file mode 100644 index 0000000..d2468b8 --- /dev/null +++ b/web/UCW/CGI.pm @@ -0,0 +1,517 @@ +# Poor Man's CGI Module for Perl +# +# (c) 2002--2011 Martin Mares +# Slightly modified by Tomas Valla +# +# This software may be freely distributed and used according to the terms +# of the GNU Lesser General Public License. + +package UCW::CGI; + +# First of all, set up error handling, so that even errors during parsing +# will be reported properly. + +# Variables to be set by the calling module: +# $UCW::CGI::error_mail mail address of the script admin (optional) +# (this one has to be set in the BEGIN block!) +# $UCW::CGI::error_hook function to be called for reporting errors + +my $error_reported; +my $exit_code; +my $debug = 0; + +sub report_bug($) +{ + if (!defined $error_reported) { + $error_reported = 1; + print STDERR $_[0]; + if (defined($UCW::CGI::error_hook)) { + &$UCW::CGI::error_hook($_[0]); + } else { + print "Content-Type: text/plain\n\n"; + print "Internal bug:\n"; + print $_[0], "\n"; + print "Please notify $UCW::CGI::error_mail\n" if defined $UCW::CGI::error_mail; + } + } + die; +} + +BEGIN { + $SIG{__DIE__} = sub { report_bug($_[0]); }; + $SIG{__WARN__} = sub { report_bug("WARNING: " . $_[0]); }; + $exit_code = 0; +} + +END { + $? = $exit_code; +} + +use strict; +use warnings; + +require Exporter; +our $VERSION = 1.0; +our @ISA = qw(Exporter); +our @EXPORT = qw(&html_escape &url_escape &url_deescape &url_param_escape &url_param_deescape &self_ref &self_form &http_get); +our @EXPORT_OK = qw(); + +our $utf8_mode = 0; + +sub http_error($;@) { + my $err = shift @_; + print join("\n", "Status: $err", "Content-Type: text/plain", @_, "", $err, ""); + exit; +} + +### Escaping ### + +sub url_escape($) { + my $x = shift @_; + utf8::encode($x) if $utf8_mode; + $x =~ s/([^-\$_.!*'(),0-9A-Za-z\x80-\xff])/"%".unpack('H2',$1)/ge; + utf8::decode($x) if $utf8_mode; + return $x; +} + +sub url_deescape($) { + my $x = shift @_; + utf8::encode($x) if $utf8_mode; + $x =~ s/%(..)/pack("H2",$1)/ge; + utf8::decode($x) if $utf8_mode; + return $x; +} + +sub url_param_escape($) { + my $x = shift @_; + $x = url_escape($x); + $x =~ s/%20/+/g; + return $x; +} + +sub url_param_deescape($) { + my $x = shift @_; + $x =~ s/\+/ /g; + return url_deescape($x); +} + +sub html_escape($) { + my $x = shift @_; + $x =~ s/&/&/g; + $x =~ s//>/g; + $x =~ s/"/"/g; + $x =~ s/'/'/g; + return $x; +} + +### Analysing RFC 822 Style Headers ### + +sub rfc822_prepare($) { + my $x = shift @_; + # Convert all %'s and backslash escapes to %xx escapes + $x =~ s/%/%25/g; + $x =~ s/\\(.)/"%".unpack("H2",$1)/ge; + # Remove all comments, beware, they can be nested (unterminated comments are closed at EOL automatically) + while ($x =~ s/^(("[^"]*"|[^"(])*(\([^)]*)*)(\([^()]*(\)|$))/$1 /) { } + # Remove quotes and escape dangerous characters inside (again closing at the end automatically) + $x =~ s{"([^"]*)("|$)}{my $z=$1; $z =~ s/([^0-9a-zA-Z%_-])/"%".unpack("H2",$1)/ge; $z;}ge; + # All control characters are properly escaped, tokens are clearly visible. + # Finally remove all unnecessary spaces. + $x =~ s/\s+/ /g; + $x =~ s/(^ | $)//g; + $x =~ s{\s*([()<>@,;:\\"/\[\]?=])\s*}{$1}g; + return $x; +} + +sub rfc822_deescape($) { + my $x = shift @_; + return url_deescape($x); +} + +### Reading of HTTP headers ### + +sub http_get($) { + my $h = shift @_; + $h =~ tr/a-z-/A-Z_/; + return $ENV{"HTTP_$h"} // $ENV{"$h"}; +} + +### Parsing of Arguments ### + +my $main_arg_table; +my %raw_args; + +sub parse_raw_args_ll($$) { + my ($arg, $s) = @_; + $s =~ s/\r\n/\n/g; + $s =~ s/\r/\n/g; + utf8::decode($s) if $utf8_mode; + push @{$raw_args{$arg}}, $s; +} + +sub parse_raw_args($) { + my ($s) = @_; + $s =~ s/\s+//; + for $_ (split /[&:]/, $s) { + (/^([^=]+)=(.*)$/) or next; + my $arg = $1; + $_ = $2; + s/\+/ /g; + s/%(..)/pack("H2",$1)/eg; + parse_raw_args_ll($arg, $_); + } +} + +sub parse_multipart_form_data(); + +sub init_args() { + if (!defined $ENV{"GATEWAY_INTERFACE"}) { + print STDERR "Must be called as a CGI script.\n"; + $exit_code = 1; + exit; + } + + my $method = $ENV{"REQUEST_METHOD"}; + if (my $qs = $ENV{"QUERY_STRING"}) { + parse_raw_args($qs); + } + if ($method eq "GET" || $method eq "HEAD") { + } elsif ($method eq "POST") { + my $content_type = $ENV{"CONTENT_TYPE"} // ""; + if ($content_type =~ /^application\/x-www-form-urlencoded\b/i) { + while () { + chomp; + parse_raw_args($_); + } + } elsif ($content_type =~ /^multipart\/form-data\b/i) { + parse_multipart_form_data(); + } else { + http_error "415 Unsupported Media Type"; + exit; + } + } else { + http_error "405 Method Not Allowed", "Allow: GET, HEAD, PUT"; + } +} + +sub parse_args($) { # CAVEAT: attached files must be defined in the main arg table + my $args = shift @_; + if (!$main_arg_table) { + $main_arg_table = $args; + init_args(); + } + + for my $a (values %$args) { + my $r = ref($a->{'var'}); + defined($a->{'default'}) or $a->{'default'}=""; + if ($r eq 'SCALAR') { + ${$a->{'var'}} = $a->{'default'}; + } elsif ($r eq 'ARRAY') { + @{$a->{'var'}} = (); + } + } + + for my $arg (keys %$args) { + my $a = $args->{$arg}; + defined($raw_args{$arg}) or next; + for (@{$raw_args{$arg}}) { + $a->{'multiline'} or s/(\n|\t)/ /g; + s/^\s+//; + s/\s+$//; + if (my $rx = $a->{'check'}) { + if (!/^$rx$/) { $_ = $a->{'default'}; } + } + + my $v = $a->{'var'}; + my $r = ref($v); + if ($r eq 'SCALAR') { + $$v = $_; + } elsif ($r eq 'ARRAY') { + push @$v, $_; + } + } + } +} + +### Parsing Multipart Form Data ### + +my $boundary; +my $boundary_len; +my $mp_buffer; +my $mp_buffer_i; +my $mp_buffer_boundary; +my $mp_eof; + +sub refill_mp_data($) { + my ($more) = @_; + if ($mp_buffer_boundary >= $mp_buffer_i) { + return $mp_buffer_boundary - $mp_buffer_i; + } elsif ($mp_buffer_i + $more <= length($mp_buffer) - $boundary_len) { + return $more; + } else { + if ($mp_buffer_i) { + $mp_buffer = substr($mp_buffer, $mp_buffer_i); + $mp_buffer_i = 0; + } + while ($mp_buffer_i + $more > length($mp_buffer) - $boundary_len) { + last if $mp_eof; + my $data; + my $n = read(STDIN, $data, 2048); + if ($n > 0) { + $mp_buffer .= $data; + } else { + $mp_eof = 1; + } + } + $mp_buffer_boundary = index($mp_buffer, $boundary, $mp_buffer_i); + if ($mp_buffer_boundary >= 0) { + return $mp_buffer_boundary; + } elsif ($mp_eof) { + return length($mp_buffer); + } else { + return length($mp_buffer) - $boundary_len; + } + } +} + +sub get_mp_line($) { + my ($allow_empty) = @_; + my $n = refill_mp_data(1024); + my $i = index($mp_buffer, "\r\n", $mp_buffer_i); + if ($i >= $mp_buffer_i && $i < $mp_buffer_i + $n - 1) { + my $s = substr($mp_buffer, $mp_buffer_i, $i - $mp_buffer_i); + $mp_buffer_i = $i + 2; + return $s; + } elsif ($allow_empty) { + if ($n) { # An incomplete line + my $s = substr($mp_buffer, $mp_buffer_i, $n); + $mp_buffer_i += $n; + return $s; + } else { # No more lines + return undef; + } + } else { + http_error "400 Bad Request: Premature end of multipart POST data"; + } +} + +sub skip_mp_boundary() { + if ($mp_buffer_boundary != $mp_buffer_i) { + http_error "400 Bad Request: Premature end of multipart POST data"; + } + $mp_buffer_boundary = -1; + $mp_buffer_i += 2; + my $b = get_mp_line(0); + print STDERR "SEP $b\n" if $debug; + $mp_buffer_boundary = index($mp_buffer, $boundary, $mp_buffer_i); + if (substr("\r\n$b", 0, $boundary_len) eq "$boundary--") { + return 0; + } else { + return 1; + } +} + +sub parse_mp_header() { + my $h = {}; + my $last; + while ((my $l = get_mp_line(0)) ne "") { + print STDERR "HH $l\n" if $debug; + if (my ($name, $value) = ($l =~ /([A-Za-z0-9-]+)\s*:\s*(.*)/)) { + $name =~ tr/A-Z/a-z/; + $h->{$name} = $value; + $last = $name; + } elsif ($l =~ /^\s+/ && $last) { + $h->{$last} .= $l; + } else { + $last = undef; + } + } + foreach my $n (keys %$h) { + $h->{$n} = rfc822_prepare($h->{$n}); + print STDERR "H $n: $h->{$n}\n" if $debug; + } + return (keys %$h) ? $h : undef; +} + +sub parse_multipart_form_data() { + # First of all, find the boundary string + my $ct = rfc822_prepare($ENV{"CONTENT_TYPE"}); + if (!(($boundary) = ($ct =~ /^.*;\s*boundary=([^; ]+)/))) { + http_error "400 Bad Request: Multipart content with no boundary string received"; + } + $boundary = rfc822_deescape($boundary); + print STDERR "BOUNDARY IS $boundary\n" if $debug; + + # BUG: IE 3.01 on Macintosh forgets to add the "--" at the start of the boundary string + # as the MIME specs preach. Workaround borrowed from CGI.pm in Perl distribution. + my $agent = http_get("User-Agent") // ""; + $boundary = "--$boundary" unless $agent =~ /MSIE\s+3\.0[12];\s*Mac/; + $boundary = "\r\n$boundary"; + $boundary_len = length($boundary) + 2; + + # Check upload size in advance + if (my $size = http_get("Content-Length")) { + my $max_allowed = 0; + foreach my $a (values %$main_arg_table) { + $max_allowed += $a->{"maxsize"} || 65536; + } + if ($size > $max_allowed) { + http_error "413 Request Entity Too Large"; + } + } + + # Initialize our buffering mechanism and part splitter + $mp_buffer = "\r\n"; + $mp_buffer_i = 0; + $mp_buffer_boundary = -1; + $mp_eof = 0; + + # Skip garbage before the 1st part + while (my $i = refill_mp_data(256)) { $mp_buffer_i += $i; } + skip_mp_boundary() || return; + + # Process individual parts + do { PART: { + print STDERR "NEXT PART\n" if $debug; + my $h = parse_mp_header(); + my ($field, $cdisp, $a); + if ($h && + ($cdisp = $h->{"content-disposition"}) && + $cdisp =~ /^form-data/ && + (($field) = ($cdisp =~ /;name=([^;]+)/)) && + ($a = $main_arg_table->{"$field"})) { + print STDERR "FIELD $field\n" if $debug; + if (defined $h->{"content-transfer-encoding"}) { + http_error "400 Bad Request: Unexpected Content-Transfer-Encoding"; + } + if (defined $a->{"var"}) { + while (defined (my $l = get_mp_line(1))) { + print STDERR "VALUE $l\n" if $debug; + parse_raw_args_ll($field, $l); + } + next PART; + } elsif (defined $a->{"file"}) { + require File::Temp; + require IO::Handle; + my $max_size = $a->{"maxsize"} || 1048576; + my @tmpargs = (undef, UNLINK => 1); + push @tmpargs, DIR => $a->{"tmpdir"} if defined $a->{"tmpdir"}; + my ($fh, $fn) = File::Temp::tempfile(@tmpargs); + print STDERR "FILE UPLOAD to $fn\n" if $debug; + ${$a->{"file"}} = $fn; + ${$a->{"fh"}} = $fh if defined $a->{"fh"}; + my $total_size = 0; + while (my $i = refill_mp_data(4096)) { + print $fh substr($mp_buffer, $mp_buffer_i, $i); + $mp_buffer_i += $i; + $total_size += $i; + if ($total_size > $max_size) { http_error "413 Request Entity Too Large"; } + } + $fh->flush(); # Don't close the handle, the file would disappear otherwise + next PART; + } + } + print STDERR "SKIPPING\n" if $debug; + while (my $i = refill_mp_data(256)) { $mp_buffer_i += $i; } + } } while (skip_mp_boundary()); +} + +### Generating Self-ref URL's ### + +sub make_out_args(@) { # Usage: make_out_args([arg_table, ...] name => value, ...) + my @arg_tables = ( $main_arg_table ); + while (@_ && ref($_[0]) eq 'HASH') { + push @arg_tables, shift @_; + } + my %overrides = @_; + my $out = {}; + for my $table (@arg_tables) { + for my $name (keys %$table) { + my $arg = $table->{$name}; + defined($arg->{'var'}) || next; + defined($arg->{'pass'}) && !$arg->{'pass'} && !exists $overrides{$name} && next; + defined $arg->{'default'} or $arg->{'default'} = ""; + my $value; + if (!defined($value = $overrides{$name})) { + if (exists $overrides{$name}) { + $value = $arg->{'default'}; + } else { + $value = ${$arg->{'var'}}; + defined $value or $value = $arg->{'default'}; + } + } + if ($value ne $arg->{'default'}) { + $out->{$name} = $value; + } + } + } + return $out; +} + +sub self_ref(@) { + my $out = make_out_args(@_); + return "?" . join(':', map { "$_=" . url_param_escape($out->{$_}) } sort keys %$out); +} + +sub self_form(@) { + my $out = make_out_args(@_); + return join('', map { "\n" } sort keys %$out); +} + +### Cookies + +sub set_cookie($$@) { + # + # Unfortunately, the support for the new cookie standard (RFC 2965) among + # web browsers is still very scarce, so we are still using the old Netscape + # specification. + # + # Usage: set_cookie(name, value, option => value...), where options are: + # + # max-age maximal age in seconds + # domain domain name scope + # path path name scope + # secure if present, cookie applies only to SSL connections + # (in this case, the value should be undefined) + # discard if present with any value, the cookie is discarded + # + + my $key = shift @_; + my $value = shift @_; + my %other = @_; + if (exists $other{'discard'}) { + delete $other{'discard'}; + $other{'max-age'} = 0; + } + if (defined(my $age = $other{'max-age'})) { + delete $other{'max-age'}; + my $exp = ($age ? (time + $age) : 0); + # Avoid problems with locales + my ($S,$M,$H,$d,$m,$y,$wd) = gmtime $exp; + my @wdays = ( 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat' ); + my @mons = ( 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ); + $other{'expires'} = sprintf("%s, %02d-%s-%d %02d:%02d:%02d GMT", + $wdays[$wd], $d, $mons[$m], $y+1900, $H, $M, $S); + } + + print "Set-Cookie: $key=", url_escape($value); + foreach my $k (keys %other) { + print "; $k"; + print "=", $other{$k} if defined $other{$k}; + } + print "\n"; +} + +sub parse_cookies() { + my $h = http_get("Cookie") or return (); + my @cook = (); + foreach my $x (split /;\s*/, $h) { + my ($k,$v) = split /=/, $x; + $v = url_deescape($v) if defined $v; + push @cook, $k => $v; + } + return @cook; +} + +1; # OK diff --git a/web/index.cgi b/web/index.cgi new file mode 100755 index 0000000..760c662 --- /dev/null +++ b/web/index.cgi @@ -0,0 +1,46 @@ +#!/usr/bin/perl + +use strict; +use warnings; + +use lib '.'; +use UCW::CGI; + +my $graph; + +UCW::CGI::parse_args({ + 'g' => { 'var' => \$graph, 'default' => 'temp-12h' }, +}); + +print < + +Weather in the Burrow + +

Weather in the Burrow

+AMEN + +sub links(@) { + my $prefix = shift @_; + my $out = ""; + for my $x (@_) { + my $y = $prefix . $x; + if ($graph eq $y) { + $out .= " $x"; + } else { + $out .= " $x"; + } + } + return $out; +} + +print "

Temperature:", links("temp-", "12h", "48h", "month"), "\n"; +print "

Humidity:", links("rh-", "12h", "48h", "month"), "\n"; +print "

Power:", links("power-", "2h", "2h-detail", "day", "day-detail", "month"), "\n"; + +if ($graph =~ /^power-/) { + $graph = "http://micac.burrow.ucw.cz/cgi-bin/$graph"; +} +print "

\n"; diff --git a/web/rh-12h.cgi b/web/rh-12h.cgi new file mode 100755 index 0000000..ece763f --- /dev/null +++ b/web/rh-12h.cgi @@ -0,0 +1,15 @@ +#!/bin/sh +echo "Content-type: image/png" +echo +D=/var/log/arexxd +exec rrdtool graph - \ + --start 'now-12h' \ + --end 'now' \ + --title "Relative Humidity" \ + -w 720 -h 600 \ + -x MINUTE:10:HOUR:1:HOUR:2:0:%H:%M \ + -y 5:2 \ + --right-axis 1:0 --right-axis-format "%3.0lf" \ + --legend-position east \ + --units-exponent 0 --lower-limit 0 --upper-limit 100 --rigid \ + DEF:a=$D/sensor-19247.rrd:rh:AVERAGE 'LINE1:a#cc0000:Catarium' diff --git a/web/rh-48h.cgi b/web/rh-48h.cgi new file mode 100755 index 0000000..bfc4be3 --- /dev/null +++ b/web/rh-48h.cgi @@ -0,0 +1,15 @@ +#!/bin/sh +echo "Content-type: image/png" +echo +D=/var/log/arexxd +exec rrdtool graph - \ + --start 'now-48h' \ + --end 'now' \ + --title "Relative Humidity: Last 2 days" \ + -w 720 -h 600 \ + -x HOUR:1:HOUR:8:HOUR:8:0:%H:%M \ + -y 5:2 \ + --right-axis 1:0 --right-axis-format "%3.0lf" \ + --legend-position east \ + --units-exponent 0 --lower-limit 0 --upper-limit 100 --rigid \ + DEF:a=$D/sensor-19247.rrd:rh:AVERAGE 'LINE1:a#cc0000:Catarium' diff --git a/web/rh-month.cgi b/web/rh-month.cgi new file mode 100755 index 0000000..ced298c --- /dev/null +++ b/web/rh-month.cgi @@ -0,0 +1,16 @@ +#!/bin/sh +echo "Content-type: image/png" +echo +D=/var/log/arexxd +exec rrdtool graph - \ + --start 'now-30d' \ + --end 'now' \ + --title "Relative Humidity: MIN and MAX" \ + -w 720 -h 600 \ + -x DAY:1:DAY:5:DAY:5:0:%d:%m \ + -y 5:2 \ + --right-axis 1:0 --right-axis-format "%3.0lf" \ + --legend-position east \ + --units-exponent 0 --lower-limit 0 --upper-limit 100 --rigid \ + DEF:alo=$D/sensor-19247.rrd:rh:MIN 'LINE1:alo#cc0000:Catarium' \ + DEF:ahi=$D/sensor-19247.rrd:rh:MAX 'LINE1:ahi#cc0000' diff --git a/web/temp-12h.cgi b/web/temp-12h.cgi new file mode 100755 index 0000000..013461e --- /dev/null +++ b/web/temp-12h.cgi @@ -0,0 +1,17 @@ +#!/bin/sh +echo "Content-type: image/png" +echo +D=/var/log/arexxd +exec rrdtool graph - \ + --start 'now-12h' \ + --end 'now' \ + --title "Temperature" \ + -w 720 -h 600 \ + -x MINUTE:10:HOUR:1:HOUR:2:0:%H:%M \ + -y 5:1 \ + --right-axis 1:0 --right-axis-format "%3.0lf" \ + --units-exponent 0 --lower-limit -20 --upper-limit 40 --rigid \ + --legend-position east \ + DEF:a=$D/sensor-10415.rrd:temp:AVERAGE 'LINE1:a#0000cc:Ursarium\n' \ + DEF:b=$D/sensor-12133.rrd:temp:AVERAGE 'LINE1:b#00cc00:Balcony\n' \ + DEF:c=$D/sensor-19246.rrd:temp:AVERAGE 'LINE1:c#cc0000:Catarium' diff --git a/web/temp-48h.cgi b/web/temp-48h.cgi new file mode 100755 index 0000000..a67deca --- /dev/null +++ b/web/temp-48h.cgi @@ -0,0 +1,17 @@ +#!/bin/sh +echo "Content-type: image/png" +echo +D=/var/log/arexxd +exec rrdtool graph - \ + --start 'now-48h' \ + --end 'now' \ + --title "Temperature: Last 2 days" \ + -w 720 -h 600 \ + -x HOUR:1:HOUR:8:HOUR:8:0:%H:%M \ + -y 5:1 \ + --right-axis 1:0 --right-axis-format "%3.0lf" \ + --units-exponent 0 --lower-limit -20 --upper-limit 40 --rigid \ + --legend-position east \ + DEF:a=$D/sensor-10415.rrd:temp:AVERAGE 'LINE1:a#0000cc:Ursarium\n' \ + DEF:b=$D/sensor-12133.rrd:temp:AVERAGE 'LINE1:b#00cc00:Balcony\n' \ + DEF:c=$D/sensor-19246.rrd:temp:AVERAGE 'LINE1:c#cc0000:Catarium' diff --git a/web/temp-month.cgi b/web/temp-month.cgi new file mode 100755 index 0000000..46ab4e6 --- /dev/null +++ b/web/temp-month.cgi @@ -0,0 +1,20 @@ +#!/bin/sh +echo "Content-type: image/png" +echo +D=/var/log/arexxd +exec rrdtool graph - \ + --start 'now-30d' \ + --end 'now' \ + --title "Temperature: MIN and MAX" \ + -w 720 -h 600 \ + -x DAY:1:DAY:5:DAY:5:0:%d:%m \ + -y 5:1 \ + --right-axis 1:0 --right-axis-format "%3.0lf" \ + --units-exponent 0 --lower-limit -20 --upper-limit 40 --rigid \ + --legend-position east \ + DEF:alo=$D/sensor-10415.rrd:temp:MIN 'LINE1:alo#0000cc:Ursarium\n' \ + DEF:ahi=$D/sensor-10415.rrd:temp:MAX 'LINE1:ahi#0000cc' \ + DEF:blo=$D/sensor-12133.rrd:temp:MIN 'LINE1:blo#00cc00:Balcony\n' \ + DEF:bhi=$D/sensor-12133.rrd:temp:MAX 'LINE1:bhi#00cc00' \ + DEF:clo=$D/sensor-19246.rrd:temp:MIN 'LINE1:clo#cc0000:Catarium' \ + DEF:chi=$D/sensor-19246.rrd:temp:MAX 'LINE1:chi#cc0000' diff --git a/web/temp-quick.cgi b/web/temp-quick.cgi new file mode 100755 index 0000000..1ee67ea --- /dev/null +++ b/web/temp-quick.cgi @@ -0,0 +1,17 @@ +#!/bin/sh +echo "Content-type: image/png" +echo +D=/var/log/arexxd +exec rrdtool graph - \ + --start 'now-2h' \ + --end 'now' \ + --title "Temperature" \ + -w 360 -h 200 \ + -x MINUTE:10:MINUTE:30:MINUTE:30:0:%H:%M \ + -y 5:1 \ + --right-axis 1:0 --right-axis-format "%3.0lf" \ + --units-exponent 0 --lower-limit -20 --upper-limit 40 --rigid \ + --legend-position east \ + DEF:a=$D/sensor-10415.rrd:temp:AVERAGE 'LINE1:a#0000cc:Ursarium\n' \ + DEF:b=$D/sensor-12133.rrd:temp:AVERAGE 'LINE1:b#00cc00:Balcony\n' \ + DEF:c=$D/sensor-19246.rrd:temp:AVERAGE 'LINE1:c#cc0000:Catarium'