Imported from bharvey88/claude-code-setup (
skills/upstream-contrib/SKILL.md). Install upstream withnpx skills add bharvey88/claude-code-setup --skill upstream-contrib. Copyright stays with the author.
Upstream Issues and PRs
Hard gates (Brandon has corrected these many times)
- Show before submit. Always show the complete, final issue/PR title and body verbatim and get explicit approval before
gh issue create/gh pr create. No exceptions, even for small issues. - One logical change per issue/PR. ESPHome maintainers reject mashed-together PRs. If the work contains multiple independent changes, split into separate atomic issues/PRs and say so up front.
- Attribution rules differ by destination:
- Apollo repos (ApolloAutomation/*): include the
🤖 Generated with [Claude Code](https://claude.com/claude-code)footer on commits/PRs (the repo owner requires it). Still NOCo-Authored-By: Claudetrailer. - Everywhere else (esphome, bharvey88 repos, third-party): no Claude footer AND no co-author trailer. No Claude credit of any kind.
- Commit identity is always Brandon Harvey,
8107750+bharvey88@users.noreply.github.com.
- Apollo repos (ApolloAutomation/*): include the
- De-AI the prose. Terse, factual, minimal. Prove claims with logs/links instead of adjectives. No headers-for-everything bulk in small issues.
- Issues report, they don't fix. No speculation about causes, no unrequested workarounds or "vibe coded" patches - just enough evidence to prove the issue ("dont add the workaround just post the issue lol"). Share a snippet only if it proves the point.
- Port exactly what was asked. When duplicating a feature across repos ("do the same thing CAST-1 does"), port precisely that logic - no adjacent improvements, no bundled extras. If extra work seems warranted, ask first. Scope creep here has cost real trust.
Process
- Verify the correct target repo (backend vs frontend, e.g. device-builder vs esphome) before drafting.
- Read the repo's
CONTRIBUTING.md/agents.md/ PR template and follow it. Keep every PR template header, checkbox, and prompt - fill them in, never delete them. - Work from Brandon's fork (
bharvey88). Force-push (--force-with-lease, fork remote only) is fine before review starts. Once anyone has begun reviewing a PR, never force-push it. A force-push makes a reviewer re-figure-out what changed, so the PR gets set aside in favor of the hundreds that didn't. To bring an out-of-date branch current or fix a build break mid-review, merge the base branch in (git merge origin/dev) or add a commit on top — a merge/normal commit shows the diff clearly. Rebase-and-force only on a branch no one is reviewing yet. - For screenshots/GIFs: leave a placeholder like
<!-- Brandon: drop image here -->- he uploads media manually after posting. There is no CLI/API path to PR-body attachments; do NOT work around it by pushing the media to a fork branch and embedding the raw URL - Brandon rejected that (2026-07-26, WLED-Docs #347): the file outlives on his fork and breaks if the branch goes. Placeholder + his drag-and-drop, always. - After submitting, babysit follow-ups when asked: bring a stale branch current with a merge commit (not a rebase-force — see step 3), CI failures via
gh run view --log-failed. - Fixing someone ELSE's open PR (no push perms as outside contributor, even with allow-edits): post a review comment with a
suggestionblock viagh api repos/OWNER/REPO/pulls/N/comments --input body.json(fields: body, commit_id = PR head sha, path, side: RIGHT, line). Author or any maintainer commits it with one click. Get the line number from the head-ref file content, not the diff hunk. - Feature-enable PRs to docs repos: Brandon splits capability from adoption - one PR enables the feature (config/CSS only, zero content pages), a follow-up PR changes the docs to use it ("i dont want to bundle it with any added annotations", WLED-Docs #347/#348). Offer this split whenever a docs PR would mix a new mechanism with content that uses it.
Commit mechanics from the Bash tool (learned 2026-08-22/23)
-
Never chain
pushunconditionally after a validation step.validate; commit; pushshipped a broken commit to a public branch when validation failed (2026-09-05, hub75-studio) because the chain ran regardless. Gate it:if ($code -eq 0) { commit; push } else { show errors }. -
Single-branch clones (
git clone -b X) only fetch-track X, so--force-with-leaseon any other branch fails with "stale info" (no lease baseline). Verify the remote tip yourself (git fetch origin <branch>+git log FETCH_HEAD), then push with an explicit baseline:--force-with-lease=<branch>:<sha>. -
git commit -F <(printf ...)does NOT work from the Bash tool: git dies withcould not read log file '/proc/<pid>/fd/63'and nothing is committed, while; echo OKafterwards still prints. Write the message to a real file in the scratchpad and-Fthat, then confirm withgit log -1. This is the mechanism behind the CLAUDE.md "plain ASCII temp file" rule, not just a PowerShell quirk. -
The
block-coauthorPreToolUse hook decides "is this an Apollo repo" from the command text. A commit with the Apollo footer run from a scratch clone underC:\tmp\(noapollo/ApolloAutomationin the path) gets blocked. Putecho "target repo: ApolloAutomation/<name>"at the front of the command; the retry then passes. Remember the blocked command ran nothing, so re-include every step (aseddropped on retry silently left a version bump unapplied once).
gh CLI on Windows PowerShell 5.1 (bit us 3x, 2026-07)
--jqexpressions containing spaces get split into multiple args by PS native-arg passing ("accepts 1 arg(s), received N"), and embedded double quotes inside single-quoted args get eaten (jq parse errors). Don't fight the quoting: pipegh api ... | ConvertFrom-Jsonand filter in PowerShell.- POST/PUT bodies: build a hashtable,
ConvertTo-Json, write it with[IO.File]::WriteAllText(path, $json, (New-Object Text.UTF8Encoding($false))), thengh api --input path.Out-File -Encoding utf8writes a BOM in PS 5.1 and GitHub rejects the JSON (HTTP 400 "Problems parsing JSON").
esphome/esphome specifics (learned 2026-07)
-
The proof IS the failing test: for bugfix PRs, write a unit test that fails before the fix and passes after, and say so in one sentence. Their PR template treats a linked issue as optional, so a self-proving PR needs no separate issue. Brandon: no verbose wording, "they dont like AI vibe coded stuff".
-
PR title MUST start with a
[tag]prefix or the "Validate PR title" check fails. Use the component name for component work ([sen5x] ...); use[core]for shared/core code that isn't one component (config_validation.py,AGENTS.md, etc.). This bit us on acv.rename_keyPR (title had no tag). "Check blocking labels" failing right after open is usually transient (it only fails onneeds-docs/merge-after-release/chained-prlabels). -
Before pushing: run BOTH
ruff checkandruff format --checkon touched files (aruff checkpass alone is not enough; format failures fail their CI - this bit us once). -
Docstrings must start with a capitalized word. The pre-commit flake8 hook runs pydocstyle, and D403 ("First word of the first line should be properly capitalized") fails on a docstring that opens with a lowercase config key, e.g.
"""hidden without ssid raises.""". Reword to"""Hidden without ssid raises."""or lead with a verb. Bit us on the wifi shorthand PR (2026-08-07). -
Stacked PRs exist there now; use only for genuinely dependent changes. Independent fixes stay separate PRs so either can merge alone.
-
Windows-only bugs: a filesystem-based repro test may pass on their POSIX CI both before and after the fix. Add an OS-independent variant (e.g. monkeypatched glob returning unnormalized paths) so CI actually guards the regression. Raw-backslash fake paths do NOT work as the portable variant - POSIX treats backslash as a filename char and is_file filters drop them.
-
esphome/const.pyis FROZEN (CIlint_const_py_frozeninscript/ci-custom.py). A newCONF_constant shared across 2+ components goes inesphome/components/const/__init__.py(alphabetical, name must match value), imported viafrom esphome.components.const import CONF_X. A constant defined in 3+ files failslint_constants_usage; the usage lint matches definition lines, not imports. -
pre-commit
pylinthook in a git worktree fails withFileNotFoundErrorunless the worktree has avenv/. The hook runsscript/run-in-env.py pylint, which only looks forvenv/,.venv/or./Scripts/activateundergit rev-parse --show-toplevel(the worktree, not the main clone) and otherwise execs barepylint; prepending a venv to PATH from Git Bash did NOT fix it (2026-08-22, #16618). Fix: junction the main clone's venv into the worktree:cmd //c 'mklink /J <worktree>\venv C:\Users\bharv\development\esphome\venv'(venvis gitignored). The main clone's venv is what the installed git hook'sINSTALL_PYTHONpoints at and already haspylint.exe. ruff/flake8/ci-custom are isolated by pre-commit and need nothing. -
Config-key renames:
cv.rename_key(OLD, NEW)silently remaps for back-compat (used inapi). For an honest deprecation, write a warn-then-remap validator (_LOGGER.warning("'x' is deprecated, use 'y'. Will be removed in YYYY.M.0")thenconfig[new]=config.pop(old)) placed first incv.All(...), and check the Breaking-change box. There is nocv.deprecatedhelper. A key rename needs a matching docs PR. Reviewers also want a config-validation test:tests/component_tests/<comp>/test_<comp>.py(+ empty__init__.py) importing the validator and asserting remap +pytest.raises(Invalid)on the collision case (a config specifying both old and new key must raise, not silently drop one). Model ontests/component_tests/aqi/test_aqi.py. -
Name test fixture files for what they exercise, never extend a numbered series. a maintainer on #16618 (2026-08-22):
test_deep_sleep4.yaml"could use a better name" even thoughtest_deep_sleep1..3.yamlalready existed alongside it. Existing numbered files are legacy, not a convention to follow; a new YAML/test goes in astest_<component>_<behavior>.yaml(e.g.test_deep_sleep_nested_wakeup_pin_mode.yaml). Same for test function names. -
Type-hint EVERYTHING (a maintainer enforces this on review, twice this session). Validators:
def _v(config: ConfigType) -> ConfigType:(from esphome.types import ConfigType). Test functions too:def test_x(old_key: str, new_key: str) -> None:— even though older tests likeaqi/test_aqi.pylack hints, that's not the current bar. Add hints up front to avoid a review round-trip. -
Local test/lint venv: the pre-commit
pylinthook and runningtests/component_tests/both need tools installed intoesphome-venvand itsScriptson PATH:pylint==4.0.6,pytest==9.1.1(+ pins inrequirements_test.txt). Run component tests withPYTHONPATH=<worktree> python -m pytest tests/component_tests/<comp>/ -q. -
C++ comments: terse, or a maintainer calls it an "AI essay". On #17847 (2026-08-22) a 15-line header block explaining a 15-line helper (why, return contract, out-param contract, why templated) drew the inline note "trim the AI essay, this repo prefers terse comments". AGENTS.md section 10 allows contract detail, but the bar in practice is a few lines: one line on what it returns, one on any out-param, one on a non-obvious constraint. No restating the PR description in the header, no paragraph breaks between comment blocks. Same rule for test-file comments: one line per test at most.
-
C++ logging conventions (a maintainer enforced these across 3 review rounds on the sen5x model PR, 2026-07-21; canonical doc: developers.esphome.io/architecture/logging/): messages terse, no explanatory caveat sentences (those go in the docs PR), no near-duplicate format strings (each unique string costs flash); no setup-time
ESP_LOGEfor failures thaterror_code_already surfaces viadump_config- set the error code andmark_failed()silently. Macro split:LOG_STR("x")only forLogString*values crossing function boundaries (unwrap at the log site withLOG_STR_ARG); a literal created and consumed inline in one log statement usesLOG_STR_LITERAL("x")directly (it ISLOG_STR_ARG(LOG_STR(x))). Runtime-compared string constants (e.g. strncmp product names) should use flash storage + flash compare helpers on ESP8266-supporting components - acceptable as a followup PR if flagged. -
Code comments: terse, or a maintainer bounces them ("trim the AI essay, this repo prefers terse comments", #17847, 2026-08-22). A header comment is 2 to 4 lines stating the contract (what it returns, preconditions, out-params), not the rationale, the history, or why alternatives lost. Inline comments are one line. Rationale goes in the PR body or the commit message. Apply this to every comment written for esphome before pushing, including ones cherry-picked from a bot branch; the reviewer holds Brandon to it regardless of who wrote it. General comment rules:
writing-voice. -
AGENTS.md mandates the walrus for config access in
to_code:if (x := config.get(CONF_X)) is not None: cg.add(var.set_x(x))- theif CONF_X in config:+config[CONF_X]form is their documented "Bad" example. Read the repo's AGENTS.md before pushing; it is long and opinionated (heap rules, container choices, callback patterns). -
Review babysitting flow: after fixing a review comment, reply in-thread ("Done in ") and resolve the thread via GraphQL
resolveReviewThread; leave reviewer "followup PR" notes unresolved as their marker. Fork-PR authors CANNOT re-request review via REST/GraphQL (404/FORBIDDEN) - only the UI button next to the reviewer's name, which is Brandon's click. The Kōan bot (esphbot) re-reviews on every push and its non-blocking suggestions may be declined with a short rationale comment; never invoke@esphbot rebase(it pushes its own fixes). When a bot suggestion contradicts what a human maintainer asked for in the same review, the human wins. -
Follow-up PR spun out of an open PR (learned 2026-08-22, #17847 -> #18612): when a maintainer declines a suggestion as scope creep ("should be a separate PR"), Brandon's call is "not a chained PR, it just needs to be referenced": branch off the open PR's head, target
devlike any PR, cite the parent in the body, and accept that the Files tab shows the parent's commits until it merges. Don't use thechained-prlabel flow. Each time the parent moves,git mergeit into the follow-up (normal merge commit) and re-run the tests. -
When the parent PR squash-merges, the follow-up's file moves come back (#18612, 2026-08-22). The follow-up renamed files the parent created; after the parent's squash commit lands on
dev, adevmerge (a maintainer did it from the UI) treats the squash as new content and re-adds the old copies next to the moved ones: duplicate helper, duplicate gtest names, stale include, and the reviewer's "still needs some cleanup". Right after the parent merges:git fetch fork <branch>(the maintainer may have pushed to it),git rmevery path the follow-up had moved or deleted, drop the old include, re-run the host tests, confirm withgh pr diff <n> --name-onlythat only the intended files remain. -
Maintainer-side bot patches (Koan /
bluetoothbot): when a maintainer asks the bot for something ("suggest a way to reduce the flash increase") and it posts a ready branch,git fetchit, read the diff, verify (gtests + a real compile), thengit cherry-pickit onto the reviewed PR keeping the bot's authorship, as a normal commit. The bot verifies the sha on its next round. Bot "non-blocking suggestions" go to Brandon as an inventory with a recommendation; he picks ("do 1 and 3"), and deliberately skipped items stay skipped without a reply unless he asks for one. -
The codeowner bot pings
@esphome/coreby itself on any PR touching a path inCODEOWNERS(captive_portal, improv_serial, ...), and the timeline attributes the team review request to the PR author. It is not something we did and not something to "fix"; it happens on every such PR. -
Host C++ unit tests (
tests/components/<comp>/*_test.cpp, gtest): CI runsscript/cpp_unit_test.py <component>; on Windows the script just prints "Skipping unit tests on Windows", so run it from WSL Ubuntu:wsl.exe -d Ubuntu -- bash -lc 'cd /mnt/c/.../<worktree> && ~/esphome-test-venv/bin/python script/cpp_unit_test.py <comp>'(pipe throughtr -d '\0'). If the component's manifest deps don't build on host (web_server_base, ota), addtests/components/<comp>/__init__.pywith anoverride_manifest()that clearsdependencies/auto_load; delete it when the last_test.cppleaves that dir. Always mutation-check a new test (remove the guarded line, expect that one test to fail, restore), the bot checks for untested branches. -
git mvthen editing the moved file leaves the index holding the OLD content. pre-commit (ci-custom namespace lint etc.) runs on the index, so the commit fails against text you already fixed.git add -Athe touched paths before every commit. -
Backslash-escape trap in the Bash tool: a C literal
'\0'(or a doubled\\0) written through a heredoc into python/sed/perl arrives as a real NUL byte in the file, andperl -pi -e 's/\x00/\\0/'fails the same way because its replacement gets collapsed too (git then says "Binary file matches"). Fix escape-free: pythonb.replace(b"\x00", chr(92).encode() + b"0")on the bytes, then verify withod -c. Never trust the echoed diff for that line. -
clang-tidy traps that passed local checks (2026-08-25, sen6x #18780):
bugprone-optional-value-conversion- never assignoptional.value()into another optional, assign the optional directly. Local pre-commit does not run clang-tidy; expect CI to catch a class of bugs the hooks don't. -
dump_configraces async setup. dump_config runs within ~1s of boot; a component whose setup() identifies hardware via chained timeouts finishes later. Gate dump output on config-time facts (e.g. the configuredtype:), never on state the async chain populates (nulled sensor pointers) - the race printed wrong CO2 lines on real hardware before review ever saw it. -
Identical action builders: stacking multiple
@automation.register_actiondecorators on one to_code coroutine is accepted upstream (mhz19); prefer it over N copy-pasted builders. -
Bash-tool cwd persists across commands and worktrees. A fix script plus
git commit --amendran in the WRONG worktree because cwd was left from a prior rebase (2026-08-25); the script's asserts fired before damage, the amend re-committed an identical tree. Alwayscd <worktree> && ...in the same command as any commit/amend/rebase, and put asserts before writes in fix scripts. -
Before posting any reply or PR to esphome, run two passes (Brandon asked for both three times on 2026-08-22, and they caught real errors each time): (1) an adversarial review in a a maintainer persona via a subagent pointed at the actual diff and draft text, ranked BLOCKING / REQUEST CHANGES / NIT with file:line evidence; (2) an AGENTS.md compliance table (sections 4, 7, 8, 9, 10 plus the PR template). The specific failure they prevent: confidently stated claims that were never checked against the source. A third pass Brandon asked for by name on the sen6x redo (2026-08-25): a fable-model agent in the same a maintainer/bot persona, scoped to
git show HEADdiffs only, with already-fixed findings listed in the prompt as do-not-re-report and the output word-capped - cheap, and it caught a same-timeout-ID blocker both earlier passes missed. Offer it before any multi-PR submission. Two in one night: "the lock flag shows/hides the password field" (it only draws a padlock; the input is always rendered) and "no C++ unit test harness exists" (it does). Decode/grep the thing the sentence is about before writing the sentence. -
Replying to a maintainer's top-level PR comment: there is no thread to reply into, so post a new conversation comment that starts with
@handleand quotes the line it answers (> ...). Subscribed participants get notified anyway, but busy maintainers filter to mentions. Pushing commits notifies nobody. When the maintainer ends with "need to think about it", the reply is where the reasoning goes, with the code locations he can verify; a bare "done" leaves him to rediscover it. -
Bot notes a maintainer endorses become separate PRs. When a Koan "optional, separate PR" note gets his "trivial PR worth doing" or "decline scope creep, should be a separate PR", that is an invitation to file it from a fresh branch off
origin/dev, not to fold it into the open PR. Applying the open PR's rule to a sibling code path on another platform (e.g. BK72xx vs ESP32 in the same validator) is also a separate PR when it changes that platform's behavior. -
Check for a sibling session before starting an esphome follow-up.
git -C development/esphome worktree listplusgit branch --list '<name>': on 2026-08-22 a second session had already created the accessor-inline and shared-scan-list branches while this one was planning them. Never touch another session's worktree; a cherry-picked bot commit there keeps the bot as author and cannot be fixed once pushed to a reviewed PR. -
Host C++ unit tests (
script/cpp_unit_test.py <component>) build with gtest + ASan/UBSan on the host and need a Linux toolchain: run from WSL Ubuntu in/mnt/c/...with~/esphome-test-venv(pip bootstrapped via get-pip; the older~/esphome-venvhas no pip andpython3-venvis not installed). Facts that shape the code: target-platform components (wifi, esp32) are skipped on host, so a helper that must be testable cannot depend onwifi::WiFiScanResult; template it on the entry type and test with a stub struct exposing the same accessors (returnStringRefwhere production does). A component whose dependencies do not build on host (web_server_base wants ESPAsyncWebServer.h, ota.web_server wants md5.h) getstests/components/<comp>/__init__.pywithoverride_manifestsettingmanifest.dependencies = []/manifest.auto_load = [], with the actual compile errors named in the comment. Test namespaceesphome::<comp>::testing. Prove the tests guard something with one mutation run (e.g. flip>to>=) before pushing.GTEST_FILTER='Suite*'narrows a run. -
script/test_build_components.pyon Windows needsPYTHONUTF8=1(2026-09-10). It prints a✓during planning, before any compile, and cp1252 stdout raisesUnicodeEncodeError. Piped throughgrep | tailthe loop still exits 0, so a "passed" build that never compiled looks green. Capture$?of the python call itself. Usage:python script/test_build_components.py -e compile -c <comp> -t esp8266-ard --isolate <comp>with esphome-venv Scripts on PATH and cwd = worktree. -
A
cdin one of several parallel Bash calls leaks into the others (2026-09-10): a docsgit commit --no-editwithout its owncdran in a sibling worktree and committed nothing. Every parallel git command gets an explicitcd <worktree> &&orgit -C. -
ESP-IDF target compiles must be launched from PowerShell, not the Bash tool. ESP-IDF's installer aborts with "MSys/Mingw is not supported" under Git Bash;
esphome compilefor esp8266 is fine from either. Keep the test YAML in the session scratchpad and set cwd to the worktree so the local source shadows the installed package (banner shows the dev version).
Hardware-testing someone else's esphome PR (learned 2026-07-25, #17850)
Maintainers sometimes ask Brandon to clear a PR by testing it on real hardware. Flashing and serial are Brandon's job; building, config design and the writeup are Claude's.
- Build setup:
git worktree addthe PR head, put the test YAML outside the worktree (so.esphome/build dirs don't dirty it), and runpython -m esphome compile <cfg>with cwd set to the worktree - the local source then shadows the installedesphomepackage (banner shows the dev version, confirming it took). - Memory delta: only quote a dev-vs-PR RAM/flash diff after checking
git merge-base origin/dev <head>equals dev HEAD; otherwise the delta silently includes other commits. Build the identical YAML in a second worktree atorigin/dev. A compile-only build on an unaffected platform (esp8266) coming out byte identical is cheap, strong evidence. - Prove the code path is actually live, not silently compiled out: grep the generated
.esphome/build/<name>/src/esphome/core/defines.hfor the PR's define plus whatever platform gate gates it (e.g.USE_WIFI_SCAN_RESULTS_LOCK+ESPHOME_THREAD_MULTI_ATOMICS, notESPHOME_THREAD_SINGLE). esphome runpicks the wrong upload target.captive_portalauto-injectsota: platform: web_serverinto the resolved config, so the device chooser offers an OTA entry that dies with "Cannot upload via web_server OTA: the web_server component is not configured". Always pass--device COMx. Check withesphome configwhen an unexpected upload path appears.- Silent serial on C3/S3/C6/H2/P4: the logger defaults to
hardware_uart: USB_SERIAL_JTAG. On a board whose USB port is a CP210x/CH340 bridge that means no output at all; sethardware_uart: UART0. Read the port's driver name in Device Manager to tell which. - Log to a file so Brandon doesn't hand-paste:
esphome logs ... | Tee-Object -FilePath x.log(writes UTF-16, so parse it withGet-Content/Select-String, notgrep), and have any load scriptStart-Transcriptto a file.
Bench-config gotchas (sen6x on ESK-1, 2026-08-25):
- A minimal test YAML must replicate the board's power-rail switches. The Starter Kit feeds its breakout port from a GPIO-switched "Accessory Power" rail; without that switch in the config the sensor is unpowered and clamps I2C ("SCL is held low", empty bus scan) through every reseat and power cycle. When a bus is mysteriously dead under a minimal config, diff against the stock YAML for power/enable pins first, and use "flash the known-good stock YAML" as the isolation test.
binary_sensorstate publishes log at VERBOSE (ESP_LOGV), not DEBUG. A DEBUG-level bench run shows zero publishes and looks like a dead code path; setlogger: level: VERBOSEbefore concluding anything.- On native-USB chips (ESP32-C6 etc.) the reset button drops the serial port. To capture a full boot log, re-run
esphome run(it reattaches at the first boot line) instead of reset-while-attached. - For timing/action features, bake a scripted
on_bootself-test into the bench config: numberedlogger.logmarkers (TEST 1: ... (expect 'Device busy')) with the expected outcome in the marker text. Verification becomes log matching, timing windows get exercised exactly, and the marker block doubles as the PR's test evidence. - Tee every flash to its own log file; PR bodies get a short collapsed
<details>excerpt per PR (boot sequence, one poll cycle, the marker block), never whole logs. - During long bench/multi-PR sessions, keep a live HANDOFF.md next to the test artifacts and update it after each step (Brandon asked for this explicitly, 2026-08-25) - it is the resume point and the test record.
Wifi/captive-portal test rigs specifically. Four harness mistakes cost four reflashes on #17850; check the state machine before designing the config, not after:
- A nonexistent SSID never appears in a scan, so retry treats it as hidden and blind-retries direct connects without rescanning. No scans means no writer activity.
- A real SSID with a wrong password is worse: every failed 4-way handshake kicks all fallback-AP clients off, stranding sockets.
- While the captive portal is active, ESPHome deliberately suppresses scanning (
determine_next_phase_returnsRETRY_HIDDENoncehas_completed_scan_after_captive_portal_start_is set; scanning blocks portal DNS/HTTP). Stock behavior is exactly one scan, at portal start. Forcing scans from a test lambda works but makes the AP nearly unusable, since the radio goes off channel and clients time out. - Unpaced HTTP load exhausts the IDF http server's socket pool and it refuses accepts with
httpd_accept_conn: error in accept (23)(lwIPENFILE), which stops the endpoint under test from being exercised at all. Pace to ~4 req/s withConnection: close(Invoke-WebRequest -DisableKeepAlive).
Writing the report: say what the test proves and what it doesn't. Black-box hammering cannot prove a use-after-free is gone; claim the working behavior (endpoint serves correct data, no deadlock between the two tasks, no regression, memory delta) and state the limit in one sentence. Include harness artifacts that turned out not to be PR bugs, flagged as such, rather than hiding them. Never quote a request tally from an aborted run - drop the number instead of inventing one.
esphome API/protocol changes (learned 2026-08-29, #18881/#18882)
- Live end-to-end demo before filing, when hardware exists. Brandon held two ready PR chains until the feature ran on a real MTR-1 against a patched HA ("if we can test locally we need to do that first"). For user-visible features, build the demo path (device firmware + consuming client) as part of the work, not as an afterthought.
- Proto changes are a 3-repo chain: esphome (api.proto +
script/api_protobuf/api_protobuf.pyregen), aioesphomeapi (its api.proto copy byte-identical +script/gen-protoc), then HA core. aioesphomeapi's PR template: proto changes must land in esphome first. New optional fields get(field_ifdef) = "USE_..."so unused = compiled out; codegen adds the define only when config uses the feature. Precedent to crib: #12136 (supports_response). - Regen gotchas (Windows): esphome's generator needs protoc on PATH plus venv aioesphomeapi >= the requirements.txt pin (older ones miss api_options attrs and crash). aioesphomeapi's regen wants the protoc whose bundled runtime matches the "Protobuf Python Version" header in api_pb2.py (6.30.0 -> protoc 30.0). Both write CRLF via write_text on Windows - normalize every generated file to LF before diffing/committing. Descriptor-offset churn across the whole api_pb2.py is normal.
- aioesphomeapi regen without Docker (2026-09-10, #1891): the repo documents a Docker builder and this PC has no Docker, but Docker is only a way to get protoc 30.0. Download
protoc-30.0-win64.zipfrom the protobuf GitHub releases into the scratchpad (scratchpads are per-session, so re-download each time) and extract it with Pythonzipfile.script/gen-protocfails withgoogle/protobuf/descriptor.proto: File not foundbecause the Windows zip'sinclude/is not on protoc's search path. Run protoc directly with an extra-I <zip>/include(does not change the output), then replicate gen-protoc's two post-steps in bytes: rewriteimport api_options_pb2tofrom . import api_options_pb2, prepend# type: ignore, strip CR. Proof it matches CI's image:api_options_pb2.pycomes out byte-identical to main. A conflict inapi_pb2.pyafter merging main is always a regen, never a hand merge:git checkout origin/main -- aioesphomeapi/api_pb2.py, regen, stage. Gate thegit addon protoc succeeding, or a failed regen silently stages main's copy. - Integration tests (tests/integration/) run in WSL with ~/esphome-test-venv; they consume the installed aioesphomeapi, so a PR whose test asserts new fields stays red in CI until the aioesphomeapi release + requirements.txt bump (state it in the PR body; #12136 did the same dance).
- Fork-referenced stacked PRs work fine (no org membership needed): branch off the open parent's head, target dev, say "builds on #N; new diff is the second commit". The chained-pr label flow is the org-only thing.
HA core changes (WSL test env, learned 2026-08-29)
- HA tests cannot run on Windows (
fcntlimport in the runner). WSL env: uv-managed Python (matchpyprojectfloor, was 3.14.2),uv pip install -e . -r requirements_test.txt+ the integration's manifest deps; extract extra per-domain deps fromrequirements_all.txtby its# homeassistant.components.Xcomments instead of whack-a-mole. - Run
python -m script.translations develop --allbefore any test that loads service descriptions - without it teardown fails "Translation not found" on core services and a whole file looks broken (143 phantom errors vs 143 passed after). - A locally-built aioesphomeapi in the HA venv gets silently clobbered by any later install whose deps pin it (bleak-esphome). Reinstall and verify the import (
UserServiceArg(optional=...)) immediately before trusting a test run or a live demo; AttributeError in_async_register_serviceat runtime = stale copy. - WSL VM lifecycle bites twice: when the last Windows-side handle exits, the VM idle-stops - killing nohup'd processes (dev hass died repeatedly) AND discarding unsynced disk writes (a venv install evaporated after its own success message). Long-lived processes: run attached under a session background task. After installs:
sync. /tmp does not survive VM restarts. - wsl.exe mangles argument paths (MSYS rewrites /home, /tmp, even /mnt/c to C:/Program Files/Git/...). Pipe scripts via stdin:
printf '...' | wsl.exe -d Ubuntu -- bash. Never pass POSIX paths as wsl.exe arguments from Git Bash. - Dev-HA-for-demo recipe: minimal config (homeassistant/frontend/config keys), add the ESPHome integration by IP, localhost: reachable from Windows via WSL relay - but a Windows process squatting the port silently wins on one address family (stale session servers on 8123 did this);
Get-NetTCPConnection -LocalPortfirst.
esphome docs (esphome.io)
- The docs repo is now
esphome/esphome.io(Astro/Starlight,.mdxundersrc/content/docs/components/), NOT the old Sphinxesphome-docs. Brandon's fork is still namedbharvey88/esphome-docs(forked before the rename;gh repo forkreports "already exists"). Default branch iscurrent. - Branch rule (PR template): merge into
nextwhen the docs change matches an unreleased esphome code PR (e.g. a rename whose new key only exists after the firmware ships) - targetingcurrentwould show users a key their installed firmware lacks. Merge intocurrentonly for fixes to already-released behavior. Notes use GitHub> [!NOTE]blockquotes. - The repo has a PR template now (
.github/PULL_REQUEST_TEMPLATE.md, with the next-vs-current checkboxes). Fetch and fill it like any other; don't hand-roll a body. - Every PR gets a Netlify deploy preview. Use it before asking for review: fetch the preview URL for the changed page and confirm the rendered output, including that any new heading anchor actually resolves (
#example-connecting-to-a-hidden-network). Cheaper than a review round-trip over a broken link. - One docs PR per code PR. When the code work is split into several PRs, split the docs the same way from the start. A single combined docs PR can't merge until every code PR has, and the maintainer asked for it to be split (esphome.io#7276, 2026-09-23, split into #7276 + #7427 to #7431). To split an existing combined PR: write each piece off
origin/nextwith no wording changes, confirm the pieces recombine to the original file (git merge-filechain +cmp), trim the original PR with a normal commit (no force-push), and reply to the maintainer with the new PR numbers mapped to their code PRs. Sections added at the same spot conflict when the second one merges; mergenextin then. - esphome.io CI fetches from openhomefoundation.org at build time (referrer allowlist in
prebuild, opengraph images for blog pages). When that host flakes,Buildand the Netlify preview fail on every PR withUNRESOLVED_IMPORT ... allowed-referrers.json(the "keep committed file" fallback is broken, the file is gitignored) orFailedToFetchRemoteImageDimensions. Before touching the PR, checkgh run list --workflow CI --branch current: ifcurrentfailed the same way, it's the outage. Outside contributors can't re-run jobs ("Must have admin rights"); close and reopen the PR re-triggers CI, but only once the host is stable, and tell Brandon it's upstream up front instead of re-triggering blind (2026-09-23: a reopen during the outage came back half red). - The esphome.io husky hook runs
lint-staged, which has no.mdxrule, so.mdx-only commits pass the hook trivially. Never bypass it withcore.hooksPathanyway. - Editing docs via
gh api PUT contents(create fork branch from the target-branch SHA, PUT the file) sidesteps the repo's husky/npm pre-commit hooks; PR CI still validates. Two gotchas from the sen5x docs PR: the base64 body must go through-F content=@file(-fsends the literal@pathstring, 422 "content is not valid Base64"), and the file must be LF-only - their lint hard-fails on any CRLF ("File contains Windows newline"), and round-tripping content through Python stdout on Windows silently converts to CRLF. Strip\rand write bytes.
wled/WLED-Docs
Full workflow lives in the wled-docs skill (repo setup, verification doctrine, CodeRabbit and maintainer dynamics, page-structure rules, mkdocs tooling). The hard gates and commit mechanics in this skill still apply there.
Apollo product repos (MSR-1/2, AIR-1, MTR-1, TEMP-1, PLT-1, BTN-1, PUMP-1, R_PRO-1...)
Feature branch → PR to beta → STOP. A maintainer merges. Never self-merge, never PR straight to main. (The docs repo is different - see apollo-docs.) The eventual beta → main promotion uses a merge commit, not squash, so beta and main share history.
Repo-meta carve-out: changes that don't publish firmware - README, PR template, requirements, datasheets, .github/workflows/* - target main directly ("it's just fixes for the repos themselves"). Keep main and beta copies of ci.yml-type workflow files in agreement, or routine main↔beta merges resurrect old bugs.
Before pushing to an existing PR branch: verify which remote is ApolloAutomation (git remote -v) - clones/worktrees differ (origin is sometimes the fork), and pushing to the wrong remote silently creates a stray fork branch instead of updating the PR.
For upstream OSS PRs: once automated checks pass, open the PR - don't gate on building a manual visual-verification matrix first.
Exception — ApolloAutomation/installer (central web flasher, created 2026-07-08): not a firmware repo. PRs target main (merge auto-deploys Pages staging), no version bump applies, Brandon merges. See memory apollo-installer.
Rolling releases (CI-published pre-releases)
Never name a rolling release tag the same as a branch (e.g. tag beta alongside branch beta). ESPHome remote-package ref: and bare git fetch origin <name> resolve tags before branches, so the tag shadows the branch and freezes users' remote packages at the tag's commit. Apollo convention: the rolling beta pre-release tag is beta-fw. Also: gh release create <tag> without --target tags default-branch HEAD, not the branch you built from.
Editing the firmware YAML itself
Version bump, which file to edit (Core.yaml vs per-variant), ESPHome deprecated-API traps, on_boot merge form, flash-size ceiling, and local esphome config validation all live in the apollo-yaml skill. Use it for any change to the built firmware; come back here for the PR submit mechanics above.