@mega-yfue/eufy-sdk 0.2.0-beta.20 → 0.2.0-beta.22

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.
@@ -5,7 +5,7 @@
5
5
  * `interface EufyMega` (the typed on/once/off/emit overloads) stays in `eufy-mega.ts` next to the
6
6
  * class — TS declaration merging requires both in the same module.
7
7
  */
8
- import type { MegaClientConfig } from "../transport/http/mega-client.js";
8
+ import type { MegaClientConfig, SessionExpiredError } from "../transport/http/mega-client.js";
9
9
  import type { FcmStore } from "../transport/push/store.js";
10
10
  import type { FfmpegLevel } from "../transport/ffmpeg.js";
11
11
  import type { DeviceEventMap } from "../model/capabilities/index.js";
@@ -375,8 +375,12 @@ export type EufyMegaEventMap = {
375
375
  * token expired. The SDK has already cleared the persisted session, so recovery is a fresh `login()`
376
376
  * (which usually needs 2FA). Distinct from `error`: a session error is emitted ONLY here, not also
377
377
  * on `error`.
378
+ *
379
+ * The error carries the rate: `err.retryAfterMs` is how long the next session replacement is barred
380
+ * for, and `err.contended` says this session is being displaced by another client rather than expiring
381
+ * — which a re-login does not answer. A login made before that wait elapses extends it.
378
382
  */
379
- sessionExpired: [err: Error];
383
+ sessionExpired: [err: SessionExpiredError];
380
384
  error: [err: Error];
381
385
  };
382
386
  /** Event names {@link EufyMega} can emit. */
package/dist/index.js CHANGED
@@ -1092,9 +1092,25 @@ var MegaApiError = class extends Error {
1092
1092
  };
1093
1093
  var OWNER_ONLY_CODE = 20004;
1094
1094
  var SessionExpiredError = class extends Error {
1095
- constructor(message) {
1095
+ /**
1096
+ * How long the next session replacement is barred for, in milliseconds; `0` when nothing bars one now.
1097
+ *
1098
+ * The remainder of the client's own hold-off, which doubles per consecutive replacement and is capped —
1099
+ * and which every replacement extends, whether the client spent it or a login made on this error did.
1100
+ */
1101
+ retryAfterMs;
1102
+ /**
1103
+ * Whether this rejection landed inside that bar — a token replaced recently and rejected again since.
1104
+ *
1105
+ * It says the session is being DISPLACED rather than expiring: something else is signing in on this
1106
+ * account, and replacing the token again only trades one login for another.
1107
+ */
1108
+ contended;
1109
+ constructor(message, opts = {}) {
1096
1110
  super(message);
1097
1111
  this.name = "SessionExpiredError";
1112
+ this.retryAfterMs = opts.retryAfterMs ?? 0;
1113
+ this.contended = opts.contended ?? false;
1098
1114
  }
1099
1115
  };
1100
1116
  var EufyCloudErrorCode = {
@@ -1133,7 +1149,7 @@ var LoginStatus = {
1133
1149
  var REAUTH_HOLD_OFF_MS = 6e4;
1134
1150
  var REAUTH_HOLD_OFF_CAP_MS = 30 * 6e4;
1135
1151
  var REAUTH_STABLE_MS = 10 * 6e4;
1136
- var CONTENDED_SESSION_HINT = "another client may be signed in with the same account and device identity, and each login displaces the other's session; give each client its own openudid";
1152
+ var CONTENDED_SESSION_HINT = "another client signed in on this account keeps displacing this session \u2014 the cloud holds about one session per account and device identity, so each login ends the other's; where the other client is another SDK install, give each its own openudid";
1137
1153
  function tokenRejected(code, msg) {
1138
1154
  return code === EufyCloudErrorCode.SESSION_KICKED || /user_id is empty|invalid[^,]*\btoken\b|\btoken\b[^,]*(expired|error|not exist)|kicked|(?:\btoken\b|\bsession\b)[^,]*does not exist|unauthor/i.test(msg ?? "");
1139
1155
  }
@@ -1183,9 +1199,11 @@ var MegaHttpClient = class {
1183
1199
  loggingIn = false;
1184
1200
  /** The one in-flight re-login every call rejected on the same dead token waits on. */
1185
1201
  reauthAttempt;
1186
- /** Replacements since the held session last proved stable, and when the last one ran — see {@link recoveryDue}. */
1202
+ /** Replacements since the held session last proved stable, and when the last one ran — see {@link holdOffRemainingMs}. */
1187
1203
  recoveries = 0;
1188
1204
  lastRecoveryAt = 0;
1205
+ /** A token of ours has been rejected and not yet replaced — see {@link noteTokenReplacement}. */
1206
+ rejectedTokenPending = false;
1189
1207
  constructor(cfg) {
1190
1208
  this.cfg = {
1191
1209
  appName: "eufy_mega",
@@ -1468,12 +1486,17 @@ var MegaHttpClient = class {
1468
1486
  const tokenFinished = identityError && retry.identity || authed && last?.status === 401 && tokenRejected(last.code, last.msg);
1469
1487
  if (tokenFinished) {
1470
1488
  const reason = `${path} failed (${last?.status}/${last?.code}): ${last?.msg}`;
1489
+ this.rejectedTokenPending = true;
1471
1490
  const recovery = retry.reauth ? "unrecoverable" : await this.recoverRejectedSession(sentToken);
1472
1491
  if (recovery === "retry")
1473
1492
  return this.postSigned(host, path, body, authed, { ...retry, reauth: true }, headerOverrides);
1474
1493
  if (!this.sessionReplacedSince(sentToken))
1475
1494
  this.clearSession();
1476
- throw new SessionExpiredError(recovery === "held-off" ? `${reason} \u2014 ${CONTENDED_SESSION_HINT}` : reason);
1495
+ const contended = recovery === "held-off";
1496
+ throw new SessionExpiredError(contended ? `${reason} \u2014 ${CONTENDED_SESSION_HINT}` : reason, {
1497
+ retryAfterMs: this.holdOffRemainingMs(),
1498
+ contended
1499
+ });
1477
1500
  }
1478
1501
  throw new MegaApiError(`${path} failed (${last?.status}/${last?.code}): ${last?.msg}`, last?.code, last?.status);
1479
1502
  }
@@ -1798,8 +1821,7 @@ var MegaHttpClient = class {
1798
1821
  return "unrecoverable";
1799
1822
  if (!this.recoveryDue())
1800
1823
  return "held-off";
1801
- this.recoveries++;
1802
- this.lastRecoveryAt = Date.now();
1824
+ this.noteTokenReplacement();
1803
1825
  this.clearSession();
1804
1826
  return await this.reauthenticate() ? "retry" : "unrecoverable";
1805
1827
  }
@@ -1814,20 +1836,47 @@ var MegaHttpClient = class {
1814
1836
  * and a caller is told the honest reason instead of being served a fight.
1815
1837
  */
1816
1838
  recoveryDue() {
1817
- if (this.recoveries === 0)
1839
+ const remaining = this.holdOffRemainingMs();
1840
+ if (remaining === 0)
1818
1841
  return true;
1819
- const wait = Math.min(REAUTH_HOLD_OFF_MS * 2 ** (this.recoveries - 1), REAUTH_HOLD_OFF_CAP_MS);
1820
- const waited = Date.now() - this.lastRecoveryAt;
1821
- if (waited >= wait)
1822
- return true;
1823
- this.logger.warn(`[mega] token rejected ${Math.round(waited / 1e3)}s after the last replacement \u2014 holding off ${Math.round(wait / 1e3)}s. ${CONTENDED_SESSION_HINT}`);
1842
+ this.logger.warn(`[mega] token rejected ${Math.round((Date.now() - this.lastRecoveryAt) / 1e3)}s after the last replacement \u2014 holding off ${Math.round(remaining / 1e3)}s more. ${CONTENDED_SESSION_HINT}`);
1824
1843
  return false;
1825
1844
  }
1845
+ /**
1846
+ * How much longer a token replacement must wait, in milliseconds; `0` when one may run now.
1847
+ *
1848
+ * The wait doubles per consecutive replacement and is capped, and it is what {@link recoveryDue} gates
1849
+ * this client's own recovery on — and what {@link SessionExpiredError.retryAfterMs} hands a host that
1850
+ * drives its own. One function so the two cannot disagree about the rate, which they would have to for
1851
+ * a host to be told it may retry while this client is still holding off.
1852
+ */
1853
+ holdOffRemainingMs() {
1854
+ if (this.recoveries === 0)
1855
+ return 0;
1856
+ const wait = Math.min(REAUTH_HOLD_OFF_MS * 2 ** (this.recoveries - 1), REAUTH_HOLD_OFF_CAP_MS);
1857
+ return Math.max(0, wait - (Date.now() - this.lastRecoveryAt));
1858
+ }
1859
+ /**
1860
+ * Count one token replacement against the hold-off, and clear the rejection it answered.
1861
+ *
1862
+ * Every replacement passes through here, wherever it was spent from: {@link recoverRejectedSession}, and
1863
+ * a {@link login} that follows a rejection this client surfaced. A hold-off that counted only its own
1864
+ * would be no bound at all — the wait would sit at its first value however many sessions had been spent,
1865
+ * and {@link SessionExpiredError.retryAfterMs} would report a minute while logins ran every few seconds.
1866
+ * Which of the two counted a given replacement is the flag: the recovery path clears it before logging
1867
+ * in, so the login cannot count the same one again.
1868
+ */
1869
+ noteTokenReplacement() {
1870
+ this.recoveries++;
1871
+ this.lastRecoveryAt = Date.now();
1872
+ this.rejectedTokenPending = false;
1873
+ }
1826
1874
  /**
1827
1875
  * Note that the held session is working. A replacement that keeps serving calls for long enough is not
1828
1876
  * contention, so the hold-off is forgotten and the next genuine expiry recovers immediately.
1829
1877
  */
1830
1878
  noteSessionWorking() {
1879
+ this.rejectedTokenPending = false;
1831
1880
  if (this.recoveries > 0 && Date.now() - this.lastRecoveryAt > REAUTH_STABLE_MS)
1832
1881
  this.recoveries = 0;
1833
1882
  }
@@ -1944,6 +1993,8 @@ var MegaHttpClient = class {
1944
1993
  this.sessionKey = void 0;
1945
1994
  await this.ensureSessionKey();
1946
1995
  this.persist();
1996
+ if (this.rejectedTokenPending)
1997
+ this.noteTokenReplacement();
1947
1998
  return { status: LoginStatus.Ok, session: { userId, authToken, geoKey: this.auth_.geoKey, raw: res } };
1948
1999
  }
1949
2000
  /** Save the current token + session key for reuse across runs. */
@@ -3179,7 +3230,14 @@ function describeBound(modules, bound, ctx) {
3179
3230
  else
3180
3231
  undescribedActions.push(name);
3181
3232
  }
3182
- out.push({ capability: m.capability, accessor, reads: reads2, actions, undescribedActions, events: emitsOf(m) });
3233
+ out.push({
3234
+ capability: m.capability,
3235
+ accessor,
3236
+ reads: reads2,
3237
+ actions,
3238
+ undescribedActions,
3239
+ events: emitsOf(m, reads2, ctx)
3240
+ });
3183
3241
  }
3184
3242
  return out;
3185
3243
  }
@@ -3198,8 +3256,22 @@ function readDescriptor(name, m, descriptors, ctx) {
3198
3256
  description: m.description
3199
3257
  };
3200
3258
  }
3201
- function emitsOf(m) {
3202
- return [.../* @__PURE__ */ new Set([...(m.events ?? []).map((e) => e.emit), ...m.emits ?? []])];
3259
+ function emitsOf(m, reads2, ctx) {
3260
+ const installed = new Set(reads2.map((r) => r.accessor));
3261
+ const claimed = (m.events ?? []).filter((e) => holds(e.claim, installed, ctx)).map((e) => e.emit);
3262
+ return [.../* @__PURE__ */ new Set([...claimed, ...m.emits ?? []])];
3263
+ }
3264
+ function holds(claim, installed, ctx) {
3265
+ if (!claim)
3266
+ return true;
3267
+ if (claim.codecs && (ctx?.codec === void 0 || !claim.codecs.includes(ctx.codec)))
3268
+ return false;
3269
+ if (claim.reads?.some((name) => !installed.has(name)))
3270
+ return false;
3271
+ if (claim.homeBaseAttached !== void 0 && ctx?.homeBaseAttached !== void 0) {
3272
+ return ctx.homeBaseAttached === claim.homeBaseAttached;
3273
+ }
3274
+ return true;
3203
3275
  }
3204
3276
 
3205
3277
  // dist/model/capabilities/video.js
@@ -3704,6 +3776,9 @@ var AiDetectType = {
3704
3776
  vehicle: 4,
3705
3777
  pet: 8
3706
3778
  };
3779
+ var CAMERA_AI_CLAIM = { codecs: ["camera"] };
3780
+ var VEHICLE_CLAIM = { ...CAMERA_AI_CLAIM, reads: ["aiDetectType"] };
3781
+ var DOG_CLAIM = { ...CAMERA_AI_CLAIM, homeBaseAttached: true };
3707
3782
  var SENSITIVITY_SCALES = [
3708
3783
  /** Standalone PIR sensor: five steps counting DOWN. */
3709
3784
  {
@@ -4095,21 +4170,24 @@ var MOTION = {
4095
4170
  events: [
4096
4171
  { source: "push", match: DoorbellPushEvent.MOTION_DETECTION, emit: "motion" },
4097
4172
  { source: "push", match: CusPushEvent.MOTION_SENSOR_PIR, emit: "motion" },
4098
- { source: "push", match: IndoorPushEvent.CRYING_DETECTION, emit: "cryingDetected" },
4099
- { source: "push", match: IndoorPushEvent.SOUND_DETECTION, emit: "soundDetected" },
4100
- { source: "push", match: DoorbellPushEvent.VEHICLE_DETECTION, emit: "vehicleDetected" },
4101
- { source: "push", match: HB3PairedDevicePushEvent.DOG_DETECTION, emit: "dogDetected" },
4173
+ { source: "push", match: IndoorPushEvent.CRYING_DETECTION, emit: "cryingDetected", claim: CAMERA_AI_CLAIM },
4174
+ { source: "push", match: IndoorPushEvent.SOUND_DETECTION, emit: "soundDetected", claim: CAMERA_AI_CLAIM },
4175
+ { source: "push", match: IndoorPushEvent.PET_DETECTION, emit: "petDetection", claim: CAMERA_AI_CLAIM },
4176
+ { source: "push", match: DoorbellPushEvent.VEHICLE_DETECTION, emit: "vehicleDetected", claim: VEHICLE_CLAIM },
4177
+ { source: "push", match: HB3PairedDevicePushEvent.DOG_DETECTION, emit: "dogDetected", claim: DOG_CLAIM },
4102
4178
  {
4103
4179
  source: "push",
4104
4180
  match: HB3PairedDevicePushEvent.DOG_LICK_DETECTION,
4105
4181
  emit: "dogDetected",
4106
- payload: { kind: "lick" }
4182
+ payload: { kind: "lick" },
4183
+ claim: DOG_CLAIM
4107
4184
  },
4108
4185
  {
4109
4186
  source: "push",
4110
4187
  match: HB3PairedDevicePushEvent.DOG_POOP_DETECTION,
4111
4188
  emit: "dogDetected",
4112
- payload: { kind: "poop" }
4189
+ payload: { kind: "poop" },
4190
+ claim: DOG_CLAIM
4113
4191
  }
4114
4192
  ]
4115
4193
  };
@@ -6779,10 +6857,16 @@ var DOORBELL = {
6779
6857
  properties: propertiesOf(DOORBELL_MEMBERS),
6780
6858
  /** Doorbells self-report no single unambiguous param; the model name is the reliable signal. */
6781
6859
  detection: { modelHints: [/doorbell/i] },
6782
- /** Inbound FCM doorbell events (`DoorbellPushEvent`): ring press, pet, package delivered/taken. */
6860
+ /**
6861
+ * Inbound FCM doorbell events (`DoorbellPushEvent`): ring press and the package trio.
6862
+ *
6863
+ * Pet (3106) is NOT here. The id is declared identically in the doorbell, indoor and HB3-paired
6864
+ * vocabularies, so it belongs to the camera-wide `motion` module that every camera binds; claiming
6865
+ * it here as well would make it a contested id that a doorbell — which has both capabilities —
6866
+ * matches twice, emitting one push as two events.
6867
+ */
6783
6868
  events: [
6784
6869
  { source: "push", match: DoorbellPushEvent.PRESS_DOORBELL, emit: "doorbellPress" },
6785
- { source: "push", match: DoorbellPushEvent.PET_DETECTION, emit: "petDetection" },
6786
6870
  { source: "push", match: DoorbellPushEvent.PACKAGE_DELIVERED, emit: "packageDelivered" },
6787
6871
  { source: "push", match: DoorbellPushEvent.PACKAGE_TAKEN, emit: "packageTaken" },
6788
6872
  { source: "push", match: DoorbellPushEvent.PACKAGE_STRANDED, emit: "packageStranded" }
@@ -8275,6 +8359,25 @@ var CARPET_STRATEGIES = ["autoRaise", "avoid", "ignore"];
8275
8359
  var CARPET_STRATEGY = { 0: "autoRaise", 1: "avoid", 2: "ignore" };
8276
8360
  var CLEAN_EXTENTS = ["normal", "narrow", "quick"];
8277
8361
  var CLEAN_EXTENT = { 0: "normal", 1: "narrow", 2: "quick" };
8362
+ function cleanParamWireValue(names, name) {
8363
+ const found = Object.entries(names).find(([, candidate]) => candidate === name);
8364
+ if (found === void 0)
8365
+ throw new Error(`clean param: ${name} is not a value this setting takes`);
8366
+ return Number(found[0]);
8367
+ }
8368
+ function writeCleanSetting(p, wrapper, inner, value) {
8369
+ p.sub(wrapper, (w) => {
8370
+ if (value !== 0)
8371
+ w.int(inner, value);
8372
+ });
8373
+ }
8374
+ function encodeCleanParam(cleanType, cleanExtent, mopLevel) {
8375
+ return rawDp((w) => w.sub(CLEAN_PARAM_FIELD.CONFIGURED, (p) => {
8376
+ writeCleanSetting(p, CLEAN_PARAM_FIELD.CLEAN_TYPE, CLEAN_PARAM_FIELD.VALUE, cleanParamWireValue(CLEAN_TYPE, cleanType));
8377
+ writeCleanSetting(p, CLEAN_PARAM_FIELD.CLEAN_EXTENT, CLEAN_PARAM_FIELD.VALUE, cleanParamWireValue(CLEAN_EXTENT, cleanExtent));
8378
+ writeCleanSetting(p, CLEAN_PARAM_FIELD.MOP_MODE, CLEAN_PARAM_FIELD.MOP_LEVEL, cleanParamWireValue(MOP_LEVEL, mopLevel));
8379
+ }));
8380
+ }
8278
8381
  function decodeCleanParamValue(raw, codec, field, inner) {
8279
8382
  if (typeof raw !== "string" || !codec)
8280
8383
  return void 0;
@@ -9954,6 +10057,49 @@ var VACUUM_CLEAN_MEMBERS = {
9954
10057
  * WATCHED: run on a T2351, it started the named scene.
9955
10058
  */
9956
10059
  startScene: method(({ sink }) => (sceneId) => sink.dispatch(aiotDp(VACUUM_DP.MODE_CTRL, encodeSceneClean(sceneId))), "Run a saved cleaning scene by its id (ModeCtrlRequest method 24 over DP 152).", isAiotVacuum),
10060
+ /**
10061
+ * State the cleaning settings a run uses — `CleanParamRequest.clean_param` over DP 154.
10062
+ *
10063
+ * The write counterpart of {@link VACUUM_CLEAN_MEMBERS.cleanType},
10064
+ * {@link VACUUM_CLEAN_MEMBERS.cleanExtent} and {@link VACUUM_CLEAN_MEMBERS.mopLevel}: one message
10065
+ * carries all three, so they are set together rather than through three setters that would each send
10066
+ * the same message with the other two silent.
10067
+ *
10068
+ * **The evidence, and its limit.** The frame is field 1 of `CleanParamRequest`, which carries the
10069
+ * very `CleanParam` this module decodes out of field 1 of the reports a live T2351 sends — the
10070
+ * field numbers, the single-field wrappers and the `mop_mode.level` scale are all read off that
10071
+ * capture, and `encodeCleanParam` writes what `decodeCleanParamValue` reads. What is NOT captured is
10072
+ * the write direction itself. It ships as a method rather than an unverified write because the
10073
+ * hazard that rule answers does not arise here: DP 154 is the robot's own settings report, so a frame
10074
+ * it does not accept leaves those three reads unchanged, where a wrong fire-and-forget command would
10075
+ * look exactly like success.
10076
+ *
10077
+ * Suction is not here. It has its own data point and its own capability — a `fan` field exists in
10078
+ * this message and is deliberately not written, for the same reason it is not read.
10079
+ */
10080
+ setCleanParam: {
10081
+ ...method(({ sink }) => (cleanType, cleanExtent, mopLevel) => sink.dispatch(aiotDp(VACUUM_DP.CLEAN_PARAM, encodeCleanParam(cleanType, cleanExtent, mopLevel))), "Set the cleaning type, extent and mop water level together (CleanParamRequest.clean_param over DP 154).", isAiotVacuum),
10082
+ args: [
10083
+ {
10084
+ name: "cleanType",
10085
+ kind: "enum",
10086
+ values: VACUUM_CLEAN_TYPES,
10087
+ description: "What the robot does with a surface."
10088
+ },
10089
+ {
10090
+ name: "cleanExtent",
10091
+ kind: "enum",
10092
+ values: CLEAN_EXTENTS,
10093
+ description: "How far past the mapped edge a job reaches. Wire order, not app order."
10094
+ },
10095
+ {
10096
+ name: "mopLevel",
10097
+ kind: "enum",
10098
+ values: MOP_LEVELS,
10099
+ description: "How much water the mop lays down. Only meaningful for a clean type that mops."
10100
+ }
10101
+ ]
10102
+ },
9957
10103
  /**
9958
10104
  * Clean the named rooms of a named map (ModeCtrlRequest method 1 over DP 152).
9959
10105
  *
@@ -12992,7 +13138,8 @@ var Device = class _Device {
12992
13138
  details: describeCapabilities(this.actionMap, {
12993
13139
  codec: this.codec,
12994
13140
  model: this.model,
12995
- capabilities: new Set(this.capabilities)
13141
+ capabilities: new Set(this.capabilities),
13142
+ homeBaseAttached: this.stationSn !== this.sn
12996
13143
  })
12997
13144
  };
12998
13145
  }