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