#!/usr/bin/perl -w

# lcd-nut: monitors UPSes and displays the results on an LCD screen.
# 
# lcd-nut was written to provide a visual indication of the status of all
# Uninteruptable Power Supplies on the network.  For each UPS described
# in the $hosts_conf file, a screen will be displayed listing some
# key information about the UPS.
#
# If the UPS is unavailable, the screen shows the connection to the UPS
# is lost.  If the UPS is on battery or low battery, or unavailable,
# the general purpose output (GPO) is lit.
#
# This utility is written for the APC Smart-UPS 700 to display on a
# Matrix Orbital LCD2041 with an LED wired to the General Purpose Output.
#
# To work for other configurations:
# -- other LCDs:  remove the calls to output (although nothing will happen
#    if you leave it in.  Reformat update_screen() to fit the size of the
#    screen.
# -- other UPS: add or remove variables and reformat update_screen()
#
# Requires:
#   LCDd is correctly configured
#   NUT is correctly configured
#   Perl-LCD is installed
#   UPS::Nut is installed
#
# This software is licensed under the OpenBSD license, which Perl 
# itself is licensed.  Comments and suggestions should be sent to 
# projects@webBastards.com
#
# Have Fun!
#

$VERSION = 0.10;

use strict;
use LCDd;
use UPS::Nut;

#### User Configurable Values
my $hosts_conf = "/usr/local/ups/etc/hosts.conf"; # location of hosts.conf
my $polling_interval = 5; 	# in seconds

#### Constants and odd variables
my %status_map=(
    "OL" => {text=>"ONLINE",      priority=> 128},
    "OB" => {text=>"ON BATTERY",  priority=> 64},
    "LB" => {text=>"LOW BATTERY", priority=> 32},
);

########
# Main #
########

#### parse the config files
my @ups = hosts_conf( $hosts_conf )
  or die "Cannot open $hosts_conf: $!";

#### create the screens
my $lcd = LCDd->new( client_id=>"nut", onHuh=>sub{ print "$_\n" } );
die "Cannot connect to LCDd server: $!" if (!defined $lcd );

$lcd->output("off");

fork && exit;  ## run the program in the background

# connect to the daemons
connect_ups( @ups );
create_screens( @ups );

# setup for an early retirement
$SIG{INT} = \&cleanup;
$SIG{QUIT} = \&cleanup;

# put up the first set of the screens
update_screens( @ups );

# set up the polling to update the screens
$SIG{ALRM} = \&onAlarm;
alarm $polling_interval;

# run the main message loop
$lcd->Pump;

# if we get here, the lcd server must be shutting down
cleanup();
exit;

#### Subroutines

# onAlarm
#
# Tries to connect to UPSes we're not alreay connected to,
# and then updates the screens.
sub onAlarm
{
    connect_ups( @ups );
    update_screens( @ups );
	alarm $polling_interval;
}

# cleanup
#
# Disconnects from all the servers
sub cleanup
{
    alarm(0);
    for my $ups ( @ups )
    {
        $ups->{nut}->Logout();
        $ups->{screen}->remove();
    }
    $lcd->close();
    exit();
}

# onScreen
#
# a call-back routine called whenever any screen is first displayed.
#
# Turn on output #1 if the status is not normal. It's meant to draw
# our attention.
sub onScreen
{
    my $screen = shift;

    $lcd->output(1) if ( ! $screen->{status} or $screen->{status} ne "OL" );
}

# offScreen
#
# a call-back routine called whenever any screen stops being displayed.
#
# Turn off all outputs.
sub offScreen
{
    my $screen = shift;
    $lcd->output(0);
}

# connect_ups
#
# connects to all UPSes not yet connected to.  It's called every cycle
# because a server with a UPS may disconnect.  This will reconnect when
# the server comes back on line.
sub connect_ups
{
    foreach my $ups ( @_ )
    {
        if ( !defined $ups->{nut} )
        {
            $ups->{nut} = new UPS::Nut(
              NAME => $ups->{name},
              HOST => $ups->{host},
              PORT => $ups->{port},
            );
        }
    }
}

# create_screens
#
# creates all the screens, one per UPS.  All are formated for the 
# Matrix Orbital LCd2041 (20 chars by 4 lines).  
#
# To Be Done: use the attributes of $lcd.
sub create_screens
{
    foreach my $ups ( @ups )
    {
        my $screen = $ups->{screen}
            = LCDd::Screen->new($lcd, name=>$ups->{ord},
                -onListen=>\&onScreen, -onIgnore=>\&offScreen
              );
    
        ## create the widgets
        $ups->{widgets}[0] = LCDd::Title->new( $screen );
        $ups->{widgets}[1] = LCDd::String->new( $screen, x=>1, y=>2 );
        $ups->{widgets}[2] = LCDd::String->new( $screen, x=>1, y=>3 );
        $ups->{widgets}[3] = LCDd::String->new( $screen, x=>1, y=>4 );
    }
}

# update_screens
#
# updates the information on all the screens.
sub update_screens
{
    for my $ups ( @_ )
    {
        update_screen( $ups );
    }
}

# update_screen
#
# updates the information on the screens.  All are formated for the 
# Matrix Orbital LCd2041 (20 chars by 4 lines).  
#
# To Be Done: use the attributes of $lcd.
sub update_screen
{
    my $ups = shift;
    my %val;
    my $status;

    if ( defined $ups->{nut} )
    {
        %val = $ups->{nut}->ListRequest(
          qw/ STATUS MFR MODEL BATTPCT UTILITY LOADPCT UPSTEMP RUNTIME /
        );
    }

    $status = $ups->{screen}->{status} =
        ( defined %val ) ? $val{STATUS} : undef;

    if ( !defined $status )
    {
        ## have no connection to the UPS
        delete $ups->{nut};	## cleaned for the next time
        $ups->{screen}->set( priority => 16 );
        $ups->{widgets}[0]->set(
            title => "UPS " . ($ups->{ord}+1) . ": " . $ups->{description}
        );
        $ups->{widgets}[1]->set( text=>"" );
        $ups->{widgets}[2]->set( text=>"Cannot connect to UPS" );
        $ups->{widgets}[3]->set( text=>"" );
    }
    elsif ( $status eq "OL" )
    {
        # set the priority of the screen
        $ups->{screen}->set(
            priority => $status_map{$status}{priority}
        );
        $ups->{widgets}[0]->set(
            title => "UPS " . ($ups->{ord}+1) . ": " . $ups->{description}
              . ": " .  $val{MFR} . " " . $val{MODEL}
        );
        $ups->{widgets}[1]->set(
            text=>"Status:   ". $status_map{$status}{text}
        );
        $ups->{widgets}[2]->set(
    	    text=>sprintf("Bat %s%% Utl %5.1fv",
                  format_percent($val{BATTPCT}), $val{UTILITY} )
        );
        $ups->{widgets}[3]->set(
          text=>sprintf("Lod %s%% Tmp %5.1fC", format_percent($val{LOADPCT}),
            $val{UPSTEMP} )
        );
    }
    else
    {
        # set the priority of the screen
        $ups->{screen}->set(
            priority => $status_map{$status}{priority}
        );
        $ups->{widgets}[0]->set(
            title => "UPS " . ($ups->{ord}+1) . ": " . $ups->{description} . ": " .
              $val{MFR} . " " . $val{MODEL}
        );
        $ups->{widgets}[1]->set(
            text=>"Status:   ". $status_map{$status}{text}
        );
        $ups->{widgets}[2]->set(
    	    text=>sprintf("Bat %s%% Utl %5.1fv", format_percent($val{BATTPCT}),
    		  $val{UTILITY} )
        );
        $ups->{widgets}[3]->set( 
    	    text=>sprintf("RUN TIME: %d", $val{RUNTIME} )
        );
    }
}

# hosts_conf
#
# Processes the hosts_conf file and create the lists of UPSes.
sub hosts_conf
{
    my $file = shift or return undef;
    my @results;
    my $ord = 0;

    open( FILE, $file ) or return undef ;

    while (<FILE>)
    {
        my ($ups, $description);
        chomp;
        next if ( 0 == (( $ups, $description ) = /^\s*MONITOR\s+([^#\s]+)\s+(".*"|[^#\s]+)?/ ) );
	    my (undef,$name,$host,undef,$port) = ( $ups =~ /^((.*)@)?([^:]+)(:(.*))?$/ );
		$description =~ s/"//g;
        push @results, {
           ord =>$ord,
           name=>$name,
           host=>$host,
           port=>$port,
           description=>$description,
        };
        $ord++;
    }

    close FILE;

    return @results;
    
}

# format_percent
#
# constraints a floating point number to fit within a 5 character field.
# The number has to be less than or equal a hundred, hence the name of the
# routine.
sub format_percent
{
    my $value = shift || die "format_percent() needs a value.";
 
    return ( $value >= 100 ) ?
        sprintf( "%4d", $value ) : sprintf( "%4.1f", $value );
}

=head1 NAME

lcd-nut - monitors UPSes and displays the results on an LCD screen.

=head1 SYNOPSIS

Usage: lcd-nut

No parameters to pass; if the configuration file and connections to
the servers are correct, then the program will fork and exec in the
background.

=head1 DESCRIPTION

lcd-nut was written to provide a visual indication of the status of all
Uninteruptable Power Supplies on the network.  For each UPS described
in the $hosts_conf file, a screen will be displayed listing some
key information about the UPS.

If the UPS is unavailable, the screen shows the connection to the UPS
is lost.  If the UPS is on battery or low battery, or unavailable,
the general purpose output (GPO) is lit.

=head1 OPTIONS

There are no options, per say, but there are two user-editable settings
at the top of the Perl script:

$hosts_conf -- the location of the hosts.conf file used to specify
which servers to monitor.

$polling_interval -- the interval between getting values from the UPSes.

=head1 ERRORS

In case of a network error for the UPS, lcd-nut will attempt to reconnect.
The LCD screen will display a message that there was no connection and will
stay there until lcd-nut reconnects to the server.

If there is a network error connecting to the LCD server, then the program
will end.

=head1 FILES

Requires:
  LCDd is correctly configured
  NUT is correctly configured
  Perl 5.005_3 or greater
  Perl-LCD is installed
  UPS::Nut is installed

=head1 CAVEATS

This utility is written for the APC Smart-UPS 700 to display on a
Matrix Orbital LCD2041 with an LED wired to the General Purpose Output.

To work for other configurations:

-- other LCDs:  remove the calls to output (although nothing will happen if you leave it in.  Reformat update_screen() to fit the size of the screen.

-- other UPS: add or remove variables and reformat update_screen()

=head1 RESTRICTIONS

This software is licensed under the OpenBSD license, which Perl itself is licensed.

=head1 NOTES

Requires:
  LCDd is correctly configured
  NUT is correctly configured
  Perl-LCD is installed
  UPS::Nut is installed

=head1 SEE ALSO

LCDd and UPS::Nut man pages

=head1 AUTHOR

Wayne Wylupski <projects@webBastards.com>

=cut
1;
