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/mqtt.js
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
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 One MQTT-over-WebSocket connection to AWS IoT.
|
|
9
|
+
*
|
|
10
|
+
* This is the transport and nothing else: it knows about packets, packet
|
|
11
|
+
* identifiers, keepalive and the socket. It knows nothing about NaviLink
|
|
12
|
+
* topics, commands or appliances, which live in `protocol.ts` and `session.ts`.
|
|
13
|
+
* The split is what lets the whole of this file be tested against a stand-in
|
|
14
|
+
* socket with no network and no clock.
|
|
15
|
+
*
|
|
16
|
+
* Three behaviours here are load-bearing and easy to leave out.
|
|
17
|
+
*
|
|
18
|
+
* **An unanswered ping ends the connection.** A WebSocket can stay open with
|
|
19
|
+
* nothing behind it: a NAT that dropped the mapping, a broker that vanished
|
|
20
|
+
* without a close frame. Nothing surfaces that except a ping that goes
|
|
21
|
+
* unanswered, and without this check the plugin reports yesterday's setpoint
|
|
22
|
+
* as current state for as long as the socket stays nominally open.
|
|
23
|
+
*
|
|
24
|
+
* **A failure is terminal for the connection, never for the process.** Every
|
|
25
|
+
* error path ends in {@link fail}, which settles anything waiting, reports
|
|
26
|
+
* once, and hands control to the owner to decide about reconnecting. Nothing
|
|
27
|
+
* here retries: a transport that reconnects itself competes with the session
|
|
28
|
+
* above it that also wants to.
|
|
29
|
+
*
|
|
30
|
+
* **The signed URL is never logged.** It carries the session token. The one
|
|
31
|
+
* thing this file prints about its destination is the endpoint host.
|
|
32
|
+
*/
|
|
33
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
34
|
+
exports.MqttConnection = exports.openWebSocket = void 0;
|
|
35
|
+
const settings_1 = require("../settings");
|
|
36
|
+
const errors_1 = require("../utils/errors");
|
|
37
|
+
const errors_2 = require("../utils/errors");
|
|
38
|
+
const mqtt_codec_1 = require("./mqtt-codec");
|
|
39
|
+
const sigv4_1 = require("./sigv4");
|
|
40
|
+
/** The default socket factory: the runtime's own WebSocket. */
|
|
41
|
+
const openWebSocket = (url, handlers) => {
|
|
42
|
+
// `mqtt` is the subprotocol AWS IoT requires on the upgrade. Omitting it
|
|
43
|
+
// gets a successful handshake and a connection that never carries a packet.
|
|
44
|
+
const socket = new WebSocket(url, ['mqtt']);
|
|
45
|
+
socket.binaryType = 'arraybuffer';
|
|
46
|
+
socket.addEventListener('open', () => handlers.onOpen());
|
|
47
|
+
socket.addEventListener('message', (event) => {
|
|
48
|
+
const data = event.data;
|
|
49
|
+
if (data instanceof ArrayBuffer) {
|
|
50
|
+
handlers.onMessage(new Uint8Array(data));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
// A text frame is a protocol violation here; MQTT is binary throughout.
|
|
54
|
+
handlers.onError(new errors_1.ProtocolError('the broker sent a non-binary frame'));
|
|
55
|
+
});
|
|
56
|
+
socket.addEventListener('close', (event) => {
|
|
57
|
+
handlers.onClose(`code ${event.code}${event.reason ? `: ${event.reason}` : ''}`);
|
|
58
|
+
});
|
|
59
|
+
// The DOM error event carries no detail by design, so there is nothing to
|
|
60
|
+
// unwrap. The close event that follows is where the useful reason is.
|
|
61
|
+
socket.addEventListener('error', () => handlers.onError(new errors_1.ConnectionError('websocket error')));
|
|
62
|
+
return {
|
|
63
|
+
send: (data) => socket.send(detach(data)),
|
|
64
|
+
close: () => socket.close(),
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
exports.openWebSocket = openWebSocket;
|
|
68
|
+
/**
|
|
69
|
+
* Copy packet bytes into an ArrayBuffer of their own.
|
|
70
|
+
*
|
|
71
|
+
* `WebSocket.send` will not take a view whose buffer might be shared, and a
|
|
72
|
+
* view handed straight to it would in any case be sent in full, including
|
|
73
|
+
* anything else that happens to live in the same buffer. The packets are a
|
|
74
|
+
* few hundred bytes each and sent rarely, so a copy costs nothing and removes
|
|
75
|
+
* a class of bug where a subarray leaks its neighbours onto the wire.
|
|
76
|
+
*/
|
|
77
|
+
function detach(data) {
|
|
78
|
+
const copy = new ArrayBuffer(data.byteLength);
|
|
79
|
+
new Uint8Array(copy).set(data);
|
|
80
|
+
return copy;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* One live MQTT session.
|
|
84
|
+
*
|
|
85
|
+
* Single use. Once it has failed or been closed it stays that way; the owner
|
|
86
|
+
* builds a new one to reconnect. That keeps the state machine to two states
|
|
87
|
+
* and means a stale reference cannot silently resurrect a connection whose
|
|
88
|
+
* credentials have since expired.
|
|
89
|
+
*/
|
|
90
|
+
class MqttConnection {
|
|
91
|
+
options;
|
|
92
|
+
decoder = new mqtt_codec_1.PacketDecoder();
|
|
93
|
+
pending = new Map();
|
|
94
|
+
socket;
|
|
95
|
+
nextPacketId = 1;
|
|
96
|
+
connected = false;
|
|
97
|
+
closed = false;
|
|
98
|
+
/** Set once, and the reason every later operation rejects with. */
|
|
99
|
+
failure;
|
|
100
|
+
connectSettle;
|
|
101
|
+
keepaliveTimer;
|
|
102
|
+
pingTimeoutTimer;
|
|
103
|
+
onMessageHandler;
|
|
104
|
+
onCloseHandler;
|
|
105
|
+
constructor(options) {
|
|
106
|
+
this.options = options;
|
|
107
|
+
}
|
|
108
|
+
/** True while the broker has accepted us and nothing has gone wrong. */
|
|
109
|
+
get isConnected() {
|
|
110
|
+
return this.connected && !this.closed;
|
|
111
|
+
}
|
|
112
|
+
/** Register the handler for inbound application messages. */
|
|
113
|
+
onMessage(handler) {
|
|
114
|
+
this.onMessageHandler = handler;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Register the handler for the connection ending.
|
|
118
|
+
*
|
|
119
|
+
* Called exactly once, for any reason including a clean {@link close}, so an
|
|
120
|
+
* owner has one place to decide whether to reconnect.
|
|
121
|
+
*/
|
|
122
|
+
onClose(handler) {
|
|
123
|
+
this.onCloseHandler = handler;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Open the socket and complete the MQTT handshake.
|
|
127
|
+
*
|
|
128
|
+
* Resolves when CONNACK reports acceptance. Rejects on a refused CONNACK, a
|
|
129
|
+
* socket that will not open, or a handshake that does not finish inside
|
|
130
|
+
* {@link MQTT_CONNECT_TIMEOUT_MS}.
|
|
131
|
+
*/
|
|
132
|
+
async connect() {
|
|
133
|
+
if (this.closed) {
|
|
134
|
+
throw this.failure ?? new errors_1.ConnectionError('this connection has already been closed');
|
|
135
|
+
}
|
|
136
|
+
const url = (0, sigv4_1.presignIotWebsocketUrl)(this.options.credentials, {
|
|
137
|
+
signHostWithPort: this.options.signHostWithPort === true,
|
|
138
|
+
});
|
|
139
|
+
// The host, never the URL. See the file header.
|
|
140
|
+
this.options.log.debug(`MQTT connecting to ${this.options.credentials.endpoint}`);
|
|
141
|
+
const open = this.options.openSocket ?? exports.openWebSocket;
|
|
142
|
+
const handshake = new Promise((resolve, reject) => {
|
|
143
|
+
this.connectSettle = {
|
|
144
|
+
resolve: () => resolve(),
|
|
145
|
+
reject,
|
|
146
|
+
timer: this.setTimer(() => {
|
|
147
|
+
this.fail(new errors_1.ConnectionError(`the broker did not complete the MQTT handshake within ${settings_1.MQTT_CONNECT_TIMEOUT_MS}ms`));
|
|
148
|
+
}, settings_1.MQTT_CONNECT_TIMEOUT_MS),
|
|
149
|
+
};
|
|
150
|
+
});
|
|
151
|
+
this.socket = open(url, {
|
|
152
|
+
onOpen: () => this.handleOpen(),
|
|
153
|
+
onMessage: (data) => this.handleData(data),
|
|
154
|
+
onClose: (reason) => this.fail(new errors_1.ConnectionError(`the broker closed the connection (${reason})`)),
|
|
155
|
+
onError: (error) => this.fail(error),
|
|
156
|
+
});
|
|
157
|
+
return handshake;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Subscribe, and confirm the broker granted every filter.
|
|
161
|
+
*
|
|
162
|
+
* A refusal is treated as fatal rather than logged and carried on with. A
|
|
163
|
+
* NaviLink session that is subscribed to some of its response topics is
|
|
164
|
+
* worse than one that failed: state arrives for some accessories and not
|
|
165
|
+
* others, which reads as a hardware fault rather than a permissions problem.
|
|
166
|
+
*/
|
|
167
|
+
async subscribe(subscriptions) {
|
|
168
|
+
const packetId = this.takePacketId();
|
|
169
|
+
const packet = await this.exchange(packetId, (0, mqtt_codec_1.encodeSubscribe)(packetId, subscriptions), settings_1.MQTT_SUBSCRIBE_TIMEOUT_MS, 'SUBSCRIBE');
|
|
170
|
+
if (packet.type !== mqtt_codec_1.PacketType.SUBACK) {
|
|
171
|
+
throw new errors_1.ProtocolError(`expected a SUBACK, got packet type ${packet.type}`);
|
|
172
|
+
}
|
|
173
|
+
const refused = packet.returnCodes
|
|
174
|
+
.map((code, index) => ({ code, topic: subscriptions[index]?.topic ?? '(unknown)' }))
|
|
175
|
+
.filter((entry) => (0, mqtt_codec_1.isSubscribeFailure)(entry.code));
|
|
176
|
+
if (refused.length > 0) {
|
|
177
|
+
// Last path segment only: the full topic embeds the gateway MAC.
|
|
178
|
+
const labels = refused.map((entry) => entry.topic.slice(entry.topic.lastIndexOf('/') + 1));
|
|
179
|
+
throw new errors_1.ProtocolError(`the broker refused ${refused.length} subscription(s): ${labels.join(', ')}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Publish at QoS 1 and wait for the broker's acknowledgement.
|
|
184
|
+
*
|
|
185
|
+
* QoS 1 rather than 0 because a control command that vanished in transit
|
|
186
|
+
* must not be reported to HomeKit as applied. Note the limit of what this
|
|
187
|
+
* proves: a PUBACK means the *broker* has the message, not that the
|
|
188
|
+
* appliance has acted on it. The appliance's answer arrives separately, as a
|
|
189
|
+
* status frame or a rejection on the failure topic.
|
|
190
|
+
*/
|
|
191
|
+
async publish(topic, payload, timeoutMs) {
|
|
192
|
+
const packetId = this.takePacketId();
|
|
193
|
+
const packet = await this.exchange(packetId, (0, mqtt_codec_1.encodePublish)({ topic, payload, qos: 1, packetId }), timeoutMs, 'PUBLISH');
|
|
194
|
+
if (packet.type !== mqtt_codec_1.PacketType.PUBACK) {
|
|
195
|
+
throw new errors_1.ProtocolError(`expected a PUBACK, got packet type ${packet.type}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Close cleanly.
|
|
200
|
+
*
|
|
201
|
+
* DISCONNECT before closing the socket is what stops the broker publishing
|
|
202
|
+
* our last will. The will exists to tell the gateway we have gone away
|
|
203
|
+
* unexpectedly, so firing it on an orderly shutdown would be a lie, and on
|
|
204
|
+
* a Homebridge restart, a lie the gateway acts on.
|
|
205
|
+
*/
|
|
206
|
+
close() {
|
|
207
|
+
if (this.closed) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (this.connected) {
|
|
211
|
+
this.trySend((0, mqtt_codec_1.encodeDisconnect)());
|
|
212
|
+
}
|
|
213
|
+
this.fail(new errors_1.ConnectionError('the connection was closed by this plugin'), { expected: true });
|
|
214
|
+
}
|
|
215
|
+
// --- Internals ------------------------------------------------------------
|
|
216
|
+
handleOpen() {
|
|
217
|
+
this.trySend((0, mqtt_codec_1.encodeConnect)({
|
|
218
|
+
clientId: this.options.clientId,
|
|
219
|
+
keepaliveSec: settings_1.MQTT_KEEPALIVE_SEC,
|
|
220
|
+
// A clean session every time. A persistent session would have the broker
|
|
221
|
+
// queue messages for us while we are away, and on reconnect we would
|
|
222
|
+
// replay a backlog of status frames oldest-first. That would publish a
|
|
223
|
+
// sequence of stale states to HomeKit before arriving at the current one.
|
|
224
|
+
cleanSession: true,
|
|
225
|
+
...(this.options.username === undefined ? {} : { username: this.options.username }),
|
|
226
|
+
...(this.options.will === undefined ? {} : { will: this.options.will }),
|
|
227
|
+
}));
|
|
228
|
+
}
|
|
229
|
+
handleData(data) {
|
|
230
|
+
let packets;
|
|
231
|
+
try {
|
|
232
|
+
packets = this.decoder.push(data);
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
// Framing is lost and MQTT has no delimiter to resynchronise on, so the
|
|
236
|
+
// only correct response is to end the connection.
|
|
237
|
+
this.fail(new errors_1.ProtocolError(`the broker sent an unreadable packet: ${(0, errors_2.describeError)(error)}`));
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
for (const packet of packets) {
|
|
241
|
+
this.handlePacket(packet);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
handlePacket(packet) {
|
|
245
|
+
switch (packet.type) {
|
|
246
|
+
case mqtt_codec_1.PacketType.CONNACK:
|
|
247
|
+
this.handleConnack(packet.returnCode);
|
|
248
|
+
return;
|
|
249
|
+
case mqtt_codec_1.PacketType.PUBLISH:
|
|
250
|
+
this.handlePublish(packet.topic, packet.payload, packet.packetId);
|
|
251
|
+
return;
|
|
252
|
+
case mqtt_codec_1.PacketType.PUBACK:
|
|
253
|
+
case mqtt_codec_1.PacketType.SUBACK:
|
|
254
|
+
this.settle(packet.packetId, packet);
|
|
255
|
+
return;
|
|
256
|
+
case mqtt_codec_1.PacketType.PINGRESP:
|
|
257
|
+
this.clearTimer(this.pingTimeoutTimer);
|
|
258
|
+
this.pingTimeoutTimer = undefined;
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
handleConnack(returnCode) {
|
|
263
|
+
if (returnCode !== 0) {
|
|
264
|
+
this.fail(new errors_1.ConnectionError(`the broker refused the connection: ${(0, mqtt_codec_1.describeConnackReturnCode)(returnCode)}`));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
this.connected = true;
|
|
268
|
+
const settle = this.connectSettle;
|
|
269
|
+
this.connectSettle = undefined;
|
|
270
|
+
if (settle !== undefined) {
|
|
271
|
+
this.clearTimer(settle.timer);
|
|
272
|
+
settle.resolve({ type: mqtt_codec_1.PacketType.PINGRESP });
|
|
273
|
+
}
|
|
274
|
+
this.scheduleKeepalive();
|
|
275
|
+
}
|
|
276
|
+
handlePublish(topic, payload, packetId) {
|
|
277
|
+
if (packetId !== undefined) {
|
|
278
|
+
// Acknowledged before the handler runs. A handler that throws must not
|
|
279
|
+
// leave the broker redelivering a message forever, and at QoS 1 the
|
|
280
|
+
// message has already been delivered whatever we do with it.
|
|
281
|
+
this.trySend((0, mqtt_codec_1.encodePuback)(packetId));
|
|
282
|
+
}
|
|
283
|
+
const handler = this.onMessageHandler;
|
|
284
|
+
if (handler === undefined) {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
handler({ topic, payload: Buffer.from(payload).toString('utf8') });
|
|
289
|
+
}
|
|
290
|
+
catch (error) {
|
|
291
|
+
// A handler fault is a bug in the layer above, not a reason to drop a
|
|
292
|
+
// working connection and reconnect into the same fault.
|
|
293
|
+
this.options.log.debug(`an MQTT message handler threw: ${(0, errors_2.describeError)(error)}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/** Send a packet and wait for the acknowledgement carrying the same id. */
|
|
297
|
+
async exchange(packetId, packet, timeoutMs, label) {
|
|
298
|
+
if (this.failure !== undefined) {
|
|
299
|
+
throw this.failure;
|
|
300
|
+
}
|
|
301
|
+
if (!this.connected) {
|
|
302
|
+
throw new errors_1.ConnectionError(`cannot send ${label}: the MQTT session is not connected`);
|
|
303
|
+
}
|
|
304
|
+
return new Promise((resolve, reject) => {
|
|
305
|
+
this.pending.set(packetId, {
|
|
306
|
+
resolve,
|
|
307
|
+
reject,
|
|
308
|
+
timer: this.setTimer(() => {
|
|
309
|
+
this.pending.delete(packetId);
|
|
310
|
+
// Not fatal to the connection. A single unacknowledged publish is
|
|
311
|
+
// worth failing on its own so the accessory that asked hears about
|
|
312
|
+
// it, while an otherwise healthy session keeps carrying state for
|
|
313
|
+
// every other accessory.
|
|
314
|
+
reject(new errors_1.ConnectionError(`the broker did not acknowledge ${label} within ${timeoutMs}ms`));
|
|
315
|
+
}, timeoutMs),
|
|
316
|
+
});
|
|
317
|
+
try {
|
|
318
|
+
this.send(packet);
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
const entry = this.pending.get(packetId);
|
|
322
|
+
this.pending.delete(packetId);
|
|
323
|
+
if (entry !== undefined) {
|
|
324
|
+
this.clearTimer(entry.timer);
|
|
325
|
+
}
|
|
326
|
+
reject(error instanceof Error ? error : new errors_1.ConnectionError(String(error)));
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
settle(packetId, packet) {
|
|
331
|
+
const entry = this.pending.get(packetId);
|
|
332
|
+
if (entry === undefined) {
|
|
333
|
+
// A duplicate or very late acknowledgement. Harmless, and worth a debug
|
|
334
|
+
// line only: at QoS 1 the broker is allowed to repeat itself.
|
|
335
|
+
this.options.log.debug(`ignoring an unmatched acknowledgement for packet ${packetId}`);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
this.pending.delete(packetId);
|
|
339
|
+
this.clearTimer(entry.timer);
|
|
340
|
+
entry.resolve(packet);
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Allocate a packet identifier.
|
|
344
|
+
*
|
|
345
|
+
* One to 65535, wrapping, skipping any still in flight. Zero is reserved by
|
|
346
|
+
* the specification. The scan is bounded so a session that somehow filled
|
|
347
|
+
* the space fails loudly instead of looping.
|
|
348
|
+
*/
|
|
349
|
+
takePacketId() {
|
|
350
|
+
for (let attempt = 0; attempt < 0xFFFF; attempt += 1) {
|
|
351
|
+
const candidate = this.nextPacketId;
|
|
352
|
+
this.nextPacketId = this.nextPacketId >= 0xFFFF ? 1 : this.nextPacketId + 1;
|
|
353
|
+
if (!this.pending.has(candidate)) {
|
|
354
|
+
return candidate;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
throw new errors_1.ProtocolError('every MQTT packet identifier is in flight');
|
|
358
|
+
}
|
|
359
|
+
scheduleKeepalive() {
|
|
360
|
+
this.clearTimer(this.keepaliveTimer);
|
|
361
|
+
this.keepaliveTimer = this.setTimer(() => {
|
|
362
|
+
if (this.closed) {
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
this.trySend((0, mqtt_codec_1.encodePingreq)());
|
|
366
|
+
this.pingTimeoutTimer = this.setTimer(() => {
|
|
367
|
+
this.fail(new errors_1.ConnectionError(`the broker did not answer a keepalive ping within ${settings_1.MQTT_PING_TIMEOUT_MS}ms`));
|
|
368
|
+
}, settings_1.MQTT_PING_TIMEOUT_MS);
|
|
369
|
+
this.scheduleKeepalive();
|
|
370
|
+
}, settings_1.MQTT_KEEPALIVE_SEC * 1_000);
|
|
371
|
+
}
|
|
372
|
+
send(packet) {
|
|
373
|
+
const socket = this.socket;
|
|
374
|
+
if (socket === undefined) {
|
|
375
|
+
throw new errors_1.ConnectionError('the MQTT socket is not open');
|
|
376
|
+
}
|
|
377
|
+
socket.send(packet);
|
|
378
|
+
}
|
|
379
|
+
/** Send without caring whether it worked, for teardown and keepalive paths. */
|
|
380
|
+
trySend(packet) {
|
|
381
|
+
try {
|
|
382
|
+
this.send(packet);
|
|
383
|
+
}
|
|
384
|
+
catch (error) {
|
|
385
|
+
this.options.log.debug(`could not write to the MQTT socket: ${(0, errors_2.describeError)(error)}`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* End the connection, settling everything waiting on it.
|
|
390
|
+
*
|
|
391
|
+
* Idempotent, because several paths can reach it at once: a socket error is
|
|
392
|
+
* routinely followed by a close event, and a ping timeout can race the close
|
|
393
|
+
* it predicted.
|
|
394
|
+
*/
|
|
395
|
+
fail(error, options = {}) {
|
|
396
|
+
if (this.closed) {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
this.closed = true;
|
|
400
|
+
this.connected = false;
|
|
401
|
+
this.failure = error;
|
|
402
|
+
this.clearTimer(this.keepaliveTimer);
|
|
403
|
+
this.clearTimer(this.pingTimeoutTimer);
|
|
404
|
+
this.keepaliveTimer = undefined;
|
|
405
|
+
this.pingTimeoutTimer = undefined;
|
|
406
|
+
const handshake = this.connectSettle;
|
|
407
|
+
this.connectSettle = undefined;
|
|
408
|
+
if (handshake !== undefined) {
|
|
409
|
+
this.clearTimer(handshake.timer);
|
|
410
|
+
handshake.reject(error);
|
|
411
|
+
}
|
|
412
|
+
for (const [, entry] of this.pending) {
|
|
413
|
+
this.clearTimer(entry.timer);
|
|
414
|
+
entry.reject(error);
|
|
415
|
+
}
|
|
416
|
+
this.pending.clear();
|
|
417
|
+
try {
|
|
418
|
+
this.socket?.close();
|
|
419
|
+
}
|
|
420
|
+
catch (closeError) {
|
|
421
|
+
this.options.log.debug(`could not close the MQTT socket: ${(0, errors_2.describeError)(closeError)}`);
|
|
422
|
+
}
|
|
423
|
+
this.socket = undefined;
|
|
424
|
+
if (options.expected !== true) {
|
|
425
|
+
this.options.log.debug(`MQTT session ended: ${(0, errors_2.describeError)(error)}`);
|
|
426
|
+
}
|
|
427
|
+
this.onCloseHandler?.(error);
|
|
428
|
+
}
|
|
429
|
+
setTimer(handler, ms) {
|
|
430
|
+
if (this.options.setTimer !== undefined) {
|
|
431
|
+
return this.options.setTimer(handler, ms);
|
|
432
|
+
}
|
|
433
|
+
const timer = setTimeout(handler, ms);
|
|
434
|
+
// Nothing awaits these directly, and a keepalive must not be the reason a
|
|
435
|
+
// shutdown cannot finish.
|
|
436
|
+
timer.unref?.();
|
|
437
|
+
return timer;
|
|
438
|
+
}
|
|
439
|
+
clearTimer(handle) {
|
|
440
|
+
if (handle === undefined) {
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (this.options.clearTimer !== undefined) {
|
|
444
|
+
this.options.clearTimer(handle);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
clearTimeout(handle);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
exports.MqttConnection = MqttConnection;
|
|
@@ -0,0 +1,212 @@
|
|
|
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 request frames: the envelope, and the commands inside
|
|
8
|
+
* it.
|
|
9
|
+
*
|
|
10
|
+
* Every message to a gateway has the same outer shape. A `request` object
|
|
11
|
+
* carries the command code and its arguments; the envelope around it says who
|
|
12
|
+
* is asking, where the answer should go, and which protocol revision is being
|
|
13
|
+
* spoken.
|
|
14
|
+
*
|
|
15
|
+
* ```json
|
|
16
|
+
* {
|
|
17
|
+
* "clientID": "…",
|
|
18
|
+
* "protocolVersion": 1,
|
|
19
|
+
* "request": { "command": 16777217, "macAddress": "…", "…": "…" },
|
|
20
|
+
* "requestTopic": "cmd/1/navilink-…/status/start",
|
|
21
|
+
* "responseTopic": "cmd/1/…/res/channelinfo",
|
|
22
|
+
* "sessionID": "1700000000000"
|
|
23
|
+
* }
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* The command codes are not arbitrary. They are a class in the high byte and
|
|
27
|
+
* an index in the low: `0x01000001` is the first read, `0x01000004` the
|
|
28
|
+
* fourth. They are written in hex here with the decimal alongside. The
|
|
29
|
+
* decimal is what appears in a capture, and the hex is what makes the
|
|
30
|
+
* pattern visible.
|
|
31
|
+
*
|
|
32
|
+
* Nothing in this file talks to a socket. It builds objects, so the frames
|
|
33
|
+
* this plugin sends can be asserted on byte for byte in a test.
|
|
34
|
+
*/
|
|
35
|
+
import type { TemperatureScale } from '../utils/temperature';
|
|
36
|
+
import type { TopicIdentity } from './topics';
|
|
37
|
+
/**
|
|
38
|
+
* Protocol revision sent in every envelope.
|
|
39
|
+
*
|
|
40
|
+
* `1` is what the gateway accepts for every frame this plugin sends.
|
|
41
|
+
*/
|
|
42
|
+
export declare const PROTOCOL_VERSION = 1;
|
|
43
|
+
/**
|
|
44
|
+
* Command codes.
|
|
45
|
+
*
|
|
46
|
+
* The high byte is the class: `0x01` reads, `0x02` writes. The low byte is
|
|
47
|
+
* the operation index. `0x02000000 | index` is the write for that
|
|
48
|
+
* operation.
|
|
49
|
+
*
|
|
50
|
+
* These are sent to a gas appliance, so the codes and mode strings are the
|
|
51
|
+
* ones the gateway dispatches on, not a generated pair.
|
|
52
|
+
*/
|
|
53
|
+
export declare const Command: {
|
|
54
|
+
/** Ask what the gateway has attached. Answered on `res/channelinfo`. */
|
|
55
|
+
readonly CHANNEL_INFO: 16777217;
|
|
56
|
+
/** Ask a channel what it is doing. Answered on `res/channelstatus`. */
|
|
57
|
+
readonly CHANNEL_STATUS: 16777220;
|
|
58
|
+
/** Switch the appliance on or off. */
|
|
59
|
+
readonly POWER: 33554433;
|
|
60
|
+
/** Enable or disable the space-heating loop. */
|
|
61
|
+
readonly HEAT: 33554434;
|
|
62
|
+
/** Set the domestic hot water setpoint. */
|
|
63
|
+
readonly DHW_TEMPERATURE: 33554435;
|
|
64
|
+
/** Set the space-heating water setpoint. */
|
|
65
|
+
readonly HEAT_TEMPERATURE: 33554436;
|
|
66
|
+
/** Start, stop or warm up the recirculation pump. */
|
|
67
|
+
readonly ON_DEMAND: 33554437;
|
|
68
|
+
/** Set the recirculation setpoint. */
|
|
69
|
+
readonly RECIRCULATION_TEMPERATURE: 33554439;
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* The `control.mode` string that accompanies each write command.
|
|
73
|
+
*
|
|
74
|
+
* The command code alone is not enough: the gateway dispatches on the mode
|
|
75
|
+
* string, and the capitalisation is not consistent between them:
|
|
76
|
+
* `DHWTemperature` but `heatTemperature`, `power` but not `Power`. The
|
|
77
|
+
* table spells each one out because the gateway matches the string as
|
|
78
|
+
* written.
|
|
79
|
+
*/
|
|
80
|
+
export declare const ControlMode: {
|
|
81
|
+
readonly POWER: "power";
|
|
82
|
+
readonly HEAT: "heat";
|
|
83
|
+
readonly DHW_TEMPERATURE: "DHWTemperature";
|
|
84
|
+
readonly HEAT_TEMPERATURE: "heatTemperature";
|
|
85
|
+
readonly ON_DEMAND: "onDemand";
|
|
86
|
+
readonly RECIRCULATION_TEMPERATURE: "recirculation";
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* The appliance's boolean encoding.
|
|
90
|
+
*
|
|
91
|
+
* Not zero and one. Zero means "unknown" throughout this protocol, so a
|
|
92
|
+
* command carrying it is asking for nothing. Disabled features report `2`
|
|
93
|
+
* on the read side, and writes use the same pair.
|
|
94
|
+
*/
|
|
95
|
+
export declare const OnOff: {
|
|
96
|
+
readonly ON: 1;
|
|
97
|
+
readonly OFF: 2;
|
|
98
|
+
};
|
|
99
|
+
/** On-demand recirculation takes a third value the other flags do not. */
|
|
100
|
+
export declare const ON_DEMAND_WARMUP = 3;
|
|
101
|
+
/** What one request needs to address a gateway. */
|
|
102
|
+
export interface RequestContext extends TopicIdentity {
|
|
103
|
+
/** The opaque discriminator from the device list. Often an empty string. */
|
|
104
|
+
additionalValue: string;
|
|
105
|
+
}
|
|
106
|
+
/** A frame ready to publish: where it goes, and what it says. */
|
|
107
|
+
export interface OutboundFrame {
|
|
108
|
+
topic: string;
|
|
109
|
+
payload: string;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Ask the gateway to describe itself.
|
|
113
|
+
*
|
|
114
|
+
* The first thing sent on a new session. Until it is answered nothing else is
|
|
115
|
+
* meaningful: the reply carries `temperatureType`, without which every
|
|
116
|
+
* temperature in every later frame is unreadable.
|
|
117
|
+
*/
|
|
118
|
+
export declare function channelInfoRequest(input: {
|
|
119
|
+
context: RequestContext;
|
|
120
|
+
topics: {
|
|
121
|
+
start: string;
|
|
122
|
+
};
|
|
123
|
+
responseTopic: string;
|
|
124
|
+
}): OutboundFrame;
|
|
125
|
+
/**
|
|
126
|
+
* Ask one channel for its current state.
|
|
127
|
+
*
|
|
128
|
+
* `unitNumberStart` and `unitNumberEnd` select which appliances in a cascade
|
|
129
|
+
* to report. For a single appliance both ends are 1. The range is inclusive
|
|
130
|
+
* and one-based, which is why a caller passes a count rather than an index.
|
|
131
|
+
*/
|
|
132
|
+
export declare function channelStatusRequest(input: {
|
|
133
|
+
context: RequestContext;
|
|
134
|
+
topics: {
|
|
135
|
+
statusRequest: string;
|
|
136
|
+
};
|
|
137
|
+
responseTopic: string;
|
|
138
|
+
channelNumber: number;
|
|
139
|
+
unitCount: number;
|
|
140
|
+
}): OutboundFrame;
|
|
141
|
+
/** Everything a control frame needs beyond the command itself. */
|
|
142
|
+
export interface ControlInput {
|
|
143
|
+
context: RequestContext;
|
|
144
|
+
topics: {
|
|
145
|
+
control: string;
|
|
146
|
+
};
|
|
147
|
+
/**
|
|
148
|
+
* Where the gateway should answer.
|
|
149
|
+
*
|
|
150
|
+
* The **channel-status** response topic, not a control-specific one. A
|
|
151
|
+
* control is answered with a status frame, which is the appliance
|
|
152
|
+
* confirming what it now holds rather than acknowledging what it was told.
|
|
153
|
+
*/
|
|
154
|
+
responseTopic: string;
|
|
155
|
+
channelNumber: number;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Build a control frame.
|
|
159
|
+
*
|
|
160
|
+
* `param` is always an array, even for a single scalar and even when empty.
|
|
161
|
+
* That is the vendor's shape, not a convenience: the gateway parses the field
|
|
162
|
+
* as a list and a bare number is rejected.
|
|
163
|
+
*/
|
|
164
|
+
export declare function controlRequest(input: ControlInput & {
|
|
165
|
+
command: number;
|
|
166
|
+
mode: string;
|
|
167
|
+
param: readonly number[];
|
|
168
|
+
}): OutboundFrame;
|
|
169
|
+
/** Switch the appliance on or off. */
|
|
170
|
+
export declare function powerControl(input: ControlInput & {
|
|
171
|
+
on: boolean;
|
|
172
|
+
}): OutboundFrame;
|
|
173
|
+
/** Enable or disable the space-heating loop. */
|
|
174
|
+
export declare function heatingEnableControl(input: ControlInput & {
|
|
175
|
+
on: boolean;
|
|
176
|
+
}): OutboundFrame;
|
|
177
|
+
/** Set the domestic hot water setpoint, in the appliance's native scale. */
|
|
178
|
+
export declare function dhwSetpointControl(input: ControlInput & {
|
|
179
|
+
native: number;
|
|
180
|
+
scale: TemperatureScale;
|
|
181
|
+
}): OutboundFrame;
|
|
182
|
+
/** Set the space-heating water setpoint, in the appliance's native scale. */
|
|
183
|
+
export declare function heatingSetpointControl(input: ControlInput & {
|
|
184
|
+
native: number;
|
|
185
|
+
scale: TemperatureScale;
|
|
186
|
+
}): OutboundFrame;
|
|
187
|
+
/** Start or stop the recirculation pump. */
|
|
188
|
+
export declare function onDemandControl(input: ControlInput & {
|
|
189
|
+
on: boolean;
|
|
190
|
+
}): OutboundFrame;
|
|
191
|
+
/**
|
|
192
|
+
* The message the broker publishes if this client disappears.
|
|
193
|
+
*
|
|
194
|
+
* `status: 0` means an app has gone away. The gateway uses this to decide
|
|
195
|
+
* whether anyone is watching, which on some firmware governs how eagerly it
|
|
196
|
+
* pushes updates. `os: 'A'` is what the app sends; the gateway has not been
|
|
197
|
+
* observed to treat it as meaningful.
|
|
198
|
+
*
|
|
199
|
+
* Registered as the MQTT will rather than sent, so it fires precisely when
|
|
200
|
+
* this client stops answering. That is the one case it is for, and the one
|
|
201
|
+
* case where the plugin cannot send anything itself.
|
|
202
|
+
*/
|
|
203
|
+
export declare function appDisconnectWill(context: RequestContext, topic: string): string;
|
|
204
|
+
/**
|
|
205
|
+
* Encode a setpoint for a control command.
|
|
206
|
+
*
|
|
207
|
+
* Kept here rather than inlined at the call site because the wire encoding of
|
|
208
|
+
* a temperature has to be identical in a command and in the status frame that
|
|
209
|
+
* reports it back. If the two ever disagree, a setpoint written as 120 reads
|
|
210
|
+
* back as 60 and the thermostat appears to halve itself on every write.
|
|
211
|
+
*/
|
|
212
|
+
export declare function encodeSetpoint(native: number, scale: TemperatureScale): number;
|