smartermeter 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/Gemfile ADDED
@@ -0,0 +1,2 @@
1
+ source :rubygems
2
+ gemspec
data/README.md ADDED
@@ -0,0 +1,65 @@
1
+ Smartermeter - the smarter way to read your PGE SmartMeter
2
+ =========================================================
3
+
4
+ So I have PGE SmartMeter and I like playing with data. However I didn't really
5
+ want to jump through 37 hoops to see the data on PG&E's website. So I made
6
+ this.
7
+
8
+ While making this library I discovered that PG&E doesn't even manage the
9
+ software for the energy reporting. It's all done by energyguide.com. Not
10
+ terribly useful but an interesting piece of trivia.
11
+
12
+ Getting Started
13
+ ---------------
14
+
15
+ gem install smartermeter
16
+ smartermeter
17
+
18
+ Google PowerMeter
19
+ -----------------
20
+
21
+ Once you've configured smartermeter once, you might want to use it with Google
22
+ PowerMeter.
23
+
24
+ 1. Visit: https://www.google.com/powermeter/device/activate?mfg=Ruby&model=SmarterMeter&did=PGE&dvars=1
25
+ 1. Then sign in with your desired Google Account.
26
+ 1. Follow the directions on screen.
27
+ 1. On the final screen copy the entire "authInfo" into your favorite editor.
28
+ Pull out the "token" and the "path" from the string.
29
+ 1. Take the "path" you collected previously and append ".d1" to the end of it.
30
+ 1. Then append the following to your ~/.smartermeter file to
31
+ automatically upload data as it's retrieved from PG&E.
32
+
33
+ :transport: :google_powermeter
34
+ :google_powermeter:
35
+ :token: "your-token"
36
+ :variable: "your-path-with.d1-appended"
37
+
38
+ To Build
39
+ --------
40
+
41
+ In order to build the self contained binaries, you'll need a working jruby interpreter.
42
+
43
+ git clone git://github.com/mcolyer/smartermeter.git
44
+ cd smartermeter
45
+ rake rawr:prepare
46
+ rake rawr:bundle:exe # builds a windows executable
47
+ rake rawr:bundle:app # builds an OSX executable
48
+ rake build # builds a ruby gem
49
+
50
+ Questions
51
+ ---------
52
+
53
+ * How much lag is there?
54
+
55
+ It'll show you the last full day's worth of data. The PGE website claims that
56
+ data becomes available around 3-10pm on the following day. However my
57
+ experience says that it's sometimes available earlier.
58
+
59
+ * How long is data saved for?
60
+
61
+ I don't know. If you know tell me.
62
+
63
+ * How can I help?
64
+
65
+ Make sure it works, make cool things with it or send me git pull requests.
data/Rakefile ADDED
@@ -0,0 +1,182 @@
1
+ require 'rubygems'
2
+ require 'rake'
3
+ require 'date'
4
+
5
+ #############################################################################
6
+ #
7
+ # Helper functions
8
+ #
9
+ #############################################################################
10
+
11
+ def name
12
+ @name ||= Dir['*.gemspec'].first.split('.').first
13
+ end
14
+
15
+ def version
16
+ line = File.read("lib/#{name}.rb")[/^\s*VERSION\s*=\s*.*/]
17
+ line.match(/.*VERSION\s*=\s*['"](.*)['"]/)[1]
18
+ end
19
+
20
+ def date
21
+ Date.today.to_s
22
+ end
23
+
24
+ def rubyforge_project
25
+ name
26
+ end
27
+
28
+ def gemspec_file
29
+ "#{name}.gemspec"
30
+ end
31
+
32
+ def gem_file
33
+ "#{name}-#{version}.gem"
34
+ end
35
+
36
+ def replace_header(head, header_name)
37
+ head.sub!(/(\.#{header_name}\s*= ').*'/) { "#{$1}#{send(header_name)}'"}
38
+ end
39
+
40
+ #############################################################################
41
+ #
42
+ # Standard tasks
43
+ #
44
+ #############################################################################
45
+
46
+ task :default => :test
47
+
48
+ require 'rake/testtask'
49
+ Rake::TestTask.new(:test) do |test|
50
+ test.libs << 'lib' << 'test'
51
+ test.pattern = 'specs/*_spec.rb'
52
+ test.verbose = true
53
+ end
54
+
55
+ desc "Generate RCov test coverage and open in your browser"
56
+ task :coverage do
57
+ require 'rcov'
58
+ sh "rm -fr coverage"
59
+ sh "rcov test/test_*.rb"
60
+ sh "open coverage/index.html"
61
+ end
62
+
63
+ require 'rake/rdoctask'
64
+ Rake::RDocTask.new do |rdoc|
65
+ rdoc.rdoc_dir = 'rdoc'
66
+ rdoc.title = "#{name} #{version}"
67
+ rdoc.rdoc_files.include('README*')
68
+ rdoc.rdoc_files.include('lib/**/*.rb')
69
+ end
70
+
71
+ desc "Open an irb session preloaded with this library"
72
+ task :console do
73
+ sh "irb -rubygems -r ./lib/#{name}.rb"
74
+ end
75
+
76
+ #############################################################################
77
+ #
78
+ # Custom tasks (add your own tasks here)
79
+ #
80
+ #############################################################################
81
+
82
+ require 'rawr'
83
+
84
+ namespace :rawr do
85
+ task :prepare do
86
+ dir = File.join(File.dirname(__FILE__), "vendor", "gems")
87
+ FileUtils.rm_rf(dir)
88
+ FileUtils.mkdir_p(dir)
89
+ ["nokogiri", "mechanize", "crypt"].each do |gem|
90
+ `gem unpack -t "#{dir}" #{gem}`
91
+ end
92
+
93
+ # Rawr can't handle folders with dashes in the name, so we'll remove the
94
+ # version numbers from the gems.
95
+ Dir.glob(File.join(dir, "*-*")).each do |gem|
96
+ no_version = File.basename(gem).split("-")[0] + ".old"
97
+ FileUtils.mv(gem, File.join(dir, no_version))
98
+ end
99
+
100
+ Dir.glob(File.join(dir, "nokogiri.old", "lib", "*")).each do |file|
101
+ FileUtils.mv(file, File.join(dir))
102
+ end
103
+ FileUtils.mv(File.join(dir, "crypt.old", "crypt"), File.join(dir, "crypt"))
104
+ Dir.glob(File.join(dir, "mechanize.old", "lib", "*")).each do |file|
105
+ FileUtils.mv(file, File.join(dir))
106
+ end
107
+
108
+ Dir.glob(File.join(dir, "*.old")).each do |gem|
109
+ FileUtils.rm_rf(gem)
110
+ end
111
+ end
112
+ end
113
+
114
+ #############################################################################
115
+ #
116
+ # Packaging tasks
117
+ #
118
+ #############################################################################
119
+
120
+ desc "Create tag v#{version} and build and push #{gem_file} to Rubygems"
121
+ task :release => :build do
122
+ unless `git branch` =~ /^\* master$/
123
+ puts "You must be on the master branch to release!"
124
+ exit!
125
+ end
126
+ sh "git commit --allow-empty -a -m 'Release #{version}'"
127
+ sh "git tag v#{version}"
128
+ sh "git push origin master"
129
+ sh "git push origin v#{version}"
130
+ #sh "gem push pkg/#{name}-#{version}.gem"
131
+ end
132
+
133
+ desc "Build #{gem_file} into the pkg directory"
134
+ task :build => :gemspec do
135
+ sh "mkdir -p pkg"
136
+ sh "gem build #{gemspec_file}"
137
+ sh "mv #{gem_file} pkg"
138
+ end
139
+
140
+ desc "Generate #{gemspec_file}"
141
+ task :gemspec => :validate do
142
+ # read spec file and split out manifest section
143
+ spec = File.read(gemspec_file)
144
+ head, manifest, tail = spec.split(" # = MANIFEST =\n")
145
+
146
+ # replace name version and date
147
+ replace_header(head, :name)
148
+ replace_header(head, :version)
149
+ replace_header(head, :date)
150
+ #comment this out if your rubyforge_project has a different name
151
+ replace_header(head, :rubyforge_project)
152
+
153
+ # determine file list from git ls-files
154
+ files = `git ls-files`.
155
+ split("\n").
156
+ sort.
157
+ reject { |file| file =~ /^\./ }.
158
+ reject { |file| file =~ /^(rdoc|pkg)/ }.
159
+ reject { |file| file =~ /^rawr/ }.
160
+ reject { |file| file =~ /\.jar$/ }.
161
+ map { |file| " #{file}" }.
162
+ join("\n")
163
+
164
+ # piece file back together and write
165
+ manifest = " s.files = %w[\n#{files}\n ]\n"
166
+ spec = [head, manifest, tail].join(" # = MANIFEST =\n")
167
+ File.open(gemspec_file, 'w') { |io| io.write(spec) }
168
+ puts "Updated #{gemspec_file}"
169
+ end
170
+
171
+ desc "Validate #{gemspec_file}"
172
+ task :validate do
173
+ libfiles = Dir['lib/*'] - ["lib/#{name}.rb", "lib/#{name}"]
174
+ unless libfiles.empty?
175
+ puts "Directory `lib` should only contain a `#{name}.rb` file and `#{name}` dir."
176
+ exit!
177
+ end
178
+ unless Dir['VERSION*'].empty?
179
+ puts "A `VERSION` file at root level violates Gem best practices."
180
+ exit!
181
+ end
182
+ end
data/bin/smartermeter ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env ruby
2
+ begin
3
+ require 'jruby'
4
+ # This is needed for nokogiri to function properly under jruby
5
+ JRuby.objectspace=true
6
+ rescue Exception
7
+ end
8
+ require 'smartermeter'
9
+
10
+ SmarterMeter::Daemon.new.start
@@ -0,0 +1,89 @@
1
+ configuration do |c|
2
+ # The name for your resulting application file (e.g., if the project_name is 'foo' then you'll get foo.jar, foo.exe, etc.)
3
+ # default value: "smartermeter"
4
+ #
5
+ #c.project_name = "smartermeter"
6
+
7
+ # Undocumented option 'output_dir'
8
+ # default value: "package"
9
+ #
10
+ c.output_dir = "pkg"
11
+
12
+ # The main ruby file to invoke, minus the .rb extension
13
+ # default value: "main"
14
+ #
15
+ c.main_ruby_file = "smartermeter/main"
16
+
17
+ # The fully-qualified name of the main Java file used to initiate the application.
18
+ # default value: "org.rubyforge.rawr.Main"
19
+ #
20
+ #c.main_java_file = "org.rubyforge.rawr.Main"
21
+
22
+ # A list of directories where source files reside
23
+ # default value: ["src"]
24
+ #
25
+ c.source_dirs = ["lib", "vendor/gems", "rawr"]
26
+
27
+ # A list of regexps of files to exclude
28
+ # default value: []
29
+ #
30
+ #c.source_exclude_filter = []
31
+
32
+ # Whether Ruby source files should be compiled into .class files
33
+ # default value: true
34
+ #
35
+ #c.compile_ruby_files = true
36
+
37
+ # A list of individual Java library files to include.
38
+ # default value: []
39
+ #
40
+ #c.java_lib_files = []
41
+
42
+ # A list of directories for rawr to include . All files in the given directories get bundled up.
43
+ # default value: ["lib/java"]
44
+ #
45
+ c.java_lib_dirs = ["vendor/java"]
46
+
47
+ # Undocumented option 'files_to_copy'
48
+ # default value: []
49
+ #
50
+ #c.files_to_copy = []
51
+
52
+ # Undocumented option 'target_jvm_version'
53
+ # default value: 1.6
54
+ #
55
+ #c.target_jvm_version = 1.6
56
+
57
+ # Undocumented option 'jvm_arguments'
58
+ # default value: ""
59
+ #
60
+ #c.jvm_arguments = ""
61
+
62
+ # Undocumented option 'java_library_path'
63
+ # default value: ""
64
+ #
65
+ #c.java_library_path = ""
66
+
67
+ # Undocumented option 'extra_user_jars'
68
+ # default value: {}
69
+ #
70
+ #c.extra_user_jars[:data] = { :directory => 'data/images/png',
71
+ # :location_in_jar => 'images',
72
+ # :exclude => /*.bak$/ }
73
+
74
+ # Undocumented option 'mac_do_not_generate_plist'
75
+ # default value: nil
76
+ #
77
+ #c.mac_do_not_generate_plist = nil
78
+
79
+ # Undocumented option 'mac_icon_path'
80
+ # default value: nil
81
+ #
82
+ #c.mac_icon_path = nil
83
+
84
+ # Undocumented option 'windows_icon_path'
85
+ # default value: nil
86
+ #
87
+ #c.windows_icon_path = nil
88
+
89
+ end
@@ -0,0 +1,236 @@
1
+ require 'crypt/blowfish'
2
+ require 'yaml'
3
+ require 'logger'
4
+ require 'date'
5
+
6
+ module SmarterMeter
7
+ class Daemon
8
+
9
+ # Loads the configuration, and starts
10
+ #
11
+ # Never returns.
12
+ def start
13
+ configure
14
+ run
15
+ end
16
+
17
+ protected
18
+ def config_file
19
+ File.expand_path("~/.smartermeter")
20
+ end
21
+
22
+ def default_data_dir
23
+ File.expand_path(File.join(File.dirname(__FILE__), "..", "data"))
24
+ end
25
+
26
+ # Returns a filename for the data belonging to the given date.
27
+ def data_file(date)
28
+ File.expand_path(File.join(@config[:data_dir], date.strftime("%Y-%m-%d.csv")))
29
+ end
30
+
31
+ def log
32
+ return @logger if @logger
33
+ @logger = Logger.new STDOUT
34
+ @logger.level = Logger::INFO
35
+ @logger
36
+ end
37
+
38
+ # Loads the configuration and prompts for required settings if they are
39
+ # missing.
40
+ #
41
+ # Returns nothing.
42
+ def configure
43
+ load_configuration
44
+ verify_configuration
45
+ end
46
+
47
+ # Loads the configuration from disk.
48
+ #
49
+ # Returns the configuration hash.
50
+ def load_configuration
51
+ @config = {
52
+ :start_date => Date.today,
53
+ :data_dir => default_data_dir
54
+ }
55
+
56
+ if File.exist?(config_file)
57
+ @config = YAML.load_file(config_file)
58
+ end
59
+
60
+ @config
61
+ end
62
+
63
+ def cipher
64
+ Crypt::Blowfish.new("Our easily discoverable key.")
65
+ end
66
+
67
+ # Takes the unencrypted password and encrypts it.
68
+ def password=(unencrypted)
69
+ @config[:password] = cipher.encrypt_block(unencrypted)
70
+ end
71
+
72
+ # Returns the clear-text password or nil if it isn't set.
73
+ def password
74
+ password = @config.fetch(:password, nil)
75
+ if password
76
+ cipher.decrypt_block(password)
77
+ else
78
+ password
79
+ end
80
+ end
81
+
82
+ # Prompts the user for required settings that are blank.
83
+ #
84
+ # Returns nothing.
85
+ def verify_configuration
86
+ return if @config[:username] and @config[:password]
87
+
88
+ puts
89
+ puts "Smartermeter: Initial Configuration"
90
+ puts "--------------------------------------------------------------------------------"
91
+ puts "This program stores your PG&E account username and password on disk. The"
92
+ puts "password is encrypted but could be retrieved fairly easily. If this makes you"
93
+ puts "uncomfortable quit now (use ctrl-c)."
94
+ puts "--------------------------------------------------------------------------------"
95
+
96
+ unless @config[:username]
97
+ print "PG&E account username: "
98
+ @config[:username] = gets.strip
99
+ end
100
+
101
+ unless @config[:password]
102
+ print "PG&E account password: "
103
+ self.password = gets.strip
104
+ end
105
+
106
+ save_configuration
107
+ puts "Setup complete"
108
+ end
109
+
110
+ # Saves the current configuration to disk.
111
+ #
112
+ # Returns nothing.
113
+ def save_configuration
114
+ File.open(config_file, "w") do |file|
115
+ file.write(YAML.dump(@config))
116
+ end
117
+ end
118
+
119
+ # Continually checks for new data for any missing days, since the first day
120
+ # smartermeter started watching.
121
+ #
122
+ # Never returns.
123
+ def run
124
+ one_hour = 60 * 60
125
+
126
+ while true
127
+ dates = dates_requiring_data
128
+ unless dates.empty?
129
+ log.info("Attempting to fetch data for: #{dates.join(",")}")
130
+ results = fetch_dates(dates)
131
+ log.info("Successfully fetched: #{results.join(",")}")
132
+ else
133
+ log.info("Sleeping")
134
+ end
135
+ sleep(one_hour)
136
+ end
137
+ end
138
+
139
+ # Create an authorized Service instance.
140
+ #
141
+ # Note: An authorization failure will cause an exits, as it is a dire
142
+ # condition.
143
+ #
144
+ # Returns a new Service instance which has been properly authorized.
145
+ def service
146
+ service = Service.new
147
+ log.info("Logging in as #{@config[:username]}")
148
+ unless service.login(@config[:username], password)
149
+ log.error("Incorrect username or password given.")
150
+ log.error("Please remove ~/.smartermeter and configure smartermeter again.")
151
+ exit(-1)
152
+ end
153
+ log.info("Logged in as #{@config[:username]}")
154
+ service
155
+ end
156
+
157
+ # Connect and authenticate to the PG&E Website.
158
+ #
159
+ # It provides an instance of Service to the provided block
160
+ # for direct manipulation.
161
+ #
162
+ # Returns nothing.
163
+ def connect
164
+ s = service
165
+ begin
166
+ yield s
167
+ rescue SocketError => e
168
+ log.error("Could not access the PG&E site, are you connected to the Internet?")
169
+ end
170
+ end
171
+
172
+ # Attempts to retrieve power data for each of the dates in the list.
173
+ #
174
+ # dates - An array of Date objects to retrieve power data for.
175
+ #
176
+ # Returns an Array of successfully retrieved dates.
177
+ def fetch_dates(dates)
178
+ completed = []
179
+
180
+ connect do |service|
181
+ dates.each do |date|
182
+ log.info("Fetching #{date}")
183
+ data = service.fetch_csv(date)
184
+
185
+ log.info("Verifying #{date}")
186
+ samples = Sample.parse_csv(data).values.first
187
+ first_sample = samples.first
188
+
189
+ if first_sample.kwh
190
+ log.info("Saving #{date}")
191
+ File.open(data_file(date), "w") do |f|
192
+ f.write(data)
193
+ end
194
+
195
+ upload(date, samples)
196
+
197
+ log.info("Completed #{date}")
198
+ completed << date
199
+ else
200
+ log.info("Incomplete #{date}")
201
+ end
202
+ end
203
+ end
204
+
205
+ completed
206
+ end
207
+
208
+ def upload(date, samples)
209
+ case @config[:transport]
210
+ when :google_powermeter
211
+ log.info("Uploading #{date} to Google PowerMeter")
212
+ transport = SmarterMeter::Transports::GooglePowerMeter.new(@config[:google_powermeter])
213
+ if transport.upload(samples)
214
+ log.info("Upload for #{date} complete")
215
+ else
216
+ log.info("Upload for #{date} failed")
217
+ end
218
+ end
219
+ end
220
+
221
+ # Returns an Array of Date objects containing all dates since start_date
222
+ # missing power data.
223
+ def dates_requiring_data
224
+ collected = Dir.glob(File.join(@config[:data_dir], "*-*-*.csv")).map { |f| File.basename(f, ".csv") }
225
+ all_days = []
226
+
227
+ count_of_days = (Date.today - @config[:start_date]).to_i
228
+
229
+ count_of_days.times do |i|
230
+ all_days << (@config[:start_date] + i).strftime("%Y-%m-%d")
231
+ end
232
+
233
+ (all_days - collected).map { |d| Date.parse(d) }
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,9 @@
1
+ # Used only to launch from a jar using rawr
2
+
3
+ require 'jruby'
4
+ # This is needed for nokogiri to function properly under jruby
5
+ JRuby.objectspace=true
6
+
7
+ require 'smartermeter'
8
+
9
+ SmarterMeter::Daemon.new.start
@@ -0,0 +1,68 @@
1
+ require 'time'
2
+
3
+ module SmarterMeter
4
+ class Sample
5
+ attr_accessor :time, :kwh
6
+
7
+ # Parses the CSV returned by PG&E
8
+ #
9
+ # data - The string containing the CSV returned by PG&E
10
+ #
11
+ # Returns a Hash of with keys as Date objects and values of Arrays of samples.
12
+ def self.parse_csv(data)
13
+ samples = {}
14
+ date_re = /([0-9]{1,2})\/([0-9]{1,2})\/([0-9]{4})/
15
+
16
+ # Apparently they felt the need to put a = outside of the correct place
17
+ data = data.gsub('=','')
18
+
19
+ hour_increment = 1/24.0
20
+ CSV.parse(data) do |row|
21
+ next unless row.length > 0 and date_re.match row[0]
22
+
23
+ month, day, year = date_re.match(row[0]).captures
24
+ month = month.to_i
25
+ day = day.to_i
26
+ year = year.to_i
27
+
28
+ timestamp = Time.local(year, month, day, 0) - hour_increment + 1/(24.0*60)
29
+ hourly_samples = row[1..24].map do |v|
30
+ if v == "-"
31
+ kwh = nil
32
+ else
33
+ kwh = v.to_f
34
+ end
35
+
36
+ timestamp = timestamp + hour_increment
37
+ Sample.new(timestamp, kwh)
38
+ end
39
+ samples[Date.new(year, month, day)] = hourly_samples
40
+ end
41
+ samples
42
+ end
43
+
44
+ def initialize(time, kwh)
45
+ @time = time
46
+ @kwh = kwh
47
+ end
48
+
49
+ # Public: The start time of this measurement
50
+ #
51
+ # Returns the time in the format of 2011-01-06T09:00:00.000Z
52
+ def utc_start_time
53
+ @time.utc.strftime("%Y-%m-%dT%H:%M:%S.000Z")
54
+ end
55
+
56
+ # Public: The stop time of this measurement, it's assumed to be 1 hour after
57
+ # the start.
58
+ #
59
+ # Returns the time in the format of 2011-01-06T09:00:00.000Z
60
+ def utc_stop_time
61
+ (@time + 60*60).utc.strftime("%Y-%m-%dT%H:%M:%S.000Z")
62
+ end
63
+
64
+ def inspect
65
+ "<Sample #{@time} #{@kwh}>"
66
+ end
67
+ end
68
+ end