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 +11 -0
- package/README.md +49 -0
- package/index.js +95 -1
- package/lib/bridge/index.js +226 -0
- package/lib/bridge/shelly.js +58 -0
- package/lib/bridge/victron.js +115 -0
- package/package.json +5 -2
- package/public/fullscreen.js +36 -0
- package/public/icon-180.png +0 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/index.html +49 -5
- package/public/manifest.webmanifest +25 -0
- package/public/remoteEntry.js +112 -0
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.
|
|
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
|
|
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
|
|
351
|
+
const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
|
|
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
|
+
}
|
package/public/remoteEntry.js
CHANGED
|
@@ -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.'),
|