File Coverage

blib/lib/WebService/BorisBikes.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 WebService::BorisBikes;
2              
3 1     1   6578 use strict;
  1         4  
  1         48  
4 1     1   7 use warnings;
  1         1  
  1         41  
5              
6 1     1   1009 use LWP::Simple qw(get);
  1         967174  
  1         10  
7 1     1   880 use XML::Simple qw(:strict);
  0            
  0            
8             use Perl6::Slurp;
9             use GIS::Distance::Lite;
10             use Geo::Postcode;
11             use Try::Tiny;
12             use Scalar::Util qw(looks_like_number);
13             use Carp qw(cluck confess);
14              
15             use WebService::BorisBikes::Station;
16              
17             =head1 NAME
18              
19             WebService::BorisBikes - A very simple web service to wrap around the
20             live Barclays cycle hire availibility data from the Transport for London website.
21              
22             To use this module, please register and create an account at transport for london
23             first.
24             http://www.tfl.gov.uk/businessandpartners/syndication/default.aspx
25              
26             and always follow the usage guidelines ..
27             http://www.tfl.gov.uk/tfl/businessandpartners/syndication/assets/syndication-developer-guidelines.pdf
28              
29             =head1 VERSION
30              
31             version 0.14
32              
33             =head1 SYNOPSIS
34              
35             use WebService::BorisBikes;
36              
37             my %params = (
38             refresh_rate => 120, ## seconds
39             debug_filename => '/tmp/tflcycledata.xml',
40             );
41            
42             my $BB = WebService::BorisBikes->new( \%params );
43              
44             =cut
45              
46             our @station_fields = @WebService::BorisBikes::Station::station_fields;
47              
48             my $TFL_LIVE_CYCLE_DATA_URL =
49             'http://www.tfl.gov.uk/tfl/syndication/feeds/cycle-hire/livecyclehireupdates.xml';
50              
51             =head1 PUBLIC METHODS
52              
53             =head2 new
54              
55             Returns a WebService::BorisBikes object. Accepts a hashref with possible keys of
56             refresh_rate & debug_filename.
57              
58             The refresh rate specifies in seconds how often to update station information.
59             Refresh is performed automatically if needed after calling one of
60             the public methods.
61              
62             The debug_filename specifies the absolute position of a local London Cycle
63             Scheme XML feed and is used for testing and debugging.
64              
65             =cut
66              
67             sub new {
68             my $class = shift;
69             my $rh_params = shift;
70              
71             my $self;
72             foreach my $key ( keys %{$rh_params} ) {
73             $self->{$key} = $rh_params->{$key};
74             }
75              
76             if ( $self->{refresh_rate} < 60 && !$self->{debug_filename}) {
77             die "Please specify a refresh time of 60 seconds or more.";
78             }
79              
80             bless $self, $class;
81              
82             $self->_refresh_stations();
83              
84             return $self;
85             }
86              
87             =head2 get_station_by_id
88              
89             Returns a WebService::BorisBikes::Station object of the given id
90              
91             my $Station = $BB->get_station_by_id(533);
92              
93             =cut
94              
95             sub get_station_by_id {
96             my $self = shift;
97             my $id = shift;
98              
99             # refresh stations if need be
100             $self->_refresh_stations();
101              
102             return $self->{stations}->{$id};
103             }
104              
105             =head2 get_all_stations
106              
107             Returns an hashref with keys being the station_id and values being a
108             WebService::BorisBikes::Station object.
109              
110             my $rh_stations = $BB->get_all_stations();
111              
112             =cut
113              
114             sub get_all_stations {
115             my $self = shift;
116             my $id = shift;
117              
118             # refresh stations if need be
119             $self->_refresh_stations();
120              
121             return $self->{stations};
122             }
123              
124             =head2 get_meters_distance_between_two_stations
125              
126             Returns the distance in meters between two station id's.
127              
128             my $meters = $BB->get_meters_distance_between_two_stations(566,547);
129            
130             =cut
131              
132             sub get_meters_distance_between_two_stations {
133             my $self = shift;
134             my ($id1, $id2) = @_;
135              
136             my $Station1 = $self->get_station_by_id($id1);
137             my $Station2 = $self->get_station_by_id($id2);
138              
139             my $meters = $self->_get_meters_distance_between_two_coordinates(
140             $Station1->get_lat(),
141             $Station1->get_long(),
142             $Station2->get_lat(),
143             $Station2->get_long(),
144             );
145              
146             # round off
147             $meters = sprintf "%.0f", $meters;
148              
149             return $meters;
150             }
151              
152             =head2 get_stations_nearby
153              
154             Accepts a hashref, where the keys must contain 'distance' in meters
155             and B of the following ..
156              
157             =over 4
158              
159             =item 1
160              
161             latlong => A comma delimited string of a valid latitude and longitude
162              
163             my $rhh_stations = $BB->get_stations_nearby(
164             { 'distance' => 200, latlong => '52.521,-0.102' }
165             );
166              
167             =item 2
168              
169             postcode => A valid UK postcode (in greater London).
170              
171             my $rhh_stations = $BB->get_stations_nearby(
172             { 'distance' => 200, postcode => 'EC1M 5RF' }
173             );
174              
175             =back
176              
177             If you do populate both latlong and postcode params, the latlong will be used,
178             and the postcode ignored.
179              
180             Returns a hashref with the keys being station_ids and values being ...
181              
182             'distance' => in meters from the postcode/latlong argument
183             'obj' => the WebService::BorisBikes::Station object.
184              
185             For example:
186              
187             '246' => {
188             'obj' => bless( {
189             'id' => '246'
190             'nbEmptyDocks' => '39',
191             ...
192             }, 'WebService::BorisBikes::Station' ),
193             'distance' => '248.45237388466'
194              
195             =cut
196              
197             sub get_stations_nearby {
198             my $self = shift;
199             my $rh_params = shift;
200              
201             # validate $distance
202             if ( !looks_like_number($rh_params->{'distance'}) ) {
203             cluck "distance parameter is not a number";
204             return;
205             }
206              
207             # get coordinates
208             my ($lat,$long) = $self->_get_coordinates_from_place($rh_params);
209              
210             # refresh stations if need be
211             $self->_refresh_stations();
212              
213             return $self->_get_stations_near_lat_long($lat,
214             $long,
215             $rh_params->{'distance'}
216             );
217             }
218              
219             =head2 get_station_ids_nearby_order_by_distance_from
220              
221             Accepts the same parameters as get_stations_nearby, but returns an arrayref
222             of station ids, ordered by distance from.
223              
224              
225             my $ra_stations = $BB->get_station_ids_nearby_order_by_distance_from ({
226             postcode => 'EC1M 5RF',
227             });
228              
229             =cut
230              
231             sub get_station_ids_nearby_order_by_distance_from {
232             my $self = shift;
233             my $rh_params = shift;
234              
235             # validate $distance
236             if ( !looks_like_number($rh_params->{'distance'}) ) {
237             cluck "distance parameter is not a number";
238             return;
239             }
240              
241             # get coordinates
242             my ($lat,$long) = $self->_get_coordinates_from_place($rh_params);
243              
244             # validate $lat & $long
245             if ( !$self->_validate_lat_long($lat,$long) ) {
246             cluck "not a valid latitude or longitude";
247             return;
248             }
249              
250             # are coordinates even in greater london?
251             if ( !$self->_coordinates_in_greater_london( $lat, $long ) ) {
252             cluck "Coordinates don't appear to be in greater london";
253             return;
254             }
255              
256             # refresh stations if need be
257             $self->_refresh_stations();
258              
259             # get station ids ordered by distance from
260             my @station_ids =
261             map { $_->[0] }
262             sort { $a->[1] <=> $b->[1] }
263             grep { $_->[1] <= $rh_params->{distance} }
264             map {
265             [
266             $self->{stations}->{$_}->get_id(),
267             $self->_get_meters_distance_between_station_and_coordinates(
268             $self->{stations}->{$_}, $lat, $long)
269             ]
270             } keys %{$self->{stations}};
271              
272             return \@station_ids;
273             }
274              
275             =head2 get_stations_by_name
276              
277             Search for station by their name attribute with case insensitive matching.
278             Returns a hashref, keys being station id and values being WebService::BorisBikes::Station
279             object.
280              
281             my $rh_stations = $BB->get_stations_by_name('holland park');
282              
283             =cut
284              
285             sub get_stations_by_name {
286             my $self = shift;
287             my $search = shift;
288              
289             # refresh stations if need be
290             $self->_refresh_stations();
291              
292             my $rh_stations;
293             foreach my $Station ( values %{$self->{stations}}) {
294             if ( $Station->get_name =~ /$search/i ) {
295             $rh_stations->{$Station->get_id()} = $Station;
296             }
297             }
298              
299             return $rh_stations;
300             }
301              
302             =head1 PRIVATE METHODS
303              
304             =head2 _get_stations_near_lat_long
305              
306             Accepts latitude, longitude and distance parameters finds the stations
307             within range.
308              
309             =cut
310              
311             sub _get_stations_near_lat_long {
312             my $self = shift;
313             my $lat = shift;
314             my $long = shift;
315             my $distance = shift;
316              
317             # validate $lat & $long
318             if ( !$self->_validate_lat_long($lat,$long) ) {
319             cluck "not a valid latitude or longitude";
320             return;
321             }
322              
323             # are coordinates even in greater london?
324             if ( !$self->_coordinates_in_greater_london( $lat, $long ) ) {
325             cluck "Coordinates don't appear to be in greater london";
326             return;
327             }
328              
329             # find and return the stations within range
330             my $rh_stations;
331             foreach my $Station ( values %{$self->{stations}} ) {
332             my $meters = $self->_get_meters_distance_between_station_and_coordinates(
333             $Station, $lat, $long);
334             if ($meters <= $distance) {
335             $rh_stations->{$Station->get_id()} = {
336             'obj' => $Station,
337             'distance' => $meters,
338             }
339             }
340             }
341              
342             return $rh_stations;
343             }
344              
345             =head2 _refresh_stations
346              
347             Populates $self->{stations} hashref. The key being the station_id,
348             and the value is a WebService::BorisBikes:Station object.
349              
350             $self->{stations}->{1} = WebService::BorisBikes::Station
351              
352             =cut
353              
354             sub _refresh_stations {
355             my $self = shift;
356              
357             # do we need to refresh at all?
358             return unless $self->_needs_refreshing();
359              
360             # yes, so clear current stations.
361             delete $self->{stations};
362              
363             # get new data
364             my $rhh_stations = $self->_get_station_data();
365              
366             # populate $self->{stations}
367             foreach my $station_id ( keys %{$rhh_stations} ) {
368             my $Station = WebService::BorisBikes::Station->new();
369             foreach my $field (@station_fields) {
370             my $setter = "set_$field";
371             $Station->$setter( $rhh_stations->{$station_id}->{$field} );
372             }
373             $self->{stations}->{$station_id} = $Station;
374             }
375              
376             # now set epoch since last refresh
377             $self->{epoch_since_last_refresh} = time;
378              
379             warn "Refreshed station data!" if ($self->{debug_filename});
380              
381             return;
382             }
383              
384             =head2 _needs_refreshing
385              
386             Returns true if our station data has become stale. Returns false otherwise.
387              
388             =cut
389              
390             sub _needs_refreshing {
391             my $self = shift;
392              
393             if ( !exists $self->{epoch_since_last_refresh} ) {
394             return 1;
395             }
396              
397             my $diff = time - $self->{epoch_since_last_refresh};
398              
399             if ( $diff >= $self->{refresh_rate} ) {
400             return 1;
401             }
402              
403             return;
404             }
405              
406             =head2 _get_station_data
407              
408             If $self->{debug_filename} is not set, station data will be retrieved from the
409             tfl website using LWP::Simple.
410              
411             Otherwise, station data will be slurped from a downloaded xml file in the
412             absolute location of $self->{debug_filename}.
413              
414             Returns an hashref of station data hashes, after setting
415             $self->{epoch_since_last_refresh}.
416              
417             =cut
418              
419             sub _get_station_data {
420             my $self = shift;
421              
422             # get cycle data
423             my $xmlfeed;
424             if ( $self->{debug_filename} ) {
425             if ( -e $self->{debug_filename} ) {
426             $xmlfeed = slurp $self->{debug_filename};
427             }
428             else {
429             confess "Failed to get station data, debug file: $self->{debug_filename} does not exist!";
430             return;
431             }
432             }
433             else {
434             $xmlfeed = LWP::Simple::get($TFL_LIVE_CYCLE_DATA_URL);
435             }
436              
437             # parse XML
438             my $parsed = try {
439             XMLin(
440             $xmlfeed,
441             ForceArray => 0,
442             KeyAttr => {},
443             SuppressEmpty => undef,
444             );
445             }
446             catch {
447             confess "Error in parsing tfl XML feed: $_";
448             };
449              
450             # parse data
451             my $rhh_stations;
452             foreach my $station ( @{ $parsed->{station} } ) {
453             $rhh_stations->{ $station->{id} }->{$_} = $station->{$_}
454             foreach @station_fields;
455             }
456              
457             return $rhh_stations;
458             }
459              
460             =head2 _coordinates_in_greater_london
461              
462             Return true if coordinate arguments are within a bounding box roughly the size
463             of greater London.
464              
465             =cut
466              
467             sub _coordinates_in_greater_london {
468             my $self = shift;
469             my ( $lat, $long ) = @_;
470              
471             ## greater London bounding box roughly 45x45km
472             my $greater_london_min_lat = 51.161;
473             my $greater_london_max_lat = 51.667278;
474             my $greater_london_min_long = -0.593938;
475             my $greater_london_max_long = 0.448882;
476              
477             if ( $long >= $greater_london_min_long
478             && $long <= $greater_london_max_long
479             && $lat >= $greater_london_min_lat
480             && $lat <= $greater_london_max_lat )
481             {
482             return 1;
483             }
484              
485             return;
486             }
487              
488             =head2 _get_meters_distance_between_station_and_coordinates
489              
490             Returns the distance in meters between a WebService::BorisBikes::Station object
491             and WGS84 coordinates.
492              
493             =cut
494              
495             sub _get_meters_distance_between_station_and_coordinates {
496             my $self = shift;
497             my ( $Station, $lat, $long ) = @_;
498              
499             return $self->_get_meters_distance_between_two_coordinates(
500             $Station->get_lat, $Station->get_long => $lat, $long
501             );
502             }
503              
504             =head2 _get_meters_distance_between_two_coordinates
505              
506             Uses GIS::Distance::Lite to calculate the distance in meters between two
507             WGS84 coordinates, (Haversine formula).
508              
509             =cut
510              
511             sub _get_meters_distance_between_two_coordinates {
512             my $self = shift;
513             my ( $lat1, $long1, $lat2, $long2 ) = @_;
514              
515             return GIS::Distance::Lite::distance(
516             $lat1, $long1 => $lat2, $long2
517             );
518             }
519              
520             =head2 _get_coordinates_from_place
521              
522             Accepts the same hashref as WebService::BorisBikes::get_stations_nearby()
523             and returns a latitude and longitude.
524              
525             =cut
526              
527             sub _get_coordinates_from_place {
528             my $self = shift;
529             my $rh_params = shift;
530              
531             my ($lat, $long);
532              
533             if ( $rh_params->{'latlong'} ) {
534             ($lat, $long) = split ',', $rh_params->{'latlong'};
535             }
536             elsif ( $rh_params->{'postcode'} ) {
537             my $GeoPostCode = Geo::Postcode->new($rh_params->{'postcode'});
538             if ( !$GeoPostCode->valid() ) {
539             cluck "not a valid UK postcode";
540             return;
541             }
542             $lat = $GeoPostCode->lat;
543             $long = $GeoPostCode->long;
544             }
545              
546             return ($lat, $long);
547             }
548              
549             =head2 _validate_lat_long
550              
551             Returns true if parameters latitude is a float between -180 and 180
552             and longitude is a float between -90 and 90.
553              
554             =cut
555              
556             sub _validate_lat_long {
557             my $self = shift;
558             my ($lat, $long) = @_;
559             return $self->_validate_lat($lat) && $self->_validate_long($long);
560             };
561              
562              
563              
564             # latitude is a float between -180 and 180
565             sub _validate_lat {
566             my $self = shift;
567             my $val = shift;
568             if ( defined($val) && $val =~ /^[\+\-]?\d+\.?\d*$/ ) {
569             return -180 <= $val && $val <= 180;
570             }
571             else {
572             return;
573             }
574             }
575              
576             # longitude is a float between -90 and 90
577             sub _validate_long {
578             my $self = shift;
579             my $val = shift;
580             if ( defined($val) && $val =~ /^[\+\-]?\d+\.?\d*$/ ) {
581             return -90 <= $val && $val <= 90;
582             }
583             else {
584             return;
585             }
586             }
587              
588             1;