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,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