lifx 0.0.1 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +1 -0
  3. data/Gemfile +10 -0
  4. data/LICENSE.txt +1 -1
  5. data/README.md +71 -13
  6. data/Rakefile +12 -0
  7. data/bin/lifx-console +15 -0
  8. data/bin/lifx-snoop +50 -0
  9. data/examples/auto-off/Gemfile +3 -0
  10. data/examples/auto-off/auto-off.rb +35 -0
  11. data/examples/identify/Gemfile +3 -0
  12. data/examples/identify/identify.rb +70 -0
  13. data/examples/travis-build-light/Gemfile +4 -0
  14. data/examples/travis-build-light/build-light.rb +57 -0
  15. data/lib/bindata_ext/bool.rb +29 -0
  16. data/lib/bindata_ext/record.rb +11 -0
  17. data/lib/lifx/client.rb +136 -0
  18. data/lib/lifx/color.rb +190 -0
  19. data/lib/lifx/config.rb +12 -0
  20. data/lib/lifx/firmware.rb +55 -0
  21. data/lib/lifx/gateway_connection.rb +177 -0
  22. data/lib/lifx/light.rb +406 -0
  23. data/lib/lifx/light_collection.rb +105 -0
  24. data/lib/lifx/light_target.rb +189 -0
  25. data/lib/lifx/logging.rb +11 -0
  26. data/lib/lifx/message.rb +166 -0
  27. data/lib/lifx/network_context.rb +200 -0
  28. data/lib/lifx/observable.rb +46 -0
  29. data/lib/lifx/protocol/address.rb +21 -0
  30. data/lib/lifx/protocol/device.rb +225 -0
  31. data/lib/lifx/protocol/header.rb +24 -0
  32. data/lib/lifx/protocol/light.rb +110 -0
  33. data/lib/lifx/protocol/message.rb +17 -0
  34. data/lib/lifx/protocol/metadata.rb +21 -0
  35. data/lib/lifx/protocol/payload.rb +7 -0
  36. data/lib/lifx/protocol/sensor.rb +29 -0
  37. data/lib/lifx/protocol/type.rb +134 -0
  38. data/lib/lifx/protocol/wan.rb +50 -0
  39. data/lib/lifx/protocol/wifi.rb +76 -0
  40. data/lib/lifx/protocol_path.rb +84 -0
  41. data/lib/lifx/routing_manager.rb +110 -0
  42. data/lib/lifx/routing_table.rb +33 -0
  43. data/lib/lifx/seen.rb +15 -0
  44. data/lib/lifx/site.rb +89 -0
  45. data/lib/lifx/tag_manager.rb +105 -0
  46. data/lib/lifx/tag_table.rb +47 -0
  47. data/lib/lifx/target.rb +23 -0
  48. data/lib/lifx/timers.rb +18 -0
  49. data/lib/lifx/transport/tcp.rb +81 -0
  50. data/lib/lifx/transport/udp.rb +67 -0
  51. data/lib/lifx/transport.rb +41 -0
  52. data/lib/lifx/transport_manager/lan.rb +140 -0
  53. data/lib/lifx/transport_manager.rb +34 -0
  54. data/lib/lifx/utilities.rb +33 -0
  55. data/lib/lifx/version.rb +1 -1
  56. data/lib/lifx.rb +15 -1
  57. data/lifx.gemspec +11 -7
  58. data/spec/color_spec.rb +45 -0
  59. data/spec/gateway_connection_spec.rb +32 -0
  60. data/spec/integration/client_spec.rb +40 -0
  61. data/spec/integration/light_spec.rb +43 -0
  62. data/spec/integration/tags_spec.rb +31 -0
  63. data/spec/message_spec.rb +163 -0
  64. data/spec/protocol_path_spec.rb +109 -0
  65. data/spec/routing_manager_spec.rb +22 -0
  66. data/spec/spec_helper.rb +52 -0
  67. data/spec/transport/udp_spec.rb +38 -0
  68. data/spec/transport_spec.rb +14 -0
  69. metadata +143 -26
data/lib/lifx/color.rb ADDED
@@ -0,0 +1,190 @@
1
+ module LIFX
2
+ module Colors
3
+ DEFAULT_KELVIN = 3500
4
+
5
+ {
6
+ red: 0,
7
+ orange: 36,
8
+ yellow: 60,
9
+ green: 120,
10
+ cyan: 195,
11
+ blue: 250,
12
+ purple: 280,
13
+ pink: 325
14
+ }.each do |color, hue|
15
+ define_method(color) do |saturation: 1.0, brightness: 1.0, kelvin: DEFAULT_KELVIN|
16
+ Color.new(hue, saturation, brightness, kelvin)
17
+ end
18
+ end
19
+
20
+ # Helper to create a white {Color}
21
+ # @param brightness: [Float] Valid range: `0..1`
22
+ # @param kelvin: [Integer] Valid range: `2500..10000`
23
+ # @return [Color]
24
+ def white(brightness: 1.0, kelvin: DEFAULT_KELVIN)
25
+ Color.new(0, 0, brightness, kelvin)
26
+ end
27
+
28
+ # Helper to create a random {Color}
29
+ def random_color(hue: rand(360), saturation: rand, brightness: rand, kelvin: DEFAULT_KELVIN)
30
+ Color.new(hue, saturation, brightness, kelvin)
31
+ end
32
+ end
33
+
34
+ # LIFX::Color represents a color intervally by HSBK (Hue, Saturation, Brightness/Value, Kelvin).
35
+ # It has methods to construct a LIFX::Color instance from various color representations.
36
+ class Color < Struct.new(:hue, :saturation, :brightness, :kelvin)
37
+ extend Colors
38
+ UINT16_MAX = 65535
39
+ KELVIN_MIN = 2500
40
+ KELVIN_MAX = 10000
41
+
42
+ class << self
43
+ # Helper method to create from HSB/HSV
44
+ # @param hue [Float] Valid range: `0..360`
45
+ # @param saturation [Float] Valid range: `0..1`
46
+ # @param brightness [Float] Valid range: `0..1`
47
+ # @return [Color]
48
+ def hsb(hue, saturation, brightness)
49
+ new(hue, saturation, brightness, DEFAULT_KELVIN)
50
+ end
51
+ alias_method :hsv, :hsb
52
+
53
+ # Helper method to create from HSBK/HSVK
54
+ # @param hue [Float] Valid range: `0..360`
55
+ # @param saturation [Float] Valid range: `0..1`
56
+ # @param brightness [Float] Valid range: `0..1`
57
+ # @param kelvin [Integer] Valid range: `2500..10000`
58
+ # @return [Color]
59
+ def hsbk(hue, saturation, brightness, kelvin)
60
+ new(hue, saturation, brightness, kelvin)
61
+ end
62
+
63
+ # Helper method to create from HSL
64
+ # @param hue [Float] Valid range: `0..360`
65
+ # @param saturation [Float] Valid range: `0..1`
66
+ # @param luminance [Float] Valid range: `0..1`
67
+ # @return [Color]
68
+ def hsl(hue, saturation, luminance)
69
+ # From: http://ariya.blogspot.com.au/2008/07/converting-between-hsl-and-hsv.html
70
+ l = luminance * 2
71
+ saturation *= (l <= 1) ? l : 2 - l
72
+ brightness = (l + saturation) / 2
73
+ saturation = (2 * saturation) / (l + saturation)
74
+ new(hue, saturation, brightness, DEFAULT_KELVIN)
75
+ end
76
+
77
+ # Helper method to create from RGB.
78
+ # @note RGB is not the recommended way to create colors
79
+ # @param r [Integer] Red. Valid range: `0..255`
80
+ # @param g [Integer] Green. Valid range: `0..255`
81
+ # @param b [Integer] Blue. Valid range: `0..255`
82
+ # @return [Color]
83
+ def rgb(r, g, b)
84
+ r = r / 255.0
85
+ g = g / 255.0
86
+ b = b / 255.0
87
+
88
+ max = [r, g, b].max
89
+ min = [r, g, b].min
90
+
91
+ h = s = v = max
92
+ d = max - min
93
+ s = max.zero? ? 0 : d / max
94
+
95
+ if max == min
96
+ h = 0
97
+ else
98
+ case max
99
+ when r
100
+ h = (g - b) / d + (g < b ? 6 : 0)
101
+ when g
102
+ h = (b - r) / d + 2
103
+ when b
104
+ h = (r - g) / d + 4
105
+ end
106
+ h = h * 60
107
+ end
108
+
109
+ new(h, s, v, DEFAULT_KELVIN)
110
+ end
111
+
112
+ # Creates an instance from a {Protocol::Light::Hsbk} struct
113
+ # @api private
114
+ # @param hsbk [Protocol::Light::Hsbk]
115
+ # @return [Color]
116
+ def from_struct(hsbk)
117
+ new(
118
+ (hsbk.hue.to_f / UINT16_MAX) * 360,
119
+ (hsbk.saturation.to_f / UINT16_MAX),
120
+ (hsbk.brightness.to_f / UINT16_MAX),
121
+ hsbk.kelvin
122
+ )
123
+ end
124
+ end
125
+
126
+ def initialize(hue, saturation, brightness, kelvin)
127
+ hue = hue % 360
128
+ super(hue, saturation, brightness, kelvin)
129
+ end
130
+
131
+ # Returns a new Color with the hue changed while keeping other attributes
132
+ # @param hue [Float] Hue in degrees. `0..360`
133
+ # @return [Color]
134
+ def with_hue(hue)
135
+ Color.new(hue, saturation, brightness, kelvin)
136
+ end
137
+
138
+ # Returns a new Color with the saturaiton changed while keeping other attributes
139
+ # @param saturaiton [Float] Saturation as float. `0..1`
140
+ # @return [Color]
141
+ def with_saturation(saturation)
142
+ Color.new(hue, saturation, brightness, kelvin)
143
+ end
144
+
145
+ # Returns a new Color with the brightness changed while keeping other attributes
146
+ # @param brightness [Float] Brightness as float. `0..1`
147
+ # @return [Color]
148
+ def with_brightness(brightness)
149
+ Color.new(hue, saturation, brightness, kelvin)
150
+ end
151
+
152
+ # Returns a new Color with the kelvin changed while keeping other attributes
153
+ # @param kelvin [Integer] Kelvin. `2500..10000`
154
+ # @return [Color]
155
+ def with_kelvin(kelvin)
156
+ Color.new(hue, saturation, brightness, kelvin)
157
+ end
158
+
159
+ # Returns a struct for use by the protocol
160
+ # @api private
161
+ # @return [Protocol::Light::Hsbk]
162
+ def to_hsbk
163
+ Protocol::Light::Hsbk.new(
164
+ hue: (hue / 360.0 * UINT16_MAX).to_i,
165
+ saturation: (saturation * UINT16_MAX).to_i,
166
+ brightness: (brightness * UINT16_MAX).to_i,
167
+ kelvin: [KELVIN_MIN, kelvin.to_i, KELVIN_MAX].sort[1]
168
+ )
169
+ end
170
+
171
+ # Returns hue, saturation, brightness and kelvin in an array
172
+ # @return [Array<Float, Float, Float, Integer>]
173
+ def to_a
174
+ [hue, saturation, brightness, kelvin]
175
+ end
176
+
177
+ EQUALITY_THRESHOLD = 0.001 # 0.1% variance
178
+ # Checks if colours are equal to 0.1% variance
179
+ # @param other [Color] Color to compare to
180
+ # @return [Boolean]
181
+ def ==(other)
182
+ return false unless other.is_a?(Color)
183
+ conditions = []
184
+ conditions << ((hue - other.hue).abs < (EQUALITY_THRESHOLD * 360))
185
+ conditions << ((saturation - other.saturation).abs < EQUALITY_THRESHOLD)
186
+ conditions << ((brightness - other.brightness).abs < EQUALITY_THRESHOLD)
187
+ conditions.all?
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,12 @@
1
+ require 'configatron/core'
2
+ require 'yell'
3
+ module LIFX
4
+ Config = Configatron::Store.new
5
+
6
+ Config.default_duration = 1
7
+ Config.allowed_transports = [:udp, :tcp]
8
+ Config.logger = Yell.new do |logger|
9
+ logger.level = 'gte.warn'
10
+ logger.adapter STDERR, format: '%d [%5L] %p/%t : %m'
11
+ end
12
+ end
@@ -0,0 +1,55 @@
1
+ module LIFX
2
+ # LIFX::Firmware handles decoding firmware payloads
3
+ class Firmware < Struct.new(:build_time, :major, :minor)
4
+ include Comparable
5
+
6
+ def initialize(payload)
7
+ self.build_time = decode_time(payload.build)
8
+ self.major = (payload.version >> 0x10)
9
+ self.minor = (payload.version & 0xFF)
10
+ end
11
+
12
+ def to_s
13
+ "#<Firmware version=#{self.major}.#{self.minor}>"
14
+ end
15
+ alias_method :inspect, :to_s
16
+
17
+ def <=>(obj)
18
+ case obj
19
+ when String
20
+ major, minor = obj.split('.', 2).map(&:to_i)
21
+ [self.major, self.minor] <=> [major, minor]
22
+ when Firmware
23
+ [self.major, self.minor] <=> [obj.major, obj.minor]
24
+ else
25
+ nil
26
+ end
27
+ end
28
+
29
+
30
+ protected
31
+
32
+ def decode_time(int)
33
+ if int < 1300000000000000000
34
+ year = byte(int, 56) + 2000
35
+ month = bytes(int, 48, 40, 32).map(&:chr).join
36
+ day = byte(int, 24)
37
+ hour = byte(int, 16)
38
+ min = byte(int, 8)
39
+ sec = byte(int, 0)
40
+ # Don't want to pull in DateTime just for DateTime.new
41
+ Time.parse("%s %d %04d, %02d:%02d:%02d" % [month, day, year, hour, min, sec])
42
+ else
43
+ Time.at(int / 1000000000)
44
+ end
45
+ end
46
+
47
+ def byte(n, pos)
48
+ 0xFF & (n >> pos)
49
+ end
50
+
51
+ def bytes(n, *range)
52
+ range.map {|r| byte(n, r)}
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,177 @@
1
+ require 'lifx/observable'
2
+ require 'lifx/timers'
3
+
4
+ module LIFX
5
+ # @api private
6
+ class GatewayConnection
7
+ # GatewayConnection handles the UDP and TCP connections to the gateway
8
+ # A GatewayConnection is created when a new device sends a StatePanGateway
9
+ include Timers
10
+ include Logging
11
+ include Observable
12
+
13
+ MAX_TCP_ATTEMPTS = 3
14
+ def initialize
15
+ @threads = []
16
+ @tcp_attempts = 0
17
+ @threads << initialize_write_queue
18
+ end
19
+
20
+ def handle_message(message, ip, transport)
21
+ payload = message.payload
22
+ case payload
23
+ when Protocol::Device::StatePanGateway
24
+ if use_udp? && !udp_connected? && payload.service == Protocol::Device::Service::UDP
25
+ # UDP transport here is only for sending directly to bulb
26
+ # We receive responses via UDP transport listening to broadcast in Network
27
+ connect_udp(ip, payload.port.to_i)
28
+ elsif use_tcp? && !tcp_connected? && payload.service == Protocol::Device::Service::TCP && (port = payload.port.snapshot) > 0
29
+ connect_tcp(ip, port)
30
+ end
31
+ else
32
+ logger.error("#{self}: Unhandled message: #{message}")
33
+ end
34
+ end
35
+
36
+ def use_udp?
37
+ Config.allowed_transports.include?(:udp)
38
+ end
39
+
40
+ def use_tcp?
41
+ Config.allowed_transports.include?(:tcp)
42
+ end
43
+
44
+ def udp_connected?
45
+ @udp_transport && @udp_transport.connected?
46
+ end
47
+
48
+ def tcp_connected?
49
+ @tcp_transport && @tcp_transport.connected?
50
+ end
51
+
52
+ def connected?
53
+ udp_connected? || tcp_connected?
54
+ end
55
+
56
+ def connect_udp(ip, port)
57
+ @udp_transport = Transport::UDP.new(ip, port)
58
+ end
59
+
60
+ def connect_tcp(ip, port)
61
+ if @tcp_attempts > MAX_TCP_ATTEMPTS
62
+ logger.info("#{self}: Ignoring TCP service of #{ip}:#{port} due to too many failed attempts.")
63
+ return
64
+ end
65
+ @tcp_attempts += 1
66
+ logger.info("#{self}: Establishing connection to #{ip}:#{port}")
67
+ @tcp_transport = Transport::TCP.new(ip, port)
68
+ @tcp_transport.add_observer(self) do |message:, ip:, transport:|
69
+ notify_observers(message: message, ip: ip, transport: @tcp_transport)
70
+ end
71
+ @tcp_transport.listen
72
+ at_exit do
73
+ @tcp_transport.close if @tcp_transport
74
+ end
75
+ end
76
+
77
+ def write(message)
78
+ @queue.push(message)
79
+ end
80
+
81
+ def close
82
+ @threads.each { |thr| Thread.kill(thr) }
83
+ [@tcp_transport, @udp_transport].compact.each(&:close)
84
+ end
85
+
86
+ def flush(timeout: nil)
87
+ proc = lambda do
88
+ while !@queue.empty?
89
+ sleep 0.05
90
+ end
91
+ end
92
+ if timeout
93
+ Timeout.timeout(timeout) do
94
+ proc.call
95
+ end
96
+ else
97
+ proc.call
98
+ end
99
+ end
100
+
101
+ def to_s
102
+ "#<LIFX::GatewayConnection tcp=#{@tcp_transport} tcp_attempts=#{@tcp_attempts} udp=#{@udp_transport}>"
103
+ end
104
+ alias_method :inspect, :to_s
105
+
106
+ def set_message_rate(rate)
107
+ @message_rate = rate
108
+ end
109
+ protected
110
+
111
+ MAXIMUM_QUEUE_LENGTH = 10
112
+ DEFAULT_MESSAGE_RATE = 5
113
+ def message_rate
114
+ @message_rate || DEFAULT_MESSAGE_RATE
115
+ end
116
+
117
+ def initialize_write_queue
118
+ @queue = SizedQueue.new(MAXIMUM_QUEUE_LENGTH)
119
+ @last_write = Time.now
120
+ Thread.abort_on_exception = true
121
+ Thread.new do
122
+ loop do
123
+ if !connected?
124
+ sleep 0.1
125
+ next
126
+ end
127
+ delay = [(1.0 / message_rate) - (Time.now - @last_write), 0].max
128
+ logger.debug("#{self}: Sleeping for #{delay}")
129
+ sleep(delay)
130
+ message = @queue.pop
131
+ if !message.is_a?(Message)
132
+ raise ArgumentError.new("Unexpected object in message queue: #{message.inspect}")
133
+ end
134
+ if !actually_write(message)
135
+ logger.error("#{self}: Couldn't write, pushing back onto queue.")
136
+ @queue << message
137
+ end
138
+ @last_write = Time.now
139
+ end
140
+ end
141
+ end
142
+
143
+ def check_connections
144
+ if @tcp_transport && !tcp_connected?
145
+ @tcp_transport = nil
146
+ logger.info("#{self}: TCP connection dropped, clearing.")
147
+ end
148
+
149
+ if @udp_transport && !udp_connected?
150
+ @udp_transport = nil
151
+ logger.info("#{self}: UDP connection dropped, clearing.")
152
+ end
153
+ end
154
+
155
+ def actually_write(message)
156
+ check_connections
157
+
158
+ # TODO: Support force sending over UDP
159
+ if tcp_connected?
160
+ if @tcp_transport.write(message)
161
+ logger.debug("-> #{self} #{@tcp_transport}: #{message}")
162
+ return true
163
+ end
164
+ end
165
+
166
+ if udp_connected?
167
+ if @udp_transport.write(message)
168
+ logger.debug("-> #{self} #{@tcp_transport}: #{message}")
169
+ return true
170
+ end
171
+ end
172
+
173
+ false
174
+ end
175
+
176
+ end
177
+ end