Devices & capabilities
getDevice(sn) returns a live Device — the primitive a host app reads and controls. There are no subclasses: a device is entirely described by its resolved { codec, capabilities, properties }. You ask what it can do, never instanceof.
const dev = await eufy.getDevice(sn);
dev.has("ptz"); // → boolean — does this device have the capability?
dev.battery?.()?.level; // → number | undefined — typed read (see Typed reads below)
dev.camera?.()?.on(); // → fluent typed action (see Fluent actions below)Read and control a device through its fluent capability accessors — dev.<cap>()?.… — for both reads (typed getters) and writes (action methods). That's the whole surface a host needs.
Reads are cheap and never wake the device: they return in-memory state kept fresh by realtime, with a read-through cache refreshing stale values in the background — so polling a Device is safe. Reuse the object from getDevice(sn) rather than re-fetching each read: it stays current, and its capability set widens in place if the device later reports evidence for more. See Connectivity & battery → Read-through cache.
Fluent, typed actions
Each capability the device HAS exposes a fluent accessor returning a fully-typed action object — IDE autocomplete on the accessor, the methods, and their arguments. The accessor is absent when the device lacks the capability, and returns undefined until the device is bound, so call it dev.<cap>?.() and read through with ?.:
A device carries only the accessors it can answer for
dev.camera exists on a camera and is absent on a smart light — the accessor set is a truthful description of the device, so "camera" in dev and dev.capabilities agree.
That means two optional calls: dev.camera?.()?.on(). The first asks "does this device have a camera", the second "is it bound to a live transport yet". dev.has("camera") is the explicit form if you'd rather branch than chain.
A capability is granted on evidence the device reports, so a device that hasn't reported a given param yet doesn't have the capability that param proves. When it later does, the SDK widens the object you are already holding — the accessor appears, bound, and a deviceCapabilities event fires. Capabilities are never retracted, so the set only grows:
eufy.on("deviceCapabilities", ({ deviceSn, gained }) => console.log(deviceSn, "gained", gained));import { PtzDirection, ArmingMode } from "@mega-yfue/eufy-sdk";
await dev.camera?.()?.on(); // power on
const stored = await dev.camera?.()?.snapshotStored?.(); // → Buffer; latest retained push JPEG
const fresh = await dev.camera?.()?.snapshotLive(); // → { jpeg, width, height }; fresh live capture
await dev.ptz?.()?.rotate(PtzDirection.left, 1.0); // PTZ step — see the PTZ guide
await dev.light?.()?.setBrightness(50); // spotlight 1–100
await dev.arming?.()?.setMode(ArmingMode.home); // guard mode
await dev.lock?.()?.lock();
await dev.lock?.()?.setAutoLock(true, 60); // enable, 60s delay
await dev.lock?.()?.setRainMode?.(true); // P2P video lock only — optional accessorArgument constants (PtzDirection, ArmingMode, …) are exported from the package — pass the named member so the choices autocomplete and can't drift.
Available today: camera (on/off/privacy/statusLed + stored/live snapshots, live/record when bound), light (camera floodlight/spotlight: on/off/brightness/colorTemp/enable), smartLight (the eufy_life permanent-outdoor-light line: on/off/brightness/custom colour/effect — see the Smart lights guide), ptz (rotate + left/right/up/down, zoom, and a preset() sub-API — see the PTZ guide), plus arming, lock (lock/unlock/setAutoLock on both the P2P video lock and the MQTT garage door, plus setRainMode on the video lock only — optional accessor, ?.()), and siren (volume, alarm duration, and test/stop triggers — see below).
Unverified writes are absent, they don't guess. A write method present on a
dev.<cap>()object means its wire is verified — so you know at compile time what's settable. Where a write isn't yet verified on a mega device the method is simply absent from the object (an optional?.()that reads backundefined), not present-and-throwing: a few lock settings (setOneTouchLockand its siblings) are absent until confirmed on-device. The read getter still works in every case.
A value outside what a control accepts is refused, not adjusted. Every setter states the values it takes — an option set, or a numeric range — and a value outside it rejects before anything is sent, naming the set it had to come from. Nothing is quietly rounded into range and nothing falls back to a default, because these writes are fire-and-forget: a value that was silently altered on the way out is indistinguishable from one the device accepted. Read the range off the control's own description rather than assuming one, and treat a rejection as a bug in the caller.
Typed reads
The same fluent dev.<cap>() object exposes typed read getters alongside its actions — the read twin of the write methods. They fix the read/write asymmetry: instead of the loose getProperty("battery")?.value (boolean | number | string), a getter returns the value already narrowed to its type:
const bat = dev.battery?.();
bat?.level; // number | undefined (0–100)
bat?.charging; // boolean | undefined
bat?.powerSource; // number | undefined
dev.contact?.()?.open; // boolean | undefined — door/window open
dev.lock?.()?.locked; // boolean | undefined
dev.camera?.()?.nightVision; // number | undefined
dev.motion?.()?.detectionEnabled; // boolean | undefined
dev.arming?.()?.mode; // number | undefined — current guard modeRead getters live on every capability that has readable state (battery, contact, lock, camera, light, motion, arming, siren, the leak/smoke/co/keypad sensors, storage, audio, doorbell, ptz, RoboVac vacuumClean/suction, personDetection).
A getter narrows the value's type; what the value means — a percentage, a temperature, an instant, one of a named set — is declared beside it and is what a host needs to render a reading. See What a reading means.
Getters are evidence-gated. A getter is present only when the device actually reported the backing value — so a device advertises exactly the reads it has, never a phantom sub-feature of the capability it happens to own. A battery camera that reports its custom-recording settings exposes battery()?.recordDuration; one that reports only a level and a temperature won't have that key at all:
const bat = dev.battery?.();
"recordDuration" in bat!; // false on a device that doesn't report it
bat?.recordDuration; // undefinedSetters are gated too, on the same evidence. A device that never reported a setting does not get the method that writes it — an entry sensor reports a battery level and nothing else, so it carries battery()?.level and no setters at all. That matters because these writes are fire-and-forget: a method that exists but is ignored by the hardware is indistinguishable from one that worked. Optional setters are therefore genuinely optional — guard them (bat?.setRecordDuration?.(60)) or check first.
Like every fluent accessor these are bound-only (the dev.<cap>() object exists once the device is bound to a live client, which is the normal case after getDevice).
(A low-level getProperty(name) / getProperties() escape hatch exists for unbound model objects, diagnostics, or a param not yet surfaced on a capability — but reach for the typed capability getters above in application code.)
When a setting means something different per device
A few settings are not one scale. Motion sensitivity is the clearest: across the device families this has been captured on, it rides four different command ids in three different frame shapes, and on two of them the number counts DOWN as the device gets more sensitive. A raw value means the opposite thing depending on what it reaches, and two families report the very same set of ids while accepting different ones — so nothing a device exposes lets a caller work out which applies.
So the SDK does not hand over the number. It reports how many steps the device offers, which one is current, and takes a step back:
const m = dev.motion?.();
const steps = m?.sensitivitySteps(); // 5, 7 … or undefined
if (steps === undefined) {
// Not drivable on this device — don't offer the control.
} else {
slider({ min: 1, max: steps, value: m.sensitivityStep() ?? 1 });
onChange = (step) => m.setSensitivityStep(step);
}Step 1 is always the least sensitive, whatever the device's own numbering does. The same three lines drive a sensor whose ladder runs 80 down to 8 and a camera whose picker runs 1 up to 5.
undefined is an answer, not a gap: it means no capture places this model's scale, and the SDK would rather say so than send an id it is guessing at — a write it ignores would otherwise look like it worked. A step outside the device's range is rejected rather than clamped.
What you don't get is labels. Whether five steps read as "low/medium/high" is presentation, and the detection distances the vendor app shows are only known for the PIR sensor — so a host renders a position, not a name.
Device info (metadata)
Every device exposes a universal, read-only info() accessor returning its identity metadata — handy for a host's device registry or device-info surface:
const i = dev.info?.(); // defined for every bound device
i?.manufacturer; // → "eufy" (always)
i?.model; // → "T8410" (model / T-code), when known
i?.serialNumber; // → the device serial, when known
i?.name; // → the display name, when known
i?.deviceType; // → numeric device type (diagnostic), when known
i?.firmwareVersion; // → firmware / main software version (e.g. "3.8.2.8"), when reported
i?.hardwareVersion; // → hardware version (e.g. "V05"), when reported
i?.firmwareSubVersion; // → secondary firmware version, when reported
i?.macAddress; // → Wi-Fi MAC address, when reported
i?.updateAvailable; // → true when the device reports a firmware update is availablemanufacturer is always "eufy". Like every fluent accessor, info() is undefined only on an unbound model object — call it with ?..
firmwareVersion/hardwareVersioncome straight off the device record and are populated whenever the device reports them; they areundefined(optional) for a device that doesn't. They are never a guessed value.
Examples
Snapshot + camera/light control:
/**
* Example 03 — snapshot + fluent capability control.
*
* Resolves one device and drives it through the typed fluent API: grab a still, then (if the
* device has the capability) power it on and pan-tilt. Accessors are `undefined` when the device
* lacks the capability, so guard with `?.`.
*
* `snapshotStored()` passively returns the latest qualifying push JPEG retained in memory. It never
* opens P2P or falls back to live capture. When no JPEG is retained it throws the typed
* `StoredSnapshotUnavailableError`; use `snapshotLive()` separately when a fresh capture is required.
*
* EUFY_EMAIL=… EUFY_PASSWORD=… node examples/03-snapshot-and-control.ts <serial>
*
* Requires `npm run build` first.
*/
import fs from "node:fs";
import { PtzDirection, StoredSnapshotUnavailableError } from "../dist/index.js";
import { loginClient } from "./_client.ts";
async function main(): Promise<void> {
const sn = process.argv[2];
if (!sn) throw new Error("usage: node examples/03-snapshot-and-control.ts <serial>");
const eufy = await loginClient();
const dev = await eufy.getDevice(sn);
const cam = dev.camera?.();
// Media actions exist only when the device is bound to a live client (they are here) — the
// method is optional on the type, so guard the method, not just the `camera()` accessor.
try {
const jpeg = await cam?.snapshotStored?.();
if (jpeg) {
fs.writeFileSync("snapshot.jpg", jpeg);
console.log("saved snapshot.jpg", jpeg.length, "bytes");
}
} catch (e) {
if (e instanceof StoredSnapshotUnavailableError) console.log(`no stored snapshot available: ${e.reason}`);
else throw e;
}
// Fluent control — the accessor is undefined if the device lacks the capability; confirm the
// individual action exists before calling it too.
if (typeof cam?.on === "function") await cam.on();
const pt = dev.ptz?.();
if (typeof pt?.rotate === "function") await pt.rotate(PtzDirection.left);
console.log("done");
await eufy.disconnect();
}
main().catch((e: unknown) => {
console.error("FATAL", e instanceof Error ? e.message : String(e));
process.exit(1);
});Pan-tilt-zoom is its own guide — see PTZ.
Camera spotlight / floodlight — a light built into a camera:
/**
* Example 05 — control a CAMERA's spotlight / floodlight.
*
* This is the `light` capability: a light built INTO a camera, driven over the camera's own wire. It is
* a different product line from the standalone eufy Life smart lights, which use `smartLight()` — see
* example 09. A device has one or the other, never both.
*
* Resolves a device with the `light` capability and drives it via the fluent `light()` accessor:
* `on()/off()` (or `set(bool)`), plus `setBrightness(1–100)`, `setColorTemp(0 warm–100 cool)` and
* `setEnabled(bool)` for the master switch. The accessor is `undefined` on a device without a light,
* so guard with `?.` (or check `dev.has("light")`).
*
* Note: on/off ride the level-1 wire and work on standalone cameras. Brightness / colorTemp /
* master-enable are level-2 (direct-binary) — on a standalone camera with no HomeBase they throw
* `level-2 key not ready` (see README "Control-command encryption level"). Skipped here unless the
* key negotiates.
*
* EUFY_EMAIL=… EUFY_PASSWORD=… node examples/05-camera-light.ts <serial> [on|off] [brightness 1-100]
*
* Requires `npm run build` first.
*/
import { loginClient } from "./_client.ts";
async function main(): Promise<void> {
const sn = process.argv[2];
const state = (process.argv[3] || "on").toLowerCase();
const brightness = process.argv[4] ? Number(process.argv[4]) : undefined;
if (!sn) throw new Error("usage: node examples/05-camera-light.ts <serial> [on|off] [brightness 1-100]");
if (state !== "on" && state !== "off") throw new Error(`bad state "${state}" — one of on|off`);
const eufy = await loginClient();
const dev = await eufy.getDevice(sn);
const light = dev.light?.();
if (!light) throw new Error(`${sn} has no light capability`);
// Confirm the action exists on this binding before calling it.
const action = state === "on" ? "on" : "off";
const setPower = light[action];
if (typeof setPower !== "function") throw new Error(`${sn} light has no ${state}() action`);
console.log(`turning light ${state} …`);
await setPower.call(light);
// `isOn` (the momentary lighting) and `spotlightEnabled` (the master switch) are two different facts,
// and both are readable. The lamp is lit only while something is streaming — the vendor app lights it
// for a live view and drops it on quitting — whereas the master switch is the SETTING a user changes
// and expects to stay changed. A change to either arrives as `propertyChanged`.
console.log({ lit: light.isOn, masterSwitch: light.spotlightEnabled, brightness: light.brightness });
if (brightness !== undefined) {
if (brightness < 1 || brightness > 100) throw new Error("brightness must be 1–100");
// A light may be on/off-only (status-LED cams report no brightness param) — the typed read is
// evidence-gated, so `brightness` is undefined when the device doesn't report one.
if (light.brightness === undefined) throw new Error(`${sn} light has no brightness control`);
if (typeof light.setBrightness !== "function") throw new Error(`${sn} light has no setBrightness() action`);
console.log(`setting brightness ${brightness} …`);
// Level-2 wire — throws `level-2 key not ready` on a standalone (HomeBase-less) camera.
await light.setBrightness(brightness);
}
console.log("sent — watch the camera");
await eufy.disconnect();
}
main().catch((e: unknown) => {
console.error("FATAL", e instanceof Error ? e.message : String(e));
process.exit(1);
});Standalone smart light — the eufy Life lighting line, a different product line with its own accessor and its own state model (see Smart lights):
/**
* Example 09 — control a standalone eufy Life SMART LIGHT and read its state back.
*
* This is the `smartLight` capability: the standalone lighting line (e.g. the Permanent Outdoor Lights),
* a different product line from a camera's built-in spotlight — that one is `light()`, see example 05. A
* device has one or the other, never both, so the accessor you get tells you which line you're on.
*
* Unlike most devices, a smart light has no pollable cloud state: everything is reported over realtime.
* The SDK asks for a snapshot as soon as the light's channel is up and `getDevice` waits for the
* answer, so the getters are populated on return. They stay optional — a device that never answers
* leaves them `undefined` rather than blocking the lookup — and `smartLightState` is what tells you the
* moment state changes.
*
* EUFY_EMAIL=… EUFY_PASSWORD=… node examples/09-smart-light.ts <serial> [on|off] [brightness 0-100]
*
* Requires `npm run build` first.
*/
import { loginClient } from "./_client.ts";
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
async function main(): Promise<void> {
const sn = process.argv[2];
const state = (process.argv[3] || "on").toLowerCase();
const brightness = process.argv[4] ? Number(process.argv[4]) : undefined;
if (!sn) throw new Error("usage: node examples/09-smart-light.ts <serial> [on|off] [brightness 0-100]");
if (state !== "on" && state !== "off") throw new Error(`bad state "${state}" — one of on|off`);
const eufy = await loginClient();
// Every report arrives as this event. Fields are optional: a report carries only what the device
// sent, so an absent field is silence about it rather than a change to it.
eufy.on("smartLightState", (e) => {
console.log(` [report] power=${e.power} brightness=${e.brightness} running=${e.cloudEffectId}`);
});
const dev = await eufy.getDevice(sn);
const light = dev.smartLight?.();
if (!light) throw new Error(`${sn} has no smart_light capability (is it a eufy Life light?)`);
const show = (label: string): void => {
console.log(
`${label}: power=${light.power} brightness=${light.brightness} ` +
`selected=${light.effectId} running=${light.cloudEffectId} segments=${light.lightLength}`,
);
};
// Populated already: the snapshot request goes out as soon as the light's channel is up, and
// getDevice waits for the answer. `undefined` here only if the device never answered.
show("state at connect");
console.log(`turning ${state} …`);
await (state === "on" ? light.on() : light.off());
if (brightness !== undefined) {
if (brightness < 0 || brightness > 100) throw new Error("brightness must be 0–100");
console.log(`setting brightness ${brightness} …`);
await light.setBrightness(brightness);
}
// Writes are answered with a fresh report; give it a moment to land, then read the getters again.
await sleep(4000);
show("state after writes");
// `brightness` is the CONFIGURED level and survives `off` — pair it with `power`, don't read 0 as off.
// `effectId` is what's selected; `cloudEffectId` is what's actually running (0 when nothing is).
if (light.power === false && light.brightness) {
console.log(`(off, but still configured for brightness ${light.brightness})`);
}
// Re-ask on demand. Resolves once the request is sent — the answer arrives as a report.
await light.refreshState();
await sleep(3000);
show("state after refreshState()");
await eufy.disconnect();
}
main().catch((e: unknown) => {
console.error("FATAL", e instanceof Error ? e.message : String(e));
process.exit(1);
});Lock:
/**
* Example 06 — read a smart lock's state and drive it.
*
* Resolves a device with the `lock` capability and reads its state via the typed fluent getters:
* `lock().locked` (lock state), `lock().battery` (0–100), `lock().rssi` (dBm). Inbound lock events
* arrive as the `lockState` semantic event over push (see example 02).
*
* `lock()`/`unlock()` and `setAutoLock(enabled, delaySeconds?)` are LIVE-VERIFIED on both the P2P
* video lock (T8531) and the MQTT-only garage door (T85D0) — `dev.lock?.()` looks identical either
* way, the capability picks the transport. `setRainMode(enabled)` is P2P-only (T8531) and OPTIONAL
* on the returned object — the garage door doesn't have it, so guard with `?.()`.
*
* EUFY_EMAIL=… EUFY_PASSWORD=… node examples/06-lock.ts <serial> [lock|unlock|autolock-on|autolock-off|rain-on|rain-off]
*
* Requires `npm run build` first.
*/
import { loginClient } from "./_client.ts";
async function main(): Promise<void> {
const sn = process.argv[2];
const action = (process.argv[3] || "lock").toLowerCase();
const valid = ["lock", "unlock", "autolock-on", "autolock-off", "rain-on", "rain-off"];
if (!sn) throw new Error(`usage: node examples/06-lock.ts <serial> [${valid.join("|")}]`);
if (!valid.includes(action)) throw new Error(`bad action "${action}" — one of ${valid.join("|")}`);
const eufy = await loginClient();
const dev = await eufy.getDevice(sn);
const lock = dev.lock?.();
if (!lock) throw new Error(`${sn} has no lock capability`);
// Read side works today — typed fluent getters, evidence-gated (present only when reported).
console.log(`lock ${sn}:`);
console.log(` locked : ${lock.locked ?? "unknown"}`);
console.log(` battery: ${lock.battery ?? "unknown"}%`);
console.log(` rssi : ${lock.rssi ?? "unknown"} dBm`);
switch (action) {
case "lock":
console.log("locking …");
await lock.lock();
break;
case "unlock":
console.log("unlocking …");
await lock.unlock();
break;
case "autolock-on":
console.log("enabling auto-lock (60s delay) …");
await lock.setAutoLock(true, 60);
break;
case "autolock-off":
console.log("disabling auto-lock …");
await lock.setAutoLock(false);
break;
case "rain-on":
case "rain-off":
// Optional — only the P2P video lock (T8531) has this, so check before calling.
if (!lock.setRainMode) throw new Error(`${sn} has no setRainMode (not a P2P video lock?)`);
console.log(`setting rain mode ${action === "rain-on" ? "on" : "off"} …`);
await lock.setRainMode(action === "rain-on");
break;
}
console.log("sent — check the app");
await eufy.disconnect();
}
main().catch((e: unknown) => {
console.error("FATAL", e instanceof Error ? e.message : String(e));
process.exit(1);
});Devices joining or leaving
The account roster is re-checked on the same interval as cloud params:
eufy.on("deviceAdded", (d) => registerAccessory(d));
eufy.on("deviceRemoved", (d) => dropAccessory(d.sn));Two things to rely on:
- The first enumeration after login is not a stream of additions.
deviceAddedfires only for a device the SDK has previously seen the account without, so you can register your initial accessories fromgetDevices()and treat the event purely as a delta. deviceRemovedis conservative. The device list is assembled from several queries, and one can fail while the others succeed. Rather than report the missing devices as removed — which would have you delete live accessories during a transient outage — a partly-resolved refresh reports no removals at all.deviceAddedis gated the same way in the other direction: nothing is announced as a join against a baseline that only partly resolved, so a recovering outage doesn't read as a pairing burst.
Because it rides the poll interval, a pairing shows up within that window rather than instantly.
Next: Events · Live media.