@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.
@@ -90,8 +90,33 @@ export type BatteryActions = Surface<typeof BATTERY_MEMBERS>;
90
90
  * The richer raw `APP_CMD_SET_POWER_SOURCE` blob is a separate param, surfaced as `powerSourceInfo`.
91
91
  */
92
92
  declare function decodePowerSource(raw: string | number | boolean): number | string;
93
- /** False for a mains camera that only reports 1101 as a sentinel — used to gate the physical reads. */
94
- declare const notMainsCamera: (ctx: AvailabilityContext) => boolean;
93
+ /**
94
+ * The params whose subject IS the physical cell — so every member reading one must carry
95
+ * {@link notMainsCamera}.
96
+ *
97
+ * The ONE place that fact is declared. {@link cellGated} applies {@link notMainsCamera} from this list
98
+ * when the table is built, so a member never states the gate itself: adding a cell read is adding its
99
+ * param here, and there is no second place for it to be missing from. A per-member `available` was the
100
+ * alternative and is what the first two passes of this guard got wrong, in both directions — first by
101
+ * covering two of the seven, then by reading `unexposed` as covering a third.
102
+ *
103
+ * What is NOT here matters as much.
104
+ *
105
+ * - `workingMode` and the three `record*` settings describe how hard the camera works, not what powers
106
+ * it, and a mains camera genuinely has them. They are the reason the capability stays attached.
107
+ * - `cameraInfo` (1103) is a number whose meaning is unevidenced. Gating it would assert it is a
108
+ * battery fact, which is the kind of claim this guard exists to stop making.
109
+ * - `powerSource` (1293) is the open one. Both values it names — `Battery` and `External Solar Panel` —
110
+ * describe how a CELL is fed, so by this list's own rule it arguably belongs here. It is out because
111
+ * every param above is one a live mains camera was observed to publish and 1293 was not among them,
112
+ * and because it is `requires`-gated on its own param, so it appears only where the device reports
113
+ * it. Gating it would also withhold a described WRITE rather than a read, which is a different class
114
+ * of change. Unresolved rather than decided: see the note in the pull request.
115
+ *
116
+ * The two solar params ARE here: a panel exists to charge a cell, so a device without one has no solar
117
+ * harvest to report either.
118
+ */
119
+ export declare const CELL_PARAMS: readonly number[];
95
120
  /**
96
121
  * Every `battery` feature, declared once — the property schema, the evidence-gated getters, the derived
97
122
  * setters, the intent routes and the descriptions all come out of this table. Order is schema order.
@@ -117,7 +142,6 @@ export declare const BATTERY_MEMBERS: {
117
142
  readonly unit: "%";
118
143
  readonly kind: "percent";
119
144
  readonly provenance: "verified";
120
- readonly available: typeof notMainsCamera;
121
145
  readonly description: "Battery level 0-100 (verified: param 1101).";
122
146
  };
123
147
  /**
@@ -130,7 +154,6 @@ export declare const BATTERY_MEMBERS: {
130
154
  readonly type: "bool";
131
155
  readonly kind: "boolean";
132
156
  readonly provenance: "apk";
133
- readonly available: typeof notMainsCamera;
134
157
  readonly coerce: (v: string | number | boolean) => boolean;
135
158
  readonly description: string;
136
159
  };
@@ -300,6 +323,11 @@ export declare const BATTERY_MEMBERS: {
300
323
  * Reported, so it stays in the schema and answers through `getProperty` — but given no typed getter:
301
324
  * the payload's fields have never been decoded, and a getter would hand back an opaque blob typed as
302
325
  * though it meant something.
326
+ *
327
+ * `unexposed` is NOT a substitute for the cell gate. It suppresses the fluent GETTER; `propertiesOf`
328
+ * filters `writeOnly` and `available` and deliberately not `unexposed`, because a schema entry
329
+ * reachable through `getProperty` is the whole point of the mark. So a cell param needs
330
+ * {@link CELL_PARAMS} either way, or a mains camera publishes "battery power history" and answers it.
303
331
  */
304
332
  readonly batteryPowerStats: {
305
333
  readonly param: 3100;
@@ -54,6 +54,15 @@ export declare function getCapabilityModule(cap: Capability): CapabilityModule |
54
54
  * @internal
55
55
  */
56
56
  export declare const CAPABILITY_MODULES: Record<Capability, CapabilityModule>;
57
+ /**
58
+ * Every param these capabilities declare before any gate — the set a device's schema is a subset of.
59
+ *
60
+ * A param in here that a device's schema does NOT carry is one a gate withheld: the capability resolved,
61
+ * and its member decided the read does not describe this device — a cell reading on a mains model, a mode
62
+ * a family does not carry. Read-aliases count, since a member reads them under its own name.
63
+ * @internal
64
+ */
65
+ export declare function claimedParams(caps: Capability[]): Set<number>;
57
66
  /**
58
67
  * Merge the property schemas of several capabilities into one flat, de-duplicated list.
59
68
  *
@@ -62,6 +62,11 @@ export declare class Device {
62
62
  * different wire ids across device families still resolves to one named value.
63
63
  */
64
64
  private specByParam;
65
+ /**
66
+ * Params a resolved capability declares that this device's schema does not carry — withheld by a
67
+ * member's gate, so not named from the param dictionary either. See {@link Device.applyParams}.
68
+ */
69
+ private withheld;
65
70
  /** Which param namespace this device's ids live in (clean DPs vs security P2P). */
66
71
  private namespace;
67
72
  /** The record's `device_name` as stated, before the {@link modelName} fallback is applied. */
@@ -201,6 +206,16 @@ export declare class Device {
201
206
  * Apply a raw param map (cloud record or P2P notification). Known params update their named
202
207
  * property; unrecognised params are retained as `unknown_<paramType>` so nothing is lost.
203
208
  *
209
+ * Naming precedence: this device's own `PropertySpec` (curated), then the param dictionary for its
210
+ * namespace, then the `unknown_<paramType>` passthrough. The dictionary def is consulted even where a
211
+ * spec exists, because `encoding` lives there.
212
+ *
213
+ * A param a resolved capability's gate WITHHELD takes the passthrough instead of its dictionary name.
214
+ * The gate decided the read does not describe this device, the dictionary names it what the member
215
+ * would have, and republishing it there hands a caller a reading indistinguishable from one the device
216
+ * really answered. A capability that never resolved withholds nothing: a param arriving before its
217
+ * capability is still the device's own, and keeps its dictionary name.
218
+ *
204
219
  * @param params param_type → raw value.
205
220
  * @param ts observation time (epoch ms); defaults to `Date.now()`.
206
221
  * @returns the list of property names whose value changed.
@@ -88,7 +88,7 @@ export interface P2PRouterDeps {
88
88
  }
89
89
  export declare class P2PCommandRouter {
90
90
  private readonly deps;
91
- /** Per-station P2P session lifecycle: on-demand open + battery-aware idle-detach + refcount. */
91
+ /** P2P session lifecycle: on-demand open + battery-aware idle-detach + refcount, per session key. */
92
92
  private readonly manager;
93
93
  /** Error objects already forwarded while a station startup awaits the same session signal. */
94
94
  private readonly reportedErrors;
@@ -96,6 +96,21 @@ export declare class P2PCommandRouter {
96
96
  private readonly liveSources;
97
97
  /** The options each live source was built from, so a later caller's conflicting ones can be reported. */
98
98
  private readonly liveSourceOpts;
99
+ /**
100
+ * The session each live source pulls over — the station's own serial, or the source's own media
101
+ * session key.
102
+ *
103
+ * Written the instant the choice is made and BEFORE the connection is opened, which is what makes it
104
+ * a reservation rather than a record. Two cameras started together (the four-tile case this feature
105
+ * exists for) would otherwise both find the station's session free: a consumer attaches only after
106
+ * `sharedLiveSourceFor` returns, so neither is visible to the other through consumer counts, and both
107
+ * would take the shared connection and contend on it.
108
+ *
109
+ * A station entry is kept as well as a media one, because "is the station's own session already
110
+ * claimed" is the question being asked, and an entry that is present but not yet in
111
+ * {@link liveSources} is a claim in flight.
112
+ */
113
+ private readonly liveSessionKeys;
99
114
  /**
100
115
  * The open talkback per `${parentSn}:${channel}`, if any. The device plays one audio stream at a
101
116
  * time and the session carries one audio sequence, so this path is exclusive where a live pull is
@@ -122,7 +137,10 @@ export declare class P2PCommandRouter {
122
137
  * `no P2P session`.
123
138
  */
124
139
  static claimsDevice(dev: EufyDevice): boolean;
125
- /** Stations with a live P2P session (a snapshot; mutate via the lifecycle methods, not this map). */
140
+ /**
141
+ * The open P2P sessions by key — a station's own under its serial, a camera's media session under
142
+ * `<stationSn>#live:<channel>` (a snapshot; mutate via the lifecycle methods, not this map).
143
+ */
126
144
  getSessions(): Map<string, P2PSession>;
127
145
  /**
128
146
  * Speculatively open + briefly hold a station's session (e.g. after a doorbell ring) so a
@@ -132,7 +150,7 @@ export declare class P2PCommandRouter {
132
150
  *
133
151
  * One hold, taken before the open so a slow connect can't idle-close mid-flight. It expires on its
134
152
  * own, which arms the station's idle window rather than closing the session, per {@link PREWARM_MS}.
135
- * A second hold after the open would buy nothing: {@link openStation} returns once the socket is bound
153
+ * A second hold after the open would buy nothing: {@link openSession} returns once the socket is bound
136
154
  * and the lookups are away, not once the peer has answered, so both would expire together.
137
155
  *
138
156
  * Best-effort — a failed open surfaces via `onError`. A {@link SessionSupersededError} does not: the
@@ -165,7 +183,41 @@ export declare class P2PCommandRouter {
165
183
  * when present, else the freshest private IP in the record ({@link freshestLanIp}) — so P2P works
166
184
  * on-LAN even when broadcast is blocked (AP isolation) or the record's `ip_addr` went stale.
167
185
  */
168
- private openStation;
186
+ /**
187
+ * The key a camera's own media session is filed under, distinct from every station serial because a
188
+ * serial contains no `#`.
189
+ */
190
+ private static mediaSessionKey;
191
+ /** Whether `key` names a media session rather than a station's own. */
192
+ private static isMediaSessionKey;
193
+ /**
194
+ * Open (or reuse) a SECOND connection to a station, carrying one camera's media and nothing else.
195
+ *
196
+ * One session serves one camera: a station fans its cameras over a session and answers the most recent
197
+ * start on it, so two cameras down one tunnel take it from each other in turn. Another connection is
198
+ * how a station serves another camera.
199
+ *
200
+ * The hardware was shown to do this before the SDK did. A first-party display showing four tiles was
201
+ * captured opening one PPCS session per camera, with three cameras' video arriving in the same second
202
+ * at 2304x1296, 1600x1200 and 3840x2160 — three geometries at once, which no composed stream can be.
203
+ * Reproduced here afterwards on a base carrying two attached cameras, one at 3840x2160, both holding
204
+ * full frame rate at once over a session each, where the same pair down one session could only take
205
+ * turns.
206
+ *
207
+ * It carries media alone. The station announces its state to every client that connects, so a session
208
+ * wired to the same fan-out would report every event a second time; {@link makeSession} leaves this one
209
+ * unannounced, and the station's own session stays the single source of connection state, control
210
+ * notifications and frames.
211
+ *
212
+ * The level-2 key is waited for here on the same terms the station's own session gets, because a
213
+ * connection is not usable for this without one: an attached camera's media start has no level-1 form,
214
+ * so a start issued before the key arrives is dropped as `media-command-unsent`, and the stream then
215
+ * shows nothing until a later keepalive tick happens to find the key. A connected session that cannot
216
+ * carry the start is not a connected session, so this refuses rather than returning one.
217
+ */
218
+ private openMediaSession;
219
+ /** Open (or reuse) the session filed under `key`, dialling `parentSn`'s endpoint. */
220
+ private openSession;
169
221
  /**
170
222
  * Build + wire a {@link P2PSession} for a station (NOT yet connected — the caller awaits `connect()`).
171
223
  *
@@ -177,6 +229,12 @@ export declare class P2PCommandRouter {
177
229
  *
178
230
  * The `close` handler drops the session from the {@link SessionManager} and disposes any shared live
179
231
  * source riding this station (consumers get `stop`; a later attach rebuilds via the factory).
232
+ *
233
+ * A session filed under a media key is wired for errors and its own teardown ONLY. Connection state,
234
+ * level-2 readiness and inbound frames all reach the owner through the station's own session, and a
235
+ * station announces those to every client that connects — so fanning a second connection's copies out
236
+ * under the same station serial would report each one twice, and a close would tear down the station
237
+ * while its own session is still serving.
180
238
  */
181
239
  private makeSession;
182
240
  /**
@@ -285,22 +343,40 @@ export declare class P2PCommandRouter {
285
343
  * {@link releaseLingeringSiblings}. A pull with consumers is never touched. The release runs before the
286
344
  * reuse branch, so a reuse frees the station as a cold start does.
287
345
  *
346
+ * `mayOpenOwnSession` decides whether a camera that finds the station's own session claimed may open a
347
+ * connection of its own for it. Only the continuous-pull egresses pass it. A still must not: it wants one
348
+ * frame, and a socket plus a level-2 negotiation per thumbnail is a cost a tile refresh cannot justify —
349
+ * so a still asked for while a sibling is being watched contends as it always did, and the caller's
350
+ * retained image answers it. A live view still outranks a tile; what changed is that two live views no
351
+ * longer have to outrank each other.
352
+ *
288
353
  * The session goes into a {@link HeldSession} cell, so it can be replaced under a source that stays in
289
354
  * place.
290
355
  */
291
- sharedLiveSourceFor(sn: string, opts?: SharedLiveOpts): Promise<SharedLiveSource>;
356
+ sharedLiveSourceFor(sn: string, opts?: SharedLiveOpts, mayOpenOwnSession?: boolean): Promise<SharedLiveSource>;
292
357
  /**
293
358
  * Tear down any pull on this station that is lingering for ANOTHER camera, before starting this one.
294
359
  *
295
360
  * A lingering pull has no consumers but is still held open, and on an attached camera holding it open means
296
- * re-sending the full media start every keepalive tick. Two channels doing that at once on a station that
297
- * serves one camera at a time leaves the new stream receiving nothing but the old camera's frames for as
298
- * long as the linger lasts.
361
+ * re-sending the full media start every keepalive tick. Two channels doing that at once over ONE session,
362
+ * which serves one camera at a time, leaves the new stream receiving nothing but the old camera's frames
363
+ * for as long as the linger lasts.
299
364
  *
300
365
  * Several cameras genuinely being WATCHED together are never disturbed — the linger exists to make
301
366
  * re-opening the SAME camera cheap, and it keeps doing that. What it may not do is keep a camera nobody is
302
367
  * looking at competing with one somebody just asked for.
303
368
  *
369
+ * An `idle` source is skipped, because it is not a linger and holds nothing: it has never warmed, so it
370
+ * has sent no media start and is competing for nothing. Without that, two cameras opened at the same
371
+ * moment destroy each other — the second finds the first's source built but not yet attached to, reads
372
+ * zero consumers as a linger, and disposes the source its caller is holding. Which is the four-tile case
373
+ * this whole path exists for.
374
+ *
375
+ * A sibling lingering on a connection of its OWN is skipped for the same reason stated the other way: the
376
+ * contention this releases is contention over one session, and that sibling is not on this one. Dropping
377
+ * it would close a socket and throw away the cheap re-attach the linger exists to provide, to relieve a
378
+ * competition that is not happening.
379
+ *
304
380
  * A snapshot tile is nobody looking. Opening a live view in the Home app takes that cell fullscreen, so the
305
381
  * pulls refreshing the other cells are off screen, yet each goes on re-issuing its own media start every
306
382
  * retry tick — measured as four pulls warming together off one HomeBase, a live request landing 1.4 s later,
@@ -311,14 +387,27 @@ export declare class P2PCommandRouter {
311
387
  */
312
388
  private releaseLingeringSiblings;
313
389
  /**
314
- * The channel a live viewer already holds on this station, if any, ignoring `key` itself.
390
+ * Whether another camera has already claimed this station's OWN session, ignoring `key`.
391
+ *
392
+ * The question a newcomer has to answer is not whether the station is busy — it can serve one camera
393
+ * per connection — but whether the connection it would otherwise share is taken. A camera on a media
394
+ * session of its own does not hold this one, so a station whose first camera has since stopped hands
395
+ * its own session to the next arrival rather than opening a socket beside an idle one.
396
+ *
397
+ * A claim counts while its source is still `idle`, source or no source. That is a start that has been
398
+ * handed to its caller but not yet attached to, and it is the only state in which two cameras asked for
399
+ * at the same moment can see each other: consumers attach after this method has already run for both.
400
+ *
401
+ * The cost is that a pull genuinely abandoned before its first attach goes on holding the station's own
402
+ * session, and the next camera pays for a connection of its own rather than reclaiming it. That is one
403
+ * socket against destroying a start someone is waiting on, which is not a close trade.
315
404
  *
316
405
  * A stopped source is skipped even when consumers are still attached to it. A failed start fails its
317
406
  * consumers without detaching them, so a caller still holding a dead handle leaves the count non-zero,
318
- * and counting that as a viewer would refuse every later stream on the station until the client
319
- * restarted. Only a source that can still deliver holds a place.
407
+ * and counting that as a viewer would put every later camera on a connection of its own until the
408
+ * client restarted. Only a source that can still deliver holds a place.
320
409
  */
321
- private occupiedSiblingChannel;
410
+ private stationSessionInUse;
322
411
  /**
323
412
  * Whether the stream on `key` should re-assert its channel to hold the station.
324
413
  *
@@ -328,8 +417,8 @@ export declare class P2PCommandRouter {
328
417
  * - Nothing attached: no. There is nobody to take the station for.
329
418
  * - A live viewer attached: yes. That is the picture someone is looking at.
330
419
  * - Held only for stills, while a sibling on this station has a live viewer: no. A still refreshes a
331
- * tile that is off screen while the live view is on it, and a station serving one camera at a time
332
- * cannot satisfy both. Measured: a still on a sibling halved a live view's frame rate for as long as
420
+ * tile that is off screen while the live view is on it, and one session serving one camera at a time
421
+ * 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
333
422
  * it took, and its own capture then took fifteen seconds because it was contending.
334
423
  *
335
424
  * A still with no live sibling re-asserts, so a tile refreshing on a quiet station is
@@ -344,8 +433,24 @@ export declare class P2PCommandRouter {
344
433
  * not.
345
434
  */
346
435
  private attachUnlessAborted;
347
- /** Dispose one cached live source and forget it, so the next acquisition builds a fresh one. */
436
+ /**
437
+ * Dispose one cached live source and forget it, so the next acquisition builds a fresh one. Its claim
438
+ * on a session goes with it, and a media session opened for this source alone is closed: nothing else
439
+ * can reach that connection, so leaving it open would hold a socket and a station keepalive for a
440
+ * camera no longer being pulled. A claim on the STATION's own session is released without closing
441
+ * anything — that connection carries the station's control traffic and outlives any one camera.
442
+ */
348
443
  private dropLiveSource;
444
+ /** Close and forget the media session `key`'s live source owned, if it owned one. */
445
+ private closeMediaSession;
446
+ /**
447
+ * Drop the live source a media session was carrying, after that session closed on its own.
448
+ *
449
+ * The source holds the closed connection and never re-resolves it, so it can only answer its retained
450
+ * keyframe and then fail on its warm-up deadline. Its consumers get `stop`, and the next attach builds
451
+ * a fresh source — which, finding the station busy again, opens a fresh media session for it.
452
+ */
453
+ private tearDownMediaSession;
349
454
  /**
350
455
  * Drop everything that was riding a station's session, and report the station closed.
351
456
  *
@@ -67,7 +67,7 @@ export interface LiveStreamOptions {
67
67
  * How long an attached stream tolerates silence on its own channel before re-asserting again, in ms.
68
68
  *
69
69
  * The re-assert is settled by the first own-channel frame, because settling it is what stops two attached
70
- * streams contending on a station that serves one camera at a time. Silence for this long says the station
70
+ * streams contending over one session, which serves one camera at a time. Silence for this long says the station
71
71
  * is no longer serving this camera, which is the only condition the re-assert was for. Defaults to twice
72
72
  * the keepalive interval, so a stream whose media flows never reaches it.
73
73
  */
@@ -129,8 +129,9 @@ export declare class LiveStream extends EventEmitter {
129
129
  * Stop re-issuing the media start once this camera's own media has arrived, on an attached camera.
130
130
  *
131
131
  * The nudge differs by topology and only one branch is a ping: an own-session camera sends a small
132
- * keepalive, while an attached camera has no such state and re-sends the FULL media start. On a station that
133
- * serves one camera at a time that restart re-asserts this channel against whatever else is warm, so two
132
+ * keepalive, while an attached camera has no such state and re-sends the FULL media start. Over one session,
133
+ * which serves one camera at a time, that restart re-asserts this channel against whatever else is warm on
134
+ * it, so two
134
135
  * attached streams restart every interval and contend for the station continuously — measured on a real base
135
136
  * as a full start every 3 s from each.
136
137
  *
@@ -185,7 +186,7 @@ export declare class LiveStream extends EventEmitter {
185
186
  *
186
187
  * The match is UNCONDITIONAL, however long a station serves another camera instead of this one.
187
188
  *
188
- * A station serving one camera at a time hands a newly opened camera nothing but its sibling's frames until
189
+ * One session serving one camera at a time hands a newly opened camera nothing but its sibling's frames until
189
190
  * it switches, so "no media of my own yet, plenty for someone else" is what an ordinary handover looks like
190
191
  * and does not distinguish a station that tags differently from one that is simply busy.
191
192
  *
@@ -36,8 +36,8 @@ export declare function openLiveStream(session: P2PSession, opts?: LiveStreamOpt
36
36
  * image, so the image is its source of truth.
37
37
  *
38
38
  * The consumer is detached the moment the collected run is complete, and the decode that follows holds no
39
- * station: it works on bytes already in memory. A station serves one camera at a time and the SDK refuses a
40
- * second channel on one that is busy, so a still that kept its pull attached across its own decode would deny
39
+ * station: it works on bytes already in memory. One session serves one camera at a time and a still never
40
+ * opens a second one, so a still that kept its pull attached across its own decode would deny
41
41
  * that station to every live request for the length of an FFmpeg run — measured on a real base as a live
42
42
  * request refused 370ms after the still it was waiting on had already collected everything it needed.
43
43
  *
@@ -26,24 +26,35 @@ export interface SessionManagerOpts {
26
26
  batteryIdleMs?: number;
27
27
  /** Keepalive a single command holds after dispatch (ms). Default {@link COMMAND_KEEPALIVE_MS}. */
28
28
  commandKeepAliveMs?: number;
29
- /** Power tier per station serial — injected by the facade (no model import). Default: everything `wired`. */
29
+ /**
30
+ * Power tier per station serial — injected by the facade (no model import). Default: everything
31
+ * `wired`. Asked about the STATION a session connects to, never the key it is filed under, so every
32
+ * session to one station gets that station's idle window.
33
+ */
30
34
  poweredFor?: (parentSn: string) => PowerTier;
31
35
  /**
32
- * Called after the manager closes a station on its OWN initiative — an elapsed idle window, or a
33
- * deferred reset falling due.
36
+ * Called with the KEY of a session the manager closed on its OWN initiative — an elapsed idle window,
37
+ * or a deferred reset falling due. The key, not the station: several sessions can share a station and
38
+ * only the one that closed is stale, so an owner told the station would tear down connections that are
39
+ * still serving.
34
40
  *
35
41
  * Those two are the only closes with no caller to follow up: everything riding the session is stale the
36
42
  * moment it goes, and only the owner knows what that is. A close a caller asked for is that caller's to
37
43
  * clean up after, which is why this does not fire for {@link SessionManager.close},
38
44
  * {@link SessionManager.closeAll}, or a superseded open.
39
45
  */
40
- onAutoClose?: (parentSn: string) => void;
46
+ onAutoClose?: (key: string) => void;
41
47
  /** Diagnostics sink for the lifecycle transitions (open / idle-arm / detach). Omit for silence. */
42
48
  logger?: Logger;
43
49
  }
44
50
  /**
45
- * Manages P2P sessions keyed by **parent station serial**. The router builds/wires the actual
46
- * `P2PSession` (it owns the socket + event fan-out); this decides open/close timing.
51
+ * Manages P2P sessions by **key**, each recording the station it connects to. The router builds and
52
+ * wires the actual `P2PSession` (it owns the socket and event fan-out); this decides open/close timing.
53
+ *
54
+ * A key is the station's serial for the one session that carries its control traffic and its events.
55
+ * Where a station has to serve more than one camera at once it also holds a session per camera, filed
56
+ * under a key of the router's choosing and recording the same station — so each has its own refcount and
57
+ * its own idle window, and releasing one never disturbs another.
47
58
  */
48
59
  export declare class SessionManager {
49
60
  private readonly opts;
@@ -53,23 +64,40 @@ export declare class SessionManager {
53
64
  private readonly logger;
54
65
  constructor(opts?: SessionManagerOpts);
55
66
  /** The live session for a station, or `undefined` if not open. */
56
- get(parentSn: string): P2PSession | undefined;
57
- /** Serials of stations with a live session. */
67
+ get(key: string): P2PSession | undefined;
68
+ /** Keys of the open sessions. */
58
69
  keys(): string[];
59
- /** A plain `Map<parentSn, P2PSession>` snapshot of the live sessions (for `getSessions()` / tests). */
70
+ /** A plain `Map<key, P2PSession>` snapshot of the open sessions (for `getSessions()` / tests). */
60
71
  liveSessions(): Map<string, P2PSession>;
61
- /** Get or create the lifecycle entry for a station. */
72
+ /**
73
+ * Get or create the lifecycle entry under `key`, recording which station it connects to. An existing
74
+ * entry keeps the station it was opened with — the key owns one connection for its lifetime, and a
75
+ * later caller passing a different station would otherwise re-point a live entry's power tier.
76
+ */
62
77
  private entry;
63
- /** Register an already-built session for test seeding or an externally assembled connection. */
64
- register(parentSn: string, session: P2PSession): void;
65
78
  /**
66
- * Ensure a session to `parentSn` is open, building it via `factory` if cold. Concurrent calls for the
79
+ * Register an already-built session for test seeding or an externally assembled connection.
80
+ *
81
+ * `station` defaults to the key, which is safe HERE and nowhere else in this class: the paths that open a
82
+ * session under a key that is not a station serial all go through {@link acquire}, which requires it. A
83
+ * caller seeding one under such a key must pass it.
84
+ */
85
+ register(key: string, session: P2PSession, station?: string): void;
86
+ /**
87
+ * Ensure a session to `key` is open, building it via `factory` if cold. Concurrent calls for the
67
88
  * same cold station share ONE connect (the `connecting` promise); `factory` builds + wires + awaits
68
89
  * `connect()` and resolves the connected session.
69
90
  */
70
- acquire(parentSn: string, factory: (register: (session: P2PSession) => void) => Promise<P2PSession>): Promise<P2PSession>;
71
- /** Add a reason to stay connected; cancels a pending idle-close. */
72
- retain(parentSn: string): void;
91
+ acquire(key: string, factory: (register: (session: P2PSession) => void) => Promise<P2PSession>, station: string): Promise<P2PSession>;
92
+ /**
93
+ * Add a reason to stay connected; cancels a pending idle-close.
94
+ *
95
+ * Refused when nothing is open under `key`, for the reason {@link release} gives in the other direction: a
96
+ * retain names a session that was acquired, and one that names nothing would file an entry with no
97
+ * connection behind it. {@link hold} is the path that legitimately creates one, and it opens the entry
98
+ * itself before retaining it.
99
+ */
100
+ retain(key: string): void;
73
101
  /**
74
102
  * Release a reason; arm the idle-close when the last one goes.
75
103
  *
@@ -78,22 +106,28 @@ export declare class SessionManager {
78
106
  * from scratch or complete a deferred reset a real viewer has not yet earned. Clamping to zero did
79
107
  * both silently.
80
108
  */
81
- release(parentSn: string): void;
109
+ release(key: string): void;
82
110
  /**
83
111
  * Hold a session warm for `commandKeepAliveMs` after a control command, then release. A burst of
84
112
  * commands each re-holds before the previous release fires, so the session never idles mid-burst.
85
113
  */
86
- bumpCommand(parentSn: string): void;
114
+ bumpCommand(key: string, station: string): void;
87
115
  /**
88
- * Retain a station and release it again after `ms` — the primitive behind command-keepalive and event
116
+ * Retain a session and release it again after `ms` — the primitive behind command-keepalive and event
89
117
  * pre-warm, and the only way to hold one open without an attachment to release it.
90
118
  *
119
+ * `station` is required rather than defaulted from the key, because this is the one path that can
120
+ * CREATE an entry: a pre-warm takes its hold before the open. An entry filed under a media key with
121
+ * that key as its own station would be asked for the power tier of a serial that does not exist, be
122
+ * answered `wired`, and never idle-detach — which on a battery station is the drain this class exists
123
+ * to prevent, and is invisible until the battery is flat.
124
+ *
91
125
  * The timer is owned by the entry, so {@link discard} cancels it. That ownership is the point: keyed
92
126
  * only by serial, an expiring hold would otherwise outlive the entry it was taken on and release a
93
127
  * retain counted by the SUCCESSOR entry — dropping a live viewer's count and arming an idle-detach
94
128
  * underneath it.
95
129
  */
96
- hold(parentSn: string, ms: number): void;
130
+ hold(key: string, ms: number, station: string): void;
97
131
  /**
98
132
  * Arm the idle-close timer for a station whose retain count just reached zero. A wired station with
99
133
  * an infinite window is left persistent (no timer). Any subsequent {@link retain} cancels it.
@@ -123,9 +157,9 @@ export declare class SessionManager {
123
157
  */
124
158
  private autoClose;
125
159
  /** Drop a station's entry + timer (called from the session's `close` handler). Idempotent. */
126
- remove(parentSn: string): void;
160
+ remove(key: string): void;
127
161
  /** Close one station now and discard its lifecycle entry. */
128
- close(parentSn: string): Promise<void>;
162
+ close(key: string): Promise<void>;
129
163
  /**
130
164
  * Reset once every viewer detaches, ignoring only expiring holds.
131
165
  *
@@ -136,7 +170,7 @@ export declare class SessionManager {
136
170
  * Both branches close through {@link autoClose}: the caller asked for a recycle, not for the station's
137
171
  * live sources to be dropped, so it does not clean up after one — exactly like the idle path.
138
172
  */
139
- resetWhenUnused(parentSn: string): Promise<void>;
173
+ resetWhenUnused(key: string): Promise<void>;
140
174
  /** Settle a discarded entry's reset callers with the same outcome as its session close. */
141
175
  private settleReset;
142
176
  /** Close one discarded entry and settle only its own reset callers before preserving any failure. */
@@ -10,7 +10,7 @@ export interface SharedLiveSourceOptions {
10
10
  * `.start()` itself.
11
11
  *
12
12
  * `ctx.reassertWanted` answers whether this pull still has anyone attached. A stream that re-asserts a
13
- * channel to hold it open should consult it, so a pull nothing is watching stops competing for a station
13
+ * channel to hold it open should consult it, so a pull nothing is watching stops competing for a session
14
14
  * that serves one camera at a time.
15
15
  */
16
16
  makeStream: (ctx: {
@@ -73,6 +73,15 @@ export interface SharedLiveSourceOptions {
73
73
  * an owner does in response to this callback.
74
74
  */
75
75
  onStartFailed?: () => void;
76
+ /**
77
+ * The pull has ended and will not resume: the linger elapsed, the battery budget ran out, or the source
78
+ * was torn down. A later attach builds a fresh stream rather than reviving this one.
79
+ *
80
+ * Distinct from {@link onIdle} by what is still running. `onIdle` fires at the last detach, while the
81
+ * linger is still holding the pull open so a quick re-attach costs nothing; between the two the pull is
82
+ * alive. This fires when it is not.
83
+ */
84
+ onStopped?: () => void;
76
85
  /**
77
86
  * A media start was abandoned unacknowledged before anything was delivered, so this session is not being
78
87
  * heard. The owner is asked for a replacement and calls {@link SharedLiveSource.rewarm} once it has one.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mega-yfue/eufy-sdk",
3
- "version": "0.2.0-beta.10",
3
+ "version": "0.2.0-beta.12",
4
4
  "description": "One typed TypeScript client for the Anker eufy v6 cloud — capability-driven devices, realtime events over P2P/MQTT/push, and live media",
5
5
  "license": "Apache-2.0",
6
6
  "author": "mega-yfue",