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.
- 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
|