homebridge-plugin-utils 1.35.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 (204) hide show
  1. package/README.md +130 -2
  2. package/build/eslint-plugin/README.md +164 -0
  3. package/build/eslint-plugin/config.mjs +308 -0
  4. package/build/eslint-plugin/index.mjs +7 -0
  5. package/build/eslint-plugin/plugin.mjs +46 -0
  6. package/build/eslint-plugin/rules/blank-line-after-open-brace.mjs +137 -0
  7. package/build/eslint-plugin/rules/blank-line-after-open-brace.test.mjs +112 -0
  8. package/build/eslint-plugin/rules/comment-style.mjs +190 -0
  9. package/build/eslint-plugin/rules/comment-style.test.mjs +190 -0
  10. package/build/eslint-plugin/rules/enforce-node-protocol.mjs +114 -0
  11. package/build/eslint-plugin/rules/enforce-node-protocol.test.mjs +116 -0
  12. package/build/eslint-plugin/rules/paren-comparisons-in-logical.mjs +94 -0
  13. package/build/eslint-plugin/rules/paren-comparisons-in-logical.test.mjs +107 -0
  14. package/build/eslint-plugin/rules/split-type-imports.mjs +354 -0
  15. package/build/eslint-plugin/rules/split-type-imports.test.mjs +268 -0
  16. package/build/eslint-plugin/test-setup.mjs +21 -0
  17. package/build/fs-ops.mjs +184 -0
  18. package/build/tsconfig.json +18 -3
  19. package/dist/backpressure.d.ts +94 -45
  20. package/dist/backpressure.js +229 -93
  21. package/dist/backpressure.js.map +1 -1
  22. package/dist/cli/index.d.ts +166 -0
  23. package/dist/cli/index.js +551 -0
  24. package/dist/cli/index.js.map +1 -0
  25. package/dist/clock-double.d.ts +93 -0
  26. package/dist/clock-double.js +141 -0
  27. package/dist/clock-double.js.map +1 -0
  28. package/dist/clock.d.ts +39 -0
  29. package/dist/clock.js +34 -0
  30. package/dist/clock.js.map +1 -0
  31. package/dist/disposable-stack.d.ts +59 -0
  32. package/dist/disposable-stack.js +155 -0
  33. package/dist/disposable-stack.js.map +1 -0
  34. package/dist/docChrome.d.ts +260 -0
  35. package/dist/docChrome.js +361 -0
  36. package/dist/docChrome.js.map +1 -0
  37. package/dist/eslint-plugin/config.d.mts +193 -0
  38. package/dist/eslint-plugin/index.d.mts +2 -0
  39. package/dist/eslint-plugin/plugin.d.mts +87 -0
  40. package/dist/eslint-plugin/rules/blank-line-after-open-brace.d.mts +19 -0
  41. package/dist/eslint-plugin/rules/comment-style.d.mts +15 -0
  42. package/dist/eslint-plugin/rules/enforce-node-protocol.d.mts +18 -0
  43. package/dist/eslint-plugin/rules/paren-comparisons-in-logical.d.mts +15 -0
  44. package/dist/eslint-plugin/rules/split-type-imports.d.mts +16 -0
  45. package/dist/featureOptions-docs.d.ts +96 -0
  46. package/dist/featureOptions-docs.js +193 -0
  47. package/dist/featureOptions-docs.js.map +1 -0
  48. package/dist/featureOptions.d.ts +674 -0
  49. package/dist/featureOptions.js +870 -0
  50. package/dist/featureOptions.js.map +1 -0
  51. package/dist/ffmpeg/codecs.d.ts +256 -72
  52. package/dist/ffmpeg/codecs.js +477 -262
  53. package/dist/ffmpeg/codecs.js.map +1 -1
  54. package/dist/ffmpeg/dgram-util.d.ts +46 -0
  55. package/dist/ffmpeg/dgram-util.js +38 -0
  56. package/dist/ffmpeg/dgram-util.js.map +1 -0
  57. package/dist/ffmpeg/exec.d.ts +83 -64
  58. package/dist/ffmpeg/exec.js +77 -86
  59. package/dist/ffmpeg/exec.js.map +1 -1
  60. package/dist/ffmpeg/fmp4.d.ts +49 -2
  61. package/dist/ffmpeg/fmp4.js +47 -11
  62. package/dist/ffmpeg/fmp4.js.map +1 -1
  63. package/dist/ffmpeg/hap-enums.d.ts +214 -0
  64. package/dist/ffmpeg/hap-enums.js +92 -0
  65. package/dist/ffmpeg/hap-enums.js.map +1 -0
  66. package/dist/ffmpeg/index.d.ts +16 -9
  67. package/dist/ffmpeg/index.js +6 -0
  68. package/dist/ffmpeg/index.js.map +1 -1
  69. package/dist/ffmpeg/mp4-assembler.d.ts +161 -0
  70. package/dist/ffmpeg/mp4-assembler.js +424 -0
  71. package/dist/ffmpeg/mp4-assembler.js.map +1 -0
  72. package/dist/ffmpeg/mp4-parser.d.ts +94 -0
  73. package/dist/ffmpeg/mp4-parser.js +130 -0
  74. package/dist/ffmpeg/mp4-parser.js.map +1 -0
  75. package/dist/ffmpeg/options.d.ts +62 -149
  76. package/dist/ffmpeg/options.js +608 -499
  77. package/dist/ffmpeg/options.js.map +1 -1
  78. package/dist/ffmpeg/process.d.ts +142 -96
  79. package/dist/ffmpeg/process.js +406 -278
  80. package/dist/ffmpeg/process.js.map +1 -1
  81. package/dist/ffmpeg/record.d.ts +325 -186
  82. package/dist/ffmpeg/record.js +418 -565
  83. package/dist/ffmpeg/record.js.map +1 -1
  84. package/dist/ffmpeg/recording-process-double.d.ts +157 -0
  85. package/dist/ffmpeg/recording-process-double.js +190 -0
  86. package/dist/ffmpeg/recording-process-double.js.map +1 -0
  87. package/dist/ffmpeg/rtp-parser.d.ts +70 -0
  88. package/dist/ffmpeg/rtp-parser.js +77 -0
  89. package/dist/ffmpeg/rtp-parser.js.map +1 -0
  90. package/dist/ffmpeg/rtp.d.ts +198 -141
  91. package/dist/ffmpeg/rtp.js +474 -251
  92. package/dist/ffmpeg/rtp.js.map +1 -1
  93. package/dist/ffmpeg/settings.d.ts +5 -2
  94. package/dist/ffmpeg/settings.js +20 -5
  95. package/dist/ffmpeg/settings.js.map +1 -1
  96. package/dist/ffmpeg/stream.d.ts +57 -107
  97. package/dist/ffmpeg/stream.js +121 -150
  98. package/dist/ffmpeg/stream.js.map +1 -1
  99. package/dist/formatters.d.ts +106 -0
  100. package/dist/formatters.js +174 -0
  101. package/dist/formatters.js.map +1 -0
  102. package/dist/homebridge-enums.d.ts +30 -0
  103. package/dist/homebridge-enums.js +17 -0
  104. package/dist/homebridge-enums.js.map +1 -0
  105. package/dist/index.d.ts +13 -6
  106. package/dist/index.js +8 -2
  107. package/dist/index.js.map +1 -1
  108. package/dist/logclient/auth.d.ts +114 -0
  109. package/dist/logclient/auth.js +199 -0
  110. package/dist/logclient/auth.js.map +1 -0
  111. package/dist/logclient/cli-run.d.ts +76 -0
  112. package/dist/logclient/cli-run.js +639 -0
  113. package/dist/logclient/cli-run.js.map +1 -0
  114. package/dist/logclient/cli.d.ts +3 -0
  115. package/dist/logclient/cli.js +97 -0
  116. package/dist/logclient/cli.js.map +1 -0
  117. package/dist/logclient/client.d.ts +145 -0
  118. package/dist/logclient/client.js +600 -0
  119. package/dist/logclient/client.js.map +1 -0
  120. package/dist/logclient/config.d.ts +173 -0
  121. package/dist/logclient/config.js +199 -0
  122. package/dist/logclient/config.js.map +1 -0
  123. package/dist/logclient/endpoints.d.ts +54 -0
  124. package/dist/logclient/endpoints.js +73 -0
  125. package/dist/logclient/endpoints.js.map +1 -0
  126. package/dist/logclient/filter.d.ts +45 -0
  127. package/dist/logclient/filter.js +51 -0
  128. package/dist/logclient/filter.js.map +1 -0
  129. package/dist/logclient/frame.d.ts +93 -0
  130. package/dist/logclient/frame.js +203 -0
  131. package/dist/logclient/frame.js.map +1 -0
  132. package/dist/logclient/index.d.ts +31 -0
  133. package/dist/logclient/index.js +12 -0
  134. package/dist/logclient/index.js.map +1 -0
  135. package/dist/logclient/parser.d.ts +211 -0
  136. package/dist/logclient/parser.js +393 -0
  137. package/dist/logclient/parser.js.map +1 -0
  138. package/dist/logclient/rest.d.ts +41 -0
  139. package/dist/logclient/rest.js +111 -0
  140. package/dist/logclient/rest.js.map +1 -0
  141. package/dist/logclient/settings.d.ts +15 -0
  142. package/dist/logclient/settings.js +64 -0
  143. package/dist/logclient/settings.js.map +1 -0
  144. package/dist/logclient/socket-double.d.ts +201 -0
  145. package/dist/logclient/socket-double.js +384 -0
  146. package/dist/logclient/socket-double.js.map +1 -0
  147. package/dist/logclient/socket.d.ts +257 -0
  148. package/dist/logclient/socket.js +620 -0
  149. package/dist/logclient/socket.js.map +1 -0
  150. package/dist/logclient/stitch.d.ts +83 -0
  151. package/dist/logclient/stitch.js +146 -0
  152. package/dist/logclient/stitch.js.map +1 -0
  153. package/dist/logclient/time-expression.d.ts +42 -0
  154. package/dist/logclient/time-expression.js +181 -0
  155. package/dist/logclient/time-expression.js.map +1 -0
  156. package/dist/logclient/time-window.d.ts +38 -0
  157. package/dist/logclient/time-window.js +53 -0
  158. package/dist/logclient/time-window.js.map +1 -0
  159. package/dist/logclient/types.d.ts +107 -0
  160. package/dist/logclient/types.js +6 -0
  161. package/dist/logclient/types.js.map +1 -0
  162. package/dist/mqttClient.d.ts +287 -0
  163. package/dist/mqttClient.js +433 -0
  164. package/dist/mqttClient.js.map +1 -0
  165. package/dist/service.d.ts +64 -15
  166. package/dist/service.js +93 -66
  167. package/dist/service.js.map +1 -1
  168. package/dist/ui/featureOptions.js +870 -0
  169. package/dist/ui/featureOptions.js.map +1 -0
  170. package/dist/ui/formatters.js +174 -0
  171. package/dist/ui/formatters.js.map +1 -0
  172. package/dist/ui/pluginConfigSession.mjs +141 -0
  173. package/dist/ui/webUi-featureOptions/categoryState.mjs +135 -0
  174. package/dist/ui/webUi-featureOptions/effects/keyboard.mjs +61 -0
  175. package/dist/ui/webUi-featureOptions/effects/persist.mjs +226 -0
  176. package/dist/ui/webUi-featureOptions/effects/theme.mjs +398 -0
  177. package/dist/ui/webUi-featureOptions/effects/tokens.mjs +152 -0
  178. package/dist/ui/webUi-featureOptions/rendering.mjs +431 -0
  179. package/dist/ui/webUi-featureOptions/selectors.mjs +360 -0
  180. package/dist/ui/webUi-featureOptions/state.mjs +319 -0
  181. package/dist/ui/webUi-featureOptions/store.mjs +181 -0
  182. package/dist/ui/webUi-featureOptions/utils.mjs +213 -0
  183. package/dist/ui/webUi-featureOptions/views/connectionError.mjs +152 -0
  184. package/dist/ui/webUi-featureOptions/views/deviceInfo.mjs +80 -0
  185. package/dist/ui/webUi-featureOptions/views/header.mjs +77 -0
  186. package/dist/ui/webUi-featureOptions/views/nav.mjs +341 -0
  187. package/dist/ui/webUi-featureOptions/views/options.mjs +521 -0
  188. package/dist/ui/webUi-featureOptions/views/search.mjs +395 -0
  189. package/dist/ui/webUi-featureOptions.mjs +702 -0
  190. package/dist/ui/webUi.mjs +225 -88
  191. package/dist/util.d.ts +679 -45
  192. package/dist/util.js +830 -77
  193. package/dist/util.js.map +1 -1
  194. package/package.json +33 -15
  195. package/build/eslint-rules.mjs +0 -511
  196. package/dist/featureoptions.d.ts +0 -264
  197. package/dist/featureoptions.js +0 -480
  198. package/dist/featureoptions.js.map +0 -1
  199. package/dist/mqttclient.d.ts +0 -178
  200. package/dist/mqttclient.js +0 -310
  201. package/dist/mqttclient.js.map +0 -1
  202. package/dist/ui/featureoptions.js +0 -480
  203. package/dist/ui/featureoptions.js.map +0 -1
  204. package/dist/ui/webUi-featureoptions.mjs +0 -3765
@@ -0,0 +1,433 @@
1
+ /* Copyright(C) 2017-2026, HJD (https://github.com/hjdhjd). All rights reserved.
2
+ *
3
+ * mqttClient.ts: Signal-driven MQTT client for Homebridge plugins.
4
+ */
5
+ /**
6
+ * AsyncDisposable MQTT client whose connection lifetime is a composed {@link AbortSignal}.
7
+ *
8
+ * The client wraps the underlying MQTT.js connection in the same lifetime shape every other long-lived resource class in this library uses: a composed
9
+ * `AbortSignal`, a single `abort()` verb, and `Symbol.asyncDispose` for scope-bound ownership. Per-subscription and per-publish signals compose into the connection-
10
+ * level signal so that tearing down a specific handler or cancelling a single publish unwinds cleanly without touching the rest of the client.
11
+ *
12
+ * Non-abort transient disconnects continue to trigger MQTT.js's own auto-reconnect - `abort()` is specifically "this client is done for good," not "pause until
13
+ * further notice." Calling `abort()` (or letting a parent signal fire) ends the connection permanently via `mqtt.end(true)`, rejects any pending publishes with the
14
+ * signal's reason, clears all subscription state, and makes every subsequent call a no-op.
15
+ *
16
+ * Construction fails loudly on invalid broker URLs: the constructor throws with the underlying mqtt.js error attached as `cause`, so a misconfigured plugin cannot
17
+ * silently sit in a zombie state where every call either pretends to succeed or throws an unrelated abort error. Callers who want graceful degradation wrap the
18
+ * `new MqttClient(...)` call in their own try/catch.
19
+ *
20
+ * @module
21
+ */
22
+ import { HbpuAbortError, composeSignals, formatErrorMessage, markHandled, onAbort, runWithAbort, waitWithSignal } from "./util.js";
23
+ import { connect } from "mqtt";
24
+ import util from "node:util";
25
+ // Default reconnect interval, in seconds. A one-minute cadence gives a typical LAN broker (a mosquitto instance restarting or a brief network blip) time to come
26
+ // back without hammering it with reconnect attempts, while still recovering well within the timescale a user would notice as "the plugin is broken."
27
+ const MQTT_DEFAULT_RECONNECT_INTERVAL = 60;
28
+ // Module-scope success sentinel for `subscribeSet` handler invocations. `runWithAbort` returns `null` when its composed signal fires, so we use a sentinel to
29
+ // distinguish "user setter ran to completion" from "the invocation was cancelled." Hoisted once at module load rather than allocated per message, and declared as
30
+ // `unique symbol` so the typed comparison below keeps its narrowing precision.
31
+ const SUBSCRIBE_SET_OK = Symbol("mqtt:subscribeSet:ok");
32
+ /**
33
+ * Route a transport-level MQTT error to the appropriate log line and signal whether the underlying transport should be ended. Pure function: no class state, no
34
+ * mqtt.js handles, no closure over the live client. The wiring layer in {@link MqttClient} forwards every `client.on("error", ...)` invocation through here and acts
35
+ * on the returned `endTransport` flag.
36
+ *
37
+ * The routing paths mirror the transport-error categories HBPU distinguishes:
38
+ *
39
+ * - `ECONNREFUSED` - the broker host is up but no listener accepts the connection. Recoverable; auto-reconnect handles it.
40
+ * - `ECONNRESET` - the broker accepted then dropped the connection. Recoverable; auto-reconnect handles it.
41
+ * - `ENOTFOUND` - DNS could not resolve the broker hostname. Non-recoverable - retrying the same hostname will keep failing - so we end the transport permanently
42
+ * and emit a standalone log line without the retry-cadence suffix.
43
+ * - default - any other error code (or none). Logged through the retry-cadence formatter with `util.inspect` of the error, so a future mqtt.js error code we
44
+ * did not anticipate still surfaces in the log stream rather than being silently swallowed.
45
+ *
46
+ * @param error - The error event payload from the underlying mqtt.js client.
47
+ * @param log - Logger used to emit the routed message.
48
+ * @param reconnectInterval - Configured reconnect interval (in seconds) used to format the retry-cadence suffix.
49
+ *
50
+ * @returns A {@link MqttBrokerErrorResult} indicating whether the wiring layer should end the transport.
51
+ *
52
+ * @category Utilities
53
+ */
54
+ export function routeMqttBrokerError(error, log, reconnectInterval) {
55
+ const logError = (message) => {
56
+ log.error("MQTT Broker: %s. Will retry again in %s second%s.", message, reconnectInterval, (reconnectInterval === 1) ? "" : "s");
57
+ };
58
+ switch (error.code) {
59
+ case "ECONNREFUSED":
60
+ logError("Connection refused");
61
+ return { endTransport: false };
62
+ case "ECONNRESET":
63
+ logError("Connection reset");
64
+ return { endTransport: false };
65
+ case "ENOTFOUND":
66
+ log.error("MQTT Broker: Hostname or IP address not found.");
67
+ return { endTransport: true };
68
+ default:
69
+ logError(util.inspect(error, { sorted: true }));
70
+ return { endTransport: false };
71
+ }
72
+ }
73
+ /**
74
+ * Route the outcome of a `subscribeGet` response publish to the appropriate log line. Pure function: no class state, no mqtt.js handles, no closure over the live
75
+ * client. The wiring in {@link MqttClient.subscribeGet} forwards each `.then` / `.catch` settlement here, and tests cover both branches by calling the function
76
+ * directly with synthetic `{ ok: true }` and `{ ok: false, error: ... }` outcomes - bypassing the real-broker substrate where a forced QoS-0 publish failure would
77
+ * require contrived socket-level setup that is not worth the test-architecture complexity.
78
+ *
79
+ * @param log - Logger that receives the routed message.
80
+ * @param type - Human-readable label (the `type` argument the caller passed to `subscribeGet`).
81
+ * @param outcome - The publish outcome. See {@link GetterPublishOutcome}.
82
+ *
83
+ * @category Utilities
84
+ */
85
+ export function logGetterPublishOutcome(log, type, outcome) {
86
+ if (outcome.ok) {
87
+ log.info("MQTT: %s status published.", type);
88
+ return;
89
+ }
90
+ log.error("MQTT: failed to publish %s status: %s.", type, formatErrorMessage(outcome.error));
91
+ }
92
+ /**
93
+ * Signal-driven MQTT client with automatic topic-prefix management, composed connection lifetime, and per-operation signal support.
94
+ *
95
+ * @example
96
+ *
97
+ * ```ts
98
+ * import { MqttClient } from "homebridge-plugin-utils";
99
+ *
100
+ * await using mqtt = new MqttClient({ brokerUrl: "mqtt://localhost:1883", log, topicPrefix: "homebridge" }, { signal: platform.signal });
101
+ *
102
+ * // A subscription that auto-unsubscribes on the per-feature signal.
103
+ * const feature = new AbortController();
104
+ *
105
+ * mqtt.subscribe("device1/status", (payload) => log.info("Status: %s.", payload.toString()), { signal: feature.signal });
106
+ *
107
+ * // Abort-aware publish.
108
+ * await mqtt.publish("device1/status", "on");
109
+ * ```
110
+ *
111
+ * @category Utilities
112
+ */
113
+ export class MqttClient {
114
+ /**
115
+ * The composed abort signal representing this client's lifetime. Aborts exactly once when {@link MqttClient.abort} is called or when the parent signal fires.
116
+ */
117
+ signal;
118
+ #brokerUrl;
119
+ #controller;
120
+ #log;
121
+ #mqtt;
122
+ #reconnectInterval;
123
+ #subscriptions;
124
+ #topicPrefix;
125
+ #isConnected;
126
+ /**
127
+ * Construct and start a new MQTT client.
128
+ *
129
+ * Connection is initiated synchronously as part of construction; there is no separate `connect()` step. A synchronous failure from mqtt.js (typically an invalid
130
+ * broker URL) surfaces as an `Error` wrapping the underlying cause, so a misconfigured plugin fails loudly instead of living in a zombie state. Network-level
131
+ * failures (an unreachable broker reachable by a valid URL) do not throw - they surface asynchronously through the client's `error` event, are logged, and trigger
132
+ * mqtt.js's built-in auto-reconnect until {@link MqttClient.abort} or a parent signal ends the client for good. A pre-aborted parent signal still constructs
133
+ * a client (so `#mqtt` stays non-null) and then immediately runs the regular teardown path.
134
+ *
135
+ * @param config - Static broker / topic configuration. See {@link MqttConfig}.
136
+ * @param init - Optional init options. See {@link MqttClientInit}.
137
+ *
138
+ * @throws `Error` (with the underlying mqtt.js error attached as `cause`) when mqtt.js's `connect()` fails synchronously.
139
+ */
140
+ constructor(config, init = {}) {
141
+ this.#brokerUrl = config.brokerUrl;
142
+ this.#isConnected = false;
143
+ this.#log = config.log;
144
+ this.#reconnectInterval = config.reconnectInterval ?? MQTT_DEFAULT_RECONNECT_INTERVAL;
145
+ this.#subscriptions = new Map();
146
+ this.#topicPrefix = config.topicPrefix;
147
+ this.#controller = new AbortController();
148
+ this.signal = composeSignals(init.signal, this.#controller.signal);
149
+ // Establish the underlying MQTT.js connection unconditionally so `#mqtt` is always a live reference by the time the constructor returns. A synchronous failure
150
+ // (typically URL parsing) is wrapped in an Error whose `cause` carries the original error, so callers distinguish via `error.cause` rather than matching on
151
+ // mqtt.js's internal message text.
152
+ try {
153
+ // We connect with `rejectUnauthorized: false` deliberately. The broker URL is user-supplied and, in the typical Homebridge deployment, points at a local
154
+ // LAN broker (a mosquitto instance on the same network) that is frequently fronted by a self-signed certificate with no chain to a public CA. Enforcing strict
155
+ // certificate-chain verification would break those common self-signed setups and force users to hand-wire a CA bundle, so we accept the broker's certificate as
156
+ // presented. The trust boundary here is the user's own network and their own broker, not a public endpoint, which makes this the correct posture rather than a
157
+ // weakening of transport security.
158
+ this.#mqtt = connect(this.#brokerUrl, { reconnectPeriod: this.#reconnectInterval * 1000, rejectUnauthorized: false });
159
+ }
160
+ catch (error) {
161
+ throw new Error("MqttClient: connection setup failed.", { cause: error });
162
+ }
163
+ this.#wireMqttEvents(this.#mqtt);
164
+ // Teardown convergence point. `onAbort` registers the one-shot teardown handler AND runs it synchronously when the signal is already aborted at construction
165
+ // time (the AbortSignal spec does not re-dispatch historical events, so bare `addEventListener` would silently skip the handler for a pre-aborted parent).
166
+ // Running the handler in both paths means pre-aborted construction unwinds identically to a mid-session abort - the brief mqtt.js `connect()` call above is
167
+ // cancelled by `end(true)` before any network work proceeds.
168
+ onAbort(this.signal, () => this.#teardown());
169
+ }
170
+ /**
171
+ * Publish `payload` to `topic`, returning a promise that resolves when the broker acknowledges the publish, or rejects on failure or abort.
172
+ *
173
+ * The topic is prefixed with the configured {@link MqttConfig.topicPrefix} before being sent; callers supply the topic tail (for example, `"device1/status"`).
174
+ *
175
+ * @param topic - The relative topic (tail) to publish to.
176
+ * @param payload - The payload to publish. Buffers and strings are passed through unchanged.
177
+ * @param init - Optional per-publish options. See {@link MqttPublishInit}.
178
+ *
179
+ * @returns A promise that resolves once the broker acknowledges, or rejects on error or abort.
180
+ */
181
+ async publish(topic, payload, init = {}) {
182
+ const composed = composeSignals(this.signal, init.signal);
183
+ // Short-circuit pre-aborted signals before queueing anything into mqtt.js. Without this check, a publish issued after the client has already torn down would
184
+ // still enqueue inside mqtt.js's internal buffer (which is already being flushed by `end(true)`), producing a phantom write.
185
+ composed.throwIfAborted();
186
+ const full = this.#expandTopic(topic);
187
+ this.#log.debug("MQTT publish: %s.", full);
188
+ // Wrap mqtt.js's callback-style publish in a promise, then race it against the composed signal through `waitWithSignal` - the canonical primitive every other
189
+ // signal-aware wait in this library uses. `Promise.withResolvers` is the codebase-wide pattern for callback-bridged deferreds; using it here keeps the hop from
190
+ // mqtt.js's callback shape to a Promise on the same primitive every other wrap in HBPU uses.
191
+ const { promise: ackPromise, resolve, reject } = Promise.withResolvers();
192
+ this.#mqtt.publish(full, payload, (error) => {
193
+ if (error) {
194
+ reject(error);
195
+ return;
196
+ }
197
+ resolve();
198
+ });
199
+ return waitWithSignal(ackPromise, composed);
200
+ }
201
+ /**
202
+ * Subscribe to `topic` with the given handler. The topic is prefixed with the configured {@link MqttConfig.topicPrefix} before being registered with the broker.
203
+ * Multiple handlers may subscribe to the same topic; each gets independent delivery.
204
+ *
205
+ * @param topic - The relative topic (tail) to subscribe to.
206
+ * @param handler - Callback invoked with each received payload.
207
+ * @param init - Optional per-subscription options. See {@link MqttSubscribeInit}.
208
+ */
209
+ subscribe(topic, handler, init = {}) {
210
+ // Ignore subscribes that cannot produce any effect: the client is already dead or the caller handed us a pre-aborted per-subscription signal. Early-return keeps
211
+ // the subscription map honest - nothing gets registered that cannot receive.
212
+ if (this.signal.aborted || init.signal?.aborted) {
213
+ return;
214
+ }
215
+ const full = this.#expandTopic(topic);
216
+ this.#log.debug("MQTT subscribe: %s.", full);
217
+ let handlers = this.#subscriptions.get(full);
218
+ if (!handlers) {
219
+ handlers = new Set();
220
+ this.#subscriptions.set(full, handlers);
221
+ // First handler on this topic: issue the wire-level subscribe. Subsequent handlers on the same topic reuse the existing broker subscription, matching the
222
+ // "pay once per topic" cost model the MQTT protocol naturally provides.
223
+ this.#mqtt.subscribe(full);
224
+ }
225
+ handlers.add(handler);
226
+ // Per-subscription cleanup: compose the caller's signal with the connection-level signal so either teardown path fires the listener exactly once. Attaching the
227
+ // listener to the composed signal (rather than to `init.signal` directly) ensures the listener auto-releases when the client aborts even if the caller never
228
+ // aborts their per-subscription controller - the composed signal is what prevents the closure from pinning the handler past the connection's lifetime.
229
+ if (init.signal) {
230
+ const composed = composeSignals(this.signal, init.signal);
231
+ onAbort(composed, () => this.#removeHandler(full, handler));
232
+ }
233
+ }
234
+ /**
235
+ * Subscribe to the `/get` child of `topic`. When a `"true"` message arrives on the get topic, the provided `getValue` callback runs and its return value is
236
+ * published back on the parent topic. The classic HomeKit "get" pattern, wrapped once.
237
+ *
238
+ * @param topic - The relative topic (tail); the `/get` suffix is appended automatically.
239
+ * @param type - Human-readable label used in log messages (for example, `"Temperature"`).
240
+ * @param getValue - Callback returning the current value as a string, invoked on each incoming `"true"` message.
241
+ * @param init - Optional per-subscription options. See {@link MqttSubscribeInit}.
242
+ */
243
+ subscribeGet(topic, type, getValue, init = {}) {
244
+ this.subscribe(topic + "/get", (message) => {
245
+ const value = message.toString().toLowerCase();
246
+ // The get-pattern contract: only `"true"` triggers a republish. Other values (empty, `"false"`, noise) are ignored silently so the broker-side fan-out can
247
+ // request status without every listener responding.
248
+ if (value !== "true") {
249
+ return;
250
+ }
251
+ // Sequence both log lines through {@link logGetterPublishOutcome} so the routing between success and failure is expressible as a pure function and testable in
252
+ // isolation. The publish promise settles exactly once - either `.then` (success) or `.catch` (failure) runs - and each path forwards its outcome through the
253
+ // single routing point, keeping the log surface honest about what actually happened on the wire.
254
+ void this.publish(topic, getValue())
255
+ .then(() => logGetterPublishOutcome(this.#log, type, { ok: true }))
256
+ .catch((error) => logGetterPublishOutcome(this.#log, type, { error, ok: false }));
257
+ }, init);
258
+ }
259
+ /**
260
+ * Subscribe to the `/set` child of `topic`. Each incoming message invokes `setValue` with the lowercased normalized value, the raw message string, and an
261
+ * {@link AbortSignal} that composes the connection-level signal with the optional per-invocation `timeout`. Signal-aware setters forward that signal to cancellation-
262
+ * capable APIs so the setter's work actually stops when the timeout elapses or the client aborts; signal-unaware setters continue to run but the subscription slot
263
+ * is released either way, so a hanging setter cannot tie up the slot indefinitely.
264
+ *
265
+ * @param topic - The relative topic (tail); the `/set` suffix is appended automatically.
266
+ * @param type - Human-readable label used in log messages.
267
+ * @param setValue - Callback invoked with each received value. Receives three arguments: `(value, rawValue, signal)`. See {@link MqttSetHandler}.
268
+ * @param init - Optional per-subscription options including a handler-invocation `timeout`. See {@link MqttSubscribeSetInit}.
269
+ */
270
+ subscribeSet(topic, type, setValue, init = {}) {
271
+ const timeout = init.timeout;
272
+ this.subscribe(topic + "/set", async (message) => {
273
+ const rawValue = message.toString();
274
+ const value = rawValue.toLowerCase();
275
+ try {
276
+ // `runWithAbort` composes the connection-level signal with the optional per-invocation timeout and passes the composed signal to its factory. We forward that
277
+ // signal into the user's setter so signal-aware setters can actually cancel in-flight work on timeout or teardown. A module-level sentinel distinguishes "user
278
+ // setter ran to completion" from "runWithAbort returned null because the signal aborted," keeping cancellation on the `warn` log path rather than the error
279
+ // path. The `{ signal, timeout?: number }` option form accepts `timeout: undefined`, so a single call shape covers both the with-timeout and without-timeout
280
+ // cases.
281
+ const result = await runWithAbort(async (setterSignal) => {
282
+ await setValue(value, rawValue, setterSignal);
283
+ return SUBSCRIBE_SET_OK;
284
+ }, { signal: this.signal, timeout });
285
+ if (result !== SUBSCRIBE_SET_OK) {
286
+ this.#log.warn("MQTT: set handler for %s was cancelled before completion.", type);
287
+ return;
288
+ }
289
+ this.#log.info("MQTT: set message received for %s: %s.", type, value);
290
+ }
291
+ catch (error) {
292
+ this.#log.error("MQTT: error setting %s to %s: %s.", type, value, formatErrorMessage(error));
293
+ }
294
+ }, init);
295
+ }
296
+ /**
297
+ * Unsubscribe all handlers for the specified `(id, topic)` tuple. Reconstructs the topic using the configured {@link MqttConfig.topicPrefix}, removes the
298
+ * subscription from the internal map, and issues the wire-level unsubscribe. Preserved as a separate imperative verb for the mid-session feature-toggle pattern
299
+ * where the caller has the `(id, topic)` tuple but never retained a dedicated controller.
300
+ *
301
+ * Deliberately does not accept a `{ signal }` option: unsubscribe is synchronous and has nothing to cancel, and exposing a vestigial signal would suggest a
302
+ * cancellation semantic the method cannot deliver. Callers composing teardown through a signal remove handlers by aborting the per-subscription signal they passed
303
+ * to `subscribe*` instead.
304
+ *
305
+ * @param id - The device or accessory identifier portion of the topic. An empty string short-circuits the whole call.
306
+ * @param topic - The topic tail relative to the id.
307
+ */
308
+ unsubscribe(id, topic) {
309
+ // No-op on an aborted client: the wire-level subscribe has already been released by `#teardown`, and the subscription map is empty. Checking the signal up front
310
+ // lets subsequent `this.#mqtt` accesses run unguarded.
311
+ if (this.signal.aborted || !id) {
312
+ return;
313
+ }
314
+ const full = this.#expandTopic(id + "/" + topic);
315
+ this.#subscriptions.delete(full);
316
+ this.#mqtt.unsubscribe(full);
317
+ }
318
+ /**
319
+ * Abort the client and tear the connection down. Defaults to `HbpuAbortError("shutdown")` when no reason is supplied; explicit reasons pass through unchanged.
320
+ *
321
+ * Safe to call more than once. After this runs, every subsequent `publish`, `subscribe*`, or `unsubscribe` call is a no-op.
322
+ *
323
+ * @param reason - Optional abort reason. Typically an {@link HbpuAbortError}; platform errors also interoperate by convention.
324
+ */
325
+ abort(reason) {
326
+ if (this.aborted) {
327
+ return;
328
+ }
329
+ this.#controller.abort(reason ?? new HbpuAbortError("shutdown"));
330
+ }
331
+ /**
332
+ * `AsyncDisposable` implementation. Aborts the client (defaulting to `"shutdown"`), which tears the MQTT connection down and rejects any pending publishes through
333
+ * the regular teardown path.
334
+ *
335
+ * @returns A promise that resolves once teardown has been scheduled. MQTT.js's `end(true)` completes synchronously for userland purposes, so the awaited microtask
336
+ * is all the ordering the caller needs.
337
+ */
338
+ async [Symbol.asyncDispose]() {
339
+ this.abort();
340
+ }
341
+ /**
342
+ * `true` once `this.signal` has aborted. Derived from the signal; no independent state.
343
+ */
344
+ get aborted() {
345
+ return this.signal.aborted;
346
+ }
347
+ // Wire up the MQTT.js event handlers that drive message dispatch, connection lifecycle logging, and error escalation. Extracted into a helper so the constructor
348
+ // reads as a linear flow (set up state -> compose signal -> connect -> wire events).
349
+ #wireMqttEvents(client) {
350
+ client.on("connect", () => {
351
+ this.#isConnected = true;
352
+ // The replace strips the password from the broker URL's `user:password@host` userinfo before logging, so credentials never reach the log stream. The capture
353
+ // groups preserve the scheme-plus-username prefix and the `@host` suffix while swapping only the password segment for REDACTED.
354
+ this.#log.info("MQTT Broker: Connected to %s (topic: %s).", this.#brokerUrl.replace(/^(.*:\/\/.*:)(.*)(@.*)$/, "$1REDACTED$3"), this.#topicPrefix);
355
+ });
356
+ client.on("close", () => {
357
+ // Only log a disconnect when we had previously connected - otherwise we would spam the log on every failed connect attempt during a retry loop. Tracking
358
+ // `#isConnected` as an edge flag (true on connect, false on close) captures the transition cleanly; mqtt.js's own `.connected` property is already `false` by
359
+ // the time this listener fires, so it cannot answer the "were we previously connected?" question on its own.
360
+ if (!this.#isConnected) {
361
+ return;
362
+ }
363
+ this.#isConnected = false;
364
+ this.#log.info("MQTT Broker: Connection closed.");
365
+ });
366
+ client.on("message", (topic, message) => {
367
+ const handlers = this.#subscriptions.get(topic);
368
+ if (!handlers) {
369
+ return;
370
+ }
371
+ // Snapshot the handler set before iterating so a handler that removes itself (or a sibling) mid-dispatch cannot alter which handlers this dispatch pass
372
+ // invokes. Iterating the live Set directly would still be spec-correct - Set iteration order and hole-skipping on delete are well-defined - but it would make
373
+ // the invocation set implicit and mutation-order-dependent. The snapshot cost is O(n) in handlers per topic, same as the dispatch itself, so the overhead is
374
+ // negligible. Sync handlers run inline so their effects are observable on the calling turn (matching
375
+ // EventEmitter's dispatch model and the public contract these tests assert); only the per-handler error handling is split between the sync and async legs,
376
+ // both of which route through the same `logHandlerError` so the log surface is single-source-of-truth. One bad handler logs but does not destabilize the
377
+ // connection or skip its siblings.
378
+ const logHandlerError = (err) => {
379
+ this.#log.error("MQTT: handler for %s threw: %s.", topic, formatErrorMessage(err));
380
+ };
381
+ for (const handler of [...handlers]) {
382
+ try {
383
+ const result = handler(message);
384
+ if (result instanceof Promise) {
385
+ void markHandled(result.catch(logHandlerError));
386
+ }
387
+ }
388
+ catch (err) {
389
+ logHandlerError(err);
390
+ }
391
+ }
392
+ });
393
+ client.on("error", (error) => {
394
+ // Route the error through the pure {@link routeMqttBrokerError} helper, which selects the right log line for each known errno code (and the default branch for
395
+ // unknown codes). The wiring layer's only side-effect responsibility is acting on the returned `endTransport` flag - the routing logic is testable in isolation
396
+ // through direct invocation against synthetic errors, so this `client.on("error", ...)` callback stays a thin adapter from the mqtt.js event surface to the
397
+ // pure routing function.
398
+ const result = routeMqttBrokerError(error, this.#log, this.#reconnectInterval);
399
+ if (result.endTransport) {
400
+ this.#mqtt.end(true);
401
+ }
402
+ });
403
+ }
404
+ // Remove a single handler from a topic's subscription set. When the last handler leaves, the underlying MQTT subscription is dropped so idle topics do not continue
405
+ // consuming broker bandwidth. Safe to call for handlers that were never registered (silently no-ops) and safe to call after the connection has aborted (the
406
+ // subscription map is cleared by `#teardown`, so the initial `!handlers` check short-circuits before any `#mqtt` interaction).
407
+ #removeHandler(full, handler) {
408
+ const handlers = this.#subscriptions.get(full);
409
+ if (!handlers) {
410
+ return;
411
+ }
412
+ handlers.delete(handler);
413
+ if (handlers.size === 0) {
414
+ this.#subscriptions.delete(full);
415
+ this.#mqtt.unsubscribe(full);
416
+ }
417
+ }
418
+ // Expand a relative topic tail into the full broker-facing topic. Always concatenates with the configured prefix; the caller is responsible for any id structure
419
+ // inside the tail. Keeping this as a single small helper means every publish / subscribe site goes through identical expansion logic, avoiding drift.
420
+ #expandTopic(topic) {
421
+ return this.#topicPrefix + "/" + topic;
422
+ }
423
+ // Single teardown convergence point, fired exactly once when `this.signal` aborts. Clears the subscription map so multi-consumer fan-out stops delivering messages
424
+ // on the way out, then ends the MQTT.js connection with `force = true` so any in-flight publish / subscribe packets are dropped rather than awaited - the client is
425
+ // unambiguously done, and the underlying library's reconnect logic exits permanently. The `#mqtt` reference is not nulled: callers never reach `#mqtt` accesses
426
+ // when the signal is aborted (every public method short-circuits on `signal.aborted`), and keeping the reference preserves the `readonly #mqtt: MqttJsClient`
427
+ // guarantee that lets TypeScript drop every non-null assertion in the live paths.
428
+ #teardown() {
429
+ this.#subscriptions.clear();
430
+ this.#mqtt.end(true);
431
+ }
432
+ }
433
+ //# sourceMappingURL=mqttClient.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mqttClient.js","sourceRoot":"","sources":["../src/mqttClient.ts"],"names":[],"mappings":"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"}
package/dist/service.d.ts CHANGED
@@ -4,18 +4,38 @@
4
4
  * @module
5
5
  */
6
6
  import type { PlatformAccessory, Service, WithUUID } from "homebridge";
7
- import { type Nullable } from "./util.js";
7
+ import type { Nullable } from "./util.ts";
8
+ /**
9
+ * The constructor shape {@link acquireService} expects for a Service subclass. Every HAP Service subclass (Lightbulb, Switch, Television, ...) satisfies both halves
10
+ * of this intersection naturally:
11
+ *
12
+ * - `WithUUID<typeof Service>` - provides the static `UUID` property AND assignability to HAP's `getService` / `getServiceById` lookup APIs that require this exact
13
+ * shape.
14
+ * - `new (displayName?: string, subtype?: string) => T` - the actual runtime constructor signature every Service subclass exposes; supersedes the BASE Service
15
+ * class's `(displayName, UUID, subtype?)` signature that the wider `WithUUID<typeof Service>` would otherwise surface.
16
+ *
17
+ * Intersecting both shapes lets the function invoke `new serviceType(sanitized, subtype)` against an honest type-checked signature without any cast or non-null
18
+ * assertion.
19
+ *
20
+ * @typeParam T - The concrete Service subclass produced by the constructor. Inferred from the call site so callers receive the specific subclass type back.
21
+ *
22
+ * @category Accessory
23
+ */
24
+ export type AcquireServiceTarget<T extends Service = Service> = WithUUID<typeof Service> & (new (displayName?: string, subtype?: string) => T);
8
25
  /**
9
26
  * Utility method that either creates a new service on an accessory if needed, or returns an existing one. Optionally, it executes a callback to initialize a new
10
27
  * service instance. Additionally, the various name characteristics of the service are set to the specified name, and optionally added if necessary.
11
28
  *
29
+ * @typeParam T - The concrete Service subclass being acquired. Inferred from `serviceType` so callers receive the specific subclass type back rather than
30
+ * the wider `Service` type.
12
31
  * @param accessory - The Homebridge accessory to check or modify.
13
- * @param serviceType - The type of service to instantiate or retrieve.
32
+ * @param serviceType - The type of service to instantiate or retrieve. Must be a HAP Service subclass with the standard `(displayName?, subtype?)` constructor;
33
+ * see {@link AcquireServiceTarget}.
14
34
  * @param name - Name to be displayed to the end user for this service.
15
35
  * @param subtype - Optional service subtype to uniquely identify the service.
16
36
  * @param onServiceCreate - Optional callback invoked only when a new service is created, receiving the new service as its argument.
17
37
  *
18
- * @returns Returns the created or retrieved service, or `null` if service creation failed.
38
+ * @returns Returns the created or retrieved service. Construction failures throw rather than returning `null`.
19
39
  *
20
40
  * @remarks
21
41
  * This method ensures that the service's display name and available name characteristics are updated to the specified name. If `onServiceCreate` is provided,
@@ -25,10 +45,11 @@ import { type Nullable } from "./util.js";
25
45
  *
26
46
  * @example
27
47
  * ```typescript
28
- * // Example: Ensure a Lightbulb service exists with a user-friendly name, and initialize it if newly created.
29
- * const lightbulbService = acquireService(accessory, hap.Service.Lightbulb, "Living Room Lamp", undefined, (svc: Service): void => {
48
+ * // Example: Ensure a Lightbulb service exists with a user-friendly name, and initialize it if newly created. The return type is narrowed to `Lightbulb | null`,
49
+ * // so callers can invoke subclass-specific behavior on the result without casts.
50
+ * const lightbulbService = acquireService(accessory, hap.Service.Lightbulb, "Living Room Lamp", undefined, (svc): void => {
30
51
  *
31
- * // Called only if the service is newly created.
52
+ * // Called only if the service is newly created. `svc` is typed as `Lightbulb` here.
32
53
  * svc.setCharacteristic(hap.Characteristic.On, false);
33
54
  * });
34
55
  *
@@ -39,11 +60,11 @@ import { type Nullable } from "./util.js";
39
60
  * }
40
61
  * ```
41
62
  *
42
- * @see setServiceName updates the newly created (or existing) services name-related characteristics.
43
- * @see validService validate or prune services after acquisition.
63
+ * @see setServiceName - updates the newly created (or existing) service's name-related characteristics.
64
+ * @see validService - validate or prune services after acquisition.
44
65
  * @category Accessory
45
66
  */
46
- export declare function acquireService(accessory: PlatformAccessory, serviceType: WithUUID<typeof Service>, name: string, subtype?: string, onServiceCreate?: (svc: Service) => void): Nullable<Service>;
67
+ export declare function acquireService<T extends Service>(accessory: PlatformAccessory, serviceType: AcquireServiceTarget<T>, name: string, subtype?: string, onServiceCreate?: (svc: T) => void): Nullable<T>;
47
68
  /**
48
69
  * Validates whether a specific service should exist on the given accessory, removing the service if it fails validation.
49
70
  *
@@ -74,17 +95,44 @@ export declare function acquireService(accessory: PlatformAccessory, serviceType
74
95
  * validService(accessory, Service.Switch, (hasService) => hasService || config.enableSwitch);
75
96
  * ```
76
97
  *
77
- * @see acquireService to add or retrieve services.
98
+ * @see acquireService - to add or retrieve services.
78
99
  * @category Accessory
79
100
  */
80
101
  export declare function validService(accessory: PlatformAccessory, serviceType: WithUUID<typeof Service>, validate: boolean | ((hasService: boolean) => boolean), subtype?: string): boolean;
81
102
  /**
82
- * Retrieves the primary name of a service, preferring the ConfiguredName characteristic over the Name characteristic.
103
+ * Build a `validService` predicate for a service gated on a hardware capability and a user toggle, applying an additive-eager / subtractive-conservative asymmetry
104
+ * between the two: the user `toggle` is absolute - when false, the service is removed - while the hardware `capability` is conservative - an existing service is kept
105
+ * through a transient capability-false, and a new service is created only when the capability reports.
106
+ *
107
+ * @param options - The `capability` and `toggle` inputs for the gate.
108
+ *
109
+ * @returns A `validService` function-form predicate, `(hasService) => toggle && (hasService || capability)`.
110
+ *
111
+ * @remarks
112
+ * Pass the result as `validService`'s `validate` argument. The asymmetry keeps a capability-gated service from being removed during a transient window in which the
113
+ * device under-reports its capability, while still honoring a user who disables the service. A service with no user toggle should gate on its capability directly.
114
+ *
115
+ * @example
116
+ * ```typescript
117
+ * // Keep the service while its user toggle is on, add it when the capability reports, and keep an existing one through a transient capability-false.
118
+ * validService(accessory, Service.Switch, capabilityGate({ capability: deviceReportsFeature, toggle: config.enableSwitch }));
119
+ * ```
120
+ *
121
+ * @see validService - consumes the returned predicate.
122
+ * @category Accessory
123
+ */
124
+ export declare function capabilityGate({ capability, toggle }: {
125
+ capability: boolean;
126
+ toggle: boolean;
127
+ }): (hasService: boolean) => boolean;
128
+ /**
129
+ * Retrieves the primary name of a service, preferring the ConfiguredName characteristic over the Name characteristic. This is a pure read - it never mutates the
130
+ * service.
83
131
  *
84
132
  * @param service - The service from which to retrieve the name.
85
- * @returns The configured or display name of the service, or `undefined` if neither is set.
133
+ * @returns The configured or display name of the service, or `undefined` if neither characteristic is present or set.
86
134
  *
87
- * @see setServiceName to update the current name on a service.
135
+ * @see setServiceName - to update the current name on a service.
88
136
  * @category Accessory
89
137
  */
90
138
  export declare function getServiceName(service?: Service): string | undefined;
@@ -98,8 +146,9 @@ export declare function getServiceName(service?: Service): string | undefined;
98
146
  * This function ensures the name is validated, updates the service's `displayName`, and sets the `ConfiguredName` and `Name`
99
147
  * characteristics when supported by the service type.
100
148
  *
101
- * @see acquireService to add or retrieve services.
102
- * @see getServiceName to retrieve the current name set on a service.
149
+ * @see acquireService - to add or retrieve services.
150
+ * @see getServiceName - to retrieve the current name set on a service.
103
151
  * @category Accessory
104
152
  */
105
153
  export declare function setServiceName(service: Service, name: string): void;
154
+ //# sourceMappingURL=service.d.ts.map