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,51 @@
1
+ module LIFX
2
+ module LAN
3
+ module Protocol
4
+ # @api private
5
+ module Wan
6
+ class ConnectPlain < Payload
7
+ endian :little
8
+
9
+ string :user, length: 32, trim_padding: true
10
+ string :pass, length: 32, trim_padding: true
11
+ end
12
+
13
+ class ConnectKey < Payload
14
+ endian :little
15
+
16
+ string :auth_key, length: 32
17
+ end
18
+
19
+ class StateConnect < Payload
20
+ endian :little
21
+
22
+ string :auth_key, length: 32
23
+ end
24
+
25
+ class Sub < Payload
26
+ endian :little
27
+
28
+ string :target, length: 8
29
+ string :site, length: 6
30
+ bool :device # 0 - Targets a device. 1 - Targets a tag.
31
+ end
32
+
33
+ class Unsub < Payload
34
+ endian :little
35
+
36
+ string :target, length: 8
37
+ string :site, length: 6
38
+ bool :device # 0 - Targets a device. 1 - Targets a tag.
39
+ end
40
+
41
+ class StateSub < Payload
42
+ endian :little
43
+
44
+ string :target, length: 8
45
+ string :site, length: 6
46
+ bool :device # 0 - Targets a device. 1 - Targets a tag.
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,102 @@
1
+ module LIFX
2
+ module LAN
3
+ module Protocol
4
+ # @api private
5
+ module Wifi
6
+ module Interface
7
+ SOFT_AP = 1
8
+ STATION = 2
9
+ end
10
+
11
+ module Security
12
+ UNKNOWN = 0
13
+ OPEN = 1
14
+ WEP_PSK = 2
15
+ WPA_TKIP_PSK = 3
16
+ WPA_AES_PSK = 4
17
+ WPA2_AES_PSK = 5
18
+ WPA2_TKIP_PSK = 6
19
+ WPA2_MIXED_PSK = 7
20
+ end
21
+
22
+ module Status
23
+ CONNECTING = 0
24
+ CONNECTED = 1
25
+ FAILED = 2
26
+ OFF = 3
27
+ end
28
+
29
+ # Gets interface status
30
+ class Get < Payload
31
+ endian :little
32
+
33
+ uint8 :interface # Interface to introspect
34
+ end
35
+
36
+ # Switch interface on/off
37
+ class Set < Payload
38
+ endian :little
39
+
40
+ uint8 :interface # Interface to introspect
41
+ bool :active # Turns off interface if active is false
42
+ end
43
+
44
+ # Describes interface state
45
+ class State < Payload
46
+ endian :little
47
+
48
+ uint8 :interface # Interface to introspect
49
+ uint8 :status # Interface status
50
+ uint32 :ipv4 # IPv4 address if interface is active
51
+ string :ipv6, length: 16 # IPv6 address if interface is active and is IPv6 addressable
52
+ end
53
+
54
+ # Scan for WiFi access points. Returns StateAccessPoints
55
+ class GetAccessPoints < Payload
56
+ endian :little
57
+
58
+ end
59
+
60
+ # The AP scan results for an interface
61
+ class StateAccessPoints < Payload
62
+ endian :little
63
+
64
+ uint8 :interface # Interface to introspect
65
+ string :ssid, length: 32, trim_padding: true # SSID of Access Point
66
+ uint8 :security # Security mode
67
+ int16 :strength # Signal strength in dB
68
+ uint16 :channel # Frequency channel
69
+ end
70
+
71
+ # Get WiFi access point information. Returns StateAccessPoint
72
+ class GetAccessPoint < Payload
73
+ endian :little
74
+
75
+ uint8 :interface # Interface to introspect
76
+ end
77
+
78
+ # Configure interface, should return StateAccessPoint
79
+ class SetAccessPoint < Payload
80
+ endian :little
81
+
82
+ uint8 :interface # Interface to introspect
83
+ string :ssid, length: 32, trim_padding: true # SSID of Access Point
84
+ string :pass, length: 64, trim_padding: true # Passphrase used to authenticate
85
+ uint8 :security # Security mode
86
+ end
87
+
88
+ # Interface configuration
89
+ class StateAccessPoint < Payload
90
+ endian :little
91
+
92
+ uint8 :interface # Interface to introspect
93
+ string :ssid, length: 32, trim_padding: true # SSID of Access Point
94
+ uint8 :security # Security mode
95
+ int16 :strength # Signal strength in dB
96
+ uint16 :channel # Frequency channel
97
+ end
98
+
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,85 @@
1
+ require 'lifx/lan/utilities'
2
+
3
+ module LIFX
4
+ module LAN
5
+ # @private
6
+ class ProtocolPath
7
+ # ProtocolPath contains all the addressable information that is required
8
+ # for the protocol.
9
+ # It handles the conversion between raw binary and hex strings
10
+ # as well as raw tags_field to tags array
11
+ include Utilities
12
+
13
+ attr_accessor :raw_site, :raw_target, :tagged
14
+
15
+ def initialize(raw_site: "\x00".b * 6, raw_target: "\x00".b * 8, tagged: false,
16
+ site_id: nil, device_id: nil, tag_ids: nil)
17
+ self.raw_site = raw_site
18
+ self.raw_target = raw_target
19
+ self.tagged = tagged
20
+
21
+ self.site_id = site_id if site_id
22
+ self.device_id = device_id if device_id
23
+ self.tag_ids = tag_ids if tag_ids
24
+ end
25
+
26
+ def site_id
27
+ raw_site.unpack('H*').join
28
+ end
29
+
30
+ def site_id=(value)
31
+ self.raw_site = [value].pack('H12').b
32
+ end
33
+
34
+ def device_id
35
+ if !tagged?
36
+ raw_target[0...6].unpack('H*').join
37
+ else
38
+ nil
39
+ end
40
+ end
41
+
42
+ def device_id=(value)
43
+ self.raw_target = [value].pack('H16').b
44
+ self.tagged = false
45
+ end
46
+
47
+ def tag_ids
48
+ if tagged?
49
+ tag_ids_from_field(tags_field)
50
+ else
51
+ nil
52
+ end
53
+ end
54
+
55
+ def tag_ids=(values)
56
+ self.tags_field = values.reduce(0) do |value, tag_id|
57
+ value |= 2 ** tag_id
58
+ end
59
+ end
60
+
61
+ def tagged?
62
+ !!tagged
63
+ end
64
+
65
+ def all_sites?
66
+ site_id == "000000000000"
67
+ end
68
+
69
+ def all_tags?
70
+ tagged? && tag_ids.empty?
71
+ end
72
+
73
+ protected
74
+
75
+ def tags_field
76
+ raw_target.unpack('Q').first
77
+ end
78
+
79
+ def tags_field=(value)
80
+ self.raw_target = [value].pack('Q').b
81
+ self.tagged = true
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,12 @@
1
+ module LIFX
2
+ module LAN
3
+ module RequiredKeywordArguments
4
+ def required!(name)
5
+ backtrace = caller_locations(1).map { |c| c.to_s }
6
+ ex = ArgumentError.new("Missing required keyword argument '#{name}'")
7
+ ex.set_backtrace(backtrace)
8
+ raise ex
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,114 @@
1
+ require 'lifx/lan/routing_table'
2
+ require 'lifx/lan/tag_table'
3
+ require 'lifx/lan/utilities'
4
+ require 'weakref'
5
+
6
+ module LIFX
7
+ module LAN
8
+ # @private
9
+ class RoutingManager
10
+ include Logging
11
+ include Utilities
12
+ include RequiredKeywordArguments
13
+
14
+ # RoutingManager manages a routing table of site <-> device
15
+ # It can resolve a target to ProtocolPaths and manages the TagTable
16
+
17
+ attr_reader :context, :tag_table, :routing_table
18
+
19
+ STALE_ROUTING_TABLE_PURGE_INTERVAL = 60
20
+
21
+ def initialize(context: required!(:context))
22
+ @context = WeakRef.new(context)
23
+ @routing_table = RoutingTable.new
24
+ @tag_table = TagTable.new
25
+ @last_refresh_seen = {}
26
+ @context.timers.every(STALE_ROUTING_TABLE_PURGE_INTERVAL) do
27
+ routing_table.clear_stale_entries
28
+ end
29
+ end
30
+
31
+ def resolve_target(target)
32
+ if target.tag?
33
+ @tag_table.entries_with(label: target.tag).map do |entry|
34
+ ProtocolPath.new(site_id: entry.site_id, tag_ids: [entry.tag_id])
35
+ end
36
+ elsif target.broadcast?
37
+ [ProtocolPath.new(site_id: "\x00".b * 6, tag_ids: [])]
38
+ elsif target.site_id && target.device_id.nil?
39
+ [ProtocolPath.new(site_id: target.site_id, tag_ids: [])]
40
+ else
41
+ [ProtocolPath.new(site_id: target.site_id, device_id: target.device_id)]
42
+ end
43
+ end
44
+
45
+ def tags_for_device_id(device_id)
46
+ entry = @routing_table.entry_for_device_id(device_id)
47
+ return [] if entry.nil?
48
+ entry.tag_ids.map do |tag_id|
49
+ tag = @tag_table.entry_with(device_id: entry.device_id, tag_id: tag_id)
50
+ tag && tag.label
51
+ end.compact
52
+ end
53
+
54
+ def update_from_message(message)
55
+ return if message.site_id == NULL_SITE_ID
56
+ if message.tagged?
57
+ case message.payload
58
+ when Protocol::Light::Get
59
+ if message.path.all_tags?
60
+ @last_refresh_seen[message.device_id] = Time.now
61
+ end
62
+ end
63
+ return
64
+ end
65
+
66
+ payload = message.payload
67
+ if !@routing_table.device_ids.include?(message.device_id)
68
+ # New device detected, fire refresh events
69
+ refresh_site(message.site_id, message.device_id)
70
+ end
71
+ case payload
72
+ when Protocol::Device::StateTagLabels
73
+ tag_ids = tag_ids_from_field(payload.tags)
74
+ if payload.label.empty?
75
+ tag_ids.each do |tag_id|
76
+ @tag_table.delete_entries_with(device_id: message.device_id, tag_id: tag_id)
77
+ end
78
+ else
79
+ @tag_table.update_table(device_id: message.device_id, tag_id: tag_ids.first, label: payload.label.to_s.force_encoding('utf-8'))
80
+ end
81
+ when Protocol::Device::StateTags, Protocol::Light::State
82
+ @routing_table.update_table(site_id: message.site_id,
83
+ device_id: message.device_id,
84
+ tag_ids: tag_ids_from_field(message.payload.tags))
85
+ when Protocol::Device::StateService, Protocol::Device::StatePower
86
+ @routing_table.update_table(site_id: message.site_id, device_id: message.device_id)
87
+ end
88
+ end
89
+
90
+ MINIMUM_REFRESH_INTERVAL = 1
91
+ def refresh(force: false)
92
+ @routing_table.device_ids.each do |device_id|
93
+ next if (seen = @last_refresh_seen[device_id]) && Time.now - seen < MINIMUM_REFRESH_INTERVAL && !force
94
+ site_id = @routing_table.site_id_for_device_id(device_id)
95
+ refresh_site(site_id, device_id)
96
+ end
97
+ end
98
+
99
+ def refresh_site(site_id, device_id)
100
+ get_lights(site_id, device_id)
101
+ get_tag_labels(site_id, device_id)
102
+ end
103
+
104
+ def get_lights(site_id, device_id)
105
+ context.send_message(target: Target.new(site_id: site_id, device_id: device_id), payload: Protocol::Light::Get.new)
106
+ end
107
+
108
+ UINT64_MAX = 2 ** 64 - 1
109
+ def get_tag_labels(site_id, device_id)
110
+ context.send_message(target: Target.new(site_id: site_id, device_id: device_id), payload: Protocol::Device::GetTagLabels.new(tags: UINT64_MAX))
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,48 @@
1
+ module LIFX
2
+ module LAN
3
+ # @private
4
+ class RoutingTable
5
+ include Logging
6
+
7
+ class Entry < Struct.new(:site_id, :device_id, :tag_ids, :last_seen); end
8
+ # RoutingTable stores the device <-> site mapping
9
+ def initialize(entries: {})
10
+ @device_site_mapping = entries
11
+ end
12
+
13
+ def update_table(site_id: self.site_id, device_id: self.device_id, tag_ids: nil, last_seen: Time.now)
14
+ device_mapping = @device_site_mapping[device_id] ||= Entry.new(site_id, device_id, [])
15
+ device_mapping.site_id = site_id
16
+ device_mapping.last_seen = last_seen
17
+ device_mapping.tag_ids = tag_ids if tag_ids
18
+ end
19
+
20
+ def entry_for_device_id(device_id)
21
+ @device_site_mapping[device_id]
22
+ end
23
+
24
+ def site_id_for_device_id(device_id)
25
+ entry = entry_for_device_id(device_id)
26
+ entry ? entry.site_id : nil
27
+ end
28
+
29
+ def site_ids
30
+ entries.map(&:site_id).uniq
31
+ end
32
+
33
+ def device_ids
34
+ entries.map(&:device_id).uniq
35
+ end
36
+
37
+ def entries
38
+ @device_site_mapping.values
39
+ end
40
+
41
+ def clear_stale_entries(threshold: 60 * 5)
42
+ @device_site_mapping.reject! do |device_id, entry|
43
+ entry.last_seen < Time.now - threshold
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,25 @@
1
+ module LIFX
2
+ module LAN
3
+ module Seen
4
+ # Returns the time when the device was last seen.
5
+ # @return [Time]
6
+ def last_seen
7
+ @last_seen
8
+ end
9
+
10
+ # Returns the number of seconds since the device was last seen.
11
+ # If the device hasn't been seen yet, it will use Unix epoch as
12
+ # the time it was seen.
13
+ # @return [Float]
14
+ def seconds_since_seen
15
+ Time.now - (last_seen || Time.at(0))
16
+ end
17
+
18
+ # Marks the device as being seen.
19
+ # @private
20
+ def seen!
21
+ @last_seen = Time.now
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,97 @@
1
+ require 'lifx/lan/seen'
2
+ require 'lifx/lan/timers'
3
+ require 'lifx/lan/observable'
4
+ require 'lifx/lan/gateway_connection'
5
+
6
+ module LIFX
7
+ module LAN
8
+ # @api private
9
+ class Site
10
+ include Seen
11
+ include Timers
12
+ include Logging
13
+ include Observable
14
+ include RequiredKeywordArguments
15
+
16
+ attr_reader :id, :gateways, :tag_manager
17
+
18
+ def initialize(id: required!(:id))
19
+ @id = id
20
+ @gateways = {}
21
+ @gateways_mutex = Mutex.new
22
+ @threads = []
23
+ @threads << initialize_timer_thread
24
+ initialize_stale_gateway_check
25
+ end
26
+
27
+ def write(message)
28
+ message.path.site_id = id
29
+ @gateways.values.each do |gateway|
30
+ gateway.write(message)
31
+ end
32
+ end
33
+
34
+ def handle_message(message, ip, transport)
35
+ logger.debug("<- #{self} #{transport}: #{message}")
36
+ payload = message.payload
37
+ case payload
38
+ when Protocol::Device::StateService
39
+ @gateways_mutex.synchronize do
40
+ @gateways[message.device_id] ||= GatewayConnection.new
41
+ @gateways[message.device_id].handle_message(message, ip, transport)
42
+ @gateways[message.device_id].add_observer(self, :message_received) do |**args|
43
+ notify_observers(:message_received, **args)
44
+ end
45
+ end
46
+ end
47
+ seen!
48
+ end
49
+
50
+ def flush(**options)
51
+ @gateways.values.map do |gateway|
52
+ Thread.start do
53
+ gateway.flush(**options)
54
+ end
55
+ end.each(&:join)
56
+ end
57
+
58
+ def to_s
59
+ %Q{#<LIFX::LAN::Site id=#{id}>}
60
+ end
61
+ alias_method :inspect, :to_s
62
+
63
+ def stop
64
+ @threads.each do |thread|
65
+ thread.abort
66
+ end
67
+ @gateways.values.each do |gateway|
68
+ gateway.close
69
+ end
70
+ end
71
+
72
+ def observer_callback_definition
73
+ {
74
+ message_received: -> (message: nil, ip: nil, transport: nil) {}
75
+ }
76
+ end
77
+
78
+
79
+ protected
80
+
81
+ STALE_GATEWAY_CHECK_INTERVAL = 10
82
+ def initialize_stale_gateway_check
83
+ timers.every(STALE_GATEWAY_CHECK_INTERVAL) do
84
+ @gateways_mutex.synchronize do
85
+ stale_gateways = @gateways.select do |k, v|
86
+ !v.connected?
87
+ end
88
+ stale_gateways.each do |id, _|
89
+ logger.info("#{self}: Dropping stale gateway id #{id}")
90
+ @gateways.delete(id)
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end