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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
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
+
3
10
  ## 0.3.0-beta.14
4
11
 
5
12
  - Adds bidirectional CZone links to Shelly Gen2+ switches and Victron GX outputs, with startup, confirmation and relay-function safeguards.
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.
@@ -215,7 +237,7 @@ The switch is read every 2 s; a CZone change is acted on as its status message a
215
237
 
216
238
  ## Installation
217
239
 
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.
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.
219
241
 
220
242
  ### Signal K App Store
221
243
 
@@ -239,11 +261,11 @@ npm install signalk-czone@latest --omit=dev --ignore-scripts
239
261
 
240
262
  Restart Signal K and follow the configuration steps above. For Docker or appliance installations, use the corresponding container/configuration directory and restart method.
241
263
 
242
- For installation from a maintainer-provided npm tarball, run `npm install /path/to/signalk-czone-0.3.0-beta.9.tgz --omit=dev --ignore-scripts` from the same configuration directory. Its pinned parser dependency must already be published to npm. Do not simply extract the plugin into `node_modules`: that does not install dependencies.
264
+ For installation from a maintainer-provided npm tarball, run `npm install /path/to/signalk-czone-0.3.0-beta.15.tgz --omit=dev --ignore-scripts` from the same configuration directory. Its pinned parser dependency must already be published to npm. Do not simply extract the plugin into `node_modules`: that does not install dependencies.
243
265
 
244
266
  ### Publishing (maintainers)
245
267
 
246
- Publish `signalk-czone-zcf@0.1.0-beta.1` first. Then run `npm install`, `npm test`, and `npm pack --dry-run` in this repository, verify the runtime files and App Store assets, and run `npm publish --tag latest` after npm authentication.
268
+ Publish `signalk-czone-zcf@0.1.0-beta.2` first. Then run `npm install`, `npm test`, and `npm pack --dry-run` in this repository, verify the runtime files and App Store assets, and run `npm publish --tag latest` after npm authentication.
247
269
 
248
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.
249
271
 
@@ -442,6 +464,15 @@ Only the installed path is stored in plugin configuration; the binary ZCF is not
442
464
 
443
465
  The beta is intended for extended real-world testing before a stable `0.3.0` release.
444
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
+
445
476
  ## Fullscreen and tablet home-screen launch
446
477
 
447
478
  Open `/signalk-czone/` directly in your browser. The **fullscreen icon** beside
package/index.js CHANGED
@@ -3,6 +3,7 @@
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')
@@ -171,6 +172,10 @@ module.exports = function (app) {
171
172
  // Circuits nominated to confirm before switching, by circuit name.
172
173
  let confirmOff = new Set()
173
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()
174
179
 
175
180
  const configRoot = app.config && app.config.configPath
176
181
  ? path.join(app.config.configPath, 'plugin-config-data')
@@ -213,12 +218,16 @@ module.exports = function (app) {
213
218
  function loadConfiguredZcf () {
214
219
  if (!fs.existsSync(zcfPath())) {
215
220
  mapping = null
221
+ switchInputs = []
222
+ inputState = new Map()
216
223
  return null
217
224
  }
218
225
  mapping = zcf.load(zcfPath())
219
226
  mapping.commandDeviceId = selectCommandDeviceId(mapping)
220
227
  publishedCircuitValues.clear()
221
228
  monitoringRuntime = monitoring.createRuntime(mapping.meters || [])
229
+ switchInputs = inputs.switchInputs(mapping)
230
+ inputState = new Map(switchInputs.map(i => [i.path, { state: null, since: null }]))
222
231
  runtimeState = new Map(mapping.circuits.map(c => [c.name, {
223
232
  state: null,
224
233
  percent: null,
@@ -892,6 +901,25 @@ module.exports = function (app) {
892
901
  }
893
902
  }
894
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
+
895
923
  function handleRawFrame (line) {
896
924
  const frame = nmea.parseRawLine(line)
897
925
  if (!frame) return
@@ -901,7 +929,12 @@ module.exports = function (app) {
901
929
  handleConfigFastPacket(frame)
902
930
  return
903
931
  }
932
+ if (frame.pgn === inputs.INPUT_PGN) {
933
+ decodeSwitchInputs(frame)
934
+ return
935
+ }
904
936
  if (frame.pgn === 65284) {
937
+ decodeSwitchInputs(frame)
905
938
  decodeCzoneCircuitStatus(frame)
906
939
  return
907
940
  }
@@ -1401,6 +1434,8 @@ module.exports = function (app) {
1401
1434
  mapping = null
1402
1435
  runtimeState.clear()
1403
1436
  publishedCircuitValues.clear()
1437
+ switchInputs = []
1438
+ inputState = new Map()
1404
1439
  // Signal K removes PUT handlers when the plugin stops. The plugin
1405
1440
  // factory can be reused on a subsequent enable, so allow start() to
1406
1441
  // register the handlers again instead of treating the stale paths as
@@ -1409,6 +1444,22 @@ module.exports = function (app) {
1409
1444
  },
1410
1445
 
1411
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
+
1412
1463
  router.get('/status', (_req, res) => {
1413
1464
  res.json({
1414
1465
  sendingEnabled: settings.allowCzoneWrite === true,
@@ -1547,11 +1598,9 @@ module.exports = function (app) {
1547
1598
  fs.copyFileSync(source, temp)
1548
1599
  const next = zcf.load(temp)
1549
1600
  try { fs.unlinkSync(temp) } catch (_) {}
1550
- const newSettings = {
1551
- allowCzoneWrite: settings.allowCzoneWrite === true,
1552
- configurationSource: 'networkCache',
1553
- networkConfigFile: filename
1554
- }
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 }
1555
1604
  const restartResult = await saveAndRestart(newSettings)
1556
1605
  res.json({ ok: true, file: filename, bytes: next.fileSize, vesselName: next.vesselName || null, configuration: newSettings, restartRequested: restartResult.restartRequested })
1557
1606
  } catch (err) { res.status(400).json({ ok: false, error: err.message }) }
@@ -1579,11 +1628,9 @@ module.exports = function (app) {
1579
1628
  savedAt: new Date().toISOString()
1580
1629
  }, null, 2))
1581
1630
  } catch (_) {}
1582
- const newSettings = {
1583
- allowCzoneWrite: settings.allowCzoneWrite === true,
1584
- configurationSource: 'installedZcf',
1585
- networkConfigFile: ''
1586
- }
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: '' }
1587
1634
  const restartResult = await saveAndRestart(newSettings)
1588
1635
  res.json({
1589
1636
  ok: true,
package/lib/inputs.js ADDED
@@ -0,0 +1,94 @@
1
+ 'use strict'
2
+
3
+ // CZone switch inputs: ON / OFF of each switch wired to a CZone module, from
4
+ // the module's own messages. The inputs and their wiring come from the ZCF's
5
+ // signal-input table (signalk-czone-zcf parseSignalInputs).
6
+ //
7
+ // PGN 65284 (0xFF04), module status, about every two seconds and on change:
8
+ // 27 99 <module dipswitch> <module kind> <bitmap, 32 bits, low byte first>
9
+ // PGN 65308 (0xFF1C), input message, sent on change:
10
+ // 27 99 <module dipswitch> <variant> <bitmap, 32 bits, low byte first>
11
+ //
12
+ // Where an input sits depends on the kind of module (its type in the ZCF):
13
+ //
14
+ // Signal Interface (0x0D). Test bench, 6 Oct 2026: module 2, five switches on
15
+ // inputs 0-4 closed one after another gave 02 00, 0A 00, 2A 00, AA 00, AA 02:
16
+ // input n (from 0) is bit 2n+1, in 65284 and in 65308 (variant 00). The
17
+ // message comes on change too, so a tap on a momentary switch is caught.
18
+ //
19
+ // COI (0x1F). SugarShack capture, 27 Sep 2026: a COI's 65284 carries its
20
+ // outputs only; its inputs are in 65308 (variant 03), numbered from 16 in
21
+ // the ZCF, input i at bit 2(i-16)+1. Module 1A input 19 -> 80 00, 1A input
22
+ // 20 -> 00 02, 1C input 17 -> 08 00.
23
+ //
24
+ // Control X PLUS (0x36). Compass Rose, 15 Oct 2026, each input operated twice:
25
+ // its 65284 has the outputs in bits 0-15 and the inputs above, input i (from
26
+ // 16 in the ZCF) at bit i when it switches to positive, bit i+1 when it
27
+ // switches to negative. Anchor Up (input 17, to +) bit 17; Ignition (24, to +)
28
+ // bit 24; High Bilge Water Alarm (18, to -) bit 19. Only one input to
29
+ // negative was seen. Its 65308 messages come in four variants (byte 3: 00,
30
+ // 01, 03, 06) and are not read.
31
+ //
32
+ // Other kinds: not decoded (no state rather than a guess).
33
+
34
+ const STATUS_PGN = 65284
35
+ const INPUT_PGN = 65308
36
+
37
+ const oddBits = first => input => 2 * (input - first) + 1
38
+ const LAYOUT = {
39
+ 0x0d: { name: 'Signal Interface', pgns: [STATUS_PGN, INPUT_PGN], first: 0, bit: oddBits(0) },
40
+ 0x1f: { name: 'COI', pgns: [INPUT_PGN], first: 16, bit: oddBits(16) },
41
+ 0x36: { name: 'Control X PLUS', pgns: [STATUS_PGN], first: 16, bit: (input, wiring) => input + (wiring === 'switchToNeg' ? 1 : 0) }
42
+ }
43
+
44
+ // data: 8 bytes of a 65284 or 65308 frame -> { module, variant, bitmap } or null
45
+ function decodeFrame (data) {
46
+ if (!data || data.length !== 8 || data[0] !== 0x27 || data[1] !== 0x99) return null
47
+ return { module: data[2], variant: data[3], bitmap: data.readUInt32LE(4) }
48
+ }
49
+
50
+ // true / false, or undefined when this kind of module or message does not
51
+ // carry the input.
52
+ function inputState (kind, bitmap, input, pgn, wiring) {
53
+ const layout = LAYOUT[kind]
54
+ if (!layout || !Number.isInteger(input) || !layout.pgns.includes(pgn)) return undefined
55
+ if (input < layout.first) return undefined
56
+ const bit = layout.bit(input, wiring)
57
+ if (bit > 31) return undefined
58
+ return ((bitmap >>> bit) & 1) === 1
59
+ }
60
+
61
+ function slugify (name) {
62
+ const cleaned = String(name || 'Input').trim().replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '')
63
+ return cleaned || 'Input'
64
+ }
65
+
66
+ // The switch inputs of a loaded configuration, each with its Signal K path
67
+ // (electrical.czone.inputs.<name>.state) and its module's kind.
68
+ function switchInputs (mapping) {
69
+ const table = mapping && mapping.signalInputs
70
+ if (!table || !Array.isArray(table.inputs)) return []
71
+ const kinds = new Map((mapping.modules || []).filter(m => Number.isInteger(m.module)).map(m => [m.module, m.type]))
72
+ const used = new Map()
73
+ return table.inputs
74
+ .filter(i => i.type === 'switch' && Number.isInteger(i.module) && i.module !== 0)
75
+ .map(i => {
76
+ const base = slugify(i.name)
77
+ const count = (used.get(base) || 0) + 1
78
+ used.set(base, count)
79
+ const slug = count === 1 ? base : `${base}${count}`
80
+ const kind = kinds.get(i.module)
81
+ return {
82
+ name: String(i.name).trim(),
83
+ slug,
84
+ path: `electrical.czone.inputs.${slug}.state`,
85
+ module: i.module,
86
+ input: i.input,
87
+ wiring: i.wiring,
88
+ moduleKind: Number.isInteger(kind) ? kind : null,
89
+ decoded: Boolean(LAYOUT[kind])
90
+ }
91
+ })
92
+ }
93
+
94
+ module.exports = { STATUS_PGN, INPUT_PGN, LAYOUT, decodeFrame, inputState, switchInputs }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-czone",
3
- "version": "0.3.0-beta.14",
3
+ "version": "0.3.0-beta.15",
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,10 +42,10 @@
42
42
  "icon.png"
43
43
  ],
44
44
  "scripts": {
45
- "test": "node test/zcf.test.js && node test/zcf-fixtures.test.js && node test/circuit-state.test.js && node test/czone.test.js && node test/nmea2000.test.js && node test/control-x-plus.test.js && node test/signalk.test.js && node test/write.test.js && node test/circuit-status.test.js && node test/status-mapping-regression.test.js && node test/state.test.js && node test/config-network.test.js && node test/config-namespace.test.js && node test/monitoring.test.js && node test/confirm-off.test.js && node test/webapp-current.test.js && node test/bridge.test.js && node test/trends.test.js && node --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 test/inputs.test.js && node --require ./test/helpers/gx-low-space.cjs test/trends.test.js"
46
46
  },
47
47
  "dependencies": {
48
- "signalk-czone-zcf": "github:mattsmitchell/signalk-czone-zcf#4991170ac20a3506bfcc1131b2c7dd025c4a983c"
48
+ "signalk-czone-zcf": "0.1.0-beta.2"
49
49
  },
50
50
  "optionalDependencies": {
51
51
  "dbus-native": "^0.4.0"
package/public/index.html CHANGED
@@ -247,6 +247,72 @@
247
247
  .no-flexgap .trend-stats > * { margin-right:28px; }
248
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
249
  @media (max-width: 780px) { .no-flexgap .section-head .sort { margin-left:auto; } }
250
+ /* Chartplotter layout. A Navico plotter (B&G / Simrad / Lowrance)
251
+ shows the page in its own browser, Chromium 69, driven by touch that
252
+ arrives as mouse events. It has no flex gap, no colour-mix, few symbol
253
+ fonts and no hover; a drag selects text instead of scrolling. The page
254
+ gets the class "mfd" when the plotter's own parameters are on the address
255
+ (or ?layout=mfd, for trying it in a desktop browser). Nothing here
256
+ applies anywhere else. */
257
+ .mfd, .mfd * { -webkit-user-select:none; user-select:none; -webkit-tap-highlight-color:transparent; }
258
+ .mfd input[type=search] { -webkit-user-select:text; user-select:text; }
259
+ .mfd body { font-size:16px; }
260
+ .mfd svg.ico { width:22px; height:22px; display:block; fill:none; stroke:currentColor; stroke-width:2; stroke-linecap:round; stroke-linejoin:round; }
261
+ .mfd .app { grid-template-columns:230px minmax(0,1fr); }
262
+ .mfd .hero, .mfd .overview-card, .mfd .rightbar > .right-card:last-child, .mfd #sectionIcon, .mfd .search > span { display:none; }
263
+ .mfd .modes-card.none { display:none; }
264
+ .mfd .main { padding:10px 12px 30px; }
265
+ .mfd .connection { padding:0 4px 8px; }
266
+ .mfd .sidebar { padding:12px 10px; }
267
+ .mfd .rightbar { padding:0 10px 14px; }
268
+ .mfd .brand { padding:2px 8px 12px; }
269
+ .mfd .fullscreen-toggle { display:none !important; } /* the plotter's tile is already full screen */
270
+ .mfd .brand > * + *, .mfd .category-card > * + * { margin-left:11px; }
271
+ .mfd .nav button { min-height:50px; font-size:16px; padding:10px 12px; margin-bottom:4px; }
272
+ .mfd .nav-icon { width:24px; margin-right:12px; flex:none; }
273
+ .mfd .nav-count { font-size:14px; }
274
+ .mfd .category-nav .category-card { min-height:60px; margin-bottom:6px; }
275
+ .mfd .category-nav .category-card strong { font-size:14px; }
276
+ .mfd .category-nav .category-card span { font-size:12px; }
277
+ .mfd .cat-icon { background:rgba(120,170,220,.14); }
278
+ .mfd .section-head { padding:12px 14px; }
279
+ .mfd .section-head > * + * { margin-left:10px; }
280
+ .mfd .section-head .search { margin-left:auto; }
281
+ .mfd .leftwell { background:rgba(5,16,29,.78); border-right:1px solid var(--line); }
282
+ .mfd .sidebar, .mfd .rightbar { background:none; border:0; -webkit-backdrop-filter:none; backdrop-filter:none; }
283
+ .mfd .section-head h2 { font-size:20px; }
284
+ .mfd .section-head .subtle { font-size:13px; }
285
+ .mfd .search input { padding:12px 15px; font-size:15px; }
286
+ .mfd .section-head .sort { padding:11px 10px; font-size:14px; }
287
+ .mfd .circuit { grid-template-columns:44px minmax(0,1fr) auto 34px; min-height:74px; padding:10px 12px; }
288
+ .mfd .tags, .mfd .status { display:none; }
289
+ .mfd .circuit > div:nth-last-child(2) { justify-self:end; }
290
+ .mfd .fav { font-size:24px; padding:8px; }
291
+ .mfd .circuit-name strong { font-size:17px; }
292
+ .mfd .circuit-name small { font-size:12px; }
293
+ .mfd .toggle { min-width:104px; min-height:54px; font-size:17px; }
294
+ .mfd .ask-box { max-width:520px; padding:26px; box-shadow:none; }
295
+ .mfd .ask-box h3 { font-size:24px; }
296
+ .mfd .ask-box p { font-size:17px; }
297
+ .mfd .ask-buttons button { min-height:64px; font-size:19px; }
298
+ .mfd .ask-mark svg { width:16px; height:16px; }
299
+ .mfd .control > * + *, .mfd .range-wrap > * + * { margin-left:9px; }
300
+ .mfd .connection > * + * { margin-left:7px; }
301
+ .mfd .arrow { font-size:26px; padding:8px 4px; }
302
+ .mfd .mode-button { min-height:50px; font-size:15px; }
303
+ .mfd .empty { font-size:15px; }
304
+ /* Cheap to draw: the plotter redraws the page in software as it scrolls. */
305
+ .mfd, .mfd body { background:#06101d; }
306
+ .mfd * { box-shadow:none !important; text-shadow:none !important; transition:none !important; }
307
+ .mfd .section { background:#0a1a2b; }
308
+ .mfd .leftwell { background:#07121f; }
309
+ .mfd .toggle.on { background:#1aa56a; }
310
+ .mfd .nav button.active { background:#12456e; }
311
+ .mfd .category-card, .mfd .category-nav .category-card.active { background:#0f2740; }
312
+ .mfd .category-card:hover { transform:none; }
313
+ .mfd .circuit:hover, .mfd .nav button:hover, .mfd .category-nav button:hover { background:transparent; }
314
+ .mfd .nav button.active:hover { background:#12456e; }
315
+ .mfd-probe { position:fixed; left:8px; bottom:8px; z-index:9; margin:0; padding:8px 10px; max-width:70%; font:12px/1.35 monospace; color:#d6f5d6; background:rgba(0,0,0,.88); border:1px solid #2c6; border-radius:8px; white-space:pre; pointer-events:none; }
250
316
  </style>
251
317
  <script>
252
318
  (() => {
@@ -306,6 +372,7 @@
306
372
  <script src="fullscreen.js"></script>
307
373
  <script>
308
374
  (() => {
375
+ const OWN_SCRIPT = document.currentScript ? document.currentScript.textContent : null; // see "a newer page" below
309
376
  const API = '/plugins/signalk-czone';
310
377
  const SK = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/signalk/v1/stream?subscribe=none`;
311
378
  const categoryIcons = {
@@ -336,6 +403,39 @@
336
403
  'User Definable 5', 'Bilge Pumps'
337
404
  ];
338
405
  const topLevelCategories = new Set(['AC', 'DC', 'Favorites']);
406
+ // On a Navico chartplotter (see the .mfd styles). The plotter adds
407
+ // mfd_name, mfd_model_detail, brand, lang and mode to the address.
408
+ // signalk-navico-embedder, the proxy that puts Signal K webapps on those
409
+ // plotters, also leaves window.SK_TOKEN on the page; either sign will do.
410
+ const MFD = /[?&](mfd_name|mfd_model_detail|brand|layout=mfd)\b/.test(location.search) || typeof window.SK_TOKEN === 'string';
411
+ if (MFD) document.documentElement.classList.add('mfd');
412
+ // Drawn icons for the plotter, whose fonts lack most of the symbol
413
+ // characters the desktop page uses (they show as empty boxes there).
414
+ const MFD_ICONS = {
415
+ home:'<path d="M3 11l9-8 9 8"/><path d="M5 10v10h14V10"/>', dot:'<circle cx="12" cy="12" r="5" fill="currentColor"/>',
416
+ star:'<path d="M12 3l2.8 6 6.2.6-4.7 4.2 1.5 6.2L12 16.6 6.2 20l1.5-6.2L3 9.6 9.2 9z"/>',
417
+ ac:'<path d="M2 12c2.5-9 6-9 8.5 0s6 9 8.5 0"/><path d="M21 12h1"/>', battery:'<rect x="3" y="7" width="16" height="10" rx="2"/><path d="M22 10v4"/>',
418
+ gauge:'<path d="M4 18a9 9 0 1 1 16 0"/><path d="M12 14l4-6"/>', bolt:'<path d="M13 2L5 14h6l-1 8 8-12h-6z"/>',
419
+ bulb:'<path d="M9 18h6M10 21h4"/><path d="M12 3a6 6 0 0 0-4 10.5V15h8v-1.5A6 6 0 0 0 12 3z"/>',
420
+ compass:'<circle cx="12" cy="12" r="9"/><path d="M15.5 8.5l-2 5-5 2 2-5z"/>', drop:'<path d="M12 3c4 5 6 8 6 11a6 6 0 0 1-12 0c0-3 2-6 6-11z"/>',
421
+ music:'<path d="M9 18V6l10-2v12"/><circle cx="7" cy="18" r="2"/><circle cx="17" cy="16" r="2"/>',
422
+ fan:'<circle cx="12" cy="12" r="2"/><path d="M12 10c-1-4 1-7 4-7 1 3-1 6-4 7zM13.7 13c4-1 7 1 7 4-3 1-6-1-7-4zM10.3 13c-1 4-4 6-7 5 0-3 3-5 7-5z"/>',
423
+ chip:'<rect x="7" y="7" width="10" height="10" rx="1"/><path d="M10 3v4M14 3v4M10 17v4M14 17v4M3 10h4M3 14h4M17 10h4M17 14h4"/>',
424
+ snow:'<path d="M12 3v18M4.2 7.5l15.6 9M19.8 7.5l-15.6 9"/>', phone:'<path d="M5 4h4l2 5-2.5 1.5a11 11 0 0 0 5 5L15 13l5 2v4a2 2 0 0 1-2 2A15 15 0 0 1 3 6a2 2 0 0 1 2-2z"/>',
425
+ warn:'<path d="M12 3l10 18H2z"/><path d="M12 10v5M12 18v.500"/>', clock:'<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>', plus:'<path d="M12 5v14M5 12h14"/>',
426
+ gear:'<circle cx="12" cy="12" r="4"/><path d="M12 2v4M12 18v4M2 12h4M18 12h4M4.9 4.9l2.8 2.8M16.3 16.3l2.8 2.8M4.9 19.1l2.8-2.8M16.3 7.7l2.8-2.8"/>',
427
+ anchor:'<circle cx="12" cy="5" r="2"/><path d="M12 7v14M5 13a7 7 0 0 0 14 0M9 10h6"/>', sun:'<circle cx="12" cy="12" r="4"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2"/>',
428
+ swap:'<path d="M4 9h15l-3-3M20 15H5l3 3"/>', tank:'<rect x="5" y="6" width="14" height="14" rx="2"/><path d="M5 13h14M9 6V3h6v3"/>',
429
+ temp:'<path d="M12 3a2 2 0 0 0-2 2v9a4 4 0 1 0 4 0V5a2 2 0 0 0-2-2z"/>', cloud:'<path d="M7 18a4 4 0 0 1 0-8 5 5 0 0 1 9.6-1A4.5 4.5 0 0 1 17 18z"/>',
430
+ toggle:'<rect x="3" y="8" width="18" height="8" rx="4"/><circle cx="8" cy="12" r="2"/>', house:'<path d="M3 11l9-8 9 8"/><path d="M5 10v10h5v-6h4v6h5V10"/>',
431
+ appliance:'<rect x="5" y="3" width="14" height="18" rx="2"/><circle cx="12" cy="14" r="4"/><path d="M8 6h2"/>'
432
+ };
433
+ const MFD_ICON_FOR = { all:'home', inuse:'dot', favorites:'star', ac:'ac', dc:'battery', monitor:'gauge',
434
+ Lighting:'bulb', Navigation:'compass', Pumps:'drop', 'Bilge Pumps':'drop', Power:'bolt', Entertainment:'music', 'Fans/Ventilation':'fan', Electronics:'chip', Climate:'snow', Refrigeration:'snow',
435
+ Communications:'phone', 'Vessel Critical':'warn', 'Indicators and Alarms':'warn', '24-Hour Circuits':'clock', Accessories:'plus', 'Engine Management':'gear', 'Vessel Management':'gear',
436
+ 'Propulsion Management':'fan', 'House/Habitat':'house', Appliances:'appliance', 'Deck Equipment':'anchor',
437
+ Batteries:'battery', Solar:'sun', Alternators:'gear', Converters:'swap', 'Wind Generators':'fan', 'AC Power':'ac', Tanks:'tank', Temperatures:'temp', Environment:'cloud', 'Circuit current':'bolt', Inputs:'toggle' };
438
+ const mfdIcon = key => `<svg class="ico" viewBox="0 0 24 24">${MFD_ICONS[MFD_ICON_FOR[key]] || MFD_ICONS.dot}</svg>`;
339
439
  let circuits = [], status = {}, modes = [], activeMode = null, vesselName = '', view = 'all', category = null, query = '', sort = 'name', socket = null;
340
440
  let socketReconnectTimer = null, watchdogTimer = null, lastSocketUpdateAt = 0, socketConnected = false, socketEverOpen = false;
341
441
  const WS_WATCHDOG_MS = 120000;
@@ -369,7 +469,7 @@
369
469
  const activeCount = () => circuits.filter(stateOf).length;
370
470
  const masterCount = m => circuits.filter(c => (categoryData(c).master || []).includes(m)).length;
371
471
  const subCategoryList = c => [...new Set([...(categoryData(c).sub || []), ...(categoryData(c).userDefined || [])])];
372
- const icon = c => categoryIcons[c] || '•';
472
+ const icon = c => MFD ? mfdIcon(c) : (categoryIcons[c] || '•');
373
473
  const color = c => categoryColors[c] || '#4bb5ff';
374
474
 
375
475
  function saveFavorites() { localStorage.setItem(favoritesKey, JSON.stringify([...favorites])); }
@@ -382,7 +482,7 @@
382
482
  ['all','⌂','All Circuits',circuits.length], ['inuse','●','In Use',activeCount()], ['favorites','☆','Favorites',circuits.filter(c=>favorites.has(nameKey(c))).length],
383
483
  ['ac','♆','AC',masterCount('AC')], ['dc','▣','DC',masterCount('DC')]
384
484
  ];
385
- document.querySelector('#mainNav').innerHTML = items.map(([v,i,t,n]) => `<button class="${view===v && !category?'active':''}" onclick="setView('${v}')"><span class="nav-icon">${i}</span>${t}<span class="nav-count">${n}</span></button>`).join('');
485
+ document.querySelector('#mainNav').innerHTML = items.map(([v,i,t,n]) => `<button class="${view===v && !category?'active':''}" onclick="setView('${v}')"><span class="nav-icon">${MFD ? mfdIcon(v) : i}</span>${t}<span class="nav-count">${n}</span></button>`).join('');
386
486
  }
387
487
 
388
488
  function renderCategories() {
@@ -412,7 +512,7 @@
412
512
  const title = category || ({all:'All Circuits',inuse:'In Use',favorites:'Favorites',ac:'AC Circuits',dc:'DC Circuits',search:'Search Results'}[view] || 'Circuits');
413
513
  document.querySelector('#sectionTitle').textContent = title;
414
514
  document.querySelector('#sectionCount').textContent = `${list.length} circuit${list.length===1?'':'s'}`;
415
- document.querySelector('#sectionIcon').textContent = category ? icon(category) : view==='favorites' ? '☆' : view==='inuse' ? '●' : '⚡';
515
+ document.querySelector('#sectionIcon').textContent = MFD ? '' : category ? icon(category) : view==='favorites' ? '☆' : view==='inuse' ? '●' : '⚡';
416
516
  document.querySelector('#circuits').innerHTML = list.length ? list.map(circuitRow).join('') : `<div class="empty">No circuits match this view.</div>`;
417
517
  }
418
518
 
@@ -982,6 +1082,7 @@
982
1082
  function renderModes() {
983
1083
  const el = document.querySelector('#modes');
984
1084
  if (!el) return;
1085
+ if (el.parentElement) el.parentElement.classList.toggle('none', !modes.length); // hidden on a plotter when there are none
985
1086
  if (!modes.length) { el.innerHTML = '<div class="empty" style="padding:10px 4px">No CZone modes found.</div>'; return; }
986
1087
  el.innerHTML = modes.map(m => {
987
1088
  const active = activeMode && activeMode.slug === m.slug;
@@ -1025,6 +1126,128 @@
1025
1126
  document.querySelector('#connectionCard').innerHTML = `<div style="color:#a9bfd2;font-size:12px;line-height:1.7"><span style="color:${status.sendingEnabled?'#64e4aa':'#ffcc77'}">● NMEA 2000 sending ${status.sendingEnabled?'enabled':'disabled'}</span><br><span style="color:${readyColor}">● ${readyText}</span>${lastOut}</div>`;
1026
1127
  }
1027
1128
 
1129
+ // Scrolling on a chartplotter. Its touch arrives as mouse events, so a
1130
+ // drag would select text and nothing would scroll. So a press that moves
1131
+ // more than a few pixels scrolls the nearest scrolling box (the list or the
1132
+ // left column). Positions are applied once per frame, and the tap that ends
1133
+ // a drag is not passed on as a click.
1134
+ // The plotter is slow, so redraws caused by live data wait while a finger is
1135
+ // down (see liveRender).
1136
+ let mfdBusyUntil = 0; // no data-driven redraw before this time
1137
+ if (MFD) {
1138
+ let drag = null, swallow = false, frame = 0;
1139
+ const scrollerOf = el => {
1140
+ for (let n = el; n && n !== document.body && n !== document.documentElement; n = n.parentElement) {
1141
+ const oy = getComputedStyle(n).overflowY;
1142
+ if ((oy === 'auto' || oy === 'scroll') && n.scrollHeight > n.clientHeight + 1) return n;
1143
+ }
1144
+ return document.scrollingElement || document.documentElement;
1145
+ };
1146
+ const applyDrag = () => { frame = 0; if (drag && drag.moved) drag.box.scrollTop = drag.want; };
1147
+ document.addEventListener('mousedown', e => {
1148
+ if (e.button !== 0 || (e.target.closest && e.target.closest('input, select, canvas, .ask'))) return;
1149
+ const box = scrollerOf(e.target);
1150
+ drag = { box, y: e.clientY, top: box.scrollTop, want: box.scrollTop, moved: false, held: false };
1151
+ mfdBusyUntil = Date.now() + 1500;
1152
+ }, true);
1153
+ document.addEventListener('mousemove', e => {
1154
+ if (!drag) return;
1155
+ // Released outside the page. Only trusted once this drag has reported a
1156
+ // held button, since some touch layers never report one.
1157
+ if (e.buttons) drag.held = true; else if (drag.held) { endDrag(); return; }
1158
+ const dy = e.clientY - drag.y;
1159
+ if (!drag.moved && Math.abs(dy) < 10) return;
1160
+ drag.moved = true;
1161
+ drag.want = drag.top - dy;
1162
+ mfdBusyUntil = Date.now() + 1500;
1163
+ if (!frame) frame = requestAnimationFrame(applyDrag);
1164
+ e.preventDefault();
1165
+ }, true);
1166
+ function endDrag() {
1167
+ if (drag && drag.moved) { applyDrag(); swallow = true; setTimeout(() => { swallow = false; }, 80); }
1168
+ drag = null;
1169
+ mfdBusyUntil = Date.now() + 400;
1170
+ }
1171
+ document.addEventListener('mouseup', endDrag, true);
1172
+ window.addEventListener('blur', endDrag);
1173
+ document.addEventListener('click', e => { if (swallow) { e.stopPropagation(); e.preventDefault(); swallow = false; } }, true);
1174
+ document.addEventListener('contextmenu', e => e.preventDefault());
1175
+ document.addEventListener('selectstart', e => { if (!(e.target.closest && e.target.closest('input'))) e.preventDefault(); });
1176
+
1177
+ // A newer page. The plotter keeps showing the page it first loaded, even
1178
+ // after the tile is closed and reopened, so an update never appeared
1179
+ // without restarting the plotter. Once a minute the page fetches itself
1180
+ // and, if its own script has changed on the server, reloads. One attempt
1181
+ // per version, so a plotter that will not let go cannot loop.
1182
+ const checkPage = async () => {
1183
+ try {
1184
+ if (!OWN_SCRIPT) return;
1185
+ const r = await fetch(location.pathname + '?fresh=' + Date.now(), { cache: 'no-store', credentials: 'include' });
1186
+ if (!r.ok) return;
1187
+ const text = await r.text();
1188
+ const found = text.split('<scr' + 'ipt>').map(part => part.split('</scr' + 'ipt>')[0]).find(part => part.indexOf("const API = '/plugins/signalk-czone'") >= 0);
1189
+ if (!found || found === OWN_SCRIPT) return;
1190
+ const tried = String(found.length) + ':' + found.slice(-200).length + ':' + found.split('\n').length;
1191
+ let before = null;
1192
+ try { before = sessionStorage.getItem('czone-reload'); sessionStorage.setItem('czone-reload', tried); } catch (_) { return; }
1193
+ if (before !== tried) location.reload(true);
1194
+ } catch (_) {}
1195
+ };
1196
+ setTimeout(checkPage, 15000);
1197
+ setInterval(checkPage, 60000);
1198
+
1199
+ // What the plotter actually sends: five taps on the connection line show
1200
+ // the last input events and how long frames and redraws take. For
1201
+ // diagnosing a plotter we cannot connect a debugger to.
1202
+ let taps = [], probe = null;
1203
+ document.querySelector('#connection').addEventListener('click', () => {
1204
+ const now = Date.now();
1205
+ taps = taps.filter(t => now - t < 3000).concat(now);
1206
+ if (taps.length < 5) return;
1207
+ taps = [];
1208
+ if (probe) { probe.remove(); probe = null; return; }
1209
+ probe = document.createElement('pre');
1210
+ probe.className = 'mfd-probe';
1211
+ document.body.appendChild(probe);
1212
+ const log = [];
1213
+ let last = performance.now(), frames = [];
1214
+ const note = e => {
1215
+ const t = performance.now();
1216
+ log.push(`${String(Math.round(t - last)).padStart(5)} ms ${e.type.padEnd(11)} b=${e.buttons === undefined ? '-' : e.buttons} ${e.pointerType || ''} ${e.touches ? 't' + e.touches.length : ''} ${e.deltaY !== undefined ? 'dy=' + Math.round(e.deltaY) : ''}`);
1217
+ last = t;
1218
+ if (log.length > 16) log.shift();
1219
+ };
1220
+ for (const type of ['mousedown', 'mousemove', 'mouseup', 'click', 'touchstart', 'touchmove', 'touchend', 'pointerdown', 'pointermove', 'pointerup', 'wheel', 'scroll']) document.addEventListener(type, note, true);
1221
+ // Frame time: the gap between two frames in a row. Redraw: rebuilding the list.
1222
+ const tick = () => {
1223
+ if (!probe) return;
1224
+ requestAnimationFrame(t1 => requestAnimationFrame(t2 => {
1225
+ if (!probe) return;
1226
+ frames.push(t2 - t1);
1227
+ if (frames.length > 10) frames.shift();
1228
+ const t0 = performance.now(); renderCircuits(); const redraw = performance.now() - t0;
1229
+ probe.textContent = `${window.innerWidth}x${window.innerHeight} frame ${Math.round(frames.reduce((a, b) => a + b, 0) / frames.length)} ms redraw ${Math.round(redraw)} ms\n${navigator.userAgent.replace(/^.*(Chrome\/[\d.]+).*$/, '$1')}\n` + log.join('\n');
1230
+ setTimeout(tick, 700);
1231
+ }));
1232
+ };
1233
+ tick();
1234
+ });
1235
+ }
1236
+ // A redraw asked for by live data. Anywhere else it runs at once. On a
1237
+ // plotter it waits until no finger is down, and runs at most twice a second.
1238
+ let liveTimer = null, liveLast = 0;
1239
+ function liveRender() {
1240
+ if (!MFD) { render(); return; }
1241
+ if (liveTimer) return;
1242
+ const run = () => {
1243
+ const now = Date.now();
1244
+ const wait = Math.max(mfdBusyUntil - now, liveLast + 500 - now);
1245
+ if (wait > 0) { liveTimer = setTimeout(run, wait); return; }
1246
+ liveTimer = null; liveLast = now; render();
1247
+ };
1248
+ run();
1249
+ }
1250
+
1028
1251
  function render() { renderNav(); renderCategories(); renderCircuits(); renderStats(); renderModes(); renderHero(); }
1029
1252
 
1030
1253
  document.querySelector('#search').addEventListener('input', e => { query=e.target.value.trim(); view=query?'search':'all'; category=null; render(); });
@@ -1094,7 +1317,7 @@
1094
1317
  console.debug('[CZone WS] delta received', { valuesSeen, circuitValues, changed, updateSource: msg.updates.map(u => u.source || null), updateTimestamp: msg.updates.map(u => u.timestamp || null), timestamp: new Date(lastSocketUpdateAt).toISOString() });
1095
1318
  updateSocketIndicator(circuitValues ? 'Connected · CZone updates live' : 'Connected · WebSocket live', true);
1096
1319
  scheduleWatchdog();
1097
- if (changed) render();
1320
+ if (changed) liveRender();
1098
1321
  } catch (e) { console.debug('[CZone WS] message parse error', e); }
1099
1322
  };
1100
1323
  socket.onclose = () => {