MQTT bridge for cloud-locked smart home devices.
MQTT-Brücke für Cloud-gefangene Smart-Home-Geräte.
English · Deutsch
Small gateway service that translates devices locked into a vendor cloud into your own MQTT broker. First target: Levoit air purifiers via the VeSync cloud.
Devices like Levoit speak no native MQTT — they are reachable only through their manufacturer's cloud. This gateway polls that cloud, normalises the values and publishes them to MQTT, retained. Commands travel the same road in the opposite direction.
The status bar shows poller, cloud, broker and connection from every view. Below it one card per device — and only the controls the model actually has: the 200S has a night light, the 300S have particulate readings and an auto mode instead.
- Polls the VeSync cloud at a fixed interval and publishes every device as one retained JSON, plus each reading on its own topic
- Commands over MQTT (
cmnd/<property>) — the web interface uses the very same path, there is no second way in - Only what the model can do: the gateway asks each device for its
capabilities; fields a model lacks are omitted, never sent as
null - Home Assistant discovery, optional and switchable — turning it off removes the entities again instead of leaving ghosts behind
- Topic export for EisBär SCADA V3/V4 (channel CSV + payload profile XML)
- Diagnostics bundle: report, configuration without credentials and the log in one ZIP — account, broker and device names replaced by placeholders
- Interface in English and German, light and dark, remembered per browser
# 1. Create the configuration (configdir/ is gitignored)
mkdir configdir
cp src/config.example.yaml configdir/config.yaml
# -> fill in your VeSync account and broker address
# 2. Build and start
docker compose up -d --build
# 3. Watch the log
docker logs -f cloud2mqtt
# 4. Stop
docker compose downThen open http://your-host:8088 and set the password for the interface.
You can also hand the first password in through the environment variable
CLOUD2MQTT_PASSWORD; it is stored as a hash on first start, after which the
line can disappear from your compose file again. Without a password set, the
API serves no data at all — not even to another program on your network.
| Environment | Path on the host | Mount inside the container |
|---|---|---|
| Local | ./configdir/ |
/config (a directory, :rw) |
| Synology DSM | /volume1/docker/cloud2mqtt/ |
/config (a directory, :rw) |
Why a directory and not the file itself? The interface writes the configuration with
os.replace, which is atomic. On a single-file mount that fails withOSError: Device or resource busy— renaming onto the exact mount point of a single file is structurally blocked, on every filesystem, DSM included. So both environments mount a directory with:rw.
Everything lives under the configured base topic and is published retained:
cloud2mqtt/<group>/<device>/state one complete JSON per device
cloud2mqtt/<group>/<device>/status/<field> every reading on its own topic
cloud2mqtt/<group>/<device>/cmnd/<field> commands go here
cloud2mqtt/status availability (Last Will)
cloud2mqtt/health/cloud/state online / degraded / offline
Commands live in their own subtree on purpose, so the gateway's own status publish cannot come straight back at it as a command.
The full topic list with data types and switch values, split by model, is in
docs/MQTT_REFERENZ.md; the wire format itself is
specified in docs/MQTT_FORMAT.md. What your own
installation currently serves is shown by the topic export in the interface —
generated from your running devices, not from a maintained list.
The topic export saves you the typing — as a general CSV, or for EisBär SCADA as payload XML plus channel CSV. Next to it the diagnostics bundle: report, configuration without credentials, and the log, with placeholders instead of your account, broker address and device names.
The log runs live, filterable by level and searchable — the same lines
that end up in the diagnostics bundle. It is in English whichever language
the interface is set to: it leaves the house in that bundle, and it sits
next to uvicorn and paho in docker logs.
The update check is the only connection to anyone besides VeSync and your own broker — so what it fetches is spelled out next to it, and next to that the switch.
This gateway holds your VeSync credentials, so it is worth saying plainly what it talks to:
- the VeSync cloud, to read your devices and send commands
- your own broker, at the address you configured
- Docker Hub, and only to read the public list of versions — switchable off, and explained in the interface
Nothing else. The diagnostics bundle does not send itself anywhere; you download it and decide.
Cloud2MQTT speaks HTTP. On your own network that is fine; across the internet put a reverse proxy with TLS in front of it, or the password travels in the clear.
One thing VeSync imposes, not us: the account usually allows a single active session. Polling can log the phone app out, and the phone app can log the gateway out. That is not a bug in this gateway.
A core (MQTT, poll loop, topics) plus interchangeable adapters/: an adapter
translates a cloud device into a target-neutral dict, and the core knows
nothing about any specific cloud. Adding another vendor cloud means writing a
new adapter — the core stays untouched.
Part of the CheetahLab workbench — cheetahlab.de


