A short orientation file for an LLM working in this repo. Skim before making changes; keep edits consistent with what's described here. Read README.rst for the user-facing intro.
aioesphomeapi is the asyncio Python client for the ESPHome
Native API. It talks
length-prefixed protobuf frames over TCP — either plaintext or
Noise (Noise_NNpsk0_25519_ChaChaPoly_SHA256) — to ESPHome-flashed
devices. Used directly by the Home Assistant esphome integration
and by anything else that wants to drive an ESPHome device from
Python.
The protocol is defined firmware-side in esphome/esphome's
api.proto; this repo's aioesphomeapi/api.proto is the
matching client view. Any protocol change lands in the firmware
repo first (see PR workflow).
Hot paths (connection.py, client_base.py, _frame_helper/*)
are Cythonized at build time for throughput. They keep working as
pure Python — SKIP_CYTHON=1 disables the extension build — but
production wheels ship compiled and benchmarks track that path.
-
Docstrings: terse, default to single-line. A docstring is the function's contract, not its narrative. Almost every docstring should be one line —
"""Summary."""— describing what the function does and what the caller can pass. Multi-line is the exception, only justified when there is non-obvious caller-visible behaviour the type signature and parameter names don't already convey.What does NOT belong in docstrings or comments:
- Rationale / motivation / "why we used to do X" — that's the PR description and the commit message. Git already remembers.
- Cross-references to issue numbers ("closes #N", "follow-up to #M") — the PR body carries those.
- Restatement of the function body in prose. If the next line of the docstring is just describing what the next line of code does, delete the docstring line.
- Test docstrings retelling the production-side story. A test docstring should name what the test pins, in one sentence — not re-explain the bug, the fix, or the surrounding flow.
-
Comments: same bar. Default to writing no comments. Add one only when the why is non-obvious: a hidden constraint, a subtle invariant, a workaround for a specific bug, behaviour that would surprise a reader. If removing the comment wouldn't confuse a future reader, don't write it.
Don't remove existing comments unless the code they describe is gone — the original author left them for a reason.
-
Don't pad commits, docstrings, or comments with cross- references to old codepaths or issue numbers unless there's a clear reason a future reader needs that link.
-
Method order: public API at the top, private helpers (
_underscore_prefixed) at the bottom. -
Line length: ruff default. Python 3.11+ (
python_requires = ">=3.11",target-version = "py311"for ruff). -
Imports: ruff/isort sorted (
force-sort-within-sections,combine-as-imports,split-on-trailing-comma = false).from __future__ import annotationsat the top of regular source modules so we can use modern type syntax. Known exceptions: generated*_pb2.py, the re-export__init__.py, and_frame_helper/packets.py(Cython needs annotations evaluated at runtime). -
Generated files are excluded from lint.
api_pb2.pyandapi_options_pb2.pyare ruff-excluded — never hand-edit them; regenerate via the docker builder (see Regenerating protobuf below).
- No
Co-Authored-By: Claudetrailer. Project preference. - Imperative-mood subject line ("Add X", not "Added X").
- The PR template lives in
.github/PULL_REQUEST_TEMPLATE.md; fill in every section, tick exactly one "Types of changes" box. Thepr-workflowskill (under.claude/skills/pr-workflow/) walks through filling it in — branch offorigin/main, pass the body via--body-fileso the template's backticks aren't shell-escaped. api.protochanges need an upstream esphome PR first. The firmware repo owns the protocol; the matching change lands there before the client PR. Link the esphome PR in the body and tick the corresponding checklist row.- Pre-commit / CI runs ruff (lint + format). Run
./venv/bin/ruff check --fix . && ./venv/bin/ruff format .before pushing. Failures auto-fix where possible, then the commit needs to be re-staged.
./venv/bin/python -m pytest tests/ -vThe asyncio mode is auto (configured in pyproject.toml); test
files don't need an explicit marker. CodSpeed benchmarks live
under tests/benchmarks/ and run in CI on a separate path.
When modifying api.proto, regenerate the bindings with the
official docker image (the version-pinned image ensures api_pb2.py
stays compatible with the protobuf runtime used by the project):
docker run --rm -v $PWD:/aioesphomeapi \
ghcr.io/esphome/aioesphomeapi-proto-builder:latestOr with podman:
podman run --rm -v $PWD:/aioesphomeapi --userns=keep-id \
ghcr.io/esphome/aioesphomeapi-proto-builder:latestDon't hand-edit api_pb2.py / api_options_pb2.py — regenerate
through the builder. They're excluded from ruff for a reason.
- Cython is optional but expected in wheels.
setup.pycythonizes the hot paths listed inTO_CYTHONIZE(connection.py,client_base.py,_frame_helper/{base, noise, noise_encryption, packets, plain_text}.py, plus_frame_helper/pack.pyx).OptionalBuildExtswallows build failures so source installs fall back to pure Python; CI wheel builds setREQUIRE_CYTHON=1to make the build fail loudly if the extension can't be produced. - Modules that get Cythonized ship a sibling
.pxdfor type declarations. When changing the signature of a Cythonized function, update its.pxdin the same commit, or the extension build will pick up a stale declaration. language_level = "3",freethreading_compatible = True(PEP 703). New.py/.pyxpaths added toTO_CYTHONIZEmust stay free-threading-safe.
These are non-obvious traps in the .py + .pxd setup that work
fine in pure-Python mode but break or silently misbehave in the
shipped Cython wheels. Tests pass locally with SKIP_CYTHON=1
fallback paths, then CI on use_cython builds catches the issue —
or worse, the issue ships and only manifests in production wheels.
-
cdef-typed module constants are not Python-importable. Declaringcdef int _MAX_Xin.pxdmakes Cython treat_MAX_X = 5in the.pyas a C int assignment; the Python module dict never gets the binding.from module import _MAX_Xsucceeds in pure-Python but raisesImportErrorunder Cython. Pattern: define both names —MAX_X = 5(Python-importable) and_MAX_X = MAX_X(cdef-typed for hot-path comparisons). Tests import the public name; production code uses either. -
noexceptcdef paths must be pure C. Calling a Python method that can raise (e.g._handle_error_and_close) from inside acdef ... noexceptfunction is undefined / lossy — Cython reports the exception via CPython'sPyErr_WriteUnraisable()and silently continues. Keepnoexceptpaths to sentinel returns and let the caller handle Python-level work. -
unsigned intresult returned throughcdef intcan flip sign. A varuint decoded intoresult="unsigned int"and returned viacdef intwill come back negative for any value with bit 31 set. If the caller doesif x < 0: return, an attacker-controlled large value silently hits the "incomplete" / "stop processing" branch instead of being rejected. Either cap the input range so decoded values stay in signed-int range, or check explicit sentinel values (x == _SENTINEL_A) instead of generic< 0. Issue #1642 / PR #1651 was exactly this trap. -
except */except? -Nadds per-call exception checks. Switching a hot-pathcdef ... noexcepttoexcept *orexcept? -3adds aPyErr_Occurred()check after every call. Negligible for cold paths, measurable on hot paths — CodSpeed caught a ~14% regression on BLE plaintext benchmarks when this was applied to_read_varuint. Prefernoexceptfor hot paths and route error handling through the caller. -
Module-level Python int constants force PyLong conversion in hot path comparisons.
if length > _MAX_FRAME_SIZEcompiles to a Python attribute lookup +PyLong_AsLongper call. Addingcdef int _MAX_FRAME_SIZEto the.pxdmakes it a native C comparison. CodSpeed caught a measurable degradation when this was missed; restoring the cdef declaration recovered it. -
Sign-compare warnings in generated C are real.
gcc/clangwarns when comparingunsigned intwithintbecause the signed value is implicitly converted to unsigned for the compare — a negative value becomes a huge positive. Match the signedness of compared operands in the.pxd(e.g. if the local isunsigned int, declare the constant ascdef unsigned int; if the local isint, declare itcdef int). The warning predicts a class of overflow bug like the unsigned->signed varuint trap above. -
CodSpeed regressions only show in the Cython build. Pure-Python (
SKIP_CYTHON=1) tests can pass while the production wire-format hot paths regress. Trust the CodSpeed check on PRs that touch any file inTO_CYTHONIZE; run the following locally before pushing if perf-sensitive code changed:REQUIRE_CYTHON=1 python setup.py build_ext --inplace
| Path | What |
|---|---|
aioesphomeapi/client.py |
High-level APIClient — what most callers use |
aioesphomeapi/client_base.py |
Lower-level APIClientBase (Cythonized) |
aioesphomeapi/connection.py |
APIConnection, framing, handshake (Cythonized) |
aioesphomeapi/_frame_helper/ |
Plaintext + Noise frame helpers (Cythonized) |
aioesphomeapi/model.py |
Public dataclasses re-exported via aioesphomeapi.* |
aioesphomeapi/core.py |
Exception hierarchy + message-type tables |
aioesphomeapi/reconnect_logic.py |
ReconnectLogic retry/backoff helper |
aioesphomeapi/host_resolver.py |
DNS / mDNS resolution for connect |
aioesphomeapi/discover.py |
aioesphomeapi-discover CLI |
aioesphomeapi/log_reader.py |
aioesphomeapi-logs CLI |
aioesphomeapi/api.proto |
Client-side protocol definition (mirror of firmware) |
aioesphomeapi/api_pb2.py |
Generated — do not hand-edit |
tests/ |
Pytest suite (asyncio_mode=auto) |
tests/benchmarks/ |
CodSpeed benchmarks |
- Don't hand-edit
api_pb2.py/api_options_pb2.py. Regenerate via the docker builder. - Don't land an
api.protochange without the matching esphome PR. The firmware side is the source of truth. - Don't add
Co-Authored-By: Claudeto commits in this repo. - Don't change Cythonized module signatures without updating
the
.pxd— the extension build will silently pick up a stale declaration. - Don't bypass
OptionalBuildExt's exception swallowing in setup.py without thought. Pure-Python fallback is a feature for source installs on platforms without a compiler.