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 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 row says **LIVE**, or **NOT ON BUS** with the instance nothing is sending ("Nothing is sending instance 3 on NMEA 2000"), which is the number to set on the sending device. NMEA 2000 "not available" values are never shown as readings.
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
- const MONITOR_LIVE_MS = 120e3
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
- res.json(trends.read(skPath, custom ? { from: Number(q.from), to: Number(q.to) } : q.range))
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.19",
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 box = `<b>${u.f(r.value).toFixed(u.dp)}</b><i>${u.unit}</i><small>${u.label}</small>`;
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="mon-val" title="${u.label} trend" onclick="openSeries('${encodeURIComponent(r.path)}','${encodeURIComponent(item.name)}','${r.field}')">${box}</button>`
597
- : `<div class="mon-val">${box}</div>`;
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
- values = item.live ? item.readings.map(r => monitorValue(item, r)).join('') : '';
607
- if (item.live && item.capacity) values += `<div class="mon-val"><b>${Math.round(item.capacity * 1000)}</b><i>L</i><small>Capacity</small></div>`;
608
- sub = item.live ? `Instance ${item.instance}${item.thirdParty ? ' · third-party' : ''}` : `Nothing is sending instance ${item.instance} on NMEA 2000`;
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
- 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">${item.live ? 'LIVE' : 'NOT ON BUS'}</span></div>`;
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 */ }