lifx-lan 0.1.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.
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