From e4cce6962d572a4fe0e46192a11c6a34c275bca4 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:39:07 -0700 Subject: [PATCH 1/7] fix(config_flow): tell a moved panel apart from an impersonated one A pinned entry whose panel changed DHCP lease had no way back. The panel serves a perfectly good certificate that no longer names where it answers, and `async_leaf_chains_to_ca` returned one boolean for "does not chain" and "does not name this host" alike, so: - reconfigure refused with "not signed by the authority it published", which was false -- it was signed, by exactly that authority; - the mDNS move-guard refused on the same check; - reauth reads the stored host and offers no field to change it; - the CA-changed repair never fired, because the CA had not changed. Deleting and re-adding the entry was the only route left, for a panel that had done nothing but take a new address. `async_leaf_verdict` answers the three questions separately -- TRUSTED, NAME_MISMATCH, UNTRUSTED -- plus UNREACHABLE, which had been folded into the same value, so an unplugged cable was reported to users as an unsigned certificate. `async_leaf_chains_to_ca` stays as a wrapper for TRUSTED, so every existing caller keeps the meaning it had. Classification alone would have fixed nothing: `detect_api_version` and `register_fqdn` run over the entry's pinned transport, and both would fail the same hostname check before registration could repair anything. So on NAME_MISMATCH the reconfigure flow rebuilds its own transport with the pin kept and only the name binding dropped. It probes, registers the FQDN, and the panel regenerates its certificate around the new name. That transport never leaves the flow and is never stored. A bare IP the certificate does not name is still refused, because this flow can relax the name binding for its own probe and the coordinator and broker cannot -- storing it would point the entry at an address every later connection rejects. The new `ca_name_mismatch` error says so and names both ways through: an FQDN, or the panel's .local name. The relaxed context and the SAN matcher come from span-panel-api 3.2.0 rather than being written here. `ssl.match_hostname` was removed in 3.12, so the matcher is hand-written, and a hand-written security primitive with two implementations is the drift `_ssl`'s docstring warns about. --- CHANGELOG.md | 4 +- README.md | 13 +- custom_components/span_panel/config_flow.py | 92 ++++++++--- .../span_panel/config_flow_validation.py | 116 ++++++++++--- custom_components/span_panel/manifest.json | 4 +- custom_components/span_panel/strings.json | 1 + .../span_panel/translations/en.json | 1 + .../span_panel/translations/es.json | 1 + .../span_panel/translations/fr.json | 1 + .../span_panel/translations/ja.json | 1 + .../span_panel/translations/pt.json | 1 + pyproject.toml | 2 +- requirements_test.txt | 2 +- tests/test_config_flow_validation.py | 19 ++- tests/test_v2_config_flow.py | 4 +- tests/test_v2_config_flow_tls.py | 153 +++++++++++++++++- uv.lock | 2 +- 17 files changed, 365 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bccfe8f..a1593f7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -132,7 +132,9 @@ This release requires Home Assistant 2026.8 to avoid a deprecated API — the ol name that is not a fully qualified domain, so the certificate never comes to name it. Set that panel up by its IP address, or by the `.local` name shown in the mobile app. - **A discovered address this entry's authority rejects is refused with a log warning and nothing else** — if the panel really has moved, use **Reconfigure** on - the entry, which checks the new address against the standing pin before storing it. + the entry, which checks the new address against the standing pin before storing it. A panel whose certificate does not yet name where it now answers is moved + by reconfiguring to a fully qualified domain name, which asks the panel to regenerate its certificate around it, or to the `.local` name its certificate + already covers; a bare new IP address the certificate does not name is refused, because every connection after the flow would reject it too. ## [2.0.8] - 5/2026 diff --git a/README.md b/README.md index 09ff8333..aefe18ac 100644 --- a/README.md +++ b/README.md @@ -692,11 +692,14 @@ against the address the certificate already names, hostname verification never r **A pinned entry follows its panel to a new address only when the new address proves itself.** An entry moves when your panel announces itself over mDNS at a new address, and when you add the same panel again by hand. Both used to be decided by a serial read from an unauthenticated endpoint, which anything on your network can claim. The entry now asks whether the candidate serves a certificate its own anchor validates, on the port the entry uses, and refuses the move — -logging at `WARNING` — otherwise. If the panel really has moved and its certificate really has changed, use **Reconfigure** on the entry. - -A panel that arrives through the **Supervisor** — a Home Assistant add-on — is the one exception to that check, and deliberately so: it announces itself over -the authenticated Supervisor API and legitimately reallocates its own ports, so holding it to the stored address would freeze the entry. An add-on already -holding Supervisor privileges can therefore move a pinned entry. +logging at `WARNING` — otherwise. If the panel really has moved, use **Reconfigure** on the entry. + +**Reconfigure tells a moved panel apart from an impersonated one.** A panel that changed address serves a perfectly good certificate that does not yet name +where it now answers, and something impersonating your panel serves one that chains to nothing — the same failure, until you ask which. Reconfigure asks. A host +that does not chain to the pinned authority is refused as before, and one that does not answer at all is now reported as unreachable rather than as an unsigned +certificate. A host that chains but is not named is your panel, and the way through is to reconfigure to a **fully qualified domain name**, which asks the panel +to regenerate its certificate around that name, or to the panel's **`.local` name**, which its certificate already covers. Reconfiguring to a bare new IP +address the certificate does not name is refused, because the entry would then be pointed at an address every later connection rejects. **Panels configured before this release are pinned differently, and are not risk-free until they are.** They are pinned on the first startup that reaches the panel, logged at `WARNING` with the fingerprint so you can find the value afterwards. What was fetched is checked against the certificate the panel serves on diff --git a/custom_components/span_panel/config_flow.py b/custom_components/span_panel/config_flow.py index cb61cff8..e69e4f8c 100644 --- a/custom_components/span_panel/config_flow.py +++ b/custom_components/span_panel/config_flow.py @@ -43,12 +43,14 @@ process_general_options_input, ) from .config_flow_validation import ( + LeafVerdict, PanelCaUnusableError, PanelRestTransport, as_port, async_download_ca_or_none, async_fetch_panel_ca, async_leaf_chains_to_ca, + async_leaf_verdict, async_panel_leaf_host, check_fqdn_tls_ready, is_fqdn, @@ -208,6 +210,10 @@ def __init__(self) -> None: # would reject", and refuse rather than write an entry that cannot # connect to its own panel. self._bootstrap_host: str | None = None + # Whether the host this flow is about to store is one the pinned + # certificate names. Only reconfigure sets it false, and only to + # refuse -- see `async_step_reconfigure`. + self._leaf_names_host: bool = True # Whether the panel accepted the FQDN and now serves it. Recorded rather # than re-derived from `is_fqdn(host)` at entry creation, because that # question is "does this look like a domain name" and the one that @@ -1312,21 +1318,62 @@ async def async_step_reconfigure( data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}), errors={"base": "ca_unusable"}, ) - # The new host may be a name the panel has never heard of, and this - # entry is pinned: probing it by name would fail hostname verification - # and report the panel unreachable. Settle the address first, so the - # probe reaches the panel and `_bootstrap_host` records whether the new - # name is one the pinned certificate already covers. + # The new host may be one the panel's certificate does not name -- a new + # DHCP lease, or an FQDN it has not been told about yet -- and this entry + # is pinned. Ask what is actually true of it before deciding, because + # "not your panel" and "your panel, under a name it has not caught up + # with" have opposite remedies and used to be the same answer. self.host = host + self._leaf_names_host = True pinned_pem = self._rest_transport.ca_pem - if pinned_pem is not None and not await self._async_choose_bootstrap_host( - pinned_pem, self._rest_transport.port - ): - return self.async_show_form( - step_id="reconfigure", - data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}), - errors={"base": "ca_leaf_mismatch"}, - ) + if pinned_pem is not None: + if await self._async_choose_bootstrap_host(pinned_pem, self._rest_transport.port): + # Something under the pin both validates and names itself. The + # host is storable only when that something was the host: a + # substituted address reaches the panel but says nothing about + # the name the user asked to store. + self._leaf_names_host = self._bootstrap_host is None + else: + # Nothing the anchor both validates and names, which used to end + # the flow here. Three different things produce that, and only + # one of them is the panel being absent. + verdict = await async_leaf_verdict(host, self._rest_transport.port, pinned_pem) + if verdict is LeafVerdict.UNREACHABLE: + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}), + errors={"base": "cannot_connect"}, + ) + if verdict is not LeafVerdict.NAME_MISMATCH: + _LOGGER.warning( + "Refusing to reconfigure panel %s to %s: the certificate served there on " + "port %s does not chain to the authority this entry is pinned to " + "(SHA-256 %s)", + reconfigure_entry.title, + host, + self._rest_transport.port, + pem_fingerprint_or_reason(pinned_pem), + ) + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}), + errors={"base": "ca_leaf_mismatch"}, + ) + # The panel is there -- only it holds a key the pinned anchor + # signed -- but its certificate names neither this address nor + # anything this flow could reach it by. Every call over the + # strict transport would fail and report it unreachable, so this + # flow's own calls keep the pin and drop only the name binding. + # That is what lets the FQDN branch below ask the panel to + # regenerate the certificate that fixes this. The relaxed + # transport never leaves this flow and is never stored. + self._leaf_names_host = False + self._rest_transport = panel_rest_transport( + self.hass, + reconfigure_entry.data, + allow_plaintext_fallback=False, + verify_hostname=False, + ) try: detection = await detect_api_version( self._rest_host, @@ -1376,17 +1423,26 @@ async def async_step_reconfigure( # New host is not an FQDN — simple update. Nothing on this branch will # ask the panel to start naming it, so a host the pinned certificate # does not cover is refused here rather than written into the entry. - if self._bootstrap_host is not None: + # + # Refused even though the panel demonstrably answered here, because + # storing it would not help: this flow can relax the name binding for + # its own probe, but the coordinator and the broker cannot, so every + # connection after the flow ends would fail hostname verification + # against the same certificate. The entry would be pointed at an address + # it cannot use. The remedies are real ones and the message names them. + if not self._leaf_names_host: _LOGGER.warning( - "Panel %s does not name %s in the certificate it serves; refusing to " - "point a pinned entry at a host its own certificate authority rejects", - self._bootstrap_host, + "Panel %s answered at %s but does not name it in the certificate it serves; " + "refusing to store an address every later connection would reject. Reconfigure " + "to an FQDN, which asks the panel to regenerate its certificate, or to the " + "panel's .local name, which its certificate already covers", + reconfigure_entry.title, host, ) return self.async_show_form( step_id="reconfigure", data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}), - errors={"base": "ca_leaf_mismatch"}, + errors={"base": "ca_name_mismatch"}, ) data_updates: dict[str, Any] = {CONF_HOST: host} diff --git a/custom_components/span_panel/config_flow_validation.py b/custom_components/span_panel/config_flow_validation.py index 3337a5fc..a035dbcb 100644 --- a/custom_components/span_panel/config_flow_validation.py +++ b/custom_components/span_panel/config_flow_validation.py @@ -5,6 +5,7 @@ import asyncio from collections.abc import Mapping from dataclasses import dataclass +from enum import StrEnum import ipaddress import logging import socket @@ -19,6 +20,7 @@ ca_fingerprint, detect_api_version, download_ca_cert, + leaf_names_host, register_v2, ) from span_panel_api.exceptions import ( @@ -211,45 +213,104 @@ async def async_fetch_panel_ca(hass: HomeAssistant, host: str, http_port: int = ) -async def async_leaf_chains_to_ca(host: str, tls_port: int, ca_pem: str) -> bool: - """Whether the certificate served on `tls_port` validates against `ca_pem` alone. +class LeafVerdict(StrEnum): + """What the certificate on a host says about that host. + + One boolean used to answer three different questions, and the answers call + for opposite responses. Splitting them is the whole point: + + - `TRUSTED` — the leaf chains to the anchor *and* names this host. The only + verdict that authorises storing the host or carrying traffic to it. + - `NAME_MISMATCH` — the leaf chains to the anchor but does not name this + host. Only the panel can produce this, because only the panel holds a key + the anchor signed; what it means is that the panel's certificate has not + caught up with the address it is being reached at, which is what a DHCP + move looks like from here. + - `UNTRUSTED` — something answered and its certificate does not chain to the + anchor. The interception case, and the only one worth alarming a user + about. + - `UNREACHABLE` — nothing answered at all. Distinct from `UNTRUSTED` + because it had been collapsed into it, so an unplugged cable was reported + to users as "the certificate this panel serves is not signed by the + authority it published" — an accusation of interception for a network + timeout. + """ + + TRUSTED = "trusted" + NAME_MISMATCH = "name_mismatch" + UNTRUSTED = "untrusted" + UNREACHABLE = "unreachable" + + +async def async_leaf_verdict(host: str, tls_port: int, ca_pem: str) -> LeafVerdict: + """Classify the certificate served on `tls_port` against `ca_pem`. - A CA that cannot validate the certificate the panel actually serves is not - the panel's CA. That does not make a fetched PEM trustworthy — an attacker - who can substitute the CA can serve a leaf signed by it — but it does catch - the fetch that returned something unrelated, and it is the only check - available without a fingerprint from another channel. + One handshake, with hostname checking off, which establishes the chain; the + name binding is then evaluated separately from the certificate the + handshake returned. Both halves come from the library rather than being + written here, for the reason its `_ssl` module gives about the fingerprint: + a security primitive with two implementations drifts, and a hand-written + hostname matcher is the most error-prone of the three now that + `ssl.match_hostname` no longer exists to defer to. - Hostname verification stays on, so this also fails when the panel's - certificate does not name the address being used, which is the condition - `check_fqdn_tls_ready` is polling for. + Relaxing the hostname check does not relax trust. The chain, the signature + and the expiry are all still verified against the pinned anchor, so a peer + without a key that anchor signed fails here exactly as it did before; what + changes is only that failing the *name* is now reported as a different + thing from failing the *chain*. """ loop = asyncio.get_running_loop() - def _check() -> bool: + def _check() -> LeafVerdict: try: # The library's builder, not a hand-rolled context: the panel's CA # omits the Authority Key Identifier extension, which Python's # default-on VERIFY_X509_STRICT rejects outright. A context built # here without clearing that flag fails against a healthy panel. - ctx = build_panel_ssl_context(ca_pem) + ctx = build_panel_ssl_context(ca_pem, check_hostname=False) except (ssl.SSLError, ValueError): - return False + # An anchor that will not load cannot validate anything. Nothing was + # reached, so this is not an accusation against the host. + return LeafVerdict.UNTRUSTED try: with ( socket.create_connection((host, tls_port), timeout=5) as sock, - ctx.wrap_socket(sock, server_hostname=host), + ctx.wrap_socket(sock, server_hostname=host) as tls, ): - return True - except (ssl.SSLCertVerificationError, ssl.SSLError, OSError, TimeoutError, UnicodeError): + peer = tls.getpeercert() + except ssl.SSLCertVerificationError: + return LeafVerdict.UNTRUSTED + except (OSError, TimeoutError, UnicodeError): # `UnicodeError` for the same reason as in `async_resolve_host`: the # connect resolves the name, and an over-long label raises out of - # IDNA encoding rather than as a lookup failure. - return False + # IDNA encoding rather than as a lookup failure. `ssl.SSLError` is a + # subclass of `OSError` and lands here too -- a handshake that broke + # without a verification failure says nothing about the peer's + # certificate, so it is a failure to reach, not a failure to trust. + return LeafVerdict.UNREACHABLE + + # The handshake completed under the pinned anchor, so the peer holds a + # key that anchor signed. Only the name is left in question. + if peer and leaf_names_host(peer, host): + return LeafVerdict.TRUSTED + return LeafVerdict.NAME_MISMATCH return await loop.run_in_executor(None, _check) +async def async_leaf_chains_to_ca(host: str, tls_port: int, ca_pem: str) -> bool: + """Whether the panel at `host` serves a certificate that chains *and* names it. + + The question every existing caller was already asking, kept as its own name + so that the answer does not quietly widen underneath them. A caller that + wants to act on *why* the answer is no asks `async_leaf_verdict` instead; + the two must not be confused, because a `NAME_MISMATCH` host is one this + function has always refused and must keep refusing -- storing it would + strand every later connection, all of which verify the hostname. + """ + return await async_leaf_verdict(host, tls_port, ca_pem) is LeafVerdict.TRUSTED + + async def async_panel_leaf_host( hass: HomeAssistant, host: str, tls_port: int, ca_pem: str ) -> str | None: @@ -443,9 +504,26 @@ def panel_rest_transport( entry_data: Mapping[str, object], *, allow_plaintext_fallback: bool = True, + verify_hostname: bool = True, ) -> PanelRestTransport: """Decide how this entry's REST calls should reach the panel. + `verify_hostname=False` keeps the pin and drops only the name binding, for + the single case that needs it: a reconfigure of a pinned entry whose panel + is answering at an address its certificate does not yet name. Such a panel + has proven it holds a key the pinned anchor signed -- nothing else can + complete the handshake -- but every call to it still fails hostname + verification, so without this the flow cannot probe the panel, cannot ask + it to regenerate its certificate, and cannot repair the situation it + exists to repair. + + It is for a flow deciding *which* host to talk to, and for nothing else. It + must never reach a transport that gets stored on the entry or used at + runtime: the name binding is what stops a validated certificate being + replayed by a host it was not issued to, and a coordinator carrying data + over a relaxed context would give that up permanently rather than for the + length of one flow. + A malformed stored PEM falls back to plaintext rather than raising. The alternative is a config entry that cannot make a single REST call until somebody edits `.storage` by hand, which is a worse outcome than the @@ -469,7 +547,7 @@ def panel_rest_transport( pem = entry_data.get(CONF_PANEL_CA_PEM) if pem: try: - context = build_panel_ssl_context(str(pem)) + context = build_panel_ssl_context(str(pem), check_hostname=verify_hostname) except (ssl.SSLError, ValueError) as err: if not allow_plaintext_fallback: raise PanelCaUnusableError(str(err)) from err diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 9ad216bb..7154cdc6 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -23,11 +23,11 @@ ], "quality_scale": "gold", "requirements": [ - "span-panel-api==3.1.1", + "span-panel-api==3.2.0", "span-panel-api-schema-0==1.1.2", "span-panel-api-schema-1==1.1.3" ], - "version": "2.1.0", + "version": "2.1.0b22", "zeroconf": [ { "type": "_span._tcp.local." diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index 7a022294..9adc0d95 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -14,6 +14,7 @@ }, "error": { "ca_leaf_mismatch": "The certificate this panel serves is not signed by the authority it published. Check that the HTTPS port is correct and that nothing is intercepting the connection, then try again.", + "ca_name_mismatch": "The panel answered at this address but its certificate does not name it, so every later connection would reject it. Reconfigure to a fully qualified domain name, which asks the panel to regenerate its certificate to include it, or to the panel's .local name, which its certificate already covers.", "ca_unavailable": "The panel's certificate authority could not be read. Check that the panel is reachable and powered on, then try again. Setup cannot continue without it, because your passphrase would otherwise be sent in the clear.", "ca_unusable": "The certificate authority stored for this panel cannot be read, so this change would have to be sent unencrypted. Nothing has been changed. Reauthenticate the panel to store a usable authority, then try again.", "cannot_connect": "Failed to connect to Span Panel", diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index 7a022294..9adc0d95 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -14,6 +14,7 @@ }, "error": { "ca_leaf_mismatch": "The certificate this panel serves is not signed by the authority it published. Check that the HTTPS port is correct and that nothing is intercepting the connection, then try again.", + "ca_name_mismatch": "The panel answered at this address but its certificate does not name it, so every later connection would reject it. Reconfigure to a fully qualified domain name, which asks the panel to regenerate its certificate to include it, or to the panel's .local name, which its certificate already covers.", "ca_unavailable": "The panel's certificate authority could not be read. Check that the panel is reachable and powered on, then try again. Setup cannot continue without it, because your passphrase would otherwise be sent in the clear.", "ca_unusable": "The certificate authority stored for this panel cannot be read, so this change would have to be sent unencrypted. Nothing has been changed. Reauthenticate the panel to store a usable authority, then try again.", "cannot_connect": "Failed to connect to Span Panel", diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index 9e27eede..aed70164 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -14,6 +14,7 @@ }, "error": { "ca_leaf_mismatch": "El certificado que sirve este panel no está firmado por la autoridad que ha publicado. Compruebe que el puerto HTTPS es correcto y que nada intercepta la conexión, y vuelva a intentarlo.", + "ca_name_mismatch": "El panel respondió en esta dirección pero su certificado no la nombra, por lo que cualquier conexión posterior la rechazaría. Reconfigure con un nombre de dominio completo, lo que pide al panel que regenere su certificado para incluirlo, o con el nombre .local del panel, que su certificado ya cubre.", "ca_unavailable": "No se ha podido leer la autoridad de certificación del panel. Compruebe que el panel es accesible y está encendido, y vuelva a intentarlo. La configuración no puede continuar sin ella, porque de lo contrario su contraseña se enviaría en claro.", "ca_unusable": "La autoridad de certificación almacenada para este panel no se puede leer, por lo que este cambio tendría que enviarse sin cifrar. No se ha cambiado nada. Vuelva a autenticar el panel para almacenar una autoridad utilizable y vuelva a intentarlo.", "cannot_connect": "No se logró establecer conexión con Span Panel", diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index 897335e2..1c5b90de 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -14,6 +14,7 @@ }, "error": { "ca_leaf_mismatch": "Le certificat présenté par ce panneau n'est pas signé par l'autorité qu'il a publiée. Vérifiez que le port HTTPS est correct et que rien n'intercepte la connexion, puis réessayez.", + "ca_name_mismatch": "Le panneau a répondu à cette adresse mais son certificat ne la nomme pas, de sorte que toute connexion ultérieure la rejetterait. Reconfigurez avec un nom de domaine pleinement qualifié, ce qui demande au panneau de régénérer son certificat pour l'inclure, ou avec le nom .local du panneau, que son certificat couvre déjà.", "ca_unavailable": "L'autorité de certification du panneau n'a pas pu être lue. Vérifiez que le panneau est joignable et sous tension, puis réessayez. La configuration ne peut pas continuer sans elle, car votre mot de passe serait sinon envoyé en clair.", "ca_unusable": "L'autorité de certification enregistrée pour ce panneau ne peut pas être lue, ce changement devrait donc être envoyé sans chiffrement. Rien n'a été modifié. Réauthentifiez le panneau pour enregistrer une autorité utilisable, puis réessayez.", "cannot_connect": "Échec de la connexion au Span Panel", diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index c11ae3a3..eb12e4f7 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -14,6 +14,7 @@ }, "error": { "ca_leaf_mismatch": "このパネルが提供する証明書は、パネルが公開した認証局で署名されていません。HTTPS ポートが正しいこと、接続が傍受されていないことを確認してから、もう一度お試しください。", + "ca_name_mismatch": "パネルはこのアドレスで応答しましたが、証明書がこのアドレスを名前として含んでいないため、以降の接続はすべて拒否されます。完全修飾ドメイン名で再構成すると、パネルに証明書の再生成を求めてこのアドレスを含めます。または、証明書が既に対象としているパネルの .local 名で再構成してください。", "ca_unavailable": "パネルの認証局を読み取れませんでした。パネルに到達でき、電源が入っていることを確認してから、もう一度お試しください。これがないとセットアップは続行できません。続行するとパスフレーズが平文で送信されてしまうためです。", "ca_unusable": "このパネルに保存されている認証局を読み取れないため、この変更は暗号化されずに送信されることになります。何も変更されていません。パネルを再認証して使用可能な認証局を保存してから、もう一度お試しください。", "cannot_connect": "スパンパネルへの接続に失敗しました", diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index b0529486..8e14fca3 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -14,6 +14,7 @@ }, "error": { "ca_leaf_mismatch": "O certificado que este painel serve não está assinado pela autoridade que publicou. Verifique se a porta HTTPS está correta e se nada está a intercetar a ligação, e tente novamente.", + "ca_name_mismatch": "O painel respondeu neste endereço mas o seu certificado não o nomeia, pelo que qualquer ligação posterior o rejeitaria. Reconfigure com um nome de domínio totalmente qualificado, o que pede ao painel que regenere o seu certificado para o incluir, ou com o nome .local do painel, que o seu certificado já cobre.", "ca_unavailable": "Não foi possível ler a autoridade de certificação do painel. Verifique se o painel está acessível e ligado e tente novamente. A configuração não pode continuar sem ela, porque de outro modo a sua frase-passe seria enviada em claro.", "ca_unusable": "A autoridade de certificação armazenada para este painel não pode ser lida, pelo que esta alteração teria de ser enviada sem cifra. Nada foi alterado. Volte a autenticar o painel para armazenar uma autoridade utilizável e tente novamente.", "cannot_connect": "Falha ao ligar ao Painel Span", diff --git a/pyproject.toml b/pyproject.toml index 09edb043..1003054a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ readme = "README.md" requires-python = ">=3.14.3,<3.15" dependencies = [ "homeassistant==2026.8.0", - "span-panel-api==3.1.1", + "span-panel-api==3.2.0", # Separate distributions, not dependencies of the bootstrap: adapters are found # through the `span_panel_api.schema_adapters` entry-point group, so each one has # to be installed for discovery to see it. manifest.json carries the same three, diff --git a/requirements_test.txt b/requirements_test.txt index a87d993e..12589e57 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -3,6 +3,6 @@ pytest-asyncio>=1.4.0 setuptools>=65.7.0 pytest-homeassistant-custom-component==0.13.354 homeassistant==2026.8.0 -span-panel-api==3.1.1 +span-panel-api==3.2.0 span-panel-api-schema-0==1.1.2 span-panel-api-schema-1==1.1.3 diff --git a/tests/test_config_flow_validation.py b/tests/test_config_flow_validation.py index 477401ff..28aeb38a 100644 --- a/tests/test_config_flow_validation.py +++ b/tests/test_config_flow_validation.py @@ -248,6 +248,11 @@ def __enter__(self): def __exit__(self, exc_type, exc, tb): return False + def getpeercert(self): + # The handshake now establishes only the chain; the name binding is + # judged separately from the certificate it hands back. + return {"subjectAltName": (("DNS", "panel.example.com"),)} + class FakeSSLContext: def wrap_socket(self, _sock, server_hostname: str): assert server_hostname == "panel.example.com" @@ -274,7 +279,7 @@ def wrap_socket(self, _sock, server_hostname: str): assert await check_fqdn_tls_ready("panel.example.com", 8883, "pinned-pem") is True mock_download.assert_not_awaited() - build_context.assert_called_once_with("pinned-pem") + build_context.assert_called_once_with("pinned-pem", check_hostname=False) @pytest.mark.asyncio @@ -292,6 +297,11 @@ def __enter__(self): def __exit__(self, exc_type, exc, tb): return False + def getpeercert(self): + # The handshake now establishes only the chain; the name binding is + # judged separately from the certificate it hands back. + return {"subjectAltName": (("DNS", "panel.example.com"),)} + class FakeSSLContext: def wrap_socket(self, _sock, server_hostname: str): assert server_hostname == "panel.example.com" @@ -315,7 +325,7 @@ def wrap_socket(self, _sock, server_hostname: str): # The library's builder, not a hand-rolled context: it is the one that clears # VERIFY_X509_STRICT, which the panel's AKI-less CA fails without. - build_context.assert_called_once_with("pem-data") + build_context.assert_called_once_with("pem-data", check_hostname=False) @pytest.mark.asyncio @@ -333,6 +343,11 @@ def __enter__(self): def __exit__(self, exc_type, exc, tb): return False + def getpeercert(self): + # The handshake now establishes only the chain; the name binding is + # judged separately from the certificate it hands back. + return {"subjectAltName": (("DNS", "panel.example.com"),)} + class FakeSSLContext: def wrap_socket(self, _sock, server_hostname: str): raise ssl.SSLError(f"bad cert for {server_hostname}") diff --git a/tests/test_v2_config_flow.py b/tests/test_v2_config_flow.py index ed2e4671..8ec2ca96 100644 --- a/tests/test_v2_config_flow.py +++ b/tests/test_v2_config_flow.py @@ -1038,7 +1038,9 @@ async def test_replacing_an_unusable_stored_anchor_says_what_it_is_now_pinned_to ) entry.add_to_hass(hass) - def _build(pem: str) -> MagicMock: + def _build(pem: str, *, check_hostname: bool = True) -> MagicMock: + # `check_hostname` is accepted and ignored: an anchor that will not load + # fails the same way whichever question the caller was going to ask of it. if pem == "not-a-certificate": raise ssl.SSLError("not a certificate") return MagicMock() diff --git a/tests/test_v2_config_flow_tls.py b/tests/test_v2_config_flow_tls.py index b0064d65..1951f325 100644 --- a/tests/test_v2_config_flow_tls.py +++ b/tests/test_v2_config_flow_tls.py @@ -645,6 +645,13 @@ async def test_reconfigure_refuses_a_name_the_pinned_certificate_does_not_cover( The address bootstrap is what lets the probe reach the panel at all; it is not permission to store a host the pinned certificate rejects, and nothing on this branch will ask the panel to start serving it. + + Refused as a *naming* failure, not a signing one. The panel answered and its + certificate chains to the pinned anchor -- nothing else holds a key that + anchor signed -- so telling the user it "is not signed by the authority it + published" would accuse the network of interception over a name the panel + simply has not been told about. The remedies differ too, and only the + naming message can name them. """ entry = _pinned_entry(hass, panel) @@ -655,7 +662,7 @@ async def test_reconfigure_refuses_a_name_the_pinned_certificate_does_not_cover( assert result["type"] == FlowResultType.FORM, result assert result["step_id"] == "reconfigure" - assert result["errors"] == {"base": "ca_leaf_mismatch"} + assert result["errors"] == {"base": "ca_name_mismatch"} assert entry.data[CONF_HOST] == PANEL_LOOPBACK @@ -832,3 +839,147 @@ async def test_re_adding_a_pinned_panel_by_a_name_it_serves_moves_the_entry( assert result["type"] == FlowResultType.ABORT, result assert result["reason"] == "already_configured" assert entry.data[CONF_HOST] == PANEL_SHORTNAME + + +@pytest.fixture +def moved_panel(tmp_path: Any) -> Iterator[Panel]: + """Serve a leaf naming an address the panel no longer has, as a moved lease does. + + The listener is on loopback, so the flow reaches it while its certificate + names 10.0.0.99 and nothing else. That is what a DHCP move looks like from + the integration's side, and it is chain-valid throughout -- the panel is + still the panel, and still the only holder of a key the pinned anchor + signed. + """ + instance = Panel(tmp_path) + instance.present([x509.IPAddress(ipaddress.ip_address("10.0.0.99"))]) + yield instance + instance.close() + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_a_moved_panel_can_be_reconfigured_to_an_fqdn( + hass: HomeAssistant, moved_panel: Panel +) -> None: + """The case that had no way out before this. + + The panel's certificate names neither the address it now answers on nor the + name being moved to, so every probe over the pinned transport failed + hostname verification and the flow reported it unreachable. Reconfigure was + the documented remedy and refused for the same reason, the mDNS move-guard + refused, and reauth offers no host field -- deleting the entry was the only + route left. + + Registration is what repairs it: the flow probes over a transport that keeps + the pin and drops only the name binding, asks the panel to regenerate its + certificate around the FQDN, and stores the name once the panel serves it. + """ + entry = _pinned_entry(hass, moved_panel) + + register = AsyncMock(side_effect=_registration_adds_the_fqdn(moved_panel)) + with ( + patch("custom_components.span_panel.config_flow.register_fqdn", new=register), + patch("custom_components.span_panel.config_flow.asyncio.sleep", new=AsyncMock()), + ): + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: PANEL_FQDN} + ) + result = await _finish_progress(hass, result) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT, result + assert result["reason"] == "reconfigure_successful" + assert entry.data[CONF_HOST] == PANEL_FQDN + assert entry.data[CONF_REGISTERED_FQDN] == PANEL_FQDN + # The anchor is untouched throughout: nothing here is a CA change. + assert entry.data[CONF_PANEL_CA_PEM] == moved_panel.ca_pem + assert register.await_args.args[2] == PANEL_FQDN + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_a_moved_panel_is_still_refused_at_a_bare_address( + hass: HomeAssistant, moved_panel: Panel +) -> None: + """Reaching the panel is not permission to store the address it was reached at. + + Nothing on this branch asks the panel to start naming the address, so the + entry would be stored pointing somewhere the coordinator and the broker both + reject -- they verify the hostname and cannot be relaxed the way one flow's + own probe can. + """ + entry = _pinned_entry(hass, moved_panel) + + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: PANEL_LOOPBACK} + ) + + assert result["type"] == FlowResultType.FORM, result + assert result["errors"] == {"base": "ca_name_mismatch"} + assert entry.data[CONF_HOST] == PANEL_LOOPBACK + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_an_impostor_is_still_refused_as_a_signing_failure( + hass: HomeAssistant, panel: Panel +) -> None: + """The guarantee the relaxed probe must not weaken. + + Relaxing the name binding leaves the chain, the signature and the expiry + verified against the pinned anchor, so a host without a key that anchor + signed fails exactly as it did before -- and is reported as what it is, + rather than as a naming problem. + """ + entry = _pinned_entry(hass, panel) + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_PANEL_CA_PEM: unrelated_ca_pem()} + ) + + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: PANEL_LOOPBACK} + ) + + assert result["type"] == FlowResultType.FORM, result + assert result["errors"] == {"base": "ca_leaf_mismatch"} + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_an_unreachable_host_is_not_accused_of_interception( + hass: HomeAssistant, panel: Panel +) -> None: + """An unplugged cable used to be reported as an unsigned certificate. + + `async_leaf_chains_to_ca` answered False for a timeout and a verification + failure alike, so a host that never answered produced "the certificate this + panel serves is not signed by the authority it published" -- an accusation + about a certificate nothing ever presented. + """ + entry = _pinned_entry(hass, panel) + + # A port on loopback with nothing listening: connection refused, no TLS. + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_HTTPS_PORT: _closed_port()} + ) + + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: PANEL_LOOPBACK} + ) + + assert result["type"] == FlowResultType.FORM, result + assert result["errors"] == {"base": "cannot_connect"} + + +def _closed_port() -> int: + """Return a port nothing is listening on, by binding and immediately closing.""" + import socket as _socket + + with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) diff --git a/uv.lock b/uv.lock index 49879d9f..d615e95a 100644 --- a/uv.lock +++ b/uv.lock @@ -2619,7 +2619,7 @@ dev = [ [[package]] name = "span-panel-api" -version = "3.1.1" +version = "3.2.0" source = { editable = "../../span/span-panel-api" } dependencies = [ { name = "httpx" }, From a2674c94f898d51c24c166352d9d17caf8fb5603 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:51:59 -0700 Subject: [PATCH 2/7] fix(config_flow): a moved panel with no address to fall back to is refused "Continue anyway" after a failed FQDN registration stored the name and reported success. On every path but one that is the right trade: the flow falls back to the address it reached the panel by, which the anchor verified, so the entry lands somewhere usable. The moved-panel path has no such address. Its certificate names neither the FQDN nor anything reachable -- that is why the flow relaxed the name binding for its own probe -- so `_fall_back_to_the_bootstrap_address` had nothing to adopt and returned having changed nothing, and the flow then wrote the unserved name behind a `reconfigure_successful` banner. Every runtime connection would fail the hostname check the coordinator and the broker apply and cannot relax: the stranded entry this branch refuses for bare IP addresses, delivered as a success. It now re-shows the form with `ca_name_mismatch` and leaves the entry untouched, and the fallback's docstring no longer claims an invariant it cannot restore without an address. Also: an empty `getpeercert()` after a completed handshake now classifies as UNTRUSTED rather than NAME_MISMATCH. `CERT_REQUIRED` should make it unreachable, but NAME_MISMATCH is the verdict that unlocks the relaxed transport, and "no validated certificate in hand" is not evidence that anything holds a key the anchor signed. --- custom_components/span_panel/config_flow.py | 28 ++++++++++++ .../span_panel/config_flow_validation.py | 9 +++- tests/test_v2_config_flow_tls.py | 44 +++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/custom_components/span_panel/config_flow.py b/custom_components/span_panel/config_flow.py index e69e4f8c..03b32a4d 100644 --- a/custom_components/span_panel/config_flow.py +++ b/custom_components/span_panel/config_flow.py @@ -936,6 +936,13 @@ def _fall_back_to_the_bootstrap_address(self) -> None: so this is the point where an unusable name is traded for the address that got this far — and the invariant is restored, because the host being recorded is now one the anchor accepts. + + That restoration depends on there being an address to trade for. On the + moved-panel path there is not: the leaf names nothing this flow could + reach the panel by, so `_bootstrap_host` is None and this returns having + changed nothing. A caller must not read that as permission to store + `self.host` — see `async_step_reconfigure_fqdn_failed`, which refuses + rather than record a name the panel does not serve. """ if self._bootstrap_host is None: return @@ -1523,6 +1530,27 @@ async def async_step_reconfigure_fqdn_failed( that is what the next reconfigure needs in order to clean it up. """ if user_input is not None: + if not self._leaf_names_host and self._bootstrap_host is None: + # There is nothing to continue to. This is the moved-panel path: + # the certificate names neither the name asked for nor any + # address this flow could reach the panel by, and registration — + # the one thing that would have made the panel serve the name — + # is what just failed. Falling back has no address to fall back + # to, so continuing would store the unserved name and report + # success, stranding every later connection on the hostname + # check this flow relaxed only for itself. + _LOGGER.warning( + "Registration failed and panel %s serves a certificate naming neither %s " + "nor an address this flow reached it by, so there is no host to store. " + "Try the panel's .local name, which its certificate already covers", + self._get_reconfigure_entry().title, + self.host, + ) + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema({vol.Required(CONF_HOST, default=self.host or ""): str}), + errors={"base": "ca_name_mismatch"}, + ) # User chose to continue anyway — update host without FQDN registration self._fall_back_to_the_bootstrap_address() reconfigure_entry = self._get_reconfigure_entry() diff --git a/custom_components/span_panel/config_flow_validation.py b/custom_components/span_panel/config_flow_validation.py index a035dbcb..b421cee2 100644 --- a/custom_components/span_panel/config_flow_validation.py +++ b/custom_components/span_panel/config_flow_validation.py @@ -291,7 +291,14 @@ def _check() -> LeafVerdict: # The handshake completed under the pinned anchor, so the peer holds a # key that anchor signed. Only the name is left in question. - if peer and leaf_names_host(peer, host): + if not peer: + # `CERT_REQUIRED` should make this unreachable. It fails to + # UNTRUSTED rather than to NAME_MISMATCH because NAME_MISMATCH is + # the verdict that unlocks the relaxed transport, and "the handshake + # completed but no validated certificate came back" is not evidence + # that anything holds a key the anchor signed. + return LeafVerdict.UNTRUSTED + if leaf_names_host(peer, host): return LeafVerdict.TRUSTED return LeafVerdict.NAME_MISMATCH diff --git a/tests/test_v2_config_flow_tls.py b/tests/test_v2_config_flow_tls.py index 1951f325..72d9d289 100644 --- a/tests/test_v2_config_flow_tls.py +++ b/tests/test_v2_config_flow_tls.py @@ -983,3 +983,47 @@ def _closed_port() -> int: with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as sock: sock.bind(("127.0.0.1", 0)) return int(sock.getsockname()[1]) + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_a_moved_panel_whose_registration_fails_has_nothing_to_continue_to( + hass: HomeAssistant, moved_panel: Panel +) -> None: + """"Continue anyway" must not store a name the panel never started serving. + + On every other path there is a verified address to fall back to -- the one + the flow reached the panel by. On this one there is not: the certificate + names neither the FQDN nor anything reachable, which is why the flow relaxed + the name binding for its own probe in the first place, and registration was + the single thing that would have fixed that. It failed. + + Falling back therefore has nowhere to fall back to, and continuing would + write the unserved name and report success -- stranding every runtime + connection on the hostname check, which the coordinator and the broker apply + and cannot relax. The entry is left exactly as it was. + """ + entry = _pinned_entry(hass, moved_panel) + + with ( + # The panel accepts the call and regenerates nothing, so its leaf still + # names the address it no longer has. + patch("custom_components.span_panel.config_flow.register_fqdn", new=AsyncMock()), + patch("custom_components.span_panel.config_flow.asyncio.sleep", new=AsyncMock()), + ): + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: PANEL_FQDN} + ) + result = await _finish_progress(hass, result) + assert result["step_id"] == "reconfigure_fqdn_failed", result + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM, result + assert result["errors"] == {"base": "ca_name_mismatch"} + # Untouched: the entry still points where it did, and is still pinned. + assert entry.data[CONF_HOST] == PANEL_LOOPBACK + assert CONF_REGISTERED_FQDN not in entry.data + assert entry.data[CONF_PANEL_CA_PEM] == moved_panel.ca_pem From cf23c30bfd0e7b8ddf71de5b299d09c9d3a00585 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:55:46 -0700 Subject: [PATCH 3/7] test(config_flow): prove the refusal form is not a dead end The Finding A fix returns a `reconfigure` form from inside `async_step_reconfigure_fqdn_failed`, which is only correct if submitting it re-enters `async_step_reconfigure` and gets judged again. Asserted rather than assumed: the test now submits the form it lands on and confirms the flow is still live and the entry still untouched. --- tests/test_v2_config_flow_tls.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_v2_config_flow_tls.py b/tests/test_v2_config_flow_tls.py index 72d9d289..06b3e046 100644 --- a/tests/test_v2_config_flow_tls.py +++ b/tests/test_v2_config_flow_tls.py @@ -1022,8 +1022,19 @@ async def test_a_moved_panel_whose_registration_fails_has_nothing_to_continue_to await hass.async_block_till_done() assert result["type"] == FlowResultType.FORM, result + assert result["step_id"] == "reconfigure" assert result["errors"] == {"base": "ca_name_mismatch"} # Untouched: the entry still points where it did, and is still pinned. assert entry.data[CONF_HOST] == PANEL_LOOPBACK assert CONF_REGISTERED_FQDN not in entry.data assert entry.data[CONF_PANEL_CA_PEM] == moved_panel.ca_pem + + # And the form it lands on is live, not a dead end: submitting it re-enters + # the reconfigure step and is judged again, rather than wedging the flow. + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: PANEL_LOOPBACK} + ) + assert result["type"] == FlowResultType.FORM, result + assert result["step_id"] == "reconfigure" + assert result["errors"] == {"base": "ca_name_mismatch"} + assert entry.data[CONF_HOST] == PANEL_LOOPBACK From 4e805e72849be059d62443bfe945068d7c0888f6 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:01:32 -0700 Subject: [PATCH 4/7] docs: make the security section guidance, not narrative The section had grown to explain its own reasoning inline, so a reader looking for what to do worked through why each choice was made to find it. The four situation-and-remedy paragraphs -- hostname setup, the mDNS move guard, Reconfigure, and an entry with no anchor -- are a table now, which leaves nowhere to put a justifying clause. 68 lines to 30. The reasoning is kept, in developer.md under "Why the CA pinning behaves as it does": why no fingerprint is offered at first contact, why diagnostics carry the fingerprint and not the certificate, why an unreachable panel does not stop setup, why reauth keeps the anchor it acquires, the difference between pinning on screen and pinning during a reload, and why a registered domain is checked when the panel reports it. Corrections along the way: - The deployment bullet named `tcp/80` for the REST bootstrap. A pinned entry uses `tcp/443`; `tcp/80` is the plaintext CA fetch, and REST only until the entry pins. Following it literally would have blocked every REST call the moment pinning succeeded. - "The changeover is designed to be seamless.**" had a closing bold marker with no opener, so it rendered literally. - The `r202633` sentence was never closed and "after the firmware hits" dangled. - "differ in two:" introduced four bullets, one of which is not a difference. - The prerequisite read as a floor; 2.0.8 is a required stepping stone. - Install step 8 said "IP address or .local address". - One `behavior` against the document's British spelling. The adopted-device and adopted-vendor-reading sections move below the entity tables. They had been sitting between the Microgrid Interconnect Device and Power Sensor Attributes, so a reader working down the tables hit fifty lines of vendor-extensibility prose and landed back in tables. developer.md's Supervisor section pointed at a README paragraph that no longer exists; the reference is dropped. --- README.md | 247 +++++++++++++++++++++------------------------------ developer.md | 32 ++++++- 2 files changed, 131 insertions(+), 148 deletions(-) diff --git a/README.md b/README.md index aefe18ac..2b895663 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ The integration includes a built-in dashboard accessible from the Home Assistant monitoring with configurable alerts, and circuit settings for relays and load shedding. See [Frontend Dashboard](frontend.md) for details. You can optionally use the [span-card](https://github.com/SpanPanel/span-card) Lovelace card for visualization and switch control. -The [SPAN Panel Simulator](https://github.com/SpanPanel/simulator) HA App lets you clone your panel's circuit layout for testing, or model an upgrade to +The [SPAN Panel PanelBench](https://github.com/SpanPanel/panelbench) HA App lets you clone your panel's circuit layout for testing, or model an upgrade to evaluate firmware or integration changes in a sandbox before applying them to your real panel. This integration communicates with the SPAN Panel over your local network using SPAN's official @@ -46,23 +46,23 @@ real-time state updates without polling. ## ⚠️ Backup and Upgrade to v2.1.x before your panel's firmware updates, or the integration will stop working (upgrade only from v2.0.8!) -**SPAN firmware `r202633` changes the API in a non-compatible way after the firmware hits** When your panel takes that update, 2.0.8 stops being able to read -it. The integration still connects, still shows as loaded, and reports every circuit as missing — sensors go unavailable, automations stop firing, dashboards go -blank. It does not fail loudly. It goes quiet. +**SPAN firmware `r202633` changes the API in a way that is not backward compatible.** When your panel takes that update, 2.0.8 stops being able to read it. The +integration still connects, still shows as loaded, and reports every circuit as missing — sensors go unavailable, automations stop firing, dashboards go blank. +It does not fail loudly. It goes quiet. **Nobody outside SPAN knows when your panel will update, and you cannot defer it from Home Assistant.** Panels update on SPAN's timing. There is no schedule to -plan around, which is why the safe move is to be on 2.1.x already rather than to wait for a signal that is less appealing. +plan around, which is why the safe move is to be on 2.1.x already rather than to wait for the signal, which is your panel going quiet. **Upgrading first costs you nothing.** On your current firmware, 2.1.x reads your panel exactly as 2.0.8 does. -The changeover is designed to be seamless.** The integration detects the new format on the wire, reloads itself, and carries on: +**The changeover is designed to be seamless.** The integration detects the new format on the wire, reloads itself, and carries on: -- no re-pairing, no re-authentication. +- No re-pairing, no re-authentication. - Entity ids, unique ids and long-term statistics survive. Dashboards, automations and history follow. - New entities appear because the new firmware genuinely publishes more. Nothing you already had is removed. Upgrade afterwards instead and you reach the same place — after however long it takes you to notice, and to work out that a firmware update is the reason your -panel went silent. Worst case should be a reload. +panel went silent. The worst case is a reload. Take another backup after the upgrade. @@ -81,7 +81,7 @@ The old format is retired in the same update that introduces the new one — the - [Home Assistant](https://www.home-assistant.io/) installed - [HACS](https://hacs.xyz/) installed - SPAN Panel with firmware `spanos2/r202603/05` or later -- SPAN Panel integration v1.3.0 or later +- SPAN Panel integration v2.0.8 specifically, if you are upgrading to 2.1.x - Panel passphrase (found via the SPAN app) **or** physical access to the panel door ## Installation @@ -93,11 +93,11 @@ The old format is retired in the same update that introduces the new one — the 5. Restart Home Assistant (you will be prompted by a repair notification) 6. Go to `Settings` > `Devices & Services` 7. Click `+ Add Integration` and search for "Span" -8. Enter the IP address of your SPAN Panel +8. Pick your panel from the discovered list, or enter its IP address or `.local` name (both shown in the mobile app) 9. The integration detects the panel as v2 and presents an authentication choice: - **Enter Panel Passphrase** — type the passphrase found in the SPAN mobile app under On-premise settings - **Proof of Proximity** — open and close the panel door 3 times, then click Submit -10. Choose your entity naming pattern +10. Choose your entity ID naming pattern (see naming patterns below) 11. Optionally adjust the snapshot update interval — 0 is real-time, up to 15 seconds based on CPU ### Upgrade Process @@ -258,57 +258,6 @@ can island — so **Grid Islandable** reads `Off` and **Grid Forming Entity** re `DSM Grid State` keeps its entity id and history but is no longer inferred from the battery or the dominant power source; it now reads the islanding state the MID actually senses. -### Adopted Devices - -The eBus schema is vendor-extensible, so your panel can publish a device type this integration has never modelled. Rather than ignoring it, the integration -gives it a card of its own hanging off the panel, carrying whatever identity it publishes, with its readings as entities beneath it. - -Everything adopted arrives **disabled and diagnostic**, so nothing reaches a dashboard uninvited, and the new-entity notification names the device so you can -find it. A property the device accepts writes to becomes a control rather than a reading — a `boolean` becomes a switch, an enumeration becomes a select, a -number becomes a number entity — and those arrive switched off too. - -Two things worth knowing before you build on one: - -- **Nothing adopted enters long-term statistics.** No adopted entity carries a `state_class`, because the correct one is not published on the wire and guessing - wrong writes corrupt statistics that fixing the panel afterwards does not repair. If you want statistics from an adopted reading, wrap it in a template - sensor, a Riemann-sum integration or a utility meter — a deliberate choice on an entity you enabled. -- **A new property on a device this integration already models is adopted too**, but as a reading on that device's existing card rather than as a device of its - own. See [Adopted Vendor Readings](#adopted-vendor-readings) below. - -### Adopted Vendor Readings - -The other half of vendor extensibility. A publisher can add a property to a device this integration already models — the battery, a charger, the solar inverter, -a circuit or the panel itself — and until 2.1.x that reading went nowhere: it appeared in the diagnostics download and in no entity list. It now becomes an -entity on that device's own card. The wire already says which device and node it belongs to, so it has a home; what it does not say is how important it is. - -They behave like adopted devices in the ways that matter, and differ in two: - -- **They arrive switched off and filed as diagnostics**, so nothing lands on a dashboard uninvited. The new-entity notification names each one, up to five per - device; beyond that it gives the device and a count instead, because fifteen new vendor readings at once would otherwise cost you the curated additions in the - same message. -- **They are readings only — never switches, selects or number boxes**, even where the panel says the property accepts writes. These sit beside curated controls - that do real work, such as the charge limit that refuses a value above what your charger was commissioned for, and a generic control would sit there with none - of that. If a control is worth having, it arrives curated in a release. -- **They keep the panel's own wording**, so a vendor property on the battery reads `Battery 2 Cell Temperature` rather than something tidied up. Deliberately - plainer than a curated entity's name: it is how you tell at a glance which entities this integration designed and which it is passing through. -- **Nothing adopted enters long-term statistics**, exactly as for adopted devices, and here it buys something extra: with no statistics behind them, a later - release can correct one of these entities' units, device class or category with nothing to repair. - -**What the delete button does**, since it is not quite what you would expect: - -- Delete one while your panel is still publishing that property and it comes back — switched off — at the next reload. There is no setting to suppress it, - because leaving it switched off is already that. -- Delete one after your panel has stopped publishing it and it stays gone, because nothing exists to recreate it from. - -So deletion means "hide it until next time" for a live reading and "clear it out" for a dead one, and your panel decides which. A property your panel stops -publishing is left in place reading unknown rather than removed: silence on the wire does not distinguish a property that is gone from one that has not arrived -yet, and deleting your entity on a guess is not something an upgrade should do. - -**These entities are permanent in id, not in identity.** If one of these readings is later curated properly, the curated entity is a new entity with its own id -and its own history — the adopted one is not renamed into it. That is the trade for surfacing a reading the moment it appears rather than waiting for a release -to model it, and it is why a vendor reading you have come to depend on is worth mentioning in an issue: curation is what turns it into something with a real -name, a proper category and statistics. - ### Power Sensor Attributes Applies to Current Power, Feed Through Power, Battery Power, PV Power, Grid Power, and Site Power sensors. @@ -505,6 +454,54 @@ Applies to Main Meter and Feed Through energy sensors. | ---------------------------- | ------ | ----------------------------------------------------------------- | | GFE Override: Grid Connected | Button | Tell the panel the grid is up when BESS communication interrupted | +### Adopted Devices + +The eBus schema is vendor-extensible, so your panel can publish a device type this integration has never modelled. Rather than ignoring it, the integration +gives it a card of its own hanging off the panel, carrying whatever identity it publishes, with its readings as entities beneath it. + +Everything adopted arrives **disabled and diagnostic**, so nothing reaches a dashboard uninvited, and the new-entity notification names the device so you can +find it. A property the device accepts writes to becomes a control rather than a reading — a `boolean` becomes a switch, an enumeration becomes a select, a +number becomes a number entity — and those arrive switched off too. + +Two things worth knowing before you build on one: + +- **Nothing adopted enters long-term statistics.** No adopted entity carries a `state_class`, because the correct one is not published on the wire and guessing + wrong writes corrupt statistics that fixing the panel afterwards does not repair. If you want statistics from an adopted reading, wrap it in a template + sensor, a Riemann-sum integration or a utility meter — a deliberate choice on an entity you enabled. +- **A new property on a device this integration already models is adopted too**, but as a reading on that device's existing card rather than as a device of its + own. See [Adopted Vendor Readings](#adopted-vendor-readings) below. + +### Adopted Vendor Readings + +The other half of vendor extensibility. A publisher can add a property to a device this integration already models — the battery, a charger, the solar inverter, +a circuit or the panel itself — and until 2.1.x that reading went nowhere: it appeared in the diagnostics download and in no entity list. It now becomes an +entity on that device's own card. The wire already says which device and node it belongs to, so it has a home; what it does not say is how important it is. + +They behave like adopted devices in the ways that matter, and differ as follows: + +- **They arrive switched off and filed as diagnostics**, so nothing lands on a dashboard uninvited. The new-entity notification names each one, up to five per + device; beyond that it gives the device and a count instead, because fifteen new vendor readings at once would otherwise cost you the curated additions in the + same message. +- **They are readings only — never switches, selects or number boxes**, even where the panel says the property accepts writes. These sit beside curated controls + that do real work, such as the charge limit that refuses a value above what your charger was commissioned for, and a generic control would sit there with none + of that. If a control is worth having, it arrives curated in a release. +- **They keep the panel's own wording**, so a vendor property on the battery reads `Battery 2 Cell Temperature` rather than something tidied up. Deliberately + plainer than a curated entity's name: it is how you tell at a glance which entities this integration designed and which it is passing through. +- **What the delete button does**, since it is not quite what you would expect: + + - Delete one while your panel is still publishing that property and it comes back — switched off — at the next reload. There is no setting to suppress it, + because leaving it switched off is already that. + - Delete one after your panel has stopped publishing it and it stays gone, because nothing exists to recreate it from. + +So deletion means "hide it until next time" for a live reading and "clear it out" for a dead one, and your panel decides which. A property your panel stops +publishing is left in place reading unknown rather than removed: silence on the wire does not distinguish a property that is gone from one that has not arrived +yet, and deleting your entity on a guess is not something an upgrade should do. + +**These entities are permanent in id, not in identity.** If one of these readings is later curated properly (delivered as an official part of the integration), +the curated entity is a new entity with its own id and its own history — the adopted one is not renamed into it. That is the trade for surfacing a reading the +moment it appears rather than waiting for a release to model it, and it is why a vendor reading you have come to depend on is worth raising in an issue: +curation is what turns it into something with a real name, a proper category and statistics. + ### BESS & Grid Management This section explains how the SPAN panel manages power sources and load shedding when a Battery Energy Storage System (BESS) is installed, and what the @@ -569,7 +566,7 @@ even after the grid is restored. Manual confirmation or an external sensor is re When `bess_connected` returns to on, no action is needed — firmware resumes normal GFE management automatically. -For a detailed discussion of failure scenarios, the MID topology, generator and non-integrated BESS behavior, and `/set` risk analysis, see +For a detailed discussion of failure scenarios, the MID topology, generator and non-integrated BESS behaviour, and `/set` risk analysis, see [BESS & Grid Management Deep Dive](bess-grid-management.md). ## Configuration Options @@ -632,18 +629,12 @@ A dip is compensated as soon as it is seen, but not believed straight away. A co so a reading that drops and then returns to where it was is a transport artifact rather than a reset, and its offset is taken back. The offset stays provisional until a later reading either disproves it (the counter comes back) or corroborates it (the counter climbs from the new, lower base). -**Corroboration is evidence, not a verdict.** A debounced burst can deliver two stale samples with the later one higher, which looks exactly like a counter -climbing away from a fresh base, so a corroborated dip stays provisional for three further readings; a return to the original baseline inside that window still -takes the whole offset back. Once the window is spent the offset is final. A second dip arriving inside another's window is judged against its own baseline -rather than folded into the older one, so each is retracted or kept on its own evidence. - **The persistent notification therefore lists a dip once it settles — when that window closes — and a dip that is disproved produces no notification at all** — + the sensor was compensated the whole time and nothing needs your attention. Reporting on corroboration alone left a notice standing for an event the next reading undid, which a persistent notification cannot take back the way the offset can. Seeing no notification after a momentary dip is the feature working, not failing. -Both the provisional record and its remaining window survive a restart, so restarting neither loses a retraction nor renews the window it had left. - **Diagnostic attributes** (visible when compensation is active): | Attribute | Description | @@ -663,71 +654,33 @@ and select your preferred precision from the "Display Precision" menu. ## Security -There are limits to what the integration can enforce. Anything that already holds the eBus MQTT broker password — including another integration running in the -same Home Assistant process — talks to the panel directly and is not subject to Home Assistant's permission model at all. - -**What pinning the panel's certificate authority does and does not buy you.** The authority is fetched over your local network on a connection that has nothing -to verify itself against — it is the anchor everything else is checked against. So: - -- Anyone merely **listening** on your network cannot read your passphrase or the credentials the panel returns for it. -- A device **actively standing between** Home Assistant and your panel at that first fetch could answer with an authority of its own, sign a certificate with - it, and still see them. Pinning cannot detect that on its own. - -Comparing the fingerprint against another source is what closes the second case. At first contact there is nothing to compare against: SPAN does not publish the -value, so the question could only be answered by pressing Submit. The fingerprint is put where it can actually be used instead — diagnostics report it under -`panel_ca`, it is logged at setup, and another install of this integration on the same panel reports the same value. - -After the first pin, nothing can change the authority without stopping and asking you — there is a prior value to compare against. See the -certificate-authority-changed entry in Troubleshooting. - -**Re-authenticating an entry that has no anchor acquires one first.** Reauth is a registration, so it carries the passphrase out and the broker password back — -the same exchange setup performs. An entry that arrived from before pinning, or one whose stored authority will no longer load, goes through the -certificate-authority step before either sign-in method is offered, and keeps the anchor afterwards instead of falling back to a plaintext fetch on every -connection. - -**Setting a panel up by hostname works, and the hostname is verified.** A panel's certificate names the addresses it already knows itself by; a domain joins -that list only when the integration asks the panel to regenerate its certificate, which happens after you have authenticated. Everything before that runs -against the address the certificate already names, hostname verification never relaxed, and the domain is checked once the panel reports the new certificate. - -**A pinned entry follows its panel to a new address only when the new address proves itself.** An entry moves when your panel announces itself over mDNS at a -new address, and when you add the same panel again by hand. Both used to be decided by a serial read from an unauthenticated endpoint, which anything on your -network can claim. The entry now asks whether the candidate serves a certificate its own anchor validates, on the port the entry uses, and refuses the move — -logging at `WARNING` — otherwise. If the panel really has moved, use **Reconfigure** on the entry. - -**Reconfigure tells a moved panel apart from an impersonated one.** A panel that changed address serves a perfectly good certificate that does not yet name -where it now answers, and something impersonating your panel serves one that chains to nothing — the same failure, until you ask which. Reconfigure asks. A host -that does not chain to the pinned authority is refused as before, and one that does not answer at all is now reported as unreachable rather than as an unsigned -certificate. A host that chains but is not named is your panel, and the way through is to reconfigure to a **fully qualified domain name**, which asks the panel -to regenerate its certificate around that name, or to the panel's **`.local` name**, which its certificate already covers. Reconfiguring to a bare new IP -address the certificate does not name is refused, because the entry would then be pointed at an address every later connection rejects. - -**Panels configured before this release are pinned differently, and are not risk-free until they are.** They are pinned on the first startup that reaches the -panel, logged at `WARNING` with the fingerprint so you can find the value afterwards. What was fetched is checked against the certificate the panel serves on -the broker port — the connection the anchor is actually used for — and an authority that signs nothing the panel serves is not stored. Until a pin succeeds, the -connection to the broker still authenticates with your panel's password while the authority is re-fetched over plaintext HTTP on every connection and whatever -answers is trusted — the substitution pinning exists to close. +**The integration cannot enforce much on its own.** Anything holding the eBus broker password — including another integration in the same Home Assistant process +— talks to the panel directly, outside Home Assistant's permission model. -If the panel is unreachable the integration starts anyway and retries on the next startup. That is deliberate. The exposure in the meantime is exactly the one -the entry already had before this release, and refusing to start would not remove it — it would only remove the integration, leaving the credential no safer -while guaranteeing an outage. Retrying closes it at the first opportunity instead. +**What pinning the panel's certificate authority buys.** The authority is fetched over your network on a connection with nothing to verify itself against, +because it _is_ the anchor everything else is checked against: -The same is true of an entry whose broker port does not serve a certificate the panel's own authority signs — a proxy terminating TLS with a certificate of its -own, say. It stays unpinned and logs that warning at every start, and no repair is raised for it. +- A **listener** cannot read your passphrase or the credentials the panel returns for it. +- A device **actively in the path at that first fetch** can answer with an authority of its own and read both. Pinning alone cannot detect this. -The reverse arrangement has the opposite cost. Where a proxy terminates only port 443, the panel's own authority still signs the broker's certificate, so the -entry pins at the reload — and the anchor is then also what every REST call to the panel is verified against. **Reauthenticate**, **Reconfigure** and **Rotate -credentials** all refuse, rather than send your access token unencrypted, until port 443 serves the panel's own certificate too. +Comparing the fingerprint against another source closes the second case. It is in diagnostics under `panel_ca`, in the setup log, and reported identically by +another install of this integration on the same panel. After the first pin, any change stops the integration and raises a repair — see Troubleshooting. -**Reauthenticate is the route that does this on screen.** An unpinned entry sent through **Reauthenticate** goes to the certificate-authority step first, which -asks for the TLS port where the HTTP port has been moved — the install most likely to be behind a proxy — and refuses with an error if the authority does not -sign what the panel serves. Reconfiguring the entry to the panel's own address is the other way through, but the pin then happens during the reload that -follows, silently: the only announcement is a `WARNING` reading "Pinned the CA advertised by SPAN panel …" with the fingerprint. +| Situation | What happens | +| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Setting up by hostname | Verified, never relaxed. A domain joins the certificate's SAN only after you authenticate; everything before that runs against an address the certificate already names. | +| Panel announces a new address, or you re-add it | The entry moves only if the candidate serves a certificate its own anchor validates. Otherwise the move is refused and logged at `WARNING`. | +| The panel really has moved | Use **Reconfigure**. A host that does not chain is refused, one that does not answer is reported unreachable, and one that chains but is not named is your panel — move it to an **FQDN** (the panel regenerates its certificate around that name) or the panel's **`.local` name** (already covered). A bare new IP the certificate does not name is refused. | +| The entry has no anchor, or a stored one won't load | **Reauthenticate** acquires one before either sign-in method is offered. | -Diagnostics report the fingerprint under `panel_ca`. The certificate itself is not included — it is public, but multi-KB, and the fingerprint is the part worth -reading. +**Entries from before pinning** pin at the first startup that reaches the panel, logged at `WARNING` with the fingerprint. Until that succeeds the authority is +re-fetched over plaintext on every connection and whatever answers is trusted. If the panel is unreachable the integration starts anyway and retries, because +refusing to start would remove the integration without making the credential any safer. -If your panel serves TLS somewhere other than port 443 — behind a reverse proxy, say — the setup flow asks for the port, but only when you have already changed -the HTTP port from 80. +**Proxies cut both ways.** One terminating the broker port with a certificate of its own leaves the entry unpinned, warning at every start with no repair +raised. One terminating only port 443 lets the entry pin, but **Reauthenticate**, **Reconfigure** and **Rotate credentials** then refuse rather than send your +access token unencrypted, until port 443 serves the panel's own certificate too. If your panel serves TLS on another port the setup flow asks — but only once +you have moved the HTTP port off 80. ### Restricting who can operate the panel @@ -769,13 +722,13 @@ Every command is also logged at `INFO`. Commands report one of four outcomes, and the distinctions matter: -| Outcome | Meaning | -| ------------- | ---------------------------------------------------------------------------------------- | -| `confirmed` | The panel reported the value you asked for. | -| `accepted` | The broker acknowledged the message and the panel did not report a change. | -| `unconfirmed` | Nothing came back within the deadline. **Not an error** — see the troubleshooting entry. | -| `failed` | The command was never handed to the broker and will not be delivered. | -| `refused:…` | This integration refused it, for the named reason. | +| Outcome | Meaning | +| ------------- | --------------------------------------------------------------------------------------- | +| `confirmed` | The panel reported the value you asked for. | +| `accepted` | The broker acknowledged the message and the panel did not report a change. | +| `unconfirmed` | Nothing came back within the deadline.**Not an error** — see the troubleshooting entry. | +| `failed` | The command was never handed to the broker and will not be delivered. | +| `refused:…` | This integration refused it, for the named reason. | ### Rotating panel credentials @@ -812,15 +765,15 @@ the one that works. The action reports the failure and says so; check the log an The panel credential is a single all-or-nothing secret, and anyone standing at the panel can mint a fresh one with three presses of the door switch. Network topology and physical control of the panel are the real boundary; everything above is defense in depth behind it. -- **Put the panel on its own VLAN**, with default-deny between VLANs, and allow only the Home Assistant host to reach it. The integration currently uses - `tcp/80` for the REST bootstrap and `tcp/8883` for MQTTS. Deny `tcp/9001` and `tcp/9002` (plaintext and WebSocket MQTT) from every source unless you are +- **Put the panel on a trusted VLAN**, with default-deny between VLANs, and allow only the Home Assistant host to reach it. Open `tcp/443` (REST), `tcp/8883` + (MQTTS) and `tcp/80` (the plaintext certificate-authority fetch, and REST too until the entry pins), and deny `tcp/9001` and `tcp/9002` unless you are actively using the SPAN Home on-premise UI. -- **Use the IP address or an FQDN, not the `.local` name.** mDNS does not cross VLAN boundaries. The panel's IP is in its certificate SAN, so hostname - verification still works; for an FQDN, the integration registers it with the panel so the panel adds it to the SAN. -- **If Home Assistant cannot be on the panel's VLAN**, put a reverse proxy on the panel's VLAN and restrict its inbound to the Home Assistant host. The proxy - holds no panel credential; it only relays. Note that MQTTS is a TCP stream, not HTTP — a plain HTTP reverse proxy covers the REST port only, and `tcp/8883` - needs a stream proxy (HAProxy, nginx `stream`, or Caddy's `layer4` plugin). -- **Lock the panel enclosure.** The three-press proximity bypass hands out full credentials to anyone who can open the door; it is the equivalent of a printed +- **Use the IP address or an FQDN, not the `.local` name**, because mDNS does not cross VLAN boundaries. The panel's IP is already in its certificate SAN, and + for an FQDN the integration registers it so the panel adds it. +- **If Home Assistant cannot be on the panel's VLAN**, put a reverse proxy there and restrict its inbound to the Home Assistant host; it holds no panel + credential and only relays. MQTTS is a TCP stream rather than HTTP, so `tcp/8883` needs a stream proxy (HAProxy, nginx `stream`, or Caddy's `layer4` plugin) + while a plain HTTP proxy covers the REST port alone. +- **Secure the panel enclosure**, because the three-press proximity bypass hands full credentials to anyone who can open the door — the equivalent of a printed root password. This outranks every software control on this page. - **Put dashboard-only household members in Home Assistant's read-only group.** Home Assistant's default user policy grants every non-admin user control of every entity, which includes this integration's circuit switches and priority selects. @@ -844,11 +797,11 @@ See [WebSocket API Reference](websocket-api.md) for the full schema, response fo | **Replaced sub-device shows the old serial number** | After replacing a SPAN sub-device (Drive / EVSE, BESS, PV inverter), the device entry in Home Assistant keeps showing the previous hardware's serial number. The integration keys entities off the panel-assigned node identity, which is intentionally stable across hardware swaps so long-term history (e.g. lifetime charging kWh for a Drive) is preserved. The device-registry serial number, however, does not auto-refresh. | In**Settings → Devices & Services → Span Panel**, open the affected sub-device and delete it, then reload the integration (or restart Home Assistant). The device re-registers with the new serial number. Entity IDs and their recorded history are preserved. | | **Door sensor unavailable** | The SPAN API returns UNKNOWN if the cabinet door has not been operated recently. This is a defect in the SPAN API. | The integration reports the sensor as unavailable until a proper value arrives. Opening or closing the door publishes the correct state. The door is classified as a tamper sensor (`Detected` / `Clear`) to differentiate it from a normal entry door. | | **No switch on a circuit** | A circuit has no switch entity exposed in Home Assistant. | The circuit is configured in the SPAN App as one of the "Always on Circuits". The API does not permit user control of those circuits, so no switch is created. | -| **Reinstalling to change the entity ID style gives back the old entity IDs** | The naming style is chosen at install and cannot be changed from the options, so reinstalling looks like the way to switch. It is not: every entity returns with the entity ID it had before. | Home Assistant remembers a removed entity for **30 days**, keyed on its unique ID, and restores that record's entity ID — along with its name, area, labels and icon — as soon as an entity with the same unique ID appears again. This integration's unique IDs do not change with the naming style, so the remembered ID wins over the one the new style asks for. Either clear the leftover registry entries between removing and reinstalling, or wait out the 30 days and let Home Assistant discard them. A tool such as [ha-registry-clean](https://github.com/LegoTypes/ha-registry-clean) can do the clearing; it is a separate project, not part of this integration. Clearing also discards the names, areas and labels you had assigned. | +| **Reinstalling to change the entity ID style gives back the old entity IDs** | The naming style is chosen at install and cannot be changed from the options, so reinstalling looks like the way to switch. It is not: every entity returns with the entity ID it had before. | Home Assistant remembers a removed entity for**30 days**, keyed on its unique ID, and restores that record's entity ID — along with its name, area, labels and icon — as soon as an entity with the same unique ID appears again. This integration's unique IDs do not change with the naming style, so the remembered ID wins over the one the new style asks for. Either clear the leftover registry entries between removing and reinstalling, or wait out the 30 days and let Home Assistant discard them. A tool such as [ha-registry-clean](https://github.com/LegoTypes/ha-registry-clean) can do the clearing; it is a separate project, not part of this integration. Clearing also discards the names, areas and labels you had assigned. | | **Setup fails after downgrading the integration** | After installing an older release, the SPAN Panel config entry fails to set up and Home Assistant reports an unsupported configuration version. | The release that stopped storing the panel passphrase migrated the config entry to version 7. Home Assistant refuses to load a config entry whose version is newer than the installed integration understands, and there is no automatic downgrade. Reinstall the newer release, or restore a backup taken before the upgrade. Removing and re-adding the integration also works and preserves entity IDs, but needs the panel passphrase or physical access to the door again. | -| **"SPAN Panel certificate authority changed" repair** | The integration has stopped connecting and a repair reports two fingerprints: the one it pinned and the one the panel now advertises. Entities are unavailable. | The panel is presenting a different certificate authority than the one you accepted at setup. Two things look identical from here and only you can tell them apart: a firmware upgrade or a factory reset rotates the authority legitimately, and so does a device on your network standing in for your panel. **If you know why it changed**, open the repair, compare the new fingerprint, and accept it — that re-pins and reconnects. **If nothing should have changed**, do not accept. Check what else is on the panel's network segment first. The integration will not reconnect on its own and will not re-pin on its own, deliberately: retrying would mean waiting to succeed against whatever is answering. | +| **"SPAN Panel certificate authority changed" repair** | The integration has stopped connecting and a repair reports two fingerprints: the one it pinned and the one the panel now advertises. Entities are unavailable. | The panel is presenting a different certificate authority than the one you accepted at setup. Two things look identical from here and only you can tell them apart: a firmware upgrade or a factory reset rotates the authority legitimately, and so does a device on your network standing in for your panel.**If you know why it changed**, open the repair, compare the new fingerprint, and accept it — that re-pins and reconnects. **If nothing should have changed**, do not accept. Check what else is on the panel's network segment first. The integration will not reconnect on its own and will not re-pin on its own, deliberately: retrying would mean waiting to succeed against whatever is answering. | | **A circuit re-commissioned in the SPAN App has the wrong controls** | A circuit whose configuration changed in the SPAN App — made controllable, locked, or set to never back up — still has the controls it had before. A newly controllable circuit has no Breaker switch. A newly locked one still shows its switch, and operating it reports that the command was refused. | Reload the integration (**Settings → Devices & Services → Span Panel → ⋮ → Reload**). Which control entities exist is decided when the integration starts, from what the panel declares about each circuit at that moment; a change made afterwards is picked up at the next reload or restart. Reloading is safe — entity IDs, history, names and areas are preserved, because they are keyed on identifiers that do not change. Until you reload, a control the panel no longer accepts is refused rather than published: the command is not queued and the breaker does not move. | -| **A control reports `unconfirmed`** | The logbook or the `span_panel_control_command` event says a command was `unconfirmed`. Nothing appears broken. | **This is not an error.** It means the panel took the command and did not report a change within the deadline, and the most common reason by far is that there was no change to report — the relay was already open, the priority was already that value. It is also indistinguishable from a silent rejection by the panel, because SPAN's firmware sends no reason code; the integration reports what it observed rather than guessing. The two outcomes that do mean something went wrong are `failed`, which means the command was never sent, and `refused:…`, which means this integration refused it and names why. | +| **A control reports `unconfirmed`** | The logbook or the`span_panel_control_command` event says a command was `unconfirmed`. Nothing appears broken. | **This is not an error.** It means the panel took the command and did not report a change within the deadline, and the most common reason by far is that there was no change to report — the relay was already open, the priority was already that value. It is also indistinguishable from a silent rejection by the panel, because SPAN's firmware sends no reason code; the integration reports what it observed rather than guessing. The two outcomes that do mean something went wrong are `failed`, which means the command was never sent, and `refused:…`, which means this integration refused it and names why. | ## Development diff --git a/developer.md b/developer.md index d409fd3e..1e9bdb3f 100644 --- a/developer.md +++ b/developer.md @@ -793,6 +793,36 @@ rather than in `services`. Both halves of the check are real, which is why the h that core _deletes_ on unload, so the attribute is genuinely absent on an entry that has not finished setting up (hence `getattr` with a default), and what is there is whatever the owning integration put there, so `isinstance` is what says it is ours. AGENTS.md service point 6 names this helper. +## Why the CA pinning behaves as it does + +The README says what the pinning does; this is why each of those choices was made rather than the obvious alternative. All of it was written for the README and +moved here, because it answers "why is it like that" rather than "what do I do". + +**The fingerprint is not shown at first contact, on purpose.** Comparing it against another source is what closes the active-in-path case, and at first contact +there is nothing to compare against — SPAN does not publish the value, so the question could only ever be answered by pressing Submit. A dialog that asks a +question the user cannot answer trains them to dismiss it, which costs the one time it matters. So the value is put where it can actually be used instead: +diagnostics under `panel_ca`, the setup log, and any other install of this integration on the same panel. + +**Diagnostics carry the fingerprint and not the certificate.** The certificate is public, so withholding it buys no secrecy — it is omitted because it is +multi-KB and the fingerprint is the part anyone reads. + +**An unreachable panel does not stop setup.** The integration starts anyway and retries on the next startup. The exposure in the meantime is exactly the one the +entry already had before pinning existed, and refusing to start would not remove it — it would remove the integration, leaving the credential no safer while +guaranteeing an outage. Retrying closes the window at the first opportunity instead. + +**Reauth keeps the anchor it acquires.** An entry that predates pinning, or whose stored authority no longer loads, goes through the certificate-authority step +before either sign-in method is offered, and keeps the anchor afterwards rather than falling back to a plaintext fetch on every connection. Reauth is a +registration — it carries the passphrase out and the broker password back, the same exchange setup performs — so it is the one flow where acquiring an anchor +first is worth a step in front of the user. + +**Reauthenticate is the route that pins on screen; Reconfigure pins silently.** The certificate-authority step asks for the TLS port where the HTTP port has +been moved — the install most likely to be behind a proxy — and errors if the authority does not sign what the panel serves. Reconfiguring the entry to the +panel's own address reaches the same place, but the pin then happens during the reload that follows, announced only by a `WARNING` reading "Pinned the CA +advertised by SPAN panel …" with the fingerprint. + +**A registered domain is checked once the panel reports the new certificate**, not when registration is requested. The panel regenerates its certificate around +the FQDN asynchronously, so the flow polls for the name to appear in the SAN rather than assuming the call took effect. + ## The Supervisor discovery path is deliberately unguarded A pinned entry only follows a discovered host when that host serves a certificate the entry's anchor validates — `async_step_zeroconf` and the by-hand re-add @@ -801,7 +831,7 @@ both go through that check, because the serial they match on comes from an unaut `async_step_hassio` deliberately does not. A Supervisor discovery arrives over the authenticated Supervisor API from an add-on the user installed, and add-ons legitimately reallocate their own ports, so applying the stored-address guard would freeze the entry against its own add-on. The cost is stated rather than hidden: an add-on that already holds Supervisor privileges can move a pinned entry. If that trade is ever revisited, the guard is the same helper the other two -routes call, and README's Security section carries the user-facing note. +routes call. ## Linting and Type Checking From 4243ec3fd70862e8235e353f8935b24e982cf690 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:46:59 -0700 Subject: [PATCH 5/7] fix(repairs): tell the user when the panel is not at the configured address A pinned CA that still validates the panel's certificate, on a handshake that fails anyway, means one of two things the library used to conflate: an expired leaf, which fixes itself, and a certificate that does not name the configured host, which never will. 3.3.0 tells the two apart and reports the second on a callback of its own; this subscribes to it and puts the remedy in front of a person. `leaf_repairs.py` follows `ca_repairs` in shape and the `schema_repairs` reconciliation model in substance: not persistent, because the transport is alive and re-derives the condition on every attempt; WARNING, because nothing was intercepted and nothing refused; not fixable, because Reconfigure is the remedy, already refuses a host the leaf does not name, and a fix flow cannot hand off to a config flow. Subscribed before `connect()` rather than after it, unlike every other callback here. The library runs the diagnosis inside the first connect and a mismatch makes it raise, so setup raises ConfigEntryNotReady and the retry builds a new client -- a subscription after a successful connect would never see the case it exists for. Cleared on the coordinator's connection edge, which is the exact event the library re-arms its own once-per-outage signal on, and at setup beside the CA one. A CA change on the same entry supersedes it: only that finding carries a security decision, and pointing somebody at an address served by a certificate just refused is the wrong instruction. Two translation keys rather than one. A placeholder is substituted verbatim, so a certificate naming no address at all gets a description of its own instead of an English phrase passed through {leaf_names}. Requires span-panel-api 3.3.0, which is not yet on PyPI. --- CHANGELOG.md | 2 + custom_components/span_panel/__init__.py | 29 +- custom_components/span_panel/ca_repairs.py | 9 + custom_components/span_panel/coordinator.py | 10 + custom_components/span_panel/leaf_repairs.py | 138 +++++++ custom_components/span_panel/manifest.json | 2 +- custom_components/span_panel/strings.json | 8 + .../span_panel/translations/en.json | 8 + .../span_panel/translations/es.json | 8 + .../span_panel/translations/fr.json | 8 + .../span_panel/translations/ja.json | 8 + .../span_panel/translations/pt.json | 8 + pyproject.toml | 2 +- requirements_test.txt | 2 +- tests/test_leaf_name_mismatch_repair.py | 391 ++++++++++++++++++ uv.lock | 2 +- 16 files changed, 629 insertions(+), 6 deletions(-) create mode 100644 custom_components/span_panel/leaf_repairs.py create mode 100644 tests/test_leaf_name_mismatch_repair.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a1593f7f..b3eb08e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -103,6 +103,8 @@ This release requires Home Assistant 2026.8 to avoid a deprecated API — the ol added each circuit's whole lifetime counter to its offset. - **A panel this integration can no longer reach makes its entities unavailable instead of reporting 0 W** — a certificate authority that changes mid-session used to leave every power sensor reading zero and every energy sensor frozen, indefinitely and indistinguishably from a panel drawing no power. +- **A panel that has moved to another address now says so in Repairs**, naming the addresses its certificate does carry so you can point the entry at one with + Reconfigure, instead of retrying silently with every entity unavailable and one line a minute in the log. - **Recreate entity IDs proposes the ids your panel would produce now** (#252), in your installation's naming style, leaving unique ids and statistics untouched. - **The README described Battery Power's sign backwards**, since the sensor has always reported discharging as positive (#184) and only the documentation was diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py index 6b4c2c7c..a1085e86 100644 --- a/custom_components/span_panel/__init__.py +++ b/custom_components/span_panel/__init__.py @@ -21,7 +21,12 @@ ) from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.typing import ConfigType -from span_panel_api import SpanMqttClient, SpanPanelSnapshot, ca_fingerprint +from span_panel_api import ( + LeafNameMismatch, + SpanMqttClient, + SpanPanelSnapshot, + ca_fingerprint, +) from span_panel_api.exceptions import ( SpanPanelAPIError, SpanPanelAuthError, @@ -67,6 +72,7 @@ async_save_panel_settings as async_save_panel_settings, ) from .graph_horizon import GraphHorizonManager +from .leaf_repairs import async_clear_leaf_name_mismatch, async_raise_leaf_name_mismatch from .migrations import CURRENT_CONFIG_VERSION, async_migrate_entry # noqa: F401 from .notices import async_forget, async_restore from .options import SNAPSHOT_UPDATE_INTERVAL @@ -350,6 +356,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> # the one every other integration already shares. httpx_client=get_async_client(hass), ) + + # Before `connect()`, unlike every other subscription here, because + # the library runs the diagnosis this reports *inside* the first + # connect. Registered afterwards it would see nothing on the path it + # exists for: a mismatch makes `connect()` raise + # `SpanPanelConnectionError`, setup raises `ConfigEntryNotReady`, and + # the retry builds a new client -- so every attempt would fire the + # signal into a client with no subscriber and the user would keep + # getting the log line and nothing else. + @callback + def _on_leaf_name_mismatch(mismatch: LeafNameMismatch) -> None: + async_raise_leaf_name_mismatch(hass, entry, mismatch) + + entry.async_on_unload(client.register_leaf_mismatch_callback(_on_leaf_name_mismatch)) + try: await client.connect() except SpanPanelCAChangedError as err: @@ -384,8 +405,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> raise ConfigEntryNotReady(f"SPAN panel is not ready yet: {err}") from err # The connection got as far as a handshake under the current pin, so - # any standing Repair describes a state that no longer holds. + # any standing Repair describes a state that no longer holds. That + # covers the name mismatch too: the handshake that just succeeded is + # the one whose failure the mismatch explains, and it is the same + # event the library re-arms its own signal on. async_clear_ca_changed(hass, entry) + async_clear_leaf_name_mismatch(hass, entry) # The other half of the same condition: the reconnect loop runs # fire-and-forget, so a CA that changes mid-session cannot surface as diff --git a/custom_components/span_panel/ca_repairs.py b/custom_components/span_panel/ca_repairs.py index 5fe4a14e..7a8c37c6 100644 --- a/custom_components/span_panel/ca_repairs.py +++ b/custom_components/span_panel/ca_repairs.py @@ -25,6 +25,7 @@ from homeassistant.helpers import issue_registry as ir from .const import DOMAIN +from .leaf_repairs import async_clear_leaf_name_mismatch _LOGGER = logging.getLogger(__name__) @@ -52,7 +53,15 @@ def async_raise_ca_changed( `is_persistent` because the transport this describes is already dead. A non-persistent issue reloads as a tombstone, and this one has no live state to re-assert it from — the client that would have noticed is gone. + + Supersedes a standing name mismatch on the same entry, and does it here + rather than at each call site so no future one can forget. The two findings + come out of the same failed handshake and its diagnosis, so both can stand + against one panel; only this one carries a decision, and telling somebody to + re-point their configuration at an address served by a certificate this + integration has just refused to trust is the wrong instruction. """ + async_clear_leaf_name_mismatch(hass, entry) _LOGGER.error( "SPAN panel %s is advertising CA %s where %s was pinned. Not reconnecting: a " "rotated CA and an intercepted connection look identical from here. Confirm the " diff --git a/custom_components/span_panel/coordinator.py b/custom_components/span_panel/coordinator.py index 1a0241da..41461d79 100644 --- a/custom_components/span_panel/coordinator.py +++ b/custom_components/span_panel/coordinator.py @@ -37,6 +37,7 @@ detect_capabilities, ) from .id_builder import build_circuit_unique_id +from .leaf_repairs import async_clear_leaf_name_mismatch from .notices import async_raise, read_translations from .schema_repairs import async_sync_schema_issues from .schema_validation import SchemaFindings, evaluate_field_metadata @@ -459,10 +460,19 @@ def _on_connection_change(self, connected: bool) -> None: Listener fan-out is guarded by a real state change so a misbehaving or future-version library that re-emits the same edge does not trigger spurious entity re-renders. + + A connect also drops any standing name-mismatch Repair, and this edge is + the right place for it rather than a snapshot arriving: it is the exact + event the library re-arms its own once-per-outage signal on, so the + notice the user sees and the library's idea of whether it has reported + anything cannot drift apart. Raising is deliberately not done here -- + `async_setup_entry` holds the one subscription that raises, so a mismatch + is reported once however many things are listening. """ was_offline = self._panel_offline was_dead = self._transport_dead if connected: + async_clear_leaf_name_mismatch(self.hass, self.config_entry) self._mark_panel_online() # The one thing that disproves a dead transport: it connected. Not # folded into `_mark_panel_online`, which a successful snapshot also diff --git a/custom_components/span_panel/leaf_repairs.py b/custom_components/span_panel/leaf_repairs.py new file mode 100644 index 00000000..24180f19 --- /dev/null +++ b/custom_components/span_panel/leaf_repairs.py @@ -0,0 +1,138 @@ +"""The Repair raised when the panel's certificate does not name the configured host. + +The third outcome of the library's failed-handshake diagnosis, and the only one +that used to reach nobody. A pinned CA that still validates the panel's +certificate says the peer is the panel; a certificate that does not carry the +configured address says the panel is not where the entry claims it is. Most often +a new DHCP lease, sometimes an entry recorded under a name the panel does not +know itself by. + +Separate from `ca_repairs` because the two are reconciled on opposite terms, and +this one is the `schema_repairs` model rather than the `ca_repairs` one: + +- **Not persistent.** The transport is alive and still retrying. The library + re-derives this on every attempt and re-arms the signal on the next successful + connect, so there is live state to re-assert it from and a restart may sweep it + away. +- **Severity `WARNING`, not `ERROR`.** Nothing is being intercepted and nothing + has been refused -- the chain verified under the pin. The panel is simply + somewhere other than where the entry says. +- **Not fixable.** The remedy is Reconfigure, which already exists, already + refuses a host the leaf does not name, and already carries the FQDN + registration path. A Repairs fix flow cannot hand off to a config flow, so a + fixable Repair would have to re-implement a subset of Reconfigure -- host + entry, verdict, store -- and `async_panel_leaf_host` warns in as many words + that persisting a resolved address is where that subset goes wrong. One + implementation of "change the host" is the point; this Repair's job is to make + the condition visible and name the address to use. + +Two translation keys rather than one, because a certificate that names nothing +at all is a different sentence and not a different value. Home Assistant +substitutes `translation_placeholders` into the description verbatim, so a phrase +passed as a placeholder would reach every user in English no matter what language +they read the rest of the notice in; the empty case therefore gets a description +of its own, and the phrase is translated with everything else. It also reads as +what it is -- a fault in the panel rather than in the address the user chose. +""" + +from __future__ import annotations + +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import issue_registry as ir +from span_panel_api import LeafNameMismatch + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +LEAF_NAME_MISMATCH_ISSUE_PREFIX = "panel_leaf_name_mismatch_" + +_NAMED_TRANSLATION_KEY = "panel_leaf_name_mismatch" +_UNNAMED_TRANSLATION_KEY = "panel_leaf_name_mismatch_no_names" + + +def leaf_name_mismatch_issue_id(entry_id: str) -> str: + """Issue id for one entry's name mismatch.""" + return f"{LEAF_NAME_MISMATCH_ISSUE_PREFIX}{entry_id}" + + +@callback +def async_raise_leaf_name_mismatch( + hass: HomeAssistant, + entry: ConfigEntry, + mismatch: LeafNameMismatch, +) -> None: + """Raise the Repair, carrying the configured host and the names actually served. + + Both, because "the address is wrong" without saying which address is right + leaves the user exactly where the log line left them. The names come from the + certificate's SAN entries in certificate order, and one of them is what + Reconfigure wants. + + `is_persistent=False` because the condition is derived from a transport that + is still running: the library re-diagnoses it on every reconnect attempt and + re-arms the signal on the next successful connect, so a restart that resolves + nothing raises it again and a restart after the panel came back does not. + """ + issue_id = leaf_name_mismatch_issue_id(entry.entry_id) + if mismatch.leaf_names: + leaf_names = ", ".join(mismatch.leaf_names) + _LOGGER.warning( + "SPAN panel %s is configured as %s, but the certificate it serves names only " + "%s. The panel has probably moved. Use Reconfigure on the SPAN Panel entry to " + "point it at one of those names; the integration keeps retrying meanwhile", + entry.title, + mismatch.host, + leaf_names, + ) + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + is_fixable=False, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key=_NAMED_TRANSLATION_KEY, + translation_placeholders={ + "panel": entry.title, + "host": mismatch.host, + "leaf_names": leaf_names, + }, + ) + return + + _LOGGER.warning( + "SPAN panel %s is configured as %s, but the certificate it serves names no address " + "at all, so there is no address to re-point the entry at. The panel's certificate " + "needs regenerating; the integration keeps retrying meanwhile", + entry.title, + mismatch.host, + ) + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + is_fixable=False, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key=_UNNAMED_TRANSLATION_KEY, + translation_placeholders={ + "panel": entry.title, + "host": mismatch.host, + }, + ) + + +@callback +def async_clear_leaf_name_mismatch(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Drop the Repair. + + Cleared on any connection that succeeded under the current configuration -- + which is exactly when the library re-arms the signal, so the two never + disagree about whether the condition still holds -- and when a CA change + takes over the same entry, because that finding supersedes this one. + """ + ir.async_delete_issue(hass, DOMAIN, leaf_name_mismatch_issue_id(entry.entry_id)) diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 7154cdc6..ea428a30 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -23,7 +23,7 @@ ], "quality_scale": "gold", "requirements": [ - "span-panel-api==3.2.0", + "span-panel-api==3.3.0", "span-panel-api-schema-0==1.1.2", "span-panel-api-schema-1==1.1.3" ], diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index 9adc0d95..645df518 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -900,6 +900,14 @@ "ca_leaf_mismatch": "The certificate this panel serves is not signed by the authority it published, so that authority has not been offered for you to accept and nothing has been changed. Check that the HTTPS port is correct and that nothing is intercepting the connection, then try again." } } + }, + "panel_leaf_name_mismatch": { + "title": "SPAN Panel is not at the configured address", + "description": "{panel} is configured as **{host}**, but the certificate it serves names only: **{leaf_names}**. The panel has probably moved, or the address was stored under a name the panel does not know itself by.\n\nUse *Reconfigure* on the SPAN Panel entry to point it at one of the names above. The integration keeps retrying meanwhile and will recover on its own if the panel returns to {host}." + }, + "panel_leaf_name_mismatch_no_names": { + "title": "SPAN Panel is not at the configured address", + "description": "{panel} is configured as **{host}**, but the certificate it serves names no address at all, so there is no address to point the entry at. That is a fault in the panel's certificate rather than in the address you configured, and it usually clears after the panel regenerates it — a restart of the panel is the usual way.\n\nIf it persists, use *Reconfigure* on the SPAN Panel entry to re-establish the connection. The integration keeps retrying meanwhile and will recover on its own if the panel starts naming {host} again." } } } diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index 9adc0d95..645df518 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -900,6 +900,14 @@ "ca_leaf_mismatch": "The certificate this panel serves is not signed by the authority it published, so that authority has not been offered for you to accept and nothing has been changed. Check that the HTTPS port is correct and that nothing is intercepting the connection, then try again." } } + }, + "panel_leaf_name_mismatch": { + "title": "SPAN Panel is not at the configured address", + "description": "{panel} is configured as **{host}**, but the certificate it serves names only: **{leaf_names}**. The panel has probably moved, or the address was stored under a name the panel does not know itself by.\n\nUse *Reconfigure* on the SPAN Panel entry to point it at one of the names above. The integration keeps retrying meanwhile and will recover on its own if the panel returns to {host}." + }, + "panel_leaf_name_mismatch_no_names": { + "title": "SPAN Panel is not at the configured address", + "description": "{panel} is configured as **{host}**, but the certificate it serves names no address at all, so there is no address to point the entry at. That is a fault in the panel's certificate rather than in the address you configured, and it usually clears after the panel regenerates it — a restart of the panel is the usual way.\n\nIf it persists, use *Reconfigure* on the SPAN Panel entry to re-establish the connection. The integration keeps retrying meanwhile and will recover on its own if the panel starts naming {host} again." } } } diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index aed70164..90d5e826 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -900,6 +900,14 @@ "ca_leaf_mismatch": "El certificado que sirve este panel no está firmado por la autoridad que ha publicado, por lo que esa autoridad no se ha ofrecido para su aceptación y no se ha cambiado nada. Compruebe que el puerto HTTPS es correcto y que nada intercepta la conexión, y vuelva a intentarlo." } } + }, + "panel_leaf_name_mismatch": { + "title": "El SPAN Panel no está en la dirección configurada", + "description": "{panel} está configurado como **{host}**, pero el certificado que sirve solo nombra: **{leaf_names}**. Es probable que el panel se haya movido, o que la dirección se guardara con un nombre por el que el panel no se conoce a sí mismo.\n\nUse *Reconfigurar* en la entrada del SPAN Panel para apuntarla a uno de los nombres anteriores. Mientras tanto, la integración sigue reintentando y se recuperará por sí sola si el panel vuelve a {host}." + }, + "panel_leaf_name_mismatch_no_names": { + "title": "El SPAN Panel no está en la dirección configurada", + "description": "{panel} está configurado como **{host}**, pero el certificado que sirve no nombra ninguna dirección, por lo que no hay ninguna dirección a la que apuntar la entrada. Se trata de un fallo del certificado del panel y no de la dirección que usted configuró, y suele resolverse cuando el panel lo regenera: lo habitual es reiniciar el panel.\n\nSi persiste, use *Reconfigurar* en la entrada del SPAN Panel para restablecer la conexión. Mientras tanto, la integración sigue reintentando y se recuperará por sí sola si el panel vuelve a nombrar {host}." } } } diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index 1c5b90de..488f01ff 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -900,6 +900,14 @@ "ca_leaf_mismatch": "Le certificat présenté par ce panneau n'est pas signé par l'autorité qu'il a publiée ; cette autorité ne vous a donc pas été proposée et rien n'a été modifié. Vérifiez que le port HTTPS est correct et que rien n'intercepte la connexion, puis réessayez." } } + }, + "panel_leaf_name_mismatch": { + "title": "Le SPAN Panel n'est pas à l'adresse configurée", + "description": "{panel} est configuré comme **{host}**, mais le certificat qu'il présente ne nomme que : **{leaf_names}**. Le panneau a probablement changé d'adresse, ou l'adresse a été enregistrée sous un nom que le panneau ne se connaît pas.\n\nUtilisez *Reconfigurer* sur l'entrée SPAN Panel pour la pointer vers l'un des noms ci-dessus. L'intégration continue de réessayer entre-temps et se rétablira d'elle-même si le panneau revient à {host}." + }, + "panel_leaf_name_mismatch_no_names": { + "title": "Le SPAN Panel n'est pas à l'adresse configurée", + "description": "{panel} est configuré comme **{host}**, mais le certificat qu'il présente ne nomme aucune adresse ; il n'y a donc aucune adresse vers laquelle pointer l'entrée. C'est un défaut du certificat du panneau et non de l'adresse que vous avez configurée, et cela se résout généralement lorsque le panneau le régénère — un redémarrage du panneau est la manière habituelle.\n\nSi cela persiste, utilisez *Reconfigurer* sur l'entrée SPAN Panel pour rétablir la connexion. L'intégration continue de réessayer entre-temps et se rétablira d'elle-même si le panneau nomme à nouveau {host}." } } } diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index eb12e4f7..670fbf55 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -900,6 +900,14 @@ "ca_leaf_mismatch": "このパネルが提供する証明書は、パネルが公開した認証局で署名されていません。そのため、その認証局は承認の対象として提示されず、何も変更されていません。HTTPS ポートが正しいこと、接続が傍受されていないことを確認してから、もう一度お試しください。" } } + }, + "panel_leaf_name_mismatch": { + "title": "SPAN Panel が設定されたアドレスにありません", + "description": "{panel} は **{host}** として設定されていますが、パネルが提供する証明書は **{leaf_names}** しか名前として持っていません。パネルのアドレスが変わったか、パネル自身が認識していない名前でアドレスが保存された可能性があります。\n\nSPAN Panel のエントリで *再構成* を使い、上記のいずれかの名前を指すように変更してください。その間も統合は再試行を続け、パネルが {host} に戻れば自動的に復旧します。" + }, + "panel_leaf_name_mismatch_no_names": { + "title": "SPAN Panel が設定されたアドレスにありません", + "description": "{panel} は **{host}** として設定されていますが、パネルが提供する証明書はアドレスをまったく名前として持っていないため、エントリを指し直す先がありません。これは設定したアドレスではなくパネルの証明書側の問題で、パネルが証明書を再生成すれば通常は解消します。パネルの再起動が一般的な方法です。\n\n解消しない場合は、SPAN Panel のエントリで *再構成* を使って接続をやり直してください。その間も統合は再試行を続け、パネルが再び {host} を名前として持つようになれば自動的に復旧します。" } } } diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index 8e14fca3..d97042ab 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -900,6 +900,14 @@ "ca_leaf_mismatch": "O certificado que este painel serve não está assinado pela autoridade que publicou, pelo que essa autoridade não foi proposta para aceitação e nada foi alterado. Verifique se a porta HTTPS está correta e se nada está a intercetar a ligação, e tente novamente." } } + }, + "panel_leaf_name_mismatch": { + "title": "O SPAN Panel não está no endereço configurado", + "description": "{panel} está configurado como **{host}**, mas o certificado que serve nomeia apenas: **{leaf_names}**. É provável que o painel tenha mudado de endereço, ou que o endereço tenha sido guardado com um nome pelo qual o painel não se conhece a si próprio.\n\nUse *Reconfigurar* na entrada do SPAN Panel para a apontar para um dos nomes acima. Entretanto, a integração continua a tentar e recupera sozinha se o painel voltar a {host}." + }, + "panel_leaf_name_mismatch_no_names": { + "title": "O SPAN Panel não está no endereço configurado", + "description": "{panel} está configurado como **{host}**, mas o certificado que serve não nomeia qualquer endereço, pelo que não há endereço nenhum para onde apontar a entrada. É uma falha do certificado do painel e não do endereço que configurou, e costuma resolver-se quando o painel o regenera — reiniciar o painel é a forma habitual.\n\nSe persistir, use *Reconfigurar* na entrada do SPAN Panel para restabelecer a ligação. Entretanto, a integração continua a tentar e recupera sozinha se o painel voltar a nomear {host}." } } } diff --git a/pyproject.toml b/pyproject.toml index 1003054a..51ad93b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ readme = "README.md" requires-python = ">=3.14.3,<3.15" dependencies = [ "homeassistant==2026.8.0", - "span-panel-api==3.2.0", + "span-panel-api==3.3.0", # Separate distributions, not dependencies of the bootstrap: adapters are found # through the `span_panel_api.schema_adapters` entry-point group, so each one has # to be installed for discovery to see it. manifest.json carries the same three, diff --git a/requirements_test.txt b/requirements_test.txt index 12589e57..c1b255d6 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -3,6 +3,6 @@ pytest-asyncio>=1.4.0 setuptools>=65.7.0 pytest-homeassistant-custom-component==0.13.354 homeassistant==2026.8.0 -span-panel-api==3.2.0 +span-panel-api==3.3.0 span-panel-api-schema-0==1.1.2 span-panel-api-schema-1==1.1.3 diff --git a/tests/test_leaf_name_mismatch_repair.py b/tests/test_leaf_name_mismatch_repair.py new file mode 100644 index 00000000..f3586302 --- /dev/null +++ b/tests/test_leaf_name_mismatch_repair.py @@ -0,0 +1,391 @@ +"""Tests for the Repair raised when the panel's certificate does not name the host. + +The condition this covers is the one the library used to report to nobody: a +handshake that fails against a pin the panel still advertises, because the +certificate underneath names an address the entry does not use. The user was left +with a minute-by-minute log warning, every entity unavailable, and nothing in the +UI at all. + +The wiring is the substance of it, so most of these tests are about *where* the +subscription happens rather than what it does with the signal. The library runs +the diagnosis inside `connect()`, and a mismatch makes `connect()` raise, so a +subscription placed after it -- the natural place, and where the fatal-error one +lives -- would never see the case it exists for. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +from homeassistant import config_entries +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import issue_registry as ir +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry +from span_panel_api import LeafNameMismatch +from span_panel_api.exceptions import SpanPanelConnectionError + +from custom_components.span_panel import async_setup_entry +from custom_components.span_panel.ca_repairs import async_raise_ca_changed, ca_changed_issue_id +from custom_components.span_panel.const import ( + CONF_API_VERSION, + CONF_EBUS_BROKER_HOST, + CONF_EBUS_BROKER_PASSWORD, + CONF_EBUS_BROKER_PORT, + CONF_EBUS_BROKER_USERNAME, + CONF_HTTP_PORT, + CONF_PANEL_CA_PEM, + DOMAIN, +) +from custom_components.span_panel.coordinator import SpanPanelCoordinator +from custom_components.span_panel.leaf_repairs import ( + async_clear_leaf_name_mismatch, + async_raise_leaf_name_mismatch, + leaf_name_mismatch_issue_id, +) + +from .factories import SpanPanelSnapshotFactory + +PEM = "-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n" + +_STRINGS = ( + Path(__file__).resolve().parent.parent / "custom_components" / "span_panel" / "strings.json" +) + +MOVED = LeafNameMismatch(host="192.168.1.100", leaf_names=("span-panel.local", "192.168.1.187")) +NAMELESS = LeafNameMismatch(host="192.168.1.100", leaf_names=()) + + +def _entry(hass: HomeAssistant) -> MockConfigEntry: + """Add a pinned v2 entry, the only shape this condition can arise on.""" + entry = MockConfigEntry( + domain=DOMAIN, + version=7, + title="Span Panel", + data={ + CONF_HOST: "192.168.1.100", + CONF_ACCESS_TOKEN: "token", + CONF_API_VERSION: "v2", + CONF_PANEL_CA_PEM: PEM, + }, + source=config_entries.SOURCE_USER, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + return entry + + +def _setup_entry(hass: HomeAssistant) -> MockConfigEntry: + """Add an entry carrying everything `async_setup_entry` needs of a v2 panel.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_VERSION: "v2", + CONF_HOST: "192.168.1.100", + CONF_EBUS_BROKER_HOST: "span-panel.local", + CONF_EBUS_BROKER_USERNAME: "mqtt-user", + CONF_EBUS_BROKER_PASSWORD: "mqtt-pass", + CONF_EBUS_BROKER_PORT: 8883, + CONF_HTTP_PORT: 80, + }, + entry_id="entry-leaf", + title="sp3-leaf-001", + unique_id="sp3-leaf-001", + ) + entry.add_to_hass(hass) + return entry + + +def _issue(hass: HomeAssistant, entry: MockConfigEntry) -> ir.IssueEntry | None: + return ir.async_get(hass).async_get_issue(DOMAIN, leaf_name_mismatch_issue_id(entry.entry_id)) + + +# ---------- what the notice says ---------- + + +async def test_a_mismatch_names_the_addresses_the_panel_does_answer_to( + hass: HomeAssistant, +) -> None: + """Saying the address is wrong without saying which is right is the log line again.""" + entry = _entry(hass) + + async_raise_leaf_name_mismatch(hass, entry, MOVED) + + issue = _issue(hass, entry) + assert issue is not None + assert issue.translation_key == "panel_leaf_name_mismatch" + assert issue.translation_placeholders == { + "panel": "Span Panel", + "host": "192.168.1.100", + "leaf_names": "span-panel.local, 192.168.1.187", + } + + +async def test_the_notice_is_a_warning_the_user_cannot_click_through( + hass: HomeAssistant, +) -> None: + """Not fixable, because Reconfigure is the remedy and a fix flow cannot start one. + + Not persistent either, and not an error: the transport is alive and still + retrying, the chain verified under the pin, and nothing was refused. + """ + entry = _entry(hass) + + async_raise_leaf_name_mismatch(hass, entry, MOVED) + + issue = _issue(hass, entry) + assert issue is not None + assert issue.is_fixable is False + assert issue.is_persistent is False + assert issue.severity == ir.IssueSeverity.WARNING + + +async def test_a_certificate_naming_nothing_gets_its_own_description( + hass: HomeAssistant, +) -> None: + """The empty case is a different sentence, not an empty value. + + A placeholder is substituted verbatim, so a phrase passed as one would reach + a Spanish reader in English. It is also a different fault -- the panel's + certificate rather than the user's address -- and "names only: " followed by + nothing is not a sentence in any language. + """ + entry = _entry(hass) + + async_raise_leaf_name_mismatch(hass, entry, NAMELESS) + + issue = _issue(hass, entry) + assert issue is not None + assert issue.translation_key == "panel_leaf_name_mismatch_no_names" + assert issue.translation_placeholders == { + "panel": "Span Panel", + "host": "192.168.1.100", + } + + +async def test_both_descriptions_exist_and_use_only_the_placeholders_supplied( + hass: HomeAssistant, +) -> None: + """`strings.json` is the source of truth, and it has to match what is passed. + + A description referencing a placeholder nothing supplies renders the literal + `{leaf_names}` to the user, which is how the empty case would fail if it were + ever pointed at the other key. + """ + strings = json.loads(_STRINGS.read_text(encoding="utf-8")) + issues = strings["issues"] + entry = _entry(hass) + + for mismatch in (MOVED, NAMELESS): + async_raise_leaf_name_mismatch(hass, entry, mismatch) + issue = _issue(hass, entry) + assert issue is not None + assert issue.translation_placeholders is not None + block = issues[issue.translation_key] + assert block["title"] + block["description"].format(**issue.translation_placeholders) + + +# ---------- when it goes away ---------- + + +async def test_a_connection_clears_a_standing_notice(hass: HomeAssistant) -> None: + """The same edge the library re-arms its once-per-outage signal on. + + Cleared on the connection callback rather than on a snapshot so the two + cannot drift: if the notice outlived the re-arm, the next mismatch would fire + against an issue that is already standing and tell the user nothing new. + """ + entry = _entry(hass) + async_raise_leaf_name_mismatch(hass, entry, MOVED) + coordinator = SpanPanelCoordinator(hass, MagicMock(), entry) + + coordinator._on_connection_change(True) + + assert _issue(hass, entry) is None + + +async def test_a_disconnect_leaves_the_notice_standing(hass: HomeAssistant) -> None: + """Losing the connection is the condition, not its resolution.""" + entry = _entry(hass) + async_raise_leaf_name_mismatch(hass, entry, MOVED) + coordinator = SpanPanelCoordinator(hass, MagicMock(), entry) + + coordinator._on_connection_change(False) + + assert _issue(hass, entry) is not None + + +async def test_a_ca_change_takes_the_notice_over(hass: HomeAssistant) -> None: + """Two findings out of one handshake, and only one of them carries a decision. + + Telling somebody to re-point their configuration at an address served by a + certificate the integration has just refused to trust is the wrong + instruction, so the CA Repair does not stand beside this one. + """ + entry = _entry(hass) + async_raise_leaf_name_mismatch(hass, entry, MOVED) + + async_raise_ca_changed(hass, entry, "aa" * 32, "bb" * 32) + + assert _issue(hass, entry) is None + assert ( + ir.async_get(hass).async_get_issue(DOMAIN, ca_changed_issue_id(entry.entry_id)) is not None + ) + + +async def test_clearing_a_notice_that_was_never_raised_is_not_an_error( + hass: HomeAssistant, +) -> None: + """Setup and every connect call this, and almost every one has nothing to drop.""" + entry = _entry(hass) + + async_clear_leaf_name_mismatch(hass, entry) + + assert _issue(hass, entry) is None + + +# ---------- the wiring ---------- + + +def _diagnosing_client(mismatch: LeafNameMismatch | None) -> tuple[MagicMock, list[str]]: + """Build a client that diagnoses inside `connect()` and then raises, as the library does. + + The signal is fired from within `connect`, so a callback registered after it + returns -- or after it raises -- never sees it. That is the whole point of + the ordering under test, and a mock that fired it from anywhere else would + prove nothing. + + Returns the calls it saw, in order, so the ordering can be asserted directly + as well as through its consequence. + """ + client = MagicMock() + subscribers: list[Any] = [] + order: list[str] = [] + + def register(callback: Any) -> Any: + order.append("register") + subscribers.append(callback) + return MagicMock(name="unregister") + + async def connect() -> None: + order.append("connect") + for callback in subscribers: + if mismatch is not None: + callback(mismatch) + raise SpanPanelConnectionError("TLS handshake failed") + + client.register_leaf_mismatch_callback = MagicMock(side_effect=register) + client.connect = AsyncMock(side_effect=connect) + client.close = AsyncMock() + return client, order + + +async def test_a_mismatch_on_the_very_first_connect_still_reaches_the_user( + hass: HomeAssistant, +) -> None: + """The regression this ordering exists for. + + A mismatch makes `connect()` raise, setup raises `ConfigEntryNotReady`, and + the retry builds a new client -- so a subscription registered after a + successful connect would be registered on a client that never has one. Every + setup attempt would fire the signal into nothing. + """ + entry = _setup_entry(hass) + client, _order = _diagnosing_client(MOVED) + + with ( + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + pytest.raises(ConfigEntryNotReady), + ): + await async_setup_entry(hass, entry) + + issue = _issue(hass, entry) + assert issue is not None + assert issue.translation_placeholders is not None + assert issue.translation_placeholders["leaf_names"] == "span-panel.local, 192.168.1.187" + + +async def test_the_subscription_is_registered_before_the_connect_attempt( + hass: HomeAssistant, +) -> None: + """Stated as an ordering too, not only through its consequence. + + The test above would keep passing on a subscription moved back after + `connect()` if the library ever stopped raising on a mismatch, which is a + change to the library rather than to this decision. + """ + entry = _setup_entry(hass) + client, order = _diagnosing_client(None) + + with ( + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + pytest.raises(ConfigEntryNotReady), + ): + await async_setup_entry(hass, entry) + + assert order == ["register", "connect"] + + +async def test_a_successful_setup_clears_a_notice_left_from_a_previous_run( + hass: HomeAssistant, +) -> None: + """A handshake that succeeded is the same disproof a reconnect is.""" + entry = _setup_entry(hass) + async_raise_leaf_name_mismatch(hass, entry, MOVED) + + snapshot = SpanPanelSnapshotFactory.create(serial_number="sp3-leaf-001") + client = MagicMock() + client.connect = AsyncMock() + coordinator = MagicMock() + coordinator.async_config_entry_first_refresh = AsyncMock() + coordinator.async_setup_streaming = AsyncMock() + coordinator.data = snapshot + + with ( + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + patch("custom_components.span_panel.SpanPanelCoordinator", return_value=coordinator), + patch("custom_components.span_panel.ensure_device_registered", AsyncMock()), + patch.object(hass.config_entries, "async_forward_entry_setups", AsyncMock()), + patch.object(hass.config_entries, "async_update_entry"), + ): + assert await async_setup_entry(hass, entry) is True + + assert _issue(hass, entry) is None + + +async def test_unloading_the_entry_takes_the_subscription_with_it( + hass: HomeAssistant, +) -> None: + """The client goes when the entry does, and so must the callback holding `hass`.""" + entry = _setup_entry(hass) + unregister = MagicMock(name="unregister") + client = MagicMock() + client.connect = AsyncMock() + client.register_leaf_mismatch_callback = MagicMock(return_value=unregister) + coordinator = MagicMock() + coordinator.async_config_entry_first_refresh = AsyncMock() + coordinator.async_setup_streaming = AsyncMock() + coordinator.data = SpanPanelSnapshotFactory.create(serial_number="sp3-leaf-001") + + with ( + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + patch("custom_components.span_panel.SpanPanelCoordinator", return_value=coordinator), + patch("custom_components.span_panel.ensure_device_registered", AsyncMock()), + patch.object(hass.config_entries, "async_forward_entry_setups", AsyncMock()), + patch.object(hass.config_entries, "async_update_entry"), + ): + assert await async_setup_entry(hass, entry) is True + + unregister.assert_not_called() + await entry._async_process_on_unload(hass) + unregister.assert_called_once_with() diff --git a/uv.lock b/uv.lock index d615e95a..6a92320c 100644 --- a/uv.lock +++ b/uv.lock @@ -2619,7 +2619,7 @@ dev = [ [[package]] name = "span-panel-api" -version = "3.2.0" +version = "3.3.0" source = { editable = "../../span/span-panel-api" } dependencies = [ { name = "httpx" }, From 0a03afa0d5c593ddb0c4bf0a4aadedb59460dde2 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:55:50 -0700 Subject: [PATCH 6/7] fix(config_flow): a Supervisor discovery reallocates ports, not the host `async_step_hassio` put the add-on's published host straight into the already-configured update on every add-on start. Observed on ha-test: an entry configured at an address that reached the panel and that the panel's certificate names was overwritten with the add-on's container hostname, a name the panel does not serve, 56 seconds after the entry was created. Every connection afterwards failed verification against the entry's own pin. The update also moved `host` without `ebus_broker_host`, leaving the entry naming two different machines. The docstring's argument for being unguarded -- add-ons legitimately reallocate their own ports -- covers the ports and does not reach the host. An add-on that moved a port has not moved the panel. So the ports still go straight in, and `_async_hassio_host_update` decides the host on the evidence the entry already has: probe the *configured* host on the *newly discovered* port, which is precisely the reallocated-port case, and keep the stored host when a v2 answer there carries this serial. Only a configured host that has stopped answering for this panel is replaced, and then `CONF_HOST` and `CONF_EBUS_BROKER_HOST` move together. Deliberately not `_async_host_update`'s pinned-address check. That one decides whether an unauthenticated claim may move an entry; this one decides whether there is anything to move at all. The Supervisor route's documented trade is unchanged for a host that has genuinely gone away. Bumps the integration to 2.1.0b23 and re-mirrors pyproject's version onto the manifest's, which had drifted at 2.1.0. --- CHANGELOG.md | 2 + README.md | 1 + custom_components/span_panel/config_flow.py | 94 ++++++++- custom_components/span_panel/manifest.json | 2 +- developer.md | 18 +- pyproject.toml | 2 +- tests/test_v2_config_flow.py | 204 ++++++++++++++------ uv.lock | 2 +- 8 files changed, 252 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3eb08e4..b6a8951c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,6 +105,8 @@ This release requires Home Assistant 2026.8 to avoid a deprecated API — the ol used to leave every power sensor reading zero and every energy sensor frozen, indefinitely and indistinguishably from a panel drawing no power. - **A panel that has moved to another address now says so in Repairs**, naming the addresses its certificate does carry so you can point the entry at one with Reconfigure, instead of retrying silently with every entity unavailable and one line a minute in the log. +- **An add-on restart no longer overwrites the address you configured** — a panel announced by an add-on keeps the host you gave it for as long as that host + still answers, and only the add-on's own ports are taken as published. - **Recreate entity IDs proposes the ids your panel would produce now** (#252), in your installation's naming style, leaving unique ids and statistics untouched. - **The README described Battery Power's sign backwards**, since the sensor has always reported discharging as positive (#184) and only the documentation was diff --git a/README.md b/README.md index 2b895663..99a45103 100644 --- a/README.md +++ b/README.md @@ -671,6 +671,7 @@ another install of this integration on the same panel. After the first pin, any | Setting up by hostname | Verified, never relaxed. A domain joins the certificate's SAN only after you authenticate; everything before that runs against an address the certificate already names. | | Panel announces a new address, or you re-add it | The entry moves only if the candidate serves a certificate its own anchor validates. Otherwise the move is refused and logged at `WARNING`. | | The panel really has moved | Use **Reconfigure**. A host that does not chain is refused, one that does not answer is reported unreachable, and one that chains but is not named is your panel — move it to an **FQDN** (the panel regenerates its certificate around that name) or the panel's **`.local` name** (already covered). A bare new IP the certificate does not name is refused. | +| A panel announced by an **add-on** | The add-on's ports are taken as published, because add-ons reallocate their own. The address you configured is kept while it still answers for that panel, and replaced only when it has stopped answering. | | The entry has no anchor, or a stored one won't load | **Reauthenticate** acquires one before either sign-in method is offered. | **Entries from before pinning** pin at the first startup that reaches the panel, logged at `WARNING` with the fingerprint. Until that succeeds the authority is diff --git a/custom_components/span_panel/config_flow.py b/custom_components/span_panel/config_flow.py index 03b32a4d..5a30a6af 100644 --- a/custom_components/span_panel/config_flow.py +++ b/custom_components/span_panel/config_flow.py @@ -363,6 +363,75 @@ async def _async_host_update(self) -> dict[str, Any] | None: ) return None + async def _async_hassio_host_update(self, host: str, port: int, serial: str) -> dict[str, Any]: + """Return the host keys a Supervisor discovery should carry, usually none. + + An add-on restart republishes the container hostname it answers on, + which is not a claim that the panel has moved and is not the address the + user configured. Overwriting the stored host with it broke a working + entry seconds after creation: the panel's certificate names the address + the user typed and does not name the add-on's hostname, so every + connection afterwards failed verification against the entry's own pin. + + The question that decides it is the same one the entry itself asks on + every setup -- does this address still reach this panel -- so it is + asked directly rather than by proxy: probe the configured host on the + *newly discovered* port, because a reallocated port on the same machine + is exactly the case the ports being unguarded exists for. A v2 answer + carrying this serial means the entry is fine where it is and is left + alone. + + Deliberately not `_async_host_update`'s pinned-address check. That one + decides whether an unauthenticated claim may move an entry, and answers + "no" by refusing the move outright; this one decides whether there is + anything to move at all, and its evidence is the panel the entry already + has rather than the candidate. An add-on that has genuinely taken the + panel's place still moves the entry, which is the Supervisor route's + documented trade. + + `CONF_EBUS_BROKER_HOST` moves with `CONF_HOST` or not at all. The two + are the same address for the same panel -- `async_setup_entry` dials the + configured host and only logs the broker's own advertisement -- and + moving one without the other left the entry describing two different + machines. + """ + entry = self.hass.config_entries.async_entry_for_domain_unique_id(self.handler, serial) + if entry is None: + return {CONF_HOST: host} + + configured = str(entry.data.get(CONF_HOST, "")) + if not configured or configured == host: + return {CONF_HOST: host} + + detection = await detect_api_version( + configured, port=port, httpx_client=get_async_client(self.hass, verify_ssl=False) + ) + reached = ( + detection.api_version == "v2" + and detection.status_info is not None + and detection.status_info.serial_number == serial + ) + if reached: + _LOGGER.debug( + "Supervisor discovery published host %s, but panel %s still answers at its " + "configured host %s on port %s. Taking the ports and leaving the host alone", + host, + serial, + configured, + port, + ) + return {} + + _LOGGER.info( + "Moving the entry for panel %s from %s to %s: the configured host no longer " + "answers for this panel on port %s", + serial, + configured, + host, + port, + ) + return {CONF_HOST: host, CONF_EBUS_BROKER_HOST: host} + async def async_step_zeroconf(self, discovery_info: ZeroconfServiceInfo) -> ConfigFlowResult: """Handle a flow initiated by zeroconf discovery.""" # Do not probe device if the host is already configured @@ -434,18 +503,22 @@ async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> ConfigFl Deduplicate by panel serial, not by host, so each panel gets its own config entry. - **This route is deliberately not held to the pinned-address check** that + **The ports are deliberately not held to the pinned-address check** that `async_step_zeroconf` and the by-hand re-add both go through, in `_async_host_update`. A Supervisor discovery arrives over the authenticated Supervisor API from an add-on the user installed, and - add-ons legitimately reallocate their own ports, so applying that guard - would freeze the entry against its own add-on -- which is why the ports - go straight into the update below. The cost is stated rather than - hidden, here and in README's Security section: an add-on that already - holds Supervisor privileges can move a pinned entry. If the trade is - ever revisited, the guard is the same helper the other two routes call. - See developer.md, "The Supervisor discovery path is deliberately - unguarded". + add-ons legitimately reallocate their own ports across restarts, so + holding the ports to the stored values would freeze the entry against + its own add-on. They go straight into the update below. + + The *host* is a different question and is not carried by that argument. + An add-on reallocating a port has not moved, and the address the user + configured is the one their panel's certificate names; overwriting it + with the add-on's own container hostname broke a working entry seconds + after it was created, because that hostname is not a name the panel + serves. So the host moves only when the configured one has stopped + answering for this panel -- see `_async_hassio_host_update`. See + developer.md, "The Supervisor discovery path is unguarded on ports". """ config = discovery_info.config host = str(config.get("host", "")) @@ -483,9 +556,10 @@ async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> ConfigFl # restarts: an entry left pointing at last run's ports is an entry that # cannot reach its panel. await self.async_set_unique_id(panel_serial) - updates: dict[str, Any] = {CONF_HOST: host, CONF_HTTP_PORT: port} + updates: dict[str, Any] = {CONF_HTTP_PORT: port} if self._https_port_known: updates[CONF_HTTPS_PORT] = self._https_port + updates.update(await self._async_hassio_host_update(host, port, panel_serial)) self._abort_if_unique_id_configured(updates=updates) # Set up flow — same path as v2 zeroconf discovery diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index ea428a30..22f9bb58 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -27,7 +27,7 @@ "span-panel-api-schema-0==1.1.2", "span-panel-api-schema-1==1.1.3" ], - "version": "2.1.0b22", + "version": "2.1.0b23", "zeroconf": [ { "type": "_span._tcp.local." diff --git a/developer.md b/developer.md index 1e9bdb3f..34229cdb 100644 --- a/developer.md +++ b/developer.md @@ -823,15 +823,23 @@ advertised by SPAN panel …" with the fingerprint. **A registered domain is checked once the panel reports the new certificate**, not when registration is requested. The panel regenerates its certificate around the FQDN asynchronously, so the flow polls for the name to appear in the SAN rather than assuming the call took effect. -## The Supervisor discovery path is deliberately unguarded +## The Supervisor discovery path is unguarded on ports A pinned entry only follows a discovered host when that host serves a certificate the entry's anchor validates — `async_step_zeroconf` and the by-hand re-add both go through that check, because the serial they match on comes from an unauthenticated endpoint anything on the LAN can answer. -`async_step_hassio` deliberately does not. A Supervisor discovery arrives over the authenticated Supervisor API from an add-on the user installed, and add-ons -legitimately reallocate their own ports, so applying the stored-address guard would freeze the entry against its own add-on. The cost is stated rather than -hidden: an add-on that already holds Supervisor privileges can move a pinned entry. If that trade is ever revisited, the guard is the same helper the other two -routes call. +`async_step_hassio` deliberately does not hold its **ports** to that check. A Supervisor discovery arrives over the authenticated Supervisor API from an add-on +the user installed, and add-ons legitimately reallocate their own ports across restarts, so holding the ports to the stored values would freeze the entry +against its own add-on. + +**The host is not covered by that argument, and is not taken on the add-on's word.** An add-on republishing its container hostname has not moved the panel, and +that hostname is generally not a name the panel's certificate carries — writing it over a working host broke an entry seconds after it was created. So +`_async_hassio_host_update` probes the _configured_ host on the _newly discovered_ port, which is precisely the reallocated-port case, and keeps the stored host +whenever a v2 answer there carries this panel's serial. Only a configured host that has stopped answering for this panel is replaced, and then `CONF_HOST` and +`CONF_EBUS_BROKER_HOST` move together — moving one without the other left the entry naming two different machines. + +That is a narrower check than the other two routes make, and the residual cost is stated rather than hidden: an add-on that already holds Supervisor privileges +can still move an entry whose configured host has genuinely gone away. If that trade is ever revisited, the guard is the same helper the other two routes call. ## Linting and Type Checking diff --git a/pyproject.toml b/pyproject.toml index 51ad93b3..f648785c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span" -version = "2.1.0" +version = "2.1.0b23" description = "Span Panel Custom Integration for Home Assistant" authors = [{name = "SpanPanel"}] license = {text = "MIT"} diff --git a/tests/test_v2_config_flow.py b/tests/test_v2_config_flow.py index 8ec2ca96..a1d9ddff 100644 --- a/tests/test_v2_config_flow.py +++ b/tests/test_v2_config_flow.py @@ -2,18 +2,19 @@ from __future__ import annotations +from collections.abc import Callable import ipaddress import logging import ssl from unittest.mock import AsyncMock, MagicMock, patch -import pytest from homeassistant import config_entries from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo +import pytest from pytest_homeassistant_custom_component.common import MockConfigEntry from span_panel_api import DetectionResult, V2AuthResponse, V2StatusInfo from span_panel_api.exceptions import SpanPanelAuthError, SpanPanelConnectionError @@ -190,9 +191,7 @@ async def test_user_flow_detects_v2_and_shows_auth_choice(hass: HomeAssistant) - assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" - result2 = await _submit_host_and_pin( - hass, result["flow_id"], {CONF_HOST: MOCK_HOST} - ) + result2 = await _submit_host_and_pin(hass, result["flow_id"], {CONF_HOST: MOCK_HOST}) assert result2["type"] == FlowResultType.MENU assert result2["step_id"] == "choose_v2_auth" @@ -282,9 +281,7 @@ async def test_passphrase_auth_success(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await _submit_host_and_pin( - hass, result["flow_id"], {CONF_HOST: MOCK_HOST} - ) + result2 = await _submit_host_and_pin(hass, result["flow_id"], {CONF_HOST: MOCK_HOST}) assert result2["step_id"] == "choose_v2_auth" # Select passphrase auth from the menu @@ -324,9 +321,7 @@ async def test_passphrase_auth_bad_passphrase(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await _submit_host_and_pin( - hass, result["flow_id"], {CONF_HOST: MOCK_HOST} - ) + result2 = await _submit_host_and_pin(hass, result["flow_id"], {CONF_HOST: MOCK_HOST}) # Select passphrase auth from the menu result2b = await hass.config_entries.flow.async_configure( @@ -365,9 +360,7 @@ async def test_passphrase_auth_connection_error(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await _submit_host_and_pin( - hass, result["flow_id"], {CONF_HOST: MOCK_HOST} - ) + result2 = await _submit_host_and_pin(hass, result["flow_id"], {CONF_HOST: MOCK_HOST}) # Select passphrase auth from the menu result2b = await hass.config_entries.flow.async_configure( @@ -411,9 +404,7 @@ async def test_v2_entry_contains_mqtt_credentials(hass: HomeAssistant) -> None: ) # Step 1: submit host - result2 = await _submit_host_and_pin( - hass, result["flow_id"], {CONF_HOST: MOCK_HOST} - ) + result2 = await _submit_host_and_pin(hass, result["flow_id"], {CONF_HOST: MOCK_HOST}) # Step 2: choose auth method (passphrase) result2b = await hass.config_entries.flow.async_configure( @@ -456,9 +447,7 @@ async def _reach_the_ca_step(hass: HomeAssistant): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - return await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_HOST: MOCK_HOST} - ) + return await hass.config_entries.flow.async_configure(result["flow_id"], {CONF_HOST: MOCK_HOST}) @pytest.mark.asyncio @@ -584,9 +573,7 @@ async def test_a_recovered_panel_can_be_retried_into_a_successful_pin( patch("custom_components.span_panel.config_flow.validate_host", return_value=True), patch( "custom_components.span_panel.config_flow.async_fetch_panel_ca", - new=AsyncMock( - side_effect=[SpanPanelConnectionError("unreachable"), FAKE_CA_PEM] - ), + new=AsyncMock(side_effect=[SpanPanelConnectionError("unreachable"), FAKE_CA_PEM]), ), ): failed = await _reach_the_ca_step(hass) @@ -1408,9 +1395,7 @@ async def test_user_flow_recovery_after_bad_host(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "cannot_connect"} # Second attempt succeeds - result3 = await _submit_host_and_pin( - hass, result2["flow_id"], {CONF_HOST: MOCK_HOST} - ) + result3 = await _submit_host_and_pin(hass, result2["flow_id"], {CONF_HOST: MOCK_HOST}) assert result3["type"] == FlowResultType.MENU assert result3["step_id"] == "choose_v2_auth" @@ -1435,9 +1420,7 @@ async def test_passphrase_auth_empty_passphrase(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await _submit_host_and_pin( - hass, result["flow_id"], {CONF_HOST: MOCK_HOST} - ) + result2 = await _submit_host_and_pin(hass, result["flow_id"], {CONF_HOST: MOCK_HOST}) result2b = await hass.config_entries.flow.async_configure( result2["flow_id"], @@ -1475,9 +1458,7 @@ async def test_passphrase_auth_recovery_after_error(hass: HomeAssistant) -> None DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await _submit_host_and_pin( - hass, result["flow_id"], {CONF_HOST: MOCK_HOST} - ) + result2 = await _submit_host_and_pin(hass, result["flow_id"], {CONF_HOST: MOCK_HOST}) result2b = await hass.config_entries.flow.async_configure( result2["flow_id"], @@ -1524,9 +1505,7 @@ async def test_proximity_auth_success(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await _submit_host_and_pin( - hass, result["flow_id"], {CONF_HOST: MOCK_HOST} - ) + result2 = await _submit_host_and_pin(hass, result["flow_id"], {CONF_HOST: MOCK_HOST}) assert result2["step_id"] == "choose_v2_auth" result2b = await hass.config_entries.flow.async_configure( @@ -1562,9 +1541,7 @@ async def test_proximity_not_proven_returns_to_menu(hass: HomeAssistant) -> None DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await _submit_host_and_pin( - hass, result["flow_id"], {CONF_HOST: MOCK_HOST} - ) + result2 = await _submit_host_and_pin(hass, result["flow_id"], {CONF_HOST: MOCK_HOST}) result2b = await hass.config_entries.flow.async_configure( result2["flow_id"], @@ -1599,9 +1576,7 @@ async def test_proximity_switch_to_passphrase(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await _submit_host_and_pin( - hass, result["flow_id"], {CONF_HOST: MOCK_HOST} - ) + result2 = await _submit_host_and_pin(hass, result["flow_id"], {CONF_HOST: MOCK_HOST}) result2b = await hass.config_entries.flow.async_configure( result2["flow_id"], @@ -2228,28 +2203,53 @@ async def test_hassio_discovery_routes_to_confirm(hass: HomeAssistant) -> None: @pytest.mark.asyncio -async def test_hassio_dedup_by_serial(hass: HomeAssistant) -> None: - """Hassio discovery of an already-configured serial should abort and update host/port.""" - existing = MockConfigEntry( +def _hassio_configured_entry(hass: HomeAssistant, **data: object) -> MockConfigEntry: + """Add an entry for the simulator's serial, configured at a host of its own.""" + entry = MockConfigEntry( version=3, minor_version=1, domain=DOMAIN, title="Span Panel", data={ CONF_HOST: "192.168.1.40", + CONF_EBUS_BROKER_HOST: "192.168.1.40", CONF_ACCESS_TOKEN: "existing-token", CONF_API_VERSION: "v2", CONF_HTTP_PORT: 80, + **data, }, source=config_entries.SOURCE_USER, options={}, unique_id="SPAN-SIM-001", ) - existing.add_to_hass(hass) + entry.add_to_hass(hass) + return entry + + +def _detection_by_host(*answering: str) -> Callable[..., DetectionResult]: + """Answer as the simulator for the named hosts and as nothing for the rest. + + Keyed by host because that is the whole question here: the discovered host + and the configured one are two different addresses, and which of them + answers is what decides whether the entry moves. + """ + + def detect(host: str, **_kwargs: object) -> DetectionResult: + if host in answering: + return MOCK_V2_DETECTION_SIM + return DetectionResult(api_version="v1", probe_failed=True) + + return detect + + +@pytest.mark.asyncio +async def test_hassio_dedup_by_serial(hass: HomeAssistant) -> None: + """A discovery of an already-configured serial aborts rather than adding a second entry.""" + existing = _hassio_configured_entry(hass) with patch( "custom_components.span_panel.config_flow.detect_api_version", - return_value=MOCK_V2_DETECTION_SIM, + side_effect=_detection_by_host("192.168.1.50", "192.168.1.40"), ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -2259,11 +2259,111 @@ async def test_hassio_dedup_by_serial(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" - # Host and port should be updated to the new values + assert existing.data[CONF_HTTP_PORT] == 9090 + + +@pytest.mark.asyncio +async def test_hassio_keeps_a_configured_host_that_still_answers(hass: HomeAssistant) -> None: + """The defect: an add-on restart overwrote a working host with its own hostname. + + The add-on republishes the container hostname it answers on, which is not a + claim that the panel moved and is generally not a name the panel's + certificate carries. Writing it over the configured host broke the entry + seconds after it was created — every connection afterwards failed + verification against the entry's own pin. + """ + existing = _hassio_configured_entry(hass) + + with patch( + "custom_components.span_panel.config_flow.detect_api_version", + side_effect=_detection_by_host("192.168.1.50", "192.168.1.40"), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=_hassio_service_info(MOCK_HASSIO_CONFIG), + ) + + assert result["reason"] == "already_configured" + assert existing.data[CONF_HOST] == "192.168.1.40" + assert existing.data[CONF_EBUS_BROKER_HOST] == "192.168.1.40" + # The ports are the add-on's own and are taken as published either way. + assert existing.data[CONF_HTTP_PORT] == 9090 + + +@pytest.mark.asyncio +async def test_hassio_probes_the_configured_host_on_the_newly_published_port( + hass: HomeAssistant, +) -> None: + """A reallocated port on the same machine is the case the ports are unguarded for. + + Probing the configured host on its *stored* port would report it dead every + time the add-on moved, which is the move this is meant to prevent. + """ + _hassio_configured_entry(hass) + probe = AsyncMock(side_effect=_detection_by_host("192.168.1.50", "192.168.1.40")) + + with patch("custom_components.span_panel.config_flow.detect_api_version", probe): + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=_hassio_service_info(MOCK_HASSIO_CONFIG), + ) + + probed = [(call.args[0], call.kwargs["port"]) for call in probe.call_args_list] + assert ("192.168.1.40", 9090) in probed + + +@pytest.mark.asyncio +async def test_hassio_moves_host_and_broker_host_together_when_the_old_one_is_dead( + hass: HomeAssistant, +) -> None: + """A configured host that has stopped answering is a panel that really moved. + + Both keys move or neither does: they are the same address for the same + panel, and moving one without the other left the entry naming two different + machines. + """ + existing = _hassio_configured_entry(hass) + + with patch( + "custom_components.span_panel.config_flow.detect_api_version", + side_effect=_detection_by_host("192.168.1.50"), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=_hassio_service_info(MOCK_HASSIO_CONFIG), + ) + + assert result["reason"] == "already_configured" assert existing.data[CONF_HOST] == "192.168.1.50" + assert existing.data[CONF_EBUS_BROKER_HOST] == "192.168.1.50" assert existing.data[CONF_HTTP_PORT] == 9090 +@pytest.mark.asyncio +async def test_hassio_takes_both_ports_whichever_way_the_host_goes( + hass: HomeAssistant, +) -> None: + """The published ports are the add-on's own and are never held to stored values.""" + kept = _hassio_configured_entry(hass, **{CONF_HTTPS_PORT: 443}) + + with patch( + "custom_components.span_panel.config_flow.detect_api_version", + side_effect=_detection_by_host("192.168.1.50", "192.168.1.40"), + ): + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=_hassio_service_info({**MOCK_HASSIO_CONFIG, "https_port": 10090}), + ) + + assert kept.data[CONF_HOST] == "192.168.1.40" + assert kept.data[CONF_HTTP_PORT] == 9090 + assert kept.data[CONF_HTTPS_PORT] == 10090 + + @pytest.mark.usefixtures("socket_enabled") @pytest.mark.asyncio async def test_hassio_end_to_end_entry_creation(hass: HomeAssistant) -> None: @@ -2290,9 +2390,7 @@ async def test_hassio_end_to_end_entry_creation(hass: HomeAssistant) -> None: # Step 2: confirm -> HTTPS port (this panel moved its HTTP port) -> CA port_step = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert port_step["step_id"] == "panel_https_port" - result2 = await _submit_host_and_pin( - hass, port_step["flow_id"], {CONF_HTTPS_PORT: 9443} - ) + result2 = await _submit_host_and_pin(hass, port_step["flow_id"], {CONF_HTTPS_PORT: 9443}) assert result2["type"] == FlowResultType.MENU assert result2["step_id"] == "choose_v2_auth" @@ -2599,9 +2697,7 @@ async def test_zeroconf_invalid_http_port_defaults_to_80(hass: HomeAssistant) -> assert result["type"] == FlowResultType.FORM assert result["step_id"] == "confirm_discovery" - mock_detect.assert_awaited_once_with( - "192.168.1.200", port=80, httpx_client=fake_client - ) + mock_detect.assert_awaited_once_with("192.168.1.200", port=80, httpx_client=fake_client) @pytest.mark.usefixtures("socket_enabled") @@ -2801,9 +2897,7 @@ async def test_fqdn_entry_creation_sets_registered_fqdn_and_unique_title( ) assert port_step["step_id"] == "panel_https_port" # Left at the default, so it is not written to the entry. - result2 = await _submit_host_and_pin( - hass, port_step["flow_id"], {CONF_HTTPS_PORT: 443} - ) + result2 = await _submit_host_and_pin(hass, port_step["flow_id"], {CONF_HTTPS_PORT: 443}) result2b = await hass.config_entries.flow.async_configure( result2["flow_id"], {"next_step_id": "auth_passphrase"}, diff --git a/uv.lock b/uv.lock index 6a92320c..fb4249ba 100644 --- a/uv.lock +++ b/uv.lock @@ -2560,7 +2560,7 @@ wheels = [ [[package]] name = "span" -version = "2.1.0" +version = "2.1.0b23" source = { virtual = "." } dependencies = [ { name = "homeassistant" }, From b1a134f0ecf0b60e2f9256b2ef50529117a73312 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:05:27 -0700 Subject: [PATCH 7/7] chore(release): 2.1.0 --- custom_components/span_panel/manifest.json | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 22f9bb58..49c0c6fa 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -27,7 +27,7 @@ "span-panel-api-schema-0==1.1.2", "span-panel-api-schema-1==1.1.3" ], - "version": "2.1.0b23", + "version": "2.1.0", "zeroconf": [ { "type": "_span._tcp.local." diff --git a/pyproject.toml b/pyproject.toml index f648785c..51ad93b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span" -version = "2.1.0b23" +version = "2.1.0" description = "Span Panel Custom Integration for Home Assistant" authors = [{name = "SpanPanel"}] license = {text = "MIT"} diff --git a/uv.lock b/uv.lock index fb4249ba..6a92320c 100644 --- a/uv.lock +++ b/uv.lock @@ -2560,7 +2560,7 @@ wheels = [ [[package]] name = "span" -version = "2.1.0b23" +version = "2.1.0" source = { virtual = "." } dependencies = [ { name = "homeassistant" },