nlhue 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +11 -0
- data/.travis.yml +4 -0
- data/Gemfile +4 -0
- data/LICENSE +22 -0
- data/README.md +91 -0
- data/Rakefile +1 -0
- data/bin/console +12 -0
- data/bin/setup +7 -0
- data/lib/nlhue/bridge.rb +830 -0
- data/lib/nlhue/disco.rb +362 -0
- data/lib/nlhue/group.rb +79 -0
- data/lib/nlhue/light.rb +20 -0
- data/lib/nlhue/request_queue.rb +131 -0
- data/lib/nlhue/scene.rb +88 -0
- data/lib/nlhue/ssdp.rb +106 -0
- data/lib/nlhue/target.rb +411 -0
- data/lib/nlhue/version.rb +3 -0
- data/lib/nlhue.rb +36 -0
- data/nlhue.gemspec +28 -0
- metadata +148 -0
data/lib/nlhue/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
|