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/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/*

View file

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

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

View file

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

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 . 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)

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",
"islamic_prayer_times",
"izone",
"juicenet",
"konnected",
"life360",
"lifx",

View file

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

View file

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

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