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 +11 -0
- package/README.md +35 -2
- package/index.js +185 -5
- package/lib/candump.js +103 -0
- package/lib/monitoring.js +64 -11
- package/lib/nmea2000.js +3 -1
- package/lib/sensors.js +226 -0
- package/lib/sources.js +91 -0
- package/package.json +2 -2
- package/public/index.html +252 -29
- package/public/remoteEntry.js +13 -2
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
|
|
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
|
-
|
|
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
|
-
|
|
930
|
-
|
|
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
|
|
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
|
|
33
|
-
rippleVoltage: payload
|
|
34
|
-
ampHours: payload
|
|
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
|
|
43
|
-
current: payload
|
|
44
|
-
temperature: payload
|
|
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
|
|
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
|
|
66
|
-
lineToLineVoltage: payload
|
|
67
|
-
frequency: payload
|
|
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 || !
|
|
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
|