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 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.15.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.
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.15",
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) throw new Error('Signal K plugin unavailable (HTTP ' + r.status + ')');
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
- document.querySelector('#connection').innerHTML = '<i></i><span>Waiting for Signal K…</span>';
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();
@@ -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.'),