mysa-js-sdk 3.0.0 → 3.1.1
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 +50 -0
- package/README.md +4 -5
- package/dist/index.cjs +267 -62
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +112 -5
- package/dist/index.d.ts +112 -5
- package/dist/index.js +266 -62
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,55 @@
|
|
|
1
1
|
# mysa-js-sdk
|
|
2
2
|
|
|
3
|
+
## 3.1.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#121](https://github.com/bourquep/mysa2mqtt/pull/121) [`8fab111`](https://github.com/bourquep/mysa2mqtt/commit/8fab111568255ea6130123be8dd3fcf5cbb08b5b) Thanks [@souvik101990](https://github.com/souvik101990)! - Derive AC fan modes from `SupportedCaps` and preserve state on fan-mode changes (CodeNum=1117).
|
|
8
|
+
|
|
9
|
+
AC-V1-X thermostats (Mysa for Mini-Split) report their supported fan speeds through `SupportedCaps.fanSpeeds` and use canonical `fn` values (`[1, 2, 4, 6]`) that differ from the legacy universal mapping. `mysa2mqtt` now:
|
|
10
|
+
|
|
11
|
+
- recognizes the canonical `fn=2/4/6` values on the receive path so the current fan speed is reported instead of dropped;
|
|
12
|
+
- derives the advertised `fan_modes` from the device's actual `SupportedCaps` instead of a hardcoded list (devices without fan-speed support advertise only `auto`), deduplicating modes that map from both legacy and canonical raw values;
|
|
13
|
+
- rejects fan-mode commands the device doesn't support instead of silently reapplying the current state; and
|
|
14
|
+
- preserves the current target temperature and climate mode when changing fan mode, and keeps the current fan mode when a state update omits the fan speed.
|
|
15
|
+
|
|
16
|
+
`mysa-js-sdk` adds an optional per-mode `fanSpeeds` field to `SupportedCaps.modes` (the top-level `fanSpeeds` field was already present).
|
|
17
|
+
|
|
18
|
+
- [#217](https://github.com/bourquep/mysa2mqtt/pull/217) [`0da4a1d`](https://github.com/bourquep/mysa2mqtt/commit/0da4a1d4e95dddf9638ec00747895008a10f7ba8) Thanks [@bourquep](https://github.com/bourquep)! - Updated dependencies to latest versions
|
|
19
|
+
|
|
20
|
+
- [#220](https://github.com/bourquep/mysa2mqtt/pull/220) [`f0dc8c1`](https://github.com/bourquep/mysa2mqtt/commit/f0dc8c1c812e469cde891b513a812e717d584aff) Thanks [@dependabot](https://github.com/apps/dependabot)! - chore(deps): Bump @aws-sdk/client-iot from 3.1090.0 to 3.1094.0
|
|
21
|
+
|
|
22
|
+
- [#219](https://github.com/bourquep/mysa2mqtt/pull/219) [`6bd3f92`](https://github.com/bourquep/mysa2mqtt/commit/6bd3f920dc85697c2d9665b31e25b36767884ecd) Thanks [@dependabot](https://github.com/apps/dependabot)! - chore(deps): Bump @aws-sdk/credential-providers from 3.1090.0 to 3.1094.0
|
|
23
|
+
|
|
24
|
+
## 3.1.0
|
|
25
|
+
|
|
26
|
+
### Minor Changes
|
|
27
|
+
|
|
28
|
+
- [#211](https://github.com/bourquep/mysa2mqtt/pull/211) [`5dc3270`](https://github.com/bourquep/mysa2mqtt/commit/5dc32709beee8fa7caf487f422c0ed97ebc7c4a2) Thanks [@bourquep](https://github.com/bourquep)! - Support Mysa in-floor heating thermostats (INF-V1-0) ([#94](https://github.com/bourquep/mysa2mqtt/issues/94)).
|
|
29
|
+
|
|
30
|
+
- Parse the in-floor-specific status fields — floor-probe temperature (`flrSnsrTemp`), binary heating-relay state (`heatStat`), tracked sensor (`trackedSnsr`) and line voltage (`lineVtg`) — which share the V2 status message type (`msg: 40`). `heatStat` maps onto the emitted `dutyCycle`, and the floor-probe reading is surfaced as a new `Status.floorTemperature`.
|
|
31
|
+
- Send the correct control command `type` (3) for in-floor thermostats so setpoint and mode changes take effect instead of being silently ignored.
|
|
32
|
+
|
|
33
|
+
- [#212](https://github.com/bourquep/mysa2mqtt/pull/212) [`a1000a7`](https://github.com/bourquep/mysa2mqtt/commit/a1000a7475c4487931edddab678ace6558a1d0e7) Thanks [@bourquep](https://github.com/bourquep)! - Add a `mysa2mqtt-capture` tool (and the underlying `MysaApiClient.startRawTopicCapture()` SDK method) to record the raw AWS IoT Device Shadow traffic of unsupported thermostats, most notably the central-HVAC ST-V1.
|
|
34
|
+
|
|
35
|
+
Unlike the real-time path, `startRawTopicCapture()` subscribes to arbitrary MQTT topic filters and relays every message verbatim (full topic + decoded payload) with no parsing, re-subscribing across reconnects. The `mysa2mqtt-capture` command uses it to dump a device's REST metadata and passively record every shadow message to a file, providing the raw material needed to implement support for a new device family. Run `npm run capture -w mysa2mqtt -- --help` for usage.
|
|
36
|
+
|
|
37
|
+
### Patch Changes
|
|
38
|
+
|
|
39
|
+
- [#205](https://github.com/bourquep/mysa2mqtt/pull/205) [`b5bf8f9`](https://github.com/bourquep/mysa2mqtt/commit/b5bf8f922c90a2342466e9ea1ba7e398ff0cd5d6) Thanks [@bourquep](https://github.com/bourquep)! - Fix fan-speed `fn` mapping for AC-V1-X (CodeNum=1117) devices ([#179](https://github.com/bourquep/mysa2mqtt/issues/179)).
|
|
40
|
+
|
|
41
|
+
- Derive the send-side `fn` values from the device's reported `SupportedCaps.fanSpeeds` (positionally mapped to the canonical `[auto, low, medium, high, max]` order) instead of a hardcoded universal map. Devices that don't report `fanSpeeds` keep the previous behaviour.
|
|
42
|
+
- Recognise the CodeNum=1117 canonical receive values (`fn` 2/4/6 → low/medium/high) so the current fan speed is surfaced instead of coming back `undefined`.
|
|
43
|
+
- Add the `fanSpeeds` field to the `SupportedCaps` type so consumers no longer need to cast to access it.
|
|
44
|
+
- Throw a new `UnsupportedFanSpeedError` when a requested fan speed is not supported by the target device (e.g. `max` on a device that only exposes auto/low/medium/high), instead of silently publishing a no-op command.
|
|
45
|
+
|
|
46
|
+
- [#202](https://github.com/bourquep/mysa2mqtt/pull/202) [`49d3017`](https://github.com/bourquep/mysa2mqtt/commit/49d3017fd301c1b79560d1a6403927a7c15de3be) Thanks [@bourquep](https://github.com/bourquep)! - Harden the MQTT connection against `AWS_ERROR_MQTT_UNEXPECTED_HANGUP` interrupt storms ([#178](https://github.com/bourquep/mysa2mqtt/issues/178)).
|
|
47
|
+
|
|
48
|
+
- Switch to clean MQTT sessions so the broker no longer redelivers a backlog of queued QoS1 messages on reconnect (the source of the ~1000x message bursts and exponential Home Assistant database growth) and so forced resets no longer leave orphaned broker sessions.
|
|
49
|
+
- Make the forced connection reset repeatable with exponential backoff instead of one-shot, so a persistent storm keeps recovering (fresh client id + fresh credentials) rather than giving up.
|
|
50
|
+
- Tag each connection with a generation and ignore events from discarded connections, and ensure the reset never rejects — preventing the in-flight publish crash (`AWS_ERROR_MQTT_CONNECTION_DESTROYED`) that terminated the process.
|
|
51
|
+
- Add interrupt dwell-time and session diagnostics to help pin down the server-side hangup trigger.
|
|
52
|
+
|
|
3
53
|
## 3.0.0
|
|
4
54
|
|
|
5
55
|
### Major Changes
|
package/README.md
CHANGED
|
@@ -203,8 +203,7 @@ copyright notice and license text in any copy of the software or substantial por
|
|
|
203
203
|
|
|
204
204
|
## Support
|
|
205
205
|
|
|
206
|
-
For bug reports and feature requests, please use the [GitHub Issues](https://github.com/bourquep/mysa2mqtt/issues)
|
|
207
|
-
page.
|
|
206
|
+
For bug reports and feature requests, please use the [GitHub Issues](https://github.com/bourquep/mysa2mqtt/issues) page.
|
|
208
207
|
|
|
209
208
|
For general questions and discussions, join our [Discussion Forum](https://github.com/bourquep/mysa2mqtt/discussions).
|
|
210
209
|
|
|
@@ -216,6 +215,6 @@ protocol which is being used by this library.
|
|
|
216
215
|
|
|
217
216
|
## Contributors ✨
|
|
218
217
|
|
|
219
|
-
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors)
|
|
220
|
-
|
|
221
|
-
|
|
218
|
+
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. See the
|
|
219
|
+
[contributor list](https://github.com/bourquep/mysa2mqtt#contributors-) in the repository README. Contributions of any
|
|
220
|
+
kind welcome!
|
package/dist/index.cjs
CHANGED
|
@@ -60,6 +60,7 @@ __export(index_exports, {
|
|
|
60
60
|
OutMessageType: () => OutMessageType,
|
|
61
61
|
UnauthenticatedError: () => UnauthenticatedError,
|
|
62
62
|
UnknownDeviceError: () => UnknownDeviceError,
|
|
63
|
+
UnsupportedFanSpeedError: () => UnsupportedFanSpeedError,
|
|
63
64
|
VoidLogger: () => VoidLogger
|
|
64
65
|
});
|
|
65
66
|
module.exports = __toCommonJS(index_exports);
|
|
@@ -118,6 +119,27 @@ var UnknownDeviceError = class extends Error {
|
|
|
118
119
|
}
|
|
119
120
|
deviceId;
|
|
120
121
|
};
|
|
122
|
+
var UnsupportedFanSpeedError = class extends Error {
|
|
123
|
+
/**
|
|
124
|
+
* Creates a new UnsupportedFanSpeedError instance.
|
|
125
|
+
*
|
|
126
|
+
* @param deviceId - The id of the device the command was aimed at.
|
|
127
|
+
* @param fanSpeed - The requested fan speed that the device does not support.
|
|
128
|
+
* @param supportedFanSpeeds - The fan speeds the device does support.
|
|
129
|
+
*/
|
|
130
|
+
constructor(deviceId, fanSpeed, supportedFanSpeeds) {
|
|
131
|
+
super(
|
|
132
|
+
`Device '${deviceId}' does not support the '${fanSpeed}' fan speed. Supported fan speeds: ${supportedFanSpeeds.join(", ") || "(none)"}.`
|
|
133
|
+
);
|
|
134
|
+
this.deviceId = deviceId;
|
|
135
|
+
this.fanSpeed = fanSpeed;
|
|
136
|
+
this.supportedFanSpeeds = supportedFanSpeeds;
|
|
137
|
+
this.name = "UnsupportedFanSpeedError";
|
|
138
|
+
}
|
|
139
|
+
deviceId;
|
|
140
|
+
fanSpeed;
|
|
141
|
+
supportedFanSpeeds;
|
|
142
|
+
};
|
|
121
143
|
var MqttPublishError = class extends Error {
|
|
122
144
|
/**
|
|
123
145
|
* Creates a new MqttPublishError instance.
|
|
@@ -274,6 +296,43 @@ var MqttEndpoint = "a3q27gia9qg3zy-ats.iot.us-east-1.amazonaws.com";
|
|
|
274
296
|
var MysaApiBaseUrl = "https://app-prod.mysa.cloud";
|
|
275
297
|
var RealtimeKeepAliveInterval = import_dayjs.default.duration(5, "minutes");
|
|
276
298
|
var PublishAckTimeout = import_dayjs.default.duration(30, "seconds");
|
|
299
|
+
var MqttInterruptWindow = import_dayjs.default.duration(30, "seconds");
|
|
300
|
+
var MqttInterruptThreshold = 3;
|
|
301
|
+
var MqttResetBaseDelay = import_dayjs.default.duration(1, "second");
|
|
302
|
+
var MqttResetMaxDelay = import_dayjs.default.duration(30, "seconds");
|
|
303
|
+
var MqttStabilityWindow = import_dayjs.default.duration(60, "seconds");
|
|
304
|
+
var CanonicalFanSpeedOrder = ["auto", "low", "medium", "high", "max"];
|
|
305
|
+
var LegacyFanSpeedSendMap = { auto: 1, low: 3, medium: 5, high: 7, max: 8 };
|
|
306
|
+
var FanSpeedReceiveMap = {
|
|
307
|
+
1: "auto",
|
|
308
|
+
2: "low",
|
|
309
|
+
// CodeNum=1117 canonical low
|
|
310
|
+
3: "low",
|
|
311
|
+
// legacy
|
|
312
|
+
4: "medium",
|
|
313
|
+
// CodeNum=1117 canonical medium
|
|
314
|
+
5: "medium",
|
|
315
|
+
// legacy
|
|
316
|
+
6: "high",
|
|
317
|
+
// CodeNum=1117 canonical high
|
|
318
|
+
7: "high",
|
|
319
|
+
// legacy
|
|
320
|
+
8: "max"
|
|
321
|
+
};
|
|
322
|
+
function buildFanSpeedSendMap(device) {
|
|
323
|
+
var _a;
|
|
324
|
+
const fanSpeeds = (_a = device.SupportedCaps) == null ? void 0 : _a.fanSpeeds;
|
|
325
|
+
if (!fanSpeeds || fanSpeeds.length === 0) {
|
|
326
|
+
return LegacyFanSpeedSendMap;
|
|
327
|
+
}
|
|
328
|
+
const map = {};
|
|
329
|
+
CanonicalFanSpeedOrder.forEach((name, index) => {
|
|
330
|
+
if (index < fanSpeeds.length) {
|
|
331
|
+
map[name] = fanSpeeds[index];
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
return map;
|
|
335
|
+
}
|
|
277
336
|
var MysaApiClient = class {
|
|
278
337
|
/** The credentials of the Mysa account this client authenticates as. */
|
|
279
338
|
_credentials;
|
|
@@ -297,8 +356,21 @@ var MysaApiClient = class {
|
|
|
297
356
|
_mqttInterrupts = [];
|
|
298
357
|
/** Whether a forced MQTT reset is currently in progress (guards against re-entrancy). */
|
|
299
358
|
_mqttResetInProgress = false;
|
|
359
|
+
/** Monotonic id of the current MQTT connection; used to ignore events from discarded connections. */
|
|
360
|
+
_mqttGeneration = 0;
|
|
361
|
+
/** Consecutive forced resets without an intervening stable period; drives reset backoff. */
|
|
362
|
+
_mqttConsecutiveResets = 0;
|
|
363
|
+
/** Timestamp (ms) of the most recent successful MQTT (re)connect, for interrupt dwell-time diagnostics. */
|
|
364
|
+
_mqttLastConnectionSuccessAt;
|
|
365
|
+
/** Timer that clears the consecutive-reset counter once a connection stays healthy. */
|
|
366
|
+
_mqttStabilityTimer;
|
|
300
367
|
/** The device IDs that are currently being updated in real-time, mapped to their respective timeouts. */
|
|
301
368
|
_realtimeDeviceIds = /* @__PURE__ */ new Map();
|
|
369
|
+
/**
|
|
370
|
+
* Raw topic filters registered via {@link startRawTopicCapture}, mapped to their message handlers. Re-subscribed on
|
|
371
|
+
* every reconnect so a debug capture survives connection resets.
|
|
372
|
+
*/
|
|
373
|
+
_rawTopicCaptures = /* @__PURE__ */ new Map();
|
|
302
374
|
/** The cached devices object, if any. */
|
|
303
375
|
_cachedDevices;
|
|
304
376
|
/**
|
|
@@ -352,6 +424,7 @@ var MysaApiClient = class {
|
|
|
352
424
|
this._cognitoUserSession = void 0;
|
|
353
425
|
this._mqttClientId = void 0;
|
|
354
426
|
this._mqttInterrupts = [];
|
|
427
|
+
this._mqttConsecutiveResets = 0;
|
|
355
428
|
const { username, password } = this._credentials;
|
|
356
429
|
return new Promise((resolve, reject) => {
|
|
357
430
|
const user = new import_amazon_cognito_identity_js.CognitoUser({
|
|
@@ -543,6 +616,7 @@ var MysaApiClient = class {
|
|
|
543
616
|
* unchanged).
|
|
544
617
|
* @throws {@link UnauthenticatedError} When the client cannot authenticate with its credentials.
|
|
545
618
|
* @throws {@link UnknownDeviceError} When the device id does not match any device on the account.
|
|
619
|
+
* @throws {@link UnsupportedFanSpeedError} When the requested fan speed is not supported by the device.
|
|
546
620
|
* @throws {@link Error} When MQTT connection or command sending fails.
|
|
547
621
|
*/
|
|
548
622
|
async setDeviceState(deviceId, setPoint, mode, fanSpeed) {
|
|
@@ -560,7 +634,10 @@ var MysaApiClient = class {
|
|
|
560
634
|
const now = (0, import_dayjs.default)();
|
|
561
635
|
this._logger.debug(`Sending request to set device state for '${deviceId}'...`);
|
|
562
636
|
const modeMap = { off: 1, auto: 2, heat: 3, cool: 4, fan_only: 5, dry: 6 };
|
|
563
|
-
const fanSpeedMap =
|
|
637
|
+
const fanSpeedMap = buildFanSpeedSendMap(device);
|
|
638
|
+
if (fanSpeed !== void 0 && fanSpeedMap[fanSpeed] === void 0) {
|
|
639
|
+
throw new UnsupportedFanSpeedError(deviceId, fanSpeed, Object.keys(fanSpeedMap));
|
|
640
|
+
}
|
|
564
641
|
const payload = serializeMqttPayload({
|
|
565
642
|
msg: 44 /* CHANGE_DEVICE_STATE */,
|
|
566
643
|
id: now.valueOf(),
|
|
@@ -577,7 +654,7 @@ var MysaApiClient = class {
|
|
|
577
654
|
resp: 2,
|
|
578
655
|
body: {
|
|
579
656
|
ver: 1,
|
|
580
|
-
type: device.Model.startsWith("BB-V1") || device.Model.startsWith("v1") ? 1 : device.Model.startsWith("AC-V1") ? 2 : device.Model.startsWith("BB-V2") ? device.Model.endsWith("-L") ? 5 : 4 : 0,
|
|
657
|
+
type: device.Model.startsWith("BB-V1") || device.Model.startsWith("v1") ? 1 : device.Model.startsWith("AC-V1") ? 2 : device.Model.startsWith("INF-V1") ? 3 : device.Model.startsWith("BB-V2") ? device.Model.endsWith("-L") ? 5 : 4 : 0,
|
|
581
658
|
cmd: [
|
|
582
659
|
{
|
|
583
660
|
tm: -1,
|
|
@@ -681,6 +758,46 @@ var MysaApiClient = class {
|
|
|
681
758
|
clearInterval(timer);
|
|
682
759
|
this._realtimeDeviceIds.delete(deviceId);
|
|
683
760
|
}
|
|
761
|
+
/**
|
|
762
|
+
* Subscribes to raw MQTT topic filters and relays every message verbatim.
|
|
763
|
+
*
|
|
764
|
+
* Unlike {@link startRealtimeUpdates}, this performs no parsing, emits no typed events and sends no "start publishing"
|
|
765
|
+
* request to the device — it simply forwards the full message topic and the decoded UTF-8 payload of everything that
|
|
766
|
+
* arrives on the given filters. It exists to reverse-engineer device families the SDK does not model yet, most
|
|
767
|
+
* notably the AWS IoT Device Shadow protocol used by the central-HVAC ST-V1 thermostats, where both the topic (which
|
|
768
|
+
* shadow, and `accepted`/`rejected`/`delta`/`documents`) and the raw JSON body carry the information a new
|
|
769
|
+
* implementation needs.
|
|
770
|
+
*
|
|
771
|
+
* The capture is passive: the device only publishes to its shadow topics when something drives a change (the Mysa
|
|
772
|
+
* mobile app, a schedule, or the device itself), so exercise the thermostat while a capture is running.
|
|
773
|
+
*
|
|
774
|
+
* Registered filters are re-subscribed automatically after a reconnect.
|
|
775
|
+
*
|
|
776
|
+
* @param topicFilters - MQTT topic filters to subscribe to. Wildcards (`+`, `#`) are allowed, subject to the AWS IoT
|
|
777
|
+
* policy attached to the account's Cognito identity — a filter the policy forbids resolves with a non-zero
|
|
778
|
+
* `error_code`, which is logged rather than thrown so the remaining filters still subscribe.
|
|
779
|
+
* @param handler - Invoked with the full message topic and the decoded UTF-8 payload for every message received.
|
|
780
|
+
* @throws {@link Error} When the MQTT connection cannot be established.
|
|
781
|
+
*/
|
|
782
|
+
async startRawTopicCapture(topicFilters, handler) {
|
|
783
|
+
this._logger.info(`Starting raw topic capture for ${topicFilters.length} filter(s)`);
|
|
784
|
+
const connection = await this._getMqttConnection();
|
|
785
|
+
const decoder = new TextDecoder("utf-8");
|
|
786
|
+
for (const filter of topicFilters) {
|
|
787
|
+
this._rawTopicCaptures.set(filter, handler);
|
|
788
|
+
this._logger.debug(`Subscribing to raw topic filter '${filter}'...`);
|
|
789
|
+
try {
|
|
790
|
+
const result = await connection.subscribe(filter, import_aws_iot_device_sdk_v2.mqtt.QoS.AtLeastOnce, (topic, payload) => {
|
|
791
|
+
handler(topic, decoder.decode(payload));
|
|
792
|
+
});
|
|
793
|
+
this._logger.debug(
|
|
794
|
+
`Raw subscribe to '${filter}' granted (topic='${result.topic}', qos=${result.qos}, error_code=${result.error_code ?? 0})`
|
|
795
|
+
);
|
|
796
|
+
} catch (error) {
|
|
797
|
+
this._logger.warn(`Failed to subscribe to raw topic filter '${filter}'`, error);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
684
801
|
/**
|
|
685
802
|
* Ensures a valid, non-expired session is available.
|
|
686
803
|
*
|
|
@@ -882,7 +999,7 @@ var MysaApiClient = class {
|
|
|
882
999
|
const usernameHash = (0, import_crypto.hash)("sha1", username);
|
|
883
1000
|
this._mqttClientId = `mysa-js-sdk-${usernameHash}-${process.pid}-${getRandomClientId()}`;
|
|
884
1001
|
}
|
|
885
|
-
const builder = import_aws_iot_device_sdk_v2.iot.AwsIotMqttConnectionConfigBuilder.new_with_websockets().with_credentials(AwsRegion, credentials.accessKeyId, credentials.secretAccessKey, credentials.sessionToken).with_endpoint(MqttEndpoint).with_client_id(this._mqttClientId).with_clean_session(
|
|
1002
|
+
const builder = import_aws_iot_device_sdk_v2.iot.AwsIotMqttConnectionConfigBuilder.new_with_websockets().with_credentials(AwsRegion, credentials.accessKeyId, credentials.secretAccessKey, credentials.sessionToken).with_endpoint(MqttEndpoint).with_client_id(this._mqttClientId).with_clean_session(true).with_keep_alive_seconds(30).with_ping_timeout_ms(3e3).with_protocol_operation_timeout_ms(6e4).with_reconnect_min_sec(3).with_reconnect_max_sec(30);
|
|
886
1003
|
const config = builder.build();
|
|
887
1004
|
config.websocket_handshake_transform = async (request, done) => {
|
|
888
1005
|
try {
|
|
@@ -925,89 +1042,174 @@ var MysaApiClient = class {
|
|
|
925
1042
|
};
|
|
926
1043
|
const client = new import_aws_iot_device_sdk_v2.mqtt.MqttClient();
|
|
927
1044
|
const connection = client.new_connection(config);
|
|
928
|
-
|
|
929
|
-
|
|
1045
|
+
const generation = ++this._mqttGeneration;
|
|
1046
|
+
connection.on("connect", (sessionPresent) => {
|
|
1047
|
+
if (generation !== this._mqttGeneration) return;
|
|
1048
|
+
this._logger.debug(`MQTT connect (clientId=${this._mqttClientId}, sessionPresent=${sessionPresent})`);
|
|
930
1049
|
});
|
|
931
|
-
connection.on("connection_success", () => {
|
|
932
|
-
|
|
1050
|
+
connection.on("connection_success", (result) => {
|
|
1051
|
+
if (generation !== this._mqttGeneration) return;
|
|
1052
|
+
this._mqttLastConnectionSuccessAt = Date.now();
|
|
1053
|
+
this._logger.debug(
|
|
1054
|
+
`MQTT connection_success (clientId=${this._mqttClientId}, sessionPresent=${result == null ? void 0 : result.session_present}, reasonCode=${result == null ? void 0 : result.reason_code})`
|
|
1055
|
+
);
|
|
1056
|
+
this._armMqttStabilityTimer(generation);
|
|
933
1057
|
});
|
|
934
1058
|
connection.on("connection_failure", (e) => {
|
|
1059
|
+
if (generation !== this._mqttGeneration) return;
|
|
935
1060
|
this._logger.error(`MQTT connection_failure (clientId=${this._mqttClientId})`, e);
|
|
936
1061
|
});
|
|
937
|
-
connection.on("interrupt",
|
|
1062
|
+
connection.on("interrupt", (e) => {
|
|
938
1063
|
var _a2;
|
|
939
|
-
|
|
1064
|
+
if (generation !== this._mqttGeneration) return;
|
|
1065
|
+
const dwellMs = this._mqttLastConnectionSuccessAt !== void 0 ? Date.now() - this._mqttLastConnectionSuccessAt : void 0;
|
|
1066
|
+
this._logger.warn(
|
|
1067
|
+
`MQTT interrupt (clientId=${this._mqttClientId}, dwellMs=${dwellMs ?? "n/a"}, generation=${generation})`,
|
|
1068
|
+
e
|
|
1069
|
+
);
|
|
1070
|
+
this._clearMqttStabilityTimer();
|
|
940
1071
|
const now = Date.now();
|
|
941
|
-
this._mqttInterrupts = this._mqttInterrupts.filter((t) => now - t <
|
|
1072
|
+
this._mqttInterrupts = this._mqttInterrupts.filter((t) => now - t < MqttInterruptWindow.asMilliseconds());
|
|
942
1073
|
this._mqttInterrupts.push(now);
|
|
943
1074
|
const areCredentialsExpired = !(((_a2 = this._mqttCredentialsExpiration) == null ? void 0 : _a2.isAfter((0, import_dayjs.default)())) ?? false);
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
`High interrupt rate (${this._mqttInterrupts.length}/60s). Possible clientId collision. Regenerating clientId and resetting connection...`
|
|
949
|
-
);
|
|
950
|
-
} else {
|
|
951
|
-
this._logger.warn(`Credentials expired. Regenerating clientId and resetting connection...`);
|
|
952
|
-
}
|
|
953
|
-
this._mqttClientId = void 0;
|
|
954
|
-
this._mqttCredentialsExpiration = void 0;
|
|
955
|
-
this._mqttInterrupts = [];
|
|
956
|
-
this._mqttConnectionPromise = void 0;
|
|
957
|
-
try {
|
|
958
|
-
connection.removeAllListeners();
|
|
959
|
-
await connection.disconnect();
|
|
960
|
-
try {
|
|
961
|
-
this._logger.debug("Old MQTT connection disconnected; establishing new connection...");
|
|
962
|
-
const newConnection = await this._getMqttConnection();
|
|
963
|
-
for (const deviceId of Array.from(this._realtimeDeviceIds.keys())) {
|
|
964
|
-
const topic = `/v1/dev/${deviceId}/out`;
|
|
965
|
-
this._logger.debug(`Re-subscribing to ${topic}`);
|
|
966
|
-
await newConnection.subscribe(topic, import_aws_iot_device_sdk_v2.mqtt.QoS.AtLeastOnce, (_topic, payload) => {
|
|
967
|
-
this._processMqttMessage(payload);
|
|
968
|
-
});
|
|
969
|
-
}
|
|
970
|
-
this._logger.info("MQTT connection rebuilt successfully after interrupt storm or credentials expiration");
|
|
971
|
-
} catch (err) {
|
|
972
|
-
this._logger.error("Failed to re-subscribe after interrupt storm or credentials expiration", err);
|
|
973
|
-
}
|
|
974
|
-
} catch (error) {
|
|
975
|
-
this._logger.error("Error during MQTT reset", error);
|
|
976
|
-
} finally {
|
|
977
|
-
this._mqttResetInProgress = false;
|
|
978
|
-
}
|
|
1075
|
+
const isStorm = this._mqttInterrupts.length >= MqttInterruptThreshold;
|
|
1076
|
+
if (isStorm || areCredentialsExpired) {
|
|
1077
|
+
const reason = isStorm ? `High interrupt rate (${this._mqttInterrupts.length} in ${MqttInterruptWindow.asSeconds()}s)` : "Credentials expired";
|
|
1078
|
+
void this._resetMqttConnection(reason);
|
|
979
1079
|
}
|
|
980
1080
|
});
|
|
981
1081
|
connection.on("resume", async (returnCode, sessionPresent) => {
|
|
1082
|
+
if (generation !== this._mqttGeneration) return;
|
|
982
1083
|
this._logger.info(
|
|
983
1084
|
`MQTT resume returnCode=${returnCode} sessionPresent=${sessionPresent} clientId=${this._mqttClientId}`
|
|
984
1085
|
);
|
|
985
1086
|
if (!sessionPresent) {
|
|
986
|
-
this._logger.info("No session present, re-subscribing each device");
|
|
987
1087
|
try {
|
|
988
|
-
|
|
989
|
-
const topic = `/v1/dev/${deviceId}/out`;
|
|
990
|
-
this._logger.debug(`Re-subscribing to ${topic}`);
|
|
991
|
-
await connection.subscribe(topic, import_aws_iot_device_sdk_v2.mqtt.QoS.AtLeastOnce, (_topic, payload) => {
|
|
992
|
-
this._processMqttMessage(payload);
|
|
993
|
-
});
|
|
994
|
-
}
|
|
1088
|
+
await this._resubscribeAll(connection);
|
|
995
1089
|
} catch (err) {
|
|
996
1090
|
this._logger.error("Failed to re-subscribe after resume", err);
|
|
997
1091
|
}
|
|
998
1092
|
}
|
|
999
1093
|
});
|
|
1000
1094
|
connection.on("error", (e) => {
|
|
1095
|
+
if (generation !== this._mqttGeneration) return;
|
|
1001
1096
|
this._logger.error(`MQTT error (clientId=${this._mqttClientId})`, e);
|
|
1002
1097
|
});
|
|
1003
1098
|
connection.on("closed", () => {
|
|
1099
|
+
if (generation !== this._mqttGeneration) return;
|
|
1004
1100
|
this._logger.info("MQTT connection closed");
|
|
1101
|
+
this._clearMqttStabilityTimer();
|
|
1005
1102
|
this._mqttConnectionPromise = void 0;
|
|
1006
1103
|
this._mqttCredentialsExpiration = void 0;
|
|
1007
1104
|
});
|
|
1008
1105
|
await connection.connect();
|
|
1009
1106
|
return connection;
|
|
1010
1107
|
}
|
|
1108
|
+
/**
|
|
1109
|
+
* Re-subscribes every device currently receiving real-time updates on the given connection.
|
|
1110
|
+
*
|
|
1111
|
+
* Safe to call on every (re)connect: aws-crt replaces the per-topic callback on a duplicate subscribe, so this never
|
|
1112
|
+
* registers a message handler more than once.
|
|
1113
|
+
*
|
|
1114
|
+
* @param connection - The connection to (re-)establish subscriptions on.
|
|
1115
|
+
*/
|
|
1116
|
+
async _resubscribeAll(connection) {
|
|
1117
|
+
for (const deviceId of Array.from(this._realtimeDeviceIds.keys())) {
|
|
1118
|
+
const topic = `/v1/dev/${deviceId}/out`;
|
|
1119
|
+
this._logger.debug(`Re-subscribing to ${topic}`);
|
|
1120
|
+
await connection.subscribe(topic, import_aws_iot_device_sdk_v2.mqtt.QoS.AtLeastOnce, (_topic, payload) => {
|
|
1121
|
+
this._processMqttMessage(payload);
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
const decoder = new TextDecoder("utf-8");
|
|
1125
|
+
for (const [filter, handler] of Array.from(this._rawTopicCaptures.entries())) {
|
|
1126
|
+
this._logger.debug(`Re-subscribing to raw topic filter '${filter}'`);
|
|
1127
|
+
try {
|
|
1128
|
+
await connection.subscribe(filter, import_aws_iot_device_sdk_v2.mqtt.QoS.AtLeastOnce, (topic, payload) => {
|
|
1129
|
+
handler(topic, decoder.decode(payload));
|
|
1130
|
+
});
|
|
1131
|
+
} catch (error) {
|
|
1132
|
+
this._logger.warn(`Failed to re-subscribe to raw topic filter '${filter}'`, error);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
/**
|
|
1137
|
+
* Arms (or re-arms) the stability timer. When a connection stays interrupt-free for {@link MqttStabilityWindow}, the
|
|
1138
|
+
* consecutive-reset backoff counter is cleared so a future isolated storm still gets a fast first reset.
|
|
1139
|
+
*
|
|
1140
|
+
* @param generation - The connection generation that armed the timer; the callback is a no-op if the connection has
|
|
1141
|
+
* since been replaced.
|
|
1142
|
+
*/
|
|
1143
|
+
_armMqttStabilityTimer(generation) {
|
|
1144
|
+
var _a, _b;
|
|
1145
|
+
this._clearMqttStabilityTimer();
|
|
1146
|
+
this._mqttStabilityTimer = setTimeout(() => {
|
|
1147
|
+
if (generation !== this._mqttGeneration) return;
|
|
1148
|
+
if (this._mqttConsecutiveResets > 0) {
|
|
1149
|
+
this._logger.debug("MQTT connection stable; clearing consecutive-reset counter");
|
|
1150
|
+
this._mqttConsecutiveResets = 0;
|
|
1151
|
+
}
|
|
1152
|
+
}, MqttStabilityWindow.asMilliseconds());
|
|
1153
|
+
(_b = (_a = this._mqttStabilityTimer).unref) == null ? void 0 : _b.call(_a);
|
|
1154
|
+
}
|
|
1155
|
+
/** Clears the stability timer, if armed. */
|
|
1156
|
+
_clearMqttStabilityTimer() {
|
|
1157
|
+
if (this._mqttStabilityTimer) {
|
|
1158
|
+
clearTimeout(this._mqttStabilityTimer);
|
|
1159
|
+
this._mqttStabilityTimer = void 0;
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
/**
|
|
1163
|
+
* Forcefully tears down the current MQTT connection and rebuilds it with a fresh client id and fresh credentials,
|
|
1164
|
+
* escaping interrupt storms that a plain native reconnect cannot.
|
|
1165
|
+
*
|
|
1166
|
+
* Repeatable and self-contained: only one reset runs at a time, consecutive resets without an intervening stable
|
|
1167
|
+
* period back off exponentially (up to {@link MqttResetMaxDelay}), and it never rejects — so it is safe to invoke
|
|
1168
|
+
* fire-and-forget from an event handler.
|
|
1169
|
+
*
|
|
1170
|
+
* @param reason - Human-readable reason for the reset, used in logs.
|
|
1171
|
+
*/
|
|
1172
|
+
async _resetMqttConnection(reason) {
|
|
1173
|
+
if (this._mqttResetInProgress) {
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
this._mqttResetInProgress = true;
|
|
1177
|
+
const connectionToClose = this._mqttConnectionPromise;
|
|
1178
|
+
try {
|
|
1179
|
+
this._mqttConsecutiveResets++;
|
|
1180
|
+
const delayMs = Math.min(
|
|
1181
|
+
MqttResetBaseDelay.asMilliseconds() * Math.pow(2, this._mqttConsecutiveResets - 1),
|
|
1182
|
+
MqttResetMaxDelay.asMilliseconds()
|
|
1183
|
+
);
|
|
1184
|
+
this._logger.warn(
|
|
1185
|
+
`${reason}. Forcing MQTT reset #${this._mqttConsecutiveResets} (new clientId, fresh credentials) after ${Math.round(delayMs)}ms...`
|
|
1186
|
+
);
|
|
1187
|
+
this._mqttClientId = void 0;
|
|
1188
|
+
this._mqttCredentialsExpiration = void 0;
|
|
1189
|
+
this._mqttInterrupts = [];
|
|
1190
|
+
this._clearMqttStabilityTimer();
|
|
1191
|
+
this._mqttConnectionPromise = void 0;
|
|
1192
|
+
if (connectionToClose) {
|
|
1193
|
+
try {
|
|
1194
|
+
const connection = await connectionToClose;
|
|
1195
|
+
connection.removeAllListeners();
|
|
1196
|
+
await connection.disconnect();
|
|
1197
|
+
} catch (err) {
|
|
1198
|
+
this._logger.debug("Error tearing down old MQTT connection during reset (ignored)", err);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
if (delayMs > 0) {
|
|
1202
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
1203
|
+
}
|
|
1204
|
+
const newConnection = await this._getMqttConnection();
|
|
1205
|
+
await this._resubscribeAll(newConnection);
|
|
1206
|
+
this._logger.info(`MQTT connection rebuilt successfully after reset #${this._mqttConsecutiveResets} (${reason})`);
|
|
1207
|
+
} catch (err) {
|
|
1208
|
+
this._logger.error("Failed to rebuild MQTT connection after reset", err);
|
|
1209
|
+
} finally {
|
|
1210
|
+
this._mqttResetInProgress = false;
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1011
1213
|
/**
|
|
1012
1214
|
* Processes incoming MQTT messages and emits appropriate events.
|
|
1013
1215
|
*
|
|
@@ -1043,7 +1245,6 @@ var MysaApiClient = class {
|
|
|
1043
1245
|
} else if (isMsgOutPayload(parsedPayload)) {
|
|
1044
1246
|
switch (parsedPayload.msg) {
|
|
1045
1247
|
case 30 /* DEVICE_AC_STATUS */:
|
|
1046
|
-
case 40 /* DEVICE_V2_STATUS */:
|
|
1047
1248
|
this.emitter.emit("statusChanged", {
|
|
1048
1249
|
deviceId: parsedPayload.src.ref,
|
|
1049
1250
|
temperature: parsedPayload.body.ambTemp,
|
|
@@ -1052,6 +1253,16 @@ var MysaApiClient = class {
|
|
|
1052
1253
|
dutyCycle: parsedPayload.body.dtyCycle
|
|
1053
1254
|
});
|
|
1054
1255
|
break;
|
|
1256
|
+
case 40 /* DEVICE_V2_STATUS */:
|
|
1257
|
+
this.emitter.emit("statusChanged", {
|
|
1258
|
+
deviceId: parsedPayload.src.ref,
|
|
1259
|
+
temperature: parsedPayload.body.ambTemp,
|
|
1260
|
+
humidity: parsedPayload.body.hum,
|
|
1261
|
+
setPoint: parsedPayload.body.stpt,
|
|
1262
|
+
dutyCycle: parsedPayload.body.dtyCycle ?? parsedPayload.body.heatStat,
|
|
1263
|
+
floorTemperature: parsedPayload.body.flrSnsrTemp
|
|
1264
|
+
});
|
|
1265
|
+
break;
|
|
1055
1266
|
case 44 /* DEVICE_STATE_CHANGE */: {
|
|
1056
1267
|
const modeMap = {
|
|
1057
1268
|
1: "off",
|
|
@@ -1061,18 +1272,11 @@ var MysaApiClient = class {
|
|
|
1061
1272
|
5: "fan_only",
|
|
1062
1273
|
6: "dry"
|
|
1063
1274
|
};
|
|
1064
|
-
const fanSpeedMap = {
|
|
1065
|
-
1: "auto",
|
|
1066
|
-
3: "low",
|
|
1067
|
-
5: "medium",
|
|
1068
|
-
7: "high",
|
|
1069
|
-
8: "max"
|
|
1070
|
-
};
|
|
1071
1275
|
this.emitter.emit("stateChanged", {
|
|
1072
1276
|
deviceId: parsedPayload.src.ref,
|
|
1073
1277
|
mode: parsedPayload.body.state.md ? modeMap[parsedPayload.body.state.md] : void 0,
|
|
1074
1278
|
setPoint: parsedPayload.body.state.sp,
|
|
1075
|
-
fanSpeed: parsedPayload.body.state.fn !== void 0 ?
|
|
1279
|
+
fanSpeed: parsedPayload.body.state.fn !== void 0 ? FanSpeedReceiveMap[parsedPayload.body.state.fn] : void 0
|
|
1076
1280
|
});
|
|
1077
1281
|
break;
|
|
1078
1282
|
}
|
|
@@ -1092,6 +1296,7 @@ var MysaApiClient = class {
|
|
|
1092
1296
|
OutMessageType,
|
|
1093
1297
|
UnauthenticatedError,
|
|
1094
1298
|
UnknownDeviceError,
|
|
1299
|
+
UnsupportedFanSpeedError,
|
|
1095
1300
|
VoidLogger
|
|
1096
1301
|
});
|
|
1097
1302
|
//# sourceMappingURL=index.cjs.map
|