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