@mega-yfue/eufy-sdk 0.2.0-beta.10 → 0.2.0-beta.12

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
@@ -2615,16 +2615,6 @@ var StationUnreachableError = class extends Error {
2615
2615
  this.name = "StationUnreachableError";
2616
2616
  }
2617
2617
  };
2618
- var StationBusyError = class extends Error {
2619
- servingChannel;
2620
- /** Always true: the station is busy now, and stops being busy when the other stream is released. */
2621
- retryable = true;
2622
- constructor(servingChannel, options) {
2623
- super(`the station is already serving channel ${servingChannel} to a viewer, and serves one camera at a time \u2014 stop that stream before opening another`, options);
2624
- this.servingChannel = servingChannel;
2625
- this.name = "StationBusyError";
2626
- }
2627
- };
2628
2618
  var LiveStreamStartError = class extends Error {
2629
2619
  reason;
2630
2620
  stage;
@@ -3982,12 +3972,32 @@ function decodePowerSource(raw) {
3982
3972
  function recordSetting(param, value, ctx) {
3983
3973
  return setStationScalar(param, value, ctx.channel);
3984
3974
  }
3985
- var MAINS_CAMERA_MODELS = ["T8425", "T8419"];
3975
+ var MAINS_CAMERA_MODELS = ["T8425", "T8419", "T8410"];
3986
3976
  var notMainsCamera = (ctx) => {
3987
3977
  const model = (ctx.model ?? "").toUpperCase();
3988
3978
  return !MAINS_CAMERA_MODELS.some((prefix) => model.startsWith(prefix));
3989
3979
  };
3990
- var BATTERY_MEMBERS = {
3980
+ var CELL_PARAMS = [
3981
+ BATTERY_PARAM.BATTERY,
3982
+ BATTERY_PARAM.BATTERY_STATUS,
3983
+ BATTERY_PARAM.BATTERY_TEMP,
3984
+ BATTERY_PARAM.BATTERY_HEALTH,
3985
+ BATTERY_PARAM.SOLAR_INTENSITY,
3986
+ BATTERY_PARAM.SOLAR_CONNECT_24H,
3987
+ BATTERY_PARAM.BATTERY_POWER_DATAS
3988
+ ];
3989
+ function cellGated(members) {
3990
+ const gated = Object.entries(members).map(([name, m]) => {
3991
+ const param = m.param;
3992
+ if (typeof param !== "number" || !CELL_PARAMS.includes(param))
3993
+ return [name, m];
3994
+ const own = m.available;
3995
+ const available = own ? (ctx) => notMainsCamera(ctx) && own(ctx) : notMainsCamera;
3996
+ return [name, { ...m, available }];
3997
+ });
3998
+ return Object.fromEntries(gated);
3999
+ }
4000
+ var BATTERY_MEMBERS = cellGated({
3991
4001
  /**
3992
4002
  * The headline percentage, and this capability's detection evidence: reporting 1101 is what proves a
3993
4003
  * device is battery-powered, which is also the fact `MediaProvider` reads to decide a stream needs a
@@ -4000,7 +4010,6 @@ var BATTERY_MEMBERS = {
4000
4010
  unit: "%",
4001
4011
  kind: "percent",
4002
4012
  provenance: "verified",
4003
- available: notMainsCamera,
4004
4013
  description: "Battery level 0-100 (verified: param 1101)."
4005
4014
  },
4006
4015
  /**
@@ -4013,7 +4022,6 @@ var BATTERY_MEMBERS = {
4013
4022
  type: "bool",
4014
4023
  kind: "boolean",
4015
4024
  provenance: "apk",
4016
- available: notMainsCamera,
4017
4025
  coerce: (v) => {
4018
4026
  const n = Number(v);
4019
4027
  return n !== 0 && n !== 2;
@@ -4184,6 +4192,11 @@ var BATTERY_MEMBERS = {
4184
4192
  * Reported, so it stays in the schema and answers through `getProperty` — but given no typed getter:
4185
4193
  * the payload's fields have never been decoded, and a getter would hand back an opaque blob typed as
4186
4194
  * though it meant something.
4195
+ *
4196
+ * `unexposed` is NOT a substitute for the cell gate. It suppresses the fluent GETTER; `propertiesOf`
4197
+ * filters `writeOnly` and `available` and deliberately not `unexposed`, because a schema entry
4198
+ * reachable through `getProperty` is the whole point of the mark. So a cell param needs
4199
+ * {@link CELL_PARAMS} either way, or a mains camera publishes "battery power history" and answers it.
4187
4200
  */
4188
4201
  batteryPowerStats: {
4189
4202
  param: BATTERY_PARAM.BATTERY_POWER_DATAS,
@@ -4209,7 +4222,7 @@ var BATTERY_MEMBERS = {
4209
4222
  unexposed: true,
4210
4223
  description: "Raw GET_CAMERA_INFO value (1103), meaning unevidenced \u2014 read live as the constant 5 on a T8170 at 92% and a T8171 at 27%, so it is NOT a low-battery flag."
4211
4224
  }
4212
- };
4225
+ });
4213
4226
  var BATTERY = {
4214
4227
  capability: "battery",
4215
4228
  description: "Battery level, charging, health, temperature, and solar input.",
@@ -10103,6 +10116,16 @@ function getCapabilityModule(cap) {
10103
10116
  return BY_CAP.get(cap);
10104
10117
  }
10105
10118
  var CAPABILITY_MODULES = Object.fromEntries(MODULES.map((m) => [m.capability, m]));
10119
+ function claimedParams(caps) {
10120
+ const claimed = /* @__PURE__ */ new Set();
10121
+ for (const cap of caps)
10122
+ for (const spec of getCapabilityModule(cap)?.properties ?? []) {
10123
+ claimed.add(spec.paramType);
10124
+ for (const alias of spec.readAliases ?? [])
10125
+ claimed.add(alias.paramType);
10126
+ }
10127
+ return claimed;
10128
+ }
10106
10129
  function mergeProperties(caps, ctx) {
10107
10130
  const seen = /* @__PURE__ */ new Set();
10108
10131
  const merged = [];
@@ -12394,6 +12417,11 @@ var Device = class _Device {
12394
12417
  * different wire ids across device families still resolves to one named value.
12395
12418
  */
12396
12419
  specByParam;
12420
+ /**
12421
+ * Params a resolved capability declares that this device's schema does not carry — withheld by a
12422
+ * member's gate, so not named from the param dictionary either. See {@link Device.applyParams}.
12423
+ */
12424
+ withheld = /* @__PURE__ */ new Set();
12397
12425
  /** Which param namespace this device's ids live in (clean DPs vs security P2P). */
12398
12426
  namespace;
12399
12427
  /** The record's `device_name` as stated, before the {@link modelName} fallback is applied. */
@@ -12460,6 +12488,7 @@ var Device = class _Device {
12460
12488
  byParam.set(a.paramType, { spec: p, invert: a.invert ?? false });
12461
12489
  }
12462
12490
  this.specByParam = byParam;
12491
+ this.withheld = new Set([...claimedParams(resolved.capabilities)].filter((pt) => !byParam.has(pt)));
12463
12492
  this.namespace = namespaceForCodec(resolved.codec);
12464
12493
  this.installAccessors();
12465
12494
  }
@@ -12622,6 +12651,16 @@ var Device = class _Device {
12622
12651
  * Apply a raw param map (cloud record or P2P notification). Known params update their named
12623
12652
  * property; unrecognised params are retained as `unknown_<paramType>` so nothing is lost.
12624
12653
  *
12654
+ * Naming precedence: this device's own `PropertySpec` (curated), then the param dictionary for its
12655
+ * namespace, then the `unknown_<paramType>` passthrough. The dictionary def is consulted even where a
12656
+ * spec exists, because `encoding` lives there.
12657
+ *
12658
+ * A param a resolved capability's gate WITHHELD takes the passthrough instead of its dictionary name.
12659
+ * The gate decided the read does not describe this device, the dictionary names it what the member
12660
+ * would have, and republishing it there hands a caller a reading indistinguishable from one the device
12661
+ * really answered. A capability that never resolved withholds nothing: a param arriving before its
12662
+ * capability is still the device's own, and keeps its dictionary name.
12663
+ *
12625
12664
  * @param params param_type → raw value.
12626
12665
  * @param ts observation time (epoch ms); defaults to `Date.now()`.
12627
12666
  * @returns the list of property names whose value changed.
@@ -12634,7 +12673,7 @@ var Device = class _Device {
12634
12673
  continue;
12635
12674
  const hit = this.specByParam.get(pt);
12636
12675
  const spec = hit?.spec;
12637
- const def = paramDef(this.namespace, pt);
12676
+ const def = this.withheld.has(pt) ? void 0 : paramDef(this.namespace, pt);
12638
12677
  const name = spec ? spec.name : def ? def.name : `${UNKNOWN_PARAM_PREFIX}${pt}`;
12639
12678
  const value = def?.encoding ? decodeEncoded(rawVal, def.encoding) : spec ? coerce(spec, rawVal, hit.invert, this.logger) : def ? coerceByType(def.type, rawVal, def.name, this.logger) : String(rawVal);
12640
12679
  const prev = this.state.get(name);
@@ -16785,8 +16824,9 @@ var LiveStream = class extends EventEmitter3 {
16785
16824
  * Stop re-issuing the media start once this camera's own media has arrived, on an attached camera.
16786
16825
  *
16787
16826
  * The nudge differs by topology and only one branch is a ping: an own-session camera sends a small
16788
- * keepalive, while an attached camera has no such state and re-sends the FULL media start. On a station that
16789
- * serves one camera at a time that restart re-asserts this channel against whatever else is warm, so two
16827
+ * keepalive, while an attached camera has no such state and re-sends the FULL media start. Over one session,
16828
+ * which serves one camera at a time, that restart re-asserts this channel against whatever else is warm on
16829
+ * it, so two
16790
16830
  * attached streams restart every interval and contend for the station continuously — measured on a real base
16791
16831
  * as a full start every 3 s from each.
16792
16832
  *
@@ -16953,7 +16993,7 @@ var LiveStream = class extends EventEmitter3 {
16953
16993
  *
16954
16994
  * The match is UNCONDITIONAL, however long a station serves another camera instead of this one.
16955
16995
  *
16956
- * A station serving one camera at a time hands a newly opened camera nothing but its sibling's frames until
16996
+ * One session serving one camera at a time hands a newly opened camera nothing but its sibling's frames until
16957
16997
  * it switches, so "no media of my own yet, plenty for someone else" is what an ordinary handover looks like
16958
16998
  * and does not distinguish a station that tags differently from one that is simply busy.
16959
16999
  *
@@ -18003,6 +18043,8 @@ var SharedLiveSource = class {
18003
18043
  this.configuredFrom = void 0;
18004
18044
  this.ring = [];
18005
18045
  this._state = state;
18046
+ if (state === "stopped")
18047
+ this.opts.onStopped?.();
18006
18048
  if (startFailed && report)
18007
18049
  this.opts.onStartFailed?.();
18008
18050
  }
@@ -18085,14 +18127,14 @@ var SessionManager = class {
18085
18127
  this.logger = opts.logger ?? noopLogger;
18086
18128
  }
18087
18129
  /** The live session for a station, or `undefined` if not open. */
18088
- get(parentSn) {
18089
- return this.entries.get(parentSn)?.session;
18130
+ get(key) {
18131
+ return this.entries.get(key)?.session;
18090
18132
  }
18091
- /** Serials of stations with a live session. */
18133
+ /** Keys of the open sessions. */
18092
18134
  keys() {
18093
18135
  return [...this.entries].filter(([, e]) => e.session).map(([sn]) => sn);
18094
18136
  }
18095
- /** A plain `Map<parentSn, P2PSession>` snapshot of the live sessions (for `getSessions()` / tests). */
18137
+ /** A plain `Map<key, P2PSession>` snapshot of the open sessions (for `getSessions()` / tests). */
18096
18138
  liveSessions() {
18097
18139
  const m = /* @__PURE__ */ new Map();
18098
18140
  for (const [sn, e] of this.entries)
@@ -18100,56 +18142,77 @@ var SessionManager = class {
18100
18142
  m.set(sn, e.session);
18101
18143
  return m;
18102
18144
  }
18103
- /** Get or create the lifecycle entry for a station. */
18104
- entry(parentSn) {
18105
- let e = this.entries.get(parentSn);
18145
+ /**
18146
+ * Get or create the lifecycle entry under `key`, recording which station it connects to. An existing
18147
+ * entry keeps the station it was opened with — the key owns one connection for its lifetime, and a
18148
+ * later caller passing a different station would otherwise re-point a live entry's power tier.
18149
+ */
18150
+ entry(key, station) {
18151
+ let e = this.entries.get(key);
18106
18152
  if (!e) {
18107
- e = { retained: 0, holdTimers: /* @__PURE__ */ new Set(), idle: new Timer() };
18108
- this.entries.set(parentSn, e);
18153
+ e = { station, retained: 0, holdTimers: /* @__PURE__ */ new Set(), idle: new Timer() };
18154
+ this.entries.set(key, e);
18109
18155
  }
18110
18156
  return e;
18111
18157
  }
18112
- /** Register an already-built session for test seeding or an externally assembled connection. */
18113
- register(parentSn, session) {
18114
- this.entry(parentSn).session = session;
18158
+ /**
18159
+ * Register an already-built session for test seeding or an externally assembled connection.
18160
+ *
18161
+ * `station` defaults to the key, which is safe HERE and nowhere else in this class: the paths that open a
18162
+ * session under a key that is not a station serial all go through {@link acquire}, which requires it. A
18163
+ * caller seeding one under such a key must pass it.
18164
+ */
18165
+ register(key, session, station = key) {
18166
+ this.entry(key, station).session = session;
18115
18167
  }
18116
18168
  /**
18117
- * Ensure a session to `parentSn` is open, building it via `factory` if cold. Concurrent calls for the
18169
+ * Ensure a session to `key` is open, building it via `factory` if cold. Concurrent calls for the
18118
18170
  * same cold station share ONE connect (the `connecting` promise); `factory` builds + wires + awaits
18119
18171
  * `connect()` and resolves the connected session.
18120
18172
  */
18121
- async acquire(parentSn, factory) {
18122
- const e = this.entry(parentSn);
18173
+ async acquire(key, factory, station) {
18174
+ const e = this.entry(key, station);
18123
18175
  if (e.connecting) {
18124
- this.logger.debug(`[session ${parentSn}] connecting \u2014 joining in-flight open`);
18176
+ this.logger.debug(`[session ${key}] connecting \u2014 joining in-flight open`);
18125
18177
  return e.connecting;
18126
18178
  }
18127
18179
  if (e.session)
18128
18180
  return e.session;
18129
- this.logger.debug(`[session ${parentSn}] connecting now (on demand)`);
18181
+ this.logger.debug(`[session ${key}] connecting now (on demand)`);
18130
18182
  const generation = this.generation;
18131
18183
  const p = factory((session) => e.session = session);
18132
18184
  e.connecting = p;
18133
18185
  try {
18134
18186
  const session = await p;
18135
- if (generation !== this.generation || this.entries.get(parentSn) !== e) {
18187
+ if (generation !== this.generation || this.entries.get(key) !== e) {
18136
18188
  await session.close();
18137
- throw new SessionSupersededError(`P2P session start superseded for station ${parentSn}`);
18189
+ throw new SessionSupersededError(`P2P session start superseded for ${key}`);
18138
18190
  }
18139
18191
  e.session ??= session;
18140
- this.logger.debug(`[session ${parentSn}] connected`);
18192
+ this.logger.debug(`[session ${key}] connected`);
18141
18193
  return e.session;
18142
18194
  } finally {
18143
18195
  if (e.connecting === p)
18144
18196
  e.connecting = void 0;
18145
18197
  }
18146
18198
  }
18147
- /** Add a reason to stay connected; cancels a pending idle-close. */
18148
- retain(parentSn) {
18149
- const e = this.entry(parentSn);
18199
+ /**
18200
+ * Add a reason to stay connected; cancels a pending idle-close.
18201
+ *
18202
+ * Refused when nothing is open under `key`, for the reason {@link release} gives in the other direction: a
18203
+ * retain names a session that was acquired, and one that names nothing would file an entry with no
18204
+ * connection behind it. {@link hold} is the path that legitimately creates one, and it opens the entry
18205
+ * itself before retaining it.
18206
+ */
18207
+ retain(key) {
18208
+ const e = this.entries.get(key);
18209
+ if (!e) {
18210
+ this.logger.warn(`[session ${key}] retain on a session that is not open \u2014 ignored`);
18211
+ return;
18212
+ }
18150
18213
  e.retained++;
18151
18214
  if (e.idle.pending)
18152
- this.logger.debug(`[session ${parentSn}] in use again \u2014 idle-detach cancelled`);
18215
+ this.logger.debug(`[session ${key}] in use again \u2014 idle-detach cancelled`);
18153
18216
  e.idle.cancel();
18154
18217
  }
18155
18218
  /**
@@ -18160,44 +18223,50 @@ var SessionManager = class {
18160
18223
  * from scratch or complete a deferred reset a real viewer has not yet earned. Clamping to zero did
18161
18224
  * both silently.
18162
18225
  */
18163
- release(parentSn) {
18164
- const e = this.entries.get(parentSn);
18226
+ release(key) {
18227
+ const e = this.entries.get(key);
18165
18228
  if (!e)
18166
18229
  return;
18167
18230
  if (e.retained === 0) {
18168
- this.logger.warn(`[session ${parentSn}] release with nothing retained \u2014 ignored`);
18231
+ this.logger.warn(`[session ${key}] release with nothing retained \u2014 ignored`);
18169
18232
  return;
18170
18233
  }
18171
18234
  e.retained -= 1;
18172
18235
  if (e.reset && e.retained <= e.holdTimers.size) {
18173
- void this.autoClose(parentSn).catch((error) => this.logger.error(`[session ${parentSn}] deferred reset failed`, error));
18236
+ void this.autoClose(key).catch((error) => this.logger.error(`[session ${key}] deferred reset failed`, error));
18174
18237
  return;
18175
18238
  }
18176
18239
  if (e.retained === 0)
18177
- this.armIdle(parentSn, e);
18240
+ this.armIdle(key, e);
18178
18241
  }
18179
18242
  /**
18180
18243
  * Hold a session warm for `commandKeepAliveMs` after a control command, then release. A burst of
18181
18244
  * commands each re-holds before the previous release fires, so the session never idles mid-burst.
18182
18245
  */
18183
- bumpCommand(parentSn) {
18184
- this.hold(parentSn, this.opts.commandKeepAliveMs ?? COMMAND_KEEPALIVE_MS);
18246
+ bumpCommand(key, station) {
18247
+ this.hold(key, this.opts.commandKeepAliveMs ?? COMMAND_KEEPALIVE_MS, station);
18185
18248
  }
18186
18249
  /**
18187
- * Retain a station and release it again after `ms` — the primitive behind command-keepalive and event
18250
+ * Retain a session and release it again after `ms` — the primitive behind command-keepalive and event
18188
18251
  * pre-warm, and the only way to hold one open without an attachment to release it.
18189
18252
  *
18253
+ * `station` is required rather than defaulted from the key, because this is the one path that can
18254
+ * CREATE an entry: a pre-warm takes its hold before the open. An entry filed under a media key with
18255
+ * that key as its own station would be asked for the power tier of a serial that does not exist, be
18256
+ * answered `wired`, and never idle-detach — which on a battery station is the drain this class exists
18257
+ * to prevent, and is invisible until the battery is flat.
18258
+ *
18190
18259
  * The timer is owned by the entry, so {@link discard} cancels it. That ownership is the point: keyed
18191
18260
  * only by serial, an expiring hold would otherwise outlive the entry it was taken on and release a
18192
18261
  * retain counted by the SUCCESSOR entry — dropping a live viewer's count and arming an idle-detach
18193
18262
  * underneath it.
18194
18263
  */
18195
- hold(parentSn, ms) {
18196
- const entry = this.entry(parentSn);
18197
- this.retain(parentSn);
18264
+ hold(key, ms, station) {
18265
+ const entry = this.entry(key, station);
18266
+ this.retain(key);
18198
18267
  const timer = setTimeout(() => {
18199
18268
  entry.holdTimers.delete(timer);
18200
- this.release(parentSn);
18269
+ this.release(key);
18201
18270
  }, ms);
18202
18271
  timer.unref?.();
18203
18272
  entry.holdTimers.add(timer);
@@ -18206,29 +18275,29 @@ var SessionManager = class {
18206
18275
  * Arm the idle-close timer for a station whose retain count just reached zero. A wired station with
18207
18276
  * an infinite window is left persistent (no timer). Any subsequent {@link retain} cancels it.
18208
18277
  */
18209
- armIdle(parentSn, e) {
18278
+ armIdle(key, e) {
18210
18279
  e.idle.cancel();
18211
- if ((this.opts.poweredFor?.(parentSn) ?? "wired") !== "battery") {
18212
- this.logger.debug(`[session ${parentSn}] idle (nothing retained) \u2014 staying persistent (wired)`);
18280
+ if ((this.opts.poweredFor?.(e.station) ?? "wired") !== "battery") {
18281
+ this.logger.debug(`[session ${key}] idle (nothing retained) \u2014 staying persistent (wired)`);
18213
18282
  return;
18214
18283
  }
18215
18284
  const idleMs = this.opts.batteryIdleMs ?? BATTERY_IDLE_MS;
18216
- this.logger.debug(`[session ${parentSn}] idle (nothing retained) \u2014 detaching in ${idleMs}ms unless reused`);
18217
- e.idle.arm(idleMs, () => this.onIdle(parentSn));
18285
+ this.logger.debug(`[session ${key}] idle (nothing retained) \u2014 detaching in ${idleMs}ms unless reused`);
18286
+ e.idle.arm(idleMs, () => this.onIdle(key));
18218
18287
  }
18219
18288
  /**
18220
18289
  * Close a station's session once its idle window elapses with nothing retained, letting the device sleep.
18221
18290
  * Re-checks the count first (activity between the timer firing and now re-arms instead). Dropping the
18222
18291
  * entry here and the session's own `close` → {@link remove} are both idempotent.
18223
18292
  */
18224
- onIdle(parentSn) {
18225
- const e = this.entries.get(parentSn);
18293
+ onIdle(key) {
18294
+ const e = this.entries.get(key);
18226
18295
  if (!e)
18227
18296
  return;
18228
18297
  if (e.retained > 0)
18229
18298
  return;
18230
- this.logger.debug(`[session ${parentSn}] idle window elapsed \u2014 disconnecting now (device can sleep)`);
18231
- void this.autoClose(parentSn).catch((error) => this.logger.error(`[session ${parentSn}] idle detach failed`, error));
18299
+ this.logger.debug(`[session ${key}] idle window elapsed \u2014 disconnecting now (device can sleep)`);
18300
+ void this.autoClose(key).catch((error) => this.logger.error(`[session ${key}] idle detach failed`, error));
18232
18301
  }
18233
18302
  /**
18234
18303
  * Close a station the manager itself decided to close, and announce it.
@@ -18246,23 +18315,23 @@ var SessionManager = class {
18246
18315
  * An entry that never carried a session is still torn down, but silently: a pre-warm whose open failed
18247
18316
  * leaves one behind, and announcing it would report a station closed that was never reported open.
18248
18317
  */
18249
- async autoClose(parentSn) {
18250
- const entry = this.discard(parentSn);
18318
+ async autoClose(key) {
18319
+ const entry = this.discard(key);
18251
18320
  if (!entry)
18252
18321
  return;
18253
18322
  if (entry.session)
18254
- this.opts.onAutoClose?.(parentSn);
18323
+ this.opts.onAutoClose?.(key);
18255
18324
  await this.closeEntry(entry);
18256
18325
  }
18257
18326
  /** Drop a station's entry + timer (called from the session's `close` handler). Idempotent. */
18258
- remove(parentSn) {
18259
- const entry = this.discard(parentSn);
18327
+ remove(key) {
18328
+ const entry = this.discard(key);
18260
18329
  if (entry)
18261
18330
  this.settleReset(entry);
18262
18331
  }
18263
18332
  /** Close one station now and discard its lifecycle entry. */
18264
- async close(parentSn) {
18265
- const entry = this.discard(parentSn);
18333
+ async close(key) {
18334
+ const entry = this.discard(key);
18266
18335
  if (entry)
18267
18336
  await this.closeEntry(entry);
18268
18337
  }
@@ -18276,12 +18345,12 @@ var SessionManager = class {
18276
18345
  * Both branches close through {@link autoClose}: the caller asked for a recycle, not for the station's
18277
18346
  * live sources to be dropped, so it does not clean up after one — exactly like the idle path.
18278
18347
  */
18279
- resetWhenUnused(parentSn) {
18280
- const entry = this.entries.get(parentSn);
18348
+ resetWhenUnused(key) {
18349
+ const entry = this.entries.get(key);
18281
18350
  if (!entry)
18282
18351
  return Promise.resolve();
18283
18352
  if (entry.retained <= entry.holdTimers.size)
18284
- return this.autoClose(parentSn);
18353
+ return this.autoClose(key);
18285
18354
  entry.reset ??= Promise.withResolvers();
18286
18355
  return entry.reset.promise;
18287
18356
  }
@@ -18313,22 +18382,22 @@ var SessionManager = class {
18313
18382
  * discarded entry owns no live timer, which is what stops a deferred release from landing on whatever
18314
18383
  * entry next occupies this serial.
18315
18384
  */
18316
- discard(parentSn) {
18317
- const entry = this.entries.get(parentSn);
18385
+ discard(key) {
18386
+ const entry = this.entries.get(key);
18318
18387
  if (!entry)
18319
18388
  return void 0;
18320
18389
  entry.idle.cancel();
18321
18390
  for (const timer of entry.holdTimers)
18322
18391
  clearTimeout(timer);
18323
18392
  entry.holdTimers.clear();
18324
- this.entries.delete(parentSn);
18393
+ this.entries.delete(key);
18325
18394
  return entry;
18326
18395
  }
18327
18396
  /** Close every session and clear all timers. */
18328
18397
  async closeAll() {
18329
18398
  this.generation++;
18330
- const entries = [...this.entries.keys()].flatMap((parentSn) => {
18331
- const entry = this.discard(parentSn);
18399
+ const entries = [...this.entries.keys()].flatMap((key) => {
18400
+ const entry = this.discard(key);
18332
18401
  return entry ? [entry] : [];
18333
18402
  });
18334
18403
  const results = await Promise.allSettled(entries.map((entry) => this.closeEntry(entry)));
@@ -19354,7 +19423,7 @@ function sameLiveOpt(a, b) {
19354
19423
  }
19355
19424
  var P2PCommandRouter = class _P2PCommandRouter {
19356
19425
  deps;
19357
- /** Per-station P2P session lifecycle: on-demand open + battery-aware idle-detach + refcount. */
19426
+ /** P2P session lifecycle: on-demand open + battery-aware idle-detach + refcount, per session key. */
19358
19427
  manager;
19359
19428
  /** Error objects already forwarded while a station startup awaits the same session signal. */
19360
19429
  reportedErrors = /* @__PURE__ */ new WeakSet();
@@ -19362,6 +19431,21 @@ var P2PCommandRouter = class _P2PCommandRouter {
19362
19431
  liveSources = /* @__PURE__ */ new Map();
19363
19432
  /** The options each live source was built from, so a later caller's conflicting ones can be reported. */
19364
19433
  liveSourceOpts = /* @__PURE__ */ new Map();
19434
+ /**
19435
+ * The session each live source pulls over — the station's own serial, or the source's own media
19436
+ * session key.
19437
+ *
19438
+ * Written the instant the choice is made and BEFORE the connection is opened, which is what makes it
19439
+ * a reservation rather than a record. Two cameras started together (the four-tile case this feature
19440
+ * exists for) would otherwise both find the station's session free: a consumer attaches only after
19441
+ * `sharedLiveSourceFor` returns, so neither is visible to the other through consumer counts, and both
19442
+ * would take the shared connection and contend on it.
19443
+ *
19444
+ * A station entry is kept as well as a media one, because "is the station's own session already
19445
+ * claimed" is the question being asked, and an entry that is present but not yet in
19446
+ * {@link liveSources} is a claim in flight.
19447
+ */
19448
+ liveSessionKeys = /* @__PURE__ */ new Map();
19365
19449
  /**
19366
19450
  * The open talkback per `${parentSn}:${channel}`, if any. The device plays one audio stream at a
19367
19451
  * time and the session carries one audio sequence, so this path is exclusive where a live pull is
@@ -19375,7 +19459,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
19375
19459
  this.manager = new SessionManager({
19376
19460
  poweredFor: deps.poweredFor,
19377
19461
  logger: deps.logger,
19378
- onAutoClose: (parentSn) => this.tearDownStation(parentSn),
19462
+ onAutoClose: (key) => _P2PCommandRouter.isMediaSessionKey(key) ? this.tearDownMediaSession(key) : this.tearDownStation(key),
19379
19463
  ...deps.sessionIdle
19380
19464
  });
19381
19465
  }
@@ -19407,7 +19491,10 @@ var P2PCommandRouter = class _P2PCommandRouter {
19407
19491
  static claimsDevice(dev) {
19408
19492
  return typeof dev.p2pDid === "string" && dev.p2pDid.length > 0;
19409
19493
  }
19410
- /** Stations with a live P2P session (a snapshot; mutate via the lifecycle methods, not this map). */
19494
+ /**
19495
+ * The open P2P sessions by key — a station's own under its serial, a camera's media session under
19496
+ * `<stationSn>#live:<channel>` (a snapshot; mutate via the lifecycle methods, not this map).
19497
+ */
19411
19498
  getSessions() {
19412
19499
  return this.manager.liveSessions();
19413
19500
  }
@@ -19419,16 +19506,16 @@ var P2PCommandRouter = class _P2PCommandRouter {
19419
19506
  *
19420
19507
  * One hold, taken before the open so a slow connect can't idle-close mid-flight. It expires on its
19421
19508
  * own, which arms the station's idle window rather than closing the session, per {@link PREWARM_MS}.
19422
- * A second hold after the open would buy nothing: {@link openStation} returns once the socket is bound
19509
+ * A second hold after the open would buy nothing: {@link openSession} returns once the socket is bound
19423
19510
  * and the lookups are away, not once the peer has answered, so both would expire together.
19424
19511
  *
19425
19512
  * Best-effort — a failed open surfaces via `onError`. A {@link SessionSupersededError} does not: the
19426
19513
  * session was deliberately closed underneath a speculative open, which is not a fault to report.
19427
19514
  */
19428
19515
  async prewarm(parentSn, ms = PREWARM_MS) {
19429
- this.manager.hold(parentSn, ms);
19516
+ this.manager.hold(parentSn, ms, parentSn);
19430
19517
  try {
19431
- await this.openStation(parentSn);
19518
+ await this.openSession(parentSn, parentSn);
19432
19519
  } catch (e) {
19433
19520
  if (e instanceof SessionSupersededError)
19434
19521
  return;
@@ -19445,6 +19532,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
19445
19532
  src.dispose();
19446
19533
  this.liveSources.clear();
19447
19534
  this.liveSourceOpts.clear();
19535
+ this.liveSessionKeys.clear();
19448
19536
  await this.manager.closeAll();
19449
19537
  }
19450
19538
  /** This serial's loaded record, or `undefined` — the one place the cached list is searched by serial. */
@@ -19486,8 +19574,55 @@ var P2PCommandRouter = class _P2PCommandRouter {
19486
19574
  * when present, else the freshest private IP in the record ({@link freshestLanIp}) — so P2P works
19487
19575
  * on-LAN even when broadcast is blocked (AP isolation) or the record's `ip_addr` went stale.
19488
19576
  */
19489
- async openStation(parentSn) {
19490
- return this.manager.acquire(parentSn, async (register) => {
19577
+ /**
19578
+ * The key a camera's own media session is filed under, distinct from every station serial because a
19579
+ * serial contains no `#`.
19580
+ */
19581
+ static mediaSessionKey(parentSn, channel) {
19582
+ return `${parentSn}#live:${channel}`;
19583
+ }
19584
+ /** Whether `key` names a media session rather than a station's own. */
19585
+ static isMediaSessionKey(key) {
19586
+ return key.includes("#live:");
19587
+ }
19588
+ /**
19589
+ * Open (or reuse) a SECOND connection to a station, carrying one camera's media and nothing else.
19590
+ *
19591
+ * One session serves one camera: a station fans its cameras over a session and answers the most recent
19592
+ * start on it, so two cameras down one tunnel take it from each other in turn. Another connection is
19593
+ * how a station serves another camera.
19594
+ *
19595
+ * The hardware was shown to do this before the SDK did. A first-party display showing four tiles was
19596
+ * captured opening one PPCS session per camera, with three cameras' video arriving in the same second
19597
+ * at 2304x1296, 1600x1200 and 3840x2160 — three geometries at once, which no composed stream can be.
19598
+ * Reproduced here afterwards on a base carrying two attached cameras, one at 3840x2160, both holding
19599
+ * full frame rate at once over a session each, where the same pair down one session could only take
19600
+ * turns.
19601
+ *
19602
+ * It carries media alone. The station announces its state to every client that connects, so a session
19603
+ * wired to the same fan-out would report every event a second time; {@link makeSession} leaves this one
19604
+ * unannounced, and the station's own session stays the single source of connection state, control
19605
+ * notifications and frames.
19606
+ *
19607
+ * The level-2 key is waited for here on the same terms the station's own session gets, because a
19608
+ * connection is not usable for this without one: an attached camera's media start has no level-1 form,
19609
+ * so a start issued before the key arrives is dropped as `media-command-unsent`, and the stream then
19610
+ * shows nothing until a later keepalive tick happens to find the key. A connected session that cannot
19611
+ * carry the start is not a connected session, so this refuses rather than returning one.
19612
+ */
19613
+ async openMediaSession(parentSn, channel, signal) {
19614
+ const session = await this.openSession(_P2PCommandRouter.mediaSessionKey(parentSn, channel), parentSn);
19615
+ let ready = await abortable(session.awaitLevel2Key(LEVEL2_GRACE_MS, "call"), signal);
19616
+ if (!ready && session.repromptLevel2Key()) {
19617
+ ready = await abortable(session.awaitLevel2Key(LEVEL2_GRACE_MS, "call"), signal);
19618
+ }
19619
+ if (!ready)
19620
+ throw new StationKeyUnavailableError(parentSn);
19621
+ return session;
19622
+ }
19623
+ /** Open (or reuse) the session filed under `key`, dialling `parentSn`'s endpoint. */
19624
+ async openSession(key, parentSn) {
19625
+ return this.manager.acquire(key, async (register) => {
19491
19626
  const stationDev = this.recordFor(parentSn);
19492
19627
  if (!stationDev)
19493
19628
  throw new Error(`station ${parentSn} is not in the device list`);
@@ -19502,11 +19637,11 @@ var P2PCommandRouter = class _P2PCommandRouter {
19502
19637
  this.deps.onError(e instanceof Error ? e : new Error(String(e)));
19503
19638
  }
19504
19639
  const localAddress = this.deps.localAddresses?.[parentSn] ?? freshestLanIp(raw);
19505
- const session = this.makeSession(parentSn, did, raw, dskKey, localAddress);
19640
+ const session = this.makeSession(parentSn, did, raw, dskKey, localAddress, key, key === parentSn);
19506
19641
  register(session);
19507
19642
  await session.connect();
19508
19643
  return session;
19509
- });
19644
+ }, parentSn);
19510
19645
  }
19511
19646
  /**
19512
19647
  * Build + wire a {@link P2PSession} for a station (NOT yet connected — the caller awaits `connect()`).
@@ -19519,8 +19654,14 @@ var P2PCommandRouter = class _P2PCommandRouter {
19519
19654
  *
19520
19655
  * The `close` handler drops the session from the {@link SessionManager} and disposes any shared live
19521
19656
  * source riding this station (consumers get `stop`; a later attach rebuilds via the factory).
19657
+ *
19658
+ * A session filed under a media key is wired for errors and its own teardown ONLY. Connection state,
19659
+ * level-2 readiness and inbound frames all reach the owner through the station's own session, and a
19660
+ * station announces those to every client that connects — so fanning a second connection's copies out
19661
+ * under the same station serial would report each one twice, and a close would tear down the station
19662
+ * while its own session is still serving.
19522
19663
  */
19523
- makeSession(stationSn, did, raw, dskKey, localAddress) {
19664
+ makeSession(stationSn, did, raw, dskKey, localAddress, key, announces) {
19524
19665
  const conn = raw?.p2p_conn ?? raw?.app_conn;
19525
19666
  const adminUserId = raw?.member?.admin_user_id || this.deps.mega.auth?.userId || "";
19526
19667
  const session = new P2PSession({
@@ -19554,6 +19695,14 @@ var P2PCommandRouter = class _P2PCommandRouter {
19554
19695
  },
19555
19696
  logger: this.deps.logger ?? noopLogger
19556
19697
  });
19698
+ session.on("error", (e) => this.reportError(e));
19699
+ if (!announces) {
19700
+ session.on("close", () => {
19701
+ if (this.manager.get(key) === session)
19702
+ this.tearDownMediaSession(key);
19703
+ });
19704
+ return session;
19705
+ }
19557
19706
  session.on("connect", () => {
19558
19707
  if (this.manager.get(stationSn) === session)
19559
19708
  this.deps.onConnect(stationSn);
@@ -19563,7 +19712,6 @@ var P2PCommandRouter = class _P2PCommandRouter {
19563
19712
  return;
19564
19713
  this.tearDownStation(stationSn);
19565
19714
  });
19566
- session.on("error", (e) => this.reportError(e));
19567
19715
  session.on("level2Ready", ({ cipherId }) => this.deps.onLevel2Ready(stationSn, cipherId));
19568
19716
  session.on("data", (f) => this.deps.onFrame(stationSn, f));
19569
19717
  return session;
@@ -19574,7 +19722,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
19574
19722
  */
19575
19723
  async ensureStation(parentSn, signal) {
19576
19724
  try {
19577
- const session = await this.openStation(parentSn);
19725
+ const session = await this.openSession(parentSn, parentSn);
19578
19726
  if (session.isConnected)
19579
19727
  return;
19580
19728
  await new Promise((resolve, reject) => {
@@ -19622,7 +19770,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
19622
19770
  const dev = this.recordFor(sn);
19623
19771
  if (!dev)
19624
19772
  throw new Error(`device ${sn} not found`);
19625
- await this.openStation(this.stationKeyFor(dev));
19773
+ await this.openSession(this.stationKeyFor(dev), this.stationKeyFor(dev));
19626
19774
  return dev;
19627
19775
  }
19628
19776
  /**
@@ -19706,11 +19854,11 @@ var P2PCommandRouter = class _P2PCommandRouter {
19706
19854
  });
19707
19855
  },
19708
19856
  live: async (opts) => {
19709
- const source = await this.sharedLiveSourceFor(sn, opts);
19857
+ const source = await this.sharedLiveSourceFor(sn, opts, true);
19710
19858
  return this.attachUnlessAborted(source, opts?.signal);
19711
19859
  },
19712
19860
  openReadable: async (opts) => {
19713
- const source = await this.sharedLiveSourceFor(sn, opts ?? {});
19861
+ const source = await this.sharedLiveSourceFor(sn, opts ?? {}, true);
19714
19862
  return openReadableFromConsumer(this.attachUnlessAborted(source, opts?.signal), opts);
19715
19863
  },
19716
19864
  recordFragments: (opts) => this.recordFragments(sn, opts),
@@ -19851,10 +19999,17 @@ var P2PCommandRouter = class _P2PCommandRouter {
19851
19999
  * {@link releaseLingeringSiblings}. A pull with consumers is never touched. The release runs before the
19852
20000
  * reuse branch, so a reuse frees the station as a cold start does.
19853
20001
  *
20002
+ * `mayOpenOwnSession` decides whether a camera that finds the station's own session claimed may open a
20003
+ * connection of its own for it. Only the continuous-pull egresses pass it. A still must not: it wants one
20004
+ * frame, and a socket plus a level-2 negotiation per thumbnail is a cost a tile refresh cannot justify —
20005
+ * so a still asked for while a sibling is being watched contends as it always did, and the caller's
20006
+ * retained image answers it. A live view still outranks a tile; what changed is that two live views no
20007
+ * longer have to outrank each other.
20008
+ *
19854
20009
  * The session goes into a {@link HeldSession} cell, so it can be replaced under a source that stays in
19855
20010
  * place.
19856
20011
  */
19857
- async sharedLiveSourceFor(sn, opts = {}) {
20012
+ async sharedLiveSourceFor(sn, opts = {}, mayOpenOwnSession = false) {
19858
20013
  const { session, parentSn, channel, accountId, homeBaseAttached } = await this.resolveSession(sn, {
19859
20014
  waitLevel2: "soft",
19860
20015
  requireLevel2ForAttached: true,
@@ -19867,14 +20022,19 @@ var P2PCommandRouter = class _P2PCommandRouter {
19867
20022
  source = void 0;
19868
20023
  }
19869
20024
  this.releaseLingeringSiblings(parentSn, channel);
19870
- if (homeBaseAttached) {
19871
- const serving = this.occupiedSiblingChannel(parentSn, key);
19872
- if (serving !== void 0)
19873
- throw new StationBusyError(serving);
19874
- }
19875
20025
  if (!source) {
19876
20026
  const logger = this.deps.logger ?? noopLogger;
19877
- const held = { session };
20027
+ const sessionKey = mayOpenOwnSession && homeBaseAttached && this.stationSessionInUse(parentSn, key) ? _P2PCommandRouter.mediaSessionKey(parentSn, channel) : parentSn;
20028
+ this.liveSessionKeys.set(key, sessionKey);
20029
+ let held;
20030
+ try {
20031
+ held = {
20032
+ session: sessionKey === parentSn ? session : await this.openMediaSession(parentSn, channel, opts.signal)
20033
+ };
20034
+ } catch (error) {
20035
+ this.liveSessionKeys.delete(key);
20036
+ throw error;
20037
+ }
19878
20038
  source = new SharedLiveSource({
19879
20039
  makeStream: (ctx) => new LiveStream(held.session, {
19880
20040
  channel,
@@ -19892,8 +20052,9 @@ var P2PCommandRouter = class _P2PCommandRouter {
19892
20052
  budgetGraceMs: opts.budgetGraceMs,
19893
20053
  logger,
19894
20054
  label: key,
19895
- onActive: () => this.manager.retain(parentSn),
19896
- onIdle: () => this.manager.release(parentSn),
20055
+ onActive: () => this.manager.retain(sessionKey),
20056
+ onIdle: () => this.manager.release(sessionKey),
20057
+ onStopped: () => this.closeMediaSession(key),
19897
20058
  onStartFailed: () => this.onLiveStartFailed(sn, key),
19898
20059
  onSessionUnreachable: () => this.replaceUnreachableSession(sn, key, held)
19899
20060
  });
@@ -19908,14 +20069,25 @@ var P2PCommandRouter = class _P2PCommandRouter {
19908
20069
  * Tear down any pull on this station that is lingering for ANOTHER camera, before starting this one.
19909
20070
  *
19910
20071
  * A lingering pull has no consumers but is still held open, and on an attached camera holding it open means
19911
- * re-sending the full media start every keepalive tick. Two channels doing that at once on a station that
19912
- * serves one camera at a time leaves the new stream receiving nothing but the old camera's frames for as
19913
- * long as the linger lasts.
20072
+ * re-sending the full media start every keepalive tick. Two channels doing that at once over ONE session,
20073
+ * which serves one camera at a time, leaves the new stream receiving nothing but the old camera's frames
20074
+ * for as long as the linger lasts.
19914
20075
  *
19915
20076
  * Several cameras genuinely being WATCHED together are never disturbed — the linger exists to make
19916
20077
  * re-opening the SAME camera cheap, and it keeps doing that. What it may not do is keep a camera nobody is
19917
20078
  * looking at competing with one somebody just asked for.
19918
20079
  *
20080
+ * An `idle` source is skipped, because it is not a linger and holds nothing: it has never warmed, so it
20081
+ * has sent no media start and is competing for nothing. Without that, two cameras opened at the same
20082
+ * moment destroy each other — the second finds the first's source built but not yet attached to, reads
20083
+ * zero consumers as a linger, and disposes the source its caller is holding. Which is the four-tile case
20084
+ * this whole path exists for.
20085
+ *
20086
+ * A sibling lingering on a connection of its OWN is skipped for the same reason stated the other way: the
20087
+ * contention this releases is contention over one session, and that sibling is not on this one. Dropping
20088
+ * it would close a socket and throw away the cheap re-attach the linger exists to provide, to relieve a
20089
+ * competition that is not happening.
20090
+ *
19919
20091
  * A snapshot tile is nobody looking. Opening a live view in the Home app takes that cell fullscreen, so the
19920
20092
  * pulls refreshing the other cells are off screen, yet each goes on re-issuing its own media start every
19921
20093
  * retry tick — measured as four pulls warming together off one HomeBase, a live request landing 1.4 s later,
@@ -19929,28 +20101,49 @@ var P2PCommandRouter = class _P2PCommandRouter {
19929
20101
  for (const [key, source] of [...this.liveSources]) {
19930
20102
  if (!key.startsWith(`${parentSn}:`) || key === own || source.consumerCount > 0)
19931
20103
  continue;
19932
- (this.deps.logger ?? noopLogger).debug(`[live ${key}] releasing a pull nothing is attached to so ${own} can start \u2014 one station serves one camera at a time`);
20104
+ if (source.state === "idle")
20105
+ continue;
20106
+ if (this.liveSessionKeys.get(key) !== parentSn)
20107
+ continue;
20108
+ (this.deps.logger ?? noopLogger).debug(`[live ${key}] releasing a pull nothing is attached to so ${own} can start \u2014 one session serves one camera at a time`);
19933
20109
  this.dropLiveSource(key);
19934
20110
  }
19935
20111
  }
19936
20112
  /**
19937
- * The channel a live viewer already holds on this station, if any, ignoring `key` itself.
20113
+ * Whether another camera has already claimed this station's OWN session, ignoring `key`.
20114
+ *
20115
+ * The question a newcomer has to answer is not whether the station is busy — it can serve one camera
20116
+ * per connection — but whether the connection it would otherwise share is taken. A camera on a media
20117
+ * session of its own does not hold this one, so a station whose first camera has since stopped hands
20118
+ * its own session to the next arrival rather than opening a socket beside an idle one.
20119
+ *
20120
+ * A claim counts while its source is still `idle`, source or no source. That is a start that has been
20121
+ * handed to its caller but not yet attached to, and it is the only state in which two cameras asked for
20122
+ * at the same moment can see each other: consumers attach after this method has already run for both.
20123
+ *
20124
+ * The cost is that a pull genuinely abandoned before its first attach goes on holding the station's own
20125
+ * session, and the next camera pays for a connection of its own rather than reclaiming it. That is one
20126
+ * socket against destroying a start someone is waiting on, which is not a close trade.
19938
20127
  *
19939
20128
  * A stopped source is skipped even when consumers are still attached to it. A failed start fails its
19940
20129
  * consumers without detaching them, so a caller still holding a dead handle leaves the count non-zero,
19941
- * and counting that as a viewer would refuse every later stream on the station until the client
19942
- * restarted. Only a source that can still deliver holds a place.
20130
+ * and counting that as a viewer would put every later camera on a connection of its own until the
20131
+ * client restarted. Only a source that can still deliver holds a place.
19943
20132
  */
19944
- occupiedSiblingChannel(parentSn, key) {
19945
- for (const [siblingKey, sibling] of this.liveSources) {
19946
- if (siblingKey === key || !siblingKey.startsWith(`${parentSn}:`))
20133
+ stationSessionInUse(parentSn, key) {
20134
+ for (const [siblingKey, sessionKey] of this.liveSessionKeys) {
20135
+ if (siblingKey === key || sessionKey !== parentSn)
20136
+ continue;
20137
+ const sibling = this.liveSources.get(siblingKey);
20138
+ if (!sibling)
20139
+ return true;
20140
+ if (sibling.state === "stopped")
19947
20141
  continue;
19948
- if (sibling.state === "stopped" || sibling.consumerCount === 0)
20142
+ if (sibling.state !== "idle" && sibling.consumerCount === 0)
19949
20143
  continue;
19950
- const channel = Number(siblingKey.slice(parentSn.length + 1));
19951
- return Number.isFinite(channel) ? channel : void 0;
20144
+ return true;
19952
20145
  }
19953
- return void 0;
20146
+ return false;
19954
20147
  }
19955
20148
  /**
19956
20149
  * Whether the stream on `key` should re-assert its channel to hold the station.
@@ -19961,8 +20154,8 @@ var P2PCommandRouter = class _P2PCommandRouter {
19961
20154
  * - Nothing attached: no. There is nobody to take the station for.
19962
20155
  * - A live viewer attached: yes. That is the picture someone is looking at.
19963
20156
  * - Held only for stills, while a sibling on this station has a live viewer: no. A still refreshes a
19964
- * tile that is off screen while the live view is on it, and a station serving one camera at a time
19965
- * cannot satisfy both. Measured: a still on a sibling halved a live view's frame rate for as long as
20157
+ * tile that is off screen while the live view is on it, and one session serving one camera at a time
20158
+ * cannot satisfy both — and a still does not open a connection of its own. Measured: a still on a sibling halved a live view's frame rate for as long as
19966
20159
  * it took, and its own capture then took fifteen seconds because it was contending.
19967
20160
  *
19968
20161
  * A still with no live sibling re-asserts, so a tile refreshing on a quiet station is
@@ -19984,7 +20177,13 @@ var P2PCommandRouter = class _P2PCommandRouter {
19984
20177
  }
19985
20178
  return consumer;
19986
20179
  }
19987
- /** Dispose one cached live source and forget it, so the next acquisition builds a fresh one. */
20180
+ /**
20181
+ * Dispose one cached live source and forget it, so the next acquisition builds a fresh one. Its claim
20182
+ * on a session goes with it, and a media session opened for this source alone is closed: nothing else
20183
+ * can reach that connection, so leaving it open would hold a socket and a station keepalive for a
20184
+ * camera no longer being pulled. A claim on the STATION's own session is released without closing
20185
+ * anything — that connection carries the station's control traffic and outlives any one camera.
20186
+ */
19988
20187
  dropLiveSource(key) {
19989
20188
  const source = this.liveSources.get(key);
19990
20189
  if (!source)
@@ -19992,6 +20191,34 @@ var P2PCommandRouter = class _P2PCommandRouter {
19992
20191
  source.dispose();
19993
20192
  this.liveSources.delete(key);
19994
20193
  this.liveSourceOpts.delete(key);
20194
+ this.closeMediaSession(key);
20195
+ }
20196
+ /** Close and forget the media session `key`'s live source owned, if it owned one. */
20197
+ closeMediaSession(key) {
20198
+ const sessionKey = this.liveSessionKeys.get(key);
20199
+ if (sessionKey === void 0)
20200
+ return;
20201
+ this.liveSessionKeys.delete(key);
20202
+ if (!_P2PCommandRouter.isMediaSessionKey(sessionKey))
20203
+ return;
20204
+ void this.manager.close(sessionKey).catch((error) => this.reportError(error instanceof Error ? error : new Error(String(error))));
20205
+ }
20206
+ /**
20207
+ * Drop the live source a media session was carrying, after that session closed on its own.
20208
+ *
20209
+ * The source holds the closed connection and never re-resolves it, so it can only answer its retained
20210
+ * keyframe and then fail on its warm-up deadline. Its consumers get `stop`, and the next attach builds
20211
+ * a fresh source — which, finding the station busy again, opens a fresh media session for it.
20212
+ */
20213
+ tearDownMediaSession(sessionKey) {
20214
+ this.manager.remove(sessionKey);
20215
+ for (const [key, owned] of this.liveSessionKeys) {
20216
+ if (owned === sessionKey) {
20217
+ this.liveSessionKeys.delete(key);
20218
+ this.dropLiveSource(key);
20219
+ return;
20220
+ }
20221
+ }
19995
20222
  }
19996
20223
  /**
19997
20224
  * Drop everything that was riding a station's session, and report the station closed.
@@ -20335,14 +20562,15 @@ var P2PCommandRouter = class _P2PCommandRouter {
20335
20562
  const parentSn = homeBaseAttached ? raw.parent_sn : dev.stationSn ?? sn;
20336
20563
  const session = this.manager.get(parentSn) ?? this.manager.get(sn) ?? (dev.stationSn ? this.manager.get(dev.stationSn) : void 0);
20337
20564
  if (!session) {
20338
- throw new Error(`no P2P session for ${sn} (known: ${this.manager.keys().join(", ") || "none"})`);
20565
+ const stations = this.manager.keys().filter((k) => !_P2PCommandRouter.isMediaSessionKey(k));
20566
+ throw new Error(`no P2P session for ${sn} (known: ${stations.join(", ") || "none"})`);
20339
20567
  }
20340
20568
  if (session.pathAnswering === false && !rebuilt) {
20341
20569
  (this.deps.logger ?? noopLogger).debug(`[p2p] ${parentSn} path stopped answering \u2014 rebuilding before use`);
20342
20570
  await this.manager.close(parentSn).catch((error) => this.reportError(error instanceof Error ? error : new Error(String(error))));
20343
20571
  return await this.resolveSession(sn, opts, true);
20344
20572
  }
20345
- this.manager.bumpCommand(parentSn);
20573
+ this.manager.bumpCommand(parentSn, parentSn);
20346
20574
  const channel = typeof raw.device_channel === "number" ? raw.device_channel : 0;
20347
20575
  const stationAdminId = raw.member?.admin_user_id;
20348
20576
  const accountId = stationAdminId ?? this.deps.mega.auth?.userId ?? "";
@@ -24649,9 +24877,9 @@ var EufyMega = class extends EventEmitter9 {
24649
24877
  /**
24650
24878
  * Combine explicit P2P media with the optional passive push-thumbnail provider.
24651
24879
  *
24652
- * The retained still also becomes the answer for a live still that could not be captured. A station
24653
- * serves one camera at a time and a live view outranks a tile, so a still asked for while a sibling is
24654
- * being watched is refused at the transport. Answering the retained bytes answers the read rather than
24880
+ * The retained still also becomes the answer for a live still that could not be captured. One session
24881
+ * serves one camera at a time and a live view outranks a tile — a still does not open a connection of its
24882
+ * own — so a still asked for while a sibling is being watched is refused at the transport. Answering the retained bytes answers the read rather than
24655
24883
  * failing it, marked {@link MediaProvider.snapshotLive} `retained` so the caller knows they are not
24656
24884
  * current. With nothing retained the refusal stands.
24657
24885
  */
@@ -25404,10 +25632,15 @@ var EufyMega = class extends EventEmitter9 {
25404
25632
  await sink.dispatch(cmd);
25405
25633
  }
25406
25634
  /**
25407
- * Stations with a live P2P session. P2P is auto-managed: wired stations are warmed at login, battery
25635
+ * The open P2P sessions, by key. P2P is auto-managed: wired stations are warmed at login, battery
25408
25636
  * stations open on demand (command / stream, or an opted-in event pre-warm) and idle-detach — so this
25409
- * map grows and shrinks over time. `p2pConnect(stationSn)` / `p2pClose(stationSn)` events track the
25410
- * changes.
25637
+ * map grows and shrinks over time.
25638
+ *
25639
+ * A station's own session is keyed by its serial, and `p2pConnect(stationSn)` / `p2pClose(stationSn)`
25640
+ * track those. A station serving more than one camera at once also holds a session per extra camera,
25641
+ * keyed `<stationSn>#live:<channel>` — these carry media alone and raise no connection events, because
25642
+ * a station announces its state to every client that connects and reporting each copy would double
25643
+ * every event the station's own session already delivers.
25411
25644
  */
25412
25645
  getP2pSessions() {
25413
25646
  return this.p2p.getSessions();
@@ -26480,7 +26713,6 @@ export {
26480
26713
  SolixDevice,
26481
26714
  SolixMqtt,
26482
26715
  StateConvergenceError,
26483
- StationBusyError,
26484
26716
  StationKeyUnavailableError,
26485
26717
  StationUnreachableError,
26486
26718
  StoredSnapshotUnavailableError,
@@ -26517,6 +26749,7 @@ export {
26517
26749
  buildRealtimeInit,
26518
26750
  captureSnapshotFromShared,
26519
26751
  cellAtPoint,
26752
+ claimedParams,
26520
26753
  clamp,
26521
26754
  classify,
26522
26755
  classifyDevice,