From e7e49397396d2dde36d8dba51db3b0610215e2c5 Mon Sep 17 00:00:00 2001 From: Misterspliss Date: Fri, 28 Aug 2026 09:40:00 +0200 Subject: [PATCH 1/4] feat: add Fronius GEN24 BYD driver --- CHANGELOG.md | 1 + custom_components/omnibattery/__init__.py | 43 + custom_components/omnibattery/config_flow.py | 325 +++++- .../omnibattery/drivers/fronius_gen24.py | 951 ++++++++++++++++++ .../omnibattery/frontend/marstek-panel.js | 18 +- .../omnibattery/infra/coordinator.py | 24 +- custom_components/omnibattery/sensor.py | 15 +- custom_components/omnibattery/strings.json | 124 ++- custom_components/omnibattery/switch.py | 2 +- .../omnibattery/translations/de.json | 141 ++- .../omnibattery/translations/en.json | 124 ++- mkdocs.yml | 2 + .../batteries/fronius-gen24.es.md | 50 + .../configuration/batteries/fronius-gen24.md | 48 + site-docs/configuration/batteries/index.es.md | 1 + site-docs/configuration/batteries/index.md | 1 + tests/test_fronius_gen24_driver.py | 521 ++++++++++ 17 files changed, 2323 insertions(+), 68 deletions(-) create mode 100644 custom_components/omnibattery/drivers/fronius_gen24.py create mode 100644 site-docs/configuration/batteries/fronius-gen24.es.md create mode 100644 site-docs/configuration/batteries/fronius-gen24.md create mode 100644 tests/test_fronius_gen24_driver.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d77444a..b3395979 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 supported SunSpec representation is integer plus scale factor (`int+SF`); `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. diff --git a/custom_components/omnibattery/__init__.py b/custom_components/omnibattery/__init__.py index d222155d..6256cb50 100644 --- a/custom_components/omnibattery/__init__.py +++ b/custom_components/omnibattery/__init__.py @@ -548,6 +548,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 @@ -600,6 +605,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 @@ -2138,6 +2144,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. @@ -2598,6 +2640,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, diff --git a/custom_components/omnibattery/config_flow.py b/custom_components/omnibattery/config_flow.py index b82c086f..11f77732 100644 --- a/custom_components/omnibattery/config_flow.py +++ b/custom_components/omnibattery/config_flow.py @@ -174,6 +174,11 @@ hoymiles_capacity_kwh, hoymiles_model_profile, ) +from .drivers.fronius_gen24 import ( + FRONIUS_GEN24_DEFAULT_CAPACITY_KWH, + FRONIUS_GEN24_DEFAULT_MAX_POWER_W, + FroniusGen24Driver, +) from .pricing.nordpool import is_official_nordpool_sensor _ANKER_MAX_POWER_W = 3500 @@ -181,6 +186,7 @@ _SESSY_MAX_DISCHARGE_POWER_W = 1700 _SESSY_DEFAULT_MIN_SOC = 5 _HOYMILES_MODEL_AUTO = "auto" +_FRONIUS_GEN24_MAX_POWER_W = FRONIUS_GEN24_DEFAULT_MAX_POWER_W def _hoymiles_model_selector(default: str = _HOYMILES_MODEL_AUTO): @@ -446,12 +452,15 @@ def _soc_selector_limits(brand: str) -> tuple[int, int, int, int, int, int]: # The inverter keeps its own discharge cutoff as a backstop; this window # is what Omnibattery enforces on top of it. min_lo, min_hi, min_default = 0, 30, 10 + elif brand == "fronius_gen24": + min_lo, min_hi, min_default = 5, 50, 20 else: min_lo, min_hi, min_default = 12, 30, 12 # Omnibattery enforces the charge ceiling in software. Sessy's reported SOC # spans 0–100 %, so the standard 100 % ceiling is valid for this driver. - return min_lo, min_hi, min_default, 80, 100, 100 + soc_max_default = 95 if brand == "fronius_gen24" else 100 + return min_lo, min_hi, min_default, 80, 100, soc_max_default def _hoymiles_apply_probe_caps( @@ -621,6 +630,26 @@ def _anker_power_ceilings(battery_data: dict) -> tuple[int, int]: ) +def _fronius_apply_probe_caps(battery_data: dict, caps: dict) -> None: + """Store Fronius GEN24/BYD hardware ceilings from probe for config seeding.""" + for src, dst in ( + ("device_max_charge_power", "device_max_charge_power"), + ("device_max_discharge_power", "device_max_discharge_power"), + ): + if src in caps: + battery_data[dst] = int(caps[src]) + + +def _fronius_power_ceilings(battery_data: dict) -> tuple[int, int]: + """Hardware max charge/discharge from probe, falling back to the static envelope.""" + charge = int(battery_data.get("device_max_charge_power") or _FRONIUS_GEN24_MAX_POWER_W) + discharge = int(battery_data.get("device_max_discharge_power") or _FRONIUS_GEN24_MAX_POWER_W) + return ( + max(100, min(_FRONIUS_GEN24_MAX_POWER_W, charge)), + max(100, min(_FRONIUS_GEN24_MAX_POWER_W, discharge)), + ) + + async def _validate_anker_connection( hass: Any, entry_id: str, @@ -665,6 +694,44 @@ async def _validate_anker_connection( return await AnkerModbusDriver.probe(host, port, slave_id) +async def _validate_fronius_connection( + hass: Any, + entry_id: str, + host: str, + port: int, + slave_id: int, +) -> tuple[bool, dict[str, int]]: + """Validate a Fronius GEN24/BYD endpoint without fighting an active client.""" + entry_data = getattr(hass, "data", {}).get(DOMAIN, {}).get(entry_id, {}) + for coordinator in entry_data.get("coordinators", []): + if ( + getattr(coordinator, "brand", None) == "fronius_gen24" + and getattr(coordinator, "host", None) == host + and int(getattr(coordinator, "port", 502)) == port + and int(getattr(coordinator, "slave_id", DEFAULT_SLAVE_ID)) == slave_id + and bool(getattr(coordinator, "is_available", False)) + ): + data = getattr(coordinator, "data", None) or {} + caps: dict[str, int] = {} + for src, dst in ( + ("max_charge_power", "device_max_charge_power"), + ("max_discharge_power", "device_max_discharge_power"), + ): + value = data.get(src) + if isinstance(value, (int, float)) and int(value) > 0: + caps[dst] = int(value) + _LOGGER.info( + "Reusing active Fronius GEN24 coordinator for connection " + "validation at %s:%s slave %s", + host, + port, + slave_id, + ) + return True, caps + + return await FroniusGen24Driver.probe(host, port, slave_id) + + def _seed_software_power_limits(merged: dict, brand: str) -> None: """Persist soft-max keys for Zendure (read-only chargeMaxLimit + software ceiling).""" if brand != "zendure": @@ -1423,6 +1490,8 @@ async def async_step_battery_brand( return await self.async_step_battery_connection_sessy() if brand == "huawei": return await self.async_step_battery_connection_huawei() + if brand == "fronius_gen24": + return await self.async_step_battery_connection_fronius_gen24() return await self.async_step_battery_connection() return self.async_show_form( @@ -1439,6 +1508,7 @@ async def async_step_battery_brand( {"value": "sessy", "label": "Sessy"}, {"value": "hoymiles", "label": "Hoymiles MQTT"}, {"value": "huawei", "label": "Huawei SUN2000 + LUNA2000"}, + {"value": "fronius_gen24", "label": "Fronius GEN24 / BYD"}, ], mode=SelectSelectorMode.DROPDOWN, )), @@ -1933,6 +2003,46 @@ async def async_step_battery_connection_anker( description_placeholders={"battery_num": str(battery_num)}, ) + async def async_step_battery_connection_fronius_gen24( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Step 3b (Fronius GEN24): Connection details for a BYD storage system.""" + errors = {} + battery_num = self.battery_index + 1 + + if user_input is not None: + host = user_input[CONF_HOST].strip() + port = int(user_input.get(CONF_PORT, 502)) + slave_id = int(user_input.get(CONF_SLAVE_ID, DEFAULT_SLAVE_ID)) + ok, caps = await FroniusGen24Driver.probe(host, port, slave_id) + if not ok: + errors["base"] = "cannot_connect" + else: + self._current_battery_data.update({ + CONF_NAME: user_input[CONF_NAME], + CONF_HOST: host, + CONF_PORT: port, + CONF_SLAVE_ID: slave_id, + "brand": "fronius_gen24", + }) + _fronius_apply_probe_caps(self._current_battery_data, caps) + return await self.async_step_battery_limits() + + return self.async_show_form( + step_id="battery_connection_fronius_gen24", + data_schema=vol.Schema( + { + vol.Required(CONF_NAME, default=f"Fronius GEN24 / BYD {battery_num}"): str, + vol.Required(CONF_HOST): str, + vol.Optional(CONF_PORT, default=502): int, + vol.Required(CONF_SLAVE_ID, default=DEFAULT_SLAVE_ID): + vol.All(NumberSelector(NumberSelectorConfig(min=1, max=247, step=1, mode=NumberSelectorMode.BOX)), vol.Coerce(int)), + } + ), + errors=errors, + description_placeholders={"battery_num": str(battery_num)}, + ) + async def async_step_battery_limits( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -1955,6 +2065,8 @@ async def async_step_battery_limits( max_charge_power, max_discharge_power = _hoymiles_power_ceilings(self._current_battery_data) elif brand == "huawei": max_charge_power, max_discharge_power = _huawei_power_ceilings(self._current_battery_data) + elif brand == "fronius_gen24": + max_charge_power, max_discharge_power = _fronius_power_ceilings(self._current_battery_data) else: battery_version = self._current_battery_data.get(CONF_BATTERY_VERSION, DEFAULT_VERSION) max_charge_power = max_discharge_power = max_power_for_battery_version( @@ -2000,13 +2112,21 @@ async def async_step_battery_limits( ) merged["backup_offgrid_threshold"] = int(user_input.get("backup_offgrid_threshold", 50)) merged[CONF_FULL_CHARGE_VOLTAGE_TAPER_ENABLED] = ( - False if brand in ("zendure", "anker", "sessy", "hoymiles", "huawei") + False + if brand in ( + "zendure", + "anker", + "sessy", + "hoymiles", + "huawei", + "fronius_gen24", + ) else user_input.get(CONF_FULL_CHARGE_VOLTAGE_TAPER_ENABLED, DEFAULT_FULL_CHARGE_VOLTAGE_TAPER_ENABLED) ) - if brand in ("zendure", "sessy", "hoymiles"): + if brand in ("zendure", "sessy", "hoymiles", "fronius_gen24"): capacity_default = ( _hoymiles_capacity_default(self._current_battery_data) - if brand == "hoymiles" else 0.0 + if brand == "hoymiles" else FRONIUS_GEN24_DEFAULT_CAPACITY_KWH if brand == "fronius_gen24" else 0.0 ) merged["battery_capacity_kwh"] = round(float(user_input.get("battery_capacity_kwh", capacity_default)), 2) _apply_mac_tracking(user_input, merged) @@ -2044,19 +2164,26 @@ async def async_step_battery_limits( vol.Required("backup_offgrid_threshold", default=50): NumberSelector(NumberSelectorConfig(min=0, max=2500, step=10, unit_of_measurement="W", mode=NumberSelectorMode.SLIDER)), }) - if brand not in ("zendure", "anker", "sessy", "hoymiles", "huawei"): + if brand not in ( + "zendure", + "anker", + "sessy", + "hoymiles", + "huawei", + "fronius_gen24", + ): _schema[vol.Required(CONF_FULL_CHARGE_VOLTAGE_TAPER_ENABLED, default=DEFAULT_FULL_CHARGE_VOLTAGE_TAPER_ENABLED)] = bool if brand == "sessy": _schema[vol.Required("battery_capacity_kwh")] = NumberSelector( NumberSelectorConfig(min=0.01, max=100, step=0.01, unit_of_measurement="kWh", mode=NumberSelectorMode.BOX) ) - elif brand in ("zendure", "hoymiles"): + elif brand in ("zendure", "hoymiles", "fronius_gen24"): capacity_default = ( _hoymiles_capacity_default(self._current_battery_data) - if brand == "hoymiles" else 0.0 + if brand == "hoymiles" else FRONIUS_GEN24_DEFAULT_CAPACITY_KWH if brand == "fronius_gen24" else 0.0 ) _schema[vol.Optional("battery_capacity_kwh", default=capacity_default)] = NumberSelector( - NumberSelectorConfig(min=0.01 if brand == "hoymiles" else 0, max=100, step=0.01, unit_of_measurement="kWh", mode=NumberSelectorMode.BOX) + NumberSelectorConfig(min=0.01 if brand in ("hoymiles", "fronius_gen24") else 0, max=100, step=0.01, unit_of_measurement="kWh", mode=NumberSelectorMode.BOX) ) if self.config_data.get(CONF_THREE_PHASE_ENABLED): # Keep the established L1 suggestion for a brand-new setup while @@ -2810,6 +2937,8 @@ async def async_step_reconfigure_battery( return await self.async_step_reconfigure_battery_hoymiles(user_input) if current.get("brand", "marstek") == "huawei": return await self.async_step_reconfigure_battery_huawei(user_input) + if current.get("brand", "marstek") == "fronius_gen24": + return await self.async_step_reconfigure_battery_fronius_gen24(user_input) errors = {} @@ -3357,6 +3486,81 @@ async def _huawei_reconfigure_store( ) return await self.async_step_reconfigure_battery() + async def async_step_reconfigure_battery_fronius_gen24( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Update connection settings for a Fronius GEN24 / BYD battery.""" + entry = self._get_reconfigure_entry() + current_batteries = entry.data.get("batteries", []) + battery_num = self.battery_index + 1 + current = ( + current_batteries[self.battery_index] + if self.battery_index < len(current_batteries) + else {} + ) + errors = {} + + if user_input is not None: + new_host = user_input[CONF_HOST].strip() + new_port = int(user_input.get(CONF_PORT, 502)) + slave_id = int(user_input.get(CONF_SLAVE_ID, DEFAULT_SLAVE_ID)) + ok, caps = await _validate_fronius_connection( + self.hass, + entry.entry_id, + new_host, + new_port, + slave_id, + ) + if not ok: + errors["base"] = "cannot_connect" + else: + old_host = current.get(CONF_HOST) + old_port = current.get(CONF_PORT) + + if old_host and old_port and (old_host != new_host or old_port != new_port): + self._migrate_battery_registry_ids( + entry, old_host, old_port, new_host, new_port + ) + + updated = dict(current) + updated[CONF_NAME] = user_input[CONF_NAME] + updated[CONF_HOST] = new_host + updated[CONF_PORT] = new_port + updated[CONF_SLAVE_ID] = slave_id + updated["brand"] = "fronius_gen24" + _fronius_apply_probe_caps(updated, caps) + self._reconfigure_batteries.append(updated) + self.battery_index += 1 + + if self.battery_index >= len(current_batteries): + return self.async_update_reload_and_abort( + entry, + data_updates={"batteries": self._reconfigure_batteries}, + ) + return await self.async_step_reconfigure_battery() + + defaults = { + CONF_NAME: current.get(CONF_NAME, f"Fronius GEN24 / BYD {battery_num}"), + CONF_HOST: current.get(CONF_HOST, ""), + CONF_PORT: current.get(CONF_PORT, 502), + CONF_SLAVE_ID: current.get(CONF_SLAVE_ID, DEFAULT_SLAVE_ID), + } + + return self.async_show_form( + step_id="reconfigure_battery_fronius_gen24", + data_schema=vol.Schema( + { + vol.Required(CONF_NAME, default=defaults[CONF_NAME]): str, + vol.Required(CONF_HOST, default=defaults[CONF_HOST]): str, + vol.Required(CONF_PORT, default=defaults[CONF_PORT]): int, + vol.Required(CONF_SLAVE_ID, default=defaults[CONF_SLAVE_ID]): + vol.All(NumberSelector(NumberSelectorConfig(min=1, max=247, step=1, mode=NumberSelectorMode.BOX)), vol.Coerce(int)), + } + ), + errors=errors, + description_placeholders={"battery_num": str(battery_num)}, + ) + async def async_step_reconfigure_battery_hoymiles(self, user_input: dict[str, Any] | None = None) -> FlowResult: """Update the MQTT device id without asking for broker credentials.""" entry = self._get_reconfigure_entry() @@ -3909,6 +4113,8 @@ async def async_step_battery_brand(self, user_input: dict[str, Any] | None = Non return await self.async_step_battery_connection_huawei() if brand == "hoymiles": return await self.async_step_battery_connection_hoymiles() + if brand == "fronius_gen24": + return await self.async_step_battery_connection_fronius_gen24() return await self.async_step_battery_connection() return self.async_show_form( @@ -3925,6 +4131,7 @@ async def async_step_battery_brand(self, user_input: dict[str, Any] | None = Non {"value": "sessy", "label": "Sessy"}, {"value": "hoymiles", "label": "Hoymiles MQTT"}, {"value": "huawei", "label": "Huawei SUN2000 + LUNA2000"}, + {"value": "fronius_gen24", "label": "Fronius GEN24 / BYD"}, ], mode=SelectSelectorMode.DROPDOWN, )), @@ -4531,6 +4738,75 @@ async def async_step_battery_connection_anker(self, user_input: dict[str, Any] | description_placeholders={"battery_num": str(battery_num)}, ) + async def async_step_battery_connection_fronius_gen24( + self, + user_input: dict[str, Any] | None = None, + ) -> FlowResult: + """Configure connection details for a Fronius GEN24 / BYD battery.""" + errors = {} + + try: + battery_num = self.battery_index + 1 + current_batteries = self.config_entry.data.get("batteries", []) + + if user_input is not None: + host = user_input[CONF_HOST].strip() + port = int(user_input.get(CONF_PORT, 502)) + slave_id = int(user_input.get(CONF_SLAVE_ID, DEFAULT_SLAVE_ID)) + ok, caps = await _validate_fronius_connection( + self.hass, + self.config_entry.entry_id, + host, + port, + slave_id, + ) + if not ok: + errors["base"] = "cannot_connect" + else: + self._current_battery_data.update({ + CONF_NAME: user_input[CONF_NAME], + CONF_HOST: host, + CONF_PORT: port, + CONF_SLAVE_ID: slave_id, + "brand": "fronius_gen24", + }) + _fronius_apply_probe_caps(self._current_battery_data, caps) + return await self.async_step_battery_limits() + + if self.battery_index < len(current_batteries): + current_battery = current_batteries[self.battery_index] + defaults = { + CONF_NAME: current_battery.get(CONF_NAME, f"Fronius GEN24 / BYD {battery_num}"), + CONF_HOST: current_battery.get(CONF_HOST, ""), + CONF_PORT: current_battery.get(CONF_PORT, 502), + CONF_SLAVE_ID: current_battery.get(CONF_SLAVE_ID, DEFAULT_SLAVE_ID), + } + else: + defaults = { + CONF_NAME: f"Fronius GEN24 / BYD {battery_num}", + CONF_HOST: "", + CONF_PORT: 502, + CONF_SLAVE_ID: DEFAULT_SLAVE_ID, + } + except Exception as e: + _LOGGER.error("Error in options flow battery_connection_fronius_gen24 step: %s", e, exc_info=True) + return self.async_abort(reason="unknown_error") + + return self.async_show_form( + step_id="battery_connection_fronius_gen24", + data_schema=vol.Schema( + { + vol.Required(CONF_NAME, default=defaults[CONF_NAME]): str, + vol.Required(CONF_HOST, default=defaults[CONF_HOST]): str, + vol.Optional(CONF_PORT, default=defaults[CONF_PORT]): int, + vol.Required(CONF_SLAVE_ID, default=defaults[CONF_SLAVE_ID]): + vol.All(NumberSelector(NumberSelectorConfig(min=1, max=247, step=1, mode=NumberSelectorMode.BOX)), vol.Coerce(int)), + } + ), + errors=errors, + description_placeholders={"battery_num": str(battery_num)}, + ) + async def async_step_battery_limits(self, user_input: dict[str, Any] | None = None) -> FlowResult: """Configure power and SOC limits for the current battery.""" errors: dict[str, str] = {} @@ -4552,6 +4828,8 @@ async def async_step_battery_limits(self, user_input: dict[str, Any] | None = No max_charge_power, max_discharge_power = _hoymiles_power_ceilings(self._current_battery_data) elif brand == "huawei": max_charge_power, max_discharge_power = _huawei_power_ceilings(self._current_battery_data) + elif brand == "fronius_gen24": + max_charge_power, max_discharge_power = _fronius_power_ceilings(self._current_battery_data) else: battery_version = self._current_battery_data.get(CONF_BATTERY_VERSION, DEFAULT_VERSION) max_charge_power = max_discharge_power = max_power_for_battery_version( @@ -4611,13 +4889,21 @@ async def async_step_battery_limits(self, user_input: dict[str, Any] | None = No ) merged["backup_offgrid_threshold"] = int(user_input.get("backup_offgrid_threshold", 50)) merged[CONF_FULL_CHARGE_VOLTAGE_TAPER_ENABLED] = ( - False if brand in ("zendure", "anker", "sessy", "hoymiles", "huawei") + False + if brand in ( + "zendure", + "anker", + "sessy", + "hoymiles", + "huawei", + "fronius_gen24", + ) else user_input.get(CONF_FULL_CHARGE_VOLTAGE_TAPER_ENABLED, DEFAULT_FULL_CHARGE_VOLTAGE_TAPER_ENABLED) ) - if brand in ("zendure", "sessy", "hoymiles"): + if brand in ("zendure", "sessy", "hoymiles", "fronius_gen24"): capacity_default = ( _hoymiles_capacity_default(self._current_battery_data) - if brand == "hoymiles" else 0.0 + if brand == "hoymiles" else FRONIUS_GEN24_DEFAULT_CAPACITY_KWH if brand == "fronius_gen24" else 0.0 ) merged["battery_capacity_kwh"] = round(float(user_input.get("battery_capacity_kwh", capacity_default)), 2) _apply_mac_tracking(user_input, merged) @@ -4650,7 +4936,7 @@ async def async_step_battery_limits(self, user_input: dict[str, Any] | None = No "battery_capacity_kwh": current_battery.get( "battery_capacity_kwh", _hoymiles_capacity_default(self._current_battery_data) - if brand == "hoymiles" else 0.0, + if brand == "hoymiles" else FRONIUS_GEN24_DEFAULT_CAPACITY_KWH if brand == "fronius_gen24" else 0.0, ), CONF_BATTERY_PHASE: normalize_battery_phase( current_battery.get(CONF_BATTERY_PHASE, PHASE_UNASSIGNED) @@ -4679,7 +4965,7 @@ async def async_step_battery_limits(self, user_input: dict[str, Any] | None = No CONF_FULL_CHARGE_VOLTAGE_TAPER_ENABLED: DEFAULT_FULL_CHARGE_VOLTAGE_TAPER_ENABLED, "battery_capacity_kwh": ( _hoymiles_capacity_default(self._current_battery_data) - if brand == "hoymiles" else 0.0 + if brand == "hoymiles" else FRONIUS_GEN24_DEFAULT_CAPACITY_KWH if brand == "fronius_gen24" else 0.0 ), CONF_BATTERY_PHASE: PHASE_UNASSIGNED, } @@ -4705,7 +4991,14 @@ async def async_step_battery_limits(self, user_input: dict[str, Any] | None = No vol.Required("backup_offgrid_threshold", default=defaults["backup_offgrid_threshold"]): NumberSelector(NumberSelectorConfig(min=0, max=2500, step=10, unit_of_measurement="W", mode=NumberSelectorMode.SLIDER)), }) - if brand not in ("zendure", "anker", "sessy", "hoymiles", "huawei"): + if brand not in ( + "zendure", + "anker", + "sessy", + "hoymiles", + "huawei", + "fronius_gen24", + ): _schema[vol.Required(CONF_FULL_CHARGE_VOLTAGE_TAPER_ENABLED, default=defaults[CONF_FULL_CHARGE_VOLTAGE_TAPER_ENABLED])] = bool if brand == "sessy": saved_capacity = float(defaults["battery_capacity_kwh"]) @@ -4717,9 +5010,9 @@ async def async_step_battery_limits(self, user_input: dict[str, Any] | None = No _schema[capacity_field] = NumberSelector( NumberSelectorConfig(min=0.01, max=100, step=0.01, unit_of_measurement="kWh", mode=NumberSelectorMode.BOX) ) - elif brand in ("zendure", "hoymiles"): + elif brand in ("zendure", "hoymiles", "fronius_gen24"): _schema[vol.Optional("battery_capacity_kwh", default=defaults["battery_capacity_kwh"])] = NumberSelector( - NumberSelectorConfig(min=0.01 if brand == "hoymiles" else 0, max=100, step=0.01, unit_of_measurement="kWh", mode=NumberSelectorMode.BOX) + NumberSelectorConfig(min=0.01 if brand in ("hoymiles", "fronius_gen24") else 0, max=100, step=0.01, unit_of_measurement="kWh", mode=NumberSelectorMode.BOX) ) if self.config_entry.data.get( CONF_THREE_PHASE_ENABLED, diff --git a/custom_components/omnibattery/drivers/fronius_gen24.py b/custom_components/omnibattery/drivers/fronius_gen24.py new file mode 100644 index 00000000..54422549 --- /dev/null +++ b/custom_components/omnibattery/drivers/fronius_gen24.py @@ -0,0 +1,951 @@ +"""Fronius GEN24 / BYD Battery-Box Modbus TCP driver. + +Implements :class:`BatteryDriver` for a BYD battery controlled through a +Fronius GEN24 inverter's SunSpec storage control registers. + +Register map source of truth for this first implementation is the working local +Home Assistant setup this driver replaces: + +* Storage control block: 40355, count 24, structure ``>10H2h4H8h`` +* DC measurement block: 40265, count 88, structure compatible with the local + ``reading_inverter_multiple_raw`` sensor +* Control writes: 40358 (StorCtl_Mod), 40365 (OutWRte), 40366 (InWRte) + +Sign conventions: + Omnibattery net power: +charge / -discharge + Fronius Power Flow sensor: +discharge / -charge + Fronius DC block used here: 3_DCW = charge, 4_DCW = discharge + Therefore battery_power = 3_DCW - 4_DCW. +""" + +from __future__ import annotations + +import asyncio +import logging +from dataclasses import dataclass +from typing import Any, Optional + +try: + import aiohttp +except ImportError: # pragma: no cover - Home Assistant provides aiohttp. + aiohttp = None + +from ..infra.modbus_client import MarstekModbusClient, decode_registers +from .base import ( + BatteryDriver, + DriverCapabilities, + ReadGroup, + SetpointResult, + TelemetrySnapshot, +) + +_LOGGER = logging.getLogger(__name__) + +FRONIUS_GEN24_DEFAULT_MAX_POWER_W = 5000 +FRONIUS_GEN24_DEFAULT_CAPACITY_KWH = 11.0 + +_ADDR_DCW_BLOCK = 40265 +_DCW_BLOCK_COUNT = 88 +_DCW_SF_OFFSET = 2 +# Raw Modbus word offsets. The HA custom sensor exposes these as split indices +# 15/19 after skipping reserved bytes in ``>hhhh8xH16xHHH16x...``. +_DCW_TO_BATTERY_OFFSET = 59 +_DCW_FROM_BATTERY_OFFSET = 79 + +_ADDR_STORAGE_BLOCK = 40355 +_STORAGE_BLOCK_COUNT = 24 +_IDX_WCHAMAX = 0 +_IDX_STORCTL_MOD = 3 +_IDX_MIN_RSV_PCT = 5 +_IDX_CHA_STATE = 6 +_IDX_CHA_STATUS = 9 +_IDX_OUTWRTE = 10 +_IDX_INWRTE = 11 +_IDX_CHAGRISET = 15 +_IDX_WCHAMAX_SF = 16 +_IDX_MIN_RSV_PCT_SF = 19 +_IDX_CHA_STATE_SF = 20 +_IDX_INOUTWRTE_SF = 23 + +_ADDR_STORCTL_MOD = 40358 +_ADDR_MIN_RSV_PCT = 40360 +_ADDR_OUTWRTE = 40365 +_ADDR_INWRTE = 40366 + +_MODE_AUTO = 0 +_MODE_STORAGE_CONTROL = 3 +_NEUTRAL_WINDOW_WORD = 10000 +_DEFAULT_MIN_RSV_WORD = 500 +_MIN_OPERATING_POWER_W = 10 +_MIN_DISCHARGE_DIRECTION_CHANGE_W = 750 +_SOC_LIMIT_HOLD_CHARGE_W = 10 +_SETTLE_SECONDS = 0.2 +_STORAGE_API_PATH = "/solar_api/v1/GetStorageRealtimeData.cgi" +_STORAGE_API_TIMEOUT_SECONDS = 5 +_STORAGE_API_METADATA_KEYS = { + "fronius_storage_serial", + "fronius_storage_model", + "fronius_storage_manufacturer", +} + +SENSOR_DEFINITIONS: list[dict] = [ + {"key": "battery_soc", "name": "Battery SOC", "unit": "%", + "device_class": "battery", "state_class": "measurement", "scale": 1, + "precision": 1, "scan_interval": "medium", "enabled_by_default": True}, + {"key": "battery_power", "name": "Battery Power", "unit": "W", + "device_class": "power", "state_class": "measurement", "scale": 1, + "precision": 0, "scan_interval": "high", "enabled_by_default": True}, + {"key": "ac_power", "name": "AC Power", "unit": "W", + "device_class": "power", "state_class": "measurement", "scale": 1, + "precision": 0, "scan_interval": "high", "enabled_by_default": True}, + {"key": "battery_charge_power", "name": "Battery Charge Power", "unit": "W", + "device_class": "power", "state_class": "measurement", "scale": 1, + "precision": 0, "scan_interval": "high", "enabled_by_default": False}, + {"key": "battery_discharge_power", "name": "Battery Discharge Power", "unit": "W", + "device_class": "power", "state_class": "measurement", "scale": 1, + "precision": 0, "scan_interval": "high", "enabled_by_default": False}, + {"key": "internal_temperature", "name": "Internal Temperature", "unit": "°C", + "device_class": "temperature", "state_class": "measurement", "scale": 1, + "precision": 1, "scan_interval": "medium", "enabled_by_default": True}, + {"key": "battery_voltage", "name": "Battery Voltage", "unit": "V", + "device_class": "voltage", "state_class": "measurement", "scale": 1, + "precision": 1, "scan_interval": "medium", "enabled_by_default": True}, + {"key": "battery_current", "name": "Battery Current", "unit": "A", + "device_class": "current", "state_class": "measurement", "scale": 1, + "precision": 2, "scan_interval": "medium", "enabled_by_default": True}, + {"key": "max_charge_power", "name": "Max Charge Power", "unit": "W", + "device_class": None, "state_class": None, "scale": 1, "precision": 0, + "icon": "mdi:battery-charging-high", "scan_interval": "medium", + "enabled_by_default": True}, + {"key": "max_discharge_power", "name": "Max Discharge Power", "unit": "W", + "device_class": None, "state_class": None, "scale": 1, "precision": 0, + "icon": "mdi:battery-arrow-down-outline", "scan_interval": "medium", + "enabled_by_default": True}, + {"key": "storctl_mod", "name": "Storage Control Mode", "unit": None, + "device_class": None, "state_class": None, "scale": 1, "precision": 0, + "icon": "mdi:tune", "scan_interval": "medium", "enabled_by_default": True, + "states": {0: "Auto", 3: "External Control"}}, + {"key": "outwrte", "name": "OutWRte", "unit": None, + "device_class": None, "state_class": None, "scale": 1, "precision": 0, + "icon": "mdi:export", "scan_interval": "medium", "enabled_by_default": False}, + {"key": "inwrte", "name": "InWRte", "unit": None, + "device_class": None, "state_class": None, "scale": 1, "precision": 0, + "icon": "mdi:import", "scan_interval": "medium", "enabled_by_default": False}, + {"key": "min_rsv_pct", "name": "Minimum Reserve", "unit": "%", + "device_class": "battery", "state_class": "measurement", "scale": 1, + "precision": 1, "scan_interval": "medium", "enabled_by_default": False}, + {"key": "fronius_charge_state", "name": "Fronius Charge State", "unit": "%", + "device_class": "battery", "state_class": "measurement", "scale": 1, + "precision": 1, "scan_interval": "medium", "enabled_by_default": False}, + {"key": "fronius_charge_status", "name": "Fronius Charge Status", "unit": None, + "device_class": None, "state_class": None, "scale": 1, "precision": 0, + "icon": "mdi:battery-sync", "scan_interval": "medium", + "enabled_by_default": False}, + {"key": "chagriset", "name": "ChaGriSet", "unit": None, + "device_class": None, "state_class": None, "scale": 1, "precision": 0, + "icon": "mdi:transmission-tower-import", "scan_interval": "medium", + "enabled_by_default": False}, + {"key": "inverter_state", "name": "Inverter State", "unit": None, + "device_class": None, "state_class": None, "scale": 1, "precision": 0, + "icon": "mdi:state-machine", "scan_interval": "high", + "enabled_by_default": True, + "states": {1: "Standby", 2: "Charge", 3: "Discharge"}}, +] + +NUMBER_DEFINITIONS: list[dict] = [] +SELECT_DEFINITIONS: list[dict] = [] +SWITCH_DEFINITIONS: list[dict] = [] +BINARY_SENSOR_DEFINITIONS: list[dict] = [] +BUTTON_DEFINITIONS: list[dict] = [] + + +@dataclass(frozen=True) +class RegisterWrite: + """One Modbus single-register write.""" + + address: int + value: int + + +@dataclass(frozen=True) +class SetpointPlan: + """Decoded command plan for a signed Omnibattery net setpoint.""" + + net_power_w: int + mode: str + outwrte_word: int + inwrte_word: int + writes: tuple[RegisterWrite, ...] + + +def _int16_word(value: int) -> int: + """Encode a signed int16 value as an unsigned Modbus register word.""" + return int(value) & 0xFFFF + + +def _decode_int16(word: int) -> int: + return int(decode_registers([word], "int16") or 0) + + +def _scaled(value: float | int | None, scale_factor: float | int | None) -> float | None: + if value is None or scale_factor is None: + return None + try: + return float(value) * (10 ** int(scale_factor)) + except (TypeError, ValueError, OverflowError): + return None + + +def _rate_denominator(inout_sf: int | None) -> float: + """Return raw register units for 100% power. + + The local Fronius/BYD setup writes 10000 for 100% because InOutWRte_SF is -2. + Keep the formula scale-factor aware so a different SunSpec encoding still + derives the command echo correctly. + """ + sf = -2 if inout_sf is None else int(inout_sf) + return 100.0 / (10 ** sf) + + +def _clamp_power(value: int, ceiling: int) -> int: + ceiling = max(0, int(ceiling or 0)) + return max(0, min(ceiling, int(value))) + + +def plan_storage_setpoint( + net_power_w: int, + *, + wcha_max_w: int, + max_charge_power_w: int, + max_discharge_power_w: int, +) -> SetpointPlan: + """Build the exact GEN24/BYD register-write plan for one setpoint. + + ``net_power_w`` follows the Omnibattery convention (+charge / -discharge). + The formulas mirror the working local scripts: + + * charge: ``OutWRte = 65535 - pct`` and ``InWRte = 1 + pct`` + * discharge: ``OutWRte = int((power + 1) / WChaMax * 10000)`` and + ``InWRte = 65535 - pct`` + * neutral: both windows are set to ``10000`` while external control remains + active. + """ + wcha = max(1, int(wcha_max_w or 1)) + charge_ceiling = min(wcha, max(0, int(max_charge_power_w or 0))) + discharge_ceiling = min(wcha, max(0, int(max_discharge_power_w or 0))) + + if net_power_w > 0: + power = _clamp_power(net_power_w, charge_ceiling) + pct = int(power / wcha * 10000) + out_word = _int16_word(-(pct + 1)) + in_word = pct + 1 + writes = ( + RegisterWrite(_ADDR_OUTWRTE, out_word), + RegisterWrite(_ADDR_INWRTE, in_word), + RegisterWrite(_ADDR_STORCTL_MOD, _MODE_STORAGE_CONTROL), + RegisterWrite(_ADDR_OUTWRTE, out_word), + RegisterWrite(_ADDR_INWRTE, in_word), + ) + return SetpointPlan(power, "charge", out_word, in_word, writes) + + if net_power_w < 0: + power = _clamp_power(-net_power_w, discharge_ceiling) + pct = int(power / wcha * 10000) + out_word = int((power + 1) / wcha * 10000) + in_word = _int16_word(-(pct + 1)) + writes = ( + RegisterWrite(_ADDR_STORCTL_MOD, _MODE_STORAGE_CONTROL), + RegisterWrite(_ADDR_OUTWRTE, out_word), + RegisterWrite(_ADDR_INWRTE, in_word), + RegisterWrite(_ADDR_OUTWRTE, out_word), + RegisterWrite(_ADDR_INWRTE, in_word), + ) + return SetpointPlan(-power, "discharge", out_word, in_word, writes) + + writes = ( + RegisterWrite(_ADDR_OUTWRTE, _NEUTRAL_WINDOW_WORD), + RegisterWrite(_ADDR_INWRTE, _NEUTRAL_WINDOW_WORD), + RegisterWrite(_ADDR_STORCTL_MOD, _MODE_STORAGE_CONTROL), + RegisterWrite(_ADDR_OUTWRTE, _NEUTRAL_WINDOW_WORD), + RegisterWrite(_ADDR_INWRTE, _NEUTRAL_WINDOW_WORD), + ) + return SetpointPlan(0, "neutral", _NEUTRAL_WINDOW_WORD, _NEUTRAL_WINDOW_WORD, writes) + + +def plan_reset_to_auto() -> tuple[RegisterWrite, ...]: + """Return control to Fronius auto mode using the local reset script values.""" + return ( + RegisterWrite(_ADDR_STORCTL_MOD, _MODE_AUTO), + RegisterWrite(_ADDR_OUTWRTE, _NEUTRAL_WINDOW_WORD), + RegisterWrite(_ADDR_MIN_RSV_PCT, _DEFAULT_MIN_RSV_WORD), + RegisterWrite(_ADDR_INWRTE, _NEUTRAL_WINDOW_WORD), + ) + + +def decode_storage_registers(regs: list[int]) -> TelemetrySnapshot: + """Decode the local 40355 / ``>10H2h4H8h`` storage-control block.""" + if len(regs) < _STORAGE_BLOCK_COUNT: + return {} + + wcha_sf = _decode_int16(regs[_IDX_WCHAMAX_SF]) + min_rsv_sf = _decode_int16(regs[_IDX_MIN_RSV_PCT_SF]) + cha_state_sf = _decode_int16(regs[_IDX_CHA_STATE_SF]) + inout_sf = _decode_int16(regs[_IDX_INOUTWRTE_SF]) + + wcha = _scaled(regs[_IDX_WCHAMAX], wcha_sf) + soc = _scaled(regs[_IDX_CHA_STATE], cha_state_sf) + min_rsv = _scaled(regs[_IDX_MIN_RSV_PCT], min_rsv_sf) + + snapshot: TelemetrySnapshot = { + "storctl_mod": int(regs[_IDX_STORCTL_MOD]), + "outwrte": _decode_int16(regs[_IDX_OUTWRTE]), + "inwrte": _decode_int16(regs[_IDX_INWRTE]), + "inoutwrte_sf": inout_sf, + "fronius_charge_status": int(regs[_IDX_CHA_STATUS]), + "chagriset": int(regs[_IDX_CHAGRISET]), + } + if wcha is not None and wcha > 0: + wcha_w = int(round(wcha)) + snapshot["wcha_max"] = wcha_w + snapshot["max_charge_power"] = wcha_w + snapshot["max_discharge_power"] = wcha_w + if soc is not None: + snapshot["battery_soc"] = soc + snapshot["fronius_charge_state"] = soc + if min_rsv is not None: + snapshot["min_rsv_pct"] = min_rsv + return snapshot + + +def decode_dc_power_registers(regs: list[int]) -> TelemetrySnapshot: + """Decode the local GEN24 DC block into Omnibattery battery power.""" + if len(regs) <= max(_DCW_SF_OFFSET, _DCW_TO_BATTERY_OFFSET, _DCW_FROM_BATTERY_OFFSET): + return {} + + dcw_sf = _decode_int16(regs[_DCW_SF_OFFSET]) + charge_w = _scaled(regs[_DCW_TO_BATTERY_OFFSET], dcw_sf) + discharge_w = _scaled(regs[_DCW_FROM_BATTERY_OFFSET], dcw_sf) + if charge_w is None or discharge_w is None: + return {} + + battery_power = int(round(charge_w - discharge_w)) + snapshot: TelemetrySnapshot = { + "battery_charge_power": int(round(charge_w)), + "battery_discharge_power": int(round(discharge_w)), + "battery_power": battery_power, + "ac_power": -battery_power, + } + if battery_power > 50: + snapshot["inverter_state"] = 2 + elif battery_power < -50: + snapshot["inverter_state"] = 3 + else: + snapshot["inverter_state"] = 1 + return snapshot + + +def _as_float(value: Any) -> float | None: + try: + return float(value) + except (TypeError, ValueError): + return None + + +def decode_storage_api_payload(payload: dict[str, Any]) -> TelemetrySnapshot: + """Decode Fronius ``GetStorageRealtimeData.cgi`` battery telemetry.""" + try: + data = payload["Body"]["Data"] + except (KeyError, TypeError): + return {} + + if not isinstance(data, dict) or not data: + return {} + + first_storage = data.get("0") + if not isinstance(first_storage, dict): + first_storage = next((value for value in data.values() if isinstance(value, dict)), None) + if not isinstance(first_storage, dict): + return {} + + controller = first_storage.get("Controller") + if not isinstance(controller, dict): + return {} + + snapshot: TelemetrySnapshot = {} + mapping = { + "Temperature_Cell": "internal_temperature", + "Voltage_DC": "battery_voltage", + "Current_DC": "battery_current", + "StateOfCharge_Relative": "battery_soc", + } + for source, target in mapping.items(): + value = _as_float(controller.get(source)) + if value is not None: + snapshot[target] = value + + capacity = _as_float(controller.get("Capacity_Maximum")) + if capacity is None or capacity <= 0: + capacity = _as_float(controller.get("DesignedCapacity")) + if capacity is not None and capacity > 0: + snapshot["battery_total_energy"] = round(capacity / 1000.0, 3) + + details = controller.get("Details") + if isinstance(details, dict): + serial = details.get("Serial") + model = details.get("Model") + manufacturer = details.get("Manufacturer") + if isinstance(serial, str) and serial.strip(): + snapshot["fronius_storage_serial"] = serial.strip() + if isinstance(model, str) and model.strip(): + snapshot["fronius_storage_model"] = model.strip() + if isinstance(manufacturer, str) and manufacturer.strip(): + snapshot["fronius_storage_manufacturer"] = manufacturer.strip() + + return snapshot + + +class FroniusGen24Driver(BatteryDriver): + """Modbus TCP driver for a Fronius GEN24-controlled BYD battery.""" + + def __init__( + self, + host: str, + port: int = 502, + slave_id: int = 1, + *, + client: Optional[MarstekModbusClient] = None, + http_session: Optional[Any] = None, + max_charge_power_w: int = FRONIUS_GEN24_DEFAULT_MAX_POWER_W, + max_discharge_power_w: int = FRONIUS_GEN24_DEFAULT_MAX_POWER_W, + ) -> None: + self._host = host + self._port = port + self._slave_id = slave_id + self._client = client or MarstekModbusClient( + host, + port, + message_wait_ms=50, + timeout=5, + is_v3=False, + slave_id=slave_id, + ) + self._wcha_max_w = max(1, int(max(max_charge_power_w, max_discharge_power_w))) + self._max_charge_w = max(0, int(max_charge_power_w)) + self._max_discharge_w = max(0, int(max_discharge_power_w)) + self._max_soc_pct = 100.0 + self._min_soc_pct = 0.0 + self._last_soc_pct: Optional[float] = None + self._last_net_power_w: Optional[int] = None + self._last_inout_sf = -2 + self._serial: Optional[str] = None + self._http_session = http_session + self._owns_http_session = http_session is None + self._storage_api_url = f"http://{host}{_STORAGE_API_PATH}" + self._read_groups = [ + ReadGroup( + "high", + ( + "battery_power", + "ac_power", + "battery_charge_power", + "battery_discharge_power", + "inverter_state", + ), + ), + ReadGroup( + "medium", + ( + "battery_soc", + "fronius_charge_state", + "fronius_charge_status", + "max_charge_power", + "max_discharge_power", + "storctl_mod", + "outwrte", + "inwrte", + "min_rsv_pct", + "chagriset", + "internal_temperature", + "battery_voltage", + "battery_current", + "battery_total_energy", + ), + ), + ] + self._capabilities = DriverCapabilities( + hardware_soc_cutoff=False, + has_force_mode=False, + push_telemetry=False, + max_charge_power_w=max(1, self._max_charge_w), + max_discharge_power_w=max(1, self._max_discharge_w), + min_charge_power_w=_MIN_OPERATING_POWER_W, + min_discharge_power_w=_MIN_OPERATING_POWER_W, + has_mppt_pv=False, + has_alarm_registers=False, + has_rs485_control=False, + has_energy_counters=False, + has_nominal_capacity=False, + has_daily_energy_counters=False, + setpoint_confirm_reliable=True, + actuator_latency_s=1.0, + readback_latency_s=1.5, + ) + + @property + def capabilities(self) -> DriverCapabilities: + return self._capabilities + + @property + def model_label(self) -> Optional[str]: + return "GEN24 / BYD" + + @property + def serial(self) -> Optional[str]: + """Return the physical BYD serial used by synthetic-energy backup.""" + return self._serial + + @property + def sensor_definitions(self) -> list[dict]: + return SENSOR_DEFINITIONS + + @property + def number_definitions(self) -> list[dict]: + return NUMBER_DEFINITIONS + + @property + def select_definitions(self) -> list[dict]: + return SELECT_DEFINITIONS + + @property + def switch_definitions(self) -> list[dict]: + return SWITCH_DEFINITIONS + + @property + def binary_sensor_definitions(self) -> list[dict]: + return BINARY_SENSOR_DEFINITIONS + + @property + def button_definitions(self) -> list[dict]: + return BUTTON_DEFINITIONS + + @property + def all_definitions(self) -> list[dict]: + return ( + SENSOR_DEFINITIONS + + NUMBER_DEFINITIONS + + SELECT_DEFINITIONS + + SWITCH_DEFINITIONS + + BINARY_SENSOR_DEFINITIONS + + BUTTON_DEFINITIONS + ) + + @property + def connected(self) -> bool: + return self._client.connected + + async def connect(self) -> bool: + ok = await self._client.async_connect() + if ok: + await self._refresh_storage_cache() + return ok + + async def close(self) -> None: + if self._owns_http_session and self._http_session is not None: + await self._http_session.close() + self._http_session = None + await self._client.async_close() + + def set_shutting_down(self, value: bool) -> None: + self._client.set_shutting_down(value) + + @property + def read_groups(self) -> list[ReadGroup]: + return self._read_groups + + async def _read_storage_block(self) -> TelemetrySnapshot: + self._client.unit_id = self._slave_id + regs = await self._client.async_read_block( + _ADDR_STORAGE_BLOCK, + _STORAGE_BLOCK_COUNT, + block_key="fronius_storage", + ) + if not regs: + return {} + return decode_storage_registers(regs) + + async def _read_dc_power_block(self) -> TelemetrySnapshot: + self._client.unit_id = self._slave_id + regs = await self._client.async_read_block( + _ADDR_DCW_BLOCK, + _DCW_BLOCK_COUNT, + block_key="fronius_dc_power", + ) + if not regs: + return {} + return decode_dc_power_registers(regs) + + def _ensure_http_session(self) -> Any: + if self._http_session is None or getattr(self._http_session, "closed", False): + if aiohttp is None: + raise RuntimeError("aiohttp is not available") + self._http_session = aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=_STORAGE_API_TIMEOUT_SECONDS) + ) + self._owns_http_session = True + return self._http_session + + async def _read_storage_api(self) -> TelemetrySnapshot: + try: + session = self._ensure_http_session() + async with session.get(self._storage_api_url) as response: + if response.status != 200: + _LOGGER.debug( + "Fronius storage API %s returned HTTP %s", + self._storage_api_url, + response.status, + ) + return {} + payload = await response.json(content_type=None) + except (asyncio.TimeoutError, RuntimeError, ValueError) as exc: + _LOGGER.debug("Fronius storage API read failed: %s", exc) + return {} + except Exception as exc: + if aiohttp is not None and isinstance(exc, aiohttp.ClientError): + _LOGGER.debug("Fronius storage API read failed: %s", exc) + return {} + raise + return decode_storage_api_payload(payload) + + async def _refresh_storage_cache(self) -> None: + snapshot = await self._read_storage_block() + max_power = snapshot.get("wcha_max") or snapshot.get("max_charge_power") + if isinstance(max_power, (int, float)) and int(max_power) > 0: + self._wcha_max_w = int(max_power) + inout_sf = snapshot.get("inoutwrte_sf") + if isinstance(inout_sf, (int, float)): + self._last_inout_sf = int(inout_sf) + soc = snapshot.get("battery_soc") + if isinstance(soc, (int, float)): + self._last_soc_pct = float(soc) + + def _remember_soc(self, snapshot: TelemetrySnapshot) -> None: + soc = snapshot.get("battery_soc") + if isinstance(soc, (int, float)): + self._last_soc_pct = float(soc) + + def _max_soc_reached(self) -> bool: + return ( + self._last_soc_pct is not None + and self._max_soc_pct < 100.0 + and self._last_soc_pct >= self._max_soc_pct + ) + + async def read_telemetry(self, keys: Optional[list[str]] = None) -> TelemetrySnapshot: + wanted = set(keys) if keys is not None else {d["key"] for d in SENSOR_DEFINITIONS} + snapshot: TelemetrySnapshot = {} + + storage_keys = { + "battery_soc", + "fronius_charge_state", + "fronius_charge_status", + "max_charge_power", + "max_discharge_power", + "storctl_mod", + "outwrte", + "inwrte", + "min_rsv_pct", + "chagriset", + "wcha_max", + "inoutwrte_sf", + } + storage_api_keys = { + "internal_temperature", + "battery_voltage", + "battery_current", + "battery_total_energy", + "battery_soc", + "fronius_storage_serial", + "fronius_storage_model", + "fronius_storage_manufacturer", + } + dc_keys = { + "battery_power", + "ac_power", + "battery_charge_power", + "battery_discharge_power", + "inverter_state", + } + + if wanted & storage_keys: + storage = await self._read_storage_block() + snapshot.update(storage) + self._remember_soc(storage) + max_power = storage.get("wcha_max") or storage.get("max_charge_power") + if isinstance(max_power, (int, float)) and int(max_power) > 0: + self._wcha_max_w = int(max_power) + inout_sf = storage.get("inoutwrte_sf") + if isinstance(inout_sf, (int, float)): + self._last_inout_sf = int(inout_sf) + + if wanted & dc_keys: + snapshot.update(await self._read_dc_power_block()) + + if wanted & storage_api_keys: + storage_api = await self._read_storage_api() + snapshot.update(storage_api) + self._remember_soc(storage_api) + serial = storage_api.get("fronius_storage_serial") + if isinstance(serial, str) and serial: + self._serial = serial + wanted.update(_STORAGE_API_METADATA_KEYS) + + if keys is None: + return snapshot + return {key: value for key, value in snapshot.items() if key in wanted} + + async def apply_setpoint( + self, + net_power_w: int, + *, + mode_hint: Optional[str] = None, + read_back: bool = True, + ) -> SetpointResult: + _ = mode_hint + if not self.connected: + return SetpointResult( + ok=False, + net_power_w=0, + confirmed=False, + failure_reason="not_connected", + ) + + if self._wcha_max_w <= 1: + await self._refresh_storage_cache() + if self._wcha_max_w <= 1: + return SetpointResult( + ok=False, + net_power_w=0, + confirmed=False, + failure_reason="missing_wchamax", + ) + + # The GEN24/BYD storage controller reacts to every sign change as a real + # mode transition. Small discharge requests during PV charging therefore + # collapse a steady kW-scale charge into a brief discharge/idle cycle + # before the inverter ramps back up. Treat those sub-material discharge + # requests as idle and let Fronius continue absorbing PV surplus. + requested_power_w = int(net_power_w) + if ( + requested_power_w < 0 + and abs(requested_power_w) <= _MIN_DISCHARGE_DIRECTION_CHANGE_W + ): + _LOGGER.debug( + "Suppressing small Fronius GEN24 discharge request %d W at/below " + "%d W; writing neutral storage control instead", + requested_power_w, + _MIN_DISCHARGE_DIRECTION_CHANGE_W, + ) + requested_power_w = 0 + + if self._last_soc_pct is None and requested_power_w >= 0 and self._max_soc_pct < 100.0: + self._remember_soc(await self.read_telemetry(["battery_soc"])) + if requested_power_w >= 0 and self._max_soc_reached(): + if requested_power_w != _SOC_LIMIT_HOLD_CHARGE_W: + _LOGGER.debug( + "Fronius GEN24 max SOC %.1f%% reached at %.1f%%; holding " + "storage control with %d W charge limit instead of %d W", + self._max_soc_pct, + self._last_soc_pct, + _SOC_LIMIT_HOLD_CHARGE_W, + requested_power_w, + ) + requested_power_w = _SOC_LIMIT_HOLD_CHARGE_W + + plan = plan_storage_setpoint( + requested_power_w, + wcha_max_w=self._wcha_max_w, + max_charge_power_w=self._max_charge_w, + max_discharge_power_w=self._max_discharge_w, + ) + ok = await self._write_plan(plan.writes) + if not ok: + return SetpointResult( + ok=False, + net_power_w=plan.net_power_w, + confirmed=False, + failure_reason="write_failed", + ) + + self._last_net_power_w = plan.net_power_w + applied = { + "commanded_net_power": plan.net_power_w, + "storctl_mod": _MODE_STORAGE_CONTROL, + "outwrte": _decode_int16(plan.outwrte_word), + "inwrte": _decode_int16(plan.inwrte_word), + } + + if not read_back: + return SetpointResult( + ok=True, + net_power_w=plan.net_power_w, + confirmed=False, + applied=applied, + ) + + await asyncio.sleep(_SETTLE_SECONDS) + echo = await self.read_telemetry(["storctl_mod", "outwrte", "inwrte", "battery_power"]) + if not {"storctl_mod", "outwrte", "inwrte"}.issubset(echo): + return SetpointResult( + ok=True, + net_power_w=plan.net_power_w, + confirmed=False, + failure_reason="feedback_timeout", + ) + + expected_out = _decode_int16(plan.outwrte_word) + expected_in = _decode_int16(plan.inwrte_word) + confirmed = ( + int(echo["storctl_mod"]) == _MODE_STORAGE_CONTROL + and int(echo["outwrte"]) == expected_out + and int(echo["inwrte"]) == expected_in + ) + applied.update({ + "storctl_mod": echo["storctl_mod"], + "outwrte": echo["outwrte"], + "inwrte": echo["inwrte"], + }) + battery_power = echo.get("battery_power") + if battery_power is not None: + applied["battery_power"] = battery_power + return SetpointResult( + ok=True, + net_power_w=plan.net_power_w, + confirmed=confirmed, + exact=confirmed, + failure_reason=None if confirmed else "ack_mismatch", + battery_power_w=int(battery_power) if battery_power is not None else None, + applied=applied, + ) + + async def _write_plan(self, writes: tuple[RegisterWrite, ...]) -> bool: + self._client.unit_id = self._slave_id + ok = True + for write in writes: + ok = await self._client.async_write_register(write.address, write.value) and ok + return bool(ok) + + async def write_control(self, key: str, value: int) -> bool: + address_by_key = { + "storctl_mod": _ADDR_STORCTL_MOD, + "outwrte": _ADDR_OUTWRTE, + "inwrte": _ADDR_INWRTE, + "min_rsv_pct": _ADDR_MIN_RSV_PCT, + } + address = address_by_key.get(key) + if address is None: + return False + wire_value = int(value) + if key in {"outwrte", "inwrte"}: + wire_value = _int16_word(wire_value) + self._client.unit_id = self._slave_id + return await self._client.async_write_register(address, wire_value) + + def net_power_from_data(self, data: dict) -> Optional[int]: + try: + mode = int(round(float(data["storctl_mod"]))) + outwrte = int(round(float(data["outwrte"]))) + inwrte = int(round(float(data["inwrte"]))) + except (KeyError, TypeError, ValueError): + return None + if mode != _MODE_STORAGE_CONTROL: + return None + if outwrte == _NEUTRAL_WINDOW_WORD and inwrte == _NEUTRAL_WINDOW_WORD: + return 0 + + wcha = data.get("wcha_max") or data.get("max_charge_power") or self._wcha_max_w + try: + wcha_w = float(wcha) + except (TypeError, ValueError): + return None + if wcha_w <= 0: + return None + + inout_sf = data.get("inoutwrte_sf", self._last_inout_sf) + denom = _rate_denominator(int(inout_sf)) + if outwrte < 0 and inwrte > 0: + pct = max(0, inwrte - 1) + return int(round((pct / denom) * wcha_w)) + if outwrte > 0 and inwrte < 0: + pct = max(0, abs(inwrte) - 1) + return -int(round((pct / denom) * wcha_w)) + return None + + @property + def control_dependency_keys(self) -> frozenset: + return frozenset({ + "storctl_mod", + "outwrte", + "inwrte", + "inoutwrte_sf", + "max_charge_power", + "max_discharge_power", + "battery_power", + "commanded_net_power", + }) + + async def apply_config( + self, + *, + max_soc_pct: float, + min_soc_pct: float, + max_charge_power_w: int, + max_discharge_power_w: int, + ) -> bool: + """Keep initial setup non-invasive for the Fronius storage controller. + + Omnibattery enforces max/min SOC and software power ceilings itself for + this driver. We deliberately do not rewrite MinRsvPct or grid-charge + flags during setup/reload. + """ + self._max_soc_pct = max(0.0, min(100.0, float(max_soc_pct))) + self._min_soc_pct = max(0.0, min(100.0, float(min_soc_pct))) + self._max_charge_w = max(0, int(max_charge_power_w)) + self._max_discharge_w = max(0, int(max_discharge_power_w)) + return True + + async def set_charge_cutoff(self, soc_pct: float) -> bool: + _ = soc_pct + return False + + async def standby(self) -> bool: + """Return control to Fronius automatic mode for integration unload.""" + if not self.connected: + return False + self._last_net_power_w = 0 + return await self._write_plan(plan_reset_to_auto()) + + @classmethod + async def probe( + cls, + host: str, + port: int = 502, + slave_id: int = 1, + ) -> tuple[bool, dict[str, int]]: + """Probe a GEN24 storage block and return detected power ceilings.""" + driver = cls(host, port, slave_id) + try: + if not await driver.connect(): + return False, {} + snapshot = await driver.read_telemetry(["battery_soc", "max_charge_power"]) + max_power = snapshot.get("max_charge_power") + caps: dict[str, int] = {} + if isinstance(max_power, (int, float)) and int(max_power) > 0: + caps["device_max_charge_power"] = int(max_power) + caps["device_max_discharge_power"] = int(max_power) + return bool(snapshot.get("battery_soc") is not None or max_power), caps + except Exception as err: + _LOGGER.debug("Fronius GEN24 probe failed for %s:%s slave %s: %s", + host, port, slave_id, err) + return False, {} + finally: + await driver.close() diff --git a/custom_components/omnibattery/frontend/marstek-panel.js b/custom_components/omnibattery/frontend/marstek-panel.js index 4b10fa49..ef7fc1a0 100644 --- a/custom_components/omnibattery/frontend/marstek-panel.js +++ b/custom_components/omnibattery/frontend/marstek-panel.js @@ -49,7 +49,7 @@ const I18N = { tabResumen: "Overview", tabBaterias: "Batteries", tabControl: "Control", moreInfo: "Show history", zoomReset: "All", - infoModel: "Model", infoSoftware: "Software", infoSerial: "Serial", infoInverter: "Inverter", infoPowerModule: "Power module", + infoManufacturer: "Manufacturer", infoModel: "Model", infoSoftware: "Software", infoSerial: "Serial", infoInverter: "Inverter", infoPowerModule: "Power module", placeholderMsg: "This view is coming in a future phase. For now, use the Overview view.", cardFlow: "Energy flow", cardSoc: "System status", cardDaily: "Energy today", cardWeekly: "Weekly energy", cardPower: "Power", cardSocToday: "SOC · today", @@ -134,7 +134,7 @@ const I18N = { tabResumen: "Resumen", tabBaterias: "Baterías", tabControl: "Control", moreInfo: "Ver histórico", zoomReset: "Todo", - infoModel: "Modelo", infoSoftware: "Software", infoSerial: "N.º serie", infoInverter: "Inversor", infoPowerModule: "Módulo de potencia", + infoManufacturer: "Fabricante", infoModel: "Modelo", infoSoftware: "Software", infoSerial: "N.º serie", infoInverter: "Inversor", infoPowerModule: "Módulo de potencia", placeholderMsg: "Esta vista llegará en una próxima fase. Por ahora, usa la vista Resumen.", cardFlow: "Flujo de energía", cardSoc: "Estado del sistema", cardDaily: "Energía hoy", cardWeekly: "Energía semanal", cardPower: "Potencias", cardSocToday: "SOC · hoy", @@ -219,7 +219,7 @@ const I18N = { tabResumen: "Resum", tabBaterias: "Bateries", tabControl: "Control", moreInfo: "Veure històric", zoomReset: "Tot", - infoModel: "Model", infoSoftware: "Programari", infoSerial: "Núm. sèrie", infoInverter: "Inversor", infoPowerModule: "Mòdul de potència", + infoManufacturer: "Fabricant", infoModel: "Model", infoSoftware: "Programari", infoSerial: "Núm. sèrie", infoInverter: "Inversor", infoPowerModule: "Mòdul de potència", placeholderMsg: "Aquesta vista arribarà en una fase futura. De moment, fes servir la vista Resum.", cardFlow: "Flux d'energia", cardSoc: "Estat del sistema", cardDaily: "Energia avui", cardWeekly: "Energia setmanal", cardPower: "Potències", cardSocToday: "SOC · avui", @@ -300,7 +300,7 @@ const I18N = { tabResumen: "Übersicht", tabBaterias: "Batterien", tabControl: "Steuerung", moreInfo: "Verlauf anzeigen", zoomReset: "Alles", - infoModel: "Modell", infoSoftware: "Software", infoSerial: "Seriennr.", infoInverter: "Wechselrichter", infoPowerModule: "Leistungsmodul", + infoManufacturer: "Hersteller", infoModel: "Modell", infoSoftware: "Software", infoSerial: "Seriennr.", infoInverter: "Wechselrichter", infoPowerModule: "Leistungsmodul", placeholderMsg: "Diese Ansicht kommt in einer späteren Phase. Nutze vorerst die Übersicht.", cardFlow: "Energiefluss", cardSoc: "Systemstatus", cardDaily: "Energie heute", cardWeekly: "Wochenenergie", cardPower: "Leistung", cardSocToday: "SOC · heute", @@ -381,7 +381,7 @@ const I18N = { tabResumen: "Résumé", tabBaterias: "Batteries", tabControl: "Contrôle", moreInfo: "Voir l'historique", zoomReset: "Tout", - infoModel: "Modèle", infoSoftware: "Logiciel", infoSerial: "N° série", infoInverter: "Onduleur", infoPowerModule: "Module de puissance", + infoManufacturer: "Fabricant", infoModel: "Modèle", infoSoftware: "Logiciel", infoSerial: "N° série", infoInverter: "Onduleur", infoPowerModule: "Module de puissance", placeholderMsg: "Cette vue arrivera dans une phase ultérieure. Pour l'instant, utilisez la vue Résumé.", cardFlow: "Flux d'énergie", cardSoc: "État du système", cardDaily: "Énergie aujourd'hui", cardWeekly: "Énergie hebdomadaire", cardPower: "Puissances", cardSocToday: "SOC · aujourd'hui", @@ -462,7 +462,7 @@ const I18N = { tabResumen: "Overzicht", tabBaterias: "Batterijen", tabControl: "Bediening", moreInfo: "Geschiedenis tonen", zoomReset: "Alles", - infoModel: "Model", infoSoftware: "Software", infoSerial: "Serienr.", infoInverter: "Omvormer", infoPowerModule: "Vermogensmodule", + infoManufacturer: "Fabrikant", infoModel: "Model", infoSoftware: "Software", infoSerial: "Serienr.", infoInverter: "Omvormer", infoPowerModule: "Vermogensmodule", placeholderMsg: "Deze weergave komt in een latere fase. Gebruik voorlopig het Overzicht.", cardFlow: "Energiestroom", cardSoc: "Systeemstatus", cardDaily: "Energie vandaag", cardWeekly: "Energie per week", cardPower: "Vermogen", cardSocToday: "SOC · vandaag", @@ -5340,7 +5340,7 @@ class MarstekVenusPanel extends HTMLElement { name, // model label rides on the battery_soc entity attributes (device-registry // model is hardcoded "Venus"): Marstek version / Zendure product. - model: (socObj.attributes && socObj.attributes.model) || null, + model: (socObj.attributes && socObj.attributes.model) || (devReg && devReg.model) || null, soc: this._num(socObj), // Net cell flow (+charge / -discharge). On Venus A/D this includes MPPT; // on AC-only units it remains the inverse of ac_power. @@ -5376,9 +5376,10 @@ class MarstekVenusPanel extends HTMLElement { entIds: idByTk, entIdsDomain: idByTkDomain, info: { + manufacturer: (socObj.attributes && socObj.attributes.manufacturer) || (devReg && devReg.manufacturer) || null, sw: this._sval(byTk[K.softwareVersion]), // Huawei publishes the serial as a sensor; the registry entry has none. - serial: (devReg && devReg.serial_number) || this._sval(byTk[K.powerModuleSerial]), + serial: (socObj.attributes && socObj.attributes.serial) || (devReg && devReg.serial_number) || this._sval(byTk[K.powerModuleSerial]), powerModuleFw: this._sval(byTk[K.powerModuleFirmware]), inverterModel: this._sval(byTk[K.deviceName]), inverterSn: this._sval(byTk[K.inverterSerial]), @@ -5794,6 +5795,7 @@ class MarstekVenusPanel extends HTMLElement { if (val != null && val !== "") rows.push(`
${label}${val}
`); }; + addRow(this._t("infoManufacturer"), b.info.manufacturer); addRow(this._t("infoModel"), b.model); addRow(this._t("infoSoftware"), b.info.sw); addRow("BMS", b.info.bms); diff --git a/custom_components/omnibattery/infra/coordinator.py b/custom_components/omnibattery/infra/coordinator.py index eebc129b..9aec7eb5 100644 --- a/custom_components/omnibattery/infra/coordinator.py +++ b/custom_components/omnibattery/infra/coordinator.py @@ -27,6 +27,7 @@ from ..drivers.sessy import SessyLocalDriver from ..drivers.hoymiles import HoymilesMqttDriver from ..drivers.huawei import HuaweiSolarDriver +from ..drivers.fronius_gen24 import FroniusGen24Driver from ..drivers.base import SetpointResult from .alarm_notifier import AlarmNotifier from .mac_tracking import normalise_mac @@ -158,7 +159,7 @@ def __init__(self, hass: HomeAssistant, name: str, host: str, port: int, consump self.brand = brand self.ems_version = ems_version self.zendure_model = zendure_model - if self.brand in ("zendure", "anker", "hoymiles", "huawei"): + if self.brand in ("zendure", "anker", "hoymiles", "huawei", "fronius_gen24"): full_charge_voltage_taper_enabled = False # Validate and store battery version @@ -327,6 +328,14 @@ def __init__(self, hass: HomeAssistant, name: str, host: str, port: int, consump max_charge_power_w=self.configured_max_charge_power, max_discharge_power_w=self.configured_max_discharge_power, ) + elif self.brand == "fronius_gen24": + self.driver = FroniusGen24Driver( + self.host, + self.port, + self.slave_id, + max_charge_power_w=self.configured_max_charge_power, + max_discharge_power_w=self.configured_max_discharge_power, + ) else: self.driver = MarstekModbusDriver( self.host, self.port, self.battery_version, self.slave_id, @@ -626,6 +635,7 @@ def battery_device_info(self) -> dict: else "Sessy" if self.brand == "sessy" else "Hoymiles" if self.brand == "hoymiles" else "Huawei" if self.brand == "huawei" + else "Fronius" if self.brand == "fronius_gen24" else "Marstek" ), "model": self.driver.model_label or ( @@ -633,6 +643,7 @@ def battery_device_info(self) -> dict: else "Solarbank Max AC" if self.brand == "anker" else "Sessy" if self.brand == "sessy" else "MS-A2" if self.brand == "hoymiles" + else "GEN24 / BYD" if self.brand == "fronius_gen24" else "Venus" ), } @@ -641,6 +652,17 @@ def battery_device_info(self) -> dict: serial = getattr(getattr(self, "driver", None), "serial", None) if serial: info["serial_number"] = str(serial) + if self.brand == "fronius_gen24": + data = self.data or {} + manufacturer = data.get("fronius_storage_manufacturer") + model = data.get("fronius_storage_model") + serial = data.get("fronius_storage_serial") + if isinstance(manufacturer, str) and manufacturer: + info["manufacturer"] = manufacturer + if isinstance(model, str) and model: + info["model"] = model + if isinstance(serial, str) and serial: + info["serial_number"] = serial # getattr: several tests build a stub coordinator and read this # property off it, so the attribute cannot be assumed present. if getattr(self, "mac", None): diff --git a/custom_components/omnibattery/sensor.py b/custom_components/omnibattery/sensor.py index 738c313d..3d57e12d 100644 --- a/custom_components/omnibattery/sensor.py +++ b/custom_components/omnibattery/sensor.py @@ -362,8 +362,19 @@ def extra_state_attributes(self): """ if self.definition["key"] != "battery_soc": return None - model = getattr(self.coordinator.driver, "model_label", None) - return {"model": model} if model else None + data = self.coordinator.data or {} + model = data.get("fronius_storage_model") or getattr(self.coordinator.driver, "model_label", None) + attrs = {} + if model: + attrs["model"] = model + for attr, key in ( + ("manufacturer", "fronius_storage_manufacturer"), + ("serial", "fronius_storage_serial"), + ): + value = data.get(key) + if value: + attrs[attr] = value + return attrs or None @property def device_info(self): diff --git a/custom_components/omnibattery/strings.json b/custom_components/omnibattery/strings.json index 635abcc7..f0b2bdfe 100644 --- a/custom_components/omnibattery/strings.json +++ b/custom_components/omnibattery/strings.json @@ -94,7 +94,7 @@ "brand": "Brand" }, "data_description": { - "brand": "Marstek Venus: Modbus TCP. Zendure SolarFlow: local HTTP (HEMS must be disabled for Omnibattery control). Anker SOLIX Solarbank Max AC / 4 E5000 Pro: Modbus TCP (enable Modbus TCP under Third-Party Control in the Anker app; only one Modbus client at a time). Sessy: local HTTP." + "brand": "Marstek Venus: Modbus TCP. Zendure SolarFlow: local HTTP (HEMS must be disabled for Omnibattery control). Anker SOLIX Solarbank Max AC / 4 E5000 Pro: Modbus TCP (enable Modbus TCP under Third-Party Control in the Anker app; only one Modbus client at a time). Sessy: local HTTP. Fronius GEN24 / BYD: Modbus TCP storage control." } }, "battery_connection": { @@ -134,8 +134,20 @@ "battery_connection_sessy": { "title": "Configure battery {battery_num} — Connection (Sessy)", "description": "Enter the connection details and the local credentials printed on the Sessy dongle for battery {battery_num}.", - "data": {"name": "Name", "host": "IP Address", "port": "HTTP Port", "username": "Username", "password": "Password"}, - "data_description": {"name": "Descriptive name to identify this battery", "host": "IP address of the Sessy device on your local network", "port": "HTTP port (default 80)", "username": "Local username printed on the Sessy dongle", "password": "Local password printed on the Sessy dongle"} + "data": { + "name": "Name", + "host": "IP Address", + "port": "HTTP Port", + "username": "Username", + "password": "Password" + }, + "data_description": { + "name": "Descriptive name to identify this battery", + "host": "IP address of the Sessy device on your local network", + "port": "HTTP port (default 80)", + "username": "Local username printed on the Sessy dongle", + "password": "Local password printed on the Sessy dongle" + } }, "battery_connection_huawei": { "title": "Configure battery {battery_num} — Connection (Huawei)", @@ -167,8 +179,15 @@ "battery_connection_hoymiles": { "title": "Configure battery {battery_num} — Connection (Hoymiles)", "description": "Enable MQTT Service in S-Miles Home and point it to Home Assistant's configured MQTT broker before continuing.", - "data": {"name": "Name", "device_id": "MQTT device ID", "hoymiles_model": "Battery model"}, - "data_description": {"device_id": "Hoymiles MQTT device ID, for example MSA-280024341346.", "hoymiles_model": "Use automatic discovery unless the device publishes an incorrect or generic model."} + "data": { + "name": "Name", + "device_id": "MQTT device ID", + "hoymiles_model": "Battery model" + }, + "data_description": { + "device_id": "Hoymiles MQTT device ID, for example MSA-280024341346.", + "hoymiles_model": "Use automatic discovery unless the device publishes an incorrect or generic model." + } }, "battery_connection_esphome": { "title": "Configure battery {battery_num} — Connection (LilyGo/ESPHome)", @@ -620,8 +639,20 @@ "reconfigure_battery_sessy": { "title": "Reconfigure battery {battery_num} — Connection (Sessy)", "description": "Update connection details for the Sessy battery {battery_num}. All other settings are preserved.", - "data": {"name": "Name", "host": "IP Address", "port": "HTTP Port", "username": "Username", "password": "Password"}, - "data_description": {"name": "Descriptive name to identify this battery", "host": "IP address of the Sessy device on your local network", "port": "HTTP port (default 80)", "username": "Local username printed on the Sessy dongle", "password": "Local password printed on the Sessy dongle"} + "data": { + "name": "Name", + "host": "IP Address", + "port": "HTTP Port", + "username": "Username", + "password": "Password" + }, + "data_description": { + "name": "Descriptive name to identify this battery", + "host": "IP address of the Sessy device on your local network", + "port": "HTTP port (default 80)", + "username": "Local username printed on the Sessy dongle", + "password": "Local password printed on the Sessy dongle" + } }, "reconfigure_battery_anker": { "title": "Reconfigure battery {battery_num} — Connection (Anker)", @@ -669,8 +700,14 @@ "reconfigure_battery_hoymiles": { "title": "Reconfigure battery {battery_num} — Hoymiles MQTT", "description": "Enter the Hoymiles MQTT device ID; Home Assistant manages the broker and Omnibattery detects the model.", - "data": {"name": "Name", "device_id": "MQTT device ID", "hoymiles_model": "Battery model"}, - "data_description": {"hoymiles_model": "Use automatic discovery unless the device publishes an incorrect or generic model."} + "data": { + "name": "Name", + "device_id": "MQTT device ID", + "hoymiles_model": "Battery model" + }, + "data_description": { + "hoymiles_model": "Use automatic discovery unless the device publishes an incorrect or generic model." + } }, "reconfigure_battery_esphome": { "title": "Reconfigure battery {battery_num} — Connection (LilyGo/ESPHome)", @@ -690,6 +727,38 @@ "data": { "restore": "Restore previous configuration" } + }, + "battery_connection_fronius_gen24": { + "title": "Configure battery {battery_num} — Connection (Fronius GEN24 / BYD)", + "description": "Enter the Fronius GEN24 Modbus TCP endpoint for BYD battery {battery_num}. Modbus TCP and storage control must be enabled on the inverter.", + "data": { + "name": "Name", + "host": "IP Address", + "port": "Modbus Port", + "slave_id": "Modbus Slave ID" + }, + "data_description": { + "name": "Descriptive name to identify this battery", + "host": "IP address of the Fronius GEN24 inverter", + "port": "Modbus TCP port (default 502)", + "slave_id": "Inverter storage slave/unit id (default 1)" + } + }, + "reconfigure_battery_fronius_gen24": { + "title": "Reconfigure battery {battery_num} — Connection (Fronius GEN24 / BYD)", + "description": "Update connection details for the Fronius GEN24 / BYD battery {battery_num}. All other settings are preserved.", + "data": { + "name": "Name", + "host": "IP Address", + "port": "Modbus Port", + "slave_id": "Modbus Slave ID" + }, + "data_description": { + "name": "Descriptive name to identify this battery", + "host": "New IP address of the Fronius GEN24 inverter", + "port": "Modbus TCP port (default 502)", + "slave_id": "Inverter storage slave/unit id (default 1)" + } } }, "error": { @@ -823,7 +892,7 @@ "brand": "Brand" }, "data_description": { - "brand": "Marstek Venus: Modbus TCP. Zendure SolarFlow: local HTTP (HEMS must be disabled for Omnibattery control). Anker SOLIX Solarbank Max AC / 4 E5000 Pro: Modbus TCP (enable Modbus TCP under Third-Party Control in the Anker app; only one Modbus client at a time). Sessy: local HTTP." + "brand": "Marstek Venus: Modbus TCP. Zendure SolarFlow: local HTTP (HEMS must be disabled for Omnibattery control). Anker SOLIX Solarbank Max AC / 4 E5000 Pro: Modbus TCP (enable Modbus TCP under Third-Party Control in the Anker app; only one Modbus client at a time). Sessy: local HTTP. Fronius GEN24 / BYD: Modbus TCP storage control." } }, "battery_connection": { @@ -1276,8 +1345,20 @@ "battery_connection_sessy": { "title": "Configure battery {battery_num} — Connection (Sessy)", "description": "Modify the connection details and local dongle credentials for the Sessy battery {battery_num}.", - "data": {"name": "Name", "host": "IP Address", "port": "HTTP Port", "username": "Username", "password": "Password"}, - "data_description": {"name": "Descriptive name to identify this battery", "host": "IP address of the Sessy device on your local network", "port": "HTTP port (default 80)", "username": "Local username printed on the Sessy dongle", "password": "Local password printed on the Sessy dongle"} + "data": { + "name": "Name", + "host": "IP Address", + "port": "HTTP Port", + "username": "Username", + "password": "Password" + }, + "data_description": { + "name": "Descriptive name to identify this battery", + "host": "IP address of the Sessy device on your local network", + "port": "HTTP port (default 80)", + "username": "Local username printed on the Sessy dongle", + "password": "Local password printed on the Sessy dongle" + } }, "battery_connection_huawei": { "title": "Configure battery {battery_num} — Connection (Huawei)", @@ -1305,6 +1386,22 @@ "port": "Modbus TCP port (default 502)", "slave_id": "Modbus slave/unit id (default 1)" } + }, + "battery_connection_fronius_gen24": { + "title": "Configure battery {battery_num} — Connection (Fronius GEN24 / BYD)", + "description": "Modify the Fronius GEN24 Modbus TCP endpoint for BYD battery {battery_num}. Modbus TCP and storage control must be enabled on the inverter.", + "data": { + "name": "Name", + "host": "IP Address", + "port": "Modbus Port", + "slave_id": "Modbus Slave ID" + }, + "data_description": { + "name": "Descriptive name to identify this battery", + "host": "IP address of the Fronius GEN24 inverter", + "port": "Modbus TCP port (default 502)", + "slave_id": "Inverter storage slave/unit id (default 1)" + } } }, "error": { @@ -1943,6 +2040,9 @@ }, "input_limit": { "name": "Input Limit" + }, + "battery_current": { + "name": "Battery Current" } }, "binary_sensor": { diff --git a/custom_components/omnibattery/switch.py b/custom_components/omnibattery/switch.py index 7d54bdfc..ea4b9b14 100644 --- a/custom_components/omnibattery/switch.py +++ b/custom_components/omnibattery/switch.py @@ -79,7 +79,7 @@ async def async_setup_entry( entities.append(BatteryManualModeSwitch(hass, entry, controller, coordinator)) # Marstek-only cell maintenance: voltage taper needs per-cell # voltages that Anker/Zendure do not expose in the same way. - if coordinator.brand not in ("zendure", "anker"): + if coordinator.brand not in ("zendure", "anker", "fronius_gen24"): entities.append(BatteryFullChargeVoltageTaperSwitch(hass, entry, controller, coordinator)) # Add manual mode switch (system-level, always present) diff --git a/custom_components/omnibattery/translations/de.json b/custom_components/omnibattery/translations/de.json index e9ad2783..b8e327c3 100644 --- a/custom_components/omnibattery/translations/de.json +++ b/custom_components/omnibattery/translations/de.json @@ -76,7 +76,9 @@ "phase_assignments": { "title": "Phasenzuordnung Batterie {battery_num}", "description": "Wählen Sie die physische Phase, an die {battery_name} angeschlossen ist, oder Nicht zugewiesen, wenn keine geschützte Phase verwendet wird.", - "data": {"battery_phase": "Physische Batteriephase"} + "data": { + "battery_phase": "Physische Batteriephase" + } }, "batteries": { "title": "Anzahl der Batterien", @@ -92,7 +94,7 @@ "brand": "Batteriemarke" }, "data_description": { - "brand": "Marstek Venus: Modbus TCP. Zendure SolarFlow: local HTTP (HEMS must be disabled for Omnibattery control). Anker SOLIX Solarbank Max AC / 4 E5000 Pro: Modbus TCP (enable Modbus TCP under Third-Party Control in the Anker app; only one Modbus client at a time). Sessy: local HTTP." + "brand": "Marstek Venus: Modbus TCP. Zendure SolarFlow: local HTTP (HEMS must be disabled for Omnibattery control). Anker SOLIX Solarbank Max AC / 4 E5000 Pro: Modbus TCP (enable Modbus TCP under Third-Party Control in the Anker app; only one Modbus client at a time). Sessy: local HTTP. Fronius GEN24 / BYD: Modbus TCP Storage Control." } }, "battery_connection": { @@ -132,8 +134,15 @@ "battery_connection_hoymiles": { "title": "Batterie {battery_num} konfigurieren — Verbindung (Hoymiles)", "description": "Aktivieren Sie MQTT Service in S-Miles Home und verwenden Sie den in Home Assistant konfigurierten MQTT-Broker.", - "data": {"name": "Name", "device_id": "MQTT-Geräte-ID", "hoymiles_model": "Batteriemodell"}, - "data_description": {"device_id": "Hoymiles MQTT-ID, zum Beispiel MSA-280024341346.", "hoymiles_model": "Verwenden Sie die automatische Erkennung, sofern das Gerät kein falsches oder generisches Modell veröffentlicht."} + "data": { + "name": "Name", + "device_id": "MQTT-Geräte-ID", + "hoymiles_model": "Batteriemodell" + }, + "data_description": { + "device_id": "Hoymiles MQTT-ID, zum Beispiel MSA-280024341346.", + "hoymiles_model": "Verwenden Sie die automatische Erkennung, sofern das Gerät kein falsches oder generisches Modell veröffentlicht." + } }, "battery_connection_esphome": { "title": "Batterie {battery_num} konfigurieren — Verbindung (LilyGo/ESPHome)", @@ -589,8 +598,20 @@ "reconfigure_battery_sessy": { "title": "Batterie {battery_num} neu konfigurieren — Verbindung (Sessy)", "description": "Aktualisieren Sie die Verbindungsdaten für die Sessy-Batterie {battery_num}. Alle übrigen Einstellungen bleiben erhalten.", - "data": {"name": "Name", "host": "IP-Adresse", "port": "HTTP-Port", "username": "Benutzername", "password": "Passwort"}, - "data_description": {"name": "Beschreibender Name zur Identifizierung dieser Batterie", "host": "IP-Adresse des Sessy-Geräts im lokalen Netzwerk", "port": "HTTP-Port (Standard 80)", "username": "Auf dem Sessy-Dongle aufgedruckter lokaler Benutzername", "password": "Auf dem Sessy-Dongle aufgedrucktes lokales Passwort"} + "data": { + "name": "Name", + "host": "IP-Adresse", + "port": "HTTP-Port", + "username": "Benutzername", + "password": "Passwort" + }, + "data_description": { + "name": "Beschreibender Name zur Identifizierung dieser Batterie", + "host": "IP-Adresse des Sessy-Geräts im lokalen Netzwerk", + "port": "HTTP-Port (Standard 80)", + "username": "Auf dem Sessy-Dongle aufgedruckter lokaler Benutzername", + "password": "Auf dem Sessy-Dongle aufgedrucktes lokales Passwort" + } }, "reconfigure_battery_anker": { "title": "Reconfigure battery {battery_num} — Connection (Anker)", @@ -638,8 +659,14 @@ "reconfigure_battery_hoymiles": { "title": "Batterie {battery_num} neu konfigurieren — Hoymiles MQTT", "description": "Geben Sie die Hoymiles MQTT-Geräte-ID ein; Home Assistant verwaltet den Broker und Omnibattery erkennt das Modell.", - "data": {"name": "Name", "device_id": "MQTT-Geräte-ID", "hoymiles_model": "Batteriemodell"}, - "data_description": {"hoymiles_model": "Verwenden Sie die automatische Erkennung, sofern das Gerät kein falsches oder generisches Modell veröffentlicht."} + "data": { + "name": "Name", + "device_id": "MQTT-Geräte-ID", + "hoymiles_model": "Batteriemodell" + }, + "data_description": { + "hoymiles_model": "Verwenden Sie die automatische Erkennung, sofern das Gerät kein falsches oder generisches Modell veröffentlicht." + } }, "reconfigure_battery_esphome": { "title": "Batterie {battery_num} neu konfigurieren — Verbindung (LilyGo/ESPHome)", @@ -663,8 +690,20 @@ "battery_connection_sessy": { "title": "Batterie {battery_num} konfigurieren — Verbindung (Sessy)", "description": "Geben Sie die Verbindungsdaten und die auf dem Sessy-Dongle aufgedruckten lokalen Zugangsdaten für Batterie {battery_num} ein.", - "data": {"name": "Name", "host": "IP-Adresse", "port": "HTTP-Port", "username": "Benutzername", "password": "Passwort"}, - "data_description": {"name": "Beschreibender Name zur Identifizierung dieser Batterie", "host": "IP-Adresse des Sessy-Geräts im lokalen Netzwerk", "port": "HTTP-Port (Standard 80)", "username": "Auf dem Sessy-Dongle aufgedruckter lokaler Benutzername", "password": "Auf dem Sessy-Dongle aufgedrucktes lokales Passwort"} + "data": { + "name": "Name", + "host": "IP-Adresse", + "port": "HTTP-Port", + "username": "Benutzername", + "password": "Passwort" + }, + "data_description": { + "name": "Beschreibender Name zur Identifizierung dieser Batterie", + "host": "IP-Adresse des Sessy-Geräts im lokalen Netzwerk", + "port": "HTTP-Port (Standard 80)", + "username": "Auf dem Sessy-Dongle aufgedruckter lokaler Benutzername", + "password": "Auf dem Sessy-Dongle aufgedrucktes lokales Passwort" + } }, "battery_connection_huawei": { "title": "Batterie {battery_num} einrichten — Verbindung (Huawei)", @@ -692,6 +731,38 @@ "port": "Modbus TCP port (default 502)", "slave_id": "Modbus slave/unit id (default 1)" } + }, + "battery_connection_fronius_gen24": { + "title": "Batterie {battery_num} konfigurieren — Verbindung (Fronius GEN24 / BYD)", + "description": "Geben Sie den Fronius-GEN24-Modbus-TCP-Endpunkt für BYD-Batterie {battery_num} ein. Modbus TCP und Storage Control müssen am Wechselrichter aktiviert sein.", + "data": { + "name": "Name", + "host": "IP-Adresse", + "port": "Modbus-Port", + "slave_id": "Modbus-Slave-ID" + }, + "data_description": { + "name": "Beschreibender Name zur Identifizierung dieser Batterie", + "host": "IP-Adresse des Fronius GEN24 Wechselrichters", + "port": "Modbus-TCP-Port (Standard 502)", + "slave_id": "Storage-Slave-/Unit-ID des Wechselrichters (Standard 1)" + } + }, + "reconfigure_battery_fronius_gen24": { + "title": "Batterie {battery_num} neu konfigurieren — Verbindung (Fronius GEN24 / BYD)", + "description": "Aktualisieren Sie die Verbindungsdaten für die Fronius-GEN24-/BYD-Batterie {battery_num}. Alle übrigen Einstellungen bleiben erhalten.", + "data": { + "name": "Name", + "host": "IP-Adresse", + "port": "Modbus-Port", + "slave_id": "Modbus-Slave-ID" + }, + "data_description": { + "name": "Beschreibender Name zur Identifizierung dieser Batterie", + "host": "Neue IP-Adresse des Fronius GEN24 Wechselrichters", + "port": "Modbus-TCP-Port (Standard 502)", + "slave_id": "Storage-Slave-/Unit-ID des Wechselrichters (Standard 1)" + } } }, "error": { @@ -761,7 +832,9 @@ "phase_assignments": { "title": "Phasenzuordnung Batterie {battery_num}", "description": "Wählen Sie die physische Phase, an die {battery_name} angeschlossen ist, oder Nicht zugewiesen, wenn keine geschützte Phase verwendet wird.", - "data": {"battery_phase": "Physische Batteriephase"} + "data": { + "battery_phase": "Physische Batteriephase" + } }, "init": { "title": "Omnibattery neu konfigurieren", @@ -820,7 +893,7 @@ "brand": "Batteriemarke" }, "data_description": { - "brand": "Marstek Venus: Modbus TCP. Zendure SolarFlow: local HTTP (HEMS must be disabled for Omnibattery control). Anker SOLIX Solarbank Max AC / 4 E5000 Pro: Modbus TCP (enable Modbus TCP under Third-Party Control in the Anker app; only one Modbus client at a time). Sessy: local HTTP." + "brand": "Marstek Venus: Modbus TCP. Zendure SolarFlow: local HTTP (HEMS must be disabled for Omnibattery control). Anker SOLIX Solarbank Max AC / 4 E5000 Pro: Modbus TCP (enable Modbus TCP under Third-Party Control in the Anker app; only one Modbus client at a time). Sessy: local HTTP. Fronius GEN24 / BYD: Modbus TCP Storage Control." } }, "battery_connection": { @@ -1277,8 +1350,20 @@ "battery_connection_sessy": { "title": "Batterie {battery_num} konfigurieren — Verbindung (Sessy)", "description": "Ändern Sie die Verbindungsdaten und lokalen Dongle-Zugangsdaten für die Sessy-Batterie {battery_num}.", - "data": {"name": "Name", "host": "IP-Adresse", "port": "HTTP-Port", "username": "Benutzername", "password": "Passwort"}, - "data_description": {"name": "Beschreibender Name zur Identifizierung dieser Batterie", "host": "IP-Adresse des Sessy-Geräts im lokalen Netzwerk", "port": "HTTP-Port (Standard 80)", "username": "Auf dem Sessy-Dongle aufgedruckter lokaler Benutzername", "password": "Auf dem Sessy-Dongle aufgedrucktes lokales Passwort"} + "data": { + "name": "Name", + "host": "IP-Adresse", + "port": "HTTP-Port", + "username": "Benutzername", + "password": "Passwort" + }, + "data_description": { + "name": "Beschreibender Name zur Identifizierung dieser Batterie", + "host": "IP-Adresse des Sessy-Geräts im lokalen Netzwerk", + "port": "HTTP-Port (Standard 80)", + "username": "Auf dem Sessy-Dongle aufgedruckter lokaler Benutzername", + "password": "Auf dem Sessy-Dongle aufgedrucktes lokales Passwort" + } }, "battery_connection_huawei": { "title": "Batterie {battery_num} einrichten — Verbindung (Huawei)", @@ -1306,6 +1391,22 @@ "port": "Modbus TCP port (default 502)", "slave_id": "Modbus slave/unit id (default 1)" } + }, + "battery_connection_fronius_gen24": { + "title": "Batterie {battery_num} konfigurieren — Verbindung (Fronius GEN24 / BYD)", + "description": "Ändern Sie den Fronius-GEN24-Modbus-TCP-Endpunkt für BYD-Batterie {battery_num}. Modbus TCP und Storage Control müssen am Wechselrichter aktiviert sein.", + "data": { + "name": "Name", + "host": "IP-Adresse", + "port": "Modbus-Port", + "slave_id": "Modbus-Slave-ID" + }, + "data_description": { + "name": "Beschreibender Name zur Identifizierung dieser Batterie", + "host": "IP-Adresse des Fronius GEN24 Wechselrichters", + "port": "Modbus-TCP-Port (Standard 502)", + "slave_id": "Storage-Slave-/Unit-ID des Wechselrichters (Standard 1)" + } } }, "error": { @@ -1354,7 +1455,12 @@ } }, "battery_phase": { - "options": {"unassigned": "Nicht zugewiesen", "l1": "L1", "l2": "L2", "l3": "L3"} + "options": { + "unassigned": "Nicht zugewiesen", + "l1": "L1", + "l2": "L2", + "l3": "L3" + } }, "weekday": { "options": { @@ -1385,7 +1491,7 @@ "realtime_price": "Echtzeit-Preis (sofortiger Schwellenwert)" } }, - "price_integration_type": { + "price_integration_type": { "options": { "nordpool": "Nordpool", "pvpc": "PVPC (Spanien – ESIOS REE)", @@ -1939,6 +2045,9 @@ }, "input_limit": { "name": "Eingangslimit" + }, + "battery_current": { + "name": "Batteriestrom" } }, "binary_sensor": { diff --git a/custom_components/omnibattery/translations/en.json b/custom_components/omnibattery/translations/en.json index 99516c82..9819f804 100644 --- a/custom_components/omnibattery/translations/en.json +++ b/custom_components/omnibattery/translations/en.json @@ -94,7 +94,7 @@ "brand": "Brand" }, "data_description": { - "brand": "Marstek Venus: Modbus TCP. Zendure SolarFlow: local HTTP (HEMS must be disabled for Omnibattery control). Anker SOLIX Solarbank Max AC / 4 E5000 Pro: Modbus TCP (enable Modbus TCP under Third-Party Control in the Anker app; only one Modbus client at a time). Sessy: local HTTP." + "brand": "Marstek Venus: Modbus TCP. Zendure SolarFlow: local HTTP (HEMS must be disabled for Omnibattery control). Anker SOLIX Solarbank Max AC / 4 E5000 Pro: Modbus TCP (enable Modbus TCP under Third-Party Control in the Anker app; only one Modbus client at a time). Sessy: local HTTP. Fronius GEN24 / BYD: Modbus TCP storage control." } }, "battery_connection": { @@ -134,8 +134,15 @@ "battery_connection_hoymiles": { "title": "Configure battery {battery_num} — Connection (Hoymiles)", "description": "Enable MQTT Service in S-Miles Home and point it to Home Assistant's configured MQTT broker before continuing.", - "data": {"name": "Name", "device_id": "MQTT device ID", "hoymiles_model": "Battery model"}, - "data_description": {"device_id": "Hoymiles MQTT device ID, for example MSA-280024341346.", "hoymiles_model": "Use automatic discovery unless the device publishes an incorrect or generic model."} + "data": { + "name": "Name", + "device_id": "MQTT device ID", + "hoymiles_model": "Battery model" + }, + "data_description": { + "device_id": "Hoymiles MQTT device ID, for example MSA-280024341346.", + "hoymiles_model": "Use automatic discovery unless the device publishes an incorrect or generic model." + } }, "battery_connection_esphome": { "title": "Configure battery {battery_num} — Connection (LilyGo/ESPHome)", @@ -589,8 +596,20 @@ "reconfigure_battery_sessy": { "title": "Reconfigure battery {battery_num} — Connection (Sessy)", "description": "Update connection details for the Sessy battery {battery_num}. All other settings are preserved.", - "data": {"name": "Name", "host": "IP Address", "port": "HTTP Port", "username": "Username", "password": "Password"}, - "data_description": {"name": "Descriptive name to identify this battery", "host": "IP address of the Sessy device on your local network", "port": "HTTP port (default 80)", "username": "Local username printed on the Sessy dongle", "password": "Local password printed on the Sessy dongle"} + "data": { + "name": "Name", + "host": "IP Address", + "port": "HTTP Port", + "username": "Username", + "password": "Password" + }, + "data_description": { + "name": "Descriptive name to identify this battery", + "host": "IP address of the Sessy device on your local network", + "port": "HTTP port (default 80)", + "username": "Local username printed on the Sessy dongle", + "password": "Local password printed on the Sessy dongle" + } }, "reconfigure_battery_anker": { "title": "Reconfigure battery {battery_num} — Connection (Anker)", @@ -638,8 +657,14 @@ "reconfigure_battery_hoymiles": { "title": "Reconfigure battery {battery_num} — Hoymiles MQTT", "description": "Enter the Hoymiles MQTT device ID; Home Assistant manages the broker and Omnibattery detects the model.", - "data": {"name": "Name", "device_id": "MQTT device ID", "hoymiles_model": "Battery model"}, - "data_description": {"hoymiles_model": "Use automatic discovery unless the device publishes an incorrect or generic model."} + "data": { + "name": "Name", + "device_id": "MQTT device ID", + "hoymiles_model": "Battery model" + }, + "data_description": { + "hoymiles_model": "Use automatic discovery unless the device publishes an incorrect or generic model." + } }, "reconfigure_battery_esphome": { "title": "Reconfigure battery {battery_num} — Connection (LilyGo/ESPHome)", @@ -663,8 +688,20 @@ "battery_connection_sessy": { "title": "Configure battery {battery_num} — Connection (Sessy)", "description": "Enter the connection details and the local credentials printed on the Sessy dongle for battery {battery_num}.", - "data": {"name": "Name", "host": "IP Address", "port": "HTTP Port", "username": "Username", "password": "Password"}, - "data_description": {"name": "Descriptive name to identify this battery", "host": "IP address of the Sessy device on your local network", "port": "HTTP port (default 80)", "username": "Local username printed on the Sessy dongle", "password": "Local password printed on the Sessy dongle"} + "data": { + "name": "Name", + "host": "IP Address", + "port": "HTTP Port", + "username": "Username", + "password": "Password" + }, + "data_description": { + "name": "Descriptive name to identify this battery", + "host": "IP address of the Sessy device on your local network", + "port": "HTTP port (default 80)", + "username": "Local username printed on the Sessy dongle", + "password": "Local password printed on the Sessy dongle" + } }, "battery_connection_huawei": { "title": "Configure battery {battery_num} — Connection (Huawei)", @@ -692,6 +729,38 @@ "port": "Modbus TCP port (default 502)", "slave_id": "Modbus slave/unit id (default 1)" } + }, + "battery_connection_fronius_gen24": { + "title": "Configure battery {battery_num} — Connection (Fronius GEN24 / BYD)", + "description": "Enter the Fronius GEN24 Modbus TCP endpoint for BYD battery {battery_num}. Modbus TCP and storage control must be enabled on the inverter.", + "data": { + "name": "Name", + "host": "IP Address", + "port": "Modbus Port", + "slave_id": "Modbus Slave ID" + }, + "data_description": { + "name": "Descriptive name to identify this battery", + "host": "IP address of the Fronius GEN24 inverter", + "port": "Modbus TCP port (default 502)", + "slave_id": "Inverter storage slave/unit id (default 1)" + } + }, + "reconfigure_battery_fronius_gen24": { + "title": "Reconfigure battery {battery_num} — Connection (Fronius GEN24 / BYD)", + "description": "Update connection details for the Fronius GEN24 / BYD battery {battery_num}. All other settings are preserved.", + "data": { + "name": "Name", + "host": "IP Address", + "port": "Modbus Port", + "slave_id": "Modbus Slave ID" + }, + "data_description": { + "name": "Descriptive name to identify this battery", + "host": "New IP address of the Fronius GEN24 inverter", + "port": "Modbus TCP port (default 502)", + "slave_id": "Inverter storage slave/unit id (default 1)" + } } }, "error": { @@ -826,7 +895,7 @@ "brand": "Brand" }, "data_description": { - "brand": "Marstek Venus: Modbus TCP. Zendure SolarFlow: local HTTP (HEMS must be disabled for Omnibattery control). Anker SOLIX Solarbank Max AC / 4 E5000 Pro: Modbus TCP (enable Modbus TCP under Third-Party Control in the Anker app; only one Modbus client at a time). Sessy: local HTTP." + "brand": "Marstek Venus: Modbus TCP. Zendure SolarFlow: local HTTP (HEMS must be disabled for Omnibattery control). Anker SOLIX Solarbank Max AC / 4 E5000 Pro: Modbus TCP (enable Modbus TCP under Third-Party Control in the Anker app; only one Modbus client at a time). Sessy: local HTTP. Fronius GEN24 / BYD: Modbus TCP storage control." } }, "battery_connection": { @@ -1281,8 +1350,20 @@ "battery_connection_sessy": { "title": "Configure battery {battery_num} — Connection (Sessy)", "description": "Modify the connection details and local dongle credentials for the Sessy battery {battery_num}.", - "data": {"name": "Name", "host": "IP Address", "port": "HTTP Port", "username": "Username", "password": "Password"}, - "data_description": {"name": "Descriptive name to identify this battery", "host": "IP address of the Sessy device on your local network", "port": "HTTP port (default 80)", "username": "Local username printed on the Sessy dongle", "password": "Local password printed on the Sessy dongle"} + "data": { + "name": "Name", + "host": "IP Address", + "port": "HTTP Port", + "username": "Username", + "password": "Password" + }, + "data_description": { + "name": "Descriptive name to identify this battery", + "host": "IP address of the Sessy device on your local network", + "port": "HTTP port (default 80)", + "username": "Local username printed on the Sessy dongle", + "password": "Local password printed on the Sessy dongle" + } }, "battery_connection_huawei": { "title": "Configure battery {battery_num} — Connection (Huawei)", @@ -1310,6 +1391,22 @@ "port": "Modbus TCP port (default 502)", "slave_id": "Modbus slave/unit id (default 1)" } + }, + "battery_connection_fronius_gen24": { + "title": "Configure battery {battery_num} — Connection (Fronius GEN24 / BYD)", + "description": "Modify the Fronius GEN24 Modbus TCP endpoint for BYD battery {battery_num}. Modbus TCP and storage control must be enabled on the inverter.", + "data": { + "name": "Name", + "host": "IP Address", + "port": "Modbus Port", + "slave_id": "Modbus Slave ID" + }, + "data_description": { + "name": "Descriptive name to identify this battery", + "host": "IP address of the Fronius GEN24 inverter", + "port": "Modbus TCP port (default 502)", + "slave_id": "Inverter storage slave/unit id (default 1)" + } } }, "error": { @@ -1949,6 +2046,9 @@ }, "input_limit": { "name": "Input Limit" + }, + "battery_current": { + "name": "Battery Current" } }, "binary_sensor": { diff --git a/mkdocs.yml b/mkdocs.yml index 3eac883f..6316b777 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -68,6 +68,7 @@ plugins: Three-phase current protection: Protección de corriente trifásica Batteries: Baterías Hoymiles MQTT: Hoymiles MQTT + Fronius GEN24 / BYD: Fronius GEN24 / BYD Hoymiles MS-A2 setup: Instalación de Hoymiles MS-A2 Time slots: Franjas horarias Excluded devices: Dispositivos excluidos @@ -149,6 +150,7 @@ nav: - Anker SOLIX: configuration/batteries/anker.md - Sessy: configuration/batteries/sessy.md - Hoymiles MQTT: configuration/batteries/hoymiles.md + - Fronius GEN24 / BYD: configuration/batteries/fronius-gen24.md - Time slots: configuration/time-slots.md - Excluded devices: configuration/excluded-devices.md - Predictive charging: diff --git a/site-docs/configuration/batteries/fronius-gen24.es.md b/site-docs/configuration/batteries/fronius-gen24.es.md new file mode 100644 index 00000000..9731d161 --- /dev/null +++ b/site-docs/configuration/batteries/fronius-gen24.es.md @@ -0,0 +1,50 @@ +# Fronius GEN24 con BYD Battery-Box + +Omnibattery puede supervisar y controlar una BYD Battery-Box mediante la +interfaz de almacenamiento de un inversor Fronius GEN24. El controlador usa +Modbus TCP local para el control y la telemetría rápida, y la Solar API local +del inversor para el modelo, número de serie, temperatura, tensión, corriente y +capacidad de la batería BYD. + +## Requisitos + +- Un inversor Fronius GEN24 con almacenamiento BYD compatible +- Modbus TCP habilitado en el inversor +- Control de almacenamiento habilitado en el inversor +- Acceso desde Home Assistant al puerto TCP `502` y a la API HTTP del inversor + +Selecciona **Fronius GEN24 / BYD** e introduce el host del inversor, el puerto +Modbus y el ID de unidad SunSpec. Los valores predeterminados son el puerto +`502` y el ID `1`. + +## Representación SunSpec compatible + +Este controlador admite la representación SunSpec de **entero más factor de +escala (`int+SF`)**. Lee el bloque de control de almacenamiento y sus factores +de escala de los registros `40355`–`40378`; el modelo SunSpec de coma flotante +no se detecta automáticamente ni es compatible actualmente. + +## Límites de SOC + +Omnibattery aplica `min_soc` y `max_soc` mediante su bucle de control. En +particular, `max_soc` **no es un corte de hardware garantizado**: el GEN24 u +otra automatización puede seguir cargando desde la energía fotovoltaica y la +BYD puede superar el valor configurado. El inversor y el BMS conservan la +responsabilidad de sus límites físicos de seguridad. + +## Identidad y persistencia + +El número de serie físico de la BYD se lee desde +`GetStorageRealtimeData.cgi`. Omnibattery utiliza ese número para la copia de +seguridad de energía sintética, de modo que puede recuperar la energía +acumulada al eliminar y volver a añadir la batería aunque cambie la dirección +del inversor. Hasta obtener el número real, no se usa un sustituto derivado del +host. + +## Confirmación de consignas + +En la instalación GEN24/BYD usada para validar el controlador, los registros +escritos se podían leer tras una espera de `0,2 s`. La respuesta física de +potencia puede tardar más; Omnibattery anuncia una latencia de lectura de +`1,5 s` al controlador y sigue comprobando la potencia medida en las +actualizaciones normales de telemetría. diff --git a/site-docs/configuration/batteries/fronius-gen24.md b/site-docs/configuration/batteries/fronius-gen24.md new file mode 100644 index 00000000..1ee17451 --- /dev/null +++ b/site-docs/configuration/batteries/fronius-gen24.md @@ -0,0 +1,48 @@ +# Fronius GEN24 with BYD Battery-Box + +Omnibattery can monitor and control a BYD Battery-Box through the storage +interface of a Fronius GEN24 inverter. The driver uses local Modbus TCP for +control and fast telemetry, plus the inverter's local Solar API for BYD model, +serial number, temperature, voltage, current and capacity. + +## Requirements + +- A Fronius GEN24 inverter with compatible BYD storage +- Modbus TCP enabled on the inverter +- Storage control enabled on the inverter +- Network access from Home Assistant to TCP port `502` and the inverter's HTTP API + +Select **Fronius GEN24 / BYD**, then enter the inverter host, Modbus port and +SunSpec unit ID. The defaults are port `502` and unit ID `1`. + +## Supported SunSpec representation + +This driver supports the SunSpec **integer plus scale-factor (`int+SF`)** +register representation. It reads the storage-control block and its scale +factors from registers `40355`–`40378`; the floating-point SunSpec model is not +currently auto-detected or supported. If the connection test fails, verify the +SunSpec data-model setting on the inverter before changing the unit ID. + +## SOC limits + +`min_soc` and `max_soc` are enforced by Omnibattery's software control loop. +In particular, `max_soc` is **not a guaranteed hardware cutoff**: the GEN24 or +another automation may continue charging from PV and the BYD can therefore +rise above the configured value. The inverter and BMS remain responsible for +their hardware safety limits. + +## Identity and persistence + +The physical BYD serial number is read from +`GetStorageRealtimeData.cgi`. Omnibattery uses that serial for synthetic-energy +backup, so deleting and re-adding the battery can restore its accumulated +energy even when the inverter's address changes. Until the Solar API returns a +serial, no host-derived substitute is used. + +## Setpoint acknowledgement + +On the GEN24/BYD installation used to validate the driver, the written storage +control registers were readable after a `0.2 s` settle. Physical battery-power +response can lag the register acknowledgement; Omnibattery advertises a +`1.5 s` readback latency to its controller and continues checking measured +power on normal telemetry updates. diff --git a/site-docs/configuration/batteries/index.es.md b/site-docs/configuration/batteries/index.es.md index bbeadc78..42ae9a89 100644 --- a/site-docs/configuration/batteries/index.es.md +++ b/site-docs/configuration/batteries/index.es.md @@ -15,6 +15,7 @@ parte de los controles en tiempo de ejecución son comunes. | **Anker SOLIX** | Modbus TCP | [Anker SOLIX](anker.md) | | **Sessy** | API HTTP local mediante el dongle de Sessy | [Sessy](sessy.md) | | **Hoymiles MS-A2 / HiBattery** | MQTT mediante Home Assistant | [Hoymiles MQTT](hoymiles.md) | +| **Fronius GEN24 / BYD** | Modbus TCP y Solar API local | [Fronius GEN24 / BYD](fronius-gen24.md) | ![Selector de marca de batería](../../assets/screenshots/configuration/battery-brand-form.png){ width="650" style="display: block; margin: 0 auto;"} diff --git a/site-docs/configuration/batteries/index.md b/site-docs/configuration/batteries/index.md index f973cd5d..41ae3581 100644 --- a/site-docs/configuration/batteries/index.md +++ b/site-docs/configuration/batteries/index.md @@ -14,6 +14,7 @@ loop, dashboard, predictive charging and most runtime controls are shared. | **Anker SOLIX** | Modbus TCP | [Anker SOLIX](anker.md) | | **Sessy** | Local HTTP API through the Sessy dongle | [Sessy](sessy.md) | | **Hoymiles MS-A2 / HiBattery** | MQTT through Home Assistant | [Hoymiles MQTT](hoymiles.md) | +| **Fronius GEN24 / BYD** | Modbus TCP and local Solar API | [Fronius GEN24 / BYD](fronius-gen24.md) | ![Battery brand selector](../../assets/screenshots/configuration/battery-brand-form.png){ width="650" style="display: block; margin: 0 auto;"} diff --git a/tests/test_fronius_gen24_driver.py b/tests/test_fronius_gen24_driver.py new file mode 100644 index 00000000..fc12a4df --- /dev/null +++ b/tests/test_fronius_gen24_driver.py @@ -0,0 +1,521 @@ +"""Dry tests for the Fronius GEN24 / BYD OmniBattery driver. + +These tests intentionally avoid importing Home Assistant. The local Codex +environment used for this repository does not ship HA or pymodbus packages, so +the driver contract and Modbus client are stubbed just enough to load the driver +module and verify its register math. +""" + +from __future__ import annotations + +import asyncio +import importlib.util +import sys +import types +import unittest +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + + +@dataclass(frozen=True) +class DriverCapabilities: + hardware_soc_cutoff: bool + has_force_mode: bool + push_telemetry: bool + max_charge_power_w: int + max_discharge_power_w: int + min_charge_power_w: int = 0 + min_discharge_power_w: int = 0 + has_mppt_pv: bool = False + has_alarm_registers: bool = False + has_rs485_control: bool = False + has_energy_counters: bool = True + has_nominal_capacity: bool = True + cycles_from_discharge_only: bool = False + has_daily_energy_counters: bool = True + setpoint_confirm_reliable: bool = True + actuator_latency_s: float = 0.5 + readback_latency_s: Optional[float] = None + + +@dataclass(frozen=True) +class ReadGroup: + scan_interval: Optional[str] + keys: tuple[str, ...] + + +@dataclass(frozen=True) +class SetpointResult: + ok: bool + net_power_w: int + confirmed: bool + failure_reason: Optional[str] = None + exact: bool = True + battery_power_w: Optional[int] = None + applied: Optional[dict] = None + + +class BatteryDriver: + pass + + +class MarstekModbusClient: + def __init__(self, *args, **kwargs) -> None: + self.connected = False + self.unit_id = kwargs.get("slave_id", 1) + + +def decode_registers(regs, data_type: str = "uint16", bit_index: Optional[int] = None): + if not regs: + return None + if data_type == "int16": + value = int(regs[0]) + return value - 0x10000 if value >= 0x8000 else value + if data_type == "uint16": + return int(regs[0]) + raise ValueError(f"Unsupported data_type: {data_type}") + + +def _install_driver_stubs() -> None: + sys.modules.setdefault("custom_components", types.ModuleType("custom_components")) + sys.modules.setdefault("custom_components.omnibattery", types.ModuleType("custom_components.omnibattery")) + sys.modules.setdefault("custom_components.omnibattery.drivers", types.ModuleType("custom_components.omnibattery.drivers")) + sys.modules.setdefault("custom_components.omnibattery.infra", types.ModuleType("custom_components.omnibattery.infra")) + + base = types.ModuleType("custom_components.omnibattery.drivers.base") + base.BatteryDriver = BatteryDriver + base.DriverCapabilities = DriverCapabilities + base.ReadGroup = ReadGroup + base.SetpointResult = SetpointResult + base.TelemetrySnapshot = dict + sys.modules["custom_components.omnibattery.drivers.base"] = base + + modbus_client = types.ModuleType("custom_components.omnibattery.infra.modbus_client") + modbus_client.MarstekModbusClient = MarstekModbusClient + modbus_client.decode_registers = decode_registers + sys.modules["custom_components.omnibattery.infra.modbus_client"] = modbus_client + + +def _load_driver_module(): + module_name = "custom_components.omnibattery.drivers.fronius_gen24" + stub_names = ( + "custom_components", + "custom_components.omnibattery", + "custom_components.omnibattery.drivers", + "custom_components.omnibattery.infra", + "custom_components.omnibattery.drivers.base", + "custom_components.omnibattery.infra.modbus_client", + module_name, + ) + missing = object() + previous = {name: sys.modules.get(name, missing) for name in stub_names} + _install_driver_stubs() + module_path = ( + Path(__file__).resolve().parents[1] + / "custom_components" + / "omnibattery" + / "drivers" + / "fronius_gen24.py" + ) + spec = importlib.util.spec_from_file_location(module_name, module_path) + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + assert spec.loader is not None + try: + spec.loader.exec_module(module) + finally: + for name, original in previous.items(): + if original is missing: + sys.modules.pop(name, None) + else: + sys.modules[name] = original + return module + + +driver = _load_driver_module() + + +def word(value: int) -> int: + return value & 0xFFFF + + +class FakeClient: + def __init__(self) -> None: + self.connected = True + self.unit_id = None + self.writes: list[tuple[int, int]] = [] + + async def async_connect(self) -> bool: + self.connected = True + return True + + async def async_close(self) -> None: + self.connected = False + + def set_shutting_down(self, value: bool) -> None: + self.shutting_down = value + + async def async_read_block(self, address: int, count: int, block_key: str): + raise AssertionError("read-back is disabled in these dry tests") + + async def async_write_register(self, address: int, value: int) -> bool: + self.writes.append((address, value)) + return True + + +class FakeHttpResponse: + def __init__(self, payload: dict, status: int = 200) -> None: + self._payload = payload + self.status = status + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + return None + + async def json(self, content_type=None): + return self._payload + + +class FakeHttpSession: + def __init__(self, payload: dict) -> None: + self.payload = payload + self.closed = False + self.urls: list[str] = [] + + def get(self, url: str) -> FakeHttpResponse: + self.urls.append(url) + return FakeHttpResponse(self.payload) + + async def close(self) -> None: + self.closed = True + + +STORAGE_API_PAYLOAD = { + "Body": { + "Data": { + "0": { + "Controller": { + "Capacity_Maximum": 10240.0, + "Current_DC": -0.89710383800329652, + "DesignedCapacity": 10240.0, + "Details": { + "Manufacturer": "BYD", + "Model": "BYD Battery-Box Premium HV", + "Serial": "P030T020Z2112160742 ", + }, + "Enable": 1, + "StateOfCharge_Relative": 92.599998474121094, + "Temperature_Cell": 26.0, + "Voltage_DC": 424.70001220703125, + }, + "Modules": [], + } + } + } +} + + +class FroniusGen24DriverTests(unittest.TestCase): + def test_charge_plan_matches_existing_ha_script(self) -> None: + plan = driver.plan_storage_setpoint( + 500, + wcha_max_w=5000, + max_charge_power_w=5000, + max_discharge_power_w=5000, + ) + + self.assertEqual(plan.net_power_w, 500) + self.assertEqual(plan.mode, "charge") + self.assertEqual(plan.outwrte_word, 64535) + self.assertEqual(plan.inwrte_word, 1001) + self.assertEqual( + [(write.address, write.value) for write in plan.writes], + [(40365, 64535), (40366, 1001), (40358, 3), (40365, 64535), (40366, 1001)], + ) + + def test_discharge_plan_matches_existing_ha_script(self) -> None: + plan = driver.plan_storage_setpoint( + -500, + wcha_max_w=5000, + max_charge_power_w=5000, + max_discharge_power_w=5000, + ) + + self.assertEqual(plan.net_power_w, -500) + self.assertEqual(plan.mode, "discharge") + self.assertEqual(plan.outwrte_word, 1002) + self.assertEqual(plan.inwrte_word, 64535) + self.assertEqual( + [(write.address, write.value) for write in plan.writes], + [(40358, 3), (40365, 1002), (40366, 64535), (40365, 1002), (40366, 64535)], + ) + + def test_neutral_and_auto_reset_plans(self) -> None: + neutral = driver.plan_storage_setpoint( + 0, + wcha_max_w=5000, + max_charge_power_w=5000, + max_discharge_power_w=5000, + ) + + self.assertEqual(neutral.net_power_w, 0) + self.assertEqual(neutral.mode, "neutral") + self.assertEqual( + [(write.address, write.value) for write in neutral.writes], + [(40365, 10000), (40366, 10000), (40358, 3), (40365, 10000), (40366, 10000)], + ) + self.assertEqual( + [(write.address, write.value) for write in driver.plan_reset_to_auto()], + [(40358, 0), (40365, 10000), (40360, 500), (40366, 10000)], + ) + + def test_decode_storage_registers_uses_local_mapping_and_scale_factors(self) -> None: + regs = [0] * 24 + regs[0] = 4895 + regs[3] = 3 + regs[5] = 500 + regs[6] = 8612 + regs[9] = 1 + regs[10] = word(-1001) + regs[11] = 1001 + regs[15] = 0 + regs[16] = 0 + regs[19] = word(-2) + regs[20] = word(-2) + regs[23] = word(-2) + + snapshot = driver.decode_storage_registers(regs) + + self.assertEqual(snapshot["wcha_max"], 4895) + self.assertEqual(snapshot["max_charge_power"], 4895) + self.assertEqual(snapshot["max_discharge_power"], 4895) + self.assertAlmostEqual(snapshot["battery_soc"], 86.12) + self.assertAlmostEqual(snapshot["min_rsv_pct"], 5.0) + self.assertEqual(snapshot["storctl_mod"], 3) + self.assertEqual(snapshot["outwrte"], -1001) + self.assertEqual(snapshot["inwrte"], 1001) + + def test_decode_dc_power_registers_uses_charge_minus_discharge(self) -> None: + regs = [0] * 88 + regs[2] = word(-1) + regs[59] = 12000 + regs[79] = 2500 + + snapshot = driver.decode_dc_power_registers(regs) + + self.assertEqual(snapshot["battery_charge_power"], 1200) + self.assertEqual(snapshot["battery_discharge_power"], 250) + self.assertEqual(snapshot["battery_power"], 950) + self.assertEqual(snapshot["ac_power"], -950) + self.assertEqual(snapshot["inverter_state"], 2) + + def test_decode_storage_api_payload_maps_byd_info_values(self) -> None: + snapshot = driver.decode_storage_api_payload(STORAGE_API_PAYLOAD) + + self.assertAlmostEqual(snapshot["internal_temperature"], 26.0) + self.assertAlmostEqual(snapshot["battery_voltage"], 424.70001220703125) + self.assertAlmostEqual(snapshot["battery_current"], -0.89710383800329652) + self.assertAlmostEqual(snapshot["battery_soc"], 92.599998474121094) + self.assertAlmostEqual(snapshot["battery_total_energy"], 10.24) + self.assertEqual(snapshot["fronius_storage_manufacturer"], "BYD") + self.assertEqual(snapshot["fronius_storage_model"], "BYD Battery-Box Premium HV") + self.assertEqual(snapshot["fronius_storage_serial"], "P030T020Z2112160742") + + def test_read_telemetry_fetches_storage_api_from_same_host(self) -> None: + fake = FakeClient() + http = FakeHttpSession(STORAGE_API_PAYLOAD) + battery = driver.FroniusGen24Driver( + "pv-harig", + client=fake, + http_session=http, + ) + + snapshot = asyncio.run(battery.read_telemetry(["internal_temperature", "battery_voltage"])) + + self.assertEqual(http.urls, ["http://pv-harig/solar_api/v1/GetStorageRealtimeData.cgi"]) + self.assertEqual(snapshot["internal_temperature"], 26.0) + self.assertAlmostEqual(snapshot["battery_voltage"], 424.70001220703125) + self.assertEqual(snapshot["fronius_storage_manufacturer"], "BYD") + self.assertEqual(snapshot["fronius_storage_model"], "BYD Battery-Box Premium HV") + self.assertEqual(snapshot["fronius_storage_serial"], "P030T020Z2112160742") + self.assertEqual(battery.serial, "P030T020Z2112160742") + + def test_serial_is_unknown_until_physical_byd_serial_is_read(self) -> None: + battery = driver.FroniusGen24Driver("192.0.2.10", client=FakeClient()) + + self.assertIsNone(battery.serial) + + def test_apply_setpoint_writes_planned_registers_without_live_readback(self) -> None: + fake = FakeClient() + battery = driver.FroniusGen24Driver( + "192.0.2.10", + client=fake, + max_charge_power_w=5000, + max_discharge_power_w=5000, + ) + + result = asyncio.run(battery.apply_setpoint(500, read_back=False)) + + self.assertTrue(result.ok) + self.assertFalse(result.confirmed) + self.assertEqual(result.net_power_w, 500) + self.assertEqual( + fake.writes, + [(40365, 64535), (40366, 1001), (40358, 3), (40365, 64535), (40366, 1001)], + ) + + def test_small_discharge_request_is_suppressed_to_neutral(self) -> None: + fake = FakeClient() + battery = driver.FroniusGen24Driver( + "192.0.2.10", + client=fake, + max_charge_power_w=5000, + max_discharge_power_w=5000, + ) + + result = asyncio.run(battery.apply_setpoint(-75, read_back=False)) + + self.assertTrue(result.ok) + self.assertFalse(result.confirmed) + self.assertEqual(result.net_power_w, 0) + self.assertEqual( + fake.writes, + [(40365, 10000), (40366, 10000), (40358, 3), (40365, 10000), (40366, 10000)], + ) + + def test_discharge_suppression_includes_threshold_value(self) -> None: + fake = FakeClient() + battery = driver.FroniusGen24Driver( + "192.0.2.10", + client=fake, + max_charge_power_w=5000, + max_discharge_power_w=5000, + ) + + result = asyncio.run(battery.apply_setpoint(-750, read_back=False)) + + self.assertTrue(result.ok) + self.assertEqual(result.net_power_w, 0) + self.assertEqual( + fake.writes, + [(40365, 10000), (40366, 10000), (40358, 3), (40365, 10000), (40366, 10000)], + ) + + def test_idle_at_max_soc_is_held_with_tiny_charge_limit(self) -> None: + fake = FakeClient() + battery = driver.FroniusGen24Driver( + "192.0.2.10", + client=fake, + max_charge_power_w=5000, + max_discharge_power_w=5000, + ) + asyncio.run( + battery.apply_config( + max_soc_pct=90, + min_soc_pct=16, + max_charge_power_w=5000, + max_discharge_power_w=5000, + ) + ) + battery._last_soc_pct = 90.0 + + result = asyncio.run(battery.apply_setpoint(0, read_back=False)) + hold = driver.plan_storage_setpoint( + 10, + wcha_max_w=5000, + max_charge_power_w=5000, + max_discharge_power_w=5000, + ) + + self.assertTrue(result.ok) + self.assertEqual(result.net_power_w, 10) + self.assertEqual(fake.writes, [(write.address, write.value) for write in hold.writes]) + + def test_idle_below_max_soc_stays_neutral(self) -> None: + fake = FakeClient() + battery = driver.FroniusGen24Driver( + "192.0.2.10", + client=fake, + max_charge_power_w=5000, + max_discharge_power_w=5000, + ) + asyncio.run( + battery.apply_config( + max_soc_pct=90, + min_soc_pct=16, + max_charge_power_w=5000, + max_discharge_power_w=5000, + ) + ) + battery._last_soc_pct = 89.9 + + result = asyncio.run(battery.apply_setpoint(0, read_back=False)) + + self.assertTrue(result.ok) + self.assertEqual(result.net_power_w, 0) + self.assertEqual( + fake.writes, + [(40365, 10000), (40366, 10000), (40358, 3), (40365, 10000), (40366, 10000)], + ) + + def test_small_discharge_at_max_soc_is_held_with_tiny_charge_limit(self) -> None: + fake = FakeClient() + battery = driver.FroniusGen24Driver( + "192.0.2.10", + client=fake, + max_charge_power_w=5000, + max_discharge_power_w=5000, + ) + asyncio.run( + battery.apply_config( + max_soc_pct=90, + min_soc_pct=16, + max_charge_power_w=5000, + max_discharge_power_w=5000, + ) + ) + battery._last_soc_pct = 91.0 + + result = asyncio.run(battery.apply_setpoint(-250, read_back=False)) + hold = driver.plan_storage_setpoint( + 10, + wcha_max_w=5000, + max_charge_power_w=5000, + max_discharge_power_w=5000, + ) + + self.assertTrue(result.ok) + self.assertEqual(result.net_power_w, 10) + self.assertEqual(fake.writes, [(write.address, write.value) for write in hold.writes]) + + def test_material_discharge_request_is_still_applied(self) -> None: + fake = FakeClient() + battery = driver.FroniusGen24Driver( + "192.0.2.10", + client=fake, + max_charge_power_w=5000, + max_discharge_power_w=5000, + ) + + result = asyncio.run(battery.apply_setpoint(-1000, read_back=False)) + + self.assertTrue(result.ok) + self.assertEqual(result.net_power_w, -1000) + self.assertEqual( + fake.writes, + [(40358, 3), (40365, 2002), (40366, 63535), (40365, 2002), (40366, 63535)], + ) + + def test_standby_returns_to_fronius_auto_mode(self) -> None: + fake = FakeClient() + battery = driver.FroniusGen24Driver("192.0.2.10", client=fake) + + self.assertTrue(asyncio.run(battery.standby())) + self.assertEqual(fake.writes, [(40358, 0), (40365, 10000), (40360, 500), (40366, 10000)]) + + +if __name__ == "__main__": + unittest.main() From 8c89a3d731283601e57ec0faff97cbff0ea4b968 Mon Sep 17 00:00:00 2001 From: Misterspliss Date: Sat, 29 Aug 2026 13:01:05 +0200 Subject: [PATCH 2/4] feat: auto-detect Fronius SunSpec model type --- CHANGELOG.md | 2 +- .../omnibattery/drivers/fronius_gen24.py | 184 ++++++++++++++---- .../omnibattery/frontend/marstek-panel.js | 15 +- custom_components/omnibattery/sensor.py | 5 + custom_components/omnibattery/strings.json | 3 + .../omnibattery/translations/ca.json | 3 + .../omnibattery/translations/de.json | 3 + .../omnibattery/translations/en.json | 3 + .../omnibattery/translations/es.json | 3 + .../omnibattery/translations/fr.json | 3 + .../omnibattery/translations/nl.json | 3 + .../batteries/fronius-gen24.es.md | 26 ++- .../configuration/batteries/fronius-gen24.md | 25 ++- tests/test_fronius_gen24_driver.py | 103 ++++++++++ 14 files changed, 321 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3395979..f68b58e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +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 supported SunSpec representation is integer plus scale factor (`int+SF`); `max_soc` remains a software control limit rather than a guaranteed hardware cutoff. Thanks to @MisterSpliss for the contribution. +- **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. `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. diff --git a/custom_components/omnibattery/drivers/fronius_gen24.py b/custom_components/omnibattery/drivers/fronius_gen24.py index 54422549..7744fef3 100644 --- a/custom_components/omnibattery/drivers/fronius_gen24.py +++ b/custom_components/omnibattery/drivers/fronius_gen24.py @@ -3,13 +3,14 @@ Implements :class:`BatteryDriver` for a BYD battery controlled through a Fronius GEN24 inverter's SunSpec storage control registers. -Register map source of truth for this first implementation is the working local -Home Assistant setup this driver replaces: +The driver detects the Fronius SunSpec model type from the Model 124 header and +uses the corresponding address layout. Fronius' ``float``/``int+SF`` setting +changes the preceding inverter model and therefore shifts Models 160 and 124; +those two models themselves keep their integer-plus-scale-factor encoding. -* Storage control block: 40355, count 24, structure ``>10H2h4H8h`` -* DC measurement block: 40265, count 88, structure compatible with the local - ``reading_inverter_multiple_raw`` sensor -* Control writes: 40358 (StorCtl_Mod), 40365 (OutWRte), 40366 (InWRte) +* Float: Model 160 data at 40265, Model 124 data at 40355 +* int+SF: Model 160 data at 40255, Model 124 data at 40345 +* Model 124 data structure: ``>10H2h4H8h`` Sign conventions: Omnibattery net power: +charge / -discharge @@ -44,7 +45,6 @@ FRONIUS_GEN24_DEFAULT_MAX_POWER_W = 5000 FRONIUS_GEN24_DEFAULT_CAPACITY_KWH = 11.0 -_ADDR_DCW_BLOCK = 40265 _DCW_BLOCK_COUNT = 88 _DCW_SF_OFFSET = 2 # Raw Modbus word offsets. The HA custom sensor exposes these as split indices @@ -52,7 +52,6 @@ _DCW_TO_BATTERY_OFFSET = 59 _DCW_FROM_BATTERY_OFFSET = 79 -_ADDR_STORAGE_BLOCK = 40355 _STORAGE_BLOCK_COUNT = 24 _IDX_WCHAMAX = 0 _IDX_STORCTL_MOD = 3 @@ -67,10 +66,7 @@ _IDX_CHA_STATE_SF = 20 _IDX_INOUTWRTE_SF = 23 -_ADDR_STORCTL_MOD = 40358 -_ADDR_MIN_RSV_PCT = 40360 -_ADDR_OUTWRTE = 40365 -_ADDR_INWRTE = 40366 +_MODEL_124_ID = 124 _MODE_AUTO = 0 _MODE_STORAGE_CONTROL = 3 @@ -145,6 +141,10 @@ "device_class": None, "state_class": None, "scale": 1, "precision": 0, "icon": "mdi:transmission-tower-import", "scan_interval": "medium", "enabled_by_default": False}, + {"key": "fronius_sunspec_model_type", "name": "SunSpec Model Type", + "unit": None, "device_class": None, "state_class": None, "scale": 1, + "precision": 0, "icon": "mdi:code-braces-box", "scan_interval": "medium", + "category": "diagnostic", "enabled_by_default": False}, {"key": "inverter_state", "name": "Inverter State", "unit": None, "device_class": None, "state_class": None, "scale": 1, "precision": 0, "icon": "mdi:state-machine", "scan_interval": "high", @@ -159,6 +159,43 @@ BUTTON_DEFINITIONS: list[dict] = [] +@dataclass(frozen=True) +class SunSpecLayout: + """Addresses for one Fronius SunSpec inverter-model representation.""" + + model_type: str + dc_model_header: int + storage_model_header: int + + @property + def dc_block(self) -> int: + return self.dc_model_header + 2 + + @property + def storage_block(self) -> int: + return self.storage_model_header + 2 + + @property + def storctl_mod(self) -> int: + return self.storage_block + _IDX_STORCTL_MOD + + @property + def min_rsv_pct(self) -> int: + return self.storage_block + _IDX_MIN_RSV_PCT + + @property + def outwrte(self) -> int: + return self.storage_block + _IDX_OUTWRTE + + @property + def inwrte(self) -> int: + return self.storage_block + _IDX_INWRTE + + +SUNSPEC_FLOAT_LAYOUT = SunSpecLayout("float", 40263, 40353) +SUNSPEC_INT_SF_LAYOUT = SunSpecLayout("int+SF", 40253, 40343) + + @dataclass(frozen=True) class RegisterWrite: """One Modbus single-register write.""" @@ -218,6 +255,7 @@ def plan_storage_setpoint( wcha_max_w: int, max_charge_power_w: int, max_discharge_power_w: int, + layout: SunSpecLayout = SUNSPEC_FLOAT_LAYOUT, ) -> SetpointPlan: """Build the exact GEN24/BYD register-write plan for one setpoint. @@ -240,11 +278,11 @@ def plan_storage_setpoint( out_word = _int16_word(-(pct + 1)) in_word = pct + 1 writes = ( - RegisterWrite(_ADDR_OUTWRTE, out_word), - RegisterWrite(_ADDR_INWRTE, in_word), - RegisterWrite(_ADDR_STORCTL_MOD, _MODE_STORAGE_CONTROL), - RegisterWrite(_ADDR_OUTWRTE, out_word), - RegisterWrite(_ADDR_INWRTE, in_word), + RegisterWrite(layout.outwrte, out_word), + RegisterWrite(layout.inwrte, in_word), + RegisterWrite(layout.storctl_mod, _MODE_STORAGE_CONTROL), + RegisterWrite(layout.outwrte, out_word), + RegisterWrite(layout.inwrte, in_word), ) return SetpointPlan(power, "charge", out_word, in_word, writes) @@ -254,31 +292,33 @@ def plan_storage_setpoint( out_word = int((power + 1) / wcha * 10000) in_word = _int16_word(-(pct + 1)) writes = ( - RegisterWrite(_ADDR_STORCTL_MOD, _MODE_STORAGE_CONTROL), - RegisterWrite(_ADDR_OUTWRTE, out_word), - RegisterWrite(_ADDR_INWRTE, in_word), - RegisterWrite(_ADDR_OUTWRTE, out_word), - RegisterWrite(_ADDR_INWRTE, in_word), + RegisterWrite(layout.storctl_mod, _MODE_STORAGE_CONTROL), + RegisterWrite(layout.outwrte, out_word), + RegisterWrite(layout.inwrte, in_word), + RegisterWrite(layout.outwrte, out_word), + RegisterWrite(layout.inwrte, in_word), ) return SetpointPlan(-power, "discharge", out_word, in_word, writes) writes = ( - RegisterWrite(_ADDR_OUTWRTE, _NEUTRAL_WINDOW_WORD), - RegisterWrite(_ADDR_INWRTE, _NEUTRAL_WINDOW_WORD), - RegisterWrite(_ADDR_STORCTL_MOD, _MODE_STORAGE_CONTROL), - RegisterWrite(_ADDR_OUTWRTE, _NEUTRAL_WINDOW_WORD), - RegisterWrite(_ADDR_INWRTE, _NEUTRAL_WINDOW_WORD), + RegisterWrite(layout.outwrte, _NEUTRAL_WINDOW_WORD), + RegisterWrite(layout.inwrte, _NEUTRAL_WINDOW_WORD), + RegisterWrite(layout.storctl_mod, _MODE_STORAGE_CONTROL), + RegisterWrite(layout.outwrte, _NEUTRAL_WINDOW_WORD), + RegisterWrite(layout.inwrte, _NEUTRAL_WINDOW_WORD), ) return SetpointPlan(0, "neutral", _NEUTRAL_WINDOW_WORD, _NEUTRAL_WINDOW_WORD, writes) -def plan_reset_to_auto() -> tuple[RegisterWrite, ...]: +def plan_reset_to_auto( + layout: SunSpecLayout = SUNSPEC_FLOAT_LAYOUT, +) -> tuple[RegisterWrite, ...]: """Return control to Fronius auto mode using the local reset script values.""" return ( - RegisterWrite(_ADDR_STORCTL_MOD, _MODE_AUTO), - RegisterWrite(_ADDR_OUTWRTE, _NEUTRAL_WINDOW_WORD), - RegisterWrite(_ADDR_MIN_RSV_PCT, _DEFAULT_MIN_RSV_WORD), - RegisterWrite(_ADDR_INWRTE, _NEUTRAL_WINDOW_WORD), + RegisterWrite(layout.storctl_mod, _MODE_AUTO), + RegisterWrite(layout.outwrte, _NEUTRAL_WINDOW_WORD), + RegisterWrite(layout.min_rsv_pct, _DEFAULT_MIN_RSV_WORD), + RegisterWrite(layout.inwrte, _NEUTRAL_WINDOW_WORD), ) @@ -438,6 +478,10 @@ def __init__( self._last_net_power_w: Optional[int] = None self._last_inout_sf = -2 self._serial: Optional[str] = None + # Float is the Fronius default and preserves the established behavior + # until connect() verifies the Model 124 header. + self._sunspec_layout = SUNSPEC_FLOAT_LAYOUT + self._sunspec_layout_detected = False self._http_session = http_session self._owns_http_session = http_session is None self._storage_api_url = f"http://{host}{_STORAGE_API_PATH}" @@ -465,6 +509,7 @@ def __init__( "inwrte", "min_rsv_pct", "chagriset", + "fronius_sunspec_model_type", "internal_temperature", "battery_voltage", "battery_current", @@ -504,6 +549,15 @@ def serial(self) -> Optional[str]: """Return the physical BYD serial used by synthetic-energy backup.""" return self._serial + @property + def sunspec_model_type(self) -> Optional[str]: + """Return the detected Fronius inverter-model representation.""" + return ( + self._sunspec_layout.model_type + if self._sunspec_layout_detected + else None + ) + @property def sensor_definitions(self) -> list[dict]: return SENSOR_DEFINITIONS @@ -546,6 +600,16 @@ def connected(self) -> bool: async def connect(self) -> bool: ok = await self._client.async_connect() if ok: + if not await self._detect_sunspec_layout(): + _LOGGER.warning( + "Fronius GEN24 at %s:%s slave %s exposes no supported " + "SunSpec Model 124 header", + self._host, + self._port, + self._slave_id, + ) + await self._client.async_close() + return False await self._refresh_storage_cache() return ok @@ -562,23 +626,57 @@ def set_shutting_down(self, value: bool) -> None: def read_groups(self) -> list[ReadGroup]: return self._read_groups + async def _detect_sunspec_layout(self) -> bool: + """Detect float versus int+SF from the Basic Storage Model header.""" + self._client.unit_id = self._slave_id + for layout in (SUNSPEC_FLOAT_LAYOUT, SUNSPEC_INT_SF_LAYOUT): + regs = await self._client.async_read_block( + layout.storage_model_header, + 2, + block_key=f"fronius_model_124_{layout.model_type}", + ) + if ( + regs + and len(regs) >= 2 + and int(regs[0]) == _MODEL_124_ID + and int(regs[1]) == _STORAGE_BLOCK_COUNT + ): + self._sunspec_layout = layout + self._sunspec_layout_detected = True + _LOGGER.info( + "Detected Fronius GEN24 SunSpec model type %s at %s:%s " + "slave %s", + layout.model_type, + self._host, + self._port, + self._slave_id, + ) + return True + self._sunspec_layout_detected = False + return False + async def _read_storage_block(self) -> TelemetrySnapshot: self._client.unit_id = self._slave_id regs = await self._client.async_read_block( - _ADDR_STORAGE_BLOCK, + self._sunspec_layout.storage_block, _STORAGE_BLOCK_COUNT, - block_key="fronius_storage", + block_key=f"fronius_storage_{self._sunspec_layout.model_type}", ) if not regs: return {} - return decode_storage_registers(regs) + snapshot = decode_storage_registers(regs) + if self._sunspec_layout_detected: + snapshot["fronius_sunspec_model_type"] = ( + self._sunspec_layout.model_type + ) + return snapshot async def _read_dc_power_block(self) -> TelemetrySnapshot: self._client.unit_id = self._slave_id regs = await self._client.async_read_block( - _ADDR_DCW_BLOCK, + self._sunspec_layout.dc_block, _DCW_BLOCK_COUNT, - block_key="fronius_dc_power", + block_key=f"fronius_dc_power_{self._sunspec_layout.model_type}", ) if not regs: return {} @@ -657,6 +755,7 @@ async def read_telemetry(self, keys: Optional[list[str]] = None) -> TelemetrySna "chagriset", "wcha_max", "inoutwrte_sf", + "fronius_sunspec_model_type", } storage_api_keys = { "internal_temperature", @@ -766,6 +865,7 @@ async def apply_setpoint( wcha_max_w=self._wcha_max_w, max_charge_power_w=self._max_charge_w, max_discharge_power_w=self._max_discharge_w, + layout=self._sunspec_layout, ) ok = await self._write_plan(plan.writes) if not ok: @@ -836,10 +936,10 @@ async def _write_plan(self, writes: tuple[RegisterWrite, ...]) -> bool: async def write_control(self, key: str, value: int) -> bool: address_by_key = { - "storctl_mod": _ADDR_STORCTL_MOD, - "outwrte": _ADDR_OUTWRTE, - "inwrte": _ADDR_INWRTE, - "min_rsv_pct": _ADDR_MIN_RSV_PCT, + "storctl_mod": self._sunspec_layout.storctl_mod, + "outwrte": self._sunspec_layout.outwrte, + "inwrte": self._sunspec_layout.inwrte, + "min_rsv_pct": self._sunspec_layout.min_rsv_pct, } address = address_by_key.get(key) if address is None: @@ -922,7 +1022,7 @@ async def standby(self) -> bool: if not self.connected: return False self._last_net_power_w = 0 - return await self._write_plan(plan_reset_to_auto()) + return await self._write_plan(plan_reset_to_auto(self._sunspec_layout)) @classmethod async def probe( diff --git a/custom_components/omnibattery/frontend/marstek-panel.js b/custom_components/omnibattery/frontend/marstek-panel.js index ef7fc1a0..472d794d 100644 --- a/custom_components/omnibattery/frontend/marstek-panel.js +++ b/custom_components/omnibattery/frontend/marstek-panel.js @@ -49,7 +49,7 @@ const I18N = { tabResumen: "Overview", tabBaterias: "Batteries", tabControl: "Control", moreInfo: "Show history", zoomReset: "All", - infoManufacturer: "Manufacturer", infoModel: "Model", infoSoftware: "Software", infoSerial: "Serial", infoInverter: "Inverter", infoPowerModule: "Power module", + infoManufacturer: "Manufacturer", infoModel: "Model", infoSunSpecModel: "SunSpec model", infoSoftware: "Software", infoSerial: "Serial", infoInverter: "Inverter", infoPowerModule: "Power module", placeholderMsg: "This view is coming in a future phase. For now, use the Overview view.", cardFlow: "Energy flow", cardSoc: "System status", cardDaily: "Energy today", cardWeekly: "Weekly energy", cardPower: "Power", cardSocToday: "SOC · today", @@ -134,7 +134,7 @@ const I18N = { tabResumen: "Resumen", tabBaterias: "Baterías", tabControl: "Control", moreInfo: "Ver histórico", zoomReset: "Todo", - infoManufacturer: "Fabricante", infoModel: "Modelo", infoSoftware: "Software", infoSerial: "N.º serie", infoInverter: "Inversor", infoPowerModule: "Módulo de potencia", + infoManufacturer: "Fabricante", infoModel: "Modelo", infoSunSpecModel: "Modelo SunSpec", infoSoftware: "Software", infoSerial: "N.º serie", infoInverter: "Inversor", infoPowerModule: "Módulo de potencia", placeholderMsg: "Esta vista llegará en una próxima fase. Por ahora, usa la vista Resumen.", cardFlow: "Flujo de energía", cardSoc: "Estado del sistema", cardDaily: "Energía hoy", cardWeekly: "Energía semanal", cardPower: "Potencias", cardSocToday: "SOC · hoy", @@ -219,7 +219,7 @@ const I18N = { tabResumen: "Resum", tabBaterias: "Bateries", tabControl: "Control", moreInfo: "Veure històric", zoomReset: "Tot", - infoManufacturer: "Fabricant", infoModel: "Model", infoSoftware: "Programari", infoSerial: "Núm. sèrie", infoInverter: "Inversor", infoPowerModule: "Mòdul de potència", + infoManufacturer: "Fabricant", infoModel: "Model", infoSunSpecModel: "Model SunSpec", infoSoftware: "Programari", infoSerial: "Núm. sèrie", infoInverter: "Inversor", infoPowerModule: "Mòdul de potència", placeholderMsg: "Aquesta vista arribarà en una fase futura. De moment, fes servir la vista Resum.", cardFlow: "Flux d'energia", cardSoc: "Estat del sistema", cardDaily: "Energia avui", cardWeekly: "Energia setmanal", cardPower: "Potències", cardSocToday: "SOC · avui", @@ -300,7 +300,7 @@ const I18N = { tabResumen: "Übersicht", tabBaterias: "Batterien", tabControl: "Steuerung", moreInfo: "Verlauf anzeigen", zoomReset: "Alles", - infoManufacturer: "Hersteller", infoModel: "Modell", infoSoftware: "Software", infoSerial: "Seriennr.", infoInverter: "Wechselrichter", infoPowerModule: "Leistungsmodul", + infoManufacturer: "Hersteller", infoModel: "Modell", infoSunSpecModel: "SunSpec-Modell", infoSoftware: "Software", infoSerial: "Seriennr.", infoInverter: "Wechselrichter", infoPowerModule: "Leistungsmodul", placeholderMsg: "Diese Ansicht kommt in einer späteren Phase. Nutze vorerst die Übersicht.", cardFlow: "Energiefluss", cardSoc: "Systemstatus", cardDaily: "Energie heute", cardWeekly: "Wochenenergie", cardPower: "Leistung", cardSocToday: "SOC · heute", @@ -381,7 +381,7 @@ const I18N = { tabResumen: "Résumé", tabBaterias: "Batteries", tabControl: "Contrôle", moreInfo: "Voir l'historique", zoomReset: "Tout", - infoManufacturer: "Fabricant", infoModel: "Modèle", infoSoftware: "Logiciel", infoSerial: "N° série", infoInverter: "Onduleur", infoPowerModule: "Module de puissance", + infoManufacturer: "Fabricant", infoModel: "Modèle", infoSunSpecModel: "Modèle SunSpec", infoSoftware: "Logiciel", infoSerial: "N° série", infoInverter: "Onduleur", infoPowerModule: "Module de puissance", placeholderMsg: "Cette vue arrivera dans une phase ultérieure. Pour l'instant, utilisez la vue Résumé.", cardFlow: "Flux d'énergie", cardSoc: "État du système", cardDaily: "Énergie aujourd'hui", cardWeekly: "Énergie hebdomadaire", cardPower: "Puissances", cardSocToday: "SOC · aujourd'hui", @@ -462,7 +462,7 @@ const I18N = { tabResumen: "Overzicht", tabBaterias: "Batterijen", tabControl: "Bediening", moreInfo: "Geschiedenis tonen", zoomReset: "Alles", - infoManufacturer: "Fabrikant", infoModel: "Model", infoSoftware: "Software", infoSerial: "Serienr.", infoInverter: "Omvormer", infoPowerModule: "Vermogensmodule", + infoManufacturer: "Fabrikant", infoModel: "Model", infoSunSpecModel: "SunSpec-model", infoSoftware: "Software", infoSerial: "Serienr.", infoInverter: "Omvormer", infoPowerModule: "Vermogensmodule", placeholderMsg: "Deze weergave komt in een latere fase. Gebruik voorlopig het Overzicht.", cardFlow: "Energiestroom", cardSoc: "Systeemstatus", cardDaily: "Energie vandaag", cardWeekly: "Energie per week", cardPower: "Vermogen", cardSocToday: "SOC · vandaag", @@ -821,6 +821,7 @@ const K = { cyclesCalc: "battery_cycle_count_calc", rte: "round_trip_efficiency_total", softwareVersion: "software_version", + froniusSunspecModelType: "fronius_sunspec_model_type", powerModuleSerial: "power_module_serial_number", powerModuleFirmware: "power_module_firmware_version", inverterSerial: "inverter_serial_number", @@ -5378,6 +5379,7 @@ class MarstekVenusPanel extends HTMLElement { info: { manufacturer: (socObj.attributes && socObj.attributes.manufacturer) || (devReg && devReg.manufacturer) || null, sw: this._sval(byTk[K.softwareVersion]), + sunspecModelType: (socObj.attributes && socObj.attributes.sunspec_model_type) || this._sval(byTk[K.froniusSunspecModelType]), // Huawei publishes the serial as a sensor; the registry entry has none. serial: (socObj.attributes && socObj.attributes.serial) || (devReg && devReg.serial_number) || this._sval(byTk[K.powerModuleSerial]), powerModuleFw: this._sval(byTk[K.powerModuleFirmware]), @@ -5797,6 +5799,7 @@ class MarstekVenusPanel extends HTMLElement { }; addRow(this._t("infoManufacturer"), b.info.manufacturer); addRow(this._t("infoModel"), b.model); + addRow(this._t("infoSunSpecModel"), b.info.sunspecModelType); addRow(this._t("infoSoftware"), b.info.sw); addRow("BMS", b.info.bms); addRow("VMS", b.info.vms); diff --git a/custom_components/omnibattery/sensor.py b/custom_components/omnibattery/sensor.py index 3d57e12d..6677b547 100644 --- a/custom_components/omnibattery/sensor.py +++ b/custom_components/omnibattery/sensor.py @@ -370,8 +370,13 @@ def extra_state_attributes(self): for attr, key in ( ("manufacturer", "fronius_storage_manufacturer"), ("serial", "fronius_storage_serial"), + ("sunspec_model_type", "fronius_sunspec_model_type"), ): value = data.get(key) + if not value and attr == "sunspec_model_type": + value = getattr( + self.coordinator.driver, "sunspec_model_type", None + ) if value: attrs[attr] = value return attrs or None diff --git a/custom_components/omnibattery/strings.json b/custom_components/omnibattery/strings.json index f0b2bdfe..440014fe 100644 --- a/custom_components/omnibattery/strings.json +++ b/custom_components/omnibattery/strings.json @@ -2043,6 +2043,9 @@ }, "battery_current": { "name": "Battery Current" + }, + "fronius_sunspec_model_type": { + "name": "SunSpec Model Type" } }, "binary_sensor": { diff --git a/custom_components/omnibattery/translations/ca.json b/custom_components/omnibattery/translations/ca.json index 97e1b5a2..9d7dc600 100644 --- a/custom_components/omnibattery/translations/ca.json +++ b/custom_components/omnibattery/translations/ca.json @@ -1939,6 +1939,9 @@ }, "input_limit": { "name": "Límit d'Entrada" + }, + "fronius_sunspec_model_type": { + "name": "Tipus de model SunSpec" } }, "binary_sensor": { diff --git a/custom_components/omnibattery/translations/de.json b/custom_components/omnibattery/translations/de.json index b8e327c3..0d165b15 100644 --- a/custom_components/omnibattery/translations/de.json +++ b/custom_components/omnibattery/translations/de.json @@ -2048,6 +2048,9 @@ }, "battery_current": { "name": "Batteriestrom" + }, + "fronius_sunspec_model_type": { + "name": "SunSpec-Modelltyp" } }, "binary_sensor": { diff --git a/custom_components/omnibattery/translations/en.json b/custom_components/omnibattery/translations/en.json index 9819f804..a97efd49 100644 --- a/custom_components/omnibattery/translations/en.json +++ b/custom_components/omnibattery/translations/en.json @@ -2049,6 +2049,9 @@ }, "battery_current": { "name": "Battery Current" + }, + "fronius_sunspec_model_type": { + "name": "SunSpec Model Type" } }, "binary_sensor": { diff --git a/custom_components/omnibattery/translations/es.json b/custom_components/omnibattery/translations/es.json index a9d66d51..3ac666b4 100644 --- a/custom_components/omnibattery/translations/es.json +++ b/custom_components/omnibattery/translations/es.json @@ -1939,6 +1939,9 @@ }, "input_limit": { "name": "Límite de Entrada" + }, + "fronius_sunspec_model_type": { + "name": "Tipo de modelo SunSpec" } }, "binary_sensor": { diff --git a/custom_components/omnibattery/translations/fr.json b/custom_components/omnibattery/translations/fr.json index 9b78964d..a498c7e5 100644 --- a/custom_components/omnibattery/translations/fr.json +++ b/custom_components/omnibattery/translations/fr.json @@ -1952,6 +1952,9 @@ }, "input_limit": { "name": "Limite d'Entrée" + }, + "fronius_sunspec_model_type": { + "name": "Type de modèle SunSpec" } }, "binary_sensor": { diff --git a/custom_components/omnibattery/translations/nl.json b/custom_components/omnibattery/translations/nl.json index 5846e9c1..536b0edf 100644 --- a/custom_components/omnibattery/translations/nl.json +++ b/custom_components/omnibattery/translations/nl.json @@ -1939,6 +1939,9 @@ }, "input_limit": { "name": "Ingangslimiet" + }, + "fronius_sunspec_model_type": { + "name": "SunSpec-modeltype" } }, "binary_sensor": { diff --git a/site-docs/configuration/batteries/fronius-gen24.es.md b/site-docs/configuration/batteries/fronius-gen24.es.md index 9731d161..4d87df3e 100644 --- a/site-docs/configuration/batteries/fronius-gen24.es.md +++ b/site-docs/configuration/batteries/fronius-gen24.es.md @@ -17,12 +17,28 @@ Selecciona **Fronius GEN24 / BYD** e introduce el host del inversor, el puerto Modbus y el ID de unidad SunSpec. Los valores predeterminados son el puerto `502` y el ID `1`. -## Representación SunSpec compatible +## Detección del modelo SunSpec -Este controlador admite la representación SunSpec de **entero más factor de -escala (`int+SF`)**. Lee el bloque de control de almacenamiento y sus factores -de escala de los registros `40355`–`40378`; el modelo SunSpec de coma flotante -no se detecta automáticamente ni es compatible actualmente. +El controlador admite las configuraciones SunSpec **`float`** e **`int+SF`** +de Fronius y detecta automáticamente el diseño activo mediante la cabecera del +modelo Basic Storage Control (124). No es necesario seleccionar el tipo de +modelo en Omnibattery. + +Fronius aplica la representación elegida al modelo de inversor anterior. Los +modelos 160 y 124 siguen usando enteros y factores de escala en ambos diseños, +pero sus direcciones se desplazan diez registros: + +| Bloque | `float` | `int+SF` | +|---|---:|---:| +| Datos del modelo Multiple MPPT 160 | `40265` | `40255` | +| Datos del modelo Basic Storage 124 | `40355` | `40345` | + +Estas posiciones corresponden a la +[documentación oficial de Modbus para Fronius GEN24](https://manuals.fronius.com/html/4204102649/es.html#BasicStorageControlsRegister). + +Todas las lecturas, escrituras de consignas y comprobaciones usan el diseño +detectado. El tipo aparece como **Modelo SunSpec** en la caja de información de +la batería. ## Límites de SOC diff --git a/site-docs/configuration/batteries/fronius-gen24.md b/site-docs/configuration/batteries/fronius-gen24.md index 1ee17451..49321a94 100644 --- a/site-docs/configuration/batteries/fronius-gen24.md +++ b/site-docs/configuration/batteries/fronius-gen24.md @@ -15,13 +15,26 @@ serial number, temperature, voltage, current and capacity. Select **Fronius GEN24 / BYD**, then enter the inverter host, Modbus port and SunSpec unit ID. The defaults are port `502` and unit ID `1`. -## Supported SunSpec representation +## SunSpec model detection -This driver supports the SunSpec **integer plus scale-factor (`int+SF`)** -register representation. It reads the storage-control block and its scale -factors from registers `40355`–`40378`; the floating-point SunSpec model is not -currently auto-detected or supported. If the connection test fails, verify the -SunSpec data-model setting on the inverter before changing the unit ID. +The driver supports both Fronius **`float`** and **`int+SF`** SunSpec model +settings and detects the active layout automatically from the Basic Storage +Control Model (124) header. No model-type setting is required in Omnibattery. + +Fronius applies the selected representation to the preceding inverter model. +Models 160 and 124 still use integer values and scale factors in both layouts, +but their addresses move by ten registers: + +| Block | `float` | `int+SF` | +|---|---:|---:| +| Multiple MPPT Model 160 data | `40265` | `40255` | +| Basic Storage Model 124 data | `40355` | `40345` | + +These positions follow the +[official Fronius GEN24 Modbus documentation](https://manuals.fronius.com/html/4204102649/en-US.html#BasicStorageControlsRegister). + +All reads, setpoint writes and readbacks use the detected layout. The detected +type is shown as **SunSpec model** in the battery information box. ## SOC limits diff --git a/tests/test_fronius_gen24_driver.py b/tests/test_fronius_gen24_driver.py index fc12a4df..079fcfa1 100644 --- a/tests/test_fronius_gen24_driver.py +++ b/tests/test_fronius_gen24_driver.py @@ -164,6 +164,18 @@ async def async_write_register(self, address: int, value: int) -> bool: return True +class RegisterMapClient(FakeClient): + def __init__(self, blocks: dict[tuple[int, int], list[int]]) -> None: + super().__init__() + self.blocks = blocks + self.reads: list[tuple[int, int]] = [] + + async def async_read_block(self, address: int, count: int, block_key: str): + self.reads.append((address, count)) + value = self.blocks.get((address, count)) + return list(value) if value is not None else None + + class FakeHttpResponse: def __init__(self, payload: dict, status: int = 200) -> None: self._payload = payload @@ -218,7 +230,98 @@ async def close(self) -> None: } +def storage_registers() -> list[int]: + regs = [0] * 24 + regs[0] = 4895 + regs[3] = 3 + regs[5] = 500 + regs[6] = 8612 + regs[9] = 1 + regs[10] = word(-1001) + regs[11] = 1001 + regs[15] = 0 + regs[16] = 0 + regs[19] = word(-2) + regs[20] = word(-2) + regs[23] = word(-2) + return regs + + +def dc_registers() -> list[int]: + regs = [0] * 88 + regs[2] = word(-1) + regs[59] = 12000 + regs[79] = 2500 + return regs + + class FroniusGen24DriverTests(unittest.TestCase): + def test_connect_detects_float_layout(self) -> None: + fake = RegisterMapClient({ + (40353, 2): [124, 24], + (40355, 24): storage_registers(), + }) + battery = driver.FroniusGen24Driver("192.0.2.10", client=fake) + + self.assertTrue(asyncio.run(battery.connect())) + self.assertEqual(battery.sunspec_model_type, "float") + self.assertEqual(fake.reads, [(40353, 2), (40355, 24)]) + + snapshot = asyncio.run( + battery.read_telemetry( + ["battery_soc", "fronius_sunspec_model_type"] + ) + ) + self.assertEqual(snapshot["fronius_sunspec_model_type"], "float") + self.assertAlmostEqual(snapshot["battery_soc"], 86.12) + + def test_connect_detects_int_sf_layout_and_shifts_reads_and_writes(self) -> None: + fake = RegisterMapClient({ + (40343, 2): [124, 24], + (40345, 24): storage_registers(), + (40255, 88): dc_registers(), + }) + battery = driver.FroniusGen24Driver( + "192.0.2.10", + client=fake, + max_charge_power_w=5000, + max_discharge_power_w=5000, + ) + + self.assertTrue(asyncio.run(battery.connect())) + self.assertEqual(battery.sunspec_model_type, "int+SF") + self.assertEqual(fake.reads[:3], [(40353, 2), (40343, 2), (40345, 24)]) + + snapshot = asyncio.run(battery.read_telemetry(["battery_power"])) + self.assertEqual(snapshot["battery_power"], 950) + self.assertEqual(fake.reads[-1], (40255, 88)) + + result = asyncio.run(battery.apply_setpoint(500, read_back=False)) + self.assertTrue(result.ok) + self.assertEqual( + fake.writes, + [(40355, 64514), (40356, 1022), (40348, 3), (40355, 64514), (40356, 1022)], + ) + + fake.writes.clear() + self.assertTrue(asyncio.run(battery.write_control("min_rsv_pct", 500))) + self.assertEqual(fake.writes, [(40350, 500)]) + + fake.writes.clear() + self.assertTrue(asyncio.run(battery.standby())) + self.assertEqual( + fake.writes, + [(40348, 0), (40355, 10000), (40350, 500), (40356, 10000)], + ) + + def test_connect_rejects_missing_basic_storage_model(self) -> None: + fake = RegisterMapClient({}) + battery = driver.FroniusGen24Driver("192.0.2.10", client=fake) + + self.assertFalse(asyncio.run(battery.connect())) + self.assertIsNone(battery.sunspec_model_type) + self.assertEqual(fake.reads, [(40353, 2), (40343, 2)]) + def test_charge_plan_matches_existing_ha_script(self) -> None: plan = driver.plan_storage_setpoint( 500, From fd1853916bfba7914333dd22b3ce33c18fb9935f Mon Sep 17 00:00:00 2001 From: Misterspliss Date: Sat, 29 Aug 2026 15:22:50 +0200 Subject: [PATCH 3/4] fix: restore Fronius v12 ownership and SunSpec detection --- CHANGELOG.md | 2 +- custom_components/omnibattery/__init__.py | 69 ++++- custom_components/omnibattery/config_flow.py | 9 +- .../omnibattery/const/integration_const.py | 3 + .../omnibattery/control/charge_delay.py | 6 +- .../omnibattery/control/max_soc_charge.py | 8 +- .../omnibattery/control/phase_power_limit.py | 2 +- .../omnibattery/control/weekly_full_charge.py | 28 +- custom_components/omnibattery/diagnostics.py | 19 +- .../omnibattery/drivers/fronius_gen24.py | 257 +++++++++++++----- .../omnibattery/frontend/marstek-panel.js | 19 +- .../omnibattery/infra/coordinator.py | 36 +++ .../omnibattery/infra/manual_control.py | 3 + .../omnibattery/infra/modbus_client.py | 60 ++++ .../omnibattery/pricing/engine.py | 18 +- custom_components/omnibattery/sensor.py | 23 +- custom_components/omnibattery/strings.json | 3 + custom_components/omnibattery/switch.py | 83 +++++- .../omnibattery/translations/ca.json | 3 + .../omnibattery/translations/de.json | 3 + .../omnibattery/translations/en.json | 3 + .../omnibattery/translations/es.json | 3 + .../omnibattery/translations/fr.json | 3 + .../omnibattery/translations/nl.json | 3 + .../batteries/fronius-gen24.es.md | 15 + .../configuration/batteries/fronius-gen24.md | 13 + tests/test_domain_migration.py | 2 +- tests/test_fronius_gen24_driver.py | 236 +++++++++++----- tests/test_hysteresis_migration.py | 29 +- tests/test_system_uid_migration.py | 2 +- 30 files changed, 763 insertions(+), 200 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f68b58e3..09402d6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +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. `max_soc` remains a software control limit rather than a guaranteed hardware cutoff. Thanks to @MisterSpliss for the contribution. +- **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. diff --git a/custom_components/omnibattery/__init__.py b/custom_components/omnibattery/__init__.py index 6256cb50..0f1293c9 100644 --- a/custom_components/omnibattery/__init__.py +++ b/custom_components/omnibattery/__init__.py @@ -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, @@ -2203,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) @@ -2216,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.""" @@ -3715,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 @@ -5935,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 @@ -8940,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) @@ -9184,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 @@ -9542,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 @@ -9657,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 diff --git a/custom_components/omnibattery/config_flow.py b/custom_components/omnibattery/config_flow.py index 11f77732..59b7caca 100644 --- a/custom_components/omnibattery/config_flow.py +++ b/custom_components/omnibattery/config_flow.py @@ -99,6 +99,7 @@ max_power_for_battery_version, MAX_BATTERIES, CONF_ENABLE_SYSTEM_POWER_LIMITS, + CONF_FRONIUS_INTERNAL_CONTROL_DISABLED, CONF_CAPACITY_PROTECTION_ENABLED, CONF_CAPACITY_PROTECTION_EXCLUDED_DEVICES, CONF_PREDICTIVE_CHARGING_MODE, @@ -1227,7 +1228,7 @@ def _apply_mac_tracking(user_input: dict, merged: dict) -> None: class MarstekVenusConfigFlow(LegacyDomainMigrationMixin, ConfigFlow, domain=DOMAIN): """Handle a config flow for Omnibattery.""" - VERSION = 11 + VERSION = 12 def __init__(self): """Initialize the config flow.""" @@ -2025,6 +2026,9 @@ async def async_step_battery_connection_fronius_gen24( CONF_SLAVE_ID: slave_id, "brand": "fronius_gen24", }) + self._current_battery_data.setdefault( + CONF_FRONIUS_INTERNAL_CONTROL_DISABLED, True + ) _fronius_apply_probe_caps(self._current_battery_data, caps) return await self.async_step_battery_limits() @@ -4770,6 +4774,9 @@ async def async_step_battery_connection_fronius_gen24( CONF_SLAVE_ID: slave_id, "brand": "fronius_gen24", }) + self._current_battery_data.setdefault( + CONF_FRONIUS_INTERNAL_CONTROL_DISABLED, True + ) _fronius_apply_probe_caps(self._current_battery_data, caps) return await self.async_step_battery_limits() diff --git a/custom_components/omnibattery/const/integration_const.py b/custom_components/omnibattery/const/integration_const.py index 30ff868e..bea9fc59 100644 --- a/custom_components/omnibattery/const/integration_const.py +++ b/custom_components/omnibattery/const/integration_const.py @@ -575,6 +575,9 @@ def normalize_battery_phase(value: object) -> str: CONF_ENABLE_SYSTEM_POWER_LIMITS = "enable_system_power_limits" CONF_SYSTEM_MAX_CHARGE_POWER = "system_max_charge_power" CONF_SYSTEM_MAX_DISCHARGE_POWER = "system_max_discharge_power" +# Fronius/BYD ownership safety. True keeps SunSpec storage control active with +# a 0/0 idle window during setup, reload and orderly integration shutdown. +CONF_FRONIUS_INTERNAL_CONTROL_DISABLED = "fronius_internal_control_disabled" # Default PD Controller Parameters # Lowered from Kp 0.65 / Kd 0.5 to curb overshoot under the cadence-independent diff --git a/custom_components/omnibattery/control/charge_delay.py b/custom_components/omnibattery/control/charge_delay.py index f10a91cd..fecd15cc 100644 --- a/custom_components/omnibattery/control/charge_delay.py +++ b/custom_components/omnibattery/control/charge_delay.py @@ -257,7 +257,7 @@ def is_charge_delayed(self) -> bool: ( c.data.get("battery_soc", 100) for c in ctrl.coordinators - if c.data and not getattr(c, "battery_manual_mode_enabled", False) + if c.data and not ctrl._is_battery_manual_owned(c) ), default=100, ) @@ -322,7 +322,7 @@ def refresh_setpoint_blocks(self) -> None: and not ctrl._balance_monitor_overrides_delay() ) for coordinator in ctrl.coordinators: - if getattr(coordinator, "battery_manual_mode_enabled", False): + if ctrl._is_battery_manual_owned(coordinator): ctrl.remove_charge_block( "charge_delay_setpoint", coordinator=coordinator ) @@ -409,7 +409,7 @@ def _should_delay_charge(self, target_soc: int) -> bool: ctrl = self._controller automatic_batteries = [ coordinator for coordinator in ctrl.coordinators - if not getattr(coordinator, "battery_manual_mode_enabled", False) + if not ctrl._is_battery_manual_owned(coordinator) ] now = _decision_now() diff --git a/custom_components/omnibattery/control/max_soc_charge.py b/custom_components/omnibattery/control/max_soc_charge.py index f5dc4393..29d997f3 100644 --- a/custom_components/omnibattery/control/max_soc_charge.py +++ b/custom_components/omnibattery/control/max_soc_charge.py @@ -109,7 +109,7 @@ def _taper_enabled(coordinator) -> bool: def _taper_applies(self, coordinator) -> bool: """Return True when taper is enabled for this coordinator.""" - if getattr(coordinator, "battery_manual_mode_enabled", False): + if self._controller._is_battery_manual_owned(coordinator): return False if not self._taper_enabled(coordinator): return False @@ -171,7 +171,7 @@ def tick_bms_cutoff_retry_acceptance(self) -> None: if not retry_active.get(coordinator, False): accept_counts.pop(coordinator, None) continue - if getattr(coordinator, "battery_manual_mode_enabled", False): + if self._controller._is_battery_manual_owned(coordinator): # Manual mode owns the battery and must not advance automatic # handover state. Require a fresh uninterrupted acceptance # streak if automatic control later resumes. @@ -582,7 +582,7 @@ def refresh_blocks(self) -> None: self.reset_if_new_day() for coordinator in c.coordinators: - if getattr(coordinator, "battery_manual_mode_enabled", False): + if c._is_battery_manual_owned(coordinator): # Preserve normal top-of-charge state while the user owns the # battery; it is reevaluated after returning to automatic mode. c.remove_charge_block("max_soc", coordinator=coordinator) @@ -892,7 +892,7 @@ async def handle_measurement(self) -> bool: active_coordinators.add(coordinator) for coordinator in list(c._normal_balance_phases): - if getattr(coordinator, "battery_manual_mode_enabled", False): + if c._is_battery_manual_owned(coordinator): continue if coordinator not in active_coordinators: c._normal_balance_phases.pop(coordinator, None) diff --git a/custom_components/omnibattery/control/phase_power_limit.py b/custom_components/omnibattery/control/phase_power_limit.py index 0a50dfa7..05e23add 100644 --- a/custom_components/omnibattery/control/phase_power_limit.py +++ b/custom_components/omnibattery/control/phase_power_limit.py @@ -721,7 +721,7 @@ def limit_single_command( if ( other is not coordinator and self._battery_phase(other) == phase - and not getattr(other, "battery_manual_mode_enabled", False) + and not self.controller._is_battery_manual_owned(other) ) ) allowed = _round_down( diff --git a/custom_components/omnibattery/control/weekly_full_charge.py b/custom_components/omnibattery/control/weekly_full_charge.py index c1217020..4741f3af 100644 --- a/custom_components/omnibattery/control/weekly_full_charge.py +++ b/custom_components/omnibattery/control/weekly_full_charge.py @@ -125,7 +125,7 @@ def tick_bms_cutoff(self) -> None: # during the same control cycle. tick_retry_acceptance() for c in ctrl.coordinators: - if getattr(c, "battery_manual_mode_enabled", False): + if ctrl._is_battery_manual_owned(c): # Individual manual mode owns the battery. Freeze its cutoff # evidence so weekly charge cannot classify or reconfigure it. continue @@ -215,7 +215,7 @@ def is_battery_full(self, coordinator: Any) -> bool: Used by both handle_registers() (weekly completion) and _get_available_batteries() (normal max_soc=100% case). """ - if getattr(coordinator, "battery_manual_mode_enabled", False): + if self._controller._is_battery_manual_owned(coordinator): return False if not coordinator.data: return False @@ -457,7 +457,7 @@ async def _restore_hardware_cutoffs(self, reason: str) -> bool: all_ok = True saved = getattr(ctrl, "_weekly_charge_saved_max_soc", {}) for coordinator in ctrl.coordinators: - if getattr(coordinator, "battery_manual_mode_enabled", False): + if ctrl._is_battery_manual_owned(coordinator): continue if ctrl._is_backup_function_active(coordinator): continue @@ -542,12 +542,12 @@ async def handle_registers(self) -> None: automatic_batteries = [ coordinator for coordinator in ctrl.coordinators - if not getattr(coordinator, "battery_manual_mode_enabled", False) + if not ctrl._is_battery_manual_owned(coordinator) ] if not automatic_batteries: - # Do not mark a weekly run as active when every battery is owned by - # individual manual mode. Nothing may be written until a battery - # returns to the automatic pool. + # Do not mark a weekly run as active when no battery is owned by + # Omnibattery. Nothing may be written until one returns to the + # automatic pool. ctrl._weekly_charge_status["state"] = "Idle" ctrl._weekly_charge_status.pop("completion_reason", None) return @@ -567,7 +567,7 @@ async def handle_registers(self) -> None: pending_hardware = { coordinator.name for coordinator in ctrl.coordinators - if not getattr(coordinator, "battery_manual_mode_enabled", False) + if not ctrl._is_battery_manual_owned(coordinator) and not ctrl._is_backup_function_active(coordinator) and coordinator.capabilities.hardware_soc_cutoff } @@ -583,7 +583,7 @@ async def handle_registers(self) -> None: else: _LOGGER.info("Weekly Full Charge: Activating for compatible batteries") for coordinator in ctrl.coordinators: - if getattr(coordinator, "battery_manual_mode_enabled", False): + if ctrl._is_battery_manual_owned(coordinator): continue if ctrl._is_backup_function_active(coordinator): _LOGGER.debug("%s: Skipping weekly full charge - backup function is active", coordinator.name) @@ -648,7 +648,7 @@ async def handle_registers(self) -> None: c for c in ctrl.coordinators if c.data - and not getattr(c, "battery_manual_mode_enabled", False) + and not ctrl._is_battery_manual_owned(c) ] all_batteries_full = bool(batteries_with_data) and all( self.is_battery_full(c) @@ -663,7 +663,7 @@ async def handle_registers(self) -> None: } for c in ctrl.coordinators if c.data - and not getattr(c, "battery_manual_mode_enabled", False) + and not ctrl._is_battery_manual_owned(c) } if all_batteries_full and not ctrl.weekly_full_charge_complete: @@ -678,7 +678,7 @@ async def _complete_weekly_charge(self, reason: str) -> None: ctrl._weekly_charge_status["completion_reason"] = reason completion_batteries: dict = {} for coordinator in ctrl.coordinators: - if getattr(coordinator, "battery_manual_mode_enabled", False): + if ctrl._is_battery_manual_owned(coordinator): continue data = coordinator.data or {} soc = data.get("battery_soc") @@ -748,7 +748,7 @@ async def _complete_weekly_charge(self, reason: str) -> None: # the 60-second diagnostic measurement (measurement is best-effort). measured = getattr(ctrl, "_normal_balance_last_delta_v", {}) for coordinator in ctrl.coordinators: - if getattr(coordinator, "battery_manual_mode_enabled", False): + if ctrl._is_battery_manual_owned(coordinator): continue if ( measurement_state is not None @@ -784,7 +784,7 @@ async def _complete_weekly_charge(self, reason: str) -> None: # Re-enable hysteresis for batteries that have it configured. for coordinator in ctrl.coordinators: - if getattr(coordinator, "battery_manual_mode_enabled", False): + if ctrl._is_battery_manual_owned(coordinator): continue if coordinator.enable_charge_hysteresis: coordinator._hysteresis_active = True diff --git a/custom_components/omnibattery/diagnostics.py b/custom_components/omnibattery/diagnostics.py index ee43a07c..ee46181a 100644 --- a/custom_components/omnibattery/diagnostics.py +++ b/custom_components/omnibattery/diagnostics.py @@ -799,9 +799,12 @@ async def async_get_config_entry_diagnostics( "battery_manual_mode_enabled": bool( getattr(coord, "battery_manual_mode_enabled", False) ), - "automatic_pool": not bool( - getattr(coord, "battery_manual_mode_enabled", False) + "fronius_internal_control_disabled": bool( + getattr(coord, "fronius_internal_control_disabled", True) ), + "automatic_pool": not controller._is_battery_manual_owned(coord) + if controller is not None + else not bool(getattr(coord, "battery_manual_mode_enabled", False)), }, } for coord in coordinators @@ -813,7 +816,16 @@ async def async_get_config_entry_diagnostics( ] automatic_batteries = [ coord.name for coord in coordinators - if not getattr(coord, "battery_manual_mode_enabled", False) + if not ( + controller._is_battery_manual_owned(coord) + if controller is not None + else getattr(coord, "battery_manual_mode_enabled", False) + ) + ] + fronius_released_batteries = [ + coord.name for coord in coordinators + if getattr(coord, "brand", None) == "fronius_gen24" + and not getattr(coord, "fronius_internal_control_disabled", True) ] consumption_profile = {} @@ -857,6 +869,7 @@ async def async_get_config_entry_diagnostics( "control_pool": { "manual_batteries": manual_batteries, "automatic_batteries": automatic_batteries, + "fronius_released_batteries": fronius_released_batteries, }, "dynamic_pricing": _dynamic_pricing_info(controller), "daily_operation_timeline": _daily_operation_timeline_summary(controller), diff --git a/custom_components/omnibattery/drivers/fronius_gen24.py b/custom_components/omnibattery/drivers/fronius_gen24.py index 7744fef3..3e3d76fc 100644 --- a/custom_components/omnibattery/drivers/fronius_gen24.py +++ b/custom_components/omnibattery/drivers/fronius_gen24.py @@ -24,6 +24,7 @@ import asyncio import logging from dataclasses import dataclass +from time import monotonic from typing import Any, Optional try: @@ -70,12 +71,12 @@ _MODE_AUTO = 0 _MODE_STORAGE_CONTROL = 3 -_NEUTRAL_WINDOW_WORD = 10000 -_DEFAULT_MIN_RSV_WORD = 500 -_MIN_OPERATING_POWER_W = 10 -_MIN_DISCHARGE_DIRECTION_CHANGE_W = 750 -_SOC_LIMIT_HOLD_CHARGE_W = 10 -_SETTLE_SECONDS = 0.2 +_IDLE_WINDOW_WORD = 0 +_MIN_OPERATING_POWER_W = 150 +_DIRECTION_CHANGE_HOLD_SECONDS = 2.0 +_POWER_CONFIRM_TIMEOUT_SECONDS = 4.0 +_POWER_CONFIRM_POLL_SECONDS = 0.5 +_IDLE_POWER_TOLERANCE_W = 150 _STORAGE_API_PATH = "/solar_api/v1/GetStorageRealtimeData.cgi" _STORAGE_API_TIMEOUT_SECONDS = 5 _STORAGE_API_METADATA_KEYS = { @@ -265,13 +266,19 @@ def plan_storage_setpoint( * charge: ``OutWRte = 65535 - pct`` and ``InWRte = 1 + pct`` * discharge: ``OutWRte = int((power + 1) / WChaMax * 10000)`` and ``InWRte = 65535 - pct`` - * neutral: both windows are set to ``10000`` while external control remains - active. + * idle: both limits are set to 0% while external control remains active. + Fronius documents this as the power window ``[0 W, 0 W]``. A 100%/100% + window would permit the complete automatic charge/discharge range. """ wcha = max(1, int(wcha_max_w or 1)) charge_ceiling = min(wcha, max(0, int(max_charge_power_w or 0))) discharge_ceiling = min(wcha, max(0, int(max_discharge_power_w or 0))) + if (net_power_w > 0 and charge_ceiling == 0) or ( + net_power_w < 0 and discharge_ceiling == 0 + ): + net_power_w = 0 + if net_power_w > 0: power = _clamp_power(net_power_w, charge_ceiling) pct = int(power / wcha * 10000) @@ -292,34 +299,29 @@ def plan_storage_setpoint( out_word = int((power + 1) / wcha * 10000) in_word = _int16_word(-(pct + 1)) writes = ( - RegisterWrite(layout.storctl_mod, _MODE_STORAGE_CONTROL), RegisterWrite(layout.outwrte, out_word), RegisterWrite(layout.inwrte, in_word), + RegisterWrite(layout.storctl_mod, _MODE_STORAGE_CONTROL), RegisterWrite(layout.outwrte, out_word), RegisterWrite(layout.inwrte, in_word), ) return SetpointPlan(-power, "discharge", out_word, in_word, writes) writes = ( - RegisterWrite(layout.outwrte, _NEUTRAL_WINDOW_WORD), - RegisterWrite(layout.inwrte, _NEUTRAL_WINDOW_WORD), + RegisterWrite(layout.outwrte, _IDLE_WINDOW_WORD), + RegisterWrite(layout.inwrte, _IDLE_WINDOW_WORD), RegisterWrite(layout.storctl_mod, _MODE_STORAGE_CONTROL), - RegisterWrite(layout.outwrte, _NEUTRAL_WINDOW_WORD), - RegisterWrite(layout.inwrte, _NEUTRAL_WINDOW_WORD), + RegisterWrite(layout.outwrte, _IDLE_WINDOW_WORD), + RegisterWrite(layout.inwrte, _IDLE_WINDOW_WORD), ) - return SetpointPlan(0, "neutral", _NEUTRAL_WINDOW_WORD, _NEUTRAL_WINDOW_WORD, writes) + return SetpointPlan(0, "idle", _IDLE_WINDOW_WORD, _IDLE_WINDOW_WORD, writes) def plan_reset_to_auto( layout: SunSpecLayout = SUNSPEC_FLOAT_LAYOUT, ) -> tuple[RegisterWrite, ...]: - """Return control to Fronius auto mode using the local reset script values.""" - return ( - RegisterWrite(layout.storctl_mod, _MODE_AUTO), - RegisterWrite(layout.outwrte, _NEUTRAL_WINDOW_WORD), - RegisterWrite(layout.min_rsv_pct, _DEFAULT_MIN_RSV_WORD), - RegisterWrite(layout.inwrte, _NEUTRAL_WINDOW_WORD), - ) + """Release external control without changing the user's Fronius settings.""" + return (RegisterWrite(layout.storctl_mod, _MODE_AUTO),) def decode_storage_registers(regs: list[int]) -> TelemetrySnapshot: @@ -478,6 +480,11 @@ def __init__( self._last_net_power_w: Optional[int] = None self._last_inout_sf = -2 self._serial: Optional[str] = None + # Fail-safe default: retain external storage control across teardown. + # Releasing control to Fronius must be an explicit persisted choice. + self._internal_control_disabled = True + self._last_active_sign = 0 + self._idle_since_monotonic: Optional[float] = None # Float is the Fronius default and preserves the established behavior # until connect() verifies the Model 124 header. self._sunspec_layout = SUNSPEC_FLOAT_LAYOUT @@ -531,9 +538,9 @@ def __init__( has_energy_counters=False, has_nominal_capacity=False, has_daily_energy_counters=False, - setpoint_confirm_reliable=True, - actuator_latency_s=1.0, - readback_latency_s=1.5, + setpoint_confirm_reliable=False, + actuator_latency_s=2.0, + readback_latency_s=2.0, ) @property @@ -828,37 +835,40 @@ async def apply_setpoint( failure_reason="missing_wchamax", ) - # The GEN24/BYD storage controller reacts to every sign change as a real - # mode transition. Small discharge requests during PV charging therefore - # collapse a steady kW-scale charge into a brief discharge/idle cycle - # before the inverter ramps back up. Treat those sub-material discharge - # requests as idle and let Fronius continue absorbing PV surplus. requested_power_w = int(net_power_w) - if ( - requested_power_w < 0 - and abs(requested_power_w) <= _MIN_DISCHARGE_DIRECTION_CHANGE_W - ): + if 0 < abs(requested_power_w) < _MIN_OPERATING_POWER_W: _LOGGER.debug( - "Suppressing small Fronius GEN24 discharge request %d W at/below " - "%d W; writing neutral storage control instead", + "Suppressing sub-minimum Fronius GEN24 request %d W below %d W; " + "writing idle storage control instead", requested_power_w, - _MIN_DISCHARGE_DIRECTION_CHANGE_W, + _MIN_OPERATING_POWER_W, ) requested_power_w = 0 - if self._last_soc_pct is None and requested_power_w >= 0 and self._max_soc_pct < 100.0: - self._remember_soc(await self.read_telemetry(["battery_soc"])) - if requested_power_w >= 0 and self._max_soc_reached(): - if requested_power_w != _SOC_LIMIT_HOLD_CHARGE_W: - _LOGGER.debug( - "Fronius GEN24 max SOC %.1f%% reached at %.1f%%; holding " - "storage control with %d W charge limit instead of %d W", - self._max_soc_pct, - self._last_soc_pct, - _SOC_LIMIT_HOLD_CHARGE_W, - requested_power_w, + requested_sign = ( + 1 if requested_power_w > 0 else -1 if requested_power_w < 0 else 0 + ) + now = monotonic() + if requested_sign == 0: + if ( + self._last_net_power_w not in (None, 0) + and self._idle_since_monotonic is None + ): + self._idle_since_monotonic = now + elif self._last_active_sign and requested_sign != self._last_active_sign: + if self._idle_since_monotonic is None: + self._idle_since_monotonic = now + idle_s = now - self._idle_since_monotonic + if idle_s < _DIRECTION_CHANGE_HOLD_SECONDS: + _LOGGER.info( + "Fronius GEN24 direction change held at idle for %.1fs/%.1fs", + idle_s, + _DIRECTION_CHANGE_HOLD_SECONDS, ) - requested_power_w = _SOC_LIMIT_HOLD_CHARGE_W + requested_power_w = 0 + requested_sign = 0 + elif requested_sign == self._last_active_sign: + self._idle_since_monotonic = None plan = plan_storage_setpoint( requested_power_w, @@ -877,6 +887,12 @@ async def apply_setpoint( ) self._last_net_power_w = plan.net_power_w + applied_sign = ( + 1 if plan.net_power_w > 0 else -1 if plan.net_power_w < 0 else 0 + ) + if applied_sign: + self._last_active_sign = applied_sign + self._idle_since_monotonic = None applied = { "commanded_net_power": plan.net_power_w, "storctl_mod": _MODE_STORAGE_CONTROL, @@ -892,9 +908,10 @@ async def apply_setpoint( applied=applied, ) - await asyncio.sleep(_SETTLE_SECONDS) - echo = await self.read_telemetry(["storctl_mod", "outwrte", "inwrte", "battery_power"]) - if not {"storctl_mod", "outwrte", "inwrte"}.issubset(echo): + echo, registers_confirmed, power_confirmed = ( + await self._confirm_applied_plan(plan) + ) + if not echo: return SetpointResult( ok=True, net_power_w=plan.net_power_w, @@ -902,17 +919,11 @@ async def apply_setpoint( failure_reason="feedback_timeout", ) - expected_out = _decode_int16(plan.outwrte_word) - expected_in = _decode_int16(plan.inwrte_word) - confirmed = ( - int(echo["storctl_mod"]) == _MODE_STORAGE_CONTROL - and int(echo["outwrte"]) == expected_out - and int(echo["inwrte"]) == expected_in - ) + confirmed = registers_confirmed and power_confirmed applied.update({ - "storctl_mod": echo["storctl_mod"], - "outwrte": echo["outwrte"], - "inwrte": echo["inwrte"], + "storctl_mod": echo.get("storctl_mod", applied["storctl_mod"]), + "outwrte": echo.get("outwrte", applied["outwrte"]), + "inwrte": echo.get("inwrte", applied["inwrte"]), }) battery_power = echo.get("battery_power") if battery_power is not None: @@ -922,17 +933,90 @@ async def apply_setpoint( net_power_w=plan.net_power_w, confirmed=confirmed, exact=confirmed, - failure_reason=None if confirmed else "ack_mismatch", + failure_reason=( + None + if confirmed + else "ack_mismatch" + if not registers_confirmed + else "power_not_settled" + ), battery_power_w=int(battery_power) if battery_power is not None else None, applied=applied, ) + @staticmethod + def _registers_match_plan( + plan: SetpointPlan, echo: TelemetrySnapshot + ) -> bool: + try: + return ( + int(echo["storctl_mod"]) == _MODE_STORAGE_CONTROL + and int(echo["outwrte"]) == _decode_int16(plan.outwrte_word) + and int(echo["inwrte"]) == _decode_int16(plan.inwrte_word) + ) + except (KeyError, TypeError, ValueError): + return False + + @staticmethod + def _power_matches_plan(plan: SetpointPlan, battery_power: Any) -> bool: + try: + measured_w = float(battery_power) + except (TypeError, ValueError): + return False + if plan.net_power_w == 0: + return abs(measured_w) <= _IDLE_POWER_TOLERANCE_W + threshold_w = max(_MIN_OPERATING_POWER_W, abs(plan.net_power_w) * 0.10) + return ( + measured_w >= threshold_w + if plan.net_power_w > 0 + else measured_w <= -threshold_w + ) + + async def _confirm_applied_plan( + self, plan: SetpointPlan + ) -> tuple[TelemetrySnapshot, bool, bool]: + """Wait for both the register acknowledgement and delivered direction.""" + deadline = monotonic() + _POWER_CONFIRM_TIMEOUT_SECONDS + last_echo: TelemetrySnapshot = {} + registers_confirmed = False + while True: + last_echo = await self.read_telemetry( + ["storctl_mod", "outwrte", "inwrte", "battery_power"] + ) + registers_confirmed = self._registers_match_plan(plan, last_echo) + if registers_confirmed and self._power_matches_plan( + plan, last_echo.get("battery_power") + ): + return last_echo, True, True + remaining_s = deadline - monotonic() + if remaining_s <= 0: + return last_echo, registers_confirmed, False + await asyncio.sleep(min(_POWER_CONFIRM_POLL_SECONDS, remaining_s)) + async def _write_plan(self, writes: tuple[RegisterWrite, ...]) -> bool: self._client.unit_id = self._slave_id - ok = True - for write in writes: - ok = await self._client.async_write_register(write.address, write.value) and ok - return bool(ok) + index = 0 + while index < len(writes): + write = writes[index] + if ( + index + 1 < len(writes) + and write.address == self._sunspec_layout.outwrte + and writes[index + 1].address == self._sunspec_layout.inwrte + and hasattr(self._client, "async_write_registers") + ): + ok = await self._client.async_write_registers( + self._sunspec_layout.outwrte, + [write.value, writes[index + 1].value], + ) + index += 2 + else: + ok = await self._client.async_write_register( + write.address, write.value + ) + index += 1 + if not ok: + return False + return True async def write_control(self, key: str, value: int) -> bool: address_by_key = { @@ -959,7 +1043,7 @@ def net_power_from_data(self, data: dict) -> Optional[int]: return None if mode != _MODE_STORAGE_CONTROL: return None - if outwrte == _NEUTRAL_WINDOW_WORD and inwrte == _NEUTRAL_WINDOW_WORD: + if outwrte == _IDLE_WINDOW_WORD and inwrte == _IDLE_WINDOW_WORD: return 0 wcha = data.get("wcha_max") or data.get("max_charge_power") or self._wcha_max_w @@ -1017,12 +1101,45 @@ async def set_charge_cutoff(self, soc_pct: float) -> bool: _ = soc_pct return False + def configure_internal_control_disabled(self, disabled: bool) -> None: + """Choose the persistent ownership policy used by :meth:`standby`.""" + self._internal_control_disabled = bool(disabled) + + async def set_internal_control_disabled(self, disabled: bool) -> bool: + """Apply an explicit BYD/Fronius ownership transition immediately.""" + if not self.connected: + return False + if disabled: + result = await self.apply_setpoint(0, read_back=False) + if result.ok: + self._internal_control_disabled = True + return result.ok + + ok = await self._write_plan( + plan_reset_to_auto(self._sunspec_layout) + ) + if ok: + self._internal_control_disabled = False + self._last_net_power_w = 0 + self._last_active_sign = 0 + self._idle_since_monotonic = None + return ok + async def standby(self) -> bool: - """Return control to Fronius automatic mode for integration unload.""" + """Apply the persisted ownership policy during integration unload. + + The safe default retains external control with a genuine 0/0 idle + window. Fronius automatic control is restored only after an explicit + release through the dedicated device switch. + """ if not self.connected: return False - self._last_net_power_w = 0 - return await self._write_plan(plan_reset_to_auto(self._sunspec_layout)) + if not self._internal_control_disabled: + return await self._write_plan( + plan_reset_to_auto(self._sunspec_layout) + ) + result = await self.apply_setpoint(0, read_back=False) + return result.ok @classmethod async def probe( diff --git a/custom_components/omnibattery/frontend/marstek-panel.js b/custom_components/omnibattery/frontend/marstek-panel.js index 472d794d..18271057 100644 --- a/custom_components/omnibattery/frontend/marstek-panel.js +++ b/custom_components/omnibattery/frontend/marstek-panel.js @@ -99,7 +99,7 @@ const I18N = { ctlHide: "Hide card", ctlShow: "Show card", ctlHidden: "Hidden cards", sysEmptyTitle: "No controls available", sysEmptyMsg: "This integration exposes no system controls, or they are disabled. Enable them in Settings → entities.", - bcAllowCharge: "Allow charge", bcAllowDischarge: "Allow discharge", bcBatteryManual: "Manual battery control", + bcAllowCharge: "Allow charge", bcAllowDischarge: "Allow discharge", bcBatteryManual: "Manual battery control", bcFroniusLock: "Keep Fronius/BYD internal control disabled", bcSocMax: "Max SOC", bcSocMin: "Min SOC", bcForceMode: "Force mode", bcChargePower: "Charge power", bcDischargePower: "Discharge power", bcMaxCharge: "Max charge", bcMaxDischarge: "Max discharge", @@ -184,7 +184,7 @@ const I18N = { ctlHide: "Ocultar tarjeta", ctlShow: "Mostrar tarjeta", ctlHidden: "Tarjetas ocultas", sysEmptyTitle: "Sin controles disponibles", sysEmptyMsg: "Esta integración no expone controles de sistema, o están deshabilitados. Actívalos en Ajustes → entidades.", - bcAllowCharge: "Permitir carga", bcAllowDischarge: "Permitir descarga", bcBatteryManual: "Control manual de batería", + bcAllowCharge: "Permitir carga", bcAllowDischarge: "Permitir descarga", bcBatteryManual: "Control manual de batería", bcFroniusLock: "Mantener desactivado el control interno Fronius/BYD", bcSocMax: "SOC máximo", bcSocMin: "SOC mínimo", bcForceMode: "Modo forzado", bcChargePower: "Potencia de carga", bcDischargePower: "Potencia de descarga", bcMaxCharge: "Máx. carga", bcMaxDischarge: "Máx. descarga", @@ -266,7 +266,7 @@ const I18N = { ctlEmpty: "No hi ha controls habilitats. Activa'ls al dispositiu (Configuració → entitats deshabilitades).", sysEmptyTitle: "Sense controls disponibles", sysEmptyMsg: "Aquesta integració no exposa controls de sistema, o estan deshabilitats. Activa'ls a Configuració → entitats.", - bcAllowCharge: "Permet la càrrega", bcAllowDischarge: "Permet la descàrrega", bcBatteryManual: "Control manual de la bateria", + bcAllowCharge: "Permet la càrrega", bcAllowDischarge: "Permet la descàrrega", bcBatteryManual: "Control manual de la bateria", bcFroniusLock: "Mantén desactivat el control intern Fronius/BYD", bcSocMax: "SOC màxim", bcSocMin: "SOC mínim", bcForceMode: "Mode forçat", bcChargePower: "Potència de càrrega", bcDischargePower: "Potència de descàrrega", bcMaxCharge: "Màx. càrrega", bcMaxDischarge: "Màx. descàrrega", @@ -347,7 +347,7 @@ const I18N = { ctlEmpty: "Keine Steuerungen aktiviert. Aktiviere sie am Gerät (Einstellungen → deaktivierte Entitäten).", sysEmptyTitle: "Keine Steuerungen verfügbar", sysEmptyMsg: "Diese Integration stellt keine Systemsteuerungen bereit oder sie sind deaktiviert. Aktiviere sie in Einstellungen → Entitäten.", - bcAllowCharge: "Laden erlauben", bcAllowDischarge: "Entladen erlauben", bcBatteryManual: "Manuelle Batteriesteuerung", + bcAllowCharge: "Laden erlauben", bcAllowDischarge: "Entladen erlauben", bcBatteryManual: "Manuelle Batteriesteuerung", bcFroniusLock: "Fronius/BYD-Eigensteuerung dauerhaft sperren", bcSocMax: "Max. SOC", bcSocMin: "Min. SOC", bcForceMode: "Betriebsmodus erzwingen", bcChargePower: "Ladeleistung", bcDischargePower: "Entladeleistung", bcMaxCharge: "Max. Ladeleistung", bcMaxDischarge: "Max. Entladeleistung", @@ -428,7 +428,7 @@ const I18N = { ctlEmpty: "Aucun contrôle activé. Activez-les sur l'appareil (Paramètres → entités désactivées).", sysEmptyTitle: "Aucun contrôle disponible", sysEmptyMsg: "Cette intégration n'expose aucun contrôle système, ou ils sont désactivés. Activez-les dans Paramètres → entités.", - bcAllowCharge: "Autoriser la charge", bcAllowDischarge: "Autoriser la décharge", bcBatteryManual: "Contrôle manuel de la batterie", + bcAllowCharge: "Autoriser la charge", bcAllowDischarge: "Autoriser la décharge", bcBatteryManual: "Contrôle manuel de la batterie", bcFroniusLock: "Maintenir le contrôle interne Fronius/BYD désactivé", bcSocMax: "SOC max.", bcSocMin: "SOC min.", bcForceMode: "Mode forcé", bcChargePower: "Puissance de charge", bcDischargePower: "Puissance de décharge", bcMaxCharge: "Charge max.", bcMaxDischarge: "Décharge max.", @@ -509,7 +509,7 @@ const I18N = { ctlEmpty: "Geen bedieningen ingeschakeld. Schakel ze in op het apparaat (Instellingen → uitgeschakelde entiteiten).", sysEmptyTitle: "Geen bedieningen beschikbaar", sysEmptyMsg: "Deze integratie biedt geen systeembedieningen, of ze zijn uitgeschakeld. Schakel ze in via Instellingen → entiteiten.", - bcAllowCharge: "Laden toestaan", bcAllowDischarge: "Ontladen toestaan", bcBatteryManual: "Handmatige batterijregeling", + bcAllowCharge: "Laden toestaan", bcAllowDischarge: "Ontladen toestaan", bcBatteryManual: "Handmatige batterijregeling", bcFroniusLock: "Interne Fronius/BYD-regeling uitgeschakeld houden", bcSocMax: "Max. SOC", bcSocMin: "Min. SOC", bcForceMode: "Geforceerde modus", bcChargePower: "Laadvermogen", bcDischargePower: "Ontlaadvermogen", bcMaxCharge: "Max. laden", bcMaxDischarge: "Max. ontladen", @@ -914,6 +914,7 @@ const BAT_CONTROLS = [ { key: "battery_allow_charge", domain: "switch", lk: "bcAllowCharge", icon: "mdi:battery-arrow-up" }, { key: "battery_allow_discharge", domain: "switch", lk: "bcAllowDischarge", icon: "mdi:battery-arrow-down" }, { key: "battery_manual_mode", domain: "switch", lk: "bcBatteryManual", icon: "mdi:hand-back-right-outline" }, + { key: "fronius_internal_control_disabled", domain: "switch", lk: "bcFroniusLock", icon: "mdi:battery-lock" }, // SOC limits: the Marstek register and its Zendure equivalent share each label; // only one of each pair exists on a given device, so both layouts read // "SOC máximo" then "SOC mínimo" in this order. @@ -1210,6 +1211,7 @@ const SYS_HELP = { secOffgridMeter: "Selects the configured off-grid power sensor as the source for control and derived statistics. It does not enable any battery off-grid/EPS port. A battery actively supplying its own off-grid output remains excluded from PD.", vacation_mode: "When ON, household-consumption learning and the legacy daily average are paused. Physical consumption meters, the daily-operation graph and battery control continue normally. Forecasts use a constant baseline calculated from 01:00–05:00: a night is valid after 3 hours of coverage, using the median of up to the last three valid nights. Turn it OFF to resume learning; vacation data remains excluded from Recorder backfill.", battery_manual_mode: "When ON, this battery is idled once and removed from automatic control. Its manual force mode and setpoints can then be selected while other batteries continue automatically. Omnibattery software limits do not constrain it, but the battery's own BMS/driver protections still apply. Global Manual Mode is separate.", + fronius_internal_control_disabled: "When ON (recommended), Omnibattery retains external storage control with a 0/0 idle window during setup, reload and orderly shutdown. Turning it OFF explicitly returns control to Fronius and removes BYD from Omnibattery's automatic pool.", secWeeklyFull: "Select the day of the week when batteries should charge to 100% for cell balancing. After reaching 100%, the system reverts to your configured maximum charge limit.", secSlots: "Define when and how the batteries are allowed to operate. The ticks control each direction, SOC and power. Manual mode forces an exact power, bypassing the PD algorithm.", secExcluded: "Configure devices with special management: you can EXCLUDE devices that should NOT be powered by battery, or ADD devices that SHOULD be powered by battery even if they're not in the home consumption sensor.", @@ -1286,6 +1288,7 @@ const SYS_HELP = { secOffgridMeter: "Selecciona el sensor de potencia off-grid configurado como fuente del control y de las estadísticas derivadas. No habilita ningún puerto off-grid/EPS. Una batería que suministre por su propia salida off-grid sigue excluida del PD.", vacation_mode: "Al ACTIVARLO se pausan el aprendizaje del consumo doméstico y la media diaria heredada. Los contadores físicos, el gráfico de operación diaria y el control de las baterías siguen funcionando normalmente. Las previsiones usan un baseline constante calculado entre las 01:00 y las 05:00: una noche es válida con 3 horas de cobertura y se usa la mediana de hasta las tres últimas noches válidas. DESACTÍVALO para reanudar el aprendizaje; los datos vacacionales seguirán excluidos del backfill de Recorder.", battery_manual_mode: "Al ACTIVARLO, esta batería pasa una vez a 0 W y sale del control automático. Sus modos y consignas manuales se pueden elegir entonces; las demás baterías continúan en automático. Los límites de software de Omnibattery no la restringen, pero sí las protecciones propias del BMS/driver. El Modo manual global es independiente.", + fronius_internal_control_disabled: "ACTIVADO (recomendado) mantiene el control externo con una ventana de reposo 0/0 durante configuración, recarga y apagado. DESACTIVADO devuelve explícitamente el control a Fronius y elimina BYD del grupo automático.", secWeeklyFull: "Selecciona el día de la semana en el que las baterías deben cargarse al 100% para el balanceo de celdas. Una vez alcanzado el 100%, el sistema revertirá al límite de carga máximo configurado.", secSlots: "Define cuándo y cómo se permite operar a las baterías. Los ticks permiten controlar cada dirección, el SOC y la potencia. El modo manual fuerza una potencia exacta ignorando el algoritmo PD.", secExcluded: "Configura dispositivos con gestión especial: puedes EXCLUIR dispositivos que NO deben alimentarse por batería, o AÑADIR dispositivos que SÍ debe alimentar la batería aunque no estén en el sensor de consumo del hogar.", @@ -1353,6 +1356,7 @@ const SYS_HELP = { secManual: "Quan està ACTIVAT, el control automàtic (PD, càrrega predictiva, franges horàries, reducció de pics…) es pausa i totes les bateries es posen a 0 W (en repòs). DESACTIVA'L per reprendre el control automàtic.", vacation_mode: "Quan està ACTIVAT, es pausen l'aprenentatge del consum domèstic i la mitjana diària heretada. Els comptadors físics, el gràfic d'operació diària i el control de les bateries continuen funcionant normalment. Les previsions utilitzen un baseline constant calculat entre la 01:00 i les 05:00: una nit és vàlida amb 3 hores de cobertura i s'utilitza la mediana de fins a les tres últimes nits vàlides. DESACTIVA'L per reprendre l'aprenentatge; les dades de vacances continuaran excloses del backfill de Recorder.", battery_manual_mode: "Quan s'ACTIVA, aquesta bateria passa una vegada a 0 W i surt del control automàtic. Els seus modes i consignes manuals es poden triar aleshores; les altres bateries segueixen en automàtic. Els límits de programari d'Omnibattery no la restringeixen, però sí les proteccions pròpies del BMS/driver. El mode manual global és independent.", + fronius_internal_control_disabled: "ACTIVAT (recomanat) manté el control extern amb una finestra de repòs 0/0 durant configuració, recàrrega i aturada. DESACTIVAT retorna explícitament el control a Fronius i treu BYD del grup automàtic.", secWeeklyFull: "Selecciona el dia de la setmana en què les bateries s'han de carregar al 100% per a l'equilibratge de cel·les. Un cop assolit el 100%, el sistema tornarà al límit de càrrega màxim configurat.", secSlots: "Defineix quan i com es permet operar a les bateries. Els ticks permeten controlar cada direcció, el SOC i la potència. El mode manual força una potència exacta ignorant l'algorisme PD.", secExcluded: "Configura dispositius amb gestió especial: pots EXCLOURE dispositius que NO s'han d'alimentar per bateria, o AFEGIR dispositius que SÍ ha d'alimentar la bateria encara que no estiguin al sensor de consum de la llar.", @@ -1418,6 +1422,7 @@ const SYS_HELP = { secManual: "Wenn EIN, wird die automatische Regelung (PD, prädiktives Laden, Zeitfenster, Lastspitzenkappung…) pausiert und jede Batterie auf 0 W (Leerlauf) gesetzt. Schalte AUS, um die automatische Regelung fortzusetzen.", vacation_mode: "Wenn EIN, werden das Lernen des Haushaltsverbrauchs und der bisherige Tagesmittelwert pausiert. Physische Verbrauchszähler, das Tagesbetriebsdiagramm und die Batteriesteuerung laufen normal weiter. Prognosen verwenden eine konstante Grundlast aus 01:00–05:00 Uhr: Eine Nacht gilt ab 3 Stunden Abdeckung; verwendet wird der Median der bis zu drei letzten gültigen Nächte. Schalte AUS, um das Lernen fortzusetzen; Urlaubsdaten bleiben vom Recorder-Backfill ausgeschlossen.", battery_manual_mode: "Wenn EIN, wird diese Batterie einmal auf 0 W gesetzt und aus der automatischen Regelung genommen. Ihr manueller Modus und ihre Sollwerte können danach gewählt werden; andere Batterien laufen automatisch weiter. Omnibattery-Softwaregrenzen wirken nicht, die eigenen BMS-/Treiber-Schutzfunktionen jedoch schon. Der globale manuelle Modus ist unabhängig.", + fronius_internal_control_disabled: "Wenn EIN (empfohlen), behält Omnibattery die externe Speichersteuerung mit einem 0/0-Leerlauffenster bei Einrichtung, Neuladen und geordnetem Herunterfahren. AUS gibt die Steuerung ausdrücklich an Fronius zurück und entfernt BYD aus dem automatischen Pool.", secWeeklyFull: "Wähle den Wochentag, an dem die Batterien zum Zellausgleich auf 100% geladen werden. Nach Erreichen von 100% kehrt das System zum konfigurierten maximalen Ladelimit zurück.", secSlots: "Lege fest, wann und wie die Batterien arbeiten dürfen. Die Häkchen steuern jede Richtung, SOC und Leistung. Der manuelle Modus erzwingt eine exakte Leistung und umgeht den PD-Algorithmus.", secExcluded: "Geräte mit spezieller Verwaltung konfigurieren: Du kannst Geräte AUSSCHLIESSEN, die NICHT von der Batterie versorgt werden sollen, oder Geräte HINZUFÜGEN, die von der Batterie versorgt werden SOLLEN, auch wenn sie nicht im Hausverbrauchssensor erfasst sind.", @@ -1483,6 +1488,7 @@ const SYS_HELP = { secManual: "Quand ACTIVÉ, le contrôle automatique (PD, charge prédictive, plages horaires, écrêtage des pics…) est mis en pause et chaque batterie est réglée à 0 W (repos). DÉSACTIVE-le pour reprendre le contrôle automatique.", vacation_mode: "Quand il est ACTIVÉ, l'apprentissage de la consommation du foyer et l'ancienne moyenne journalière sont suspendus. Les compteurs physiques, le graphique d'opération quotidienne et le contrôle des batteries continuent normalement. Les prévisions utilisent une charge de base constante calculée de 01:00 à 05:00 : une nuit est valide avec 3 heures de couverture et la médiane des trois dernières nuits valides au maximum est utilisée. DÉSACTIVE-le pour reprendre l'apprentissage ; les données de vacances restent exclues du backfill Recorder.", battery_manual_mode: "Lorsque cette option est activée, cette batterie passe une fois à 0 W et sort du contrôle automatique. Son mode et ses consignes manuels peuvent ensuite être choisis ; les autres batteries continuent en automatique. Les limites logicielles d'Omnibattery ne s'appliquent pas, mais les protections du BMS/driver restent actives. Le mode manuel global est indépendant.", + fronius_internal_control_disabled: "ACTIVÉ (recommandé) conserve le contrôle externe avec une fenêtre de repos 0/0 pendant la configuration, le rechargement et l'arrêt. DÉSACTIVÉ rend explicitement le contrôle à Fronius et retire BYD du groupe automatique.", secWeeklyFull: "Sélectionne le jour de la semaine où les batteries doivent se charger à 100% pour l'équilibrage des cellules. Une fois 100% atteint, le système revient à la limite de charge maximale configurée.", secSlots: "Définis quand et comment les batteries sont autorisées à fonctionner. Les cases contrôlent chaque direction, le SOC et la puissance. Le mode manuel force une puissance exacte en contournant l'algorithme PD.", secExcluded: "Configure des appareils avec une gestion spéciale : tu peux EXCLURE des appareils qui ne doivent PAS être alimentés par la batterie, ou AJOUTER des appareils qui DOIVENT être alimentés par la batterie même s'ils ne sont pas dans le capteur de consommation domestique.", @@ -1548,6 +1554,7 @@ const SYS_HELP = { secManual: "Wanneer AAN, wordt de automatische regeling (PD, voorspellend laden, tijdvensters, piekafvlakking…) gepauzeerd en wordt elke batterij op 0 W (rust) gezet. Zet UIT om de automatische regeling te hervatten.", vacation_mode: "Wanneer AAN, worden het leren van het huishoudverbruik en het oude daggemiddelde gepauzeerd. Fysieke verbruiksmeters, de dagelijkse werkinggrafiek en de batterijregeling blijven normaal werken. Prognoses gebruiken een constante basislast uit 01:00–05:00: een nacht is geldig vanaf 3 uur dekking en de mediaan van maximaal de laatste drie geldige nachten wordt gebruikt. Zet UIT om het leren te hervatten; vakantiegegevens blijven uitgesloten van Recorder-backfill.", battery_manual_mode: "Als deze optie AAN staat, wordt deze batterij eenmalig op 0 W gezet en uit de automatische regeling gehaald. De handmatige modus en setpoints kunnen daarna worden gekozen; andere batterijen blijven automatisch werken. Softwarelimieten van Omnibattery gelden niet, maar de eigen BMS-/driverbeveiliging wel. De globale handmatige modus staat hier los van.", + fronius_internal_control_disabled: "AAN (aanbevolen) behoudt externe regeling met een 0/0-rustvenster tijdens configuratie, herladen en afsluiten. UIT geeft de regeling expliciet terug aan Fronius en verwijdert BYD uit de automatische groep.", secWeeklyFull: "Selecteer de dag van de week waarop de batterijen tot 100% moeten laden voor celbalancering. Na het bereiken van 100% keert het systeem terug naar de geconfigureerde maximale laadlimiet.", secSlots: "Bepaal wanneer en hoe de batterijen mogen werken. De vinkjes regelen elke richting, SOC en vermogen. De handmatige modus forceert een exact vermogen en omzeilt het PD-algoritme.", secExcluded: "Configureer apparaten met speciaal beheer: je kunt apparaten UITSLUITEN die NIET door de batterij gevoed mogen worden, of apparaten TOEVOEGEN die WEL door de batterij gevoed moeten worden, ook al staan ze niet in de huisverbruikssensor.", diff --git a/custom_components/omnibattery/infra/coordinator.py b/custom_components/omnibattery/infra/coordinator.py index 9aec7eb5..3bf65bed 100644 --- a/custom_components/omnibattery/infra/coordinator.py +++ b/custom_components/omnibattery/infra/coordinator.py @@ -130,6 +130,7 @@ def __init__(self, hass: HomeAssistant, name: str, host: str, port: int, consump username: str = "", password: str = "", battery_manual_mode_enabled: bool = False, + fronius_internal_control_disabled: bool = True, device_max_charge_power: int | None = None, device_max_discharge_power: int | None = None, ems_version: object = None, @@ -203,6 +204,9 @@ def __init__(self, hass: HomeAssistant, name: str, host: str, port: int, consump # without force_mode / set_*_power registers (e.g. Zendure). The # controller asserts these via apply_setpoint each cycle. Persisted. self.battery_manual_mode_enabled = bool(battery_manual_mode_enabled) + self.fronius_internal_control_disabled = bool( + fronius_internal_control_disabled + ) self.manual_force_mode = "None" self.manual_set_charge_power = 0 self.manual_set_discharge_power = 0 @@ -336,6 +340,9 @@ def __init__(self, hass: HomeAssistant, name: str, host: str, port: int, consump max_charge_power_w=self.configured_max_charge_power, max_discharge_power_w=self.configured_max_discharge_power, ) + self.driver.configure_internal_control_disabled( + self.fronius_internal_control_disabled + ) else: self.driver = MarstekModbusDriver( self.host, self.port, self.battery_version, self.slave_id, @@ -1655,3 +1662,32 @@ async def standby(self) -> bool: if not self._is_shutting_down: _LOGGER.error("[%s] Exception setting standby: %s", self.name, e) return False + + async def set_fronius_internal_control_disabled(self, disabled: bool) -> bool: + """Retain external idle control or explicitly release it to Fronius.""" + if self.brand != "fronius_gen24": + return False + previous = self.fronius_internal_control_disabled + # Publish the ownership transition before waiting for the device lock so + # a new automatic cycle cannot queue another Fronius write meanwhile. + self.fronius_internal_control_disabled = bool(disabled) + async with self.lock: + try: + ok = await self.driver.set_internal_control_disabled(disabled) + except Exception as err: + if not self._is_shutting_down: + _LOGGER.error( + "[%s] Exception changing Fronius/BYD ownership: %s", + self.name, + err, + ) + self.fronius_internal_control_disabled = previous + self.driver.configure_internal_control_disabled(previous) + return False + if ok: + self._consecutive_failures = 0 + self._is_connected = True + else: + self.fronius_internal_control_disabled = previous + self.driver.configure_internal_control_disabled(previous) + return ok diff --git a/custom_components/omnibattery/infra/manual_control.py b/custom_components/omnibattery/infra/manual_control.py index ca68b770..284b06e5 100644 --- a/custom_components/omnibattery/infra/manual_control.py +++ b/custom_components/omnibattery/infra/manual_control.py @@ -40,6 +40,9 @@ def controller_owns_battery(hass, coordinator) -> bool: return False if getattr(controller, "manual_mode_enabled", False): return False + ownership_check = getattr(controller, "_is_battery_manual_owned", None) + if callable(ownership_check): + return not ownership_check(coordinator) return not bool(getattr(coordinator, CONF_BATTERY_MANUAL_MODE_ENABLED, False)) diff --git a/custom_components/omnibattery/infra/modbus_client.py b/custom_components/omnibattery/infra/modbus_client.py index e1e61071..6710e512 100644 --- a/custom_components/omnibattery/infra/modbus_client.py +++ b/custom_components/omnibattery/infra/modbus_client.py @@ -556,3 +556,63 @@ async def async_write_register(self, register: int, value: int, max_retries: int max_retries, ) return False + + async def async_write_registers( + self, + start: int, + values: list[int], + max_retries: int = 1, + retry_delay: float = 0.1, + ) -> bool: + """Write adjacent holding registers in one Modbus FC16 transaction.""" + attempt = 0 + current_retry_delay = retry_delay + while attempt < max_retries: + try: + if DEBUG_RAW_MODBUS_READS: + _LOGGER.debug( + "Modbus multi-write: start=%d/0x%04X values=%s", + start, + start, + values, + ) + try: + result = await asyncio.wait_for( + self.client.write_registers( + address=start, + values=values, + **{self._slave_kwarg: self.unit_id}, + ), + timeout=self._request_timeout, + ) + finally: + if self._message_wait_sec and not self._is_shutting_down: + await asyncio.sleep(self._message_wait_sec) + return not result.isError() + except (ConnectionException, ModbusIOException, asyncio.TimeoutError): + if self._is_shutting_down: + return False + _LOGGER.debug( + "Connection error writing register block at %d (0x%04X)", + start, + start, + ) + except Exception as err: + if not self._is_shutting_down: + _LOGGER.exception( + "Exception during Modbus multi-write at %d (0x%04X) " + "on attempt %d: %s", + start, + start, + attempt + 1, + err, + ) + if self._is_shutting_down: + return False + attempt += 1 + if attempt < max_retries: + await asyncio.sleep( + current_retry_delay + _backoff_jitter(current_retry_delay) + ) + current_retry_delay = min(current_retry_delay * 2, 5.0) + return False diff --git a/custom_components/omnibattery/pricing/engine.py b/custom_components/omnibattery/pricing/engine.py index e93d10f3..d46e481e 100644 --- a/custom_components/omnibattery/pricing/engine.py +++ b/custom_components/omnibattery/pricing/engine.py @@ -436,7 +436,7 @@ async def startup_evaluation(self) -> None: coordinators_with_data = [ c for c in self._controller.coordinators - if c.data and not getattr(c, "battery_manual_mode_enabled", False) + if c.data and not self._controller._is_battery_manual_owned(c) ] if not coordinators_with_data: _LOGGER.warning( @@ -933,7 +933,7 @@ def _opportunistic_battery_eligible(self, coordinator) -> bool: if ( getattr(coordinator, "data", None) is None or not getattr(coordinator, "is_available", True) - or getattr(coordinator, "battery_manual_mode_enabled", False) + or self._controller._is_battery_manual_owned(coordinator) or not getattr(coordinator, "allow_charge", True) or getattr(coordinator, "rs485_user_disabled", False) ): @@ -1395,7 +1395,7 @@ def _curtailment_battery_snapshots(self) -> list[BatterySnapshot]: eligible = bool( data and coordinator.is_available - and not getattr(coordinator, "battery_manual_mode_enabled", False) + and not self._controller._is_battery_manual_owned(coordinator) and not self._controller._non_responsive.is_excluded(coordinator) and not self._controller._is_backup_function_active(coordinator) and not coordinator.rs485_user_disabled @@ -3606,7 +3606,7 @@ def _is_dp_soc_drop_reeval(self) -> bool: return False coords = [ c for c in self._controller.coordinators - if c.data and not getattr(c, "battery_manual_mode_enabled", False) + if c.data and not self._controller._is_battery_manual_owned(c) ] if not coords: return False @@ -3973,7 +3973,7 @@ async def _evaluate_evening_recharge(self) -> None: # --- Battery state --- coordinators_with_data = [ c for c in self._controller.coordinators - if c.data and not getattr(c, "battery_manual_mode_enabled", False) + if c.data and not self._controller._is_battery_manual_owned(c) ] if not coordinators_with_data: _LOGGER.info("Evening recharge: no battery data, skipping") @@ -4266,12 +4266,12 @@ async def _send_evening_recharge_notification( avg_soc = sum( (c.data.get("battery_soc", 0) or 0) for c in self._controller.coordinators - if c.data and not getattr(c, "battery_manual_mode_enabled", False) + if c.data and not self._controller._is_battery_manual_owned(c) ) / max( 1, sum( 1 for c in self._controller.coordinators - if c.data and not getattr(c, "battery_manual_mode_enabled", False) + if c.data and not self._controller._is_battery_manual_owned(c) ), ) title, message = notifications.format_evening_recharge_notification( @@ -4384,7 +4384,7 @@ async def _stop_dynamic_price_slot( controller.first_execution = True if write_idle: for coordinator in getattr(controller, "coordinators", []): - if getattr(coordinator, "battery_manual_mode_enabled", False): + if controller._is_battery_manual_owned(coordinator): continue await controller._set_battery_power(coordinator, 0, 0) _LOGGER.info("Dynamic pricing: stopped active slot (%s)", reason) @@ -4781,7 +4781,7 @@ async def handle_time_slot_predictive_charging(self) -> None: automatic = [ c for c in self._controller.coordinators - if c.data and not getattr(c, "battery_manual_mode_enabled", False) + if c.data and not self._controller._is_battery_manual_owned(c) ] current_avg_soc = ( sum(c.data.get("battery_soc", 0) for c in automatic) diff --git a/custom_components/omnibattery/sensor.py b/custom_components/omnibattery/sensor.py index 6677b547..1a86d417 100644 --- a/custom_components/omnibattery/sensor.py +++ b/custom_components/omnibattery/sensor.py @@ -546,13 +546,19 @@ def extra_state_attributes(self) -> dict: ] automatic = [ c.name for c in self._coordinators - if not getattr(c, "battery_manual_mode_enabled", False) + if not self.controller._is_battery_manual_owned(c) + ] + fronius_released = [ + c.name for c in self._coordinators + if getattr(c, "brand", None) == "fronius_gen24" + and not getattr(c, "fronius_internal_control_disabled", True) ] attrs = { "total_batteries": total, "manual_batteries": manual, "automatic_batteries": automatic, + "fronius_released_batteries": fronius_released, "discharge_active": len(discharge), "discharge_batteries": [c.name for c in discharge], "charge_active": len(charge), @@ -835,7 +841,7 @@ def _time_slot_blocked(self, direction: str) -> bool: coordinator for coordinator in c.coordinators if getattr(coordinator, "is_available", True) - and not getattr(coordinator, "battery_manual_mode_enabled", False) + and not c._is_battery_manual_owned(coordinator) ] if not coordinators: return False @@ -886,7 +892,7 @@ def _balance_hold_batteries(self) -> list[str]: coordinator.name for coordinator in self._controller.coordinators if getattr(coordinator, "balance_hold", False) - and not getattr(coordinator, "battery_manual_mode_enabled", False) + and not self._controller._is_battery_manual_owned(coordinator) ] def _backup_cooldown_batteries(self) -> list[str]: @@ -897,7 +903,7 @@ def _backup_cooldown_batteries(self) -> list[str]: return [ coordinator.name for coordinator, cooldown_until in self._controller._backup_cooldown_until.items() - if not getattr(coordinator, "battery_manual_mode_enabled", False) + if not self._controller._is_battery_manual_owned(coordinator) if cooldown_until and now < cooldown_until ] @@ -995,7 +1001,14 @@ def extra_state_attributes(self) -> dict: ], "automatic_batteries": [ coordinator.name for coordinator in c.coordinators - if not getattr(coordinator, "battery_manual_mode_enabled", False) + if not c._is_battery_manual_owned(coordinator) + ], + "fronius_released_batteries": [ + coordinator.name for coordinator in c.coordinators + if getattr(coordinator, "brand", None) == "fronius_gen24" + and not getattr( + coordinator, "fronius_internal_control_disabled", True + ) ], "grid_charging_active": c.grid_charging_active, "price_based_discharge_blocked": c._price_based_discharge_blocked, diff --git a/custom_components/omnibattery/strings.json b/custom_components/omnibattery/strings.json index 440014fe..d61bf437 100644 --- a/custom_components/omnibattery/strings.json +++ b/custom_components/omnibattery/strings.json @@ -1566,6 +1566,9 @@ "battery_manual_mode": { "name": "Manual Battery Control" }, + "fronius_internal_control_disabled": { + "name": "Keep Fronius/BYD Internal Control Disabled" + }, "full_charge_voltage_taper": { "name": "100% Charge Voltage Taper" }, diff --git a/custom_components/omnibattery/switch.py b/custom_components/omnibattery/switch.py index ea4b9b14..a395ea1a 100644 --- a/custom_components/omnibattery/switch.py +++ b/custom_components/omnibattery/switch.py @@ -28,6 +28,7 @@ CONF_FULL_CHARGE_VOLTAGE_TAPER_ENABLED, CONF_MANUAL_MODE_ENABLED, CONF_BATTERY_MANUAL_MODE_ENABLED, + CONF_FRONIUS_INTERNAL_CONTROL_DISABLED, CONF_NO_PD_MODE_ENABLED, CONF_OFFGRID_POWER_SENSOR, CONF_OFFGRID_MODE_ENABLED, @@ -77,6 +78,12 @@ async def async_setup_entry( entities.append(BatteryAllowChargeSwitch(hass, entry, controller, coordinator)) entities.append(BatteryAllowDischargeSwitch(hass, entry, controller, coordinator)) entities.append(BatteryManualModeSwitch(hass, entry, controller, coordinator)) + if coordinator.brand == "fronius_gen24": + entities.append( + FroniusInternalControlDisabledSwitch( + hass, entry, controller, coordinator + ) + ) # Marstek-only cell maintenance: voltage taper needs per-cell # voltages that Anker/Zendure do not expose in the same way. if coordinator.brand not in ("zendure", "anker", "fronius_gen24"): @@ -495,6 +502,67 @@ async def async_turn_off(self, **kwargs) -> None: def device_info(self): return self.coordinator.battery_device_info + +class FroniusInternalControlDisabledSwitch(SwitchEntity): + """Persistent Fronius/BYD ownership boundary independent of PD mode.""" + + def __init__(self, hass, entry, controller, coordinator) -> None: + self.hass = hass + self.entry = entry + self.controller = controller + self.coordinator = coordinator + self._attr_has_entity_name = True + self._attr_translation_key = "fronius_internal_control_disabled" + self._attr_unique_id = ( + f"{coordinator.device_key}_fronius_internal_control_disabled" + ) + self.entity_id = english_entity_id( + "switch", coordinator.name, "fronius_internal_control_disabled" + ) + self._attr_icon = "mdi:battery-lock" + self._attr_should_poll = False + + @property + def is_on(self) -> bool: + """Return whether Fronius internal battery control is locked out.""" + return bool( + getattr( + self.coordinator, + CONF_FRONIUS_INTERNAL_CONTROL_DISABLED, + True, + ) + ) + + async def _set_disabled(self, disabled: bool) -> None: + async with self.controller._control_lock: + self.controller._reset_battery_ownership_state(self.coordinator) + ok = await self.coordinator.set_fronius_internal_control_disabled( + disabled + ) + if not ok: + raise HomeAssistantError( + f"Unable to {'retain' if disabled else 'release'} Fronius/BYD " + f"control for {self.coordinator.name}" + ) + self.coordinator.persist_battery_config( + CONF_FRONIUS_INTERNAL_CONTROL_DISABLED, disabled + ) + await self.coordinator.async_request_refresh() + self.async_write_ha_state() + + async def async_turn_on(self, **kwargs) -> None: + """Retain external control and assert the idle 0/0 window now.""" + await self._set_disabled(True) + + async def async_turn_off(self, **kwargs) -> None: + """Explicitly release the battery to Fronius automatic control.""" + await self._set_disabled(False) + + @property + def device_info(self): + return self.coordinator.battery_device_info + + class VacationModeSwitch(SwitchEntity): """Persistently pause consumption learning while the household is away.""" @@ -1468,6 +1536,19 @@ async def async_turn_on(self, **kwargs) -> None: # Set all batteries to 0W (idle state) when entering manual mode for coordinator in self.controller.coordinators: try: + if ( + coordinator.brand == "fronius_gen24" + and not getattr( + coordinator, + CONF_FRONIUS_INTERNAL_CONTROL_DISABLED, + True, + ) + ): + _LOGGER.debug( + "Skipping %s - control was explicitly released to Fronius", + coordinator.name, + ) + continue individual_manual = bool( getattr(coordinator, CONF_BATTERY_MANUAL_MODE_ENABLED, False) ) @@ -1522,7 +1603,7 @@ async def async_turn_off(self, **kwargs) -> None: self.controller._active_discharge_batteries = [] self.controller._active_charge_batteries = [] for coordinator in self.controller.coordinators: - if getattr(coordinator, CONF_BATTERY_MANUAL_MODE_ENABLED, False): + if self.controller._is_battery_manual_owned(coordinator): continue coordinator.manual_force_mode = "None" coordinator.persist_battery_config("manual_force_mode", "None") diff --git a/custom_components/omnibattery/translations/ca.json b/custom_components/omnibattery/translations/ca.json index 9d7dc600..cca6b1ca 100644 --- a/custom_components/omnibattery/translations/ca.json +++ b/custom_components/omnibattery/translations/ca.json @@ -1465,6 +1465,9 @@ "battery_manual_mode": { "name": "Control Manual de la Bateria" }, + "fronius_internal_control_disabled": { + "name": "Mantén desactivat el control intern Fronius/BYD" + }, "full_charge_voltage_taper": { "name": "Reducció Càrrega 100% per Voltatge" }, diff --git a/custom_components/omnibattery/translations/de.json b/custom_components/omnibattery/translations/de.json index 0d165b15..6fa3e5a6 100644 --- a/custom_components/omnibattery/translations/de.json +++ b/custom_components/omnibattery/translations/de.json @@ -1571,6 +1571,9 @@ "battery_manual_mode": { "name": "Manuelle Batteriesteuerung" }, + "fronius_internal_control_disabled": { + "name": "Fronius/BYD-Eigensteuerung dauerhaft sperren" + }, "full_charge_voltage_taper": { "name": "100%-Ladungsreduktion per Spannung" }, diff --git a/custom_components/omnibattery/translations/en.json b/custom_components/omnibattery/translations/en.json index a97efd49..674d9dfd 100644 --- a/custom_components/omnibattery/translations/en.json +++ b/custom_components/omnibattery/translations/en.json @@ -1590,6 +1590,9 @@ "battery_manual_mode": { "name": "Manual Battery Control" }, + "fronius_internal_control_disabled": { + "name": "Keep Fronius/BYD Internal Control Disabled" + }, "full_charge_voltage_taper": { "name": "100% Charge Voltage Taper" }, diff --git a/custom_components/omnibattery/translations/es.json b/custom_components/omnibattery/translations/es.json index 3ac666b4..b98abbc7 100644 --- a/custom_components/omnibattery/translations/es.json +++ b/custom_components/omnibattery/translations/es.json @@ -1483,6 +1483,9 @@ "battery_manual_mode": { "name": "Control Manual de Batería" }, + "fronius_internal_control_disabled": { + "name": "Mantener desactivado el control interno Fronius/BYD" + }, "full_charge_voltage_taper": { "name": "Reduccion Carga 100% por Voltaje" }, diff --git a/custom_components/omnibattery/translations/fr.json b/custom_components/omnibattery/translations/fr.json index a498c7e5..44979ae4 100644 --- a/custom_components/omnibattery/translations/fr.json +++ b/custom_components/omnibattery/translations/fr.json @@ -1478,6 +1478,9 @@ "battery_manual_mode": { "name": "Contrôle manuel de la batterie" }, + "fronius_internal_control_disabled": { + "name": "Maintenir le contrôle interne Fronius/BYD désactivé" + }, "full_charge_voltage_taper": { "name": "Réduction Charge 100% par Tension" }, diff --git a/custom_components/omnibattery/translations/nl.json b/custom_components/omnibattery/translations/nl.json index 536b0edf..d3f08116 100644 --- a/custom_components/omnibattery/translations/nl.json +++ b/custom_components/omnibattery/translations/nl.json @@ -1465,6 +1465,9 @@ "battery_manual_mode": { "name": "Handmatige batterijregeling" }, + "fronius_internal_control_disabled": { + "name": "Interne Fronius/BYD-regeling uitgeschakeld houden" + }, "full_charge_voltage_taper": { "name": "100%-Laadbegrenzing op Spanning" }, diff --git a/site-docs/configuration/batteries/fronius-gen24.es.md b/site-docs/configuration/batteries/fronius-gen24.es.md index 4d87df3e..139ad116 100644 --- a/site-docs/configuration/batteries/fronius-gen24.es.md +++ b/site-docs/configuration/batteries/fronius-gen24.es.md @@ -40,6 +40,21 @@ Todas las lecturas, escrituras de consignas y comprobaciones usan el diseño detectado. El tipo aparece como **Modelo SunSpec** en la caja de información de la batería. +## Propiedad del control y reposo seguro + +El interruptor del dispositivo **Mantener desactivado el control interno +Fronius/BYD** está activado por defecto. Mientras está activo, Omnibattery +mantiene el control SunSpec externo con una ventana de potencia cerrada `0/0` +durante la configuración, la recarga y el apagado ordenado. Así, un reinicio de +la integración no devuelve silenciosamente la batería al control automático de +Fronius. + +Al desactivarlo se escribe explícitamente `StorCtl_Mod = 0`, se devuelve el +control a Fronius y se retira la batería del grupo de control automático de +Omnibattery. La elección se conserva. La liberación no modifica `MinRsvPct` ni +las ventanas de potencia; al volver a activarlo se aplica inmediatamente el +reposo externo. + ## Límites de SOC Omnibattery aplica `min_soc` y `max_soc` mediante su bucle de control. En diff --git a/site-docs/configuration/batteries/fronius-gen24.md b/site-docs/configuration/batteries/fronius-gen24.md index 49321a94..7a8a46b9 100644 --- a/site-docs/configuration/batteries/fronius-gen24.md +++ b/site-docs/configuration/batteries/fronius-gen24.md @@ -36,6 +36,19 @@ These positions follow the All reads, setpoint writes and readbacks use the detected layout. The detected type is shown as **SunSpec model** in the battery information box. +## Control ownership and safe idle + +The device switch **Keep Fronius/BYD internal control disabled** is enabled by +default. While enabled, Omnibattery retains external SunSpec storage control +with a closed `0/0` power window during setup, reload and orderly shutdown. This +prevents an integration restart from silently returning the battery to Fronius +automatic control. + +Turning the switch off explicitly writes `StorCtl_Mod = 0`, returns ownership to +Fronius and removes the battery from Omnibattery's automatic control pool. The +choice is persisted. Releasing ownership does not rewrite `MinRsvPct` or either +power window; switching the control back on immediately asserts external idle. + ## SOC limits `min_soc` and `max_soc` are enforced by Omnibattery's software control loop. diff --git a/tests/test_domain_migration.py b/tests/test_domain_migration.py index ce260e29..4058c7b4 100644 --- a/tests/test_domain_migration.py +++ b/tests/test_domain_migration.py @@ -442,7 +442,7 @@ async def test_single_old_entry_system_entity_survives_seamless_migration( assert new_entry.state is ConfigEntryState.LOADED # Confirms the real async_migrate_entry ran all the way through (proves # the v9 heal block actually executed, not skipped). - assert new_entry.version == 11 + assert new_entry.version == 12 final = reg.async_get(HISTORICAL_EID) assert final is not None, "historical entity_id should survive" diff --git a/tests/test_fronius_gen24_driver.py b/tests/test_fronius_gen24_driver.py index 079fcfa1..07689920 100644 --- a/tests/test_fronius_gen24_driver.py +++ b/tests/test_fronius_gen24_driver.py @@ -13,6 +13,7 @@ import sys import types import unittest +from unittest import mock from dataclasses import dataclass from pathlib import Path from typing import Optional @@ -145,6 +146,7 @@ def __init__(self) -> None: self.connected = True self.unit_id = None self.writes: list[tuple[int, int]] = [] + self.multi_writes: list[tuple[int, list[int]]] = [] async def async_connect(self) -> bool: self.connected = True @@ -163,6 +165,16 @@ async def async_write_register(self, address: int, value: int) -> bool: self.writes.append((address, value)) return True + async def async_write_registers( + self, address: int, values: list[int] + ) -> bool: + self.multi_writes.append((address, list(values))) + self.writes.extend( + (address + offset, value) + for offset, value in enumerate(values) + ) + return True + class RegisterMapClient(FakeClient): def __init__(self, blocks: dict[tuple[int, int], list[int]]) -> None: @@ -311,7 +323,7 @@ def test_connect_detects_int_sf_layout_and_shifts_reads_and_writes(self) -> None self.assertTrue(asyncio.run(battery.standby())) self.assertEqual( fake.writes, - [(40348, 0), (40355, 10000), (40350, 500), (40356, 10000)], + [(40355, 0), (40356, 0), (40348, 3), (40355, 0), (40356, 0)], ) def test_connect_rejects_missing_basic_storage_model(self) -> None: @@ -353,28 +365,47 @@ def test_discharge_plan_matches_existing_ha_script(self) -> None: self.assertEqual(plan.inwrte_word, 64535) self.assertEqual( [(write.address, write.value) for write in plan.writes], - [(40358, 3), (40365, 1002), (40366, 64535), (40365, 1002), (40366, 64535)], + [(40365, 1002), (40366, 64535), (40358, 3), (40365, 1002), (40366, 64535)], ) - def test_neutral_and_auto_reset_plans(self) -> None: - neutral = driver.plan_storage_setpoint( + def test_idle_and_auto_release_plans(self) -> None: + idle = driver.plan_storage_setpoint( 0, wcha_max_w=5000, max_charge_power_w=5000, max_discharge_power_w=5000, ) - self.assertEqual(neutral.net_power_w, 0) - self.assertEqual(neutral.mode, "neutral") + self.assertEqual(idle.net_power_w, 0) + self.assertEqual(idle.mode, "idle") self.assertEqual( - [(write.address, write.value) for write in neutral.writes], - [(40365, 10000), (40366, 10000), (40358, 3), (40365, 10000), (40366, 10000)], + [(write.address, write.value) for write in idle.writes], + [(40365, 0), (40366, 0), (40358, 3), (40365, 0), (40366, 0)], ) self.assertEqual( [(write.address, write.value) for write in driver.plan_reset_to_auto()], - [(40358, 0), (40365, 10000), (40360, 500), (40366, 10000)], + [(40358, 0)], ) + def test_disabled_direction_produces_idle_plan(self) -> None: + charge = driver.plan_storage_setpoint( + 500, + wcha_max_w=5000, + max_charge_power_w=0, + max_discharge_power_w=5000, + ) + discharge = driver.plan_storage_setpoint( + -500, + wcha_max_w=5000, + max_charge_power_w=5000, + max_discharge_power_w=0, + ) + + self.assertEqual(charge.net_power_w, 0) + self.assertEqual(charge.mode, "idle") + self.assertEqual(discharge.net_power_w, 0) + self.assertEqual(discharge.mode, "idle") + def test_decode_storage_registers_uses_local_mapping_and_scale_factors(self) -> None: regs = [0] * 24 regs[0] = 4895 @@ -469,8 +500,12 @@ def test_apply_setpoint_writes_planned_registers_without_live_readback(self) -> fake.writes, [(40365, 64535), (40366, 1001), (40358, 3), (40365, 64535), (40366, 1001)], ) + self.assertEqual( + fake.multi_writes, + [(40365, [64535, 1001]), (40365, [64535, 1001])], + ) - def test_small_discharge_request_is_suppressed_to_neutral(self) -> None: + def test_subminimum_request_is_suppressed_to_idle(self) -> None: fake = FakeClient() battery = driver.FroniusGen24Driver( "192.0.2.10", @@ -486,10 +521,10 @@ def test_small_discharge_request_is_suppressed_to_neutral(self) -> None: self.assertEqual(result.net_power_w, 0) self.assertEqual( fake.writes, - [(40365, 10000), (40366, 10000), (40358, 3), (40365, 10000), (40366, 10000)], + [(40365, 0), (40366, 0), (40358, 3), (40365, 0), (40366, 0)], ) - def test_discharge_suppression_includes_threshold_value(self) -> None: + def test_material_discharge_is_not_suppressed(self) -> None: fake = FakeClient() battery = driver.FroniusGen24Driver( "192.0.2.10", @@ -501,43 +536,9 @@ def test_discharge_suppression_includes_threshold_value(self) -> None: result = asyncio.run(battery.apply_setpoint(-750, read_back=False)) self.assertTrue(result.ok) - self.assertEqual(result.net_power_w, 0) - self.assertEqual( - fake.writes, - [(40365, 10000), (40366, 10000), (40358, 3), (40365, 10000), (40366, 10000)], - ) + self.assertEqual(result.net_power_w, -750) - def test_idle_at_max_soc_is_held_with_tiny_charge_limit(self) -> None: - fake = FakeClient() - battery = driver.FroniusGen24Driver( - "192.0.2.10", - client=fake, - max_charge_power_w=5000, - max_discharge_power_w=5000, - ) - asyncio.run( - battery.apply_config( - max_soc_pct=90, - min_soc_pct=16, - max_charge_power_w=5000, - max_discharge_power_w=5000, - ) - ) - battery._last_soc_pct = 90.0 - - result = asyncio.run(battery.apply_setpoint(0, read_back=False)) - hold = driver.plan_storage_setpoint( - 10, - wcha_max_w=5000, - max_charge_power_w=5000, - max_discharge_power_w=5000, - ) - - self.assertTrue(result.ok) - self.assertEqual(result.net_power_w, 10) - self.assertEqual(fake.writes, [(write.address, write.value) for write in hold.writes]) - - def test_idle_below_max_soc_stays_neutral(self) -> None: + def test_idle_uses_closed_external_window(self) -> None: fake = FakeClient() battery = driver.FroniusGen24Driver( "192.0.2.10", @@ -561,10 +562,10 @@ def test_idle_below_max_soc_stays_neutral(self) -> None: self.assertEqual(result.net_power_w, 0) self.assertEqual( fake.writes, - [(40365, 10000), (40366, 10000), (40358, 3), (40365, 10000), (40366, 10000)], + [(40365, 0), (40366, 0), (40358, 3), (40365, 0), (40366, 0)], ) - def test_small_discharge_at_max_soc_is_held_with_tiny_charge_limit(self) -> None: + def test_discharge_at_max_soc_remains_available(self) -> None: fake = FakeClient() battery = driver.FroniusGen24Driver( "192.0.2.10", @@ -583,16 +584,9 @@ def test_small_discharge_at_max_soc_is_held_with_tiny_charge_limit(self) -> None battery._last_soc_pct = 91.0 result = asyncio.run(battery.apply_setpoint(-250, read_back=False)) - hold = driver.plan_storage_setpoint( - 10, - wcha_max_w=5000, - max_charge_power_w=5000, - max_discharge_power_w=5000, - ) self.assertTrue(result.ok) - self.assertEqual(result.net_power_w, 10) - self.assertEqual(fake.writes, [(write.address, write.value) for write in hold.writes]) + self.assertEqual(result.net_power_w, -250) def test_material_discharge_request_is_still_applied(self) -> None: fake = FakeClient() @@ -609,15 +603,135 @@ def test_material_discharge_request_is_still_applied(self) -> None: self.assertEqual(result.net_power_w, -1000) self.assertEqual( fake.writes, - [(40358, 3), (40365, 2002), (40366, 63535), (40365, 2002), (40366, 63535)], + [(40365, 2002), (40366, 63535), (40358, 3), (40365, 2002), (40366, 63535)], ) - def test_standby_returns_to_fronius_auto_mode(self) -> None: + def test_direction_change_must_spend_two_seconds_at_idle(self) -> None: fake = FakeClient() battery = driver.FroniusGen24Driver("192.0.2.10", client=fake) + with mock.patch.object( + driver, "monotonic", side_effect=[0.0, 1.0, 2.0, 3.1] + ): + charge = asyncio.run(battery.apply_setpoint(500, read_back=False)) + first_flip = asyncio.run( + battery.apply_setpoint(-500, read_back=False) + ) + held_flip = asyncio.run( + battery.apply_setpoint(-500, read_back=False) + ) + discharge = asyncio.run( + battery.apply_setpoint(-500, read_back=False) + ) + + self.assertEqual(charge.net_power_w, 500) + self.assertEqual(first_flip.net_power_w, 0) + self.assertEqual(held_flip.net_power_w, 0) + self.assertEqual(discharge.net_power_w, -500) + + def test_capabilities_expose_measured_latency_and_floor(self) -> None: + battery = driver.FroniusGen24Driver( + "192.0.2.10", client=FakeClient() + ) + + self.assertEqual(battery.capabilities.min_charge_power_w, 150) + self.assertEqual(battery.capabilities.min_discharge_power_w, 150) + self.assertEqual(battery.capabilities.actuator_latency_s, 2.0) + self.assertEqual(battery.capabilities.readback_latency_s, 2.0) + self.assertEqual(driver._POWER_CONFIRM_TIMEOUT_SECONDS, 4.0) + + def test_power_confirmation_requires_direction_or_idle(self) -> None: + charge = driver.plan_storage_setpoint( + 500, + wcha_max_w=5000, + max_charge_power_w=5000, + max_discharge_power_w=5000, + ) + discharge = driver.plan_storage_setpoint( + -500, + wcha_max_w=5000, + max_charge_power_w=5000, + max_discharge_power_w=5000, + ) + idle = driver.plan_storage_setpoint( + 0, + wcha_max_w=5000, + max_charge_power_w=5000, + max_discharge_power_w=5000, + ) + + self.assertTrue( + driver.FroniusGen24Driver._power_matches_plan(charge, 200) + ) + self.assertFalse( + driver.FroniusGen24Driver._power_matches_plan(charge, -200) + ) + self.assertTrue( + driver.FroniusGen24Driver._power_matches_plan(discharge, -200) + ) + self.assertFalse( + driver.FroniusGen24Driver._power_matches_plan(discharge, 200) + ) + self.assertTrue( + driver.FroniusGen24Driver._power_matches_plan(idle, 100) + ) + self.assertFalse( + driver.FroniusGen24Driver._power_matches_plan(idle, 300) + ) + + def test_standby_retains_external_control_by_default(self) -> None: + fake = FakeClient() + battery = driver.FroniusGen24Driver("192.0.2.10", client=fake) + + self.assertTrue(asyncio.run(battery.standby())) + self.assertEqual( + fake.writes, + [(40365, 0), (40366, 0), (40358, 3), (40365, 0), (40366, 0)], + ) + + def test_explicit_release_returns_to_auto_and_persists_for_standby(self) -> None: + fake = FakeClient() + battery = driver.FroniusGen24Driver("192.0.2.10", client=fake) + + self.assertTrue( + asyncio.run(battery.set_internal_control_disabled(False)) + ) + self.assertEqual(fake.writes, [(40358, 0)]) + + fake.writes.clear() + self.assertTrue(asyncio.run(battery.standby())) + self.assertEqual(fake.writes, [(40358, 0)]) + + def test_retain_control_reasserts_idle_after_explicit_release(self) -> None: + fake = FakeClient() + battery = driver.FroniusGen24Driver("192.0.2.10", client=fake) + asyncio.run(battery.set_internal_control_disabled(False)) + fake.writes.clear() + + self.assertTrue( + asyncio.run(battery.set_internal_control_disabled(True)) + ) + self.assertEqual( + fake.writes, + [(40365, 0), (40366, 0), (40358, 3), (40365, 0), (40366, 0)], + ) + + def test_int_sf_ownership_writes_shifted_addresses(self) -> None: + fake = FakeClient() + battery = driver.FroniusGen24Driver("192.0.2.10", client=fake) + battery._sunspec_layout = driver.SUNSPEC_INT_SF_LAYOUT + self.assertTrue(asyncio.run(battery.standby())) - self.assertEqual(fake.writes, [(40358, 0), (40365, 10000), (40360, 500), (40366, 10000)]) + self.assertEqual( + fake.writes, + [(40355, 0), (40356, 0), (40348, 3), (40355, 0), (40356, 0)], + ) + + fake.writes.clear() + self.assertTrue( + asyncio.run(battery.set_internal_control_disabled(False)) + ) + self.assertEqual(fake.writes, [(40348, 0)]) if __name__ == "__main__": diff --git a/tests/test_hysteresis_migration.py b/tests/test_hysteresis_migration.py index c5c31eee..6a6038db 100644 --- a/tests/test_hysteresis_migration.py +++ b/tests/test_hysteresis_migration.py @@ -12,7 +12,8 @@ branch heals the entity registry, which the light no-``hass``-fixture fakes can't provide, so we patch the two entity_registry helpers it calls to no-op here (the v9 heal has its own dedicated registry test). v10 renames the title, handled by -accepting the kwarg in the fake; v11 adds the disabled-by-default phase schema. +accepting the kwarg in the fake; v11 adds the disabled-by-default phase schema +and v12 adds the persisted Fronius ownership boundary. """ from __future__ import annotations @@ -53,7 +54,7 @@ def _migrate(batteries): with _no_registry(): result = asyncio.run(async_migrate_entry(hass, entry)) assert result is True - assert hass.config_entries.updated["version"] == 11 + assert hass.config_entries.updated["version"] == 12 return hass.config_entries.updated["data"]["batteries"] @@ -81,8 +82,26 @@ def test_enabled_below_floor_is_clamped_up(): assert out[0]["charge_hysteresis_percent"] == MIN_CHARGE_HYSTERESIS_PERCENT -def test_already_v11_is_noop(): +def test_v11_adds_safe_fronius_ownership_default(): hass = SimpleNamespace(config_entries=_FakeConfigEntries()) - entry = SimpleNamespace(version=11, data={"batteries": [{}]}) + entry = SimpleNamespace( + version=11, + data={ + "batteries": [ + {"brand": "fronius_gen24"}, + {"brand": "marstek"}, + ] + }, + ) + assert asyncio.run(async_migrate_entry(hass, entry)) is True + assert hass.config_entries.updated["version"] == 12 + batteries = hass.config_entries.updated["data"]["batteries"] + assert batteries[0]["fronius_internal_control_disabled"] is True + assert "fronius_internal_control_disabled" not in batteries[1] + + +def test_already_v12_is_noop(): + hass = SimpleNamespace(config_entries=_FakeConfigEntries()) + entry = SimpleNamespace(version=12, data={"batteries": [{}]}) assert asyncio.run(async_migrate_entry(hass, entry)) is True - assert hass.config_entries.updated is None # nothing rewritten + assert hass.config_entries.updated is None diff --git a/tests/test_system_uid_migration.py b/tests/test_system_uid_migration.py index 2d5fa170..522c6bba 100644 --- a/tests/test_system_uid_migration.py +++ b/tests/test_system_uid_migration.py @@ -77,7 +77,7 @@ async def test_v9_heals_system_entity_duplicates(hass: HomeAssistant) -> None: assert live.entity_id == "switch.marstek_venus_system_manual_mode_2" assert await async_migrate_entry(hass, entry) is True - assert entry.version == 11 + assert entry.version == 12 # orphan removed; live re-keyed to the stable uid AND reclaimed the clean id. assert reg.async_get_entity_id("switch", DOMAIN, f"{OLD_ULID}_manual_mode") is None From f189b22c161fc639fbb61b69e121480e1337e913 Mon Sep 17 00:00:00 2001 From: Misterspliss Date: Sat, 29 Aug 2026 17:05:41 +0200 Subject: [PATCH 4/4] fix: restore Fronius v12 ownership and SunSpec detection --- .../omnibattery/frontend/._marstek-panel.js | Bin 0 -> 4096 bytes .../omnibattery/frontend/marstek-panel.js | 11 +++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 custom_components/omnibattery/frontend/._marstek-panel.js diff --git a/custom_components/omnibattery/frontend/._marstek-panel.js b/custom_components/omnibattery/frontend/._marstek-panel.js new file mode 100644 index 0000000000000000000000000000000000000000..123bc0ac24520a3cd54b53033fc93c8d9bbab83b GIT binary patch literal 4096 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDJkFz{^v(m+1nBL)UWIUt(=a103vf+zv$ zV3+~K+-O=D5#plB`MG+D1qC^&dId%KWvO|IdC92^j7$ui7bfwsdi`fZ(>lr>4S~@R z7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu;s>>&W^Y=STt$c1EN7Aq8`7U!21C8sK+ y .ctl-k:not(:first-child) { margin-top: 6px; } + .bat-ctl-grid > .ctl-toggle { margin-bottom: 3px; } + .bat-ctl-grid > .ctl-btn { grid-column: 1; } + } @media (max-width: 480px) { .bat-grid { grid-template-columns: 1fr; } } /* ===== Control tab ===== */