Project skeleton
Some checks failed
HACS Validation / validate (push) Failing after 38s
Validate with hassfest / validate (push) Failing after 41s

This commit is contained in:
Adrian Rumpold
2025-04-07 10:17:40 +02:00
commit 5180992e98
23 changed files with 3302 additions and 0 deletions

View File

@@ -0,0 +1,79 @@
"""The PV Microinverter integration."""
from __future__ import annotations
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .api import PVMicroinverterApiClient
from .api import PVMicroinverterApiClientError as PVMicroinverterApiClientError
from .const import (
CONF_STATION_ID,
CONF_UPDATE_INTERVAL,
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
)
from .coordinator import PVMicroinverterDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
# List of platforms to support
PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up PV Microinverter from a config entry."""
hass.data.setdefault(DOMAIN, {})
# Get configuration from the config entry
station_id = entry.data[CONF_STATION_ID]
update_interval = entry.data.get(CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL)
# Create API client
session = async_get_clientsession(hass)
api_client = PVMicroinverterApiClient(
session=session,
station_id=station_id,
)
# Initialize coordinator
coordinator = PVMicroinverterDataUpdateCoordinator(
hass=hass,
api_client=api_client,
update_interval=update_interval,
)
# Fetch initial data
try:
await coordinator.async_config_entry_first_refresh()
except ConfigEntryNotReady as error:
raise ConfigEntryNotReady(f"Failed to load initial data: {error}") from error
# Store coordinator in hass.data
hass.data[DOMAIN][entry.entry_id] = coordinator
# Set up all platforms
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# Add update listener for config entry changes
entry.async_on_unload(entry.add_update_listener(async_update_options))
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Update options."""
await hass.config_entries.async_reload(entry.entry_id)

View File

@@ -0,0 +1,163 @@
"""API client for PV Microinverter."""
import logging
from dataclasses import dataclass
from datetime import datetime
from enum import StrEnum
from typing import Any
import aiohttp
from pv_microinverter.units import WATT, Dimension
from .const import PVMicroinverterData
_LOGGER = logging.getLogger(__name__)
class ApiEndpoints(StrEnum):
GET_STATION_INFO = "GetStationInfo"
@dataclass
class StationInfoData:
UnitCapacity: str
UnitEToday: str
UnitEMonth: str
UnitEYear: str
UnitETotal: str
Power: float
PowerStr: str
Capacity: float
LoadPower: str
GridPower: str
StrCO2: str
StrTrees: str
StrIncome: str
PwImg: str
StationName: str
InvModel1: str
InvModel2: str | None
Lat: str
Lng: str
TimeZone: str
StrPeakPower: str
Installer: str | None
CreateTime: datetime
CreateYear: int
CreateMonth: int
Etoday: float
InvTotal: int
@dataclass
class StationInfoResponse:
Status: int
Result: Any
Data: StationInfoData
class PVMicroinverterApiClientError(Exception):
"""Exception to indicate an error with the API client."""
class PVMicroinverterApiClient:
"""API client for PV Microinverter."""
def __init__(
self,
session: aiohttp.ClientSession,
station_id: str,
base_url: str = "https://www.envertecportal.com/ApiStations",
) -> None:
"""Initialize the Envertech API client.
Args:
session: The aiohttp client session
station_id: The station identifier
base_url: The base URL for the API
"""
self._session = session
self._station_id = station_id
self._base_url = base_url
async def async_get_data(self) -> PVMicroinverterData:
"""Get data from the API.
Returns:
PVMicroinverterData: The data from the API
Raises:
PVMicroinverterApiClientError: If the API request fails
"""
try:
# Make the request to the API
response = await self._session.post(
f"{self._base_url}/{ApiEndpoints.GET_STATION_INFO}",
json={"stationId": self._station_id},
headers={
"Content-Type": "application/json",
},
)
response.raise_for_status()
data = await response.json()
# Process the response
return self._process_data(data)
except aiohttp.ClientError as error:
_LOGGER.error("Error fetching data: %s", error)
raise PVMicroinverterApiClientError(
"Error fetching data from API"
) from error
except Exception as error:
_LOGGER.exception("Unexpected error: %s", error)
raise PVMicroinverterApiClientError("Unexpected error occurred") from error
def _process_data(self, data: dict[str, Any]) -> PVMicroinverterData:
"""Process the API response data.
Args:
data: The data from the API
Returns:
PVMicroinverterData: The processed data
"""
station_data = StationInfoData(**data.get("Data", {}))
station_info = StationInfoResponse(
Status=data.get("Status"),
Result=data.get("Result"),
Data=station_data,
)
if station_info.Status != "0":
raise PVMicroinverterApiClientError(f"API error: {station_info.Result}")
return PVMicroinverterData(
current_power=Dimension(station_data.Power, WATT),
today_energy=Dimension.parse(station_data.UnitEToday).to_base_unit(),
lifetime_energy=Dimension.parse(station_data.UnitETotal).to_base_unit(),
last_updated=datetime.now().isoformat(),
)
async def async_check_connection(self) -> bool:
"""Test the API connection to verify credentials.
Returns:
bool: True if connection is successful, False otherwise
"""
try:
response = await self._session.post(
f"{self._base_url}/{ApiEndpoints.GET_STATION_INFO}",
headers={
"Content-Type": "application/json",
},
json={"stationId": self._station_id},
)
response.raise_for_status()
return True
except Exception as error:
_LOGGER.error("Connection test failed: %s", error)
return False

View File

@@ -0,0 +1,137 @@
"""Config flow for PV Microinverter integration."""
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .api import PVMicroinverterApiClient
from .const import (
CONF_STATION_ID,
CONF_UPDATE_INTERVAL,
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema({
vol.Required(CONF_STATION_ID): str,
vol.Optional(CONF_UPDATE_INTERVAL, default=DEFAULT_UPDATE_INTERVAL): int,
})
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
"""Validate the user input allows us to connect to the API.
Args:
hass: The Home Assistant instance
data: The user input
Returns:
Dict[str, Any]: The validated data
Raises:
CannotConnect: If the API connection cannot be established
InvalidAuth: If the API key is invalid
"""
session = async_get_clientsession(hass)
api_client = PVMicroinverterApiClient(
session=session,
station_id=data[CONF_STATION_ID],
)
# Test connection and authentication
connection_successful = await api_client.async_check_connection()
if not connection_successful:
raise CannotConnect
# Return validated data
return {
CONF_STATION_ID: data[CONF_STATION_ID],
CONF_UPDATE_INTERVAL: data[CONF_UPDATE_INTERVAL],
}
class PVMicroinverterConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for PV Microinverter."""
VERSION = 1
async def async_step_user(self, user_input: dict[str, Any] = None) -> FlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
try:
info = await validate_input(self.hass, user_input)
# Check if we already have an entry for this system_id
await self.async_set_unique_id(user_input[CONF_STATION_ID])
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"PV Microinverter {user_input[CONF_STATION_ID]}",
data=info,
)
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=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_reauth(self, user_input: dict[str, Any] = None) -> FlowResult:
"""Handle re-authentication."""
errors: dict[str, str] = {}
if user_input is not None:
try:
info = await validate_input(self.hass, user_input)
# Get existing entry
existing_entry = await self.async_set_unique_id(
user_input[CONF_STATION_ID]
)
if existing_entry:
self.hass.config_entries.async_update_entry(
existing_entry, data=info
)
await self.hass.config_entries.async_reload(existing_entry.entry_id)
return self.async_abort(reason="reauth_successful")
return self.async_abort(reason="reauth_failed_existing_entry_not_found")
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="reauth", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""
class InvalidAuth(HomeAssistantError):
"""Error to indicate there is invalid auth."""

View File

@@ -0,0 +1,52 @@
"""Constants for the PV Microinverter integration."""
from dataclasses import dataclass
from typing import Final
DOMAIN: Final = "pv_microinverter"
MANUFACTURER: Final = "Envertech"
# Config flow
CONF_STATION_ID: Final = "station_id"
CONF_UPDATE_INTERVAL: Final = "update_interval"
# Default values
DEFAULT_UPDATE_INTERVAL: Final = 60 # 1 minute
# Entity attributes
ATTR_LAST_UPDATED: Final = "last_updated"
# Sensors
SENSOR_TYPES: Final = {
"current_power": {
"name": "Current Power",
"icon": "mdi:solar-power",
"unit": "W",
"device_class": "power",
"state_class": "measurement",
},
"today_energy": {
"name": "Today's Energy",
"icon": "mdi:solar-power",
"unit": "kWh",
"device_class": "energy",
"state_class": "total_increasing",
},
"lifetime_energy": {
"name": "Lifetime Energy",
"icon": "mdi:solar-power",
"unit": "kWh",
"device_class": "energy",
"state_class": "total_increasing",
},
}
@dataclass
class PVMicroinverterData:
"""Class to hold PV microinverter data."""
current_power: float
today_energy: float
lifetime_energy: float
last_updated: str

View File

@@ -0,0 +1,54 @@
"""Data update coordinator for PV Microinverter integration."""
import logging
from datetime import timedelta
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import (
DataUpdateCoordinator,
UpdateFailed,
)
from .api import PVMicroinverterApiClient, PVMicroinverterApiClientError
from .const import DOMAIN, PVMicroinverterData
_LOGGER = logging.getLogger(__name__)
class PVMicroinverterDataUpdateCoordinator(DataUpdateCoordinator[PVMicroinverterData]):
"""Class to manage fetching PV Microinverter data."""
def __init__(
self,
hass: HomeAssistant,
api_client: PVMicroinverterApiClient,
update_interval: int,
) -> None:
"""Initialize the coordinator.
Args:
hass: The Home Assistant instance
api_client: The API client
update_interval: The update interval in seconds
"""
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=timedelta(seconds=update_interval),
)
self.api_client = api_client
async def _async_update_data(self) -> PVMicroinverterData:
"""Fetch data from the API.
Returns:
PVMicroinverterData: The fetched data
Raises:
UpdateFailed: If the update fails
"""
try:
return await self.api_client.async_get_data()
except PVMicroinverterApiClientError as error:
raise UpdateFailed(f"Error communicating with API: {error}") from error

View File

@@ -0,0 +1,42 @@
"""Base entity for PV Microinverter integration."""
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
from .coordinator import PVMicroinverterDataUpdateCoordinator
class PVMicroinverterEntity(CoordinatorEntity[PVMicroinverterDataUpdateCoordinator]):
"""Base entity for PV Microinverter integration."""
def __init__(
self,
coordinator: PVMicroinverterDataUpdateCoordinator,
station_id: str,
sensor_type: str,
) -> None:
"""Initialize the entity.
Args:
coordinator: The data update coordinator
system_id: The system identifier
sensor_type: The sensor type
"""
super().__init__(coordinator)
self._station_id = station_id
self._sensor_type = sensor_type
self._attr_unique_id = f"{station_id}_{sensor_type}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, station_id)},
name=f"PV Microinverter {station_id}",
manufacturer=MANUFACTURER,
model="Microinverter",
entry_type=DeviceEntryType.SERVICE,
)
@property
def available(self) -> bool:
"""Return if entity is available."""
return self.coordinator.last_update_success and super().available

View File

@@ -0,0 +1,123 @@
"""Sensor platform for PV Microinverter integration."""
from __future__ import annotations
import logging
from typing import Any, Final
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfEnergy, UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import ATTR_LAST_UPDATED, DOMAIN, SENSOR_TYPES
from .coordinator import PVMicroinverterDataUpdateCoordinator
from .entity import PVMicroinverterEntity
_LOGGER = logging.getLogger(__name__)
# Map to convert API units to HA units
UNIT_MAP: Final = {
"W": UnitOfPower.WATT,
"kWh": UnitOfEnergy.KILO_WATT_HOUR,
}
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up PV Microinverter sensors based on a config entry."""
coordinator: PVMicroinverterDataUpdateCoordinator = hass.data[DOMAIN][
entry.entry_id
]
system_id = entry.data["system_id"]
entities = []
# Create a sensor entity for each sensor type
for sensor_key, sensor_info in SENSOR_TYPES.items():
entities.append(
PVMicroinverterSensor(
coordinator=coordinator,
system_id=system_id,
sensor_type=sensor_key,
sensor_info=sensor_info,
)
)
async_add_entities(entities, True)
class PVMicroinverterSensor(PVMicroinverterEntity, SensorEntity):
"""Representation of a PV Microinverter sensor."""
def __init__(
self,
coordinator: PVMicroinverterDataUpdateCoordinator,
system_id: str,
sensor_type: str,
sensor_info: dict[str, Any],
) -> None:
"""Initialize the sensor.
Args:
coordinator: The data update coordinator
system_id: The system identifier
sensor_type: The sensor type
sensor_info: The sensor information
"""
super().__init__(coordinator, system_id, sensor_type)
self._attr_name = sensor_info["name"]
self._attr_icon = sensor_info["icon"]
# Convert API units to HA units
unit = sensor_info["unit"]
self._attr_native_unit_of_measurement = UNIT_MAP.get(unit, unit)
# Set device class if available
device_class = sensor_info.get("device_class")
if device_class:
if device_class == "power":
self._attr_device_class = SensorDeviceClass.POWER
elif device_class == "energy":
self._attr_device_class = SensorDeviceClass.ENERGY
# Set state class if available
state_class = sensor_info.get("state_class")
if state_class:
if state_class == "measurement":
self._attr_state_class = SensorStateClass.MEASUREMENT
elif state_class == "total_increasing":
self._attr_state_class = SensorStateClass.TOTAL_INCREASING
@property
def native_value(self) -> float:
"""Return the state of the sensor."""
data = self.coordinator.data
if not data:
return None
if self._sensor_type == "current_power":
return data.current_power
elif self._sensor_type == "today_energy":
return data.today_energy
elif self._sensor_type == "lifetime_energy":
return data.lifetime_energy
return None
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the state attributes of the sensor."""
return {
ATTR_LAST_UPDATED: self.coordinator.data.last_updated
if self.coordinator.data
else None,
}

View File

@@ -0,0 +1,47 @@
{
"config": {
"step": {
"user": {
"title": "Connect to PV Microinverter",
"description": "Enter your PV Microinverter API credentials.",
"data": {
"api_key": "API Key",
"system_id": "System ID",
"update_interval": "Update interval (seconds)"
}
},
"reauth": {
"title": "Reauthenticate with PV Microinverter",
"description": "The PV Microinverter integration needs to re-authenticate your account.",
"data": {
"api_key": "API Key",
"system_id": "System ID",
"update_interval": "Update interval (seconds)"
}
}
},
"error": {
"cannot_connect": "Failed to connect, please try again",
"invalid_auth": "Invalid authentication",
"unknown": "Unexpected error"
},
"abort": {
"already_configured": "This system is already configured",
"reauth_successful": "Re-authentication was successful",
"reauth_failed_existing_entry_not_found": "Could not find existing config entry to re-authenticate"
}
},
"entity": {
"sensor": {
"current_power": {
"name": "Current Power"
},
"today_energy": {
"name": "Today's Energy"
},
"lifetime_energy": {
"name": "Lifetime Energy"
}
}
}
}

View File

@@ -0,0 +1,114 @@
from typing import Self
# SI prefix multipliers
SI_PREFIXES = {
"Y": 1e24,
"Z": 1e21,
"E": 1e18,
"P": 1e15,
"T": 1e12,
"G": 1e9,
"M": 1e6,
"k": 1e3,
"h": 1e2,
"da": 1e1,
"d": 1e-1,
"c": 1e-2,
"m": 1e-3,
"µ": 1e-6,
"u": 1e-6,
"n": 1e-9,
"p": 1e-12,
"f": 1e-15,
"a": 1e-18,
"z": 1e-21,
"y": 1e-24,
}
def _get_unit_symbol(unit_val: str) -> str:
"""Extract the unit symbol from a unit value string."""
# Assuming the unit value is in the format "value unit"
return unit_val.strip().split(" ")[-1]
class SIUnit:
"""SI Units with automatic unit conversion."""
def __init__(self, name: str, quantity: str, symbol: str, factor: float):
self.name = name
self.quantity = quantity
self.symbol = symbol
self.factor = factor
def __str__(self):
return f"{self.name} ({self.symbol})"
def __repr__(self):
return f"SIUnit(name={self.name}, symbol={self.symbol}, factor={self.factor})"
@classmethod
def parse(cls, unit_str: str) -> Self:
"""
Return an SIUnit by parsing the given unit string, supporting SI prefixes.
"""
# If the unit is registered directly, return it.
if unit_str in BASE_UNITS:
return BASE_UNITS[unit_str]
# Otherwise, check for a valid prefix.
for prefix in sorted(SI_PREFIXES, key=len, reverse=True):
if unit_str.startswith(prefix):
base_symbol = unit_str[len(prefix) :]
if base_symbol in BASE_UNITS:
base_unit = BASE_UNITS[base_symbol]
multiplier = SI_PREFIXES[prefix]
return SIUnit(
f"{prefix}{base_unit.name}",
base_unit.quantity,
f"{prefix}{base_unit.symbol}",
base_unit.factor * multiplier,
)
raise ValueError(f"Unknown unit: {unit_str}")
class Dimension:
"""Class to represent a dimension with a unit."""
def __init__(self, value: float, unit: SIUnit):
self.value = value
self.unit = unit
def __str__(self):
return f"{self.value} {self.unit.symbol}"
def to_base_unit(self) -> float:
"""Convert the dimension value to its base unit."""
return float(self.value) * self.unit.factor
def __repr__(self):
return f"Dimension(value={self.value}, unit={self.unit})"
@classmethod
def parse(cls, unit_val: str) -> Self:
"""
Parse a unit value string into a Dimension object, supporting SI unit prefixes.
Expects the format "value unit", e.g. "3.5 kW" or "10 Wh".
"""
parts = unit_val.strip().split()
if len(parts) != 2:
raise ValueError(f"Invalid unit value format: {unit_val}")
value, unit_str = parts
unit = SIUnit.parse(unit_str)
return cls(float(value), unit)
# Define SI units
WATT = SIUnit("Watt", "Power", "W", 1)
WATT_HOUR = SIUnit("Watt-hour", "Energy", "Wh", 1)
# Mapping of base unit symbols to registered SIUnit objects
BASE_UNITS = {
"W": WATT,
"Wh": WATT_HOUR,
}