nlhue 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
data/lib/nlhue/ssdp.rb ADDED
@@ -0,0 +1,106 @@
1
+ # Barebones asynchronous SSDP device discovery using EventMachine.
2
+ # Part of Nitrogen Logic's Ruby interface library for the Philips Hue.
3
+ # (C)2012 Mike Bourgeous
4
+
5
+ require 'socket'
6
+ require 'eventmachine'
7
+
8
+ module NLHue
9
+ module SSDP
10
+ SSDP_ADDR = '239.255.255.250'
11
+ SSDP_PORT = 1900
12
+
13
+ # TODO: Support passive discovery of other devices' announcements
14
+
15
+ # Eventually calls the given block with a NLHue::SSDP::Response
16
+ # for each matching device found on the network within timeout
17
+ # seconds. The block will be called with nil after the
18
+ # timeout. Returns the connection used for discovery; call
19
+ # #shutdown on the returned object to abort discovery.
20
+ def self.discover type='ssdp:all', timeout=5, &block
21
+ raise 'A block must be given to discover().' unless block_given?
22
+
23
+ con = EM::open_datagram_socket('0.0.0.0', 0, SSDPConnection, type, timeout, block)
24
+ EM.add_timer(timeout) do
25
+ con.close_connection
26
+ EM.next_tick do
27
+ yield nil
28
+ end
29
+ end
30
+
31
+ # TODO: Structure this using EM::Deferrable instead?
32
+ con
33
+ end
34
+
35
+ # The HTTP response representing a service discovered by SSDP.
36
+ class Response
37
+ attr_reader :ip, :response, :headers
38
+
39
+ def initialize ip, response
40
+ @ip = ip
41
+ @response = response
42
+ @headers = {}
43
+
44
+ response.split(/\r?\n\r?\n/, 2)[0].lines.each do |line|
45
+ if line.include? ':'
46
+ key, value = line.split(/: ?/, 2)
47
+ @headers[key.downcase] = value.strip
48
+ end
49
+ end
50
+ end
51
+
52
+ def to_s
53
+ "#{@ip}:\n\t#{@response.lines.to_a.join("\t")}"
54
+ end
55
+
56
+ # Retrieves the value of a header, with case
57
+ # insensitive matching.
58
+ def [] header
59
+ @headers[header.downcase]
60
+ end
61
+ end
62
+
63
+ private
64
+ # UDP connection used for SSDP by discover().
65
+ class SSDPConnection < EM::Connection
66
+ # type - the SSDP service type (used in the ST: field)
67
+ # timeout - the number of seconds to wait for responses (used in the MX: field)
68
+ # receiver is the block passed to discover().
69
+ def initialize type, timeout, receiver
70
+ super
71
+ @type = type
72
+ @timeout = timeout
73
+ @receiver = receiver
74
+ @msg = "M-SEARCH * HTTP/1.1\r\n" +
75
+ "HOST: #{SSDP_ADDR}:#{SSDP_PORT}\r\n" +
76
+ "MAN: ssdp:discover\r\n" +
77
+ "MX: #{timeout.to_i}\r\n" +
78
+ "ST: #{type}\r\n" +
79
+ "\r\n"
80
+ end
81
+
82
+ def post_init
83
+ send_datagram @msg, SSDP_ADDR, SSDP_PORT
84
+ EM.add_timer(0.5) do
85
+ send_datagram @msg, SSDP_ADDR, SSDP_PORT
86
+ end
87
+ end
88
+
89
+ def receive_data data
90
+ port, ip = Socket.unpack_sockaddr_in(get_peername)
91
+ @receiver.call Response.new(ip, data) if @receiver
92
+ end
93
+
94
+ # Closes the UDP socket and ignores any future SSDP messages.
95
+ def shutdown
96
+ @receiver = nil
97
+ close_connection
98
+ end
99
+
100
+ # Indicates whether shutdown has been called.
101
+ def closed?
102
+ @receiver.nil?
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,411 @@
1
+ # Base class representing a light or group known to a Hue bridge.
2
+ # (C)2015 Mike Bourgeous
3
+
4
+ module NLHue
5
+ # Base class representing a light or group known to a Hue bridge. See
6
+ # NLHue::Light and NLHue::Group.
7
+ class Target
8
+ attr_reader :id, :type, :name, :bridge, :transitiontime
9
+
10
+ # bridge - The Bridge that controls this light or group.
11
+ # id - The light or group's ID (>=0 for groups, >=1 for lights).
12
+ # info - Parsed Hash of the JSON light or group info object from the bridge.
13
+ # api_category - The category to pass to the bridge for rate limiting API
14
+ # requests. Also forms part of the API URL.
15
+ # api_target - @api_target for lights, @api_target for groups
16
+ def initialize(bridge, id, info, api_category, api_target)
17
+ @bridge = bridge
18
+ @id = id.to_i
19
+ @api_category = api_category
20
+ @api_target = api_target
21
+
22
+ @changes = Set.new
23
+ @defer = false
24
+
25
+ @info = {api_target => {}}
26
+ handle_json(info || {})
27
+ end
28
+
29
+ # Updates this light or group object using a Hash parsed from
30
+ # the JSON info from the Hue bridge.
31
+ def handle_json(info)
32
+ raise "Light/group info must be a Hash, not #{info.class}." unless info.is_a?(Hash)
33
+
34
+ # A group contains no 'xy' for a short time after creation.
35
+ # Add fake xy color for lamps that don't support color.
36
+ info[@api_target] = {} unless info[@api_target].is_a?(Hash)
37
+ info[@api_target]['xy'] ||= [0.33333, 0.33333]
38
+
39
+ info['id'] = @id
40
+
41
+ # Preserve deferred changes that have not yet been sent to the bridge
42
+ @changes.each do |key|
43
+ info[@api_target][key] = @info[@api_target][key]
44
+ end
45
+
46
+ @info = info
47
+ @type = @info['type']
48
+ @name = @info['name'] || @name || "Lightset #{@id}"
49
+ end
50
+
51
+ # Gets the current state of this light or group from the
52
+ # bridge. The block, if given, will be called with true and
53
+ # the response on success, or false and an Exception on error.
54
+ def update(&block)
55
+ tx = rand
56
+ @bridge.get_api "/#{@api_category}/#{@id}", @api_category do |response|
57
+ puts "#{tx} Target #{@id} update response: #{response}" # XXX
58
+
59
+ begin
60
+ status, result = @bridge.check_json(response)
61
+ handle_json(result) if status
62
+ rescue => e
63
+ status = false
64
+ result = e
65
+ end
66
+
67
+ yield status, result if block_given?
68
+ end
69
+ end
70
+
71
+ # Returns a copy of the hash representing the light or group's
72
+ # state as parsed from the JSON returned by the bridge, without
73
+ # any range scaling (e.g. so hue range is 0..65535).
74
+ def to_h
75
+ @info.clone
76
+ end
77
+
78
+ # Converts the Hash returned by #state to JSON.
79
+ def to_json(*args)
80
+ state.to_json(*args)
81
+ end
82
+
83
+ # Call to queue changes to be sent all at once. Updates will
84
+ # not be sent to the light or group until #submit is called.
85
+ # Call #nodefer to stop deferring changes.
86
+ def defer
87
+ @defer = true
88
+ end
89
+
90
+ # Stops deferring changes and sends any queued changes
91
+ # immediately.
92
+ def nodefer
93
+ @defer = false
94
+ set {}
95
+ end
96
+
97
+ # Sets the transition time in centiseconds used for the next
98
+ # immediate parameter change or deferred batch parameter
99
+ # change. The transition time will be reset when send_changes
100
+ # is called. Call with nil to clear the transition time.
101
+ def transitiontime=(time)
102
+ if time.nil?
103
+ @transitiontime = nil
104
+ else
105
+ time = 0 if time < 0
106
+ @transitiontime = time.to_i
107
+ end
108
+ end
109
+
110
+ # Tells the Bridge object that this Light or Group is ready to
111
+ # have its deferred data sent. The NLHue::Bridge will schedule
112
+ # a rate-limited call to #send_changes, which sends all changes
113
+ # queued since the last call to defer. The block, if given,
114
+ # will be called with true and the response on success, or
115
+ # false and an Exception on error. The transition time sent to
116
+ # the bridge can be controlled with transitiontime=. If no
117
+ # transition time is set, the default transition time will be
118
+ # used by the bridge.
119
+ def submit(&block)
120
+ puts "Submitting changes to #{self}" # XXX
121
+ @bridge.add_target self, &block
122
+ end
123
+
124
+ # Returns a Hash containing the light's info and current state,
125
+ # with symbolized key names and hue scaled to 0..360. Example:
126
+ # {
127
+ # :id => 1,
128
+ # :name => 'Hue Lamp 2',
129
+ # :type => 'Extended color light',
130
+ # :on => false,
131
+ # :bri => 220,
132
+ # :ct => 500,
133
+ # :x => 0.5,
134
+ # :y => 0.5,
135
+ # :hue => 193.5,
136
+ # :sat => 255,
137
+ # :colormode => 'hs'
138
+ # }
139
+ def state
140
+ {
141
+ :id => id,
142
+ :name => name,
143
+ :type => type,
144
+ :on => on?,
145
+ :bri => bri,
146
+ :ct => ct,
147
+ :x => x,
148
+ :y => y,
149
+ :hue => hue,
150
+ :sat => sat,
151
+ :colormode => colormode
152
+ }
153
+ end
154
+
155
+ # Tells the light or group to flash once if repeat is false, or
156
+ # several times if repeat is true. Sets the 'alert' property.
157
+ def alert!(repeat=false)
158
+ set({ 'alert' => repeat ? 'select' : 'lselect' })
159
+ end
160
+
161
+ # Stops any existing flashing of the light or group.
162
+ def clear_alert
163
+ set({ 'alert' => 'none' })
164
+ end
165
+
166
+ # Sets the light or group's alert status to the given string
167
+ # (one of 'select' (flash once), 'lselect' (flash several
168
+ # times), or 'none' (stop flashing)). Any other value may
169
+ # result in an error from the bridge.
170
+ def alert=(alert)
171
+ set({ 'alert' => alert })
172
+ end
173
+
174
+ # Returns the current alert state of the light or group (or the
175
+ # stored state if defer() was called, but send() has not yet
176
+ # been called). Groups are not updated when their constituent
177
+ # lights are changed individually.
178
+ def alert
179
+ (@info[@api_target] || @info[@api_target])['alert']
180
+ end
181
+
182
+ # Sets the on/off state of this light or group (true or false).
183
+ # Lights must be on before other parameters can be changed.
184
+ def on=(on)
185
+ set({ 'on' => !!on })
186
+ end
187
+
188
+ # The light state most recently set with on=, #on! or #off!, or
189
+ # the last light state received from the bridge due to calling
190
+ # #update on the light/group or on the NLHue::Bridge.
191
+ def on?
192
+ @info[@api_target]['on']
193
+ end
194
+
195
+ # Turns the light or group on.
196
+ def on!
197
+ self.on = true
198
+ end
199
+
200
+ # Turns the light or group off.
201
+ def off!
202
+ self.on = false
203
+ end
204
+
205
+ # Sets the brightness of this light or group (0-255 inclusive).
206
+ # Note that a brightness of 0 is not off. The light(s) must
207
+ # already be switched on for this to work, if not deferred.
208
+ def bri=(bri)
209
+ bri = 0 if bri < 0
210
+ bri = 255 if bri > 255
211
+
212
+ set({ 'bri' => bri.to_i })
213
+ end
214
+
215
+ # The brightness most recently set with #bri=, or the last
216
+ # brightness received from the light or group due to calling
217
+ # #update on the target or on the bridge.
218
+ def bri
219
+ # TODO: Field storing @api_target or @api_target
220
+ @info[@api_target]['bri'].to_i
221
+ end
222
+
223
+ # Switches the light or group into color temperature mode and
224
+ # sets the color temperature of the light in mireds (154-500
225
+ # inclusive, where 154 is highest temperature (bluer), 500 is
226
+ # lowest temperature (yellower)). The light(s) must be on for
227
+ # this to work, if not deferred.
228
+ def ct=(ct)
229
+ ct = 154 if ct < 154
230
+ ct = 500 if ct > 500
231
+
232
+ set({ 'ct' => ct.to_i, 'colormode' => 'ct' })
233
+ end
234
+
235
+ # The color temperature most recently set with ct=, or the last
236
+ # color temperature received from the light due to calling
237
+ # #update on the light or on the bridge.
238
+ def ct
239
+ @info[@api_target]['ct'].to_i
240
+ end
241
+
242
+ # Switches the light or group into CIE XYZ color mode and sets
243
+ # the X color coordinate to the given floating point value
244
+ # between 0 and 1, inclusive. Lights must be on for this to
245
+ # work.
246
+ def x=(x)
247
+ self.xy = [ x, @info[@api_target]['xy'][1] ]
248
+ end
249
+
250
+ # The X color coordinate most recently set with #x= or #xy=, or
251
+ # the last X color coordinate received from the light or group.
252
+ def x
253
+ @info[@api_target]['xy'][0].to_f
254
+ end
255
+
256
+ # Switches the light or group into CIE XYZ color mode and sets
257
+ # the Y color coordinate to the given floating point value
258
+ # between 0 and 1, inclusive. Lights must be on for this to
259
+ # work.
260
+ def y=(y)
261
+ self.xy = [ @info[@api_target]['xy'][0], y ]
262
+ end
263
+
264
+ # The Y color coordinate most recently set with #y= or #xy=, or
265
+ # the last Y color coordinate received from the light or group.
266
+ def y
267
+ @info[@api_target]['xy'][1].to_f
268
+ end
269
+
270
+ # Switches the light or group into CIE XYZ color mode and sets
271
+ # the XY color coordinates to the given two-element array of
272
+ # floating point values between 0 and 1, inclusive. Lights
273
+ # must be on for this to work, if not deferred.
274
+ def xy=(xy)
275
+ unless xy.is_a?(Array) && xy.length == 2 && xy[0].is_a?(Numeric) && xy[1].is_a?(Numeric)
276
+ raise 'Pass a two-element array of numbers to xy=.'
277
+ end
278
+
279
+ xy[0] = 0 if xy[0] < 0
280
+ xy[0] = 1 if xy[0] > 1
281
+ xy[1] = 0 if xy[1] < 0
282
+ xy[1] = 1 if xy[1] > 1
283
+
284
+ set({ 'xy' => xy, 'colormode' => 'xy' })
285
+ end
286
+
287
+ # The XY color coordinates most recently set with #x=, #y=, or
288
+ # #xy=, or the last color coordinates received from the light
289
+ # or group due to calling #update on the target or the bridge.
290
+ def xy
291
+ xy = @info[@api_target]['xy']
292
+ [ xy[0].to_f, xy[1].to_f ]
293
+ end
294
+
295
+ # Switches the light or group into hue/saturation mode and sets
296
+ # the hue to the given value (floating point degrees, wrapped
297
+ # to 0-360). The light(s) must already be on for this to work.
298
+ def hue=(hue)
299
+ hue = (hue * 65536 / 360).to_i & 65535
300
+ set({ 'hue' => hue, 'colormode' => 'hs' })
301
+ end
302
+
303
+ # The hue most recently set with #hue=, or the last hue
304
+ # received from the light or group due to calling #update on
305
+ # the target or on the bridge.
306
+ def hue
307
+ @info[@api_target]['hue'].to_i * 360 / 65536.0
308
+ end
309
+
310
+ # Switches the light into hue/saturation mode and sets the
311
+ # light's saturation to the given value (0-255 inclusive).
312
+ def sat=(sat)
313
+ sat = 0 if sat < 0
314
+ sat = 255 if sat > 255
315
+
316
+ set({ 'sat' => sat.to_i, 'colormode' => 'hs' })
317
+ end
318
+
319
+ # The saturation most recently set with #saturation=, or the
320
+ # last saturation received from the light due to calling
321
+ # #update on the light or on the bridge.
322
+ def sat
323
+ @info[@api_target]['sat'].to_i
324
+ end
325
+
326
+ # Sets the light or group's special effect mode (either 'none'
327
+ # or 'colorloop').
328
+ def effect= effect
329
+ effect = 'none' unless effect == 'colorloop'
330
+ set({ 'effect' => effect })
331
+ end
332
+
333
+ # The light or group's last set special effect mode.
334
+ def effect
335
+ @info[@api_target]['effect']
336
+ end
337
+
338
+ # Returns the light or group's current or last set color mode
339
+ # ('ct' for color temperature, 'hs' for hue/saturation, 'xy'
340
+ # for CIE XYZ).
341
+ def colormode
342
+ @info[@api_target]['colormode']
343
+ end
344
+
345
+ # Sends parameters named in @changes to the bridge. The block,
346
+ # if given, will be called with true and the response, or false
347
+ # and an Exception. This should only be called internally or
348
+ # by the NLHue::Bridge.
349
+ def send_changes(&block)
350
+ msg = {}
351
+
352
+ @changes.each do |param|
353
+ case param
354
+ when 'colormode'
355
+ case @info[@api_target]['colormode']
356
+ when 'hs'
357
+ msg['hue'] = @info[@api_target]['hue'] if @changes.include? 'hue'
358
+ msg['sat'] = @info[@api_target]['sat'] if @changes.include? 'sat'
359
+ when 'xy'
360
+ msg['xy'] = @info[@api_target]['xy']
361
+ when 'ct'
362
+ msg['ct'] = @info[@api_target]['ct']
363
+ end
364
+
365
+ when 'bri', 'on', 'alert', 'effect', 'scene'
366
+ msg[param] = @info[@api_target][param]
367
+ end
368
+ end
369
+
370
+ msg['transitiontime'] = @transitiontime if @transitiontime
371
+
372
+ put_target(msg) do |status, result|
373
+ rmsg = result.to_s
374
+ # TODO: Parse individual parameters' error messages? Example:
375
+ # [{"error":{"type":6,"address":"/lights/2/state/zfhue","description":"parameter, zfhue, not available"}},{"success":{"/lights/2/state/transitiontime":0}}]
376
+ @changes.delete('alert') if rmsg.include? 'Device is set to off'
377
+ @changes.clear if status || rmsg =~ /(invalid value|not available)/
378
+ yield status, result if block_given?
379
+ end
380
+
381
+ @transitiontime = nil
382
+ end
383
+
384
+ private
385
+ # Sets one or more parameters on the local light, then sends
386
+ # them to the bridge (unless defer was called).
387
+ def set(params)
388
+ params.each do |k, v|
389
+ @changes << k
390
+ @info[@api_target][k] = v
391
+ end
392
+
393
+ send_changes unless @defer
394
+ end
395
+
396
+ # PUTs the given Hash or Array, converted to JSON, to this
397
+ # light or group's API endpoint. The given block will be
398
+ # called as described for NLHue::Bridge#put_api().
399
+ def put_target(msg, &block)
400
+ unless msg.is_a?(Hash) || msg.is_a?(Array)
401
+ raise "Message to PUT must be a Hash or an Array, not #{msg.class.inspect}."
402
+ end
403
+
404
+ api_path = "/#{@api_category}/#{@id}/#{@api_target}"
405
+ @bridge.put_api api_path, msg.to_json, @api_category do |response|
406
+ status, result = @bridge.check_json response
407
+ yield status, result if block_given?
408
+ end
409
+ end
410
+ end
411
+ end
@@ -0,0 +1,3 @@
1
+ module NLHue
2
+ VERSION = "0.1.1"
3
+ end
data/lib/nlhue.rb ADDED
@@ -0,0 +1,36 @@
1
+ # Nitrogen Logic's Ruby interface library for the Philips Hue.
2
+ # (C)2013 Mike Bourgeous
3
+
4
+ # Dummy benchmarking method that may be overridden by library users.
5
+ unless private_methods.include?(:bench)
6
+ def bench label, *args, &block
7
+ yield
8
+ end
9
+ end
10
+
11
+ # Logging method that may be overridden by library users.
12
+ unless private_methods.include?(:log)
13
+ def log msg
14
+ puts msg
15
+ end
16
+ end
17
+
18
+ # Exception logging method that may be overridden by library users.
19
+ unless private_methods.include?(:log_e)
20
+ def log_e e, msg=nil
21
+ e ||= StandardError.new('No exception given to log')
22
+ if msg
23
+ puts "#{msg}: #{e}", e.backtrace
24
+ else
25
+ puts e, e.backtrace
26
+ end
27
+ end
28
+ end
29
+
30
+ require_relative 'nlhue/ssdp'
31
+ require_relative 'nlhue/disco'
32
+ require_relative 'nlhue/bridge'
33
+ require_relative 'nlhue/target'
34
+ require_relative 'nlhue/light'
35
+ require_relative 'nlhue/group'
36
+ require_relative 'nlhue/scene'
data/nlhue.gemspec ADDED
@@ -0,0 +1,28 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'nlhue/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "nlhue"
8
+ spec.version = NLHue::VERSION
9
+ spec.authors = ["Mike Bourgeous"]
10
+ spec.email = ["mike@nitrogenlogic.com"]
11
+
12
+ spec.summary = %q{An EventMachine-based library for interfacing with the Philips Hue lighting system.}
13
+ spec.description = spec.summary
14
+ spec.homepage = "https://github.com/nitrogenlogic/nlhue"
15
+
16
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
17
+ spec.bindir = "exe"
18
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_development_dependency "bundler", "~> 1.10"
22
+ spec.add_development_dependency "rake", "~> 10.0"
23
+ spec.add_development_dependency 'pry'
24
+ spec.add_development_dependency 'pry-byebug'
25
+
26
+ spec.add_runtime_dependency 'eventmachine', '~> 1.0'
27
+ spec.add_runtime_dependency 'UPnP', '~> 1.2'
28
+ end