#!/usr/bin/perl # vim: set softtabstop=2 shiftwidth=2 expandtab : ############################################################################## # $Id$ ############################################################################## # # lepresenced # # checks for one or multiple bluetooth *low energy* devices for their # presence state and reports it to the 73_PRESENCE.pm module. # # Copyright (C) 2015-2018 P. Reinhardt, pr-fhem (at) reinhardtweb (dot) de # # This script 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 2 of the License, or # (at your option) any later version. # # The GNU General Public License can be found at # http://www.gnu.org/copyleft/gpl.html. # A copy is found in the textfile GPL.txt and important notices to the # license from the author is found in LICENSE.txt distributed with these # scripts. # # This script 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. # ############################################################################## use strict; use warnings; use Readonly; use threads; use threads::shared; use Digest::MD5; use Fcntl 'LOCK_EX', 'LOCK_NB'; use IO::Select; use IO::Socket::INET; use Getopt::Long; use Sys::Syslog qw(:standard :macros); use Time::HiRes qw(usleep); use Net::Server::Daemonize qw(daemonize); use Data::Dumper; Readonly my $RETRY_SLEEP => 1; Readonly my $INET_RECV_BUFFER => 1024; Readonly my $MAINLOOP_SLEEP_US => 250 * 1000; Readonly my $BATTERY_TASK_SETTLE_PRE_SLEEP => 1; Readonly my $BATTERY_TASK_SETTLE_POST_SLEEP => 2; Readonly my $KILL_SIGNAL => 2; #SIGINT Readonly my $CLEANUP_INTERVAL => 15 * 60; Readonly my $CLEANUP_MAX_AGE => 30 * 60; Readonly my $STATS_INTERVAL_INFO => 5 * 60; Readonly my $STATS_INTERVAL_DEBUG => 1 * 60; Readonly my $DUMP_INTERVAL => 10; Readonly my $DEFAULT_BATTERY_INTERVAL_H => 6; Readonly my $SHORT_BATTERY_INTERVAL_S => 2 * 60; Readonly my $DEFAULT_RSSI_THRESHOLD => 10; Readonly my $RSSI_WINDOW => 10; Readonly my $ME => 'lepresenced'; Readonly my $VERSION => '0.93'; Readonly my $PIDFILE => "/var/run/$ME.pid"; Readonly my $BATTERY_LEVEL_CHARACTERISTIC_UUID => '00002a19-0000-1000-8000-00805f9b34fb'; Readonly my $BATTERY_MAX_AGE_FACTOR => 4; Readonly my $HCIDUMP_STATE_NONE => 0; Readonly my $HCIDUMP_STATE_LE_META_EVENT => 1; Readonly my $HCIDUMP_STATE_LE_ADVERTISING_REPORT => 2; Readonly my $HCIDUMP_STATE_ADV_INT => 3; Readonly my $HCIDUMP_STATE_SCAN_RSP => 4; Readonly my $THREAD_COMMAND_RUN => 0; Readonly my $THREAD_COMMAND_STOP => 1; Readonly my $THREAD_COMMAND_RESTART => 2; my %devices :shared; my @clients = (); my ($log_level, $log_target); my $debug; my ($beacons_hcitool, $beacons_hcidump) : shared = (0, 0); my %thread_commands :shared = ( 'bluetooth_scan_thread' => $THREAD_COMMAND_RUN, 'bluetooth_dump_thread' => $THREAD_COMMAND_RUN, ); my ($next_dump_time, $next_stats_time, $next_cleanup_time, $next_battery_time); $next_battery_time = time() + $SHORT_BATTERY_INTERVAL_S; sub syslogw { my ($priority, @args) = @_; return if (scalar(@args) < 1); my $logmessage; if (scalar(@args)==1) { my ($message) = @args; $logmessage = sprintf("[tid:%i] %s: $message", threads->self()->tid(), (caller(1))[3] // 'main'); } else { my ($format, @args) = @args; $logmessage = sprintf("[tid:%i] %s: $format", threads->self()->tid(), (caller(1))[3] // 'main', @args); } if ($log_level >= $priority) { if ($log_target eq 'syslog') { syslog($priority, $logmessage) if ($log_level >= $priority); } elsif ($log_target eq 'stdout' && !$debug) { printf("%s\n", $logmessage); } } printf("%s\n", $logmessage) if ($debug); return(); } sub error_exit { my ($exit_code, @args) = @_; syslogw(LOG_ERR, @args); foreach my $thread (threads->list()) { $thread->exit(0); } exit ($exit_code); } sub usage_exit { print("usage:\n"); printf("\t%s --bluetoothdevice --listenaddress --listenport --loglevel --logtarget --daemon\n", $ME); printf("\t%s -b -a -p -l -t -d\n", $ME); print("valid log levels:\n"); print("\tLOG_CRIT, LOG_ERR, LOG_WARNING, LOG_NOTICE, LOG_INFO, LOG_DEBUG. Default: LOG_INFO\n"); print("valid log targets:\n"); print("\tsyslog, stdout. Default: syslog\n"); print("optional arguments:\n"); print("\t--debug - print extensive debug output to stdout (mutually exclusive with --daemon).\n"); print("\t--legacymode - legacy mode without rssi detection. Use if you do not have hcidump installed.\n"); printf("\t--rssithreshold - rssi deviation to trigger an update. Minimum value: 5, default: %s.\n", $DEFAULT_RSSI_THRESHOLD); printf("\t--batteryinterval - interval for battery checks in hours, default: %s.\n", $DEFAULT_BATTERY_INTERVAL_H); print("examples:\n"); printf("\t%s --bluetoothdevice hci0 --listenaddress 127.0.0.1 --listenport 5333 --daemon\n", $ME); printf("\t%s --loglevel LOG_DEBUG --daemon\n", $ME); closelog(); exit(1); } sub parse_log_level { my ($log_level_str) = @_; $log_level_str = uc($log_level_str); return ( $log_level_str eq 'LOG_EMERG' ? LOG_EMERG : $log_level_str eq 'LOG_ALERT' ? LOG_ALERT : $log_level_str eq 'LOG_CRIT' ? LOG_CRIT : $log_level_str eq 'LOG_ERR' ? LOG_ERR : $log_level_str eq 'LOG_WARNING' ? LOG_WARNING : $log_level_str eq 'LOG_NOTICE' ? LOG_NOTICE : $log_level_str eq 'LOG_INFO' ? LOG_INFO : $log_level_str eq 'LOG_DEBUG' ? LOG_DEBUG : usage_exit() ); } sub humanize_thread_command { my ($command) = @_; return ( $command eq $THREAD_COMMAND_RUN ? 'THREAD_COMMAND_RUN' : $command eq $THREAD_COMMAND_STOP ? 'THREAD_COMMAND_STOP' : $command eq $THREAD_COMMAND_RESTART ? 'THREAD_COMMAND_RESTART' : '?' ); } sub parse_options { my $device = "hci0"; my $daemonize = 0; my $listen_address = "0.0.0.0"; my $listen_port = "5333"; my $log_target = "syslog"; my $log_level = "LOG_INFO"; my $debug = 0; my $legacy_mode = 0; my $rssi_threshold = $DEFAULT_RSSI_THRESHOLD; my $battery_interval_h = $DEFAULT_BATTERY_INTERVAL_H; GetOptions( 'bluetoothdevice|device|b=s' => \$device, 'daemon|daemonize|d!' => \$daemonize, 'listenaddress|address|a=s' => \$listen_address, 'listenport|port|p=i' => \$listen_port, 'loglevel|l=s' => \$log_level, 'logtarget|t=s' => \$log_target, 'debug!' => \$debug, 'legacymode|legacy!' => \$legacy_mode, 'rssithreshold=i' => \$rssi_threshold, 'batteryinterval=i' => \$battery_interval_h, ) or usage_exit(); usage_exit() if ($rssi_threshold < 5); usage_exit() if ($battery_interval_h < 1); $listen_address =~ m/^\d+\.\d+\.\d+\.\d+$/ or usage_exit(); $log_target =~ m/^(syslog|stdout)$/ or usage_exit(); $log_level = parse_log_level($log_level); $daemonize = 0 if ($debug); return ($device, $daemonize, $listen_address, $listen_port, $log_level, $log_target, $debug, $legacy_mode, $rssi_threshold, $battery_interval_h); } sub sanity_check { my ($legacy_mode) = @_; error_exit(3, "ERROR: lepresenced is already running. Exiting.") if (!flock DATA, LOCK_EX | LOCK_NB); # log md5 digest of lepresenced open (my $me, '<', $0); binmode ($me); syslogw(LOG_INFO, "md5 digest of '%s' is: '%s'.", $0, Digest::MD5->new->addfile($me)->hexdigest()); close($me); # check if necessary external binaries exist my $ok = 1; foreach my $binary ($legacy_mode ? qw/hciconfig hcitool gatttool/ : qw/hciconfig hcitool gatttool hcidump/) { my $binpath = `which $binary 2>/dev/null`; chomp($binpath); if ($? == 0) { syslogw(LOG_INFO, "%s found at '%s'.", $binary, $binpath); } else { syslogw(LOG_ERR, "ERROR: %s not found!", $binary); $ok = 0; } } error_exit(4, "ERROR: Exiting due to missing binaries.") if (!$ok); return(); } sub update_device { my ($mac, $name, $rssi, $address_type) = @_; $mac = lc($mac); { lock(%devices); unless (exists $devices{$mac}) { my %device :shared; $devices{$mac} = \%device; } $name = '(unknown)' if ($name eq ''); if (!defined($devices{$mac}{'name'}) || $name ne '(unknown)') { $devices{$mac}{'name'} = $name } $devices{$mac}{'rssi'} = $rssi; $devices{$mac}{'reported_rssi'} = $rssi if (!defined($devices{$mac}{'reported_rssi'})); $devices{$mac}{'prevtimestamp'} = $devices{$mac}{'timestamp'}; $devices{$mac}{'address_type'} = lc($address_type); $devices{$mac}{'timestamp'} = time(); } return(); } sub set_thread_command { my ($thread, $command) = @_; syslogw(LOG_DEBUG, "Setting thread command of thread '%s' to '%s'.", $thread, humanize_thread_command($command)); $thread_commands{$thread} = $command; return(); } sub bluetooth_scan_thread { my ($device, $legacy_mode) = @_; my $hcitool; for(;;) { #syslogw(LOG_DEBUG, "Thread command: '%s'.", $thread_commands{bluetooth_scan_thread}); if ($thread_commands{bluetooth_scan_thread} != $THREAD_COMMAND_STOP) { ($beacons_hcitool, $beacons_hcidump) = (0, 0); my $pid = open($hcitool, "-|", "stdbuf -oL hcitool -i " . $device . " lescan --duplicates 2>&1") || die('Unable to start scanning. Please make sure hcitool and stdbuf are installed!'); while (<$hcitool>) { #syslogw(LOG_DEBUG, "Thread command: '%s'.", $thread_commands{bluetooth_scan_thread}) if ($thread_commands{bluetooth_scan_thread} != $THREAD_COMMAND_RUN); last() if ($thread_commands{bluetooth_scan_thread} != $THREAD_COMMAND_RUN); chomp($_); if ($_ eq 'LE Scan ...') { syslogw(LOG_INFO, "Received '%s'.", $_); } elsif (my ($fbmac, $fbname) = $_ =~ /^([\da-f]{2}:[\da-f]{2}:[\da-f]{2}:[\da-f]{2}:[\da-f]{2}:[\da-f]{2})\s(.*)$/i) { $beacons_hcitool++; if ($legacy_mode) { update_device($fbmac, $fbname, 'unknown', undef); } } elsif ( $_ =~ m/^Set scan parameters failed: Input\/output error$/ || $_ =~ m/^Invalid device: Network is down$/ ) { syslogw(LOG_WARNING, "Received '%s', resetting...", $_); system(sprintf('hciconfig %s reset', $device)); } else { syslogw(LOG_WARNING, "Received unknown output: '%s'!", $_); } } kill($KILL_SIGNAL, $pid); close($hcitool); syslogw(LOG_WARNING, $thread_commands{bluetooth_scan_thread} == $THREAD_COMMAND_STOP ? "hcitool was stopped." : $thread_commands{bluetooth_scan_thread} == $THREAD_COMMAND_RESTART ? "restarting hcitool..." : "hcitool exited, retrying..." ); set_thread_command('bluetooth_scan_thread', $THREAD_COMMAND_RUN) if ($thread_commands{bluetooth_scan_thread} == $THREAD_COMMAND_RESTART); } sleep($RETRY_SLEEP); } return(); } sub bluetooth_dump_thread { my ($device) = @_; my $hcidump; my %rssitable; for(;;) { #syslogw(LOG_DEBUG, "Thread command: '%s'.", $thread_commands{bluetooth_dump_thread}); if ($thread_commands{bluetooth_dump_thread} != $THREAD_COMMAND_STOP) { ($beacons_hcitool, $beacons_hcidump) = (0, 0); my $pid = open($hcidump, "-|", "hcidump -i " . $device . " 2>&1") || die('Unable to start scanning. Please make sure hcidump is installed or use legacy mode (--legacymode)!'); my $state = $HCIDUMP_STATE_NONE; my $current_mac = ''; my $current_rssi = ''; my $current_name = ''; my $current_address_type = ''; while (<$hcidump>) { #syslogw(LOG_DEBUG, "Thread command: '%s'.", $thread_commands{bluetooth_dump_thread}) if ($thread_commands{bluetooth_dump_thread} != $THREAD_COMMAND_RUN); last() if ($thread_commands{bluetooth_dump_thread} != $THREAD_COMMAND_RUN); chomp($_); if ($_ =~ m/^< HCI Command: /) { if ($beacons_hcitool > 0) { # Ignore initial settings, i. e. before first beacon # https://forum.fhem.de/index.php/topic,75559.msg1007719.html#msg1007719 syslogw(LOG_WARNING, "Received '%s', telling hcidump and hcitool to restart...", $_); set_thread_command('bluetooth_scan_thread', $THREAD_COMMAND_RESTART); set_thread_command('bluetooth_dump_thread', $THREAD_COMMAND_RESTART); } } elsif ($_ =~ m/^>/) { if ($current_mac) { # update rssi queue unless (exists $rssitable{$current_mac}) { $rssitable{$current_mac} = []; } if ($current_rssi) { shift(@{$rssitable{$current_mac}}) if(scalar(@{$rssitable{$current_mac}}) >= $RSSI_WINDOW); push(@{$rssitable{$current_mac}}, $current_rssi); } my $mean_rssi = 0; foreach my $rssi (@{$rssitable{$current_mac}}) { $mean_rssi += $rssi; } $mean_rssi = int($mean_rssi / scalar(@{$rssitable{$current_mac}})); #printf("DEBUG: mac: %s, rssi count: %i, rssis: %s, mean: %s\n", $current_mac, scalar(@{$rssitable{$current_mac}}), join(',', @{$rssitable{$current_mac}}), $mean_rssi); update_device($current_mac, $current_name, $mean_rssi, $current_address_type); } $current_mac = ''; $current_rssi = ''; $current_name = ''; $current_address_type = ''; if ($_ =~ m/^> HCI Event: LE Meta Event \(0x3e\) plen \d+$/) { $state = $HCIDUMP_STATE_LE_META_EVENT; } else { $state = $HCIDUMP_STATE_NONE; } } elsif ( $state == $HCIDUMP_STATE_LE_META_EVENT && $_ eq ' LE Advertising Report' ) { $state = $HCIDUMP_STATE_LE_ADVERTISING_REPORT; } elsif ($state == $HCIDUMP_STATE_LE_ADVERTISING_REPORT) { if ( $_ eq ' ADV_IND - Connectable undirected advertising (0)' || $_ eq ' ADV_NONCONN_IND - Non connectable undirected advertising (3)' ) { $state = $HCIDUMP_STATE_ADV_INT; } elsif ($_ eq ' SCAN_RSP - Scan Response (4)') { $state = $HCIDUMP_STATE_SCAN_RSP; } } elsif ($state == $HCIDUMP_STATE_SCAN_RSP || $state == $HCIDUMP_STATE_ADV_INT) { if ($_ =~ m/^ bdaddr ([0-9a-fA-F]{2}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}) \((Public|Random)\)$/) { $beacons_hcidump++; $current_mac = $1; $current_address_type = $2; } elsif ($_ =~ m/^ Complete local name: '(.*)'$/) { $current_name = $1; } elsif ($_ =~ m/^ RSSI: (-\d+)$/) { $current_rssi = $1; } } elsif ($_ !~ m/^ /) { syslogw(LOG_DEBUG, 'Received \'%s\'.', $_); } } kill($KILL_SIGNAL, $pid); close($hcidump); syslogw(LOG_WARNING, $thread_commands{bluetooth_dump_thread} == $THREAD_COMMAND_STOP ? "hcidump was stopped." : $thread_commands{bluetooth_dump_thread} == $THREAD_COMMAND_RESTART ? "restarting hcidump..." : "hcidump exited, retrying..." ); set_thread_command('bluetooth_dump_thread', $THREAD_COMMAND_RUN) if ($thread_commands{bluetooth_dump_thread} == $THREAD_COMMAND_RESTART); } sleep($RETRY_SLEEP); } return(); } sub handle_command { my ($buf, $current_client) = @_; if (my ($mac, undef, $interval) = $buf =~ m/^\s*(([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2})\s*\|\s*(\d+)\s*$/) { $mac = lc($mac); if (my ($client) = grep { $current_client == $_->{'handle'} } @clients) { syslogw(LOG_INFO, "Received query update for mac address %s, interval: %i by client %s:%i.", $mac, $interval, $current_client->peerhost(), $current_client->peerport()); $client->{'mac'} = $mac; $client->{'interval'} = $interval; $client->{'next_check'} = 0; #now } else { syslogw(LOG_INFO, "Received query for mac address %s, interval: %i. Adding client %s:%i to clients list.", $mac, $interval, $current_client->peerhost(), $current_client->peerport()); my %new_client; $new_client{'handle'} = $current_client; $new_client{'mac'} = $mac; $new_client{'interval'} = $interval; $new_client{'next_check'} = 0; #now push(@clients, \%new_client); } print $current_client "command accepted\n" } elsif ($buf =~ m/^\s*now\s*$/) { syslogw(LOG_DEBUG, "Received now command from client %s:%i. Scheduling update...", $current_client->peerhost(), $current_client->peerport()); foreach my $client (grep { $_->{'handle'} == $current_client } @clients) { $client->{'next_check'} = 0; #now } $next_battery_time = time() + $SHORT_BATTERY_INTERVAL_S; print $current_client "command accepted\n" } elsif ($buf =~ m/^\s*ping\s*$/) { syslogw(LOG_DEBUG, "Received ping command from client %s:%i.", $current_client->peerhost(), $current_client->peerport()); my ($min_age, $max_age, $devices) = gather_stats(); print $current_client sprintf("pong [clients=%i;devices=%i;min_age=%s;max_age=%s;beacons_hcitool=%i;beacons_hcidump=%i;beacons_diff=%i]\n", scalar(@clients), $devices, $min_age // '%', $max_age // '%', $beacons_hcitool, $beacons_hcidump, abs($beacons_hcitool - $beacons_hcidump)); return(1); } elsif ($buf =~ m/^\s*stop\s*$/) { # Stop does not make sense when scanning permanently syslogw(LOG_DEBUG, "Received stop command from client %s:%i. Pretending to care and ignoring...", $current_client->peerhost(), $current_client->peerport()); print $current_client "no command running\n" # ToDo: Does the FHEM module even care? } else { syslogw(LOG_WARNING, "Received unknown command: '%s'.", $buf); } return(0); } sub gather_stats { my ($min_age, $max_age, $devices); { lock(%devices); $devices = scalar(keys(%devices)); foreach my $mac (keys(%devices)) { my $age = time() - $devices{$mac}{'timestamp'}; $min_age = $age if (!defined($min_age) || $age < $min_age); $max_age = $age if (!defined($max_age) || $age > $max_age); } } return($min_age, $max_age, $devices); } sub stats_task { my ($min_age, $max_age, $devices) = gather_stats(); syslogw(LOG_INFO, "Active clients: %i, known devices: %i (min/max age: %s/%s), received beacons (hcitool/hcidump/difference): %i/%i/%i", scalar(@clients), $devices, $min_age // '%', $max_age // '%', $beacons_hcitool, $beacons_hcidump, abs($beacons_hcitool - $beacons_hcidump)); return(); } sub dump_task { printf("Known devices (%i):\n", scalar(keys(%devices))); foreach my $mac (sort keys(%devices)) { printf("\tmac: %s, ages: %2s/%2s, rssi: %s, name: %s, battery: %s\n", $mac, time() - $devices{$mac}{'timestamp'}, $devices{$mac}{'prevtimestamp'} ? time() - $devices{$mac}{'prevtimestamp'} : '%', $devices{$mac}{'rssi'}, $devices{$mac}{'name'}, exists($devices{$mac}{'battery_level'}) ? sprintf("%s (age: %ss)", $devices{$mac}{'battery_level'}, time() - $devices{$mac}{'battery_time'}) : 'unknown' ); } printf("Received beacons (hcitool/hcidump): %i/%i, difference: %i\n", $beacons_hcitool, $beacons_hcidump, abs($beacons_hcitool - $beacons_hcidump)); return(); } sub cleanup_task { my $start_time = time(); my $deleted_items = 0; { lock(%devices); foreach my $mac (keys(%devices)) { my $age = time() - $devices{$mac}{'timestamp'}; if ( $age > $CLEANUP_MAX_AGE && scalar(grep { $_->{'mac'} eq $mac } @clients) == 0 ) { $deleted_items++; syslogw(LOG_DEBUG, "Deleting device %s.", $mac); delete($devices{$mac}); } } } syslogw(LOG_INFO, "Cleanup finished, deleted %i devices in %i seconds.", $deleted_items, time() - $start_time); return(); } sub get_battery_level { my ($device, $mac) = @_; my $address_type = $devices{$mac}{'address_type'} // 'public'; open(my $gatttool, "-|", "gatttool -i $device -b $mac -t $address_type --char-read --uuid=$BATTERY_LEVEL_CHARACTERISTIC_UUID 2>&1") || die('Error executing gatttool!'); my $result = 'unknown'; while (<$gatttool>) { chomp($_); syslogw(LOG_DEBUG, "gatttool (mac: %s, address type: '%s'): '%s'", $mac, $address_type, $_); if ($_ =~ m/^handle:\s[0-9A-Fa-fx]+\s+value:\s([0-9a-f]+)\s*$/) { # Success: 'handle: 0x0028 value: 64' $result = hex($1); } elsif ($_ =~ m/^Read characteristics by UUID failed: No attribute found within the given range$/) { # Unsupported: 'Read characteristics by UUID failed: No attribute found within the given range' $result = 'unknown (unsupported)'; printf } elsif ($_ =~ m/^connect error: Connection refused \(111\)$/) { # Unreachable (after 40s): 'connect error: Connection refused (111)' # Shouldn't happen very often because we try to query only reachable clients $result = 'unknown (timeout)'; } } close($gatttool); return($result); } sub battery_task { my ($device) = @_; my @present_clients; foreach my $client (@clients) { push(@present_clients, $client) if (is_present($client)); } if (scalar(@present_clients) > 0) { syslogw(LOG_INFO, "Starting battery task, %i reachable device(s) to query...", scalar(@present_clients)); set_thread_command('bluetooth_dump_thread', $THREAD_COMMAND_STOP); set_thread_command('bluetooth_scan_thread', $THREAD_COMMAND_STOP); sleep($BATTERY_TASK_SETTLE_PRE_SLEEP); foreach my $client (@present_clients) { my $battery_level = get_battery_level($device, $client->{'mac'}); syslogw(LOG_INFO, "Battery level for mac %s is %s.", $client->{'mac'}, $battery_level); # Don't overwrite a valid battery level with unknown if(defined($devices{$client->{'mac'}}) && $battery_level !~ m/^unknown/) { lock(%devices); $devices{$client->{'mac'}}{'battery_level'} = $battery_level; $devices{$client->{'mac'}}{'battery_time'} = time(); # allow present clients a full interval to recover after scan stop $client->{'next_check'} = time() + $client->{'interval'}; } } sleep($BATTERY_TASK_SETTLE_POST_SLEEP); set_thread_command('bluetooth_scan_thread', $THREAD_COMMAND_RUN); set_thread_command('bluetooth_dump_thread', $THREAD_COMMAND_RUN); syslogw(LOG_INFO, "Battery task completed."); } else { syslogw(LOG_INFO, "Skipping battery task, no devices to query."); } return(); } sub is_present { my ($client) = @_; return( defined($devices{$client->{'mac'}}) && time()-$devices{$client->{'mac'}}{timestamp} <= $client->{'interval'} && defined($devices{$client->{'mac'}}{prevtimestamp}) && time()-$devices{$client->{'mac'}}{prevtimestamp} <= $client->{'interval'} ); } openlog($ME, 'pid', LOG_USER); (my $device, my $daemonize, my $listen_address, my $listen_port, $log_level, $log_target, $debug, my $legacy_mode, my $rssi_threshold, my $battery_interval_h) = parse_options(); local $SIG{INT} = local $SIG{TERM} = local $SIG{HUP} = sub { syslogw(LOG_NOTICE, "Caught signal, cleaning up and exiting..."); unlink($PIDFILE) if (-e $PIDFILE); closelog(); exit(1); }; syslogw(LOG_NOTICE, "Version %s started (device: %s, listen addr: %s, listen port: %s, daemonize: %i, legacy mode: %i, rssi threshold: %i, battery interval: %i, log level: %i, debug: %i).", $VERSION, $device, $listen_address, $listen_port, $daemonize, $legacy_mode, $rssi_threshold, $battery_interval_h, $log_level, $debug); sanity_check($legacy_mode); daemonize('root', 'root', $PIDFILE) if $daemonize; my ($bluetooth_dump_thread, $bluetooth_scan_thread); $bluetooth_scan_thread = threads->new(\&bluetooth_scan_thread, $device, $legacy_mode)->detach(); $bluetooth_dump_thread = threads->new(\&bluetooth_dump_thread, $device)->detach() if (!$legacy_mode); my $current_client; local $| = 1; my $server_socket = IO::Socket::INET->new( LocalHost => $listen_address, LocalPort => $listen_port, Proto => 'tcp', Listen => 5, ReuseAddr => 1, ); $server_socket or error_exit(2, "ERROR: Unable to create TCP server: $!, Exiting."); my $select = IO::Select->new($server_socket) or error_exit(1, "ERROR: Unable to select: $!, Exiting."); $next_stats_time = time() + $STATS_INTERVAL_DEBUG; $next_dump_time = time() + $DUMP_INTERVAL if ($debug); $next_cleanup_time = time() + $CLEANUP_INTERVAL; local $SIG{PIPE} = sub { syslogw(LOG_INFO, "SIGPIPE received!"); }; for(;;) { # Process INET socket foreach my $current_client ($select->can_read(0)) { if($current_client == $server_socket) { my $client_socket = $server_socket->accept(); $select->add($client_socket); syslogw(LOG_INFO, "Connection from %s:%s. Connected clients: %i.", $client_socket->peerhost(), $client_socket->peerport(), $select->count()-1); } else { sysread ($current_client, my $buf, $INET_RECV_BUFFER); my $disconnect; if ($buf) { chomp($buf); $disconnect = handle_command($buf, $current_client); } if (!$buf || $disconnect) { $select->remove($current_client); @clients = grep {$_->{'handle'} != $current_client} @clients; syslogw(LOG_INFO, "Client %s:%s disconnected. Connected clients: %i.", $current_client->peerhost(), $current_client->peerport(), $select->count()-1); $current_client->close(); } } } # Check for updates due to a changed rssi if (!$legacy_mode) { lock(%devices); my $devices = scalar(keys(%devices)); foreach my $mac (keys(%devices)) { if (abs($devices{$mac}{'reported_rssi'} - $devices{$mac}{'rssi'}) > $rssi_threshold) { if (my @due_clients = grep { $_->{'mac'} eq $mac } @clients) { syslogw(LOG_DEBUG, "Mac address %s needs update due to changed rssi. Old/new rssi: %i/%i, difference: %i, affected clients: %i.", $mac, $devices{$mac}{'reported_rssi'}, $devices{$mac}{'rssi'}, abs($devices{$mac}{'reported_rssi'} - $devices{$mac}{'rssi'}), scalar(@due_clients)); foreach my $client (@due_clients) { $client->{'next_check'} = 0; #now } } } } } # Check for due client updates, cleanup, stats # For performance reasons, a maximum of one task is performed per loop if (my @due_clients = grep { time() >= $_->{'next_check'} } @clients) { foreach my $client (@due_clients) { if (is_present($client)) { my $battery_age = exists($devices{$client->{'mac'}}{'battery_time'}) ? int((time() - $devices{$client->{'mac'}}{'battery_time'})/3600) : 'unknown'; my $send_battery = defined($devices{$client->{'mac'}}{'battery_level'}) && $battery_age ne 'unknown' && $battery_age <= $battery_interval_h * $BATTERY_MAX_AGE_FACTOR; syslogw(LOG_DEBUG, "Sending update for mac address %s, ages: %i/%i, max age: %i, rssi: %i, battery level: %s (age: %s)%s, result: present.", $client->{'mac'}, time()-$devices{$client->{'mac'}}{'timestamp'}, time()-$devices{$client->{'mac'}}{'prevtimestamp'}, $client->{'interval'}, $devices{$client->{'mac'}}{'rssi'}, $devices{$client->{'mac'}}{'battery_level'} // 'unknown', $battery_age, $send_battery ? '' : ' (ignored)' ); printf {$client->{'handle'}} "present;device_name=%s;rssi=%s%s;model=lan-lepresenced;daemon=%s V%s\n", $devices{$client->{'mac'}}{'name'}, $devices{$client->{'mac'}}{'rssi'}, $send_battery ? sprintf(";batteryPercent=%s;batteryPercentAge=%s", $devices{$client->{'mac'}}{'battery_level'} // 'unknown', $battery_age) : '', $ME, $VERSION ; } else { syslogw(LOG_DEBUG, "Sending update for mac address %s, max age: %i, result: absence.", $client->{'mac'}, $client->{'interval'}); printf {$client->{'handle'}} "absence;rssi=unreachable;model=lan-lepresenced;daemon=%s V%s\n", $ME, $VERSION; } if (defined($devices{$client->{'mac'}})) { lock(%devices); $devices{$client->{'mac'}}{'reported_rssi'} = $devices{$client->{'mac'}}{'rssi'}; } $client->{'next_check'} = time() + $client->{'interval'}; } } elsif (time() > $next_cleanup_time) { cleanup_task(); $next_cleanup_time = time() + $CLEANUP_INTERVAL; } elsif (time() > $next_stats_time) { stats_task(); $next_stats_time = time() + ($log_level == LOG_DEBUG ? $STATS_INTERVAL_DEBUG : $STATS_INTERVAL_INFO); } elsif ($debug && time() > $next_dump_time) { dump_task(); $next_dump_time = time() + $DUMP_INTERVAL; } elsif (time() > $next_battery_time) { battery_task($device); $next_battery_time = time() + $battery_interval_h * 60 * 60; } usleep($MAINLOOP_SLEEP_US); } $server_socket->close(); __DATA__ This exists to allow the locking code at the beginning of the file to work and to praise adamk's wisdom.