emu_power 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
+ SHA256:
3
+ metadata.gz: 749cd7c9a9756e9154e204fd1f0429ec3bd847716edbaadd873e08a9d5c8d2f8
4
+ data.tar.gz: 21ac87685aa627635cd7e6e8fa9b2f18d12a5a570f50ca32f0da1ddd6c10236e
5
+ SHA512:
6
+ metadata.gz: 1cfd81a5436476c76d45d163cf027958bb5550be01d69a0d3b71863d712d59a56d1e62059db70dc06d744257746887d7a9b49a0fd58ccfd8ae65f56e8f6df0a5
7
+ data.tar.gz: 0aec10ac51e7a084d968e60760aff1c693c0ee5d4b9677ae894ae07b41c2a493acb20460d8e0bef0b9a9a13588d61913a6bc7e263ed0ee35777b0532f74ba98f
data/lib/emu_power.rb ADDED
@@ -0,0 +1,7 @@
1
+ class EmuPower
2
+ end
3
+
4
+ require 'emu_power/api'
5
+ require 'emu_power/stream_parser'
6
+ require 'emu_power/types'
7
+ require 'emu_power/commands'
@@ -0,0 +1,135 @@
1
+ # API for communicating with the Rainforest EMU-2 monitoring
2
+ # unit. This API is asynchronous, and allows event handlers
3
+ # to be registered for the various message types.
4
+
5
+ require 'emu_power/stream_parser'
6
+ require 'emu_power/types'
7
+
8
+ require 'serialport'
9
+ require 'nokogiri'
10
+
11
+ class EmuPower::Api
12
+
13
+ LINE_TERMINATOR = "\r\n"
14
+
15
+ # Initialize the serial connection and build notification histories
16
+ def initialize(tty, history_length = 10)
17
+
18
+ @port = SerialPort.new(tty, baud: 115200)
19
+
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
26
+
27
+ end
28
+
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.
34
+ def callback(klass, &block)
35
+ @callbacks[klass] = block
36
+ return true
37
+ end
38
+
39
+ # Send a command to the device. Expects an instance of one of the
40
+ # command classes defined in commands.rb. The serial connection
41
+ # must be started before this can be used.
42
+ def issue_command(obj)
43
+ return false if @thread.nil?
44
+ return false unless obj.respond_to?(:to_command)
45
+ xml = obj.to_command
46
+ @port.write(xml)
47
+ return true
48
+ end
49
+
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)
55
+
56
+ return false unless @thread.nil?
57
+
58
+ parser = construct_parser
59
+
60
+ @thread = Thread.new do
61
+ 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
69
+ end
70
+ end
71
+ end
72
+
73
+ if blocking
74
+ @thread.join
75
+ else
76
+ return true
77
+ end
78
+
79
+ end
80
+
81
+ # Stop polling for data. Already-received objects will
82
+ # remain available.
83
+ def stop_serial
84
+ return false if @thread.nil?
85
+ @thread.terminate
86
+ @thread = nil
87
+ return true
88
+ end
89
+
90
+ # Get the full history buffer for a given notify type
91
+ def history_for(klass)
92
+ return @histories[klass].compact
93
+ end
94
+
95
+ # Get the most recent object for the given type
96
+ def current(klass)
97
+ return history_for(klass)[0]
98
+ end
99
+
100
+ private
101
+
102
+ # Handle the completed hash objects when notified by the parser
103
+ def handle_response(obj)
104
+
105
+ container = EmuPower::Types.construct(obj)
106
+
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
114
+
115
+ end
116
+
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)
121
+ 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
+
133
+ end
134
+
135
+ end
@@ -0,0 +1,111 @@
1
+ # Collection of command types for controlling various
2
+ # functions on the EMU device.
3
+
4
+ class EmuPower::Commands
5
+
6
+ # Base class that all commands inherit from
7
+ class Command
8
+
9
+ def initialize(name)
10
+ @data = { name: name }
11
+ end
12
+
13
+ def to_command
14
+
15
+ tags = @data.map do |k, v|
16
+ tag = k.to_s.capitalize
17
+ next "<#{tag}>#{v}</#{tag}>"
18
+ end
19
+
20
+ return "<Command>#{tags.join}</Command>"
21
+ end
22
+
23
+ # Convert bool to Y or N
24
+ def to_yn(bool)
25
+ return bool ? 'Y' : 'N'
26
+ end
27
+
28
+ # Convert int to 0xABCD hex
29
+ def to_hex(i, width = 8)
30
+ return "0x%0#{width}x" % i
31
+ end
32
+
33
+ end
34
+
35
+ class GetNetworkInfo < Command
36
+ def initialize
37
+ super('get_network_info')
38
+ end
39
+ end
40
+
41
+ class GetNetworkStatus < Command
42
+ def initialize
43
+ super('get_network_status')
44
+ end
45
+ end
46
+
47
+ class GetInstantaneousDemand < Command
48
+ def initialize
49
+ super('get_instantaneous_demand')
50
+ end
51
+ end
52
+
53
+ class GetPrice < Command
54
+ def initialize
55
+ super('get_price')
56
+ end
57
+ end
58
+
59
+ class GetMessage < Command
60
+ def initialize
61
+ super('get_message')
62
+ end
63
+ end
64
+
65
+ # TODO: Confirm Message
66
+
67
+ class GetCurrentSummation < Command
68
+ def initialize
69
+ super('get_current_summation')
70
+ end
71
+ end
72
+
73
+ # TODO: Get History Data
74
+
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.
78
+ class SetSchedule < Command
79
+
80
+ EVENTS = %w[time message price summation demand scheduled_prices profile_data billing_period block_period]
81
+
82
+ def initialize(event, frequency, enabled)
83
+ super('set_schedule')
84
+ raise ArgumentError.new("Event must be one of #{EVENTS.join(', ')}") unless EVENTS.include?(event)
85
+ @data[:event] = event
86
+ @data[:frequency] = to_hex(frequency, 4)
87
+ @data[:enabled] = to_yn(enabled)
88
+ end
89
+
90
+ end
91
+
92
+ # TODO: Add event field
93
+ class GetSchedule < Command
94
+
95
+ EVENTS = %w[time message price summation demand scheduled_prices profile_data billing_period block_period]
96
+
97
+ def initialize(event = nil)
98
+
99
+ super('get_schedule')
100
+
101
+ unless event.nil?
102
+ raise ArgumentError.new("Event must be one of #{EVENTS.join(', ')}") unless EVENTS.include?(event)
103
+ @data[:event] = event
104
+ end
105
+
106
+ end
107
+ end
108
+
109
+ # TODO: Reboot
110
+
111
+ end
@@ -0,0 +1,118 @@
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
@@ -0,0 +1,143 @@
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
+ ts = self.raw['TimeStamp']
30
+ return nil if ts == nil
31
+ return Integer(ts) + 946684800
32
+ end
33
+
34
+ def parse_hex(prop)
35
+ v = @raw[prop]
36
+ return nil if v.nil?
37
+ return Integer(v)
38
+ end
39
+
40
+ def parse_bool(prop)
41
+ v = @raw[prop]
42
+ return nil if v.nil?
43
+ return (@raw[prop] == 'Y') ? true : false
44
+ end
45
+
46
+ # Name of the XML root object corresponding to this type
47
+ def self.root_name
48
+ return self.name.split('::').last
49
+ end
50
+
51
+ def self.subclasses
52
+ return ObjectSpace.each_object(::Class).select do |k|
53
+ k < self
54
+ end
55
+ end
56
+
57
+ end
58
+
59
+ class ConnectionStatus < Notification
60
+ end
61
+
62
+ class DeviceInfo < Notification
63
+ end
64
+
65
+ class ScheduleInfo < Notification
66
+ end
67
+
68
+ class MeterList < Notification
69
+ end
70
+
71
+ class MeterInfo < Notification
72
+ end
73
+
74
+ class NetworkInfo < Notification
75
+ end
76
+
77
+ class TimeCluster < Notification
78
+ end
79
+
80
+ class MessageCluster < Notification
81
+ end
82
+
83
+ class PriceCluster < Notification
84
+ end
85
+
86
+ class InstantaneousDemand < Notification
87
+
88
+ attr_accessor :raw_demand
89
+ attr_accessor :multiplier
90
+ attr_accessor :divisor
91
+ attr_accessor :digits_right
92
+ attr_accessor :digits_left
93
+ attr_accessor :suppress_leading_zeroes
94
+
95
+ def build(hash)
96
+ self.raw_demand = parse_hex('Demand')
97
+ self.multiplier = parse_hex('Multiplier')
98
+ self.divisor = parse_hex('Divisor')
99
+ self.digits_right = parse_hex('DigitsRight')
100
+ self.digits_left = parse_hex('DigitsLeft')
101
+ self.suppress_leading_zeroes = parse_bool('SuppressLeadingZero')
102
+ end
103
+
104
+ # Return computed demand in KW. This may return nil if data is missing.
105
+ def demand
106
+ return 0 if self.divisor == 0
107
+ return nil if self.multiplier.nil? || self.raw_demand.nil? || self.divisor.nil?
108
+ return self.multiplier * self.raw_demand / Float(self.divisor)
109
+ end
110
+
111
+ end
112
+
113
+ class CurrentSummationDelivered < Notification
114
+ end
115
+
116
+ class CurrentPeriodUsage < Notification
117
+ end
118
+
119
+ class LastPeriodUsage < Notification
120
+ end
121
+
122
+ class ProfileData < Notification
123
+ end
124
+
125
+ # Dispatch to the appropriate container class based
126
+ # on the type. Expects a data hash. Returns nil on
127
+ # bad message.
128
+ def self.construct(data)
129
+
130
+ type = data['MessageType']
131
+ return nil if type == nil || !notify_roots.include?(type)
132
+
133
+ klass = self.const_get(type)
134
+ return klass.new(data)
135
+
136
+ end
137
+
138
+ # Helper to get the element names of all types
139
+ def self.notify_roots
140
+ return Notification.subclasses.map(&:root_name)
141
+ end
142
+
143
+ end
data/readme.md ADDED
@@ -0,0 +1 @@
1
+ Placeholder readme
metadata ADDED
@@ -0,0 +1,77 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: emu_power
3
+ version: !ruby/object:Gem::Version
4
+ version: '1.0'
5
+ platform: ruby
6
+ authors:
7
+ - Steven Bertolucci
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2021-02-23 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.11'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.11'
27
+ - !ruby/object:Gem::Dependency
28
+ name: serialport
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.3'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.3'
41
+ description: This is an implementation of the XML API for the Rainforest EMU in ruby.
42
+ email: srbertol@mtu.edu
43
+ executables: []
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - lib/emu_power.rb
48
+ - lib/emu_power/api.rb
49
+ - lib/emu_power/commands.rb
50
+ - lib/emu_power/stream_parser.rb
51
+ - lib/emu_power/types.rb
52
+ - readme.md
53
+ homepage:
54
+ licenses:
55
+ - MIT
56
+ metadata: {}
57
+ post_install_message:
58
+ rdoc_options: []
59
+ require_paths:
60
+ - lib
61
+ required_ruby_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '0'
66
+ required_rubygems_version: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: '0'
71
+ requirements: []
72
+ rubyforge_project:
73
+ rubygems_version: 2.7.6.2
74
+ signing_key:
75
+ specification_version: 4
76
+ summary: API for interfacing with the Rainforest EMU energy monitor.
77
+ test_files: []