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.
- package/CHANGELOG.md +5 -0
- package/LICENSE +202 -0
- package/README.md +161 -0
- package/SECURITY.md +73 -0
- package/config.schema.json +138 -0
- package/dist/api/channel.d.ts +94 -0
- package/dist/api/channel.js +334 -0
- package/dist/api/http.d.ts +68 -0
- package/dist/api/http.js +205 -0
- package/dist/api/identity.d.ts +51 -0
- package/dist/api/identity.js +80 -0
- package/dist/api/index.d.ts +17 -0
- package/dist/api/index.js +33 -0
- package/dist/api/mqtt-codec.d.ts +181 -0
- package/dist/api/mqtt-codec.js +377 -0
- package/dist/api/mqtt.d.ts +178 -0
- package/dist/api/mqtt.js +450 -0
- package/dist/api/protocol.d.ts +212 -0
- package/dist/api/protocol.js +286 -0
- package/dist/api/rest.d.ts +139 -0
- package/dist/api/rest.js +317 -0
- package/dist/api/sigv4.d.ts +70 -0
- package/dist/api/sigv4.js +123 -0
- package/dist/api/topics.d.ts +75 -0
- package/dist/api/topics.js +90 -0
- package/dist/devices/base-accessory.d.ts +127 -0
- package/dist/devices/base-accessory.js +232 -0
- package/dist/devices/dhw-accessory.d.ts +62 -0
- package/dist/devices/dhw-accessory.js +100 -0
- package/dist/devices/fault-accessory.d.ts +31 -0
- package/dist/devices/fault-accessory.js +73 -0
- package/dist/devices/heating-accessory.d.ts +63 -0
- package/dist/devices/heating-accessory.js +128 -0
- package/dist/devices/host.d.ts +77 -0
- package/dist/devices/host.js +23 -0
- package/dist/devices/index.d.ts +17 -0
- package/dist/devices/index.js +33 -0
- package/dist/devices/power-accessory.d.ts +32 -0
- package/dist/devices/power-accessory.js +86 -0
- package/dist/devices/probe-accessory.d.ts +45 -0
- package/dist/devices/probe-accessory.js +108 -0
- package/dist/devices/recirculation-accessory.d.ts +47 -0
- package/dist/devices/recirculation-accessory.js +122 -0
- package/dist/devices/thermostat-accessory.d.ts +129 -0
- package/dist/devices/thermostat-accessory.js +372 -0
- package/dist/discovery.d.ts +99 -0
- package/dist/discovery.js +423 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +15 -0
- package/dist/platform.d.ts +120 -0
- package/dist/platform.js +425 -0
- package/dist/session.d.ts +243 -0
- package/dist/session.js +717 -0
- package/dist/settings.d.ts +218 -0
- package/dist/settings.js +240 -0
- package/dist/types/index.d.ts +265 -0
- package/dist/types/index.js +58 -0
- package/dist/ui-api.d.ts +27 -0
- package/dist/ui-api.js +39 -0
- package/dist/utils/context.d.ts +32 -0
- package/dist/utils/context.js +74 -0
- package/dist/utils/errors.d.ts +69 -0
- package/dist/utils/errors.js +118 -0
- package/dist/utils/index.d.ts +15 -0
- package/dist/utils/index.js +31 -0
- package/dist/utils/redact.d.ts +85 -0
- package/dist/utils/redact.js +210 -0
- package/dist/utils/serial.d.ts +18 -0
- package/dist/utils/serial.js +24 -0
- package/dist/utils/temperature.d.ts +114 -0
- package/dist/utils/temperature.js +150 -0
- package/dist/utils/timing.d.ts +68 -0
- package/dist/utils/timing.js +91 -0
- package/dist/utils/validators.d.ts +96 -0
- package/dist/utils/validators.js +353 -0
- package/docs/FEATURES.md +80 -0
- package/docs/PROTOCOL.md +200 -0
- package/docs/README-DETAILED.md +312 -0
- package/homebridge-ui/public/index.html +123 -0
- package/homebridge-ui/public/index.js +503 -0
- package/homebridge-ui/server.js +174 -0
- package/package.json +87 -0
package/dist/api/http.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
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 JSON over HTTPS against the NaviLink cloud.
|
|
9
|
+
*
|
|
10
|
+
* Built on `node:https` rather than `fetch` for three reasons, in ascending
|
|
11
|
+
* order of how much they matter.
|
|
12
|
+
*
|
|
13
|
+
* A response size cap. `fetch` gives you a body you have already committed to
|
|
14
|
+
* buffering, or a stream you have to cap by hand; a cap applied as bytes
|
|
15
|
+
* arrive is both simpler and actually enforced. The cloud is not ours and its
|
|
16
|
+
* responses are not bounded by anything we control.
|
|
17
|
+
*
|
|
18
|
+
* Separate connect and total deadlines. `AbortSignal.timeout` expresses one
|
|
19
|
+
* instant, so it cannot say "reach the host quickly, then allow it time to
|
|
20
|
+
* answer". An unreachable cloud should fail startup in seconds, not hold a
|
|
21
|
+
* slot for the whole request budget.
|
|
22
|
+
*
|
|
23
|
+
* Control over what is logged. Everything on this path is a credential: the
|
|
24
|
+
* request body carries the password, and the response body carries a JWT pair
|
|
25
|
+
* and a set of AWS keys. `fetch` errors quote the URL, and a debug logger
|
|
26
|
+
* wrapped round it would eventually quote a body. Here, the only thing that
|
|
27
|
+
* reaches the log is the method and the path.
|
|
28
|
+
*/
|
|
29
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
30
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
31
|
+
};
|
|
32
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
33
|
+
exports.postJson = void 0;
|
|
34
|
+
exports.parseJsonBody = parseJsonBody;
|
|
35
|
+
const node_https_1 = __importDefault(require("node:https"));
|
|
36
|
+
const errors_1 = require("../utils/errors");
|
|
37
|
+
const redact_1 = require("../utils/redact");
|
|
38
|
+
/** Longest body excerpt put into a parse-failure message. */
|
|
39
|
+
const MAX_BODY_EXCERPT = 80;
|
|
40
|
+
/**
|
|
41
|
+
* POST a JSON body and read the response as text.
|
|
42
|
+
*
|
|
43
|
+
* Rejects with {@link ConnectionError} for anything that prevented an answer
|
|
44
|
+
* and {@link ProtocolError} when the answer arrived but was unusable. Parsing
|
|
45
|
+
* is left to the caller: a non-200 from this API still carries a JSON body
|
|
46
|
+
* explaining why, and throwing on the status here would discard it.
|
|
47
|
+
*
|
|
48
|
+
* Redirects are not followed. The cloud does not issue them, and following one
|
|
49
|
+
* would let a compromised or spoofed endpoint move a request that carries the
|
|
50
|
+
* user's password to a host of its choosing.
|
|
51
|
+
*/
|
|
52
|
+
const postJson = (url, body, options) => {
|
|
53
|
+
const { connectTimeoutMs, totalTimeoutMs, maxBytes, headers = {}, signal } = options;
|
|
54
|
+
const payload = JSON.stringify(body ?? {});
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
if (signal?.aborted === true) {
|
|
57
|
+
reject(new errors_1.ConnectionError('request aborted before it started'));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
let settled = false;
|
|
61
|
+
let onAbort;
|
|
62
|
+
// Held in a container because `cleanup` closes over it before the timer
|
|
63
|
+
// that fills it in can be created: the timer's callback needs `fail`, and
|
|
64
|
+
// `fail` needs `cleanup`.
|
|
65
|
+
const timers = {};
|
|
66
|
+
const request = node_https_1.default.request(url, {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
// A connection per request. There is no long-poll to queue behind here,
|
|
69
|
+
// but sign-in and the device list are a handful of calls per hour and a
|
|
70
|
+
// pooled socket to a cloud endpoint is one more thing holding state
|
|
71
|
+
// across a token refresh.
|
|
72
|
+
agent: false,
|
|
73
|
+
headers: {
|
|
74
|
+
accept: 'application/json',
|
|
75
|
+
'content-type': 'application/json',
|
|
76
|
+
'content-length': String(Buffer.byteLength(payload)),
|
|
77
|
+
...headers,
|
|
78
|
+
},
|
|
79
|
+
}, (response) => {
|
|
80
|
+
const chunks = [];
|
|
81
|
+
let received = 0;
|
|
82
|
+
response.on('data', (chunk) => {
|
|
83
|
+
received += chunk.length;
|
|
84
|
+
if (received > maxBytes) {
|
|
85
|
+
fail(new errors_1.ProtocolError(`response exceeds ${maxBytes} bytes`));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
chunks.push(chunk);
|
|
89
|
+
});
|
|
90
|
+
response.on('end', () => {
|
|
91
|
+
finish({
|
|
92
|
+
status: response.statusCode ?? 0,
|
|
93
|
+
body: Buffer.concat(chunks).toString('utf8'),
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
response.on('error', (error) => {
|
|
97
|
+
fail(new errors_1.ConnectionError('response stream failed', { cause: error }));
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
const cleanup = () => {
|
|
101
|
+
if (timers.connect !== undefined) {
|
|
102
|
+
clearTimeout(timers.connect);
|
|
103
|
+
}
|
|
104
|
+
if (timers.total !== undefined) {
|
|
105
|
+
clearTimeout(timers.total);
|
|
106
|
+
}
|
|
107
|
+
if (onAbort !== undefined) {
|
|
108
|
+
signal?.removeEventListener('abort', onAbort);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
const finish = (response) => {
|
|
112
|
+
if (settled) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
settled = true;
|
|
116
|
+
cleanup();
|
|
117
|
+
resolve(response);
|
|
118
|
+
};
|
|
119
|
+
const fail = (error) => {
|
|
120
|
+
if (settled) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
settled = true;
|
|
124
|
+
cleanup();
|
|
125
|
+
request.destroy();
|
|
126
|
+
reject(error);
|
|
127
|
+
};
|
|
128
|
+
// Our own timer, not `request.setTimeout`. Node defers that call until
|
|
129
|
+
// the socket is already connected, then this module immediately clears
|
|
130
|
+
// it. Without that, a host that blackholes the SYN would sit on the
|
|
131
|
+
// total deadline instead of failing in seconds, which is the whole
|
|
132
|
+
// point of having two clocks.
|
|
133
|
+
timers.connect = setTimeout(() => {
|
|
134
|
+
fail(new errors_1.ConnectionError(`connect timed out after ${connectTimeoutMs}ms`));
|
|
135
|
+
}, connectTimeoutMs);
|
|
136
|
+
request.on('socket', (socket) => {
|
|
137
|
+
const onReady = () => {
|
|
138
|
+
if (timers.connect !== undefined) {
|
|
139
|
+
clearTimeout(timers.connect);
|
|
140
|
+
timers.connect = undefined;
|
|
141
|
+
}
|
|
142
|
+
socket.setNoDelay(true);
|
|
143
|
+
};
|
|
144
|
+
if (socket.connecting) {
|
|
145
|
+
// A TLSSocket has `encrypted`. Waiting on TCP `connect` would clear
|
|
146
|
+
// the timer before the handshake finishes. A plain socket (the test
|
|
147
|
+
// loopback under an `https:` URL) only emits `connect`.
|
|
148
|
+
if ('encrypted' in socket) {
|
|
149
|
+
socket.once('secureConnect', onReady);
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
socket.once('connect', onReady);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
onReady();
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
request.on('error', (error) => {
|
|
160
|
+
// The message is deliberately generic. Node puts the request URL into
|
|
161
|
+
// some socket errors, and while this API's URLs carry no secrets, the
|
|
162
|
+
// rule that nothing from this layer quotes a URL is easier to keep than
|
|
163
|
+
// an exception list.
|
|
164
|
+
fail(new errors_1.ConnectionError('request failed', { cause: error }));
|
|
165
|
+
});
|
|
166
|
+
// Referenced on purpose, and always cleared in `cleanup`: a caller is
|
|
167
|
+
// awaiting this request, so the process must not be free to exit under it.
|
|
168
|
+
timers.total = setTimeout(() => {
|
|
169
|
+
fail(new errors_1.ConnectionError(`request timed out after ${totalTimeoutMs}ms`));
|
|
170
|
+
}, totalTimeoutMs);
|
|
171
|
+
if (signal !== undefined) {
|
|
172
|
+
onAbort = () => {
|
|
173
|
+
fail(new errors_1.ConnectionError('request aborted'));
|
|
174
|
+
};
|
|
175
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
176
|
+
}
|
|
177
|
+
request.end(payload);
|
|
178
|
+
});
|
|
179
|
+
};
|
|
180
|
+
exports.postJson = postJson;
|
|
181
|
+
/**
|
|
182
|
+
* Parse a response body as JSON, or explain why it could not be.
|
|
183
|
+
*
|
|
184
|
+
* The excerpt in the failure message is capped and redacted: a gateway error
|
|
185
|
+
* page can be a whole HTML document, and a partial JSON body from this API
|
|
186
|
+
* would otherwise put a token into the log.
|
|
187
|
+
*/
|
|
188
|
+
function parseJsonBody(body) {
|
|
189
|
+
if (body.length === 0) {
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
return JSON.parse(body);
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
throw new errors_1.ProtocolError(`the cloud returned a body that is not JSON (${bodyExcerpt(body)})`, { cause: error });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/** A short, redacted look at a body that could not be parsed. */
|
|
200
|
+
function bodyExcerpt(body) {
|
|
201
|
+
const cleaned = (0, redact_1.redactSecrets)(body).replace(/[\u0000-\u001F\u007F]/g, '\uFFFD');
|
|
202
|
+
return cleaned.length > MAX_BODY_EXCERPT
|
|
203
|
+
? `${cleaned.slice(0, MAX_BODY_EXCERPT)}\u2026`
|
|
204
|
+
: cleaned;
|
|
205
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
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 Stable appliance identity.
|
|
8
|
+
*
|
|
9
|
+
* Accessory identity has to survive everything that legitimately changes about
|
|
10
|
+
* an installation: a new router, a re-paired gateway, a firmware update, a
|
|
11
|
+
* renamed device, a changed account email. What is left is the gateway's MAC
|
|
12
|
+
* address and the channel the appliance sits on, so that is what identity is.
|
|
13
|
+
*
|
|
14
|
+
* The MAC never leaves this layer in the clear. It is the appliance's address
|
|
15
|
+
* in every MQTT topic, so it is a capability as much as an identifier: it goes
|
|
16
|
+
* into a device id and into topic construction, and everywhere else (logs,
|
|
17
|
+
* HomeKit serial numbers, bug reports) it is masked.
|
|
18
|
+
*/
|
|
19
|
+
import type { AccessoryKind, ResolvedAccessory } from '../types';
|
|
20
|
+
/**
|
|
21
|
+
* Normalise a MAC to the cloud's own spelling: lower-case hex, no separators.
|
|
22
|
+
*
|
|
23
|
+
* Accepts the colon-separated form as well, because a hand-edited
|
|
24
|
+
* configuration is likely to use it and rejecting that would be a puzzle
|
|
25
|
+
* rather than a help. Returns undefined rather than guessing, so a caller can
|
|
26
|
+
* skip the entry instead of building an unstable identity from a bad value.
|
|
27
|
+
*/
|
|
28
|
+
export declare function normalizeMac(value: unknown): string | undefined;
|
|
29
|
+
/** Build a device id from a gateway MAC and a channel number. */
|
|
30
|
+
export declare function makeDeviceId(mac: string, channel: number): string;
|
|
31
|
+
/** The MAC and channel inside a device id, or undefined when it is malformed. */
|
|
32
|
+
export declare function parseDeviceId(value: unknown): {
|
|
33
|
+
mac: string;
|
|
34
|
+
channel: number;
|
|
35
|
+
} | undefined;
|
|
36
|
+
/**
|
|
37
|
+
* The identity of an accessory: what it does, for which appliance.
|
|
38
|
+
*
|
|
39
|
+
* Two accessories with the same key are the same accessory, and one whose key
|
|
40
|
+
* changes is a different accessory that takes the old one's rooms, scenes and
|
|
41
|
+
* automations with it when the old one goes.
|
|
42
|
+
*/
|
|
43
|
+
export declare function accessoryIdentityKey(accessory: {
|
|
44
|
+
kind: AccessoryKind;
|
|
45
|
+
deviceId: string;
|
|
46
|
+
}): string;
|
|
47
|
+
/** True when a cached context describes the same accessory as a resolved one. */
|
|
48
|
+
export declare function hasAccessoryIdentity(context: {
|
|
49
|
+
kind?: unknown;
|
|
50
|
+
deviceId?: unknown;
|
|
51
|
+
}, accessory: ResolvedAccessory): boolean;
|
|
@@ -0,0 +1,80 @@
|
|
|
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 Stable appliance identity.
|
|
9
|
+
*
|
|
10
|
+
* Accessory identity has to survive everything that legitimately changes about
|
|
11
|
+
* an installation: a new router, a re-paired gateway, a firmware update, a
|
|
12
|
+
* renamed device, a changed account email. What is left is the gateway's MAC
|
|
13
|
+
* address and the channel the appliance sits on, so that is what identity is.
|
|
14
|
+
*
|
|
15
|
+
* The MAC never leaves this layer in the clear. It is the appliance's address
|
|
16
|
+
* in every MQTT topic, so it is a capability as much as an identifier: it goes
|
|
17
|
+
* into a device id and into topic construction, and everywhere else (logs,
|
|
18
|
+
* HomeKit serial numbers, bug reports) it is masked.
|
|
19
|
+
*/
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.normalizeMac = normalizeMac;
|
|
22
|
+
exports.makeDeviceId = makeDeviceId;
|
|
23
|
+
exports.parseDeviceId = parseDeviceId;
|
|
24
|
+
exports.accessoryIdentityKey = accessoryIdentityKey;
|
|
25
|
+
exports.hasAccessoryIdentity = hasAccessoryIdentity;
|
|
26
|
+
const settings_1 = require("../settings");
|
|
27
|
+
/** Twelve hex digits, which is how the cloud spells a MAC. */
|
|
28
|
+
const BARE_MAC = /^[0-9a-f]{12}$/;
|
|
29
|
+
/**
|
|
30
|
+
* Normalise a MAC to the cloud's own spelling: lower-case hex, no separators.
|
|
31
|
+
*
|
|
32
|
+
* Accepts the colon-separated form as well, because a hand-edited
|
|
33
|
+
* configuration is likely to use it and rejecting that would be a puzzle
|
|
34
|
+
* rather than a help. Returns undefined rather than guessing, so a caller can
|
|
35
|
+
* skip the entry instead of building an unstable identity from a bad value.
|
|
36
|
+
*/
|
|
37
|
+
function normalizeMac(value) {
|
|
38
|
+
if (typeof value !== 'string') {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
const cleaned = value.trim().toLowerCase().replaceAll(/[:-]/g, '');
|
|
42
|
+
return BARE_MAC.test(cleaned) ? cleaned : undefined;
|
|
43
|
+
}
|
|
44
|
+
/** Build a device id from a gateway MAC and a channel number. */
|
|
45
|
+
function makeDeviceId(mac, channel) {
|
|
46
|
+
return `${mac.toLowerCase()}:${channel}`;
|
|
47
|
+
}
|
|
48
|
+
/** The MAC and channel inside a device id, or undefined when it is malformed. */
|
|
49
|
+
function parseDeviceId(value) {
|
|
50
|
+
if (typeof value !== 'string') {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
const separator = value.lastIndexOf(':');
|
|
54
|
+
if (separator <= 0) {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
const mac = normalizeMac(value.slice(0, separator));
|
|
58
|
+
const channel = Number(value.slice(separator + 1));
|
|
59
|
+
if (mac === undefined || !Number.isInteger(channel)) {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
if (channel < settings_1.MIN_CHANNEL || channel > settings_1.MAX_CHANNEL) {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
return { mac, channel };
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The identity of an accessory: what it does, for which appliance.
|
|
69
|
+
*
|
|
70
|
+
* Two accessories with the same key are the same accessory, and one whose key
|
|
71
|
+
* changes is a different accessory that takes the old one's rooms, scenes and
|
|
72
|
+
* automations with it when the old one goes.
|
|
73
|
+
*/
|
|
74
|
+
function accessoryIdentityKey(accessory) {
|
|
75
|
+
return `${accessory.deviceId}:${accessory.kind}`;
|
|
76
|
+
}
|
|
77
|
+
/** True when a cached context describes the same accessory as a resolved one. */
|
|
78
|
+
function hasAccessoryIdentity(context, accessory) {
|
|
79
|
+
return context.kind === accessory.kind && context.deviceId === accessory.deviceId;
|
|
80
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
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 API barrel.
|
|
8
|
+
*/
|
|
9
|
+
export * from './channel';
|
|
10
|
+
export * from './http';
|
|
11
|
+
export * from './identity';
|
|
12
|
+
export * from './mqtt';
|
|
13
|
+
export * from './mqtt-codec';
|
|
14
|
+
export * from './protocol';
|
|
15
|
+
export * from './rest';
|
|
16
|
+
export * from './sigv4';
|
|
17
|
+
export * from './topics';
|
|
@@ -0,0 +1,33 @@
|
|
|
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 API barrel.
|
|
9
|
+
*/
|
|
10
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
11
|
+
if (k2 === undefined) k2 = k;
|
|
12
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
13
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
14
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
15
|
+
}
|
|
16
|
+
Object.defineProperty(o, k2, desc);
|
|
17
|
+
}) : (function(o, m, k, k2) {
|
|
18
|
+
if (k2 === undefined) k2 = k;
|
|
19
|
+
o[k2] = m[k];
|
|
20
|
+
}));
|
|
21
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
22
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
23
|
+
};
|
|
24
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
25
|
+
__exportStar(require("./channel"), exports);
|
|
26
|
+
__exportStar(require("./http"), exports);
|
|
27
|
+
__exportStar(require("./identity"), exports);
|
|
28
|
+
__exportStar(require("./mqtt"), exports);
|
|
29
|
+
__exportStar(require("./mqtt-codec"), exports);
|
|
30
|
+
__exportStar(require("./protocol"), exports);
|
|
31
|
+
__exportStar(require("./rest"), exports);
|
|
32
|
+
__exportStar(require("./sigv4"), exports);
|
|
33
|
+
__exportStar(require("./topics"), exports);
|
|
@@ -0,0 +1,181 @@
|
|
|
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 MQTT 3.1.1 packet encoding and decoding, for the subset AWS
|
|
8
|
+
* IoT needs.
|
|
9
|
+
*
|
|
10
|
+
* Writing this rather than taking a dependency was a deliberate call, and the
|
|
11
|
+
* reasoning belongs next to the code it justifies.
|
|
12
|
+
*
|
|
13
|
+
* `mqtt.js` rebuilds the WebSocket path from its own parsed options, which
|
|
14
|
+
* discards the SigV4 query string, so the upgrade is rejected or hangs. The
|
|
15
|
+
* documented way round is `transformWsUrl`, a hook that exists to undo what the
|
|
16
|
+
* library just did. That is a bad sign for the one part of this plugin where a
|
|
17
|
+
* subtle failure means a user's heating silently stops responding.
|
|
18
|
+
*
|
|
19
|
+
* AWS IoT speaks 3.1.1, this plugin needs seven packet types, and the wire
|
|
20
|
+
* format is a fixed header, a variable-length integer and length-prefixed
|
|
21
|
+
* strings. It is a few hundred lines with no I/O in it, which makes it
|
|
22
|
+
* exhaustively testable without a socket. The family this plugin belongs to
|
|
23
|
+
* already hand-rolls a capped XML reader for the same reason. The dependency
|
|
24
|
+
* it replaces brings roughly twenty transitive packages into a plugin that
|
|
25
|
+
* handles a cloud password, which the security posture would rather not carry.
|
|
26
|
+
*
|
|
27
|
+
* What is deliberately absent: QoS 2 (AWS IoT does not support it), retained
|
|
28
|
+
* message handling beyond the flag, topic aliases and MQTT 5 properties. If a
|
|
29
|
+
* future NaviLink protocol needs any of them, add it here with a test rather
|
|
30
|
+
* than reaching back for the library.
|
|
31
|
+
*
|
|
32
|
+
* Section references are to the OASIS MQTT 3.1.1 specification.
|
|
33
|
+
*/
|
|
34
|
+
/** Packet type codes (§2.2.1). */
|
|
35
|
+
export declare const PacketType: {
|
|
36
|
+
readonly CONNECT: 1;
|
|
37
|
+
readonly CONNACK: 2;
|
|
38
|
+
readonly PUBLISH: 3;
|
|
39
|
+
readonly PUBACK: 4;
|
|
40
|
+
readonly SUBSCRIBE: 8;
|
|
41
|
+
readonly SUBACK: 9;
|
|
42
|
+
readonly PINGREQ: 12;
|
|
43
|
+
readonly PINGRESP: 13;
|
|
44
|
+
readonly DISCONNECT: 14;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Largest packet accepted from the broker.
|
|
48
|
+
*
|
|
49
|
+
* A status frame is a couple of kilobytes. 256 KiB is generous while keeping a
|
|
50
|
+
* malfunctioning or hostile broker from growing the heap: the remaining-length
|
|
51
|
+
* field can encode 256 MB, and a decoder that trusts it will happily buffer
|
|
52
|
+
* that much before noticing.
|
|
53
|
+
*/
|
|
54
|
+
export declare const MAX_PACKET_BYTES = 262144;
|
|
55
|
+
/** A message the broker delivered to us. */
|
|
56
|
+
export interface PublishPacket {
|
|
57
|
+
type: typeof PacketType.PUBLISH;
|
|
58
|
+
topic: string;
|
|
59
|
+
payload: Uint8Array;
|
|
60
|
+
qos: 0 | 1;
|
|
61
|
+
retain: boolean;
|
|
62
|
+
dup: boolean;
|
|
63
|
+
/** Present for QoS 1, which must be acknowledged. */
|
|
64
|
+
packetId?: number;
|
|
65
|
+
}
|
|
66
|
+
/** The broker's answer to our CONNECT. */
|
|
67
|
+
export interface ConnackPacket {
|
|
68
|
+
type: typeof PacketType.CONNACK;
|
|
69
|
+
sessionPresent: boolean;
|
|
70
|
+
returnCode: number;
|
|
71
|
+
}
|
|
72
|
+
/** The broker's acknowledgement of a QoS 1 PUBLISH we sent. */
|
|
73
|
+
export interface PubackPacket {
|
|
74
|
+
type: typeof PacketType.PUBACK;
|
|
75
|
+
packetId: number;
|
|
76
|
+
}
|
|
77
|
+
/** The broker's answer to a SUBSCRIBE. */
|
|
78
|
+
export interface SubackPacket {
|
|
79
|
+
type: typeof PacketType.SUBACK;
|
|
80
|
+
packetId: number;
|
|
81
|
+
/** One per requested topic: the granted QoS, or 0x80 for a refusal. */
|
|
82
|
+
returnCodes: number[];
|
|
83
|
+
}
|
|
84
|
+
/** The broker's answer to a PINGREQ. */
|
|
85
|
+
export interface PingrespPacket {
|
|
86
|
+
type: typeof PacketType.PINGRESP;
|
|
87
|
+
}
|
|
88
|
+
/** Any packet this decoder produces. */
|
|
89
|
+
export type DecodedPacket = ConnackPacket | PublishPacket | PubackPacket | SubackPacket | PingrespPacket;
|
|
90
|
+
/** A topic filter and the QoS to request for it. */
|
|
91
|
+
export interface Subscription {
|
|
92
|
+
topic: string;
|
|
93
|
+
qos: 0 | 1;
|
|
94
|
+
}
|
|
95
|
+
/** A last-will message, published by the broker if we disappear. */
|
|
96
|
+
export interface WillMessage {
|
|
97
|
+
topic: string;
|
|
98
|
+
payload: string;
|
|
99
|
+
qos: 0 | 1;
|
|
100
|
+
retain: boolean;
|
|
101
|
+
}
|
|
102
|
+
/** Everything CONNECT carries. */
|
|
103
|
+
export interface ConnectOptions {
|
|
104
|
+
clientId: string;
|
|
105
|
+
keepaliveSec: number;
|
|
106
|
+
cleanSession: boolean;
|
|
107
|
+
username?: string;
|
|
108
|
+
password?: string;
|
|
109
|
+
will?: WillMessage;
|
|
110
|
+
}
|
|
111
|
+
/** Human-readable text for a CONNACK return code. */
|
|
112
|
+
export declare function describeConnackReturnCode(code: number): string;
|
|
113
|
+
/** True when a SUBACK return code means the subscription was refused (§3.9.3). */
|
|
114
|
+
export declare function isSubscribeFailure(code: number): boolean;
|
|
115
|
+
/**
|
|
116
|
+
* Encode the remaining-length field (§2.2.3).
|
|
117
|
+
*
|
|
118
|
+
* A base-128 varint, little-endian, with the top bit as the continuation flag
|
|
119
|
+
* and a hard limit of four bytes.
|
|
120
|
+
*/
|
|
121
|
+
export declare function encodeRemainingLength(length: number): number[];
|
|
122
|
+
/**
|
|
123
|
+
* Build a CONNECT packet (§3.1).
|
|
124
|
+
*
|
|
125
|
+
* AWS IoT authenticates from the SigV4 signature on the WebSocket URL, so the
|
|
126
|
+
* username and password fields are not credentials here. The username is
|
|
127
|
+
* optional. The verified US endpoint accepts a CONNECT without one, so this
|
|
128
|
+
* plugin omits it rather than inventing an SDK marker we have not recorded.
|
|
129
|
+
*/
|
|
130
|
+
export declare function encodeConnect(options: ConnectOptions): Uint8Array;
|
|
131
|
+
/**
|
|
132
|
+
* Build a PUBLISH packet (§3.3).
|
|
133
|
+
*
|
|
134
|
+
* `packetId` is required for QoS 1 and forbidden for QoS 0, which the
|
|
135
|
+
* specification states and this enforces: a QoS 1 publish with no identifier
|
|
136
|
+
* cannot be acknowledged, so the caller would wait for a PUBACK that can never
|
|
137
|
+
* be matched.
|
|
138
|
+
*/
|
|
139
|
+
export declare function encodePublish(input: {
|
|
140
|
+
topic: string;
|
|
141
|
+
payload: string;
|
|
142
|
+
qos: 0 | 1;
|
|
143
|
+
packetId?: number;
|
|
144
|
+
retain?: boolean;
|
|
145
|
+
}): Uint8Array;
|
|
146
|
+
/** Build a PUBACK, acknowledging a QoS 1 message the broker sent us (§3.4). */
|
|
147
|
+
export declare function encodePuback(packetId: number): Uint8Array;
|
|
148
|
+
/**
|
|
149
|
+
* Build a SUBSCRIBE packet (§3.8).
|
|
150
|
+
*
|
|
151
|
+
* The fixed-header flags are required to be `0b0010`; a broker must treat
|
|
152
|
+
* anything else as a protocol violation and close the connection.
|
|
153
|
+
*/
|
|
154
|
+
export declare function encodeSubscribe(packetId: number, subscriptions: readonly Subscription[]): Uint8Array;
|
|
155
|
+
/** Build a PINGREQ (§3.12). */
|
|
156
|
+
export declare function encodePingreq(): Uint8Array;
|
|
157
|
+
/** Build a DISCONNECT (§3.14), which tells the broker not to send our will. */
|
|
158
|
+
export declare function encodeDisconnect(): Uint8Array;
|
|
159
|
+
/**
|
|
160
|
+
* Reassembles packets from a byte stream.
|
|
161
|
+
*
|
|
162
|
+
* Needed because MQTT framing and WebSocket framing are unrelated. One
|
|
163
|
+
* WebSocket message can carry several MQTT packets, one MQTT packet can be
|
|
164
|
+
* split across several WebSocket messages, and a split can fall inside the
|
|
165
|
+
* length field itself. A decoder that assumed one frame is one packet works
|
|
166
|
+
* perfectly on a quiet connection and corrupts under load, which is the worst
|
|
167
|
+
* possible failure schedule.
|
|
168
|
+
*/
|
|
169
|
+
export declare class PacketDecoder {
|
|
170
|
+
private buffer;
|
|
171
|
+
/**
|
|
172
|
+
* Add received bytes and return every complete packet now available.
|
|
173
|
+
*
|
|
174
|
+
* Throws {@link ProtocolError} on a malformed stream. The caller is expected
|
|
175
|
+
* to treat that as fatal for the connection: once framing is lost there is no
|
|
176
|
+
* way to resynchronise, because MQTT has no frame delimiter to scan for.
|
|
177
|
+
*/
|
|
178
|
+
push(chunk: Uint8Array): DecodedPacket[];
|
|
179
|
+
/** Bytes held pending the rest of a packet. Exposed for tests and diagnostics. */
|
|
180
|
+
get pending(): number;
|
|
181
|
+
}
|