A small Node.js daemon that bridges a Eufy Smart Lock C33 to Home
Assistant over MQTT. Subscribes to a set topic for LOCK / UNLOCK commands,
publishes lock state + battery + availability, and emits HA MQTT Discovery
config payloads so the lock and battery appear as native HA entities with zero
YAML editing.
Why this exists, in one line: the npm
eufy-security-clientpackage routes lock commands via P2P, which does not work on the C33. This daemon uses a vendored fork (tale/eufy-security-client, 3.7.2-based) that routes via the cloud security-MQTT broker, which does work.
Works on a Eufy Smart Lock C33 (T85L0… serials). Other Eufy locks that use
the same security-MQTT command path might work — untested. Locks that
require P2P (older models) will not.
- Node.js 20+ (or Docker)
- An MQTT broker reachable from the daemon. Any broker works; Mosquitto is the default assumption.
- Home Assistant with the MQTT integration configured against the same broker.
- A Eufy cloud account already paired with the lock.
git clone <this-repo>
cd mqtt-eufy-lock
npm install
cp .env.local.template .env.local
# edit .env.local with your Eufy + MQTT credentials
node src/index.jsIf the daemon prints ready with no errors, you're done — open Home Assistant
and the lock entity should appear automatically.
Copy .env.local.template → .env.local and fill in. Values are loaded
literally — do not quote them. A literal $ in EUFY_PASSWORD is fine.
| Var | Purpose |
|---|---|
EUFY_USERNAME |
Eufy cloud account email |
EUFY_PASSWORD |
Eufy cloud account password |
EUFY_COUNTRY |
Two-letter country code (e.g. US) |
LOCK_SERIAL |
C33 serial (starts with T85L0…) |
MQTT_HOST |
MQTT broker hostname or IP |
MQTT_PORT |
MQTT broker port (usually 1883) |
MQTT_USER |
MQTT broker username |
MQTT_PASSWORD |
MQTT broker password |
| Var | Default | Purpose |
|---|---|---|
PERSISTENT_DIR |
./data |
Where persistent.json (Eufy session cache) + CAPTCHA_* markers live. In a container, mount a volume here. |
TRUSTED_DEVICE_NAME |
(unset — lib default) | Do not set unless you understand the captcha trap (see Troubleshooting). |
MQTT_TOPIC |
eufy/lock/back-door |
Base topic prefix for set / state / available / battery |
POLL_INTERVAL_SEC |
60 |
Reconcile loop tick interval |
HEARTBEAT_EVERY_N_POLLS |
15 |
State + battery republish cadence (default = every 15 min) |
HA_DISCOVERY_PREFIX |
homeassistant |
HA MQTT Discovery prefix. Set to empty string to disable. |
On cold authentication the Eufy SDK often triggers a captcha. The daemon has
no captcha solver: if Eufy demands one, it writes data/CAPTCHA_REQUIRED and
exits.
Easiest first run is on your dev machine, where you can solve the captcha
interactively if it appears. Once data/persistent.json is written, future
runs reuse the cached session and the captcha is not re-prompted.
If you plan to deploy the daemon to a headless host (container, server,
RPi, etc.), do the first run on your dev machine so persistent.json is
warm, then copy it to the deploy host alongside the source.
The daemon is a long-running process — run it under whatever supervisor you prefer. Two common patterns:
This repo ships source only; bring your own Dockerfile. A minimal one:
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
COPY lib/eufy-security-client/package*.json ./lib/eufy-security-client/
RUN npm ci --omit=dev
COPY src/ ./src/
COPY lib/eufy-security-client/build/ ./lib/eufy-security-client/build/
CMD ["node", "src/index.js"]Then in compose (or docker run):
services:
mqtt-eufy-lock:
build: ./mqtt-eufy-lock
restart: always
env_file:
# `format: raw` disables Compose's $-interpolation so a literal $
# in EUFY_PASSWORD is preserved.
- path: ./mqtt-eufy-lock/.env.local
format: raw
volumes:
- ./mqtt-eufy-lock/data:/data
# In .env.local, set PERSISTENT_DIR=/data and MQTT_HOST=<your broker>node src/index.jsThe daemon handles SIGTERM / SIGINT gracefully (publishes offline,
closes sockets, exits 0).
With HA_DISCOVERY_PREFIX set (default homeassistant), the daemon publishes
two retained discovery payloads on startup and every MQTT reconnect:
homeassistant/lock/eufy_lock_back_door/config— the lock entityhomeassistant/sensor/eufy_lock_back_door_battery/config— the battery sensor
Both share device.identifiers: ["eufy_lock_back_door"], so HA groups them
under one device named "Eufy Smart Lock C33 (Back Door)". You do not need
to edit configuration.yaml.
The resulting lock entity behaves like any native HA MQTT lock — same automations, voice control, dashboard cards, lovelace UI.
Note: The HA entity name (
Back Door),unique_id, and device name are hardcoded for v1. If you have multiple locks or want a different name, you'll need to editsrc/mqtt.js(publishHADiscovery). PRs welcome.
# In .env.local
HA_DISCOVERY_PREFIX=The daemon logs ha-discovery: disabled and skips both publishes. Existing
retained config payloads on the broker are not removed by setting this —
see next.
If you ever change uniqueId or topic structure and want HA to forget the old
entity, publish an empty retained payload to the old config topic:
mosquitto_pub -h <broker> -u <user> -P <pw> -r -n \
-t 'homeassistant/lock/eufy_lock_back_door/config'HA removes the entity on receiving an empty payload.
# Lock from the broker (no HA involvement):
mosquitto_pub -h <broker> -u <user> -P <pw> \
-t 'eufy/lock/back-door/set' -m 'LOCK'
# Watch all topics:
mosquitto_sub -h <broker> -u <user> -P <pw> -v \
-t 'eufy/lock/back-door/#'Payloads are case-insensitive on the wire (LOCK, lock, Unlock all
accepted). Unknown payloads are logged and ignored.
If Eufy's auth flow flags the session, the daemon writes
data/CAPTCHA_REQUIRED and exits. Subsequent restarts refuse to start until
the marker is removed.
Recovery:
- Delete the stale session cache + marker:
rm data/persistent.json data/CAPTCHA_* - Re-run the daemon locally (
node src/index.js) and solve the captcha if prompted. The freshpersistent.jsonis now warm. - Copy the new
persistent.jsonto your deploy host (if separate from your dev machine). - Restart the daemon.
Same recipe as captcha — persistent.json rotates roughly every 3 weeks.
Symptom: ApiGenericError or auth failure in the logs on a previously-working
deploy.
Setting a novel TRUSTED_DEVICE_NAME on cold auth forces Eufy to register a
new trusted device, which always gates with captcha. Once
persistent.json is cached, the value is ignored. Rule of thumb: leave it
unset unless you know what you're doing.
Check the logs for one of these:
Missing required env vars→ check.env.localhas all 8 required keys.captcha pending→ see Captcha section above.security-MQTT did not connect within 30s→ usually a transient Eufy cloud issue; the daemon exits non-zero and your supervisor should restart it.
src/
index.js — daemon entrypoint; startup sequence, signal handling, reconcile loop
config.js — env validation + redacted-config logging
log.js — JSON line logger
eufy.js — the ONLY module that imports the vendored SDK fork
mqtt.js — broker adapter + topic layout + HA Discovery payloads
captcha.js — captcha event handling (writes marker, exits)
lib/eufy-security-client/
Vendored 3.7.2-based fork from tale/eufy-security-client. Routes lock
commands via security-MQTT instead of P2P. DO NOT replace with the npm
package — the C33 will silently fail.
Startup sequence (each step gated):
- Load + validate env.
- Refuse to start if
CAPTCHA_REQUIREDmarker exists. - Eufy cloud connect → wait for
connectevent → wait for security-MQTT. - MQTT connect (LWT registered as
offlineretained on availability topic). - Publish
online, current lock state, HA discovery payloads, initial battery. - Subscribe to
set. Subscribe to SDKdevice lockedevents. - Start reconcile loop (poll every 60 s, heartbeat republish every 15th tick).
- SIGTERM / SIGINT → publish
offline, close clients, exit 0.
| Topic | Direction | Payload | Retained |
|---|---|---|---|
eufy/lock/back-door/set |
sub | LOCK / UNLOCK (case-insensitive) |
no |
eufy/lock/back-door/state |
pub | LOCKED / UNLOCKED |
yes |
eufy/lock/back-door/available |
pub + LWT | online / offline |
yes |
eufy/lock/back-door/battery |
pub | integer percent 0–100 | yes |
homeassistant/lock/eufy_lock_back_door/config |
pub | JSON discovery payload | yes |
homeassistant/sensor/eufy_lock_back_door_battery/config |
pub | JSON discovery payload | yes |
Base prefix (eufy/lock/back-door) is configurable via MQTT_TOPIC. HA
discovery prefix is configurable via HA_DISCOVERY_PREFIX.
MIT. The vendored fork at lib/eufy-security-client/ is also MIT
(see its own LICENSE).
tale/eufy-security-client— the 3.7.2-based fork whose security-MQTT routing makes this whole thing possible.