File Coverage

blib/lib/Device/Onkyo.pm
Criterion Covered Total %
statement 150 182 82.4
branch 42 74 56.7
condition 1 3 33.3
subroutine 24 29 82.7
pod 12 12 100.0
total 229 300 76.3


line stmt bran cond sub pod time code
1 4     4   135589 use strict;
  4         7  
  4         130  
2 4     4   16 use warnings;
  4         4  
  4         168  
3             package Device::Onkyo;
4             $Device::Onkyo::VERSION = '1.143640';
5 4     4   16 use Carp qw/croak carp/;
  4         6  
  4         206  
6 4     4   14 use Fcntl;
  4         4  
  4         761  
7 4     4   2458 use IO::Select;
  4         4858  
  4         155  
8 4     4   1108 use Socket;
  4         7418  
  4         1661  
9 4     4   928 use Symbol qw(gensym);
  4         1259  
  4         191  
10 4     4   2790 use Time::HiRes;
  4         5007  
  4         18  
11              
12 4     4   395 use constant DEBUG => $ENV{DEVICE_ONKYO_DEBUG};
  4         6  
  4         8507  
13              
14             # ABSTRACT: Perl module to control Onkyo/Integra AV equipment
15              
16              
17             sub new {
18 3     3 1 2875 my ($pkg, %p) = @_;
19 3         94 my $self = bless {
20             _buf => '',
21             _q => [],
22             type => 'eISCP',
23             port => 60128,
24             baud => 9600,
25             device => 'discover',
26             broadcast_source_ip => '0.0.0.0',
27             broadcast_dest_ip => '255.255.255.255',
28             %p
29             }, $pkg;
30 3 100       20 if (exists $p{filehandle}) {
31 2         12 delete $self->{device};
32             } else {
33 1         19 $self->_open();
34             }
35 3         13 $self;
36             }
37              
38              
39 0     0 1 0 sub device { shift->{device} }
40              
41              
42 0     0 1 0 sub type { shift->{type} }
43              
44              
45 0     0 1 0 sub baud { shift->{baud} }
46              
47              
48 3     3 1 1241 sub port { shift->{port} }
49              
50              
51 3     3 1 111 sub filehandle { shift->{filehandle} }
52              
53             sub _open {
54 1     1   7 my $self = shift;
55 1 50       54 if ($self->{device} =~ m![/\\]!) {
56 0         0 $self->_open_serial_port(@_);
57             } else {
58 1 50       21 if ($self->{device} eq 'discover') {
59 1         15 my $devices = $self->discover;
60 1         2 my ($ip, $port) = @{$devices->[0]};
  1         3  
61 1         3 $self->{port} = $port;
62 1         5 $self->{device} = $ip.':'.$port
63             }
64 1         6 $self->_open_tcp_port(@_);
65             }
66             }
67              
68             sub _open_tcp_port {
69 1     1   1 my $self = shift;
70 1         2 my $dev = $self->{device};
71 1         2 print STDERR "Opening $dev as tcp socket\n" if DEBUG;
72 1         24 require IO::Socket::INET; import IO::Socket::INET;
  1         38  
73 1 50       1027 if ($dev =~ s/:(\d+)$//) {
74 1         19 $self->{port} = $1;
75             }
76 1 50       5 my $fh = IO::Socket::INET->new($dev.':'.$self->port) or
77             croak "TCP connect to '$dev' failed: $!";
78 1         3200 return $self->{filehandle} = $fh;
79             }
80              
81             sub _open_serial_port {
82 0     0   0 my $self = shift;
83 0         0 $self->{type} = 'ISCP';
84 0         0 my $dev = $self->{device};
85 0         0 print STDERR "Opening $dev as serial port\n" if DEBUG;
86 0         0 eval { require Device::SerialPort; };
  0         0  
87 0 0       0 die "Device::SerialPort is required for serial port devices\n$@\n" if ($@);
88 0         0 import Device::SerialPort qw/:PARAM :STAT 0.07/;
89 0         0 my $fh = gensym();
90 0 0       0 my $sport = tie (*$fh, 'Device::SerialPort', $dev) or
91             croak "Could not tie serial port, $dev, to file handle: $!";
92 0         0 $sport->baudrate($self->baud);
93 0         0 $sport->databits(8);
94 0         0 $sport->parity("none");
95 0         0 $sport->stopbits(1);
96 0         0 $sport->datatype("raw");
97 0         0 $sport->write_settings();
98              
99 0 0       0 sysopen $fh, $dev, O_RDWR|O_NOCTTY|O_NDELAY or
100             croak "sysopen of '$dev' failed: $!";
101 0         0 $fh->autoflush(1);
102 0         0 return $self->{filehandle} = $fh;
103             }
104              
105              
106             sub read {
107 4     4 1 1679 my ($self, $timeout) = @_;
108 4         10 my $res = $self->read_one(\$self->{_buf});
109 4 100       13 $self->_write_now() if (defined $res);
110 4 100       15 return $res if (defined $res);
111 2         4 my $fh = $self->filehandle;
112 2         10 my $sel = IO::Select->new($fh);
113 2         74 do {
114 2         5 my $start = $self->_time_now;
115 2 50       17 $sel->can_read($timeout) or return;
116 2         67 my $bytes = sysread $fh, $self->{_buf}, 2048, length $self->{_buf};
117 2         3 $self->{_last_read} = $self->_time_now;
118 2 50       5 $timeout -= $self->{_last_read} - $start if (defined $timeout);
119 2 50       143 croak defined $bytes ? 'closed' : 'error: '.$! unless ($bytes);
    100          
120 1         2 $res = $self->read_one(\$self->{_buf});
121 1 50       5 $self->_write_now() if (defined $res);
122 1 50       10 return $res if (defined $res);
123             } while (1);
124             }
125              
126              
127             sub read_one {
128 6     6 1 7 my ($self, $rbuf) = @_;
129 6 100       15 return unless ($$rbuf);
130              
131 4         4 print STDERR "rbuf=", _hexdump($$rbuf), "\n" if DEBUG;
132              
133 4 100       11 if ($self->{type} eq 'eISCP') {
134 2         4 my $length = length $$rbuf;
135 2 50       6 return unless ($length >= 16);
136 2         16 my ($magic, $header_size,
137             $data_size, $version, $res1, $res2, $res3) = unpack 'a4 N N C4', $$rbuf;
138 2 50       10 croak "Unexpected magic: expected 'ISCP', got '$magic'\n"
139             unless ($magic eq 'ISCP');
140 2 50       8 return unless ($length >= $header_size+$data_size);
141 2         5 substr $$rbuf, 0, $header_size, '';
142 2 50       12 carp(sprintf "Unexpected version: expected '0x01', got '0x%02x'\n",
143             $version) unless ($version == 0x01);
144 2 50       7 carp(sprintf "Unexpected header size: expected '0x10', got '0x%02x'\n",
145             $header_size) unless ($header_size == 0x10);
146 2         8 my $body = substr $$rbuf, 0, $data_size, '';
147 2         5 my $sd = substr $body, 0, 2, '';
148 2         27 $body =~ s/[\032\r\n]+$//;
149 2 50       11 carp "Unexpected start/destination: expected '!1', got '$sd'\n"
150             unless ($sd eq '!1');
151 2         7 return $body;
152             } else {
153 2 50       13 return unless ($$rbuf =~ s/^(..)(....*?)[\032\r\n]+//);
154 2         6 my ($sd, $body) = ($1, $2);
155 2 50       4 carp "Unexpected start/destination: expected '!1', got '$sd'\n"
156             unless ($sd eq '!1');
157 2         5 return $body;
158             }
159             }
160              
161             sub _time_now {
162 5     5   35 Time::HiRes::time
163             }
164              
165              
166             # 4953 4350 0000 0010 0000 000b 0100 0000 ISCP............
167             # 2178 4543 4e51 5354 4e0d 0a !xECNQSTN\r\n
168              
169             sub discover {
170 1     1 1 5 my $self = shift;
171 1         4 my $s;
172 1         131 socket $s, PF_INET, SOCK_DGRAM, getprotobyname('udp');
173 1         4 setsockopt $s, SOL_SOCKET, SO_BROADCAST, 1;
174 1         3 binmode $s;
175 1         22 bind $s, sockaddr_in(0, inet_aton($self->{broadcast_source_ip}));
176 1         21 send($s,
177             pack("a* N N N a*",
178             'ISCP', 0x10, 0xb, 0x01000000, "!xECNQSTN\r\n"),
179             0,
180             sockaddr_in($self->port, inet_aton($self->{broadcast_dest_ip})));
181 1         195 my $sel = IO::Select->new($s);
182 1 50       80 $sel->can_read(10) or die;
183 1         205 my $sender = recv $s, my $buf, 2048, 0;
184 1 50       103 croak 'error: '.$! unless (defined $sender);
185              
186 1         6 my ($port, $addr) = sockaddr_in($sender);
187 1         25 my $ip = inet_ntoa($addr);
188 1         3 my $b = $buf;
189 1         12 my $msg = $self->read_one(\$b); # don't uncork writes
190 1         8 ($port) = ($msg =~ m!/(\d+)/../[0-9a-f]{12}!i);
191 1         1 print STDERR "discovered: $ip:$port ($msg)\n" if DEBUG;
192 1         42 return [[$ip, $port]];
193             }
194              
195              
196             sub write {
197 1     1 1 2 my ($self, $cmd, $cb) = @_;
198 1         2 print STDERR "queuing: $cmd\n" if DEBUG;
199 1         4 my $str = $self->pack($cmd);
200 1         2 push @{$self->{_q}}, [$str, $cmd, $cb];
  1         4  
201 1 50       6 $self->_write_now unless ($self->{_waiting});
202 1         3 1;
203             }
204              
205             sub _write_now {
206 4     4   5 my $self = shift;
207 4         6 my $rec = shift @{$self->{_q}};
  4         6  
208 4         8 my $wait_rec = delete $self->{_waiting};
209 4 50 33     11 if ($wait_rec && $wait_rec->[1]) {
210 0         0 my ($str, $cmd, $cb) = @{$wait_rec->[1]};
  0         0  
211 0 0       0 $cb->() if ($cb);
212             }
213 4 100       11 return unless (defined $rec);
214 1         3 $self->_real_write(@$rec);
215 1         5 $self->{_waiting} = [ $self->_time_now, $rec ];
216             }
217              
218             sub _real_write {
219 1     1   2 my ($self, $str, $desc, $cb) = @_;
220 1         1 print STDERR "sending: $desc\n ", _hexdump($str), "\n" if DEBUG;
221 1         4 syswrite $self->filehandle, $str, length $str;
222             }
223              
224              
225             sub pack {
226 1     1 1 1 my $self = shift;
227 1         2 my $d = '!1'.$_[0];
228 1 50       4 if ($self->{type} eq 'eISCP') {
229             # 4953 4350 0000 0010 0000 000a 0100 0000 ISCP............
230             # 2131 4d56 4c32 381a 0d0a !1MVL28...
231 1         1 $d .= "\r";
232 1         11 pack("a* N N N a*",
233             'ISCP', 0x10, (length $d), 0x01000000, $d);
234             } else {
235 0         0 $d .= "\r\n";
236             }
237             }
238              
239             sub _canon_command {
240 326     326   261 my $str = shift;
241 326         327 $str = lc $str;
242 326         272 $str =~ s/(?:question|query|qstn)/?/g;
243 326         248 $str =~ s/^master\ //g;
244 326         261 $str =~ s/volume/vol/g;
245 326         228 $str =~ s/centre/center/g;
246 326         259 $str =~ s/up/+/g;
247 326         254 $str =~ s/down/-/g;
248 326         656 $str =~ s/\s+//g;
249 326         613 $str;
250             }
251              
252             our %command_map =
253             (
254             'power on' => 'PWR01',
255             'power off' => 'PWR00',
256             'power standby' => 'PWR00',
257             'power?' => 'PWRQSTN',
258             'mute' => 'AMT00',
259             'unmute' => 'AMT01',
260             'toggle mute' => 'AMTTG',
261             'mute?' => 'AMTQSTN',
262             'speaker a on' => 'SPA01',
263             'speaker a off' => 'SPA00',
264             'toggle speaker a' => 'SPAUP',
265             'speaker a?' => 'SPAQSTN',
266             'speaker b on' => 'SPB01',
267             'speaker b off' => 'SPB00',
268             'toggle speaker b' => 'SPBUP',
269             'speaker b?' => 'SPBQSTN',
270             'volume+' => 'MVLUP',
271             'volume-' => 'MVLDOWN',
272             'volume?' => 'MVLQSTN',
273              
274             'front bass+' => 'TFRBUP',
275             'front bass-' => 'TFRBDOWN',
276             'front treble+' => 'TFRTUP',
277             'front treble-' => 'TFRTDOWN',
278             'front tone?' => 'TFRQSTN',
279              
280             'front wide bass+' => 'TFWBUP',
281             'front wide bass-' => 'TFWBDOWN',
282             'front wide treble+' => 'TFWTUP',
283             'front wide treble-' => 'TFWTDOWN',
284             'front wide tone?' => 'TFWQSTN',
285              
286             'front high bass+' => 'TFHBUP',
287             'front high bass-' => 'TFHBDOWN',
288             'front high treble+' => 'TFHTUP',
289             'front high treble-' => 'TFHTDOWN',
290             'front high tone?' => 'TFHQSTN',
291              
292             'center bass+' => 'TCTBUP',
293             'center bass-' => 'TCTBDOWN',
294             'center treble+' => 'TCTTUP',
295             'center treble-' => 'TCTTDOWN',
296             'center tone?' => 'TCTQSTN',
297              
298             'surround bass+' => 'TSRBUP',
299             'surround bass-' => 'TSRBDOWN',
300             'surround treble+' => 'TSRTUP',
301             'surround treble-' => 'TSRTDOWN',
302             'surround tone?' => 'TSRQSTN',
303              
304             'surround back bass+' => 'TSBBUP',
305             'surround back bass-' => 'TSBBDOWN',
306             'surround back treble+' => 'TSBTUP',
307             'surround back treble-' => 'TSBTDOWN',
308             'surround back tone?' => 'TSBQSTN',
309              
310             'subwoofer bass+' => 'TSWBUP',
311             'subwoofer bass-' => 'TSWBDOWN',
312             'subwoofer treble+' => 'TSWTUP',
313             'subwoofer treble-' => 'TSWTDOWN',
314             'subwoofer tone?' => 'TSWQSTN',
315              
316             'sleep off' => 'SLPOFF',
317             'sleep?' => 'SLPQSTN',
318              
319             'display0' => 'DIF00',
320             'display1' => 'DIF01',
321             'display2' => 'DIF02',
322             'display3' => 'DIF03',
323             'display toggle' => 'DIFTG',
324             'display?' => 'DIFQSTN',
325              
326             'dimmer bright' => 'DIM00',
327             'dimmer dim' => 'DIM01',
328             'dimmer dark' => 'DIM02',
329             'dimmer off' => 'DIM03',
330             'dimmer ledoff' => 'DIM08',
331             'dimmer toggle' => 'DIMTG',
332             'dimmer?' => 'DIMQSTN',
333              
334             'menu key' => 'OSDMENU',
335             'up key' => 'OSDUP',
336             'down key' => 'OSDDOWN',
337             'right key' => 'OSDRIGHT',
338             'left key' => 'OSDLEFT',
339             'enter key' => 'OSDENTER',
340             'exit key' => 'OSDEXIT',
341             'audio key' => 'OSDAUDIO',
342             'video key' => 'OSDVIDEO',
343             'home key' => 'OSDHOME',
344              
345             # 'memory store' => 'MEMSTR',
346             # 'memory recall' => 'MEMRCL',
347             # 'memory lock' => 'MEMLOCK',
348             # 'memory unlock' => 'MEMUNLK',
349              
350             );
351             foreach my $k (keys %command_map) {
352             $command_map{_canon_command($k)} = delete $command_map{$k};
353             }
354              
355              
356             sub command {
357 10     10 1 6154 my ($self, $cmd, $cb) = @_;
358 10         27 my $canon = _canon_command($cmd);
359 10         23 my $str = $command_map{$canon};
360 10 100       32 if (defined $str) {
    100          
    50          
    50          
361 7         9 $cmd = $str;
362             } elsif ($canon =~ /^vol(100|[0-9][0-9]?)%?$/) {
363 2         10 $cmd = sprintf 'MVL%02x', $1;
364             } elsif ($canon =~ /^sleep(90|[0-8][0-9]|[1-9])m\w+?$/) {
365 0         0 $cmd = sprintf 'SLP%02x', $1;
366             } elsif ($cmd !~ /^[A-Z][A-Z][A-Z]/) {
367 1         179 croak ref($self)."->command: '$cmd' does not match /^[A-Z][A-Z][A-Z]/";
368             }
369 9         21 $self->write($cmd, $cb);
370             }
371              
372             sub _hexdump {
373 0     0     my $s = shift;
374 0           my $r = unpack 'H*', $s;
375 0           $s =~ s/[^ -~]/./g;
376 0           $r.' '.$s;
377             }
378              
379             1;
380              
381             __END__