@mega-yfue/eufy-sdk 0.0.4 → 0.1.0-beta.0

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 (143) hide show
  1. package/README.md +31 -12
  2. package/dist/client/device-registry.d.ts +343 -0
  3. package/dist/client/eufy-mega.d.ts +872 -0
  4. package/dist/client/index.d.ts +1 -6
  5. package/dist/client/map-channels.d.ts +22 -0
  6. package/dist/client/types.d.ts +383 -0
  7. package/dist/core/contracts.d.ts +898 -0
  8. package/dist/core/crypto.d.ts +98 -0
  9. package/dist/core/index.d.ts +9 -7
  10. package/dist/core/logger.d.ts +53 -0
  11. package/dist/core/lz4-block.d.ts +35 -0
  12. package/dist/core/raw-dp-hex.d.ts +32 -0
  13. package/dist/core/raw-dp-writer.d.ts +83 -0
  14. package/dist/core/store.d.ts +43 -0
  15. package/dist/core/types.d.ts +169 -0
  16. package/dist/core/util.d.ts +78 -0
  17. package/dist/index.d.ts +4 -7
  18. package/dist/index.js +25526 -14
  19. package/dist/index.js.map +7 -1
  20. package/dist/model/capabilities/access.d.ts +127 -0
  21. package/dist/model/capabilities/arming.d.ts +201 -0
  22. package/dist/model/capabilities/audio.d.ts +154 -0
  23. package/dist/model/capabilities/battery.d.ts +363 -0
  24. package/dist/model/capabilities/camera.d.ts +564 -0
  25. package/dist/model/capabilities/co.d.ts +40 -0
  26. package/dist/model/capabilities/contact.d.ts +133 -0
  27. package/dist/model/capabilities/doorbell.d.ts +345 -0
  28. package/dist/model/capabilities/dp-catalog.d.ts +38 -0
  29. package/dist/model/capabilities/index.d.ts +561 -0
  30. package/dist/model/capabilities/info.d.ts +28 -0
  31. package/dist/model/capabilities/keypad.d.ts +61 -0
  32. package/dist/model/capabilities/leak.d.ts +43 -0
  33. package/dist/model/capabilities/light.d.ts +174 -0
  34. package/dist/model/capabilities/locate.d.ts +63 -0
  35. package/dist/model/capabilities/lock.d.ts +242 -0
  36. package/dist/model/capabilities/manifest.d.ts +107 -0
  37. package/dist/model/capabilities/members.d.ts +647 -0
  38. package/dist/model/capabilities/motion.d.ts +377 -0
  39. package/dist/model/capabilities/person-detection.d.ts +8 -0
  40. package/dist/model/capabilities/ptz.d.ts +289 -0
  41. package/dist/model/capabilities/rtsp.d.ts +221 -0
  42. package/dist/model/capabilities/siren.d.ts +218 -0
  43. package/dist/model/capabilities/smart-light.d.ts +172 -0
  44. package/dist/model/capabilities/smoke.d.ts +40 -0
  45. package/dist/model/capabilities/snapshot.d.ts +6 -0
  46. package/dist/model/capabilities/storage.d.ts +11 -0
  47. package/dist/model/capabilities/suction.d.ts +104 -0
  48. package/dist/model/capabilities/types.d.ts +484 -0
  49. package/dist/model/capabilities/vacuum-clean.d.ts +1946 -0
  50. package/dist/model/capabilities/vacuum-dock.d.ts +208 -0
  51. package/dist/model/capabilities/video.d.ts +6 -0
  52. package/dist/model/classify.d.ts +78 -0
  53. package/dist/model/clean-record-detail.d.ts +65 -0
  54. package/dist/model/clean-records.d.ts +69 -0
  55. package/dist/model/device-family.d.ts +73 -0
  56. package/dist/model/device-types.d.ts +123 -0
  57. package/dist/model/device.d.ts +265 -0
  58. package/dist/model/index.d.ts +29 -4
  59. package/dist/model/infer.d.ts +23 -0
  60. package/dist/model/inspect.d.ts +61 -0
  61. package/dist/model/life-params.d.ts +21 -0
  62. package/dist/model/map-pixels.d.ts +70 -0
  63. package/dist/model/param-dictionary.d.ts +28 -0
  64. package/dist/model/param-namespace.d.ts +21 -0
  65. package/dist/model/proto-read.d.ts +53 -0
  66. package/dist/model/push-events.d.ts +147 -0
  67. package/dist/model/registry.d.ts +54 -0
  68. package/dist/model/types.d.ts +301 -0
  69. package/dist/model/vacuum-map-store.d.ts +92 -0
  70. package/dist/model/vacuum-map.d.ts +286 -0
  71. package/dist/model/vacuum-scenes.d.ts +76 -0
  72. package/dist/model/vacuum-schedules.d.ts +85 -0
  73. package/dist/transport/dp-preset.d.ts +102 -0
  74. package/dist/transport/ff09.d.ts +444 -0
  75. package/dist/transport/ffmpeg.d.ts +86 -0
  76. package/dist/transport/http/decodeImageV1.d.ts +20 -0
  77. package/dist/transport/http/decodeImageV2.d.ts +19 -0
  78. package/dist/transport/http/index.d.ts +5 -0
  79. package/dist/transport/http/light-catalog.d.ts +62 -0
  80. package/dist/transport/http/media-download.d.ts +14 -0
  81. package/dist/transport/http/mega-client.d.ts +514 -0
  82. package/dist/transport/http/phone-model.d.ts +21 -0
  83. package/dist/transport/index.d.ts +10 -7
  84. package/dist/transport/mqtt/app-client-id.d.ts +16 -0
  85. package/dist/transport/mqtt/availability.d.ts +14 -0
  86. package/dist/transport/mqtt/bare-ip-tls.d.ts +46 -0
  87. package/dist/transport/mqtt/biz-stream.d.ts +98 -0
  88. package/dist/transport/mqtt/broker-discovery.d.ts +55 -0
  89. package/dist/transport/mqtt/clean-codec.d.ts +14 -0
  90. package/dist/transport/mqtt/command-router.d.ts +285 -0
  91. package/dist/transport/mqtt/dp-codec.d.ts +58 -0
  92. package/dist/transport/mqtt/dp-color.d.ts +14 -0
  93. package/dist/transport/mqtt/engine.d.ts +16 -0
  94. package/dist/transport/mqtt/index.d.ts +5 -0
  95. package/dist/transport/mqtt/secure-mqtt.d.ts +107 -0
  96. package/dist/transport/mqtt/topics.d.ts +80 -0
  97. package/dist/transport/p2p/adts.d.ts +91 -0
  98. package/dist/transport/p2p/annexb.d.ts +124 -0
  99. package/dist/transport/p2p/codec.d.ts +166 -0
  100. package/dist/transport/p2p/command-router.d.ts +655 -0
  101. package/dist/transport/p2p/commands.d.ts +550 -0
  102. package/dist/transport/p2p/envelope.d.ts +46 -0
  103. package/dist/transport/p2p/fmp4.d.ts +89 -0
  104. package/dist/transport/p2p/fragment-recording.d.ts +33 -0
  105. package/dist/transport/p2p/index.d.ts +13 -0
  106. package/dist/transport/p2p/lan-ip.d.ts +25 -0
  107. package/dist/transport/p2p/live-stream.d.ts +223 -0
  108. package/dist/transport/p2p/live-trace.d.ts +123 -0
  109. package/dist/transport/p2p/media.d.ts +105 -0
  110. package/dist/transport/p2p/p2p-session.d.ts +620 -0
  111. package/dist/transport/p2p/readable-egress.d.ts +27 -0
  112. package/dist/transport/p2p/session-manager.d.ts +154 -0
  113. package/dist/transport/p2p/shared-live-source.d.ts +431 -0
  114. package/dist/transport/p2p/talkback.d.ts +187 -0
  115. package/dist/transport/p2p/video.d.ts +150 -0
  116. package/dist/transport/p2p/write-commands.d.ts +21 -0
  117. package/dist/transport/protobuf.d.ts +5 -0
  118. package/dist/transport/push/fcm.d.ts +23 -0
  119. package/dist/transport/push/index.d.ts +6 -0
  120. package/dist/transport/push/message-tags.d.ts +26 -0
  121. package/dist/transport/push/parser.d.ts +27 -0
  122. package/dist/transport/push/proto.d.ts +11 -0
  123. package/dist/transport/push/push-client.d.ts +64 -0
  124. package/dist/transport/push/store.d.ts +23 -0
  125. package/dist/transport/push/types.d.ts +180 -0
  126. package/dist/transport/raw-dp.d.ts +6 -0
  127. package/dist/transport/stored-image-cache.d.ts +23 -0
  128. package/dist/transport/tuya/account.d.ts +44 -0
  129. package/dist/transport/tuya/client.d.ts +89 -0
  130. package/dist/transport/tuya/command-router.d.ts +79 -0
  131. package/dist/transport/tuya/dp-codec.d.ts +56 -0
  132. package/dist/transport/tuya/index.d.ts +28 -0
  133. package/dist/transport/tuya/request.d.ts +157 -0
  134. package/dist/transport/tuya/sign.d.ts +64 -0
  135. package/package.json +12 -13
  136. package/dist/client/index.js +0 -2
  137. package/dist/client/index.js.map +0 -1
  138. package/dist/core/index.js +0 -2
  139. package/dist/core/index.js.map +0 -1
  140. package/dist/model/index.js +0 -2
  141. package/dist/model/index.js.map +0 -1
  142. package/dist/transport/index.js +0 -2
  143. package/dist/transport/index.js.map +0 -1
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The `payload.protocol` discriminator, which says what the whole message is before any field of it is
3
+ * read. Taken from the app's own dispatch, which switches on exactly these two and ignores the rest.
4
+ *
5
+ * `41` is the number the reference integration named its parser after without saying where it came
6
+ * from; it is this field.
7
+ */
8
+ export declare const BIZ_PROTOCOL: {
9
+ /** Map-stream data: the frames this module parses. */
10
+ readonly MAP_DATA: 41;
11
+ /** A live photo from the robot's camera — a different payload entirely, not read here. */
12
+ readonly LIVE_PHOTO: 43;
13
+ };
14
+ /**
15
+ * The channels a map frame can arrive on, by id.
16
+ *
17
+ * **The trap.** Ids 1–10 come from `Metadata.ChanIds` in `stream.proto`, where each *field* names a
18
+ * message and each field's *value* is the id the device chose for it. The names below pair each
19
+ * channel with its field NUMBER, because that is the numbering the vendor ships and the one a live
20
+ * capture shows — but a device announcing a `Metadata` frame is announcing its own numbering, and a
21
+ * firmware that renumbers would make this table wrong without making it look wrong. Anything that
22
+ * *decodes* a channel's bytes must therefore prefer a device-declared mapping when one has been seen;
23
+ * this table is the default to fall back on, not an authority.
24
+ *
25
+ * Channel 0 is the exception and is not in `ChanIds` at all: the app hard-codes it, routing it to a
26
+ * scene-data event rather than a map one, and asks for the scene list on it by number. That one is
27
+ * fixed.
28
+ */
29
+ export declare const BIZ_CHANNEL: {
30
+ /** Scene (room-group) data. Hard-coded in the app, not part of `ChanIds`. */
31
+ readonly SCENES: 0;
32
+ readonly MAP_INFO: 1;
33
+ readonly PATH: 2;
34
+ readonly ROOM_OUTLINE: 3;
35
+ readonly ROOM_PARAMS: 4;
36
+ readonly RESTRICTED_ZONE: 5;
37
+ readonly DYNAMIC_DATA: 6;
38
+ readonly TEMPORARY_DATA: 7;
39
+ readonly OBSTACLE_INFO: 8;
40
+ readonly MAP_DATA: 9;
41
+ readonly CRUISE_DATA: 10;
42
+ };
43
+ /** A channel's name under the default numbering — see the trap in {@link BIZ_CHANNEL}. */
44
+ export type BizChannel = keyof typeof BIZ_CHANNEL;
45
+ /**
46
+ * Name a channel id under the default numbering, or `undefined` for an id the vendor's table does not
47
+ * list. Not a substitute for a device-declared mapping where one exists.
48
+ */
49
+ export declare function bizChannelName(id: number): BizChannel | undefined;
50
+ /** One frame off the map stream, unwrapped as far as its bytes but not decoded. */
51
+ export interface BizMapFrame {
52
+ /** Which channel the bytes belong to. Compare against {@link BIZ_CHANNEL}, mindful of its trap. */
53
+ readonly channelId: number;
54
+ /**
55
+ * The vendor's `clear_type`. Forwarded, not interpreted: the app passes it straight through to its
56
+ * own UI layer and nothing observed says what its values mean. A `Map` I-frame is documented to make
57
+ * the device clear the channel's accumulated data first, so this plausibly says so — plausibly is
58
+ * not a decode.
59
+ */
60
+ readonly clearType: number;
61
+ /** The vendor's `data_type`. The app reads this field and then discards it; so does this. */
62
+ readonly dataType: number;
63
+ /**
64
+ * The vendor's `offset` and `len`. A large map is split across frames, and these two locate this
65
+ * one — but which of "offset into the channel buffer" and "length of this chunk" versus "length of
66
+ * the whole" they mean is decided in the app's React Native layer, which ships as Hermes bytecode
67
+ * and was not readable. They are carried verbatim so a reassembler can be written against a capture
68
+ * rather than against a guess.
69
+ */
70
+ readonly offset: number;
71
+ /** See {@link BizMapFrame.offset}. */
72
+ readonly len: number;
73
+ /**
74
+ * The frame's bytes, re-encoded as the base64 a `RawDpCodec` reads. The wire sends hex; the framing
75
+ * underneath is the same `varint(len) ++ protobufMessage` every Raw DP uses.
76
+ */
77
+ readonly payload: string;
78
+ }
79
+ /**
80
+ * Parse one message off `biz/…/res` into a map frame, or `undefined` if it is not one.
81
+ *
82
+ * **`undefined` is the common case and not an error.** This runs against every message on a shared
83
+ * connection: DP reports, ACKs on the `biz/…/req` leg, live-photo notifications, and anything else the
84
+ * broker delivers all arrive here and all correctly decline. Nothing is logged and nothing throws.
85
+ *
86
+ * The shape, layer by layer, is the app's:
87
+ *
88
+ * 1. `{ payload }`, where `payload` is an object OR a JSON string holding one — the same
89
+ * double-encoding the DP path already handles.
90
+ * 2. `payload.protocol === 41`. A `43` here is a live photo and belongs to a different reader.
91
+ * 3. `payload.data` carrying all six of `channel_id`, `clear_type`, `data_type`, `offset`, `data` and
92
+ * `len`. The app refuses the message outright when any one is missing, and so does this: a frame
93
+ * missing a field is a frame this code does not understand, and half of it is worth nothing.
94
+ * 4. `data.data`, hex, re-encoded to base64 — and rejected unless it is *clean* hex, because hex
95
+ * truncates in the one direction the frame's own length prefix cannot catch (see
96
+ * {@link hexToRawDp}).
97
+ */
98
+ export declare function parseBizMapFrame(raw: unknown): BizMapFrame | undefined;
@@ -0,0 +1,55 @@
1
+ export interface BrokerCredentials {
2
+ /** Broker hostname — used as the TLS SNI + cert-CN check target, NOT the socket connect target. */
3
+ hostname: string;
4
+ port?: number;
5
+ certificate_pem: string;
6
+ private_key: string;
7
+ aws_root_ca1_pem: string;
8
+ }
9
+ export interface ProbeResult {
10
+ ip: string;
11
+ /** True iff SUBSCRIBE to the target topic was granted (qos 0/1/2, not the 0x80 deny code). */
12
+ granted: boolean;
13
+ grantedQos?: number;
14
+ error?: string;
15
+ ms: number;
16
+ }
17
+ /** Resolve current A records for the broker hostname. An NLB typically returns one IP per AZ, so this
18
+ * is normally a short list (2-3), not the dozen the DNS-round-robin theory implied. */
19
+ export declare function resolveBrokerIps(hostname: string): Promise<string[]>;
20
+ /** Merge freshly-resolved DNS candidates with any previously-seen-good IPs, de-duplicated, DNS-first
21
+ * (the DNS results are current; the extras are a fallback in case this resolver returns fewer targets
22
+ * than have been seen historically). Pure/no I/O — kept separate so it's unit-testable. */
23
+ export declare function mergeCandidateIps(dnsIps: string[], extraIps?: string[]): string[];
24
+ /** Rank probe results with granted instances first (stable order otherwise). Pure — unit-testable
25
+ * without a real network connection. */
26
+ export declare function rankResults(results: ProbeResult[]): ProbeResult[];
27
+ /**
28
+ * Probe ONE candidate IP: connect, SUBSCRIBE to `topic`, record whether it was granted, then
29
+ * disconnect. Never publishes anything — this function cannot actuate a device.
30
+ *
31
+ * The probe presents the account's client certificate, so it verifies the instance it dials: the TLS
32
+ * options come from `./bare-ip-tls.ts`, which checks the presented certificate against
33
+ * `creds.hostname` rather than the IP.
34
+ */
35
+ export declare function probeBrokerInstance(ip: string, creds: BrokerCredentials, opts: {
36
+ clientId: string;
37
+ topic: string;
38
+ timeoutMs?: number;
39
+ }): Promise<ProbeResult>;
40
+ export interface DiscoverOptions {
41
+ /** Build a fresh client_id per attempt (the real client_id includes a connect timestamp). */
42
+ clientIdFor: (ip: string) => string;
43
+ /** The device's `.../res` topic to subscribe (never a `/req` topic — this module doesn't publish). */
44
+ topic: string;
45
+ /** Previously-seen-good IPs to probe alongside a fresh DNS resolution. */
46
+ candidateIps?: string[];
47
+ perAttemptTimeoutMs?: number;
48
+ /** Stop at the first granted SUBSCRIBE instead of probing every candidate. */
49
+ stopOnFirstGrant?: boolean;
50
+ }
51
+ /**
52
+ * Probe every candidate instance (fresh DNS + any known extras), in sequence, SUBSCRIBE-only. Returns
53
+ * every result, granted ones first, rather than only the first hit.
54
+ */
55
+ export declare function discoverReachableInstance(creds: BrokerCredentials, opts: DiscoverOptions): Promise<ProbeResult[]>;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Build the AIoT MQTT envelope for a Tuya DP write to a clean-line device (vacuum/mower).
3
+ *
4
+ * Wire format confirmed on a live T2351: the outer JSON carries `{head, payload}` where `payload`
5
+ * is itself a JSON **string** containing the DP map. `head.cmd` 65537 (0x10001), `cmd_status` 2,
6
+ * `sign_code` 0 — all confirmed from T2351 captures.
7
+ *
8
+ * The DP value is sent as-is: booleans, numbers, and base64 strings all appear directly in the
9
+ * `data` map (the device interprets the type from its own schema).
10
+ */
11
+ export declare function buildCleanDpEnvelope(accountId: string, deviceSn: string, dp: number, value: boolean | number | string, opts?: {
12
+ timestamp?: number;
13
+ uuid?: string;
14
+ }): string;
@@ -0,0 +1,285 @@
1
+ import type { EufyDevice } from "../../core/types.js";
2
+ import type { Command, AutoLockSnapshot } from "../../core/contracts.js";
3
+ import { type Logger } from "../../core/logger.js";
4
+ import type { MegaHttpClient } from "../http/mega-client.js";
5
+ import { type Ff09FrameInput, type Ff09TransferPayload } from "../ff09.js";
6
+ import { type DpPresetSpec } from "../dp-preset.js";
7
+ /**
8
+ * The ff09 command topic — always the `eufy_security` scope, regardless of the device's own category
9
+ * (garage/standalone lock records aren't necessarily `eufy_security`-categorized, but this is the one
10
+ * topic that's live-verified to accept `head.cmd:9` commands).
11
+ */
12
+ export declare function ff09MqttTopic(pn: string, sn: string): string;
13
+ /**
14
+ * The MQTT `ff09-actuate` builder input — the same fields as {@link Ff09FrameInput} minus `omitUserFields`
15
+ * (the actuate path always carries full user attribution), with `username`/`shortUserId` made **required**
16
+ * (the frame builder allows omitting them only under `omitUserFields`, which this path never sets).
17
+ * Derived from `Ff09FrameInput` so the shared fields (`engage`/`adminUserId`/`deviceSn`/`unixTime`/
18
+ * `nonce`/`seqNum`) can't drift.
19
+ */
20
+ export type Ff09TransInput = Omit<Ff09FrameInput, "omitUserFields" | "username" | "shortUserId"> & {
21
+ username: string;
22
+ shortUserId: string;
23
+ };
24
+ /** The inner `trans` object (before base64) — `{cmd:1940,…,payload:{apiCommand,lock_payload,…}}`. */
25
+ export interface Ff09Trans {
26
+ cmd: number;
27
+ mChannel: number;
28
+ mValue3: number;
29
+ payload: Ff09TransferPayload;
30
+ }
31
+ /**
32
+ * Build the ff09 actuate command as the inner `trans` object. `engage` only flips the ff09 frame's
33
+ * internal `A3` byte; the `apiCommand` comes from the frame builder (see `transport/ff09.ts`).
34
+ */
35
+ export declare function buildFf09Trans(input: Ff09TransInput): Ff09Trans;
36
+ /**
37
+ * Build the `{head, payload}` envelope a ff09 command publishes to {@link ff09MqttTopic} — `head.cmd:9`,
38
+ * `payload` = `{account_id, device_sn, trans: base64(trans)}`. `timestamp`/`sessId`/`seed` are
39
+ * injectable for deterministic tests; default to fresh/random.
40
+ */
41
+ export declare function buildFf09MqttEnvelope(input: {
42
+ trans: Ff09Trans;
43
+ clientId: string;
44
+ accountId: string;
45
+ deviceSn: string;
46
+ timestamp?: number;
47
+ sessId?: string;
48
+ seed?: string;
49
+ }): string;
50
+ /**
51
+ * The facade-side dependencies the router needs. It owns the one-shot MQTT connection lifecycle + all
52
+ * command wire logic, but defers device-list access + lifecycle event fan-out to the client (which owns
53
+ * the typed EventEmitter).
54
+ */
55
+ export interface MqttRouterDeps {
56
+ mega: MegaHttpClient;
57
+ /** Diagnostics sink. Omit for silence. */
58
+ logger?: Logger;
59
+ /** Current (already-loaded) device list. */
60
+ listDevices: () => EufyDevice[];
61
+ /** Load the device list if it isn't loaded yet (delegates to the client's getDevices). */
62
+ ensureDevices: () => Promise<void>;
63
+ /** A command delivery/actuation ack — the client re-emits it as the `commandAck` event. */
64
+ onCommandAck: (info: Record<string, unknown>) => void;
65
+ onError: (err: Error) => void;
66
+ /**
67
+ * The `a2` account id an `eufy_life` DP frame embeds for `dev` — the owning member's `admin_user_id`
68
+ * (or the session user id). Injected because it needs session state the transport doesn't hold; the
69
+ * router treats it opaquely. Required once any `mqtt-dp` command family can be routed.
70
+ */
71
+ resolveAccountId?: (dev: EufyDevice) => string;
72
+ /**
73
+ * Resolve a gallery `lightId` to its serializable effect definition — a thin wrapper over the HTTP
74
+ * effect catalog the client owns (so the transport never imports HTTP). Injected; required to route
75
+ * `mqtt-dp-preset`.
76
+ */
77
+ resolvePreset?: (lightId: number) => Promise<DpPresetSpec>;
78
+ /**
79
+ * Publish an already-built MQTT message `body` to `topic` over the facade's persistent account-wide
80
+ * secure-MQTT transport — the CONFIRMED `eufy_life` light-write path (the facade's `connectMQTT()` +
81
+ * `transport.publish`). Injected because that persistent connection is owned by the facade, not this
82
+ * router (which otherwise opens per-command one-shot connections for ff09). Required to route
83
+ * `mqtt-dp`/`mqtt-dp-color`/`mqtt-dp-preset`.
84
+ */
85
+ publishSecure?: (dev: EufyDevice, topic: string, body: string) => Promise<void>;
86
+ }
87
+ export declare class MqttCommandRouter {
88
+ private readonly deps;
89
+ private readonly logger;
90
+ /** Stable per-process install id for the app-shaped MQTT client_id (see {@link buildAppShapedClientId}) —
91
+ * generated once, reused for every security-MQTT connect this router makes. */
92
+ private mqttUuid?;
93
+ constructor(deps: MqttRouterDeps);
94
+ /**
95
+ * Whether this transport stack drives `dev`'s `ff09-*` commands — a **eufy-cloud device**
96
+ * (`api === "mega"`) with NO usable P2P endpoint (empty `p2p_did`): a standalone lock/garage (T85D0),
97
+ * an appliance, and so on. Named positively by the plane it drives rather than "anything without a
98
+ * P2P id", so a device on another cloud (a printer, `api === "ankermake"`) is claimed by NEITHER this
99
+ * stack nor {@link P2PCommandRouter.claimsDevice} instead of falling onto the eufy MQTT plane. (See
100
+ * P2P's `claimsDevice` for why the endpoint, not the `realtime` tag, is the routing fact.)
101
+ */
102
+ static claimsDevice(dev: EufyDevice): boolean;
103
+ /**
104
+ * Route a transport-neutral {@link Command} to the secure-MQTT wire — the MQTT half of the command
105
+ * sink. The `ff09-actuate`/`ff09-autolock` (locks/garage) and `mqtt-dp`/`mqtt-dp-color`/`mqtt-dp-preset`
106
+ * (`eufy_life` lights) kinds reach here (the facade fans everything else to the P2P router); any
107
+ * other kind is a routing bug, so fail loud rather than resolve as a silent success.
108
+ */
109
+ dispatchCommand(sn: string, cmd: Command): Promise<void>;
110
+ /** Resolve a serial to its loaded device record (loading the device list if needed). */
111
+ private deviceFor;
112
+ /**
113
+ * Fallback broker-instance IPs, tried alongside a fresh DNS resolution — the `aiot-mqtt-{region}
114
+ * .anker.com` NLB has been observed (2026-07-16) to answer a single DNS query with only 2 of its
115
+ * targets, and NOT necessarily including the one currently holding a given device's session (e.g.
116
+ * `3.139.229.186` held a live T85D0 session all evening but never once appeared in `dig`/`resolve4`
117
+ * output during that window). These are just previously-observed AWS infra IPs, not secrets — kept as
118
+ * a small seed list so discovery doesn't depend on DNS happening to expose the right target.
119
+ *
120
+ * NOTE: this is a hardcoded FALLBACK, not a source of truth — a fresh `dig`/`resolve4` of
121
+ * `aiot-mqtt-{region}.anker.com` runs alongside it every discovery, and AWS can rotate these NLB
122
+ * targets at any time. If discovery starts failing, the seed list is the first thing to re-resolve
123
+ * from DNS and refresh; it's not meant to be maintained by hand long-term.
124
+ */
125
+ private static readonly KNOWN_AIOT_BROKER_IPS;
126
+ /**
127
+ * Connect a fresh **security-scoped** (`eufy_security`) MQTT client PINNED to whichever broker
128
+ * instance currently holds `dev`'s live session. `aiot-mqtt-{region}.anker.com` fronts multiple
129
+ * independent backend instances (an AWS NLB, one target per AZ) that do NOT share subscribe/publish
130
+ * routing — a plain DNS connect can silently land on an instance that will accept the TLS CONNECT but
131
+ * never route to this device (looks exactly like an authorization wall, isn't one). This probes every
132
+ * candidate with a SUBSCRIBE-only connection (never publishes during discovery — see
133
+ * `transport/mqtt/broker-discovery.ts`), then opens one real connection pinned to the first instance
134
+ * that granted the SUBSCRIBE, for the caller to actually publish on.
135
+ *
136
+ * Which cert is used does NOT change the outcome — the account's own `get_user_mqtt_info` cert and a
137
+ * cert extracted from a real phone's keystore produce the identical grant/deny pattern across every
138
+ * candidate IP. Only the instance matters, which is why this probes instances and not credentials.
139
+ * One exception is known: a single garage unit whose own-cert SUBSCRIBE was denied on every candidate
140
+ * while a phone-extracted cert granted on the same one, with a sibling of the same model on the same
141
+ * account granting fine. That is a per-device authorization gap on the vendor's backend, not a
142
+ * broker-instance problem and not something this SDK works around — such a device reports as offline
143
+ * (the throw below) until the backend grants the account's own credentials.
144
+ *
145
+ * One fresh, explicitly non-reconnecting (`reconnectPeriod: 0`) connection per call — it repays the
146
+ * full mTLS handshake every command and tears the socket down right after (see
147
+ * {@link SecureMqttOptions.reconnectPeriod} for why a one-shot connection MUST disable reconnect).
148
+ * That cost is accepted because garage/lock commands are rare. ⚠️ A reused connection off a persistent
149
+ * security-MQTT transport would amortize the handshake and stay mounted long enough for a slow actuator
150
+ * (a garage door travels ~20-30s) to push an async state update on the same socket, subscribing once
151
+ * with a scope-bounded wildcard (`cmd/{app}/+/+/res`) instead of per-call subscribe/unsubscribe.
152
+ */
153
+ private ensureSecurityMqttFor;
154
+ /**
155
+ * How long {@link dispatchFf09Actuate} waits for the device's `/res` **delivery ack** before giving up.
156
+ * This is NOT a physical-actuation budget, so the SAME number covers a deadbolt and a garage door
157
+ * despite their very different travel times — see that method's doc for why.
158
+ */
159
+ private static readonly FF09_ACK_TIMEOUT_MS;
160
+ /**
161
+ * Shared "arm a listener, resolve on the first matching message, else time out" scaffold —
162
+ * {@link dispatchFf09Actuate}'s ack wait and {@link dispatchFf09Autolock}'s GET-reply/SET-ack waits
163
+ * are all one instance of this pattern (differing only in `match`), so it's factored here instead of
164
+ * three near-identical inline `new Promise(...)` blocks. `match` returns the extracted value once a
165
+ * message satisfies it, or `undefined` to keep waiting; the returned promise resolves `undefined` on
166
+ * timeout with no unhandled listener left behind either way.
167
+ */
168
+ private waitForMqttMessage;
169
+ /**
170
+ * The `ff09-actuate` intent handler over MQTT — a standalone lock/garage door (T85D0) with no P2P endpoint.
171
+ * Builds the same `ff09` frame the P2P lock uses (`transport/ff09.ts`), wraps it in the MQTT `trans`
172
+ * envelope (`buildFf09Trans`/`buildFf09MqttEnvelope`, this module), discovers which broker instance holds the device's
173
+ * live session (see {@link ensureSecurityMqttFor}), and publishes to `cmd/eufy_security/{pn}/{sn}/req`.
174
+ * `cmd.engage` maps straight to the frame's direction byte (`true`=lock/close, `false`=unlock/open) — no inversion.
175
+ *
176
+ * **Verification status differs by direction — don't conflate them.** ✅ Unlock is LIVE-VERIFIED on a
177
+ * T85D0 (2026-07-16): 3/3 closed→open transitions, direct cause→observed-effect, driven fully
178
+ * end-to-end through this exact codepath. Lock is byte-exact against two independently-captured
179
+ * real-app close frames — the same evidentiary bar the P2P video lock's wire ships under — but has
180
+ * NOT itself been driven through this codepath and physically observed closing the real door; that's
181
+ * still open.
182
+ *
183
+ * **What the `/res` reply actually means — a delivery ack, NOT "motion complete".** A live garage-open
184
+ * test (2026-07-15) got its `/res` in ~2.5s, far faster than the ~20-30s a garage door physically takes
185
+ * to travel — so this is the device saying "I received the command", not "I finished moving". That's
186
+ * why {@link FF09_ACK_TIMEOUT_MS} is one fixed, short window shared by every ff09 device regardless of
187
+ * its actuator's physical speed: it's timing a network round-trip, not a door. (There is currently no
188
+ * signal at all for actuation completion — that would need a different source, e.g. the device's own
189
+ * state push — so this method stays fire-and-forget: it resolves either way, never throws on a missing
190
+ * ack, and reports what it knows via the `commandAck` event rather than the return value, so a future
191
+ * richer per-device state model doesn't have to fight this method's `Promise<void>` contract.)
192
+ *
193
+ * Subscribes to the reply topic BEFORE publishing and waits for the actual message event (or the
194
+ * timeout) rather than a blind sleep, so a reply arriving anywhere in that window is never missed —
195
+ * unlike a fixed-sleep-then-teardown, where a late reply arrives after the subscription is already gone.
196
+ */
197
+ private dispatchFf09Actuate;
198
+ /**
199
+ * The `ff09-autolock` intent handler over MQTT — read-modify-write the T85D0's auto-lock setting.
200
+ * ✅ LIVE-VERIFIED end-to-end (2026-07-17): both enable and disable
201
+ * driven through this exact codepath against a real T85D0, confirmed via the app UI showing the new
202
+ * state afterward, not just byte-exact against a capture. Unlike {@link dispatchFf09Actuate} (one
203
+ * fire-and-forget frame), this is a GET then a SET over the SAME one-shot MQTT connection:
204
+ *
205
+ * 1. Publish a settings GET query ({@link buildFf09QueryTrans}), then wait for the device's `/res`
206
+ * reply — NOT the generic "any /res" ack {@link dispatchFf09Actuate} accepts, but specifically a
207
+ * message matching {@link parseFf09SettingsResponseTrans}'s shape whose `time` (hex string,
208
+ * parsed as the keyTime) equals the GET's own `time`, so unrelated traffic on the same
209
+ * subscription can't be mistaken for the reply. **No reply within the timeout throws** — unlike
210
+ * lock/unlock, this is a genuine precondition (we need the device's live `A7`/`A8`/delay values
211
+ * to preserve them), not a fire-and-forget actuation, so silently guess-writing would be worse
212
+ * than failing loud.
213
+ * 2+3. Decrypt the reply, preserve the current delay (`a2`) + `A7`/`A8` passthrough values (`a4`/
214
+ * `a5`), and build the SET frame that changes only `A4`=`cmd.enabled` / `A5`=`cmd.delaySeconds`
215
+ * (or the just-read delay if omitted) — the decrypt→read→rebuild is shared with the P2P sibling in
216
+ * `transport/ff09.ts`'s {@link buildFf09AutolockSetFrame}; here it's wrapped in the MQTT `trans`
217
+ * envelope and published. Its ack follows the same fire-and-forget device-scoped `/res` convention
218
+ * as {@link dispatchFf09Actuate} —
219
+ * there's no captured evidence of a distinct SET-ack shape to match more strictly against. This
220
+ * listener is armed fresh (topic-only match, no keyTime correlation like step 1's), so in theory a
221
+ * REDELIVERED copy of the already-consumed GET reply (QoS-1 retransmit) could latch it early —
222
+ * telemetry-only (the call resolves either way; `setAcked` only affects a debug log line), so left
223
+ * as-is rather than adding keyTime correlation this ack doesn't otherwise need.
224
+ */
225
+ private dispatchFf09Autolock;
226
+ /**
227
+ * Shared GET-and-wait step behind both {@link dispatchFf09Autolock} (which reads to preserve A7/A8
228
+ * across a write) and {@link getAutoLockState} (which reads for its own sake) — extracted so the two
229
+ * don't drift on the query/keyTime-matching machinery. Takes an already-connected+subscribed
230
+ * `mqtt`/`topic`; connection lifecycle stays the caller's concern (the write path keeps the
231
+ * connection open for a following SET, the read path closes right after). Throws if no matching
232
+ * reply arrives within {@link FF09_ACK_TIMEOUT_MS}.
233
+ */
234
+ private fetchFf09SettingsGetReply;
235
+ /**
236
+ * **Read the T85D0's current auto-lock settings over MQTT** — the `Ff09SettingsReader` implementation.
237
+ * A pure GET, no SET: opens its own one-shot MQTT connection (same
238
+ * lifecycle as {@link dispatchFf09Autolock}), reuses {@link fetchFf09SettingsGetReply}, then decrypts
239
+ * + decodes fields `a1`-`a5` per `transport/ff09.ts`'s response tag map. Live-verified only insofar
240
+ * as the underlying GET step already is (`setAutoLock`'s own read) — the standalone read path itself
241
+ * has not been independently exercised against a real device yet.
242
+ */
243
+ getAutoLockState(sn: string, cmd: {
244
+ adminUserId: string;
245
+ deviceSn: string;
246
+ }): Promise<AutoLockSnapshot>;
247
+ /**
248
+ * The `a2` account id an `eufy_life` DP frame embeds — the owning member's `admin_user_id` (or the
249
+ * session user id), resolved by the injected {@link MqttRouterDeps.resolveAccountId}. Throws if the
250
+ * resolver isn't wired or yields an empty id: the frame's `a2` opener is a required field, and a
251
+ * fire-and-forget write with an empty account id would look like success while doing nothing.
252
+ */
253
+ private requireAccountId;
254
+ /**
255
+ * Wrap a built DP frame in its `eufy_life` MQTT envelope and publish it to the device's `.../req`
256
+ * topic over the facade's persistent account-wide transport ({@link MqttRouterDeps.publishSecure}) —
257
+ * the CONFIRMED light-write path. Fire-and-forget: the DP wire carries no delivery ack the SDK
258
+ * has captured, so there's nothing to wait on here (unlike the ff09 paths above).
259
+ */
260
+ private publishDpFrame;
261
+ /**
262
+ * The `mqtt-dp` handler — a single `eufy_life` DP TLV write (on/off, brightness). The capability
263
+ * supplies the opaque `mqttCmdCode`/`cmdCode` + already-tagged scalar `fields`; this builds the frame
264
+ * ({@link buildDpFrame} prepends the `a1` timestamp + `a2` account id) and publishes it.
265
+ */
266
+ private dispatchMqttDp;
267
+ /** Serialize and publish one semantic RGB DP action without changing configured brightness. */
268
+ private dispatchDpColor;
269
+ /**
270
+ * The `aiot-dp` handler — a single DP write to a clean-line device (vacuum/mower). Builds the
271
+ * AIoT MQTT envelope ({@link buildCleanDpEnvelope}) and publishes fire-and-forget to the device's
272
+ * `cmd/eufy_home/{pn}/{sn}/req` topic over the facade's persistent transport. The clean line uses
273
+ * the DEFAULT credential (same as `eufy_mega` devices) — no separate MQTT connection needed; only
274
+ * `eufy_life` (smart lights) gets its own certificate. Confirmed live on T2351 (2026-07-30).
275
+ */
276
+ private dispatchAiotDp;
277
+ /**
278
+ * The `mqtt-dp-preset` handler — select a gallery effect by catalog id. Resolves the effect's
279
+ * layer definition via the injected {@link MqttRouterDeps.resolvePreset} (HTTP catalog, client-
280
+ * owned), serializes it to the `0x020D` effect frame ({@link dpPresetFields}), and publishes it;
281
+ * if the catalog entry carries an overall brightness, sends the companion `0x0201` brightness frame
282
+ * ({@link dpLevelFields}) right after, matching the app's two-frame effect apply.
283
+ */
284
+ private dispatchDpPreset;
285
+ }
@@ -0,0 +1,58 @@
1
+ import type { DpInboundFrame } from "../../core/contracts.js";
2
+ /** One TLV field in a DP frame (tag `0xa1`-`0xff`, arbitrary-length value). */
3
+ export interface DpField {
4
+ tag: number;
5
+ value: Buffer;
6
+ }
7
+ /**
8
+ * Build one `eufy_life` DP TLV frame: `[ff 09][total len][00 03 00 02 02][subtype][tag/len/value…]
9
+ * [xor]`. `subtype` = `cmdCode & 0xff`. Every frame opens with `a1`=timestamp (u32LE) + `a2`=account
10
+ * id (ASCII), prepended here so callers supply only the command-specific fields (`0xa3` onward).
11
+ *
12
+ * The total-size field at offset 2 is a **u16LE**, not a single byte: `0x020D` effect frames routinely
13
+ * exceed 255 bytes, and a truncated high byte yields a frame the light rejects outright.
14
+ */
15
+ export declare function buildDpFrame(cmdCode: number, accountId: string, fields: readonly DpField[]): Buffer;
16
+ /**
17
+ * Wrap a DP frame in the `eufy_life` MQTT `/req` envelope the SDK publishes:
18
+ * `{head:{…,cmd:mqttCmdCode}, payload: JSON({account_id, device_sn, data:<b64 frame>, trans:""})}`.
19
+ * The stringified result is the MQTT message body. `client_id` follows the app's
20
+ * `ios-eufy_mega-{userId}-{uuid}` shape; its exact value is CONFIRMED not to affect a write.
21
+ */
22
+ export declare function buildDpEnvelope(opts: {
23
+ accountId: string;
24
+ deviceSn: string;
25
+ mqttCmdCode: number;
26
+ frame: Buffer;
27
+ }): string;
28
+ /**
29
+ * Decode an inbound `eufy_life` MQTT message into its DP frame, or `undefined` on any shape mismatch —
30
+ * another appliance's traffic on the same connection must fall through, never throw.
31
+ *
32
+ * The inbound wrapping is **asymmetric with {@link buildDpEnvelope}** and double-nested: `payload` is a
33
+ * JSON string `{data, sn, pn}` whose `data` is base64 of *another* JSON string, whose own `data` is the
34
+ * frame as **hex** (outbound carries base64 at that inner position). The frame is then validated —
35
+ * magic, the u16LE self-declared length — before any field is read, and the TLV walk is bounded by the
36
+ * trailing checksum byte so a truncated or lying length yields nothing rather than a misread.
37
+ *
38
+ * Tag MEANING is not decided here: the fields come back in wire order for the capability to interpret.
39
+ */
40
+ export declare function parseDpMessage(raw: unknown): DpInboundFrame | undefined;
41
+ /**
42
+ * Parse an AIoT realtime report into its data-point map — the Clean/appliance line's device→app leg,
43
+ * and the counterpart to {@link parseDpMessage}'s TLV frames. The two lines share a broker and a
44
+ * `{head, payload}` envelope but nothing below it: this payload is plain JSON, not a binary frame.
45
+ *
46
+ * `payload` is `{t, protocol, account_id, device_sn, data}` — sometimes as a JSON string, sometimes
47
+ * already an object, so both are accepted. The points live under `data`; a report that flattens them
48
+ * to the top level is read that way instead, with the envelope's own keys skipped.
49
+ *
50
+ * Point ids come back as numbers and every value as a string, matching the cloud record's param shape
51
+ * so a report merges into device state on the same path a polled record does. Values are NOT
52
+ * interpreted: a scalar arrives as its own text and a structured point as the base64 its device sent,
53
+ * for the capability that owns the id to decode.
54
+ *
55
+ * Defensive throughout — this runs against every message on a shared connection, so an unrelated one
56
+ * yields `undefined` rather than throwing.
57
+ */
58
+ export declare function parseAiotDpReport(raw: unknown): Record<number, string> | undefined;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Command-specific fields for the `0x0206` DP custom-colour action. The T8L02 frame applies one
3
+ * foreground RGBCW block to every reported segment, carries no background colour, and marks the
4
+ * selection as outside the cloud catalog. Framing, account identity and publication remain in MQTT.
5
+ */
6
+ import type { DpField } from "./dp-codec.js";
7
+ export interface DpColorSpec {
8
+ red: number;
9
+ green: number;
10
+ blue: number;
11
+ segmentCount: number;
12
+ }
13
+ /** Serialize a validated semantic RGB intent into the complete confirmed `0xa3`-`0xb0` field run. */
14
+ export declare function dpColorFields(spec: DpColorSpec): DpField[];
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The MQTT engine, loaded on first use rather than at import.
3
+ *
4
+ * `mqtt` costs **+19 MB of RSS** to import — measured one fresh process per module, because a single
5
+ * process importing several in sequence gives meaningless per-module deltas — which is a large share of
6
+ * what importing this package costs at all (+36 MB over a bare node; +24 after this). Every consumer
7
+ * paid it at module load, including the ones whose accounts have no appliance to talk MQTT to: the
8
+ * secure broker is only reached when a device needs it, and a camera-only account never opens one.
9
+ *
10
+ * So the import moves to the two places that actually dial a broker. Both already return promises, so
11
+ * nothing about their contracts changes — an `await` in front of a network connect is not a cost.
12
+ */
13
+ type Mqtt = typeof import("mqtt");
14
+ /** Memoized lazy import of the MQTT engine — the ONLY runtime reference to `mqtt` in this package. */
15
+ export declare function loadMqtt(): Promise<Mqtt>;
16
+ export {};
@@ -0,0 +1,5 @@
1
+ export * from "./secure-mqtt.js";
2
+ export * from "./topics.js";
3
+ export * from "./app-client-id.js";
4
+ export * from "./broker-discovery.js";
5
+ export * from "./biz-stream.js";