@mega-yfue/eufy-sdk 0.2.0-beta.13 → 0.2.0-beta.15

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.
@@ -1,19 +1,13 @@
1
- /**
2
- * Per-channel auto-contrast, a byte-exact port of PIL `ImageOps.autocontrast` (cutoff 0.5%). The lost
3
- * quant tables leave the reconstruction low-contrast ("foggy"); stretching each channel's clipped range
4
- * to full scale restores a natural-looking image. Mutates `data` (RGBA) in place.
5
- *
6
- * Parity notes vs PIL: the cutoff count is `n * cutoff // 100` (integer floor); it is trimmed off each
7
- * end by zeroing whole histogram bins until the count is spent; the range is then the first/last
8
- * non-empty bins; and the LUT truncates toward zero (`int()`), NOT rounds — rounding would shift pixels.
9
- */
10
- export declare function autoContrast(data: Uint8Array, width: number, height: number, cutoff?: number): void;
11
1
  /** True if the blob is a v2 `v2_eufysecurity:` push thumbnail. */
12
2
  export declare function isV2Image(data: Buffer): boolean;
13
3
  /**
14
- * Decode a v2 blob to a plain JPEG buffer by reconstructing its header, or null if it isn't v2 or the
15
- * plaintext scan can't be located. The search first chooses subsampling and coarse geometry, derives
16
- * the fixed MCU count, refines width by row shear, and pins the exact fill height before applying
17
- * auto-contrast and re-encoding. See the module doc for the keyless-splice rationale.
4
+ * Decode a v2 blob to a plain JPEG buffer by reconstructing its header, or null if it isn't v2, the
5
+ * plaintext scan can't be located, or no frame shape explains it.
6
+ *
7
+ * Three steps and no pixel rewrite: the frame shape is read out of the entropy scan,
8
+ * one probe decode both proves the spliced JPEG decodes and measures how far the substitute quant
9
+ * tables fall short of the camera's, and the answer is the same tail under a header carrying the
10
+ * corrected tables. See the module doc for the keyless-splice rationale and for why neither the search
11
+ * nor the correction decodes candidate frames or re-encodes the picture.
18
12
  */
19
13
  export declare function decodeImageV2(data: Buffer): Buffer | null;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * A baseline-JPEG entropy scanner that decodes no pixels.
3
+ *
4
+ * The v2 thumbnail decoder next door has to discover a frame geometry its blob does not state, and the
5
+ * only evidence is the plaintext entropy-coded scan: how many MCUs it carries, and whether it carries
6
+ * them under a given chroma subsampling. A JPEG decoder answers that — it throws on a frame the scan
7
+ * does not fill — and charges a full set of component and output buffers for each question.
8
+ *
9
+ * That bill is fatal for a caller under a hard memory cap. Each `jpeg-js` decode churns roughly a
10
+ * megabyte of typed arrays, glibc keeps the arenas rather than handing them back, and a search asking
11
+ * the question of every candidate geometry costs tens of megabytes per thumbnail — permanently, and
12
+ * more than an embedded host gives an app in total.
13
+ *
14
+ * This module asks the same question by walking the Huffman-coded coefficients and throwing them away:
15
+ * no IDCT, no component planes, no output image. What it keeps is one number per MCU — the DC
16
+ * coefficient of its luma block(s), i.e. that block's average brightness — which is all the geometry
17
+ * search needs to tell a sheared row apart from a continuous one. Allocation is a single `Int32Array`
18
+ * of MCU count, and the walk is linear in the scan's length.
19
+ *
20
+ * Baseline sequential only (SOF0), which is what the v2 tail is: no progressive refinement, no
21
+ * arithmetic coding. Restart markers are tolerated — the scan resynchronises and resets its DC
22
+ * predictors, exactly as a decoder would.
23
+ *
24
+ * @module transport/http/jpeg-scan
25
+ */
26
+ /** What the scan carried, under one subsampling hypothesis. */
27
+ export interface EntropyScan {
28
+ /** Complete MCUs decoded before the data ran out. */
29
+ mcus: number;
30
+ /**
31
+ * Mean luma DC per MCU, in scan order — a thumbnail of the picture at MCU resolution.
32
+ *
33
+ * Quantized units (the quant tables are lost with the v2 head), so the values are a scale of their
34
+ * own. Differences between neighbours are what the geometry search reads, and those survive.
35
+ */
36
+ luma: Int32Array;
37
+ /**
38
+ * Whether the scan ended where a whole MCU ended, with nothing but the EOI marker left.
39
+ *
40
+ * The discriminator between hypotheses: a wrong one reads a block with the wrong Huffman table,
41
+ * diverges, and either dies mid-MCU or stops with data still ahead of it. Only the subsampling the
42
+ * encoder used walks the scan to its last byte on an MCU boundary.
43
+ */
44
+ complete: boolean;
45
+ }
46
+ /**
47
+ * Walk the plaintext tail's scan under one subsampling hypothesis.
48
+ *
49
+ * `extraTables` carries the DHT segments that are NOT in the tail — for a v2 thumbnail, the standard
50
+ * luma tables the reconstructed header supplies, since only the chroma ones survive in plaintext.
51
+ * Returns null when the tail is not a baseline scan at all (no SOS, missing tables).
52
+ *
53
+ * The walk stops at the first byte it cannot read as this hypothesis's next block, and the result is
54
+ * `complete` only when that happened on an MCU boundary with nothing but a marker left. The DC array
55
+ * grows as the scan turns out to be long rather than being sized from the scan's byte length: a wrong
56
+ * hypothesis dies after a handful of MCUs, and provisioning three whole-scan arrays to discover that is
57
+ * the kind of allocation this module exists to avoid.
58
+ */
59
+ export declare function scanEntropy(tail: Uint8Array, subsampling: number, extraTables: readonly Uint8Array[]): EntropyScan | null;
@@ -1,7 +1,10 @@
1
+ import type { MediaFailureReason } from "../media-failure.js";
1
2
  type ResolveHost = (hostname: string) => Promise<readonly {
2
3
  address: string;
3
4
  family: number;
4
5
  }[]>;
6
+ /** Tag a failure OUTSIDE the transfer itself — the push-image decoder refusing bytes that did arrive. @internal */
7
+ export declare function mediaFailureError(message: string, reason: MediaFailureReason, cause?: unknown): Error;
5
8
  /** Signals rejection of the active Eufy session without exposing response content. @internal */
6
9
  export declare class MediaDownloadAuthenticationError extends Error {
7
10
  }
@@ -353,7 +353,13 @@ export declare class MegaHttpClient {
353
353
  registerPushToken(token: string): Promise<void>;
354
354
  /** Download raw bytes from a push-media URL using the active account session. */
355
355
  downloadMedia(url: string): Promise<Buffer>;
356
- /** Download push image bytes and decrypt a recognized v1 wrapper when its device key input is available. */
356
+ /**
357
+ * Download push image bytes and decrypt a recognized v1 wrapper when its device key input is available.
358
+ *
359
+ * A decoder throw is tagged `decode-failed`: to anything downstream, the difference between "the
360
+ * bytes never arrived" and "the bytes arrived and the wrapper would not decrypt" is the difference
361
+ * between a network problem and a key problem, and one of them is this SDK's to fix.
362
+ */
357
363
  downloadImage(url: string, p2pDid?: string): Promise<Buffer>;
358
364
  /** The security-app data host for this region (face recognition, media, etc.). */
359
365
  private securityAppHost;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Why acquiring a push thumbnail produced no bytes.
3
+ *
4
+ * Transport vocabulary, and it stays inside `transport/`: the modules that raise it and the cache that
5
+ * reads it are both here, and neither the device model nor the client names it. Nothing crosses the
6
+ * capability boundary, so nothing belongs in the shared floor.
7
+ *
8
+ * @module transport/media-failure
9
+ */
10
+ /**
11
+ * The terms an acquisition failure is reported in — a CLOSED vocabulary, and that is the point.
12
+ *
13
+ * `download-failed` says a candidate did not become an image; it does not say whether the URL was
14
+ * refused before a packet moved, the host answered 404, the transfer timed out, or the bytes arrived
15
+ * and would not decode. Those need entirely different fixes, so the distinction has to survive to
16
+ * whatever is reading the log.
17
+ *
18
+ * It is a fixed vocabulary rather than an error message because the thing that fails holds a signed
19
+ * media URL and a response body, and neither may reach a log line. Every member here is a term this
20
+ * file defines; a downloader's own wording never is.
21
+ *
22
+ * - `url-not-allowed` — the candidate URL failed the media allowlist (scheme, credentials, port, a
23
+ * literal address, or a host the SDK does not download from). Nothing was requested.
24
+ * - `address-not-public` — the host resolved to nothing, or to an address that is not public.
25
+ * - `redirect-not-allowed` — the media host redirected somewhere the object-store allowlist refuses,
26
+ * redirected without a target, or redirected twice.
27
+ * - `http-status` — the host answered, with a status other than 200. Carried alongside as `status`.
28
+ * - `too-large` — the body exceeded the download bound, declared or observed.
29
+ * - `timeout` — the whole attempt, DNS included, outlived its window.
30
+ * - `network` — the request itself failed: connect, TLS, a reset mid-body.
31
+ * - `decode-failed` — the bytes arrived and the push-image decoder refused them. A property of the
32
+ * image, not of the network.
33
+ */
34
+ export type MediaFailureReason = "url-not-allowed" | "address-not-public" | "redirect-not-allowed" | "http-status" | "too-large" | "timeout" | "network" | "decode-failed";
35
+ /** Every {@link MediaFailureReason}, so a tag that crossed an injected boundary can be checked against it. */
36
+ export declare const MEDIA_FAILURE_REASONS: readonly MediaFailureReason[];
37
+ /**
38
+ * The tag a media-acquisition failure carries for diagnostics.
39
+ *
40
+ * A property rather than a base class: the cache that logs it takes its downloader by injection and
41
+ * must not import the transport that throws — so it reads this shape off an unknown error and checks
42
+ * the term against {@link MEDIA_FAILURE_REASONS} before it goes anywhere near a log.
43
+ */
44
+ export interface MediaFailure {
45
+ readonly mediaFailure: MediaFailureReason;
46
+ /** The HTTP status, when {@link mediaFailure} is `http-status`. */
47
+ readonly status?: number;
48
+ }
@@ -28,10 +28,12 @@ import { FragmentRecording } from "./fragment-recording.js";
28
28
  * when these change.
29
29
  *
30
30
  * `connect` applies to every call on a station, because nothing can be addressed to one before its session is
31
- * up. `level2Grace` applies twice where the key is required: the negotiation is re-prompted once.
31
+ * up, and it is the session's own connect deadline: a session that reaches it closes itself, so waiting past it
32
+ * waits on a connection that can no longer answer. `level2Grace` applies twice where the key is required: the
33
+ * negotiation is re-prompted once.
32
34
  */
33
35
  export declare const P2P_STATION_WAITS: {
34
- readonly connect: 20000;
36
+ readonly connect: 15000;
35
37
  readonly level2Grace: 25000;
36
38
  readonly level2Settle: 8000;
37
39
  };
@@ -63,6 +63,20 @@ 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
+ }
66
80
  /**
67
81
  * Work on a station is holding for its session to connect, with the milliseconds it will wait.
68
82
  *
@@ -150,17 +164,21 @@ export type LiveTrace =
150
164
  /**
151
165
  * A station was resolved for a call, stating what the caller's device is on it and whose station it is.
152
166
  *
153
- * Emitted before anything is sent, so it is the only account of the intended topology on a call that fails
154
- * during resolution: an attached camera's media start has no unencrypted form, so whether a device was taken
155
- * as attached decides what its failure means. `stationAdmin` states whether the signed-in account is the
156
- * station's administrator, which is what a key the account cannot resolve turns on; `unstated` is a device
157
- * record that names no administrator, which is not the same as naming another.
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.
158
175
  */
159
176
  | {
160
177
  phase: "station-resolved";
161
178
  topology: "attached" | "own";
162
179
  channel: number;
163
180
  stationAdmin: "self" | "other" | "unstated";
181
+ stationModel?: string;
164
182
  }
165
183
  /** A shared source began warming, with the interval it re-issues on and the deadline it fails at. */
166
184
  | {
@@ -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.
@@ -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.13",
3
+ "version": "0.2.0-beta.15",
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",