diff --git a/CODEOWNERS b/CODEOWNERS index c2be1901344..7e7f25ad153 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1066,6 +1066,8 @@ homeassistant/components/updater/* @home-assistant/core tests/components/updater/* @home-assistant/core homeassistant/components/upnp/* @StevenLooman @ehendrix23 tests/components/upnp/* @StevenLooman @ehendrix23 +homeassistant/components/uptime/* @frenck +tests/components/uptime/* @frenck homeassistant/components/uptimerobot/* @ludeeus @chemelli74 tests/components/uptimerobot/* @ludeeus @chemelli74 homeassistant/components/usb/* @bdraco diff --git a/homeassistant/components/uptime/__init__.py b/homeassistant/components/uptime/__init__.py index 99abc91cdf1..1c36fea3b32 100644 --- a/homeassistant/components/uptime/__init__.py +++ b/homeassistant/components/uptime/__init__.py @@ -1 +1,16 @@ -"""The uptime component.""" +"""The Uptime integration.""" +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import PLATFORMS + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up from a config entry.""" + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/uptime/config_flow.py b/homeassistant/components/uptime/config_flow.py new file mode 100644 index 00000000000..6ff36ee34b1 --- /dev/null +++ b/homeassistant/components/uptime/config_flow.py @@ -0,0 +1,37 @@ +"""Config flow to configure the Uptime integration.""" +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_NAME +from homeassistant.data_entry_flow import FlowResult + +from .const import DEFAULT_NAME, DOMAIN + + +class UptimeConfigFlow(ConfigFlow, domain=DOMAIN): + """Config flow for Uptime.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + if user_input is not None: + return self.async_create_entry( + title=user_input.get(CONF_NAME, DEFAULT_NAME), + data={}, + ) + + return self.async_show_form(step_id="user", data_schema=vol.Schema({})) + + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: + """Handle import from configuration.yaml.""" + return await self.async_step_user(user_input) diff --git a/homeassistant/components/uptime/const.py b/homeassistant/components/uptime/const.py new file mode 100644 index 00000000000..bbce8021474 --- /dev/null +++ b/homeassistant/components/uptime/const.py @@ -0,0 +1,9 @@ +"""Constants for the Uptime integration.""" +from typing import Final + +from homeassistant.const import Platform + +DOMAIN: Final = "uptime" +PLATFORMS: Final = [Platform.SENSOR] + +DEFAULT_NAME: Final = "Uptime" diff --git a/homeassistant/components/uptime/manifest.json b/homeassistant/components/uptime/manifest.json index cf2dd1a6ea1..3bcc47815f8 100644 --- a/homeassistant/components/uptime/manifest.json +++ b/homeassistant/components/uptime/manifest.json @@ -2,7 +2,8 @@ "domain": "uptime", "name": "Uptime", "documentation": "https://www.home-assistant.io/integrations/uptime", - "codeowners": [], + "codeowners": ["@frenck"], "quality_scale": "internal", - "iot_class": "local_push" + "iot_class": "local_push", + "config_flow": true } diff --git a/homeassistant/components/uptime/sensor.py b/homeassistant/components/uptime/sensor.py index a622835a0da..944f9b77de8 100644 --- a/homeassistant/components/uptime/sensor.py +++ b/homeassistant/components/uptime/sensor.py @@ -8,6 +8,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_NAME, CONF_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv @@ -15,17 +16,15 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util -DEFAULT_NAME = "Uptime" +from .const import DEFAULT_NAME, DOMAIN PLATFORM_SCHEMA = vol.All( - cv.deprecated(CONF_UNIT_OF_MEASUREMENT), + cv.removed(CONF_UNIT_OF_MEASUREMENT, raise_if_present=False), PLATFORM_SCHEMA.extend( { vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_UNIT_OF_MEASUREMENT, default="days"): vol.All( - cv.string, vol.In(["minutes", "hours", "days", "seconds"]) - ), - } + vol.Remove(CONF_UNIT_OF_MEASUREMENT): cv.string, + }, ), ) @@ -37,9 +36,22 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the uptime sensor platform.""" - name = config[CONF_NAME] + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + ) - async_add_entities([UptimeSensor(name)], True) + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the platform from config_entry.""" + async_add_entities([UptimeSensor(entry)]) class UptimeSensor(SensorEntity): @@ -48,7 +60,8 @@ class UptimeSensor(SensorEntity): _attr_device_class = SensorDeviceClass.TIMESTAMP _attr_should_poll = False - def __init__(self, name: str) -> None: + def __init__(self, entry: ConfigEntry) -> None: """Initialize the uptime sensor.""" - self._attr_name = name + self._attr_name = entry.title self._attr_native_value = dt_util.utcnow() + self._attr_unique_id = entry.entry_id diff --git a/homeassistant/components/uptime/strings.json b/homeassistant/components/uptime/strings.json new file mode 100644 index 00000000000..9ceb91de9ba --- /dev/null +++ b/homeassistant/components/uptime/strings.json @@ -0,0 +1,13 @@ +{ + "title": "Uptime", + "config": { + "step": { + "user": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + } + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + } + } +} diff --git a/homeassistant/components/uptime/translations/en.json b/homeassistant/components/uptime/translations/en.json new file mode 100644 index 00000000000..5d38ae74e21 --- /dev/null +++ b/homeassistant/components/uptime/translations/en.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Already configured. Only a single configuration possible." + }, + "step": { + "user": { + "description": "Do you want to start set up?" + } + } + }, + "title": "Uptime" +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 93592a2e1a9..b48df75c189 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -355,6 +355,7 @@ FLOWS = [ "upb", "upcloud", "upnp", + "uptime", "uptimerobot", "vallox", "velbus", diff --git a/tests/components/uptime/conftest.py b/tests/components/uptime/conftest.py new file mode 100644 index 00000000000..7ee34856e63 --- /dev/null +++ b/tests/components/uptime/conftest.py @@ -0,0 +1,41 @@ +"""Fixtures for Uptime integration tests.""" +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import patch + +import pytest + +from homeassistant.components.uptime.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Uptime", + domain=DOMAIN, + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[None, None, None]: + """Mock setting up a config entry.""" + with patch("homeassistant.components.uptime.async_setup_entry", return_value=True): + yield + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> MockConfigEntry: + """Set up the Uptime integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/uptime/test_config_flow.py b/tests/components/uptime/test_config_flow.py new file mode 100644 index 00000000000..69ba00f6ac8 --- /dev/null +++ b/tests/components/uptime/test_config_flow.py @@ -0,0 +1,72 @@ +"""Tests for the Uptime config flow.""" +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.uptime.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from tests.common import MockConfigEntry + + +async def test_full_user_flow( + hass: HomeAssistant, + mock_setup_entry: MagicMock, +) -> None: + """Test the full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == SOURCE_USER + assert "flow_id" in result + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result2.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result2.get("title") == "Uptime" + assert result2.get("data") == {} + + +@pytest.mark.parametrize("source", [SOURCE_USER, SOURCE_IMPORT]) +async def test_single_instance_allowed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + source: str, +) -> None: + """Test we abort if already setup.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": source} + ) + + assert result.get("type") == RESULT_TYPE_ABORT + assert result.get("reason") == "single_instance_allowed" + + +async def test_import_flow( + hass: HomeAssistant, + mock_setup_entry: MagicMock, +) -> None: + """Test the import configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_NAME: "My Uptime"}, + ) + + assert result.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result.get("title") == "My Uptime" + assert result.get("data") == {} diff --git a/tests/components/uptime/test_init.py b/tests/components/uptime/test_init.py new file mode 100644 index 00000000000..0f966734550 --- /dev/null +++ b/tests/components/uptime/test_init.py @@ -0,0 +1,55 @@ +"""Tests for the Uptime integration.""" +from unittest.mock import AsyncMock + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.uptime.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Uptime configuration entry loading/unloading.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert not hass.data.get(DOMAIN) + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_import_config( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test Uptime being set up from config via import.""" + assert await async_setup_component( + hass, + SENSOR_DOMAIN, + { + SENSOR_DOMAIN: { + "platform": DOMAIN, + CONF_NAME: "My Uptime", + } + }, + ) + await hass.async_block_till_done() + + config_entries = hass.config_entries.async_entries(DOMAIN) + assert len(config_entries) == 1 + + entry = config_entries[0] + assert entry.title == "My Uptime" + assert entry.unique_id is None + assert entry.data == {} diff --git a/tests/components/uptime/test_sensor.py b/tests/components/uptime/test_sensor.py index fe3ae30a843..e8d0306246f 100644 --- a/tests/components/uptime/test_sensor.py +++ b/tests/components/uptime/test_sensor.py @@ -1,11 +1,27 @@ """The tests for the uptime sensor platform.""" +import pytest -from homeassistant.setup import async_setup_component +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import ATTR_DEVICE_CLASS +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry -async def test_uptime_sensor_name_change(hass): - """Test uptime sensor with different name.""" - config = {"sensor": {"platform": "uptime", "name": "foobar"}} - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() - assert hass.states.get("sensor.foobar") +@pytest.mark.freeze_time("2022-03-01 00:00:00+00:00") +async def test_uptime_sensor( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Test Uptime sensor.""" + state = hass.states.get("sensor.uptime") + assert state + assert state.state == "2022-03-01T00:00:00+00:00" + assert state.attributes["friendly_name"] == "Uptime" + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TIMESTAMP + + entity_registry = er.async_get(hass) + entry = entity_registry.async_get("sensor.uptime") + assert entry + assert entry.unique_id == init_integration.entry_id