@apocaliss92/nodedreame 1.11.9 → 1.11.11

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.cts CHANGED
@@ -169,6 +169,8 @@ declare class TypedEmitter<Events extends Record<string, unknown[]>> {
169
169
  once<K extends keyof Events & string>(event: K, listener: (...args: Events[K]) => void): this;
170
170
  off<K extends keyof Events & string>(event: K, listener: (...args: Events[K]) => void): this;
171
171
  emit<K extends keyof Events & string>(event: K, ...args: Events[K]): boolean;
172
+ /** Number of listeners registered for `event`. */
173
+ listenerCount<K extends keyof Events & string>(event: K): number;
172
174
  removeAllListeners(): this;
173
175
  }
174
176
 
@@ -179,6 +181,14 @@ interface PropertyChange {
179
181
  piid: number;
180
182
  value: unknown;
181
183
  }
184
+ /**
185
+ * True when `err` (or an error in its `cause` chain) is an MQTT CONNACK
186
+ * auth-refusal. mqtt.js raises an `ErrorWithReasonCode` carrying a numeric
187
+ * `code`: MQTT 3.1.1 uses 4 (bad user/pass) / 5 (not authorized); MQTT 5 uses
188
+ * 134 / 135. We also match the human-readable message as a belt-and-braces
189
+ * fallback in case the code is not surfaced through a wrapper.
190
+ */
191
+ declare function isAuthRefusedError(err: unknown): boolean;
182
192
 
183
193
  /**
184
194
  * A read-only set of capability tokens a device model supports. Phase 2 ships
@@ -294,7 +304,7 @@ interface PushLike {
294
304
  }
295
305
  /** Injectable collaborators — defaults wire the real transport + command layer. */
296
306
  interface BaseDeviceDeps {
297
- createPush(device: DreameDevice, session: DreameSession, region: DreameRegion): PushLike;
307
+ createPush(device: DreameDevice, session: DreameSession, region: DreameRegion, onAuthFailure?: () => Promise<DreameSession>): PushLike;
298
308
  getProperties(base: CommonInput, props: MiotProp[]): Promise<PropertyResult[]>;
299
309
  /** Read the cloud-cached (shadow) values WITHOUT waking the device. */
300
310
  getCachedProperties(base: CommonInput, props: MiotProp[]): Promise<PropertyResult[]>;
@@ -316,6 +326,12 @@ interface BaseDeviceInput {
316
326
  region: DreameRegion;
317
327
  /** Always reads the LATEST session — the facade owns the variable. */
318
328
  sessionRef: () => DreameSession;
329
+ /**
330
+ * Force-refresh callback handed to the underlying push so it can self-heal a
331
+ * broker CONNACK auth-refusal (stale token) by minting a fresh session.
332
+ * Wired by the facade to `Nodreame.reauthenticate()`.
333
+ */
334
+ onAuthFailure?: () => Promise<DreameSession>;
319
335
  deps?: BaseDeviceDeps;
320
336
  /** Eager-seed the cache on `start()`. Default true. */
321
337
  fetchInitialValues?: boolean;
@@ -376,6 +392,12 @@ interface CreateDeviceArgs {
376
392
  device: DreameDevice;
377
393
  region: DreameRegion;
378
394
  sessionRef: () => DreameSession;
395
+ /**
396
+ * Force-refresh callback handed down to the device's MQTT push so it can
397
+ * self-heal a broker CONNACK auth-refusal (stale token). Bound by the facade
398
+ * to {@link Nodreame.reauthenticate}.
399
+ */
400
+ onAuthFailure?: () => Promise<DreameSession>;
379
401
  }
380
402
  /** Injectable collaborators — defaults wire the real P1 modules. */
381
403
  interface NodreameDeps {
@@ -419,6 +441,15 @@ declare class Nodreame extends TypedEmitter<NodreameEvents> {
419
441
  login(): Promise<DreameSession>;
420
442
  /** Return a valid session, refreshing proactively within the leeway window. */
421
443
  ensureSession(): Promise<DreameSession>;
444
+ /**
445
+ * Force a session refresh REGARDLESS of the current expiry, then propagate the
446
+ * new token to every live device push. Wired as each push's `onAuthFailure`:
447
+ * when a broker refuses a CONNECT because it considers the token stale (even
448
+ * though {@link ensureSession} still thinks it valid — e.g. server-side early
449
+ * revocation or clock skew), this mints a genuinely fresh token instead of
450
+ * replaying the rejected one.
451
+ */
452
+ reauthenticate(): Promise<DreameSession>;
422
453
  /** Discover devices and build a live handle per device. */
423
454
  discoverDevices(): Promise<readonly BaseDevice[]>;
424
455
  /** Tear everything down: close every device push and clear timers. */
@@ -2598,4 +2629,4 @@ declare function createDumper(target: DumperDevice, options?: DumperOptions): Du
2598
2629
  */
2599
2630
  declare function createClientDumper(client: Nodreame, options?: DumperOptions): Dumper[];
2600
2631
 
2601
- export { AI_FEATURE_BIT, AI_FEATURE_JSON_KEY, AUTO_SWITCH_JSON_KEY, type AiDetectionRaw, type AutoSwitchKey, type AutoSwitchRaw, BaseDevice, type BaseDeviceEvents, type BatchDeviceDataFetcher, type CapabilityResolver, ChargingStatus, type CleanOpts, CleaningMode, type ConsumableReading, type ConsumableSpec, type CreateDeviceArgs, DefaultCapabilityResolver, type DeviceCapabilities, type DeviceDump, type DeviceEvent, type DreameAiFeature, DreameApiError, DreameAuthError, type DreameCloudState, type DreameConsumableKey, type DreameDevice, DreameDeviceOfflineError, DreameError, type DreameRegion, type DreameSession, DreameTransportError, type DumperOptions, LIBRARY_NAME, MODEL_CAPABILITIES as MOWER_MODEL_CAPABILITIES, MOWER_TASK_SUBSTATES, type MapBoundingBox, type MapCleanedAreaOverlay, type MapColorScheme, type MapDimensions, type MapFrameType, type MapLayer, type MapLayerType, type MapLowLyingArea, type MapObstacle, type MapPath, type MapPathType, type MapPoint, type MapPose, type MapRestrictedArea, type MapRoom, type MapRoomWall, type MapRun, type MapSegment, type MapStorey, type MapVirtualWall, type MapWallsInfo, type MiotAction, MiotError, type MiotProp, MiotState, type MowerAvailableMap, type MowerCapabilities, MowerCapabilityResolver, MowerChargingStatus, type MowerConsumableKey, type MowerConsumableReading, type MowerContour, MowerControlAction, type MowerControlState, MowerDevice, type MowerDeviceInput, MowerFault, type MowerFaultSeverity, type MowerHeartbeat, type MowerMap, type MowerMapBoundary, type MowerMowPath, type MowerMowingProgress, type MowerPathEntry, type MowerPoint, type MowerSpotArea, MowerStatus, type MowerTaskDescriptor, MowerTaskStatus, type MowerTaskSubState, type MowerZone, Nodreame, type NodreameDeps, type NodreameEvents, type NodreameOptions, type OssFetchInput, type OssFetcherLike, type PropertyChangedEvent, type PropertyResult, type PropertyState, type PropertyWrite, type RenderMowerSvgOptions, type RenderVacuumPngOptions, type StateChangedEvent, SuctionLevel, TaskStatus, VACUUM_CONSUMABLES, MODEL_CAPABILITIES$1 as VACUUM_MODEL_CAPABILITIES, type VacuumCapabilities, VacuumCapabilityResolver, VacuumDevice, type VacuumGetMapInput, type VacuumMap, WaterVolume, consumableSpec, createClientDumper, createDumper, decodeAiFeature, decodeAutoSwitch, decodeAutoSwitchAll, encodeAiFeatureWrite, encodeAutoSwitchWrite, extractMowerConsumableValues, getMowerCapabilities, getVacuumCapabilities, isDreameConsumableKey, mowerConsumableIndex, mowerFaultSeverity, parseMowerConsumables, parseMowerHeartbeat, parseMowingProgress, renderMowerSvg, renderVacuumPng, resolveCapabilities, supportedAutoSwitchKeys };
2632
+ export { AI_FEATURE_BIT, AI_FEATURE_JSON_KEY, AUTO_SWITCH_JSON_KEY, type AiDetectionRaw, type AutoSwitchKey, type AutoSwitchRaw, BaseDevice, type BaseDeviceEvents, type BatchDeviceDataFetcher, type CapabilityResolver, ChargingStatus, type CleanOpts, CleaningMode, type ConsumableReading, type ConsumableSpec, type CreateDeviceArgs, DefaultCapabilityResolver, type DeviceCapabilities, type DeviceDump, type DeviceEvent, type DreameAiFeature, DreameApiError, DreameAuthError, type DreameCloudState, type DreameConsumableKey, type DreameDevice, DreameDeviceOfflineError, DreameError, type DreameRegion, type DreameSession, DreameTransportError, type DumperOptions, LIBRARY_NAME, MODEL_CAPABILITIES as MOWER_MODEL_CAPABILITIES, MOWER_TASK_SUBSTATES, type MapBoundingBox, type MapCleanedAreaOverlay, type MapColorScheme, type MapDimensions, type MapFrameType, type MapLayer, type MapLayerType, type MapLowLyingArea, type MapObstacle, type MapPath, type MapPathType, type MapPoint, type MapPose, type MapRestrictedArea, type MapRoom, type MapRoomWall, type MapRun, type MapSegment, type MapStorey, type MapVirtualWall, type MapWallsInfo, type MiotAction, MiotError, type MiotProp, MiotState, type MowerAvailableMap, type MowerCapabilities, MowerCapabilityResolver, MowerChargingStatus, type MowerConsumableKey, type MowerConsumableReading, type MowerContour, MowerControlAction, type MowerControlState, MowerDevice, type MowerDeviceInput, MowerFault, type MowerFaultSeverity, type MowerHeartbeat, type MowerMap, type MowerMapBoundary, type MowerMowPath, type MowerMowingProgress, type MowerPathEntry, type MowerPoint, type MowerSpotArea, MowerStatus, type MowerTaskDescriptor, MowerTaskStatus, type MowerTaskSubState, type MowerZone, Nodreame, type NodreameDeps, type NodreameEvents, type NodreameOptions, type OssFetchInput, type OssFetcherLike, type PropertyChangedEvent, type PropertyResult, type PropertyState, type PropertyWrite, type RenderMowerSvgOptions, type RenderVacuumPngOptions, type StateChangedEvent, SuctionLevel, TaskStatus, VACUUM_CONSUMABLES, MODEL_CAPABILITIES$1 as VACUUM_MODEL_CAPABILITIES, type VacuumCapabilities, VacuumCapabilityResolver, VacuumDevice, type VacuumGetMapInput, type VacuumMap, WaterVolume, consumableSpec, createClientDumper, createDumper, decodeAiFeature, decodeAutoSwitch, decodeAutoSwitchAll, encodeAiFeatureWrite, encodeAutoSwitchWrite, extractMowerConsumableValues, getMowerCapabilities, getVacuumCapabilities, isAuthRefusedError, isDreameConsumableKey, mowerConsumableIndex, mowerFaultSeverity, parseMowerConsumables, parseMowerHeartbeat, parseMowingProgress, renderMowerSvg, renderVacuumPng, resolveCapabilities, supportedAutoSwitchKeys };
package/dist/index.d.ts CHANGED
@@ -169,6 +169,8 @@ declare class TypedEmitter<Events extends Record<string, unknown[]>> {
169
169
  once<K extends keyof Events & string>(event: K, listener: (...args: Events[K]) => void): this;
170
170
  off<K extends keyof Events & string>(event: K, listener: (...args: Events[K]) => void): this;
171
171
  emit<K extends keyof Events & string>(event: K, ...args: Events[K]): boolean;
172
+ /** Number of listeners registered for `event`. */
173
+ listenerCount<K extends keyof Events & string>(event: K): number;
172
174
  removeAllListeners(): this;
173
175
  }
174
176
 
@@ -179,6 +181,14 @@ interface PropertyChange {
179
181
  piid: number;
180
182
  value: unknown;
181
183
  }
184
+ /**
185
+ * True when `err` (or an error in its `cause` chain) is an MQTT CONNACK
186
+ * auth-refusal. mqtt.js raises an `ErrorWithReasonCode` carrying a numeric
187
+ * `code`: MQTT 3.1.1 uses 4 (bad user/pass) / 5 (not authorized); MQTT 5 uses
188
+ * 134 / 135. We also match the human-readable message as a belt-and-braces
189
+ * fallback in case the code is not surfaced through a wrapper.
190
+ */
191
+ declare function isAuthRefusedError(err: unknown): boolean;
182
192
 
183
193
  /**
184
194
  * A read-only set of capability tokens a device model supports. Phase 2 ships
@@ -294,7 +304,7 @@ interface PushLike {
294
304
  }
295
305
  /** Injectable collaborators — defaults wire the real transport + command layer. */
296
306
  interface BaseDeviceDeps {
297
- createPush(device: DreameDevice, session: DreameSession, region: DreameRegion): PushLike;
307
+ createPush(device: DreameDevice, session: DreameSession, region: DreameRegion, onAuthFailure?: () => Promise<DreameSession>): PushLike;
298
308
  getProperties(base: CommonInput, props: MiotProp[]): Promise<PropertyResult[]>;
299
309
  /** Read the cloud-cached (shadow) values WITHOUT waking the device. */
300
310
  getCachedProperties(base: CommonInput, props: MiotProp[]): Promise<PropertyResult[]>;
@@ -316,6 +326,12 @@ interface BaseDeviceInput {
316
326
  region: DreameRegion;
317
327
  /** Always reads the LATEST session — the facade owns the variable. */
318
328
  sessionRef: () => DreameSession;
329
+ /**
330
+ * Force-refresh callback handed to the underlying push so it can self-heal a
331
+ * broker CONNACK auth-refusal (stale token) by minting a fresh session.
332
+ * Wired by the facade to `Nodreame.reauthenticate()`.
333
+ */
334
+ onAuthFailure?: () => Promise<DreameSession>;
319
335
  deps?: BaseDeviceDeps;
320
336
  /** Eager-seed the cache on `start()`. Default true. */
321
337
  fetchInitialValues?: boolean;
@@ -376,6 +392,12 @@ interface CreateDeviceArgs {
376
392
  device: DreameDevice;
377
393
  region: DreameRegion;
378
394
  sessionRef: () => DreameSession;
395
+ /**
396
+ * Force-refresh callback handed down to the device's MQTT push so it can
397
+ * self-heal a broker CONNACK auth-refusal (stale token). Bound by the facade
398
+ * to {@link Nodreame.reauthenticate}.
399
+ */
400
+ onAuthFailure?: () => Promise<DreameSession>;
379
401
  }
380
402
  /** Injectable collaborators — defaults wire the real P1 modules. */
381
403
  interface NodreameDeps {
@@ -419,6 +441,15 @@ declare class Nodreame extends TypedEmitter<NodreameEvents> {
419
441
  login(): Promise<DreameSession>;
420
442
  /** Return a valid session, refreshing proactively within the leeway window. */
421
443
  ensureSession(): Promise<DreameSession>;
444
+ /**
445
+ * Force a session refresh REGARDLESS of the current expiry, then propagate the
446
+ * new token to every live device push. Wired as each push's `onAuthFailure`:
447
+ * when a broker refuses a CONNECT because it considers the token stale (even
448
+ * though {@link ensureSession} still thinks it valid — e.g. server-side early
449
+ * revocation or clock skew), this mints a genuinely fresh token instead of
450
+ * replaying the rejected one.
451
+ */
452
+ reauthenticate(): Promise<DreameSession>;
422
453
  /** Discover devices and build a live handle per device. */
423
454
  discoverDevices(): Promise<readonly BaseDevice[]>;
424
455
  /** Tear everything down: close every device push and clear timers. */
@@ -2598,4 +2629,4 @@ declare function createDumper(target: DumperDevice, options?: DumperOptions): Du
2598
2629
  */
2599
2630
  declare function createClientDumper(client: Nodreame, options?: DumperOptions): Dumper[];
2600
2631
 
2601
- export { AI_FEATURE_BIT, AI_FEATURE_JSON_KEY, AUTO_SWITCH_JSON_KEY, type AiDetectionRaw, type AutoSwitchKey, type AutoSwitchRaw, BaseDevice, type BaseDeviceEvents, type BatchDeviceDataFetcher, type CapabilityResolver, ChargingStatus, type CleanOpts, CleaningMode, type ConsumableReading, type ConsumableSpec, type CreateDeviceArgs, DefaultCapabilityResolver, type DeviceCapabilities, type DeviceDump, type DeviceEvent, type DreameAiFeature, DreameApiError, DreameAuthError, type DreameCloudState, type DreameConsumableKey, type DreameDevice, DreameDeviceOfflineError, DreameError, type DreameRegion, type DreameSession, DreameTransportError, type DumperOptions, LIBRARY_NAME, MODEL_CAPABILITIES as MOWER_MODEL_CAPABILITIES, MOWER_TASK_SUBSTATES, type MapBoundingBox, type MapCleanedAreaOverlay, type MapColorScheme, type MapDimensions, type MapFrameType, type MapLayer, type MapLayerType, type MapLowLyingArea, type MapObstacle, type MapPath, type MapPathType, type MapPoint, type MapPose, type MapRestrictedArea, type MapRoom, type MapRoomWall, type MapRun, type MapSegment, type MapStorey, type MapVirtualWall, type MapWallsInfo, type MiotAction, MiotError, type MiotProp, MiotState, type MowerAvailableMap, type MowerCapabilities, MowerCapabilityResolver, MowerChargingStatus, type MowerConsumableKey, type MowerConsumableReading, type MowerContour, MowerControlAction, type MowerControlState, MowerDevice, type MowerDeviceInput, MowerFault, type MowerFaultSeverity, type MowerHeartbeat, type MowerMap, type MowerMapBoundary, type MowerMowPath, type MowerMowingProgress, type MowerPathEntry, type MowerPoint, type MowerSpotArea, MowerStatus, type MowerTaskDescriptor, MowerTaskStatus, type MowerTaskSubState, type MowerZone, Nodreame, type NodreameDeps, type NodreameEvents, type NodreameOptions, type OssFetchInput, type OssFetcherLike, type PropertyChangedEvent, type PropertyResult, type PropertyState, type PropertyWrite, type RenderMowerSvgOptions, type RenderVacuumPngOptions, type StateChangedEvent, SuctionLevel, TaskStatus, VACUUM_CONSUMABLES, MODEL_CAPABILITIES$1 as VACUUM_MODEL_CAPABILITIES, type VacuumCapabilities, VacuumCapabilityResolver, VacuumDevice, type VacuumGetMapInput, type VacuumMap, WaterVolume, consumableSpec, createClientDumper, createDumper, decodeAiFeature, decodeAutoSwitch, decodeAutoSwitchAll, encodeAiFeatureWrite, encodeAutoSwitchWrite, extractMowerConsumableValues, getMowerCapabilities, getVacuumCapabilities, isDreameConsumableKey, mowerConsumableIndex, mowerFaultSeverity, parseMowerConsumables, parseMowerHeartbeat, parseMowingProgress, renderMowerSvg, renderVacuumPng, resolveCapabilities, supportedAutoSwitchKeys };
2632
+ export { AI_FEATURE_BIT, AI_FEATURE_JSON_KEY, AUTO_SWITCH_JSON_KEY, type AiDetectionRaw, type AutoSwitchKey, type AutoSwitchRaw, BaseDevice, type BaseDeviceEvents, type BatchDeviceDataFetcher, type CapabilityResolver, ChargingStatus, type CleanOpts, CleaningMode, type ConsumableReading, type ConsumableSpec, type CreateDeviceArgs, DefaultCapabilityResolver, type DeviceCapabilities, type DeviceDump, type DeviceEvent, type DreameAiFeature, DreameApiError, DreameAuthError, type DreameCloudState, type DreameConsumableKey, type DreameDevice, DreameDeviceOfflineError, DreameError, type DreameRegion, type DreameSession, DreameTransportError, type DumperOptions, LIBRARY_NAME, MODEL_CAPABILITIES as MOWER_MODEL_CAPABILITIES, MOWER_TASK_SUBSTATES, type MapBoundingBox, type MapCleanedAreaOverlay, type MapColorScheme, type MapDimensions, type MapFrameType, type MapLayer, type MapLayerType, type MapLowLyingArea, type MapObstacle, type MapPath, type MapPathType, type MapPoint, type MapPose, type MapRestrictedArea, type MapRoom, type MapRoomWall, type MapRun, type MapSegment, type MapStorey, type MapVirtualWall, type MapWallsInfo, type MiotAction, MiotError, type MiotProp, MiotState, type MowerAvailableMap, type MowerCapabilities, MowerCapabilityResolver, MowerChargingStatus, type MowerConsumableKey, type MowerConsumableReading, type MowerContour, MowerControlAction, type MowerControlState, MowerDevice, type MowerDeviceInput, MowerFault, type MowerFaultSeverity, type MowerHeartbeat, type MowerMap, type MowerMapBoundary, type MowerMowPath, type MowerMowingProgress, type MowerPathEntry, type MowerPoint, type MowerSpotArea, MowerStatus, type MowerTaskDescriptor, MowerTaskStatus, type MowerTaskSubState, type MowerZone, Nodreame, type NodreameDeps, type NodreameEvents, type NodreameOptions, type OssFetchInput, type OssFetcherLike, type PropertyChangedEvent, type PropertyResult, type PropertyState, type PropertyWrite, type RenderMowerSvgOptions, type RenderVacuumPngOptions, type StateChangedEvent, SuctionLevel, TaskStatus, VACUUM_CONSUMABLES, MODEL_CAPABILITIES$1 as VACUUM_MODEL_CAPABILITIES, type VacuumCapabilities, VacuumCapabilityResolver, VacuumDevice, type VacuumGetMapInput, type VacuumMap, WaterVolume, consumableSpec, createClientDumper, createDumper, decodeAiFeature, decodeAutoSwitch, decodeAutoSwitchAll, encodeAiFeatureWrite, encodeAutoSwitchWrite, extractMowerConsumableValues, getMowerCapabilities, getVacuumCapabilities, isAuthRefusedError, isDreameConsumableKey, mowerConsumableIndex, mowerFaultSeverity, parseMowerConsumables, parseMowerHeartbeat, parseMowingProgress, renderMowerSvg, renderVacuumPng, resolveCapabilities, supportedAutoSwitchKeys };
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // src/support/version.ts
2
2
  var LIBRARY_NAME = "nodedreame";
3
- var LIBRARY_VERSION = "1.11.9";
3
+ var LIBRARY_VERSION = "1.11.10";
4
4
 
5
5
  // src/transport/errors.ts
6
6
  var DreameError = class extends Error {
@@ -629,6 +629,10 @@ var TypedEmitter = class {
629
629
  emit(event, ...args) {
630
630
  return this.#emitter.emit(event, ...args);
631
631
  }
632
+ /** Number of listeners registered for `event`. */
633
+ listenerCount(event) {
634
+ return this.#emitter.listenerCount(event);
635
+ }
632
636
  removeAllListeners() {
633
637
  this.#emitter.removeAllListeners();
634
638
  return this;
@@ -641,6 +645,24 @@ function defaultConnect(url, opts) {
641
645
  const typed = client;
642
646
  return typed;
643
647
  }
648
+ function isAuthRefusedError(err) {
649
+ let cur = err;
650
+ for (let depth = 0; cur != null && depth < 6; depth += 1) {
651
+ if (typeof cur !== "object") {
652
+ break;
653
+ }
654
+ const code = cur.code;
655
+ if (code === 4 || code === 5 || code === 134 || code === 135) {
656
+ return true;
657
+ }
658
+ const message = cur.message;
659
+ if (typeof message === "string" && /not authorized|bad user ?name|bad username or password|bad password/i.test(message)) {
660
+ return true;
661
+ }
662
+ cur = cur.cause;
663
+ }
664
+ return false;
665
+ }
644
666
  var DreamePush = class extends TypedEmitter {
645
667
  #device;
646
668
  #session;
@@ -649,11 +671,15 @@ var DreamePush = class extends TypedEmitter {
649
671
  #backoffMs;
650
672
  #keepaliveSeconds;
651
673
  #rejectUnauthorized;
674
+ #onAuthFailure;
675
+ #maxAuthRetries;
652
676
  #topic;
653
677
  #client = null;
654
678
  #closed = false;
655
679
  #tearingDown = false;
656
680
  #reconnectTimer = null;
681
+ /** Consecutive auth-refused re-auths since the last successful connect. */
682
+ #authFailureCount = 0;
657
683
  /**
658
684
  * Per-instance bound close handler. Stored so we can pass the EXACT same
659
685
  * function reference to both `client.on('close', …)` and
@@ -670,6 +696,8 @@ var DreamePush = class extends TypedEmitter {
670
696
  this.#backoffMs = input.reconnectBackoffMs ?? 5e3;
671
697
  this.#keepaliveSeconds = input.keepaliveSeconds ?? 60;
672
698
  this.#rejectUnauthorized = input.rejectUnauthorized ?? false;
699
+ this.#onAuthFailure = input.onAuthFailure ?? null;
700
+ this.#maxAuthRetries = input.maxAuthRetries ?? 3;
673
701
  this.#topic = buildStatusTopic(this.#device, this.#session.uid, this.#region);
674
702
  this.#onClose = () => {
675
703
  if (this.#tearingDown) {
@@ -706,7 +734,11 @@ var DreamePush = class extends TypedEmitter {
706
734
  this.#reconnectTimer = null;
707
735
  }
708
736
  await this.#teardownClient();
709
- await this.#connectAndSubscribe();
737
+ try {
738
+ await this.#connectAndSubscribe();
739
+ } catch {
740
+ this.#scheduleReconnect();
741
+ }
710
742
  }
711
743
  /** Tear down permanently. Closed subscriptions cannot be reopened. */
712
744
  async close() {
@@ -743,6 +775,7 @@ var DreamePush = class extends TypedEmitter {
743
775
  reject(new DreameTransportError(`mqtt subscribe failed: ${err.message}`, err));
744
776
  return;
745
777
  }
778
+ this.#authFailureCount = 0;
746
779
  this.emit("connect");
747
780
  resolve();
748
781
  });
@@ -776,12 +809,55 @@ var DreamePush = class extends TypedEmitter {
776
809
  if (this.#closed || this.#client) {
777
810
  return;
778
811
  }
779
- void this.#connectAndSubscribe().catch((err) => {
780
- this.emit("error", err instanceof Error ? err : new DreameTransportError(String(err)));
781
- this.#scheduleReconnect();
782
- });
812
+ void this.#attemptReconnect();
783
813
  }, this.#backoffMs);
784
814
  }
815
+ /**
816
+ * One reconnect attempt with reactive auth self-heal. If the broker refuses
817
+ * the CONNECT with an auth error (a stale access token) and an
818
+ * `onAuthFailure` provider is wired and the re-auth budget is not exhausted,
819
+ * mint a fresh session and retry with the new token instead of replaying the
820
+ * old one. Any other failure (or an exhausted budget) falls back to the plain
821
+ * backoff reconnect so bad credentials cannot hot-loop re-login.
822
+ */
823
+ async #attemptReconnect() {
824
+ try {
825
+ await this.#connectAndSubscribe();
826
+ } catch (err) {
827
+ if (this.#closed) {
828
+ return;
829
+ }
830
+ if (this.#onAuthFailure && isAuthRefusedError(err) && this.#authFailureCount < this.#maxAuthRetries) {
831
+ await this.#reauthenticateAndReconnect();
832
+ return;
833
+ }
834
+ this.emit("error", err instanceof Error ? err : new DreameTransportError(String(err)));
835
+ this.#scheduleReconnect();
836
+ }
837
+ }
838
+ /** Mint a fresh session via `onAuthFailure`, adopt its token, and reconnect. */
839
+ async #reauthenticateAndReconnect() {
840
+ this.#authFailureCount += 1;
841
+ let session;
842
+ try {
843
+ session = await this.#onAuthFailure();
844
+ } catch (err) {
845
+ this.emit("error", err instanceof Error ? err : new DreameTransportError(String(err)));
846
+ if (!this.#client) {
847
+ this.#scheduleReconnect();
848
+ }
849
+ return;
850
+ }
851
+ if (this.#closed) {
852
+ return;
853
+ }
854
+ this.#session = session;
855
+ this.#topic = buildStatusTopic(this.#device, this.#session.uid, this.#region);
856
+ if (this.#client) {
857
+ return;
858
+ }
859
+ void this.#attemptReconnect();
860
+ }
785
861
  async #teardownClient() {
786
862
  const client = this.#client;
787
863
  this.#client = null;
@@ -957,7 +1033,12 @@ function resolveCapabilities(model) {
957
1033
  // src/device/base-device.ts
958
1034
  function defaultBaseDeviceDeps() {
959
1035
  return {
960
- createPush: (device, session, region) => new DreamePush({ device, session, region }),
1036
+ createPush: (device, session, region, onAuthFailure) => new DreamePush({
1037
+ device,
1038
+ session,
1039
+ region,
1040
+ ...onAuthFailure !== void 0 ? { onAuthFailure } : {}
1041
+ }),
961
1042
  getProperties: (base, props) => getProperties(base, props),
962
1043
  getCachedProperties: (base, props) => getCachedProperties(base, props),
963
1044
  setProperties: (base, writes) => setProperties(base, writes),
@@ -969,6 +1050,7 @@ var BaseDevice = class extends TypedEmitter {
969
1050
  #device;
970
1051
  #region;
971
1052
  #sessionRef;
1053
+ #onAuthFailure;
972
1054
  #deps;
973
1055
  #fetchInitial;
974
1056
  #initialProps;
@@ -984,6 +1066,7 @@ var BaseDevice = class extends TypedEmitter {
984
1066
  this.#device = input.device;
985
1067
  this.#region = input.region;
986
1068
  this.#sessionRef = input.sessionRef;
1069
+ this.#onAuthFailure = input.onAuthFailure;
987
1070
  this.#deps = input.deps ?? defaultBaseDeviceDeps();
988
1071
  this.#fetchInitial = input.fetchInitialValues ?? true;
989
1072
  this.#initialProps = input.initialProps ?? [];
@@ -1023,7 +1106,12 @@ var BaseDevice = class extends TypedEmitter {
1023
1106
  }
1024
1107
  /** Open the push, wire events, optionally seed the cache. */
1025
1108
  async start() {
1026
- const push = this.#deps.createPush(this.#device, this.#sessionRef(), this.#region);
1109
+ const push = this.#deps.createPush(
1110
+ this.#device,
1111
+ this.#sessionRef(),
1112
+ this.#region,
1113
+ this.#onAuthFailure
1114
+ );
1027
1115
  this.#push = push;
1028
1116
  push.on("properties", (changes) => this.#onProperties(changes));
1029
1117
  push.on(
@@ -4863,11 +4951,15 @@ var DEFAULT_HEIGHT = 1200;
4863
4951
  var DEFAULT_PADDING = 50;
4864
4952
  var BACKGROUND = "#f5f5f0";
4865
4953
  var MAP_BOUNDARY = "#006400";
4866
- var MOWING_PATH = "#ffa500";
4954
+ var MOWING_PATH = "#32cd32";
4867
4955
  var NAV_PATH = "#b4b4b4";
4868
4956
  var OBSTACLE_STROKE = "#ff4d00";
4869
4957
  var OBSTACLE_FILL = "#ff4d0065";
4870
4958
  var TEXT_COLOR = "#000000";
4959
+ var ZONE_LABEL_COLOR = "#3c3c3c";
4960
+ function escapeXml(s) {
4961
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
4962
+ }
4871
4963
  var ZONE_COLORS = [
4872
4964
  ["#a4d291c8", "#86be73"],
4873
4965
  ["#a0c8dcc8", "#82aac8"],
@@ -4990,7 +5082,6 @@ function renderMowerSvg(map, opts = {}) {
4990
5082
  return lines.join("\n");
4991
5083
  }
4992
5084
  const bounds = calculateBounds(allPoints);
4993
- const multiZone = map.zones.length > 1;
4994
5085
  for (const navPath of map.paths) {
4995
5086
  const dashed = svgPathFromSegments(
4996
5087
  [navPath.path],
@@ -5007,9 +5098,6 @@ function renderMowerSvg(map, opts = {}) {
5007
5098
  }
5008
5099
  }
5009
5100
  map.zones.forEach((zone, i) => {
5010
- if (!multiZone) {
5011
- return;
5012
- }
5013
5101
  const palette = ZONE_COLORS[i % ZONE_COLORS.length];
5014
5102
  const [fill, outline] = palette ?? ZONE_COLORS[0] ?? ["#cccccc", "#888888"];
5015
5103
  const poly = svgPolygon(zone.path, bounds, width, height, padding, fill, outline);
@@ -5018,7 +5106,7 @@ function renderMowerSvg(map, opts = {}) {
5018
5106
  }
5019
5107
  });
5020
5108
  map.zones.forEach((zone, i) => {
5021
- const outline = multiZone ? ZONE_COLORS[i % ZONE_COLORS.length]?.[1] ?? MAP_BOUNDARY : MAP_BOUNDARY;
5109
+ const outline = ZONE_COLORS[i % ZONE_COLORS.length]?.[1] ?? MAP_BOUNDARY;
5022
5110
  const boundary = svgPathFromSegments(
5023
5111
  [zone.path],
5024
5112
  bounds,
@@ -5062,6 +5150,22 @@ function renderMowerSvg(map, opts = {}) {
5062
5150
  lines.push(poly);
5063
5151
  }
5064
5152
  }
5153
+ for (const zone of map.zones) {
5154
+ if (zone.name.length === 0 || zone.path.length === 0) {
5155
+ continue;
5156
+ }
5157
+ let sumX = 0;
5158
+ let sumY = 0;
5159
+ for (const p of zone.path) {
5160
+ sumX += p.x;
5161
+ sumY += p.y;
5162
+ }
5163
+ const centroid = { x: sumX / zone.path.length, y: sumY / zone.path.length };
5164
+ const { px, py } = coordToPixel(centroid, bounds, width, height, padding);
5165
+ lines.push(
5166
+ `<text x="${px}" y="${py}" font-family="Arial, sans-serif" font-size="20" font-weight="bold" fill="${ZONE_LABEL_COLOR}" text-anchor="middle" dominant-baseline="middle">${escapeXml(zone.name)}</text>`
5167
+ );
5168
+ }
5065
5169
  lines.push("</svg>");
5066
5170
  return lines.join("\n");
5067
5171
  }
@@ -5421,6 +5525,7 @@ function defaultDeps(opts) {
5421
5525
  device: args.device,
5422
5526
  region: args.region,
5423
5527
  sessionRef: args.sessionRef,
5528
+ ...args.onAuthFailure !== void 0 ? { onAuthFailure: args.onAuthFailure } : {},
5424
5529
  ...opts.fetchInitialValues !== void 0 ? { fetchInitialValues: opts.fetchInitialValues } : {},
5425
5530
  ...opts.pollIntervalMs !== void 0 ? { pollIntervalMs: opts.pollIntervalMs } : {}
5426
5531
  });
@@ -5434,6 +5539,15 @@ var Nodreame = class extends TypedEmitter {
5434
5539
  #session = null;
5435
5540
  #devices = [];
5436
5541
  #closed = false;
5542
+ /** Background timer that proactively refreshes before the token expires. */
5543
+ #refreshTimer = null;
5544
+ /** Guard against overlapping proactive-refresh runs. */
5545
+ #refreshInFlight = false;
5546
+ /**
5547
+ * The single in-flight refresh, shared by concurrent callers so the proactive
5548
+ * timer and a manual `ensureSession()`/`reauthenticate()` never double-refresh.
5549
+ */
5550
+ #inFlightRefresh = null;
5437
5551
  constructor(opts, deps) {
5438
5552
  super();
5439
5553
  if (!opts.username || !opts.password) {
@@ -5462,6 +5576,7 @@ var Nodreame = class extends TypedEmitter {
5462
5576
  ...this.#opts.lang !== void 0 ? { lang: this.#opts.lang } : {},
5463
5577
  ...this.#opts.fetchImpl !== void 0 ? { fetchImpl: this.#opts.fetchImpl } : {}
5464
5578
  });
5579
+ this.#scheduleProactiveRefresh();
5465
5580
  return this.#session;
5466
5581
  }
5467
5582
  /** Return a valid session, refreshing proactively within the leeway window. */
@@ -5473,18 +5588,61 @@ var Nodreame = class extends TypedEmitter {
5473
5588
  if (Date.now() < current.expiresAt - this.#leewayMs) {
5474
5589
  return current;
5475
5590
  }
5591
+ return this.#refreshNow(current);
5592
+ }
5593
+ /**
5594
+ * Force a session refresh REGARDLESS of the current expiry, then propagate the
5595
+ * new token to every live device push. Wired as each push's `onAuthFailure`:
5596
+ * when a broker refuses a CONNECT because it considers the token stale (even
5597
+ * though {@link ensureSession} still thinks it valid — e.g. server-side early
5598
+ * revocation or clock skew), this mints a genuinely fresh token instead of
5599
+ * replaying the rejected one.
5600
+ */
5601
+ async reauthenticate() {
5602
+ if (this.#closed) {
5603
+ throw new DreameAuthError("client is closed");
5604
+ }
5605
+ const current = this.#session;
5606
+ if (!current) {
5607
+ return this.login();
5608
+ }
5609
+ return this.#refreshNow(current);
5610
+ }
5611
+ /**
5612
+ * Refresh via the refresh-token (with a full re-login fallback) and propagate
5613
+ * the new token to every device push. Shared by {@link ensureSession} (within
5614
+ * the leeway window) and {@link reauthenticate} (unconditional).
5615
+ */
5616
+ async #refreshNow(current) {
5617
+ const existing = this.#inFlightRefresh;
5618
+ if (existing) {
5619
+ return existing;
5620
+ }
5621
+ const run = this.#doRefresh(current);
5622
+ this.#inFlightRefresh = run;
5623
+ try {
5624
+ return await run;
5625
+ } finally {
5626
+ this.#inFlightRefresh = null;
5627
+ }
5628
+ }
5629
+ async #doRefresh(current) {
5476
5630
  if (current.refreshToken) {
5631
+ let next = null;
5477
5632
  try {
5478
- const next = await this.#deps.refresh({
5633
+ next = await this.#deps.refresh({
5479
5634
  refreshToken: current.refreshToken,
5480
5635
  region: this.#opts.region,
5481
5636
  ...this.#opts.country !== void 0 ? { country: this.#opts.country } : {},
5482
5637
  ...this.#opts.lang !== void 0 ? { lang: this.#opts.lang } : {},
5483
5638
  ...this.#opts.fetchImpl !== void 0 ? { fetchImpl: this.#opts.fetchImpl } : {}
5484
5639
  });
5640
+ } catch {
5641
+ next = null;
5642
+ }
5643
+ if (next) {
5485
5644
  await this.#adoptSession(next);
5486
5645
  return next;
5487
- } catch {
5488
5646
  }
5489
5647
  }
5490
5648
  const fresh = await this.login();
@@ -5506,23 +5664,30 @@ var Nodreame = class extends TypedEmitter {
5506
5664
  (device) => this.#deps.createDevice({
5507
5665
  device,
5508
5666
  region: this.#opts.region,
5509
- sessionRef: () => this.#requireSession()
5667
+ sessionRef: () => this.#requireSession(),
5668
+ onAuthFailure: () => this.reauthenticate()
5510
5669
  })
5511
5670
  );
5512
5671
  for (const h of handles) {
5513
5672
  h.on("stateChanged", (e) => this.emit("stateChanged", e));
5514
5673
  h.on("event", (e) => this.emit("event", e));
5515
- h.on("error", (err) => this.emit("error", err));
5674
+ h.on("error", (err) => {
5675
+ if (this.listenerCount("error") > 0) {
5676
+ this.emit("error", err);
5677
+ }
5678
+ });
5516
5679
  await h.start();
5517
5680
  }
5518
5681
  const previous = this.#devices;
5519
5682
  this.#devices = [...handles];
5520
5683
  await Promise.all(previous.map((d) => d.close()));
5684
+ this.#scheduleProactiveRefresh();
5521
5685
  return this.#devices;
5522
5686
  }
5523
5687
  /** Tear everything down: close every device push and clear timers. */
5524
5688
  async close() {
5525
5689
  this.#closed = true;
5690
+ this.#clearRefreshTimer();
5526
5691
  const devices = this.#devices;
5527
5692
  this.#devices = [];
5528
5693
  await Promise.all(devices.map((d) => d.close()));
@@ -5537,13 +5702,65 @@ var Nodreame = class extends TypedEmitter {
5537
5702
  async #adoptSession(session) {
5538
5703
  this.#session = session;
5539
5704
  await this.#propagateSession(session);
5705
+ this.#scheduleProactiveRefresh();
5706
+ }
5707
+ #clearRefreshTimer() {
5708
+ if (this.#refreshTimer) {
5709
+ clearTimeout(this.#refreshTimer);
5710
+ this.#refreshTimer = null;
5711
+ }
5712
+ }
5713
+ /** Arm the proactive-refresh timer `delayMs` from now (clamped to ≥0). */
5714
+ #armRefreshTimer(delayMs) {
5715
+ this.#clearRefreshTimer();
5716
+ if (this.#closed) {
5717
+ return;
5718
+ }
5719
+ this.#refreshTimer = setTimeout(() => {
5720
+ this.#refreshTimer = null;
5721
+ void this.#runProactiveRefresh();
5722
+ }, Math.max(0, delayMs));
5723
+ }
5724
+ /** (Re)arm the proactive-refresh timer against the current session's expiry. */
5725
+ #scheduleProactiveRefresh() {
5726
+ const session = this.#session;
5727
+ if (!session) {
5728
+ this.#clearRefreshTimer();
5729
+ return;
5730
+ }
5731
+ this.#armRefreshTimer(session.expiresAt - this.#leewayMs - Date.now());
5732
+ }
5733
+ /**
5734
+ * Timer body: refresh the session before it expires, then reschedule against
5735
+ * the new expiry. Because {@link ensureSession} propagates the refreshed token
5736
+ * to every device push, each MQTT connection is re-keyed BEFORE the broker
5737
+ * would reject the old one — the reactive re-auth loop never even starts.
5738
+ */
5739
+ async #runProactiveRefresh() {
5740
+ if (this.#closed || this.#refreshInFlight) {
5741
+ return;
5742
+ }
5743
+ this.#refreshInFlight = true;
5744
+ try {
5745
+ await this.ensureSession();
5746
+ this.#refreshInFlight = false;
5747
+ this.#scheduleProactiveRefresh();
5748
+ } catch (err) {
5749
+ this.#refreshInFlight = false;
5750
+ if (this.listenerCount("error") > 0) {
5751
+ this.emit("error", err instanceof Error ? err : new Error(String(err)));
5752
+ }
5753
+ if (!this.#closed) {
5754
+ this.#armRefreshTimer(this.#leewayMs);
5755
+ }
5756
+ }
5540
5757
  }
5541
5758
  /** Push the refreshed token to every live device push. */
5542
5759
  async #propagateSession(session) {
5543
5760
  if (this.#closed) {
5544
5761
  return;
5545
5762
  }
5546
- await Promise.all(this.#devices.map((d) => d.applySession(session)));
5763
+ await Promise.allSettled(this.#devices.map((d) => d.applySession(session)));
5547
5764
  }
5548
5765
  };
5549
5766
 
@@ -6045,6 +6262,7 @@ export {
6045
6262
  extractMowerConsumableValues,
6046
6263
  getMowerCapabilities,
6047
6264
  getVacuumCapabilities,
6265
+ isAuthRefusedError,
6048
6266
  isDreameConsumableKey,
6049
6267
  mowerConsumableIndex,
6050
6268
  mowerFaultSeverity,