seriamp 0.1.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -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