mirror of
https://github.com/home-assistant/core.git
synced 2025-07-19 19:27:45 +00:00
Make Ambient PWS async and cloud-push (#20332)
* Moving existing sensor file * Initial functionality in place * Added test for config flow * Updated coverage and CODEOWNERS * Linting * Linting * Member comments * Hound * Moving socket disconnect on HASS stop * Member comments * Removed unnecessary dispatcher call * Config entry fix * Added support in config flow for good accounts with no devices * Hound * Updated comment * Member comments * Stale docstrings * Stale docstring
This commit is contained in:
parent
abeb875c61
commit
2c7060896b
@ -19,6 +19,9 @@ omit =
|
|||||||
homeassistant/components/alarmdecoder.py
|
homeassistant/components/alarmdecoder.py
|
||||||
homeassistant/components/*/alarmdecoder.py
|
homeassistant/components/*/alarmdecoder.py
|
||||||
|
|
||||||
|
homeassistant/components/ambient_station/__init__.py
|
||||||
|
homeassistant/components/ambient_station/sensor.py
|
||||||
|
|
||||||
homeassistant/components/amcrest.py
|
homeassistant/components/amcrest.py
|
||||||
homeassistant/components/*/amcrest.py
|
homeassistant/components/*/amcrest.py
|
||||||
|
|
||||||
@ -732,7 +735,6 @@ omit =
|
|||||||
homeassistant/components/sensor/aftership.py
|
homeassistant/components/sensor/aftership.py
|
||||||
homeassistant/components/sensor/airvisual.py
|
homeassistant/components/sensor/airvisual.py
|
||||||
homeassistant/components/sensor/alpha_vantage.py
|
homeassistant/components/sensor/alpha_vantage.py
|
||||||
homeassistant/components/sensor/ambient_station.py
|
|
||||||
homeassistant/components/sensor/arest.py
|
homeassistant/components/sensor/arest.py
|
||||||
homeassistant/components/sensor/arwn.py
|
homeassistant/components/sensor/arwn.py
|
||||||
homeassistant/components/sensor/bbox.py
|
homeassistant/components/sensor/bbox.py
|
||||||
|
@ -153,6 +153,7 @@ homeassistant/components/weather/openweathermap.py @fabaff
|
|||||||
homeassistant/components/xiaomi_aqara.py @danielhiversen @syssi
|
homeassistant/components/xiaomi_aqara.py @danielhiversen @syssi
|
||||||
|
|
||||||
# A
|
# A
|
||||||
|
homeassistant/components/ambient_station/* @bachya
|
||||||
homeassistant/components/arduino.py @fabaff
|
homeassistant/components/arduino.py @fabaff
|
||||||
homeassistant/components/*/arduino.py @fabaff
|
homeassistant/components/*/arduino.py @fabaff
|
||||||
homeassistant/components/*/arest.py @fabaff
|
homeassistant/components/*/arest.py @fabaff
|
||||||
|
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"error": {
|
||||||
|
"identifier_exists": "Application Key and/or API Key already registered",
|
||||||
|
"invalid_key": "Invalid API Key and/or Application Key",
|
||||||
|
"no_devices": "No devices found in account"
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"data": {
|
||||||
|
"api_key": "API Key",
|
||||||
|
"app_key": "Application Key"
|
||||||
|
},
|
||||||
|
"title": "Fill in your information"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "Ambient PWS"
|
||||||
|
}
|
||||||
|
}
|
212
homeassistant/components/ambient_station/__init__.py
Normal file
212
homeassistant/components/ambient_station/__init__.py
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
"""
|
||||||
|
Support for Ambient Weather Station Service.
|
||||||
|
|
||||||
|
For more details about this component, please refer to the documentation at
|
||||||
|
https://home-assistant.io/components/ambient_station/
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.config_entries import SOURCE_IMPORT
|
||||||
|
from homeassistant.const import (
|
||||||
|
ATTR_NAME, ATTR_LOCATION, CONF_API_KEY, CONF_MONITORED_CONDITIONS,
|
||||||
|
CONF_UNIT_SYSTEM, EVENT_HOMEASSISTANT_STOP)
|
||||||
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
|
from homeassistant.helpers import aiohttp_client, config_validation as cv
|
||||||
|
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||||
|
from homeassistant.helpers.event import async_call_later
|
||||||
|
|
||||||
|
from .config_flow import configured_instances
|
||||||
|
from .const import (
|
||||||
|
ATTR_LAST_DATA, CONF_APP_KEY, DATA_CLIENT, DOMAIN, TOPIC_UPDATE, UNITS_SI,
|
||||||
|
UNITS_US)
|
||||||
|
|
||||||
|
REQUIREMENTS = ['aioambient==0.1.0']
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_SOCKET_MIN_RETRY = 15
|
||||||
|
|
||||||
|
SENSOR_TYPES = {
|
||||||
|
'24hourrainin': ['24 Hr Rain', 'in'],
|
||||||
|
'baromabsin': ['Abs Pressure', 'inHg'],
|
||||||
|
'baromrelin': ['Rel Pressure', 'inHg'],
|
||||||
|
'battout': ['Battery', ''],
|
||||||
|
'co2': ['co2', 'ppm'],
|
||||||
|
'dailyrainin': ['Daily Rain', 'in'],
|
||||||
|
'dewPoint': ['Dew Point', ['°F', '°C']],
|
||||||
|
'eventrainin': ['Event Rain', 'in'],
|
||||||
|
'feelsLike': ['Feels Like', ['°F', '°C']],
|
||||||
|
'hourlyrainin': ['Hourly Rain Rate', 'in/hr'],
|
||||||
|
'humidity': ['Humidity', '%'],
|
||||||
|
'humidityin': ['Humidity In', '%'],
|
||||||
|
'lastRain': ['Last Rain', ''],
|
||||||
|
'maxdailygust': ['Max Gust', 'mph'],
|
||||||
|
'monthlyrainin': ['Monthly Rain', 'in'],
|
||||||
|
'solarradiation': ['Solar Rad', 'W/m^2'],
|
||||||
|
'tempf': ['Temp', ['°F', '°C']],
|
||||||
|
'tempinf': ['Inside Temp', ['°F', '°C']],
|
||||||
|
'totalrainin': ['Lifetime Rain', 'in'],
|
||||||
|
'uv': ['uv', 'Index'],
|
||||||
|
'weeklyrainin': ['Weekly Rain', 'in'],
|
||||||
|
'winddir': ['Wind Dir', '°'],
|
||||||
|
'winddir_avg10m': ['Wind Dir Avg 10m', '°'],
|
||||||
|
'winddir_avg2m': ['Wind Dir Avg 2m', 'mph'],
|
||||||
|
'windgustdir': ['Gust Dir', '°'],
|
||||||
|
'windgustmph': ['Wind Gust', 'mph'],
|
||||||
|
'windspdmph_avg10m': ['Wind Avg 10m', 'mph'],
|
||||||
|
'windspdmph_avg2m': ['Wind Avg 2m', 'mph'],
|
||||||
|
'windspeedmph': ['Wind Speed', 'mph'],
|
||||||
|
'yearlyrainin': ['Yearly Rain', 'in'],
|
||||||
|
}
|
||||||
|
|
||||||
|
CONFIG_SCHEMA = vol.Schema({
|
||||||
|
DOMAIN:
|
||||||
|
vol.Schema({
|
||||||
|
vol.Required(CONF_APP_KEY):
|
||||||
|
cv.string,
|
||||||
|
vol.Required(CONF_API_KEY):
|
||||||
|
cv.string,
|
||||||
|
vol.Optional(
|
||||||
|
CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)):
|
||||||
|
vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
|
||||||
|
vol.Optional(CONF_UNIT_SYSTEM):
|
||||||
|
vol.In([UNITS_SI, UNITS_US]),
|
||||||
|
})
|
||||||
|
}, extra=vol.ALLOW_EXTRA)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup(hass, config):
|
||||||
|
"""Set up the Ambient PWS component."""
|
||||||
|
hass.data[DOMAIN] = {}
|
||||||
|
hass.data[DOMAIN][DATA_CLIENT] = {}
|
||||||
|
|
||||||
|
if DOMAIN not in config:
|
||||||
|
return True
|
||||||
|
|
||||||
|
conf = config[DOMAIN]
|
||||||
|
|
||||||
|
if conf[CONF_APP_KEY] in configured_instances(hass):
|
||||||
|
return True
|
||||||
|
|
||||||
|
hass.async_create_task(
|
||||||
|
hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={'source': SOURCE_IMPORT}, data=conf))
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass, config_entry):
|
||||||
|
"""Set up the Ambient PWS as config entry."""
|
||||||
|
from aioambient import Client
|
||||||
|
from aioambient.errors import WebsocketConnectionError
|
||||||
|
|
||||||
|
session = aiohttp_client.async_get_clientsession(hass)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ambient = AmbientStation(
|
||||||
|
hass,
|
||||||
|
config_entry,
|
||||||
|
Client(
|
||||||
|
config_entry.data[CONF_API_KEY],
|
||||||
|
config_entry.data[CONF_APP_KEY], session),
|
||||||
|
config_entry.data.get(
|
||||||
|
CONF_MONITORED_CONDITIONS, list(SENSOR_TYPES)),
|
||||||
|
config_entry.data.get(CONF_UNIT_SYSTEM))
|
||||||
|
hass.loop.create_task(ambient.ws_connect())
|
||||||
|
hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = ambient
|
||||||
|
except WebsocketConnectionError as err:
|
||||||
|
_LOGGER.error('Config entry failed: %s', err)
|
||||||
|
raise ConfigEntryNotReady
|
||||||
|
|
||||||
|
hass.bus.async_listen_once(
|
||||||
|
EVENT_HOMEASSISTANT_STOP, ambient.client.websocket.disconnect())
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(hass, config_entry):
|
||||||
|
"""Unload an Ambient PWS config entry."""
|
||||||
|
ambient = hass.data[DOMAIN][DATA_CLIENT].pop(config_entry.entry_id)
|
||||||
|
hass.async_create_task(ambient.ws_disconnect())
|
||||||
|
|
||||||
|
await hass.config_entries.async_forward_entry_unload(
|
||||||
|
config_entry, 'sensor')
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class AmbientStation:
|
||||||
|
"""Define a class to handle the Ambient websocket."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, hass, config_entry, client, monitored_conditions,
|
||||||
|
unit_system):
|
||||||
|
"""Initialize."""
|
||||||
|
self._config_entry = config_entry
|
||||||
|
self._hass = hass
|
||||||
|
self._ws_reconnect_delay = DEFAULT_SOCKET_MIN_RETRY
|
||||||
|
self.client = client
|
||||||
|
self.monitored_conditions = monitored_conditions
|
||||||
|
self.stations = {}
|
||||||
|
self.unit_system = unit_system
|
||||||
|
|
||||||
|
async def ws_connect(self):
|
||||||
|
"""Register handlers and connect to the websocket."""
|
||||||
|
from aioambient.errors import WebsocketError
|
||||||
|
|
||||||
|
def on_connect():
|
||||||
|
"""Define a handler to fire when the websocket is connected."""
|
||||||
|
_LOGGER.info('Connected to websocket')
|
||||||
|
|
||||||
|
def on_data(data):
|
||||||
|
"""Define a handler to fire when the data is received."""
|
||||||
|
mac_address = data['macAddress']
|
||||||
|
if data != self.stations[mac_address][ATTR_LAST_DATA]:
|
||||||
|
_LOGGER.debug('New data received: %s', data)
|
||||||
|
self.stations[mac_address][ATTR_LAST_DATA] = data
|
||||||
|
async_dispatcher_send(self._hass, TOPIC_UPDATE)
|
||||||
|
|
||||||
|
def on_disconnect():
|
||||||
|
"""Define a handler to fire when the websocket is disconnected."""
|
||||||
|
_LOGGER.info('Disconnected from websocket')
|
||||||
|
|
||||||
|
def on_subscribed(data):
|
||||||
|
"""Define a handler to fire when the subscription is set."""
|
||||||
|
for station in data['devices']:
|
||||||
|
if station['macAddress'] in self.stations:
|
||||||
|
continue
|
||||||
|
|
||||||
|
_LOGGER.debug('New station subscription: %s', data)
|
||||||
|
|
||||||
|
self.stations[station['macAddress']] = {
|
||||||
|
ATTR_LAST_DATA: station['lastData'],
|
||||||
|
ATTR_LOCATION: station['info']['location'],
|
||||||
|
ATTR_NAME: station['info']['name'],
|
||||||
|
}
|
||||||
|
|
||||||
|
self._hass.async_create_task(
|
||||||
|
self._hass.config_entries.async_forward_entry_setup(
|
||||||
|
self._config_entry, 'sensor'))
|
||||||
|
|
||||||
|
self._ws_reconnect_delay = DEFAULT_SOCKET_MIN_RETRY
|
||||||
|
|
||||||
|
self.client.websocket.on_connect(on_connect)
|
||||||
|
self.client.websocket.on_data(on_data)
|
||||||
|
self.client.websocket.on_disconnect(on_disconnect)
|
||||||
|
self.client.websocket.on_subscribed(on_subscribed)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.client.websocket.connect()
|
||||||
|
except WebsocketError as err:
|
||||||
|
_LOGGER.error("Error with the websocket connection: %s", err)
|
||||||
|
|
||||||
|
self._ws_reconnect_delay = min(
|
||||||
|
2 * self._ws_reconnect_delay, 480)
|
||||||
|
|
||||||
|
async_call_later(
|
||||||
|
self._hass, self._ws_reconnect_delay, self.ws_connect)
|
||||||
|
|
||||||
|
async def ws_disconnect(self):
|
||||||
|
"""Disconnect from the websocket."""
|
||||||
|
await self.client.websocket.disconnect()
|
72
homeassistant/components/ambient_station/config_flow.py
Normal file
72
homeassistant/components/ambient_station/config_flow.py
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
"""Config flow to configure the Ambient PWS component."""
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.const import CONF_API_KEY
|
||||||
|
from homeassistant.core import callback
|
||||||
|
from homeassistant.helpers import aiohttp_client
|
||||||
|
|
||||||
|
from .const import CONF_APP_KEY, DOMAIN
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def configured_instances(hass):
|
||||||
|
"""Return a set of configured Ambient PWS instances."""
|
||||||
|
return set(
|
||||||
|
entry.data[CONF_APP_KEY]
|
||||||
|
for entry in hass.config_entries.async_entries(DOMAIN))
|
||||||
|
|
||||||
|
|
||||||
|
@config_entries.HANDLERS.register(DOMAIN)
|
||||||
|
class AmbientStationFlowHandler(config_entries.ConfigFlow):
|
||||||
|
"""Handle an Ambient PWS config flow."""
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
|
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH
|
||||||
|
|
||||||
|
async def _show_form(self, errors=None):
|
||||||
|
"""Show the form to the user."""
|
||||||
|
data_schema = vol.Schema({
|
||||||
|
vol.Required(CONF_API_KEY): str,
|
||||||
|
vol.Required(CONF_APP_KEY): str,
|
||||||
|
})
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id='user',
|
||||||
|
data_schema=data_schema,
|
||||||
|
errors=errors if errors else {},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_step_import(self, import_config):
|
||||||
|
"""Import a config entry from configuration.yaml."""
|
||||||
|
return await self.async_step_user(import_config)
|
||||||
|
|
||||||
|
async def async_step_user(self, user_input=None):
|
||||||
|
"""Handle the start of the config flow."""
|
||||||
|
from aioambient import Client
|
||||||
|
from aioambient.errors import AmbientError
|
||||||
|
|
||||||
|
if not user_input:
|
||||||
|
return await self._show_form()
|
||||||
|
|
||||||
|
if user_input[CONF_APP_KEY] in configured_instances(self.hass):
|
||||||
|
return await self._show_form({CONF_APP_KEY: 'identifier_exists'})
|
||||||
|
|
||||||
|
session = aiohttp_client.async_get_clientsession(self.hass)
|
||||||
|
client = Client(
|
||||||
|
user_input[CONF_API_KEY], user_input[CONF_APP_KEY], session)
|
||||||
|
|
||||||
|
try:
|
||||||
|
devices = await client.api.get_devices()
|
||||||
|
except AmbientError:
|
||||||
|
return await self._show_form({'base': 'invalid_key'})
|
||||||
|
|
||||||
|
if not devices:
|
||||||
|
return await self._show_form({'base': 'no_devices'})
|
||||||
|
|
||||||
|
# The Application Key (which identifies each config entry) is too long
|
||||||
|
# to show nicely in the UI, so we take the first 12 characters (similar
|
||||||
|
# to how GitHub does it):
|
||||||
|
return self.async_create_entry(
|
||||||
|
title=user_input[CONF_APP_KEY][:12], data=user_input)
|
13
homeassistant/components/ambient_station/const.py
Normal file
13
homeassistant/components/ambient_station/const.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
"""Define constants for the Ambient PWS component."""
|
||||||
|
DOMAIN = 'ambient_station'
|
||||||
|
|
||||||
|
ATTR_LAST_DATA = 'last_data'
|
||||||
|
|
||||||
|
CONF_APP_KEY = 'app_key'
|
||||||
|
|
||||||
|
DATA_CLIENT = 'data_client'
|
||||||
|
|
||||||
|
TOPIC_UPDATE = 'update'
|
||||||
|
|
||||||
|
UNITS_SI = 'si'
|
||||||
|
UNITS_US = 'us'
|
115
homeassistant/components/ambient_station/sensor.py
Normal file
115
homeassistant/components/ambient_station/sensor.py
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
"""
|
||||||
|
Support for Ambient Weather Station Service.
|
||||||
|
|
||||||
|
For more details about this platform, please refer to the documentation at
|
||||||
|
https://home-assistant.io/components/sensor.ambient_station/
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from homeassistant.components.ambient_station import SENSOR_TYPES
|
||||||
|
from homeassistant.helpers.entity import Entity
|
||||||
|
from homeassistant.const import ATTR_NAME
|
||||||
|
from homeassistant.core import callback
|
||||||
|
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
ATTR_LAST_DATA, DATA_CLIENT, DOMAIN, TOPIC_UPDATE, UNITS_SI, UNITS_US)
|
||||||
|
|
||||||
|
DEPENDENCIES = ['ambient_station']
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
UNIT_SYSTEM = {UNITS_US: 0, UNITS_SI: 1}
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_platform(
|
||||||
|
hass, config, async_add_entities, discovery_info=None):
|
||||||
|
"""Set up an Ambient PWS sensor based on existing config."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass, entry, async_add_entities):
|
||||||
|
"""Set up an Ambient PWS sensor based on a config entry."""
|
||||||
|
ambient = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id]
|
||||||
|
|
||||||
|
if ambient.unit_system:
|
||||||
|
sys_units = ambient.unit_system
|
||||||
|
elif hass.config.units.is_metric:
|
||||||
|
sys_units = UNITS_SI
|
||||||
|
else:
|
||||||
|
sys_units = UNITS_US
|
||||||
|
|
||||||
|
sensor_list = []
|
||||||
|
for mac_address, station in ambient.stations.items():
|
||||||
|
for condition in ambient.monitored_conditions:
|
||||||
|
name, unit = SENSOR_TYPES[condition]
|
||||||
|
if isinstance(unit, list):
|
||||||
|
unit = unit[UNIT_SYSTEM[sys_units]]
|
||||||
|
|
||||||
|
sensor_list.append(
|
||||||
|
AmbientWeatherSensor(
|
||||||
|
ambient, mac_address, station[ATTR_NAME], condition, name,
|
||||||
|
unit))
|
||||||
|
|
||||||
|
async_add_entities(sensor_list, True)
|
||||||
|
|
||||||
|
|
||||||
|
class AmbientWeatherSensor(Entity):
|
||||||
|
"""Define an Ambient sensor."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, ambient, mac_address, station_name, sensor_type, sensor_name,
|
||||||
|
units):
|
||||||
|
"""Initialize the sensor."""
|
||||||
|
self._ambient = ambient
|
||||||
|
self._async_unsub_dispatcher_connect = None
|
||||||
|
self._mac_address = mac_address
|
||||||
|
self._sensor_name = sensor_name
|
||||||
|
self._sensor_type = sensor_type
|
||||||
|
self._state = None
|
||||||
|
self._station_name = station_name
|
||||||
|
self._units = units
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
"""Return the name of the sensor."""
|
||||||
|
return '{0}_{1}'.format(self._station_name, self._sensor_name)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def should_poll(self):
|
||||||
|
"""Disable polling."""
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self):
|
||||||
|
"""Return the state of the sensor."""
|
||||||
|
return self._state
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unit_of_measurement(self):
|
||||||
|
"""Return the unit of measurement."""
|
||||||
|
return self._units
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unique_id(self):
|
||||||
|
"""Return a unique, unchanging string that represents this sensor."""
|
||||||
|
return '{0}_{1}'.format(self._mac_address, self._sensor_name)
|
||||||
|
|
||||||
|
async def async_added_to_hass(self):
|
||||||
|
"""Register callbacks."""
|
||||||
|
@callback
|
||||||
|
def update():
|
||||||
|
"""Update the state."""
|
||||||
|
self.async_schedule_update_ha_state(True)
|
||||||
|
|
||||||
|
self._async_unsub_dispatcher_connect = async_dispatcher_connect(
|
||||||
|
self.hass, TOPIC_UPDATE, update)
|
||||||
|
|
||||||
|
async def async_will_remove_from_hass(self):
|
||||||
|
"""Disconnect dispatcher listener when removed."""
|
||||||
|
if self._async_unsub_dispatcher_connect:
|
||||||
|
self._async_unsub_dispatcher_connect()
|
||||||
|
|
||||||
|
async def async_update(self):
|
||||||
|
"""Fetch new state data for the sensor."""
|
||||||
|
self._state = self._ambient.stations[
|
||||||
|
self._mac_address][ATTR_LAST_DATA].get(self._sensor_type)
|
19
homeassistant/components/ambient_station/strings.json
Normal file
19
homeassistant/components/ambient_station/strings.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"title": "Ambient PWS",
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"title": "Fill in your information",
|
||||||
|
"data": {
|
||||||
|
"api_key": "API Key",
|
||||||
|
"app_key": "Application Key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"identifier_exists": "Application Key and/or API Key already registered",
|
||||||
|
"invalid_key": "Invalid API Key and/or Application Key",
|
||||||
|
"no_devices": "No devices found in account"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,212 +0,0 @@
|
|||||||
"""
|
|
||||||
Support for Ambient Weather Station Service.
|
|
||||||
|
|
||||||
For more details about this platform, please refer to the documentation at
|
|
||||||
https://home-assistant.io/components/sensor.ambient_station/
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from datetime import timedelta
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import voluptuous as vol
|
|
||||||
|
|
||||||
from homeassistant.components.sensor import PLATFORM_SCHEMA
|
|
||||||
from homeassistant.const import CONF_API_KEY, CONF_MONITORED_CONDITIONS
|
|
||||||
import homeassistant.helpers.config_validation as cv
|
|
||||||
from homeassistant.helpers.entity import Entity
|
|
||||||
from homeassistant.util import Throttle
|
|
||||||
|
|
||||||
REQUIREMENTS = ['ambient_api==1.5.2']
|
|
||||||
|
|
||||||
CONF_APP_KEY = 'app_key'
|
|
||||||
|
|
||||||
SENSOR_NAME = 0
|
|
||||||
SENSOR_UNITS = 1
|
|
||||||
|
|
||||||
CONF_UNITS = 'units'
|
|
||||||
UNITS_US = 'us'
|
|
||||||
UNITS_SI = 'si'
|
|
||||||
UNIT_SYSTEM = {UNITS_US: 0, UNITS_SI: 1}
|
|
||||||
|
|
||||||
SCAN_INTERVAL = timedelta(seconds=300)
|
|
||||||
|
|
||||||
SENSOR_TYPES = {
|
|
||||||
'winddir': ['Wind Dir', '°'],
|
|
||||||
'windspeedmph': ['Wind Speed', 'mph'],
|
|
||||||
'windgustmph': ['Wind Gust', 'mph'],
|
|
||||||
'maxdailygust': ['Max Gust', 'mph'],
|
|
||||||
'windgustdir': ['Gust Dir', '°'],
|
|
||||||
'windspdmph_avg2m': ['Wind Avg 2m', 'mph'],
|
|
||||||
'winddir_avg2m': ['Wind Dir Avg 2m', 'mph'],
|
|
||||||
'windspdmph_avg10m': ['Wind Avg 10m', 'mph'],
|
|
||||||
'winddir_avg10m': ['Wind Dir Avg 10m', '°'],
|
|
||||||
'humidity': ['Humidity', '%'],
|
|
||||||
'humidityin': ['Humidity In', '%'],
|
|
||||||
'tempf': ['Temp', ['°F', '°C']],
|
|
||||||
'tempinf': ['Inside Temp', ['°F', '°C']],
|
|
||||||
'battout': ['Battery', ''],
|
|
||||||
'hourlyrainin': ['Hourly Rain Rate', 'in/hr'],
|
|
||||||
'dailyrainin': ['Daily Rain', 'in'],
|
|
||||||
'24hourrainin': ['24 Hr Rain', 'in'],
|
|
||||||
'weeklyrainin': ['Weekly Rain', 'in'],
|
|
||||||
'monthlyrainin': ['Monthly Rain', 'in'],
|
|
||||||
'yearlyrainin': ['Yearly Rain', 'in'],
|
|
||||||
'eventrainin': ['Event Rain', 'in'],
|
|
||||||
'totalrainin': ['Lifetime Rain', 'in'],
|
|
||||||
'baromrelin': ['Rel Pressure', 'inHg'],
|
|
||||||
'baromabsin': ['Abs Pressure', 'inHg'],
|
|
||||||
'uv': ['uv', 'Index'],
|
|
||||||
'solarradiation': ['Solar Rad', 'W/m^2'],
|
|
||||||
'co2': ['co2', 'ppm'],
|
|
||||||
'lastRain': ['Last Rain', ''],
|
|
||||||
'dewPoint': ['Dew Point', ['°F', '°C']],
|
|
||||||
'feelsLike': ['Feels Like', ['°F', '°C']],
|
|
||||||
}
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
|
||||||
vol.Required(CONF_API_KEY): cv.string,
|
|
||||||
vol.Required(CONF_APP_KEY): cv.string,
|
|
||||||
vol.Required(CONF_MONITORED_CONDITIONS):
|
|
||||||
vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
|
|
||||||
vol.Optional(CONF_UNITS): vol.In([UNITS_SI, UNITS_US]),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
|
||||||
"""Initialze each sensor platform for each monitored condition."""
|
|
||||||
api_key = config[CONF_API_KEY]
|
|
||||||
app_key = config[CONF_APP_KEY]
|
|
||||||
station_data = AmbientStationData(hass, api_key, app_key)
|
|
||||||
if not station_data.connect_success:
|
|
||||||
_LOGGER.error("Could not connect to weather station API")
|
|
||||||
return
|
|
||||||
|
|
||||||
sensor_list = []
|
|
||||||
|
|
||||||
if CONF_UNITS in config:
|
|
||||||
sys_units = config[CONF_UNITS]
|
|
||||||
elif hass.config.units.is_metric:
|
|
||||||
sys_units = UNITS_SI
|
|
||||||
else:
|
|
||||||
sys_units = UNITS_US
|
|
||||||
|
|
||||||
for condition in config[CONF_MONITORED_CONDITIONS]:
|
|
||||||
# create a sensor object for each monitored condition
|
|
||||||
sensor_params = SENSOR_TYPES[condition]
|
|
||||||
name = sensor_params[SENSOR_NAME]
|
|
||||||
units = sensor_params[SENSOR_UNITS]
|
|
||||||
if isinstance(units, list):
|
|
||||||
units = sensor_params[SENSOR_UNITS][UNIT_SYSTEM[sys_units]]
|
|
||||||
|
|
||||||
sensor_list.append(AmbientWeatherSensor(station_data, condition,
|
|
||||||
name, units))
|
|
||||||
|
|
||||||
add_entities(sensor_list)
|
|
||||||
|
|
||||||
|
|
||||||
class AmbientWeatherSensor(Entity):
|
|
||||||
"""Representation of a Sensor."""
|
|
||||||
|
|
||||||
def __init__(self, station_data, condition, name, units):
|
|
||||||
"""Initialize the sensor."""
|
|
||||||
self._state = None
|
|
||||||
self.station_data = station_data
|
|
||||||
self._condition = condition
|
|
||||||
self._name = name
|
|
||||||
self._units = units
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self):
|
|
||||||
"""Return the name of the sensor."""
|
|
||||||
return self._name
|
|
||||||
|
|
||||||
@property
|
|
||||||
def state(self):
|
|
||||||
"""Return the state of the sensor."""
|
|
||||||
return self._state
|
|
||||||
|
|
||||||
@property
|
|
||||||
def unit_of_measurement(self):
|
|
||||||
"""Return the unit of measurement."""
|
|
||||||
return self._units
|
|
||||||
|
|
||||||
async def async_update(self):
|
|
||||||
"""Fetch new state data for the sensor.
|
|
||||||
|
|
||||||
This is the only method that should fetch new data for Home Assistant.
|
|
||||||
"""
|
|
||||||
_LOGGER.debug("Getting data for sensor: %s", self._name)
|
|
||||||
data = await self.station_data.get_data()
|
|
||||||
if data is None:
|
|
||||||
# update likely got throttled and returned None, so use the cached
|
|
||||||
# data from the station_data object
|
|
||||||
self._state = self.station_data.data[self._condition]
|
|
||||||
else:
|
|
||||||
if self._condition in data:
|
|
||||||
self._state = data[self._condition]
|
|
||||||
else:
|
|
||||||
_LOGGER.warning("%s sensor data not available from the "
|
|
||||||
"station", self._condition)
|
|
||||||
|
|
||||||
_LOGGER.debug("Sensor: %s | Data: %s", self._name, self._state)
|
|
||||||
|
|
||||||
|
|
||||||
class AmbientStationData:
|
|
||||||
"""Class to interface with ambient-api library."""
|
|
||||||
|
|
||||||
def __init__(self, hass, api_key, app_key):
|
|
||||||
"""Initialize station data object."""
|
|
||||||
self.hass = hass
|
|
||||||
self._api_keys = {
|
|
||||||
'AMBIENT_ENDPOINT':
|
|
||||||
'https://api.ambientweather.net/v1',
|
|
||||||
'AMBIENT_API_KEY': api_key,
|
|
||||||
'AMBIENT_APPLICATION_KEY': app_key,
|
|
||||||
'log_level': 'DEBUG'
|
|
||||||
}
|
|
||||||
|
|
||||||
self.data = None
|
|
||||||
self._station = None
|
|
||||||
self._api = None
|
|
||||||
self._devices = None
|
|
||||||
self.connect_success = False
|
|
||||||
|
|
||||||
self.get_data = Throttle(SCAN_INTERVAL)(self.async_update)
|
|
||||||
self._connect_api() # attempt to connect to API
|
|
||||||
|
|
||||||
async def async_update(self):
|
|
||||||
"""Get new data."""
|
|
||||||
# refresh API connection since servers turn over nightly
|
|
||||||
_LOGGER.debug("Getting new data from server")
|
|
||||||
new_data = None
|
|
||||||
await self.hass.async_add_executor_job(self._connect_api)
|
|
||||||
await asyncio.sleep(2) # need minimum 2 seconds between API calls
|
|
||||||
if self._station is not None:
|
|
||||||
data = await self.hass.async_add_executor_job(
|
|
||||||
self._station.get_data)
|
|
||||||
if data is not None:
|
|
||||||
new_data = data[0]
|
|
||||||
self.data = new_data
|
|
||||||
else:
|
|
||||||
_LOGGER.debug("data is None type")
|
|
||||||
else:
|
|
||||||
_LOGGER.debug("Station is None type")
|
|
||||||
|
|
||||||
return new_data
|
|
||||||
|
|
||||||
def _connect_api(self):
|
|
||||||
"""Connect to the API and capture new data."""
|
|
||||||
from ambient_api.ambientapi import AmbientAPI
|
|
||||||
|
|
||||||
self._api = AmbientAPI(**self._api_keys)
|
|
||||||
self._devices = self._api.get_devices()
|
|
||||||
|
|
||||||
if self._devices:
|
|
||||||
self._station = self._devices[0]
|
|
||||||
if self._station is not None:
|
|
||||||
self.connect_success = True
|
|
||||||
else:
|
|
||||||
_LOGGER.debug("No station devices available")
|
|
@ -135,6 +135,7 @@ SOURCE_IMPORT = 'import'
|
|||||||
HANDLERS = Registry()
|
HANDLERS = Registry()
|
||||||
# Components that have config flows. In future we will auto-generate this list.
|
# Components that have config flows. In future we will auto-generate this list.
|
||||||
FLOWS = [
|
FLOWS = [
|
||||||
|
'ambient_station',
|
||||||
'cast',
|
'cast',
|
||||||
'daikin',
|
'daikin',
|
||||||
'deconz',
|
'deconz',
|
||||||
|
@ -86,6 +86,9 @@ abodepy==0.15.0
|
|||||||
# homeassistant.components.media_player.frontier_silicon
|
# homeassistant.components.media_player.frontier_silicon
|
||||||
afsapi==0.0.4
|
afsapi==0.0.4
|
||||||
|
|
||||||
|
# homeassistant.components.ambient_station
|
||||||
|
aioambient==0.1.0
|
||||||
|
|
||||||
# homeassistant.components.asuswrt
|
# homeassistant.components.asuswrt
|
||||||
aioasuswrt==1.1.18
|
aioasuswrt==1.1.18
|
||||||
|
|
||||||
@ -141,9 +144,6 @@ alarmdecoder==1.13.2
|
|||||||
# homeassistant.components.sensor.alpha_vantage
|
# homeassistant.components.sensor.alpha_vantage
|
||||||
alpha_vantage==2.1.0
|
alpha_vantage==2.1.0
|
||||||
|
|
||||||
# homeassistant.components.sensor.ambient_station
|
|
||||||
ambient_api==1.5.2
|
|
||||||
|
|
||||||
# homeassistant.components.amcrest
|
# homeassistant.components.amcrest
|
||||||
amcrest==1.2.3
|
amcrest==1.2.3
|
||||||
|
|
||||||
|
@ -30,6 +30,9 @@ PyTransportNSW==0.1.1
|
|||||||
# homeassistant.components.notify.yessssms
|
# homeassistant.components.notify.yessssms
|
||||||
YesssSMS==0.2.3
|
YesssSMS==0.2.3
|
||||||
|
|
||||||
|
# homeassistant.components.ambient_station
|
||||||
|
aioambient==0.1.0
|
||||||
|
|
||||||
# homeassistant.components.device_tracker.automatic
|
# homeassistant.components.device_tracker.automatic
|
||||||
aioautomatic==0.6.5
|
aioautomatic==0.6.5
|
||||||
|
|
||||||
|
@ -36,6 +36,7 @@ COMMENT_REQUIREMENTS = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
TEST_REQUIREMENTS = (
|
TEST_REQUIREMENTS = (
|
||||||
|
'aioambient',
|
||||||
'aioautomatic',
|
'aioautomatic',
|
||||||
'aiohttp_cors',
|
'aiohttp_cors',
|
||||||
'aiohue',
|
'aiohue',
|
||||||
|
1
tests/components/ambient_station/__init__.py
Normal file
1
tests/components/ambient_station/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Define tests for the Ambient PWS component."""
|
130
tests/components/ambient_station/test_config_flow.py
Normal file
130
tests/components/ambient_station/test_config_flow.py
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
"""Define tests for the Ambient PWS config flow."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
import aioambient
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant import data_entry_flow
|
||||||
|
from homeassistant.components.ambient_station import (
|
||||||
|
CONF_APP_KEY, DOMAIN, config_flow)
|
||||||
|
from homeassistant.const import CONF_API_KEY
|
||||||
|
|
||||||
|
from tests.common import (
|
||||||
|
load_fixture, MockConfigEntry, MockDependency, mock_coro)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def get_devices_response():
|
||||||
|
"""Define a fixture for a successful /devices response."""
|
||||||
|
return mock_coro()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_aioambient(get_devices_response):
|
||||||
|
"""Mock the aioambient library."""
|
||||||
|
with MockDependency('aioambient') as mock_aioambient_:
|
||||||
|
mock_aioambient_.Client(
|
||||||
|
).api.get_devices.return_value = get_devices_response
|
||||||
|
yield mock_aioambient_
|
||||||
|
|
||||||
|
|
||||||
|
async def test_duplicate_error(hass):
|
||||||
|
"""Test that errors are shown when duplicates are added."""
|
||||||
|
conf = {
|
||||||
|
CONF_API_KEY: '12345abcde12345abcde',
|
||||||
|
CONF_APP_KEY: '67890fghij67890fghij',
|
||||||
|
}
|
||||||
|
|
||||||
|
MockConfigEntry(domain=DOMAIN, data=conf).add_to_hass(hass)
|
||||||
|
flow = config_flow.AmbientStationFlowHandler()
|
||||||
|
flow.hass = hass
|
||||||
|
|
||||||
|
result = await flow.async_step_user(user_input=conf)
|
||||||
|
assert result['errors'] == {CONF_APP_KEY: 'identifier_exists'}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'get_devices_response',
|
||||||
|
[mock_coro(exception=aioambient.errors.AmbientError)])
|
||||||
|
async def test_invalid_api_key(hass, mock_aioambient):
|
||||||
|
"""Test that an invalid API/App Key throws an error."""
|
||||||
|
conf = {
|
||||||
|
CONF_API_KEY: '12345abcde12345abcde',
|
||||||
|
CONF_APP_KEY: '67890fghij67890fghij',
|
||||||
|
}
|
||||||
|
|
||||||
|
flow = config_flow.AmbientStationFlowHandler()
|
||||||
|
flow.hass = hass
|
||||||
|
|
||||||
|
result = await flow.async_step_user(user_input=conf)
|
||||||
|
assert result['errors'] == {'base': 'invalid_key'}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('get_devices_response', [mock_coro(return_value=[])])
|
||||||
|
async def test_no_devices(hass, mock_aioambient):
|
||||||
|
"""Test that an account with no associated devices throws an error."""
|
||||||
|
conf = {
|
||||||
|
CONF_API_KEY: '12345abcde12345abcde',
|
||||||
|
CONF_APP_KEY: '67890fghij67890fghij',
|
||||||
|
}
|
||||||
|
|
||||||
|
flow = config_flow.AmbientStationFlowHandler()
|
||||||
|
flow.hass = hass
|
||||||
|
|
||||||
|
result = await flow.async_step_user(user_input=conf)
|
||||||
|
assert result['errors'] == {'base': 'no_devices'}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_show_form(hass):
|
||||||
|
"""Test that the form is served with no input."""
|
||||||
|
flow = config_flow.AmbientStationFlowHandler()
|
||||||
|
flow.hass = hass
|
||||||
|
|
||||||
|
result = await flow.async_step_user(user_input=None)
|
||||||
|
|
||||||
|
assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result['step_id'] == 'user'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'get_devices_response',
|
||||||
|
[mock_coro(return_value=json.loads(load_fixture('ambient_devices.json')))])
|
||||||
|
async def test_step_import(hass, mock_aioambient):
|
||||||
|
"""Test that the import step works."""
|
||||||
|
conf = {
|
||||||
|
CONF_API_KEY: '12345abcde12345abcde',
|
||||||
|
CONF_APP_KEY: '67890fghij67890fghij',
|
||||||
|
}
|
||||||
|
|
||||||
|
flow = config_flow.AmbientStationFlowHandler()
|
||||||
|
flow.hass = hass
|
||||||
|
|
||||||
|
result = await flow.async_step_import(import_config=conf)
|
||||||
|
assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||||
|
assert result['title'] == '67890fghij67'
|
||||||
|
assert result['data'] == {
|
||||||
|
CONF_API_KEY: '12345abcde12345abcde',
|
||||||
|
CONF_APP_KEY: '67890fghij67890fghij',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'get_devices_response',
|
||||||
|
[mock_coro(return_value=json.loads(load_fixture('ambient_devices.json')))])
|
||||||
|
async def test_step_user(hass, mock_aioambient):
|
||||||
|
"""Test that the user step works."""
|
||||||
|
conf = {
|
||||||
|
CONF_API_KEY: '12345abcde12345abcde',
|
||||||
|
CONF_APP_KEY: '67890fghij67890fghij',
|
||||||
|
}
|
||||||
|
|
||||||
|
flow = config_flow.AmbientStationFlowHandler()
|
||||||
|
flow.hass = hass
|
||||||
|
|
||||||
|
result = await flow.async_step_user(user_input=conf)
|
||||||
|
assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||||
|
assert result['title'] == '67890fghij67'
|
||||||
|
assert result['data'] == {
|
||||||
|
CONF_API_KEY: '12345abcde12345abcde',
|
||||||
|
CONF_APP_KEY: '67890fghij67890fghij',
|
||||||
|
}
|
15
tests/fixtures/ambient_devices.json
vendored
Normal file
15
tests/fixtures/ambient_devices.json
vendored
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
[{
|
||||||
|
"macAddress": "12:34:56:78:90:AB",
|
||||||
|
"lastData": {
|
||||||
|
"dateutc": 1546889640000,
|
||||||
|
"baromrelin": 30.09,
|
||||||
|
"baromabsin": 24.61,
|
||||||
|
"tempinf": 68.9,
|
||||||
|
"humidityin": 30,
|
||||||
|
"date": "2019-01-07T19:34:00.000Z"
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"name": "Home",
|
||||||
|
"location": "Home"
|
||||||
|
}
|
||||||
|
}]
|
Loading…
x
Reference in New Issue
Block a user