From 155a5f7c2680c4bbb6c5dbaec6b7935e64ddfd43 Mon Sep 17 00:00:00 2001 From: cgtobi Date: Thu, 9 Jul 2020 06:39:33 +0200 Subject: [PATCH] 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 --- .coveragerc | 1 + homeassistant/components/netatmo/__init__.py | 4 + .../components/netatmo/config_flow.py | 134 ++++++++++++++- homeassistant/components/netatmo/const.py | 9 + homeassistant/components/netatmo/sensor.py | 160 +++++++++++++----- homeassistant/components/netatmo/strings.json | 27 ++- .../components/netatmo/translations/en.json | 53 ++++-- homeassistant/components/netatmo/webhook.py | 21 ++- tests/components/netatmo/test_config_flow.py | 52 +++++- 9 files changed, 394 insertions(+), 67 deletions(-) diff --git a/.coveragerc b/.coveragerc index ece1c125821..2e42b7f4e6a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -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/* diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index b7d439b4a74..cb5408c2259 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -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) } diff --git a/homeassistant/components/netatmo/config_flow.py b/homeassistant/components/netatmo/config_flow.py index 6d524ab9f29..380878c6e73 100644 --- a/homeassistant/components/netatmo/config_flow.py +++ b/homeassistant/components/netatmo/config_flow.py @@ -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 + ) diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index 4e4ff308755..835d42a32ba 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -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" diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index aa382d52068..d022b1061ac 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -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 diff --git a/homeassistant/components/netatmo/strings.json b/homeassistant/components/netatmo/strings.json index 2d41f560cff..116a37adb55 100644 --- a/homeassistant/components/netatmo/strings.json +++ b/homeassistant/components/netatmo/strings.json @@ -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" + } + } } -} +} \ No newline at end of file diff --git a/homeassistant/components/netatmo/translations/en.json b/homeassistant/components/netatmo/translations/en.json index b290dcf3dd6..c958c188405 100644 --- a/homeassistant/components/netatmo/translations/en.json +++ b/homeassistant/components/netatmo/translations/en.json @@ -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" + } + } + } } \ No newline at end of file diff --git a/homeassistant/components/netatmo/webhook.py b/homeassistant/components/netatmo/webhook.py index 8b6a4d3f1e1..9e5d33f5dbb 100644 --- a/homeassistant/components/netatmo/webhook.py +++ b/homeassistant/components/netatmo/webhook.py @@ -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, diff --git a/tests/components/netatmo/test_config_flow.py b/tests/components/netatmo/test_config_flow.py index b497df93bf5..24668ea47e6 100644 --- a/tests/components/netatmo/test_config_flow.py +++ b/tests/components/netatmo/test_config_flow.py @@ -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}}