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/dist/index.d.ts CHANGED
@@ -39,6 +39,20 @@ declare class UnknownDeviceError extends Error {
39
39
  */
40
40
  constructor(deviceId: string);
41
41
  }
42
+ /** Error thrown when a requested fan speed is not supported by the target device. */
43
+ declare class UnsupportedFanSpeedError extends Error {
44
+ readonly deviceId: string;
45
+ readonly fanSpeed: string;
46
+ readonly supportedFanSpeeds: string[];
47
+ /**
48
+ * Creates a new UnsupportedFanSpeedError instance.
49
+ *
50
+ * @param deviceId - The id of the device the command was aimed at.
51
+ * @param fanSpeed - The requested fan speed that the device does not support.
52
+ * @param supportedFanSpeeds - The fan speeds the device does support.
53
+ */
54
+ constructor(deviceId: string, fanSpeed: string, supportedFanSpeeds: string[]);
55
+ }
42
56
  /** Error thrown when an MQTT publish ultimately fails after retry attempts. */
43
57
  declare class MqttPublishError extends Error {
44
58
  attempts: number;
@@ -120,6 +134,8 @@ interface Status {
120
134
  current?: number;
121
135
  /** Optional heating element duty cycle, as a fraction between 0.0 and 1.0 */
122
136
  dutyCycle?: number;
137
+ /** Optional floor-probe temperature reading, reported only by in-floor heating thermostats (INF-V1-0) */
138
+ floorTemperature?: number;
123
139
  }
124
140
 
125
141
  /** Interface for logging operations at different severity levels */
@@ -215,6 +231,12 @@ interface SupportedCaps {
215
231
  version: string;
216
232
  /** Array of supported remote control key codes */
217
233
  keys: number[];
234
+ /**
235
+ * Optional device-specific fan-speed `fn` values, ordered by canonical fan speed (`[auto, low, medium, high, max]`).
236
+ * E.g. `[1, 2, 4, 6]` for CodeNum=1117 devices means auto=1, low=2, medium=4, high=6. When absent, a legacy universal
237
+ * mapping is assumed.
238
+ */
239
+ fanSpeeds?: number[];
218
240
  }
219
241
  /**
220
242
  * Device operating mode information.
@@ -568,12 +590,29 @@ interface DeviceV2Status extends MsgPayload<OutMessageType.DEVICE_V2_STATUS> {
568
590
  body: {
569
591
  /** Ambient temperature reading from the device sensor */
570
592
  ambTemp: number;
571
- /** Current duty cycle of the heating element, as a fraction between 0.0 and 1.0 */
572
- dtyCycle: number;
593
+ /**
594
+ * Current duty cycle of the heating element, as a fraction between 0.0 and 1.0. Reported by baseboard V2 devices;
595
+ * absent on in-floor heating thermostats (INF-V1-0), which report {@link heatStat} instead.
596
+ */
597
+ dtyCycle?: number;
573
598
  /** Relative humidity percentage reading from the device sensor */
574
599
  hum: number;
575
600
  /** Current temperature setpoint setting */
576
601
  stpt: number;
602
+ /**
603
+ * Binary heating-relay state (0 = off, 1 = energized) reported by in-floor heating thermostats (INF-V1-0) in place
604
+ * of {@link dtyCycle}. Absent on baseboard V2 devices.
605
+ */
606
+ heatStat?: number;
607
+ /** Floor-probe temperature reading (°C) from in-floor heating thermostats. Absent on baseboard V2 devices. */
608
+ flrSnsrTemp?: number;
609
+ /**
610
+ * Which sensor the in-floor thermostat regulates against (3 = floor probe, 5 = ambient air). Absent on baseboard V2
611
+ * devices.
612
+ */
613
+ trackedSnsr?: number;
614
+ /** Line voltage (V) reported by in-floor heating thermostats. Absent on baseboard V2 devices. */
615
+ lineVtg?: number;
577
616
  };
578
617
  }
579
618
 
@@ -805,8 +844,21 @@ declare class MysaApiClient {
805
844
  private _mqttInterrupts;
806
845
  /** Whether a forced MQTT reset is currently in progress (guards against re-entrancy). */
807
846
  private _mqttResetInProgress;
847
+ /** Monotonic id of the current MQTT connection; used to ignore events from discarded connections. */
848
+ private _mqttGeneration;
849
+ /** Consecutive forced resets without an intervening stable period; drives reset backoff. */
850
+ private _mqttConsecutiveResets;
851
+ /** Timestamp (ms) of the most recent successful MQTT (re)connect, for interrupt dwell-time diagnostics. */
852
+ private _mqttLastConnectionSuccessAt?;
853
+ /** Timer that clears the consecutive-reset counter once a connection stays healthy. */
854
+ private _mqttStabilityTimer?;
808
855
  /** The device IDs that are currently being updated in real-time, mapped to their respective timeouts. */
809
856
  private _realtimeDeviceIds;
857
+ /**
858
+ * Raw topic filters registered via {@link startRawTopicCapture}, mapped to their message handlers. Re-subscribed on
859
+ * every reconnect so a debug capture survives connection resets.
860
+ */
861
+ private _rawTopicCaptures;
810
862
  /** The cached devices object, if any. */
811
863
  private _cachedDevices?;
812
864
  /**
@@ -945,6 +997,7 @@ declare class MysaApiClient {
945
997
  * unchanged).
946
998
  * @throws {@link UnauthenticatedError} When the client cannot authenticate with its credentials.
947
999
  * @throws {@link UnknownDeviceError} When the device id does not match any device on the account.
1000
+ * @throws {@link UnsupportedFanSpeedError} When the requested fan speed is not supported by the device.
948
1001
  * @throws {@link Error} When MQTT connection or command sending fails.
949
1002
  */
950
1003
  setDeviceState(deviceId: string, setPoint?: number, mode?: MysaDeviceMode, fanSpeed?: MysaFanSpeedMode): Promise<void>;
@@ -980,6 +1033,28 @@ declare class MysaApiClient {
980
1033
  * @throws {@link Error} When MQTT unsubscription fails.
981
1034
  */
982
1035
  stopRealtimeUpdates(deviceId: string): Promise<void>;
1036
+ /**
1037
+ * Subscribes to raw MQTT topic filters and relays every message verbatim.
1038
+ *
1039
+ * Unlike {@link startRealtimeUpdates}, this performs no parsing, emits no typed events and sends no "start publishing"
1040
+ * request to the device — it simply forwards the full message topic and the decoded UTF-8 payload of everything that
1041
+ * arrives on the given filters. It exists to reverse-engineer device families the SDK does not model yet, most
1042
+ * notably the AWS IoT Device Shadow protocol used by the central-HVAC ST-V1 thermostats, where both the topic (which
1043
+ * shadow, and `accepted`/`rejected`/`delta`/`documents`) and the raw JSON body carry the information a new
1044
+ * implementation needs.
1045
+ *
1046
+ * The capture is passive: the device only publishes to its shadow topics when something drives a change (the Mysa
1047
+ * mobile app, a schedule, or the device itself), so exercise the thermostat while a capture is running.
1048
+ *
1049
+ * Registered filters are re-subscribed automatically after a reconnect.
1050
+ *
1051
+ * @param topicFilters - MQTT topic filters to subscribe to. Wildcards (`+`, `#`) are allowed, subject to the AWS IoT
1052
+ * policy attached to the account's Cognito identity — a filter the policy forbids resolves with a non-zero
1053
+ * `error_code`, which is logged rather than thrown so the remaining filters still subscribe.
1054
+ * @param handler - Invoked with the full message topic and the decoded UTF-8 payload for every message received.
1055
+ * @throws {@link Error} When the MQTT connection cannot be established.
1056
+ */
1057
+ startRawTopicCapture(topicFilters: string[], handler: (topic: string, payload: string) => void): Promise<void>;
983
1058
  /**
984
1059
  * Ensures a valid, non-expired session is available.
985
1060
  *
@@ -1053,6 +1128,36 @@ declare class MysaApiClient {
1053
1128
  * @throws {@link Error} When connection establishment fails.
1054
1129
  */
1055
1130
  private _createMqttConnection;
1131
+ /**
1132
+ * Re-subscribes every device currently receiving real-time updates on the given connection.
1133
+ *
1134
+ * Safe to call on every (re)connect: aws-crt replaces the per-topic callback on a duplicate subscribe, so this never
1135
+ * registers a message handler more than once.
1136
+ *
1137
+ * @param connection - The connection to (re-)establish subscriptions on.
1138
+ */
1139
+ private _resubscribeAll;
1140
+ /**
1141
+ * Arms (or re-arms) the stability timer. When a connection stays interrupt-free for {@link MqttStabilityWindow}, the
1142
+ * consecutive-reset backoff counter is cleared so a future isolated storm still gets a fast first reset.
1143
+ *
1144
+ * @param generation - The connection generation that armed the timer; the callback is a no-op if the connection has
1145
+ * since been replaced.
1146
+ */
1147
+ private _armMqttStabilityTimer;
1148
+ /** Clears the stability timer, if armed. */
1149
+ private _clearMqttStabilityTimer;
1150
+ /**
1151
+ * Forcefully tears down the current MQTT connection and rebuilds it with a fresh client id and fresh credentials,
1152
+ * escaping interrupt storms that a plain native reconnect cannot.
1153
+ *
1154
+ * Repeatable and self-contained: only one reset runs at a time, consecutive resets without an intervening stable
1155
+ * period back off exponentially (up to {@link MqttResetMaxDelay}), and it never rejects — so it is safe to invoke
1156
+ * fire-and-forget from an event handler.
1157
+ *
1158
+ * @param reason - Human-readable reason for the reset, used in logs.
1159
+ */
1160
+ private _resetMqttConnection;
1056
1161
  /**
1057
1162
  * Processes incoming MQTT messages and emits appropriate events.
1058
1163
  *
@@ -1175,4 +1280,4 @@ type MsgTypeInPayload = CheckDeviceSettings | StartPublishingDeviceStatus;
1175
1280
  */
1176
1281
  type InPayload = MsgTypeInPayload | MsgInPayload;
1177
1282
 
1178
- export { type BrandInfo, type ChangeDeviceState, type CheckDeviceSettings, type DeviceAcStatus, type DeviceBase, type DeviceLog, type DevicePostBoot, type DeviceSetpointChange, type DeviceState, type DeviceStateChange, type DeviceStates, type DeviceStatesObj, type DeviceV1Status, type DeviceV2Status, type Devices, type DevicesObj, type FirmwareDevice, type Firmwares, type HomeBase, type Homes, InMessageType, type InPayload, type Logger, type ModeObj, MqttPublishError, type MqttPublishOptions, type MsgBasePayload, type MsgInPayload, type MsgOutPayload, type MsgPayload, type MsgTypeBasePayload, type MsgTypeInPayload, type MsgTypeOutPayload, type MsgTypePayload, MysaApiClient, type MysaApiClientEventTypes, type MysaApiClientOptions, MysaApiError, type MysaCredentials, type MysaDeviceMode, type MysaFanSpeedMode, OutMessageType, type OutPayload, type SetPointChange, type StartPublishingDeviceStatus, type StateChange, type Status, type SupportedCaps, type TimestampedValue, UnauthenticatedError, UnknownDeviceError, VoidLogger };
1283
+ export { type BrandInfo, type ChangeDeviceState, type CheckDeviceSettings, type DeviceAcStatus, type DeviceBase, type DeviceLog, type DevicePostBoot, type DeviceSetpointChange, type DeviceState, type DeviceStateChange, type DeviceStates, type DeviceStatesObj, type DeviceV1Status, type DeviceV2Status, type Devices, type DevicesObj, type FirmwareDevice, type Firmwares, type HomeBase, type Homes, InMessageType, type InPayload, type Logger, type ModeObj, MqttPublishError, type MqttPublishOptions, type MsgBasePayload, type MsgInPayload, type MsgOutPayload, type MsgPayload, type MsgTypeBasePayload, type MsgTypeInPayload, type MsgTypeOutPayload, type MsgTypePayload, MysaApiClient, type MysaApiClientEventTypes, type MysaApiClientOptions, MysaApiError, type MysaCredentials, type MysaDeviceMode, type MysaFanSpeedMode, OutMessageType, type OutPayload, type SetPointChange, type StartPublishingDeviceStatus, type StateChange, type Status, type SupportedCaps, type TimestampedValue, UnauthenticatedError, UnknownDeviceError, UnsupportedFanSpeedError, VoidLogger };
package/dist/index.js CHANGED
@@ -76,6 +76,27 @@ var UnknownDeviceError = class extends Error {
76
76
  }
77
77
  deviceId;
78
78
  };
79
+ var UnsupportedFanSpeedError = class extends Error {
80
+ /**
81
+ * Creates a new UnsupportedFanSpeedError instance.
82
+ *
83
+ * @param deviceId - The id of the device the command was aimed at.
84
+ * @param fanSpeed - The requested fan speed that the device does not support.
85
+ * @param supportedFanSpeeds - The fan speeds the device does support.
86
+ */
87
+ constructor(deviceId, fanSpeed, supportedFanSpeeds) {
88
+ super(
89
+ `Device '${deviceId}' does not support the '${fanSpeed}' fan speed. Supported fan speeds: ${supportedFanSpeeds.join(", ") || "(none)"}.`
90
+ );
91
+ this.deviceId = deviceId;
92
+ this.fanSpeed = fanSpeed;
93
+ this.supportedFanSpeeds = supportedFanSpeeds;
94
+ this.name = "UnsupportedFanSpeedError";
95
+ }
96
+ deviceId;
97
+ fanSpeed;
98
+ supportedFanSpeeds;
99
+ };
79
100
  var MqttPublishError = class extends Error {
80
101
  /**
81
102
  * Creates a new MqttPublishError instance.
@@ -232,6 +253,43 @@ var MqttEndpoint = "a3q27gia9qg3zy-ats.iot.us-east-1.amazonaws.com";
232
253
  var MysaApiBaseUrl = "https://app-prod.mysa.cloud";
233
254
  var RealtimeKeepAliveInterval = dayjs.duration(5, "minutes");
234
255
  var PublishAckTimeout = dayjs.duration(30, "seconds");
256
+ var MqttInterruptWindow = dayjs.duration(30, "seconds");
257
+ var MqttInterruptThreshold = 3;
258
+ var MqttResetBaseDelay = dayjs.duration(1, "second");
259
+ var MqttResetMaxDelay = dayjs.duration(30, "seconds");
260
+ var MqttStabilityWindow = dayjs.duration(60, "seconds");
261
+ var CanonicalFanSpeedOrder = ["auto", "low", "medium", "high", "max"];
262
+ var LegacyFanSpeedSendMap = { auto: 1, low: 3, medium: 5, high: 7, max: 8 };
263
+ var FanSpeedReceiveMap = {
264
+ 1: "auto",
265
+ 2: "low",
266
+ // CodeNum=1117 canonical low
267
+ 3: "low",
268
+ // legacy
269
+ 4: "medium",
270
+ // CodeNum=1117 canonical medium
271
+ 5: "medium",
272
+ // legacy
273
+ 6: "high",
274
+ // CodeNum=1117 canonical high
275
+ 7: "high",
276
+ // legacy
277
+ 8: "max"
278
+ };
279
+ function buildFanSpeedSendMap(device) {
280
+ var _a;
281
+ const fanSpeeds = (_a = device.SupportedCaps) == null ? void 0 : _a.fanSpeeds;
282
+ if (!fanSpeeds || fanSpeeds.length === 0) {
283
+ return LegacyFanSpeedSendMap;
284
+ }
285
+ const map = {};
286
+ CanonicalFanSpeedOrder.forEach((name, index) => {
287
+ if (index < fanSpeeds.length) {
288
+ map[name] = fanSpeeds[index];
289
+ }
290
+ });
291
+ return map;
292
+ }
235
293
  var MysaApiClient = class {
236
294
  /** The credentials of the Mysa account this client authenticates as. */
237
295
  _credentials;
@@ -255,8 +313,21 @@ var MysaApiClient = class {
255
313
  _mqttInterrupts = [];
256
314
  /** Whether a forced MQTT reset is currently in progress (guards against re-entrancy). */
257
315
  _mqttResetInProgress = false;
316
+ /** Monotonic id of the current MQTT connection; used to ignore events from discarded connections. */
317
+ _mqttGeneration = 0;
318
+ /** Consecutive forced resets without an intervening stable period; drives reset backoff. */
319
+ _mqttConsecutiveResets = 0;
320
+ /** Timestamp (ms) of the most recent successful MQTT (re)connect, for interrupt dwell-time diagnostics. */
321
+ _mqttLastConnectionSuccessAt;
322
+ /** Timer that clears the consecutive-reset counter once a connection stays healthy. */
323
+ _mqttStabilityTimer;
258
324
  /** The device IDs that are currently being updated in real-time, mapped to their respective timeouts. */
259
325
  _realtimeDeviceIds = /* @__PURE__ */ new Map();
326
+ /**
327
+ * Raw topic filters registered via {@link startRawTopicCapture}, mapped to their message handlers. Re-subscribed on
328
+ * every reconnect so a debug capture survives connection resets.
329
+ */
330
+ _rawTopicCaptures = /* @__PURE__ */ new Map();
260
331
  /** The cached devices object, if any. */
261
332
  _cachedDevices;
262
333
  /**
@@ -310,6 +381,7 @@ var MysaApiClient = class {
310
381
  this._cognitoUserSession = void 0;
311
382
  this._mqttClientId = void 0;
312
383
  this._mqttInterrupts = [];
384
+ this._mqttConsecutiveResets = 0;
313
385
  const { username, password } = this._credentials;
314
386
  return new Promise((resolve, reject) => {
315
387
  const user = new CognitoUser({
@@ -501,6 +573,7 @@ var MysaApiClient = class {
501
573
  * unchanged).
502
574
  * @throws {@link UnauthenticatedError} When the client cannot authenticate with its credentials.
503
575
  * @throws {@link UnknownDeviceError} When the device id does not match any device on the account.
576
+ * @throws {@link UnsupportedFanSpeedError} When the requested fan speed is not supported by the device.
504
577
  * @throws {@link Error} When MQTT connection or command sending fails.
505
578
  */
506
579
  async setDeviceState(deviceId, setPoint, mode, fanSpeed) {
@@ -518,7 +591,10 @@ var MysaApiClient = class {
518
591
  const now = dayjs();
519
592
  this._logger.debug(`Sending request to set device state for '${deviceId}'...`);
520
593
  const modeMap = { off: 1, auto: 2, heat: 3, cool: 4, fan_only: 5, dry: 6 };
521
- const fanSpeedMap = { auto: 1, low: 3, medium: 5, high: 7, max: 8 };
594
+ const fanSpeedMap = buildFanSpeedSendMap(device);
595
+ if (fanSpeed !== void 0 && fanSpeedMap[fanSpeed] === void 0) {
596
+ throw new UnsupportedFanSpeedError(deviceId, fanSpeed, Object.keys(fanSpeedMap));
597
+ }
522
598
  const payload = serializeMqttPayload({
523
599
  msg: 44 /* CHANGE_DEVICE_STATE */,
524
600
  id: now.valueOf(),
@@ -535,7 +611,7 @@ var MysaApiClient = class {
535
611
  resp: 2,
536
612
  body: {
537
613
  ver: 1,
538
- 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,
614
+ 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,
539
615
  cmd: [
540
616
  {
541
617
  tm: -1,
@@ -639,6 +715,46 @@ var MysaApiClient = class {
639
715
  clearInterval(timer);
640
716
  this._realtimeDeviceIds.delete(deviceId);
641
717
  }
718
+ /**
719
+ * Subscribes to raw MQTT topic filters and relays every message verbatim.
720
+ *
721
+ * Unlike {@link startRealtimeUpdates}, this performs no parsing, emits no typed events and sends no "start publishing"
722
+ * request to the device — it simply forwards the full message topic and the decoded UTF-8 payload of everything that
723
+ * arrives on the given filters. It exists to reverse-engineer device families the SDK does not model yet, most
724
+ * notably the AWS IoT Device Shadow protocol used by the central-HVAC ST-V1 thermostats, where both the topic (which
725
+ * shadow, and `accepted`/`rejected`/`delta`/`documents`) and the raw JSON body carry the information a new
726
+ * implementation needs.
727
+ *
728
+ * The capture is passive: the device only publishes to its shadow topics when something drives a change (the Mysa
729
+ * mobile app, a schedule, or the device itself), so exercise the thermostat while a capture is running.
730
+ *
731
+ * Registered filters are re-subscribed automatically after a reconnect.
732
+ *
733
+ * @param topicFilters - MQTT topic filters to subscribe to. Wildcards (`+`, `#`) are allowed, subject to the AWS IoT
734
+ * policy attached to the account's Cognito identity — a filter the policy forbids resolves with a non-zero
735
+ * `error_code`, which is logged rather than thrown so the remaining filters still subscribe.
736
+ * @param handler - Invoked with the full message topic and the decoded UTF-8 payload for every message received.
737
+ * @throws {@link Error} When the MQTT connection cannot be established.
738
+ */
739
+ async startRawTopicCapture(topicFilters, handler) {
740
+ this._logger.info(`Starting raw topic capture for ${topicFilters.length} filter(s)`);
741
+ const connection = await this._getMqttConnection();
742
+ const decoder = new TextDecoder("utf-8");
743
+ for (const filter of topicFilters) {
744
+ this._rawTopicCaptures.set(filter, handler);
745
+ this._logger.debug(`Subscribing to raw topic filter '${filter}'...`);
746
+ try {
747
+ const result = await connection.subscribe(filter, mqtt.QoS.AtLeastOnce, (topic, payload) => {
748
+ handler(topic, decoder.decode(payload));
749
+ });
750
+ this._logger.debug(
751
+ `Raw subscribe to '${filter}' granted (topic='${result.topic}', qos=${result.qos}, error_code=${result.error_code ?? 0})`
752
+ );
753
+ } catch (error) {
754
+ this._logger.warn(`Failed to subscribe to raw topic filter '${filter}'`, error);
755
+ }
756
+ }
757
+ }
642
758
  /**
643
759
  * Ensures a valid, non-expired session is available.
644
760
  *
@@ -840,7 +956,7 @@ var MysaApiClient = class {
840
956
  const usernameHash = hash("sha1", username);
841
957
  this._mqttClientId = `mysa-js-sdk-${usernameHash}-${process.pid}-${getRandomClientId()}`;
842
958
  }
843
- const builder = 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);
959
+ const builder = 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);
844
960
  const config = builder.build();
845
961
  config.websocket_handshake_transform = async (request, done) => {
846
962
  try {
@@ -883,89 +999,174 @@ var MysaApiClient = class {
883
999
  };
884
1000
  const client = new mqtt.MqttClient();
885
1001
  const connection = client.new_connection(config);
886
- connection.on("connect", () => {
887
- this._logger.debug(`MQTT connect (clientId=${this._mqttClientId})`);
1002
+ const generation = ++this._mqttGeneration;
1003
+ connection.on("connect", (sessionPresent) => {
1004
+ if (generation !== this._mqttGeneration) return;
1005
+ this._logger.debug(`MQTT connect (clientId=${this._mqttClientId}, sessionPresent=${sessionPresent})`);
888
1006
  });
889
- connection.on("connection_success", () => {
890
- this._logger.debug(`MQTT connection_success (clientId=${this._mqttClientId})`);
1007
+ connection.on("connection_success", (result) => {
1008
+ if (generation !== this._mqttGeneration) return;
1009
+ this._mqttLastConnectionSuccessAt = Date.now();
1010
+ this._logger.debug(
1011
+ `MQTT connection_success (clientId=${this._mqttClientId}, sessionPresent=${result == null ? void 0 : result.session_present}, reasonCode=${result == null ? void 0 : result.reason_code})`
1012
+ );
1013
+ this._armMqttStabilityTimer(generation);
891
1014
  });
892
1015
  connection.on("connection_failure", (e) => {
1016
+ if (generation !== this._mqttGeneration) return;
893
1017
  this._logger.error(`MQTT connection_failure (clientId=${this._mqttClientId})`, e);
894
1018
  });
895
- connection.on("interrupt", async (e) => {
1019
+ connection.on("interrupt", (e) => {
896
1020
  var _a2;
897
- this._logger.warn(`MQTT interrupt (clientId=${this._mqttClientId})`, e);
1021
+ if (generation !== this._mqttGeneration) return;
1022
+ const dwellMs = this._mqttLastConnectionSuccessAt !== void 0 ? Date.now() - this._mqttLastConnectionSuccessAt : void 0;
1023
+ this._logger.warn(
1024
+ `MQTT interrupt (clientId=${this._mqttClientId}, dwellMs=${dwellMs ?? "n/a"}, generation=${generation})`,
1025
+ e
1026
+ );
1027
+ this._clearMqttStabilityTimer();
898
1028
  const now = Date.now();
899
- this._mqttInterrupts = this._mqttInterrupts.filter((t) => now - t < 6e4);
1029
+ this._mqttInterrupts = this._mqttInterrupts.filter((t) => now - t < MqttInterruptWindow.asMilliseconds());
900
1030
  this._mqttInterrupts.push(now);
901
1031
  const areCredentialsExpired = !(((_a2 = this._mqttCredentialsExpiration) == null ? void 0 : _a2.isAfter(dayjs())) ?? false);
902
- if ((this._mqttInterrupts.length > 5 || areCredentialsExpired) && !this._mqttResetInProgress) {
903
- this._mqttResetInProgress = true;
904
- if (this._mqttInterrupts.length > 5) {
905
- this._logger.warn(
906
- `High interrupt rate (${this._mqttInterrupts.length}/60s). Possible clientId collision. Regenerating clientId and resetting connection...`
907
- );
908
- } else {
909
- this._logger.warn(`Credentials expired. Regenerating clientId and resetting connection...`);
910
- }
911
- this._mqttClientId = void 0;
912
- this._mqttCredentialsExpiration = void 0;
913
- this._mqttInterrupts = [];
914
- this._mqttConnectionPromise = void 0;
915
- try {
916
- connection.removeAllListeners();
917
- await connection.disconnect();
918
- try {
919
- this._logger.debug("Old MQTT connection disconnected; establishing new connection...");
920
- const newConnection = await this._getMqttConnection();
921
- for (const deviceId of Array.from(this._realtimeDeviceIds.keys())) {
922
- const topic = `/v1/dev/${deviceId}/out`;
923
- this._logger.debug(`Re-subscribing to ${topic}`);
924
- await newConnection.subscribe(topic, mqtt.QoS.AtLeastOnce, (_topic, payload) => {
925
- this._processMqttMessage(payload);
926
- });
927
- }
928
- this._logger.info("MQTT connection rebuilt successfully after interrupt storm or credentials expiration");
929
- } catch (err) {
930
- this._logger.error("Failed to re-subscribe after interrupt storm or credentials expiration", err);
931
- }
932
- } catch (error) {
933
- this._logger.error("Error during MQTT reset", error);
934
- } finally {
935
- this._mqttResetInProgress = false;
936
- }
1032
+ const isStorm = this._mqttInterrupts.length >= MqttInterruptThreshold;
1033
+ if (isStorm || areCredentialsExpired) {
1034
+ const reason = isStorm ? `High interrupt rate (${this._mqttInterrupts.length} in ${MqttInterruptWindow.asSeconds()}s)` : "Credentials expired";
1035
+ void this._resetMqttConnection(reason);
937
1036
  }
938
1037
  });
939
1038
  connection.on("resume", async (returnCode, sessionPresent) => {
1039
+ if (generation !== this._mqttGeneration) return;
940
1040
  this._logger.info(
941
1041
  `MQTT resume returnCode=${returnCode} sessionPresent=${sessionPresent} clientId=${this._mqttClientId}`
942
1042
  );
943
1043
  if (!sessionPresent) {
944
- this._logger.info("No session present, re-subscribing each device");
945
1044
  try {
946
- for (const deviceId of Array.from(this._realtimeDeviceIds.keys())) {
947
- const topic = `/v1/dev/${deviceId}/out`;
948
- this._logger.debug(`Re-subscribing to ${topic}`);
949
- await connection.subscribe(topic, mqtt.QoS.AtLeastOnce, (_topic, payload) => {
950
- this._processMqttMessage(payload);
951
- });
952
- }
1045
+ await this._resubscribeAll(connection);
953
1046
  } catch (err) {
954
1047
  this._logger.error("Failed to re-subscribe after resume", err);
955
1048
  }
956
1049
  }
957
1050
  });
958
1051
  connection.on("error", (e) => {
1052
+ if (generation !== this._mqttGeneration) return;
959
1053
  this._logger.error(`MQTT error (clientId=${this._mqttClientId})`, e);
960
1054
  });
961
1055
  connection.on("closed", () => {
1056
+ if (generation !== this._mqttGeneration) return;
962
1057
  this._logger.info("MQTT connection closed");
1058
+ this._clearMqttStabilityTimer();
963
1059
  this._mqttConnectionPromise = void 0;
964
1060
  this._mqttCredentialsExpiration = void 0;
965
1061
  });
966
1062
  await connection.connect();
967
1063
  return connection;
968
1064
  }
1065
+ /**
1066
+ * Re-subscribes every device currently receiving real-time updates on the given connection.
1067
+ *
1068
+ * Safe to call on every (re)connect: aws-crt replaces the per-topic callback on a duplicate subscribe, so this never
1069
+ * registers a message handler more than once.
1070
+ *
1071
+ * @param connection - The connection to (re-)establish subscriptions on.
1072
+ */
1073
+ async _resubscribeAll(connection) {
1074
+ for (const deviceId of Array.from(this._realtimeDeviceIds.keys())) {
1075
+ const topic = `/v1/dev/${deviceId}/out`;
1076
+ this._logger.debug(`Re-subscribing to ${topic}`);
1077
+ await connection.subscribe(topic, mqtt.QoS.AtLeastOnce, (_topic, payload) => {
1078
+ this._processMqttMessage(payload);
1079
+ });
1080
+ }
1081
+ const decoder = new TextDecoder("utf-8");
1082
+ for (const [filter, handler] of Array.from(this._rawTopicCaptures.entries())) {
1083
+ this._logger.debug(`Re-subscribing to raw topic filter '${filter}'`);
1084
+ try {
1085
+ await connection.subscribe(filter, mqtt.QoS.AtLeastOnce, (topic, payload) => {
1086
+ handler(topic, decoder.decode(payload));
1087
+ });
1088
+ } catch (error) {
1089
+ this._logger.warn(`Failed to re-subscribe to raw topic filter '${filter}'`, error);
1090
+ }
1091
+ }
1092
+ }
1093
+ /**
1094
+ * Arms (or re-arms) the stability timer. When a connection stays interrupt-free for {@link MqttStabilityWindow}, the
1095
+ * consecutive-reset backoff counter is cleared so a future isolated storm still gets a fast first reset.
1096
+ *
1097
+ * @param generation - The connection generation that armed the timer; the callback is a no-op if the connection has
1098
+ * since been replaced.
1099
+ */
1100
+ _armMqttStabilityTimer(generation) {
1101
+ var _a, _b;
1102
+ this._clearMqttStabilityTimer();
1103
+ this._mqttStabilityTimer = setTimeout(() => {
1104
+ if (generation !== this._mqttGeneration) return;
1105
+ if (this._mqttConsecutiveResets > 0) {
1106
+ this._logger.debug("MQTT connection stable; clearing consecutive-reset counter");
1107
+ this._mqttConsecutiveResets = 0;
1108
+ }
1109
+ }, MqttStabilityWindow.asMilliseconds());
1110
+ (_b = (_a = this._mqttStabilityTimer).unref) == null ? void 0 : _b.call(_a);
1111
+ }
1112
+ /** Clears the stability timer, if armed. */
1113
+ _clearMqttStabilityTimer() {
1114
+ if (this._mqttStabilityTimer) {
1115
+ clearTimeout(this._mqttStabilityTimer);
1116
+ this._mqttStabilityTimer = void 0;
1117
+ }
1118
+ }
1119
+ /**
1120
+ * Forcefully tears down the current MQTT connection and rebuilds it with a fresh client id and fresh credentials,
1121
+ * escaping interrupt storms that a plain native reconnect cannot.
1122
+ *
1123
+ * Repeatable and self-contained: only one reset runs at a time, consecutive resets without an intervening stable
1124
+ * period back off exponentially (up to {@link MqttResetMaxDelay}), and it never rejects — so it is safe to invoke
1125
+ * fire-and-forget from an event handler.
1126
+ *
1127
+ * @param reason - Human-readable reason for the reset, used in logs.
1128
+ */
1129
+ async _resetMqttConnection(reason) {
1130
+ if (this._mqttResetInProgress) {
1131
+ return;
1132
+ }
1133
+ this._mqttResetInProgress = true;
1134
+ const connectionToClose = this._mqttConnectionPromise;
1135
+ try {
1136
+ this._mqttConsecutiveResets++;
1137
+ const delayMs = Math.min(
1138
+ MqttResetBaseDelay.asMilliseconds() * Math.pow(2, this._mqttConsecutiveResets - 1),
1139
+ MqttResetMaxDelay.asMilliseconds()
1140
+ );
1141
+ this._logger.warn(
1142
+ `${reason}. Forcing MQTT reset #${this._mqttConsecutiveResets} (new clientId, fresh credentials) after ${Math.round(delayMs)}ms...`
1143
+ );
1144
+ this._mqttClientId = void 0;
1145
+ this._mqttCredentialsExpiration = void 0;
1146
+ this._mqttInterrupts = [];
1147
+ this._clearMqttStabilityTimer();
1148
+ this._mqttConnectionPromise = void 0;
1149
+ if (connectionToClose) {
1150
+ try {
1151
+ const connection = await connectionToClose;
1152
+ connection.removeAllListeners();
1153
+ await connection.disconnect();
1154
+ } catch (err) {
1155
+ this._logger.debug("Error tearing down old MQTT connection during reset (ignored)", err);
1156
+ }
1157
+ }
1158
+ if (delayMs > 0) {
1159
+ await new Promise((r) => setTimeout(r, delayMs));
1160
+ }
1161
+ const newConnection = await this._getMqttConnection();
1162
+ await this._resubscribeAll(newConnection);
1163
+ this._logger.info(`MQTT connection rebuilt successfully after reset #${this._mqttConsecutiveResets} (${reason})`);
1164
+ } catch (err) {
1165
+ this._logger.error("Failed to rebuild MQTT connection after reset", err);
1166
+ } finally {
1167
+ this._mqttResetInProgress = false;
1168
+ }
1169
+ }
969
1170
  /**
970
1171
  * Processes incoming MQTT messages and emits appropriate events.
971
1172
  *
@@ -1001,7 +1202,6 @@ var MysaApiClient = class {
1001
1202
  } else if (isMsgOutPayload(parsedPayload)) {
1002
1203
  switch (parsedPayload.msg) {
1003
1204
  case 30 /* DEVICE_AC_STATUS */:
1004
- case 40 /* DEVICE_V2_STATUS */:
1005
1205
  this.emitter.emit("statusChanged", {
1006
1206
  deviceId: parsedPayload.src.ref,
1007
1207
  temperature: parsedPayload.body.ambTemp,
@@ -1010,6 +1210,16 @@ var MysaApiClient = class {
1010
1210
  dutyCycle: parsedPayload.body.dtyCycle
1011
1211
  });
1012
1212
  break;
1213
+ case 40 /* DEVICE_V2_STATUS */:
1214
+ this.emitter.emit("statusChanged", {
1215
+ deviceId: parsedPayload.src.ref,
1216
+ temperature: parsedPayload.body.ambTemp,
1217
+ humidity: parsedPayload.body.hum,
1218
+ setPoint: parsedPayload.body.stpt,
1219
+ dutyCycle: parsedPayload.body.dtyCycle ?? parsedPayload.body.heatStat,
1220
+ floorTemperature: parsedPayload.body.flrSnsrTemp
1221
+ });
1222
+ break;
1013
1223
  case 44 /* DEVICE_STATE_CHANGE */: {
1014
1224
  const modeMap = {
1015
1225
  1: "off",
@@ -1019,18 +1229,11 @@ var MysaApiClient = class {
1019
1229
  5: "fan_only",
1020
1230
  6: "dry"
1021
1231
  };
1022
- const fanSpeedMap = {
1023
- 1: "auto",
1024
- 3: "low",
1025
- 5: "medium",
1026
- 7: "high",
1027
- 8: "max"
1028
- };
1029
1232
  this.emitter.emit("stateChanged", {
1030
1233
  deviceId: parsedPayload.src.ref,
1031
1234
  mode: parsedPayload.body.state.md ? modeMap[parsedPayload.body.state.md] : void 0,
1032
1235
  setPoint: parsedPayload.body.state.sp,
1033
- fanSpeed: parsedPayload.body.state.fn !== void 0 ? fanSpeedMap[parsedPayload.body.state.fn] : void 0
1236
+ fanSpeed: parsedPayload.body.state.fn !== void 0 ? FanSpeedReceiveMap[parsedPayload.body.state.fn] : void 0
1034
1237
  });
1035
1238
  break;
1036
1239
  }
@@ -1049,6 +1252,7 @@ export {
1049
1252
  OutMessageType,
1050
1253
  UnauthenticatedError,
1051
1254
  UnknownDeviceError,
1255
+ UnsupportedFanSpeedError,
1052
1256
  VoidLogger
1053
1257
  };
1054
1258
  //# sourceMappingURL=index.js.map