@mega-yfue/eufy-sdk 0.2.0-beta.2 → 0.2.0-beta.21

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.
Files changed (52) hide show
  1. package/dist/client/eufy-mega.d.ts +16 -11
  2. package/dist/client/types.d.ts +6 -2
  3. package/dist/core/contracts.d.ts +57 -27
  4. package/dist/core/crypto.d.ts +10 -0
  5. package/dist/core/index.d.ts +1 -0
  6. package/dist/core/logger.d.ts +5 -3
  7. package/dist/core/solix-types.d.ts +115 -0
  8. package/dist/core/store.d.ts +38 -10
  9. package/dist/index.js +2713 -452
  10. package/dist/index.js.map +4 -4
  11. package/dist/model/capabilities/access.d.ts +22 -3
  12. package/dist/model/capabilities/arming.d.ts +33 -27
  13. package/dist/model/capabilities/battery.d.ts +32 -4
  14. package/dist/model/capabilities/contact.d.ts +4 -0
  15. package/dist/model/capabilities/display.d.ts +85 -0
  16. package/dist/model/capabilities/index.d.ts +18 -3
  17. package/dist/model/capabilities/ptz.d.ts +6 -2
  18. package/dist/model/capabilities/solix.d.ts +173 -0
  19. package/dist/model/capabilities/types.d.ts +21 -5
  20. package/dist/model/capabilities/vacuum-clean.d.ts +74 -25
  21. package/dist/model/device.d.ts +15 -0
  22. package/dist/model/index.d.ts +5 -0
  23. package/dist/model/param-dictionary.d.ts +24 -0
  24. package/dist/model/param-namespace.d.ts +1 -1
  25. package/dist/model/solix-catalog.d.ts +25 -0
  26. package/dist/model/solix-device.d.ts +136 -0
  27. package/dist/model/solix-family.d.ts +31 -0
  28. package/dist/model/solix-site.d.ts +70 -0
  29. package/dist/model/types.d.ts +5 -5
  30. package/dist/transport/ff09.d.ts +7 -0
  31. package/dist/transport/http/decodeImageV2.d.ts +8 -14
  32. package/dist/transport/http/index.d.ts +1 -0
  33. package/dist/transport/http/jpeg-scan.d.ts +59 -0
  34. package/dist/transport/http/media-download.d.ts +3 -0
  35. package/dist/transport/http/mega-client.d.ts +89 -9
  36. package/dist/transport/http/solix-client.d.ts +269 -0
  37. package/dist/transport/http/solix-constants.d.ts +56 -0
  38. package/dist/transport/media-failure.d.ts +48 -0
  39. package/dist/transport/mqtt/index.d.ts +2 -0
  40. package/dist/transport/mqtt/secure-mqtt.d.ts +14 -1
  41. package/dist/transport/mqtt/solix-mqtt.d.ts +321 -0
  42. package/dist/transport/mqtt/topics.d.ts +30 -0
  43. package/dist/transport/p2p/command-router.d.ts +152 -15
  44. package/dist/transport/p2p/index.d.ts +1 -0
  45. package/dist/transport/p2p/live-stream.d.ts +5 -4
  46. package/dist/transport/p2p/live-trace.d.ts +118 -6
  47. package/dist/transport/p2p/media.d.ts +11 -0
  48. package/dist/transport/p2p/p2p-session.d.ts +13 -0
  49. package/dist/transport/p2p/session-manager.d.ts +57 -23
  50. package/dist/transport/p2p/shared-live-source.d.ts +10 -1
  51. package/dist/transport/stored-image-cache.d.ts +7 -1
  52. package/package.json +3 -2
@@ -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
  *
@@ -63,12 +63,51 @@ export type LiveTrace =
63
63
  phase: "sequence-restart";
64
64
  dataType: number;
65
65
  }
66
+ /**
67
+ * Which lookup channels a connection can ask for the station on, before it asks.
68
+ *
69
+ * A station is found by a local lookup, by a cloud lookup, or by both, and each needs something the other
70
+ * does not: the local one needs the station on this link, the cloud one needs both a key for the station and
71
+ * an address to ask. A connect that had one channel failed for that channel's reason alone, and a connect
72
+ * that had neither could not have succeeded — outcomes a station that is switched off is otherwise
73
+ * indistinguishable from, because nothing else in a failed connect states what was even attempted.
74
+ */
75
+ | {
76
+ phase: "lookup-channels";
77
+ local: boolean;
78
+ cloud: boolean;
79
+ }
80
+ /**
81
+ * Work on a station is holding for its session to connect, with the milliseconds it will wait.
82
+ *
83
+ * The earliest phase there is: nothing else on a station can be attempted until its session is up, and a
84
+ * caller whose own deadline expires inside this wait has this record and no other. Emitted only where a wait
85
+ * actually happens, so its absence states that the session was already connected.
86
+ */
87
+ | {
88
+ phase: "session-connect-wait";
89
+ waitMs: number;
90
+ }
91
+ /** The session connected, after this long. */
92
+ | {
93
+ phase: "session-connected";
94
+ waitedMs: number;
95
+ }
96
+ /**
97
+ * The session did not connect within its wait, so nothing on this station can be attempted.
98
+ *
99
+ * The one outcome that is otherwise indistinguishable from a station that answered and then refused: both
100
+ * leave a caller with no media and no phase naming a station.
101
+ */
102
+ | {
103
+ phase: "session-unreachable";
104
+ waitedMs: number;
105
+ }
66
106
  /**
67
107
  * A live start is holding for the station's level-2 key, with the milliseconds it will wait.
68
108
  *
69
- * The first of three phases that account for the wait before any media command is sent. A start that looks
70
- * slow is either waiting here, waiting for the station to serve the channel it was asked for, or being
71
- * re-issued — and only these separate them.
109
+ * A start that looks slow is either waiting here, waiting for its session to connect, waiting for the station
110
+ * to serve the channel it was asked for, or being re-issued — and only these phases separate them.
72
111
  */
73
112
  | {
74
113
  phase: "level2-wait";
@@ -79,10 +118,67 @@ export type LiveTrace =
79
118
  phase: "level2-ready";
80
119
  cipherId: number;
81
120
  }
82
- /** The level-2 key did not arrive in its grace, so the start proceeds at level 1 or not at all. */
121
+ /**
122
+ * The station's key is not coming, why, and the cipher where a station named one.
123
+ *
124
+ * Every ending of a level-2 wait carries one of these reasons, so a start refused for want of a key is
125
+ * accounted for however it ended. `grace-elapsed` is a wait that ran out and states how long was waited;
126
+ * the rest are answered without waiting, because the negotiation is one-shot per connection and a
127
+ * concluded one is final. `no-cipher-key` and `derivation-failed` are about this account's cipher
128
+ * material, `not-negotiating` and `session-closed` about the station or its connection — and only a
129
+ * reason reached under a negotiation has a cipher to name.
130
+ *
131
+ * A `grace-elapsed` start proceeds at level 1 where it has such a form, and not at all where it does not.
132
+ */
83
133
  | {
84
- phase: "level2-absent";
85
- waitedMs: number;
134
+ phase: "level2-unavailable";
135
+ reason: "no-cipher-key" | "derivation-failed" | "not-negotiating" | "session-closed" | "grace-elapsed";
136
+ cipherId?: number;
137
+ waitedMs?: number;
138
+ }
139
+ /**
140
+ * A station's cipher was answered with material for a DIFFERENT cipher, which was used in its place.
141
+ *
142
+ * The one lookup outcome no other phase accounts for: material for the cipher the station named is followed
143
+ * by `level2-ready` or by `level2-unavailable` with `derivation-failed`, an answer holding none by
144
+ * `no-cipher-key`, and a lookup that threw is reported as an error. Substituted material derives to
145
+ * nothing and otherwise reads as a station fault. `cipherId` is the cipher the station asked for,
146
+ * `answeredCipherId` the one whose material was used.
147
+ */
148
+ | {
149
+ phase: "cipher-fallback";
150
+ cipherId: number;
151
+ answeredCipherId: number;
152
+ }
153
+ /**
154
+ * The station answered its gateway-info prompt, so a key derivation has begun under the cipher it named.
155
+ *
156
+ * What separates a station that never answered the prompt from one that answered and produced no usable key:
157
+ * without it, `level2-unavailable` with `not-negotiating` covers both, and they are a station or network
158
+ * problem and an account cipher-material problem respectively.
159
+ */
160
+ | {
161
+ phase: "level2-negotiating";
162
+ cipherId: number;
163
+ }
164
+ /**
165
+ * A station was resolved for a call, stating what the caller's device is on it and whose station it is.
166
+ *
167
+ * Emitted before the session is waited on, so a station that is never reached still has this record: an
168
+ * attached camera's media start has no unencrypted form, so whether a device was taken as attached decides
169
+ * what its failure means. `stationAdmin` states whether the signed-in account is the station's
170
+ * administrator, which is what a key the account cannot resolve turns on; `unstated` is a device record
171
+ * that names no administrator, which is not the same as naming another. `stationModel` is the model of the
172
+ * station the call resolved — the base's for an attached camera, the device's own where it is its own
173
+ * station — absent where that record states none; without it a base this SDK reaches differently is
174
+ * indistinguishable from one that is switched off.
175
+ */
176
+ | {
177
+ phase: "station-resolved";
178
+ topology: "attached" | "own";
179
+ channel: number;
180
+ stationAdmin: "self" | "other" | "unstated";
181
+ stationModel?: string;
86
182
  }
87
183
  /** A shared source began warming, with the interval it re-issues on and the deadline it fails at. */
88
184
  | {
@@ -110,6 +206,22 @@ export type LiveTrace =
110
206
  | {
111
207
  phase: "path-stale";
112
208
  silentMs: number;
209
+ }
210
+ /**
211
+ * A stream received nothing on its own channel for the stall window, and what was done about it.
212
+ *
213
+ * A station that switches to a sibling leaves the stream it was serving with no frames, no error and no
214
+ * stop, so this silence is the only statement that it happened. `reasserted` re-issued the media start,
215
+ * which is the repair; `declined` left the channel alone because nothing is attached to this pull and
216
+ * taking the station back would take it from a camera someone is watching.
217
+ *
218
+ * Media still arriving means this never fires, so a picture that stopped advancing while this is silent
219
+ * stopped for a reason upstream of the station's attention.
220
+ */
221
+ | {
222
+ phase: "channel-silent";
223
+ silentMs: number;
224
+ outcome: "reasserted" | "declined";
113
225
  };
114
226
  /**
115
227
  * Record one startup observation at debug level.
@@ -34,11 +34,22 @@ export declare function openLiveStream(session: P2PSession, opts?: LiveStreamOpt
34
34
  * The header states the stream's geometry when the capture started, and a stream that reconfigures
35
35
  * mid-burst leaves it describing something the returned bytes contradict; the return value describes an
36
36
  * image, so the image is its source of truth.
37
+ *
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. 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
+ * that station to every live request for the length of an FFmpeg run — measured on a real base as a live
42
+ * request refused 370ms after the still it was waiting on had already collected everything it needed.
43
+ *
44
+ * `signal` ends the collection itself, not only the wait for it, and rejects with the signal's own reason
45
+ * because the abandonment is the caller's fact and not a failure of the source. It reaches only the
46
+ * collection: past that the station is already free, so there is nothing left for it to release.
37
47
  */
38
48
  export declare function captureSnapshotFromShared(source: SharedLiveSource, opts?: {
39
49
  timeoutMs?: number;
40
50
  collectMs?: number;
41
51
  skipKeyframes?: number;
52
+ signal?: AbortSignal;
42
53
  logger?: Logger;
43
54
  ffmpegLevel?: FfmpegLevel;
44
55
  ffmpegPath?: string;
@@ -16,6 +16,15 @@
16
16
  import { EventEmitter } from "node:events";
17
17
  import { type Address, type P2PDataFrameHeader } from "./codec.js";
18
18
  import { type Logger } from "../../core/logger.js";
19
+ /**
20
+ * How long a station is given to answer a lookup before the connection gives up on it and closes.
21
+ *
22
+ * The whole deadline for reaching a station: the lookups are re-sent every second until one is answered, and
23
+ * a connection that reaches this closes itself, so nothing addressed to that station can succeed afterwards.
24
+ * Published because it bounds every wait on a session connecting — a second number for the same deadline
25
+ * elsewhere would outlive the connection it waits on and charge the difference to every failure.
26
+ */
27
+ export declare const CONNECT_TIMEOUT_MS = 15000;
19
28
  /**
20
29
  * The channel a command addresses the station itself on, rather than one of its cameras, and the value a
21
30
  * session's channel-taking methods resolve an omitted channel to.
@@ -208,6 +217,10 @@ export declare class P2PSession extends EventEmitter {
208
217
  * cameras from that second group streamed normally at level-1 — including one of the same firmware as an
209
218
  * own-session camera that delivered no video at all for a reason of its own. An expired grace therefore
210
219
  * separates nothing on this path, and a start failure on such a session is not evidence about it.
220
+ *
221
+ * Every `false` answer carries a `level2-unavailable` trace naming its reason, wherever the wait ended: a
222
+ * `terminal` outcome is the one already stated where the negotiation concluded, since that is where the
223
+ * cipher and the cause are known, and re-stating it here would double every settled negotiation.
211
224
  */
212
225
  awaitLevel2Key(graceMs: number, graceFrom?: "call" | "session"): Promise<boolean>;
213
226
  /**
@@ -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.
@@ -10,7 +10,13 @@ export declare class StoredImageCache {
10
10
  private activeDownloads;
11
11
  private generation;
12
12
  constructor(downloader: (url: string, deviceKey: string) => Promise<Buffer>, logger: Logger, clock?: () => number, isLifecycleError?: (error: unknown) => boolean);
13
- /** Observe a normalized thumbnail URL and start acquisition eagerly. */
13
+ /**
14
+ * Observe a normalized thumbnail URL and start acquisition eagerly.
15
+ *
16
+ * A URL already inside this device's window of recent attempts is ignored, so one event arriving as
17
+ * several pushes downloads one thumbnail. The window is a `Set`, which iterates in insertion order,
18
+ * so the entry evicted once it is full is the oldest attempt.
19
+ */
14
20
  observe(deviceKey: string, url: string): void;
15
21
  /** Return retained bytes without starting or awaiting network work. */
16
22
  snapshotStored(deviceKey: string): Promise<Buffer>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mega-yfue/eufy-sdk",
3
- "version": "0.2.0-beta.2",
3
+ "version": "0.2.0-beta.21",
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",
@@ -64,7 +64,8 @@
64
64
  "guard:docrefs": "bash scripts/ci/guard-doc-refs.sh",
65
65
  "guard:lines": "bash scripts/ci/guard-lines.sh",
66
66
  "check:esm": "node -e \"import('./dist/index.js').then(m=>console.log('ESM OK:',Object.keys(m).length,'exports'))\"",
67
- "verify": "npm run format:check && npm run typecheck && npm run guard:decorrelation && npm run guard:lines && npm run guard:docrefs && npm run guard:consumer-agnostic && npm run guard:capability-ownership && npm run build && npm run check:esm && npm run typecheck:examples && npm test",
67
+ "check:snippets": "node scripts/ci/check-doc-snippets.mjs",
68
+ "verify": "npm run format:check && npm run typecheck && npm run guard:decorrelation && npm run guard:lines && npm run guard:docrefs && npm run guard:consumer-agnostic && npm run guard:capability-ownership && npm run build && npm run check:esm && npm run check:snippets && npm run typecheck:examples && npm test",
68
69
  "release": "bash scripts/release.sh"
69
70
  },
70
71
  "engines": {