|
esphome-api-client 0.5.0
Modern C++17 client for the ESPHome native API (plaintext + Noise)
|
A fully-typed C++17 client library for the ESPHome native API — the raw TCP + Protocol Buffers protocol that ESPHome devices speak on port 6053, with optional Noise (Noise_NNpsk0_25519_ChaChaPoly_SHA256) encryption.
It gives you an object-oriented, typed surface over every ESPHome subsystem (sensors, lights, covers, climate, locks, media players, the Bluetooth/voice/Z-Wave/serial proxies, logs, …) while still letting you reach the raw generated protobuf messages when you need an escape hatch.
The repository also ships a full command-line tool, **esphome-cli**, built on top of this library. Its documentation lives in **`bin/README.md`**.
ESPHome devices expose a binary protobuf protocol. Talking to it by hand means framing messages, running a Noise handshake, mapping message IDs to types, and decoding 26 entity domains. This library does all of that and hands you typed objects.
light->set_brightness(0.5F)) instead of raw protobuf.SyncClient) or an async event loop (Client) — both are provided.The smallest useful program: connect, print the device identity, read sensors, toggle a light.
Why this works:
SyncClient is a blocking facade. connect() performs the TCP connect and protocol handshake, then — because ClientOptions::subscribe_on_connect defaults to true — it also enumerates the device's entities and subscribes to state updates. So entities() is already populated and live when connect() returns.client.entities().light("ceiling") returns a std::optional<LightEntity>. It is empty if the device has no light with that object id, which is why the example checks it with if (auto …).esphome::api::ApiError. Catch the base class to handle them all.The library is a normal CMake project. The exported target is **esphome::api**.
When the library is consumed this way, its tests, examples, and the esphome-cli tool are off by default (they only build when the library is the top-level project), so you pay only for the library itself.
If you vendor the sources (e.g. as a git submodule):
After make install, consumers can use the installed CMake package config:
cxx_std_17 is a public compile feature of the target).tools/protogen) parses the vendored proto/*.proto and emits the message classes, enums, and id↔type table.src/crypto/detail/, paired with the OS CSPRNG.FetchContent. It is a private dependency — it never appears in the public headers, so consumers do not need to find or link it.ESPHOME_API_WITH_NOISE (default ON). The public macro ESPHOME_API_HAS_NOISE reflects whether it was compiled in, so config.hpp and your own code can branch on the feature.Everything needed to reach one device. Plain struct — fill in what you need.
ClientOptions::connection is a ConnectionOptions with handshake/keepalive tunables: client_info, noise_psk, expected_name, login (send an AuthenticationRequest, default on), password (deprecated plaintext auth), and the connect_timeout / keepalive_interval / keepalive_timeout durations.
The blocking facade. Each call pumps the internal Asio event loop until the request resolves (or its timeout fires). Use it for scripts and request/response code.
Key members: connect(), disconnect(), is_connected(), state(), server_hello(), device_info(), entities(), store(), the subsystem accessors below, list_entities(), subscribe_states(), and pump_until(predicate, timeout) for waiting on asynchronous results.
The async core. You drive the Asio event loop yourself and react to callbacks. Use it when you have your own event loop or need many concurrent connections.
SyncClient::async() exposes the underlying Client if you started sync and need the loop.
client.entities() returns an EntityRegistry. Plural accessors return iterable lists; singular accessors take an object id (or numeric key) and return a std::optional<…Entity>.
Handles are lightweight views into the client's entity store. They are valid while the client is connected and the store holds the entity; do not keep them past disconnect().
client.store() is the lower-level cache the registry sits on. Use it to iterate everything or to get a callback on each state change.
Reachable directly from the client; each is a typed manager.
Other accessors: home_assistant(), voice(), zwave(), serial(), and serial_proxy(index) / serial_proxy(name) for a single UART port handle.
Find devices on the LAN over mDNS — no connection required.
DiscoveredDevice::connect_host() returns the resolved IP if known, otherwise the hostname. Scanning requires multicast access to 224.0.0.251:5353.
The transport (plaintext vs Noise) is selected automatically from the device's indicator byte; you only supply the key. Connecting with a key to a plaintext-only device — or without one to a device that requires Noise — throws EncryptionMismatchError, whose message names the fix.
Each returns std::optional, so a missing entity is a normal, checkable result — not an exception.
Some operations (a Bluetooth GATT read, a serial modem-pin query) complete via callback. Drive the loop until your predicate is satisfied:
pump_until throws TimeoutError if the deadline passes before the predicate becomes true.
If your project already uses nlohmann/json, include the header-only adapter to convert any value type to/from JSON:
This is intentionally separate so the core library stays JSON-free for consumers who do not want the dependency.
The library reports errors by throwing exceptions. All derive from esphome::api::ApiError (itself a std::runtime_error), so catching the base class catches everything.
| Exception | Meaning |
|---|---|
ApiError | Base class for every library error. |
ConnectionError | TCP-level failure: refused, reset, EOF, host unreachable. |
ProtocolError | Peer violated framing/message protocol (bad indicator byte, oversized frame, undecodable payload). |
HandshakeError | Noise handshake failed (bad PSK, MAC mismatch, unexpected message). Subclass of ProtocolError. |
EncryptionMismatchError | Encryption mode does not match the device (key supplied to a plaintext device, or missing for an encrypted one). Subclass of HandshakeError; message names the fix. |
AuthenticationError | Auth rejected, or negotiated API version unsupported. |
TimeoutError | An operation did not complete before its deadline. |
EncryptionError | A symmetric encrypt/decrypt failed (AEAD tag mismatch, bad nonce). Subclass of ProtocolError. |
Note that entity lookups (entities().light("x")) do not throw for a missing entity — they return an empty std::optional. Exceptions are reserved for connection, protocol, and timeout failures.
switch_ has a trailing underscore.** switch is a C++ keyword, so the registry accessor is entities().switch_(...). Some struct names are similarly disambiguated (LockEntityState, MediaPlayerStatus, AlarmControlPanelStatus, UpdateControl).subscribe_on_connect = true, connect() populates them. If you set it to false, call list_entities() (and subscribe_states() for live updates) yourself, then pump the loop before reading.LightEntity / SwitchEntity etc. references state owned by the client's store. Using one after disconnect() (or after the client is destroyed) is a use-after-free. Re-fetch from entities() instead of caching handles long-term.pump_until(..., timeout) briefly to let the state update arrive before reading it back.std::optional{}, not an exception. Always check the optional.Discovery::scan needs multicast.** It requires access to 224.0.0.251:5353; firewalled or container networks without multicast will return nothing.| API | Purpose | Notes |
|---|---|---|
ClientOptions | Connection parameters | host, port, connection, subscribe_on_connect |
ConnectionOptions | Handshake/keepalive tunables | noise_psk, expected_name, login, timeouts |
SyncClient | Blocking client | Pumps the loop per call; throws on failure |
Client | Async client | You drive the Asio loop (run / run_for / poll) |
EntityRegistry (entities()) | Typed entity access | Plural lists; singular returns std::optional |
EntityStore (store()) | Live state cache | entities(), find(key), on_state(cb) |
DeviceInfo (device_info()) | Device identity | name, model, version, MAC, project, … |
bluetooth() / logs() / serial() / voice() / zwave() / home_assistant() | Subsystem managers | Typed proxies |
Discovery::scan(timeout) | mDNS LAN discovery | Returns std::vector<DiscoveredDevice> |
ApiError and subclasses | Error reporting | See Error handling |
<esphome/api/json.hpp> | Optional JSON adapter | Requires nlohmann/json; not auto-included |
For the full surface, see the headers under include/esphome/api/ (or build the Doxygen docs with make docs).
The examples/ directory contains runnable programs. Each accepts <host> [port] plus --key|-k <base64-psk> (for Noise) and --name|-n <device-name>.
| Example | Demonstrates |
|---|---|
examples/hello.cpp | Connect, print device identity, disconnect — the minimal flow |
examples/list_entities.cpp | Enumerate and print every entity by domain |
examples/subscribe_states.cpp | Stream live state changes |
examples/control_light.cpp | Find the first light and toggle it via the typed handle |
examples/logs.cpp | Stream the device log to stdout |
examples/bluetooth_scan.cpp | Subscribe to BLE advertisements via the device's proxy |
examples/noise_connect.cpp | Encrypted connect (requires --key) |
examples/discover.cpp | Scan the LAN for ESPHome devices over mDNS |
Build them with make examples, then run e.g.:
Or directly with CMake/CTest:
The test suite includes an in-process device emulator (plaintext + Noise, over a real socket), so the integration tests exercise the full stack. The Noise handshake is verified byte-for-byte against the official NNpsk0_25519_ChaChaPoly_SHA256 test vector.
CMake options use the ESPHOME_API_ prefix: WITH_NOISE (default ON), BUILD_TESTS, BUILD_EXAMPLES, BUILD_CLI, BUILD_SHARED, BUILD_DOCS, ENABLE_SANITIZERS, ENABLE_COVERAGE, ENABLE_CLANG_TIDY, WARNINGS_AS_ERRORS, INSTALL.
Seven layers, each depending only on those below:
TcpTransport; in-tree Noise primitives / handshake (vendored, public-domain crypto).PlaintextFrameHelper / NoiseFrameHelper (reassembly + framing).MessageRegistry maps message id ↔ type via a static generated table (no runtime reflection).Client, DeviceInfo, EntityStore, per-domain entities + commands.SyncClient pumps the event loop until each blocking call resolves.The proto schema (proto/api.proto) is vendored from ESPHome. At build time the tools/protogen C++ generator parses it and emits the message classes, enums, the MessageId enum, and the id↔type registry table. The generated code produces standard protobuf wire format, verified byte-for-byte by a golden test (tests/test_wire_golden.cpp).
Do I need to link Asio or a crypto library myself? No. Asio is a private dependency hidden behind the public API, and the Noise crypto is in-tree. Link only esphome::api.
Is it header-only? No. It is a compiled static (or shared, with ESPHOME_API_BUILD_SHARED) library.
Sync or async? Both. Start with SyncClient for scripts; use Client when you have your own event loop.
What happens if an entity doesn't exist? The singular accessor returns an empty std::optional. No exception.
Can I use the raw protobuf messages? Yes — client.send(msg), client.on(id, ...), and client.on_raw(...) expose the generated esphome::api::proto::* types as an escape hatch.
How do I enable encryption? Set options.connection.noise_psk to the device's base64 32-byte key. The transport is chosen automatically.
Where is the command-line tool documented? In `bin/README.md`.
esphome-cli wraps this library for shell use: discover devices, inspect entities, control any domain, stream logs and state, and drive the Bluetooth and serial proxies.
Install it via Docker or Homebrew, and read the full reference here:
docker run --rm --network host aurimasniekis/esphome-cli scanbrew install aurimasniekis/tap/esphome-cliThe CLI's own dependencies (CLI11, spdlog, nlohmann/json) are private to the tool and never reach the library target.
Contributions to the library are welcome! If you encounter any issues or have suggestions for improvements, please feel free to submit a pull request or open an issue on the project's repository.
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.