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.
@@ -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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EISCP
4
+ module Parser
5
+ module DynamicValueParser
6
+ # Still trying to sort this out.
7
+ end
8
+ end
9
+ 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
@@ -1,180 +1,203 @@
1
- require 'socket'
2
- require 'eiscp/message'
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
- ONKYO_MAGIC = Message.new("ECN", "QSTN", "x").to_eiscp
15
- ONKYO_PORT = 60128
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
- # Create a new EISCP object to communicate with a receiver.
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, port = ONKYO_PORT)
22
- if host == nil
23
- if first_rec = self.class.discover[0]
24
- host = first_rec[1]
25
- set_info first_rec[0]
26
- else
27
- raise Exception
28
- end
29
- end
30
- @host = Resolv.getaddress host
31
- @port = port
32
- unless @model
33
- set_info get_ecn
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
- def set_info(ecn_string)
38
- array = self.class.parse_ecn(ecn_string)
39
- @model = array.shift
40
- @port = array.shift.to_i
41
- @area = array.shift
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
- def get_ecn
47
- self.class.discover.each do |entry|
48
- if @host == entry[1]
49
- return entry[0]
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
- # Gets the ECNQSTN response of self using @host
55
- # then parses it with parse_ecn, returning an array
56
- # with receiver info
57
-
58
- def get_ecn_array
59
- self.class.discover.each do |entry|
60
- if @host == entry[1]
61
- array = self.class.parse_ecn(entry[0])
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
- # Returns array containing @model, @port, @area, and @mac_address
68
- # from ECNQSTN response
69
-
70
- def self.parse_ecn(ecn_string)
71
- message = EISCP::Message.parse(ecn_string)
72
- message.parameter.split("/")
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
- # Internal method for receiving data with a timeout
76
-
77
- def self.recv(sock, timeout = 0.5)
78
- data = []
79
- while true
80
- ready = IO.select([sock], nil, nil, timeout)
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
- # Returns an array of arrays consisting of a discovery response packet string
102
- # and the source ip address of the reciever.
103
-
104
- def self.discover
105
- sock = UDPSocket.new
106
- sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_BROADCAST, true)
107
- sock.send(ONKYO_MAGIC, 0, '<broadcast>', ONKYO_PORT)
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
- # Sends a packet string on the network
133
-
134
- def send(eiscp_packet)
135
- sock = TCPSocket.new @host, @port
136
- sock.puts eiscp_packet
137
- sock.close
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
- # Send a packet string and return recieved data string.
141
-
142
- def send_recv(eiscp_packet)
143
- sock = TCPSocket.new @host, @port
144
- sock.puts eiscp_packet
145
- return Receiver.recv(sock, 0.5)
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
- # Open a TCP connection to the host and print all received messages until
149
- # killed.
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
- def connect(&block)
152
- sock = TCPSocket.new @host, @port
153
- while true
154
- ready = IO.select([sock], nil, nil, nil)
155
- if ready != nil
156
- then readable = ready[0]
157
- else
158
- return
159
- end
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
- readable.each do |socket|
162
- begin
163
- if socket == sock
164
- data = sock.recv_nonblock(1024).chomp
165
- if block_given?
166
- yield data
167
- else
168
- puts data
169
- end
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