diff --git a/homeassistant/components/niko_home_control/__init__.py b/homeassistant/components/niko_home_control/__init__.py index bdbb8d6b85f..0bc1b117a70 100644 --- a/homeassistant/components/niko_home_control/__init__.py +++ b/homeassistant/components/niko_home_control/__init__.py @@ -2,35 +2,29 @@ from __future__ import annotations -from datetime import timedelta -import logging - from nclib.errors import NetcatError -from nikohomecontrol import NikoHomeControl +from nhc.controller import NHCController from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.util import Throttle +from homeassistant.helpers import entity_registry as er + +from .const import _LOGGER PLATFORMS: list[Platform] = [Platform.LIGHT] -type NikoHomeControlConfigEntry = ConfigEntry[NikoHomeControlData] - - -_LOGGER = logging.getLogger(__name__) -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1) +type NikoHomeControlConfigEntry = ConfigEntry[NHCController] async def async_setup_entry( hass: HomeAssistant, entry: NikoHomeControlConfigEntry ) -> bool: """Set Niko Home Control from a config entry.""" + controller = NHCController(entry.data[CONF_HOST]) try: - controller = NikoHomeControl({"ip": entry.data[CONF_HOST], "port": 8000}) - niko_data = NikoHomeControlData(hass, controller) - await niko_data.async_update() + await controller.connect() except NetcatError as err: raise ConfigEntryNotReady("cannot connect to controller.") from err except OSError as err: @@ -38,46 +32,45 @@ async def async_setup_entry( "unknown error while connecting to controller." ) from err - entry.runtime_data = niko_data + entry.runtime_data = controller await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True +async def async_migrate_entry( + hass: HomeAssistant, config_entry: NikoHomeControlConfigEntry +) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating configuration from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + + if config_entry.minor_version < 2: + registry = er.async_get(hass) + entries = er.async_entries_for_config_entry(registry, config_entry.entry_id) + + for entry in entries: + if entry.unique_id.startswith("light-"): + action_id = entry.unique_id.split("-")[-1] + new_unique_id = f"{config_entry.entry_id}-{action_id}" + registry.async_update_entity( + entry.entity_id, new_unique_id=new_unique_id + ) + + hass.config_entries.async_update_entry(config_entry, minor_version=2) + + _LOGGER.debug( + "Migration to configuration version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + return True + + async def async_unload_entry( hass: HomeAssistant, entry: NikoHomeControlConfigEntry ) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -class NikoHomeControlData: - """The class for handling data retrieval.""" - - def __init__(self, hass, nhc): - """Set up Niko Home Control Data object.""" - self.nhc = nhc - self.hass = hass - self.available = True - self.data = {} - self._system_info = None - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - async def async_update(self): - """Get the latest data from the NikoHomeControl API.""" - _LOGGER.debug("Fetching async state in bulk") - try: - self.data = await self.hass.async_add_executor_job( - self.nhc.list_actions_raw - ) - self.available = True - except OSError as ex: - _LOGGER.error("Unable to retrieve data from Niko, %s", str(ex)) - self.available = False - - def get_state(self, aid): - """Find and filter state based on action id.""" - for state in self.data: - if state["id"] == aid: - return state["value1"] - _LOGGER.error("Failed to retrieve state off unknown light") - return None diff --git a/homeassistant/components/niko_home_control/config_flow.py b/homeassistant/components/niko_home_control/config_flow.py index 9174a932534..f37e5e9248a 100644 --- a/homeassistant/components/niko_home_control/config_flow.py +++ b/homeassistant/components/niko_home_control/config_flow.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any -from nikohomecontrol import NikoHomeControlConnection +from nhc.controller import NHCController import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -19,10 +19,12 @@ DATA_SCHEMA = vol.Schema( ) -def test_connection(host: str) -> str | None: +async def test_connection(host: str) -> str | None: """Test if we can connect to the Niko Home Control controller.""" + + controller = NHCController(host, 8000) try: - NikoHomeControlConnection(host, 8000) + await controller.connect() except Exception: # noqa: BLE001 return "cannot_connect" return None @@ -31,7 +33,7 @@ def test_connection(host: str) -> str | None: class NikoHomeControlConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Niko Home Control.""" - VERSION = 1 + MINOR_VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -41,7 +43,7 @@ class NikoHomeControlConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) - error = test_connection(user_input[CONF_HOST]) + error = await test_connection(user_input[CONF_HOST]) if not error: return self.async_create_entry( title="Niko Home Control", @@ -56,7 +58,7 @@ class NikoHomeControlConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult: """Import a config entry.""" self._async_abort_entries_match({CONF_HOST: import_info[CONF_HOST]}) - error = test_connection(import_info[CONF_HOST]) + error = await test_connection(import_info[CONF_HOST]) if not error: return self.async_create_entry( diff --git a/homeassistant/components/niko_home_control/const.py b/homeassistant/components/niko_home_control/const.py index 202b031b9a2..82b7ce7ed38 100644 --- a/homeassistant/components/niko_home_control/const.py +++ b/homeassistant/components/niko_home_control/const.py @@ -1,3 +1,6 @@ """Constants for niko_home_control integration.""" +import logging + DOMAIN = "niko_home_control" +_LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/niko_home_control/light.py b/homeassistant/components/niko_home_control/light.py index f2bf302eab7..29b952fcb77 100644 --- a/homeassistant/components/niko_home_control/light.py +++ b/homeassistant/components/niko_home_control/light.py @@ -2,10 +2,9 @@ from __future__ import annotations -from datetime import timedelta -import logging from typing import Any +from nhc.light import NHCLight import voluptuous as vol from homeassistant.components.light import ( @@ -24,12 +23,9 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import NikoHomeControlConfigEntry +from . import NHCController, NikoHomeControlConfigEntry from .const import DOMAIN -_LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(seconds=30) - # delete after 2025.7.0 PLATFORM_SCHEMA = LIGHT_PLATFORM_SCHEMA.extend({vol.Required(CONF_HOST): cv.string}) @@ -87,43 +83,52 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Niko Home Control light entry.""" - niko_data = entry.runtime_data + controller = entry.runtime_data async_add_entities( - NikoHomeControlLight(light, niko_data) for light in niko_data.nhc.list_actions() + NikoHomeControlLight(light, controller, entry.entry_id) + for light in controller.lights ) class NikoHomeControlLight(LightEntity): - """Representation of an Niko Light.""" + """Representation of a Niko Light.""" - def __init__(self, light, data): + def __init__( + self, action: NHCLight, controller: NHCController, unique_id: str + ) -> None: """Set up the Niko Home Control light platform.""" - self._data = data - self._light = light - self._attr_unique_id = f"light-{light.id}" - self._attr_name = light.name - self._attr_is_on = light.is_on + self._controller = controller + self._action = action + self._attr_unique_id = f"{unique_id}-{action.id}" + self._attr_name = action.name + self._attr_is_on = action.is_on self._attr_color_mode = ColorMode.ONOFF self._attr_supported_color_modes = {ColorMode.ONOFF} - if light._state["type"] == 2: # noqa: SLF001 + self._attr_should_poll = False + if action.is_dimmable: self._attr_color_mode = ColorMode.BRIGHTNESS self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} + async def async_added_to_hass(self) -> None: + """Subscribe to updates.""" + self.async_on_remove( + self._controller.register_callback( + self._action.id, self.async_update_callback + ) + ) + def turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" - _LOGGER.debug("Turn on: %s", self.name) - self._light.turn_on(kwargs.get(ATTR_BRIGHTNESS, 255) / 2.55) + self._action.turn_on(kwargs.get(ATTR_BRIGHTNESS, 255) / 2.55) def turn_off(self, **kwargs: Any) -> None: """Instruct the light to turn off.""" - _LOGGER.debug("Turn off: %s", self.name) - self._light.turn_off() + self._action.turn_off() - async def async_update(self) -> None: - """Get the latest data from NikoHomeControl API.""" - await self._data.async_update() - state = self._data.get_state(self._light.id) - self._attr_is_on = state != 0 + async def async_update_callback(self, state: int) -> None: + """Handle updates from the controller.""" + self._attr_is_on = state > 0 if brightness_supported(self.supported_color_modes): - self._attr_brightness = state * 2.55 + self._attr_brightness = round(state * 2.55) + self.async_write_ha_state() diff --git a/homeassistant/components/niko_home_control/manifest.json b/homeassistant/components/niko_home_control/manifest.json index 194596d534f..d252a11b38e 100644 --- a/homeassistant/components/niko_home_control/manifest.json +++ b/homeassistant/components/niko_home_control/manifest.json @@ -4,7 +4,7 @@ "codeowners": ["@VandeurenGlenn"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/niko_home_control", - "iot_class": "local_polling", + "iot_class": "local_push", "loggers": ["nikohomecontrol"], - "requirements": ["niko-home-control==0.2.1"] + "requirements": ["nhc==0.3.2"] } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index f037b8d7ce6..ad4af2f024c 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4160,7 +4160,7 @@ "name": "Niko Home Control", "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling" + "iot_class": "local_push" }, "nilu": { "name": "Norwegian Institute for Air Research (NILU)", diff --git a/requirements_all.txt b/requirements_all.txt index b1aa085ee52..4cf22eaf153 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1463,15 +1463,15 @@ nextcord==2.6.0 # homeassistant.components.nextdns nextdns==4.0.0 +# homeassistant.components.niko_home_control +nhc==0.3.2 + # homeassistant.components.nibe_heatpump nibe==2.14.0 # homeassistant.components.nice_go nice-go==1.0.0 -# homeassistant.components.niko_home_control -niko-home-control==0.2.1 - # homeassistant.components.nilu niluclient==0.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3fdd84009fc..747594117e6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1226,15 +1226,15 @@ nextcord==2.6.0 # homeassistant.components.nextdns nextdns==4.0.0 +# homeassistant.components.niko_home_control +nhc==0.3.2 + # homeassistant.components.nibe_heatpump nibe==2.14.0 # homeassistant.components.nice_go nice-go==1.0.0 -# homeassistant.components.niko_home_control -niko-home-control==0.2.1 - # homeassistant.components.nfandroidtv notifications-android-tv==0.1.5 diff --git a/tests/components/niko_home_control/conftest.py b/tests/components/niko_home_control/conftest.py index 932480ac710..63307a88e8a 100644 --- a/tests/components/niko_home_control/conftest.py +++ b/tests/components/niko_home_control/conftest.py @@ -26,7 +26,7 @@ def mock_niko_home_control_connection() -> Generator[AsyncMock]: """Mock a NHC client.""" with ( patch( - "homeassistant.components.niko_home_control.config_flow.NikoHomeControlConnection", + "homeassistant.components.niko_home_control.config_flow.NHCController", autospec=True, ) as mock_client, ): diff --git a/tests/components/niko_home_control/test_config_flow.py b/tests/components/niko_home_control/test_config_flow.py index 8220ee15e02..f911f4ebb1a 100644 --- a/tests/components/niko_home_control/test_config_flow.py +++ b/tests/components/niko_home_control/test_config_flow.py @@ -46,7 +46,7 @@ async def test_cannot_connect(hass: HomeAssistant, mock_setup_entry: AsyncMock) assert result["errors"] == {} with patch( - "homeassistant.components.niko_home_control.config_flow.NikoHomeControlConnection", + "homeassistant.components.niko_home_control.config_flow.NHCController.connect", side_effect=Exception, ): result = await hass.config_entries.flow.async_configure( @@ -58,7 +58,7 @@ async def test_cannot_connect(hass: HomeAssistant, mock_setup_entry: AsyncMock) assert result["errors"] == {"base": "cannot_connect"} with patch( - "homeassistant.components.niko_home_control.config_flow.NikoHomeControlConnection" + "homeassistant.components.niko_home_control.config_flow.NHCController.connect", ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -114,7 +114,7 @@ async def test_import_cannot_connect( """Test the cannot connect error.""" with patch( - "homeassistant.components.niko_home_control.config_flow.NikoHomeControlConnection", + "homeassistant.components.niko_home_control.config_flow.NHCController.connect", side_effect=Exception, ): result = await hass.config_entries.flow.async_init( diff --git a/tests/components/niko_home_control/test_init.py b/tests/components/niko_home_control/test_init.py new file mode 100644 index 00000000000..422b7d7c30c --- /dev/null +++ b/tests/components/niko_home_control/test_init.py @@ -0,0 +1,36 @@ +"""Test init.""" + +from unittest.mock import AsyncMock + +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.niko_home_control.const import DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_migrate_entry( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_setup_entry: AsyncMock +) -> None: + """Validate that the unique_id is migrated to the new unique_id.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + minor_version=1, + data={CONF_HOST: "192.168.0.123"}, + ) + config_entry.add_to_hass(hass) + entity_entry = entity_registry.async_get_or_create( + LIGHT_DOMAIN, DOMAIN, "light-1", config_entry=config_entry + ) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entity_entry = entity_registry.async_get(entity_entry.entity_id) + + assert config_entry.minor_version == 2 + assert ( + entity_registry.async_get(entity_entry.entity_id).unique_id + == f"{config_entry.entry_id}-1" + )