emu_power 1.2 → 1.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9d6c6e6d6a64f468e6444fadddc097273e53c3d9ac39137e5aa98b9572f880fe
4
- data.tar.gz: 975a4a0af9508b812694a3cfcb79feba1cf342f4751042d3d64b9bf34a87d423
3
+ metadata.gz: 1a735ca710b544606634af881808df7df97cac003dcd9425d0e6ad1800d94f5a
4
+ data.tar.gz: 407c3060e5fbe93825f4ddc86675a5bbf8c377d6758561345690ca7c8919114b
5
5
  SHA512:
6
- metadata.gz: 98bd516022ccc746b236943c64e6dacb2cde05e96936a21f9e45b762d35bb0833df82542c260586f3bdf20dc74506abc7dce7842656820484a1c8be03fe5f418
7
- data.tar.gz: d2175b774449b338d3ce38317551ab70ae2a6ca969ba2e43f8a26f9adc5eb05818dfc4785f6517b38c60137f33cbe25cc51dfee7b6453442467989e2e5cefdd1
6
+ metadata.gz: c37948b89801d604e9e92309c2c15c1c657c3d452be30d6243cabc9e05d5212a33b6e1b766c73cbe0b5850956b589fc6428ef69d1e0b11942648258a2338297b
7
+ data.tar.gz: 7e241b00ec429803aa74f12b81edde4cd5a30c968fcbc29f3ce618632fbad7d0d15bd862be58d89b7197c05bc9edf28c585eb6083dfe02fd737241ef745aefb0
data/lib/emu_power.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  class EmuPower
2
2
  end
3
3
 
4
- require 'emu_power/api'
5
- require 'emu_power/stream_parser'
6
- require 'emu_power/types'
7
- require 'emu_power/commands'
4
+ require_relative 'emu_power/api'
5
+ require_relative 'emu_power/notifications'
6
+ require_relative 'emu_power/commands'
data/lib/emu_power/api.rb CHANGED
@@ -2,133 +2,174 @@
2
2
  # unit. This API is asynchronous, and allows event handlers
3
3
  # to be registered for the various message types.
4
4
 
5
- require 'emu_power/stream_parser'
6
- require 'emu_power/types'
7
-
5
+ require_relative 'notifications'
8
6
  require 'serialport'
9
- require 'nokogiri'
10
7
 
11
8
  class EmuPower::Api
12
9
 
13
10
  LINE_TERMINATOR = "\r\n"
14
11
 
15
- # Initialize the serial connection and build notification histories
16
- def initialize(tty, history_length = 10)
12
+ attr_accessor :debug_mode
17
13
 
18
- @port = SerialPort.new(tty, baud: 115200)
14
+ # Initialize the serial connection and set up internal structures.
15
+ def initialize(tty, debug: false)
19
16
 
20
- @histories = {}
21
- @callbacks = {}
22
- EmuPower::Types::Notification.subclasses.each do |n|
23
- @histories[n] = Array.new(history_length)
24
- @callbacks[n] = nil
25
- end
17
+ @port = SerialPort.new(tty, 115200, 8, 1, SerialPort::NONE)
18
+
19
+ # Get rid of any existing buffered data - we only want to operate on
20
+ # fresh notifications.
21
+ @port.flush_input
22
+ @port.flush_output
23
+
24
+ @debug_mode = debug
25
+
26
+ reset_callbacks!
26
27
 
27
28
  end
28
29
 
29
- # Register the callback for specific notification events. Expects
30
- # a subclass of Types::Notification. If :global is passed for klass,
31
- # the callback will be triggered for every event in addition to the
32
- # normal callback. Note that only one callback may be registered
33
- # per event - setting another will replace the existing one.
30
+ # Register the callback for specific notification events. Expects either an
31
+ # EmuPower::Notifications::Notification subclass, or :global, or :fallback. If :global
32
+ # is passed, the callback will be fired on every notification. If :fallback is
33
+ # passed, the callback will be fired for every notification that does not have
34
+ # a specific callback registered already.
34
35
  def callback(klass, &block)
35
- @callbacks[klass] = block
36
+
37
+ if klass == :global || klass == 'global'
38
+ @global_callback = block
39
+ elsif klass == :fallback || klass == 'fallback'
40
+ @fallback_callback = block
41
+ elsif EmuPower::Notifications::Notification.subclasses.include?(klass)
42
+ @callbacks[klass] = block
43
+ else
44
+ klass_list = EmuPower::Notifications::Notification.subclasses.map(&:name).join(', ')
45
+ raise ArgumentError.new("Class must be :global, :fallback, or one of #{klass_list}")
46
+ end
47
+
36
48
  return true
49
+
50
+ end
51
+
52
+ # Reset all callbacks to the default no-op state.
53
+ def reset_callbacks!
54
+ @global_callback = nil
55
+ @fallback_callback = nil
56
+ @callbacks = {}
37
57
  end
38
58
 
39
59
  # Send a command to the device. Expects an instance of one of the
40
60
  # command classes defined in commands.rb. The serial connection
41
61
  # must be started before this can be used.
42
62
  def issue_command(obj)
43
- return false if @thread.nil?
44
- return false unless obj.respond_to?(:to_command)
63
+
64
+ return false if @thread.nil? || !obj.respond_to?(:to_command)
65
+
45
66
  xml = obj.to_command
46
67
  @port.write(xml)
68
+
47
69
  return true
70
+
48
71
  end
49
72
 
50
- # Begin polling for serial data. We spawn a new thread to
51
- # handle this so we don't block input. If blocking is set
52
- # to true, this method blocks indefinitely. If false, it
53
- # returns true and expects the caller to handle things.
54
- def start_serial(interval: 1, blocking: true)
73
+ # Begin polling for serial data. We spawn a new thread to handle this so we don't
74
+ # block input. This method blocks until the reader thread terminates, which in most
75
+ # cases is never. This should usually be called at the end of a program after all
76
+ # callbacks are registered. If blocking is set to false, returns immediately and
77
+ # lets the caller handle the spawned thread. Non-blocking mode should mostly be
78
+ # used for development purposes; most production scripts should use blocking mode
79
+ # and callbacks.
80
+ def start_serial(blocking = true)
55
81
 
56
82
  return false unless @thread.nil?
57
83
 
58
- parser = construct_parser
59
-
60
84
  @thread = Thread.new do
85
+
86
+ # Define boundary tags
87
+ root_elements = EmuPower::Notifications.notify_roots
88
+ start_tags = root_elements.map { |v| "<#{v}>" }
89
+ stop_tags = root_elements.map { |v| "</#{v}>" }
90
+
91
+ current_notify = ''
92
+
93
+ # Build up complete XML fragments line-by-line and dispatch callbacks
61
94
  loop do
62
- begin
63
- parser.parse
64
- sleep(interval)
65
- rescue Nokogiri::XML::SyntaxError
66
- # This means that we probably connected in the middle
67
- # of a message, so just reset the parser.
68
- parser = construct_parser
95
+
96
+ line = @port.readline(LINE_TERMINATOR).strip
97
+
98
+ if start_tags.include?(line)
99
+ current_notify = line
100
+
101
+ elsif stop_tags.include?(line)
102
+
103
+ xml = current_notify + line
104
+ current_notify = ''
105
+
106
+ begin
107
+ obj = EmuPower::Notifications.construct(xml)
108
+ rescue StandardError
109
+ puts "Failed to construct object for XML fragment: #{xml}" if @debug_mode
110
+ next
111
+ end
112
+
113
+ if obj
114
+ puts obj if @debug_mode
115
+ perform_callbacks(obj)
116
+ else
117
+ puts "Incomplete XML stream: #{xml}" if @debug_mode
118
+ end
119
+
120
+ else
121
+ current_notify += line
69
122
  end
123
+
70
124
  end
71
125
  end
72
126
 
73
127
  if blocking
74
- @thread.join
128
+
129
+ # Block until thread is terminated, and ensure we clean up after ourselves.
130
+ begin
131
+ @thread.join
132
+ ensure
133
+ stop_serial if @thread
134
+ end
135
+
75
136
  else
76
- return true
137
+ return @thread
77
138
  end
78
139
 
79
140
  end
80
141
 
81
- # Stop polling for data. Already-received objects will
82
- # remain available.
142
+ # Terminate the reader thread. The start_serial method will return
143
+ # once this is called. This will usually be called from a signal
144
+ # trap or similar, since the main program will usually be blocked
145
+ # by start_serial.
83
146
  def stop_serial
147
+
84
148
  return false if @thread.nil?
149
+
85
150
  @thread.terminate
86
151
  @thread = nil
87
- return true
88
- end
89
152
 
90
- # Get the full history buffer for a given notify type
91
- def history_for(klass)
92
- return @histories[klass].compact
93
- end
153
+ return true
94
154
 
95
- # Get the most recent object for the given type
96
- def current(klass)
97
- return history_for(klass)[0]
98
155
  end
99
156
 
100
157
  private
101
158
 
102
- # Handle the completed hash objects when notified by the parser
103
- def handle_response(obj)
104
-
105
- container = EmuPower::Types.construct(obj)
159
+ # Dispatch the appropriate callback
160
+ def perform_callbacks(obj)
106
161
 
107
- if container == nil
108
- puts "BAD OBJECT #{obj}"
109
- else
110
- push_history(container)
111
- @callbacks[container.class]&.call(container)
112
- @callbacks[:global]&.call(container)
113
- end
162
+ klass = obj.class
114
163
 
115
- end
164
+ # Fire global callback
165
+ @global_callback&.call(obj)
116
166
 
117
- # Helper for initializing underlying parser
118
- def construct_parser
119
- return EmuPower::StreamParser.new(@port, LINE_TERMINATOR, EmuPower::Types.notify_roots) do |obj|
120
- handle_response(obj)
167
+ klass_specific = @callbacks[klass]
168
+ if klass_specific
169
+ klass_specific.call(obj)
170
+ else
171
+ @fallback_callback&.call(obj)
121
172
  end
122
- end
123
-
124
- # Helper for inserting object into appropriate history queue
125
- def push_history(obj)
126
-
127
- type = obj.class
128
-
129
- old = @histories[type].pop
130
- @histories[type].prepend(obj)
131
- return old
132
173
 
133
174
  end
134
175
 
@@ -1,5 +1,6 @@
1
- # Collection of command types for controlling various
2
- # functions on the EMU device.
1
+ # Collection of command types for controlling various functions on the EMU
2
+ # device. These should be constructed and passed as arguments to the API
3
+ # object's issue_command method.
3
4
 
4
5
  class EmuPower::Commands
5
6
 
@@ -32,49 +33,108 @@ class EmuPower::Commands
32
33
 
33
34
  end
34
35
 
35
- class GetNetworkInfo < Command
36
+ # Helper class for defining basic commands easily. Uses the current class
37
+ # name to define the Command Name element of the output XML.
38
+ class BasicCommand < Command
36
39
  def initialize
37
- super('get_network_info')
40
+
41
+ class_name = self.class.name.split('::').last
42
+ command_name = class_name
43
+ .gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2')
44
+ .gsub(/([a-z\d])([A-Z])/,'\1_\2')
45
+ .tr("-", "_")
46
+ .downcase
47
+ super(command_name)
48
+
38
49
  end
39
50
  end
40
51
 
41
- class GetNetworkStatus < Command
42
- def initialize
43
- super('get_network_status')
44
- end
52
+
53
+ # Restart the EMU device.
54
+ class Restart < BasicCommand
45
55
  end
46
56
 
47
- class GetInstantaneousDemand < Command
48
- def initialize
49
- super('get_instantaneous_demand')
50
- end
57
+ # Get the current time. Triggers a TimeCluster notification.
58
+ class GetTime < BasicCommand
51
59
  end
52
60
 
53
- class GetPrice < Command
54
- def initialize
55
- super('get_price')
56
- end
61
+ # Get current messages from the device. Triggers a MessageCluster
62
+ # notification for each message.
63
+ class GetMessage < BasicCommand
57
64
  end
58
65
 
59
- class GetMessage < Command
60
- def initialize
61
- super('get_message')
62
- end
66
+ # Request information about the meter network. Triggers a
67
+ # NetworkInfo notification.
68
+ class GetNetworkInfo < BasicCommand
63
69
  end
64
70
 
65
- # TODO: Confirm Message
71
+ # Request information about the connection between the EMU-2
72
+ # and the meter. Triggers a ConnectionStatus notification.
73
+ class GetConnectionStatus < BasicCommand
74
+ end
66
75
 
67
- class GetCurrentSummation < Command
68
- def initialize
69
- super('get_current_summation')
70
- end
76
+ # Request a list of all connected meters. This triggers one
77
+ # MeterList notification for each connected meter.
78
+ class GetMeterList < BasicCommand
71
79
  end
72
80
 
73
- # TODO: Get History Data
81
+ # Get detailed info on a specific meter. If more than one
82
+ # meter is connected, a MAC must be passed to identify
83
+ # the target. Triggers a MeterInfo notification.
84
+ # TODO: Allow passing meter MAC argument
85
+ class GetMeterInfo < BasicCommand
86
+ end
87
+
88
+ # Request information about the EMU-2 device. Triggers a
89
+ # DeviceInfo notification.
90
+ class GetDeviceInfo < BasicCommand
91
+ end
74
92
 
75
- # Note: This doesn't seem to work. The command is issued successfully, but
76
- # the EMU does not update any schedule info. This may be disallowed by the
77
- # meter or something.
93
+ # Get the current fast poll period. Triggers a FastPollStatus
94
+ # notification.
95
+ class GetFastPollStatus < BasicCommand
96
+ end
97
+
98
+ # Get the running total since the last CloseCurrentPeriod
99
+ # command was issued. Triggers a CurrentPeriodUsage notify
100
+ class GetCurrentPeriodUsage < BasicCommand
101
+ end
102
+
103
+ # Close out the current billing period. Does not trigger
104
+ # any notifications.
105
+ class CloseCurrentPeriod < BasicCommand
106
+ end
107
+
108
+ # Get the previous billing period's usage. Triggers a TODO
109
+ class GetLastPeriodUsage < BasicCommand
110
+ end
111
+
112
+ # Get the current power draw in kilowatts. Triggers an
113
+ # InstantaneousDemand notification.
114
+ class GetInstantaneousDemand < BasicCommand
115
+ end
116
+
117
+ # Get the current meter reading. This is independent of the
118
+ # current period usage.
119
+ class GetCurrentSummationDelivered < BasicCommand
120
+ end
121
+
122
+ # Get the current electricity rate. This is either provided
123
+ # by the meter, or set manually on the device during setup.
124
+ # This triggers a PriceCluster notification.
125
+ class GetCurrentPrice < BasicCommand
126
+ end
127
+
128
+ # Get the current block prices. This triggers a BlockPriceDetail
129
+ # notification, and is only applicable to block-based billing
130
+ # schemes.
131
+ class GetPriceBlocks < BasicCommand
132
+ end
133
+
134
+ # Set the notification schedule on the EMU. Note: this only seems to be effective shortly after the
135
+ # unit starts up, while the modes of the schedule are all 'default'. After that, the meter seems to
136
+ # push a schedule configuration and set the mode to 'rest', which overwrites the existing schedule
137
+ # and ignores subsequent SetSchedule commands.
78
138
  class SetSchedule < Command
79
139
 
80
140
  EVENTS = %w[time message price summation demand scheduled_prices profile_data billing_period block_period]
@@ -89,7 +149,8 @@ class EmuPower::Commands
89
149
 
90
150
  end
91
151
 
92
- # TODO: Add event field
152
+ # Get the current event schedule. This triggers one ScheduleInfo notification for
153
+ # each of the listed event types.
93
154
  class GetSchedule < Command
94
155
 
95
156
  EVENTS = %w[time message price summation demand scheduled_prices profile_data billing_period block_period]
@@ -104,8 +165,7 @@ class EmuPower::Commands
104
165
  end
105
166
 
106
167
  end
107
- end
108
168
 
109
- # TODO: Reboot
169
+ end
110
170
 
111
171
  end
@@ -0,0 +1,392 @@
1
+ # Notification types. Provides convenience calculators and
2
+ # accessors for the notifications sent by the EMU device.
3
+
4
+ require 'nori'
5
+
6
+ class EmuPower::Notifications
7
+
8
+ # Base class for notifications
9
+ class Notification
10
+
11
+ # Timestamp of Jan 1st 2000. Used to shift epoch to standard timestamp.
12
+ UNIX_TIME_OFFSET = 946684800
13
+
14
+ attr_accessor :raw
15
+ attr_accessor :device_mac
16
+ attr_accessor :meter_mac
17
+ attr_accessor :timestamp
18
+
19
+ def initialize(hash)
20
+
21
+ @raw = hash
22
+
23
+ # All messages may contain this metadata
24
+ @device_mac = @raw['DeviceMacId']
25
+ @meter_mac = @raw['MeterMacId']
26
+
27
+ # The EMU sets timestamps relative to Jan 1st 2000 UTC. We convert
28
+ # these into more standard Unix epoch timestamps by adding the
29
+ # appropriate offset.
30
+ @timestamp = parse_timestamp('TimeStamp')
31
+
32
+ # Build out type-specific fields
33
+ build(hash)
34
+
35
+ end
36
+
37
+ def build(hash)
38
+ # Overridden by subclasses
39
+ end
40
+
41
+ def parse_timestamp(prop)
42
+ v = @raw[prop]
43
+ return nil if v == nil
44
+ return Integer(v) + 946684800
45
+ end
46
+
47
+ def parse_hex(prop)
48
+ v = @raw[prop]
49
+ return nil if v.nil?
50
+ return Integer(v)
51
+ end
52
+
53
+ def parse_bool(prop)
54
+ v = @raw[prop]
55
+ return nil if v.nil?
56
+ return (@raw[prop] == 'Y') ? true : false
57
+ end
58
+
59
+ # Calculate real total from divisors and multipliers
60
+ def parse_amount(prop, mul_prop = 'Multiplier', div_prop = 'Divisor')
61
+
62
+ multiplier = parse_hex(mul_prop)
63
+ divisor = parse_hex(div_prop)
64
+ v = parse_hex(prop)
65
+
66
+ return 0.0 if v.nil? || multiplier.nil? || divisor.nil?
67
+ return multiplier * v / Float(divisor)
68
+
69
+ end
70
+
71
+ def to_s
72
+ "#{self.class.root_name} Notification: #{@raw.to_s}"
73
+ end
74
+
75
+ # Name of the XML root object corresponding to this type
76
+ def self.root_name
77
+ return self.name.split('::').last
78
+ end
79
+
80
+ def self.subclasses
81
+ return ObjectSpace.each_object(::Class).select do |k|
82
+ k < self
83
+ end
84
+ end
85
+
86
+ end
87
+
88
+ # Dispatch to the appropriate container class based
89
+ # on the type. Expects a data hash. Returns nil on
90
+ # bad message.
91
+ def self.construct(xml)
92
+
93
+ hash = Nori.new.parse(xml)
94
+
95
+ # Extract the root of the hash and dispatch to the appropriate
96
+ # container class.
97
+ type, data = hash.first
98
+
99
+ return nil unless notify_roots.include?(type)
100
+
101
+ klass = self.const_get(type)
102
+ return klass.new(data)
103
+
104
+ end
105
+
106
+ # Helper to get the element names of all types
107
+ def self.notify_roots
108
+ return Notification.subclasses.map(&:root_name)
109
+ end
110
+
111
+ ###
112
+ # Begin notification objects
113
+ ###
114
+
115
+ class TimeCluster < Notification
116
+
117
+ attr_accessor :utc_time
118
+ attr_accessor :local_time
119
+
120
+ def build(hash)
121
+ self.utc_time = parse_timestamp('UTCTime')
122
+ self.local_time = parse_timestamp('LocalTime')
123
+ end
124
+
125
+ end
126
+
127
+ class MessageCluster < Notification
128
+
129
+ attr_accessor :id
130
+ attr_accessor :text
131
+ attr_accessor :priority
132
+ attr_accessor :start_time
133
+ attr_accessor :duration
134
+ attr_accessor :confirmation_required
135
+ attr_accessor :confirmed
136
+ attr_accessor :queue
137
+
138
+ def build(hash)
139
+ self.id = hash['Id']
140
+ self.text = hash['Text']
141
+ self.priority = hash['Priority']
142
+ self.start_time = parse_timestamp('StartTime')
143
+ self.duration = parse_hex('Duration')
144
+ self.confirmation_required = parse_bool('ConfirmationRequired')
145
+ self.confirmed = parse_bool('Confirmed')
146
+ self.queue = hash['Queue']
147
+ end
148
+
149
+ end
150
+
151
+ class NetworkInfo < Notification
152
+
153
+ attr_accessor :coordinator_mac
154
+ attr_accessor :status
155
+ attr_accessor :description
156
+ attr_accessor :pan_id
157
+ attr_accessor :channel
158
+ attr_accessor :short_address
159
+ attr_accessor :link_strength
160
+
161
+ def build(hash)
162
+ self.coordinator_mac = parse_hex('CoordMacId')
163
+ self.status = hash['Status']
164
+ self.description = hash['Description']
165
+ self.pan_id = hash['ExtPanId']
166
+ self.channel = hash['Channel']
167
+ self.short_address = hash['ShortAddr']
168
+ self.link_strength = parse_hex('LinkStrength')
169
+ end
170
+
171
+ end
172
+
173
+ class ConnectionStatus < Notification
174
+
175
+ attr_accessor :status
176
+ attr_accessor :description
177
+ attr_accessor :pan_id
178
+ attr_accessor :channel
179
+ attr_accessor :short_address
180
+ attr_accessor :link_strength
181
+
182
+ def build(hash)
183
+ self.status = hash['Status']
184
+ self.description = hash['Description']
185
+ self.pan_id = hash['ExtPanId']
186
+ self.channel = hash['Channel']
187
+ self.short_address = hash['ShortAddr']
188
+ self.link_strength = parse_hex('LinkStrength')
189
+ end
190
+
191
+ end
192
+
193
+ # Note: This has no fields except DeviceMacId and MeterMacId
194
+ class MeterList < Notification
195
+ end
196
+
197
+ class MeterInfo < Notification
198
+
199
+ attr_accessor :type
200
+ attr_accessor :nickname
201
+ attr_accessor :account
202
+ attr_accessor :auth
203
+ attr_accessor :host
204
+ attr_accessor :enabled
205
+
206
+ def build(hash)
207
+ self.type = parse_hex('Type')
208
+ self.nickname = hash['Nickname']
209
+ self.account = hash['Account']
210
+ self.auth = hash['Auth']
211
+ self.host = hash['Host']
212
+ self.enabled = parse_bool('Enabled')
213
+ end
214
+
215
+ end
216
+
217
+ class DeviceInfo < Notification
218
+
219
+ attr_accessor :install_code
220
+ attr_accessor :link_key
221
+ attr_accessor :firmware_version
222
+ attr_accessor :hardware_version
223
+ attr_accessor :image_type
224
+ attr_accessor :manufacturer
225
+ attr_accessor :model
226
+ attr_accessor :date_code
227
+
228
+ def build(hash)
229
+ self.install_code = hash['InstallCode']
230
+ self.link_key = hash['LinkKey']
231
+ self.firmware_version = hash['FWVersion']
232
+ self.hardware_version = hash['HWVersion']
233
+ self.image_type = hash['ImageType']
234
+ self.manufacturer = hash['Manufacturer']
235
+ self.model = hash['ModelId']
236
+ self.date_code = hash['DateCode']
237
+ end
238
+
239
+ end
240
+
241
+ class FastPollStatus < Notification
242
+
243
+ attr_accessor :frequency
244
+ attr_accessor :end_time
245
+
246
+ def build(hash)
247
+ self.frequency = parse_hex('Frequency')
248
+ self.end_time = parse_timestamp('EndTime')
249
+ end
250
+
251
+ end
252
+
253
+ class CurrentPeriodUsage < Notification
254
+
255
+ attr_accessor :usage
256
+ attr_accessor :digits_right
257
+ attr_accessor :digits_left
258
+ attr_accessor :suppress_leading_zeroes
259
+ attr_accessor :start_date
260
+
261
+ def build(hash)
262
+ self.usage = parse_amount('CurrentUsage')
263
+ self.digits_right = parse_hex('DigitsRight')
264
+ self.digits_left = parse_hex('DigitsLeft')
265
+ self.suppress_leading_zeroes = parse_bool('SuppressLeadingZero')
266
+ self.start_date = parse_timestamp('StartDate')
267
+
268
+ end
269
+
270
+ end
271
+
272
+ class LastPeriodUsage < Notification
273
+
274
+ attr_accessor :usage
275
+ attr_accessor :digits_right
276
+ attr_accessor :digits_left
277
+ attr_accessor :suppress_leading_zeroes
278
+ attr_accessor :start_date
279
+ attr_accessor :end_date
280
+
281
+ def build(hash)
282
+ self.usage = parse_amount('LastUsage')
283
+ self.digits_right = parse_hex('DigitsRight')
284
+ self.digits_left = parse_hex('DigitsLeft')
285
+ self.suppress_leading_zeroes = parse_bool('SuppressLeadingZero')
286
+ self.start_date = parse_timestamp('StartDate')
287
+ self.end_date = parse_timestamp('EndDate')
288
+ end
289
+
290
+ end
291
+
292
+ class InstantaneousDemand < Notification
293
+
294
+ attr_accessor :demand
295
+ attr_accessor :digits_right
296
+ attr_accessor :digits_left
297
+ attr_accessor :suppress_leading_zeroes
298
+
299
+ def build(hash)
300
+ self.demand = parse_amount('Demand')
301
+ self.digits_right = parse_hex('DigitsRight')
302
+ self.digits_left = parse_hex('DigitsLeft')
303
+ self.suppress_leading_zeroes = parse_bool('SuppressLeadingZero')
304
+ end
305
+
306
+ end
307
+
308
+ class CurrentSummationDelivered < Notification
309
+
310
+ attr_accessor :delivered
311
+ attr_accessor :received
312
+ attr_accessor :digits_right
313
+ attr_accessor :digits_left
314
+ attr_accessor :suppress_leading_zeroes
315
+
316
+ def build(hash)
317
+ self.delivered = parse_amount('SummationDelivered')
318
+ self.received = parse_amount('SummationReceived')
319
+ self.digits_right = parse_hex('DigitsRight')
320
+ self.digits_left = parse_hex('DigitsLeft')
321
+ self.suppress_leading_zeroes = parse_bool('SuppressLeadingZero')
322
+ end
323
+
324
+ end
325
+
326
+ class PriceCluster < Notification
327
+
328
+ attr_accessor :price
329
+ attr_accessor :currency_code # This is an ISO 3-digit currency code. 840 is USD
330
+ attr_accessor :trailing_digits
331
+ attr_accessor :tier
332
+ attr_accessor :start_time
333
+ attr_accessor :duration
334
+ attr_accessor :label
335
+
336
+ def build(hash)
337
+ self.price = parse_hex('Price')
338
+ self.currency_code = parse_hex('Currency')
339
+ self.trailing_digits = parse_hex('TrailingDigits')
340
+ self.tier = parse_hex('Tier')
341
+ self.start_time = parse_timestamp('StartTime')
342
+ self.duration = parse_hex('Duration')
343
+ self.label = hash['RateLabel']
344
+ end
345
+
346
+ end
347
+
348
+ class BlockPriceDetail < Notification
349
+
350
+ attr_accessor :current_start
351
+ attr_accessor :current_duration
352
+ attr_accessor :block_consumption
353
+ attr_accessor :number_of_blocks
354
+ attr_accessor :currency_code
355
+ attr_accessor :trailing_digits
356
+
357
+ def build(hash)
358
+ self.current_start = parse_timestamp('CurrentStart')
359
+ self.current_duration = parse_hex('CurrentDuration')
360
+ self.block_consumption = parse_amount(
361
+ 'BlockPeriodConsumption',
362
+ 'BlockPeriodConsumptionMultiplier',
363
+ 'BlockPeriodConsumptionDivisor'
364
+ )
365
+
366
+ # Note: Not sure if multiplier/divisor are supposed to tie in here
367
+ self.number_of_blocks = parse_amount('NumberOfBlocks')
368
+
369
+ self.currency_code = parse_hex('Currency')
370
+ self.trailing_digits = parse_hex('TrailingDigits')
371
+
372
+ end
373
+
374
+ end
375
+
376
+ class ScheduleInfo < Notification
377
+
378
+ attr_accessor :mode
379
+ attr_accessor :event
380
+ attr_accessor :frequency
381
+ attr_accessor :enabled
382
+
383
+ def build(hash)
384
+ self.mode = hash['Mode']
385
+ self.event = hash['Event']
386
+ self.frequency = parse_hex('Frequency')
387
+ self.enabled = parse_bool('Enabled')
388
+ end
389
+
390
+ end
391
+
392
+ end
metadata CHANGED
@@ -1,29 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: emu_power
3
3
  version: !ruby/object:Gem::Version
4
- version: '1.2'
4
+ version: '1.3'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Steven Bertolucci
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-02-26 00:00:00.000000000 Z
11
+ date: 2021-02-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: nokogiri
14
+ name: nori
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '1.11'
19
+ version: '2.6'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '1.11'
26
+ version: '2.6'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: serialport
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -47,8 +47,7 @@ files:
47
47
  - lib/emu_power.rb
48
48
  - lib/emu_power/api.rb
49
49
  - lib/emu_power/commands.rb
50
- - lib/emu_power/stream_parser.rb
51
- - lib/emu_power/types.rb
50
+ - lib/emu_power/notifications.rb
52
51
  - readme.md
53
52
  homepage: https://github.com/Steve0320/EmuPower
54
53
  licenses:
@@ -1,118 +0,0 @@
1
- # SAX parser implementation for processing a stream of
2
- # XML fragments.
3
-
4
- require 'nokogiri'
5
-
6
- class EmuPower::StreamParser < Nokogiri::XML::SAX::Document
7
-
8
- FAKEROOT = 'FAKEROOT'
9
-
10
- def initialize(io, line_terminator, roots, &block)
11
-
12
- @line_terminator = line_terminator
13
- @io = io
14
-
15
- # Use a push parser so we can fake a single root element
16
- @parser = Nokogiri::XML::SAX::PushParser.new(Parser.new(FAKEROOT, roots, &block))
17
-
18
- # This is the "root" of the document. We intentionally never close this
19
- # so that the parser doesn't get mad when it encounters multiple real
20
- # root elements.
21
- @parser << "<#{FAKEROOT}>"
22
-
23
- end
24
-
25
- # Push all new lines from the io into the parser. The parser
26
- # will fire the callback given on construction once a whole
27
- # object has been processed.
28
- def parse
29
- lines = @io.readlines(@line_terminator)
30
- lines.each { |l| @parser << l }
31
- end
32
-
33
- # Nokogiri parser definition. Processes a flat XML
34
- # stream with multiple roots.
35
- class Parser < Nokogiri::XML::SAX::Document
36
-
37
- # Initialize the set of root tags to consider
38
- def initialize(fakeroot, roots, &block)
39
-
40
- @roots = roots
41
-
42
- @current_object = nil
43
- @current_property = nil
44
- @current_root = nil
45
-
46
- @callback = block
47
-
48
- # All element parsers ignore this tag. This is only
49
- # used to persuade Nokogiri to parse multiple roots
50
- # in a single stream without getting mad.
51
- @fakeroot = fakeroot
52
-
53
- end
54
-
55
- # For each tag, initialize a root element if we don't already have
56
- # one. Otherwise, consider it a property of the current element.
57
- def start_element(name, attrs = [])
58
-
59
- return if name == @fakeroot
60
- return if @current_object == nil && !@roots.include?(name)
61
-
62
- if @current_object == nil
63
- @current_root = name
64
- @current_object = { "MessageType" => name }
65
- else
66
- @current_property = name
67
- end
68
-
69
- end
70
-
71
- # Populate the content of the current element
72
- def characters(str)
73
- if @current_object != nil && @current_property != nil
74
-
75
- #cur = @current_object[@current_property]
76
- #return if cur == str
77
-
78
- # Wrap into array if we already have a value (XML permits duplicates)
79
- #cur = [cur] unless cur == nil
80
-
81
- #if cur.kind_of?(Array)
82
- # cur << str
83
- #else
84
- # cur = str
85
- #end
86
-
87
- #@current_object[@current_property] = cur
88
-
89
- @current_object[@current_property] = str
90
-
91
- end
92
- end
93
-
94
- # Close out the current tag and clear context
95
- def end_element(name, attrs = [])
96
-
97
- return if name == @fakeroot
98
-
99
- if @current_root == name
100
-
101
- if @callback != nil
102
- @callback.call(@current_object)
103
- else
104
- puts "DEBUG: #{@current_object}"
105
- end
106
-
107
- @current_object = nil
108
- @current_root = nil
109
-
110
- elsif @current_object != nil
111
- @current_property = nil
112
- end
113
-
114
- end
115
-
116
- end
117
-
118
- end
@@ -1,212 +0,0 @@
1
- # Notification types. Provides convenience calculators and
2
- # accessors for the notifications sent by the EMU device.
3
- class EmuPower::Types
4
-
5
- # Base class for notifications
6
- class Notification
7
-
8
- UNIX_TIME_OFFSET = 946684800
9
-
10
- attr_accessor :raw
11
- attr_accessor :device_mac
12
- attr_accessor :meter_mac
13
- attr_accessor :timestamp
14
-
15
- def initialize(hash)
16
- @raw = hash
17
- @device_mac = @raw['DeviceMacId']
18
- @meter_mac = @raw['MeterMacId']
19
- build(hash)
20
- end
21
-
22
- def build(hash)
23
- end
24
-
25
- # The EMU sets timestamps relative to Jan 1st 2000 UTC. We convert
26
- # these into more standard Unix epoch timestamps by adding the
27
- # appropriate offset.
28
- def timestamp
29
- parse_timestamp('TimeStamp')
30
- end
31
-
32
- def parse_timestamp(prop)
33
- v = @raw[prop]
34
- return nil if v == nil
35
- return Integer(v) + 946684800
36
- end
37
-
38
- def parse_hex(prop)
39
- v = @raw[prop]
40
- return nil if v.nil?
41
- return Integer(v)
42
- end
43
-
44
- def parse_bool(prop)
45
- v = @raw[prop]
46
- return nil if v.nil?
47
- return (@raw[prop] == 'Y') ? true : false
48
- end
49
-
50
- # Name of the XML root object corresponding to this type
51
- def self.root_name
52
- return self.name.split('::').last
53
- end
54
-
55
- def self.subclasses
56
- return ObjectSpace.each_object(::Class).select do |k|
57
- k < self
58
- end
59
- end
60
-
61
- end
62
-
63
- # TODO
64
- class ConnectionStatus < Notification
65
- end
66
-
67
- # TODO
68
- class DeviceInfo < Notification
69
- end
70
-
71
- class ScheduleInfo < Notification
72
-
73
- attr_accessor :mode
74
- attr_accessor :event
75
- attr_accessor :frequency
76
- attr_accessor :enabled
77
-
78
- def build(hash)
79
- self.mode = hash['Mode']
80
- self.event = hash['Event']
81
- self.frequency = parse_hex('Frequency')
82
- self.enabled = parse_bool('Enabled')
83
- end
84
-
85
- end
86
-
87
- # TODO
88
- class MeterList < Notification
89
- end
90
-
91
- # TODO
92
- class MeterInfo < Notification
93
- end
94
-
95
- # TODO
96
- class NetworkInfo < Notification
97
- end
98
-
99
- class TimeCluster < Notification
100
-
101
- attr_accessor :utc_time
102
- attr_accessor :local_time
103
-
104
- def build(hash)
105
- self.utc_time = parse_timestamp('UTCTime')
106
- self.local_time = parse_timestamp('LocalTime')
107
- end
108
-
109
- end
110
-
111
- # TODO
112
- class MessageCluster < Notification
113
- end
114
-
115
- # TODO
116
- class PriceCluster < Notification
117
- end
118
-
119
- class InstantaneousDemand < Notification
120
-
121
- attr_accessor :raw_demand
122
- attr_accessor :multiplier
123
- attr_accessor :divisor
124
- attr_accessor :digits_right
125
- attr_accessor :digits_left
126
- attr_accessor :suppress_leading_zeroes
127
-
128
- def build(hash)
129
- self.raw_demand = parse_hex('Demand')
130
- self.multiplier = parse_hex('Multiplier')
131
- self.divisor = parse_hex('Divisor')
132
- self.digits_right = parse_hex('DigitsRight')
133
- self.digits_left = parse_hex('DigitsLeft')
134
- self.suppress_leading_zeroes = parse_bool('SuppressLeadingZero')
135
- end
136
-
137
- # Return computed demand in KW. This may return nil if data is missing.
138
- def demand
139
- return 0 if self.divisor == 0
140
- return nil if self.multiplier.nil? || self.raw_demand.nil? || self.divisor.nil?
141
- return self.multiplier * self.raw_demand / Float(self.divisor)
142
- end
143
-
144
- end
145
-
146
- class CurrentSummationDelivered < Notification
147
-
148
- attr_accessor :raw_delivered
149
- attr_accessor :raw_received
150
- attr_accessor :multiplier
151
- attr_accessor :divisor
152
- attr_accessor :digits_right
153
- attr_accessor :digits_left
154
- attr_accessor :suppress_leading_zeroes
155
-
156
- def build(hash)
157
-
158
- self.raw_delivered = parse_hex('SummationDelivered')
159
- self.raw_received = parse_hex('SummationReceived')
160
- self.multiplier = parse_hex('Multiplier')
161
- self.divisor = parse_hex('Divisor')
162
- self.digits_right = parse_hex('DigitsRight')
163
- self.digits_left = parse_hex('DigitsLeft')
164
- self.suppress_leading_zeroes = parse_bool('SuppressLeadingZero')
165
-
166
- end
167
-
168
- def delivered
169
- return 0 if self.raw_delivered == 0
170
- return nil if self.multiplier.nil? || self.raw_delivered.nil? || self.divisor.nil?
171
- return self.multiplier * self.raw_delivered / Float(self.divisor)
172
- end
173
-
174
- def received
175
- return 0 if self.divisor == 0
176
- return nil if self.multiplier.nil? || self.raw_received.nil? || self.divisor.nil?
177
- return self.multiplier * self.raw_received / Float(self.divisor)
178
- end
179
-
180
- end
181
-
182
- # TODO
183
- class CurrentPeriodUsage < Notification
184
- end
185
-
186
- # TODO
187
- class LastPeriodUsage < Notification
188
- end
189
-
190
- # TODO
191
- class ProfileData < Notification
192
- end
193
-
194
- # Dispatch to the appropriate container class based
195
- # on the type. Expects a data hash. Returns nil on
196
- # bad message.
197
- def self.construct(data)
198
-
199
- type = data['MessageType']
200
- return nil if type == nil || !notify_roots.include?(type)
201
-
202
- klass = self.const_get(type)
203
- return klass.new(data)
204
-
205
- end
206
-
207
- # Helper to get the element names of all types
208
- def self.notify_roots
209
- return Notification.subclasses.map(&:root_name)
210
- end
211
-
212
- end