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