File Coverage

blib/lib/Device/LaCrosse/WS23xx.pm
Criterion Covered Total %
statement 115 196 58.6
branch 34 106 32.0
condition 7 23 30.4
subroutine 21 23 91.3
pod 2 2 100.0
total 179 350 51.1


line stmt bran cond sub pod time code
1             # -*- perl -*-
2             #
3             # Device::LaCrosse::WS23xx - interface to La Crosse WS-23xx weather stations
4             #
5             # $Id: 214 $
6             #
7             package Device::LaCrosse::WS23xx;
8              
9 3     3   100030 use 5.006;
  3         12  
10              
11 3     3   18 use strict;
  3         3  
  3         69  
12 3     3   14 use warnings;
  3         9  
  3         90  
13 3     3   15 use Carp;
  3         5  
  3         248  
14 3     3   2486 use Time::Local;
  3         5174  
  3         183  
15 3     3   2020 use Device::LaCrosse::WS23xx::MemoryMap;
  3         8  
  3         302  
16              
17             (our $ME = $0) =~ s|^.*/||;
18              
19             ###############################################################################
20             # BEGIN user-customizable section
21              
22             # The conversions we know how to do. Format of this table is:
23             #
24             # ()
25             #
26             # where:
27             #
28             # from name of units to convert FROM. This must be one of the
29             # units used in WS23xx/MemoryMap.pm
30             #
31             # to name of units to convert TO. Feel free to add your own.
32             # Say, m/s to furlongs/fortnight or even degrees to radians.
33             #
34             # precision how many significant digits to return
35             #
36             # expression mathematical expression using the variable '$value'
37             #
38             our $Conversions = <<'END_CONVERSIONS';
39             C F(1) $value * 9.0 / 5.0 + 32
40              
41             hPa inHg(2) $value / 33.8638864
42             hPa mmHg(1) $value / 1.3332239
43              
44             m/s kph(1) $value * 3.6
45             m/s kt(1) $value * 1.9438445
46             m/s mph(1) $value * 2.2369363
47              
48             mm in(2) $value / 25.4
49             END_CONVERSIONS
50              
51             # END user-customizable section
52             ###############################################################################
53              
54             require Exporter;
55             require DynaLoader;
56              
57 3     3   16 use vars qw(@ISA %EXPORT_TAGS @EXPORT_OK @EXPORT);
  3         5  
  3         7259  
58              
59             @ISA = qw(Exporter DynaLoader);
60              
61             %EXPORT_TAGS = ( );
62             @EXPORT_OK = ( );
63             @EXPORT = ( );
64              
65             our $VERSION = '0.10';
66              
67             our $PKG = __PACKAGE__; # For interpolating into error messages
68              
69             bootstrap Device::LaCrosse::WS23xx $VERSION;
70              
71             sub new {
72 1     1 1 38 my $proto = shift;
73 1   33     7 my $class = ref($proto) || $proto;
74              
75 1 50       4 my $device = shift # in: mandatory arg
76             or croak "Usage: ".__PACKAGE__."->new( \"/dev/LACROSSE-DEV-NAME\" )";
77              
78             # Is $device path a plain (not device) file with a special name?
79 1 50 33     25 if ($device =~ /map.*\.txt/ && ! -c $device) {
80 1         9 return Device::LaCrosse::WS23xx::Fake->new($device, @_);
81             }
82              
83 0         0 my $self = {
84             path => $device,
85             mmap => Device::LaCrosse::WS23xx::MemoryMap->new(),
86              
87             cache_expire => 10,
88             cache_readahead => 30,
89             };
90              
91             # Any cache parameters included?
92 0 0       0 if (@_) {
93 0         0 my %param;
94 0 0       0 if (@_ % 2 == 0) {
    0          
95 0         0 %param = @_;
96             }
97             elsif (@_ == 1) {
98 0 0       0 ref($_[0]) eq 'HASH'
99             or croak "Second arg to ->new() must be a hashref";
100 0         0 %param = %{$_[0]};
  0         0  
101             }
102             else {
103 0         0 croak "$PKG->new() takes options, but you need to read the docs";
104             }
105              
106 0 0       0 if (my $n = delete $param{cache_expire}) {
107 0 0       0 $n =~ /^\s*(\d{1,3})\s*$/
108             or croak "cache_expire must be a 1- to 3-digit number";
109 0         0 $self->{cache_expire} = $1;
110             }
111              
112 0 0       0 if (my $n = delete $param{cache_readahead}) {
113 0 0       0 $n =~ /^\s*(\d{1,2})\s*$/
114             or croak "cache_readahead must be a 1- or 2-digit number";
115 0         0 $n = $1;
116 0 0       0 if ($n > 30) {
117 0         0 carp "cache_readahead is limited to 30 nybbles; truncating";
118 0         0 $n = 30;
119             }
120 0         0 $self->{cache_readahead} = $n;
121             }
122              
123 0 0       0 if (my $p = delete $param{trace}) {
124 0 0       0 if ($p eq '1') {
125 0         0 my @lt = localtime;
126 0         0 $p = sprintf(".ws23xx-trace.%04d-%02d-%02d_%02d%02d%02d",
127             $lt[5]+1900,$lt[4]+1,@lt[3,2,1,0]);
128             }
129 0         0 _ws_trace_path($p);
130             }
131              
132 0 0       0 if (my @unknown = sort keys %param) {
133 0         0 croak "Unknown param '@unknown'";
134             }
135             }
136              
137             # Open and initialize the device. If that fails, we'll get undef
138             # and pass it along (hoping that $! is set).
139 0 0       0 $self->{fh} = _ws_open($device)
140             or return undef;
141              
142 0         0 return bless $self, $class;
143             }
144              
145              
146             #############
147             # DESTROY # Destructor. Call C code to close the filehandle.
148             #############
149             sub DESTROY {
150 2     2   401 my $self = shift;
151              
152 2 50       304 if (defined $self->{fh}) {
153             _ws_close($self->{fh})
154 0 0       0 or warn "$ME: Error closing $self->{path}: $!";
155             }
156             }
157              
158              
159             sub _read_data {
160 0     0   0 my $self = shift;
161 0         0 my $address = shift;
162 0         0 my $length = shift;
163              
164 0 0       0 if ($length > 30) {
165 0         0 carp "cannot read more than 30 nybbles; truncating";
166 0         0 $length = 30;
167             }
168              
169             # See if we've already cached this address range
170 0 0       0 if (my $cache = $self->{cache}) {
171             CACHE_ENTRY:
172 0         0 for (my $i=0; $i < @$cache; $i++) {
173 0         0 my $c = $cache->[$i];
174              
175             # First, delete expired entries
176 0 0       0 if ($c->{expires} < time) {
177 0         0 splice @$cache, $i, 1;
178 0 0       0 last CACHE_ENTRY if @$cache == 0;
179 0         0 redo CACHE_ENTRY;
180             }
181              
182             # Check range
183 0 0       0 if ($c->{address} <= $address) {
184 0 0       0 if ($address+$length < $c->{address} + @{$c->{data}}) {
  0         0  
185 0         0 my $data = $c->{data};
186 0         0 my $start = $address - $c->{address};
187 0         0 return @{$data}[$start .. $start + $length - 1];
  0         0  
188             }
189             }
190             }
191             }
192              
193             # Not cached (or expired). Read the desired range, plus a few more.
194 0         0 my $n_read = $self->{cache_readahead};
195 0         0 my $expire = $self->{cache_expire};
196              
197 0 0 0     0 if (($n_read < $length) || ($expire == 0)) {
198 0         0 $n_read = $length;
199             }
200              
201 0         0 my @data = _ws_read($self->{fh}, $address, $n_read);
202              
203             # Preserve in our cache
204 0 0       0 if ($expire != 0) {
205 0   0     0 $self->{cache} ||= [];
206 0         0 push @{ $self->{cache} }, {
207             address => $address,
208             data => \@data,
209             expires => time + $self->{cache_expire},
210 0         0 };
211             }
212              
213             # Return desired address range
214 0         0 return @data[0 .. $length-1];
215             }
216              
217             sub get {
218 74     74 1 26825 my $self = shift;
219 74 50       186 my $field = shift
220             or croak "Usage: $PKG->new( FIELD )";
221              
222 74 50       219 my $get = $self->{mmap}->find_field( $field )
223             or croak "No such field, '$field'";
224              
225 74         201 my @data = $self->_read_data($get->{address}, $get->{count});
226              
227             # Convert to string context: (0, 3, 0xF, 9) becomes '03F9'.
228 74         155 my $data = join('', map { sprintf "%X",$_ } @data);
  377         909  
229              
230             # Asked for raw data? If called with 'raw' as second argument,
231             # return the nybbles directly as they are.
232 74 50 33     240 if (@_ && lc($_[0]) eq 'raw') {
233             return wantarray ? @data
234 0 0       0 : $data;
235             }
236              
237             # Interpret. This will be done inside an eval which may access
238             # the variable $BCD. $BCD is simply the sequence of data nybbles
239             # read from the device, in string form. Note that data nybbles
240             # are returned Least Significant First. So if @data = (0, 3, 2)
241             # then $BCD will be '230' (two hundred and thirty), not '032'.
242 74         118 my $BCD = reverse($data);
243 74         153 $BCD =~ s/^0+//;
244 74 100       164 $BCD = '0' if $BCD eq '';
245              
246 74         116 my $expr = $get->{expr};
247              
248             # Bug 41461
249             # Every so often the unit returns "AA" as a data value, leading to:
250             # Argument "AA10" isn't numeric in division (/) at (eval 8) line 1
251             # ...which isn't very helpful.
252             # Try to detect those, and issue a better warning. If we see any
253             # non-decimal characters, issue a warning (if desired) and return undef.
254 74 50 66     221 if ($BCD =~ /[^0-9]/ && $expr !~ /hex/) {
255 0 0       0 warn "$ME: WARNING: device returned invalid '$BCD' for $field\n"
256             if $^W;
257 0         0 return;
258             }
259              
260             # Special case for datetime: return a unix time_t
261             sub _time_convert($$) {
262             # YY MM DD hh mm
263 17 50   17   85 if ($_[0] =~ m!^(\d{1,2})(\d\d)(\d\d)(\d\d)(\d\d)$!) {
264 17         75 return timelocal( 0,$5,$4, $3, $2-1, $1+100);
265             }
266              
267 0         0 carp "$ME: ->$_[1](): WARNING: bad datetime '$_[0]'";
268 0         0 return 0;
269             }
270              
271             # Special case for values with well-defined meanings:
272             # 0=Foo, 1=Bar, 2=Fubar, ...
273 74 100       179 if ($expr =~ /\d=.*,.*\d=/) {
274 3         4 my @string_value;
275 3         18 for my $pair (split(/\s*,\s*/, $expr)) {
276             # FIXME: don't die! This is customer code.
277 9 50       35 $pair =~ /([0-9a-f])=(.*)/i or die;
278 9         71 $string_value[hex($1)] = $2;
279             }
280              
281 3         8 my $val = $string_value[hex($BCD)];
282 3 50       8 if (defined $val) {
283 3         12 return $val;
284             }
285             else {
286 0         0 return "undefined($BCD)";
287             }
288             }
289              
290             # Interpret the equation, e.g. $BCD / 10.0
291 71         3680 my $val = eval($expr);
292 71 50       1223 if ($@) {
293 0         0 croak "$ME: ->$field(): eval( $get->{expr} ) died: $@";
294             }
295              
296             # Asked to convert units?
297 71 50       154 if (@_) {
298 0         0 return _unit_convert($val, $get->{units}, $_[0]);
299             }
300              
301 71         217 return $val;
302             }
303              
304              
305             sub _unit_convert {
306 10     10   3339 my $value = shift;
307 10         16 my $units_in = shift;
308 10         12 my $units_out = shift;
309              
310             # Identity?
311 10 100       31 if (lc($units_in) eq lc($units_out)) {
312 1         3 return $value;
313             }
314              
315 9         9 our %Convert;
316             # First time through? Read and parse the conversion table at top
317 9 100       24 if (! keys %Convert) {
318 1         7 for my $line (split "\n", $Conversions) {
319 10 100       26 next if $line eq '';
320 7 50       84 $line =~ m!^(\S+)\s+(\S+)\((\d+)\)\s+(.*)!
321             or croak "Internal error: Cannot grok conversion '$line'";
322 7         8 push @{ $Convert{$1} }, { to => $2, precision => $3, expr => $4 };
  7         49  
323             }
324             }
325              
326             # No known conversions for this unit?
327 9 50       23 if (! exists $Convert{$units_in}) {
328 0         0 warn "$ME: Cannot convert '$units_in' to anything\n";
329 0         0 return $value;
330             }
331 9         11 my @conversions = @{ $Convert{$units_in} };
  9         22  
332              
333             # There exists at least one conversion. Do we have the one
334             # requested by our caller?
335 9         15 my @match = grep { lc($_->{to}) eq lc($units_out) } @conversions;
  17         61  
336 9 50       21 if (! @match) {
337 0         0 my @try = map { $_->{to} } @conversions;
  0         0  
338 0         0 my $try = join ", ", @try;
339 0         0 warn "$ME: Cannot convert '$units_in' to '$units_out'. Try: $try\n";
340 0         0 return $value;
341             }
342              
343 9         475 my $newval = eval $match[0]->{expr};
344 9 50       33 if ($@) {
345 0         0 warn "$@";
346 0         0 return $value;
347             }
348              
349 9         82 return sprintf("%.*f", $match[0]->{precision}, $newval);
350             }
351              
352             ###############################################################################
353             # BEGIN tie() code for treating the ws23xx as a perl array
354              
355             sub TIEARRAY {
356 1     1   43145 my $class = shift;
357 1         2 my $ws = shift; # in: weatherstation object _or_ path
358              
359 1         3 my $ws_obj;
360 1 50       7 if (ref($class)) {
    50          
361             # Called as: 'tie @X, $ws'
362 0         0 $ws_obj = $class;
363             }
364             elsif ($ws) {
365 1 50       4 if (ref($ws)) {
366 1 50       6 if (ref($ws) =~ /^Device::LaCrosse::WS23xx/) {
367 1         3 $ws_obj = $ws;
368             }
369             else {
370 0         0 croak "Error: you called 'tie' with a strange object";
371             }
372             }
373             else {
374             # $ws is not a ref: assume it's a path
375 0 0       0 $ws_obj = $class->new($ws)
376             or die "Cannot make a WS object out of $ws";
377             }
378             }
379             else {
380             # Called without a class object or a ws
381 0         0 croak "Usage: tie \@X, [ WS obj | \"$PKG\", \"/dev/path\" ]";
382             }
383              
384 1         3 my $self = { ws => $ws_obj };
385              
386 1   33     12 return bless $self, ref($class)||$class;
387             }
388              
389             sub FETCH {
390 5072     5072   16699 my $self = shift;
391 5072         5414 my $index = shift;
392              
393             # FIXME: assert that 0 <= index <= MAX
394             # FIXME: read and cache more than just 1
395 5072         10766 my @data = $self->{ws}->_read_data($index, 1);
396              
397 5072         12111 return $data[0];
398             }
399              
400             sub FETCHSIZE {
401 5073     5073   59950 return 0x13D0;
402             }
403              
404             sub STORE {
405 0     0   0 croak "Cannot (yet) write to WS23xx";
406             }
407              
408             # END tie() code for treating the ws23xx as a perl array
409             ###############################################################################
410             # BEGIN fake-device handler for testing
411              
412             package Device::LaCrosse::WS23xx::Fake;
413              
414 3     3   19 use strict;
  3         6  
  3         70  
415 3     3   18 use warnings;
  3         4  
  3         85  
416 3     3   25 use Carp;
  3         4  
  3         175  
417 3     3   15 use Device::LaCrosse::WS23xx::MemoryMap;
  3         5  
  3         1030  
418              
419             our @ISA = qw(Device::LaCrosse::WS23xx);
420              
421             sub new {
422 1     1   2 my $proto = shift;
423 1   33     6 my $class = ref($proto) || $proto;
424              
425 1 50       4 my $path = shift
426             or croak "Usage: ".__PACKAGE__."->new( \"path_to_mem_map.txt\" )";
427              
428 1         9 my $self = {
429             path => $path,
430             mmap => Device::LaCrosse::WS23xx::MemoryMap->new(),
431             fakedata => [],
432             };
433              
434 1 50       32 open my $map_fh, '<', $path
435             or croak "Cannot read $path: $!";
436 1         31 while (my $line = <$map_fh>) {
437             # E.g. 0019 0 alarm set flags
438 5158 100       14377 if ($line =~ m!^([0-9a-f]{4})\s+([0-9a-f])\s*!i) {
439 5072         17710 $self->{fakedata}->[hex($1)] = hex($2);
440             }
441             }
442 1         26 close $map_fh;
443              
444 1         11 return bless $self, $class;
445             }
446              
447             sub _read_data {
448 5147     5147   6022 my $self = shift;
449 5147         5611 my $address = shift;
450 5147         5417 my $length = shift;
451              
452 5147         7631 return @{$self->{fakedata}}[$address .. $address+$length-1];
  5147         12454  
453             }
454              
455             # END fake-device handler for testing
456             ###############################################################################
457              
458             ###############################################################################
459             # BEGIN documentation
460              
461             =head1 NAME
462              
463             Device::LaCrosse::WS23xx - read data from La Crosse weather station
464              
465             =head1 SYNOPSIS
466              
467             use Device::LaCrosse::WS23xx;
468              
469             my $serial = "/dev/ttyUSB0";
470             my $ws = Device::LaCrosse::WS23xx->new($serial)
471             or die "Cannot communicate with $serial: $!\n";
472              
473             for my $field qw(Indoor_Temp Pressure_Rel Outdoor_Humidity) {
474             printf "%-15s = %s\n", $field, $ws->get($field);
475             }
476              
477              
478             =head1 DESCRIPTION
479              
480             Device::LaCrosse::WS23xx provides a simple interface for
481             reading data from La Crosse Technology WS-2300 series
482             weather stations. It is based on the Open2300 project,
483             but differs in several respects:
484              
485             =over 2
486              
487             =item *
488              
489             Simplicity: the interface is simple and intuitive. For hackers,
490             the Tied interface makes it easy to visualize the address space.
491             And you don't have to do any of the nybble shifting or masking:
492             it's all done for you.
493              
494             =item *
495              
496             Versatility: read the values you want, in the units you want.
497             Write a script that logs only the values you're interested in.
498              
499             =item *
500              
501             Caching: to minimize communication errors, Device::LaCrosse::WS23xx
502             reads large blocks and caches them for a few seconds.
503              
504             =item *
505              
506             Debugging: the La Crosse units don't always communicate too
507             reliably. Use the B option to log serial I/O and track down
508             problems.
509              
510             =back
511              
512             =head1 CONSTRUCTOR
513              
514             =over 4
515              
516             =item B( PATH [,OPTIONS] )
517              
518             Establishes a connection to the weather station.
519             PATH is the serial line hooked up to the weather station. Typical
520             values are F, F.
521              
522             Available options are:
523              
524             =over 3
525              
526             =item B =E SECONDS (default: B<10>)
527              
528             How long to keep cached data. If your WS-23xx uses a cabled connection,
529             you probably want to set this to 8 seconds or less. If you use a wireless
530             connection, you might want to go as far as 128 seconds. To disable
531             caching entirely, set to B<0>.
532              
533             =item B =E NYBBLES (default: B<30>)
534              
535             How much data to cache (max B<30>).
536              
537             =item B =E PATH
538              
539             Log all serial I/O to B. If PATH is just '1', a filename
540             is autogenerated of the form F<.ws23xx-trace.YYYY-MM-DD_hhmmss>.
541              
542             =back
543              
544             =back
545              
546             =head1 METHODS
547              
548             =over 4
549              
550             =item B( FIELD [, UNITS] )
551              
552             Retrieves a reading from the weather station, optionally
553             converting it to B.
554              
555             For a list of the available FIELDs and their default units,
556             see L
557              
558             Example:
559              
560             $h = $ws->get('Humidity_Indoor'); # e.g. '37'
561             $p = $ws->get('Absolute_Pressure', 'inHg'); # e.g. '23.20'
562              
563             Only a few reasonable UNIT conversions are available:
564              
565             From To
566             ---- --
567             C F
568             hPa inHh, mmHg
569             m/s kph, mph, kt
570             mm in
571              
572             It's trivial to add your own: see the module source. (If you do add
573             conversions you think might be useful to others, please send them
574             to the module author).
575              
576             =back
577              
578             =head1 Tied Array Interface
579              
580             The WS-2300 memory map can be visualized as a simple sequence
581             of addresses, each of which contains one data nybble. In
582             other words, a perl array:
583              
584             my $serial = '/dev/ttyUSB0';
585             tie my @ws, 'Device::LaCrosse::WS23xx', $serial
586             or die "Cannot tie to $serial: $!\n";
587              
588             Or, if you already have a $ws object, it's even simpler:
589              
590             tie my @ws, $ws;
591              
592             Then access any WS-2300 memory cells as if the unit were
593             directly mapped to the array:
594              
595             print "backlight = $ws[0x16]\n";
596              
597             my @temp_in = @ws[0x346..0x349];
598             print "@temp_in\n"; # e.g. '0 8 9 4'
599              
600             Note that each value is a B: a value between 0 and 0xF.
601              
602             The tied interface is not really useful for actual weather station monitoring.
603             It is intended for hackers who want direct access to the device,
604             either for learning purposes or because Device::LaCrosse::WS23xx
605             is missing some important mappings.
606              
607             The Tied interface is read-only. If you have a need for read/write,
608             contact the author.
609              
610             =head1 AUTHOR
611              
612             Eduardo Santiago
613              
614             =head1 ACKNOWLEDGMENTS
615              
616             I am indebted to Kenneth Lavrsen, author of Open2300, for his
617             excellent code and documentation. Thanks also to Claude
618             Ocquidant for very helpful notes on the WS-23xx protocol.
619              
620             =head1 BUGS
621              
622             No support for writing values to the device. To reset the rain
623             counters or perform other write operations, use the Open2300 tools.
624              
625             Please report any bugs or feature requests to C, or through
626             the web interface at L. I will be notified, and then you'll
627             automatically be notified of progress on your bug as I make changes.
628              
629             =head1 SEE ALSO
630              
631             Open2300:
632             L
633              
634             Claude Ocquidant:
635             L
636              
637             =cut
638              
639             # END documentation
640             ###############################################################################
641              
642             1;