lepresenced 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  1. #!/usr/bin/perl
  2. ##############################################################################
  3. # $Id: lepresenced 11515 2016-05-24 19:00:30Z preinhardt $
  4. ##############################################################################
  5. #
  6. # lepresenced
  7. #
  8. # checks for one or multiple bluetooth *low energy* devices for their
  9. # presence state and reports it to the 73_PRESENCE.pm module.
  10. #
  11. # Copyright (C) 2015-2016 P. Reinhardt, pr-fhem (at) reinhardtweb (dot) de
  12. #
  13. # This script free software; you can redistribute it and/or modify
  14. # it under the terms of the GNU General Public License as published by
  15. # the Free Software Foundation; either version 2 of the License, or
  16. # (at your option) any later version.
  17. #
  18. # The GNU General Public License can be found at
  19. # http://www.gnu.org/copyleft/gpl.html.
  20. # A copy is found in the textfile GPL.txt and important notices to the
  21. # license from the author is found in LICENSE.txt distributed with these
  22. # scripts.
  23. #
  24. # This script is distributed in the hope that it will be useful,
  25. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  26. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  27. # GNU General Public License for more details.
  28. #
  29. ##############################################################################
  30. use strict;
  31. use warnings;
  32. use threads;
  33. use threads::shared;
  34. use IO::Select;
  35. use IO::Socket::INET;
  36. use Getopt::Long;
  37. use Sys::Syslog qw(:standard :macros);
  38. use Time::HiRes qw(usleep);
  39. use Net::Server::Daemonize qw(daemonize);
  40. use constant RETRY_SLEEP => 1;
  41. use constant INET_RECV_BUFFER => 1024;
  42. use constant MAINLOOP_SLEEP_US => 100 * 1000;
  43. use constant CLEANUP_INTERVAL => 15 * 60;
  44. use constant CLEANUP_MAX_AGE => 30 * 60;
  45. use constant STATS_INTERVAL => 5 * 60;
  46. use constant ME => 'lepresenced';
  47. use constant VERSION => '0.6';
  48. use constant PIDFILE => '/var/run/' . ME . '.pid';
  49. my %devices :shared;
  50. my @clients = ();
  51. my $syslog_level;
  52. sub syslogw {
  53. return if (scalar(@_) < 2);
  54. if (scalar(@_)==2) {
  55. my ($priority, $message) = @_;
  56. syslog($priority, "[tid:%i] %s: $message", threads->self()->tid(), (caller(1))[3] // 'main') if ($syslog_level >= $priority);
  57. } else {
  58. my ($priority, $format, @args) = @_;
  59. syslog($priority, "[tid:%i] %s: $format", threads->self()->tid(), (caller(1))[3] // 'main', @args) if ($syslog_level >= $priority);
  60. }
  61. }
  62. sub error_exit {
  63. my $exit_code = shift();
  64. syslogw(LOG_ERR, @_);
  65. foreach my $thread (threads->list()) {
  66. $thread->exit(0);
  67. }
  68. exit ($exit_code);
  69. }
  70. sub usage_exit() {
  71. print("usage:\n");
  72. printf("\t%s --bluetoothdevice <bluetooth device> --listenaddress <listen address> --listenport <listen port> --loglevel <log level> --daemon\n", ME);
  73. printf("\t%s -b <bluetooth device> -a <listen address> -p <listen port> -l <log level> -d\n", ME);
  74. print("valid log levels:\n");
  75. print("\tLOG_CRIT, LOG_ERR, LOG_WARNING, LOG_NOTICE, LOG_INFO, LOG_DEBUG. Default: LOG_INFO\n");
  76. print("examples:\n");
  77. printf("\t%s --bluetoothdevice hci0 --listenaddress 127.0.0.1 --listenport 5333 --daemon\n", ME);
  78. printf("\t%s --loglevel LOG_DEBUG --daemon\n", ME);
  79. closelog();
  80. exit(1);
  81. }
  82. sub parse_options() {
  83. my $device = "hci0";
  84. my $daemonize = 0;
  85. my $listen_address = "0.0.0.0";
  86. my $listen_port = "5333";
  87. my $syslog_level = "LOG_INFO";
  88. GetOptions(
  89. 'bluetoothdevice|device|b=s' => \$device,
  90. 'daemon|daemonize|d!' => \$daemonize,
  91. 'listenaddress|address|a=s' => \$listen_address,
  92. 'listenport|port|p=i' => \$listen_port,
  93. 'loglevel|l=s' => \$syslog_level
  94. ) or usage_exit();
  95. $listen_address =~ m/^\d+\.\d+\.\d+\.\d+$/ or usage_exit();
  96. $syslog_level =~ m/^LOG_(EMERG|ALERT|CRIT|ERR|WARNING|NOTICE|INFO|DEBUG)$/ or usage_exit();
  97. $syslog_level = eval($syslog_level);
  98. return ($device, $daemonize, $listen_address, $listen_port, $syslog_level);
  99. }
  100. sub update_device($$) {
  101. my ($mac, $name) = @_;
  102. $mac = lc($mac);
  103. {
  104. lock(%devices);
  105. unless (exists $devices{$mac}) {
  106. my %device :shared;
  107. $devices{$mac} = \%device;
  108. }
  109. $devices{$mac}{'name'} = $name unless ($name eq '(unknown)' && defined($devices{$mac}{'name'}));
  110. $devices{$mac}{'timestamp'} = time();
  111. }
  112. }
  113. sub bluetooth_thread($) {
  114. my ($device) = @_;
  115. my $hcitool;
  116. for(;;) {
  117. 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!');
  118. while (<$hcitool>) {
  119. chomp($_);
  120. if ($_ eq 'LE Scan ...') {
  121. syslogw(LOG_INFO, "Received '%s'.", $_);
  122. } elsif (my ($fbmac, $fbname) = $_ =~ /^([\da-f]{2}:[\da-f]{2}:[\da-f]{2}:[\da-f]{2}:[\da-f]{2}:[\da-f]{2})\s(.*)$/i) {
  123. #syslogw(LOG_DEBUG, "Received advertisement from bluetooth mac address '%s' with name '%s'.", $fbmac, $fbname);
  124. update_device($fbmac, $fbname);
  125. } elsif (
  126. $_ =~ m/^Set scan parameters failed: Input\/output error$/ ||
  127. $_ =~ m/^Invalid device: Network is down$/
  128. ) {
  129. syslogw(LOG_WARNING, "Received '%s', resetting...", $_);
  130. system(sprintf('hciconfig %s reset', $device));
  131. } else {
  132. syslogw(LOG_WARNING, "Received unknown output: '%s'!", $_);
  133. }
  134. }
  135. syslogw(LOG_WARNING, "hcitool exited, retrying...");
  136. close($hcitool);
  137. sleep(RETRY_SLEEP);
  138. }
  139. }
  140. sub handle_command($$) {
  141. my ($buf, $current_client) = @_;
  142. if (my ($mac, undef, $interval) = $buf =~ m/^\s*(([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2})\s*\|\s*(\d+)\s*$/) {
  143. $mac = lc($mac);
  144. if (my ($client) = grep { $current_client == $_->{'handle'} } @clients) {
  145. syslogw(LOG_INFO, "Received query update for mac address %s, interval: %i by client %s:%i.", $mac, $interval, $current_client->peerhost(), $current_client->peerport());
  146. $client->{'mac'} = $mac;
  147. $client->{'interval'} = $interval;
  148. $client->{'next_check'} = 0; #now
  149. } else {
  150. 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());
  151. my %new_client;
  152. $new_client{'handle'} = $current_client;
  153. $new_client{'mac'} = $mac;
  154. $new_client{'interval'} = $interval;
  155. $new_client{'next_check'} = 0; #now
  156. push(@clients, \%new_client);
  157. }
  158. print $current_client "command accepted\n"
  159. } elsif ($buf =~ m/^\s*now\s*$/) {
  160. syslogw(LOG_DEBUG, "Received now command from client %s:%i. Scheduling update...", $current_client->peerhost(), $current_client->peerport());
  161. foreach my $client (grep { $_->{'handle'} == $current_client } @clients) {
  162. $client->{'next_check'} = 0; #now
  163. }
  164. print $current_client "command accepted\n"
  165. } elsif ($buf =~ m/^\s*stop\s*$/) {
  166. # Stop does not make sense when scanning permanently
  167. syslogw(LOG_DEBUG, "Received stop command from client %s:%i. Pretending to care and ignoring...", $current_client->peerhost(), $current_client->peerport());
  168. print $current_client "no command running\n" # ToDo: Does the FHEM module even care?
  169. } else {
  170. syslogw(LOG_WARNING, "Received unknown command: '%s'.", $buf);
  171. }
  172. }
  173. sub stats_task() {
  174. my ($min_age, $max_age, $devices);
  175. {
  176. lock(%devices);
  177. $devices = scalar(keys(%devices));
  178. foreach my $mac (keys(%devices)) {
  179. my $age = time() - $devices{$mac}{'timestamp'};
  180. $min_age = $age if (!defined($min_age) || $age < $min_age);
  181. $max_age = $age if (!defined($max_age) || $age > $max_age);
  182. }
  183. }
  184. syslogw(LOG_INFO, "Active clients: %i, known devices: %i (min/max age: %s/%s)", scalar(@clients), $devices, $min_age // '%', $max_age // '%');
  185. }
  186. sub cleanup_task() {
  187. my $start_time = time();
  188. my $deleted_items = 0;
  189. {
  190. lock(%devices);
  191. foreach my $mac (keys(%devices)) {
  192. my $age = time() - $devices{$mac}{'timestamp'};
  193. if ($age > CLEANUP_MAX_AGE) {
  194. $deleted_items++;
  195. syslogw(LOG_DEBUG, "Deleting device %s.", $mac);
  196. delete($devices{$mac});
  197. }
  198. }
  199. }
  200. syslogw(LOG_INFO, "Cleanup finished, deleted %i devices in %i seconds.", $deleted_items, time() - $start_time);
  201. }
  202. openlog(ME, 'pid', LOG_USER);
  203. (my $device, my $daemonize, my $listen_address, my $listen_port, $syslog_level) = parse_options();
  204. local $SIG{INT} = local $SIG{TERM} = local $SIG{HUP} = sub {
  205. syslogw(LOG_NOTICE, "Caught signal, cleaning up and exiting...");
  206. unlink(PIDFILE) if (-e PIDFILE);
  207. closelog();
  208. exit(1);
  209. };
  210. syslogw(LOG_NOTICE, "Version %s started (device: %s, listen addr: %s, listen port: %s, daemonize: %i, log level: %i).",
  211. VERSION, $device, $listen_address, $listen_port, $daemonize, $syslog_level);
  212. daemonize('root', 'root', PIDFILE) if $daemonize;
  213. my $bluetooth_thread = threads->new(\&bluetooth_thread, $device)->detach();
  214. my $current_client;
  215. $| = 1;
  216. my $server_socket = new IO::Socket::INET (
  217. LocalHost => $listen_address,
  218. LocalPort => $listen_port,
  219. Proto => 'tcp',
  220. Listen => 5,
  221. ReuseAddr => 1,
  222. );
  223. $server_socket or error_exit(1, "ERROR: Unable to create TCP server: $!, Exiting.");
  224. my $select = IO::Select->new($server_socket) or error_exit(1, "ERROR: Unable to select: $!, Exiting.");
  225. my $next_stats_time = 0;
  226. my $next_cleanup_time = 0;
  227. for(;;) {
  228. # Process INET socket
  229. foreach my $current_client ($select->can_read(0)) {
  230. if($current_client == $server_socket) {
  231. my $client_socket = $server_socket->accept();
  232. $select->add($client_socket);
  233. syslogw(LOG_INFO, "Connection from %s:%s. Connected clients: %i.", $client_socket->peerhost(), $client_socket->peerport(), $select->count()-1);
  234. } else {
  235. sysread ($current_client, my $buf, INET_RECV_BUFFER);
  236. if ($buf) {
  237. chomp($buf);
  238. handle_command($buf, $current_client);
  239. } else {
  240. $select->remove($current_client);
  241. @clients = grep {$_->{'handle'} != $current_client} @clients;
  242. syslogw(LOG_INFO, "Client %s:%s disconnected. Connected clients: %i.", $current_client->peerhost(), $current_client->peerport(), $select->count()-1);
  243. $current_client->close();
  244. }
  245. }
  246. }
  247. # Check for due client updates, cleanup, stats
  248. # For performance reasons, a maximum of one task is performed per loop
  249. if (my @due_clients = grep { time() >= $_->{'next_check'} } @clients) {
  250. foreach my $client (@due_clients) {
  251. if (
  252. defined($devices{$client->{'mac'}}) &&
  253. time()-$devices{$client->{'mac'}}{timestamp} <= $client->{'interval'}
  254. ) {
  255. syslogw(LOG_DEBUG, "Sending update for mac address %s, age: %i, max age: %i, result: present.", $client->{'mac'}, time()-$devices{$client->{'mac'}}{timestamp}, $client->{'interval'});
  256. printf {$client->{'handle'}} "present;%s\n", $devices{$client->{'mac'}}{name}
  257. } else {
  258. syslogw(LOG_DEBUG, "Sending update for mac address %s, max age: %i, result: absence.", $client->{'mac'}, $client->{'interval'});
  259. print {$client->{'handle'}} "absence\n"
  260. }
  261. $client->{'next_check'} = time() + $client->{'interval'};
  262. }
  263. } elsif (time() > $next_cleanup_time) {
  264. cleanup_task();
  265. $next_cleanup_time = time() + CLEANUP_INTERVAL;
  266. } elsif (time() > $next_stats_time) {
  267. stats_task();
  268. $next_stats_time = time() + STATS_INTERVAL;
  269. }
  270. usleep(MAINLOOP_SLEEP_US);
  271. }
  272. $server_socket->close();