Migrate somfy_mylink to pysomfymylink and adopt integration (#176848)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
sslivins
2026-08-12 16:40:52 +02:00
committed by GitHub
co-authored by Copilot Joost Lekkerkerker
parent c821ba3447
commit f48e8ca252
7 changed files with 92 additions and 118 deletions
Generated
+2
View File
@@ -1726,6 +1726,8 @@ CLAUDE.md @home-assistant/core
/tests/components/solax/ @squishykid @Darsstar
/homeassistant/components/soma/ @ratsept
/tests/components/soma/ @ratsept
/homeassistant/components/somfy_mylink/ @sslivins
/tests/components/somfy_mylink/ @sslivins
/homeassistant/components/sonarr/ @ctalkington
/tests/components/sonarr/ @ctalkington
/homeassistant/components/songpal/ @rytilahti @shenxn
@@ -1,10 +1,13 @@
"""Component for the Somfy MyLink device supporting the Synergy API."""
"""The Somfy MyLink integration."""
from dataclasses import dataclass
import logging
from typing import Any
from somfy_mylink_synergy import SomfyMyLinkSynergy
from pysomfymylink import (
Shade,
SomfyMyLink,
SomfyMyLinkApiError,
SomfyMyLinkConnectionError,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT
@@ -13,8 +16,6 @@ from homeassistant.exceptions import ConfigEntryNotReady
from .const import CONF_SYSTEM_ID, PLATFORMS
_LOGGER = logging.getLogger(__name__)
type SomfyMyLinkConfigEntry = ConfigEntry[SomfyMyLinkRuntimeData]
@@ -22,39 +23,28 @@ type SomfyMyLinkConfigEntry = ConfigEntry[SomfyMyLinkRuntimeData]
class SomfyMyLinkRuntimeData:
"""Runtime data for Somfy MyLink."""
somfy_mylink: SomfyMyLinkSynergy
mylink_status: dict[str, Any]
somfy_mylink: SomfyMyLink
shades: list[Shade]
async def async_setup_entry(hass: HomeAssistant, entry: SomfyMyLinkConfigEntry) -> bool:
"""Set up Somfy MyLink from a config entry."""
config = entry.data
somfy_mylink = SomfyMyLinkSynergy(
config[CONF_SYSTEM_ID], config[CONF_HOST], config[CONF_PORT]
somfy_mylink = SomfyMyLink(
entry.data[CONF_HOST],
entry.data[CONF_SYSTEM_ID],
port=entry.data[CONF_PORT],
)
try:
mylink_status = await somfy_mylink.status_info()
except TimeoutError as ex:
shades = await somfy_mylink.status_info()
except (SomfyMyLinkConnectionError, SomfyMyLinkApiError) as ex:
raise ConfigEntryNotReady(
"Unable to connect to the Somfy MyLink device, please check your settings"
"Unable to reach the Somfy MyLink device, please check your settings"
) from ex
if not mylink_status or "error" in mylink_status:
_LOGGER.error(
"Somfy Mylink failed to setup because of an error: %s",
mylink_status.get("error", {}).get(
"message", "Empty response from mylink device"
),
)
return False
if "result" not in mylink_status:
raise ConfigEntryNotReady("The Somfy MyLink device returned an empty result")
entry.runtime_data = SomfyMyLinkRuntimeData(
somfy_mylink=somfy_mylink,
mylink_status=mylink_status,
shades=shades,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -4,7 +4,7 @@ from copy import deepcopy
import logging
from typing import Any, override
from somfy_mylink_synergy import SomfyMyLinkSynergy
from pysomfymylink import SomfyMyLink, SomfyMyLinkApiError, SomfyMyLinkConnectionError
import voluptuous as vol
from homeassistant.config_entries import (
@@ -33,23 +33,21 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
async def validate_input(hass: HomeAssistant, data):
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]:
"""Validate the user input allows us to connect.
Data has the keys from schema with values provided by the user.
"""
somfy_mylink = SomfyMyLinkSynergy(
data[CONF_SYSTEM_ID], data[CONF_HOST], data[CONF_PORT]
somfy_mylink = SomfyMyLink(
data[CONF_HOST], data[CONF_SYSTEM_ID], port=data[CONF_PORT]
)
try:
status_info = await somfy_mylink.status_info()
except TimeoutError as ex:
await somfy_mylink.status_info()
except SomfyMyLinkConnectionError as ex:
raise CannotConnect from ex
if not status_info or "error" in status_info:
_LOGGER.debug("Auth error: %s", status_info)
raise InvalidAuth
except SomfyMyLinkApiError as ex:
raise InvalidAuth from ex
return {"title": f"MyLink {data[CONF_HOST]}"}
@@ -136,18 +134,13 @@ class OptionsFlowHandler(OptionsFlowWithReload):
self._target_id: str | None = None
@callback
def _async_callback_targets(self):
"""Return the list of targets."""
return self.config_entry.runtime_data.mylink_status["result"]
@callback
def _async_get_target_name(self, target_id) -> str:
def _async_get_target_name(self, target_id: str) -> str:
"""Find the name of a target in the api data."""
mylink_targets = self._async_callback_targets()
for cover in mylink_targets:
if cover["targetID"] == target_id:
return cover["name"]
raise KeyError
names = {
shade.target_id: shade.name
for shade in self.config_entry.runtime_data.shades
}
return names[target_id]
async def async_step_init(
self, user_input: dict[str, Any] | None = None
@@ -164,11 +157,9 @@ class OptionsFlowHandler(OptionsFlowWithReload):
return self.async_create_entry(title="", data=self.options)
cover_dict = {None: None}
mylink_targets = self._async_callback_targets()
if mylink_targets:
for cover in mylink_targets:
cover_dict[cover["targetID"]] = cover["name"]
cover_dict: dict[str | None, str | None] = {None: None}
for shade in self.config_entry.runtime_data.shades:
cover_dict[shade.target_id] = shade.name
data_schema = vol.Schema({vol.Optional(CONF_TARGET_ID): vol.In(cover_dict)})
@@ -188,6 +179,7 @@ class OptionsFlowHandler(OptionsFlowWithReload):
return await self.async_step_init()
self._target_id = target_id
assert target_id is not None
return self.async_show_form(
step_id="target_config",
+27 -26
View File
@@ -3,6 +3,8 @@
import logging
from typing import Any, override
from pysomfymylink import Shade, SomfyMyLink
from homeassistant.components.cover import CoverDeviceClass, CoverEntity, CoverState
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
@@ -14,7 +16,7 @@ from .const import CONF_REVERSED_TARGET_IDS, DOMAIN, MANUFACTURER
_LOGGER = logging.getLogger(__name__)
MYLINK_COVER_TYPE_TO_DEVICE_CLASS = {
MYLINK_COVER_TYPE_TO_DEVICE_CLASS: dict[int | None, CoverDeviceClass] = {
0: CoverDeviceClass.BLIND,
1: CoverDeviceClass.SHUTTER,
}
@@ -26,28 +28,26 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Discover and configure Somfy covers."""
reversed_target_ids = config_entry.options.get(CONF_REVERSED_TARGET_IDS, {})
reversed_target_ids: dict[str, bool] = config_entry.options.get(
CONF_REVERSED_TARGET_IDS, {}
)
mylink_status = config_entry.runtime_data.mylink_status
somfy_mylink = config_entry.runtime_data.somfy_mylink
cover_list = []
for cover in mylink_status["result"]:
cover_config = {
"target_id": cover["targetID"],
"name": cover["name"],
"device_class": MYLINK_COVER_TYPE_TO_DEVICE_CLASS.get(
cover.get("type"), CoverDeviceClass.WINDOW
),
"reverse": reversed_target_ids.get(cover["targetID"], False),
}
cover_list.append(SomfyShade(somfy_mylink, **cover_config))
for shade in config_entry.runtime_data.shades:
cover_list.append(
SomfyShade(
somfy_mylink,
shade,
reverse=reversed_target_ids.get(shade.target_id, False),
)
)
_LOGGER.debug(
"Adding Somfy Cover: %s with targetID %s",
cover_config["name"],
cover_config["target_id"],
shade.name,
shade.target_id,
)
async_add_entities(cover_list)
@@ -63,23 +63,24 @@ class SomfyShade(RestoreEntity, CoverEntity):
def __init__(
self,
somfy_mylink,
target_id,
name="SomfyShade",
reverse=False,
device_class=CoverDeviceClass.WINDOW,
):
somfy_mylink: SomfyMyLink,
shade: Shade,
*,
reverse: bool = False,
) -> None:
"""Initialize the cover."""
self.somfy_mylink = somfy_mylink
self._target_id = target_id
self._attr_unique_id = target_id
self._target_id = shade.target_id
self._attr_unique_id = shade.target_id
self._reverse = reverse
self._attr_is_closed = None
self._attr_device_class = device_class
self._attr_device_class = MYLINK_COVER_TYPE_TO_DEVICE_CLASS.get(
shade.cover_type, CoverDeviceClass.WINDOW
)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._target_id)},
manufacturer=MANUFACTURER,
name=name,
name=shade.name,
)
@override
@@ -1,7 +1,7 @@
{
"domain": "somfy_mylink",
"name": "Somfy MyLink",
"codeowners": [],
"codeowners": ["@sslivins"],
"config_flow": true,
"dhcp": [
{
@@ -12,6 +12,6 @@
"documentation": "https://www.home-assistant.io/integrations/somfy_mylink",
"integration_type": "hub",
"iot_class": "assumed_state",
"loggers": ["somfy_mylink_synergy"],
"requirements": ["somfy-mylink-synergy==1.0.6"]
"loggers": ["pysomfymylink"],
"requirements": ["pysomfymylink==1.0.0"]
}
+3 -3
View File
@@ -2610,6 +2610,9 @@ pysnooz==0.8.6
# homeassistant.components.soma
pysoma==0.0.12
# homeassistant.components.somfy_mylink
pysomfymylink==1.0.0
# homeassistant.components.spc
pyspcwebgw==0.7.0
@@ -3082,9 +3085,6 @@ solarman-opendata==0.0.3
# homeassistant.components.solax
solax==3.2.4
# homeassistant.components.somfy_mylink
somfy-mylink-synergy==1.0.6
# homeassistant.components.sonos
sonos-websocket==0.2.0
@@ -2,6 +2,7 @@
from unittest.mock import patch
from pysomfymylink import Shade, SomfyMyLinkApiError, SomfyMyLinkConnectionError
import pytest
from homeassistant import config_entries
@@ -29,8 +30,8 @@ async def test_form_user(hass: HomeAssistant) -> None:
with (
patch(
"homeassistant.components.somfy_mylink.config_flow.SomfyMyLinkSynergy.status_info",
return_value={"any": "data"},
"homeassistant.components.somfy_mylink.config_flow.SomfyMyLink.status_info",
return_value=[],
),
patch(
"homeassistant.components.somfy_mylink.async_setup_entry",
@@ -73,8 +74,8 @@ async def test_form_user_already_configured(hass: HomeAssistant) -> None:
with (
patch(
"homeassistant.components.somfy_mylink.config_flow.SomfyMyLinkSynergy.status_info",
return_value={"any": "data"},
"homeassistant.components.somfy_mylink.config_flow.SomfyMyLink.status_info",
return_value=[],
),
patch(
"homeassistant.components.somfy_mylink.async_setup_entry",
@@ -102,12 +103,8 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None:
)
with patch(
"homeassistant.components.somfy_mylink.config_flow.SomfyMyLinkSynergy.status_info",
return_value={
"jsonrpc": "2.0",
"error": {"code": -32652, "message": "Invalid auth"},
"id": 818,
},
"homeassistant.components.somfy_mylink.config_flow.SomfyMyLink.status_info",
side_effect=SomfyMyLinkApiError("Invalid auth", code=-32652),
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -129,8 +126,8 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None:
)
with patch(
"homeassistant.components.somfy_mylink.config_flow.SomfyMyLinkSynergy.status_info",
side_effect=TimeoutError,
"homeassistant.components.somfy_mylink.config_flow.SomfyMyLink.status_info",
side_effect=SomfyMyLinkConnectionError,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -152,7 +149,7 @@ async def test_form_unknown_error(hass: HomeAssistant) -> None:
)
with patch(
"homeassistant.components.somfy_mylink.config_flow.SomfyMyLinkSynergy.status_info",
"homeassistant.components.somfy_mylink.config_flow.SomfyMyLink.status_info",
side_effect=ValueError,
):
result2 = await hass.config_entries.flow.async_configure(
@@ -178,16 +175,16 @@ async def test_options_not_loaded(hass: HomeAssistant) -> None:
config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.somfy_mylink.SomfyMyLinkSynergy.status_info",
return_value={"result": []},
"homeassistant.components.somfy_mylink.SomfyMyLink.status_info",
return_value=[],
):
result = await hass.config_entries.options.async_init(config_entry.entry_id)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
@pytest.mark.parametrize("reversed", [True, False])
async def test_options_with_targets(hass: HomeAssistant, reversed) -> None:
@pytest.mark.parametrize("reversed_target", [True, False])
async def test_options_with_targets(hass: HomeAssistant, reversed_target: bool) -> None:
"""Test we can configure reverse for a target."""
config_entry = MockConfigEntry(
@@ -197,16 +194,8 @@ async def test_options_with_targets(hass: HomeAssistant, reversed) -> None:
config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.somfy_mylink.SomfyMyLinkSynergy.status_info",
return_value={
"result": [
{
"targetID": "a",
"name": "Master Window",
"type": 0,
}
]
},
"homeassistant.components.somfy_mylink.SomfyMyLink.status_info",
return_value=[Shade(target_id="a", name="Master Window", cover_type=0)],
):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
@@ -223,7 +212,7 @@ async def test_options_with_targets(hass: HomeAssistant, reversed) -> None:
assert result2["type"] is FlowResultType.FORM
result3 = await hass.config_entries.options.async_configure(
result2["flow_id"],
user_input={"reverse": reversed},
user_input={"reverse": reversed_target},
)
assert result3["type"] is FlowResultType.FORM
@@ -235,7 +224,7 @@ async def test_options_with_targets(hass: HomeAssistant, reversed) -> None:
assert result4["type"] is FlowResultType.CREATE_ENTRY
assert config_entry.options == {
CONF_REVERSED_TARGET_IDS: {"a": reversed},
CONF_REVERSED_TARGET_IDS: {"a": reversed_target},
}
await hass.async_block_till_done()
@@ -252,8 +241,8 @@ async def test_form_user_already_configured_from_dhcp(hass: HomeAssistant) -> No
with (
patch(
"homeassistant.components.somfy_mylink.config_flow.SomfyMyLinkSynergy.status_info",
return_value={"any": "data"},
"homeassistant.components.somfy_mylink.config_flow.SomfyMyLink.status_info",
return_value=[],
),
patch(
"homeassistant.components.somfy_mylink.async_setup_entry",
@@ -313,8 +302,8 @@ async def test_dhcp_discovery(hass: HomeAssistant) -> None:
with (
patch(
"homeassistant.components.somfy_mylink.config_flow.SomfyMyLinkSynergy.status_info",
return_value={"any": "data"},
"homeassistant.components.somfy_mylink.config_flow.SomfyMyLink.status_info",
return_value=[],
),
patch(
"homeassistant.components.somfy_mylink.async_setup_entry",