Prepare Plugwise integration for USB products (#41201)

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
pull/41440/head
Tom 2020-10-07 22:25:42 +02:00 committed by GitHub
parent 343e5d64b8
commit b8f291d58e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 299 additions and 212 deletions

View File

@ -1,41 +1,21 @@
"""Plugwise platform for Home Assistant Core."""
import asyncio
from datetime import timedelta
import logging
from typing import Dict
from Plugwise_Smile.Smile import Smile
import async_timeout
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_SCAN_INTERVAL
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
UpdateFailed,
)
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from .const import (
COORDINATOR,
DEFAULT_PORT,
DEFAULT_SCAN_INTERVAL,
DOMAIN,
UNDO_UPDATE_LISTENER,
)
from .const import ALL_PLATFORMS, DOMAIN, UNDO_UPDATE_LISTENER
from .gateway import async_setup_entry_gw
CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA)
_LOGGER = logging.getLogger(__name__)
SENSOR_PLATFORMS = ["sensor"]
ALL_PLATFORMS = ["binary_sensor", "climate", "sensor", "switch"]
async def async_setup(hass: HomeAssistant, config: dict):
"""Set up the Plugwise platform."""
@ -43,108 +23,11 @@ async def async_setup(hass: HomeAssistant, config: dict):
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Plugwise Smiles from a config entry."""
websession = async_get_clientsession(hass, verify_ssl=False)
api = Smile(
host=entry.data[CONF_HOST],
password=entry.data[CONF_PASSWORD],
port=entry.data.get(CONF_PORT, DEFAULT_PORT),
timeout=30,
websession=websession,
)
try:
connected = await api.connect()
if not connected:
_LOGGER.error("Unable to connect to Smile")
raise ConfigEntryNotReady
except Smile.InvalidAuthentication:
_LOGGER.error("Invalid Smile ID")
return False
except Smile.PlugwiseError as err:
_LOGGER.error("Error while communicating to device")
raise ConfigEntryNotReady from err
except asyncio.TimeoutError as err:
_LOGGER.error("Timeout while connecting to Smile")
raise ConfigEntryNotReady from err
update_interval = timedelta(
seconds=entry.options.get(
CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL[api.smile_type]
)
)
async def async_update_data():
"""Update data via API endpoint."""
try:
async with async_timeout.timeout(10):
await api.full_update_device()
return True
except Smile.XMLDataMissingError as err:
raise UpdateFailed("Smile update failed") from err
coordinator = DataUpdateCoordinator(
hass,
_LOGGER,
name="Smile",
update_method=async_update_data,
update_interval=update_interval,
)
await coordinator.async_refresh()
if not coordinator.last_update_success:
raise ConfigEntryNotReady
api.get_all_devices()
if entry.unique_id is None:
if api.smile_version[0] != "1.8.0":
hass.config_entries.async_update_entry(entry, unique_id=api.smile_hostname)
undo_listener = entry.add_update_listener(_update_listener)
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
"api": api,
COORDINATOR: coordinator,
UNDO_UPDATE_LISTENER: undo_listener,
}
device_registry = await dr.async_get_registry(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, api.gateway_id)},
manufacturer="Plugwise",
name=entry.title,
model=f"Smile {api.smile_name}",
sw_version=api.smile_version[0],
)
single_master_thermostat = api.single_master_thermostat()
platforms = ALL_PLATFORMS
if single_master_thermostat is None:
platforms = SENSOR_PLATFORMS
for component in platforms:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, component)
)
return True
async def _update_listener(hass: HomeAssistant, entry: ConfigEntry):
"""Handle options update."""
coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR]
coordinator.update_interval = timedelta(
seconds=entry.options.get(CONF_SCAN_INTERVAL)
)
"""Set up Plugwise components from a config entry."""
if entry.data.get(CONF_HOST):
return await async_setup_entry_gw(hass, entry)
# PLACEHOLDER USB entry setup
return False
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
@ -164,60 +47,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
class SmileGateway(CoordinatorEntity):
"""Represent Smile Gateway."""
def __init__(self, api, coordinator, name, dev_id):
"""Initialise the gateway."""
super().__init__(coordinator)
self._api = api
self._name = name
self._dev_id = dev_id
self._unique_id = None
self._model = None
self._entity_name = self._name
@property
def unique_id(self):
"""Return a unique ID."""
return self._unique_id
@property
def name(self):
"""Return the name of the entity, if any."""
return self._name
@property
def device_info(self) -> Dict[str, any]:
"""Return the device information."""
device_information = {
"identifiers": {(DOMAIN, self._dev_id)},
"name": self._entity_name,
"manufacturer": "Plugwise",
}
if self._model is not None:
device_information["model"] = self._model.replace("_", " ").title()
if self._dev_id != self._api.gateway_id:
device_information["via_device"] = (DOMAIN, self._api.gateway_id)
return device_information
async def async_added_to_hass(self):
"""Subscribe to updates."""
self._async_process_data()
self.async_on_remove(
self.coordinator.async_add_listener(self._async_process_data)
)
@callback
def _async_process_data(self):
"""Interpret and process API data."""
raise NotImplementedError

View File

@ -18,7 +18,6 @@ from homeassistant.components.climate.const import (
from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS
from homeassistant.core import callback
from . import SmileGateway
from .const import (
COORDINATOR,
DEFAULT_MAX_TEMP,
@ -27,6 +26,7 @@ from .const import (
SCHEDULE_OFF,
SCHEDULE_ON,
)
from .gateway import SmileGateway
HVAC_MODES_HEAT_ONLY = [HVAC_MODE_HEAT, HVAC_MODE_AUTO]
HVAC_MODES_HEAT_COOL = [HVAC_MODE_HEAT_COOL, HVAC_MODE_AUTO]

View File

@ -14,35 +14,30 @@ from .const import ( # pylint:disable=unused-import
DEFAULT_PORT,
DEFAULT_SCAN_INTERVAL,
DOMAIN,
ZEROCONF_MAP,
)
_LOGGER = logging.getLogger(__name__)
ZEROCONF_MAP = {
"smile": "P1 DSMR",
"smile_thermo": "Climate (Anna)",
"smile_open_therm": "Climate (Adam)",
}
def _base_schema(discovery_info):
"""Generate base schema."""
base_schema = {}
def _base_gw_schema(discovery_info):
"""Generate base schema for gateways."""
base_gw_schema = {}
if not discovery_info:
base_schema[vol.Required(CONF_HOST)] = str
base_schema[vol.Optional(CONF_PORT, default=DEFAULT_PORT)] = int
base_gw_schema[vol.Required(CONF_HOST)] = str
base_gw_schema[vol.Optional(CONF_PORT, default=DEFAULT_PORT)] = int
base_schema[vol.Required(CONF_PASSWORD)] = str
base_gw_schema[vol.Required(CONF_PASSWORD)] = str
return vol.Schema(base_schema)
return vol.Schema(base_gw_schema)
async def validate_input(hass: core.HomeAssistant, data):
async def validate_gw_input(hass: core.HomeAssistant, data):
"""
Validate the user input allows us to connect.
Validate the user input allows us to connect to the gateray.
Data has the keys from _base_schema() with values provided by the user.
Data has the keys from _base_gw_schema() with values provided by the user.
"""
websession = async_get_clientsession(hass, verify_ssl=False)
@ -64,6 +59,9 @@ async def validate_input(hass: core.HomeAssistant, data):
return api
# PLACEHOLDER USB connection validation
class PlugwiseConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Plugwise Smile."""
@ -95,8 +93,8 @@ class PlugwiseConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
}
return await self.async_step_user()
async def async_step_user(self, user_input=None):
"""Handle the initial step."""
async def async_step_user_gateway(self, user_input=None):
"""Handle the initial step for gateways."""
errors = {}
if user_input is not None:
@ -110,7 +108,7 @@ class PlugwiseConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="already_configured")
try:
api = await validate_input(self.hass, user_input)
api = await validate_gw_input(self.hass, user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
@ -128,11 +126,19 @@ class PlugwiseConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return self.async_create_entry(title=api.smile_name, data=user_input)
return self.async_show_form(
step_id="user",
data_schema=_base_schema(self.discovery_info),
step_id="user_gateway",
data_schema=_base_gw_schema(self.discovery_info),
errors=errors or {},
)
# PLACEHOLDER USB async_step_user_usb and async_step_user_usb_manual_paht
async def async_step_user(self, user_input=None):
"""Handle the initial step."""
# PLACEHOLDER USB vs Gateway Logic
return await self.async_step_user_gateway()
@staticmethod
@callback
def async_get_options_flow(config_entry):

View File

@ -1,6 +1,9 @@
"""Constant for Plugwise component."""
DOMAIN = "plugwise"
SENSOR_PLATFORMS = ["sensor"]
ALL_PLATFORMS = ["binary_sensor", "climate", "sensor", "switch"]
# Sensor mapping
SENSOR_MAP_MODEL = 0
SENSOR_MAP_UOM = 1
@ -42,3 +45,9 @@ FLOW_ON_ICON = "mdi:water-pump"
UNDO_UPDATE_LISTENER = "undo_update_listener"
COORDINATOR = "coordinator"
ZEROCONF_MAP = {
"smile": "P1",
"smile_thermo": "Anna",
"smile_open_therm": "Adam",
}

View File

@ -0,0 +1,217 @@
"""Plugwise platform for Home Assistant Core."""
import asyncio
from datetime import timedelta
import logging
from typing import Dict
from Plugwise_Smile.Smile import Smile
import async_timeout
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_SCAN_INTERVAL
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
UpdateFailed,
)
from .const import (
ALL_PLATFORMS,
COORDINATOR,
DEFAULT_PORT,
DEFAULT_SCAN_INTERVAL,
DOMAIN,
SENSOR_PLATFORMS,
UNDO_UPDATE_LISTENER,
)
CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA)
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry_gw(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Plugwise Smiles from a config entry."""
websession = async_get_clientsession(hass, verify_ssl=False)
api = Smile(
host=entry.data[CONF_HOST],
password=entry.data[CONF_PASSWORD],
port=entry.data.get(CONF_PORT, DEFAULT_PORT),
timeout=30,
websession=websession,
)
try:
connected = await api.connect()
if not connected:
_LOGGER.error("Unable to connect to Smile")
raise ConfigEntryNotReady
except Smile.InvalidAuthentication:
_LOGGER.error("Invalid Smile ID")
return False
except Smile.PlugwiseError as err:
_LOGGER.error("Error while communicating to device")
raise ConfigEntryNotReady from err
except asyncio.TimeoutError as err:
_LOGGER.error("Timeout while connecting to Smile")
raise ConfigEntryNotReady from err
update_interval = timedelta(
seconds=entry.options.get(
CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL[api.smile_type]
)
)
async def async_update_data():
"""Update data via API endpoint."""
try:
async with async_timeout.timeout(10):
await api.full_update_device()
return True
except Smile.XMLDataMissingError as err:
raise UpdateFailed("Smile update failed") from err
coordinator = DataUpdateCoordinator(
hass,
_LOGGER,
name="Smile",
update_method=async_update_data,
update_interval=update_interval,
)
await coordinator.async_refresh()
if not coordinator.last_update_success:
raise ConfigEntryNotReady
api.get_all_devices()
if entry.unique_id is None:
if api.smile_version[0] != "1.8.0":
hass.config_entries.async_update_entry(entry, unique_id=api.smile_hostname)
undo_listener = entry.add_update_listener(_update_listener)
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
"api": api,
COORDINATOR: coordinator,
UNDO_UPDATE_LISTENER: undo_listener,
}
device_registry = await dr.async_get_registry(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, api.gateway_id)},
manufacturer="Plugwise",
name=entry.title,
model=f"Smile {api.smile_name}",
sw_version=api.smile_version[0],
)
single_master_thermostat = api.single_master_thermostat()
platforms = ALL_PLATFORMS
if single_master_thermostat is None:
platforms = SENSOR_PLATFORMS
for component in platforms:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, component)
)
return True
async def _update_listener(hass: HomeAssistant, entry: ConfigEntry):
"""Handle options update."""
coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR]
coordinator.update_interval = timedelta(
seconds=entry.options.get(CONF_SCAN_INTERVAL)
)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Unload a config entry."""
unload_ok = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(entry, component)
for component in ALL_PLATFORMS
]
)
)
hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENER]()
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
class SmileGateway(CoordinatorEntity):
"""Represent Smile Gateway."""
def __init__(self, api, coordinator, name, dev_id):
"""Initialise the gateway."""
super().__init__(coordinator)
self._api = api
self._name = name
self._dev_id = dev_id
self._unique_id = None
self._model = None
self._entity_name = self._name
@property
def unique_id(self):
"""Return a unique ID."""
return self._unique_id
@property
def name(self):
"""Return the name of the entity, if any."""
return self._name
@property
def device_info(self) -> Dict[str, any]:
"""Return the device information."""
device_information = {
"identifiers": {(DOMAIN, self._dev_id)},
"name": self._entity_name,
"manufacturer": "Plugwise",
}
if self._model is not None:
device_information["model"] = self._model.replace("_", " ").title()
if self._dev_id != self._api.gateway_id:
device_information["via_device"] = (DOMAIN, self._api.gateway_id)
return device_information
async def async_added_to_hass(self):
"""Subscribe to updates."""
self._async_process_data()
self.async_on_remove(
self.coordinator.async_add_listener(self._async_process_data)
)
@callback
def _async_process_data(self):
"""Interpret and process API data."""
raise NotImplementedError

View File

@ -19,7 +19,6 @@ from homeassistant.const import (
from homeassistant.core import callback
from homeassistant.helpers.entity import Entity
from . import SmileGateway
from .const import (
COOL_ICON,
COORDINATOR,
@ -32,6 +31,7 @@ from .const import (
SENSOR_MAP_UOM,
UNIT_LUMEN,
)
from .gateway import SmileGateway
_LOGGER = logging.getLogger(__name__)

View File

@ -12,6 +12,13 @@
"config": {
"step": {
"user": {
"title": "Plugwise type",
"description": "Product:",
"data": {
"flow_type": "Connection type"
}
},
"user_gateway": {
"title": "Connect to the Smile",
"description": "Please enter:",
"data": {

View File

@ -7,8 +7,8 @@ from Plugwise_Smile.Smile import Smile
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import callback
from . import SmileGateway
from .const import COORDINATOR, DOMAIN
from .gateway import SmileGateway
_LOGGER = logging.getLogger(__name__)

View File

@ -7,7 +7,7 @@ from Plugwise_Smile.Smile import Smile
import jsonpickle
import pytest
from tests.async_mock import AsyncMock, patch
from tests.async_mock import AsyncMock, Mock, patch
from tests.common import load_fixture
from tests.test_util.aiohttp import AiohttpClientMocker
@ -47,7 +47,7 @@ def mock_smile_error(aioclient_mock: AiohttpClientMocker) -> None:
@pytest.fixture(name="mock_smile_notconnect")
def mock_smile_notconnect():
"""Mock the Plugwise Smile general connection failure for Home Assistant."""
with patch("homeassistant.components.plugwise.Smile") as smile_mock:
with patch("homeassistant.components.plugwise.gateway.Smile") as smile_mock:
smile_mock.InvalidAuthentication = Smile.InvalidAuthentication
smile_mock.ConnectionFailedError = Smile.ConnectionFailedError
smile_mock.PlugwiseError = Smile.PlugwiseError
@ -64,7 +64,7 @@ def _get_device_data(chosen_env, device_id):
def mock_smile_adam():
"""Create a Mock Adam environment for testing exceptions."""
chosen_env = "adam_multiple_devices_per_zone"
with patch("homeassistant.components.plugwise.Smile") as smile_mock:
with patch("homeassistant.components.plugwise.gateway.Smile") as smile_mock:
smile_mock.InvalidAuthentication = Smile.InvalidAuthentication
smile_mock.ConnectionFailedError = Smile.ConnectionFailedError
smile_mock.XMLDataMissingError = Smile.XMLDataMissingError
@ -79,6 +79,9 @@ def mock_smile_adam():
smile_mock.return_value.full_update_device.side_effect = AsyncMock(
return_value=True
)
smile_mock.return_value.single_master_thermostat.side_effect = Mock(
return_value=True
)
smile_mock.return_value.set_schedule_state.side_effect = AsyncMock(
return_value=True
)
@ -104,7 +107,7 @@ def mock_smile_adam():
def mock_smile_anna():
"""Create a Mock Anna environment for testing exceptions."""
chosen_env = "anna_heatpump"
with patch("homeassistant.components.plugwise.Smile") as smile_mock:
with patch("homeassistant.components.plugwise.gateway.Smile") as smile_mock:
smile_mock.InvalidAuthentication = Smile.InvalidAuthentication
smile_mock.ConnectionFailedError = Smile.ConnectionFailedError
smile_mock.XMLDataMissingError = Smile.XMLDataMissingError
@ -119,6 +122,9 @@ def mock_smile_anna():
smile_mock.return_value.full_update_device.side_effect = AsyncMock(
return_value=True
)
smile_mock.return_value.single_master_thermostat.side_effect = Mock(
return_value=True
)
smile_mock.return_value.set_schedule_state.side_effect = AsyncMock(
return_value=True
)
@ -144,7 +150,7 @@ def mock_smile_anna():
def mock_smile_p1():
"""Create a Mock P1 DSMR environment for testing exceptions."""
chosen_env = "p1v3_full_option"
with patch("homeassistant.components.plugwise.Smile") as smile_mock:
with patch("homeassistant.components.plugwise.gateway.Smile") as smile_mock:
smile_mock.InvalidAuthentication = Smile.InvalidAuthentication
smile_mock.ConnectionFailedError = Smile.ConnectionFailedError
smile_mock.XMLDataMissingError = Smile.XMLDataMissingError
@ -160,6 +166,10 @@ def mock_smile_p1():
return_value=True
)
smile_mock.return_value.single_master_thermostat.side_effect = Mock(
return_value=None
)
smile_mock.return_value.get_all_devices.return_value = _read_json(
chosen_env, "get_all_devices"
)

View File

@ -4,11 +4,14 @@ import asyncio
from Plugwise_Smile.Smile import Smile
from homeassistant.components.plugwise import DOMAIN
from homeassistant.components.plugwise.gateway import async_unload_entry
from homeassistant.config_entries import (
ENTRY_STATE_SETUP_ERROR,
ENTRY_STATE_SETUP_RETRY,
)
from tests.common import AsyncMock
from tests.components.plugwise.common import async_init_integration
@ -43,3 +46,12 @@ async def test_smile_adam_xmlerror(hass, mock_smile_adam):
mock_smile_adam.full_update_device.side_effect = Smile.XMLDataMissingError
entry = await async_init_integration(hass, mock_smile_adam)
assert entry.state == ENTRY_STATE_SETUP_RETRY
async def test_unload_entry(hass, mock_smile_adam):
"""Test being able to unload an entry."""
entry = await async_init_integration(hass, mock_smile_adam)
mock_smile_adam.async_reset = AsyncMock(return_value=True)
assert await async_unload_entry(hass, entry)
assert not hass.data[DOMAIN]