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

16
.github/workflows/hassfest.yaml vendored Normal file
View File

@@ -0,0 +1,16 @@
name: Validate with hassfest
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: "0 0 * * *"
jobs:
validate:
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v3"
- uses: home-assistant/actions/hassfest@master

19
.github/workflows/validate.yaml vendored Normal file
View File

@@ -0,0 +1,19 @@
name: HACS Validation
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: "0 0 * * *"
jobs:
validate:
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v3"
- name: HACS validation
uses: "hacs/action@main"
with:
category: "integration"

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv

5
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,5 @@
{
"python.testing.pytestArgs": ["tests"],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true
}

87
README.md Normal file
View File

@@ -0,0 +1,87 @@
# PV Microinverter Integration for Home Assistant
[![GitHub Release][releases-shield]][releases]
[![GitHub Activity][commits-shield]][commits]
[![License][license-shield]](LICENSE)
[![hacs][hacs-shield]][hacs]
_Home Assistant integration to get data from PV Microinverter systems._
## Overview
This integration allows you to monitor your solar PV microinverter system in Home Assistant. It periodically fetches data from the microinverter API and provides sensor entities for:
- Current power generation
- Today's energy production
- Lifetime energy production
## Installation
### HACS (Recommended)
1. Ensure that [HACS](https://hacs.xyz/) is installed.
2. Go to HACS > Integrations.
3. Click on the "+ Explore & Download Repositories" button.
4. Search for "PV Microinverter".
5. Click on it and select "Download".
6. Restart Home Assistant.
### Manual Installation
1. Copy the `custom_components/pv_microinverter` directory from this repository to the `custom_components` directory in your Home Assistant configuration directory.
2. Restart Home Assistant.
## Configuration
1. Go to Settings > Devices & Services.
2. Click on the "+ Add Integration" button.
3. Search for "PV Microinverter".
4. Follow the configuration steps, providing:
- API Key: Your PV Microinverter API key
- System ID: Your system ID
- Update Interval: How often to refresh data (in seconds, default is 300)
## Usage
After configuration, the integration will create several sensors:
- `sensor.pv_microinverter_current_power`: Shows the current power generation in watts.
- `sensor.pv_microinverter_today_energy`: Shows today's energy production in kilowatt-hours.
- `sensor.pv_microinverter_lifetime_energy`: Shows the lifetime energy production in kilowatt-hours.
These sensors can be used in automations, dashboards, energy monitoring, and more.
## Example Lovelace UI
```yaml
type: entities
entities:
- entity: sensor.pv_microinverter_current_power
- entity: sensor.pv_microinverter_today_energy
- entity: sensor.pv_microinverter_lifetime_energy
title: Solar Production
```
## Troubleshooting
- **No data or errors**: Check your API credentials and system ID.
- **Delayed updates**: Adjust the update interval to refresh more frequently.
- **API rate limiting**: If you experience API rate limiting, increase the update interval.
## Contributing
If you want to contribute to this integration, please read the [Contributing Guidelines](CONTRIBUTING.md).
## License
This integration is licensed under the MIT License.
---
[commits-shield]: https://img.shields.io/github/commit-activity/y/your-github-username/pv_microinverter.svg
[commits]: https://github.com/your-github-username/pv_microinverter/commits/main
[hacs-shield]: https://img.shields.io/badge/HACS-Custom-orange.svg
[hacs]: https://github.com/hacs/integration
[license-shield]: https://img.shields.io/github/license/your-github-username/pv_microinverter.svg
[releases-shield]: https://img.shields.io/github/release/your-github-username/pv_microinverter.svg
[releases]: https://github.com/your-github-username/pv_microinverter/releases

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,
}

5
hacs.json Normal file
View File

@@ -0,0 +1,5 @@
{
"name": "PV Microinverter",
"render_readme": true,
"homeassistant": "2023.8.0"
}

11
manifest.json Normal file
View File

@@ -0,0 +1,11 @@
{
"domain": "pv_microinverter",
"name": "PV Microinverter",
"codeowners": ["@AdrianoKF"],
"config_flow": true,
"documentation": "https://git.rumpold-it.de/adriano/home-assistant-envertech",
"iot_class": "cloud_polling",
"issue_tracker": "https://git.rumpold-it.de/adriano/home-assistant-envertech/issues",
"version": "0.1.0",
"requirements": ["aiohttp>=3.8.4"]
}

51
pyproject.toml Normal file
View File

@@ -0,0 +1,51 @@
[project]
name = "envertech-logger"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [{ name = "Adrian Rumpold", email = "a.rumpold@gmail.com" }]
requires-python = ">=3.13"
dependencies = []
[project.scripts]
envertech-logger = "envertech_logger:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[dependency-groups]
dev = [
"homeassistant>=2025.4.1",
"pytest>=8.3.5",
"pytest-asyncio>=0.26.0",
"ruff>=0.11.4",
]
[tool.hatch.build.targets.wheel]
packages = ["custom_components/pv_microinverter"]
[tool.ruff]
src = ["custom_components"]
preview = true
[tool.ruff.lint]
select = [
"E",
"F",
"I",
"W",
"B",
"UP",
"C4",
"PYI",
"PTH",
"T10", # prevent stray debug breakpoints
]
ignore = [
"E501", # Line too long
"RUF029", # Unused Async (FastAPI routes are async)
]
[tool.pytest.ini_options]
asyncio_default_fixture_loop_scope = "session"

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Tests package for PV Microinverter integration."""

56
tests/conftest.py Normal file
View File

@@ -0,0 +1,56 @@
"""Pytest fixtures for PV Microinverter tests."""
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock
import pytest
from pv_microinverter.api import PVMicroinverterApiClient
from pv_microinverter.const import (
CONF_STATION_ID,
CONF_UPDATE_INTERVAL,
DEFAULT_UPDATE_INTERVAL,
PVMicroinverterData,
)
from pv_microinverter.coordinator import (
PVMicroinverterDataUpdateCoordinator,
)
@pytest.fixture
def mock_api_client():
"""Return a mocked PV Microinverter API client."""
client = MagicMock(spec=PVMicroinverterApiClient)
client.async_get_data = AsyncMock(
return_value=PVMicroinverterData(
current_power=500.0,
today_energy=2.5,
lifetime_energy=150.0,
last_updated=datetime.now().isoformat(),
)
)
client.async_check_connection = AsyncMock(return_value=True)
return client
@pytest.fixture
def mock_config_entry():
"""Return a mock config entry."""
return MagicMock(
data={
CONF_STATION_ID: "test_station_id",
CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL,
},
entry_id="test_entry_id",
)
@pytest.fixture
def mock_coordinator(mock_api_client):
"""Return a mock coordinator."""
coordinator = MagicMock(spec=PVMicroinverterDataUpdateCoordinator)
coordinator.api_client = mock_api_client
coordinator.data = mock_api_client.async_get_data.return_value
coordinator.last_update_success = True
coordinator.async_config_entry_first_refresh = AsyncMock()
return coordinator

206
tests/test_api.py Normal file
View File

@@ -0,0 +1,206 @@
"""Tests for the PV Microinverter API client."""
import json
from unittest.mock import AsyncMock, MagicMock
import aiohttp
import pytest
from aiohttp import ClientResponseError, ClientSession
from custom_components.pv_microinverter.api import (
PVMicroinverterApiClient,
PVMicroinverterApiClientError,
)
@pytest.fixture
def mock_session():
"""Return a mocked aiohttp client session."""
session = MagicMock(spec=ClientSession)
session.get = AsyncMock()
return session
@pytest.fixture
def api_client(mock_session):
"""Return a new API client with a mocked session."""
return PVMicroinverterApiClient(
session=mock_session,
station_id="test_station_id",
base_url="https://api.example.com/v1",
)
@pytest.fixture
def mock_response():
"""Return a mocked API response."""
mock = MagicMock()
mock.raise_for_status = MagicMock()
mock.json = AsyncMock(
return_value={
"current_power": 500.5,
"today_energy": 3.75,
"lifetime_energy": 1250.25,
"last_updated": "2023-04-01T12:00:00Z",
}
)
return mock
@pytest.mark.asyncio
async def test_async_get_data_success(api_client, mock_session, mock_response):
"""Test successful data retrieval."""
# Setup the mock response
mock_session.get.return_value = mock_response
# Call the method
data = await api_client.async_get_data()
# Verify the API call
mock_session.get.assert_called_once_with(
"https://api.example.com/v1/systems/test_system_id/stats",
headers={
"Authorization": "Bearer test_api_key",
"Content-Type": "application/json",
},
)
# Verify the response processing
assert data.current_power == 500.5
assert data.today_energy == 3.75
assert data.lifetime_energy == 1250.25
assert data.last_updated == "2023-04-01T12:00:00Z"
@pytest.mark.asyncio
async def test_async_get_data_http_error(api_client, mock_session):
"""Test handling of HTTP errors."""
# Setup the mock to raise an error
error_response = MagicMock()
error_response.raise_for_status.side_effect = ClientResponseError(
request_info=MagicMock(),
history=None,
status=401,
message="Unauthorized",
headers=None,
)
mock_session.get.return_value = error_response
# Call the method and expect an exception
with pytest.raises(PVMicroinverterApiClientError) as excinfo:
await api_client.async_get_data()
# Verify the error message
assert "Error fetching data from API" in str(excinfo.value)
@pytest.mark.asyncio
async def test_async_get_data_connection_error(api_client, mock_session):
"""Test handling of connection errors."""
# Setup the mock to raise a connection error
mock_session.get.side_effect = aiohttp.ClientConnectionError("Connection refused")
# Call the method and expect an exception
with pytest.raises(PVMicroinverterApiClientError) as excinfo:
await api_client.async_get_data()
# Verify the error message
assert "Error fetching data from API" in str(excinfo.value)
@pytest.mark.asyncio
async def test_async_get_data_invalid_json(api_client, mock_session, mock_response):
"""Test handling of invalid JSON responses."""
# Setup the mock to return invalid JSON
mock_response.json.side_effect = json.JSONDecodeError("Invalid JSON", "", 0)
mock_session.get.return_value = mock_response
# Call the method and expect an exception
with pytest.raises(PVMicroinverterApiClientError) as excinfo:
await api_client.async_get_data()
# Verify the error message
assert "Unexpected error occurred" in str(excinfo.value)
@pytest.mark.asyncio
async def test_async_get_data_missing_fields(api_client, mock_session, mock_response):
"""Test handling of responses with missing fields."""
# Setup the mock to return a response with missing fields
mock_response.json.return_value = {"some_other_field": "value"}
mock_session.get.return_value = mock_response
# Call the method - it should handle missing fields gracefully
data = await api_client.async_get_data()
# Verify default values are used
assert data.current_power == 0
assert data.today_energy == 0
assert data.lifetime_energy == 0
assert data.last_updated is not None # Should default to current time
@pytest.mark.asyncio
async def test_async_check_connection_success(api_client, mock_session):
"""Test successful connection check."""
# Setup the mock response
mock_response = MagicMock()
mock_response.raise_for_status = MagicMock()
mock_session.get.return_value = mock_response
# Call the method
result = await api_client.async_check_connection()
# Verify the API call
mock_session.post.assert_called_once_with(
f"{api_client._base_url}/GetStationInfo",
headers={
"Authorization": "Bearer test_api_key",
"Content-Type": "application/json",
},
)
# Verify the result
assert result is True
@pytest.mark.asyncio
async def test_async_check_connection_failure(api_client, mock_session):
"""Test failed connection check."""
# Setup the mock to raise an error
mock_session.get.side_effect = aiohttp.ClientError("Connection error")
# Call the method
result = await api_client.async_check_connection()
# Verify the result
assert result is False
@pytest.mark.asyncio
async def test_process_data_with_various_types(api_client):
"""Test data processing with various data types."""
# Test with string values that should be converted to float
data_with_strings = {
"current_power": "450.75",
"today_energy": "2.5",
"lifetime_energy": "1000",
"last_updated": "2023-04-01T14:30:00Z",
}
result = api_client._process_data(data_with_strings)
assert result.current_power == 450.75
assert result.today_energy == 2.5
assert result.lifetime_energy == 1000.0
assert result.last_updated == "2023-04-01T14:30:00Z"
# Test with mixed types
data_mixed = {
"current_power": 300,
"today_energy": 1.5,
"lifetime_energy": "750.5",
"last_updated": "2023-04-01T14:30:00Z",
}
result = api_client._process_data(data_mixed)
assert result.current_power == 300.0
assert result.today_energy == 1.5
assert result.lifetime_energy == 750.5
assert result.last_updated == "2023-04-01T14:30:00Z"

72
tests/test_sensor.py Normal file
View File

@@ -0,0 +1,72 @@
"""Tests for the PV Microinverter sensor platform."""
from datetime import datetime
from unittest.mock import MagicMock
import pytest
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
from homeassistant.const import UnitOfEnergy, UnitOfPower
from pv_microinverter.const import PVMicroinverterData
from pv_microinverter.coordinator import (
PVMicroinverterDataUpdateCoordinator,
)
from pv_microinverter.sensor import PVMicroinverterSensor
@pytest.mark.asyncio
async def test_sensor_initialization():
"""Test sensor initialization."""
# Mock data and coordinator
mock_data = PVMicroinverterData(
current_power=500.0,
today_energy=2.5,
lifetime_energy=150.0,
last_updated=datetime.now().isoformat(),
)
mock_coordinator = MagicMock(spec=PVMicroinverterDataUpdateCoordinator)
mock_coordinator.data = mock_data
mock_coordinator.last_update_success = True
# Test current power sensor
current_power_sensor = PVMicroinverterSensor(
coordinator=mock_coordinator,
system_id="test_system",
sensor_type="current_power",
sensor_info={
"name": "Current Power",
"icon": "mdi:solar-power",
"unit": "W",
"device_class": "power",
"state_class": "measurement",
},
)
# Verify sensor properties
assert current_power_sensor.name == "Current Power"
assert current_power_sensor.native_unit_of_measurement == UnitOfPower.WATT
assert current_power_sensor.device_class == SensorDeviceClass.POWER
assert current_power_sensor.state_class == SensorStateClass.MEASUREMENT
assert current_power_sensor.native_value == 500.0
# Test today's energy sensor
today_energy_sensor = PVMicroinverterSensor(
coordinator=mock_coordinator,
system_id="test_system",
sensor_type="today_energy",
sensor_info={
"name": "Today's Energy",
"icon": "mdi:solar-power",
"unit": "kWh",
"device_class": "energy",
"state_class": "total_increasing",
},
)
# Verify sensor properties
assert today_energy_sensor.name == "Today's Energy"
assert today_energy_sensor.native_unit_of_measurement == UnitOfEnergy.KILO_WATT_HOUR
assert today_energy_sensor.device_class == SensorDeviceClass.ENERGY
assert today_energy_sensor.state_class == SensorStateClass.TOTAL_INCREASING
assert today_energy_sensor.native_value == 2.5

42
tests/test_units.py Normal file
View File

@@ -0,0 +1,42 @@
import pytest
from pv_microinverter.units import Dimension, SIUnit
def test_siunit_parse_base_unit():
unit = SIUnit.parse("W")
# Check that parsing base unit returns the registered unit
assert unit.name == "Watt"
assert unit.symbol == "W"
assert unit.factor == 1
def test_siunit_parse_prefixed_unit():
unit = SIUnit.parse("kW")
# The expected unit is created by prefix "k" and the base unit "Watt"
assert unit.name == "kWatt"
assert unit.symbol == "kW"
assert unit.factor == 1000 # 1e3 multiplier
def test_dimension_parse_valid():
# Parsing a valid dimension string should succeed
dim = Dimension.parse("3.5 kW")
assert dim.value == "3.5"
# Check unit attributes from SIUnit.parse
assert dim.unit.name == "kWatt"
assert dim.unit.symbol == "kW"
# Check conversion to base unit: 3.5 * 1000 = 3500.0
assert dim.to_base_unit() == 3500.0
def test_dimension_parse_invalid_format():
# Missing space between value and unit should raise a ValueError
with pytest.raises(ValueError):
Dimension.parse("3.5kW")
def test_siunit_parse_unknown_unit():
# Attempting to parse an unknown unit should raise a ValueError
with pytest.raises(ValueError):
SIUnit.parse("invalid")

1910
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff