Support local Smappee Genius device (#48627)
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
parent
8c52dfa1c5
commit
8b08134850
9 changed files with 171 additions and 25 deletions
|
@ -1,7 +1,7 @@
|
||||||
"""The Smappee integration."""
|
"""The Smappee integration."""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from pysmappee import Smappee
|
from pysmappee import Smappee, helper, mqtt
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
@ -75,8 +75,21 @@ async def async_setup(hass: HomeAssistant, config: dict):
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
|
||||||
"""Set up Smappee from a zeroconf or config entry."""
|
"""Set up Smappee from a zeroconf or config entry."""
|
||||||
if CONF_IP_ADDRESS in entry.data:
|
if CONF_IP_ADDRESS in entry.data:
|
||||||
smappee_api = api.api.SmappeeLocalApi(ip=entry.data[CONF_IP_ADDRESS])
|
if helper.is_smappee_genius(entry.data[CONF_SERIALNUMBER]):
|
||||||
smappee = Smappee(api=smappee_api, serialnumber=entry.data[CONF_SERIALNUMBER])
|
# next generation: local mqtt broker
|
||||||
|
smappee_mqtt = mqtt.SmappeeLocalMqtt(
|
||||||
|
serial_number=entry.data[CONF_SERIALNUMBER]
|
||||||
|
)
|
||||||
|
await hass.async_add_executor_job(smappee_mqtt.start_and_wait_for_config)
|
||||||
|
smappee = Smappee(
|
||||||
|
api=smappee_mqtt, serialnumber=entry.data[CONF_SERIALNUMBER]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# legacy devices through local api
|
||||||
|
smappee_api = api.api.SmappeeLocalApi(ip=entry.data[CONF_IP_ADDRESS])
|
||||||
|
smappee = Smappee(
|
||||||
|
api=smappee_api, serialnumber=entry.data[CONF_SERIALNUMBER]
|
||||||
|
)
|
||||||
await hass.async_add_executor_job(smappee.load_local_service_location)
|
await hass.async_add_executor_job(smappee.load_local_service_location)
|
||||||
else:
|
else:
|
||||||
implementation = (
|
implementation = (
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
"""Config flow for Smappee."""
|
"""Config flow for Smappee."""
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from pysmappee import helper, mqtt
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
|
@ -41,7 +42,6 @@ class SmappeeFlowHandler(
|
||||||
"""Handle zeroconf discovery."""
|
"""Handle zeroconf discovery."""
|
||||||
|
|
||||||
if not discovery_info[CONF_HOSTNAME].startswith(SUPPORTED_LOCAL_DEVICES):
|
if not discovery_info[CONF_HOSTNAME].startswith(SUPPORTED_LOCAL_DEVICES):
|
||||||
# We currently only support Energy and Solar models (legacy)
|
|
||||||
return self.async_abort(reason="invalid_mdns")
|
return self.async_abort(reason="invalid_mdns")
|
||||||
|
|
||||||
serial_number = (
|
serial_number = (
|
||||||
|
@ -86,10 +86,18 @@ class SmappeeFlowHandler(
|
||||||
serial_number = self.context.get(CONF_SERIALNUMBER)
|
serial_number = self.context.get(CONF_SERIALNUMBER)
|
||||||
|
|
||||||
# Attempt to make a connection to the local device
|
# Attempt to make a connection to the local device
|
||||||
smappee_api = api.api.SmappeeLocalApi(ip=ip_address)
|
if helper.is_smappee_genius(serial_number):
|
||||||
logon = await self.hass.async_add_executor_job(smappee_api.logon)
|
# next generation device, attempt connect to the local mqtt broker
|
||||||
if logon is None:
|
smappee_mqtt = mqtt.SmappeeLocalMqtt(serial_number=serial_number)
|
||||||
return self.async_abort(reason="cannot_connect")
|
connect = await self.hass.async_add_executor_job(smappee_mqtt.start_attempt)
|
||||||
|
if not connect:
|
||||||
|
return self.async_abort(reason="cannot_connect")
|
||||||
|
else:
|
||||||
|
# legacy devices, without local mqtt broker, try api access
|
||||||
|
smappee_api = api.api.SmappeeLocalApi(ip=ip_address)
|
||||||
|
logon = await self.hass.async_add_executor_job(smappee_api.logon)
|
||||||
|
if logon is None:
|
||||||
|
return self.async_abort(reason="cannot_connect")
|
||||||
|
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
title=f"{DOMAIN}{serial_number}",
|
title=f"{DOMAIN}{serial_number}",
|
||||||
|
@ -141,23 +149,35 @@ class SmappeeFlowHandler(
|
||||||
)
|
)
|
||||||
# In a LOCAL setup we still need to resolve the host to serial number
|
# In a LOCAL setup we still need to resolve the host to serial number
|
||||||
ip_address = user_input["host"]
|
ip_address = user_input["host"]
|
||||||
|
serial_number = None
|
||||||
|
|
||||||
|
# Attempt 1: try to use the local api (older generation) to resolve host to serialnumber
|
||||||
smappee_api = api.api.SmappeeLocalApi(ip=ip_address)
|
smappee_api = api.api.SmappeeLocalApi(ip=ip_address)
|
||||||
logon = await self.hass.async_add_executor_job(smappee_api.logon)
|
logon = await self.hass.async_add_executor_job(smappee_api.logon)
|
||||||
if logon is None:
|
if logon is not None:
|
||||||
return self.async_abort(reason="cannot_connect")
|
advanced_config = await self.hass.async_add_executor_job(
|
||||||
|
smappee_api.load_advanced_config
|
||||||
|
)
|
||||||
|
for config_item in advanced_config:
|
||||||
|
if config_item["key"] == "mdnsHostName":
|
||||||
|
serial_number = config_item["value"]
|
||||||
|
else:
|
||||||
|
# Attempt 2: try to use the local mqtt broker (newer generation) to resolve host to serialnumber
|
||||||
|
smappee_mqtt = mqtt.SmappeeLocalMqtt()
|
||||||
|
connect = await self.hass.async_add_executor_job(smappee_mqtt.start_attempt)
|
||||||
|
if not connect:
|
||||||
|
return self.async_abort(reason="cannot_connect")
|
||||||
|
|
||||||
advanced_config = await self.hass.async_add_executor_job(
|
serial_number = await self.hass.async_add_executor_job(
|
||||||
smappee_api.load_advanced_config
|
smappee_mqtt.start_and_wait_for_config
|
||||||
)
|
)
|
||||||
serial_number = None
|
await self.hass.async_add_executor_job(smappee_mqtt.stop)
|
||||||
for config_item in advanced_config:
|
if serial_number is None:
|
||||||
if config_item["key"] == "mdnsHostName":
|
return self.async_abort(reason="cannot_connect")
|
||||||
serial_number = config_item["value"]
|
|
||||||
|
|
||||||
if serial_number is None or not serial_number.startswith(
|
if serial_number is None or not serial_number.startswith(
|
||||||
SUPPORTED_LOCAL_DEVICES
|
SUPPORTED_LOCAL_DEVICES
|
||||||
):
|
):
|
||||||
# We currently only support Energy and Solar models (legacy)
|
|
||||||
return self.async_abort(reason="invalid_mdns")
|
return self.async_abort(reason="invalid_mdns")
|
||||||
|
|
||||||
serial_number = serial_number.replace("Smappee", "")
|
serial_number = serial_number.replace("Smappee", "")
|
||||||
|
|
|
@ -14,7 +14,7 @@ ENV_LOCAL = "local"
|
||||||
|
|
||||||
PLATFORMS = ["binary_sensor", "sensor", "switch"]
|
PLATFORMS = ["binary_sensor", "sensor", "switch"]
|
||||||
|
|
||||||
SUPPORTED_LOCAL_DEVICES = ("Smappee1", "Smappee2")
|
SUPPORTED_LOCAL_DEVICES = ("Smappee1", "Smappee2", "Smappee50")
|
||||||
|
|
||||||
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=20)
|
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=20)
|
||||||
|
|
||||||
|
|
|
@ -4,8 +4,12 @@
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/smappee",
|
"documentation": "https://www.home-assistant.io/integrations/smappee",
|
||||||
"dependencies": ["http"],
|
"dependencies": ["http"],
|
||||||
"requirements": ["pysmappee==0.2.17"],
|
"requirements": [
|
||||||
"codeowners": ["@bsmappee"],
|
"pysmappee==0.2.24"
|
||||||
|
],
|
||||||
|
"codeowners": [
|
||||||
|
"@bsmappee"
|
||||||
|
],
|
||||||
"zeroconf": [
|
"zeroconf": [
|
||||||
{
|
{
|
||||||
"type": "_ssh._tcp.local.",
|
"type": "_ssh._tcp.local.",
|
||||||
|
@ -14,6 +18,10 @@
|
||||||
{
|
{
|
||||||
"type": "_ssh._tcp.local.",
|
"type": "_ssh._tcp.local.",
|
||||||
"name": "smappee2*"
|
"name": "smappee2*"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "_ssh._tcp.local.",
|
||||||
|
"name": "smappee50*"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"iot_class": "cloud_polling"
|
"iot_class": "cloud_polling"
|
||||||
|
|
|
@ -205,6 +205,11 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||||
if service_location.has_voltage_values:
|
if service_location.has_voltage_values:
|
||||||
for sensor_name, sensor in VOLTAGE_SENSORS.items():
|
for sensor_name, sensor in VOLTAGE_SENSORS.items():
|
||||||
if service_location.phase_type in sensor[5]:
|
if service_location.phase_type in sensor[5]:
|
||||||
|
if (
|
||||||
|
sensor_name.startswith("line_")
|
||||||
|
and service_location.local_polling
|
||||||
|
):
|
||||||
|
continue
|
||||||
entities.append(
|
entities.append(
|
||||||
SmappeeSensor(
|
SmappeeSensor(
|
||||||
smappee_base=smappee_base,
|
smappee_base=smappee_base,
|
||||||
|
|
|
@ -157,6 +157,10 @@ ZEROCONF = {
|
||||||
{
|
{
|
||||||
"domain": "smappee",
|
"domain": "smappee",
|
||||||
"name": "smappee2*"
|
"name": "smappee2*"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"domain": "smappee",
|
||||||
|
"name": "smappee50*"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"_touch-able._tcp.local.": [
|
"_touch-able._tcp.local.": [
|
||||||
|
|
|
@ -1714,7 +1714,7 @@ pyskyqhub==0.1.3
|
||||||
pysma==0.4.3
|
pysma==0.4.3
|
||||||
|
|
||||||
# homeassistant.components.smappee
|
# homeassistant.components.smappee
|
||||||
pysmappee==0.2.17
|
pysmappee==0.2.24
|
||||||
|
|
||||||
# homeassistant.components.smartthings
|
# homeassistant.components.smartthings
|
||||||
pysmartapp==0.3.3
|
pysmartapp==0.3.3
|
||||||
|
|
|
@ -941,7 +941,7 @@ pysignalclirestapi==0.3.4
|
||||||
pysma==0.4.3
|
pysma==0.4.3
|
||||||
|
|
||||||
# homeassistant.components.smappee
|
# homeassistant.components.smappee
|
||||||
pysmappee==0.2.17
|
pysmappee==0.2.24
|
||||||
|
|
||||||
# homeassistant.components.smartthings
|
# homeassistant.components.smartthings
|
||||||
pysmartapp==0.3.3
|
pysmartapp==0.3.3
|
||||||
|
|
|
@ -77,9 +77,69 @@ async def test_show_zeroconf_connection_error_form(hass):
|
||||||
assert len(hass.config_entries.async_entries(DOMAIN)) == 0
|
assert len(hass.config_entries.async_entries(DOMAIN)) == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_show_zeroconf_connection_error_form_next_generation(hass):
|
||||||
|
"""Test that the zeroconf confirmation form is served."""
|
||||||
|
with patch("pysmappee.mqtt.SmappeeLocalMqtt.start_attempt", return_value=False):
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": SOURCE_ZEROCONF},
|
||||||
|
data={
|
||||||
|
"host": "1.2.3.4",
|
||||||
|
"port": 22,
|
||||||
|
CONF_HOSTNAME: "Smappee5001000212.local.",
|
||||||
|
"type": "_ssh._tcp.local.",
|
||||||
|
"name": "Smappee5001000212._ssh._tcp.local.",
|
||||||
|
"properties": {"_raw": {}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["description_placeholders"] == {CONF_SERIALNUMBER: "5001000212"}
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == "zeroconf_confirm"
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], {"host": "1.2.3.4"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||||
|
assert result["reason"] == "cannot_connect"
|
||||||
|
assert len(hass.config_entries.async_entries(DOMAIN)) == 0
|
||||||
|
|
||||||
|
|
||||||
async def test_connection_error(hass):
|
async def test_connection_error(hass):
|
||||||
"""Test we show user form on Smappee connection error."""
|
"""Test we show user form on Smappee connection error."""
|
||||||
with patch("pysmappee.api.SmappeeLocalApi.logon", return_value=None):
|
with patch("pysmappee.api.SmappeeLocalApi.logon", return_value=None), patch(
|
||||||
|
"pysmappee.mqtt.SmappeeLocalMqtt.start_attempt", return_value=None
|
||||||
|
):
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": SOURCE_USER},
|
||||||
|
)
|
||||||
|
assert result["step_id"] == "environment"
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], {"environment": ENV_LOCAL}
|
||||||
|
)
|
||||||
|
assert result["step_id"] == ENV_LOCAL
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], {"host": "1.2.3.4"}
|
||||||
|
)
|
||||||
|
assert result["reason"] == "cannot_connect"
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||||
|
|
||||||
|
|
||||||
|
async def test_user_local_connection_error(hass):
|
||||||
|
"""Test we show user form on Smappee connection error in local next generation option."""
|
||||||
|
with patch("pysmappee.api.SmappeeLocalApi.logon", return_value=None), patch(
|
||||||
|
"pysmappee.mqtt.SmappeeLocalMqtt.start_attempt", return_value=True
|
||||||
|
), patch("pysmappee.mqtt.SmappeeLocalMqtt.start", return_value=True), patch(
|
||||||
|
"pysmappee.mqtt.SmappeeLocalMqtt.stop", return_value=True
|
||||||
|
), patch(
|
||||||
|
"pysmappee.mqtt.SmappeeLocalMqtt.is_config_ready", return_value=None
|
||||||
|
):
|
||||||
result = await hass.config_entries.flow.async_init(
|
result = await hass.config_entries.flow.async_init(
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
context={"source": SOURCE_USER},
|
context={"source": SOURCE_USER},
|
||||||
|
@ -123,7 +183,7 @@ async def test_full_user_wrong_mdns(hass):
|
||||||
"""Test we abort user flow if unsupported mDNS name got resolved."""
|
"""Test we abort user flow if unsupported mDNS name got resolved."""
|
||||||
with patch("pysmappee.api.SmappeeLocalApi.logon", return_value={}), patch(
|
with patch("pysmappee.api.SmappeeLocalApi.logon", return_value={}), patch(
|
||||||
"pysmappee.api.SmappeeLocalApi.load_advanced_config",
|
"pysmappee.api.SmappeeLocalApi.load_advanced_config",
|
||||||
return_value=[{"key": "mdnsHostName", "value": "Smappee5010000001"}],
|
return_value=[{"key": "mdnsHostName", "value": "Smappee5100000001"}],
|
||||||
), patch(
|
), patch(
|
||||||
"pysmappee.api.SmappeeLocalApi.load_command_control_config", return_value=[]
|
"pysmappee.api.SmappeeLocalApi.load_command_control_config", return_value=[]
|
||||||
), patch(
|
), patch(
|
||||||
|
@ -464,3 +524,39 @@ async def test_full_user_local_flow(hass):
|
||||||
|
|
||||||
entry = hass.config_entries.async_entries(DOMAIN)[0]
|
entry = hass.config_entries.async_entries(DOMAIN)[0]
|
||||||
assert entry.unique_id == "1006000212"
|
assert entry.unique_id == "1006000212"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_full_zeroconf_flow_next_generation(hass):
|
||||||
|
"""Test the full zeroconf flow."""
|
||||||
|
with patch(
|
||||||
|
"pysmappee.mqtt.SmappeeLocalMqtt.start_attempt", return_value=True
|
||||||
|
), patch("pysmappee.mqtt.SmappeeLocalMqtt.start", return_value=None,), patch(
|
||||||
|
"pysmappee.mqtt.SmappeeLocalMqtt.is_config_ready",
|
||||||
|
return_value=None,
|
||||||
|
):
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": SOURCE_ZEROCONF},
|
||||||
|
data={
|
||||||
|
"host": "1.2.3.4",
|
||||||
|
"port": 22,
|
||||||
|
CONF_HOSTNAME: "Smappee5001000212.local.",
|
||||||
|
"type": "_ssh._tcp.local.",
|
||||||
|
"name": "Smappee5001000212._ssh._tcp.local.",
|
||||||
|
"properties": {"_raw": {}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == "zeroconf_confirm"
|
||||||
|
assert result["description_placeholders"] == {CONF_SERIALNUMBER: "5001000212"}
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], {"host": "1.2.3.4"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||||
|
assert result["title"] == "smappee5001000212"
|
||||||
|
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
|
||||||
|
|
||||||
|
entry = hass.config_entries.async_entries(DOMAIN)[0]
|
||||||
|
assert entry.unique_id == "5001000212"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue