Add DHCP support to goalzero (#50425)
parent
4d55290932
commit
40993f3ebb
|
@ -42,7 +42,7 @@ async def async_setup_entry(hass, entry):
|
||||||
try:
|
try:
|
||||||
await api.get_state()
|
await api.get_state()
|
||||||
except exceptions.ConnectError as err:
|
except exceptions.ConnectError as err:
|
||||||
raise UpdateFailed(f"Failed to communicating with API: {err}") from err
|
raise UpdateFailed(f"Failed to communicating with device {err}") from err
|
||||||
|
|
||||||
coordinator = DataUpdateCoordinator(
|
coordinator = DataUpdateCoordinator(
|
||||||
hass,
|
hass,
|
||||||
|
@ -84,21 +84,16 @@ class YetiEntity(CoordinatorEntity):
|
||||||
@property
|
@property
|
||||||
def device_info(self):
|
def device_info(self):
|
||||||
"""Return the device information of the entity."""
|
"""Return the device information of the entity."""
|
||||||
if self.api.data:
|
info = {
|
||||||
sw_version = self.api.data["firmwareVersion"]
|
|
||||||
else:
|
|
||||||
sw_version = None
|
|
||||||
if self.api.sysdata:
|
|
||||||
model = self.api.sysdata["model"]
|
|
||||||
else:
|
|
||||||
model = model or None
|
|
||||||
return {
|
|
||||||
"identifiers": {(DOMAIN, self._server_unique_id)},
|
"identifiers": {(DOMAIN, self._server_unique_id)},
|
||||||
"manufacturer": "Goal Zero",
|
"manufacturer": "Goal Zero",
|
||||||
"model": model,
|
|
||||||
"name": self._name,
|
"name": self._name,
|
||||||
"sw_version": sw_version,
|
|
||||||
}
|
}
|
||||||
|
if self.api.sysdata:
|
||||||
|
info["model"] = self.api.sysdata["model"]
|
||||||
|
if self.api.data:
|
||||||
|
info["sw_version"] = self.api.data["firmwareVersion"]
|
||||||
|
return info
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def device_class(self):
|
def device_class(self):
|
||||||
|
|
|
@ -1,12 +1,18 @@
|
||||||
"""Config flow for Goal Zero Yeti integration."""
|
"""Config flow for Goal Zero Yeti integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from goalzero import Yeti, exceptions
|
from goalzero import Yeti, exceptions
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.components.dhcp import IP_ADDRESS, MAC_ADDRESS
|
||||||
from homeassistant.const import CONF_HOST, CONF_NAME
|
from homeassistant.const import CONF_HOST, CONF_NAME
|
||||||
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
from homeassistant.helpers.device_registry import format_mac
|
||||||
|
|
||||||
from .const import DEFAULT_NAME, DOMAIN
|
from .const import DEFAULT_NAME, DOMAIN
|
||||||
|
|
||||||
|
@ -20,32 +26,63 @@ class GoalZeroFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
|
|
||||||
VERSION = 1
|
VERSION = 1
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize a Goal Zero Yeti flow."""
|
||||||
|
self.ip_address = None
|
||||||
|
|
||||||
|
async def async_step_dhcp(self, discovery_info):
|
||||||
|
"""Handle dhcp discovery."""
|
||||||
|
self.ip_address = discovery_info[IP_ADDRESS]
|
||||||
|
|
||||||
|
await self.async_set_unique_id(discovery_info[MAC_ADDRESS])
|
||||||
|
self._abort_if_unique_id_configured(updates={CONF_HOST: self.ip_address})
|
||||||
|
self._async_abort_entries_match({CONF_HOST: self.ip_address})
|
||||||
|
|
||||||
|
_, error = await self._async_try_connect(self.ip_address)
|
||||||
|
if error is None:
|
||||||
|
return await self.async_step_confirm_discovery()
|
||||||
|
return self.async_abort(reason=error)
|
||||||
|
|
||||||
|
async def async_step_confirm_discovery(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Allow the user to confirm adding the device."""
|
||||||
|
if user_input is not None:
|
||||||
|
return self.async_create_entry(
|
||||||
|
title="Goal Zero",
|
||||||
|
data={
|
||||||
|
CONF_HOST: self.ip_address,
|
||||||
|
CONF_NAME: DEFAULT_NAME,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self._set_confirm_only()
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="confirm_discovery",
|
||||||
|
description_placeholders={
|
||||||
|
CONF_HOST: self.ip_address,
|
||||||
|
CONF_NAME: DEFAULT_NAME,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
async def async_step_user(self, user_input=None):
|
async def async_step_user(self, user_input=None):
|
||||||
"""Handle a flow initiated by the user."""
|
"""Handle a flow initiated by the user."""
|
||||||
errors = {}
|
errors = {}
|
||||||
|
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
host = user_input[CONF_HOST]
|
host = user_input[CONF_HOST]
|
||||||
name = user_input[CONF_NAME]
|
name = user_input[CONF_NAME]
|
||||||
|
|
||||||
self._async_abort_entries_match({CONF_HOST: host})
|
self._async_abort_entries_match({CONF_HOST: host})
|
||||||
|
|
||||||
try:
|
mac_address, error = await self._async_try_connect(host)
|
||||||
await self._async_try_connect(host)
|
if error is None:
|
||||||
except exceptions.ConnectError:
|
await self.async_set_unique_id(format_mac(mac_address))
|
||||||
errors["base"] = "cannot_connect"
|
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
|
||||||
_LOGGER.error("Error connecting to device at %s", host)
|
|
||||||
except exceptions.InvalidHost:
|
|
||||||
errors["base"] = "invalid_host"
|
|
||||||
_LOGGER.error("Invalid host at %s", host)
|
|
||||||
except Exception: # pylint: disable=broad-except
|
|
||||||
_LOGGER.exception("Unexpected exception")
|
|
||||||
errors["base"] = "unknown"
|
|
||||||
else:
|
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
title=name,
|
title=name,
|
||||||
data={CONF_HOST: host, CONF_NAME: name},
|
data={CONF_HOST: host, CONF_NAME: name},
|
||||||
)
|
)
|
||||||
|
errors["base"] = error
|
||||||
|
|
||||||
user_input = user_input or {}
|
user_input = user_input or {}
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
|
@ -64,6 +101,18 @@ class GoalZeroFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _async_try_connect(self, host):
|
async def _async_try_connect(self, host):
|
||||||
session = async_get_clientsession(self.hass)
|
"""Try connecting to Goal Zero Yeti."""
|
||||||
api = Yeti(host, self.hass.loop, session)
|
try:
|
||||||
await api.get_state()
|
session = async_get_clientsession(self.hass)
|
||||||
|
api = Yeti(host, self.hass.loop, session)
|
||||||
|
await api.sysinfo()
|
||||||
|
except exceptions.ConnectError:
|
||||||
|
_LOGGER.error("Error connecting to device at %s", host)
|
||||||
|
return None, "cannot_connect"
|
||||||
|
except exceptions.InvalidHost:
|
||||||
|
_LOGGER.error("Invalid host at %s", host)
|
||||||
|
return None, "invalid_host"
|
||||||
|
except Exception: # pylint: disable=broad-except
|
||||||
|
_LOGGER.exception("Unexpected exception")
|
||||||
|
return None, "unknown"
|
||||||
|
return str(api.sysdata["macAddress"]), None
|
||||||
|
|
|
@ -4,6 +4,9 @@
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/goalzero",
|
"documentation": "https://www.home-assistant.io/integrations/goalzero",
|
||||||
"requirements": ["goalzero==0.1.7"],
|
"requirements": ["goalzero==0.1.7"],
|
||||||
|
"dhcp": [
|
||||||
|
{"hostname": "yeti*"}
|
||||||
|
],
|
||||||
"codeowners": ["@tkdrob"],
|
"codeowners": ["@tkdrob"],
|
||||||
"iot_class": "local_polling"
|
"iot_class": "local_polling"
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,11 +3,15 @@
|
||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"title": "Goal Zero Yeti",
|
"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. DHCP reservation 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.",
|
"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 Wi-fi network. DHCP reservation on your router is recommended. If not set up, the device may become unavailable until Home Assistant detects the new ip address. Refer to your router's user manual.",
|
||||||
"data": {
|
"data": {
|
||||||
"host": "[%key:common::config_flow::data::host%]",
|
"host": "[%key:common::config_flow::data::host%]",
|
||||||
"name": "[%key:common::config_flow::data::name%]"
|
"name": "[%key:common::config_flow::data::name%]"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"confirm_discovery": {
|
||||||
|
"title": "Goal Zero Yeti",
|
||||||
|
"description": "DHCP reservation on your router is recommended. If not set up, the device may become unavailable until Home Assistant detects the new ip address. Refer to your router's user manual."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
|
@ -16,7 +20,9 @@
|
||||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
|
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||||
|
"invalid_host": "[%key:common::config_flow::error::invalid_host%]",
|
||||||
|
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,7 +14,7 @@
|
||||||
"host": "Host",
|
"host": "Host",
|
||||||
"name": "Name"
|
"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. DHCP reservation 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.",
|
"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 Wi-fi network. DHCP reservation is recommended. If not set up, the device may become unavailable until Home Assistant detects the new ip address. Set it up in your router settings for the device. Refer to your router's user manual.",
|
||||||
"title": "Goal Zero Yeti"
|
"title": "Goal Zero Yeti"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -66,6 +66,10 @@ DHCP = [
|
||||||
"domain": "flume",
|
"domain": "flume",
|
||||||
"hostname": "flume-gw-*"
|
"hostname": "flume-gw-*"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"domain": "goalzero",
|
||||||
|
"hostname": "yeti*"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"domain": "gogogate2",
|
"domain": "gogogate2",
|
||||||
"hostname": "ismartgate*"
|
"hostname": "ismartgate*"
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS, MAC_ADDRESS
|
||||||
from homeassistant.const import CONF_HOST, CONF_NAME
|
from homeassistant.const import CONF_HOST, CONF_NAME
|
||||||
|
|
||||||
HOST = "1.2.3.4"
|
HOST = "1.2.3.4"
|
||||||
|
@ -17,6 +18,12 @@ CONF_CONFIG_FLOW = {
|
||||||
CONF_NAME: NAME,
|
CONF_NAME: NAME,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CONF_DHCP_FLOW = {
|
||||||
|
IP_ADDRESS: "1.1.1.1",
|
||||||
|
MAC_ADDRESS: "AA:BB:CC:DD:EE:FF",
|
||||||
|
HOSTNAME: "any",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _create_mocked_yeti(raise_exception=False):
|
async def _create_mocked_yeti(raise_exception=False):
|
||||||
mocked_yeti = AsyncMock()
|
mocked_yeti = AsyncMock()
|
||||||
|
|
|
@ -4,16 +4,18 @@ from unittest.mock import patch
|
||||||
from goalzero import exceptions
|
from goalzero import exceptions
|
||||||
|
|
||||||
from homeassistant.components.goalzero.const import DOMAIN
|
from homeassistant.components.goalzero.const import DOMAIN
|
||||||
from homeassistant.config_entries import SOURCE_USER
|
from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER
|
||||||
from homeassistant.data_entry_flow import (
|
from homeassistant.data_entry_flow import (
|
||||||
RESULT_TYPE_ABORT,
|
RESULT_TYPE_ABORT,
|
||||||
RESULT_TYPE_CREATE_ENTRY,
|
RESULT_TYPE_CREATE_ENTRY,
|
||||||
RESULT_TYPE_FORM,
|
RESULT_TYPE_FORM,
|
||||||
)
|
)
|
||||||
|
from homeassistant.setup import async_setup_component
|
||||||
|
|
||||||
from . import (
|
from . import (
|
||||||
CONF_CONFIG_FLOW,
|
CONF_CONFIG_FLOW,
|
||||||
CONF_DATA,
|
CONF_DATA,
|
||||||
|
CONF_DHCP_FLOW,
|
||||||
CONF_HOST,
|
CONF_HOST,
|
||||||
CONF_NAME,
|
CONF_NAME,
|
||||||
NAME,
|
NAME,
|
||||||
|
@ -114,3 +116,69 @@ async def test_flow_user_unknown_error(hass):
|
||||||
assert result["type"] == RESULT_TYPE_FORM
|
assert result["type"] == RESULT_TYPE_FORM
|
||||||
assert result["step_id"] == "user"
|
assert result["step_id"] == "user"
|
||||||
assert result["errors"] == {"base": "unknown"}
|
assert result["errors"] == {"base": "unknown"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_dhcp_discovery(hass):
|
||||||
|
"""Test we can process the discovery from dhcp."""
|
||||||
|
await async_setup_component(hass, "persistent_notification", {})
|
||||||
|
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_DHCP},
|
||||||
|
data=CONF_DHCP_FLOW,
|
||||||
|
)
|
||||||
|
assert result["type"] == "form"
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
|
||||||
|
assert result["data"] == {
|
||||||
|
CONF_HOST: "1.1.1.1",
|
||||||
|
CONF_NAME: "Yeti",
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": SOURCE_DHCP},
|
||||||
|
data=CONF_DHCP_FLOW,
|
||||||
|
)
|
||||||
|
assert result["type"] == RESULT_TYPE_ABORT
|
||||||
|
assert result["reason"] == "already_configured"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_dhcp_discovery_failed(hass):
|
||||||
|
"""Test failed setup from dhcp."""
|
||||||
|
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_DHCP},
|
||||||
|
data=CONF_DHCP_FLOW,
|
||||||
|
)
|
||||||
|
assert result["type"] == RESULT_TYPE_ABORT
|
||||||
|
assert result["reason"] == "cannot_connect"
|
||||||
|
|
||||||
|
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_DHCP},
|
||||||
|
data=CONF_DHCP_FLOW,
|
||||||
|
)
|
||||||
|
assert result["type"] == RESULT_TYPE_ABORT
|
||||||
|
assert result["reason"] == "invalid_host"
|
||||||
|
|
||||||
|
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_DHCP},
|
||||||
|
data=CONF_DHCP_FLOW,
|
||||||
|
)
|
||||||
|
assert result["type"] == RESULT_TYPE_ABORT
|
||||||
|
assert result["reason"] == "unknown"
|
||||||
|
|
Loading…
Reference in New Issue