@mega-yfue/eufy-sdk 0.4.0-beta.4 → 0.4.0-beta.6

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.js CHANGED
@@ -2125,6 +2125,10 @@ function solixUserTopics(appName, userId) {
2125
2125
 
2126
2126
  // dist/transport/mqtt/secure-mqtt.js
2127
2127
  var SUBACK_FAILURE = 128;
2128
+ function isRefusal(err) {
2129
+ const granted = err?.packet?.granted;
2130
+ return Array.isArray(granted) && granted.some((g) => typeof g === "number" && (g & SUBACK_FAILURE) !== 0);
2131
+ }
2128
2132
  function isNotAuthorized(err) {
2129
2133
  return err instanceof Error && /connection refused/i.test(err.message) && /not authori[sz]ed/i.test(err.message);
2130
2134
  }
@@ -2227,19 +2231,16 @@ var SecureMqtt = class extends EventEmitter {
2227
2231
  }
2228
2232
  /**
2229
2233
  * Subscribe every inbound leg this device's line uses (see `topics.ts` — one `/res` for most lines,
2230
- * four topics for `eufy_life`).
2234
+ * four topics for `eufy_life` and the clean line).
2231
2235
  *
2232
- * The grants are INSPECTED, not assumed: AWS IoT answers a policy-denied filter with a
2233
- * SUBACK_FAILURE (`0x80`) grant rather than failing the SUBSCRIBE, so subscribing with a credential
2234
- * whose scope doesn't cover the topic looks identical to success and then delivers nothing. A denied
2235
- * topic is reported via `error` naming the credential scope; only an all-denied device throws, so a
2236
- * line that grants its state channel but refuses (say) the OTA leg still works.
2236
+ * The grants are INSPECTED, not assumed: AWS IoT refuses a policy-denied filter with a
2237
+ * {@link SUBACK_FAILURE} grant rather than failing the connection. A denied topic is reported via
2238
+ * `error` naming the credential scope; only an all-denied device throws, so a line that grants its
2239
+ * state channel but refuses (say) a business leg still works.
2237
2240
  */
2238
2241
  async subscribeDevice(device) {
2239
- if (!this.client)
2240
- throw new Error("SecureMqtt not connected");
2241
2242
  const topics = [...subscribeTopics(device)];
2242
- const { denied } = this.partitionGrants(await this.client.subscribeAsync(topics, { qos: 1 }));
2243
+ const { denied } = await this.subscribeEach(topics);
2243
2244
  const scope = this.o.credentials.app_name ?? "default";
2244
2245
  if (denied.length === topics.length) {
2245
2246
  throw new Error(`subscribe ${device.sn}: every topic denied on credential scope "${scope}" \u2014 this line's topics are granted to a different scope (${denied.join(", ")})`);
@@ -2250,25 +2251,43 @@ var SecureMqtt = class extends EventEmitter {
2250
2251
  }
2251
2252
  /**
2252
2253
  * Subscribe to explicit topic filters, returning the topics that were granted. A scope-denied filter
2253
- * comes back with SUBACK_FAILURE rather than an error (AWS IoT quirk), so it is dropped from the result
2254
- * instead of throwing — callers that need every leg check the returned list. Used by lines whose topic
2255
- * vocabulary isn't the eufy `subscribeTopics` shape (e.g. Anker Solix `dt/{app}/{pn}/{sn}`).
2254
+ * is dropped from the result instead of throwing — callers that need every leg check the returned
2255
+ * list. Used by lines whose topic vocabulary isn't the eufy `subscribeTopics` shape (e.g. Anker Solix
2256
+ * `dt/{app}/{pn}/{sn}`).
2256
2257
  */
2257
2258
  async subscribe(topics) {
2258
- if (!this.client)
2259
- throw new Error("SecureMqtt not connected");
2260
- return this.partitionGrants(await this.client.subscribeAsync(topics, { qos: 1 })).granted;
2259
+ return (await this.subscribeEach(topics)).granted;
2261
2260
  }
2262
2261
  /**
2263
- * Split SUBACK grants into granted vs scope-denied topics. AWS IoT marks a policy-denied filter with a
2264
- * SUBACK_FAILURE (`0x80`) grant rather than failing the SUBSCRIBE, so the two subscribe paths share
2265
- * this split and layer their own policy (drop vs report) on top.
2262
+ * Subscribe each filter in a SUBSCRIBE of its own and split the outcome into granted vs refused.
2263
+ *
2264
+ * One request per filter because the MQTT engine treats a SUBACK as all-or-nothing: one refused grant
2265
+ * rejects the whole request, and it forgets EVERY filter of that request for resubscription after a
2266
+ * reconnect — so a batch holding one denied leg would lose the granted legs on the next drop. Alone,
2267
+ * a refused filter costs only itself. A rejection that carries no SUBACK is a transport failure and
2268
+ * is thrown.
2269
+ *
2270
+ * The cost is one SUBSCRIBE and one SUBACK per filter instead of one per device — four for a
2271
+ * four-leg line. They are sent concurrently, so the wall-clock cost is one round trip. Folding them
2272
+ * back into one request brings back the lost resubscription.
2266
2273
  */
2267
- partitionGrants(grants) {
2274
+ async subscribeEach(topics) {
2275
+ const client = this.client;
2276
+ if (!client)
2277
+ throw new Error("SecureMqtt not connected");
2278
+ const outcomes = await Promise.allSettled(topics.map((topic) => client.subscribeAsync(topic, { qos: 1 })));
2268
2279
  const granted = [];
2269
2280
  const denied = [];
2270
- for (const g of grants)
2271
- (g.qos === SUBACK_FAILURE ? denied : granted).push(g.topic);
2281
+ outcomes.forEach((outcome, i) => {
2282
+ const topic = topics[i];
2283
+ if (outcome.status === "fulfilled") {
2284
+ granted.push(topic);
2285
+ return;
2286
+ }
2287
+ if (!isRefusal(outcome.reason))
2288
+ throw outcome.reason;
2289
+ denied.push(topic);
2290
+ });
2272
2291
  return { granted, denied };
2273
2292
  }
2274
2293
  /**
@@ -15159,6 +15178,7 @@ var LOCAL_LOOKUP_PORT = 32108;
15159
15178
  var HEARTBEAT_MS = 5e3;
15160
15179
  var PATH_SILENCE_MS = HEARTBEAT_MS * 3;
15161
15180
  var LOOKUP_RETRY_MS = 1e3;
15181
+ var RECEIVE_BUFFER_BYTES = 4 * 1024 * 1024;
15162
15182
  var CONNECT_TIMEOUT_MS = 15e3;
15163
15183
  var STATION_CHANNEL3 = 255;
15164
15184
  var CMD_GATEWAYINFO = 1100;
@@ -15214,6 +15234,8 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
15214
15234
  connected = false;
15215
15235
  connecting = false;
15216
15236
  closed = false;
15237
+ /** Whether a short receive buffer has been reported, so a reconnect does not repeat the same warning. */
15238
+ receiveBufferReported = false;
15217
15239
  connectAddress;
15218
15240
  seqNumber = 0;
15219
15241
  /**
@@ -15536,6 +15558,26 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
15536
15558
  get isConnected() {
15537
15559
  return this.connected;
15538
15560
  }
15561
+ /**
15562
+ * Ask the OS for {@link RECEIVE_BUFFER_BYTES} on a bound socket, and warn once per session when it grants
15563
+ * less or refuses.
15564
+ *
15565
+ * The request is made here rather than through `createSocket`'s `recvBufferSize`: Node applies that option
15566
+ * inside the bind callback, where a refusal is thrown out of reach of this session and ends the process.
15567
+ */
15568
+ requestReceiveBuffer(socket) {
15569
+ let granted;
15570
+ try {
15571
+ socket.setRecvBufferSize(RECEIVE_BUFFER_BYTES);
15572
+ granted = socket.getRecvBufferSize();
15573
+ } catch {
15574
+ granted = 0;
15575
+ }
15576
+ if (granted >= RECEIVE_BUFFER_BYTES || this.receiveBufferReported)
15577
+ return;
15578
+ this.receiveBufferReported = true;
15579
+ this.logger.warn(`[p2p] ${this.cfg.stationSn} UDP receive buffer below the ${RECEIVE_BUFFER_BYTES} bytes requested` + (granted > 0 ? ` (granted ${granted})` : " (request refused)") + `; live video can lose keyframes. Raise the OS limit (net.core.rmem_max on Linux, kern.ipc.maxsockbuf on BSD) to at least ${RECEIVE_BUFFER_BYTES}.`);
15580
+ }
15539
15581
  /** Open the socket and start the lookup → hole-punch handshake. */
15540
15582
  async connect() {
15541
15583
  if (this.connecting || this.connected)
@@ -15559,6 +15601,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
15559
15601
  socket.setBroadcast(true);
15560
15602
  } catch {
15561
15603
  }
15604
+ this.requestReceiveBuffer(socket);
15562
15605
  resolve();
15563
15606
  });
15564
15607
  });