diff --git a/custom_components/plugwise/__init__.py b/custom_components/plugwise/__init__.py index 35dc73047..bcbd161c8 100644 --- a/custom_components/plugwise/__init__.py +++ b/custom_components/plugwise/__init__.py @@ -2,42 +2,15 @@ import asyncio import logging -from datetime import timedelta -from typing import Dict -import async_timeout import voluptuous as vol -from Plugwise_Smile.Smile import Smile from homeassistant.config_entries import ConfigEntry -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, - CONF_PASSWORD, - CONF_PORT, - CONF_SCAN_INTERVAL, - CONF_USERNAME, -) +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant -from .const import ( - ALL_PLATFORMS, - API, - COORDINATOR, - DEFAULT_PORT, - DEFAULT_SCAN_INTERVAL, - DOMAIN, - SENSOR_PLATFORMS, - SERVICE_DELETE, - 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) @@ -48,161 +21,12 @@ async def async_setup(hass: HomeAssistant, config: dict): """Set up the Plugwise platform.""" return True - 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) - - # When migrating from Core to beta, add the username to ConfigEntry - entry_updates = {} - try: - username = entry.data[CONF_USERNAME] - except KeyError: - username = DEFAULT_USERNAME - data = {**entry.data} - data.update({"username": username}) - entry_updates["data"] = data - - if entry_updates: - hass.config_entries.async_update_entry(entry, **entry_updates) - - api = Smile( - host=entry.data[CONF_HOST], - username=entry.data[CONF_USERNAME], - 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 %s", smile_name) - raise ConfigEntryNotReady - - except Smile.InvalidAuthentication: - _LOGGER.error("Invalid username or Smile ID") - return False - - except Smile.PlugwiseError as err: - _LOGGER.error("Error while communicating to Smile %s", api.smile_name) - raise ConfigEntryNotReady from err - - except asyncio.TimeoutError as err: - _LOGGER.error("Timeout while connecting to Smile %s", api.smile_name) - 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.""" - _LOGGER.debug("Updating Smile %s", api.smile_name) - try: - async with async_timeout.timeout(60): - await api.full_update_device() - _LOGGER.debug("Succesfully updated Smile %s", api.smile_name) - return True - except Smile.XMLDataMissingError as err: - _LOGGER.debug( - "Updating Smile failed, expected XML data for %s", api.smile_name - ) - raise UpdateFailed("Smile update failed") from err - except Smile.PlugwiseError as err: - _LOGGER.debug( - "Updating Smile failed, generic failure for %s", api.smile_name - ) - raise UpdateFailed("Smile update failed") from err - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - name=f"Smile {api.smile_name}", - update_method=async_update_data, - update_interval=update_interval, - ) - - await coordinator.async_refresh() - - if not coordinator.last_update_success: - raise ConfigEntryNotReady - - _LOGGER.debug("Async update interval %s", update_interval) - - api.get_all_devices() - - undo_listener = entry.add_update_listener(_update_listener) - - # Migrate to a valid unique_id when needed - 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) - - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - API: api, - COORDINATOR: coordinator, - UNDO_UPDATE_LISTENER: undo_listener, - } - - _LOGGER.debug("Gateway is %s", api.gateway_id) - - _LOGGER.debug("Gateway sofware version is %s", api.smile_version) - _LOGGER.debug("Appliances is %s", api.get_all_appliances()) - _LOGGER.debug("Scan thermostats is %s", api.scan_thermostats()) - _LOGGER.debug("Locations (matched) is %s", api.match_locations()) - - 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() - _LOGGER.debug("Single master thermostat = %s", single_master_thermostat) - - platforms = ALL_PLATFORMS - if single_master_thermostat is None: - platforms = SENSOR_PLATFORMS - - async def async_delete_notification(self): - """Service: delete the Plugwise Notification.""" - _LOGGER.debug("Service delete PW Notification called for %s", api.smile_name) - try: - deleted = await api.delete_notification() - _LOGGER.debug("PW Notification deleted: %s", deleted) - except Smile.PlugwiseError: - _LOGGER.debug( - "Failed to delete the Plugwise Notification for %s", api.smile_name - ) - - for component in platforms: - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, component) - ) - if component == "climate": - hass.services.async_register( - DOMAIN, SERVICE_DELETE, async_delete_notification, schema=vol.Schema({}) - ) - - 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): """Unload a config entry.""" @@ -220,60 +44,4 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): 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 + return unload_ok \ No newline at end of file diff --git a/custom_components/plugwise/climate.py b/custom_components/plugwise/climate.py index 0c28d9ff1..dd9aee9b7 100644 --- a/custom_components/plugwise/climate.py +++ b/custom_components/plugwise/climate.py @@ -18,7 +18,7 @@ from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS from homeassistant.core import callback -from . import SmileGateway +from .gateway import SmileGateway from .const import ( API, COORDINATOR, diff --git a/custom_components/plugwise/config_flow.py b/custom_components/plugwise/config_flow.py index 5240c0605..54b575282 100644 --- a/custom_components/plugwise/config_flow.py +++ b/custom_components/plugwise/config_flow.py @@ -24,34 +24,34 @@ DEFAULT_SCAN_INTERVAL, DOMAIN, ZEROCONF_MAP, - ) # pylint:disable=unused-import + ) _LOGGER = logging.getLogger(__name__) -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.update( + base_gw_schema.update( { vol.Required(CONF_USERNAME, description={"suggested_value": "smile"}): str, 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 whether the user input allows us to connect. + Validate whether the user input allows us to connect to the gateway. - 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) @@ -74,6 +74,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.""" @@ -108,8 +111,8 @@ async def async_step_zeroconf(self, discovery_info: DiscoveryInfoType): } 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: @@ -123,7 +126,7 @@ async def async_step_user(self, user_input=None): 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[CONF_BASE] = "cannot_connect" @@ -142,11 +145,18 @@ async def async_step_user(self, user_input=None): 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): diff --git a/custom_components/plugwise/gateway.py b/custom_components/plugwise/gateway.py new file mode 100644 index 000000000..bec827bb9 --- /dev/null +++ b/custom_components/plugwise/gateway.py @@ -0,0 +1,255 @@ +"""Plugwise platform for Home Assistant Core.""" + +import asyncio +import logging +from datetime import timedelta +from typing import Dict + +import async_timeout +import voluptuous as vol +from Plugwise_Smile.Smile import Smile + +from homeassistant.config_entries import ConfigEntry +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, + CONF_PASSWORD, + CONF_PORT, + CONF_SCAN_INTERVAL, + CONF_USERNAME, +) + +from .const import ( + ALL_PLATFORMS, + API, + COORDINATOR, + DEFAULT_PORT, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + SENSOR_PLATFORMS, + SERVICE_DELETE, + 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) + + # When migrating from Core to beta, add the username to ConfigEntry + entry_updates = {} + try: + username = entry.data[CONF_USERNAME] + except KeyError: + username = DEFAULT_USERNAME + data = {**entry.data} + data.update({"username": username}) + entry_updates["data"] = data + + if entry_updates: + hass.config_entries.async_update_entry(entry, **entry_updates) + + api = Smile( + host=entry.data[CONF_HOST], + username=entry.data[CONF_USERNAME], + 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 %s", smile_name) + raise ConfigEntryNotReady + + except Smile.InvalidAuthentication: + _LOGGER.error("Invalid username or Smile ID") + return False + + except Smile.PlugwiseError as err: + _LOGGER.error("Error while communicating to Smile %s", api.smile_name) + raise ConfigEntryNotReady from err + + except asyncio.TimeoutError as err: + _LOGGER.error("Timeout while connecting to Smile %s", api.smile_name) + 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.""" + _LOGGER.debug("Updating Smile %s", api.smile_name) + try: + async with async_timeout.timeout(60): + await api.full_update_device() + _LOGGER.debug("Succesfully updated Smile %s", api.smile_name) + return True + except Smile.XMLDataMissingError as err: + _LOGGER.debug( + "Updating Smile failed, expected XML data for %s", api.smile_name + ) + raise UpdateFailed("Smile update failed") from err + except Smile.PlugwiseError as err: + _LOGGER.debug( + "Updating Smile failed, generic failure for %s", api.smile_name + ) + raise UpdateFailed("Smile update failed") from err + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=f"Smile {api.smile_name}", + update_method=async_update_data, + update_interval=update_interval, + ) + + await coordinator.async_refresh() + + if not coordinator.last_update_success: + raise ConfigEntryNotReady + + _LOGGER.debug("Async update interval %s", update_interval) + + api.get_all_devices() + + undo_listener = entry.add_update_listener(_update_listener) + + # Migrate to a valid unique_id when needed + 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) + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { + API: api, + COORDINATOR: coordinator, + UNDO_UPDATE_LISTENER: undo_listener, + } + + _LOGGER.debug("Gateway is %s", api.gateway_id) + + _LOGGER.debug("Gateway sofware version is %s", api.smile_version) + _LOGGER.debug("Appliances is %s", api.get_all_appliances()) + _LOGGER.debug("Scan thermostats is %s", api.scan_thermostats()) + _LOGGER.debug("Locations (matched) is %s", api.match_locations()) + + 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() + _LOGGER.debug("Single master thermostat = %s", single_master_thermostat) + + platforms = ALL_PLATFORMS + if single_master_thermostat is None: + platforms = SENSOR_PLATFORMS + + async def async_delete_notification(self): + """Service: delete the Plugwise Notification.""" + _LOGGER.debug("Service delete PW Notification called for %s", api.smile_name) + try: + deleted = await api.delete_notification() + _LOGGER.debug("PW Notification deleted: %s", deleted) + except Smile.PlugwiseError: + _LOGGER.debug( + "Failed to delete the Plugwise Notification for %s", api.smile_name + ) + + for component in platforms: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + if component == "climate": + hass.services.async_register( + DOMAIN, SERVICE_DELETE, async_delete_notification, schema=vol.Schema({}) + ) + + 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) + ) + + +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 \ No newline at end of file diff --git a/custom_components/plugwise/sensor.py b/custom_components/plugwise/sensor.py index 429a38207..7a1c06b07 100644 --- a/custom_components/plugwise/sensor.py +++ b/custom_components/plugwise/sensor.py @@ -11,7 +11,7 @@ from homeassistant.core import callback from homeassistant.helpers.entity import Entity -from . import SmileGateway +from .gateway import SmileGateway from .const import ( API, AUX_DEV_SENSOR_MAP, diff --git a/custom_components/plugwise/strings.json b/custom_components/plugwise/strings.json index 0d77c8a25..cb5baa599 100644 --- a/custom_components/plugwise/strings.json +++ b/custom_components/plugwise/strings.json @@ -13,6 +13,13 @@ "flow_title": "Smile: {name}", "step": { "user": { + "title": "Plugwise type", + "description": "Product:", + "data": { + "flow_type": "Connection type" + } + }, + "user_gateway": { "title": "Connect to the Smile", "description": "Please enter:", "data": { diff --git a/custom_components/plugwise/switch.py b/custom_components/plugwise/switch.py index e057d72a4..ff0532813 100644 --- a/custom_components/plugwise/switch.py +++ b/custom_components/plugwise/switch.py @@ -8,7 +8,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.core import callback -from . import SmileGateway +from .gateway import SmileGateway from .const import API, COORDINATOR, DOMAIN, SWITCH_CLASSES, SWITCH_ICON _LOGGER = logging.getLogger(__name__) diff --git a/custom_components/plugwise/translations/en.json b/custom_components/plugwise/translations/en.json index ba8ed8e02..cb5baa599 100644 --- a/custom_components/plugwise/translations/en.json +++ b/custom_components/plugwise/translations/en.json @@ -13,10 +13,17 @@ "flow_title": "Smile: {name}", "step": { "user": { + "title": "Plugwise type", + "description": "Product:", + "data": { + "flow_type": "Connection type" + } + }, + "user_gateway": { "title": "Connect to the Smile", "description": "Please enter:", "data": { - "host": "Smile IP-address(:port)", + "host": "Smile IP-address", "username" : "Smile Username", "password": "Smile ID", "port": "Smile port number" diff --git a/custom_components/plugwise/translations/nl.json b/custom_components/plugwise/translations/nl.json index 088c059a1..dd149b908 100644 --- a/custom_components/plugwise/translations/nl.json +++ b/custom_components/plugwise/translations/nl.json @@ -13,10 +13,17 @@ "flow_title": "Smile: {name}", "step": { "user": { + "title": "Plugwise type", + "description": "Product:", + "data": { + "flow_type": "Connectie type" + } + }, + "user_gateway": { "title": "Verbinden met de Plugwise Smile", "description": "Aub invoeren:", "data": { - "host": "Smile IP-adres(:poort)", + "host": "Smile IP-adres", "username": "Smile gebruikersnaam", "password": "Smile ID", "port": "Smile poort nummer"