mirror of
https://github.com/home-assistant/core.git
synced 2026-09-25 17:04:04 -04:00
sandbox/plans: add translation-forwarding brainstorm + plan
Brainstorm → plan for forwarding a sandboxed integration's translations into main: live pull-RPC (Phase B) for running integrations + a catalog provider (Phase A) for picker discoverability. Includes interview, research notes, scratchpad, and the phased plan. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
595c7077ac
commit
b7d9bf6a5b
@@ -0,0 +1,200 @@
|
||||
# Brainstorm — Sandbox translation forwarding
|
||||
|
||||
## Topic
|
||||
|
||||
Forward an integration's translations from the sandbox subprocess into the
|
||||
main HA instance so the frontend renders translated strings for sandboxed
|
||||
integrations — entity names, entity-state translations, config/options flow,
|
||||
selectors, services, exceptions, issues, etc.
|
||||
|
||||
## Problem / Why
|
||||
|
||||
The sandbox runs an integration's code (setup, config flow, entities,
|
||||
services) in an isolated subprocess while main keeps the unified frontend.
|
||||
Translations in HA are loaded from each integration's on-disk
|
||||
`translations/<lang>.json`, **keyed by integration domain**, and served to the
|
||||
frontend via `frontend/get_translations`. For sandboxed integrations the
|
||||
strings don't reach the frontend today:
|
||||
|
||||
- **Built-in** integrations: the translation files *do* exist on main's disk
|
||||
(same bundled `homeassistant` package), but the domain isn't in main's
|
||||
`hass.config.components` (it ran in the sandbox), so the entity/state
|
||||
category never loads for it. Files are present; loading is the gap.
|
||||
- **Custom** (HACS) integrations: the code — and its `translations/` dir — is
|
||||
fetched into the *sandbox's* `<config>/custom_components/<domain>` per the
|
||||
integration-source design. The files **do not exist on main at all**.
|
||||
Strings genuinely have to cross the wire. Core's `title`→`integration.name`
|
||||
fallback (`translation.py:119-124`) also can't run on main (no `Integration`
|
||||
object for the custom domain).
|
||||
|
||||
## Grounding (code facts)
|
||||
|
||||
- `homeassistant/helpers/translation.py`
|
||||
- `_TranslationCache` is lazy and **per-language**; once a (language, domain)
|
||||
is loaded it's cached forever (translations are never unloaded).
|
||||
- `_async_load` → `async_get_integrations` (needs the `Integration` object)
|
||||
→ `_async_get_component_strings` (reads `translations/<lang>.json` from the
|
||||
integration's dir). Both steps break for a custom domain absent on main.
|
||||
- `async_get_translations(..., category, integrations, config_flow)`:
|
||||
with `config_flow=True` and no integration list,
|
||||
`components = async_get_config_flows(hass) - hass.config.components`
|
||||
→ a **bulk** set of every config-flow integration.
|
||||
- Categories are **not a fixed enum** — `build_resources` slices whatever
|
||||
top-level keys exist in the strings file (`config`, `options`, `selector`,
|
||||
`title`, `entity`, `state`, `services`, `exceptions`, `issues`, …).
|
||||
- `title` fallback: missing `title` ⇒ `component.<domain>.title =
|
||||
integration.name` (line 119-124). Unavailable for custom on main.
|
||||
- Frontend (`../frontend`):
|
||||
- Add-integration **picker**: `dialog-add-integration.ts:606,640`
|
||||
→ `loadBackendTranslation("title", discoveredHandlers, true)` — loads only
|
||||
the **`title`** category, in **bulk**, for all handlers; names also come
|
||||
from the static integrations index (`integration.name` /
|
||||
`domainToName`). No `config` category at picker time.
|
||||
- **Running** flow: `show-dialog-config-flow.ts:29-46`
|
||||
→ loads `config` + `selector` + `title` for a **single** `handler`. By
|
||||
then that integration's sandbox is alive.
|
||||
- Existing extensibility pattern to mirror: `sources.py`
|
||||
`async_register_sandbox_source_resolver` (HACS-agnostic resolver hook that
|
||||
maps a custom domain → git source). The `router` attribute on
|
||||
`ConfigEntries` and the `current_sandbox` ContextVar are the other
|
||||
precedents for a small declared core hook.
|
||||
|
||||
## Decisions so far
|
||||
|
||||
1. **Scope = both groups, unified path** (user). One conceptual mechanism for
|
||||
built-in and custom rather than per-group branching — subject to the picker
|
||||
carve-out below.
|
||||
2. **Transport = pull / RPC on demand** (user). Main intercepts translation
|
||||
loading for a sandboxed *running* domain and issues a
|
||||
`sandbox/get_translations(domain, language)` RPC; the sandbox reads its
|
||||
local `translations/<lang>.json` and returns it. Matches the lazy
|
||||
per-language cache; only fetches what the frontend asks for; couples
|
||||
availability to the sandbox being alive (it is, when entities/flows are
|
||||
active).
|
||||
3. **Picker is a separate, static seam** (user insight). The add-integration
|
||||
dialog only needs the **`title`** category and must work when no sandbox is
|
||||
running. So:
|
||||
- built-in → main reads `title` from its own disk (unchanged);
|
||||
- custom → the **source resolver / index** supplies the picker `title`
|
||||
(and any minimal picker strings), exactly like it already supplies the
|
||||
git install source. No sandbox spawn to render the picker.
|
||||
- Once the user *starts* a flow, the sandbox spawns and the full `config` /
|
||||
`selector` / `title` strings come over the live pull-RPC.
|
||||
|
||||
## Resulting shape (two seams)
|
||||
|
||||
- **Seam A — static picker strings (no live sandbox):** extend the
|
||||
integration-source resolver/index so a custom integration contributes its
|
||||
`title` (picker name + minimal strings) to main. Built-in stays disk-served.
|
||||
- **Seam B — live pull-RPC (sandbox running):** a declared core hook in the
|
||||
translation loader routes a sandboxed domain's (language) strings request to
|
||||
the bridge → `sandbox/get_translations` → sandbox reads local file → returns
|
||||
the whole strings dict; main caches it in the existing `_TranslationCache`
|
||||
and `build_resources` slices by category as usual.
|
||||
|
||||
## Open questions / edges
|
||||
|
||||
- **RPC granularity:** return the *whole* strings dict for (domain, language)
|
||||
in one shot (simplest; main slices) vs per-category. Whole-dict matches how
|
||||
main reads the file today.
|
||||
- **Domain → sandbox-group resolution on main** for a running domain: derive
|
||||
from `ConfigEntry.sandbox` / the bridge's registered-entity map. Pre-entry
|
||||
(flow in progress) the router already knows the group.
|
||||
- **Cache invalidation on custom-integration update** (new sha → changed
|
||||
strings): core never invalidates translation cache; a sandbox restart with a
|
||||
new ref may need an explicit drop of cached (domain, *) strings.
|
||||
- **Sandbox liveness for non-flow loads:** entity/state/exceptions strings for
|
||||
a loaded sandboxed entry — sandbox is running, fine. Guard the core hook to
|
||||
only redirect domains that are actually sandboxed-and-owned, so a
|
||||
not-running / main domain still reads disk.
|
||||
- **`title` fallback** (`integration.name`) for custom on main — must come
|
||||
from Seam A, since main has no `Integration` object.
|
||||
- **Custom-integration discovery on main** (whether a custom domain even
|
||||
appears in `async_get_config_flows` when its code lives only in the sandbox)
|
||||
is an adjacent unknown that Seam A's index likely also has to feed.
|
||||
|
||||
## Research findings (2 agents)
|
||||
|
||||
Full notes: `research/translation-forwarding-core-seam.md`,
|
||||
`research/translation-forwarding-discovery-and-index.md`.
|
||||
|
||||
### Seam B — live pull-RPC (sandbox running) — well-supported
|
||||
|
||||
- **Silent-vanish today:** `async_get_integrations` returns `IntegrationNotFound`
|
||||
*as the dict value* (not raised, not cached — `loader.py:1441-1447`);
|
||||
`_async_load` skips Exception-valued domains (`translation.py:221-227`);
|
||||
`_async_get_component_strings` does `integrations.get(domain)` → a custom
|
||||
sandboxed domain silently yields `{}` and its frontend strings just disappear.
|
||||
- **Recommended seam:** branch **inside `_async_load`**
|
||||
(`translation.py:208-253`) right after `async_get_integrations`, overlaying the
|
||||
RPC result onto `translation_by_language_strings` *before*
|
||||
`_build_category_cache` (which owns EN-fallback expansion + cache bookkeeping
|
||||
the sandboxed domain must share).
|
||||
- *Antithesis:* runs under the cache lock → a per-domain RPC serialises
|
||||
latency-sensitive frontend loads. Mitigate: **batch per group**, and
|
||||
**degrade to empty on a dead channel** (never block the picker/frontend).
|
||||
- **RPC shape:** `{language: {domain: raw strings.json dict}}`, un-flattened,
|
||||
with `title` pre-filled **sandbox-side** (main can't run the
|
||||
`integration.name` fallback at `translation.py:118-124` for a custom domain).
|
||||
- **Domain→group is already wired:** `ConfigEntry.sandbox`
|
||||
(`config_entries.py:432`) → `SandboxData.bridges[group]`
|
||||
(`sandbox/__init__.py:38-45`); pre-entry (flow in progress) the group comes
|
||||
from the active `SandboxFlowProxy` / `_assignment_for_new_flow`
|
||||
(`router.py:189-201`). No new index needed for the live path.
|
||||
- **Cache invalidation:** there is **no eviction API** — `loaded`/`cache` only
|
||||
grow (`translation.py:168-171`). A custom integration re-fetched at a new
|
||||
commit sha (changed strings) needs a new `async_invalidate(components)` to
|
||||
drop stale `(domain, *)` entries. Minimal addition.
|
||||
|
||||
### Seam A — picker — bigger than translations
|
||||
|
||||
- **Reframe:** the picker does **not** use `async_get_config_flows`. It calls
|
||||
WS `integration/descriptions` → `async_get_integration_descriptions`
|
||||
(`loader.py:416-460`), built from the generated `integrations.json` (core) +
|
||||
`async_get_custom_components` — a **disk scan of `<config>/custom_components`**
|
||||
(`loader.py:325-343`). A custom integration whose code lives **only in the
|
||||
sandbox appears in none of these** (descriptions, config-flows, or index).
|
||||
*"Today the picker works by accident because HACS still drops code on disk;
|
||||
the stateless-sandbox future breaks it."* So the picker gap is
|
||||
**discoverability**, not just translation — the `title` strings are a subset
|
||||
of the catalog metadata main is missing.
|
||||
- **Recommended shape:** a **separate** `async_register_sandbox_catalog_provider`
|
||||
hook (eager, enumerable, display-only) rather than overloading the
|
||||
security-critical, sha-pinned `IntegrationSourceDict` source resolver. Core
|
||||
merges the catalog into `async_get_integration_descriptions` + the `title`
|
||||
fallback; HACS fills it.
|
||||
- *Antithesis:* HACS reliably has the manifest `name` but may **not** have
|
||||
`translations/*.json` indexed (they live inside the un-fetched tarball) — so
|
||||
`title_translations` must be **optional**, degrading to `name` via the
|
||||
existing fallback. A wrong/missing name is cosmetic (unlike `ref`), so no
|
||||
strict validation needed.
|
||||
|
||||
## Converged approaches
|
||||
|
||||
**Approach 1 — Seam B first (live pull-RPC only).** Self-contained,
|
||||
shippable. Covers entity names/state, running config/options flow, selectors,
|
||||
services, exceptions, issues for any sandboxed integration whose sandbox is
|
||||
alive. Built-in flows work (they run in the sandbox). Custom integrations work
|
||||
*once their flow is running / entry is loaded*. Does **not** fix the
|
||||
not-running picker for sandbox-only customs — but that's already broken for
|
||||
*discoverability* independent of translations, so this doesn't regress
|
||||
anything. Smallest core surface: one `_async_load` branch + a provider hook +
|
||||
`async_invalidate`.
|
||||
|
||||
**Approach 2 — Both seams (B + catalog provider A).** Adds
|
||||
`async_register_sandbox_catalog_provider` so stateless custom integrations are
|
||||
discoverable *and* titled in the picker without a sandbox. Larger surface;
|
||||
overlaps the broader "how do sandbox-only custom integrations appear on main"
|
||||
question, which is arguably its own feature beyond translations.
|
||||
|
||||
**Open sub-decisions (either approach):**
|
||||
- Built-in in Seam B: read main's local disk (skip the redundant RPC, one
|
||||
`is_built_in` branch) vs uniform pull. Research leans local-disk for built-in
|
||||
— main has byte-identical files.
|
||||
- Cache invalidation now (`async_invalidate` on sha change) vs defer.
|
||||
- RPC granularity: whole strings dict per (domain, language) — confirmed
|
||||
simplest, matches how main reads the file.
|
||||
|
||||
## Coverage
|
||||
|
||||
What 2/2 · Why 2/2 · Scope 2/2 · Where 2/2 · How 2/2 · Edge 2/2 (12/12)
|
||||
@@ -0,0 +1,191 @@
|
||||
# Plan — Sandbox translation forwarding (both seams)
|
||||
|
||||
> Source: `/phx:brainstorm` → `/phx:plan` (adapted to Python / Home Assistant
|
||||
> core — not Elixir/Phoenix). Brainstorm + research are complete; this plan
|
||||
> consumes them rather than re-deriving.
|
||||
>
|
||||
> Inputs: `interview-translation-forwarding.md`,
|
||||
> `research/translation-forwarding-core-seam.md`,
|
||||
> `research/translation-forwarding-discovery-and-index.md`.
|
||||
> Scratchpad: `scratchpad-translation-forwarding.md`.
|
||||
>
|
||||
> **Locked decisions:** both seams in scope · pull/RPC transport · built-in
|
||||
> reads local disk in the live path (RPC reserved for customs) · separate
|
||||
> catalog-provider hook for the picker · whole-strings-dict RPC granularity.
|
||||
|
||||
## Problem
|
||||
|
||||
A sandboxed integration runs in an isolated subprocess; its
|
||||
`translations/<lang>.json` is keyed by integration domain and served to the
|
||||
frontend via `frontend/get_translations`. Today those strings don't reach the
|
||||
frontend:
|
||||
|
||||
- **Custom** sandboxed integration → main has no `Integration` object;
|
||||
`async_get_integrations` returns `IntegrationNotFound` *as the dict value*
|
||||
(`loader.py:1441-1447`), `_async_load` skips Exception-valued domains
|
||||
(`translation.py:221-227`), so strings silently vanish (`{}`).
|
||||
- **Picker** → the add-integration dialog is built from `integration/
|
||||
descriptions` (a disk scan of `<config>/custom_components`,
|
||||
`loader.py:325-343,416-460`); a sandbox-only custom appears in *none* of
|
||||
main's lists — a **discoverability** gap, of which `title` is a subset.
|
||||
|
||||
## Goal & success criteria
|
||||
|
||||
- [ ] Live: entity names/state, running config/options flow, selectors,
|
||||
services, exceptions, issues resolve for built-in *and* custom sandboxed
|
||||
integrations whose sandbox is alive.
|
||||
- [ ] Picker: a sandbox-only custom integration is discoverable + named in the
|
||||
add-integration dialog with **no sandbox spawn**.
|
||||
- [ ] No regression for non-sandboxed integrations (disk path unchanged).
|
||||
- [ ] **Iron Law:** public declared hooks only — no monkey-patching private
|
||||
internals (the sandbox subsystem's standing rule).
|
||||
|
||||
---
|
||||
|
||||
## Phase B — live pull-RPC (ship first; self-contained)
|
||||
|
||||
### B1 · Wire protocol `[protocol]`
|
||||
- [ ] Add `sandbox/get_translations` to `proto/sandbox.proto`; regenerate
|
||||
`_proto/sandbox_pb2.py(i)` via `proto/generate.sh` (run
|
||||
`proto/check_drift.sh`).
|
||||
- [ ] Mirror the constant in **both** `protocol.py` files
|
||||
(`homeassistant/components/sandbox/protocol.py` +
|
||||
`sandbox/hass_client/hass_client/protocol.py`).
|
||||
- [ ] Shapes — request `{ language: str, domains: [str] }` (batched per group);
|
||||
response `{ language: str, strings: { domain: <raw strings.json dict> } }`,
|
||||
un-flattened, `title` pre-filled.
|
||||
|
||||
### B2 · Sandbox handler `[hass_client]`
|
||||
- [ ] Register a `sandbox/get_translations` handler in `SandboxRuntime`.
|
||||
- [ ] For each domain, load raw strings for `language` from the sandbox's own
|
||||
filesystem — built-in from the bundled package, custom from the fetched
|
||||
`<config>/custom_components/<domain>` — reusing core's
|
||||
`_async_get_component_strings` / `component_translation_path` against the
|
||||
sandbox's private `hass`, or reading the file directly.
|
||||
- [ ] **Pre-fill `title`**: if absent, inject `integration.name` (the sandbox
|
||||
*has* the `Integration`; `translation.py:118-124` can't run on main for
|
||||
customs).
|
||||
|
||||
### B3 · Core translation hook `[core: homeassistant/helpers/translation.py]`
|
||||
- [ ] Add `async_register_sandbox_translation_provider(hass, provider)`,
|
||||
mirroring the `sources.py` resolver convention (HassKey + unregister
|
||||
callback). `provider(language, components) -> {language: {domain: raw_strings}}`,
|
||||
returning only the domains it owns.
|
||||
- [ ] In `_TranslationCache._async_load` (`:208-253`), **after**
|
||||
`async_get_integrations` and **before** `_build_category_cache`, call the
|
||||
provider and overlay its result onto `translation_by_language_strings`.
|
||||
Provider-claimed domains bypass the disk/`IntegrationNotFound` path;
|
||||
everything else unchanged.
|
||||
- [ ] **Batch per group** + **degrade to empty on a dead channel** — the
|
||||
overlay runs under the cache lock; never block the frontend.
|
||||
- [ ] Add `_TranslationCache.async_invalidate(components)` (+ module wrapper):
|
||||
discard from `loaded[*]` and `del cache[*][*][component]` (no eviction API
|
||||
exists today — `:168-171`).
|
||||
|
||||
### B4 · Provider impl + registration `[sandbox: bridge.py / __init__.py]`
|
||||
- [ ] Implement the provider: domain → group via `ConfigEntry.sandbox`
|
||||
(`config_entries.py:432`) for loaded entries, or the active
|
||||
`SandboxFlowProxy` / `_assignment_for_new_flow` (`router.py:189-201`) for a
|
||||
flow in progress; group → bridge via `SandboxData.bridges[group]`
|
||||
(`sandbox/__init__.py:38-45`); issue the batched RPC; `{}` for
|
||||
unowned/unreachable domains.
|
||||
- [ ] **Built-in carve-out:** return nothing for `Integration.is_built_in`
|
||||
domains — main reads its byte-identical disk files. One branch.
|
||||
- [ ] Register the provider in `async_setup`; unregister on unload.
|
||||
- [ ] Call `async_invalidate({domain})` on entry reload / sandbox restart at a
|
||||
new integration-source `ref` (strings may have changed).
|
||||
|
||||
---
|
||||
|
||||
## Phase A — catalog provider (picker discoverability + title)
|
||||
|
||||
> The picker gap is discoverability, not just translation — `title` is a subset
|
||||
> of the catalog metadata main lacks for a sandbox-only custom.
|
||||
|
||||
### A1 · Core hook `[sandbox: sources.py (or sibling)]`
|
||||
- [ ] Add a **separate** `async_register_sandbox_catalog_provider(hass,
|
||||
provider)` — display-only, enumerable; do **not** overload the sha-pinned,
|
||||
security-critical `IntegrationSourceDict` (`sources.py:38-56`). Entry shape:
|
||||
`{ domain, name, config_flow, integration_type, iot_class,
|
||||
single_config_entry, title_translations?: {lang: str} }`.
|
||||
`title_translations` **optional** (HACS may not index the un-fetched
|
||||
tarball's `translations/`); absent ⇒ degrade to `name`.
|
||||
|
||||
### A2 · Merge into descriptions `[core: homeassistant/loader.py]`
|
||||
- [ ] Append catalog entries to the custom half of
|
||||
`async_get_integration_descriptions` (`:416-460`) so the picker lists them.
|
||||
- [ ] Use catalog `name` / `title_translations` in the `title` fallback chain
|
||||
when no on-disk `Integration` exists for a custom domain.
|
||||
|
||||
### A3 · HACS contract `[docs]`
|
||||
- [ ] Core exposes the hook; HACS fills it (HACS-agnostic posture, same as the
|
||||
source resolver). Wrong/missing name is cosmetic — no strict validation
|
||||
(unlike `ref`). Document in `sandbox/docs/`.
|
||||
|
||||
---
|
||||
|
||||
## Core surface touched (high review attention)
|
||||
|
||||
| File | Change | Phase |
|
||||
|---|---|---|
|
||||
| `homeassistant/helpers/translation.py` | provider hook + `_async_load` overlay + `async_invalidate` | B3 |
|
||||
| `homeassistant/loader.py` | catalog merge into `async_get_integration_descriptions` + title fallback | A2 |
|
||||
| `homeassistant/components/sandbox/protocol.py` + `sandbox/hass_client/.../protocol.py` + `proto/sandbox.proto` + `_proto/*pb2*` | `get_translations` message | B1 |
|
||||
| `homeassistant/components/sandbox/{bridge,__init__,sources}.py` | translation + catalog provider impl & registration | B4, A1 |
|
||||
| `sandbox/hass_client/.../` runtime | `get_translations` handler + title pre-fill | B2 |
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# core translation helper
|
||||
uv run pytest tests/helpers/test_translation.py -q
|
||||
# sandbox HA-core side
|
||||
uv run pytest tests/components/sandbox/ --no-cov -q
|
||||
# client side (separate uv env — no --no-cov)
|
||||
uv run pytest sandbox/hass_client/ -q
|
||||
# lint/format on changed files
|
||||
uv run prek run --files <changed files>
|
||||
```
|
||||
|
||||
- [ ] `tests/components/sandbox/`: assert a sandboxed integration's
|
||||
`frontend/get_translations` returns `config`/`entity`/`state`/`services`/
|
||||
`exceptions` strings — built-in *and* a fixture custom.
|
||||
- [ ] `async_invalidate` drops stale strings after a simulated ref change.
|
||||
- [ ] `hass_client`: `get_translations` returns title-prefilled raw dict for
|
||||
built-in + custom fixture.
|
||||
- [ ] Catalog: a registered provider makes a sandbox-only custom appear in
|
||||
`async_get_integration_descriptions` + supplies the picker name.
|
||||
- [ ] `test_translation.py`: provider overlay + degrade-to-empty on dead
|
||||
channel; non-sandboxed integrations unaffected.
|
||||
|
||||
## Risks & self-check
|
||||
|
||||
- **Cache lock × RPC latency.** The overlay runs under the cache lock;
|
||||
per-group batching + degrade-to-empty are load-bearing, not optional.
|
||||
- **Pre-entry flow translations** for a brand-new custom (no entry, no code on
|
||||
main): group must come from the live `SandboxFlowProxy`, not `entry.sandbox`.
|
||||
- **Invalidation correctness** on sha change — get the `loaded` + nested
|
||||
`cache` eviction keys right, or stale strings persist.
|
||||
- **Scope creep on Phase A** — it bleeds into the broader "sandbox-only custom
|
||||
discovery" feature; keep the catalog strictly display metadata.
|
||||
|
||||
*Self-check:*
|
||||
1. *What could make this wrong?* Translation loads that happen before the
|
||||
sandbox is up (boot-time) — guard the provider to claim only running/owned
|
||||
domains so they fall through to disk/empty, not block.
|
||||
2. *Simplest thing that works?* Phase B alone is shippable and delivers the
|
||||
bulk of the UX; Phase A can land separately.
|
||||
3. *What did research explicitly warn?* `IntegrationNotFound` is a dict
|
||||
*value* not a raise; there is *no* cache-eviction API; HACS may lack
|
||||
indexed translations — `title_translations` must be optional.
|
||||
|
||||
## Phasing
|
||||
|
||||
1. **Phase B** (B1→B4) — live pull-RPC. Self-contained, biggest win.
|
||||
2. **Phase A** (A1→A3) — catalog provider; pairs with the broader
|
||||
stateless-custom-discovery work; can ship independently.
|
||||
|
||||
## Status
|
||||
|
||||
Not started. Next: `/phx:work sandbox/plans/plan-translation-forwarding.md`
|
||||
(or implement Phase B directly).
|
||||
@@ -0,0 +1,399 @@
|
||||
# Core seam: redirecting a sandboxed integration's translation load to RPC
|
||||
|
||||
All citations are against the working tree at `/home/paulus/dev/hass/core`.
|
||||
THE file is `homeassistant/helpers/translation.py`.
|
||||
|
||||
---
|
||||
|
||||
## 1. The redirect seam
|
||||
|
||||
### 1a. What `async_get_integrations` returns for an unknown (custom, sandboxed) domain
|
||||
|
||||
`_TranslationCache._async_load` (`translation.py:208-253`) calls
|
||||
`async_get_integrations(self.hass, components)` (`:220`) and then iterates,
|
||||
**skipping any domain whose value is an `Exception`** (`:221-227`):
|
||||
|
||||
```python
|
||||
ints_or_excs = await async_get_integrations(self.hass, components)
|
||||
for domain, int_or_exc in ints_or_excs.items():
|
||||
if isinstance(int_or_exc, Exception):
|
||||
_LOGGER.warning("Failed to load integration for translation: %s", int_or_exc)
|
||||
continue
|
||||
integrations[domain] = int_or_exc
|
||||
```
|
||||
|
||||
`async_get_integrations` (`loader.py:1375-1449`) does **not** raise for an
|
||||
unknown domain. It resolves custom components first (`:1413-1417`), then
|
||||
`_resolve_integrations_from_root` (`:1426-1428`); for anything still
|
||||
unresolved it returns an `IntegrationNotFound` *as the dict value*
|
||||
(`loader.py:1441-1447`):
|
||||
|
||||
```python
|
||||
del cache[domain]
|
||||
exc = IntegrationNotFound(domain)
|
||||
results[domain] = exc
|
||||
future.set_result(exc) # not set_exception — value, not raise
|
||||
```
|
||||
|
||||
So for a custom sandboxed domain with no code on disk, main gets
|
||||
`{domain: IntegrationNotFound(domain)}`. The cache deliberately does **not**
|
||||
memoise the miss (`:1434-1441`), so a later disk appearance is re-resolvable.
|
||||
|
||||
### 1b. What `_async_get_component_strings` does with a missing Integration
|
||||
|
||||
`_async_get_component_strings` (`:86-128`) only ever reads `integrations.get(domain)`
|
||||
— it never indexes, so a domain absent from the `integrations` dict simply
|
||||
produces **no file to load** and **no title** (see §3). The two relevant guards:
|
||||
|
||||
- File collection (`:100-107`): a domain is only added to `files_to_load` when
|
||||
`(integration := integrations.get(domain)) and integration.has_translations`.
|
||||
A missing integration ⇒ skipped, no `KeyError`.
|
||||
- Title injection (`:118-124`): `integration := integrations.get(domain)` is
|
||||
falsy ⇒ no `title` is set.
|
||||
|
||||
Net effect for a custom sandboxed domain on main today: it falls through
|
||||
silently to `loaded_translations.setdefault(domain, {})` (`:120`) — an **empty
|
||||
dict**, no warning beyond the `_async_load` one. That empty dict flows into
|
||||
`translations_by_language[lang][domain] = {}`, `_build_category_cache` finds no
|
||||
categories for it, and the domain is marked loaded with zero strings. **The
|
||||
integration's frontend strings are simply missing** — this is the gap to fill.
|
||||
|
||||
### 1c. Recommended seam — split sandboxed domains out *before* `_async_load`
|
||||
|
||||
**Thesis:** redirect inside `_async_load`, by partitioning `components` into
|
||||
`local` vs `remote` and merging an RPC result into
|
||||
`translation_by_language_strings` *before* `_build_category_cache` runs. This is
|
||||
the single cleanest seam because:
|
||||
|
||||
- `_async_load` already owns the `language → languages` fallback expansion
|
||||
(`:217`), the per-language cache build (`:234-251`), and the `loaded` set
|
||||
bookkeeping (`:244-253`). A sandboxed domain must go through the *same*
|
||||
`_build_category_cache` / `loaded` machinery so `get_cached`, the
|
||||
English-fallback overlay, and `async_is_loaded` keep working uniformly.
|
||||
- `_async_get_component_strings` is the right *shape* producer to mirror (its
|
||||
output is the merge target), but it is `Integration`-driven and disk-path
|
||||
only. Putting the branch there would force passing the sandbox lookup down
|
||||
one more layer for no benefit. Keep that function untouched (disk-only).
|
||||
|
||||
Concretely, the branch sits right after `async_get_integrations` returns, in
|
||||
`_async_load` (`:219-231`):
|
||||
|
||||
```python
|
||||
integrations: dict[str, Integration] = {}
|
||||
ints_or_excs = await async_get_integrations(self.hass, components)
|
||||
for domain, int_or_exc in ints_or_excs.items():
|
||||
if isinstance(int_or_exc, Exception):
|
||||
_LOGGER.warning("Failed to load integration for translation: %s", int_or_exc)
|
||||
continue
|
||||
integrations[domain] = int_or_exc
|
||||
|
||||
translation_by_language_strings = await _async_get_component_strings(
|
||||
self.hass, languages, components, integrations
|
||||
)
|
||||
# NEW: overlay remote (sandboxed) component strings fetched over RPC.
|
||||
# For each sandboxed domain in `components`, replace its (empty) entry in
|
||||
# translation_by_language_strings[lang][domain] with the RPC payload.
|
||||
```
|
||||
|
||||
**Why not split before `_async_load` (in `async_load`, `:160-176`)?** That
|
||||
would bypass the lock-coalescing (`:166-176`) and the
|
||||
`components - loaded` diff that `async_load` computes, and would need to
|
||||
duplicate the English-fallback `languages` expansion. The diff/lock belong to
|
||||
the *whole* component set; only the *strings source* differs per-domain. So
|
||||
split inside `_async_load`, not above it.
|
||||
|
||||
**Main risk / antithesis:** `_async_load` runs under the cache lock and is
|
||||
`await`-heavy already; adding a synchronous RPC round-trip per sandboxed domain
|
||||
serialises translation loads behind the channel. Frontend translation fetches
|
||||
are frequent and latency-sensitive (`async_get_translations` is called per
|
||||
category). Mitigations: batch all remote domains of one group into a single RPC
|
||||
(the channel already multiplexes — `channel.call`, `channel.py:392`), and have
|
||||
the RPC payload pre-shaped so no extra disk/CPU work happens under the lock. A
|
||||
channel-down / `IntegrationNotFound`-equivalent must degrade to "empty strings,
|
||||
domain marked loaded" (today's behavior) rather than raising, so a dead sandbox
|
||||
never wedges the frontend translation endpoint.
|
||||
|
||||
### 1d. Required RPC return shape
|
||||
|
||||
The merge target is `translation_by_language_strings`, the return of
|
||||
`_async_get_component_strings` (`:91-128`). Shape is
|
||||
**`{language: {domain: <raw strings.json dict>}}`**:
|
||||
|
||||
```python
|
||||
translations_by_language: dict[str, dict[str, Any]] = {
|
||||
"en": {
|
||||
"light": { # == contents of light/translations/en.json
|
||||
"title": "...", # injected from integration.name if absent
|
||||
"config": {...},
|
||||
"entity": {...},
|
||||
"exceptions": {...},
|
||||
...
|
||||
},
|
||||
},
|
||||
"<lang>": { "light": {...} },
|
||||
}
|
||||
```
|
||||
|
||||
`_build_category_cache` (`:300-330`) then walks each component's top-level keys
|
||||
as *categories* (`config`, `entity`, `exceptions`, `entity_component`, …),
|
||||
calls `build_resources` (`:71-83`) per category, and `recursive_flatten`s into
|
||||
`component.<domain>.<category>.<...>` keys. So the RPC must return the **raw,
|
||||
un-flattened nested `strings.json` structure** for each requested language —
|
||||
exactly what a `translations/<lang>.json` file on disk contains, with `title`
|
||||
already filled in (the sandbox *has* the `Integration` and can inject it; see
|
||||
§3). The languages requested are `["en"]` or `["en", <lang>]` (`:217`) — the
|
||||
RPC should accept that language list and return both, because `_async_load`
|
||||
loads English as the fallback overlay (`:233-251`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Cache structure & invalidation
|
||||
|
||||
### Keying
|
||||
|
||||
`_TranslationsCacheData` (`:131-141`) holds two dicts shared across cache
|
||||
instances:
|
||||
|
||||
- `loaded: dict[str, set[str]]` — per **language** → set of component domains
|
||||
already loaded. Drives `async_is_loaded` (`:156-158`,
|
||||
`components.issubset(...)`) and the `components - loaded` diff (`:167`, `:175`).
|
||||
- `cache: dict[str, dict[str, dict[str, dict[str, str]]]]` — nested
|
||||
**`language → category → component → {flat_key: value}`** (built in
|
||||
`_build_category_cache`, `:309-330`; read in `get_cached`, `:196-206`).
|
||||
|
||||
Flat keys are `component.<domain>.<category>.<path>` (`:324`, `:327-328`).
|
||||
|
||||
### "Never unloaded"
|
||||
|
||||
The comment is in `async_load` (`:168-171`):
|
||||
|
||||
```python
|
||||
# Translations are never unloaded so if there are no components to load
|
||||
# we can skip the lock which reduces contention ...
|
||||
```
|
||||
|
||||
There is **no eviction API anywhere** in `translation.py`. `loaded` only ever
|
||||
grows (`:166` `setdefault`, `:251`/`:253` `update`); `cache` only ever
|
||||
`setdefault`/`update` (`:309`, `:318`, `:321`, `:330`). Nothing deletes from
|
||||
either. (`async_setup`, `:382-412`, only *adds* on language change.)
|
||||
|
||||
### What re-fetch-at-new-sha needs
|
||||
|
||||
For a custom integration re-fetched at a new commit sha whose `strings.json`
|
||||
changed, stale entries to drop are, for that one `domain` across **all
|
||||
languages** and **all categories**:
|
||||
|
||||
- every `loaded[lang]` set containing `domain` → `discard(domain)`
|
||||
- every `cache[lang][category]` dict that has a `domain` key → `del`
|
||||
|
||||
There is **no existing API** to do this. Minimal addition: a single callback
|
||||
method on `_TranslationCache`, e.g.
|
||||
|
||||
```python
|
||||
@callback
|
||||
def async_invalidate(self, components: set[str]) -> None:
|
||||
"""Drop cached + loaded state for the given components (all languages)."""
|
||||
for loaded in self.cache_data.loaded.values():
|
||||
loaded -= components
|
||||
for by_category in self.cache_data.cache.values():
|
||||
for category_cache in by_category.values():
|
||||
for component in components & category_cache.keys():
|
||||
del category_cache[component]
|
||||
```
|
||||
|
||||
plus a thin module-level `async_invalidate_translations(hass, components)`
|
||||
wrapper that goes through `_async_get_translations_cache(hass)` (`:376-379`),
|
||||
mirroring the existing `async_load_integrations` wrapper (`:415-419`). The
|
||||
sandbox would call it on each re-fetch / entry reload of a custom domain, and
|
||||
the next `async_fetch` re-runs `_async_load` (now via the RPC seam from §1).
|
||||
Note: invalidation must hold or respect `self.lock` (`:153`, `:172`) if it can
|
||||
race a concurrent `_async_load`; the simplest correct form is to make it
|
||||
`async` and take the lock, or to document it as caller-serialised against loads.
|
||||
|
||||
---
|
||||
|
||||
## 3. `title` fallback needs an `Integration` — confirmed
|
||||
|
||||
`:118-124`:
|
||||
|
||||
```python
|
||||
for domain in components:
|
||||
component_translations = loaded_translations.setdefault(domain, {})
|
||||
if "title" not in component_translations and (
|
||||
integration := integrations.get(domain)
|
||||
):
|
||||
component_translations["title"] = integration.name
|
||||
```
|
||||
|
||||
The fallback reads `integration.name`, which requires the `Integration`
|
||||
object's manifest. For a custom sandboxed domain main has **no `Integration`**
|
||||
(§1a returns `IntegrationNotFound`), so this branch **cannot run on main** for
|
||||
such a domain — `integrations.get(domain)` is `None` and `title` stays unset.
|
||||
|
||||
**Implication:** title injection must happen **on the sandbox side**, which
|
||||
*does* hold the loaded `Integration` (it fetched + imported the code). The RPC
|
||||
payload should therefore return component strings with `title` already filled
|
||||
(the sandbox runs the equivalent of `_async_get_component_strings`' title
|
||||
fallback before serialising). If the sandbox omits it, the integration's
|
||||
display name is blank on main's frontend. Built-in sandboxed domains are a
|
||||
non-issue — their `Integration` resolves on main from the bundled package, so
|
||||
they could even stay on the local disk path; only **custom** domains truly need
|
||||
the remote title.
|
||||
|
||||
---
|
||||
|
||||
## 4. Existing core hooks to mirror
|
||||
|
||||
### `async_register_sandbox_source_resolver` (the convention to copy)
|
||||
|
||||
`homeassistant/components/sandbox/sources.py:67-87` — a `@callback` that appends
|
||||
a resolver to a `HassKey`-stored list and returns an unregister `@callback`:
|
||||
|
||||
```python
|
||||
SandboxSourceResolver = Callable[[str], IntegrationSourceDict | None] # :56
|
||||
DATA_SOURCE_RESOLVERS: HassKey[list[SandboxSourceResolver]] = HassKey(
|
||||
"sandbox_source_resolvers") # :58-60
|
||||
|
||||
@callback
|
||||
def async_register_sandbox_source_resolver(hass, resolver): # :67-87
|
||||
resolvers = hass.data.setdefault(DATA_SOURCE_RESOLVERS, [])
|
||||
resolvers.append(resolver)
|
||||
@callback
|
||||
def _unregister() -> None:
|
||||
resolvers.remove(resolver)
|
||||
return _unregister
|
||||
```
|
||||
|
||||
The consumer (`async_resolve_integration_source`, `:90-114`) short-circuits
|
||||
built-ins via `Integration.is_built_in` and otherwise consults resolvers in
|
||||
order, **raising** `SandboxSourceError` if none matches. A "remote translation
|
||||
provider" hook can follow this exact convention: a `HassKey`-stored provider
|
||||
keyed by group (or a single resolver `domain → group | None`), registered by
|
||||
the sandbox integration at setup, consulted from the new `translation.py`
|
||||
branch. Because `translation.py` is in `homeassistant/helpers/` (core), it must
|
||||
**not** import the `sandbox` component — the hook lives there as a registration
|
||||
seam exactly like the source resolver, and the sandbox integration registers
|
||||
into it.
|
||||
|
||||
### The `router` attribute on `ConfigEntries`
|
||||
|
||||
`config_entries.py:2160-2161` — a single nullable hook attribute:
|
||||
|
||||
```python
|
||||
# Optional hook for diverting flows and entry setup (used by sandbox).
|
||||
self.router: ConfigEntryRouter | None = None
|
||||
```
|
||||
|
||||
`ConfigEntryRouter` is a `Protocol` (`:2121-2142`) with three `async def`
|
||||
methods (`async_create_flow`, `async_setup_entry`, `async_unload_entry`), each
|
||||
returning `None` to fall through. `SandboxFlowRouter` (`router.py:46`)
|
||||
structurally implements it; it's assigned in `sandbox/__init__.py:96`
|
||||
(`data.router = router`) and presumably set onto `hass.config_entries.router`
|
||||
at setup. A translation hook could mirror either style — a list of providers
|
||||
(source-resolver style) or a single Protocol object (router style). The
|
||||
**list-of-resolvers** style is the better fit here since translation has no
|
||||
fall-through chain semantics beyond "which group owns this domain".
|
||||
|
||||
### Where the singleton translation cache lives on hass
|
||||
|
||||
`translation.py:376-379` — stored via the `singleton` decorator under the key
|
||||
`TRANSLATION_FLATTEN_CACHE = "translation_flatten_cache"` (`:29`):
|
||||
|
||||
```python
|
||||
@singleton.singleton(TRANSLATION_FLATTEN_CACHE)
|
||||
def _async_get_translations_cache(hass: HomeAssistant) -> _TranslationCache:
|
||||
return _TranslationCache(hass)
|
||||
```
|
||||
|
||||
Every public entry point (`async_get_translations` `:353`,
|
||||
`async_get_cached_translations` `:371`, `async_load_integrations` `:417`,
|
||||
`async_translations_loaded` `:425`) goes through `_async_get_translations_cache(hass)`.
|
||||
A new `async_invalidate_translations(hass, ...)` (§2) and any provider lookup
|
||||
hang off the same singleton, so there is exactly one cache + one hook registry
|
||||
per `hass`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Liveness guard — knowing a domain is sandboxed and its group, at load time
|
||||
|
||||
Two signals already exist; both are reachable from `hass` without importing the
|
||||
sandbox internals if a small accessor hook is added (per §4).
|
||||
|
||||
### Loaded-entry case (authoritative)
|
||||
|
||||
`ConfigEntry.sandbox: str | None` (`config_entries.py:432`, declared
|
||||
`:566-569`) is the group name, set at flow completion (`:1813`,
|
||||
`async_finish_flow` reads `ConfigFlowResult["sandbox"]`) and read back from
|
||||
storage (`:2356`). It persists in `as_dict` only when set (`:1221-1224`). So at
|
||||
translation-load time, the canonical "is `domain` sandboxed and where" is:
|
||||
|
||||
```python
|
||||
for entry in hass.config_entries.async_entries(domain):
|
||||
if entry.sandbox is not None:
|
||||
group = entry.sandbox # this domain is sandboxed into `group`
|
||||
break
|
||||
```
|
||||
|
||||
This is exactly the pattern the router already uses for new flows —
|
||||
`SandboxFlowRouter._assignment_for_new_flow` (`router.py:189-200`) loops
|
||||
`async_entries(handler_key)` and returns `existing.sandbox` if set, else falls
|
||||
back to `classify(integration)`. Translation loading should reuse that resolve
|
||||
order (entry-wins, classifier-fallback) so it agrees with where the entry
|
||||
actually ran.
|
||||
|
||||
### Live-channel / bridge case (for issuing the RPC)
|
||||
|
||||
Group → live transport lives in `SandboxData` (`sandbox/__init__.py:38-45`),
|
||||
stored under `DATA_SANDBOX` (`const.py:12`,
|
||||
`HassKey[SandboxData](DOMAIN)`):
|
||||
|
||||
- `data.bridges: dict[str, SandboxBridge]` (`:45`) — the per-group bridge owning
|
||||
the channel; populated in `_on_channel_ready` (`:53-57`).
|
||||
- `data.channels: dict[str, Channel]` (`:44`) — the raw channel.
|
||||
- `data.manager.get(group)` (`manager.py:582-584`) returns the
|
||||
`SandboxProcess` or `None`; `sandbox.channel` may be `None` if down
|
||||
(router checks this at `router.py:104-105`, `:169`).
|
||||
|
||||
So the RPC issuer resolves `domain → group` (via `entry.sandbox`), then
|
||||
`group → channel` (via `data.bridges[group].channel` or
|
||||
`manager.get(group).channel`), then `channel.call(MSG_..., payload)`
|
||||
(`channel.py:392`). If the group has no running sandbox / no live channel, the
|
||||
guard degrades to empty strings (the §1c risk note) — never raise into the
|
||||
frontend translation path.
|
||||
|
||||
### Pre-entry (flow-in-progress) case
|
||||
|
||||
Before any `ConfigEntry` exists (the add-integration flow is mid-render and
|
||||
wants config-flow translations — `async_get_translations(..., config_flow=True)`,
|
||||
`:346-347`), `entry.sandbox` is unavailable. Fall back to
|
||||
`classify(await async_get_integration(hass, domain))`
|
||||
(`classifier.py:58-76`) — the same fallback the flow router uses
|
||||
(`router.py:199-200`). For a **custom** domain, `classify` returns
|
||||
`Sandbox("custom")` (`classifier.py:73-74`) **without importing** the
|
||||
integration (it uses manifest + `platforms_exists`). Caveat: at flow-start the
|
||||
custom integration's code may not yet be on disk on main at all, so even
|
||||
`async_get_integration` can raise `IntegrationNotFound` — in that pre-entry,
|
||||
no-code state the loader genuinely has nothing, and the config-flow translation
|
||||
RPC must target whichever group the flow proxy spun up (the flow already routes
|
||||
through `SandboxFlowProxy`, `router.py:79-83`, which knows its
|
||||
`sandbox_group`). That is the trickiest corner: config-flow translations for a
|
||||
brand-new custom integration need the *flow's* group, not an entry's group.
|
||||
|
||||
---
|
||||
|
||||
## Summary of the recommended change set
|
||||
|
||||
1. **Seam:** branch in `_TranslationCache._async_load` (`translation.py:219-231`),
|
||||
after `async_get_integrations`, overlaying remote-fetched
|
||||
`{lang: {domain: raw_strings}}` onto `translation_by_language_strings`.
|
||||
2. **Hook:** a source-resolver-style registry (mirror `sources.py:58-87`) so
|
||||
core stays sandbox-agnostic; the sandbox integration registers a
|
||||
`domain → group` resolver + an RPC fetcher.
|
||||
3. **RPC shape:** raw un-flattened `strings.json` nesting per requested
|
||||
language, `title` pre-injected sandbox-side (§3).
|
||||
4. **Invalidation:** new `async_invalidate(components)` on `_TranslationCache`
|
||||
(`loaded` discard + `cache[*][*]` del) + module wrapper, called on re-fetch
|
||||
at a new sha (§2).
|
||||
5. **Liveness:** resolve group via `entry.sandbox` (entry-wins) →
|
||||
`classify` fallback (pre-entry); resolve channel via
|
||||
`DATA_SANDBOX.bridges[group]`; degrade to empty on a down channel.
|
||||
@@ -0,0 +1,316 @@
|
||||
# Sandbox translation forwarding — discovery & index research
|
||||
|
||||
Goal: figure out how the *add-integration picker* can show a `title` (display
|
||||
name) for a **custom/HACS integration whose code lives only in a sandbox**, and
|
||||
how the not-yet-running config-flow entry can be rendered — **without spawning a
|
||||
sandbox**. Two seams:
|
||||
|
||||
- **Seam A (cold/picker path):** picker list + `title` strings, no flow running,
|
||||
no entry loaded, possibly no code on main's disk.
|
||||
- **Seam B (live pull path):** a flow *is* running or an entry *is* loaded —
|
||||
main already knows the sandbox group for the domain.
|
||||
|
||||
All `file:line` references are against repo root `/home/paulus/dev/hass/core`
|
||||
(backend) and `/home/paulus/dev/hass/frontend` (frontend, read-only).
|
||||
|
||||
---
|
||||
|
||||
## 1. Where the picker's integration list comes from
|
||||
|
||||
**The picker does NOT call `async_get_config_flows`.** That function
|
||||
(`homeassistant/loader.py:346-367`) is the *flow allow-list* (used by config-flow
|
||||
init to decide whether a domain may start a flow). The picker UI is fed by a
|
||||
different function.
|
||||
|
||||
### The picker's actual source: `integration/descriptions`
|
||||
|
||||
- Frontend: `dialog-add-integration.ts:590-644` `_load()` calls
|
||||
`getIntegrationDescriptions(this.hass)`
|
||||
(`frontend/src/data/integrations.ts:42-47`), which is the WS command
|
||||
`integration/descriptions`.
|
||||
- Backend handler: `homeassistant/components/websocket_api/commands.py:1303-1309`
|
||||
`handle_integration_descriptions` → `async_get_integration_descriptions`.
|
||||
- Builder: `homeassistant/loader.py:416-460`
|
||||
`async_get_integration_descriptions`:
|
||||
- **core** half: read verbatim from the generated
|
||||
`homeassistant/generated/integrations.json`
|
||||
(`loader.py:420-424`). This is a build-time artifact (brands index +
|
||||
per-integration metadata + a `translated_name` list). Never includes
|
||||
sandbox-only customs.
|
||||
- **custom** half: built live from `async_get_custom_components(hass)`
|
||||
(`loader.py:425-458`). That function (`loader.py:325-343` →
|
||||
`_get_custom_components`) **scans `<config>/custom_components` on main's
|
||||
disk**. Each custom integration contributes a metadata dict:
|
||||
`config_flow`, `integration_type`, `iot_class`, `name`,
|
||||
`single_config_entry`, `overwrites_built_in` (`loader.py:448-458`).
|
||||
|
||||
### Consequence for a sandbox-only custom integration
|
||||
|
||||
A custom integration whose code lives **only in the sandbox** (fetched on
|
||||
`entry_setup` per `sandbox/sources.py`; not present under main's
|
||||
`<config>/custom_components`) is invisible to **both** lists:
|
||||
|
||||
- It is **not** in `async_get_config_flows` — the custom branch
|
||||
(`loader.py:360-365`) iterates `async_get_custom_components(...).values()`,
|
||||
which only sees on-disk customs.
|
||||
- It is **not** in `async_get_integration_descriptions` `custom.integration` —
|
||||
same on-disk scan (`loader.py:425-431`).
|
||||
- It is **not** in the generated `integrations.json` (that ships only built-ins).
|
||||
|
||||
So **with stateless sandboxes the picker simply has no row for the integration**,
|
||||
and `frontend/get_translations` has nothing to load a title from. This is the
|
||||
core gap the feature must close.
|
||||
|
||||
> Note on the today-state: the stateless-sandbox source model
|
||||
> (`sandbox/CLAUDE.md` "Stateless sandboxes — integration source") presumes the
|
||||
> code is fetched at setup time. But the *current* HACS install still drops the
|
||||
> code under `<config>/custom_components`, so today the picker works by accident
|
||||
> (the on-disk scan finds it). The research target is the future where the code
|
||||
> is NOT on main's disk — then the picker breaks unless we feed it from the
|
||||
> resolver/index.
|
||||
|
||||
---
|
||||
|
||||
## 2. The `title` strings for the picker
|
||||
|
||||
Two-layer title resolution, both rooted on main's disk:
|
||||
|
||||
1. **Display name in the list** — `dialog-add-integration.ts:266 / 282 / 299 /
|
||||
324` use `integration.name || domainToName(localize, domain)`. `name` is the
|
||||
manifest name carried in the descriptions payload (`loader.py:452`,
|
||||
`"name": integration.name`). For a sandbox-only custom there is **no manifest
|
||||
on disk → no `name`**, and `domainToName` only falls back to a prettified
|
||||
domain string. So the row, if it existed at all, would show e.g.
|
||||
`My Custom Thing` only if `integration.name` were supplied.
|
||||
|
||||
2. **`title` translation category** — loaded lazily via
|
||||
`dialog-add-integration.ts:639-643`
|
||||
`loadBackendTranslation("title", descriptions.core.translated_name, true)`
|
||||
(and `:606` for in-progress discovered handlers). Backend:
|
||||
`frontend/get_translations` (`frontend/__init__.py:987-1009`) →
|
||||
`async_get_translations` → `_async_get_component_strings`
|
||||
(`translation.py:86-127`). Title strings come from:
|
||||
- the integration's on-disk `translations/<lang>.json`
|
||||
(`translation.py:100-106`, `integration.file_path / "translations" /
|
||||
<lang>.json`), **OR**
|
||||
- fallback to `integration.name` (manifest name) when the `title` key is
|
||||
missing (`translation.py:118-124`):
|
||||
```python
|
||||
if "title" not in component_translations and (
|
||||
integration := integrations.get(domain)
|
||||
):
|
||||
component_translations["title"] = integration.name
|
||||
```
|
||||
But `integrations.get(domain)` is the loaded `Integration` object — for a
|
||||
sandbox-only custom, `async_get_integrations` cannot load it (no code on
|
||||
disk), so even the fallback has nothing.
|
||||
|
||||
**Confirmed gap:**
|
||||
|
||||
| Integration kind | manifest `name` on main | `translations/*.json` on main |
|
||||
|---|---|---|
|
||||
| built-in, sandboxed | ✅ bundled | ✅ bundled |
|
||||
| custom, code on disk (today's HACS) | ✅ | ✅ (HACS ships them) |
|
||||
| **custom, sandbox-only (target)** | ❌ | ❌ |
|
||||
|
||||
**Minimal data the picker actually needs** (the whole reason we don't need a
|
||||
sandbox): per custom domain, a tiny static descriptor —
|
||||
- `name` (display name; what feeds both the list label *and* the `title`
|
||||
translation fallback),
|
||||
- the picker metadata it already wants: `config_flow: true`, `integration_type`
|
||||
(so it lands in the right bucket — `integration` vs `helper`), `iot_class`
|
||||
(cloud badge), optionally `single_config_entry`.
|
||||
|
||||
That is exactly the subset `async_get_integration_descriptions` writes today
|
||||
(`loader.py:448-458`). No `config`/`selector` schema, no description body — just
|
||||
enough to render a row and a name. (See §5: the picker never loads `config`.)
|
||||
|
||||
---
|
||||
|
||||
## 3. Extending the resolver / index to carry the picker `title`
|
||||
|
||||
### What exists
|
||||
|
||||
`async_register_sandbox_source_resolver(hass, resolver)`
|
||||
(`sandbox/sources.py:67-87`). A resolver is `Callable[[str], IntegrationSourceDict
|
||||
| None]` (`sources.py:56`) — domain in, git source out. `IntegrationSourceDict`
|
||||
(`sources.py:38-51`) is purely *code-location* data: `kind, url, ref, tag,
|
||||
domain, subdir`. There is **no name/title/metadata** anywhere in the contract.
|
||||
Resolvers are consulted **lazily, per-domain, only at `entry_setup`** via
|
||||
`async_resolve_integration_source` (`sources.py:90-114`) — i.e. only once you
|
||||
already know the domain you want. That shape is wrong for the picker, which needs
|
||||
to *enumerate* unknown domains up front.
|
||||
|
||||
### The contract decomposition: core EXPOSES, HACS FILLS
|
||||
|
||||
Mirror the existing resolver philosophy (core HACS-agnostic; HACS registers).
|
||||
Two cleanly-separable concerns:
|
||||
|
||||
- **Code location** (existing): `domain → {kind:git,url,ref,...}`, lazy,
|
||||
per-domain, security-critical (`ref` must be a sha). Keep as-is.
|
||||
- **Picker metadata** (new): the full *set* of custom domains plus their
|
||||
display metadata, eager, enumerable, NOT security-critical (it's just a
|
||||
display string). This wants a **listing** hook, not a per-domain resolver.
|
||||
|
||||
**Cleanest shape — a parallel "catalog provider" hook**, registered the same way:
|
||||
|
||||
```python
|
||||
class SandboxIntegrationDescriptor(TypedDict, total=False):
|
||||
domain: str
|
||||
name: str # display name → list label + title fallback
|
||||
integration_type: str # "integration" | "helper" | ...
|
||||
config_flow: bool
|
||||
iot_class: str | None
|
||||
single_config_entry: bool
|
||||
# title translations are optional; see thesis/antithesis below
|
||||
title_translations: dict[str, str] | None # {lang: title}
|
||||
|
||||
SandboxCatalogProvider = Callable[[], list[SandboxIntegrationDescriptor]]
|
||||
|
||||
@callback
|
||||
def async_register_sandbox_catalog_provider(hass, provider) -> Callable[[], None]:
|
||||
...
|
||||
```
|
||||
|
||||
Then `async_get_integration_descriptions` (`loader.py:416-460`) — or a thin
|
||||
sandbox-aware wrapper — merges these descriptors into the `custom.integration` /
|
||||
`custom.helper` buckets exactly where the on-disk scan does today
|
||||
(`loader.py:431-458`), de-duping by domain against on-disk customs. And
|
||||
`frontend/get_translations` for category `title` gains a sandbox source: when a
|
||||
domain isn't loadable on disk, pull `name` (and/or `title_translations[lang]`)
|
||||
from the catalog instead of `integration.name` (the fallback at
|
||||
`translation.py:118-124`).
|
||||
|
||||
Why a **separate** provider and not extending `IntegrationSourceDict`:
|
||||
|
||||
- The source resolver is *lazy per-domain* and *security-critical* (sha pinning,
|
||||
`sources.py:19-22,130-135`). The catalog is *eager enumerable* and *display-
|
||||
only*. Fusing them would force the security-critical path to also be a full
|
||||
listing API and would drag display strings through the sha-validation code.
|
||||
- Keeps the wire/`entry_setup` proto (`pb.IntegrationSource`) untouched — title
|
||||
strings never need to cross to the sandbox; they're a main-side display
|
||||
concern.
|
||||
|
||||
### Thesis — extend the index to carry `title`
|
||||
|
||||
HACS already maintains a full catalog of installed (and installable) custom
|
||||
integrations including their repo, version/sha (it supplies `ref` today), and
|
||||
the `manifest.json` `name`. Surfacing `name` (+ optionally cached
|
||||
`translations/en.json` `title`) per domain is nearly free for HACS and is the
|
||||
single source of truth. Core stays agnostic (just a registry + a merge point);
|
||||
the picker renders custom rows and a sensible name with zero sandbox spin-up.
|
||||
Matches the established "core exposes hook, HACS fills" precedent exactly
|
||||
(`sources.py` module docstring lines 10-14).
|
||||
|
||||
### Antithesis — what if HACS doesn't have translations indexed?
|
||||
|
||||
- HACS reliably knows the **manifest `name`** (it parses `manifest.json` to
|
||||
validate installs) and the sha. It does **not** necessarily have the
|
||||
integration's `translations/*.json` indexed — those live inside the repo
|
||||
tarball, which under the stateless model is only fetched at `entry_setup`. So
|
||||
`title_translations` may be empty for most/all domains.
|
||||
- Mitigation built into the fallback chain we already rely on: if
|
||||
`title_translations[lang]` is absent, the picker degrades to
|
||||
`integration.name` (manifest name) — which is exactly the existing
|
||||
`translation.py:118-124` behavior and the `integration.name ||
|
||||
domainToName(...)` chain in `dialog-add-integration.ts:266`. So
|
||||
`title_translations` should be **optional**; the load-bearing field is `name`.
|
||||
A localized title is a nice-to-have, not a requirement, for the picker.
|
||||
- Risk: a custom integration that *only* defines its display name in
|
||||
`translations/<lang>.json` `title` (no good manifest `name`) would show a
|
||||
prettified domain. Acceptable for v1; HACS could later cache `en.json` title
|
||||
at install time if it wants better names.
|
||||
- Trust boundary: unlike `ref` (sha-pinned, security-critical), a wrong/missing
|
||||
`name` is cosmetic — no need for the strict validation the source path has.
|
||||
|
||||
**Recommendation:** ship the catalog provider carrying `name` + the small
|
||||
picker-metadata subset; make `title_translations` optional. This unblocks the
|
||||
picker with the data HACS definitely has, and the title localization can improve
|
||||
later without a contract change.
|
||||
|
||||
---
|
||||
|
||||
## 4. Domain → sandbox-group resolution for the LIVE pull path (seam B)
|
||||
|
||||
When a flow IS running or an entry IS loaded, main already maps domain → group;
|
||||
no catalog needed. The existing lookups:
|
||||
|
||||
- **`ConfigEntry.sandbox: str | None`** — the routing tag stored on the entry.
|
||||
Declared `config_entries.py:432`; `__init__` kwarg `:448`; written to
|
||||
`as_dict` `:1223-1224`; read back from storage `:1813` / `:2356`; plumbed via
|
||||
`async_finish_flow`/`async_update_entry` (`:2576`, `:2597`, `:2616`). Its value
|
||||
is the **group name** (e.g. `"custom"` / `"built_in"`), not a per-domain id.
|
||||
- **New-flow assignment** — `router.py:189-201` `_assignment_for_new_flow`:
|
||||
first an *existing entry's* `sandbox` wins
|
||||
(`async_entries(handler_key)` → `existing.sandbox`, `:196-198`); otherwise
|
||||
`classify(integration)` decides (`classifier.py:58-76`: system/ALWAYS_MAIN/
|
||||
incompatible-platform → MAIN; `not is_built_in → GROUP_CUSTOM`; else
|
||||
`GROUP_BUILT_IN`). The classifier is *also* a domain→group function and works
|
||||
off the `Integration` object, **but it needs the integration loadable** — so
|
||||
for a sandbox-only custom not on disk, `classify` would fail to load it. For
|
||||
seam B that's fine because an existing entry's `sandbox` field already pins the
|
||||
group (the entry was created when the code was present / fetched).
|
||||
- **Group → live bridge / channel** — `SandboxData`
|
||||
(`__init__.py:38-45`): `bridges: dict[str, SandboxBridge]` and
|
||||
`channels: dict[str, Channel]`, keyed by **group name**. Populated on channel
|
||||
ready (`__init__.py:53-57`). The router uses `self._data.bridges.get(group)`
|
||||
(`router.py:183-184`) and `self._manager.get(group)` / `ensure_started(group)`
|
||||
(`router.py:91`, `:168`). `entry_setup`/`unload` resolve group via
|
||||
`entry.sandbox` (`router.py:87`, `:165`).
|
||||
- **Bridge-level domain tracking** — `bridge.py` keys its owned
|
||||
`EntityPlatform`s by `(entry_id, domain)` (`bridge.py:179-183`) and tracks
|
||||
mirrored `(domain, service)` pairs (`bridge.py:183`). This is reverse-mapping
|
||||
(group→domains it owns), used for entity/service mirroring, not for the
|
||||
picker's forward domain→group lookup.
|
||||
|
||||
**Net:** seam B's forward lookup already exists end-to-end:
|
||||
`entry.sandbox` (group) → `SandboxData.bridges[group]` / `manager.get(group)`.
|
||||
For a *running flow* the group is carried on `SandboxFlowProxy(sandbox_group=...)`
|
||||
created in `async_create_flow` (`router.py:79-83`). So **a translation pull for a
|
||||
live/in-progress sandboxed domain can be routed to the right bridge using the
|
||||
existing group keying** — no new index needed for seam B; the catalog (§3) is
|
||||
only for the cold picker (seam A).
|
||||
|
||||
---
|
||||
|
||||
## 5. Does the picker ever load category `config` in bulk?
|
||||
|
||||
**No.** The picker loads **only `title`**:
|
||||
|
||||
- `dialog-add-integration.ts:606` —
|
||||
`loadBackendTranslation("title", discoveredHandlers, true)` (discovered flow
|
||||
handlers only).
|
||||
- `dialog-add-integration.ts:639-643` —
|
||||
`loadBackendTranslation("title", descriptions.core.translated_name, true)`.
|
||||
|
||||
No `config` / `selector` / fragment load anywhere in `dialog-add-integration.ts`.
|
||||
|
||||
`config`/`selector` are loaded **only once a specific flow starts**, in
|
||||
`show-dialog-config-flow.ts`:
|
||||
|
||||
- `:29-33` (initial step):
|
||||
`loadFragmentTranslation("config")`, `loadBackendTranslation("config", handler)`,
|
||||
`loadBackendTranslation("selector", handler)`, `loadBackendTranslation("title",
|
||||
handler)`.
|
||||
- `:40-46` (subsequent step): same set for `step.handler`.
|
||||
|
||||
So the heavy `config`/`selector` payload is per-flow and lazy. **For the picker we
|
||||
only ever need `title` (which collapses to a display `name`)** — confirming §2's
|
||||
"minimal data" conclusion: the catalog provider only has to carry a name, not
|
||||
flow schema.
|
||||
|
||||
---
|
||||
|
||||
## Summary of the contract to build
|
||||
|
||||
- **Seam A (picker / cold):** new `async_register_sandbox_catalog_provider`
|
||||
(parallel to the source resolver). HACS fills a list of
|
||||
`{domain, name, integration_type, config_flow, iot_class,
|
||||
single_config_entry, title_translations?}`. Core merges it into
|
||||
`async_get_integration_descriptions` custom buckets (`loader.py:431-458`) and
|
||||
into the `title` fallback in `translation.py:118-124`. `name` is the
|
||||
load-bearing field; `title_translations` optional.
|
||||
- **Seam B (live):** nothing new — reuse `entry.sandbox` → `SandboxData.bridges`
|
||||
/ `manager.get(group)` (`router.py`, `__init__.py:38-45`).
|
||||
- Keep the security-critical source resolver (`sources.py`) untouched and
|
||||
separate from the display-only catalog.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Scratchpad — translation forwarding
|
||||
|
||||
Decisions, rejected paths, and things to remember while implementing
|
||||
`plan-translation-forwarding.md`.
|
||||
|
||||
## Decisions (locked)
|
||||
- **Both seams** — live pull-RPC (B) + catalog provider (A).
|
||||
- **Pull/RPC**, not push — matches the lazy per-language `_TranslationCache`;
|
||||
only fetch what the frontend asks for.
|
||||
- **Built-in reads local disk** in the live path — files are byte-identical on
|
||||
main; RPC reserved for customs. One `is_built_in` branch in the provider.
|
||||
- **Whole-strings-dict** RPC granularity — main slices via `build_resources`,
|
||||
matching how it reads the file today.
|
||||
- **Separate** `async_register_sandbox_catalog_provider` for the picker — do
|
||||
NOT overload the sha-pinned `IntegrationSourceDict` source resolver.
|
||||
|
||||
## Rejected / not chosen
|
||||
- Push-at-setup / hybrid transport — needs a language-set decision + still
|
||||
needs pull on language switch. Dropped.
|
||||
- Pure unified pull (RPC for built-in too) — redundant data + needless sandbox
|
||||
spawn for the picker. Replaced by the built-in disk carve-out.
|
||||
- Spawning every sandbox to render the bulk picker — rejected; picker only
|
||||
needs `title`, served statically (disk for built-in, catalog for custom).
|
||||
|
||||
## Landmines (from research, file:line)
|
||||
- `async_get_integrations` returns `IntegrationNotFound` as the dict **value**,
|
||||
never raises, never caches the miss (`loader.py:1441-1447`).
|
||||
- `_async_load` skips Exception-valued domains (`translation.py:221-227`);
|
||||
missing domain ⇒ silent `{}`.
|
||||
- No cache-eviction API — `loaded`/`cache` only grow (`translation.py:168-171`).
|
||||
Must add `async_invalidate`.
|
||||
- `title`→`integration.name` fallback (`translation.py:118-124`) needs an
|
||||
`Integration` object — impossible on main for customs ⇒ pre-fill sandbox-side.
|
||||
- Picker uses `integration/descriptions` (disk scan, `loader.py:325-343,
|
||||
416-460`), NOT `async_get_config_flows`. Sandbox-only custom is in none.
|
||||
- Picker loads only `title` (`dialog-add-integration.ts:606,639-643`);
|
||||
`config`/`selector` load per-flow (`show-dialog-config-flow.ts:29-46`).
|
||||
|
||||
## Open questions to resolve during work
|
||||
- Exact RPC batching unit: per group is the intent — confirm the provider
|
||||
groups `components` by `ConfigEntry.sandbox` before issuing RPCs.
|
||||
- Boot-time translation loads before any sandbox is up — verify the provider
|
||||
returns `{}` (fall through to disk/empty), never blocks.
|
||||
Reference in New Issue
Block a user