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,111 @@
1
+ require 'lifx/lan/light_target'
2
+
3
+ module LIFX
4
+ module LAN
5
+ # LightCollection represents a collection of {Light}s, which can either refer to
6
+ # all lights on a {NetworkContext}, or lights
7
+ class LightCollection
8
+ include LightTarget
9
+ include Enumerable
10
+ include RequiredKeywordArguments
11
+ extend Forwardable
12
+
13
+ class TagNotFound < ArgumentError; end
14
+
15
+ # Refers to {NetworkContext} the instance belongs to
16
+ # @return [NetworkContext]
17
+ attr_reader :context
18
+
19
+ # Tag of the collection. `nil` represents all lights
20
+ # @return [String]
21
+ attr_reader :tag
22
+
23
+ # Creates a {LightCollection} instance. Should not be used directly.
24
+ # @api private
25
+ # @param context: [NetworkContext] NetworkContext this collection belongs to
26
+ # @param tag: [String] Tag
27
+ def initialize(context: required!(:context), tag: nil)
28
+ @context = context
29
+ @tag = tag
30
+ end
31
+
32
+ # Queues a {Protocol::Payload} to be sent to bulbs in the collection
33
+ # @param payload [Protocol::Payload] Payload to be sent
34
+ # @param acknowledge: [Boolean] whether recipients should acknowledge message
35
+ # @param at_time: [Integer] Unix epoch in milliseconds to run the payload. Only applicable to certain payload types.
36
+ # @api private
37
+ # @return [LightCollection] self for chaining
38
+ def send_message(payload, acknowledge: false, at_time: nil)
39
+ if tag
40
+ context.send_message(target: Target.new(tag: tag), payload: payload, acknowledge: acknowledge, at_time: at_time)
41
+ else
42
+ context.send_message(target: Target.new(broadcast: true), payload: payload, acknowledge: acknowledge, at_time: at_time)
43
+ end
44
+ self
45
+ end
46
+
47
+ # Returns a {Light} with device id matching `id`
48
+ # @param id [String] Device ID
49
+ # @return [Light]
50
+ def with_id(id)
51
+ lights.find { |l| l.id == id}
52
+ end
53
+
54
+ # Returns a {Light} with its label matching `label`
55
+ # @param label [String, Regexp] Label
56
+ # @return [Light]
57
+ def with_label(label)
58
+ if label.is_a?(Regexp)
59
+ lights.find { |l| l.label(fetch: false) =~ label }
60
+ else
61
+ lights.find { |l| l.label(fetch: false) == label }
62
+ end
63
+ end
64
+
65
+ # Returns a {LightCollection} of {Light}s tagged with `tag`
66
+ # @param tag [String] Tag
67
+ # @return [LightCollection]
68
+ def with_tag(tag)
69
+ if context.tags.include?(tag)
70
+ self.class.new(context: context, tag: tag)
71
+ else
72
+ raise TagNotFound.new("No such tag '#{tag}'")
73
+ end
74
+ end
75
+
76
+ # Returns an Array of {Light}s
77
+ # @return [Array<Light>]
78
+ def lights
79
+ if tag
80
+ context.all_lights.select { |l| l.tags.include?(tag) }
81
+ else
82
+ context.all_lights
83
+ end
84
+ end
85
+
86
+ DEFAULT_ALIVE_THRESHOLD = 30 # seconds
87
+ # Returns an Array of {Light}s considered alive
88
+ # @param threshold: The maximum number of seconds a {Light} was last seen to be considered alive
89
+ # @return [Array<Light>] Lights considered alive
90
+ def alive(threshold: DEFAULT_ALIVE_THRESHOLD)
91
+ lights.select { |l| l.seconds_since_seen <= threshold }
92
+ end
93
+
94
+ # Returns an Array of {Light}s considered stale
95
+ # @param threshold: The minimum number of seconds since a {Light} was last seen to be considered stale
96
+ # @return [Array<Light>] Lights considered stale
97
+ def stale(threshold: DEFAULT_ALIVE_THRESHOLD)
98
+ lights.select { |l| l.seconds_since_seen > threshold }
99
+ end
100
+
101
+ # Returns a nice string representation of itself
102
+ # @return [String]
103
+ def to_s
104
+ %Q{#<#{self.class.name} lights=#{lights}#{tag ? " tag=#{tag}" : ''}>}
105
+ end
106
+ alias_method :inspect, :to_s
107
+
108
+ def_delegators :lights, :empty?, :each
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,185 @@
1
+ module LIFX
2
+ module LAN
3
+ # LightTarget is a module that contains Light commands that can work
4
+ # with either a single {Light} or multiple Lights via a {LightCollection}
5
+ module LightTarget
6
+ MSEC_PER_SEC = 1000
7
+
8
+ # Attempts to set the color of the light(s) to `color` asynchronously.
9
+ # This method cannot guarantee that the message was received.
10
+ # @param color [Color] The color to be set
11
+ # @param duration: [Numeric] Transition time in seconds
12
+ # @return [Light, LightCollection] self for chaining
13
+ def set_color(color, duration: LIFX::Config.default_duration)
14
+ send_message(Protocol::Light::SetColor.new(
15
+ color: color.to_hsbk,
16
+ duration: (duration * MSEC_PER_SEC).to_i,
17
+ stream: 0,
18
+ ))
19
+ self
20
+ end
21
+
22
+ # Attempts to apply a waveform to the light(s) asynchronously.
23
+ # @note Don't use this directly.
24
+ # @api private
25
+ def set_waveform(color, waveform: required!(:waveform),
26
+ cycles: required!(:cycles),
27
+ stream: 0,
28
+ transient: true,
29
+ period: 1.0,
30
+ skew_ratio: 0.5,
31
+ acknowledge: false)
32
+ send_message(Protocol::Light::SetWaveform.new(
33
+ color: color.to_hsbk,
34
+ waveform: waveform,
35
+ cycles: cycles,
36
+ stream: stream,
37
+ transient: transient,
38
+ period: (period * 1_000).to_i,
39
+ skew_ratio: (skew_ratio * 65535).round - 32768,
40
+ ), acknowledge: acknowledge)
41
+ end
42
+
43
+ # Attempts to make the light(s) pulse `color` and then back to its original color. Asynchronous.
44
+ # @param color [Color] Color to pulse
45
+ # @param duty_cycle: [Float] Ratio of a cycle the light(s) is set to `color`
46
+ # @param cycles: [Integer] Number of cycles
47
+ # @param transient: [Boolean] If false, the light will remain at the color the waveform is at when it ends
48
+ # @param period: [Integer] Number of seconds a cycle. Must be above 1.0 (?)
49
+ # @param stream: [Integer] Unused
50
+ def pulse(color, cycles: 1,
51
+ duty_cycle: 0.5,
52
+ transient: true,
53
+ period: 1.0,
54
+ stream: 0)
55
+ set_waveform(color, waveform: Protocol::Light::Waveform::PULSE,
56
+ cycles: cycles,
57
+ skew_ratio: 1 - duty_cycle,
58
+ stream: stream,
59
+ transient: transient,
60
+ period: period)
61
+ end
62
+
63
+ # Attempts to make the light(s) transition to `color` and back in a smooth sine wave. Asynchronous.
64
+ # @param color [Color] Color
65
+ # @param cycles: [Integer] Number of cycles
66
+ # @param peak: [Float] Defines the peak point of the wave. Defaults to 0.5 which is a standard sine
67
+ # @param transient: [Boolean] If false, the light will remain at the color the waveform is at when it ends
68
+ # @param period: [Integer] Number of seconds a cycle. Must be above 1.0 (?)
69
+ # @param stream: [Integer] Unused
70
+ def sine(color, cycles: 1,
71
+ period: 1.0,
72
+ peak: 0.5,
73
+ transient: true,
74
+ stream: 0)
75
+ set_waveform(color, waveform: Protocol::Light::Waveform::SINE,
76
+ cycles: cycles,
77
+ skew_ratio: peak,
78
+ stream: stream,
79
+ transient: transient,
80
+ period: period)
81
+ end
82
+
83
+ # Attempts to make the light(s) transition to `color` smoothly, then immediately back to its original color. Asynchronous.
84
+ # @param color [Color] Color
85
+ # @param cycles: [Integer] Number of cycles
86
+ # @param transient: [Boolean] If false, the light will remain at the color the waveform is at when it ends
87
+ # @param period: [Integer] Number of seconds a cycle. Must be above 1.0 (?)
88
+ # @param stream: [Integer] Unused
89
+ def half_sine(color, cycles: 1,
90
+ period: 1.0,
91
+ transient: true,
92
+ stream: 0)
93
+ set_waveform(color, waveform: Protocol::Light::Waveform::HALF_SINE,
94
+ cycles: cycles,
95
+ stream: stream,
96
+ transient: transient,
97
+ period: period)
98
+ end
99
+
100
+ # Attempts to make the light(s) transition to `color` linearly and back. Asynchronous.
101
+ # @param color [Color] Color to pulse
102
+ # @param cycles: [Integer] Number of cycles
103
+ # @param transient: [Boolean] If false, the light will remain at the color the waveform is at when it ends
104
+ # @param period: [Integer] Number of seconds a cycle. Must be above 1.0 (?)
105
+ # @param stream: [Integer] Unused
106
+ def triangle(color, cycles: 1,
107
+ period: 1.0,
108
+ peak: 0.5,
109
+ transient: true,
110
+ stream: 0)
111
+ set_waveform(color, waveform: Protocol::Light::Waveform::TRIANGLE,
112
+ cycles: cycles,
113
+ skew_ratio: peak,
114
+ stream: stream,
115
+ transient: transient,
116
+ period: period)
117
+ end
118
+
119
+ # Attempts to make the light(s) transition to `color` linearly, then instantly back. Asynchronous.
120
+ # @param color [Color] Color to saw wave
121
+ # @param cycles: [Integer] Number of cycles
122
+ # @param transient: [Boolean] If false, the light will remain at the color the waveform is at when it ends
123
+ # @param period: [Integer] Number of seconds a cycle. Must be above 1.0 (?)
124
+ # @param stream: [Integer] Unused
125
+ def saw(color, cycles: 1,
126
+ period: 1.0,
127
+ transient: true,
128
+ stream: 0)
129
+ set_waveform(color, waveform: Protocol::Light::Waveform::SAW,
130
+ cycles: cycles,
131
+ stream: stream,
132
+ transient: transient,
133
+ period: period)
134
+ end
135
+
136
+ # Attempts to set the power state to `state` asynchronously.
137
+ # This method cannot guarantee the message was received.
138
+ # @param state [:on, :off]
139
+ # @return [Light, LightCollection] self for chaining
140
+ def set_power(state)
141
+ level = case state
142
+ when :on
143
+ 1
144
+ when :off
145
+ 0
146
+ else
147
+ raise ArgumentError.new("Must pass in either :on or :off")
148
+ end
149
+ send_message(Protocol::Device::SetPower.new(level: level))
150
+ self
151
+ end
152
+
153
+ # Attempts to turn the light(s) on asynchronously.
154
+ # This method cannot guarantee the message was received.
155
+ # @return [Light, LightCollection] self for chaining
156
+ def turn_on
157
+ set_power(:on)
158
+ end
159
+
160
+ # Attempts to turn the light(s) off asynchronously.
161
+ # This method cannot guarantee the message was received.
162
+ # @return [Light, LightCollection] self for chaining
163
+ def turn_off
164
+ set_power(:off)
165
+ end
166
+
167
+ # Requests light(s) to report their state
168
+ # This method cannot guarantee the message was received.
169
+ # @return [Light, LightCollection] self for chaining
170
+ def refresh
171
+ send_message(Protocol::Light::Get.new)
172
+ self
173
+ end
174
+
175
+ NSEC_IN_SEC = 1_000_000_000
176
+ # Attempts to set the device time on the targets
177
+ # @api private
178
+ # @param time [Time] The time to set
179
+ # @return [void]
180
+ def set_time(time = Time.now)
181
+ send_message(Protocol::Device::SetTime.new(time: (time.to_f * NSEC_IN_SEC).round))
182
+ end
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,14 @@
1
+ module LIFX
2
+ module LAN
3
+ # @private
4
+ module Logging
5
+ def self.included(mod)
6
+ mod.extend(self)
7
+ end
8
+
9
+ def logger
10
+ LIFX::LAN::Config.logger
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,168 @@
1
+ require 'forwardable'
2
+ require 'lifx/lan/protocol_path'
3
+
4
+ module LIFX
5
+ module LAN
6
+ # @api private
7
+ class Message
8
+ include Logging
9
+ extend Forwardable
10
+
11
+ class MessageError < StandardError; end
12
+ class UnpackError < MessageError; end
13
+ class PackError < MessageError; end
14
+
15
+ class InvalidFrame < UnpackError; end
16
+ class UnsupportedProtocolVersion < UnpackError; end
17
+ class NotAddressableFrame < UnpackError; end
18
+ class NoPayload < PackError; end
19
+ class UnmappedPayload < MessageError; end
20
+ class InvalidFields < PackError; end
21
+
22
+ PROTOCOL_VERSION = 1024
23
+
24
+ class << self
25
+ def unpack(data)
26
+ raise InvalidFrame if data.length < 2
27
+
28
+ header = Protocol::Header.read(data)
29
+ raise UnsupportedProtocolVersion.new("Expected #{PROTOCOL_VERSION} but got #{header.protocol} instead") if header.protocol != PROTOCOL_VERSION
30
+ raise NotAddressableFrame if header.addressable == 0
31
+
32
+ message = Protocol::Message.read(data)
33
+ path = ProtocolPath.new(raw_site: message.raw_site, raw_target: message.raw_target, tagged: message.tagged)
34
+ payload_class = message_type_for_id(message.type_.snapshot)
35
+ if payload_class.nil?
36
+ if Config.log_invalid_messages
37
+ logger.error("Message.unpack: Unrecognised payload ID: #{message.type_}")
38
+ logger.error("Message.unpack: Message: #{message}")
39
+ end
40
+ return nil # FIXME
41
+ raise UnmappedPayload.new("Unrecognised payload ID: #{message.type_}")
42
+ end
43
+ begin
44
+ payload = payload_class.read(message.payload)
45
+ rescue => ex
46
+ if message.raw_site == "\x00" * 6
47
+ logger.info("Message.unpack: Ignoring malformed message from virgin bulb")
48
+ else
49
+ if Config.log_invalid_messages
50
+ logger.error("Message.unpack: Exception while unpacking payload of type #{payload_class}: #{ex}")
51
+ logger.error("Message.unpack: Data: #{data.inspect}")
52
+ end
53
+ end
54
+ end
55
+ new(path, message, payload)
56
+ rescue => ex
57
+ if Config.log_invalid_messages
58
+ logger.debug("Message.unpack: Exception while unpacking #{data.inspect}")
59
+ logger.debug("Message.unpack: #{ex} - #{ex.backtrace.join("\n")}")
60
+ end
61
+ raise ex
62
+ end
63
+
64
+ def message_type_for_id(type_id)
65
+ Protocol::TYPE_ID_TO_CLASS[type_id]
66
+ end
67
+
68
+ def type_id_for_message_class(klass)
69
+ Protocol::CLASS_TO_TYPE_ID[klass]
70
+ end
71
+
72
+ def valid_fields
73
+ @valid_fields ||= Protocol::Message.new.field_names.map(&:to_sym)
74
+ end
75
+ end
76
+
77
+ LIFX::LAN::Protocol::Message.fields.each do |field|
78
+ define_method(field.name) do
79
+ @message.send(field.name).snapshot
80
+ end
81
+
82
+ define_method("#{field.name}=") do |value|
83
+ @message.send("#{field.name}=", value)
84
+ end
85
+ end
86
+
87
+ alias_method :tagged?, :tagged
88
+ alias_method :addressable?, :addressable
89
+
90
+ def_delegators :path, :device_id, :site_id, :tagged
91
+
92
+ attr_accessor :path, :payload
93
+ def initialize(*args)
94
+ if args.count == 3
95
+ @path, @message, @payload = args
96
+ elsif (hash = args.first).is_a?(Hash)
97
+ path = hash.delete(:path) || ProtocolPath.new
98
+ payload = hash.delete(:payload)
99
+
100
+ check_valid_fields!(hash)
101
+
102
+ @message = Protocol::Message.new(hash)
103
+ self.payload = payload
104
+ self.path = path
105
+ @message.tagged = path.tagged? if path
106
+ else
107
+ @message = Protocol::Message.new
108
+ end
109
+ @message.msg_size = @message.num_bytes
110
+ @message.protocol = PROTOCOL_VERSION
111
+ rescue => ex
112
+ raise MessageError.new("Unable to initialize message with args: #{args.inspect} - #{ex}")
113
+ end
114
+
115
+ def payload=(payload)
116
+ @payload = payload
117
+ type_id = self.class.type_id_for_message_class(payload.class)
118
+ if type_id.nil?
119
+ raise UnmappedPayload.new("Unmapped payload class #{payload.class}")
120
+ end
121
+ @message.type_ = type_id
122
+ @message.payload = payload.pack
123
+ end
124
+
125
+ def pack
126
+ raise NoPayload if !payload
127
+ @message.raw_site = path.raw_site
128
+ @message.raw_target = path.raw_target
129
+ @message.tagged = path.tagged?
130
+ @message.msg_size = @message.num_bytes
131
+ @message.pack
132
+ end
133
+
134
+ def to_s
135
+ hash = {site: path.site_id}
136
+ if path.tagged?
137
+ hash[:tags] = path.tag_ids
138
+ hash[:tags] = 'all' if hash[:tags].empty?
139
+ else
140
+ hash[:device] = path.device_id
141
+ end
142
+ hash[:type_] = payload.class.to_s.sub('LIFX::Protocol::', '')
143
+ hash[:addressable] = addressable? ? 'true' : 'false'
144
+ hash[:tagged] = path.tagged? ? 'true' : 'false'
145
+ hash[:at_time] = @message.at_time if @message.at_time && @message.at_time > 0
146
+ hash[:protocol] = protocol
147
+ hash[:payload] = payload.snapshot if payload
148
+ attrs = hash.map { |k, v| "#{k}=#{v}" }.join(' ')
149
+ %Q{#<LIFX::LAN::Message #{attrs}>}
150
+ end
151
+
152
+ def to_hex
153
+ pack.each_byte.map { |b| b.to_s(16).rjust(2, '0') }.join
154
+ end
155
+
156
+ alias_method :inspect, :to_s
157
+
158
+ protected
159
+
160
+ def check_valid_fields!(hash)
161
+ invalid_fields = hash.keys - self.class.valid_fields
162
+ if invalid_fields.count > 0
163
+ raise InvalidFields.new("Invalid fields for Message: #{invalid_fields.join(', ')}")
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end