@rhizomatics/signalk-einklabel-plugin 1.3.0-beta11 → 1.3.0-beta13
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 +6 -0
- package/README.md +14 -543
- package/dist/config.d.ts +21 -5
- package/dist/config.js +101 -33
- package/dist/docs/templateReference.d.ts +18 -0
- package/dist/docs/templateReference.js +207 -0
- package/dist/repaintScheduler.js +23 -17
- package/dist/resolveApiUrl.js +3 -0
- package/docs/bluetooth.md +109 -0
- package/docs/cli.md +131 -0
- package/docs/examples/README.md +25 -0
- package/docs/examples/tide-clock.md +93 -0
- package/docs/examples/watch-schedule.md +37 -0
- package/docs/extending.md +33 -0
- package/docs/faq.md +40 -0
- package/docs/getting-started.md +111 -0
- package/docs/templates.md +142 -0
- package/package.json +5 -2
package/dist/config.d.ts
CHANGED
|
@@ -22,7 +22,7 @@ export interface DeviceConfig {
|
|
|
22
22
|
/**
|
|
23
23
|
* Free-text notes about where this label is physically mounted/viewed from, e.g. "chart table,
|
|
24
24
|
* viewed from ~1m in poor light" - purely descriptive. Available to any template via
|
|
25
|
-
* `source=
|
|
25
|
+
* `source=label,path=description` (see `buildLabelContext` in
|
|
26
26
|
* `./render/binding.ts`) if it wants it - e.g. useful to a `TemplateProvider` extension (see
|
|
27
27
|
* `./render/templateProviders.ts`) tailoring content to where the label actually sits.
|
|
28
28
|
*/
|
|
@@ -47,6 +47,10 @@ export interface DeviceConfig {
|
|
|
47
47
|
intervalHours?: number;
|
|
48
48
|
/** ...at this minute past the hour. */
|
|
49
49
|
intervalMinute?: number;
|
|
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 {
|
|
50
54
|
/**
|
|
51
55
|
* How to fit the rendered image onto the device's actual panel size when it doesn't match (see
|
|
52
56
|
* `ReframeMode`) - e.g. a template family with no variant sized for this particular label. Left
|
|
@@ -55,10 +59,6 @@ export interface DeviceConfig {
|
|
|
55
59
|
* showing *something*, even off-size, beats a repaint that just fails outright.
|
|
56
60
|
*/
|
|
57
61
|
reframe?: ReframeMode;
|
|
58
|
-
/** Settings most labels never need, grouped so the admin UI shows them in their own "Advanced settings" box. */
|
|
59
|
-
advanced?: AdvancedDeviceSettings;
|
|
60
|
-
}
|
|
61
|
-
export interface AdvancedDeviceSettings {
|
|
62
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
63
|
compress?: boolean;
|
|
64
64
|
/** Flip the image before sending - for a panel whose layout is mirrored, or one mounted upside down (`"both"`). Unset means `"none"`. */
|
|
@@ -187,6 +187,22 @@ export declare function parseDevice(device: string): {
|
|
|
187
187
|
hwVersion?: string;
|
|
188
188
|
address: string;
|
|
189
189
|
} | undefined;
|
|
190
|
+
export declare function listSvgFiles(dir: string): string[];
|
|
191
|
+
export interface TemplateVariant {
|
|
192
|
+
fileName: string;
|
|
193
|
+
width: number;
|
|
194
|
+
height: number;
|
|
195
|
+
colours: Colour[];
|
|
196
|
+
}
|
|
197
|
+
export declare function listTemplateVariants(dir: string): TemplateVariant[];
|
|
198
|
+
/**
|
|
199
|
+
* A directory only counts as a template-family option if it actually has at least one parseable
|
|
200
|
+
* variant file in it - otherwise it's something else entirely, e.g. `.assets`. Dot-prefixed
|
|
201
|
+
* directories (e.g. `.assets`, `.blank`) are always excluded, even if they happen to contain
|
|
202
|
+
* parseable variant files, since they're reserved for non-template-option use (asset bundles,
|
|
203
|
+
* work-in-progress templates not ready to appear in the dropdown, etc).
|
|
204
|
+
*/
|
|
205
|
+
export declare function listTemplateFamilies(dir: string): string[];
|
|
190
206
|
/**
|
|
191
207
|
* Resolves a template name to an actual file path - a local template overrides the bundled one of the
|
|
192
208
|
* same name. `templateName` can also name a template-family *directory* (see `pickBestVariant`), in
|
package/dist/config.js
CHANGED
|
@@ -7,6 +7,9 @@ exports.readCurrentConfig = readCurrentConfig;
|
|
|
7
7
|
exports.healStoredConfig = healStoredConfig;
|
|
8
8
|
exports.resolveTemplatesDir = resolveTemplatesDir;
|
|
9
9
|
exports.parseDevice = parseDevice;
|
|
10
|
+
exports.listSvgFiles = listSvgFiles;
|
|
11
|
+
exports.listTemplateVariants = listTemplateVariants;
|
|
12
|
+
exports.listTemplateFamilies = listTemplateFamilies;
|
|
10
13
|
exports.resolveTemplatePath = resolveTemplatePath;
|
|
11
14
|
exports.configSchema = configSchema;
|
|
12
15
|
exports.configUiSchema = configUiSchema;
|
|
@@ -29,6 +32,7 @@ exports.ALL_DEVICES = "ALL";
|
|
|
29
32
|
* saved before they were grouped still has them there. See `migrateDeviceConfig`.
|
|
30
33
|
*/
|
|
31
34
|
const ADVANCED_DEVICE_KEYS = [
|
|
35
|
+
"reframe",
|
|
32
36
|
"compress",
|
|
33
37
|
"mirror",
|
|
34
38
|
"compressionFormat",
|
|
@@ -359,6 +363,12 @@ function withEnum(schema, values, names) {
|
|
|
359
363
|
return schema;
|
|
360
364
|
return names ? { ...schema, oneOf: values.map((value, i) => ({ const: value, title: names[i] ?? value })) } : { ...schema, enum: values };
|
|
361
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
|
+
}
|
|
362
372
|
/**
|
|
363
373
|
* An enum-like string field whose options show explanatory labels rather than their raw stored values
|
|
364
374
|
* - `oneOf` with `const`/`title`, which RJSF 5 (the admin UI's form library) renders as each option's
|
|
@@ -447,8 +457,12 @@ function configSchema(app, discovered = []) {
|
|
|
447
457
|
signalkApiUrl: {
|
|
448
458
|
type: "string",
|
|
449
459
|
title: "SignalK API base URL (leave blank to auto-detect)",
|
|
450
|
-
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
|
|
451
|
-
|
|
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*)$",
|
|
452
466
|
},
|
|
453
467
|
devices: {
|
|
454
468
|
type: "array",
|
|
@@ -469,43 +483,32 @@ function configSchema(app, discovered = []) {
|
|
|
469
483
|
type: "string",
|
|
470
484
|
title: "Location/description (optional)",
|
|
471
485
|
description: 'Free-text notes about where this label is physically mounted/viewed from, e.g. "chart table, viewed from ~1m ' +
|
|
472
|
-
'in poor light" - available to any template as source=
|
|
486
|
+
'in poor light" - available to any template as `source=label,path=description`. ' +
|
|
487
|
+
docsLink("templates/#label-details"),
|
|
473
488
|
},
|
|
474
|
-
templateName: withEnum({
|
|
489
|
+
templateName: withEnum({
|
|
490
|
+
type: "string",
|
|
491
|
+
title: "Template",
|
|
492
|
+
description: `A bundled template, or one from your templates directory. ${docsLink("examples/")}`,
|
|
493
|
+
}, templateNameOptions(resolveTemplatesDir(current.templatesDir))),
|
|
475
494
|
repaintTrigger: choiceField("Repaint trigger", [
|
|
476
495
|
["subscription", "When a SignalK path changes"],
|
|
477
496
|
["interval", "On a timed schedule"],
|
|
478
497
|
]),
|
|
479
|
-
triggerPath: {
|
|
480
|
-
type: "string",
|
|
481
|
-
title: "Trigger SignalK path (if repaint trigger is subscription)",
|
|
482
|
-
},
|
|
483
|
-
intervalHours: {
|
|
484
|
-
type: "number",
|
|
485
|
-
title: "Repaint every N hours (if repaint trigger is interval)",
|
|
486
|
-
minimum: 1,
|
|
487
|
-
},
|
|
488
|
-
intervalMinute: {
|
|
489
|
-
type: "number",
|
|
490
|
-
title: "Minutes past the hour (if repaint trigger is interval)",
|
|
491
|
-
minimum: 0,
|
|
492
|
-
maximum: 59,
|
|
493
|
-
default: 0,
|
|
494
|
-
},
|
|
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
498
|
advanced: {
|
|
501
499
|
type: "object",
|
|
502
500
|
title: "Advanced settings",
|
|
503
501
|
description: "Most labels never need these.",
|
|
504
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" }),
|
|
505
508
|
compress: {
|
|
506
509
|
type: "boolean",
|
|
507
510
|
title: 'Compress upload (Zhsunyco, Gicisky 7.5"/10.2")',
|
|
508
|
-
description:
|
|
511
|
+
description: `Sends far less data over BLE, so repaints are quicker. Turn off if a label stops updating. ${docsLink("templates/#other-image-options")}`,
|
|
509
512
|
default: true,
|
|
510
513
|
},
|
|
511
514
|
mirror: choiceField("Mirror", [
|
|
@@ -513,14 +516,20 @@ function configSchema(app, discovered = []) {
|
|
|
513
516
|
["horizontal", "Flip left to right"],
|
|
514
517
|
["vertical", "Flip top to bottom"],
|
|
515
518
|
["both", "Rotate 180° - for a label mounted upside down"],
|
|
516
|
-
], {
|
|
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
|
+
}),
|
|
517
523
|
compressionFormat: choiceField("Wire format (Gicisky, experimental)", [
|
|
518
524
|
["auto", "Auto - the model's usual format"],
|
|
519
525
|
[
|
|
520
526
|
"chunked",
|
|
521
527
|
'Chunked - send compressed like the 7.5"/10.2" panels, e.g. to speed up a 4.2" BWR (untested on current firmware)',
|
|
522
528
|
],
|
|
523
|
-
], {
|
|
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
|
+
}),
|
|
524
533
|
forceRepaint: {
|
|
525
534
|
type: "boolean",
|
|
526
535
|
title: "Force repaint",
|
|
@@ -529,7 +538,9 @@ function configSchema(app, discovered = []) {
|
|
|
529
538
|
},
|
|
530
539
|
aesKey: {
|
|
531
540
|
type: "string",
|
|
532
|
-
title: "BLE AES key (
|
|
541
|
+
title: "BLE AES key (Zhsunyco)",
|
|
542
|
+
description: "32 hex characters. Leave blank to use the default key, which works for most labels.",
|
|
543
|
+
pattern: "^([0-9a-fA-F]{32})?$",
|
|
533
544
|
},
|
|
534
545
|
paintConnectTimeoutSeconds: {
|
|
535
546
|
type: "number",
|
|
@@ -546,21 +557,78 @@ function configSchema(app, discovered = []) {
|
|
|
546
557
|
},
|
|
547
558
|
},
|
|
548
559
|
},
|
|
560
|
+
// Shows only the fields for the chosen trigger. RJSF keeps a hidden field's value, so switching
|
|
561
|
+
// trigger and back doesn't lose it - and the scheduler only reads the ones matching the trigger.
|
|
562
|
+
dependencies: {
|
|
563
|
+
repaintTrigger: {
|
|
564
|
+
oneOf: [
|
|
565
|
+
{
|
|
566
|
+
properties: {
|
|
567
|
+
repaintTrigger: { const: "subscription" },
|
|
568
|
+
triggerPath: {
|
|
569
|
+
type: "string",
|
|
570
|
+
title: "Trigger SignalK path",
|
|
571
|
+
description: "Repaints whenever this path's value changes.",
|
|
572
|
+
},
|
|
573
|
+
},
|
|
574
|
+
},
|
|
575
|
+
{
|
|
576
|
+
properties: {
|
|
577
|
+
repaintTrigger: { const: "interval" },
|
|
578
|
+
intervalHours: {
|
|
579
|
+
type: "number",
|
|
580
|
+
title: "Repaint every N hours",
|
|
581
|
+
minimum: 1,
|
|
582
|
+
},
|
|
583
|
+
intervalMinute: {
|
|
584
|
+
type: "number",
|
|
585
|
+
title: "Minutes past the hour",
|
|
586
|
+
minimum: 0,
|
|
587
|
+
maximum: 59,
|
|
588
|
+
default: 0,
|
|
589
|
+
},
|
|
590
|
+
},
|
|
591
|
+
},
|
|
592
|
+
],
|
|
593
|
+
},
|
|
594
|
+
},
|
|
549
595
|
},
|
|
550
596
|
},
|
|
551
597
|
},
|
|
552
598
|
};
|
|
553
599
|
}
|
|
600
|
+
/** Lets a field's `description` include Markdown - used for its `docsLink`. */
|
|
601
|
+
const MARKDOWN = { "ui:enableMarkdownInDescription": true };
|
|
554
602
|
function configUiSchema() {
|
|
555
603
|
return {
|
|
604
|
+
signalkApiUrl: { "ui:placeholder": "Auto-detect, or e.g. http://localhost:3001" },
|
|
556
605
|
devices: {
|
|
557
606
|
items: {
|
|
558
|
-
|
|
607
|
+
// Keeps the trigger's own fields (added by the schema's `dependencies`) next to it, rather than
|
|
608
|
+
// after every other field, and Advanced settings last.
|
|
609
|
+
"ui:order": [
|
|
610
|
+
"friendlyName",
|
|
611
|
+
"device",
|
|
612
|
+
"description",
|
|
613
|
+
"templateName",
|
|
614
|
+
"repaintTrigger",
|
|
615
|
+
"triggerPath",
|
|
616
|
+
"intervalHours",
|
|
617
|
+
"intervalMinute",
|
|
618
|
+
"*",
|
|
619
|
+
"advanced",
|
|
620
|
+
],
|
|
621
|
+
friendlyName: { "ui:placeholder": "e.g. Tide clock" },
|
|
622
|
+
description: { "ui:widget": "textarea", "ui:placeholder": "e.g. at companionway", ...MARKDOWN },
|
|
623
|
+
templateName: MARKDOWN,
|
|
559
624
|
repaintTrigger: { "ui:widget": "radio" },
|
|
560
|
-
|
|
625
|
+
triggerPath: { "ui:placeholder": "e.g. environment.tide.state" },
|
|
561
626
|
advanced: {
|
|
562
|
-
|
|
563
|
-
|
|
627
|
+
reframe: { "ui:widget": "radio", ...MARKDOWN },
|
|
628
|
+
compress: MARKDOWN,
|
|
629
|
+
mirror: { "ui:widget": "radio", ...MARKDOWN },
|
|
630
|
+
compressionFormat: { "ui:widget": "radio", ...MARKDOWN },
|
|
631
|
+
aesKey: { "ui:placeholder": "e.g. 00112233445566778899aabbccddeeff" },
|
|
564
632
|
},
|
|
565
633
|
},
|
|
566
634
|
},
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** The reference block for one bundled template name, e.g. `tides` (a family) or `tide.svg`. */
|
|
2
|
+
export declare function templateReference(templateName: string, templatesDir?: string): string;
|
|
3
|
+
/** Every bundled template name a user can pick - plain `.svg` files and family directories, not dot-prefixed internals. */
|
|
4
|
+
export declare function bundledTemplateNames(templatesDir?: string): string[];
|
|
5
|
+
/** A one-row-per-template summary, linking each to the example page whose reference block covers it. */
|
|
6
|
+
export declare function templatesSummary(pagesByTemplate: Map<string, string>, templatesDir?: string): string;
|
|
7
|
+
/**
|
|
8
|
+
* Regenerates every marked block under `examplesDir`, returning each file's current and regenerated
|
|
9
|
+
* content. `templates-summary` blocks link to whichever page holds a template's `template-reference`
|
|
10
|
+
* block, so all pages are scanned for those first.
|
|
11
|
+
*/
|
|
12
|
+
export declare function regenerateExampleDocs(examplesDir: string, templatesDir?: string): {
|
|
13
|
+
path: string;
|
|
14
|
+
current: string;
|
|
15
|
+
updated: string;
|
|
16
|
+
stale: boolean;
|
|
17
|
+
}[];
|
|
18
|
+
export declare const EXAMPLES_DOCS_DIR: string;
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.EXAMPLES_DOCS_DIR = void 0;
|
|
4
|
+
exports.templateReference = templateReference;
|
|
5
|
+
exports.bundledTemplateNames = bundledTemplateNames;
|
|
6
|
+
exports.templatesSummary = templatesSummary;
|
|
7
|
+
exports.regenerateExampleDocs = regenerateExampleDocs;
|
|
8
|
+
const fs_1 = require("fs");
|
|
9
|
+
const path_1 = require("path");
|
|
10
|
+
const binding_1 = require("../render/binding");
|
|
11
|
+
const config_1 = require("../config");
|
|
12
|
+
const zhsunyco_1 = require("../devices/zhsunyco");
|
|
13
|
+
const gicisky_1 = require("../devices/gicisky");
|
|
14
|
+
/**
|
|
15
|
+
* Generates the "Template reference" blocks in the docs' example pages from the bundled templates
|
|
16
|
+
* themselves - sizes, aspect ratios, colours, which supported labels each size fits, and the data
|
|
17
|
+
* fields each one reads - so that reference can't drift from the templates it describes. Blocks sit
|
|
18
|
+
* between `<!-- BEGIN GENERATED: <kind> [<template>] -->` and `<!-- END GENERATED -->` markers in
|
|
19
|
+
* hand-written pages; everything outside them is left alone. Run `npm run docs:templates` to update,
|
|
20
|
+
* and `templateReference.test.ts` fails when a block is stale.
|
|
21
|
+
*/
|
|
22
|
+
const TEMPLATE_SOURCE_URL = "https://github.com/rhizomatics/signalk-einklabel-plugin/blob/main/templates";
|
|
23
|
+
const MARKER = /<!-- BEGIN GENERATED: ([\w-]+)(?: ([^\s>]+))? -->[\s\S]*?<!-- END GENERATED -->/g;
|
|
24
|
+
const COLOUR_LETTER = { black: "B", white: "W", red: "R", yellow: "Y" };
|
|
25
|
+
function supportedLabels() {
|
|
26
|
+
return [new zhsunyco_1.ZhsunycoDriver(), new gicisky_1.GiciskyDriver()].flatMap((driver) => driver.supportedDevices());
|
|
27
|
+
}
|
|
28
|
+
/** Every file a bundled template name covers - one for a plain `.svg`, one per variant for a family directory. */
|
|
29
|
+
function templateFiles(templatesDir, templateName) {
|
|
30
|
+
const path = (0, path_1.join)(templatesDir, templateName);
|
|
31
|
+
if (!(0, fs_1.existsSync)(path)) {
|
|
32
|
+
throw new Error(`template "${templateName}" not found in ${templatesDir}`);
|
|
33
|
+
}
|
|
34
|
+
const read = (relativePath, colours) => {
|
|
35
|
+
const source = (0, fs_1.readFileSync)((0, path_1.join)(templatesDir, relativePath), "utf-8");
|
|
36
|
+
return { path: relativePath, ...(0, binding_1.readTemplateDimensions)(source), colours, bindings: (0, binding_1.findBindings)(source) };
|
|
37
|
+
};
|
|
38
|
+
if ((0, fs_1.statSync)(path).isDirectory()) {
|
|
39
|
+
return (0, config_1.listTemplateVariants)(path)
|
|
40
|
+
.sort((a, b) => b.width - a.width || b.height - a.height)
|
|
41
|
+
.map((variant) => read(`${templateName}/${variant.fileName}`, variant.colours));
|
|
42
|
+
}
|
|
43
|
+
return [read(templateName)];
|
|
44
|
+
}
|
|
45
|
+
function variantName(file) {
|
|
46
|
+
return file.path
|
|
47
|
+
.split("/")
|
|
48
|
+
.pop()
|
|
49
|
+
.replace(/\.svg$/, "");
|
|
50
|
+
}
|
|
51
|
+
function aspectRatio(width, height) {
|
|
52
|
+
return `${(width / height).toFixed(2)} : 1`;
|
|
53
|
+
}
|
|
54
|
+
function matchingLabels(file, labels) {
|
|
55
|
+
const matches = labels
|
|
56
|
+
.filter((label) => label.width === file.width && label.height - label.voffset === file.height)
|
|
57
|
+
.map((label) => {
|
|
58
|
+
const colours = label.colours.map((colour) => COLOUR_LETTER[colour]).join("");
|
|
59
|
+
// Some models' own label already names their colours (e.g. Gicisky's `2.9" BWR`).
|
|
60
|
+
const name = label.label.includes(colours) ? label.label : `${label.label} ${colours}`;
|
|
61
|
+
return `${label.manufacturer ?? ""} ${name}`.trim();
|
|
62
|
+
});
|
|
63
|
+
return matches.length > 0 ? [...new Set(matches)].join(", ") : "none exactly - see [Reframing](../templates.md#reframing)";
|
|
64
|
+
}
|
|
65
|
+
function describeSource(binding) {
|
|
66
|
+
switch (binding.source) {
|
|
67
|
+
case "signalk":
|
|
68
|
+
return binding.context === "self" ? "Signal K path" : `Signal K path (${binding.context})`;
|
|
69
|
+
case "resources":
|
|
70
|
+
return `\`${binding.resource}\` resource${binding.provider ? ` (provider \`${binding.provider}\`)` : ""}`;
|
|
71
|
+
case "einklabel":
|
|
72
|
+
return "Plugin";
|
|
73
|
+
case "label":
|
|
74
|
+
return "Label details";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function describeOptions(binding) {
|
|
78
|
+
return [
|
|
79
|
+
binding.assets && `image from \`${binding.assets}\``,
|
|
80
|
+
binding.format && `format \`${binding.format}\``,
|
|
81
|
+
binding.category && `category \`${binding.category}\``,
|
|
82
|
+
binding.round !== undefined && `round ${binding.round}`,
|
|
83
|
+
binding.default !== undefined && `default \`${binding.default}\``,
|
|
84
|
+
].filter((option) => Boolean(option));
|
|
85
|
+
}
|
|
86
|
+
/** A table row per distinct field - array indexes collapsed, so `extremes[0].time` and `extremes[2].time` are one row. */
|
|
87
|
+
function fieldsTable(files) {
|
|
88
|
+
const rows = new Map();
|
|
89
|
+
for (const file of files) {
|
|
90
|
+
for (const binding of file.bindings) {
|
|
91
|
+
const source = describeSource(binding);
|
|
92
|
+
const path = binding.path.replace(/\[\d+\]/g, "[n]");
|
|
93
|
+
const key = `${source}\u0000${path}`;
|
|
94
|
+
const row = rows.get(key) ?? { source, path, options: new Set(), usedIn: new Set() };
|
|
95
|
+
describeOptions(binding).forEach((option) => row.options.add(option));
|
|
96
|
+
row.usedIn.add(variantName(file));
|
|
97
|
+
rows.set(key, row);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const showUsedIn = files.length > 1;
|
|
101
|
+
const header = showUsedIn ? "| Source | Path | Options | Used in |\n|---|---|---|---|" : "| Source | Path | Options |\n|---|---|---|";
|
|
102
|
+
const sorted = [...rows.values()].sort((a, b) => a.source.localeCompare(b.source) || a.path.localeCompare(b.path));
|
|
103
|
+
const lines = sorted.map((row) => {
|
|
104
|
+
const usedIn = row.usedIn.size === files.length ? "all" : [...row.usedIn].join(", ");
|
|
105
|
+
const cells = [row.source, `\`${row.path}\``, [...row.options].join(", ") || "-", ...(showUsedIn ? [usedIn] : [])];
|
|
106
|
+
return `| ${cells.join(" | ")} |`;
|
|
107
|
+
});
|
|
108
|
+
return [header, ...lines].join("\n");
|
|
109
|
+
}
|
|
110
|
+
/** The reference block for one bundled template name, e.g. `tides` (a family) or `tide.svg`. */
|
|
111
|
+
function templateReference(templateName, templatesDir = config_1.BUNDLED_TEMPLATES_DIR) {
|
|
112
|
+
const files = templateFiles(templatesDir, templateName);
|
|
113
|
+
const labels = supportedLabels();
|
|
114
|
+
const sizes = files.map((file) => {
|
|
115
|
+
const size = file.width && file.height ? `${file.width} × ${file.height}` : "-";
|
|
116
|
+
const ratio = file.width && file.height ? aspectRatio(file.width, file.height) : "-";
|
|
117
|
+
const colours = file.colours?.join(", ") ?? "-";
|
|
118
|
+
return `| [\`${file.path}\`](${TEMPLATE_SOURCE_URL}/${file.path}) | ${size} | ${ratio} | ${colours} | ${matchingLabels(file, labels)} |`;
|
|
119
|
+
});
|
|
120
|
+
return [
|
|
121
|
+
`**Template:** \`${templateName}\``,
|
|
122
|
+
"",
|
|
123
|
+
"| File | Size (px) | Aspect ratio | Colours | Fits these labels exactly |",
|
|
124
|
+
"|---|---|---|---|---|",
|
|
125
|
+
...sizes,
|
|
126
|
+
"",
|
|
127
|
+
"**Data used**",
|
|
128
|
+
"",
|
|
129
|
+
fieldsTable(files),
|
|
130
|
+
].join("\n");
|
|
131
|
+
}
|
|
132
|
+
/** Every bundled template name a user can pick - plain `.svg` files and family directories, not dot-prefixed internals. */
|
|
133
|
+
function bundledTemplateNames(templatesDir = config_1.BUNDLED_TEMPLATES_DIR) {
|
|
134
|
+
return [...(0, config_1.listTemplateFamilies)(templatesDir), ...(0, config_1.listSvgFiles)(templatesDir)].sort();
|
|
135
|
+
}
|
|
136
|
+
/** A one-row-per-template summary, linking each to the example page whose reference block covers it. */
|
|
137
|
+
function templatesSummary(pagesByTemplate, templatesDir = config_1.BUNDLED_TEMPLATES_DIR) {
|
|
138
|
+
const rows = bundledTemplateNames(templatesDir).map((name) => {
|
|
139
|
+
const files = templateFiles(templatesDir, name);
|
|
140
|
+
const sizes = files.map((file) => (file.width && file.height ? `${file.width}×${file.height}` : "?")).join(", ");
|
|
141
|
+
// Only what has to come from outside the plugin - its own and the label's details are always there.
|
|
142
|
+
const external = files
|
|
143
|
+
.flatMap((file) => file.bindings)
|
|
144
|
+
.filter((binding) => binding.source === "signalk" || binding.source === "resources");
|
|
145
|
+
const resources = external.filter((binding) => binding.source === "resources").map(describeSource);
|
|
146
|
+
const paths = external.filter((binding) => binding.source === "signalk").map((binding) => `\`${binding.path}\``);
|
|
147
|
+
const sources = [...new Set(resources), ...(paths.length > 0 ? [`Signal K ${[...new Set(paths)].sort().join(", ")}`] : [])];
|
|
148
|
+
const page = pagesByTemplate.get(name);
|
|
149
|
+
return `| ${page ? `[\`${name}\`](${page})` : `\`${name}\``} | ${sizes} | ${sources.join(", ") || "-"} |`;
|
|
150
|
+
});
|
|
151
|
+
return ["| Template | Sizes | Data needed |", "|---|---|---|", ...rows].join("\n");
|
|
152
|
+
}
|
|
153
|
+
function markdownFiles(dir) {
|
|
154
|
+
return (0, fs_1.readdirSync)(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
155
|
+
const path = (0, path_1.join)(dir, entry.name);
|
|
156
|
+
if (entry.isDirectory())
|
|
157
|
+
return markdownFiles(path);
|
|
158
|
+
return entry.name.endsWith(".md") ? [path] : [];
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Regenerates every marked block under `examplesDir`, returning each file's current and regenerated
|
|
163
|
+
* content. `templates-summary` blocks link to whichever page holds a template's `template-reference`
|
|
164
|
+
* block, so all pages are scanned for those first.
|
|
165
|
+
*/
|
|
166
|
+
function regenerateExampleDocs(examplesDir, templatesDir = config_1.BUNDLED_TEMPLATES_DIR) {
|
|
167
|
+
const pages = markdownFiles(examplesDir).map((path) => ({ path, current: (0, fs_1.readFileSync)(path, "utf-8") }));
|
|
168
|
+
const pagesByTemplate = new Map();
|
|
169
|
+
for (const page of pages) {
|
|
170
|
+
for (const match of page.current.matchAll(MARKER)) {
|
|
171
|
+
if (match[1] === "template-reference" && match[2]) {
|
|
172
|
+
pagesByTemplate.set(match[2], page.path.slice(examplesDir.length + 1));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return pages.map((page) => {
|
|
177
|
+
const updated = page.current.replace(MARKER, (_block, kind, arg) => {
|
|
178
|
+
const body = kind === "template-reference" && arg
|
|
179
|
+
? templateReference(arg, templatesDir)
|
|
180
|
+
: kind === "templates-summary"
|
|
181
|
+
? templatesSummary(pagesByTemplate, templatesDir)
|
|
182
|
+
: undefined;
|
|
183
|
+
if (body === undefined)
|
|
184
|
+
throw new Error(`${page.path}: unknown generated block "${kind}${arg ? ` ${arg}` : ""}"`);
|
|
185
|
+
return `<!-- BEGIN GENERATED: ${kind}${arg ? ` ${arg}` : ""} -->\n<!-- Generated from the bundled templates by \`npm run docs:templates\` - do not edit by hand. -->\n\n${body}\n\n<!-- END GENERATED -->`;
|
|
186
|
+
});
|
|
187
|
+
return { ...page, updated, stale: ignoringTableAlignment(updated) !== ignoringTableAlignment(page.current) };
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* The formatter (`oxfmt`, run by `npm run docs:templates` after this) pads Markdown table columns to
|
|
192
|
+
* line up, which this generator doesn't - so compare with that padding stripped, or a freshly
|
|
193
|
+
* formatted page would always look stale.
|
|
194
|
+
*/
|
|
195
|
+
function ignoringTableAlignment(markdown) {
|
|
196
|
+
return markdown
|
|
197
|
+
.split("\n")
|
|
198
|
+
.map((line) => (line.startsWith("|") ? line.replace(/\s*\|\s*/g, "|").replace(/-{3,}/g, "---") : line))
|
|
199
|
+
.join("\n");
|
|
200
|
+
}
|
|
201
|
+
exports.EXAMPLES_DOCS_DIR = (0, path_1.join)(__dirname, "..", "..", "docs", "examples");
|
|
202
|
+
if (require.main === module) {
|
|
203
|
+
const changed = regenerateExampleDocs(exports.EXAMPLES_DOCS_DIR).filter((page) => page.stale);
|
|
204
|
+
for (const page of changed)
|
|
205
|
+
(0, fs_1.writeFileSync)(page.path, page.updated);
|
|
206
|
+
console.log(changed.length > 0 ? `updated ${changed.map((page) => page.path).join(", ")}` : "template docs already up to date");
|
|
207
|
+
}
|
package/dist/repaintScheduler.js
CHANGED
|
@@ -260,6 +260,23 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
|
|
|
260
260
|
let dataHash = "";
|
|
261
261
|
let paintDurationMs = 0;
|
|
262
262
|
let repaintReason = "render failed";
|
|
263
|
+
// Built up front, outside the try below, so the fallback warning render gets it too - it's all local
|
|
264
|
+
// facts (no network or template involved), so there's nothing here that can fail a render.
|
|
265
|
+
const rawPosition = (0, unwrapSignalkTree_1.unwrapSignalkTree)(app.getSelfPath("navigation.position"));
|
|
266
|
+
const position = typeof rawPosition?.latitude === "number" && typeof rawPosition?.longitude === "number"
|
|
267
|
+
? { latitude: rawPosition.latitude, longitude: rawPosition.longitude }
|
|
268
|
+
: undefined;
|
|
269
|
+
const labelContext = (0, binding_1.buildLabelContext)({
|
|
270
|
+
// Falls back to the driver's own internal vendor key (e.g. "zhsunyco") when a device model has no
|
|
271
|
+
// explicit `manufacturer` of its own - see `DeviceMetadata.manufacturer`'s doc comment.
|
|
272
|
+
manufacturer: metadata.manufacturer ?? target.vendor,
|
|
273
|
+
label: metadata.label,
|
|
274
|
+
width,
|
|
275
|
+
height,
|
|
276
|
+
colours: metadata.colours,
|
|
277
|
+
description: device.description,
|
|
278
|
+
position,
|
|
279
|
+
});
|
|
263
280
|
try {
|
|
264
281
|
const apiUrl = await getApiUrl().catch((err) => {
|
|
265
282
|
app.debug(`${label}: ${err.message}`);
|
|
@@ -281,21 +298,6 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
|
|
|
281
298
|
bindings = (0, binding_1.findBindings)((0, fs_1.readFileSync)(templatePath, "utf-8"));
|
|
282
299
|
}
|
|
283
300
|
const rawContext = await assembleRawContext(app, apiUrl, bindings);
|
|
284
|
-
const rawPosition = (0, unwrapSignalkTree_1.unwrapSignalkTree)(app.getSelfPath("navigation.position"));
|
|
285
|
-
const position = typeof rawPosition?.latitude === "number" && typeof rawPosition?.longitude === "number"
|
|
286
|
-
? { latitude: rawPosition.latitude, longitude: rawPosition.longitude }
|
|
287
|
-
: undefined;
|
|
288
|
-
const labelContext = (0, binding_1.buildLabelContext)({
|
|
289
|
-
// Falls back to the driver's own internal vendor key (e.g. "zhsunyco") when a device model has no
|
|
290
|
-
// explicit `manufacturer` of its own - see `DeviceMetadata.manufacturer`'s doc comment.
|
|
291
|
-
manufacturer: metadata.manufacturer ?? target.vendor,
|
|
292
|
-
label: metadata.label,
|
|
293
|
-
width,
|
|
294
|
-
height,
|
|
295
|
-
colours: metadata.colours,
|
|
296
|
-
description: device.description,
|
|
297
|
-
position,
|
|
298
|
-
});
|
|
299
301
|
// Hashed before `meta` is merged in below, deliberately, so a template merely *displaying* the
|
|
300
302
|
// repaint timestamp doesn't perpetually invalidate its own dedup and force a repaint every check. A
|
|
301
303
|
// full paint flashes the whole panel several times, and there's no confirmed partial-refresh path
|
|
@@ -320,6 +322,7 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
|
|
|
320
322
|
repainted: new Date().toISOString(),
|
|
321
323
|
local_zone: (0, formatters_1.resolveLocalZoneAbbreviation)(rawContext),
|
|
322
324
|
plugin_version: pluginVersion_1.PLUGIN_VERSION,
|
|
325
|
+
// Undocumented legacy alias of `label.description` - kept so templates already using it keep working.
|
|
323
326
|
description: device.description ?? "",
|
|
324
327
|
},
|
|
325
328
|
};
|
|
@@ -344,7 +347,10 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
|
|
|
344
347
|
height: metadata.height,
|
|
345
348
|
colours: metadata.colours,
|
|
346
349
|
});
|
|
347
|
-
bitmap = await renderer.render(fallbackPath, {
|
|
350
|
+
bitmap = await renderer.render(fallbackPath, {
|
|
351
|
+
label: labelContext,
|
|
352
|
+
meta: { repainted: new Date().toISOString(), plugin_version: pluginVersion_1.PLUGIN_VERSION, description: device.description ?? "" },
|
|
353
|
+
}, width, height, templatesDir, config_1.BUNDLED_TEMPLATES_DIR);
|
|
348
354
|
}
|
|
349
355
|
const connectTimeoutMs = (device.advanced?.paintConnectTimeoutSeconds ?? config.paintConnectTimeoutSeconds) * 1000;
|
|
350
356
|
const gattBackend = config.useBleApi && app.bleApi ? (0, bleBackend_1.bleApiBackend)(app.bleApi, pluginVersion_1.PLUGIN_NAME) : undefined;
|
|
@@ -357,7 +363,7 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
|
|
|
357
363
|
pid: target.pid,
|
|
358
364
|
aesKey: device.advanced?.aesKey,
|
|
359
365
|
connectTimeoutMs,
|
|
360
|
-
reframe: device.reframe,
|
|
366
|
+
reframe: device.advanced?.reframe,
|
|
361
367
|
mirror: device.advanced?.mirror,
|
|
362
368
|
compress: device.advanced?.compress,
|
|
363
369
|
compressionFormat: device.advanced?.compressionFormat,
|
package/dist/resolveApiUrl.js
CHANGED
|
@@ -37,6 +37,9 @@ async function probe(url) {
|
|
|
37
37
|
* and uses the first that responds.
|
|
38
38
|
*/
|
|
39
39
|
async function resolveSignalkApiUrl(configuredUrl) {
|
|
40
|
+
// Typed free-form in the config UI, so tolerate stray whitespace and a trailing slash, which would
|
|
41
|
+
// otherwise double up with the leading slash of every API path appended to it.
|
|
42
|
+
configuredUrl = configuredUrl?.trim().replace(/\/+$/, "") || undefined;
|
|
40
43
|
const candidates = configuredUrl ? [configuredUrl] : exports.SIGNALK_API_URL_OPTIONS;
|
|
41
44
|
for (const url of candidates) {
|
|
42
45
|
if (await probe(url))
|