#!/usr/bin/perl
#
# magmakeys
# by Stefan Tomanek <stefan.tomanek@wertarbyte.de>
# http://wertarbyte.de/magmakeys/
# 
# Magmakey is a system wide hotkey daemon.
# It watches all connected input devices for
# key and switch events and can launch arbitrary
# commands when certain events are observed.
#
# It is designed to handle hardware specific keys
# like wireless or suspend controls that usually
# are not user specific

use constant VERSION => "0.5.1";

use strict;

=pod

=head1 NAME

magmakeys -- global hotkey event daemon

=head1 SYNOPSIS

B<magmakeys> [B<-c> I<configdir>] [B<-e> I<eventdir>] [B<--dump>] [B<-t> I<tablefile>] [B<--hal> | B<--nohal> [B<-d> I<device>] [I<devices...>]] 

=head1 DESCRIPTION

Magmakeys is a hotkey daemon that operates on a system wide scale. It watches all connected input devices
for key, switch or button events and can launch arbitrary commands specified by the administrator. In contrast
to hotkey services provided by desktop environments, Magmakeys is especially suited to hardware related switches
like volume or wifi control; it works independently from a specific user being logged in.

=head1 OPTIONS AND ARGUMENTS

=over

=item B<--help>

Shows usage instructions

=item B<--configdir> F<directory> | B<-c> F<directory>

Read configuration files from F<directory>
(mandatory when not using B<--dump> or B<--eventdir>)

=item B<--eventdir> F<directory> | B<-e> F<directory>

Use I<directory> as a source for event handler scripts
(mandatory when not using B<--dump> or B<--configdir>)

=item B<--hal>

Enables HAL for detecting input devices (default)

=item B<--nohal>

Disables the use of HAL for detecting input devices;
Devices that should be observed must be specified
on the command line (see B<--dev>)

=item B<--dev> F<dev> | B<-d> F<dev>

Specifies a single input device I<dev> to read from

=item B<--dump>

Dump all recognized events to STDOUT

=back

Additional command line arguments are considered filenames of input devices.

=head1 CONFIGURATION

=head2 Key combination based handlers

The hotkey bindings used by Magmakeys are set in the configuration files placed in the directory F</etc/magmakeys/conf.d/> (or any other directory specified by B<-c>). Each line consists of three segments:
The symbolic name of the key combination or event name to react on, the value carried by the expected event, and of course the command to be launched.

The event names are translated by looking up the kernel codes through the file F</usr/share/magmakeys/eventcodes.txt> (or any other file specified by B<-t>) and can be found by pressing the selected key while running magmakeys with the option B<--dump>.

Key events carry the value I<1> for a key being pressed and transmit the payload I<0> when it is released; holding the key down constantly yields events with a value of I<2>.

The command can include any number of arguments. Please include the full path to avoid trouble through different $PATH settings for the daemon and your interactive session.

The three fields are seperated by an arbitrary number of whitespaces, while anything behind a # character is ignored and considered a comment.


=head2 Event based handlers

An alternative to using a simple configuration file are event directory scripts; if enabled with a proper base value (e.g. F</etc/magmakeys/events>), magmakeys will check various subdirectories for scripts to execute. An event with the type I<TYPE>, the name I<NAME> and the value <VALUE> will trigger the search for executables in the following directories below the specified base directory:

=over

=item F<I<BASE>/I<TYPE>>

(e.g. F</etc/magmakeys/events/EV_KEY/>)

=item F<I<BASE>/I<NAME>>

(e.g. F</etc/magmakeys/events/KEY_RADIO/>)

=item F<I<BASE>/I<NAME>/I<VALUE>>

(e.g. F</etc/magmakeys/events/EV_KEY/>)

=item F<I<BASE>/I<TYPE>/I<NAME>>

(e.g. F</etc/magmakeys/events/EV_KEY/KEY_RADIO/>)

=item F<I<BASE>/I<TYPE>/I<NAME>/I<VALUE>>

(e.g. F</etc/magmakeys/events/EV_KEY/KEY_RADIO/1>)

=back

Scripts found in these directories are executed with no particular order. For a script to be executed, the following criteria have to be met:

=over

=item *

The script is placed in a directory searched by the wanted device handler

=item *

The script is marked executable

=item *

The script meets the naming convention of L<run-parts>

=item *

The script name does not end in F<.disabled> or F<.dpkg-*>

=back

Various aspects of the triggering event are exported to environment variables:

=over

=item C<EVENT_TYPE>

The type of the event, for example "EV_KEY" or "EV_SW".

=item C<EVENT_NAME>

The name of the event, for example "KEY_POWER" or "SW_LID".

=item C<EVENT_VALUE>

The numeric value of the event

=back

=head1 EXAMPLE

=head2 Starting the daemon

B<magmakeys --hal --dump>

Dump all events processable by magmakeys to the console; this is useful to find out the correct event name for a specific key.

B<magmakeys -c /etc/magmakeys/conf.d/>

Retrieve the list of input devices from HAL (default) and react to events according to the configuration files placed in I</etc/magmakeys/conf.d/>.

B<magmakeys --dump --nohal /dev/input/event5 /dev/input/event6>

Disable the use of HAL and only dump events from the two devices specified

=head2 Configuration files

Any number of event handlers can be placed in the configuration file:

    # /etc/magmakeys/conf.d/example
    #
    # Suspend the system
    KEY_SLEEP       1       /usr/sbin/hibernate-ram
    
    # Change mixer volume when pressing the appropiate keys (or holding them)
    KEY_VOLUMEUP    1,2    /usr/bin/amixer set Master 5%+
    KEY_VOLUMEDOWN  1,2    /usr/bin/amixer set Master 5%-
    
    # Change the beeper volume when pressing shift
    KEY_LEFTSHIFT+KEY_VOLUMEUP    1,2     /usr/bin/amixer set Beep 5%+
    KEY_LEFTSHIFT+KEY_VOLUMEDOWN  1,2     /usr/bin/amixer set Beep 5%-

=head1 FILES

=over

=item F</etc/magmakeys/conf.d/>

Configuration file directory for hotkey definitions

=item F</usr/share/magmakeys/eventcodes.txt>

Translation table for symbolic event names and numeric event codes

=item F</etc/init.d/magmakeys>

Init script for the daemon

=item F</etc/default/magmakeys>

Configuration file to pass additional options to the daemon when using the init script

=back

=head1 AUTHOR

Stefan Tomanek E<lt>stefan.tomanek@wertarbyte.deE<gt>

=cut

sub showHelp {
    my ($message, $error) = @_;

    my $msg = "Magmakeys ".VERSION."\n";
    $msg .= "\n$message\n" if $message;
    my $exit_status = ($error ? 2 : 0);
    my $filehandle = ($error ? \*STDERR : \*STDOUT);
    pod2usage(   -msg     => $msg,
                 -exitval => $exit_status,
                 -verbose => 1,
                 -output  => $filehandle );
}

{
    package EventDumper;

    sub new {
        my $proto = shift;
        my $class = ref($proto) || $proto;

        my $self = {};
        $self->{DUMP} = 0;

        bless( $self, $class );
    }
    
    # dump received events?
    sub dump {
        my $self = shift;
        $self->{DUMP} = shift if (@_);
        return $self->{DUMP};
    }
}

{
    # Event class
    package InputEvent;
    require IO::File;
    use Config;

    my $struct_len = ($Config{longsize} * 2) +
                     ($Config{i16size} * 2) +
                     ($Config{i32size});
    
    # type name -> type number
    our %type = ();
    # event name -> event number
    our %code = ();
    # (type number, event number) -> event name
    our %ev_dict = ();

    sub load_constants {
        my $proto = shift;
        my $class = ref($proto) || $proto;

        my $filename = shift;

        my $fh = new IO::File($filename, "r");
        return 0 unless defined $fh;

        while (my $line = <$fh>) {
            # ignore comments
            next if $line =~ /^#/;
            #
            # lines look like this:
            # KEY_F1 0x28
            #
            # the prefix indicates the event type (KEY)
            # the (hex)number is the code we'll read from the device file
            my ($string, $number) = split(/[[:space:]]+/, $line);
            $number = hex($number) if $number =~ /^0x/;

            my ($prefix, $desc) = split(/_/, $string, 2);
            # lines starting with EV_ contain the code numbers indicating the event type
            if ($prefix eq "EV") {
                $type{$string} = $number;
            } else {
                $code{$string} = { type => "EV_".$prefix, code => $number };
                
                my $type_code = $type{"EV_".$prefix};
                $ev_dict{$type_code}{$number} = { event => $string, type => "EV_".$prefix };
            }
        }
        $fh->close();
        return 1;
    }

    sub lookup_constants {
        my $proto = shift;
        my $class = ref($proto) || $proto;
        my $name = shift;

        my $code_id = $code{$name}{code};
        my $type_id = $type{ $code{$name}{type} };
        return [$type_id, $code_id];
    }

    # construct a new event object by specifying code numbers for type, code and value
    sub new {
        my $proto = shift;
        my $class = ref($proto) || $proto;
        my $self = {};

        if (@_ == 3) {
            $self->{TYPE} = shift;
            $self->{CODE} = shift;
            $self->{VALUE} = shift;
        } else {
            return undef;
        }
        $self->{DEVICE} = undef;

        bless($self, $class);
    }

    sub read_from_device {
        my $proto = shift;
        my $class = ref($proto) || $proto;
        
        my $dev = shift;
        
        my $buffer;
        my $len = sysread($dev, $buffer, $struct_len);
        return undef unless $len > 0;
        
        my ($sec, $usec, $type, $code, $value) = unpack('L!L!S!S!i!', $buffer);

        my $me = $class->new($type, $code, $value);
        $me->{DEVICE} = $dev->filename;
        return $me;
    }

    sub type { return shift()->{TYPE}; }
    sub code { return shift()->{CODE}; }
    sub value { return shift()->{VALUE}; }
    sub device { return shift()->{DEVICE}; }

    sub name {
        my $self = shift;
        return $ev_dict{$self->type}{$self->code}{event};
    }

    sub type_description {
        my $self = shift;
        return $ev_dict{$self->type}{$self->code}{type};
    }


    sub matches {
        my $self = shift;
        my $other = shift;

        return ($self->type == $other->type &&
                $self->code == $other->code &&
                $self->value== $other->value);
    }

    sub as_string {
        my $self = shift;
        return ($self->device ? $self->device : "Unknown device"),": InputEvent ",$self->type,"/",$self->code," (", $self->name, ") with value ", $self->value;
    }

    1;
} 

{
    package DeviceFile;
    our @ISA = ("IO::File");

    sub new {
        my $proto = shift;
        my $class = ref($proto) || $proto;

        my $filename = shift;
        my $self = $class->SUPER::new($filename, 'r');

        ${*$self}->{FILENAME} = $filename;

        bless( $self, $class );
    }

    sub filename {
        my $self = shift;
        return ${*$self}->{FILENAME};
    }
}

{
    package DeviceWatcher;
    our @ISA = ("EventDumper");

    # abstract class
    sub new {
        my $proto = shift;
        my $class = ref($proto) || $proto;
        my $self = $class->SUPER::new();

        $self->{LISTENERS} = [];

        bless( $self, $class );
    }

    sub add_listener {
        my $self = shift;
        my $l = shift;
        push @{ $self->{LISTENERS} }, $l;
    }

    # add or remove handles
    sub add_filehandle { }
    sub remove_filehandle { }
    # start main loop
    sub watch { }

    # read an event from a given filehandle and process it
    sub process_filehandle {
        my $self = shift;
        my $fh = shift;
        my $ie = InputEvent->read_from_device($fh);
        
        unless (defined $ie) {
            print "Error reading from filehandle, removing ",$fh->filename," from watchlist\n";
            $self->remove_filehandle($fh);
            return;
        } 

        if ($ie->type_description eq "EV_KEY" || $ie->type_description eq "EV_SW") {
            if ($self->dump) {
                print $ie->as_string, "\n";
            }
            # do something useful and inform our listeners
            for my $l ( @{ $self->{LISTENERS} } ) {
                $l->handle_event($ie);
            }
        }
    }
    1;
}

{
    package SelectDeviceWatcher;
    our @ISA = ("DeviceWatcher");

    require IO::Select;

    sub new {
        my $proto = shift;
        my $class = ref($proto) || $proto;
        my $self = {};
        
        $self->{SELECTOR} = new IO::Select;
        
        bless($self, $class);
    }

    sub add_filehandle {
        my $self = shift;
        my $dev = shift;
        $self->{SELECTOR}->add($dev);
    }

    sub remove_filehandle {
        my $self = shift;
        my $fh = shift;
        $self->SUPER::remove_filehandle($fh);
        $self->{SELECTOR}->remove($fh);
    }

    sub watch {
        my $self = shift;
        my $sel = $self->{SELECTOR};
        while(my @ready = $sel->can_read) {
            for my $fh (@ready) {
                $self->process_filehandle($fh);
            }
            return unless $sel->count > 0;
        }
    }
    1;
}

{
    package DBusDeviceWatcher;
    our @ISA = ("DeviceWatcher");

    sub new {
        require Net::DBus;
        require Net::DBus::Reactor;
        my $proto = shift;
        my $class = ref($proto) || $proto;
        my $self = $class->SUPER::new();
        
        my $bus = Net::DBus->system;
        $self->{HAL} = $bus->get_service("org.freedesktop.Hal");

        $self->{REACTOR} = Net::DBus::Reactor->main;

        bless($self, $class);
    }


    sub add_filehandle {
        my $self = shift;
        my $dev = shift;

        my $read_cb = sub {
            my $dev = shift;
            $self->process_filehandle($dev);
        };

        $self->{REACTOR}->add_read( 
            $dev->fileno,
            Net::DBus::Callback->new(
                method => $read_cb,
                args   => [$dev]
            ),
            1
        );
    }

    sub remove_filehandle {
        my $self = shift;
        my $dev = shift;
        $self->{REACTOR}->remove_read($dev->fileno);
    }

    sub cb_device_added {
        my $self = shift;
        my $device_id = shift;
        # a new device has been added to the system, let's take a look at it
        my $dev = $self->{HAL}->get_object($device_id, 'org.freedesktop.Hal.Device');
        my $props = $dev->GetAllProperties;
        # is it an input device?
        if ($props->{"linux.subsystem"} eq "input") {
            # check input capabilities
            # only watch devices with keys, switches or buttons
            for my $cap (@{$props->{"info.capabilities"}}) {
                if ($cap eq "input.keyboard" ||
                    $cap eq "input.keypad" ||
                    $cap eq "input.keys" ||
                    $cap eq "input.switch" ||
                    $cap eq "button") {

                    # add it to our watch list
                    $self->add_filehandle(new DeviceFile($props->{"input.device"}));

                    last;
                }
            }
        }
    }

    sub watch {
        my $self = shift;
        my $manager = $self->{HAL}->get_object("/org/freedesktop/Hal/Manager", "org.freedesktop.Hal.Manager");
        # add all existing devices
        foreach my $id (@{$manager->GetAllDevices}) {
            $self->cb_device_added($id);
        }

        # register hotplug callback
        # sometimes devices disappear before we can investigate - therefore we place
        # the method in an eval block
        $manager->connect_to_signal("DeviceAdded", sub {eval{ $self->cb_device_added(shift) }} );

        # start main loop
        $self->{REACTOR}->run();
    }

    1;
}

{
    package EventHandlerManager;
    our @ISA = ("EventDumper");
    require IO::File;

    sub new {
        my $proto = shift;
        my $class = ref($proto) || $proto;
        my $self = $class->SUPER::new();
        $self->{"keys"} = {};
        
        bless($self, $class);
    }
    
    sub press_key {
        my $self = shift;
        my $key = shift;

        $self->{"keys"}{$key}++;
    }

    sub release_key {
        my $self = shift;
        my $key = shift;
        $self->{"keys"}{$key}--;
        unless ($self->{"keys"}{$key} > 0) {
            delete $self->{"keys"}{$key};
        }
    }

    sub pressed_keys {
        my $self = shift;
        return sort keys %{$self->{keys}};
    }

    sub handle_event {
        my ($self, $event) = @_;
        if ($event->type_description eq "EV_KEY") {
            # handle multiple pressed keys by joing their names
            if ($event->value == 1) {
                $self->press_key($event->name);
            } elsif ($event->value == 0) {
                $self->release_key($event->name);
            }
        }
    }

   sub launch_command {
        my $self = shift;
        my $cmd = shift;
        my $event = shift;
        print "Launching command '", $cmd, "'\n";
        if (fork() == 0) {
            $SIG{CHLD} = undef;
            # set up environment
            if ($event) {
                $ENV{EVENT_TYPE} = $event->type_description;
                $ENV{EVENT_NAME} = $event->name;
                $ENV{EVENT_VALUE} = $event->value;
                # FIXME transport both the old and the new state
                $ENV{PRESSED_KEYS} = join("+", $self->pressed_keys);
            }
            exec($cmd);
        }
    }
}

{
    package EventFileManager;
    our @ISA = ("EventHandlerManager");

    sub new {
        my $proto = shift;
        my $class = ref($proto) || $proto;
        
        my $self = $class->SUPER::new();

        $self->{"keys"} = {};
        $self->{"HANDLERS"} = {};

        bless($self, $class);
    }
    
    sub flush_handlers {
        my $self = shift;
        $self->{HANDLERS} = {};
    }

    sub read_handlers_from_file {
        my $self = shift;
        my $filename = shift;
        
        my $fh = new IO::File($filename, 'r');
        my $i = 0;
        while (<$fh>) {
            $i++;
            s/#.*//;
            next unless /[^[:space:]]/;

            if (/^((?:KEY|SW)_[[:alnum:]_]+(?:\+(?:KEY|SW)_[[:alnum:]_]+)*)[[:space:]]+([[:digit:],]+)[[:space:]]+(.*)$/) {
                my ($ev, $values, $cmd) = ($1, $2, $3);
                for my $val ( split(/,/, $values) ) {
                    unless ($self->add_handler($ev, $val, $cmd)) {
                        print STDERR "Unknown to register event handler for event '$ev' and value '$val' (line $i)\n";
                    }
                }
            } else {
                print STDERR "Unable to parse line $i: $_\n";
            }
        }
        $fh->close();
    }
 
    sub add_handler {
        my $self = shift;
        my $event_name = shift;
        my $value = shift;
        my $cmd = shift;
        
        push @{ $self->{HANDLERS}{$event_name}{$value} }, $cmd;
    }


    sub handle_event {
        my $self = shift;
        my $event = shift;
        
        my $cmds = undef;

        if ($event->type_description eq "EV_KEY") {
            # handle multiple pressed keys by joing their names
            my $state_string;
            if ($event->value == 1) {
                $self->press_key($event->name);
                $state_string = join("+", $self->pressed_keys);
            } elsif ($event->value == 0) {
                $state_string = join("+", $self->pressed_keys);
                $self->release_key($event->name);
            } elsif ($event->value == 2) {
                $state_string = join("+", $self->pressed_keys);
            }
            if ($self->dump) {
                print "Key state: $state_string (", $event->value, ")\n";
            }
            $cmds = $self->{HANDLERS}{$state_string}{$event->value};
        } else {
            $cmds = $self->{HANDLERS}{$event->name}{$event->value};
        }
        if (defined $cmds && ref $cmds) {
            for my $cmd (@$cmds) {
                $self->launch_command($cmd, $event);
            }
        }
    }
}

{
    package EventDirectoryManager;
    our @ISA = ("EventHandlerManager");
    
    sub new {
        my $proto = shift;
        my $class = ref($proto) || $proto;
        my $basedir = shift;
        
        my $self = $class->SUPER::new();

        $self->{event_directory} = $basedir;

        bless($self, $class);
    }

    sub get_directories {
        my ($self, $event) = @_;
        my $b  = $self->{event_directory};
        my $en = $event->name;
        my $et = $event->type_description;
        my $v  = $event->value;
        return map { $b."/".$_ } (
            "$en",              # KEY_RADIO
            "$en/$v",           # KEY_RADIO/1
            "$et",              # EV_KEY
            "$et/$en",          # EV_KEY/KEY_RADIO
            "$et/$en/$v"        # EV_KEY/KEY_RADIO/1
        );
    }

    sub handle_event {
        my $self = shift;
        my $event = shift;

        $self->SUPER::handle_event($event);
        
        for my $dir ( $self->get_directories($event) ) {
            print "Checking event directory: $dir\n" if $self->dump;
            next unless ( -d $dir );

            foreach my $f ( <$dir/*> ) {
                my $scriptname = substr($f,length($dir)+1);
                next unless ( $scriptname =~ /^[a-z0-9]+$/ || 
                              $scriptname =~ /^_?([a-z0-9_.]+-)+[a-z0-9]+$/ || 
                              $scriptname =~ /^[a-z0-9][a-z0-9-]*$/ );
                next if ($scriptname =~ /\.dpkg-(?:dist|new|old|tmp)$/ ||
                         $scriptname =~ /\.disabled$/);

                next unless ( -x $f);

                $self->launch_command( $f, $event );
            }
        }
    }

}

use Getopt::Long;
use Pod::Usage;

my $use_hal = 1;
my $help = 0;
my $dump_events = 0;
my $config_file = undef;
my $config_dir = undef;
my $event_dir = undef;
my $table_file = "/usr/share/magmakeys/eventcodes.txt";
my @manual_devs = ();

my $event_manager;

sub say {
    my ($error, @msg) = @_;
    if ($error) {
        print STDERR @msg, "\n";
    } else {
        print STDOUT @msg, "\n";
    }
}

# install signal handlers
$SIG{CHLD} = "IGNORE";

# parse command line
my $result = GetOptions ("help|h"     => \$help,
                         "hal!"       => \$use_hal,
                         "dump"       => \$dump_events,
                         "eventdir|e=s" => \$event_dir,
                         "dev|d=s"    => \@manual_devs,
                         "configdir|c=s" => \$config_dir,
                         "table|t=s"  => \$table_file);

if ($help || not $result) {
    showHelp("", not $help);
}

# additional arguments not touched by Getopt are considered input devices
for my $d (@ARGV) {
    push @manual_devs, $d;
}

if (@manual_devs && $use_hal) {
    showHelp("HAL and manually specified devices are mutually exclusive.", 1);
}

# load constants from file
unless ( InputEvent->load_constants($table_file) ) {
    say 1, "Error loading constants from '$table_file'";
    exit 1;
}

my $watcher = undef;
if ($use_hal) {
    $watcher = new DBusDeviceWatcher();
} else {
    $watcher = new SelectDeviceWatcher();
    # add specified devices
    for my $d (@manual_devs) {
        my $dev = new DeviceFile($d);
        unless ($dev) {
            say 1, "Error opening '$d'";
            exit 1;
        }
        $watcher->add_filehandle($dev);
    }
}
$watcher->dump($dump_events);

$event_manager = new EventFileManager();
$event_manager->dump($dump_events);
$watcher->add_listener($event_manager);

if ($config_dir) {
    # load all config files from the directory
    unless ( -d $config_dir ) {
        say 1, "Error reading config directory '$config_dir'";
    }

    foreach my $c ( <$config_dir/*> ) {
        my $confname = substr($c,length($config_dir)+1);
        next unless ( $confname =~ /^[a-z0-9]+$/ || 
                      $confname =~ /^_?([a-z0-9_.]+-)+[a-z0-9]+$/ || 
                      $confname =~ /^[a-z0-9][a-z0-9-]*$/ );
        next if ($confname =~ /\.dpkg-(?:dist|new|old|tmp)$/ ||
                 $confname =~ /\.disabled$/);
        say 0, "Loading config file '$c'";
        my $success = $event_manager->read_handlers_from_file($c);
        unless ($success) {
            say 1, "Error reading config file '$config_file'";
        }
    }
}

if ($event_dir) {
    my $ed_manager = new EventDirectoryManager($event_dir);
    $ed_manager->dump($dump_events);
    $watcher->add_listener($ed_manager);
}

unless ( $dump_events || defined $config_dir || defined $event_dir) {
    showHelp("Either --configdir, --eventdir or --dump is required. Nothing to do, quitting.", 1);
}

$watcher->watch();
