@rhizomatics/signalk-einklabel-plugin 1.3.0-beta9 → 1.3.0

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/dist/config.d.ts CHANGED
@@ -1,8 +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
4
  import { MirrorMode } from "./render/mirror";
5
- import { CompressionFormat } from "./devices/types";
6
5
  /**
7
6
  * Special `device` value meaning "every currently-known discovered device" instead of one specific
8
7
  * BLE address - lets a single `DeviceConfig` entry (one template, one trigger) broadcast to every
@@ -23,13 +22,11 @@ export interface DeviceConfig {
23
22
  /**
24
23
  * Free-text notes about where this label is physically mounted/viewed from, e.g. "chart table,
25
24
  * viewed from ~1m in poor light" - purely descriptive. Available to any template via
26
- * `source=einklabel,path=description` or `source=label,path=description` (see `buildLabelContext` in
25
+ * `source=label,path=description` (see `buildLabelContext` in
27
26
  * `./render/binding.ts`) if it wants it - e.g. useful to a `TemplateProvider` extension (see
28
27
  * `./render/templateProviders.ts`) tailoring content to where the label actually sits.
29
28
  */
30
29
  description?: string;
31
- /** Per-device override; if omitted, the vendor driver may fall back to a stock/manufacturer-default key. */
32
- aesKey?: string;
33
30
  /**
34
31
  * Either one specific `.svg` file, or the name of a *template-family* directory holding several
35
32
  * same-purpose templates for different panel sizes/colour-sets, named `<width>x<height>-<colours>.svg`
@@ -50,8 +47,10 @@ export interface DeviceConfig {
50
47
  intervalHours?: number;
51
48
  /** ...at this minute past the hour. */
52
49
  intervalMinute?: number;
53
- /** One-shot override to repaint even if the data is unchanged; cleared automatically once that repaint completes. */
54
- forceRepaint?: boolean;
50
+ /** Settings most labels never need, grouped so the admin UI shows them in their own "Advanced settings" box. */
51
+ advanced?: AdvancedDeviceSettings;
52
+ }
53
+ export interface AdvancedDeviceSettings {
55
54
  /**
56
55
  * How to fit the rendered image onto the device's actual panel size when it doesn't match (see
57
56
  * `ReframeMode`) - e.g. a template family with no variant sized for this particular label. Left
@@ -60,17 +59,32 @@ export interface DeviceConfig {
60
59
  * showing *something*, even off-size, beats a repaint that just fails outright.
61
60
  */
62
61
  reframe?: ReframeMode;
63
- /** Flip the image before sending - for a panel whose layout is mirrored, or one mounted upside down (`"both"`). Unset means `"none"`. */
64
- mirror?: MirrorMode;
65
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. */
66
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;
67
66
  /**
68
67
  * Experimental opt-in wire format (gicisky only) - `"chunked"` sends a 4.2" BWR (or another plain
69
68
  * two-plane panel) QuickLZ-compressed like the 7.5"/10.2". Unset means `"auto"`, the model's own
70
69
  * format. See `CompressionFormat`.
71
70
  */
72
71
  compressionFormat?: CompressionFormat;
72
+ /** Send the image without waiting for each write to be acknowledged (zhsunyco) - see `VendorDeviceConfig.writeWithoutResponse`. Unset means off. */
73
+ writeWithoutResponse?: boolean;
74
+ /** One-shot override to repaint even if the data is unchanged; cleared automatically once that repaint completes. */
75
+ forceRepaint?: boolean;
76
+ /** Per-device override; if omitted, the vendor driver may fall back to a stock/manufacturer-default key. */
77
+ aesKey?: string;
78
+ /** Per-device override of `PluginConfig.paintConnectTimeoutSeconds` - unset uses the plugin-wide value. */
79
+ paintConnectTimeoutSeconds?: number;
80
+ /** Per-device override of `PluginConfig.paintRetries` - unset uses the plugin-wide value. */
81
+ paintRetries?: number;
73
82
  }
83
+ /** Applies `migrateDeviceConfig` to every device entry - see `healStoredConfig` for persisting the result. */
84
+ export declare function migrateConfig<T extends Partial<PluginConfig>>(config: T): {
85
+ config: T;
86
+ migrated: boolean;
87
+ };
74
88
  export interface PluginConfig {
75
89
  /**
76
90
  * Directory the plugin scans for template files, instead of an upload UI - follows
@@ -152,16 +166,21 @@ export declare const RENDER_FALLBACK_TEMPLATE_NAME = ".error";
152
166
  export declare function defaultConfig(): PluginConfig;
153
167
  export declare function readCurrentConfig(app: ServerAPI): Partial<PluginConfig>;
154
168
  /**
155
- * Actively rewrites the on-disk file once it's nested (see `readCurrentConfig`'s doc comment) -
156
- * `readCurrentConfig` alone only self-heals in memory for callers that go through it, but the admin
157
- * UI's own config-editing form round-trips whatever raw JSON it was handed verbatim, including a
158
- * stray nested `configuration` key it never touches (no schema field maps to it) - so left alone,
159
- * every future save from the UI keeps re-persisting that dead weight forever (see
160
- * `support/signalk-einklabel-plugin.json`). Called once at plugin start, which - unlike
161
- * `clearForceRepaint` - isn't gated on any device having `forceRepaint` set, so a nested file gets
162
- * flattened even if nothing ever triggers that path.
169
+ * Actively rewrites the on-disk file when it's in an outdated shape - `readCurrentConfig` alone only
170
+ * fixes things up in memory for callers that go through it, but the admin UI's config form
171
+ * round-trips whatever raw JSON it was handed verbatim. Two shapes are healed:
172
+ *
173
+ * - A nested file (see `readCurrentConfig`'s doc comment): left alone, the UI keeps re-persisting a
174
+ * stray nested `configuration` key it never touches (no schema field maps to it) forever (see
175
+ * `support/signalk-einklabel-plugin.json`).
176
+ * - Advanced device settings saved before they were grouped under `advanced` (see
177
+ * `migrateDeviceConfig`): left alone, the UI would show that group empty, even though the plugin
178
+ * itself still honours the old values.
179
+ *
180
+ * Called once at plugin start, which - unlike `clearForceRepaint` - isn't gated on any device having
181
+ * `forceRepaint` set, so an outdated file gets fixed even if nothing ever triggers that path.
163
182
  */
164
- export declare function healNestedConfig(app: ServerAPI): void;
183
+ export declare function healStoredConfig(app: ServerAPI): void;
165
184
  /** See `resolveDir` - `templatesDir`'s own resolution. */
166
185
  export declare function resolveTemplatesDir(templatesDir: string | undefined): string;
167
186
  export declare function parseDevice(device: string): {
@@ -170,6 +189,22 @@ export declare function parseDevice(device: string): {
170
189
  hwVersion?: string;
171
190
  address: string;
172
191
  } | undefined;
192
+ export declare function listSvgFiles(dir: string): string[];
193
+ export interface TemplateVariant {
194
+ fileName: string;
195
+ width: number;
196
+ height: number;
197
+ colours: Colour[];
198
+ }
199
+ export declare function listTemplateVariants(dir: string): TemplateVariant[];
200
+ /**
201
+ * A directory only counts as a template-family option if it actually has at least one parseable
202
+ * variant file in it - otherwise it's something else entirely, e.g. `.assets`. Dot-prefixed
203
+ * directories (e.g. `.assets`, `.blank`) are always excluded, even if they happen to contain
204
+ * parseable variant files, since they're reserved for non-template-option use (asset bundles,
205
+ * work-in-progress templates not ready to appear in the dropdown, etc).
206
+ */
207
+ export declare function listTemplateFamilies(dir: string): string[];
173
208
  /**
174
209
  * Resolves a template name to an actual file path - a local template overrides the bundled one of the
175
210
  * same name. `templateName` can also name a template-family *directory* (see `pickBestVariant`), in
package/dist/config.js CHANGED
@@ -1,19 +1,21 @@
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;
10
+ exports.listSvgFiles = listSvgFiles;
11
+ exports.listTemplateVariants = listTemplateVariants;
12
+ exports.listTemplateFamilies = listTemplateFamilies;
9
13
  exports.resolveTemplatePath = resolveTemplatePath;
10
14
  exports.configSchema = configSchema;
11
15
  exports.configUiSchema = configUiSchema;
12
16
  const fs_1 = require("fs");
13
17
  const os_1 = require("os");
14
18
  const path_1 = require("path");
15
- const mirror_1 = require("./render/mirror");
16
- const types_1 = require("./devices/types");
17
19
  const templateProviders_1 = require("./render/templateProviders");
18
20
  const resolveApiUrl_1 = require("./resolveApiUrl");
19
21
  /**
@@ -25,6 +27,50 @@ const resolveApiUrl_1 = require("./resolveApiUrl");
25
27
  * an on-demand scan itself if nothing's been discovered yet.
26
28
  */
27
29
  exports.ALL_DEVICES = "ALL";
30
+ /**
31
+ * Every `AdvancedDeviceSettings` key - these all used to sit directly on `DeviceConfig`, so a config
32
+ * saved before they were grouped still has them there. See `migrateDeviceConfig`.
33
+ */
34
+ const ADVANCED_DEVICE_KEYS = [
35
+ "reframe",
36
+ "compress",
37
+ "mirror",
38
+ "compressionFormat",
39
+ "forceRepaint",
40
+ "aesKey",
41
+ "paintConnectTimeoutSeconds",
42
+ "paintRetries",
43
+ ];
44
+ /**
45
+ * Moves any `AdvancedDeviceSettings` key found at the top level of a device entry (the pre-grouping
46
+ * layout) into its `advanced` object, reporting whether anything moved. A value already under
47
+ * `advanced` wins over a legacy top-level one - it can only have got there from the grouped form, so
48
+ * it's the newer of the two.
49
+ */
50
+ function migrateDeviceConfig(raw) {
51
+ const legacy = {};
52
+ const rest = { ...raw };
53
+ for (const key of ADVANCED_DEVICE_KEYS) {
54
+ if (key in rest) {
55
+ legacy[key] = rest[key];
56
+ delete rest[key];
57
+ }
58
+ }
59
+ if (Object.keys(legacy).length === 0) {
60
+ return { device: raw, migrated: false };
61
+ }
62
+ return { device: { ...rest, advanced: { ...legacy, ...raw.advanced } }, migrated: true };
63
+ }
64
+ /** Applies `migrateDeviceConfig` to every device entry - see `healStoredConfig` for persisting the result. */
65
+ function migrateConfig(config) {
66
+ if (!Array.isArray(config.devices)) {
67
+ return { config, migrated: false };
68
+ }
69
+ const results = config.devices.map(migrateDeviceConfig);
70
+ return results.some((result) => result.migrated)
71
+ ? { config: { ...config, devices: results.map((result) => result.device) }, migrated: true }
72
+ : { config, migrated: false };
73
+ }
28
74
  /**
29
75
  * The package's own bundled `templates/` directory (ships alongside `dist/`, see
30
76
  * package.json's `files`) - templates here are always available, but a same-named template in the
@@ -110,27 +156,33 @@ function pickKnownKeys(raw) {
110
156
  }
111
157
  function readCurrentConfig(app) {
112
158
  const { unwrapped } = unwrapNestedConfiguration(app.readPluginOptions());
113
- return pickKnownKeys(unwrapped);
159
+ return migrateConfig(pickKnownKeys(unwrapped)).config;
114
160
  }
115
161
  /**
116
- * Actively rewrites the on-disk file once it's nested (see `readCurrentConfig`'s doc comment) -
117
- * `readCurrentConfig` alone only self-heals in memory for callers that go through it, but the admin
118
- * UI's own config-editing form round-trips whatever raw JSON it was handed verbatim, including a
119
- * stray nested `configuration` key it never touches (no schema field maps to it) - so left alone,
120
- * every future save from the UI keeps re-persisting that dead weight forever (see
121
- * `support/signalk-einklabel-plugin.json`). Called once at plugin start, which - unlike
122
- * `clearForceRepaint` - isn't gated on any device having `forceRepaint` set, so a nested file gets
123
- * flattened even if nothing ever triggers that path.
162
+ * Actively rewrites the on-disk file when it's in an outdated shape - `readCurrentConfig` alone only
163
+ * fixes things up in memory for callers that go through it, but the admin UI's config form
164
+ * round-trips whatever raw JSON it was handed verbatim. Two shapes are healed:
165
+ *
166
+ * - A nested file (see `readCurrentConfig`'s doc comment): left alone, the UI keeps re-persisting a
167
+ * stray nested `configuration` key it never touches (no schema field maps to it) forever (see
168
+ * `support/signalk-einklabel-plugin.json`).
169
+ * - Advanced device settings saved before they were grouped under `advanced` (see
170
+ * `migrateDeviceConfig`): left alone, the UI would show that group empty, even though the plugin
171
+ * itself still honours the old values.
172
+ *
173
+ * Called once at plugin start, which - unlike `clearForceRepaint` - isn't gated on any device having
174
+ * `forceRepaint` set, so an outdated file gets fixed even if nothing ever triggers that path.
124
175
  */
125
- function healNestedConfig(app) {
176
+ function healStoredConfig(app) {
126
177
  const { unwrapped, wasNested } = unwrapNestedConfiguration(app.readPluginOptions());
127
- if (!wasNested)
178
+ const { config, migrated } = migrateConfig(pickKnownKeys(unwrapped));
179
+ if (!wasNested && !migrated)
128
180
  return;
129
- app.savePluginOptions(pickKnownKeys(unwrapped), (err) => {
181
+ app.savePluginOptions(config, (err) => {
130
182
  if (err)
131
- app.debug(`failed to clean up legacy nested plugin config: ${err.message}`);
183
+ app.debug(`failed to update the stored plugin config: ${err.message}`);
132
184
  else
133
- app.debug("cleaned up a legacy nested plugin config file on disk");
185
+ app.debug(`updated the stored plugin config (${[wasNested && "flattened nesting", migrated && "grouped advanced device settings"].filter(Boolean).join(", ")})`);
134
186
  });
135
187
  }
136
188
  /**
@@ -300,9 +352,40 @@ function resolveTemplatePath(templatesDir, templateName, target) {
300
352
  const localPath = (0, path_1.join)(templatesDir, templateName);
301
353
  return (0, fs_1.existsSync)(localPath) ? localPath : (0, path_1.join)(exports.BUNDLED_TEMPLATES_DIR, templateName);
302
354
  }
303
- /** 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. */
355
+ /**
356
+ * Restricts a string field to `values`, shown as `names` where given - as `oneOf` with `const`/`title`,
357
+ * the form RJSF 5 (the admin UI's form library) supports going forward, rather than the deprecated
358
+ * `enumNames`. JSON Schema forbids an empty `enum`/`oneOf` array, so neither is attached without at
359
+ * least one option - otherwise the whole config schema fails validation.
360
+ */
304
361
  function withEnum(schema, values, names) {
305
- return values.length > 0 ? { ...schema, enum: values, ...(names ? { enumNames: names } : {}) } : schema;
362
+ if (values.length === 0)
363
+ return schema;
364
+ return names ? { ...schema, oneOf: values.map((value, i) => ({ const: value, title: names[i] ?? value })) } : { ...schema, enum: values };
365
+ }
366
+ /** The plugin's documentation site - its sections' anchors are the README's own headings (see `site/scripts/sync-readme.mjs`). */
367
+ const DOCS_URL = "https://signalk-einklabel.rhizomatics.org.uk/";
368
+ /** A Markdown link to a docs page (and optional section, e.g. `templates/#reframing`), for a field description rendered with `ui:enableMarkdownInDescription` (see `configUiSchema`). */
369
+ function docsLink(page, text = "More in the docs") {
370
+ return `[${text}](${DOCS_URL}${page})`;
371
+ }
372
+ /**
373
+ * An enum-like string field whose options show explanatory labels rather than their raw stored values
374
+ * - `oneOf` with `const`/`title`, which RJSF 5 (the admin UI's form library) renders as each option's
375
+ * label while still saving the bare value, so existing configs are unaffected.
376
+ *
377
+ * Each label is prefixed with an en space: the admin UI renders RJSF's default-theme radio markup under
378
+ * Bootstrap 5, which has no styling for it, so the label otherwise butts straight up against its
379
+ * button - and a plugin can't ship its own CSS. An en space, unlike a plain one, isn't collapsed away
380
+ * by HTML whitespace handling.
381
+ */
382
+ function choiceField(title, options, extra = {}) {
383
+ return {
384
+ type: "string",
385
+ title,
386
+ ...extra,
387
+ oneOf: options.map(([value, label]) => ({ const: value, title: `\u2002${label}` })),
388
+ };
306
389
  }
307
390
  function configSchema(app, discovered = []) {
308
391
  const defaults = defaultConfig();
@@ -349,14 +432,16 @@ function configSchema(app, discovered = []) {
349
432
  paintConnectTimeoutSeconds: {
350
433
  type: "number",
351
434
  title: "Paint connect timeout (seconds)",
352
- description: "How long to wait for a device to accept a BLE connection before giving up on a repaint attempt.",
435
+ description: "How long to wait for a device to accept a BLE connection before giving up on a repaint attempt. " +
436
+ "The default for every device - each device can override it in its own settings below.",
353
437
  minimum: 1,
354
438
  default: defaults.paintConnectTimeoutSeconds,
355
439
  },
356
440
  paintRetries: {
357
441
  type: "number",
358
442
  title: "Paint retries",
359
- description: "How many times to attempt a repaint (including the first try) before giving up and reporting failure.",
443
+ description: "How many times to attempt a repaint (including the first try) before giving up and reporting failure. " +
444
+ "The default for every device - each device can override it in its own settings below.",
360
445
  minimum: 1,
361
446
  default: defaults.paintRetries,
362
447
  },
@@ -372,8 +457,12 @@ function configSchema(app, discovered = []) {
372
457
  signalkApiUrl: {
373
458
  type: "string",
374
459
  title: "SignalK API base URL (leave blank to auto-detect)",
375
- description: "Used for plugin access to SignalK REST APIs not yet integrated for direct plugin access. Left blank, the plugin probes the likely options at startup (3000, 80, 443 ) - only set this manually to skip probing. Anonymous read access is required.",
376
- enum: ["", ...resolveApiUrl_1.SIGNALK_API_URL_OPTIONS],
460
+ description: "Used for plugin access to SignalK REST APIs not yet integrated for direct plugin access. Left blank, the plugin probes the likely options at startup (3000, 80, 443) - " +
461
+ "only set this to skip probing, or for a server on another port or host, e.g. http://localhost:3001. Anonymous read access is required.",
462
+ // Free text with the probed options as suggestions - `examples` renders as the input's
463
+ // autocomplete list in the admin UI's form library (RJSF 5), where `enum` would forbid anything else.
464
+ examples: resolveApiUrl_1.SIGNALK_API_URL_OPTIONS,
465
+ pattern: "^(\\s*|\\s*https?://\\S+\\s*)$",
377
466
  },
378
467
  devices: {
379
468
  type: "array",
@@ -394,67 +483,120 @@ function configSchema(app, discovered = []) {
394
483
  type: "string",
395
484
  title: "Location/description (optional)",
396
485
  description: 'Free-text notes about where this label is physically mounted/viewed from, e.g. "chart table, viewed from ~1m ' +
397
- 'in poor light" - available to any template as source=einklabel,path=description or source=label,path=description.',
398
- },
399
- templateName: withEnum({ type: "string", title: "Template" }, templateNameOptions(resolveTemplatesDir(current.templatesDir))),
400
- repaintTrigger: {
401
- type: "string",
402
- title: "Repaint trigger",
403
- enum: ["subscription", "interval"],
404
- },
405
- triggerPath: {
406
- type: "string",
407
- title: "Trigger SignalK path (if repaint trigger is subscription)",
408
- },
409
- intervalHours: {
410
- type: "number",
411
- title: "Repaint every N hours (if repaint trigger is interval)",
412
- minimum: 1,
413
- },
414
- intervalMinute: {
415
- type: "number",
416
- title: "Minutes past the hour (if repaint trigger is interval)",
417
- minimum: 0,
418
- maximum: 59,
419
- default: 0,
486
+ 'in poor light" - available to any template as `source=label,path=description`. ' +
487
+ docsLink("templates/#label-details"),
420
488
  },
421
- aesKey: {
489
+ templateName: withEnum({
422
490
  type: "string",
423
- title: "BLE AES key (vendor-specific; leave blank to use a default key)",
491
+ title: "Template",
492
+ description: `A bundled template, or one from your templates directory. ${docsLink("examples/")}`,
493
+ }, templateNameOptions(resolveTemplatesDir(current.templatesDir))),
494
+ repaintTrigger: choiceField("Repaint trigger", [
495
+ ["subscription", "When a SignalK path changes"],
496
+ ["interval", "On a timed schedule"],
497
+ ]),
498
+ advanced: {
499
+ type: "object",
500
+ title: "Advanced settings",
501
+ description: "Most labels never need these.",
502
+ properties: {
503
+ reframe: choiceField("If the render doesn't match the panel size", [
504
+ ["crop", "Crop - place at the top-left, cutting off anything too big or leaving the rest blank"],
505
+ ["scale", "Scale - stretch to fit exactly (may distort)"],
506
+ ["fixed", "Fixed - fail the repaint rather than show an off-size image"],
507
+ ], { description: docsLink("templates/#reframing"), default: "crop" }),
508
+ compress: {
509
+ type: "boolean",
510
+ title: 'Compress upload (Zhsunyco, Gicisky 7.5"/10.2")',
511
+ description: `Sends far less data over BLE, so repaints are quicker. Turn off if a label stops updating. ${docsLink("templates/#other-image-options")}`,
512
+ default: true,
513
+ },
514
+ mirror: choiceField("Mirror", [
515
+ ["none", "No flip"],
516
+ ["horizontal", "Flip left to right"],
517
+ ["vertical", "Flip top to bottom"],
518
+ ["both", "Rotate 180° - for a label mounted upside down"],
519
+ ], {
520
+ description: `Only needed if the image shows up mirrored or upside down on the label. ${docsLink("templates/#other-image-options")}`,
521
+ default: "none",
522
+ }),
523
+ compressionFormat: choiceField("Wire format (Gicisky, experimental)", [
524
+ ["auto", "Auto - the model's usual format"],
525
+ [
526
+ "chunked",
527
+ 'Chunked - send compressed like the 7.5"/10.2" panels, e.g. to speed up a 4.2" BWR (untested on current firmware)',
528
+ ],
529
+ ], {
530
+ description: `Chunked needs Compress upload on. Switch back to Auto if the label stops updating. ${docsLink("templates/#other-image-options")}`,
531
+ default: "auto",
532
+ }),
533
+ writeWithoutResponse: {
534
+ type: "boolean",
535
+ title: "Send image without waiting for each write (Zhsunyco)",
536
+ description: "Turn on if a Zhsunyco label fails with ATT error 0x0e when using the SignalK BLE Manager - its writes that " +
537
+ `wait for acknowledgement are rejected by these labels (SignalK 2.33 and earlier). ${docsLink("bluetooth/#zhsunyco-labels-and-the-ble-manager")}`,
538
+ default: false,
539
+ },
540
+ forceRepaint: {
541
+ type: "boolean",
542
+ title: "Force repaint",
543
+ description: "Repaint even if the data is unchanged - clears itself automatically once that repaint completes",
544
+ default: false,
545
+ },
546
+ aesKey: {
547
+ type: "string",
548
+ title: "BLE AES key (Zhsunyco)",
549
+ description: "32 hex characters. Leave blank to use the default key, which works for most labels.",
550
+ pattern: "^([0-9a-fA-F]{32})?$",
551
+ },
552
+ paintConnectTimeoutSeconds: {
553
+ type: "number",
554
+ title: "Paint connect timeout for this device (seconds)",
555
+ description: `Leave blank to use the plugin-wide setting (currently ${current.paintConnectTimeoutSeconds}s).`,
556
+ minimum: 1,
557
+ },
558
+ paintRetries: {
559
+ type: "number",
560
+ title: "Paint retries for this device",
561
+ description: `Leave blank to use the plugin-wide setting (currently ${current.paintRetries}).`,
562
+ minimum: 1,
563
+ },
564
+ },
424
565
  },
425
- forceRepaint: {
426
- type: "boolean",
427
- title: "Force repaint",
428
- description: "Repaint even if the data is unchanged - clears itself automatically once that repaint completes",
429
- default: false,
430
- },
431
- reframe: {
432
- type: "string",
433
- title: "If the render doesn't match the panel size",
434
- 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.",
435
- enum: ["crop", "scale", "fixed"],
436
- default: "crop",
437
- },
438
- mirror: {
439
- type: "string",
440
- title: "Mirror",
441
- description: "Flip the image if it shows up mirrored on the label. Both = rotate 180°, e.g. for a label mounted upside down.",
442
- enum: mirror_1.MIRROR_MODES,
443
- default: "none",
444
- },
445
- compress: {
446
- type: "boolean",
447
- title: 'Compress upload (Zhsunyco, Gicisky 7.5"/10.2")',
448
- description: "Sends far less data over BLE, so repaints are quicker. Turn off if a label stops updating.",
449
- default: true,
450
- },
451
- compressionFormat: {
452
- type: "string",
453
- title: "Wire format (Gicisky, experimental)",
454
- description: 'Auto: the model\'s usual format. Chunked: send compressed like the 7.5"/10.2" panels - may speed up a 4.2" BWR, ' +
455
- "but untested on current firmware. Needs Compress upload on to actually compress. Switch back to Auto if the label stops updating.",
456
- enum: types_1.COMPRESSION_FORMATS,
457
- default: "auto",
566
+ },
567
+ // Shows only the fields for the chosen trigger. RJSF keeps a hidden field's value, so switching
568
+ // trigger and back doesn't lose it - and the scheduler only reads the ones matching the trigger.
569
+ dependencies: {
570
+ repaintTrigger: {
571
+ oneOf: [
572
+ {
573
+ properties: {
574
+ repaintTrigger: { const: "subscription" },
575
+ triggerPath: {
576
+ type: "string",
577
+ title: "Trigger SignalK path",
578
+ description: "Repaints whenever this path's value changes.",
579
+ },
580
+ },
581
+ },
582
+ {
583
+ properties: {
584
+ repaintTrigger: { const: "interval" },
585
+ intervalHours: {
586
+ type: "number",
587
+ title: "Repaint every N hours",
588
+ minimum: 1,
589
+ },
590
+ intervalMinute: {
591
+ type: "number",
592
+ title: "Minutes past the hour",
593
+ minimum: 0,
594
+ maximum: 59,
595
+ default: 0,
596
+ },
597
+ },
598
+ },
599
+ ],
458
600
  },
459
601
  },
460
602
  },
@@ -462,15 +604,40 @@ function configSchema(app, discovered = []) {
462
604
  },
463
605
  };
464
606
  }
607
+ /** Lets a field's `description` include Markdown - used for its `docsLink`. */
608
+ const MARKDOWN = { "ui:enableMarkdownInDescription": true };
465
609
  function configUiSchema() {
466
610
  return {
611
+ signalkApiUrl: { "ui:placeholder": "Auto-detect, or e.g. http://localhost:3001" },
467
612
  devices: {
468
613
  items: {
469
- description: { "ui:widget": "textarea" },
614
+ // Keeps the trigger's own fields (added by the schema's `dependencies`) next to it, rather than
615
+ // after every other field, and Advanced settings last.
616
+ "ui:order": [
617
+ "friendlyName",
618
+ "device",
619
+ "description",
620
+ "templateName",
621
+ "repaintTrigger",
622
+ "triggerPath",
623
+ "intervalHours",
624
+ "intervalMinute",
625
+ "*",
626
+ "advanced",
627
+ ],
628
+ friendlyName: { "ui:placeholder": "e.g. Tide clock" },
629
+ description: { "ui:widget": "textarea", "ui:placeholder": "e.g. at companionway", ...MARKDOWN },
630
+ templateName: MARKDOWN,
470
631
  repaintTrigger: { "ui:widget": "radio" },
471
- reframe: { "ui:widget": "radio" },
472
- mirror: { "ui:widget": "radio" },
473
- compressionFormat: { "ui:widget": "radio" },
632
+ triggerPath: { "ui:placeholder": "e.g. environment.tide.state" },
633
+ advanced: {
634
+ reframe: { "ui:widget": "radio", ...MARKDOWN },
635
+ compress: MARKDOWN,
636
+ mirror: { "ui:widget": "radio", ...MARKDOWN },
637
+ compressionFormat: { "ui:widget": "radio", ...MARKDOWN },
638
+ writeWithoutResponse: MARKDOWN,
639
+ aesKey: { "ui:placeholder": "e.g. 00112233445566778899aabbccddeeff" },
640
+ },
474
641
  },
475
642
  },
476
643
  };