Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/device-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Comment on lines +149 to +150

```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:
Expand Down
41 changes: 41 additions & 0 deletions pyoverkiz/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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.
"""
Comment on lines +529 to +534
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
Expand Down
91 changes: 91 additions & 0 deletions tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
StateDefinition,
StateDefinitions,
States,
SupportedAlias,
ZoneCreatedEvent,
ZoneDeletedEvent,
ZoneUpdatedEvent,
Expand Down Expand Up @@ -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"}