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 <chris@talkingtontech.com>

Co-authored-by: Chris Talkington <chris@talkingtontech.com>
This commit is contained in:
Marcio Granzotto Rodrigues 2020-08-09 15:15:56 -03:00 committed by GitHub
parent 96d48c309f
commit 761067559d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 759 additions and 0 deletions

View file

@ -272,6 +272,7 @@ homeassistant/components/netdata/* @fabaff
homeassistant/components/nexia/* @ryannazaretian @bdraco homeassistant/components/nexia/* @ryannazaretian @bdraco
homeassistant/components/nextbus/* @vividboarder homeassistant/components/nextbus/* @vividboarder
homeassistant/components/nextcloud/* @meichthys homeassistant/components/nextcloud/* @meichthys
homeassistant/components/nightscout/* @marciogranzotto
homeassistant/components/nilu/* @hfurubotten homeassistant/components/nilu/* @hfurubotten
homeassistant/components/nissan_leaf/* @filcole homeassistant/components/nissan_leaf/* @filcole
homeassistant/components/nmbs/* @thibmaek homeassistant/components/nmbs/* @thibmaek

View file

@ -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

View file

@ -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."""

View file

@ -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"

View file

@ -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"
}

View file

@ -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

View file

@ -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%]"
}
}
}

View file

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

View file

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

View file

@ -0,0 +1,15 @@
{
"config": {
"error": {
"cannot_connect": "Failed to connect",
"unknown": "Unexpected error"
},
"step": {
"user": {
"data": {
"url": "URL"
}
}
}
}
}

View file

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

View file

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

View file

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

View file

@ -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"
}
}
}
}
}

View file

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

View file

@ -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"
}
}
}
}
}

View file

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

View file

@ -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"
}
}
}
}
}

View file

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

View file

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

View file

@ -114,6 +114,7 @@ FLOWS = [
"nest", "nest",
"netatmo", "netatmo",
"nexia", "nexia",
"nightscout",
"notion", "notion",
"nuheat", "nuheat",
"nut", "nut",

View file

@ -1161,6 +1161,9 @@ py-cpuinfo==5.0.0
# homeassistant.components.melissa # homeassistant.components.melissa
py-melissa-climate==2.0.0 py-melissa-climate==2.0.0
# homeassistant.components.nightscout
py-nightscout==1.2.1
# homeassistant.components.schluter # homeassistant.components.schluter
py-schluter==0.1.7 py-schluter==0.1.7

View file

@ -548,6 +548,9 @@ py-canary==0.5.0
# homeassistant.components.melissa # homeassistant.components.melissa
py-melissa-climate==2.0.0 py-melissa-climate==2.0.0
# homeassistant.components.nightscout
py-nightscout==1.2.1
# homeassistant.components.seventeentrack # homeassistant.components.seventeentrack
py17track==2.2.2 py17track==2.2.2

View file

@ -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

View file

@ -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,
)

View file

@ -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

View file

@ -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"