lifx-lan 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (73) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +17 -0
  3. data/.travis.yml +8 -0
  4. data/.yardopts +3 -0
  5. data/CHANGES.md +45 -0
  6. data/Gemfile +19 -0
  7. data/LICENSE.txt +23 -0
  8. data/README.md +15 -0
  9. data/Rakefile +20 -0
  10. data/bin/lifx-snoop +50 -0
  11. data/examples/auto-off/auto-off.rb +34 -0
  12. data/examples/blink/blink.rb +19 -0
  13. data/examples/identify/identify.rb +69 -0
  14. data/examples/travis-build-light/build-light.rb +57 -0
  15. data/lib/bindata_ext/bool.rb +30 -0
  16. data/lib/bindata_ext/record.rb +11 -0
  17. data/lib/lifx-lan.rb +27 -0
  18. data/lib/lifx/lan/client.rb +149 -0
  19. data/lib/lifx/lan/color.rb +199 -0
  20. data/lib/lifx/lan/config.rb +17 -0
  21. data/lib/lifx/lan/firmware.rb +60 -0
  22. data/lib/lifx/lan/gateway_connection.rb +185 -0
  23. data/lib/lifx/lan/light.rb +440 -0
  24. data/lib/lifx/lan/light_collection.rb +111 -0
  25. data/lib/lifx/lan/light_target.rb +185 -0
  26. data/lib/lifx/lan/logging.rb +14 -0
  27. data/lib/lifx/lan/message.rb +168 -0
  28. data/lib/lifx/lan/network_context.rb +188 -0
  29. data/lib/lifx/lan/observable.rb +66 -0
  30. data/lib/lifx/lan/protocol/address.rb +25 -0
  31. data/lib/lifx/lan/protocol/device.rb +387 -0
  32. data/lib/lifx/lan/protocol/header.rb +24 -0
  33. data/lib/lifx/lan/protocol/light.rb +142 -0
  34. data/lib/lifx/lan/protocol/message.rb +19 -0
  35. data/lib/lifx/lan/protocol/metadata.rb +23 -0
  36. data/lib/lifx/lan/protocol/payload.rb +12 -0
  37. data/lib/lifx/lan/protocol/sensor.rb +31 -0
  38. data/lib/lifx/lan/protocol/type.rb +204 -0
  39. data/lib/lifx/lan/protocol/wan.rb +51 -0
  40. data/lib/lifx/lan/protocol/wifi.rb +102 -0
  41. data/lib/lifx/lan/protocol_path.rb +85 -0
  42. data/lib/lifx/lan/required_keyword_arguments.rb +12 -0
  43. data/lib/lifx/lan/routing_manager.rb +114 -0
  44. data/lib/lifx/lan/routing_table.rb +48 -0
  45. data/lib/lifx/lan/seen.rb +25 -0
  46. data/lib/lifx/lan/site.rb +97 -0
  47. data/lib/lifx/lan/tag_manager.rb +111 -0
  48. data/lib/lifx/lan/tag_table.rb +49 -0
  49. data/lib/lifx/lan/target.rb +24 -0
  50. data/lib/lifx/lan/thread.rb +13 -0
  51. data/lib/lifx/lan/timers.rb +29 -0
  52. data/lib/lifx/lan/transport.rb +46 -0
  53. data/lib/lifx/lan/transport/tcp.rb +91 -0
  54. data/lib/lifx/lan/transport/udp.rb +87 -0
  55. data/lib/lifx/lan/transport_manager.rb +43 -0
  56. data/lib/lifx/lan/transport_manager/lan.rb +169 -0
  57. data/lib/lifx/lan/utilities.rb +36 -0
  58. data/lib/lifx/lan/version.rb +5 -0
  59. data/lifx-lan.gemspec +26 -0
  60. data/spec/color_spec.rb +43 -0
  61. data/spec/gateway_connection_spec.rb +30 -0
  62. data/spec/integration/client_spec.rb +42 -0
  63. data/spec/integration/light_spec.rb +56 -0
  64. data/spec/integration/tags_spec.rb +42 -0
  65. data/spec/light_collection_spec.rb +37 -0
  66. data/spec/message_spec.rb +183 -0
  67. data/spec/protocol_path_spec.rb +109 -0
  68. data/spec/routing_manager_spec.rb +25 -0
  69. data/spec/routing_table_spec.rb +23 -0
  70. data/spec/spec_helper.rb +56 -0
  71. data/spec/transport/udp_spec.rb +44 -0
  72. data/spec/transport_spec.rb +14 -0
  73. metadata +187 -0
@@ -0,0 +1,27 @@
1
+ require "lifx/lan/version"
2
+ require "bindata"
3
+ require "bindata_ext/bool"
4
+ require "bindata_ext/record"
5
+
6
+ require "lifx/lan/required_keyword_arguments"
7
+ require "lifx/lan/utilities"
8
+ require "lifx/lan/logging"
9
+
10
+ require "lifx/lan/thread"
11
+
12
+ require "lifx/lan/protocol/payload"
13
+ %w(device light sensor wan wifi message).each { |f| require "lifx/lan/protocol/#{f}" }
14
+ require "lifx/lan/protocol/type"
15
+ require "lifx/lan/message"
16
+ require "lifx/lan/transport"
17
+
18
+ require "lifx/lan/config"
19
+ require "lifx/lan/client"
20
+
21
+ module LIFX
22
+ module LAN
23
+ NULL_SITE_ID = "000000000000"
24
+
25
+ class TimeoutError < StandardError; end
26
+ end
27
+ end
@@ -0,0 +1,149 @@
1
+ require 'socket'
2
+ require 'timeout'
3
+
4
+ require 'lifx/lan/network_context'
5
+ require 'lifx/lan/light_collection'
6
+
7
+ module LIFX
8
+ module LAN
9
+ # {LIFX::LAN::Client} is the top level interface to the library. It mainly maps
10
+ # methods to the backing {NetworkContext} instance.
11
+ class Client
12
+
13
+ class << self
14
+ # Returns a {Client} set up for accessing devices on the LAN
15
+ #
16
+ # @return [Client] A LAN LIFX::Client
17
+ def lan
18
+ @lan ||= new(transport_manager: TransportManager::LAN.new)
19
+ end
20
+ end
21
+
22
+ extend Forwardable
23
+ include Utilities
24
+ include RequiredKeywordArguments
25
+
26
+ # Refers to the client's network context.
27
+ # @return [NetworkContext] Enclosed network context
28
+ attr_reader :context
29
+
30
+ # @param transport_manager: [TransportManager] Specify the {TransportManager}
31
+ def initialize(transport_manager: required!('transport_manager'))
32
+ @context = NetworkContext.new(transport_manager: transport_manager)
33
+ end
34
+
35
+ # Default timeout in seconds for discovery
36
+ DISCOVERY_DEFAULT_TIMEOUT = 10
37
+
38
+ # This method tells the {NetworkContext} to look for devices asynchronously.
39
+ # @return [Client] self
40
+ def discover
41
+ @context.discover
42
+ end
43
+
44
+ def stop_discovery
45
+ @context.stop_discovery
46
+ end
47
+
48
+ class DiscoveryTimeout < Timeout::Error; end
49
+ # This method tells the {NetworkContext} to look for devices, and will block
50
+ # until there's at least one device.
51
+ #
52
+ # @example Wait until at least three lights have been found
53
+ # client.discover! { |c| c.lights.count >= 3 }
54
+ #
55
+ # @param timeout: [Numeric] How long to try to wait for before returning
56
+ # @param condition_interval: [Numeric] Seconds between evaluating the block
57
+ # @yield [Client] This block is evaluated every `condition_interval` seconds. If true, method returns. If no block is supplied, it will block until it finds at least one light.
58
+ # @raise [DiscoveryTimeout] If discovery times out
59
+ # @return [Client] self
60
+ def discover!(timeout: DISCOVERY_DEFAULT_TIMEOUT, condition_interval: 0.1, &block)
61
+ block ||= -> { self.lights.count > 0 }
62
+ try_until -> { block.arity == 1 ? block.call(self) : block.call },
63
+ timeout: timeout,
64
+ timeout_exception: DiscoveryTimeout,
65
+ condition_interval: condition_interval,
66
+ action_interval: 1 do
67
+ discover
68
+ refresh
69
+ end
70
+ self
71
+ end
72
+
73
+ # Sends a request to refresh devices and tags.
74
+ # @return [void]
75
+ def refresh
76
+ @context.refresh
77
+ end
78
+
79
+ # This method takes a block consisting of multiple asynchronous color or power changing targets
80
+ # and it will try to schedule them so they run at the same time.
81
+ #
82
+ # You cannot nest `sync` calls, nor call synchronous methods inside a `sync` block.
83
+ #
84
+ # Due to messaging rate constraints, the amount of messages determine the delay before
85
+ # the commands are executed. This method also assumes all the lights have the same time.
86
+ # @example This example sets all the lights to a random colour at the same time.
87
+ # client.sync do
88
+ # client.lights.each do |light|
89
+ # light.set_color(rand(4) * 90, 1, 1)
90
+ # end
91
+ # end
92
+ #
93
+ # @note This method is in alpha and might go away. Use tags for better group messaging.
94
+ # @yield Block of commands to synchronize
95
+ # @return [Float] Number of seconds until commands are executed
96
+ def sync(**kwargs, &block)
97
+ @context.sync(**kwargs, &block)
98
+ end
99
+
100
+ # This is the same as {#sync}, except it will block until the commands have been executed.
101
+ # @see #sync
102
+ # @return [Float] Number of seconds slept
103
+ def sync!(&block)
104
+ sync(&block).tap do |delay|
105
+ sleep(delay)
106
+ end
107
+ end
108
+
109
+ # @return [LightCollection] Lights available to the client
110
+ # @see [NetworkContext#lights]
111
+ def lights
112
+ context.lights
113
+ end
114
+
115
+ # @return [Array<String>] All tags visible to the client
116
+ # @see [NetworkContext#tags]
117
+ def tags
118
+ context.tags
119
+ end
120
+
121
+ # @return [Array<String>] Tags that are currently unused by known devices
122
+ # @see [NetworkContext#unused_tags]
123
+ def unused_tags
124
+ context.unused_tags
125
+ end
126
+
127
+ # Purges unused tags from the system.
128
+ # Should only use when all devices are on the network, otherwise
129
+ # offline devices using their tags will not be tagged correctly.
130
+ # @return [Array<String>] Tags that were purged
131
+ def purge_unused_tags!
132
+ context.purge_unused_tags!
133
+ end
134
+
135
+ # Blocks until all messages have been sent to the gateways
136
+ # @param timeout: [Numeric] When specified, flush will wait `timeout:` seconds before throwing `Timeout::Error`
137
+ # @raise [TimeoutError] if `timeout:` was exceeded while waiting for send queue to flush
138
+ # @return [void]
139
+ def flush(timeout: nil)
140
+ context.flush(timeout: timeout)
141
+ end
142
+
143
+ # Stops everything and cleans up.
144
+ def stop
145
+ context.stop
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,199 @@
1
+ module LIFX
2
+ module LAN
3
+ module Colors
4
+ DEFAULT_KELVIN = 3500
5
+
6
+ {
7
+ red: 0,
8
+ orange: 36,
9
+ yellow: 60,
10
+ green: 120,
11
+ cyan: 195,
12
+ blue: 250,
13
+ purple: 280,
14
+ pink: 325
15
+ }.each do |color, hue|
16
+ define_method(color) do |saturation: 1.0, brightness: 1.0, kelvin: DEFAULT_KELVIN|
17
+ Color.new(hue, saturation, brightness, kelvin)
18
+ end
19
+ end
20
+
21
+ # Helper to create a white {Color}
22
+ # @param brightness: [Float] Valid range: `0..1`
23
+ # @param kelvin: [Integer] Valid range: `2500..9000`
24
+ # @return [Color]
25
+ def white(brightness: 1.0, kelvin: DEFAULT_KELVIN)
26
+ Color.new(0, 0, brightness, kelvin)
27
+ end
28
+
29
+ # Helper to create a random {Color}
30
+ def random_color(hue: rand(360), saturation: rand, brightness: rand, kelvin: DEFAULT_KELVIN)
31
+ Color.new(hue, saturation, brightness, kelvin)
32
+ end
33
+ end
34
+
35
+ # LIFX::Color represents a color intervally by HSBK (Hue, Saturation, Brightness/Value, Kelvin).
36
+ # It has methods to construct a LIFX::Color instance from various color representations.
37
+ class Color < Struct.new(:hue, :saturation, :brightness, :kelvin)
38
+ extend Colors
39
+ UINT16_MAX = 65535
40
+ KELVIN_MIN = 2500
41
+ KELVIN_MAX = 9000
42
+
43
+ class << self
44
+ # Helper method to create from HSB/HSV
45
+ # @param hue [Float] Valid range: `0..360`
46
+ # @param saturation [Float] Valid range: `0..1`
47
+ # @param brightness [Float] Valid range: `0..1`
48
+ # @return [Color]
49
+ def hsb(hue, saturation, brightness)
50
+ new(hue, saturation, brightness, DEFAULT_KELVIN)
51
+ end
52
+ alias_method :hsv, :hsb
53
+
54
+ # Helper method to create from HSBK/HSVK
55
+ # @param hue [Float] Valid range: `0..360`
56
+ # @param saturation [Float] Valid range: `0..1`
57
+ # @param brightness [Float] Valid range: `0..1`
58
+ # @param kelvin [Integer] Valid range: `2500..9000`
59
+ # @return [Color]
60
+ def hsbk(hue, saturation, brightness, kelvin)
61
+ new(hue, saturation, brightness, kelvin)
62
+ end
63
+
64
+ # Helper method to create from HSL
65
+ # @param hue [Float] Valid range: `0..360`
66
+ # @param saturation [Float] Valid range: `0..1`
67
+ # @param luminance [Float] Valid range: `0..1`
68
+ # @return [Color]
69
+ def hsl(hue, saturation, luminance)
70
+ # From: http://ariya.blogspot.com.au/2008/07/converting-between-hsl-and-hsv.html
71
+ l = luminance * 2
72
+ saturation *= (l <= 1) ? l : 2 - l
73
+ brightness = (l + saturation) / 2
74
+ saturation = (2 * saturation) / (l + saturation)
75
+ new(hue, saturation, brightness, DEFAULT_KELVIN)
76
+ end
77
+
78
+ # Helper method to create from RGB.
79
+ # @note RGB is not the recommended way to create colors
80
+ # @param r [Integer] Red. Valid range: `0..255`
81
+ # @param g [Integer] Green. Valid range: `0..255`
82
+ # @param b [Integer] Blue. Valid range: `0..255`
83
+ # @return [Color]
84
+ def rgb(r, g, b)
85
+ r = r / 255.0
86
+ g = g / 255.0
87
+ b = b / 255.0
88
+
89
+ max = [r, g, b].max
90
+ min = [r, g, b].min
91
+
92
+ h = s = v = max
93
+ d = max - min
94
+ s = max.zero? ? 0 : d / max
95
+
96
+ if max == min
97
+ h = 0
98
+ else
99
+ case max
100
+ when r
101
+ h = (g - b) / d + (g < b ? 6 : 0)
102
+ when g
103
+ h = (b - r) / d + 2
104
+ when b
105
+ h = (r - g) / d + 4
106
+ end
107
+ h = h * 60
108
+ end
109
+
110
+ new(h, s, v, DEFAULT_KELVIN)
111
+ end
112
+
113
+ # Creates an instance from a {Protocol::Light::Hsbk} struct
114
+ # @api private
115
+ # @param hsbk [Protocol::Light::Hsbk]
116
+ # @return [Color]
117
+ def from_struct(hsbk)
118
+ new(
119
+ (hsbk.hue.to_f / UINT16_MAX) * 360,
120
+ (hsbk.saturation.to_f / UINT16_MAX),
121
+ (hsbk.brightness.to_f / UINT16_MAX),
122
+ hsbk.kelvin
123
+ )
124
+ end
125
+ end
126
+
127
+ def initialize(hue, saturation, brightness, kelvin)
128
+ hue = hue % 360
129
+ super(hue, saturation, brightness, kelvin)
130
+ end
131
+
132
+ # Returns a new Color with the hue changed while keeping other attributes
133
+ # @param hue [Float] Hue in degrees. `0..360`
134
+ # @return [Color]
135
+ def with_hue(hue)
136
+ Color.new(hue, saturation, brightness, kelvin)
137
+ end
138
+
139
+ # Returns a new Color with the saturaiton changed while keeping other attributes
140
+ # @param saturaiton [Float] Saturation as float. `0..1`
141
+ # @return [Color]
142
+ def with_saturation(saturation)
143
+ Color.new(hue, saturation, brightness, kelvin)
144
+ end
145
+
146
+ # Returns a new Color with the brightness changed while keeping other attributes
147
+ # @param brightness [Float] Brightness as float. `0..1`
148
+ # @return [Color]
149
+ def with_brightness(brightness)
150
+ Color.new(hue, saturation, brightness, kelvin)
151
+ end
152
+
153
+ # Returns a new Color with the kelvin changed while keeping other attributes
154
+ # @param kelvin [Integer] Kelvin. `2500..9000`
155
+ # @return [Color]
156
+ def with_kelvin(kelvin)
157
+ Color.new(hue, saturation, brightness, kelvin)
158
+ end
159
+
160
+ # Returns a struct for use by the protocol
161
+ # @api private
162
+ # @return [Protocol::Light::Hsbk]
163
+ def to_hsbk
164
+ Protocol::Light::Hsbk.new(
165
+ hue: (hue / 360.0 * UINT16_MAX).to_i,
166
+ saturation: (saturation * UINT16_MAX).to_i,
167
+ brightness: (brightness * UINT16_MAX).to_i,
168
+ kelvin: [KELVIN_MIN, kelvin.to_i, KELVIN_MAX].sort[1]
169
+ )
170
+ end
171
+
172
+ # Returns hue, saturation, brightness and kelvin in an array
173
+ # @return [Array<Float, Float, Float, Integer>]
174
+ def to_a
175
+ [hue, saturation, brightness, kelvin]
176
+ end
177
+
178
+ DEFAULT_SIMILAR_THRESHOLD = 0.001 # 0.1% variance
179
+ # Checks if colours are equal to 0.1% variance
180
+ # @param other [Color] Color to compare to
181
+ # @param threshold: [Float] 0..1. Threshold to consider it similar
182
+ # @return [Boolean]
183
+ def similar_to?(other, threshold: DEFAULT_SIMILAR_THRESHOLD)
184
+ return false unless other.is_a?(Color)
185
+ conditions = []
186
+
187
+ conditions << (((hue - other.hue).abs < (threshold * 360)) || begin
188
+ # FIXME: Surely there's a better way.
189
+ hues = [hue, other.hue].sort
190
+ hues[0] += 360
191
+ (hues[0] - hues[1]).abs < (threshold * 360)
192
+ end)
193
+ conditions << ((saturation - other.saturation).abs < threshold)
194
+ conditions << ((brightness - other.brightness).abs < threshold)
195
+ conditions.all?
196
+ end
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,17 @@
1
+ require 'configatron/core'
2
+ require 'logger'
3
+ module LIFX
4
+ module LAN
5
+ Config = Configatron::Store.new
6
+
7
+ Config.default_duration = 1
8
+ Config.message_wait_timeout = 3
9
+ Config.message_retry_interval = 0.5
10
+ Config.broadcast_ip = '255.255.255.255'
11
+ Config.allowed_transports = [:udp, :tcp]
12
+ Config.log_invalid_messages = true
13
+ Config.logger = Logger.new(STDERR).tap do |logger|
14
+ logger.level = Logger::WARN
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,60 @@
1
+ require 'time'
2
+
3
+ module LIFX
4
+ module LAN
5
+ # LIFX::LAN::Firmware handles decoding firmware payloads
6
+ # @private
7
+ class Firmware < Struct.new(:build_time, :major, :minor)
8
+ include Comparable
9
+
10
+ def initialize(payload)
11
+ self.build_time = decode_time(payload.build)
12
+ self.major = (payload.version >> 0x10)
13
+ self.minor = (payload.version & 0xFF)
14
+ end
15
+
16
+ def to_s
17
+ "#<Firmware version=#{self.major}.#{self.minor}>"
18
+ end
19
+ alias_method :inspect, :to_s
20
+
21
+ def <=>(obj)
22
+ case obj
23
+ when String
24
+ major, minor = obj.split('.', 2).map(&:to_i)
25
+ [self.major, self.minor] <=> [major, minor]
26
+ when Firmware
27
+ [self.major, self.minor] <=> [obj.major, obj.minor]
28
+ else
29
+ nil
30
+ end
31
+ end
32
+
33
+
34
+ protected
35
+
36
+ def decode_time(int)
37
+ if int < 1300000000000000000
38
+ year = byte(int, 56) + 2000
39
+ month = bytes(int, 48, 40, 32).map(&:chr).join
40
+ day = byte(int, 24)
41
+ hour = byte(int, 16)
42
+ min = byte(int, 8)
43
+ sec = byte(int, 0)
44
+ # Don't want to pull in DateTime just for DateTime.new
45
+ Time.parse("%s %d %04d, %02d:%02d:%02d" % [month, day, year, hour, min, sec])
46
+ else
47
+ Time.at(int / 1000000000)
48
+ end
49
+ end
50
+
51
+ def byte(n, pos)
52
+ 0xFF & (n >> pos)
53
+ end
54
+
55
+ def bytes(n, *range)
56
+ range.map {|r| byte(n, r)}
57
+ end
58
+ end
59
+ end
60
+ end