Skip to content

caseman72/mqtt-eufy-lock

Repository files navigation

mqtt-eufy-lock

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-client package 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.

Status

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.

Prerequisites

  • 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.

Quick start

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.js

If the daemon prints ready with no errors, you're done — open Home Assistant and the lock entity should appear automatically.

Environment variables

Copy .env.local.template.env.local and fill in. Values are loaded literally — do not quote them. A literal $ in EUFY_PASSWORD is fine.

Required

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

Optional (defaults shown)

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.

Bootstrap — warm the session cache before first deploy

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.

Running in production

The daemon is a long-running process — run it under whatever supervisor you prefer. Two common patterns:

Docker

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>

systemd / pm2 / direct

node src/index.js

The daemon handles SIGTERM / SIGINT gracefully (publishes offline, closes sockets, exits 0).

Home Assistant integration

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 entity
  • homeassistant/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 edit src/mqtt.js (publishHADiscovery). PRs welcome.

Disabling discovery

# 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.

Retracting a stale discovery payload

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.

Testing manually

# 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.

Troubleshooting

Captcha required

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:

  1. Delete the stale session cache + marker:
    rm data/persistent.json data/CAPTCHA_*
  2. Re-run the daemon locally (node src/index.js) and solve the captcha if prompted. The fresh persistent.json is now warm.
  3. Copy the new persistent.json to your deploy host (if separate from your dev machine).
  4. Restart the daemon.

Session expiry (~21 days)

Same recipe as captcha — persistent.json rotates roughly every 3 weeks. Symptom: ApiGenericError or auth failure in the logs on a previously-working deploy.

TRUSTED_DEVICE_NAME and the captcha trap

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.

Daemon won't start

Check the logs for one of these:

  • Missing required env vars → check .env.local has 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.

Architecture

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):

  1. Load + validate env.
  2. Refuse to start if CAPTCHA_REQUIRED marker exists.
  3. Eufy cloud connect → wait for connect event → wait for security-MQTT.
  4. MQTT connect (LWT registered as offline retained on availability topic).
  5. Publish online, current lock state, HA discovery payloads, initial battery.
  6. Subscribe to set. Subscribe to SDK device locked events.
  7. Start reconcile loop (poll every 60 s, heartbeat republish every 15th tick).
  8. SIGTERM / SIGINT → publish offline, close clients, exit 0.

Topic reference

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.

License

MIT. The vendored fork at lib/eufy-security-client/ is also MIT (see its own LICENSE).

Credits

About

Eufy Smart Lock C33 → MQTT bridge for Home Assistant (auto-discovery, battery, LWT)

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors