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 'weakref'
2
+
3
+ module LIFX
4
+ module LAN
5
+ # @api private
6
+ # @private
7
+ class TagManager
8
+ # TagManager handles discovery of tags, resolving tags to [site_id, tags_field] pairs,
9
+ # creating, setting and removing tags.
10
+
11
+ # Stores site <-> [tag_name, tag_id]
12
+ include Utilities
13
+ include Logging
14
+ include RequiredKeywordArguments
15
+
16
+ attr_reader :context
17
+
18
+ class TagLimitReached < StandardError; end
19
+
20
+ def initialize(context: required!(:context), tag_table: required!(:tag_table))
21
+ @context = WeakRef.new(context)
22
+ @tag_table = tag_table
23
+ end
24
+
25
+ def create_tag(label: required!(:label), site_id: required!(:site_id))
26
+ id = next_unused_id_on_site_id(site_id)
27
+ raise TagLimitReached if id.nil?
28
+ # Add the entry for the tag we're about to create to prevent a case where
29
+ # we don't receive a StateTagLabels before another tag gets created
30
+ @tag_table.update_table(tag_id: id, label: label, site_id: site_id)
31
+ context.send_message(target: Target.new(site_id: site_id),
32
+ payload: Protocol::Device::SetTagLabels.new(tags: id_to_tags_field(id), label: label.encode('utf-8')))
33
+ end
34
+
35
+ def add_tag_to_device(tag: required!(:tag), device: required!(:device))
36
+ tag_entry = entry_with(label: tag, site_id: device.site_id)
37
+ if !tag_entry
38
+ create_tag(label: tag, site_id: device.site_id)
39
+ tag_entry = entry_with(label: tag, site_id: device.site_id)
40
+ end
41
+
42
+ device_tags_field = device.tags_field
43
+ device_tags_field |= id_to_tags_field(tag_entry.tag_id)
44
+ device.send_message!(Protocol::Device::SetTags.new(tags: device_tags_field), wait_for: Protocol::Device::StateTags) do
45
+ device.tags.include?(tag)
46
+ end
47
+ end
48
+
49
+ def remove_tag_from_device(tag: required!(:tag), device: required!(:device))
50
+ tag_entry = entry_with(label: tag, site_id: device.site_id)
51
+ return if !tag_entry
52
+
53
+ device_tags_field = device.tags_field
54
+ device_tags_field &= ~id_to_tags_field(tag_entry.tag_id)
55
+ device.send_message!(Protocol::Device::SetTags.new(tags: device_tags_field), wait_for: Protocol::Device::StateTags) do
56
+ !device.tags.include?(tag)
57
+ end
58
+ end
59
+
60
+ def tags
61
+ @tag_table.tags
62
+ end
63
+
64
+ def unused_tags
65
+ @tag_table.tags.select do |tag|
66
+ context.lights.with_tag(tag).empty?
67
+ end
68
+ end
69
+
70
+ # This will clear out tags that currently do not resolve to any devices.
71
+ # If used when devices that are tagged with a tag that is not attached to an
72
+ # active device, it will effectively untag them when they're back on.
73
+ def purge_unused_tags!
74
+ unused_tags.each do |tag|
75
+ logger.info("Purging tag '#{tag}'")
76
+ entries_with(label: tag).each do |entry|
77
+ payload = Protocol::Device::SetTagLabels.new(tags: id_to_tags_field(entry.tag_id), label: '')
78
+ context.send_message(target: Target.new(site_id: entry.site_id),
79
+ payload: payload,
80
+ acknowledge: true)
81
+ end
82
+ end
83
+ Timeout.timeout(5) do
84
+ while !unused_tags.empty?
85
+ sleep 0.1
86
+ end
87
+ end
88
+ end
89
+
90
+ protected
91
+
92
+ VALID_TAG_IDS = (0...64).to_a.freeze
93
+
94
+ def entry_with(**args)
95
+ entries_with(**args).first
96
+ end
97
+
98
+ def entries_with(**args)
99
+ @tag_table.entries_with(**args)
100
+ end
101
+
102
+ def id_to_tags_field(id)
103
+ 2 ** id
104
+ end
105
+
106
+ def next_unused_id_on_site_id(site_id)
107
+ (VALID_TAG_IDS - entries_with(site_id: site_id).map(&:tag_id)).first
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,49 @@
1
+ module LIFX
2
+ module LAN
3
+ # @api private
4
+ class TagTable
5
+ class Entry < Struct.new(:tag_id, :label, :device_id); end
6
+
7
+ def initialize(entries: {})
8
+ @entries = Hash.new { |h, k| h[k] = {} }
9
+ entries.each do |k, v|
10
+ @entries[k] = v
11
+ end
12
+ end
13
+
14
+ def entries_with(tag_id: nil, device_id: nil, label: nil)
15
+ entries.select do |entry|
16
+ ret = []
17
+ ret << (entry.tag_id == tag_id) if tag_id
18
+ ret << (entry.device_id == device_id) if device_id
19
+ ret << (entry.label == label) if label
20
+ ret.all?
21
+ end
22
+ end
23
+
24
+ def entry_with(**args)
25
+ entries_with(**args).first
26
+ end
27
+
28
+ def update_table(tag_id: self.tag_id, label: self.label, device_id: self.device_id)
29
+ entry = @entries[device_id][tag_id] ||= Entry.new(tag_id, label, device_id)
30
+ entry.label = label
31
+ end
32
+
33
+ def delete_entries_with(tag_id: nil, device_id: nil, label: nil)
34
+ matching_entries = entries_with(tag_id: tag_id, device_id: device_id, label: label)
35
+ matching_entries.each do |entry|
36
+ @entries[entry.device_id].delete(entry.tag_id)
37
+ end
38
+ end
39
+
40
+ def tags
41
+ entries.map(&:label).uniq
42
+ end
43
+
44
+ def entries
45
+ @entries.values.map(&:values).flatten
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,24 @@
1
+ module LIFX
2
+ module LAN
3
+ # Target is a high-level abstraction for the target of a Message
4
+ # @api private
5
+ class Target
6
+
7
+ attr_reader :site_id, :device_id, :tag, :broadcast
8
+ def initialize(device_id: nil, site_id: nil, tag: nil, broadcast: nil)
9
+ @site_id = site_id
10
+ @device_id = device_id
11
+ @tag = tag
12
+ @broadcast = broadcast
13
+ end
14
+
15
+ def broadcast?
16
+ !!broadcast
17
+ end
18
+
19
+ def tag?
20
+ !!tag
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,13 @@
1
+ require 'thread'
2
+
3
+ module LIFX
4
+ module LAN
5
+ class Thread < ::Thread
6
+ def abort
7
+ if alive?
8
+ kill.join
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,29 @@
1
+ require 'timers'
2
+ module LIFX
3
+ module LAN
4
+ # @private
5
+ module Timers
6
+ protected
7
+ def initialize_timer_thread
8
+ timers.after(1) {} # Just so timers.wait doesn't complain when there's no timer
9
+ @timer_thread = Thread.start do
10
+ loop do
11
+ timers.wait
12
+ end
13
+ end
14
+ end
15
+
16
+ def stop_timers
17
+ timers.each(&:cancel)
18
+ if @timer_thread
19
+ @timer_thread.abort
20
+ end
21
+ end
22
+
23
+ public
24
+ def timers
25
+ @timers ||= ::Timers.new
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,46 @@
1
+ require 'lifx/lan/observable'
2
+
3
+ module LIFX
4
+ module LAN
5
+ # @api private
6
+ class Transport
7
+ include Logging
8
+ include Observable
9
+
10
+ attr_reader :host, :port
11
+
12
+ def initialize(host, port, ignore_unpackable_messages: true)
13
+ @host = host
14
+ @port = port
15
+ @ignore_unpackable_messages = ignore_unpackable_messages
16
+ end
17
+
18
+ def listen
19
+ raise NotImplementedError
20
+ end
21
+
22
+ def write(message)
23
+ raise NotImplementedError
24
+ end
25
+
26
+ def close
27
+ remove_observers
28
+ end
29
+
30
+ def to_s
31
+ %Q{#<#{self.class.name} #{host}:#{port}>}
32
+ end
33
+ alias_method :inspect, :to_s
34
+
35
+ def observer_callback_definition
36
+ {
37
+ message_received: -> (message: nil, ip: nil, transport: nil) {},
38
+ disconnected: -> {}
39
+ }
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ require 'lifx/lan/transport/udp'
46
+ require 'lifx/lan/transport/tcp'
@@ -0,0 +1,91 @@
1
+ require 'socket'
2
+
3
+ module LIFX
4
+ module LAN
5
+ class Transport
6
+ # @api private
7
+ # @private
8
+ class TCP < Transport
9
+ include Logging
10
+
11
+ def initialize(*args)
12
+ super
13
+ connect
14
+ end
15
+
16
+ def connected?
17
+ !!(@socket && !@socket.closed?)
18
+ end
19
+
20
+ CONNECT_TIMEOUT = 3
21
+ def connect
22
+ Timeout.timeout(CONNECT_TIMEOUT) do
23
+ @socket = TCPSocket.new(host, port) # Performs the connection
24
+ end
25
+ @socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_SNDBUF, 1024)
26
+ @socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
27
+ logger.info("#{self}: Connected.")
28
+ rescue => ex
29
+ logger.warn("#{self}: Exception occurred in #connect - #{ex}")
30
+ logger.debug("#{self}: Backtrace: #{ex.backtrace.join("\n")}")
31
+ @socket = nil
32
+ end
33
+
34
+ def close
35
+ super
36
+ return if !@socket
37
+ if !@socket.closed?
38
+ @socket.close
39
+ notify_observers(:disconnected)
40
+ end
41
+ @socket = nil
42
+ if @listener
43
+ @listener.abort
44
+ end
45
+ @listener = nil
46
+ end
47
+
48
+ HEADER_SIZE = 8
49
+ def listen
50
+ return if @listener
51
+ @listener = Thread.start do
52
+ while @socket do
53
+ begin
54
+ header_data = @socket.recv(HEADER_SIZE, Socket::MSG_PEEK)
55
+ header = Protocol::Header.read(header_data)
56
+ size = header.msg_size
57
+ data = @socket.recv(size)
58
+ message = Message.unpack(data)
59
+
60
+ notify_observers(:message_received, {message: message, ip: host, transport: self})
61
+ rescue Message::UnpackError
62
+ if Config.log_invalid_messages
63
+ logger.info("#{self}: Exception occurred while decoding message - #{ex}")
64
+ logger.info("Data: #{data.inspect}")
65
+ end
66
+ rescue => ex
67
+ logger.warn("#{self}: Exception occurred in #listen - #{ex}")
68
+ logger.debug("#{self}: Backtrace: #{ex.backtrace.join("\n")}")
69
+ close
70
+ end
71
+ end
72
+ end
73
+ end
74
+
75
+ SEND_TIMEOUT = 2
76
+ def write(message)
77
+ data = message.pack
78
+ Timeout.timeout(SEND_TIMEOUT) do
79
+ @socket.write(data)
80
+ end
81
+ true
82
+ rescue => ex
83
+ logger.warn("#{self}: Exception in #write: #{ex}")
84
+ logger.debug("#{self}: Backtrace: #{ex.backtrace.join("\n")}")
85
+ close
86
+ false
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,87 @@
1
+ require 'socket'
2
+ module LIFX
3
+ module LAN
4
+ class Transport
5
+ # @api private
6
+ # @private
7
+ class UDP < Transport
8
+ def initialize(*args)
9
+ super
10
+ @socket = create_socket
11
+ end
12
+
13
+ def connected?
14
+ !!@socket
15
+ end
16
+
17
+ def write_raw(data)
18
+ logger.debug("-> #{data}")
19
+ @socket.send(data.scan(/../).map { |x| x.hex.chr }.join, 0, host, port)
20
+ true
21
+ rescue => ex
22
+ logger.warn("#{self}: Error on #write: #{ex}")
23
+ logger.debug("#{self}: Backtrace: #{ex.backtrace.join("\n")}")
24
+ close
25
+ false
26
+ end
27
+
28
+ def write(message)
29
+ logger.debug("-> Broadcast UDP message: #{message}")
30
+ logger.debug("-> #{message.to_hex}")
31
+ data = message.pack
32
+ @socket.send(data, 0, host, port)
33
+ true
34
+ rescue => ex
35
+ logger.warn("#{self}: Error on #write: #{ex}")
36
+ logger.debug("#{self}: Backtrace: #{ex.backtrace.join("\n")}")
37
+ close
38
+ false
39
+ end
40
+
41
+ def listen(ip: self.host, port: self.port)
42
+ if @listener
43
+ raise "Socket already being listened to"
44
+ end
45
+
46
+ @listener = Thread.start do
47
+ reader = UDPSocket.new
48
+ reader.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, true)
49
+ reader.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEPORT, true) if Socket.const_defined?('SO_REUSEPORT')
50
+ reader.bind(ip, port)
51
+ loop do
52
+ begin
53
+ bytes, (_, _, ip, _) = reader.recvfrom(128)
54
+ logger.debug("<- #{bytes.each_byte.map { |b| b.to_s(16).rjust(2, '0') }.join}")
55
+ message = Message.unpack(bytes)
56
+ logger.debug("<- Incoming message: #{message}")
57
+ notify_observers(:message_received, {message: message, ip: ip, transport: self})
58
+ rescue Message::UnpackError
59
+ if Config.log_invalid_messages
60
+ logger.warn("#{self}: Unrecognised bytes: #{bytes.bytes.map { |b| '%02x ' % b }.join}")
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+
67
+ def close
68
+ super
69
+ return if !@socket
70
+ @socket.close
71
+ @socket = nil
72
+ if @listener
73
+ @listener.abort
74
+ end
75
+ end
76
+
77
+ protected
78
+
79
+ def create_socket
80
+ UDPSocket.new.tap do |socket|
81
+ socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_BROADCAST, true)
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end