@mega-yfue/eufy-sdk / CapabilityModule
Interface: CapabilityModule
A capability module: property schema + detection + inbound decode + outbound commands. Written once, reused by every device that lists the capability.
Properties
capability
capability: Capability;description?
optional description?: string;Human description for docs.
detection?
optional detection?: DetectionSpec;How devices are detected as having this capability. Absent = only via codec baseline / registry.
emits?
optional emits?: readonly string[];Semantic event names this capability emits from decodeEvent, which the flat events table cannot express — a frame the module parses itself has no id to list there.
Declared so the published manifest can state what a capability emits without a caller subscribing blind: the manifest's event list is this union'd with the table's own emit names. A capability whose events all come from the table omits it; decode-event.spec.ts locks the union against a hardcoded list, since the typed event map erases at build and cannot keep this honest.
events?
optional events?: EventMapping[];Declarative inbound-event mappings (push eventType / poll param → semantic event name). The common case: pure data, no code. The barrel indexes these for direct lookup.
line?
optional line?: ProductLine;The product line this capability belongs to; defaults to security (the bulk of the catalogue).
Checked BEFORE any detection evidence, so a capability can never land on a device from another line. This matters because the detection fields are OR-ed and several capabilities are found by NAME alone — eufy's retail vocabulary collides across ecosystems, so a light called "Outdoor Spotlights" or a strip named for a water effect otherwise picks up a camera or leak capability whose wire it does not speak.
members?
optional members?: Members;The capability's surface, one entry per feature — the schema, the getters, the setters, the intent routes and the descriptions all derived from it. See ./members.ts.
A feature is spelled ONCE: properties is propertiesOf(X_MEMBERS), never a second list.
Absent only on a capability with no surface to bind: pure detection (video, snapshot), or one whose object is a projection rather than device params (info).
ownedByStation?
optional ownedByStation?: boolean;The capability belongs to whichever device OWNS the group, so a device that hangs off a parent station must not claim it — even when a curated row grants it or the device mirrors the param.
Guard mode is the case that grounds this: a standalone SoloCam/Indoor cam owns its own mode (read live: armingMode answers on a standalone T8170/T8171/T8410), but behind a HomeBase the hub owns it and the app shows it there, not per camera — an attached T8170 reports no mode at all. Applied AFTER the three tiers are unioned (see resolveDevice), because the point is to withhold a control the device cannot answer for however it was granted.
properties
properties: PropertySpec[];Properties this capability contributes.
A module with a members table sets this to propertiesOf(ITS_MEMBERS) rather than writing it out: the schema is derived from the same declaration as the getters and setters, and is materialised here so every existing consumer keeps reading it the same way.
stateEvents?
optional stateEvents?: object[];Semantic events that describe a state, and the payload field holding it — so a state that several transports report is announced once per real change instead of once per transport.
One physical change can reach the SDK on more than one path: an entry sensor's contact arrives as a station notify ~2 s before the same value arrives as an FCM push. Both are real, but they describe one change. Listing the event here makes the emitter edge-triggered on that field: a value equal to the last one announced for that device is suppressed.
Declare this ONLY for events carrying a settled state. An event that is a pulse — motion, a doorbell press — must be omitted: consecutive pulses are identical by nature and deduping them would drop real detections.
Edge-triggering is applied to realtime sources only. A poll still re-announces an unchanged state, so a missed realtime frame is re-synchronised rather than left waiting for the state to change again.
event
event: string;field
field: string;Methods
actions()?
optional actions(deps): CapabilityActions;The object of bound action methods exposed on a device that HAS this capability (e.g. device.light() → {on, off, setBrightness}). Control actions resolve a Command and emit it through sink; media actions (snapshot/live/record) delegate to the optional media provider (absent on a model object not bound to a live client).
Every injected provider here names a technical job, never the capability that happens to be its first caller: media is shared by every media-capable device, and ff09Settings is named for the frame family it reads, so any device driven by that frame can use it. That is the rule, not a convention — a provider that cannot be named without saying "lock" or "vacuum" is a provider split in the wrong place.
ff09Settings exists as its own boundary because GET_SETTINGS is a request/reply query (decrypt + parse + pick P2P-vs-MQTT), and neither CommandSink (write-only, Promise<void>) nor MediaProvider.p2pQuery (P2P-only, raw passthrough, no decrypt) fits it. A further ff09 setting needing a live read is one more method on Ff09SettingsReader; a genuinely different wire family is its own provider, not another parameter bolted on next to this one.
Parameters
deps
Returns
buildCommand()?
optional buildCommand(
action,
value,
ctx
): Command | undefined;Resolve a semantic action into a transport-neutral Command for THIS device, or undefined if this module doesn't handle (action). action is a capability-local verb (e.g. "on", "off", "setBrightness", "rotate"), NOT a param id. Uses ctx to pick the right variant. This is where per-device variance lives — once, in the module.
Parameters
action
string
value
string | number | boolean
ctx
Returns
Command | undefined
decodeEvent()?
optional decodeEvent(signal): CapabilityEvent | null;Escape hatch for inbound signals a flat EventMapping table can't express — e.g. parsing a binary P2P frame (the pan-tilt position stream). Return the semantic CapabilityEvent or null. Simple capabilities use only events and omit this.
Parameters
signal
Returns
CapabilityEvent | null
decodeState()?
optional decodeState(signal): DecodedState | null;Recover device STATE from an inbound signal, as wire ids in this device's own param namespace — the input to Device.applyParams, so a realtime-only line's values reach the same CapabilityModule.members getters a pollable cloud param would.
Separate from CapabilityModule.decodeEvent because the two answer different questions: a report that repeats the current state carries no event worth emitting but must still refresh the readable state. A module that decodes both shares one private parser between the hooks.
Parameters
signal
Returns
DecodedState | null
realtimeInit()?
optional realtimeInit(ctx): Command[];Commands to send once this device's realtime channel is up — e.g. a state-snapshot request, for a line whose state is pushed on change with no periodic heartbeat, so the typed reads are populated at connect instead of only after the first write. Best-effort: a failure is reported, never fatal.
Parameters
ctx
Returns
Command[]