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.
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.
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 viewPtzDirection 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:
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| Method | What 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 ?.:
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
/**
* 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.