sonamp 0.0.4 → 0.0.7

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3b63474ec681132cd0674f5f2772e59494a585188e20b241af83fe865461adcd
4
- data.tar.gz: 0d066e5ef22315e64ae704ddba85735fa996c8c6e7f818ed9f41e38b645a1aaa
3
+ metadata.gz: caf6fd82a4c7c254e7c132a7b6865094f6c311bd634a92db6a842ab1c3417624
4
+ data.tar.gz: 31e699f752afaef597a2f328f1a9d3eaceae3f0de2e898a75b8ca4af00ac7737
5
5
  SHA512:
6
- metadata.gz: 3903c43f129e3a48f74e4a610f9cf3fd5b2e38d176508126c201370e6f171cbe929185f8bd6cc179bdff8d5188334aa157273127fa06df7fb50558ade4fa4a22
7
- data.tar.gz: b8d88a7775bca97209ebdda191939ee4b4e1bc0de2f3a5629665051506e2f083a3c3fa0ec4a9a734da113969166de9e65b5f7ccef44721ee04285c5edd44eb22
6
+ metadata.gz: 0f1a00b0586c04eaece4df0fc7b0af818a827d9b4767862b7b4a12d840fbe3abe1f12f065197fee3f6b734d24fccceb7701aa8b8dc2ed5847d58dd6858ea06b8
7
+ data.tar.gz: 736d03293482038f5b8a592d1f9c6bbc47f526159baeec9ca704290995ed7c1f3b7cc04d29fae178dd82992894f81dbcb269a41f520b2e38b3ac699fe82abda2
data/LICENSE ADDED
@@ -0,0 +1,23 @@
1
+ Copyright (c) 2022 Oleg Pudeyev
2
+ All rights reserved.
3
+
4
+ Redistribution and use in source and binary forms, with or without
5
+ modification, are permitted provided that the following conditions are met:
6
+
7
+ 1. Redistributions of source code must retain the above copyright notice, this
8
+ list of conditions and the following disclaimer.
9
+
10
+ 2. Redistributions in binary form must reproduce the above copyright notice,
11
+ this list of conditions and the following disclaimer in the documentation
12
+ and/or other materials provided with the distribution.
13
+
14
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
15
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
16
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
17
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
18
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
19
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
20
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
21
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
22
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
23
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
data/bin/sonamp CHANGED
@@ -1,49 +1,10 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
3
  begin
4
- require 'sonamp'
4
+ require 'sonamp/cmd'
5
5
  rescue LoadError
6
6
  $: << File.join(File.dirname(__FILE__), '../lib')
7
- require 'sonamp'
7
+ require 'sonamp/cmd'
8
8
  end
9
- require 'optparse'
10
- require 'logger'
11
- require 'sonamp/utils'
12
9
 
13
- options = {}
14
- OptionParser.new do |opts|
15
- opts.banner = "Usage: sonamp [-d device] command arg..."
16
-
17
- opts.on("-d", "--device DEVICE", "TTY to use (default autodetect)") do |v|
18
- options[:device] = v
19
- end
20
- end.parse!
21
-
22
- logger = Logger.new(STDERR)
23
- client = Sonamp::Client.new(options[:device], logger: logger)
24
-
25
- cmd = ARGV.shift
26
- unless cmd
27
- raise ArgumentError, "No command given"
28
- end
29
-
30
- include Sonamp::Utils
31
-
32
- case cmd
33
- when 'power'
34
- zone = ARGV.shift.to_i
35
- state = parse_on_off(ARGV.shift)
36
- client.set_zone_power(zone, state)
37
- when 'zvol'
38
- zone = ARGV.shift.to_i
39
- volume = ARGV.shift.to_i
40
- client.set_zone_volume(zone, volume)
41
- when 'cvol'
42
- channel = ARGV.shift.to_i
43
- volume = ARGV.shift.to_i
44
- client.set_channel_volume(channel, volume)
45
- when 'status'
46
- client.status
47
- else
48
- raise ArgumentError, "Unknown command: #{cmd}"
49
- end
10
+ Sonamp::Cmd.new(ARGV).run
data/lib/sonamp/client.rb CHANGED
@@ -1,15 +1,51 @@
1
+ require 'timeout'
2
+
1
3
  module Sonamp
2
4
  class Error < StandardError; end
3
5
  class InvalidCommand < Error; end
4
6
  class NotApplicable < Error; end
5
7
  class UnexpectedResponse < Error; end
8
+ class CommunicationTimeout < Error; end
9
+
10
+ RS232_TIMEOUT = 3
11
+ DEFAULT_DEVICE_GLOB = '/dev/ttyUSB*'
12
+
13
+ module_function def detect_device(*patterns, logger: nil)
14
+ if patterns.empty?
15
+ patterns = [DEFAULT_DEVICE_GLOB]
16
+ end
17
+ devices = patterns.map do |pattern|
18
+ Dir.glob(pattern)
19
+ end.flatten.uniq
20
+ queue = Queue.new
21
+ threads = devices.map do |device|
22
+ Thread.new do
23
+ Timeout.timeout(RS232_TIMEOUT * 2) do
24
+ logger&.debug("Trying #{device}")
25
+ Client.new(device, logger: logger).get_zone_power
26
+ logger&.debug("Found receiver at #{device}")
27
+ queue << device
28
+ end
29
+ rescue CommunicationTimeout, IOError, SystemCallError => exc
30
+ logger&.debug("Failed on #{device}: #{exc.class}: #{exc}")
31
+ end
32
+ end
33
+ wait_thread = Thread.new do
34
+ threads.map(&:join)
35
+ queue << nil
36
+ end
37
+ queue.shift.tap do
38
+ threads.map(&:kill)
39
+ wait_thread.kill
40
+ end
41
+ end
6
42
 
7
43
  class Client
8
44
  def initialize(device = nil, logger: nil)
9
45
  @logger = logger
10
46
 
11
47
  if device.nil?
12
- device = Dir['/dev/ttyUSB*'].sort.first
48
+ device = Sonamp.detect_device(logger: logger)
13
49
  if device
14
50
  logger&.info("Using #{device} as TTY device")
15
51
  end
@@ -39,29 +75,22 @@ module Sonamp
39
75
  end
40
76
 
41
77
  def get_zone_volume(zone = nil)
42
- if zone
43
- if zone < 1 || zone > 4
44
- raise ArgumentError, "Zone must be between 1 and 4: #{zone}"
45
- end
46
- resp = dispatch(":V#{zone}?")
47
- resp[2...].to_i
48
- else
49
- dispatch(":VG?", 4).map do |resp|
50
- resp[2...].to_i
51
- end
52
- end
78
+ get_zone_value('V', zone)
53
79
  end
54
80
 
55
81
  def set_zone_volume(zone, volume)
56
- if zone < 1 || zone > 4
57
- raise ArgumentError, "Zone must be between 1 and 4: #{zone}"
58
- end
59
82
  if volume < 0 || volume > 100
60
83
  raise ArgumentError, "Volume must be between 0 and 100: #{volume}"
61
84
  end
62
- cmd = ":V#{zone}#{volume}"
63
- expected = cmd[1...cmd.length]
64
- dispatch_assert(cmd, expected)
85
+ set_zone_value('V', zone, volume)
86
+ end
87
+
88
+ def set_zone_mute(zone, state)
89
+ set_zone_value('M', zone, state ? 1 : 0)
90
+ end
91
+
92
+ def get_channel_volume(channel = nil)
93
+ get_channel_value('VC', channel)
65
94
  end
66
95
 
67
96
  def set_channel_volume(channel, volume)
@@ -76,10 +105,26 @@ module Sonamp
76
105
  dispatch_assert(cmd, expected)
77
106
  end
78
107
 
108
+ def set_channel_mute(channel, state)
109
+ set_channel_value('MC', channel, state ? 1 : 0)
110
+ end
111
+
79
112
  def get_zone_mute(zone = nil)
80
113
  get_zone_state('M', zone)
81
114
  end
82
115
 
116
+ def get_channel_mute(channel = nil)
117
+ get_channel_state('MC', channel)
118
+ end
119
+
120
+ def get_channel_front_panel_level(channel = nil)
121
+ get_channel_value('TVL', channel)
122
+ end
123
+
124
+ def get_zone_fault(zone = nil)
125
+ get_zone_state('FP', zone)
126
+ end
127
+
83
128
  def get_bbe(zone = nil)
84
129
  get_zone_state('BP', zone)
85
130
  end
@@ -100,25 +145,34 @@ module Sonamp
100
145
  get_zone_state('VTI', zone)
101
146
  end
102
147
 
148
+ def get_firmware_version
149
+ global_query('VER')
150
+ end
151
+
152
+ def get_temperature
153
+ Integer(global_query('TP'))
154
+ end
155
+
103
156
  def status
104
157
  # Reusing the opened device file makes :VTIG? fail even with a delay
105
158
  # in front.
106
159
  #open_device do
107
- p dispatch(':VER?')
108
- p dispatch(':TP?')
109
- p get_power
110
- p dispatch(':FPG?', 4)
111
- p get_zone_volume
112
- p dispatch(':VCG?', 8)
113
- p get_auto_trigger_input
114
- sleep 0.1
115
- p get_voltage_trigger_input
116
- p dispatch(':TVLG?', 8)
117
- p get_zone_mute
118
- p dispatch(':MCG?', 8)
119
- p get_bbe
120
- p get_bbe_high_boost
121
- p get_bbe_low_boost
160
+ {
161
+ firmware_version: get_firmware_version,
162
+ temperature: get_temperature,
163
+ zone_power: get_zone_power,
164
+ zone_fault: get_zone_fault,
165
+ zone_volume: get_zone_volume,
166
+ channel_volume: get_channel_volume,
167
+ zone_mute: get_zone_mute,
168
+ channel_mute: get_channel_mute,
169
+ bbe: get_bbe,
170
+ bbe_high_boost: get_bbe_high_boost,
171
+ bbe_low_boost: get_bbe_low_boost,
172
+ auto_trigger_input: get_auto_trigger_input,
173
+ voltage_trigger_input: get_voltage_trigger_input,
174
+ channel_front_panel_level: get_channel_front_panel_level,
175
+ }
122
176
  #end
123
177
  end
124
178
 
@@ -128,7 +182,7 @@ module Sonamp
128
182
  if @f
129
183
  yield
130
184
  else
131
- File.open(device, 'r+') do |f|
185
+ File.open(device, 'r+b') do |f|
132
186
  @f = f
133
187
  yield.tap do
134
188
  @f = nil
@@ -139,7 +193,9 @@ module Sonamp
139
193
 
140
194
  def dispatch(cmd, resp_lines_count = 1)
141
195
  open_device do
142
- @f << "#{cmd}\x0d"
196
+ with_timeout do
197
+ @f.syswrite("#{cmd}\x0d")
198
+ end
143
199
  resp = 1.upto(resp_lines_count).map do
144
200
  read_line(@f, cmd)
145
201
  end
@@ -158,28 +214,105 @@ module Sonamp
158
214
  end
159
215
  end
160
216
 
217
+ def extract_suffix(resp, expected_prefix)
218
+ unless resp[0..expected_prefix.length-1] == expected_prefix
219
+ raise UnexpectedResponse, "Unexpected response: expected #{expected_prefix}..., actual #{resp}"
220
+ end
221
+ resp[expected_prefix.length..]
222
+ end
223
+
224
+ def dispatch_extract_suffix(cmd, expected_prefix)
225
+ resp = dispatch(cmd)
226
+ extract_suffix(resp, expected_prefix)
227
+ end
228
+
229
+ def global_query(cmd)
230
+ dispatch_extract_suffix(":#{cmd}?", cmd)
231
+ end
232
+
233
+ def with_timeout(&block)
234
+ Timeout.timeout(RS232_TIMEOUT, CommunicationTimeout, &block)
235
+ end
236
+
161
237
  def read_line(f, cmd)
162
- f.readline.strip.tap do |resp|
163
- if resp == 'ERR'
164
- raise InvalidCommand, "Invalid command: #{cmd}"
165
- elsif resp == 'N/A'
166
- raise NotApplicable, "Command was recognized but could not be executed - is serial control enabled on the amplifier?"
238
+ with_timeout do
239
+ f.readline.strip.tap do |resp|
240
+ if resp == 'ERR'
241
+ raise InvalidCommand, "Invalid command: #{cmd}"
242
+ elsif resp == 'N/A'
243
+ raise NotApplicable, "Command was recognized but could not be executed - is serial control enabled on the amplifier?"
244
+ end
167
245
  end
168
246
  end
169
247
  end
170
248
 
171
- def get_zone_state(cmd_prefix, zone)
249
+ def set_zone_value(cmd_prefix, zone, value)
250
+ if zone < 1 || zone > 4
251
+ raise ArgumentError, "Zone must be between 1 and 4: #{zone}"
252
+ end
253
+ cmd = ":#{cmd_prefix}#{zone}#{value}"
254
+ expected = cmd[1...cmd.length]
255
+ dispatch_assert(cmd, expected)
256
+ end
257
+
258
+ def get_zone_value(cmd_prefix, zone, boolize: false)
172
259
  if zone
173
260
  if zone < 1 || zone > 4
174
261
  raise ArgumentError, "Zone must be between 1 and 4: #{zone}"
175
262
  end
176
263
  resp = dispatch(":#{cmd_prefix}#{zone}?")
177
- resp[cmd_prefix.length + 1] == '1' ? true : false
264
+ typecast_value(resp[cmd_prefix.length + 1..], boolize)
178
265
  else
179
- dispatch(":#{cmd_prefix}G?", 4).map do |resp|
180
- resp[cmd_prefix.length + 1] == '1' ? true : false
266
+ index = 1
267
+ hashize_query_result(dispatch(":#{cmd_prefix}G?", 4), cmd_prefix, boolize)
268
+ end
269
+ end
270
+
271
+ def hashize_query_result(resp_lines, cmd_prefix, boolize)
272
+ index = 1
273
+ Hash[resp_lines.map do |resp|
274
+ value = typecast_value(extract_suffix(resp, "#{cmd_prefix}#{index}"), boolize)
275
+ [index, value].tap do
276
+ index += 1
181
277
  end
278
+ end]
279
+ end
280
+
281
+ def get_zone_state(cmd_prefix, zone)
282
+ get_zone_value(cmd_prefix, zone, boolize: true)
283
+ end
284
+
285
+ def set_channel_value(cmd_prefix, channel, value)
286
+ if channel < 1 || channel > 8
287
+ raise ArgumentError, "Channel must be between 1 and 8: #{channel}"
288
+ end
289
+ cmd = ":#{cmd_prefix}#{channel}#{value}"
290
+ expected = cmd[1...cmd.length]
291
+ dispatch_assert(cmd, expected)
292
+ end
293
+
294
+ def get_channel_value(cmd_prefix, channel, boolize: false)
295
+ if channel
296
+ if channel < 1 || channel > 8
297
+ raise ArgumentError, "Channel must be between 1 and 8: #{channel}"
298
+ end
299
+ typecast_value(dispatch_extract_suffix(":#{cmd_prefix}#{channel}?", "#{cmd_prefix}#{channel}"), boolize)
300
+ else
301
+ index = 1
302
+ hashize_query_result(dispatch(":#{cmd_prefix}G?", 8), cmd_prefix, boolize)
303
+ end
304
+ end
305
+
306
+ def get_channel_state(cmd_prefix, channel)
307
+ get_channel_value(cmd_prefix, channel, boolize: true)
308
+ end
309
+
310
+ def typecast_value(value, boolize)
311
+ value = Integer(value)
312
+ if boolize
313
+ value = value == 1
182
314
  end
315
+ value
183
316
  end
184
317
  end
185
318
  end
data/lib/sonamp/cmd.rb ADDED
@@ -0,0 +1,81 @@
1
+ require 'optparse'
2
+ require 'logger'
3
+ require 'sonamp/utils'
4
+ require 'sonamp/client'
5
+
6
+ module Sonamp
7
+ class Cmd
8
+ def initialize(args)
9
+ options = {}
10
+ OptionParser.new do |opts|
11
+ opts.banner = "Usage: sonamp [-d device] command arg..."
12
+
13
+ opts.on("-d", "--device DEVICE", "TTY to use (default autodetect)") do |v|
14
+ options[:device] = v
15
+ end
16
+ end.parse!(args)
17
+
18
+ @options = options
19
+
20
+ @logger = Logger.new(STDERR)
21
+ @client = Sonamp::Client.new(options[:device], logger: @logger)
22
+
23
+ @args = args
24
+ end
25
+
26
+ attr_reader :args
27
+ attr_reader :logger
28
+
29
+ def run
30
+ cmd = args.shift
31
+ unless cmd
32
+ raise ArgumentError, "No command given"
33
+ end
34
+
35
+ case cmd
36
+ when 'detect'
37
+ device = Sonamp.detect_device(*args, logger: logger)
38
+ if device
39
+ puts device
40
+ exit 0
41
+ else
42
+ STDERR.puts("Sonamp amplifier not found")
43
+ exit 3
44
+ end
45
+ when 'off'
46
+ client.set_zone_power(1, false)
47
+ client.set_zone_power(2, false)
48
+ client.set_zone_power(3, false)
49
+ client.set_zone_power(4, false)
50
+ when 'power'
51
+ zone = args.shift.to_i
52
+ state = Utils.parse_on_off(ARGV.shift)
53
+ client.set_zone_power(zone, state)
54
+ when 'zvol'
55
+ zone = args.shift.to_i
56
+ volume = ARGV.shift.to_i
57
+ client.set_zone_volume(zone, volume)
58
+ when 'cvol'
59
+ channel = args.shift.to_i
60
+ volume = args.shift.to_i
61
+ client.set_channel_volume(channel, volume)
62
+ when 'zmute'
63
+ zone = args.shift.to_i
64
+ mute = ARGV.shift.to_i
65
+ client.set_zone_mute(zone, mute)
66
+ when 'cmute'
67
+ channel = args.shift.to_i
68
+ mute = args.shift.to_i
69
+ client.set_channel_mute(channel, mute)
70
+ when 'status'
71
+ pp client.status
72
+ else
73
+ raise ArgumentError, "Unknown command: #{cmd}"
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ attr_reader :client
80
+ end
81
+ end
data/lib/sonamp/utils.rb CHANGED
@@ -5,7 +5,7 @@ module Sonamp
5
5
  case value&.downcase
6
6
  when '1', 'on', 'yes', 'true'
7
7
  true
8
- when '0', 'off', 'no', 'value'
8
+ when '0', 'off', 'no', 'false'
9
9
  false
10
10
  else
11
11
  raise ArgumentError, "Invalid on/off value: #{value}"
@@ -1,3 +1,3 @@
1
1
  module Sonamp
2
- VERSION = '0.0.4'.freeze
2
+ VERSION = '0.0.7'.freeze
3
3
  end
data/sonamp.gemspec CHANGED
@@ -2,15 +2,15 @@
2
2
 
3
3
  Gem::Specification.new do |spec|
4
4
  spec.name = "sonamp"
5
- spec.version = '0.0.4'
5
+ spec.version = '0.0.7'
6
6
  spec.authors = ['Oleg Pudeyev']
7
7
  spec.email = ['code@olegp.name']
8
8
  spec.summary = %q{Sonance Sonamp Amplifier Serial Control Interface}
9
- spec.description = %q{Library for controlling Sonance Sonamp 875D & 875D MkII amplifiers via the serial port}
9
+ spec.description = %q{Library for controlling Sonance Sonamp 875D SE & 875D MkII amplifiers via the serial port}
10
10
  spec.homepage = "https://github.com/p/sonamp-ruby"
11
11
  spec.license = "MIT"
12
12
 
13
- spec.files = `git ls-files -z`.split("\x0")
13
+ spec.files = `git ls-files -z`.split("\x0").reject { |path| path.start_with?('docs/') }
14
14
  spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
15
15
  spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
16
16
  spec.require_paths = ["lib"]
metadata CHANGED
@@ -1,17 +1,17 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sonamp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.4
4
+ version: 0.0.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Oleg Pudeyev
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-10-26 00:00:00.000000000 Z
11
+ date: 2022-11-07 00:00:00.000000000 Z
12
12
  dependencies: []
13
- description: Library for controlling Sonance Sonamp 875D & 875D MkII amplifiers via
14
- the serial port
13
+ description: Library for controlling Sonance Sonamp 875D SE & 875D MkII amplifiers
14
+ via the serial port
15
15
  email:
16
16
  - code@olegp.name
17
17
  executables:
@@ -21,11 +21,13 @@ extensions: []
21
21
  extra_rdoc_files: []
22
22
  files:
23
23
  - ".gitignore"
24
+ - LICENSE
24
25
  - bin/sonamp
25
26
  - bin/sonamp-web
26
27
  - lib/sonamp.rb
27
28
  - lib/sonamp/app.rb
28
29
  - lib/sonamp/client.rb
30
+ - lib/sonamp/cmd.rb
29
31
  - lib/sonamp/utils.rb
30
32
  - lib/sonamp/version.rb
31
33
  - sonamp.gemspec