Skip to content

feat(ws): route device.action to the ptz surface, incl. the preset sub-API - #45

Open
Filpin011 wants to merge 1 commit into
mega-yfue:devfrom
Filpin011:feat/ptz-buttons
Open

Filpin011 wants to merge 1 commit into
mega-yfue:devfrom
Filpin011:feat/ptz-buttons

Conversation

@Filpin011

Copy link
Copy Markdown

What

device.action resolved a verb against [smartLight, camera] only. The PTZ verbs live on
dev.ptz(), which was never consulted, so every pan-tilt control a host might draw came back
no action 'left'ws-protocol.md listed "PTZ move" under Not yet exposed for exactly this
reason.

Adding the surface is one entry in that list. Presets needed a little more: preset answers
rather than acts — it hands back the sub-API namespace — so a flat lookup cannot reach
goto/save/list. A dotted action now walks the namespace: every segment before the leaf is
called with no arguments, and only the leaf receives args. preset.goto therefore means
dev.ptz().preset().goto(id), with no per-verb wiring, and the same mechanism covers any sub-API
added later.

Why it is shaped this way

  • Dotted paths instead of named cases. A case "preset.goto" per verb would need touching this
    file for every future one. Walking the path keeps the transport capability-agnostic, which is the
    line the rest of the file already holds.
  • Pan calibration stays listed as not exposed. CMD_INDOOR_PAN_CALIBRATION (6017) and
    CMD_OUTDOOR_PAN_CALIBRATION (6251) are raw P2P command ids the SDK never promotes to capability
    members, so device.action cannot reach them by design — worth saying out loud, since a host
    author will look for a calibrate verb and not find one.

Docs

device.action had no protocol section at all; it has one now, including the fire-and-forget
caveat (P2P sends no ack, so ok: true means the frame left, not that the camera moved) and the
silent no-op on an empty preset slot. Not yet exposed is corrected accordingly.

Tests

wiring.test.mjs gains a pan-tilt device in the fake SDK and covers: a bare verb resolving on the
ptz surface, a dotted path reaching the preset namespace with the args landing on the leaf only, a
missing verb and a missing leaf both failing cleanly, and a fixed camera refusing movement.

Note for reviewers on Windows: config.test.mjs fails there before and after this change, on a
path separator (data\.eufy-fcm.json vs data/.eufy-fcm.json). Unrelated, and green in CI.

Verified on hardware

The four movement verbs and preset.list / preset.preview were driven against a real pan-tilt
camera through this branch.

@max246 max246 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work — the approach is right and the code is correct. I traced the dotted-path walk: a bare verb (left) resolves on the ptz surface, preset.goto walks ptz().preset().goto(id) with args landing only on the leaf, and a missing verb or missing leaf both fail cleanly. Keeping it a path-walk instead of per-verb cases is the right call — the transport stays capability-agnostic, and hardware-verifying the movement verbs + preset list/preview is exactly what a fire-and-forget P2P control needs. Tests and the new device.action protocol section are thorough.

One thing before it can merge: it's currently CONFLICTING, and that's on us — two changes landed on dev after you branched and both touch the same device.action block:

  1. the ws-server refactor (#52) reorganized the file and reformatted the surfaces array to multi-line, and
  2. the lock PR (#49) added dev.lock?.() to that array.

So the resolution is mechanical — rebase onto dev and the merged case is your change on top of theirs:

const surfaces = [dev.smartLight?.(), dev.camera?.(), dev.lock?.(), dev.ptz?.()].filter(Boolean);
const path = action.split(".");
const leaf = path.pop();
let target = surfaces.find((s) => typeof s?.[path[0] ?? leaf] === "function");
for (const seg of path) target = typeof target?.[seg] === "function" ? target[seg]() : undefined;
if (typeof target?.[leaf] !== "function") return fail(`no action '${action}' on ${msg.sn}`);
// ...
const result = await target[leaf](...args);

That's just: keep dev.lock?.() from #49, add your dev.ptz?.(), and apply your walk logic in the new (single-switch) structure. Approving on the code; give it a rebase and it's good to merge. (CI also hasn't reported on the branch yet — the rebase push should kick it.)

@max246

max246 commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

@Filpin011 can you fix the conflict?

@Filpin011

Copy link
Copy Markdown
Author

Yeah, tomorrow i'll fix it ☺️

`device.action` resolved a verb against `[smartLight, camera]` only, so every
pan-tilt control a host might draw came back `no action 'left'` — the PTZ verbs
live on `dev.ptz()`, which was never consulted. Add that surface.

Preset operations sit one level deeper: `preset` ANSWERS rather than acts (it
hands back the sub-API namespace), so a flat lookup can't reach `goto`/`save`.
Resolve a dotted `action` by walking the namespace — every segment before the
leaf is called with no arguments, and only the leaf receives `args`. That makes
`preset.goto` mean `dev.ptz().preset().goto(id)` with no per-verb wiring, and
the same mechanism covers any future sub-API.

Document `device.action` (it had no protocol section at all) and correct "Not
yet exposed", which still listed PTZ move. Pan calibration stays listed there:
6017/6251 are raw P2P command ids the SDK never promotes to capability members,
so `device.action` cannot reach them by design.
@Filpin011

Copy link
Copy Markdown
Author

@max246 done

@Filpin011
Filpin011 requested a review from max246 September 21, 2026 07:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants