lifx-lan 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +17 -0
- data/.travis.yml +8 -0
- data/.yardopts +3 -0
- data/CHANGES.md +45 -0
- data/Gemfile +19 -0
- data/LICENSE.txt +23 -0
- data/README.md +15 -0
- data/Rakefile +20 -0
- data/bin/lifx-snoop +50 -0
- data/examples/auto-off/auto-off.rb +34 -0
- data/examples/blink/blink.rb +19 -0
- data/examples/identify/identify.rb +69 -0
- data/examples/travis-build-light/build-light.rb +57 -0
- data/lib/bindata_ext/bool.rb +30 -0
- data/lib/bindata_ext/record.rb +11 -0
- data/lib/lifx-lan.rb +27 -0
- data/lib/lifx/lan/client.rb +149 -0
- data/lib/lifx/lan/color.rb +199 -0
- data/lib/lifx/lan/config.rb +17 -0
- data/lib/lifx/lan/firmware.rb +60 -0
- data/lib/lifx/lan/gateway_connection.rb +185 -0
- data/lib/lifx/lan/light.rb +440 -0
- data/lib/lifx/lan/light_collection.rb +111 -0
- data/lib/lifx/lan/light_target.rb +185 -0
- data/lib/lifx/lan/logging.rb +14 -0
- data/lib/lifx/lan/message.rb +168 -0
- data/lib/lifx/lan/network_context.rb +188 -0
- data/lib/lifx/lan/observable.rb +66 -0
- data/lib/lifx/lan/protocol/address.rb +25 -0
- data/lib/lifx/lan/protocol/device.rb +387 -0
- data/lib/lifx/lan/protocol/header.rb +24 -0
- data/lib/lifx/lan/protocol/light.rb +142 -0
- data/lib/lifx/lan/protocol/message.rb +19 -0
- data/lib/lifx/lan/protocol/metadata.rb +23 -0
- data/lib/lifx/lan/protocol/payload.rb +12 -0
- data/lib/lifx/lan/protocol/sensor.rb +31 -0
- data/lib/lifx/lan/protocol/type.rb +204 -0
- data/lib/lifx/lan/protocol/wan.rb +51 -0
- data/lib/lifx/lan/protocol/wifi.rb +102 -0
- data/lib/lifx/lan/protocol_path.rb +85 -0
- data/lib/lifx/lan/required_keyword_arguments.rb +12 -0
- data/lib/lifx/lan/routing_manager.rb +114 -0
- data/lib/lifx/lan/routing_table.rb +48 -0
- data/lib/lifx/lan/seen.rb +25 -0
- data/lib/lifx/lan/site.rb +97 -0
- data/lib/lifx/lan/tag_manager.rb +111 -0
- data/lib/lifx/lan/tag_table.rb +49 -0
- data/lib/lifx/lan/target.rb +24 -0
- data/lib/lifx/lan/thread.rb +13 -0
- data/lib/lifx/lan/timers.rb +29 -0
- data/lib/lifx/lan/transport.rb +46 -0
- data/lib/lifx/lan/transport/tcp.rb +91 -0
- data/lib/lifx/lan/transport/udp.rb +87 -0
- data/lib/lifx/lan/transport_manager.rb +43 -0
- data/lib/lifx/lan/transport_manager/lan.rb +169 -0
- data/lib/lifx/lan/utilities.rb +36 -0
- data/lib/lifx/lan/version.rb +5 -0
- data/lifx-lan.gemspec +26 -0
- data/spec/color_spec.rb +43 -0
- data/spec/gateway_connection_spec.rb +30 -0
- data/spec/integration/client_spec.rb +42 -0
- data/spec/integration/light_spec.rb +56 -0
- data/spec/integration/tags_spec.rb +42 -0
- data/spec/light_collection_spec.rb +37 -0
- data/spec/message_spec.rb +183 -0
- data/spec/protocol_path_spec.rb +109 -0
- data/spec/routing_manager_spec.rb +25 -0
- data/spec/routing_table_spec.rb +23 -0
- data/spec/spec_helper.rb +56 -0
- data/spec/transport/udp_spec.rb +44 -0
- data/spec/transport_spec.rb +14 -0
- 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,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
|