lifx 0.0.1 → 0.4.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.
- checksums.yaml +4 -4
- data/.yardopts +1 -0
- data/Gemfile +10 -0
- data/LICENSE.txt +1 -1
- data/README.md +71 -13
- data/Rakefile +12 -0
- data/bin/lifx-console +15 -0
- data/bin/lifx-snoop +50 -0
- data/examples/auto-off/Gemfile +3 -0
- data/examples/auto-off/auto-off.rb +35 -0
- data/examples/identify/Gemfile +3 -0
- data/examples/identify/identify.rb +70 -0
- data/examples/travis-build-light/Gemfile +4 -0
- data/examples/travis-build-light/build-light.rb +57 -0
- data/lib/bindata_ext/bool.rb +29 -0
- data/lib/bindata_ext/record.rb +11 -0
- data/lib/lifx/client.rb +136 -0
- data/lib/lifx/color.rb +190 -0
- data/lib/lifx/config.rb +12 -0
- data/lib/lifx/firmware.rb +55 -0
- data/lib/lifx/gateway_connection.rb +177 -0
- data/lib/lifx/light.rb +406 -0
- data/lib/lifx/light_collection.rb +105 -0
- data/lib/lifx/light_target.rb +189 -0
- data/lib/lifx/logging.rb +11 -0
- data/lib/lifx/message.rb +166 -0
- data/lib/lifx/network_context.rb +200 -0
- data/lib/lifx/observable.rb +46 -0
- data/lib/lifx/protocol/address.rb +21 -0
- data/lib/lifx/protocol/device.rb +225 -0
- data/lib/lifx/protocol/header.rb +24 -0
- data/lib/lifx/protocol/light.rb +110 -0
- data/lib/lifx/protocol/message.rb +17 -0
- data/lib/lifx/protocol/metadata.rb +21 -0
- data/lib/lifx/protocol/payload.rb +7 -0
- data/lib/lifx/protocol/sensor.rb +29 -0
- data/lib/lifx/protocol/type.rb +134 -0
- data/lib/lifx/protocol/wan.rb +50 -0
- data/lib/lifx/protocol/wifi.rb +76 -0
- data/lib/lifx/protocol_path.rb +84 -0
- data/lib/lifx/routing_manager.rb +110 -0
- data/lib/lifx/routing_table.rb +33 -0
- data/lib/lifx/seen.rb +15 -0
- data/lib/lifx/site.rb +89 -0
- data/lib/lifx/tag_manager.rb +105 -0
- data/lib/lifx/tag_table.rb +47 -0
- data/lib/lifx/target.rb +23 -0
- data/lib/lifx/timers.rb +18 -0
- data/lib/lifx/transport/tcp.rb +81 -0
- data/lib/lifx/transport/udp.rb +67 -0
- data/lib/lifx/transport.rb +41 -0
- data/lib/lifx/transport_manager/lan.rb +140 -0
- data/lib/lifx/transport_manager.rb +34 -0
- data/lib/lifx/utilities.rb +33 -0
- data/lib/lifx/version.rb +1 -1
- data/lib/lifx.rb +15 -1
- data/lifx.gemspec +11 -7
- data/spec/color_spec.rb +45 -0
- data/spec/gateway_connection_spec.rb +32 -0
- data/spec/integration/client_spec.rb +40 -0
- data/spec/integration/light_spec.rb +43 -0
- data/spec/integration/tags_spec.rb +31 -0
- data/spec/message_spec.rb +163 -0
- data/spec/protocol_path_spec.rb +109 -0
- data/spec/routing_manager_spec.rb +22 -0
- data/spec/spec_helper.rb +52 -0
- data/spec/transport/udp_spec.rb +38 -0
- data/spec/transport_spec.rb +14 -0
- metadata +143 -26
    
        data/lib/lifx/site.rb
    ADDED
    
    | @@ -0,0 +1,89 @@ | |
| 1 | 
            +
            require 'lifx/seen'
         | 
| 2 | 
            +
            require 'lifx/timers'
         | 
| 3 | 
            +
            require 'lifx/observable'
         | 
| 4 | 
            +
            require 'lifx/gateway_connection'
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            module LIFX
         | 
| 7 | 
            +
              # @api private
         | 
| 8 | 
            +
              class Site
         | 
| 9 | 
            +
                include Seen
         | 
| 10 | 
            +
                include Timers
         | 
| 11 | 
            +
                include Logging
         | 
| 12 | 
            +
                include Observable
         | 
| 13 | 
            +
                
         | 
| 14 | 
            +
                attr_reader :id, :gateways, :tag_manager
         | 
| 15 | 
            +
             | 
| 16 | 
            +
                def initialize(id:)
         | 
| 17 | 
            +
                  @id            = id
         | 
| 18 | 
            +
                  @gateways      = {}
         | 
| 19 | 
            +
                  @gateways_mutex = Mutex.new
         | 
| 20 | 
            +
                  @threads       = []
         | 
| 21 | 
            +
                  @threads << initialize_timer_thread
         | 
| 22 | 
            +
                  initialize_stale_gateway_check
         | 
| 23 | 
            +
                end
         | 
| 24 | 
            +
             | 
| 25 | 
            +
                def write(message)
         | 
| 26 | 
            +
                  message.path.site_id = id
         | 
| 27 | 
            +
                  @gateways.values.each do |gateway|
         | 
| 28 | 
            +
                    gateway.write(message)
         | 
| 29 | 
            +
                  end
         | 
| 30 | 
            +
                end
         | 
| 31 | 
            +
             | 
| 32 | 
            +
                def handle_message(message, ip, transport)
         | 
| 33 | 
            +
                  logger.debug("<- #{self} #{transport}: #{message}")
         | 
| 34 | 
            +
                  payload = message.payload
         | 
| 35 | 
            +
                  case payload
         | 
| 36 | 
            +
                  when Protocol::Device::StatePanGateway
         | 
| 37 | 
            +
                    @gateways_mutex.synchronize do
         | 
| 38 | 
            +
                      @gateways[message.device_id] ||= GatewayConnection.new
         | 
| 39 | 
            +
                      @gateways[message.device_id].handle_message(message, ip, transport)
         | 
| 40 | 
            +
                      @gateways[message.device_id].add_observer(self) do |**args|
         | 
| 41 | 
            +
                        notify_observers(**args)
         | 
| 42 | 
            +
                      end
         | 
| 43 | 
            +
                    end
         | 
| 44 | 
            +
                  end
         | 
| 45 | 
            +
                  seen!
         | 
| 46 | 
            +
                end
         | 
| 47 | 
            +
             | 
| 48 | 
            +
                def flush(**options)
         | 
| 49 | 
            +
                  @gateways.values.map do |gateway|
         | 
| 50 | 
            +
                    Thread.new do
         | 
| 51 | 
            +
                      gateway.flush(**options)
         | 
| 52 | 
            +
                    end
         | 
| 53 | 
            +
                  end.each(&:join)
         | 
| 54 | 
            +
                end
         | 
| 55 | 
            +
                
         | 
| 56 | 
            +
                def to_s
         | 
| 57 | 
            +
                  %Q{#<LIFX::Site id=#{id}>}
         | 
| 58 | 
            +
                end
         | 
| 59 | 
            +
                alias_method :inspect, :to_s
         | 
| 60 | 
            +
             | 
| 61 | 
            +
                def stop
         | 
| 62 | 
            +
                  @threads.each do |thread|
         | 
| 63 | 
            +
                    Thread.kill(thread)
         | 
| 64 | 
            +
                  end
         | 
| 65 | 
            +
                  @gateways.values.each do |gateway|
         | 
| 66 | 
            +
                    gateway.close
         | 
| 67 | 
            +
                  end
         | 
| 68 | 
            +
                end
         | 
| 69 | 
            +
             | 
| 70 | 
            +
             | 
| 71 | 
            +
                protected
         | 
| 72 | 
            +
             | 
| 73 | 
            +
                STALE_GATEWAY_CHECK_INTERVAL = 10
         | 
| 74 | 
            +
                def initialize_stale_gateway_check
         | 
| 75 | 
            +
                  timers.every(STALE_GATEWAY_CHECK_INTERVAL) do
         | 
| 76 | 
            +
                    @gateways_mutex.synchronize do
         | 
| 77 | 
            +
                      stale_gateways = @gateways.select do |k, v|
         | 
| 78 | 
            +
                        !v.connected?
         | 
| 79 | 
            +
                      end
         | 
| 80 | 
            +
                      stale_gateways.each do |id, _|
         | 
| 81 | 
            +
                        logger.info("#{self}: Dropping stale gateway id #{id}")
         | 
| 82 | 
            +
                        @gateways.delete(id)
         | 
| 83 | 
            +
                      end
         | 
| 84 | 
            +
                    end
         | 
| 85 | 
            +
                  end
         | 
| 86 | 
            +
                end
         | 
| 87 | 
            +
             | 
| 88 | 
            +
              end
         | 
| 89 | 
            +
            end
         | 
| @@ -0,0 +1,105 @@ | |
| 1 | 
            +
            module LIFX
         | 
| 2 | 
            +
              # @api private
         | 
| 3 | 
            +
              class TagManager
         | 
| 4 | 
            +
                # TagManager handles discovery of tags, resolving tags to [site_id, tags_field] pairs,
         | 
| 5 | 
            +
                # creating, setting and removing tags.
         | 
| 6 | 
            +
             | 
| 7 | 
            +
                # Stores site <-> [tag_name, tag_id]
         | 
| 8 | 
            +
                include Utilities
         | 
| 9 | 
            +
                include Logging
         | 
| 10 | 
            +
             | 
| 11 | 
            +
                attr_reader :context
         | 
| 12 | 
            +
             | 
| 13 | 
            +
                class TagLimitReached < StandardError; end
         | 
| 14 | 
            +
             | 
| 15 | 
            +
                def initialize(context:, tag_table:)
         | 
| 16 | 
            +
                  @context = context
         | 
| 17 | 
            +
                  @tag_table = tag_table
         | 
| 18 | 
            +
                end
         | 
| 19 | 
            +
             | 
| 20 | 
            +
                def create_tag(label:, site_id:)
         | 
| 21 | 
            +
                  id = next_unused_id_on_site_id(site_id)
         | 
| 22 | 
            +
                  raise TagLimitReached if id.nil?
         | 
| 23 | 
            +
                  # Add the entry for the tag we're about to create to prevent a case where
         | 
| 24 | 
            +
                  # we don't receive a StateTagLabels before another tag gets created
         | 
| 25 | 
            +
                  @tag_table.update_table(tag_id: id, label: label, site_id: site_id)
         | 
| 26 | 
            +
                  context.send_message(target: Target.new(site_id: site_id),
         | 
| 27 | 
            +
                                       payload: Protocol::Device::SetTagLabels.new(tags: id_to_tags_field(id), label: label))
         | 
| 28 | 
            +
                end
         | 
| 29 | 
            +
             | 
| 30 | 
            +
                def add_tag_to_device(tag:, device:)
         | 
| 31 | 
            +
                  tag_entry = entry_with(label: tag, site_id: device.site_id)
         | 
| 32 | 
            +
                  if !tag_entry
         | 
| 33 | 
            +
                    create_tag(label: tag, site_id: device.site_id)
         | 
| 34 | 
            +
                    tag_entry = entry_with(label: tag, site_id: device.site_id)
         | 
| 35 | 
            +
                  end
         | 
| 36 | 
            +
             | 
| 37 | 
            +
                  device_tags_field = device.tags_field
         | 
| 38 | 
            +
                  device_tags_field |= id_to_tags_field(tag_entry.tag_id)
         | 
| 39 | 
            +
                  device.send_message!(Protocol::Device::SetTags.new(tags: device_tags_field), wait_for: Protocol::Device::StateTags) do
         | 
| 40 | 
            +
                    device.tags.include?(tag)
         | 
| 41 | 
            +
                  end
         | 
| 42 | 
            +
                end
         | 
| 43 | 
            +
             | 
| 44 | 
            +
                def remove_tag_from_device(tag:, device:)
         | 
| 45 | 
            +
                  tag_entry = entry_with(label: tag, site_id: device.site_id)
         | 
| 46 | 
            +
                  return if !tag_entry
         | 
| 47 | 
            +
             | 
| 48 | 
            +
                  device_tags_field = device.tags_field
         | 
| 49 | 
            +
                  device_tags_field &= ~id_to_tags_field(tag_entry.tag_id)
         | 
| 50 | 
            +
                  device.send_message!(Protocol::Device::SetTags.new(tags: device_tags_field), wait_for: Protocol::Device::StateTags) do
         | 
| 51 | 
            +
                    !device.tags.include?(tag)
         | 
| 52 | 
            +
                  end
         | 
| 53 | 
            +
                end
         | 
| 54 | 
            +
             | 
| 55 | 
            +
                def tags
         | 
| 56 | 
            +
                  @tag_table.tags
         | 
| 57 | 
            +
                end
         | 
| 58 | 
            +
             | 
| 59 | 
            +
                def unused_tags
         | 
| 60 | 
            +
                  @tag_table.tags.select do |tag|
         | 
| 61 | 
            +
                    context.lights.with_tag(tag).empty?
         | 
| 62 | 
            +
                  end
         | 
| 63 | 
            +
                end
         | 
| 64 | 
            +
             | 
| 65 | 
            +
                # This will clear out tags that currently do not resolve to any devices.
         | 
| 66 | 
            +
                # If used when devices that are tagged with a tag that is not attached to an
         | 
| 67 | 
            +
                # active device, it will effectively untag them when they're back on.
         | 
| 68 | 
            +
                def purge_unused_tags!
         | 
| 69 | 
            +
                  unused_tags.each do |tag|
         | 
| 70 | 
            +
                    logger.info("Purging tag '#{tag}'")
         | 
| 71 | 
            +
                    entries_with(label: tag).each do |entry|
         | 
| 72 | 
            +
                      payload = Protocol::Device::SetTagLabels.new(tags: id_to_tags_field(entry.tag_id), label: '')
         | 
| 73 | 
            +
                      context.send_message(target: Target.new(site_id: entry.site_id),
         | 
| 74 | 
            +
                                           payload: payload,
         | 
| 75 | 
            +
                                           acknowledge: true)
         | 
| 76 | 
            +
                    end
         | 
| 77 | 
            +
                  end
         | 
| 78 | 
            +
                  Timeout.timeout(5) do
         | 
| 79 | 
            +
                    while !unused_tags.empty?
         | 
| 80 | 
            +
                      sleep 0.1
         | 
| 81 | 
            +
                    end
         | 
| 82 | 
            +
                  end
         | 
| 83 | 
            +
                end
         | 
| 84 | 
            +
                
         | 
| 85 | 
            +
                protected
         | 
| 86 | 
            +
             | 
| 87 | 
            +
                VALID_TAG_IDS = (0...64).to_a.freeze
         | 
| 88 | 
            +
             | 
| 89 | 
            +
                def entry_with(**args)
         | 
| 90 | 
            +
                  entries_with(**args).first
         | 
| 91 | 
            +
                end
         | 
| 92 | 
            +
             | 
| 93 | 
            +
                def entries_with(**args)
         | 
| 94 | 
            +
                  @tag_table.entries_with(**args)
         | 
| 95 | 
            +
                end
         | 
| 96 | 
            +
             | 
| 97 | 
            +
                def id_to_tags_field(id)
         | 
| 98 | 
            +
                  2 ** id
         | 
| 99 | 
            +
                end
         | 
| 100 | 
            +
             | 
| 101 | 
            +
                def next_unused_id_on_site_id(site_id)
         | 
| 102 | 
            +
                  (VALID_TAG_IDS - entries_with(site_id: site_id).map(&:tag_id)).first
         | 
| 103 | 
            +
                end
         | 
| 104 | 
            +
              end
         | 
| 105 | 
            +
            end
         | 
| @@ -0,0 +1,47 @@ | |
| 1 | 
            +
            module LIFX
         | 
| 2 | 
            +
              # @api private
         | 
| 3 | 
            +
              class TagTable
         | 
| 4 | 
            +
                class Entry < Struct.new(:tag_id, :label, :site_id); end
         | 
| 5 | 
            +
             | 
| 6 | 
            +
                def initialize(entries: {})
         | 
| 7 | 
            +
                  @entries = Hash.new { |h, k| h[k] = {} }
         | 
| 8 | 
            +
                  entries.each do |k, v|
         | 
| 9 | 
            +
                    @entries[k] = v
         | 
| 10 | 
            +
                  end
         | 
| 11 | 
            +
                end
         | 
| 12 | 
            +
             | 
| 13 | 
            +
                def entries_with(tag_id: nil, site_id: nil, label: nil)
         | 
| 14 | 
            +
                  entries.select do |entry|
         | 
| 15 | 
            +
                    ret = []
         | 
| 16 | 
            +
                    ret << (entry.tag_id == tag_id) if tag_id
         | 
| 17 | 
            +
                    ret << (entry.site_id == site_id) if site_id
         | 
| 18 | 
            +
                    ret << (entry.label == label) if label
         | 
| 19 | 
            +
                    ret.all?
         | 
| 20 | 
            +
                  end
         | 
| 21 | 
            +
                end
         | 
| 22 | 
            +
             | 
| 23 | 
            +
                def entry_with(**args)
         | 
| 24 | 
            +
                  entries_with(**args).first
         | 
| 25 | 
            +
                end
         | 
| 26 | 
            +
             | 
| 27 | 
            +
                def update_table(tag_id:, label:, site_id:)
         | 
| 28 | 
            +
                  entry = @entries[site_id][tag_id] ||= Entry.new(tag_id, label, site_id)
         | 
| 29 | 
            +
                  entry.label = label
         | 
| 30 | 
            +
                end
         | 
| 31 | 
            +
             | 
| 32 | 
            +
                def delete_entries_with(tag_id: nil, site_id: nil, label: nil)
         | 
| 33 | 
            +
                  matching_entries = entries_with(tag_id: tag_id, site_id: site_id, label: label)
         | 
| 34 | 
            +
                  matching_entries.each do |entry|
         | 
| 35 | 
            +
                    @entries[entry.site_id].delete(entry.tag_id)
         | 
| 36 | 
            +
                  end
         | 
| 37 | 
            +
                end
         | 
| 38 | 
            +
             | 
| 39 | 
            +
                def tags
         | 
| 40 | 
            +
                  entries.map(&:label).uniq
         | 
| 41 | 
            +
                end
         | 
| 42 | 
            +
             | 
| 43 | 
            +
                def entries
         | 
| 44 | 
            +
                  @entries.values.map(&:values).flatten
         | 
| 45 | 
            +
                end
         | 
| 46 | 
            +
              end
         | 
| 47 | 
            +
            end
         | 
    
        data/lib/lifx/target.rb
    ADDED
    
    | @@ -0,0 +1,23 @@ | |
| 1 | 
            +
            module LIFX
         | 
| 2 | 
            +
              # @api private
         | 
| 3 | 
            +
             | 
| 4 | 
            +
              class Target
         | 
| 5 | 
            +
                # Target is a high-level abstraction for the target of a Message
         | 
| 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
         | 
    
        data/lib/lifx/timers.rb
    ADDED
    
    | @@ -0,0 +1,18 @@ | |
| 1 | 
            +
            require 'timers'
         | 
| 2 | 
            +
            module LIFX
         | 
| 3 | 
            +
              module Timers
         | 
| 4 | 
            +
                protected
         | 
| 5 | 
            +
                def initialize_timer_thread
         | 
| 6 | 
            +
                  timers.after(1) {} # Just so timers.wait doesn't complain when there's no timer
         | 
| 7 | 
            +
                  Thread.new do
         | 
| 8 | 
            +
                    loop do
         | 
| 9 | 
            +
                      timers.wait
         | 
| 10 | 
            +
                    end
         | 
| 11 | 
            +
                  end
         | 
| 12 | 
            +
                end
         | 
| 13 | 
            +
             | 
| 14 | 
            +
                def timers
         | 
| 15 | 
            +
                  @timers ||= ::Timers.new
         | 
| 16 | 
            +
                end
         | 
| 17 | 
            +
              end
         | 
| 18 | 
            +
            end
         | 
| @@ -0,0 +1,81 @@ | |
| 1 | 
            +
            require 'socket'
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module LIFX
         | 
| 4 | 
            +
              class Transport
         | 
| 5 | 
            +
                # @api private
         | 
| 6 | 
            +
                class TCP < Transport
         | 
| 7 | 
            +
                  include Logging
         | 
| 8 | 
            +
             | 
| 9 | 
            +
                  def initialize(*args)
         | 
| 10 | 
            +
                    super
         | 
| 11 | 
            +
                    connect
         | 
| 12 | 
            +
                  end
         | 
| 13 | 
            +
             | 
| 14 | 
            +
                  def connected?
         | 
| 15 | 
            +
                    @socket && !@socket.closed?
         | 
| 16 | 
            +
                  end
         | 
| 17 | 
            +
             | 
| 18 | 
            +
                  CONNECT_TIMEOUT = 3
         | 
| 19 | 
            +
                  def connect
         | 
| 20 | 
            +
                    Timeout.timeout(CONNECT_TIMEOUT) do
         | 
| 21 | 
            +
                      @socket = TCPSocket.new(host, port) # Performs the connection
         | 
| 22 | 
            +
                    end
         | 
| 23 | 
            +
                    @socket.setsockopt(Socket::SOL_SOCKET,  Socket::SO_SNDBUF,    1024)
         | 
| 24 | 
            +
                    @socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY,  1)
         | 
| 25 | 
            +
                    logger.info("#{self}: Connected.")
         | 
| 26 | 
            +
                  rescue => ex
         | 
| 27 | 
            +
                    logger.warn("#{self}: Exception occured in #connect - #{ex}")
         | 
| 28 | 
            +
                    logger.debug("#{self}: Backtrace: #{ex.backtrace.join("\n")}")
         | 
| 29 | 
            +
                    @socket = nil
         | 
| 30 | 
            +
                  end
         | 
| 31 | 
            +
             | 
| 32 | 
            +
                  def close
         | 
| 33 | 
            +
                    return if !@socket
         | 
| 34 | 
            +
                    Thread.kill(@listener) if @listener
         | 
| 35 | 
            +
                    @listener = nil
         | 
| 36 | 
            +
                    @socket.close if !@socket.closed?
         | 
| 37 | 
            +
                    @socket = nil
         | 
| 38 | 
            +
                  end
         | 
| 39 | 
            +
             | 
| 40 | 
            +
                  HEADER_SIZE = 8
         | 
| 41 | 
            +
                  def listen
         | 
| 42 | 
            +
                    return if @listener
         | 
| 43 | 
            +
                    @listener = Thread.new do
         | 
| 44 | 
            +
                      while @socket do
         | 
| 45 | 
            +
                        begin
         | 
| 46 | 
            +
                          header_data = @socket.recv(HEADER_SIZE, Socket::MSG_PEEK)
         | 
| 47 | 
            +
                          header      = Protocol::Header.read(header_data)
         | 
| 48 | 
            +
                          size        = header.msg_size
         | 
| 49 | 
            +
                          data        = @socket.recv(size)
         | 
| 50 | 
            +
                          message     = Message.unpack(data)
         | 
| 51 | 
            +
                          
         | 
| 52 | 
            +
                          notify_observers(message: message, ip: host, transport: self)
         | 
| 53 | 
            +
                        rescue Message::UnpackError
         | 
| 54 | 
            +
                          if !@ignore_unpackable_messages
         | 
| 55 | 
            +
                            logger.warn("#{self}: Exception occured - #{ex}")
         | 
| 56 | 
            +
                          end
         | 
| 57 | 
            +
                        rescue => ex
         | 
| 58 | 
            +
                          logger.warn("#{self}: Exception occured in #listen - #{ex}")
         | 
| 59 | 
            +
                          logger.debug("#{self}: Backtrace: #{ex.backtrace.join("\n")}")
         | 
| 60 | 
            +
                          close
         | 
| 61 | 
            +
                        end
         | 
| 62 | 
            +
                      end
         | 
| 63 | 
            +
                    end
         | 
| 64 | 
            +
                  end
         | 
| 65 | 
            +
             | 
| 66 | 
            +
                  SEND_TIMEOUT = 2
         | 
| 67 | 
            +
                  def write(message)
         | 
| 68 | 
            +
                    data = message.pack
         | 
| 69 | 
            +
                    Timeout.timeout(SEND_TIMEOUT) do
         | 
| 70 | 
            +
                      @socket.write(data)
         | 
| 71 | 
            +
                    end
         | 
| 72 | 
            +
                    true
         | 
| 73 | 
            +
                  rescue => ex
         | 
| 74 | 
            +
                    logger.warn("#{self}: Exception in #write: #{ex}")
         | 
| 75 | 
            +
                    logger.debug("#{self}: Backtrace: #{ex.backtrace.join("\n")}")
         | 
| 76 | 
            +
                    close
         | 
| 77 | 
            +
                    false
         | 
| 78 | 
            +
                  end
         | 
| 79 | 
            +
                end
         | 
| 80 | 
            +
              end
         | 
| 81 | 
            +
            end
         | 
| @@ -0,0 +1,67 @@ | |
| 1 | 
            +
            require 'socket'
         | 
| 2 | 
            +
            module LIFX
         | 
| 3 | 
            +
              class Transport
         | 
| 4 | 
            +
                # @api private
         | 
| 5 | 
            +
                class UDP < Transport
         | 
| 6 | 
            +
                  def initialize(*args)
         | 
| 7 | 
            +
                    super
         | 
| 8 | 
            +
                    @socket = create_socket
         | 
| 9 | 
            +
                  end
         | 
| 10 | 
            +
             | 
| 11 | 
            +
                  def connected?
         | 
| 12 | 
            +
                    !!@socket
         | 
| 13 | 
            +
                  end
         | 
| 14 | 
            +
             | 
| 15 | 
            +
                  def write(message)
         | 
| 16 | 
            +
                    data = message.pack
         | 
| 17 | 
            +
                    @socket.send(data, 0, host, port)
         | 
| 18 | 
            +
                    true
         | 
| 19 | 
            +
                  rescue => ex
         | 
| 20 | 
            +
                    logger.warn("#{self}: Error on #write: #{ex}")
         | 
| 21 | 
            +
                    logger.debug("#{self}: Backtrace: #{ex.backtrace.join("\n")}")
         | 
| 22 | 
            +
                    close
         | 
| 23 | 
            +
                    false
         | 
| 24 | 
            +
                  end
         | 
| 25 | 
            +
             | 
| 26 | 
            +
                  def listen(ip: self.host, port: self.port)
         | 
| 27 | 
            +
                    if @listener
         | 
| 28 | 
            +
                      raise "Socket already being listened to"
         | 
| 29 | 
            +
                    end
         | 
| 30 | 
            +
                    
         | 
| 31 | 
            +
                    Thread.abort_on_exception = true
         | 
| 32 | 
            +
                    @listener = Thread.new do
         | 
| 33 | 
            +
                      reader = UDPSocket.new
         | 
| 34 | 
            +
                      reader.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, true)
         | 
| 35 | 
            +
                      reader.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEPORT, true)
         | 
| 36 | 
            +
                      reader.bind(ip, port)
         | 
| 37 | 
            +
                      loop do
         | 
| 38 | 
            +
                        begin
         | 
| 39 | 
            +
                          bytes, (_, _, ip, _) = reader.recvfrom(128)
         | 
| 40 | 
            +
                          message = Message.unpack(bytes)
         | 
| 41 | 
            +
                          notify_observers(message: message, ip: ip, transport: self)
         | 
| 42 | 
            +
                        rescue Message::UnpackError
         | 
| 43 | 
            +
                          if !@ignore_unpackable_messages
         | 
| 44 | 
            +
                            logger.warn("#{self}: Unrecognised bytes: #{bytes.bytes.map { |b| '%02x ' % b }.join}")
         | 
| 45 | 
            +
                          end
         | 
| 46 | 
            +
                        end
         | 
| 47 | 
            +
                      end
         | 
| 48 | 
            +
                    end
         | 
| 49 | 
            +
                  end
         | 
| 50 | 
            +
             | 
| 51 | 
            +
                  def close
         | 
| 52 | 
            +
                    Thread.kill(@listener) if @listener
         | 
| 53 | 
            +
                    return if !@socket
         | 
| 54 | 
            +
                    @socket.close
         | 
| 55 | 
            +
                    @socket = nil
         | 
| 56 | 
            +
                  end
         | 
| 57 | 
            +
             | 
| 58 | 
            +
                  protected
         | 
| 59 | 
            +
             | 
| 60 | 
            +
                  def create_socket
         | 
| 61 | 
            +
                    UDPSocket.new.tap do |socket|
         | 
| 62 | 
            +
                      socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_BROADCAST, true)
         | 
| 63 | 
            +
                    end
         | 
| 64 | 
            +
                  end
         | 
| 65 | 
            +
                end
         | 
| 66 | 
            +
              end
         | 
| 67 | 
            +
            end
         | 
| @@ -0,0 +1,41 @@ | |
| 1 | 
            +
            require 'lifx/observable'
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module LIFX
         | 
| 4 | 
            +
              # @api private
         | 
| 5 | 
            +
              class Transport
         | 
| 6 | 
            +
                include Logging
         | 
| 7 | 
            +
                include Observable
         | 
| 8 | 
            +
                
         | 
| 9 | 
            +
                attr_reader :host, :port
         | 
| 10 | 
            +
             | 
| 11 | 
            +
                def initialize(host, port, ignore_unpackable_messages: true)
         | 
| 12 | 
            +
                  @host = host
         | 
| 13 | 
            +
                  @port = port
         | 
| 14 | 
            +
                  @ignore_unpackable_messages = ignore_unpackable_messages
         | 
| 15 | 
            +
                end
         | 
| 16 | 
            +
             | 
| 17 | 
            +
                def listen
         | 
| 18 | 
            +
                  raise NotImplementedError
         | 
| 19 | 
            +
                end
         | 
| 20 | 
            +
             | 
| 21 | 
            +
                def write(message)
         | 
| 22 | 
            +
                  raise NotImplementedError
         | 
| 23 | 
            +
                end
         | 
| 24 | 
            +
             | 
| 25 | 
            +
                def close
         | 
| 26 | 
            +
                  raise NotImplementedError
         | 
| 27 | 
            +
                end
         | 
| 28 | 
            +
             | 
| 29 | 
            +
                def to_s
         | 
| 30 | 
            +
                  %Q{#<#{self.class.name} #{host}:#{port}>}
         | 
| 31 | 
            +
                end
         | 
| 32 | 
            +
                alias_method :inspect, :to_s
         | 
| 33 | 
            +
             | 
| 34 | 
            +
                def observer_callback_definition
         | 
| 35 | 
            +
                  -> (message:, ip: nil, transport: nil) {}
         | 
| 36 | 
            +
                end
         | 
| 37 | 
            +
              end
         | 
| 38 | 
            +
            end
         | 
| 39 | 
            +
             | 
| 40 | 
            +
            require 'lifx/transport/udp'
         | 
| 41 | 
            +
            require 'lifx/transport/tcp'
         | 
| @@ -0,0 +1,140 @@ | |
| 1 | 
            +
            require 'lifx/site'
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module LIFX
         | 
| 4 | 
            +
              module TransportManager
         | 
| 5 | 
            +
                class LAN < Base
         | 
| 6 | 
            +
                  def initialize(bind_ip: '0.0.0.0', send_ip: '255.255.255.255', port: 56700, peer_port: 56750)
         | 
| 7 | 
            +
                    super
         | 
| 8 | 
            +
                    @bind_ip   = bind_ip
         | 
| 9 | 
            +
                    @send_ip   = send_ip
         | 
| 10 | 
            +
                    @port      = port
         | 
| 11 | 
            +
                    @peer_port = peer_port
         | 
| 12 | 
            +
                    
         | 
| 13 | 
            +
                    @sites = {}
         | 
| 14 | 
            +
                    initialize_transports
         | 
| 15 | 
            +
                  end
         | 
| 16 | 
            +
             | 
| 17 | 
            +
                  def flush(**options)
         | 
| 18 | 
            +
                    @sites.values.map do |site|
         | 
| 19 | 
            +
                      Thread.new do
         | 
| 20 | 
            +
                        site.flush(**options)
         | 
| 21 | 
            +
                      end
         | 
| 22 | 
            +
                    end.each(&:join)
         | 
| 23 | 
            +
                  end
         | 
| 24 | 
            +
             | 
| 25 | 
            +
                  DISCOVERY_INTERVAL_WHEN_NO_SITES_FOUND = 1    # seconds
         | 
| 26 | 
            +
                  DISCOVERY_INTERVAL                     = 15   # seconds
         | 
| 27 | 
            +
                  def discover
         | 
| 28 | 
            +
                    stop_discovery
         | 
| 29 | 
            +
                    Thread.abort_on_exception = true
         | 
| 30 | 
            +
                    @discovery_thread = Thread.new do
         | 
| 31 | 
            +
                      @last_request_seen = Time.at(0)
         | 
| 32 | 
            +
                      message = Message.new(path: ProtocolPath.new(tagged: true), payload: Protocol::Device::GetPanGateway.new)
         | 
| 33 | 
            +
                      logger.info("Discovering gateways on #{@bind_ip}:#{@port}")
         | 
| 34 | 
            +
                      loop do
         | 
| 35 | 
            +
                        interval = @sites.empty? ?
         | 
| 36 | 
            +
                          DISCOVERY_INTERVAL_WHEN_NO_SITES_FOUND :
         | 
| 37 | 
            +
                          DISCOVERY_INTERVAL
         | 
| 38 | 
            +
                        if Time.now - @last_request_seen > interval
         | 
| 39 | 
            +
                          write(message)
         | 
| 40 | 
            +
                        end
         | 
| 41 | 
            +
                        sleep(interval / 2.0)
         | 
| 42 | 
            +
                      end
         | 
| 43 | 
            +
                    end
         | 
| 44 | 
            +
                  end
         | 
| 45 | 
            +
             | 
| 46 | 
            +
                  def stop_discovery
         | 
| 47 | 
            +
                    Thread.kill(@discovery_thread) if @discovery_thread
         | 
| 48 | 
            +
                  end
         | 
| 49 | 
            +
             | 
| 50 | 
            +
                  def stop
         | 
| 51 | 
            +
                    stop_discovery
         | 
| 52 | 
            +
                    @transport.close
         | 
| 53 | 
            +
                    @sites.values.each do |site|
         | 
| 54 | 
            +
                      site.stop
         | 
| 55 | 
            +
                    end
         | 
| 56 | 
            +
                  end
         | 
| 57 | 
            +
             | 
| 58 | 
            +
                  def write(message)
         | 
| 59 | 
            +
                    return unless on_network?
         | 
| 60 | 
            +
                    if message.path.all_sites?
         | 
| 61 | 
            +
                      broadcast(message)
         | 
| 62 | 
            +
                    else
         | 
| 63 | 
            +
                      site = @sites[message.path.site_id]
         | 
| 64 | 
            +
                      if site
         | 
| 65 | 
            +
                        site.write(message)
         | 
| 66 | 
            +
                      else
         | 
| 67 | 
            +
                        broadcast(message)
         | 
| 68 | 
            +
                      end
         | 
| 69 | 
            +
                    end
         | 
| 70 | 
            +
                    broadcast_to_peers(message)
         | 
| 71 | 
            +
                  end
         | 
| 72 | 
            +
             | 
| 73 | 
            +
                  def on_network?
         | 
| 74 | 
            +
                    Socket.getifaddrs.any? { |ifaddr| ifaddr.broadaddr }
         | 
| 75 | 
            +
                  end
         | 
| 76 | 
            +
             | 
| 77 | 
            +
                  def broadcast(message)
         | 
| 78 | 
            +
                    if !@transport.connected?
         | 
| 79 | 
            +
                      create_broadcast_transport
         | 
| 80 | 
            +
                    end
         | 
| 81 | 
            +
                    @transport.write(message)
         | 
| 82 | 
            +
                  end
         | 
| 83 | 
            +
             | 
| 84 | 
            +
                  def broadcast_to_peers(message)
         | 
| 85 | 
            +
                    if !@peer_transport.connected?
         | 
| 86 | 
            +
                      create_peer_transport
         | 
| 87 | 
            +
                    end
         | 
| 88 | 
            +
                    @peer_transport.write(message)
         | 
| 89 | 
            +
                  end
         | 
| 90 | 
            +
             | 
| 91 | 
            +
                  def sites
         | 
| 92 | 
            +
                    @sites.dup
         | 
| 93 | 
            +
                  end
         | 
| 94 | 
            +
             | 
| 95 | 
            +
                  def gateways
         | 
| 96 | 
            +
                    @sites.values.map(&:gateways)
         | 
| 97 | 
            +
                  end
         | 
| 98 | 
            +
             | 
| 99 | 
            +
                  protected
         | 
| 100 | 
            +
             | 
| 101 | 
            +
                  def initialize_transports
         | 
| 102 | 
            +
                    create_broadcast_transport
         | 
| 103 | 
            +
                    create_peer_transport
         | 
| 104 | 
            +
                  end
         | 
| 105 | 
            +
             | 
| 106 | 
            +
                  def create_broadcast_transport
         | 
| 107 | 
            +
                    @transport = Transport::UDP.new(@send_ip, @port)
         | 
| 108 | 
            +
                    @transport.add_observer(self) do |message:, ip:, transport:|
         | 
| 109 | 
            +
                      handle_broadcast_message(message, ip, @transport)
         | 
| 110 | 
            +
                      notify_observers(message: message, ip: ip, transport: transport)
         | 
| 111 | 
            +
                    end
         | 
| 112 | 
            +
                    @transport.listen(ip: @bind_ip)
         | 
| 113 | 
            +
                  end
         | 
| 114 | 
            +
             | 
| 115 | 
            +
                  def create_peer_transport
         | 
| 116 | 
            +
                    @peer_transport = Transport::UDP.new('255.255.255.255', @peer_port)
         | 
| 117 | 
            +
                    @peer_transport.add_observer(self) do |message:, ip:, transport:|
         | 
| 118 | 
            +
                      notify_observers(message: message, ip: ip, transport: transport)
         | 
| 119 | 
            +
                    end
         | 
| 120 | 
            +
                    @peer_transport.listen(ip: @bind_ip)
         | 
| 121 | 
            +
                  end
         | 
| 122 | 
            +
             | 
| 123 | 
            +
                  def handle_broadcast_message(message, ip, transport)
         | 
| 124 | 
            +
                    payload = message.payload
         | 
| 125 | 
            +
                    case payload
         | 
| 126 | 
            +
                    when Protocol::Device::StatePanGateway
         | 
| 127 | 
            +
                      if !@sites.has_key?(message.path.site_id)
         | 
| 128 | 
            +
                        @sites[message.path.site_id] = Site.new(id: message.path.site_id)
         | 
| 129 | 
            +
                        @sites[message.path.site_id].add_observer(self) do |**args|
         | 
| 130 | 
            +
                          notify_observers(**args)
         | 
| 131 | 
            +
                        end
         | 
| 132 | 
            +
                      end
         | 
| 133 | 
            +
                      @sites[message.path.site_id].handle_message(message, ip, transport)
         | 
| 134 | 
            +
                    when Protocol::Device::GetPanGateway
         | 
| 135 | 
            +
                      @last_request_seen = Time.now
         | 
| 136 | 
            +
                    end
         | 
| 137 | 
            +
                  end
         | 
| 138 | 
            +
                end
         | 
| 139 | 
            +
              end
         | 
| 140 | 
            +
            end
         | 
| @@ -0,0 +1,34 @@ | |
| 1 | 
            +
            require 'lifx/observable'
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module LIFX
         | 
| 4 | 
            +
              # @api private
         | 
| 5 | 
            +
              module TransportManager
         | 
| 6 | 
            +
                class Base
         | 
| 7 | 
            +
                  include Logging
         | 
| 8 | 
            +
                  include Observable
         | 
| 9 | 
            +
             | 
| 10 | 
            +
                  def initialize(**args)
         | 
| 11 | 
            +
                  end
         | 
| 12 | 
            +
             | 
| 13 | 
            +
                  def discover
         | 
| 14 | 
            +
                    raise NotImplementedError
         | 
| 15 | 
            +
                  end
         | 
| 16 | 
            +
             | 
| 17 | 
            +
                  def write(message)
         | 
| 18 | 
            +
                    raise NotImplementedError
         | 
| 19 | 
            +
                  end
         | 
| 20 | 
            +
             | 
| 21 | 
            +
                  def flush(**options)
         | 
| 22 | 
            +
                    raise NotImplementedError
         | 
| 23 | 
            +
                  end
         | 
| 24 | 
            +
             | 
| 25 | 
            +
                  def stop
         | 
| 26 | 
            +
                    raise NotImplementedError
         | 
| 27 | 
            +
                  end
         | 
| 28 | 
            +
             | 
| 29 | 
            +
             | 
| 30 | 
            +
                end
         | 
| 31 | 
            +
              end
         | 
| 32 | 
            +
            end
         | 
| 33 | 
            +
             | 
| 34 | 
            +
            require 'lifx/transport_manager/lan'
         | 
| @@ -0,0 +1,33 @@ | |
| 1 | 
            +
            module LIFX
         | 
| 2 | 
            +
              module Utilities
         | 
| 3 | 
            +
                def try_until(condition_proc, timeout_exception: Timeout::Error,
         | 
| 4 | 
            +
                                                        timeout: 3,
         | 
| 5 | 
            +
                                             condition_interval: 0.1,
         | 
| 6 | 
            +
                                                action_interval: 0.5,
         | 
| 7 | 
            +
                                                         signal: nil, &action_block)
         | 
| 8 | 
            +
                  Timeout.timeout(timeout) do
         | 
| 9 | 
            +
                    m = Mutex.new
         | 
| 10 | 
            +
                    time = 0
         | 
| 11 | 
            +
                    while !condition_proc.call
         | 
| 12 | 
            +
                      if Time.now.to_f - time > action_interval
         | 
| 13 | 
            +
                        time = Time.now.to_f
         | 
| 14 | 
            +
                        action_block.call
         | 
| 15 | 
            +
                      end
         | 
| 16 | 
            +
                      if signal
         | 
| 17 | 
            +
                        m.synchronize do
         | 
| 18 | 
            +
                          signal.wait(m, condition_interval)
         | 
| 19 | 
            +
                        end
         | 
| 20 | 
            +
                      else
         | 
| 21 | 
            +
                        sleep(condition_interval)
         | 
| 22 | 
            +
                      end
         | 
| 23 | 
            +
                    end
         | 
| 24 | 
            +
                  end
         | 
| 25 | 
            +
                rescue Timeout::Error
         | 
| 26 | 
            +
                  raise timeout_exception if timeout_exception
         | 
| 27 | 
            +
                end
         | 
| 28 | 
            +
             | 
| 29 | 
            +
                def tag_ids_from_field(tags_field)
         | 
| 30 | 
            +
                  (0...64).to_a.select { |t| (tags_field & (2 ** t)) > 0 }
         | 
| 31 | 
            +
                end
         | 
| 32 | 
            +
              end
         | 
| 33 | 
            +
            end
         | 
    
        data/lib/lifx/version.rb
    CHANGED