mysa-js-sdk 2.1.2 → 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.js CHANGED
@@ -24,14 +24,24 @@ SOFTWARE.
24
24
 
25
25
  // src/api/Errors.ts
26
26
  var UnauthenticatedError = class extends Error {
27
+ /**
28
+ * The underlying failure that prevented authentication.
29
+ *
30
+ * Callers need it to tell a credential rejection apart from a transport or service failure, since both surface as
31
+ * this error. Declared explicitly rather than passed to `super` so the SDK keeps compiling against its current lib,
32
+ * which predates the two-argument `Error` constructor.
33
+ */
34
+ cause;
27
35
  /**
28
36
  * Creates a new UnauthenticatedError instance.
29
37
  *
30
38
  * @param message - The error message
39
+ * @param cause - The underlying failure that prevented authentication, if any.
31
40
  */
32
- constructor(message) {
41
+ constructor(message, cause) {
33
42
  super(message);
34
43
  this.name = "UnauthenticatedError";
44
+ this.cause = cause;
35
45
  }
36
46
  };
37
47
  var MysaApiError = class extends Error {
@@ -53,6 +63,40 @@ var MysaApiError = class extends Error {
53
63
  this.statusText = apiResponse.statusText;
54
64
  }
55
65
  };
66
+ var UnknownDeviceError = class extends Error {
67
+ /**
68
+ * Creates a new UnknownDeviceError instance.
69
+ *
70
+ * @param deviceId - The device id that could not be resolved
71
+ */
72
+ constructor(deviceId) {
73
+ super(`Unknown device id '${deviceId}': no such device on this account.`);
74
+ this.deviceId = deviceId;
75
+ this.name = "UnknownDeviceError";
76
+ }
77
+ deviceId;
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
+ };
56
100
  var MqttPublishError = class extends Error {
57
101
  /**
58
102
  * Creates a new MqttPublishError instance.
@@ -150,8 +194,9 @@ function parseMqttPayload(payload) {
150
194
  const jsonString = decoder.decode(payload);
151
195
  return JSON.parse(jsonString);
152
196
  } catch (error) {
153
- console.error("Error parsing MQTT payload:", error);
154
- throw new Error("Failed to parse MQTT payload");
197
+ const parseError = new Error("Failed to parse MQTT payload");
198
+ parseError.cause = error;
199
+ throw parseError;
155
200
  }
156
201
  }
157
202
  function serializeMqttPayload(payload) {
@@ -191,15 +236,7 @@ var OutMessageType = /* @__PURE__ */ ((OutMessageType2) => {
191
236
  // src/api/MysaApiClient.ts
192
237
  import { DescribeThingCommand, IoTClient } from "@aws-sdk/client-iot";
193
238
  import { fromCognitoIdentityPool } from "@aws-sdk/credential-providers";
194
- import {
195
- AuthenticationDetails,
196
- CognitoAccessToken,
197
- CognitoIdToken,
198
- CognitoRefreshToken,
199
- CognitoUser,
200
- CognitoUserPool,
201
- CognitoUserSession
202
- } from "amazon-cognito-identity-js";
239
+ import { AuthenticationDetails, CognitoUser, CognitoUserPool } from "amazon-cognito-identity-js";
203
240
  import { auth, iot, mqtt } from "aws-iot-device-sdk-v2";
204
241
  import { hash } from "crypto";
205
242
  import dayjs from "dayjs";
@@ -216,11 +253,52 @@ var MqttEndpoint = "a3q27gia9qg3zy-ats.iot.us-east-1.amazonaws.com";
216
253
  var MysaApiBaseUrl = "https://app-prod.mysa.cloud";
217
254
  var RealtimeKeepAliveInterval = dayjs.duration(5, "minutes");
218
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
+ }
219
293
  var MysaApiClient = class {
294
+ /** The credentials of the Mysa account this client authenticates as. */
295
+ _credentials;
220
296
  /** The current session object, if any. */
221
297
  _cognitoUserSession;
222
298
  /** The current user object, if any. */
223
299
  _cognitoUser;
300
+ /** The in-flight session acquisition, if any, so that concurrent callers share a single refresh or login. */
301
+ _freshSessionPromise;
224
302
  /** The logger instance used by the client. */
225
303
  _logger;
226
304
  /** The fetcher function used by the client. */
@@ -235,8 +313,21 @@ var MysaApiClient = class {
235
313
  _mqttInterrupts = [];
236
314
  /** Whether a forced MQTT reset is currently in progress (guards against re-entrancy). */
237
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;
238
324
  /** The device IDs that are currently being updated in real-time, mapped to their respective timeouts. */
239
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();
240
331
  /** The cached devices object, if any. */
241
332
  _cachedDevices;
242
333
  /**
@@ -245,89 +336,63 @@ var MysaApiClient = class {
245
336
  * @see {@link MysaApiClientEventTypes} for the possible events and their payloads.
246
337
  */
247
338
  emitter = new EventEmitter();
248
- /**
249
- * Gets the persistable session object.
250
- *
251
- * @returns The current persistable session object, if any.
252
- */
253
- get session() {
254
- if (!this._cognitoUserSession || !this._cognitoUser) {
255
- return void 0;
256
- }
257
- return {
258
- username: this._cognitoUser.getUsername(),
259
- idToken: this._cognitoUserSession.getIdToken().getJwtToken(),
260
- accessToken: this._cognitoUserSession.getAccessToken().getJwtToken(),
261
- refreshToken: this._cognitoUserSession.getRefreshToken().getToken()
262
- };
263
- }
264
- /**
265
- * Returns whether the client currently has an active session.
266
- *
267
- * @returns True if the client has an active session, false otherwise.
268
- */
269
- get isAuthenticated() {
270
- return !!this.session;
271
- }
272
339
  /**
273
340
  * Constructs a new instance of the MysaApiClient.
274
341
  *
275
- * @param session - The persistable session object, if any.
342
+ * @param credentials - The credentials of the Mysa account to authenticate as.
276
343
  * @param options - The options for the client.
277
344
  */
278
- constructor(session, options) {
345
+ constructor(credentials, options) {
346
+ this._credentials = credentials;
279
347
  this._logger = (options == null ? void 0 : options.logger) || new VoidLogger();
280
348
  this._fetcher = (options == null ? void 0 : options.fetcher) || fetch;
281
- if (session) {
282
- this._cognitoUser = new CognitoUser({
283
- Username: session.username,
284
- Pool: new CognitoUserPool({ UserPoolId: CognitoUserPoolId, ClientId: CognitoClientId })
285
- });
286
- this._cognitoUserSession = new CognitoUserSession({
287
- IdToken: new CognitoIdToken({ IdToken: session.idToken }),
288
- AccessToken: new CognitoAccessToken({ AccessToken: session.accessToken }),
289
- RefreshToken: new CognitoRefreshToken({ RefreshToken: session.refreshToken })
290
- });
291
- }
292
349
  }
293
350
  /**
294
- * Logs in the user with the given email address and password.
351
+ * Ensures the client has a usable session, logging in with the credentials it was constructed with if needed.
295
352
  *
296
- * This method authenticates the user with Mysa's Cognito user pool and establishes a session that can be used for
297
- * subsequent API calls. Upon successful login, a 'sessionChanged' event is emitted.
353
+ * Calling this method is optional: the client authenticates on demand before its first API call, and re-authenticates
354
+ * on its own whenever its session can no longer be refreshed. Call it explicitly at startup to fail fast on invalid
355
+ * credentials instead of on the first API call. It is a no-op when the current session is still usable.
298
356
  *
299
357
  * @example
300
358
  *
301
359
  * ```typescript
302
360
  * try {
303
- * await client.login('user@example.com', 'password123');
361
+ * await client.login();
304
362
  * console.log('Login successful!');
305
363
  * } catch (error) {
306
364
  * console.error('Login failed:', error.message);
307
365
  * }
308
366
  * ```
309
367
  *
310
- * @param emailAddress - The email address of the user.
311
- * @param password - The password of the user.
368
+ * @throws {@link UnauthenticatedError} When authentication fails due to invalid credentials or network issues.
369
+ */
370
+ async login() {
371
+ await this._getFreshSession();
372
+ }
373
+ /**
374
+ * Authenticates with Mysa's Cognito user pool and replaces the current session, if any.
375
+ *
376
+ * @returns A promise that resolves to the newly established session.
312
377
  * @throws {@link Error} When authentication fails due to invalid credentials or network issues.
313
378
  */
314
- async login(emailAddress, password) {
379
+ _login() {
315
380
  this._cognitoUser = void 0;
316
381
  this._cognitoUserSession = void 0;
317
382
  this._mqttClientId = void 0;
318
383
  this._mqttInterrupts = [];
319
- this.emitter.emit("sessionChanged", this.session);
384
+ this._mqttConsecutiveResets = 0;
385
+ const { username, password } = this._credentials;
320
386
  return new Promise((resolve, reject) => {
321
387
  const user = new CognitoUser({
322
- Username: emailAddress,
388
+ Username: username,
323
389
  Pool: new CognitoUserPool({ UserPoolId: CognitoUserPoolId, ClientId: CognitoClientId })
324
390
  });
325
- user.authenticateUser(new AuthenticationDetails({ Username: emailAddress, Password: password }), {
391
+ user.authenticateUser(new AuthenticationDetails({ Username: username, Password: password }), {
326
392
  onSuccess: (session) => {
327
393
  this._cognitoUser = user;
328
394
  this._cognitoUserSession = session;
329
- this.emitter.emit("sessionChanged", this.session);
330
- resolve();
395
+ resolve(session);
331
396
  },
332
397
  onFailure: (err) => {
333
398
  reject(err);
@@ -352,7 +417,7 @@ var MysaApiClient = class {
352
417
  *
353
418
  * @returns A promise that resolves to the list of devices.
354
419
  * @throws {@link MysaApiError} When the API request fails.
355
- * @throws {@link UnauthenticatedError} When the user is not authenticated.
420
+ * @throws {@link UnauthenticatedError} When the client cannot authenticate with its credentials.
356
421
  */
357
422
  async getDevices() {
358
423
  this._logger.debug(`Fetching devices...`);
@@ -386,7 +451,7 @@ var MysaApiClient = class {
386
451
  *
387
452
  * @param deviceId - The ID of the device to get the serial number for.
388
453
  * @returns A promise that resolves to the serial number, or undefined if not found.
389
- * @throws {@link UnauthenticatedError} When the user is not authenticated.
454
+ * @throws {@link UnauthenticatedError} When the client cannot authenticate with its credentials.
390
455
  */
391
456
  async getDeviceSerialNumber(deviceId) {
392
457
  var _a;
@@ -424,7 +489,7 @@ var MysaApiClient = class {
424
489
  *
425
490
  * @returns A promise that resolves to the firmware information for all devices.
426
491
  * @throws {@link MysaApiError} When the API request fails.
427
- * @throws {@link UnauthenticatedError} When the user is not authenticated.
492
+ * @throws {@link UnauthenticatedError} When the client cannot authenticate with its credentials.
428
493
  */
429
494
  async getDeviceFirmwares() {
430
495
  this._logger.debug(`Fetching device firmwares...`);
@@ -444,7 +509,7 @@ var MysaApiClient = class {
444
509
  *
445
510
  * @returns A promise that resolves to the current state of all devices.
446
511
  * @throws {@link MysaApiError} When the API request fails.
447
- * @throws {@link UnauthenticatedError} When the user is not authenticated.
512
+ * @throws {@link UnauthenticatedError} When the client cannot authenticate with its credentials.
448
513
  */
449
514
  async getDeviceStates() {
450
515
  this._logger.debug(`Fetching device states...`);
@@ -464,7 +529,7 @@ var MysaApiClient = class {
464
529
  *
465
530
  * @returns A promise that resolves to the homes information.
466
531
  * @throws {@link MysaApiError} When the API request fails.
467
- * @throws {@link UnauthenticatedError} When the user is not authenticated.
532
+ * @throws {@link UnauthenticatedError} When the client cannot authenticate with its credentials.
468
533
  */
469
534
  async getHomes() {
470
535
  this._logger.debug(`Fetching homes...`);
@@ -506,29 +571,37 @@ var MysaApiClient = class {
506
571
  * @param mode - The operating mode to set (one of MysaDeviceMode values, or undefined to leave unchanged).
507
572
  * @param fanSpeed - The fan speed mode to set ('low', 'medium', 'high', 'max', 'auto', or undefined to leave
508
573
  * unchanged).
509
- * @throws {@link UnauthenticatedError} When the user is not authenticated.
574
+ * @throws {@link UnauthenticatedError} When the client cannot authenticate with its credentials.
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.
510
577
  * @throws {@link Error} When MQTT connection or command sending fails.
511
578
  */
512
579
  async setDeviceState(deviceId, setPoint, mode, fanSpeed) {
513
- var _a;
514
580
  this._logger.debug(`Setting device state for '${deviceId}'`);
515
581
  if (!this._cachedDevices) {
516
582
  this._cachedDevices = await this.getDevices();
517
583
  }
584
+ if (!Object.prototype.hasOwnProperty.call(this._cachedDevices.DevicesObj, deviceId)) {
585
+ throw new UnknownDeviceError(deviceId);
586
+ }
518
587
  const device = this._cachedDevices.DevicesObj[deviceId];
588
+ await this._getFreshSession();
519
589
  this._logger.debug(`Initializing MQTT connection...`);
520
590
  const mqttConnection = await this._getMqttConnection();
521
591
  const now = dayjs();
522
592
  this._logger.debug(`Sending request to set device state for '${deviceId}'...`);
523
593
  const modeMap = { off: 1, auto: 2, heat: 3, cool: 4, fan_only: 5, dry: 6 };
524
- 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
+ }
525
598
  const payload = serializeMqttPayload({
526
599
  msg: 44 /* CHANGE_DEVICE_STATE */,
527
600
  id: now.valueOf(),
528
601
  time: now.unix(),
529
602
  ver: "1.0",
530
603
  src: {
531
- ref: ((_a = this.session) == null ? void 0 : _a.username) ?? "",
604
+ ref: this._cognitoUser.getUsername(),
532
605
  type: 100
533
606
  },
534
607
  dest: {
@@ -538,7 +611,7 @@ var MysaApiClient = class {
538
611
  resp: 2,
539
612
  body: {
540
613
  ver: 1,
541
- 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,
542
615
  cmd: [
543
616
  {
544
617
  tm: -1,
@@ -642,37 +715,102 @@ var MysaApiClient = class {
642
715
  clearInterval(timer);
643
716
  this._realtimeDeviceIds.delete(deviceId);
644
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
+ }
645
758
  /**
646
759
  * Ensures a valid, non-expired session is available.
647
760
  *
648
761
  * This method checks if the current session is valid and not expired. If the session is expired, it automatically
649
- * refreshes it using the refresh token.
762
+ * refreshes it using the refresh token. If there is no session yet, or if the refresh token has itself expired or
763
+ * been revoked, the client logs back in with its credentials.
764
+ *
765
+ * Concurrent callers share a single in-flight acquisition, so a burst of API calls never triggers more than one
766
+ * refresh or login.
650
767
  *
651
768
  * @returns A promise that resolves to a valid CognitoUserSession.
652
- * @throws {@link UnauthenticatedError} When no session exists or refresh fails.
769
+ * @throws {@link UnauthenticatedError} When neither refreshing nor logging back in succeeds.
653
770
  */
654
771
  async _getFreshSession() {
655
- if (!this._cognitoUser || !this._cognitoUserSession) {
656
- throw new UnauthenticatedError("An attempt was made to access a resource without a valid session.");
657
- }
658
- if (this._cognitoUserSession.isValid() && dayjs.unix(this._cognitoUserSession.getIdToken().getExpiration()).isAfter()) {
772
+ var _a;
773
+ if (this._cognitoUser && ((_a = this._cognitoUserSession) == null ? void 0 : _a.isValid()) && dayjs.unix(this._cognitoUserSession.getIdToken().getExpiration()).isAfter()) {
659
774
  this._logger.debug("Session is valid, no need to refresh");
660
- return Promise.resolve(this._cognitoUserSession);
775
+ return this._cognitoUserSession;
661
776
  }
662
- this._logger.debug("Session is not valid or expired, refreshing...");
663
- return new Promise((resolve, reject) => {
664
- this._cognitoUser.refreshSession(this._cognitoUserSession.getRefreshToken(), (error, session) => {
665
- if (error) {
666
- this._logger.error("Failed to refresh session:", error);
667
- reject(new UnauthenticatedError("Unable to refresh the authentication session."));
668
- } else {
669
- this._logger.debug("Session refreshed successfully");
670
- this._cognitoUserSession = session;
671
- this.emitter.emit("sessionChanged", this.session);
672
- resolve(session);
673
- }
674
- });
777
+ this._freshSessionPromise ??= this._acquireFreshSession().finally(() => {
778
+ this._freshSessionPromise = void 0;
675
779
  });
780
+ return this._freshSessionPromise;
781
+ }
782
+ /**
783
+ * Refreshes the current session, falling back to a full login when it cannot be refreshed.
784
+ *
785
+ * @returns A promise that resolves to a valid CognitoUserSession.
786
+ * @throws {@link UnauthenticatedError} When logging back in fails.
787
+ */
788
+ async _acquireFreshSession() {
789
+ if (this._cognitoUser && this._cognitoUserSession) {
790
+ this._logger.debug("Session is not valid or expired, refreshing...");
791
+ try {
792
+ return await new Promise((resolve, reject) => {
793
+ this._cognitoUser.refreshSession(this._cognitoUserSession.getRefreshToken(), (error, session) => {
794
+ if (error) {
795
+ reject(error);
796
+ } else {
797
+ this._logger.debug("Session refreshed successfully");
798
+ this._cognitoUserSession = session;
799
+ resolve(session);
800
+ }
801
+ });
802
+ });
803
+ } catch (error) {
804
+ this._logger.warn("Failed to refresh session, logging back in:", error);
805
+ }
806
+ }
807
+ try {
808
+ this._logger.info("Logging in...");
809
+ return await this._login();
810
+ } catch (error) {
811
+ this._logger.error("Failed to log in:", error);
812
+ throw new UnauthenticatedError("Unable to establish an authentication session.", error);
813
+ }
676
814
  }
677
815
  /**
678
816
  * Establishes and returns an MQTT connection for real-time communication.
@@ -814,11 +952,11 @@ var MysaApiClient = class {
814
952
  throw new Error("MQTT credentials are already expired.");
815
953
  }
816
954
  if (!this._mqttClientId) {
817
- const username = ((_a = this.session) == null ? void 0 : _a.username) ?? "anon";
955
+ const username = ((_a = this._cognitoUser) == null ? void 0 : _a.getUsername()) ?? "anon";
818
956
  const usernameHash = hash("sha1", username);
819
957
  this._mqttClientId = `mysa-js-sdk-${usernameHash}-${process.pid}-${getRandomClientId()}`;
820
958
  }
821
- 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);
822
960
  const config = builder.build();
823
961
  config.websocket_handshake_transform = async (request, done) => {
824
962
  try {
@@ -861,89 +999,174 @@ var MysaApiClient = class {
861
999
  };
862
1000
  const client = new mqtt.MqttClient();
863
1001
  const connection = client.new_connection(config);
864
- connection.on("connect", () => {
865
- 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})`);
866
1006
  });
867
- connection.on("connection_success", () => {
868
- 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);
869
1014
  });
870
1015
  connection.on("connection_failure", (e) => {
1016
+ if (generation !== this._mqttGeneration) return;
871
1017
  this._logger.error(`MQTT connection_failure (clientId=${this._mqttClientId})`, e);
872
1018
  });
873
- connection.on("interrupt", async (e) => {
1019
+ connection.on("interrupt", (e) => {
874
1020
  var _a2;
875
- 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();
876
1028
  const now = Date.now();
877
- this._mqttInterrupts = this._mqttInterrupts.filter((t) => now - t < 6e4);
1029
+ this._mqttInterrupts = this._mqttInterrupts.filter((t) => now - t < MqttInterruptWindow.asMilliseconds());
878
1030
  this._mqttInterrupts.push(now);
879
1031
  const areCredentialsExpired = !(((_a2 = this._mqttCredentialsExpiration) == null ? void 0 : _a2.isAfter(dayjs())) ?? false);
880
- if ((this._mqttInterrupts.length > 5 || areCredentialsExpired) && !this._mqttResetInProgress) {
881
- this._mqttResetInProgress = true;
882
- if (this._mqttInterrupts.length > 5) {
883
- this._logger.warn(
884
- `High interrupt rate (${this._mqttInterrupts.length}/60s). Possible clientId collision. Regenerating clientId and resetting connection...`
885
- );
886
- } else {
887
- this._logger.warn(`Credentials expired. Regenerating clientId and resetting connection...`);
888
- }
889
- this._mqttClientId = void 0;
890
- this._mqttCredentialsExpiration = void 0;
891
- this._mqttInterrupts = [];
892
- this._mqttConnectionPromise = void 0;
893
- try {
894
- connection.removeAllListeners();
895
- await connection.disconnect();
896
- try {
897
- this._logger.debug("Old MQTT connection disconnected; establishing new connection...");
898
- const newConnection = await this._getMqttConnection();
899
- for (const deviceId of Array.from(this._realtimeDeviceIds.keys())) {
900
- const topic = `/v1/dev/${deviceId}/out`;
901
- this._logger.debug(`Re-subscribing to ${topic}`);
902
- await newConnection.subscribe(topic, mqtt.QoS.AtLeastOnce, (_topic, payload) => {
903
- this._processMqttMessage(payload);
904
- });
905
- }
906
- this._logger.info("MQTT connection rebuilt successfully after interrupt storm or credentials expiration");
907
- } catch (err) {
908
- this._logger.error("Failed to re-subscribe after interrupt storm or credentials expiration", err);
909
- }
910
- } catch (error) {
911
- this._logger.error("Error during MQTT reset", error);
912
- } finally {
913
- this._mqttResetInProgress = false;
914
- }
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);
915
1036
  }
916
1037
  });
917
1038
  connection.on("resume", async (returnCode, sessionPresent) => {
1039
+ if (generation !== this._mqttGeneration) return;
918
1040
  this._logger.info(
919
1041
  `MQTT resume returnCode=${returnCode} sessionPresent=${sessionPresent} clientId=${this._mqttClientId}`
920
1042
  );
921
1043
  if (!sessionPresent) {
922
- this._logger.info("No session present, re-subscribing each device");
923
1044
  try {
924
- for (const deviceId of Array.from(this._realtimeDeviceIds.keys())) {
925
- const topic = `/v1/dev/${deviceId}/out`;
926
- this._logger.debug(`Re-subscribing to ${topic}`);
927
- await connection.subscribe(topic, mqtt.QoS.AtLeastOnce, (_topic, payload) => {
928
- this._processMqttMessage(payload);
929
- });
930
- }
1045
+ await this._resubscribeAll(connection);
931
1046
  } catch (err) {
932
1047
  this._logger.error("Failed to re-subscribe after resume", err);
933
1048
  }
934
1049
  }
935
1050
  });
936
1051
  connection.on("error", (e) => {
1052
+ if (generation !== this._mqttGeneration) return;
937
1053
  this._logger.error(`MQTT error (clientId=${this._mqttClientId})`, e);
938
1054
  });
939
1055
  connection.on("closed", () => {
1056
+ if (generation !== this._mqttGeneration) return;
940
1057
  this._logger.info("MQTT connection closed");
1058
+ this._clearMqttStabilityTimer();
941
1059
  this._mqttConnectionPromise = void 0;
942
1060
  this._mqttCredentialsExpiration = void 0;
943
1061
  });
944
1062
  await connection.connect();
945
1063
  return connection;
946
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
+ }
947
1170
  /**
948
1171
  * Processes incoming MQTT messages and emits appropriate events.
949
1172
  *
@@ -979,7 +1202,6 @@ var MysaApiClient = class {
979
1202
  } else if (isMsgOutPayload(parsedPayload)) {
980
1203
  switch (parsedPayload.msg) {
981
1204
  case 30 /* DEVICE_AC_STATUS */:
982
- case 40 /* DEVICE_V2_STATUS */:
983
1205
  this.emitter.emit("statusChanged", {
984
1206
  deviceId: parsedPayload.src.ref,
985
1207
  temperature: parsedPayload.body.ambTemp,
@@ -988,6 +1210,16 @@ var MysaApiClient = class {
988
1210
  dutyCycle: parsedPayload.body.dtyCycle
989
1211
  });
990
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;
991
1223
  case 44 /* DEVICE_STATE_CHANGE */: {
992
1224
  const modeMap = {
993
1225
  1: "off",
@@ -997,18 +1229,11 @@ var MysaApiClient = class {
997
1229
  5: "fan_only",
998
1230
  6: "dry"
999
1231
  };
1000
- const fanSpeedMap = {
1001
- 1: "auto",
1002
- 3: "low",
1003
- 5: "medium",
1004
- 7: "high",
1005
- 8: "max"
1006
- };
1007
1232
  this.emitter.emit("stateChanged", {
1008
1233
  deviceId: parsedPayload.src.ref,
1009
1234
  mode: parsedPayload.body.state.md ? modeMap[parsedPayload.body.state.md] : void 0,
1010
1235
  setPoint: parsedPayload.body.state.sp,
1011
- 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
1012
1237
  });
1013
1238
  break;
1014
1239
  }
@@ -1026,6 +1251,8 @@ export {
1026
1251
  MysaApiError,
1027
1252
  OutMessageType,
1028
1253
  UnauthenticatedError,
1254
+ UnknownDeviceError,
1255
+ UnsupportedFanSpeedError,
1029
1256
  VoidLogger
1030
1257
  };
1031
1258
  //# sourceMappingURL=index.js.map