Change niko_home_control library to nhc to get push updates (#132750)

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: VandeurenGlenn <8685280+VandeurenGlenn@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
Glenn Vandeuren (aka Iondependent) 2024-12-21 19:28:11 +01:00 committed by GitHub
parent 944ad9022d
commit 0037799bfe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 131 additions and 92 deletions

View File

@ -2,35 +2,29 @@
from __future__ import annotations from __future__ import annotations
from datetime import timedelta
import logging
from nclib.errors import NetcatError from nclib.errors import NetcatError
from nikohomecontrol import NikoHomeControl from nhc.controller import NHCController
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady 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] PLATFORMS: list[Platform] = [Platform.LIGHT]
type NikoHomeControlConfigEntry = ConfigEntry[NikoHomeControlData] type NikoHomeControlConfigEntry = ConfigEntry[NHCController]
_LOGGER = logging.getLogger(__name__)
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1)
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: NikoHomeControlConfigEntry hass: HomeAssistant, entry: NikoHomeControlConfigEntry
) -> bool: ) -> bool:
"""Set Niko Home Control from a config entry.""" """Set Niko Home Control from a config entry."""
controller = NHCController(entry.data[CONF_HOST])
try: try:
controller = NikoHomeControl({"ip": entry.data[CONF_HOST], "port": 8000}) await controller.connect()
niko_data = NikoHomeControlData(hass, controller)
await niko_data.async_update()
except NetcatError as err: except NetcatError as err:
raise ConfigEntryNotReady("cannot connect to controller.") from err raise ConfigEntryNotReady("cannot connect to controller.") from err
except OSError as err: except OSError as err:
@ -38,46 +32,45 @@ async def async_setup_entry(
"unknown error while connecting to controller." "unknown error while connecting to controller."
) from err ) from err
entry.runtime_data = niko_data entry.runtime_data = controller
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True 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( async def async_unload_entry(
hass: HomeAssistant, entry: NikoHomeControlConfigEntry hass: HomeAssistant, entry: NikoHomeControlConfigEntry
) -> bool: ) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 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

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any from typing import Any
from nikohomecontrol import NikoHomeControlConnection from nhc.controller import NHCController
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult 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.""" """Test if we can connect to the Niko Home Control controller."""
controller = NHCController(host, 8000)
try: try:
NikoHomeControlConnection(host, 8000) await controller.connect()
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
return "cannot_connect" return "cannot_connect"
return None return None
@ -31,7 +33,7 @@ def test_connection(host: str) -> str | None:
class NikoHomeControlConfigFlow(ConfigFlow, domain=DOMAIN): class NikoHomeControlConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Niko Home Control.""" """Handle a config flow for Niko Home Control."""
VERSION = 1 MINOR_VERSION = 2
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
@ -41,7 +43,7 @@ class NikoHomeControlConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None: if user_input is not None:
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) 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: if not error:
return self.async_create_entry( return self.async_create_entry(
title="Niko Home Control", 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: async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult:
"""Import a config entry.""" """Import a config entry."""
self._async_abort_entries_match({CONF_HOST: import_info[CONF_HOST]}) 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: if not error:
return self.async_create_entry( return self.async_create_entry(

View File

@ -1,3 +1,6 @@
"""Constants for niko_home_control integration.""" """Constants for niko_home_control integration."""
import logging
DOMAIN = "niko_home_control" DOMAIN = "niko_home_control"
_LOGGER = logging.getLogger(__name__)

View File

@ -2,10 +2,9 @@
from __future__ import annotations from __future__ import annotations
from datetime import timedelta
import logging
from typing import Any from typing import Any
from nhc.light import NHCLight
import voluptuous as vol import voluptuous as vol
from homeassistant.components.light import ( 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.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import NikoHomeControlConfigEntry from . import NHCController, NikoHomeControlConfigEntry
from .const import DOMAIN from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=30)
# delete after 2025.7.0 # delete after 2025.7.0
PLATFORM_SCHEMA = LIGHT_PLATFORM_SCHEMA.extend({vol.Required(CONF_HOST): cv.string}) 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, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up the Niko Home Control light entry.""" """Set up the Niko Home Control light entry."""
niko_data = entry.runtime_data controller = entry.runtime_data
async_add_entities( 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): 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.""" """Set up the Niko Home Control light platform."""
self._data = data self._controller = controller
self._light = light self._action = action
self._attr_unique_id = f"light-{light.id}" self._attr_unique_id = f"{unique_id}-{action.id}"
self._attr_name = light.name self._attr_name = action.name
self._attr_is_on = light.is_on self._attr_is_on = action.is_on
self._attr_color_mode = ColorMode.ONOFF self._attr_color_mode = ColorMode.ONOFF
self._attr_supported_color_modes = {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_color_mode = ColorMode.BRIGHTNESS
self._attr_supported_color_modes = {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: def turn_on(self, **kwargs: Any) -> None:
"""Instruct the light to turn on.""" """Instruct the light to turn on."""
_LOGGER.debug("Turn on: %s", self.name) self._action.turn_on(kwargs.get(ATTR_BRIGHTNESS, 255) / 2.55)
self._light.turn_on(kwargs.get(ATTR_BRIGHTNESS, 255) / 2.55)
def turn_off(self, **kwargs: Any) -> None: def turn_off(self, **kwargs: Any) -> None:
"""Instruct the light to turn off.""" """Instruct the light to turn off."""
_LOGGER.debug("Turn off: %s", self.name) self._action.turn_off()
self._light.turn_off()
async def async_update(self) -> None: async def async_update_callback(self, state: int) -> None:
"""Get the latest data from NikoHomeControl API.""" """Handle updates from the controller."""
await self._data.async_update() self._attr_is_on = state > 0
state = self._data.get_state(self._light.id)
self._attr_is_on = state != 0
if brightness_supported(self.supported_color_modes): 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()

View File

@ -4,7 +4,7 @@
"codeowners": ["@VandeurenGlenn"], "codeowners": ["@VandeurenGlenn"],
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/niko_home_control", "documentation": "https://www.home-assistant.io/integrations/niko_home_control",
"iot_class": "local_polling", "iot_class": "local_push",
"loggers": ["nikohomecontrol"], "loggers": ["nikohomecontrol"],
"requirements": ["niko-home-control==0.2.1"] "requirements": ["nhc==0.3.2"]
} }

View File

@ -4160,7 +4160,7 @@
"name": "Niko Home Control", "name": "Niko Home Control",
"integration_type": "hub", "integration_type": "hub",
"config_flow": true, "config_flow": true,
"iot_class": "local_polling" "iot_class": "local_push"
}, },
"nilu": { "nilu": {
"name": "Norwegian Institute for Air Research (NILU)", "name": "Norwegian Institute for Air Research (NILU)",

View File

@ -1463,15 +1463,15 @@ nextcord==2.6.0
# homeassistant.components.nextdns # homeassistant.components.nextdns
nextdns==4.0.0 nextdns==4.0.0
# homeassistant.components.niko_home_control
nhc==0.3.2
# homeassistant.components.nibe_heatpump # homeassistant.components.nibe_heatpump
nibe==2.14.0 nibe==2.14.0
# homeassistant.components.nice_go # homeassistant.components.nice_go
nice-go==1.0.0 nice-go==1.0.0
# homeassistant.components.niko_home_control
niko-home-control==0.2.1
# homeassistant.components.nilu # homeassistant.components.nilu
niluclient==0.1.2 niluclient==0.1.2

View File

@ -1226,15 +1226,15 @@ nextcord==2.6.0
# homeassistant.components.nextdns # homeassistant.components.nextdns
nextdns==4.0.0 nextdns==4.0.0
# homeassistant.components.niko_home_control
nhc==0.3.2
# homeassistant.components.nibe_heatpump # homeassistant.components.nibe_heatpump
nibe==2.14.0 nibe==2.14.0
# homeassistant.components.nice_go # homeassistant.components.nice_go
nice-go==1.0.0 nice-go==1.0.0
# homeassistant.components.niko_home_control
niko-home-control==0.2.1
# homeassistant.components.nfandroidtv # homeassistant.components.nfandroidtv
notifications-android-tv==0.1.5 notifications-android-tv==0.1.5

View File

@ -26,7 +26,7 @@ def mock_niko_home_control_connection() -> Generator[AsyncMock]:
"""Mock a NHC client.""" """Mock a NHC client."""
with ( with (
patch( patch(
"homeassistant.components.niko_home_control.config_flow.NikoHomeControlConnection", "homeassistant.components.niko_home_control.config_flow.NHCController",
autospec=True, autospec=True,
) as mock_client, ) as mock_client,
): ):

View File

@ -46,7 +46,7 @@ async def test_cannot_connect(hass: HomeAssistant, mock_setup_entry: AsyncMock)
assert result["errors"] == {} assert result["errors"] == {}
with patch( with patch(
"homeassistant.components.niko_home_control.config_flow.NikoHomeControlConnection", "homeassistant.components.niko_home_control.config_flow.NHCController.connect",
side_effect=Exception, side_effect=Exception,
): ):
result = await hass.config_entries.flow.async_configure( 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"} assert result["errors"] == {"base": "cannot_connect"}
with patch( 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 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
@ -114,7 +114,7 @@ async def test_import_cannot_connect(
"""Test the cannot connect error.""" """Test the cannot connect error."""
with patch( with patch(
"homeassistant.components.niko_home_control.config_flow.NikoHomeControlConnection", "homeassistant.components.niko_home_control.config_flow.NHCController.connect",
side_effect=Exception, side_effect=Exception,
): ):
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(

View File

@ -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"
)