@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 +25 -1
- package/README.md +42 -3
- package/dist/cli/index.d.ts +4 -1
- package/dist/cli/index.js +25 -1
- package/dist/config.d.ts +46 -14
- package/dist/config.js +170 -53
- package/dist/devices/bleBackend.d.ts +30 -2
- package/dist/devices/bleBackend.js +179 -7
- package/dist/devices/bleDiscovery.d.ts +9 -2
- package/dist/devices/bleDiscovery.js +12 -3
- package/dist/devices/discoveryCoordinator.d.ts +18 -0
- package/dist/devices/discoveryCoordinator.js +89 -22
- package/dist/devices/gicisky/compression.d.ts +22 -9
- package/dist/devices/gicisky/compression.js +128 -13
- package/dist/devices/gicisky/encode.d.ts +1 -1
- package/dist/devices/gicisky/encode.js +2 -2
- package/dist/devices/gicisky/index.js +19 -8
- package/dist/devices/gicisky/layout.d.ts +13 -1
- package/dist/devices/gicisky/layout.js +21 -0
- package/dist/devices/types.d.ts +22 -0
- package/dist/devices/types.js +2 -0
- package/dist/devices/zhsunyco/compression.d.ts +1 -0
- package/dist/devices/zhsunyco/compression.js +30 -0
- package/dist/devices/zhsunyco/index.js +10 -4
- package/dist/devices/zhsunyco/protocol.d.ts +2 -0
- package/dist/devices/zhsunyco/protocol.js +2 -0
- package/dist/plugin.js +11 -2
- package/dist/render/mirror.d.ts +11 -0
- package/dist/render/mirror.js +24 -0
- package/dist/repaintScheduler.js +27 -12
- package/package.json +2 -2
|
@@ -2,33 +2,148 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Wire framing for `packing: "chunked"` devices (the 7.5"/10.2" panels).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
5
|
+
* Each 64-byte chunk of a plane is sent either as a `0x75`-tagged QuickLZ-compressed block or a
|
|
6
|
+
* `0x74`-tagged raw one, whichever the compressor decides - a port of hass-gicisky's
|
|
7
|
+
* `gicisky_ble/compression.py`. That's QuickLZ Level 1, but with the vendor firmware's 6-bit
|
|
8
|
+
* (64-bucket) hash rather than stock QuickLZ's 12-bit one: match tokens carry a hash-table slot, not
|
|
9
|
+
* an offset, and the panel's decoder only ever fills 64 slots - a token naming a slot above that
|
|
10
|
+
* reads one that was never written and silently decodes garbage. So this must stay byte-compatible
|
|
11
|
+
* with the vendor's hash, not just any valid QuickLZ.
|
|
12
|
+
*
|
|
13
|
+
* One deliberate difference from hass-gicisky: `UNCONDITIONAL_MATCHLEN` is stock QuickLZ's 6, not
|
|
14
|
+
* its 12, so matches may start closer to a chunk's end. That's what the vendor's own app does - with
|
|
15
|
+
* 6 this reproduces 483 of the 486 distinct `0x75` chunks in Cabalist's BLE captures of the app
|
|
16
|
+
* (https://github.com/Cabalist/gicisky_image_notes) byte for byte, against 408 with 12. The other 3
|
|
17
|
+
* are chunks the app sends "compressed" despite them growing; this sends those raw (`0x74`) instead.
|
|
12
18
|
*/
|
|
13
19
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.qlzCompressChunk = qlzCompressChunk;
|
|
14
21
|
exports.frameChunkedPlanes = frameChunkedPlanes;
|
|
15
22
|
const CHUNK_SIZE = 64;
|
|
16
23
|
const RAW_CHUNK_TAG = 0x74;
|
|
17
|
-
|
|
24
|
+
const COMPRESSED_CHUNK_TAG = 0x75;
|
|
25
|
+
const CWORD_LEN = 4;
|
|
26
|
+
const HASH_VALUES = 64;
|
|
27
|
+
const NO_ENTRY = -1;
|
|
28
|
+
const MIN_OFFSET = 2;
|
|
29
|
+
const UNCONDITIONAL_MATCHLEN = 6;
|
|
30
|
+
const UNCOMPRESSED_END = 4;
|
|
31
|
+
/** Control-word sentinel: the top bit marks where the 31 flag bits below it run out. */
|
|
32
|
+
const CWORD_SENTINEL = 0x80000000;
|
|
33
|
+
function hashOf(fetch) {
|
|
34
|
+
return ((fetch >>> 12) ^ fetch) & (HASH_VALUES - 1);
|
|
35
|
+
}
|
|
36
|
+
function read3(data, pos) {
|
|
37
|
+
return pos + 3 > data.length ? 0 : data[pos] | (data[pos + 1] << 8) | (data[pos + 2] << 16);
|
|
38
|
+
}
|
|
39
|
+
/** Whether the `n + 1` bytes from `pos` are all equal. */
|
|
40
|
+
function allSame(data, pos, n) {
|
|
41
|
+
if (pos < 0 || pos + n >= data.length)
|
|
42
|
+
return false;
|
|
43
|
+
for (let i = 1; i <= n; i++) {
|
|
44
|
+
if (data[pos + i] !== data[pos])
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* QuickLZ L1 compression of one chunk, mirroring `_qlz_compress_core` step for step (including its
|
|
51
|
+
* quirks, e.g. the run-of-identical-bytes special case) so the output matches the vendor app's byte for
|
|
52
|
+
* byte. Returns `undefined` when compressing wouldn't save anything.
|
|
53
|
+
*/
|
|
54
|
+
function qlzCompressChunk(source) {
|
|
55
|
+
const size = source.length;
|
|
56
|
+
const lastByte = size - 1;
|
|
57
|
+
const lastMatchStart = lastByte - UNCONDITIONAL_MATCHLEN - UNCOMPRESSED_END;
|
|
58
|
+
if (lastMatchStart < 0)
|
|
59
|
+
return undefined;
|
|
60
|
+
const out = Buffer.alloc(size * 2 + 400);
|
|
61
|
+
let cwordPtr = 0;
|
|
62
|
+
let dst = CWORD_LEN;
|
|
63
|
+
let cword = CWORD_SENTINEL;
|
|
64
|
+
let src = 0;
|
|
65
|
+
let lits = 0;
|
|
66
|
+
const hashOffset = new Int32Array(HASH_VALUES).fill(NO_ENTRY);
|
|
67
|
+
const hashCache = new Int32Array(HASH_VALUES);
|
|
68
|
+
const flushCword = () => {
|
|
69
|
+
out.writeUInt32LE(((cword >>> 1) | CWORD_SENTINEL) >>> 0, cwordPtr);
|
|
70
|
+
cwordPtr = dst;
|
|
71
|
+
dst += CWORD_LEN;
|
|
72
|
+
cword = CWORD_SENTINEL;
|
|
73
|
+
};
|
|
74
|
+
while (src <= lastMatchStart) {
|
|
75
|
+
if ((cword & 1) === 1) {
|
|
76
|
+
if (src > size >> 1 && dst > src - (src >> 5))
|
|
77
|
+
return undefined;
|
|
78
|
+
flushCword();
|
|
79
|
+
}
|
|
80
|
+
const fetch = read3(source, src);
|
|
81
|
+
const h = hashOf(fetch);
|
|
82
|
+
const cached = fetch ^ hashCache[h];
|
|
83
|
+
hashCache[h] = fetch;
|
|
84
|
+
const o = hashOffset[h];
|
|
85
|
+
hashOffset[h] = src;
|
|
86
|
+
if ((cached & 0xffffff) === 0 &&
|
|
87
|
+
o !== NO_ENTRY &&
|
|
88
|
+
(src - o > MIN_OFFSET || (src === o + 1 && lits >= 3 && src > 3 && allSame(source, src - 3, 6)))) {
|
|
89
|
+
let matchLen = 3;
|
|
90
|
+
const remaining = Math.min(255, lastByte - UNCOMPRESSED_END - src + 1);
|
|
91
|
+
while (matchLen < remaining && source[src + matchLen] === source[o + matchLen])
|
|
92
|
+
matchLen++;
|
|
93
|
+
const hShifted = h << 4;
|
|
94
|
+
cword = ((cword >>> 1) | CWORD_SENTINEL) >>> 0;
|
|
95
|
+
if (matchLen < 18) {
|
|
96
|
+
out.writeUInt16LE((matchLen - 2) | hShifted, dst);
|
|
97
|
+
dst += 2;
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
out.writeUInt16LE(hShifted, dst);
|
|
101
|
+
out[dst + 2] = matchLen;
|
|
102
|
+
dst += 3;
|
|
103
|
+
}
|
|
104
|
+
src += matchLen;
|
|
105
|
+
lits = 0;
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
lits++;
|
|
109
|
+
out[dst++] = source[src++];
|
|
110
|
+
cword >>>= 1;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
while (src <= lastByte) {
|
|
114
|
+
if ((cword & 1) === 1)
|
|
115
|
+
flushCword();
|
|
116
|
+
if (src <= lastByte - 2) {
|
|
117
|
+
const f = read3(source, src);
|
|
118
|
+
const hh = hashOf(f);
|
|
119
|
+
hashCache[hh] = f;
|
|
120
|
+
hashOffset[hh] = src;
|
|
121
|
+
}
|
|
122
|
+
out[dst++] = source[src++];
|
|
123
|
+
cword >>>= 1;
|
|
124
|
+
}
|
|
125
|
+
while ((cword & 1) !== 1)
|
|
126
|
+
cword >>>= 1;
|
|
127
|
+
out.writeUInt32LE(((cword >>> 1) | CWORD_SENTINEL) >>> 0, cwordPtr);
|
|
128
|
+
return dst >= size ? undefined : out.subarray(0, dst);
|
|
129
|
+
}
|
|
130
|
+
function chunkPlane(data, compress) {
|
|
18
131
|
const chunks = [];
|
|
19
132
|
for (let offset = 0; offset < data.length; offset += CHUNK_SIZE) {
|
|
20
133
|
const chunk = data.subarray(offset, Math.min(offset + CHUNK_SIZE, data.length));
|
|
21
|
-
const
|
|
22
|
-
|
|
134
|
+
const compressed = compress ? qlzCompressChunk(chunk) : undefined;
|
|
135
|
+
const body = compressed ?? chunk;
|
|
136
|
+
chunks.push(Buffer.from([compressed ? COMPRESSED_CHUNK_TAG : RAW_CHUNK_TAG, 3 + body.length, chunk.length]), body);
|
|
23
137
|
}
|
|
24
138
|
return Buffer.concat(chunks);
|
|
25
139
|
}
|
|
26
140
|
/**
|
|
27
141
|
* Frames two equal-length bit-planes (e.g. BW and red) as `[4-byte LE length of planeB]` followed
|
|
28
|
-
* by each plane's
|
|
142
|
+
* by each plane's chunked bytes, matching `compress()`'s output shape in the reference driver.
|
|
143
|
+
* `compress: false` sends every chunk raw (`0x74`), like hass-gicisky's `force_raw`.
|
|
29
144
|
*/
|
|
30
|
-
function frameChunkedPlanes(planeA, planeB) {
|
|
145
|
+
function frameChunkedPlanes(planeA, planeB, compress = true) {
|
|
31
146
|
const header = Buffer.alloc(4);
|
|
32
147
|
header.writeUInt32LE(planeB.length, 0);
|
|
33
|
-
return Buffer.concat([header,
|
|
148
|
+
return Buffer.concat([header, chunkPlane(planeA, compress), chunkPlane(planeB, compress)]);
|
|
34
149
|
}
|
|
@@ -7,4 +7,4 @@ import { GiciskyLayout } from "./layout";
|
|
|
7
7
|
* colour selection uses palette-nearest classification (matching this codebase's `zhsunyco`
|
|
8
8
|
* driver) rather than the reference driver's raw-luminance thresholds.
|
|
9
9
|
*/
|
|
10
|
-
export declare function encodeBitmap(bitmap: Bitmap, metadata: DeviceMetadata, layout: GiciskyLayout): Buffer;
|
|
10
|
+
export declare function encodeBitmap(bitmap: Bitmap, metadata: DeviceMetadata, layout: GiciskyLayout, compress?: boolean): Buffer;
|
|
@@ -115,7 +115,7 @@ function packFourColour(bitmap, layout, supported) {
|
|
|
115
115
|
* colour selection uses palette-nearest classification (matching this codebase's `zhsunyco`
|
|
116
116
|
* driver) rather than the reference driver's raw-luminance thresholds.
|
|
117
117
|
*/
|
|
118
|
-
function encodeBitmap(bitmap, metadata, layout) {
|
|
118
|
+
function encodeBitmap(bitmap, metadata, layout, compress = true) {
|
|
119
119
|
if (layout.packing === "unsupported") {
|
|
120
120
|
throw new Error(`gicisky paint: device "${metadata.label}" isn't supported yet (needs compression/resize support this driver doesn't implement)`);
|
|
121
121
|
}
|
|
@@ -134,7 +134,7 @@ function encodeBitmap(bitmap, metadata, layout) {
|
|
|
134
134
|
}
|
|
135
135
|
const redPlane = packPlane(rotated, layout, supported, (colour) => colour === "red");
|
|
136
136
|
if (layout.packing === "chunked") {
|
|
137
|
-
return (0, compression_1.frameChunkedPlanes)(bwPlane, redPlane);
|
|
137
|
+
return (0, compression_1.frameChunkedPlanes)(bwPlane, redPlane, compress);
|
|
138
138
|
}
|
|
139
139
|
return Buffer.concat([bwPlane, redPlane]);
|
|
140
140
|
}
|
|
@@ -6,6 +6,7 @@ const metadata_1 = require("./metadata");
|
|
|
6
6
|
const layout_1 = require("./layout");
|
|
7
7
|
const encode_1 = require("./encode");
|
|
8
8
|
const reframe_1 = require("../../render/reframe");
|
|
9
|
+
const mirror_1 = require("../../render/mirror");
|
|
9
10
|
const protocol_1 = require("./protocol");
|
|
10
11
|
/** How long to actively rescan for a fresh advertisement when the cached one is missing/stale - see `BleBackend.waitForManufacturerData`. */
|
|
11
12
|
const MANUFACTURER_DATA_RESCAN_TIMEOUT_MS = 15000;
|
|
@@ -53,21 +54,25 @@ class GiciskyDriver {
|
|
|
53
54
|
*/
|
|
54
55
|
const manufacturerData = await backend.waitForManufacturerData(config.address, protocol_1.GICISKY_MANUFACTURER_ID, MANUFACTURER_DATA_RESCAN_TIMEOUT_MS);
|
|
55
56
|
const info = manufacturerData ? (0, protocol_1.decodeAdvertisedInfo)(manufacturerData) : undefined;
|
|
57
|
+
// A device that's quiet right now (e.g. still refreshing from the last paint) can still be painted
|
|
58
|
+
// when its PID is already known from config/a previous scan - the connect below doesn't need the
|
|
59
|
+
// advertisement, only this lookup does.
|
|
60
|
+
const pid = info?.deviceId ?? config.pid;
|
|
56
61
|
const metadata = config.modelOverride
|
|
57
|
-
? { pid:
|
|
58
|
-
:
|
|
59
|
-
? this.metadataForPid(
|
|
62
|
+
? { pid: pid ?? 0, ...config.modelOverride }
|
|
63
|
+
: pid !== undefined
|
|
64
|
+
? this.metadataForPid(pid)
|
|
60
65
|
: undefined;
|
|
61
66
|
if (!metadata) {
|
|
62
|
-
throw new Error(
|
|
67
|
+
throw new Error(pid === undefined
|
|
63
68
|
? "gicisky device isn't advertising - rescanned but got nothing back (out of range, asleep, or already connected " +
|
|
64
69
|
"elsewhere) - pass --width/--height/--voffset/--colours to describe it manually"
|
|
65
|
-
: `gicisky device reports unrecognised deviceId 0x${
|
|
70
|
+
: `gicisky device reports unrecognised deviceId 0x${pid.toString(16).padStart(4, "0")} - ` +
|
|
66
71
|
"pass --width/--height/--voffset/--colours to describe it manually");
|
|
67
72
|
}
|
|
68
|
-
const layout = (
|
|
69
|
-
const framed = (0, reframe_1.reframeBitmap)(bitmap, metadata.width, metadata.height, config.reframe ?? "crop");
|
|
70
|
-
const payload = (0, encode_1.encodeBitmap)(framed, metadata, layout);
|
|
73
|
+
const layout = (0, layout_1.withCompressionFormat)((pid !== undefined && layout_1.GICISKY_PID_LAYOUT[pid]) || (0, layout_1.defaultLayoutFor)(metadata.colours), config.compressionFormat ?? "auto", metadata.colours);
|
|
74
|
+
const framed = (0, mirror_1.mirrorBitmap)((0, reframe_1.reframeBitmap)(bitmap, metadata.width, metadata.height, config.reframe ?? "crop"), config.mirror ?? "none");
|
|
75
|
+
const payload = (0, encode_1.encodeBitmap)(framed, metadata, layout, config.compress ?? true);
|
|
71
76
|
const conn = await backend.connectGatt(config.address, config.connectTimeoutMs ?? DEFAULT_PAINT_CONNECT_TIMEOUT_MS);
|
|
72
77
|
try {
|
|
73
78
|
const { cmdServiceUuid, cmdUuid, imgServiceUuid, imgUuid } = await findCommandAndImageCharacteristics(conn);
|
|
@@ -89,6 +94,12 @@ class GiciskyDriver {
|
|
|
89
94
|
const chunk = payload.subarray(part * chunkSize, Math.min(part * chunkSize + chunkSize, payload.length));
|
|
90
95
|
const ackData = await writeAndAwaitAck(conn, imgServiceUuid, imgUuid, (0, protocol_1.imageChunkPacket)(part, chunk), ack);
|
|
91
96
|
const decoded = (0, protocol_1.decodeTransferAck)(ackData);
|
|
97
|
+
if (decoded && !decoded.ok && part * chunkSize + chunk.length >= payload.length) {
|
|
98
|
+
// The device answers the final chunk with a non-zero status (seen: `05 08 00000000`) once
|
|
99
|
+
// it has the whole image and starts refreshing - the transfer's done, not failed. Matches
|
|
100
|
+
// hass-gicisky's writer, which ends the transfer on any non-zero status rather than erroring.
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
92
103
|
if (!decoded?.ok) {
|
|
93
104
|
throw new Error(`gicisky device reported an error transferring image part ${part}: ${ackData.toString("hex")}`);
|
|
94
105
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CompressionFormat } from "../types";
|
|
1
2
|
/**
|
|
2
3
|
* Per-model wire-layout quirks, keyed the same way as `GICISKY_PID_METADATA` (by `deviceId`).
|
|
3
4
|
* These aren't part of the shared `DeviceMetadata` shape since no other vendor needs them -
|
|
@@ -9,7 +10,7 @@
|
|
|
9
10
|
export type GiciskyPacking =
|
|
10
11
|
/** Plain concatenated bit-planes, no framing - covers most panels. */
|
|
11
12
|
"plain"
|
|
12
|
-
/** Two bit-planes each split into
|
|
13
|
+
/** Two bit-planes each split into 64-byte chunks (QuickLZ-compressed or raw) framed per `compression.ts` - the 7.5"/10.2" panels. */
|
|
13
14
|
| "chunked"
|
|
14
15
|
/**
|
|
15
16
|
* A panel this driver can identify (for `scan`/discovery) but can't paint correctly yet - either
|
|
@@ -33,3 +34,14 @@ export interface GiciskyLayout {
|
|
|
33
34
|
export declare const GICISKY_PID_LAYOUT: Record<number, GiciskyLayout>;
|
|
34
35
|
/** Best-effort layout for a `modelOverride`d PID this table has no entry for - assumes the common case. */
|
|
35
36
|
export declare function defaultLayoutFor(colours: string[]): GiciskyLayout;
|
|
37
|
+
/**
|
|
38
|
+
* Applies a user's opt-in `compressionFormat: "chunked"` - switching a `"plain"` two-plane (BW + red)
|
|
39
|
+
* layout to the QuickLZ-chunked framing, with the flagged `writeScreen` command that goes with it.
|
|
40
|
+
* Opt-in rather than a model default because only one data point says a plain panel accepts it:
|
|
41
|
+
* Cabalist's 2023 BLE captures (https://github.com/Cabalist/gicisky_image_notes) of the vendor app
|
|
42
|
+
* sending a 4.2" BWR exactly this way, on firmware that may not match what's shipping now.
|
|
43
|
+
*
|
|
44
|
+
* Throws for layouts the framing can't carry, rather than quietly sending plain anyway - the user
|
|
45
|
+
* asked for chunked, so a repaint error says why they're not getting it.
|
|
46
|
+
*/
|
|
47
|
+
export declare function withCompressionFormat(layout: GiciskyLayout, format: CompressionFormat, colours: string[]): GiciskyLayout;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.GICISKY_PID_LAYOUT = void 0;
|
|
4
4
|
exports.defaultLayoutFor = defaultLayoutFor;
|
|
5
|
+
exports.withCompressionFormat = withCompressionFormat;
|
|
5
6
|
const DEFAULT_LAYOUT = {
|
|
6
7
|
rotation: 0,
|
|
7
8
|
mirrorX: false,
|
|
@@ -33,3 +34,23 @@ exports.GICISKY_PID_LAYOUT = {
|
|
|
33
34
|
function defaultLayoutFor(colours) {
|
|
34
35
|
return { ...DEFAULT_LAYOUT, fourColour: colours.includes("yellow") };
|
|
35
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Applies a user's opt-in `compressionFormat: "chunked"` - switching a `"plain"` two-plane (BW + red)
|
|
39
|
+
* layout to the QuickLZ-chunked framing, with the flagged `writeScreen` command that goes with it.
|
|
40
|
+
* Opt-in rather than a model default because only one data point says a plain panel accepts it:
|
|
41
|
+
* Cabalist's 2023 BLE captures (https://github.com/Cabalist/gicisky_image_notes) of the vendor app
|
|
42
|
+
* sending a 4.2" BWR exactly this way, on firmware that may not match what's shipping now.
|
|
43
|
+
*
|
|
44
|
+
* Throws for layouts the framing can't carry, rather than quietly sending plain anyway - the user
|
|
45
|
+
* asked for chunked, so a repaint error says why they're not getting it.
|
|
46
|
+
*/
|
|
47
|
+
function withCompressionFormat(layout, format, colours) {
|
|
48
|
+
if (format === "auto" || layout.packing === "chunked") {
|
|
49
|
+
return layout;
|
|
50
|
+
}
|
|
51
|
+
if (layout.packing !== "plain" || layout.fourColour || !colours.includes("red")) {
|
|
52
|
+
throw new Error('gicisky paint: compressionFormat "chunked" needs a panel sent as separate black/white and red planes - ' +
|
|
53
|
+
'this model isn\'t (four-colour, black/white only, or unsupported) - set it back to "auto"');
|
|
54
|
+
}
|
|
55
|
+
return { ...layout, packing: "chunked" };
|
|
56
|
+
}
|
package/dist/devices/types.d.ts
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import { Bitmap } from "../render/types";
|
|
2
2
|
import { ReframeMode } from "../render/reframe";
|
|
3
|
+
import { MirrorMode } from "../render/mirror";
|
|
3
4
|
import { BleBackend } from "./bleBackend";
|
|
4
5
|
import { GattConnection } from "./gattConnection";
|
|
5
6
|
export type Colour = "black" | "white" | "red" | "yellow";
|
|
7
|
+
/**
|
|
8
|
+
* Which wire format to send in - `"auto"` uses whatever the driver's model table says;
|
|
9
|
+
* `"chunked"` opts a gicisky panel with separate BW/red planes (e.g. the 4.2" BWR, normally sent
|
|
10
|
+
* plain) into the QuickLZ-compressed chunk framing the 7.5"/10.2" use - see `withCompressionFormat`
|
|
11
|
+
* in `gicisky/layout.ts`. Ignored by other vendors.
|
|
12
|
+
*/
|
|
13
|
+
export type CompressionFormat = "auto" | "chunked";
|
|
14
|
+
export declare const COMPRESSION_FORMATS: CompressionFormat[];
|
|
6
15
|
/**
|
|
7
16
|
* Static facts about one device model, keyed by (vendor, pid) by the registry —
|
|
8
17
|
* PID alone is not assumed unique across vendors.
|
|
@@ -53,6 +62,13 @@ export type DeviceModelOverride = Omit<DeviceMetadata, "pid">;
|
|
|
53
62
|
/** Per-device settings the user supplies when registering a device, beyond what's in DeviceMetadata. */
|
|
54
63
|
export interface VendorDeviceConfig {
|
|
55
64
|
address: string;
|
|
65
|
+
/**
|
|
66
|
+
* The PID already known for this device (from the configured `"<vendor>:<pid>@<address>"` or a
|
|
67
|
+
* previous scan) - a fallback for drivers that otherwise only learn it from a fresh advertisement
|
|
68
|
+
* (gicisky), so a device that's quiet at paint time can still be painted. A live advertised PID
|
|
69
|
+
* takes precedence when there is one.
|
|
70
|
+
*/
|
|
71
|
+
pid?: number;
|
|
56
72
|
/** AES key for vendors that need it, entered by the user. If omitted, vendors that have one may fall back to a stock/manufacturer-default key instead of failing. */
|
|
57
73
|
aesKey?: string;
|
|
58
74
|
/** Forces the device model facts instead of looking up the advertised PID - for hardware not yet in the driver's table. */
|
|
@@ -61,6 +77,12 @@ export interface VendorDeviceConfig {
|
|
|
61
77
|
connectTimeoutMs?: number;
|
|
62
78
|
/** How to fit the bitmap onto the panel when its size doesn't already match - see `ReframeMode`. Defaults to `"crop"` - a live label showing *something*, even off-size, beats a repaint that just fails outright; pass `"fixed"` explicitly to get the old reject-the-mismatch behaviour back. */
|
|
63
79
|
reframe?: ReframeMode;
|
|
80
|
+
/** Flips the image after reframing, before encoding - see `MirrorMode`. Defaults to `"none"`. */
|
|
81
|
+
mirror?: MirrorMode;
|
|
82
|
+
/** Compress the upload where the vendor protocol supports it (zhsunyco; gicisky's chunked 7.5"/10.2" panels); ignored otherwise. Defaults to `true`. */
|
|
83
|
+
compress?: boolean;
|
|
84
|
+
/** Overrides the model's own wire format - see `CompressionFormat`. Defaults to `"auto"`. */
|
|
85
|
+
compressionFormat?: CompressionFormat;
|
|
64
86
|
/**
|
|
65
87
|
* How `paint()` reaches the device's BLE hardware - omitted (always true for the CLI, which has no
|
|
66
88
|
* `ServerAPI`/`app.bleApi` to source one from) means direct BlueZ access via a fresh
|
package/dist/devices/types.js
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function compressWolinkBlocks(data: Buffer): Buffer;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.compressWolinkBlocks = compressWolinkBlocks;
|
|
4
|
+
const zlib_1 = require("zlib");
|
|
5
|
+
/**
|
|
6
|
+
* Wolink block-deflate payload format, ported from the `zhsunyco_esl` package bundled with the
|
|
7
|
+
* Home Assistant Wolink ESL integration (`zhsunyco_esl/protocol.py`):
|
|
8
|
+
*
|
|
9
|
+
* A5 A6 <blockCount u8> 02, then per block: <1-based index u8> <compressedLength u16le> <raw deflate>
|
|
10
|
+
*
|
|
11
|
+
* Each block holds up to 8 KB of the uncompressed 2bpp buffer. Sent in place of the raw buffer,
|
|
12
|
+
* followed by `COMMAND.refreshCompressed` carrying the compressed length.
|
|
13
|
+
*/
|
|
14
|
+
const WOLINK_BLOCK_SIZE = 8192;
|
|
15
|
+
const WOLINK_BLOCK_FORMAT = 0x02;
|
|
16
|
+
function compressWolinkBlocks(data) {
|
|
17
|
+
const blockCount = Math.ceil(data.length / WOLINK_BLOCK_SIZE);
|
|
18
|
+
if (blockCount > 0xff) {
|
|
19
|
+
throw new Error(`zhsunyco compression: ${data.length} bytes needs ${blockCount} blocks, format allows at most 255`);
|
|
20
|
+
}
|
|
21
|
+
const parts = [Buffer.from([0xa5, 0xa6, blockCount, WOLINK_BLOCK_FORMAT])];
|
|
22
|
+
for (let i = 0; i < blockCount; i++) {
|
|
23
|
+
const deflated = (0, zlib_1.deflateRawSync)(data.subarray(i * WOLINK_BLOCK_SIZE, (i + 1) * WOLINK_BLOCK_SIZE), { level: 9 });
|
|
24
|
+
const header = Buffer.alloc(3);
|
|
25
|
+
header[0] = i + 1;
|
|
26
|
+
header.writeUInt16LE(deflated.length, 1);
|
|
27
|
+
parts.push(header, deflated);
|
|
28
|
+
}
|
|
29
|
+
return Buffer.concat(parts);
|
|
30
|
+
}
|
|
@@ -7,6 +7,8 @@ const pluginVersion_1 = require("../../pluginVersion");
|
|
|
7
7
|
const metadata_1 = require("./metadata");
|
|
8
8
|
const encode_1 = require("./encode");
|
|
9
9
|
const reframe_1 = require("../../render/reframe");
|
|
10
|
+
const mirror_1 = require("../../render/mirror");
|
|
11
|
+
const compression_1 = require("./compression");
|
|
10
12
|
const protocol_1 = require("./protocol");
|
|
11
13
|
/** node-ble has no MTU API; this matches the reference driver's mtu(247)-9 default. */
|
|
12
14
|
const UPLOAD_CHUNK_SIZE = 238;
|
|
@@ -77,14 +79,18 @@ class ZhsunycoDriver {
|
|
|
77
79
|
const challenge = await conn.read(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.authenticate);
|
|
78
80
|
await conn.write(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.authenticate, (0, protocol_1.authResponse)(challenge, aesKey), false);
|
|
79
81
|
await (0, bleDiscovery_1.sleep)(AUTH_SETTLE_DELAY_MS);
|
|
80
|
-
const framed = (0, reframe_1.reframeBitmap)(bitmap, metadata.width, metadata.height - metadata.voffset, config.reframe ?? "crop");
|
|
82
|
+
const framed = (0, mirror_1.mirrorBitmap)((0, reframe_1.reframeBitmap)(bitmap, metadata.width, metadata.height - metadata.voffset, config.reframe ?? "crop"), config.mirror ?? "none");
|
|
81
83
|
const pixelData = (0, encode_1.encodeBitmap)(framed, metadata);
|
|
82
|
-
|
|
83
|
-
|
|
84
|
+
const compress = config.compress ?? true;
|
|
85
|
+
// Upload offsets and the refresh length both count bytes of whatever's actually sent - the
|
|
86
|
+
// compressed payload when compressing, not the raw buffer it inflates back to.
|
|
87
|
+
const payload = compress ? (0, compression_1.compressWolinkBlocks)(pixelData) : pixelData;
|
|
88
|
+
for (let offset = 0; offset < payload.length; offset += UPLOAD_CHUNK_SIZE) {
|
|
89
|
+
const chunk = payload.subarray(offset, offset + UPLOAD_CHUNK_SIZE);
|
|
84
90
|
await conn.write(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.data, Buffer.concat([(0, protocol_1.commandHeader)(protocol_1.COMMAND.uploadBlock, offset), chunk]), true);
|
|
85
91
|
await (0, bleDiscovery_1.sleep)(CHUNK_WRITE_DELAY_MS);
|
|
86
92
|
}
|
|
87
|
-
await conn.write(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.data, (0, protocol_1.commandHeader)(protocol_1.COMMAND.refreshUncompressed,
|
|
93
|
+
await conn.write(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.data, (0, protocol_1.commandHeader)(compress ? protocol_1.COMMAND.refreshCompressed : protocol_1.COMMAND.refreshUncompressed, payload.length), true);
|
|
88
94
|
// `Promise.race` can't cancel its loser, so once `statusReceived` settles (the common case)
|
|
89
95
|
// this timer would otherwise sit alive for the rest of its 60s regardless - see the identical
|
|
90
96
|
// reasoning on `readDeviceDetails`'s own race, below.
|
|
@@ -15,6 +15,8 @@ export declare const WOLINK_CHARACTERISTIC_UUIDS: {
|
|
|
15
15
|
export declare const COMMAND: {
|
|
16
16
|
readonly uploadBlock: 42240;
|
|
17
17
|
readonly refreshUncompressed: 42241;
|
|
18
|
+
/** Refresh after uploading a `compressWolinkBlocks` payload rather than the raw buffer. */
|
|
19
|
+
readonly refreshCompressed: 42242;
|
|
18
20
|
};
|
|
19
21
|
export interface AdvertisedDeviceInfo {
|
|
20
22
|
pid: number;
|
|
@@ -25,6 +25,8 @@ exports.WOLINK_CHARACTERISTIC_UUIDS = {
|
|
|
25
25
|
exports.COMMAND = {
|
|
26
26
|
uploadBlock: 0xa500,
|
|
27
27
|
refreshUncompressed: 0xa501,
|
|
28
|
+
/** Refresh after uploading a `compressWolinkBlocks` payload rather than the raw buffer. */
|
|
29
|
+
refreshCompressed: 0xa502,
|
|
28
30
|
};
|
|
29
31
|
/** Decodes the 8-byte header shared by the advertising mirror and config characteristic. */
|
|
30
32
|
function decodeAdvertisedInfo(data) {
|
package/dist/plugin.js
CHANGED
|
@@ -9,6 +9,7 @@ const discoveryCoordinator_1 = require("./devices/discoveryCoordinator");
|
|
|
9
9
|
const bleDiscovery_1 = require("./devices/bleDiscovery");
|
|
10
10
|
const discoveredDevicesStore_1 = require("./devices/discoveredDevicesStore");
|
|
11
11
|
const repaintScheduler_1 = require("./repaintScheduler");
|
|
12
|
+
const pluginVersion_1 = require("./pluginVersion");
|
|
12
13
|
/** Mirrors signalk-bluetti-plugin's convention: scan briefly, report finds via plugin status for the user to copy-paste. */
|
|
13
14
|
async function runStartupScan(app, durationSeconds, useBleApi) {
|
|
14
15
|
const alreadyRunning = (0, discoveryCoordinator_1.scanInProgressSince)();
|
|
@@ -41,6 +42,7 @@ function createPlugin(app) {
|
|
|
41
42
|
// enough to have added it - checked at runtime rather than trusted from the type.
|
|
42
43
|
const bleApiAvailable = !!app.bleApi;
|
|
43
44
|
let scheduler;
|
|
45
|
+
let stopDiscoveryListener;
|
|
44
46
|
let stopped = false;
|
|
45
47
|
const plugin = {
|
|
46
48
|
id: "signalk-einklabel-plugin",
|
|
@@ -54,10 +56,10 @@ function createPlugin(app) {
|
|
|
54
56
|
start(config) {
|
|
55
57
|
const pluginConfig = {
|
|
56
58
|
...(0, config_1.defaultConfig)(),
|
|
57
|
-
...config,
|
|
59
|
+
...(0, config_1.migrateConfig)(config).config,
|
|
58
60
|
};
|
|
59
61
|
app.debug(`starting with ${pluginConfig.devices.length} configured device(s)`);
|
|
60
|
-
(0, config_1.
|
|
62
|
+
(0, config_1.healStoredConfig)(app);
|
|
61
63
|
stopped = false;
|
|
62
64
|
const useBleApi = pluginConfig.useBleApi && bleApiAvailable;
|
|
63
65
|
if (pluginConfig.useBleApi && !bleApiAvailable) {
|
|
@@ -73,6 +75,11 @@ function createPlugin(app) {
|
|
|
73
75
|
scheduler = (0, repaintScheduler_1.startRepaintScheduler)(app, pluginConfig);
|
|
74
76
|
};
|
|
75
77
|
if (useBleApi) {
|
|
78
|
+
// BLE Manager already runs its own continuous scan server-side (that's what populates its own
|
|
79
|
+
// admin UI device list) - unlike direct BlueZ access below, discovery here doesn't need a
|
|
80
|
+
// bounded scan window at all, so this listens for as long as the plugin runs rather than only
|
|
81
|
+
// during `scanOnStart`/an on-demand `ALL_DEVICES` scan. See its own doc comment.
|
|
82
|
+
stopDiscoveryListener = (0, discoveryCoordinator_1.startBleApiDiscoveryListener)(app, pluginVersion_1.PLUGIN_NAME);
|
|
76
83
|
// The server/provider governs its own local-adapter readiness once BLE Manager mode owns
|
|
77
84
|
// `hci0` (or has none at all, behind a remote gateway) - waiting on a *local* BlueZ adapter
|
|
78
85
|
// here would be waiting on something this mode may never even need.
|
|
@@ -90,6 +97,8 @@ function createPlugin(app) {
|
|
|
90
97
|
stopped = true;
|
|
91
98
|
scheduler?.stop();
|
|
92
99
|
scheduler = undefined;
|
|
100
|
+
stopDiscoveryListener?.();
|
|
101
|
+
stopDiscoveryListener = undefined;
|
|
93
102
|
app.debug("stopped");
|
|
94
103
|
},
|
|
95
104
|
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Bitmap } from "./types";
|
|
2
|
+
/**
|
|
3
|
+
* Flips the rendered image before it's encoded for the panel - for hardware whose RAM layout is
|
|
4
|
+
* mirrored relative to what a driver's encoder assumes (the Wolink HA integration found the 2.9"
|
|
5
|
+
* and 3.5" panels differ from the 3.7" this plugin's zhsunyco encoder was confirmed against), or a
|
|
6
|
+
* label that's simply mounted upside down (`"both"` is a 180° rotation).
|
|
7
|
+
*/
|
|
8
|
+
export type MirrorMode = "none" | "horizontal" | "vertical" | "both";
|
|
9
|
+
export declare const MIRROR_MODES: MirrorMode[];
|
|
10
|
+
/** Returns `bitmap` unchanged for `"none"`, otherwise a flipped copy. */
|
|
11
|
+
export declare function mirrorBitmap(bitmap: Bitmap, mode: MirrorMode): Bitmap;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MIRROR_MODES = void 0;
|
|
4
|
+
exports.mirrorBitmap = mirrorBitmap;
|
|
5
|
+
exports.MIRROR_MODES = ["none", "horizontal", "vertical", "both"];
|
|
6
|
+
/** Returns `bitmap` unchanged for `"none"`, otherwise a flipped copy. */
|
|
7
|
+
function mirrorBitmap(bitmap, mode) {
|
|
8
|
+
if (mode === "none") {
|
|
9
|
+
return bitmap;
|
|
10
|
+
}
|
|
11
|
+
const { width, height } = bitmap;
|
|
12
|
+
const flipX = mode === "horizontal" || mode === "both";
|
|
13
|
+
const flipY = mode === "vertical" || mode === "both";
|
|
14
|
+
const data = new Uint8Array(bitmap.data.length);
|
|
15
|
+
for (let y = 0; y < height; y++) {
|
|
16
|
+
const srcY = flipY ? height - 1 - y : y;
|
|
17
|
+
for (let x = 0; x < width; x++) {
|
|
18
|
+
const srcX = flipX ? width - 1 - x : x;
|
|
19
|
+
const srcOffset = (srcY * width + srcX) * 4;
|
|
20
|
+
data.set(bitmap.data.subarray(srcOffset, srcOffset + 4), (y * width + x) * 4);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return { width, height, data };
|
|
24
|
+
}
|
package/dist/repaintScheduler.js
CHANGED
|
@@ -23,6 +23,8 @@ const pluginVersion_1 = require("./pluginVersion");
|
|
|
23
23
|
const httpJson_1 = require("./httpJson");
|
|
24
24
|
const INTERVAL_POLL_MS = 60000;
|
|
25
25
|
const SUBSCRIPTION_DEBOUNCE_MS = 2000;
|
|
26
|
+
/** Pause between paint attempts - see `withRetries`' `delayMs`. */
|
|
27
|
+
const PAINT_RETRY_DELAY_MS = 5000;
|
|
26
28
|
const RESOURCES_API_PATH = "/signalk/v2/api/resources";
|
|
27
29
|
/**
|
|
28
30
|
* The most recent wall-clock instant at or before `now` that an `interval`-triggered device's own
|
|
@@ -173,7 +175,7 @@ async function assembleRawContext(app, apiUrl, bindings) {
|
|
|
173
175
|
}
|
|
174
176
|
function clearForceRepaint(app, friendlyName) {
|
|
175
177
|
const current = (0, config_1.readCurrentConfig)(app);
|
|
176
|
-
const devices = (current.devices ?? []).map((device) => device.friendlyName === friendlyName ? { ...device, forceRepaint: false } : device);
|
|
178
|
+
const devices = (current.devices ?? []).map((device) => device.friendlyName === friendlyName ? { ...device, advanced: { ...device.advanced, forceRepaint: false } } : device);
|
|
177
179
|
app.savePluginOptions({ ...current, devices }, (err) => {
|
|
178
180
|
if (err)
|
|
179
181
|
app.debug(`failed to clear forceRepaint for "${friendlyName}": ${err.message}`);
|
|
@@ -307,7 +309,7 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
|
|
|
307
309
|
// scheduled tick" (e.g. a regenerated forecast) is the whole point of one - which is exactly why a
|
|
308
310
|
// provider-backed device should use `repaintTrigger: "interval"`, not `subscription`, for
|
|
309
311
|
// cost/battery reasons (each repaint may be a paid API call on the provider's side).
|
|
310
|
-
if (!provider && !templateChanged && !dataChanged && !device.forceRepaint) {
|
|
312
|
+
if (!provider && !templateChanged && !dataChanged && !device.advanced?.forceRepaint) {
|
|
311
313
|
app.debug(`${label}: data unchanged, skipping repaint`);
|
|
312
314
|
return;
|
|
313
315
|
}
|
|
@@ -325,7 +327,7 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
|
|
|
325
327
|
? await provider.render({ templateName: device.templateName, context: renderContext, width, height, colours: metadata.colours })
|
|
326
328
|
: await renderer.render(templatePath, renderContext, width, height, templatesDir, config_1.BUNDLED_TEMPLATES_DIR);
|
|
327
329
|
succeeded = true;
|
|
328
|
-
repaintReason = device.forceRepaint
|
|
330
|
+
repaintReason = device.advanced?.forceRepaint
|
|
329
331
|
? "forced"
|
|
330
332
|
: provider
|
|
331
333
|
? "provider-rendered"
|
|
@@ -344,16 +346,29 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
|
|
|
344
346
|
});
|
|
345
347
|
bitmap = await renderer.render(fallbackPath, { meta: { repainted: new Date().toISOString(), plugin_version: pluginVersion_1.PLUGIN_VERSION, description: device.description ?? "" } }, width, height, templatesDir, config_1.BUNDLED_TEMPLATES_DIR);
|
|
346
348
|
}
|
|
347
|
-
const connectTimeoutMs = config.paintConnectTimeoutSeconds * 1000;
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
}
|
|
349
|
+
const connectTimeoutMs = (device.advanced?.paintConnectTimeoutSeconds ?? config.paintConnectTimeoutSeconds) * 1000;
|
|
350
|
+
const gattBackend = config.useBleApi && app.bleApi ? (0, bleBackend_1.bleApiBackend)(app.bleApi, pluginVersion_1.PLUGIN_NAME) : undefined;
|
|
351
|
+
const attempts = Math.max(1, device.advanced?.paintRetries ?? config.paintRetries);
|
|
352
|
+
const paintWithRetries = () => (0, bleDiscovery_1.withRetries)(attempts, async (attempt) => {
|
|
353
|
+
app.debug(`${label}: attempting paint ${attempt}/${attempts}`);
|
|
352
354
|
const startedAt = Date.now();
|
|
353
|
-
|
|
354
|
-
|
|
355
|
+
await driver.paint(bitmap, {
|
|
356
|
+
address,
|
|
357
|
+
pid: target.pid,
|
|
358
|
+
aesKey: device.advanced?.aesKey,
|
|
359
|
+
connectTimeoutMs,
|
|
360
|
+
reframe: device.reframe,
|
|
361
|
+
mirror: device.advanced?.mirror,
|
|
362
|
+
compress: device.advanced?.compress,
|
|
363
|
+
compressionFormat: device.advanced?.compressionFormat,
|
|
364
|
+
gattBackend,
|
|
365
|
+
});
|
|
355
366
|
paintDurationMs = Date.now() - startedAt;
|
|
367
|
+
}, {
|
|
368
|
+
delayMs: PAINT_RETRY_DELAY_MS,
|
|
369
|
+
onError: (err, attempt) => app.debug(`${label}: paint attempt ${attempt}/${attempts} failed: ${err.message}`),
|
|
356
370
|
});
|
|
371
|
+
await (gattBackend ? (0, bleBackend_1.exclusiveBleManagerAccess)(paintWithRetries) : paintWithRetries());
|
|
357
372
|
(0, discoveredDevicesStore_1.touchDiscoveredDevice)(app, { address, vendor: target.vendor, pid: target.pid, hwVersion: target.hwVersion, metadata });
|
|
358
373
|
// Only a successful render counts as "repainted" for dedup/catch-up purposes - a failure must not be
|
|
359
374
|
// recorded here (see this function's doc comment on why).
|
|
@@ -393,7 +408,7 @@ function startRepaintScheduler(app, config) {
|
|
|
393
408
|
// A single `forceRepaint` flag covers every target under `ALL_DEVICES` too - only clear it once
|
|
394
409
|
// every target has actually succeeded, so a target that failed still gets forced again next time
|
|
395
410
|
// instead of quietly reverting to ordinary hash-based dedup.
|
|
396
|
-
if (device.forceRepaint && results.every((result) => result.status === "fulfilled")) {
|
|
411
|
+
if (device.advanced?.forceRepaint && results.every((result) => result.status === "fulfilled")) {
|
|
397
412
|
clearForceRepaint(app, device.friendlyName);
|
|
398
413
|
}
|
|
399
414
|
};
|
|
@@ -440,7 +455,7 @@ function startRepaintScheduler(app, config) {
|
|
|
440
455
|
// still avoids a redundant paint once targets are resolved.
|
|
441
456
|
const startupCheckTimer = setTimeout(() => {
|
|
442
457
|
for (const device of config.devices) {
|
|
443
|
-
if (device.repaintTrigger === "interval" && !device.forceRepaint && device.device !== config_1.ALL_DEVICES) {
|
|
458
|
+
if (device.repaintTrigger === "interval" && !device.advanced?.forceRepaint && device.device !== config_1.ALL_DEVICES) {
|
|
444
459
|
const hours = device.intervalHours ?? 1;
|
|
445
460
|
const minute = device.intervalMinute ?? 0;
|
|
446
461
|
const dueSlot = mostRecentScheduledSlot(new Date(), hours, minute);
|