@rhizomatics/signalk-einklabel-plugin 1.2.0 → 1.2.2
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 +12 -0
- package/README.md +9 -5
- package/dist/cli/index.js +2 -2
- package/dist/config.d.ts +9 -0
- package/dist/config.js +8 -0
- package/dist/devices/bleDiscovery.d.ts +26 -0
- package/dist/devices/bleDiscovery.js +79 -0
- package/dist/devices/gicisky/encode.js +14 -4
- package/dist/devices/gicisky/index.js +10 -7
- package/dist/devices/types.d.ts +1 -1
- package/dist/devices/zhsunyco/encode.js +15 -4
- package/dist/devices/zhsunyco/index.js +1 -1
- package/dist/plugin.js +16 -4
- package/dist/render/reframe.d.ts +8 -6
- package/dist/repaintScheduler.js +1 -1
- package/package.json +5 -5
- package/templates/tides/296x128-BWRY.svg +160 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
# 1.2.2
|
|
2
|
+
|
|
3
|
+
- Fully implemented the backoff and retry logic on failed paint, by default after 30s
|
|
4
|
+
- Auto reframe now happens in main plugin as well as CLI
|
|
5
|
+
- Auto reframe now defaults to `crop` for CLI, can be overidden back to `fixed`
|
|
6
|
+
- Handle Gicisky stale cache paint failures by prompting a re-scan when necessary
|
|
7
|
+
- Improve background default for `crop` reframing
|
|
8
|
+
|
|
9
|
+
# 1.2.1
|
|
10
|
+
|
|
11
|
+
- Additional tide template for 296x128
|
|
12
|
+
|
|
1
13
|
# 1.2.0
|
|
2
14
|
|
|
3
15
|
- Added support for Gicisky labels (also known by other brands), tested with a 2.9" BWRY model
|
package/README.md
CHANGED
|
@@ -161,6 +161,10 @@ Known by other names, e.g. 'Picksmart', and with white label brands
|
|
|
161
161
|
|
|
162
162
|
Templates are simply SVG files, to which expressions can be added to use SignalK data, with options to make it easier to read, like rounding or simplifying dates and times. The template can have sample data in the placeholder, so is easy to layout and visualize.
|
|
163
163
|
|
|
164
|
+
### Reframing
|
|
165
|
+
|
|
166
|
+
There's some wiggle room with the `reframe` options to use a template that's a bit too small, or too large, for the label, although best results come from a template that's precisely matching the pixel height and width of the label. Next best is template that has the same aspect ratio, so it can be cleanly scaled. `crop` is the 2nd least worst, though if its only a handful of pixels its often not worth worring about a separate template and `crop` is just fine. `scale` is likely to look worst, since it will force an image in regardless of aspect ratio.
|
|
167
|
+
|
|
164
168
|
### Template Families (multiple panel sizes/colours)
|
|
165
169
|
|
|
166
170
|
A "Template" selection can either be one specific `.svg` file, or a _directory_ holding several versions of the same template for different panel sizes/colour-sets, e.g. `templates/tides/416x240-BWRY.svg` and `templates/tides/250x128-BWRY.svg` both implement the tide clock, just at different sizes.
|
|
@@ -284,11 +288,13 @@ The width, height, vertical offset and colour palette for the device are taken f
|
|
|
284
288
|
|
|
285
289
|
Left unset, both `render` and `paint` default `-w/--width`/`--height` to the template's own declared `width`/`height` (or `viewBox`) - neither command connects to a device just to size the render, since that would mean an extra BLE connect ahead of `paint`'s own, and doing two back-to-back is exactly the kind of churn that trips real BLE hardware.
|
|
286
290
|
|
|
287
|
-
`paint` also takes `--reframe <mode>`, applied once it has connected and identified the device, for when the rendered image doesn't come out the same size as its actual panel:
|
|
291
|
+
`paint` also takes `--reframe <mode>`, applied once it has connected and identified the device, for when the rendered image doesn't come out the same size as its actual panel (see [Reframing](#reframing) above):
|
|
288
292
|
|
|
289
|
-
- `
|
|
293
|
+
- `crop` (default) - keeps pixels 1:1, placed from the top-left; a bigger render is truncated to fit, a smaller one leaves the extra panel space blank
|
|
290
294
|
- `scale` - stretches the rendered image onto the panel's exact dimensions (independently per axis, not preserving aspect ratio)
|
|
291
|
-
- `
|
|
295
|
+
- `fixed` - no adjustment; rejects a size mismatch with an error instead
|
|
296
|
+
|
|
297
|
+
The main SignalK plugin offers the same choice per device (defaulting to `crop` there too) in each device's own config - "If the render doesn't match the panel size".
|
|
292
298
|
|
|
293
299
|
`esl-cli` can also be extended with new subcommands by a `-r/--require`'d package - see [Extending](#extending) below - which is how [`@rhizomatics/signalk-einklabel-genai-plugin`](#genai-rendering) adds its own `prompt`/`generate` commands for testing prompts without a device.
|
|
294
300
|
|
|
@@ -378,8 +384,6 @@ and this version will work even without a running SignalK server, using some pre
|
|
|
378
384
|
npx esl-cli render -t templates/tides/250x128-BWRY.svg -o example.png -e examples
|
|
379
385
|
```
|
|
380
386
|
|
|
381
|
-
|
|
382
|
-
|
|
383
387
|
#### List all Fields and Rendered Values
|
|
384
388
|
|
|
385
389
|
```bash
|
package/dist/cli/index.js
CHANGED
|
@@ -38,7 +38,7 @@ function parseColours(code) {
|
|
|
38
38
|
}
|
|
39
39
|
return colours;
|
|
40
40
|
}
|
|
41
|
-
const REFRAME_MODES = ["
|
|
41
|
+
const REFRAME_MODES = ["crop", "scale", "fixed"];
|
|
42
42
|
function parseReframeMode(value) {
|
|
43
43
|
if (!REFRAME_MODES.includes(value)) {
|
|
44
44
|
throw new Error(`unknown --reframe value "${value}" - expected one of ${REFRAME_MODES.join(", ")}`);
|
|
@@ -225,7 +225,7 @@ exports.program
|
|
|
225
225
|
.option("--height <px>", "render height - defaults to the template's declared height/viewBox - see --reframe for fitting onto a differently-sized panel")
|
|
226
226
|
.option("--voffset <px>", "vertical pixel offset of the panel - overrides the looked-up model for unsupported hardware (requires --colours)", "0")
|
|
227
227
|
.option("--colours <code>", "device colour palette for unsupported hardware: BW, BWR, or BWRY - overrides the looked-up model (uses --width/--height/--voffset)")
|
|
228
|
-
.option("--reframe <mode>", "how to fit the rendered image onto the device's actual panel size when it doesn't match:
|
|
228
|
+
.option("--reframe <mode>", "how to fit the rendered image onto the device's actual panel size when it doesn't match: crop (default - place at top-left, truncating or leaving the rest blank), scale (stretch the template to the panel), fixed (reject the mismatch instead)", "crop")
|
|
229
229
|
.option("--connect-timeout <seconds>", "BLE connect timeout before giving up on an attempt", "30")
|
|
230
230
|
.option("--retries <n>", "number of paint attempts (including the first) before giving up", "3")
|
|
231
231
|
.action(async (opts) => {
|
package/dist/config.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ServerAPI } from "@signalk/server-api";
|
|
2
2
|
import { Colour, DiscoveredDevice } from "./devices/types";
|
|
3
|
+
import { ReframeMode } from "./render/reframe";
|
|
3
4
|
/**
|
|
4
5
|
* Special `device` value meaning "every currently-known discovered device" instead of one specific
|
|
5
6
|
* BLE address - lets a single `DeviceConfig` entry (one template, one trigger) broadcast to every
|
|
@@ -49,6 +50,14 @@ export interface DeviceConfig {
|
|
|
49
50
|
intervalMinute?: number;
|
|
50
51
|
/** One-shot override to repaint even if the data is unchanged; cleared automatically once that repaint completes. */
|
|
51
52
|
forceRepaint?: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* How to fit the rendered image onto the device's actual panel size when it doesn't match (see
|
|
55
|
+
* `ReframeMode`) - e.g. a template family with no variant sized for this particular label. Left
|
|
56
|
+
* unset, `driver.paint()` defaults to `"crop"` itself (see `VendorDeviceConfig.reframe`'s doc
|
|
57
|
+
* comment) - place from the top-left, truncating or leaving the rest blank - since a live label
|
|
58
|
+
* showing *something*, even off-size, beats a repaint that just fails outright.
|
|
59
|
+
*/
|
|
60
|
+
reframe?: ReframeMode;
|
|
52
61
|
}
|
|
53
62
|
export interface PluginConfig {
|
|
54
63
|
/**
|
package/dist/config.js
CHANGED
|
@@ -411,6 +411,13 @@ function configSchema(app, discovered = []) {
|
|
|
411
411
|
description: "Repaint even if the data is unchanged - clears itself automatically once that repaint completes",
|
|
412
412
|
default: false,
|
|
413
413
|
},
|
|
414
|
+
reframe: {
|
|
415
|
+
type: "string",
|
|
416
|
+
title: "If the render doesn't match the panel size",
|
|
417
|
+
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.',
|
|
418
|
+
enum: ["crop", "scale", "fixed"],
|
|
419
|
+
default: "crop",
|
|
420
|
+
},
|
|
414
421
|
},
|
|
415
422
|
},
|
|
416
423
|
},
|
|
@@ -423,6 +430,7 @@ function configUiSchema() {
|
|
|
423
430
|
items: {
|
|
424
431
|
description: { "ui:widget": "textarea" },
|
|
425
432
|
repaintTrigger: { "ui:widget": "radio" },
|
|
433
|
+
reframe: { "ui:widget": "radio" },
|
|
426
434
|
},
|
|
427
435
|
},
|
|
428
436
|
};
|
|
@@ -52,3 +52,29 @@ export declare function withDiscovery<T>(durationMs: number, fn: (adapter: Adapt
|
|
|
52
52
|
export declare function connectWithTimeout(device: Device, timeoutMs: number): Promise<void>;
|
|
53
53
|
/** Uses an already-known device if BlueZ has one cached, otherwise scans until it appears. */
|
|
54
54
|
export declare function getOrDiscoverDevice(adapter: Adapter, address: string, timeoutMs: number): Promise<Device>;
|
|
55
|
+
/**
|
|
56
|
+
* Returns `device`'s manufacturer data under `manufacturerId` if BlueZ already has it cached, else
|
|
57
|
+
* actively (re)scans for it - covers a device BlueZ already knows about (so `getOrDiscoverDevice`'s
|
|
58
|
+
* cheap `adapter.getDevice()` path returns it immediately, never triggering a scan) but whose
|
|
59
|
+
* advertisement cache is empty or stale, e.g. it hasn't actually been seen since `bluetoothd`
|
|
60
|
+
* restarted. BlueZ updates the *same* `Device` object's properties in place as fresh adverts arrive
|
|
61
|
+
* during a discovery window, so this just starts one (unless already running) and polls the existing
|
|
62
|
+
* `device` handle rather than re-resolving it. Bounded by `timeoutMs`; returns undefined rather than
|
|
63
|
+
* throwing if nothing arrives in time, so a caller can fall back to its own error/manual-override path.
|
|
64
|
+
*/
|
|
65
|
+
export declare function waitForManufacturerData(adapter: Adapter, device: Device, manufacturerId: number, timeoutMs: number): Promise<Buffer | undefined>;
|
|
66
|
+
/**
|
|
67
|
+
* Blocks until `bluetooth.defaultAdapter()` actually succeeds, retrying with exponential backoff
|
|
68
|
+
* (2s, doubling, capped at 30s) rather than failing once and giving up - covers the boot-time race
|
|
69
|
+
* where SignalK starts before bluetoothd/D-Bus is up (see the README's "SignalK starts before the
|
|
70
|
+
* Bluetooth daemon" FAQ), and equally a later transient loss (e.g. `bluetoothd` restarting) if a
|
|
71
|
+
* caller invokes this again rather than just once at startup.
|
|
72
|
+
*
|
|
73
|
+
* A non-Linux platform is `createBluetooth`'s own hard incompatibility, not a transient readiness
|
|
74
|
+
* race, so it returns `false` immediately there instead of retrying forever - preserves this
|
|
75
|
+
* plugin's "everything except scan/paint still works" story for template development off a real
|
|
76
|
+
* device (see README's Pre-requisites). `cancelled` is polled between backoff waits so a caller
|
|
77
|
+
* with its own lifecycle (e.g. the plugin's own `stop()`) can abandon an in-progress wait instead of
|
|
78
|
+
* leaving it to retry forever in the background after the thing it was waiting to unblock is gone.
|
|
79
|
+
*/
|
|
80
|
+
export declare function waitForAdapter(logDebug: (message: string) => void, cancelled?: () => boolean): Promise<boolean>;
|
|
@@ -8,6 +8,8 @@ exports.withRetries = withRetries;
|
|
|
8
8
|
exports.withDiscovery = withDiscovery;
|
|
9
9
|
exports.connectWithTimeout = connectWithTimeout;
|
|
10
10
|
exports.getOrDiscoverDevice = getOrDiscoverDevice;
|
|
11
|
+
exports.waitForManufacturerData = waitForManufacturerData;
|
|
12
|
+
exports.waitForAdapter = waitForAdapter;
|
|
11
13
|
const node_ble_1 = require("@naugehyde/node-ble");
|
|
12
14
|
function sleep(ms) {
|
|
13
15
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -157,3 +159,80 @@ async function getOrDiscoverDevice(adapter, address, timeoutMs) {
|
|
|
157
159
|
}
|
|
158
160
|
}
|
|
159
161
|
}
|
|
162
|
+
const MANUFACTURER_DATA_POLL_MS = 500;
|
|
163
|
+
/**
|
|
164
|
+
* Returns `device`'s manufacturer data under `manufacturerId` if BlueZ already has it cached, else
|
|
165
|
+
* actively (re)scans for it - covers a device BlueZ already knows about (so `getOrDiscoverDevice`'s
|
|
166
|
+
* cheap `adapter.getDevice()` path returns it immediately, never triggering a scan) but whose
|
|
167
|
+
* advertisement cache is empty or stale, e.g. it hasn't actually been seen since `bluetoothd`
|
|
168
|
+
* restarted. BlueZ updates the *same* `Device` object's properties in place as fresh adverts arrive
|
|
169
|
+
* during a discovery window, so this just starts one (unless already running) and polls the existing
|
|
170
|
+
* `device` handle rather than re-resolving it. Bounded by `timeoutMs`; returns undefined rather than
|
|
171
|
+
* throwing if nothing arrives in time, so a caller can fall back to its own error/manual-override path.
|
|
172
|
+
*/
|
|
173
|
+
async function waitForManufacturerData(adapter, device, manufacturerId, timeoutMs) {
|
|
174
|
+
const key = manufacturerId.toString();
|
|
175
|
+
const existing = await device.getManufacturerData().catch(() => undefined);
|
|
176
|
+
if (existing?.[key]) {
|
|
177
|
+
return existing[key];
|
|
178
|
+
}
|
|
179
|
+
const wasDiscovering = await adapter.isDiscovering();
|
|
180
|
+
if (!wasDiscovering) {
|
|
181
|
+
await adapter.startDiscovery();
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
const deadline = Date.now() + timeoutMs;
|
|
185
|
+
while (Date.now() < deadline) {
|
|
186
|
+
const data = await device.getManufacturerData().catch(() => undefined);
|
|
187
|
+
if (data?.[key]) {
|
|
188
|
+
return data[key];
|
|
189
|
+
}
|
|
190
|
+
await sleep(MANUFACTURER_DATA_POLL_MS);
|
|
191
|
+
}
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
finally {
|
|
195
|
+
if (!wasDiscovering) {
|
|
196
|
+
await adapter.stopDiscovery();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const ADAPTER_WAIT_BASE_DELAY_MS = 2000;
|
|
201
|
+
const ADAPTER_WAIT_MAX_DELAY_MS = 30000;
|
|
202
|
+
/**
|
|
203
|
+
* Blocks until `bluetooth.defaultAdapter()` actually succeeds, retrying with exponential backoff
|
|
204
|
+
* (2s, doubling, capped at 30s) rather than failing once and giving up - covers the boot-time race
|
|
205
|
+
* where SignalK starts before bluetoothd/D-Bus is up (see the README's "SignalK starts before the
|
|
206
|
+
* Bluetooth daemon" FAQ), and equally a later transient loss (e.g. `bluetoothd` restarting) if a
|
|
207
|
+
* caller invokes this again rather than just once at startup.
|
|
208
|
+
*
|
|
209
|
+
* A non-Linux platform is `createBluetooth`'s own hard incompatibility, not a transient readiness
|
|
210
|
+
* race, so it returns `false` immediately there instead of retrying forever - preserves this
|
|
211
|
+
* plugin's "everything except scan/paint still works" story for template development off a real
|
|
212
|
+
* device (see README's Pre-requisites). `cancelled` is polled between backoff waits so a caller
|
|
213
|
+
* with its own lifecycle (e.g. the plugin's own `stop()`) can abandon an in-progress wait instead of
|
|
214
|
+
* leaving it to retry forever in the background after the thing it was waiting to unblock is gone.
|
|
215
|
+
*/
|
|
216
|
+
async function waitForAdapter(logDebug, cancelled = () => false) {
|
|
217
|
+
if (process.platform !== "linux")
|
|
218
|
+
return false;
|
|
219
|
+
let delayMs = ADAPTER_WAIT_BASE_DELAY_MS;
|
|
220
|
+
while (!cancelled()) {
|
|
221
|
+
let destroy;
|
|
222
|
+
try {
|
|
223
|
+
const created = createBluetooth();
|
|
224
|
+
destroy = created.destroy;
|
|
225
|
+
await created.bluetooth.defaultAdapter();
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
logDebug(`BLE adapter not ready (${err.message}) - retrying in ${delayMs / 1000}s...`);
|
|
230
|
+
await sleep(delayMs);
|
|
231
|
+
delayMs = Math.min(delayMs * 2, ADAPTER_WAIT_MAX_DELAY_MS);
|
|
232
|
+
}
|
|
233
|
+
finally {
|
|
234
|
+
destroy?.();
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
@@ -12,9 +12,19 @@ const FALLBACK = {
|
|
|
12
12
|
yellow: "red",
|
|
13
13
|
red: "black",
|
|
14
14
|
};
|
|
15
|
-
|
|
15
|
+
/** Below this, a pixel counts as "blank" rather than whatever colour its (possibly meaningless, for a fully transparent pixel) RGB happens to hold. */
|
|
16
|
+
const TRANSPARENT_ALPHA_THRESHOLD = 128;
|
|
17
|
+
/**
|
|
18
|
+
* A mostly-transparent pixel - undrawn SVG canvas, e.g. a template resized without its background
|
|
19
|
+
* rect following - classifies as white (blank paper), not the RGB-thresholds' own default of black:
|
|
20
|
+
* resvg-wasm leaves transparent pixels at RGB (0,0,0), and without this check that reads as
|
|
21
|
+
* ink-black rather than the blank label surface it actually represents.
|
|
22
|
+
*/
|
|
23
|
+
function classifyColour(r, g, b, a, supported) {
|
|
16
24
|
let colour = "black";
|
|
17
|
-
if (
|
|
25
|
+
if (a < TRANSPARENT_ALPHA_THRESHOLD)
|
|
26
|
+
colour = "white";
|
|
27
|
+
else if (r > 150 && g > 150 && b > 150)
|
|
18
28
|
colour = "white";
|
|
19
29
|
else if (r > 150 && g > 100 && b < 80)
|
|
20
30
|
colour = "yellow";
|
|
@@ -69,7 +79,7 @@ function packPlane(bitmap, layout, supported, predicate) {
|
|
|
69
79
|
const sx = layout.mirrorX ? width - 1 - x : x;
|
|
70
80
|
const sy = layout.mirrorY ? height - 1 - y : y;
|
|
71
81
|
const offset = (sy * width + sx) * 4;
|
|
72
|
-
const colour = classifyColour(bitmap.data[offset], bitmap.data[offset + 1], bitmap.data[offset + 2], supported);
|
|
82
|
+
const colour = classifyColour(bitmap.data[offset], bitmap.data[offset + 1], bitmap.data[offset + 2], bitmap.data[offset + 3], supported);
|
|
73
83
|
if (predicate(colour)) {
|
|
74
84
|
const byteIndex = y * bytesPerRow + (x >> 3);
|
|
75
85
|
plane[byteIndex] |= 0x80 >> (x % 8);
|
|
@@ -90,7 +100,7 @@ function packFourColour(bitmap, layout, supported) {
|
|
|
90
100
|
const sx = layout.mirrorX ? width - 1 - x : x;
|
|
91
101
|
const sy = layout.mirrorY ? height - 1 - y : y;
|
|
92
102
|
const offset = (sy * width + sx) * 4;
|
|
93
|
-
const colour = classifyColour(bitmap.data[offset], bitmap.data[offset + 1], bitmap.data[offset + 2], supported);
|
|
103
|
+
const colour = classifyColour(bitmap.data[offset], bitmap.data[offset + 1], bitmap.data[offset + 2], bitmap.data[offset + 3], supported);
|
|
94
104
|
const code = FOUR_COLOUR_CODE[colour];
|
|
95
105
|
const byteIndex = y * bytesPerRow + Math.floor(x / pixelsPerByte);
|
|
96
106
|
const shift = 6 - (x % pixelsPerByte) * 2;
|
|
@@ -8,6 +8,8 @@ const encode_1 = require("./encode");
|
|
|
8
8
|
const reframe_1 = require("../../render/reframe");
|
|
9
9
|
const protocol_1 = require("./protocol");
|
|
10
10
|
const DEVICE_DISCOVERY_TIMEOUT_MS = 30000;
|
|
11
|
+
/** How long to actively rescan for a fresh advertisement when the cached one is missing/stale - see `waitForManufacturerData`. */
|
|
12
|
+
const MANUFACTURER_DATA_RESCAN_TIMEOUT_MS = 15000;
|
|
11
13
|
const DEFAULT_PAINT_CONNECT_TIMEOUT_MS = 60000;
|
|
12
14
|
const ACK_TIMEOUT_MS = 15000;
|
|
13
15
|
/** Fallback if a device's `requestBlockSize` ack doesn't decode - matches the block size both reference drivers assume. */
|
|
@@ -53,12 +55,12 @@ class GiciskyDriver {
|
|
|
53
55
|
* Unlike zhsunyco, there's no GATT characteristic that reports the device's PID on demand -
|
|
54
56
|
* the only source for it is the advertisement, cached on the `Device` object by BlueZ from
|
|
55
57
|
* the last time it was seen (the same cache `identifyDevice`/a scan reads, just without
|
|
56
|
-
* connecting first - see `forEachAdvertisedDevice` in `bleDiscovery.ts`).
|
|
58
|
+
* connecting first - see `forEachAdvertisedDevice` in `bleDiscovery.ts`). That cache can be
|
|
59
|
+
* empty or stale (e.g. nothing has actively scanned since `bluetoothd` last restarted) even
|
|
60
|
+
* though `getOrDiscoverDevice` above found the device instantly via BlueZ's own cache of
|
|
61
|
+
* *devices* - so rescan for a fresh advertisement here rather than failing on the first read.
|
|
57
62
|
*/
|
|
58
|
-
const manufacturerData = await device
|
|
59
|
-
.getManufacturerData()
|
|
60
|
-
.then((data) => data[protocol_1.GICISKY_MANUFACTURER_ID.toString()])
|
|
61
|
-
.catch(() => undefined);
|
|
63
|
+
const manufacturerData = await (0, bleDiscovery_1.waitForManufacturerData)(adapter, device, protocol_1.GICISKY_MANUFACTURER_ID, MANUFACTURER_DATA_RESCAN_TIMEOUT_MS);
|
|
62
64
|
const info = manufacturerData ? (0, protocol_1.decodeAdvertisedInfo)(manufacturerData) : undefined;
|
|
63
65
|
const metadata = config.modelOverride
|
|
64
66
|
? { pid: info?.deviceId ?? 0, ...config.modelOverride }
|
|
@@ -67,12 +69,13 @@ class GiciskyDriver {
|
|
|
67
69
|
: undefined;
|
|
68
70
|
if (!metadata) {
|
|
69
71
|
throw new Error(info === undefined
|
|
70
|
-
? "gicisky device
|
|
72
|
+
? "gicisky device isn't advertising - rescanned but got nothing back (out of range, asleep, or already connected " +
|
|
73
|
+
"elsewhere) - pass --width/--height/--voffset/--colours to describe it manually"
|
|
71
74
|
: `gicisky device reports unrecognised deviceId 0x${info.deviceId.toString(16).padStart(4, "0")} - ` +
|
|
72
75
|
"pass --width/--height/--voffset/--colours to describe it manually");
|
|
73
76
|
}
|
|
74
77
|
const layout = (info && layout_1.GICISKY_PID_LAYOUT[info.deviceId]) || (0, layout_1.defaultLayoutFor)(metadata.colours);
|
|
75
|
-
const framed = (0, reframe_1.reframeBitmap)(bitmap, metadata.width, metadata.height, config.reframe ?? "
|
|
78
|
+
const framed = (0, reframe_1.reframeBitmap)(bitmap, metadata.width, metadata.height, config.reframe ?? "crop");
|
|
76
79
|
const payload = (0, encode_1.encodeBitmap)(framed, metadata, layout);
|
|
77
80
|
await (0, bleDiscovery_1.connectWithTimeout)(device, config.connectTimeoutMs ?? DEFAULT_PAINT_CONNECT_TIMEOUT_MS);
|
|
78
81
|
try {
|
package/dist/devices/types.d.ts
CHANGED
|
@@ -58,7 +58,7 @@ export interface VendorDeviceConfig {
|
|
|
58
58
|
modelOverride?: DeviceModelOverride;
|
|
59
59
|
/** How long to wait for the BLE connect step before giving up - if omitted, the driver picks its own default. */
|
|
60
60
|
connectTimeoutMs?: number;
|
|
61
|
-
/** How to fit the bitmap onto the panel when its size doesn't already match - see `ReframeMode`. Defaults to `"
|
|
61
|
+
/** 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. */
|
|
62
62
|
reframe?: ReframeMode;
|
|
63
63
|
}
|
|
64
64
|
export interface VendorDriver {
|
|
@@ -16,10 +16,21 @@ const FALLBACK = {
|
|
|
16
16
|
yellow: "red",
|
|
17
17
|
red: "black",
|
|
18
18
|
};
|
|
19
|
-
/**
|
|
20
|
-
|
|
19
|
+
/** Below this, a pixel counts as "blank" rather than whatever colour its (possibly meaningless, for a fully transparent pixel) RGB happens to hold. */
|
|
20
|
+
const TRANSPARENT_ALPHA_THRESHOLD = 128;
|
|
21
|
+
/**
|
|
22
|
+
* Mirrors the reference driver's `from_pillow` nearest-colour decision tree, then maps the result
|
|
23
|
+
* down onto whatever colours this particular device's panel actually supports (see
|
|
24
|
+
* `DeviceMetadata.colours`). A mostly-transparent pixel - undrawn SVG canvas, e.g. a template resized
|
|
25
|
+
* without its background rect following - classifies as white (blank paper), not the RGB-thresholds'
|
|
26
|
+
* own default of black: resvg-wasm leaves transparent pixels at RGB (0,0,0), and without this check
|
|
27
|
+
* that reads as ink-black rather than the blank label surface it actually represents.
|
|
28
|
+
*/
|
|
29
|
+
function nearestColour(r, g, b, a, supported) {
|
|
21
30
|
let colour = "black";
|
|
22
|
-
if (
|
|
31
|
+
if (a < TRANSPARENT_ALPHA_THRESHOLD)
|
|
32
|
+
colour = "white";
|
|
33
|
+
else if (r > 150 && g > 150 && b > 150)
|
|
23
34
|
colour = "white";
|
|
24
35
|
else if (r > 150 && g > 100 && b < 80)
|
|
25
36
|
colour = "yellow";
|
|
@@ -65,5 +76,5 @@ function encodeBitmap(bitmap, metadata) {
|
|
|
65
76
|
}
|
|
66
77
|
function samplePixel(bitmap, x, y, supported) {
|
|
67
78
|
const offset = (y * bitmap.width + x) * 4;
|
|
68
|
-
return nearestColour(bitmap.data[offset], bitmap.data[offset + 1], bitmap.data[offset + 2], supported);
|
|
79
|
+
return nearestColour(bitmap.data[offset], bitmap.data[offset + 1], bitmap.data[offset + 2], bitmap.data[offset + 3], supported);
|
|
69
80
|
}
|
|
@@ -87,7 +87,7 @@ class ZhsunycoDriver {
|
|
|
87
87
|
const challenge = await authChar.readValue();
|
|
88
88
|
await authChar.writeValueWithoutResponse((0, protocol_1.authResponse)(challenge, aesKey));
|
|
89
89
|
await (0, bleDiscovery_1.sleep)(AUTH_SETTLE_DELAY_MS);
|
|
90
|
-
const framed = (0, reframe_1.reframeBitmap)(bitmap, metadata.width, metadata.height - metadata.voffset, config.reframe ?? "
|
|
90
|
+
const framed = (0, reframe_1.reframeBitmap)(bitmap, metadata.width, metadata.height - metadata.voffset, config.reframe ?? "crop");
|
|
91
91
|
const pixelData = (0, encode_1.encodeBitmap)(framed, metadata);
|
|
92
92
|
for (let offset = 0; offset < pixelData.length; offset += UPLOAD_CHUNK_SIZE) {
|
|
93
93
|
const chunk = pixelData.subarray(offset, offset + UPLOAD_CHUNK_SIZE);
|
package/dist/plugin.js
CHANGED
|
@@ -6,6 +6,7 @@ const registry_1 = require("./devices/registry");
|
|
|
6
6
|
const zhsunyco_1 = require("./devices/zhsunyco");
|
|
7
7
|
const gicisky_1 = require("./devices/gicisky");
|
|
8
8
|
const discoveryCoordinator_1 = require("./devices/discoveryCoordinator");
|
|
9
|
+
const bleDiscovery_1 = require("./devices/bleDiscovery");
|
|
9
10
|
const discoveredDevicesStore_1 = require("./devices/discoveredDevicesStore");
|
|
10
11
|
const repaintScheduler_1 = require("./repaintScheduler");
|
|
11
12
|
/** Mirrors signalk-bluetti-plugin's convention: scan briefly, report finds via plugin status for the user to copy-paste. */
|
|
@@ -36,6 +37,7 @@ function createPlugin(app) {
|
|
|
36
37
|
(0, registry_1.registerDriver)(new zhsunyco_1.ZhsunycoDriver());
|
|
37
38
|
(0, registry_1.registerDriver)(new gicisky_1.GiciskyDriver());
|
|
38
39
|
let scheduler;
|
|
40
|
+
let stopped = false;
|
|
39
41
|
const plugin = {
|
|
40
42
|
id: "signalk-einklabel-plugin",
|
|
41
43
|
name: "eInk ESL (Electronic Shelf Label)",
|
|
@@ -52,12 +54,22 @@ function createPlugin(app) {
|
|
|
52
54
|
};
|
|
53
55
|
app.debug(`starting with ${pluginConfig.devices.length} configured device(s)`);
|
|
54
56
|
(0, config_1.healNestedConfig)(app);
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
stopped = false;
|
|
58
|
+
// Waits (with backoff, indefinitely on Linux) for a BLE adapter before the startup scan or the
|
|
59
|
+
// repaint scheduler touch BLE at all - see `waitForAdapter`'s doc comment on the boot-time race
|
|
60
|
+
// this covers. `stopped` is checked after, not just passed as `cancelled`, since the wait can
|
|
61
|
+
// also resolve `true` on its own right as `stop()` runs.
|
|
62
|
+
void (0, bleDiscovery_1.waitForAdapter)((message) => app.debug(message), () => stopped).then(() => {
|
|
63
|
+
if (stopped)
|
|
64
|
+
return;
|
|
65
|
+
if (pluginConfig.scanOnStart) {
|
|
66
|
+
void runStartupScan(app, pluginConfig.scanDurationSeconds);
|
|
67
|
+
}
|
|
68
|
+
scheduler = (0, repaintScheduler_1.startRepaintScheduler)(app, pluginConfig);
|
|
69
|
+
});
|
|
59
70
|
},
|
|
60
71
|
stop() {
|
|
72
|
+
stopped = true;
|
|
61
73
|
scheduler?.stop();
|
|
62
74
|
scheduler = undefined;
|
|
63
75
|
app.debug("stopped");
|
package/dist/render/reframe.d.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { Bitmap } from "./types";
|
|
2
2
|
/**
|
|
3
|
-
* How a rendered bitmap is fitted onto a device's actual panel size when the two don't match -
|
|
4
|
-
* `
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* `"
|
|
8
|
-
*
|
|
3
|
+
* How a rendered bitmap is fitted onto a device's actual panel size when the two don't match - see
|
|
4
|
+
* `VendorDeviceConfig.reframe` for where the default (`"crop"`) actually lives; this type has none
|
|
5
|
+
* of its own. `"fixed"` (the only behaviour that existed before this type did) makes no change,
|
|
6
|
+
* leaving each vendor driver's own exact-size check in `encodeBitmap` to reject the mismatch;
|
|
7
|
+
* `"scale"` scales the source onto the target dimensions independently per axis (not preserving
|
|
8
|
+
* aspect ratio); `"crop"` keeps source pixels 1:1, placing them from the top-left and either
|
|
9
|
+
* truncating whatever doesn't fit (source bigger than target) or leaving the extra target space
|
|
10
|
+
* blank (source smaller).
|
|
9
11
|
*/
|
|
10
12
|
export type ReframeMode = "fixed" | "scale" | "crop";
|
|
11
13
|
/** Applies `mode` to fit `bitmap` onto a `targetWidth`x`targetHeight` panel - a no-op whenever the size already matches, regardless of mode. */
|
package/dist/repaintScheduler.js
CHANGED
|
@@ -349,7 +349,7 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
|
|
|
349
349
|
app.debug(`${label}: attempting paint ${attempt}/${config.paintRetries}`);
|
|
350
350
|
}
|
|
351
351
|
const startedAt = Date.now();
|
|
352
|
-
await driver.paint(bitmap, { address, aesKey: device.aesKey, connectTimeoutMs });
|
|
352
|
+
await driver.paint(bitmap, { address, aesKey: device.aesKey, connectTimeoutMs, reframe: device.reframe });
|
|
353
353
|
paintDurationMs = Date.now() - startedAt;
|
|
354
354
|
});
|
|
355
355
|
(0, discoveredDevicesStore_1.touchDiscoveredDevice)(app, { address, vendor: target.vendor, pid: target.pid, hwVersion: target.hwVersion, metadata });
|
package/package.json
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rhizomatics/signalk-einklabel-plugin",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.2",
|
|
4
4
|
"description": "Display SignalK data on eInk Electronic Shelf Labels, includes working examples for tide clock and watch schedule.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ble",
|
|
7
7
|
"display",
|
|
8
8
|
"eink",
|
|
9
9
|
"esl",
|
|
10
|
+
"gicisky",
|
|
10
11
|
"instrument",
|
|
11
12
|
"signalk",
|
|
12
|
-
"zhsunyco",
|
|
13
|
-
"gicisky",
|
|
14
13
|
"signalk-category-hardware",
|
|
15
14
|
"signalk-category-instruments",
|
|
16
15
|
"signalk-node-server-plugin",
|
|
17
16
|
"tides",
|
|
18
|
-
"watch-schedule"
|
|
17
|
+
"watch-schedule",
|
|
18
|
+
"zhsunyco"
|
|
19
19
|
],
|
|
20
20
|
"homepage": "https://signalk-einklabel.rhizomatics.org.uk/",
|
|
21
21
|
"bugs": {
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"devDependencies": {
|
|
67
67
|
"@signalk/server-api": "^2.31.0",
|
|
68
68
|
"@types/luxon": "^3.7.1",
|
|
69
|
-
"@types/node": "^
|
|
69
|
+
"@types/node": "^26.4.0",
|
|
70
70
|
"@types/pngjs": "^6.0.5",
|
|
71
71
|
"oxfmt": "^0.66.0",
|
|
72
72
|
"oxlint": "^1.81.0",
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
2
|
+
<svg
|
|
3
|
+
width="296"
|
|
4
|
+
height="128"
|
|
5
|
+
viewBox="0 0 296 128"
|
|
6
|
+
version="1.1"
|
|
7
|
+
id="svg1"
|
|
8
|
+
sodipodi:docname="296x128-BWRY.svg"
|
|
9
|
+
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
|
|
10
|
+
xml:space="preserve"
|
|
11
|
+
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
|
12
|
+
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
|
13
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
14
|
+
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
|
15
|
+
id="defs1" /><sodipodi:namedview
|
|
16
|
+
id="namedview1"
|
|
17
|
+
pagecolor="#ffffff"
|
|
18
|
+
bordercolor="#000000"
|
|
19
|
+
borderopacity="0.25"
|
|
20
|
+
inkscape:showpageshadow="2"
|
|
21
|
+
inkscape:pageopacity="0.0"
|
|
22
|
+
inkscape:pagecheckerboard="0"
|
|
23
|
+
inkscape:deskcolor="#d1d1d1"
|
|
24
|
+
inkscape:zoom="1.3906433"
|
|
25
|
+
inkscape:cx="199.54794"
|
|
26
|
+
inkscape:cy="131.23423"
|
|
27
|
+
inkscape:window-width="1488"
|
|
28
|
+
inkscape:window-height="674"
|
|
29
|
+
inkscape:window-x="24"
|
|
30
|
+
inkscape:window-y="33"
|
|
31
|
+
inkscape:window-maximized="0"
|
|
32
|
+
inkscape:current-layer="svg1"
|
|
33
|
+
showgrid="true"
|
|
34
|
+
showguides="true"><inkscape:grid
|
|
35
|
+
id="grid1"
|
|
36
|
+
units="px"
|
|
37
|
+
originx="0"
|
|
38
|
+
originy="0"
|
|
39
|
+
spacingx="1"
|
|
40
|
+
spacingy="1"
|
|
41
|
+
empcolor="#0099e5"
|
|
42
|
+
empopacity="0.30196078"
|
|
43
|
+
color="#0099e5"
|
|
44
|
+
opacity="0.14901961"
|
|
45
|
+
empspacing="5"
|
|
46
|
+
dotted="false"
|
|
47
|
+
gridanglex="30"
|
|
48
|
+
gridanglez="30"
|
|
49
|
+
visible="true" /></sodipodi:namedview><rect
|
|
50
|
+
x="1.0169492"
|
|
51
|
+
y="2.0798673e-08"
|
|
52
|
+
width="249.17073"
|
|
53
|
+
height="129.97897"
|
|
54
|
+
fill="#ffffff"
|
|
55
|
+
id="rect1"
|
|
56
|
+
style="stroke-width:0.569552" /><text
|
|
57
|
+
id="station.name"
|
|
58
|
+
x="7.2081757"
|
|
59
|
+
y="19.148237"
|
|
60
|
+
font-size="18"
|
|
61
|
+
font-family="serif"
|
|
62
|
+
fill="yellow"
|
|
63
|
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:18.6667px;font-family:serif;-inkscape-font-specification:'serif, Normal ';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#ffff00;stroke:#000000;stroke-width:1.2278;stroke-opacity:1"
|
|
64
|
+
transform="scale(0.89274881,1.1201359)">Tidal Station Name<desc
|
|
65
|
+
id="desc1">source=resources,resource=tides,provider=tides,path=station.name</desc></text><text
|
|
66
|
+
id="last_repaint"
|
|
67
|
+
x="221.55899"
|
|
68
|
+
y="104.94675"
|
|
69
|
+
font-size="18"
|
|
70
|
+
font-family="serif"
|
|
71
|
+
fill="black"
|
|
72
|
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:10.6667px;font-family:serif;-inkscape-font-specification:'serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#ffff00;stroke-width:1.19309"
|
|
73
|
+
transform="scale(0.85412559,1.170788)"><desc
|
|
74
|
+
id="desc12">source=einklabel,path=repainted,format=local_datetime_short</desc><tspan
|
|
75
|
+
sodipodi:role="line"
|
|
76
|
+
id="tspan3"
|
|
77
|
+
x="221.55899"
|
|
78
|
+
y="104.94675">27 Jun 26 18:05</tspan></text><text
|
|
79
|
+
id="extremes.0"
|
|
80
|
+
x="7.9661012"
|
|
81
|
+
y="42.022709"
|
|
82
|
+
font-size="14"
|
|
83
|
+
font-family="monospace"
|
|
84
|
+
fill="black"
|
|
85
|
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:16px;font-family:serif;-inkscape-font-specification:'serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#ff0000"><desc
|
|
86
|
+
id="desc13">source=resources,resource=tides,provider=tides,path=extremes[0].label</desc>High or Low</text><text
|
|
87
|
+
id="extremes.1"
|
|
88
|
+
x="8.3191881"
|
|
89
|
+
y="83.34079"
|
|
90
|
+
font-size="14"
|
|
91
|
+
font-family="monospace"
|
|
92
|
+
fill="black"
|
|
93
|
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:16px;font-family:serif;-inkscape-font-specification:'serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#ff0000"><desc
|
|
94
|
+
id="desc14">source=resources,resource=tides,provider=tides,path=extremes[1].label</desc>High or Low</text><text
|
|
95
|
+
id="extremes.1.level"
|
|
96
|
+
x="220.4532"
|
|
97
|
+
y="70.261024"
|
|
98
|
+
font-size="14"
|
|
99
|
+
font-family="monospace"
|
|
100
|
+
fill="red"
|
|
101
|
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:26.6667px;font-family:serif;-inkscape-font-specification:'serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#000000;stroke-width:1.701"
|
|
102
|
+
transform="scale(0.75163533,1.3304324)"
|
|
103
|
+
inkscape:transform-center-y="-140.38618"
|
|
104
|
+
inkscape:transform-center-x="-0.00038058817"><tspan
|
|
105
|
+
sodipodi:role="line"
|
|
106
|
+
id="tspan2-1"
|
|
107
|
+
x="220.4532"
|
|
108
|
+
y="70.261024"
|
|
109
|
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:26.6667px;font-family:serif;-inkscape-font-specification:'serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;stroke-width:1.701">1.6m</tspan><desc
|
|
110
|
+
id="desc5">source=resources,resource=tides,provider=tides,path=extremes[1].level,category=depth</desc></text><text
|
|
111
|
+
id="extremes.1.time"
|
|
112
|
+
x="137.72458"
|
|
113
|
+
y="70.627983"
|
|
114
|
+
font-size="14"
|
|
115
|
+
font-family="monospace"
|
|
116
|
+
fill="red"
|
|
117
|
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:26.6667px;font-family:serif;-inkscape-font-specification:'serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#000000;stroke-width:1.48364"
|
|
118
|
+
transform="scale(0.7345432,1.3613903)">09:36<desc
|
|
119
|
+
id="desc8">source=resources,resource=tides,provider=tides,path=extremes[1].time,format=local_time</desc></text><text
|
|
120
|
+
id="extremes.0.time"
|
|
121
|
+
x="138.1739"
|
|
122
|
+
y="42.23238"
|
|
123
|
+
font-size="14"
|
|
124
|
+
font-family="monospace"
|
|
125
|
+
fill="red"
|
|
126
|
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:26.6667px;font-family:serif;-inkscape-font-specification:'serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#000000;stroke-width:1.55397"
|
|
127
|
+
transform="scale(0.74654856,1.3394976)">03:27<desc
|
|
128
|
+
id="desc3">source=resources,resource=tides,provider=tides,path=extremes[0].time,format=local_time</desc></text><text
|
|
129
|
+
id="extremes.0.date"
|
|
130
|
+
x="11.198709"
|
|
131
|
+
y="51.513836"
|
|
132
|
+
font-size="14"
|
|
133
|
+
font-family="monospace"
|
|
134
|
+
fill="red"
|
|
135
|
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:16px;font-family:serif;-inkscape-font-specification:'serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#666666;stroke-width:1.32646"
|
|
136
|
+
transform="scale(0.82428216,1.2131768)"><desc
|
|
137
|
+
id="desc2">source=resources,resource=tides,provider=tides,path=extremes[0].time,format=day_mon</desc>27 Jun</text><text
|
|
138
|
+
id="extremes.1.date"
|
|
139
|
+
x="9.9649849"
|
|
140
|
+
y="85.409508"
|
|
141
|
+
font-size="14"
|
|
142
|
+
font-family="monospace"
|
|
143
|
+
fill="red"
|
|
144
|
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:16px;font-family:serif;-inkscape-font-specification:'serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#666666;stroke-width:1.32646"
|
|
145
|
+
transform="scale(0.82428216,1.2131768)"><desc
|
|
146
|
+
id="desc7">source=resources,resource=tides,provider=tides,path=extremes[1].time,format=day_mon</desc>27 Jun</text><text
|
|
147
|
+
id="extremes.0.level"
|
|
148
|
+
x="217.65784"
|
|
149
|
+
y="43.220345"
|
|
150
|
+
font-size="14"
|
|
151
|
+
font-family="monospace"
|
|
152
|
+
fill="red"
|
|
153
|
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:26.6667px;font-family:serif;-inkscape-font-specification:'serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#000000;stroke-width:1.71992"
|
|
154
|
+
transform="scale(0.7703174,1.2981662)"><tspan
|
|
155
|
+
sodipodi:role="line"
|
|
156
|
+
id="tspan2"
|
|
157
|
+
x="217.65784"
|
|
158
|
+
y="43.220345"
|
|
159
|
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:26.6667px;font-family:serif;-inkscape-font-specification:'serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;stroke-width:1.71992">3.4m</tspan><desc
|
|
160
|
+
id="desc4">source=resources,resource=tides,provider=tides,path=extremes[0].level,category=depth</desc></text></svg>
|