Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@

### Added

- **Fronius GEN24 / BYD Battery-Box driver**: adds local Modbus TCP storage control and telemetry, BYD metadata and capacity from the Fronius Solar API, persistent synthetic-energy identity based on the physical BYD serial, configuration/reconfiguration flows, dashboard details and translations. The driver automatically detects Fronius `float` and `int+SF` layouts, shifts all Model 160/124 reads and control writes accordingly, and shows the detected SunSpec model in the battery information box. A persistent device switch keeps external control in a safe `0/0` idle window across setup, reload and shutdown by default; releasing the battery to Fronius is explicit. `max_soc` remains a software control limit rather than a guaranteed hardware cutoff. Thanks to @MisterSpliss for the contribution.
- **Huawei SUN2000 + LUNA2000 driver**: adds native Modbus telemetry and optional service/direct-write control for Huawei hybrid inverters with LUNA2000 storage. Thanks to @sphings79 for the contribution.
- **Off-grid meter mode**: an optional second W/kW power sensor, with its own inverted-sign setting, now exposes a Home Assistant switch and dashboard control that change the active source used by PD control and derived consumption/grid statistics. The switch is software-only and never enables or changes a battery's physical off-grid/EPS port.

Expand Down
112 changes: 98 additions & 14 deletions custom_components/omnibattery/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@
DEFAULT_CAPACITY_PROTECTION_LIMIT,
CONF_MANUAL_MODE_ENABLED,
CONF_BATTERY_MANUAL_MODE_ENABLED,
CONF_FRONIUS_INTERNAL_CONTROL_DISABLED,
CONF_PREDICTIVE_CHARGING_OVERRIDDEN,
CONF_PREDICTIVE_CHARGING_MODE,
CONF_PRICE_SENSOR,
Expand Down Expand Up @@ -548,6 +549,11 @@ def _backup_switch_enabled(value) -> bool:
class ChargeDischargeController:
"""Controller to manage charge/discharge logic for all batteries."""

_FRONIUS_PD_DEADBAND_FLOOR_W = 50
_FRONIUS_PD_MIN_CHARGE_FLOOR_W = 100
_FRONIUS_PD_MIN_DISCHARGE_FLOOR_W = 250
_FRONIUS_PD_MIN_CYCLE_INTERVAL_FLOOR_S = 5.0

def __init__(self, hass: HomeAssistant, coordinators: list[MarstekVenusDataUpdateCoordinator], consumption_sensor: str, config_entry: ConfigEntry):
"""Initialize the controller."""
self.hass = hass
Expand Down Expand Up @@ -600,6 +606,7 @@ def __init__(self, hass: HomeAssistant, coordinators: list[MarstekVenusDataUpdat
# (Marstek) need to track the grid meter's full cadence. Slow-actuator pacing
# belongs per-battery in the power distribution, not in the loop cadence.
self._min_cycle_interval_s = config_entry.data.get(CONF_PD_MIN_CYCLE_INTERVAL, DEFAULT_PD_MIN_CYCLE_INTERVAL)
self._apply_driver_pd_floors()
self._last_cycle_monotonic = 0.0
self._background_tasks: set[asyncio.Task] = set()
self._startup_dynamic_pricing_task: asyncio.Task | None = None
Expand Down Expand Up @@ -2138,6 +2145,42 @@ def _refresh_daily_operation_timeline(
if batching:
end_batch()

def _has_fronius_gen24_battery(self) -> bool:
return any(getattr(coordinator, "brand", None) == "fronius_gen24" for coordinator in self.coordinators)

def _apply_driver_pd_floors(self) -> None:
"""Apply driver-specific PD floors for storage that dislikes sign chatter."""
if not self._has_fronius_gen24_battery():
return

old_values = (
self.deadband,
self.min_charge_power,
self.min_discharge_power,
self._min_cycle_interval_s,
)
self.deadband = max(self.deadband, self._FRONIUS_PD_DEADBAND_FLOOR_W)
self.min_charge_power = max(self.min_charge_power, self._FRONIUS_PD_MIN_CHARGE_FLOOR_W)
self.min_discharge_power = max(self.min_discharge_power, self._FRONIUS_PD_MIN_DISCHARGE_FLOOR_W)
self._min_cycle_interval_s = max(
self._min_cycle_interval_s,
self._FRONIUS_PD_MIN_CYCLE_INTERVAL_FLOOR_S,
)
if (
self.deadband,
self.min_charge_power,
self.min_discharge_power,
self._min_cycle_interval_s,
) != old_values:
_LOGGER.info(
"Fronius GEN24 / BYD detected: applying PD stability floors "
"(deadband=%dW, min_charge=%dW, min_discharge=%dW, min_cycle=%.1fs)",
self.deadband,
self.min_charge_power,
self.min_discharge_power,
self._min_cycle_interval_s,
)

def _configured_system_limit(self, is_charging: bool) -> int:
"""Return the optional system-wide power limit for the direction.

Expand All @@ -2161,7 +2204,7 @@ def _effective_system_capacity(self, batteries: list, is_charging: bool) -> int:
"""Return available capacity after applying the optional global cap."""
batteries = [
coordinator for coordinator in batteries
if not getattr(coordinator, CONF_BATTERY_MANUAL_MODE_ENABLED, False)
if not self._is_battery_manual_owned(coordinator)
]
total_capacity = sum(
self._battery_power_limit(c, is_charging)
Expand All @@ -2174,8 +2217,15 @@ def _effective_system_capacity(self, batteries: list, is_charging: bool) -> int:

@staticmethod
def _is_battery_manual_owned(coordinator) -> bool:
"""Return whether an individual battery is outside automatic control."""
return bool(getattr(coordinator, CONF_BATTERY_MANUAL_MODE_ENABLED, False))
"""Return whether a battery is outside Omnibattery automatic control."""
if bool(getattr(coordinator, CONF_BATTERY_MANUAL_MODE_ENABLED, False)):
return True
return bool(
getattr(coordinator, "brand", None) == "fronius_gen24"
and not getattr(
coordinator, CONF_FRONIUS_INTERNAL_CONTROL_DISABLED, True
)
)

def _get_automatic_batteries(self) -> list:
"""Return the batteries available to automatic planning and control."""
Expand Down Expand Up @@ -2598,6 +2648,7 @@ def update_pd_parameters(self):
self.min_discharge_power = self.config_entry.data.get(CONF_PD_MIN_DISCHARGE_POWER, DEFAULT_PD_MIN_DISCHARGE_POWER)
self._relay_cooldown_s = self.config_entry.data.get(CONF_PD_RELAY_COOLDOWN, DEFAULT_PD_RELAY_COOLDOWN)
self._min_cycle_interval_s = self.config_entry.data.get(CONF_PD_MIN_CYCLE_INTERVAL, DEFAULT_PD_MIN_CYCLE_INTERVAL)
self._apply_driver_pd_floors()
self.target_grid_power = self.config_entry.data.get(CONF_TARGET_GRID_POWER, DEFAULT_TARGET_GRID_POWER)
self.enable_system_power_limits = self.config_entry.data.get(
CONF_ENABLE_SYSTEM_POWER_LIMITS,
Expand Down Expand Up @@ -3672,12 +3723,12 @@ def _get_available_batteries(
if coordinator.data is None:
continue

# Individual manual mode is an ownership boundary, not an
# operation blocker. Exclude it before availability and blocker
# evaluation so planning cannot select or classify it as automatic.
if getattr(coordinator, CONF_BATTERY_MANUAL_MODE_ENABLED, False):
# Manual ownership (including an explicitly released Fronius) is
# an ownership boundary, not an operation blocker. Exclude it
# before planning can classify the battery as automatic.
if self._is_battery_manual_owned(coordinator):
_LOGGER.debug(
"%s: Skipping - individual manual mode owns this battery",
"%s: Skipping - battery is outside automatic ownership",
coordinator.name,
)
continue
Expand Down Expand Up @@ -5892,11 +5943,9 @@ async def _set_battery_power(

Returns True if command was acknowledged, False otherwise.
"""
if owner == "automatic" and getattr(
coordinator, CONF_BATTERY_MANUAL_MODE_ENABLED, False
):
if owner == "automatic" and self._is_battery_manual_owned(coordinator):
_LOGGER.debug(
"[%s] Skipping automatic power write - individual manual mode owns this battery",
"[%s] Skipping automatic power write - battery is outside automatic ownership",
getattr(coordinator, "name", coordinator),
)
return False
Expand Down Expand Up @@ -8897,8 +8946,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
v9 -> v10: rename config entry title to "Omnibattery".
v10 -> v11: add the disabled-by-default three-phase protection schema and
normalize an empty battery phase on existing batteries.
v11 -> v12: retain external idle control for Fronius/BYD during unload;
releasing to the inverter becomes an explicit device setting.
"""
if entry.version >= 11:
if entry.version >= 12:
return True

new_data = dict(entry.data)
Expand Down Expand Up @@ -9141,11 +9192,26 @@ def _fix_home_consumption_uid(entity_entry):
"(three-phase protection disabled; battery phases normalized)",
)

if entry.version < 12:
migrated_batteries = []
for battery in new_data.get("batteries", []):
migrated = dict(battery)
if migrated.get("brand") == "fronius_gen24":
migrated.setdefault(
CONF_FRONIUS_INTERNAL_CONTROL_DISABLED, True
)
migrated_batteries.append(migrated)
new_data["batteries"] = migrated_batteries
_LOGGER.info(
"Omnibattery: migrated config entry to version 12 "
"(Fronius/BYD retains external idle control on unload)",
)

hass.config_entries.async_update_entry(
entry,
title="Omnibattery",
data=new_data,
version=11,
version=12,
)
return True

Expand Down Expand Up @@ -9499,6 +9565,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
battery_manual_mode_enabled=battery_config.get(
CONF_BATTERY_MANUAL_MODE_ENABLED, False
),
fronius_internal_control_disabled=battery_config.get(
CONF_FRONIUS_INTERNAL_CONTROL_DISABLED, True
),
mac=entry_macs[battery_index],
)
# Physical phase is metadata for the safety limiter only. It is never
Expand Down Expand Up @@ -9614,6 +9683,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
max_discharge_power_w=max_discharge_power,
)

# Establish the persisted Fronius ownership boundary before
# the controller can issue automatic setpoints. The safe
# default is external 0/0 idle; explicit release selects auto.
if coordinator.brand == "fronius_gen24":
ownership_ok = (
await coordinator.set_fronius_internal_control_disabled(
coordinator.fronius_internal_control_disabled
)
)
if not ownership_ok:
raise ConfigEntryNotReady(
"Could not establish Fronius/BYD control ownership "
f"for {coordinator.name}"
)

# Manually trigger first refresh and wait for it
await coordinator.async_request_refresh()
# Give a moment for the data to be processed
Expand Down
Loading