seriamp 0.1.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +3 -0
- data/LICENSE +23 -0
- data/README.md +75 -0
- data/bin/sonamp +10 -0
- data/bin/sonamp-web +31 -0
- data/bin/yamaha +12 -0
- data/bin/yamaha-web +31 -0
- data/lib/seriamp/backend/ffi.rb +109 -0
- data/lib/seriamp/backend/serial_port.rb +34 -0
- data/lib/seriamp/detect.rb +44 -0
- data/lib/seriamp/error.rb +10 -0
- data/lib/seriamp/sonamp/app.rb +62 -0
- data/lib/seriamp/sonamp/client.rb +314 -0
- data/lib/seriamp/sonamp/cmd.rb +102 -0
- data/lib/seriamp/sonamp.rb +4 -0
- data/lib/seriamp/utils.rb +17 -0
- data/lib/seriamp/version.rb +5 -0
- data/lib/seriamp/yamaha/app.rb +58 -0
- data/lib/seriamp/yamaha/client.rb +279 -0
- data/lib/seriamp/yamaha/cmd.rb +139 -0
- data/lib/seriamp/yamaha/protocol/constants.rb +183 -0
- data/lib/seriamp/yamaha/protocol/methods.rb +134 -0
- data/lib/seriamp/yamaha.rb +4 -0
- data/lib/seriamp.rb +5 -0
- data/seriamp.gemspec +17 -0
- metadata +73 -0
@@ -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,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,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
|