signalk-czone 0.3.0-beta.15 → 0.3.0-beta.17
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 +13 -1
- package/index.js +56 -0
- package/lib/victron/switches.js +635 -0
- package/lib/victron/vedbus.js +182 -0
- package/package.json +2 -2
- package/public/index.html +42 -4
- package/public/remoteEntry.js +35 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0-beta.17
|
|
4
|
+
|
|
5
|
+
- Prompts for Signal K login when CZone circuit requests are unauthorized, returning to the webapp after sign-in (#34).
|
|
6
|
+
- Shows permission and circuit-loading errors with a retry option instead of waiting indefinitely for Signal K.
|
|
7
|
+
|
|
8
|
+
## 0.3.0-beta.16
|
|
9
|
+
|
|
10
|
+
- Adds an opt-in Victron GX switch pane for CZone circuits on the GX, Remote Console and VRM (#33).
|
|
11
|
+
- Preserves write-control and confirmation rules, synchronises observed state, and keeps GX names and grouping settings.
|
|
12
|
+
- Resets on circuits when changing to momentary mode; refuses blocked changes and restores the previous type if CZone does not confirm OFF.
|
|
13
|
+
|
|
3
14
|
## 0.3.0-beta.15
|
|
4
15
|
|
|
5
16
|
- Preserves plugin settings when uploading a ZCF or selecting a network configuration (#32).
|
package/README.md
CHANGED
|
@@ -235,6 +235,18 @@ The switch follows the CZone circuit, and the circuit follows the switch when it
|
|
|
235
235
|
|
|
236
236
|
The switch is read every 2 s; a CZone change is acted on as its status message arrives. `GET /plugins/signalk-czone/bridge/status` shows each link; `/bridge/outputs` lists the GX's outputs; `/bridge/shelly?host=&channel=` asks a Shelly. Tested on a bench (Shelly PM1 both ways) and on a boat (GX relay 2 switched from a CZone display; the alarm relay refused).
|
|
237
237
|
|
|
238
|
+
## Victron switch pane
|
|
239
|
+
|
|
240
|
+
On a Victron GX (Venus OS 3.60 or later), tick **Show CZone circuits in the Victron switch pane** in the configuration panel and the circuits appear as switches on the GX touch screen, Remote Console and VRM. The plugin registers `com.victronenergy.switch.czone` on the GX's D-Bus, following Victron's switchable-output API.
|
|
241
|
+
|
|
242
|
+
- **Which circuits:** those the webapp lists, one card per CZone category (the first sub-category, in a fixed priority, when there are several; DC or AC when there are none). A circuit linked to a third-party switch is left out, since that switch is already in the pane.
|
|
243
|
+
- **Both ways:** a tap in the pane switches the circuit with the same code as a Signal K PUT, so it needs **Enable CZone read/write control**, and a circuit set to confirm before turning off (on) is not turned off (on) from the pane unless other apps are allowed to; the pane's switch goes back. A change made anywhere else shows in the pane, from the plugin's own decoded 65284 state, checked every two seconds.
|
|
244
|
+
- **Label:** the circuit name, plus the current while it is on ("Freezer (2.9 A)"; untick **Show circuit current** to leave it off). A reading that only wobbles does not change the label, since every rename makes the GX re-sort its lists. Names, groups, types and the device's name can be edited on the GX and are kept (`victron-switches.json` in the plugin's data folder).
|
|
245
|
+
- **Device list:** Settings > Devices lists each switch as "CZone: name".
|
|
246
|
+
- `GET /plugins/signalk-czone/victron/status` reports whether it is running, which circuits the pane has as on, any that disagree with the plugin, and the last few requests from the pane.
|
|
247
|
+
|
|
248
|
+
Off a GX, or without `dbus-native` (an optional dependency), it reports that D-Bus is not available and the rest of the plugin is unaffected.
|
|
249
|
+
|
|
238
250
|
## Installation
|
|
239
251
|
|
|
240
252
|
The plugin installs the pinned `signalk-czone-zcf@0.1.0-beta.2` npm dependency automatically. No separate parser installation or Git checkout is required.
|
|
@@ -261,7 +273,7 @@ npm install signalk-czone@latest --omit=dev --ignore-scripts
|
|
|
261
273
|
|
|
262
274
|
Restart Signal K and follow the configuration steps above. For Docker or appliance installations, use the corresponding container/configuration directory and restart method.
|
|
263
275
|
|
|
264
|
-
For installation from a maintainer-provided npm tarball, run `npm install /path/to/signalk-czone-0.3.0-beta.
|
|
276
|
+
For installation from a maintainer-provided npm tarball, run `npm install /path/to/signalk-czone-0.3.0-beta.16.tgz --omit=dev --ignore-scripts` from the same configuration directory. Its pinned parser dependency must already be published to npm. Do not simply extract the plugin into `node_modules`: that does not install dependencies.
|
|
265
277
|
|
|
266
278
|
### Publishing (maintainers)
|
|
267
279
|
|
package/index.js
CHANGED
|
@@ -13,6 +13,7 @@ const confirmOffLib = require('./lib/confirm-off')
|
|
|
13
13
|
const { createBridge, bridgeEntries } = require('./lib/bridge')
|
|
14
14
|
const { createShellyDriver } = require('./lib/bridge/shelly')
|
|
15
15
|
const { createVictronDriver } = require('./lib/bridge/victron')
|
|
16
|
+
const { createVictronSwitches } = require('./lib/victron/switches')
|
|
16
17
|
|
|
17
18
|
const MAX_UPLOAD_BYTES = 1024 * 1024
|
|
18
19
|
const CZONE_CONFIG_BLOCK_HEADER = 23
|
|
@@ -151,6 +152,44 @@ module.exports = function (app) {
|
|
|
151
152
|
},
|
|
152
153
|
drivers: bridgeDrivers
|
|
153
154
|
})
|
|
155
|
+
// Victron switch pane (lib/victron/switches): the CZone circuits as
|
|
156
|
+
// switches on the GX touch screen, Remote Console and VRM. Off unless
|
|
157
|
+
// ticked. A tap there switches the circuit through the same code as a
|
|
158
|
+
// Signal K PUT, so "Enable CZone read/write control" and the confirm-before
|
|
159
|
+
// rules apply; the state shown is the plugin's own decoded 65284 state.
|
|
160
|
+
const switchPane = createVictronSwitches(app, {
|
|
161
|
+
log: m => log(m),
|
|
162
|
+
version: require('./package.json').version,
|
|
163
|
+
getCurrent: skPath => {
|
|
164
|
+
const m = /^electrical\.czone\.([^.]+)\.current$/.exec(skPath || '')
|
|
165
|
+
const c = m && mapping && mapping.circuits.find(x => x.slug === m[1])
|
|
166
|
+
const st = c && runtimeState.get(c.name)
|
|
167
|
+
return st && Number.isFinite(st.current) ? st.current : null
|
|
168
|
+
},
|
|
169
|
+
controls: () => ({
|
|
170
|
+
state: (slug, on) => {
|
|
171
|
+
const c = circuitByName(slug)
|
|
172
|
+
if (on) refuseUnconfirmedOn(c)
|
|
173
|
+
else refuseUnconfirmedOff(c)
|
|
174
|
+
sendCircuitState(c, on)
|
|
175
|
+
const st = runtimeState.get(c.name)
|
|
176
|
+
if (st) st.lastRequested = on ? 'ON' : 'OFF'
|
|
177
|
+
},
|
|
178
|
+
brightness: (slug, normalized) => {
|
|
179
|
+
const c = circuitByName(slug)
|
|
180
|
+
const st = runtimeState.get(c.name)
|
|
181
|
+
if (normalized <= 0) refuseUnconfirmedOff(c)
|
|
182
|
+
else if (!st || st.state !== 'ON') refuseUnconfirmedOn(c)
|
|
183
|
+
sendCircuitBrightness(c, normalized)
|
|
184
|
+
if (st) st.lastRequestedPercent = Math.round(normalized * 100)
|
|
185
|
+
},
|
|
186
|
+
getState: slug => {
|
|
187
|
+
const c = mapping && mapping.circuits.find(x => x.slug === slug)
|
|
188
|
+
const st = c && runtimeState.get(c.name)
|
|
189
|
+
return st && st.statusObserved ? { state: st.state, percent: st.percent } : null
|
|
190
|
+
}
|
|
191
|
+
})
|
|
192
|
+
})
|
|
154
193
|
let monitoringRuntime = null
|
|
155
194
|
// Trend recording of circuit current (see lib/trends).
|
|
156
195
|
let trends = null
|
|
@@ -1309,6 +1348,18 @@ module.exports = function (app) {
|
|
|
1309
1348
|
default: false,
|
|
1310
1349
|
description: 'Other Signal K apps switch with a PUT and cannot ask "are you sure?". Unticked, an off from them is refused with a message; the circuit can still be turned off from the webapp or a CZone keypad.'
|
|
1311
1350
|
},
|
|
1351
|
+
victronSwitches: {
|
|
1352
|
+
type: 'boolean',
|
|
1353
|
+
title: 'Show CZone circuits in the Victron switch pane',
|
|
1354
|
+
default: false,
|
|
1355
|
+
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
|
+
},
|
|
1357
|
+
victronSwitchCurrent: {
|
|
1358
|
+
type: 'boolean',
|
|
1359
|
+
title: 'Show circuit current in the switch pane',
|
|
1360
|
+
default: true,
|
|
1361
|
+
description: 'Adds the current to a switch\'s label while the circuit is on, for example "Freezer (2.9 A)".'
|
|
1362
|
+
},
|
|
1312
1363
|
bridges: {
|
|
1313
1364
|
type: 'array',
|
|
1314
1365
|
title: 'Third-party switches',
|
|
@@ -1350,6 +1401,7 @@ module.exports = function (app) {
|
|
|
1350
1401
|
})
|
|
1351
1402
|
trends.start()
|
|
1352
1403
|
startBridge()
|
|
1404
|
+
switchPane.start(settings, mapping).catch(err => log(`Victron switch pane: ${err.message}`))
|
|
1353
1405
|
|
|
1354
1406
|
const ready = (reason = 'nmea2000OutAvailable event') => {
|
|
1355
1407
|
nmeaReady = true
|
|
@@ -1426,6 +1478,7 @@ module.exports = function (app) {
|
|
|
1426
1478
|
if (trends) trends.stop()
|
|
1427
1479
|
trends = null
|
|
1428
1480
|
bridge.stop()
|
|
1481
|
+
switchPane.stop()
|
|
1429
1482
|
closeBridgeBus()
|
|
1430
1483
|
reassembler = null
|
|
1431
1484
|
configFastPacket = null
|
|
@@ -1544,6 +1597,7 @@ module.exports = function (app) {
|
|
|
1544
1597
|
|
|
1545
1598
|
// Third-party switches: link status, the GX's outputs, a Shelly test.
|
|
1546
1599
|
router.get('/bridge/status', (_req, res) => res.json({ links: bridge.status() }))
|
|
1600
|
+
router.get('/victron/status', (_req, res) => res.json(switchPane.status()))
|
|
1547
1601
|
router.get('/bridge/outputs', (_req, res) => {
|
|
1548
1602
|
bridgeDrivers.victron.list()
|
|
1549
1603
|
.then(outputs => res.json({ available: true, outputs }))
|
|
@@ -1733,5 +1787,7 @@ module.exports = function (app) {
|
|
|
1733
1787
|
|
|
1734
1788
|
// For tests: run one bridge poll now instead of waiting for its timer.
|
|
1735
1789
|
Object.defineProperty(plugin, '_bridgePoll', { value: () => bridge.poll(), enumerable: false })
|
|
1790
|
+
// For tests: the Victron switch pane.
|
|
1791
|
+
Object.defineProperty(plugin, '_switchPane', { value: switchPane, enumerable: false })
|
|
1736
1792
|
return plugin
|
|
1737
1793
|
}
|
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// CZone circuits in the Venus OS switch pane (Venus OS 3.60+).
|
|
4
|
+
//
|
|
5
|
+
// Registers com.victronenergy.switch.czone on the GX's system D-Bus
|
|
6
|
+
// with one /SwitchableOutput/<slug>/… channel per ZCF circuit, following
|
|
7
|
+
// Victron's switch API (github.com/victronenergy/venus/wiki/dbus#switch):
|
|
8
|
+
// State (0/1), Status (0x00 off / 0x09 on), Name, Dimming (0-100, dimmers),
|
|
9
|
+
// Current (A, from the CZone output tables), Settings/Type, ValidTypes,
|
|
10
|
+
// Group (one card per CZone category), CustomName, ShowUIControl.
|
|
11
|
+
//
|
|
12
|
+
// Pane -> CZone: writes to State / Dimming are passed to this plugin's own
|
|
13
|
+
// Signal K PUT handlers (electrical.czone.<slug>.switch.state / .brightness),
|
|
14
|
+
// so the same safety interlock (Enable NMEA 2000 sending) applies.
|
|
15
|
+
// CZone -> pane: the plugin's Signal K deltas for those paths update State,
|
|
16
|
+
// Status and Dimming, so circuits switched from a display or wall switch
|
|
17
|
+
// follow in the pane.
|
|
18
|
+
//
|
|
19
|
+
// Settings changed in the pane (name, group, type, visibility) are kept in
|
|
20
|
+
// victron-switches.json in the plugin's data directory.
|
|
21
|
+
|
|
22
|
+
const fs = require('fs')
|
|
23
|
+
const path = require('path')
|
|
24
|
+
const { createVeService } = require('./vedbus')
|
|
25
|
+
|
|
26
|
+
// The card a circuit goes in when it has several sub-categories: the first of
|
|
27
|
+
// them in this order (a fridge that is also "Power" belongs with Refrigeration).
|
|
28
|
+
const GROUP_PRIORITY = [
|
|
29
|
+
'Indicators and Alarms', 'Navigation', 'Refrigeration', 'Bilge Pumps', 'Pumps', 'Lighting', 'Fans/Ventilation', 'Climate',
|
|
30
|
+
'Entertainment', 'Appliances', 'Electronics', 'Communications', 'Engine Management', 'Propulsion Management',
|
|
31
|
+
'Power', 'Vessel Management', 'Accessories', 'House/Habitat', 'Vessel Critical', '24-Hour Circuits', 'Other'
|
|
32
|
+
]
|
|
33
|
+
const rank = name => { const i = GROUP_PRIORITY.indexOf(name); return i < 0 ? GROUP_PRIORITY.length : i }
|
|
34
|
+
const primaryCategory = names => [...(names || [])].filter(Boolean).sort((a, b) => rank(a) - rank(b))[0] || null
|
|
35
|
+
|
|
36
|
+
const SERVICE = 'com.victronenergy.switch.czone'
|
|
37
|
+
const SETTINGS_ID = 'czone'
|
|
38
|
+
const TYPE = { momentary: 0, toggle: 1, dimmable: 2 }
|
|
39
|
+
const STATUS_ON = 0x09
|
|
40
|
+
const STATUS_OFF = 0x00
|
|
41
|
+
|
|
42
|
+
const clip32 = s => {
|
|
43
|
+
let out = String(s || '')
|
|
44
|
+
while (Buffer.byteLength(out, 'utf8') > 32) out = out.slice(0, -1)
|
|
45
|
+
return out
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// A channel's /Name. Venus OS lists a switch in Settings > Devices as
|
|
49
|
+
// "/Name: CustomName" (CustomName is the label the pane shows, and keeps each
|
|
50
|
+
// switch in its category card), so /Name is the same for every switch:
|
|
51
|
+
// "CZone: Freezer (13.3 °C)", sorted A-Z by circuit. The circuit name here
|
|
52
|
+
// showed it twice; a module.channel prefix meant nothing to most people.
|
|
53
|
+
const CHANNEL_NAME = 'CZone'
|
|
54
|
+
|
|
55
|
+
// What follows the name in a switch's label: its temperature, where a
|
|
56
|
+
// temperature input is named after the circuit, and its current while it is on:
|
|
57
|
+
// "Freezer (-8.2 °C, 2.9 A)" "Fridge (5.1 °C)" "Lights (1.9 A)"
|
|
58
|
+
// The GX lists switches alphabetically by this label and has no other order,
|
|
59
|
+
// so the suffix must not move a switch: " (" sorts ahead of any letter or
|
|
60
|
+
// digit, which keeps "Freezer (…)" before "Freezer Temp Control", where plain
|
|
61
|
+
// "Freezer" is. (The earlier "Freezer · 3.0 A" sorted after it, so a switch
|
|
62
|
+
// changed place when it turned on.)
|
|
63
|
+
const TEMP_PART = /^-?\d+(?:\.\d+)?\s*(?:°[CF]|K)$/
|
|
64
|
+
const AMPS_PART = /^\d+(?:\.\d+)?\s*A$/
|
|
65
|
+
// A name typed on the GX comes back with the suffix still on it: take it off.
|
|
66
|
+
// Brackets that are part of a real name ("Pump (aft)") are left alone.
|
|
67
|
+
function stripSuffix (label) {
|
|
68
|
+
const s = String(label || '').replace(/\s*·\s*[\d.]+\s*A$/, '')
|
|
69
|
+
const m = /\s*\(([^()]*)\)$/.exec(s)
|
|
70
|
+
if (!m) return s
|
|
71
|
+
const parts = m[1].split(',').map(p => p.trim())
|
|
72
|
+
return parts.every(p => TEMP_PART.test(p) || AMPS_PART.test(p)) ? s.slice(0, m.index) : s
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Kelvin (as on NMEA 2000 and in Signal K) to the GX's temperature unit.
|
|
76
|
+
// compact drops the decimal and the space, for a label that would not fit.
|
|
77
|
+
function temperatureText (kelvin, unit, compact) {
|
|
78
|
+
const v = unit === 'F' ? (kelvin - 273.15) * 9 / 5 + 32 : unit === 'K' ? kelvin : kelvin - 273.15
|
|
79
|
+
const sym = unit === 'K' ? 'K' : `°${unit === 'F' ? 'F' : 'C'}`
|
|
80
|
+
const n = compact ? String(Math.round(v)) : v.toFixed(1)
|
|
81
|
+
return `${n === '-0' ? '0' : n === '-0.0' ? '0.0' : n}${compact ? '' : ' '}${sym}`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// "celsius" / "fahrenheit" / "kelvin" from the GX's settings -> C, F or K.
|
|
85
|
+
const temperatureUnitFrom = raw => (/^\s*f/i.test(String(raw)) ? 'F' : /^\s*k/i.test(String(raw)) ? 'K' : 'C')
|
|
86
|
+
|
|
87
|
+
const LABEL_BYTES = 32
|
|
88
|
+
const bytes = s => Buffer.byteLength(s, 'utf8')
|
|
89
|
+
function labelWith (name, kelvin, amps, unit) {
|
|
90
|
+
let out = name
|
|
91
|
+
for (const compact of [false, true]) {
|
|
92
|
+
const parts = []
|
|
93
|
+
if (typeof kelvin === 'number') parts.push(temperatureText(kelvin, unit, compact))
|
|
94
|
+
if (typeof amps === 'number') parts.push(`${amps.toFixed(1)} A`)
|
|
95
|
+
const suffix = parts.length ? ` (${parts.join(', ')})` : ''
|
|
96
|
+
out = name + suffix
|
|
97
|
+
if (bytes(out) <= LABEL_BYTES) return out
|
|
98
|
+
if (compact) { // still too long: the name gives way
|
|
99
|
+
let n = name
|
|
100
|
+
while (n.length && bytes(n.trimEnd() + suffix) > LABEL_BYTES) n = n.slice(0, -1)
|
|
101
|
+
return n.trimEnd() + suffix
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return out
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// One card per circuit: the first of its sub-categories (and user-defined
|
|
108
|
+
// categories) in GROUP_PRIORITY order; one with none falls back to DC / AC.
|
|
109
|
+
function groupFor (circuit) {
|
|
110
|
+
const cats = circuit.categories || {}
|
|
111
|
+
const subs = [...(cats.sub || circuit.subCategories || []), ...(cats.userDefined || [])]
|
|
112
|
+
return clip32(circuit.group || primaryCategory(subs) || (cats.master || [])[0] || circuit.masterCategory || 'CZone')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function channelId (circuit) {
|
|
116
|
+
// D-Bus object path elements: [A-Za-z0-9_]
|
|
117
|
+
return String(circuit.slug || circuit.name).replace(/[^A-Za-z0-9_]/g, '_') || `c${circuit.zcfCircuitId}`
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let instances = 0 // pane services created in this process
|
|
121
|
+
|
|
122
|
+
function createVictronSwitches (app, { log = () => {}, getCurrent = () => null, getTemperature = () => null, controls = () => null, version = '0', now = () => Date.now() } = {}) {
|
|
123
|
+
let bus = null
|
|
124
|
+
let svc = null
|
|
125
|
+
let unsub = null
|
|
126
|
+
let currentTimer = null
|
|
127
|
+
let announceTimers = []
|
|
128
|
+
let status = { enabled: false, running: false, error: null, channels: 0, deviceInstance: null }
|
|
129
|
+
let channels = new Map() // slug -> { id, circuit }
|
|
130
|
+
let store = { outputs: {} }
|
|
131
|
+
let storeFile = null
|
|
132
|
+
let showCurrent = true // amps in the switch label while on (gui-v2 does not display /Current yet)
|
|
133
|
+
let showTemperature = true // and the circuit's temperature, where an input is named after it
|
|
134
|
+
let temperatureUnit = 'C' // the GX's own setting, read at start and every 30 s
|
|
135
|
+
let run = 0 // start/stop generation: a start that was overtaken must not carry on
|
|
136
|
+
const diag = { instance: ++instances, pid: process.pid, startedAt: null, seedRuns: 0, seedAt: null, seedFound: null, seedErrors: 0, lastError: null, owner: null, ownerCheckedAt: null, temperatureUnit: null }
|
|
137
|
+
const fail = (where, err) => { diag.seedErrors++; diag.lastError = `${where}: ${err && err.message ? err.message : err}` }
|
|
138
|
+
const recent = [] // last switch-pane requests, for /victron/status
|
|
139
|
+
const note = entry => { entry.at = new Date().toISOString(); recent.unshift(entry); recent.length = Math.min(recent.length, 10) }
|
|
140
|
+
|
|
141
|
+
function loadStore () {
|
|
142
|
+
try { store = JSON.parse(fs.readFileSync(storeFile, 'utf8')) } catch (_) { store = { outputs: {} } }
|
|
143
|
+
if (!store.outputs) store.outputs = {}
|
|
144
|
+
}
|
|
145
|
+
function saveStore () {
|
|
146
|
+
try { fs.writeFileSync(storeFile, JSON.stringify(store, null, 2)) } catch (err) { log(`victron-switches.json: ${err.message}`) }
|
|
147
|
+
}
|
|
148
|
+
function remember (id, key, value) {
|
|
149
|
+
store.outputs[id] = { ...(store.outputs[id] || {}), [key]: value }
|
|
150
|
+
saveStore()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Switch a circuit with the same functions the webapp's ON/OFF and slider
|
|
154
|
+
// use (direct, synchronous, errors reported). Signal K PUT is the fallback
|
|
155
|
+
// when the host plugin didn't hand its controls over.
|
|
156
|
+
function command (kind, slug, value) {
|
|
157
|
+
const c = controls()
|
|
158
|
+
if (c && typeof c[kind] === 'function') {
|
|
159
|
+
const rec = { circuit: slug, [kind]: value, via: 'plugin' }
|
|
160
|
+
note(rec)
|
|
161
|
+
try { c[kind](slug, value); rec.result = 'sent'; return true } catch (err) { rec.result = `refused: ${err.message}`; log(`${slug} ${kind}: ${err.message}`); return false }
|
|
162
|
+
}
|
|
163
|
+
return put(`electrical.czone.${slug}.switch.${kind === 'state' ? 'state' : 'brightness'}`, value)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function put (skPath, value) {
|
|
167
|
+
const rec = { path: skPath, value, result: 'sent', via: 'signalk-put' }
|
|
168
|
+
note(rec)
|
|
169
|
+
if (typeof app.putSelfPath !== 'function') { rec.result = 'app.putSelfPath unavailable'; log(rec.result); return false }
|
|
170
|
+
try {
|
|
171
|
+
const r = app.putSelfPath(skPath, value, reply => {
|
|
172
|
+
rec.reply = reply && { state: reply.state, statusCode: reply.statusCode, message: reply.message }
|
|
173
|
+
if (reply && reply.statusCode >= 300) log(`PUT ${skPath}=${value}: ${reply.statusCode} ${reply.message || ''}`)
|
|
174
|
+
})
|
|
175
|
+
if (r && typeof r.then === 'function') {
|
|
176
|
+
r.then(reply => { if (reply && !rec.reply) rec.reply = { state: reply.state, statusCode: reply.statusCode, message: reply.message } })
|
|
177
|
+
.catch(err => { rec.result = `error: ${err.message || err}` })
|
|
178
|
+
} else if (r && typeof r === 'object' && !rec.reply) {
|
|
179
|
+
rec.reply = { state: r.state, statusCode: r.statusCode, message: r.message }
|
|
180
|
+
}
|
|
181
|
+
return true
|
|
182
|
+
} catch (err) { rec.result = `error: ${err.message}`; log(`PUT ${skPath}: ${err.message}`); return false }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function invokeSettings (msg) {
|
|
186
|
+
return new Promise((resolve, reject) => bus.invoke({ destination: 'com.victronenergy.settings', ...msg }, (err, res) => err ? reject(new Error(Array.isArray(err) ? err.join(' ') : String(err.message || err))) : resolve(res)))
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Reserve a VRM device instance the Venus way (localsettings ClassAndVrmInstance).
|
|
190
|
+
async function deviceInstance () {
|
|
191
|
+
const p = `/Settings/Devices/${SETTINGS_ID}/ClassAndVrmInstance`
|
|
192
|
+
try {
|
|
193
|
+
await invokeSettings({ path: '/Settings', interface: 'com.victronenergy.Settings', member: 'AddSetting', signature: 'ssvsvv', body: [`Devices/${SETTINGS_ID}`, 'ClassAndVrmInstance', ['s', 'switch:100'], 's', ['i', 0], ['i', 0]] })
|
|
194
|
+
const v = await invokeSettings({ path: p, interface: 'com.victronenergy.BusItem', member: 'GetValue' })
|
|
195
|
+
const text = Array.isArray(v) ? v[1] && v[1][0] : v
|
|
196
|
+
const m = /:(\d+)$/.exec(String(text))
|
|
197
|
+
return m ? Number(m[1]) : 100
|
|
198
|
+
} catch (err) {
|
|
199
|
+
log(`Device instance from localsettings failed (${err.message}); using 100`)
|
|
200
|
+
return 100
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function addChannel (circuit) {
|
|
205
|
+
const id = channelId(circuit)
|
|
206
|
+
const base = `/SwitchableOutput/${id}`
|
|
207
|
+
const dimmer = !!(circuit.capabilities && circuit.capabilities.dimmer)
|
|
208
|
+
const saved = store.outputs[id] || {}
|
|
209
|
+
const validTypes = dimmer ? (1 << TYPE.dimmable) | (1 << TYPE.toggle) : (1 << TYPE.toggle) | (1 << TYPE.momentary)
|
|
210
|
+
const type = Number.isInteger(saved.type) && (validTypes & (1 << saved.type)) ? saved.type : (dimmer ? TYPE.dimmable : TYPE.toggle)
|
|
211
|
+
|
|
212
|
+
svc.add(`${base}/Name`, { type: 's', value: CHANNEL_NAME })
|
|
213
|
+
svc.add(`${base}/State`, {
|
|
214
|
+
type: 'i',
|
|
215
|
+
value: 0,
|
|
216
|
+
text: v => (v ? 'On' : 'Off'),
|
|
217
|
+
onSet: v => {
|
|
218
|
+
note({ dbus: `${base}/State`, raw: JSON.stringify(v) })
|
|
219
|
+
const on = Number(v) ? 1 : 0
|
|
220
|
+
if (!command('state', circuit.slug, on === 1)) {
|
|
221
|
+
// Refused (sending off, or a circuit set to confirm before off):
|
|
222
|
+
// say again what the state is, so the pane's switch goes back.
|
|
223
|
+
svc.announce([`${base}/State`, `${base}/Status`])
|
|
224
|
+
return false
|
|
225
|
+
}
|
|
226
|
+
svc.set(`${base}/State`, on) // optimistic; CZone feedback confirms
|
|
227
|
+
commanded(circuit.slug)
|
|
228
|
+
return true
|
|
229
|
+
}
|
|
230
|
+
})
|
|
231
|
+
svc.add(`${base}/Status`, { type: 'i', value: STATUS_OFF, text: v => (v & 0x01 ? 'On' : 'Off') })
|
|
232
|
+
if (dimmer) {
|
|
233
|
+
svc.add(`${base}/Dimming`, {
|
|
234
|
+
type: 'd',
|
|
235
|
+
value: null,
|
|
236
|
+
text: v => (v == null ? '' : `${Math.round(v)}%`),
|
|
237
|
+
onSet: v => {
|
|
238
|
+
const pct = Math.max(0, Math.min(100, Number(v)))
|
|
239
|
+
if (!Number.isFinite(pct) || !command('brightness', circuit.slug, pct / 100)) return false
|
|
240
|
+
svc.set(`${base}/Dimming`, pct)
|
|
241
|
+
commanded(circuit.slug)
|
|
242
|
+
return true
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
svc.add(`${base}/Current`, { type: 'd', value: null, text: v => (v == null ? '' : `${v.toFixed(1)}A`) })
|
|
247
|
+
svc.add(`${base}/Settings/Type`, {
|
|
248
|
+
type: 'i',
|
|
249
|
+
value: type,
|
|
250
|
+
onSet: v => {
|
|
251
|
+
const t = Number(v)
|
|
252
|
+
if (!(validTypes & (1 << t))) return false
|
|
253
|
+
// Victron's switch API: selecting momentary resets the output. An
|
|
254
|
+
// output left on would stay on until the next press. The off goes
|
|
255
|
+
// through the same checks as a tap (sending allowed, confirm before
|
|
256
|
+
// off); if it is refused, so is the type change.
|
|
257
|
+
if (t === TYPE.momentary && svc.get(`${base}/State`) === 1) {
|
|
258
|
+
if (!command('state', circuit.slug, false)) {
|
|
259
|
+
svc.announce([`${base}/State`, `${base}/Status`, `${base}/Settings/Type`])
|
|
260
|
+
return false
|
|
261
|
+
}
|
|
262
|
+
const from = svc.get(`${base}/Settings/Type`)
|
|
263
|
+
svc.set(`${base}/State`, 0)
|
|
264
|
+
svc.set(`${base}/Status`, STATUS_OFF)
|
|
265
|
+
commanded(circuit.slug)
|
|
266
|
+
// Until CZone reports the output off, the change is provisional:
|
|
267
|
+
// with no off within the grace period it is undone (see settleType).
|
|
268
|
+
const ch = channels.get(circuit.slug)
|
|
269
|
+
if (ch) ch.typePending = { from }
|
|
270
|
+
}
|
|
271
|
+
svc.set(`${base}/Settings/Type`, t); remember(id, 'type', t); return true
|
|
272
|
+
}
|
|
273
|
+
})
|
|
274
|
+
svc.add(`${base}/Settings/ValidTypes`, { type: 'i', value: validTypes })
|
|
275
|
+
svc.add(`${base}/Settings/Group`, {
|
|
276
|
+
type: 's',
|
|
277
|
+
value: saved.Group !== undefined ? saved.Group : groupFor(circuit),
|
|
278
|
+
onSet: v => { const s = clip32(v); svc.set(`${base}/Settings/Group`, s); remember(id, 'Group', s); return true }
|
|
279
|
+
})
|
|
280
|
+
// The label is the user's name (default: circuit name) plus the circuit
|
|
281
|
+
// current while it is on. A rename from the GUI keeps the name only.
|
|
282
|
+
svc.add(`${base}/Settings/CustomName`, {
|
|
283
|
+
type: 's',
|
|
284
|
+
value: clip32(saved.CustomName || circuit.name),
|
|
285
|
+
onSet: v => {
|
|
286
|
+
const name = stripSuffix(String(v || '')).trim() || circuit.name
|
|
287
|
+
const ch = channels.get(circuit.slug)
|
|
288
|
+
if (ch) ch.label = name
|
|
289
|
+
remember(id, 'CustomName', clip32(name))
|
|
290
|
+
if (ch) refreshLabel(ch); else svc.set(`${base}/Settings/CustomName`, clip32(name))
|
|
291
|
+
return true
|
|
292
|
+
}
|
|
293
|
+
})
|
|
294
|
+
svc.add(`${base}/Settings/ShowUIControl`, {
|
|
295
|
+
type: 'i',
|
|
296
|
+
value: Number.isInteger(saved.ShowUIControl) ? saved.ShowUIControl : 1,
|
|
297
|
+
onSet: v => { const n = Number(v) | 0; svc.set(`${base}/Settings/ShowUIControl`, n); remember(id, 'ShowUIControl', n); return true }
|
|
298
|
+
})
|
|
299
|
+
channels.set(circuit.slug, { id, base, circuit, dimmer, label: saved.CustomName || circuit.name })
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Signal K -> pane
|
|
303
|
+
function onDelta (update) {
|
|
304
|
+
if (!svc) return
|
|
305
|
+
if (!update || typeof update.path !== 'string' || !update.path.startsWith('electrical.czone.')) return
|
|
306
|
+
const m = /^electrical\.czone\.([^.]+)\.switch\.(state|brightness)$/.exec(update.path)
|
|
307
|
+
if (!m) return
|
|
308
|
+
const ch = channels.get(m[1])
|
|
309
|
+
if (!ch) return
|
|
310
|
+
const v = update.value && typeof update.value === 'object' && 'value' in update.value ? update.value.value : update.value
|
|
311
|
+
apply(ch, m[2], v)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function apply (ch, kind, v) {
|
|
315
|
+
if (kind === 'state') {
|
|
316
|
+
const on = v === true || v === 1 || v === 'on' || v === 'ON'
|
|
317
|
+
svc.set(`${ch.base}/State`, on ? 1 : 0)
|
|
318
|
+
svc.set(`${ch.base}/Status`, on ? STATUS_ON : STATUS_OFF)
|
|
319
|
+
if (!on) delete ch.typePending // CZone confirmed the off: momentary stands
|
|
320
|
+
refreshLabel(ch)
|
|
321
|
+
} else if (ch.dimmer && typeof v === 'number') {
|
|
322
|
+
svc.set(`${ch.base}/Dimming`, Math.round(v * 1000) / 10)
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// A pane tap shows at once; give CZone a few seconds to confirm before the
|
|
327
|
+
// pane is put back to what Signal K says.
|
|
328
|
+
const COMMAND_GRACE_MS = 5000
|
|
329
|
+
function commanded (slug) {
|
|
330
|
+
const ch = channels.get(slug)
|
|
331
|
+
if (ch) ch.commandAt = Date.now()
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Bring every switch in line with Signal K. Run at start and then every two
|
|
335
|
+
// seconds: the plugin only publishes a state when it changes, and the first
|
|
336
|
+
// CZone status can arrive before this service is listening, so a circuit
|
|
337
|
+
// that was already on (and stays on) would otherwise never show as on.
|
|
338
|
+
function seedFromSignalK () {
|
|
339
|
+
const now = Date.now()
|
|
340
|
+
const c = controls()
|
|
341
|
+
const fromPlugin = c && typeof c.getState === 'function' ? c.getState : null
|
|
342
|
+
const found = { plugin: 0, signalk: 0 }
|
|
343
|
+
diag.seedRuns++
|
|
344
|
+
diag.seedAt = new Date(now).toISOString()
|
|
345
|
+
for (const [slug, ch] of channels) {
|
|
346
|
+
if (ch.commandAt && now - ch.commandAt < COMMAND_GRACE_MS) continue
|
|
347
|
+
// One circuit failing must not stop the ones after it.
|
|
348
|
+
try {
|
|
349
|
+
// First choice: the state the host plugin decoded from the bus, the
|
|
350
|
+
// same one the webapp shows. It is there from the first status frame,
|
|
351
|
+
// whenever this service started listening.
|
|
352
|
+
const st = fromPlugin ? fromPlugin(slug) : null
|
|
353
|
+
if (st && (st.state === 'ON' || st.state === 'OFF')) {
|
|
354
|
+
found.plugin++
|
|
355
|
+
apply(ch, 'state', st.state === 'ON')
|
|
356
|
+
if (Number.isFinite(st.percent)) apply(ch, 'brightness', st.percent / 100)
|
|
357
|
+
continue
|
|
358
|
+
}
|
|
359
|
+
if (typeof app.getSelfPath !== 'function') continue
|
|
360
|
+
for (const kind of ['state', 'brightness']) {
|
|
361
|
+
const node = app.getSelfPath(`electrical.czone.${slug}.switch.${kind}`)
|
|
362
|
+
if (node === undefined || node === null) continue
|
|
363
|
+
if (kind === 'state') found.signalk++
|
|
364
|
+
onDelta({ path: `electrical.czone.${slug}.switch.${kind}`, value: node })
|
|
365
|
+
}
|
|
366
|
+
} catch (err) {
|
|
367
|
+
fail(`resync ${slug}`, err)
|
|
368
|
+
} finally {
|
|
369
|
+
// Here, not after the try: both state readers end with `continue`.
|
|
370
|
+
settleType(ch)
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
diag.seedFound = found
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// A switch set to momentary while on was sent off. If the grace period has
|
|
377
|
+
// passed and CZone never reported it off, the output is still on: put the
|
|
378
|
+
// previous type back rather than leave an on output marked momentary.
|
|
379
|
+
function settleType (ch) {
|
|
380
|
+
if (!ch.typePending) return
|
|
381
|
+
const { from } = ch.typePending
|
|
382
|
+
delete ch.typePending
|
|
383
|
+
svc.set(`${ch.base}/Settings/Type`, from)
|
|
384
|
+
remember(ch.id, 'type', from)
|
|
385
|
+
const why = `${ch.circuit.name}: CZone did not confirm the off, so momentary was not applied`
|
|
386
|
+
note({ dbus: `${ch.base}/Settings/Type`, refused: why })
|
|
387
|
+
log(`Victron switch pane: ${why}`)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// What the host plugin has decoded for a circuit (null = nothing yet).
|
|
391
|
+
function pluginState (slug) {
|
|
392
|
+
const c = controls()
|
|
393
|
+
let st = null
|
|
394
|
+
try { st = c && typeof c.getState === 'function' ? c.getState(slug) : null } catch (_) {}
|
|
395
|
+
return st && (st.state === 'ON' || st.state === 'OFF') ? (st.state === 'ON' ? 1 : 0) : null
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// What Signal K says a circuit is, as the pane would apply it (null = no value).
|
|
399
|
+
function signalkState (slug) {
|
|
400
|
+
let node
|
|
401
|
+
try { node = app.getSelfPath(`electrical.czone.${slug}.switch.state`) } catch (_) { return null }
|
|
402
|
+
if (node === undefined || node === null) return null
|
|
403
|
+
const v = node && typeof node === 'object' && 'value' in node ? node.value : node
|
|
404
|
+
return v === true || v === 1 || v === 'on' || v === 'ON' ? 1 : 0
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Is this instance still the one the GX talks to? The newest instance takes
|
|
408
|
+
// the service name when it starts (see vedbus.claim), so finding the name
|
|
409
|
+
// with another connection means this one has been replaced: it retires
|
|
410
|
+
// rather than fight for the name.
|
|
411
|
+
async function checkOwner () {
|
|
412
|
+
if (!svc) return
|
|
413
|
+
const mine = run
|
|
414
|
+
const o = await svc.owner()
|
|
415
|
+
if (mine !== run || !svc) return
|
|
416
|
+
diag.owner = o
|
|
417
|
+
diag.ownerCheckedAt = new Date().toISOString()
|
|
418
|
+
if (o.owner && o.self && o.owner !== o.self) {
|
|
419
|
+
log(`Victron switch pane: ${SERVICE} now belongs to ${o.owner}; this instance (${o.self}) stops`)
|
|
420
|
+
status = { ...status, running: false, error: `replaced by a newer instance (${o.owner})` }
|
|
421
|
+
stop(true)
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Repeat every switch's state to the GX (see vedbus.announce): shortly after
|
|
426
|
+
// start, when the GX is still reading the new service, and then regularly.
|
|
427
|
+
const ANNOUNCE_AFTER_MS = [3000, 10000, 20000]
|
|
428
|
+
const ANNOUNCE_EVERY_MS = 30000
|
|
429
|
+
function announceAll () {
|
|
430
|
+
if (!svc) return
|
|
431
|
+
const paths = []
|
|
432
|
+
for (const ch of channels.values()) {
|
|
433
|
+
paths.push(`${ch.base}/State`, `${ch.base}/Status`, `${ch.base}/Settings/CustomName`)
|
|
434
|
+
if (ch.dimmer) paths.push(`${ch.base}/Dimming`)
|
|
435
|
+
}
|
|
436
|
+
svc.announce(paths)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function updateCurrents () {
|
|
440
|
+
for (const [slug, ch] of channels) {
|
|
441
|
+
try {
|
|
442
|
+
const a = getCurrent(`electrical.czone.${slug}.current`)
|
|
443
|
+
svc.set(`${ch.base}/Current`, typeof a === 'number' ? a : null)
|
|
444
|
+
refreshLabel(ch)
|
|
445
|
+
} catch (err) { fail(`current ${slug}`, err) }
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// What a label shows is held steady. Every change of a switch's name makes
|
|
450
|
+
// the GX re-sort its lists and jump to the selected row (seen on the device's
|
|
451
|
+
// Outputs page on Compass Rose), and a reading that wobbles by 0.1 was
|
|
452
|
+
// changing the name every few seconds. A shown value is replaced at once
|
|
453
|
+
// when the reading is a real step away from it, or appears or goes; a
|
|
454
|
+
// smaller difference replaces it only once the reading has stayed away from
|
|
455
|
+
// the shown value for a while. /Current itself is always the exact reading.
|
|
456
|
+
const STEADY = { amps: { step: 0.2, after: 120e3 }, kelvin: { step: 0.5, after: 300e3 } }
|
|
457
|
+
function steady (ch, key, value) {
|
|
458
|
+
const shown = ch.shown || (ch.shown = {})
|
|
459
|
+
const was = shown[key]
|
|
460
|
+
const take = () => { shown[key] = value; shown[key + 'Since'] = null; return value }
|
|
461
|
+
if (value === null || was === null || was === undefined) return take()
|
|
462
|
+
if (Math.round(was * 10) === Math.round(value * 10)) { shown[key + 'Since'] = null; return was }
|
|
463
|
+
if (Math.abs(value - was) >= STEADY[key].step - 1e-9) return take()
|
|
464
|
+
const t = now()
|
|
465
|
+
if (!shown[key + 'Since']) shown[key + 'Since'] = t
|
|
466
|
+
else if (t - shown[key + 'Since'] >= STEADY[key].after) return take()
|
|
467
|
+
return was
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function refreshLabel (ch) {
|
|
471
|
+
const a = svc.get(`${ch.base}/Current`)
|
|
472
|
+
const on = svc.get(`${ch.base}/State`) === 1
|
|
473
|
+
let t = null
|
|
474
|
+
if (showTemperature) { try { t = getTemperature(ch.circuit.slug) } catch (_) { t = null } }
|
|
475
|
+
const amps = steady(ch, 'amps', showCurrent && on && typeof a === 'number' && a > 0 ? a : null)
|
|
476
|
+
const kelvin = steady(ch, 'kelvin', typeof t === 'number' ? t : null)
|
|
477
|
+
svc.set(`${ch.base}/Settings/CustomName`, labelWith(ch.label, kelvin, amps, temperatureUnit))
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// The unit the GX shows temperatures in (Settings > Display > Units).
|
|
481
|
+
async function readTemperatureUnit () {
|
|
482
|
+
const mine = run
|
|
483
|
+
try {
|
|
484
|
+
const v = await invokeSettings({ path: '/Settings/System/Units/Temperature', interface: 'com.victronenergy.BusItem', member: 'GetValue' })
|
|
485
|
+
if (mine !== run) return
|
|
486
|
+
const raw = Array.isArray(v) ? (v[1] && v[1][0]) : v
|
|
487
|
+
diag.temperatureUnit = { setting: raw, used: temperatureUnitFrom(raw) }
|
|
488
|
+
temperatureUnit = diag.temperatureUnit.used
|
|
489
|
+
} catch (err) {
|
|
490
|
+
if (mine !== run) return
|
|
491
|
+
diag.temperatureUnit = { setting: null, used: 'C', error: err.message }
|
|
492
|
+
temperatureUnit = 'C'
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// mapping: the loaded configuration (lib/zcf), or the path of a ZCF to load.
|
|
497
|
+
async function start (settings = {}, mapping = null, options = {}) {
|
|
498
|
+
stop()
|
|
499
|
+
const mine = ++run
|
|
500
|
+
// A start overtaken by stop() or a newer start() ends here: stop() has
|
|
501
|
+
// already closed what this one opened.
|
|
502
|
+
const overtaken = () => mine !== run
|
|
503
|
+
status = { enabled: settings.victronSwitches === true, running: false, error: null, channels: 0, deviceInstance: null }
|
|
504
|
+
showCurrent = settings.victronSwitchCurrent !== false
|
|
505
|
+
showTemperature = settings.victronSwitchTemperature !== false
|
|
506
|
+
if (!status.enabled) return status
|
|
507
|
+
try {
|
|
508
|
+
const zcfFile = typeof mapping === 'string' ? mapping : null
|
|
509
|
+
const dataDir = typeof app.getDataDirPath === 'function' ? app.getDataDirPath() : path.dirname(zcfFile || '.')
|
|
510
|
+
storeFile = path.join(dataDir, 'victron-switches.json')
|
|
511
|
+
loadStore()
|
|
512
|
+
|
|
513
|
+
if (zcfFile) mapping = require('../zcf').load(zcfFile)
|
|
514
|
+
// The circuits the webapp lists: not those no CZone display shows
|
|
515
|
+
// (showInCircuitList false) or hidden ones, nor those linked to a
|
|
516
|
+
// third-party switch: that switch has its own place in the pane, and the
|
|
517
|
+
// circuit would show it twice.
|
|
518
|
+
const bridged = new Set((Array.isArray(settings.bridges) ? settings.bridges : []).map(b => b && b.circuit).filter(Boolean))
|
|
519
|
+
const circuits = ((mapping && mapping.circuits) || []).filter(c => !c.hidden && c.showInCircuitList !== false && !bridged.has(c.name))
|
|
520
|
+
if (!circuits.length) throw new Error('No circuits in the loaded ZCF')
|
|
521
|
+
|
|
522
|
+
if (!options.bus) {
|
|
523
|
+
let dbus
|
|
524
|
+
try { dbus = require('dbus-native') } catch (_) { throw new Error('D-Bus support is not installed (only available on a Victron GX)') }
|
|
525
|
+
bus = dbus.systemBus()
|
|
526
|
+
} else bus = options.bus
|
|
527
|
+
// An error on the connection (closed while still connecting, bus restart)
|
|
528
|
+
// is reported, never thrown: unhandled, it would take Signal K down.
|
|
529
|
+
if (bus.connection && typeof bus.connection.on === 'function') {
|
|
530
|
+
bus.connection.on('error', err => { fail('dbus connection', err); log(`Victron switch pane D-Bus: ${err && err.message ? err.message : err}`) })
|
|
531
|
+
}
|
|
532
|
+
svc = createVeService({ bus, name: SERVICE, log })
|
|
533
|
+
const instance = await deviceInstance()
|
|
534
|
+
if (overtaken()) return status
|
|
535
|
+
status.deviceInstance = instance
|
|
536
|
+
await readTemperatureUnit()
|
|
537
|
+
if (overtaken()) return status
|
|
538
|
+
const vessel = mapping.vesselName || 'CZone'
|
|
539
|
+
const root = {
|
|
540
|
+
'/Mgmt/ProcessName': ['s', 'signalk-czone'],
|
|
541
|
+
'/Mgmt/ProcessVersion': ['s', version],
|
|
542
|
+
'/Mgmt/Connection': ['s', 'Signal K plugin'],
|
|
543
|
+
'/DeviceInstance': ['i', instance],
|
|
544
|
+
'/ProductId': ['i', 0xFFFF],
|
|
545
|
+
'/ProductName': ['s', 'CZone Circuits'],
|
|
546
|
+
'/FirmwareVersion': ['s', version],
|
|
547
|
+
'/HardwareVersion': ['s', 'Signal K'],
|
|
548
|
+
'/Serial': ['s', `czone-${SETTINGS_ID}`],
|
|
549
|
+
'/Connected': ['i', 1],
|
|
550
|
+
'/State': ['i', 0x100]
|
|
551
|
+
}
|
|
552
|
+
for (const [p, [type, value]] of Object.entries(root)) svc.add(p, { type, value })
|
|
553
|
+
// The device's name in the GX device list. Defaults to the vessel name in
|
|
554
|
+
// the ZCF; a name typed on the GX is kept (victron-switches.json) and
|
|
555
|
+
// survives restarts and new ZCF uploads. Cleared = back to the default.
|
|
556
|
+
const defaultName = clip32(`CZone ${vessel}`)
|
|
557
|
+
svc.add('/CustomName', {
|
|
558
|
+
type: 's',
|
|
559
|
+
value: clip32(store.deviceName || defaultName),
|
|
560
|
+
onSet: v => {
|
|
561
|
+
const name = clip32(String(v == null ? '' : v).trim())
|
|
562
|
+
if (name) store.deviceName = name; else delete store.deviceName
|
|
563
|
+
saveStore()
|
|
564
|
+
svc.set('/CustomName', name || defaultName)
|
|
565
|
+
note({ device: 'CustomName', value: name || defaultName })
|
|
566
|
+
return true
|
|
567
|
+
}
|
|
568
|
+
})
|
|
569
|
+
for (const c of circuits) addChannel(c)
|
|
570
|
+
// Listen before the (slow) D-Bus registration, so no change is missed.
|
|
571
|
+
const sb = app.streambundle && typeof app.streambundle.getSelfBus === 'function' ? app.streambundle.getSelfBus() : null
|
|
572
|
+
if (sb && typeof sb.onValue === 'function') unsub = sb.onValue(onDelta)
|
|
573
|
+
seedFromSignalK()
|
|
574
|
+
updateCurrents()
|
|
575
|
+
await svc.start()
|
|
576
|
+
if (overtaken()) return status
|
|
577
|
+
diag.startedAt = new Date().toISOString()
|
|
578
|
+
seedFromSignalK()
|
|
579
|
+
currentTimer = setInterval(() => { seedFromSignalK(); updateCurrents() }, 2000)
|
|
580
|
+
announceTimers = ANNOUNCE_AFTER_MS.map(ms => setTimeout(announceAll, ms))
|
|
581
|
+
announceTimers.push(setInterval(announceAll, ANNOUNCE_EVERY_MS))
|
|
582
|
+
announceTimers.push(setTimeout(checkOwner, 5000), setInterval(checkOwner, ANNOUNCE_EVERY_MS), setInterval(readTemperatureUnit, ANNOUNCE_EVERY_MS))
|
|
583
|
+
status = { ...status, running: true, channels: channels.size }
|
|
584
|
+
log(`Victron switch pane: ${channels.size} circuits on ${SERVICE} (device instance ${instance})`)
|
|
585
|
+
} catch (err) {
|
|
586
|
+
if (overtaken()) return status
|
|
587
|
+
status = { ...status, running: false, error: err.message }
|
|
588
|
+
log(`Victron switch pane failed: ${err.message}`)
|
|
589
|
+
stop(true)
|
|
590
|
+
}
|
|
591
|
+
return status
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function stop (keepStatus) {
|
|
595
|
+
run++
|
|
596
|
+
if (typeof unsub === 'function') { try { unsub() } catch (_) {} }
|
|
597
|
+
unsub = null
|
|
598
|
+
clearInterval(currentTimer)
|
|
599
|
+
currentTimer = null
|
|
600
|
+
for (const t of announceTimers) { clearTimeout(t); clearInterval(t) }
|
|
601
|
+
announceTimers = []
|
|
602
|
+
if (svc) svc.stop()
|
|
603
|
+
svc = null
|
|
604
|
+
if (bus && typeof bus.connection === 'object' && bus.connection && typeof bus.connection.end === 'function') {
|
|
605
|
+
try { bus.connection.end() } catch (_) {}
|
|
606
|
+
}
|
|
607
|
+
bus = null
|
|
608
|
+
channels = new Map()
|
|
609
|
+
if (!keepStatus) status = { ...status, running: false }
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Everything needed to tell, from one page, why the pane and a circuit
|
|
613
|
+
// disagree: is this the instance the GX talks to, is the resync running, and
|
|
614
|
+
// which circuits differ between Signal K and what the pane has been given.
|
|
615
|
+
function report () {
|
|
616
|
+
const on = []
|
|
617
|
+
const mismatch = []
|
|
618
|
+
if (svc) {
|
|
619
|
+
for (const [slug, ch] of channels) {
|
|
620
|
+
const pane = svc.get(`${ch.base}/State`)
|
|
621
|
+
const plugin = pluginState(slug)
|
|
622
|
+
const sk = signalkState(slug)
|
|
623
|
+
if (pane === 1) on.push(slug)
|
|
624
|
+
if ((plugin !== null && plugin !== pane) || (sk !== null && sk !== pane)) mismatch.push({ circuit: slug, plugin, signalk: sk, pane })
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
let signalkValues = 0
|
|
628
|
+
for (const slug of channels.keys()) if (signalkState(slug) !== null) signalkValues++
|
|
629
|
+
return { ...status, on, mismatch, diag: { ...diag, signalkValues, liveChannels: channels.size, ownsName: diag.owner ? diag.owner.owner === diag.owner.self : null }, recent }
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
return { start, stop, status: report, _checkOwner: checkOwner, _onDelta: onDelta, _service: () => svc, _resync: () => { seedFromSignalK(); updateCurrents() }, _announce: announceAll }
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
module.exports = { createVictronSwitches, SERVICE, groupFor, channelId, GROUP_PRIORITY, labelWith, CHANNEL_NAME, stripSuffix, temperatureText, temperatureUnitFrom }
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Minimal Victron "VeDbus" service on dbus-native, modelled on velib_python's
|
|
4
|
+
// vedbus.py, which every Venus OS driver uses:
|
|
5
|
+
//
|
|
6
|
+
// - one D-Bus service name (e.g. com.victronenergy.switch.czone)
|
|
7
|
+
// - every value is an object path implementing com.victronenergy.BusItem
|
|
8
|
+
// (GetValue / SetValue / GetText), and emits PropertiesChanged(a{sv})
|
|
9
|
+
// - the root "/" also implements GetItems (all paths) and GetValue (dict),
|
|
10
|
+
// and emits ItemsChanged(a{sa{sv}}) for batched updates (used by gui-v2)
|
|
11
|
+
//
|
|
12
|
+
// Values are dbus-native variants: ['i', 1], ['d', 1.5], ['s', 'x'].
|
|
13
|
+
// An invalid value is the empty int array ['ai', []], as in vedbus.py.
|
|
14
|
+
|
|
15
|
+
const IFACE = 'com.victronenergy.BusItem'
|
|
16
|
+
|
|
17
|
+
const BUSITEM = {
|
|
18
|
+
name: IFACE,
|
|
19
|
+
methods: {
|
|
20
|
+
GetValue: ['', 'v', [], ['value']],
|
|
21
|
+
SetValue: ['v', 'i', ['value'], ['result']],
|
|
22
|
+
GetText: ['', 's', [], ['text']]
|
|
23
|
+
},
|
|
24
|
+
signals: { PropertiesChanged: ['a{sv}', ['changes']] }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const ROOT = {
|
|
28
|
+
name: IFACE,
|
|
29
|
+
methods: {
|
|
30
|
+
GetItems: ['', 'a{sa{sv}}', [], ['items']],
|
|
31
|
+
GetValue: ['', 'v', [], ['value']],
|
|
32
|
+
SetValue: ['v', 'i', ['value'], ['result']],
|
|
33
|
+
GetText: ['', 's', [], ['text']]
|
|
34
|
+
},
|
|
35
|
+
signals: { ItemsChanged: ['a{sa{sv}}', ['changes']] }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const INVALID = ['ai', []]
|
|
39
|
+
|
|
40
|
+
function variant (type, value) {
|
|
41
|
+
if (value === null || value === undefined) return INVALID
|
|
42
|
+
if (type === 'i') return Number.isFinite(value) ? ['i', Math.round(value)] : INVALID
|
|
43
|
+
if (type === 'd') return Number.isFinite(value) ? ['d', value] : INVALID
|
|
44
|
+
if (type === 's') return ['s', String(value)]
|
|
45
|
+
if (type === 'ai') return ['ai', value.map(v => Math.round(v))]
|
|
46
|
+
return INVALID
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// dbus-native hands a received variant over as [[signature], [value]].
|
|
50
|
+
function unwrapVariant (v) {
|
|
51
|
+
if (Array.isArray(v) && v.length === 2 && Array.isArray(v[1])) {
|
|
52
|
+
const sig = v[0] && v[0][0] && v[0][0].type
|
|
53
|
+
if (sig === 'a') return v[1][0] // array value (e.g. LightControls)
|
|
54
|
+
return v[1][0]
|
|
55
|
+
}
|
|
56
|
+
return v
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// items: { '/Path': { type: 'i'|'d'|'s'|'ai', value, text?, onSet?(value) -> boolean } }
|
|
60
|
+
function createVeService ({ bus, name, log = () => {} }) {
|
|
61
|
+
const items = new Map()
|
|
62
|
+
let started = false
|
|
63
|
+
let pending = new Map() // batched ItemsChanged
|
|
64
|
+
let flushTimer = null
|
|
65
|
+
|
|
66
|
+
const textOf = (path, it) => (typeof it.text === 'function' ? it.text(it.value) : it.text) ?? (it.value == null ? '' : String(it.value))
|
|
67
|
+
const entry = (path, it) => [path, [['Value', variant(it.type, it.value)], ['Text', ['s', textOf(path, it)]]]]
|
|
68
|
+
|
|
69
|
+
function exportPath (path, it) {
|
|
70
|
+
const obj = {
|
|
71
|
+
GetValue: () => variant(it.type, it.value),
|
|
72
|
+
GetText: () => textOf(path, it),
|
|
73
|
+
SetValue: v => {
|
|
74
|
+
const value = unwrapVariant(v)
|
|
75
|
+
if (typeof it.onSet !== 'function') return 1
|
|
76
|
+
try { return it.onSet(value) === false ? 1 : 0 } catch (err) { log(`SetValue ${path}: ${err.message}`); return 1 }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
bus.exportInterface(obj, path, BUSITEM)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function exportRoot () {
|
|
83
|
+
bus.exportInterface({
|
|
84
|
+
GetItems: () => [...items].map(([p, it]) => entry(p, it)),
|
|
85
|
+
GetValue: () => ['a{sv}', [...items].map(([p, it]) => [p.slice(1), variant(it.type, it.value)])],
|
|
86
|
+
GetText: () => '',
|
|
87
|
+
SetValue: () => 1
|
|
88
|
+
}, '/', ROOT)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function flush () {
|
|
92
|
+
flushTimer = null
|
|
93
|
+
if (!pending.size) return
|
|
94
|
+
const changes = [...pending].map(([p, it]) => entry(p, it))
|
|
95
|
+
pending = new Map()
|
|
96
|
+
try { bus.sendSignal('/', IFACE, 'ItemsChanged', 'a{sa{sv}}', [changes]) } catch (err) { log(`ItemsChanged: ${err.message}`) }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function add (path, def) {
|
|
100
|
+
const it = { ...def }
|
|
101
|
+
items.set(path, it)
|
|
102
|
+
if (started) { exportPath(path, it); pending.set(path, it); scheduleFlush() }
|
|
103
|
+
return it
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function scheduleFlush () {
|
|
107
|
+
if (!flushTimer) flushTimer = setTimeout(flush, 50)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Update a value; emits PropertiesChanged on the path and a batched
|
|
111
|
+
// ItemsChanged on the root. No-op if unchanged.
|
|
112
|
+
function set (path, value) {
|
|
113
|
+
const it = items.get(path)
|
|
114
|
+
if (!it) return
|
|
115
|
+
const same = Array.isArray(value) ? JSON.stringify(value) === JSON.stringify(it.value) : it.value === value
|
|
116
|
+
if (same) return
|
|
117
|
+
it.value = value
|
|
118
|
+
if (!started) return
|
|
119
|
+
try {
|
|
120
|
+
bus.sendSignal(path, IFACE, 'PropertiesChanged', 'a{sv}', [[['Value', variant(it.type, it.value)], ['Text', ['s', textOf(path, it)]]]])
|
|
121
|
+
} catch (err) { log(`PropertiesChanged ${path}: ${err.message}`) }
|
|
122
|
+
pending.set(path, it)
|
|
123
|
+
scheduleFlush()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Send the current values again, changed or not, in one ItemsChanged.
|
|
127
|
+
// A listener that took its snapshot of a newly registered service just as a
|
|
128
|
+
// value changed can be left holding the old value; since `set` only signals
|
|
129
|
+
// changes, nothing would ever correct it.
|
|
130
|
+
function announce (paths) {
|
|
131
|
+
if (!started) return
|
|
132
|
+
for (const p of paths) {
|
|
133
|
+
const it = items.get(p)
|
|
134
|
+
if (it) pending.set(p, it)
|
|
135
|
+
}
|
|
136
|
+
if (pending.size) scheduleFlush()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function get (path) { const it = items.get(path); return it ? it.value : undefined }
|
|
140
|
+
|
|
141
|
+
// ALLOW_REPLACEMENT | REPLACE_EXISTING | DO_NOT_QUEUE: the newest instance
|
|
142
|
+
// always gets the name. An older one that was never shut down properly
|
|
143
|
+
// (plugin restarted, or an earlier Signal K process still alive) would
|
|
144
|
+
// otherwise keep answering the GX with values that no longer change.
|
|
145
|
+
const NAME_FLAGS = 0x1 | 0x2 | 0x4
|
|
146
|
+
function claim () {
|
|
147
|
+
return new Promise((resolve, reject) => {
|
|
148
|
+
bus.requestName(name, NAME_FLAGS, (err, code) => {
|
|
149
|
+
if (err) return reject(new Error(`requestName ${name}: ${err.message || err}`))
|
|
150
|
+
if (code !== 1 && code !== 4) return reject(new Error(`requestName ${name}: code ${code} (name in use?)`))
|
|
151
|
+
resolve()
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function start () {
|
|
157
|
+
exportRoot()
|
|
158
|
+
for (const [p, it] of items) exportPath(p, it)
|
|
159
|
+
started = true
|
|
160
|
+
return claim()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Who the bus says owns the service name, and this connection's own name.
|
|
164
|
+
function owner () {
|
|
165
|
+
return new Promise(resolve => {
|
|
166
|
+
const done = (err, who) => resolve({ owner: err ? null : who, self: bus.name || null, error: err ? String((err && err.message) || err) : null })
|
|
167
|
+
try {
|
|
168
|
+
bus.invoke({ destination: 'org.freedesktop.DBus', path: '/org/freedesktop/DBus', interface: 'org.freedesktop.DBus', member: 'GetNameOwner', signature: 's', body: [name] }, done)
|
|
169
|
+
} catch (err) { done(err) }
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function stop () {
|
|
174
|
+
clearTimeout(flushTimer)
|
|
175
|
+
started = false
|
|
176
|
+
try { bus.releaseName && bus.releaseName(name, () => {}) } catch (_) {}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { add, set, get, announce, start, stop, claim, owner, items }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = { createVeService, variant, unwrapVariant, INVALID }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-czone",
|
|
3
|
-
"version": "0.3.0-beta.
|
|
3
|
+
"version": "0.3.0-beta.17",
|
|
4
4
|
"description": "BETA: Signal K CZone monitoring and control using dynamically parsed ZCF configuration and NMEA 2000",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"keywords": [
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"icon.png"
|
|
43
43
|
],
|
|
44
44
|
"scripts": {
|
|
45
|
-
"test": "node test/zcf.test.js && node test/zcf-fixtures.test.js && node test/circuit-state.test.js && node test/czone.test.js && node test/nmea2000.test.js && node test/control-x-plus.test.js && node test/signalk.test.js && node test/write.test.js && node test/circuit-status.test.js && node test/status-mapping-regression.test.js && node test/state.test.js && node test/config-network.test.js && node test/config-namespace.test.js && node test/monitoring.test.js && node test/confirm-off.test.js && node test/webapp-current.test.js && node test/bridge.test.js && node test/trends.test.js && node test/inputs.test.js && node --require ./test/helpers/gx-low-space.cjs test/trends.test.js"
|
|
45
|
+
"test": "node test/zcf.test.js && node test/zcf-fixtures.test.js && node test/circuit-state.test.js && node test/czone.test.js && node test/nmea2000.test.js && node test/control-x-plus.test.js && node test/signalk.test.js && node test/write.test.js && node test/circuit-status.test.js && node test/status-mapping-regression.test.js && node test/state.test.js && node test/config-network.test.js && node test/config-namespace.test.js && node test/monitoring.test.js && node test/confirm-off.test.js && node test/webapp-current.test.js && node test/webapp-auth.test.js && node test/bridge.test.js && node test/victron-switches.test.js && node test/victron-label-steady.test.js && node test/switch-pane-plugin.test.js && node test/trends.test.js && node test/inputs.test.js && node --require ./test/helpers/gx-low-space.cjs test/trends.test.js"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"signalk-czone-zcf": "0.1.0-beta.2"
|
package/public/index.html
CHANGED
|
@@ -353,7 +353,7 @@
|
|
|
353
353
|
<div class="connection" id="connection"><i></i><span>Connecting to Signal K…</span></div>
|
|
354
354
|
<section class="hero">
|
|
355
355
|
<h1 id="heroTitle">Your Boat, Your Circuits</h1>
|
|
356
|
-
<p>Monitor and control your boat's electrical systems.</p><div id="writeGate" style="display:none;margin-top:12px;padding:10px 12px;border:1px solid rgba(255,195,77,.35);border-radius:10px;background:rgba(255,195,77,.08);color:#ffd98a;font-size:12px;line-height:1.5"></div>
|
|
356
|
+
<p>Monitor and control your boat's electrical systems.</p><div id="loadError" role="alert" hidden style="position:relative;z-index:1;margin-top:12px;padding:12px;border:1px solid var(--amber);border-radius:10px;background:var(--panel);line-height:1.5"></div><div id="writeGate" style="display:none;margin-top:12px;padding:10px 12px;border:1px solid rgba(255,195,77,.35);border-radius:10px;background:rgba(255,195,77,.08);color:#ffd98a;font-size:12px;line-height:1.5"></div>
|
|
357
357
|
</section>
|
|
358
358
|
<section class="section trend-panel" id="trendPanel">
|
|
359
359
|
<div class="section-head"><span>📈</span><div><h2 id="trendTitle"></h2><span class="subtle" id="trendSub"></span></div><button type="button" class="sort trend-close" onclick="closeTrend()" aria-label="Close trend">✕ Close</button></div>
|
|
@@ -700,10 +700,45 @@
|
|
|
700
700
|
if (Array.isArray(data.modes)) modes = data.modes;
|
|
701
701
|
if (data.activeMode) activeMode = data.activeMode;
|
|
702
702
|
}
|
|
703
|
+
function showLoadError(message, signIn) {
|
|
704
|
+
const el = document.querySelector('#loadError');
|
|
705
|
+
el.textContent = message + ' ';
|
|
706
|
+
const link = document.createElement('a');
|
|
707
|
+
link.style.color = 'var(--cyan)';
|
|
708
|
+
if (signIn) {
|
|
709
|
+
const here = window.location.pathname + window.location.search + window.location.hash;
|
|
710
|
+
link.href = '/admin/#/login?redirect=' + encodeURIComponent(here);
|
|
711
|
+
link.textContent = 'Sign in';
|
|
712
|
+
} else {
|
|
713
|
+
link.href = window.location.href;
|
|
714
|
+
link.textContent = 'Retry';
|
|
715
|
+
link.onclick = event => { event.preventDefault(); window.location.reload(); };
|
|
716
|
+
}
|
|
717
|
+
el.appendChild(link);
|
|
718
|
+
el.hidden = false;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
async function showCircuitRequestError(response) {
|
|
722
|
+
let signIn = response.status === 401;
|
|
723
|
+
if (response.status === 403) {
|
|
724
|
+
try {
|
|
725
|
+
const r = await fetch('/skServer/loginStatus', { cache:'no-store', credentials:'include' });
|
|
726
|
+
if (r.ok) signIn = (await r.json()).status === 'notLoggedIn';
|
|
727
|
+
} catch (_) { /* Keep the permission error if login status cannot be checked. */ }
|
|
728
|
+
}
|
|
729
|
+
const message = signIn
|
|
730
|
+
? 'Sign in to Signal K to load CZone circuits.'
|
|
731
|
+
: response.status === 403
|
|
732
|
+
? 'Signal K denied access to CZone circuits. Your account may not have the required permissions.'
|
|
733
|
+
: 'Could not load CZone circuits (HTTP ' + response.status + '). Check the Signal K server and CZone plugin.';
|
|
734
|
+
showLoadError(message, signIn);
|
|
735
|
+
updateSocketIndicator(signIn ? 'Sign in required' : 'Unable to load CZone circuits', false);
|
|
736
|
+
}
|
|
737
|
+
|
|
703
738
|
async function refreshOnce(reason = 'manual') {
|
|
704
739
|
try {
|
|
705
740
|
const r = await fetch(`${API}/circuits`, { cache:'no-store', credentials:'include' });
|
|
706
|
-
if (!r.ok)
|
|
741
|
+
if (!r.ok) { await showCircuitRequestError(r); return false; }
|
|
707
742
|
const data = await r.json();
|
|
708
743
|
const incoming = Array.isArray(data.circuits) ? data.circuits : [];
|
|
709
744
|
console.debug('[CZone UI] REST refresh', reason, { circuits: incoming.length, modes: Array.isArray(data.modes) ? data.modes.length : 0, sendingEnabled: data.sendingEnabled === true, nmeaReady: data.nmeaReady === true });
|
|
@@ -719,12 +754,14 @@
|
|
|
719
754
|
: socketEverOpen
|
|
720
755
|
? `<i></i><span>Live updates lost · reconnecting… (circuits as of ${new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })})</span>`
|
|
721
756
|
: '<i></i><span>Circuits loaded · connecting live updates…</span>';
|
|
757
|
+
document.querySelector('#loadError').hidden = true;
|
|
722
758
|
render();
|
|
723
759
|
return true;
|
|
724
760
|
} catch (e) {
|
|
725
761
|
console.warn('[CZone UI] REST refresh failed', reason, e);
|
|
726
762
|
document.querySelector('#connection').classList.remove('ok');
|
|
727
|
-
|
|
763
|
+
showLoadError('Could not load CZone circuits. Check the Signal K connection and CZone plugin. ' + e.message, false);
|
|
764
|
+
updateSocketIndicator('Unable to load CZone circuits', false);
|
|
728
765
|
return false;
|
|
729
766
|
}
|
|
730
767
|
}
|
|
@@ -1334,7 +1371,8 @@
|
|
|
1334
1371
|
}
|
|
1335
1372
|
|
|
1336
1373
|
refreshVesselName();
|
|
1337
|
-
refreshOnce('initial-load').then(
|
|
1374
|
+
refreshOnce('initial-load').then(loaded => {
|
|
1375
|
+
if (!loaded) return;
|
|
1338
1376
|
if (circuits.length) {
|
|
1339
1377
|
connectSocket();
|
|
1340
1378
|
sendSubscriptions();
|
package/public/remoteEntry.js
CHANGED
|
@@ -31,6 +31,7 @@ var signalk_czone = (function () {
|
|
|
31
31
|
var uploadState = React.useState(null)
|
|
32
32
|
var uploadFile = uploadState[0]
|
|
33
33
|
var setUploadFile = uploadState[1]
|
|
34
|
+
var paneState = React.useState(null); var pane = paneState[0]; var setPane = paneState[1]
|
|
34
35
|
var trendState = React.useState(null)
|
|
35
36
|
var trend = trendState[0]
|
|
36
37
|
var setTrend = trendState[1]
|
|
@@ -111,11 +112,26 @@ var signalk_czone = (function () {
|
|
|
111
112
|
.catch(function () {})
|
|
112
113
|
}
|
|
113
114
|
|
|
115
|
+
function loadPaneStatus () {
|
|
116
|
+
return fetch('/plugins/signalk-czone/victron/status', { credentials: 'same-origin', cache: 'no-store' })
|
|
117
|
+
.then(function (r) { return r.ok ? r.json() : null })
|
|
118
|
+
.then(function (value) { setPane(value) })
|
|
119
|
+
.catch(function () {})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function paneSummary () {
|
|
123
|
+
if (configuration.victronSwitches !== true) return 'Switched off.'
|
|
124
|
+
if (!pane) return 'Checking…'
|
|
125
|
+
if (pane.running) return pane.channels + ' circuits in the switch pane (device instance ' + pane.deviceInstance + ').'
|
|
126
|
+
return pane.error ? 'Not running: ' + pane.error : 'Starting…'
|
|
127
|
+
}
|
|
128
|
+
|
|
114
129
|
React.useEffect(function () {
|
|
115
130
|
loadConfiguration().catch(function (err) { setError(err.message || String(err)) })
|
|
116
131
|
loadTrendStatus()
|
|
132
|
+
loadPaneStatus()
|
|
117
133
|
// Saving a trend setting restarts the plugin; show where trends go now.
|
|
118
|
-
var timer = setInterval(loadTrendStatus, 5000)
|
|
134
|
+
var timer = setInterval(function () { loadTrendStatus(); loadPaneStatus() }, 5000)
|
|
119
135
|
return function () { clearInterval(timer) }
|
|
120
136
|
}, [])
|
|
121
137
|
|
|
@@ -447,6 +463,24 @@ var signalk_czone = (function () {
|
|
|
447
463
|
)
|
|
448
464
|
),
|
|
449
465
|
|
|
466
|
+
React.createElement('div', { style: { marginBottom: 14, padding: 12, border: '1px solid #ccc', borderRadius: 6 } },
|
|
467
|
+
React.createElement('strong', null, 'Victron switch pane'),
|
|
468
|
+
React.createElement('div', { style: { marginTop: 6, fontSize: 12 } }, 'Victron GX only (Venus OS 3.60 or later). The CZone circuits as switches on the GX touch screen, Remote Console and VRM, one card per CZone category. Switching there needs CZone read/write control enabled; circuits set to confirm before turning off (on) are not turned off (on) from there unless other apps are allowed to.'),
|
|
469
|
+
React.createElement('div', { style: { marginTop: 9 } },
|
|
470
|
+
React.createElement('label', null,
|
|
471
|
+
React.createElement('input', { type: 'checkbox', checked: configuration.victronSwitches === true, disabled: busy, onChange: function (e) { persist({ victronSwitches: e.target.checked }) } }),
|
|
472
|
+
' Show CZone circuits in the Victron switch pane'
|
|
473
|
+
)
|
|
474
|
+
),
|
|
475
|
+
React.createElement('div', { style: { marginTop: 6 } },
|
|
476
|
+
React.createElement('label', null,
|
|
477
|
+
React.createElement('input', { type: 'checkbox', checked: configuration.victronSwitchCurrent !== false, disabled: busy || configuration.victronSwitches !== true, onChange: function (e) { persist({ victronSwitchCurrent: e.target.checked }) } }),
|
|
478
|
+
' Show circuit current in the label while on'
|
|
479
|
+
)
|
|
480
|
+
),
|
|
481
|
+
React.createElement('div', { style: { marginTop: 6, fontSize: 12 } }, paneSummary())
|
|
482
|
+
),
|
|
483
|
+
|
|
450
484
|
React.createElement('div', { style: { marginBottom: 14, padding: 12, border: '1px solid #ccc', borderRadius: 6 } },
|
|
451
485
|
React.createElement('strong', null, 'Trends'),
|
|
452
486
|
React.createElement('div', { style: { marginTop: 6, fontSize: 12 } }, 'Off by default. Stores each circuit\'s current every 10 seconds as plain CSV files, for the trend charts in the webapp (the arrow at the end of a circuit row). Not needed if the data already goes to a database such as InfluxDB.'),
|