File Coverage

lib/Device/Power/Synaccess/NP05B.pm
Criterion Covered Total %
statement 146 161 90.6
branch 46 64 71.8
condition 3 6 50.0
subroutine 20 21 95.2
pod 12 18 66.6
total 227 270 84.0


line stmt bran cond sub pod time code
1             package Device::Power::Synaccess::NP05B;
2              
3             # ABSTRACT: Manage and monitor the Synaccess NP-05B networked power strip
4              
5 4     4   417085 use strict;
  4         33  
  4         94  
6 4     4   16 use warnings;
  4         5  
  4         110  
7 4     4   2951 use Net::Telnet;
  4         122389  
  4         6584  
8             our $VERSION = '1.03';
9              
10             =head1 NAME
11              
12             Device::Power::Synaccess::NP05B -- Manage and monitor the Synaccess NP05B networked power strip
13              
14             =head1 SYNOPSIS
15              
16             my $np = Device::Power::Synaccess::NP05B->new(addr => '10.0.0.1');
17              
18             # must initiate a connection and log in before issuing commands:
19             ($ok, $err) = $np->connect;
20             ($ok, $err) = $np->login;
21              
22             # are we still connected?
23             $np->is_connected or die "whoops";
24              
25             # get the status of the connection:
26             say $np->cond;
27              
28             # get the on/off status of the power outlets:
29             ($ok, $hashref) = $np->power_status;
30              
31             # turn on outlet 2:
32             ($ok, $err) = $np->power_set(2, 1)
33              
34             # get the full system status, including network attributes:
35             ($ok, $hashref) = $np->status;
36              
37             # must log out cleanly or device can get confused:
38             ($ok, $err) = $np->logout;
39            
40              
41             =head1 ABSTRACT
42              
43             Synaccess makes a power strip product called the C which can be remotely accessed and controlled via telnet or http.
44              
45             C accesses the C via telnet and provides programmatic access to some of its functions, notably system status and turning on/off specific power outlets.
46              
47             =head1 METHODS
48              
49             =head2 new
50              
51             my $np = Device::Power::Synaccess::NP05B->new();
52             my $np = Device::Power::Synaccess::NP05B->new(addr => '10.0.0.6', ...);
53              
54             Instantiates an C object. It takes some optional named parameters:
55              
56             =over 4
57              
58             =item * addr => string
59              
60             Specify the IP address of the C device. Defaults to "192.168.1.100", which was the factory default of the device sold to me.
61              
62             =item * user => string
63              
64             Specify the login username. Defaults to "admin", which was the factory default of the device sold to me.
65              
66             =item * pass => string
67              
68             Specify the login password. Defaults to "admin", which was the factory default of the device sold to me.
69              
70             =back
71              
72             A new C object will have a condition of "disconnected".
73              
74             =cut
75              
76             sub new {
77 4     4 1 49 my ($class, %opt_hr) = @_;
78 4         32 my $self = {
79             opt_hr => \%opt_hr,
80             ok => 'OK',
81             n_err => 0,
82             n_warn => 0,
83             err => '',
84             err_ar => [],
85             cond => 'disconnected',
86             status => undef,
87             buffer => undef
88             };
89 4         8 bless ($self, $class);
90              
91 4         7 foreach my $k0 (keys %{$self->{opt_hr}}) {
  4         25  
92 4         17 my $k1 = join('_', split(/-/, $k0));
93 4 50       13 next if ($k0 eq $k1);
94 0         0 $self->{opt_hr}->{$k1} = $self->{opt_hr}->{$k0};
95 0         0 delete $self->{opt_hr}->{$k0};
96             }
97              
98 4         18 $self->addr = $self->opt('addr', '192.168.1.100');
99 4         15 $self->user = $self->opt('user', 'admin');
100 4         12 $self->pass = $self->opt('pass', 'admin');
101              
102 4         11 return $self;
103             }
104              
105             =head2 connect
106              
107             my ($ok, $err) = $np->connect;
108             die "connect: $err" unless ($ok eq 'OK');
109              
110             Attempt to open a telnet connection to the C device. This must be done before attempting C or any other method.
111              
112             After successful connection, the C object will have a condition of "connected".
113              
114             Returns ('OK', '') on success, or ('ERROR', $error_message) on failure, where $error_message is a short string describing the error (or, in some cases, the exception string thrown by L).
115              
116             =cut
117              
118             sub connect {
119 2     2 1 962 my ($self) = @_;
120 2         2 my $t; # reference to Net::Telnet object or Mock::Net::Telnet
121 2 50       6 if ($self->opt('telnet_or','')) {
122             # Using mocked object for unit testing
123 2         6 $t = $self->opt('telnet_or');
124             } else {
125 0         0 $t = new Net::Telnet(Timeout => 3, Prompt => '/>$/');
126             }
127 2         6 $t->open($self->addr);
128 2         3 $self->{telnet_or} = $t;
129 2         4 my @results;
130 2         1001153 select(undef, undef, undef, 0.5); # to avoid command line pollution on remote end -- mysterious \0's injected.
131 2         19 eval { @results = $t->cmd("ver") };
  2         27  
132 2 50       8 if (@results) {
133 2         13 $self->cond = 'connected';
134 2         7 $self->{buffer} = \@results;
135 2         9 return $self->ok();
136             }
137 0         0 $self->cond = 'disconnected';
138 0         0 return $self->err("did not connect", $@);
139             }
140              
141             =head2 login
142              
143             my ($ok, $err) = $np->login;
144              
145             Attempt to log in to the C device. This must be done before attempting any other access or control methods.
146              
147             Once successfully logged in, it is inadvisable to terminate the connection without first calling the C method. The device can get into a sick state otherwise and misbehave in subsequent connections.
148              
149             After successful login, the C object will have a condition of "authenticated".
150              
151             Returns ('OK', '') on success, or ('ERROR', $error_message) on failure, where $error_message is a short string describing the error (or, in some cases, the exception string thrown by L).
152              
153             =cut
154              
155             sub login { # Can't use telnet_or->login method because Synaccess uses nonstandard prompt format that telnet_or cannot accomodate.
156 2     2 1 700 my ($self) = @_;
157 2 100       7 return $self->err("not connected") unless ($self->is_connected);
158 1         3 my $t = $self->{telnet_or};
159 1         6 $t->print(""); # Sometimes there's garbage on the commandline
160 1         4 $t->print("login");
161 1         1000150 sleep(1);
162 1         16 $t->print($self->user);
163 1         1000096 sleep(1);
164 1         22 $t->print($self->pass);
165 1         1000098 sleep(1);
166 1         9 my @results;
167 1         4 eval { @results = $t->cmd("ver") };
  1         12  
168 1 50       5 if (@results) {
169 1         6 $self->cond = 'authenticated';
170 1         6 $self->{buffer} = \@results;
171 1         4 return $self->ok();
172             }
173 0         0 $self->cond = 'disconnected';
174 0         0 return $self->err("login failed", $@);
175             }
176              
177             =head2 is_connected
178              
179             say $np->is_connected ? "still connected" : "not connected";
180              
181             Check the connection status. Returns 1 if C condition is "connected" or "authenticated", or 0 otherwise.
182              
183             =cut
184              
185             sub is_connected {
186 8     8 1 15 my ($self) = @_;
187 8 100       12 return 1 if ($self->cond eq 'connected');
188 7 100       9 return 1 if ($self->cond eq 'authenticated');
189 1         3 return 0;
190             }
191              
192             =head2 logout
193              
194             my ($ok, $err) = $np->logout;
195              
196             Needed to cleanly terminate the remote connection.
197              
198             After successful logout, the C object will have a condition of "disconnected", and further access will require calling L and L.
199              
200             Returns ('OK', '') on success, or ('ERROR', $error_message) on failure, where $error_message is a short string describing the error (or, in some cases, the exception string thrown by L).
201              
202             =cut
203              
204             sub logout {
205 1     1 1 433 my ($self) = @_;
206 1 50       18 return $self->err("not connected") unless ($self->is_connected);
207 1         17 my @results;
208 1         3 eval { @results = ($self->{telnet_or}->cmd("ver"), $self->{telnet_or}->cmd("logout")) };
  1         5  
209 1         5 $self->{telnet_or}->close();
210 1         2 $self->{telnet_or} = undef;
211 1         3 $self->cond = 'disconnected';
212 1         4 $self->{buffer} = [@results, $@];
213 1         3 return $self->ok();
214             # return $self->warn("might have disconnected uncleanly", $@);
215             }
216              
217             =head2 power_status
218              
219             my ($ok, $hashref) = $np->power_status;
220              
221             Retrieves the on/off status of the C device's power outlets in the form of a hashref which keys on the port number to either 0 (off) or 1 (on).
222              
223             For instance, if ports 1 2 and 3 are on and ports 4 and 5 are off, $hashref will reference:
224              
225             {1 => 1, 2 => 1, 3 => 1, 4 => 0, 5 => 0}
226              
227             Returns ('OK', $hashref) on success, or ('ERROR', $error_message) on failure, where $error_message is a short string describing the error (or, in some cases, the exception string thrown by L).
228              
229             =cut
230              
231             sub power_status {
232 2     2 1 674 my ($self) = @_;
233 2 50       7 return $self->err("not connected") unless ($self->is_connected);
234 2         3 my @results;
235 2         3 eval { @results = ($self->{telnet_or}->cmd("ver"),$self->{telnet_or}->cmd("pshow"),$self->{telnet_or}->cmd("ver")) };
  2         6  
236 2         5 $self->{buffer} = \@results;
237 2 50       5 return $self->err("telnet exception", $@) unless (@results);
238 2         4 my %ps;
239             # "\rPort | Name |Status\n","\r 1 | Outlet1 | ON | 2 | Outlet2 | ON |
240             # 3 | Outlet3 | ON | 4 | Outlet4 | OFF|
241             # 5 | Outlet5 | ON |\n"
242 2         4 foreach my $s (@results) {
243 8 100       21 next unless ($s =~ /^\s+\d+\s+\|\s+Outlet\d/);
244 2         17 foreach my $outlet (split(/(\d+\s+\|\s+Outlet\d+\s+\|\s+[OFN]+\s*\|)/, $s)) {
245 20 100       56 $ps{$1} = $2 eq 'ON' ? 1 : 0 if ($outlet =~ /\s+Outlet(\d+)\s+\|\s+([OFN]+)\s*\|/);
    100          
246             }
247             }
248 2 50       5 return $self->err("could not parse power status", \@results) unless (keys %ps);
249 2         4 return $self->ok(\%ps);
250             }
251              
252             =head2 power_set
253              
254             my ($ok, $hashref) = $np->power_set(3, 1);
255              
256             Turns a specified C device's power outlet on or off. Its first parameter is the outlet number (1..5 on my device), and the second parameter is either 0 (to turn it off) or 1 (to turn it on).
257              
258             Upon success, the returned $hashref is identical in format and semantics to the one returned by L.
259              
260             Returns ('OK', $hashref) on success, or ('ERROR', $error_message) on failure, where $error_message is a short string describing the error (or, in some cases, the exception string thrown by L).
261              
262             =cut
263              
264             sub power_set {
265 1     1 1 420 my ($self, $outlet, $on_or_off) = @_;
266 1 50       4 return $self->err("not connected") unless ($self->is_connected);
267 1         10 $self->{telnet_or}->cmd("ver");
268 1         9 $self->{telnet_or}->cmd("pset $outlet $on_or_off");
269 1         3 $self->{telnet_or}->cmd("ver");
270 1         2 my ($ok, $ps_hr, @errs) = $self->power_status;
271 1 50       4 return ('ERROR', $ps_hr, @errs) unless ($ok eq 'OK');
272 1 50       4 my $normalized_on_or_off = $on_or_off ? 1 : 0;
273 1 50       4 return $self->warn('outlet number out of range') unless(defined($ps_hr->{$outlet}));
274 1 50       5 return $self->err('unexpected outlet status') unless($ps_hr->{$outlet} == $normalized_on_or_off);
275 1         3 return $self->ok($ps_hr);
276             }
277              
278             =head2 status
279              
280             my ($ok, $hashref) = $np->status;
281              
282             Retrieves the full system status of the C device. The returned hashref is a bit complex:
283              
284             {
285             'src_ip' => '0.0.0.0',
286             's_mask' => '255.255.0.0',
287             'source' => 'static',
288             'port_telnet' => '23',
289             'port_http' => '80',
290             'model' => 'NP-05B',
291             'mask' => '255.255.0.0',
292             'eth' => 'on',
293             'ip' => '192.168.1.100',
294             's_ip' => '192.168.1.100',
295             's_gw' => '192.168.1.1',
296             'mac' => '00:90:c2:12:34:56',
297             'power_hr' => {
298             '2' => 1,
299             '5' => 1,
300             '3' => 1,
301             '1' => 1,
302             '4' => 1
303             },
304             'gw' => '192.168.1.1'
305             }
306              
307             Returns ('OK', $hashref) on success, or ('ERROR', $error_message) on failure, where $error_message is a short string describing the error (or, in some cases, the exception string thrown by L).
308              
309             =cut
310              
311             sub status {
312 2     2 1 808 my ($self) = @_;
313 2 50       6 return $self->err("not connected") unless ($self->is_connected);
314 2         3 my @results;
315 2         3 eval { @results = ($self->{telnet_or}->cmd("ver"),$self->{telnet_or}->cmd("sysshow"),$self->{telnet_or}->cmd("ver")) };
  2         6  
316 2         5 $self->{buffer} = \@results;
317 2 50       5 return $self->err("telnet exception", $@) unless (@results);
318 2         3 my %st_h;
319 2         4 push @results, ''; # to make lookahead safe
320 2         5 for (my $i = 0; $i < @results; $i++) { # yes, really, a C-style for loop .. easiest way to parse this evil soup
321 26         27 my $s = $results[$i];
322 26         20 my $v = $results[$i+1];
323 26 50       37 if ($s =~ /^\s*Sys\s?Name\s*:\s*([^\s]+)/) { $st_h{'model'} = $1; }
  0         0  
324 26 50 33     35 if ($s =~ /^\s*IP Static or DHCP/ && $v =~ /Using (\w+)/) { $st_h{'source'} = lc($1); }
  0         0  
325 26 100       36 if ($s =~ /^\s*IP-Mask-GW\s*:\s*([^-]+)-([^-]+)-([^\s]+)/) { ($st_h{'ip'}, $st_h{'mask'}, $st_h{'gw'}) = ($1, $2, $3); }
  2         10  
326 26 100       34 if ($s =~ /^\s*Static IP\/Mask\/Gateway\s*:\s*([^-]+)-([^-]+)-([^\s]+)/) { ($st_h{'s_ip'}, $st_h{'s_mask'}, $st_h{'s_gw'}) = ($1, $2, $3); }
  2         7  
327 26 100       32 if ($s =~ /^\s*Ethernet Port is (\w+)/) { $st_h{'eth'} = lc($1); }
  2         5  
328 26 100       33 if ($s =~ /^\s*HTTP\/Telnet Port .s\s*:\s*(\d+)[^\d]+(\d+)/) { ($st_h{'port_http'}, $st_h{'port_telnet'}) = ($1, $2); }
  2         7  
329 26 100       34 if ($s =~ /^\s*MAC Address\s*:\s*([\w\:]+)/) { $st_h{'mac'} = lc($1); }
  2         5  
330 26 100 66     44 if ($s =~ /^\s*Designated Source IP/ && $v =~ /^\s*(\d+\.\d+\.\d+\.\d+)/) { $st_h{'src_ip'} = $1; }
  2         5  
331 26 100       57 if ($s =~ /^\s*Outlet Status[^:]+: ([\d\s]+)/) {
332 2         3 my $outlets = $1;
333 2         3 my $ix = 1;
334 2         3 $st_h{'power_hr'} = {};
335 2         8 foreach my $o (split(/\s+/, $outlets)) {
336 10         19 $st_h{'power_hr'}->{$ix++} = int($o);
337             }
338             }
339             }
340 2 50       7 return $self->err('no recognizable status', \@results) unless (keys %st_h);
341 2         3 $self->{status} = \%st_h;
342 2         4 return $self->ok(\%st_h);
343             }
344              
345             =head1 ACCESSORS
346              
347             =head2 addr
348              
349             my $address = $np->addr;
350             $np->addr = '10.0.0.6';
351              
352             Get/set the C attribute, which determines where L will attempt to open a connection.
353              
354             =head2 user
355              
356             my $username = $np->user;
357             $np->addr = 'bob';
358              
359             Get/set the C attribute, which must be correct for L to work.
360              
361             =head2 pass
362              
363             my $password = $np->pass;
364             $np->pass = 'sekrit';
365              
366             Get/set the C attribute, which must be correct for L to work.
367              
368             =head2 cond
369              
370             my $condition = $np->cond;
371             $np->addr = 'disconnected';
372              
373             Get/set the C attribute, which reflects the connectedness/authentication status of the object.
374              
375             Setting this attribute yourself is B.
376              
377             =cut
378              
379 8     8 1 29 sub addr :lvalue { $_[0]->{addr} }
380 7     7 1 38 sub user :lvalue { $_[0]->{user} }
381 7     7 1 31 sub pass :lvalue { $_[0]->{pass} }
382 21     21 1 64 sub cond :lvalue { $_[0]->{cond} }
383              
384             sub all_is_well {
385 9     9 0 12 my ($self) = @_;
386 9         13 $self->{ok} = 'OK';
387 9         14 $self->{err} = '';
388 9         14 $self->{err_ar} = [];
389 9         11 return;
390             }
391              
392             sub opt {
393 16     16 0 29 my ($self, $name, $default_value, $alt_hr) = @_;
394 16         44 return def($self->{opt_hr}->{$name}, $alt_hr->{$name}, $default_value);
395             }
396              
397             sub def {
398 16 100   16 0 21 foreach my $v (@_) { return $v if (defined($v)); }
  40         93  
399 0         0 return undef;
400             }
401              
402             sub ok {
403 9     9 0 25 my $self = shift(@_);
404 9         21 $self->all_is_well();
405 9         38 return ('OK', @_);
406             }
407              
408             sub err {
409 1     1 0 2 my $self = shift(@_);
410 1         1 $self->{n_err}++;
411 1         2 $self->{err} = $_[0];
412 1         2 $self->{err_ar} = \@_;
413 1         3 return ('ERROR', @_);
414             }
415              
416             sub warn {
417 0     0 0   my $self = shift(@_);
418 0           $self->{n_warn}++;
419 0           $self->{err} = $_[0];
420 0           $self->{err_ar} = \@_;
421 0           return ('WARNING', @_);
422             }
423              
424             =head1 CAVEATS
425              
426             This module works for the specific device shipped to the author, and might not work for you if Synaccess changes the behavior of their product.
427              
428             The C can misbehave in odd ways if commands are sent to it too quickly or if connections are not terminated cleanly. The module uses short delays which helps mitigate some of these problems. (Despite these problems, the C is pretty good value for the price.)
429              
430             =head1 TO DO
431              
432             =over 4
433              
434             =item * Support commands for changing the C network configuration.
435              
436             =item * Improve the unit tests, which are a little shallow.
437              
438             =item * Support nonstandard port mapping.
439              
440             =back
441              
442             =head1 SEE ALSO
443              
444             L - a light CLI utility wrapping this module. Not distributed with C to avoid spurious dependencies.
445              
446             =head1 AUTHOR
447              
448             TTK Ciar Ettk@ciar.orgE
449              
450             =head1 COPYRIGHT
451              
452             You may use and distribute this module under the same terms as Perl itself.
453              
454             =cut
455              
456             1;