Skip to content
Snippets Groups Projects
Unverified Commit 67a7b28c authored by tkdrob's avatar tkdrob Committed by GitHub
Browse files

Add Integration for Goal Zero Yeti Power Stations (#39231)


* Add Integration for Goal Zero Yeti Power Stations

* Goal Zero Yeti integration with config flow

* Remove unused entities

* Remove entry from requirements_test_all

* Pylint fix

* Apply suggestions from code review

Co-authored-by: default avatarFranck Nijhof <frenck@frenck.nl>

* Add tests for goalzero integration

* Fix UNIT_PERCENTAGE to PERCENTAGE

* isort PERCENTAGE

* Add tests

* Add en translation

* Fix tests

* bump goalzero to 0.1.1

* fix await

* bump goalzero to 0.1.2

* Update tests/components/goalzero/__init__.py

Co-authored-by: default avatarJ. Nick Koston <nick@koston.org>

* apply recommended changes

* isort

* bump goalzero to 0.1.4

* apply recommended changes

* apply recommended changes

Co-authored-by: default avatarFranck Nijhof <frenck@frenck.nl>
Co-authored-by: default avatarJ. Nick Koston <nick@koston.org>
parent 4ca7b856
No related branches found
No related tags found
No related merge requests found
Showing
with 522 additions and 0 deletions
...@@ -320,6 +320,8 @@ omit = ...@@ -320,6 +320,8 @@ omit =
homeassistant/components/glances/sensor.py homeassistant/components/glances/sensor.py
homeassistant/components/gntp/notify.py homeassistant/components/gntp/notify.py
homeassistant/components/goalfeed/* homeassistant/components/goalfeed/*
homeassistant/components/goalzero/__init__.py
homeassistant/components/goalzero/binary_sensor.py
homeassistant/components/google/* homeassistant/components/google/*
homeassistant/components/google_cloud/tts.py homeassistant/components/google_cloud/tts.py
homeassistant/components/google_maps/device_tracker.py homeassistant/components/google_maps/device_tracker.py
......
...@@ -157,6 +157,7 @@ homeassistant/components/geonetnz_volcano/* @exxamalte ...@@ -157,6 +157,7 @@ homeassistant/components/geonetnz_volcano/* @exxamalte
homeassistant/components/gios/* @bieniu homeassistant/components/gios/* @bieniu
homeassistant/components/gitter/* @fabaff homeassistant/components/gitter/* @fabaff
homeassistant/components/glances/* @fabaff @engrbm87 homeassistant/components/glances/* @fabaff @engrbm87
homeassistant/components/goalzero/* @tkdrob
homeassistant/components/gogogate2/* @vangorra homeassistant/components/gogogate2/* @vangorra
homeassistant/components/google_assistant/* @home-assistant/cloud homeassistant/components/google_assistant/* @home-assistant/cloud
homeassistant/components/google_cloud/* @lufton homeassistant/components/google_cloud/* @lufton
......
"""The Goal Zero Yeti integration."""
import asyncio
import logging
from goalzero import Yeti, exceptions
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_NAME
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 (
CoordinatorEntity,
DataUpdateCoordinator,
UpdateFailed,
)
from .const import (
DATA_KEY_API,
DATA_KEY_COORDINATOR,
DEFAULT_NAME,
DOMAIN,
MIN_TIME_BETWEEN_UPDATES,
)
_LOGGER = logging.getLogger(__name__)
GOALZERO_SCHEMA = vol.Schema(
vol.All(
{
vol.Required(CONF_HOST): cv.matches_regex(
r"\A(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2 \
[0-4][0-9]|[01]?[0-9][0-9]?)\Z"
),
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
},
)
)
CONFIG_SCHEMA = vol.Schema(
{DOMAIN: vol.Schema(vol.All(cv.ensure_list, [GOALZERO_SCHEMA]))},
extra=vol.ALLOW_EXTRA,
)
PLATFORMS = ["binary_sensor"]
async def async_setup(hass: HomeAssistant, config):
"""Set up the Goal Zero Yeti component."""
hass.data[DOMAIN] = {}
return True
async def async_setup_entry(hass, entry):
"""Set up Goal Zero Yeti from a config entry."""
name = entry.data[CONF_NAME]
host = entry.data[CONF_HOST]
_LOGGER.debug("Setting up %s integration with host %s", DOMAIN, host)
session = async_get_clientsession(hass)
api = Yeti(host, hass.loop, session)
try:
await api.get_state()
except exceptions.ConnectError as ex:
_LOGGER.warning("Failed to connect: %s", ex)
raise ConfigEntryNotReady from ex
async def async_update_data():
"""Fetch data from API endpoint."""
try:
await api.get_state()
except exceptions.ConnectError as err:
_LOGGER.warning("Failed to update data from Yeti")
raise UpdateFailed(f"Failed to communicating with API: {err}") from err
coordinator = DataUpdateCoordinator(
hass,
_LOGGER,
name=name,
update_method=async_update_data,
update_interval=MIN_TIME_BETWEEN_UPDATES,
)
hass.data[DOMAIN][entry.entry_id] = {
DATA_KEY_API: api,
DATA_KEY_COORDINATOR: coordinator,
}
for platform in PLATFORMS:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, platform)
)
return True
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)
return unload_ok
class YetiEntity(CoordinatorEntity):
"""Representation of a Goal Zero Yeti entity."""
def __init__(self, _api, coordinator, name, sensor_name, server_unique_id):
"""Initialize a Goal Zero Yeti entity."""
super().__init__(coordinator)
self.api = _api
self._name = name
self._server_unique_id = server_unique_id
self._device_class = None
@property
def device_info(self):
"""Return the device information of the entity."""
return {
"identifiers": {(DOMAIN, self._server_unique_id)},
"name": self._name,
"manufacturer": "Goal Zero",
}
@property
def device_class(self):
"""Return the class of this device."""
return self._device_class
"""Support for Goal Zero Yeti Sensors."""
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.const import CONF_NAME
from . import YetiEntity
from .const import BINARY_SENSOR_DICT, DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN
PARALLEL_UPDATES = 0
async def async_setup_entry(hass, entry, async_add_entities):
"""Set up the Goal Zero Yeti sensor."""
name = entry.data[CONF_NAME]
goalzero_data = hass.data[DOMAIN][entry.entry_id]
sensors = [
YetiBinarySensor(
goalzero_data[DATA_KEY_API],
goalzero_data[DATA_KEY_COORDINATOR],
name,
sensor_name,
entry.entry_id,
)
for sensor_name in BINARY_SENSOR_DICT
]
async_add_entities(sensors, True)
class YetiBinarySensor(YetiEntity, BinarySensorEntity):
"""Representation of a Goal Zero Yeti sensor."""
def __init__(self, api, coordinator, name, sensor_name, server_unique_id):
"""Initialize a Goal Zero Yeti sensor."""
super().__init__(api, coordinator, name, sensor_name, server_unique_id)
self._condition = sensor_name
variable_info = BINARY_SENSOR_DICT[sensor_name]
self._condition_name = variable_info[0]
self._icon = variable_info[2]
self.api = api
self._device_class = variable_info[1]
@property
def name(self):
"""Return the name of the sensor."""
return f"{self._name} {self._condition_name}"
@property
def unique_id(self):
"""Return the unique id of the sensor."""
return f"{self._server_unique_id}/{self._condition_name}"
@property
def is_on(self):
"""Return if the service is on."""
if self.api.data:
return self.api.data[self._condition] == 1
@property
def icon(self):
"""Icon to use in the frontend, if any."""
return self._icon
"""Config flow for Goal Zero Yeti integration."""
import logging
from goalzero import Yeti, exceptions
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_HOST, CONF_NAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DEFAULT_NAME, DOMAIN # pylint:disable=unused-import
_LOGGER = logging.getLogger(__name__)
DATA_SCHEMA = vol.Schema({"host": str, "name": str})
class GoalZeroFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Goal Zero Yeti."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
async def async_step_user(self, user_input=None):
"""Handle a flow initiated by the user."""
errors = {}
if user_input is not None:
host = user_input[CONF_HOST]
name = user_input[CONF_NAME]
if await self._async_endpoint_existed(host):
return self.async_abort(reason="already_configured")
try:
await self._async_try_connect(host)
return self.async_create_entry(
title=name,
data={CONF_HOST: host, CONF_NAME: name},
)
except exceptions.ConnectError:
errors["base"] = "cannot_connect"
_LOGGER.exception("Error connecting to device at %s", host)
except exceptions.InvalidHost:
errors["base"] = "invalid_host"
_LOGGER.exception("Invalid data received from device at %s", host)
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
user_input = user_input or {}
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(
CONF_HOST, default=user_input.get(CONF_HOST) or ""
): str,
vol.Optional(
CONF_NAME, default=user_input.get(CONF_NAME) or DEFAULT_NAME
): str,
}
),
errors=errors,
)
async def _async_endpoint_existed(self, endpoint):
for entry in self._async_current_entries():
if endpoint == entry.data.get(CONF_HOST):
return endpoint
async def _async_try_connect(self, host):
session = async_get_clientsession(self.hass)
api = Yeti(host, self.hass.loop, session)
await api.get_state()
"""Constants for the Goal Zero Yeti integration."""
from datetime import timedelta
from homeassistant.components.binary_sensor import (
DEVICE_CLASS_BATTERY_CHARGING,
DEVICE_CLASS_CONNECTIVITY,
DEVICE_CLASS_POWER,
)
DATA_KEY_COORDINATOR = "coordinator"
DOMAIN = "goalzero"
DEFAULT_NAME = "Yeti"
DATA_KEY_API = "api"
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30)
BINARY_SENSOR_DICT = {
"v12PortStatus": ["12V Port Status", DEVICE_CLASS_POWER, None],
"usbPortStatus": ["USB Port Status", DEVICE_CLASS_POWER, None],
"acPortStatus": ["AC Port Status", DEVICE_CLASS_POWER, None],
"backlight": ["Backlight", None, "mdi:clock-digital"],
"app_online": [
"App Online",
DEVICE_CLASS_CONNECTIVITY,
None,
],
"isCharging": ["Charging", DEVICE_CLASS_BATTERY_CHARGING, None],
}
{
"domain": "goalzero",
"name": "Goal Zero Yeti",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/goalzero",
"requirements": ["goalzero==0.1.4"],
"codeowners": ["@tkdrob"]
}
{
"config": {
"step": {
"user": {
"title": "Goal Zero Yeti",
"description": "First, you need to download the Goal Zero app: https://www.goalzero.com/product-features/yeti-app/\n\nFollow the instructions to connect your Yeti to your Wifi network. Then get the host ip from your router. DHCP must be set up in your router settings for the device to ensure the host ip does not change. Refer to your router's user manual.",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"name": "Name"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_host": "This is not the Yeti you are looking for",
"unknown": "Unknown Error"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
}
}
}
{
"config": {
"abort": {
"already_configured": "Already Configured"
},
"error": {
"cannot_connect": "Error connecting to host",
"invalid_host": "This is not a Yeti",
"unknown": "Unknown Error"
},
"step": {
"user": {
"data": {
"host": "Host",
"name": "Name"
},
"description": "First, you need to download the Goal Zero app: https://www.goalzero.com/product-features/yeti-app/\n\nFollow the instructions to connect your Yeti to your Wifi network. Then get the host ip from your router. DHCP must be set up in your router settings for the device to ensure the host ip does not change. Refer to your router's user manual.",
"title": "Goal Zero Yeti"
}
}
}
}
\ No newline at end of file
...@@ -68,6 +68,7 @@ FLOWS = [ ...@@ -68,6 +68,7 @@ FLOWS = [
"geonetnz_volcano", "geonetnz_volcano",
"gios", "gios",
"glances", "glances",
"goalzero",
"gogogate2", "gogogate2",
"gpslogger", "gpslogger",
"griddy", "griddy",
......
...@@ -668,6 +668,9 @@ glances_api==0.2.0 ...@@ -668,6 +668,9 @@ glances_api==0.2.0
# homeassistant.components.gntp # homeassistant.components.gntp
gntp==1.0.3 gntp==1.0.3
# homeassistant.components.goalzero
goalzero==0.1.4
# homeassistant.components.gogogate2 # homeassistant.components.gogogate2
gogogate2-api==2.0.3 gogogate2-api==2.0.3
......
...@@ -336,6 +336,9 @@ gios==0.1.4 ...@@ -336,6 +336,9 @@ gios==0.1.4
# homeassistant.components.glances # homeassistant.components.glances
glances_api==0.2.0 glances_api==0.2.0
# homeassistant.components.goalzero
goalzero==0.1.4
# homeassistant.components.gogogate2 # homeassistant.components.gogogate2
gogogate2-api==2.0.3 gogogate2-api==2.0.3
......
"""Tests for the Goal Zero Yeti integration."""
from homeassistant.const import CONF_HOST, CONF_NAME
from tests.async_mock import AsyncMock, patch
HOST = "1.2.3.4"
NAME = "Yeti"
CONF_DATA = {
CONF_HOST: HOST,
CONF_NAME: NAME,
}
CONF_CONFIG_FLOW = {
CONF_HOST: HOST,
CONF_NAME: NAME,
}
async def _create_mocked_yeti(raise_exception=False):
mocked_yeti = AsyncMock()
mocked_yeti.get_state = AsyncMock()
return mocked_yeti
def _patch_init_yeti(mocked_yeti):
return patch("homeassistant.components.goalzero.Yeti", return_value=mocked_yeti)
def _patch_config_flow_yeti(mocked_yeti):
return patch(
"homeassistant.components.goalzero.config_flow.Yeti",
return_value=mocked_yeti,
)
"""Test Goal Zero Yeti config flow."""
from goalzero import exceptions
from homeassistant.components.goalzero.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.data_entry_flow import (
RESULT_TYPE_ABORT,
RESULT_TYPE_CREATE_ENTRY,
RESULT_TYPE_FORM,
)
from . import (
CONF_CONFIG_FLOW,
CONF_DATA,
CONF_HOST,
CONF_NAME,
NAME,
_create_mocked_yeti,
_patch_config_flow_yeti,
)
from tests.async_mock import patch
from tests.common import MockConfigEntry
def _flow_next(hass, flow_id):
return next(
flow
for flow in hass.config_entries.flow.async_progress()
if flow["flow_id"] == flow_id
)
def _patch_setup():
return patch(
"homeassistant.components.goalzero.async_setup_entry",
return_value=True,
)
async def test_flow_user(hass):
"""Test user initialized flow."""
mocked_yeti = await _create_mocked_yeti()
with _patch_config_flow_yeti(mocked_yeti), _patch_setup():
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
_flow_next(hass, result["flow_id"])
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=CONF_CONFIG_FLOW,
)
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
assert result["title"] == NAME
assert result["data"] == CONF_DATA
async def test_flow_user_already_configured(hass):
"""Test user initialized flow with duplicate server."""
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: "1.2.3.4", CONF_NAME: "Yeti"},
)
entry.add_to_hass(hass)
service_info = {
"host": "1.2.3.4",
"name": "Yeti",
}
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=service_info
)
assert result["type"] == RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"
async def test_flow_user_cannot_connect(hass):
"""Test user initialized flow with unreachable server."""
mocked_yeti = await _create_mocked_yeti(True)
with _patch_config_flow_yeti(mocked_yeti) as yetimock:
yetimock.side_effect = exceptions.ConnectError
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=CONF_CONFIG_FLOW
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": "cannot_connect"}
async def test_flow_user_invalid_host(hass):
"""Test user initialized flow with invalid server."""
mocked_yeti = await _create_mocked_yeti(True)
with _patch_config_flow_yeti(mocked_yeti) as yetimock:
yetimock.side_effect = exceptions.InvalidHost
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=CONF_CONFIG_FLOW
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": "invalid_host"}
async def test_flow_user_unknown_error(hass):
"""Test user initialized flow with unreachable server."""
mocked_yeti = await _create_mocked_yeti(True)
with _patch_config_flow_yeti(mocked_yeti) as yetimock:
yetimock.side_effect = Exception
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=CONF_CONFIG_FLOW
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": "unknown"}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment