lifx 0.0.1 → 0.4.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.yardopts +1 -0
- data/Gemfile +10 -0
- data/LICENSE.txt +1 -1
- data/README.md +71 -13
- data/Rakefile +12 -0
- data/bin/lifx-console +15 -0
- data/bin/lifx-snoop +50 -0
- data/examples/auto-off/Gemfile +3 -0
- data/examples/auto-off/auto-off.rb +35 -0
- data/examples/identify/Gemfile +3 -0
- data/examples/identify/identify.rb +70 -0
- data/examples/travis-build-light/Gemfile +4 -0
- data/examples/travis-build-light/build-light.rb +57 -0
- data/lib/bindata_ext/bool.rb +29 -0
- data/lib/bindata_ext/record.rb +11 -0
- data/lib/lifx/client.rb +136 -0
- data/lib/lifx/color.rb +190 -0
- data/lib/lifx/config.rb +12 -0
- data/lib/lifx/firmware.rb +55 -0
- data/lib/lifx/gateway_connection.rb +177 -0
- data/lib/lifx/light.rb +406 -0
- data/lib/lifx/light_collection.rb +105 -0
- data/lib/lifx/light_target.rb +189 -0
- data/lib/lifx/logging.rb +11 -0
- data/lib/lifx/message.rb +166 -0
- data/lib/lifx/network_context.rb +200 -0
- data/lib/lifx/observable.rb +46 -0
- data/lib/lifx/protocol/address.rb +21 -0
- data/lib/lifx/protocol/device.rb +225 -0
- data/lib/lifx/protocol/header.rb +24 -0
- data/lib/lifx/protocol/light.rb +110 -0
- data/lib/lifx/protocol/message.rb +17 -0
- data/lib/lifx/protocol/metadata.rb +21 -0
- data/lib/lifx/protocol/payload.rb +7 -0
- data/lib/lifx/protocol/sensor.rb +29 -0
- data/lib/lifx/protocol/type.rb +134 -0
- data/lib/lifx/protocol/wan.rb +50 -0
- data/lib/lifx/protocol/wifi.rb +76 -0
- data/lib/lifx/protocol_path.rb +84 -0
- data/lib/lifx/routing_manager.rb +110 -0
- data/lib/lifx/routing_table.rb +33 -0
- data/lib/lifx/seen.rb +15 -0
- data/lib/lifx/site.rb +89 -0
- data/lib/lifx/tag_manager.rb +105 -0
- data/lib/lifx/tag_table.rb +47 -0
- data/lib/lifx/target.rb +23 -0
- data/lib/lifx/timers.rb +18 -0
- data/lib/lifx/transport/tcp.rb +81 -0
- data/lib/lifx/transport/udp.rb +67 -0
- data/lib/lifx/transport.rb +41 -0
- data/lib/lifx/transport_manager/lan.rb +140 -0
- data/lib/lifx/transport_manager.rb +34 -0
- data/lib/lifx/utilities.rb +33 -0
- data/lib/lifx/version.rb +1 -1
- data/lib/lifx.rb +15 -1
- data/lifx.gemspec +11 -7
- data/spec/color_spec.rb +45 -0
- data/spec/gateway_connection_spec.rb +32 -0
- data/spec/integration/client_spec.rb +40 -0
- data/spec/integration/light_spec.rb +43 -0
- data/spec/integration/tags_spec.rb +31 -0
- data/spec/message_spec.rb +163 -0
- data/spec/protocol_path_spec.rb +109 -0
- data/spec/routing_manager_spec.rb +22 -0
- data/spec/spec_helper.rb +52 -0
- data/spec/transport/udp_spec.rb +38 -0
- data/spec/transport_spec.rb +14 -0
- 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
|
data/lib/lifx/config.rb
ADDED
@@ -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
|