All imports below come from @keesmod/eufy-mega-client. Public results and events
use library-owned types. Protocol SDK objects are private implementation details.
Use Node.js 24 and install the compiled package as described in the README.
import { EufyMegaClient, FileSessionStore } from '@keesmod/eufy-mega-client';
const client = new EufyMegaClient({
credentials: {
email: process.env.EUFY_EMAIL!,
password: process.env.EUFY_PASSWORD!,
country: 'NL',
},
sessionStore: new FileSessionStore('/private/eufy/mega-session.json'),
});
try {
const auth = await client.connect();
if (auth.state === 'connected') {
const devices = await client.listDevices();
const station = devices.find((device) => device.kind === 'station');
if (station) await client.connectStation(station.id);
// Select devices by their stable IDs. See the operations below.
}
// Return authentication challenges to your UI. Do not retry them in a loop.
} finally {
await client.shutdown();
}Run on the HomeBase LAN with one controlling bridge per installation. The tested Home Assistant deployment uses host networking and binds its bridge API to loopback. Routed/VLAN and Docker bridge discovery have not passed acceptance.
The library uses Eufy's cloud and local device protocols, with no legacy cloud fallback. It returns device operations and raw media streams. FFmpeg, playback, viewer leases and Home Assistant entities belong in the consuming bridge.
EufyClient adds optional security and mowers modules. Existing
EufyMegaClient imports, constructor options, methods and events remain valid.
Omitting a module or setting it to false leaves its property undefined and
requires no credentials or session store for that purpose. An empty client is
inert. Construction does not authenticate or start network activity.
Each bridge constructs its own library client. For the camera bridge:
import { EufyClient, FileSessionStore } from '@keesmod/eufy-mega-client';
const camera = new EufyClient({
security: {
credentials: cameraCredentials,
sessionStore: new FileSessionStore('/private/camera/session.json'),
},
});
await camera.security!.connect();
const devices = await camera.security!.listDevices();
await camera.shutdown();The security module retains the complete camera API below, including typed
events. Both modules expose authState, connected, lifecycle,
connect(answer?, signal?), shutdown() and close(). lifecycle moves from
open to closing to closed. Authentication state describes the individual
module, with no shared authenticated flag. connected accounts for session
expiry reported by the owner. Auth-state results are copied and exclude extra
adapter fields.
Call authentication explicitly on the desired module. A failure in one module
does not authenticate, close or invalidate the other. Closing one module is
terminal for that module. To reopen it, create a new client with its own store.
EufyClient.shutdown() closes every configured module, awaits their cleanup and
reports shutdown_incomplete if any cleanup fails. Repeated shutdown calls reuse
the same result. There is no automatic retry of authentication or physical work.
A mower-only client can be constructed without camera credentials. Version 0.3.0
uses the independent Home/Tuya adapter by default. Existing custom lifecycle
adapters remain supported. A custom adapter without discovery reports
mower_protocol_unavailable when discovery is requested.
const mower = new EufyClient({
mowers: {
credentials: mowerCredentials,
sessionStore: mowerSessionStore,
adapter: createMowerAdapter,
},
});
await mower.mowers!.connect();
await mower.shutdown();createMowerAdapter is an implementation of the exported MowerAdapter contract,
not a bundled protocol factory. The module calls the factory lazily, once after
its first login request, with only its credentials and MowerSessionStore.
Return a fresh adapter for each client. Adapters expose library-owned AuthState
results and a current connected getter. They must bind restored sessions to the
account, report expiry, honor the supplied AbortSignal, and cancel activity and
flush persistence in shutdown(). Adapter failures expose known error codes or
mower_authentication_failed, without upstream messages, causes or objects.
A MowerSessionStore implements load() and save(session) for
MowerSession, an opaque { version: 1, data: string } secret. Its data encoding
belongs to the adapter. It is deliberately separate from the existing security
Session and FileSessionStore formats. Protect the whole value with private
storage permissions. Session values are persistence inputs only, never device
identifiers, API results or diagnostics.
Use distinct credentials, stores and backing files for camera and mower
installations. A combined library client rejects the same store object assigned
to both modules with shared_session_store. Custom stores and different store
objects can still target the same file, so the caller must keep backing storage
separate. There is no shared service, HTTP server or dependency on the other
bridge. Neither module shuts down the other installation.
The default Home/Tuya authentication and discovery profile is described in Mower authentication. The EU E15 binding has a live validation receipt. Telemetry, commands, settings and maps remain in their E3/E4 stories. The adapter contract adds no physical control methods and makes no E15 hardware claims.
Construct EufyMegaClient with credentials and a SessionStore. A custom store
implements load() and save(session). Treat the entire session as secret.
FileSessionStore writes atomically with private directory/file permissions.
Its session format is independent of legacy eufy-security-client sessions.
connect(answer?, signal?) returns an AuthState:
| State | Caller action |
|---|---|
connected |
Discover devices and use operations. |
verification_required |
Ask the user for the code; call connect({verifyCode}). |
captcha_required |
Present image to the user; submit connect({captchaId, answer}). |
locked |
Pause login attempts. Resolve the lock through Eufy before explicitly starting a new session. |
disconnected |
No authenticated session is available. |
Restart with the same store to reuse a valid session. connected becomes false
before the stored token expires; call connect() to authenticate again. No
device command is automatically replayed after authentication or transport loss.
Account changes do not restore another account's session. Concurrent login
attempts fail with authentication_busy.
Cloud requests have a 15-second deadline, including queue time, and three-second
spacing by default. requestTimeoutMs and minRequestIntervalMs configure these.
CAPTCHA, verification and lockout states pause automatic login attempts.
listDevices(signal?)returns supported stations and cameras with stableid,stationId, model, name, firmware, hardware and optional battery value. Unknown hardware is excluded; a supported camera with an unsupported parent causes an error. Inventory at the server's 100-device boundary is unconfirmed.connectStation(id, signal?)establishes the explicit LAN connection.connectStation(id, { signal?, onProgress? })also reports its stages.getStationState(id)returns the last observed state. Mode values can benullbefore device telemetry arrives.refreshStationState(id, signal?)requests a fresh device observation.snapshot(cameraId, signal?)returns the latest HomeBase cover JPEG with its receipt time. It does not wake a camera for a new live capture.
The library looks for the station on the LAN before it opens a session. The
first lookup goes to the private LAN address the inventory had when the
station's session was created. Without one, it goes to the private address the
station last completed a session handshake from, if any. When the first lookup
stays silent for a second, every retry also goes to the directed broadcast
address of each external IPv4 interface, at most 16 and skipping /31 and /32
interfaces. Without any address the first lookup already goes to those
broadcasts. Without any address and without a broadcast address, the earlier
guess applies, the /24 broadcast of the first external IPv4 address if there is
one. Without an interface list at all, only a known address is asked. Only the
station's answer with its own DID ends the lookup. A connection attempt fails
with device_request_timeout after 20 seconds, or earlier with
device_disconnected when it joins a reconnect the library started itself and
that reconnect gives up first.
connectStation(id, { onProgress }) reports each stage of that attempt once,
in the order observed, with elapsedMs since the attempt started:
| Stage | Meaning |
|---|---|
lookup |
The attempt started and its local lookup is about to begin. inventoryAddress says whether the station's session was created with a LAN address from the inventory |
station_found |
The station answered the lookup with its own DID |
session_open |
The station answered the session handshake |
encryption_ready |
The command key is in place and the connection resolves |
An already connected station resolves without stages. The callback runs
synchronously until the attempt resolves, fails or is cancelled. Errors it
throws are ignored, and it cannot change or retry the connection. A stage
carries no identifiers or addresses. A failure after lookup alone means no
station answered, after station_found the station did not complete the
handshake, and after session_open the command key was not established.
startLive(cameraId, signal?) returns a LiveStream: metadata, separate raw
video/audio readable streams, ended and stop(). Metadata identifies H.264
or H.265 and supported AAC variants. Consume both streams, or call resume() on
an unused audio stream. The library does not transcode or package browser video.
One station carries one live stream by default. maxLiveStreamsPerStation
(1 to 4, default 1) lets that many cameras on the same station stream at the
same time. With the default of 1 the stream uses the station's primary session,
which also carries control, snapshots and recordings, and while it runs
recording transfers and mode commands fail with station_busy. Above 1, every
live stream gets its own P2P session, keyed by station and channel, opened by
startLive and released when that stream ends, and the primary session stays
free: mode commands, state refreshes and snapshots work while cameras stream.
Recording transfers still fail with station_busy while any live stream runs.
The same camera cannot stream twice. A start beyond the limit, during a
recording transfer or during a station command fails with station_busy. A
lost session ends only the streams it carried, and station state reports the
primary session.
Each stream has its own upper bound, 120 seconds by default.
startLive(cameraId, { signal?, maxDurationMs? }) may set a bound between one
second and the client's liveUpperBoundMs ceiling (120000 to 3600000 ms,
default 120000), so a longer stream needs both a raised ceiling and an explicit
per-start value. Values outside that range fail with invalid_live_bound. At
the bound the library sends STOP and the stream ends with the device's
acknowledgement like any other stop. The caller's abort signal also stops an
established stream.
startLive(cameraId, { onProgress }) reports each stage of that start once, in
the order observed, with elapsedMs since the call:
| Stage | Meaning |
|---|---|
session_ready |
The P2P session that carries the stream is connected and encrypted |
start_issued |
START was handed to that session |
start_result |
The station answered START, with its numeric returnCode, 0 for success |
no_data_end |
The P2P library ended the stream because no media followed: 5 seconds after the station's answer, or 20 seconds after START without one |
metadata |
The stream's metadata arrived and the start resolves |
The callback runs synchronously from the call until the start resolves, fails
or is cancelled, also while an issued start awaits its STOP. Errors it throws
are ignored, and it cannot change, end or retry the start. A stage carries no
identifiers. elapsedMs is bounded to one hour and returnCode to a 32-bit
integer, and a code outside that range is omitted. Only the first answer to
START counts. A start that ends after start_issued got no answer from the
station, a non-zero returnCode is a refusal, and start_result 0 followed
by no_data_end or by the caller's cancellation is a start the station
accepted without sending media.
Two concurrent streams are verified on one HomeBase 3 with two eufyCam 3 cameras, see the research note. Three or four streams are permitted by the option but unverified on hardware. A ten-minute stream and control on an idle primary session are verified on the same bench, see the live bound note.
Always await stop() or ended. confirmed: true requires the device's STOP
acknowledgement; a local EOF, socket close or timeout does not establish success.
Cancelling an issued start retains its cleanup owner while awaiting STOP, even
if no stream handle was returned. Subscribe to live-stop for that result.
stopLive(cameraId) stops an owned stream on whichever session carries it.
ensureLiveStopped(cameraId, signal?) does the same for a camera's own stream.
For an uncertain previous session it sends an explicit STOP, checks its
acknowledgement and renews the primary session, which needs an idle station: it
rejects with station_busy while any camera streams or another operation owns
the station. An old stream handle cannot stop a newer stream.
recordingCalendar(stationId, 'YYYY-MM', signal?)returns presence dates for the HomeBase. Calendar markers are not camera-specific counts.listRecordings(stationId, 'YYYY-MM-DD', cameraIds?, signal?)returns{recordings, returned, complete: true}only after completeness checks. Dates use the process's local timezone; configure it to match the installation. Camera filters do not change the underlying full-day completeness query.recordingThumbnail(recordingId, signal?)returns the referenced JPEG.downloadRecording(recordingId, signal?)returns aRecordingDownloadwith metadata, raw video/audio streams,completedandcancel().
Recording IDs are opaque, bound to this client and expire after 15 minutes. Refresh the list when they expire. Device paths and cipher identifiers are never public API inputs. Queries widen beyond 100 rows through 500, 2,000 and 10,000; fixed caps, duplicate IDs, changed prefixes and exact safety boundaries fail with unconfirmed history. An empty or partial result is not substituted.
Consume both media streams and inspect completed.complete. Completion requires
the HomeBase's finish message and drained source streams. Transfers are bounded
to 32 MiB and 40 seconds, with a further five seconds for cancel acknowledgement.
Failure results include a reason and stopConfirmed. Keep a failed transfer out
of a playback cache. cancel() is idempotent and does not retry the download.
Call startEvents(signal?) after discovery. Events use Google FCM and Mega push
registration, plus device messages. Reconnect restores the push registration and
stored delivery IDs. eventStatus reports connectivity and delivery counters.
Typed events are auth, device, station, snapshot, event,
events-connection, live-stop and fault. Detection events preserve supported
motion/person/ring semantics and recognized names when supplied by the device.
Deduplication correlates device/push copies and persists recent delivery hashes;
it does not manufacture missing names or detections.
setGuardMode(stationId, mode, signal?) accepts 0 (Away), 1 (Home), 2 (Schedule),
3–5 (Custom), 47 (Geofencing) or 63 (Disarmed). Its result requires a matching
command acknowledgement and fresh device-reported Guard Mode. commandSent
is false if a fresh read already matches. Rejection or timeout never causes an
automatic retry. Read the device again before deciding what to do next.
Catch EufyError and use its stable code; remoteCode is an optional numeric
device/service result. Do not log session contents or upstream objects.
Await shutdown() (alias close()) to stop owned media, close device and event
transports and flush sessions. Shutdown is idempotent. A closed client cannot
be reopened; construct another client with the same session store.
EufyClient({ mowers: { credentials, sessionStore } }) now uses the independent
Home/Tuya adapter. home.requestTimeoutMs bounds each request, default 15000 ms.
home.fetch injects a trusted private HTTP transport for synthetic tests. Existing
custom adapter factories retain precedence. Omitting the mower module makes
no Home/Tuya request.
Call await client.mowers.connect() explicitly, then
await client.mowers.discover(signal). Results are copied MowerDevice values:
{ id, kind: 'mower', model: 'E15', productCode: 'T2880' }. The ID is opaque and
account-scoped. It is stable only while the same private identity salt is kept.
No local key, real device identifier, user name, token or SDK object is returned.
Discovery does not log in, refresh or replay a failed request. On
authentication_required, the caller decides when to call connect() again.
Unknown regions or unapproved regional hosts report mower_region_unsupported.
A mismatched cloud/device binding reports mower_binding_unavailable.
Concurrent discovery reports mower_discovery_busy. Neither operation issues
physical commands. The EU E15 authentication, discovery and private key binding are independently
validated. This does not establish command, telemetry or map support. Other
regions have synthetic coverage only.
PortableMapAcquisition owns one bounded read-only acquisition session for one
verified mower binding. It accepts private MapSessionProvisioning, exposes
copied lastComplete transport files and supports acquire, disconnect,
shutdown and clearLastComplete. This independent adapter uses Node built-ins
and preserves the existing camera and mower module interfaces. See the
full contract, bounds and feature evidence.
decodeMowerMapSnapshot turns one lastComplete snapshot into read-only
geometry: map with identity, grid, bounds, station pose, region boundaries,
obstacles, forbidden zones, walls, tunnels, required and pass-through zones,
cleaningPath with point kinds and end pose, and the display-only
navigationPose. decodeMowerMapFile, decodeMowerPathFile and
decodeMowerPoseFile decode one file each. Every file decodes independently,
none of the functions throws on malformed input, faults name the reason, byte
offset and message path, unknown enumeration values keep their code and
undecoded field numbers are listed. Integers stay integers and nothing is
selectable or editable. See the
field semantics, confirmation levels and provenance.
MowerPathAccumulator merges the decoded cleaning paths of successive
acquisition snapshots into one ordered MowerPathHistory per map identity and
generation. merge takes the MowerMapGeometry of each snapshot in receipt
order and reports started, extended, unchanged, empty or rejected
with a reason. Every segment records the acquisition revision and receivedAt
it came from, the end pose and the path kind follow the latest history or
complete path, point kinds stay unchanged, a realtime path never replaces
established points, and empty, malformed, duplicate and out-of-order inputs
leave the history untouched. A changed identity or generation and a diverging
history path start a new history and return the previous one. Histories are
frozen plain data, history returns the current one and clear drops it.
See the merge rules and confirmation levels.
client.mowers.openLocalSession(id, { host, port?, timeoutMs? }, signal?) opens one
authenticated Tuya LAN protocol 3.5 session to one discovered E15 and returns a
MowerLocalSession. id is the opaque ID from discover(). The private local key
is borrowed from the verified cloud binding only while the session key is
negotiated and is erased afterwards. Consumers never receive or store the local
key. Discovery must have succeeded on the same connected module, otherwise the
call reports authentication_required or mower_binding_unavailable. A custom
adapter without the private binding capability reports mower_protocol_unavailable.
const [mower] = await client.mowers.discover();
const session = await client.mowers.openLocalSession(mower.id, { host: '192.0.2.10' });
try {
const snapshot = await session.queryStatus();
// snapshot.dps holds raw data points exactly as reported. Do not log them.
} finally {
await session.disconnect();
}The session is read-only. queryStatus(signal?) sends one status query and
returns a copied MowerDpSnapshot with source: 'local-tuya-3.5', observedAt
as the local ISO 8601 receipt time and dps, the raw data points as the device
reported them. No data point is interpreted, written or refreshed, and this API
has no command or setting. queryTelemetry() decodes that same query response. Snapshot
values can contain private data, so consumers must not log them.
connected reports whether the negotiated socket is still open. closed
resolves with a MowerLocalSessionEnd once the socket has closed and all owned
resources are released. disconnect() is idempotent and resolves after actual
closure. Module shutdown() closes every open session with shutdown and awaits
that closure. An open session no longer depends on the cloud session, so it
survives cloud expiry and a later discover(). Opening a new session requires a
connected module with current discovery bindings.
One session runs one operation at a time, without retry, reconnect or automatic
polling. timeoutMs bounds connecting plus key negotiation, and separately each
query, default 5000 ms and at most 60000 ms. The device closes a connection that
stays silent for about 30 seconds, so poll one open session at a shorter interval
or open a session per poll. Errors are EufyError codes: mower_invalid_options,
mower_local_key_invalid, mower_local_unreachable, mower_local_authentication_failed,
mower_local_protocol_error, mower_local_rejected, mower_local_binding_mismatch,
mower_local_disconnected and mower_local_busy, plus the existing
request_timeout, request_aborted and client_closed. A wrong local key usually
appears as request_timeout or mower_local_disconnected while opening, because
the device cannot authenticate the first frame. After any failure other than a
device rejection the session is closed and must be opened again.
The protocol facts, their public sources and the independent reproduction are in Mower transport provenance. The 2026-09-16 E15 receipt adds live query and cleanup evidence on firmware 6.9.28 to the synthetic tests.
session.receiveReport(signal?) waits for one authenticated command-8 device
report. It returns MowerDpReport, a snapshot with kind: 'device-report' and
the frame's sequence. It never substitutes a query response, cloud cache or
previously merged data points. Report counters may be zero or independent of
request counters, so they are not request acknowledgements.
const session = await client.mowers.openLocalSession(mower.id, {
host: '192.0.2.10',
timeoutMs: 20_000,
});
try {
const report = await session.receiveReport(signal);
// This is full-frame arrival time, even if the report waited before consumption.
const ageMs = Date.now() - Date.parse(report.observedAt);
if (ageMs >= 0 && ageMs < 5_000) {
const telemetry = decodeMowerTelemetry(report, { schema: session.schema });
// Only this report's data points are decoded. Missing values remain missing.
}
} finally {
await session.disconnect();
}The session timeout bounds each read. Cancellation, timeout and client shutdown close the connection. A command-9 transport heartbeat is sent every 10 seconds only while a report read is pending. It changes no data point and does not request a refresh. There is no reconnect, report replay or background poll. One read or query owns the session at a time.
Incoming complete frames retain their arrival time in a queue bounded to 32
frames and 128 KiB including partial input. A slow consumer that exceeds either
bound loses the session with a protocol error instead of silently losing data.
observedAt proves receipt, not when the device measured a field. A report can
be partial. Do not mark an older absent field fresh by merging it into a newer
report. A status query can discard intervening reports while waiting for its
reply, so use successive receiveReport() calls when observing transitions.
The permitted protocol sources and owned-device observations are recorded in the report receipt.
session.queryTelemetry(signal?) runs one status query and returns a
MowerTelemetry: source, observedAt, the four typed fields status,
battery, progress and network, fields with every reported data point
typed by the device's own declared schema, and the raw dps copy.
decodeMowerTelemetry(snapshot, { schema?, definitions? }) is the same pure
decoder for a snapshot you already hold. session.schema is a copy of the
declared data points from discovery, or undefined when the cloud supplied none.
const telemetry = await session.queryTelemetry();
if (telemetry.battery.state === 'reported') console.log(telemetry.battery.value.percent);
// telemetry.fields['8'] carries the E15 battery's declared code, type, unit and validity.A typed field is reported only from a confirmed definition and a value that
conforms to both the definition and the device declaration. Otherwise it is
missing, invalid or unconfirmed. Nothing is inferred from age or absence.
The E15 defaults report battery percentage, the observed Wifi network kind
and network.value.signalPercent from independently observed definitions.
signalPercent is a 0 to 100 percentage and is not converted to dBm. Status
reports mowing, paused or returning from the confirmed DP 107 payloads,
and since 0.22.0 from the mission status, which adds the app's other mowing
missions and idle for a message without a mission,
mowing progress remains unconfirmed, and unobserved network enum values are
rejected. Consumers with their own confirmed evidence pass definitions,
or [] to disable the defaults. See
typed mower telemetry for the definition format, levels,
schema provenance and remaining acceptance.
parseMowerWirePayload(value) decodes one raw data-point value from base64 into
wire records without assigning meaning. It returns shape: 'fields' with
number, wire and value per record, shape: 'default' for the observed
empty or single-zero-byte payload, or shape: 'malformed' with a reason. It
is bounded to 256 bytes and 32 records and never throws. The decoder attaches
the same result as fields[dp].wire for data points named by a wire
definition, today DP 107 robot_status on the E15.
const telemetry = decodeMowerTelemetry(report, { schema: session.schema });
const wire = telemetry.fields['107']?.wire;
if (wire?.shape === 'fields') {
// Structural values only, for example [{ number: 1, wire: 'varint', value: 2 }, ...].
// telemetry.status reports 'mowing' for this payload from the confirmed E15 registry.
}The shipped DP 107 definitions are confirmed for mowing, paused and
returning. A wire definition reports its activity only when every listed
field matches, so the transitional first frame after a control, the map-saving
payload, field 6 and the default payload leave status invalid for that
report. See the
DP 107 contract receipt
and the
reproduction receipt.
Physical control is off unless the client is constructed with
mowers.commands. The opt-in must be explicit and must name the consumer's
own stop route. An invalid opt-in fails construction with
mower_invalid_options. Without it session.sendCommand() reports
mower_commands_disabled before any frame is written.
const client = new EufyClient({
mowers: {
credentials,
sessionStore,
commands: {
enabled: true,
stopRoute: 'pause then return through this session, official app at hand',
readBackMs: 10_000,
},
},
});
const session = await client.mowers.openLocalSession(mower.id, { host: '192.0.2.10' });
const outcome = await session.sendCommand({ kind: 'pause' });
if (outcome.end === 'reflected') {
// A fresh DP 107 report decoded to `paused`. Nothing else counts as success.
}sendCommand({ kind, readBackMs? }) accepts start, pause, resume,
stop and return. It runs one fresh status query, returned as before,
decides the typed refusals on it, writes one declared boolean point, then
reads fresh reports back within the bound, default 10 seconds and at most 60.
The result carries write, sentAt, the device's frame reply when one
arrived, the first acknowledgement by a control-point or echo report, the
first matching activity from the confirmed DP 107 definitions or, for
stop, the first payload whose DP 107 records are exactly the map-saving
payload, every report received in reports, and stage with end. stage is sent, acknowledged or
reflected. end is reflected, rejected, timed_out or report_limit.
A timeout resolves rather than throws because the write already happened, and
the library never resends it. There is no completed and dock arrival is
never inferred.
An optional onProgress callback receives fresh observations while the
read-back runs: { kind: 'acknowledged', observedAt, sequence, dp } once, as
in the outcome, and { kind: 'activity', observedAt, sequence, value } for
every fresh DP 107 report that decodes to a confirmed activity. After a stop
on the owned E15 that is returning within a second, about 30 seconds before
the map-saving reflection ends the read-back. The callback is called
synchronously with frozen copies. Errors it throws are ignored. It cannot
change, end, retry or replay the command, and the outcome stays the only
result. A non-function onProgress is refused with mower_command_invalid.
Refusals before any write are mower_command_invalid,
mower_command_undeclared, mower_command_evidence_missing,
mower_command_map_saving, mower_command_task_active and
mower_command_already_set. return is written only from the stopped task,
DP 1 switch_go false with DP 118 at 100, the state in which the official app
offers Charge. On the owned E15 stop over DP 1 false ends the task and the
mower returns to the dock by itself, and return over DP 3 was ignored from
paused and from the stopped task, see the hardware acceptance in
Opt-in mower commands. One
command owns the session, so a concurrent read reports mower_local_busy. Peer loss during
the read-back reports mower_local_disconnected and closes the session, which
the consumer must reopen deliberately. client.mowers.commandsEnabled and
session.commandsEnabled report the opt-in. The written points, their public
sources, the frame format and the remaining hardware acceptance are in
Opt-in mower commands.
Setting writes are off unless the client is constructed with
mowers.settings, an opt-in separate from commands. An invalid opt-in fails
construction with mower_invalid_options. Without it session.setSetting()
reports mower_settings_disabled before any frame is written. Reading needs
no opt-in.
const client = new EufyClient({
mowers: { credentials, sessionStore, settings: { enabled: true, readBackMs: 10_000 } },
});
const session = await client.mowers.openLocalSession(mower.id, { host: '192.0.2.10' });
const { settings } = await session.querySettings();
if (settings.mowHeight.state === 'reported' && settings.mowHeight.writable) {
const outcome = await session.setSetting({ name: 'mowHeight', value: 45 });
if (outcome.end === 'reflected') {
// A fresh report carried DP 110 at 45. A restore is a second, deliberate call:
await session.setSetting({ name: 'mowHeight', value: outcome.previous });
}
}querySettings() runs one status query and decodeMowerSettings(snapshot, { schema }) decodes an existing snapshot without I/O. Each of mowHeight,
volume, smartNoGoZones, sparseLawnOptimization, rainAutoReturn,
childLock and birdViewCapture is reported with dp, type, value and
writable, plus min, max, step and unit for the two value settings,
or missing or invalid. writable requires the library's table and the
session schema, not the opt-in.
setSetting({ name, value, readBackMs? }) writes only mowHeight (25 to 75
mm), volume (0 to 100 %), smartNoGoZones and sparseLawnOptimization. It
runs one fresh status query, returned as before with the current value as
previous, decides the typed refusals on it, writes one declared point in the
control frame of the commands, then reads fresh reports back within the
bound, default 10 seconds and at most 60. stage is sent or reflected.
end is reflected, rejected, timed_out or report_limit. The first
fresh report with the written value is reflection, the latest fresh report
with another value is other. A timeout resolves because the write already
happened, and the library never resends or restores it.
Refusals before any write are mower_setting_invalid,
mower_setting_read_only, mower_setting_undeclared,
mower_setting_evidence_missing, mower_setting_map_saving and
mower_setting_already_set. Rain and child protection, rainAutoReturn and
childLock, are read only in either direction, as is birdViewCapture. One
write owns the session like a command. client.mowers.settingsEnabled and
session.settingsEnabled report the opt-in. The data points, their permitted
sources and the pending hardware acceptance are in
Opt-in mower settings.
Upgrade note: MowerLocalSession gains settingsEnabled, querySettings()
and setSetting(), and MowerModule gains settingsEnabled. A consumer that
implements these interfaces itself, for example in a test double, adds them.
client.mowers.queryCloudState(id, signal?) reads DP 107 activity, the DP 108
charger contact and DP 155 work parameters from one discovered E15's
authenticated cloud device record. It makes one tuya.m.device.get request and
returns MowerCloudStateReading:
const reading = await client.mowers.queryCloudState(mower.id);
// source is 'cloud'. observedAt is receipt time, not device time.
if (reading.status.state === 'reported') {
const activity = reading.status.value;
}
if (reading.charger.state === 'reported') {
const atStation = reading.charger.connected;
}
if (reading.workParameters.state === 'reported') {
const parameters = reading.workParameters.parameters;
}status is { state: 'reported', value: MowerActivity }, { state: 'missing' }
or { state: 'invalid' }. It shares the confirmed DP 107 decoder with local
telemetry. No activity is inferred from a task flag, battery value or absent
point. idle does not prove that the mower is docked.
charger is { state: 'reported', connected: boolean }, { state: 'missing' }
or { state: 'invalid' }, since 0.27.0. It reads field 2 of DP 108, the
battery status message, which the official app decodes as its charger
connection. connected is true while the mower stands on its charging
station's contacts and false once it has left them. On the owned E15 it held
through a 29-minute rest in hibernation and through 20-minute stops on the
lawn, so it is the confirmed reading that separates a rest at the station from
a stop on the lawn. It does not replace status:
while the mower defogs in the station after a start, status is already
mowing and charger still connected, and on arrival charger connects
before status leaves returning. See the
charger contact receipt.
workParameters retains the MowerWorkParametersReading contract below and has
the same observedAt. Each field keeps its own missing or invalid result.
All three fields are cloud cache values. The library knows when the response arrived, not when the device last changed any value. Consumers own refresh policy and expiry. Keep this source separate from local telemetry and never use it to confirm a command or setting write. The reading exposes no raw data points, device identifiers or credentials. No control opt-in is needed.
The module must be connected and the mower must have a current discovery binding.
The read uses the existing mower session, never logs in, retries, polls or writes.
Cancellation, shutdown, binding revocation and session expiry prevent a late
result. Authentication, binding, request and lifecycle errors match
queryWorkParameters() below. Custom adapters without the internal read
capability report mower_protocol_unavailable. Existing queryWorkParameters()
callers retain their return contract and still make one request per call.
client.mowers.queryWorkParameters(id, signal?) reads DP 155, the work
parameters of one discovered E15, from the Tuya cloud's device record through
the request discovery already makes. The E15's local status replies do not
carry DP 155. The result is a MowerWorkParametersReading with
source: 'cloud' and observedAt, the library's receipt time of the cloud
response. The cloud value is a cache, so it can be older than that time.
const [mower] = await client.mowers.discover();
const reading = await client.mowers.queryWorkParameters(mower.id);
if (reading.state === 'reported') {
const { mowSpeed, bladeSpeed, direction } = reading.parameters;
// Each field exists only when the value carried it. Nothing was written.
}state is reported with parameters and undecodedFields, missing when
the record carries no DP 155, or invalid when its value does not decode. The
parameters are mowHeight, mowSpeed, edgeDistance, direction,
mowSpacing, bladeSpeed and currentMowSpacing, as the device's integers
and the app's enumeration names, with { unknown: number } for an unnamed
enumeration value. decodeMowerWorkParameters(value) is the same pure decoder
for a base64 value you already hold. It returns shape: 'decoded' or
shape: 'malformed' with a reason, is bounded to 256 bytes, 64 records and
three nested messages, and never throws. Only DP 155 leaves the adapter. No
identifier, key or other data point of the record is returned.
Discovery must have succeeded on the same connected module, otherwise the
call reports authentication_required or mower_binding_unavailable. A
custom adapter without the capability reports mower_protocol_unavailable.
Cloud errors are mower_request_failed, mower_invalid_response,
request_timeout, request_aborted and client_closed. Each call makes one
request, without retry or caching. The numbering, its source and the decoding
rules are in Mower work parameters.
Behind the settings opt-in, session.setWorkParameter({ name, value, readBackMs? }) writes the mow speed or the blade speed as one partial DP 155
message on an open local session:
const outcome = await session.setWorkParameter({ name: 'bladeSpeed', value: 'high' });
if (outcome.end === 'reflected') {
// A fresh report carried DP 155 with the blade speed at high. A restore is a second call:
await session.setWorkParameter({ name: 'bladeSpeed', value: outcome.previous });
}mowSpeed takes low, medium or adaptive_high, and bladeSpeed takes
low, medium or high, as the exported frozen WRITABLE_WORK_PARAMETERS
table lists them. Edge distance, mow spacing, the direction, the mow
height and the current mow spacing are refused as read only. One cloud
reading, like queryWorkParameters(), supplies previous, because the LAN
status query does not carry DP 155, and one fresh status query decides the
map-save refusal. The write sends only the parameter's field, then reads fresh
reports back like setSetting(). reflection carries every parameter the
reflecting report decoded to. The outcome also carries cloud, the reading
the write was decided on, and write.encoded, the base64 message sent.
Refusals before any write are the settings refusals: mower_settings_disabled,
mower_setting_invalid, mower_setting_read_only,
mower_setting_undeclared, mower_setting_evidence_missing, which includes a
failed or late cloud reading, mower_setting_map_saving and
mower_setting_already_set. They leave the session open. The written values
and their sources are in Mower work parameters.
Upgrade note: MowerModule gains queryWorkParameters() and
MowerLocalSession gains setWorkParameter(). A consumer that implements
these interfaces itself, for example in a test double, adds them. The
settings opt-in now also allows the two work parameter writes, and only
through this new call.
discoverDevices(signal?) returns typed DiscoveryResult data with devices,
relationships and per-device issues. listDevices(signal?) retains Device[]
and stable identifier fields. Recognized unsupported identities remain visible.
See the discovery contract for operation gates and software evidence.
getDeviceState(id) returns the latest available Device state without opening
a P2P connection. Device.availability is an additive optional field, returned
as online, offline, disabled or null from the model's reported status.
listDevices() and discoverDevices() also include validated cloud battery and
availability observations. See the eufyCam state contract for missing
values, unsupported properties, source priority and unchanged identity fields.
The SoloCam evidence guide lists ten exact model/type pairs with
H3 discovery, observed state and motion/person routing. Public identifiers and
API signatures are unchanged. Standalone descriptors identify their own owner
but retain standalone_transport_unverified. Existing T8134 media admission is
preserved. Version 0.12.0 enables the nine additional models on the existing
H3 media profile, with unchanged API methods.
Unsupported owner/firmware combinations retain camera_media_unverified.
LiveStream.metadata returns the current observed metadata. Video-only startup
remains bounded to the existing audio discovery deadline. A later first audio
packet updates audioCodec before its bytes reach LiveStream.audio, without
a second start event or camera command. Read the handle metadata again when
consuming first audio data. A cached metadata object remains a snapshot.
This does not renegotiate consumer transports. A consumer that started a video-only mux or SDP session must handle late track admission separately. No actual audio packet is inferred from expiration of the startup timer.