Skip to content

Latest commit

 

History

72 Commits

Folders and files

Repository files navigation

Part of MajorDom — the next-gen smart home

integration-esphome

A MajorDom integration — bridges ESPHome devices into the MajorDom language.

Built for the MajorDom Hub, but it doesn't need it: this is a standalone, standardized library for ESPHome that you can use on its own (see Run it standalone below). Built on the MajorDom Integration SDK. The entry point is ESPHomeController (majordom_esphome/controller.py), which the Hub — or the SDK's dev runner — instantiates and drives through its lifecycle: discovery → pairing → commands → teardown.

Documentation

Full integration-author docs — the controller lifecycle, data models, storing data, discovery, and a worked example — live at docs.majordom.io.

Development

poetry install && poetry run poe install
Task Description
poe check Full quality pipeline (ruff, ty, pytest, poetry build/check)
poe check --ci Same, plus git diff --exit-code

Work lands on develop; master is protected and released via Actions → Release.

Tests drive the controller with the SDK's test doubles against a virtual ESPHome device: a native binary built from tests/virtual/sketches/*.yaml (ESPHome host platform, one entity of every supported kind, with and without API encryption) — no physical hardware required. The binaries are built on first use and need the ESPHome CLI:

pipx install esphome && poetry run python -m tests.virtual.build

Host nodes have no BLE, so the Improv tests run the real client against a scripted Improv GATT service. End-to-end tests on real hardware are kept in a private repository.

Run it standalone (without the Hub)

majordom-esphome is a standalone library — import it into your own app, or run just this integration interactively (discover, pair, control, and inspect devices from a prompt) with no Hub. It needs ESPHome devices reachable on the local network (Wi-Fi or Ethernet).

See Standalone mode for the interactive CLI, watch mode, and the programmatic API.

Supported transports

The integration speaks the ESPHome native API over TCP (port 6053 by default), with or without Noise encryption. Devices must have the api: component enabled in their YAML; an API-less device (e.g. web server only) is not supported.

About this integration

  • Protocol / platform: ESPHome native API (aioesphomeapi).
  • Transport(s): TCP/IP (Wi-Fi, Ethernet); devices are found via mDNS (_esphomelib._tcp). Nodes without Wi-Fi yet are found over BLE (Improv) and set up onto the Hub's Wi-Fi (see below).
  • Supported entities: light (on/off, brightness, RGB), switch, sensor, binary sensor, text sensor, number, select, button, cover (position), fan (on/off), climate (mode, targets). Every other entity the library knows (lock, valve, text, alarm panel, update, ...) is mapped generically from the library's own types (see below); cameras and events are skipped.
  • Credentials needed to pair: the API encryption key (type secret) if the node has one configured in its YAML; otherwise none.
  • Encryption keys: a node with api: encryption: but no key: (it announces api_encryption_supported) is given a key of the integration's own at pairing: generated, stored on the device record, then set with noise_encryption_set_key over a Noise session with the well-known all-zeros PSK (in plaintext on firmware that does not announce api_provisioning=zero-psk). From then on the node accepts only that key, so the user never types one; the key is stored before it is set, so a failed attempt (or a Hub restart in between) does not lock the Hub out. A node without encryption: still pairs in plaintext, with a warning in the log to turn encryption on.
  • Wi-Fi setup over BLE (Improv): a node built with esp32_improv (improv_ble from ESPHome 2026.11) and no Wi-Fi networks advertises the Improv service; it is offered as a ble discovery named (and identified) by its node name. Pairing it gives it the Hub's Wi-Fi with py-improv-ble-client (asking the user to press its button when it has an authorizer), waits for it to announce itself over mDNS (matched by the name it advertised, which ESPHome cuts to 20 characters), then pairs it like any other node, keyless key included. The Hub's device takes the node's own identity (pair_device returns it). The Wi-Fi comes from the Hub's wifi_credentials dependency (SDK feature/wifi-credentials); a Hub without one cannot set nodes up over BLE.

Required harness

  • Hardware adapters: none — ESPHome devices are self-contained and connect over the network.
  • Third-party software services: none — the integration speaks directly to ESPHome devices.
  • OS / permissions: network access to the ESPHome devices (same LAN or routable subnet); mDNS/SSDP may be required for discovery depending on configuration.

Protocol stack (OSI)

Every integration is two things stacked: the MajorDom integration layer — mapping the protocol to MajorDom's domain model — sitting on top of the protocol stack it bridges. The top layer is always this repo.

Layer Protocol Implemented by
MajorDom integration maps ESPHome ↔ MajorDom domain model this repo, always
Application (7) ESPHome Native API aioesphomeapi
Transport (4) TCP OS / device firmware
Network (3) IP (IPv4 / IPv6) OS / device firmware
Data link / Physical (1–2) Wi-Fi (802.11) or Ethernet device hardware

Progress

Two checklists. An item is ticked only when a test in tests/ proves it.

Implementation — makes the integration functional:

  • Discovery service registered via self.dependencies.zeroconf_discovery_service; the cancel closure is called in stop
  • Discovery listener methods fire when nodes appear, change and leave, and the controller calls the matching self.dependencies.output.controller_did_*_discovery
  • Paired devices reconnect when the Hub restarts (controller_did_connect_device is called)
  • start_pairing_window: not needed, mDNS and BLE discovery are always on (SDK default is a no-op)
  • Device pairing, including the encryption key (CredentialsType.secret), and a generated key for keyless nodes
  • Device schema is mapped from the entities the device reports: parameters, units, limits and valid values
  • Hub → Device control (send_command)
  • Device → Hub event subscription (controller_did_receive_events on every state change)
  • identify: ESPHome has no identify action, so it is a documented no-op
  • unpair
  • fetch reports the current value of every parameter in one batch
  • Paired devices going offline / coming back online while the Hub is running update device.available and last_error, and call controller_did_lose_device / controller_did_connect_device
  • Graceful shutdown in stop: discovery stopped, connections closed, no tasks left running
  • Tests pass against a virtual ESPHome device (tests/)
  • README matches the code

Quality — makes it reliable and maintainable (the bar for release):

  • Recovers automatically from connection loss / offline device / restarted device: retried with exponential backoff (1 s up to 30 s), no manual restart
  • No exception escapes discovery handlers, state callbacks or background tasks
  • Failures are surfaced on the device (available / last_error) in plain language and cleared on recovery; a rejected encryption key is reported as such
  • Fully asynchronous: no blocking I/O on the event loop
  • Stable identity: device and parameter UUIDs derive through the SDK helpers (device_uuid, parameter_uuid) from the node's MAC address and the entity's object_id
  • End-to-end tests drive discovery (over a stubbed zeroconf, never the real network) → pair → state → command → fetch → restart → outage → unpair against a virtual device, with and without encryption
  • Failure paths tested: unreachable device, wrong or missing key, a key the node refuses, a node unreachable after it took its key, unsupported credentials, command on a read-only parameter, out-of-range values, offline device
  • Broad device coverage: the entities above by hand, every other entity generically (tested with a lock, valve, text, date and alarm panel)
  • Readable & structured: conversion logic in a mapper, models separated
  • Efficient: subscriptions instead of polling; states are delivered in order
  • Diagnosable: connection failures and unexpected errors are logged with their cause
  • Parameter metadata: visibility follows the entity category, and main_parameter is a one-tap toggle
  • Owned: maintained by Parker Industries, who keep it working as ESPHome and the library evolve

Parameter mapping

Parameters are built from what the device reports (list_entities_services), never from fixed defaults:

Entity Parameters
switch, fan state (bool)
light state, brightness (%) if the light dims, color_hue (°) and color_saturation (%) if it supports RGB; settings: transition_length (s, kept by the integration and added to every later command of the light) and flash (a struct command with its length as argument)
cover position (%), operation (IDLE / IS_OPENING / IS_CLOSING, read-only)
climate mode (enum of the modes it supports), current_temperature, target_temperature or target_temperature_low/high
number state with the device's min / max / step
select state as the option index (enum, valid_values labels are the options)
sensor, binary sensor, text sensor state (read-only)
button state (fires the press)

Brightness, positions and saturation are percentages (0–100, ESPHome reports 0–1); colour is hue in degrees plus saturation, the model the Matter and Zigbee integrations use, converted from ESPHome's RGB. Enums are integers with string labels in valid_values, labelled by the ESPHome enum names. A command may give an enum value as its key or its label (the option or member name). Units come from the entity's own unit string, else from its device class. Entities in the config / diagnostic category are setting, and entities disabled by default are system.

Remaining fields

The hand-written mapping above gives the everyday parameters. Every other state or command field an entity supports is added by the generic mapping as a setting parameter (majordom_esphome/generic.py), so nothing the library reports is dropped silently: a fan's speed level, direction, oscillation and presets; a cover's tilt and stop; a light's colour temperature, white and effects; a climate's action, fan, swing and preset modes and humidity. A water heater's state bit mask becomes on and away switches; a date or time is one struct parameter whose fields are its parts; a command that needs a code (a lock or alarm panel that requires one) is a command_with_code struct command with the command and the code as arguments, next to a code setting holding a default code: a code typed with the command wins, else the default is sent with every command of the entity, so one-tap actions and automations work once the user opts in. Commands with arguments are struct parameters with the arguments as fields, as the SDK describes. A field is only added when the entity says it supports it, enums are limited to the supported members, option lists (effects, presets) become enums like a select, and the limits the entity reports (speed count, mireds, humidity) are used. The few fields that are exposed under another name or not at all (RGB channels, deprecated fields) are listed with the reason in UNMAPPED in the catalogue, which fails when a new field appears that is neither mapped nor explained.

Generic mapping

Entities without a hand-written mapping get parameters from aioesphomeapi's own types (majordom_esphome/generic.py): one parameter per field of the entity's state (read-only, unless the entity's command accepts it) and one per command argument that can be sent on its own. bool, int, float and str map directly, the library's integer enums become enum parameters labelled with their member names, fractions such as a position or volume become percentages, and a command-only flag (a valve's stop) becomes a button. Commands with several required arguments (a date) are read-only. A hand-written mapping wins wherever the generic result is not good enough for the user.

Entity catalogue

tests/test_catalogue.py and tests/virtual/catalogue.py mirror the Matter integration's virtual-device catalogue. The list of entity kinds is whatever the installed aioesphomeapi knows. Fast checks (always run) fail when a kind is neither mapped by the integration nor skipped on purpose, or has neither a recipe nor a reason it cannot run on the host node. Recipes are written by hand, never guessed: each one switches on every capability of its entity and comes with the exact parameters it must produce (EXPECTED) and every state or command field that is deliberately not exposed, with the reason (UNMAPPED), so a field ESPHome adds later fails the sweep until it is mapped or explained. The sweep (poetry run pytest -m catalogue) builds one virtual node per recipe in parallel, pairs it, checks its parameters against EXPECTED and UNMAPPED, reads its states, sends a valid command to every control parameter and checks that the connection survives and every value has the declared type. .github/workflows/canary.yml runs everything monthly against the latest ESPHome and aioesphomeapi and goes red when upstream adds something unsupported.

Notes

The device id derives from the node's MAC address (mDNS mac TXT record) via the SDK's UUID helpers, so ids are stable across restarts and namespaced per integration.

ESPHome devices must have the api: component enabled in their YAML.

Connecting: a node is reached through its hostname first, then through the IP addresses it announced. The first address that works moves to the front and the ones that failed move to the end, so the next connection starts with what worked last time. When a paired node announces itself again (e.g. after a DHCP change) its address list is refreshed.

License

See LICENSE. For commercial licensing or partnership inquiries regarding MajorDom, contact us via parker-industries.org/partnership.