saal 0.2.23 → 0.3.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/bin/saal_chart +49 -4
- data/bin/saal_envoy_generate_config +53 -0
- data/bin/saal_envoy_read +144 -0
- data/lib/chart.rb +1 -1
- data/lib/chart_data.rb +1 -1
- data/lib/dbstore.rb +15 -18
- data/lib/dinrelay.rb +2 -21
- data/lib/envoy.rb +287 -0
- data/lib/http.rb +51 -0
- data/lib/outliercache.rb +1 -1
- data/lib/saal.rb +4 -2
- data/lib/sensors.rb +22 -2
- data/saal.gemspec +11 -5
- data/test/chart_data_test.rb +1 -1
- data/test/chart_test.rb +0 -3
- data/test/daemon_test.rb +2 -2
- data/test/dbstore_test.rb +5 -2
- data/test/dinrelay_test.rb +1 -1
- data/test/sensors_test.rb +5 -5
- data/test/test_db.yml +3 -3
- data/test/test_helper.rb +1 -2
- data/test/test_sensors.yml +1 -1
- metadata +65 -42
- data/bin/saal_chart~ +0 -90
- data/bin/saal_daemon~ +0 -31
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: a8e07d635b3ad4c944de3ccbe81096f826754aa457b9ebf06854f01a760d715d
|
4
|
+
data.tar.gz: 766a7168fa122a72b388b6547b1f6b9531cd4afd5bc1df9df52673b9fd8291ce
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 1ada5f05af261f043ef3e871c21d61b3f1924b4b99d8059233f1202a8594c30ea9af425e3f62d75050ee2f6c8bc15782c1535e4fc4696c277d7619c0569e40dc
|
7
|
+
data.tar.gz: 39ece8cad43590608d9410d7ffdcab1449e1fcc309f123343ffa6872dd84b820a59635a0f3a1aaf5807d05e111394674aa1aa49d9dcc2589930477d5f3c10426
|
data/bin/saal_chart
CHANGED
@@ -16,7 +16,11 @@ if ARGV.size != 1
|
|
16
16
|
exit (2)
|
17
17
|
end
|
18
18
|
|
19
|
-
|
19
|
+
MAX_RANGES = {:temperature=>[-10,70],
|
20
|
+
:humidity=>[0,110],
|
21
|
+
:pressure=>[900,1200]}
|
22
|
+
|
23
|
+
TYPES = {:temperature => "ºC", :humidity => "%", :pressure => "hPa"}
|
20
24
|
|
21
25
|
|
22
26
|
SAAL::Charts.new.each do |chart|
|
@@ -57,9 +61,35 @@ SAAL::Charts.new.each do |chart|
|
|
57
61
|
NUM_VALUES_SMALL
|
58
62
|
|
59
63
|
@averages = chart.average(num_values)
|
64
|
+
@ranges = {}
|
65
|
+
|
66
|
+
# First find the smallest interval that fits all sensors for each type
|
67
|
+
@data = chart.sensors.map do |sensor|
|
68
|
+
avgs = @averages[sensor.name.to_sym]
|
69
|
+
|
70
|
+
min = avgs.select{|o| o != nil}.min
|
71
|
+
max = avgs.select{|o| o != nil}.max
|
72
|
+
if min and max
|
73
|
+
range = [(min/5).floor*5, (max/5).ceil*5]
|
74
|
+
@ranges[sensor.sensor_type] ||= range
|
75
|
+
previous = @ranges[sensor.sensor_type]
|
76
|
+
@ranges[sensor.sensor_type] = [[range[0],previous[0]].min,
|
77
|
+
[range[1],previous[1]].max]
|
78
|
+
end
|
79
|
+
end
|
60
80
|
|
81
|
+
# Then clip those intervals to MAX_RANGES
|
61
82
|
@data = chart.sensors.map do |sensor|
|
62
|
-
|
83
|
+
maxrange = MAX_RANGES[sensor.sensor_type]
|
84
|
+
@ranges[sensor.sensor_type] ||= maxrange
|
85
|
+
previous = @ranges[sensor.sensor_type]
|
86
|
+
@ranges[sensor.sensor_type] = [[maxrange[0],previous[0]].max,
|
87
|
+
[maxrange[1],previous[1]].min]
|
88
|
+
end
|
89
|
+
|
90
|
+
# Finally use those intervals to normalize the data
|
91
|
+
@data = chart.sensors.map do |sensor|
|
92
|
+
normalize_data(@averages[sensor.name.to_sym], *@ranges[sensor.sensor_type])
|
63
93
|
end
|
64
94
|
|
65
95
|
@dataurl = @data.map {|values| values.join(",")}.join('|')
|
@@ -74,12 +104,27 @@ SAAL::Charts.new.each do |chart|
|
|
74
104
|
end
|
75
105
|
@xincr = 100.0/@numperiods.to_f*10000.truncate.to_f/10000
|
76
106
|
|
107
|
+
@axes = []
|
108
|
+
@ranges.each do |type, range|
|
109
|
+
min,max = range
|
110
|
+
step = (max-min).to_f/4.0
|
111
|
+
steps = (0..4).map{|i| (min+i*step).to_i.to_s}
|
112
|
+
steps[0] = "#{min} #{TYPES[type]}"
|
113
|
+
steps[4] = "#{max} #{TYPES[type]}"
|
114
|
+
@axes << steps.join("||")
|
115
|
+
end
|
116
|
+
# Duplicate the axis if there's only one
|
117
|
+
@axes *= 2 if @axes.size == 1
|
118
|
+
# Alternate between left and right axes
|
119
|
+
@axisset = (['y','r']*@axes.size)[0..@axes.size-1].join(",")
|
120
|
+
@axes = @axes.each_with_index.map {|a,i| "#{i+1}:|#{a}"}.join("|")
|
121
|
+
|
77
122
|
r[:chof] = "png"
|
78
123
|
r[:chs] = "700x300"
|
79
124
|
r[:cht] = "lc"
|
80
125
|
r[:chco] = "00ff00,ff0000,0000ff,ff9933,800080"
|
81
|
-
r[:chxt] = "x
|
82
|
-
r[:chxl] = "0:#{@periodnamesurl}
|
126
|
+
r[:chxt] = "x,#{@axisset}"
|
127
|
+
r[:chxl] = "0:#{@periodnamesurl}#{@axes}"
|
83
128
|
r[:chg] = "#{@xincr},12.5,1,5"
|
84
129
|
r[:chd] = "t:#{@dataurl}"
|
85
130
|
|
@@ -0,0 +1,53 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require File.dirname(__FILE__)+'/../lib/saal.rb'
|
4
|
+
|
5
|
+
if ARGV.size != 1 && ARGV.size != 3
|
6
|
+
$stderr.puts "USAGE: saal_envoy_generate_config <host> [<user> <password>]"
|
7
|
+
exit(1)
|
8
|
+
end
|
9
|
+
|
10
|
+
puts "power:"
|
11
|
+
puts " envoy_power_energy:"
|
12
|
+
puts " host: \"#{ARGV[0]}\""
|
13
|
+
puts
|
14
|
+
puts "ac:"
|
15
|
+
puts " envoy_ac_quality:"
|
16
|
+
puts " host: \"#{ARGV[0]}\""
|
17
|
+
|
18
|
+
user = "envoy"
|
19
|
+
password = nil
|
20
|
+
|
21
|
+
if ARGV[2]
|
22
|
+
user = ARGV[1]
|
23
|
+
password = ARGV[2]
|
24
|
+
else
|
25
|
+
Net::HTTP.get(ARGV[0], '/home').split("\n").each do |line|
|
26
|
+
if line.include?("serial:")
|
27
|
+
password = line.split('"')[1][-6..-1]
|
28
|
+
end
|
29
|
+
end
|
30
|
+
if !password
|
31
|
+
$stderr.puts "Couldn't find serial number for envoy"
|
32
|
+
exit 2
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
envoy = SAAL::Envoy::Inverters::new(
|
37
|
+
:host => ARGV[0],
|
38
|
+
:user => user,
|
39
|
+
:password => password,
|
40
|
+
)
|
41
|
+
envoy.set_all_inverters!
|
42
|
+
exit 0 if envoy.inverters.size <= 0
|
43
|
+
|
44
|
+
puts
|
45
|
+
puts "inverters:"
|
46
|
+
puts " envoy_inverters:"
|
47
|
+
puts " host: \"#{ARGV[0]}\""
|
48
|
+
puts " user: \"#{user}\""
|
49
|
+
puts " password: \"#{password}\""
|
50
|
+
puts " inverters:"
|
51
|
+
envoy.inverters.each do |serial|
|
52
|
+
puts " - #{serial}"
|
53
|
+
end
|
data/bin/saal_envoy_read
ADDED
@@ -0,0 +1,144 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require File.dirname(__FILE__)+'/../lib/saal.rb'
|
4
|
+
|
5
|
+
if ARGV.size != 3
|
6
|
+
$stderr.puts "USAGE: saal_envoy_read <host> <user> <password>"
|
7
|
+
exit(1)
|
8
|
+
end
|
9
|
+
|
10
|
+
def fdisp(val)
|
11
|
+
if val
|
12
|
+
'%10.0f' % val
|
13
|
+
else
|
14
|
+
" n/a"
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def fdispk(val)
|
19
|
+
if val
|
20
|
+
'%10.2f' % (val / 1000.0)
|
21
|
+
else
|
22
|
+
" n/a"
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def fdisp_dec(val)
|
27
|
+
if val
|
28
|
+
'%10.2f' % val
|
29
|
+
else
|
30
|
+
" n/a"
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def l(vals, name)
|
35
|
+
sensor = vals[name]
|
36
|
+
if sensor
|
37
|
+
sensor.read
|
38
|
+
else
|
39
|
+
nil
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def lratio(vals, name1, name2)
|
44
|
+
v1 = l(vals, name1)
|
45
|
+
v2 = l(vals, name2)
|
46
|
+
if v1 && v2
|
47
|
+
v1 / v2
|
48
|
+
else
|
49
|
+
nil
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
ac_quality = SAAL::Envoy::ACQuality::new(:host => ARGV[0]).create_sensors
|
54
|
+
|
55
|
+
puts " ========= AC QUALITY ========"
|
56
|
+
puts " voltage (V) freq (Hz)"
|
57
|
+
def qual_line(vals,name, type)
|
58
|
+
puts " #{name} \
|
59
|
+
#{fdisp_dec(l(vals,"ac_#{type}_voltage"))} \
|
60
|
+
#{fdisp_dec(l(vals,"ac_#{type}_frequency"))} \
|
61
|
+
"
|
62
|
+
end
|
63
|
+
qual_line(ac_quality, "Total: ", "total")
|
64
|
+
qual_line(ac_quality, "Phase1:", "phase1")
|
65
|
+
qual_line(ac_quality, "Phase2:", "phase2")
|
66
|
+
qual_line(ac_quality, "Phase3:", "phase3")
|
67
|
+
|
68
|
+
production = SAAL::Envoy::PowerEnergy::new(:host => ARGV[0]).create_sensors
|
69
|
+
|
70
|
+
puts ""
|
71
|
+
puts " ============ TRUE POWER (W) ============ ======= TRUE ENERGY (kWh) ======="
|
72
|
+
puts " consumption production net consumption production net"
|
73
|
+
def p_line(vals, name, type, metric)
|
74
|
+
puts " #{name} \
|
75
|
+
#{fdisp(l(vals,"pv_total_consumption_#{type}_#{metric}_now"))} \
|
76
|
+
#{fdisp(l(vals,"pv_production_#{type}_#{metric}_now"))} \
|
77
|
+
#{fdisp(l(vals,"pv_net_consumption_#{type}_#{metric}_now"))} \
|
78
|
+
#{fdispk(l(vals,"pv_total_consumption_#{type}_#{metric}h_lifetime"))} \
|
79
|
+
#{fdispk(l(vals,"pv_production_#{type}_#{metric}h_lifetime"))} \
|
80
|
+
#{fdispk(l(vals,"pv_net_consumption_#{type}_#{metric}h_lifetime"))} \
|
81
|
+
"
|
82
|
+
end
|
83
|
+
p_line(production, "Total: ", "total", "w")
|
84
|
+
p_line(production, "Phase1:", "phase1", "w")
|
85
|
+
p_line(production, "Phase2:", "phase2", "w")
|
86
|
+
p_line(production, "Phase3:", "phase3", "w")
|
87
|
+
puts " Total Inverters: \
|
88
|
+
#{fdisp(l(production,"pv_production_inverters_w_now"))} \
|
89
|
+
\
|
90
|
+
#{fdispk(l(production,"pv_production_inverters_wh_lifetime"))} \
|
91
|
+
"
|
92
|
+
|
93
|
+
puts ""
|
94
|
+
puts " ========== APPARENT POWER (VA) ========= ===== APPARENT ENERGY (kVAh) ===="
|
95
|
+
puts " consumption production net consumption production net"
|
96
|
+
p_line(production, "Total: ", "total", "va")
|
97
|
+
p_line(production, "Phase1:", "phase1", "va")
|
98
|
+
p_line(production, "Phase2:", "phase2", "va")
|
99
|
+
p_line(production, "Phase3:", "phase3", "va")
|
100
|
+
|
101
|
+
def pf_line(vals, name, type, metric)
|
102
|
+
pf_total_consumption_instant = lratio(vals,"pv_total_consumption_#{type}_w_now","pv_total_consumption_#{type}_va_now")
|
103
|
+
pf_total_production_instant = lratio(vals,"pv_production_#{type}_w_now","pv_production_#{type}_va_now")
|
104
|
+
pf_net_production_instant = lratio(vals,"pv_net_consumption_#{type}_w_now","pv_net_consumption_#{type}_va_now")
|
105
|
+
|
106
|
+
pf_total_consumption_lifetime = lratio(vals,"pv_total_consumption_#{type}_wh_lifetime","pv_total_consumption_#{type}_vah_lifetime")
|
107
|
+
pf_total_production_lifetime = lratio(vals,"pv_production_#{type}_wh_lifetime","pv_production_#{type}_vah_lifetime")
|
108
|
+
pf_net_production_lifetime = lratio(vals,"pv_net_consumption_#{type}_wh_lifetime","pv_net_consumption_#{type}_vah_lifetime")
|
109
|
+
|
110
|
+
puts " #{name} \
|
111
|
+
#{fdisp_dec(pf_total_consumption_instant)} \
|
112
|
+
#{fdisp_dec(pf_total_production_instant)} \
|
113
|
+
#{fdisp_dec(pf_net_production_instant)} \
|
114
|
+
#{fdisp_dec(pf_total_consumption_lifetime)} \
|
115
|
+
#{fdisp_dec(pf_total_production_lifetime)} \
|
116
|
+
#{fdisp_dec(pf_net_production_lifetime)} \
|
117
|
+
"
|
118
|
+
end
|
119
|
+
|
120
|
+
puts ""
|
121
|
+
puts " ========= INSTANT POWER FACTOR ========= ====== LIFETIME POWER FACTOR ====="
|
122
|
+
puts " consumption production net consumption production net"
|
123
|
+
pf_line(production, "Total: ", "total", "va")
|
124
|
+
pf_line(production, "Phase1:", "phase1", "va")
|
125
|
+
pf_line(production, "Phase2:", "phase2", "va")
|
126
|
+
pf_line(production, "Phase3:", "phase3", "va")
|
127
|
+
|
128
|
+
puts ""
|
129
|
+
envoy = SAAL::Envoy::Inverters::new(
|
130
|
+
:host => ARGV[0],
|
131
|
+
:user => ARGV[1],
|
132
|
+
:password => ARGV[2],
|
133
|
+
:types => ["w_now", "last_report_date", "w_max"],
|
134
|
+
)
|
135
|
+
envoy.set_all_inverters!
|
136
|
+
inverters = envoy.create_sensors
|
137
|
+
puts " Found #{envoy.inverters.size} inverters"
|
138
|
+
envoy.inverters.each do |serial|
|
139
|
+
puts " INVERTER: #{serial} \
|
140
|
+
date:#{l(inverters,"inverters_#{serial}_last_report_date")} \
|
141
|
+
lastWatts:#{l(inverters,"inverters_#{serial}_w_now")} \
|
142
|
+
maxWatts:#{l(inverters,"inverters_#{serial}_w_max")} \
|
143
|
+
"
|
144
|
+
end
|
data/lib/chart.rb
CHANGED
@@ -5,7 +5,7 @@ module SAAL
|
|
5
5
|
@name = name
|
6
6
|
@defs = defs
|
7
7
|
@alignlabels = (defs['alignlabels'] || :center).to_sym
|
8
|
-
@sensors = defs['sensors'].map{|
|
8
|
+
@sensors = defs['sensors'].map{|sname| sensors.send(sname)}
|
9
9
|
@num = defs['last']
|
10
10
|
@periods = defs['periods']
|
11
11
|
@alt = defs['alt']
|
data/lib/chart_data.rb
CHANGED
data/lib/dbstore.rb
CHANGED
@@ -46,54 +46,51 @@ module SAAL
|
|
46
46
|
WHERE sensor = '#{db_quote(sensor.to_s)}'
|
47
47
|
AND date > '#{Time.now.utc.to_i - MAX_LAST_VAL_AGE}'
|
48
48
|
ORDER BY date DESC LIMIT 1" do |r|
|
49
|
-
|
50
|
-
|
49
|
+
row = r.first
|
50
|
+
if row
|
51
|
+
_date, value = [row["date"].to_i, row["value"].to_f]
|
52
|
+
value
|
51
53
|
else
|
52
|
-
|
53
|
-
date, value = [row[0].to_i, row[1].to_f]
|
54
|
-
return value
|
54
|
+
nil
|
55
55
|
end
|
56
56
|
end
|
57
57
|
end
|
58
58
|
|
59
59
|
def each
|
60
60
|
db_query "SELECT sensor,date,value FROM sensor_reads" do |r|
|
61
|
-
r.
|
62
|
-
row
|
63
|
-
yield [row[0],row[1].to_i, row[2].to_f]
|
61
|
+
r.each do |row|
|
62
|
+
yield [row["sensor"],row["date"].to_i, row["value"].to_f]
|
64
63
|
end
|
65
64
|
end
|
66
65
|
end
|
67
66
|
|
68
67
|
private
|
69
68
|
def db_range(function, sensor, from, to)
|
70
|
-
db_query "SELECT #{function}(value) AS
|
69
|
+
db_query "SELECT #{function}(value) AS func FROM sensor_reads
|
71
70
|
WHERE sensor = '#{db_quote(sensor.to_s)}'
|
72
71
|
AND date >= #{from.to_s}
|
73
72
|
AND date <= #{to.to_s}" do |r|
|
74
|
-
|
75
|
-
|
73
|
+
row = r.first
|
74
|
+
if row && row["func"]
|
75
|
+
row["func"].to_f
|
76
76
|
else
|
77
|
-
|
78
|
-
row[0] ? row[0].to_f : nil
|
77
|
+
nil
|
79
78
|
end
|
80
79
|
end
|
81
80
|
end
|
82
81
|
|
83
82
|
def db_quote(text)
|
84
|
-
|
83
|
+
Mysql2::Client.escape(text)
|
85
84
|
end
|
86
85
|
|
87
86
|
def db_query(query, opts={})
|
88
87
|
db = nil
|
89
88
|
begin
|
90
89
|
# connect to the MySQL server
|
91
|
-
db =
|
92
|
-
@dbopts['db'],@dbopts['port'],@dbopts['socket'],
|
93
|
-
@dbopts['flags'])
|
90
|
+
db = Mysql2::Client.new(@dbopts)
|
94
91
|
res = db.query(query)
|
95
92
|
yield res if block_given?
|
96
|
-
rescue
|
93
|
+
rescue Mysql2::Error => e
|
97
94
|
$stderr.puts "MySQL Error #{e.errno}: #{e.error}" if !(e.errno == opts[:ignoreerr])
|
98
95
|
ensure
|
99
96
|
db.close if db
|
data/lib/dinrelay.rb
CHANGED
@@ -42,6 +42,7 @@ module SAAL
|
|
42
42
|
@cache_timeout = opts[:cache_timeout] || opts['cache_timeout'] || DEFAULT_CACHE_TIMEOUT
|
43
43
|
@cache = nil
|
44
44
|
@cachehit = nil
|
45
|
+
@cachetime = nil
|
45
46
|
end
|
46
47
|
|
47
48
|
def state(num)
|
@@ -55,33 +56,13 @@ module SAAL
|
|
55
56
|
def set_state(num, state)
|
56
57
|
@cachetime = nil
|
57
58
|
response = do_get("/outlet?#{num}=#{state}")
|
58
|
-
#FIXME: Find a better workaround for dinrelay's crashing when you cycle
|
59
|
-
# through outlets too fast
|
60
|
-
sleep 1
|
61
59
|
response != nil
|
62
60
|
end
|
63
61
|
|
64
62
|
private
|
65
63
|
def do_get(path)
|
66
|
-
|
67
|
-
http = Net::HTTP.new(@host,@port)
|
68
|
-
# Timeout faster when the other side doesn't respond
|
69
|
-
http.open_timeout = @timeout
|
70
|
-
http.read_timeout = @timeout
|
71
|
-
req = Net::HTTP::Get.new(path)
|
72
|
-
req.basic_auth @user, @pass
|
73
|
-
response = http.request(req)
|
74
|
-
if response.code != "200"
|
75
|
-
#$stderr.puts "ERROR: Code #{response.code}"
|
76
|
-
#$stderr.puts response.body
|
77
|
-
return nil
|
78
|
-
end
|
79
|
-
return response
|
80
|
-
rescue Exception
|
81
|
-
return nil
|
82
|
-
end
|
64
|
+
SAAL::do_http_get(@host, @port, path, @user, @pass, @timeout)
|
83
65
|
end
|
84
|
-
|
85
66
|
def parse_index_html(str)
|
86
67
|
doc = Nokogiri::HTML(str)
|
87
68
|
outlets = doc.css('tr[bgcolor="#F4F4F4"]')
|
data/lib/envoy.rb
ADDED
@@ -0,0 +1,287 @@
|
|
1
|
+
require 'json'
|
2
|
+
|
3
|
+
module SAAL
|
4
|
+
module Envoy
|
5
|
+
class PowerEnergyUnderlying < SensorUnderlying
|
6
|
+
def initialize(key, production)
|
7
|
+
@key = key
|
8
|
+
@production = production
|
9
|
+
end
|
10
|
+
|
11
|
+
def read(uncached = false)
|
12
|
+
@production.read_val(@key)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
class PowerEnergy
|
17
|
+
DEFAULT_HOST = "envoy.local"
|
18
|
+
DEFAULT_TIMEOUT = 2
|
19
|
+
DEFAULT_CACHE_TIMEOUT = 50
|
20
|
+
DEFAULT_SOURCES = [
|
21
|
+
"production_inverters",
|
22
|
+
"production_phase1", "production_phase2", "production_phase3", "production_total",
|
23
|
+
"net_consumption_phase1", "net_consumption_phase2", "net_consumption_phase3", "net_consumption_total",
|
24
|
+
"total_consumption_phase1", "total_consumption_phase2", "total_consumption_phase3", "total_consumption_total",
|
25
|
+
]
|
26
|
+
DEFAULT_TYPES = [
|
27
|
+
"w_now", "wh_lifetime", "va_now", "vah_lifetime",
|
28
|
+
]
|
29
|
+
DEFAULT_PREFIX = "pv"
|
30
|
+
|
31
|
+
def initialize(defs, opts={})
|
32
|
+
@host = defs[:host] || defs['host'] || DEFAULT_HOST
|
33
|
+
@timeout = opts[:timeout] || opts['timeout'] || DEFAULT_TIMEOUT
|
34
|
+
@cache_timeout = opts[:cache_timeout] || opts['cache_timeout'] || DEFAULT_CACHE_TIMEOUT
|
35
|
+
@cache = nil
|
36
|
+
@cachetime = nil
|
37
|
+
@sources = defs[:sources] || defs['source'] || DEFAULT_SOURCES
|
38
|
+
@types = defs[:types] || defs['types'] || DEFAULT_TYPES
|
39
|
+
@prefix = defs[:prefix] || defs['prefix'] || DEFAULT_PREFIX
|
40
|
+
end
|
41
|
+
|
42
|
+
def read_val(name)
|
43
|
+
if !@cachetime or @cachetime < Time.now - @cache_timeout
|
44
|
+
@cache = read_all()
|
45
|
+
@cachetime = Time.now
|
46
|
+
end
|
47
|
+
return @cache ? @cache[name] : nil
|
48
|
+
end
|
49
|
+
|
50
|
+
def create_sensors
|
51
|
+
sensors = {}
|
52
|
+
@sources.product(@types).each do |source, type|
|
53
|
+
key = "#{@prefix}_#{source}_#{type}"
|
54
|
+
sensors[key] = PowerEnergyUnderlying.new(key, self)
|
55
|
+
end
|
56
|
+
sensors
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
def save_vals(dest, name, source)
|
61
|
+
{
|
62
|
+
"wNow" => "w_now",
|
63
|
+
"apprntPwr" => "va_now",
|
64
|
+
"whLifetime" => "wh_lifetime",
|
65
|
+
"vahLifetime" => "vah_lifetime",
|
66
|
+
}.each do |type, label|
|
67
|
+
dest["#{@prefix}_#{name}_#{label}"] = source[type]
|
68
|
+
end
|
69
|
+
|
70
|
+
# Hack around the fact that apprntPwr is broken on the total consumption
|
71
|
+
# calculation for the three-phase sum at least
|
72
|
+
# In those cases it seems to be missing a divide by three, so when the
|
73
|
+
# calculation for voltage and current alone is close do the extra divide
|
74
|
+
va_now = dest["#{@prefix}_#{name}_va_now"]
|
75
|
+
if va_now && !name.include?("phase")
|
76
|
+
voltage = source["rmsVoltage"]
|
77
|
+
current = source["rmsCurrent"]
|
78
|
+
if voltage && current
|
79
|
+
va_alt = voltage * current
|
80
|
+
if ((va_alt / va_now) - 1.0).abs < 0.05
|
81
|
+
dest["#{@prefix}_#{name}_va_now"] = va_now / 3.0
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def read_all
|
88
|
+
response = SAAL::do_http_get(@host, 80, "/production.json?details=1", nil, nil, @timeout)
|
89
|
+
return nil if !response
|
90
|
+
|
91
|
+
values = JSON.parse(response.body)
|
92
|
+
outputs = {}
|
93
|
+
|
94
|
+
values["production"].each do |source|
|
95
|
+
type = source["type"]
|
96
|
+
case type
|
97
|
+
when "inverters"
|
98
|
+
save_vals(outputs, "production_inverters", source)
|
99
|
+
when "eim"
|
100
|
+
if source["lines"]
|
101
|
+
save_vals(outputs, "production_phase1", source["lines"][0])
|
102
|
+
save_vals(outputs, "production_phase2", source["lines"][1])
|
103
|
+
save_vals(outputs, "production_phase3", source["lines"][2])
|
104
|
+
end
|
105
|
+
save_vals(outputs, "production_total", source)
|
106
|
+
else
|
107
|
+
$stderr.puts "WARNING: ENVOY: don't know source type #{type}"
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
values["consumption"].each do |source|
|
112
|
+
type = {
|
113
|
+
"total-consumption" => "total",
|
114
|
+
"net-consumption" => "net",
|
115
|
+
}[source["measurementType"]] || "unknown";
|
116
|
+
|
117
|
+
if source["lines"]
|
118
|
+
save_vals(outputs, "#{type}_consumption_phase1", source["lines"][0])
|
119
|
+
save_vals(outputs, "#{type}_consumption_phase2", source["lines"][1])
|
120
|
+
save_vals(outputs, "#{type}_consumption_phase3", source["lines"][2])
|
121
|
+
end
|
122
|
+
save_vals(outputs, "#{type}_consumption_total", source)
|
123
|
+
end
|
124
|
+
|
125
|
+
outputs
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
class ACQualityUnderlying < SensorUnderlying
|
130
|
+
def initialize(key, production)
|
131
|
+
@key = key
|
132
|
+
@production = production
|
133
|
+
end
|
134
|
+
|
135
|
+
def read(uncached = false)
|
136
|
+
@production.read_val(@key)
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
class ACQuality
|
141
|
+
DEFAULT_HOST = "envoy.local"
|
142
|
+
DEFAULT_TIMEOUT = 2
|
143
|
+
DEFAULT_CACHE_TIMEOUT = 50
|
144
|
+
DEFAULT_SOURCES = ["total","phase1","phase2","phase3",]
|
145
|
+
DEFAULT_TYPES = ["frequency","voltage"]
|
146
|
+
DEFAULT_PREFIX = "ac"
|
147
|
+
|
148
|
+
def initialize(defs, opts={})
|
149
|
+
@host = defs[:host] || defs['host'] || DEFAULT_HOST
|
150
|
+
@timeout = opts[:timeout] || opts['timeout'] || DEFAULT_TIMEOUT
|
151
|
+
@cache_timeout = opts[:cache_timeout] || opts['cache_timeout'] || DEFAULT_CACHE_TIMEOUT
|
152
|
+
@cache = nil
|
153
|
+
@cachetime = nil
|
154
|
+
@sources = defs[:sources] || defs['source'] || DEFAULT_SOURCES
|
155
|
+
@types = defs[:types] || defs['types'] || DEFAULT_TYPES
|
156
|
+
@prefix = defs[:prefix] || defs['prefix'] || DEFAULT_PREFIX
|
157
|
+
end
|
158
|
+
|
159
|
+
def read_val(name)
|
160
|
+
if !@cachetime or @cachetime < Time.now - @cache_timeout
|
161
|
+
@cache = read_all()
|
162
|
+
@cachetime = Time.now
|
163
|
+
end
|
164
|
+
return @cache ? @cache[name] : nil
|
165
|
+
end
|
166
|
+
|
167
|
+
def create_sensors
|
168
|
+
sensors = {}
|
169
|
+
@sources.product(@types).each do |source, type|
|
170
|
+
key = "#{@prefix}_#{source}_#{type}"
|
171
|
+
sensors[key] = ACQualityUnderlying.new(key, self)
|
172
|
+
end
|
173
|
+
sensors
|
174
|
+
end
|
175
|
+
|
176
|
+
private
|
177
|
+
def save_vals(dest, name, source)
|
178
|
+
{
|
179
|
+
"voltage" => "voltage",
|
180
|
+
"freq" => "frequency",
|
181
|
+
}.each do |type, label|
|
182
|
+
dest["#{@prefix}_#{name}_#{label}"] = source[type]
|
183
|
+
end
|
184
|
+
end
|
185
|
+
|
186
|
+
def read_all
|
187
|
+
response = SAAL::do_http_get(@host, 80, "/ivp/meters/readings", nil, nil, @timeout)
|
188
|
+
return nil if !response
|
189
|
+
|
190
|
+
values = JSON.parse(response.body)
|
191
|
+
outputs = {}
|
192
|
+
source = values[0]
|
193
|
+
save_vals(outputs, "total", source)
|
194
|
+
if source["channels"]
|
195
|
+
save_vals(outputs, "phase1", source["channels"][0])
|
196
|
+
save_vals(outputs, "phase2", source["channels"][1])
|
197
|
+
save_vals(outputs, "phase3", source["channels"][2])
|
198
|
+
end
|
199
|
+
|
200
|
+
outputs
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
class InverterUnderlying < SensorUnderlying
|
205
|
+
def initialize(key, inverters)
|
206
|
+
@key = key
|
207
|
+
@inverters = inverters
|
208
|
+
end
|
209
|
+
|
210
|
+
def read(uncached = false)
|
211
|
+
@inverters.read_val(@key)
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
class Inverters
|
216
|
+
DEFAULT_TIMEOUT = 2
|
217
|
+
DEFAULT_CACHE_TIMEOUT = 50
|
218
|
+
DEFAULT_SOURCES = []
|
219
|
+
DEFAULT_TYPES = ["w_now"] # "last_report_date", "w_max"
|
220
|
+
DEFAULT_USER = nil
|
221
|
+
DEFAULT_PASSWORD = nil
|
222
|
+
DEFAULT_PREFIX = "inverters"
|
223
|
+
attr_reader :inverters
|
224
|
+
|
225
|
+
def initialize(defs, opts={})
|
226
|
+
@host = defs[:host] || defs['host'] || DEFAULT_HOST
|
227
|
+
@user = defs[:user] || defs['user'] || DEFAULT_USER
|
228
|
+
@password = defs[:password] || defs['password'] || DEFAULT_PASSWORD
|
229
|
+
@timeout = opts[:timeout] || opts['timeout'] || DEFAULT_TIMEOUT
|
230
|
+
@cache_timeout = opts[:cache_timeout] || opts['cache_timeout'] || DEFAULT_CACHE_TIMEOUT
|
231
|
+
@cache = nil
|
232
|
+
@cachetime = nil
|
233
|
+
@inverters_list = {}
|
234
|
+
@inverters = defs[:inverters] || defs['inverters'] || DEFAULT_SOURCES
|
235
|
+
@types = defs[:types] || defs['types'] || DEFAULT_TYPES
|
236
|
+
@prefix = defs[:prefix] || defs['prefix'] || DEFAULT_PREFIX
|
237
|
+
end
|
238
|
+
|
239
|
+
def read_val(name)
|
240
|
+
if !@cachetime or @cachetime < Time.now - @cache_timeout
|
241
|
+
@cache = read_all()
|
242
|
+
@cachetime = Time.now
|
243
|
+
end
|
244
|
+
return @cache ? @cache[name] : nil
|
245
|
+
end
|
246
|
+
|
247
|
+
def enumerate
|
248
|
+
read_val("foo") # Force a read to make sure the inverter serials are stored
|
249
|
+
@inverters_list.keys
|
250
|
+
end
|
251
|
+
|
252
|
+
def set_all_inverters!
|
253
|
+
@inverters = self.enumerate
|
254
|
+
end
|
255
|
+
|
256
|
+
def create_sensors
|
257
|
+
sensors = {}
|
258
|
+
@inverters.product(@types).each do |source, type|
|
259
|
+
key = "#{@prefix}_#{source}_#{type}"
|
260
|
+
sensors[key] = InverterUnderlying.new(key, self)
|
261
|
+
end
|
262
|
+
sensors
|
263
|
+
end
|
264
|
+
|
265
|
+
private
|
266
|
+
def read_all
|
267
|
+
response = SAAL::do_http_get_digest(@host, 80, "/api/v1/production/inverters", @user, @password, @timeout)
|
268
|
+
return nil if !response
|
269
|
+
|
270
|
+
values = JSON.parse(response.body)
|
271
|
+
inverters = {}
|
272
|
+
values.each do |inverter|
|
273
|
+
serial = inverter["serialNumber"]
|
274
|
+
@inverters_list[serial] = true
|
275
|
+
{"lastReportDate" => "last_report_date",
|
276
|
+
"lastReportWatts" => "w_now",
|
277
|
+
"maxReportWatts" => "w_max",
|
278
|
+
}.each do |type, label|
|
279
|
+
inverters["#{@prefix}_#{serial}_#{label}"] = inverter[type]
|
280
|
+
end
|
281
|
+
end
|
282
|
+
|
283
|
+
inverters
|
284
|
+
end
|
285
|
+
end
|
286
|
+
end
|
287
|
+
end
|
data/lib/http.rb
ADDED
@@ -0,0 +1,51 @@
|
|
1
|
+
require 'uri'
|
2
|
+
require 'net/http'
|
3
|
+
require 'net/http/digest_auth'
|
4
|
+
|
5
|
+
def SAAL::do_http_get(host, port, path, user, pass, timeout)
|
6
|
+
begin
|
7
|
+
http = Net::HTTP.new(host,port)
|
8
|
+
# Timeout faster when the other side doesn't respond
|
9
|
+
http.open_timeout = timeout
|
10
|
+
http.read_timeout = timeout
|
11
|
+
req = Net::HTTP::Get.new(path)
|
12
|
+
req.basic_auth(user, pass) if user && pass
|
13
|
+
response = http.request(req)
|
14
|
+
if response.code != "200"
|
15
|
+
#$stderr.puts "ERROR: Code #{response.code}"
|
16
|
+
#$stderr.puts response.body
|
17
|
+
return nil
|
18
|
+
end
|
19
|
+
return response
|
20
|
+
rescue Exception
|
21
|
+
return nil
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def SAAL::do_http_get_digest(host, port, path, user, pass, timeout)
|
26
|
+
begin
|
27
|
+
uri = URI.parse "http://#{host}:#{port}/#{path}"
|
28
|
+
digest_auth = Net::HTTP::DigestAuth.new
|
29
|
+
uri.user = user
|
30
|
+
uri.password = pass
|
31
|
+
http = Net::HTTP.new(host,port)
|
32
|
+
# Timeout faster when the other side doesn't respond
|
33
|
+
http.open_timeout = timeout
|
34
|
+
http.read_timeout = timeout
|
35
|
+
req = Net::HTTP::Get.new(path)
|
36
|
+
response = http.request(req)
|
37
|
+
if response.code == "401" && user && pass
|
38
|
+
auth = digest_auth.auth_header uri, response['www-authenticate'], 'GET'
|
39
|
+
req.add_field 'Authorization', auth
|
40
|
+
response = http.request(req)
|
41
|
+
end
|
42
|
+
if response.code != "200"
|
43
|
+
#$stderr.puts "ERROR: Code #{response.code}"
|
44
|
+
#$stderr.puts response.body
|
45
|
+
return nil
|
46
|
+
end
|
47
|
+
return response
|
48
|
+
rescue Exception
|
49
|
+
return nil
|
50
|
+
end
|
51
|
+
end
|
data/lib/outliercache.rb
CHANGED
@@ -41,7 +41,7 @@ module SAAL
|
|
41
41
|
def valid_cache
|
42
42
|
@compcache.sort!
|
43
43
|
central = @compcache[1..(@compcache.size-2)]
|
44
|
-
sum = central.inject(0.0){|
|
44
|
+
sum = central.inject(0.0){|csum,el| csum+el}
|
45
45
|
return false if sum == 0.0
|
46
46
|
average = sum/central.size
|
47
47
|
central.each do |el|
|
data/lib/saal.rb
CHANGED
@@ -1,5 +1,5 @@
|
|
1
1
|
require 'yaml'
|
2
|
-
require "
|
2
|
+
require "mysql2"
|
3
3
|
require 'ownet'
|
4
4
|
require 'nokogiri'
|
5
5
|
require 'erb'
|
@@ -10,7 +10,7 @@ module SAAL
|
|
10
10
|
DBCONF = CONFDIR+"database.yml"
|
11
11
|
CHARTSCONF = CONFDIR+"charts.yml"
|
12
12
|
|
13
|
-
VERSION = '0.2
|
13
|
+
VERSION = '0.3.2'
|
14
14
|
end
|
15
15
|
|
16
16
|
require File.dirname(__FILE__)+'/dbstore.rb'
|
@@ -23,4 +23,6 @@ require File.dirname(__FILE__)+'/chart.rb'
|
|
23
23
|
require File.dirname(__FILE__)+'/chart_data.rb'
|
24
24
|
require File.dirname(__FILE__)+'/outliercache.rb'
|
25
25
|
require File.dirname(__FILE__)+'/dinrelay.rb'
|
26
|
+
require File.dirname(__FILE__)+'/envoy.rb'
|
27
|
+
require File.dirname(__FILE__)+'/http.rb'
|
26
28
|
|
data/lib/sensors.rb
CHANGED
@@ -40,9 +40,29 @@ module SAAL
|
|
40
40
|
defs.merge!('name' => outlet_descriptions[num])
|
41
41
|
Sensor.new(dbstore, oname, DINRelay::Outlet.new(num.to_i, og), defs, opts)
|
42
42
|
end
|
43
|
+
elsif defs['envoy_power_energy']
|
44
|
+
defs = defs['envoy_power_energy'].merge('prefix' => name)
|
45
|
+
pe = SAAL::Envoy::PowerEnergy::new(defs)
|
46
|
+
sensors = pe.create_sensors
|
47
|
+
return sensors.map do |name, underlying|
|
48
|
+
Sensor.new(dbstore, name, underlying, defs, opts)
|
49
|
+
end
|
50
|
+
elsif defs['envoy_ac_quality']
|
51
|
+
defs = defs['envoy_ac_quality'].merge('prefix' => name)
|
52
|
+
pe = SAAL::Envoy::ACQuality::new(defs)
|
53
|
+
sensors = pe.create_sensors
|
54
|
+
return sensors.map do |name, underlying|
|
55
|
+
Sensor.new(dbstore, name, underlying, defs, opts)
|
56
|
+
end
|
57
|
+
elsif defs['envoy_inverters']
|
58
|
+
defs = defs['envoy_inverters'].merge('prefix' => name)
|
59
|
+
pe = SAAL::Envoy::Inverters::new(defs)
|
60
|
+
sensors = pe.create_sensors
|
61
|
+
return sensors.map do |name, underlying|
|
62
|
+
Sensor.new(dbstore, name, underlying, defs, opts)
|
63
|
+
end
|
43
64
|
else
|
44
|
-
|
45
|
-
"from the configuration for #{name}"
|
65
|
+
$stderror.puts "WARNING: Couldn't figure out a valid sensor type for #{name}"
|
46
66
|
end
|
47
67
|
end
|
48
68
|
end
|
data/saal.gemspec
CHANGED
@@ -6,8 +6,8 @@ Gem::Specification.new do |s|
|
|
6
6
|
s.platform = Gem::Platform::RUBY
|
7
7
|
|
8
8
|
s.name = 'saal'
|
9
|
-
s.version = '0.2
|
10
|
-
s.date = '
|
9
|
+
s.version = '0.3.2'
|
10
|
+
s.date = '2020-12-25'
|
11
11
|
|
12
12
|
s.summary = "Thin abstraction layer for interfacing and recording sensors (currently onewire) and actuators (currently dinrelay)"
|
13
13
|
s.description = <<EOF
|
@@ -19,6 +19,7 @@ EOF
|
|
19
19
|
s.authors = ["Pedro Côrte-Real"]
|
20
20
|
s.email = 'pedro@pedrocr.net'
|
21
21
|
s.homepage = 'https://github.com/pedrocr/saal'
|
22
|
+
s.licenses = 'LGPL-2.1'
|
22
23
|
|
23
24
|
s.require_paths = %w[lib]
|
24
25
|
|
@@ -28,9 +29,10 @@ EOF
|
|
28
29
|
|
29
30
|
s.executables = Dir.glob("bin/*").map{|f| f.gsub('bin/','')}
|
30
31
|
|
31
|
-
s.
|
32
|
-
s.
|
33
|
-
s.
|
32
|
+
s.add_runtime_dependency 'ownet', "~>0.2"
|
33
|
+
s.add_runtime_dependency 'mysql2', "~>0.5"
|
34
|
+
s.add_runtime_dependency 'nokogiri', '~>1.8'
|
35
|
+
s.add_runtime_dependency 'net-http-digest_auth', '~>1.4'
|
34
36
|
|
35
37
|
# = MANIFEST =
|
36
38
|
s.files = %w[
|
@@ -44,6 +46,8 @@ EOF
|
|
44
46
|
bin/saal_chart
|
45
47
|
bin/saal_daemon
|
46
48
|
bin/saal_dump_database
|
49
|
+
bin/saal_envoy_generate_config
|
50
|
+
bin/saal_envoy_read
|
47
51
|
bin/saal_import_mysql
|
48
52
|
bin/saal_readall
|
49
53
|
lib/chart.rb
|
@@ -52,6 +56,8 @@ EOF
|
|
52
56
|
lib/daemon.rb
|
53
57
|
lib/dbstore.rb
|
54
58
|
lib/dinrelay.rb
|
59
|
+
lib/envoy.rb
|
60
|
+
lib/http.rb
|
55
61
|
lib/outliercache.rb
|
56
62
|
lib/owsensor.rb
|
57
63
|
lib/saal.rb
|
data/test/chart_data_test.rb
CHANGED
@@ -78,7 +78,7 @@ class TestChartData < Test::Unit::TestCase
|
|
78
78
|
Time.utc(2012, 3, 25, 23, 59, 59),
|
79
79
|
["0"]+(2..23).map{|s| s.to_s}+["0"],
|
80
80
|
Time.utc(2012, 3, 25, 23, 59, 59),
|
81
|
-
"
|
81
|
+
"_changing_timezone_test2","Europe/Lisbon")
|
82
82
|
|
83
83
|
assert_alignment_interval(24, :hours, Time.utc(2010, 12, 29, 16, 0, 0),
|
84
84
|
Time.utc(2010, 12, 30, 15, 59, 59),
|
data/test/chart_test.rb
CHANGED
@@ -16,7 +16,6 @@ class TestChart < Test::Unit::TestCase
|
|
16
16
|
|
17
17
|
def test_average
|
18
18
|
name = 'week'
|
19
|
-
defs = @defs[name]
|
20
19
|
chart = @charts.find(name)
|
21
20
|
assert_equal ['Fri','Sat','Sun','Mon','Tue','Wed','Thu'], chart.periodnames
|
22
21
|
chart.sensors.each {|s| s.mock_set(:average => 1)}
|
@@ -31,7 +30,6 @@ class TestChart < Test::Unit::TestCase
|
|
31
30
|
|
32
31
|
def test_min_max_avg_1arity
|
33
32
|
name = 'week'
|
34
|
-
defs = @defs[name]
|
35
33
|
chart = @charts.find(name)
|
36
34
|
assert_equal ['Fri','Sat','Sun','Mon','Tue','Wed','Thu'], chart.periodnames
|
37
35
|
v = {:minimum => 1.0, :maximum => 2.0, :average => 1.5}
|
@@ -43,7 +41,6 @@ class TestChart < Test::Unit::TestCase
|
|
43
41
|
|
44
42
|
def test_min_max_0arity
|
45
43
|
name = 'week'
|
46
|
-
defs = @defs[name]
|
47
44
|
chart = @charts.find(name)
|
48
45
|
assert_equal ['Fri','Sat','Sun','Mon','Tue','Wed','Thu'], chart.periodnames
|
49
46
|
v = {:minimum => 1.0, :maximum => 2.0, :average => 1.5}
|
data/test/daemon_test.rb
CHANGED
@@ -18,7 +18,7 @@ class TestDaemon < Test::Unit::TestCase
|
|
18
18
|
end
|
19
19
|
|
20
20
|
db_test_query("SELECT * FROM sensor_reads") do |res|
|
21
|
-
assert res.
|
21
|
+
assert res.count > 0, "No sensor reads in DB"
|
22
22
|
end
|
23
23
|
end
|
24
24
|
|
@@ -38,7 +38,7 @@ class TestDaemon < Test::Unit::TestCase
|
|
38
38
|
end
|
39
39
|
|
40
40
|
db_test_query("SELECT * FROM sensor_reads") do |res|
|
41
|
-
assert res.
|
41
|
+
assert res.count == 0
|
42
42
|
end
|
43
43
|
end
|
44
44
|
end
|
data/test/dbstore_test.rb
CHANGED
@@ -9,8 +9,11 @@ class TestFileStore < Test::Unit::TestCase
|
|
9
9
|
@dbstore.write(:test_sensor, test_time, test_value)
|
10
10
|
|
11
11
|
db_test_query("SELECT * FROM sensor_reads") do |res|
|
12
|
-
assert_equal 1, res.
|
13
|
-
|
12
|
+
assert_equal 1, res.count
|
13
|
+
row = res.first
|
14
|
+
assert_equal "test_sensor", row["sensor"]
|
15
|
+
assert_equal test_time, row["date"].to_i
|
16
|
+
assert_equal test_value, row["value"].to_f
|
14
17
|
end
|
15
18
|
end
|
16
19
|
|
data/test/dinrelay_test.rb
CHANGED
@@ -151,7 +151,7 @@ class TestDINRelay < Test::Unit::TestCase
|
|
151
151
|
|
152
152
|
# Test that the cache times out
|
153
153
|
def test_cache_invalidation
|
154
|
-
|
154
|
+
_sensors = SAAL::Sensors.new(@test_sensors_dinrelay_file, TEST_DBCONF)
|
155
155
|
@og.cache_timeout = 0.1
|
156
156
|
with_webrick(:html=>create_index_html(@vals)) do |feedback|
|
157
157
|
@og.state(1)
|
data/test/sensors_test.rb
CHANGED
@@ -67,11 +67,11 @@ class TestSensors < Test::Unit::TestCase
|
|
67
67
|
end
|
68
68
|
|
69
69
|
db_test_query("SELECT * FROM sensor_reads") do |res|
|
70
|
-
assert_equal 1, res.
|
71
|
-
row = res.
|
72
|
-
assert_equal "fake_temp", row[
|
73
|
-
assert_in_delta Time.now.utc.to_i, row[
|
74
|
-
assert_instance_of Float, row[
|
70
|
+
assert_equal 1, res.count
|
71
|
+
row = res.first
|
72
|
+
assert_equal "fake_temp", row["sensor"]
|
73
|
+
assert_in_delta Time.now.utc.to_i, row["date"].to_i, 100
|
74
|
+
assert_instance_of Float, row["value"].to_f
|
75
75
|
end
|
76
76
|
end
|
77
77
|
end
|
data/test/test_db.yml
CHANGED
@@ -1,4 +1,4 @@
|
|
1
1
|
host: localhost
|
2
|
-
|
3
|
-
|
4
|
-
|
2
|
+
username: sensor_reads
|
3
|
+
password: password
|
4
|
+
database: sensor_reads_test
|
data/test/test_helper.rb
CHANGED
data/test/test_sensors.yml
CHANGED
metadata
CHANGED
@@ -1,63 +1,85 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: saal
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.2
|
5
|
-
prerelease:
|
4
|
+
version: 0.3.2
|
6
5
|
platform: ruby
|
7
6
|
authors:
|
8
7
|
- Pedro Côrte-Real
|
9
|
-
autorequire:
|
8
|
+
autorequire:
|
10
9
|
bindir: bin
|
11
10
|
cert_chain: []
|
12
|
-
date:
|
11
|
+
date: 2020-12-25 00:00:00.000000000 Z
|
13
12
|
dependencies:
|
14
13
|
- !ruby/object:Gem::Dependency
|
15
14
|
name: ownet
|
16
|
-
requirement:
|
17
|
-
none: false
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
18
16
|
requirements:
|
19
|
-
- -
|
17
|
+
- - "~>"
|
20
18
|
- !ruby/object:Gem::Version
|
21
|
-
version: 0.2
|
19
|
+
version: '0.2'
|
22
20
|
type: :runtime
|
23
21
|
prerelease: false
|
24
|
-
version_requirements:
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0.2'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: mysql2
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0.5'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0.5'
|
25
41
|
- !ruby/object:Gem::Dependency
|
26
42
|
name: nokogiri
|
27
|
-
requirement:
|
28
|
-
none: false
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
29
44
|
requirements:
|
30
|
-
- -
|
45
|
+
- - "~>"
|
31
46
|
- !ruby/object:Gem::Version
|
32
|
-
version: '
|
47
|
+
version: '1.8'
|
33
48
|
type: :runtime
|
34
49
|
prerelease: false
|
35
|
-
version_requirements:
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '1.8'
|
36
55
|
- !ruby/object:Gem::Dependency
|
37
|
-
name:
|
38
|
-
requirement:
|
39
|
-
none: false
|
56
|
+
name: net-http-digest_auth
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
40
58
|
requirements:
|
41
|
-
- -
|
59
|
+
- - "~>"
|
42
60
|
- !ruby/object:Gem::Version
|
43
|
-
version: '
|
61
|
+
version: '1.4'
|
44
62
|
type: :runtime
|
45
63
|
prerelease: false
|
46
|
-
version_requirements:
|
47
|
-
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '1.4'
|
69
|
+
description: "A daemon and libraries to create an abstraction layer that interfaces
|
48
70
|
with \nsensors and actuators, recording their state, responding to requests \nfor
|
49
71
|
current and historical values, and allowing changes of state.\n"
|
50
72
|
email: pedro@pedrocr.net
|
51
73
|
executables:
|
52
|
-
- saal_chart~
|
53
74
|
- dinrelaystatus
|
54
|
-
- saal_readall
|
55
|
-
- saal_dump_database
|
56
|
-
- saal_daemon
|
57
|
-
- saal_chart
|
58
|
-
- saal_daemon~
|
59
75
|
- dinrelayset
|
60
76
|
- saal_import_mysql
|
77
|
+
- saal_dump_database
|
78
|
+
- saal_readall
|
79
|
+
- saal_chart
|
80
|
+
- saal_daemon
|
81
|
+
- saal_envoy_generate_config
|
82
|
+
- saal_envoy_read
|
61
83
|
extensions: []
|
62
84
|
extra_rdoc_files:
|
63
85
|
- README.rdoc
|
@@ -73,6 +95,8 @@ files:
|
|
73
95
|
- bin/saal_chart
|
74
96
|
- bin/saal_daemon
|
75
97
|
- bin/saal_dump_database
|
98
|
+
- bin/saal_envoy_generate_config
|
99
|
+
- bin/saal_envoy_read
|
76
100
|
- bin/saal_import_mysql
|
77
101
|
- bin/saal_readall
|
78
102
|
- lib/chart.rb
|
@@ -81,6 +105,8 @@ files:
|
|
81
105
|
- lib/daemon.rb
|
82
106
|
- lib/dbstore.rb
|
83
107
|
- lib/dinrelay.rb
|
108
|
+
- lib/envoy.rb
|
109
|
+
- lib/http.rb
|
84
110
|
- lib/outliercache.rb
|
85
111
|
- lib/owsensor.rb
|
86
112
|
- lib/saal.rb
|
@@ -104,34 +130,31 @@ files:
|
|
104
130
|
- test/test_helper.rb
|
105
131
|
- test/test_sensor_cleanups.yml
|
106
132
|
- test/test_sensors.yml
|
107
|
-
- bin/saal_chart~
|
108
|
-
- bin/saal_daemon~
|
109
133
|
homepage: https://github.com/pedrocr/saal
|
110
|
-
licenses:
|
111
|
-
|
134
|
+
licenses:
|
135
|
+
- LGPL-2.1
|
136
|
+
metadata: {}
|
137
|
+
post_install_message:
|
112
138
|
rdoc_options:
|
113
|
-
- -S
|
114
|
-
- -w 2
|
115
|
-
- -N
|
116
|
-
- -c utf8
|
139
|
+
- "-S"
|
140
|
+
- "-w 2"
|
141
|
+
- "-N"
|
142
|
+
- "-c utf8"
|
117
143
|
require_paths:
|
118
144
|
- lib
|
119
145
|
required_ruby_version: !ruby/object:Gem::Requirement
|
120
|
-
none: false
|
121
146
|
requirements:
|
122
|
-
- -
|
147
|
+
- - ">="
|
123
148
|
- !ruby/object:Gem::Version
|
124
149
|
version: '0'
|
125
150
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
126
|
-
none: false
|
127
151
|
requirements:
|
128
|
-
- -
|
152
|
+
- - ">="
|
129
153
|
- !ruby/object:Gem::Version
|
130
154
|
version: '0'
|
131
155
|
requirements: []
|
132
|
-
|
133
|
-
|
134
|
-
signing_key:
|
156
|
+
rubygems_version: 3.1.2
|
157
|
+
signing_key:
|
135
158
|
specification_version: 2
|
136
159
|
summary: Thin abstraction layer for interfacing and recording sensors (currently onewire)
|
137
160
|
and actuators (currently dinrelay)
|
data/bin/saal_chart~
DELETED
@@ -1,90 +0,0 @@
|
|
1
|
-
#!/usr/bin/env ruby
|
2
|
-
|
3
|
-
NUM_VALUES_SMALL = 500 # Datapoints in "small" charts
|
4
|
-
NUM_VALUES_LARGE = 150 # Datapoints in "large" charts
|
5
|
-
LARGE_CHART_THRESHOLD = 30*24*60*60 # Threshold for a large chart (in seconds)
|
6
|
-
|
7
|
-
require File.dirname(__FILE__)+'/../lib/saal.rb'
|
8
|
-
|
9
|
-
def usage
|
10
|
-
$stderr.puts("Usage: saal_chart <chart dir>")
|
11
|
-
end
|
12
|
-
|
13
|
-
if ARGV.size != 1
|
14
|
-
usage
|
15
|
-
exit (2)
|
16
|
-
end
|
17
|
-
|
18
|
-
SENSOR_RANGES = {:temperature=>[-15, 45], :humidity=>[0,100], :pressure=>[950,1050]}
|
19
|
-
|
20
|
-
|
21
|
-
SAAL::Charts.new.each do |chart|
|
22
|
-
$stderr.puts "Generating chart #{chart.name}"
|
23
|
-
|
24
|
-
pngfile = ARGV[0]+'/chart-'+chart.name.to_s+'.png'
|
25
|
-
ymlfile = ARGV[0]+'/chart-'+chart.name.to_s+'.yml'
|
26
|
-
|
27
|
-
@mins = chart.minimum
|
28
|
-
@maxs = chart.maximum
|
29
|
-
@avgs = chart.average
|
30
|
-
@minmax = {}
|
31
|
-
chart.sensors.each do |s|
|
32
|
-
s = s.name.to_sym
|
33
|
-
@minmax[s] = {:maximum => @maxs[s], :minimum => @mins[s], :average => @avgs[s]}
|
34
|
-
end
|
35
|
-
|
36
|
-
File.open(ymlfile, 'w').write(YAML::dump(@minmax))
|
37
|
-
|
38
|
-
def normalize_data(data, min, max)
|
39
|
-
data.map do |i|
|
40
|
-
if i.nil?
|
41
|
-
-1.0
|
42
|
-
elsif i < min
|
43
|
-
0.0
|
44
|
-
elsif i > max
|
45
|
-
100.0
|
46
|
-
else
|
47
|
-
(((i-min)/(max-min).to_f)*1000).round/10.0
|
48
|
-
end
|
49
|
-
end
|
50
|
-
end
|
51
|
-
|
52
|
-
@periodnames = chart.periodnames
|
53
|
-
@numperiods = @periodnames.size
|
54
|
-
num_values = ((chart.to-chart.from)>LARGE_CHART_THRESHOLD) ?
|
55
|
-
NUM_VALUES_LARGE :
|
56
|
-
NUM_VALUES_SMALL
|
57
|
-
|
58
|
-
@averages = chart.average(num_values)
|
59
|
-
|
60
|
-
@data = chart.sensors.map do |sensor|
|
61
|
-
normalize_data(@averages[sensor.name.to_sym], *(SENSOR_RANGES[sensor.sensor_type]))
|
62
|
-
end
|
63
|
-
|
64
|
-
@dataurl = @data.map {|values| values.join(",")}.join('|')
|
65
|
-
|
66
|
-
r = {}
|
67
|
-
case chart.alignlabels
|
68
|
-
when :center
|
69
|
-
@periodnamesurl = "||"+@periodnames.join('||')+"||"
|
70
|
-
when :left
|
71
|
-
@periodnamesurl = "|"+@periodnames.join('|')+"||"
|
72
|
-
r[:chxs] = "0,555555,11,-1,lt"
|
73
|
-
end
|
74
|
-
@xincr = 100.0/@numperiods.to_f*10000.truncate.to_f/10000
|
75
|
-
|
76
|
-
r[:chof] = "png"
|
77
|
-
r[:chs] = "700x300"
|
78
|
-
r[:cht] = "lc"
|
79
|
-
r[:chco] = "00ff00,ff0000,0000ff,ff9933,800080"
|
80
|
-
r[:chxt] = "x,y,y,r"
|
81
|
-
r[:chxl] = "0:#{@periodnamesurl}1:|-15ºC||0||15||30||45ºC|2:|0%|25|50|75|100%|3:|950||975||1000||1025||1050 hPa"
|
82
|
-
r[:chg] = "#{@xincr},12.5,1,5"
|
83
|
-
r[:chd] = "t:#{@dataurl}"
|
84
|
-
|
85
|
-
@url = "http://chart.apis.google.com/chart?&"
|
86
|
-
@postdata = r.map{|k,v| k.to_s+"="+v}.join("&")
|
87
|
-
|
88
|
-
|
89
|
-
system "wget --quiet \"#{@url}\" --post-data=\"#{@postdata}\" -O #{pngfile}"
|
90
|
-
end
|
data/bin/saal_daemon~
DELETED
@@ -1,31 +0,0 @@
|
|
1
|
-
#!/usr/bin/env ruby
|
2
|
-
|
3
|
-
SENSOR_INTERVAL = 60 # seconds between consecutive measurements of the sensors
|
4
|
-
DBCONF = "/etc/saal/database.yml"
|
5
|
-
SENSORSCONF = "/etc/saal/sensors.yml"
|
6
|
-
|
7
|
-
require File.dirname(__FILE__)+'/../lib/saal.rb'
|
8
|
-
|
9
|
-
def usage
|
10
|
-
$stderr.puts "Usage: saal_daemon <pidfile|--foreground>"
|
11
|
-
end
|
12
|
-
|
13
|
-
if ARGV.size != 1
|
14
|
-
usage
|
15
|
-
exit 2
|
16
|
-
else
|
17
|
-
pidfile = ARGV[0]
|
18
|
-
foreground = (ARGV[0] == '--foreground')
|
19
|
-
d = SAAL::Daemon.new(:interval => SENSOR_INTERVAL,
|
20
|
-
:sensorconf => SENSORSCONF,
|
21
|
-
:dbconf => DBCONF,
|
22
|
-
:foreground => foreground,
|
23
|
-
:keep_stdin => true)
|
24
|
-
pid = d.run
|
25
|
-
if !foreground
|
26
|
-
File.open(pidfile, 'w') do |f|
|
27
|
-
f.write(pid)
|
28
|
-
f.close
|
29
|
-
end
|
30
|
-
end
|
31
|
-
end
|