homebridge-navilink 0.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 (82) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +202 -0
  3. package/README.md +161 -0
  4. package/SECURITY.md +73 -0
  5. package/config.schema.json +138 -0
  6. package/dist/api/channel.d.ts +94 -0
  7. package/dist/api/channel.js +334 -0
  8. package/dist/api/http.d.ts +68 -0
  9. package/dist/api/http.js +205 -0
  10. package/dist/api/identity.d.ts +51 -0
  11. package/dist/api/identity.js +80 -0
  12. package/dist/api/index.d.ts +17 -0
  13. package/dist/api/index.js +33 -0
  14. package/dist/api/mqtt-codec.d.ts +181 -0
  15. package/dist/api/mqtt-codec.js +377 -0
  16. package/dist/api/mqtt.d.ts +178 -0
  17. package/dist/api/mqtt.js +450 -0
  18. package/dist/api/protocol.d.ts +212 -0
  19. package/dist/api/protocol.js +286 -0
  20. package/dist/api/rest.d.ts +139 -0
  21. package/dist/api/rest.js +317 -0
  22. package/dist/api/sigv4.d.ts +70 -0
  23. package/dist/api/sigv4.js +123 -0
  24. package/dist/api/topics.d.ts +75 -0
  25. package/dist/api/topics.js +90 -0
  26. package/dist/devices/base-accessory.d.ts +127 -0
  27. package/dist/devices/base-accessory.js +232 -0
  28. package/dist/devices/dhw-accessory.d.ts +62 -0
  29. package/dist/devices/dhw-accessory.js +100 -0
  30. package/dist/devices/fault-accessory.d.ts +31 -0
  31. package/dist/devices/fault-accessory.js +73 -0
  32. package/dist/devices/heating-accessory.d.ts +63 -0
  33. package/dist/devices/heating-accessory.js +128 -0
  34. package/dist/devices/host.d.ts +77 -0
  35. package/dist/devices/host.js +23 -0
  36. package/dist/devices/index.d.ts +17 -0
  37. package/dist/devices/index.js +33 -0
  38. package/dist/devices/power-accessory.d.ts +32 -0
  39. package/dist/devices/power-accessory.js +86 -0
  40. package/dist/devices/probe-accessory.d.ts +45 -0
  41. package/dist/devices/probe-accessory.js +108 -0
  42. package/dist/devices/recirculation-accessory.d.ts +47 -0
  43. package/dist/devices/recirculation-accessory.js +122 -0
  44. package/dist/devices/thermostat-accessory.d.ts +129 -0
  45. package/dist/devices/thermostat-accessory.js +372 -0
  46. package/dist/discovery.d.ts +99 -0
  47. package/dist/discovery.js +423 -0
  48. package/dist/index.d.ts +11 -0
  49. package/dist/index.js +15 -0
  50. package/dist/platform.d.ts +120 -0
  51. package/dist/platform.js +425 -0
  52. package/dist/session.d.ts +243 -0
  53. package/dist/session.js +717 -0
  54. package/dist/settings.d.ts +218 -0
  55. package/dist/settings.js +240 -0
  56. package/dist/types/index.d.ts +265 -0
  57. package/dist/types/index.js +58 -0
  58. package/dist/ui-api.d.ts +27 -0
  59. package/dist/ui-api.js +39 -0
  60. package/dist/utils/context.d.ts +32 -0
  61. package/dist/utils/context.js +74 -0
  62. package/dist/utils/errors.d.ts +69 -0
  63. package/dist/utils/errors.js +118 -0
  64. package/dist/utils/index.d.ts +15 -0
  65. package/dist/utils/index.js +31 -0
  66. package/dist/utils/redact.d.ts +85 -0
  67. package/dist/utils/redact.js +210 -0
  68. package/dist/utils/serial.d.ts +18 -0
  69. package/dist/utils/serial.js +24 -0
  70. package/dist/utils/temperature.d.ts +114 -0
  71. package/dist/utils/temperature.js +150 -0
  72. package/dist/utils/timing.d.ts +68 -0
  73. package/dist/utils/timing.js +91 -0
  74. package/dist/utils/validators.d.ts +96 -0
  75. package/dist/utils/validators.js +353 -0
  76. package/docs/FEATURES.md +80 -0
  77. package/docs/PROTOCOL.md +200 -0
  78. package/docs/README-DETAILED.md +312 -0
  79. package/homebridge-ui/public/index.html +123 -0
  80. package/homebridge-ui/public/index.js +503 -0
  81. package/homebridge-ui/server.js +174 -0
  82. package/package.json +87 -0
@@ -0,0 +1,317 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 tbaur
4
+ *
5
+ * Licensed under the Apache License, Version 2.0
6
+ * See LICENSE file for full license text
7
+ *
8
+ * @fileoverview The NaviLink REST surface: sign in, and list what the account
9
+ * owns.
10
+ *
11
+ * Three calls, and they do very little. Everything that changes arrives over
12
+ * MQTT: a temperature, a setpoint, a fault. REST exists to get the credentials
13
+ * that make MQTT possible and to learn which appliances the account has.
14
+ *
15
+ * This is an unofficial API. There is no specification, no versioning promise
16
+ * and no deprecation notice; it is the interface the NaviLink mobile app uses.
17
+ * Everything here therefore treats an unexpected response as a protocol error
18
+ * with a readable message. It does not reach into a shape it assumes is there.
19
+ * When Navien changes something, the plugin should say what it did not
20
+ * recognise.
21
+ *
22
+ * **Every value on this path is a credential or personal data.** The request
23
+ * body carries the password; the response carries a JWT pair, temporary AWS
24
+ * IAM credentials, the account holder's name, and, from `device/info`, the
25
+ * street address and coordinates of the installation. Nothing from a response
26
+ * is logged, and the fields the plugin does not need are never read out of it.
27
+ */
28
+ Object.defineProperty(exports, "__esModule", { value: true });
29
+ exports.NaviLinkRest = void 0;
30
+ const settings_1 = require("../settings");
31
+ const errors_1 = require("../utils/errors");
32
+ const http_1 = require("./http");
33
+ /**
34
+ * How long a session lasts when the cloud's answer is not believable.
35
+ *
36
+ * The sign-in response reports two lifetimes, and neither their unit nor which
37
+ * one governs the AWS credentials is documented. They are read as seconds and
38
+ * sanity-checked; anything outside a plausible band falls back to this, which
39
+ * is short enough to be safe against a much shorter real lifetime and long
40
+ * enough not to be a sign-in loop.
41
+ */
42
+ const FALLBACK_SESSION_MS = 50 * 60 * 1_000;
43
+ /** Page size `device/list` requires. */
44
+ const DEVICE_LIST_PAGE_SIZE = 20;
45
+ /** Hard stop so a cloud that always returns a full page cannot loop forever. */
46
+ const MAX_DEVICE_LIST_PAGES = 10;
47
+ /** Shortest lifetime believed from the cloud. Below this, use the fallback. */
48
+ const MIN_BELIEVABLE_SESSION_MS = 5 * 60 * 1_000;
49
+ /** Longest lifetime believed from the cloud. Above this, use the fallback. */
50
+ const MAX_BELIEVABLE_SESSION_MS = 24 * 60 * 60 * 1_000;
51
+ /** Talks to the NaviLink REST service. */
52
+ class NaviLinkRest {
53
+ log;
54
+ post;
55
+ now;
56
+ signal;
57
+ constructor(options) {
58
+ this.log = options.log;
59
+ this.post = options.post ?? http_1.postJson;
60
+ this.now = options.now ?? Date.now;
61
+ this.signal = options.signal;
62
+ }
63
+ /**
64
+ * Exchange an email and password for a session.
65
+ *
66
+ * Distinguishes a rejected account from an unreachable cloud, because the
67
+ * two need opposite responses: a wrong password must stop the plugin
68
+ * retrying, and a cloud outage must not.
69
+ */
70
+ async signIn(email, password) {
71
+ if (typeof email !== 'string' || typeof password !== 'string') {
72
+ // A JS caller that passes `{ email, password }` as one argument would
73
+ // otherwise POST that object as `userId`. The cloud answers
74
+ // COMMON_BAD_REQUEST, which looks like a credential problem.
75
+ throw new TypeError('sign-in needs an email and a password as two strings');
76
+ }
77
+ const response = await this.call('/user/sign-in', { userId: email, password });
78
+ const body = this.readBody(response.body, 'sign-in');
79
+ const data = asRecord(body.data);
80
+ const token = asRecord(data?.token);
81
+ const accessToken = asNonEmptyString(token?.accessToken);
82
+ if (accessToken === undefined || token === undefined || data === undefined) {
83
+ // Deliberately not keyed on the status code. **This API answers a
84
+ // rejected password with HTTP 200** and an error in the body, so a
85
+ // status check alone classifies a wrong password as a transient
86
+ // protocol fault, and then retries it every few seconds until the
87
+ // account is locked. The presence of a token is the only reliable
88
+ // discriminator, so that is what decides success here.
89
+ throw this.signInFailure(response.status, body);
90
+ }
91
+ const accessKeyId = asNonEmptyString(token.accessKeyId);
92
+ const secretKey = asNonEmptyString(token.secretKey);
93
+ const sessionToken = asNonEmptyString(token.sessionToken);
94
+ if (accessKeyId === undefined || secretKey === undefined || sessionToken === undefined) {
95
+ throw new errors_1.ProtocolError('the sign-in response carried no AWS IoT credentials, so live status is not available');
96
+ }
97
+ const userSeq = readUserSeq(data);
98
+ if (userSeq === undefined) {
99
+ throw new errors_1.ProtocolError('the sign-in response carried no account identifier');
100
+ }
101
+ return {
102
+ userSeq,
103
+ accessToken,
104
+ refreshToken: asNonEmptyString(token.refreshToken),
105
+ credentials: {
106
+ accessKeyId,
107
+ secretKey,
108
+ sessionToken,
109
+ endpoint: settings_1.IOT_ENDPOINT,
110
+ region: settings_1.IOT_REGION,
111
+ },
112
+ expiresAt: this.now() + this.sessionLifetimeMs(token),
113
+ };
114
+ }
115
+ /**
116
+ * List the gateways on the account.
117
+ *
118
+ * `count` is a page size the API requires rather than a limit worth
119
+ * configuring. Pages are walked until a short one arrives, so a 21st
120
+ * gateway is not dropped. A cloud that never sends a short page is capped.
121
+ */
122
+ async listDevices(input) {
123
+ const found = [];
124
+ for (let page = 0; page < MAX_DEVICE_LIST_PAGES; page += 1) {
125
+ const { entries, isFull } = await this.listDevicePage(input, page * DEVICE_LIST_PAGE_SIZE);
126
+ found.push(...entries);
127
+ if (!isFull) {
128
+ return found;
129
+ }
130
+ }
131
+ this.log.warn(`the account listed ${MAX_DEVICE_LIST_PAGES * DEVICE_LIST_PAGE_SIZE} gateways, `
132
+ + 'which is this plugin\'s cap; any further gateway is not shown');
133
+ return found;
134
+ }
135
+ /** One page of the device list, already parsed. */
136
+ async listDevicePage(input, offset) {
137
+ const response = await this.call('/device/list', { offset, count: DEVICE_LIST_PAGE_SIZE, userId: input.email }, input.accessToken);
138
+ const body = this.readBody(response.body, 'device list');
139
+ if (response.status === 401 || response.status === 403) {
140
+ throw new errors_1.AuthenticationError('the cloud rejected the session token when listing devices');
141
+ }
142
+ if (response.status !== 200) {
143
+ throw new errors_1.ProtocolError(`the device list failed: ${describeApiFailure(response.status, body)}`);
144
+ }
145
+ // Two shapes have been seen: a bare array, and an object wrapping one.
146
+ // Both are accepted rather than one being declared correct, because there
147
+ // is no specification to be right about.
148
+ const data = body.data;
149
+ const raw = Array.isArray(data)
150
+ ? data
151
+ : (asRecord(data)?.deviceList ?? asRecord(data)?.devices);
152
+ if (!Array.isArray(raw)) {
153
+ throw new errors_1.ProtocolError('the device list response did not contain a list of devices');
154
+ }
155
+ return {
156
+ entries: raw.flatMap((entry) => {
157
+ const device = this.readListedDevice(entry);
158
+ return device === undefined ? [] : [device];
159
+ }),
160
+ isFull: raw.length >= DEVICE_LIST_PAGE_SIZE,
161
+ };
162
+ }
163
+ /**
164
+ * Read a single device's detail.
165
+ *
166
+ * Used only to learn the firmware revision, which is worth having in
167
+ * Accessory Information and in a bug report. **The response also carries the
168
+ * installation's street address and coordinates**; those fields are never
169
+ * read, so they cannot reach a log, an accessory context or a capture.
170
+ */
171
+ async readFirmware(input) {
172
+ const response = await this.call('/device/info', {
173
+ macAddress: input.macAddress,
174
+ additionalValue: input.additionalValue,
175
+ userId: input.email,
176
+ }, input.accessToken);
177
+ if (response.status !== 200) {
178
+ // Not fatal, and deliberately quiet: firmware is a nicety, and this
179
+ // endpoint has been observed answering 403 on accounts where the device
180
+ // list works perfectly well.
181
+ this.log.debug(`device/info answered HTTP ${response.status}; firmware will be unknown`);
182
+ return undefined;
183
+ }
184
+ const body = this.readBody(response.body, 'device info');
185
+ const info = asRecord(asRecord(body.data)?.deviceInfo);
186
+ return asNonEmptyString(info?.fwVersion);
187
+ }
188
+ async call(path, body, accessToken) {
189
+ // The path, never the body: the body of the very first call is the
190
+ // password.
191
+ this.log.debug(`POST ${path}`);
192
+ return this.post(`${settings_1.API_BASE}${path}`, body, {
193
+ connectTimeoutMs: settings_1.CONNECT_TIMEOUT_MS,
194
+ totalTimeoutMs: settings_1.REST_TIMEOUT_MS,
195
+ maxBytes: settings_1.MAX_REST_BYTES,
196
+ // No `Bearer` prefix. The API wants the raw token, and sending a
197
+ // correctly-formed bearer header gets a 401.
198
+ ...(accessToken === undefined ? {} : { headers: { authorization: accessToken } }),
199
+ ...(this.signal === undefined ? {} : { signal: this.signal }),
200
+ });
201
+ }
202
+ readBody(text, what) {
203
+ const parsed = (0, http_1.parseJsonBody)(text);
204
+ const record = asRecord(parsed);
205
+ if (record === undefined) {
206
+ throw new errors_1.ProtocolError(`the ${what} response was not a JSON object`);
207
+ }
208
+ return record;
209
+ }
210
+ /**
211
+ * Turn a failed sign-in into the right kind of error.
212
+ *
213
+ * The distinction is the point. `AuthenticationError` with
214
+ * `credentialsRejected` stops the plugin trying again, because repeating a
215
+ * wrong password is how an account gets locked out. Anything else is
216
+ * transient and must be retried, because a cloud that is briefly unwell is
217
+ * not a reason to require the user to restart Homebridge.
218
+ */
219
+ signInFailure(status, body) {
220
+ const message = asNonEmptyString(body.msg)?.toUpperCase() ?? '';
221
+ if (message.includes('USER_NOT_FOUND')) {
222
+ return new errors_1.AuthenticationError('NaviLink does not recognise that email address', { credentialsRejected: true });
223
+ }
224
+ if (message.includes('PASSWORD') || message.includes('INVALID_USER')) {
225
+ return new errors_1.AuthenticationError('NaviLink rejected the email address or password', { credentialsRejected: true });
226
+ }
227
+ if (message.length > 0) {
228
+ // The cloud named a fault we do not have a mapping for. Not treated as
229
+ // a rejected credential, because guessing that would stop the plugin
230
+ // retrying something that may well be transient.
231
+ return new errors_1.ProtocolError(`sign-in failed: ${describeApiFailure(status, body)}`);
232
+ }
233
+ return new errors_1.ProtocolError(`sign-in did not return a token: ${describeApiFailure(status, body)}`);
234
+ }
235
+ /**
236
+ * Decide how long this session is good for.
237
+ *
238
+ * The response reports `authorizationExpiresIn` and
239
+ * `authenticationExpiresIn`. Neither is documented, the unit is not stated,
240
+ * and which one governs the AWS credentials rather than the JWT is not
241
+ * obvious from the names. So: read both as seconds, take the shorter, and
242
+ * only believe it if it lands in a plausible band. A value outside that band
243
+ * means the reading is wrong, and a wrong reading in the optimistic
244
+ * direction is a session that dies mid-winter without reconnecting.
245
+ */
246
+ sessionLifetimeMs(token) {
247
+ const candidates = [token.authorizationExpiresIn, token.authenticationExpiresIn]
248
+ .map((value) => (typeof value === 'number' && Number.isFinite(value) ? value * 1_000 : undefined))
249
+ .filter((value) => value !== undefined)
250
+ .filter((value) => value >= MIN_BELIEVABLE_SESSION_MS && value <= MAX_BELIEVABLE_SESSION_MS);
251
+ if (candidates.length === 0) {
252
+ this.log.debug('the cloud did not report a believable session lifetime; '
253
+ + `assuming ${Math.round(FALLBACK_SESSION_MS / 60_000)} minutes`);
254
+ return FALLBACK_SESSION_MS;
255
+ }
256
+ return Math.min(...candidates);
257
+ }
258
+ /**
259
+ * Read one device-list entry, skipping anything unusable.
260
+ *
261
+ * Skipped rather than thrown on: an account with one unrecognised gateway
262
+ * and one good one should expose the good one.
263
+ */
264
+ readListedDevice(entry) {
265
+ // Both a wrapped and a bare shape have been seen in the wild.
266
+ const record = asRecord(asRecord(entry)?.deviceInfo) ?? asRecord(entry);
267
+ const macAddress = asNonEmptyString(record?.macAddress)?.toLowerCase();
268
+ if (record === undefined || macAddress === undefined) {
269
+ this.log.debug('skipping a device-list entry with no MAC address');
270
+ return undefined;
271
+ }
272
+ return {
273
+ macAddress,
274
+ additionalValue: typeof record.additionalValue === 'string' ? record.additionalValue : '',
275
+ deviceType: typeof record.deviceType === 'number' ? record.deviceType : 1,
276
+ homeSeq: String(record.homeSeq ?? ''),
277
+ deviceName: asNonEmptyString(record.deviceName) ?? 'NaviLink',
278
+ connected: typeof record.connected === 'number' ? record.connected : 0,
279
+ };
280
+ }
281
+ }
282
+ exports.NaviLinkRest = NaviLinkRest;
283
+ /** Describe an API failure without quoting a body that may hold a token. */
284
+ function describeApiFailure(status, body) {
285
+ const message = asNonEmptyString(body.msg);
286
+ const code = typeof body.code === 'number' ? body.code : undefined;
287
+ if (message !== undefined) {
288
+ return `${message}${code === undefined ? '' : ` (code ${code})`}`;
289
+ }
290
+ return `HTTP ${status}`;
291
+ }
292
+ /**
293
+ * The account identifier, wherever this response happens to put it.
294
+ *
295
+ * Observed under `userInfo.userSeq`. Read defensively because it is not
296
+ * optional: it appears in every MQTT response topic, and a session without it
297
+ * can subscribe to nothing.
298
+ */
299
+ function readUserSeq(data) {
300
+ const direct = data.userSeq;
301
+ if (typeof direct === 'string' || typeof direct === 'number') {
302
+ return String(direct);
303
+ }
304
+ const nested = asRecord(data.userInfo)?.userSeq;
305
+ if (typeof nested === 'string' || typeof nested === 'number') {
306
+ return String(nested);
307
+ }
308
+ return undefined;
309
+ }
310
+ function asRecord(value) {
311
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
312
+ ? value
313
+ : undefined;
314
+ }
315
+ function asNonEmptyString(value) {
316
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
317
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Copyright (c) 2026 tbaur
3
+ *
4
+ * Licensed under the Apache License, Version 2.0
5
+ * See LICENSE file for full license text
6
+ *
7
+ * @fileoverview AWS Signature Version 4, for a pre-signed AWS IoT WebSocket URL.
8
+ *
9
+ * AWS IoT's WebSocket transport takes no password on the socket: authority is
10
+ * the signature in the URL's query string. This plugin signs a
11
+ * `wss://…/mqtt` URL with the temporary IAM credentials that
12
+ * `POST /user/sign-in` hands back, and connects to that.
13
+ *
14
+ * Only the tiny slice of SigV4 this needs is implemented: one service, one
15
+ * signed header, an empty payload and no request body. The full algorithm has
16
+ * a great deal more in it, and an `@aws-sdk/*` dependency to do this would be
17
+ * several megabytes to build one string.
18
+ *
19
+ * **The URL this produces is a credential.** It carries the session token, the
20
+ * access key id and a signature over them, and anyone holding it can speak to
21
+ * the account's IoT endpoint until it expires. It must never be logged, never
22
+ * put in an error message, and never written to a capture. `redactSecrets`
23
+ * knows the query parameter names, but the primary defence is that the
24
+ * transport logs the endpoint host and never the URL.
25
+ */
26
+ /** Temporary IAM credentials, as `POST /user/sign-in` returns them. */
27
+ export interface IotCredentials {
28
+ accessKeyId: string;
29
+ secretKey: string;
30
+ sessionToken: string;
31
+ /** The account's `…-ats.iot.<region>.amazonaws.com` endpoint. */
32
+ endpoint: string;
33
+ region: string;
34
+ }
35
+ /**
36
+ * Percent-encode for a canonical request.
37
+ *
38
+ * `encodeURIComponent` is close but not identical to the AWS rule: it leaves
39
+ * `*` alone, which AWS requires encoded, and it encodes `~`, which AWS
40
+ * requires left alone. Both appear in session tokens, so getting either wrong
41
+ * produces a signature mismatch that reads as "not authorised" and sends you
42
+ * looking at IAM policy instead of at this function.
43
+ */
44
+ export declare function uriEncode(value: string): string;
45
+ /** The two timestamp forms SigV4 wants, derived from one instant. */
46
+ export declare function amzTimestamps(now?: Date): {
47
+ amzDate: string;
48
+ dateStamp: string;
49
+ };
50
+ /**
51
+ * Build a signed `wss://` URL for the account's IoT endpoint.
52
+ *
53
+ * `signHostWithPort` selects what goes in the signed `host` header. Measured
54
+ * against the NaviLink endpoint: signing the bare host succeeds and signing
55
+ * `host:443` does not, so the default is the bare host. The alternative is
56
+ * kept because the two forms are both defensible readings of the SigV4 rule
57
+ * for a default port, other AWS regions and endpoints have been reported to
58
+ * want the other one, and the failure is indistinguishable from a wrong
59
+ * password: a silent 403 on the upgrade. The transport tries the default and
60
+ * then the fallback, so a user does not have to guess.
61
+ *
62
+ * The security token is appended *after* signing and is not part of the
63
+ * canonical query string. That is specific to IoT's WebSocket flow and is not
64
+ * how SigV4 normally treats `X-Amz-Security-Token`; including it in the
65
+ * canonical string produces a signature the endpoint rejects.
66
+ */
67
+ export declare function presignIotWebsocketUrl(credentials: IotCredentials, options?: {
68
+ signHostWithPort?: boolean;
69
+ now?: Date;
70
+ }): string;
@@ -0,0 +1,123 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 tbaur
4
+ *
5
+ * Licensed under the Apache License, Version 2.0
6
+ * See LICENSE file for full license text
7
+ *
8
+ * @fileoverview AWS Signature Version 4, for a pre-signed AWS IoT WebSocket URL.
9
+ *
10
+ * AWS IoT's WebSocket transport takes no password on the socket: authority is
11
+ * the signature in the URL's query string. This plugin signs a
12
+ * `wss://…/mqtt` URL with the temporary IAM credentials that
13
+ * `POST /user/sign-in` hands back, and connects to that.
14
+ *
15
+ * Only the tiny slice of SigV4 this needs is implemented: one service, one
16
+ * signed header, an empty payload and no request body. The full algorithm has
17
+ * a great deal more in it, and an `@aws-sdk/*` dependency to do this would be
18
+ * several megabytes to build one string.
19
+ *
20
+ * **The URL this produces is a credential.** It carries the session token, the
21
+ * access key id and a signature over them, and anyone holding it can speak to
22
+ * the account's IoT endpoint until it expires. It must never be logged, never
23
+ * put in an error message, and never written to a capture. `redactSecrets`
24
+ * knows the query parameter names, but the primary defence is that the
25
+ * transport logs the endpoint host and never the URL.
26
+ */
27
+ Object.defineProperty(exports, "__esModule", { value: true });
28
+ exports.uriEncode = uriEncode;
29
+ exports.amzTimestamps = amzTimestamps;
30
+ exports.presignIotWebsocketUrl = presignIotWebsocketUrl;
31
+ const node_crypto_1 = require("node:crypto");
32
+ /** The service name IoT's data plane signs as. Not `iot`. */
33
+ const SERVICE = 'iotdevicegateway';
34
+ /** The only path AWS IoT serves MQTT-over-WebSocket on. */
35
+ const PATH = '/mqtt';
36
+ /**
37
+ * Validity requested for the signature.
38
+ *
39
+ * The signature may say 24 hours, but the *credentials* it is built from
40
+ * expire much sooner. The sign-in response says when. The session is
41
+ * re-established on the credential deadline, not on this one, so this value
42
+ * only has to be comfortably longer than a connection's life.
43
+ */
44
+ const EXPIRES_SEC = 86_400;
45
+ function sha256Hex(data) {
46
+ return (0, node_crypto_1.createHash)('sha256').update(data, 'utf8').digest('hex');
47
+ }
48
+ function hmac(key, data) {
49
+ return (0, node_crypto_1.createHmac)('sha256', key).update(data, 'utf8').digest();
50
+ }
51
+ /**
52
+ * Percent-encode for a canonical request.
53
+ *
54
+ * `encodeURIComponent` is close but not identical to the AWS rule: it leaves
55
+ * `*` alone, which AWS requires encoded, and it encodes `~`, which AWS
56
+ * requires left alone. Both appear in session tokens, so getting either wrong
57
+ * produces a signature mismatch that reads as "not authorised" and sends you
58
+ * looking at IAM policy instead of at this function.
59
+ */
60
+ function uriEncode(value) {
61
+ return encodeURIComponent(value)
62
+ .replaceAll('*', '%2A')
63
+ .replaceAll('%7E', '~');
64
+ }
65
+ /** The two timestamp forms SigV4 wants, derived from one instant. */
66
+ function amzTimestamps(now = new Date()) {
67
+ const amzDate = now.toISOString().replaceAll('-', '').replaceAll(':', '').replace(/\.\d{3}Z$/, 'Z');
68
+ return { amzDate, dateStamp: amzDate.slice(0, 8) };
69
+ }
70
+ /**
71
+ * Build a signed `wss://` URL for the account's IoT endpoint.
72
+ *
73
+ * `signHostWithPort` selects what goes in the signed `host` header. Measured
74
+ * against the NaviLink endpoint: signing the bare host succeeds and signing
75
+ * `host:443` does not, so the default is the bare host. The alternative is
76
+ * kept because the two forms are both defensible readings of the SigV4 rule
77
+ * for a default port, other AWS regions and endpoints have been reported to
78
+ * want the other one, and the failure is indistinguishable from a wrong
79
+ * password: a silent 403 on the upgrade. The transport tries the default and
80
+ * then the fallback, so a user does not have to guess.
81
+ *
82
+ * The security token is appended *after* signing and is not part of the
83
+ * canonical query string. That is specific to IoT's WebSocket flow and is not
84
+ * how SigV4 normally treats `X-Amz-Security-Token`; including it in the
85
+ * canonical string produces a signature the endpoint rejects.
86
+ */
87
+ function presignIotWebsocketUrl(credentials, options = {}) {
88
+ const { signHostWithPort = false, now } = options;
89
+ const host = credentials.endpoint;
90
+ const canonicalHost = signHostWithPort ? `${host}:443` : host;
91
+ const { amzDate, dateStamp } = amzTimestamps(now);
92
+ const scope = `${dateStamp}/${credentials.region}/${SERVICE}/aws4_request`;
93
+ // Query parameters must be in code-point order for the canonical request.
94
+ // They are written in order here rather than sorted, because a sort on a
95
+ // list that is already correct is a line nobody checks again.
96
+ const canonicalQuery = [
97
+ 'X-Amz-Algorithm=AWS4-HMAC-SHA256',
98
+ `X-Amz-Credential=${uriEncode(`${credentials.accessKeyId}/${scope}`)}`,
99
+ `X-Amz-Date=${amzDate}`,
100
+ `X-Amz-Expires=${EXPIRES_SEC}`,
101
+ 'X-Amz-SignedHeaders=host',
102
+ ].join('&');
103
+ const canonicalRequest = [
104
+ 'GET',
105
+ PATH,
106
+ canonicalQuery,
107
+ `host:${canonicalHost}`,
108
+ '',
109
+ 'host',
110
+ sha256Hex(''),
111
+ ].join('\n');
112
+ const stringToSign = [
113
+ 'AWS4-HMAC-SHA256',
114
+ amzDate,
115
+ scope,
116
+ sha256Hex(canonicalRequest),
117
+ ].join('\n');
118
+ const signingKey = hmac(hmac(hmac(hmac(`AWS4${credentials.secretKey}`, dateStamp), credentials.region), SERVICE), 'aws4_request');
119
+ const signature = (0, node_crypto_1.createHmac)('sha256', signingKey).update(stringToSign, 'utf8').digest('hex');
120
+ return `wss://${host}:443${PATH}?${canonicalQuery}`
121
+ + `&X-Amz-Signature=${signature}`
122
+ + `&X-Amz-Security-Token=${uriEncode(credentials.sessionToken)}`;
123
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Copyright (c) 2026 tbaur
3
+ *
4
+ * Licensed under the Apache License, Version 2.0
5
+ * See LICENSE file for full license text
6
+ *
7
+ * @fileoverview NaviLink MQTT topic construction.
8
+ *
9
+ * Two prefixes, and the difference between them is the thing to understand.
10
+ *
11
+ * The **gateway prefix**, `cmd/{deviceType}/navilink-{mac}/`, addresses one
12
+ * appliance. Requests are published under it, and the gateway publishes its
13
+ * answers under it too, below `res/`. It contains nothing about who is asking,
14
+ * so every client watching that appliance sees every answer, including
15
+ * answers to somebody else's request, such as the NaviLink app on a phone.
16
+ * That is a feature, not a hazard: a setpoint changed on the wall
17
+ * controller or in the app reaches this plugin without it asking.
18
+ *
19
+ * The **session prefix**, `cmd/{deviceType}/{homeSeq}/{userSeq}/{clientId}/res/`,
20
+ * is private to one client. A request nominates it as its response topic.
21
+ *
22
+ * Both are subscribed. Measured against an NCB-240E: the answers arrived on
23
+ * the gateway prefix, not the session prefix. Relying on the session prefix
24
+ * alone would produce a plugin that connects cleanly, subscribes successfully
25
+ * and then waits forever. So both are taken, and a frame is matched on its
26
+ * last path segment, not on an exact topic.
27
+ */
28
+ /** Everything needed to address one gateway. */
29
+ export interface TopicIdentity {
30
+ /** Gateway MAC, lower-case hex with no separators, as the cloud spells it. */
31
+ macAddress: string;
32
+ /** Gateway kind. `1` for every NaviLink gateway seen so far. */
33
+ deviceType: number;
34
+ /** Home grouping id from the device list. */
35
+ homeSeq: string;
36
+ /** Account sequence number from sign-in. */
37
+ userSeq: string;
38
+ /** This client's MQTT client identifier. */
39
+ clientId: string;
40
+ }
41
+ /** The topics one session uses for one gateway. */
42
+ export interface GatewayTopics {
43
+ /** Where a channel-info request goes, and where a session announces itself. */
44
+ start: string;
45
+ /** Where a channel-status request goes. */
46
+ statusRequest: string;
47
+ /** Where a control command goes. */
48
+ control: string;
49
+ /** Everything to subscribe to, in one list. */
50
+ subscriptions: readonly string[];
51
+ /** The gateway's own last-will topic, announcing an app coming or going. */
52
+ appConnection: string;
53
+ }
54
+ /**
55
+ * The response kinds this plugin understands.
56
+ *
57
+ * `other` is not an error: the gateway publishes several frame types nobody
58
+ * here has needed yet, and an unrecognised one should be ignored quietly
59
+ * rather than logged as a fault on every arrival.
60
+ */
61
+ export type FrameKind = 'channelinfo' | 'channelstatus' | 'controlfail' | 'connection' | 'other';
62
+ /** The response topic a request nominates for its answer. */
63
+ export declare function responseTopic(identity: TopicIdentity, kind: FrameKind): string;
64
+ /** Build every topic one session needs for one gateway. */
65
+ export declare function buildTopics(identity: TopicIdentity): GatewayTopics;
66
+ /**
67
+ * Classify an inbound topic by its last segment.
68
+ *
69
+ * Deliberately not an exact match against the subscribed list. The two
70
+ * prefixes produce two topics per frame kind, a gateway can answer on either,
71
+ * and matching exactly would mean a frame arriving on the unexpected prefix is
72
+ * silently dropped. That looks exactly like an appliance that has stopped
73
+ * reporting.
74
+ */
75
+ export declare function classifyTopic(topic: string): FrameKind;
@@ -0,0 +1,90 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 tbaur
4
+ *
5
+ * Licensed under the Apache License, Version 2.0
6
+ * See LICENSE file for full license text
7
+ *
8
+ * @fileoverview NaviLink MQTT topic construction.
9
+ *
10
+ * Two prefixes, and the difference between them is the thing to understand.
11
+ *
12
+ * The **gateway prefix**, `cmd/{deviceType}/navilink-{mac}/`, addresses one
13
+ * appliance. Requests are published under it, and the gateway publishes its
14
+ * answers under it too, below `res/`. It contains nothing about who is asking,
15
+ * so every client watching that appliance sees every answer, including
16
+ * answers to somebody else's request, such as the NaviLink app on a phone.
17
+ * That is a feature, not a hazard: a setpoint changed on the wall
18
+ * controller or in the app reaches this plugin without it asking.
19
+ *
20
+ * The **session prefix**, `cmd/{deviceType}/{homeSeq}/{userSeq}/{clientId}/res/`,
21
+ * is private to one client. A request nominates it as its response topic.
22
+ *
23
+ * Both are subscribed. Measured against an NCB-240E: the answers arrived on
24
+ * the gateway prefix, not the session prefix. Relying on the session prefix
25
+ * alone would produce a plugin that connects cleanly, subscribes successfully
26
+ * and then waits forever. So both are taken, and a frame is matched on its
27
+ * last path segment, not on an exact topic.
28
+ */
29
+ Object.defineProperty(exports, "__esModule", { value: true });
30
+ exports.responseTopic = responseTopic;
31
+ exports.buildTopics = buildTopics;
32
+ exports.classifyTopic = classifyTopic;
33
+ /** Build the gateway prefix. */
34
+ function gatewayPrefix(identity) {
35
+ return `cmd/${identity.deviceType}/navilink-${identity.macAddress}/`;
36
+ }
37
+ /** Build the session prefix. */
38
+ function sessionPrefix(identity) {
39
+ return `cmd/${identity.deviceType}/${identity.homeSeq}/${identity.userSeq}/${identity.clientId}/res/`;
40
+ }
41
+ /** The response topic a request nominates for its answer. */
42
+ function responseTopic(identity, kind) {
43
+ return `${sessionPrefix(identity)}${kind}`;
44
+ }
45
+ /** Build every topic one session needs for one gateway. */
46
+ function buildTopics(identity) {
47
+ const gateway = gatewayPrefix(identity);
48
+ const session = sessionPrefix(identity);
49
+ return {
50
+ start: `${gateway}status/start`,
51
+ statusRequest: `${gateway}status/channelstatus`,
52
+ control: `${gateway}control`,
53
+ subscriptions: [
54
+ // The gateway prefix first, because that is where answers actually
55
+ // arrive, and because it is the path a change made elsewhere reaches us
56
+ // on.
57
+ `${gateway}res/channelinfo`,
58
+ `${gateway}res/channelstatus`,
59
+ `${gateway}res/controlfail`,
60
+ `${gateway}connection`,
61
+ // The session prefix, for a firmware that honours the nominated
62
+ // response topic. Subscribing to both costs one SUBSCRIBE.
63
+ `${session}channelinfo`,
64
+ `${session}channelstatus`,
65
+ `${session}controlfail`,
66
+ ],
67
+ appConnection: `evt/${identity.deviceType}/navilink-${identity.macAddress}/app-connection`,
68
+ };
69
+ }
70
+ /**
71
+ * Classify an inbound topic by its last segment.
72
+ *
73
+ * Deliberately not an exact match against the subscribed list. The two
74
+ * prefixes produce two topics per frame kind, a gateway can answer on either,
75
+ * and matching exactly would mean a frame arriving on the unexpected prefix is
76
+ * silently dropped. That looks exactly like an appliance that has stopped
77
+ * reporting.
78
+ */
79
+ function classifyTopic(topic) {
80
+ const last = topic.slice(topic.lastIndexOf('/') + 1);
81
+ switch (last) {
82
+ case 'channelinfo':
83
+ case 'channelstatus':
84
+ case 'controlfail':
85
+ case 'connection':
86
+ return last;
87
+ default:
88
+ return 'other';
89
+ }
90
+ }