lifx 0.0.1 → 0.4.0
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 +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
|