signalk-container-helper 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -154,12 +154,14 @@ rewrites `app.getDataDirPath()` per plugin, and signalk-container resolves that
154
154
  field from _its own_ app object — so you get
155
155
  `<configRoot>/plugin-config-data/signalk-container/`, not yours.
156
156
 
157
- | You want | Use |
158
- | ------------------------------------------- | ------------------------------------------------ |
159
- | A private writable area in the SK data tree | `signalkDataMount` (signalk-container's) |
160
- | The whole SK config root | `signalkConfigRootMount` (incl. `security.json`) |
161
- | **Your own plugin's data dir** | **`resolveMount()`** + a `volumes` entry |
162
- | **Is there a GPU / device on the host?** | **`probeHostDevice()`** |
157
+ | You want | Use |
158
+ | --------------------------------------------- | ------------------------------------------------ |
159
+ | A private writable area in the SK data tree | `signalkDataMount` (signalk-container's) |
160
+ | The whole SK config root | `signalkConfigRootMount` (incl. `security.json`) |
161
+ | **Your own plugin's data dir** | **`resolveMount()`** + a `volumes` entry |
162
+ | **Is there a GPU / device on the host?** | **`probeHostDevice()`** |
163
+ | **Which USB serial devices are plugged in?** | **`listHostSerialDevices()`** |
164
+ | **Act when a USB device is pulled/replugged** | **`watchHostDevice()`** |
163
165
 
164
166
  Either mount field resolves to a host path or a named volume, depending on the
165
167
  deployment. A named volume always arrives whole: signalk-container sends no
@@ -192,6 +194,58 @@ report it as absent. Throws `ContainerHelperError('unsupported-manager')` on
192
194
  signalk-container older than 1.30.0, so catch it if you would rather degrade
193
195
  than fail. Works on both Docker and rootless podman.
194
196
 
197
+ ### USB devices that come and go
198
+
199
+ A container gets its device nodes when it is created. Pull a USB stick while
200
+ it runs and the program inside usually exits, and `unless-stopped` restarts it
201
+ against a node that is gone; plug it back in and it may come up as another
202
+ node (`ttyUSB1` for `ttyUSB0`) that the old container never sees.
203
+
204
+ `listHostSerialDevices(manager)` lists the host's `/dev/serial/by-id` links as
205
+ full, sorted paths. Their names carry vendor, product and serial number, so a
206
+ plugin can recognise its hardware without asking the user for a path, and the
207
+ link is the path to pass to the container: it survives a replug that changes
208
+ the `ttyUSB` number. `[]` means nothing is plugged in, `null` that the host
209
+ could not be asked. A containerized Signal K needs signalk-container 1.37.2 or
210
+ later to list the links.
211
+
212
+ `watchHostDevice(options)` follows one device and tells the plugin when to act;
213
+ what acting means stays with the plugin:
214
+
215
+ ```ts
216
+ import { watchHostDevice } from "signalk-container-helper";
217
+
218
+ const watch = watchHostDevice({
219
+ manager,
220
+ path: "/dev/serial/by-id/usb-ITead_Sonoff_Zigbee_3.0_USB_Dongle_Plus_…",
221
+ isStarted: () => started,
222
+ isHealthy: () => bridge.online, // undefined when you cannot tell
223
+ onEvent: async (event) => {
224
+ if (event === "unplugged") await manager.stop(name);
225
+ else await container.applyUpdate(tag); // pluggedIn or stuck: recreate
226
+ },
227
+ });
228
+ // in plugin.stop():
229
+ await watch.stop();
230
+ ```
231
+
232
+ | Event | When |
233
+ | ----------- | ----------------------------------------------------------------------------------------- |
234
+ | `unplugged` | The device went away under a started container. Once per unplug. |
235
+ | `pluggedIn` | The device is back after an unplug, or appeared while nothing was started. |
236
+ | `stuck` | The device is there and the workload stayed unhealthy for `stuckMs`. Repeats until fixed. |
237
+
238
+ The host is probed only while the container is not started or not healthy,
239
+ so a working setup costs nothing (a probe is a short-lived container under a
240
+ containerized Signal K). A probe that cannot tell changes nothing, and with
241
+ health unknown no `stuck` is raised. Ticks run every `intervalMs` (default
242
+ 30 s, the first one interval after the call), handlers never overlap, a
243
+ throwing handler is logged through `debug`, and `stop()` waits for a tick in
244
+ progress (from inside `onEvent` it returns at once). Pass
245
+ `initiallyAbsent: true` when the device was missing at start, so its arrival
246
+ is reported as `pluggedIn`. `decideDeviceEvent` is the pure decision behind
247
+ it, exported for plugins that run their own timer.
248
+
195
249
  `resolveMount` translates any absolute host path into a mount the container
196
250
  runtime can actually bind, on bare-metal and containerized Signal K alike:
197
251
 
@@ -783,6 +837,9 @@ Adopt these even where you don't use the components:
783
837
  | `managedModeSchema(opts)` | The two config fields as plain JSON Schema + matching defaults (`signalk-container-helper/schema`) |
784
838
  | `waitForHttpReady(url, opts)` | Poll until 2xx or deadline (throws) |
785
839
  | `retryForever(fn, opts)` | Retry until success — 15s doubling to a 120s ceiling, no attempt cap. `ManagedContainer` takes it as `readinessRetry`; exported standalone for work this library does not manage |
840
+ | `probeHostDevice(manager, path)` | Whether the host has a device, with its node names and groups; `null` when unknown |
841
+ | `listHostSerialDevices(manager)` | The host's `/dev/serial/by-id` links as full paths; `[]` when none, `null` when unknown |
842
+ | `watchHostDevice(opts)` | Poll a host device while its container is unhealthy or not started; reports `unplugged`, `pluggedIn`, `stuck` (see [USB devices that come and go](#usb-devices-that-come-and-go)) |
786
843
  | `anySignal(signals)` | Compose several `AbortSignal`s into one that aborts when any does (`undefined` when none are given) |
787
844
  | `probeHttpHealth(url, opts)` | Retrying liveness probe with slow-response detection (never throws) |
788
845
  | `fetchWithTimeout(url, opts)` | `fetch` with an `AbortController` timeout |
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Following a host device that can be unplugged while its container runs.
3
+ *
4
+ * A container gets its device nodes when it is created. When the device is
5
+ * pulled, the program inside usually exits, and `unless-stopped` restarts it
6
+ * against a node that is gone, over and over. When the device comes back it
7
+ * may get another node (ttyUSB1 for ttyUSB0), which the old container never
8
+ * sees. Nothing in the runtime notices either event, so the plugin has to.
9
+ *
10
+ * `watchHostDevice` polls the host through {@link probeHostDevice} and tells
11
+ * the plugin when to stop its container, when to (re)create it, and when a
12
+ * container that has its device has stayed unhealthy too long. What "stop" and
13
+ * "recreate" mean stays with the plugin.
14
+ */
15
+ import type { ContainerManagerApi } from "./types.js";
16
+ /** Default poll interval. signalk-container caches a probe for 60 s. */
17
+ export declare const DEVICE_WATCH_INTERVAL_MS = 30000;
18
+ /** Default time a container may stay unhealthy with its device present. */
19
+ export declare const DEVICE_STUCK_MS = 180000;
20
+ export interface DeviceWatchState {
21
+ /** The device was missing on the last tick that could tell. */
22
+ absent: boolean;
23
+ /** When the container was first seen unhealthy with its device present. */
24
+ unhealthySince?: number;
25
+ }
26
+ export interface DeviceObservation {
27
+ /** The host probe: undefined when it could not tell. */
28
+ present: boolean | undefined;
29
+ /** The plugin has created the container. */
30
+ started: boolean;
31
+ /** The workload's health; undefined when the plugin cannot tell. */
32
+ healthy: boolean | undefined;
33
+ now: number;
34
+ stuckMs: number;
35
+ }
36
+ /**
37
+ * - `unplugged`: the device went away under a started container.
38
+ * - `pluggedIn`: the device is back (or appeared for the first time).
39
+ * - `stuck`: the device is there, the container is not healthy.
40
+ */
41
+ export type DeviceWatchEvent = "unplugged" | "pluggedIn" | "stuck";
42
+ /** Whether a tick needs to ask the host at all. */
43
+ export declare function deviceProbeNeeded(started: boolean, healthy: boolean | undefined): boolean;
44
+ /** The decision of one tick, and the state for the next one. */
45
+ export declare function decideDeviceEvent(state: DeviceWatchState, obs: DeviceObservation): {
46
+ event: DeviceWatchEvent | undefined;
47
+ next: DeviceWatchState;
48
+ };
49
+ export interface WatchHostDeviceOptions {
50
+ manager: ContainerManagerApi;
51
+ /** Host path of the device, ideally a `/dev/serial/by-id/…` link. */
52
+ path: string;
53
+ /** Whether the plugin has created the container for this device. */
54
+ isStarted: () => boolean;
55
+ /**
56
+ * Whether the workload works. Return undefined when the plugin cannot tell
57
+ * (its own connection to the workload is down): no `stuck` is raised then.
58
+ */
59
+ isHealthy: () => boolean | undefined;
60
+ /**
61
+ * Called for each event, never while the previous call runs and never after
62
+ * `stop()`. `unplugged` and `pluggedIn` fire once per transition; `stuck`
63
+ * repeats every `stuckMs` while the workload stays unhealthy. A rejection is
64
+ * logged through `debug` and the watch moves on: a recreate that failed shows
65
+ * up again as `stuck`.
66
+ */
67
+ onEvent: (event: DeviceWatchEvent) => Promise<void> | void;
68
+ /** The device was missing when the plugin last looked. Default false. */
69
+ initiallyAbsent?: boolean;
70
+ intervalMs?: number;
71
+ stuckMs?: number;
72
+ debug?: (msg: string) => void;
73
+ }
74
+ export interface HostDeviceWatch {
75
+ /**
76
+ * Stops polling and waits for a tick in progress. Called from inside
77
+ * `onEvent` it returns at once instead of waiting for itself.
78
+ */
79
+ stop(): Promise<void>;
80
+ }
81
+ /**
82
+ * Poll the host for `path` and report its transitions.
83
+ *
84
+ * ```ts
85
+ * const watch = watchHostDevice({
86
+ * manager,
87
+ * path: "/dev/serial/by-id/usb-…",
88
+ * isStarted: () => started,
89
+ * isHealthy: () => bridge.online,
90
+ * onEvent: async (event) => {
91
+ * if (event === "unplugged") await manager.stop(name);
92
+ * else await container.applyUpdate(tag); // recreate with the new node
93
+ * },
94
+ * });
95
+ * // plugin.stop():
96
+ * await watch.stop();
97
+ * ```
98
+ *
99
+ * A healthy started container is never probed, so the cost of a probe (a
100
+ * short-lived container under a containerized Signal K) is paid only while
101
+ * something is wrong. A probe that cannot tell (`null`, or an older
102
+ * signalk-container) changes nothing. The first tick runs one interval after
103
+ * the call. `stuck` repeats every `stuckMs` (plus up to one interval) for as
104
+ * long as the workload stays unhealthy with its device present.
105
+ */
106
+ export declare function watchHostDevice(options: WatchHostDeviceOptions): HostDeviceWatch;
107
+ //# sourceMappingURL=device-watch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"device-watch.d.ts","sourceRoot":"","sources":["../src/device-watch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAIH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AAEtD,wEAAwE;AACxE,eAAO,MAAM,wBAAwB,QAAS,CAAC;AAC/C,2EAA2E;AAC3E,eAAO,MAAM,eAAe,SAAU,CAAC;AAEvC,MAAM,WAAW,gBAAgB;IAC/B,+DAA+D;IAC/D,MAAM,EAAE,OAAO,CAAC;IAChB,2EAA2E;IAC3E,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,iBAAiB;IAChC,wDAAwD;IACxD,OAAO,EAAE,OAAO,GAAG,SAAS,CAAC;IAC7B,4CAA4C;IAC5C,OAAO,EAAE,OAAO,CAAC;IACjB,oEAAoE;IACpE,OAAO,EAAE,OAAO,GAAG,SAAS,CAAC;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;GAIG;AACH,MAAM,MAAM,gBAAgB,GAAG,WAAW,GAAG,WAAW,GAAG,OAAO,CAAC;AAEnE,mDAAmD;AACnD,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,OAAO,GAAG,SAAS,GAC3B,OAAO,CAET;AAED,gEAAgE;AAChE,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,gBAAgB,EACvB,GAAG,EAAE,iBAAiB,GACrB;IAAE,KAAK,EAAE,gBAAgB,GAAG,SAAS,CAAC;IAAC,IAAI,EAAE,gBAAgB,CAAA;CAAE,CAuBjE;AAED,MAAM,WAAW,sBAAsB;IACrC,OAAO,EAAE,mBAAmB,CAAC;IAC7B,qEAAqE;IACrE,IAAI,EAAE,MAAM,CAAC;IACb,oEAAoE;IACpE,SAAS,EAAE,MAAM,OAAO,CAAC;IACzB;;;OAGG;IACH,SAAS,EAAE,MAAM,OAAO,GAAG,SAAS,CAAC;IACrC;;;;;;OAMG;IACH,OAAO,EAAE,CAAC,KAAK,EAAE,gBAAgB,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IAC3D,yEAAyE;IACzE,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CAC/B;AAED,MAAM,WAAW,eAAe;IAC9B;;;OAGG;IACH,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACvB;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,eAAe,CAC7B,OAAO,EAAE,sBAAsB,GAC9B,eAAe,CA0DjB"}
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Following a host device that can be unplugged while its container runs.
3
+ *
4
+ * A container gets its device nodes when it is created. When the device is
5
+ * pulled, the program inside usually exits, and `unless-stopped` restarts it
6
+ * against a node that is gone, over and over. When the device comes back it
7
+ * may get another node (ttyUSB1 for ttyUSB0), which the old container never
8
+ * sees. Nothing in the runtime notices either event, so the plugin has to.
9
+ *
10
+ * `watchHostDevice` polls the host through {@link probeHostDevice} and tells
11
+ * the plugin when to stop its container, when to (re)create it, and when a
12
+ * container that has its device has stayed unhealthy too long. What "stop" and
13
+ * "recreate" mean stays with the plugin.
14
+ */
15
+ import { probeHostDevice } from "./host-device.js";
16
+ import { errMsg } from "./util.js";
17
+ /** Default poll interval. signalk-container caches a probe for 60 s. */
18
+ export const DEVICE_WATCH_INTERVAL_MS = 30_000;
19
+ /** Default time a container may stay unhealthy with its device present. */
20
+ export const DEVICE_STUCK_MS = 180_000;
21
+ /** Whether a tick needs to ask the host at all. */
22
+ export function deviceProbeNeeded(started, healthy) {
23
+ return !started || healthy !== true;
24
+ }
25
+ /** The decision of one tick, and the state for the next one. */
26
+ export function decideDeviceEvent(state, obs) {
27
+ if (obs.started && obs.healthy === true) {
28
+ return { event: undefined, next: { absent: false } };
29
+ }
30
+ if (obs.present === undefined)
31
+ return { event: undefined, next: state };
32
+ if (!obs.present) {
33
+ return {
34
+ event: obs.started && !state.absent ? "unplugged" : undefined,
35
+ next: { absent: true },
36
+ };
37
+ }
38
+ if (state.absent || !obs.started) {
39
+ return {
40
+ event: state.absent ? "pluggedIn" : undefined,
41
+ next: { absent: false },
42
+ };
43
+ }
44
+ if (obs.healthy === undefined)
45
+ return { event: undefined, next: state };
46
+ const unhealthySince = state.unhealthySince ?? obs.now;
47
+ if (obs.now - unhealthySince >= obs.stuckMs) {
48
+ return { event: "stuck", next: { absent: false } };
49
+ }
50
+ return { event: undefined, next: { absent: false, unhealthySince } };
51
+ }
52
+ /**
53
+ * Poll the host for `path` and report its transitions.
54
+ *
55
+ * ```ts
56
+ * const watch = watchHostDevice({
57
+ * manager,
58
+ * path: "/dev/serial/by-id/usb-…",
59
+ * isStarted: () => started,
60
+ * isHealthy: () => bridge.online,
61
+ * onEvent: async (event) => {
62
+ * if (event === "unplugged") await manager.stop(name);
63
+ * else await container.applyUpdate(tag); // recreate with the new node
64
+ * },
65
+ * });
66
+ * // plugin.stop():
67
+ * await watch.stop();
68
+ * ```
69
+ *
70
+ * A healthy started container is never probed, so the cost of a probe (a
71
+ * short-lived container under a containerized Signal K) is paid only while
72
+ * something is wrong. A probe that cannot tell (`null`, or an older
73
+ * signalk-container) changes nothing. The first tick runs one interval after
74
+ * the call. `stuck` repeats every `stuckMs` (plus up to one interval) for as
75
+ * long as the workload stays unhealthy with its device present.
76
+ */
77
+ export function watchHostDevice(options) {
78
+ const debug = options.debug ?? (() => undefined);
79
+ const stuckMs = options.stuckMs ?? DEVICE_STUCK_MS;
80
+ let state = { absent: options.initiallyAbsent ?? false };
81
+ let stopped = false;
82
+ let tick;
83
+ let inHandler = false;
84
+ const once = async () => {
85
+ const started = options.isStarted();
86
+ const healthy = options.isHealthy();
87
+ let present;
88
+ if (deviceProbeNeeded(started, healthy)) {
89
+ try {
90
+ present = (await probeHostDevice(options.manager, options.path))
91
+ ?.exists;
92
+ }
93
+ catch (err) {
94
+ debug(`watchHostDevice: probing ${options.path}: ${errMsg(err)}`);
95
+ }
96
+ if (stopped)
97
+ return;
98
+ }
99
+ const { event, next } = decideDeviceEvent(state, {
100
+ present,
101
+ started,
102
+ healthy,
103
+ now: Date.now(),
104
+ stuckMs,
105
+ });
106
+ state = next;
107
+ if (event === undefined)
108
+ return;
109
+ debug(`watchHostDevice: ${options.path} ${event}`);
110
+ inHandler = true;
111
+ try {
112
+ await options.onEvent(event);
113
+ }
114
+ finally {
115
+ inHandler = false;
116
+ }
117
+ };
118
+ const timer = setInterval(() => {
119
+ if (stopped || tick !== undefined)
120
+ return;
121
+ tick = once()
122
+ .catch((err) => {
123
+ debug(`watchHostDevice: ${options.path}: ${errMsg(err)}`);
124
+ })
125
+ .finally(() => {
126
+ tick = undefined;
127
+ });
128
+ }, options.intervalMs ?? DEVICE_WATCH_INTERVAL_MS);
129
+ return {
130
+ async stop() {
131
+ stopped = true;
132
+ clearInterval(timer);
133
+ // Awaiting the tick from inside its own handler would never resolve.
134
+ if (!inHandler)
135
+ await tick;
136
+ },
137
+ };
138
+ }
139
+ //# sourceMappingURL=device-watch.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"device-watch.js","sourceRoot":"","sources":["../src/device-watch.ts"],"names":[],"mappings":"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"}
package/dist/index.d.ts CHANGED
@@ -9,6 +9,9 @@ export { buildVersionSource } from "./version-source.js";
9
9
  export type { VersionSourceSpec } from "./version-source.js";
10
10
  export { resolveMount } from "./host-path.js";
11
11
  export { probeHostDevice } from "./host-device.js";
12
+ export { listHostSerialDevices, SERIAL_BY_ID_DIR } from "./serial-devices.js";
13
+ export { watchHostDevice, decideDeviceEvent, deviceProbeNeeded, DEVICE_WATCH_INTERVAL_MS, DEVICE_STUCK_MS, } from "./device-watch.js";
14
+ export type { DeviceWatchEvent, DeviceWatchState, DeviceObservation, WatchHostDeviceOptions, HostDeviceWatch, } from "./device-watch.js";
12
15
  export type { HostMount, ResolveMountOptions } from "./host-path.js";
13
16
  export { retryForever, anySignal } from "./retry.js";
14
17
  export type { RetryForeverOptions } from "./retry.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,OAAO,EAAE,mBAAmB,EAAE,uBAAuB,EAAE,MAAM,cAAc,CAAC;AAC5E,YAAY,EAAE,qBAAqB,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AAC7E,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,YAAY,EACV,uBAAuB,EACvB,oBAAoB,EACpB,gBAAgB,EAChB,WAAW,EACX,UAAU,EACV,YAAY,EACZ,mBAAmB,EACnB,gBAAgB,EAChB,WAAW,GACZ,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,YAAY,EAAE,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AACtE,OAAO,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AACzD,YAAY,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAC7D,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAC9C,OAAO,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACnD,YAAY,EAAE,SAAS,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AACrE,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AACrD,YAAY,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AACtD,OAAO,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAChF,OAAO,EACL,eAAe,EACf,oBAAoB,EACpB,aAAa,EACb,gBAAgB,GACjB,MAAM,eAAe,CAAC;AACvB,YAAY,EACV,YAAY,EACZ,gBAAgB,EAChB,sBAAsB,EACtB,oBAAoB,GACrB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,oBAAoB,EAAE,MAAM,UAAU,CAAC;AAChD,OAAO,EACL,wBAAwB,EACxB,kBAAkB,EAClB,iBAAiB,GAClB,MAAM,sBAAsB,CAAC;AAC9B,YAAY,EAAE,mBAAmB,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AACnE,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAChD,YAAY,EACV,eAAe,EACf,kBAAkB,EAClB,iBAAiB,EACjB,wBAAwB,GACzB,MAAM,aAAa,CAAC;AACrB,YAAY,EACV,SAAS,EACT,uBAAuB,EACvB,uBAAuB,EACvB,sBAAsB,EACtB,iBAAiB,GAClB,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,WAAW,EACX,cAAc,EACd,MAAM,EACN,eAAe,EACf,iBAAiB,EACjB,oBAAoB,GACrB,MAAM,WAAW,CAAC;AACnB,YAAY,EAAE,wBAAwB,EAAE,MAAM,WAAW,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,OAAO,EAAE,mBAAmB,EAAE,uBAAuB,EAAE,MAAM,cAAc,CAAC;AAC5E,YAAY,EAAE,qBAAqB,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AAC7E,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,YAAY,EACV,uBAAuB,EACvB,oBAAoB,EACpB,gBAAgB,EAChB,WAAW,EACX,UAAU,EACV,YAAY,EACZ,mBAAmB,EACnB,gBAAgB,EAChB,WAAW,GACZ,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,YAAY,EAAE,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AACtE,OAAO,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AACzD,YAAY,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAC7D,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAC9C,OAAO,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACnD,OAAO,EAAE,qBAAqB,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAC9E,OAAO,EACL,eAAe,EACf,iBAAiB,EACjB,iBAAiB,EACjB,wBAAwB,EACxB,eAAe,GAChB,MAAM,mBAAmB,CAAC;AAC3B,YAAY,EACV,gBAAgB,EAChB,gBAAgB,EAChB,iBAAiB,EACjB,sBAAsB,EACtB,eAAe,GAChB,MAAM,mBAAmB,CAAC;AAC3B,YAAY,EAAE,SAAS,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AACrE,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AACrD,YAAY,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AACtD,OAAO,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAChF,OAAO,EACL,eAAe,EACf,oBAAoB,EACpB,aAAa,EACb,gBAAgB,GACjB,MAAM,eAAe,CAAC;AACvB,YAAY,EACV,YAAY,EACZ,gBAAgB,EAChB,sBAAsB,EACtB,oBAAoB,GACrB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,oBAAoB,EAAE,MAAM,UAAU,CAAC;AAChD,OAAO,EACL,wBAAwB,EACxB,kBAAkB,EAClB,iBAAiB,GAClB,MAAM,sBAAsB,CAAC;AAC9B,YAAY,EAAE,mBAAmB,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AACnE,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAChD,YAAY,EACV,eAAe,EACf,kBAAkB,EAClB,iBAAiB,EACjB,wBAAwB,GACzB,MAAM,aAAa,CAAC;AACrB,YAAY,EACV,SAAS,EACT,uBAAuB,EACvB,uBAAuB,EACvB,sBAAsB,EACtB,iBAAiB,GAClB,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,WAAW,EACX,cAAc,EACd,MAAM,EACN,eAAe,EACf,iBAAiB,EACjB,oBAAoB,GACrB,MAAM,WAAW,CAAC;AACnB,YAAY,EAAE,wBAAwB,EAAE,MAAM,WAAW,CAAC"}
package/dist/index.js CHANGED
@@ -5,6 +5,8 @@ export { AdoptedContainer } from "./adopted-container.js";
5
5
  export { buildVersionSource } from "./version-source.js";
6
6
  export { resolveMount } from "./host-path.js";
7
7
  export { probeHostDevice } from "./host-device.js";
8
+ export { listHostSerialDevices, SERIAL_BY_ID_DIR } from "./serial-devices.js";
9
+ export { watchHostDevice, decideDeviceEvent, deviceProbeNeeded, DEVICE_WATCH_INTERVAL_MS, DEVICE_STUCK_MS, } from "./device-watch.js";
8
10
  export { retryForever, anySignal } from "./retry.js";
9
11
  export { fetchWithTimeout, waitForHttpReady, probeHttpHealth } from "./http.js";
10
12
  export { resolveEndpoint, waitForEndpointReady, isManagedMode, describeEndpoint, } from "./endpoint.js";
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,OAAO,EAAE,mBAAmB,EAAE,uBAAuB,EAAE,MAAM,cAAc,CAAC;AAE5E,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAY1D,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAE1D,OAAO,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAEzD,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAC9C,OAAO,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAEnD,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAErD,OAAO,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAChF,OAAO,EACL,eAAe,EACf,oBAAoB,EACpB,aAAa,EACb,gBAAgB,GACjB,MAAM,eAAe,CAAC;AAOvB,OAAO,EAAE,oBAAoB,EAAE,MAAM,UAAU,CAAC;AAChD,OAAO,EACL,wBAAwB,EACxB,kBAAkB,EAClB,iBAAiB,GAClB,MAAM,sBAAsB,CAAC;AAE9B,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAchD,OAAO,EACL,WAAW,EACX,cAAc,EACd,MAAM,EACN,eAAe,EACf,iBAAiB,EACjB,oBAAoB,GACrB,MAAM,WAAW,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,OAAO,EAAE,mBAAmB,EAAE,uBAAuB,EAAE,MAAM,cAAc,CAAC;AAE5E,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAY1D,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAE1D,OAAO,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAEzD,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAC9C,OAAO,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACnD,OAAO,EAAE,qBAAqB,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAC9E,OAAO,EACL,eAAe,EACf,iBAAiB,EACjB,iBAAiB,EACjB,wBAAwB,EACxB,eAAe,GAChB,MAAM,mBAAmB,CAAC;AAS3B,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAErD,OAAO,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAChF,OAAO,EACL,eAAe,EACf,oBAAoB,EACpB,aAAa,EACb,gBAAgB,GACjB,MAAM,eAAe,CAAC;AAOvB,OAAO,EAAE,oBAAoB,EAAE,MAAM,UAAU,CAAC;AAChD,OAAO,EACL,wBAAwB,EACxB,kBAAkB,EAClB,iBAAiB,GAClB,MAAM,sBAAsB,CAAC;AAE9B,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAchD,OAAO,EACL,WAAW,EACX,cAAc,EACd,MAAM,EACN,eAAe,EACf,iBAAiB,EACjB,oBAAoB,GACrB,MAAM,WAAW,CAAC"}
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Finding USB serial devices on the host by name.
3
+ *
4
+ * udev gives every USB serial device a link in `/dev/serial/by-id` named after
5
+ * its vendor, product and serial number
6
+ * (`usb-ITead_Sonoff_Zigbee_3.0_USB_Dongle_Plus_…-if00-port0`), and removes it
7
+ * when the device is unplugged. That name is what lets a plugin recognise its
8
+ * hardware without asking the user for a path, and the link is the path to
9
+ * hand to the container: it survives a replug that changes the ttyUSB number.
10
+ */
11
+ import type { ContainerManagerApi } from "./types.js";
12
+ export declare const SERIAL_BY_ID_DIR = "/dev/serial/by-id";
13
+ /**
14
+ * The `/dev/serial/by-id` links on the host, as full paths, sorted.
15
+ *
16
+ * Returns `[]` when no USB serial device is plugged in, and **null when the
17
+ * host could not be asked** — see {@link probeHostDevice}. With nothing
18
+ * plugged in udev removes the directory itself, which a containerized Signal K
19
+ * cannot mount, so there null usually means "none". A containerized Signal K
20
+ * needs signalk-container 1.37.2 or later; older ones list nothing.
21
+ *
22
+ * @throws ContainerHelperError `unsupported-manager` on signalk-container
23
+ * older than 1.30.0.
24
+ */
25
+ export declare function listHostSerialDevices(manager: ContainerManagerApi): Promise<string[] | null>;
26
+ //# sourceMappingURL=serial-devices.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serial-devices.d.ts","sourceRoot":"","sources":["../src/serial-devices.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAGH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AAEtD,eAAO,MAAM,gBAAgB,sBAAsB,CAAC;AAEpD;;;;;;;;;;;GAWG;AACH,wBAAsB,qBAAqB,CACzC,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,MAAM,EAAE,GAAG,IAAI,CAAC,CAK1B"}
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Finding USB serial devices on the host by name.
3
+ *
4
+ * udev gives every USB serial device a link in `/dev/serial/by-id` named after
5
+ * its vendor, product and serial number
6
+ * (`usb-ITead_Sonoff_Zigbee_3.0_USB_Dongle_Plus_…-if00-port0`), and removes it
7
+ * when the device is unplugged. That name is what lets a plugin recognise its
8
+ * hardware without asking the user for a path, and the link is the path to
9
+ * hand to the container: it survives a replug that changes the ttyUSB number.
10
+ */
11
+ import { probeHostDevice } from "./host-device.js";
12
+ export const SERIAL_BY_ID_DIR = "/dev/serial/by-id";
13
+ /**
14
+ * The `/dev/serial/by-id` links on the host, as full paths, sorted.
15
+ *
16
+ * Returns `[]` when no USB serial device is plugged in, and **null when the
17
+ * host could not be asked** — see {@link probeHostDevice}. With nothing
18
+ * plugged in udev removes the directory itself, which a containerized Signal K
19
+ * cannot mount, so there null usually means "none". A containerized Signal K
20
+ * needs signalk-container 1.37.2 or later; older ones list nothing.
21
+ *
22
+ * @throws ContainerHelperError `unsupported-manager` on signalk-container
23
+ * older than 1.30.0.
24
+ */
25
+ export async function listHostSerialDevices(manager) {
26
+ const probe = await probeHostDevice(manager, SERIAL_BY_ID_DIR);
27
+ if (probe === null)
28
+ return null;
29
+ if (!probe.exists)
30
+ return [];
31
+ return probe.nodes.map((name) => `${SERIAL_BY_ID_DIR}/${name}`).sort();
32
+ }
33
+ //# sourceMappingURL=serial-devices.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serial-devices.js","sourceRoot":"","sources":["../src/serial-devices.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAGnD,MAAM,CAAC,MAAM,gBAAgB,GAAG,mBAAmB,CAAC;AAEpD;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,KAAK,UAAU,qBAAqB,CACzC,OAA4B;IAE5B,MAAM,KAAK,GAAG,MAAM,eAAe,CAAC,OAAO,EAAE,gBAAgB,CAAC,CAAC;IAC/D,IAAI,KAAK,KAAK,IAAI;QAAE,OAAO,IAAI,CAAC;IAChC,IAAI,CAAC,KAAK,CAAC,MAAM;QAAE,OAAO,EAAE,CAAC;IAC7B,OAAO,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,GAAG,gBAAgB,IAAI,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;AACzE,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-container-helper",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Helper library for Signal K plugins that manage containers via the signalk-container plugin",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -36,8 +36,7 @@
36
36
  "lint": "eslint --fix",
37
37
  "format": "npm run prettier && npm run lint",
38
38
  "ci-lint": "eslint && prettier --check .",
39
- "prepublishOnly": "npm run build && npm test",
40
- "release": "git tag -d v$npm_package_version; git tag v$npm_package_version && git push && git push origin v$npm_package_version"
39
+ "prepublishOnly": "npm run build && npm test"
41
40
  },
42
41
  "repository": {
43
42
  "type": "git",