signalk-czone 0.3.0-beta.13 → 0.3.0-beta.15

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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0-beta.15
4
+
5
+ - Preserves plugin settings when uploading a ZCF or selecting a network configuration (#32).
6
+ - Publishes switch-input states for Signal Interface, COI and Control X PLUS modules (#30).
7
+ - Adds the Navico chartplotter touch layout, drag scrolling and reduced redraw cost (#31).
8
+ - Updates the canonical ZCF parser to 0.1.0-beta.2 for newer Configuration Tool layouts and corrected tank settings.
9
+
10
+ ## 0.3.0-beta.14
11
+
12
+ - Adds bidirectional CZone links to Shelly Gen2+ switches and Victron GX outputs, with startup, confirmation and relay-function safeguards.
13
+ - Restores Navico chartplotter compatibility with Chromium 69 syntax and flex-spacing fallbacks.
14
+
3
15
  ## 0.3.0-beta.13
4
16
 
5
17
  - Adds a fullscreen icon beside the CZone / Boat Systems branding, with expand/collapse states and accessible labels.
package/README.md CHANGED
@@ -57,6 +57,28 @@ The existing electrical-monitoring definition endpoint remains:
57
57
  GET /plugins/signalk-czone/monitoring
58
58
  ```
59
59
 
60
+ ## Switch inputs
61
+
62
+ Each switch input in the ZCF (a switch, float switch or signal wired to a CZone module) is published as `electrical.czone.inputs.<input name>.state`, `true` when the input is on. The name is cleaned the same way as circuit names; a repeated name gets `2`, `3` and so on. A value is published when the plugin first hears the module and then only on change.
63
+
64
+ Where an input's state is read depends on the kind of module (its type in the ZCF):
65
+
66
+ | Module | Message read | Input `i` is at |
67
+ | --- | --- | --- |
68
+ | Signal Interface | 65284 status and 65308 input message | bit `2i+1` |
69
+ | COI | 65308 input message only (its 65284 is its outputs) | bit `2(i-16)+1` |
70
+ | Control X PLUS | 65284 status only | bit `i`, or `i+1` when wired to switch to negative |
71
+
72
+ Inputs on other kinds of module are listed but not given a state. Checked on a Signal Interface on a test bench, COIs on SugarShack and a Control X PLUS on Compass Rose (Anchor Up, Ignition, High Bilge Water Alarm). Only one Control X PLUS input wired to negative has been seen.
73
+
74
+ ```text
75
+ GET /plugins/signalk-czone/inputs
76
+ ```
77
+
78
+ lists each input with its module, input number, wiring, module type, state and when it last changed.
79
+
80
+ The input table needs `signalk-czone-zcf` with `signalInputs` (the newer Configuration Tool layout included).
81
+
60
82
  ## Design
61
83
 
62
84
  The plugin does **not** modify Signal K Server, canboatjs, n2k-signalk, or any global PGN definitions.
@@ -195,9 +217,27 @@ Full detail is written on change (plus the last unchanged sample before a change
195
217
  - `GET /plugins/signalk-czone/trend?path=<Signal K path>&range=1h|24h|7d|31d|90d|1y`, or `&from=<ms>&to=<ms>` for a custom period. Answers `{ available, tier, start, end, gapMs, data, latest }`, where `data` rows are `[t, value]` (full detail) or `[t, avg, min, max]` (summaries, or full detail reduced to about 400 points), and `latest` is the live value while the circuit is reporting.
196
218
  - `GET /plugins/signalk-czone/trend/status` answers where trends are going, free space, and how many circuits are being recorded, or the reason trends are off.
197
219
 
220
+ ## Third-party switches
221
+
222
+ Switch something that is not on the CZone network from a CZone display or keypad, with no switchbank: a Shelly, a GX relay, or any output in the Victron switch pane.
223
+
224
+ 1. In the CZone Configuration Tool, add a **virtual circuit** for it and put it on the display. Write the configuration and upload the new ZCF to the plugin.
225
+ 2. In the configuration panel, under **Third-party switches**, choose that circuit and link it to:
226
+ - **a Victron output (through the GX):** anything Venus OS lists as a switchable output: a Shelly added to the GX, the GX's own relays, a Node-RED virtual switch. Needs the plugin running on the GX (D-Bus; `dbus-native` is an optional dependency). A GX relay is offered only when its function is **Manual**; one used as an alarm relay, for a generator, a tank pump or temperature is listed greyed out with the reason and never switched. Set-points and dimmers are not offered as on/off switches.
227
+ - **a Shelly (directly, by IP address):** Shelly Gen2 or later, no password, through its local RPC API (`/rpc/Switch.Set`, `/rpc/Switch.GetStatus`). No GX needed. **Test** asks the Shelly for its state.
228
+
229
+ The switch follows the CZone circuit, and the circuit follows the switch when it is switched from anywhere else (its app, the Victron pane, VRM); that needs **Enable CZone read/write control**. Rules, so the two never fight:
230
+
231
+ - a side is acted on only when it changes, and only if the other side differs; the plugin's own change coming back is recognised;
232
+ - **nothing is switched when the plugin starts**: if the two differ (a GX relay comes back off after a reboot), the link shows *out of step* until one side is switched;
233
+ - a circuit set to **confirm before turning off (on)** is never turned off (on) by a link, unless other apps are allowed to; the link says why and does not retry;
234
+ - a change one side could not take (unreachable, refused, sending disabled) is retried every 15 s; a switch that accepts a command but does not change is reported after 6 s.
235
+
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
+
198
238
  ## Installation
199
239
 
200
- The plugin installs the pinned `signalk-czone-zcf@0.1.0-beta.1` npm dependency automatically. No separate parser installation or Git checkout is required.
240
+ 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.
201
241
 
202
242
  ### Signal K App Store
203
243
 
@@ -221,11 +261,11 @@ npm install signalk-czone@latest --omit=dev --ignore-scripts
221
261
 
222
262
  Restart Signal K and follow the configuration steps above. For Docker or appliance installations, use the corresponding container/configuration directory and restart method.
223
263
 
224
- For installation from a maintainer-provided npm tarball, run `npm install /path/to/signalk-czone-0.3.0-beta.9.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.
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.
225
265
 
226
266
  ### Publishing (maintainers)
227
267
 
228
- Publish `signalk-czone-zcf@0.1.0-beta.1` first. Then run `npm install`, `npm test`, and `npm pack --dry-run` in this repository, verify the runtime files and App Store assets, and run `npm publish --tag latest` after npm authentication.
268
+ Publish `signalk-czone-zcf@0.1.0-beta.2` first. Then run `npm install`, `npm test`, and `npm pack --dry-run` in this repository, verify the runtime files and App Store assets, and run `npm publish --tag latest` after npm authentication.
229
269
 
230
270
  The package defaults to `latest`, including versions named `-beta.N`. The GitHub publishing workflow runs when `package.json` or the workflow changes on `main`: it tests and packs the release, publishes it if the version is new, and sets and verifies the `latest` tag. If the version already exists, the workflow promotes that existing version without republishing its contents.
231
271
 
@@ -424,6 +464,15 @@ Only the installed path is stored in plugin configuration; the binary ZCF is not
424
464
 
425
465
  The beta is intended for extended real-world testing before a stable `0.3.0` release.
426
466
 
467
+ ## On a Navico chartplotter (B&G, Simrad, Lowrance)
468
+
469
+ A Navico plotter shows the webapp in its own browser (Chromium 69), driven by a touch screen whose touches arrive as mouse events. The page recognises the plotter from the parameters the plotter adds to the address (`mfd_name`, `mfd_model_detail`, `brand`), or from the login token left by `signalk-navico-embedder`, and switches to a touch layout. `?layout=mfd` shows it in a desktop browser for testing. Nothing changes anywhere else.
470
+
471
+ - **Touch layout:** larger rows, buttons and navigation; no banner, overview or category chips; drawn icons in place of emoji and symbol characters the plotter's fonts lack; no shadows, gradients, blur or transitions, which are slow to draw there.
472
+ - **Scrolling:** a drag scrolls the list or the left column instead of selecting text, applied once per frame, and the tap that ends a drag is not taken as a press. Redraws caused by live data wait while a finger is down.
473
+ - **Updates:** the plotter keeps showing the page it first loaded. Once a minute the page checks whether its script has changed on the server and reloads once if so.
474
+ - **Diagnosis:** five taps on the connection line show the last input events, the frame time and the time to redraw the list.
475
+
427
476
  ## Fullscreen and tablet home-screen launch
428
477
 
429
478
  Open `/signalk-czone/` directly in your browser. The **fullscreen icon** beside
package/index.js CHANGED
@@ -3,12 +3,16 @@
3
3
  const path = require('path')
4
4
  const fs = require('fs')
5
5
  const zcf = require('./lib/zcf')
6
+ const inputs = require('./lib/inputs')
6
7
  const czone = require('./lib/czone')
7
8
  const nmea = require('./lib/nmea2000')
8
9
  const monitoring = require('./lib/monitoring')
9
10
  const { createTrends } = require('./lib/trends')
10
11
  const signalk = require('./lib/signalk')
11
12
  const confirmOffLib = require('./lib/confirm-off')
13
+ const { createBridge, bridgeEntries } = require('./lib/bridge')
14
+ const { createShellyDriver } = require('./lib/bridge/shelly')
15
+ const { createVictronDriver } = require('./lib/bridge/victron')
12
16
 
13
17
  const MAX_UPLOAD_BYTES = 1024 * 1024
14
18
  const CZONE_CONFIG_BLOCK_HEADER = 23
@@ -104,6 +108,49 @@ module.exports = function (app) {
104
108
  let nmeaReadyAt = null
105
109
  let lastNmeaOutput = null
106
110
  let runtimeState = new Map()
111
+
112
+ // Third-party switches (lib/bridge): a CZone circuit, normally a virtual
113
+ // circuit added for the purpose, linked to a switch that is not on the CZone
114
+ // network: a Victron switchable output on the GX's D-Bus (a Shelly added to
115
+ // the GX, a GX relay set to Manual, a Node-RED virtual switch) or a Shelly
116
+ // reached directly by IP. lib/bridge holds the linking rules and the drivers
117
+ // and has no CZone or Signal K code in it.
118
+ let bridgeBus = null
119
+ function getBridgeBus () {
120
+ if (bridgeBus) return bridgeBus
121
+ let dbus
122
+ try { dbus = require('dbus-native') } catch (_) { throw new Error('D-Bus support is not installed (only available on a Victron GX)') }
123
+ bridgeBus = dbus.systemBus()
124
+ if (bridgeBus && bridgeBus.connection && typeof bridgeBus.connection.on === 'function') {
125
+ bridgeBus.connection.on('error', err => { log(`Bridge D-Bus: ${err.message}`); closeBridgeBus() })
126
+ }
127
+ return bridgeBus
128
+ }
129
+ function closeBridgeBus () {
130
+ if (!bridgeBus) return
131
+ try { if (bridgeBus.connection && typeof bridgeBus.connection.end === 'function') bridgeBus.connection.end() } catch (_) {}
132
+ bridgeBus = null
133
+ }
134
+ const bridgeDrivers = { victron: createVictronDriver({ getBus: getBridgeBus }), shelly: createShellyDriver() }
135
+ const bridge = createBridge({
136
+ log: m => log(m),
137
+ readCircuit: name => {
138
+ const st = runtimeState.get(name)
139
+ return st && st.statusObserved && (st.state === 'ON' || st.state === 'OFF') ? st.state === 'ON' : undefined
140
+ },
141
+ writeCircuit: (name, on) => {
142
+ const c = mapping && mapping.circuits.find(x => x.name === name)
143
+ if (!c) throw new Error(`no circuit "${name}" in the CZone configuration`)
144
+ // A link cannot ask "are you sure?": a circuit nominated to confirm is
145
+ // never switched that way by a link, unless other apps are allowed to.
146
+ // Refused for good (not retried); the link shows why.
147
+ const refuse = text => { const err = new Error(text); err.permanent = true; throw err }
148
+ if (!on && confirmOff.has(c.name) && settings.confirmOffAllowElsewhere !== true) refuse('not switched off: this circuit asks before turning off')
149
+ if (on && confirmOn.has(c.name) && settings.confirmOnAllowElsewhere !== true) refuse('not switched on: this circuit asks before turning on')
150
+ sendCircuitState(c, on)
151
+ },
152
+ drivers: bridgeDrivers
153
+ })
107
154
  let monitoringRuntime = null
108
155
  // Trend recording of circuit current (see lib/trends).
109
156
  let trends = null
@@ -125,6 +172,10 @@ module.exports = function (app) {
125
172
  // Circuits nominated to confirm before switching, by circuit name.
126
173
  let confirmOff = new Set()
127
174
  let confirmOn = new Set()
175
+ // Switch inputs of the loaded configuration (lib/inputs) and their last
176
+ // decoded state, by Signal K path.
177
+ let switchInputs = []
178
+ let inputState = new Map()
128
179
 
129
180
  const configRoot = app.config && app.config.configPath
130
181
  ? path.join(app.config.configPath, 'plugin-config-data')
@@ -167,12 +218,16 @@ module.exports = function (app) {
167
218
  function loadConfiguredZcf () {
168
219
  if (!fs.existsSync(zcfPath())) {
169
220
  mapping = null
221
+ switchInputs = []
222
+ inputState = new Map()
170
223
  return null
171
224
  }
172
225
  mapping = zcf.load(zcfPath())
173
226
  mapping.commandDeviceId = selectCommandDeviceId(mapping)
174
227
  publishedCircuitValues.clear()
175
228
  monitoringRuntime = monitoring.createRuntime(mapping.meters || [])
229
+ switchInputs = inputs.switchInputs(mapping)
230
+ inputState = new Map(switchInputs.map(i => [i.path, { state: null, since: null }]))
176
231
  runtimeState = new Map(mapping.circuits.map(c => [c.name, {
177
232
  state: null,
178
233
  percent: null,
@@ -718,6 +773,13 @@ module.exports = function (app) {
718
773
  // bitmap. The runtime module/bit mapping is decoded from the ZCF status
719
774
  // table; it is intentionally not inferred from the primary ZCF
720
775
  // module/channel identity.
776
+ function startBridge () {
777
+ const entries = bridgeEntries(settings.bridges)
778
+ const known = entries.filter(e => mapping && mapping.circuits.some(c => c.name === e.circuit))
779
+ for (const e of entries) if (!known.includes(e)) log(`Third-party switch: circuit "${e.circuit}" is not in the CZone configuration; link ignored`)
780
+ bridge.start(known)
781
+ }
782
+
721
783
  function decodeCzoneCircuitStatus (frame) {
722
784
  if (!frame || frame.pgn !== 65284 || !frame.data || frame.data.length !== 8) return
723
785
  const data = frame.data
@@ -763,6 +825,7 @@ module.exports = function (app) {
763
825
  }
764
826
  }
765
827
 
828
+ bridge.onCircuit(circuit.name, enabled) // a linked switch follows at once
766
829
  const stateSource = signalk.nmea2000Source(frame.source, frame.pgn)
767
830
  const observedState = enabled ? 'ON' : 'OFF'
768
831
  log(`CZone STATE: ${circuit.name} -> ${observedState} (runtime ${Number(circuit.statusModule).toString(16).padStart(2, '0')}:${Number(circuit.statusMask).toString(16).padStart(8, '0')}, src=${frame.source})`)
@@ -838,6 +901,25 @@ module.exports = function (app) {
838
901
  }
839
902
  }
840
903
 
904
+ // Switch inputs: PGN 65284 (module status) and 65308 (input message), each
905
+ // input read only from the message its kind of module carries it in.
906
+ function decodeSwitchInputs (frame) {
907
+ if (!switchInputs.length) return
908
+ const decoded = inputs.decodeFrame(frame.data)
909
+ if (!decoded) return
910
+ for (const input of switchInputs) {
911
+ if (input.module !== decoded.module) continue
912
+ const on = inputs.inputState(input.moduleKind, decoded.bitmap, input.input, frame.pgn, input.wiring)
913
+ if (on === undefined) continue
914
+ const entry = inputState.get(input.path)
915
+ if (entry.state === on) continue
916
+ entry.state = on
917
+ entry.since = new Date().toISOString()
918
+ log(`CZone INPUT: ${input.name} ${on ? 'ON' : 'OFF'} (PGN ${frame.pgn} module=0x${decoded.module.toString(16).padStart(2, '0')} bitmap=0x${decoded.bitmap.toString(16).padStart(8, '0')})`)
919
+ publishDelta(input.path, on)
920
+ }
921
+ }
922
+
841
923
  function handleRawFrame (line) {
842
924
  const frame = nmea.parseRawLine(line)
843
925
  if (!frame) return
@@ -847,7 +929,12 @@ module.exports = function (app) {
847
929
  handleConfigFastPacket(frame)
848
930
  return
849
931
  }
932
+ if (frame.pgn === inputs.INPUT_PGN) {
933
+ decodeSwitchInputs(frame)
934
+ return
935
+ }
850
936
  if (frame.pgn === 65284) {
937
+ decodeSwitchInputs(frame)
851
938
  decodeCzoneCircuitStatus(frame)
852
939
  return
853
940
  }
@@ -1221,6 +1308,23 @@ module.exports = function (app) {
1221
1308
  title: 'Let other apps turn those circuits off',
1222
1309
  default: false,
1223
1310
  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
+ },
1312
+ bridges: {
1313
+ type: 'array',
1314
+ title: 'Third-party switches',
1315
+ description: 'CZone circuits (normally virtual circuits added for the purpose) linked to a switch that is not on the CZone network. Set up in the configuration panel.',
1316
+ default: [],
1317
+ items: {
1318
+ type: 'object',
1319
+ properties: {
1320
+ circuit: { type: 'string', title: 'CZone circuit' },
1321
+ target: { type: 'string', title: 'Linked to', enum: ['victron', 'shelly'], enumNames: ['A Victron output (through the GX)', 'A Shelly (directly, by IP address)'] },
1322
+ output: { type: 'string', title: 'Victron output (service#id)' },
1323
+ label: { type: 'string', title: 'Name' },
1324
+ host: { type: 'string', title: 'Shelly IP address' },
1325
+ channel: { type: 'number', title: 'Shelly channel', default: 0 }
1326
+ }
1327
+ }
1224
1328
  }
1225
1329
  }
1226
1330
  }),
@@ -1245,6 +1349,7 @@ module.exports = function (app) {
1245
1349
  log
1246
1350
  })
1247
1351
  trends.start()
1352
+ startBridge()
1248
1353
 
1249
1354
  const ready = (reason = 'nmea2000OutAvailable event') => {
1250
1355
  nmeaReady = true
@@ -1320,6 +1425,8 @@ module.exports = function (app) {
1320
1425
  monitoringRuntime = null
1321
1426
  if (trends) trends.stop()
1322
1427
  trends = null
1428
+ bridge.stop()
1429
+ closeBridgeBus()
1323
1430
  reassembler = null
1324
1431
  configFastPacket = null
1325
1432
  clearConfigTransfer()
@@ -1327,6 +1434,8 @@ module.exports = function (app) {
1327
1434
  mapping = null
1328
1435
  runtimeState.clear()
1329
1436
  publishedCircuitValues.clear()
1437
+ switchInputs = []
1438
+ inputState = new Map()
1330
1439
  // Signal K removes PUT handlers when the plugin stops. The plugin
1331
1440
  // factory can be reused on a subsequent enable, so allow start() to
1332
1441
  // register the handlers again instead of treating the stale paths as
@@ -1335,6 +1444,22 @@ module.exports = function (app) {
1335
1444
  },
1336
1445
 
1337
1446
  registerWithRouter: router => {
1447
+ router.get('/inputs', (_req, res) => {
1448
+ res.json({
1449
+ inputs: switchInputs.map(i => ({
1450
+ name: i.name,
1451
+ path: i.path,
1452
+ module: i.module,
1453
+ input: i.input,
1454
+ wiring: i.wiring || null,
1455
+ moduleType: i.decoded ? inputs.LAYOUT[i.moduleKind].name : null,
1456
+ decoded: i.decoded,
1457
+ state: inputState.get(i.path).state,
1458
+ since: inputState.get(i.path).since
1459
+ }))
1460
+ })
1461
+ })
1462
+
1338
1463
  router.get('/status', (_req, res) => {
1339
1464
  res.json({
1340
1465
  sendingEnabled: settings.allowCzoneWrite === true,
@@ -1417,6 +1542,21 @@ module.exports = function (app) {
1417
1542
  })
1418
1543
  })
1419
1544
 
1545
+ // Third-party switches: link status, the GX's outputs, a Shelly test.
1546
+ router.get('/bridge/status', (_req, res) => res.json({ links: bridge.status() }))
1547
+ router.get('/bridge/outputs', (_req, res) => {
1548
+ bridgeDrivers.victron.list()
1549
+ .then(outputs => res.json({ available: true, outputs }))
1550
+ .catch(err => res.json({ available: false, outputs: [], detail: `No Victron GX found (${err.message})` }))
1551
+ })
1552
+ router.get('/bridge/shelly', (req, res) => {
1553
+ const host = String((req.query && req.query.host) || '').trim()
1554
+ if (!/^[\w.-]+(:\d{1,5})?$/.test(host)) return res.status(400).json({ ok: false, detail: 'Give the Shelly\'s IP address' })
1555
+ bridgeDrivers.shelly.status({ host, channel: Number(req.query.channel) || 0 })
1556
+ .then(st => res.json({ ok: typeof st.output === 'boolean', output: st.output, power: st.apower, voltage: st.voltage }))
1557
+ .catch(err => res.json({ ok: false, detail: err.message }))
1558
+ })
1559
+
1420
1560
  router.get('/configuration', (_req, res) => {
1421
1561
  res.json({
1422
1562
  source: settings.configurationSource || 'installedZcf',
@@ -1427,7 +1567,10 @@ module.exports = function (app) {
1427
1567
  networkRead: lastNetworkConfig ? { ...lastNetworkConfig, blocks: undefined } : null,
1428
1568
  nmeaReady,
1429
1569
  // The circuits offered under "Confirm before turning off".
1430
- circuitNames: confirmOffLib.choices({}, mapping ? mapping.circuits : []).map(n => n.value)
1570
+ circuitNames: confirmOffLib.choices({}, mapping ? mapping.circuits : []).map(n => n.value),
1571
+ // Every circuit, for linking a third-party switch (a virtual circuit
1572
+ // added for one may not be listed in the webapp).
1573
+ allCircuitNames: mapping ? mapping.circuits.map(c => c.name).sort((a, b) => a.localeCompare(b)) : []
1431
1574
  })
1432
1575
  })
1433
1576
 
@@ -1455,11 +1598,9 @@ module.exports = function (app) {
1455
1598
  fs.copyFileSync(source, temp)
1456
1599
  const next = zcf.load(temp)
1457
1600
  try { fs.unlinkSync(temp) } catch (_) {}
1458
- const newSettings = {
1459
- allowCzoneWrite: settings.allowCzoneWrite === true,
1460
- configurationSource: 'networkCache',
1461
- networkConfigFile: filename
1462
- }
1601
+ // Only the configuration source changes; every other setting (confirm
1602
+ // before on/off, trends, third-party switches, …) is kept.
1603
+ const newSettings = { ...settings, configurationSource: 'networkCache', networkConfigFile: filename }
1463
1604
  const restartResult = await saveAndRestart(newSettings)
1464
1605
  res.json({ ok: true, file: filename, bytes: next.fileSize, vesselName: next.vesselName || null, configuration: newSettings, restartRequested: restartResult.restartRequested })
1465
1606
  } catch (err) { res.status(400).json({ ok: false, error: err.message }) }
@@ -1487,11 +1628,9 @@ module.exports = function (app) {
1487
1628
  savedAt: new Date().toISOString()
1488
1629
  }, null, 2))
1489
1630
  } catch (_) {}
1490
- const newSettings = {
1491
- allowCzoneWrite: settings.allowCzoneWrite === true,
1492
- configurationSource: 'installedZcf',
1493
- networkConfigFile: ''
1494
- }
1631
+ // Only the configuration source changes; every other setting (confirm
1632
+ // before on/off, trends, third-party switches, …) is kept.
1633
+ const newSettings = { ...settings, configurationSource: 'installedZcf', networkConfigFile: '' }
1495
1634
  const restartResult = await saveAndRestart(newSettings)
1496
1635
  res.json({
1497
1636
  ok: true,
@@ -1592,5 +1731,7 @@ module.exports = function (app) {
1592
1731
  }
1593
1732
  }
1594
1733
 
1734
+ // For tests: run one bridge poll now instead of waiting for its timer.
1735
+ Object.defineProperty(plugin, '_bridgePoll', { value: () => bridge.poll(), enumerable: false })
1595
1736
  return plugin
1596
1737
  }
@@ -0,0 +1,226 @@
1
+ 'use strict'
2
+
3
+ // Third-party switches driven from CZone.
4
+ //
5
+ // A CZone circuit (normally a virtual circuit added in the CZone configuration
6
+ // for the purpose, with no output of its own) is linked to a switch that is not
7
+ // on the CZone network: a Victron switchable output (a Shelly added to the GX,
8
+ // a GX relay, ...) or a Shelly reached directly on the network.
9
+ //
10
+ // CZone circuit changes (display, keypad, webapp) -> the switch follows
11
+ // the switch changes (Victron pane, VRM, its app) -> the CZone circuit follows
12
+ //
13
+ // The CZone side needs nothing new: a virtual circuit is held by a CZone module
14
+ // like any other circuit (on the bench, the Output Interface reports it as
15
+ // channel 6 in its status message), and the plugin already reads that state and
16
+ // can send it. Only the switch side differs, in a driver:
17
+ //
18
+ // driver.read(entry) -> Promise<true|false>
19
+ // driver.write(entry, on) -> Promise
20
+ //
21
+ // Rules, so the two sides never fight:
22
+ // - a side is only acted on when it changes, and only if the other side
23
+ // differs;
24
+ // - our own change coming back is recognised and not passed on again;
25
+ // - at start (or when a side is first seen) nothing is switched: if the two
26
+ // sides differ the link says "out of step" and waits for the next real
27
+ // change on either side. (It used to take the switch as right, but a GX
28
+ // relay comes back off after a reboot, and a link to Instruments then
29
+ // turned Instruments off: Compass Rose, 15 Oct 2026.);
30
+ // - a change the host refuses for good (err.permanent: a circuit that asks
31
+ // before turning off) is reported and not tried again;
32
+ // - a change one side could not take (switch unreachable or refusing,
33
+ // NMEA 2000 sending off or not ready yet) is kept and retried every
34
+ // RETRY_MS until it is taken or that side is changed some other way.
35
+ //
36
+ // Self-contained (no CZone or Signal K code here) so it can move to another
37
+ // plugin as it is: give it readCircuit/writeCircuit and the drivers.
38
+
39
+ const POLL_MS = 2000
40
+ const SETTLE_MS = 6000
41
+ const RETRY_MS = 15000
42
+ const OUT_OF_STEP = 'Out of step at start: nothing switched; the next change on either side brings them together'
43
+
44
+ function createBridge ({
45
+ log = () => {},
46
+ readCircuit = () => undefined, // name -> true | false | undefined (not known yet)
47
+ writeCircuit = () => { throw new Error('No CZone writer') }, // (name, on)
48
+ drivers = {},
49
+ now = () => Date.now(),
50
+ pollMs = POLL_MS,
51
+ settleMs = SETTLE_MS,
52
+ retryMs = RETRY_MS
53
+ } = {}) {
54
+ let links = []
55
+ let timer = null
56
+ let polling = false
57
+
58
+ function describe (entry) {
59
+ const d = drivers[entry.target]
60
+ return d && typeof d.describe === 'function' ? d.describe(entry) : `${entry.target}`
61
+ }
62
+
63
+ function start (entries = []) {
64
+ stop()
65
+ links = entries
66
+ .filter(e => e && e.circuit && drivers[e.target])
67
+ .map(entry => ({ entry, driver: drivers[entry.target], cz: undefined, dev: undefined, want: undefined, wantCz: undefined, retryAt: 0, pending: null, error: null, last: null, at: null }))
68
+ if (!links.length) return
69
+ log(`Bridge: ${links.map(l => `"${l.entry.circuit}" <-> ${describe(l.entry)}`).join('; ')}`)
70
+ timer = setInterval(() => { poll().catch(() => {}) }, pollMs)
71
+ if (timer.unref) timer.unref()
72
+ poll().catch(() => {})
73
+ }
74
+
75
+ function stop () {
76
+ if (timer) clearInterval(timer)
77
+ timer = null
78
+ links = []
79
+ }
80
+
81
+ function note (link, text) {
82
+ link.last = text
83
+ link.at = now()
84
+ log(`Bridge "${link.entry.circuit}": ${text}`)
85
+ }
86
+
87
+ async function toDevice (link, on, why) {
88
+ link.pending = { side: 'device', value: on, until: now() + settleMs }
89
+ note(link, `${why}: switching ${describe(link.entry)} ${on ? 'ON' : 'OFF'}`)
90
+ try {
91
+ await link.driver.write(link.entry, on)
92
+ link.error = null
93
+ } catch (err) {
94
+ link.pending = null
95
+ link.retryAt = now() + retryMs // link.want is kept: tried again later
96
+ link.error = `Could not switch ${describe(link.entry)}: ${err.message}`
97
+ log(`Bridge "${link.entry.circuit}": ${link.error}`)
98
+ }
99
+ }
100
+
101
+ function toCzone (link, on, why) {
102
+ link.pending = { side: 'czone', value: on, until: now() + settleMs }
103
+ note(link, `${why}: switching CZone circuit ${on ? 'ON' : 'OFF'}`)
104
+ try {
105
+ writeCircuit(link.entry.circuit, on)
106
+ link.error = null
107
+ } catch (err) {
108
+ link.pending = null
109
+ link.wantCz = err && err.permanent ? undefined : on
110
+ link.retryAt = now() + retryMs
111
+ link.error = `Could not switch the CZone circuit: ${err.message}`
112
+ log(`Bridge "${link.entry.circuit}": ${link.error}`)
113
+ }
114
+ }
115
+
116
+ function seeCircuit (link, on) {
117
+ if (on !== true && on !== false) return
118
+ const before = link.cz
119
+ link.cz = on
120
+ if (before === undefined) {
121
+ if (link.dev !== undefined && link.dev !== on && link.want === undefined) note(link, OUT_OF_STEP)
122
+ return
123
+ }
124
+ if (before === on) return
125
+ if (link.pending && link.pending.side === 'czone' && link.pending.value === on) { link.pending = null; link.wantCz = undefined; return } // our own change
126
+ link.wantCz = undefined
127
+ link.want = on
128
+ link.retryAt = 0
129
+ if (link.dev === on) link.want = undefined
130
+ else if (link.dev !== undefined) toDevice(link, on, 'CZone circuit switched')
131
+ // switch not seen yet: taken when it is (see poll)
132
+ }
133
+
134
+ function seeDevice (link, on) {
135
+ if (on !== true && on !== false) return
136
+ const before = link.dev
137
+ link.dev = on
138
+ if (before === undefined) {
139
+ if (link.want === undefined && link.cz !== undefined && link.cz !== on) note(link, OUT_OF_STEP)
140
+ return
141
+ }
142
+ if (before === on) return
143
+ if (link.pending && link.pending.side === 'device' && link.pending.value === on) { link.pending = null; link.want = undefined; return } // our own change
144
+ link.want = undefined
145
+ if (link.cz !== undefined && link.cz !== on) toCzone(link, on, 'Switch changed elsewhere')
146
+ }
147
+
148
+ // From the plugin's CZone state decoding: called as each status arrives, so
149
+ // a display press reaches the switch without waiting for the next poll.
150
+ function onCircuit (name, on) {
151
+ for (const link of links) if (link.entry.circuit === name) seeCircuit(link, on)
152
+ }
153
+
154
+ async function poll () {
155
+ if (polling) return
156
+ polling = true
157
+ try {
158
+ for (const link of links) {
159
+ if (link.pending && now() > link.pending.until) {
160
+ note(link, `No confirmation from the ${link.pending.side === 'czone' ? 'CZone circuit' : 'switch'}`)
161
+ link.pending = null
162
+ link.retryAt = now() + retryMs // anything still wanted waits its turn
163
+ }
164
+ seeCircuit(link, readCircuit(link.entry.circuit))
165
+ // A switch change the CZone circuit has not taken yet.
166
+ if (link.wantCz !== undefined && !link.pending) {
167
+ if (link.cz === link.wantCz) link.wantCz = undefined
168
+ else if (now() >= link.retryAt) toCzone(link, link.wantCz, 'Switch changed (trying the CZone circuit again)')
169
+ }
170
+ try {
171
+ const on = await link.driver.read(link.entry)
172
+ if (link.error && /^Not reachable/.test(link.error)) link.error = null
173
+ seeDevice(link, on)
174
+ // A CZone change the switch has not taken yet.
175
+ if (link.want !== undefined && !link.pending) {
176
+ if (link.dev === link.want) link.want = undefined
177
+ else if (now() >= link.retryAt) await toDevice(link, link.want, 'CZone circuit switched (trying again)')
178
+ }
179
+ } catch (err) {
180
+ if (!link.error || /^Not reachable/.test(link.error)) link.error = `Not reachable: ${err.message}`
181
+ }
182
+ }
183
+ } finally {
184
+ polling = false
185
+ }
186
+ }
187
+
188
+ function status () {
189
+ return links.map(l => ({
190
+ circuit: l.entry.circuit,
191
+ target: describe(l.entry),
192
+ czone: l.cz === undefined ? null : l.cz,
193
+ device: l.dev === undefined ? null : l.dev,
194
+ inStep: l.cz !== undefined && l.dev !== undefined ? l.cz === l.dev : null,
195
+ pending: l.pending ? { side: l.pending.side, value: l.pending.value } : null,
196
+ error: l.error,
197
+ last: l.last,
198
+ at: l.at
199
+ }))
200
+ }
201
+
202
+ const circuits = () => new Set(links.map(l => l.entry.circuit))
203
+
204
+ return { start, stop, onCircuit, poll, status, circuits }
205
+ }
206
+
207
+ // Saved settings -> bridge entries. Bad or incomplete rows are dropped.
208
+ // { circuit, target: 'victron', output: '<service>#<id>' }
209
+ // { circuit, target: 'shelly', host: '192.168.1.204', channel: 0 }
210
+ function bridgeEntries (list) {
211
+ const out = []
212
+ for (const row of Array.isArray(list) ? list : []) {
213
+ if (!row || typeof row.circuit !== 'string' || !row.circuit.trim()) continue
214
+ const circuit = row.circuit.trim()
215
+ if (row.target === 'victron' && typeof row.output === 'string' && /^com\.victronenergy\.[\w.]+#[\w-]+$/.test(row.output)) {
216
+ const [service, id] = row.output.split('#')
217
+ out.push({ circuit, target: 'victron', service, output: id, label: row.label || '' })
218
+ } else if (row.target === 'shelly' && typeof row.host === 'string' && /^[\w.-]+(:\d{1,5})?$/.test(row.host.trim())) {
219
+ const channel = Number.isInteger(Number(row.channel)) && Number(row.channel) >= 0 ? Number(row.channel) : 0
220
+ out.push({ circuit, target: 'shelly', host: row.host.trim(), channel, label: row.label || '' })
221
+ }
222
+ }
223
+ return out
224
+ }
225
+
226
+ module.exports = { createBridge, bridgeEntries, POLL_MS, SETTLE_MS, RETRY_MS }