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.
This commit is contained in:
@@ -214,9 +214,7 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
|
||||
import cbor2
|
||||
from smartthings_local.protocol.dtls_session import DtlsCoapSession
|
||||
from .registry.batch import parse_device0_batch
|
||||
from .registry.by_type import (
|
||||
for_device, for_device_by_model, for_device_by_resources,
|
||||
)
|
||||
from .registry.by_type import resolve as resolve_registry
|
||||
|
||||
_LOGGER.debug("Fetching Samsung cloud UUID from %s", _SAMSUNG_CLOUD_HOST)
|
||||
try:
|
||||
@@ -275,25 +273,12 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
|
||||
)
|
||||
if not serial or _is_placeholder_serial(serial):
|
||||
serial = f"{host}:{port}"
|
||||
one_ui_version = (
|
||||
resources
|
||||
.get('/otninformation/vs/0', {})
|
||||
.get('swVersionInfo', {})
|
||||
.get('oneUiVersion', '')
|
||||
)
|
||||
info_resource = resources.get('/information/vs/0', {})
|
||||
recognized_registry = (
|
||||
for_device(one_ui_version) if one_ui_version else None
|
||||
) or for_device_by_model(
|
||||
info_resource.get('x.com.samsung.da.modelNum', ''),
|
||||
info_resource.get('x.com.samsung.da.description', ''),
|
||||
) or for_device_by_resources(resources)
|
||||
recognized_registry = resolve_registry(resources)
|
||||
return {
|
||||
"port": port,
|
||||
"serial": serial,
|
||||
"leaf_cert_pem": fullchain_pem,
|
||||
"leaf_key_pem": leaf_key_pem,
|
||||
"one_ui_version": one_ui_version,
|
||||
"device_type_recognized": recognized_registry is not None,
|
||||
}
|
||||
except CannotConnect:
|
||||
@@ -407,27 +392,9 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Shown only when the probe already knows the device type is unrecognized."""
|
||||
if user_input is not None:
|
||||
return self._create_entry(self._pending_info)
|
||||
|
||||
if not self._pending_info["one_ui_version"]:
|
||||
return await self.async_step_confirm_unknown_type_no_version()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="confirm_unknown_type",
|
||||
data_schema=vol.Schema({}),
|
||||
description_placeholders={
|
||||
"one_ui_version": self._pending_info["one_ui_version"],
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_confirm_unknown_type_no_version(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Confirm an unknown appliance that did not report oneUiVersion."""
|
||||
if user_input is not None:
|
||||
return self._create_entry(self._pending_info)
|
||||
return self.async_show_form(
|
||||
step_id="confirm_unknown_type_no_version",
|
||||
data_schema=vol.Schema({}),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ from smartthings_local.protocol.dtls_session import DtlsCoapSession
|
||||
from smartthings_local.ocf.state_cache import StateCache
|
||||
|
||||
from .registry.batch import parse_device0_batch
|
||||
from .registry.by_type import for_device, for_device_by_model, for_device_by_resources
|
||||
from .registry.by_type import resolve as resolve_registry
|
||||
from .registry.capabilities.common import (
|
||||
merge_items_field,
|
||||
merge_options_field,
|
||||
@@ -352,19 +352,15 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _run_discovery(self, resources: dict[str, dict]) -> None:
|
||||
one_ui = (resources.get('/otninformation/vs/0', {})
|
||||
.get('swVersionInfo', {})
|
||||
.get('oneUiVersion', ''))
|
||||
self.one_ui_version = one_ui
|
||||
# Reported for diagnostics only -- it names the firmware generation
|
||||
# ('7.0 Air conditioner' is Tizen Lite), which is useful when triaging
|
||||
# an issue. It does not route: only a minority of hardware reports it
|
||||
# at all, and every device that does is already typed by its modelNum.
|
||||
self.one_ui_version = (resources.get('/otninformation/vs/0', {})
|
||||
.get('swVersionInfo', {})
|
||||
.get('oneUiVersion', ''))
|
||||
info = resources.get('/information/vs/0', {})
|
||||
reg = for_device(one_ui) if one_ui else None
|
||||
if reg is None:
|
||||
reg = for_device_by_model(
|
||||
info.get('x.com.samsung.da.modelNum', ''),
|
||||
info.get('x.com.samsung.da.description', ''),
|
||||
)
|
||||
if reg is None:
|
||||
reg = for_device_by_resources(resources)
|
||||
reg = resolve_registry(resources)
|
||||
unbound: list[str] = []
|
||||
hot, warm = set(), set()
|
||||
|
||||
@@ -374,13 +370,14 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
elif tier == 'warm':
|
||||
warm.add(href)
|
||||
|
||||
model_num = info.get('x.com.samsung.da.modelNum', '')
|
||||
if reg is not None:
|
||||
self._log.debug("device type: %s (oneUiVersion=%r)", reg.name, one_ui)
|
||||
self._log.debug("device type: %s (modelNum=%r)", reg.name, model_num)
|
||||
bound = discover(resources, reg.capabilities, reg.pattern_capabilities,
|
||||
log=unbound.append, tier_log=_tier_log)
|
||||
self.device_type_name = reg.name
|
||||
else:
|
||||
self._log.warning("unknown device type oneUiVersion=%r; using common caps", one_ui)
|
||||
self._log.warning("unknown device type modelNum=%r; using common caps", model_num)
|
||||
bound = discover(resources, CAPABILITIES, log=unbound.append, tier_log=_tier_log)
|
||||
self.device_type_name = None
|
||||
self.bound = bound
|
||||
@@ -393,7 +390,6 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
|
||||
ident = self._identity
|
||||
device_type = reg.name.replace('_', ' ').title() if reg else 'Appliance'
|
||||
model_num = info.get('x.com.samsung.da.modelNum', '')
|
||||
model = model_num.split('|', 1)[0] if model_num else (ident.model if ident else '')
|
||||
name = f"Samsung {device_type} ({model})" if model else f"Samsung {device_type}"
|
||||
mfr = (ident.manufacturer if ident else '') or 'Samsung'
|
||||
|
||||
@@ -10,17 +10,17 @@ from . import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'DeviceRegistry', '_type_key', 'for_device', 'for_device_by_model',
|
||||
'DeviceRegistry', 'resolve', 'for_device_by_model',
|
||||
'for_device_by_resources', '_board_tokens',
|
||||
]
|
||||
|
||||
|
||||
# One entry per registry, no aliases: every key here is reachable from
|
||||
# `_BOARD_TOKEN_TO_KEY`, `_CONSUMER_PREFIX_TO_KEY`, or `for_device_by_resources`.
|
||||
_REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
|
||||
'air_dresser': air_dresser.REGISTRY,
|
||||
'air_purifier': air_purifier.REGISTRY,
|
||||
'airpurifier': air_purifier.REGISTRY,
|
||||
'airconditioner': airconditioner.REGISTRY,
|
||||
'air_conditioner': airconditioner.REGISTRY,
|
||||
'cooktop': cooktop.REGISTRY,
|
||||
'dehumidifier': dehumidifier.REGISTRY,
|
||||
'dishwasher': dishwasher.REGISTRY,
|
||||
@@ -28,7 +28,6 @@ _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
|
||||
'induction_cooktop': induction_cooktop.REGISTRY,
|
||||
'microwave': microwave.REGISTRY,
|
||||
'oven': oven.REGISTRY,
|
||||
'hood': range_hood.REGISTRY,
|
||||
'range': _range.REGISTRY,
|
||||
'range_hood': range_hood.REGISTRY,
|
||||
'refrigerator': refrigerator.REGISTRY,
|
||||
@@ -38,48 +37,6 @@ _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
|
||||
}
|
||||
|
||||
|
||||
def _type_key(one_ui_version: str) -> str:
|
||||
"""Convert oneUiVersion string to registry key.
|
||||
|
||||
Args:
|
||||
one_ui_version: String like '7.0 Dishwasher' or 'Oven'.
|
||||
|
||||
Returns:
|
||||
Lowercase key with version prefix stripped and spaces/hyphens converted to underscores.
|
||||
|
||||
Examples:
|
||||
'7.0 Dishwasher' -> 'dishwasher'
|
||||
'7.0 French Door Refrigerator' -> 'french_door_refrigerator'
|
||||
'Oven' -> 'oven'
|
||||
"""
|
||||
if ' ' in one_ui_version:
|
||||
# Strip version prefix: everything before and including the first space
|
||||
suffix = one_ui_version.split(' ', 1)[-1]
|
||||
else:
|
||||
suffix = one_ui_version
|
||||
|
||||
return suffix.lower().replace(' ', '_').replace('-', '_')
|
||||
|
||||
|
||||
def for_device(one_ui_version: str) -> Optional[DeviceRegistry]:
|
||||
"""Return the DeviceRegistry for the given oneUiVersion string, or None if unknown.
|
||||
|
||||
Args:
|
||||
one_ui_version: Device's oneUiVersion string (e.g., '7.0 Dishwasher').
|
||||
|
||||
Returns:
|
||||
DeviceRegistry if a matching registry exists, None otherwise.
|
||||
"""
|
||||
key = _type_key(one_ui_version)
|
||||
if key in _REGISTRY_BY_KEY:
|
||||
return _REGISTRY_BY_KEY[key]
|
||||
# Suffix fallback: e.g. "french_door_refrigerator" ends with "_refrigerator"
|
||||
for rkey, reg in _REGISTRY_BY_KEY.items():
|
||||
if key.endswith(f'_{rkey}'):
|
||||
return reg
|
||||
return None
|
||||
|
||||
|
||||
# Consumer-model prefix (first two letters of the '_'-delimited token in
|
||||
# `description` right before any '/board-info' suffix) -> registry key.
|
||||
# NOT derived from `modelNum` -- washer and dryer share the same 'DA_WM_'
|
||||
@@ -214,9 +171,10 @@ def _consumer_model_key(description: str) -> Optional[str]:
|
||||
|
||||
|
||||
def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegistry]:
|
||||
"""Fallback device-type detection for hardware that never reports
|
||||
oneUiVersion (confirmed for washers -- their /otninformation/vs/0 has
|
||||
no swVersionInfo key at all).
|
||||
"""Device-type detection from /information/vs/0's model strings.
|
||||
|
||||
The primary path: the board named in `modelNum` determines the resource
|
||||
surface, which is what a registry describes.
|
||||
|
||||
Three passes, narrowest evidence first:
|
||||
|
||||
@@ -252,11 +210,13 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi
|
||||
def for_device_by_resources(resources: dict[str, dict]) -> Optional[DeviceRegistry]:
|
||||
"""Detect a device family from a distinctive local-resource signature.
|
||||
|
||||
Some newer cooktops omit both ``oneUiVersion`` and
|
||||
``/information/vs/0``. Their mode resource still identifies them: it
|
||||
contains a DeviceType option and multiple per-burner OperationState
|
||||
options. Require both shapes so an oven's unrelated ``/mode/vs/0`` is
|
||||
not misclassified.
|
||||
For boards that ship no ``/information/vs/0`` at all, leaving
|
||||
`for_device_by_model` nothing to read. Some newer cooktops are the
|
||||
original case: their mode resource still identifies them, carrying a
|
||||
DeviceType option and multiple per-burner OperationState options.
|
||||
|
||||
Require two independent shapes for every signature here, never one, so
|
||||
an unrelated family's ``/mode/vs/0`` isn't misclassified.
|
||||
"""
|
||||
mode = resources.get('/mode/vs/0', {})
|
||||
options = mode.get('x.com.samsung.da.options') or ()
|
||||
@@ -292,3 +252,28 @@ def for_device_by_resources(resources: dict[str, dict]) -> Optional[DeviceRegist
|
||||
return _REGISTRY_BY_KEY['range']
|
||||
return _REGISTRY_BY_KEY['oven']
|
||||
return None
|
||||
|
||||
|
||||
def resolve(resources: dict[str, dict]) -> Optional[DeviceRegistry]:
|
||||
"""Device type for a parsed /device/0 dump, or None if unrecognized.
|
||||
|
||||
The single entry point for detection -- the coordinator, the config
|
||||
flow's probe and the golden-regression harness all call this, so the
|
||||
order can't drift between what ships and what the tests assert.
|
||||
|
||||
Model strings first (`for_device_by_model`), then a distinctive resource
|
||||
signature (`for_device_by_resources`) for boards that report no
|
||||
/information/vs/0 at all.
|
||||
|
||||
`/otninformation/vs/0`'s oneUiVersion is deliberately not consulted. It
|
||||
reads like the obvious signal -- the device naming its own type, e.g.
|
||||
'7.0 Dishwasher' -- but only a minority of hardware populates it, every
|
||||
device that does is already typed by its modelNum board token, and no
|
||||
device-support issue has ever been fixed by adding a mapping for it. It
|
||||
is still reported in diagnostics as a firmware-generation marker.
|
||||
"""
|
||||
info = resources.get('/information/vs/0', {})
|
||||
return for_device_by_model(
|
||||
info.get('x.com.samsung.da.modelNum', ''),
|
||||
info.get('x.com.samsung.da.description', ''),
|
||||
) or for_device_by_resources(resources)
|
||||
|
||||
@@ -4,16 +4,16 @@ Spans three board generations sharing this one registry (see
|
||||
capabilities/air_purifier.py's module docstring for the per-href
|
||||
match_fn discriminators that keep them from colliding):
|
||||
|
||||
- ARTIK051_TVTL-class (issue #56). Reports no oneUiVersion; resolved via
|
||||
for_device_by_model's '_TVTL_' modelNum token (see registry.py).
|
||||
- TP1X_DA-AC-AIR-class (issue #130). Self-reports oneUiVersion "7.0 Air
|
||||
purifier", resolved via for_device(). Adds real fan-mode control plus
|
||||
- ARTIK051_TVTL-class (issue #56). Resolved via the 'TVTL' modelNum board
|
||||
token (see by_type/__init__.py).
|
||||
- TP1X_DA-AC-AIR-class (issue #130). Resolved via the 'AIR' board token.
|
||||
Adds real fan-mode control plus
|
||||
display/HEPA-filter/pet-filter/sound resources the older family never
|
||||
reported; reuses airconditioner.DISPLAY_LIGHT and airconditioner.MUTE_ONCE
|
||||
for /light/vs/0 and /option/muteonce/vs/0, which are identical shapes on
|
||||
the shared DA-AC- board family.
|
||||
- A-VTWW-TP2-21-COMMON-class (issue #151). Reports no oneUiVersion and no
|
||||
existing modelNum token; falls back to unknown until routed here. Its fan
|
||||
- A-VTWW-TP2-21-COMMON-class (issue #151). Resolved via the 'VTWW' board
|
||||
token, added for it. Its fan
|
||||
is WIND_STRENGTH_FAN on /wind/strength/vs/0 rather than FAN on
|
||||
/mode/vs/0 -- see that capability's comment.
|
||||
|
||||
|
||||
@@ -4,9 +4,8 @@ Named 'gas_cooktop' (not 'cooktop') so its diagnostics/device-info label
|
||||
doesn't collide with the unrelated induction_cooktop family (issue #86,
|
||||
by_type/induction_cooktop.py) -- two different OCF surfaces that happen to
|
||||
share the English word "cooktop". The `_REGISTRY_BY_KEY['cooktop']` lookup
|
||||
key is unchanged: it's relied on by real devices reporting
|
||||
oneUiVersion "Cooktop" (for_device), the legacy ARTIK051 modelNum rule
|
||||
(for_device_by_model), and the resource-signature fallback
|
||||
key is unchanged: it's relied on by the legacy ARTIK051 'CT' modelNum token
|
||||
(for_device_by_model) and the resource-signature fallback
|
||||
(for_device_by_resources) alike.
|
||||
"""
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Capabilities specific to the AirDresser family (Samsung DA_DF-class,
|
||||
issues #162/#157).
|
||||
|
||||
This board family reports no oneUiVersion and no /information/vs/0 token
|
||||
any existing family routes on, so it gets its own device type -- but most
|
||||
This board family carried no /information/vs/0 token any existing family
|
||||
routed on, so it gets its own device type (the 'DF' board token) -- but most
|
||||
of the resources it exposes are already handled by the shared laundry
|
||||
machinery:
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
front-load washers).
|
||||
|
||||
Resources verified against two live WW90DG6U25LEU4 dumps (Table_02 course
|
||||
family). Washers never report `oneUiVersion` -- see
|
||||
`registry/by_type/__init__.py`'s `for_device_by_model()` for the fallback
|
||||
detection this device type requires.
|
||||
family). Washers share the `DA_WM_` laundry board with dryers, so their
|
||||
`modelNum` can't tell the two apart -- see `registry/by_type/__init__.py`'s
|
||||
`_CONSUMER_PREFIX_TO_KEY` for the `description`-based detection this device
|
||||
type requires.
|
||||
|
||||
The shared laundry surface -- power/kids-lock/remote-control OCF+vendor
|
||||
fallback pairs, buzzer, energy meter, job-beginning-status, and the
|
||||
|
||||
@@ -1068,11 +1068,7 @@
|
||||
},
|
||||
"confirm_unknown_type": {
|
||||
"title": "Appliance type not recognized",
|
||||
"description": "This appliance reported oneUiVersion \"{one_ui_version}\", which isn't a recognized type. It'll still be added, but only with common capabilities (power, alarms, etc. where present) rather than the full set for its family. You can help add full support afterward by downloading diagnostics for this device (Settings > Devices & Services > this device > the menu > Download diagnostics) and filing them in a new issue. Submit to add it anyway."
|
||||
},
|
||||
"confirm_unknown_type_no_version": {
|
||||
"title": "Appliance type not recognized",
|
||||
"description": "This appliance did not report a oneUiVersion and its type couldn't be recognized. It'll still be added, but only with common capabilities (power, alarms, etc. where present) rather than the full set for its family. You can help add full support afterward by downloading diagnostics for this device (Settings > Devices & Services > this device > the menu > Download diagnostics) and filing them in a new issue. Submit to add it anyway."
|
||||
"description": "This appliance's type couldn't be recognized. It'll still be added, but only with common capabilities (power, alarms, etc. where present) rather than the full set for its family. You can help add full support afterward by downloading diagnostics for this device (Settings > Devices & Services > this device > the menu > Download diagnostics) and filing them in a new issue. Submit to add it anyway."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -1068,11 +1068,7 @@
|
||||
},
|
||||
"confirm_unknown_type": {
|
||||
"title": "Apparaattype niet herkend",
|
||||
"description": "Dit apparaat meldt oneUiVersion \"{one_ui_version}\", wat niet als apparaattype wordt herkend. Het apparaat wordt toch toegevoegd, maar alleen met algemene mogelijkheden (zoals voeding en alarmen, voor zover aanwezig), in plaats van alle mogelijkheden voor deze apparaatfamilie. Je kunt daarna helpen volledige ondersteuning toe te voegen door diagnostische gegevens voor dit apparaat te downloaden (Instellingen > Apparaten & diensten > dit apparaat > het menu > Diagnostische gegevens downloaden) en deze bij een nieuw issue te voegen. Kies Verzenden om het apparaat toch toe te voegen."
|
||||
},
|
||||
"confirm_unknown_type_no_version": {
|
||||
"title": "Apparaattype niet herkend",
|
||||
"description": "Dit apparaat meldt geen oneUiVersion en het apparaattype kon niet worden herkend. Het apparaat wordt toch toegevoegd, maar alleen met algemene mogelijkheden (zoals voeding en alarmen, voor zover aanwezig), in plaats van alle mogelijkheden voor deze apparaatfamilie. Je kunt daarna helpen volledige ondersteuning toe te voegen door diagnostische gegevens voor dit apparaat te downloaden (Instellingen > Apparaten & diensten > dit apparaat > het menu > Diagnostische gegevens downloaden) en deze bij een nieuw issue te voegen. Kies Verzenden om het apparaat toch toe te voegen."
|
||||
"description": "Het apparaattype van dit apparaat kon niet worden herkend. Het apparaat wordt toch toegevoegd, maar alleen met algemene mogelijkheden (zoals voeding en alarmen, voor zover aanwezig), in plaats van alle mogelijkheden voor deze apparaatfamilie. Je kunt daarna helpen volledige ondersteuning toe te voegen door diagnostische gegevens voor dit apparaat te downloaden (Instellingen > Apparaten & diensten > dit apparaat > het menu > Diagnostische gegevens downloaden) en deze bij een nieuw issue te voegen. Kies Verzenden om het apparaat toch toe te voegen."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
Reference in New Issue
Block a user