Update to smartthings-local 0.1.6, handle redacted typed errors

Bumps the smartthings-local floor from >=0.1.2 to >=0.1.6 (manifest,
requirements-dev.txt, Dockerfile) and adopts the interface/behavior
changes introduced along the way:

- 0.1.3 ("redacted typed failures", PR #23) replaced connect()'s
  ConnectionError(f"DTLS handshake error: {e}") with fixed, redacted
  exceptions (SessionError, SessionTimeoutError, etc.) that never carry
  backend text -- including the TLS alert name. The config flow's
  _classify_handshake_failure relied on parsing that text out of the
  exception (_alert_name) to tell a rejected certificate from any other
  handshake failure; against a current library that regex never matches
  again, silently downgrading every setup failure to the generic
  "cannot_connect" message.

  Fixed by adding _resolve_alert: it still tries _alert_name first (a
  harmless fallback if it ever matches), then falls back to one bounded
  smartthings_local.protocol.dtls_probe.diagnose_dtls_handshake() call
  against the specific port that failed, which classifies the fatal
  Alert straight from the raw record instead of an exception string.
  _handshake_and_read now threads the resolved per-port alerts into
  _classify_handshake_failure, so CertRejected vs. HandshakeFailed keeps
  working the way it did before the redaction.

- 0.1.3 also moved the DTLS session onto a connected UDP socket (see
  endpoint.py's open_connected_udp_socket), which changes why
  coordinator._local_source_port needs a unique port per device -- the
  kernel now demuxes by the full local-port/remote-peer tuple instead of
  relying on an unconnected recvfrom(). Docstring updated to match.

- 0.1.6's reader-thread fail-fast fix (_check_live/_reader_running) makes
  a dead reader raise SessionClosedError immediately instead of hanging
  a request out to its timeout. No code change needed: SessionClosedError
  is a ConnectionError subclass (not TimeoutError), so the coordinator's
  existing _defer_reconnect_for/isinstance(e, TimeoutError) split already
  routes it to the immediate-reconnect path.

Every other new/changed piece (endpoint.py, dtls_probe.py bounded
probing, auth.py's CertificateAuth/PskAuth providers) stays behind
compatible built-in exception types and unchanged get()/post()/
subscribe()/ping() signatures, per the library's own compatibility
table, so the coordinator's and observe.py's broad exception handling
needed no changes.

Tests: added coverage for _resolve_alert's exception-text vs.
diagnostic-handshake fallback, _classify_handshake_failure with a
resolved alerts mapping, and an end-to-end config-flow re-mint test
against a FakeSession that raises the new redacted SessionError instead
of the old text-bearing ConnectionError.

Full suite (1517 tests), ruff, and ty all pass against smartthings-local
0.1.6 installed from PyPI.
This commit is contained in:
Marc Billow committed 2026-08-14 17:50:10 +00:00
1 parent cbe881818b
commit 3f9789512d
6 files changed
+200 -21

No files matched your search

+114
View File
@@ -238,6 +238,11 @@ class FakeSession:
instances: ClassVar[list[FakeSession]] = []
reject_certs: ClassVar[set[str]] = set()
# smartthings-local >= 0.1.3 ("redacted typed failures") no longer puts
# the alert in connect()'s exception text -- set True to model that, so
# a test can check the diagnostic-handshake fallback (_resolve_alert)
# instead of the legacy _alert_name text-parsing path.
redact_rejection: ClassVar[bool] = False
def __init__(self, host, port, cert_pem=None, key_pem=None, **kwargs):
self.host, self.port, self.cert_pem = host, port, cert_pem
@@ -245,6 +250,10 @@ class FakeSession:
def connect(self):
if self.cert_pem in FakeSession.reject_certs:
if FakeSession.redact_rejection:
from smartthings_local.errors import SessionError
raise SessionError()
raise ConnectionError(
"DTLS handshake error: [('SSL routines', '', 'sslv3 alert bad certificate')]"
)
@@ -268,6 +277,7 @@ def fake_dtls(monkeypatch):
FakeSession.instances = []
FakeSession.reject_certs = set()
FakeSession.redact_rejection = False
monkeypatch.setattr(config_flow, "_fetch_samsung_uuid", lambda: "test-uuid")
monkeypatch.setattr(
config_flow,
@@ -480,6 +490,44 @@ async def test_rejected_reused_leaf_is_reminted(
assert [s.cert_pem for s in FakeSession.instances] == [MOCK_LEAF_CERT_PEM, "FULLCHAIN"]
async def test_rejected_reused_leaf_is_reminted_against_a_redacted_library(
hass: HomeAssistant, monkeypatch, fake_dtls
) -> None:
"""Same flow as test_rejected_reused_leaf_is_reminted, but against a
connect() failure shaped like smartthings-local >= 0.1.3 -- a fixed,
redacted exception with no alert text at all (see errors.py's
"Classified errors"). The re-mint decision has to come from
_resolve_alert's diagnostic-handshake fallback instead of _alert_name."""
from custom_components.localthings import config_flow
existing = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id="localthings_other")
existing.add_to_hass(hass)
_patch_clienthello(monkeypatch, {49154})
FakeSession.reject_certs = {MOCK_LEAF_CERT_PEM}
FakeSession.redact_rejection = True
class _DiagnosticResult:
alert = (2, "bad_certificate")
diagnosed: list[int] = []
def _diagnostic_alert(host, port, cert_pem, key_pem):
diagnosed.append(port)
return _DiagnosticResult()
monkeypatch.setattr(config_flow, "_diagnostic_alert", _diagnostic_alert)
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: MOCK_HOST}
)
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["data"][CONF_LEAF_CERT_PEM] == "FULLCHAIN"
assert [s.cert_pem for s in FakeSession.instances] == [MOCK_LEAF_CERT_PEM, "FULLCHAIN"]
assert diagnosed == [49154]
async def test_unconfirmed_port_failure_is_not_reminted(
hass: HomeAssistant, monkeypatch, fake_dtls
) -> None:
@@ -554,6 +602,72 @@ def test_cert_alert_is_reported_as_a_certificate_problem() -> None:
assert err.error_key == "cert_rejected"
def test_classify_handshake_failure_uses_a_resolved_alert_over_exception_text() -> None:
"""_handshake_and_read passes its own resolved `alerts` mapping (built
via _resolve_alert, which is what actually classifies a failure against
smartthings-local >= 0.1.3's redacted exceptions) -- it must win even
when the exception text itself says nothing."""
from custom_components.localthings.config_flow import (
CertRejected,
_classify_handshake_failure,
)
err = _classify_handshake_failure(
MOCK_HOST,
_scan(confirmed=[49154]),
[(49154, RuntimeError("session operation failed"))],
{49154: "bad_certificate"},
)
assert isinstance(err, CertRejected)
assert err.error_key == "cert_rejected"
def test_resolve_alert_prefers_exception_text_over_the_diagnostic_handshake() -> None:
"""A library still stamping the alert into its exception text (< 0.1.3)
answers for free; the diagnostic handshake must not run at all then."""
from custom_components.localthings.config_flow import _resolve_alert
def _must_not_run(*args, **kwargs):
raise AssertionError("must not run the diagnostic handshake")
with patch("custom_components.localthings.config_flow._diagnostic_alert", _must_not_run):
name = _resolve_alert(
_openssl_alert("tlsv1 alert unknown ca"), MOCK_HOST, 49154, "CERT", "KEY"
)
assert name == "unknown_ca"
def test_resolve_alert_falls_back_to_the_diagnostic_handshake() -> None:
"""smartthings-local >= 0.1.3 redacts the exception text (see errors.py's
"Classified errors"), so the only way left to learn *why* a handshake
failed is the library's own classification of the raw alert record."""
from smartthings_local.errors import SessionError
from custom_components.localthings import config_flow
class _Result:
alert = (2, "bad_certificate")
with patch.object(config_flow, "_diagnostic_alert", lambda *a, **k: _Result()):
name = config_flow._resolve_alert(SessionError(), MOCK_HOST, 49154, "CERT", "KEY")
assert name == "bad_certificate"
def test_resolve_alert_is_none_when_the_diagnostic_handshake_also_fails() -> None:
"""A best-effort extra probe: its own failure must not raise out of
_resolve_alert, it just leaves the caller with no alert to report."""
from smartthings_local.errors import SessionError, SessionTimeoutError
from custom_components.localthings import config_flow
def _boom(*args, **kwargs):
raise SessionTimeoutError()
with patch.object(config_flow, "_diagnostic_alert", _boom):
name = config_flow._resolve_alert(SessionError(), MOCK_HOST, 49154, "CERT", "KEY")
assert name is None
def test_non_cert_alert_is_kept_distinct_from_a_cert_problem() -> None:
"""A cipher or version mismatch is also a deliberate refusal, but no
amount of fiddling with CA credentials will fix it."""