File Coverage

blib/lib/Weather/NOAA/Alert.pm
Criterion Covered Total %
statement 10 12 83.3
branch n/a
condition n/a
subroutine 4 4 100.0
pod n/a
total 14 16 87.5


line stmt bran cond sub pod time code
1             package Weather::NOAA::Alert;
2              
3 1     1   24542 use strict;
  1         3  
  1         42  
4 1     1   6 use warnings;
  1         3  
  1         30  
5              
6 1     1   914 use LWP::Simple;
  1         94217  
  1         10  
7 1     1   916 use XML::Twig;
  0            
  0            
8             use Data::Dumper;
9             #use Carp;
10              
11             #use Exporter;
12             #our @ISA = qw(Exporter);
13             #@EXPORT = qw//;
14             #@EXPORT_OK = qw//;
15              
16             =head1 NAME
17              
18             Weather::NOAA::Alert - Polling and parsing of NOAA CAP 1.1 Alerts
19              
20             =head1 VERSION
21              
22             Version 0.90
23              
24             =cut
25              
26             our $VERSION = '0.90';
27              
28             =head1 SYNOPSIS
29            
30             my $alert = Weather::NOAA::Alert->new(['TXZ104', 'TXC082', 'TXZ097']);
31             my $events = $alert->get_events();
32             my ($errorCount, $eventCount, $addCount, $deleteCount) = $alert->poll_events();
33            
34             =head1 DESCRIPTION
35              
36             Weather::NOAA::Alert will retrieve and parse the NOAA National Weather Service
37             ATOM and CAP 1.1 feeds for the specified forecast zones. It is designed to
38             cache previously polled CAP items. The overall process is to get the
39             requested ATOM feed, get any CAP entries that are not in the cache, and store
40             the alerts.
41              
42             You can find the zone list and more information about NOAA watches, warnings,
43             and advisories at the following sites:
44              
45             =over 4
46              
47             =item * Zone List: L
48              
49             =item * Zone Maps: L
50              
51             =back
52            
53             =head1 EXAMPLE
54            
55             use Weather::NOAA::Alert;
56             use Data::Dumper;
57            
58             my $SLEEP_SECS = 60; #Poll every 1 minute
59            
60             #my $alert = Weather::NOAA::Alert->new(['US']);
61             my $alert = Weather::NOAA::Alert->new(['TXC085', 'TXZ097']);
62             $alert->printLog(1);
63             $alert->errorLog(1);
64              
65             my $events = $alert->get_events();
66            
67             while (1) {
68             my ($errorCount, $eventCount, $addCount, $deleteCount)
69             = $alert->poll_events();
70            
71             print Dumper( $events) . "\n";
72            
73             print "Tracking $eventCount " . ($eventCount ==1 ? "event" : "events");
74             print " $addCount added, $deleteCount deleted";
75             print ", $errorCount " . ($errorCount ==1 ? "error" : "errors");
76             print "\n";
77            
78             print "Sleeping $SLEEP_SECS seconds\n-----------------\n\n";
79             sleep($SLEEP_SECS);
80             }
81              
82              
83              
84             =head1 METHODS
85              
86             =head2 B - Create a new Weather::NOAA::Alert object
87              
88             $alert = Weather::NOAA:Alert->new(@zones);
89             $alert = Weather::NOAA:Alert->new();
90              
91             An array reference may be provided with the list of NOAA forecast zones
92             that should be polled. If the list is not supplied then you must call
93             C<$object-Ezones(@zones)> to set the zone list prior to calling
94             C<$object-Epoll_events()>.
95              
96             =cut
97              
98             sub new {
99             my $class = shift;
100             my $self = {};
101            
102             $self->{events} = {};
103             $self->{formatTime} = 0;
104             $self->{formatAsterisk} = 0;
105             $self->{printLog} = 0;
106             $self->{printActions} = 0;
107             $self->{errorLog} = 0;
108             $self->{diagDump} = 0;
109             $self->{diagFile} = undef;
110             $self->{atomURLZone} = "http://alerts.weather.gov/cap/wwaatmget.php?x=";
111             $self->{atomURLUS} = "http://alerts.weather.gov/cap/us.atom";
112              
113             #http://alerts.weather.gov/cap/us.atom
114             #http://alerts.weather.gov/cap/wwaatmget.php?x=TXZ104
115             #http://alerts.weather.gov/cap/wwacapget.php?x=CO20110424120100WinterStormWarning20110425120000CO.GJTWSWGJT.790bacf3c14f2dc0e2d5149cf668f95c
116            
117             bless( $self, $class);
118            
119             my $zoneList = shift;
120             $self->zones($zoneList) if defined $zoneList;
121            
122             return $self;
123             }
124              
125             =head2 B - Set the monitored zone list
126            
127             $object->zones([zone1, zone2, ...]);
128             @zones = $object->zones();
129              
130             Setting the zone list will overwrite the existing list with the
131             supplied list. If called with no arguments, returns a reference
132             to the current zone array. To return data for all zones use "US".
133              
134             =cut
135              
136             sub zones { $_[0]->{zones}=$_[1] if defined $_[1]; return $_[0]->{zones}; }
137              
138             =head2 B - get a reference to the alert object events hash
139              
140             %events = $object->get_events();
141              
142             This is the primary output of the Weather::NOAA::Alert module. The data
143             structure is the result of parsing the NOAA CAP 1.1 objects retrieved for
144             the specified zones. The events hash is indexed first by zone, then by
145             the CAP ID.
146              
147             Remembers past events in a data structure hash consisting of:
148             C<{zone}-E{CAP id}-E{'delete'}>
149             C< -E{'actionTime'}>
150             C< -E{'event'}>
151             C< -E{'certainty'}>
152             C< -E{'senderName'}>
153             C< -E{'urgency'}>
154             C< -E{'instruction'}>
155             C< -E{'description'}>
156             C< -E{'category'}>
157             C< -E{'severity'}>
158             C< -E{'effective'}>
159             C< -E{'headline'}>
160             C< -E{'expires'}>
161              
162             Note that the hash keys are dynamically created from the section of the
163             event. If NOAA adds, renames, or removes an XML parameter it will also
164             change in the structure. In addition, nothing is currently parsed from the
165             event header. There are XML parameters in the header that might be
166             interesting to collect but I didn't have a use for any of that data. Future
167             module revisions might include more of the NOAA data.
168              
169             =cut
170              
171             sub get_events { return $_[0]->{events}; }
172              
173             =head1 SETTINGS
174              
175             =head2 B - Add colons to strings that look like time stamps
176              
177             $object->formatTime( [1 | 0] );
178             $curr_setting = $object->formatTime();
179              
180             When called without parameters will return the current setting.
181              
182             =cut
183              
184             sub formatTime { $_[0]->{formatTime}=$_[1] if defined $_[1]; return $_[0]->{formatTime}; }
185              
186             =head2 B - Strip asterisks from description and information tags
187              
188             $object->formatAsterisk( [1 | 0] );
189             $curr_setting = $object->formatAsterisk();
190              
191             When called without parameters will return the current setting.
192              
193             =cut
194              
195             sub formatAsterisk { $_[0]->{formatAsterisk}=$_[1] if defined $_[1]; return $_[0]->{formatAsterisk}; }
196              
197             =head2 B - Print basic status information while retrieving cap entities
198              
199             $object->printLog( [1 | 0] );
200             $curr_setting = $object->printLog();
201              
202             When called without parameters will return the current setting.
203              
204             =cut
205              
206             sub printLog { $_[0]->{printLog}=$_[1] if defined $_[1]; return $_[0]->{printLog}; }
207              
208             =head2 B - Print the cap ID for every entry added or deleted
209              
210             $object->printActions( [1 | 0] );
211             $curr_setting = $object->printActions();
212              
213             When called without parameters will return the current setting.
214              
215             =cut
216              
217             sub printActions { $_[0]->{printActions}=$_[1] if defined $_[1]; return $_[0]->{printActions}; }
218              
219             =head2 B - Print error descriptions
220              
221             $object->errorLog( [1 | 0] );
222             $curr_setting = $object->errorLog();
223              
224             When called without parameters will return the current setting.
225              
226             =cut
227              
228             sub errorLog { $_[0]->{errorLog}=$_[1] if defined $_[1]; return $_[0]->{errorLog}; }
229              
230             =head2 B - Save all atom content to file
231              
232             $object->diagDump( [1 | 0] );
233             $curr_setting = $object->diagDump();
234              
235             Dumps all atom files received to the file diagnostics.txt in the
236             current directory. When called without parameters will return
237             the current setting.
238              
239             =cut
240              
241             sub diagDump { $_[0]->{diagDump}=$_[1] if defined $_[1]; return $_[0]->{diagDump}; }
242              
243             =head2 B - Sets the Atom URL for zones
244              
245             $object->atomURLZone( $url );
246             $curr_setting = $object->atomURLZone();
247              
248             When called without parameters will return the current setting. The
249             default setting is:
250              
251             C
252              
253             =cut
254              
255             sub atomURLZone { $_[0]->{atomURLZone}=$_[1] if defined $_[1]; return $_[0]->{atomURLZone}; }
256              
257             =head2 B - Sets the Atom URL when specifying zone "US"
258            
259             $object->atomURLUS( $url );
260             $curr_setting = $object->atomURLUS();
261              
262             When called without parameters will return the current setting. The
263             default setting is:
264              
265             C
266              
267             =cut
268              
269             sub atomURLUS { $_[0]->{atomURLUS}=$_[1] if defined $_[1]; return $_[0]->{atomURLUS}; }
270              
271             =head2 B - Returns the current module version
272              
273             my $version = Weather::NOAA::Alert->VERSION;
274              
275             =cut
276              
277             sub VERSION { return $VERSION; }
278              
279             =head2 B - Poll NWS Public Alerts
280              
281             my ($errorCount, $eventCount, $addCount, $deleteCount)
282             = $object->poll_events();
283              
284             Polls the National Weather Service Public Alerts and updates the events hash
285             for each event. Returns an array of counts:
286              
287             =over 4
288              
289             =item * $errorCount - The number of errors encountered while pulling the Atom files
290              
291             =item * $eventCount - The number of actively tracked events
292              
293             =item * $addCount - The number of events added on this poll
294              
295             =item * $deleteCount - The number of events deleted on this poll
296              
297             =back
298              
299             =cut
300              
301             sub poll_events {
302             my ($self) = @_;
303             my ($errorCount, $eventCount, $addCount, $deleteCount) = (0, 0, 0, 0);
304              
305             if( !defined( $self->{diagFile}) and $self->{diagDump}) {
306             open($self->{diagFile}, ">>diagnostics.txt");
307             }
308            
309             foreach my $zone (@{$_[0]->{zones}}) {
310             print "Pulling ATOM feed for zone $zone\n" if($self->{printLog});
311             my $atomTwig= new XML::Twig(
312             TwigRoots => {'entry' => 1},
313             TwigHandlers => {'entry' => \&atomInfoHandler},
314             pretty_print => 'indented',
315             );
316            
317             my $atomURL;
318             if( $zone ne 'US') {
319             $atomURL = $self->{atomURLZone} . $zone;
320             } else {
321             $atomURL = $self->{atomURLUS};
322             }
323             my $atomContent;
324             my $firstCAP = 1;
325             if ($atomContent = get($atomURL)) {
326             if( defined $self->{diagFile} and $self->{diagDump}) {
327             print( {$self->{diagFile}} "Time: " . scalar(localtime()));
328             print( {$self->{diagFile}} "\natomURL:: $atomURL\n");
329             print( {$self->{diagFile}} Dumper( \$atomContent) . "\n");
330             }
331              
332             if ($atomTwig->safe_parse($atomContent)) {
333            
334             #Set the delete flag for all events in this zone
335             foreach my $capId (keys( %{$_[0]->{events}->{$zone}}) ) {
336             $_[0]->{events}->{$zone}{$capId}{'delete'} = 1;
337             }
338            
339             my @atomItems = $atomTwig->root->children;
340             foreach my $atomItem (@atomItems) {
341             my $capId = $atomItem->first_child('id')->text;
342              
343             if( $capId eq $atomURL) {
344             #http://alerts.weather.gov/cap/wwaatmget.php?x=TXZ104
345             #XXX-Fragile code. How do we unambiguously determine
346             #that this entry is a "null" entry? IMO, there should
347             #not be an "" at all... That would tell us!
348             print "There are no active watches, warnings or advisories for zone $zone\n" if($self->{printLog});
349             next;
350             } elsif( $capId =~ /^http:\/\/alerts\.weather\.gov\/cap\/\w{2}\.atom/) {
351             #http://alerts.weather.gov/cap/ct.atom
352             #ignore; these occur when parsing the entire US zone.
353             #XXX-Fragile code. How should one unambiguously
354             #determine that this entry is not a real CAP event?
355             next;
356             }
357            
358             if( !exists($_[0]->{events}->{$zone}{$capId})) {
359             if( $firstCAP) {
360             print "Pulling new CAP entries: " if($self->{printLog} and not $self->{printActions});
361             $firstCAP = 0;
362             }
363             print "." if($self->{printLog} and not $self->{printActions});
364            
365             if( $_[0]->retrieveCAP($zone, $capId)) {
366             #XXX- Fragile code! why does NOAA make me match on
367             #some random string for expired events??
368             if( $_[0]->{events}->{$zone}{$capId}{'description'} =~
369             /alert has expired/ ) {
370             delete($_[0]->{events}->{$zone}{$capId});
371             print "Exp: $capId\n" if($self->{printActions});
372             } else {
373             $eventCount++;
374             $addCount++;
375             print "Add: $capId\n" if($self->{printActions});
376             }
377             } else {
378             $errorCount++;
379             }
380             } else {
381             #Still exists so reset the delete flag
382             $_[0]->{events}->{$zone}{$capId}{'delete'} = 0;
383             $eventCount++;
384             }
385             }
386            
387             #Delete events that were not reset
388             foreach my $capId (keys( %{$_[0]->{events}->{$zone}}) ) {
389             if( $_[0]->{events}->{$zone}{$capId}{'delete'}) {
390             $deleteCount++;
391             delete($_[0]->{events}->{$zone}{$capId});
392             print "Del: $capId\n" if($self->{printActions});
393             }
394             }
395              
396             print "No new events" if( $firstCAP and $self->{printLog});
397             print "\n" if($self->{printLog});
398            
399             } else {
400             print "Error parsing ATOM file for zone $zone :: $0\n" if($self->{errorLog});
401             print $atomContent . "\n" if($self->{errorLog});
402             $errorCount++;
403             } #if ($twig->safe_parse($atomContent))
404             } else {
405             print "Failed to retrieve ATOM file for zone $zone\n" if($self->{errorLog});
406             $errorCount++;
407             } #if ($atomContent = get($atomURL))
408             } #foreach my $zone (@zones)
409            
410             if( defined( $self->{diagFile}) and $self->{diagDump}) {
411             close( $self->{diagFile});
412             $self->{diagFile} = undef;
413             }
414            
415             return( $errorCount, $eventCount, $addCount, $deleteCount);
416             }
417              
418             sub retrieveCAP {
419             my ($self, $zone, $capId) = @_;
420            
421             my $capTwig= new XML::Twig(
422             TwigRoots => {'info' => 1},
423             # TwigHandlers => {'info' => \&capInfoHandler},
424             TwigHandlers => {'info' => sub { capInfoHandler( $self->{formatTime}, $self->{formatAsterisk}, @_) } },
425             pretty_print => 'indented',
426             );
427            
428             my $capContent;
429             if ($capContent = get($capId)) {
430            
431             if( defined( $self->{diagFile}) and $self->{diagDump}) {
432             print( {$self->{diagFile}} "capId:: $capId\n");
433             print( {$self->{diagFile}} Dumper( \$capContent) . "\n");
434             }
435            
436             if ($capTwig->safe_parse($capContent)) {
437            
438             #Parse only the first enclosure
439             #Loop through it appending items to the event hash
440             foreach my $child ($capTwig->root->first_child->children) {
441             #ignore nested items: eventCode, parameter, area; they're too hard :^)
442             if($child->tag ne 'eventCode' and $child->tag ne 'parameter' and $child->tag ne 'area') {
443             $self->{events}->{$zone}{$capId}{$child->tag} = $child->text;
444             }
445             }
446             $self->{events}->{$zone}{$capId}{'delete'} = 0;
447            
448             } else {
449             print "Error parsing CAP file for event $capId :: $0\n" if($self->{errorLog});
450             print $capContent . "\n" if($self->{errorLog});
451             return 0;
452             }
453             } else {
454             print "Failed to retrieve CAP file for event $capId\n" if($self->{errorLog});
455             return 0;
456             }
457            
458             return 1; #No errors encountered
459             }
460              
461              
462              
463             sub atomInfoHandler {
464             my ($twig, $atomInfo) = @_;
465             atomFormatTags($atomInfo);
466             }
467              
468             sub capInfoHandler {
469             my ($twig, $formatTime, $formatAsterisk, $capInfo) = @_;
470             capFormatTags($formatTime, $formatAsterisk, $capInfo);
471             }
472              
473             sub atomFormatTags {
474             my ($atomInfo) = @_;
475            
476             # my @children = $atomInfo->children;
477             # foreach my $child (@children) {
478             # if ($child->tag eq '') {
479             # my $childText = $child->text;
480             # $childText =~ s/^\n//;
481             # $childText =~ s/\n$//;
482             # $child->set_text($childText);
483            
484             # #Insert new tags into the document
485             # my $elt= new XML::Twig::Elt( 'parsedHeadline', $parsedHeadline);
486             # $elt->paste( 'last_child', $capInfo);
487             # }
488             # }
489             }
490              
491             sub capFormatTags {
492             my ($formatTime, $formatAsterisk, $capInfo) = @_;
493            
494             #Format some of the fields. Need to remove newlines from every field
495             #except the description and instruction; they need other adjustments.
496             my @children = $capInfo->children;
497             foreach my $child (@children) {
498             my $childText = $child->text;
499             if ($child->tag ne 'description' and $child->tag ne 'instruction') {
500             #Adjust the formatting a little. Why would a CAP file
501             #need to contain newline formatting?
502             $childText =~ s/^\n//;
503             $childText =~ s/\n/ /g;
504             $childText =~ s/^\s+//; #remove leading spaces
505             $childText =~ s/\s+$//; #remove trailing spaces
506             } else {
507             if( $formatTime) {
508             # if( 1 ) {
509             #Try to add colons to all the time fields. This allows
510             #the MS SAPI engine to correctly pronounce the time
511             $childText =~ s/(\d{1,2}?)(\d{2})\s{1}(AM|PM)\s{1}[A-Z]{3}/$1:$2 $3/g;
512             }
513             if( $formatAsterisk) {
514             # if( 1 ) {
515             #Remove any "*" because it sounds real funny when SAPI
516             #pronounces "asterisk" in the middle of the speech stream
517             $childText =~ s/\*/ /g;
518             }
519             }
520             $child->set_text($childText);
521             }
522             }
523              
524             =head1 NOAA CAP CHALLENGES
525              
526             The following items represent fragile parts of the code caused by ambiguous
527             data in the National Weather Service CAP feeds. These items should be
528             addressed by NWS by publishing a document that clearly states the behavior
529             for each of these conditions or by generally adding more XML enclosures
530             especially replacing the long list of counties that can occur.
531              
532             =over 4
533              
534             =item * B
535              
536             Expired alerts contain a valid CeventEE/eventE> section
537             with CdescriptionEalert has expiredE/descriptionE>.
538             There seems to be little else in the document that can be used to
539             determine that the event is basically empty and expired. One option
540             might be to key on a null CexpiresEE/expiresE> section
541             but nothing is guaranteed since this behavior is not documented by the
542             national weather service as far as I can tell.
543              
544             =item * B
545              
546             If there are no notifications for a county or zone, the CAP document
547             will contain a valid CeventEE/eventE> section where
548             the CidEE/idE> is the same as the CAP URL (i.e. points
549             to its self). Again there is little else in the document that can be
550             used to unambiguously determine that there are no events for that zone
551             or county.
552              
553             =item * B
554              
555             Events can contain a long list of counties. For anyone trying
556             to perform text to speech or even SMS delivery of the events
557             the text can be too long. Ideally the county list would be in
558             other XML enclosures so that XSL files could still display pages
559             correctly, but other uses would not need to consider the, potentially
560             long list of counties.
561              
562             =back
563              
564             =head1 TODO
565              
566             =over 4
567              
568             =item * B
569              
570             Events contain a CpolygonEE/polygonE> that could be
571             used to determine if a specific latitude / longitude is in the event
572             area. This would greatly reduce the number of alerts for a specific
573             point in a county since the NWS has recently started issuing and
574             expiring events for specific areas that are not bound by county.
575             Tracking at the county level can trigger several events over a short
576             period as a storm progresses through the county.
577              
578             =back
579              
580             =head1 SEE ALSO
581              
582             =over 4
583              
584             =item * L - Primary NOAA National Weather Service
585             page for Public Alerts. Has a full list of all county and forecast zone IDs.
586              
587             =item * L - List of possible
588             data that is populated in the field
589              
590             =back
591              
592             =head1 AUTHOR
593              
594             Michael Stovenour, C<< >>
595              
596             =head1 BUGS
597              
598             Please report any bugs or feature requests to C, or through
599             the web interface at L. I will be notified, and then you'll
600             automatically be notified of progress on your bug as I make changes.
601              
602              
603             =head1 SUPPORT
604              
605             You can find documentation for this module with the perldoc command.
606              
607             perldoc Weather::NOAA::Alert
608              
609            
610             You can also look for information at:
611              
612             =over 4
613              
614             =item * RT: CPAN's request tracker (report bugs here)
615              
616             L
617              
618             =item * AnnoCPAN: Annotated CPAN documentation
619              
620             L
621              
622             =item * CPAN Ratings
623              
624             L
625              
626             =item * Search CPAN
627              
628             L
629              
630             =back
631              
632             =head1 LICENSE AND COPYRIGHT
633              
634             Copyright 2011 Michael Stovenour.
635              
636             This program is free software; you can redistribute it and/or modify it
637             under the terms of either: the GNU General Public License as published
638             by the Free Software Foundation; or the Artistic License.
639              
640             See http://dev.perl.org/licenses/ for more information.
641              
642              
643             =cut
644              
645             1; # End of Weather::NOAA::Alert