homebridge-plugin-utils 2.0.0 → 2.1.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.
Files changed (83) hide show
  1. package/dist/backpressure.d.ts +1 -1
  2. package/dist/backpressure.js +4 -4
  3. package/dist/backpressure.js.map +1 -1
  4. package/dist/cli/index.d.ts +73 -2
  5. package/dist/cli/index.js +221 -25
  6. package/dist/cli/index.js.map +1 -1
  7. package/dist/clock-double.js +2 -2
  8. package/dist/clock-double.js.map +1 -1
  9. package/dist/disposable-stack.d.ts +59 -0
  10. package/dist/disposable-stack.js +155 -0
  11. package/dist/disposable-stack.js.map +1 -0
  12. package/dist/docChrome.d.ts +260 -0
  13. package/dist/docChrome.js +361 -0
  14. package/dist/docChrome.js.map +1 -0
  15. package/dist/featureOptions-docs.d.ts +2 -2
  16. package/dist/featureOptions-docs.js +1 -1
  17. package/dist/featureOptions.d.ts +5 -5
  18. package/dist/featureOptions.js +6 -6
  19. package/dist/ffmpeg/exec.d.ts +2 -2
  20. package/dist/ffmpeg/exec.js +2 -2
  21. package/dist/ffmpeg/mp4-assembler.d.ts +45 -4
  22. package/dist/ffmpeg/mp4-assembler.js +45 -8
  23. package/dist/ffmpeg/mp4-assembler.js.map +1 -1
  24. package/dist/ffmpeg/mp4-parser.d.ts +1 -1
  25. package/dist/ffmpeg/mp4-parser.js +1 -1
  26. package/dist/ffmpeg/options.js +3 -3
  27. package/dist/ffmpeg/options.js.map +1 -1
  28. package/dist/ffmpeg/process.d.ts +1 -1
  29. package/dist/ffmpeg/process.js +4 -4
  30. package/dist/ffmpeg/process.js.map +1 -1
  31. package/dist/ffmpeg/record.d.ts +26 -1
  32. package/dist/ffmpeg/record.js +12 -0
  33. package/dist/ffmpeg/record.js.map +1 -1
  34. package/dist/ffmpeg/recording-process-double.d.ts +13 -0
  35. package/dist/ffmpeg/recording-process-double.js +22 -0
  36. package/dist/ffmpeg/recording-process-double.js.map +1 -1
  37. package/dist/ffmpeg/rtp-parser.d.ts +1 -1
  38. package/dist/ffmpeg/rtp-parser.js +1 -1
  39. package/dist/ffmpeg/rtp.d.ts +4 -4
  40. package/dist/ffmpeg/rtp.js +7 -6
  41. package/dist/ffmpeg/rtp.js.map +1 -1
  42. package/dist/ffmpeg/stream.js +1 -1
  43. package/dist/ffmpeg/stream.js.map +1 -1
  44. package/dist/index.d.ts +1 -0
  45. package/dist/index.js +1 -0
  46. package/dist/index.js.map +1 -1
  47. package/dist/logclient/auth.d.ts +3 -3
  48. package/dist/logclient/auth.js +2 -2
  49. package/dist/logclient/auth.js.map +1 -1
  50. package/dist/logclient/cli-run.js +2 -2
  51. package/dist/logclient/cli-run.js.map +1 -1
  52. package/dist/logclient/cli.js +2 -2
  53. package/dist/logclient/client.js +2 -2
  54. package/dist/logclient/client.js.map +1 -1
  55. package/dist/logclient/rest.js +1 -1
  56. package/dist/logclient/socket-double.d.ts +1 -1
  57. package/dist/logclient/socket-double.js +1 -1
  58. package/dist/logclient/socket.js +5 -5
  59. package/dist/logclient/socket.js.map +1 -1
  60. package/dist/logclient/time-window.d.ts +1 -1
  61. package/dist/mqttClient.d.ts +1 -1
  62. package/dist/mqttClient.js +3 -3
  63. package/dist/mqttClient.js.map +1 -1
  64. package/dist/ui/featureOptions.js +6 -6
  65. package/dist/ui/pluginConfigSession.mjs +1 -1
  66. package/dist/ui/webUi-featureOptions/effects/persist.mjs +2 -2
  67. package/dist/ui/webUi-featureOptions/effects/theme.mjs +1 -1
  68. package/dist/ui/webUi-featureOptions/effects/tokens.mjs +1 -1
  69. package/dist/ui/webUi-featureOptions/rendering.mjs +2 -2
  70. package/dist/ui/webUi-featureOptions/selectors.mjs +3 -3
  71. package/dist/ui/webUi-featureOptions/state.mjs +6 -6
  72. package/dist/ui/webUi-featureOptions/store.mjs +1 -1
  73. package/dist/ui/webUi-featureOptions/utils.mjs +14 -1
  74. package/dist/ui/webUi-featureOptions/views/connectionError.mjs +1 -1
  75. package/dist/ui/webUi-featureOptions/views/nav.mjs +42 -28
  76. package/dist/ui/webUi-featureOptions/views/options.mjs +2 -2
  77. package/dist/ui/webUi-featureOptions/views/search.mjs +2 -2
  78. package/dist/ui/webUi-featureOptions.mjs +57 -32
  79. package/dist/ui/webUi.mjs +43 -11
  80. package/dist/util.d.ts +89 -12
  81. package/dist/util.js +62 -14
  82. package/dist/util.js.map +1 -1
  83. package/package.json +9 -9
@@ -4,10 +4,10 @@
4
4
  */
5
5
  "use strict";
6
6
 
7
+ import { delay, toastError } from "./webUi-featureOptions/utils.mjs";
7
8
  import { initialState, reducer } from "./webUi-featureOptions/state.mjs";
8
9
  import { FeatureOptionsStore } from "./webUi-featureOptions/store.mjs";
9
10
  import { buildCatalogIndex } from "./featureOptions.js";
10
- import { delay } from "./webUi-featureOptions/utils.mjs";
11
11
  import { mountConnectionErrorView } from "./webUi-featureOptions/views/connectionError.mjs";
12
12
  import { mountDeviceInfoView } from "./webUi-featureOptions/views/deviceInfo.mjs";
13
13
  import { mountHeaderView } from "./webUi-featureOptions/views/header.mjs";
@@ -44,10 +44,19 @@ const FLUSH_TEARDOWN_TIMEOUT_MS = 2000;
44
44
  * @property {string} serialNumber - The unique serial number of the controller.
45
45
  */
46
46
 
47
+ /**
48
+ * The resolved shape of a `getDevices` hook: the single contract every device fetch crosses. It carries the device list and the connection outcome together, so a
49
+ * failure travels back with the response it belongs to rather than through a separate side-channel a concurrent probe could rewrite.
50
+ *
51
+ * @typedef {Object} DeviceListResult
52
+ * @property {Object[]} devices - The devices for the requested controller; empty when the probe failed or when the controller legitimately has none.
53
+ * @property {string} error - The user-facing connection-failure message: empty when the fetch succeeded, the failure text when the fetch failed and `devices` is empty.
54
+ */
55
+
47
56
  /**
48
57
  * @typedef {Object} FeatureOptionsConfig
49
58
  * @property {Function} [getControllers] - Handler to retrieve available controllers.
50
- * @property {Function} [getDevices] - Handler to retrieve devices for a controller.
59
+ * @property {(controller: (Controller|null)) => Promise<DeviceListResult>} [getDevices] - Handler resolving a controller's {@link DeviceListResult}.
51
60
  * @property {Function} [infoPanel] - Handler to display device information.
52
61
  * @property {Object} [sidebar] - Sidebar configuration options.
53
62
  * @property {string} [sidebar.controllerLabel="Controllers"] - Label for the controllers section.
@@ -68,7 +77,8 @@ const FLUSH_TEARDOWN_TIMEOUT_MS = 2000;
68
77
  *
69
78
  * Public API: constructor takes the same options shape, `show()` reveals the UI, `hide()` is the navigate-away (it flushes any pending edit, then tears down),
70
79
  * `cleanup()` is immediate destructive teardown (may drop an unsaved debounced edit; for forced/synchronous disposal), `getHomebridgeDevices()` is the default
71
- * device source. Plugins consuming the library see no change to this surface.
80
+ * device source. The device-list contract is rich: a `getDevices` hook resolves a {@link DeviceListResult} carrying both the device array and the connection
81
+ * outcome, and `getHomebridgeDevices` resolves the same shape.
72
82
  *
73
83
  * Internally, the store owns per-show state, effects own side effects, views own DOM, and the orchestrator is the lifecycle seam that boots and tears them down. The one
74
84
  * piece of state it keeps itself is #initialOptions - the revert-to-saved snapshot - which must outlive the store's per-show() reset; all else flows through the store.
@@ -79,7 +89,7 @@ const FLUSH_TEARDOWN_TIMEOUT_MS = 2000;
79
89
  * const session = await PluginConfigSession.open({ host: homebridge, name: "My Plugin" });
80
90
  * const featureOptionsUI = new webUiFeatureOptions({
81
91
  * getControllers: ({ config }) => myPlugin.controllersFrom(config),
82
- * getDevices: async (controller, { config }) => controller ? myPlugin.getDevices(controller, config) : [],
92
+ * getDevices: async (controller, { config }) => controller ? { devices: await myPlugin.getDevices(controller, config), error: "" } : { devices: [], error: "" },
83
93
  * ui: {
84
94
  * isController: (device) => device?.type === "controller",
85
95
  * validOption: (device, option) => device?.type !== "controller" || !option.name.startsWith("Video.")
@@ -197,8 +207,8 @@ export class webUiFeatureOptions {
197
207
  * 7. Once controllers resolves: if controller-based mode with empty controllers, show the no-controllers message and return.
198
208
  * 8. Pre-fire the devices fetch for the initial controller so it overlaps with the feature catalog.
199
209
  * 9. Once the feature catalog resolves: build the catalog, dispatch model:loaded, mount all views.
200
- * 10. Once devices resolve: dispatch devices:loaded. If a controller is selected but no devices were returned, fetch the error message, dispatch
201
- * connection:error, and return; otherwise set the initial scope.
210
+ * 10. Once devices resolve: dispatch devices:loaded. If a controller is selected and the result carried a connection-failure error, dispatch connection:error
211
+ * with that message and return; otherwise set the initial scope.
202
212
  * 11. Reveal regions that views render into.
203
213
  *
204
214
  * @param {import("./pluginConfigSession.mjs").PluginConfigSession} session - The config session supplied by the orchestrator; the page's single source of
@@ -221,15 +231,15 @@ export class webUiFeatureOptions {
221
231
  await this.hide();
222
232
 
223
233
  // Re-read the host config into the session before the page renders against it. show() is the single entry chokepoint (launch, first-run, the menu, and the
224
- // connection-error retry), so re-syncing here makes "every show is fresh" an unconditional invariant: an edit made in the Settings tab while this page was hidden
234
+ // connection-error retry), so re-syncing here makes "every show is fresh" an unconditional guarantee: an edit made in the Settings tab while this page was hidden
225
235
  // is reflected on return rather than rendering against a frozen snapshot. The sync lands before getControllers reads session.platform and before the options read
226
- // below, so both derive from the re-read config. A read failure surfaces as a toast and bails rather than rejecting into a dropped-promise menu handler.
236
+ // below, so both derive from the re-read config. A read failure surfaces as a toast and bails, so the page never renders against a config the session could not read.
227
237
  try {
228
238
 
229
239
  await this.#session.sync();
230
240
  } catch(err) {
231
241
 
232
- homebridge.toast.error(err?.message ?? String(err), "Error");
242
+ toastError(err);
233
243
 
234
244
  return;
235
245
  }
@@ -342,28 +352,23 @@ export class webUiFeatureOptions {
342
352
  }
343
353
 
344
354
  // Wait for devices. The fetch overlapped with config + features; typically already resolved by now.
345
- const devices = await devicesPromise;
355
+ const { devices, error } = await devicesPromise;
346
356
 
347
357
  if(signal.aborted) {
348
358
 
349
359
  return;
350
360
  }
351
361
 
352
- this.#store.dispatch({ controllerId: initialController?.serialNumber ?? null, devices: devices ?? [], type: "devices:loaded" });
362
+ this.#store.dispatch({ controllerId: initialController?.serialNumber ?? null, devices, type: "devices:loaded" });
353
363
 
354
- // Connection-error short-circuit: controller-based mode with a selected controller but no devices returned means the controller is configured but unreachable.
355
- // Fetch the user-facing error message from the host and dispatch connection:error so the connection-error view takes over the header. Do not transition the
356
- // scope to a device-view (there are no devices); leave it at global so any subsequent retry re-shows from a known scope.
357
- if((initialController !== null) && (devices.length === 0)) {
358
-
359
- const message = String(await homebridge.request("/getErrorMessage") ?? "");
360
-
361
- if(signal.aborted) {
362
-
363
- return;
364
- }
364
+ // Connection-error short-circuit: a selected controller whose probe reported a failure. The failure message arrives with the device-list response - it travels
365
+ // back on the DeviceListResult rather than through a separate request - so a non-empty error is the failure signal and dispatching connection:error hands the
366
+ // header to the connection-error view. A zero-device result with an empty error is a legitimately empty controller and falls through to the scope logic below,
367
+ // never the connection-error view. Do not transition the scope to a device-view here (there are no devices); leave it at global so any subsequent retry re-shows
368
+ // from a known scope.
369
+ if((initialController !== null) && error.length) {
365
370
 
366
- this.#store.dispatch({ message, type: "connection:error" });
371
+ this.#store.dispatch({ message: error, type: "connection:error" });
367
372
 
368
373
  // The connection-error view reveals #headerInfo itself when it renders the error block (it is the sole owner of the error display, content and reveal together),
369
374
  // so the orchestrator only transitions state here and returns. The sidebar, search panel, and config table stay hidden - hide() set them so at show() start and
@@ -488,7 +493,9 @@ export class webUiFeatureOptions {
488
493
  * Used as the default `getDevices`, it is extracted unbound and later invoked with `#config` as the receiver, so its body must never reference `this` - it reads only
489
494
  * the global `homebridge` object. A future edit that needs instance state must bind it explicitly (or stop using it as the bare default).
490
495
  *
491
- * @returns {Promise<Device[]>} The list of devices sorted alphabetically by name.
496
+ * The device-only default always succeeds against the local accessory cache, so the {@link DeviceListResult} it resolves carries an empty error.
497
+ *
498
+ * @returns {Promise<DeviceListResult>} The device list sorted alphabetically by name, paired with an empty error.
492
499
  * @public
493
500
  */
494
501
  async getHomebridgeDevices() {
@@ -511,7 +518,7 @@ export class webUiFeatureOptions {
511
518
  });
512
519
  }
513
520
 
514
- return devices.toSorted((a, b) => (a.name ?? "").toLowerCase().localeCompare((b.name ?? "").toLowerCase()));
521
+ return { devices: devices.toSorted((a, b) => (a.name ?? "").toLowerCase().localeCompare((b.name ?? "").toLowerCase())), error: "" };
515
522
  }
516
523
 
517
524
  // Mount every view against the active store and page DOM. Each view registers its own listeners with the page signal; nothing here references the views after
@@ -534,9 +541,16 @@ export class webUiFeatureOptions {
534
541
 
535
542
  // Retry routes through show(), which owns teardown: its internal `await this.hide()` flushes any debounced edit before aborting the page signal, so a retry
536
543
  // cannot drop a pending write. We deliberately do not call cleanup() here - cleanup() aborts the signal without flushing, which is exactly the drop we avoid.
544
+ // The retry button fires this as `void onRetry()`, so a rejection is otherwise unobserved; the try/catch surfaces a failed re-show as an error toast instead.
537
545
  onRetry: async () => {
538
546
 
539
- await this.show(this.#session);
547
+ try {
548
+
549
+ await this.show(this.#session);
550
+ } catch(err) {
551
+
552
+ toastError(err);
553
+ }
540
554
  },
541
555
  retryDelayMs: this.#config.controllerRetryEnableDelayMs,
542
556
  root: headerInfo,
@@ -574,7 +588,6 @@ export class webUiFeatureOptions {
574
588
  mountNavView({
575
589
 
576
590
  getDevices: (controller) => this.#devicesFor(controller),
577
- host: homebridge,
578
591
  labelControllers: this.#config.labelControllers,
579
592
  labelDevices: this.#config.labelDevices,
580
593
  rootControllers: controllersContainer,
@@ -585,12 +598,24 @@ export class webUiFeatureOptions {
585
598
  }
586
599
  }
587
600
 
588
- // Resolve the device list for a controller, injecting the live platform config the plugin's getDevices needs to recover the controller's credentials. Centralizing
589
- // the injection here means both the initial fetch in show() and the on-click fetch in the nav view call the same config-fed seam, and the config is read fresh from
590
- // the session on every call - never captured - so a credential change is always reflected. The default device-only getDevices ignores the injected config.
591
- #devicesFor(controller) {
601
+ // Resolve a controller's DeviceListResult, injecting the live platform config the plugin's getDevices needs to recover the controller's credentials. This is the
602
+ // single seam every device fetch crosses - the initial fetch in show() and the on-click fetch in the nav view both route through it - and the config is read fresh
603
+ // from the session on every call, never captured, so a credential change is always reflected. The default device-only getDevices ignores the injected config.
604
+ //
605
+ // The full rich contract is enforced here with a fail-fast guard: the hook must resolve an object carrying a `devices` array and a string `error`. The error half
606
+ // is what guarantees every downstream reader (the connection-error view's DOM construction, whose createElement child loop passes a non-string message straight to
607
+ // appendChild) receives a string. A resolved value that does not match trips a TypeError naming the contract, so a shape mistake surfaces loudly at the seam rather
608
+ // than as a corrupted render deeper in.
609
+ async #devicesFor(controller) {
610
+
611
+ const result = await this.#config.getDevices(controller, { config: this.#session?.platform });
612
+
613
+ if(!result || !Array.isArray(result.devices) || (typeof result.error !== "string")) {
614
+
615
+ throw new TypeError("getDevices must resolve to { devices, error }.");
616
+ }
592
617
 
593
- return this.#config.getDevices(controller, { config: this.#session?.platform });
618
+ return result;
594
619
  }
595
620
  }
596
621
 
package/dist/ui/webUi.mjs CHANGED
@@ -5,6 +5,7 @@
5
5
  "use strict";
6
6
 
7
7
  import { PluginConfigSession } from "./pluginConfigSession.mjs";
8
+ import { toastError } from "./webUi-featureOptions/utils.mjs";
8
9
  import { webUiFeatureOptions } from "./webUi-featureOptions.mjs";
9
10
 
10
11
  /**
@@ -80,10 +81,9 @@ export class webUi {
80
81
  await this.#launchWebUI();
81
82
  } catch(err) {
82
83
 
83
- // Outermost user-facing diagnostic seam in the webUI. Caller-supplied first-run handlers and other extension points can throw any shape (strings, plain
84
- // objects, primitives), so normalize before reaching the toast: `err?.message` extracts the message field when present, the nullish coalesce falls back
85
- // to a string coercion of the whole value otherwise. This keeps the toast text useful regardless of what bubbled out of `#launchWebUI`.
86
- homebridge.toast.error(err?.message ?? String(err), "Error");
84
+ // Outermost user-facing diagnostic seam in the webUI. Caller-supplied first-run handlers and other extension points can throw any shape, so the shared
85
+ // toastError normalization extracts a useful message regardless of what bubbled out of `#launchWebUI`.
86
+ toastError(err);
87
87
  } finally {
88
88
 
89
89
  homebridge.hideSpinner();
@@ -94,7 +94,10 @@ export class webUi {
94
94
  * Show the first-run user experience.
95
95
  *
96
96
  * Wires the submit button to run the caller-supplied submit handler, swap the page from first-run to feature-options, and hand off to the feature-options view.
97
- * The save button stays disabled until the user completes the first-run flow so a partially-configured plugin cannot be written back to disk.
97
+ * The save button stays disabled until the user completes the first-run flow so a partially-configured plugin cannot be written back to disk. A submit failure
98
+ * surfaces as an error toast, and where the failure landed decides what the user is left looking at: a rejected submit throws before the page swap and leaves the
99
+ * first-run page fully visible for another attempt, while a failure during the feature-options handoff after a successful submit leaves the main shell visible with
100
+ * the menu still usable for recovery.
98
101
  *
99
102
  * @returns {Promise<void>}
100
103
  * @private
@@ -134,6 +137,13 @@ export class webUi {
134
137
  await this.featureOptions.show(this.#session);
135
138
 
136
139
  homebridge.enableSaveButton();
140
+ } catch(err) {
141
+
142
+ // A first-run submit can throw from two places, and where it threw decides what the user is left looking at. A rejected onSubmit (a failed login or
143
+ // configuration validation) throws before the page swap, so the first-run page stays fully visible for another attempt. A rejection from
144
+ // featureOptions.show() after a successful submit throws after the swap, so the main shell is visible with the menu still usable for recovery. In both cases
145
+ // the toast is the diagnostic, and the finally below brings the spinner down.
146
+ toastError(err);
137
147
  } finally {
138
148
 
139
149
  homebridge.hideSpinner();
@@ -143,6 +153,27 @@ export class webUi {
143
153
  document.getElementById("pageFirstRun").style.display = "block";
144
154
  }
145
155
 
156
+ /**
157
+ * Show the feature-options tab from the menu.
158
+ *
159
+ * The menuFeatureOptions button re-enters the feature-options view. `featureOptions.show()` can reject - a plugin `getDevices` hook that resolves the wrong shape
160
+ * trips the device-list contract guard, for one - and the click listener drops the returned promise, so this method brackets the re-entry in a try/catch that
161
+ * surfaces a failed re-show as an error toast rather than an unobserved rejection.
162
+ *
163
+ * @returns {Promise<void>}
164
+ * @private
165
+ */
166
+ async #showFeatureOptions() {
167
+
168
+ try {
169
+
170
+ await this.featureOptions.show(this.#session);
171
+ } catch(err) {
172
+
173
+ toastError(err);
174
+ }
175
+ }
176
+
146
177
  /**
147
178
  * Show the main plugin configuration tab.
148
179
  *
@@ -228,13 +259,14 @@ export class webUi {
228
259
  // later reader sees.
229
260
  this.#session = await PluginConfigSession.open({ host: homebridge, name: this.#name });
230
261
 
231
- // Menu click listeners use a uniform shape: an arrow expression that calls the handler and returns its result. addEventListener discards the return value, so an
232
- // async handler's promise is dropped either way; wrapping `featureOptions.show()` in `async () => await ...` would add a microtask hop and break the visual
233
- // symmetry across the three sibling registrations for no behavioral benefit. All three handlers are async (show() syncs; #showSettings / #showSupport await
234
- // the navigate-away flush before revealing the next tab), and the dropped promise is a deliberate fire-and-forget: each handler's own try/finally reveals the tab
235
- // and drains the spinner on every path, and the flush drain's failure surfaces internally via persist:failed's toast - so there is no unobserved rejection here.
262
+ // Menu click listeners use a uniform shape: an arrow expression that calls a handler and returns its result. addEventListener discards the return value, so each
263
+ // async handler's promise is dropped; the handlers own their error handling so the drop carries no unobserved rejection. #showFeatureOptions wraps
264
+ // featureOptions.show() in a try/catch that toasts a failed re-entry - the show pipeline can reject (a plugin getDevices hook that resolves the wrong shape trips
265
+ // the device-list contract guard, for one), and without the wrapper that rejection would surface nowhere. #showSettings and #showSupport each bracket their
266
+ // navigate-away flush in a try/finally that reveals the next tab and drains the spinner on every path (the flush drain's own failure surfaces via persist:failed's
267
+ // toast).
236
268
  document.getElementById("menuHome").addEventListener("click", () => this.#showSupport());
237
- document.getElementById("menuFeatureOptions").addEventListener("click", () => this.featureOptions.show(this.#session));
269
+ document.getElementById("menuFeatureOptions").addEventListener("click", () => this.#showFeatureOptions());
238
270
  document.getElementById("menuSettings").addEventListener("click", () => this.#showSettings());
239
271
 
240
272
  // The caller's first-run gate decides routing against the injected platform config. No separate "is there any config?" test is needed: a plugin with a first-run
package/dist/util.d.ts CHANGED
@@ -8,8 +8,8 @@ import type { Logging } from "homebridge";
8
8
  * The canonical set of abort reasons used across `homebridge-plugin-utils`.
9
9
  *
10
10
  * Every long-lived resource class in the library exposes an {@link AbortSignal} whose abort reason is normally an {@link HbpuAbortError} carrying one of these names.
11
- * Consumers discriminate on the `.name` field. Platform errors produced by `AbortSignal.timeout()` and bare `controller.abort()` interoperate by matching names:
12
- * `TimeoutError` and `AbortError` from the platform both flow through the same discrimination paths unchanged.
11
+ * Consumers branch on the `.name` field. Platform errors produced by `AbortSignal.timeout()` and bare `controller.abort()` interoperate by matching names:
12
+ * `TimeoutError` and `AbortError` from the platform both flow through the same branching paths unchanged.
13
13
  *
14
14
  * @remarks When to use each reason:
15
15
  *
@@ -34,7 +34,7 @@ export interface HbpuAbortErrorOptions {
34
34
  */
35
35
  cause?: unknown;
36
36
  /**
37
- * Optional human-readable message. When omitted, the error's `message` defaults to the reason name, which is sufficient for discrimination-based handling.
37
+ * Optional human-readable message. When omitted, the error's `message` defaults to the reason name, which is sufficient for name-based handling.
38
38
  */
39
39
  message?: string;
40
40
  }
@@ -73,7 +73,7 @@ export interface HbpuAbortErrorOptions {
73
73
  */
74
74
  export declare class HbpuAbortError extends Error {
75
75
  /**
76
- * The discriminator. Matches one of {@link HbpuAbortReason}.
76
+ * The tag. Matches one of {@link HbpuAbortReason}.
77
77
  */
78
78
  readonly name: HbpuAbortReason;
79
79
  /**
@@ -87,7 +87,7 @@ export declare class HbpuAbortError extends Error {
87
87
  /**
88
88
  * Type guard: returns `true` if `error` is an {@link HbpuAbortError}.
89
89
  *
90
- * Use this to discriminate HBPU's canonical abort errors from arbitrary thrown values, without nesting `instanceof` checks.
90
+ * Use this to distinguish HBPU's canonical abort errors from arbitrary thrown values, without nesting `instanceof` checks.
91
91
  *
92
92
  * @param error - The value to test.
93
93
  *
@@ -101,7 +101,7 @@ export declare function isHbpuAbortError(error: unknown): error is HbpuAbortErro
101
101
  * `error.cause` and related fields without further casts.
102
102
  *
103
103
  * Collapses the common "is this an HBPU abort, and was it this specific reason?" question into a single call, avoiding the `instanceof` + `.name` nesting that appears
104
- * throughout consuming code. The generic parameter `R` preserves the specific reason string in the narrowed type so callers that discriminate further by name get the
104
+ * throughout consuming code. The generic parameter `R` preserves the specific reason string in the narrowed type so callers that distinguish further by name get the
105
105
  * literal narrowed form automatically.
106
106
  *
107
107
  * @typeParam R - The specific reason being matched. Defaulted by inference from `reason`.
@@ -118,7 +118,7 @@ export declare function isHbpuAbortReason<R extends HbpuAbortReason>(error: unkn
118
118
  /**
119
119
  * Test whether an abort reason indicates a timeout. Matches both the canonical {@link HbpuAbortError} with `"timeout"` name - produced by project watchdogs
120
120
  * ({@link Watchdog}, the inactivity monitors on `FfmpegProcess` / `RtpDemuxer` / `Mp4SegmentAssembler`) - and the platform {@link DOMException}/`Error` whose
121
- * `.name === "TimeoutError"` - produced by `AbortSignal.timeout()`. Consumers discriminate on a single predicate regardless of which code path originated the timeout.
121
+ * `.name === "TimeoutError"` - produced by `AbortSignal.timeout()`. Consumers branch on a single predicate regardless of which code path originated the timeout.
122
122
  *
123
123
  * Exists because every long-lived resource class exposes an `isTimedOut` getter with identical branching logic; routing all of them through this single predicate
124
124
  * enforces one taxonomy and eliminates drift if the project ever needs to add, say, a third timeout shape (e.g., an upstream-framework cancellation).
@@ -185,7 +185,7 @@ export declare function isTimeoutReason(reason: unknown): boolean;
185
185
  * // Abort-driven action goes here.
186
186
  * });
187
187
  *
188
- * // `return await promise` (not a bare `return promise`) is load-bearing inside an async function. `using` disposes when the enclosing function body finishes
188
+ * // `return await promise` (not a bare `return promise`) is required inside an async function. `using` disposes when the enclosing function body finishes
189
189
  * // executing, and without an `await` the body finishes synchronously at the `return` statement - even though the returned promise is still pending. The
190
190
  * // listener would therefore be removed the instant the function returned, well before the promise settles. Adding `await` creates a suspension point that
191
191
  * // keeps the `using` scope alive until the promise actually settles, which is what the "scope-bound registration" pattern relies on.
@@ -590,9 +590,9 @@ export declare function composeSignals(...signals: (AbortSignal | undefined)[]):
590
590
  * Supervise a detached, signal-bound async loop: run the loop, resolve quietly when it ends or its signal aborts, and route any genuine fault to a caller-supplied
591
591
  * handler exactly once.
592
592
  *
593
- * Resilient background loops - membership observers, reachability probes, telemetry firehoses - all share one subtle, correctness-critical invariant: a throw is a
593
+ * Resilient background loops - membership observers, reachability probes, telemetry firehoses - all share one subtle, correctness-critical rule: a throw is a
594
594
  * *fault* only when we did not cause it. When the bound signal is aborted, a throw is the orderly unwinding of a loop the caller already tore down, so it is swallowed
595
- * silently. Any other throw is a genuine fault and is handed to `onError` exactly once. Hand-copying that swallow-on-abort-versus-surface-once discrimination across
595
+ * silently. Any other throw is a genuine fault and is handed to `onError` exactly once. Hand-copying that swallow-on-abort-versus-surface-once distinction across
596
596
  * call sites that share no ancestor is how it drifts apart; owning it in one generic primitive is how it stays consistent.
597
597
  *
598
598
  * The home is here, beside {@link composeSignals}, because the envelope is fully generic - it carries no logging policy, no message wording, and makes no detachment
@@ -674,6 +674,83 @@ export declare function superviseLoop(options: {
674
674
  * @category Utilities
675
675
  */
676
676
  export declare function loopFaultReporter(log: HomebridgePluginLogging, label: string): (error: unknown) => void;
677
+ /**
678
+ * The minimal completion-callback shape {@link guardedDispatch} guards. HomeKit's camera-delegate callbacks (snapshot, prepare-stream, stream-request) each answer with
679
+ * an optional leading `Error` and, in some cases, an optional trailing payload; every one is structurally assignable to this error-first shape, so `guardedDispatch`
680
+ * serves them all without importing any HomeKit or hap-nodejs type. A richer callback that also carries a success payload is preserved through the generic parameter on
681
+ * {@link guardedDispatch}, which keeps the caller's exact signature while still guarding it.
682
+ *
683
+ * @category Utilities
684
+ */
685
+ export type DispatchCallback = (error?: Error) => void;
686
+ /**
687
+ * Run an async handler that HomeKit invokes without awaiting - a camera-delegate method whose interface return type is `void` - so a rejection can never float as an
688
+ * unhandled rejection and the delegate's completion callback is answered exactly once.
689
+ *
690
+ * HomeKit's camera-delegate methods (snapshot, prepare-stream, stream-request) are declared to return `void` yet are naturally written as `async`. Calling an async
691
+ * method in that position discards its promise: a rejection surfaces as a process-level unhandled rejection, and if the method faulted before answering its callback,
692
+ * HomeKit waits forever for a response that never comes. `guardedDispatch` closes both gaps. It owns a once-guard around the real callback and hands the guarded callback
693
+ * to `handler`, so however the handler behaves - answered then faulted, faulted before answering, or answered twice by mistake - the real callback fires exactly once:
694
+ * the first answer wins; a fault after an answer is logged and the earlier answer stands; a fault before any answer is delivered to HomeKit through the callback itself.
695
+ * The handler's promise is marked observed through {@link markHandled}, so nothing floats.
696
+ *
697
+ * @typeParam C - The caller's exact callback signature. Constrained to {@link DispatchCallback} (error-first) so any HomeKit delegate callback fits, while
698
+ * preserving a richer signature - one that also passes a snapshot buffer or stream response - for the handler to answer with.
699
+ * @param options - Dispatch inputs.
700
+ * @param options.callback - The real completion callback HomeKit supplied. It is wrapped in a once-guard and never invoked more than once.
701
+ * @param options.handler - The async work, given the guarded callback to answer with. A rejection, or a synchronous throw, is caught: while the callback is still open
702
+ * it receives the error, otherwise the error is logged.
703
+ * @param options.label - A short human-readable name for the operation (for example `"snapshot request"`), interpolated into the failure log line.
704
+ * @param options.log - The logger a post-answer fault is reported through.
705
+ *
706
+ * @example
707
+ *
708
+ * ```ts
709
+ * import { guardedDispatch } from "homebridge-plugin-utils";
710
+ *
711
+ * // A snapshot delegate HomeKit calls without awaiting. The method itself returns void; the async work and its one-shot callback are handed to guardedDispatch.
712
+ * public handleSnapshotRequest(request: SnapshotRequest, callback: SnapshotRequestCallback): void {
713
+ *
714
+ * guardedDispatch({ callback, handler: (answer) => this.snapshot(request, answer), label: "snapshot request", log: this.log });
715
+ * }
716
+ * ```
717
+ *
718
+ * @category Utilities
719
+ */
720
+ export declare function guardedDispatch<C extends DispatchCallback>(options: {
721
+ callback: C;
722
+ handler: (callback: C) => Promise<void>;
723
+ label: string;
724
+ log: Logger;
725
+ }): void;
726
+ /**
727
+ * Run an async, callback-less handler that HomeKit (or any caller) invokes without awaiting, so a rejection can never float as an unhandled rejection. With no callback
728
+ * to carry a failure back, a fault is simply logged.
729
+ *
730
+ * @param options - Dispatch inputs.
731
+ * @param options.handler - The async work to run. A rejection, or a synchronous throw, is caught and logged.
732
+ * @param options.label - A short human-readable name for the operation (for example `"recording activation"`), interpolated into the failure log line.
733
+ * @param options.log - The logger a fault is reported through.
734
+ *
735
+ * @example
736
+ *
737
+ * ```ts
738
+ * import { guardedDispatch } from "homebridge-plugin-utils";
739
+ *
740
+ * // A recording-state update HomeKit calls without awaiting. There is no callback, so a fault is logged rather than reported back to HomeKit.
741
+ * public updateRecordingActive(active: boolean): void {
742
+ *
743
+ * guardedDispatch({ handler: () => this.applyRecordingActive(active), label: "recording activation", log: this.log });
744
+ * }
745
+ * ```
746
+ *
747
+ * @category Utilities
748
+ */
749
+ export declare function guardedDispatch(options: {
750
+ handler: () => Promise<void>;
751
+ label: string;
752
+ log: Logger;
753
+ }): void;
677
754
  /**
678
755
  * Options for {@link runWithAbort}. At least one of `signal` or `timeout` must be provided so there is always an abort mechanism. TypeScript enforces this at compile
679
756
  * time through a discriminated union - the "no abort mechanism" case is unrepresentable.
@@ -790,12 +867,12 @@ export declare class Watchdog implements Disposable {
790
867
  arm(): void;
791
868
  /**
792
869
  * Cancel any pending fire without aborting anything and without marking the watchdog as permanently dead. Subsequent `arm()` calls continue to work. Safe to call
793
- * when no arm is pending - the method is idempotent.
870
+ * when no arm is pending - repeat calls are no-ops.
794
871
  */
795
872
  clear(): void;
796
873
  /**
797
874
  * `Disposable` implementation. Clears any pending fire AND permanently disables the watchdog: after this runs, `arm()` is a no-op and no further `onFire` calls can
798
- * occur. This is the contract `using watchdog = new Watchdog(...)` relies on - the resource is dead when the block exits, not merely quiescent. Idempotent; repeated
875
+ * occur. This is the contract `using watchdog = new Watchdog(...)` relies on - the resource is dead when the block exits, not merely quiescent. Repeated
799
876
  * disposal is a no-op. Because the class does not own an abort controller, disposal does not signal anything to the rest of the system.
800
877
  */
801
878
  [Symbol.dispose](): void;