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.md for the user-facing intro and docs/ARCHITECTURE.md for the deep dive.
Backend for the new ESPHome Device Builder dashboard — a WebSocket API
server that replaces the legacy esphome dashboard. Single multiplexed
/ws endpoint, persistent firmware-job queue, mDNS + ping device
discovery, schema-driven component catalog, curated board catalog.
Frontend is a separate repo (esphome/device-builder-frontend) and
ships prebuilt inside our wheel.
Issues filed here are often frontend bugs. Users file every
dashboard bug against this backend repo, but the Visual Editor,
component-catalog rendering, and YAML-form UI all live in the
esphome/device-builder-frontend SPA. When triaging, check whether the
symptom is UI / editor behaviour before assuming a backend fix, and
trace both sides: the backend can parse and emit correctly while the
frontend's rendering or value-sync drops the data. A frontend fix is a
PR against esphome/device-builder-frontend (base main) that links
fixes <this repo's issue URL> cross-repo. Example: issue #1005 (dotted
logger.logs map keys lost in the Visual Editor) was a frontend
data-field-key round-trip bug, not a backend one.
Base functions are in late beta; remote / offload functions are in early beta. Expect undocumented breaking changes until stable. Targeted to land as an opt-in preview toggle in the official ESPHome container and Home Assistant add-on.
-
Docstrings: terse, default to single-line. A docstring is the function's contract, not its narrative. Almost every docstring should be one line —
"""Summary.""". Multi-line is the exception, justified only by genuinely non-obvious caller-visible behaviour the signature and parameter names don't convey. When multi-line is needed, put content on the line after""":def merge_component_yaml(...) -> str: """ Render *component* and merge it into *existing* YAML. Platform-style components append under the existing ``<domain>:``. """
Default target for new code: three lines between the
"""markers. Longer is acceptable when the contract genuinely needs it (non-obvious priority orders, security-relevant fallbacks, an empirical anchor like an issue number proving a value matters, load-bearing ordering).Never put in docstrings or comments: rationale / motivation / "why we used to do X" (that's the PR/commit); issue-number cross-refs ("closes #N"); prose restatement of the function body; test docstrings retelling the production story (name what the test pins, in one sentence); "same shape as X / mirrors Y" framing.
-
Comments: same bar. Default to none. Add one only when the why is non-obvious: a hidden constraint, subtle invariant, bug workaround, surprising behaviour. Don't remove existing comments unless the code they describe is gone.
-
Don't pad commits, docstrings, or comments with cross-references to old codepaths or issue numbers unless a future reader needs the link.
-
Method order: public API at the top, private helpers (
_underscore_prefixed) at the bottom. Same for module-level functions in scripts. -
Line length: 100 (ruff).
target-version = "py312". -
Imports: ruff/isort sorted.
from __future__ import annotationsat the top of every module. -
File size: 800-line soft cap. When a Python module reaches ~800 lines, plan a split before adding more. The cap exists because large modules degrade LLM context budget, slow human review, and accumulate invisible cross-concern coupling.
The split pattern: replace
controllers/X.pywith acontrollers/X/package containingcontroller.py(main class + public API) plus per-concern submodules (controllers/X/foo.py).__init__.pyre-exports the controller class so existingfrom .controllers.X import XControllercallers keep working. Seecontrollers/devices/,controllers/firmware/,controllers/remote_build/for the canonical shape.State dataclass convention. Group mutable domain state — anything a sibling module reads or writes — into a typed
XxxStatedataclass incontrollers/X/_state.py; the controller ownsself.state: XxxState; siblings reach throughcontroller.state.X. Canonical:OffloaderState,DevicesState,ReceiverState. Controller-internal handles no sibling touches (_unsub_job_completed,_pairings_store, base infra like_db/_listeners/_tasks) stay on the controller even when reassigned acrossstart()/stop(). The cut is cross-module data, not reassignment. Design withXxxStatefrom PR 1 of a split — adding it later forces a cleanup arc (#795, #797). Whenstart()/stop()repopulates astate.Xdict/set something captured a bound method of (.add,.__contains__), use in-place.clear()+.update(...)rather than reassignment, or the captured method points at the original empty container forever (theDevicesController.state.ignored_devicesloader hit this; #797 added the regression test).Existing over-800 modules (worst was 5176 lines) are grandfathered. The split rule is scoped to invasive changes:
- Small bugfix for a patch release: ship the fix, skip the split. The cap is soft; readability isn't worth a delayed patch.
- Invasive change (new feature, significant refactor, structural touch): split first as a behavior-free refactor PR, then layer the change on top in a follow-up PR — each reviewable as one concern.
A drive-by split inside an unrelated bugfix is scope creep. Opportunistic refactor PRs targeting the largest offenders are welcome as their own PRs. A PR touching a module already over 800 lines should not make it worse. New top-level modules start under the cap.
Exception: pytest conftests. Conftest is the single import surface for shared fixtures/factories; it's intentionally a grab-bag, edits touch one fixture at a time, and splitting per-helper buys nothing. The 800-line cap does not apply. When hoisting a duplicated helper, prefer the narrowest conftest covering every caller; only widen to
tests/conftest.pywhen callers span the whole suite. Canonical subdirectory conftests:tests/controllers/devices/conftest.py,tests/controllers/firmware/conftest.py,tests/e2e/conftest.py.
- No
Co-Authored-By: Claudetrailer. Project preference. - Imperative-mood subject line ("Add X", not "Added X").
- Every PR needs exactly one label from:
breaking-change,new-feature,enhancement,bugfix,refactor,docs,maintenance,ci,dependencies. CI enforces this via.github/workflows/pr-labels.yaml. Template is in.github/PULL_REQUEST_TEMPLATE.md; thepr-workflowskill walks through it — branch offorigin/main, tick exactly one Types-of-changes box, pass the body via--body-fileso the template's backticks aren't shell-escaped. - Pre-commit runs ruff (lint + format), codespell, yaml/json/python checks. Failures auto-fix where possible, then re-stage.
- All GitHub Actions are SHA-pinned with the version as a trailing
comment (
uses: actions/checkout@<sha> # v4) so dependabot can bump while preserving traceability. Org policy. - Release flow lives in docs/ARCHITECTURE.md.
- CI runs the test matrix on Windows too. PowerShell is the default
shell on
windows-latestand does not accept bash-style\line continuations ("Missing expression after unary operator '--'"). Keep cross-platform CI commands on a single line, or use PowerShell- compatible backtick continuations.
When a change needs the live dashboard (UI behaviour, WS round-trip shape, end-to-end flows), pair this backend with its companion frontend dev server. Tests verify correctness; the browser verifies what the user sees.
- Backend on
:6052in dev mode:.venv/bin/python -m esphome_device_builder --dev configs.--devservesindex.htmlwithCache-Control: no-cacheso a rebuilt bundle isn't masked by a stale SPA shell. - Frontend dev server in a checkout of
esphome/device-builder-frontend(default port 5173):npm run dev. It proxies/wstohttp://localhost:6052, so the backend above answers. HMR is on. If 5173 is taken, passPORT=5174 npm run dev— configure the frontend port; the backend stays on 6052 and the proxy still works.
Run both in the background (Bash with run_in_background: true
under Claude Code). Wait for both before telling the user the URL:
poll lsof -iTCP:6052 -sTCP:LISTEN and
lsof -iTCP:<frontend-port> -sTCP:LISTEN; once both LISTEN, point at
http://localhost:<frontend-port>/. Cleanup:
lsof -iTCP:6052 -iTCP:<frontend-port> -sTCP:LISTEN -t | xargs kill.
Alternative: production-shape testing. npm run build in the
frontend checkout writes to the esphome_device_builder_frontend
site-packages dir the backend reads via _get_frontend_dir; no proxy,
browse to http://localhost:6052/. Use dev-server for fast iteration,
built path to verify the bundle the user actually receives.
npm i puppeteer (downloads its own browser, no OS-specific path) and
drive the dashboard with headless:true. The non-obvious bits:
- Deep-link an editor at
/device/<configuration>.yaml; drop repro configs inconfigs/(gitignored). - The SPA is Lit + Web Awesome, so everything is in shadow DOM that
ordinary selectors miss: walk shadow roots to find nodes. Drive a
wa-selectby setting.valueto awa-option'svalueand dispatchingchange+input({bubbles: true, composed: true}); a dependent select populates only after the one it depends on fires. - To prove a backend command without fighting the wizard DOM, open a
WebSocketto/wsinpage.evaluateand send the frame the frontend does ({command, message_id, args}, shape in the frontend repo'ssrc/api/esphome-api.ts); gate the send on the first server frame (server_version); replies are{result}or{error_code, details}. - For a before/after, run the backend on pre-fix code without touching
your branch:
git show origin/main:<path> > <path>, restart, thengit checkout HEAD -- <path>.
The legacy Tornado dashboard (esphome/dashboard/ upstream) is the
upstream-canonical reference for shared concerns (mDNS source dispatch,
build-info hashes, StorageJSON layout, CORE lifecycle, address_cache
semantics). Functional parity is essentially achieved as of 2026-05; the
one intentional decline is the HA Supervisor /auth POST flow (our HA
add-on path is ingress-only by design — issue #85, inline comment at
device_builder.py:419). Before declaring a new feature complete, check
the open issue list filtered to "legacy parity".
Lessons about the comparison itself: the legacy code is upstream-canonical for shared concerns — when we diverge, that should be an intentional choice flagged in code comments, not an oversight. The new dashboard's cleaner shape can hide gaps the legacy mess was solving (Wi-Fi vs Ethernet adoption, WS heartbeat, reverse-proxy origin allowlist, per-device build-dir cleanup on delete); audit additions against legacy behaviour before assuming the simpler version suffices.
-
WS-first API. Real-time updates are the default — clients
subscribe_eventsonce and get pushes. REST is only kept for HA backward compat inapi/legacy.py. -
Stateful lists ship through subscribe_events, not a
list_*WS command. Any per-session list whose contents mutate over a connected client's lifetime (devices, importable devices, offloader pairings, receiver peers, …) must reach the frontend via the snapshot-plus-events pattern. The shape that doesn't belong on new code is list-then-poll ("client callslist_Xon mount, re-calls after every mutation"), which has three recurring failure modes we've hit:- Read-vs-write races — a snapshot read concurrent with a write
returns whichever side won the lock, then disagrees with the next
event; frontend state ping-pongs until reload.
remote_build/list_peershad this (#514). - Cross-tab desync — a second tab's mutation never reaches the first unless it re-polls.
- Round-trip overhead — every mutation pays a follow-up list-fetch the events were already going to deliver.
Do this instead:
- Hold the list as a RAM-canonical dict on the controller, keyed
on what the wire commands key on (
dashboard_idfor receiver peers,(hostname, port)for offloader pairings, the YAML filename stem for devices). Mutations update RAM immediately and schedule a debounced disk write through a per-filehelpers.storage.Store(mirrorRemoteBuildController._approved_peers/_peers_store). Projections and post-mutation responses read straight off the dict. RAM loads from the Store atcontroller.start(); disk is just persistence. - Seed the first paint through
subscribe_events'sinitial_statepush — the_send_initialinner helper inDeviceBuilder._cmd_subscribe_events, passed assend_initial=tohelpers.event_bus.stream_events. Add a sync*_snapshot()method on the controller (e.g.pairings_snapshot(),peers_snapshot()) and stitchinitial["<key>"] = [s.to_dict() for s in controller.<key>_snapshot()]. Snapshot reads MUST be sync — the subscribe handler runs in the WS dispatch hot path; an executor hop on every connect slows cold-load on large fleets. - Fire per-mutation bus events with TypedDict payloads carrying
every field a subscriber needs to build the row from the event
alone — no follow-up snapshot read (see
RemoteBuildPairRequestReceivedData'spaired_at, #514). Frontend mutates its local list from the event. - Emit one event per state transition.
subscribe_eventsattaches the bus listener before awaiting_send_initial, so events fired during the snapshot await buffer behind initial_state and arrive in order; subscribers can rely on "initial_state first, then live updates".
Don't add
list_*commands for new state surfaces. Existing carve-outs:remote_build/list_hosts(transient mDNS browse, not stateful) anddevices/list_archived(cold archive listing, read-once).labels/listis the snapshot-fetch-then-events holdover — new code lands throughinitial_state, not by copying it. - Read-vs-write races — a snapshot read concurrent with a write
returns whichever side won the lock, then disagrees with the next
event; frontend state ping-pongs until reload.
-
Event payloads use TypedDict, not dataclass. Mirrors HA core's
Event[_DataT]/EventStateChangedDatapattern. Each event-specific shape gets aTypedDictnext to the controller that fires it (e.g.RemoteBuildPairRequestReceivedDatainmodels/remote_build.py,JobLifecycleData/JobOutputData/JobProgressDatainmodels/firmware.py,DeviceEventData/DeviceStateChangedData/DeviceReachabilityDatainmodels/devices.py). Fire with TypedDict- call syntax so mypy validates:bus.fire(EventType.X, SomeEventData(field=value)).EventandEventBus.fireare generic onDataT, so a typed payload flows withoutcast(). Subscribers narrow at the callback signature:def _on_x(event: Event[SomeEventData]).add_listeneris intentionally non-generic (type-erased bucket,Anybridges variance) — it enforces the correct pairing but doesn't reject the wrong one; mismatches live in review.tests/test_event_payload_contracts.pypins each TypedDict against its emitter and walksmodels.*to assert coverage. New events ship with a TypedDict from day one and a row in_PAYLOAD_FACTORIES. Full rationale indocs/ARCHITECTURE.md"Event bus → Typing event payloads". -
Persistent firmware queue — two concurrent lanes. A CPU/compile lane and a network/upload lane each run one job at a time, but run concurrently, so a slow upload doesn't block the next compile (#3702).
FirmwareState.compile_lane/upload_lane(controllers/firmware/ _state.py);lane_for(job)routes UPLOAD to the upload lane, everything else to compile.firmware/installenqueues a COMPILE job + a dependent local UPLOAD job (FirmwareJob.depends_on); the upload is held off its lane until the compile succeeds (lifecycle.release_dependents), and a cancelled/failed compile cascades to cancel the held upload (so a cancelled build never flashes). Remote install uses the same chain — a remote compile materialises artifacts locally, then the local upload lane flashes. Queue + output buffers survive restarts. Seecontrollers/firmware/. -
Remote build-server pool (#1229). A third queue consumer beside the two lanes. A compile eligible for a paired server is marked
JobSource.REMOTE_PENDINGat submit (pin bound at dispatch, not submit) and holds inRemoteDispatchStateinstead of on the compile lane, so paired servers compile concurrently and a host paired/freed mid-queue is used.run_dispatch_loop(gathered in_run_queue) matches waiting compiles to free servers viapick_dispatch_target(adds aWAIToutcome topick_build_path's local/remote/raise);place_on_laneis the single router andRemoteDispatchStateowns the pending→in-flight→free transitions as methods. A server lost mid-build re-routes to the next worker (bounded by_MAX_SERVER_LOSS_RETRIES). Local-only is a pool of one — the lane path is unchanged. Full write-up indocs/ARCHITECTURE.md"Remote build-server pool". -
Component catalog is generated, not hand-edited. Source is ESPHome's pre-built schema bundle (https://schema.esphome.io) plus narrow live
esphomeintrospection for what the schema doesn't carry (multi_conf,platform_defaults,supported_platforms, type refinement,unit_of_measurementoptions). Component descriptions/ titles fall back to the docs MDX repo when the schema index is sparse. All inscript/sync_components.py. -
The long-lived process never imports
esphome.components.*. Importingesphome.components.esp32drags in espidf → requests →esphome.config(~9s of cold start on an HA Green). Static platform metadata is snapshotted intoplatform_capabilities.index.jsonby the nightly sync and read at runtime; the one dynamic case (get_download_typesfor libretiny/nrf52, which reads the build dir) runs in thedevice-builder-helpersubprocess.script/check_import_time.pytests/test_cold_import_floor.pyguard the invariant in CI. Don't add an eager (or runtime in-process)esphome.components.*import on the dashboard side; precompute into the index or push it into the helper.
-
Catalog id format:
<domain>.<stem>(e.g.sensor.dht). The schema's natural format is the reverse —<stem>.<domain>;_split_qualified_keyflips it. -
Board catalog (
definitions/boards/<id>/manifest.yaml) is hand-curated YAML. ~80 popular boards plus generic fallbacks per platform.script/validate_definitions.pylints the manifests. -
Frontend handoff for the catalog is documented inline in models (
ConfigEntry,ComponentCatalogEntry). NewConfigEntryTypevalues need a frontend update — coordinate. -
Deployment modes change the on-disk paths — never hardcode them. Three shapes ship, and
CORE.data_dirresolves differently in each. Every storage / build-info / firmware-binary read MUST go throughext_storage_path(orCORE.data_dir) rather than reconstructing<config_dir>/.esphome/..., or the read silently misses the file in the addon (user sees empty-Local-hash + Pending-install on every device):Mode CORE.data_dirStorageJSON Build tree Default (pip install, dev checkout) <config_dir>/.esphome<config_dir>/.esphome/storage/<file>.json<config_dir>/.esphome/build/<name>/HA addon ( is_ha_addon()true)/data/data/storage/<file>.json/data/build/<name>/ESPHOME_DATA_DIRenv override$ESPHOME_DATA_DIR$ESPHOME_DATA_DIR/storage/<file>.json$ESPHOME_DATA_DIR/build/<name>/The HA-addon shape is dominant in production: YAML configs at
/config/esphome/(HA's/configmount), every ESPHome artefact at/data/(the addon's per-instance volume). The split lets the addon wipe/data/build/for upgrades without touching user YAML, and gives two addon instances independent data dirs while sharing the config tree.CORE.config_pathis set to a sentinel YAML insideconfig_diron startup (controllers/config/settings.py:_DASHBOARD_SENTINEL_FILE); helpers wanting the storage layout MUST resolve through that initialised CORE, not reconstruct from aPathargument. The audit coverscontrollers/firmware/,controllers/devices/,helpers/config_hash,helpers/build_size,helpers/device_yaml; new callers mirror it. Tests needCORE.config_pathset to a tmp-path sentinel — see autouse fixtures intests/controllers/devices/conftest.pyandtests/test_config_hash.py.Native Windows auto-applies the override row:
__main__wraps startup inhelpers/windows_build_pathsto relocateCORE.data_dirtoC:\esphb\<id8>(clearsMAX_PATH+ spaces), so don't assume default-mode<config>/.esphomepaths on Windows. -
config_hashsource of truth isbuild_info.json. ESPHome writes<storage.build_path>/build_info.jsonafter every successful compile and every--only-generate(thewrite_cpp(config)call runs before theargs.only_generateexit). Read it back viahelpers.config_hash.read_build_info_hash; don't recompute (see "Things that have bitten us")._resolve_device_metadatareadsbuild_info.jsonfirst, falling back to the.device-builder.jsonsidecar only when the build dir is wiped. -
api_encryptionmDNS TXT is a tri-state, not a boolean. Truthy (e.g.Noise_NNpsk0_25519_ChaChaPoly_SHA256) → encryption confirmed. Empty string → TXT seen, key absent → confirmed plaintext.None→ no broadcast yet, unknown. Frontend'sgetEncryptionStateand backend'sapply_api_encryptionboth lean on the empty-string-means-plaintext distinction; a nullable boolean would lose it. -
Optimistic post-flash sync in
DevicesController._sync_deployed_hash_after_flashpre-pinsdeployed_config_hash = expected_config_hashafter a successful UPLOAD/INSTALL viaDeviceStateMonitor.apply_config_hash, so the dot clears immediately instead of waiting on the rebooted device's mDNS announce. If the OTA silently failed, the next real announce pushes the truth back through the same callback. -
Two mDNS paths with different OFFLINE semantics:
- Browser callback (
_on_service_state_change) — passively subscribed to_esphomelib._tcp.local.. Trust mDNS both directions:AsyncServiceBrowserdelivers aRemovedevent on TTL expiry, the canonical "device gone" signal. ONLINE → mdns, OFFLINE → mdns, no ICMP. - One-off active resolve (
_resolve_non_api_mdns_targets, for non-API devices not on_esphomelib._tcp.local.). Trust mDNS for ONLINE only (priority 3, locks out ICMP — once mDNS answers, repeat-pinging is redundant noise). A miss is deliberately silent: a single active query that didn't reply conflates "gone", "slow", and "packet loss", and there's no subscription delivering TTL-expiry here. Wait for the ICMP sweep in the same loop to decide OFFLINE.
Don't add an OFFLINE branch to the active-resolve path without re-reading this. The asymmetry is the only way to get aggressive ONLINE detection without flipping the indicator red on every quiet device.
- Browser callback (
-
The
Deviceis the source of truth, not the monitor.DeviceStateMonitor.apply_*(state, ip, version, config_hash, api_encryption) dedupe by comparing the broadcast against every matchingDevice's current field — never a separate monitor-side cache. A cache drifts when the scanner rebuilds aDevicewithprevious=None(atomic-save churn, REMOVED+re-ADDED) and then short-circuits the next legitimate broadcast, leaving empty fields forever. Lesson from PR #75; future apply-* methods follow this shape. -
Scanner keeps a name-keyed index alongside the path-keyed one.
DeviceScanner._devices_by_name: dict[str, list[Device]]is maintained in lockstep with_devicesvia_set_device/_pop_device/_unindex_name. Buckets are sorted byconfigurationfilename sobucket[0]consumers and the dedupe path see a deterministic "first match".scanner.get_by_name(name)returns a fresh list snapshot, so callers iterate without poisoning the index. -
mDNS-source dedupe must look at every matching device, not just
bucket[0]. Two YAMLs sharing anesphome.name(a config plus afoo (1).yamlcopy,dashboard_importsiblings) share one mDNS broadcast. Ifapply()checks only the first match, a sibling rebuilt with state=UNKNOWN never catches up.apply()and_any_matching_device_differsuseall(...)/any(...)over the whole bucket; per-device callbacks fan out the mutation.
-
Never generate invalid configs; fix the source, not the consumer. When a downstream path hits an invalid YAML (
esphome configexits non-zero, schema rejects, compile fails), fix the generator (wizard,dashboard_import,clone,create_device) so it always produces something the next step validates cleanly. Don't add a consumer fallback that "tries to make it work anyway." The rename path tried that — a file-level rewrite whenesphome configfailed — and silently desynced on-disk state from running firmware (YAML renamed, device kept its old hostname, no error). Same shape bitedit_friendly_name(PR #390 added pre-write validation); the rename fallback was deleted (PR #402, upstream companion esphome/esphome#16296). When tempted to add a defensive "what if input is broken" branch, audit what generated this YAML and harden the generator. If the generator legitimately can't guarantee validity (user hand-edits between create and rename), surface a typedCommandError(INVALID_ARGS, …)with the actual errors — refuse cleanly, no silent best-effort. Pair the consumer-side error with a generator-side test running output througheditor.validate_yaml.Important exception — user-supplied content is not a generator. Don't apply this to YAMLs the user brings in via "Upload YAML", drag-and-drop, paste-into-editor, or any user-typed entry point — the point is to land an existing config so the user can repair it in the editor. The common case is a YAML from an older ESPHome version whose components changed schema (deprecated
esphome.platform/esphome.board, renamed fields likewifi.use_address); refusing the write strands the user. Validate our outputs (generate_device_yaml,generate_minimal_stub_yaml,dashboard_import.import_config, clone's leaf rewrites) but pass user-supplied content through unchanged. PR #412 reverses #405's overzealous validation oncreate_device'sfile_contentbranch and pins the legacy-config acceptance contract. The next compile / install surfaces real schema errors with line numbers — that's what the user wants when repairing an old config.
When changing the sync script or catalog handling, watch for these:
-
Don't swap
sys.executablefor a siblingpython. It silently jumps to a different interpreter (e.g. system Python withoutesphome) → "No module named esphome" at compile time._find_esphome_cmdusessys.executabledirectly. -
extends:references need a deep merge. The schema uses partial overrides —dht.sensor.humidity.config_vars.device_classcarries only{"default": "humidity"}and inherits the rest from_SENSOR_SCHEMA. A flat{**extended, **local}drops inherited enum values._convert_config_varsdoes per-field deep merge. -
id_type≠use_id_type.id_typeis the type of id this field creates;use_id_typemarks a cross-reference (i2c_id,output_id). Don't pullreferences_componentfromid_type— that turned everyoutput.gpio.idinto a "select existing gpio" dropdown. -
Schema's
type: "schema"can be an extends-only wrapper. If the inner has onlyextendsand noconfig_vars, collapse to the underlying primitive (often a time_period). Don't blindly emittype=nested. -
Custom validators lose type info upstream.
api.encryptionemerges as{key: Optional, docs: ...}because ESPHome validates it with a custom function. Use_FIELD_OVERRIDES; keep the list small. -
MDX field-description backfill is top-level only. The
## Configuration variablesbullet list is flat; recursing into nested entries leaks descriptions across levels (e.g.esphome.name→esphome.areas[].name). -
CORE.config_hashis post-codegen, not post-read_config. Each component'sto_coderuns after validation and can mutate the config (id-pinning, default backfill, normalisation); the build readsCORE.config_hashfromwriter.get_build_infoaftergenerate_cpp_contents. A subprocess that loads YAML, callsread_config, and reads the property disagrees with the firmware's broadcast. Verified againstacfloatmonitor32.yaml: pre-codegenf3e21d5a, post-codegen5a94a12d(the latter is baked in). Readbuild_info.jsoninstead. -
compute_has_pending_changeschecks the hash before the mtime. When bothexpected_config_hashanddeployed_config_hashare known, the hash comparison is authoritative — equal hashes mean the firmware is built from the same logical config the YAML resolves to today, even when mtime is newer (whitespace edits,--only-generaterewrites, comments). The mtime check is the fallback for pre-#16145 firmware that doesn't broadcast a hash. -
ext_storage_pathrequiresCORE.config_pathset. It wrapsCORE.data_dir, which crashes (AttributeError: 'NoneType' ... is_dir) when CORE isn't initialised. Fine in production; tests get it via the autouse_core_config_path_in_tmpfixture intests/conftest.py. New helpers reading storage / build_info / firmware-bin paths MUST resolve throughext_storage_path(orCORE.data_dir) — never reconstruct<yaml_dir>/.esphome/...from aPath, even if it works locally. The default-mode shortcut is invisibly wrong on the HA addon and silently returnsNonefor every device. See the deployment-modes table above. -
Atomic-save editors (vscode-on-macOS et al.) can briefly remove the YAML mid-save. The scanner sees it disappear, fires
REMOVED, then re-ADDEDnext sweep withprevious=None— so monitor-derived Device state (deployed_config_hash,api_encryption_active,ip,deployed_version,state) resets to default. Don't let dedupe layers cache values keyed only on device name; the rebuilt Device starts fresh and the cache masks the next legitimate broadcast. (See "TheDeviceis the source of truth".) -
Test callbacks that drive dedupe must mirror production's state mutation. The monitor's apply-* methods short-circuit when every matching device's field already equals the broadcast — and in production the controller's
_on_*_changecallback writes that value back. A bareMagicMockdoesn't, so a "second call short-circuits" assertion fails unless the mock has aside_effectflipping the device's field. See_flip_state/_flipintests/test_mdns_*.py. -
In-place file writes need
esphome.helpers.write_file, notPath.write_text. BothPath.write_textandopen(path, "w")truncate before writing — a crash between truncate and flush leaves an empty or half-written file; for a YAML config that's unrecoverable.write_filestages bytes in aNamedTemporaryFilein the destination dir, thenshutil.moves into place (atomic only when it resolves to a same-FSos.rename; staging in the destination dir keeps it same-FS). It alsofchmods to 0o644 and wrapsOSErrorasEsphomeError. Use it for any in-place rewrite of user-editable YAML / settings (edit_friendly_nameis canonical). Don't usetempfile.mkstempwithoutdir=— it lands on/tmp, a separate FS from/configin the addon, and the cross-FS move loses atomicity.New files differ —
clone_deviceopens viaopen(path, "x")(exclusive-create, already atomic). Only in-place edits needwrite_file. Build artefacts (StorageJSON sidecars,.device-builder.json) recoverable on next compile / scan can stay on direct writes — the criterion is "would losing this lose user-authored content." -
CodSpeed parametrize values need explicit
pytest.param(value, id="<short>")IDs when the value isn't a short primitive. pytest's auto-ID concatenates values verbatim, so a multi-KB YAML / bytes payload bakes into the test name; the benchmark passes and uploads, but the server-side "CodSpeed Performance Analysis" check fails ("Unable to generate the performance report") because the test-identifier length limit silently drops the run. Followtests/benchmarks/test_log_streaming.pyandtests/benchmarks/test_peer_link_noise_xx.py:pytest.param(_NEWLINE_PAYLOAD, 1000, id="newline_1k"),pytest.param(1024, id="1KiB"). Bare ints / short slugs are fine.
| Path | What |
|---|---|
esphome_device_builder/device_builder.py |
Singleton owning controllers + event bus |
esphome_device_builder/controllers/*.py |
One file per API surface. Larger surfaces (devices, firmware, remote_build) are packages — controller.py for the main class, per-concern submodules, __init__.py re-exporting the controller. |
esphome_device_builder/models/*.py |
Data classes (mashumaro) — pure shape, no logic |
esphome_device_builder/api/ws.py |
WebSocket dispatch |
esphome_device_builder/definitions/components.index.json + components/<id>.json |
Generated; do not hand-edit. Slim index loaded eagerly; per-id bodies hydrate lazily via ComponentCatalog.get_body. |
esphome_device_builder/definitions/boards.index.json + board_bodies/<id>.json + featured_components.index.json |
Generated; do not hand-edit. Slim board index + per-id lazy bodies (via BoardCatalog._body_store) + aggregated featured-components map (read once by the components controller's registry build). |
esphome_device_builder/definitions/boards/<id>/manifest.yaml |
Curated; hand-edited. The body directory is board_bodies/ (separate from this manifests dir) so the body-swap rmtree can't trample the hand-curated source. |
esphome_device_builder/definitions/platform_capabilities.index.json |
Generated; do not hand-edit. esphome platform metadata the long-lived process reads instead of importing esphome.components.* (download routing, wifi-inference no-wifi sets, static download-types). Loaded via load_platform_capabilities_index. |
esphome_device_builder/helper_cli.py (device-builder-helper) |
Subprocess for get_download_types on build-dir-dependent platforms (libretiny/nrf52), so the child imports esphome.components.<X>, not the dashboard process. |
script/sync_boards.py |
Regenerates the split board catalog from the manifests |
script/sync_components.py |
Regenerates the component catalog + platform_capabilities.index.json |
script/check_catalog.py |
Smoke test for popular components |
script/check_import_time.py |
CI guard: fails if import …device_builder regresses past script/import_time_budget.json (e.g. a fresh eager esphome.components.* import) |
script/validate_definitions.py |
Lint board manifests |
docs/ARCHITECTURE.md |
Full architecture + deployment + CI overview |
docs/API.md |
Every WS command + payload shape + event |
- Don't hand-edit
components.index.json/components/<id>.json. Regenerate viascript/sync_components.py. CI runs the sync nightly and opens a PR — that's the intended update path. - Don't auto-merge catalog PRs. Schema regressions and sync-script bugs both surface as PR diffs; a human gate catches them. The diff summary in the PR body is designed for fast review.
- Don't add
Co-Authored-By: Claudeto commits in this repo. - Don't bump the
esphomedependency casually. Dependabot ignores it for a reason — bumping needs a coordinated catalog re-sync against the matching schema version. Do it deliberately at release time. - Don't reorder existing public methods without a reason. The controllers' API surface is the de-facto public interface for the frontend.