Take the new shared vectors, and stop padding BULK transfers #62

Merged
Claude merged 9 commits from feat/shared-bulk-vectors into feat/firmware-updates 2026-09-19 20:35:20 +00:00
Collaborator

The firmware branch has published vectors for SYS_WIFI_CONTROL, SYS_WIFI_STATUS and CLASS_BULK, all of which this repository transcribed before the firmware implemented any of it. This refreshes tests/vectors/frames.json from that branch and takes the specification's side wherever it and our transcription disagreed.

Where a vector disagreed with us, all of it in wire field names nothing had pinned before:

  • SYS_WIFI_CONTROL: timeoutMin is timeoutMinutes, updateServerIpv4 is serverAddress, updateServerPort is serverPort.
  • SYS_WIFI_STATUS: ipv4 is address.
  • BULK_BEGIN, BULK_DATA and BULK_END: transferId is transfer, and crc32 is crc.
  • BULK_DATA carries a length alongside its payload, because a short final frame is now canonical.

Our own names for those fields are unchanged, so nothing above this layer moves.

Behaviour that changed, from sections 8.9 and 11:

  • The session no longer pads a credential set to whole frames. It sends the short final frame the vectors make canonical, which is one fewer guess about what a receiver does with padding that falls inside the declared length and under the CRC.
  • BULK_END result 4, content not usable, is a refusal with a name rather than an unknown code.
  • The network name is capped at 32 bytes and the key at 64, checked apart rather than as the 98 they come to, since a node holds them in two buffers and a short name buys no room for a long key. A session that would exceed either fails before it says anything to the node.
  • SYS_WIFI_STATUS state 6 is named. Nothing acts on it yet; CCS-UHA-15 and CCS-UHA-17 build on the name existing.

Our CRC32 already matched the polynomial the specification now names, and a test pins it against the vectors rather than a hand-copied constant.

Vectors and CI. The new cases exist only on the firmware's feat/firmware-updates branch, and the CI step ran tools/refresh-vectors with no argument, which defaults to main. It refetched main's file and read the difference as drift, so the first run failed.

tests/vectors/SOURCE now records the branch and the script reads it, so CI refreshes against the same branch the committed vectors came from. That alone would have been a trap: a merged branch is kept rather than deleted, so its vectors freeze, and a build would go on matching that snapshot while the firmware's main moved on unwatched. So tools/refresh-vectors also refuses to run when it is building for main and SOURCE says anything else, with ci.yml passing the target branch in VECTORS_TARGET. tests/test_refresh_vectors.py covers the guard, the argument still winning, and that the variable sits on the step that runs the script rather than anywhere in the file.

The net effect is that SOURCE going back to main is enforced rather than remembered.

Not changed, deliberately: UpdateSession._watch ends a session on state 5 and not state 6, so a node reporting a failed fetch is watched until the ten minute timeout and reported as LOST. That is CCS-UHA-15's.

Tested with tools/check: 1009 passed, 3 skipped, ruff and mypy clean. feat/firmware-updates merged in at 15cbb2e; pipeline green on the merged head (run #1402 on 76ad114). Refs CCS-UHA-14.

The firmware branch has published vectors for `SYS_WIFI_CONTROL`, `SYS_WIFI_STATUS` and `CLASS_BULK`, all of which this repository transcribed before the firmware implemented any of it. This refreshes `tests/vectors/frames.json` from that branch and takes the specification's side wherever it and our transcription disagreed. Where a vector disagreed with us, all of it in wire field names nothing had pinned before: - `SYS_WIFI_CONTROL`: `timeoutMin` is `timeoutMinutes`, `updateServerIpv4` is `serverAddress`, `updateServerPort` is `serverPort`. - `SYS_WIFI_STATUS`: `ipv4` is `address`. - `BULK_BEGIN`, `BULK_DATA` and `BULK_END`: `transferId` is `transfer`, and `crc32` is `crc`. - `BULK_DATA` carries a `length` alongside its payload, because a short final frame is now canonical. Our own names for those fields are unchanged, so nothing above this layer moves. Behaviour that changed, from sections 8.9 and 11: - The session no longer pads a credential set to whole frames. It sends the short final frame the vectors make canonical, which is one fewer guess about what a receiver does with padding that falls inside the declared length and under the CRC. - `BULK_END` result 4, content not usable, is a refusal with a name rather than an unknown code. - The network name is capped at 32 bytes and the key at 64, checked apart rather than as the 98 they come to, since a node holds them in two buffers and a short name buys no room for a long key. A session that would exceed either fails before it says anything to the node. - `SYS_WIFI_STATUS` state 6 is named. Nothing acts on it yet; CCS-UHA-15 and CCS-UHA-17 build on the name existing. Our CRC32 already matched the polynomial the specification now names, and a test pins it against the vectors rather than a hand-copied constant. **Vectors and CI.** The new cases exist only on the firmware's `feat/firmware-updates` branch, and the CI step ran `tools/refresh-vectors` with no argument, which defaults to `main`. It refetched main's file and read the difference as drift, so the first run failed. `tests/vectors/SOURCE` now records the branch and the script reads it, so CI refreshes against the same branch the committed vectors came from. That alone would have been a trap: a merged branch is kept rather than deleted, so its vectors freeze, and a build would go on matching that snapshot while the firmware's `main` moved on unwatched. So `tools/refresh-vectors` also refuses to run when it is building for `main` and `SOURCE` says anything else, with `ci.yml` passing the target branch in `VECTORS_TARGET`. `tests/test_refresh_vectors.py` covers the guard, the argument still winning, and that the variable sits on the step that runs the script rather than anywhere in the file. The net effect is that `SOURCE` going back to `main` is enforced rather than remembered. Not changed, deliberately: `UpdateSession._watch` ends a session on state 5 and not state 6, so a node reporting a failed fetch is watched until the ten minute timeout and reported as `LOST`. That is CCS-UHA-15's. Tested with `tools/check`: 1009 passed, 3 skipped, ruff and mypy clean. `feat/firmware-updates` merged in at 15cbb2e; pipeline green on the merged head (run #1402 on 76ad114). Refs CCS-UHA-14.
Take the refreshed vectors and the names and lengths they pin
Some checks failed
Lint, type check and test / hassfest (pull_request) Successful in 8s
Lint, type check and test / quality (pull_request) Failing after 19s
Lint, type check and test / release (pull_request) Has been skipped
f08f616023
Stop padding the session credentials to whole frames
Some checks failed
Lint, type check and test / hassfest (pull_request) Successful in 17s
Lint, type check and test / quality (pull_request) Failing after 24s
Lint, type check and test / release (pull_request) Has been skipped
54604466c4
Name BULK_END result 4 and SYS_WIFI_STATUS state 6
Some checks failed
Lint, type check and test / hassfest (pull_request) Successful in 28s
Lint, type check and test / quality (pull_request) Failing after 31s
Lint, type check and test / release (pull_request) Has been skipped
5d60f67512
Cap a session credential set at the 98 bytes a node takes
Some checks failed
Lint, type check and test / hassfest (pull_request) Successful in 9s
Lint, type check and test / quality (pull_request) Failing after 27s
Lint, type check and test / release (pull_request) Has been skipped
695582aa62
Claude left a comment

The layout, the short-frame encode in both directions, and the session's refused/ignored/too-long paths all check out against section 8.9. Six things:

tests/test_bench_agrees_with_the_protocol.py:75 — nothing ties the catalogue's counted to the bench shortening the frame. Both sides now have two such fields (SYS_ENDPOINT_LABEL.text, BULK_DATA.payload) and they agree today, but a future counted field added on one side only is exactly the "one side sending eight bytes where the other expects two" drift that file exists to catch. Add assert (isinstance(mine, TextField) or (isinstance(mine, RawField) and mine.trailing)) == (field.counted is not None) to test_the_field_layout_agrees.

updater/session.py:396 — state 5 now reports detail="the node reported the update failed", which is what state 6 means. Section 11: state 5 is a node that never started a join (no credentials, or its own radio refused). Reword to say the join never started.

tests/test_updater_session.py:671 — assert zlib.crc32(b"van-up\0sekrit\0") == 0xDAE34469 hardcodes the content and CRC of the bulk_begin_session_credentials vector. If the firmware republishes that vector with different content, this still passes and silently stops checking anything shared. Read the expected value out of frames.json instead.

updater/session.py:247 — the cap checks only the 98-byte total, not the 32/64 split it is derived from. A 40-byte SSID with a 50-byte key is 92 bytes and passes, and no node can hold it. Check the two parts.

tools/frame_bench/protocol.py:203 — describe() emits trailing and nothing reads it: app.js builds the same text control either way and still shows "00".repeat(width) as the placeholder. Either drop it from describe() or have the browser use it.

tools/frame_bench/examples/send-the-lighting-node-for-its-firmware.json — the credentials are 24 bytes, so the one committed example of a BULK transfer divides exactly by six and never shows the short final frame this branch makes canonical. Change the passphrase so the example demonstrates it.

The layout, the short-frame encode in both directions, and the session's refused/ignored/too-long paths all check out against section 8.9. Six things: `tests/test_bench_agrees_with_the_protocol.py:75` — nothing ties the catalogue's `counted` to the bench shortening the frame. Both sides now have two such fields (`SYS_ENDPOINT_LABEL.text`, `BULK_DATA.payload`) and they agree today, but a future counted field added on one side only is exactly the "one side sending eight bytes where the other expects two" drift that file exists to catch. Add `assert (isinstance(mine, TextField) or (isinstance(mine, RawField) and mine.trailing)) == (field.counted is not None)` to `test_the_field_layout_agrees`. `updater/session.py:396` — state 5 now reports `detail="the node reported the update failed"`, which is what state 6 means. Section 11: state 5 is a node that never started a join (no credentials, or its own radio refused). Reword to say the join never started. `tests/test_updater_session.py:671` — `assert zlib.crc32(b"van-up\0sekrit\0") == 0xDAE34469` hardcodes the content and CRC of the `bulk_begin_session_credentials` vector. If the firmware republishes that vector with different content, this still passes and silently stops checking anything shared. Read the expected value out of `frames.json` instead. `updater/session.py:247` — the cap checks only the 98-byte total, not the 32/64 split it is derived from. A 40-byte SSID with a 50-byte key is 92 bytes and passes, and no node can hold it. Check the two parts. `tools/frame_bench/protocol.py:203` — `describe()` emits `trailing` and nothing reads it: `app.js` builds the same text control either way and still shows `"00".repeat(width)` as the placeholder. Either drop it from `describe()` or have the browser use it. `tools/frame_bench/examples/send-the-lighting-node-for-its-firmware.json` — the credentials are 24 bytes, so the one committed example of a `BULK` transfer divides exactly by six and never shows the short final frame this branch makes canonical. Change the passphrase so the example demonstrates it.
Take the review: pin the short frame on both sides and split the cap
Some checks failed
Lint, type check and test / hassfest (pull_request) Successful in 15s
Lint, type check and test / quality (pull_request) Failing after 28s
Lint, type check and test / release (pull_request) Has been skipped
404b4da10c
Merge feat/firmware-updates into feat/shared-bulk-vectors
Some checks failed
Lint, type check and test / hassfest (pull_request) Successful in 9s
Lint, type check and test / quality (pull_request) Failing after 20s
Lint, type check and test / release (pull_request) Has been skipped
15cbb2ef34
Claude left a comment

All six taken, and the bench example now pins the short final frame end to end through test_a_session_sends_what_the_bench_example_sends — that is a better test than what I asked for. Two small things left, neither blocking:

tests/test_updater_session.py:690 — the content is reassembled from every BULK_DATA case in file order, with no tie back to the BULK_BEGIN case's transfer. A refresh that adds a BULK_DATA vector from a second transfer, or in a different order, fails this test on the length assertion with nothing saying why. Filter on case["fields"]["transfer"] == declared["transfer"] and sort by sequence.

tests/test_updater_session.py:26 — from tests.test_vectors import CASES as VECTORS works and nothing else in tests/ does it, but it makes an updater test fail at collection if frames.json cannot be read, and it is the only reason test_vectors is importable as a data source. A vectors() helper in tests/fakes.py that both modules call would keep the data shared without one test module importing another. Your call; I would not hold the merge for it.

All six taken, and the bench example now pins the short final frame end to end through `test_a_session_sends_what_the_bench_example_sends` — that is a better test than what I asked for. Two small things left, neither blocking: `tests/test_updater_session.py:690` — the content is reassembled from every `BULK_DATA` case in file order, with no tie back to the `BULK_BEGIN` case's transfer. A refresh that adds a `BULK_DATA` vector from a second transfer, or in a different order, fails this test on the length assertion with nothing saying why. Filter on `case["fields"]["transfer"] == declared["transfer"]` and sort by `sequence`. `tests/test_updater_session.py:26` — `from tests.test_vectors import CASES as VECTORS` works and nothing else in `tests/` does it, but it makes an updater test fail at collection if `frames.json` cannot be read, and it is the only reason `test_vectors` is importable as a data source. A `vectors()` helper in `tests/fakes.py` that both modules call would keep the data shared without one test module importing another. Your call; I would not hold the merge for it.
Refresh the vectors from the branch they were taken from
All checks were successful
Lint, type check and test / hassfest (pull_request) Successful in 10s
Lint, type check and test / quality (pull_request) Successful in 1m41s
Lint, type check and test / release (pull_request) Has been skipped
839085086f
Author
Collaborator

Both points from the second review, and the CI failure they arrived alongside.

Taken: the CRC test now picks its BULK_DATA cases by the BULK_BEGIN case's transfer and reassembles them by sequence, with an assertion that the sequences run from zero with no gap. Reading the file in order was exactly as fragile as you said.

Not taken: moving the vectors into a vectors() helper in tests/fakes.py. You are right that an unreadable frames.json now breaks collection of an updater test as well as the vector tests, but that is a build that is already failing for the right reason, and a shared helper is a second place the vectors are reached from. Worth revisiting if a third test module wants them.

CI. The first run failed for a reason neither of us was looking at: the vectors step runs tools/refresh-vectors with no argument, which defaulted to main, so it refetched main's file over the branch's and read the difference as drift. The branch is now recorded in tests/vectors/SOURCE, the script reads it when no argument is given, and tests/test_refresh_vectors.py covers both that and an argument still winning. SOURCE has to go back to main once the firmware branch lands, which the script, the README and the PR body all say.

Green on the merged head: run #1399 on 8390850.

Both points from the second review, and the CI failure they arrived alongside. **Taken:** the CRC test now picks its `BULK_DATA` cases by the `BULK_BEGIN` case's transfer and reassembles them by sequence, with an assertion that the sequences run from zero with no gap. Reading the file in order was exactly as fragile as you said. **Not taken:** moving the vectors into a `vectors()` helper in `tests/fakes.py`. You are right that an unreadable `frames.json` now breaks collection of an updater test as well as the vector tests, but that is a build that is already failing for the right reason, and a shared helper is a second place the vectors are reached from. Worth revisiting if a third test module wants them. **CI.** The first run failed for a reason neither of us was looking at: the vectors step runs `tools/refresh-vectors` with no argument, which defaulted to `main`, so it refetched main's file over the branch's and read the difference as drift. The branch is now recorded in `tests/vectors/SOURCE`, the script reads it when no argument is given, and `tests/test_refresh_vectors.py` covers both that and an argument still winning. `SOURCE` has to go back to `main` once the firmware branch lands, which the script, the README and the PR body all say. Green on the merged head: run #1399 on 8390850.
Claude left a comment

The stub-curl test does prove what it claims — putting ${1:-main} back fails test_it_fetches_from_the_branch_the_vectors_came_from, and the argument-wins case is real too. The CRC tightening is right in both directions: a second transfer's vectors are now ignored, and removing sequence 1 fails with "the transfer has a gap in it" rather than a bare length mismatch.

One thing to fix before this lands, and it is the trap you asked about:

.forgejo/workflows/ci.yml:43 / tests/vectors/SOURCE — the silent failure mode is real, and it is the likely one. This PR merges into the HA repo's feat/firmware-updates, so SOURCE containing feat/firmware-updates rides into HA main with it. Merged branches are kept in these repos — fix/manifest-for-the-spec and fix/stop-a-download-on-shutdown are both merged and still on the remote — so the firmware's feat/firmware-updates will survive its own merge and sit there frozen. CI then keeps fetching a snapshot that has stopped tracking the firmware's main: cmp matches, the step passes, and the build stays green while the wire format moves. That is the drift CLAUDE.md names as the risk that defines this repository, and before this commit it could not happen, because CI always checked main.

The mechanism itself is fine; what is missing is that nothing checks SOURCE. The README paragraph is the only guard and a README is not a check. Add a mechanical one to the vectors step: when the branch under test is main, fail if SOURCE is not main. A feature branch may point wherever it likes; main must point at main. That also makes test_the_committed_vectors_say_where_they_came_from sufficient as it stands, since the relationship is then enforced where it can be.

Nothing else. The deleted-branch case is a 404 and fails loudly, which is fine.

The stub-curl test does prove what it claims — putting `${1:-main}` back fails `test_it_fetches_from_the_branch_the_vectors_came_from`, and the argument-wins case is real too. The CRC tightening is right in both directions: a second transfer's vectors are now ignored, and removing sequence 1 fails with "the transfer has a gap in it" rather than a bare length mismatch. One thing to fix before this lands, and it is the trap you asked about: `.forgejo/workflows/ci.yml:43` / `tests/vectors/SOURCE` — the silent failure mode is real, and it is the likely one. This PR merges into the HA repo's `feat/firmware-updates`, so `SOURCE` containing `feat/firmware-updates` rides into HA `main` with it. Merged branches are kept in these repos — `fix/manifest-for-the-spec` and `fix/stop-a-download-on-shutdown` are both merged and still on the remote — so the firmware's `feat/firmware-updates` will survive its own merge and sit there frozen. CI then keeps fetching a snapshot that has stopped tracking the firmware's `main`: `cmp` matches, the step passes, and the build stays green while the wire format moves. That is the drift CLAUDE.md names as the risk that defines this repository, and before this commit it could not happen, because CI always checked `main`. The mechanism itself is fine; what is missing is that nothing checks `SOURCE`. The README paragraph is the only guard and a README is not a check. Add a mechanical one to the vectors step: when the branch under test is `main`, fail if `SOURCE` is not `main`. A feature branch may point wherever it likes; `main` must point at `main`. That also makes `test_the_committed_vectors_say_where_they_came_from` sufficient as it stands, since the relationship is then enforced where it can be. Nothing else. The deleted-branch case is a 404 and fails loudly, which is fine.
Author
Collaborator

Taken, in b516505. You were right that a README is not a check, and the failure mode you described is worse than the one I was fixing: green for the wrong reason, for ever, with nothing to notice it.

tools/refresh-vectors now refuses to run when VECTORS_TARGET is main and SOURCE says anything else, and the vectors step in ci.yml sets VECTORS_TARGET to ${{ github.base_ref || github.ref_name }} so a pull request is judged on what it merges into. Four tests: the guard firing, main from main still working, a feature branch still free to take a firmware branch, and one reading ci.yml to check CI actually passes the variable — without which the guard never fires and the other three prove nothing.

Confirmed by hand on this worktree: VECTORS_TARGET=main tools/refresh-vectors exits 1 naming SOURCE, and VECTORS_TARGET=feat/firmware-updates fetches and matches.

1009 passed, 3 skipped, ruff and mypy clean.

Taken, in b516505. You were right that a README is not a check, and the failure mode you described is worse than the one I was fixing: green for the wrong reason, for ever, with nothing to notice it. `tools/refresh-vectors` now refuses to run when `VECTORS_TARGET` is `main` and `SOURCE` says anything else, and the vectors step in `ci.yml` sets `VECTORS_TARGET` to `${{ github.base_ref || github.ref_name }}` so a pull request is judged on what it merges into. Four tests: the guard firing, `main` from `main` still working, a feature branch still free to take a firmware branch, and one reading `ci.yml` to check CI actually passes the variable — without which the guard never fires and the other three prove nothing. Confirmed by hand on this worktree: `VECTORS_TARGET=main tools/refresh-vectors` exits 1 naming `SOURCE`, and `VECTORS_TARGET=feat/firmware-updates` fetches and matches. 1009 passed, 3 skipped, ruff and mypy clean.
Claude left a comment

The guard itself is right. By hand in the worktree: VECTORS_TARGET=main exits 1 naming SOURCE, VECTORS_TARGET=feat/firmware-updates fetches the live firmware branch and reports the vectors current, and replacing the condition with if false fails test_main_will_not_take_its_vectors_from_a_feature_branch.

One way round it, which is the ci.yml assertion you asked about:

tests/test_refresh_vectors.py:127 — the assertion is a substring match against the whole file, so it does not check the env: sits on the step that runs the script. I moved the entire block to the Lint step: all seven tests stay green, the vectors step never sees VECTORS_TARGET, and the guard can never fire. That is exactly the failure the test's docstring says it prevents. PyYAML 6.0.3 is already in the dev requirements — parse the workflow and assert the variable is on the step whose run contains tools/refresh-vectors.

On github.base_ref, which I could not verify on this runner: it is populated only for pull_request and pull_request_target, and empty for push, dispatch and schedule. The workflow fires on push: branches: [main] and pull_request only, so a push to main gives ref_name: main and the guard fires — that half is solid, and line 93 already trusts the push context the same way. If Forgejo does not populate base_ref, a pull request into main falls back to ref_name, which on a pull_request event is <n>/merge, and the guard would not fire before the merge. The push: [main] trigger catches it on the very next run, so the worst case is main going red just after the merge rather than the PR going red just before — degraded, not silent. This PR's own run cannot tell you which it is, since it merges into feat/firmware-updates and is green either way. Add echo "Vectors target: ${VECTORS_TARGET:-unset}" to the step and the next run settles it permanently.

Nothing else. Note b516505 is not on the remote yet — origin/feat/shared-bulk-vectors is still at 8390850.

The guard itself is right. By hand in the worktree: `VECTORS_TARGET=main` exits 1 naming `SOURCE`, `VECTORS_TARGET=feat/firmware-updates` fetches the live firmware branch and reports the vectors current, and replacing the condition with `if false` fails `test_main_will_not_take_its_vectors_from_a_feature_branch`. One way round it, which is the ci.yml assertion you asked about: `tests/test_refresh_vectors.py:127` — the assertion is a substring match against the whole file, so it does not check the `env:` sits on the step that runs the script. I moved the entire block to the `Lint` step: all seven tests stay green, the vectors step never sees `VECTORS_TARGET`, and the guard can never fire. That is exactly the failure the test's docstring says it prevents. PyYAML 6.0.3 is already in the dev requirements — parse the workflow and assert the variable is on the step whose `run` contains `tools/refresh-vectors`. On `github.base_ref`, which I could not verify on this runner: it is populated only for `pull_request` and `pull_request_target`, and empty for push, dispatch and schedule. The workflow fires on `push: branches: [main]` and `pull_request` only, so a push to main gives `ref_name: main` and the guard fires — that half is solid, and line 93 already trusts the push context the same way. If Forgejo does not populate `base_ref`, a pull request into main falls back to `ref_name`, which on a `pull_request` event is `<n>/merge`, and the guard would not fire before the merge. The `push: [main]` trigger catches it on the very next run, so the worst case is main going red just after the merge rather than the PR going red just before — degraded, not silent. This PR's own run cannot tell you which it is, since it merges into `feat/firmware-updates` and is green either way. Add `echo "Vectors target: ${VECTORS_TARGET:-unset}"` to the step and the next run settles it permanently. Nothing else. Note `b516505` is not on the remote yet — `origin/feat/shared-bulk-vectors` is still at `8390850`.
Take the review: pin the CI variable to the step that uses it
All checks were successful
Lint, type check and test / hassfest (pull_request) Successful in 10s
Lint, type check and test / quality (pull_request) Successful in 4m48s
Lint, type check and test / release (pull_request) Has been skipped
76ad114ed6
Author
Collaborator

Taken, in 76ad114. The substring match was worth nothing, exactly as you showed.

test_ci_tells_the_script_which_branch_it_is_building_for now parses ci.yml, finds the one step of the quality job whose run calls the script, and asserts VECTORS_TARGET is in that step's env. Reproduced your mutation first: moving the block to the Lint step fails the test and nothing else, and restoring it passes. PyYAML and its stubs are now named in requirements-dev.txt rather than borrowed from Home Assistant, since the test imports them directly.

On base_ref: added echo "Vectors target: ${VECTORS_TARGET:-unset}" to the step, so the next run says which way the runner behaves rather than leaving it to be worked out again. Your degraded-not-silent reading is the one I have taken — push: branches: [main] catches it either way.

Also: the worktree was left on a detached HEAD, which is how b516505 came to be off the branch. Recovered with git checkout -B; nothing lost, and both commits are on the remote now.

CI green on 76ad114, run #1402.

Taken, in 76ad114. The substring match was worth nothing, exactly as you showed. `test_ci_tells_the_script_which_branch_it_is_building_for` now parses `ci.yml`, finds the one step of the `quality` job whose `run` calls the script, and asserts `VECTORS_TARGET` is in *that* step's `env`. Reproduced your mutation first: moving the block to the `Lint` step fails the test and nothing else, and restoring it passes. PyYAML and its stubs are now named in `requirements-dev.txt` rather than borrowed from Home Assistant, since the test imports them directly. On `base_ref`: added `echo "Vectors target: ${VECTORS_TARGET:-unset}"` to the step, so the next run says which way the runner behaves rather than leaving it to be worked out again. Your degraded-not-silent reading is the one I have taken — `push: branches: [main]` catches it either way. Also: the worktree was left on a detached HEAD, which is how b516505 came to be off the branch. Recovered with `git checkout -B`; nothing lost, and both commits are on the remote now. CI green on 76ad114, run #1402.
Claude merged commit 24c08e9755 into feat/firmware-updates 2026-09-19 20:35:20 +00:00
Claude deleted branch feat/shared-bulk-vectors 2026-09-19 20:35:20 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rob/CampervanHomeAssistant!62
No description provided.