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 +11 -0
- package/README.md +35 -2
- package/index.js +195 -6
- package/lib/candump.js +103 -0
- package/lib/monitoring.js +64 -11
- package/lib/nmea2000.js +3 -1
- package/lib/power-history.js +66 -0
- package/lib/sensors.js +226 -0
- package/lib/sources.js +91 -0
- package/package.json +2 -2
- package/public/index.html +223 -27
- package/public/remoteEntry.js +13 -2
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
|
|
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
|
-
|
|
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
|
-
|
|
930
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|