Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion homeassistant/components/fronius/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,10 @@ async def _init_modbus_inverter(self, inverter_info: FroniusDeviceInfo) -> None:
"""Set up a Modbus coordinator for an inverter exposing SunSpec MPPT data."""
if inverter_info.solar_net_id in [
coordinator.inverter_info.solar_net_id
for coordinator in self.modbus_inverter_coordinators
for coordinator in (
*self.modbus_inverter_coordinators,
*self.modbus_settings_coordinators,
)
Comment thread
farmio marked this conversation as resolved.
Outdated
]:
return
if (unit_id := self._modbus_unit_id(inverter_info.solar_net_id)) is None:
Expand Down Expand Up @@ -398,6 +401,7 @@ async def _init_modbus_inverter(self, inverter_info: FroniusDeviceInfo) -> None:
)
if await self._start_modbus_coordinator(settings):
self.modbus_settings_coordinators.append(settings)
await settings.async_start_heartbeat()

_LOGGER.debug(
"Modbus enabled for inverter %s (UID: %s, unit ID: %s)",
Expand Down
43 changes: 40 additions & 3 deletions homeassistant/components/fronius/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,25 @@
from pyfronius import Fronius, FroniusError
import voluptuous as vol

from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import (
ConfigFlow,
ConfigFlowResult,
OptionsFlowWithReload,
)
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo

from .const import CONF_MODBUS_PORT, DEFAULT_MODBUS_PORT, DOMAIN, FroniusConfigEntryData
from . import FroniusConfigEntry
from .const import (
CONF_AUTO_REVERT,
CONF_MODBUS_PORT,
DEFAULT_MODBUS_PORT,
DOMAIN,
FroniusConfigEntryData,
)

_LOGGER: Final = logging.getLogger(__name__)

Expand Down Expand Up @@ -74,6 +85,13 @@ def __init__(self) -> None:
"""Initialize flow."""
self.info: FroniusConfigEntryData

@staticmethod
@callback
@override
def async_get_options_flow(config_entry: FroniusConfigEntry) -> FroniusOptionsFlow:
"""Get the options flow for this handler."""
return FroniusOptionsFlow()

@override
async def async_step_user(
self, user_input: dict[str, Any] | None = None
Expand Down Expand Up @@ -195,5 +213,24 @@ async def async_step_reconfigure(
)


class FroniusOptionsFlow(OptionsFlowWithReload):
Comment thread
farmio marked this conversation as resolved.
Outdated
"""Handle the Fronius options."""

async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage how the inverter handles a setpoint Home Assistant left."""
if user_input is not None:
return self.async_create_entry(data=user_input)

auto_revert = self.config_entry.options.get(CONF_AUTO_REVERT, False)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{vol.Required(CONF_AUTO_REVERT, default=auto_revert): bool}
),
)


class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""
7 changes: 7 additions & 0 deletions homeassistant/components/fronius/const.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Constants for the Fronius integration."""

from datetime import timedelta
from enum import StrEnum
from typing import Final, NamedTuple, TypedDict

Expand All @@ -11,6 +12,12 @@
CONF_MODBUS_PORT: Final = "modbus_port"
DEFAULT_MODBUS_PORT: Final = 502

CONF_AUTO_REVERT: Final = "auto_revert"
# how long the device holds a setpoint after it last received it
AUTO_REVERT_SECONDS: Final = 3600
# the setpoint is sent again this often, so a restart has room to spare
HEARTBEAT_INTERVAL: Final = timedelta(minutes=15)

type SolarNetId = str
SOLAR_NET_DISCOVERY_NEW: Final = "fronius_discovery_new"
SOLAR_NET_ID_POWER_FLOW: SolarNetId = "power_flow"
Expand Down
66 changes: 65 additions & 1 deletion homeassistant/components/fronius/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@

from abc import ABC, abstractmethod
from collections.abc import Mapping, Sequence
from datetime import timedelta
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any, cast, override

from fronius_modbus import (
Controls,
FroniusModbusInverter,
Mppt,
SunSpecError,
Expand All @@ -18,11 +19,15 @@
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed

from .binary_sensor import POWER_FLOW_BINARY_SENSOR_DESCRIPTIONS
from .const import (
AUTO_REVERT_SECONDS,
CONF_AUTO_REVERT,
DOMAIN,
HEARTBEAT_INTERVAL,
SOLAR_NET_ID_POWER_FLOW,
SOLAR_NET_ID_SYSTEM,
FroniusDeviceInfo,
Expand Down Expand Up @@ -286,6 +291,56 @@ class FroniusModbusSettingsUpdateCoordinator(FroniusModbusCoordinatorBase):
Platform.SWITCH: MODBUS_SWITCH_ENTITY_DESCRIPTIONS,
}

@property
def revert_seconds(self) -> int:
"""Return the fallback period to give the device, 0 for none."""
if self.config_entry.options.get(CONF_AUTO_REVERT, False):
return AUTO_REVERT_SECONDS
return 0

async def async_start_heartbeat(self) -> None:
"""Keep an active power limit alive against the device's fallback.

The device holds a power limit only for as long as it keeps hearing
it, so it is sent again well before the period is up. Sending it once
here also lets the option take effect on a limit that is already
running - clearing the period on the device when it is turned off.
"""
await self._async_resend_power_limit()
if not self.revert_seconds:
return
Comment thread
farmio marked this conversation as resolved.
self.config_entry.async_on_unload(
async_track_time_interval(
self.hass, self._async_resend_power_limit, HEARTBEAT_INTERVAL
)
)

async def _async_resend_power_limit(self, _now: datetime | None = None) -> None:
"""Send an active power limit again to restart the fallback period.

Fronius documents the period as restarted by every received Modbus
message, but a Gen24 dropped the limit on time while being read every
five seconds - only writing the limit again restarts it.

Nothing to do while no limit is in force: without one there is no
period running that could be restarted or cleared.
"""
controls = self.modbus_inverter.controls
if (
controls is None
or not controls.enabled
or (limit := controls.power_limit) is None
):
return
try:
await controls.set_power_limit(limit, revert_seconds=self.revert_seconds)
Comment thread
farmio marked this conversation as resolved.
Outdated
except (ModbusError, SunSpecError) as err:
self.logger.warning(
"Could not send the AC power limit to inverter %s again: %s",
self.inverter_info.solar_net_id,
err,
)
Comment thread
farmio marked this conversation as resolved.

@override
async def _refresh_components(self) -> None:
"""Refresh the models carrying the writable settings."""
Expand All @@ -310,6 +365,10 @@ async def async_write(
register map before anything is written - the register addresses
move when the data type setting is changed on the device.

A device holding a different fallback period than the configured one
is corrected first, so an output power limit the user sets reverts on
the schedule they chose.

``enable_field`` names the register that puts a setpoint into effect.
It is written again after a change, because the device only picks up a
change to an active mode when the mode is enabled again - but only
Expand All @@ -325,6 +384,11 @@ async def async_write(
)
try:
await component.async_update()
if (
isinstance(component, Controls)
and component.revert_seconds != self.revert_seconds
):
await component.write("revert_seconds", self.revert_seconds)
await component.write(field, value)
if enable_field is not None and getattr(component, enable_field):
await component.write(enable_field, True)
Expand Down
19 changes: 16 additions & 3 deletions homeassistant/components/fronius/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@
"name": "Grid export tariff"
},
"co2_factor": {
"name": "CO\u2082 factor"
"name": "CO factor"
},
"current_ac": {
"name": "AC current"
Expand Down Expand Up @@ -176,8 +176,8 @@
"hardware_id_problem": "Hardware ID problem",
"hid_range_error": "HID range error",
"initialisation_error_file_system_error_on_usb": "Initialization error in file system on USB flash drive",
"initialisation_error_usb_flash_drive_not_supported": "Initialization error \u2013 USB flash drive is not supported",
"initialisation_error_usb_stick_over_current": "Initialization error \u2013 Overcurrent on USB stick",
"initialisation_error_usb_flash_drive_not_supported": "Initialization error USB flash drive is not supported",
"initialisation_error_usb_stick_over_current": "Initialization error Overcurrent on USB stick",
"insulation_error_on_solar_panels": "Insulation error on the solar panels",
"insulation_fault": "Insulation fault",
"insulation_measurement_triggered": "Insulation measurement triggered",
Expand Down Expand Up @@ -458,5 +458,18 @@
"update_failed": {
"message": "An error occurred while attempting to fetch data: {fronius_error}"
}
},
"options": {
"step": {
"init": {
"data": {
"auto_revert": "Revert the AC power limit if Home Assistant stops"
},
"data_description": {
"auto_revert": "The inverter drops the AC power limit an hour after it last received it, and returns to its own settings. Home Assistant sends the limit again every 15 minutes, so this only takes effect once it stops sending - because Home Assistant is down, or because the integration was removed. Battery setpoints are not affected - the inverter supports no timeout for them."
},
"description": "These settings apply to controlling the inverter over Modbus."
}
}
}
}
2 changes: 2 additions & 0 deletions tests/components/fronius/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ async def setup_fronius_integration(
is_logger: bool = True,
unique_id: str = MOCK_UID,
modbus_port: int | None = None,
options: dict[str, Any] | None = None,
) -> ConfigEntry:
"""Create the Fronius integration.

Expand All @@ -37,6 +38,7 @@ async def setup_fronius_integration(
"is_logger": is_logger,
**({"modbus_port": modbus_port} if modbus_port is not None else {}),
},
options=options or {},
minor_version=1 if modbus_port is None else 2,
)
entry.add_to_hass(hass)
Expand Down
24 changes: 23 additions & 1 deletion tests/components/fronius/test_config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import pytest

from homeassistant import config_entries
from homeassistant.components.fronius.const import DOMAIN
from homeassistant.components.fronius.const import CONF_AUTO_REVERT, DOMAIN
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
Expand Down Expand Up @@ -467,3 +467,25 @@ async def test_reconfigure_to_different_device(hass: HomeAssistant) -> None:
await assert_abort_flow_with_logger(
hass, result["flow_id"], reason="unique_id_mismatch"
)


async def test_options_flow(hass: HomeAssistant) -> None:
"""Test turning on the fallback for setpoints."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id="123.4567890",
data={CONF_HOST: "10.1.2.3", "is_logger": True},
)
entry.add_to_hass(hass)

result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init"

result = await hass.config_entries.options.async_configure(
result["flow_id"], {CONF_AUTO_REVERT: True}
)
await hass.async_block_till_done()

assert result["type"] is FlowResultType.CREATE_ENTRY
assert entry.options == {CONF_AUTO_REVERT: True}
Loading
Loading