lifx_dash 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.
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "lifx_dash"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "gli"
4
+ require "lifx_dash"
5
+
6
+ include GLI::App
7
+
8
+ LOGGER = Logger.new(STDOUT)
9
+ config_options = LifxDash::Configuration.load
10
+
11
+ program_desc "Toggle LIFX lights with an Amazon Dash button"
12
+
13
+ version LifxDash::VERSION
14
+
15
+ subcommand_option_handling :normal
16
+ arguments :strict
17
+
18
+ desc "Set (and persist) default options for commands"
19
+ long_desc "Use config to set default values for the command options, these will
20
+ be saved to: ~/.lifx_dash.rc.yml and used as defaults. They can still be
21
+ overridden with any arguments passed on the command line."
22
+ skips_pre
23
+ command :config do |c|
24
+ c.desc "Show the config file"
25
+ c.switch [:s, :show]
26
+
27
+ c.action do |_global_options, options, _args|
28
+ LifxDash::Configuration.new.run(show_config: options["show"])
29
+ end
30
+ end
31
+
32
+
33
+ desc "Listen for Dash button presses on the network and show packet information"
34
+ long_desc "Monitor the sepcified network interface for any Dash button ARP
35
+ packets and log the device MAC address to stdout. Wait for the network to quiet
36
+ down, before pressing the button, since other devices may respond with ARP
37
+ packets of their own when you press. Take care to choose the MAC address from
38
+ the ARP packet that occurs only once from a single MAC address."
39
+ command :snoop do |c|
40
+ c.desc "Network Interface"
41
+ c.default_value config_options["iface"] || "en0"
42
+ c.flag [:i, :iface]
43
+
44
+ c.action do |_global_options, options, _args|
45
+ LifxDash::Snoop.new(options["iface"]).run
46
+ end
47
+ end
48
+
49
+ desc "Listen for a Dash button press, and the toggle selected lights"
50
+ long_desc "Monitor the sepcified network interface, filtered by a Dash button
51
+ MAC address. If a valid ARP packet is detected, the LIFX lights (identified by
52
+ the selector) will be toggled using the LIFX HTTP API (v1). You can optionally
53
+ pass a LIFX bulb selector (the bulb ID), or choose to daemonize the `monitor`
54
+ process.
55
+
56
+ You can get a free LIFX API token from here: https://cloud.lifx.com/settings
57
+
58
+ If running as a daemon (-d switch), this command will log to:
59
+ #{LifxDash::Daemonizer::LOG_FILE} by default. Use the --log-file flag to
60
+ overrride this location."
61
+ command :monitor do |c|
62
+
63
+ c.desc "Dash button MAC address"
64
+ c.default_value config_options["mac-address"]
65
+ c.flag [:m, :"mac-address"]
66
+
67
+ c.desc "LIFX HTTP API Token"
68
+ c.default_value config_options["token"]
69
+ c.flag [:t, :token]
70
+
71
+ c.desc "LIFX Bulb Selector"
72
+ c.default_value config_options["selector"] || "all"
73
+ c.flag [:s, :selector]
74
+
75
+ c.desc "Network Interface"
76
+ c.default_value config_options["iface"] || "en0"
77
+ c.flag [:i, :iface]
78
+
79
+ c.desc "Log file location (when running as a daemon)"
80
+ c.default_value config_options["log-file"] || LifxDash::Daemonizer::LOG_FILE
81
+ c.flag [:l, :"log-file"]
82
+
83
+ c.desc "Dameonize the monitor process"
84
+ c.switch [:d, :daemonize]
85
+
86
+ c.action do |_global_options, options, _args|
87
+ help_now!("a valid Dash button MAC address option (-m) is required: use `lifx_dash snoop #{options["iface"]}` to find it") unless options["mac-address"]
88
+ help_now!("a valid LIFX API Token option (-t) is required: get one from https://cloud.lifx.com/settings") unless options["token"]
89
+
90
+ if options["daemonize"]
91
+ LifxDash::Daemonizer.start(options["log-file"])
92
+ end
93
+
94
+ LifxDash::Monitor.new(
95
+ iface: options["iface"],
96
+ token: options["token"],
97
+ mac: options["mac-address"],
98
+ selector: options["selector"]
99
+ ).run
100
+ end
101
+ end
102
+
103
+ pre do |_global_options, _command, options, _args|
104
+ # check user has root access for packet sniffing
105
+ if Process.euid.zero?
106
+ true
107
+ else
108
+ help_now!("sudo required: you must be a root user to capture packets on #{options["iface"]}")
109
+ end
110
+ end
111
+
112
+ exit run(ARGV)
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ # install deps
7
+ bundle install
8
+
9
+ # generate man page and rdocs
10
+ bundle exec ronn man/lifx_dash.1.ronn
11
+ bundle exec rake rdoc
@@ -0,0 +1,17 @@
1
+ Feature: Show help information
2
+ In order to show command line help
3
+
4
+ Scenario: Show general command line help
5
+ When I get help for "lifx_dash"
6
+ Then the output should contain " lifx_dash - Toggle LIFX lights with an Amazon Dash button"
7
+ Then the output should match / config - (.*)/
8
+ Then the output should match / help - (.*)/
9
+ Then the output should match / monitor - (.*)/
10
+ Then the output should match / snoop - (.*)/
11
+ And the exit status should be 0
12
+
13
+ Scenario: Show config help
14
+ When I run `lifx_dash help config`
15
+ Then the output should contain " config - Set (and persist) default options for commands"
16
+ Then the output should contain " -s, --[no-]show - Show the config file"
17
+ And the exit status should be 0
@@ -0,0 +1,4 @@
1
+ When /^I get help for "([^"]*)"$/ do |app_name|
2
+ @app_name = app_name
3
+ step %(I run `#{app_name} help`)
4
+ end
@@ -0,0 +1 @@
1
+ require 'aruba/cucumber'
@@ -0,0 +1,13 @@
1
+ # stdlib
2
+ require "logger"
3
+
4
+ # gem
5
+ require "lifx_dash/version"
6
+ require "lifx_dash/capturer"
7
+ require "lifx_dash/daemonizer"
8
+ require "lifx_dash/lifx_http_api"
9
+
10
+ # commands
11
+ require "lifx_dash/configuration"
12
+ require "lifx_dash/monitor"
13
+ require "lifx_dash/snoop"
@@ -0,0 +1,37 @@
1
+ require "packetfu"
2
+
3
+ module LifxDash
4
+ class Capturer
5
+
6
+ attr_reader :iface
7
+
8
+ def initialize(network_iface_id)
9
+ @iface = network_iface_id
10
+ end
11
+
12
+ def listen(&block)
13
+ # examine packets on the stream
14
+ capturer.stream.each do |packet|
15
+ pkt = PacketFu::ARPPacket.parse(packet)
16
+ # parse packet header
17
+ mac = PacketFu::EthHeader.str2mac(pkt.eth_src)
18
+ # valid ARP pkt when opcode is 1
19
+ if pkt.arp_opcode == 1
20
+ block.call(pkt, mac) if block
21
+ end
22
+ end
23
+ end
24
+
25
+
26
+ private
27
+
28
+ def capturer
29
+ # filter and capture ARP packets on network interface
30
+ @capturer ||= PacketFu::Capture.new(
31
+ iface: @iface,
32
+ start: true,
33
+ filter: "arp"
34
+ )
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,68 @@
1
+ require "yaml"
2
+
3
+ module LifxDash
4
+ class Configuration
5
+
6
+ CONFIG_FILE = File.join(ENV["HOME"], ".lifx_dash.rc.yml")
7
+ OPTION_PROMPTS = {
8
+ "iface" => "Network interface identifier e.g. en0 (choose from ifconfig -l)",
9
+ "token" => "LIFX API token (get a free personal token at cloud.lifx.com)",
10
+ "mac-address" => "Dash button MAC address (use lifx_dash snoop to find it)",
11
+ "selector" => "LIFX bulb selector e.g. all or a LIFX bulb ID",
12
+ "log-file" => "Log file location (when running as a daemon)"
13
+ }
14
+
15
+ def self.load
16
+ if File.exist?(CONFIG_FILE)
17
+ YAML.load_file(CONFIG_FILE)
18
+ else
19
+ {}
20
+ end
21
+ end
22
+
23
+ def run(show_config: false)
24
+ # decide to show config or start configuring
25
+ show_config ? show : configure
26
+ end
27
+
28
+ def show
29
+ if File.exist?(CONFIG_FILE)
30
+ puts "Configuration file at #{CONFIG_FILE} ...\n\n"
31
+ puts File.read(CONFIG_FILE)
32
+ puts "\nChange these options with `lifx_dash config`"
33
+ else
34
+ puts "No configuration file exists at #{CONFIG_FILE}"
35
+ end
36
+ end
37
+
38
+ def configure
39
+ puts "Configuring lifx_dash ...\n\n"
40
+ user_options = ask_for_options
41
+
42
+ if user_options.values.all?(&:nil?)
43
+ puts "\nNo options set, configuration is unchanged"
44
+ else
45
+ File.open(CONFIG_FILE, "w") do |file|
46
+ YAML::dump(user_options, file)
47
+ end
48
+ puts "\nConfiguration saved to #{CONFIG_FILE}"
49
+ end
50
+ end
51
+
52
+ def ask_for_options
53
+ OPTION_PROMPTS.keys.reduce({}) do |acc, key|
54
+ print " * #{OPTION_PROMPTS[key]}: "
55
+ acc.merge(key => parse_user_input(STDIN.gets.strip))
56
+ end
57
+ end
58
+
59
+ def parse_user_input(str)
60
+ # handle empty strings
61
+ if str.empty?
62
+ nil
63
+ else
64
+ str
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,39 @@
1
+ module LifxDash
2
+ class Daemonizer
3
+
4
+ LOG_FILE = "/tmp/lifx_dash.log"
5
+
6
+ def self.start(log_file = LOG_FILE)
7
+ # fork process (skip IO redirect to /dev/null)
8
+ Process.daemon(false, true)
9
+ # show pid and log file info on stdout right away
10
+ puts "[#{Process.pid}] Starting lifx_dash ... (daemon logging to #{log_file})"
11
+ redirect_io(log_file)
12
+ end
13
+
14
+ # Free the STDIN/STDOUT/STDERR file descriptors and point them somewhere
15
+ # sensible - inspired by daemons gem
16
+ def self.redirect_io(log_file)
17
+ STDIN.reopen '/dev/null'
18
+
19
+ begin
20
+ # ensure log file exists with good permissions
21
+ FileUtils.mkdir_p(File.dirname(log_file), :mode => 0755)
22
+ FileUtils.touch log_file
23
+ File.chmod(0644, log_file)
24
+
25
+ # reopen STOUT stream to file
26
+ STDOUT.reopen log_file, 'a'
27
+ STDOUT.sync = true
28
+ rescue ::StandardError
29
+ STDOUT.reopen '/dev/null'
30
+ end
31
+
32
+ # reopen STERR stream to STDOUT (file stream)
33
+ STDERR.reopen STDOUT
34
+ STDERR.sync = true
35
+ rescue => e
36
+ raise "#{self} - error: could not redirect IO - #{e.message}"
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,72 @@
1
+ require "net/https"
2
+ require "json"
3
+
4
+ module LifxDash
5
+ class LifxHTTPApi
6
+
7
+ BASE_URI = "api.lifx.com/v1"
8
+
9
+ attr_reader :token, :logger
10
+
11
+ ##
12
+ # Initialize a new api client with a an API auth token and logger
13
+ # If no logger is passed, the default LOGGER constant will be used e.g.
14
+ #
15
+ # LifxDash::LifxHTTPApi.new("my-token-here", Logger.new(STDOUT))
16
+ #
17
+ def initialize(api_token, logger = LOGGER)
18
+ @token = api_token
19
+ @logger = logger
20
+ end
21
+
22
+ ##
23
+ # Call the toggle-power endpoint on the LIFX HTTP API.
24
+ # Pass a `selector` argument (defaults to 'all'). The API auth token is
25
+ # passed via HTTP Basic AUTH.
26
+ #
27
+ # The method logs success, warning or errors to the logger. Success is
28
+ # determined by a 2XX response code and a valid JSON response body like so:
29
+ #
30
+ # {"results":[{"id":"d073d500ec8e","label":"Golden Boy","status":"ok"}]}
31
+ #
32
+ def toggle(selector = "all")
33
+ uri = URI("https://#{BASE_URI}/lights/#{selector}/toggle")
34
+ logger.info "Toggling lights! (via #{BASE_URI})"
35
+
36
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
37
+ req = Net::HTTP::Post.new(uri)
38
+ req.add_field "Authorization", "Bearer #{token}"
39
+ res = http.request(req)
40
+ if res.code.to_s =~ /^2/ && success?(res.body)
41
+ logger.info "Lights toggled successfully!"
42
+ logger.info "API reply (#{res.code}): #{res.body}"
43
+ else
44
+ logger.warn "Warning: Possible issue with LIFX lights or HTTP API response"
45
+ logger.warn "API reply (#{res.code}): #{res.body}"
46
+ end
47
+ end
48
+ rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, EOFError, URI::InvalidURIError,
49
+ Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError => e
50
+ logger.error "Error: POST request to #{BASE_URI} failed: #{e.message}"
51
+ raise e
52
+ end
53
+
54
+
55
+ private
56
+
57
+ def success?(response_body)
58
+ results = parse_results(response_body)
59
+ if results
60
+ results.all? { |r| r["status"] == "ok" }
61
+ end
62
+ end
63
+
64
+ def parse_results(response_body)
65
+ if response_body && !response_body.strip.empty?
66
+ JSON.parse(response_body)["results"]
67
+ end
68
+ rescue JSON::ParserError
69
+ nil
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,31 @@
1
+ module LifxDash
2
+ class Monitor
3
+
4
+ attr_reader :token, :mac, :selector, :iface
5
+
6
+ def initialize(token: nil, mac: nil, selector: "all", iface: "en0")
7
+ @iface = iface
8
+ @token = token
9
+ @mac = mac
10
+ @selector = selector
11
+ end
12
+
13
+ def run
14
+ puts "Starting lifx_dash monitor ..."
15
+ puts " * listening on #{iface} for Dash button #{mac} presses to toggle #{selector} bulb(s)"
16
+
17
+ LifxDash::Capturer.new(iface).listen do |pkt, source_mac_addr|
18
+ if source_mac_addr == mac
19
+ LOGGER.info "Detected Dash button press from MAC address: #{mac} -- pkt summary: #{pkt.peek}"
20
+ lifx_api.toggle(selector)
21
+ end
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def lifx_api
28
+ @lifx_api ||= LifxDash::LifxHTTPApi.new(token)
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,20 @@
1
+ module LifxDash
2
+ class Snoop
3
+
4
+ attr_reader :iface
5
+
6
+ def initialize(network_iface_id)
7
+ @iface = network_iface_id
8
+ end
9
+
10
+ def run
11
+ puts "Snooping for dash button packets on #{iface} ... press [CTRL-c] to stop\n\n"
12
+ puts " * wait for the network to quiet down, before pressing the button"
13
+ puts " * you might get more than 1 ARP packet when pressing, use the MAC address that occurs once\n\n"
14
+
15
+ LifxDash::Capturer.new(iface).listen do |pkt, mac|
16
+ LOGGER.info "possible Dash button press from MAC address: #{mac} -- pkt summary: #{pkt.peek}"
17
+ end
18
+ end
19
+ end
20
+ end