nlhue 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +11 -0
- data/.travis.yml +4 -0
- data/Gemfile +4 -0
- data/LICENSE +22 -0
- data/README.md +91 -0
- data/Rakefile +1 -0
- data/bin/console +12 -0
- data/bin/setup +7 -0
- data/lib/nlhue/bridge.rb +830 -0
- data/lib/nlhue/disco.rb +362 -0
- data/lib/nlhue/group.rb +79 -0
- data/lib/nlhue/light.rb +20 -0
- data/lib/nlhue/request_queue.rb +131 -0
- data/lib/nlhue/scene.rb +88 -0
- data/lib/nlhue/ssdp.rb +106 -0
- data/lib/nlhue/target.rb +411 -0
- data/lib/nlhue/version.rb +3 -0
- data/lib/nlhue.rb +36 -0
- data/nlhue.gemspec +28 -0
- metadata +148 -0
data/lib/nlhue/disco.rb
ADDED
@@ -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
|
data/lib/nlhue/group.rb
ADDED
@@ -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
|
data/lib/nlhue/light.rb
ADDED
@@ -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
|
data/lib/nlhue/scene.rb
ADDED
@@ -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
|