From 761067559d42db1658fe6a7975261fb0233b550d Mon Sep 17 00:00:00 2001 From: Marcio Granzotto Rodrigues Date: Sun, 9 Aug 2020 15:15:56 -0300 Subject: [PATCH] Add Nightscout integration (#38615) * Implement NightScout sensor integration * Add tests for NightScout integration * Fix Nightscout captalization * Change quality scale for Nightscout * Trigger actions * Add missing tests * Fix stale comments * Fix Nightscout manufacturer * Add entry type service * Change host to URL on nightscout config flow * Add ConfigEntryNotReady exception to nighscout init * Remote platform_schema from nightscout sensor * Update homeassistant/components/nightscout/config_flow.py Co-authored-by: Chris Talkington Co-authored-by: Chris Talkington --- CODEOWNERS | 1 + .../components/nightscout/__init__.py | 73 ++++++++++ .../components/nightscout/config_flow.py | 58 ++++++++ homeassistant/components/nightscout/const.py | 9 ++ .../components/nightscout/manifest.json | 13 ++ homeassistant/components/nightscout/sensor.py | 126 ++++++++++++++++++ .../components/nightscout/strings.json | 16 +++ .../nightscout/translations/ca.json | 15 +++ .../nightscout/translations/de.json | 15 +++ .../nightscout/translations/en.json | 15 +++ .../nightscout/translations/es.json | 15 +++ .../nightscout/translations/fr.json | 15 +++ .../nightscout/translations/it.json | 15 +++ .../nightscout/translations/ko.json | 15 +++ .../nightscout/translations/lb.json | 14 ++ .../nightscout/translations/pl.json | 15 +++ .../nightscout/translations/pt.json | 15 +++ .../nightscout/translations/ru.json | 15 +++ .../nightscout/translations/sl.json | 15 +++ .../nightscout/translations/zh-Hant.json | 15 +++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/nightscout/__init__.py | 74 ++++++++++ .../components/nightscout/test_config_flow.py | 85 ++++++++++++ tests/components/nightscout/test_init.py | 43 ++++++ tests/components/nightscout/test_sensor.py | 60 +++++++++ 27 files changed, 759 insertions(+) create mode 100644 homeassistant/components/nightscout/__init__.py create mode 100644 homeassistant/components/nightscout/config_flow.py create mode 100644 homeassistant/components/nightscout/const.py create mode 100644 homeassistant/components/nightscout/manifest.json create mode 100644 homeassistant/components/nightscout/sensor.py create mode 100644 homeassistant/components/nightscout/strings.json create mode 100644 homeassistant/components/nightscout/translations/ca.json create mode 100644 homeassistant/components/nightscout/translations/de.json create mode 100644 homeassistant/components/nightscout/translations/en.json create mode 100644 homeassistant/components/nightscout/translations/es.json create mode 100644 homeassistant/components/nightscout/translations/fr.json create mode 100644 homeassistant/components/nightscout/translations/it.json create mode 100644 homeassistant/components/nightscout/translations/ko.json create mode 100644 homeassistant/components/nightscout/translations/lb.json create mode 100644 homeassistant/components/nightscout/translations/pl.json create mode 100644 homeassistant/components/nightscout/translations/pt.json create mode 100644 homeassistant/components/nightscout/translations/ru.json create mode 100644 homeassistant/components/nightscout/translations/sl.json create mode 100644 homeassistant/components/nightscout/translations/zh-Hant.json create mode 100644 tests/components/nightscout/__init__.py create mode 100644 tests/components/nightscout/test_config_flow.py create mode 100644 tests/components/nightscout/test_init.py create mode 100644 tests/components/nightscout/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 0081057f086..3a8bfaa4842 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -272,6 +272,7 @@ homeassistant/components/netdata/* @fabaff homeassistant/components/nexia/* @ryannazaretian @bdraco homeassistant/components/nextbus/* @vividboarder homeassistant/components/nextcloud/* @meichthys +homeassistant/components/nightscout/* @marciogranzotto homeassistant/components/nilu/* @hfurubotten homeassistant/components/nissan_leaf/* @filcole homeassistant/components/nmbs/* @thibmaek diff --git a/homeassistant/components/nightscout/__init__.py b/homeassistant/components/nightscout/__init__.py new file mode 100644 index 00000000000..91cf056689c --- /dev/null +++ b/homeassistant/components/nightscout/__init__.py @@ -0,0 +1,73 @@ +"""The Nightscout integration.""" +import asyncio +from asyncio import TimeoutError as AsyncIOTimeoutError +import logging + +from aiohttp import ClientError +from py_nightscout import Api as NightscoutAPI + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity import SLOW_UPDATE_WARNING + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) +PLATFORMS = ["sensor"] +_API_TIMEOUT = SLOW_UPDATE_WARNING - 1 + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Nightscout component.""" + hass.data.setdefault(DOMAIN, {}) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Nightscout from a config entry.""" + server_url = entry.data[CONF_URL] + + api = NightscoutAPI(server_url) + try: + status = await api.get_server_status() + except (ClientError, AsyncIOTimeoutError, OSError) as error: + raise ConfigEntryNotReady from error + + hass.data[DOMAIN][entry.entry_id] = api + + device_registry = await dr.async_get_registry(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, server_url)}, + manufacturer="Nightscout Foundation", + name=status.name, + sw_version=status.version, + entry_type="service", + ) + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/nightscout/config_flow.py b/homeassistant/components/nightscout/config_flow.py new file mode 100644 index 00000000000..c54293798ea --- /dev/null +++ b/homeassistant/components/nightscout/config_flow.py @@ -0,0 +1,58 @@ +"""Config flow for Nightscout integration.""" +from asyncio import TimeoutError as AsyncIOTimeoutError +import logging + +from aiohttp import ClientError +from py_nightscout import Api as NightscoutAPI +import voluptuous as vol + +from homeassistant import config_entries, exceptions +from homeassistant.const import CONF_URL + +from .const import DOMAIN # pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema({vol.Required(CONF_URL): str}) + + +async def _validate_input(data): + """Validate the user input allows us to connect.""" + + try: + api = NightscoutAPI(data[CONF_URL]) + status = await api.get_server_status() + except (ClientError, AsyncIOTimeoutError, OSError): + raise CannotConnect + + # Return info to be stored in the config entry. + return {"title": status.name} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Nightscout.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + try: + info = await _validate_input(user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/nightscout/const.py b/homeassistant/components/nightscout/const.py new file mode 100644 index 00000000000..f07f37b7b0c --- /dev/null +++ b/homeassistant/components/nightscout/const.py @@ -0,0 +1,9 @@ +"""Constants for the Nightscout integration.""" + +DOMAIN = "nightscout" + +ATTR_DEVICE = "device" +ATTR_DATE = "date" +ATTR_SVG = "svg" +ATTR_DELTA = "delta" +ATTR_DIRECTION = "direction" diff --git a/homeassistant/components/nightscout/manifest.json b/homeassistant/components/nightscout/manifest.json new file mode 100644 index 00000000000..b3e9b3a0d55 --- /dev/null +++ b/homeassistant/components/nightscout/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "nightscout", + "name": "Nightscout", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/nightscout", + "requirements": [ + "py-nightscout==1.2.1" + ], + "codeowners": [ + "@marciogranzotto" + ], + "quality_scale": "platinum" +} \ No newline at end of file diff --git a/homeassistant/components/nightscout/sensor.py b/homeassistant/components/nightscout/sensor.py new file mode 100644 index 00000000000..fce967b60d5 --- /dev/null +++ b/homeassistant/components/nightscout/sensor.py @@ -0,0 +1,126 @@ +"""Support for Nightscout sensors.""" +from asyncio import TimeoutError as AsyncIOTimeoutError +from datetime import timedelta +import hashlib +import logging +from typing import Callable, List + +from aiohttp import ClientError +from py_nightscout import Api as NightscoutAPI + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity + +from .const import ATTR_DATE, ATTR_DELTA, ATTR_DEVICE, ATTR_DIRECTION, ATTR_SVG, DOMAIN + +SCAN_INTERVAL = timedelta(minutes=1) + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "Blood Glucose" + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: Callable[[List[Entity], bool], None], +) -> None: + """Set up the Glucose Sensor.""" + api = hass.data[DOMAIN][entry.entry_id] + async_add_entities([NightscoutSensor(api, "Blood Sugar")], True) + + +class NightscoutSensor(Entity): + """Implementation of a Nightscout sensor.""" + + def __init__(self, api: NightscoutAPI, name): + """Initialize the Nightscout sensor.""" + self.api = api + self._unique_id = hashlib.sha256(api.server_url.encode("utf-8")).hexdigest() + self._name = name + self._state = None + self._attributes = None + self._unit_of_measurement = "mg/dL" + self._icon = "mdi:cloud-question" + self._available = False + + @property + def unique_id(self): + """Return the unique ID of the sensor.""" + return self._unique_id + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit_of_measurement + + @property + def available(self): + """Return if the sensor data are available.""" + return self._available + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return self._icon + + @property + def should_poll(self): + """Return the polling state.""" + return True + + async def async_update(self): + """Fetch the latest data from Nightscout REST API and update the state.""" + try: + values = await self.api.get_sgvs() + except (ClientError, AsyncIOTimeoutError, OSError) as error: + _LOGGER.error("Error fetching data. Failed with %s", error) + self._available = False + return + + self._available = True + self._attributes = {} + self._state = None + if values: + value = values[0] + self._attributes = { + ATTR_DEVICE: value.device, + ATTR_DATE: value.date, + ATTR_SVG: value.sgv, + ATTR_DELTA: value.delta, + ATTR_DIRECTION: value.direction, + } + self._state = value.sgv + self._icon = self._parse_icon() + else: + self._available = False + _LOGGER.warning("Empty reply found when expecting JSON data") + + def _parse_icon(self) -> str: + """Update the icon based on the direction attribute.""" + switcher = { + "Flat": "mdi:arrow-right", + "SingleDown": "mdi:arrow-down", + "FortyFiveDown": "mdi:arrow-bottom-right", + "DoubleDown": "mdi:chevron-triple-down", + "SingleUp": "mdi:arrow-up", + "FortyFiveUp": "mdi:arrow-top-right", + "DoubleUp": "mdi:chevron-triple-up", + } + return switcher.get(self._attributes[ATTR_DIRECTION], "mdi:cloud-question") + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._attributes diff --git a/homeassistant/components/nightscout/strings.json b/homeassistant/components/nightscout/strings.json new file mode 100644 index 00000000000..08b2bf09361 --- /dev/null +++ b/homeassistant/components/nightscout/strings.json @@ -0,0 +1,16 @@ +{ + "config": { + "flow_title": "Nightscout", + "step": { + "user": { + "data": { + "url": "URL" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nightscout/translations/ca.json b/homeassistant/components/nightscout/translations/ca.json new file mode 100644 index 00000000000..d9b06cbe61e --- /dev/null +++ b/homeassistant/components/nightscout/translations/ca.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "url": "URL" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nightscout/translations/de.json b/homeassistant/components/nightscout/translations/de.json new file mode 100644 index 00000000000..9c76dd92f9a --- /dev/null +++ b/homeassistant/components/nightscout/translations/de.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Verbindung nicht m\u00f6glich", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "url": "URL" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nightscout/translations/en.json b/homeassistant/components/nightscout/translations/en.json new file mode 100644 index 00000000000..c67479819ea --- /dev/null +++ b/homeassistant/components/nightscout/translations/en.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Failed to connect", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "url": "URL" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nightscout/translations/es.json b/homeassistant/components/nightscout/translations/es.json new file mode 100644 index 00000000000..05545cdbc48 --- /dev/null +++ b/homeassistant/components/nightscout/translations/es.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "No se pudo conectar", + "unknown": "Error inesperado" + }, + "step": { + "user": { + "data": { + "url": "URL" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nightscout/translations/fr.json b/homeassistant/components/nightscout/translations/fr.json new file mode 100644 index 00000000000..037992b12a3 --- /dev/null +++ b/homeassistant/components/nightscout/translations/fr.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Echec de connexion", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "url": "URL" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nightscout/translations/it.json b/homeassistant/components/nightscout/translations/it.json new file mode 100644 index 00000000000..2f0790586f3 --- /dev/null +++ b/homeassistant/components/nightscout/translations/it.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Impossibile connettersi", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "url": "URL" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nightscout/translations/ko.json b/homeassistant/components/nightscout/translations/ko.json new file mode 100644 index 00000000000..66c2c8822b2 --- /dev/null +++ b/homeassistant/components/nightscout/translations/ko.json @@ -0,0 +1,15 @@ +{ + "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" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nightscout/translations/lb.json b/homeassistant/components/nightscout/translations/lb.json new file mode 100644 index 00000000000..ca7db64f416 --- /dev/null +++ b/homeassistant/components/nightscout/translations/lb.json @@ -0,0 +1,14 @@ +{ + "config": { + "error": { + "unknown": "Onerwaarte Feeler" + }, + "step": { + "user": { + "data": { + "url": "URL" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nightscout/translations/pl.json b/homeassistant/components/nightscout/translations/pl.json new file mode 100644 index 00000000000..bf0d9900695 --- /dev/null +++ b/homeassistant/components/nightscout/translations/pl.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia.", + "unknown": "Nieoczekiwany b\u0142\u0105d." + }, + "step": { + "user": { + "data": { + "url": "URL" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nightscout/translations/pt.json b/homeassistant/components/nightscout/translations/pt.json new file mode 100644 index 00000000000..218e7941b42 --- /dev/null +++ b/homeassistant/components/nightscout/translations/pt.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Falha na liga\u00e7\u00e3o", + "unknown": "Erro inesperado" + }, + "step": { + "user": { + "data": { + "url": "URL" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nightscout/translations/ru.json b/homeassistant/components/nightscout/translations/ru.json new file mode 100644 index 00000000000..a7bd268d56a --- /dev/null +++ b/homeassistant/components/nightscout/translations/ru.json @@ -0,0 +1,15 @@ +{ + "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" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nightscout/translations/sl.json b/homeassistant/components/nightscout/translations/sl.json new file mode 100644 index 00000000000..33b65a99f8a --- /dev/null +++ b/homeassistant/components/nightscout/translations/sl.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Povezava ni uspela", + "unknown": "Nepri\u010dakovana napaka" + }, + "step": { + "user": { + "data": { + "url": "URL" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nightscout/translations/zh-Hant.json b/homeassistant/components/nightscout/translations/zh-Hant.json new file mode 100644 index 00000000000..cf83adfc35a --- /dev/null +++ b/homeassistant/components/nightscout/translations/zh-Hant.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "url": "\u7db2\u5740" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 3b4216377e5..1bf776f0849 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -114,6 +114,7 @@ FLOWS = [ "nest", "netatmo", "nexia", + "nightscout", "notion", "nuheat", "nut", diff --git a/requirements_all.txt b/requirements_all.txt index 6f8b98f14d9..8549dddf313 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1161,6 +1161,9 @@ py-cpuinfo==5.0.0 # homeassistant.components.melissa py-melissa-climate==2.0.0 +# homeassistant.components.nightscout +py-nightscout==1.2.1 + # homeassistant.components.schluter py-schluter==0.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 71506e12d0f..6305c3ae1e4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -548,6 +548,9 @@ py-canary==0.5.0 # homeassistant.components.melissa py-melissa-climate==2.0.0 +# homeassistant.components.nightscout +py-nightscout==1.2.1 + # homeassistant.components.seventeentrack py17track==2.2.2 diff --git a/tests/components/nightscout/__init__.py b/tests/components/nightscout/__init__.py new file mode 100644 index 00000000000..de1bb8a2b7c --- /dev/null +++ b/tests/components/nightscout/__init__.py @@ -0,0 +1,74 @@ +"""Tests for the Nightscout integration.""" +import json + +from aiohttp import ClientConnectionError +from py_nightscout.models import SGV, ServerStatus + +from homeassistant.components.nightscout.const import DOMAIN +from homeassistant.const import CONF_URL + +from tests.async_mock import patch +from tests.common import MockConfigEntry + +GLUCOSE_READINGS = [ + SGV.new_from_json_dict( + json.loads( + '{"_id":"5f2b01f5c3d0ac7c4090e223","device":"xDrip-LimiTTer","date":1596654066533,"dateString":"2020-08-05T19:01:06.533Z","sgv":169,"delta":-5.257,"direction":"FortyFiveDown","type":"sgv","filtered":182823.5157,"unfiltered":182823.5157,"rssi":100,"noise":1,"sysTime":"2020-08-05T19:01:06.533Z","utcOffset":-180}' + ) + ) +] +SERVER_STATUS = ServerStatus.new_from_json_dict( + json.loads( + '{"status":"ok","name":"nightscout","version":"13.0.1","serverTime":"2020-08-05T18:14:02.032Z","serverTimeEpoch":1596651242032,"apiEnabled":true,"careportalEnabled":true,"boluscalcEnabled":true,"settings":{},"extendedSettings":{},"authorized":null}' + ) +) + + +async def init_integration(hass) -> MockConfigEntry: + """Set up the Nightscout integration in Home Assistant.""" + entry = MockConfigEntry(domain=DOMAIN, data={CONF_URL: "https://some.url:1234"},) + with patch( + "homeassistant.components.nightscout.NightscoutAPI.get_sgvs", + return_value=GLUCOSE_READINGS, + ), patch( + "homeassistant.components.nightscout.NightscoutAPI.get_server_status", + return_value=SERVER_STATUS, + ): + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry + + +async def init_integration_unavailable(hass) -> MockConfigEntry: + """Set up the Nightscout integration in Home Assistant.""" + entry = MockConfigEntry(domain=DOMAIN, data={CONF_URL: "https://some.url:1234"},) + with patch( + "homeassistant.components.nightscout.NightscoutAPI.get_sgvs", + side_effect=ClientConnectionError(), + ), patch( + "homeassistant.components.nightscout.NightscoutAPI.get_server_status", + return_value=SERVER_STATUS, + ): + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry + + +async def init_integration_empty_response(hass) -> MockConfigEntry: + """Set up the Nightscout integration in Home Assistant.""" + entry = MockConfigEntry(domain=DOMAIN, data={CONF_URL: "https://some.url:1234"},) + with patch( + "homeassistant.components.nightscout.NightscoutAPI.get_sgvs", return_value=[] + ), patch( + "homeassistant.components.nightscout.NightscoutAPI.get_server_status", + return_value=SERVER_STATUS, + ): + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/nightscout/test_config_flow.py b/tests/components/nightscout/test_config_flow.py new file mode 100644 index 00000000000..9db86759658 --- /dev/null +++ b/tests/components/nightscout/test_config_flow.py @@ -0,0 +1,85 @@ +"""Test the Nightscout config flow.""" +from aiohttp import ClientConnectionError + +from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.nightscout.const import DOMAIN +from homeassistant.const import CONF_URL + +from tests.async_mock import patch +from tests.components.nightscout import GLUCOSE_READINGS, SERVER_STATUS + +CONFIG = {CONF_URL: "https://some.url:1234"} + + +async def test_form(hass): + """Test we get the user initiated form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "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( + result["flow_id"], CONFIG, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == SERVER_STATUS.name # pylint: disable=maybe-no-member + assert result2["data"] == CONFIG + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.nightscout.NightscoutAPI.get_server_status", + side_effect=ClientConnectionError(), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_URL: "https://some.url:1234"}, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_user_form_unexpected_exception(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.nightscout.NightscoutAPI.get_server_status", + side_effect=Exception(), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_URL: "https://some.url:1234"}, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} + + +def _patch_async_setup(): + return patch("homeassistant.components.nightscout.async_setup", return_value=True) + + +def _patch_async_setup_entry(): + return patch( + "homeassistant.components.nightscout.async_setup_entry", return_value=True, + ) diff --git a/tests/components/nightscout/test_init.py b/tests/components/nightscout/test_init.py new file mode 100644 index 00000000000..94953b7e5b2 --- /dev/null +++ b/tests/components/nightscout/test_init.py @@ -0,0 +1,43 @@ +"""Test the Nightscout config flow.""" +from aiohttp import ClientError + +from homeassistant.components.nightscout.const import DOMAIN +from homeassistant.config_entries import ( + ENTRY_STATE_LOADED, + ENTRY_STATE_NOT_LOADED, + ENTRY_STATE_SETUP_RETRY, +) +from homeassistant.const import CONF_URL + +from tests.async_mock import patch +from tests.common import MockConfigEntry +from tests.components.nightscout import init_integration + + +async def test_unload_entry(hass): + """Test successful unload of entry.""" + entry = await init_integration(hass) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state == ENTRY_STATE_LOADED + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ENTRY_STATE_NOT_LOADED + assert not hass.data.get(DOMAIN) + + +async def test_async_setup_raises_entry_not_ready(hass): + """Test that it throws ConfigEntryNotReady when exception occurs during setup.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_URL: "https://some.url:1234"}, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.nightscout.NightscoutAPI.get_server_status", + side_effect=ClientError(), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state == ENTRY_STATE_SETUP_RETRY diff --git a/tests/components/nightscout/test_sensor.py b/tests/components/nightscout/test_sensor.py new file mode 100644 index 00000000000..c2fcfe543e7 --- /dev/null +++ b/tests/components/nightscout/test_sensor.py @@ -0,0 +1,60 @@ +"""The sensor tests for the Nightscout platform.""" + +from homeassistant.components.nightscout.const import ( + ATTR_DATE, + ATTR_DELTA, + ATTR_DEVICE, + ATTR_DIRECTION, + ATTR_SVG, +) +from homeassistant.const import ATTR_ICON, STATE_UNAVAILABLE + +from tests.components.nightscout import ( + GLUCOSE_READINGS, + init_integration, + init_integration_empty_response, + init_integration_unavailable, +) + + +async def test_sensor_state(hass): + """Test sensor state data.""" + await init_integration(hass) + + test_glucose_sensor = hass.states.get("sensor.blood_sugar") + assert test_glucose_sensor.state == str( + GLUCOSE_READINGS[0].sgv # pylint: disable=maybe-no-member + ) + + +async def test_sensor_error(hass): + """Test sensor state data.""" + await init_integration_unavailable(hass) + + test_glucose_sensor = hass.states.get("sensor.blood_sugar") + assert test_glucose_sensor.state == STATE_UNAVAILABLE + + +async def test_sensor_empty_response(hass): + """Test sensor state data.""" + await init_integration_empty_response(hass) + + test_glucose_sensor = hass.states.get("sensor.blood_sugar") + assert test_glucose_sensor.state == STATE_UNAVAILABLE + + +async def test_sensor_attributes(hass): + """Test sensor attributes.""" + await init_integration(hass) + + test_glucose_sensor = hass.states.get("sensor.blood_sugar") + reading = GLUCOSE_READINGS[0] + assert reading is not None + + attr = test_glucose_sensor.attributes + assert attr[ATTR_DATE] == reading.date # 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_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"