nlhue 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,362 @@
1
+ # Discovery of Hue bridges using SSDP.
2
+ # There is *much* opportunity to clean this up. The mountains of asynchronous
3
+ # handlers make things rather messy.
4
+ # (C)2013 Mike Bourgeous
5
+
6
+ module NLHue
7
+ # TODO: Rewrite this from the ground up in a much simpler fashion. The
8
+ # current complexity allows things like two simultaneous disco
9
+ # processes running and makes things difficult to debug.
10
+
11
+ # Use #start_discovery and #stop_discovery for continuous bridge
12
+ # discovery. Use #send_discovery to perform discovery only once.
13
+ module Disco
14
+ # Number of times a bridge can be missing from discovery if not
15
+ # subscribed (approx. 5*15=1.25min if interval is 15)
16
+ MAX_BRIDGE_AGE = 5
17
+
18
+ # Number of times a bridge can be missing from discovery if
19
+ # subscribed (approximately one month if interval is 15). This
20
+ # number is higher than MAX_BRIDGE_AGE because update failures
21
+ # (see MAX_BRIDGE_ERR) will detect an offline bridge, and
22
+ # working bridges sometimes disappear from discovery.
23
+ MAX_SUBSCRIBED_AGE = 150000
24
+
25
+ # Number of times a bridge can fail to update
26
+ MAX_BRIDGE_ERR = 2
27
+
28
+ # Bridges discovered on the network
29
+ # [serial] => {
30
+ # :bridge => NLHue::Bridge,
31
+ # :age => [# of failed discos],
32
+ # :errcount => [# of failed updates]
33
+ # }
34
+ @@bridges = {}
35
+
36
+ @@disco_interval = 15
37
+ @@disco_timer = nil
38
+ @@disco_proc = nil
39
+ @@disco_done_timer = nil
40
+ @@disco_callbacks = []
41
+ @@bridges_changed = false
42
+ @@disco_running = false
43
+ @@disco_connection = nil
44
+
45
+ # Starts a timer that periodically discovers Hue bridges. If
46
+ # the username is a String or a Hash mapping bridge serial
47
+ # numbers (String or Symbol) to usernames, the Bridge objects'
48
+ # username will be set before trying to update.
49
+ #
50
+ # Using very short intervals may overload Hue bridges, causing
51
+ # light and group commands to be delayed or erratic.
52
+ def self.start_discovery username=nil, interval=15
53
+ raise 'Discovery is already running' if @@disco_timer || @@disco_running
54
+ raise 'Interval must be a number' unless interval.is_a? Numeric
55
+ raise 'Interval must be >= 1' unless interval >= 1
56
+
57
+ unless username.nil? || username.is_a?(String) || username.is_a?(Hash)
58
+ raise 'Username must be nil, a String, or a Hash'
59
+ end
60
+
61
+ @@disco_interval = interval
62
+
63
+ @@disco_proc = proc {
64
+ @@disco_timer = nil
65
+ @@disco_running = true
66
+
67
+ notify_callbacks :start
68
+
69
+ @@bridges.each do |k, v|
70
+ v[:age] += 1
71
+ end
72
+
73
+ reset_disco_timer nil, 5
74
+ @@disco_connection = send_discovery do |br|
75
+ if br.is_a?(NLHue::Bridge) && disco_started?
76
+ if @@bridges.include?(br.serial) && br.subscribed?
77
+ reset_disco_timer br
78
+ else
79
+ u = lookup_username(br, username)
80
+ br.username = u if u
81
+ br.update do |status, result|
82
+ if disco_started?
83
+ reset_disco_timer br
84
+ elsif !@@bridges.include?(br.serial)
85
+ # Ignore bridges if disco was shut down
86
+ br.clean
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ }
93
+
94
+ do_disco
95
+ end
96
+
97
+ # Stops periodic Hue bridge discovery and clears the list of
98
+ # bridges. Preserves disco callbacks.
99
+ def self.stop_discovery
100
+ @@disco_timer.cancel if @@disco_timer
101
+ @@disco_done_timer.cancel if @@disco_done_timer
102
+ @@disco_connection.shutdown if @@disco_connection
103
+
104
+ bridges = @@bridges.clone
105
+ bridges.each do |serial, info|
106
+ puts "Removing bridge #{serial} when stopping discovery" # XXX
107
+ @@bridges.delete serial
108
+ notify_callbacks :del, info[:bridge], 'Stopping Hue Bridge discovery.'
109
+ info[:bridge].clean
110
+ end
111
+ if @@disco_running
112
+ @@disco_running = false
113
+ notify_callbacks :end, !bridges.empty?
114
+ end
115
+
116
+ EM.next_tick do
117
+ @@disco_timer = nil
118
+ @@disco_done_timer = nil
119
+ @@disco_proc = nil
120
+ @@disco_connection = nil
121
+ end
122
+ end
123
+
124
+ # Indicates whether #start_discovery has been called.
125
+ def self.disco_started?
126
+ !!(@@disco_timer || @@disco_running)
127
+ end
128
+
129
+ # Adds the given block to be called with discovery events. The return
130
+ # value may be passed to remove_disco_callback.
131
+ #
132
+ # The callback will be called with the following events:
133
+ # :start - A discovery process has started.
134
+ # :add, bridge - The given bridge was recently discovered.
135
+ # :del, bridge, msg - The given bridge was recently removed
136
+ # because of [msg]. May be called even
137
+ # if no discovery process is running.
138
+ # :end, true/false - A discovery process has ended, and whether there
139
+ # were changes to the list of bridges.
140
+ def self.add_disco_callback cb=nil, &block
141
+ raise 'No callback was given.' unless block_given? || cb
142
+ raise 'Pass only a block or a Proc object, not both.' if block_given? && cb
143
+ @@disco_callbacks << (cb || block)
144
+ block
145
+ end
146
+
147
+ # Removes a discovery callback (call this with the return value from add_disco_callback)
148
+ def self.remove_disco_callback callback
149
+ @@disco_callbacks.delete callback
150
+ end
151
+
152
+ # Triggers a discovery process immediately (fails if #start_discovery
153
+ # has not been called), unless discovery is already in progress.
154
+ def self.do_disco
155
+ raise 'Call start_discovery() first.' unless @@disco_proc
156
+ return if @@disco_running
157
+
158
+ @@disco_timer.cancel if @@disco_timer
159
+ @@disco_proc.call if @@disco_proc
160
+ end
161
+
162
+ # Indicates whether a discovery process is currently running.
163
+ def self.disco_running?
164
+ @@disco_running
165
+ end
166
+
167
+ # Returns an array of the bridges previously discovered on the
168
+ # network.
169
+ def self.bridges
170
+ @@bridges.map do |serial, info|
171
+ info[:bridge]
172
+ end
173
+ end
174
+
175
+ # Returns a bridge with the given serial number, if present.
176
+ def self.get_bridge serial
177
+ serial &&= serial.downcase
178
+ @@bridges[serial] && @@bridges[serial][:bridge]
179
+ end
180
+
181
+ # Gets the number of consecutive times a bridge has been
182
+ # missing from discovery.
183
+ def self.get_missing_count serial
184
+ serial &&= serial.downcase
185
+ @@bridges[serial] && @@bridges[serial][:age]
186
+ end
187
+
188
+ # Gets the number of consecutive times a bridge has failed to
189
+ # update. This value is only updated if the Bridge object's
190
+ # subscribe or update methods are called.
191
+ def self.get_error_count serial
192
+ serial &&= serial.downcase
193
+ @@bridges[serial] && @@bridges[serial][:errcount]
194
+ end
195
+
196
+ # Sends an SSDP discovery request, then yields an NLHue::Bridge
197
+ # object for each Hue hub found on the network. Responses may
198
+ # come for more than timeout seconds after this function is
199
+ # called. Reuses Bridge objects from previously discovered
200
+ # bridges, if any. Returns the connection used by SSDP.
201
+ def self.send_discovery timeout=4, &block
202
+ # Even though we put 'upnp:rootdevice' in the ST: header, the
203
+ # Hue hub sends multiple matching and non-matching responses.
204
+ # We'll use a hash to track which IP addresses we've seen.
205
+ devs = {}
206
+
207
+ con = NLHue::SSDP.discover 'upnp:rootdevice', timeout do |ssdp|
208
+ if ssdp && ssdp['Location'].include?('description.xml') && ssdp['USN']
209
+ serial = ssdp['USN'].gsub(/.*([0-9A-Fa-f]{12}).*/, '\1')
210
+ unless devs.include?(ssdp.ip) && devs[ssdp.ip].serial == serial
211
+ dev = @@bridges.include?(serial) ?
212
+ @@bridges[serial][:bridge] :
213
+ Bridge.new(ssdp.ip, serial)
214
+
215
+ dev.addr = ssdp.ip unless dev.addr == ssdp.ip
216
+
217
+ if dev.verified?
218
+ yield dev
219
+ else
220
+ dev.verify do |result|
221
+ if result && !con.closed?
222
+ devs[ssdp.ip] = dev
223
+ begin
224
+ yield dev
225
+ rescue => e
226
+ log_e e, "Error notifying block with discovered bridge #{serial}"
227
+ end
228
+ end
229
+ end
230
+ end
231
+ end
232
+ end
233
+ end
234
+
235
+ con
236
+ end
237
+
238
+ private
239
+ # Calls each discovery callback with the given parameters.
240
+ def self.notify_callbacks *args
241
+ bench "Notify Hue disco callbacks: #{args[0].inspect}" do
242
+ @@disco_callbacks.each do |cb|
243
+ begin
244
+ cb.call *args
245
+ rescue => e
246
+ log_e e, "Error notifying Hue disco callback #{cb.inspect} about #{args[0]} event."
247
+ end
248
+ end
249
+ end
250
+ end
251
+
252
+ # Adds the given bridge to the list of bridges (or resets the
253
+ # timeout without adding a bridge if the bridge is nil). After
254
+ # timeout seconds, removes aged-out bridges from the internal
255
+ # list of bridges and notifies disco callbacks that discovery
256
+ # has ended. Cancels any previous timeout. This is called
257
+ # each time a new bridge is discovered so that discovery ends
258
+ # when no new bridges come in for timeout seconds.
259
+ def self.reset_disco_timer br, timeout=2
260
+ if br.is_a?(NLHue::Bridge)
261
+ if @@bridges[br.serial]
262
+ @@bridges[br.serial][:age] = 0
263
+ else
264
+ @@bridges[br.serial] = { :bridge => br, :age => 0, :errcount => 0 }
265
+ @@bridges = Hash[@@bridges.sort_by{|serial, info|
266
+ info[:bridge].registered? ? "000#{serial}" : serial
267
+ }]
268
+ @@bridges_changed = true
269
+
270
+ @@bridges[br.serial][:cb] = br.add_update_callback do |status, result|
271
+ if status
272
+ @@bridges[br.serial][:errcount] = 0
273
+ else
274
+ unless result.is_a? NLHue::NotRegisteredError
275
+ @@bridges[br.serial][:errcount] += 1
276
+
277
+ # Remove here instead of with *_AGE because
278
+ # *_AGE is only checked once per disco.
279
+ if @@bridges[br.serial][:errcount] > MAX_BRIDGE_ERR
280
+ info = @@bridges[br.serial]
281
+ @@bridges.delete br.serial
282
+ br.remove_update_callback info[:cb]
283
+ notify_bridge_removed br, 'Error updating bridge.'
284
+ EM.next_tick do
285
+ br.clean # next tick to ensure after notify*removed
286
+ do_disco
287
+ end
288
+ end
289
+ end
290
+ end
291
+ end
292
+
293
+ notify_bridge_added br
294
+ end
295
+ end
296
+
297
+ @@disco_done_timer.cancel if @@disco_done_timer
298
+ @@disco_done_timer = EM::Timer.new(timeout) do
299
+ update_bridges bridges
300
+ @@disco_done_timer = nil
301
+ @@disco_connection = nil
302
+
303
+ EM.next_tick do
304
+ @@disco_running = false
305
+ notify_callbacks :end, @@bridges_changed
306
+ @@disco_timer = EM::Timer.new(@@disco_interval, @@disco_proc) if @@disco_proc
307
+ @@bridges_changed = false
308
+ end
309
+ end
310
+ end
311
+
312
+ # Deletes aged-out bridges, sends disco :del events to
313
+ # callbacks. Called when the timer set by #reset_disco_timer
314
+ # expires.
315
+ def self.update_bridges bridges
316
+ bench 'update_bridges' do
317
+ @@bridges.select! do |k, br|
318
+ age_limit = br[:bridge].subscribed? ? MAX_SUBSCRIBED_AGE : MAX_BRIDGE_AGE
319
+
320
+ if br[:age] > age_limit
321
+ log "Bridge #{br[:bridge].serial} missing from #{br[:age]} rounds of discovery."
322
+ log "Bridge #{br[:bridge].serial} subscribed: #{br[:bridge].subscribed?}"
323
+
324
+ @@bridges_changed = true
325
+ notify_bridge_removed br[:bridge],
326
+ "Bridge missing from discovery #{br[:age]} times."
327
+
328
+ EM.next_tick do
329
+ br[:bridge].clean # next tick to ensure after notify*removed
330
+ end
331
+
332
+ false
333
+ else
334
+ true
335
+ end
336
+ end
337
+ end
338
+ end
339
+
340
+ # Notifies all disco callbacks that a bridge was added.
341
+ def self.notify_bridge_added br
342
+ EM.next_tick do
343
+ notify_callbacks :add, br
344
+ end
345
+ end
346
+
347
+ # Notifies all disco callbacks that a bridge was removed.
348
+ def self.notify_bridge_removed br, msg
349
+ EM.next_tick do
350
+ notify_callbacks :del, br, msg
351
+ end
352
+ end
353
+
354
+ # Finds a username in the given String or Hash of +usernames+
355
+ # that matches the given +bridge+ serial number (String or
356
+ # Symbol). Returns +usernames+ directly if it's a String.
357
+ def self.lookup_username(bridge, usernames)
358
+ return usernames if usernames.is_a?(String)
359
+ return (usernames[bridge.serial] || usernames[bridge.serial.to_sym]) if usernames.is_a?(Hash)
360
+ end
361
+ end
362
+ end
@@ -0,0 +1,79 @@
1
+ # A class representing a group of lights on a Hue bridge.
2
+ # (C)2013-2015 Mike Bourgeous
3
+
4
+ module NLHue
5
+ # A class representing a designated group of lights on a Hue bridge.
6
+ # Recommended use is to call NLHue::Bridge#groups.
7
+ class Group < Target
8
+ # bridge - The Bridge that controls this light.
9
+ # id - The group's ID (integer >= 0).
10
+ # info - The Hash parsed from the JSON description of the group,
11
+ # if available. The group's lights will be unknown until the
12
+ # JSON from the bridge (/api/[username]/groups/[id]) is passed
13
+ # here or to #handle_json.
14
+ def initialize(bridge, id, info=nil)
15
+ @lights ||= Set.new
16
+
17
+ super bridge, id, info, :groups, 'action'
18
+ end
19
+
20
+ # Updates this Group with data parsed from the Hue bridge.
21
+ def handle_json(info)
22
+ super info
23
+
24
+ # A group returns no lights for a short time after creation
25
+ if @info['lights'].is_a?(Array) && !@info['lights'].empty?
26
+ @lights.replace @info['lights'].map(&:to_i)
27
+ end
28
+ end
29
+
30
+ # "Group: [ID]: [name] ([num] lights)"
31
+ def to_s
32
+ "Group: #{@id}: #{@name} (#{@lights.length} lights}"
33
+ end
34
+
35
+ # Returns an array containing this group's corresponding Light
36
+ # objects from the Bridge.
37
+ def lights
38
+ lights = @bridge.lights
39
+ @lights.map{|id| lights[id]}
40
+ end
41
+
42
+ # An array containing the IDs of the lights belonging to this
43
+ # group.
44
+ def light_ids
45
+ @lights.to_a
46
+ end
47
+
48
+ # Returns a Hash containing the group's info and most recently
49
+ # set state, with symbolized key names and hue scaled to 0..360.
50
+ # Example:
51
+ # {
52
+ # :id => 0,
53
+ # :name => 'Lightset 0',
54
+ # :type => 'LightGroup',
55
+ # :lights => [0, 1, 2],
56
+ # :on => false,
57
+ # :bri => 220,
58
+ # :ct => 500,
59
+ # :x => 0.5,
60
+ # :y => 0.5,
61
+ # :hue => 193.5,
62
+ # :sat => 255,
63
+ # :colormode => 'hs'
64
+ # }
65
+ def state
66
+ {lights: light_ids}.merge!(super)
67
+ end
68
+
69
+ # Recalls +scene+, which may be a Scene object or a scene ID
70
+ # String, on this group only. Any raw ID String will not be
71
+ # verified. If NLHue::Target#defer was called, the scene will
72
+ # not be recalled until #submit is called.
73
+ def recall_scene(scene)
74
+ scene = scene.id if scene.is_a?(Scene)
75
+ raise 'Scene ID to recall must be a String' unless scene.is_a?(String)
76
+ set('scene' => scene)
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,20 @@
1
+ # A class representing a light known to a Hue bridge.
2
+ # (C)2013-2015 Mike Bourgeous
3
+
4
+ module NLHue
5
+ # A class representing a light known to a Hue bridge. Recommended use
6
+ # is to get a Light object by calling NLHue::Bridge#lights().
7
+ class Light < Target
8
+ # bridge - The Bridge that controls this light.
9
+ # id - The bulb number.
10
+ # info - Parsed Hash of the JSON light info object from the bridge.
11
+ def initialize(bridge, id, info=nil)
12
+ super bridge, id, info, :lights, 'state'
13
+ end
14
+
15
+ # "Light: [ID]: [name] ([type])"
16
+ def to_s
17
+ "Light: #{@id}: #{@name} (#{@type})"
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,131 @@
1
+ # A class for managing HTTP requests to a host. Only allows one outstanding
2
+ # request to a given category, while allowing requests to unrelated categories.
3
+ # Categories default to path names.
4
+ # (C)2013 Mike Bourgeous
5
+
6
+ require 'eventmachine'
7
+ require 'em/protocols/httpclient'
8
+
9
+ module NLHue
10
+ class RequestQueue
11
+ attr_reader :host
12
+
13
+ # Initializes a request queue with the given host, default
14
+ # request timeout, and default POST/PUT content type.
15
+ def initialize host, timeout=5, content_type='application/json;charset=utf-8'
16
+ @host = host
17
+ @default_type = content_type
18
+ @default_timeout = timeout
19
+ @request_queue = {}
20
+ end
21
+
22
+ # Changes the host name used by requests.
23
+ def host= host
24
+ @host = host
25
+ end
26
+
27
+ # Makes a GET request to the given path, timing out after the
28
+ # given number of seconds, and calling the given block with a
29
+ # hash containing :content, :headers, and :status, or just
30
+ # false if there was an error. The default category is the
31
+ # path.
32
+ def get path, category=nil, timeout=nil, &block
33
+ # FIXME: Use em-http-request instead of HttpClient,
34
+ # which returns an empty :content field for /description.xml
35
+ request 'GET', path, category, nil, nil, timeout, &block
36
+ end
37
+
38
+ # Makes a POST request to the given path, with the given data
39
+ # and content type, timing out after the given number of
40
+ # seconds, and calling the given block with a hash containing
41
+ # :content, :headers, and :status, or just false if there was
42
+ # an error. The default category is the path.
43
+ def post path, data, category=nil, content_type=nil, timeout=nil, &block
44
+ request 'POST', path, category, data, content_type, timeout, &block
45
+ end
46
+
47
+ # Makes a PUT request to the given path, with the given data
48
+ # and content type, timing out after the given number of
49
+ # seconds, and calling the given block with a hash containing
50
+ # :content, :headers, and :status, or just false if there was
51
+ # an error.
52
+ def put path, data, category=nil, content_type=nil, timeout=nil, &block
53
+ request 'PUT', path, category, data, content_type, timeout, &block
54
+ end
55
+
56
+ # Makes a DELETE request to the given path, timing out after
57
+ # the given number of seconds, and calling the given block with
58
+ # a hash containing :content, :headers, and :status, or just
59
+ # false if there was an error.
60
+ def delete path, category=nil, timeout=nil, &block
61
+ request 'DELETE', path, category, nil, nil, timeout, &block
62
+ end
63
+
64
+ # Queues a request of the given type to the given path, using
65
+ # the given data and content type for e.g. PUT and POST. The
66
+ # request will time out after timeout seconds. The given block
67
+ # will be called with a hash containing :content, :headers, and
68
+ # :status if a response was received, or just false on error.
69
+ # This should be called from the EventMachine reactor thread.
70
+ def request verb, path, category=nil, data=nil, content_type=nil, timeout=nil, &block
71
+ raise 'A block must be given.' unless block_given?
72
+ raise 'Call from the EventMachine reactor thread.' unless EM.reactor_thread?
73
+
74
+ category ||= path
75
+ content_type ||= @default_type
76
+ timeout ||= @default_timeout
77
+
78
+ @request_queue[category] ||= []
79
+
80
+ req = [verb, path, category, data, content_type, timeout, block]
81
+ @request_queue[category] << req
82
+ do_next_request category if @request_queue[category].size == 1
83
+ end
84
+
85
+ private
86
+ # Sends a request with the given method/path/etc. Called by
87
+ # #do_next_request. See #request.
88
+ def do_request verb, path, category, data, content_type, timeout, &block
89
+ # FIXME: Use em-http-request
90
+ req = EM::P::HttpClient.request(
91
+ verb: verb,
92
+ host: @host,
93
+ request: path,
94
+ content: data,
95
+ contenttype: content_type,
96
+ )
97
+ req.callback {|response|
98
+ begin
99
+ yield response
100
+ rescue => e
101
+ log_e e, "Error calling a Hue bridge's request callback for #{category}."
102
+ end
103
+
104
+ @request_queue[category].shift
105
+ do_next_request category
106
+ }
107
+ req.errback {
108
+ req.close_connection # For timeout
109
+ begin
110
+ yield false
111
+ rescue => e
112
+ log_e e, "Error calling a Hue bridge's request callback with error for #{category}."
113
+ end
114
+
115
+ @request_queue[category].shift
116
+ do_next_request category
117
+ }
118
+ req.timeout timeout
119
+ end
120
+
121
+ # Shifts a request off the request queue (if it is not empty),
122
+ # then passes it to #do_request. See #request.
123
+ def do_next_request category
124
+ unless @request_queue[category].empty?
125
+ req = @request_queue[category].first
126
+ block = req.pop
127
+ do_request *req, &block
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,88 @@
1
+ # A class representing a preset scene on a Hue bridge.
2
+ # (C)2015 Mike Bourgeous
3
+
4
+ module NLHue
5
+ # Represents a preset scene on a Hue bridge, with a list of included
6
+ # lights. The actual values recalled by the scene are not available.
7
+ # TODO: Support creating new scenes
8
+ class Scene
9
+ attr_reader :id, :name, :bridge
10
+
11
+ # bridge - The Bridge that contains this scene.
12
+ # id - The scene's ID (a String).
13
+ # info - A Hash with scene info from the bridge.
14
+ def initialize(bridge, id, info)
15
+ @bridge = bridge
16
+ @id = id
17
+ @info = info
18
+ @lights = Set.new
19
+
20
+ handle_json(info)
21
+ end
22
+
23
+ # Updates this scene with any changes from the bridge.
24
+ def handle_json(info)
25
+ raise "Scene info must be a Hash" unless info.is_a?(Hash)
26
+
27
+ info['id'] = @id
28
+
29
+ @name = info['name'] || @name
30
+
31
+ if info['lights'].is_a?(Array) && !info['lights'].empty?
32
+ @lights.replace info['lights'].map(&:to_i)
33
+ end
34
+
35
+ @info = info
36
+ end
37
+
38
+ # Returns a copy of the last received info from the bridge, plus
39
+ # the scene's ID.
40
+ def to_h
41
+ @info.clone
42
+ end
43
+
44
+ # Returns a description of this scene.
45
+ def to_s
46
+ "Scene #{@id}: #{@name} (#{@lights.count} lights)"
47
+ end
48
+
49
+ # Returns a Hash containing basic information about the scene.
50
+ def state
51
+ {
52
+ id: @id,
53
+ name: @name,
54
+ lights: light_ids,
55
+ }
56
+ end
57
+
58
+ # Converts the Hash returned by #state to JSON.
59
+ def to_json(*args)
60
+ state.to_json(*args)
61
+ end
62
+
63
+ # Returns an array containing this scene's corresponding Light
64
+ # objects from the Bridge.
65
+ def lights
66
+ lights = @bridge.lights
67
+ @lights.map{|id| lights[id]}
68
+ end
69
+
70
+ # An array containing the IDs of the lights belonging to this
71
+ # scene.
72
+ def light_ids
73
+ @lights.to_a
74
+ end
75
+
76
+ # Recalls this scene on the next RequestQueue tick using group 0
77
+ # (all lights). The block, if given, will be called after the
78
+ # scene is recalled.
79
+ def recall(&block)
80
+ @bridge.add_target self, &block
81
+ end
82
+
83
+ # Recalls this scene immediately using NLHue::Bridge#put_api.
84
+ def send_changes(&block)
85
+ @bridge.put_api '/groups/0/action', {scene: @id}.to_json, :groups, &block
86
+ end
87
+ end
88
+ end