Skip to content

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.

ts
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 accessorsdev.<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:

ts
eufy.on("deviceCapabilities", ({ deviceSn, gained }) => console.log(deviceSn, "gained", gained));
ts
import { PtzDirection, ArmingMode } from "eufy-mega";

await dev.camera?.()?.on(); // power on
await dev.camera?.()?.snapshot(); // → { file, jpeg } (present only when bound to a live client)
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 accessor

Argument 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 + snapshot/live/record when bound), light (camera floodlight/spotlight: on/off/brightness/colorTemp/enable), smartLight (the eufy_life permanent-outdoor-light line: on/off/brightness/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, ?.()). siren is read-only today (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 back undefined), not present-and-throwing: siren is read-only (no on()/off() yet), and a few lock settings (setOneTouchLock and its siblings) are absent until confirmed. The read getter still works in every case.

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:

ts
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 mode

Read 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/fanSpeed, personDetection).

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 a low-battery flag exposes battery()?.batteryLow; a camera that reports only a numeric level won't have that key at all:

ts
const bat = dev.battery?.();
"batteryLow" in bat!; // false on a device that doesn't report it
bat?.batteryLow; // undefined

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.)

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:

ts
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 available

manufacturer is always "eufy". Like every fluent accessor, info() is undefined only on an unbound model object — call it with ?..

firmwareVersion/hardwareVersion come straight off the device record and are populated whenever the device reports them; they are undefined (optional) for a device that doesn't. They are never a guessed value.

Examples

Snapshot + camera/light control:

ts
/**
 * 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 `?.`.
 *
 * `snapshot()` returns a real still — the stored one, or a live-burst fallback (`file === ""` then) —
 * and is short-TTL cached + coalesced (tune with `{ cacheTtlMs }`). It throws a typed
 * `SnapshotUnavailableError` (not a falsy result) when none is obtainable; branch on `reason`
 * (`offline` vs `no-still`) and render your own placeholder — the SDK never returns placeholder bytes.
 *
 *   EUFY_EMAIL=… EUFY_PASSWORD=… node examples/03-snapshot-and-control.ts <serial>
 *
 * Requires `npm run build` first.
 */
import fs from "node:fs";
import { PtzDirection, SnapshotUnavailableError } 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 shot = await cam?.snapshot?.(); // pass { cacheTtlMs } to tune the poll cache; 0 = fresh
    if (shot) {
      fs.writeFileSync("snapshot.jpg", shot.jpeg);
      console.log("saved snapshot.jpg", shot.jpeg.length, "bytes", shot.file ? `(stored ${shot.file})` : "(live)");
    }
  } catch (e) {
    if (e instanceof SnapshotUnavailableError) console.log(`no 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:

ts
/**
 * 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);

  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):

ts
/**
 * 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:

ts
/**
 * 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:

ts
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. deviceAdded fires only for a device the SDK has previously seen the account without, so you can register your initial accessories from getDevices() and treat the event purely as a delta.
  • deviceRemoved is 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. deviceAdded is 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.

Independent and unofficial. Not affiliated with, endorsed by, or sponsored by Anker Innovations or eufy. "eufy" and "Anker" are trademarks of their respective owners. Use responsibly — rapid or failed logins can trigger captcha or temporary cooldowns.