diff --git a/.coveragerc b/.coveragerc index 7429966011a..a06f4fa92d3 100644 --- a/.coveragerc +++ b/.coveragerc @@ -639,6 +639,10 @@ omit = homeassistant/components/linode/* homeassistant/components/linux_battery/sensor.py homeassistant/components/lirc/* + homeassistant/components/livisi/__init__.py + homeassistant/components/livisi/climate.py + homeassistant/components/livisi/coordinator.py + homeassistant/components/livisi/switch.py homeassistant/components/llamalab_automate/notify.py homeassistant/components/logi_circle/__init__.py homeassistant/components/logi_circle/camera.py diff --git a/homeassistant/components/livisi/__init__.py b/homeassistant/components/livisi/__init__.py index e71c6bca660..b8d8fdbfb09 100644 --- a/homeassistant/components/livisi/__init__.py +++ b/homeassistant/components/livisi/__init__.py @@ -8,14 +8,15 @@ from aiolivisi import AioLivisi from homeassistant import core from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, device_registry as dr -from .const import DOMAIN, SWITCH_PLATFORM +from .const import DOMAIN from .coordinator import LivisiDataUpdateCoordinator -PLATFORMS: Final = [SWITCH_PLATFORM] +PLATFORMS: Final = [Platform.CLIMATE, Platform.SWITCH] async def async_setup_entry(hass: core.HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/livisi/climate.py b/homeassistant/components/livisi/climate.py new file mode 100644 index 00000000000..d0bdbe64bf7 --- /dev/null +++ b/homeassistant/components/livisi/climate.py @@ -0,0 +1,215 @@ +"""Code to handle a Livisi Virtual Climate Control.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from aiolivisi.const import CAPABILITY_MAP + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ( + DOMAIN, + LIVISI_REACHABILITY_CHANGE, + LIVISI_STATE_CHANGE, + LOGGER, + MAX_TEMPERATURE, + MIN_TEMPERATURE, + VRCC_DEVICE_TYPE, +) +from .coordinator import LivisiDataUpdateCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up climate device.""" + coordinator: LivisiDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + @callback + def handle_coordinator_update() -> None: + """Add climate device.""" + shc_devices: list[dict[str, Any]] = coordinator.data + entities: list[ClimateEntity] = [] + for device in shc_devices: + if ( + device["type"] == VRCC_DEVICE_TYPE + and device["id"] not in coordinator.devices + ): + livisi_climate: ClimateEntity = create_entity( + config_entry, device, coordinator + ) + LOGGER.debug("Include device type: %s", device.get("type")) + coordinator.devices.add(device["id"]) + entities.append(livisi_climate) + async_add_entities(entities) + + config_entry.async_on_unload( + coordinator.async_add_listener(handle_coordinator_update) + ) + + +def create_entity( + config_entry: ConfigEntry, + device: dict[str, Any], + coordinator: LivisiDataUpdateCoordinator, +) -> ClimateEntity: + """Create Climate Entity.""" + capabilities: Mapping[str, Any] = device[CAPABILITY_MAP] + room_id: str = device["location"] + room_name: str = coordinator.rooms[room_id] + livisi_climate = LivisiClimate( + config_entry, + coordinator, + unique_id=device["id"], + manufacturer=device["manufacturer"], + device_type=device["type"], + target_temperature_capability=capabilities["RoomSetpoint"], + temperature_capability=capabilities["RoomTemperature"], + humidity_capability=capabilities["RoomHumidity"], + room=room_name, + ) + return livisi_climate + + +class LivisiClimate(CoordinatorEntity[LivisiDataUpdateCoordinator], ClimateEntity): + """Represents the Livisi Climate.""" + + _attr_hvac_modes = [HVACMode.HEAT] + _attr_hvac_mode = HVACMode.HEAT + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_target_temperature_high = MAX_TEMPERATURE + _attr_target_temperature_low = MIN_TEMPERATURE + + def __init__( + self, + config_entry: ConfigEntry, + coordinator: LivisiDataUpdateCoordinator, + unique_id: str, + manufacturer: str, + device_type: str, + target_temperature_capability: str, + temperature_capability: str, + humidity_capability: str, + room: str, + ) -> None: + """Initialize the Livisi Climate.""" + self.config_entry = config_entry + self._attr_unique_id = unique_id + self._target_temperature_capability = target_temperature_capability + self._temperature_capability = temperature_capability + self._humidity_capability = humidity_capability + self.aio_livisi = coordinator.aiolivisi + self._attr_available = False + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer=manufacturer, + model=device_type, + name=room, + suggested_area=room, + via_device=(DOMAIN, config_entry.entry_id), + ) + super().__init__(coordinator) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + response = await self.aio_livisi.async_vrcc_set_temperature( + self._target_temperature_capability, + kwargs.get(ATTR_TEMPERATURE), + self.coordinator.is_avatar, + ) + if response is None: + self._attr_available = False + raise HomeAssistantError(f"Failed to turn off {self._attr_name}") + + def set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Do nothing as LIVISI devices do not support changing the hvac mode.""" + raise HomeAssistantError( + "This feature is not supported with the LIVISI climate devices" + ) + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + target_temperature = await self.coordinator.async_get_vrcc_target_temperature( + self._target_temperature_capability + ) + temperature = await self.coordinator.async_get_vrcc_temperature( + self._temperature_capability + ) + humidity = await self.coordinator.async_get_vrcc_humidity( + self._humidity_capability + ) + if temperature is None: + self._attr_current_temperature = None + self._attr_available = False + else: + self._attr_target_temperature = target_temperature + self._attr_current_temperature = temperature + self._attr_current_humidity = humidity + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{LIVISI_STATE_CHANGE}_{self._target_temperature_capability}", + self.update_target_temperature, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{LIVISI_STATE_CHANGE}_{self._temperature_capability}", + self.update_temperature, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{LIVISI_STATE_CHANGE}_{self._humidity_capability}", + self.update_humidity, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{LIVISI_REACHABILITY_CHANGE}_{self.unique_id}", + self.update_reachability, + ) + ) + + @callback + def update_target_temperature(self, target_temperature: float) -> None: + """Update the target temperature of the climate device.""" + self._attr_target_temperature = target_temperature + self.async_write_ha_state() + + @callback + def update_temperature(self, current_temperature: float) -> None: + """Update the current temperature of the climate device.""" + self._attr_current_temperature = current_temperature + self.async_write_ha_state() + + @callback + def update_humidity(self, humidity: int) -> None: + """Update the humidity temperature of the climate device.""" + self._attr_current_humidity = humidity + self.async_write_ha_state() + + @callback + def update_reachability(self, is_reachable: bool) -> None: + """Update the reachability of the climate device.""" + self._attr_available = is_reachable + self.async_write_ha_state() diff --git a/homeassistant/components/livisi/const.py b/homeassistant/components/livisi/const.py index e6abc5118de..684510cf7e3 100644 --- a/homeassistant/components/livisi/const.py +++ b/homeassistant/components/livisi/const.py @@ -7,12 +7,15 @@ DOMAIN = "livisi" CONF_HOST = "host" CONF_PASSWORD: Final = "password" +AVATAR = "Avatar" AVATAR_PORT: Final = 9090 CLASSIC_PORT: Final = 8080 DEVICE_POLLING_DELAY: Final = 60 LIVISI_STATE_CHANGE: Final = "livisi_state_change" LIVISI_REACHABILITY_CHANGE: Final = "livisi_reachability_change" -SWITCH_PLATFORM: Final = "switch" - PSS_DEVICE_TYPE: Final = "PSS" +VRCC_DEVICE_TYPE: Final = "VRCC" + +MAX_TEMPERATURE: Final = 30.0 +MIN_TEMPERATURE: Final = 6.0 diff --git a/homeassistant/components/livisi/coordinator.py b/homeassistant/components/livisi/coordinator.py index 47a612274ac..e6c29f7151e 100644 --- a/homeassistant/components/livisi/coordinator.py +++ b/homeassistant/components/livisi/coordinator.py @@ -13,6 +13,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( + AVATAR, AVATAR_PORT, CLASSIC_PORT, CONF_HOST, @@ -69,14 +70,14 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): livisi_connection_data=livisi_connection_data ) controller_data = await self.aiolivisi.async_get_controller() - if controller_data["controllerType"] == "Avatar": + if (controller_type := controller_data["controllerType"]) == AVATAR: self.port = AVATAR_PORT self.is_avatar = True else: self.port = CLASSIC_PORT self.is_avatar = False + self.controller_type = controller_type self.serial_number = controller_data["serialNumber"] - self.controller_type = controller_data["controllerType"] async def async_get_devices(self) -> list[dict[str, Any]]: """Set the discovered devices list.""" @@ -84,7 +85,7 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): async def async_get_pss_state(self, capability: str) -> bool | None: """Set the PSS state.""" - response: dict[str, Any] = await self.aiolivisi.async_get_device_state( + response: dict[str, Any] | None = await self.aiolivisi.async_get_device_state( capability[1:] ) if response is None: @@ -92,6 +93,35 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): on_state = response["onState"] return on_state["value"] + async def async_get_vrcc_target_temperature(self, capability: str) -> float | None: + """Get the target temperature of the climate device.""" + response: dict[str, Any] | None = await self.aiolivisi.async_get_device_state( + capability[1:] + ) + if response is None: + return None + if self.is_avatar: + return response["setpointTemperature"]["value"] + return response["pointTemperature"]["value"] + + async def async_get_vrcc_temperature(self, capability: str) -> float | None: + """Get the temperature of the climate device.""" + response: dict[str, Any] | None = await self.aiolivisi.async_get_device_state( + capability[1:] + ) + if response is None: + return None + return response["temperature"]["value"] + + async def async_get_vrcc_humidity(self, capability: str) -> int | None: + """Get the humidity of the climate device.""" + response: dict[str, Any] | None = await self.aiolivisi.async_get_device_state( + capability[1:] + ) + if response is None: + return None + return response["humidity"]["value"] + async def async_set_all_rooms(self) -> None: """Set the room list.""" response: list[dict[str, Any]] = await self.aiolivisi.async_get_all_rooms() @@ -108,6 +138,12 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): f"{LIVISI_STATE_CHANGE}_{event_data.source}", event_data.onState, ) + if event_data.vrccData is not None: + async_dispatcher_send( + self.hass, + f"{LIVISI_STATE_CHANGE}_{event_data.source}", + event_data.vrccData, + ) if event_data.isReachable is not None: async_dispatcher_send( self.hass,