Rewrite JuiceNet for async and config flow (#34365)

* Add config flow to JuiceNet

* Fix some lint issues

* Fix imports

* Abort on reconfigure / Allow multiple accounts
Abort on bad API token
Fix strings

* Remove unused variable

* Update strings

* Remove import

* Fix import order again

* Update imports
Remove some unused parameters

* Add back ignore

* Update config_flow.py

* iSort

* Update juicenet integration to be async

* Update coverage for juicenet config flow

* Update homeassistant/components/juicenet/entity.py

Co-Authored-By: J. Nick Koston <nick@koston.org>

* Black

* Make imports relative

* Rename translations folder

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Jesse Hills 2020-05-08 17:52:20 +12:00 committed by GitHub
parent 502afbe9c2
commit e696c08db0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 480 additions and 101 deletions

View File

@ -353,7 +353,12 @@ omit =
homeassistant/components/itach/remote.py homeassistant/components/itach/remote.py
homeassistant/components/itunes/media_player.py homeassistant/components/itunes/media_player.py
homeassistant/components/joaoapps_join/* 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/kaiterra/*
homeassistant/components/kankun/switch.py homeassistant/components/kankun/switch.py
homeassistant/components/keba/* homeassistant/components/keba/*

View File

@ -1,68 +1,115 @@
"""Support for Juicenet cloud.""" """The JuiceNet integration."""
import asyncio
from datetime import timedelta
import logging import logging
import pyjuicenet import aiohttp
from pyjuicenet import Api, TokenError
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.helpers import discovery from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.entity import Entity 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__) _LOGGER = logging.getLogger(__name__)
DOMAIN = "juicenet" PLATFORMS = ["sensor", "switch"]
CONFIG_SCHEMA = vol.Schema( CONFIG_SCHEMA = vol.Schema(
{DOMAIN: vol.Schema({vol.Required(CONF_ACCESS_TOKEN): cv.string})}, {DOMAIN: vol.Schema({vol.Required(CONF_ACCESS_TOKEN): cv.string})},
extra=vol.ALLOW_EXTRA, 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): async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up the Juicenet component.""" """Set up JuiceNet from a config entry."""
hass.data[DOMAIN] = {}
access_token = config[DOMAIN].get(CONF_ACCESS_TOKEN) config = entry.data
hass.data[DOMAIN]["api"] = pyjuicenet.Api(access_token)
for component in JUICENET_COMPONENTS: session = async_get_clientsession(hass)
discovery.load_platform(hass, component, DOMAIN, {}, config)
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 return True
class JuicenetDevice(Entity): async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Represent a base Juicenet device.""" """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): return unload_ok
"""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}"

View File

@ -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."""

View File

@ -0,0 +1,6 @@
"""Constants used by the JuiceNet component."""
DOMAIN = "juicenet"
JUICENET_API = "juicenet_api"
JUICENET_COORDINATOR = "juicenet_coordinator"

View File

@ -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

View File

@ -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",
}

View File

@ -2,6 +2,7 @@
"domain": "juicenet", "domain": "juicenet",
"name": "JuiceNet", "name": "JuiceNet",
"documentation": "https://www.home-assistant.io/integrations/juicenet", "documentation": "https://www.home-assistant.io/integrations/juicenet",
"requirements": ["python-juicenet==0.1.6"], "requirements": ["python-juicenet==1.0.1"],
"codeowners": ["@jesserockz"] "codeowners": ["@jesserockz"],
"config_flow": true
} }

View File

@ -10,7 +10,8 @@ from homeassistant.const import (
) )
from homeassistant.helpers.entity import Entity 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__) _LOGGER = logging.getLogger(__name__)
@ -25,38 +26,39 @@ SENSOR_TYPES = {
} }
def setup_platform(hass, config, add_entities, discovery_info=None): async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Juicenet sensor.""" """Set up the JuiceNet Sensors."""
api = hass.data[DOMAIN]["api"] 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.devices:
for device in api.get_devices(): for sensor in SENSOR_TYPES:
for variable in SENSOR_TYPES: entities.append(JuiceNetSensorDevice(device, sensor, coordinator))
dev.append(JuicenetSensorDevice(device, variable, hass)) async_add_entities(entities)
add_entities(dev)
class JuicenetSensorDevice(JuicenetDevice, Entity): class JuiceNetSensorDevice(JuiceNetDevice, Entity):
"""Implementation of a Juicenet sensor.""" """Implementation of a JuiceNet sensor."""
def __init__(self, device, sensor_type, hass): def __init__(self, device, sensor_type, coordinator):
"""Initialise the sensor.""" """Initialise the sensor."""
super().__init__(device, sensor_type, hass) super().__init__(device, sensor_type, coordinator)
self._name = SENSOR_TYPES[sensor_type][0] self._name = SENSOR_TYPES[sensor_type][0]
self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] self._unit_of_measurement = SENSOR_TYPES[sensor_type][1]
@property @property
def name(self): def name(self):
"""Return the name of the device.""" """Return the name of the device."""
return f"{self.device.name()} {self._name}" return f"{self.device.name} {self._name}"
@property @property
def icon(self): def icon(self):
"""Return the icon of the sensor.""" """Return the icon of the sensor."""
icon = None icon = None
if self.type == "status": if self.type == "status":
status = self.device.getStatus() status = self.device.status
if status == "standby": if status == "standby":
icon = "mdi:power-plug-off" icon = "mdi:power-plug-off"
elif status == "plugged": elif status == "plugged":
@ -87,29 +89,19 @@ class JuicenetSensorDevice(JuicenetDevice, Entity):
"""Return the state.""" """Return the state."""
state = None state = None
if self.type == "status": if self.type == "status":
state = self.device.getStatus() state = self.device.status
elif self.type == "temperature": elif self.type == "temperature":
state = self.device.getTemperature() state = self.device.temperature
elif self.type == "voltage": elif self.type == "voltage":
state = self.device.getVoltage() state = self.device.voltage
elif self.type == "amps": elif self.type == "amps":
state = self.device.getAmps() state = self.device.amps
elif self.type == "watts": elif self.type == "watts":
state = self.device.getWatts() state = self.device.watts
elif self.type == "charge_time": elif self.type == "charge_time":
state = self.device.getChargeTime() state = self.device.charge_time
elif self.type == "energy_added": elif self.type == "energy_added":
state = self.device.getEnergyAdded() state = self.device.energy_added
else: else:
state = "Unknown" state = "Unknown"
return state 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

View File

@ -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"
}
}
}
}

View File

@ -3,43 +3,45 @@ import logging
from homeassistant.components.switch import SwitchEntity 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__) _LOGGER = logging.getLogger(__name__)
def setup_platform(hass, config, add_entities, discovery_info=None): async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Juicenet switch.""" """Set up the JuiceNet switches."""
api = hass.data[DOMAIN]["api"] 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.devices:
for device in api.get_devices(): entities.append(JuiceNetChargeNowSwitch(device, coordinator))
devs.append(JuicenetChargeNowSwitch(device, hass)) async_add_entities(entities)
add_entities(devs)
class JuicenetChargeNowSwitch(JuicenetDevice, SwitchEntity): class JuiceNetChargeNowSwitch(JuiceNetDevice, SwitchEntity):
"""Implementation of a Juicenet switch.""" """Implementation of a JuiceNet switch."""
def __init__(self, device, hass): def __init__(self, device, coordinator):
"""Initialise the switch.""" """Initialise the switch."""
super().__init__(device, "charge_now", hass) super().__init__(device, "charge_now", coordinator)
@property @property
def name(self): def name(self):
"""Return the name of the device.""" """Return the name of the device."""
return f"{self.device.name()} Charge Now" return f"{self.device.name} Charge Now"
@property @property
def is_on(self): def is_on(self):
"""Return true if switch is on.""" """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.""" """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.""" """Don't charge now."""
self.device.setOverride(False) await self.device.set_override(False)

View File

@ -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"
}
}
}
}

View File

@ -68,6 +68,7 @@ FLOWS = [
"iqvia", "iqvia",
"islamic_prayer_times", "islamic_prayer_times",
"izone", "izone",
"juicenet",
"konnected", "konnected",
"life360", "life360",
"lifx", "lifx",

View File

@ -1669,7 +1669,7 @@ python-izone==1.1.2
python-join-api==0.0.4 python-join-api==0.0.4
# homeassistant.components.juicenet # homeassistant.components.juicenet
python-juicenet==0.1.6 python-juicenet==1.0.1
# homeassistant.components.lirc # homeassistant.components.lirc
# python-lirc==1.2.3 # python-lirc==1.2.3

View File

@ -668,6 +668,9 @@ python-forecastio==1.4.0
# homeassistant.components.izone # homeassistant.components.izone
python-izone==1.1.2 python-izone==1.1.2
# homeassistant.components.juicenet
python-juicenet==1.0.1
# homeassistant.components.xiaomi_miio # homeassistant.components.xiaomi_miio
python-miio==0.5.0.1 python-miio==0.5.0.1

View File

@ -0,0 +1 @@
"""Tests for the JuiceNet component."""

View File

@ -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