#!/usr/bin/perl
#    vdrpbd - A daemon to handle ACPI power button event on VDR systems
#    Copyright (C) 2020  Manuel Reimer <manuel.reimer@gmx.de>
#
#    This program is free software: you can redistribute it and/or modify
#    it under the terms of the GNU General Public License as published by
#    the Free Software Foundation, either version 3 of the License, or
#    (at your option) any later version.
#
#    This program is distributed in the hope that it will be useful,
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#    GNU General Public License for more details.
#
#    You should have received a copy of the GNU General Public License
#    along with this program.  If not, see <http://www.gnu.org/licenses/>.

use strict;
use Pod::Simple::Text;
use Getopt::Std;
use POSIX;
use threads;
use Thread::Queue;
use Sys::Syslog qw(:standard :macros);
use Fcntl qw(LOCK_EX LOCK_NB LOCK_UN);
use IO::Socket::IP;
use FileHandle;
use constant {EVIOCGRAB => 0x40044590, EV_KEY => 1, KEY_POWER => 116};
my $HAVE_DBUS = eval {require Net::DBus;};

my $VERSION = '2.1.0';
my $PROGNAME = 'vdrpbd';
my $PIDFILE = '/var/run/vdrpbd.pid';
my $CFGFILE = '/etc/vdrpbd.conf';
my $FHPID;
my %CONF = (
  ER_COUNT => 4, # Number of keypresses and ...
  ER_TIME => 3,  # ... timerange in seconds for emergency reboot
  TARGET => 'svdrp' # Target to send the power button event to
);
my $KEYQUEUE = Thread::Queue->new();

# Main code (in main thread)
{
  # Prepare logging stuff
  openlog($PROGNAME, 'pid', LOG_DAEMON);
  $SIG{__WARN__} = sub {syslog(LOG_WARNING, @_);};
  $SIG{__DIE__} =  sub {die(@_) if ($^S); syslog(LOG_ERR, @_); exit(1);};

  # Read parameters
  my %opts;
  $Getopt::Std::STANDARD_HELP_VERSION = 1;
  getopts('f', \%opts);

  # Read config
  ParseConfig();
  $CONF{TARGET} = 'dbus' if ($CONF{USE_DBUS}); # Legacy "USE_DBUS" option
  if ($CONF{TARGET} !~ /^(svdrp|dbus|kodi)$/) {
    die("Invalid value for TARGET in $CFGFILE!\n");
  }
  if ($CONF{TARGET} eq 'dbus' && !$HAVE_DBUS) {
    die("DBus support requested but no Net::DBus module present!\n");
  }
  if ($CONF{ER_COUNT} !~ /^[0-9]+$/ || $CONF{ER_COUNT} < 2) {
    die("Invalid value for ER_COUNT in $CFGFILE!\n");
  }
  if ($CONF{ER_TIME} !~ /^[0-9]+$/ || $CONF{ER_TIME} < 1) {
    die("Invalid value for ER_TIME in $CFGFILE!\n");
  }

  # Prepare environment
  chdir('/');
  Daemonize() unless ($opts{f});

  # Register cleanup stuff
  $SIG{INT} = \&Cleanup;
  $SIG{TERM} = \&Cleanup;

  # Connect to the power button devices
  # https://www.cs.ait.ac.th/~on/O/oreilly/perl/cookbook/ch07_14.htm
  # We remember all file handles and create a bitmask for "select" from them
  my @devices = GetButtonDevices();
  my @fhdevs;
  my $rin = '';
  foreach my $device (@devices) {
    open(my $fhdev, '<', $device) or die("Failed to open $device\n");
    vec($rin, fileno($fhdev), 1) = 1;
    push(@fhdevs, $fhdev);


    # VDR reacts with "Press any key to cancel shutdown" if we send our
    # shutdown request. If we let the KEY_POWER through to the X server, then
    # this *is* a key press and depending on timing it may cancel shutdown.
    # "Kodi mode" is just to not block the key from reaching the X server.
    # Kodi properly handles KEY_POWER on its own.
    if ($CONF{TARGET} ne 'kodi') {
      ioctl($fhdev, EVIOCGRAB, 1) or warn("Failed to get exclusive access!\n");
    }
  }

  # Register with systemd if needed/possible
  SystemdInhibit() if ($HAVE_DBUS && HaveSystemd());

  # Run worker thread
  threads->new(\&KeyProcessor)->detach();

  # Process keypresses
  my $struct_input_event = 'L!L!SSl';
  my @btnhist;
  # Wait until one of the devices gets ready for read
  while (select(my $rout = $rin, undef, undef, undef)) {
    # Read one event from the first "readable" device we find
    my $event = 0;
    foreach my $fhdev (@fhdevs) {
      if (vec($rout, fileno($fhdev), 1)) {
        sysread($fhdev, $event, length(pack($struct_input_event)));
        last;
      }
    }
    next unless($event);

    my ($tv_sec, $tv_usec, # <<-- timeval
        $type, $code, $value) = unpack($struct_input_event, $event);
    next unless ($type == EV_KEY && $code == KEY_POWER && $value == 0);

    # Info message to syslog
    syslog(LOG_INFO, 'Power key pressed.');

    # Detect emergency reboot case
    push(@btnhist, $tv_sec);
    if (@btnhist == $CONF{ER_COUNT} &&
        $tv_sec - shift(@btnhist) <= $CONF{ER_TIME}) {
      syslog(LOG_INFO, 'Initiating user-requested emergency reboot!');
      system('/sbin/shutdown', '-r', 'now');
    }

    # Add keypress to queue for worker thread to process.
    # Don't enqueue more than 4 keypresses.
    $KEYQUEUE->enqueue(1) if ($KEYQUEUE->pending() < 4);
  }

  # Close and cleanup
  foreach my $fhdev (@fhdevs) {
    close($fhdev);
  }
  Cleanup();
}

# Worker thread. Tries to forward enqueued keypresses to VDR/Kodi.
sub KeyProcessor {
  while ($KEYQUEUE->dequeue()) {
    if ($CONF{TARGET} eq 'dbus') {
      SendDBus();
    }
    elsif ($CONF{TARGET} eq 'svdrp') {
      SendSVDRP();
    }
  }
}

# Cleanup routine
sub Cleanup {
  if ($FHPID) {
    flock($FHPID, LOCK_UN);
    close($FHPID);
    unlink($PIDFILE);
  }
  exit(0);
}

sub VERSION_MESSAGE {
  print "$PROGNAME $VERSION\n";
}

sub HELP_MESSAGE {
  # Print out the built-in POD documentation in case of --help parameter
  Pod::Simple::Text->filter($0);
}

sub ParseConfig {
  return unless (-s $CFGFILE);
  open(my $fh, '<', $CFGFILE) or die("Failed to open $CFGFILE\n");
  while (my $line = <$fh>) {
    my ($pref, $value) = $line =~ /^\s*([A-Z_]+)\s*=\s*(.+)/ or next;
    $CONF{$pref} = $value;
  }
  close($fh);
}

sub Daemonize {
  # Fork to background
  my $pid = fork();
  die("Forking to background failed\n") unless (defined($pid));

  # Exit parent process.
  exit(0) if ($pid);

  # Close open file handles to old terminal
  close(STDIN);
  close(STDOUT);
  close(STDERR);

  # Write pidfile
  sysopen($FHPID, $PIDFILE, O_CREAT|O_RDWR) or die("Opening pidfile failed\n");
  flock($FHPID, LOCK_EX|LOCK_NB) or die("Daemon already running\n");
  truncate($FHPID, 0);
  print $FHPID "$$\n";

  # Get process group owner
  setsid();
}

sub HaveSystemd {
  # We simply test whether the systemd cgroup hierarchy is mounted
  my @a = lstat('/sys/fs/cgroup') or return 0;
  my @b = lstat('/sys/fs/cgroup/systemd') or return 0;
  return $a[0] != $b[0];
}

# Returns a list of possible "power button devices" found on this system
sub GetButtonDevices {
  # Power buttons to check for
  my @devicepaths = (
    '/sys/devices/LNXSYSTM:00/LNXSYBUS:00/PNP0C0E:00/input',
    '/sys/devices/LNXSYSTM:00/LNXPWRBN:00/input',
    '/sys/devices/LNXSYSTM:00/LNXSYBUS:00/PNP0C0C:00/input'
  );

  my @basepaths = grep {-d $_} @devicepaths or die("No power button found\n");
  my @result;
  foreach my $basepath (@basepaths) {
    opendir(my $dh, $basepath) or next;
    my ($input) = grep(/^input/, readdir($dh)) or next;
    closedir($dh);
    opendir($dh, "$basepath/$input") or next;
    my ($event) = grep(/^event/, readdir($dh)) or next;
    closedir($dh);
    push(@result, "/dev/input/$event");
  }

  die("No power button found\n") if (@result == 0);

  return @result;
}

# Establishes local TCP connection.
# Parameters:
#   aPort: Port to connect to
# Return values:
#   Connected socket on success
#   undef on error
sub ConnectTCP {
  my $timeout = 15; # Socket timeout in seconds
  my ($aPort) = @_;

  my $sock = IO::Socket::IP->new(
    PeerHost => 'localhost',
    PeerPort => $aPort,
    Type     => SOCK_STREAM,
    Timeout  => $timeout
  );

  # Connection failed
  if (!$sock) {
    warn("ConnectTCP: $!");
    return;
  }

  $sock->autoflush(1);
  return $sock;
}

sub SendSVDRP {
  my $port = getservbyname('svdrp', 'tcp') || 6419;
  my $sh = ConnectTCP($port) or return;

  # Send power button event
  print $sh "HITK POWER\nQUIT\n"; # Send full command sequence at once!
  my @reply = <$sh>;
  if ($!) { # Read timed out
    warn("svdrp: $!");
    return;
  }
  close($sh);

  # Process messages returned by VDR
  foreach my $msg (@reply) {
    $msg =~ s/\r$//;
    warn("svdrp: $msg") if ($msg =~ /^5/);
  }
}

# This one requires the "dbus2vdr-plugin" to be installed.
sub SendDBus {
  eval {
    my $bus = Net::DBus->system();
    my $service = $bus->get_service('de.tvdr.vdr');
    my $object = $service->get_object('/Remote', 'de.tvdr.vdr.remote');
    $object->HitKey('POWER');
  } or warn("SendDBus: $@");
}

sub SystemdInhibit {
  # HACK... Add support for UNIX FD return values to Net::DBus.
  # 2013-01-24: Mailed patch to module developer
  # 2013-02-07: First reply from developer --> Patch will be added after review
  # 2013-03-27: Sent mail asking for an update about current status
  # 2013-04-05: https://gitorious.org/net-dbus/net-dbus/commit/5bf227d
  unless (exists $Net::DBus::Binding::Introspector::simple_type_rev_map{ord('h')}) {
    $Net::DBus::Binding::Introspector::simple_type_rev_map{ord('h')} = 'unixfd';
    $Net::DBus::Binding::Introspector::simple_type_map{'unixfd'} = ord('h');
    my $orig_get = \&Net::DBus::Binding::Iterator::get;
    *Net::DBus::Binding::Iterator::get = sub {
      my ($self, $type) = @_;
      return ($type == ord('h')) ? $self->get_int32() : $orig_get->(@_);
    };
  }

  # Try to inhibit the power key.
  eval {
    my $bus = Net::DBus->system();
    my $logind = $bus->get_service('org.freedesktop.login1');
    my $manager = $logind->get_object('/org/freedesktop/login1',
                                      'org.freedesktop.login1.Manager');
    $manager->Inhibit('handle-power-key', $PROGNAME, '', 'block');
  } or warn("systemd-inhibit: $@");
}

__END__

=head1 NAME

vdrpbd - A daemon to handle ACPI power button event on VDR systems

=head1 SYNOPSIS

B<vdrpbd> S<[ B<-f> ]>

=head1 DESCRIPTION

B<vdrpbd> is a ACPI power button handling daemon, which has been created with a VDR-based HTPC in mind. In such setups, the power button on the front should be forwarded as event to the VDR software and no hard shutdown should be triggered. This is where B<vdrpbd> comes in. It listens on the relevant input device and forwards button presses to the VDR process.

The usual downside of this is, that a hanging VDR process makes the power button useless for shutting down the system cleanly. You either have to do a shutdown via remote access or, if available, via terminal on your TV. To address this issue, B<vdrpbd> has a "emergency reboot" feature. If you press the power button four times within three seconds, then the daemon triggers a system reboot to bring your system back to a working state in a clean, easy and fast way.

=head1 KODI SUPPORT

B<vdrpbd> supports Kodi as frontend for Linux HTPC systems. This doesn't require you to use VDR as PVR backend or any PVR backend at all. To have the power button sent to Kodi, just set B<TARGET> in vdrpbd.conf to B<kodi>.

=head2 Command Switches

Switches include:

=over 5

=item B<-f>

Run B<vdrpbd> in the foreground. Default is to run in the background as "daemon".

=item B<--help>

display this help and exit

=item B<--version>

output version information and exit

=back

=head1 SEE ALSO

vdrpbd.conf(5)
