Skip to content

feat(wmo): select the CAP <info> block by language - #72

Merged
seevee merged 1 commit into
mainfrom
feat/wmo-language-selection
Aug 4, 2026
Merged

feat(wmo): select the CAP <info> block by language#72
seevee merged 1 commit into
mainfrom
feat/wmo-language-selection

Conversation

@seevee

@seevee seevee commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Fixes #59.

The defect

_select_info read infos[0] on the premise, stated in its own docstring, that "WMO feeds carry one language each". A sweep of all 140 SWIC sources on 2026-08-03 (RSS → first live CAP body, reading <language> off every <info>) found that false — of the 110 that sampled cleanly, 46 carry more than one block and 25 of those lead with a non-English one:

source body <info> languages
cn-cma-xx en-US → zh-CN
at-zamg-en de-DE → en-GB
ch-meteoswiss-de en → de → fr → it → rm
mo-smg-xx zh-mo → pt-PT → en-US
be-irm-en nl-BE → fr-BE → en-GB → de-DE

Two consequences. The reported one: a user wanting Chinese on cn-cma-xx got en-US, though zh-CN sat in the same document. The unreported and worse one: at-zamg-en leads with de-DE, so the source whose ID ends -en served German, while ch-meteoswiss-de leads with en and served English. 15 of the 110 have a trailing ID segment that disagrees with their first block's primary subtag.

The change

_select_info(doc, language) matches each block: casefolded exact tag → BCP 47 primary subtag → any English block → document order. The English step is what makes the fallback predictable — without it a German user on mo-smg-xx would get Chinese. ECCC deliberately has no such step (a French Canadian must not silently receive English), which is why the ladder lives in wmo.py rather than a shared helper; consolidating the three providers' matchers is a follow-up, not this PR.

The whole block is selected, not just its text. Every multilingual source probed carries identical severity, expires, polygons and geocodes across its blocks, so there is nothing to merge and the GPS polygon filter is unaffected — pinned by an invariance test.

auto resolves from hass.config.language verbatim rather than truncated as MeteoAlarm does: WMO bodies carry full tags, so en-GB/en-US and pt-PT/pt-BR stay distinct, and the ladder degrades to the primary subtag on its own — which is what carries zh-Hans → zh-CN.

The first non-selected block populates the *_alt fields, as ECCC and MeteoAlarm already do. The model fields, sparse serialization, and card rendering all already existed, so model.py is untouched.

User-visible

  • WMO entries gain a Language option (dropdown of the 36 primary subtags seen in live bodies, plus free-form entry — 61 distinct tags appear across SWIC, and a fixed list would hard-reject zh-Hans, the tag this issue is about). Default auto.
  • Multilingual sources now emit headline_alt / description_alt / instruction_alt / language_alt.
  • Source dropdown labels stop asserting one language per source: Austria (ZAMG, de/en) instead of the currently-wrong Austria (ZAMG, en).
  • lu-ana-meteo-fr, us-noaa-nws-en, us-noaa-nws-en-marine become enterable — the validator rejected these real registry IDs.

No migration: both readers default to "auto", so existing entries pick up the behavior on upgrade.

Two nearby corrections the change forces

The label's language now comes from the registry's byLanguage rather than the source ID. byLanguage is itself only a hint — it over-claims for 35 of the 110 and under-claims for 20 — so it labels but never selects. And _WMO_SOURCE_RE encoded the same {country}-{agency}-{lang} assumption this PR corrects; it is strictly widened, so nothing previously accepted is now rejected.

Preserved limitation

Duplicate tags resolve first-match-wins. ca-aema-xx emits one <info> per area group (en-CA → fr-CA → en-CA → fr-CA), so only its first group survives — exactly what infos[0] did before, so this is preserved rather than regressed. Same defect class as #45; fixing it means emitting multiple alerts per document, an identity/lifecycle change. Pinned by a test so a refactor cannot drift silently. Follow-up issue to file.

No regressions

Single-language sources, sources whose blocks declare no <language> (nz-nms-en, ph-pagasa-en, sg-mss-en, tt-ttms-en), and an unset option all still return infos[0], for every value of language. Malformed tags found live (en-EN, mk-MKD, sr-Latn, TL) degrade to today's behavior rather than raising; covered by a parametrized test.

Verification

Check Result
pytest tests -q 595 passed (baseline on main: 538)
ruff check / ruff format clean
mypy custom_components/cap_alerts clean

New: tests/test_coordinator_language.py, tests/test_wmo_config_flow.py, plus language coverage in test_wmo_provider.py and label coverage in test_wmo_sources.py. No existing assertion is weakened; the only ones changed are the test_wmo_sources.py label strings, which change because the label format deliberately does.

The multilingual fixture is @MF-142's own cn-cma-xx document, attached to the issue.

Assisted-by: Claude:claude-opus-5

_select_info read infos[0] on the premise, stated in its own docstring,
that "WMO feeds carry one language each". A sweep of all 140 SWIC sources
on 2026-08-03 (RSS to first live CAP body, reading <language> off every
<info>) found that false: of the 110 sources that sampled cleanly, 46
carry more than one block and 25 of those lead with a non-English one.

Two consequences, one reported and one not. A user wanting Chinese on
cn-cma-xx got en-US even though zh-CN sat in the same document — that is
issue #59. Worse, at-zamg-en leads with de-DE, so the source whose ID
ends "-en" served German, while ch-meteoswiss-de leads with en and served
English; 15 of the 110 have a trailing ID segment that disagrees with
their first block's primary subtag.

Match the language option against each block: casefolded exact tag, then
BCP 47 primary subtag, then any English block, then document order. The
English step is what makes the fallback predictable — without it a German
user on mo-smg-xx (zh-mo, pt-PT, en-US) would get Chinese. ECCC
deliberately has no such step, since a French Canadian must not silently
receive English, which is why the ladder lives here rather than in a
shared helper.

The whole block is selected, not just its text. Every multilingual source
probed carries identical severity, expires, polygons and geocodes across
its blocks, so there is nothing to merge and the GPS polygon filter is
unaffected; a test pins that invariance.

"auto" resolves from hass.config.language verbatim rather than truncated
as MeteoAlarm does: WMO bodies carry full tags, so en-GB/en-US and
pt-PT/pt-BR stay distinct, and the ladder degrades to the primary subtag
on its own — which is what carries zh-Hans to zh-CN.

The first non-selected block populates the *_alt fields, as ECCC and
MeteoAlarm already do; the model fields, sparse serialization, and card
rendering all already existed.

Duplicate tags resolve first-match-wins. ca-aema-xx emits one <info> per
area group (en-CA, fr-CA, en-CA, fr-CA), so only its first group survives
— exactly what infos[0] did before, so this is a preserved limitation
rather than a regression, and the same defect class as ECCC #45. Pinned
by a test so a refactor cannot drift.

The options flow gains a Language dropdown seeded from the 36 primary
subtags observed in live bodies, accepting a custom value: 61 distinct
tags appear across SWIC, and a fixed list would hard-reject zh-Hans, the
tag this issue is about. Anything unmatched falls through the ladder to
today's behavior rather than raising.

Two nearby corrections the change forces. The dropdown label's language
now comes from the registry's byLanguage rather than the source ID, which
is wrong for those 15 sources; byLanguage is itself only a hint (it
over-claims for 35 of the 110 and under-claims for 20), so it labels but
never selects. And _WMO_SOURCE_RE encoded the same {country}-{agency}-
{lang} assumption, rejecting three real registry IDs — lu-ana-meteo-fr,
us-noaa-nws-en, us-noaa-nws-en-marine. Strictly widened, so nothing
previously accepted is now rejected.

No migration: both readers default to "auto", so existing entries pick up
the behavior on upgrade. Single-language sources, sources whose blocks
declare no <language>, and an unset option all still return infos[0].

The multilingual fixture is the reporter's own cn-cma-xx document.

Fixes #59

Assisted-by: Claude:claude-opus-5
@seevee
seevee merged commit 1514db8 into main Aug 4, 2026
5 checks passed
@seevee
seevee deleted the feat/wmo-language-selection branch August 4, 2026 02:44
seevee added a commit that referenced this pull request Aug 4, 2026
* feat(coordinator): filter alerts by area-code prefix

A SWIC source that publishes no per-alert geometry has exactly one usable
location mode, country-wide: selecting GPS trips _filter_by_polygon's
fail-loud guard on every poll and the entry never initializes. cn-cma-xx
is the confirmed case — 0 of 60 sampled bodies carried a <polygon> —
and it is also the source carrying the zh-CN <info> blocks #59/#72 exist
to select, so a Chinese-language user was forced into a mode that yields
261 active alerts for one entry. That is not merely unusable; it
destabilizes the frontend, which reports "Client unable to keep up with
pending messages" while the entry populates.

Add an opt-in narrowing keyed on CAPAlert.geocodes: an alert is kept when
any code it publishes starts with any configured prefix. Measured live on
2026-08-04, prefixes cut the 261 to 30 (13, Hebei), 4 (31, Shanghai), and
8 (44, Guangdong).

Deliberately provider-neutral rather than a WMO filter mode. Every
provider already populates the geocodes container, so nothing in the
matcher is WMO-specific, and the existing exclude_marine option is the
exact precedent: a cross-provider, options-configured, post-fetch
narrowing applied in coordinator._apply. Landing it there rather than in
providers/wmo.py also makes it a layer rather than a mode — it composes
with NWS zones, ECCC provinces, MeteoAlarm regions, and WMO GPS instead
of being mutually exclusive with them — and costs less code than the
per-provider shape would have.

Prefix rather than exact match because area codes are hierarchical, so a
prefix is a scope: 13 is Hebei, 1307 Zhangjiakou, 130709000000 Chongli.
Exact matching would make the user enumerate every county code, 28 of
them for Hebei alone. Matching is scheme-agnostic: there is no
cross-provider scheme-priority registry to resolve one code from
(MeteoAlarm's is country-scoped, and CMA's "CPEAS Geographic Code"
appears in no existing table), so every value under every valueName is
compared. A colliding prefix over-matches, keeping extra alerts, rather
than dropping wanted ones.

Codes vary in length within a single scheme — of 488 sampled CMA codes,
481 are 12 characters and 7 are 6 (Chongqing districts) — so the match is
a plain startswith with no zero-padding in either direction. The
consequence, that a pasted full-length code will not match a shorter
sibling, is documented in the field description and pinned by a test so a
future refactor cannot silently introduce padding.

The fail-loud contract is the subtle part. UpdateFailed fires only when
the feed returned alerts and not one carries any geocode — a source
capability failure, the exact parallel of "publishes no per-alert
geometry". Zero matches is not a failure: "no alerts in my area" is the
normal steady state, and failing there would leave the entry unavailable
most of the time, inverting what unavailability means. That leaves a
typo'd prefix indistinguishable from a quiet area, so it surfaces instead
as a one-shot WARNING naming codes the feed actually publishes, re-armed
on the first match — the pattern _tracker_resolve_warned already uses.

Configurable in two places for one reason. The options flow is the home
of the setting, offered for every provider. The WMO filter menu gains a
step that writes the same option at creation time, because setting it
afterwards means creating hundreds of entities and immediately removing
most of them — precisely the condition this change exists to avoid. The
reconfigure step merges into existing options rather than passing
options= wholesale, which would silently drop scan_interval, timeout, and
the language selection; a test pins that.

A free-text field rather than a picker: enumerating areas would need a
full CAP-body sweep (~500 fetches for CMA) since the RSS envelope carries
no geocode, which is unacceptable latency inside a config flow. Worth
revisiting if SWIC ever exposes an area index.

Additive and unversioned — the key is absent on every existing entry, and
both readers use .get(), so no migration is needed and no existing mode
changes behavior. The prefix stays out of the entry title on purpose:
_compute_device_title reads entry data only, and options are editable
independently, so a title showing the prefix would go stale the moment it
changed under Configure.

Two adjacent doc errors the change forces. docs/architecture.md claimed
WMO has no GPS-tracker mode, which async_step_wmo_gps_tracker has always
contradicted, and README claimed WMO has no language option, which #72
added.

Closes #73

Assisted-by: Claude:claude-opus-5

* chore(scripts): add a provider probe for feed-shape questions

Answers "does this source publish polygons?", "which <info> language does
this option select?", "how many entities would this entry create?" against
the real provider code path in seconds. The alternative — reconfiguring a
live entry and waiting for a coordinator cycle — costs minutes per
question and conflates provider behavior with HA setup and timeout
behavior.

Carries the geocode filter (#73) even though that is a coordinator step
rather than a provider one, applied in the same position via the same
function, because sizing an entry before creating it is exactly the
question the filter exists to answer. --active drops cancel/expired so
the count matches the entities HA would create: without it cn-cma-xx
reads 485 alerts for prefix 13, of which 119 are already cancelled, and
the number that matters is 30. --codes buckets published area codes by
prefix width, standing in for the region picker the filter deliberately
does not have.

Dev tooling only, not shipped in the integration — but scripts/ is in the
CI ruff scope, so it is linted and formatted like the rest.

Assisted-by: Claude:claude-opus-5
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.

WMO: multilingual CAP bodies fall through to infos[0] — no language selection

1 participant