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
|
@@ -0,0 +1,377 @@
|
|
|
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 MQTT 3.1.1 packet encoding and decoding, for the subset AWS
|
|
9
|
+
* IoT needs.
|
|
10
|
+
*
|
|
11
|
+
* Writing this rather than taking a dependency was a deliberate call, and the
|
|
12
|
+
* reasoning belongs next to the code it justifies.
|
|
13
|
+
*
|
|
14
|
+
* `mqtt.js` rebuilds the WebSocket path from its own parsed options, which
|
|
15
|
+
* discards the SigV4 query string, so the upgrade is rejected or hangs. The
|
|
16
|
+
* documented way round is `transformWsUrl`, a hook that exists to undo what the
|
|
17
|
+
* library just did. That is a bad sign for the one part of this plugin where a
|
|
18
|
+
* subtle failure means a user's heating silently stops responding.
|
|
19
|
+
*
|
|
20
|
+
* AWS IoT speaks 3.1.1, this plugin needs seven packet types, and the wire
|
|
21
|
+
* format is a fixed header, a variable-length integer and length-prefixed
|
|
22
|
+
* strings. It is a few hundred lines with no I/O in it, which makes it
|
|
23
|
+
* exhaustively testable without a socket. The family this plugin belongs to
|
|
24
|
+
* already hand-rolls a capped XML reader for the same reason. The dependency
|
|
25
|
+
* it replaces brings roughly twenty transitive packages into a plugin that
|
|
26
|
+
* handles a cloud password, which the security posture would rather not carry.
|
|
27
|
+
*
|
|
28
|
+
* What is deliberately absent: QoS 2 (AWS IoT does not support it), retained
|
|
29
|
+
* message handling beyond the flag, topic aliases and MQTT 5 properties. If a
|
|
30
|
+
* future NaviLink protocol needs any of them, add it here with a test rather
|
|
31
|
+
* than reaching back for the library.
|
|
32
|
+
*
|
|
33
|
+
* Section references are to the OASIS MQTT 3.1.1 specification.
|
|
34
|
+
*/
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.PacketDecoder = exports.MAX_PACKET_BYTES = exports.PacketType = void 0;
|
|
37
|
+
exports.describeConnackReturnCode = describeConnackReturnCode;
|
|
38
|
+
exports.isSubscribeFailure = isSubscribeFailure;
|
|
39
|
+
exports.encodeRemainingLength = encodeRemainingLength;
|
|
40
|
+
exports.encodeConnect = encodeConnect;
|
|
41
|
+
exports.encodePublish = encodePublish;
|
|
42
|
+
exports.encodePuback = encodePuback;
|
|
43
|
+
exports.encodeSubscribe = encodeSubscribe;
|
|
44
|
+
exports.encodePingreq = encodePingreq;
|
|
45
|
+
exports.encodeDisconnect = encodeDisconnect;
|
|
46
|
+
const errors_1 = require("../utils/errors");
|
|
47
|
+
/** Packet type codes (§2.2.1). */
|
|
48
|
+
exports.PacketType = {
|
|
49
|
+
CONNECT: 1,
|
|
50
|
+
CONNACK: 2,
|
|
51
|
+
PUBLISH: 3,
|
|
52
|
+
PUBACK: 4,
|
|
53
|
+
SUBSCRIBE: 8,
|
|
54
|
+
SUBACK: 9,
|
|
55
|
+
PINGREQ: 12,
|
|
56
|
+
PINGRESP: 13,
|
|
57
|
+
DISCONNECT: 14,
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Largest packet accepted from the broker.
|
|
61
|
+
*
|
|
62
|
+
* A status frame is a couple of kilobytes. 256 KiB is generous while keeping a
|
|
63
|
+
* malfunctioning or hostile broker from growing the heap: the remaining-length
|
|
64
|
+
* field can encode 256 MB, and a decoder that trusts it will happily buffer
|
|
65
|
+
* that much before noticing.
|
|
66
|
+
*/
|
|
67
|
+
exports.MAX_PACKET_BYTES = 262_144;
|
|
68
|
+
/** CONNACK return codes (§3.2.2.3). */
|
|
69
|
+
const CONNACK_REASONS = {
|
|
70
|
+
0: 'accepted',
|
|
71
|
+
1: 'unacceptable protocol version',
|
|
72
|
+
2: 'client identifier rejected',
|
|
73
|
+
3: 'server unavailable',
|
|
74
|
+
4: 'bad username or password',
|
|
75
|
+
5: 'not authorised',
|
|
76
|
+
};
|
|
77
|
+
/** Human-readable text for a CONNACK return code. */
|
|
78
|
+
function describeConnackReturnCode(code) {
|
|
79
|
+
return CONNACK_REASONS[code] ?? `unknown return code ${code}`;
|
|
80
|
+
}
|
|
81
|
+
/** True when a SUBACK return code means the subscription was refused (§3.9.3). */
|
|
82
|
+
function isSubscribeFailure(code) {
|
|
83
|
+
return code === 0x80;
|
|
84
|
+
}
|
|
85
|
+
// --- Encoding --------------------------------------------------------------
|
|
86
|
+
/**
|
|
87
|
+
* Encode the remaining-length field (§2.2.3).
|
|
88
|
+
*
|
|
89
|
+
* A base-128 varint, little-endian, with the top bit as the continuation flag
|
|
90
|
+
* and a hard limit of four bytes.
|
|
91
|
+
*/
|
|
92
|
+
function encodeRemainingLength(length) {
|
|
93
|
+
if (!Number.isInteger(length) || length < 0 || length > 268_435_455) {
|
|
94
|
+
throw new errors_1.ProtocolError(`remaining length ${length} is outside the encodable range`);
|
|
95
|
+
}
|
|
96
|
+
const bytes = [];
|
|
97
|
+
let value = length;
|
|
98
|
+
do {
|
|
99
|
+
let byte = value % 128;
|
|
100
|
+
value = Math.floor(value / 128);
|
|
101
|
+
if (value > 0) {
|
|
102
|
+
byte = byte | 0x80;
|
|
103
|
+
}
|
|
104
|
+
bytes.push(byte);
|
|
105
|
+
} while (value > 0);
|
|
106
|
+
return bytes;
|
|
107
|
+
}
|
|
108
|
+
/** Length-prefixed UTF-8, as every MQTT string is (§1.5.3). */
|
|
109
|
+
function encodeString(value) {
|
|
110
|
+
const bytes = Buffer.from(value, 'utf8');
|
|
111
|
+
if (bytes.length > 0xFFFF) {
|
|
112
|
+
throw new errors_1.ProtocolError(`string of ${bytes.length} bytes exceeds the MQTT field limit`);
|
|
113
|
+
}
|
|
114
|
+
return [bytes.length >> 8, bytes.length & 0xFF, ...bytes];
|
|
115
|
+
}
|
|
116
|
+
function packet(type, flags, body) {
|
|
117
|
+
return Uint8Array.from([
|
|
118
|
+
(type << 4) | flags,
|
|
119
|
+
...encodeRemainingLength(body.length),
|
|
120
|
+
...body,
|
|
121
|
+
]);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Build a CONNECT packet (§3.1).
|
|
125
|
+
*
|
|
126
|
+
* AWS IoT authenticates from the SigV4 signature on the WebSocket URL, so the
|
|
127
|
+
* username and password fields are not credentials here. The username is
|
|
128
|
+
* optional. The verified US endpoint accepts a CONNECT without one, so this
|
|
129
|
+
* plugin omits it rather than inventing an SDK marker we have not recorded.
|
|
130
|
+
*/
|
|
131
|
+
function encodeConnect(options) {
|
|
132
|
+
const { clientId, keepaliveSec, cleanSession, username, password, will } = options;
|
|
133
|
+
let flags = 0;
|
|
134
|
+
if (cleanSession) {
|
|
135
|
+
flags |= 0x02;
|
|
136
|
+
}
|
|
137
|
+
if (will !== undefined) {
|
|
138
|
+
flags |= 0x04;
|
|
139
|
+
flags |= (will.qos & 0x03) << 3;
|
|
140
|
+
if (will.retain) {
|
|
141
|
+
flags |= 0x20;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (username !== undefined) {
|
|
145
|
+
flags |= 0x80;
|
|
146
|
+
}
|
|
147
|
+
if (password !== undefined) {
|
|
148
|
+
flags |= 0x40;
|
|
149
|
+
}
|
|
150
|
+
const body = [
|
|
151
|
+
...encodeString('MQTT'),
|
|
152
|
+
// Protocol level 4 is 3.1.1 (§3.1.2.2).
|
|
153
|
+
0x04,
|
|
154
|
+
flags,
|
|
155
|
+
(keepaliveSec >> 8) & 0xFF,
|
|
156
|
+
keepaliveSec & 0xFF,
|
|
157
|
+
...encodeString(clientId),
|
|
158
|
+
];
|
|
159
|
+
if (will !== undefined) {
|
|
160
|
+
body.push(...encodeString(will.topic), ...encodeString(will.payload));
|
|
161
|
+
}
|
|
162
|
+
if (username !== undefined) {
|
|
163
|
+
body.push(...encodeString(username));
|
|
164
|
+
}
|
|
165
|
+
if (password !== undefined) {
|
|
166
|
+
body.push(...encodeString(password));
|
|
167
|
+
}
|
|
168
|
+
return packet(exports.PacketType.CONNECT, 0, body);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Build a PUBLISH packet (§3.3).
|
|
172
|
+
*
|
|
173
|
+
* `packetId` is required for QoS 1 and forbidden for QoS 0, which the
|
|
174
|
+
* specification states and this enforces: a QoS 1 publish with no identifier
|
|
175
|
+
* cannot be acknowledged, so the caller would wait for a PUBACK that can never
|
|
176
|
+
* be matched.
|
|
177
|
+
*/
|
|
178
|
+
function encodePublish(input) {
|
|
179
|
+
const { topic, payload, qos, packetId, retain = false } = input;
|
|
180
|
+
if (qos === 1 && packetId === undefined) {
|
|
181
|
+
throw new errors_1.ProtocolError('a QoS 1 publish needs a packet identifier');
|
|
182
|
+
}
|
|
183
|
+
const flags = (qos << 1) | (retain ? 1 : 0);
|
|
184
|
+
const body = [...encodeString(topic)];
|
|
185
|
+
if (qos === 1 && packetId !== undefined) {
|
|
186
|
+
body.push((packetId >> 8) & 0xFF, packetId & 0xFF);
|
|
187
|
+
}
|
|
188
|
+
body.push(...Buffer.from(payload, 'utf8'));
|
|
189
|
+
return packet(exports.PacketType.PUBLISH, flags, body);
|
|
190
|
+
}
|
|
191
|
+
/** Build a PUBACK, acknowledging a QoS 1 message the broker sent us (§3.4). */
|
|
192
|
+
function encodePuback(packetId) {
|
|
193
|
+
return packet(exports.PacketType.PUBACK, 0, [(packetId >> 8) & 0xFF, packetId & 0xFF]);
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Build a SUBSCRIBE packet (§3.8).
|
|
197
|
+
*
|
|
198
|
+
* The fixed-header flags are required to be `0b0010`; a broker must treat
|
|
199
|
+
* anything else as a protocol violation and close the connection.
|
|
200
|
+
*/
|
|
201
|
+
function encodeSubscribe(packetId, subscriptions) {
|
|
202
|
+
if (subscriptions.length === 0) {
|
|
203
|
+
throw new errors_1.ProtocolError('a subscribe needs at least one topic filter');
|
|
204
|
+
}
|
|
205
|
+
const body = [(packetId >> 8) & 0xFF, packetId & 0xFF];
|
|
206
|
+
for (const subscription of subscriptions) {
|
|
207
|
+
body.push(...encodeString(subscription.topic), subscription.qos);
|
|
208
|
+
}
|
|
209
|
+
return packet(exports.PacketType.SUBSCRIBE, 0x02, body);
|
|
210
|
+
}
|
|
211
|
+
/** Build a PINGREQ (§3.12). */
|
|
212
|
+
function encodePingreq() {
|
|
213
|
+
return packet(exports.PacketType.PINGREQ, 0, []);
|
|
214
|
+
}
|
|
215
|
+
/** Build a DISCONNECT (§3.14), which tells the broker not to send our will. */
|
|
216
|
+
function encodeDisconnect() {
|
|
217
|
+
return packet(exports.PacketType.DISCONNECT, 0, []);
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Read a remaining-length varint from `buffer` at `offset`.
|
|
221
|
+
*
|
|
222
|
+
* Returns undefined when the field is not yet complete, which is normal: a
|
|
223
|
+
* WebSocket frame boundary can fall anywhere, including inside this field.
|
|
224
|
+
*/
|
|
225
|
+
function readRemainingLength(buffer, offset) {
|
|
226
|
+
let multiplier = 1;
|
|
227
|
+
let value = 0;
|
|
228
|
+
for (let index = 0; index < 4; index += 1) {
|
|
229
|
+
const byte = buffer[offset + index];
|
|
230
|
+
if (byte === undefined) {
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
value += (byte & 0x7F) * multiplier;
|
|
234
|
+
if ((byte & 0x80) === 0) {
|
|
235
|
+
return { value, bytes: index + 1 };
|
|
236
|
+
}
|
|
237
|
+
multiplier *= 128;
|
|
238
|
+
}
|
|
239
|
+
throw new errors_1.ProtocolError('remaining length field is longer than four bytes');
|
|
240
|
+
}
|
|
241
|
+
function readUint16(buffer, offset) {
|
|
242
|
+
const high = buffer[offset];
|
|
243
|
+
const low = buffer[offset + 1];
|
|
244
|
+
if (high === undefined || low === undefined) {
|
|
245
|
+
throw new errors_1.ProtocolError('packet ended inside a two-byte field');
|
|
246
|
+
}
|
|
247
|
+
return (high << 8) | low;
|
|
248
|
+
}
|
|
249
|
+
function readString(buffer, offset) {
|
|
250
|
+
const length = readUint16(buffer, offset);
|
|
251
|
+
const start = offset + 2;
|
|
252
|
+
const end = start + length;
|
|
253
|
+
if (end > buffer.length) {
|
|
254
|
+
throw new errors_1.ProtocolError('packet ended inside a string');
|
|
255
|
+
}
|
|
256
|
+
return { value: Buffer.from(buffer.subarray(start, end)).toString('utf8'), next: end };
|
|
257
|
+
}
|
|
258
|
+
function decodePublish(flags, body) {
|
|
259
|
+
const qos = (flags >> 1) & 0x03;
|
|
260
|
+
if (qos > 1) {
|
|
261
|
+
// QoS 2 needs a four-packet handshake this client does not implement. AWS
|
|
262
|
+
// IoT does not offer it, so receiving one means something is badly wrong
|
|
263
|
+
// and pretending otherwise would drop the message silently.
|
|
264
|
+
throw new errors_1.ProtocolError(`QoS ${qos} is not supported`);
|
|
265
|
+
}
|
|
266
|
+
const { value: topic, next } = readString(body, 0);
|
|
267
|
+
let offset = next;
|
|
268
|
+
let packetId;
|
|
269
|
+
if (qos === 1) {
|
|
270
|
+
packetId = readUint16(body, offset);
|
|
271
|
+
offset += 2;
|
|
272
|
+
}
|
|
273
|
+
const result = {
|
|
274
|
+
type: exports.PacketType.PUBLISH,
|
|
275
|
+
topic,
|
|
276
|
+
payload: body.subarray(offset),
|
|
277
|
+
qos: qos,
|
|
278
|
+
retain: (flags & 0x01) === 1,
|
|
279
|
+
dup: (flags & 0x08) !== 0,
|
|
280
|
+
};
|
|
281
|
+
if (packetId !== undefined) {
|
|
282
|
+
result.packetId = packetId;
|
|
283
|
+
}
|
|
284
|
+
return result;
|
|
285
|
+
}
|
|
286
|
+
function decodeBody(type, flags, body) {
|
|
287
|
+
switch (type) {
|
|
288
|
+
case exports.PacketType.CONNACK: {
|
|
289
|
+
if (body.length < 2) {
|
|
290
|
+
throw new errors_1.ProtocolError('CONNACK is shorter than two bytes');
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
type: exports.PacketType.CONNACK,
|
|
294
|
+
sessionPresent: ((body[0] ?? 0) & 0x01) === 1,
|
|
295
|
+
returnCode: body[1] ?? 0,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
case exports.PacketType.PUBLISH:
|
|
299
|
+
return decodePublish(flags, body);
|
|
300
|
+
case exports.PacketType.PUBACK:
|
|
301
|
+
return { type: exports.PacketType.PUBACK, packetId: readUint16(body, 0) };
|
|
302
|
+
case exports.PacketType.SUBACK:
|
|
303
|
+
return {
|
|
304
|
+
type: exports.PacketType.SUBACK,
|
|
305
|
+
packetId: readUint16(body, 0),
|
|
306
|
+
returnCodes: [...body.subarray(2)],
|
|
307
|
+
};
|
|
308
|
+
case exports.PacketType.PINGRESP:
|
|
309
|
+
return { type: exports.PacketType.PINGRESP };
|
|
310
|
+
default:
|
|
311
|
+
throw new errors_1.ProtocolError(`unexpected packet type ${type} from the broker`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Reassembles packets from a byte stream.
|
|
316
|
+
*
|
|
317
|
+
* Needed because MQTT framing and WebSocket framing are unrelated. One
|
|
318
|
+
* WebSocket message can carry several MQTT packets, one MQTT packet can be
|
|
319
|
+
* split across several WebSocket messages, and a split can fall inside the
|
|
320
|
+
* length field itself. A decoder that assumed one frame is one packet works
|
|
321
|
+
* perfectly on a quiet connection and corrupts under load, which is the worst
|
|
322
|
+
* possible failure schedule.
|
|
323
|
+
*/
|
|
324
|
+
class PacketDecoder {
|
|
325
|
+
buffer = new Uint8Array(0);
|
|
326
|
+
/**
|
|
327
|
+
* Add received bytes and return every complete packet now available.
|
|
328
|
+
*
|
|
329
|
+
* Throws {@link ProtocolError} on a malformed stream. The caller is expected
|
|
330
|
+
* to treat that as fatal for the connection: once framing is lost there is no
|
|
331
|
+
* way to resynchronise, because MQTT has no frame delimiter to scan for.
|
|
332
|
+
*/
|
|
333
|
+
push(chunk) {
|
|
334
|
+
this.buffer = this.buffer.length === 0
|
|
335
|
+
? chunk
|
|
336
|
+
: concat(this.buffer, chunk);
|
|
337
|
+
const packets = [];
|
|
338
|
+
let offset = 0;
|
|
339
|
+
for (;;) {
|
|
340
|
+
const header = this.buffer[offset];
|
|
341
|
+
if (header === undefined) {
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
const length = readRemainingLength(this.buffer, offset + 1);
|
|
345
|
+
if (length === undefined) {
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
if (length.value > exports.MAX_PACKET_BYTES) {
|
|
349
|
+
throw new errors_1.ProtocolError(`packet of ${length.value} bytes exceeds the ${exports.MAX_PACKET_BYTES} byte limit`);
|
|
350
|
+
}
|
|
351
|
+
const start = offset + 1 + length.bytes;
|
|
352
|
+
const end = start + length.value;
|
|
353
|
+
if (end > this.buffer.length) {
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
packets.push(decodeBody(header >> 4, header & 0x0F, this.buffer.subarray(start, end)));
|
|
357
|
+
offset = end;
|
|
358
|
+
}
|
|
359
|
+
// Retained rather than copied when nothing was consumed, so a large packet
|
|
360
|
+
// arriving in many small frames does not re-copy its prefix each time.
|
|
361
|
+
if (offset > 0) {
|
|
362
|
+
this.buffer = this.buffer.subarray(offset);
|
|
363
|
+
}
|
|
364
|
+
return packets;
|
|
365
|
+
}
|
|
366
|
+
/** Bytes held pending the rest of a packet. Exposed for tests and diagnostics. */
|
|
367
|
+
get pending() {
|
|
368
|
+
return this.buffer.length;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
exports.PacketDecoder = PacketDecoder;
|
|
372
|
+
function concat(left, right) {
|
|
373
|
+
const merged = new Uint8Array(left.length + right.length);
|
|
374
|
+
merged.set(left, 0);
|
|
375
|
+
merged.set(right, left.length);
|
|
376
|
+
return merged;
|
|
377
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
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 One MQTT-over-WebSocket connection to AWS IoT.
|
|
8
|
+
*
|
|
9
|
+
* This is the transport and nothing else: it knows about packets, packet
|
|
10
|
+
* identifiers, keepalive and the socket. It knows nothing about NaviLink
|
|
11
|
+
* topics, commands or appliances, which live in `protocol.ts` and `session.ts`.
|
|
12
|
+
* The split is what lets the whole of this file be tested against a stand-in
|
|
13
|
+
* socket with no network and no clock.
|
|
14
|
+
*
|
|
15
|
+
* Three behaviours here are load-bearing and easy to leave out.
|
|
16
|
+
*
|
|
17
|
+
* **An unanswered ping ends the connection.** A WebSocket can stay open with
|
|
18
|
+
* nothing behind it: a NAT that dropped the mapping, a broker that vanished
|
|
19
|
+
* without a close frame. Nothing surfaces that except a ping that goes
|
|
20
|
+
* unanswered, and without this check the plugin reports yesterday's setpoint
|
|
21
|
+
* as current state for as long as the socket stays nominally open.
|
|
22
|
+
*
|
|
23
|
+
* **A failure is terminal for the connection, never for the process.** Every
|
|
24
|
+
* error path ends in {@link fail}, which settles anything waiting, reports
|
|
25
|
+
* once, and hands control to the owner to decide about reconnecting. Nothing
|
|
26
|
+
* here retries: a transport that reconnects itself competes with the session
|
|
27
|
+
* above it that also wants to.
|
|
28
|
+
*
|
|
29
|
+
* **The signed URL is never logged.** It carries the session token. The one
|
|
30
|
+
* thing this file prints about its destination is the endpoint host.
|
|
31
|
+
*/
|
|
32
|
+
import type { PluginLogger } from '../types';
|
|
33
|
+
import { type Subscription, type WillMessage } from './mqtt-codec';
|
|
34
|
+
import { type IotCredentials } from './sigv4';
|
|
35
|
+
/** Callbacks a socket implementation must drive. */
|
|
36
|
+
export interface SocketHandlers {
|
|
37
|
+
onOpen(): void;
|
|
38
|
+
onMessage(data: Uint8Array): void;
|
|
39
|
+
onClose(reason: string): void;
|
|
40
|
+
onError(error: Error): void;
|
|
41
|
+
}
|
|
42
|
+
/** The socket operations this transport needs. */
|
|
43
|
+
export interface MessageSocket {
|
|
44
|
+
send(data: Uint8Array): void;
|
|
45
|
+
close(): void;
|
|
46
|
+
}
|
|
47
|
+
/** Opens a WebSocket. Injectable so tests never touch the network. */
|
|
48
|
+
export type SocketFactory = (url: string, handlers: SocketHandlers) => MessageSocket;
|
|
49
|
+
/** A message delivered by the broker. */
|
|
50
|
+
export interface MqttMessage {
|
|
51
|
+
topic: string;
|
|
52
|
+
payload: string;
|
|
53
|
+
}
|
|
54
|
+
/** Collaborators for one connection. */
|
|
55
|
+
export interface MqttConnectionOptions {
|
|
56
|
+
log: PluginLogger;
|
|
57
|
+
credentials: IotCredentials;
|
|
58
|
+
clientId: string;
|
|
59
|
+
will?: WillMessage;
|
|
60
|
+
/**
|
|
61
|
+
* Username presented in CONNECT.
|
|
62
|
+
*
|
|
63
|
+
* Optional. AWS IoT authenticates from the URL signature. The verified
|
|
64
|
+
* endpoint accepts a CONNECT without a username, so the session leaves this
|
|
65
|
+
* unset rather than inventing an SDK marker.
|
|
66
|
+
*/
|
|
67
|
+
username?: string;
|
|
68
|
+
/** Sign the `host` header with `:443`. The fallback; see {@link presignIotWebsocketUrl}. */
|
|
69
|
+
signHostWithPort?: boolean;
|
|
70
|
+
openSocket?: SocketFactory;
|
|
71
|
+
/** Injected in tests so keepalive can be driven without real time. */
|
|
72
|
+
setTimer?: (handler: () => void, ms: number) => unknown;
|
|
73
|
+
clearTimer?: (handle: unknown) => void;
|
|
74
|
+
}
|
|
75
|
+
/** The default socket factory: the runtime's own WebSocket. */
|
|
76
|
+
export declare const openWebSocket: SocketFactory;
|
|
77
|
+
/**
|
|
78
|
+
* One live MQTT session.
|
|
79
|
+
*
|
|
80
|
+
* Single use. Once it has failed or been closed it stays that way; the owner
|
|
81
|
+
* builds a new one to reconnect. That keeps the state machine to two states
|
|
82
|
+
* and means a stale reference cannot silently resurrect a connection whose
|
|
83
|
+
* credentials have since expired.
|
|
84
|
+
*/
|
|
85
|
+
export declare class MqttConnection {
|
|
86
|
+
private readonly options;
|
|
87
|
+
private readonly decoder;
|
|
88
|
+
private readonly pending;
|
|
89
|
+
private socket;
|
|
90
|
+
private nextPacketId;
|
|
91
|
+
private connected;
|
|
92
|
+
private closed;
|
|
93
|
+
/** Set once, and the reason every later operation rejects with. */
|
|
94
|
+
private failure;
|
|
95
|
+
private connectSettle;
|
|
96
|
+
private keepaliveTimer;
|
|
97
|
+
private pingTimeoutTimer;
|
|
98
|
+
private onMessageHandler;
|
|
99
|
+
private onCloseHandler;
|
|
100
|
+
constructor(options: MqttConnectionOptions);
|
|
101
|
+
/** True while the broker has accepted us and nothing has gone wrong. */
|
|
102
|
+
get isConnected(): boolean;
|
|
103
|
+
/** Register the handler for inbound application messages. */
|
|
104
|
+
onMessage(handler: (message: MqttMessage) => void): void;
|
|
105
|
+
/**
|
|
106
|
+
* Register the handler for the connection ending.
|
|
107
|
+
*
|
|
108
|
+
* Called exactly once, for any reason including a clean {@link close}, so an
|
|
109
|
+
* owner has one place to decide whether to reconnect.
|
|
110
|
+
*/
|
|
111
|
+
onClose(handler: (error: Error) => void): void;
|
|
112
|
+
/**
|
|
113
|
+
* Open the socket and complete the MQTT handshake.
|
|
114
|
+
*
|
|
115
|
+
* Resolves when CONNACK reports acceptance. Rejects on a refused CONNACK, a
|
|
116
|
+
* socket that will not open, or a handshake that does not finish inside
|
|
117
|
+
* {@link MQTT_CONNECT_TIMEOUT_MS}.
|
|
118
|
+
*/
|
|
119
|
+
connect(): Promise<void>;
|
|
120
|
+
/**
|
|
121
|
+
* Subscribe, and confirm the broker granted every filter.
|
|
122
|
+
*
|
|
123
|
+
* A refusal is treated as fatal rather than logged and carried on with. A
|
|
124
|
+
* NaviLink session that is subscribed to some of its response topics is
|
|
125
|
+
* worse than one that failed: state arrives for some accessories and not
|
|
126
|
+
* others, which reads as a hardware fault rather than a permissions problem.
|
|
127
|
+
*/
|
|
128
|
+
subscribe(subscriptions: readonly Subscription[]): Promise<void>;
|
|
129
|
+
/**
|
|
130
|
+
* Publish at QoS 1 and wait for the broker's acknowledgement.
|
|
131
|
+
*
|
|
132
|
+
* QoS 1 rather than 0 because a control command that vanished in transit
|
|
133
|
+
* must not be reported to HomeKit as applied. Note the limit of what this
|
|
134
|
+
* proves: a PUBACK means the *broker* has the message, not that the
|
|
135
|
+
* appliance has acted on it. The appliance's answer arrives separately, as a
|
|
136
|
+
* status frame or a rejection on the failure topic.
|
|
137
|
+
*/
|
|
138
|
+
publish(topic: string, payload: string, timeoutMs: number): Promise<void>;
|
|
139
|
+
/**
|
|
140
|
+
* Close cleanly.
|
|
141
|
+
*
|
|
142
|
+
* DISCONNECT before closing the socket is what stops the broker publishing
|
|
143
|
+
* our last will. The will exists to tell the gateway we have gone away
|
|
144
|
+
* unexpectedly, so firing it on an orderly shutdown would be a lie, and on
|
|
145
|
+
* a Homebridge restart, a lie the gateway acts on.
|
|
146
|
+
*/
|
|
147
|
+
close(): void;
|
|
148
|
+
private handleOpen;
|
|
149
|
+
private handleData;
|
|
150
|
+
private handlePacket;
|
|
151
|
+
private handleConnack;
|
|
152
|
+
private handlePublish;
|
|
153
|
+
/** Send a packet and wait for the acknowledgement carrying the same id. */
|
|
154
|
+
private exchange;
|
|
155
|
+
private settle;
|
|
156
|
+
/**
|
|
157
|
+
* Allocate a packet identifier.
|
|
158
|
+
*
|
|
159
|
+
* One to 65535, wrapping, skipping any still in flight. Zero is reserved by
|
|
160
|
+
* the specification. The scan is bounded so a session that somehow filled
|
|
161
|
+
* the space fails loudly instead of looping.
|
|
162
|
+
*/
|
|
163
|
+
private takePacketId;
|
|
164
|
+
private scheduleKeepalive;
|
|
165
|
+
private send;
|
|
166
|
+
/** Send without caring whether it worked, for teardown and keepalive paths. */
|
|
167
|
+
private trySend;
|
|
168
|
+
/**
|
|
169
|
+
* End the connection, settling everything waiting on it.
|
|
170
|
+
*
|
|
171
|
+
* Idempotent, because several paths can reach it at once: a socket error is
|
|
172
|
+
* routinely followed by a close event, and a ping timeout can race the close
|
|
173
|
+
* it predicted.
|
|
174
|
+
*/
|
|
175
|
+
private fail;
|
|
176
|
+
private setTimer;
|
|
177
|
+
private clearTimer;
|
|
178
|
+
}
|