mysa-js-sdk 3.0.0 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 */
@@ -171,8 +187,8 @@ declare class EventEmitter<TEvents extends Record<string, any>> implements NodeJ
171
187
  removeAllListeners<TEventName extends keyof TEvents & string>(eventName?: TEventName | undefined): this;
172
188
  setMaxListeners(n: number): this;
173
189
  getMaxListeners(): number;
174
- listeners<TEventName extends keyof TEvents & string>(eventName: TEventName): Function[];
175
- rawListeners<TEventName extends keyof TEvents & string>(eventName: TEventName): Function[];
190
+ listeners<TEventName extends keyof TEvents & string>(eventName: TEventName): ((...args: any[]) => void)[];
191
+ rawListeners<TEventName extends keyof TEvents & string>(eventName: TEventName): ((...args: any[]) => void)[];
176
192
  listenerCount<TEventName extends keyof TEvents & string>(eventName: TEventName, listener?: (...args: TEvents[TEventName]) => void): number;
177
193
  prependListener<TEventName extends keyof TEvents & string>(eventName: TEventName, listener: (...args: TEvents[TEventName]) => void): this;
178
194
  prependOnceListener<TEventName extends keyof TEvents & string>(eventName: TEventName, listener: (...args: TEvents[TEventName]) => void): this;
@@ -209,12 +225,20 @@ interface SupportedCaps {
209
225
  [modeId: string]: {
210
226
  /** Array of available temperature setpoints for this mode */
211
227
  temperatures: number[];
228
+ /** Array of supported raw fan speed (fn) values for this mode, if any */
229
+ fanSpeeds?: number[];
212
230
  };
213
231
  };
214
232
  /** Version string of the capability definition */
215
233
  version: string;
216
234
  /** Array of supported remote control key codes */
217
235
  keys: number[];
236
+ /**
237
+ * Optional device-specific fan-speed `fn` values, ordered by canonical fan speed (`[auto, low, medium, high, max]`).
238
+ * E.g. `[1, 2, 4, 6]` for CodeNum=1117 devices means auto=1, low=2, medium=4, high=6. When absent, a legacy universal
239
+ * mapping is assumed.
240
+ */
241
+ fanSpeeds?: number[];
218
242
  }
219
243
  /**
220
244
  * Device operating mode information.
@@ -568,12 +592,29 @@ interface DeviceV2Status extends MsgPayload<OutMessageType.DEVICE_V2_STATUS> {
568
592
  body: {
569
593
  /** Ambient temperature reading from the device sensor */
570
594
  ambTemp: number;
571
- /** Current duty cycle of the heating element, as a fraction between 0.0 and 1.0 */
572
- dtyCycle: number;
595
+ /**
596
+ * Current duty cycle of the heating element, as a fraction between 0.0 and 1.0. Reported by baseboard V2 devices;
597
+ * absent on in-floor heating thermostats (INF-V1-0), which report {@link heatStat} instead.
598
+ */
599
+ dtyCycle?: number;
573
600
  /** Relative humidity percentage reading from the device sensor */
574
601
  hum: number;
575
602
  /** Current temperature setpoint setting */
576
603
  stpt: number;
604
+ /**
605
+ * Binary heating-relay state (0 = off, 1 = energized) reported by in-floor heating thermostats (INF-V1-0) in place
606
+ * of {@link dtyCycle}. Absent on baseboard V2 devices.
607
+ */
608
+ heatStat?: number;
609
+ /** Floor-probe temperature reading (°C) from in-floor heating thermostats. Absent on baseboard V2 devices. */
610
+ flrSnsrTemp?: number;
611
+ /**
612
+ * Which sensor the in-floor thermostat regulates against (3 = floor probe, 5 = ambient air). Absent on baseboard V2
613
+ * devices.
614
+ */
615
+ trackedSnsr?: number;
616
+ /** Line voltage (V) reported by in-floor heating thermostats. Absent on baseboard V2 devices. */
617
+ lineVtg?: number;
577
618
  };
578
619
  }
579
620
 
@@ -805,8 +846,21 @@ declare class MysaApiClient {
805
846
  private _mqttInterrupts;
806
847
  /** Whether a forced MQTT reset is currently in progress (guards against re-entrancy). */
807
848
  private _mqttResetInProgress;
849
+ /** Monotonic id of the current MQTT connection; used to ignore events from discarded connections. */
850
+ private _mqttGeneration;
851
+ /** Consecutive forced resets without an intervening stable period; drives reset backoff. */
852
+ private _mqttConsecutiveResets;
853
+ /** Timestamp (ms) of the most recent successful MQTT (re)connect, for interrupt dwell-time diagnostics. */
854
+ private _mqttLastConnectionSuccessAt?;
855
+ /** Timer that clears the consecutive-reset counter once a connection stays healthy. */
856
+ private _mqttStabilityTimer?;
808
857
  /** The device IDs that are currently being updated in real-time, mapped to their respective timeouts. */
809
858
  private _realtimeDeviceIds;
859
+ /**
860
+ * Raw topic filters registered via {@link startRawTopicCapture}, mapped to their message handlers. Re-subscribed on
861
+ * every reconnect so a debug capture survives connection resets.
862
+ */
863
+ private _rawTopicCaptures;
810
864
  /** The cached devices object, if any. */
811
865
  private _cachedDevices?;
812
866
  /**
@@ -945,6 +999,7 @@ declare class MysaApiClient {
945
999
  * unchanged).
946
1000
  * @throws {@link UnauthenticatedError} When the client cannot authenticate with its credentials.
947
1001
  * @throws {@link UnknownDeviceError} When the device id does not match any device on the account.
1002
+ * @throws {@link UnsupportedFanSpeedError} When the requested fan speed is not supported by the device.
948
1003
  * @throws {@link Error} When MQTT connection or command sending fails.
949
1004
  */
950
1005
  setDeviceState(deviceId: string, setPoint?: number, mode?: MysaDeviceMode, fanSpeed?: MysaFanSpeedMode): Promise<void>;
@@ -980,6 +1035,28 @@ declare class MysaApiClient {
980
1035
  * @throws {@link Error} When MQTT unsubscription fails.
981
1036
  */
982
1037
  stopRealtimeUpdates(deviceId: string): Promise<void>;
1038
+ /**
1039
+ * Subscribes to raw MQTT topic filters and relays every message verbatim.
1040
+ *
1041
+ * Unlike {@link startRealtimeUpdates}, this performs no parsing, emits no typed events and sends no "start publishing"
1042
+ * request to the device — it simply forwards the full message topic and the decoded UTF-8 payload of everything that
1043
+ * arrives on the given filters. It exists to reverse-engineer device families the SDK does not model yet, most
1044
+ * notably the AWS IoT Device Shadow protocol used by the central-HVAC ST-V1 thermostats, where both the topic (which
1045
+ * shadow, and `accepted`/`rejected`/`delta`/`documents`) and the raw JSON body carry the information a new
1046
+ * implementation needs.
1047
+ *
1048
+ * The capture is passive: the device only publishes to its shadow topics when something drives a change (the Mysa
1049
+ * mobile app, a schedule, or the device itself), so exercise the thermostat while a capture is running.
1050
+ *
1051
+ * Registered filters are re-subscribed automatically after a reconnect.
1052
+ *
1053
+ * @param topicFilters - MQTT topic filters to subscribe to. Wildcards (`+`, `#`) are allowed, subject to the AWS IoT
1054
+ * policy attached to the account's Cognito identity — a filter the policy forbids resolves with a non-zero
1055
+ * `error_code`, which is logged rather than thrown so the remaining filters still subscribe.
1056
+ * @param handler - Invoked with the full message topic and the decoded UTF-8 payload for every message received.
1057
+ * @throws {@link Error} When the MQTT connection cannot be established.
1058
+ */
1059
+ startRawTopicCapture(topicFilters: string[], handler: (topic: string, payload: string) => void): Promise<void>;
983
1060
  /**
984
1061
  * Ensures a valid, non-expired session is available.
985
1062
  *
@@ -1053,6 +1130,36 @@ declare class MysaApiClient {
1053
1130
  * @throws {@link Error} When connection establishment fails.
1054
1131
  */
1055
1132
  private _createMqttConnection;
1133
+ /**
1134
+ * Re-subscribes every device currently receiving real-time updates on the given connection.
1135
+ *
1136
+ * Safe to call on every (re)connect: aws-crt replaces the per-topic callback on a duplicate subscribe, so this never
1137
+ * registers a message handler more than once.
1138
+ *
1139
+ * @param connection - The connection to (re-)establish subscriptions on.
1140
+ */
1141
+ private _resubscribeAll;
1142
+ /**
1143
+ * Arms (or re-arms) the stability timer. When a connection stays interrupt-free for {@link MqttStabilityWindow}, the
1144
+ * consecutive-reset backoff counter is cleared so a future isolated storm still gets a fast first reset.
1145
+ *
1146
+ * @param generation - The connection generation that armed the timer; the callback is a no-op if the connection has
1147
+ * since been replaced.
1148
+ */
1149
+ private _armMqttStabilityTimer;
1150
+ /** Clears the stability timer, if armed. */
1151
+ private _clearMqttStabilityTimer;
1152
+ /**
1153
+ * Forcefully tears down the current MQTT connection and rebuilds it with a fresh client id and fresh credentials,
1154
+ * escaping interrupt storms that a plain native reconnect cannot.
1155
+ *
1156
+ * Repeatable and self-contained: only one reset runs at a time, consecutive resets without an intervening stable
1157
+ * period back off exponentially (up to {@link MqttResetMaxDelay}), and it never rejects — so it is safe to invoke
1158
+ * fire-and-forget from an event handler.
1159
+ *
1160
+ * @param reason - Human-readable reason for the reset, used in logs.
1161
+ */
1162
+ private _resetMqttConnection;
1056
1163
  /**
1057
1164
  * Processes incoming MQTT messages and emits appropriate events.
1058
1165
  *
@@ -1175,4 +1282,4 @@ type MsgTypeInPayload = CheckDeviceSettings | StartPublishingDeviceStatus;
1175
1282
  */
1176
1283
  type InPayload = MsgTypeInPayload | MsgInPayload;
1177
1284
 
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 };
1285
+ 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