Add back Netatmo public weather sensors (#34401)

* Add public weather sensors back in

* Remove stale code

* Cleanup after before adding entities

* Fix pylint complaint

* Add test for options flow

* Change mode to listbox

* Update .coveragerc

* Address comments

* Don't process empty list

* Address comment

* Fix mistake

* Make signal unique

* Make string more unique

* Fix merge conflict
This commit is contained in:
cgtobi 2020-07-09 06:39:33 +02:00 committed by GitHub
parent af6a4bb6cf
commit 155a5f7c26
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 394 additions and 67 deletions

View file

@ -538,6 +538,7 @@ omit =
homeassistant/components/netatmo/climate.py
homeassistant/components/netatmo/const.py
homeassistant/components/netatmo/sensor.py
homeassistant/components/netatmo/webhook.py
homeassistant/components/netdata/sensor.py
homeassistant/components/netgear/device_tracker.py
homeassistant/components/netgear_lte/*

View file

@ -92,6 +92,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
hass, entry
)
# Set unique id if non was set (migration)
if not entry.unique_id:
hass.config_entries.async_update_entry(entry, unique_id=DOMAIN)
hass.data[DOMAIN][entry.entry_id] = {
AUTH: api.ConfigEntryNetatmoAuth(hass, entry, implementation)
}

View file

@ -1,10 +1,24 @@
"""Config flow for Netatmo."""
import logging
from homeassistant import config_entries
from homeassistant.helpers import config_entry_oauth2_flow
import voluptuous as vol
from .const import DOMAIN
from homeassistant import config_entries
from homeassistant.const import CONF_SHOW_ON_MAP
from homeassistant.core import callback
from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv
from .const import (
CONF_AREA_NAME,
CONF_LAT_NE,
CONF_LAT_SW,
CONF_LON_NE,
CONF_LON_SW,
CONF_NEW_AREA,
CONF_PUBLIC_MODE,
CONF_WEATHER_AREAS,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
@ -17,6 +31,12 @@ class NetatmoFlowHandler(
DOMAIN = DOMAIN
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
@staticmethod
@callback
def async_get_options_flow(config_entry):
"""Get the options flow for this handler."""
return NetatmoOptionsFlowHandler(config_entry)
@property
def logger(self) -> logging.Logger:
"""Return logger."""
@ -45,11 +65,113 @@ class NetatmoFlowHandler(
async def async_step_user(self, user_input=None):
"""Handle a flow start."""
if self.hass.config_entries.async_entries(DOMAIN):
return self.async_abort(reason="already_setup")
await self.async_set_unique_id(DOMAIN)
return await super().async_step_user(user_input)
async def async_step_homekit(self, homekit_info):
"""Handle HomeKit discovery."""
return await self.async_step_user()
class NetatmoOptionsFlowHandler(config_entries.OptionsFlow):
"""Handle Netatmo options."""
def __init__(self, config_entry: config_entries.ConfigEntry):
"""Initialize Netatmo options flow."""
self.config_entry = config_entry
self.options = dict(config_entry.options)
self.options.setdefault(CONF_WEATHER_AREAS, {})
async def async_step_init(self, user_input=None):
"""Manage the Netatmo options."""
return await self.async_step_public_weather_areas()
async def async_step_public_weather_areas(self, user_input=None):
"""Manage configuration of Netatmo public weather areas."""
errors = {}
if user_input is not None:
new_client = user_input.pop(CONF_NEW_AREA, None)
areas = user_input.pop(CONF_WEATHER_AREAS, None)
user_input[CONF_WEATHER_AREAS] = {
area: self.options[CONF_WEATHER_AREAS][area] for area in areas
}
self.options.update(user_input)
if new_client:
return await self.async_step_public_weather(
user_input={CONF_NEW_AREA: new_client}
)
return await self._update_options()
weather_areas = list(self.options[CONF_WEATHER_AREAS])
data_schema = vol.Schema(
{
vol.Optional(
CONF_WEATHER_AREAS, default=weather_areas,
): cv.multi_select(weather_areas),
vol.Optional(CONF_NEW_AREA): str,
}
)
return self.async_show_form(
step_id="public_weather_areas", data_schema=data_schema, errors=errors,
)
async def async_step_public_weather(self, user_input=None):
"""Manage configuration of Netatmo public weather sensors."""
if user_input is not None and CONF_NEW_AREA not in user_input:
self.options[CONF_WEATHER_AREAS][user_input[CONF_AREA_NAME]] = user_input
return await self.async_step_public_weather_areas()
orig_options = self.config_entry.options.get(CONF_WEATHER_AREAS, {}).get(
user_input[CONF_NEW_AREA], {}
)
default_longitude = self.hass.config.longitude
default_latitude = self.hass.config.latitude
default_size = 0.04
data_schema = vol.Schema(
{
vol.Optional(CONF_AREA_NAME, default=user_input[CONF_NEW_AREA]): str,
vol.Optional(
CONF_LAT_NE,
default=orig_options.get(
CONF_LAT_NE, default_latitude + default_size
),
): cv.latitude,
vol.Optional(
CONF_LON_NE,
default=orig_options.get(
CONF_LON_NE, default_longitude + default_size
),
): cv.longitude,
vol.Optional(
CONF_LAT_SW,
default=orig_options.get(
CONF_LAT_SW, default_latitude - default_size
),
): cv.latitude,
vol.Optional(
CONF_LON_SW,
default=orig_options.get(
CONF_LON_SW, default_longitude - default_size
),
): cv.longitude,
vol.Required(
CONF_PUBLIC_MODE, default=orig_options.get(CONF_PUBLIC_MODE, "avg"),
): vol.In(["avg", "max"]),
vol.Required(
CONF_SHOW_ON_MAP, default=orig_options.get(CONF_SHOW_ON_MAP, False),
): bool,
}
)
return self.async_show_form(step_id="public_weather", data_schema=data_schema)
async def _update_options(self):
"""Update config entry options."""
return self.async_create_entry(
title="Netatmo Public Weather", data=self.options
)

View file

@ -20,6 +20,7 @@ MODELS = {
"NAModule4": "Smart Additional Indoor module",
"NAModule3": "Smart Rain Gauge",
"NAModule2": "Smart Anemometer",
"public": "Public Weather stations",
}
AUTH = "netatmo_auth"
@ -28,6 +29,14 @@ CAMERA_DATA = "netatmo_camera"
HOME_DATA = "netatmo_home_data"
CONF_CLOUDHOOK_URL = "cloudhook_url"
CONF_WEATHER_AREAS = "weather_areas"
CONF_NEW_AREA = "new_area"
CONF_AREA_NAME = "area_name"
CONF_LAT_NE = "lat_ne"
CONF_LON_NE = "lon_ne"
CONF_LAT_SW = "lat_sw"
CONF_LON_SW = "lon_sw"
CONF_PUBLIC_MODE = "mode"
OAUTH2_AUTHORIZE = "https://api.netatmo.com/oauth2/authorize"
OAUTH2_TOKEN = "https://api.netatmo.com/oauth2/token"

View file

@ -4,8 +4,12 @@ import logging
import pyatmo
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_LATITUDE,
ATTR_LONGITUDE,
CONCENTRATION_PARTS_PER_MILLION,
CONF_SHOW_ON_MAP,
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_TEMPERATURE,
@ -13,24 +17,31 @@ from homeassistant.const import (
TEMP_CELSIUS,
UNIT_PERCENTAGE,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import async_entries_for_config_entry
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.entity import Entity
from homeassistant.util import Throttle
from .const import AUTH, DOMAIN, MANUFACTURER, MODELS
from .const import (
AUTH,
CONF_AREA_NAME,
CONF_LAT_NE,
CONF_LAT_SW,
CONF_LON_NE,
CONF_LON_SW,
CONF_PUBLIC_MODE,
CONF_WEATHER_AREAS,
DOMAIN,
MANUFACTURER,
MODELS,
)
_LOGGER = logging.getLogger(__name__)
CONF_MODULES = "modules"
CONF_STATION = "station"
CONF_AREAS = "areas"
CONF_LAT_NE = "lat_ne"
CONF_LON_NE = "lon_ne"
CONF_LAT_SW = "lat_sw"
CONF_LON_SW = "lon_sw"
DEFAULT_MODE = "avg"
MODE_TYPES = {"max", "avg"}
# This is the Netatmo data upload interval in seconds
NETATMO_UPDATE_INTERVAL = 600
@ -107,10 +118,15 @@ NETATMO_DEVICE_TYPES = {
"HomeCoachData": "home coach",
}
PUBLIC = "public"
async def async_setup_entry(hass, entry, async_add_entities):
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities
):
"""Set up the Netatmo weather and homecoach platform."""
auth = hass.data[DOMAIN][entry.entry_id][AUTH]
device_registry = await hass.helpers.device_registry.async_get_registry()
def find_entities(data):
"""Find all entities."""
@ -145,6 +161,41 @@ async def async_setup_entry(hass, entry, async_add_entities):
async_add_entities(await hass.async_add_executor_job(get_entities), True)
@callback
def add_public_entities():
"""Retrieve Netatmo public weather entities."""
entities = []
for area in entry.options.get(CONF_WEATHER_AREAS, {}).values():
data = NetatmoPublicData(
auth,
lat_ne=area[CONF_LAT_NE],
lon_ne=area[CONF_LON_NE],
lat_sw=area[CONF_LAT_SW],
lon_sw=area[CONF_LON_SW],
)
for sensor_type in SUPPORTED_PUBLIC_SENSOR_TYPES:
entities.append(NetatmoPublicSensor(area, data, sensor_type,))
for device in async_entries_for_config_entry(device_registry, entry.entry_id):
if device.model == "Public Weather stations":
device_registry.async_remove_device(device.id)
if entities:
async_add_entities(entities)
async_dispatcher_connect(
hass, f"signal-{DOMAIN}-public-update-{entry.entry_id}", add_public_entities
)
entry.add_update_listener(async_config_entry_updated)
add_public_entities()
async def async_config_entry_updated(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle signals of config entry being updated."""
async_dispatcher_send(hass, f"signal-{DOMAIN}-public-update-{entry.entry_id}")
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up the Netatmo weather and homecoach platform."""
@ -403,20 +454,48 @@ class NetatmoSensor(Entity):
return
class NetatmoData:
"""Get the latest data from Netatmo."""
def __init__(self, auth, station_data):
"""Initialize the data object."""
self.data = {}
self.station_data = station_data
self.auth = auth
def get_module_infos(self):
"""Return all modules available on the API as a dict."""
return self.station_data.getModules()
@Throttle(MIN_TIME_BETWEEN_UPDATES)
def update(self):
"""Call the Netatmo API to update the data."""
self.station_data = self.station_data.__class__(self.auth)
data = self.station_data.lastData(exclude=3600, byId=True)
if not data:
_LOGGER.debug("No data received when updating station data")
return
self.data = data
class NetatmoPublicSensor(Entity):
"""Represent a single sensor in a Netatmo."""
def __init__(self, area_name, data, sensor_type, mode):
def __init__(self, area, data, sensor_type):
"""Initialize the sensor."""
self.netatmo_data = data
self.type = sensor_type
self._mode = mode
self._name = f"{MANUFACTURER} {area_name} {SENSOR_TYPES[self.type][0]}"
self._area_name = area_name
self._mode = area[CONF_PUBLIC_MODE]
self._area_name = area[CONF_AREA_NAME]
self._name = f"{MANUFACTURER} {self._area_name} {SENSOR_TYPES[self.type][0]}"
self._state = None
self._device_class = SENSOR_TYPES[self.type][3]
self._icon = SENSOR_TYPES[self.type][2]
self._unit_of_measurement = SENSOR_TYPES[self.type][1]
self._show_on_map = area[CONF_SHOW_ON_MAP]
self._unique_id = f"{self._name.replace(' ', '-')}"
self._module_type = PUBLIC
@property
def name(self):
@ -440,9 +519,24 @@ class NetatmoPublicSensor(Entity):
"identifiers": {(DOMAIN, self._area_name)},
"name": self._area_name,
"manufacturer": MANUFACTURER,
"model": "public",
"model": MODELS[self._module_type],
}
@property
def device_state_attributes(self):
"""Return the attributes of the device."""
attrs = {}
if self._show_on_map:
attrs[ATTR_LATITUDE] = (
self.netatmo_data.lat_ne + self.netatmo_data.lat_sw
) / 2
attrs[ATTR_LONGITUDE] = (
self.netatmo_data.lon_ne + self.netatmo_data.lon_sw
) / 2
return attrs
@property
def state(self):
"""Return the state of the device."""
@ -453,6 +547,11 @@ class NetatmoPublicSensor(Entity):
"""Return the unit of measurement of this entity."""
return self._unit_of_measurement
@property
def unique_id(self):
"""Return the unique ID for this sensor."""
return self._unique_id
@property
def available(self):
"""Return True if entity is available."""
@ -536,28 +635,3 @@ class NetatmoPublicData:
return
self.data = data
class NetatmoData:
"""Get the latest data from Netatmo."""
def __init__(self, auth, station_data):
"""Initialize the data object."""
self.data = {}
self.station_data = station_data
self.auth = auth
def get_module_infos(self):
"""Return all modules available on the API as a dict."""
return self.station_data.getModules()
@Throttle(MIN_TIME_BETWEEN_UPDATES)
def update(self):
"""Call the Netatmo API to update the data."""
self.station_data = self.station_data.__class__(self.auth)
data = self.station_data.lastData(exclude=3600, byId=True)
if not data:
_LOGGER.debug("No data received when updating station data")
return
self.data = data

View file

@ -13,5 +13,30 @@
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
}
},
"options": {
"step": {
"public_weather": {
"data": {
"area_name": "Name of the area",
"lat_ne": "Latitude North-East corner",
"lon_ne": "Longitude North-East corner",
"lat_sw": "Latitude South-West corner",
"lon_sw": "Longitude South-West corner",
"mode": "Calculation",
"show_on_map": "Show on map"
},
"description": "Configure a public weather sensor for an area.",
"title": "Netatmo public weather sensor"
},
"public_weather_areas": {
"data": {
"new_area": "Area name",
"weather_areas": "Weather areas"
},
"description": "Configure public weather sensors.",
"title": "Netatmo public weather sensor"
}
}
}
}
}

View file

@ -1,17 +1,42 @@
{
"config": {
"abort": {
"already_setup": "Already configured. Only a single configuration possible.",
"authorize_url_timeout": "Timeout generating authorize URL.",
"missing_configuration": "The component is not configured. Please follow the documentation."
},
"create_entry": {
"default": "Successfully authenticated"
},
"step": {
"pick_implementation": {
"title": "Pick Authentication Method"
}
}
"config": {
"step": {
"pick_implementation": {
"title": "Pick Authentication Method"
}
},
"abort": {
"already_setup": "Already configured. Only a single configuration possible.",
"authorize_url_timeout": "Timeout generating authorize URL.",
"missing_configuration": "The component is not configured. Please follow the documentation."
},
"create_entry": {
"default": "Successfully authenticated"
}
},
"options": {
"step": {
"public_weather": {
"data": {
"area_name": "Name of the area",
"lat_ne": "Latitude North-East corner",
"lon_ne": "Longitude North-East corner",
"lat_sw": "Latitude South-West corner",
"lon_sw": "Longitude South-West corner",
"mode": "Calculation",
"show_on_map": "Show on map"
},
"description": "Configure a public weather sensor for an area.",
"title": "Netatmo public weather sensor"
},
"public_weather_areas": {
"data": {
"new_area": "Area name",
"weather_areas": "Weather areas"
},
"description": "Configure public weather sensors.",
"title": "Netatmo public weather sensor"
}
}
}
}

View file

@ -23,7 +23,8 @@ async def handle_webhook(hass, webhook_id, request):
"""Handle webhook callback."""
try:
data = await request.json()
except ValueError:
except ValueError as err:
_LOGGER.error("Error in data: %s", err)
return None
_LOGGER.debug("Got webhook data: %s", data)
@ -36,6 +37,12 @@ async def handle_webhook(hass, webhook_id, request):
)
for event_data in data.get("event_list"):
async_evaluate_event(hass, event_data)
elif event_type == "therm_mode":
hass.bus.async_fire(
event_type=NETATMO_EVENT, event_data={"type": event_type, "data": data}
)
for event_data in data.get("data"):
async_evaluate_event(hass, event_data)
else:
async_evaluate_event(hass, data)
@ -58,6 +65,18 @@ def async_evaluate_event(hass, event_data):
event_type=NETATMO_EVENT,
event_data={"type": event_type, "data": person_event_data},
)
elif event_type == "therm_mode":
_LOGGER.debug("therm_mode: %s", event_data)
hass.bus.async_fire(
event_type=NETATMO_EVENT,
event_data={"type": event_type, "data": event_data},
)
elif event_type == "set_point":
_LOGGER.debug("set_point: %s", event_data)
hass.bus.async_fire(
event_type=NETATMO_EVENT,
event_data={"type": event_type, "data": event_data},
)
else:
hass.bus.async_fire(
event_type=NETATMO_EVENT,

View file

@ -2,6 +2,8 @@
from homeassistant import config_entries, data_entry_flow, setup
from homeassistant.components.netatmo import config_flow
from homeassistant.components.netatmo.const import (
CONF_NEW_AREA,
CONF_WEATHER_AREAS,
DOMAIN,
OAUTH2_AUTHORIZE,
OAUTH2_TOKEN,
@ -15,6 +17,8 @@ from tests.common import MockConfigEntry
CLIENT_ID = "1234"
CLIENT_SECRET = "5678"
VALID_CONFIG = {}
async def test_abort_if_existing_entry(hass):
"""Check flow abort when an entry already exist."""
@ -27,7 +31,7 @@ async def test_abort_if_existing_entry(hass):
"netatmo", context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_setup"
assert result["reason"] == "missing_configuration"
result = await hass.config_entries.flow.async_init(
"netatmo",
@ -35,7 +39,7 @@ async def test_abort_if_existing_entry(hass):
data={"host": "0.0.0.0", "properties": {"id": "aa:bb:cc:dd:ee:ff"}},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_setup"
assert result["reason"] == "missing_configuration"
async def test_full_flow(hass, aiohttp_client, aioclient_mock):
@ -98,3 +102,47 @@ async def test_full_flow(hass, aiohttp_client, aioclient_mock):
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert len(mock_setup.mock_calls) == 1
async def test_option_flow(hass):
"""Test config flow options."""
valid_option = {
"lat_ne": 32.91336,
"lon_sw": -117.26743,
"show_on_map": False,
"area_name": "Home",
"lon_ne": -117.187429,
"lat_sw": 32.83336,
"mode": "avg",
}
config_entry = MockConfigEntry(
domain=DOMAIN, unique_id=DOMAIN, data=VALID_CONFIG, options={},
)
config_entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(config_entry.entry_id)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "public_weather_areas"
result = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={CONF_NEW_AREA: "Home"}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "public_weather"
result = await hass.config_entries.options.async_configure(
result["flow_id"], user_input=valid_option
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "public_weather_areas"
result = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert config_entry.options == {CONF_WEATHER_AREAS: {"Home": valid_option}}