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.
@@ -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