nlhue 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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