@rhizomatics/signalk-einklabel-plugin 1.3.0-beta1 → 1.3.0-beta10

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,6 +1,30 @@
1
1
  # 1.3.0
2
2
 
3
- First implementation of using new SignalK BLE Manager rather than directly using the `bluez` services. Off by default until longer term stability demonstrated.
3
+ ## BLE Manager
4
+
5
+ First implementation of using new SignalK BLE Manager rather than directly using the `bluez` services.
6
+
7
+ - Off by default until longer term stability demonstrated.
8
+ - It may be less reliable for some devices that can be reached with direct bluez access - try it and fall back to the direct mode if so.
9
+ - BLE Manager use reduces interference between plugins competing for same BLE devices
10
+ - Recommend using SignalK at least release v2.33.0.
11
+
12
+ ## Compression
13
+
14
+ Images are now compressed before sending - no change to what's displayed, but send time is massively reduced for labels with lots of empty space, so less battery is used and sends are less likely to fail.
15
+
16
+ - On by default for Zhsunyco labels and Gicisky 7.5"/10.2" labels; can be turned off per label, or with `--no-compress` on the CLI
17
+ - Experimental opt-in _Wire format_ `chunked` sends a Gicisky 4.2" BWR compressed too (`--compression-format chunked` on the CLI)
18
+
19
+ ## Advanced Options
20
+
21
+ Connect timeout and retries can now be defined per label.
22
+
23
+ Some labels may need images flipped, so a per-label _Mirror_ option can flip the image horizontally, vertically, or both (rotate 180°, for a label mounted upside down). Also available as `--mirror` on the CLI `paint` and `render` commands.
24
+
25
+ Different 'chunking' options to help with new label models.
26
+
27
+ All the advanced options now grouped together to make the config clearer. Note that if you roll back to a previous version you may have to re-enter these values (they are automatically moved to the new section when upgrading).
4
28
 
5
29
  # 1.2.3
6
30
 
package/README.md CHANGED
@@ -134,10 +134,16 @@ Enable the plugin, and use the large **+** sign to add a label, which opens up t
134
134
  - If it's a SignalK path, enter it next, for example `environment.tide.state`
135
135
  - If it's time based, enter how many hours between repaints, for example 00:00/08:00/16:00 for an 8h schedule, and if you want a specific number of minutes after the hour.
136
136
 
137
- There are also two more advanced options, which can usually be ignored.
137
+ - _If the render doesn't match the panel size_ - see [Reframing](#reframing)
138
138
 
139
- - _BLE AES key_ - Only needed if the default key doesn't work and you have a better alternative, otherwise ignore
139
+ The rest are grouped under _Advanced settings_, and can usually be ignored.
140
+
141
+ - _Compress upload_, _Mirror_ and _Wire format_ - see [Other Image Options](#other-image-options)
140
142
  - _Force Repaint_ - Next time the label is due to be painted, update even if the data or template hasn't changed (this flag will automatically be cleared after this.)
143
+ - _BLE AES key_ - Only needed if the default key doesn't work and you have a better alternative, otherwise ignore
144
+ - _Paint connect timeout_ and _Paint retries_ for this device - leave blank to use the plugin-wide settings, or set them for a label that's slower to respond or further away than the others
145
+
146
+ Configs saved by earlier versions are moved into this layout automatically when the plugin starts.
141
147
 
142
148
  When the plugin starts, it will automatically re-paint the label if it's new, or the last timed slot was missed and the data has changed.
143
149
 
@@ -178,6 +184,14 @@ Templates are simply SVG files, to which expressions can be added to use SignalK
178
184
 
179
185
  There's some wiggle room with the `reframe` options to use a template that's a bit too small, or too large, for the label, although best results come from a template that's precisely matching the pixel height and width of the label. Next best is template that has the same aspect ratio, so it can be cleanly scaled. `crop` is the 2nd least worst, though if its only a handful of pixels its often not worth worring about a separate template and `crop` is just fine. `scale` is likely to look worst, since it will force an image in regardless of aspect ratio.
180
186
 
187
+ ### Other Image Options
188
+
189
+ Each label has a few more settings for how the image is sent, under _Advanced settings_. The CLI `paint` command has matching options (see [Command Line Interface](#command-line-interface)), so you can try them on a label before changing the plugin config.
190
+
191
+ - _Compress upload_ - on by default. Sends much less data over Bluetooth, so painting is quicker, uses less of the label's battery and is less likely to time out. Works for Zhsunyco labels and Gicisky 7.5"/10.2" labels, and is ignored for others. Turn it off if a label stops updating.
192
+ - _Wire format (Gicisky, experimental)_ - `auto` by default. `chunked` sends a Gicisky 4.2" BWR label compressed, the same way as the 7.5"/10.2". The vendor's own app has been seen doing this, but it hasn't been tested on current firmware. Set it back to `auto` if the label stops updating.
193
+ - _Mirror_ - `none` by default. `horizontal` or `vertical` fixes a label model whose image comes out mirrored. `both` rotates the image 180°, for a label that has to be mounted upside down.
194
+
181
195
  ### Template Families (multiple panel sizes/colours)
182
196
 
183
197
  A "Template" selection can either be one specific `.svg` file, or a _directory_ holding several versions of the same template 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.
@@ -309,6 +323,12 @@ Left unset, both `render` and `paint` default `-w/--width`/`--height` to the tem
309
323
 
310
324
  The main SignalK plugin offers the same choice per device (defaulting to `crop` there too) in each device's own config - "If the render doesn't match the panel size".
311
325
 
326
+ `paint` has matching options for the other per-label image settings too (see [Other Image Options](#other-image-options)):
327
+
328
+ - `--mirror <mode>` - `none` (default), `horizontal`, `vertical`, or `both` (rotate 180°). `render` also takes `--mirror`, to preview the flip as a PNG without a label
329
+ - `--no-compress` - send the image uncompressed, to rule compression out if a label won't update
330
+ - `--compression-format <format>` - `auto` (default) or `chunked`, to try the experimental compressed format on a Gicisky 4.2" BWR
331
+
312
332
  `esl-cli` can also be extended with new subcommands by a `-r/--require`'d package - see [Extending](#extending) below - which is how [`@rhizomatics/signalk-einklabel-genai-plugin`](#genai-rendering) adds its own `prompt`/`generate` commands for testing prompts without a device.
313
333
 
314
334
  ( The CLI can also be run from a checked out module, or by opening a terminal shell at `~/.signalk/node_modules/@rhizomatics/signalk-einklabel-plugin`, as `npx esl-cli command --args` )
@@ -377,12 +397,24 @@ The label address previously discovered via `esl-cli scan`
377
397
  npx esl-cli paint -t templates/tides/250x128-BWRY.svg -a FF:FF:92:84:53:93
378
398
  ```
379
399
 
380
- If the label turns out to be a different size than the template (e.g. it's a 250x128 template on a 416x240 panel), that's normally rejected as a mismatch - add `--reframe` to fit it instead:
400
+ If the label turns out to be a different size than the template (e.g. it's a 250x128 template on a 416x240 panel), it's cropped to fit by default - add `--reframe scale` to stretch it instead:
381
401
 
382
402
  ```bash
383
403
  npx esl-cli paint -t templates/tides/250x128-BWRY.svg -a FF:FF:92:84:53:93 --reframe scale
384
404
  ```
385
405
 
406
+ If a label doesn't update, try sending it uncompressed to see whether compression is the cause:
407
+
408
+ ```bash
409
+ npx esl-cli paint -t templates/tides/250x128-BWRY.svg -a FF:FF:92:84:53:93 --no-compress
410
+ ```
411
+
412
+ If the image comes out mirrored, try each `--mirror` mode until it looks right, then set the same _Mirror_ option in the label's config:
413
+
414
+ ```bash
415
+ npx esl-cli paint -t templates/tides/250x128-BWRY.svg -a FF:FF:92:84:53:93 --mirror horizontal
416
+ ```
417
+
386
418
  #### Test Template Without Updating Label
387
419
 
388
420
  This will work even if you don't have a label, or even bluetooth. (The `-u` can be left out if your SignalK server running locally on default ports).
@@ -537,6 +569,8 @@ USB_DENYLIST="0b05:190e"
537
569
 
538
570
  ## Other ESL and General eInk Resources
539
571
 
572
+ ### Components
573
+
540
574
  - [Open ePaper Link](https://openepaperlink.de) - Alternative open source firmware to flash onto eInk shelf labels, with Home Assistant integration.
541
575
  - [zhsunyco-esl](https://github.com/roxburghm/zhsunyco-esl) - Python interface
542
576
  - [WoLink](https://github.com/NickWaterton/Wolink) - Python interface and protocol analysis
@@ -545,9 +579,14 @@ USB_DENYLIST="0b05:190e"
545
579
  - [esp32-esl-system](https://github.com/giobauermeister/esp32-esl-system) - Docker and ESP32 based system for updating ESLs.
546
580
  - [hass-gicisky](https://github.com/eigger/hass-gicisky) - Home Assistant integration for Gicisky ESLs ( a similar vendor to Zhsunyco). Uses [imagespec](https://github.com/eigger/imagespec) for templating.
547
581
  - [ha-panda](https://github.com/moryoav/ha-panda) - Home Assistant integration for Panda ESLs ( a similar vendor to Zhsunyco).
582
+
583
+ ### Notes and Experiences
584
+
585
+ - [Cabalist Gicisky Image Notes](https://github.com/Cabalist/gicisky_image_notes)
548
586
  - [Dmitry.gr](https://dmitry.gr/?r=05.Projects&proj=29.%20eInk%20Price%20Tags) - Personal site of an ESL hacker
549
587
  - [Aaron Christobel](https://www.youtube.com/@atc1441) - YouTube channel of an ESL hacker.
550
588
  - [rbaron.net](https://rbaron.net/blog/2022/07/29/Daisy-chaining-multiple-electronic-shelf-labels) - Blog of an early ESL hacker.
589
+ ### Retail
551
590
  - [Pimoroni](https://shop.pimoroni.com/collections/displays?tags=e-ink%20Displays) - All shapes and sizes of eInk displays, aimed at hackers, and with an [inky](https://github.com/pimoroni/inky) GitHub project to support them.
552
591
  - [WaveShare](https://www.waveshare.com/product/displays/e-paper.htm) - Wide range of eInk displays for hardware projects, not limited to ESLs.
553
592
 
@@ -1,13 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
- import { Colour } from "../devices/types";
3
+ import { Colour, CompressionFormat } from "../devices/types";
4
4
  import { ReframeMode } from "../render/reframe";
5
+ import { MirrorMode } from "../render/mirror";
5
6
  import { Binding } from "../render/binding";
6
7
  import { TemplateContext } from "../render/types";
7
8
  /** Tried in order when -u/--url is omitted (and -e/--example-data isn't given) - first one that answers wins. */
8
9
  export declare const DEFAULT_SIGNALK_URLS: string[];
9
10
  export declare function parseColours(code: string): Colour[];
10
11
  export declare function parseReframeMode(value: string): ReframeMode;
12
+ export declare function parseMirrorMode(value: string): MirrorMode;
13
+ export declare function parseCompressionFormat(value: string): CompressionFormat;
11
14
  /** Shared by every command that takes -u/--url and -e/--example-data - -e wins when both are present; when neither is given, probes DEFAULT_SIGNALK_URLS for a default. */
12
15
  export declare function assembleContext(opts: {
13
16
  url?: string;
package/dist/cli/index.js CHANGED
@@ -4,6 +4,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.program = exports.DEFAULT_SIGNALK_URLS = void 0;
5
5
  exports.parseColours = parseColours;
6
6
  exports.parseReframeMode = parseReframeMode;
7
+ exports.parseMirrorMode = parseMirrorMode;
8
+ exports.parseCompressionFormat = parseCompressionFormat;
7
9
  exports.assembleContext = assembleContext;
8
10
  const promises_1 = require("fs/promises");
9
11
  const path_1 = require("path");
@@ -13,6 +15,8 @@ const registry_1 = require("../devices/registry");
13
15
  const zhsunyco_1 = require("../devices/zhsunyco");
14
16
  const gicisky_1 = require("../devices/gicisky");
15
17
  const bleDiscovery_1 = require("../devices/bleDiscovery");
18
+ const types_1 = require("../devices/types");
19
+ const mirror_1 = require("../render/mirror");
16
20
  const svgRenderer_1 = require("../render/svgRenderer");
17
21
  const png_1 = require("../render/png");
18
22
  const binding_1 = require("../render/binding");
@@ -45,6 +49,18 @@ function parseReframeMode(value) {
45
49
  }
46
50
  return value;
47
51
  }
52
+ function parseMirrorMode(value) {
53
+ if (!mirror_1.MIRROR_MODES.includes(value)) {
54
+ throw new Error(`unknown --mirror value "${value}" - expected one of ${mirror_1.MIRROR_MODES.join(", ")}`);
55
+ }
56
+ return value;
57
+ }
58
+ function parseCompressionFormat(value) {
59
+ if (!types_1.COMPRESSION_FORMATS.includes(value)) {
60
+ throw new Error(`unknown --compression-format value "${value}" - expected one of ${types_1.COMPRESSION_FORMATS.join(", ")}`);
61
+ }
62
+ return value;
63
+ }
48
64
  /** Probes DEFAULT_SIGNALK_URLS in order and returns the first that answers a plain GET - used when -u/--url is omitted. */
49
65
  async function resolveDefaultUrl() {
50
66
  for (const candidate of exports.DEFAULT_SIGNALK_URLS) {
@@ -226,6 +242,9 @@ exports.program
226
242
  .option("--voffset <px>", "vertical pixel offset of the panel - overrides the looked-up model for unsupported hardware (requires --colours)", "0")
227
243
  .option("--colours <code>", "device colour palette for unsupported hardware: BW, BWR, or BWRY - overrides the looked-up model (uses --width/--height/--voffset)")
228
244
  .option("--reframe <mode>", "how to fit the rendered image onto the device's actual panel size when it doesn't match: crop (default - place at top-left, truncating or leaving the rest blank), scale (stretch the template to the panel), fixed (reject the mismatch instead)", "crop")
245
+ .option("--mirror <mode>", "flip the image before sending: none (default), horizontal, vertical, or both (rotate 180°)", "none")
246
+ .option("--no-compress", 'send the image uncompressed (zhsunyco; gicisky 7.5"/10.2" or --compression-format chunked - compression is on by default)')
247
+ .option("--compression-format <format>", 'gicisky wire format: auto (default - the model\'s usual format) or chunked (experimental - send a 4.2" BWR compressed like the 7.5"/10.2")', "auto")
229
248
  .option("--connect-timeout <seconds>", "BLE connect timeout before giving up on an attempt", "30")
230
249
  .option("--retries <n>", "number of paint attempts (including the first) before giving up", "3")
231
250
  .action(async (opts) => {
@@ -269,6 +288,9 @@ exports.program
269
288
  modelOverride,
270
289
  connectTimeoutMs,
271
290
  reframe: parseReframeMode(opts.reframe),
291
+ mirror: parseMirrorMode(opts.mirror),
292
+ compress: opts.compress,
293
+ compressionFormat: parseCompressionFormat(opts.compressionFormat),
272
294
  });
273
295
  });
274
296
  console.log(`painted ${opts.address} (${bitmap.width}x${bitmap.height}) ${opts.colours}`);
@@ -287,7 +309,9 @@ exports.program
287
309
  .option("-f, --font <path>", "override a bundled font with this file (repeatable) - defaults to the bundled monospace/sans-serif/serif trio",
288
310
  // See the -r/--require option above for why `| undefined` is needed here despite commander's typings.
289
311
  (value, previous = []) => [...previous, value])
312
+ .option("--mirror <mode>", "flip the PNG the same way paint --mirror would: none (default), horizontal, vertical, or both", "none")
290
313
  .action(async (opts) => {
314
+ const mirror = parseMirrorMode(opts.mirror);
291
315
  const svgSource = await (0, promises_1.readFile)(opts.template, "utf-8");
292
316
  const declared = (0, binding_1.readTemplateDimensions)(svgSource);
293
317
  const width = resolveDimension(opts.width, [declared.width], DEFAULT_RENDER_WIDTH);
@@ -295,7 +319,7 @@ exports.program
295
319
  const bindings = (0, binding_1.findBindings)(svgSource);
296
320
  const context = await assembleContext(opts, bindings);
297
321
  const renderer = opts.font ? new svgRenderer_1.SvgRenderer(opts.font) : new svgRenderer_1.SvgRenderer();
298
- const bitmap = await renderer.render(opts.template, context, width, height, (0, path_1.dirname)(opts.template), config_1.BUNDLED_TEMPLATES_DIR);
322
+ const bitmap = (0, mirror_1.mirrorBitmap)(await renderer.render(opts.template, context, width, height, (0, path_1.dirname)(opts.template), config_1.BUNDLED_TEMPLATES_DIR), mirror);
299
323
  await (0, promises_1.writeFile)(opts.output, (0, png_1.bitmapToPng)(bitmap));
300
324
  console.log(`wrote ${opts.output} (${bitmap.width}x${bitmap.height})`);
301
325
  });
package/dist/config.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { ServerAPI } from "@signalk/server-api";
2
- import { Colour, DiscoveredDevice } from "./devices/types";
2
+ import { Colour, CompressionFormat, DiscoveredDevice } from "./devices/types";
3
3
  import { ReframeMode } from "./render/reframe";
4
+ import { MirrorMode } from "./render/mirror";
4
5
  /**
5
6
  * Special `device` value meaning "every currently-known discovered device" instead of one specific
6
7
  * BLE address - lets a single `DeviceConfig` entry (one template, one trigger) broadcast to every
@@ -26,8 +27,6 @@ export interface DeviceConfig {
26
27
  * `./render/templateProviders.ts`) tailoring content to where the label actually sits.
27
28
  */
28
29
  description?: string;
29
- /** Per-device override; if omitted, the vendor driver may fall back to a stock/manufacturer-default key. */
30
- aesKey?: string;
31
30
  /**
32
31
  * Either one specific `.svg` file, or the name of a *template-family* directory holding several
33
32
  * same-purpose templates for different panel sizes/colour-sets, named `<width>x<height>-<colours>.svg`
@@ -48,8 +47,6 @@ export interface DeviceConfig {
48
47
  intervalHours?: number;
49
48
  /** ...at this minute past the hour. */
50
49
  intervalMinute?: number;
51
- /** One-shot override to repaint even if the data is unchanged; cleared automatically once that repaint completes. */
52
- forceRepaint?: boolean;
53
50
  /**
54
51
  * How to fit the rendered image onto the device's actual panel size when it doesn't match (see
55
52
  * `ReframeMode`) - e.g. a template family with no variant sized for this particular label. Left
@@ -58,7 +55,34 @@ export interface DeviceConfig {
58
55
  * showing *something*, even off-size, beats a repaint that just fails outright.
59
56
  */
60
57
  reframe?: ReframeMode;
58
+ /** Settings most labels never need, grouped so the admin UI shows them in their own "Advanced settings" box. */
59
+ advanced?: AdvancedDeviceSettings;
61
60
  }
61
+ export interface AdvancedDeviceSettings {
62
+ /** Compress the upload (zhsunyco, and gicisky's chunked 7.5"/10.2" panels - ignored otherwise). Unset means on; turn off if a device fails to show compressed images. */
63
+ compress?: boolean;
64
+ /** Flip the image before sending - for a panel whose layout is mirrored, or one mounted upside down (`"both"`). Unset means `"none"`. */
65
+ mirror?: MirrorMode;
66
+ /**
67
+ * Experimental opt-in wire format (gicisky only) - `"chunked"` sends a 4.2" BWR (or another plain
68
+ * two-plane panel) QuickLZ-compressed like the 7.5"/10.2". Unset means `"auto"`, the model's own
69
+ * format. See `CompressionFormat`.
70
+ */
71
+ compressionFormat?: CompressionFormat;
72
+ /** One-shot override to repaint even if the data is unchanged; cleared automatically once that repaint completes. */
73
+ forceRepaint?: boolean;
74
+ /** Per-device override; if omitted, the vendor driver may fall back to a stock/manufacturer-default key. */
75
+ aesKey?: string;
76
+ /** Per-device override of `PluginConfig.paintConnectTimeoutSeconds` - unset uses the plugin-wide value. */
77
+ paintConnectTimeoutSeconds?: number;
78
+ /** Per-device override of `PluginConfig.paintRetries` - unset uses the plugin-wide value. */
79
+ paintRetries?: number;
80
+ }
81
+ /** Applies `migrateDeviceConfig` to every device entry - see `healStoredConfig` for persisting the result. */
82
+ export declare function migrateConfig<T extends Partial<PluginConfig>>(config: T): {
83
+ config: T;
84
+ migrated: boolean;
85
+ };
62
86
  export interface PluginConfig {
63
87
  /**
64
88
  * Directory the plugin scans for template files, instead of an upload UI - follows
@@ -71,6 +95,9 @@ export interface PluginConfig {
71
95
  * signalk-bluetti-plugin does. Off by default - a `device: ALL_DEVICES` entry scans on demand the
72
96
  * first time it has nothing discovered yet (see `resolveTargets` in `repaintScheduler.ts`), and an
73
97
  * explicit device selection only ever needed this to populate the dropdown once at initial setup.
98
+ * Also unnecessary with `useBleApi` on, which discovers continuously in the background instead (see
99
+ * `startBleApiDiscoveryListener` in `discoveryCoordinator.ts`) - kept meaningful mainly for direct
100
+ * BlueZ access, which has no continuous-scan equivalent to piggyback on.
74
101
  */
75
102
  scanOnStart: boolean;
76
103
  /** How long the startup scan runs, in seconds. */
@@ -137,16 +164,21 @@ export declare const RENDER_FALLBACK_TEMPLATE_NAME = ".error";
137
164
  export declare function defaultConfig(): PluginConfig;
138
165
  export declare function readCurrentConfig(app: ServerAPI): Partial<PluginConfig>;
139
166
  /**
140
- * Actively rewrites the on-disk file once it's nested (see `readCurrentConfig`'s doc comment) -
141
- * `readCurrentConfig` alone only self-heals in memory for callers that go through it, but the admin
142
- * UI's own config-editing form round-trips whatever raw JSON it was handed verbatim, including a
143
- * stray nested `configuration` key it never touches (no schema field maps to it) - so left alone,
144
- * every future save from the UI keeps re-persisting that dead weight forever (see
145
- * `support/signalk-einklabel-plugin.json`). Called once at plugin start, which - unlike
146
- * `clearForceRepaint` - isn't gated on any device having `forceRepaint` set, so a nested file gets
147
- * flattened even if nothing ever triggers that path.
167
+ * Actively rewrites the on-disk file when it's in an outdated shape - `readCurrentConfig` alone only
168
+ * fixes things up in memory for callers that go through it, but the admin UI's config form
169
+ * round-trips whatever raw JSON it was handed verbatim. Two shapes are healed:
170
+ *
171
+ * - A nested file (see `readCurrentConfig`'s doc comment): left alone, the UI keeps re-persisting a
172
+ * stray nested `configuration` key it never touches (no schema field maps to it) forever (see
173
+ * `support/signalk-einklabel-plugin.json`).
174
+ * - Advanced device settings saved before they were grouped under `advanced` (see
175
+ * `migrateDeviceConfig`): left alone, the UI would show that group empty, even though the plugin
176
+ * itself still honours the old values.
177
+ *
178
+ * Called once at plugin start, which - unlike `clearForceRepaint` - isn't gated on any device having
179
+ * `forceRepaint` set, so an outdated file gets fixed even if nothing ever triggers that path.
148
180
  */
149
- export declare function healNestedConfig(app: ServerAPI): void;
181
+ export declare function healStoredConfig(app: ServerAPI): void;
150
182
  /** See `resolveDir` - `templatesDir`'s own resolution. */
151
183
  export declare function resolveTemplatesDir(templatesDir: string | undefined): string;
152
184
  export declare function parseDevice(device: string): {
package/dist/config.js CHANGED
@@ -1,9 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.RENDER_FALLBACK_TEMPLATE_NAME = exports.BUNDLED_TEMPLATES_DIR = exports.ALL_DEVICES = void 0;
4
+ exports.migrateConfig = migrateConfig;
4
5
  exports.defaultConfig = defaultConfig;
5
6
  exports.readCurrentConfig = readCurrentConfig;
6
- exports.healNestedConfig = healNestedConfig;
7
+ exports.healStoredConfig = healStoredConfig;
7
8
  exports.resolveTemplatesDir = resolveTemplatesDir;
8
9
  exports.parseDevice = parseDevice;
9
10
  exports.resolveTemplatePath = resolveTemplatePath;
@@ -23,6 +24,49 @@ const resolveApiUrl_1 = require("./resolveApiUrl");
23
24
  * an on-demand scan itself if nothing's been discovered yet.
24
25
  */
25
26
  exports.ALL_DEVICES = "ALL";
27
+ /**
28
+ * Every `AdvancedDeviceSettings` key - these all used to sit directly on `DeviceConfig`, so a config
29
+ * saved before they were grouped still has them there. See `migrateDeviceConfig`.
30
+ */
31
+ const ADVANCED_DEVICE_KEYS = [
32
+ "compress",
33
+ "mirror",
34
+ "compressionFormat",
35
+ "forceRepaint",
36
+ "aesKey",
37
+ "paintConnectTimeoutSeconds",
38
+ "paintRetries",
39
+ ];
40
+ /**
41
+ * Moves any `AdvancedDeviceSettings` key found at the top level of a device entry (the pre-grouping
42
+ * layout) into its `advanced` object, reporting whether anything moved. A value already under
43
+ * `advanced` wins over a legacy top-level one - it can only have got there from the grouped form, so
44
+ * it's the newer of the two.
45
+ */
46
+ function migrateDeviceConfig(raw) {
47
+ const legacy = {};
48
+ const rest = { ...raw };
49
+ for (const key of ADVANCED_DEVICE_KEYS) {
50
+ if (key in rest) {
51
+ legacy[key] = rest[key];
52
+ delete rest[key];
53
+ }
54
+ }
55
+ if (Object.keys(legacy).length === 0) {
56
+ return { device: raw, migrated: false };
57
+ }
58
+ return { device: { ...rest, advanced: { ...legacy, ...raw.advanced } }, migrated: true };
59
+ }
60
+ /** Applies `migrateDeviceConfig` to every device entry - see `healStoredConfig` for persisting the result. */
61
+ function migrateConfig(config) {
62
+ if (!Array.isArray(config.devices)) {
63
+ return { config, migrated: false };
64
+ }
65
+ const results = config.devices.map(migrateDeviceConfig);
66
+ return results.some((result) => result.migrated)
67
+ ? { config: { ...config, devices: results.map((result) => result.device) }, migrated: true }
68
+ : { config, migrated: false };
69
+ }
26
70
  /**
27
71
  * The package's own bundled `templates/` directory (ships alongside `dist/`, see
28
72
  * package.json's `files`) - templates here are always available, but a same-named template in the
@@ -108,27 +152,33 @@ function pickKnownKeys(raw) {
108
152
  }
109
153
  function readCurrentConfig(app) {
110
154
  const { unwrapped } = unwrapNestedConfiguration(app.readPluginOptions());
111
- return pickKnownKeys(unwrapped);
155
+ return migrateConfig(pickKnownKeys(unwrapped)).config;
112
156
  }
113
157
  /**
114
- * Actively rewrites the on-disk file once it's nested (see `readCurrentConfig`'s doc comment) -
115
- * `readCurrentConfig` alone only self-heals in memory for callers that go through it, but the admin
116
- * UI's own config-editing form round-trips whatever raw JSON it was handed verbatim, including a
117
- * stray nested `configuration` key it never touches (no schema field maps to it) - so left alone,
118
- * every future save from the UI keeps re-persisting that dead weight forever (see
119
- * `support/signalk-einklabel-plugin.json`). Called once at plugin start, which - unlike
120
- * `clearForceRepaint` - isn't gated on any device having `forceRepaint` set, so a nested file gets
121
- * flattened even if nothing ever triggers that path.
158
+ * Actively rewrites the on-disk file when it's in an outdated shape - `readCurrentConfig` alone only
159
+ * fixes things up in memory for callers that go through it, but the admin UI's config form
160
+ * round-trips whatever raw JSON it was handed verbatim. Two shapes are healed:
161
+ *
162
+ * - A nested file (see `readCurrentConfig`'s doc comment): left alone, the UI keeps re-persisting a
163
+ * stray nested `configuration` key it never touches (no schema field maps to it) forever (see
164
+ * `support/signalk-einklabel-plugin.json`).
165
+ * - Advanced device settings saved before they were grouped under `advanced` (see
166
+ * `migrateDeviceConfig`): left alone, the UI would show that group empty, even though the plugin
167
+ * itself still honours the old values.
168
+ *
169
+ * Called once at plugin start, which - unlike `clearForceRepaint` - isn't gated on any device having
170
+ * `forceRepaint` set, so an outdated file gets fixed even if nothing ever triggers that path.
122
171
  */
123
- function healNestedConfig(app) {
172
+ function healStoredConfig(app) {
124
173
  const { unwrapped, wasNested } = unwrapNestedConfiguration(app.readPluginOptions());
125
- if (!wasNested)
174
+ const { config, migrated } = migrateConfig(pickKnownKeys(unwrapped));
175
+ if (!wasNested && !migrated)
126
176
  return;
127
- app.savePluginOptions(pickKnownKeys(unwrapped), (err) => {
177
+ app.savePluginOptions(config, (err) => {
128
178
  if (err)
129
- app.debug(`failed to clean up legacy nested plugin config: ${err.message}`);
179
+ app.debug(`failed to update the stored plugin config: ${err.message}`);
130
180
  else
131
- app.debug("cleaned up a legacy nested plugin config file on disk");
181
+ app.debug(`updated the stored plugin config (${[wasNested && "flattened nesting", migrated && "grouped advanced device settings"].filter(Boolean).join(", ")})`);
132
182
  });
133
183
  }
134
184
  /**
@@ -298,9 +348,34 @@ function resolveTemplatePath(templatesDir, templateName, target) {
298
348
  const localPath = (0, path_1.join)(templatesDir, templateName);
299
349
  return (0, fs_1.existsSync)(localPath) ? localPath : (0, path_1.join)(exports.BUNDLED_TEMPLATES_DIR, templateName);
300
350
  }
301
- /** JSON Schema forbids an empty `enum` array, so only attach one when there's at least one option - otherwise the whole config schema fails validation. */
351
+ /**
352
+ * Restricts a string field to `values`, shown as `names` where given - as `oneOf` with `const`/`title`,
353
+ * the form RJSF 5 (the admin UI's form library) supports going forward, rather than the deprecated
354
+ * `enumNames`. JSON Schema forbids an empty `enum`/`oneOf` array, so neither is attached without at
355
+ * least one option - otherwise the whole config schema fails validation.
356
+ */
302
357
  function withEnum(schema, values, names) {
303
- return values.length > 0 ? { ...schema, enum: values, ...(names ? { enumNames: names } : {}) } : schema;
358
+ if (values.length === 0)
359
+ return schema;
360
+ return names ? { ...schema, oneOf: values.map((value, i) => ({ const: value, title: names[i] ?? value })) } : { ...schema, enum: values };
361
+ }
362
+ /**
363
+ * An enum-like string field whose options show explanatory labels rather than their raw stored values
364
+ * - `oneOf` with `const`/`title`, which RJSF 5 (the admin UI's form library) renders as each option's
365
+ * label while still saving the bare value, so existing configs are unaffected.
366
+ *
367
+ * Each label is prefixed with an en space: the admin UI renders RJSF's default-theme radio markup under
368
+ * Bootstrap 5, which has no styling for it, so the label otherwise butts straight up against its
369
+ * button - and a plugin can't ship its own CSS. An en space, unlike a plain one, isn't collapsed away
370
+ * by HTML whitespace handling.
371
+ */
372
+ function choiceField(title, options, extra = {}) {
373
+ return {
374
+ type: "string",
375
+ title,
376
+ ...extra,
377
+ oneOf: options.map(([value, label]) => ({ const: value, title: `\u2002${label}` })),
378
+ };
304
379
  }
305
380
  function configSchema(app, discovered = []) {
306
381
  const defaults = defaultConfig();
@@ -309,6 +384,18 @@ function configSchema(app, discovered = []) {
309
384
  return {
310
385
  type: "object",
311
386
  properties: {
387
+ ...(app.bleApi
388
+ ? {
389
+ useBleApi: {
390
+ type: "boolean",
391
+ title: "Use the SignalK BLE Manager API",
392
+ description: "Route Bluetooth access through SignalK server's BLE Manager API (server >= 2.32.0) instead of connecting to " +
393
+ "BlueZ directly, so this plugin shares the adapter with other BLE plugins instead of contending for it. Requires " +
394
+ "the server to have a local Bluetooth adapter or BLE gateway available (Server → Settings → Bluetooth).",
395
+ default: defaults.useBleApi,
396
+ },
397
+ }
398
+ : {}),
312
399
  templatesDir: {
313
400
  type: "string",
314
401
  title: "Templates directory",
@@ -321,7 +408,8 @@ function configSchema(app, discovered = []) {
321
408
  type: "boolean",
322
409
  title: "Scan for devices on plugin start",
323
410
  description: 'Runs a short BLE scan so discovered devices show up in a device\'s "Device" picker below. ' +
324
- 'Not needed if every device uses "All discovered devices" - that scans on demand instead.',
411
+ 'Not needed if every device uses "All discovered devices" (that scans on demand instead), or if ' +
412
+ '"Use BLE Manager" below is on (that discovers continuously in the background instead of needing a scan).',
325
413
  default: defaults.scanOnStart,
326
414
  },
327
415
  scanDurationSeconds: {
@@ -331,29 +419,19 @@ function configSchema(app, discovered = []) {
331
419
  minimum: 1,
332
420
  default: defaults.scanDurationSeconds,
333
421
  },
334
- ...(app.bleApi
335
- ? {
336
- useBleApi: {
337
- type: "boolean",
338
- title: "Use the SignalK BLE Manager API",
339
- description: "Route Bluetooth access through SignalK server's BLE Manager API (server >= 2.32.0) instead of connecting to " +
340
- "BlueZ directly, so this plugin shares the adapter with other BLE plugins instead of contending for it. Requires " +
341
- "the server to have a local Bluetooth adapter or BLE gateway available (Server → Settings → Bluetooth).",
342
- default: defaults.useBleApi,
343
- },
344
- }
345
- : {}),
346
422
  paintConnectTimeoutSeconds: {
347
423
  type: "number",
348
424
  title: "Paint connect timeout (seconds)",
349
- description: "How long to wait for a device to accept a BLE connection before giving up on a repaint attempt.",
425
+ description: "How long to wait for a device to accept a BLE connection before giving up on a repaint attempt. " +
426
+ "The default for every device - each device can override it in its own settings below.",
350
427
  minimum: 1,
351
428
  default: defaults.paintConnectTimeoutSeconds,
352
429
  },
353
430
  paintRetries: {
354
431
  type: "number",
355
432
  title: "Paint retries",
356
- description: "How many times to attempt a repaint (including the first try) before giving up and reporting failure.",
433
+ description: "How many times to attempt a repaint (including the first try) before giving up and reporting failure. " +
434
+ "The default for every device - each device can override it in its own settings below.",
357
435
  minimum: 1,
358
436
  default: defaults.paintRetries,
359
437
  },
@@ -394,11 +472,10 @@ function configSchema(app, discovered = []) {
394
472
  'in poor light" - available to any template as source=einklabel,path=description or source=label,path=description.',
395
473
  },
396
474
  templateName: withEnum({ type: "string", title: "Template" }, templateNameOptions(resolveTemplatesDir(current.templatesDir))),
397
- repaintTrigger: {
398
- type: "string",
399
- title: "Repaint trigger",
400
- enum: ["subscription", "interval"],
401
- },
475
+ repaintTrigger: choiceField("Repaint trigger", [
476
+ ["subscription", "When a SignalK path changes"],
477
+ ["interval", "On a timed schedule"],
478
+ ]),
402
479
  triggerPath: {
403
480
  type: "string",
404
481
  title: "Trigger SignalK path (if repaint trigger is subscription)",
@@ -415,22 +492,58 @@ function configSchema(app, discovered = []) {
415
492
  maximum: 59,
416
493
  default: 0,
417
494
  },
418
- aesKey: {
419
- type: "string",
420
- title: "BLE AES key (vendor-specific; leave blank to use a default key)",
421
- },
422
- forceRepaint: {
423
- type: "boolean",
424
- title: "Force repaint",
425
- description: "Repaint even if the data is unchanged - clears itself automatically once that repaint completes",
426
- default: false,
427
- },
428
- reframe: {
429
- type: "string",
430
- title: "If the render doesn't match the panel size",
431
- description: "Crop: place at the top-left, truncating anything too big or leaving the rest blank if too small. Scale: stretch to fit exactly (may distort). Fixed: fail the repaint instead of showing an off-size image.",
432
- enum: ["crop", "scale", "fixed"],
433
- default: "crop",
495
+ reframe: choiceField("If the render doesn't match the panel size", [
496
+ ["crop", "Crop - place at the top-left, cutting off anything too big or leaving the rest blank"],
497
+ ["scale", "Scale - stretch to fit exactly (may distort)"],
498
+ ["fixed", "Fixed - fail the repaint rather than show an off-size image"],
499
+ ], { default: "crop" }),
500
+ advanced: {
501
+ type: "object",
502
+ title: "Advanced settings",
503
+ description: "Most labels never need these.",
504
+ properties: {
505
+ compress: {
506
+ type: "boolean",
507
+ title: 'Compress upload (Zhsunyco, Gicisky 7.5"/10.2")',
508
+ description: "Sends far less data over BLE, so repaints are quicker. Turn off if a label stops updating.",
509
+ default: true,
510
+ },
511
+ mirror: choiceField("Mirror", [
512
+ ["none", "No flip"],
513
+ ["horizontal", "Flip left to right"],
514
+ ["vertical", "Flip top to bottom"],
515
+ ["both", "Rotate 180° - for a label mounted upside down"],
516
+ ], { description: "Only needed if the image shows up mirrored or upside down on the label.", default: "none" }),
517
+ compressionFormat: choiceField("Wire format (Gicisky, experimental)", [
518
+ ["auto", "Auto - the model's usual format"],
519
+ [
520
+ "chunked",
521
+ 'Chunked - send compressed like the 7.5"/10.2" panels, e.g. to speed up a 4.2" BWR (untested on current firmware)',
522
+ ],
523
+ ], { description: "Chunked needs Compress upload on. Switch back to Auto if the label stops updating.", default: "auto" }),
524
+ forceRepaint: {
525
+ type: "boolean",
526
+ title: "Force repaint",
527
+ description: "Repaint even if the data is unchanged - clears itself automatically once that repaint completes",
528
+ default: false,
529
+ },
530
+ aesKey: {
531
+ type: "string",
532
+ title: "BLE AES key (vendor-specific; leave blank to use a default key)",
533
+ },
534
+ paintConnectTimeoutSeconds: {
535
+ type: "number",
536
+ title: "Paint connect timeout for this device (seconds)",
537
+ description: `Leave blank to use the plugin-wide setting (currently ${current.paintConnectTimeoutSeconds}s).`,
538
+ minimum: 1,
539
+ },
540
+ paintRetries: {
541
+ type: "number",
542
+ title: "Paint retries for this device",
543
+ description: `Leave blank to use the plugin-wide setting (currently ${current.paintRetries}).`,
544
+ minimum: 1,
545
+ },
546
+ },
434
547
  },
435
548
  },
436
549
  },
@@ -445,6 +558,10 @@ function configUiSchema() {
445
558
  description: { "ui:widget": "textarea" },
446
559
  repaintTrigger: { "ui:widget": "radio" },
447
560
  reframe: { "ui:widget": "radio" },
561
+ advanced: {
562
+ mirror: { "ui:widget": "radio" },
563
+ compressionFormat: { "ui:widget": "radio" },
564
+ },
448
565
  },
449
566
  },
450
567
  };