mysa-js-sdk 2.1.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,21 +1,41 @@
1
1
  # mysa-js-sdk
2
2
 
3
+ ## 3.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - [#199](https://github.com/bourquep/mysa2mqtt/pull/199) [`b384d79`](https://github.com/bourquep/mysa2mqtt/commit/b384d7950d757bc85af7580eaa26435190b47364) Thanks [@bourquep](https://github.com/bourquep)! - The client now takes Mysa account credentials instead of a session object, and re-authenticates on its own when its refresh token expires or is revoked, so long-running processes no longer die after about a month. `new MysaApiClient(session, options)` becomes `new MysaApiClient({ username, password }, options)`, `login()` no longer takes arguments and is optional (the client authenticates on demand), and the `MysaSession` type, the `session` and `isAuthenticated` properties, and the `sessionChanged` event are gone — there is no longer any session state to persist.
8
+
9
+ ### Patch Changes
10
+
11
+ - [#201](https://github.com/bourquep/mysa2mqtt/pull/201) [`10ee91c`](https://github.com/bourquep/mysa2mqtt/commit/10ee91c1981b77ef1e9f76abd3d24ba6d9a19d77) Thanks [@bourquep](https://github.com/bourquep)! - A Mysa login rejected for a bad password now reports that a `$` in it is expanded by shells and by Docker Compose (in both `environment:` entries and default-format `env_file:` files, where it must be written `$$`, but not in an `env_file:` declared with `format: raw`), and that an unquoted `#` truncates a `.env` value, instead of surfacing a bare `Incorrect username or password.` stack trace. An unrecognized account gets username-specific guidance instead, since none of the password escaping rules apply to it. Transport and Cognito service failures keep propagating without that guidance, since `UnauthenticatedError` now carries the underlying failure as its `cause`. Debug logging also reports the length of the password that was actually received -- never the password or the account it belongs to -- so a mangled value can be spotted at a glance.
12
+
13
+ - [#190](https://github.com/bourquep/mysa2mqtt/pull/190) [`7169263`](https://github.com/bourquep/mysa2mqtt/commit/7169263fcb4b2aeb51c7eae4c112972c3e2fdb08) Thanks [@vavallee](https://github.com/vavallee)! - Payload parse failures no longer write to console.error, which bypassed the configurable SDK logger and could leak payload contents. The error is rethrown with the original failure attached as cause and logged by the message handler through the SDK logger.
14
+
15
+ - [#191](https://github.com/bourquep/mysa2mqtt/pull/191) [`527ef25`](https://github.com/bourquep/mysa2mqtt/commit/527ef25886aad766a2fd71fc92002d8de126b364) Thanks [@vavallee](https://github.com/vavallee)! - setDeviceState now throws a descriptive UnknownDeviceError when the device id does not match any device on the account, instead of failing with a raw TypeError on an undefined dereference.
16
+
17
+ - [#200](https://github.com/bourquep/mysa2mqtt/pull/200) [`61dc2a2`](https://github.com/bourquep/mysa2mqtt/commit/61dc2a2b395397e9e5245098bfd31b49b501fd7d) Thanks [@bourquep](https://github.com/bourquep)! - V2 thermostats can now report power. These devices have no current sensor and only report the duty cycle of their heating relay, which is why their **Current power** sensor has always been unavailable. Set the new `--heater-watts` option (`M2M_HEATER_WATTS`) to the rated wattage of the heaters each thermostat controls — for example `M2M_HEATER_WATTS="Kitchen=1500,<device-id>=750"`, matching devices by name or id — and power is estimated as `duty cycle × rated wattage`. V1 thermostats measure their own current and continue to work with no configuration.
18
+
19
+ The power sensor is now only created for devices that can actually report a value. If you have AC devices, or V2 thermostats for which you have not configured a wattage, their **Current power** entity is removed from Home Assistant on startup; it only ever showed as unavailable.
20
+
21
+ `mqtt2ha` gains a `Discoverable.removeConfig()` method, which clears an entity's retained discovery topic so Home Assistant drops the entity. This is what makes the removal above take effect: because `writeConfig()` retains its payload, an entity published by an earlier run persists until its topic is explicitly cleared.
22
+
23
+ ## 2.1.2
24
+
25
+ ### Patch Changes
26
+
27
+ - [#182](https://github.com/bourquep/mysa2mqtt/pull/182) [`21991c0`](https://github.com/bourquep/mysa2mqtt/commit/21991c0731cb888dc69d15b3b0dc164aee4992f7) Thanks [@vavallee](https://github.com/vavallee)! - Fixed `startRealtimeUpdates` hanging forever when the Mysa broker silently ignores a `START_PUBLISHING_DEVICE_STATUS` publish. QoS1 publishes now time out after 30 seconds if no acknowledgement arrives (observed in production since July 2026: the broker drops these publishes without a PUBACK, and the client's 60s protocol operation timeout never fires). A failed status-publishing request no longer aborts realtime startup and a failed keep-alive no longer surfaces as an unhandled rejection — both are logged as warnings, since the device's autonomous periodic status reports (message type 30) keep flowing over the subscription either way.
28
+
3
29
  ## 2.1.1
4
30
 
5
31
  ### Patch Changes
6
32
 
7
- - [#149](https://github.com/bourquep/mysa2mqtt/pull/149)
8
- [`89e2950`](https://github.com/bourquep/mysa2mqtt/commit/89e2950c4874db14ea9b682380c63984aaf7a9f4) Thanks
9
- [@bourquep](https://github.com/bourquep)! - Moved development into the
10
- [mysa2mqtt monorepo](https://github.com/bourquep/mysa2mqtt).
33
+ - [#149](https://github.com/bourquep/mysa2mqtt/pull/149) [`89e2950`](https://github.com/bourquep/mysa2mqtt/commit/89e2950c4874db14ea9b682380c63984aaf7a9f4) Thanks [@bourquep](https://github.com/bourquep)! - Moved development into the [mysa2mqtt monorepo](https://github.com/bourquep/mysa2mqtt).
11
34
 
12
- There are no functional changes in this release. The package's repository and homepage links now point at the
13
- monorepo, and issues for all three packages are tracked at https://github.com/bourquep/mysa2mqtt/issues.
35
+ There are no functional changes in this release. The package's repository and homepage links now point at the monorepo, and issues for all three packages are tracked at https://github.com/bourquep/mysa2mqtt/issues.
14
36
 
15
37
  ## Releases prior to 2.1.0
16
38
 
17
- This package previously lived in its own repository and used semantic-release, which published its release notes to
18
- GitHub Releases rather than to a changelog file.
39
+ This package previously lived in its own repository and used semantic-release, which published its release notes to GitHub Releases rather than to a changelog file.
19
40
 
20
- See the [release history of the archived repository](https://github.com/bourquep/mysa-js-sdk/releases) for notes on
21
- versions up to and including 2.1.0.
41
+ See the [release history of the archived repository](https://github.com/bourquep/mysa-js-sdk/releases) for notes on versions up to and including 2.1.0.
package/README.md CHANGED
@@ -66,17 +66,20 @@ The Mysa SDK provides a simple interface to interact with Mysa smart thermostats
66
66
  ```typescript
67
67
  import { MysaApiClient } from 'mysa-js-sdk';
68
68
 
69
- const client = new MysaApiClient();
70
-
71
- // Login with email and password
72
- await client.login('your-email@example.com', 'your-password');
69
+ // The client holds your credentials and authenticates on demand
70
+ const client = new MysaApiClient({
71
+ username: 'your-email@example.com',
72
+ password: 'your-password'
73
+ });
73
74
 
74
- // Check if authenticated
75
- if (client.isAuthenticated) {
76
- console.log('Successfully authenticated!');
77
- }
75
+ // Optional: log in eagerly to fail fast on invalid credentials
76
+ await client.login();
78
77
  ```
79
78
 
79
+ The client manages its session on its own: it refreshes the session when it needs to, and logs back in automatically
80
+ when the refresh token has itself expired or been revoked. There is nothing to persist, and no need to re-create the
81
+ client after a long run.
82
+
80
83
  ### Retrieving Thermostat Data
81
84
 
82
85
  Once authenticated, you can access your thermostat data:
@@ -139,10 +142,9 @@ The SDK provides specific error types to handle API errors:
139
142
  ```typescript
140
143
  import { MysaApiClient, MysaApiError, UnauthenticatedError } from 'mysa-js-sdk';
141
144
 
142
- const client = new MysaApiClient();
145
+ const client = new MysaApiClient({ username: 'user@example.com', password: 'password' });
143
146
 
144
147
  try {
145
- await client.login('user@example.com', 'password');
146
148
  const devices = await client.getDevices();
147
149
  } catch (error) {
148
150
  if (error instanceof UnauthenticatedError) {
@@ -170,31 +172,13 @@ const logger = pino({
170
172
  });
171
173
 
172
174
  // Configure client with options
173
- const client = new MysaApiClient(undefined, {
174
- logger: logger,
175
- fetcher: fetch // Custom fetch implementation if needed
176
- });
177
-
178
- // Or restore from a saved session
179
- const savedSession = {
180
- username: 'user@example.com',
181
- idToken: 'eyJ...',
182
- accessToken: 'eyJ...',
183
- refreshToken: 'abc123...'
184
- };
185
-
186
- const clientWithSession = new MysaApiClient(savedSession, { logger });
187
-
188
- // Listen for session changes to persist them
189
- client.emitter.on('sessionChanged', (newSession) => {
190
- if (newSession) {
191
- // Save session to storage (file, database, etc.)
192
- localStorage.setItem('mysaSession', JSON.stringify(newSession));
193
- } else {
194
- // Session expired or logged out
195
- localStorage.removeItem('mysaSession');
175
+ const client = new MysaApiClient(
176
+ { username: 'user@example.com', password: 'password' },
177
+ {
178
+ logger: logger,
179
+ fetcher: fetch // Custom fetch implementation if needed
196
180
  }
197
- });
181
+ );
198
182
  ```
199
183
 
200
184
  ### Reference documentation
package/dist/index.cjs CHANGED
@@ -59,20 +59,31 @@ __export(index_exports, {
59
59
  MysaApiError: () => MysaApiError,
60
60
  OutMessageType: () => OutMessageType,
61
61
  UnauthenticatedError: () => UnauthenticatedError,
62
+ UnknownDeviceError: () => UnknownDeviceError,
62
63
  VoidLogger: () => VoidLogger
63
64
  });
64
65
  module.exports = __toCommonJS(index_exports);
65
66
 
66
67
  // src/api/Errors.ts
67
68
  var UnauthenticatedError = class extends Error {
69
+ /**
70
+ * The underlying failure that prevented authentication.
71
+ *
72
+ * Callers need it to tell a credential rejection apart from a transport or service failure, since both surface as
73
+ * this error. Declared explicitly rather than passed to `super` so the SDK keeps compiling against its current lib,
74
+ * which predates the two-argument `Error` constructor.
75
+ */
76
+ cause;
68
77
  /**
69
78
  * Creates a new UnauthenticatedError instance.
70
79
  *
71
80
  * @param message - The error message
81
+ * @param cause - The underlying failure that prevented authentication, if any.
72
82
  */
73
- constructor(message) {
83
+ constructor(message, cause) {
74
84
  super(message);
75
85
  this.name = "UnauthenticatedError";
86
+ this.cause = cause;
76
87
  }
77
88
  };
78
89
  var MysaApiError = class extends Error {
@@ -94,6 +105,19 @@ var MysaApiError = class extends Error {
94
105
  this.statusText = apiResponse.statusText;
95
106
  }
96
107
  };
108
+ var UnknownDeviceError = class extends Error {
109
+ /**
110
+ * Creates a new UnknownDeviceError instance.
111
+ *
112
+ * @param deviceId - The device id that could not be resolved
113
+ */
114
+ constructor(deviceId) {
115
+ super(`Unknown device id '${deviceId}': no such device on this account.`);
116
+ this.deviceId = deviceId;
117
+ this.name = "UnknownDeviceError";
118
+ }
119
+ deviceId;
120
+ };
97
121
  var MqttPublishError = class extends Error {
98
122
  /**
99
123
  * Creates a new MqttPublishError instance.
@@ -191,8 +215,9 @@ function parseMqttPayload(payload) {
191
215
  const jsonString = decoder.decode(payload);
192
216
  return JSON.parse(jsonString);
193
217
  } catch (error) {
194
- console.error("Error parsing MQTT payload:", error);
195
- throw new Error("Failed to parse MQTT payload");
218
+ const parseError = new Error("Failed to parse MQTT payload");
219
+ parseError.cause = error;
220
+ throw parseError;
196
221
  }
197
222
  }
198
223
  function serializeMqttPayload(payload) {
@@ -248,11 +273,16 @@ var CognitoLoginKey = `cognito-idp.${AwsRegion}.amazonaws.com/${CognitoUserPoolI
248
273
  var MqttEndpoint = "a3q27gia9qg3zy-ats.iot.us-east-1.amazonaws.com";
249
274
  var MysaApiBaseUrl = "https://app-prod.mysa.cloud";
250
275
  var RealtimeKeepAliveInterval = import_dayjs.default.duration(5, "minutes");
276
+ var PublishAckTimeout = import_dayjs.default.duration(30, "seconds");
251
277
  var MysaApiClient = class {
278
+ /** The credentials of the Mysa account this client authenticates as. */
279
+ _credentials;
252
280
  /** The current session object, if any. */
253
281
  _cognitoUserSession;
254
282
  /** The current user object, if any. */
255
283
  _cognitoUser;
284
+ /** The in-flight session acquisition, if any, so that concurrent callers share a single refresh or login. */
285
+ _freshSessionPromise;
256
286
  /** The logger instance used by the client. */
257
287
  _logger;
258
288
  /** The fetcher function used by the client. */
@@ -277,89 +307,62 @@ var MysaApiClient = class {
277
307
  * @see {@link MysaApiClientEventTypes} for the possible events and their payloads.
278
308
  */
279
309
  emitter = new EventEmitter();
280
- /**
281
- * Gets the persistable session object.
282
- *
283
- * @returns The current persistable session object, if any.
284
- */
285
- get session() {
286
- if (!this._cognitoUserSession || !this._cognitoUser) {
287
- return void 0;
288
- }
289
- return {
290
- username: this._cognitoUser.getUsername(),
291
- idToken: this._cognitoUserSession.getIdToken().getJwtToken(),
292
- accessToken: this._cognitoUserSession.getAccessToken().getJwtToken(),
293
- refreshToken: this._cognitoUserSession.getRefreshToken().getToken()
294
- };
295
- }
296
- /**
297
- * Returns whether the client currently has an active session.
298
- *
299
- * @returns True if the client has an active session, false otherwise.
300
- */
301
- get isAuthenticated() {
302
- return !!this.session;
303
- }
304
310
  /**
305
311
  * Constructs a new instance of the MysaApiClient.
306
312
  *
307
- * @param session - The persistable session object, if any.
313
+ * @param credentials - The credentials of the Mysa account to authenticate as.
308
314
  * @param options - The options for the client.
309
315
  */
310
- constructor(session, options) {
316
+ constructor(credentials, options) {
317
+ this._credentials = credentials;
311
318
  this._logger = (options == null ? void 0 : options.logger) || new VoidLogger();
312
319
  this._fetcher = (options == null ? void 0 : options.fetcher) || fetch;
313
- if (session) {
314
- this._cognitoUser = new import_amazon_cognito_identity_js.CognitoUser({
315
- Username: session.username,
316
- Pool: new import_amazon_cognito_identity_js.CognitoUserPool({ UserPoolId: CognitoUserPoolId, ClientId: CognitoClientId })
317
- });
318
- this._cognitoUserSession = new import_amazon_cognito_identity_js.CognitoUserSession({
319
- IdToken: new import_amazon_cognito_identity_js.CognitoIdToken({ IdToken: session.idToken }),
320
- AccessToken: new import_amazon_cognito_identity_js.CognitoAccessToken({ AccessToken: session.accessToken }),
321
- RefreshToken: new import_amazon_cognito_identity_js.CognitoRefreshToken({ RefreshToken: session.refreshToken })
322
- });
323
- }
324
320
  }
325
321
  /**
326
- * Logs in the user with the given email address and password.
322
+ * Ensures the client has a usable session, logging in with the credentials it was constructed with if needed.
327
323
  *
328
- * This method authenticates the user with Mysa's Cognito user pool and establishes a session that can be used for
329
- * subsequent API calls. Upon successful login, a 'sessionChanged' event is emitted.
324
+ * Calling this method is optional: the client authenticates on demand before its first API call, and re-authenticates
325
+ * on its own whenever its session can no longer be refreshed. Call it explicitly at startup to fail fast on invalid
326
+ * credentials instead of on the first API call. It is a no-op when the current session is still usable.
330
327
  *
331
328
  * @example
332
329
  *
333
330
  * ```typescript
334
331
  * try {
335
- * await client.login('user@example.com', 'password123');
332
+ * await client.login();
336
333
  * console.log('Login successful!');
337
334
  * } catch (error) {
338
335
  * console.error('Login failed:', error.message);
339
336
  * }
340
337
  * ```
341
338
  *
342
- * @param emailAddress - The email address of the user.
343
- * @param password - The password of the user.
339
+ * @throws {@link UnauthenticatedError} When authentication fails due to invalid credentials or network issues.
340
+ */
341
+ async login() {
342
+ await this._getFreshSession();
343
+ }
344
+ /**
345
+ * Authenticates with Mysa's Cognito user pool and replaces the current session, if any.
346
+ *
347
+ * @returns A promise that resolves to the newly established session.
344
348
  * @throws {@link Error} When authentication fails due to invalid credentials or network issues.
345
349
  */
346
- async login(emailAddress, password) {
350
+ _login() {
347
351
  this._cognitoUser = void 0;
348
352
  this._cognitoUserSession = void 0;
349
353
  this._mqttClientId = void 0;
350
354
  this._mqttInterrupts = [];
351
- this.emitter.emit("sessionChanged", this.session);
355
+ const { username, password } = this._credentials;
352
356
  return new Promise((resolve, reject) => {
353
357
  const user = new import_amazon_cognito_identity_js.CognitoUser({
354
- Username: emailAddress,
358
+ Username: username,
355
359
  Pool: new import_amazon_cognito_identity_js.CognitoUserPool({ UserPoolId: CognitoUserPoolId, ClientId: CognitoClientId })
356
360
  });
357
- user.authenticateUser(new import_amazon_cognito_identity_js.AuthenticationDetails({ Username: emailAddress, Password: password }), {
361
+ user.authenticateUser(new import_amazon_cognito_identity_js.AuthenticationDetails({ Username: username, Password: password }), {
358
362
  onSuccess: (session) => {
359
363
  this._cognitoUser = user;
360
364
  this._cognitoUserSession = session;
361
- this.emitter.emit("sessionChanged", this.session);
362
- resolve();
365
+ resolve(session);
363
366
  },
364
367
  onFailure: (err) => {
365
368
  reject(err);
@@ -384,7 +387,7 @@ var MysaApiClient = class {
384
387
  *
385
388
  * @returns A promise that resolves to the list of devices.
386
389
  * @throws {@link MysaApiError} When the API request fails.
387
- * @throws {@link UnauthenticatedError} When the user is not authenticated.
390
+ * @throws {@link UnauthenticatedError} When the client cannot authenticate with its credentials.
388
391
  */
389
392
  async getDevices() {
390
393
  this._logger.debug(`Fetching devices...`);
@@ -418,7 +421,7 @@ var MysaApiClient = class {
418
421
  *
419
422
  * @param deviceId - The ID of the device to get the serial number for.
420
423
  * @returns A promise that resolves to the serial number, or undefined if not found.
421
- * @throws {@link UnauthenticatedError} When the user is not authenticated.
424
+ * @throws {@link UnauthenticatedError} When the client cannot authenticate with its credentials.
422
425
  */
423
426
  async getDeviceSerialNumber(deviceId) {
424
427
  var _a;
@@ -456,7 +459,7 @@ var MysaApiClient = class {
456
459
  *
457
460
  * @returns A promise that resolves to the firmware information for all devices.
458
461
  * @throws {@link MysaApiError} When the API request fails.
459
- * @throws {@link UnauthenticatedError} When the user is not authenticated.
462
+ * @throws {@link UnauthenticatedError} When the client cannot authenticate with its credentials.
460
463
  */
461
464
  async getDeviceFirmwares() {
462
465
  this._logger.debug(`Fetching device firmwares...`);
@@ -476,7 +479,7 @@ var MysaApiClient = class {
476
479
  *
477
480
  * @returns A promise that resolves to the current state of all devices.
478
481
  * @throws {@link MysaApiError} When the API request fails.
479
- * @throws {@link UnauthenticatedError} When the user is not authenticated.
482
+ * @throws {@link UnauthenticatedError} When the client cannot authenticate with its credentials.
480
483
  */
481
484
  async getDeviceStates() {
482
485
  this._logger.debug(`Fetching device states...`);
@@ -496,7 +499,7 @@ var MysaApiClient = class {
496
499
  *
497
500
  * @returns A promise that resolves to the homes information.
498
501
  * @throws {@link MysaApiError} When the API request fails.
499
- * @throws {@link UnauthenticatedError} When the user is not authenticated.
502
+ * @throws {@link UnauthenticatedError} When the client cannot authenticate with its credentials.
500
503
  */
501
504
  async getHomes() {
502
505
  this._logger.debug(`Fetching homes...`);
@@ -538,16 +541,20 @@ var MysaApiClient = class {
538
541
  * @param mode - The operating mode to set (one of MysaDeviceMode values, or undefined to leave unchanged).
539
542
  * @param fanSpeed - The fan speed mode to set ('low', 'medium', 'high', 'max', 'auto', or undefined to leave
540
543
  * unchanged).
541
- * @throws {@link UnauthenticatedError} When the user is not authenticated.
544
+ * @throws {@link UnauthenticatedError} When the client cannot authenticate with its credentials.
545
+ * @throws {@link UnknownDeviceError} When the device id does not match any device on the account.
542
546
  * @throws {@link Error} When MQTT connection or command sending fails.
543
547
  */
544
548
  async setDeviceState(deviceId, setPoint, mode, fanSpeed) {
545
- var _a;
546
549
  this._logger.debug(`Setting device state for '${deviceId}'`);
547
550
  if (!this._cachedDevices) {
548
551
  this._cachedDevices = await this.getDevices();
549
552
  }
553
+ if (!Object.prototype.hasOwnProperty.call(this._cachedDevices.DevicesObj, deviceId)) {
554
+ throw new UnknownDeviceError(deviceId);
555
+ }
550
556
  const device = this._cachedDevices.DevicesObj[deviceId];
557
+ await this._getFreshSession();
551
558
  this._logger.debug(`Initializing MQTT connection...`);
552
559
  const mqttConnection = await this._getMqttConnection();
553
560
  const now = (0, import_dayjs.default)();
@@ -560,7 +567,7 @@ var MysaApiClient = class {
560
567
  time: now.unix(),
561
568
  ver: "1.0",
562
569
  src: {
563
- ref: ((_a = this.session) == null ? void 0 : _a.username) ?? "",
570
+ ref: this._cognitoUser.getUsername(),
564
571
  type: 100
565
572
  },
566
573
  dest: {
@@ -629,17 +636,25 @@ var MysaApiClient = class {
629
636
  Timestamp: (0, import_dayjs.default)().unix(),
630
637
  Timeout: RealtimeKeepAliveInterval.asSeconds()
631
638
  });
632
- await this._publishWithRetry(mqttConnection, `/v1/dev/${deviceId}/in`, payload, import_aws_iot_device_sdk_v2.mqtt.QoS.AtLeastOnce);
639
+ try {
640
+ await this._publishWithRetry(mqttConnection, `/v1/dev/${deviceId}/in`, payload, import_aws_iot_device_sdk_v2.mqtt.QoS.AtLeastOnce);
641
+ } catch (error) {
642
+ this._logger.warn(`Failed to request status publishing for '${deviceId}'; relying on periodic reports`, error);
643
+ }
633
644
  const timer = setInterval(async () => {
634
645
  this._logger.debug(`Sending request to keep-alive publishing device status for '${deviceId}'...`);
635
- const connection = await this._getMqttConnection();
636
- const payload2 = serializeMqttPayload({
637
- Device: deviceId,
638
- MsgType: 11 /* START_PUBLISHING_DEVICE_STATUS */,
639
- Timestamp: (0, import_dayjs.default)().unix(),
640
- Timeout: RealtimeKeepAliveInterval.asSeconds()
641
- });
642
- await this._publishWithRetry(connection, `/v1/dev/${deviceId}/in`, payload2, import_aws_iot_device_sdk_v2.mqtt.QoS.AtLeastOnce);
646
+ try {
647
+ const connection = await this._getMqttConnection();
648
+ const payload2 = serializeMqttPayload({
649
+ Device: deviceId,
650
+ MsgType: 11 /* START_PUBLISHING_DEVICE_STATUS */,
651
+ Timestamp: (0, import_dayjs.default)().unix(),
652
+ Timeout: RealtimeKeepAliveInterval.asSeconds()
653
+ });
654
+ await this._publishWithRetry(connection, `/v1/dev/${deviceId}/in`, payload2, import_aws_iot_device_sdk_v2.mqtt.QoS.AtLeastOnce);
655
+ } catch (error) {
656
+ this._logger.warn(`Failed to keep-alive status publishing for '${deviceId}'`, error);
657
+ }
643
658
  }, RealtimeKeepAliveInterval.subtract(10, "seconds").asMilliseconds());
644
659
  this._realtimeDeviceIds.set(deviceId, timer);
645
660
  }
@@ -670,33 +685,58 @@ var MysaApiClient = class {
670
685
  * Ensures a valid, non-expired session is available.
671
686
  *
672
687
  * This method checks if the current session is valid and not expired. If the session is expired, it automatically
673
- * refreshes it using the refresh token.
688
+ * refreshes it using the refresh token. If there is no session yet, or if the refresh token has itself expired or
689
+ * been revoked, the client logs back in with its credentials.
690
+ *
691
+ * Concurrent callers share a single in-flight acquisition, so a burst of API calls never triggers more than one
692
+ * refresh or login.
674
693
  *
675
694
  * @returns A promise that resolves to a valid CognitoUserSession.
676
- * @throws {@link UnauthenticatedError} When no session exists or refresh fails.
695
+ * @throws {@link UnauthenticatedError} When neither refreshing nor logging back in succeeds.
677
696
  */
678
697
  async _getFreshSession() {
679
- if (!this._cognitoUser || !this._cognitoUserSession) {
680
- throw new UnauthenticatedError("An attempt was made to access a resource without a valid session.");
681
- }
682
- if (this._cognitoUserSession.isValid() && import_dayjs.default.unix(this._cognitoUserSession.getIdToken().getExpiration()).isAfter()) {
698
+ var _a;
699
+ if (this._cognitoUser && ((_a = this._cognitoUserSession) == null ? void 0 : _a.isValid()) && import_dayjs.default.unix(this._cognitoUserSession.getIdToken().getExpiration()).isAfter()) {
683
700
  this._logger.debug("Session is valid, no need to refresh");
684
- return Promise.resolve(this._cognitoUserSession);
701
+ return this._cognitoUserSession;
685
702
  }
686
- this._logger.debug("Session is not valid or expired, refreshing...");
687
- return new Promise((resolve, reject) => {
688
- this._cognitoUser.refreshSession(this._cognitoUserSession.getRefreshToken(), (error, session) => {
689
- if (error) {
690
- this._logger.error("Failed to refresh session:", error);
691
- reject(new UnauthenticatedError("Unable to refresh the authentication session."));
692
- } else {
693
- this._logger.debug("Session refreshed successfully");
694
- this._cognitoUserSession = session;
695
- this.emitter.emit("sessionChanged", this.session);
696
- resolve(session);
697
- }
698
- });
703
+ this._freshSessionPromise ??= this._acquireFreshSession().finally(() => {
704
+ this._freshSessionPromise = void 0;
699
705
  });
706
+ return this._freshSessionPromise;
707
+ }
708
+ /**
709
+ * Refreshes the current session, falling back to a full login when it cannot be refreshed.
710
+ *
711
+ * @returns A promise that resolves to a valid CognitoUserSession.
712
+ * @throws {@link UnauthenticatedError} When logging back in fails.
713
+ */
714
+ async _acquireFreshSession() {
715
+ if (this._cognitoUser && this._cognitoUserSession) {
716
+ this._logger.debug("Session is not valid or expired, refreshing...");
717
+ try {
718
+ return await new Promise((resolve, reject) => {
719
+ this._cognitoUser.refreshSession(this._cognitoUserSession.getRefreshToken(), (error, session) => {
720
+ if (error) {
721
+ reject(error);
722
+ } else {
723
+ this._logger.debug("Session refreshed successfully");
724
+ this._cognitoUserSession = session;
725
+ resolve(session);
726
+ }
727
+ });
728
+ });
729
+ } catch (error) {
730
+ this._logger.warn("Failed to refresh session, logging back in:", error);
731
+ }
732
+ }
733
+ try {
734
+ this._logger.info("Logging in...");
735
+ return await this._login();
736
+ } catch (error) {
737
+ this._logger.error("Failed to log in:", error);
738
+ throw new UnauthenticatedError("Unable to establish an authentication session.", error);
739
+ }
700
740
  }
701
741
  /**
702
742
  * Establishes and returns an MQTT connection for real-time communication.
@@ -774,7 +814,22 @@ var MysaApiClient = class {
774
814
  while (true) {
775
815
  attempt++;
776
816
  try {
777
- await connection.publish(topic, payload, qos);
817
+ await new Promise((resolve, reject) => {
818
+ const timer = setTimeout(
819
+ () => reject(new Error(`MQTT publish timeout: no acknowledgement within ${PublishAckTimeout.asSeconds()}s`)),
820
+ PublishAckTimeout.asMilliseconds()
821
+ );
822
+ connection.publish(topic, payload, qos).then(
823
+ () => {
824
+ clearTimeout(timer);
825
+ resolve();
826
+ },
827
+ (err) => {
828
+ clearTimeout(timer);
829
+ reject(err);
830
+ }
831
+ );
832
+ });
778
833
  return;
779
834
  } catch (err) {
780
835
  const isTransient = this._isTransientMqttError(err);
@@ -823,7 +878,7 @@ var MysaApiClient = class {
823
878
  throw new Error("MQTT credentials are already expired.");
824
879
  }
825
880
  if (!this._mqttClientId) {
826
- const username = ((_a = this.session) == null ? void 0 : _a.username) ?? "anon";
881
+ const username = ((_a = this._cognitoUser) == null ? void 0 : _a.getUsername()) ?? "anon";
827
882
  const usernameHash = (0, import_crypto.hash)("sha1", username);
828
883
  this._mqttClientId = `mysa-js-sdk-${usernameHash}-${process.pid}-${getRandomClientId()}`;
829
884
  }
@@ -1036,6 +1091,7 @@ var MysaApiClient = class {
1036
1091
  MysaApiError,
1037
1092
  OutMessageType,
1038
1093
  UnauthenticatedError,
1094
+ UnknownDeviceError,
1039
1095
  VoidLogger
1040
1096
  });
1041
1097
  //# sourceMappingURL=index.cjs.map