@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/client/eufy-mega.d.ts +11 -6
- package/dist/core/contracts.d.ts +9 -34
- package/dist/index.js +363 -130
- package/dist/index.js.map +2 -2
- package/dist/model/capabilities/battery.d.ts +32 -4
- package/dist/model/capabilities/index.d.ts +9 -0
- package/dist/model/device.d.ts +15 -0
- package/dist/transport/p2p/command-router.d.ts +120 -15
- package/dist/transport/p2p/live-stream.d.ts +5 -4
- package/dist/transport/p2p/media.d.ts +2 -2
- package/dist/transport/p2p/session-manager.d.ts +57 -23
- package/dist/transport/p2p/shared-live-source.d.ts +10 -1
- package/package.json +1 -1
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
|
|
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.
|
|
16789
|
-
* serves one camera at a time that restart re-asserts this channel against whatever else is warm
|
|
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
|
-
*
|
|
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(
|
|
18089
|
-
return this.entries.get(
|
|
18130
|
+
get(key) {
|
|
18131
|
+
return this.entries.get(key)?.session;
|
|
18090
18132
|
}
|
|
18091
|
-
/**
|
|
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<
|
|
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
|
-
/**
|
|
18104
|
-
|
|
18105
|
-
|
|
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(
|
|
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
|
-
/**
|
|
18113
|
-
|
|
18114
|
-
|
|
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 `
|
|
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(
|
|
18122
|
-
const e = this.entry(
|
|
18173
|
+
async acquire(key, factory, station) {
|
|
18174
|
+
const e = this.entry(key, station);
|
|
18123
18175
|
if (e.connecting) {
|
|
18124
|
-
this.logger.debug(`[session ${
|
|
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 ${
|
|
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(
|
|
18187
|
+
if (generation !== this.generation || this.entries.get(key) !== e) {
|
|
18136
18188
|
await session.close();
|
|
18137
|
-
throw new SessionSupersededError(`P2P session start superseded for
|
|
18189
|
+
throw new SessionSupersededError(`P2P session start superseded for ${key}`);
|
|
18138
18190
|
}
|
|
18139
18191
|
e.session ??= session;
|
|
18140
|
-
this.logger.debug(`[session ${
|
|
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
|
-
/**
|
|
18148
|
-
|
|
18149
|
-
|
|
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 ${
|
|
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(
|
|
18164
|
-
const e = this.entries.get(
|
|
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 ${
|
|
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(
|
|
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(
|
|
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(
|
|
18184
|
-
this.hold(
|
|
18246
|
+
bumpCommand(key, station) {
|
|
18247
|
+
this.hold(key, this.opts.commandKeepAliveMs ?? COMMAND_KEEPALIVE_MS, station);
|
|
18185
18248
|
}
|
|
18186
18249
|
/**
|
|
18187
|
-
* Retain a
|
|
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(
|
|
18196
|
-
const entry = this.entry(
|
|
18197
|
-
this.retain(
|
|
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(
|
|
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(
|
|
18278
|
+
armIdle(key, e) {
|
|
18210
18279
|
e.idle.cancel();
|
|
18211
|
-
if ((this.opts.poweredFor?.(
|
|
18212
|
-
this.logger.debug(`[session ${
|
|
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 ${
|
|
18217
|
-
e.idle.arm(idleMs, () => this.onIdle(
|
|
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(
|
|
18225
|
-
const e = this.entries.get(
|
|
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 ${
|
|
18231
|
-
void this.autoClose(
|
|
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(
|
|
18250
|
-
const entry = this.discard(
|
|
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?.(
|
|
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(
|
|
18259
|
-
const entry = this.discard(
|
|
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(
|
|
18265
|
-
const entry = this.discard(
|
|
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(
|
|
18280
|
-
const entry = this.entries.get(
|
|
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(
|
|
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(
|
|
18317
|
-
const entry = this.entries.get(
|
|
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(
|
|
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((
|
|
18331
|
-
const entry = this.discard(
|
|
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
|
-
/**
|
|
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: (
|
|
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
|
-
/**
|
|
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
|
|
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.
|
|
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
|
-
|
|
19490
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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(
|
|
19896
|
-
onIdle: () => this.manager.release(
|
|
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
|
|
19912
|
-
* serves one camera at a time leaves the new stream receiving nothing but the old camera's frames
|
|
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
|
-
(
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
19945
|
-
for (const [siblingKey,
|
|
19946
|
-
if (siblingKey === key ||
|
|
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
|
|
20142
|
+
if (sibling.state !== "idle" && sibling.consumerCount === 0)
|
|
19949
20143
|
continue;
|
|
19950
|
-
|
|
19951
|
-
return Number.isFinite(channel) ? channel : void 0;
|
|
20144
|
+
return true;
|
|
19952
20145
|
}
|
|
19953
|
-
return
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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.
|
|
24653
|
-
* serves one camera at a time and a live view outranks a tile
|
|
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
|
-
*
|
|
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.
|
|
25410
|
-
*
|
|
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,
|