seriamp 0.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,314 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'timeout'
4
+ require 'seriamp/error'
5
+ require 'seriamp/backend/serial_port'
6
+
7
+ module Seriamp
8
+ module Sonamp
9
+
10
+ RS232_TIMEOUT = 3
11
+
12
+ class Client
13
+ def initialize(device: nil, glob: nil, logger: nil)
14
+ @logger = logger
15
+
16
+ @device = device
17
+ @detect_device = device.nil?
18
+ @glob = glob
19
+ end
20
+
21
+ attr_reader :device
22
+ attr_reader :glob
23
+ attr_reader :logger
24
+
25
+ def detect_device?
26
+ @detect_device
27
+ end
28
+
29
+ def present?
30
+ get_zone_power(1)
31
+ true
32
+ end
33
+
34
+ def get_zone_power(zone = nil)
35
+ get_zone_state('P', zone)
36
+ end
37
+
38
+ def set_zone_power(zone, state)
39
+ if zone < 1 || zone > 4
40
+ raise ArgumentError, "Zone must be between 1 and 4: #{zone}"
41
+ end
42
+ cmd = ":P#{zone}#{state ? 1 : 0}"
43
+ expected = cmd[1...cmd.length]
44
+ dispatch_assert(cmd, expected)
45
+ end
46
+
47
+ def power_off
48
+ 1.upto(4).each do |zone|
49
+ set_zone_power(zone, false)
50
+ end
51
+ end
52
+
53
+ def get_zone_volume(zone = nil)
54
+ get_zone_value('V', zone)
55
+ end
56
+
57
+ def set_zone_volume(zone, volume)
58
+ if volume < 0 || volume > 100
59
+ raise ArgumentError, "Volume must be between 0 and 100: #{volume}"
60
+ end
61
+ set_zone_value('V', zone, volume)
62
+ end
63
+
64
+ def set_zone_mute(zone, state)
65
+ set_zone_value('M', zone, state ? 1 : 0)
66
+ end
67
+
68
+ def get_channel_volume(channel = nil)
69
+ get_channel_value('VC', channel)
70
+ end
71
+
72
+ def set_channel_volume(channel, volume)
73
+ if channel < 1 || channel > 8
74
+ raise ArgumentError, "Channel must be between 1 and 4: #{channel}"
75
+ end
76
+ if volume < 0 || volume > 100
77
+ raise ArgumentError, "Volume must be between 0 and 100: #{volume}"
78
+ end
79
+ cmd = ":VC#{channel}#{volume}"
80
+ expected = cmd[1...cmd.length]
81
+ dispatch_assert(cmd, expected)
82
+ end
83
+
84
+ def set_channel_mute(channel, state)
85
+ set_channel_value('MC', channel, state ? 1 : 0)
86
+ end
87
+
88
+ def get_zone_mute(zone = nil)
89
+ get_zone_state('M', zone)
90
+ end
91
+
92
+ def get_channel_mute(channel = nil)
93
+ get_channel_state('MC', channel)
94
+ end
95
+
96
+ def get_channel_front_panel_level(channel = nil)
97
+ get_channel_value('TVL', channel)
98
+ end
99
+
100
+ def get_zone_fault(zone = nil)
101
+ get_zone_state('FP', zone)
102
+ end
103
+
104
+ def get_bbe(zone = nil)
105
+ get_zone_state('BP', zone)
106
+ end
107
+
108
+ def get_bbe_high_boost(zone = nil)
109
+ get_zone_state('BH', zone)
110
+ end
111
+
112
+ def get_bbe_low_boost(zone = nil)
113
+ get_zone_state('BL', zone)
114
+ end
115
+
116
+ def get_auto_trigger_input(zone = nil)
117
+ get_zone_state('ATI', zone)
118
+ end
119
+
120
+ def get_voltage_trigger_input(zone = nil)
121
+ get_zone_state('VTI', zone)
122
+ end
123
+
124
+ def get_firmware_version
125
+ global_query('VER')
126
+ end
127
+
128
+ def get_temperature
129
+ Integer(global_query('TP'))
130
+ end
131
+
132
+ def status
133
+ # Reusing the opened device file makes :VTIG? fail even with a delay
134
+ # in front.
135
+ #open_device do
136
+ {
137
+ firmware_version: get_firmware_version,
138
+ temperature: get_temperature,
139
+ zone_power: get_zone_power,
140
+ zone_fault: get_zone_fault,
141
+ zone_volume: get_zone_volume,
142
+ channel_volume: get_channel_volume,
143
+ zone_mute: get_zone_mute,
144
+ channel_mute: get_channel_mute,
145
+ bbe: get_bbe,
146
+ bbe_high_boost: get_bbe_high_boost,
147
+ bbe_low_boost: get_bbe_low_boost,
148
+ auto_trigger_input: get_auto_trigger_input,
149
+ voltage_trigger_input: get_voltage_trigger_input,
150
+ channel_front_panel_level: get_channel_front_panel_level,
151
+ }
152
+ #end
153
+ end
154
+
155
+ private
156
+
157
+ def open_device
158
+ if detect_device? && device.nil?
159
+ @device = Seriamp.detect_device(Sonamp, *glob, logger: logger)
160
+ if @device
161
+ logger&.info("Using #{device} as TTY device")
162
+ else
163
+ raise NoDevice, "No device specified and device could not be detected automatically"
164
+ end
165
+ end
166
+
167
+ if @f
168
+ yield
169
+ else
170
+ rv = nil
171
+ Backend::SerialPortBackend::Device.new(device) do |f|
172
+ @f = f
173
+ rv = yield
174
+ @f = nil
175
+ end
176
+ rv
177
+ end
178
+ end
179
+
180
+ def dispatch(cmd, resp_lines_count = 1)
181
+ open_device do
182
+ with_timeout do
183
+ @f.syswrite("#{cmd}\x0d")
184
+ end
185
+ resp = 1.upto(resp_lines_count).map do
186
+ read_line(@f, cmd)
187
+ end
188
+ if resp_lines_count == 1
189
+ resp.first
190
+ else
191
+ resp
192
+ end
193
+ end
194
+ end
195
+
196
+ def dispatch_assert(cmd, expected)
197
+ resp = dispatch(cmd)
198
+ if resp != expected
199
+ raise UnexpectedResponse, "Expected #{expected}, got #{resp}"
200
+ end
201
+ end
202
+
203
+ def extract_suffix(resp, expected_prefix)
204
+ unless resp[0..expected_prefix.length-1] == expected_prefix
205
+ raise UnexpectedResponse, "Unexpected response: expected #{expected_prefix}..., actual #{resp}"
206
+ end
207
+ resp[expected_prefix.length..]
208
+ end
209
+
210
+ def dispatch_extract_suffix(cmd, expected_prefix)
211
+ resp = dispatch(cmd)
212
+ extract_suffix(resp, expected_prefix)
213
+ end
214
+
215
+ def global_query(cmd)
216
+ dispatch_extract_suffix(":#{cmd}?", cmd)
217
+ end
218
+
219
+ def with_timeout(&block)
220
+ Timeout.timeout(RS232_TIMEOUT, CommunicationTimeout, &block)
221
+ end
222
+
223
+ def read_line(f, cmd)
224
+ with_timeout do
225
+ resp = +''
226
+ loop do
227
+ ch = f.sysread(1)
228
+ if ch
229
+ break if ch == ?\r
230
+ resp << ch
231
+ else
232
+ sleep 0.1
233
+ end
234
+ end
235
+ if resp == 'ERR'
236
+ raise InvalidCommand, "Invalid command: #{cmd}"
237
+ elsif resp == 'N/A'
238
+ raise NotApplicable, "Command was recognized but could not be executed - is serial control enabled on the amplifier?"
239
+ end
240
+ resp
241
+ end
242
+ end
243
+
244
+ def set_zone_value(cmd_prefix, zone, value)
245
+ if zone < 1 || zone > 4
246
+ raise ArgumentError, "Zone must be between 1 and 4: #{zone}"
247
+ end
248
+ cmd = ":#{cmd_prefix}#{zone}#{value}"
249
+ expected = cmd[1...cmd.length]
250
+ dispatch_assert(cmd, expected)
251
+ end
252
+
253
+ def get_zone_value(cmd_prefix, zone, boolize: false)
254
+ if zone
255
+ if zone < 1 || zone > 4
256
+ raise ArgumentError, "Zone must be between 1 and 4: #{zone}"
257
+ end
258
+ resp = dispatch(":#{cmd_prefix}#{zone}?")
259
+ typecast_value(resp[cmd_prefix.length + 1..], boolize)
260
+ else
261
+ index = 1
262
+ hashize_query_result(dispatch(":#{cmd_prefix}G?", 4), cmd_prefix, boolize)
263
+ end
264
+ end
265
+
266
+ def hashize_query_result(resp_lines, cmd_prefix, boolize)
267
+ index = 1
268
+ Hash[resp_lines.map do |resp|
269
+ value = typecast_value(extract_suffix(resp, "#{cmd_prefix}#{index}"), boolize)
270
+ [index, value].tap do
271
+ index += 1
272
+ end
273
+ end]
274
+ end
275
+
276
+ def get_zone_state(cmd_prefix, zone)
277
+ get_zone_value(cmd_prefix, zone, boolize: true)
278
+ end
279
+
280
+ def set_channel_value(cmd_prefix, channel, value)
281
+ if channel < 1 || channel > 8
282
+ raise ArgumentError, "Channel must be between 1 and 8: #{channel}"
283
+ end
284
+ cmd = ":#{cmd_prefix}#{channel}#{value}"
285
+ expected = cmd[1...cmd.length]
286
+ dispatch_assert(cmd, expected)
287
+ end
288
+
289
+ def get_channel_value(cmd_prefix, channel, boolize: false)
290
+ if channel
291
+ if channel < 1 || channel > 8
292
+ raise ArgumentError, "Channel must be between 1 and 8: #{channel}"
293
+ end
294
+ typecast_value(dispatch_extract_suffix(":#{cmd_prefix}#{channel}?", "#{cmd_prefix}#{channel}"), boolize)
295
+ else
296
+ index = 1
297
+ hashize_query_result(dispatch(":#{cmd_prefix}G?", 8), cmd_prefix, boolize)
298
+ end
299
+ end
300
+
301
+ def get_channel_state(cmd_prefix, channel)
302
+ get_channel_value(cmd_prefix, channel, boolize: true)
303
+ end
304
+
305
+ def typecast_value(value, boolize)
306
+ value = Integer(value)
307
+ if boolize
308
+ value = value == 1
309
+ end
310
+ value
311
+ end
312
+ end
313
+ end
314
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require 'logger'
5
+ require 'seriamp/utils'
6
+ require 'seriamp/detect'
7
+ require 'seriamp/sonamp/client'
8
+
9
+ module Seriamp
10
+ module Sonamp
11
+ class Cmd
12
+ def initialize(args)
13
+ args = args.dup
14
+
15
+ options = {}
16
+ OptionParser.new do |opts|
17
+ opts.banner = "Usage: sonamp [-d device] command arg..."
18
+
19
+ opts.on("-d", "--device DEVICE", "TTY to use (default autodetect)") do |v|
20
+ options[:device] = v
21
+ end
22
+ end.parse!(args)
23
+
24
+ @options = options
25
+
26
+ @logger = Logger.new(STDERR)
27
+ @client = Sonamp::Client.new(device: options[:device], logger: @logger)
28
+
29
+ @args = args
30
+ end
31
+
32
+ attr_reader :args
33
+ attr_reader :logger
34
+
35
+ def run
36
+ if args.any?
37
+ run_command(args)
38
+ else
39
+ STDIN.each_line do |line|
40
+ line.strip!
41
+ line.sub!(/#.*/, '')
42
+ next if line.empty?
43
+
44
+ run_command(line.strip.split(%r,\s+,))
45
+ end
46
+ end
47
+ end
48
+
49
+ def run_command(args)
50
+ cmd = args.shift
51
+ unless cmd
52
+ raise ArgumentError, "No command given"
53
+ end
54
+
55
+ case cmd
56
+ when 'detect'
57
+ device = Seriamp.detect_device(Sonamp, *args, logger: logger)
58
+ if device
59
+ puts device
60
+ exit 0
61
+ else
62
+ STDERR.puts("Sonamp amplifier not found")
63
+ exit 3
64
+ end
65
+ when 'off'
66
+ client.set_zone_power(1, false)
67
+ client.set_zone_power(2, false)
68
+ client.set_zone_power(3, false)
69
+ client.set_zone_power(4, false)
70
+ when 'power'
71
+ zone = args.shift.to_i
72
+ state = Utils.parse_on_off(ARGV.shift)
73
+ client.set_zone_power(zone, state)
74
+ when 'zvol'
75
+ zone = args.shift.to_i
76
+ volume = ARGV.shift.to_i
77
+ client.set_zone_volume(zone, volume)
78
+ when 'cvol'
79
+ channel = args.shift.to_i
80
+ volume = args.shift.to_i
81
+ client.set_channel_volume(channel, volume)
82
+ when 'zmute'
83
+ zone = args.shift.to_i
84
+ mute = ARGV.shift.to_i
85
+ client.set_zone_mute(zone, mute)
86
+ when 'cmute'
87
+ channel = args.shift.to_i
88
+ mute = args.shift.to_i
89
+ client.set_channel_mute(channel, mute)
90
+ when 'status'
91
+ pp client.status
92
+ else
93
+ raise ArgumentError, "Unknown command: #{cmd}"
94
+ end
95
+ end
96
+
97
+ private
98
+
99
+ attr_reader :client
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'seriamp/detect'
4
+ require 'seriamp/sonamp/client'
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Seriamp
4
+ module Utils
5
+
6
+ module_function def parse_on_off(value)
7
+ case value&.downcase
8
+ when '1', 'on', 'yes', 'true'
9
+ true
10
+ when '0', 'off', 'no', 'false'
11
+ false
12
+ else
13
+ raise ArgumentError, "Invalid on/off value: #{value}"
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Seriamp
4
+ VERSION = '0.1.2'
5
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sinatra/base'
4
+ require 'seriamp/utils'
5
+ require 'seriamp/yamaha/client'
6
+
7
+ module Seriamp
8
+ module Yamaha
9
+ class App < Sinatra::Base
10
+
11
+ set :device, nil
12
+ set :logger, nil
13
+ set :client, nil
14
+
15
+ get '/power' do
16
+ render_json(client.last_status.fetch(:power) > 0)
17
+ end
18
+
19
+ %w(main zone2 zone3).each do |zone|
20
+ get "/#{zone}/power" do
21
+ render_json(client.last_status.fetch(:"#{zone}_power"))
22
+ end
23
+
24
+ put "/#{zone}/power" do
25
+ state = Utils.parse_on_off(request.body.read)
26
+ client.public_send("set_#{zone}_power", state)
27
+ empty_response
28
+ end
29
+
30
+ get '/#{zone}/volume' do
31
+ render_json(client.public_send("get_#{zone}_volume"))
32
+ end
33
+
34
+ put '/#{zone}/volume' do
35
+ value = Float(request.body.read)
36
+ client.public_send("set_#{zone}_volume_db", value)
37
+ empty_response
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def client
44
+ settings.client || begin
45
+ @client ||= Yamaha::Client.new(settings.device, logger: settings.logger)
46
+ end
47
+ end
48
+
49
+ def render_json(data)
50
+ data.to_json
51
+ end
52
+
53
+ def empty_response
54
+ render_json({})
55
+ end
56
+ end
57
+ end
58
+ end