the_energy_detective 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: d4edee41131dbc88b75d2553880f43b25085409e
4
+ data.tar.gz: a898bcaa30fde51f9617720442f749b2c4f55d5f
5
+ SHA512:
6
+ metadata.gz: 72188fb54a41a3fd04a2f55f7f7ef867e780b0dc12dbfed3925fadfa674091fe8493121ed0e19ed6ce5779bc7085ffbd425bc4f9b2d666ad878c8aa6cbad6d74
7
+ data.tar.gz: 24ba5cc8ca71d5b2534c4e929038934ba1d45cc012c1109e1abd17d3148e5e2dca86b918b1a01767f0690e2ca9e3a1fcb1562103fd5586b6cd8f87750e75cdc0
data/Rakefile ADDED
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env rake
2
+ begin
3
+ require 'bundler/setup'
4
+ rescue LoadError
5
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
6
+ end
7
+ begin
8
+ require 'rdoc/task'
9
+ rescue LoadError
10
+ require 'rdoc/rdoc'
11
+ require 'rake/rdoctask'
12
+ RDoc::Task = Rake::RDocTask
13
+ end
14
+
15
+ RDoc::Task.new(:rdoc) do |rdoc|
16
+ rdoc.rdoc_dir = 'rdoc'
17
+ rdoc.title = 'The Energy Detective'
18
+ rdoc.options << '--line-numbers'
19
+ rdoc.rdoc_files.include('lib/**/*.rb')
20
+ end
21
+
22
+ Bundler::GemHelper.install_tasks
23
+
24
+ task :default => :rdoc
data/lib/ted/ecc.rb ADDED
@@ -0,0 +1,310 @@
1
+ require 'base64'
2
+ require 'csv'
3
+ require 'net/http'
4
+
5
+ require 'nokogiri'
6
+
7
+ require 'ted/mtu'
8
+ require 'ted/spyder'
9
+
10
+ module TED
11
+ class ECC
12
+ def initialize(host)
13
+ if host.is_a?(String)
14
+ @host = URI.parse(host)
15
+ else
16
+ @host = host.dup
17
+ end
18
+ @user = @host.user
19
+ @password = @host.password
20
+ @host.user = nil
21
+ @http = Net::HTTP.new(@host.host, @host.port)
22
+ @http.use_ssl = (@host.scheme == 'https')
23
+ @http.verify_mode = OpenSSL::SSL::VERIFY_NONE
24
+ end
25
+
26
+ # Removes the cached system layout, allowing access to newly defined
27
+ # MTUs[rdoc-ref:#mtus] and Spyders[rdoc-ref:#spyders]
28
+ def refresh
29
+ @mtus = nil
30
+ end
31
+
32
+ def current(source = :net)
33
+ params = {}
34
+ params[:T] = 0 # Power
35
+
36
+ params[:D] = case source
37
+ when :net
38
+ 0
39
+ when :load
40
+ 1
41
+ when :generation
42
+ 2
43
+ when MTU
44
+ params[:M] = source.index
45
+ 255
46
+ when :spyders
47
+ return spyders_current
48
+ else
49
+ raise ArgumentError, 'source must be :net, :load, :generation, or :spyders'
50
+ end
51
+
52
+ dashboard_data(Nokogiri::XML(query("api/DashData.xml", params)))
53
+ end
54
+
55
+ # A hash of the MTUs[rdoc-ref:MTU] connected to this ECC.
56
+ # It is indexed by both description and numerical index
57
+ def mtus
58
+ build_system_layout
59
+ @mtus
60
+ end
61
+
62
+ # A hash of the Spyders[rdoc-ref:Spyder::Group] connected to this ECC.
63
+ # It is index by both description and numerical index
64
+ def spyders
65
+ build_system_layout
66
+ @spyders
67
+ end
68
+
69
+ # Returns history for all connected MTUs[rdoc-ref:MTU] and Spyders[rdoc-ref:Spyder::Group]
70
+ # The return value is a hash indexed by the MTU or Spyder::Group, and a hash of timestamp,
71
+ # energy or power, and cost
72
+ def history(interval: :seconds)
73
+ raise ArgumentError, "invalid interval" unless INTERVALS.include?(interval)
74
+
75
+ params = {}
76
+
77
+ params[:T] = INTERVALS.index(interval) + 1
78
+
79
+ response = query("history/exportAll.csv", params)
80
+ result = {}
81
+ response.strip!
82
+ CSV.parse(response) do |(channel_name, timestamp, kwh, cost)|
83
+ channel = mtus[channel_name] || spyders[channel_name]
84
+ result[channel] ||= []
85
+ timestamp = case interval
86
+ when :seconds, :minutes, :hours
87
+ DateTime.strptime(timestamp, "%m/%d/%Y %H:%M:%S").to_time
88
+ when :days, :months
89
+ month, day, year = timestamp.split('/').map(&:to_i)
90
+ Date.new(year, month, day)
91
+ end
92
+ energy_key = [:seconds, :minutes].include?(interval) ? :power : :energy
93
+ result[channel] << {
94
+ timestamp: timestamp,
95
+ energy_key => (kwh.to_f * 1000).to_i,
96
+ cost: cost.to_f
97
+ }
98
+ end
99
+ result
100
+ end
101
+
102
+ # :nodoc:
103
+ def inspect
104
+ "#<TED::ECC:#{@host}>"
105
+ end
106
+
107
+ private
108
+
109
+ INTERVALS = [:seconds, :minutes, :hours, :days, :months].freeze
110
+ private_constant :INTERVALS
111
+
112
+ def history_by_source(source, interval, offset_range, date_range)
113
+ raise ArgumentError, "invalid interval" unless INTERVALS.include?(interval)
114
+
115
+
116
+ case source
117
+ when MTU
118
+ source_type = :mtu
119
+ when Spyder::Group
120
+ source_type = :spyder
121
+ end
122
+ raise ArgumentError, "interval cannot be seconds for a Spyder" if source_type == :spyder && interval == :seconds
123
+
124
+ params = {}
125
+
126
+ params[:D] = (source_type == :mtu ? 0 : 1)
127
+ params[:M] = source.index
128
+ params[:T] = case interval
129
+ when :seconds
130
+ 1
131
+ when :minutes
132
+ 2
133
+ when :hours
134
+ 3
135
+ when :days
136
+ 4
137
+ when :months
138
+ 5
139
+ end
140
+ params[:T] -= 1 if source_type == :spyder
141
+
142
+ if offset_range
143
+ if offset_range.end != Float::INFINITY
144
+ params[:C] = offset_range.end + 1
145
+ params[:C] -= 1 if offset_range.exclude_end?
146
+ end
147
+ if offset_range.begin != -Float::INFINITY && offset_range.begin != 0
148
+ raise ArgumentError, "cannot specify an offset for anything besides seconds" unless interval == :seconds
149
+ params[:I] = offset_range.begin
150
+ params[:C] -= params[:I] if params[:C]
151
+ end
152
+ end
153
+
154
+ if date_range
155
+ params[:S] = date_range.begin.to_i unless date_range.begin == -Float::INFINITY
156
+ if date_range.end != Float::INFINITY
157
+ end_timestamp = date_range.end.to_i
158
+ end_timestamp -= 1 if date_range.exclude_end?
159
+ params[:E] = end_timestamp
160
+ end
161
+ end
162
+
163
+ response = query("history/export.raw", params)
164
+ response.split("\n").map do |line|
165
+ data = Base64.decode64(line)
166
+ bytes = data.unpack('C*')
167
+ raise "Unknown header" unless bytes[0] == 0xa4
168
+ checksum = bytes[0..-2].inject(0, :+) % 256
169
+ raise "Wrong checksum" unless bytes[-1] == checksum
170
+ case source_type
171
+ when :mtu
172
+ case interval
173
+ when :seconds
174
+ _, timestamp, energy, cost, voltage, _ = data.unpack('CL<l<2S<C')
175
+ when :minutes
176
+ _, timestamp, energy, cost, voltage, _pf, _ = data.unpack('CL<l<2S<2C')
177
+ when :hours, :days
178
+ _, timestamp, energy, cost, _ = data.unpack('CL<l<2C')
179
+ when :months
180
+ _, timestamp, energy, cost, _min_charge, _fixed_charge, _demand_charge, _demand_charge_peak_power_average, _demand_charge_time, _demand_charge_tou, _ = data.unpack('CL<l<2L<2l<2L<C2')
181
+ end
182
+ when :spyder
183
+ _, timestamp, energy, cost, _ = data.unpack('CL<l<2C')
184
+ end
185
+ timestamp = Time.at(timestamp)
186
+ timestamp = timestamp.to_date if interval == :days || interval == :months
187
+ cost = cost.to_f / 100
188
+ voltage = voltage.to_f / 10 if voltage
189
+ energy_key = [:seconds, :minutes].include?(interval) ? :power : :energy
190
+ result = { timestamp: timestamp, energy_key => energy, cost: cost }
191
+ result[:voltage] = voltage if voltage
192
+ result
193
+ end
194
+ end
195
+
196
+ def spyders_current
197
+ xml = Nokogiri::XML(query('api/SpyderData.xml'))
198
+
199
+ result = {}
200
+ net_xml = xml.css("DashData")
201
+ result[:net] = dashboard_data(net_xml)
202
+
203
+ xml.css("Group").each_with_index do |group_xml, idx|
204
+ next unless (group = spyders[idx + 1])
205
+ result[group] = dashboard_data(group_xml)
206
+ end
207
+
208
+ result
209
+ end
210
+
211
+ def build_system_layout
212
+ return if @mtus
213
+
214
+ xml = Nokogiri::XML(query("api/SystemSettings.xml"))
215
+
216
+ mtus = []
217
+ xml.css("MTU").each do |mtu_xml|
218
+ description = mtu_xml.at_css("MTUDescription").text
219
+ mtus << MTU.new(self, mtus.length, description)
220
+ end
221
+
222
+ group_index = 1
223
+ @spyders = {}
224
+ xml.css("Spyder").each do |spyder_xml|
225
+ enabled = spyder_xml.at_css("Enabled").text == '1'
226
+ if !enabled
227
+ group_index += 8
228
+ next
229
+ end
230
+
231
+ cts = spyder_xml.css("CT").map do |ct_xml|
232
+ twenty_amp = ct_xml.at_css("Type").text == '1'
233
+ multiplier = ct_xml.at_css("Mult").text.to_i
234
+ multiplier = -(multiplier - 4) if multiplier > 4
235
+ description = ct_xml.at_css("Description").text
236
+ Spyder::CT.new(twenty_amp, multiplier, description)
237
+ end
238
+ groups = []
239
+ spyder_xml.css("Group").each do |group_xml|
240
+ description = group_xml.at_css("Description").text
241
+ ct_mask = group_xml.at_css("UseCT").text.to_i
242
+ group_cts = []
243
+ ct_index = 0
244
+ while ct_mask != 0
245
+ if (ct_mask & 1) == 1
246
+ group_cts << cts[ct_index]
247
+ end
248
+ ct_mask /= 2
249
+ ct_index += 1
250
+ end
251
+
252
+ unless group_cts.empty?
253
+ group = Spyder::Group.new(group_index, description, group_cts)
254
+ groups << group
255
+ @spyders[group_index] = @spyders[description] = group
256
+ end
257
+ group_index += 1
258
+ end
259
+ mtu_index = spyder_xml.at_css("MTUParent").text.to_i
260
+ mtu = mtus[mtu_index]
261
+ spyder = Spyder.new(mtu, cts, groups)
262
+ mtu.spyders << spyder
263
+ cts.each { |ct| ct.instance_variable_set(:@spyder, spyder) }
264
+ groups.each { |group| group.instance_variable_set(:@spyder, spyder) }
265
+ end
266
+
267
+ @mtus = {}
268
+ mtus.each { |mtu| @mtus[mtu.index] = @mtus[mtu.description] = mtu; mtu.spyders.freeze }
269
+ end
270
+
271
+ def query(path, params = nil)
272
+ uri = @host.merge(path)
273
+
274
+ uri.query = self.class.hash_to_query(params) if params
275
+ get = Net::HTTP::Get.new(uri)
276
+ get.basic_auth @user, @password if @user
277
+ response = @http.request(get)
278
+ response.body
279
+ end
280
+
281
+ def dashboard_data(xml)
282
+ now = xml.at_css('Now').text.to_i
283
+ today = xml.at_css('TDY').text.to_i
284
+ mtd = xml.at_css('MTD').text.to_i
285
+ { now: now, today: today, mtd: mtd }
286
+ end
287
+
288
+ def self.hash_to_query(hash)
289
+ hash.map{|k,v| "#{k}=#{v}" }.join("&")
290
+ end
291
+
292
+ def self.interpret_offsets(offset, limit)
293
+ return nil unless offset || limit
294
+ if offset.is_a?(Range)
295
+ raise ArgumentError, 'limit cannot be provided if offset is a range' if limit
296
+ return offset
297
+ end
298
+ return offset...(offset + limit) if offset && limit
299
+ return offset...Float::INFINITY if offset
300
+ return 0...limit # if limit
301
+ end
302
+
303
+ def self.interpret_dates(date_range, start_time, end_time)
304
+ raise ArgumentError, 'start_time cannot be specified with date_range' if date_range && start_time
305
+ raise ArgumentError, 'end_time cannot be specified with date_range' if date_range && start_time
306
+ return date_range if date_range
307
+ return (start_time && start_time.to_i || -Float::INFINITY)...(end_time && end_time.to_i || Float::INFINITY)
308
+ end
309
+ end
310
+ end
data/lib/ted/mtu.rb ADDED
@@ -0,0 +1,33 @@
1
+ module TED
2
+ class MTU
3
+ # The ECC this MTU belongs to
4
+ attr_reader :ecc
5
+
6
+ # The 0-based index of this MTU in the ECC
7
+ attr_reader :index
8
+
9
+ attr_reader :description
10
+
11
+ # An Array of Spyders[rdoc-ref:Spyder] connected to this MTU
12
+ attr_reader :spyders
13
+
14
+ def initialize(ecc, index, description)
15
+ @ecc, @index, @description, @spyders = ecc, index, description, []
16
+ end
17
+
18
+ def current
19
+ ecc.current(self)
20
+ end
21
+
22
+ def history(interval: :seconds, offset: nil, limit: nil, date_range: nil, start_time: nil, end_time: nil)
23
+ offset = ECC.send(:interpret_offsets, offset, limit)
24
+ date_range = ECC.send(:interpret_dates, date_range, start_time, end_time)
25
+ ecc.send(:history_by_source, self, interval, offset, date_range)
26
+ end
27
+
28
+ # :nodoc:
29
+ def inspect
30
+ "#<TED::MTU:#{index} #{description}>"
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,22 @@
1
+ module TED
2
+ class Spyder
3
+ class CT
4
+ # The Spyder this CT belongs to
5
+ attr_reader :spyder
6
+ attr_reader :multiplier, :description
7
+
8
+ def initialize(twenty_amp, multiplier, description)
9
+ @twenty_amp, @multiplier, @description = twenty_amp, multiplier, description
10
+ end
11
+
12
+ def twenty_amp?
13
+ @twenty_amp
14
+ end
15
+
16
+ # :nodoc:
17
+ def inspect
18
+ "#<TED::Spyder::CT #{description} multiplier=#{multiplier}>"
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,33 @@
1
+ module TED
2
+ class Spyder
3
+ class Group
4
+ # The Spyder this Group belongs to
5
+ attr_reader :spyder
6
+ # The one-based index of this Group in the entire ECC
7
+ attr_reader :index
8
+ attr_reader :description
9
+ # An Array of CTs[rdoc-ref:CT] that compose this Group
10
+ attr_reader :cts
11
+
12
+ def initialize(index, description, cts)
13
+ @index, @description, @cts = index, description, cts
14
+ end
15
+
16
+ # Current data for this Group
17
+ def current
18
+ spyder.mtu.ecc.current(:spyders)[self]
19
+ end
20
+
21
+ def history(interval: :minutes, offset: nil, limit: nil, date_range: nil, start_time: nil, end_time: nil)
22
+ offset = ECC.send(:interpret_offsets, offset, limit)
23
+ date_range = ECC.send(:interpret_dates, date_range, start_time, end_time)
24
+ spyder.mtu.ecc.send(:history_by_source, self, interval, offset, date_range)
25
+ end
26
+
27
+ # :nodoc:
28
+ def inspect
29
+ "#<TED::Spyder::Group:#{index} #{description} cts=#{cts.inspect}>"
30
+ end
31
+ end
32
+ end
33
+ end
data/lib/ted/spyder.rb ADDED
@@ -0,0 +1,22 @@
1
+ require 'ted/spyder/ct'
2
+ require 'ted/spyder/group'
3
+
4
+ module TED
5
+ class Spyder
6
+ # The MTU this Spyder belongs to
7
+ attr_reader :mtu
8
+ # An Array of CTs[rdoc-ref:CT] connected to this Spyder
9
+ attr_reader :cts
10
+ # An Array of Groups[rdoc-ref:Group] defined on this Spyder
11
+ attr_reader :groups
12
+
13
+ def initialize(mtu, cts, groups)
14
+ @mtu, @cts, @groups = mtu, cts, groups
15
+ end
16
+
17
+ # :nodoc:
18
+ def inspect
19
+ "#<Ted::Spyder>"
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,3 @@
1
+ module TED
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1 @@
1
+ require 'ted/ecc'
metadata ADDED
@@ -0,0 +1,80 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: the_energy_detective
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Cody Cutrer
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2015-06-15 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: nokogiri
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 1.6.6.2
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 1.6.6.2
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 10.4.2
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 10.4.2
41
+ description:
42
+ email:
43
+ - cody@cutrer.us
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - Rakefile
49
+ - lib/ted/ecc.rb
50
+ - lib/ted/mtu.rb
51
+ - lib/ted/spyder.rb
52
+ - lib/ted/spyder/ct.rb
53
+ - lib/ted/spyder/group.rb
54
+ - lib/ted/version.rb
55
+ - lib/the_energy_detective.rb
56
+ homepage: http://www.theenergydetective.com/
57
+ licenses:
58
+ - MIT
59
+ metadata: {}
60
+ post_install_message:
61
+ rdoc_options: []
62
+ require_paths:
63
+ - lib
64
+ required_ruby_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ required_rubygems_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: '0'
74
+ requirements: []
75
+ rubyforge_project:
76
+ rubygems_version: 2.4.5
77
+ signing_key:
78
+ specification_version: 4
79
+ summary: Client library for talking to a TED Home Pro
80
+ test_files: []