the_energy_detective 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 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: []