From 9b71d6eb2be2e1f48ba60be896013ad38c1419b5 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sun, 23 Aug 2026 20:30:57 +0000 Subject: [PATCH 1/4] Add typed SupportedAlias parsing and most-featured alias resolution core:SupportedAliases can list several ids for the same type, each advertising a different subset of features. Consumers had to walk the raw attribute themselves, which in practice meant treating every entry as a distinct control instead of the single one the official app resolves to. Closes #2224 --- docs/device-control.md | 21 ++++++++++ pyoverkiz/models.py | 42 +++++++++++++++++++ tests/test_models.py | 91 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+) diff --git a/docs/device-control.md b/docs/device-control.md index 00cc9729..3c689b4c 100644 --- a/docs/device-control.md +++ b/docs/device-control.md @@ -141,6 +141,27 @@ if cmd_def: print(f"Number of parameters: {cmd_def.nparams}") ``` +#### Resolve supported aliases + +Devices that support `goToAlias` advertise their alias slots through the +`core:SupportedAliases` attribute. A device can list several ids for the same +type (e.g. six `favorite1` slots), each covering a different subset of features. +The official app shows a single control per type and targets the most featured +id, which `get_most_featured_aliases()` reproduces: + +```python +devices = await client.get_devices() +device = devices[0] + +# All alias slots exactly as reported by the API +for alias in device.get_supported_aliases(): + print(f"{alias.type} (id {alias.id}): {alias.features}") + +# One alias per type, ready to use as a goToAlias parameter +for alias_type, alias in device.get_most_featured_aliases().items(): + print(f"{alias_type} -> goToAlias {alias.id}") +``` + #### Access device identifier Device URLs are automatically parsed into structured identifier components for easier access: diff --git a/pyoverkiz/models.py b/pyoverkiz/models.py index 2308e450..3fa65752 100644 --- a/pyoverkiz/models.py +++ b/pyoverkiz/models.py @@ -442,6 +442,15 @@ def from_device_url(cls, device_url: str) -> DeviceIdentifier: ) +@define(kw_only=True) +class SupportedAlias: + """An alias slot advertised by a device through core:SupportedAliases.""" + + id: str + type: str + features: list[str] = field(factory=list) + + @define(kw_only=True) class Device: """Representation of a device in the setup including parsed fields and states.""" @@ -500,6 +509,39 @@ def get_command_definition( """Return the CommandDefinition for a command, or None if unavailable.""" return self.definition.commands.get(str(command)) + def get_supported_aliases(self) -> list[SupportedAlias]: + """Return the alias slots from core:SupportedAliases, empty when absent.""" + raw_aliases = self.attributes.get_value(OverkizAttribute.CORE_SUPPORTED_ALIASES) + + if not isinstance(raw_aliases, list): + return [] + + return [ + SupportedAlias( + # The API reports ids as either a string or an integer + id=str(alias["id"]), + type=alias["type"], + features=list(alias.get("features", [])), + ) + for alias in raw_aliases + ] + + def get_most_featured_aliases(self) -> dict[str, SupportedAlias]: + """Return the alias to use per type, mirroring how the Somfy app resolves them. + + A device can advertise several ids for the same type, each covering a + different subset of features. The app shows a single control per type and + targets the most featured id, preferring the earliest one on a tie. + """ + most_featured: dict[str, SupportedAlias] = {} + + for alias in self.get_supported_aliases(): + current = most_featured.get(alias.type) + if current is None or len(alias.features) > len(current.features): + most_featured[alias.type] = alias + + return most_featured + # --------------------------------------------------------------------------- # Execution & action groups diff --git a/tests/test_models.py b/tests/test_models.py index 70ff2041..794e3266 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -51,6 +51,7 @@ StateDefinition, StateDefinitions, States, + SupportedAlias, ZoneCreatedEvent, ZoneDeletedEvent, ZoneUpdatedEvent, @@ -1653,3 +1654,93 @@ def test_get_command_definition_empty_definition(): type=ProductType.ACTUATOR, ) assert device.get_command_definition("open") is None + + +class TestSupportedAliases: + """Tests for parsing and resolving the core:SupportedAliases attribute.""" + + @staticmethod + def _device_with_aliases(value: list[dict] | None) -> Device: + """Create a Device exposing core:SupportedAliases with the given raw value.""" + attributes = ( + [{"name": "core:SupportedAliases", "type": 10, "value": value}] + if value is not None + else [] + ) + return _make_device({**RAW_DEVICES, "attributes": attributes}) + + def test_returns_empty_list_when_attribute_is_absent(self): + """Devices without the attribute report no aliases instead of raising.""" + assert self._device_with_aliases(None).get_supported_aliases() == [] + assert self._device_with_aliases(None).get_most_featured_aliases() == {} + + def test_parses_raw_entries_into_typed_aliases(self): + """Each raw entry becomes a SupportedAlias with id, type and features.""" + device = self._device_with_aliases( + [{"id": "55299", "type": "ventilation", "features": ["openClose"]}] + ) + + assert device.get_supported_aliases() == [ + SupportedAlias(id="55299", type="ventilation", features=["openClose"]) + ] + + def test_normalizes_integer_ids_to_string(self): + """The API is inconsistent about id typing, so ids are always strings.""" + device = self._device_with_aliases([{"id": 1, "type": "favorite1"}]) + + alias = device.get_supported_aliases()[0] + assert alias.id == "1" + assert alias.features == [] + + def test_resolves_most_featured_alias_per_type(self): + """A duplicated type collapses to the entry advertising the most features.""" + device = self._device_with_aliases( + [ + {"id": "1", "type": "favorite1", "features": ["openClosePosition"]}, + { + "id": "3", + "type": "favorite1", + "features": ["openClosePosition", "tiltPosition"], + }, + {"id": "2", "type": "favorite1", "features": ["tiltPosition"]}, + ] + ) + + assert device.get_most_featured_aliases() == { + "favorite1": SupportedAlias( + id="3", + type="favorite1", + features=["openClosePosition", "tiltPosition"], + ) + } + + def test_breaks_ties_on_array_order(self): + """The Somfy app picks the first of equally featured entries, not the lowest id.""" + device = self._device_with_aliases( + [ + {"id": "6", "type": "favorite1", "features": ["tilt", "openClose"]}, + {"id": "4", "type": "favorite1", "features": ["tilt", "openClose"]}, + {"id": "1", "type": "favorite1", "features": ["openClose"]}, + ] + ) + + assert device.get_most_featured_aliases()["favorite1"].id == "6" + + def test_keeps_one_alias_for_every_type(self): + """Distinct types each resolve independently.""" + device = self._device_with_aliases( + [ + {"id": "1", "type": "favorite1", "features": ["openClose"]}, + {"id": "55305", "type": "partial", "features": ["openClose"]}, + { + "id": "2", + "type": "favorite1", + "features": ["openClose", "tiltPosition"], + }, + ] + ) + + assert { + alias_type: alias.id + for alias_type, alias in device.get_most_featured_aliases().items() + } == {"favorite1": "2", "partial": "55305"} From 30016f67adb205c065fedce9687bfa9083f52177 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sun, 23 Aug 2026 20:59:19 +0000 Subject: [PATCH 2/4] Drop the unverified claim about alias id typing Every observed core:SupportedAliases payload reports ids as strings. The normalization stays because goToAlias takes a string parameter and attrs does not enforce the declared type. --- pyoverkiz/models.py | 1 - tests/test_models.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/pyoverkiz/models.py b/pyoverkiz/models.py index 3fa65752..9d878b6e 100644 --- a/pyoverkiz/models.py +++ b/pyoverkiz/models.py @@ -518,7 +518,6 @@ def get_supported_aliases(self) -> list[SupportedAlias]: return [ SupportedAlias( - # The API reports ids as either a string or an integer id=str(alias["id"]), type=alias["type"], features=list(alias.get("features", [])), diff --git a/tests/test_models.py b/tests/test_models.py index 794e3266..c0139c1d 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1685,7 +1685,7 @@ def test_parses_raw_entries_into_typed_aliases(self): ] def test_normalizes_integer_ids_to_string(self): - """The API is inconsistent about id typing, so ids are always strings.""" + """Ids are exposed as strings, since goToAlias takes a string parameter.""" device = self._device_with_aliases([{"id": 1, "type": "favorite1"}]) alias = device.get_supported_aliases()[0] From e983e69e7fead83ac0aeb8c9cb4394724049ed3e Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sat, 5 Sep 2026 23:30:22 +0000 Subject: [PATCH 3/4] docs: Clarify supported alias resolver docstring --- pyoverkiz/models.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/pyoverkiz/models.py b/pyoverkiz/models.py index 9d878b6e..7f9cfceb 100644 --- a/pyoverkiz/models.py +++ b/pyoverkiz/models.py @@ -526,12 +526,7 @@ def get_supported_aliases(self) -> list[SupportedAlias]: ] def get_most_featured_aliases(self) -> dict[str, SupportedAlias]: - """Return the alias to use per type, mirroring how the Somfy app resolves them. - - A device can advertise several ids for the same type, each covering a - different subset of features. The app shows a single control per type and - targets the most featured id, preferring the earliest one on a tie. - """ + """Return the alias with the most features for each type.""" most_featured: dict[str, SupportedAlias] = {} for alias in self.get_supported_aliases(): From f2c6780609c88d09fa96bca96d57794beea690d7 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sat, 5 Sep 2026 23:34:56 +0000 Subject: [PATCH 4/4] docs: Clarify alias resolution and test input types --- docs/device-control.md | 3 +-- tests/test_models.py | 4 +++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/device-control.md b/docs/device-control.md index 3c689b4c..c1aba818 100644 --- a/docs/device-control.md +++ b/docs/device-control.md @@ -146,8 +146,7 @@ if cmd_def: Devices that support `goToAlias` advertise their alias slots through the `core:SupportedAliases` attribute. A device can list several ids for the same type (e.g. six `favorite1` slots), each covering a different subset of features. -The official app shows a single control per type and targets the most featured -id, which `get_most_featured_aliases()` reproduces: +`get_most_featured_aliases()` returns the alias with the most features for each type: ```python devices = await client.get_devices() diff --git a/tests/test_models.py b/tests/test_models.py index c0139c1d..14a5123d 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1660,7 +1660,9 @@ class TestSupportedAliases: """Tests for parsing and resolving the core:SupportedAliases attribute.""" @staticmethod - def _device_with_aliases(value: list[dict] | None) -> Device: + def _device_with_aliases( + value: list[dict[str, str | int | list[str]]] | None, + ) -> Device: """Create a Device exposing core:SupportedAliases with the given raw value.""" attributes = ( [{"name": "core:SupportedAliases", "type": 10, "value": value}]