Aurora abb (solar) configflow (#36300)
Co-authored-by: J. Nick Koston <nick@koston.org>pull/58450/head
parent
44aa1fdc66
commit
e22aaea5b2
|
@ -83,7 +83,6 @@ omit =
|
||||||
homeassistant/components/aurora/binary_sensor.py
|
homeassistant/components/aurora/binary_sensor.py
|
||||||
homeassistant/components/aurora/const.py
|
homeassistant/components/aurora/const.py
|
||||||
homeassistant/components/aurora/sensor.py
|
homeassistant/components/aurora/sensor.py
|
||||||
homeassistant/components/aurora_abb_powerone/sensor.py
|
|
||||||
homeassistant/components/avea/light.py
|
homeassistant/components/avea/light.py
|
||||||
homeassistant/components/avion/light.py
|
homeassistant/components/avion/light.py
|
||||||
homeassistant/components/azure_devops/__init__.py
|
homeassistant/components/azure_devops/__init__.py
|
||||||
|
|
|
@ -1 +1,49 @@
|
||||||
"""The Aurora ABB Powerone PV inverter sensor integration."""
|
"""The Aurora ABB Powerone PV inverter sensor integration."""
|
||||||
|
|
||||||
|
# Reference info:
|
||||||
|
# https://s1.solacity.com/docs/PVI-3.0-3.6-4.2-OUTD-US%20Manual.pdf
|
||||||
|
# http://www.drhack.it/images/PDF/AuroraCommunicationProtocol_4_2.pdf
|
||||||
|
#
|
||||||
|
# Developer note:
|
||||||
|
# vscode devcontainer: use the following to access USB device:
|
||||||
|
# "runArgs": ["-e", "GIT_EDITOR=code --wait", "--device=/dev/ttyUSB0"],
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from aurorapy.client import AuroraSerialClient
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import CONF_ADDRESS, CONF_PORT
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
PLATFORMS = ["sensor"]
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
|
||||||
|
"""Set up Aurora ABB PowerOne from a config entry."""
|
||||||
|
|
||||||
|
comport = entry.data[CONF_PORT]
|
||||||
|
address = entry.data[CONF_ADDRESS]
|
||||||
|
serclient = AuroraSerialClient(address, comport, parity="N", timeout=1)
|
||||||
|
|
||||||
|
hass.data.setdefault(DOMAIN, {})[entry.unique_id] = serclient
|
||||||
|
|
||||||
|
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
|
||||||
|
"""Unload a config entry."""
|
||||||
|
|
||||||
|
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
|
# It should not be necessary to close the serial port because we close
|
||||||
|
# it after every use in sensor.py, i.e. no need to do entry["client"].close()
|
||||||
|
if unload_ok:
|
||||||
|
hass.data[DOMAIN].pop(entry.unique_id)
|
||||||
|
|
||||||
|
return unload_ok
|
||||||
|
|
|
@ -0,0 +1,51 @@
|
||||||
|
"""Top level class for AuroraABBPowerOneSolarPV inverters and sensors."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from aurorapy.client import AuroraSerialClient
|
||||||
|
|
||||||
|
from homeassistant.helpers.entity import Entity
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
ATTR_DEVICE_NAME,
|
||||||
|
ATTR_FIRMWARE,
|
||||||
|
ATTR_MODEL,
|
||||||
|
ATTR_SERIAL_NUMBER,
|
||||||
|
DEFAULT_DEVICE_NAME,
|
||||||
|
DOMAIN,
|
||||||
|
MANUFACTURER,
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AuroraDevice(Entity):
|
||||||
|
"""Representation of an Aurora ABB PowerOne device."""
|
||||||
|
|
||||||
|
def __init__(self, client: AuroraSerialClient, data) -> None:
|
||||||
|
"""Initialise the basic device."""
|
||||||
|
self._data = data
|
||||||
|
self.type = "device"
|
||||||
|
self.client = client
|
||||||
|
self._available = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unique_id(self) -> str:
|
||||||
|
"""Return the unique id for this device."""
|
||||||
|
serial = self._data[ATTR_SERIAL_NUMBER]
|
||||||
|
return f"{serial}_{self.type}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> bool:
|
||||||
|
"""Return True if entity is available."""
|
||||||
|
return self._available
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_info(self):
|
||||||
|
"""Return device specific attributes."""
|
||||||
|
return {
|
||||||
|
"identifiers": {(DOMAIN, self._data[ATTR_SERIAL_NUMBER])},
|
||||||
|
"manufacturer": MANUFACTURER,
|
||||||
|
"model": self._data[ATTR_MODEL],
|
||||||
|
"name": self._data.get(ATTR_DEVICE_NAME, DEFAULT_DEVICE_NAME),
|
||||||
|
"sw_version": self._data[ATTR_FIRMWARE],
|
||||||
|
}
|
|
@ -0,0 +1,147 @@
|
||||||
|
"""Config flow for Aurora ABB PowerOne integration."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from aurorapy.client import AuroraError, AuroraSerialClient
|
||||||
|
import serial.tools.list_ports
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant import config_entries, core
|
||||||
|
from homeassistant.const import CONF_ADDRESS, CONF_PORT
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
ATTR_FIRMWARE,
|
||||||
|
ATTR_MODEL,
|
||||||
|
ATTR_SERIAL_NUMBER,
|
||||||
|
DEFAULT_ADDRESS,
|
||||||
|
DEFAULT_INTEGRATION_TITLE,
|
||||||
|
DOMAIN,
|
||||||
|
MAX_ADDRESS,
|
||||||
|
MIN_ADDRESS,
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_and_connect(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.
|
||||||
|
"""
|
||||||
|
comport = data[CONF_PORT]
|
||||||
|
address = data[CONF_ADDRESS]
|
||||||
|
_LOGGER.debug("Intitialising com port=%s", comport)
|
||||||
|
ret = {}
|
||||||
|
ret["title"] = DEFAULT_INTEGRATION_TITLE
|
||||||
|
try:
|
||||||
|
client = AuroraSerialClient(address, comport, parity="N", timeout=1)
|
||||||
|
client.connect()
|
||||||
|
ret[ATTR_SERIAL_NUMBER] = client.serial_number()
|
||||||
|
ret[ATTR_MODEL] = f"{client.version()} ({client.pn()})"
|
||||||
|
ret[ATTR_FIRMWARE] = client.firmware(1)
|
||||||
|
_LOGGER.info("Returning device info=%s", ret)
|
||||||
|
except AuroraError as err:
|
||||||
|
_LOGGER.warning("Could not connect to device=%s", comport)
|
||||||
|
raise err
|
||||||
|
finally:
|
||||||
|
if client.serline.isOpen():
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
# Return info we want to store in the config entry.
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
def scan_comports():
|
||||||
|
"""Find and store available com ports for the GUI dropdown."""
|
||||||
|
comports = serial.tools.list_ports.comports(include_links=True)
|
||||||
|
comportslist = []
|
||||||
|
for port in comports:
|
||||||
|
comportslist.append(port.device)
|
||||||
|
_LOGGER.debug("COM port option: %s", port.device)
|
||||||
|
if len(comportslist) > 0:
|
||||||
|
return comportslist, comportslist[0]
|
||||||
|
_LOGGER.warning("No com ports found. Need a valid RS485 device to communicate")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
class AuroraABBConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Handle a config flow for Aurora ABB PowerOne."""
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
|
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialise the config flow."""
|
||||||
|
self.config = None
|
||||||
|
self._comportslist = None
|
||||||
|
self._defaultcomport = None
|
||||||
|
|
||||||
|
async def async_step_import(self, config: dict):
|
||||||
|
"""Import a configuration from config.yaml."""
|
||||||
|
if self.hass.config_entries.async_entries(DOMAIN):
|
||||||
|
return self.async_abort(reason="already_setup")
|
||||||
|
|
||||||
|
conf = {}
|
||||||
|
conf[ATTR_SERIAL_NUMBER] = "sn_unknown_yaml"
|
||||||
|
conf[ATTR_MODEL] = "model_unknown_yaml"
|
||||||
|
conf[ATTR_FIRMWARE] = "fw_unknown_yaml"
|
||||||
|
conf[CONF_PORT] = config["device"]
|
||||||
|
conf[CONF_ADDRESS] = config["address"]
|
||||||
|
# config["name"] from yaml is ignored.
|
||||||
|
|
||||||
|
await self.async_set_unique_id(self.flow_id)
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
|
||||||
|
return self.async_create_entry(title=DEFAULT_INTEGRATION_TITLE, data=conf)
|
||||||
|
|
||||||
|
async def async_step_user(self, user_input=None):
|
||||||
|
"""Handle a flow initialised by the user."""
|
||||||
|
|
||||||
|
errors = {}
|
||||||
|
if self._comportslist is None:
|
||||||
|
result = await self.hass.async_add_executor_job(scan_comports)
|
||||||
|
self._comportslist, self._defaultcomport = result
|
||||||
|
if self._defaultcomport is None:
|
||||||
|
return self.async_abort(reason="no_serial_ports")
|
||||||
|
|
||||||
|
# Handle the initial step.
|
||||||
|
if user_input is not None:
|
||||||
|
try:
|
||||||
|
info = await self.hass.async_add_executor_job(
|
||||||
|
validate_and_connect, self.hass, user_input
|
||||||
|
)
|
||||||
|
info.update(user_input)
|
||||||
|
# Bomb out early if someone has already set up this device.
|
||||||
|
device_unique_id = info["serial_number"]
|
||||||
|
await self.async_set_unique_id(device_unique_id)
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
|
||||||
|
return self.async_create_entry(title=info["title"], data=info)
|
||||||
|
|
||||||
|
except OSError as error:
|
||||||
|
if error.errno == 19: # No such device.
|
||||||
|
errors["base"] = "invalid_serial_port"
|
||||||
|
except AuroraError as error:
|
||||||
|
if "could not open port" in str(error):
|
||||||
|
errors["base"] = "cannot_open_serial_port"
|
||||||
|
elif "No response after" in str(error):
|
||||||
|
errors["base"] = "cannot_connect" # could be dark
|
||||||
|
else:
|
||||||
|
_LOGGER.error(
|
||||||
|
"Unable to communicate with Aurora ABB Inverter at %s: %s %s",
|
||||||
|
user_input[CONF_PORT],
|
||||||
|
type(error),
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
errors["base"] = "cannot_connect"
|
||||||
|
# If no user input, must be first pass through the config. Show initial form.
|
||||||
|
config_options = {
|
||||||
|
vol.Required(CONF_PORT, default=self._defaultcomport): vol.In(
|
||||||
|
self._comportslist
|
||||||
|
),
|
||||||
|
vol.Required(CONF_ADDRESS, default=DEFAULT_ADDRESS): vol.In(
|
||||||
|
range(MIN_ADDRESS, MAX_ADDRESS + 1)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
schema = vol.Schema(config_options)
|
||||||
|
|
||||||
|
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
|
|
@ -0,0 +1,22 @@
|
||||||
|
"""Constants for the Aurora ABB PowerOne integration."""
|
||||||
|
|
||||||
|
DOMAIN = "aurora_abb_powerone"
|
||||||
|
|
||||||
|
# Min max addresses and default according to here:
|
||||||
|
# https://library.e.abb.com/public/e57212c407344a16b4644cee73492b39/PVI-3.0_3.6_4.2-TL-OUTD-Product%20manual%20EN-RevB(M000016BG).pdf
|
||||||
|
|
||||||
|
MIN_ADDRESS = 2
|
||||||
|
MAX_ADDRESS = 63
|
||||||
|
DEFAULT_ADDRESS = 2
|
||||||
|
|
||||||
|
DEFAULT_INTEGRATION_TITLE = "PhotoVoltaic Inverters"
|
||||||
|
DEFAULT_DEVICE_NAME = "Solar Inverter"
|
||||||
|
|
||||||
|
DEVICES = "devices"
|
||||||
|
MANUFACTURER = "ABB"
|
||||||
|
|
||||||
|
ATTR_DEVICE_NAME = "device_name"
|
||||||
|
ATTR_DEVICE_ID = "device_id"
|
||||||
|
ATTR_SERIAL_NUMBER = "serial_number"
|
||||||
|
ATTR_MODEL = "model"
|
||||||
|
ATTR_FIRMWARE = "firmware"
|
|
@ -1,8 +1,11 @@
|
||||||
{
|
{
|
||||||
"domain": "aurora_abb_powerone",
|
"domain": "aurora_abb_powerone",
|
||||||
"name": "Aurora ABB Solar PV",
|
"name": "Aurora ABB PowerOne Solar PV",
|
||||||
"documentation": "https://www.home-assistant.io/integrations/aurora_abb_powerone/",
|
"config_flow": true,
|
||||||
"codeowners": ["@davet2001"],
|
"documentation": "https://www.home-assistant.io/integrations/aurora_abb_powerone",
|
||||||
"requirements": ["aurorapy==0.2.6"],
|
"requirements": ["aurorapy==0.2.6"],
|
||||||
|
"codeowners": [
|
||||||
|
"@davet2001"
|
||||||
|
],
|
||||||
"iot_class": "local_polling"
|
"iot_class": "local_polling"
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,54 +10,85 @@ from homeassistant.components.sensor import (
|
||||||
STATE_CLASS_MEASUREMENT,
|
STATE_CLASS_MEASUREMENT,
|
||||||
SensorEntity,
|
SensorEntity,
|
||||||
)
|
)
|
||||||
|
from homeassistant.config_entries import SOURCE_IMPORT
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
CONF_ADDRESS,
|
CONF_ADDRESS,
|
||||||
CONF_DEVICE,
|
CONF_DEVICE,
|
||||||
CONF_NAME,
|
CONF_NAME,
|
||||||
DEVICE_CLASS_POWER,
|
DEVICE_CLASS_POWER,
|
||||||
|
DEVICE_CLASS_TEMPERATURE,
|
||||||
POWER_WATT,
|
POWER_WATT,
|
||||||
|
TEMP_CELSIUS,
|
||||||
)
|
)
|
||||||
|
from homeassistant.exceptions import InvalidStateError
|
||||||
import homeassistant.helpers.config_validation as cv
|
import homeassistant.helpers.config_validation as cv
|
||||||
|
|
||||||
|
from .aurora_device import AuroraDevice
|
||||||
|
from .const import DEFAULT_ADDRESS, DOMAIN
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_ADDRESS = 2
|
|
||||||
DEFAULT_NAME = "Solar PV"
|
|
||||||
|
|
||||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
||||||
{
|
{
|
||||||
vol.Required(CONF_DEVICE): cv.string,
|
vol.Required(CONF_DEVICE): cv.string,
|
||||||
vol.Optional(CONF_ADDRESS, default=DEFAULT_ADDRESS): cv.positive_int,
|
vol.Optional(CONF_ADDRESS, default=DEFAULT_ADDRESS): cv.positive_int,
|
||||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
vol.Optional(CONF_NAME, default="Solar PV"): cv.string,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
|
||||||
"""Set up the Aurora ABB PowerOne device."""
|
"""Set up based on configuration.yaml (DEPRECATED)."""
|
||||||
devices = []
|
_LOGGER.warning(
|
||||||
comport = config[CONF_DEVICE]
|
"Loading aurora_abb_powerone via platform config is deprecated; The configuration"
|
||||||
address = config[CONF_ADDRESS]
|
" has been migrated to a config entry and can be safely removed from configuration.yaml"
|
||||||
name = config[CONF_NAME]
|
)
|
||||||
|
hass.async_create_task(
|
||||||
_LOGGER.debug("Intitialising com port=%s address=%s", comport, address)
|
hass.config_entries.flow.async_init(
|
||||||
client = AuroraSerialClient(address, comport, parity="N", timeout=1)
|
DOMAIN, context={"source": SOURCE_IMPORT}, data=config
|
||||||
|
)
|
||||||
devices.append(AuroraABBSolarPVMonitorSensor(client, name, "Power"))
|
)
|
||||||
add_entities(devices, True)
|
|
||||||
|
|
||||||
|
|
||||||
class AuroraABBSolarPVMonitorSensor(SensorEntity):
|
async def async_setup_entry(hass, config_entry, async_add_entities) -> None:
|
||||||
"""Representation of a Sensor."""
|
"""Set up aurora_abb_powerone sensor based on a config entry."""
|
||||||
|
entities = []
|
||||||
|
|
||||||
|
sensortypes = [
|
||||||
|
{"parameter": "instantaneouspower", "name": "Power Output"},
|
||||||
|
{"parameter": "temperature", "name": "Temperature"},
|
||||||
|
]
|
||||||
|
client = hass.data[DOMAIN][config_entry.unique_id]
|
||||||
|
data = config_entry.data
|
||||||
|
|
||||||
|
for sens in sensortypes:
|
||||||
|
entities.append(AuroraSensor(client, data, sens["name"], sens["parameter"]))
|
||||||
|
|
||||||
|
_LOGGER.debug("async_setup_entry adding %d entities", len(entities))
|
||||||
|
async_add_entities(entities, True)
|
||||||
|
|
||||||
|
|
||||||
|
class AuroraSensor(AuroraDevice, SensorEntity):
|
||||||
|
"""Representation of a Sensor on a Aurora ABB PowerOne Solar inverter."""
|
||||||
|
|
||||||
_attr_state_class = STATE_CLASS_MEASUREMENT
|
_attr_state_class = STATE_CLASS_MEASUREMENT
|
||||||
_attr_native_unit_of_measurement = POWER_WATT
|
|
||||||
_attr_device_class = DEVICE_CLASS_POWER
|
|
||||||
|
|
||||||
def __init__(self, client, name, typename):
|
def __init__(self, client: AuroraSerialClient, data, name, typename):
|
||||||
"""Initialize the sensor."""
|
"""Initialize the sensor."""
|
||||||
self._attr_name = f"{name} {typename}"
|
super().__init__(client, data)
|
||||||
self.client = client
|
if typename == "instantaneouspower":
|
||||||
|
self.type = typename
|
||||||
|
self._attr_unit_of_measurement = POWER_WATT
|
||||||
|
self._attr_device_class = DEVICE_CLASS_POWER
|
||||||
|
elif typename == "temperature":
|
||||||
|
self.type = typename
|
||||||
|
self._attr_native_unit_of_measurement = TEMP_CELSIUS
|
||||||
|
self._attr_device_class = DEVICE_CLASS_TEMPERATURE
|
||||||
|
else:
|
||||||
|
raise InvalidStateError(f"Unrecognised typename '{typename}'")
|
||||||
|
self._attr_name = f"{name}"
|
||||||
|
self.availableprev = True
|
||||||
|
|
||||||
def update(self):
|
def update(self):
|
||||||
"""Fetch new state data for the sensor.
|
"""Fetch new state data for the sensor.
|
||||||
|
@ -65,11 +96,21 @@ class AuroraABBSolarPVMonitorSensor(SensorEntity):
|
||||||
This is the only method that should fetch new data for Home Assistant.
|
This is the only method that should fetch new data for Home Assistant.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
self.availableprev = self._attr_available
|
||||||
self.client.connect()
|
self.client.connect()
|
||||||
|
if self.type == "instantaneouspower":
|
||||||
# read ADC channel 3 (grid power output)
|
# read ADC channel 3 (grid power output)
|
||||||
power_watts = self.client.measure(3, True)
|
power_watts = self.client.measure(3, True)
|
||||||
self._attr_native_value = round(power_watts, 1)
|
self._attr_state = round(power_watts, 1)
|
||||||
|
elif self.type == "temperature":
|
||||||
|
temperature_c = self.client.measure(21)
|
||||||
|
self._attr_native_value = round(temperature_c, 1)
|
||||||
|
self._attr_available = True
|
||||||
|
|
||||||
except AuroraError as error:
|
except AuroraError as error:
|
||||||
|
self._attr_state = None
|
||||||
|
self._attr_native_value = None
|
||||||
|
self._attr_available = False
|
||||||
# aurorapy does not have different exceptions (yet) for dealing
|
# aurorapy does not have different exceptions (yet) for dealing
|
||||||
# with timeout vs other comms errors.
|
# with timeout vs other comms errors.
|
||||||
# This means the (normal) situation of no response during darkness
|
# This means the (normal) situation of no response during darkness
|
||||||
|
@ -82,7 +123,14 @@ class AuroraABBSolarPVMonitorSensor(SensorEntity):
|
||||||
_LOGGER.debug("No response from inverter (could be dark)")
|
_LOGGER.debug("No response from inverter (could be dark)")
|
||||||
else:
|
else:
|
||||||
raise error
|
raise error
|
||||||
self._attr_native_value = None
|
|
||||||
finally:
|
finally:
|
||||||
|
if self._attr_available != self.availableprev:
|
||||||
|
if self._attr_available:
|
||||||
|
_LOGGER.info("Communication with %s back online", self.name)
|
||||||
|
else:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Communication with %s lost",
|
||||||
|
self.name,
|
||||||
|
)
|
||||||
if self.client.serline.isOpen():
|
if self.client.serline.isOpen():
|
||||||
self.client.close()
|
self.client.close()
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"description": "The inverter must be connected via an RS485 adaptor, please select serial port and the inverter's address as configured on the LCD panel",
|
||||||
|
"data": {
|
||||||
|
"port": "RS485 or USB-RS485 Adaptor Port",
|
||||||
|
"address": "Inverter Address"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "Unable to connect, please check serial port, address, electrical connection and that inverter is on (in daylight)",
|
||||||
|
"invalid_serial_port": "Serial port is not a valid device or could not be openned",
|
||||||
|
"cannot_open_serial_port": "Cannot open serial port, please check and try again",
|
||||||
|
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "Device is already configured",
|
||||||
|
"no_serial_ports": "No com ports found. Need a valid RS485 device to communicate."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "Device is already configured",
|
||||||
|
"no_serial_ports": "No com ports found. Need a valid RS485 device to communicate."
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "Unable to connect, please check serial port, address, electrical connection and that inverter is on (in daylight)",
|
||||||
|
"cannot_open_serial_port": "Cannot open serial port, please check and try again",
|
||||||
|
"invalid_serial_port": "Serial port is not a valid device or could not be openned",
|
||||||
|
"unknown": "Unexpected error"
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"data": {
|
||||||
|
"address": "Inverter Address",
|
||||||
|
"port": "RS485 or USB-RS485 Adaptor Port"
|
||||||
|
},
|
||||||
|
"description": "The inverter must be connected via an RS485 adaptor, please select serial port and the inverter's address as configured on the LCD panel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -31,6 +31,7 @@ FLOWS = [
|
||||||
"atag",
|
"atag",
|
||||||
"august",
|
"august",
|
||||||
"aurora",
|
"aurora",
|
||||||
|
"aurora_abb_powerone",
|
||||||
"awair",
|
"awair",
|
||||||
"axis",
|
"axis",
|
||||||
"azure_devops",
|
"azure_devops",
|
||||||
|
|
|
@ -235,6 +235,9 @@ async-upnp-client==0.22.10
|
||||||
# homeassistant.components.aurora
|
# homeassistant.components.aurora
|
||||||
auroranoaa==0.0.2
|
auroranoaa==0.0.2
|
||||||
|
|
||||||
|
# homeassistant.components.aurora_abb_powerone
|
||||||
|
aurorapy==0.2.6
|
||||||
|
|
||||||
# homeassistant.components.stream
|
# homeassistant.components.stream
|
||||||
av==8.0.3
|
av==8.0.3
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
"""Tests for the Aurora ABB PowerOne Solar PV integration."""
|
|
@ -0,0 +1,165 @@
|
||||||
|
"""Test the Aurora ABB PowerOne Solar PV config flow."""
|
||||||
|
from logging import INFO
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from aurorapy.client import AuroraError
|
||||||
|
from serial.tools import list_ports_common
|
||||||
|
|
||||||
|
from homeassistant import config_entries, data_entry_flow, setup
|
||||||
|
from homeassistant.components.aurora_abb_powerone.const import (
|
||||||
|
ATTR_FIRMWARE,
|
||||||
|
ATTR_MODEL,
|
||||||
|
ATTR_SERIAL_NUMBER,
|
||||||
|
DOMAIN,
|
||||||
|
)
|
||||||
|
from homeassistant.const import CONF_ADDRESS, CONF_PORT
|
||||||
|
|
||||||
|
|
||||||
|
async def test_form(hass):
|
||||||
|
"""Test we get the form."""
|
||||||
|
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||||
|
|
||||||
|
fakecomports = []
|
||||||
|
fakecomports.append(list_ports_common.ListPortInfo("/dev/ttyUSB7"))
|
||||||
|
with patch(
|
||||||
|
"serial.tools.list_ports.comports",
|
||||||
|
return_value=fakecomports,
|
||||||
|
):
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["errors"] == {}
|
||||||
|
|
||||||
|
with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None,), patch(
|
||||||
|
"aurorapy.client.AuroraSerialClient.serial_number",
|
||||||
|
return_value="9876543",
|
||||||
|
), patch(
|
||||||
|
"aurorapy.client.AuroraSerialClient.version",
|
||||||
|
return_value="9.8.7.6",
|
||||||
|
), patch(
|
||||||
|
"aurorapy.client.AuroraSerialClient.pn",
|
||||||
|
return_value="A.B.C",
|
||||||
|
), patch(
|
||||||
|
"aurorapy.client.AuroraSerialClient.firmware",
|
||||||
|
return_value="1.234",
|
||||||
|
), patch(
|
||||||
|
"homeassistant.components.aurora_abb_powerone.config_flow._LOGGER.getEffectiveLevel",
|
||||||
|
return_value=INFO,
|
||||||
|
) as mock_setup, patch(
|
||||||
|
"homeassistant.components.aurora_abb_powerone.async_setup_entry",
|
||||||
|
return_value=True,
|
||||||
|
) as mock_setup_entry:
|
||||||
|
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{CONF_PORT: "/dev/ttyUSB7", CONF_ADDRESS: 7},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||||
|
|
||||||
|
assert result2["data"] == {
|
||||||
|
CONF_PORT: "/dev/ttyUSB7",
|
||||||
|
CONF_ADDRESS: 7,
|
||||||
|
ATTR_FIRMWARE: "1.234",
|
||||||
|
ATTR_MODEL: "9.8.7.6 (A.B.C)",
|
||||||
|
ATTR_SERIAL_NUMBER: "9876543",
|
||||||
|
"title": "PhotoVoltaic Inverters",
|
||||||
|
}
|
||||||
|
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_no_comports(hass):
|
||||||
|
"""Test we display correct info when there are no com ports.."""
|
||||||
|
|
||||||
|
fakecomports = []
|
||||||
|
with patch(
|
||||||
|
"serial.tools.list_ports.comports",
|
||||||
|
return_value=fakecomports,
|
||||||
|
):
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
assert result["type"] == "abort"
|
||||||
|
assert result["reason"] == "no_serial_ports"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_form_invalid_com_ports(hass):
|
||||||
|
"""Test we display correct info when the comport is invalid.."""
|
||||||
|
|
||||||
|
fakecomports = []
|
||||||
|
fakecomports.append(list_ports_common.ListPortInfo("/dev/ttyUSB7"))
|
||||||
|
with patch(
|
||||||
|
"serial.tools.list_ports.comports",
|
||||||
|
return_value=fakecomports,
|
||||||
|
):
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["errors"] == {}
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"aurorapy.client.AuroraSerialClient.connect",
|
||||||
|
side_effect=OSError(19, "...no such device..."),
|
||||||
|
return_value=None,
|
||||||
|
):
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{CONF_PORT: "/dev/ttyUSB7", CONF_ADDRESS: 7},
|
||||||
|
)
|
||||||
|
assert result2["errors"] == {"base": "invalid_serial_port"}
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"aurorapy.client.AuroraSerialClient.connect",
|
||||||
|
side_effect=AuroraError("..could not open port..."),
|
||||||
|
return_value=None,
|
||||||
|
):
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{CONF_PORT: "/dev/ttyUSB7", CONF_ADDRESS: 7},
|
||||||
|
)
|
||||||
|
assert result2["errors"] == {"base": "cannot_open_serial_port"}
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"aurorapy.client.AuroraSerialClient.connect",
|
||||||
|
side_effect=AuroraError("...No response after..."),
|
||||||
|
return_value=None,
|
||||||
|
):
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{CONF_PORT: "/dev/ttyUSB7", CONF_ADDRESS: 7},
|
||||||
|
)
|
||||||
|
assert result2["errors"] == {"base": "cannot_connect"}
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"aurorapy.client.AuroraSerialClient.connect",
|
||||||
|
side_effect=AuroraError("...Some other message!!!123..."),
|
||||||
|
return_value=None,
|
||||||
|
), patch("serial.Serial.isOpen", return_value=True,), patch(
|
||||||
|
"aurorapy.client.AuroraSerialClient.close",
|
||||||
|
) as mock_clientclose:
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{CONF_PORT: "/dev/ttyUSB7", CONF_ADDRESS: 7},
|
||||||
|
)
|
||||||
|
assert result2["errors"] == {"base": "cannot_connect"}
|
||||||
|
assert len(mock_clientclose.mock_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# Tests below can be deleted after deprecation period is finished.
|
||||||
|
async def test_import(hass):
|
||||||
|
"""Test configuration.yaml import used during migration."""
|
||||||
|
TESTDATA = {"device": "/dev/ttyUSB7", "address": 3, "name": "MyAuroraPV"}
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.generic.camera.GenericCamera.async_camera_image",
|
||||||
|
return_value=None,
|
||||||
|
):
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=TESTDATA
|
||||||
|
)
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||||
|
assert result["data"][CONF_PORT] == "/dev/ttyUSB7"
|
||||||
|
assert result["data"][CONF_ADDRESS] == 3
|
|
@ -0,0 +1,37 @@
|
||||||
|
"""Pytest modules for Aurora ABB Powerone PV inverter sensor integration."""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from homeassistant.components.aurora_abb_powerone.const import (
|
||||||
|
ATTR_FIRMWARE,
|
||||||
|
ATTR_MODEL,
|
||||||
|
ATTR_SERIAL_NUMBER,
|
||||||
|
DOMAIN,
|
||||||
|
)
|
||||||
|
from homeassistant.const import CONF_ADDRESS, CONF_PORT
|
||||||
|
from homeassistant.setup import async_setup_component
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unload_entry(hass):
|
||||||
|
"""Test unloading the aurora_abb_powerone entry."""
|
||||||
|
|
||||||
|
with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch(
|
||||||
|
"homeassistant.components.aurora_abb_powerone.sensor.AuroraSensor.update",
|
||||||
|
return_value=None,
|
||||||
|
):
|
||||||
|
mock_entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
data={
|
||||||
|
CONF_PORT: "/dev/ttyUSB7",
|
||||||
|
CONF_ADDRESS: 7,
|
||||||
|
ATTR_MODEL: "model123",
|
||||||
|
ATTR_SERIAL_NUMBER: "876",
|
||||||
|
ATTR_FIRMWARE: "1.2.3.4",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
mock_entry.add_to_hass(hass)
|
||||||
|
assert await async_setup_component(hass, DOMAIN, {})
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert await hass.config_entries.async_unload(mock_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
|
@ -0,0 +1,185 @@
|
||||||
|
"""Test the Aurora ABB PowerOne Solar PV sensors."""
|
||||||
|
from datetime import timedelta
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from aurorapy.client import AuroraError
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant.components.aurora_abb_powerone.const import (
|
||||||
|
ATTR_DEVICE_NAME,
|
||||||
|
ATTR_FIRMWARE,
|
||||||
|
ATTR_MODEL,
|
||||||
|
ATTR_SERIAL_NUMBER,
|
||||||
|
DEFAULT_INTEGRATION_TITLE,
|
||||||
|
DOMAIN,
|
||||||
|
)
|
||||||
|
from homeassistant.components.aurora_abb_powerone.sensor import AuroraSensor
|
||||||
|
from homeassistant.config_entries import SOURCE_IMPORT
|
||||||
|
from homeassistant.const import CONF_ADDRESS, CONF_PORT
|
||||||
|
from homeassistant.exceptions import InvalidStateError
|
||||||
|
from homeassistant.setup import async_setup_component
|
||||||
|
import homeassistant.util.dt as dt_util
|
||||||
|
|
||||||
|
from tests.common import (
|
||||||
|
MockConfigEntry,
|
||||||
|
assert_setup_component,
|
||||||
|
async_fire_time_changed,
|
||||||
|
)
|
||||||
|
|
||||||
|
TEST_CONFIG = {
|
||||||
|
"sensor": {
|
||||||
|
"platform": "aurora_abb_powerone",
|
||||||
|
"device": "/dev/fakedevice0",
|
||||||
|
"address": 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _simulated_returns(index, global_measure=None):
|
||||||
|
returns = {
|
||||||
|
3: 45.678, # power
|
||||||
|
21: 9.876, # temperature
|
||||||
|
}
|
||||||
|
return returns[index]
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_config_entry():
|
||||||
|
return MockConfigEntry(
|
||||||
|
version=1,
|
||||||
|
domain=DOMAIN,
|
||||||
|
title=DEFAULT_INTEGRATION_TITLE,
|
||||||
|
data={
|
||||||
|
CONF_PORT: "/dev/usb999",
|
||||||
|
CONF_ADDRESS: 3,
|
||||||
|
ATTR_DEVICE_NAME: "mydevicename",
|
||||||
|
ATTR_MODEL: "mymodel",
|
||||||
|
ATTR_SERIAL_NUMBER: "123456",
|
||||||
|
ATTR_FIRMWARE: "1.2.3.4",
|
||||||
|
},
|
||||||
|
source="dummysource",
|
||||||
|
entry_id="13579",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_setup_platform_valid_config(hass):
|
||||||
|
"""Test that (deprecated) yaml import still works."""
|
||||||
|
with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch(
|
||||||
|
"aurorapy.client.AuroraSerialClient.measure",
|
||||||
|
side_effect=_simulated_returns,
|
||||||
|
), assert_setup_component(1, "sensor"):
|
||||||
|
assert await async_setup_component(hass, "sensor", TEST_CONFIG)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
power = hass.states.get("sensor.power_output")
|
||||||
|
assert power
|
||||||
|
assert power.state == "45.7"
|
||||||
|
|
||||||
|
# try to set up a second time - should abort.
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
data=TEST_CONFIG,
|
||||||
|
context={"source": SOURCE_IMPORT},
|
||||||
|
)
|
||||||
|
assert result["type"] == "abort"
|
||||||
|
assert result["reason"] == "already_setup"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sensors(hass):
|
||||||
|
"""Test data coming back from inverter."""
|
||||||
|
mock_entry = _mock_config_entry()
|
||||||
|
|
||||||
|
with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch(
|
||||||
|
"aurorapy.client.AuroraSerialClient.measure",
|
||||||
|
side_effect=_simulated_returns,
|
||||||
|
):
|
||||||
|
mock_entry.add_to_hass(hass)
|
||||||
|
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
power = hass.states.get("sensor.power_output")
|
||||||
|
assert power
|
||||||
|
assert power.state == "45.7"
|
||||||
|
|
||||||
|
temperature = hass.states.get("sensor.temperature")
|
||||||
|
assert temperature
|
||||||
|
assert temperature.state == "9.9"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sensor_invalid_type(hass):
|
||||||
|
"""Test invalid sensor type during setup."""
|
||||||
|
entities = []
|
||||||
|
mock_entry = _mock_config_entry()
|
||||||
|
|
||||||
|
with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch(
|
||||||
|
"aurorapy.client.AuroraSerialClient.measure",
|
||||||
|
side_effect=_simulated_returns,
|
||||||
|
):
|
||||||
|
mock_entry.add_to_hass(hass)
|
||||||
|
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
client = hass.data[DOMAIN][mock_entry.unique_id]
|
||||||
|
data = mock_entry.data
|
||||||
|
with pytest.raises(InvalidStateError):
|
||||||
|
entities.append(AuroraSensor(client, data, "WrongSensor", "wrongparameter"))
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sensor_dark(hass):
|
||||||
|
"""Test that darkness (no comms) is handled correctly."""
|
||||||
|
mock_entry = _mock_config_entry()
|
||||||
|
|
||||||
|
utcnow = dt_util.utcnow()
|
||||||
|
# sun is up
|
||||||
|
with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch(
|
||||||
|
"aurorapy.client.AuroraSerialClient.measure", side_effect=_simulated_returns
|
||||||
|
):
|
||||||
|
mock_entry.add_to_hass(hass)
|
||||||
|
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
power = hass.states.get("sensor.power_output")
|
||||||
|
assert power is not None
|
||||||
|
assert power.state == "45.7"
|
||||||
|
|
||||||
|
# sunset
|
||||||
|
with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch(
|
||||||
|
"aurorapy.client.AuroraSerialClient.measure",
|
||||||
|
side_effect=AuroraError("No response after 10 seconds"),
|
||||||
|
):
|
||||||
|
async_fire_time_changed(hass, utcnow + timedelta(seconds=60))
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
power = hass.states.get("sensor.power_output")
|
||||||
|
assert power.state == "unknown"
|
||||||
|
# sun rose again
|
||||||
|
with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch(
|
||||||
|
"aurorapy.client.AuroraSerialClient.measure", side_effect=_simulated_returns
|
||||||
|
):
|
||||||
|
async_fire_time_changed(hass, utcnow + timedelta(seconds=60))
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
power = hass.states.get("sensor.power_output")
|
||||||
|
assert power is not None
|
||||||
|
assert power.state == "45.7"
|
||||||
|
# sunset
|
||||||
|
with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch(
|
||||||
|
"aurorapy.client.AuroraSerialClient.measure",
|
||||||
|
side_effect=AuroraError("No response after 10 seconds"),
|
||||||
|
):
|
||||||
|
async_fire_time_changed(hass, utcnow + timedelta(seconds=60))
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
power = hass.states.get("sensor.power_output")
|
||||||
|
assert power.state == "unknown" # should this be 'available'?
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sensor_unknown_error(hass):
|
||||||
|
"""Test other comms error is handled correctly."""
|
||||||
|
mock_entry = _mock_config_entry()
|
||||||
|
|
||||||
|
with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch(
|
||||||
|
"aurorapy.client.AuroraSerialClient.measure",
|
||||||
|
side_effect=AuroraError("another error"),
|
||||||
|
):
|
||||||
|
mock_entry.add_to_hass(hass)
|
||||||
|
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
power = hass.states.get("sensor.power_output")
|
||||||
|
assert power is None
|
Loading…
Reference in New Issue