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 "eufy-mega";

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 or eufy. "eufy" and "Anker" are trademarks of their respective owners. Use responsibly — rapid or failed logins can trigger captcha or temporary cooldowns.