signalk-czone 0.3.0-beta.19 → 0.3.0-beta.20
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.
- package/CHANGELOG.md +5 -0
- package/README.md +2 -2
- package/index.js +12 -3
- package/lib/power-history.js +66 -0
- package/package.json +2 -2
- package/public/index.html +32 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0-beta.20
|
|
4
|
+
|
|
5
|
+
- Shows individual monitoring readings as live for three minutes, then keeps their last values dimmed with their age; distinguishes stale rows from no data (#38, thanks @superstar76).
|
|
6
|
+
- Reconstructs earlier DC power trends from recorded voltage and current while preserving directly recorded power history.
|
|
7
|
+
|
|
3
8
|
## 0.3.0-beta.19
|
|
4
9
|
|
|
5
10
|
- Adds opt-in Monitoring for meters, tanks, temperatures, humidity, pressure and switch inputs, with expanded trends and circuit temperature chips (#36, thanks @superstar76).
|
package/README.md
CHANGED
|
@@ -202,7 +202,7 @@ Off by default: tick **Show the Monitoring tab** in the configuration panel to t
|
|
|
202
202
|
| Environment | pressure senders | 130314 |
|
|
203
203
|
| Inputs | switch inputs | (see Switch inputs) |
|
|
204
204
|
|
|
205
|
-
A reading is matched by the NMEA 2000 instance set in the Configuration Tool, and the temperature or pressure source or tank fluid type. Each
|
|
205
|
+
A reading is matched by the NMEA 2000 instance set in the Configuration Tool, and the temperature or pressure source or tank fluid type. Each reading is **LIVE** while it has updated in the last 3 minutes. After that its last value stays, dimmed, with its age ("Charge · 5 min ago"), counting up; a row with nothing live says when it was last updated. A row that has never had a reading says **No data received** with its instance ("No data received · instance 3"), which is the number to set on the sending device. NMEA 2000 "not available" values are never shown as readings.
|
|
206
206
|
|
|
207
207
|
**Which device.** Instances are not unique across devices: a Victron charger sends a battery status on the battery's instance as well as the shunt, and a wired meter can share an instance with another device. So each reading comes from one device:
|
|
208
208
|
|
|
@@ -211,7 +211,7 @@ A reading is matched by the NMEA 2000 instance set in the Configuration Tool, an
|
|
|
211
211
|
3. A third-party DC meter: where devices say what they are (127506), only one of the meter's DC type is used, and for a battery one that reports a state of charge beats one that does not, so a shunt beats a charger. Ties go to the lowest address, so the choice is the same after a restart.
|
|
212
212
|
4. Otherwise the first device heard keeps the reading until it has been quiet for two minutes.
|
|
213
213
|
|
|
214
|
-
Values are published under `electrical.czone.monitoring.<name>.*`, as the meters already were: `voltage`, `current`, `power`, `capacity.stateOfCharge`, `frequency`, `temperature`, `relativeHumidity`, `pressure`, `currentLevel`, `currentVolume`, `capacity`. **DC power** (watts) is volts × amps of the same 127508 message, so solar, alternator and wind output can be seen and trended.
|
|
214
|
+
Values are published under `electrical.czone.monitoring.<name>.*`, as the meters already were: `voltage`, `current`, `power`, `capacity.stateOfCharge`, `frequency`, `temperature`, `relativeHumidity`, `pressure`, `currentLevel`, `currentVolume`, `capacity`. **DC power** (watts) is volts × amps of the same 127508 message, so solar, alternator and wind output can be seen and trended. Its trend goes back as far as the meter's volts and amps: before power was first recorded, the chart works it out from them (the latest of each at that time; for long periods, from their averages, with the band from their ranges). Where power itself was recorded, those exact figures are used.
|
|
215
215
|
|
|
216
216
|
**Senders bridged onto NMEA 2000 by the same machine.** Signal K does not pass a plugin its own transmissions, or a GX's:
|
|
217
217
|
|
package/index.js
CHANGED
|
@@ -10,6 +10,7 @@ const monitoring = require('./lib/monitoring')
|
|
|
10
10
|
const sensors = require('./lib/sensors')
|
|
11
11
|
const { createCandump, PGNS: CANDUMP_PGNS } = require('./lib/candump')
|
|
12
12
|
const { createSourcePicker } = require('./lib/sources')
|
|
13
|
+
const { powerHistory } = require('./lib/power-history')
|
|
13
14
|
const { createTrends } = require('./lib/trends')
|
|
14
15
|
const signalk = require('./lib/signalk')
|
|
15
16
|
const confirmOffLib = require('./lib/confirm-off')
|
|
@@ -24,7 +25,9 @@ const CZONE_CONFIG_BLOCK_SIZE = 200
|
|
|
24
25
|
const CZONE_CONFIG_READ_TIMEOUT_MS = 45000
|
|
25
26
|
const PLUGIN_ID = 'signalk-czone'
|
|
26
27
|
// A monitored value older than this is no longer live (NOT ON BUS).
|
|
27
|
-
|
|
28
|
+
// A reading is LIVE while it has updated within this long; after that it is
|
|
29
|
+
// shown dimmed with its age ("Updated 5 min ago").
|
|
30
|
+
const MONITOR_LIVE_MS = 180e3
|
|
28
31
|
// A circuit's temperature is dropped after this long without a reading.
|
|
29
32
|
const TEMPERATURE_STALE_MS = 300e3
|
|
30
33
|
const MONITOR_GROUPS = { battery: 'Batteries', solar: 'Solar', alternator: 'Alternators', converter: 'Converters', wind: 'Wind Generators' }
|
|
@@ -1027,7 +1030,7 @@ module.exports = function (app) {
|
|
|
1027
1030
|
const now = Date.now()
|
|
1028
1031
|
const reading = (path, field) => {
|
|
1029
1032
|
const v = monitorValues.get(path)
|
|
1030
|
-
return { field, path, value: v ? v.value : null, at: v ? new Date(v.at).toISOString() : null, live: !!v && now - v.at < MONITOR_LIVE_MS }
|
|
1033
|
+
return { field, path, value: v ? v.value : null, at: v ? new Date(v.at).toISOString() : null, ageMs: v ? now - v.at : null, live: !!v && now - v.at < MONITOR_LIVE_MS }
|
|
1031
1034
|
}
|
|
1032
1035
|
const item = (group, kind, x, fields) => {
|
|
1033
1036
|
const readings = fields.filter(f => x.signalK[f]).map(f => reading(x.signalK[f], f))
|
|
@@ -1727,7 +1730,13 @@ module.exports = function (app) {
|
|
|
1727
1730
|
if (!trends) return res.json({ available: false, reason: 'not_started', data: [] })
|
|
1728
1731
|
const q = req.query
|
|
1729
1732
|
const custom = q.from !== undefined && q.to !== undefined
|
|
1730
|
-
|
|
1733
|
+
const range = custom ? { from: Number(q.from), to: Number(q.to) } : q.range
|
|
1734
|
+
// A DC meter's power: before it was first recorded, worked out from
|
|
1735
|
+
// the meter's recorded volts and amps (lib/power-history).
|
|
1736
|
+
const m = /^electrical\.czone\.monitoring\.(.+)\.power$/.exec(skPath)
|
|
1737
|
+
const meter = m && monitoringRuntime ? monitoringRuntime.meters.find(x => x.slug === m[1] && x.type === 'DC') : null
|
|
1738
|
+
if (meter) return res.json(powerHistory(trends.read(skPath, range), trends.read(meter.signalK.voltage, range), trends.read(meter.signalK.current, range)))
|
|
1739
|
+
res.json(trends.read(skPath, range))
|
|
1731
1740
|
})
|
|
1732
1741
|
|
|
1733
1742
|
router.get('/trend/status', (_req, res) => {
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// DC power history for a chart.
|
|
4
|
+
//
|
|
5
|
+
// Live DC power is volts x amps of one 127508 message (lib/monitoring), and
|
|
6
|
+
// it is recorded like any other value. Before it was recorded (it is new in
|
|
7
|
+
// 0.3.0-beta.19) the meter's volts and amps were already being recorded, so
|
|
8
|
+
// the chart works that earlier part out from them:
|
|
9
|
+
// - each point is volts x amps, the latest of each at that time, as long as
|
|
10
|
+
// neither is older than the gap the chart allows;
|
|
11
|
+
// - for summarised periods (rows of [t, average, min, max]) the value is
|
|
12
|
+
// the product of the averages and the band is the range the two bands
|
|
13
|
+
// allow, so it is close but not exact.
|
|
14
|
+
// Wherever power itself was recorded, those exact figures are used.
|
|
15
|
+
//
|
|
16
|
+
// Inputs and output are the trend store's read() results:
|
|
17
|
+
// { available, data: [[t, value]] or [[t, avg, min, max]], gapMs, ... }
|
|
18
|
+
|
|
19
|
+
const round1 = v => Math.round(v * 10) / 10
|
|
20
|
+
const low = row => (row.length > 2 && row[2] !== null ? row[2] : row[1])
|
|
21
|
+
const high = row => (row.length > 3 && row[3] !== null ? row[3] : row[1])
|
|
22
|
+
|
|
23
|
+
// volts x amps, for times before `until` (ms; Infinity = all).
|
|
24
|
+
function multiply (volts, amps, until = Infinity) {
|
|
25
|
+
const v = (volts && volts.data) || []
|
|
26
|
+
const a = (amps && amps.data) || []
|
|
27
|
+
const gap = Math.max(volts.gapMs || 0, amps.gapMs || 0) || 16 * 60e3
|
|
28
|
+
const times = [...new Set([...v.map(r => r[0]), ...a.map(r => r[0])])].filter(t => t < until).sort((x, y) => x - y)
|
|
29
|
+
const out = []
|
|
30
|
+
let i = 0
|
|
31
|
+
let j = 0
|
|
32
|
+
let rv = null
|
|
33
|
+
let ra = null
|
|
34
|
+
for (const t of times) {
|
|
35
|
+
while (i < v.length && v[i][0] <= t) rv = v[i++]
|
|
36
|
+
while (j < a.length && a[j][0] <= t) ra = a[j++]
|
|
37
|
+
if (!rv || !ra || t - rv[0] > gap || t - ra[0] > gap) continue
|
|
38
|
+
if (rv.length > 2 || ra.length > 2) {
|
|
39
|
+
const corners = [low(rv) * low(ra), low(rv) * high(ra), high(rv) * low(ra), high(rv) * high(ra)]
|
|
40
|
+
out.push([t, round1(rv[1] * ra[1]), round1(Math.min(...corners)), round1(Math.max(...corners))])
|
|
41
|
+
} else {
|
|
42
|
+
out.push([t, round1(rv[1] * ra[1])])
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return out
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// power, volts, amps: read() results for the same range.
|
|
49
|
+
function powerHistory (power, volts, amps) {
|
|
50
|
+
if (!power || !power.available || power.error) return power
|
|
51
|
+
if (!volts || !volts.available || !amps || !amps.available) return power
|
|
52
|
+
const recorded = Array.isArray(power.data) ? power.data : []
|
|
53
|
+
const first = recorded.length ? recorded[0][0] : Infinity
|
|
54
|
+
const before = multiply(volts, amps, first)
|
|
55
|
+
if (!before.length) return power
|
|
56
|
+
return {
|
|
57
|
+
...power,
|
|
58
|
+
data: before.concat(recorded),
|
|
59
|
+
points: before.length + recorded.length,
|
|
60
|
+
gapMs: Math.max(power.gapMs || 0, volts.gapMs || 0, amps.gapMs || 0) || undefined,
|
|
61
|
+
// Up to here the figures are worked out from volts x amps.
|
|
62
|
+
computedUntil: before[before.length - 1][0]
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { powerHistory, multiply }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-czone",
|
|
3
|
-
"version": "0.3.0-beta.
|
|
3
|
+
"version": "0.3.0-beta.20",
|
|
4
4
|
"description": "BETA: Signal K CZone monitoring and control using dynamically parsed ZCF configuration and NMEA 2000",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"keywords": [
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"icon.png"
|
|
43
43
|
],
|
|
44
44
|
"scripts": {
|
|
45
|
-
"test": "node test/zcf.test.js && node test/zcf-fixtures.test.js && node test/circuit-state.test.js && node test/czone.test.js && node test/nmea2000.test.js && node test/control-x-plus.test.js && node test/signalk.test.js && node test/write.test.js && node test/circuit-status.test.js && node test/status-mapping-regression.test.js && node test/state.test.js && node test/config-network.test.js && node test/config-namespace.test.js && node test/monitoring.test.js && node test/sensors.test.js && node test/monitoring-plugin.test.js && node test/confirm-off.test.js && node test/webapp-current.test.js && node test/webapp-auth.test.js && node test/webapp-monitoring.test.js && node test/bridge.test.js && node test/victron-switches.test.js && node test/victron-label-steady.test.js && node test/switch-pane-plugin.test.js && node test/trends.test.js && node test/inputs.test.js && node --require ./test/helpers/gx-low-space.cjs test/trends.test.js"
|
|
45
|
+
"test": "node test/zcf.test.js && node test/zcf-fixtures.test.js && node test/circuit-state.test.js && node test/czone.test.js && node test/nmea2000.test.js && node test/control-x-plus.test.js && node test/signalk.test.js && node test/write.test.js && node test/circuit-status.test.js && node test/status-mapping-regression.test.js && node test/state.test.js && node test/config-network.test.js && node test/config-namespace.test.js && node test/monitoring.test.js && node test/sensors.test.js && node test/monitoring-plugin.test.js && node test/power-history.test.js && node test/confirm-off.test.js && node test/webapp-current.test.js && node test/webapp-auth.test.js && node test/webapp-monitoring.test.js && node test/bridge.test.js && node test/victron-switches.test.js && node test/victron-label-steady.test.js && node test/switch-pane-plugin.test.js && node test/trends.test.js && node test/inputs.test.js && node --require ./test/helpers/gx-low-space.cjs test/trends.test.js"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"signalk-czone-zcf": "0.1.0-beta.2"
|
package/public/index.html
CHANGED
|
@@ -118,6 +118,9 @@
|
|
|
118
118
|
.mon-val.on { border-color:rgba(46,213,115,.55); background:rgba(46,213,115,.12); }
|
|
119
119
|
.mon-live { margin-left:10px; font-size:10px; font-weight:700; letter-spacing:.6px; color:#2ed573; white-space:nowrap; }
|
|
120
120
|
.mon-row.off .mon-live { color:#ffb84d; }
|
|
121
|
+
/* A reading that has not updated for 3 minutes: dimmed, with its age. */
|
|
122
|
+
.mon-val.stale { opacity:.55; }
|
|
123
|
+
.mon-val.stale small { color:#ffb84d; }
|
|
121
124
|
.temp-chip { display:inline-block; margin-left:8px; padding:1px 7px; border-radius:9px; border:1px solid rgba(24,213,232,.35); background:rgba(24,213,232,.08); color:var(--cyan); font:inherit; font-size:11px; font-weight:600; vertical-align:1px; }
|
|
122
125
|
button.temp-chip { cursor:pointer; }
|
|
123
126
|
@media (max-width: 600px) {
|
|
@@ -589,12 +592,26 @@
|
|
|
589
592
|
showTrend({ path, title: decodeURIComponent(name), r: readingFor(item, field) });
|
|
590
593
|
};
|
|
591
594
|
|
|
595
|
+
// How long ago a reading arrived: its age when fetched (by the server's
|
|
596
|
+
// clock, so a tablet or plotter with the wrong time does not matter), plus
|
|
597
|
+
// the time since. It counts up as the tab refreshes.
|
|
598
|
+
function monitorAge(ageMs) {
|
|
599
|
+
const ms = ageMs + (monitor.fetchedAt ? Date.now() - monitor.fetchedAt : 0);
|
|
600
|
+
const min = Math.floor(ms / 60000);
|
|
601
|
+
if (min < 60) return `${Math.max(1, min)} min ago`;
|
|
602
|
+
const h = Math.floor(min / 60);
|
|
603
|
+
if (h < 24) return `${h} h ${min % 60} min ago`;
|
|
604
|
+
return `${Math.floor(h / 24)} d ago`;
|
|
605
|
+
}
|
|
606
|
+
|
|
592
607
|
function monitorValue(item, r) {
|
|
593
608
|
const u = readingFor(item, r.field);
|
|
594
|
-
const
|
|
609
|
+
const stale = r.live === false && Number.isFinite(r.ageMs);
|
|
610
|
+
const box = `<b>${u.f(r.value).toFixed(u.dp)}</b><i>${u.unit}</i><small>${stale ? `${u.label} · ${monitorAge(r.ageMs)}` : u.label}</small>`;
|
|
611
|
+
const cls = `mon-val${stale ? ' stale' : ''}`;
|
|
595
612
|
return monitor.trendsEnabled && r.field !== 'capacity'
|
|
596
|
-
? `<button type="button" class="
|
|
597
|
-
: `<div class="
|
|
613
|
+
? `<button type="button" class="${cls}" title="${u.label} trend" onclick="openSeries('${encodeURIComponent(r.path)}','${encodeURIComponent(item.name)}','${r.field}')">${box}</button>`
|
|
614
|
+
: `<div class="${cls}">${box}</div>`;
|
|
598
615
|
}
|
|
599
616
|
|
|
600
617
|
function monitorRow(item) {
|
|
@@ -603,11 +620,18 @@
|
|
|
603
620
|
values = item.state === null ? '' : `<div class="mon-val ${item.state ? 'on' : ''}"><b>${item.state ? 'ON' : 'OFF'}</b><small>Switch input</small></div>`;
|
|
604
621
|
sub = item.decoded === false ? 'This kind of module is not read yet' : item.live ? (item.since ? `Since ${new Date(item.since).toLocaleTimeString([], { hour:'2-digit', minute:'2-digit' })}` : 'Switch input') : 'No status from its module yet';
|
|
605
622
|
} else {
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
623
|
+
// LIVE while a reading updated in the last 3 minutes; older readings
|
|
624
|
+
// dimmed with their age; "No data received" when nothing has arrived.
|
|
625
|
+
values = item.readings.map(r => monitorValue(item, r)).join('');
|
|
626
|
+
if (item.readings.length && item.capacity) values += `<div class="mon-val${item.live ? '' : ' stale'}"><b>${Math.round(item.capacity * 1000)}</b><i>L</i><small>Capacity</small></div>`;
|
|
627
|
+
const ages = item.readings.map(r => r.ageMs).filter(Number.isFinite);
|
|
628
|
+
const where = `instance ${item.instance}${item.thirdParty ? ' · third-party' : ''}`;
|
|
629
|
+
sub = item.live ? `Instance ${item.instance}${item.thirdParty ? ' · third-party' : ''}`
|
|
630
|
+
: ages.length ? `Updated ${monitorAge(Math.min.apply(null, ages))} · ${where}`
|
|
631
|
+
: `No data received · ${where}`;
|
|
609
632
|
}
|
|
610
|
-
|
|
633
|
+
const badge = item.live ? 'LIVE' : (item.kind !== 'input' && item.readings.length ? 'NOT LIVE' : 'NO DATA');
|
|
634
|
+
return `<div class="mon-row ${item.live ? '' : 'off'}"><div class="circuit-name"><strong>${esc(item.name)}</strong><small>${esc(sub)}</small></div><div class="mon-values">${values}</div><span class="mon-live">${badge}</span></div>`;
|
|
611
635
|
}
|
|
612
636
|
|
|
613
637
|
function renderMonitoring() {
|
|
@@ -637,7 +661,7 @@
|
|
|
637
661
|
const data = await r.json();
|
|
638
662
|
const chips = () => JSON.stringify(Object.keys(monitor.temperatures).map(k => [k, monitor.temperatures[k].kelvin.toFixed(1)]).concat([monitor.items.length, monitor.trendsEnabled]));
|
|
639
663
|
const before = chips();
|
|
640
|
-
monitor = { items: Array.isArray(data.items) ? data.items : [], temperatures: data.temperatures || {}, trendsEnabled: data.trendsEnabled === true };
|
|
664
|
+
monitor = { items: Array.isArray(data.items) ? data.items : [], temperatures: data.temperatures || {}, trendsEnabled: data.trendsEnabled === true, fetchedAt: Date.now() };
|
|
641
665
|
if (view === 'monitor' && !category) { renderMonitoring(); renderNav(); }
|
|
642
666
|
else if (chips() !== before) render(); // only when a chip or the tab changed: a redraw would cut short a slider being moved
|
|
643
667
|
} catch (_) { /* the next round tries again */ }
|