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 +7 -0
- data/lib/emu_power.rb +7 -0
- data/lib/emu_power/api.rb +135 -0
- data/lib/emu_power/commands.rb +111 -0
- data/lib/emu_power/stream_parser.rb +118 -0
- data/lib/emu_power/types.rb +143 -0
- data/readme.md +1 -0
- metadata +77 -0
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,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: []
|