Commit Graph
8 Commits
Author SHA1 Message Date
Marc Billow ba089dbb5c Bump smartthings-local floor to 0.1.8
Two releases landed since the 0.1.6 pin, both confirmed by upstream
(QuiteYellow, in issue #361) as additive/opt-in with no interface
changes on our side:

- 0.1.7: server-certificate profiles (SamsungServerProfile), a bounded
  DTLS handshake deadline (connect() now defaults to a 12s bound
  instead of none), and a cancellable connect() via
  ConnectCancellation. Our connect() call sites in coordinator.py and
  config_flow.py pass no args, so they pick up the bounded handshake
  for free; the cert-profile and cancellation pieces are opt-in and
  unused here.

- 0.1.8: fixes blockwise OBSERVE notification reassembly
  (QuiteYellow/SmartThings-Local#39) -- a notification carrying only
  the first Block2 block was previously handed straight to
  on_notification instead of being reassembled, and separately, the
  Block2 loop could append a retransmitted/late block as if it were
  the next one, or miscompute the next block offset after a mid-
  transfer size downshift. Both corrupt a multi-block observed
  resource without necessarily truncating it -- the "premature end of
  stream" / "error decoding unicode string" CBOR failures reported in
  issue #361 on /mode/vs/0. All error types stay within the existing
  compatible-built-in table (ConnectionError/TimeoutError subclasses),
  so no exception handling changes.

`>=0.1.6` already permitted pip to resolve 0.1.8 on a fresh install,
but an environment that already has 0.1.6 or 0.1.7 satisfying that
floor won't be upgraded by Home Assistant's requirement check -- which
is what #361's reporter is very likely still hitting on 0.22.0.
Raising the floor to >=0.1.8 forces that upgrade on the next release.

Verified against smartthings-local 0.1.8 from PyPI: full suite (1573
tests), ruff, and ty all pass. No source changes needed beyond the
three version pins (manifest.json, requirements-dev.txt, Dockerfile).
2026-08-16 03:44:11 +00:00
Marc Billow 3f9789512d 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.
2026-08-14 17:50:10 +00:00
Marc Billow 15be379243 Rebuild device discovery on the ClientHello probe and resolve identity up front
Two problems, one setup path.

Port detection (issue #211): the config flow found the DTLS port by
elimination -- a 1-byte UDP probe can't tell a silent port from a real
DTLS server, so every port it couldn't rule out got a full certificate
handshake, and every false positive cost the whole 12s HANDSHAKE_TIMEOUT_S
before the next was tried. Adding an appliance took 30-40s.

smartthings-local 0.1.2 ships a stateless ClientHello probe that settles
this positively: a real DTLS server answers with a HelloVerifyRequest in
~1 RTT, and per RFC 6347 4.2.1 it does so without allocating association
state, so the probe leaves nothing behind on the appliance. The whole
49152-49160 range is probed at once and exactly one confirmed port is
given a certificate handshake. Fanning out is safe here in a way racing
real handshakes is not -- each probe is bounded by a 3s budget, so the
pool costs one probe's wall clock rather than the sum of the range, with
no losing threads left running behind us.

The UDP sweep stays as the fallback for when the probe confirms nothing:
it errs in the opposite direction (it reports everything it can't rule
out), so it still surfaces a device on a path that eats our ClientHello,
and it keeps its issue #192 preferred-port rescue.

Port detection now runs first and needs no credentials, so an unreachable
host fails before any round trip to Samsung's cloud. And a second
appliance reuses the existing entry's leaf cert rather than re-minting --
every device accepts the same one -- which makes adding one independent
of Samsung-cloud reachability. A confirmed-live device rejecting the
reused leaf (the UUID does rotate) re-mints and retries once, so reuse
stays self-correcting; a timeout doesn't, since a fresh cert can't fix
nothing answering.

Identity (issue #236): the coordinator seeded device_serial with the
configured host and only replaced it after the first successful poll. But
device_serial mints *permanent* registry keys -- entity unique_ids and
device identifiers -- so anything registering before that poll returned
was written into the registry keyed on the IP address forever. The
connection-mode sensor is added unconditionally rather than from `bound`,
so it was the reliable victim: when the serial-keyed identity appeared
moments later HA created a second device and entity, and the IP-keyed
pair was orphaned. Deleting them didn't help; the next restart that lost
the race recreated them.

The probe already learns the identity, so store it on the config entry --
serial, model, manufacturer, device type. The coordinator seeds
device_serial and its DeviceInfo from those at construction, so keys are
correct from the first entity that registers even if the first poll is
slow or fails outright. There is no placeholder left to correct.

Discovery now treats the registered identity as authoritative rather than
re-keying a device that already has registry entries; it adopts and
persists the polled identity only for an entry that has none, and warns
if a different appliance answers on the same IP.

Entry version 1 -> 2 recovers the serial from the entry's unique_id (the
flow has always keyed it on the probe's serial) and repairs what the old
registration orphaned: IP-keyed devices and entities are rewritten in
place where the serial-keyed key is free -- keeping entity_id, name, area
and every automation referencing them -- and removed where both exist,
since the IP-keyed one has been dead since the restart that made it.
Placeholder-serial boards (issues #83/#189) were keyed two ways at once,
`host:port` on the entry and `host` in the registry; migration collapses
the entry onto the registry's form. One resolve_serial() now serves both
sides, so they can't drift apart again.

The remaining step in the desired pipeline -- probe for subdevices, then
register devices, then populate entities -- already holds:
_enumerate_subdevices_blocking runs before _run_discovery, which runs
before platforms are forwarded. Duplicating it in the config flow would
mean re-running Pattern B's per-href fallback probe, which is the
opposite of what issue #211 is about.
2026-08-03 20:14:54 +00:00
Marc Billow daf7e3787f Add ruff (lint + format) and ty (type checking) to the project
Adds [tool.ruff] and [tool.ty] config to pyproject.toml with a curated
ruff rule set (E, F, W, I, UP, B, C4, SIM, RUF, ASYNC, LOG, G, PIE, RET,
PERF, N), pins ruff/ty in requirements-dev.txt, reformats the whole tree
with `ruff format`, and fixes the pre-existing lint and type-check debt
those tools surfaced so both run clean.

Production-code type fixes include: HA's ConfigFlowResult vs. the
generic FlowResult in config_flow.py, narrowing BoundEntity.desc to its
platform-specific subclass (SelectDesc/NumberDesc/SensorDesc/etc.) via
cast() instead of an unchecked annotation, converting HA device_class
strings to their proper enum types, a resolve_registry callback typed
as `object` instead of `DeviceRegistry | None`, and a couple of other
narrow correctness fixes (CA key type validation, an index-out-of-bounds
false positive from an empty-tuple fallback, a bool/dict argument swap).

Test-file fixes are mechanical: narrowing SamsungEntityDescription to
the correct subclass via isinstance()/cast() before accessing
subclass-only fields, and asserting Optional write_fn/unit_fn fields
are set before calling them.
2026-08-02 23:56:38 +00:00
Marc Billow cafd7d5afa refactor(registry): drop oneUiVersion from device-type detection
oneUiVersion looks like the signal you'd want -- the device naming its own
type, '7.0 Dishwasher' -- and it was the first thing detection consulted. It
never earned the position:

- Only 7 of 49 fixtures report it at all.
- All 7 resolve to the same registry from their modelNum board token alone.
- No device-support issue has ever been fixed by adding a mapping for it.
  Every one went through modelNum. The alias keys it needed in
  _REGISTRY_BY_KEY ('airpurifier', 'air_conditioner', 'hood') were
  speculative when the registries were first written and never used since.

So it bought a key-normalizing helper (_type_key), a lookup with a suffix
fallback (for_device), three alias keys, and a second config-flow step whose
only reason to exist was phrasing a sentence about oneUiVersion -- for a
signal that has never once been decisive.

Remove it from detection. It stays in diagnostics, where it's genuinely
useful: it names the firmware generation ('7.0 Air conditioner' is Tizen
Lite), which matters when triaging an issue.

Detection order was also duplicated in four places -- the coordinator, the
config flow's probe, the golden-regression harness, and the skill -- which
is how the harness and the shipped order drift apart. Collapse it into
by_type.resolve(resources), and call that everywhere.

The two "appliance type not recognized" config steps become one. They
differed only in whether they blamed a missing oneUiVersion, which is not a
distinction a user can act on, and never was.

Verified by the full suite (795 passing), including every golden regression
-- so entity output is byte-identical for all 49 device fixtures.

TestOneUiVersionIsNotConsulted locks in the premise rather than just the
outcome: for every dump that reports a oneUiVersion, the model strings alone
must still reach a registry. If a future device breaks that, the test says
so instead of the device silently losing half its entities.

Also note in requirements-dev.txt that Python 3.13 resolves the pinned
harness floor -- 3.12 and older resolve nothing and fail the whole install.
2026-07-29 02:14:13 +00:00
Marc Billow 1576d095c8 ci: run pytest in CI; complete dev requirements; unblock socket test
The suite was never run in CI (only hassfest/HACS), and requirements-dev
listed only pytest + smartthings-local while conftest.py needs the full
Home Assistant test harness.

- requirements-dev.txt: add pytest-homeassistant-custom-component (pulls
  in home-assistant + pytest + pytest-socket) and the integration's
  runtime deps (cbor2, pyOpenSSL, cryptography) needed to import it.
- .github/workflows/validate.yml: add a Pytest job on Python 3.14
  (current Home Assistant's floor) that installs requirements-dev and
  runs the suite.
- test_config_flow.py: the UDP liveness-sweep test needs real loopback
  sockets, which the HA harness blocks by default; take the pytest-socket
  socket_enabled fixture so it runs instead of erroring.

Full suite: 249 passed.
2026-07-21 02:05:57 +00:00
Marc Billow 4782878583 Depend on published smartthings-local package, rewrite README
Now that mbillow/localthings#8's protocol fixes are merged upstream and
smartthings-local 0.1.0 is on PyPI, drop the vendored ocf/coap_dtls.py +
ocf/observe_refresh.py transport (dead code, never instantiated) and the
duplicated ocf_root_ca.pem in favor of the real package. manifest.json and
requirements-dev.txt now pin smartthings-local>=0.1.0 instead of the
unmerged git branch.

README rewritten from scratch — it still described the old standalone
MQTT-bridge (samsung_appliance/, main.py, docker-compose bridge) that this
repo moved off of; it now documents the real architecture: a native HA
custom component with config-flow-driven cert minting and a per-device-type
capability registry, with the protocol layer split out to smartthings-local.
2026-07-07 22:08:02 -05:00
Marc Billow c6f08ea340 test: add pytest infra and device-dump fixtures 2026-06-30 16:32:37 -05:00