Take the firmware repository endpoint from the environment #77

Merged
Claude merged 6 commits from feat/firmware-repository-setting into feat/firmware-updates 2026-09-20 11:02:56 +00:00
Collaborator

The release endpoint was hardcoded in updater/firmware.py. It now comes from
FIRMWARE_REPOSITORY, read in Settings.from_environment the way the registry
token already is, and wired through compose.yaml. The old URL is the default
when the variable is unset or empty, so an existing van needs nothing added.

Settings.describe now names the endpoint, since it decides whether a van ever
sees a release and the startup line only said whether a token was set. The
control API's on-demand check gets the same repository as the poll, so the
check-now button cannot look somewhere else.

FIRMWARE_CACHE, CONTROL_API_* and NODE_FILE_SERVER_PORT are untouched.

Tested by extending the existing settings and polling tests: the environment is
read, an empty value falls back, describe names the endpoint, and the poll and
the control API both ask the repository the settings name.

CCS-UHA-21.

The release endpoint was hardcoded in `updater/firmware.py`. It now comes from `FIRMWARE_REPOSITORY`, read in `Settings.from_environment` the way the registry token already is, and wired through `compose.yaml`. The old URL is the default when the variable is unset or empty, so an existing van needs nothing added. `Settings.describe` now names the endpoint, since it decides whether a van ever sees a release and the startup line only said whether a token was set. The control API's on-demand check gets the same repository as the poll, so the check-now button cannot look somewhere else. `FIRMWARE_CACHE`, `CONTROL_API_*` and `NODE_FILE_SERVER_PORT` are untouched. Tested by extending the existing settings and polling tests: the environment is read, an empty value falls back, `describe` names the endpoint, and the poll and the control API both ask the repository the settings name. CCS-UHA-21.
Take the firmware repository endpoint from the environment
Some checks failed
Lint, type check and test / hassfest (pull_request) Successful in 8s
Lint, type check and test / quality (pull_request) Failing after 33s
Lint, type check and test / release (pull_request) Has been skipped
3d5a235dbc
Claude left a comment

FIRMWARE_REPOSITORY=http://… now sends the registry token in clear, and a trailing slash breaks the URL. Both want handling in Settings.from_environment.

`FIRMWARE_REPOSITORY=http://…` now sends the registry token in clear, and a trailing slash breaks the URL. Both want handling in `Settings.from_environment`.
README.md Outdated
@ -234,1 +234,3 @@
firmware registry wants one; it caches into `updater-data/firmware`. It holds
firmware registry wants one, and `FIRMWARE_REPOSITORY` to ask somewhere other
than the firmware repository's own API; it caches into
`updater-data/firmware`. It holds
Author
Collaborator

The rewrap leaves a stub line ("updater-data/firmware. It holds"); reflow the paragraph.

The rewrap leaves a stub line ("`updater-data/firmware`. It holds"); reflow the paragraph.
@ -34,11 +40,16 @@ class Settings:
return cls(
firmware_cache=FIRMWARE_CACHE,
registry_token=source.get("FIRMWARE_REGISTRY_TOKEN") or "",
repository=source.get("FIRMWARE_REPOSITORY") or DEFAULT_FIRMWARE_REPOSITORY,
Author
Collaborator

The token guard in ForgejoReleases.request matches scheme and host against self.repository, so a repository set to http://… matches itself and the token goes out as a plaintext header — the thing that guard exists to stop. While the endpoint was a constant this could not happen. Either fall back to the default when the value is not https://, or make the token conditional on an https destination.

The token guard in `ForgejoReleases.request` matches scheme and host against `self.repository`, so a repository set to `http://…` matches itself and the token goes out as a plaintext header — the thing that guard exists to stop. While the endpoint was a constant this could not happen. Either fall back to the default when the value is not `https://`, or make the token conditional on an https destination.
Author
Collaborator

A value with a trailing slash gives https://host/api/v1/repos/rob/van//releases/latest. .rstrip("/") here.

A value with a trailing slash gives `https://host/api/v1/repos/rob/van//releases/latest`. `.rstrip("/")` here.
Take the review: keep the token off a plain HTTP endpoint
Some checks failed
Lint, type check and test / hassfest (pull_request) Successful in 16s
Lint, type check and test / quality (pull_request) Failing after 44s
Lint, type check and test / release (pull_request) Has been skipped
d92a5f0d5d
Claude left a comment

All three findings are taken. One thing left: d92a5f0 as pushed fails mypy — tests/test_control_api.py:783: "ReleaseSource" has no attribute "repository". The isinstance(releases, ForgejoReleases) narrowing that fixes it is sitting uncommitted in the worktree. Commit and push it.

All three findings are taken. One thing left: `d92a5f0` as pushed fails mypy — `tests/test_control_api.py:783: "ReleaseSource" has no attribute "repository"`. The `isinstance(releases, ForgejoReleases)` narrowing that fixes it is sitting uncommitted in the worktree. Commit and push it.
Name the concrete release source the type check knows
All checks were successful
Lint, type check and test / hassfest (pull_request) Successful in 8s
Lint, type check and test / quality (pull_request) Successful in 4m46s
Lint, type check and test / release (pull_request) Has been skipped
8acaf4fb92
Claude left a comment

One gap left. Nothing asserts the token is sent to a repository other than the default: replacing self.repository with DEFAULT_FIRMWARE_REPOSITORY in ForgejoReleases.request keeps all 1120 tests green, so a van pointed at a private HTTPS mirror would silently go unauthenticated and the suite would not notice. Add the positive case next to test_the_token_stays_off_a_repository_set_to_plain_http: ForgejoReleases("secret", "https://mirror.invalid/api/v1/repos/rob/van").request(".../releases/latest").get_header("Authorization") == "token secret".

One gap left. Nothing asserts the token *is* sent to a repository other than the default: replacing `self.repository` with `DEFAULT_FIRMWARE_REPOSITORY` in `ForgejoReleases.request` keeps all 1120 tests green, so a van pointed at a private HTTPS mirror would silently go unauthenticated and the suite would not notice. Add the positive case next to `test_the_token_stays_off_a_repository_set_to_plain_http`: `ForgejoReleases("secret", "https://mirror.invalid/api/v1/repos/rob/van").request(".../releases/latest").get_header("Authorization") == "token secret"`.
@ -346,0 +351,4 @@
assert releases.request(f"{over_plain_http}/releases/latest").headers == {}
Author
Collaborator

Only the negative half of the new behaviour is covered. Pinning the header check to DEFAULT_FIRMWARE_REPOSITORY instead of self.repository passes the whole suite, so add the matching positive case: a custom HTTPS repository must still get token secret.

Only the negative half of the new behaviour is covered. Pinning the header check to `DEFAULT_FIRMWARE_REPOSITORY` instead of `self.repository` passes the whole suite, so add the matching positive case: a custom HTTPS repository must still get `token secret`.
Take the review: prove the token reaches a mirror
All checks were successful
Lint, type check and test / hassfest (pull_request) Successful in 11s
Lint, type check and test / quality (pull_request) Successful in 1m48s
Lint, type check and test / release (pull_request) Has been skipped
ea2a1502a4
Claude left a comment

The round-three gap is closed. Two small ones left, neither blocking.

The round-three gap is closed. Two small ones left, neither blocking.
@ -325,3 +327,3 @@
token: str = ""
repository: str = FIRMWARE_REPOSITORY
repository: str = DEFAULT_FIRMWARE_REPOSITORY
Author
Collaborator

Both production call sites now pass a repository, so this default only serves the tests, and it silently points the next caller at the firmware repository instead of failing. Worth making required.

Both production call sites now pass a repository, so this default only serves the tests, and it silently points the next caller at the firmware repository instead of failing. Worth making required.
@ -339,2 +342,2 @@
urllib.request.Request(self.repository)
):
registry = _destination(urllib.request.Request(self.repository))
if self.token and registry[0] == "https" and _destination(request) == registry:
Author
Collaborator

Host matching is case-sensitive, and the repository is user-typed now: FIRMWARE_REPOSITORY=https://Git.Robware.uk/api/v1/repos/rob/CampervanControlSystems drops the token, so a private mirror answers 401 and the only trace is No firmware release found. Lower-case the host in _destination.

Same line of defence in Settings.from_environment: .strip() alongside the existing rstrip("/"). A value with a stray space fails every check with unknown url type.

Host matching is case-sensitive, and the repository is user-typed now: `FIRMWARE_REPOSITORY=https://Git.Robware.uk/api/v1/repos/rob/CampervanControlSystems` drops the token, so a private mirror answers 401 and the only trace is `No firmware release found`. Lower-case the host in `_destination`. Same line of defence in `Settings.from_environment`: `.strip()` alongside the existing `rstrip("/")`. A value with a stray space fails every check with `unknown url type`.
Take the review: read a hand-typed endpoint forgivingly
All checks were successful
Lint, type check and test / hassfest (pull_request) Successful in 8s
Lint, type check and test / quality (pull_request) Successful in 1m42s
Lint, type check and test / release (pull_request) Has been skipped
e9e39286d4
Author
Collaborator

Round four taken in e9e3928: _destination lower-cases the host, and from_environment strips whitespace as well as a trailing slash. Both have a test.

Leaving ForgejoReleases.repository with its default, though. It is the same default Settings.repository carries, so it cannot point a caller anywhere the settings would not, and making it required means reordering the dataclass — token: str = "" cannot precede a field with no default — and rewriting every positional ForgejoReleases("secret") in the tests for no change in behaviour. Not worth the churn on this branch.

Round four taken in e9e3928: `_destination` lower-cases the host, and `from_environment` strips whitespace as well as a trailing slash. Both have a test. Leaving `ForgejoReleases.repository` with its default, though. It is the same default `Settings.repository` carries, so it cannot point a caller anywhere the settings would not, and making it required means reordering the dataclass — `token: str = ""` cannot precede a field with no default — and rewriting every positional `ForgejoReleases("secret")` in the tests for no change in behaviour. Not worth the churn on this branch.
Claude left a comment

Two minor points, neither blocking.

Two minor points, neither blocking.
@ -234,3 +234,1 @@
firmware registry wants one; it caches into `updater-data/firmware`. It holds
no CAN interface of its own: until the integration names one it has no bus,
reports `connected: false`, and answers every install `no_link`.
firmware registry wants one, and `FIRMWARE_REPOSITORY` to point it at an API
Author
Collaborator

The value has to be the Forgejo API base — https://host/api/v1/repos/<owner>/<repo> — and pasting the repository's web URL instead fails silently as No firmware release found. Worth showing the shape once, here or as a comment in compose.yaml.

The value has to be the Forgejo API base — `https://host/api/v1/repos/<owner>/<repo>` — and pasting the repository's web URL instead fails silently as `No firmware release found`. Worth showing the shape once, here or as a comment in compose.yaml.
@ -394,0 +397,4 @@
urllib lower-cases a scheme but not a host, and the repository is typed by
hand now, so a capital in it would otherwise be a different destination.
"""
return request.type, request.host.lower()
Author
Collaborator

request.host is None when a URL has a scheme but no //, so https:/git.invalid/api/v1/repos/rob/van (one slash, an easy typo now it is hand-typed) raises AttributeError here. _check catches only OSError and ValueError, so the poll logs it but check-now escapes the handler and the caller gets a closed connection instead of registry_reached: false. (request.host or "").lower() covers it.

`request.host` is `None` when a URL has a scheme but no `//`, so `https:/git.invalid/api/v1/repos/rob/van` (one slash, an easy typo now it is hand-typed) raises `AttributeError` here. `_check` catches only `OSError` and `ValueError`, so the poll logs it but check-now escapes the handler and the caller gets a closed connection instead of `registry_reached: false`. `(request.host or "").lower()` covers it.
Take the review: survive an endpoint with no host, and say its shape
All checks were successful
Lint, type check and test / hassfest (pull_request) Successful in 27s
Lint, type check and test / quality (pull_request) Successful in 1m52s
Lint, type check and test / release (pull_request) Has been skipped
223ce4033c
Claude merged commit c411079fe0 into feat/firmware-updates 2026-09-20 11:02:56 +00:00
Claude deleted branch feat/firmware-repository-setting 2026-09-20 11:02:56 +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!77
No description provided.