signalk-czone 0.3.0-beta.17 → 0.3.0-beta.19

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.19
4
+
5
+ - Adds opt-in Monitoring for meters, tanks, temperatures, humidity, pressure and switch inputs, with expanded trends and circuit temperature chips (#36, thanks @superstar76).
6
+ - Fixes meter-table loading, fast-packet monitoring and unavailable-value handling; supports direct monitoring reads on Victron GX.
7
+ - Keeps Monitoring search within the tab and restores circuit search wording when returning to circuits (#37).
8
+
9
+ ## 0.3.0-beta.18
10
+
11
+ - Adds in-page Signal K sign-in for browser and installed home-screen apps, without leaving CZone (#35, thanks @superstar76).
12
+ - Remembers login across app restarts, preserves the form during refreshes, and shows clear login errors with the Admin UI login retained as a fallback.
13
+
3
14
  ## 0.3.0-beta.17
4
15
 
5
16
  - Prompts for Signal K login when CZone circuit requests are unauthorized, returning to the webapp after sign-in (#34).
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 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.
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.
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,9 @@ 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')
10
13
  const { createTrends } = require('./lib/trends')
11
14
  const signalk = require('./lib/signalk')
12
15
  const confirmOffLib = require('./lib/confirm-off')
@@ -20,6 +23,12 @@ const CZONE_CONFIG_BLOCK_HEADER = 23
20
23
  const CZONE_CONFIG_BLOCK_SIZE = 200
21
24
  const CZONE_CONFIG_READ_TIMEOUT_MS = 45000
22
25
  const PLUGIN_ID = 'signalk-czone'
26
+ // A monitored value older than this is no longer live (NOT ON BUS).
27
+ const MONITOR_LIVE_MS = 120e3
28
+ // A circuit's temperature is dropped after this long without a reading.
29
+ const TEMPERATURE_STALE_MS = 300e3
30
+ const MONITOR_GROUPS = { battery: 'Batteries', solar: 'Solar', alternator: 'Alternators', converter: 'Converters', wind: 'Wind Generators' }
31
+ const MONITORED_PGNS = new Set(CANDUMP_PGNS)
23
32
  const NMEA_READY_RETRY_MS = 500
24
33
  const NMEA_READY_RETRY_LIMIT = 40
25
34
 
@@ -160,6 +169,10 @@ module.exports = function (app) {
160
169
  const switchPane = createVictronSwitches(app, {
161
170
  log: m => log(m),
162
171
  version: require('./package.json').version,
172
+ getTemperature: slug => {
173
+ const t = circuitTemperature(slug)
174
+ return t ? t.kelvin : null
175
+ },
163
176
  getCurrent: skPath => {
164
177
  const m = /^electrical\.czone\.([^.]+)\.current$/.exec(skPath || '')
165
178
  const c = m && mapping && mapping.circuits.find(x => x.slug === m[1])
@@ -191,6 +204,17 @@ module.exports = function (app) {
191
204
  })
192
205
  })
193
206
  let monitoringRuntime = null
207
+ // Tank, temperature and pressure senders (lib/sensors), the latest value
208
+ // and time of every monitored path, and when each meter or sender was last
209
+ // heard from.
210
+ let sensorRuntime = null
211
+ let monitorValues = new Map()
212
+ let circuitTemps = new Map()
213
+ let monitorFastPacket = null
214
+ let candump = null
215
+ let jsonOutListener = null
216
+ // Which device each meter and sender is read from (lib/sources).
217
+ const sourcePicker = createSourcePicker()
194
218
  // Trend recording of circuit current (see lib/trends).
195
219
  let trends = null
196
220
  // Server-side change cache for circuit state/brightness notifications.
@@ -264,7 +288,17 @@ module.exports = function (app) {
264
288
  mapping = zcf.load(zcfPath())
265
289
  mapping.commandDeviceId = selectCommandDeviceId(mapping)
266
290
  publishedCircuitValues.clear()
267
- monitoringRuntime = monitoring.createRuntime(mapping.meters || [])
291
+ // The parser gives { meters: [...] } with the table's details; older
292
+ // versions gave the list itself.
293
+ const meterList = Array.isArray(mapping.meters) ? mapping.meters : ((mapping.meters && mapping.meters.meters) || [])
294
+ monitoringRuntime = monitoring.createRuntime(meterList)
295
+ // Tank, temperature and pressure senders, the Monitoring tab and a
296
+ // circuit's temperature only when "Monitoring" is ticked (off by default).
297
+ // The meters are published either way.
298
+ const senderList = settings.monitoringEnabled === true ? sensors.senders(mapping, new Set(monitoringRuntime.meters.map(m => m.slug))) : []
299
+ sensorRuntime = sensors.createSensorRuntime(senderList)
300
+ circuitTemps = sensors.circuitTemperatures(mapping.circuits, sensorRuntime.senders)
301
+ monitorValues = new Map()
268
302
  switchInputs = inputs.switchInputs(mapping)
269
303
  inputState = new Map(switchInputs.map(i => [i.path, { state: null, since: null }]))
270
304
  runtimeState = new Map(mapping.circuits.map(c => [c.name, {
@@ -920,17 +954,126 @@ module.exports = function (app) {
920
954
 
921
955
  function publishMonitoringDelta (meter, pathName, value, source) {
922
956
  if (!monitoringRuntime || !Number.isFinite(value)) return
957
+ // Kept for the Monitoring tab (live or not) and handed to the trends
958
+ // before the change check: a steady reading is still a live one.
959
+ monitorValues.set(pathName, { value, at: Date.now() })
960
+ if (trends && !/\.capacity$/.test(pathName)) trends.observe(pathName, value)
923
961
  if (!monitoringRuntime.publishable(pathName, value)) return
924
962
  const delta = signalk.delta(pathName, value, source)
925
963
  if (typeof app.handleMessage === 'function') app.handleMessage(PLUGIN_ID, delta)
926
964
  else if (typeof app.emit === 'function') app.emit('delta', delta)
927
965
  }
928
966
 
929
- function handleMonitoringFrame (frame) {
930
- if (!monitoringRuntime) return
967
+ // Monitoring frames come from Signal K (canboatjs:rawoutput) or, on a GX,
968
+ // from candump. While candump delivers, it has every one of them, so the
969
+ // copies from Signal K are left alone.
970
+ function handleMonitoringFrame (frame, fromCandump = false) {
971
+ if (!monitoringRuntime || !MONITORED_PGNS.has(frame.pgn)) return
972
+ if (!fromCandump && candump && candump.delivering()) return
973
+ if (monitoring.FAST_PACKET_PGNS.has(frame.pgn)) {
974
+ if (monitorFastPacket) monitorFastPacket.accept(frame)
975
+ return
976
+ }
977
+ observeMeters(frame)
978
+ observeSensors(sensors.decodeSensorFrame(frame.pgn, frame.data), frame.source, frame.pgn)
979
+ }
980
+
981
+ // `device` is the sender's NMEA 2000 address, or 'signalk' for a reading
982
+ // Signal K itself sends.
983
+ function observeSensors (readings, device, pgn) {
984
+ if (!sensorRuntime || !readings.length) return
985
+ const source = { label: 'NMEA2000', type: 'NMEA2000', src: String(device), pgn }
986
+ for (const r of sensorRuntime.accept(readings, device, Date.now(), sourcePicker.allowed)) publishMonitoringDelta(null, r.path, r.value, source)
987
+ }
988
+
989
+ // A reading Signal K itself sends onto NMEA 2000 (the "Signal K to NMEA
990
+ // 2000" plugin, e.g. a Ruuvi tag bridged by a Bluetooth plugin).
991
+ function handleJsonOut (message) {
992
+ if (candump && candump.delivering()) return // candump sees it on the wire
993
+ observeSensors(sensors.decodeSensorJson(message), 'signalk', message && Number(message.pgn))
994
+ }
995
+
996
+ // On a Victron GX only (Venus OS ships candump), and only with senders or
997
+ // meters to read. `app._czoneSpawn` lets the tests stand in for candump.
998
+ function startCandump () {
999
+ const onVenus = typeof app._czoneSpawn === 'function' || fs.existsSync('/opt/victronenergy')
1000
+ const wanted = monitoringRuntime && (monitoringRuntime.meters.length || sensorRuntime.senders.length)
1001
+ if (settings.monitoringEnabled !== true || !onVenus || !wanted || settings.monitorCandump === false) return
1002
+ candump = createCandump({
1003
+ device: settings.monitorCanDevice || 'vecan0',
1004
+ log,
1005
+ spawnFn: typeof app._czoneSpawn === 'function' ? app._czoneSpawn : undefined,
1006
+ onLine: line => {
1007
+ const frame = nmea.parseRawLine(line)
1008
+ if (frame) handleMonitoringFrame(frame, true)
1009
+ }
1010
+ })
1011
+ candump.start()
1012
+ }
1013
+
1014
+ // The temperature paired with a circuit (lib/sensors circuitTemperatures),
1015
+ // while it is fresh.
1016
+ function circuitTemperature (slug) {
1017
+ const s = circuitTemps.get(slug)
1018
+ const v = s && monitorValues.get(s.signalK.temperature)
1019
+ if (!v || Date.now() - v.at > TEMPERATURE_STALE_MS) return null
1020
+ return { kelvin: v.value, path: s.signalK.temperature, name: s.name }
1021
+ }
1022
+
1023
+ // Everything the Monitoring tab shows: meters, senders and switch inputs,
1024
+ // grouped, each with its readings and whether it is live.
1025
+ function monitoringLive () {
1026
+ if (settings.monitoringEnabled !== true) return { enabled: false, file: mapping ? mapping.fileName : null, trendsEnabled: settings.trendsEnabled === true, candump: null, items: [], temperatures: {} }
1027
+ const now = Date.now()
1028
+ const reading = (path, field) => {
1029
+ 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 }
1031
+ }
1032
+ const item = (group, kind, x, fields) => {
1033
+ const readings = fields.filter(f => x.signalK[f]).map(f => reading(x.signalK[f], f))
1034
+ 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) }
1035
+ }
1036
+ const items = []
1037
+ for (const m of monitoringRuntime ? monitoringRuntime.meters : []) {
1038
+ if (m.type === 'AC') items.push(item('AC Power', 'ac', m, ['voltage', 'current', 'power', 'frequency']))
1039
+ else items.push(item(MONITOR_GROUPS[m.dcType] || 'DC', 'dc', m, ['voltage', 'current', 'power', 'stateOfCharge', 'temperature']))
1040
+ }
1041
+ for (const s of sensorRuntime ? sensorRuntime.senders : []) {
1042
+ const group = s.kind === 'tank' ? 'Tanks' : s.kind === 'temperature' ? 'Temperatures' : 'Environment'
1043
+ const x = item(group, s.kind, s, Object.keys(s.signalK))
1044
+ x.source = s.source // temperature or pressure source, tank fluid type
1045
+ if (s.kind === 'tank' && s.capacity && !x.readings.some(r => r.field === 'capacity')) x.capacity = s.capacity
1046
+ items.push(x)
1047
+ }
1048
+ for (const i of switchInputs) {
1049
+ const st = inputState.get(i.path) || {}
1050
+ 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: [] })
1051
+ }
1052
+ const temperatures = {}
1053
+ for (const slug of circuitTemps.keys()) {
1054
+ const t = circuitTemperature(slug)
1055
+ if (t) temperatures[slug] = t
1056
+ }
1057
+ return {
1058
+ enabled: true,
1059
+ file: mapping ? mapping.fileName : null,
1060
+ trendsEnabled: settings.trendsEnabled === true,
1061
+ candump: candump ? candump.status() : null,
1062
+ items,
1063
+ temperatures
1064
+ }
1065
+ }
1066
+
1067
+ function observeMeters (frame) {
1068
+ // What each device says it is, for choosing between devices on one instance.
1069
+ if (frame.pgn === monitoring.PGN_DC_DETAILED && frame.data && frame.data.length >= 4) {
1070
+ sourcePicker.noteDcDetailed(frame.data[1], frame.source, frame.data[2], frame.data[3] <= 100)
1071
+ }
931
1072
  const observations = monitoringRuntime.observe(frame)
932
1073
  for (const observation of observations) {
933
1074
  const meter = observation.meter
1075
+ const pick = { key: `meter:${meter.slug}`, module: meter.module, virtual: meter.virtual, instance: meter.instance, dcType: meter.type === 'DC' ? meter.dcType : null }
1076
+ if (!sourcePicker.allowed(pick, frame.source)) continue
934
1077
  for (const [field, value] of Object.entries(observation.values)) {
935
1078
  if (!Number.isFinite(value)) continue
936
1079
  const pathName = meter.signalK[field]
@@ -962,6 +1105,7 @@ module.exports = function (app) {
962
1105
  function handleRawFrame (line) {
963
1106
  const frame = nmea.parseRawLine(line)
964
1107
  if (!frame) return
1108
+ sourcePicker.learn(frame)
965
1109
  observeModeCommand(frame)
966
1110
  handleMonitoringFrame(frame)
967
1111
  if (frame.pgn === nmea.CZONE_DATABLOCK_PGN) {
@@ -1266,9 +1410,9 @@ module.exports = function (app) {
1266
1410
  },
1267
1411
  trendsEnabled: {
1268
1412
  type: 'boolean',
1269
- title: 'Record circuit current trends',
1413
+ title: 'Record trends',
1270
1414
  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.'
1415
+ 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
1416
  },
1273
1417
  trendDirectory: {
1274
1418
  type: 'string',
@@ -1354,6 +1498,24 @@ module.exports = function (app) {
1354
1498
  default: false,
1355
1499
  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
1500
  },
1501
+ monitoringEnabled: {
1502
+ type: 'boolean',
1503
+ title: 'Monitoring',
1504
+ default: false,
1505
+ 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.'
1506
+ },
1507
+ monitorCandump: {
1508
+ type: 'boolean',
1509
+ title: 'On a Victron GX, read monitoring messages straight off the CAN interface',
1510
+ default: true,
1511
+ 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.'
1512
+ },
1513
+ monitorCanDevice: {
1514
+ type: 'string',
1515
+ title: 'CAN interface for the above',
1516
+ default: 'vecan0',
1517
+ description: 'vecan0 is the GX\'s VE.Can / NMEA 2000 port.'
1518
+ },
1357
1519
  victronSwitchCurrent: {
1358
1520
  type: 'boolean',
1359
1521
  title: 'Show circuit current in the switch pane',
@@ -1440,9 +1602,15 @@ module.exports = function (app) {
1440
1602
  decodeCurrentPacket(packet)
1441
1603
  if (packet.pgn === CURRENT_PGN_DC) decodeDcStatePacket(packet)
1442
1604
  })
1605
+ monitorFastPacket = nmea.createFastPacketReassembler(packet => {
1606
+ if (monitoringRuntime) observeMeters({ pgn: packet.pgn, source: packet.source, data: packet.payload })
1607
+ }, { pgns: monitoring.FAST_PACKET_PGNS })
1443
1608
  rawListener = handleRawFrame
1444
1609
  app.on('canboatjs:rawoutput', rawListener)
1610
+ jsonOutListener = handleJsonOut
1611
+ app.on('nmea2000JsonOut', jsonOutListener)
1445
1612
  }
1613
+ startCandump()
1446
1614
 
1447
1615
  startupModeInferenceDone = false
1448
1616
  if (startupModeInferenceTimer) clearTimeout(startupModeInferenceTimer)
@@ -1472,6 +1640,16 @@ module.exports = function (app) {
1472
1640
  lastNmeaOutput = null
1473
1641
  if (rawListener && typeof app.removeListener === 'function') app.removeListener('canboatjs:rawoutput', rawListener)
1474
1642
  rawListener = null
1643
+ if (jsonOutListener && typeof app.removeListener === 'function') app.removeListener('nmea2000JsonOut', jsonOutListener)
1644
+ jsonOutListener = null
1645
+ if (candump) candump.stop()
1646
+ candump = null
1647
+ if (monitorFastPacket) monitorFastPacket.clear()
1648
+ monitorFastPacket = null
1649
+ sourcePicker.clear()
1650
+ sensorRuntime = null
1651
+ monitorValues = new Map()
1652
+ circuitTemps = new Map()
1475
1653
  if (reassembler) reassembler.clear()
1476
1654
  if (monitoringRuntime) monitoringRuntime.clear()
1477
1655
  monitoringRuntime = null
@@ -1532,6 +1710,8 @@ module.exports = function (app) {
1532
1710
  })
1533
1711
  })
1534
1712
 
1713
+ router.get('/monitoring/live', (_req, res) => res.json(monitoringLive()))
1714
+
1535
1715
  router.get('/monitoring/tanks', (_req, res) => {
1536
1716
  res.json({
1537
1717
  file: mapping ? mapping.fileName : null,
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