seriamp 0.1.14 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -22,6 +22,13 @@ module Seriamp
22
22
  render_json(client.get_zone_power)
23
23
  end
24
24
 
25
+ post '/off' do
26
+ 1.upto(4) do |zone|
27
+ client.set_zone_power(zone, false)
28
+ end
29
+ render_json(client.get_zone_power)
30
+ end
31
+
25
32
  get '/volume' do
26
33
  payload = {
27
34
  zone_volume: client.get_zone_volume,
@@ -46,7 +53,7 @@ module Seriamp
46
53
  end
47
54
 
48
55
  put '/zone/:zone/volume' do |zone|
49
- volume = request.body.read.to_i
56
+ volume = Integer(request.body.read)
50
57
  client.set_zone_volume(Integer(zone), volume)
51
58
  end
52
59
 
@@ -60,7 +67,7 @@ module Seriamp
60
67
  end
61
68
 
62
69
  put '/channel/:channel/volume' do |channel|
63
- volume = request.body.read.to_i
70
+ volume = Integer(request.body.read)
64
71
  client.set_channel_volume(Integer(channel), volume)
65
72
  end
66
73
 
@@ -70,13 +77,19 @@ module Seriamp
70
77
  end
71
78
 
72
79
  post '/' do
80
+ executor = Executor.new
81
+ request.body.read.split("\n").each do |line|
82
+ args = line.strip.split(/\s+/)
83
+ executor.run_command(args)
84
+ end
85
+ render_json({})
73
86
  end
74
87
 
75
88
  private
76
89
 
77
90
  def client
78
91
  settings.client || begin
79
- @client ||= Sonamp::Client.new(settings.device,
92
+ @client ||= Sonamp::Client.new(device: settings.device,
80
93
  logger: settings.logger, retries: settings.retries, thread_safe: true)
81
94
  end
82
95
  end
@@ -0,0 +1,167 @@
1
+ autoload :JSON, 'json'
2
+ autoload :FileUtils, 'fileutils'
3
+ require 'seriamp/faraday_facade'
4
+ require 'seriamp/utils'
5
+
6
+ module Seriamp
7
+ module Sonamp
8
+ class AutoPower
9
+ def initialize(**opts)
10
+ @options = opts.dup.freeze
11
+
12
+ unless options[:sonamp_url]
13
+ raise ArgumentError, 'Sonamp URL is required'
14
+ end
15
+ unless options[:yamaha_url]
16
+ raise ArgumentError, 'Yamaha URL is required'
17
+ end
18
+ end
19
+
20
+ attr_reader :options
21
+
22
+ def logger
23
+ options[:logger]
24
+ end
25
+
26
+ def run
27
+ if (state_path = options[:state_path]) && File.exist?(state_path)
28
+ begin
29
+ @stored_sonamp_power = File.open(state_path) do |f|
30
+ JSON.load(f)
31
+ end
32
+ rescue JSON::ParserError => exc
33
+ logger&.warn("Failed to load state: #{exc.class}: #{exc}")
34
+ end
35
+ end
36
+
37
+ bump('application start')
38
+
39
+ prev_sonamp_power = nil
40
+ prev_sonamp_on = nil
41
+ handle_exceptions do
42
+ prev_sonamp_power = sonamp_client.get_json('power')
43
+ store_sonamp_power(prev_sonamp_power, prev_sonamp_power)
44
+ prev_sonamp_on = prev_sonamp_power.values.any? { |v| v == true }
45
+ end
46
+
47
+ loop do
48
+ sonamp_power = nil
49
+ sonamp_on = nil
50
+ handle_exceptions do
51
+ sonamp_power = sonamp_client.get_json('power')
52
+ sonamp_on = sonamp_power.values.any? { |v| v == true }
53
+ if sonamp_on && prev_sonamp_on == false
54
+ bump('amplifier turned on')
55
+ end
56
+ store_sonamp_power(prev_sonamp_power, sonamp_power)
57
+ prev_sonamp_power = sonamp_power
58
+ prev_sonamp_on = sonamp_on
59
+ end
60
+
61
+ # If we cannot query the receiver, assume it is on to prevent unintended
62
+ # turn-offs.
63
+ receiver_power = nil
64
+ handle_exceptions do
65
+ receiver_power = case resp = yamaha_client.get!('power')
66
+ when 'true'
67
+ true
68
+ when 'false'
69
+ false
70
+ else
71
+ raise "Unknown yamaha power response: #{resp}"
72
+ end
73
+ end
74
+ case receiver_power
75
+ when true
76
+ bump('receiver is on')
77
+ if sonamp_on == false
78
+ puts("turning on amplifier")
79
+ sonamp_set_on
80
+ end
81
+ when nil
82
+ bump('failed to communicate with receiver - assuming it is on')
83
+ end
84
+
85
+ delta = (@alive_through - Utils.monotime).to_i
86
+ if delta < 0 && sonamp_on
87
+ logger&.info("Turning amplifier off")
88
+ handle_exceptions do
89
+ sonamp_client.post!('off')
90
+ end
91
+ elsif ttl > 0
92
+ puts "TTL: #{delta / 60}:#{'%02d' % (delta % 60)}"
93
+ end
94
+
95
+ sleep 20
96
+ end
97
+ end
98
+
99
+ private
100
+
101
+ attr_reader :stored_sonamp_power
102
+
103
+ def handle_exceptions
104
+ yield
105
+ rescue Interrupt, SystemExit, NoMemoryError
106
+ raise
107
+ rescue => exc
108
+ logger&.warn("Unhandled exception: #{exc.class}: #{exc}")
109
+ end
110
+
111
+ def bump(reason)
112
+ if ttl > 0
113
+ logger&.debug("Bumping #{ttl} seconds: #{reason}")
114
+ end
115
+ @alive_through = Utils.monotime + ttl*60
116
+ end
117
+
118
+ def sonamp_client
119
+ @sonamp_client ||= FaradayFacade.new(
120
+ url: options.fetch(:sonamp_url),
121
+ timeout: options[:sonamp_timeout] || 3,
122
+ )
123
+ end
124
+
125
+ def yamaha_client
126
+ @yamaha_client ||= FaradayFacade.new(
127
+ url: options.fetch(:yamaha_url),
128
+ timeout: options[:yamaha_timeout] || 5,
129
+ )
130
+ end
131
+
132
+ def ttl
133
+ @ttl ||= begin
134
+ options[:ttl] || 0
135
+ end
136
+ end
137
+
138
+ def store_sonamp_power(prev_sonamp_power, sonamp_power)
139
+ # Wait for the power state to be stable - both readings should be
140
+ # the same.
141
+ if prev_sonamp_power == sonamp_power && sonamp_power.values.any? { |v| v == true }
142
+ @stored_sonamp_power = sonamp_power
143
+ if state_path = options[:state_path]
144
+ File.open(state_path + '.part', 'w') do |f|
145
+ f << JSON.dump(sonamp_power)
146
+ end
147
+ FileUtils.mv(state_path + '.part', state_path)
148
+ end
149
+ end
150
+ end
151
+
152
+ def sonamp_set_on
153
+ if stored_sonamp_power
154
+ logger&.debug("sonamp on")
155
+ stored_sonamp_power.each do |zone, value|
156
+ if value
157
+ logger&.debug("Sonamp: zone #{zone} on")
158
+ sonamp_client.put!("zone/#{zone}/power", body: 'true')
159
+ end
160
+ end
161
+ else
162
+ logger&.warn("No stored sonamp power")
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require 'timeout'
4
4
  require 'seriamp/error'
5
- require 'seriamp/backend/serial_port'
5
+ require 'seriamp/backend'
6
6
 
7
7
  module Seriamp
8
8
  module Sonamp
@@ -224,14 +224,8 @@ module Seriamp
224
224
  logger&.debug("Opening #{device}")
225
225
  @io = Backend::SerialPortBackend::Device.new(device, logger: logger)
226
226
 
227
- warned = false
228
- while IO.select([@io.io], nil, nil, 0)
229
- unless warned
230
- logger&.warn("Serial device readable after opening - unread previous response?")
231
- warned = true
232
- end
233
- IO.read(1)
234
- end
227
+ Utils.consume_data(@io.io, logger,
228
+ "Serial device readable after opening - unread previous response?")
235
229
 
236
230
  begin
237
231
  yield @io
@@ -275,9 +269,8 @@ module Seriamp
275
269
  read_line(@io, cmd)
276
270
  end
277
271
 
278
- if @io && IO.select([@io.io], nil, nil, 0)
279
- logger&.warn("Serial device readable after completely reading status response - concurrent access?")
280
- end
272
+ Utils.consume_data(@io.io, logger,
273
+ "Serial device readable after completely reading status response - concurrent access?")
281
274
 
282
275
  if resp_lines_range_or_count == 1
283
276
  resp.first
@@ -318,15 +311,23 @@ module Seriamp
318
311
  def read_line(f, cmd)
319
312
  with_timeout do
320
313
  resp = +''
314
+ deadline = Utils.monotime + 1
321
315
  loop do
322
- ch = f.sysread(1)
323
- if ch
324
- break if ch == ?\r
325
- resp << ch
326
- else
327
- sleep 0.1
316
+ begin
317
+ buf = f.read_nonblock(1024)
318
+ if buf
319
+ resp += buf
320
+ break if buf[-1] == ?\r
321
+ end
322
+ rescue IO::WaitReadable
323
+ budget = deadline - Utils.monotime
324
+ if budget < 0
325
+ raise CommunicationTimeout
326
+ end
327
+ IO.select([f.io], nil, nil, budget)
328
328
  end
329
329
  end
330
+ resp.strip!
330
331
  if resp == 'ERR'
331
332
  raise InvalidCommand, "Invalid command: #{cmd}"
332
333
  elsif resp == 'N/A'
@@ -5,11 +5,12 @@ require 'logger'
5
5
  require 'seriamp/utils'
6
6
  require 'seriamp/detect'
7
7
  require 'seriamp/sonamp/client'
8
+ require 'seriamp/sonamp/executor'
8
9
 
9
10
  module Seriamp
10
11
  module Sonamp
11
12
  class Cmd
12
- def initialize(args)
13
+ def initialize(args = ARGV, stdin = STDIN)
13
14
  args = args.dup
14
15
 
15
16
  options = {}
@@ -27,16 +28,18 @@ module Seriamp
27
28
  @client = Sonamp::Client.new(device: options[:device], logger: @logger)
28
29
 
29
30
  @args = args
31
+ @stdin = stdin
30
32
  end
31
33
 
32
34
  attr_reader :args
35
+ attr_reader :stdin
33
36
  attr_reader :logger
34
37
 
35
38
  def run
36
39
  if args.any?
37
40
  run_command(args)
38
41
  else
39
- STDIN.each_line do |line|
42
+ stdin.each_line do |line|
40
43
  line.strip!
41
44
  line.sub!(/#.*/, '')
42
45
  next if line.empty?
@@ -54,43 +57,18 @@ module Seriamp
54
57
 
55
58
  case cmd
56
59
  when 'detect'
57
- device = Seriamp.detect_device(Sonamp, *args, logger: logger)
60
+ device = Seriamp.detect_device(Yamaha, *args, logger: logger)
58
61
  if device
59
62
  puts device
60
63
  exit 0
61
64
  else
62
- STDERR.puts("Sonamp amplifier not found")
65
+ STDERR.puts("Yamaha receiver not found")
63
66
  exit 3
64
67
  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(args.shift)
73
- client.set_zone_power(zone, state)
74
- when 'zvol'
75
- zone = args.shift.to_i
76
- volume = args.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 = args.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
68
  when 'status'
91
69
  pp client.status
92
70
  else
93
- raise ArgumentError, "Unknown command: #{cmd}"
71
+ executor.run_command(cmd, *args)
94
72
  end
95
73
  end
96
74
 
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Seriamp
4
+ module Sonamp
5
+ class Executor
6
+ def initialize(client)
7
+ @client = client
8
+ end
9
+
10
+ attr_reader :client
11
+
12
+ def run_command(cmd, *args)
13
+ case cmd
14
+ when 'detect'
15
+ device = Seriamp.detect_device(Sonamp, *args, logger: logger)
16
+ if device
17
+ puts device
18
+ exit 0
19
+ else
20
+ STDERR.puts("Yamaha receiver not found")
21
+ exit 3
22
+ end
23
+ when 'off'
24
+ client.set_zone_power(1, false)
25
+ client.set_zone_power(2, false)
26
+ client.set_zone_power(3, false)
27
+ client.set_zone_power(4, false)
28
+ when 'power'
29
+ zone = Integer(args.shift)
30
+ state = Utils.parse_on_off(args.shift)
31
+ client.set_zone_power(zone, state)
32
+ when 'zvol'
33
+ zone = Integer(args.shift)
34
+ volume = Integer(args.shift)
35
+ client.set_zone_volume(zone, volume)
36
+ when 'cvol'
37
+ channel = Integer(args.shift)
38
+ volume = Integer(args.shift)
39
+ client.set_channel_volume(channel, volume)
40
+ when 'zmute'
41
+ zone = Integer(args.shift)
42
+ mute = Integer(args.shift)
43
+ client.set_zone_mute(zone, mute)
44
+ when 'cmute'
45
+ channel = Integer(args.shift)
46
+ mute = Integer(args.shift)
47
+ client.set_channel_mute(channel, mute)
48
+ when 'status'
49
+ client.status
50
+ else
51
+ raise ArgumentError, "Unknown command: #{cmd}"
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
data/lib/seriamp/utils.rb CHANGED
@@ -17,5 +17,22 @@ module Seriamp
17
17
  module_function def monotime
18
18
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
19
19
  end
20
+
21
+ module_function def consume_data(io, logger, msg)
22
+ warned = false
23
+ read_bytes = 0
24
+ while IO.select([io], nil, nil, 0)
25
+ unless warned
26
+ logger&.warn(msg)
27
+ warned = true
28
+ end
29
+ buf = io.read_nonblock(1024)
30
+ read_bytes += buf.length
31
+ end
32
+ if read_bytes > 0
33
+ logger&.warn("Consumed #{read_bytes} bytes")
34
+ end
35
+ nil
36
+ end
20
37
  end
21
38
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Seriamp
4
- VERSION = '0.1.14'
4
+ VERSION = '0.2.0'
5
5
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require 'timeout'
4
4
  require 'seriamp/utils'
5
- require 'seriamp/backend/serial_port'
5
+ require 'seriamp/backend'
6
6
  require 'seriamp/yamaha/protocol/methods'
7
7
 
8
8
  module Seriamp
@@ -197,14 +197,8 @@ module Seriamp
197
197
  logger&.debug("Opening #{device}")
198
198
  @io = Backend::SerialPortBackend::Device.new(device, logger: logger)
199
199
 
200
- warned = false
201
- while IO.select([@io.io], nil, nil, 0)
202
- unless warned
203
- logger&.warn("Serial device readable after opening - unread previous response?")
204
- warned = true
205
- end
206
- IO.read(1)
207
- end
200
+ Utils.consume_data(@io.io, logger,
201
+ "Serial device readable after opening - unread previous response?")
208
202
 
209
203
  begin
210
204
  tries = 0
@@ -251,15 +245,20 @@ module Seriamp
251
245
 
252
246
  def read_response
253
247
  resp = +''
254
- Timeout.timeout(2, CommunicationTimeout) do
255
- loop do
256
- ch = @io.sysread(1)
257
- if ch
258
- resp << ch
259
- break if ch == ETX
260
- else
261
- sleep 0.1
248
+ deadline = Utils.monotime + 1
249
+ loop do
250
+ begin
251
+ chunk = @io.read_nonblock(1000)
252
+ if chunk
253
+ resp += chunk
254
+ break if chunk[-1] == ETX
262
255
  end
256
+ rescue IO::WaitReadable
257
+ budget = deadline - Utils.monotime
258
+ if budget < 0
259
+ raise CommunicationTimeout
260
+ end
261
+ IO.select([@io.io], nil, nil, budget)
263
262
  end
264
263
  end
265
264
  resp
@@ -296,16 +295,9 @@ module Seriamp
296
295
  def do_status
297
296
  with_retry do
298
297
  resp = nil
299
- loop do
300
- resp = dispatch(STATUS_REQ)
301
- again = false
302
- while @io && IO.select([@io.io], nil, nil, 0)
303
- logger&.warn("Serial device readable after completely reading status response - concurrent access?")
304
- read_response
305
- again = true
306
- end
307
- break unless again
308
- end
298
+ resp = dispatch(STATUS_REQ)
299
+ Utils.consume_data(@io.io, logger,
300
+ "Serial device readable after completely reading status response - concurrent access?")
309
301
  if resp.length < 10
310
302
  raise HandshakeFailure, "Broken status response: expected at least 10 bytes, got #{resp.length} bytes; concurrent operation on device?"
311
303
  end
@@ -11,7 +11,7 @@ require 'seriamp/yamaha/executor'
11
11
  module Seriamp
12
12
  module Yamaha
13
13
  class Cmd
14
- def initialize(args)
14
+ def initialize(args = ARGV, stdin = STDIN)
15
15
  options = {}
16
16
  OptionParser.new do |opts|
17
17
  opts.banner = "Usage: yamaha [-d device] command arg..."
@@ -27,16 +27,18 @@ module Seriamp
27
27
  @client = Yamaha::Client.new(device: options[:device], logger: @logger)
28
28
 
29
29
  @args = args
30
+ @stdin = stdin
30
31
  end
31
32
 
32
33
  attr_reader :args
34
+ attr_reader :stdin
33
35
  attr_reader :logger
34
36
 
35
37
  def run
36
38
  if args.any?
37
39
  run_command(args)
38
40
  else
39
- STDIN.each_line do |line|
41
+ stdin.each_line do |line|
40
42
  line.strip!
41
43
  line.sub!(/#.*/, '')
42
44
  next if line.empty?
@@ -10,6 +10,7 @@ module Seriamp
10
10
  attr_reader :client
11
11
 
12
12
  def run_command(cmd, *args)
13
+ cmd = cmd.gsub('_', '-')
13
14
  case cmd
14
15
  when 'detect'
15
16
  device = Seriamp.detect_device(Yamaha, *args, logger: logger)
data/seriamp.gemspec CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Gem::Specification.new do |spec|
4
4
  spec.name = "seriamp"
5
- spec.version = '0.1.14'
5
+ spec.version = '0.2.0'
6
6
  spec.authors = ['Oleg Pudeyev']
7
7
  spec.email = ['code@olegp.name']
8
8
  spec.summary = %q{Serial control for amplifiers & A/V receivers}
@@ -15,7 +15,11 @@ Gem::Specification.new do |spec|
15
15
  spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
16
16
  spec.require_paths = ["lib"]
17
17
 
18
- spec.add_runtime_dependency 'serialport', '~> 1.0'
18
+ spec.add_runtime_dependency 'serialport', '~> 1.3'
19
+
20
+ spec.add_development_dependency 'rspec-core', '~> 3.12'
21
+ spec.add_development_dependency 'rspec-expectations', '~> 3.12'
22
+ spec.add_development_dependency 'rspec-mocks', '~> 3.12'
19
23
 
20
24
  # Optional dependencies: sinatra for the web apps
21
25
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe Seriamp::Sonamp::App do
6
+ include Rack::Test::Methods
7
+
8
+ describe '#initialize' do
9
+ it 'works' do
10
+ described_class.new
11
+ end
12
+ end
13
+
14
+ let(:app) do
15
+ described_class.tap do |app|
16
+ app.client = client
17
+ end
18
+ end
19
+
20
+ let(:client_cls) { Seriamp::Sonamp::Client }
21
+ let(:client) { double('sonamp client') }
22
+
23
+ describe '/off' do
24
+ let(:final_state) do
25
+ {'1' => false, '2' => false, '3' => false, '4' => false}
26
+ end
27
+
28
+ it 'works' do
29
+
30
+ client.should receive(:set_zone_power).with(1, false)
31
+ client.should receive(:set_zone_power).with(2, false)
32
+ client.should receive(:set_zone_power).with(3, false)
33
+ client.should receive(:set_zone_power).with(4, false)
34
+ client.should receive(:get_zone_power).and_return(final_state)
35
+
36
+ post '/off'
37
+
38
+ last_response.status.should == 200
39
+ JSON.parse(last_response.body).should == final_state
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe Seriamp::Sonamp::Client do
6
+ describe '#initialize' do
7
+ it 'works' do
8
+ described_class.new
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe Seriamp::Sonamp::Cmd do
6
+ describe '#initialize' do
7
+ it 'works' do
8
+ described_class.new
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ ENV['RACK_ENV'] = 'test'
4
+
5
+ require 'byebug'
6
+ require 'seriamp/all'
7
+ require 'rack/test'
8
+
9
+ RSpec.configure do |rspec|
10
+ rspec.expect_with(:rspec) do |c|
11
+ c.syntax = [:should, :expect]
12
+ end
13
+ rspec.mock_with(:rspec) do |mocks|
14
+ mocks.syntax = [:should, :expect]
15
+ end
16
+ end