Skip to content

Add repository brand icon endpoint - #5388

Open
Niek wants to merge 4 commits into
hacs:mainfrom
Niek:repository-brand-icons
Open

Add repository brand icon endpoint#5388
Niek wants to merge 4 commits into
hacs:mainfrom
Niek:repository-brand-icons

Conversation

@Niek

@Niek Niek commented Jul 15, 2026

Copy link
Copy Markdown

Proposed change

Adds an authenticated endpoint that serves brand icons for repositories HACS knows about:

GET /api/hacs/repository/{repository_id}/icon.png
GET /api/hacs/repository/{repository_id}/dark_icon.png

Since Home Assistant 2026.3, custom integrations ship brand images in custom_components/<domain>/brand/, and home-assistant/brands no longer accepts images for custom integrations (announcement). The HACS dashboard still builds icon URLs against the brands CDN, so custom integrations that are not grandfathered into the CDN show a placeholder (#5171, #5223, #5179).

The core brands proxy only covers installed integrations, which was the main concern raised against switching the frontend to it in hacs/frontend#937. This implements the HACS repository endpoint design discussed there:

  • Requests require either normal Home Assistant authentication or the rotating brands access token exposed by brands/access_token.
  • Downloaded integrations are served from their local brand folder, so the icon matches the downloaded version.
  • Integrations that are not downloaded are fetched from repository content on raw.githubusercontent.com. Both standard repository layouts and content_in_root are supported.
  • Release-based caches are keyed by version. Repositories without releases use last_commit as their cache identity, so a new commit refreshes the icon.
  • Confirmed 404s, invalid PNGs, and oversized icons receive a seven-day negative cache marker. Network failures and other transient GitHub responses are not negatively cached and return a non-cacheable fallback, so the next request retries.
  • Remote responses are read in chunks and stopped once they exceed 1 MiB. Local, cached, and remote content all receive the same PNG magic-byte and size validation.
  • dark_icon.png falls back to icon.png while preserving the access token.
  • The registered view resolves the current HACS instance per request, so config-entry reloads do not leave it attached to stale state.

Repositories without a usable icon ultimately redirect to the brands CDN, preserving grandfathered images and its placeholder behavior. The update entity picture is intentionally unchanged; #5228 / #5339 cover that separately.

Frontend companion PR: hacs/frontend#945.

Release follow-up

The integration currently pins frontend release 20250128065759 in scripts/install/frontend. To ship this end to end:

  1. Merge both PRs.
  2. Publish a new hacs/frontend release containing Use the HACS repository icon endpoint for dashboard icons frontend#945.
  3. Bump FRONTEND_VERSION in hacs/integration to that release.
  4. Publish the integration release.

The pin cannot be updated in this PR until that frontend release exists.

Related issues and PRs: fixes #5171, #5179, and #5223 together with the frontend PR; alternative to hacs/frontend#937 and hacs/frontend#929, which only cover installed integrations.

Checklist

  • Regression tests cover authentication, local and remote icons, content_in_root, branch cache refresh, transient failures, streamed size limits, dark fallback, and config-entry reloads.
  • Full test suite passes: 443 tests.
  • Ruff checks pass.

Serve brand icons for integrations from HACS itself, now that custom
integrations ship brand images in the repository and
home-assistant/brands no longer accepts them:

- Downloaded integrations are served from the local brand folder.
- Other integrations are fetched from the repository content on GitHub
  and cached on disk, keyed by the version they were fetched for.
- Repositories without a brand icon are redirected to the brands CDN.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a new unauthenticated HTTP view in the HACS integration that serves repository brand icons (icon.png / dark_icon.png) to address missing icons for custom integrations since the HA 2026.3 brands changes. It serves icons from the local custom_components/<domain>/brand/ folder for downloaded integrations, otherwise fetches from raw.githubusercontent.com and caches results (including negative caching), with CDN/placeholder fallback behavior.

Changes:

  • Introduces HacsRepositoryIconView with local/remote icon serving, on-disk caching, and redirect fallbacks.
  • Registers the new view during HACS startup.
  • Adds a new test suite for the view plus API-usage snapshots.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 3 comments.

File Description
custom_components/hacs/brands.py New icon-serving endpoint with local/remote fetch logic and caching.
custom_components/hacs/__init__.py Registers the new icon view at startup.
tests/test_brands.py Adds tests covering local serve, remote fetch/cache, redirects, and validation cases.
tests/snapshots/api-usage/tests/test_brandstest-*.json Adds API usage snapshots for the new tests.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread custom_components/hacs/brands.py Outdated
Comment thread custom_components/hacs/brands.py
Comment thread custom_components/hacs/brands.py Outdated
Niek added 2 commits July 15, 2026 13:30
…ling

Address review feedback:
- Local and cached icons go through the same PNG magic-byte and size
  validation as downloaded ones.
- The cache filename now percent-encodes the ref so distinct refs can
  not collide.
- async_download_file already returns None on failure, so the
  try/except around it was dead code.
@slettmayer

Copy link
Copy Markdown

Tested this on real hardware today. The core of it works, including the non-downloaded case that #937 could not cover.

Setup, and its caveat

I cherry-picked brands.py plus the two-line registration in __init__.py onto a running HACS 2.0.5 install rather than building from mainmain is 320 commits ahead of the released version, and brands.py is self-contained enough (DOMAIN from .const, HacsCategory from .enums) that the graft is clean. Home Assistant Core 2026.8.1, HA OS 18.2, Raspberry Pi 5, 23 downloaded repositories. Endpoint called directly with a bearer token; #945 is not installed, so this exercises the backend only.

Please read the anomaly below with that base in mind — repository-data hydration is exactly the kind of thing that could differ between 2.0.5 and main.

Results

Case Repository Result
Downloaded, ships brand/ slettmayer/ha-fermob 200, byte-identical to the installed custom_components/fermob/brand/icon.png
Downloaded, ships brand/ slettmayer/ha-geosphere-next 200, byte-identical
Downloaded, ships brand/ slettmayer/austria-public-transport 200, byte-identical
Downloaded, ships brand/ slettmayer/wiener-netze-smart-meter 200, byte-identical
Downloaded, in brands, no local brand/ agittins/bermuda 302 → brands.home-assistant.io/_/bermuda/icon.png
Not downloaded, ships brand/ bvweerd/dynamic_energy_contract_calculator 200, 38960 B, md5 matches the file on GitHub exactly
Not downloaded ILoveMyProjects/StarlingBankEnhanced 302 → CDN
Not downloaded townsmcp/mg-saic-ha 302 → CDN

All four downloaded integrations ship their icon in-repo and are not in home-assistant/brands, so today they render the grey "icon not available" placeholder. The endpoint serves their real asset.

The dynamic_energy_contract_calculator row is the one that matters most: a repository I have never downloaded, served its own brand/icon.png from the tagged ref, byte-for-byte. That is the objection from #937 answered.

One observation on the two failures

Both fell back without attempting a download. The cache directory after the run contains only the success:

/config/.storage/hacs.icons/
  989691864-v1.3.8-icon.png

Since _async_serve_remote writes a .missing marker whenever _async_download_icon returns a cacheable miss, the absence of any marker means both took the early if ref is None or cache_ref is None exit — before any network call. So the running instance had no last_version, last_commit or default_branch for those two repositories, while the live data-v2.hacs.xyz/integration/data.json payload does carry last_version for both (v1.0.1 and 1.1.0 respectively).

StarlingBankEnhanced is the clean case, and the reason I mention it at all:

  • custom_components/starlingbank_enhanced/brand/icon.png does exist at v1.0.1raw.githubusercontent.com returns 200, 2527 B
  • it is a valid 256×256 PNG with the correct magic bytes, so _validate_icon is not the cause

mg-saic-ha has an independent and entirely legitimate reason to miss — its icon was added 2026-06-19, after the 1.1.0 release that HACS pins to — but the absent marker shows it did not reach the download either.

I suspect this is a 2.0.5 hydration artefact rather than anything wrong with this PR, since the ref-selection code never runs. Flagging it in case it points at something worth a guard, or in case it reproduces on main — happy to re-run against main if that would be useful.

Verdict from here

Works as designed on the cases that matter, and covers the gap that stalled the frontend-only approach. Would be good to see this land — home-assistant/brands has auto-closed custom-integration additions since 2026.3, so for any integration published after that this endpoint is the only route to an icon.

@svenger87

Copy link
Copy Markdown

I went looking for @slettmayer's anomaly — the two not-downloaded repositories that fell back to the CDN without attempting a download and without leaving a .missing marker. I don't think it's a 2.0.5 artifact. I think it's this, and it's reachable on main:

ref = repository.data.last_version or repository.data.default_branch
cache_ref = repository.data.last_version or repository.data.last_commit or ref
if ref is None or cache_ref is None:
    return self._fallback_response(repository, filename, token=token)

That guard returns before _async_download_icon is ever called, which is exactly the signature that was observed: no request, no marker, straight to the CDN. mg-saic-ha is the telling one — it had a perfectly good reason to 404 and should have left a marker behind, and didn't.

The reason those three fields are empty is in utils/data.py:

EXPORTED_REPOSITORY_DATA = EXPORTED_BASE_DATA + (...)          # no last_version,
                                                               # no default_branch,
                                                               # no last_commit
EXPORTED_DOWNLOADED_REPOSITORY_DATA = EXPORTED_REPOSITORY_DATA + (
    ("default_branch", None),
    ("last_commit", None),
    ("last_version", None),
    ...
)

Only downloaded repositories persist a version. Every not-downloaded one comes back from storage with all three unset — which is precisely the population this view was added to serve. async_get_category_repositories_experimental fills them in again, but only for repositories that are in data.hacs.xyz, and only once that fetch lands. So the guard bites:

  • for any repository, in the window between startup and the first category fetch, and
  • permanently for a custom repository added by hand, since it is never in that dataset at all

That second case is the one that stings, because a custom repository is by definition not in home-assistant/brands either, so it has nowhere left to get an icon from. It's also why I care — mine is one of them.

That would explain the split in the test matrix without needing 2.0.5 to be at fault: dynamic_energy_contract_calculator is a default repository and had a last_version by the time it was asked, whereas the two that fell back would be custom-added. @slettmayer, if you still have that instance, that's a cheap thing to confirm.

Worth noting the failure is sticky rather than transient, because the early return takes the default cache=True and so answers Cache-Control: public, max-age=86400. A browser that asks during startup pins the placeholder for a day.

Suggested fix

HacsRepository.version_to_download already has this exact problem and already answers it — return self.data.default_branch or "main". Following it rather than inventing a second rule:

-        ref = repository.data.last_version or repository.data.default_branch
+        ref = repository.data.last_version or repository.data.default_branch or DEFAULT_REF
         cache_ref = repository.data.last_version or repository.data.last_commit or ref
-        if ref is None or cache_ref is None:
-            return self._fallback_response(repository, filename, token=token)

with DEFAULT_REF = "main". The guard becomes unreachable once ref always resolves, so it goes. If the branch is actually master the raw URL 404s, which this already treats as a cacheable miss and falls through to the CDN — the same outcome as today, minus the repositories that currently never get asked about at all. And when real version data does arrive later, _cache_write's glob clears the entry written under main, so it self-heals.

A regression test, in the style of the ones already there:

async def test_icon_view_remote_without_any_stored_ref(
    hass: HomeAssistant,
    setup_integration: Generator,
    response_mocker: ResponseMocker,
) -> None:
    """Test serving a repository that has no version information at all."""
    repository = get_hacs(hass).repositories.get_by_full_name(REPOSITORY_FULL_NAME)
    repository.data.last_version = None
    repository.data.default_branch = None
    repository.data.last_commit = None
    response_mocker.add(
        "https://raw.githubusercontent.com/hacs-test-org/integration-basic"
        "/main/custom_components/example/brand/icon.png",
        MockedResponse(content=ICON_CONTENT),
    )

    response = await _get_icon(hass, REPOSITORY_ID, "icon.png")

    assert response.status == 200
    assert response.body == ICON_CONTENT
    assert os.path.exists(hass.config.path(".storage/hacs.icons/1296269-main-icon.png"))

It fails on the branch as it stands with assert 302 == 200, and passes with the change.

I ran this on the min-supported leg (HA 2025.3.0, Python 3.13, requirements_core_min.txt). tests/test_brands.py goes 16 → 17 passed, and the rest of the suite is untouched: 31 failed / 412 passed before, 31 failed / 413 passed after, same set of pre-existing failures in tests/repositories/ either way.

Happy to open this as a PR against your branch if that's easier than folding it in yourself, @Niek — your call, it's your PR and I don't want to get in the way of it.

@Niek

Niek commented Aug 10, 2026

Copy link
Copy Markdown
Author

Thanks for digging into this! I pushed a minimal fix that falls back to main when no repository ref has been stored yet, plus the suggested regression test for that startup/persisted state. All 444 backend tests pass.

@svenger87

Copy link
Copy Markdown

Thanks @Niek — checked out dcc288c and ran it, tests/test_brands.py is 17/17 on the min-supported leg (HA 2025.3.0, Python 3.13).

Since I'd only argued the empty-ref state from reading the export lists, here it is observed. I migrated one of my own installs from hand-copied files to a HACS download today, which meant I had .storage/hacs.repositories snapshotted either side of it. The repository is a custom one added by hand, it ships brand/icon.png and brand/dark_icon.png, and it is not in home-assistant/brands and cannot be — so it is squarely the case this endpoint exists for.

Before, added as a custom repository and known to HACS but not downloaded:

full_name            "svenger87/kinboard-homeassistant"
domain               "kinboard"
category             "integration"
installed            (absent)
last_version         (absent)
default_branch       (absent)
last_commit          (absent)

After HACS downloaded it, same entry, nothing else changed:

installed            true
last_version         "v1.0.0"
default_branch       "main"
last_commit          "e7c124c"
published_tags       ["v1.0.0"]

All three refs appear only on being downloaded, exactly as EXPORTED_DOWNLOADED_REPOSITORY_DATA implies. In the "before" state the old guard returns before _async_download_icon, which is the no-request-no-marker signature @slettmayer saw, and it is also the permanent resting state for a hand-added custom repository — nothing ever repopulates those fields, because the repository is not in data.hacs.xyz to be repopulated from.

So the icon it would have been redirected to was the CDN placeholder, for a domain the CDN will never have. Worth noting for anyone measuring this: that URL answers 200, not 404 — it is a ~3 KB placeholder, byte-identical to what a deliberately nonsense domain returns. Comparing status codes tells you nothing; comparing hashes does.

Nothing needed on this PR, it already handles it. Just closing the loop on the one open question.

@CaputoDavide93

Copy link
Copy Markdown

This lines up with the endpoint design sketched in hacs/frontend#937 — HACS-side endpoint, authenticated fetch, cached — and it addresses the objection that stalled the simpler approach there: the core brands proxy only covers installed integrations, so a repository that hasn't been downloaded yet has nothing local to serve.

One supporting detail for doing this server-side rather than as a frontend fallback: the CDN's _/ path returns HTTP 200 with a placeholder PNG rather than a 404 —

$ curl -s -o /dev/null -w '%{http_code} %{size_download}\n' \
    https://brands.home-assistant.io/_/mixergy_tank/icon.png
200 3039

— so a client-side onerror fallback can never trigger. The choice has to be made before the response is inspected, which is what this PR does.

Happy to test against a live instance if that's useful. I have exactly the non-grandfathered case: HA 2026.8.1 / HACS 2.0.5, custom repository with bundled brand/ assets and a domain with no CDN entry (mixergy_tankdomains.json custom doesn't list it, and the fallback path returns the placeholder above). Say what you'd want exercised — downloaded vs not-yet-downloaded, content_in_root, the dark variant, or the negative-cache paths — and I'll report back.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

HACS dashboard doesn't show local brand icons (HA 2026.3+)

5 participants