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