Skip to content

Pan-tilt-zoom (PTZ)

Cameras that pan, tilt, and zoom expose a ptz() accessor. Like every capability accessor it is undefined on a device that lacks it (or on an unbound model), so call it with ?. — or check dev.has("ptz") first.

ts
const ptz = dev.ptz?.();
if (!ptz) throw new Error("not a PTZ camera");

Move & zoom

Movement is command-driven — the camera has no "go to angle X" write; it steps in a direction.

ts
import { PtzDirection } from "@mega-yfue/eufy-sdk";

await ptz.rotate(PtzDirection.left, 1.0); // step in a direction (optional zoom factor)
await ptz.left(); // shorthands: left / right / up / down
await ptz.up();

await ptz.zoom?.(2); // digital zoom to a factor (no movement)
await ptz.zoom?.(1); // back to the full wide view

PtzDirection is exported from the package — pass the named member so the choices autocomplete and can't drift.

Zoom is a dual-lens feature (a second telephoto camera). zoom is therefore present only on cameras that have it — it's undefined on a single-lens pan-tilt cam, so call it with ?. (or check if (ptz.zoom)). This is resolved when the device loads — no wire call. There's no way to read the current zoom level at rest; observe changes via the ptzNotify event instead.

Presets

A preset is a stored camera position (a slot the camera can snap back to). All preset operations live under the preset() sub-API:

ts
const preset = ptz.preset();

await preset.goto(3); // move to stored preset 3
await preset.preview(3); // move to it transiently (preview before committing)
await preset.save(3); // save the CURRENT position into slot 3 (create or overwrite)
await preset.preview(3); // …then park on it before setting default (see note below)
await preset.setDefault(3); // make preset 3 the home position
await preset.delete(3); // remove preset 3
MethodWhat it does
goto(id)Move to stored preset id.
preview(id)Move to id transiently — the app's "preview before default" step.
save(id)Save the camera's current position into slot id (create/overwrite).
setDefault(id)Make preset id the home position — sets the default to the preset the camera is parked on, so preview(id) and let the pan finish first (see note).
delete(id)Remove preset id.
list?()List stored presets (live-only, see below).
image?(id)Fetch preset id's thumbnail (live-only, see below).

Only save populates a slot — the rest are fire-and-forget

Preset writes get no acknowledgement back, so referencing an empty slot is a silent no-op: goto / preview / setDefault / delete on an id that was never saved simply do nothing — no error surfaces. If the id might not exist, check list?() first.

setDefault(id) additionally sets the default to the preset the camera is currently parked on — so preview(id), let the pan finish, then setDefault(id). Sent with the camera elsewhere it has no effect.

Reading presets (live-only)

list() and image() are request/reply reads, so they are present only when the device is bound to a live client — call them with ?.:

ts
const presets = (await ptz.preset().list?.()) ?? []; // [{ id, raw }, …]
console.log(presets.map((p) => p.id)); // e.g. [0, 1, 3]

const thumb = await ptz.preset().image?.(3); // { index, data } — data is a base64 JPEG
if (thumb) console.log(`${thumb.data.length} bytes`);

list() returns { id, raw } per preset (raw preserves any model-specific fields). image() returns { index, data } with the thumbnail as a base64 JPEG; a very large thumbnail may not come back over the live channel yet — treat an empty result as "not available".

Example

ts
/**
 * Example 04 — pan-tilt (PTZ) a camera.
 *
 * Resolves a PT camera and steps it in a direction via the fluent `ptz()` accessor. Movement
 * is command-driven (the camera has no "go to angle" write; it steps): `rotate(dir, zoom)`, or the
 * shorthands `left()/right()/up()/down()`. The accessor is `undefined` on a device without PTZ, so
 * guard with `?.` (or check `dev.has("ptz")`).
 *
 * Stored **presets** live under the `preset()` sub-API — `preset().goto(id)` / `preview(id)` /
 * `save(id)` (save the current position into a slot) / `setDefault(id)` / `delete(id)`, plus the
 * live-only reads `preset().list?()` and `preset().image?(id)` (request/reply over P2P, present only
 * when the device is client-bound — call with `?.`).
 *
 *   EUFY_EMAIL=… EUFY_PASSWORD=… node examples/04-ptz.ts <serial> [left|right|up|down] [zoom]
 *   EUFY_EMAIL=… EUFY_PASSWORD=… node examples/04-ptz.ts <serial> preset [id]
 *
 * Requires `npm run build` first.
 */
import { PtzDirection } from "../dist/index.js";
import { loginClient } from "./_client.ts";

// `PtzDirection` is the exported direction constant + companion type — the CLI whitelist derives
// from it, so it can't drift from what `rotate()` accepts.
const isDirection = (v: string): v is PtzDirection => v in PtzDirection;

async function main(): Promise<void> {
  const sn = process.argv[2];
  const mode = process.argv[3] || PtzDirection.left;
  if (!sn) throw new Error("usage: node examples/04-ptz.ts <serial> [left|right|up|down|preset] [zoom|id]");

  const eufy = await loginClient();

  const dev = await eufy.getDevice(sn);
  const ptz = dev.ptz?.();
  if (!ptz) throw new Error(`${sn} has no pan-tilt capability`);

  if (mode === "preset") {
    // Preset workflow via the `preset()` namespace.
    const preset = ptz.preset();
    const id = process.argv[4] ? Number(process.argv[4]) : 1;

    // Reads are live-only — guard with `?.`. `list()` returns [{ id, raw }].
    const presets = (await preset.list?.()) ?? [];
    console.log(`${presets.length} stored preset(s): ${presets.map((p) => p.id).join(", ") || "(none)"}`);

    console.log(`going to preset ${id} …`);
    // Fire-and-forget: goto/preview/setDefault/delete on an empty slot are a silent no-op (no ack).
    await preset.goto(id); // move to a stored preset
    // await preset.save(id);        // save the CURRENT position into slot `id` (create/overwrite)
    // await preset.setDefault(id);  // make preset `id` the home position
    // await preset.delete(id);      // remove preset `id`
    const img = await preset.image?.(id); // thumbnail (live-only), base64 JPEG in `img.data`
    if (img) console.log(`preset ${img.index} thumbnail: ${img.data.length} bytes`);
  } else {
    if (!isDirection(mode)) throw new Error(`bad direction "${mode}" — one of ${Object.keys(PtzDirection).join("|")}`);
    const zoom = process.argv[4] ? Number(process.argv[4]) : 1.0;
    console.log(`rotating ${mode} (zoom ${zoom}) …`);
    await ptz.rotate(mode, zoom);
    // equivalent shorthands: await ptz.left(); await ptz.up(); …
    // digital zoom without moving (dual-lens cams only, so it's optional): await ptz.zoom?.(2);
  }

  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);
});

Next: Events · Live media.

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