lifx_dash 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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