signalk-czone 0.3.0-beta.12 → 0.3.0-beta.14

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.14
4
+
5
+ - Adds bidirectional CZone links to Shelly Gen2+ switches and Victron GX outputs, with startup, confirmation and relay-function safeguards.
6
+ - Restores Navico chartplotter compatibility with Chromium 69 syntax and flex-spacing fallbacks.
7
+
8
+ ## 0.3.0-beta.13
9
+
10
+ - Adds a fullscreen icon beside the CZone / Boat Systems branding, with expand/collapse states and accessible labels.
11
+ - Adds standalone tablet home-screen launch assets and safe-area spacing.
12
+ - Documents signalk-ssl setup, trusted CA certificate installation and device reboot steps for HTTPS app launch.
13
+
3
14
  ## 0.3.0-beta.12
4
15
 
5
16
  - Keeps the connection indicator disconnected when circuit-list REST refreshes succeed but the live WebSocket is down; shows connecting/reconnecting status and the snapshot time.
package/README.md CHANGED
@@ -195,6 +195,24 @@ Full detail is written on change (plus the last unchanged sample before a change
195
195
  - `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
196
  - `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
197
 
198
+ ## Third-party switches
199
+
200
+ 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.
201
+
202
+ 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.
203
+ 2. In the configuration panel, under **Third-party switches**, choose that circuit and link it to:
204
+ - **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.
205
+ - **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.
206
+
207
+ 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:
208
+
209
+ - 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;
210
+ - **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;
211
+ - 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;
212
+ - 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.
213
+
214
+ 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).
215
+
198
216
  ## Installation
199
217
 
200
218
  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.
@@ -423,3 +441,34 @@ Only the installed path is stored in plugin configuration; the binary ZCF is not
423
441
  - **0.3.0-beta.5** — added explicit AC/DC source classification for downstream telemetry storage while retaining stable circuit-name paths and the diagnostics/status tooling.
424
442
 
425
443
  The beta is intended for extended real-world testing before a stable `0.3.0` release.
444
+
445
+ ## Fullscreen and tablet home-screen launch
446
+
447
+ Open `/signalk-czone/` directly in your browser. The **fullscreen icon** beside
448
+ the CZone / Boat Systems branding enters fullscreen; tap the icon again or use the browser's exit gesture
449
+ (or Escape on desktop) to leave. It appears only when the browser exposes the
450
+ fullscreen API. Embedded views may need permission from their parent page.
451
+
452
+ For a home-screen launch, open the app over **trusted HTTPS**, then use your
453
+ browser's **Install app** / **Add to Home Screen** option (Safari: Share → Add to
454
+ Home Screen). Compatible browsers launch CZone in standalone mode without the
455
+ usual address bar. Installation and fullscreen support vary by device/browser.
456
+ The manifest uses relative URLs, so it follows your server's hostname and port;
457
+ no boat IP address is hard-coded. Plain HTTP on a LAN IP may only offer a shortcut.
458
+
459
+ ### Set up HTTPS for a full-screen app
460
+
461
+ To launch CZone from the tablet or device's home screen without the URL bar and
462
+ browser controls, first set up trusted HTTPS on your Signal K server:
463
+
464
+ 1. Install and configure the **signalk-ssl** plugin.
465
+ 2. In Signal K **Server Settings**, turn on the **Use SSL** toggle and apply the settings. Restart Signal K if prompted.
466
+ 3. On each tablet or device, download the server's CA certificate (`ca.crt`). For example: [https://einstein.local:3443/signalk-ssl/ca.crt](https://einstein.local:3443/signalk-ssl/ca.crt). Replace `einstein.local` and port `3443` with your server's hostname and HTTPS port.
467
+ 4. Import/install `ca.crt` as a trusted CA certificate in the device's certificate settings. Enable trust for the certificate if the device requires a separate step.
468
+ 5. After the certificate installs successfully, you will most likely need to **reboot the tablet or device** before the browser recognises it.
469
+ 6. Open CZone over HTTPS, for example [https://einstein.local:3443/signalk-czone/](https://einstein.local:3443/signalk-czone/), and check that the browser trusts the connection. Use **Install app** / **Add to Home Screen**, then launch CZone from its new home-screen icon.
470
+
471
+ Certificate installation and app installation menus vary by device and browser.
472
+
473
+ The app still needs a live Signal K connection. No offline caching or service
474
+ worker is installed, and installing it does not grant circuit-write permission.
package/index.js CHANGED
@@ -9,6 +9,9 @@ const monitoring = require('./lib/monitoring')
9
9
  const { createTrends } = require('./lib/trends')
10
10
  const signalk = require('./lib/signalk')
11
11
  const confirmOffLib = require('./lib/confirm-off')
12
+ const { createBridge, bridgeEntries } = require('./lib/bridge')
13
+ const { createShellyDriver } = require('./lib/bridge/shelly')
14
+ const { createVictronDriver } = require('./lib/bridge/victron')
12
15
 
13
16
  const MAX_UPLOAD_BYTES = 1024 * 1024
14
17
  const CZONE_CONFIG_BLOCK_HEADER = 23
@@ -104,6 +107,49 @@ module.exports = function (app) {
104
107
  let nmeaReadyAt = null
105
108
  let lastNmeaOutput = null
106
109
  let runtimeState = new Map()
110
+
111
+ // Third-party switches (lib/bridge): a CZone circuit, normally a virtual
112
+ // circuit added for the purpose, linked to a switch that is not on the CZone
113
+ // network: a Victron switchable output on the GX's D-Bus (a Shelly added to
114
+ // the GX, a GX relay set to Manual, a Node-RED virtual switch) or a Shelly
115
+ // reached directly by IP. lib/bridge holds the linking rules and the drivers
116
+ // and has no CZone or Signal K code in it.
117
+ let bridgeBus = null
118
+ function getBridgeBus () {
119
+ if (bridgeBus) return bridgeBus
120
+ let dbus
121
+ try { dbus = require('dbus-native') } catch (_) { throw new Error('D-Bus support is not installed (only available on a Victron GX)') }
122
+ bridgeBus = dbus.systemBus()
123
+ if (bridgeBus && bridgeBus.connection && typeof bridgeBus.connection.on === 'function') {
124
+ bridgeBus.connection.on('error', err => { log(`Bridge D-Bus: ${err.message}`); closeBridgeBus() })
125
+ }
126
+ return bridgeBus
127
+ }
128
+ function closeBridgeBus () {
129
+ if (!bridgeBus) return
130
+ try { if (bridgeBus.connection && typeof bridgeBus.connection.end === 'function') bridgeBus.connection.end() } catch (_) {}
131
+ bridgeBus = null
132
+ }
133
+ const bridgeDrivers = { victron: createVictronDriver({ getBus: getBridgeBus }), shelly: createShellyDriver() }
134
+ const bridge = createBridge({
135
+ log: m => log(m),
136
+ readCircuit: name => {
137
+ const st = runtimeState.get(name)
138
+ return st && st.statusObserved && (st.state === 'ON' || st.state === 'OFF') ? st.state === 'ON' : undefined
139
+ },
140
+ writeCircuit: (name, on) => {
141
+ const c = mapping && mapping.circuits.find(x => x.name === name)
142
+ if (!c) throw new Error(`no circuit "${name}" in the CZone configuration`)
143
+ // A link cannot ask "are you sure?": a circuit nominated to confirm is
144
+ // never switched that way by a link, unless other apps are allowed to.
145
+ // Refused for good (not retried); the link shows why.
146
+ const refuse = text => { const err = new Error(text); err.permanent = true; throw err }
147
+ if (!on && confirmOff.has(c.name) && settings.confirmOffAllowElsewhere !== true) refuse('not switched off: this circuit asks before turning off')
148
+ if (on && confirmOn.has(c.name) && settings.confirmOnAllowElsewhere !== true) refuse('not switched on: this circuit asks before turning on')
149
+ sendCircuitState(c, on)
150
+ },
151
+ drivers: bridgeDrivers
152
+ })
107
153
  let monitoringRuntime = null
108
154
  // Trend recording of circuit current (see lib/trends).
109
155
  let trends = null
@@ -718,6 +764,13 @@ module.exports = function (app) {
718
764
  // bitmap. The runtime module/bit mapping is decoded from the ZCF status
719
765
  // table; it is intentionally not inferred from the primary ZCF
720
766
  // module/channel identity.
767
+ function startBridge () {
768
+ const entries = bridgeEntries(settings.bridges)
769
+ const known = entries.filter(e => mapping && mapping.circuits.some(c => c.name === e.circuit))
770
+ for (const e of entries) if (!known.includes(e)) log(`Third-party switch: circuit "${e.circuit}" is not in the CZone configuration; link ignored`)
771
+ bridge.start(known)
772
+ }
773
+
721
774
  function decodeCzoneCircuitStatus (frame) {
722
775
  if (!frame || frame.pgn !== 65284 || !frame.data || frame.data.length !== 8) return
723
776
  const data = frame.data
@@ -763,6 +816,7 @@ module.exports = function (app) {
763
816
  }
764
817
  }
765
818
 
819
+ bridge.onCircuit(circuit.name, enabled) // a linked switch follows at once
766
820
  const stateSource = signalk.nmea2000Source(frame.source, frame.pgn)
767
821
  const observedState = enabled ? 'ON' : 'OFF'
768
822
  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})`)
@@ -1221,6 +1275,23 @@ module.exports = function (app) {
1221
1275
  title: 'Let other apps turn those circuits off',
1222
1276
  default: false,
1223
1277
  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.'
1278
+ },
1279
+ bridges: {
1280
+ type: 'array',
1281
+ title: 'Third-party switches',
1282
+ 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.',
1283
+ default: [],
1284
+ items: {
1285
+ type: 'object',
1286
+ properties: {
1287
+ circuit: { type: 'string', title: 'CZone circuit' },
1288
+ target: { type: 'string', title: 'Linked to', enum: ['victron', 'shelly'], enumNames: ['A Victron output (through the GX)', 'A Shelly (directly, by IP address)'] },
1289
+ output: { type: 'string', title: 'Victron output (service#id)' },
1290
+ label: { type: 'string', title: 'Name' },
1291
+ host: { type: 'string', title: 'Shelly IP address' },
1292
+ channel: { type: 'number', title: 'Shelly channel', default: 0 }
1293
+ }
1294
+ }
1224
1295
  }
1225
1296
  }
1226
1297
  }),
@@ -1245,6 +1316,7 @@ module.exports = function (app) {
1245
1316
  log
1246
1317
  })
1247
1318
  trends.start()
1319
+ startBridge()
1248
1320
 
1249
1321
  const ready = (reason = 'nmea2000OutAvailable event') => {
1250
1322
  nmeaReady = true
@@ -1320,6 +1392,8 @@ module.exports = function (app) {
1320
1392
  monitoringRuntime = null
1321
1393
  if (trends) trends.stop()
1322
1394
  trends = null
1395
+ bridge.stop()
1396
+ closeBridgeBus()
1323
1397
  reassembler = null
1324
1398
  configFastPacket = null
1325
1399
  clearConfigTransfer()
@@ -1417,6 +1491,21 @@ module.exports = function (app) {
1417
1491
  })
1418
1492
  })
1419
1493
 
1494
+ // Third-party switches: link status, the GX's outputs, a Shelly test.
1495
+ router.get('/bridge/status', (_req, res) => res.json({ links: bridge.status() }))
1496
+ router.get('/bridge/outputs', (_req, res) => {
1497
+ bridgeDrivers.victron.list()
1498
+ .then(outputs => res.json({ available: true, outputs }))
1499
+ .catch(err => res.json({ available: false, outputs: [], detail: `No Victron GX found (${err.message})` }))
1500
+ })
1501
+ router.get('/bridge/shelly', (req, res) => {
1502
+ const host = String((req.query && req.query.host) || '').trim()
1503
+ if (!/^[\w.-]+(:\d{1,5})?$/.test(host)) return res.status(400).json({ ok: false, detail: 'Give the Shelly\'s IP address' })
1504
+ bridgeDrivers.shelly.status({ host, channel: Number(req.query.channel) || 0 })
1505
+ .then(st => res.json({ ok: typeof st.output === 'boolean', output: st.output, power: st.apower, voltage: st.voltage }))
1506
+ .catch(err => res.json({ ok: false, detail: err.message }))
1507
+ })
1508
+
1420
1509
  router.get('/configuration', (_req, res) => {
1421
1510
  res.json({
1422
1511
  source: settings.configurationSource || 'installedZcf',
@@ -1427,7 +1516,10 @@ module.exports = function (app) {
1427
1516
  networkRead: lastNetworkConfig ? { ...lastNetworkConfig, blocks: undefined } : null,
1428
1517
  nmeaReady,
1429
1518
  // The circuits offered under "Confirm before turning off".
1430
- circuitNames: confirmOffLib.choices({}, mapping ? mapping.circuits : []).map(n => n.value)
1519
+ circuitNames: confirmOffLib.choices({}, mapping ? mapping.circuits : []).map(n => n.value),
1520
+ // Every circuit, for linking a third-party switch (a virtual circuit
1521
+ // added for one may not be listed in the webapp).
1522
+ allCircuitNames: mapping ? mapping.circuits.map(c => c.name).sort((a, b) => a.localeCompare(b)) : []
1431
1523
  })
1432
1524
  })
1433
1525
 
@@ -1592,5 +1684,7 @@ module.exports = function (app) {
1592
1684
  }
1593
1685
  }
1594
1686
 
1687
+ // For tests: run one bridge poll now instead of waiting for its timer.
1688
+ Object.defineProperty(plugin, '_bridgePoll', { value: () => bridge.poll(), enumerable: false })
1595
1689
  return plugin
1596
1690
  }
@@ -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 }
@@ -0,0 +1,58 @@
1
+ 'use strict'
2
+
3
+ // A Shelly (Gen2 and later: Plus, Pro, PM Mini, 1PM, ...) reached directly on
4
+ // the network, through its local RPC API. No GX needed. Tested on a Shelly PM1
5
+ // on the test bench, 13 Oct 2026:
6
+ //
7
+ // GET http://<host>/rpc/Switch.GetStatus?id=<channel>
8
+ // -> {"id":0,"output":true,"apower":0.0,"voltage":239.1,"current":0.000, ...}
9
+ // GET http://<host>/rpc/Switch.Set?id=<channel>&on=true|false
10
+ //
11
+ // A Shelly with a password set refuses with 401; that is reported, not
12
+ // worked around (digest login is not supported yet).
13
+
14
+ const http = require('http')
15
+
16
+ const TIMEOUT_MS = 3000
17
+
18
+ // host: '192.168.1.204', or 'host:port' (a Shelly always uses 80; the port is
19
+ // for testing and for a Shelly behind a port forward).
20
+ function get (hostAndPort, pathAndQuery, { timeoutMs = TIMEOUT_MS, port = 80 } = {}) {
21
+ const m = /^(.+?)(?::(\d+))?$/.exec(String(hostAndPort))
22
+ const host = m[1]
23
+ if (m[2]) port = Number(m[2])
24
+ return new Promise((resolve, reject) => {
25
+ const req = http.get({ host, port, path: pathAndQuery, timeout: timeoutMs }, res => {
26
+ let body = ''
27
+ res.setEncoding('utf8')
28
+ res.on('data', c => { if (body.length < 65536) body += c })
29
+ res.on('end', () => {
30
+ if (res.statusCode === 401) return reject(new Error('the Shelly has a password set'))
31
+ if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`))
32
+ try { resolve(JSON.parse(body)) } catch (_) { reject(new Error('not a Shelly Gen2 reply')) }
33
+ })
34
+ })
35
+ req.on('timeout', () => req.destroy(new Error('no answer')))
36
+ req.on('error', reject)
37
+ })
38
+ }
39
+
40
+ function createShellyDriver ({ port = 80, timeoutMs = TIMEOUT_MS } = {}) {
41
+ const opts = { port, timeoutMs }
42
+ return {
43
+ describe: entry => `Shelly ${entry.host}${entry.channel ? ` channel ${entry.channel}` : ''}`,
44
+ async status (entry) {
45
+ return get(entry.host, `/rpc/Switch.GetStatus?id=${entry.channel || 0}`, opts)
46
+ },
47
+ async read (entry) {
48
+ const s = await get(entry.host, `/rpc/Switch.GetStatus?id=${entry.channel || 0}`, opts)
49
+ if (typeof s.output !== 'boolean') throw new Error('no switch on that channel')
50
+ return s.output
51
+ },
52
+ async write (entry, on) {
53
+ await get(entry.host, `/rpc/Switch.Set?id=${entry.channel || 0}&on=${on ? 'true' : 'false'}`, opts)
54
+ }
55
+ }
56
+ }
57
+
58
+ module.exports = { createShellyDriver }
@@ -0,0 +1,115 @@
1
+ 'use strict'
2
+
3
+ // A Victron switchable output, on the GX's D-Bus: anything Venus OS lists in
4
+ // its switch pane, whatever kind of service carries it. Tested on the bench,
5
+ // 13 Oct 2026, with a Shelly PM1 added to the Cerbo, which appears as
6
+ //
7
+ // com.victronenergy.acload.shelly_28372F37CD6C_0
8
+ // /CustomName 'Power Board'
9
+ // /SwitchableOutput/0/State 1 (on) / 0 (off), writable
10
+ // /SwitchableOutput/0/Settings/CustomName 'Power Board'
11
+ // /SwitchableOutput/0/Settings/Type 1 (on/off)
12
+ //
13
+ // The service name carries the device's MAC address, so it does not change
14
+ // when the output is renamed; the name is shown, the service is stored.
15
+ //
16
+ // The GX's own relays are switchable outputs of com.victronenergy.system
17
+ // (Compass Rose, Venus OS 3.80, 15 Oct 2026):
18
+ //
19
+ // /SwitchableOutput/0/Name 'GX internal relay 1'
20
+ // /SwitchableOutput/0/Settings/Function 0 (alarm relay)
21
+ // /SwitchableOutput/1/Name 'GX internal relay 2'
22
+ // /SwitchableOutput/1/Settings/Function 2 (manual)
23
+ //
24
+ // A relay the GX uses for something (alarm, generator, tank pump,
25
+ // temperature) must not be switched by anyone else: an output with a function
26
+ // is offered only when that function is Manual, and is never written
27
+ // otherwise, even if it was linked before its function was changed. An I/O
28
+ // extender's relays are expected to carry the same setting.
29
+
30
+ const OWN_SERVICE = 'com.victronenergy.switch.czone_circuits' // this plugin's own switch pane
31
+ const IFACE = 'com.victronenergy.BusItem'
32
+ const MANUAL = 2
33
+ const FUNCTIONS = { 0: 'alarm relay', 1: 'generator start/stop', 3: 'tank pump', 4: 'temperature relay' }
34
+ const ON_OFF = 1 // Settings/Type of an on/off output
35
+ const functionName = f => FUNCTIONS[f] || `function ${f}`
36
+
37
+ function errText (err) {
38
+ return Array.isArray(err) ? err.join(' ') : String((err && err.message) || err)
39
+ }
40
+
41
+ // dbus-native hands a variant over as [[signature], [value]].
42
+ function unwrap (v) {
43
+ if (Array.isArray(v) && v.length === 2 && Array.isArray(v[0]) && Array.isArray(v[1])) return v[1][0]
44
+ return v
45
+ }
46
+
47
+ function createVictronDriver ({ getBus } = {}) {
48
+ function call (msg) {
49
+ const bus = getBus()
50
+ return new Promise((resolve, reject) => {
51
+ bus.invoke(msg, (err, res) => err ? reject(new Error(errText(err))) : resolve(res))
52
+ })
53
+ }
54
+ const getValue = (service, path) => call({ destination: service, path, interface: IFACE, member: 'GetValue' }).then(unwrap)
55
+
56
+ const optional = (service, path) => getValue(service, path).then(v => (v === '' || v === null || v === undefined || Number.isNaN(Number(v)) ? undefined : Number(v)), () => undefined)
57
+ // Why an output may not be linked or switched, or null.
58
+ async function blocked (service, id) {
59
+ const f = await optional(service, `/SwitchableOutput/${id}/Settings/Function`)
60
+ if (f !== undefined && f !== MANUAL) return `used by the GX as ${functionName(f)}`
61
+ const t = await optional(service, `/SwitchableOutput/${id}/Settings/Type`)
62
+ if (t !== undefined && t !== ON_OFF) return 'not an on/off output'
63
+ return null
64
+ }
65
+
66
+ async function read (entry) {
67
+ const v = await getValue(entry.service, `/SwitchableOutput/${entry.output}/State`)
68
+ if (typeof v !== 'number') throw new Error('no switchable output there')
69
+ return v !== 0
70
+ }
71
+
72
+ async function write (entry, on) {
73
+ const why = await blocked(entry.service, entry.output)
74
+ if (why) throw new Error(`not switched: ${why} (set it to Manual on the GX to use it here)`)
75
+ const r = await call({ destination: entry.service, path: `/SwitchableOutput/${entry.output}/State`, interface: IFACE, member: 'SetValue', signature: 'v', body: [['i', on ? 1 : 0]] })
76
+ if (typeof r === 'number' && r !== 0) throw new Error(`the GX refused (${r})`)
77
+ }
78
+
79
+ // Every switchable output on the GX, for the settings page.
80
+ async function list () {
81
+ const names = await call({ destination: 'org.freedesktop.DBus', path: '/org/freedesktop/DBus', interface: 'org.freedesktop.DBus', member: 'ListNames' })
82
+ const services = (names || []).filter(n => /^com\.victronenergy\./.test(n) && n !== OWN_SERVICE && !/^com\.victronenergy\.(settings|logger|platform|modbustcp)$/.test(n))
83
+ const out = []
84
+ for (const service of services) {
85
+ let ids = []
86
+ try {
87
+ const items = await call({ destination: service, path: '/', interface: IFACE, member: 'GetItems' })
88
+ const paths = Array.isArray(items) ? items.map(i => i[0]) : Object.keys(items || {})
89
+ ids = [...new Set(paths.map(p => /^\/SwitchableOutput\/([^/]+)\/State$/.exec(p)).filter(Boolean).map(m => m[1]))]
90
+ } catch (_) {
91
+ // No GetItems: try the usual first channels.
92
+ for (const id of ['0', '1', '2', '3']) {
93
+ try { if (typeof await getValue(service, `/SwitchableOutput/${id}/State`) === 'number') ids.push(id) } catch (_) {}
94
+ }
95
+ }
96
+ for (const id of ids) {
97
+ const text = async p => { try { const v = await getValue(service, p); return typeof v === 'string' && v.trim() ? v.trim() : '' } catch (_) { return '' } }
98
+ const name = (await text(`/SwitchableOutput/${id}/Settings/CustomName`)) || (await text('/CustomName')) ||
99
+ [await text('/ProductName'), await text(`/SwitchableOutput/${id}/Name`)].filter(Boolean).join(' ') || service
100
+ const why = await blocked(service, id)
101
+ out.push({ value: `${service}#${id}`, label: name, service, output: id, available: !why, reason: why || undefined })
102
+ }
103
+ }
104
+ return out.sort((a, b) => a.label.localeCompare(b.label))
105
+ }
106
+
107
+ return {
108
+ describe: entry => (entry.label ? `${entry.label} (GX)` : `${entry.service} output ${entry.output}`),
109
+ read,
110
+ write,
111
+ list
112
+ }
113
+ }
114
+
115
+ module.exports = { createVictronDriver, OWN_SERVICE }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-czone",
3
- "version": "0.3.0-beta.12",
3
+ "version": "0.3.0-beta.14",
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,11 +42,14 @@
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/trends.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/bridge.test.js && node test/trends.test.js && node --require ./test/helpers/gx-low-space.cjs test/trends.test.js"
46
46
  },
47
47
  "dependencies": {
48
48
  "signalk-czone-zcf": "github:mattsmitchell/signalk-czone-zcf#4991170ac20a3506bfcc1131b2c7dd025c4a983c"
49
49
  },
50
+ "optionalDependencies": {
51
+ "dbus-native": "^0.4.0"
52
+ },
50
53
  "repository": {
51
54
  "type": "git",
52
55
  "url": "git+https://github.com/mattsmitchell/signalk-czone.git"
@@ -0,0 +1,36 @@
1
+ /* Fullscreen is optional: the ordinary page and home-screen launch work without it. */
2
+ (() => {
3
+ const button = document.getElementById('fullscreenToggle');
4
+ const root = document.documentElement;
5
+ const request = root.requestFullscreen || root.webkitRequestFullscreen;
6
+ const exit = document.exitFullscreen || document.webkitExitFullscreen;
7
+ const enabled = document.fullscreenEnabled != null ? document.fullscreenEnabled : document.webkitFullscreenEnabled;
8
+ if (!button || !request || !exit || enabled === false) return;
9
+
10
+ const current = () => document.fullscreenElement || document.webkitFullscreenElement;
11
+ const update = () => {
12
+ const active = !!current();
13
+ const label = active ? 'Exit fullscreen' : 'Show CZone fullscreen';
14
+ button.setAttribute('aria-label', label);
15
+ button.setAttribute('aria-pressed', String(active));
16
+ button.title = label;
17
+ };
18
+ button.hidden = false;
19
+ update();
20
+ document.addEventListener('fullscreenchange', update);
21
+ document.addEventListener('webkitfullscreenchange', update);
22
+ button.addEventListener('click', async () => {
23
+ button.disabled = true;
24
+ try {
25
+ // Invoke directly in the tap handler; browsers require user activation.
26
+ if (current()) await exit.call(document);
27
+ else await request.call(root);
28
+ } catch (error) {
29
+ console.warn('[CZone UI] Fullscreen unavailable', error);
30
+ alert('Fullscreen is unavailable in this browser or embedded view. Try opening CZone directly in a browser, or adding it to your home screen.');
31
+ } finally {
32
+ button.disabled = false;
33
+ update();
34
+ }
35
+ });
36
+ })();
Binary file
Binary file
Binary file
package/public/index.html CHANGED
@@ -5,7 +5,10 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
6
  <meta name="theme-color" content="#071321">
7
7
  <link rel="icon" type="image/png" href="icon.png">
8
- <link rel="apple-touch-icon" href="icon.png">
8
+ <link rel="apple-touch-icon" sizes="180x180" href="icon-180.png">
9
+ <link rel="manifest" href="manifest.webmanifest">
10
+ <meta name="apple-mobile-web-app-capable" content="yes">
11
+ <meta name="apple-mobile-web-app-title" content="CZone">
9
12
  <title>CZone Circuits</title>
10
13
  <style>
11
14
  :root {
@@ -39,7 +42,16 @@
39
42
  .sidebar, .rightbar { background: rgba(5, 16, 29, .78); backdrop-filter: blur(18px); border-color: var(--line); }
40
43
  .sidebar { border-right: 1px solid var(--line); padding: 22px 16px; position: sticky; top: 0; height: 100vh; overflow-y: auto; }
41
44
  .rightbar { border-left: 1px solid var(--line); padding: 22px 16px; position: sticky; top: 0; height: 100vh; overflow-y: auto; }
42
- .brand { display:flex; align-items:center; gap:11px; padding: 2px 10px 25px; }
45
+ .brand { display:flex; flex-wrap:nowrap; align-items:center; gap:11px; padding: 2px 10px 25px; }
46
+ .fullscreen-toggle { margin-left:auto; flex:0 0 44px; width:44px; height:44px; display:grid; place-items:center; padding:10px; border:1px solid rgba(56,169,255,.25); border-radius:12px; background:linear-gradient(145deg,rgba(24,213,232,.12),rgba(56,169,255,.05)); color:var(--cyan); cursor:pointer; transition:background .15s, border-color .15s; }
47
+ .fullscreen-toggle:hover { background:rgba(24,213,232,.18); border-color:var(--cyan); }
48
+ .fullscreen-toggle:disabled { opacity:.55; cursor:wait; }
49
+ .fullscreen-toggle svg { width:22px; height:22px; }
50
+ .fullscreen-toggle .fullscreen-collapse, .fullscreen-toggle[aria-pressed="true"] .fullscreen-expand { display:none; }
51
+ .fullscreen-toggle[aria-pressed="true"] .fullscreen-collapse { display:inline; }
52
+ .fullscreen-toggle[hidden] { display:none; }
53
+ .fullscreen-toggle:focus-visible { outline:2px solid var(--cyan); outline-offset:2px; }
54
+ body { padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); }
43
55
  .brand-mark { width:38px; height:38px; display:block; object-fit:contain; border-radius:10px; box-shadow: 0 8px 25px rgba(31,154,255,.24); }
44
56
  .brand strong { display:block; font-size:17px; letter-spacing:.2px; }
45
57
  .brand span { color:var(--muted); font-size:12px; }
@@ -219,13 +231,44 @@
219
231
  .mode-name > span:first-child { min-width:0; overflow-wrap:anywhere; }
220
232
  .mode-name > span:last-child { display:none; }
221
233
  }
234
+
235
+ /* Browsers without gap in flex layouts (before Chrome 84; a Navico
236
+ chartplotter is Chrome 69) get the same spacing from margins. */
237
+ .no-flexgap .brand > * + *, .no-flexgap .nav button > * + *, .no-flexgap .category-nav button > * + *, .no-flexgap .category-card > * + *, .no-flexgap .stat > * + * { margin-left:11px; }
238
+ .no-flexgap .section-head > * + * { margin-left:10px; }
239
+ .no-flexgap .status > * + *, .no-flexgap .connection > * + * { margin-left:7px; }
240
+ .no-flexgap .status-dot { margin-right:7px; }
241
+ .no-flexgap .nav-icon { margin-right:11px; }
242
+ .no-flexgap .control > * + * { margin-left:9px; }
243
+ .no-flexgap .range-wrap > * + *, .no-flexgap .mode-name > * + * { margin-left:8px; }
244
+ .no-flexgap .trend-custom label > * + * { margin-left:6px; }
245
+ .no-flexgap .tags > * { margin:0 4px 4px 0; }
246
+ .no-flexgap .trend-controls > *, .no-flexgap .trend-custom > * { margin:0 10px 10px 0; }
247
+ .no-flexgap .trend-stats > * { margin-right:28px; }
248
+ .no-flexgap .fullscreen-toggle, .no-flexgap button > .nav-count, .no-flexgap .section-head .search, .no-flexgap .trend-panel .section-head .trend-close { margin-left:auto; }
249
+ @media (max-width: 780px) { .no-flexgap .section-head .sort { margin-left:auto; } }
222
250
  </style>
251
+ <script>
252
+ (() => {
253
+ const d = document.createElement('div');
254
+ d.style.cssText = 'display:flex;flex-direction:column;gap:1px;position:absolute;visibility:hidden';
255
+ d.appendChild(document.createElement('div')); d.appendChild(document.createElement('div'));
256
+ document.documentElement.appendChild(d);
257
+ if (d.scrollHeight !== 1) document.documentElement.classList.add('no-flexgap');
258
+ d.remove();
259
+ })();
260
+ </script>
223
261
  </head>
224
262
  <body>
225
263
  <div class="app">
226
264
  <div class="leftwell">
227
265
  <aside class="sidebar">
228
- <div class="brand"><img class="brand-mark" src="icon.png" alt="CZone" width="38" height="38"><div><strong>CZone</strong><span>Boat Systems</span></div></div>
266
+ <div class="brand"><img class="brand-mark" src="icon.png" alt="CZone" width="38" height="38"><div><strong>CZone</strong><span>Boat Systems</span></div><button id="fullscreenToggle" class="fullscreen-toggle" type="button" aria-label="Show CZone fullscreen" title="Show CZone fullscreen" aria-pressed="false" hidden>
267
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
268
+ <path class="fullscreen-expand" d="M8 3H3v5m13-5h5v5M3 16v5h5m13-5v5h-5"/>
269
+ <path class="fullscreen-collapse" d="M3 8h5V3m8 0v5h5M8 21v-5H3m18 0h-5v5"/>
270
+ </svg>
271
+ </button></div>
229
272
  <div class="nav" id="mainNav"></div>
230
273
  <div class="separator"></div>
231
274
  <div class="section-label">Categories</div>
@@ -260,6 +303,7 @@
260
303
  </main>
261
304
 
262
305
  </div>
306
+ <script src="fullscreen.js"></script>
263
307
  <script>
264
308
  (() => {
265
309
  const API = '/plugins/signalk-czone';
@@ -304,7 +348,7 @@
304
348
  const favoritesKey = 'signalk-czone-circuits:favorites';
305
349
  let favorites = new Set(JSON.parse(localStorage.getItem(favoritesKey) || '[]'));
306
350
 
307
- const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
351
+ const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
308
352
  const nameKey = c => c.slug || c.name;
309
353
  const stateOf = c => c.state && c.state.state === 'ON';
310
354
  const percentOf = c => c.state && Number.isFinite(c.state.percent) ? c.state.percent : null;
@@ -386,7 +430,7 @@
386
430
  const cats = subCategoryList(c).slice(0,3).map(x => `<span class="tag cat" role="img" tabindex="0" style="--cat:${color(x)}" title="${esc(x)}" aria-label="${esc(x)} category">${icon(x)}</span>`).join('');
387
431
  const disabled = p && p.kind === 'state';
388
432
  const toggle = `<button type="button" class="toggle ${on?'on':'off'} ${disabled?'pending':''}" data-circuit-slug="${esc(c.slug)}" ${disabled?'disabled':''} aria-busy="${disabled?'true':'false'}" aria-label="Turn ${on?'off':'on'} ${esc(c.name)}" onclick="toggleCircuit('${encodeURIComponent(c.name)}',${!on})">${disabled?'Sending…':(on && current ? current : (on?'ON':'OFF'))}</button>`;
389
- const control = dim ? `<div class="control"><div class="range-wrap"><input aria-label="${esc(c.name)} brightness" type="range" min="1" max="100" value="${Math.max(1,Math.round(pct ?? 100))}" oninput="previewPercent(this)" onchange="setLevel('${encodeURIComponent(c.name)}',this.value)"><span class="percent">${pct == null ? '—' : Math.round(pct)+'%'}</span></div>${toggle}</div>` : `<div class="control">${toggle}</div>`;
433
+ const control = dim ? `<div class="control"><div class="range-wrap"><input aria-label="${esc(c.name)} brightness" type="range" min="1" max="100" value="${Math.max(1,Math.round(pct == null ? 100 : pct))}" oninput="previewPercent(this)" onchange="setLevel('${encodeURIComponent(c.name)}',this.value)"><span class="percent">${pct == null ? '—' : Math.round(pct)+'%'}</span></div>${toggle}</div>` : `<div class="control">${toggle}</div>`;
390
434
  const sending = p ? `<span class="sending">Sending…</span>` : '';
391
435
  return `<div class="circuit"><button class="fav ${fav?'on':''}" title="${fav?'Remove from':'Add to'} favorites" onclick="toggleFavorite('${esc(nameKey(c)).replace(/'/g,"\\'")}')">${fav?'★':'☆'}</button><div class="circuit-name"><strong>${esc(c.name)}${askMark(c)}</strong><small>${esc((categoryData(c).master || [])[0] || 'CZone')} · module ${Number(c.module).toString(16).padStart(2,'0').toUpperCase()} / ch ${c.channel}</small></div><div class="tags"><span class="tag">${esc(c.masterCategory || 'CZone')}</span>${cats}</div><div><div class="status ${on?'on':''}"><span class="status-dot"></span>${on?'ON':'OFF'} ${sending}</div>${control}</div>${status.trendsEnabled === true ? `<button type="button" class="arrow" title="Current trend" aria-label="Current trend for ${esc(c.name)}" onclick="openTrend('${encodeURIComponent(c.slug)}')">›</button>` : '<span class="arrow">›</span>'}</div>`;
392
436
  }
@@ -0,0 +1,25 @@
1
+ {
2
+ "id": "./",
3
+ "name": "CZone Boat Systems",
4
+ "short_name": "CZone",
5
+ "description": "Monitor and control your boat\u2019s CZone electrical systems through Signal K.",
6
+ "start_url": "./",
7
+ "scope": "./",
8
+ "display": "standalone",
9
+ "background_color": "#06101d",
10
+ "theme_color": "#071321",
11
+ "icons": [
12
+ {
13
+ "src": "icon-192.png",
14
+ "sizes": "192x192",
15
+ "type": "image/png",
16
+ "purpose": "any"
17
+ },
18
+ {
19
+ "src": "icon-512.png",
20
+ "sizes": "512x512",
21
+ "type": "image/png",
22
+ "purpose": "any"
23
+ }
24
+ ]
25
+ }
@@ -35,6 +35,63 @@ var signalk_czone = (function () {
35
35
  var trend = trendState[0]
36
36
  var setTrend = trendState[1]
37
37
 
38
+ // Third-party switches (lib/bridge)
39
+ var bridgeKindState = React.useState('victron'); var bridgeKind = bridgeKindState[0]; var setBridgeKind = bridgeKindState[1]
40
+ var bridgeCircuitState = React.useState(''); var bridgeCircuit = bridgeCircuitState[0]; var setBridgeCircuit = bridgeCircuitState[1]
41
+ var bridgeOutputState = React.useState(''); var bridgeOutput = bridgeOutputState[0]; var setBridgeOutput = bridgeOutputState[1]
42
+ var bridgeHostState = React.useState(''); var bridgeHost = bridgeHostState[0]; var setBridgeHost = bridgeHostState[1]
43
+ var bridgeChannelState = React.useState('0'); var bridgeChannel = bridgeChannelState[0]; var setBridgeChannel = bridgeChannelState[1]
44
+ var outputsState = React.useState(null); var outputs = outputsState[0]; var setOutputs = outputsState[1]
45
+ var shellyTestState = React.useState(''); var shellyTest = shellyTestState[0]; var setShellyTest = shellyTestState[1]
46
+ var linkStatusState = React.useState([]); var linkStatus = linkStatusState[0]; var setLinkStatus = linkStatusState[1]
47
+
48
+ function bridgeList () {
49
+ return (Array.isArray(configuration.bridges) ? configuration.bridges : []).filter(function (b) { return b && b.circuit })
50
+ }
51
+ function loadOutputs () {
52
+ setOutputs({ loading: true, outputs: [] })
53
+ fetch('/plugins/signalk-czone/bridge/outputs', { credentials: 'same-origin', cache: 'no-store' })
54
+ .then(function (r) { return r.json() })
55
+ .then(function (v) { setOutputs(v) })
56
+ .catch(function (err) { setOutputs({ available: false, outputs: [], detail: err.message }) })
57
+ }
58
+ function loadLinkStatus () {
59
+ fetch('/plugins/signalk-czone/bridge/status', { credentials: 'same-origin', cache: 'no-store' })
60
+ .then(function (r) { return r.json() })
61
+ .then(function (v) { setLinkStatus(Array.isArray(v.links) ? v.links : []) })
62
+ .catch(function () {})
63
+ }
64
+ React.useEffect(function () {
65
+ loadOutputs(); loadLinkStatus()
66
+ var t = setInterval(loadLinkStatus, 3000)
67
+ return function () { clearInterval(t) }
68
+ }, [])
69
+ function testShelly () {
70
+ setShellyTest('Asking the Shelly…')
71
+ fetch('/plugins/signalk-czone/bridge/shelly?host=' + encodeURIComponent(bridgeHost.trim()) + '&channel=' + encodeURIComponent(bridgeChannel), { credentials: 'same-origin', cache: 'no-store' })
72
+ .then(function (r) { return r.json() })
73
+ .then(function (v) { setShellyTest(v.ok ? 'Found: switch is ' + (v.output ? 'ON' : 'OFF') + (typeof v.power === 'number' ? ' · ' + v.power + ' W' : '') : 'Not found: ' + (v.detail || 'no answer')) })
74
+ .catch(function (err) { setShellyTest('Not found: ' + err.message) })
75
+ }
76
+ function addBridge () {
77
+ var row
78
+ if (bridgeKind === 'victron') {
79
+ var o = (outputs && outputs.outputs || []).filter(function (x) { return x.value === bridgeOutput })[0]
80
+ if (!o) return
81
+ row = { circuit: bridgeCircuit, target: 'victron', output: o.value, label: o.label }
82
+ } else {
83
+ row = { circuit: bridgeCircuit, target: 'shelly', host: bridgeHost.trim(), channel: Number(bridgeChannel) || 0 }
84
+ }
85
+ persist({ bridges: bridgeList().concat([row]) })
86
+ setBridgeCircuit(''); setBridgeOutput(''); setShellyTest('')
87
+ }
88
+ function removeBridge (index) {
89
+ persist({ bridges: bridgeList().filter(function (_b, i) { return i !== index }) })
90
+ }
91
+ function bridgeTarget (b) {
92
+ return b.target === 'victron' ? (b.label || b.output) + ' (Victron output, through the GX)' : 'Shelly at ' + b.host + (b.channel ? ' channel ' + b.channel : '') + ' (directly)'
93
+ }
94
+
38
95
  function loadConfiguration () {
39
96
  return fetch('/plugins/signalk-czone/configuration', { credentials: 'same-origin', cache: 'no-store' })
40
97
  .then(function (r) { if (!r.ok) throw new Error('Could not load CZone configuration status'); return r.json() })
@@ -335,6 +392,61 @@ var signalk_czone = (function () {
335
392
  React.createElement('div', { style: { marginTop: 6, fontSize: 12 } }, '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.')
336
393
  ),
337
394
 
395
+ React.createElement('div', { style: { marginBottom: 14, padding: 12, border: '1px solid #ccc', borderRadius: 6 } },
396
+ React.createElement('strong', null, 'Third-party switches'),
397
+ React.createElement('div', { style: { marginTop: 6, fontSize: 12 } }, 'Switch something that is not on the CZone network from a CZone display or keypad: a Shelly, a GX relay set to Manual, or any output in the Victron switch pane. In the CZone configuration add a virtual circuit for it and put it on the display, then link that circuit here. The switch follows the CZone circuit, and the circuit follows the switch when it is switched from anywhere else (that needs Enable CZone read/write control). Nothing is switched when the plugin starts: if the two differ, the link shows out of step until one is switched. A circuit set to confirm before turning on or off is never switched that way by a link.'),
398
+ bridgeList().map(function (b, i) {
399
+ var st = linkStatus.filter(function (l) { return l.circuit === b.circuit })[0]
400
+ var state = !st ? '' : st.error ? ' · ' + st.error : st.inStep === true ? ' · in step (' + (st.device ? 'ON' : 'OFF') + ')' : st.inStep === false ? ' · out of step' : ' · waiting for both sides'
401
+ return React.createElement('div', { key: b.circuit + ':' + i, style: { marginTop: 8 } },
402
+ React.createElement('span', { style: { fontWeight: 600 } }, b.circuit),
403
+ React.createElement('span', null, ' \u2194 ' + bridgeTarget(b)),
404
+ React.createElement('span', { style: { fontSize: 12, color: st && st.error ? '#b00' : '#555' } }, state + ' '),
405
+ React.createElement('button', { type: 'button', disabled: busy, onClick: function () { removeBridge(i) } }, 'Remove')
406
+ )
407
+ }),
408
+ React.createElement('div', { style: { marginTop: 12 } },
409
+ React.createElement('label', null, 'CZone circuit ',
410
+ React.createElement('select', { value: bridgeCircuit, disabled: busy, onChange: function (e) { setBridgeCircuit(e.target.value) } },
411
+ [React.createElement('option', { key: '', value: '' }, data && Array.isArray(data.allCircuitNames) && data.allCircuitNames.length ? 'Choose a circuit' : 'Load a CZone configuration first')].concat(
412
+ (data && Array.isArray(data.allCircuitNames) ? data.allCircuitNames : [])
413
+ .filter(function (n) { return !bridgeList().some(function (b) { return b.circuit === n }) })
414
+ .map(function (n) { return React.createElement('option', { key: n, value: n }, n) })
415
+ )
416
+ )
417
+ ),
418
+ React.createElement('label', { style: { marginLeft: 12 } }, 'linked to ',
419
+ React.createElement('select', { value: bridgeKind, disabled: busy, onChange: function (e) { setBridgeKind(e.target.value) } },
420
+ React.createElement('option', { value: 'victron' }, 'a Victron output (through the GX)'),
421
+ React.createElement('option', { value: 'shelly' }, 'a Shelly (directly, by IP address)')
422
+ )
423
+ )
424
+ ),
425
+ bridgeKind === 'victron'
426
+ ? React.createElement('div', { style: { marginTop: 8 } },
427
+ React.createElement('select', { value: bridgeOutput, disabled: busy || !outputs || outputs.loading || !(outputs.outputs || []).length, onChange: function (e) { setBridgeOutput(e.target.value) } },
428
+ [React.createElement('option', { key: '', value: '' }, !outputs || outputs.loading ? 'Looking for Victron outputs…' : (outputs.outputs || []).length ? 'Choose an output' : (outputs.detail || 'No switchable outputs found on the GX'))].concat(
429
+ (outputs && outputs.outputs ? outputs.outputs : []).map(function (o) { return React.createElement('option', { key: o.value, value: o.value, disabled: o.available === false }, o.label + (o.available === false ? ' (' + o.reason + ')' : '')) })
430
+ )
431
+ ),
432
+ React.createElement('button', { type: 'button', style: { marginLeft: 8 }, disabled: busy, onClick: loadOutputs }, 'Look again')
433
+ )
434
+ : React.createElement('div', { style: { marginTop: 8 } },
435
+ React.createElement('label', null, 'IP address ',
436
+ React.createElement('input', { type: 'text', value: bridgeHost, placeholder: '192.168.1.204', disabled: busy, onChange: function (e) { setBridgeHost(e.target.value); setShellyTest('') }, style: { width: 140 } })
437
+ ),
438
+ React.createElement('label', { style: { marginLeft: 10 } }, 'channel ',
439
+ React.createElement('input', { type: 'number', min: 0, value: bridgeChannel, disabled: busy, onChange: function (e) { setBridgeChannel(e.target.value) }, style: { width: 50 } })
440
+ ),
441
+ React.createElement('button', { type: 'button', style: { marginLeft: 8 }, disabled: busy || !bridgeHost.trim(), onClick: testShelly }, 'Test'),
442
+ shellyTest ? React.createElement('span', { style: { marginLeft: 8, fontSize: 12 } }, shellyTest) : null,
443
+ React.createElement('div', { style: { marginTop: 4, fontSize: 12 } }, 'Shelly Gen2 or later, with no password. Reserve its address in the router so it does not change.')
444
+ ),
445
+ React.createElement('div', { style: { marginTop: 8 } },
446
+ React.createElement('button', { type: 'button', disabled: busy || !bridgeCircuit || (bridgeKind === 'victron' ? !bridgeOutput : !bridgeHost.trim()), onClick: addBridge }, 'Link')
447
+ )
448
+ ),
449
+
338
450
  React.createElement('div', { style: { marginBottom: 14, padding: 12, border: '1px solid #ccc', borderRadius: 6 } },
339
451
  React.createElement('strong', null, 'Trends'),
340
452
  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.'),