diff --git a/.coveragerc b/.coveragerc index 1a48fbea5a8..08cb51912c4 100644 --- a/.coveragerc +++ b/.coveragerc @@ -353,7 +353,12 @@ omit = homeassistant/components/itach/remote.py homeassistant/components/itunes/media_player.py homeassistant/components/joaoapps_join/* - homeassistant/components/juicenet/* + homeassistant/components/juicenet/__init__.py + homeassistant/components/juicenet/const.py + homeassistant/components/juicenet/device.py + homeassistant/components/juicenet/entity.py + homeassistant/components/juicenet/sensor.py + homeassistant/components/juicenet/switch.py homeassistant/components/kaiterra/* homeassistant/components/kankun/switch.py homeassistant/components/keba/* diff --git a/homeassistant/components/juicenet/__init__.py b/homeassistant/components/juicenet/__init__.py index 969e193bac8..d333b9f913b 100644 --- a/homeassistant/components/juicenet/__init__.py +++ b/homeassistant/components/juicenet/__init__.py @@ -1,68 +1,115 @@ -"""Support for Juicenet cloud.""" +"""The JuiceNet integration.""" +import asyncio +from datetime import timedelta import logging -import pyjuicenet +import aiohttp +from pyjuicenet import Api, TokenError import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR +from .device import JuiceNetApi _LOGGER = logging.getLogger(__name__) -DOMAIN = "juicenet" +PLATFORMS = ["sensor", "switch"] CONFIG_SCHEMA = vol.Schema( {DOMAIN: vol.Schema({vol.Required(CONF_ACCESS_TOKEN): cv.string})}, extra=vol.ALLOW_EXTRA, ) -JUICENET_COMPONENTS = ["sensor", "switch"] + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the JuiceNet component.""" + conf = config.get(DOMAIN) + hass.data.setdefault(DOMAIN, {}) + + if not conf: + return True + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=conf + ) + ) + return True -def setup(hass, config): - """Set up the Juicenet component.""" - hass.data[DOMAIN] = {} +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up JuiceNet from a config entry.""" - access_token = config[DOMAIN].get(CONF_ACCESS_TOKEN) - hass.data[DOMAIN]["api"] = pyjuicenet.Api(access_token) + config = entry.data - for component in JUICENET_COMPONENTS: - discovery.load_platform(hass, component, DOMAIN, {}, config) + session = async_get_clientsession(hass) + + access_token = config[CONF_ACCESS_TOKEN] + api = Api(access_token, session) + + juicenet = JuiceNetApi(api) + + try: + await juicenet.setup() + except TokenError as error: + _LOGGER.error("JuiceNet Error %s", error) + return False + except aiohttp.ClientError as error: + _LOGGER.error("Could not reach the JuiceNet API %s", error) + raise ConfigEntryNotReady + + if not juicenet.devices: + _LOGGER.error("No JuiceNet devices found for this account") + return False + _LOGGER.info("%d JuiceNet device(s) found", len(juicenet.devices)) + + async def async_update_data(): + """Update all device states from the JuiceNet API.""" + for device in juicenet.devices: + await device.update_state(True) + return True + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name="JuiceNet", + update_method=async_update_data, + update_interval=timedelta(seconds=30), + ) + + hass.data[DOMAIN][entry.entry_id] = { + JUICENET_API: juicenet, + JUICENET_COORDINATOR: coordinator, + } + + await coordinator.async_refresh() + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) return True -class JuicenetDevice(Entity): - """Represent a base Juicenet device.""" +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 PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) - def __init__(self, device, sensor_type, hass): - """Initialise the sensor.""" - self.hass = hass - self.device = device - self.type = sensor_type - - @property - def name(self): - """Return the name of the device.""" - return self.device.name() - - def update(self): - """Update state of the device.""" - self.device.update_state() - - @property - def _manufacturer_device_id(self): - """Return the manufacturer device id.""" - return self.device.id() - - @property - def _token(self): - """Return the device API token.""" - return self.device.token() - - @property - def unique_id(self): - """Return a unique ID.""" - return f"{self.device.id()}-{self.type}" + return unload_ok diff --git a/homeassistant/components/juicenet/config_flow.py b/homeassistant/components/juicenet/config_flow.py new file mode 100644 index 00000000000..3f089300025 --- /dev/null +++ b/homeassistant/components/juicenet/config_flow.py @@ -0,0 +1,79 @@ +"""Config flow for JuiceNet integration.""" +import logging + +import aiohttp +from pyjuicenet import Api, TokenError +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN # pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str}) + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + session = async_get_clientsession(hass) + juicenet = Api(data[CONF_ACCESS_TOKEN], session) + + try: + await juicenet.get_devices() + except TokenError as error: + _LOGGER.error("Token Error %s", error) + raise InvalidAuth + except aiohttp.ClientError as error: + _LOGGER.error("Error connecting %s", error) + raise CannotConnect + + # Return info that you want to store in the config entry. + return {"title": "JuiceNet"} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for JuiceNet.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + + await self.async_set_unique_id(user_input[CONF_ACCESS_TOKEN]) + self._abort_if_unique_id_configured() + + try: + info = await validate_input(self.hass, user_input) + return self.async_create_entry(title=info["title"], data=user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def async_step_import(self, user_input): + """Handle import.""" + return await self.async_step_user(user_input) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/juicenet/const.py b/homeassistant/components/juicenet/const.py new file mode 100644 index 00000000000..5dc3e5c3e27 --- /dev/null +++ b/homeassistant/components/juicenet/const.py @@ -0,0 +1,6 @@ +"""Constants used by the JuiceNet component.""" + +DOMAIN = "juicenet" + +JUICENET_API = "juicenet_api" +JUICENET_COORDINATOR = "juicenet_coordinator" diff --git a/homeassistant/components/juicenet/device.py b/homeassistant/components/juicenet/device.py new file mode 100644 index 00000000000..37d36a2b24c --- /dev/null +++ b/homeassistant/components/juicenet/device.py @@ -0,0 +1,23 @@ +"""Adapter to wrap the pyjuicenet api for home assistant.""" + +import logging + +_LOGGER = logging.getLogger(__name__) + + +class JuiceNetApi: + """Represent a connection to JuiceNet.""" + + def __init__(self, api): + """Create an object from the provided API instance.""" + self.api = api + self._devices = [] + + async def setup(self): + """JuiceNet device setup.""" # noqa: D403 + self._devices = await self.api.get_devices() + + @property + def devices(self) -> list: + """Get a list of devices managed by this account.""" + return self._devices diff --git a/homeassistant/components/juicenet/entity.py b/homeassistant/components/juicenet/entity.py new file mode 100644 index 00000000000..fe81f242cd0 --- /dev/null +++ b/homeassistant/components/juicenet/entity.py @@ -0,0 +1,54 @@ +"""Adapter to wrap the pyjuicenet api for home assistant.""" + +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + + +class JuiceNetDevice(Entity): + """Represent a base JuiceNet device.""" + + def __init__(self, device, sensor_type, coordinator): + """Initialise the sensor.""" + self.device = device + self.type = sensor_type + self.coordinator = coordinator + + @property + def name(self): + """Return the name of the device.""" + return self.device.name + + @property + def should_poll(self): + """Return False, updates are controlled via coordinator.""" + return False + + @property + def available(self): + """Return True if entity is available.""" + return self.coordinator.last_update_success + + async def async_update(self): + """Update the entity.""" + await self.coordinator.async_request_refresh() + + async def async_added_to_hass(self): + """Subscribe to updates.""" + self.async_on_remove( + self.coordinator.async_add_listener(self.async_write_ha_state) + ) + + @property + def unique_id(self): + """Return a unique ID.""" + return f"{self.device.id}-{self.type}" + + @property + def device_info(self): + """Return device information about this JuiceNet Device.""" + return { + "identifiers": {(DOMAIN, self.device.id)}, + "name": self.device.name, + "manufacturer": "JuiceNet", + } diff --git a/homeassistant/components/juicenet/manifest.json b/homeassistant/components/juicenet/manifest.json index 79ba6ba9ec5..66b7912028e 100644 --- a/homeassistant/components/juicenet/manifest.json +++ b/homeassistant/components/juicenet/manifest.json @@ -2,6 +2,7 @@ "domain": "juicenet", "name": "JuiceNet", "documentation": "https://www.home-assistant.io/integrations/juicenet", - "requirements": ["python-juicenet==0.1.6"], - "codeowners": ["@jesserockz"] + "requirements": ["python-juicenet==1.0.1"], + "codeowners": ["@jesserockz"], + "config_flow": true } diff --git a/homeassistant/components/juicenet/sensor.py b/homeassistant/components/juicenet/sensor.py index 63eeb6fc58f..e7408cf9f85 100644 --- a/homeassistant/components/juicenet/sensor.py +++ b/homeassistant/components/juicenet/sensor.py @@ -10,7 +10,8 @@ from homeassistant.const import ( ) from homeassistant.helpers.entity import Entity -from . import DOMAIN, JuicenetDevice +from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR +from .entity import JuiceNetDevice _LOGGER = logging.getLogger(__name__) @@ -25,38 +26,39 @@ SENSOR_TYPES = { } -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Juicenet sensor.""" - api = hass.data[DOMAIN]["api"] +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the JuiceNet Sensors.""" + entities = [] + juicenet_data = hass.data[DOMAIN][config_entry.entry_id] + api = juicenet_data[JUICENET_API] + coordinator = juicenet_data[JUICENET_COORDINATOR] - dev = [] - for device in api.get_devices(): - for variable in SENSOR_TYPES: - dev.append(JuicenetSensorDevice(device, variable, hass)) - - add_entities(dev) + for device in api.devices: + for sensor in SENSOR_TYPES: + entities.append(JuiceNetSensorDevice(device, sensor, coordinator)) + async_add_entities(entities) -class JuicenetSensorDevice(JuicenetDevice, Entity): - """Implementation of a Juicenet sensor.""" +class JuiceNetSensorDevice(JuiceNetDevice, Entity): + """Implementation of a JuiceNet sensor.""" - def __init__(self, device, sensor_type, hass): + def __init__(self, device, sensor_type, coordinator): """Initialise the sensor.""" - super().__init__(device, sensor_type, hass) + super().__init__(device, sensor_type, coordinator) self._name = SENSOR_TYPES[sensor_type][0] self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] @property def name(self): """Return the name of the device.""" - return f"{self.device.name()} {self._name}" + return f"{self.device.name} {self._name}" @property def icon(self): """Return the icon of the sensor.""" icon = None if self.type == "status": - status = self.device.getStatus() + status = self.device.status if status == "standby": icon = "mdi:power-plug-off" elif status == "plugged": @@ -87,29 +89,19 @@ class JuicenetSensorDevice(JuicenetDevice, Entity): """Return the state.""" state = None if self.type == "status": - state = self.device.getStatus() + state = self.device.status elif self.type == "temperature": - state = self.device.getTemperature() + state = self.device.temperature elif self.type == "voltage": - state = self.device.getVoltage() + state = self.device.voltage elif self.type == "amps": - state = self.device.getAmps() + state = self.device.amps elif self.type == "watts": - state = self.device.getWatts() + state = self.device.watts elif self.type == "charge_time": - state = self.device.getChargeTime() + state = self.device.charge_time elif self.type == "energy_added": - state = self.device.getEnergyAdded() + state = self.device.energy_added else: state = "Unknown" return state - - @property - def device_state_attributes(self): - """Return the state attributes.""" - attributes = {} - if self.type == "status": - man_dev_id = self.device.id() - if man_dev_id: - attributes["manufacturer_device_id"] = man_dev_id - return attributes diff --git a/homeassistant/components/juicenet/strings.json b/homeassistant/components/juicenet/strings.json new file mode 100644 index 00000000000..4c8ffb8c62f --- /dev/null +++ b/homeassistant/components/juicenet/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "This JuiceNet account is already configured" + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "api_token": "JuiceNet API Token" + }, + "description": "You will need the API Token from https://home.juice.net/Manage.", + "title": "Connect to JuiceNet" + } + } + } +} diff --git a/homeassistant/components/juicenet/switch.py b/homeassistant/components/juicenet/switch.py index 5f2d16fa39d..f09ec5559eb 100644 --- a/homeassistant/components/juicenet/switch.py +++ b/homeassistant/components/juicenet/switch.py @@ -3,43 +3,45 @@ import logging from homeassistant.components.switch import SwitchEntity -from . import DOMAIN, JuicenetDevice +from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR +from .entity import JuiceNetDevice _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Juicenet switch.""" - api = hass.data[DOMAIN]["api"] +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the JuiceNet switches.""" + entities = [] + juicenet_data = hass.data[DOMAIN][config_entry.entry_id] + api = juicenet_data[JUICENET_API] + coordinator = juicenet_data[JUICENET_COORDINATOR] - devs = [] - for device in api.get_devices(): - devs.append(JuicenetChargeNowSwitch(device, hass)) - - add_entities(devs) + for device in api.devices: + entities.append(JuiceNetChargeNowSwitch(device, coordinator)) + async_add_entities(entities) -class JuicenetChargeNowSwitch(JuicenetDevice, SwitchEntity): - """Implementation of a Juicenet switch.""" +class JuiceNetChargeNowSwitch(JuiceNetDevice, SwitchEntity): + """Implementation of a JuiceNet switch.""" - def __init__(self, device, hass): + def __init__(self, device, coordinator): """Initialise the switch.""" - super().__init__(device, "charge_now", hass) + super().__init__(device, "charge_now", coordinator) @property def name(self): """Return the name of the device.""" - return f"{self.device.name()} Charge Now" + return f"{self.device.name} Charge Now" @property def is_on(self): """Return true if switch is on.""" - return self.device.getOverrideTime() != 0 + return self.device.override_time != 0 - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Charge now.""" - self.device.setOverride(True) + await self.device.set_override(True) - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Don't charge now.""" - self.device.setOverride(False) + await self.device.set_override(False) diff --git a/homeassistant/components/juicenet/translations/en.json b/homeassistant/components/juicenet/translations/en.json new file mode 100644 index 00000000000..aec8a903b99 --- /dev/null +++ b/homeassistant/components/juicenet/translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "This JuiceNet account is already configured" + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "api_token": "JuiceNet API Token" + }, + "description": "You will need the API Token from https://home.juice.net/Manage.", + "title": "Connect to JuiceNet" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index eca4a938f6e..817109dfe95 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -68,6 +68,7 @@ FLOWS = [ "iqvia", "islamic_prayer_times", "izone", + "juicenet", "konnected", "life360", "lifx", diff --git a/requirements_all.txt b/requirements_all.txt index 61ee2c27182..9e6910e5478 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1669,7 +1669,7 @@ python-izone==1.1.2 python-join-api==0.0.4 # homeassistant.components.juicenet -python-juicenet==0.1.6 +python-juicenet==1.0.1 # homeassistant.components.lirc # python-lirc==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e4d246e2f60..e942596e593 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -668,6 +668,9 @@ python-forecastio==1.4.0 # homeassistant.components.izone python-izone==1.1.2 +# homeassistant.components.juicenet +python-juicenet==1.0.1 + # homeassistant.components.xiaomi_miio python-miio==0.5.0.1 diff --git a/tests/components/juicenet/__init__.py b/tests/components/juicenet/__init__.py new file mode 100644 index 00000000000..cc125664bac --- /dev/null +++ b/tests/components/juicenet/__init__.py @@ -0,0 +1 @@ +"""Tests for the JuiceNet component.""" diff --git a/tests/components/juicenet/test_config_flow.py b/tests/components/juicenet/test_config_flow.py new file mode 100644 index 00000000000..12edeee50c5 --- /dev/null +++ b/tests/components/juicenet/test_config_flow.py @@ -0,0 +1,123 @@ +"""Test the JuiceNet config flow.""" +import aiohttp +from asynctest import patch +from asynctest.mock import MagicMock +from pyjuicenet import TokenError + +from homeassistant import config_entries, setup +from homeassistant.components.juicenet.const import DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN + + +def _mock_juicenet_return_value(get_devices=None): + juicenet_mock = MagicMock() + type(juicenet_mock).get_devices = MagicMock(return_value=get_devices) + return juicenet_mock + + +async def test_form(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.juicenet.config_flow.Api.get_devices", + return_value=MagicMock(), + ), patch( + "homeassistant.components.juicenet.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.juicenet.async_setup_entry", return_value=True + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: "access_token"} + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "JuiceNet" + assert result2["data"] == {CONF_ACCESS_TOKEN: "access_token"} + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass): + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.juicenet.config_flow.Api.get_devices", + side_effect=TokenError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: "access_token"} + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.juicenet.config_flow.Api.get_devices", + side_effect=aiohttp.ClientError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: "access_token"} + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_catch_unknown_errors(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.juicenet.config_flow.Api.get_devices", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: "access_token"} + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} + + +async def test_import(hass): + """Test that import works as expected.""" + + with patch( + "homeassistant.components.juicenet.config_flow.Api.get_devices", + return_value=MagicMock(), + ), patch( + "homeassistant.components.juicenet.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.juicenet.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_ACCESS_TOKEN: "access_token"}, + ) + + assert result["type"] == "create_entry" + assert result["title"] == "JuiceNet" + assert result["data"] == {CONF_ACCESS_TOKEN: "access_token"} + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1