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