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..9d878b6e 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,38 @@ 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( + 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..c0139c1d 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): + """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] + 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"}