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