Add switch platform to UptimeRobot (#65394)

* Add switch platfor mto UptimeRobot

* Add tests

* Apply review comment

* review comments part 2

* review comments part 3

* Fix tests after swapping logic on/off

* Fix reauth test

* Check for read-only key

* Fix reauth for switch platform

* mypy

* cleanup

* cleanup part 2

* Fixes + review comments

* Tests

* Apply more review comments

* Required changes

* fix test

* Remove if

* 100% tests coverage

* Check readonly key in config_flow

* Fix strings & translation

* Add guard for 'monitor' keys

* allign tests

* Wrong API key message reworded
This commit is contained in:
Simone Chemelli 2022-03-18 12:18:19 +01:00 committed by GitHub
parent 5bb271c9fb
commit 35261a9089
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 357 additions and 42 deletions

View File

@ -26,9 +26,12 @@ from .const import API_ATTR_OK, COORDINATOR_UPDATE_INTERVAL, DOMAIN, LOGGER, PLA
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up UptimeRobot from a config entry."""
hass.data.setdefault(DOMAIN, {})
uptime_robot_api = UptimeRobot(
entry.data[CONF_API_KEY], async_get_clientsession(hass)
)
key: str = entry.data[CONF_API_KEY]
if key.startswith("ur") or key.startswith("m"):
raise ConfigEntryAuthFailed(
"Wrong API key type detected, use the 'main' API key"
)
uptime_robot_api = UptimeRobot(key, async_get_clientsession(hass))
dev_reg = await async_get_registry(hass)
hass.data[DOMAIN][entry.entry_id] = coordinator = UptimeRobotDataUpdateCoordinator(
@ -58,6 +61,7 @@ class UptimeRobotDataUpdateCoordinator(DataUpdateCoordinator):
"""Data update coordinator for UptimeRobot."""
data: list[UptimeRobotMonitor]
config_entry: ConfigEntry
def __init__(
self,

View File

@ -23,18 +23,16 @@ async def async_setup_entry(
"""Set up the UptimeRobot binary_sensors."""
coordinator: UptimeRobotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
[
UptimeRobotBinarySensor(
coordinator,
BinarySensorEntityDescription(
key=str(monitor.id),
name=monitor.friendly_name,
device_class=BinarySensorDeviceClass.CONNECTIVITY,
),
monitor=monitor,
)
for monitor in coordinator.data
],
UptimeRobotBinarySensor(
coordinator,
BinarySensorEntityDescription(
key=str(monitor.id),
name=monitor.friendly_name,
device_class=BinarySensorDeviceClass.CONNECTIVITY,
),
monitor=monitor,
)
for monitor in coordinator.data
)

View File

@ -34,9 +34,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Validate the user input allows us to connect."""
errors: dict[str, str] = {}
response: UptimeRobotApiResponse | UptimeRobotApiError | None = None
uptime_robot_api = UptimeRobot(
data[CONF_API_KEY], async_get_clientsession(self.hass)
)
key: str = data[CONF_API_KEY]
if key.startswith("ur") or key.startswith("m"):
LOGGER.error("Wrong API key type detected, use the 'main' API key")
errors["base"] = "not_main_key"
return errors, None
uptime_robot_api = UptimeRobot(key, async_get_clientsession(self.hass))
try:
response = await uptime_robot_api.async_get_account_details()

View File

@ -13,7 +13,7 @@ LOGGER: Logger = getLogger(__package__)
COORDINATOR_UPDATE_INTERVAL: timedelta = timedelta(seconds=10)
DOMAIN: Final = "uptimerobot"
PLATFORMS: Final = [Platform.BINARY_SENSOR, Platform.SENSOR]
PLATFORMS: Final = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH]
ATTRIBUTION: Final = "Data provided by UptimeRobot"

View File

@ -5,11 +5,9 @@ from pyuptimerobot import UptimeRobotMonitor
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import DeviceInfo, EntityDescription
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import UptimeRobotDataUpdateCoordinator
from .const import ATTR_TARGET, ATTRIBUTION, DOMAIN
@ -17,10 +15,11 @@ class UptimeRobotEntity(CoordinatorEntity):
"""Base UptimeRobot entity."""
_attr_attribution = ATTRIBUTION
coordinator: UptimeRobotDataUpdateCoordinator
def __init__(
self,
coordinator: DataUpdateCoordinator,
coordinator: UptimeRobotDataUpdateCoordinator,
description: EntityDescription,
monitor: UptimeRobotMonitor,
) -> None:
@ -40,6 +39,7 @@ class UptimeRobotEntity(CoordinatorEntity):
ATTR_TARGET: self.monitor.url,
}
self._attr_unique_id = str(self.monitor.id)
self.api = coordinator.api
@property
def _monitors(self) -> list[UptimeRobotMonitor]:

View File

@ -38,19 +38,17 @@ async def async_setup_entry(
"""Set up the UptimeRobot sensors."""
coordinator: UptimeRobotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
[
UptimeRobotSensor(
coordinator,
SensorEntityDescription(
key=str(monitor.id),
name=monitor.friendly_name,
entity_category=EntityCategory.DIAGNOSTIC,
device_class="uptimerobot__monitor_status",
),
monitor=monitor,
)
for monitor in coordinator.data
],
UptimeRobotSensor(
coordinator,
SensorEntityDescription(
key=str(monitor.id),
name=monitor.friendly_name,
entity_category=EntityCategory.DIAGNOSTIC,
device_class="uptimerobot__monitor_status",
),
monitor=monitor,
)
for monitor in coordinator.data
)

View File

@ -2,14 +2,14 @@
"config": {
"step": {
"user": {
"description": "You need to supply a read-only API key from UptimeRobot",
"description": "You need to supply the 'main' API key from UptimeRobot",
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
}
},
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "You need to supply a new read-only API key from UptimeRobot",
"description": "You need to supply a new 'main' API key from UptimeRobot",
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
}
@ -19,6 +19,7 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
"not_main_key": "Wrong API key type detected, use the 'main' API key",
"reauth_failed_matching_account": "The API key you provided does not match the account ID for existing configuration."
},
"abort": {

View File

@ -0,0 +1,75 @@
"""UptimeRobot switch platform."""
from __future__ import annotations
from typing import Any
from pyuptimerobot import UptimeRobotAuthenticationException, UptimeRobotException
from homeassistant.components.switch import (
SwitchDeviceClass,
SwitchEntity,
SwitchEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import UptimeRobotDataUpdateCoordinator
from .const import API_ATTR_OK, DOMAIN, LOGGER
from .entity import UptimeRobotEntity
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up the UptimeRobot switches."""
coordinator: UptimeRobotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
UptimeRobotSwitch(
coordinator,
SwitchEntityDescription(
key=str(monitor.id),
name=f"{monitor.friendly_name} Active",
device_class=SwitchDeviceClass.SWITCH,
),
monitor=monitor,
)
for monitor in coordinator.data
)
class UptimeRobotSwitch(UptimeRobotEntity, SwitchEntity):
"""Representation of a UptimeRobot switch."""
_attr_icon = "mdi:cog"
@property
def is_on(self) -> bool:
"""Return True if the entity is on."""
return bool(self.monitor.status != 0)
async def _async_edit_monitor(self, **kwargs: Any) -> None:
"""Edit monitor status."""
try:
response = await self.api.async_edit_monitor(**kwargs)
except UptimeRobotAuthenticationException:
LOGGER.debug("API authentication error, calling reauth")
self.coordinator.config_entry.async_start_reauth(self.hass)
return
except UptimeRobotException as exception:
LOGGER.error("API exception: %s", exception)
return
if response.status != API_ATTR_OK:
LOGGER.error("API exception: %s", response.error.message, exc_info=True)
return
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn on switch."""
await self._async_edit_monitor(id=self.monitor.id, status=0)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn off switch."""
await self._async_edit_monitor(id=self.monitor.id, status=1)

View File

@ -9,6 +9,7 @@
"error": {
"cannot_connect": "Failed to connect",
"invalid_api_key": "Invalid API key",
"not_main_key": "Wrong API key type detected, use the 'main' API key",
"reauth_failed_matching_account": "The API key you provided does not match the account ID for existing configuration.",
"unknown": "Unexpected error"
},
@ -17,14 +18,14 @@
"data": {
"api_key": "API Key"
},
"description": "You need to supply a new read-only API key from UptimeRobot",
"description": "You need to supply a new 'main' API key from UptimeRobot",
"title": "Reauthenticate Integration"
},
"user": {
"data": {
"api_key": "API Key"
},
"description": "You need to supply a read-only API key from UptimeRobot"
"description": "You need to supply the 'main' API key from UptimeRobot"
}
}
}

View File

@ -20,7 +20,8 @@ from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
MOCK_UPTIMEROBOT_API_KEY = "0242ac120003"
MOCK_UPTIMEROBOT_API_KEY = "u0242ac120003"
MOCK_UPTIMEROBOT_API_KEY_READ_ONLY = "ur0242ac120003"
MOCK_UPTIMEROBOT_EMAIL = "test@test.test"
MOCK_UPTIMEROBOT_UNIQUE_ID = "1234567890"
@ -37,6 +38,14 @@ MOCK_UPTIMEROBOT_MONITOR = {
"type": 1,
"url": "http://example.com",
}
MOCK_UPTIMEROBOT_MONITOR_PAUSED = {
"id": 1234,
"friendly_name": "Test monitor",
"status": 0,
"type": 1,
"url": "http://example.com",
}
MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA = {
"domain": DOMAIN,
@ -45,11 +54,19 @@ MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA = {
"unique_id": MOCK_UPTIMEROBOT_UNIQUE_ID,
"source": config_entries.SOURCE_USER,
}
MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA_KEY_READ_ONLY = {
"domain": DOMAIN,
"title": MOCK_UPTIMEROBOT_EMAIL,
"data": {"platform": DOMAIN, "api_key": MOCK_UPTIMEROBOT_API_KEY_READ_ONLY},
"unique_id": MOCK_UPTIMEROBOT_UNIQUE_ID,
"source": config_entries.SOURCE_USER,
}
STATE_UP = "up"
UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY = "binary_sensor.test_monitor"
UPTIMEROBOT_SENSOR_TEST_ENTITY = "sensor.test_monitor"
UPTIMEROBOT_SWITCH_TEST_ENTITY = "switch.test_monitor_active"
class MockApiResponseKey(str, Enum):

View File

@ -18,6 +18,7 @@ from homeassistant.data_entry_flow import (
from .common import (
MOCK_UPTIMEROBOT_ACCOUNT,
MOCK_UPTIMEROBOT_API_KEY,
MOCK_UPTIMEROBOT_API_KEY_READ_ONLY,
MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA,
MOCK_UPTIMEROBOT_UNIQUE_ID,
MockApiResponseKey,
@ -56,6 +57,29 @@ async def test_form(hass: HomeAssistant) -> None:
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_read_only(hass: HomeAssistant) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == RESULT_TYPE_FORM
assert result["errors"] is None
with patch(
"pyuptimerobot.UptimeRobot.async_get_account_details",
return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ACCOUNT),
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_API_KEY: MOCK_UPTIMEROBOT_API_KEY_READ_ONLY},
)
await hass.async_block_till_done()
assert result2["type"] == RESULT_TYPE_FORM
assert result2["errors"]["base"] == "not_main_key"
@pytest.mark.parametrize(
"exception,error_key",
[

View File

@ -19,6 +19,7 @@ from homeassistant.util import dt
from .common import (
MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA,
MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA_KEY_READ_ONLY,
MOCK_UPTIMEROBOT_MONITOR,
UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY,
MockApiResponseKey,
@ -62,6 +63,39 @@ async def test_reauthentication_trigger_in_setup(
)
async def test_reauthentication_trigger_key_read_only(
hass: HomeAssistant, caplog: LogCaptureFixture
):
"""Test reauthentication trigger."""
mock_config_entry = MockConfigEntry(
**MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA_KEY_READ_ONLY
)
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
flows = hass.config_entries.flow.async_progress()
assert mock_config_entry.state == config_entries.ConfigEntryState.SETUP_ERROR
assert (
mock_config_entry.reason
== "Wrong API key type detected, use the 'main' API key"
)
assert len(flows) == 1
flow = flows[0]
assert flow["step_id"] == "reauth_confirm"
assert flow["handler"] == DOMAIN
assert flow["context"]["source"] == config_entries.SOURCE_REAUTH
assert flow["context"]["entry_id"] == mock_config_entry.entry_id
assert (
"Config entry 'test@test.test' for uptimerobot integration could not authenticate"
in caplog.text
)
async def test_reauthentication_trigger_after_setup(
hass: HomeAssistant, caplog: LogCaptureFixture
):

View File

@ -0,0 +1,160 @@
"""Test UptimeRobot switch."""
from unittest.mock import patch
from pyuptimerobot import UptimeRobotAuthenticationException
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_OFF,
STATE_ON,
)
from homeassistant.core import HomeAssistant
from .common import (
MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA,
MOCK_UPTIMEROBOT_MONITOR,
MOCK_UPTIMEROBOT_MONITOR_PAUSED,
UPTIMEROBOT_SWITCH_TEST_ENTITY,
MockApiResponseKey,
mock_uptimerobot_api_response,
setup_uptimerobot_integration,
)
from tests.common import MockConfigEntry
async def test_presentation(hass: HomeAssistant) -> None:
"""Test the presenstation of UptimeRobot sensors."""
await setup_uptimerobot_integration(hass)
entity = hass.states.get(UPTIMEROBOT_SWITCH_TEST_ENTITY)
assert entity.state == STATE_ON
assert entity.attributes["icon"] == "mdi:cog"
assert entity.attributes["target"] == MOCK_UPTIMEROBOT_MONITOR["url"]
async def test_switch_off(hass: HomeAssistant) -> None:
"""Test entity unaviable on update failure."""
mock_entry = MockConfigEntry(**MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA)
mock_entry.add_to_hass(hass)
with patch(
"pyuptimerobot.UptimeRobot.async_get_monitors",
return_value=mock_uptimerobot_api_response(
data=[MOCK_UPTIMEROBOT_MONITOR_PAUSED]
),
), patch(
"pyuptimerobot.UptimeRobot.async_edit_monitor",
return_value=mock_uptimerobot_api_response(),
):
assert await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: UPTIMEROBOT_SWITCH_TEST_ENTITY},
blocking=True,
)
entity = hass.states.get(UPTIMEROBOT_SWITCH_TEST_ENTITY)
assert entity.state == STATE_OFF
async def test_switch_on(hass: HomeAssistant) -> None:
"""Test entity unaviable on update failure."""
mock_entry = MockConfigEntry(**MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA)
mock_entry.add_to_hass(hass)
with patch(
"pyuptimerobot.UptimeRobot.async_get_monitors",
return_value=mock_uptimerobot_api_response(data=[MOCK_UPTIMEROBOT_MONITOR]),
), patch(
"pyuptimerobot.UptimeRobot.async_edit_monitor",
return_value=mock_uptimerobot_api_response(),
):
assert await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: UPTIMEROBOT_SWITCH_TEST_ENTITY},
blocking=True,
)
entity = hass.states.get(UPTIMEROBOT_SWITCH_TEST_ENTITY)
assert entity.state == STATE_ON
async def test_authentication_error(hass: HomeAssistant, caplog) -> None:
"""Test authentication error turning switch on/off."""
await setup_uptimerobot_integration(hass)
entity = hass.states.get(UPTIMEROBOT_SWITCH_TEST_ENTITY)
assert entity.state == STATE_ON
with patch(
"pyuptimerobot.UptimeRobot.async_edit_monitor",
side_effect=UptimeRobotAuthenticationException,
), patch(
"homeassistant.config_entries.ConfigEntry.async_start_reauth"
) as config_entry_reauth:
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: UPTIMEROBOT_SWITCH_TEST_ENTITY},
blocking=True,
)
assert config_entry_reauth.assert_called
async def test_refresh_data(hass: HomeAssistant, caplog) -> None:
"""Test authentication error turning switch on/off."""
await setup_uptimerobot_integration(hass)
entity = hass.states.get(UPTIMEROBOT_SWITCH_TEST_ENTITY)
assert entity.state == STATE_ON
with patch(
"homeassistant.helpers.update_coordinator.DataUpdateCoordinator.async_request_refresh"
) as coordinator_refresh:
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: UPTIMEROBOT_SWITCH_TEST_ENTITY},
blocking=True,
)
assert coordinator_refresh.assert_called
async def test_switch_api_failure(hass: HomeAssistant, caplog) -> None:
"""Test general exception turning switch on/off."""
await setup_uptimerobot_integration(hass)
entity = hass.states.get(UPTIMEROBOT_SWITCH_TEST_ENTITY)
assert entity.state == STATE_ON
with patch(
"pyuptimerobot.UptimeRobot.async_edit_monitor",
return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ERROR),
):
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: UPTIMEROBOT_SWITCH_TEST_ENTITY},
blocking=True,
)
assert "API exception" in caplog.text