onkyo_eiscp_ruby 0.0.3 → 2.1.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -5
- data/README.md +182 -32
- data/VERSION +1 -1
- data/bin/mock_receiver.rb +25 -0
- data/bin/onkyo.rb +85 -25
- data/bin/{onkyo-server.rb → onkyo_server.rb} +2 -1
- data/eiscp-commands.yaml +3911 -3900
- data/lib/eiscp.rb +8 -5
- data/lib/eiscp/dictionary.rb +54 -0
- data/lib/eiscp/dictionary/dictionary_generators.rb +63 -0
- data/lib/eiscp/dictionary/dictionary_helpers.rb +121 -0
- data/lib/eiscp/message.rb +98 -74
- data/lib/eiscp/parser.rb +24 -0
- data/lib/eiscp/parser/dynamic_value_parser.rb +9 -0
- data/lib/eiscp/parser/eiscp_parser.rb +37 -0
- data/lib/eiscp/parser/human_readable_parser.rb +29 -0
- data/lib/eiscp/parser/iscp_parser.rb +28 -0
- data/lib/eiscp/receiver.rb +158 -135
- data/lib/eiscp/receiver/command_methods.rb +28 -0
- data/lib/eiscp/receiver/discovery.rb +49 -0
- data/onkyo_eiscp_ruby.gemspec +15 -13
- data/test/tc_dictionary.rb +45 -0
- data/test/tc_message.rb +11 -12
- data/test/tc_parser.rb +34 -0
- data/test/tc_receiver.rb +4 -3
- metadata +23 -12
- data/lib/eiscp/command.rb +0 -99
- data/lib/eiscp/mock_receiver.rb +0 -22
- data/test/tc_command.rb +0 -43
data/lib/eiscp/parser.rb
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative './parser/eiscp_parser'
|
4
|
+
require_relative './parser/iscp_parser'
|
5
|
+
require_relative './parser/human_readable_parser'
|
6
|
+
|
7
|
+
module EISCP
|
8
|
+
# This module provides an interface to the other parser modules.
|
9
|
+
#
|
10
|
+
module Parser
|
11
|
+
# Passes the string to the proper parser module's #parse method.
|
12
|
+
#
|
13
|
+
def self.parse(string)
|
14
|
+
case string
|
15
|
+
when /^ISCP/
|
16
|
+
EISCPParser.parse(string)
|
17
|
+
when ISCPParser::REGEX
|
18
|
+
ISCPParser.parse(string)
|
19
|
+
else
|
20
|
+
HumanReadableParser.parse(string)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative './iscp_parser'
|
4
|
+
|
5
|
+
module EISCP
|
6
|
+
module Parser
|
7
|
+
# This module parses an eISCP string and returns a Message object
|
8
|
+
#
|
9
|
+
module EISCPParser
|
10
|
+
def self.parse(string)
|
11
|
+
array = string.unpack('A4NNCa3A*')
|
12
|
+
msg = ISCPParser.parse(array[5])
|
13
|
+
packet = Message.new(
|
14
|
+
command: msg.command,
|
15
|
+
value: msg.value,
|
16
|
+
terminator: msg.terminator,
|
17
|
+
unit_type: msg.unit_type,
|
18
|
+
start: msg.start
|
19
|
+
)
|
20
|
+
packet.header = {
|
21
|
+
magic: array[0],
|
22
|
+
header_size: array[1],
|
23
|
+
data_size: array[2],
|
24
|
+
version: array[3],
|
25
|
+
reserved: array[4]
|
26
|
+
}
|
27
|
+
packet
|
28
|
+
end
|
29
|
+
|
30
|
+
def self.validate(packet)
|
31
|
+
packet.header.header_size.size == packet.command.size
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
class EISCPParserException < RuntimeError; end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../dictionary'
|
4
|
+
|
5
|
+
module EISCP
|
6
|
+
module Parser
|
7
|
+
# This module parses a human readable command and returns a Message object
|
8
|
+
#
|
9
|
+
module HumanReadableParser
|
10
|
+
def self.parse(string)
|
11
|
+
array = string.split(' ')
|
12
|
+
|
13
|
+
parsed_zone = if Dictionary.zones.include? array[0]
|
14
|
+
array.shift
|
15
|
+
else
|
16
|
+
Dictionary::DEFAULT_ZONE
|
17
|
+
end
|
18
|
+
|
19
|
+
command_name = array.shift
|
20
|
+
value_name = array.join(' ')
|
21
|
+
command = Dictionary.command_name_to_command(command_name, parsed_zone)
|
22
|
+
value = Dictionary.command_value_name_to_value(command, value_name)
|
23
|
+
return nil if command.nil? || value.nil?
|
24
|
+
|
25
|
+
Message.new(command: command, value: value)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module EISCP
|
4
|
+
module Parser
|
5
|
+
# This module parses an ISCP string and returns a Message object
|
6
|
+
#
|
7
|
+
module ISCPParser
|
8
|
+
# Regexp for parsing ISCP messages
|
9
|
+
REGEX = /(?<start>!)?(?<unit_type>(\d|x))?(?<command>[A-Z]{3})\s?(?<value>.*?)(?<terminator>[[:cntrl:]]*$)/.freeze
|
10
|
+
def self.parse(string)
|
11
|
+
match = string.match(REGEX)
|
12
|
+
|
13
|
+
# Convert MatchData to Hash
|
14
|
+
hash = Hash[match.names.zip(match.captures)]
|
15
|
+
|
16
|
+
# Remove nil and blank values
|
17
|
+
hash.delete_if { |_, v| v.nil? || v == '' }
|
18
|
+
|
19
|
+
# Convert keys to symbols
|
20
|
+
hash = hash.each_with_object({}) do |(k, v), memo|
|
21
|
+
memo[k.to_sym] = v
|
22
|
+
end
|
23
|
+
|
24
|
+
Message.new(**hash)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
data/lib/eiscp/receiver.rb
CHANGED
@@ -1,180 +1,203 @@
|
|
1
|
-
|
2
|
-
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
3
|
require 'resolv'
|
4
|
+
require_relative './receiver/discovery'
|
5
|
+
require_relative './receiver/command_methods'
|
4
6
|
|
5
7
|
module EISCP
|
8
|
+
# The EISCP::Receiver class is used to communicate with one or more
|
9
|
+
# receivers the network. A Receiver can be instantiated automatically
|
10
|
+
# using discovery, or by hostname and port.
|
11
|
+
#
|
12
|
+
# receiver = EISCP::Receiver.new # find first receiver on LAN
|
13
|
+
# receiver = EISCP::Receiver.new('192.168.1.12') # default port
|
14
|
+
# receiver = EISCP::Receiver.new('192.168.1.12', 60129) # non standard port
|
15
|
+
#
|
6
16
|
class Receiver
|
17
|
+
extend Discovery
|
18
|
+
include CommandMethods
|
7
19
|
|
20
|
+
# Receiver's IP address
|
8
21
|
attr_accessor :host
|
22
|
+
# Receiver's model string
|
9
23
|
attr_accessor :model
|
24
|
+
# Receiver's ISCP port
|
10
25
|
attr_accessor :port
|
26
|
+
# Receiver's region
|
11
27
|
attr_accessor :area
|
28
|
+
# Receiver's MAC address
|
12
29
|
attr_accessor :mac_address
|
13
30
|
|
14
|
-
|
15
|
-
|
31
|
+
# State object
|
32
|
+
attr_accessor :state
|
33
|
+
|
34
|
+
# Receiver's connection socket
|
35
|
+
attr_reader :socket
|
36
|
+
# Receiver's connection thread
|
37
|
+
attr_reader :thread
|
38
|
+
|
39
|
+
# Default connection timeout value in seconds
|
40
|
+
DEFAULT_TIMEOUT = 0.5
|
16
41
|
|
17
|
-
#
|
42
|
+
# Default Onkyo eISCP port
|
43
|
+
ONKYO_PORT = 60_128
|
44
|
+
|
45
|
+
# Create a new EISCP::Receiver object to communicate with a receiver.
|
18
46
|
# If no host is given, use auto discovery and create a
|
19
47
|
# receiver object using the first host to respond.
|
20
|
-
|
21
|
-
def initialize(host = nil,
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
48
|
+
#
|
49
|
+
def initialize(host = nil, info_hash = {}, &block)
|
50
|
+
# Initialize state
|
51
|
+
#
|
52
|
+
@state = {}
|
53
|
+
# This defines the behavior of CommandMethods by telling it what to do
|
54
|
+
# with the Message object that results from a CommandMethod being called.
|
55
|
+
# All we're doing here is calling #send_recv
|
56
|
+
#
|
57
|
+
command_method_proc = proc { |msg| send_recv msg }
|
58
|
+
CommandMethods.generate(&command_method_proc)
|
59
|
+
|
60
|
+
# This proc sets the four ECN attributes and initiates a connection to the
|
61
|
+
# receiver.
|
62
|
+
#
|
63
|
+
set_attrs = lambda do |hash|
|
64
|
+
@model = hash[:model]
|
65
|
+
@port = hash[:port]
|
66
|
+
@area = hash[:area]
|
67
|
+
@mac_address = hash[:mac_address]
|
68
|
+
connect(&block) if block_given?
|
34
69
|
end
|
35
|
-
end
|
36
70
|
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
@mac_address = array.shift.split("\x19")[0]
|
43
|
-
return self
|
44
|
-
end
|
71
|
+
# This lambda sets the host IP after resolving it
|
72
|
+
#
|
73
|
+
set_host = lambda do |hostname|
|
74
|
+
@host = Resolv.getaddress hostname
|
75
|
+
end
|
45
76
|
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
77
|
+
# When no host is given, the first discovered host is returned.
|
78
|
+
#
|
79
|
+
# When a host is given without a hash ::discover will be used to find
|
80
|
+
# a receiver that matches.
|
81
|
+
#
|
82
|
+
# Else, use the given host and hash to create a new Receiver object.
|
83
|
+
# This is how ::discover creates Receivers.
|
84
|
+
#
|
85
|
+
if host.nil?
|
86
|
+
first_found = Receiver.discover[0]
|
87
|
+
set_host.call first_found.host
|
88
|
+
set_attrs.call first_found.ecn_hash
|
89
|
+
elsif info_hash.empty?
|
90
|
+
set_host.call host
|
91
|
+
Receiver.discover.each do |receiver|
|
92
|
+
receiver.host == @host && set_attrs.call(receiver.ecn_hash)
|
50
93
|
end
|
94
|
+
else
|
95
|
+
set_host.call host
|
96
|
+
set_attrs.call info_hash
|
51
97
|
end
|
52
98
|
end
|
53
99
|
|
54
|
-
#
|
55
|
-
#
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
100
|
+
# Manages the thread and uses the same block passed to through #connect.
|
101
|
+
#
|
102
|
+
def update_thread
|
103
|
+
# Kill thread if it exists
|
104
|
+
thread && @thread.kill
|
105
|
+
@thread = Thread.new do
|
106
|
+
loop do
|
107
|
+
message = recv
|
108
|
+
@state[message.command] = message.value
|
109
|
+
yield(message) if block_given?
|
62
110
|
end
|
63
|
-
return array
|
64
111
|
end
|
65
112
|
end
|
113
|
+
private :update_thread
|
66
114
|
|
67
|
-
#
|
68
|
-
#
|
69
|
-
|
70
|
-
def
|
71
|
-
|
72
|
-
|
115
|
+
# This creates a socket conection to the receiver if one doesn't exist,
|
116
|
+
# and updates or sets the callback block if one is passed.
|
117
|
+
#
|
118
|
+
def connect(&block)
|
119
|
+
@socket ||= TCPSocket.new(@host, @port)
|
120
|
+
update_thread(&block)
|
121
|
+
rescue StandardError => e
|
122
|
+
puts e
|
73
123
|
end
|
74
124
|
|
75
|
-
#
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
if ready != nil
|
82
|
-
then readable = ready[0]
|
83
|
-
else
|
84
|
-
return data
|
85
|
-
end
|
86
|
-
|
87
|
-
|
88
|
-
readable.each do |socket|
|
89
|
-
begin
|
90
|
-
if socket == sock
|
91
|
-
data << sock.recv_nonblock(1024).chomp
|
92
|
-
end
|
93
|
-
rescue IO::WaitReadable
|
94
|
-
retry
|
95
|
-
end
|
96
|
-
end
|
97
|
-
|
98
|
-
end
|
125
|
+
# Disconnect from the receiver by closing the socket and killing the
|
126
|
+
# connection thread.
|
127
|
+
#
|
128
|
+
def disconnect
|
129
|
+
@thread.kill
|
130
|
+
@socket.close
|
99
131
|
end
|
100
132
|
|
101
|
-
#
|
102
|
-
#
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
data = []
|
109
|
-
while true
|
110
|
-
ready = IO.select([sock], nil, nil, 0.5)
|
111
|
-
if ready != nil
|
112
|
-
then readable = ready[0]
|
113
|
-
else
|
114
|
-
return data
|
115
|
-
end
|
116
|
-
|
117
|
-
|
118
|
-
readable.each do |socket|
|
119
|
-
begin
|
120
|
-
if socket == sock
|
121
|
-
msg, addr = sock.recvfrom_nonblock(1024)
|
122
|
-
data << [msg, addr[2]]
|
123
|
-
end
|
124
|
-
rescue IO::WaitReadable
|
125
|
-
retry
|
126
|
-
end
|
127
|
-
end
|
128
|
-
|
133
|
+
# Sends an EISCP::Message object or string on the network
|
134
|
+
#
|
135
|
+
def send(eiscp)
|
136
|
+
if eiscp.is_a? EISCP::Message
|
137
|
+
@socket.puts(eiscp.to_eiscp)
|
138
|
+
elsif eiscp.is_a? String
|
139
|
+
@socket.puts eiscp
|
129
140
|
end
|
130
141
|
end
|
131
142
|
|
132
|
-
#
|
133
|
-
|
134
|
-
def
|
135
|
-
|
136
|
-
|
137
|
-
|
143
|
+
# Reads the socket and returns and EISCP::Message
|
144
|
+
#
|
145
|
+
def recv
|
146
|
+
data = String.new
|
147
|
+
data << @socket.gets until data.match(/\r\n$/)
|
148
|
+
message = Parser.parse(data)
|
149
|
+
message
|
138
150
|
end
|
139
151
|
|
140
|
-
#
|
141
|
-
|
142
|
-
def send_recv(
|
143
|
-
|
144
|
-
|
145
|
-
|
152
|
+
# Sends an EISCP::Message object or string on the network and returns recieved data string.
|
153
|
+
#
|
154
|
+
def send_recv(eiscp)
|
155
|
+
eiscp = Parser.parse(eiscp) if eiscp.is_a? String
|
156
|
+
send eiscp
|
157
|
+
sleep DEFAULT_TIMEOUT
|
158
|
+
Parser.parse("#{eiscp.command}#{@state[eiscp.command]}")
|
146
159
|
end
|
147
160
|
|
148
|
-
#
|
149
|
-
#
|
161
|
+
# Return ECN hash with model, port, area, and MAC address
|
162
|
+
#
|
163
|
+
def ecn_hash
|
164
|
+
{ model: @model,
|
165
|
+
port: @port,
|
166
|
+
area: @area,
|
167
|
+
mac_address: @mac_address }
|
168
|
+
end
|
150
169
|
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
170
|
+
# This will return a human-readable represantion of the receiver's state.
|
171
|
+
#
|
172
|
+
def human_readable_state
|
173
|
+
hash = {}
|
174
|
+
@state.each do |c, v|
|
175
|
+
hash[Dictionary.command_to_name(c).to_s] = (Dictionary.command_value_to_value_name(c, v) || v.to_s).to_s
|
176
|
+
end
|
177
|
+
hash
|
178
|
+
end
|
160
179
|
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
180
|
+
# Runs every command that supports the 'QSTN' value. This is a good way to
|
181
|
+
# get the sate of the receiver after connecting.
|
182
|
+
#
|
183
|
+
def update_state
|
184
|
+
Thread.new do
|
185
|
+
Dictionary.commands.each do |zone, _commands|
|
186
|
+
Dictionary.commands[zone].each do |command, info|
|
187
|
+
info[:values].each do |value, _|
|
188
|
+
next unless value == 'QSTN'
|
189
|
+
|
190
|
+
send(Parser.parse(command + 'QSTN'))
|
191
|
+
# If we send any faster we risk making the stereo drop replies.
|
192
|
+
# A dropped reply is not necessarily indicative of the
|
193
|
+
# receiver's failure to receive the command and change state
|
194
|
+
# accordingly. In this case, we're only making queries, so we do
|
195
|
+
# want to capture every reply.
|
196
|
+
sleep DEFAULT_TIMEOUT
|
170
197
|
end
|
171
|
-
rescue IO::WaitReadable
|
172
|
-
retry
|
173
198
|
end
|
174
199
|
end
|
175
|
-
|
176
200
|
end
|
177
201
|
end
|
178
|
-
|
179
202
|
end
|
180
203
|
end
|