mysa-js-sdk 3.0.0 → 3.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 CHANGED
@@ -1,5 +1,34 @@
1
1
  # mysa-js-sdk
2
2
 
3
+ ## 3.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#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)).
8
+
9
+ - 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`.
10
+ - Send the correct control command `type` (3) for in-floor thermostats so setpoint and mode changes take effect instead of being silently ignored.
11
+
12
+ - [#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.
13
+
14
+ 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.
15
+
16
+ ### Patch Changes
17
+
18
+ - [#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)).
19
+
20
+ - 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.
21
+ - 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`.
22
+ - Add the `fanSpeeds` field to the `SupportedCaps` type so consumers no longer need to cast to access it.
23
+ - 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.
24
+
25
+ - [#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)).
26
+
27
+ - 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.
28
+ - 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.
29
+ - 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.
30
+ - Add interrupt dwell-time and session diagnostics to help pin down the server-side hangup trigger.
31
+
3
32
  ## 3.0.0
4
33
 
5
34
  ### Major Changes
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 = { auto: 1, low: 3, medium: 5, high: 7, max: 8 };
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(false).with_keep_alive_seconds(30).with_ping_timeout_ms(3e3).with_protocol_operation_timeout_ms(6e4).with_reconnect_min_sec(1).with_reconnect_max_sec(30);
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
- connection.on("connect", () => {
929
- this._logger.debug(`MQTT connect (clientId=${this._mqttClientId})`);
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
- this._logger.debug(`MQTT connection_success (clientId=${this._mqttClientId})`);
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", async (e) => {
1062
+ connection.on("interrupt", (e) => {
938
1063
  var _a2;
939
- this._logger.warn(`MQTT interrupt (clientId=${this._mqttClientId})`, e);
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 < 6e4);
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
- if ((this._mqttInterrupts.length > 5 || areCredentialsExpired) && !this._mqttResetInProgress) {
945
- this._mqttResetInProgress = true;
946
- if (this._mqttInterrupts.length > 5) {
947
- this._logger.warn(
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
- for (const deviceId of Array.from(this._realtimeDeviceIds.keys())) {
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 ? fanSpeedMap[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