Project skeleton
This commit is contained in:
16
.github/workflows/hassfest.yaml
vendored
Normal file
16
.github/workflows/hassfest.yaml
vendored
Normal 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
19
.github/workflows/validate.yaml
vendored
Normal 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
10
.gitignore
vendored
Normal 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
5
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"python.testing.pytestArgs": ["tests"],
|
||||||
|
"python.testing.unittestEnabled": false,
|
||||||
|
"python.testing.pytestEnabled": true
|
||||||
|
}
|
||||||
87
README.md
Normal file
87
README.md
Normal 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
|
||||||
79
custom_components/pv_microinverter/__init__.py
Normal file
79
custom_components/pv_microinverter/__init__.py
Normal 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)
|
||||||
163
custom_components/pv_microinverter/api.py
Normal file
163
custom_components/pv_microinverter/api.py
Normal 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
|
||||||
137
custom_components/pv_microinverter/config_flow.py
Normal file
137
custom_components/pv_microinverter/config_flow.py
Normal 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."""
|
||||||
52
custom_components/pv_microinverter/const.py
Normal file
52
custom_components/pv_microinverter/const.py
Normal 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
|
||||||
54
custom_components/pv_microinverter/coordinator.py
Normal file
54
custom_components/pv_microinverter/coordinator.py
Normal 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
|
||||||
42
custom_components/pv_microinverter/entity.py
Normal file
42
custom_components/pv_microinverter/entity.py
Normal 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
|
||||||
123
custom_components/pv_microinverter/sensor.py
Normal file
123
custom_components/pv_microinverter/sensor.py
Normal 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,
|
||||||
|
}
|
||||||
47
custom_components/pv_microinverter/translations/en.json
Normal file
47
custom_components/pv_microinverter/translations/en.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
114
custom_components/pv_microinverter/units.py
Normal file
114
custom_components/pv_microinverter/units.py
Normal 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
5
hacs.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "PV Microinverter",
|
||||||
|
"render_readme": true,
|
||||||
|
"homeassistant": "2023.8.0"
|
||||||
|
}
|
||||||
11
manifest.json
Normal file
11
manifest.json
Normal 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
51
pyproject.toml
Normal 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
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Tests package for PV Microinverter integration."""
|
||||||
56
tests/conftest.py
Normal file
56
tests/conftest.py
Normal 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
206
tests/test_api.py
Normal 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
72
tests/test_sensor.py
Normal 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
42
tests/test_units.py
Normal 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")
|
||||||
Reference in New Issue
Block a user