lifx_dash 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.coveralls.yml +1 -0
- data/.gitignore +10 -0
- data/.simplecov +10 -0
- data/.travis.yml +20 -0
- data/CHANGELOG.md +17 -0
- data/CODE_OF_CONDUCT.md +50 -0
- data/CONTRIBUTING.md +31 -0
- data/Gemfile +2 -0
- data/LICENSE.txt +21 -0
- data/README.md +235 -0
- data/Rakefile +38 -0
- data/bin/console +14 -0
- data/bin/lifx_dash +112 -0
- data/bin/setup +11 -0
- data/features/lifx_dash.feature +17 -0
- data/features/step_definitions/lifx_dash_steps.rb +4 -0
- data/features/support/env.rb +1 -0
- data/lib/lifx_dash.rb +13 -0
- data/lib/lifx_dash/capturer.rb +37 -0
- data/lib/lifx_dash/configuration.rb +68 -0
- data/lib/lifx_dash/daemonizer.rb +39 -0
- data/lib/lifx_dash/lifx_http_api.rb +72 -0
- data/lib/lifx_dash/monitor.rb +31 -0
- data/lib/lifx_dash/snoop.rb +20 -0
- data/lib/lifx_dash/version.rb +3 -0
- data/lifx_dash.gemspec +66 -0
- data/man/lifx_dash.1 +170 -0
- data/man/lifx_dash.1.html +217 -0
- data/man/lifx_dash.1.ronn +115 -0
- data/test/lifx_dash/lifx_http_api_test.rb +73 -0
- data/test/test_helper.rb +15 -0
- metadata +251 -0
data/bin/console
ADDED
@@ -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
|
data/bin/lifx_dash
ADDED
@@ -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)
|
data/bin/setup
ADDED
@@ -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 @@
|
|
1
|
+
require 'aruba/cucumber'
|
data/lib/lifx_dash.rb
ADDED
@@ -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
|