nlhue 0.1.1
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 +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
|