mysa-js-sdk 1.3.2 → 1.4.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
@@ -20,6 +20,19 @@ declare class MysaApiError extends Error {
20
20
  */
21
21
  constructor(apiResponse: Response);
22
22
  }
23
+ /** Error thrown when an MQTT publish ultimately fails after retry attempts. */
24
+ declare class MqttPublishError extends Error {
25
+ attempts: number;
26
+ original?: unknown | undefined;
27
+ /**
28
+ * Creates a new MqttPublishError instance.
29
+ *
30
+ * @param message - A human-readable description of the publish failure.
31
+ * @param attempts - The number of attempts that were made before giving up.
32
+ * @param original - The original error object thrown by the underlying MQTT library (optional).
33
+ */
34
+ constructor(message: string, attempts: number, original?: unknown | undefined);
35
+ }
23
36
 
24
37
  /**
25
38
  * Interface representing a temperature setpoint change event for a Mysa device.
@@ -43,7 +56,13 @@ interface SetPointChange {
43
56
  * Defines the possible operational states that a Mysa thermostat or heating device can be set to. These modes control
44
57
  * the device's heating behavior and power consumption.
45
58
  */
46
- type MysaDeviceMode = 'off' | 'heat';
59
+ type MysaDeviceMode = 'off' | 'heat' | 'cool' | 'dry' | 'fan_only' | 'auto';
60
+ /**
61
+ * Union type representing the available fan speed modes for Mysa devices.
62
+ *
63
+ * Defines the possible fan speed states that a Mysa thermostat device can be set to.
64
+ */
65
+ type MysaFanSpeedMode = 'auto' | 'low' | 'medium' | 'high' | 'max';
47
66
 
48
67
  /**
49
68
  * Interface representing a device state change event for a Mysa device.
@@ -59,6 +78,8 @@ interface StateChange {
59
78
  mode?: MysaDeviceMode;
60
79
  /** Current temperature setpoint after the state change */
61
80
  setPoint: number;
81
+ /** Optional fan speed (1 = auto, 3 = low, 5 = medium, 7 = high, 8 = max). AC only */
82
+ fanSpeed?: MysaFanSpeedMode;
62
83
  }
63
84
 
64
85
  /**
@@ -357,6 +378,8 @@ interface DeviceState {
357
378
  Humidity: TimestampedValue<number>;
358
379
  /** Lock status */
359
380
  Lock: TimestampedValue<number>;
381
+ /** Fan speed */
382
+ FanSpeed?: TimestampedValue<number>;
360
383
  }
361
384
  /**
362
385
  * Collection of device states indexed by device ID
@@ -446,10 +469,12 @@ interface DeviceStateChange extends MsgPayload<OutMessageType.DEVICE_STATE_CHANG
446
469
  ho: number;
447
470
  /** Unknown */
448
471
  lk: number;
449
- /** Device mode (1 = OFF, 3 = HEAT) */
472
+ /** Device mode (1 = OFF, 2 = AUTO, 3 = HEAT, 4 = COOL, 5 = FAN_ONLY, 6 = DRY) */
450
473
  md: number;
451
474
  /** Temperature setpoint */
452
475
  sp: number;
476
+ /** Optional fan speed (1 = auto, 3 = low, 5 = medium, 7 = high, 8 = max). AC only */
477
+ fn?: number;
453
478
  };
454
479
  /** Success indicator for the state change operation (1 = success, 0 = failure) */
455
480
  success: number;
@@ -670,6 +695,13 @@ interface MysaApiClientOptions {
670
695
  fetcher?: typeof fetch;
671
696
  }
672
697
 
698
+ /** Options for MQTT publish operations. */
699
+ interface MqttPublishOptions {
700
+ /** Maximum number of publish attempts before failing (default: 5). */
701
+ maxAttempts?: number;
702
+ /** Base delay in milliseconds used for exponential backoff calculation (default: 500). */
703
+ baseDelayMs?: number;
704
+ }
673
705
  /**
674
706
  * Main client for interacting with the Mysa API and real-time device communication.
675
707
  *
@@ -703,8 +735,8 @@ declare class MysaApiClient {
703
735
  private _logger;
704
736
  /** The fetcher function used by the client. */
705
737
  private _fetcher;
706
- /** The MQTT connection used for real-time updates. */
707
- private _mqttConnection?;
738
+ /** A promise that resolves to the MQTT connection used for real-time updates. */
739
+ private _mqttConnectionPromise?;
708
740
  /** The device IDs that are currently being updated in real-time, mapped to their respective timeouts. */
709
741
  private _realtimeDeviceIds;
710
742
  /** The cached devices object, if any. */
@@ -831,15 +863,20 @@ declare class MysaApiClient {
831
863
  *
832
864
  * // Set temperature and mode
833
865
  * await client.setDeviceState('device123', 20, 'heat');
866
+ *
867
+ * // Set fan speed
868
+ * await client.setDeviceState('device123', undefined, undefined, 'auto');
834
869
  * ```
835
870
  *
836
871
  * @param deviceId - The ID of the device to control.
837
872
  * @param setPoint - The target temperature set point (optional).
838
- * @param mode - The operating mode to set ('off', 'heat', or undefined to leave unchanged).
873
+ * @param mode - The operating mode to set (one of MysaDeviceMode values, or undefined to leave unchanged).
874
+ * @param fanSpeed - The fan speed mode to set ('low', 'medium', 'high', 'max', 'auto', or undefined to leave
875
+ * unchanged).
839
876
  * @throws {@link UnauthenticatedError} When the user is not authenticated.
840
877
  * @throws {@link Error} When MQTT connection or command sending fails.
841
878
  */
842
- setDeviceState(deviceId: string, setPoint?: number, mode?: MysaDeviceMode): Promise<void>;
879
+ setDeviceState(deviceId: string, setPoint?: number, mode?: MysaDeviceMode, fanSpeed?: MysaFanSpeedMode): Promise<void>;
843
880
  /**
844
881
  * Starts receiving real-time updates for the specified device.
845
882
  *
@@ -881,7 +918,7 @@ declare class MysaApiClient {
881
918
  * @returns A promise that resolves to a valid CognitoUserSession.
882
919
  * @throws {@link UnauthenticatedError} When no session exists or refresh fails.
883
920
  */
884
- private getFreshSession;
921
+ private _getFreshSession;
885
922
  /**
886
923
  * Establishes and returns an MQTT connection for real-time communication.
887
924
  *
@@ -891,7 +928,49 @@ declare class MysaApiClient {
891
928
  * @returns A promise that resolves to an active MQTT connection.
892
929
  * @throws {@link Error} When connection establishment fails.
893
930
  */
894
- private getMqttConnection;
931
+ private _getMqttConnection;
932
+ /**
933
+ * Determines whether an MQTT-related error is considered transient and worth retrying.
934
+ *
935
+ * Transient errors include timeouts, cancelled operations due to clean sessions, temporary connectivity loss, and
936
+ * other recoverable network issues. Fatal errors (auth, permission, configuration) should not be retried at this
937
+ * layer.
938
+ *
939
+ * @param err - The error object thrown by the underlying MQTT operation.
940
+ * @returns True if the error appears transient and a retry should be attempted; false otherwise.
941
+ */
942
+ private _isTransientMqttError;
943
+ /**
944
+ * Publishes an MQTT message with exponential backoff retries for transient failures.
945
+ *
946
+ * Retries occur for errors classified by `_isTransientMqttError`. Between attempts the delay grows exponentially with
947
+ * jitter to avoid thundering herds after broker recovery. If the connection is not currently marked as connected, a
948
+ * reconnect is attempted; if that fails, the connection is rebuilt (fresh credentials) before the next retry.
949
+ *
950
+ * On final failure (after maxAttempts) a {@link MqttPublishError} is thrown including the number of attempts and
951
+ * original error for higher-level handling.
952
+ *
953
+ * @remarks
954
+ * Retry options fields:
955
+ *
956
+ * - MaxAttempts: Maximum number of publish attempts before failing (default: 5).
957
+ * - BaseDelayMs: Base delay in milliseconds used for exponential backoff calculation (default: 500).
958
+ *
959
+ * @param connection - The active MQTT client connection used to send the publish.
960
+ * @param topic - The MQTT topic to publish to.
961
+ * @param payload - The serialized payload (binary buffer or Uint8Array).
962
+ * @param qos - The desired MQTT QoS level for the publish.
963
+ * @param opts - Retry options (defaults: maxAttempts=5, baseDelayMs=500).
964
+ * @returns A promise that resolves when the publish succeeds, or rejects with {@link MqttPublishError}.
965
+ */
966
+ private _publishWithRetry;
967
+ /**
968
+ * Creates a new MQTT connection using AWS IoT WebSocket connections with Cognito credentials.
969
+ *
970
+ * @returns A promise that resolves to an active MQTT connection.
971
+ * @throws {@link Error} When connection establishment fails.
972
+ */
973
+ private _createMqttConnection;
895
974
  /**
896
975
  * Processes incoming MQTT messages and emits appropriate events.
897
976
  *
@@ -901,7 +980,7 @@ declare class MysaApiClient {
901
980
  *
902
981
  * @param payload - The raw MQTT message payload to process.
903
982
  */
904
- private processMqttMessage;
983
+ private _processMqttMessage;
905
984
  }
906
985
 
907
986
  /**
@@ -954,6 +1033,8 @@ interface ChangeDeviceState extends MsgPayload<InMessageType.CHANGE_DEVICE_STATE
954
1033
  md?: number;
955
1034
  /** Unknown, should always be -1 */
956
1035
  tm: number;
1036
+ /** Optional fan speed (1 = auto, 3 = low, 5 = medium, 7 = high, 8 = max). AC only */
1037
+ fn?: number;
957
1038
  }
958
1039
  ];
959
1040
  /**
@@ -1012,4 +1093,4 @@ type MsgTypeInPayload = CheckDeviceSettings | StartPublishingDeviceStatus;
1012
1093
  */
1013
1094
  type InPayload = MsgTypeInPayload | MsgInPayload;
1014
1095
 
1015
- export { type BrandInfo, type ChangeDeviceState, type CheckDeviceSettings, 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, InMessageType, type InPayload, type Logger, type ModeObj, type MsgBasePayload, type MsgInPayload, type MsgOutPayload, type MsgPayload, type MsgTypeBasePayload, type MsgTypeInPayload, type MsgTypeOutPayload, type MsgTypePayload, MysaApiClient, type MysaApiClientEventTypes, type MysaApiClientOptions, MysaApiError, type MysaDeviceMode, type MysaSession, OutMessageType, type OutPayload, type SetPointChange, type StartPublishingDeviceStatus, type StateChange, type Status, type SupportedCaps, type TimestampedValue, UnauthenticatedError, VoidLogger };
1096
+ export { type BrandInfo, type ChangeDeviceState, type CheckDeviceSettings, 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, 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 MysaDeviceMode, type MysaFanSpeedMode, type MysaSession, OutMessageType, type OutPayload, type SetPointChange, type StartPublishingDeviceStatus, type StateChange, type Status, type SupportedCaps, type TimestampedValue, UnauthenticatedError, VoidLogger };
package/dist/index.js CHANGED
@@ -53,6 +53,21 @@ var MysaApiError = class extends Error {
53
53
  this.statusText = apiResponse.statusText;
54
54
  }
55
55
  };
56
+ var MqttPublishError = class extends Error {
57
+ /**
58
+ * Creates a new MqttPublishError instance.
59
+ *
60
+ * @param message - A human-readable description of the publish failure.
61
+ * @param attempts - The number of attempts that were made before giving up.
62
+ * @param original - The original error object thrown by the underlying MQTT library (optional).
63
+ */
64
+ constructor(message, attempts, original) {
65
+ super(message);
66
+ this.attempts = attempts;
67
+ this.original = original;
68
+ this.name = "MqttPublishError";
69
+ }
70
+ };
56
71
 
57
72
  // src/api/Logger.ts
58
73
  var VoidLogger = class {
@@ -203,8 +218,8 @@ var MysaApiClient = class {
203
218
  _logger;
204
219
  /** The fetcher function used by the client. */
205
220
  _fetcher;
206
- /** The MQTT connection used for real-time updates. */
207
- _mqttConnection;
221
+ /** A promise that resolves to the MQTT connection used for real-time updates. */
222
+ _mqttConnectionPromise;
208
223
  /** The device IDs that are currently being updated in real-time, mapped to their respective timeouts. */
209
224
  _realtimeDeviceIds = /* @__PURE__ */ new Map();
210
225
  /** The cached devices object, if any. */
@@ -324,7 +339,7 @@ var MysaApiClient = class {
324
339
  */
325
340
  async getDevices() {
326
341
  this._logger.debug(`Fetching devices...`);
327
- const session = await this.getFreshSession();
342
+ const session = await this._getFreshSession();
328
343
  const response = await this._fetcher(`${MysaApiBaseUrl}/devices`, {
329
344
  headers: {
330
345
  Authorization: `${session.getIdToken().getJwtToken()}`
@@ -359,7 +374,7 @@ var MysaApiClient = class {
359
374
  async getDeviceSerialNumber(deviceId) {
360
375
  var _a;
361
376
  this._logger.debug(`Fetching serial number for device ${deviceId}...`);
362
- const session = await this.getFreshSession();
377
+ const session = await this._getFreshSession();
363
378
  const credentialsProvider = fromCognitoIdentityPool({
364
379
  clientConfig: {
365
380
  region: AwsRegion
@@ -396,7 +411,7 @@ var MysaApiClient = class {
396
411
  */
397
412
  async getDeviceFirmwares() {
398
413
  this._logger.debug(`Fetching device firmwares...`);
399
- const session = await this.getFreshSession();
414
+ const session = await this._getFreshSession();
400
415
  const response = await this._fetcher(`${MysaApiBaseUrl}/devices/firmware`, {
401
416
  headers: {
402
417
  Authorization: `${session.getIdToken().getJwtToken()}`
@@ -416,7 +431,7 @@ var MysaApiClient = class {
416
431
  */
417
432
  async getDeviceStates() {
418
433
  this._logger.debug(`Fetching device states...`);
419
- const session = await this.getFreshSession();
434
+ const session = await this._getFreshSession();
420
435
  const response = await this._fetcher(`${MysaApiBaseUrl}/devices/state`, {
421
436
  headers: {
422
437
  Authorization: `${session.getIdToken().getJwtToken()}`
@@ -444,31 +459,39 @@ var MysaApiClient = class {
444
459
  *
445
460
  * // Set temperature and mode
446
461
  * await client.setDeviceState('device123', 20, 'heat');
462
+ *
463
+ * // Set fan speed
464
+ * await client.setDeviceState('device123', undefined, undefined, 'auto');
447
465
  * ```
448
466
  *
449
467
  * @param deviceId - The ID of the device to control.
450
468
  * @param setPoint - The target temperature set point (optional).
451
- * @param mode - The operating mode to set ('off', 'heat', or undefined to leave unchanged).
469
+ * @param mode - The operating mode to set (one of MysaDeviceMode values, or undefined to leave unchanged).
470
+ * @param fanSpeed - The fan speed mode to set ('low', 'medium', 'high', 'max', 'auto', or undefined to leave
471
+ * unchanged).
452
472
  * @throws {@link UnauthenticatedError} When the user is not authenticated.
453
473
  * @throws {@link Error} When MQTT connection or command sending fails.
454
474
  */
455
- async setDeviceState(deviceId, setPoint, mode) {
475
+ async setDeviceState(deviceId, setPoint, mode, fanSpeed) {
476
+ var _a;
456
477
  this._logger.debug(`Setting device state for '${deviceId}'`);
457
478
  if (!this._cachedDevices) {
458
479
  this._cachedDevices = await this.getDevices();
459
480
  }
460
481
  const device = this._cachedDevices.DevicesObj[deviceId];
461
482
  this._logger.debug(`Initializing MQTT connection...`);
462
- const mqttConnection = await this.getMqttConnection();
483
+ const mqttConnection = await this._getMqttConnection();
463
484
  const now = dayjs();
464
485
  this._logger.debug(`Sending request to set device state for '${deviceId}'...`);
486
+ const modeMap = { off: 1, auto: 2, heat: 3, cool: 4, fan_only: 5, dry: 6 };
487
+ const fanSpeedMap = { auto: 1, low: 3, medium: 5, high: 7, max: 8 };
465
488
  const payload = serializeMqttPayload({
466
489
  msg: 44 /* CHANGE_DEVICE_STATE */,
467
490
  id: now.valueOf(),
468
491
  time: now.unix(),
469
492
  ver: "1.0",
470
493
  src: {
471
- ref: this.session.username,
494
+ ref: ((_a = this.session) == null ? void 0 : _a.username) ?? "",
472
495
  type: 100
473
496
  },
474
497
  dest: {
@@ -478,17 +501,24 @@ var MysaApiClient = class {
478
501
  resp: 2,
479
502
  body: {
480
503
  ver: 1,
481
- type: device.Model.startsWith("BB-V1") ? 1 : device.Model.startsWith("BB-V2") ? device.Model.endsWith("-L") ? 5 : 4 : 0,
504
+ type: device.Model.startsWith("BB-V1") ? 1 : device.Model.startsWith("AC-V1") ? 2 : device.Model.startsWith("BB-V2") ? device.Model.endsWith("-L") ? 5 : 4 : 0,
482
505
  cmd: [
483
506
  {
484
507
  tm: -1,
485
508
  sp: setPoint,
486
- md: mode === "off" ? 1 : mode === "heat" ? 3 : void 0
509
+ md: mode ? modeMap[mode] : void 0,
510
+ fn: fanSpeed ? fanSpeedMap[fanSpeed] : void 0
487
511
  }
488
512
  ]
489
513
  }
490
514
  });
491
- await mqttConnection.publish(`/v1/dev/${deviceId}/in`, payload, mqtt.QoS.AtLeastOnce);
515
+ try {
516
+ await this._publishWithRetry(mqttConnection, `/v1/dev/${deviceId}/in`, payload, mqtt.QoS.AtLeastOnce);
517
+ this._logger.debug(`Device state publish succeeded for '${deviceId}'`);
518
+ } catch (error) {
519
+ this._logger.error(`Failed to set device state for '${deviceId}'`, error);
520
+ throw error;
521
+ }
492
522
  }
493
523
  /**
494
524
  * Starts receiving real-time updates for the specified device.
@@ -518,10 +548,10 @@ var MysaApiClient = class {
518
548
  return;
519
549
  }
520
550
  this._logger.debug(`Initializing MQTT connection...`);
521
- const mqttConnection = await this.getMqttConnection();
551
+ const mqttConnection = await this._getMqttConnection();
522
552
  this._logger.debug(`Subscribing to MQTT topic '/v1/dev/${deviceId}/out'...`);
523
553
  await mqttConnection.subscribe(`/v1/dev/${deviceId}/out`, mqtt.QoS.AtLeastOnce, (_, payload2) => {
524
- this.processMqttMessage(payload2);
554
+ this._processMqttMessage(payload2);
525
555
  });
526
556
  this._logger.debug(`Sending request to start publishing device status for '${deviceId}'...`);
527
557
  const payload = serializeMqttPayload({
@@ -530,7 +560,7 @@ var MysaApiClient = class {
530
560
  Timestamp: dayjs().unix(),
531
561
  Timeout: RealtimeKeepAliveInterval.asSeconds()
532
562
  });
533
- await mqttConnection.publish(`/v1/dev/${deviceId}/in`, payload, mqtt.QoS.AtLeastOnce);
563
+ await this._publishWithRetry(mqttConnection, `/v1/dev/${deviceId}/in`, payload, mqtt.QoS.AtLeastOnce);
534
564
  const timer = setInterval(async () => {
535
565
  this._logger.debug(`Sending request to keep-alive publishing device status for '${deviceId}'...`);
536
566
  const payload2 = serializeMqttPayload({
@@ -539,7 +569,7 @@ var MysaApiClient = class {
539
569
  Timestamp: dayjs().unix(),
540
570
  Timeout: RealtimeKeepAliveInterval.asSeconds()
541
571
  });
542
- await mqttConnection.publish(`/v1/dev/${deviceId}/in`, payload2, mqtt.QoS.AtLeastOnce);
572
+ await this._publishWithRetry(mqttConnection, `/v1/dev/${deviceId}/in`, payload2, mqtt.QoS.AtLeastOnce);
543
573
  }, RealtimeKeepAliveInterval.subtract(10, "seconds").asMilliseconds());
544
574
  this._realtimeDeviceIds.set(deviceId, timer);
545
575
  }
@@ -560,7 +590,7 @@ var MysaApiClient = class {
560
590
  return;
561
591
  }
562
592
  this._logger.debug(`Initializing MQTT connection...`);
563
- const mqttConnection = await this.getMqttConnection();
593
+ const mqttConnection = await this._getMqttConnection();
564
594
  this._logger.debug(`Unsubscribing to MQTT topic '/v1/dev/${deviceId}/out'...`);
565
595
  await mqttConnection.unsubscribe(`/v1/dev/${deviceId}/out`);
566
596
  clearInterval(timer);
@@ -575,7 +605,7 @@ var MysaApiClient = class {
575
605
  * @returns A promise that resolves to a valid CognitoUserSession.
576
606
  * @throws {@link UnauthenticatedError} When no session exists or refresh fails.
577
607
  */
578
- async getFreshSession() {
608
+ async _getFreshSession() {
579
609
  if (!this._cognitoUser || !this._cognitoUserSession) {
580
610
  throw new UnauthenticatedError("An attempt was made to access a resource without a valid session.");
581
611
  }
@@ -607,11 +637,98 @@ var MysaApiClient = class {
607
637
  * @returns A promise that resolves to an active MQTT connection.
608
638
  * @throws {@link Error} When connection establishment fails.
609
639
  */
610
- async getMqttConnection() {
611
- if (this._mqttConnection) {
612
- return this._mqttConnection;
640
+ _getMqttConnection() {
641
+ if (!this._mqttConnectionPromise) {
642
+ this._mqttConnectionPromise = this._createMqttConnection().catch((err) => {
643
+ this._mqttConnectionPromise = void 0;
644
+ throw err;
645
+ });
646
+ }
647
+ return this._mqttConnectionPromise;
648
+ }
649
+ /**
650
+ * Determines whether an MQTT-related error is considered transient and worth retrying.
651
+ *
652
+ * Transient errors include timeouts, cancelled operations due to clean sessions, temporary connectivity loss, and
653
+ * other recoverable network issues. Fatal errors (auth, permission, configuration) should not be retried at this
654
+ * layer.
655
+ *
656
+ * @param err - The error object thrown by the underlying MQTT operation.
657
+ * @returns True if the error appears transient and a retry should be attempted; false otherwise.
658
+ */
659
+ _isTransientMqttError(err) {
660
+ if (!err || typeof err !== "object") {
661
+ return false;
613
662
  }
614
- const session = await this.getFreshSession();
663
+ const anyErr = err;
664
+ const code = anyErr.error_code || anyErr.error_name || anyErr.error;
665
+ const msg = (anyErr.message || anyErr.error || "").toString();
666
+ const transientMarkers = [
667
+ "AWS_ERROR_MQTT_TIMEOUT",
668
+ "AWS_ERROR_MQTT_NO_CONNECTION",
669
+ "Time limit between request and response",
670
+ "timeout"
671
+ ];
672
+ return transientMarkers.some((m) => code && String(code).includes(m) || msg.includes(m));
673
+ }
674
+ /**
675
+ * Publishes an MQTT message with exponential backoff retries for transient failures.
676
+ *
677
+ * Retries occur for errors classified by `_isTransientMqttError`. Between attempts the delay grows exponentially with
678
+ * jitter to avoid thundering herds after broker recovery. If the connection is not currently marked as connected, a
679
+ * reconnect is attempted; if that fails, the connection is rebuilt (fresh credentials) before the next retry.
680
+ *
681
+ * On final failure (after maxAttempts) a {@link MqttPublishError} is thrown including the number of attempts and
682
+ * original error for higher-level handling.
683
+ *
684
+ * @remarks
685
+ * Retry options fields:
686
+ *
687
+ * - MaxAttempts: Maximum number of publish attempts before failing (default: 5).
688
+ * - BaseDelayMs: Base delay in milliseconds used for exponential backoff calculation (default: 500).
689
+ *
690
+ * @param connection - The active MQTT client connection used to send the publish.
691
+ * @param topic - The MQTT topic to publish to.
692
+ * @param payload - The serialized payload (binary buffer or Uint8Array).
693
+ * @param qos - The desired MQTT QoS level for the publish.
694
+ * @param opts - Retry options (defaults: maxAttempts=5, baseDelayMs=500).
695
+ * @returns A promise that resolves when the publish succeeds, or rejects with {@link MqttPublishError}.
696
+ */
697
+ async _publishWithRetry(connection, topic, payload, qos, opts = {}) {
698
+ const maxAttempts = opts.maxAttempts ?? 5;
699
+ const baseDelayMs = opts.baseDelayMs ?? 500;
700
+ let attempt = 0;
701
+ while (true) {
702
+ attempt++;
703
+ try {
704
+ await connection.publish(topic, payload, qos);
705
+ return;
706
+ } catch (err) {
707
+ const isTransient = this._isTransientMqttError(err);
708
+ if (!isTransient || attempt >= maxAttempts) {
709
+ throw new MqttPublishError(`MQTT publish failed after ${attempt} attempts`, attempt, err);
710
+ }
711
+ const JITTER_MIN_FACTOR = 0.75;
712
+ const JITTER_RANGE = 0.5;
713
+ const delay = baseDelayMs * Math.pow(2, attempt - 1) * (JITTER_MIN_FACTOR + Math.random() * JITTER_RANGE);
714
+ this._logger.warn(
715
+ `Transient MQTT publish error on '${topic}' (attempt ${attempt}/${maxAttempts}). Retrying in ${Math.round(
716
+ delay
717
+ )}ms`
718
+ );
719
+ await new Promise((r) => setTimeout(r, delay));
720
+ }
721
+ }
722
+ }
723
+ /**
724
+ * Creates a new MQTT connection using AWS IoT WebSocket connections with Cognito credentials.
725
+ *
726
+ * @returns A promise that resolves to an active MQTT connection.
727
+ * @throws {@link Error} When connection establishment fails.
728
+ */
729
+ async _createMqttConnection() {
730
+ var _a;
731
+ const session = await this._getFreshSession();
615
732
  const credentialsProvider = fromCognitoIdentityPool({
616
733
  clientConfig: {
617
734
  region: AwsRegion
@@ -623,16 +740,49 @@ var MysaApiClient = class {
623
740
  logger: this._logger
624
741
  });
625
742
  const credentials = await credentialsProvider();
626
- const builder = iot.AwsIotMqttConnectionConfigBuilder.new_with_websockets().with_credentials(AwsRegion, credentials.accessKeyId, credentials.secretAccessKey, credentials.sessionToken).with_endpoint(MqttEndpoint).with_client_id(`mysa-js-sdk-${dayjs().unix()}`).with_clean_session(true).with_keep_alive_seconds(30).with_ping_timeout_ms(3e3).with_protocol_operation_timeout_ms(6e4);
743
+ const stableClientId = `mysa-js-sdk-${((_a = this.session) == null ? void 0 : _a.username) ?? ""}`;
744
+ const builder = iot.AwsIotMqttConnectionConfigBuilder.new_with_websockets().with_credentials(AwsRegion, credentials.accessKeyId, credentials.secretAccessKey, credentials.sessionToken).with_endpoint(MqttEndpoint).with_client_id(stableClientId).with_clean_session(false).with_keep_alive_seconds(30).with_ping_timeout_ms(3e3).with_protocol_operation_timeout_ms(6e4);
627
745
  const config = builder.build();
628
746
  const client = new mqtt.MqttClient();
629
- this._mqttConnection = client.new_connection(config);
630
- this._mqttConnection.on("closed", () => {
747
+ const connection = client.new_connection(config);
748
+ connection.on("connect", () => {
749
+ this._logger.debug("MQTT connect");
750
+ });
751
+ connection.on("connection_success", () => {
752
+ this._logger.debug("MQTT connection_success");
753
+ });
754
+ connection.on("connection_failure", (e) => {
755
+ this._logger.error("MQTT connection_failure", e);
756
+ });
757
+ connection.on("interrupt", (e) => {
758
+ this._logger.warn("MQTT interrupt", e);
759
+ });
760
+ connection.on("resume", async (returnCode, sessionPresent) => {
761
+ this._logger.info(`MQTT resume returnCode=${returnCode} sessionPresent=${sessionPresent}`);
762
+ if (!sessionPresent) {
763
+ this._logger.info("No session present, re-subscribing each device");
764
+ try {
765
+ for (const deviceId of Array.from(this._realtimeDeviceIds.keys())) {
766
+ const topic = `/v1/dev/${deviceId}/out`;
767
+ this._logger.debug(`Re-subscribing to ${topic}`);
768
+ await connection.subscribe(topic, mqtt.QoS.AtLeastOnce, (_topic, payload) => {
769
+ this._processMqttMessage(payload);
770
+ });
771
+ }
772
+ } catch (err) {
773
+ this._logger.error("Failed to re-subscribe after resume", err);
774
+ }
775
+ }
776
+ });
777
+ connection.on("error", (e) => {
778
+ this._logger.error("MQTT error", e);
779
+ });
780
+ connection.on("closed", () => {
631
781
  this._logger.info("MQTT connection closed");
632
- this._mqttConnection = void 0;
782
+ this._mqttConnectionPromise = void 0;
633
783
  });
634
- await this._mqttConnection.connect();
635
- return this._mqttConnection;
784
+ await connection.connect();
785
+ return connection;
636
786
  }
637
787
  /**
638
788
  * Processes incoming MQTT messages and emits appropriate events.
@@ -643,7 +793,7 @@ var MysaApiClient = class {
643
793
  *
644
794
  * @param payload - The raw MQTT message payload to process.
645
795
  */
646
- processMqttMessage(payload) {
796
+ _processMqttMessage(payload) {
647
797
  try {
648
798
  const parsedPayload = parseMqttPayload(payload);
649
799
  this.emitter.emit("rawRealtimeMessageReceived", parsedPayload);
@@ -677,13 +827,30 @@ var MysaApiClient = class {
677
827
  dutyCycle: parsedPayload.body.dtyCycle
678
828
  });
679
829
  break;
680
- case 44 /* DEVICE_STATE_CHANGE */:
830
+ case 44 /* DEVICE_STATE_CHANGE */: {
831
+ const modeMap = {
832
+ 1: "off",
833
+ 2: "auto",
834
+ 3: "heat",
835
+ 4: "cool",
836
+ 5: "fan_only",
837
+ 6: "dry"
838
+ };
839
+ const fanSpeedMap = {
840
+ 1: "auto",
841
+ 3: "low",
842
+ 5: "medium",
843
+ 7: "high",
844
+ 8: "max"
845
+ };
681
846
  this.emitter.emit("stateChanged", {
682
847
  deviceId: parsedPayload.src.ref,
683
- mode: parsedPayload.body.state.md === 1 ? "off" : parsedPayload.body.state.md === 3 ? "heat" : void 0,
684
- setPoint: parsedPayload.body.state.sp
848
+ mode: parsedPayload.body.state.md ? modeMap[parsedPayload.body.state.md] : void 0,
849
+ setPoint: parsedPayload.body.state.sp,
850
+ fanSpeed: parsedPayload.body.state.fn !== void 0 ? fanSpeedMap[parsedPayload.body.state.fn] : void 0
685
851
  });
686
852
  break;
853
+ }
687
854
  }
688
855
  }
689
856
  } catch (error) {
@@ -693,6 +860,7 @@ var MysaApiClient = class {
693
860
  };
694
861
  export {
695
862
  InMessageType,
863
+ MqttPublishError,
696
864
  MysaApiClient,
697
865
  MysaApiError,
698
866
  OutMessageType,