Nightscout PR fixes (#38737)

* Don't allow duplicate nightscout configs

* Fix nightscout translations

* Remove unnecessary should_poll method

* Remove SVG attribute, as it was duplicating the state

* Use aiohttp client session from HA

* Move validate_input outside the config class

* Use the entry unique_id on the sensor

* Move create entity logic

* Handle unexpected exception on Nightscout config
This commit is contained in:
Marcio Granzotto Rodrigues 2020-08-13 08:46:07 -03:00 committed by GitHub
parent 86aa758ecd
commit 52a9921ed3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 74 additions and 215 deletions

View File

@ -11,6 +11,7 @@ from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity import SLOW_UPDATE_WARNING from homeassistant.helpers.entity import SLOW_UPDATE_WARNING
from .const import DOMAIN from .const import DOMAIN
@ -29,8 +30,8 @@ async def async_setup(hass: HomeAssistant, config: dict):
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up Nightscout from a config entry.""" """Set up Nightscout from a config entry."""
server_url = entry.data[CONF_URL] server_url = entry.data[CONF_URL]
session = async_get_clientsession(hass)
api = NightscoutAPI(server_url) api = NightscoutAPI(server_url, session=session)
try: try:
status = await api.get_server_status() status = await api.get_server_status()
except (ClientError, AsyncIOTimeoutError, OSError) as error: except (ClientError, AsyncIOTimeoutError, OSError) as error:

View File

@ -10,6 +10,7 @@ from homeassistant import config_entries, exceptions
from homeassistant.const import CONF_URL from homeassistant.const import CONF_URL
from .const import DOMAIN # pylint:disable=unused-import from .const import DOMAIN # pylint:disable=unused-import
from .utils import hash_from_url
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -18,12 +19,12 @@ DATA_SCHEMA = vol.Schema({vol.Required(CONF_URL): str})
async def _validate_input(data): async def _validate_input(data):
"""Validate the user input allows us to connect.""" """Validate the user input allows us to connect."""
url = data[CONF_URL]
try: try:
api = NightscoutAPI(data[CONF_URL]) api = NightscoutAPI(url)
status = await api.get_server_status() status = await api.get_server_status()
except (ClientError, AsyncIOTimeoutError, OSError): except (ClientError, AsyncIOTimeoutError, OSError):
raise CannotConnect raise InputValidationError("cannot_connect")
# Return info to be stored in the config entry. # Return info to be stored in the config entry.
return {"title": status.name} return {"title": status.name}
@ -38,11 +39,15 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
async def async_step_user(self, user_input=None): async def async_step_user(self, user_input=None):
"""Handle the initial step.""" """Handle the initial step."""
errors = {} errors = {}
if user_input is not None: if user_input is not None:
unique_id = hash_from_url(user_input[CONF_URL])
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
try: try:
info = await _validate_input(user_input) info = await _validate_input(user_input)
except CannotConnect: except InputValidationError as error:
errors["base"] = "cannot_connect" errors["base"] = error.base
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception") _LOGGER.exception("Unexpected exception")
errors["base"] = "unknown" errors["base"] = "unknown"
@ -54,5 +59,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
) )
class CannotConnect(exceptions.HomeAssistantError): class InputValidationError(exceptions.HomeAssistantError):
"""Error to indicate we cannot connect.""" """Error to indicate we cannot proceed due to invalid input."""
def __init__(self, base: str):
"""Initialize with error base."""
super().__init__()
self.base = base

View File

@ -4,6 +4,5 @@ DOMAIN = "nightscout"
ATTR_DEVICE = "device" ATTR_DEVICE = "device"
ATTR_DATE = "date" ATTR_DATE = "date"
ATTR_SVG = "svg"
ATTR_DELTA = "delta" ATTR_DELTA = "delta"
ATTR_DIRECTION = "direction" ATTR_DIRECTION = "direction"

View File

@ -1,7 +1,6 @@
"""Support for Nightscout sensors.""" """Support for Nightscout sensors."""
from asyncio import TimeoutError as AsyncIOTimeoutError from asyncio import TimeoutError as AsyncIOTimeoutError
from datetime import timedelta from datetime import timedelta
import hashlib
import logging import logging
from typing import Callable, List from typing import Callable, List
@ -12,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from .const import ATTR_DATE, ATTR_DELTA, ATTR_DEVICE, ATTR_DIRECTION, ATTR_SVG, DOMAIN from .const import ATTR_DATE, ATTR_DELTA, ATTR_DEVICE, ATTR_DIRECTION, DOMAIN
SCAN_INTERVAL = timedelta(minutes=1) SCAN_INTERVAL = timedelta(minutes=1)
@ -28,16 +27,16 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up the Glucose Sensor.""" """Set up the Glucose Sensor."""
api = hass.data[DOMAIN][entry.entry_id] api = hass.data[DOMAIN][entry.entry_id]
async_add_entities([NightscoutSensor(api, "Blood Sugar")], True) async_add_entities([NightscoutSensor(api, "Blood Sugar", entry.unique_id)], True)
class NightscoutSensor(Entity): class NightscoutSensor(Entity):
"""Implementation of a Nightscout sensor.""" """Implementation of a Nightscout sensor."""
def __init__(self, api: NightscoutAPI, name): def __init__(self, api: NightscoutAPI, name, unique_id):
"""Initialize the Nightscout sensor.""" """Initialize the Nightscout sensor."""
self.api = api self.api = api
self._unique_id = hashlib.sha256(api.server_url.encode("utf-8")).hexdigest() self._unique_id = unique_id
self._name = name self._name = name
self._state = None self._state = None
self._attributes = None self._attributes = None
@ -75,11 +74,6 @@ class NightscoutSensor(Entity):
"""Return the icon to use in the frontend, if any.""" """Return the icon to use in the frontend, if any."""
return self._icon return self._icon
@property
def should_poll(self):
"""Return the polling state."""
return True
async def async_update(self): async def async_update(self):
"""Fetch the latest data from Nightscout REST API and update the state.""" """Fetch the latest data from Nightscout REST API and update the state."""
try: try:
@ -97,7 +91,6 @@ class NightscoutSensor(Entity):
self._attributes = { self._attributes = {
ATTR_DEVICE: value.device, ATTR_DEVICE: value.device,
ATTR_DATE: value.date, ATTR_DATE: value.date,
ATTR_SVG: value.sgv,
ATTR_DELTA: value.delta, ATTR_DELTA: value.delta,
ATTR_DIRECTION: value.direction, ATTR_DIRECTION: value.direction,
} }

View File

@ -11,6 +11,9 @@
"error": { "error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
} }
} }
} }

View File

@ -1,15 +0,0 @@
{
"config": {
"error": {
"cannot_connect": "Ha fallat la connexi\u00f3",
"unknown": "Error inesperat"
},
"step": {
"user": {
"data": {
"url": "URL"
}
}
}
}
}

View File

@ -1,15 +0,0 @@
{
"config": {
"error": {
"cannot_connect": "Verbindung nicht m\u00f6glich",
"unknown": "Unerwarteter Fehler"
},
"step": {
"user": {
"data": {
"url": "URL"
}
}
}
}
}

View File

@ -1,9 +1,13 @@
{ {
"config": { "config": {
"error": { "abort": {
"cannot_connect": "Failed to connect", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
"unknown": "Unexpected error"
}, },
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"flow_title": "Nightscout",
"step": { "step": {
"user": { "user": {
"data": { "data": {

View File

@ -1,15 +0,0 @@
{
"config": {
"error": {
"cannot_connect": "No se pudo conectar",
"unknown": "Error inesperado"
},
"step": {
"user": {
"data": {
"url": "URL"
}
}
}
}
}

View File

@ -1,15 +0,0 @@
{
"config": {
"error": {
"cannot_connect": "Echec de connexion",
"unknown": "Erreur inattendue"
},
"step": {
"user": {
"data": {
"url": "URL"
}
}
}
}
}

View File

@ -1,15 +0,0 @@
{
"config": {
"error": {
"cannot_connect": "Impossibile connettersi",
"unknown": "Errore imprevisto"
},
"step": {
"user": {
"data": {
"url": "URL"
}
}
}
}
}

View File

@ -1,15 +0,0 @@
{
"config": {
"error": {
"cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4",
"unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4"
},
"step": {
"user": {
"data": {
"url": "URL \uc8fc\uc18c"
}
}
}
}
}

View File

@ -1,14 +0,0 @@
{
"config": {
"error": {
"unknown": "Onerwaarte Feeler"
},
"step": {
"user": {
"data": {
"url": "URL"
}
}
}
}
}

View File

@ -1,15 +0,0 @@
{
"config": {
"error": {
"cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia.",
"unknown": "Nieoczekiwany b\u0142\u0105d."
},
"step": {
"user": {
"data": {
"url": "URL"
}
}
}
}
}

View File

@ -1,15 +0,0 @@
{
"config": {
"error": {
"cannot_connect": "Falha na liga\u00e7\u00e3o",
"unknown": "Erro inesperado"
},
"step": {
"user": {
"data": {
"url": "URL"
}
}
}
}
}

View File

@ -1,15 +0,0 @@
{
"config": {
"error": {
"cannot_connect": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f.",
"unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430."
},
"step": {
"user": {
"data": {
"url": "URL-\u0430\u0434\u0440\u0435\u0441"
}
}
}
}
}

View File

@ -1,15 +0,0 @@
{
"config": {
"error": {
"cannot_connect": "Povezava ni uspela",
"unknown": "Nepri\u010dakovana napaka"
},
"step": {
"user": {
"data": {
"url": "URL"
}
}
}
}
}

View File

@ -1,15 +0,0 @@
{
"config": {
"error": {
"cannot_connect": "\u9023\u7dda\u5931\u6557",
"unknown": "\u672a\u9810\u671f\u932f\u8aa4"
},
"step": {
"user": {
"data": {
"url": "\u7db2\u5740"
}
}
}
}
}

View File

@ -0,0 +1,7 @@
"""Nightscout util functions."""
import hashlib
def hash_from_url(url: str):
"""Hash url to create a unique ID."""
return hashlib.sha256(url.encode("utf-8")).hexdigest()

View File

@ -3,9 +3,11 @@ from aiohttp import ClientConnectionError
from homeassistant import config_entries, data_entry_flow, setup from homeassistant import config_entries, data_entry_flow, setup
from homeassistant.components.nightscout.const import DOMAIN from homeassistant.components.nightscout.const import DOMAIN
from homeassistant.components.nightscout.utils import hash_from_url
from homeassistant.const import CONF_URL from homeassistant.const import CONF_URL
from tests.async_mock import patch from tests.async_mock import patch
from tests.common import MockConfigEntry
from tests.components.nightscout import GLUCOSE_READINGS, SERVER_STATUS from tests.components.nightscout import GLUCOSE_READINGS, SERVER_STATUS
CONFIG = {CONF_URL: "https://some.url:1234"} CONFIG = {CONF_URL: "https://some.url:1234"}
@ -20,13 +22,7 @@ async def test_form(hass):
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {} assert result["errors"] == {}
with patch( with _patch_glucose_readings(), _patch_server_status(), _patch_async_setup() as mock_setup, _patch_async_setup_entry() as mock_setup_entry:
"homeassistant.components.nightscout.NightscoutAPI.get_sgvs",
return_value=GLUCOSE_READINGS,
), patch(
"homeassistant.components.nightscout.NightscoutAPI.get_server_status",
return_value=SERVER_STATUS,
), _patch_async_setup() as mock_setup, _patch_async_setup_entry() as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], CONFIG, result["flow_id"], CONFIG,
) )
@ -53,12 +49,12 @@ async def test_user_form_cannot_connect(hass):
result["flow_id"], {CONF_URL: "https://some.url:1234"}, result["flow_id"], {CONF_URL: "https://some.url:1234"},
) )
assert result2["type"] == "form" assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result2["errors"] == {"base": "cannot_connect"} assert result2["errors"] == {"base": "cannot_connect"}
async def test_user_form_unexpected_exception(hass): async def test_user_form_unexpected_exception(hass):
"""Test we handle cannot connect error.""" """Test we handle unexpected exception."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": config_entries.SOURCE_USER}
) )
@ -71,10 +67,23 @@ async def test_user_form_unexpected_exception(hass):
result["flow_id"], {CONF_URL: "https://some.url:1234"}, result["flow_id"], {CONF_URL: "https://some.url:1234"},
) )
assert result2["type"] == "form" assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result2["errors"] == {"base": "unknown"} assert result2["errors"] == {"base": "unknown"}
async def test_user_form_duplicate(hass):
"""Test duplicate entries."""
with _patch_glucose_readings(), _patch_server_status():
unique_id = hash_from_url(CONFIG[CONF_URL])
entry = MockConfigEntry(domain=DOMAIN, unique_id=unique_id)
await hass.config_entries.async_add(entry)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}, data=CONFIG,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"
def _patch_async_setup(): def _patch_async_setup():
return patch("homeassistant.components.nightscout.async_setup", return_value=True) return patch("homeassistant.components.nightscout.async_setup", return_value=True)
@ -83,3 +92,17 @@ def _patch_async_setup_entry():
return patch( return patch(
"homeassistant.components.nightscout.async_setup_entry", return_value=True, "homeassistant.components.nightscout.async_setup_entry", return_value=True,
) )
def _patch_glucose_readings():
return patch(
"homeassistant.components.nightscout.NightscoutAPI.get_sgvs",
return_value=GLUCOSE_READINGS,
)
def _patch_server_status():
return patch(
"homeassistant.components.nightscout.NightscoutAPI.get_server_status",
return_value=SERVER_STATUS,
)

View File

@ -5,7 +5,6 @@ from homeassistant.components.nightscout.const import (
ATTR_DELTA, ATTR_DELTA,
ATTR_DEVICE, ATTR_DEVICE,
ATTR_DIRECTION, ATTR_DIRECTION,
ATTR_SVG,
) )
from homeassistant.const import ATTR_ICON, STATE_UNAVAILABLE from homeassistant.const import ATTR_ICON, STATE_UNAVAILABLE
@ -56,5 +55,4 @@ async def test_sensor_attributes(hass):
assert attr[ATTR_DELTA] == reading.delta # pylint: disable=maybe-no-member assert attr[ATTR_DELTA] == reading.delta # pylint: disable=maybe-no-member
assert attr[ATTR_DEVICE] == reading.device # pylint: disable=maybe-no-member assert attr[ATTR_DEVICE] == reading.device # pylint: disable=maybe-no-member
assert attr[ATTR_DIRECTION] == reading.direction # pylint: disable=maybe-no-member assert attr[ATTR_DIRECTION] == reading.direction # pylint: disable=maybe-no-member
assert attr[ATTR_SVG] == reading.sgv # pylint: disable=maybe-no-member
assert attr[ATTR_ICON] == "mdi:arrow-bottom-right" assert attr[ATTR_ICON] == "mdi:arrow-bottom-right"