@rhizomatics/signalk-einklabel-plugin 0.8.1 → 0.9.0-beta1

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.
Files changed (29) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +29 -5
  3. package/dist/config.d.ts +52 -8
  4. package/dist/config.js +144 -15
  5. package/dist/devices/discoveredDevicesStore.d.ts +32 -0
  6. package/dist/devices/discoveredDevicesStore.js +67 -0
  7. package/dist/devices/discoveryCoordinator.d.ts +19 -0
  8. package/dist/devices/discoveryCoordinator.js +59 -0
  9. package/dist/devices/zhsunyco/encode.js +33 -11
  10. package/dist/devices/zhsunyco/index.js +1 -1
  11. package/dist/plugin.js +23 -62
  12. package/dist/render/assets.d.ts +7 -1
  13. package/dist/render/assets.js +28 -12
  14. package/dist/render/svgRenderer.js +1 -1
  15. package/dist/repaintScheduler.js +132 -40
  16. package/package.json +1 -1
  17. package/templates/{assets → .assets}/lunar_phases/README.md +6 -2
  18. package/templates/.assets/lunar_phases/third_quarter.svg +52 -0
  19. package/templates/tide.svg +18 -10
  20. package/templates/tides/250x128-BWRY.svg +160 -0
  21. package/templates/tides/416x240-BWRY.svg +288 -0
  22. /package/templates/{assets → .assets}/lunar_phases/first_quarter.svg +0 -0
  23. /package/templates/{assets → .assets}/lunar_phases/full_moon.svg +0 -0
  24. /package/templates/{assets/lunar_phases/third_quarter.svg → .assets/lunar_phases/last_quarter.svg} +0 -0
  25. /package/templates/{assets → .assets}/lunar_phases/new_moon.svg +0 -0
  26. /package/templates/{assets → .assets}/lunar_phases/waning_crescent.svg +0 -0
  27. /package/templates/{assets → .assets}/lunar_phases/waning_gibbous.svg +0 -0
  28. /package/templates/{assets → .assets}/lunar_phases/waxing_crescent.svg +0 -0
  29. /package/templates/{assets → .assets}/lunar_phases/waxing_gibbous.svg +0 -0
package/CHANGELOG.md CHANGED
@@ -1,3 +1,22 @@
1
+ # 0.9.0
2
+
3
+ - Rename `third_quarter` to `last_quarter` for moon phase icons in `templates/.assets/lunar_phases` to match Derived Data plugin
4
+ - `tide.svg` renamed to `tides\416x240-BWRY.svg`. Original maintained but labelled as deprecated
5
+ - Added a simpler 250x128 version of tide clock for 2.13" labels
6
+ - Fix configuration JSON holding older versions of itself as subentries
7
+ - Repaint state used to track which labels to repaint separately tracks the template and data changes
8
+ - Fix most cases of scanned devices not appearing in device dropdown choice
9
+ - `nearestColour` algorithm in Zhsunyco driver supports ESLs that only have BWR or BW
10
+ - New 'ALL' as device option, and by default disable initial scan, to optimize support for single devices
11
+ - Scan and explicit device selection only required then for boats with multiple devices
12
+ - Now support a directory of templates, where each is named like `416-240-BWRY.svg` to support same functions on different devices.
13
+ - Picks the template within the directory that most closely matches the tide clock height/width/colour-set, if not matched then height/width, and then best h/w ratio for nearest width
14
+ - `template/assets` is now `template/.assets`
15
+
16
+ # 0.8.2
17
+
18
+ - Work around a SignalK bug in Server API where `ResourcesApi.listResources()` merges provider values in undetermined order and ignores specified provider.
19
+
1
20
  # 0.8.1
2
21
 
3
22
  - A preferred `provider` can now be set for `resources`.
package/README.md CHANGED
@@ -89,7 +89,11 @@ Since these are ultra-low power devices, they don't respond instantly to either
89
89
 
90
90
  One other quirk is that some devices respond with a different name at different times, for example the genric `WOESL` sometimes and model specific `WL17500C74` other times. However, the MAC address, e.g. `66:66:17:50:0D:2B` is constant, and this is what's tracked by the plugin.
91
91
 
92
- The plugin will optionally re-scan whenever it starts up, although this isn't essential once a label has been configured.
92
+ The plugin can optionally re-scan whenever it starts up (off by default), although this isn't essential once a label has been configured. Devices found by any scan are remembered across restarts - see the FAQ below.
93
+
94
+ #### Selecting a Device
95
+
96
+ A device's "Device" field can either be a specific device picked from the scan dropdown, or **"All discovered devices"**, which paints that same template/trigger to every device the plugin currently knows about. This is the simplest option for a boat with just one label - there's no need to scan first and pick it out, and if nothing's been discovered yet, selecting it triggers a scan itself the first time it's needed. It also covers several identical labels with one config entry, without listing each one out.
93
97
 
94
98
  ### Scheduling
95
99
 
@@ -107,6 +111,16 @@ The devices will be painted when the plugin starts, and then every time the sele
107
111
 
108
112
  Templates are simply SVG files, to which expressions can be added to use SignalK data, with options to make it easier to read, like rounding or simplifying dates and times. The template can have sample data in the placeholder, so is easy to layout and visualize.
109
113
 
114
+ ### Template Families (multiple panel sizes/colours)
115
+
116
+ A "Template" selection can either be one specific `.svg` file, or a _directory_ holding several same-purpose templates for different panel sizes/colour-sets, e.g. `templates/tides/416x240-BWRY.svg` and `templates/tides/250x128-BWRY.svg` both implement the tide clock, just at different sizes. Each file is named `<width>x<height>-<colours>.svg`, where `<colours>` is one letter per supported colour: `B`(lack)/`W`(hite)/`R`(ed)/`Y`(ellow) - e.g. `BWRY` for a 4-colour panel, `BWR` for a 3-colour one.
117
+
118
+ Selecting the directory (e.g. `tides`) instead of one file lets one `DeviceConfig` entry - especially a `device: "All discovered devices"` entry covering several different physical panels - automatically pick the best-fitting file for each device's actual size/colours, trying in order:
119
+
120
+ 1. An exact width/height/colour-set match.
121
+ 2. Failing that, width/height alone (any colour-set).
122
+ 3. Failing that too, the nearest width, tie-broken by whichever file's own height/width ratio is closest to the device's.
123
+
110
124
  ### Template Source Specification
111
125
 
112
126
  In the `description` of the SVG text box, use a comma separated set of key value pairs to define the data source and formatting.
@@ -156,17 +170,17 @@ These can all be combined as in `source=resources,resource=tides,provider=tides,
156
170
 
157
171
  ### Non-Textual Fields (Images)
158
172
 
159
- The same `<desc>` mechanism works on an `<image>` element instead of a `<text>` element, for a value that's better shown as a picture than as text - a moon phase icon, a wind direction arrow, a weather condition glyph, and so on. Rather than substituting text, the resolved value picks one of a directory of `.svg` files to embed, by an extra required `assets=` key naming that directory - an `assets/<name>` sub-directory looked up in your configured `templates` directory first, and the bundled `templates` directory otherwise. For example, the tide clock's moon phase icon uses:
173
+ The same `<desc>` mechanism works on an `<image>` element instead of a `<text>` element, for a value that's better shown as a picture than as text - a moon phase icon, a wind direction arrow, a weather condition glyph, and so on. Rather than substituting text, the resolved value picks one of a directory of `.svg` files to embed, by an extra required `assets=` key naming that directory - an `.assets/<name>` sub-directory looked up in your configured `templates` directory first, and the bundled `templates` directory otherwise. For example, the tide clock's moon phase icon uses:
160
174
 
161
175
  ```
162
176
  path=environment.moon.phaseName,assets=lunar_phases
163
177
  ```
164
178
 
165
- which resolves against `templates/assets/lunar_phases/` (bundled, or your own configured `templates` directory's `assets/lunar_phases/` if you have one). The resolved value (e.g. `"Waning Gibbous"`, as published by the [derived-data](https://www.npmjs.com/package/signalk-derived-data) plugin) is normalized to match a filename - lower-cased, punctuation and spaces collapsed to underscores - so `"Waning Gibbous"` picks `waning_gibbous.svg` out of that directory. If the underlying path has no value at all (e.g. the `derived-data` plugin isn't installed), or the value doesn't normalize to any file in the directory, the `<image>` element is omitted from that render - no broken image, no placeholder, nothing shown - and a line is logged to the console so a missing/unmatched value isn't silently invisible.
179
+ which resolves against `templates/.assets/lunar_phases/` (bundled, or your own configured `templates` directory's `.assets/lunar_phases/` if you have one). The resolved value (e.g. `"Waning Gibbous"`, as published by the [derived-data](https://www.npmjs.com/package/signalk-derived-data) plugin) is normalized to match a filename - lower-cased, punctuation and spaces collapsed to underscores - so `"Waning Gibbous"` picks `waning_gibbous.svg` out of that directory. If the underlying path has no value at all (e.g. the `derived-data` plugin isn't installed), or the value doesn't normalize to any file in the directory, the `<image>` element is omitted from that render - no broken image, no placeholder, nothing shown - and a line is logged to the console so a missing/unmatched value isn't silently invisible.
166
180
 
167
- If you don't like the bundled moon phase icons, save your own `<value>.svg` files in the `assets/lunar_phases` sub-directory of your configured `templates` directory - the whole directory is used in place of the bundled one, so add all 8 phases you want to keep, not just the ones you're changing.
181
+ If you don't like the bundled moon phase icons, save your own `<value>.svg` files in the `.assets/lunar_phases` sub-directory of your configured `templates` directory - the whole directory is used in place of the bundled one, so add all 8 phases you want to keep, not just the ones you're changing.
168
182
 
169
- This is a general mechanism, not specific to moon phases - any `source`/`context`/`path`/`format` combination valid for a `<text>` binding works here too (a `source=resources` value, an explicit `category=`, etc.), the only difference is the required `assets=` directory and the "no match -> no image" behaviour instead of substituted text. To add your own, put a directory of `<value>.svg` files under an `assets/<name>` sub-directory of your `templates` directory, add an `<image>` element in your SVG editor at the size/position you want, and give it a `<desc>` the same way you would a text field - overriding just the template, just its assets, or both together, all work independently.
183
+ This is a general mechanism, not specific to moon phases - any `source`/`context`/`path`/`format` combination valid for a `<text>` binding works here too (a `source=resources` value, an explicit `category=`, etc.), the only difference is the required `assets=` directory and the "no match -> no image" behaviour instead of substituted text. To add your own, put a directory of `<value>.svg` files under an `.assets/<name>` sub-directory of your `templates` directory, add an `<image>` element in your SVG editor at the size/position you want, and give it a `<desc>` the same way you would a text field - overriding just the template, just its assets, or both together, all work independently.
170
184
 
171
185
  ### Fonts
172
186
 
@@ -305,6 +319,10 @@ For example, `npx esl-cli fields -t templates/tide.svg -e examples` will show al
305
319
 
306
320
  ## Frequently Asked Questions
307
321
 
322
+ ### I can't see my device as a choice on the drop-down list after scan
323
+
324
+ SignalK plugins lack ability to self-update after something like a scan, so first time round you may have to close the config and reload it to see this. Subsequently the plugin will remember all scanned devices, and only drop previously seen ones if it goes 24 hours without a positive scan or with failed paint attempts.
325
+
308
326
  ### Sometimes values are missing on the display
309
327
 
310
328
  If the plugin repaints a display at server startup, then the plugin that provides the data may not have started ( or in the case of `derived-data` the plugin that the plugin depends on! ) and unlike Home Assistant, there's no good way of sequencing the start of plugins.
@@ -322,9 +340,15 @@ If you're a global cruiser, then use something like [signalk-set-gps-timezone](h
322
340
 
323
341
  Try a BLE proxy device, ESP32 is popular for this.
324
342
 
343
+ ### Can't edit the text contents of SVG template in VSCode
344
+
345
+ If you have an SVG viewer extension, this wll show the image rather than allowing editing of text. To solve, right click on the file in VSCode _Explorer_ view and choose to edit with _Text Editor_.
346
+
325
347
  ## Other ESL and General eInk Resources
326
348
 
327
349
  - [Open ePaper Link](https://openepaperlink.de) - Alternative open source firmware to flash onto eInk shelf labels, with Home Assistant integration.
350
+ - [zhsynyco-esl](https://github.com/roxburghm/zhsunyco-esl) - Python interface
351
+ - [WoLink](https://github.com/NickWaterton/Wolink) - Python interface and protocol analysis
328
352
  - [e-ink dashboard for Signal K](https://github.com/meri-imperiumi/dashboard) - Waveshare display based multi instrument display.
329
353
  - [eInk Dashboard Modern SK](https://github.com/VladimirKalachikhin/e-inkDashboardModernSK) - SignalK dashboard for non-ESL eInk display.
330
354
  - [esp32-esl-system](https://github.com/giobauermeister/esp32-esl-system) - Docker and ESP32 based system for updating ESLs.
package/dist/config.d.ts CHANGED
@@ -1,15 +1,31 @@
1
1
  import { ServerAPI } from "@signalk/server-api";
2
- import { DiscoveredDevice } from "./devices/types";
2
+ import { Colour, DiscoveredDevice } from "./devices/types";
3
+ /**
4
+ * Special `device` value meaning "every currently-known discovered device" instead of one specific
5
+ * BLE address - lets a single `DeviceConfig` entry (one template, one trigger) broadcast to every
6
+ * physical label without picking each one out of the scan dropdown individually, e.g. several
7
+ * identical labels on a boat, or just to skip the scan-then-select step for the common single-label
8
+ * case. Resolved lazily at repaint time (see `resolveTargets` in `repaintScheduler.ts`), triggering
9
+ * an on-demand scan itself if nothing's been discovered yet.
10
+ */
11
+ export declare const ALL_DEVICES = "ALL";
3
12
  export interface DeviceConfig {
4
13
  friendlyName: string;
5
14
  /**
6
- * `"<vendor>:<pid>[:<hwVersion>]@<address>"`, picked from a combined enum of recently
7
- * scanned devices so one selection sets both the model (width/height/colours, known
8
- * without a live BLE read) and the BLE address.
15
+ * Either `"<vendor>:<pid>[:<hwVersion>]@<address>"`, picked from a combined enum of recently
16
+ * scanned devices so one selection sets both the model (width/height/colours, known without a
17
+ * live BLE read) and the BLE address, or the special value `ALL_DEVICES` (see above).
9
18
  */
10
19
  device: string;
11
20
  /** Per-device override; if omitted, the vendor driver may fall back to a stock/manufacturer-default key. */
12
21
  aesKey?: string;
22
+ /**
23
+ * Either one specific `.svg` file, or the name of a *template-family* directory holding several
24
+ * same-purpose templates for different panel sizes/colour-sets, named `<width>x<height>-<colours>.svg`
25
+ * (e.g. `templates/tides/416x240-BWRY.svg`) - `pickBestVariant` then picks whichever file inside it
26
+ * best matches this entry's actual device(s), so one `DeviceConfig` (and one `ALL_DEVICES` entry in
27
+ * particular) works across different panel sizes without the user picking a file for each.
28
+ */
13
29
  templateName: string;
14
30
  repaintTrigger: "subscription" | "interval";
15
31
  /** SignalK path to subscribe to when `repaintTrigger` is `subscription` - a repaint is considered on every delta. */
@@ -28,7 +44,12 @@ export interface PluginConfig {
28
44
  * `~/.signalk`, or an absolute path. Use `resolveTemplatesDir` to turn this into an actual path.
29
45
  */
30
46
  templatesDir: string;
31
- /** Run a short BLE scan on plugin start and report discoveries via plugin status, like signalk-bluetti-plugin does. */
47
+ /**
48
+ * Run a short BLE scan on plugin start and report discoveries via plugin status, like
49
+ * signalk-bluetti-plugin does. Off by default - a `device: ALL_DEVICES` entry scans on demand the
50
+ * first time it has nothing discovered yet (see `resolveTargets` in `repaintScheduler.ts`), and an
51
+ * explicit device selection only ever needed this to populate the dropdown once at initial setup.
52
+ */
32
53
  scanOnStart: boolean;
33
54
  /** How long the startup scan runs, in seconds. */
34
55
  scanDurationSeconds: number;
@@ -69,10 +90,23 @@ export interface PluginConfig {
69
90
  * user's `templatesDir` takes priority. Exported so `SvgRenderer` can fall back to it when resolving
70
91
  * an `assets=` binding's directory (see `resolveAssetPath` in `./render/assets.ts`) - overriding a
71
92
  * bundled template shouldn't also require duplicating its bundled asset sets (e.g.
72
- * `templates/assets/lunar_phases`) just to keep a binding the override never touched working.
93
+ * `templates/.assets/lunar_phases`) just to keep a binding the override never touched working.
73
94
  */
74
95
  export declare const BUNDLED_TEMPLATES_DIR: string;
75
96
  export declare function defaultConfig(): PluginConfig;
97
+ /**
98
+ * `app.readPluginOptions()`/`savePluginOptions()` aren't actually symmetric despite what their doc
99
+ * comments imply: signalk-server's `readPluginOptions()` returns the *whole* on-disk file
100
+ * (`{ configuration, enabled, enableDebug, enableLogging }`), not just the `configuration` object
101
+ * `savePluginOptions()` writes into - see signalk-server's `interfaces/plugins.ts`
102
+ * (`appCopy.readPluginOptions = () => getPluginOptions(plugin.id)` vs.
103
+ * `appCopy.savePluginOptions = (configuration, cb) => savePluginOptions(id, { ...getPluginOptions(id), configuration }, cb)`).
104
+ * Spreading that return value straight back into a save call therefore nests the whole file one level
105
+ * deeper inside its own `configuration` key every time (see `support/signalk-einklabel-plugin.json` and
106
+ * `clearForceRepaint` in `./repaintScheduler.ts`). Unwrapping any such nesting and keeping only recognised
107
+ * fields here means every save collapses back down instead of growing, self-healing an already-corrupted file.
108
+ */
109
+ export declare function readCurrentConfig(app: ServerAPI): Partial<PluginConfig>;
76
110
  /**
77
111
  * Resolves the user-facing `templatesDir` setting to an actual directory, mirroring
78
112
  * signalk-parquet's `outputDirectory` convention: empty means the default location, a relative
@@ -86,7 +120,17 @@ export declare function parseDevice(device: string): {
86
120
  hwVersion?: string;
87
121
  address: string;
88
122
  } | undefined;
89
- /** Resolves a template name to an actual file path - a local template overrides the bundled one of the same name. */
90
- export declare function resolveTemplatePath(templatesDir: string, templateName: string): string;
123
+ /**
124
+ * Resolves a template name to an actual file path - a local template overrides the bundled one of the
125
+ * same name. `templateName` can also name a template-family *directory* (see `pickBestVariant`), in
126
+ * which case `target` (the device's actual width/height/colours) picks the best file inside it. A
127
+ * `target`-less call, or one where `templateName` just isn't a directory, falls through to the plain
128
+ * flat-file behaviour - the only kind the CLI (which always names an exact file) ever uses.
129
+ */
130
+ export declare function resolveTemplatePath(templatesDir: string, templateName: string, target?: {
131
+ width: number;
132
+ height: number;
133
+ colours: Colour[];
134
+ }): string;
91
135
  export declare function configSchema(app: ServerAPI, discovered?: DiscoveredDevice[]): object;
92
136
  export declare function configUiSchema(): object;
package/dist/config.js CHANGED
@@ -1,7 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.BUNDLED_TEMPLATES_DIR = void 0;
3
+ exports.BUNDLED_TEMPLATES_DIR = exports.ALL_DEVICES = void 0;
4
4
  exports.defaultConfig = defaultConfig;
5
+ exports.readCurrentConfig = readCurrentConfig;
5
6
  exports.resolveTemplatesDir = resolveTemplatesDir;
6
7
  exports.parseDevice = parseDevice;
7
8
  exports.resolveTemplatePath = resolveTemplatePath;
@@ -11,13 +12,22 @@ const fs_1 = require("fs");
11
12
  const os_1 = require("os");
12
13
  const path_1 = require("path");
13
14
  const resolveApiUrl_1 = require("./resolveApiUrl");
15
+ /**
16
+ * Special `device` value meaning "every currently-known discovered device" instead of one specific
17
+ * BLE address - lets a single `DeviceConfig` entry (one template, one trigger) broadcast to every
18
+ * physical label without picking each one out of the scan dropdown individually, e.g. several
19
+ * identical labels on a boat, or just to skip the scan-then-select step for the common single-label
20
+ * case. Resolved lazily at repaint time (see `resolveTargets` in `repaintScheduler.ts`), triggering
21
+ * an on-demand scan itself if nothing's been discovered yet.
22
+ */
23
+ exports.ALL_DEVICES = "ALL";
14
24
  /**
15
25
  * The package's own bundled `templates/` directory (ships alongside `dist/`, see
16
26
  * package.json's `files`) - templates here are always available, but a same-named template in the
17
27
  * user's `templatesDir` takes priority. Exported so `SvgRenderer` can fall back to it when resolving
18
28
  * an `assets=` binding's directory (see `resolveAssetPath` in `./render/assets.ts`) - overriding a
19
29
  * bundled template shouldn't also require duplicating its bundled asset sets (e.g.
20
- * `templates/assets/lunar_phases`) just to keep a binding the override never touched working.
30
+ * `templates/.assets/lunar_phases`) just to keep a binding the override never touched working.
21
31
  */
22
32
  exports.BUNDLED_TEMPLATES_DIR = (0, path_1.join)(__dirname, "..", "templates");
23
33
  const SIGNALK_HOME_DIR = (0, path_1.join)((0, os_1.homedir)(), ".signalk");
@@ -25,7 +35,7 @@ const DEFAULT_TEMPLATES_DIR = (0, path_1.join)(SIGNALK_HOME_DIR, "einklabel", "t
25
35
  function defaultConfig() {
26
36
  return {
27
37
  templatesDir: "",
28
- scanOnStart: true,
38
+ scanOnStart: false,
29
39
  scanDurationSeconds: 20,
30
40
  paintConnectTimeoutSeconds: 30,
31
41
  paintRetries: 3,
@@ -33,6 +43,46 @@ function defaultConfig() {
33
43
  devices: [],
34
44
  };
35
45
  }
46
+ const PLUGIN_CONFIG_KEYS = [
47
+ "templatesDir",
48
+ "scanOnStart",
49
+ "scanDurationSeconds",
50
+ "paintConnectTimeoutSeconds",
51
+ "paintRetries",
52
+ "settleSeconds",
53
+ "signalkApiUrl",
54
+ "devices",
55
+ ];
56
+ /**
57
+ * `app.readPluginOptions()`/`savePluginOptions()` aren't actually symmetric despite what their doc
58
+ * comments imply: signalk-server's `readPluginOptions()` returns the *whole* on-disk file
59
+ * (`{ configuration, enabled, enableDebug, enableLogging }`), not just the `configuration` object
60
+ * `savePluginOptions()` writes into - see signalk-server's `interfaces/plugins.ts`
61
+ * (`appCopy.readPluginOptions = () => getPluginOptions(plugin.id)` vs.
62
+ * `appCopy.savePluginOptions = (configuration, cb) => savePluginOptions(id, { ...getPluginOptions(id), configuration }, cb)`).
63
+ * Spreading that return value straight back into a save call therefore nests the whole file one level
64
+ * deeper inside its own `configuration` key every time (see `support/signalk-einklabel-plugin.json` and
65
+ * `clearForceRepaint` in `./repaintScheduler.ts`). Unwrapping any such nesting and keeping only recognised
66
+ * fields here means every save collapses back down instead of growing, self-healing an already-corrupted file.
67
+ */
68
+ function readCurrentConfig(app) {
69
+ let raw = app.readPluginOptions();
70
+ while (raw &&
71
+ typeof raw === "object" &&
72
+ "configuration" in raw &&
73
+ typeof raw.configuration === "object") {
74
+ raw = raw.configuration;
75
+ }
76
+ const result = {};
77
+ if (raw && typeof raw === "object") {
78
+ for (const key of PLUGIN_CONFIG_KEYS) {
79
+ if (key in raw) {
80
+ result[key] = raw[key];
81
+ }
82
+ }
83
+ }
84
+ return result;
85
+ }
36
86
  /**
37
87
  * Resolves the user-facing `templatesDir` setting to an actual directory, mirroring
38
88
  * signalk-parquet's `outputDirectory` convention: empty means the default location, a relative
@@ -52,12 +102,13 @@ function resolveTemplatesDir(templatesDir) {
52
102
  * so the user can at least see what was found and report the PID; repainting such a device still
53
103
  * does nothing without a model override, since there's no width/height to render with) - plus, as a
54
104
  * fallback, whatever's already saved so an existing selection doesn't vanish from the dropdown just
55
- * because this particular run hasn't re-scanned it yet.
105
+ * because this particular run hasn't re-scanned it yet. `ALL_DEVICES` is always offered first,
106
+ * regardless of what's been scanned - see its own doc comment.
56
107
  */
57
108
  function deviceOptions(discovered, current) {
58
- const values = [];
59
- const labels = [];
60
- const seen = new Set();
109
+ const values = [exports.ALL_DEVICES];
110
+ const labels = ["All discovered devices"];
111
+ const seen = new Set([exports.ALL_DEVICES]);
61
112
  for (const found of discovered) {
62
113
  if (found.pid === undefined) {
63
114
  continue;
@@ -97,14 +148,89 @@ function listSvgFiles(dir) {
97
148
  return [];
98
149
  }
99
150
  }
100
- /** Local templates take priority over a same-named bundled one; both show up as options. */
151
+ const VARIANT_COLOUR_LETTERS = { B: "black", W: "white", R: "red", Y: "yellow" };
152
+ const VARIANT_FILENAME = /^(\d+)x(\d+)-([BWRY]+)\.svg$/;
153
+ /**
154
+ * Parses a template-family file name, e.g. `416x240-BWRY.svg` -> width 416, height 240, colours
155
+ * black/white/red/yellow (one letter per supported colour: B/W/R/Y). `undefined` for anything that
156
+ * doesn't match this convention, e.g. a plain, non-family template file.
157
+ */
158
+ function parseTemplateVariant(fileName) {
159
+ const match = VARIANT_FILENAME.exec(fileName);
160
+ if (!match)
161
+ return undefined;
162
+ const colours = match[3].split("").map((letter) => VARIANT_COLOUR_LETTERS[letter]);
163
+ return { fileName, width: Number(match[1]), height: Number(match[2]), colours };
164
+ }
165
+ function listTemplateVariants(dir) {
166
+ return listSvgFiles(dir)
167
+ .map(parseTemplateVariant)
168
+ .filter((variant) => variant !== undefined);
169
+ }
170
+ /** A directory only counts as a template-family option if it actually has at least one parseable variant file in it - otherwise it's something else entirely, e.g. `.assets`. */
171
+ function listTemplateFamilies(dir) {
172
+ let entries;
173
+ try {
174
+ entries = (0, fs_1.readdirSync)(dir, { withFileTypes: true });
175
+ }
176
+ catch {
177
+ return [];
178
+ }
179
+ return entries
180
+ .filter((entry) => entry.isDirectory() && listTemplateVariants((0, path_1.join)(dir, entry.name)).length > 0)
181
+ .map((entry) => entry.name);
182
+ }
183
+ /** Local templates (files or template-family directories) take priority over a same-named bundled one; both show up as options. */
101
184
  function templateNameOptions(templatesDir) {
102
- const local = listSvgFiles(templatesDir);
103
- const bundled = listSvgFiles(exports.BUNDLED_TEMPLATES_DIR).filter((name) => !local.includes(name));
185
+ const local = [...listSvgFiles(templatesDir), ...listTemplateFamilies(templatesDir)];
186
+ const bundled = [...listSvgFiles(exports.BUNDLED_TEMPLATES_DIR), ...listTemplateFamilies(exports.BUNDLED_TEMPLATES_DIR)].filter((name) => !local.includes(name));
104
187
  return [...local, ...bundled];
105
188
  }
106
- /** Resolves a template name to an actual file path - a local template overrides the bundled one of the same name. */
107
- function resolveTemplatePath(templatesDir, templateName) {
189
+ function sameColours(a, b) {
190
+ if (a.length !== b.length)
191
+ return false;
192
+ const setA = new Set(a);
193
+ return b.every((colour) => setA.has(colour));
194
+ }
195
+ /**
196
+ * Picks the best-fitting file within a template-family directory (see `parseTemplateVariant`) for a
197
+ * device's actual panel, trying each of these in turn: (1) an exact width/height/colour-set match,
198
+ * (2) width/height alone (ignoring colour-set) next, and (3) failing either, the nearest width -
199
+ * tie-broken by whichever candidate's own height/width ratio is closest to the target's, so a
200
+ * differently-proportioned panel doesn't just get an arbitrarily stretched/cropped near-width match.
201
+ */
202
+ function pickBestVariant(variants, target) {
203
+ if (variants.length === 0)
204
+ return undefined;
205
+ const exact = variants.find((v) => v.width === target.width && v.height === target.height && sameColours(v.colours, target.colours));
206
+ if (exact)
207
+ return exact;
208
+ const sizeMatch = variants.find((v) => v.width === target.width && v.height === target.height);
209
+ if (sizeMatch)
210
+ return sizeMatch;
211
+ const targetRatio = target.height / target.width;
212
+ const nearestWidth = Math.min(...variants.map((v) => Math.abs(v.width - target.width)));
213
+ const nearestWidthCandidates = variants.filter((v) => Math.abs(v.width - target.width) === nearestWidth);
214
+ return nearestWidthCandidates.reduce((best, candidate) => Math.abs(candidate.height / candidate.width - targetRatio) < Math.abs(best.height / best.width - targetRatio) ? candidate : best);
215
+ }
216
+ /**
217
+ * Resolves a template name to an actual file path - a local template overrides the bundled one of the
218
+ * same name. `templateName` can also name a template-family *directory* (see `pickBestVariant`), in
219
+ * which case `target` (the device's actual width/height/colours) picks the best file inside it. A
220
+ * `target`-less call, or one where `templateName` just isn't a directory, falls through to the plain
221
+ * flat-file behaviour - the only kind the CLI (which always names an exact file) ever uses.
222
+ */
223
+ function resolveTemplatePath(templatesDir, templateName, target) {
224
+ if (target) {
225
+ const dir = [(0, path_1.join)(templatesDir, templateName), (0, path_1.join)(exports.BUNDLED_TEMPLATES_DIR, templateName)].find((candidate) => (0, fs_1.existsSync)(candidate) && (0, fs_1.statSync)(candidate).isDirectory());
226
+ if (dir) {
227
+ const best = pickBestVariant(listTemplateVariants(dir), target);
228
+ if (!best) {
229
+ throw new Error(`template directory "${templateName}" has no valid "<width>x<height>-<colours>.svg" files`);
230
+ }
231
+ return (0, path_1.join)(dir, best.fileName);
232
+ }
233
+ }
108
234
  const localPath = (0, path_1.join)(templatesDir, templateName);
109
235
  return (0, fs_1.existsSync)(localPath) ? localPath : (0, path_1.join)(exports.BUNDLED_TEMPLATES_DIR, templateName);
110
236
  }
@@ -114,7 +240,7 @@ function withEnum(schema, values, names) {
114
240
  }
115
241
  function configSchema(app, discovered = []) {
116
242
  const defaults = defaultConfig();
117
- const current = { ...defaults, ...app.readPluginOptions() };
243
+ const current = { ...defaults, ...readCurrentConfig(app) };
118
244
  const { values: deviceValues, labels: deviceLabels } = deviceOptions(discovered, current);
119
245
  return {
120
246
  type: "object",
@@ -130,7 +256,8 @@ function configSchema(app, discovered = []) {
130
256
  scanOnStart: {
131
257
  type: "boolean",
132
258
  title: "Scan for devices on plugin start",
133
- description: 'Runs a short BLE scan so discovered devices show up in a device\'s "Device" picker below.',
259
+ description: 'Runs a short BLE scan so discovered devices show up in a device\'s "Device" picker below. ' +
260
+ 'Not needed if every device uses "All discovered devices" - that scans on demand instead.',
134
261
  default: defaults.scanOnStart,
135
262
  },
136
263
  scanDurationSeconds: {
@@ -180,7 +307,9 @@ function configSchema(app, discovered = []) {
180
307
  device: withEnum({
181
308
  type: "string",
182
309
  title: "Device",
183
- description: "Picked from devices found by a scan (plugin start, or `esl-cli scan`) - sets both the model and BLE address.",
310
+ description: "Either a specific device found by a scan (plugin start, or `esl-cli scan`), setting both the model and BLE " +
311
+ 'address, or "All discovered devices" to paint this same template/trigger to every device the plugin currently ' +
312
+ "knows about - simplest for a single label, and also covers several identical labels without listing each one.",
184
313
  }, deviceValues, deviceLabels),
185
314
  templateName: withEnum({ type: "string", title: "Template" }, templateNameOptions(resolveTemplatesDir(current.templatesDir))),
186
315
  repaintTrigger: {
@@ -0,0 +1,32 @@
1
+ import { ServerAPI } from "@signalk/server-api";
2
+ import { DiscoveredDevice } from "./types";
3
+ /**
4
+ * How long a previously-scanned device that isn't found again in a later scan is still kept as a
5
+ * dropdown option, before being dropped - covers a device that's simply asleep/out of BLE range for
6
+ * one scan, without keeping stale entries around forever. Not yet user-configurable.
7
+ */
8
+ export declare const DISCOVERED_DEVICE_TTL_MS: number;
9
+ export type DiscoveredDeviceRecord = DiscoveredDevice & {
10
+ lastSeenAt: number;
11
+ };
12
+ export type DiscoveredDevicesState = Record<string, DiscoveredDeviceRecord>;
13
+ export declare function loadDiscoveredDevices(app: ServerAPI): DiscoveredDevicesState;
14
+ /**
15
+ * Merges one scan's finds into the previously persisted set, keyed by BLE address (the one stable
16
+ * identifier across scans - see README's note on devices advertising under different names at
17
+ * different times). A device found again gets `lastSeenAt` bumped to `now`; one not found this time
18
+ * is kept as-is as long as it's within `DISCOVERED_DEVICE_TTL_MS` of its own `lastSeenAt`, and dropped
19
+ * once older than that - so the dropdown survives a plugin restart and the occasional missed scan,
20
+ * without accumulating devices that are gone for good.
21
+ */
22
+ export declare function mergeDiscoveredDevices(previous: DiscoveredDevicesState, foundThisScan: DiscoveredDevice[], now: number): DiscoveredDevicesState;
23
+ /** Loads the persisted set, merges in this scan's finds, saves the result, and returns it. */
24
+ export declare function recordScanResults(app: ServerAPI, foundThisScan: DiscoveredDevice[], now?: number): DiscoveredDevicesState;
25
+ /**
26
+ * Bumps a single device's `lastSeenAt` outside of a scan - called after a successful paint, since
27
+ * connecting to paint it is just as much positive proof it's still there as a discovery scan hit.
28
+ * Without this, a configured device that's actively being repainted (typically with `scanOnStart`
29
+ * off once set up, per the README) would otherwise silently age out of the persisted set after
30
+ * `DISCOVERED_DEVICE_TTL_MS` even though it's demonstrably still in range and working.
31
+ */
32
+ export declare function touchDiscoveredDevice(app: ServerAPI, device: DiscoveredDevice, now?: number): void;
@@ -0,0 +1,67 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DISCOVERED_DEVICE_TTL_MS = void 0;
4
+ exports.loadDiscoveredDevices = loadDiscoveredDevices;
5
+ exports.mergeDiscoveredDevices = mergeDiscoveredDevices;
6
+ exports.recordScanResults = recordScanResults;
7
+ exports.touchDiscoveredDevice = touchDiscoveredDevice;
8
+ const fs_1 = require("fs");
9
+ const path_1 = require("path");
10
+ /**
11
+ * How long a previously-scanned device that isn't found again in a later scan is still kept as a
12
+ * dropdown option, before being dropped - covers a device that's simply asleep/out of BLE range for
13
+ * one scan, without keeping stale entries around forever. Not yet user-configurable.
14
+ */
15
+ exports.DISCOVERED_DEVICE_TTL_MS = 24 * 60 * 60 * 1000;
16
+ function storePath(app) {
17
+ return (0, path_1.join)(app.getDataDirPath(), "discovered-devices.json");
18
+ }
19
+ function loadDiscoveredDevices(app) {
20
+ try {
21
+ return JSON.parse((0, fs_1.readFileSync)(storePath(app), "utf-8"));
22
+ }
23
+ catch {
24
+ return {};
25
+ }
26
+ }
27
+ function saveDiscoveredDevices(app, state) {
28
+ (0, fs_1.writeFileSync)(storePath(app), JSON.stringify(state));
29
+ }
30
+ /**
31
+ * Merges one scan's finds into the previously persisted set, keyed by BLE address (the one stable
32
+ * identifier across scans - see README's note on devices advertising under different names at
33
+ * different times). A device found again gets `lastSeenAt` bumped to `now`; one not found this time
34
+ * is kept as-is as long as it's within `DISCOVERED_DEVICE_TTL_MS` of its own `lastSeenAt`, and dropped
35
+ * once older than that - so the dropdown survives a plugin restart and the occasional missed scan,
36
+ * without accumulating devices that are gone for good.
37
+ */
38
+ function mergeDiscoveredDevices(previous, foundThisScan, now) {
39
+ const next = {};
40
+ for (const found of foundThisScan) {
41
+ next[found.address] = { ...found, lastSeenAt: now };
42
+ }
43
+ for (const [address, record] of Object.entries(previous)) {
44
+ if (!(address in next) && now - record.lastSeenAt < exports.DISCOVERED_DEVICE_TTL_MS) {
45
+ next[address] = record;
46
+ }
47
+ }
48
+ return next;
49
+ }
50
+ /** Loads the persisted set, merges in this scan's finds, saves the result, and returns it. */
51
+ function recordScanResults(app, foundThisScan, now = Date.now()) {
52
+ const merged = mergeDiscoveredDevices(loadDiscoveredDevices(app), foundThisScan, now);
53
+ saveDiscoveredDevices(app, merged);
54
+ return merged;
55
+ }
56
+ /**
57
+ * Bumps a single device's `lastSeenAt` outside of a scan - called after a successful paint, since
58
+ * connecting to paint it is just as much positive proof it's still there as a discovery scan hit.
59
+ * Without this, a configured device that's actively being repainted (typically with `scanOnStart`
60
+ * off once set up, per the README) would otherwise silently age out of the persisted set after
61
+ * `DISCOVERED_DEVICE_TTL_MS` even though it's demonstrably still in range and working.
62
+ */
63
+ function touchDiscoveredDevice(app, device, now = Date.now()) {
64
+ const state = loadDiscoveredDevices(app);
65
+ state[device.address] = { ...device, lastSeenAt: now };
66
+ saveDiscoveredDevices(app, state);
67
+ }
@@ -0,0 +1,19 @@
1
+ import { ServerAPI } from "@signalk/server-api";
2
+ import { DiscoveredDevicesState } from "./discoveredDevicesStore";
3
+ import { DiscoveredDevice } from "./types";
4
+ export interface ScanResult {
5
+ /** Devices this particular scan actually found - distinct from `merged`, which also carries over anything still-fresh from before. */
6
+ foundThisScan: DiscoveredDevice[];
7
+ /** The full persisted set after merging `foundThisScan` in - see `discoveredDevicesStore.ts`. */
8
+ merged: DiscoveredDevicesState;
9
+ }
10
+ /** epoch ms a scan was started at, if one is currently running - `undefined` otherwise. */
11
+ export declare function scanInProgressSince(): number | undefined;
12
+ /**
13
+ * Runs one BLE discovery scan and persists what it finds. node-ble/BlueZ has no scan-cancellation
14
+ * API and can't run two discovery sessions at once, so a caller that arrives while a scan is
15
+ * already running (e.g. the startup scan and an on-demand scan for a `device: "ALL"` repaint both
16
+ * wanting to scan at the same moment) is hooked into that *same* in-flight scan's eventual result
17
+ * instead of starting a second BlueZ session, which would make both fail.
18
+ */
19
+ export declare function ensureScan(app: ServerAPI, durationSeconds: number): Promise<ScanResult>;
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.scanInProgressSince = scanInProgressSince;
4
+ exports.ensureScan = ensureScan;
5
+ const bleDiscovery_1 = require("./bleDiscovery");
6
+ const discoveredDevicesStore_1 = require("./discoveredDevicesStore");
7
+ const registry_1 = require("./registry");
8
+ let inFlight;
9
+ /** epoch ms a scan was started at, if one is currently running - `undefined` otherwise. */
10
+ function scanInProgressSince() {
11
+ return inFlight?.startedAt;
12
+ }
13
+ /**
14
+ * Runs one BLE discovery scan and persists what it finds. node-ble/BlueZ has no scan-cancellation
15
+ * API and can't run two discovery sessions at once, so a caller that arrives while a scan is
16
+ * already running (e.g. the startup scan and an on-demand scan for a `device: "ALL"` repaint both
17
+ * wanting to scan at the same moment) is hooked into that *same* in-flight scan's eventual result
18
+ * instead of starting a second BlueZ session, which would make both fail.
19
+ */
20
+ function ensureScan(app, durationSeconds) {
21
+ if (inFlight) {
22
+ return inFlight.promise;
23
+ }
24
+ const promise = runScan(app, durationSeconds).finally(() => {
25
+ inFlight = undefined;
26
+ });
27
+ inFlight = { startedAt: Date.now(), promise };
28
+ return promise;
29
+ }
30
+ async function runScan(app, durationSeconds) {
31
+ const foundThisScan = [];
32
+ const drivers = (0, registry_1.allDrivers)();
33
+ try {
34
+ await (0, bleDiscovery_1.withDiscovery)(durationSeconds * 1000, async (adapter) => {
35
+ await (0, bleDiscovery_1.forEachAdvertisedDevice)(adapter, async ({ device, address, name, manufacturerId, manufacturerData }) => {
36
+ const driver = drivers.find((candidate) => candidate.matchesAdvertisement(name, manufacturerId));
37
+ if (!driver) {
38
+ return;
39
+ }
40
+ const found = await driver.identifyDevice(device, address, name, manufacturerId, manufacturerData).catch((err) => {
41
+ app.debug(`${driver.vendor} scan failed: ${err.message}\n${err.stack ?? ""}`);
42
+ return undefined;
43
+ });
44
+ if (!found) {
45
+ return;
46
+ }
47
+ foundThisScan.push(found);
48
+ const pid = found.pid !== undefined ? `0x${found.pid.toString(16).padStart(4, "0")}` : "unknown";
49
+ const hwid = found.hwVersion ?? "unknown";
50
+ app.debug(`discovered ${driver.vendor} device "${found.name ?? ""}" [${found.address}] pid=${pid} hwid=${hwid}`);
51
+ });
52
+ });
53
+ }
54
+ catch (err) {
55
+ app.debug(`scan failed: ${err.message}\n${err.stack ?? ""}`);
56
+ }
57
+ const merged = (0, discoveredDevicesStore_1.recordScanResults)(app, foundThisScan);
58
+ return { foundThisScan, merged };
59
+ }