A program to read a Phidget IR sensor and log pull-ups with Fitbit's API
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

initial version

joshua stein 4f1b3a6a

+447
+24
LICENSE
··· 1 + Copyright (c) 2012 joshua stein <jcs@jcs.org> 2 + 3 + Redistribution and use in source and binary forms, with or without 4 + modification, are permitted provided that the following conditions 5 + are met: 6 + 7 + 1. Redistributions of source code must retain the above copyright 8 + notice, this list of conditions and the following disclaimer. 9 + 2. Redistributions in binary form must reproduce the above copyright 10 + notice, this list of conditions and the following disclaimer in the 11 + documentation and/or other materials provided with the distribution. 12 + 3. The name of the author may not be used to endorse or promote products 13 + derived from this software without specific prior written permission. 14 + 15 + THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR 16 + IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES 17 + OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 18 + IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, 19 + INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT 20 + NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 21 + DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 22 + THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 24 + THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+36
lib/config_hash.rb
··· 1 + class ConfigHash 2 + CONFIG_FILE = "#{ENV["HOME"]}/.pullup_counter" 3 + 4 + def initialize 5 + @config = {} 6 + read 7 + end 8 + 9 + def [](var) 10 + @config[var] 11 + end 12 + 13 + def []=(var, val) 14 + @config[var] = val 15 + end 16 + 17 + def read 18 + @config = {} 19 + 20 + if File.exists?(CONFIG_FILE) 21 + File.read(CONFIG_FILE).split("\n").each do |line| 22 + if m = line.strip.match(/^([^=]+)=(.*)/) 23 + @config[m[1]] = m[2] 24 + end 25 + end 26 + end 27 + end 28 + 29 + def save! 30 + File.open(CONFIG_FILE, "w+", 0600) do |f| 31 + @config.each do |k,v| 32 + f.puts "#{k}=#{v}" 33 + end 34 + end 35 + end 36 + end
+36
lib/logger/beeper.rb
··· 1 + class Beeper < Loggerish 2 + attr_accessor :player 3 + 4 + def self.args 5 + [ 6 + [ "--sound", "-s", GetoptLong::REQUIRED_ARGUMENT, 7 + "play <sound> file" ], 8 + ] 9 + end 10 + 11 + def after_initialize 12 + if @enabled = !!@parent.config["sound"] 13 + if @parent.config["verbose"] 14 + puts "#{Time.now.to_f} - playing sound file " << 15 + @parent.config["sound"] 16 + end 17 + 18 + if `uname -s`.strip == "OpenBSD" 19 + @player = [ "aucat", "-i" ] 20 + else 21 + # dunno, assume mac 22 + @player = [ "afplay" ] 23 + end 24 + end 25 + end 26 + 27 + def log_pullup!(time) 28 + cmd = @player + [ @parent.config["sound"] ] 29 + 30 + if @parent.config["debug"] 31 + puts "#{Time.now.to_f} - running " << cmd.inspect 32 + end 33 + 34 + system(*cmd) 35 + end 36 + end
+125
lib/logger/fitbit.rb
··· 1 + require "oauth" 2 + require "json" 3 + 4 + class Fitbit < Loggerish 5 + def self.args 6 + [ 7 + [ "--fitbit", "-F", GetoptLong::NO_ARGUMENT, 8 + "log to a Fitbit account (will walk through setup)" ], 9 + ] 10 + end 11 + 12 + def after_initialize 13 + if @enabled = !!@parent.config["fitbit"] 14 + if @parent.config["verbose"] 15 + puts "#{Time.now.to_f} - enabling Fitbit logging module" 16 + end 17 + 18 + verify_keys 19 + end 20 + end 21 + 22 + def log_pullup!(time) 23 + # TODO: update this to log to the user's custom tracker instead of an 24 + # activity, because activities don't allow custom units and logging pullups 25 + # in miles doesn't make any sense 26 + json = self.oauth_request("/1/user/-/activities.json", :post, { 27 + "activityName" => "Pullup", 28 + "manualCalories" => 1, 29 + "startTime" => time.strftime("%H:%M"), 30 + "durationMillis" => 1000, 31 + "date" => time.strftime("%Y-%m-%d"), 32 + "distance" => "1.0", 33 + }) 34 + 35 + begin 36 + h = JSON.parse(json) 37 + if (id = h["activityLog"]["activityId"].to_i) != 0 38 + if @parent.config["verbose"] 39 + puts "#{Time.now.to_f} - logged pullup on fitbit (id #{id})" 40 + end 41 + else 42 + raise "no activity id" 43 + end 44 + 45 + rescue => e 46 + puts "#{Time.now.to_f} - error from fitbit (#{e.message}): " << 47 + json.inspect 48 + end 49 + end 50 + 51 + def oauth_consumer 52 + OAuth::Consumer.new(@parent.config["fitbit_oauth_key"], 53 + @parent.config["fitbit_oauth_secret"], 54 + { :site => "http://api.fitbit.com", :http_method => :get }) 55 + end 56 + 57 + def oauth_request(req, method = :get, post_data = nil) 58 + begin 59 + Timeout.timeout(20) do 60 + at = OAuth::AccessToken.new(oauth_consumer, 61 + @parent.config["fitbit_token"], @parent.config["fitbit_secret"]) 62 + 63 + if method == :get 64 + res = at.get(req, { "Accept-Language" => "en_US" }) 65 + elsif method == :post 66 + res = at.post(req, post_data, { "Accept-Language" => "en_US" }) 67 + else 68 + raise "what kind of method is #{method}?" 69 + end 70 + 71 + if res.class.superclass != Net::HTTPSuccess 72 + raise res.class.to_s 73 + end 74 + 75 + return res.body 76 + end 77 + rescue Timeout::Error => e 78 + puts "#{Time.now.to_f} - timed out talking to Fitbit: #{e.message}" 79 + rescue StandardError => e 80 + puts "#{Time.now.to_f} - error talking to Fitbit: #{e.message}" 81 + end 82 + end 83 + 84 + def verify_keys 85 + while @parent.config["fitbit_oauth_key"].to_s == "" 86 + puts "No Fitbit OAuth key found. Register an application at", 87 + "https://dev.fitbit.com/apps/new with PIN authentication and enter ", 88 + "the consumer key and secret here.", 89 + "" 90 + 91 + print "OAuth consumer key: " 92 + @parent.config["fitbit_oauth_key"] = STDIN.gets.strip 93 + print "OAuth consumer secret: " 94 + @parent.config["fitbit_oauth_secret"] = STDIN.gets.strip 95 + 96 + # this will be invalid now anyway 97 + @parent.config["fitbit_token"] = "" 98 + 99 + puts "" 100 + end 101 + 102 + while @parent.config["fitbit_token"].to_s == "" 103 + request_token = oauth_consumer.get_request_token 104 + 105 + puts "No Fitbit token found. Authorize your Fitbit account at ", 106 + request_token.authorize_url, 107 + "" 108 + 109 + print "Enter the PIN received: " 110 + pin = STDIN.gets.strip 111 + 112 + access_token = request_token.get_access_token(:oauth_verifier => pin) 113 + if !access_token 114 + raise "couldn't get access token from pin" 115 + end 116 + 117 + @parent.config["fitbit_token"] = access_token.token 118 + @parent.config["fitbit_secret"] = access_token.secret 119 + 120 + @parent.config.save! 121 + 122 + puts "" 123 + end 124 + end 125 + end
+27
lib/logger/textfile.rb
··· 1 + class Textfile < Loggerish 2 + def self.args 3 + [ 4 + [ "--file", "-f", GetoptLong::REQUIRED_ARGUMENT, 5 + "log to flat file <file>" ], 6 + ] 7 + end 8 + 9 + def after_initialize 10 + if @enabled = !!@parent.config["file"] 11 + if @parent.config["verbose"] 12 + puts "#{Time.now.to_f} - enabling plaintext logging module to " << 13 + @parent.config["file"] 14 + end 15 + end 16 + end 17 + 18 + def log_pullup!(time) 19 + File.open(@parent.config["file"], "a") do |f| 20 + f.puts time.strftime("%Y-%m-%d %H:%M:%S") 21 + end 22 + 23 + if @parent.config["verbose"] 24 + puts "#{Time.now.to_f} - logged to file #{@parent.config["file"]}" 25 + end 26 + end 27 + end
+19
lib/loggerish.rb
··· 1 + class Loggerish 2 + attr_accessor :enabled 3 + 4 + def self.args 5 + end 6 + 7 + def initialize(parent) 8 + @parent = parent 9 + @enabled = false 10 + after_initialize 11 + end 12 + 13 + def after_initialize 14 + end 15 + 16 + def log_pullup!(time) 17 + puts "#{Time.now.to_f} - logged pullup" 18 + end 19 + end
+93
lib/sensor/phidget.rb
··· 1 + require "rubygems" 2 + require "phidgets-ffi" 3 + 4 + class Phidget 5 + attr_accessor :cur_value, :state, :fitbit 6 + 7 + # default values for each state to transition 8 + STATE_IDLE_TO_PULLING_UP = 150 9 + STATE_PULLING_UP_TO_PULLED_UP = 325 10 + STATE_PULLING_UP_TO_IDLE = 200 11 + STATE_PULLING_UP_TO_FINISHED = 150 12 + 13 + def initialize(parent) 14 + @parent = parent 15 + @state = :idle 16 + @last_state_change = Time.now 17 + @cur_time = nil 18 + end 19 + 20 + def main_loop 21 + begin 22 + Phidgets::InterfaceKit.new do |ifkit| 23 + if @parent.config["verbose"] 24 + puts "#{Time.now.to_f} - reading from Phidget #{ifkit.serial_number}" 25 + end 26 + 27 + ifkit.on_sensor_change do |device, input, value, obj| 28 + @cur_time = Time.now 29 + if input.index == 1 30 + self.cur_value = value 31 + end 32 + end 33 + 34 + sleep 35 + end 36 + 37 + rescue => e 38 + puts "#{Time.now.to_f} - exception in Phidget handler: #{e.message}" 39 + sleep 3 40 + retry 41 + end 42 + end 43 + 44 + def cur_value=(value) 45 + if @parent.config["debug"] 46 + puts "#{@cur_time.to_f},#{value}" 47 + end 48 + 49 + if @cur_time.to_i - @last_state_change.to_i > 10 && self.state != :idle 50 + # stuck in previous state, unlikely we're still hanging there, reset 51 + self.state = :idle 52 + end 53 + 54 + @cur_value = value 55 + 56 + case self.state 57 + when :idle 58 + if value >= STATE_IDLE_TO_PULLING_UP 59 + self.state = :pulling_up 60 + end 61 + 62 + when :pulling_up 63 + if value >= STATE_PULLING_UP_TO_PULLED_UP 64 + self.state = :pulled_up 65 + elsif value <= STATE_PULLING_UP_TO_IDLE 66 + self.state = :idle 67 + end 68 + 69 + when :pulled_up 70 + if value <= STATE_PULLING_UP_TO_FINISHED 71 + self.state = :finished 72 + end 73 + end 74 + end 75 + 76 + def state=(state) 77 + @last_state_change = @cur_time 78 + 79 + if @state == state 80 + return 81 + end 82 + 83 + @state = state 84 + if @parent.config["verbose"] 85 + puts "#{@cur_time.to_f} - state is now #{state} (#{self.cur_value})" 86 + end 87 + 88 + if state == :finished 89 + @parent.log_pullup!(@cur_time) 90 + @state = :idle 91 + end 92 + end 93 + end
+87
pullup_counter.rb
··· 1 + require "getoptlong" 2 + 3 + $:.unshift(File.dirname(__FILE__)) 4 + require "lib/config_hash" 5 + require "lib/loggerish" 6 + require "lib/sensor/phidget" 7 + 8 + # bring in all loggers 9 + Dir.glob(File.dirname(__FILE__) << "/lib/logger/*.rb").sort.each do |f| 10 + require File.absolute_path(f) 11 + end 12 + 13 + class PullupCounter 14 + OPTS = [ 15 + [ "--debug", "-d", GetoptLong::NO_ARGUMENT, 16 + "enable debugging (show sensor values)" ], 17 + [ "--help", "-h", GetoptLong::NO_ARGUMENT, 18 + "show this help" ], 19 + [ "--no-log", "-n", GetoptLong::NO_ARGUMENT, 20 + "don't actually log" ], 21 + [ "--verbose", "-v", GetoptLong::NO_ARGUMENT, 22 + "be verbose" ], 23 + ] 24 + 25 + attr_accessor :config, :loggers 26 + 27 + def initialize 28 + @config = ConfigHash.new 29 + 30 + # add in options from each logger 31 + @loggers = [] 32 + opts = OPTS 33 + ObjectSpace.each_object(Class) do |cl| 34 + if cl < Loggerish && cl.args.any? 35 + @loggers.push cl 36 + opts += cl.args 37 + end 38 + end 39 + 40 + # put args into @config or show help 41 + GetoptLong.new(*opts.map{|o| o[0 ... -1] }).each do |opt,arg| 42 + case opt 43 + when "--help" 44 + puts "#{$0} [options]" 45 + opts.each do |o| 46 + print " #{o[1]}\t#{o[0]}" 47 + 48 + tl = o[0].length 49 + if o[2] != GetoptLong::NO_ARGUMENT 50 + print "=<arg>" 51 + tl += 6 52 + end 53 + 54 + puts "\t" << (tl < 8 ? "\t" : "") << o[3] 55 + end 56 + 57 + exit 1 58 + 59 + else 60 + @config[opt.gsub(/^--/, "")] = arg.to_s == "" ? true : arg 61 + end 62 + end 63 + 64 + # initialize each logger and let it enable itself 65 + self.loggers.each_with_index do |cl,x| 66 + self.loggers[x] = cl.new(self) 67 + end 68 + 69 + Phidget.new(self).main_loop 70 + end 71 + 72 + # asynchronously send to each logger 73 + def log_pullup!(time) 74 + self.loggers.select{|l| l.enabled }.each do |logger| 75 + Thread.new do 76 + begin 77 + logger.log_pullup!(time) 78 + rescue => e 79 + puts "#{Time.now.to_f} - error from #{logger.class} logger: " << 80 + e.message 81 + end 82 + end 83 + end 84 + end 85 + end 86 + 87 + PullupCounter.new