onkyo_eiscp_ruby 0.0.3 → 2.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|