signalk-czone 0.3.0-beta.18 → 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,16 @@
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
+
8
+ ## 0.3.0-beta.19
9
+
10
+ - Adds opt-in Monitoring for meters, tanks, temperatures, humidity, pressure and switch inputs, with expanded trends and circuit temperature chips (#36, thanks @superstar76).
11
+ - Fixes meter-table loading, fast-packet monitoring and unavailable-value handling; supports direct monitoring reads on Victron GX.
12
+ - Keeps Monitoring search within the tab and restores circuit search wording when returning to circuits (#37).
13
+
3
14
  ## 0.3.0-beta.18
4
15
 
5
16
  - Adds in-page Signal K sign-in for browser and installed home-screen apps, without leaving CZone (#35, thanks @superstar76).
package/README.md CHANGED
@@ -189,11 +189,44 @@ The dimmed encoding has been checked against controlled observations at 60%, 75%
189
189
 
190
190
  The decoded secondary state and percentage are retained in the plugin diagnostics for each tracked DC circuit. The public Signal K current path remains unchanged (`electrical.czone.<circuit>.current`).
191
191
 
192
+ ## Monitoring
193
+
194
+ Off by default: tick **Show the Monitoring tab** in the configuration panel to turn it on. Meter readings are published to Signal K either way. When on, the webapp's **Monitoring** tab lists every meter and sender in the ZCF, live, with nothing else to set up:
195
+
196
+ | Group | From the ZCF | NMEA 2000 |
197
+ | --- | --- | --- |
198
+ | Batteries, Solar, Alternators, Converters, Wind Generators | DC meters, by their DC type | 127508 (volts, amps, temperature), 127506 (state of charge) |
199
+ | AC Power | AC meters | 127744 / 127747, or 127503 / 127504 |
200
+ | Tanks | tank senders, wired or third-party (capacity from the tank settings) | 127505 |
201
+ | Temperatures | temperature senders, wired or third-party | 130312 / 130316, and 130313 humidity on the same instance |
202
+ | Environment | pressure senders | 130314 |
203
+ | Inputs | switch inputs | (see Switch inputs) |
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 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
+
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
+
209
+ 1. A meter or sender wired to a CZone module is read only from that module's own address, learned from its status messages.
210
+ 2. A third-party meter or sender is never read from a CZone module's address.
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
+ 4. Otherwise the first device heard keeps the reading until it has been quiet for two minutes.
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. 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
+
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
+
218
+ - A reading sent by Signal K's **Signal K to NMEA 2000** plugin (for example a Ruuvi tag from a Bluetooth plugin) is taken from what that plugin hands Signal K to send.
219
+ - On a **Victron GX**, readings Venus OS itself sends (a Ruuvi tag or a GX tank input bridged by the GX) are read straight off the CAN interface with `candump`, as a CZone display sees them. While it delivers, the copies from Signal K are not used. Setting: **On a Victron GX, read monitoring messages straight off the CAN interface** (on; interface `vecan0`).
220
+
221
+ **A circuit's temperature.** A temperature sender named after a circuit ("Freezer Temperature" or "Freezer Temp" for the circuit "Freezer", ignoring case and punctuation) is shown as a chip beside the circuit's name, and in its Victron switch pane label: "Freezer (-18.2 °C, 2.9 A)". A name two senders share pairs with nothing. The temperature is dropped after five minutes without a reading.
222
+
223
+ With **Record trends** on, a value on the Monitoring tab, or the temperature chip, opens that value's trend.
224
+
192
225
  ## Trends
193
226
 
194
- Off by default: tick **Record circuit current trends** in the configuration panel to turn it on. Boats that already send Signal K data to a database (for example InfluxDB with Grafana) can leave it off. When on, the plugin records each circuit's current and the webapp charts it: the arrow at the end of a circuit row opens that circuit's trend at the top of the page, for the last 1 h, 24 h, 7 d, 31 d, 90 d or 1 y, or for a period of up to 366 days chosen under **Custom**.
227
+ Off by default: tick **Record trends** in the configuration panel to turn it on. Boats that already send Signal K data to a database (for example InfluxDB with Grafana) can leave it off. When on, the plugin records each circuit's current and every monitored value, and the webapp charts them: the arrow at the end of a circuit row, or a value on the Monitoring tab, opens that trend in a window in the middle of the page (the page under it stays where it was; the window can be made full screen, and fills the screen on a phone), for the last 1 h, 24 h, 7 d, 31 d, 90 d or 1 y, or for a period of up to 366 days chosen under **Custom**.
195
228
 
196
- **What is stored.** The latest value of every `electrical.czone.<circuit>.current` path is sampled every 10 seconds, however often CZone repeats it, and written to plain CSV files in two tiers:
229
+ **What is stored.** The latest value of every `electrical.czone.<circuit>.current` path, and of every monitored value, is sampled every 10 seconds, however often CZone repeats it, and written to plain CSV files in two tiers:
197
230
 
198
231
  | Tier | File | Row | Used for |
199
232
  | --- | --- | --- | --- |
package/index.js CHANGED
@@ -7,6 +7,10 @@ const inputs = require('./lib/inputs')
7
7
  const czone = require('./lib/czone')
8
8
  const nmea = require('./lib/nmea2000')
9
9
  const monitoring = require('./lib/monitoring')
10
+ const sensors = require('./lib/sensors')
11
+ const { createCandump, PGNS: CANDUMP_PGNS } = require('./lib/candump')
12
+ const { createSourcePicker } = require('./lib/sources')
13
+ const { powerHistory } = require('./lib/power-history')
10
14
  const { createTrends } = require('./lib/trends')
11
15
  const signalk = require('./lib/signalk')
12
16
  const confirmOffLib = require('./lib/confirm-off')
@@ -20,6 +24,14 @@ const CZONE_CONFIG_BLOCK_HEADER = 23
20
24
  const CZONE_CONFIG_BLOCK_SIZE = 200
21
25
  const CZONE_CONFIG_READ_TIMEOUT_MS = 45000
22
26
  const PLUGIN_ID = 'signalk-czone'
27
+ // A monitored value older than this is no longer live (NOT ON BUS).
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
31
+ // A circuit's temperature is dropped after this long without a reading.
32
+ const TEMPERATURE_STALE_MS = 300e3
33
+ const MONITOR_GROUPS = { battery: 'Batteries', solar: 'Solar', alternator: 'Alternators', converter: 'Converters', wind: 'Wind Generators' }
34
+ const MONITORED_PGNS = new Set(CANDUMP_PGNS)
23
35
  const NMEA_READY_RETRY_MS = 500
24
36
  const NMEA_READY_RETRY_LIMIT = 40
25
37
 
@@ -160,6 +172,10 @@ module.exports = function (app) {
160
172
  const switchPane = createVictronSwitches(app, {
161
173
  log: m => log(m),
162
174
  version: require('./package.json').version,
175
+ getTemperature: slug => {
176
+ const t = circuitTemperature(slug)
177
+ return t ? t.kelvin : null
178
+ },
163
179
  getCurrent: skPath => {
164
180
  const m = /^electrical\.czone\.([^.]+)\.current$/.exec(skPath || '')
165
181
  const c = m && mapping && mapping.circuits.find(x => x.slug === m[1])
@@ -191,6 +207,17 @@ module.exports = function (app) {
191
207
  })
192
208
  })
193
209
  let monitoringRuntime = null
210
+ // Tank, temperature and pressure senders (lib/sensors), the latest value
211
+ // and time of every monitored path, and when each meter or sender was last
212
+ // heard from.
213
+ let sensorRuntime = null
214
+ let monitorValues = new Map()
215
+ let circuitTemps = new Map()
216
+ let monitorFastPacket = null
217
+ let candump = null
218
+ let jsonOutListener = null
219
+ // Which device each meter and sender is read from (lib/sources).
220
+ const sourcePicker = createSourcePicker()
194
221
  // Trend recording of circuit current (see lib/trends).
195
222
  let trends = null
196
223
  // Server-side change cache for circuit state/brightness notifications.
@@ -264,7 +291,17 @@ module.exports = function (app) {
264
291
  mapping = zcf.load(zcfPath())
265
292
  mapping.commandDeviceId = selectCommandDeviceId(mapping)
266
293
  publishedCircuitValues.clear()
267
- monitoringRuntime = monitoring.createRuntime(mapping.meters || [])
294
+ // The parser gives { meters: [...] } with the table's details; older
295
+ // versions gave the list itself.
296
+ const meterList = Array.isArray(mapping.meters) ? mapping.meters : ((mapping.meters && mapping.meters.meters) || [])
297
+ monitoringRuntime = monitoring.createRuntime(meterList)
298
+ // Tank, temperature and pressure senders, the Monitoring tab and a
299
+ // circuit's temperature only when "Monitoring" is ticked (off by default).
300
+ // The meters are published either way.
301
+ const senderList = settings.monitoringEnabled === true ? sensors.senders(mapping, new Set(monitoringRuntime.meters.map(m => m.slug))) : []
302
+ sensorRuntime = sensors.createSensorRuntime(senderList)
303
+ circuitTemps = sensors.circuitTemperatures(mapping.circuits, sensorRuntime.senders)
304
+ monitorValues = new Map()
268
305
  switchInputs = inputs.switchInputs(mapping)
269
306
  inputState = new Map(switchInputs.map(i => [i.path, { state: null, since: null }]))
270
307
  runtimeState = new Map(mapping.circuits.map(c => [c.name, {
@@ -920,17 +957,126 @@ module.exports = function (app) {
920
957
 
921
958
  function publishMonitoringDelta (meter, pathName, value, source) {
922
959
  if (!monitoringRuntime || !Number.isFinite(value)) return
960
+ // Kept for the Monitoring tab (live or not) and handed to the trends
961
+ // before the change check: a steady reading is still a live one.
962
+ monitorValues.set(pathName, { value, at: Date.now() })
963
+ if (trends && !/\.capacity$/.test(pathName)) trends.observe(pathName, value)
923
964
  if (!monitoringRuntime.publishable(pathName, value)) return
924
965
  const delta = signalk.delta(pathName, value, source)
925
966
  if (typeof app.handleMessage === 'function') app.handleMessage(PLUGIN_ID, delta)
926
967
  else if (typeof app.emit === 'function') app.emit('delta', delta)
927
968
  }
928
969
 
929
- function handleMonitoringFrame (frame) {
930
- if (!monitoringRuntime) return
970
+ // Monitoring frames come from Signal K (canboatjs:rawoutput) or, on a GX,
971
+ // from candump. While candump delivers, it has every one of them, so the
972
+ // copies from Signal K are left alone.
973
+ function handleMonitoringFrame (frame, fromCandump = false) {
974
+ if (!monitoringRuntime || !MONITORED_PGNS.has(frame.pgn)) return
975
+ if (!fromCandump && candump && candump.delivering()) return
976
+ if (monitoring.FAST_PACKET_PGNS.has(frame.pgn)) {
977
+ if (monitorFastPacket) monitorFastPacket.accept(frame)
978
+ return
979
+ }
980
+ observeMeters(frame)
981
+ observeSensors(sensors.decodeSensorFrame(frame.pgn, frame.data), frame.source, frame.pgn)
982
+ }
983
+
984
+ // `device` is the sender's NMEA 2000 address, or 'signalk' for a reading
985
+ // Signal K itself sends.
986
+ function observeSensors (readings, device, pgn) {
987
+ if (!sensorRuntime || !readings.length) return
988
+ const source = { label: 'NMEA2000', type: 'NMEA2000', src: String(device), pgn }
989
+ for (const r of sensorRuntime.accept(readings, device, Date.now(), sourcePicker.allowed)) publishMonitoringDelta(null, r.path, r.value, source)
990
+ }
991
+
992
+ // A reading Signal K itself sends onto NMEA 2000 (the "Signal K to NMEA
993
+ // 2000" plugin, e.g. a Ruuvi tag bridged by a Bluetooth plugin).
994
+ function handleJsonOut (message) {
995
+ if (candump && candump.delivering()) return // candump sees it on the wire
996
+ observeSensors(sensors.decodeSensorJson(message), 'signalk', message && Number(message.pgn))
997
+ }
998
+
999
+ // On a Victron GX only (Venus OS ships candump), and only with senders or
1000
+ // meters to read. `app._czoneSpawn` lets the tests stand in for candump.
1001
+ function startCandump () {
1002
+ const onVenus = typeof app._czoneSpawn === 'function' || fs.existsSync('/opt/victronenergy')
1003
+ const wanted = monitoringRuntime && (monitoringRuntime.meters.length || sensorRuntime.senders.length)
1004
+ if (settings.monitoringEnabled !== true || !onVenus || !wanted || settings.monitorCandump === false) return
1005
+ candump = createCandump({
1006
+ device: settings.monitorCanDevice || 'vecan0',
1007
+ log,
1008
+ spawnFn: typeof app._czoneSpawn === 'function' ? app._czoneSpawn : undefined,
1009
+ onLine: line => {
1010
+ const frame = nmea.parseRawLine(line)
1011
+ if (frame) handleMonitoringFrame(frame, true)
1012
+ }
1013
+ })
1014
+ candump.start()
1015
+ }
1016
+
1017
+ // The temperature paired with a circuit (lib/sensors circuitTemperatures),
1018
+ // while it is fresh.
1019
+ function circuitTemperature (slug) {
1020
+ const s = circuitTemps.get(slug)
1021
+ const v = s && monitorValues.get(s.signalK.temperature)
1022
+ if (!v || Date.now() - v.at > TEMPERATURE_STALE_MS) return null
1023
+ return { kelvin: v.value, path: s.signalK.temperature, name: s.name }
1024
+ }
1025
+
1026
+ // Everything the Monitoring tab shows: meters, senders and switch inputs,
1027
+ // grouped, each with its readings and whether it is live.
1028
+ function monitoringLive () {
1029
+ if (settings.monitoringEnabled !== true) return { enabled: false, file: mapping ? mapping.fileName : null, trendsEnabled: settings.trendsEnabled === true, candump: null, items: [], temperatures: {} }
1030
+ const now = Date.now()
1031
+ const reading = (path, field) => {
1032
+ const v = monitorValues.get(path)
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 }
1034
+ }
1035
+ const item = (group, kind, x, fields) => {
1036
+ const readings = fields.filter(f => x.signalK[f]).map(f => reading(x.signalK[f], f))
1037
+ return { group, kind, name: x.name, slug: x.slug, instance: x.instance, thirdParty: x.thirdParty === true || x.virtual === true, live: readings.some(r => r.live), readings: readings.filter(r => r.value !== null) }
1038
+ }
1039
+ const items = []
1040
+ for (const m of monitoringRuntime ? monitoringRuntime.meters : []) {
1041
+ if (m.type === 'AC') items.push(item('AC Power', 'ac', m, ['voltage', 'current', 'power', 'frequency']))
1042
+ else items.push(item(MONITOR_GROUPS[m.dcType] || 'DC', 'dc', m, ['voltage', 'current', 'power', 'stateOfCharge', 'temperature']))
1043
+ }
1044
+ for (const s of sensorRuntime ? sensorRuntime.senders : []) {
1045
+ const group = s.kind === 'tank' ? 'Tanks' : s.kind === 'temperature' ? 'Temperatures' : 'Environment'
1046
+ const x = item(group, s.kind, s, Object.keys(s.signalK))
1047
+ x.source = s.source // temperature or pressure source, tank fluid type
1048
+ if (s.kind === 'tank' && s.capacity && !x.readings.some(r => r.field === 'capacity')) x.capacity = s.capacity
1049
+ items.push(x)
1050
+ }
1051
+ for (const i of switchInputs) {
1052
+ const st = inputState.get(i.path) || {}
1053
+ items.push({ group: 'Inputs', kind: 'input', name: i.name, slug: i.slug, path: i.path, decoded: i.decoded, live: st.state !== null && st.state !== undefined, state: st.state === undefined ? null : st.state, since: st.since || null, readings: [] })
1054
+ }
1055
+ const temperatures = {}
1056
+ for (const slug of circuitTemps.keys()) {
1057
+ const t = circuitTemperature(slug)
1058
+ if (t) temperatures[slug] = t
1059
+ }
1060
+ return {
1061
+ enabled: true,
1062
+ file: mapping ? mapping.fileName : null,
1063
+ trendsEnabled: settings.trendsEnabled === true,
1064
+ candump: candump ? candump.status() : null,
1065
+ items,
1066
+ temperatures
1067
+ }
1068
+ }
1069
+
1070
+ function observeMeters (frame) {
1071
+ // What each device says it is, for choosing between devices on one instance.
1072
+ if (frame.pgn === monitoring.PGN_DC_DETAILED && frame.data && frame.data.length >= 4) {
1073
+ sourcePicker.noteDcDetailed(frame.data[1], frame.source, frame.data[2], frame.data[3] <= 100)
1074
+ }
931
1075
  const observations = monitoringRuntime.observe(frame)
932
1076
  for (const observation of observations) {
933
1077
  const meter = observation.meter
1078
+ const pick = { key: `meter:${meter.slug}`, module: meter.module, virtual: meter.virtual, instance: meter.instance, dcType: meter.type === 'DC' ? meter.dcType : null }
1079
+ if (!sourcePicker.allowed(pick, frame.source)) continue
934
1080
  for (const [field, value] of Object.entries(observation.values)) {
935
1081
  if (!Number.isFinite(value)) continue
936
1082
  const pathName = meter.signalK[field]
@@ -962,6 +1108,7 @@ module.exports = function (app) {
962
1108
  function handleRawFrame (line) {
963
1109
  const frame = nmea.parseRawLine(line)
964
1110
  if (!frame) return
1111
+ sourcePicker.learn(frame)
965
1112
  observeModeCommand(frame)
966
1113
  handleMonitoringFrame(frame)
967
1114
  if (frame.pgn === nmea.CZONE_DATABLOCK_PGN) {
@@ -1266,9 +1413,9 @@ module.exports = function (app) {
1266
1413
  },
1267
1414
  trendsEnabled: {
1268
1415
  type: 'boolean',
1269
- title: 'Record circuit current trends',
1416
+ title: 'Record trends',
1270
1417
  default: false,
1271
- description: 'Off by default. Stores each circuit\'s current every 10 seconds as plain CSV files, for the trend charts in the webapp. Not needed if the data already goes to a database such as InfluxDB. On a Victron GX trends go to an SD card or USB stick only, never to internal flash; without a writable card nothing is recorded.'
1418
+ description: 'Off by default. Stores each circuit\'s current and every monitored value (meters, tanks, temperatures, pressure) every 10 seconds as plain CSV files, for the trend charts in the webapp. Not needed if the data already goes to a database such as InfluxDB. On a Victron GX trends go to an SD card or USB stick only, never to internal flash; without a writable card nothing is recorded.'
1272
1419
  },
1273
1420
  trendDirectory: {
1274
1421
  type: 'string',
@@ -1354,6 +1501,24 @@ module.exports = function (app) {
1354
1501
  default: false,
1355
1502
  description: 'Victron GX only (Venus OS 3.60 or later). The circuits appear as switches on the GX touch screen, Remote Console and VRM, one card per CZone category. Switching there needs CZone read/write control enabled, and circuits set to confirm before turning off (on) are not turned off (on) from there unless other apps are allowed to.'
1356
1503
  },
1504
+ monitoringEnabled: {
1505
+ type: 'boolean',
1506
+ title: 'Monitoring',
1507
+ default: false,
1508
+ description: 'Off by default. Adds the Monitoring tab to the webapp: every meter, tank, temperature, humidity and pressure sender in the ZCF, live, with DC watts, and a circuit\'s temperature beside its name and in its switch pane label. Meter readings are published to Signal K either way.'
1509
+ },
1510
+ monitorCandump: {
1511
+ type: 'boolean',
1512
+ title: 'On a Victron GX, read monitoring messages straight off the CAN interface',
1513
+ default: true,
1514
+ description: 'Signal K does not pass on what the GX itself sends onto NMEA 2000, such as a Ruuvi tag or a GX tank input bridged by Venus OS. With this on, the plugin reads those messages (tank, temperature, humidity, pressure, battery and AC) with candump, as a CZone display sees them. Only on Venus OS.'
1515
+ },
1516
+ monitorCanDevice: {
1517
+ type: 'string',
1518
+ title: 'CAN interface for the above',
1519
+ default: 'vecan0',
1520
+ description: 'vecan0 is the GX\'s VE.Can / NMEA 2000 port.'
1521
+ },
1357
1522
  victronSwitchCurrent: {
1358
1523
  type: 'boolean',
1359
1524
  title: 'Show circuit current in the switch pane',
@@ -1440,9 +1605,15 @@ module.exports = function (app) {
1440
1605
  decodeCurrentPacket(packet)
1441
1606
  if (packet.pgn === CURRENT_PGN_DC) decodeDcStatePacket(packet)
1442
1607
  })
1608
+ monitorFastPacket = nmea.createFastPacketReassembler(packet => {
1609
+ if (monitoringRuntime) observeMeters({ pgn: packet.pgn, source: packet.source, data: packet.payload })
1610
+ }, { pgns: monitoring.FAST_PACKET_PGNS })
1443
1611
  rawListener = handleRawFrame
1444
1612
  app.on('canboatjs:rawoutput', rawListener)
1613
+ jsonOutListener = handleJsonOut
1614
+ app.on('nmea2000JsonOut', jsonOutListener)
1445
1615
  }
1616
+ startCandump()
1446
1617
 
1447
1618
  startupModeInferenceDone = false
1448
1619
  if (startupModeInferenceTimer) clearTimeout(startupModeInferenceTimer)
@@ -1472,6 +1643,16 @@ module.exports = function (app) {
1472
1643
  lastNmeaOutput = null
1473
1644
  if (rawListener && typeof app.removeListener === 'function') app.removeListener('canboatjs:rawoutput', rawListener)
1474
1645
  rawListener = null
1646
+ if (jsonOutListener && typeof app.removeListener === 'function') app.removeListener('nmea2000JsonOut', jsonOutListener)
1647
+ jsonOutListener = null
1648
+ if (candump) candump.stop()
1649
+ candump = null
1650
+ if (monitorFastPacket) monitorFastPacket.clear()
1651
+ monitorFastPacket = null
1652
+ sourcePicker.clear()
1653
+ sensorRuntime = null
1654
+ monitorValues = new Map()
1655
+ circuitTemps = new Map()
1475
1656
  if (reassembler) reassembler.clear()
1476
1657
  if (monitoringRuntime) monitoringRuntime.clear()
1477
1658
  monitoringRuntime = null
@@ -1532,6 +1713,8 @@ module.exports = function (app) {
1532
1713
  })
1533
1714
  })
1534
1715
 
1716
+ router.get('/monitoring/live', (_req, res) => res.json(monitoringLive()))
1717
+
1535
1718
  router.get('/monitoring/tanks', (_req, res) => {
1536
1719
  res.json({
1537
1720
  file: mapping ? mapping.fileName : null,
@@ -1547,7 +1730,13 @@ module.exports = function (app) {
1547
1730
  if (!trends) return res.json({ available: false, reason: 'not_started', data: [] })
1548
1731
  const q = req.query
1549
1732
  const custom = q.from !== undefined && q.to !== undefined
1550
- 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))
1551
1740
  })
1552
1741
 
1553
1742
  router.get('/trend/status', (_req, res) => {
package/lib/candump.js ADDED
@@ -0,0 +1,103 @@
1
+ 'use strict'
2
+
3
+ // Monitoring PGNs read straight off a GX's CAN interface with candump.
4
+ //
5
+ // Signal K hands plugins the frames it receives from other devices, but not
6
+ // the frames the GX itself transmits. A Ruuvi tag or other sensor that Venus
7
+ // OS sends onto NMEA 2000 (bench, 1 Oct 2026: Ruuvi and Victron temperature
8
+ // sensors sent by the Cerbo at address 0x65 as 130312 / 130316) is seen by
9
+ // CZone displays and by candump, but never reaches the plugin's
10
+ // canboatjs:rawoutput listener. candump sees the wire as a CZone display
11
+ // does. The kernel filter passes only the monitoring PGNs, a few frames a
12
+ // second.
13
+ //
14
+ // Only on Venus OS, which ships candump. Anywhere else this is not started.
15
+
16
+ const { spawn } = require('child_process')
17
+
18
+ // PGNs: 130312 130313 130314 130316 127505 127506 127508 127503 127504
19
+ // 127744-127749. <can_id>:<mask> on data page + PF + PS.
20
+ const PGNS = [0x1FD08, 0x1FD09, 0x1FD0A, 0x1FD0C, 0x1F211, 0x1F212, 0x1F214, 0x1F20F, 0x1F210, 0x1F300, 0x1F301, 0x1F302, 0x1F303, 0x1F304, 0x1F305]
21
+ const FILTERS = PGNS.map(pgn => `${(pgn << 8).toString(16).toUpperCase().padStart(8, '0')}:01FFFF00`)
22
+ const LINE = /\s([0-9A-Fa-f]{8})\s+\[(\d)\]\s+((?:[0-9A-Fa-f]{2}\s*)+)$/
23
+
24
+ // " vecan0 0DF21465 [8] 02 2F 05 E7 FF FF FF 00" -> the raw line format
25
+ // the plugin already reads ("<time> R <can id> <bytes>").
26
+ function parseCandumpLine (line) {
27
+ const m = LINE.exec(String(line).trimEnd())
28
+ if (!m) return null
29
+ return `0 R ${m[1].toUpperCase()} ${m[3].trim()}`
30
+ }
31
+
32
+ function createCandump ({ device = 'vecan0', onLine, log = () => {}, spawnFn = spawn } = {}) {
33
+ let child = null
34
+ let stopped = true
35
+ let restartTimer = null
36
+ let buffer = ''
37
+ const state = { running: false, device, lines: 0, lastLineAt: 0, error: null }
38
+
39
+ function launch () {
40
+ if (stopped) return
41
+ try {
42
+ child = spawnFn('candump', [`${device},${FILTERS.join(',')}`], { stdio: ['ignore', 'pipe', 'pipe'] })
43
+ } catch (err) {
44
+ state.running = false
45
+ state.error = err.message
46
+ return
47
+ }
48
+ state.error = null
49
+ child.on('spawn', () => { state.running = true })
50
+ child.stdout.on('data', chunk => {
51
+ buffer += chunk.toString()
52
+ let nl
53
+ while ((nl = buffer.indexOf('\n')) >= 0) {
54
+ const raw = parseCandumpLine(buffer.slice(0, nl))
55
+ buffer = buffer.slice(nl + 1)
56
+ if (!raw) continue
57
+ state.lines++
58
+ state.lastLineAt = Date.now()
59
+ try { onLine(raw) } catch (_) {}
60
+ }
61
+ })
62
+ child.stderr.on('data', d => { state.error = d.toString().trim().slice(0, 200) })
63
+ child.on('error', err => {
64
+ state.running = false
65
+ state.error = err.code === 'ENOENT' ? 'candump not installed' : err.message
66
+ child = null
67
+ if (err.code !== 'ENOENT') scheduleRestart()
68
+ })
69
+ child.on('exit', code => {
70
+ state.running = false
71
+ child = null
72
+ if (!stopped && state.error !== 'candump not installed') {
73
+ log(`candump on ${device} exited (${code}); restarting in 30 s`)
74
+ scheduleRestart()
75
+ }
76
+ })
77
+ }
78
+
79
+ function scheduleRestart () {
80
+ clearTimeout(restartTimer)
81
+ if (!stopped) restartTimer = setTimeout(launch, 30e3)
82
+ }
83
+
84
+ function start () { stopped = false; launch() }
85
+
86
+ function stop () {
87
+ stopped = true
88
+ clearTimeout(restartTimer)
89
+ // Never signal without a real pid: a failed spawn can leave pid 0, and
90
+ // kill(0) would hit the whole process group.
91
+ if (child && child.pid > 0) { try { child.kill() } catch (_) {} }
92
+ child = null
93
+ state.running = false
94
+ }
95
+
96
+ // Delivering frames now: running, and a frame within the last minute (a
97
+ // candump that started but sees nothing must not hide the other route).
98
+ const delivering = (now = Date.now()) => state.running && now - state.lastLineAt < 60e3
99
+
100
+ return { start, stop, delivering, status: () => ({ ...state }) }
101
+ }
102
+
103
+ module.exports = { createCandump, parseCandumpLine, FILTERS, PGNS }
package/lib/monitoring.js CHANGED
@@ -17,6 +17,25 @@ const DC_TYPE_NAMES = Object.freeze({
17
17
  4: 'wind'
18
18
  })
19
19
 
20
+ // The older AC messages, fast packets: instance, number of lines, then per
21
+ // line (18 bytes) line/flags, voltage u16 0.01 V, current u16 0.1 A,
22
+ // frequency u16 0.01 Hz, breaker u16, real power u32 1 W, reactive power u32,
23
+ // power factor. CZone and many chartplotters use these; Victron equipment
24
+ // sends 127744-127749. Both feed the same AC meter, by its instance.
25
+ const PGN_AC_INPUT_STATUS = 127503
26
+ const PGN_AC_OUTPUT_STATUS = 127504
27
+ // Reassembled from fast packets before they reach observe().
28
+ const FAST_PACKET_PGNS = new Set([PGN_DC_DETAILED, PGN_AC_INPUT_STATUS, PGN_AC_OUTPUT_STATUS])
29
+
30
+ // The top values of every NMEA 2000 field mean "no data" (not available, out
31
+ // of range, reserved). None of them is a reading.
32
+ const naU = (v, bits) => v >= 2 ** bits - 3
33
+ const naI = (v, bits) => v >= 2 ** (bits - 1) - 3
34
+ // Scaled without floating-point dust (1320 x 0.01 = 13.2, not 13.200000000000001).
35
+ const scaled = (v, scale) => Math.round(v * scale * 1e6) / 1e6
36
+ const u16 = (b, at, scale) => { const v = b.readUInt16LE(at); return naU(v, 16) ? null : scaled(v, scale) }
37
+ const i16 = (b, at, scale) => { const v = b.readInt16LE(at); return naI(v, 16) ? null : scaled(v, scale) }
38
+
20
39
  const AC_POWER_PGNS = new Set([PGN_AC_POWER_A, PGN_AC_POWER_B, PGN_AC_POWER_C])
21
40
  const AC_VOLTAGE_PGNS = new Set([PGN_AC_VOLTAGE_A, PGN_AC_VOLTAGE_B, PGN_AC_VOLTAGE_C])
22
41
 
@@ -29,9 +48,9 @@ function decodeDcDetailed (payload) {
29
48
  dcType: DC_TYPE_NAMES[payload[2]] || null,
30
49
  stateOfCharge: payload[3] <= 100 ? payload[3] / 100 : null,
31
50
  stateOfHealth: payload[4] <= 100 ? payload[4] / 100 : null,
32
- timeRemaining: payload.readUInt16LE(5) * 60,
33
- rippleVoltage: payload.readUInt16LE(7) * 0.01,
34
- ampHours: payload.readUInt16LE(9) * 0.1
51
+ timeRemaining: u16(payload, 5, 60),
52
+ rippleVoltage: u16(payload, 7, 0.01),
53
+ ampHours: u16(payload, 9, 0.1)
35
54
  }
36
55
  }
37
56
 
@@ -39,9 +58,9 @@ function decodeBatteryStatus (payload) {
39
58
  if (!Buffer.isBuffer(payload) || payload.length < 8) return null
40
59
  return {
41
60
  instance: payload[0],
42
- voltage: payload.readUInt16LE(1) * 0.01,
43
- current: payload.readInt16LE(3) * 0.1,
44
- temperature: payload.readUInt16LE(5) * 0.01,
61
+ voltage: u16(payload, 1, 0.01),
62
+ current: i16(payload, 3, 0.1),
63
+ temperature: u16(payload, 5, 0.01),
45
64
  sid: payload[7]
46
65
  }
47
66
  }
@@ -51,8 +70,8 @@ function decodeAcPower (payload, pgn) {
51
70
  return {
52
71
  sid: payload[0],
53
72
  connection: payload[1],
54
- current: payload.readUInt16LE(2) * 0.1,
55
- power: payload.readInt32LE(4),
73
+ current: u16(payload, 2, 0.1),
74
+ power: naI(payload.readInt32LE(4), 32) ? null : payload.readInt32LE(4),
56
75
  phase: pgn - PGN_AC_POWER_A
57
76
  }
58
77
  }
@@ -62,13 +81,26 @@ function decodeAcVoltage (payload, pgn) {
62
81
  return {
63
82
  sid: payload[0],
64
83
  connection: payload[1],
65
- voltage: payload.readUInt16LE(2) * 0.1,
66
- lineToLineVoltage: payload.readUInt16LE(4) * 0.1,
67
- frequency: payload.readUInt16LE(6) * 0.1,
84
+ voltage: u16(payload, 2, 0.1),
85
+ lineToLineVoltage: u16(payload, 4, 0.1),
86
+ frequency: u16(payload, 6, 0.1),
68
87
  phase: pgn - PGN_AC_VOLTAGE_A
69
88
  }
70
89
  }
71
90
 
91
+ // Line 1 of 127503 / 127504 (a reassembled packet).
92
+ function decodeAcStatus (payload) {
93
+ if (!Buffer.isBuffer(payload) || payload.length < 15 || payload[1] === 0 || payload[1] === 0xff) return null
94
+ const watts = payload.readUInt32LE(11)
95
+ return {
96
+ connection: payload[0],
97
+ voltage: u16(payload, 3, 0.01),
98
+ current: u16(payload, 5, 0.1),
99
+ frequency: u16(payload, 7, 0.01),
100
+ power: naU(watts, 32) ? null : watts
101
+ }
102
+ }
103
+
72
104
  function signalKPaths (meter) {
73
105
  const slug = meter.slug
74
106
  const base = `electrical.czone.monitoring.${slug}`
@@ -165,11 +197,17 @@ function createRuntime (meters) {
165
197
  decoded = decodeBatteryStatus(frame.data)
166
198
  if (!decoded) return []
167
199
  meters = selectDc(decoded)
200
+ // DC watts (solar, alternator, wind, battery): volts x amps of the
201
+ // same message, so the two always belong together.
202
+ const power = decoded.voltage !== null && decoded.current !== null
203
+ ? Math.round(decoded.voltage * decoded.current * 10) / 10
204
+ : null
168
205
  return meters.map(meter => ({
169
206
  meter,
170
207
  values: {
171
208
  voltage: decoded.voltage,
172
209
  current: decoded.current,
210
+ power,
173
211
  temperature: decoded.temperature
174
212
  },
175
213
  source: { label: 'NMEA2000', type: 'NMEA2000', src: String(frame.source), pgn: frame.pgn }
@@ -187,6 +225,17 @@ function createRuntime (meters) {
187
225
  }))
188
226
  }
189
227
 
228
+ if (frame.pgn === PGN_AC_INPUT_STATUS || frame.pgn === PGN_AC_OUTPUT_STATUS) {
229
+ decoded = decodeAcStatus(frame.data)
230
+ if (!decoded) return []
231
+ meters = acCandidates(decoded.connection)
232
+ return meters.map(meter => ({
233
+ meter,
234
+ values: { voltage: decoded.voltage, current: decoded.current, frequency: decoded.frequency, power: decoded.power },
235
+ source: { label: 'NMEA2000', type: 'NMEA2000', src: String(frame.source), pgn: frame.pgn }
236
+ }))
237
+ }
238
+
190
239
  if (AC_VOLTAGE_PGNS.has(frame.pgn)) {
191
240
  decoded = decodeAcVoltage(frame.data, frame.pgn)
192
241
  if (!decoded) return []
@@ -229,6 +278,10 @@ module.exports = {
229
278
  PGN_AC_VOLTAGE_A,
230
279
  PGN_AC_VOLTAGE_B,
231
280
  PGN_AC_VOLTAGE_C,
281
+ PGN_AC_INPUT_STATUS,
282
+ PGN_AC_OUTPUT_STATUS,
283
+ FAST_PACKET_PGNS,
284
+ decodeAcStatus,
232
285
  decodeDcDetailed,
233
286
  decodeBatteryStatus,
234
287
  decodeAcPower,
package/lib/nmea2000.js CHANGED
@@ -114,6 +114,8 @@ function isCzoneCommandFrame (frame) {
114
114
  function createFastPacketReassembler (onPacket, options = {}) {
115
115
  const packets = new Map()
116
116
  const timeoutMs = options.timeoutMs || 2000
117
+ // Which PGNs to reassemble: the CZone current tables unless given.
118
+ const wanted = options.pgns instanceof Set ? pgn => options.pgns.has(pgn) : isCzoneCurrentPgn
117
119
 
118
120
  function expire () {
119
121
  const now = Date.now()
@@ -121,7 +123,7 @@ function createFastPacketReassembler (onPacket, options = {}) {
121
123
  }
122
124
 
123
125
  function accept (frame) {
124
- if (!frame || !isCzoneCurrentPgn(frame.pgn) || !frame.data || frame.data.length < 2) return
126
+ if (!frame || !wanted(frame.pgn) || !frame.data || frame.data.length < 2) return
125
127
  expire()
126
128
  const control = frame.data[0]
127
129
  const packetId = control >>> 4