diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9bea433 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ + +.DS_Store diff --git a/asteriskcontactid.pl b/asteriskcontactid.pl new file mode 100755 index 0000000..1d4e99a --- /dev/null +++ b/asteriskcontactid.pl @@ -0,0 +1,651 @@ +#!/usr/bin/perl +# +# asteriskcontactid.pl +# https://github.com/WillCodeForCats/asterisk-contact-id +# +# A Contact ID handler for Asterisk's AlarmReceiver() application. +# https://wiki.asterisk.org +# +# Uses rcell-smsclient for direct SMS notifications +# https://github.com/WillCodeForCats/rcell-smsclient +# +# + +use strict; +use IO::Dir; +use DBI; +use DateTime::Format::Strptime; +use DateTime::Format::MySQL; +use MIME::Lite; + +my $spoolDir = '/var/spool/asterisk/alarmreceiver'; +my $dbi = "DBI:mysql:host=localhost;database=asterisk"; +my $dbuIser = "asterisk"; +my $dbiPassword = ""; + +my $emailFrom = 'alarmreceiver@example.com'; +my $timezone = 'America/Los_Angeles'; + +# account names +my %accts = ( + 1111 => "Account 1111", + 2222 => "Another Account 2222", + 3333 => "Third Account 3333", +); + +# who to notify per account +# 10-digit cell number for SMS or email address only +my %notify = ( + 1111 => [ + 'user@example.com', '5555551234', + ], + 2222 => [ + 'user@example.com', '5555551234', + 'user2@example.com', '5555551234', + ], + 3333 => [ + 'user@example.com', '5555551234', + 'user3@example.com', '5555551234', + ], +); + +# Contact ID Event Codes +my %events = ( + # 100 - Medical Alarms + 100 => "Medical", + 101 => "Personal Emergency", + 102 => "Fail To Report In", + + # 110 - Fire Alarms + 110 => "Fire", + 111 => "Smoke", + 112 => "Combustion", + 113 => "Water Flow", + 114 => "Heat", + 115 => "Pull Station", + 116 => "Duct", + 117 => "Flame", + 118 => "Near Alarm", + + # 120 - Panic Alarms + 120 => "Panic", + 121 => "Duress", + 122 => "Silent", + 123 => "Audible", + 124 => "Duress - Access Granted", + 125 => "Diress - Egress Granted", + + # 130 - Burglar Alarms + 130 => "Burglary", + 131 => "Perimeter", + 132 => "Interior", + 133 => "24 Hour", + 134 => "Entry/Exit", + 135 => "Day/Night", + 136 => "Outdoor", + 137 => "Tamper", + 138 => "Near Alarm", + 139 => "Intrusion Verifier", + + # 140 - General Alarm + 140 => "General Alarm", + 141 => "Polling Loop Open", + 142 => "Polling Loop Short", + 143 => "Expansion Module Failure", + 144 => "Sensor Tamper", + 145 => "Expansion Module Tamper", + 146 => "Silent Burglary", + 147 => "Sensor Supervision Failure", + + # 150 and 160 - 24 Hour Non-Burglary + 150 => "24 Hour Non-Burglary", + 151 => "Gas Detected", + 152 => "Refrigeration", + 153 => "Loss of Heat", + 154 => "Water Leak", + 155 => "Foil Break", + 156 => "Day Trouble", + 157 => "Low Bottled Gas Level", + 158 => "High temp", + 159 => "Low temp", + 161 => "Loss of air flow", + 162 => "Carbon Monoxide detected", + 163 => "Tank level", + + # 200 and 210 - Fire Supervisory + 200 => "Fire Supervisory", + 201 => "Low Water Pressure", + 202 => "Low CO2", + 203 => "Gate Valve Sensor", + 204 => "Low Water Level", + 205 => "Pump Activated", + 206 => "Pump Failure", + + # 300 and 310 - System Troubles + 300 => "System Trouble", + 301 => "AC Loss", + 302 => "Low System Battery", + 303 => "RAM Checksum Bad", + 304 => "ROM Checksum Bad", + 305 => "System Reset", + 306 => "Panel Programming Changed", + 307 => "Self-Test Failure", + 308 => "System Shutdown", + 309 => "Battery Test Failure", + 310 => "Ground Fault", + 311 => "Battery Missing/Dead", + 312 => "Power Supply Overcurrent", + 313 => "Engineer Reset", + + # 320 - Sounder / Relay Troubles + 320 => "Sounder/Relay Trouble", + 321 => "Bell 1 Trouble", + 322 => "Bell 2 Trouble", + 323 => "Alarm Relay Trouble", + 324 => "Trouble Relay", + 325 => "Reversing Relay Trouble", + 326 => "Notification Appliance Ckt. #3 Trouble", + 327 => "Notification Appliance Ckt. #4 Trouble", + + # 330 and 340 - System Peripheral Trouble + 330 => "System Peripheral Trouble", + 331 => "Polling Loop Open", + 332 => "Polling Loop Short", + 333 => "Expansion Module Failure", + 334 => "Repeater Failure", + 335 => "Local Printer Out Of Paper", + 336 => "Local Printer Failure", + 337 => "Exp. Module DC Loss", + 338 => "Exp. Module Low Batt.", + 339 => "Exp. Module Reset", + 341 => "Exp. Module Tamper", + 342 => "Exp. Module AC Loss", + 343 => "Exp. Module Self-Test Fail", + 344 => "RF Receiver Jam Detect", + + # 350 and 360 - Communication Troubles + 350 => "Communication Trouble", + 351 => "Telco 1 Fault", + 352 => "Telco 2 Fault", + 353 => "Long Range Radio Xmitter Fault", + 354 => "Failure To Communicate Event", + 355 => "Loss Of Radio Supervision", + 356 => "Loss Of Central Polling", + 357 => "Long Range Radio Vswr Problem", + + # 370 - Protection Loop + 370 => "Protection Loop", + 371 => "Protection Loop Open", + 372 => "Protection Loop Short", + 373 => "Fire Trouble", + 374 => "Exit Error Alarm (Zone)", + 375 => "Panic Zone Trouble", + 376 => "Hold-Up Zone Trouble", + 377 => "Swinger Trouble", + 378 => "Cross-Zone Trouble", + + # 380 - Sensor Trouble + 380 => "Sensor Trouble", + 381 => "Loss Of Supervision - RF", + 382 => "Loss Of Supervision - RPM", + 383 => "Sensor Tamper", + 384 => "RF Low Battery", + 385 => "Smoke Detector Hi Sensitivity", + 386 => "Smoke Detector Low Sensitivity", + 387 => "Intrusion Detector Hi Sensitivity", + 388 => "Intrusion Detector Low Sensitivity", + 389 => "Sensor Self-Test Failure", + 391 => "Sensor Watch Trouble", + 392 => "Drift Compensation Error", + 393 => "Maintenance Alert", + + # 400 and 440 and 450 - Open/Close + 400 => "Open/Close", + 401 => "O/C By User", + 402 => "Group O/C", + 403 => "Automatic O/C", + 404 => "Late To O/C ", + 405 => "Deferred O/C", + 406 => "Cancel", + 407 => "Remote Arm/Disarm", + 408 => "Quick Arm", + 409 => "Keyswitch O/C", + 441 => "Armed Stay", + 442 => "Keyswitch Armed Stay", + 450 => "Exception O/C", + 451 => "Early O/C", + 452 => "Late O/C", + 453 => "Failed To Open", + 454 => "Failed To Close", + 455 => "Auto-Arm Failed", + 456 => "Partial Arm", + 457 => "Exit Error (User)", + 458 => "User On Premises", + 459 => "Recent Close", + 462 => "Legal Code Entry", + 463 => "Re-Arm After Alarm", + 464 => "Auto-Arm Time Extended", + 465 => "Panic Alarm Reset", + 466 => "Service On/Off Premises", + + # 410 - Remote Access + 411 => "Callback Request Made", + 412 => "Successful Download/Access", + 413 => "Unsuccessful Access", + 414 => "System Shutdown Command Received", + 415 => "Dialer Shutdown Command Received", + 416 => "Successful Upload", + + # 420 and 430 - Access Control + 421 => "Access Denied", + 422 => "Access Report By User", + 423 => "Forced Access", + 424 => "Egress Denied", + 425 => "Egress Granted", + 426 => "Access Door Propped Open", + 427 => "Access Point Door Status Monitor Trouble", + 428 => "Access Point Request To Exit Trouble", + 429 => "Access Program Mode Entry", + 430 => "Access Program Mode Exit", + 431 => "Access Threat Level Change", + 432 => "Access Relay/Trigger Fail", + 433 => "Access Rte Shunt", + 434 => "Access Dsm Shunt", + + # 500 and 510 - System Disables + 501 => "Access Reader Disable", + + # 520 - Sounder / Relay Disables + 520 => "Sounder/Relay Disable", + 521 => "Bell 1 Disable", + 522 => "Bell 2 Disable", + 523 => "Alarm Relay Disable", + 524 => "Trouble Relay Disable", + 525 => "Reversing Relay Disable", + 526 => "Notification Appliance Ckt. # 3 Disable", + 527 => "Notification Appliance Ckt. # 4 Disable", + + # 530 and 540 - System Peripheral Disables + 531 => "Module Added", + 532 => "Module Removed", + + # 550 and 560 - Communication Disables - + 551 => "Dialer Disabled", + 552 => "Radio Transmitter Disabled", + 553 => "Remote Upload/Download Disabled", + + # 570 - Bypasses + 570 => "Zone/Sensor Bypass", + 571 => "Fire Bypass", + 572 => "24 Hour Zone Bypass", + 573 => "Burg. Bypass", + 574 => "Group Bypass", + 575 => "Swinger Bypass", + 576 => "Access Zone Shunt", + 577 => "Access Point Bypass", + + # 600 and 610 - Test/Misc. + 601 => "Manual Trigger Test Report", + 602 => "Periodic Test Report", + 603 => "Periodic RF Transmission", + 604 => "Fire Test", + 605 => "Status Report To Follow", + 606 => "Listen-In To Follow", + 607 => "Walk Test Mode", + 608 => "Periodic Test - System Trouble Present", + 609 => "Video Xmitter Active", + 611 => "Point Tested OK", + 612 => "Point Not Tested", + 613 => "Intrusion Zone Walk Tested", + 614 => "Fire Zone Walk Tested", + 615 => "Panic Zone Walk Tested", + 616 => "Service Request", + + # 620 - Event Log + 621 => "Event Log Reset", + 622 => "Event Log 50% Full", + 623 => "Event Log 90% Full", + 624 => "Event Log Overflow", + 625 => "Time/Date Reset", + 626 => "Time/Date Inaccurate", + 627 => "Program Mode Entry", + 628 => "Program Mode Exit", + 629 => "32 Hour Event Log Marker", + + # 630 - Scheduling + 630 => "Schedule Change", + 631 => "Exception Schedule Change", + 632 => "Access Schedule Change", + + # 640 - Personnel Monitoring + 641 => "Senior Watch Trouble", + 642 => "Latch-Key Supervision", + + # 650 - Misc. + 651 => "Reserved For Ademco Use", + 652 => "Reserved For Ademco Use", + 653 => "Reserved For Ademco Use", + 654 => "System Inactivity", +); + +# Contact ID Event Qualifiers +my %eventQual = ( + 1 => "New Event or Opening", + 3 => "New Restore or Closing", + 6 => "Previously Reported", +); + +my %eventQualAlarm = ( + 1 => "New", + 3 => "Restored", + 6 => "Previously Reported", +); + +my %eventQualOC = ( + 1 => "Opening", + 3 => "Closing", + 6 => "Previously Reported", +); + +# Contact ID digit value map +my %map = ( + '0' => 10, + '1' => 1, + '2' => 2, + '3' => 3, + '4' => 4, + '5' => 5, + '6' => 6, + '7' => 7, + '8' => 8, + '9' => 9, + 'B' => 11, + 'C' => 12, + 'D' => 13, + 'E' => 14, + 'F' => 15, + ); +my %rmap = reverse %map; + +my $dbh = DBI->connect($dbi, $dbiUser, $dbiPassword) + or die($DBI::errstr); + +my $dir = IO::Dir->new($spoolDir); +if (defined $dir) { + while (defined($_ = $dir->read)) { + next unless /^event/; + print "Processing event file: $_\n"; + processEvents($_); + } +} +else { + print "Failed to open $spoolDir\n"; +} + +$dbh->disconnect; + + +sub processEvents { + my $eventFile = shift; + my $meta = 0; + my $events = 0; + my %metadata; + + # delete after processing (disable for testing) + my $deleteFile = 1; + + # open the file + open(my $fh, '<', "$spoolDir/$eventFile") + or die "Could not open file '$eventFile' $!"; + + # process lines in file + while (<$fh>) { + next if /^\n/; + s/\n//; + + # file has two sections: [metadata] and [events] + if ($_ =~ /^\[metadata\]$/) { + print "Begin metadata...\n"; + $meta = 1; + $events = 0; + next; + } + if ($_ =~ /^\[events\]$/) { + print "Begin events...\n"; + $meta = 0; + $events = 1; + next; + } + + if ($meta) { + s/\r?\n$//; + if (/([^=]+)=(.*)/) { + $metadata{substr(lc($1), 0, 80)} = substr($2, 0, 80); + } + } + + elsif ($events) { + # Translate DTMF into Contact ID values + s/B/E/; # DTMF B is Contact ID E + s/C/F/; # DTMF C is Contact ID F + s/\*/B/; # DTMF * is Contact ID B + s/#/C/; # DTMF # is Contact ID C + s/A/D/; # DTMF A is Contact ID D + + # Contact ID event format + # ACCT MT QXYZ GG CCC S + # + # ACCT = 4 Digit Account number (0-9, B-F) + # MT = Message Type. either 18 (preferred) or 98 (optional) + # Q = Event qualifier + # XYZ = Event code (3 Hex digits 0-9,B-F) + # GG = Group or Partition number (2 Hex digits 0-9, B-F). + # 00 to indicate that no specific group or partition information applies. + # CCC = Zone number (Event reports) or User # (Open / Close reports ) (3 Hex digits 0-9,B-F ). + # 000 to indicate that no specific zone or user information applies + # S = 1 Digit Hex checksum + # (Sum of all message digits + S) MOD 15 = 0 + if ($_ =~ /^([0-9B-F]{4})(18|98)(1|3|6)([0-9B-F]{3})([0-9B-F]{2})([0-9B-F]{3})([0-9B-F]{1})$/) { + + # skip if checksum failed + if (!checksum($7)) { + print "Skipping event $_: checksum failed!\n"; + next; + } + + # skip unknown events from unknown accounts + if (!defined($accts{$1})) { + print "Skipping event $_: unknown account $1\n"; + next; + } + + # insert event into database + storeEvent(\%metadata, $1, $4, $_); + + # process notifications for event + notifyEvent(\%metadata, $1, $3, $4, $5, $6); + + print "Account: $accts{$1}\n"; + print "Qual: $3 ".$eventQual{$3}."\n"; + print "Event: $4 ".$events{$4}."\n"; + print "Group: $5 Zone: $6\n"; + print "\n"; + + } + else { + print "Bad data: $_\n"; + } + } + + else { + next; + } + } + + close $fh; + + if ($deleteFile) { + print "delete $spoolDir/$eventFile\n"; + unlink "$spoolDir/$eventFile"; + } + +} + +sub notifyEvent { + my $metadata = shift; + my $account = shift; + my $qual = shift; + my $event = shift; + my $group = shift; + my $zone = shift; + + # don't notify for these events + return if ($event == '602'); # routine test + + # start with undefined message + my $notifyString = undef; + + # 100 series - Alarms + if ($event =~ /1[0-9]{2}/) { + $notifyString = sprintf("%s\nAlarm: %s \nZone: %s (%s)", + $accts{$account}, $events{$event}, $zone, $eventQualAlarm{$qual}); + } + + # 200 Series - Fire Supervisory + if ($event =~ /2[0-9]{2}/) { + $notifyString = sprintf("%s\nAlarm: %s \nZone: %s (%s)", + $accts{$account}, $events{$event}, $zone, $eventQualAlarm{$qual}); + } + + # 300 Series - Troubles + if ($event =~ /3[0-9]{2}/) { + if ($event == '350') { + #350 => "Communication Trouble" + $notifyString = sprintf("%s %s Line %s (%s)", + $accts{$account}, $events{$event}, $zone, $eventQualAlarm{$qual}); + } + elsif ($event == '354') { + #354 => "Failure To Communicate Event" + $notifyString = sprintf("%s %s Account %s (%s)", + $accts{$account}, $events{$event}, $zone, $eventQualAlarm{$qual}); + } + elsif ($zone != 000) { + $notifyString = sprintf("%s\nAlarm: %s \nZone: %s (%s)", + $accts{$account}, $events{$event}, $zone, $eventQualAlarm{$qual}); + } + elsif ($group != 00) { + $notifyString = sprintf("%s\nAlarm: %s \nModule: %s (%s)", + $accts{$account}, $events{$event}, $group, $eventQualAlarm{$qual}); + } + else { + $notifyString = sprintf("%s\n%s (%s)", $accts{$account}, $events{$event}, $eventQualAlarm{$qual}); + } + } + + # 400 Series - Open/Close and Access + if ($event =~ /4[0-9]{2}/) { + if ($zone ne '000' && $group ne '00') { + $notifyString = sprintf("%s\n%s: %s %s Partition %s", $accts{$account}, $eventQualOC{$qual}, $events{$event}, $zone, $group); + } + else { + $notifyString = sprintf("%s\n%s: %s", $accts{$account}, $eventQualOC{$qual}, $events{$event}); + } + } + + # 601 Manual Trigger Test Report + # 608 Periodic Test - System Trouble Present + if ($event == '608' || $event == '601') { + $notifyString = sprintf("%s\n%s", $accts{$account}, $events{$event}); + } + + # append timestamp + if (defined($notifyString)) { + $notifyString .= "\n$$metadata{'timestamp'}"; + } + + if (defined($notifyString)) { + #print "NOTIFY: $notifyString\n"; + foreach (@{$notify{$account}}) { + if (/^[0-9]{10}$/) { + print "Notify SMS: $_\n"; + open(my $sms, '|-', "/usr/local/bin/smsclient.pl -p $_") + or die "Could not open smsclient.pl' $!"; + print $sms $notifyString; + close $sms; + } + else { + print "Notify Email $_\n"; + my $msg = MIME::Lite->new( + From => $emailFrom, + To => $_, + Subject => "Alarm Event for $accts{$account} at $$metadata{'timestamp'}", + Type => 'text/plain; charset=utf-8', + Data => $notifyString + ); + $msg->add("Auto-Submitted" => "auto-generated"); + $msg->send; + } + } + } + +} + +# stores an event in the database +sub storeEvent { + my $metadata = shift; + my $account = shift; + my $cidevent = shift; + my $event = shift; + + print $$metadata{'timestamp'}."\n"; + + # parse timestamp from file metadata + # Tue May 02, 2017 @ 21:00:01 PDT + my $strp = DateTime::Format::Strptime->new( + pattern => '%a %b %d, %Y @ %H:%M:%S', + time_zone => $timezone + ); + my $dt = $strp->parse_datetime($$metadata{'timestamp'}); + + # format timestamp for mysql + my $timestamp = DateTime::Format::MySQL->format_datetime($dt); + + # insert data + $dbh->do(q{ + INSERT INTO alarmreceiver + (timestamp, account, event, protocol, callingfrom, callername) + VALUES (?, ?, ?, ?, ?, ?) + }, + undef, + $timestamp, $account, $event, $$metadata{'protocol'}, $$metadata{'callingfrom'}, $$metadata{'callername'} + ) or die($DBI::errstr); + + # 601 Manual Trigger Test Report + # 602 Periodic Test Report + if ($cidevent == '602' || $cidevent == '601') { + $dbh->do(q{ + UPDATE alarmreceiver_test + SET timestamp = ? + WHERE account = ? + }, + undef, + $timestamp, $account + ) or die($DBI::errstr); + } +} + +# Contact ID Checksum +sub checksum { + # (Sum of all message digits + S) MOD 15 = 0 + + my $sum = 0; + foreach my $c (split //) { + $sum += $map{$c}; + } + + # if result is 0, use digit F for checksum. + if ($sum == 0) { $sum = $map{'F'}; } + + # return 1 if checksum ok, 0 if not + return ($sum % 15) ? 0 : 1; +} diff --git a/schema/.sqlfluff b/schema/.sqlfluff new file mode 100644 index 0000000..8a4ad1e --- /dev/null +++ b/schema/.sqlfluff @@ -0,0 +1,3 @@ +[sqlfluff] +dialect = mysql +exclude_rules = LT01, CP01 diff --git a/schema/asteriskcontactid.sql b/schema/asteriskcontactid.sql new file mode 100644 index 0000000..f81a6b6 --- /dev/null +++ b/schema/asteriskcontactid.sql @@ -0,0 +1,30 @@ +-- +-- Table structure for table `alarmreceiver` +-- + +DROP TABLE IF EXISTS `alarmreceiver`; +CREATE TABLE `alarmreceiver` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `timestamp` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00', + `account` smallint(5) unsigned NOT NULL, + `event` varchar(16) NOT NULL, + `protocol` enum('ADEMCO_CONTACT_ID') NOT NULL, + `callingfrom` varchar(80) NOT NULL DEFAULT '', + `callername` varchar(80) NOT NULL DEFAULT '', + PRIMARY KEY (`id`), + KEY `account` (`account`), + KEY `callingfrom` (`callingfrom`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +-- +-- Table structure for table `alarmreceiver_test` +-- + +DROP TABLE IF EXISTS `alarmreceiver_test`; + +CREATE TABLE `alarmreceiver_test` ( + `account` smallint(5) unsigned NOT NULL, + `test_interval` smallint(5) unsigned NOT NULL DEFAULT '24', + `timestamp` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00', + PRIMARY KEY (`account`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8;