5 Commits
Author SHA1 Message Date
Marc Billow 81dbc6fa03 Key devices on the OCF device ID instead of the serial number
Two Samsung air purifiers of the same model report the identical, well
formed serialNum `BS7SP9AW400114A` (issue #381). Since the entry's
unique_id, the device registry identifiers and every entity unique_id
were all minted from that string, the second unit was refused as already
configured, and would have collided entity-for-entity even if it hadn't
been.

This is the third firmware family to ship an unusable serialNum, after
`Nothing(SVC)` (#83) and the flash-unset sentinel (#189), and the first
one no heuristic can catch: the value is well formed, it's just shared.
`is_placeholder_serial` was a dead end.

So identity moves onto /oic/d's `di`, falling back to /oic/p's `pi`, then
the serial, then the host. `di` is what the protocol already uses to
address the endpoint -- if it were wrong or shared, OCF discovery and the
DTLS association wouldn't work at all -- and it's device-scoped, where
`pi` is platform-scoped and would be shared by a board hosting several
logical devices. Both units in #381 report a distinct `di`. A board that
answers neither resource lands exactly where it did before, so no
existing hardware regresses.

The re-key can't happen in async_migrate_entry: the UUID is only readable
from the device, and an entry can load entirely from its snapshot while
the appliance is off (#295). So v3 -> v4 only records the legacy key, and
the coordinator adopts the UUID on the first live poll, rewriting the
entity registry, the device registry (including subdevice identifiers)
and the entry's unique_id together. Rewriting rather than recreating is
what lets a user keep entity_ids, names, areas, statistics and every
automation that references them.

Three rules keep that adoption from misfiring:

- A poll that reads no UUID never demotes a UUID-keyed entry back onto
  its serial, so one failed reconnect doesn't re-key every entity.
- A changed UUID is followed only when the serial still corroborates it
  (a factory reset may regenerate `di`) or when the entry was keyed on
  its IP, which was never an identity to defend.
- When the identity is rejected as a different appliance, the serial
  isn't adopted either -- otherwise the intruder would gain exactly the
  corroboration needed to win the next poll.

Also stop redacting `di`/`pi` from diagnostics. They're randomly assigned
per-unit UUIDs, not account data, and blanking them is what made the
first #381 diagnostics download unable to answer the only question it was
requested to answer. The owner-set device name stays redacted.

Fixes #381
2026-08-17 05:24:22 +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 c9620b47b3 fix(diagnostics): redact the OCF device name; log description on unknown type
Two things a review of this branch turned up.

/oic/d's `n` is free text the owner sets from the SmartThings app, so it may
carry a person's name. Nothing in the /device/0 dump has ever exposed it --
it only became reachable when diagnostics started reporting /oic/d earlier
in this branch, which would have started carrying it into public issue
reports. Redact it. `rt`, the device-type signal the block exists for, is
untouched, and no /device/0 resource uses a bare 'n' key, so nothing else
changes.

The unknown-device-type warning logged only modelNum. That line is what a
user pastes into an issue, and modelNum alone can't identify a washer from a
dryer -- both report the shared DA_WM_ laundry board, and detection reads
the consumer-model code out of `description` for exactly that reason. Log
both fields.
2026-07-29 02:20:37 +00:00
Marc Billow 288ba02cc5 feat(diagnostics): capture /oic/p and /oic/d identity
Device-type detection currently parses board part numbers out of
/information/vs/0's modelNum. OCF has a standard field for exactly this
question -- /oic/d's `rt` -- and read_identity() already fetches the
resource, but kept only `n` and threw the rest away. No captured dump has
ever included it either: /device/0 batch responses don't carry /oic/d, and
diagnostics didn't report it, so there's no evidence on whether real
hardware populates it usefully.

Keep `rt` as DeviceIdentity.device_types, keep both raw payloads whole
(we don't yet know which of their fields identify a type), and surface
them in diagnostics so incoming issue reports answer the question.

Nothing routes on it yet.

/oic/d and /oic/p identify the unit with bare two-letter keys -- 'di' and
'pi' -- as sensitive as the serial number redact.py already covers but far
too short to match on: 'di' alone is a substring of 'condition', 'display'
and 'dispenser'. Add a whole-key match alongside the substring rules.
2026-07-29 01:56:52 +00:00
Marc Billow 35b47d6b5a Add coverage-gap detection: ignored-hrefs registry, Repairs issue, redaction
Implements the first half of the device-capability-diagnostics spec:

- registry/capabilities/ignored.py: known-noise hrefs (Bixby/voice
  provisioning, WiFi/BLE info, OTA/region housekeeping, and a couple of
  redundant hrefs) declared as no-entity Capability objects, so
  discover()'s existing unknown-resource reporting treats them as covered
  instead of flagging every device as having gaps. Folded into each
  by_type registry and into the global fallback CAPABILITIES set.
- discovery.py: log() callback now passes the raw href instead of a
  formatted message, so callers can collect a clean unbound-hrefs list.
- coordinator.py: wires that callback into self._unbound_hrefs, tracks
  device_type_name/one_ui_version, and raises (or clears) a Repairs issue
  when a device's type is unrecognized or it has genuinely unmodeled
  hrefs left over.
- registry/redact.py: recursive, substring-keyed redaction for anything
  that looks like account/identity data, ahead of the diagnostics.py
  platform that will consume it.

Verified against the real dishwasher/refrigerator fixtures: known-noise
hrefs no longer show up as gaps, while genuine gaps (e.g. /bespoke/vs/0
on the fridge) still do.
2026-07-07 23:49:58 -05:00