From 13e3ca6ab1ac5dabd1ad98bda8224f90063a55b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Thu, 23 Dec 2021 21:04:58 +0100 Subject: [PATCH] Add config flow to version integration (#54642) --- homeassistant/components/version/__init__.py | 47 +++- .../components/version/config_flow.py | 202 +++++++++++++++ homeassistant/components/version/const.py | 128 ++++++++++ .../components/version/coordinator.py | 54 ++++ .../components/version/manifest.json | 3 +- homeassistant/components/version/sensor.py | 223 +++++++---------- homeassistant/components/version/strings.json | 26 ++ .../components/version/translations/en.json | 26 ++ homeassistant/generated/config_flows.py | 1 + tests/components/version/common.py | 73 ++++++ tests/components/version/test_config_flow.py | 236 ++++++++++++++++++ tests/components/version/test_sensor.py | 234 ++++++++--------- 12 files changed, 1010 insertions(+), 243 deletions(-) create mode 100644 homeassistant/components/version/config_flow.py create mode 100644 homeassistant/components/version/const.py create mode 100644 homeassistant/components/version/coordinator.py create mode 100644 homeassistant/components/version/strings.json create mode 100644 homeassistant/components/version/translations/en.json create mode 100644 tests/components/version/common.py create mode 100644 tests/components/version/test_config_flow.py diff --git a/homeassistant/components/version/__init__.py b/homeassistant/components/version/__init__.py index 64b04fd7d71..75545adb8db 100644 --- a/homeassistant/components/version/__init__.py +++ b/homeassistant/components/version/__init__.py @@ -1 +1,46 @@ -"""The version integration.""" +"""The Version integration.""" +from __future__ import annotations + +from pyhaversion import HaVersion + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import ( + BOARD_MAP, + CONF_BOARD, + CONF_CHANNEL, + CONF_IMAGE, + CONF_SOURCE, + DOMAIN, + PLATFORMS, +) +from .coordinator import VersionDataUpdateCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up the version integration from a config entry.""" + coordinator = VersionDataUpdateCoordinator( + hass=hass, + api=HaVersion( + session=async_get_clientsession(hass), + source=entry.data[CONF_SOURCE], + image=entry.data[CONF_IMAGE], + board=BOARD_MAP[entry.data[CONF_BOARD]], + channel=entry.data[CONF_CHANNEL].lower(), + ), + ) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload the config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok diff --git a/homeassistant/components/version/config_flow.py b/homeassistant/components/version/config_flow.py new file mode 100644 index 00000000000..5a501b4a97d --- /dev/null +++ b/homeassistant/components/version/config_flow.py @@ -0,0 +1,202 @@ +"""Config flow for Version integration.""" +from __future__ import annotations + +from typing import Any + +from pyhaversion.consts import HaVersionChannel, HaVersionSource +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_NAME, CONF_SOURCE +from homeassistant.data_entry_flow import FlowResult +from homeassistant.util import slugify + +from .const import ( + ATTR_VERSION_SOURCE, + CONF_BETA, + CONF_BOARD, + CONF_CHANNEL, + CONF_IMAGE, + CONF_VERSION_SOURCE, + DEFAULT_BOARD, + DEFAULT_CHANNEL, + DEFAULT_CONFIGURATION, + DEFAULT_IMAGE, + DEFAULT_NAME, + DEFAULT_NAME_CURRENT, + DEFAULT_NAME_LATEST, + DEFAULT_SOURCE, + DOMAIN, + POSTFIX_CONTAINER_NAME, + SOURCE_DOKCER, + SOURCE_HASSIO, + STEP_USER, + STEP_VERSION_SOURCE, + VALID_BOARDS, + VALID_CHANNELS, + VALID_CONTAINER_IMAGES, + VALID_IMAGES, + VERSION_SOURCE_DOCKER_HUB, + VERSION_SOURCE_LOCAL, + VERSION_SOURCE_MAP, + VERSION_SOURCE_MAP_INVERTED, + VERSION_SOURCE_VERSIONS, +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Version.""" + + _entry_data: dict[str, Any] = DEFAULT_CONFIGURATION.copy() + + VERSION = 1 + + async def async_step_user( + self, + user_input: dict[str, Any] | None = None, + ) -> FlowResult: + """Handle the initial user step.""" + if user_input is None: + self._entry_data = DEFAULT_CONFIGURATION.copy() + return self.async_show_form( + step_id=STEP_USER, + data_schema=vol.Schema( + { + vol.Required( + CONF_VERSION_SOURCE, + default=VERSION_SOURCE_LOCAL, + ): vol.In(VERSION_SOURCE_MAP.keys()) + } + ), + ) + + user_input[CONF_SOURCE] = VERSION_SOURCE_MAP[user_input[CONF_VERSION_SOURCE]] + self._entry_data.update(user_input) + + if not self.show_advanced_options or user_input[CONF_SOURCE] in ( + HaVersionSource.LOCAL, + HaVersionSource.HAIO, + ): + return self.async_create_entry( + title=self._config_entry_name, + data=self._entry_data, + ) + + return await self.async_step_version_source() + + async def async_step_version_source( + self, + user_input: dict[str, Any] | None = None, + ) -> FlowResult: + """Handle the version_source step.""" + if user_input is None: + if self._entry_data[CONF_SOURCE] in ( + HaVersionSource.SUPERVISOR, + HaVersionSource.CONTAINER, + ): + data_schema = vol.Schema( + { + vol.Required( + CONF_CHANNEL, default=DEFAULT_CHANNEL.title() + ): vol.In(VALID_CHANNELS), + } + ) + if self._entry_data[CONF_SOURCE] == HaVersionSource.SUPERVISOR: + data_schema = data_schema.extend( + { + vol.Required(CONF_IMAGE, default=DEFAULT_IMAGE): vol.In( + VALID_IMAGES + ), + vol.Required(CONF_BOARD, default=DEFAULT_BOARD): vol.In( + VALID_BOARDS + ), + } + ) + else: + data_schema = data_schema.extend( + { + vol.Required(CONF_IMAGE, default=DEFAULT_IMAGE): vol.In( + VALID_CONTAINER_IMAGES + ) + } + ) + else: + data_schema = vol.Schema({vol.Required(CONF_BETA, default=False): bool}) + + return self.async_show_form( + step_id=STEP_VERSION_SOURCE, + data_schema=data_schema, + description_placeholders={ + ATTR_VERSION_SOURCE: self._entry_data[CONF_VERSION_SOURCE] + }, + ) + self._entry_data.update(user_input) + self._entry_data[CONF_CHANNEL] = self._entry_data[CONF_CHANNEL].lower() + + return self.async_create_entry( + title=self._config_entry_name, data=self._entry_data + ) + + async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: + """Import a config entry from configuration.yaml.""" + self._entry_data = _convert_imported_configuration(import_config) + + for entry in self._async_current_entries(): + if _fingerprint(entry.data) == _fingerprint(self._entry_data): + return self.async_abort(reason="already_configured") + + return self.async_create_entry( + title=self._config_entry_name, data=self._entry_data + ) + + @property + def _config_entry_name(self) -> str: + """Return the name of the config entry.""" + if self._entry_data[CONF_SOURCE] == HaVersionSource.LOCAL: + return DEFAULT_NAME_CURRENT + + name = self._entry_data[CONF_VERSION_SOURCE] + + if (channel := self._entry_data[CONF_CHANNEL]) != DEFAULT_CHANNEL: + return f"{name} {channel.title()}" + + return name + + +def _fingerprint(data) -> str: + """Return a fingerprint of the configuration.""" + configuration = {**DEFAULT_CONFIGURATION, **data} + return slugify("_".join(configuration.values())) + + +def _convert_imported_configuration(config: dict[str, Any]) -> Any: + """Convert a key from the imported configuration.""" + data = DEFAULT_CONFIGURATION.copy() + if config.get(CONF_BETA): + data[CONF_CHANNEL] = HaVersionChannel.BETA + + if (source := config.get(CONF_SOURCE)) and source != DEFAULT_SOURCE: + if source == SOURCE_HASSIO: + data[CONF_SOURCE] = HaVersionSource.SUPERVISOR + data[CONF_VERSION_SOURCE] = VERSION_SOURCE_VERSIONS + elif source == SOURCE_DOKCER: + data[CONF_SOURCE] = HaVersionSource.CONTAINER + data[CONF_VERSION_SOURCE] = VERSION_SOURCE_DOCKER_HUB + else: + data[CONF_SOURCE] = source + data[CONF_VERSION_SOURCE] = VERSION_SOURCE_MAP_INVERTED[source] + + if (image := config.get(CONF_IMAGE)) and image != DEFAULT_IMAGE: + if data[CONF_SOURCE] == HaVersionSource.CONTAINER: + data[CONF_IMAGE] = f"{config[CONF_IMAGE]}{POSTFIX_CONTAINER_NAME}" + else: + data[CONF_IMAGE] = config[CONF_IMAGE] + + if (name := config.get(CONF_NAME)) and name != DEFAULT_NAME: + data[CONF_NAME] = config[CONF_NAME] + else: + if data[CONF_SOURCE] == HaVersionSource.LOCAL: + data[CONF_NAME] = DEFAULT_NAME_CURRENT + else: + data[CONF_NAME] = DEFAULT_NAME_LATEST + return data diff --git a/homeassistant/components/version/const.py b/homeassistant/components/version/const.py new file mode 100644 index 00000000000..8575b17a703 --- /dev/null +++ b/homeassistant/components/version/const.py @@ -0,0 +1,128 @@ +"""Constants for the Version integration.""" +from __future__ import annotations + +from datetime import timedelta +from logging import Logger, getLogger +from typing import Any, Final + +from pyhaversion.consts import HaVersionChannel, HaVersionSource + +from homeassistant.const import CONF_NAME, Platform + +DOMAIN: Final = "version" +LOGGER: Final[Logger] = getLogger(__package__) +PLATFORMS: Final[list[Platform]] = [Platform.SENSOR] +UPDATE_COORDINATOR_UPDATE_INTERVAL: Final[timedelta] = timedelta(minutes=5) + +ENTRY_TYPE_SERVICE: Final = "service" +HOME_ASSISTANT: Final = "Home Assistant" +POSTFIX_CONTAINER_NAME: Final = "-homeassistant" + + +CONF_BETA: Final = "beta" +CONF_BOARD: Final = "board" +CONF_CHANNEL: Final = "channel" +CONF_IMAGE: Final = "image" +CONF_VERSION_SOURCE: Final = "version_source" +CONF_SOURCE: Final = "source" + +ATTR_CHANNEL: Final = CONF_CHANNEL +ATTR_VERSION_SOURCE: Final = CONF_VERSION_SOURCE +ATTR_SOURCE: Final = CONF_SOURCE + +SOURCE_DOKCER: Final = "docker" # Kept to not break existing configurations +SOURCE_HASSIO: Final = "hassio" # Kept to not break existing configurations + +VERSION_SOURCE_DOCKER_HUB: Final = "Docker Hub" +VERSION_SOURCE_HAIO: Final = "Home Assistant Website" +VERSION_SOURCE_LOCAL: Final = "Local installation" +VERSION_SOURCE_PYPI: Final = "Python Package Index (PyPI)" +VERSION_SOURCE_VERSIONS: Final = "Home Assistant Versions" + +DEFAULT_BETA: Final = False +DEFAULT_BOARD: Final = "OVA" +DEFAULT_CHANNEL: Final[HaVersionChannel] = HaVersionChannel.STABLE +DEFAULT_IMAGE: Final = "default" +DEFAULT_NAME_CURRENT: Final = "Current Version" +DEFAULT_NAME_LATEST: Final = "Latest Version" +DEFAULT_NAME: Final = "" +DEFAULT_SOURCE: Final[HaVersionSource] = HaVersionSource.LOCAL +DEFAULT_CONFIGURATION: Final[dict[str, Any]] = { + CONF_NAME: DEFAULT_NAME, + CONF_CHANNEL: DEFAULT_CHANNEL, + CONF_IMAGE: DEFAULT_IMAGE, + CONF_BOARD: DEFAULT_BOARD, + CONF_VERSION_SOURCE: VERSION_SOURCE_LOCAL, + CONF_SOURCE: DEFAULT_SOURCE, +} + +STEP_VERSION_SOURCE: Final = "version_source" +STEP_USER: Final = "user" + +HA_VERSION_SOURCES: Final[list[str]] = [source.value for source in HaVersionSource] + +BOARD_MAP: Final[dict[str, str]] = { + "OVA": "ova", + "RaspberryPi": "rpi", + "RaspberryPi Zero-W": "rpi0-w", + "RaspberryPi 2": "rpi2", + "RaspberryPi 3": "rpi3", + "RaspberryPi 3 64bit": "rpi3-64", + "RaspberryPi 4": "rpi4", + "RaspberryPi 4 64bit": "rpi4-64", + "ASUS Tinkerboard": "tinker", + "ODROID C2": "odroid-c2", + "ODROID C4": "odroid-c4", + "ODROID N2": "odroid-n2", + "ODROID XU4": "odroid-xu4", + "Generic x86-64": "generic-x86-64", + "Intel NUC": "intel-nuc", +} + +VALID_BOARDS: Final[list[str]] = list(BOARD_MAP) + +VERSION_SOURCE_MAP: Final[dict[str, HaVersionSource]] = { + VERSION_SOURCE_LOCAL: HaVersionSource.LOCAL, + VERSION_SOURCE_VERSIONS: HaVersionSource.SUPERVISOR, + VERSION_SOURCE_HAIO: HaVersionSource.HAIO, + VERSION_SOURCE_DOCKER_HUB: HaVersionSource.CONTAINER, + VERSION_SOURCE_PYPI: HaVersionSource.PYPI, +} + +VERSION_SOURCE_MAP_INVERTED: Final[dict[HaVersionSource, str]] = { + value: key for key, value in VERSION_SOURCE_MAP.items() +} + + +VALID_SOURCES: Final[list[str]] = HA_VERSION_SOURCES + [ + SOURCE_HASSIO, # Kept to not break existing configurations + SOURCE_DOKCER, # Kept to not break existing configurations +] + +VALID_IMAGES: Final = [ + "default", + "generic-x86-64", + "intel-nuc", + "odroid-c2", + "odroid-n2", + "odroid-xu", + "qemuarm-64", + "qemuarm", + "qemux86-64", + "qemux86", + "raspberrypi", + "raspberrypi2", + "raspberrypi3-64", + "raspberrypi3", + "raspberrypi4-64", + "raspberrypi4", + "tinker", +] + +VALID_CONTAINER_IMAGES: Final[list[str]] = [ + f"{image}{POSTFIX_CONTAINER_NAME}" if image != DEFAULT_IMAGE else image + for image in VALID_IMAGES +] +VALID_CHANNELS: Final[list[str]] = [ + str(channel.value).title() for channel in HaVersionChannel +] diff --git a/homeassistant/components/version/coordinator.py b/homeassistant/components/version/coordinator.py new file mode 100644 index 00000000000..d99fb531da2 --- /dev/null +++ b/homeassistant/components/version/coordinator.py @@ -0,0 +1,54 @@ +"""Data update coordinator for Version entities.""" +from __future__ import annotations + +from typing import Any + +from awesomeversion import AwesomeVersion +from pyhaversion import HaVersion +from pyhaversion.exceptions import HaVersionException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER, UPDATE_COORDINATOR_UPDATE_INTERVAL + + +class VersionDataUpdateCoordinator(DataUpdateCoordinator): + """Data update coordinator for Version entities.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + api: HaVersion, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass=hass, + logger=LOGGER, + name=DOMAIN, + update_method=self._async_update_version_data, + update_interval=UPDATE_COORDINATOR_UPDATE_INTERVAL, + ) + self._api = api + self._version: AwesomeVersion | None = None + self._version_data: dict[str, Any] | None = None + + @property + def version(self) -> str | None: + """Return the latest version.""" + return str(self._version) if self._version else None + + @property + def version_data(self) -> dict[str, Any]: + """Return the version data.""" + return self._version_data or {} + + async def _async_update_version_data(self) -> None: + """Update version data.""" + try: + self._version, self._version_data = await self._api.get_version() + except HaVersionException as exception: + raise UpdateFailed(exception) from exception diff --git a/homeassistant/components/version/manifest.json b/homeassistant/components/version/manifest.json index f5dec053399..5a4cd70f4c7 100644 --- a/homeassistant/components/version/manifest.json +++ b/homeassistant/components/version/manifest.json @@ -10,5 +10,6 @@ "@ludeeus" ], "quality_scale": "internal", - "iot_class": "local_push" + "iot_class": "local_push", + "config_flow": true } \ No newline at end of file diff --git a/homeassistant/components/version/sensor.py b/homeassistant/components/version/sensor.py index 63e8421ed0e..1cd1aecb053 100644 --- a/homeassistant/components/version/sensor.py +++ b/homeassistant/components/version/sensor.py @@ -1,156 +1,127 @@ """Sensor that can display the current Home Assistant versions.""" -from datetime import timedelta -import logging +from __future__ import annotations + +from typing import Any, Final -from pyhaversion import ( - HaVersion, - HaVersionChannel, - HaVersionSource, - exceptions as pyhaversionexceptions, -) import voluptuous as vol +from voluptuous.schema_builder import Schema from homeassistant.components.sensor import ( - PLATFORM_SCHEMA, + PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, SensorEntity, SensorEntityDescription, ) -from homeassistant.const import CONF_NAME, CONF_SOURCE -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv -from homeassistant.util import Throttle +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity -ALL_IMAGES = [ - "default", - "generic-x86-64", - "intel-nuc", - "odroid-c2", - "odroid-n2", - "odroid-xu", - "qemuarm-64", - "qemuarm", - "qemux86-64", - "qemux86", - "raspberrypi", - "raspberrypi2", - "raspberrypi3-64", - "raspberrypi3", - "raspberrypi4-64", - "raspberrypi4", - "tinker", -] +from .const import ( + ATTR_SOURCE, + CONF_BETA, + CONF_IMAGE, + CONF_SOURCE, + DEFAULT_BETA, + DEFAULT_IMAGE, + DEFAULT_NAME, + DEFAULT_SOURCE, + DOMAIN, + HOME_ASSISTANT, + LOGGER, + VALID_IMAGES, + VALID_SOURCES, +) +from .coordinator import VersionDataUpdateCoordinator -HA_VERSION_SOURCES = [source.value for source in HaVersionSource] - -ALL_SOURCES = HA_VERSION_SOURCES + [ - "hassio", # Kept to not break existing configurations - "docker", # Kept to not break existing configurations -] - -CONF_BETA = "beta" -CONF_IMAGE = "image" - -DEFAULT_IMAGE = "default" -DEFAULT_NAME_LATEST = "Latest Version" -DEFAULT_NAME_LOCAL = "Current Version" -DEFAULT_SOURCE = "local" - -TIME_BETWEEN_UPDATES = timedelta(minutes=5) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( +PLATFORM_SCHEMA: Final[Schema] = SENSOR_PLATFORM_SCHEMA.extend( { - vol.Optional(CONF_BETA, default=False): cv.boolean, - vol.Optional(CONF_IMAGE, default=DEFAULT_IMAGE): vol.In(ALL_IMAGES), - vol.Optional(CONF_NAME, default=""): cv.string, - vol.Optional(CONF_SOURCE, default=DEFAULT_SOURCE): vol.In(ALL_SOURCES), + vol.Optional(CONF_BETA, default=DEFAULT_BETA): cv.boolean, + vol.Optional(CONF_IMAGE, default=DEFAULT_IMAGE): vol.In(VALID_IMAGES), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_SOURCE, default=DEFAULT_SOURCE): vol.In(VALID_SOURCES), } ) -_LOGGER: logging.Logger = logging.getLogger(__name__) - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the Version sensor platform.""" - - beta = config.get(CONF_BETA) - image = config.get(CONF_IMAGE) - name = config.get(CONF_NAME) - source = config.get(CONF_SOURCE) - - channel = HaVersionChannel.BETA if beta else HaVersionChannel.STABLE - session = async_get_clientsession(hass) - - if source in HA_VERSION_SOURCES: - source = HaVersionSource(source) - elif source == "hassio": - source = HaVersionSource.SUPERVISOR - elif source == "docker": - source = HaVersionSource.CONTAINER - - if ( - source == HaVersionSource.CONTAINER - and image is not None - and image != DEFAULT_IMAGE - ): - image = f"{image}-homeassistant" - - if not (name := config.get(CONF_NAME)): - if source == HaVersionSource.LOCAL: - name = DEFAULT_NAME_LOCAL - else: - name = DEFAULT_NAME_LATEST - - async_add_entities( - [ - VersionSensor( - VersionData( - HaVersion( - session=session, source=source, image=image, channel=channel - ) - ), - SensorEntityDescription(key=source, name=name), - ) - ], - True, +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the legacy version sensor platform.""" + LOGGER.warning( + "Configuration of the Version platform in YAML is deprecated and will be " + "removed in Home Assistant 2022.4; Your existing configuration " + "has been imported into the UI automatically and can be safely removed " + "from your configuration.yaml file" + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={ATTR_SOURCE: SOURCE_IMPORT}, data=config + ) ) -class VersionData: - """Get the latest data and update the states.""" +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up version sensors.""" + coordinator: VersionDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + if (entity_name := entry.data[CONF_NAME]) == DEFAULT_NAME: + entity_name = entry.title - def __init__(self, api: HaVersion) -> None: - """Initialize the data object.""" - self.api = api + version_sensor_entities: list[VersionSensorEntity] = [ + VersionSensorEntity( + coordinator=coordinator, + entity_description=SensorEntityDescription( + key=str(entry.data[CONF_SOURCE]), + name=entity_name, + ), + ) + ] - @Throttle(TIME_BETWEEN_UPDATES) - async def async_update(self): - """Get the latest version information.""" - try: - await self.api.get_version() - except pyhaversionexceptions.HaVersionFetchException as exception: - _LOGGER.warning(exception) - except pyhaversionexceptions.HaVersionParseException as exception: - _LOGGER.warning( - "Could not parse data received for %s - %s", self.api.source, exception - ) + async_add_entities(version_sensor_entities) -class VersionSensor(SensorEntity): - """Representation of a Home Assistant version sensor.""" +class VersionSensorEntity(CoordinatorEntity, SensorEntity): + """Version sensor entity class.""" _attr_icon = "mdi:package-up" + _attr_device_info = DeviceInfo( + name=f"{HOME_ASSISTANT} {DOMAIN.title()}", + identifiers={(HOME_ASSISTANT, DOMAIN)}, + manufacturer=HOME_ASSISTANT, + entry_type=DeviceEntryType.SERVICE, + ) + + coordinator: VersionDataUpdateCoordinator def __init__( self, - data: VersionData, - description: SensorEntityDescription, + coordinator: VersionDataUpdateCoordinator, + entity_description: SensorEntityDescription, ) -> None: - """Initialize the Version sensor.""" - self.data = data - self.entity_description = description + """Initialize version sensor entities.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id = ( + f"{coordinator.config_entry.unique_id}_{entity_description.key}" + ) - async def async_update(self): - """Get the latest version information.""" - await self.data.async_update() - self._attr_native_value = self.data.api.version - self._attr_extra_state_attributes = self.data.api.version_data + @property + def native_value(self) -> StateType: + """Return the native value of this sensor.""" + return self.coordinator.version + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return extra state attributes of this sensor.""" + return self.coordinator.version_data diff --git a/homeassistant/components/version/strings.json b/homeassistant/components/version/strings.json new file mode 100644 index 00000000000..b147422b32a --- /dev/null +++ b/homeassistant/components/version/strings.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "step": { + "user": { + "title": "Select installation type", + "description": "Select the source you want to track versions from", + "data": { + "version_source": "Version source" + } + }, + "version_source": { + "title": "Configure", + "description": "Configure {version_source} version tracking", + "data": { + "beta": "Include beta versions", + "board": "Which board should be tracked", + "channel": "Which channel should be tracked", + "image": "Which image should be tracked" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/version/translations/en.json b/homeassistant/components/version/translations/en.json new file mode 100644 index 00000000000..fc443a1e9c9 --- /dev/null +++ b/homeassistant/components/version/translations/en.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "step": { + "user": { + "data": { + "version_source": "Version source" + }, + "description": "Select the source you want to track versions from", + "title": "Select installation type" + }, + "version_source": { + "data": { + "beta": "Include beta versions", + "board": "Which board should be tracked", + "channel": "Which channel should be tracked", + "image": "Which image should be tracked" + }, + "description": "Configure {version_source} version tracking", + "title": "Configure" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 928ac3dad67..fa828cc6368 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -331,6 +331,7 @@ FLOWS = [ "venstar", "vera", "verisure", + "version", "vesync", "vicare", "vilfo", diff --git a/tests/components/version/common.py b/tests/components/version/common.py new file mode 100644 index 00000000000..489d1d435bf --- /dev/null +++ b/tests/components/version/common.py @@ -0,0 +1,73 @@ +"""Fixtures for version integration.""" +from __future__ import annotations + +from typing import Any, Final +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components.version.const import ( + DEFAULT_CONFIGURATION, + DEFAULT_NAME_CURRENT, + DOMAIN, + UPDATE_COORDINATOR_UPDATE_INTERVAL, + VERSION_SOURCE_LOCAL, +) +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util import dt + +from tests.common import MockConfigEntry, async_fire_time_changed + +MOCK_VERSION: Final = "1970.1.0" +MOCK_VERSION_DATA: Final = {"source": "local", "channel": "stable"} + + +MOCK_VERSION_CONFIG_ENTRY_DATA: Final[dict[str, Any]] = { + "domain": DOMAIN, + "title": VERSION_SOURCE_LOCAL, + "data": DEFAULT_CONFIGURATION, + "source": config_entries.SOURCE_USER, +} + +TEST_DEFAULT_IMPORT_CONFIG: Final = { + **DEFAULT_CONFIGURATION, + CONF_NAME: DEFAULT_NAME_CURRENT, +} + + +async def mock_get_version_update( + hass: HomeAssistant, + version: str = MOCK_VERSION, + data: dict[str, Any] = MOCK_VERSION_DATA, + side_effect: Exception = None, +) -> None: + """Mock getting version.""" + with patch( + "pyhaversion.HaVersion.get_version", + return_value=(version, data), + side_effect=side_effect, + ): + + async_fire_time_changed(hass, dt.utcnow() + UPDATE_COORDINATOR_UPDATE_INTERVAL) + await hass.async_block_till_done() + + +async def setup_version_integration(hass: HomeAssistant) -> MockConfigEntry: + """Set up the Version integration.""" + await async_setup_component(hass, "persistent_notification", {}) + mock_entry = MockConfigEntry(**MOCK_VERSION_CONFIG_ENTRY_DATA) + mock_entry.add_to_hass(hass) + + with patch( + "pyhaversion.HaVersion.get_version", + return_value=(MOCK_VERSION, MOCK_VERSION_DATA), + ): + + assert await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("sensor.local_installation").state == MOCK_VERSION + assert mock_entry.state == config_entries.ConfigEntryState.LOADED + + return mock_entry diff --git a/tests/components/version/test_config_flow.py b/tests/components/version/test_config_flow.py new file mode 100644 index 00000000000..f45ff1764f2 --- /dev/null +++ b/tests/components/version/test_config_flow.py @@ -0,0 +1,236 @@ +"""Test the Version config flow.""" +from unittest.mock import patch + +from pyhaversion.consts import HaVersionChannel, HaVersionSource + +from homeassistant import config_entries, setup +from homeassistant.components.version.const import ( + CONF_BETA, + CONF_BOARD, + CONF_CHANNEL, + CONF_IMAGE, + CONF_VERSION_SOURCE, + DEFAULT_CONFIGURATION, + DOMAIN, + UPDATE_COORDINATOR_UPDATE_INTERVAL, + VERSION_SOURCE_DOCKER_HUB, + VERSION_SOURCE_PYPI, + VERSION_SOURCE_VERSIONS, +) +from homeassistant.const import CONF_SOURCE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) +from homeassistant.util import dt + +from tests.common import async_fire_time_changed +from tests.components.version.common import ( + MOCK_VERSION, + MOCK_VERSION_DATA, + setup_version_integration, +) + + +async def test_reload(hass: HomeAssistant): + """Test the Version sensor with different sources.""" + config_entry = await setup_version_integration(hass) + + with patch( + "pyhaversion.HaVersion.get_version", + return_value=(MOCK_VERSION, MOCK_VERSION_DATA), + ): + assert await hass.config_entries.async_reload(config_entry.entry_id) + async_fire_time_changed(hass, dt.utcnow() + UPDATE_COORDINATOR_UPDATE_INTERVAL) + await hass.async_block_till_done() + + entry = hass.config_entries.async_get_entry(config_entry.entry_id) + assert entry.state == config_entries.ConfigEntryState.LOADED + assert hass.states.get("sensor.local_installation").state == MOCK_VERSION + + +async def test_basic_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER, "show_advanced_options": False}, + ) + assert result["type"] == RESULT_TYPE_FORM + + with patch( + "homeassistant.components.version.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_VERSION_SOURCE: VERSION_SOURCE_DOCKER_HUB}, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == VERSION_SOURCE_DOCKER_HUB + assert result2["data"] == { + **DEFAULT_CONFIGURATION, + CONF_SOURCE: HaVersionSource.CONTAINER, + CONF_VERSION_SOURCE: VERSION_SOURCE_DOCKER_HUB, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_advanced_form_pypi(hass: HomeAssistant) -> None: + """Show advanced form when pypi is selected.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER, "show_advanced_options": True}, + ) + assert result["type"] == RESULT_TYPE_FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_VERSION_SOURCE: VERSION_SOURCE_PYPI}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "version_source" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "version_source" + + with patch( + "homeassistant.components.version.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_BETA: True} + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == VERSION_SOURCE_PYPI + assert result["data"] == { + **DEFAULT_CONFIGURATION, + CONF_BETA: True, + CONF_SOURCE: HaVersionSource.PYPI, + CONF_VERSION_SOURCE: VERSION_SOURCE_PYPI, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_advanced_form_container(hass: HomeAssistant) -> None: + """Show advanced form when container source is selected.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER, "show_advanced_options": True}, + ) + assert result["type"] == RESULT_TYPE_FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_VERSION_SOURCE: VERSION_SOURCE_DOCKER_HUB}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "version_source" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "version_source" + + with patch( + "homeassistant.components.version.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IMAGE: "odroid-n2-homeassistant"} + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == VERSION_SOURCE_DOCKER_HUB + assert result["data"] == { + **DEFAULT_CONFIGURATION, + CONF_IMAGE: "odroid-n2-homeassistant", + CONF_SOURCE: HaVersionSource.CONTAINER, + CONF_VERSION_SOURCE: VERSION_SOURCE_DOCKER_HUB, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_advanced_form_supervisor(hass: HomeAssistant) -> None: + """Show advanced form when docker source is selected.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER, "show_advanced_options": True}, + ) + assert result["type"] == RESULT_TYPE_FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_VERSION_SOURCE: VERSION_SOURCE_VERSIONS}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "version_source" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "version_source" + + with patch( + "homeassistant.components.version.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_CHANNEL: "Dev", CONF_IMAGE: "odroid-n2", CONF_BOARD: "ODROID N2"}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == f"{VERSION_SOURCE_VERSIONS} Dev" + assert result["data"] == { + **DEFAULT_CONFIGURATION, + CONF_IMAGE: "odroid-n2", + CONF_BOARD: "ODROID N2", + CONF_CHANNEL: HaVersionChannel.DEV, + CONF_SOURCE: HaVersionSource.SUPERVISOR, + CONF_VERSION_SOURCE: VERSION_SOURCE_VERSIONS, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_existing(hass: HomeAssistant) -> None: + """Test importing existing configuration.""" + with patch( + "homeassistant.components.version.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/version/test_sensor.py b/tests/components/version/test_sensor.py index cd56223a1e6..6a37fd58b3a 100644 --- a/tests/components/version/test_sensor.py +++ b/tests/components/version/test_sensor.py @@ -1,130 +1,134 @@ """The test for the version sensor platform.""" -from datetime import timedelta +from __future__ import annotations + +from typing import Any from unittest.mock import patch -from pyhaversion import HaVersionSource, exceptions as pyhaversionexceptions +from pyhaversion import HaVersionChannel, HaVersionSource +from pyhaversion.exceptions import HaVersionException import pytest -from homeassistant.components.version.sensor import HA_VERSION_SOURCES +from homeassistant.components.version.const import ( + CONF_BETA, + CONF_CHANNEL, + CONF_IMAGE, + CONF_VERSION_SOURCE, + DEFAULT_NAME_LATEST, + DOMAIN, + VERSION_SOURCE_DOCKER_HUB, + VERSION_SOURCE_VERSIONS, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME, CONF_SOURCE +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from homeassistant.util import dt -from tests.common import async_fire_time_changed +from .common import ( + MOCK_VERSION, + MOCK_VERSION_DATA, + TEST_DEFAULT_IMPORT_CONFIG, + mock_get_version_update, + setup_version_integration, +) -MOCK_VERSION = "10.0" + +async def async_setup_sensor_wrapper( + hass: HomeAssistant, config: dict[str, Any] +) -> ConfigEntry: + """Set up the Version sensor platform.""" + await async_setup_component(hass, "persistent_notification", {}) + with patch( + "pyhaversion.HaVersion.get_version", + return_value=(MOCK_VERSION, MOCK_VERSION_DATA), + ): + assert await async_setup_component( + hass, "sensor", {"sensor": {"platform": DOMAIN, **config}} + ) + await hass.async_block_till_done() + + config_entries = hass.config_entries.async_entries(DOMAIN) + print(config_entries) + config_entry = config_entries[-1] + assert config_entry.source == "import" + return config_entry + + +async def test_version_sensor(hass: HomeAssistant): + """Test the Version sensor with different sources.""" + await setup_version_integration(hass) + + state = hass.states.get("sensor.local_installation") + assert state.state == MOCK_VERSION + assert state.attributes["source"] == "local" + assert state.attributes["channel"] == "stable" + + +async def test_update(hass: HomeAssistant, caplog: pytest.LogCaptureFixture): + """Test updates.""" + await setup_version_integration(hass) + assert hass.states.get("sensor.local_installation").state == MOCK_VERSION + + await mock_get_version_update(hass, version="1970.1.1") + assert hass.states.get("sensor.local_installation").state == "1970.1.1" + + assert "Error fetching version data" not in caplog.text + await mock_get_version_update(hass, side_effect=HaVersionException) + assert hass.states.get("sensor.local_installation").state == "unavailable" + assert "Error fetching version data" in caplog.text @pytest.mark.parametrize( - "source,target_source,name", + "yaml,converted", ( ( - ("local", HaVersionSource.LOCAL, "current_version"), - ("docker", HaVersionSource.CONTAINER, "latest_version"), - ("hassio", HaVersionSource.SUPERVISOR, "latest_version"), - ) - + tuple( - (source, HaVersionSource(source), "latest_version") - for source in HA_VERSION_SOURCES - if source != HaVersionSource.LOCAL - ) + {}, + TEST_DEFAULT_IMPORT_CONFIG, + ), + ( + {CONF_NAME: "test"}, + {**TEST_DEFAULT_IMPORT_CONFIG, CONF_NAME: "test"}, + ), + ( + {CONF_SOURCE: "hassio", CONF_IMAGE: "odroid-n2"}, + { + **TEST_DEFAULT_IMPORT_CONFIG, + CONF_NAME: DEFAULT_NAME_LATEST, + CONF_SOURCE: HaVersionSource.SUPERVISOR, + CONF_VERSION_SOURCE: VERSION_SOURCE_VERSIONS, + CONF_IMAGE: "odroid-n2", + }, + ), + ( + {CONF_SOURCE: "docker"}, + { + **TEST_DEFAULT_IMPORT_CONFIG, + CONF_NAME: DEFAULT_NAME_LATEST, + CONF_SOURCE: HaVersionSource.CONTAINER, + CONF_VERSION_SOURCE: VERSION_SOURCE_DOCKER_HUB, + }, + ), + ( + {CONF_BETA: True}, + { + **TEST_DEFAULT_IMPORT_CONFIG, + CONF_CHANNEL: HaVersionChannel.BETA, + }, + ), + ( + {CONF_SOURCE: "container", CONF_IMAGE: "odroid-n2"}, + { + **TEST_DEFAULT_IMPORT_CONFIG, + CONF_NAME: DEFAULT_NAME_LATEST, + CONF_SOURCE: HaVersionSource.CONTAINER, + CONF_VERSION_SOURCE: VERSION_SOURCE_DOCKER_HUB, + CONF_IMAGE: "odroid-n2-homeassistant", + }, + ), ), ) -async def test_version_source(hass, source, target_source, name): - """Test the Version sensor with different sources.""" - config = { - "sensor": {"platform": "version", "source": source, "image": "qemux86-64"} - } - - with patch("homeassistant.components.version.sensor.HaVersion.get_version"), patch( - "homeassistant.components.version.sensor.HaVersion.version", MOCK_VERSION - ): - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() - - state = hass.states.get(f"sensor.{name}") - assert state - assert state.attributes["source"] == target_source - - assert state.state == MOCK_VERSION - - -async def test_version_fetch_exception(hass, caplog): - """Test fetch exception thrown during updates.""" - config = {"sensor": {"platform": "version"}} - with patch( - "homeassistant.components.version.sensor.HaVersion.get_version", - side_effect=pyhaversionexceptions.HaVersionFetchException( - "Fetch exception from pyhaversion" - ), - ): - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() - assert "Fetch exception from pyhaversion" in caplog.text - - -async def test_version_parse_exception(hass, caplog): - """Test parse exception thrown during updates.""" - config = {"sensor": {"platform": "version"}} - with patch( - "homeassistant.components.version.sensor.HaVersion.get_version", - side_effect=pyhaversionexceptions.HaVersionParseException, - ): - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() - assert "Could not parse data received for HaVersionSource.LOCAL" in caplog.text - - -async def test_update(hass): - """Test updates.""" - config = {"sensor": {"platform": "version"}} - - with patch("homeassistant.components.version.sensor.HaVersion.get_version"), patch( - "homeassistant.components.version.sensor.HaVersion.version", MOCK_VERSION - ): - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() - - state = hass.states.get("sensor.current_version") - assert state - assert state.state == MOCK_VERSION - - with patch("homeassistant.components.version.sensor.HaVersion.get_version"), patch( - "homeassistant.components.version.sensor.HaVersion.version", "1234" - ): - - async_fire_time_changed(hass, dt.utcnow() + timedelta(minutes=5)) - await hass.async_block_till_done() - - state = hass.states.get("sensor.current_version") - assert state - assert state.state == "1234" - - -async def test_image_name_container(hass): - """Test the Version sensor with image name for container.""" - config = { - "sensor": {"platform": "version", "source": "docker", "image": "qemux86-64"} - } - - with patch("homeassistant.components.version.sensor.HaVersion") as haversion: - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() - - constructor = haversion.call_args[1] - assert constructor["source"] == "container" - assert constructor["image"] == "qemux86-64-homeassistant" - - -async def test_image_name_supervisor(hass): - """Test the Version sensor with image name for supervisor.""" - config = { - "sensor": {"platform": "version", "source": "hassio", "image": "qemux86-64"} - } - - with patch("homeassistant.components.version.sensor.HaVersion") as haversion: - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() - - constructor = haversion.call_args[1] - assert constructor["source"] == "supervisor" - assert constructor["image"] == "qemux86-64" +async def test_config_import( + hass: HomeAssistant, yaml: dict[str, Any], converted: dict[str, Any] +) -> None: + """Test importing YAML configuration.""" + config_entry = await async_setup_sensor_wrapper(hass, yaml) + assert config_entry.data == converted