nlhue 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +11 -0
- data/.travis.yml +4 -0
- data/Gemfile +4 -0
- data/LICENSE +22 -0
- data/README.md +91 -0
- data/Rakefile +1 -0
- data/bin/console +12 -0
- data/bin/setup +7 -0
- data/lib/nlhue/bridge.rb +830 -0
- data/lib/nlhue/disco.rb +362 -0
- data/lib/nlhue/group.rb +79 -0
- data/lib/nlhue/light.rb +20 -0
- data/lib/nlhue/request_queue.rb +131 -0
- data/lib/nlhue/scene.rb +88 -0
- data/lib/nlhue/ssdp.rb +106 -0
- data/lib/nlhue/target.rb +411 -0
- data/lib/nlhue/version.rb +3 -0
- data/lib/nlhue.rb +36 -0
- data/nlhue.gemspec +28 -0
- metadata +148 -0
data/lib/nlhue/bridge.rb
ADDED
@@ -0,0 +1,830 @@
|
|
1
|
+
# A class representing a Hue bridge.
|
2
|
+
# (C)2015 Mike Bourgeous
|
3
|
+
|
4
|
+
require 'eventmachine'
|
5
|
+
require 'em/protocols/httpclient'
|
6
|
+
require 'rexml/document'
|
7
|
+
require 'json'
|
8
|
+
|
9
|
+
require_relative 'request_queue'
|
10
|
+
|
11
|
+
module NLHue
|
12
|
+
class NotVerifiedError < StandardError
|
13
|
+
def initialize msg="Call .verify() before using the bridge."
|
14
|
+
super msg
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
class LinkButtonError < StandardError
|
19
|
+
def initialize msg="Press the bridge's link button."
|
20
|
+
super msg
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
class NotRegisteredError < StandardError
|
25
|
+
def initialize msg="Press the bridge's link button and call .register()."
|
26
|
+
super msg
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
# A class representing a Hue bridge. A Bridge object may not refer to
|
31
|
+
# an actual Hue bridge if verify() hasn't succeeded. Manages a list of
|
32
|
+
# lights and groups on the bridge. HTTP requests to the bridge are
|
33
|
+
# queued and sent one at a time to prevent overloading the bridge's
|
34
|
+
# CPU.
|
35
|
+
class Bridge
|
36
|
+
# Seconds to wait after an update round finishes before sending
|
37
|
+
# more updates to lights and groups.
|
38
|
+
RATE_LIMIT = 0.2
|
39
|
+
|
40
|
+
attr_reader :username, :addr, :serial, :name, :whitelist
|
41
|
+
|
42
|
+
@@bridge_cbs = [] # callbacks notified when a bridge has its first successful update
|
43
|
+
|
44
|
+
# Adds a callback to be called with a Bridge object and a
|
45
|
+
# status each time a Bridge has its first successful update,
|
46
|
+
# adds or removes lights or groups, or becomes unregistered.
|
47
|
+
# Bridge callbacks will be called after any corresponding :add
|
48
|
+
# or :del disco event is delivered. Returns a Proc object that
|
49
|
+
# may be passed to remove_update_cb.
|
50
|
+
#
|
51
|
+
# Callback parameters:
|
52
|
+
# Bridge first update:
|
53
|
+
# [Bridge], true
|
54
|
+
#
|
55
|
+
# Bridge added/removed lights or groups:
|
56
|
+
# [Bridge], true
|
57
|
+
#
|
58
|
+
# Bridge unregistered:
|
59
|
+
# [Bridge], false
|
60
|
+
def self.add_bridge_callback &block
|
61
|
+
@@bridge_cbs << block
|
62
|
+
block
|
63
|
+
end
|
64
|
+
|
65
|
+
# Removes the given callback (returned by add_bridge_callback)
|
66
|
+
# from Bridge first update/unregistration notifications.
|
67
|
+
def self.remove_bridge_callback cb
|
68
|
+
@@bridge_cbs.delete cb
|
69
|
+
end
|
70
|
+
|
71
|
+
# Sends the given bridge to all attached first update callbacks.
|
72
|
+
def self.notify_bridge_callbacks br, status
|
73
|
+
@@bridge_cbs.each do |cb|
|
74
|
+
begin
|
75
|
+
cb.call br, status
|
76
|
+
rescue => e
|
77
|
+
log_e e, "Error calling a first update callback"
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
# addr - The IP address or hostname of the Hue bridge.
|
83
|
+
# serial - The serial number of the bridge, if available
|
84
|
+
# (parsed from the USN header in a UPnP response)
|
85
|
+
def initialize addr, serial = nil
|
86
|
+
@addr = addr
|
87
|
+
@verified = false
|
88
|
+
@username = nil
|
89
|
+
@name = nil
|
90
|
+
@config = nil
|
91
|
+
@registered = false
|
92
|
+
@lights = {}
|
93
|
+
@groups = {}
|
94
|
+
@scenes = {}
|
95
|
+
@whitelist = {}
|
96
|
+
@lightscan = {'lastscan' => 'none'}
|
97
|
+
if serial && serial =~ /^[0-9A-Fa-f]{12}$/
|
98
|
+
@serial = serial.downcase
|
99
|
+
else
|
100
|
+
@serial = nil
|
101
|
+
end
|
102
|
+
|
103
|
+
@request_queue = NLHue::RequestQueue.new addr, 2
|
104
|
+
|
105
|
+
@update_timer = nil
|
106
|
+
@update_callbacks = []
|
107
|
+
|
108
|
+
@rate_timer = nil
|
109
|
+
@rate_targets = {}
|
110
|
+
@rate_proc = proc do
|
111
|
+
if @rate_targets.empty?
|
112
|
+
log "No targets, canceling rate timer." # XXX
|
113
|
+
@rate_timer.cancel if @rate_timer
|
114
|
+
@rate_timer = nil
|
115
|
+
else
|
116
|
+
log "Sending targets from rate timer." # XXX
|
117
|
+
send_targets do
|
118
|
+
@rate_timer = EM::Timer.new(RATE_LIMIT, @rate_proc)
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
# Calls the given block with true or false if verification by
|
125
|
+
# description.xml succeeds or fails. If verification has
|
126
|
+
# already been performed, the block will be called immediately
|
127
|
+
# with true.
|
128
|
+
def verify &block
|
129
|
+
if @verified
|
130
|
+
yield true
|
131
|
+
return true
|
132
|
+
end
|
133
|
+
|
134
|
+
@request_queue.get '/description.xml', :info, 4 do |result|
|
135
|
+
puts "Description result: #{result.inspect}" # XXX
|
136
|
+
if result.is_a?(Hash) && result[:status] == 200
|
137
|
+
@desc = REXML::Document.new result[:content]
|
138
|
+
@desc.write($stdout, 4, true) # XXX
|
139
|
+
@desc.elements.each('friendlyName') do |el|
|
140
|
+
puts "Friendly name: #{@name}" # XXX
|
141
|
+
set_name el.text
|
142
|
+
end
|
143
|
+
@desc.elements.each('serialNumber') do |el|
|
144
|
+
puts "Serial number: #{@serial}" # XXX
|
145
|
+
@serial = el.text.downcase
|
146
|
+
end
|
147
|
+
@desc.elements.each('modelName') do |el|
|
148
|
+
puts "modelName: #{el.text}" # XXX
|
149
|
+
if el.text.include? 'Philips hue'
|
150
|
+
@verified = true
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
# FIXME: Delete this line when converted to em-http-request; this
|
155
|
+
# works around the empty :content returned by EM::HttpClient
|
156
|
+
#
|
157
|
+
# See commits:
|
158
|
+
# 34110773fc45bfdd56c32972650f9d947d8fac78
|
159
|
+
# 6d8d7a0566e3c51c3ab15eb2358dde3e518594d3
|
160
|
+
@verified = true
|
161
|
+
end
|
162
|
+
|
163
|
+
begin
|
164
|
+
yield @verified
|
165
|
+
rescue => e
|
166
|
+
log_e e, "Error notifying block after verification"
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
# Attempts to register with the Bridge. The block will be
|
172
|
+
# called with true and the result array if registration
|
173
|
+
# succeeds, false and an Exception if not. If registration
|
174
|
+
# succeeds, the Bridge object's current username will be set to
|
175
|
+
# the username returned by the bridge. If the Bridge is
|
176
|
+
# already registered it will not be re-registered, and the
|
177
|
+
# block will be called with true and the current username.
|
178
|
+
# Call #update after registration succeeds. #registered? will
|
179
|
+
# not return true until #update has succeeded.
|
180
|
+
def register devicetype, &block
|
181
|
+
raise NotVerifiedError.new unless @verified
|
182
|
+
|
183
|
+
if @username && @registered
|
184
|
+
yield true, @username
|
185
|
+
return
|
186
|
+
end
|
187
|
+
|
188
|
+
msg = %Q{{"devicetype":#{devicetype.to_json}}}
|
189
|
+
@request_queue.post '/api', msg, :registration, nil, 6 do |response|
|
190
|
+
status, result = check_json response
|
191
|
+
|
192
|
+
if status
|
193
|
+
@username = result.first['success']['username']
|
194
|
+
end
|
195
|
+
|
196
|
+
yield status, result
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
# Deletes the given +username+ (or this Bridge object's current
|
201
|
+
# username if +username+ is nil) from the Bridge's whitelist,
|
202
|
+
# sets this Bridge's username to nil, and sets its registered
|
203
|
+
# state to false.
|
204
|
+
def unregister username = nil, &block
|
205
|
+
raise NotVerifiedError.new unless @verified
|
206
|
+
|
207
|
+
username ||= @username
|
208
|
+
|
209
|
+
@request_queue.delete "/api/#{username}/config/whitelist/#{username}", :registration, 6 do |response|
|
210
|
+
status, result = check_json response
|
211
|
+
|
212
|
+
if @username == username && status
|
213
|
+
@registered = false
|
214
|
+
@username = nil
|
215
|
+
Bridge.notify_bridge_callbacks self, false
|
216
|
+
end
|
217
|
+
|
218
|
+
yield status, result
|
219
|
+
end
|
220
|
+
end
|
221
|
+
|
222
|
+
# Starts a timer that retrieves the current state of the bridge
|
223
|
+
# every interval seconds. Does nothing if this Bridge is
|
224
|
+
# already subscribed.
|
225
|
+
def subscribe interval=1
|
226
|
+
return if @update_timer
|
227
|
+
|
228
|
+
update_proc = proc {
|
229
|
+
update do |status, result|
|
230
|
+
@update_timer = EM::Timer.new(interval, update_proc) if @update_timer
|
231
|
+
end
|
232
|
+
}
|
233
|
+
|
234
|
+
@update_timer = EM::Timer.new(interval, update_proc)
|
235
|
+
end
|
236
|
+
|
237
|
+
# Stops the timer started by subscribe(), if one is running.
|
238
|
+
def unsubscribe
|
239
|
+
@update_timer.cancel if @update_timer
|
240
|
+
@update_timer = nil
|
241
|
+
end
|
242
|
+
|
243
|
+
# Adds a callback to be notified when a subscription update is
|
244
|
+
# received, or when a subscription update fails. The return
|
245
|
+
# value may be passed to remove_update_callback.
|
246
|
+
#
|
247
|
+
# Callback parameters:
|
248
|
+
# Update successful:
|
249
|
+
# true, [lights or groups changed: true/false]
|
250
|
+
#
|
251
|
+
# Update failed:
|
252
|
+
# false, [exception]
|
253
|
+
def add_update_callback &cb
|
254
|
+
@update_callbacks << cb
|
255
|
+
cb
|
256
|
+
end
|
257
|
+
|
258
|
+
# Removes the given callback (returned by add_update_callback)
|
259
|
+
# from the list of callbacks notified with subscription events.
|
260
|
+
def remove_update_callback cb
|
261
|
+
@update_callbacks.delete cb
|
262
|
+
end
|
263
|
+
|
264
|
+
# Updates the Bridge object with the lights, groups, and config
|
265
|
+
# of the Hue bridge. Also updates the current light scan
|
266
|
+
# status on the first update or if the Bridge thinks a scan is
|
267
|
+
# currently active. On success the given block will be called
|
268
|
+
# with true and whether the lights/groups were changed, false
|
269
|
+
# and an exception on error.
|
270
|
+
def update &block
|
271
|
+
@request_queue.get "/api/#{@username}", :info do |response|
|
272
|
+
status, result = check_json response
|
273
|
+
|
274
|
+
changed = false
|
275
|
+
|
276
|
+
begin
|
277
|
+
if status
|
278
|
+
first_update = !@registered
|
279
|
+
|
280
|
+
# TODO: Extract a helper method for adding new
|
281
|
+
# members to and removing old members from a list
|
282
|
+
|
283
|
+
@config = result
|
284
|
+
@config['lights'].each do |id, info|
|
285
|
+
id = id.to_i
|
286
|
+
if @lights[id].is_a? Light
|
287
|
+
@lights[id].handle_json info
|
288
|
+
else
|
289
|
+
@lights[id] = Light.new(self, id, info)
|
290
|
+
changed = true
|
291
|
+
end
|
292
|
+
end
|
293
|
+
@lights.select! do |id, light|
|
294
|
+
incl = @config['lights'].include? id.to_s
|
295
|
+
changed ||= !incl
|
296
|
+
incl
|
297
|
+
end
|
298
|
+
|
299
|
+
set_name @config['config']['name'] unless @name
|
300
|
+
@serial ||= @config['config']['mac'].gsub(':', '').downcase
|
301
|
+
|
302
|
+
@registered = true
|
303
|
+
|
304
|
+
unless @groups[0].is_a? Group
|
305
|
+
@groups[0] = Group.new(self, 0)
|
306
|
+
get_api '/groups/0', :info do |response|
|
307
|
+
status, result = check_json response
|
308
|
+
if status && @groups[0]
|
309
|
+
@groups[0].handle_json result
|
310
|
+
end
|
311
|
+
end
|
312
|
+
end
|
313
|
+
|
314
|
+
@config['groups'].each do |id, info|
|
315
|
+
if @groups[id.to_i].is_a? Group
|
316
|
+
@groups[id.to_i].handle_json info
|
317
|
+
else
|
318
|
+
@groups[id.to_i] = Group.new(self, id.to_i, info)
|
319
|
+
changed = true
|
320
|
+
end
|
321
|
+
end
|
322
|
+
@groups.select! do |id, light|
|
323
|
+
incl = @config['groups'].include?(id.to_s) || id == 0
|
324
|
+
changed ||= !incl
|
325
|
+
incl
|
326
|
+
end
|
327
|
+
|
328
|
+
@config['scenes'].each do |id, info|
|
329
|
+
if @scenes[id].is_a? Scene
|
330
|
+
@scenes[id].handle_json info
|
331
|
+
else
|
332
|
+
@scenes[id] = Scene.new(self, id, info)
|
333
|
+
changed = true
|
334
|
+
end
|
335
|
+
end
|
336
|
+
@scenes.select! do |id, scene|
|
337
|
+
incl = @config['scenes'].include?(id.to_s)
|
338
|
+
changed ||= !incl
|
339
|
+
incl
|
340
|
+
end
|
341
|
+
|
342
|
+
wl = @config['config']['whitelist']
|
343
|
+
if wl != @whitelist
|
344
|
+
@whitelist = wl
|
345
|
+
changed = true
|
346
|
+
end
|
347
|
+
|
348
|
+
if changed
|
349
|
+
@scenes = Hash[@scenes.sort_by{|id, s| s.name}]
|
350
|
+
end
|
351
|
+
|
352
|
+
# TODO: schedules, rules, sensors, etc.
|
353
|
+
|
354
|
+
scan_status true if first_update || @lightscan['lastscan'] == 'active'
|
355
|
+
|
356
|
+
Bridge.notify_bridge_callbacks self, true if first_update || changed
|
357
|
+
end
|
358
|
+
rescue => e
|
359
|
+
log_e e, "Bridge #{@serial} update raised an exception"
|
360
|
+
status = false
|
361
|
+
result = e
|
362
|
+
end
|
363
|
+
|
364
|
+
result = changed if status
|
365
|
+
notify_update_callbacks status, result
|
366
|
+
|
367
|
+
yield status, result
|
368
|
+
end
|
369
|
+
end
|
370
|
+
|
371
|
+
# Initiates a scan for new lights. If a block is given, yields
|
372
|
+
# true if the scan was started, an exception if there was an
|
373
|
+
# error.
|
374
|
+
def scan_lights &block
|
375
|
+
post_api '/lights', nil do |response|
|
376
|
+
begin
|
377
|
+
status, result = check_json response
|
378
|
+
@lightscan['lastscan'] = 'active' if status
|
379
|
+
yield status if block_given?
|
380
|
+
rescue => e
|
381
|
+
yield e
|
382
|
+
end
|
383
|
+
end
|
384
|
+
end
|
385
|
+
|
386
|
+
# Calls the given block (if given) with true and the last known
|
387
|
+
# light scan status from the bridge. Requests the current scan
|
388
|
+
# status from the bridge if request is true. The block will be
|
389
|
+
# called with false and an exception if an error occurs during
|
390
|
+
# a request. Returns the last known scan status.
|
391
|
+
#
|
392
|
+
# The scan status is a Hash with the following form:
|
393
|
+
# {
|
394
|
+
# '1' => { 'name' => 'New Light 1' }, # If new lights were found
|
395
|
+
# '2' => { 'name' => 'New Light 2' },
|
396
|
+
# 'lastscan' => 'active'/'none'/ISO8601:2004
|
397
|
+
# }
|
398
|
+
def scan_status request=false, &block
|
399
|
+
if request
|
400
|
+
get_api '/lights/new' do |response|
|
401
|
+
begin
|
402
|
+
status, result = check_json response
|
403
|
+
|
404
|
+
if status
|
405
|
+
# Update group 0 if new lights are found or when a scan completes
|
406
|
+
if @lightscan['lastscan'] == 'active' && result['lastscan'] != 'active'
|
407
|
+
puts "Updating group 0" # XXX
|
408
|
+
@groups[0].update
|
409
|
+
end
|
410
|
+
@lightscan = result
|
411
|
+
end
|
412
|
+
|
413
|
+
yield status, result if block_given?
|
414
|
+
rescue => e
|
415
|
+
yield e
|
416
|
+
end
|
417
|
+
end
|
418
|
+
else
|
419
|
+
yield true, @lightscan if block_given?
|
420
|
+
end
|
421
|
+
|
422
|
+
@lightscan unless block_given?
|
423
|
+
end
|
424
|
+
|
425
|
+
# Returns true if a scan for lights is active (as of the last
|
426
|
+
# call to #update), false otherwise.
|
427
|
+
def scan_active?
|
428
|
+
@lightscan['lastscan'] == 'active'
|
429
|
+
end
|
430
|
+
|
431
|
+
# Returns whether the Bridge object has had at least one
|
432
|
+
# successful update from #update.
|
433
|
+
def updated?
|
434
|
+
@config.is_a? Hash
|
435
|
+
end
|
436
|
+
|
437
|
+
# Indicates whether verification succeeded. A Hue bridge has
|
438
|
+
# been verified to exist at the address given to the
|
439
|
+
# constructor or to #addr= if this returns true. Use #verify
|
440
|
+
# to perform verification if this returns false.
|
441
|
+
def verified?
|
442
|
+
@verified
|
443
|
+
end
|
444
|
+
|
445
|
+
# Returns true if this bridge is subscribed (i.e. periodically
|
446
|
+
# polling the Hue bridge for updates).
|
447
|
+
def subscribed?
|
448
|
+
!!@update_timer
|
449
|
+
end
|
450
|
+
|
451
|
+
# Returns whether the Bridge object believes it is registered
|
452
|
+
# with its associated Hue bridge. Set to true when #update
|
453
|
+
# or #register succeeds, false if a NotRegisteredError occurs.
|
454
|
+
def registered?
|
455
|
+
@registered
|
456
|
+
end
|
457
|
+
|
458
|
+
# Returns a Hash mapping light IDs to Light objects,
|
459
|
+
# representing the lights known to the Hue bridge.
|
460
|
+
def lights
|
461
|
+
@lights.clone
|
462
|
+
end
|
463
|
+
|
464
|
+
# The number of lights known to this bridge.
|
465
|
+
def num_lights
|
466
|
+
@lights.length
|
467
|
+
end
|
468
|
+
|
469
|
+
# Returns a Hash mapping group IDs to Group objects
|
470
|
+
# representing the groups known to this bridge, including the
|
471
|
+
# default group 0 that contains all lights from this bridge.
|
472
|
+
def groups
|
473
|
+
@groups.clone
|
474
|
+
end
|
475
|
+
|
476
|
+
# The number of groups known to this bridge, including the
|
477
|
+
# default group that contains all lights known to the bridge.
|
478
|
+
def num_groups
|
479
|
+
@groups.length
|
480
|
+
end
|
481
|
+
|
482
|
+
# Returns a Hash mapping scene IDs to Scene objects. Unlike
|
483
|
+
# lights and groups, scenes have String IDs.
|
484
|
+
def scenes
|
485
|
+
@scenes.clone
|
486
|
+
end
|
487
|
+
|
488
|
+
# The number of scenes found on the bridge.
|
489
|
+
def num_scenes
|
490
|
+
@scenes.length
|
491
|
+
end
|
492
|
+
|
493
|
+
# Returns the scene with an exactly matching ID, the most
|
494
|
+
# recently created Scene with a name starting with +prefix+
|
495
|
+
# (that is, the scene occurring last in an alphanumeric sort),
|
496
|
+
# or nil if no match was found.
|
497
|
+
def find_scene(prefix)
|
498
|
+
return @scenes[prefix] if @scenes.include? prefix
|
499
|
+
|
500
|
+
prefix = prefix.downcase
|
501
|
+
@scenes.select{|id, scene|
|
502
|
+
scene.name.downcase.start_with?(prefix)
|
503
|
+
}.values.max_by{|s|
|
504
|
+
s.name
|
505
|
+
}
|
506
|
+
end
|
507
|
+
|
508
|
+
# Creates a new group with the given name and list of lights.
|
509
|
+
# The given block will be called with true and a NLHue::Group
|
510
|
+
# object on success, false and an error on failure. If group
|
511
|
+
# creation succeeds, all bridge callbacks added with
|
512
|
+
# Bridge.add_bridge_callback will be notified after the new
|
513
|
+
# group is yielded to the block (if a block is given). Note
|
514
|
+
# that the group's name may be changed by the bridge.
|
515
|
+
def create_group name, lights, &block
|
516
|
+
raise "No group name was given" unless name.is_a?(String) && name.length > 0
|
517
|
+
raise "No lights were given" unless lights.is_a?(Array) && lights.length > 0
|
518
|
+
|
519
|
+
light_ids = []
|
520
|
+
lights.each do |l|
|
521
|
+
raise "All given lights must be NLHue::Light objects" unless l.is_a?(Light)
|
522
|
+
raise "Light #{l.id} (#{l.name}) is not from this bridge." if l.bridge != self
|
523
|
+
|
524
|
+
light_ids << l.id.to_s
|
525
|
+
end
|
526
|
+
|
527
|
+
group_data = { :lights => light_ids, :name => name }.to_json
|
528
|
+
post_api '/groups', group_data, :lights do |response|
|
529
|
+
status, result = check_json response
|
530
|
+
|
531
|
+
begin
|
532
|
+
result = result.first if result.is_a? Array
|
533
|
+
|
534
|
+
if status && result['success']
|
535
|
+
id = result['success']['id'].split('/').last.to_i
|
536
|
+
raise "Invalid ID received for new group: '#{result['success']['id']}'" unless id > 0
|
537
|
+
|
538
|
+
group = Group.new(self, id, { 'name' => name, 'lights' => light_ids, 'action' => {} })
|
539
|
+
group.update do |upstatus, upresult|
|
540
|
+
if upstatus
|
541
|
+
@groups[id] = group
|
542
|
+
Bridge.notify_bridge_callbacks self, true
|
543
|
+
yield true, group if block_given?
|
544
|
+
else
|
545
|
+
yield upstatus, upresult if block_given?
|
546
|
+
end
|
547
|
+
end
|
548
|
+
else
|
549
|
+
raise result
|
550
|
+
end
|
551
|
+
rescue => e
|
552
|
+
yield false, e if block_given?
|
553
|
+
end
|
554
|
+
end
|
555
|
+
end
|
556
|
+
|
557
|
+
# Deletes the given NLHue::Group on this bridge. Raises an
|
558
|
+
# exception if the group is group 0. Calls the given block
|
559
|
+
# with true and a message on success, or false and an error on
|
560
|
+
# failure. Bridge notification callbacks will be called after
|
561
|
+
# the result is yielded.
|
562
|
+
def delete_group group, &block
|
563
|
+
raise "No group was given to delete" if group.nil?
|
564
|
+
raise "Group must be a NLHue::Group object" unless group.is_a?(Group)
|
565
|
+
raise "Group is not from this bridge" unless group.bridge == self
|
566
|
+
raise "Cannot delete group 0" if group.id == 0
|
567
|
+
|
568
|
+
delete_api "/groups/#{group.id}", :lights do |response|
|
569
|
+
status, result = check_json response
|
570
|
+
|
571
|
+
if status
|
572
|
+
@groups.delete group.id
|
573
|
+
end
|
574
|
+
|
575
|
+
yield status, result if block_given?
|
576
|
+
|
577
|
+
if status
|
578
|
+
Bridge.notify_bridge_callbacks self, true
|
579
|
+
end
|
580
|
+
end
|
581
|
+
end
|
582
|
+
|
583
|
+
# Sets the +username+ used to interact with the bridge. This
|
584
|
+
# should be a username obtained from a previously registered
|
585
|
+
# Bridge.
|
586
|
+
def username= username
|
587
|
+
check_username username
|
588
|
+
@username = username
|
589
|
+
end
|
590
|
+
|
591
|
+
# Sets the IP address or hostname of the Hue bridge. The
|
592
|
+
# Bridge object will be marked as unverified, so #verify should
|
593
|
+
# be called afterward.
|
594
|
+
def addr= addr
|
595
|
+
@addr = addr
|
596
|
+
@verified = false
|
597
|
+
@request_queue.addr = addr
|
598
|
+
end
|
599
|
+
|
600
|
+
# Unsubscribes from bridge updates, marks this bridge as
|
601
|
+
# unregistered, notifies global bridge callbacks added with
|
602
|
+
# add_bridge_callback, then removes references to
|
603
|
+
# configuration, lights, groups, and update callbacks.
|
604
|
+
def clean
|
605
|
+
was_updated = updated?
|
606
|
+
unsubscribe
|
607
|
+
@registered = false
|
608
|
+
|
609
|
+
Bridge.notify_bridge_callbacks self, false if was_updated
|
610
|
+
|
611
|
+
@verified = false
|
612
|
+
@config = nil
|
613
|
+
@lights.clear
|
614
|
+
@groups.clear
|
615
|
+
@update_callbacks.clear
|
616
|
+
end
|
617
|
+
|
618
|
+
# Throws errors if the given username is invalid (may not catch
|
619
|
+
# all invalid names).
|
620
|
+
def check_username username
|
621
|
+
raise 'Username must be a String' unless username.is_a?(String)
|
622
|
+
raise 'Username must be >= 10 characters.' unless username.to_s.length >= 10
|
623
|
+
raise 'Spaces are not permitted in usernames.' if username =~ /[[:space:]]/
|
624
|
+
end
|
625
|
+
|
626
|
+
# Checks for a valid JSON-containing response from an HTTP
|
627
|
+
# request method, returns an error if invalid or no response.
|
628
|
+
# Does not consider non-200 HTTP response codes as errors.
|
629
|
+
# Returns true and the received JSON if no error occurred, or
|
630
|
+
# false and an exception if an error did occur. Marks this
|
631
|
+
# bridge as not registered if there is a NotRegisteredError.
|
632
|
+
def check_json response
|
633
|
+
status = false
|
634
|
+
result = nil
|
635
|
+
|
636
|
+
begin
|
637
|
+
raise 'No response received.' if response == false
|
638
|
+
|
639
|
+
if response.is_a?(Hash)
|
640
|
+
status = true
|
641
|
+
result_msgs = []
|
642
|
+
|
643
|
+
result = JSON.parse response[:content]
|
644
|
+
if result.is_a? Array
|
645
|
+
result.each do |v|
|
646
|
+
if v.is_a?(Hash) && v['error'].is_a?(Hash); then
|
647
|
+
status = false
|
648
|
+
result_msgs << v['error']['description']
|
649
|
+
end
|
650
|
+
end
|
651
|
+
end
|
652
|
+
|
653
|
+
unless status
|
654
|
+
if result_msgs.include?('link button not pressed')
|
655
|
+
raise LinkButtonError.new
|
656
|
+
elsif result_msgs.include?('unauthorized user')
|
657
|
+
was_reg = @registered
|
658
|
+
@registered = false
|
659
|
+
Bridge.notify_bridge_callbacks self, false if was_reg
|
660
|
+
|
661
|
+
raise NotRegisteredError.new
|
662
|
+
else
|
663
|
+
raise StandardError.new(result_msgs.join(', '))
|
664
|
+
end
|
665
|
+
end
|
666
|
+
end
|
667
|
+
rescue => e
|
668
|
+
status = false
|
669
|
+
result = e
|
670
|
+
end
|
671
|
+
|
672
|
+
return status, result
|
673
|
+
end
|
674
|
+
|
675
|
+
# "Hue Bridge: [IP]: [Friendly Name] ([serial]) - N lights"
|
676
|
+
def to_s
|
677
|
+
str = "Hue Bridge: #{@addr}: #{@name} (#{@serial}) - #{@lights.length} lights"
|
678
|
+
str << " (#{registered? ? '' : 'un'}registered)"
|
679
|
+
str
|
680
|
+
end
|
681
|
+
|
682
|
+
# Returns a Hash with information about this bridge:
|
683
|
+
# {
|
684
|
+
# :addr => "[IP address]",
|
685
|
+
# :name => "[name]",
|
686
|
+
# :serial => "[serial number]",
|
687
|
+
# :registered => true/false,
|
688
|
+
# :scan => [return value of #scan_status],
|
689
|
+
# :lights => [hash containing Light objects (see #lights)],
|
690
|
+
# :groups => [hash containing Group objects (see #groups)],
|
691
|
+
# :scenes => [hash containing Scene objects (see #scenes)],
|
692
|
+
# :config => [raw config from bridge] if include_config
|
693
|
+
# }
|
694
|
+
#
|
695
|
+
# Do not modify the included lights and groups hashes.
|
696
|
+
def to_h include_config=false
|
697
|
+
h = {
|
698
|
+
:addr => @addr,
|
699
|
+
:name => @name,
|
700
|
+
:serial => @serial,
|
701
|
+
:registered => @registered,
|
702
|
+
:scan => @lightscan,
|
703
|
+
:lights => @lights,
|
704
|
+
:groups => @groups,
|
705
|
+
:scenes => @scenes,
|
706
|
+
}
|
707
|
+
h[:config] = @config if include_config
|
708
|
+
h
|
709
|
+
end
|
710
|
+
|
711
|
+
# Return value of to_h converted to a JSON string.
|
712
|
+
# Options: :include_config => true -- include raw config returned by the bridge
|
713
|
+
def to_json *args
|
714
|
+
to_h(args[0].is_a?(Hash) && args[0][:include_config]).to_json(*args)
|
715
|
+
end
|
716
|
+
|
717
|
+
# Makes a GET request under the API using this Bridge's stored
|
718
|
+
# username.
|
719
|
+
def get_api subpath, category=nil, &block
|
720
|
+
@request_queue.get "/api/#{@username}#{subpath}", &block
|
721
|
+
end
|
722
|
+
|
723
|
+
# Makes a POST request under the API using this Bridge's stored
|
724
|
+
# username.
|
725
|
+
def post_api subpath, data, category=nil, content_type=nil, &block
|
726
|
+
@request_queue.post "/api/#{@username}#{subpath}", data, category, content_type, &block
|
727
|
+
end
|
728
|
+
|
729
|
+
# Makes a PUT request under the API using this Bridge's stored
|
730
|
+
# username.
|
731
|
+
def put_api subpath, data, category=nil, content_type=nil, &block
|
732
|
+
@request_queue.put "/api/#{@username}#{subpath}", data, category, content_type, &block
|
733
|
+
end
|
734
|
+
|
735
|
+
# Makes a DELETE request under the API using this Bridge's
|
736
|
+
# stored username.
|
737
|
+
def delete_api subpath, category=nil, &block
|
738
|
+
@request_queue.delete "/api/#{@username}#{subpath}", category, &block
|
739
|
+
end
|
740
|
+
|
741
|
+
# Schedules a Light, Scene, or Group to have its deferred values
|
742
|
+
# sent the next time the rate limiting timer fires, or
|
743
|
+
# immediately if the rate limiting timer has expired. Starts
|
744
|
+
# the rate limiting timer if it is not running.
|
745
|
+
def add_target t, &block
|
746
|
+
raise 'Target must respond to :send_changes' unless t.respond_to?(:send_changes)
|
747
|
+
raise "Target is from #{t.bridge.serial} not this bridge (#{@serial})" unless t.bridge == self
|
748
|
+
|
749
|
+
log "Adding deferred target #{t}" # XXX
|
750
|
+
|
751
|
+
@rate_targets[t] ||= []
|
752
|
+
@rate_targets[t] << block if block_given?
|
753
|
+
|
754
|
+
# TODO: Use different timers for different request types
|
755
|
+
unless @rate_timer
|
756
|
+
log "No rate timer -- sending targets on next tick" # XXX
|
757
|
+
|
758
|
+
# Waiting until the next tick allows multiple
|
759
|
+
# updates to be queued in the current tick that
|
760
|
+
# will all go out at the same time.
|
761
|
+
EM.next_tick do
|
762
|
+
log "It's next tick -- sending targets now" # XXX
|
763
|
+
send_targets
|
764
|
+
end
|
765
|
+
|
766
|
+
log "Setting rate timer"
|
767
|
+
@rate_timer = EM::Timer.new(RATE_LIMIT, @rate_proc)
|
768
|
+
else
|
769
|
+
log "Rate timer is set -- not sending targets now" # XXX
|
770
|
+
end
|
771
|
+
end
|
772
|
+
|
773
|
+
private
|
774
|
+
# Sets this bridge's name (call after getting UPnP XML or
|
775
|
+
# bridge config JSON), removing the IP address if present.
|
776
|
+
def set_name name
|
777
|
+
@name = name.gsub " (#{@addr})", ''
|
778
|
+
end
|
779
|
+
|
780
|
+
# Calls #send_changes on all targets in the rate-limiting
|
781
|
+
# queue, passing the result to each callback that was scheduled
|
782
|
+
# by a call to #submit on the Light or Group. Clears the
|
783
|
+
# rate-limiting queue. Calls the block (if given) with no
|
784
|
+
# arguments once all changes scheduled at the time of the
|
785
|
+
# initial call to send_targets have been sent.
|
786
|
+
def send_targets &block
|
787
|
+
targets = @rate_targets.to_a
|
788
|
+
@rate_targets.clear
|
789
|
+
|
790
|
+
return if targets.empty?
|
791
|
+
|
792
|
+
target, cbs = targets.shift
|
793
|
+
|
794
|
+
target_cb = proc {|status, result|
|
795
|
+
|
796
|
+
cbs.each do |cb|
|
797
|
+
begin
|
798
|
+
cb.call status, result if cb
|
799
|
+
rescue => e
|
800
|
+
log_e e, "Error notifying rate limited target #{t} callback #{cb.inspect}"
|
801
|
+
end
|
802
|
+
end
|
803
|
+
|
804
|
+
target, cbs = targets.shift
|
805
|
+
|
806
|
+
if target
|
807
|
+
log "Sending subsequent target #{target}" # XXX
|
808
|
+
target.send_changes &target_cb
|
809
|
+
else
|
810
|
+
yield if block_given?
|
811
|
+
end
|
812
|
+
}
|
813
|
+
|
814
|
+
log "Sending first target #{target}" # XXX
|
815
|
+
target.send_changes &target_cb
|
816
|
+
end
|
817
|
+
|
818
|
+
# Calls all callbacks added using #add_update_callback with the
|
819
|
+
# given update status and result.
|
820
|
+
def notify_update_callbacks status, result
|
821
|
+
@update_callbacks.each do |cb|
|
822
|
+
begin
|
823
|
+
cb.call status, result
|
824
|
+
rescue => e
|
825
|
+
log_e e, "Error calling an update callback"
|
826
|
+
end
|
827
|
+
end
|
828
|
+
end
|
829
|
+
end
|
830
|
+
end
|