@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.cjs CHANGED
@@ -73,6 +73,7 @@ __export(index_exports, {
73
73
  extractMowerConsumableValues: () => extractMowerConsumableValues,
74
74
  getMowerCapabilities: () => getMowerCapabilities,
75
75
  getVacuumCapabilities: () => getVacuumCapabilities,
76
+ isAuthRefusedError: () => isAuthRefusedError,
76
77
  isDreameConsumableKey: () => isDreameConsumableKey,
77
78
  mowerConsumableIndex: () => mowerConsumableIndex,
78
79
  mowerFaultSeverity: () => mowerFaultSeverity,
@@ -88,7 +89,7 @@ module.exports = __toCommonJS(index_exports);
88
89
 
89
90
  // src/support/version.ts
90
91
  var LIBRARY_NAME = "nodedreame";
91
- var LIBRARY_VERSION = "1.11.9";
92
+ var LIBRARY_VERSION = "1.11.10";
92
93
 
93
94
  // src/transport/errors.ts
94
95
  var DreameError = class extends Error {
@@ -717,6 +718,10 @@ var TypedEmitter = class {
717
718
  emit(event, ...args) {
718
719
  return this.#emitter.emit(event, ...args);
719
720
  }
721
+ /** Number of listeners registered for `event`. */
722
+ listenerCount(event) {
723
+ return this.#emitter.listenerCount(event);
724
+ }
720
725
  removeAllListeners() {
721
726
  this.#emitter.removeAllListeners();
722
727
  return this;
@@ -729,6 +734,24 @@ function defaultConnect(url, opts) {
729
734
  const typed = client;
730
735
  return typed;
731
736
  }
737
+ function isAuthRefusedError(err) {
738
+ let cur = err;
739
+ for (let depth = 0; cur != null && depth < 6; depth += 1) {
740
+ if (typeof cur !== "object") {
741
+ break;
742
+ }
743
+ const code = cur.code;
744
+ if (code === 4 || code === 5 || code === 134 || code === 135) {
745
+ return true;
746
+ }
747
+ const message = cur.message;
748
+ if (typeof message === "string" && /not authorized|bad user ?name|bad username or password|bad password/i.test(message)) {
749
+ return true;
750
+ }
751
+ cur = cur.cause;
752
+ }
753
+ return false;
754
+ }
732
755
  var DreamePush = class extends TypedEmitter {
733
756
  #device;
734
757
  #session;
@@ -737,11 +760,15 @@ var DreamePush = class extends TypedEmitter {
737
760
  #backoffMs;
738
761
  #keepaliveSeconds;
739
762
  #rejectUnauthorized;
763
+ #onAuthFailure;
764
+ #maxAuthRetries;
740
765
  #topic;
741
766
  #client = null;
742
767
  #closed = false;
743
768
  #tearingDown = false;
744
769
  #reconnectTimer = null;
770
+ /** Consecutive auth-refused re-auths since the last successful connect. */
771
+ #authFailureCount = 0;
745
772
  /**
746
773
  * Per-instance bound close handler. Stored so we can pass the EXACT same
747
774
  * function reference to both `client.on('close', …)` and
@@ -758,6 +785,8 @@ var DreamePush = class extends TypedEmitter {
758
785
  this.#backoffMs = input.reconnectBackoffMs ?? 5e3;
759
786
  this.#keepaliveSeconds = input.keepaliveSeconds ?? 60;
760
787
  this.#rejectUnauthorized = input.rejectUnauthorized ?? false;
788
+ this.#onAuthFailure = input.onAuthFailure ?? null;
789
+ this.#maxAuthRetries = input.maxAuthRetries ?? 3;
761
790
  this.#topic = buildStatusTopic(this.#device, this.#session.uid, this.#region);
762
791
  this.#onClose = () => {
763
792
  if (this.#tearingDown) {
@@ -794,7 +823,11 @@ var DreamePush = class extends TypedEmitter {
794
823
  this.#reconnectTimer = null;
795
824
  }
796
825
  await this.#teardownClient();
797
- await this.#connectAndSubscribe();
826
+ try {
827
+ await this.#connectAndSubscribe();
828
+ } catch {
829
+ this.#scheduleReconnect();
830
+ }
798
831
  }
799
832
  /** Tear down permanently. Closed subscriptions cannot be reopened. */
800
833
  async close() {
@@ -831,6 +864,7 @@ var DreamePush = class extends TypedEmitter {
831
864
  reject(new DreameTransportError(`mqtt subscribe failed: ${err.message}`, err));
832
865
  return;
833
866
  }
867
+ this.#authFailureCount = 0;
834
868
  this.emit("connect");
835
869
  resolve();
836
870
  });
@@ -864,12 +898,55 @@ var DreamePush = class extends TypedEmitter {
864
898
  if (this.#closed || this.#client) {
865
899
  return;
866
900
  }
867
- void this.#connectAndSubscribe().catch((err) => {
868
- this.emit("error", err instanceof Error ? err : new DreameTransportError(String(err)));
869
- this.#scheduleReconnect();
870
- });
901
+ void this.#attemptReconnect();
871
902
  }, this.#backoffMs);
872
903
  }
904
+ /**
905
+ * One reconnect attempt with reactive auth self-heal. If the broker refuses
906
+ * the CONNECT with an auth error (a stale access token) and an
907
+ * `onAuthFailure` provider is wired and the re-auth budget is not exhausted,
908
+ * mint a fresh session and retry with the new token instead of replaying the
909
+ * old one. Any other failure (or an exhausted budget) falls back to the plain
910
+ * backoff reconnect so bad credentials cannot hot-loop re-login.
911
+ */
912
+ async #attemptReconnect() {
913
+ try {
914
+ await this.#connectAndSubscribe();
915
+ } catch (err) {
916
+ if (this.#closed) {
917
+ return;
918
+ }
919
+ if (this.#onAuthFailure && isAuthRefusedError(err) && this.#authFailureCount < this.#maxAuthRetries) {
920
+ await this.#reauthenticateAndReconnect();
921
+ return;
922
+ }
923
+ this.emit("error", err instanceof Error ? err : new DreameTransportError(String(err)));
924
+ this.#scheduleReconnect();
925
+ }
926
+ }
927
+ /** Mint a fresh session via `onAuthFailure`, adopt its token, and reconnect. */
928
+ async #reauthenticateAndReconnect() {
929
+ this.#authFailureCount += 1;
930
+ let session;
931
+ try {
932
+ session = await this.#onAuthFailure();
933
+ } catch (err) {
934
+ this.emit("error", err instanceof Error ? err : new DreameTransportError(String(err)));
935
+ if (!this.#client) {
936
+ this.#scheduleReconnect();
937
+ }
938
+ return;
939
+ }
940
+ if (this.#closed) {
941
+ return;
942
+ }
943
+ this.#session = session;
944
+ this.#topic = buildStatusTopic(this.#device, this.#session.uid, this.#region);
945
+ if (this.#client) {
946
+ return;
947
+ }
948
+ void this.#attemptReconnect();
949
+ }
873
950
  async #teardownClient() {
874
951
  const client = this.#client;
875
952
  this.#client = null;
@@ -1045,7 +1122,12 @@ function resolveCapabilities(model) {
1045
1122
  // src/device/base-device.ts
1046
1123
  function defaultBaseDeviceDeps() {
1047
1124
  return {
1048
- createPush: (device, session, region) => new DreamePush({ device, session, region }),
1125
+ createPush: (device, session, region, onAuthFailure) => new DreamePush({
1126
+ device,
1127
+ session,
1128
+ region,
1129
+ ...onAuthFailure !== void 0 ? { onAuthFailure } : {}
1130
+ }),
1049
1131
  getProperties: (base, props) => getProperties(base, props),
1050
1132
  getCachedProperties: (base, props) => getCachedProperties(base, props),
1051
1133
  setProperties: (base, writes) => setProperties(base, writes),
@@ -1057,6 +1139,7 @@ var BaseDevice = class extends TypedEmitter {
1057
1139
  #device;
1058
1140
  #region;
1059
1141
  #sessionRef;
1142
+ #onAuthFailure;
1060
1143
  #deps;
1061
1144
  #fetchInitial;
1062
1145
  #initialProps;
@@ -1072,6 +1155,7 @@ var BaseDevice = class extends TypedEmitter {
1072
1155
  this.#device = input.device;
1073
1156
  this.#region = input.region;
1074
1157
  this.#sessionRef = input.sessionRef;
1158
+ this.#onAuthFailure = input.onAuthFailure;
1075
1159
  this.#deps = input.deps ?? defaultBaseDeviceDeps();
1076
1160
  this.#fetchInitial = input.fetchInitialValues ?? true;
1077
1161
  this.#initialProps = input.initialProps ?? [];
@@ -1111,7 +1195,12 @@ var BaseDevice = class extends TypedEmitter {
1111
1195
  }
1112
1196
  /** Open the push, wire events, optionally seed the cache. */
1113
1197
  async start() {
1114
- const push = this.#deps.createPush(this.#device, this.#sessionRef(), this.#region);
1198
+ const push = this.#deps.createPush(
1199
+ this.#device,
1200
+ this.#sessionRef(),
1201
+ this.#region,
1202
+ this.#onAuthFailure
1203
+ );
1115
1204
  this.#push = push;
1116
1205
  push.on("properties", (changes) => this.#onProperties(changes));
1117
1206
  push.on(
@@ -4951,11 +5040,15 @@ var DEFAULT_HEIGHT = 1200;
4951
5040
  var DEFAULT_PADDING = 50;
4952
5041
  var BACKGROUND = "#f5f5f0";
4953
5042
  var MAP_BOUNDARY = "#006400";
4954
- var MOWING_PATH = "#ffa500";
5043
+ var MOWING_PATH = "#32cd32";
4955
5044
  var NAV_PATH = "#b4b4b4";
4956
5045
  var OBSTACLE_STROKE = "#ff4d00";
4957
5046
  var OBSTACLE_FILL = "#ff4d0065";
4958
5047
  var TEXT_COLOR = "#000000";
5048
+ var ZONE_LABEL_COLOR = "#3c3c3c";
5049
+ function escapeXml(s) {
5050
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
5051
+ }
4959
5052
  var ZONE_COLORS = [
4960
5053
  ["#a4d291c8", "#86be73"],
4961
5054
  ["#a0c8dcc8", "#82aac8"],
@@ -5078,7 +5171,6 @@ function renderMowerSvg(map, opts = {}) {
5078
5171
  return lines.join("\n");
5079
5172
  }
5080
5173
  const bounds = calculateBounds(allPoints);
5081
- const multiZone = map.zones.length > 1;
5082
5174
  for (const navPath of map.paths) {
5083
5175
  const dashed = svgPathFromSegments(
5084
5176
  [navPath.path],
@@ -5095,9 +5187,6 @@ function renderMowerSvg(map, opts = {}) {
5095
5187
  }
5096
5188
  }
5097
5189
  map.zones.forEach((zone, i) => {
5098
- if (!multiZone) {
5099
- return;
5100
- }
5101
5190
  const palette = ZONE_COLORS[i % ZONE_COLORS.length];
5102
5191
  const [fill, outline] = palette ?? ZONE_COLORS[0] ?? ["#cccccc", "#888888"];
5103
5192
  const poly = svgPolygon(zone.path, bounds, width, height, padding, fill, outline);
@@ -5106,7 +5195,7 @@ function renderMowerSvg(map, opts = {}) {
5106
5195
  }
5107
5196
  });
5108
5197
  map.zones.forEach((zone, i) => {
5109
- const outline = multiZone ? ZONE_COLORS[i % ZONE_COLORS.length]?.[1] ?? MAP_BOUNDARY : MAP_BOUNDARY;
5198
+ const outline = ZONE_COLORS[i % ZONE_COLORS.length]?.[1] ?? MAP_BOUNDARY;
5110
5199
  const boundary = svgPathFromSegments(
5111
5200
  [zone.path],
5112
5201
  bounds,
@@ -5150,6 +5239,22 @@ function renderMowerSvg(map, opts = {}) {
5150
5239
  lines.push(poly);
5151
5240
  }
5152
5241
  }
5242
+ for (const zone of map.zones) {
5243
+ if (zone.name.length === 0 || zone.path.length === 0) {
5244
+ continue;
5245
+ }
5246
+ let sumX = 0;
5247
+ let sumY = 0;
5248
+ for (const p of zone.path) {
5249
+ sumX += p.x;
5250
+ sumY += p.y;
5251
+ }
5252
+ const centroid = { x: sumX / zone.path.length, y: sumY / zone.path.length };
5253
+ const { px, py } = coordToPixel(centroid, bounds, width, height, padding);
5254
+ lines.push(
5255
+ `<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>`
5256
+ );
5257
+ }
5153
5258
  lines.push("</svg>");
5154
5259
  return lines.join("\n");
5155
5260
  }
@@ -5509,6 +5614,7 @@ function defaultDeps(opts) {
5509
5614
  device: args.device,
5510
5615
  region: args.region,
5511
5616
  sessionRef: args.sessionRef,
5617
+ ...args.onAuthFailure !== void 0 ? { onAuthFailure: args.onAuthFailure } : {},
5512
5618
  ...opts.fetchInitialValues !== void 0 ? { fetchInitialValues: opts.fetchInitialValues } : {},
5513
5619
  ...opts.pollIntervalMs !== void 0 ? { pollIntervalMs: opts.pollIntervalMs } : {}
5514
5620
  });
@@ -5522,6 +5628,15 @@ var Nodreame = class extends TypedEmitter {
5522
5628
  #session = null;
5523
5629
  #devices = [];
5524
5630
  #closed = false;
5631
+ /** Background timer that proactively refreshes before the token expires. */
5632
+ #refreshTimer = null;
5633
+ /** Guard against overlapping proactive-refresh runs. */
5634
+ #refreshInFlight = false;
5635
+ /**
5636
+ * The single in-flight refresh, shared by concurrent callers so the proactive
5637
+ * timer and a manual `ensureSession()`/`reauthenticate()` never double-refresh.
5638
+ */
5639
+ #inFlightRefresh = null;
5525
5640
  constructor(opts, deps) {
5526
5641
  super();
5527
5642
  if (!opts.username || !opts.password) {
@@ -5550,6 +5665,7 @@ var Nodreame = class extends TypedEmitter {
5550
5665
  ...this.#opts.lang !== void 0 ? { lang: this.#opts.lang } : {},
5551
5666
  ...this.#opts.fetchImpl !== void 0 ? { fetchImpl: this.#opts.fetchImpl } : {}
5552
5667
  });
5668
+ this.#scheduleProactiveRefresh();
5553
5669
  return this.#session;
5554
5670
  }
5555
5671
  /** Return a valid session, refreshing proactively within the leeway window. */
@@ -5561,18 +5677,61 @@ var Nodreame = class extends TypedEmitter {
5561
5677
  if (Date.now() < current.expiresAt - this.#leewayMs) {
5562
5678
  return current;
5563
5679
  }
5680
+ return this.#refreshNow(current);
5681
+ }
5682
+ /**
5683
+ * Force a session refresh REGARDLESS of the current expiry, then propagate the
5684
+ * new token to every live device push. Wired as each push's `onAuthFailure`:
5685
+ * when a broker refuses a CONNECT because it considers the token stale (even
5686
+ * though {@link ensureSession} still thinks it valid — e.g. server-side early
5687
+ * revocation or clock skew), this mints a genuinely fresh token instead of
5688
+ * replaying the rejected one.
5689
+ */
5690
+ async reauthenticate() {
5691
+ if (this.#closed) {
5692
+ throw new DreameAuthError("client is closed");
5693
+ }
5694
+ const current = this.#session;
5695
+ if (!current) {
5696
+ return this.login();
5697
+ }
5698
+ return this.#refreshNow(current);
5699
+ }
5700
+ /**
5701
+ * Refresh via the refresh-token (with a full re-login fallback) and propagate
5702
+ * the new token to every device push. Shared by {@link ensureSession} (within
5703
+ * the leeway window) and {@link reauthenticate} (unconditional).
5704
+ */
5705
+ async #refreshNow(current) {
5706
+ const existing = this.#inFlightRefresh;
5707
+ if (existing) {
5708
+ return existing;
5709
+ }
5710
+ const run = this.#doRefresh(current);
5711
+ this.#inFlightRefresh = run;
5712
+ try {
5713
+ return await run;
5714
+ } finally {
5715
+ this.#inFlightRefresh = null;
5716
+ }
5717
+ }
5718
+ async #doRefresh(current) {
5564
5719
  if (current.refreshToken) {
5720
+ let next = null;
5565
5721
  try {
5566
- const next = await this.#deps.refresh({
5722
+ next = await this.#deps.refresh({
5567
5723
  refreshToken: current.refreshToken,
5568
5724
  region: this.#opts.region,
5569
5725
  ...this.#opts.country !== void 0 ? { country: this.#opts.country } : {},
5570
5726
  ...this.#opts.lang !== void 0 ? { lang: this.#opts.lang } : {},
5571
5727
  ...this.#opts.fetchImpl !== void 0 ? { fetchImpl: this.#opts.fetchImpl } : {}
5572
5728
  });
5729
+ } catch {
5730
+ next = null;
5731
+ }
5732
+ if (next) {
5573
5733
  await this.#adoptSession(next);
5574
5734
  return next;
5575
- } catch {
5576
5735
  }
5577
5736
  }
5578
5737
  const fresh = await this.login();
@@ -5594,23 +5753,30 @@ var Nodreame = class extends TypedEmitter {
5594
5753
  (device) => this.#deps.createDevice({
5595
5754
  device,
5596
5755
  region: this.#opts.region,
5597
- sessionRef: () => this.#requireSession()
5756
+ sessionRef: () => this.#requireSession(),
5757
+ onAuthFailure: () => this.reauthenticate()
5598
5758
  })
5599
5759
  );
5600
5760
  for (const h of handles) {
5601
5761
  h.on("stateChanged", (e) => this.emit("stateChanged", e));
5602
5762
  h.on("event", (e) => this.emit("event", e));
5603
- h.on("error", (err) => this.emit("error", err));
5763
+ h.on("error", (err) => {
5764
+ if (this.listenerCount("error") > 0) {
5765
+ this.emit("error", err);
5766
+ }
5767
+ });
5604
5768
  await h.start();
5605
5769
  }
5606
5770
  const previous = this.#devices;
5607
5771
  this.#devices = [...handles];
5608
5772
  await Promise.all(previous.map((d) => d.close()));
5773
+ this.#scheduleProactiveRefresh();
5609
5774
  return this.#devices;
5610
5775
  }
5611
5776
  /** Tear everything down: close every device push and clear timers. */
5612
5777
  async close() {
5613
5778
  this.#closed = true;
5779
+ this.#clearRefreshTimer();
5614
5780
  const devices = this.#devices;
5615
5781
  this.#devices = [];
5616
5782
  await Promise.all(devices.map((d) => d.close()));
@@ -5625,13 +5791,65 @@ var Nodreame = class extends TypedEmitter {
5625
5791
  async #adoptSession(session) {
5626
5792
  this.#session = session;
5627
5793
  await this.#propagateSession(session);
5794
+ this.#scheduleProactiveRefresh();
5795
+ }
5796
+ #clearRefreshTimer() {
5797
+ if (this.#refreshTimer) {
5798
+ clearTimeout(this.#refreshTimer);
5799
+ this.#refreshTimer = null;
5800
+ }
5801
+ }
5802
+ /** Arm the proactive-refresh timer `delayMs` from now (clamped to ≥0). */
5803
+ #armRefreshTimer(delayMs) {
5804
+ this.#clearRefreshTimer();
5805
+ if (this.#closed) {
5806
+ return;
5807
+ }
5808
+ this.#refreshTimer = setTimeout(() => {
5809
+ this.#refreshTimer = null;
5810
+ void this.#runProactiveRefresh();
5811
+ }, Math.max(0, delayMs));
5812
+ }
5813
+ /** (Re)arm the proactive-refresh timer against the current session's expiry. */
5814
+ #scheduleProactiveRefresh() {
5815
+ const session = this.#session;
5816
+ if (!session) {
5817
+ this.#clearRefreshTimer();
5818
+ return;
5819
+ }
5820
+ this.#armRefreshTimer(session.expiresAt - this.#leewayMs - Date.now());
5821
+ }
5822
+ /**
5823
+ * Timer body: refresh the session before it expires, then reschedule against
5824
+ * the new expiry. Because {@link ensureSession} propagates the refreshed token
5825
+ * to every device push, each MQTT connection is re-keyed BEFORE the broker
5826
+ * would reject the old one — the reactive re-auth loop never even starts.
5827
+ */
5828
+ async #runProactiveRefresh() {
5829
+ if (this.#closed || this.#refreshInFlight) {
5830
+ return;
5831
+ }
5832
+ this.#refreshInFlight = true;
5833
+ try {
5834
+ await this.ensureSession();
5835
+ this.#refreshInFlight = false;
5836
+ this.#scheduleProactiveRefresh();
5837
+ } catch (err) {
5838
+ this.#refreshInFlight = false;
5839
+ if (this.listenerCount("error") > 0) {
5840
+ this.emit("error", err instanceof Error ? err : new Error(String(err)));
5841
+ }
5842
+ if (!this.#closed) {
5843
+ this.#armRefreshTimer(this.#leewayMs);
5844
+ }
5845
+ }
5628
5846
  }
5629
5847
  /** Push the refreshed token to every live device push. */
5630
5848
  async #propagateSession(session) {
5631
5849
  if (this.#closed) {
5632
5850
  return;
5633
5851
  }
5634
- await Promise.all(this.#devices.map((d) => d.applySession(session)));
5852
+ await Promise.allSettled(this.#devices.map((d) => d.applySession(session)));
5635
5853
  }
5636
5854
  };
5637
5855
 
@@ -6134,6 +6352,7 @@ function createClientDumper(client, options) {
6134
6352
  extractMowerConsumableValues,
6135
6353
  getMowerCapabilities,
6136
6354
  getVacuumCapabilities,
6355
+ isAuthRefusedError,
6137
6356
  isDreameConsumableKey,
6138
6357
  mowerConsumableIndex,
6139
6358
  mowerFaultSeverity,