diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d77444a..09402d6d 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 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 d222155d..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, @@ -548,6 +549,11 @@ def _backup_switch_enabled(value) -> bool: class ChargeDischargeController: """Controller to manage charge/discharge logic for all batteries.""" + _FRONIUS_PD_DEADBAND_FLOOR_W = 50 + _FRONIUS_PD_MIN_CHARGE_FLOOR_W = 100 + _FRONIUS_PD_MIN_DISCHARGE_FLOOR_W = 250 + _FRONIUS_PD_MIN_CYCLE_INTERVAL_FLOOR_S = 5.0 + def __init__(self, hass: HomeAssistant, coordinators: list[MarstekVenusDataUpdateCoordinator], consumption_sensor: str, config_entry: ConfigEntry): """Initialize the controller.""" self.hass = hass @@ -600,6 +606,7 @@ def __init__(self, hass: HomeAssistant, coordinators: list[MarstekVenusDataUpdat # (Marstek) need to track the grid meter's full cadence. Slow-actuator pacing # belongs per-battery in the power distribution, not in the loop cadence. self._min_cycle_interval_s = config_entry.data.get(CONF_PD_MIN_CYCLE_INTERVAL, DEFAULT_PD_MIN_CYCLE_INTERVAL) + self._apply_driver_pd_floors() self._last_cycle_monotonic = 0.0 self._background_tasks: set[asyncio.Task] = set() self._startup_dynamic_pricing_task: asyncio.Task | None = None @@ -2138,6 +2145,42 @@ def _refresh_daily_operation_timeline( if batching: end_batch() + def _has_fronius_gen24_battery(self) -> bool: + return any(getattr(coordinator, "brand", None) == "fronius_gen24" for coordinator in self.coordinators) + + def _apply_driver_pd_floors(self) -> None: + """Apply driver-specific PD floors for storage that dislikes sign chatter.""" + if not self._has_fronius_gen24_battery(): + return + + old_values = ( + self.deadband, + self.min_charge_power, + self.min_discharge_power, + self._min_cycle_interval_s, + ) + self.deadband = max(self.deadband, self._FRONIUS_PD_DEADBAND_FLOOR_W) + self.min_charge_power = max(self.min_charge_power, self._FRONIUS_PD_MIN_CHARGE_FLOOR_W) + self.min_discharge_power = max(self.min_discharge_power, self._FRONIUS_PD_MIN_DISCHARGE_FLOOR_W) + self._min_cycle_interval_s = max( + self._min_cycle_interval_s, + self._FRONIUS_PD_MIN_CYCLE_INTERVAL_FLOOR_S, + ) + if ( + self.deadband, + self.min_charge_power, + self.min_discharge_power, + self._min_cycle_interval_s, + ) != old_values: + _LOGGER.info( + "Fronius GEN24 / BYD detected: applying PD stability floors " + "(deadband=%dW, min_charge=%dW, min_discharge=%dW, min_cycle=%.1fs)", + self.deadband, + self.min_charge_power, + self.min_discharge_power, + self._min_cycle_interval_s, + ) + def _configured_system_limit(self, is_charging: bool) -> int: """Return the optional system-wide power limit for the direction. @@ -2161,7 +2204,7 @@ def _effective_system_capacity(self, batteries: list, is_charging: bool) -> int: """Return available capacity after applying the optional global cap.""" batteries = [ coordinator for coordinator in batteries - if not getattr(coordinator, CONF_BATTERY_MANUAL_MODE_ENABLED, False) + if not self._is_battery_manual_owned(coordinator) ] total_capacity = sum( self._battery_power_limit(c, is_charging) @@ -2174,8 +2217,15 @@ def _effective_system_capacity(self, batteries: list, is_charging: bool) -> int: @staticmethod def _is_battery_manual_owned(coordinator) -> bool: - """Return whether an individual battery is outside automatic control.""" - return bool(getattr(coordinator, CONF_BATTERY_MANUAL_MODE_ENABLED, False)) + """Return whether a battery is outside Omnibattery automatic control.""" + if bool(getattr(coordinator, CONF_BATTERY_MANUAL_MODE_ENABLED, False)): + return True + return bool( + getattr(coordinator, "brand", None) == "fronius_gen24" + and not getattr( + coordinator, CONF_FRONIUS_INTERNAL_CONTROL_DISABLED, True + ) + ) def _get_automatic_batteries(self) -> list: """Return the batteries available to automatic planning and control.""" @@ -2598,6 +2648,7 @@ def update_pd_parameters(self): self.min_discharge_power = self.config_entry.data.get(CONF_PD_MIN_DISCHARGE_POWER, DEFAULT_PD_MIN_DISCHARGE_POWER) self._relay_cooldown_s = self.config_entry.data.get(CONF_PD_RELAY_COOLDOWN, DEFAULT_PD_RELAY_COOLDOWN) self._min_cycle_interval_s = self.config_entry.data.get(CONF_PD_MIN_CYCLE_INTERVAL, DEFAULT_PD_MIN_CYCLE_INTERVAL) + self._apply_driver_pd_floors() self.target_grid_power = self.config_entry.data.get(CONF_TARGET_GRID_POWER, DEFAULT_TARGET_GRID_POWER) self.enable_system_power_limits = self.config_entry.data.get( CONF_ENABLE_SYSTEM_POWER_LIMITS, @@ -3672,12 +3723,12 @@ def _get_available_batteries( if coordinator.data is None: continue - # Individual manual mode is an ownership boundary, not an - # operation blocker. Exclude it before availability and blocker - # evaluation so planning cannot select or classify it as automatic. - if getattr(coordinator, CONF_BATTERY_MANUAL_MODE_ENABLED, False): + # Manual ownership (including an explicitly released Fronius) is + # an ownership boundary, not an operation blocker. Exclude it + # before planning can classify the battery as automatic. + if self._is_battery_manual_owned(coordinator): _LOGGER.debug( - "%s: Skipping - individual manual mode owns this battery", + "%s: Skipping - battery is outside automatic ownership", coordinator.name, ) continue @@ -5892,11 +5943,9 @@ async def _set_battery_power( Returns True if command was acknowledged, False otherwise. """ - if owner == "automatic" and getattr( - coordinator, CONF_BATTERY_MANUAL_MODE_ENABLED, False - ): + if owner == "automatic" and self._is_battery_manual_owned(coordinator): _LOGGER.debug( - "[%s] Skipping automatic power write - individual manual mode owns this battery", + "[%s] Skipping automatic power write - battery is outside automatic ownership", getattr(coordinator, "name", coordinator), ) return False @@ -8897,8 +8946,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: v9 -> v10: rename config entry title to "Omnibattery". v10 -> v11: add the disabled-by-default three-phase protection schema and normalize an empty battery phase on existing batteries. + v11 -> v12: retain external idle control for Fronius/BYD during unload; + releasing to the inverter becomes an explicit device setting. """ - if entry.version >= 11: + if entry.version >= 12: return True new_data = dict(entry.data) @@ -9141,11 +9192,26 @@ def _fix_home_consumption_uid(entity_entry): "(three-phase protection disabled; battery phases normalized)", ) + if entry.version < 12: + migrated_batteries = [] + for battery in new_data.get("batteries", []): + migrated = dict(battery) + if migrated.get("brand") == "fronius_gen24": + migrated.setdefault( + CONF_FRONIUS_INTERNAL_CONTROL_DISABLED, True + ) + migrated_batteries.append(migrated) + new_data["batteries"] = migrated_batteries + _LOGGER.info( + "Omnibattery: migrated config entry to version 12 " + "(Fronius/BYD retains external idle control on unload)", + ) + hass.config_entries.async_update_entry( entry, title="Omnibattery", data=new_data, - version=11, + version=12, ) return True @@ -9499,6 +9565,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: battery_manual_mode_enabled=battery_config.get( CONF_BATTERY_MANUAL_MODE_ENABLED, False ), + fronius_internal_control_disabled=battery_config.get( + CONF_FRONIUS_INTERNAL_CONTROL_DISABLED, True + ), mac=entry_macs[battery_index], ) # Physical phase is metadata for the safety limiter only. It is never @@ -9614,6 +9683,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: max_discharge_power_w=max_discharge_power, ) + # Establish the persisted Fronius ownership boundary before + # the controller can issue automatic setpoints. The safe + # default is external 0/0 idle; explicit release selects auto. + if coordinator.brand == "fronius_gen24": + ownership_ok = ( + await coordinator.set_fronius_internal_control_disabled( + coordinator.fronius_internal_control_disabled + ) + ) + if not ownership_ok: + raise ConfigEntryNotReady( + "Could not establish Fronius/BYD control ownership " + f"for {coordinator.name}" + ) + # Manually trigger first refresh and wait for it await coordinator.async_request_refresh() # Give a moment for the data to be processed diff --git a/custom_components/omnibattery/config_flow.py b/custom_components/omnibattery/config_flow.py index b82c086f..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, @@ -174,6 +175,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 +187,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 +453,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 +631,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 +695,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": @@ -1160,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.""" @@ -1423,6 +1491,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 +1509,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 +2004,49 @@ 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", + }) + 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() + + 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 +2069,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 +2116,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 +2168,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 +2941,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 +3490,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 +4117,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 +4135,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 +4742,78 @@ 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", + }) + 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() + + 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 +4835,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 +4896,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 +4943,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 +4972,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 +4998,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 +5017,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/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 new file mode 100644 index 00000000..3e3d76fc --- /dev/null +++ b/custom_components/omnibattery/drivers/fronius_gen24.py @@ -0,0 +1,1168 @@ +"""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. + +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. + +* 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 + 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 time import monotonic +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 + +_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 + +_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 + +_MODEL_124_ID = 124 + +_MODE_AUTO = 0 +_MODE_STORAGE_CONTROL = 3 +_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 = { + "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": "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", + "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 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.""" + + 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, + layout: SunSpecLayout = SUNSPEC_FLOAT_LAYOUT, +) -> 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`` + * 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) + out_word = _int16_word(-(pct + 1)) + in_word = pct + 1 + writes = ( + 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) + + 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(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, _IDLE_WINDOW_WORD), + RegisterWrite(layout.inwrte, _IDLE_WINDOW_WORD), + RegisterWrite(layout.storctl_mod, _MODE_STORAGE_CONTROL), + RegisterWrite(layout.outwrte, _IDLE_WINDOW_WORD), + RegisterWrite(layout.inwrte, _IDLE_WINDOW_WORD), + ) + return SetpointPlan(0, "idle", _IDLE_WINDOW_WORD, _IDLE_WINDOW_WORD, writes) + + +def plan_reset_to_auto( + layout: SunSpecLayout = SUNSPEC_FLOAT_LAYOUT, +) -> tuple[RegisterWrite, ...]: + """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: + """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 + # 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 + 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}" + 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", + "fronius_sunspec_model_type", + "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=False, + actuator_latency_s=2.0, + readback_latency_s=2.0, + ) + + @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 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 + + @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: + 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 + + 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 _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( + self._sunspec_layout.storage_block, + _STORAGE_BLOCK_COUNT, + block_key=f"fronius_storage_{self._sunspec_layout.model_type}", + ) + if not regs: + return {} + 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( + self._sunspec_layout.dc_block, + _DCW_BLOCK_COUNT, + block_key=f"fronius_dc_power_{self._sunspec_layout.model_type}", + ) + 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", + "fronius_sunspec_model_type", + } + 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", + ) + + requested_power_w = int(net_power_w) + if 0 < abs(requested_power_w) < _MIN_OPERATING_POWER_W: + _LOGGER.debug( + "Suppressing sub-minimum Fronius GEN24 request %d W below %d W; " + "writing idle storage control instead", + requested_power_w, + _MIN_OPERATING_POWER_W, + ) + requested_power_w = 0 + + 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 = 0 + requested_sign = 0 + elif requested_sign == self._last_active_sign: + self._idle_since_monotonic = None + + 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, + layout=self._sunspec_layout, + ) + 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_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, + "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, + ) + + 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, + confirmed=False, + failure_reason="feedback_timeout", + ) + + confirmed = registers_confirmed and power_confirmed + applied.update({ + "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: + 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" + 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 + 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 = { + "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: + 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 == _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 + 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 + + 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: + """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 + 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( + 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 new file mode 100644 index 00000000..123bc0ac Binary files /dev/null and b/custom_components/omnibattery/frontend/._marstek-panel.js differ diff --git a/custom_components/omnibattery/frontend/marstek-panel.js b/custom_components/omnibattery/frontend/marstek-panel.js index 4b10fa49..db52fc2b 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", 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", @@ -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", @@ -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", 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", @@ -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", @@ -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", 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", @@ -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", @@ -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", 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", @@ -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", @@ -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", 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", @@ -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.", @@ -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", 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", @@ -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", @@ -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", @@ -913,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. @@ -1209,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.", @@ -1285,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.", @@ -1352,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.", @@ -1417,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.", @@ -1482,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.", @@ -1547,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.", @@ -5340,7 +5348,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 +5384,11 @@ 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]), + 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: (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,7 +5804,9 @@ 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("infoSunSpecModel"), b.info.sunspecModelType); addRow(this._t("infoSoftware"), b.info.sw); addRow("BMS", b.info.bms); addRow("VMS", b.info.vms); @@ -7144,7 +7156,7 @@ class MarstekVenusPanel extends HTMLElement { /* ===== Baterías tab ===== */ .bat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(400px, 100%), 1fr)); gap: var(--gap); align-items: start; } - .bat-card { display: flex; flex-direction: column; gap: 16px; min-width: 0; } + .bat-card { display: flex; flex-direction: column; gap: 16px; min-width: 0; container: battery-card / inline-size; } .bat-head { display: flex; align-items: center; gap: 10px; } .bat-title { display: flex; align-items: center; gap: 9px; min-width: 0; flex: 1 1 auto; --mdc-icon-size: 18px; } .bat-title .ic { color: var(--ink-dim); display: grid; place-items: center; flex-shrink: 0; } @@ -7215,8 +7227,9 @@ class MarstekVenusPanel extends HTMLElement { /* per-battery controls — 2-col grid so labels and controls align across rows and every slider/select gets the same width */ - .bat-ctl-grid { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 12px 14px; align-items: center; margin-top: 12px; } + .bat-ctl-grid { display: grid; grid-template-columns: minmax(0, min(52%, 260px)) minmax(140px, 1fr); gap: 12px 14px; align-items: center; margin-top: 12px; } .ctl-k { display: inline-flex; align-items: center; gap: 7px; color: var(--ink-mid); font-size: 13px; --mdc-icon-size: 16px; white-space: nowrap; } + .bat-ctl-grid .ctl-k { min-width: 0; white-space: normal; overflow-wrap: anywhere; } .ctl-k ha-icon { color: var(--ink-dim); flex-shrink: 0; } .ctl-empty { grid-column: 1 / -1; font-size: 12px; line-height: 1.45; } .ctl-toggle { justify-self: start; position: relative; width: 40px; height: 22px; border-radius: 999px; border: 1px solid var(--line-strong); background: var(--bg-2); cursor: pointer; padding: 0; transition: background 0.2s, border-color 0.2s; } @@ -7229,6 +7242,12 @@ class MarstekVenusPanel extends HTMLElement { .ctl-num .ctl-val { font-family: var(--font-display); font-variant-numeric: tabular-nums; font-size: 13px; color: var(--ink); white-space: nowrap; min-width: 56px; text-align: right; } .ctl-btn { grid-column: 1 / -1; display: inline-flex; align-items: center; justify-content: center; gap: 7px; width: 100%; padding: 8px 12px; border-radius: 11px; border: 1px solid var(--line-strong); background: var(--bg-2); color: var(--ink-mid); font-family: var(--font-ui); font-weight: 600; font-size: 13px; cursor: pointer; --mdc-icon-size: 16px; transition: background 0.15s, color 0.15s; } .ctl-btn:hover { background: var(--bg-hover); color: var(--ink); } + @container battery-card (max-width: 380px) { + .bat-ctl-grid { grid-template-columns: minmax(0, 1fr); row-gap: 7px; } + .bat-ctl-grid > .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 ===== */ diff --git a/custom_components/omnibattery/infra/coordinator.py b/custom_components/omnibattery/infra/coordinator.py index eebc129b..3bf65bed 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 @@ -129,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, @@ -158,7 +160,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 @@ -202,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 @@ -327,6 +332,17 @@ 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, + ) + 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, @@ -626,6 +642,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 +650,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 +659,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): @@ -1633,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 738c313d..1a86d417 100644 --- a/custom_components/omnibattery/sensor.py +++ b/custom_components/omnibattery/sensor.py @@ -362,8 +362,24 @@ 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"), + ("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 @property def device_info(self): @@ -530,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), @@ -819,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 @@ -870,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]: @@ -881,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 ] @@ -979,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 635abcc7..d61bf437 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": { @@ -1469,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" }, @@ -1943,6 +2043,12 @@ }, "input_limit": { "name": "Input Limit" + }, + "battery_current": { + "name": "Battery Current" + }, + "fronius_sunspec_model_type": { + "name": "SunSpec Model Type" } }, "binary_sensor": { diff --git a/custom_components/omnibattery/switch.py b/custom_components/omnibattery/switch.py index 7d54bdfc..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,9 +78,15 @@ 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"): + if coordinator.brand not in ("zendure", "anker", "fronius_gen24"): entities.append(BatteryFullChargeVoltageTaperSwitch(hass, entry, controller, coordinator)) # Add manual mode switch (system-level, always present) @@ -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 97e1b5a2..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" }, @@ -1939,6 +1942,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 e9ad2783..6fa3e5a6 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)", @@ -1465,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" }, @@ -1939,6 +2048,12 @@ }, "input_limit": { "name": "Eingangslimit" + }, + "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 99516c82..674d9dfd 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": { @@ -1493,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" }, @@ -1949,6 +2049,12 @@ }, "input_limit": { "name": "Input Limit" + }, + "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..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" }, @@ -1939,6 +1942,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..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" }, @@ -1952,6 +1955,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..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" }, @@ -1939,6 +1942,9 @@ }, "input_limit": { "name": "Ingangslimiet" + }, + "fronius_sunspec_model_type": { + "name": "SunSpec-modeltype" } }, "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..139ad116 --- /dev/null +++ b/site-docs/configuration/batteries/fronius-gen24.es.md @@ -0,0 +1,81 @@ +# 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`. + +## Detección del modelo SunSpec + +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. + +## 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 +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..7a8a46b9 --- /dev/null +++ b/site-docs/configuration/batteries/fronius-gen24.md @@ -0,0 +1,74 @@ +# 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`. + +## SunSpec model detection + +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. + +## 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. +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_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 new file mode 100644 index 00000000..07689920 --- /dev/null +++ b/tests/test_fronius_gen24_driver.py @@ -0,0 +1,738 @@ +"""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 unittest import mock +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]] = [] + self.multi_writes: list[tuple[int, list[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 + + 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: + 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 + 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": [], + } + } + } +} + + +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, + [(40355, 0), (40356, 0), (40348, 3), (40355, 0), (40356, 0)], + ) + + 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, + 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], + [(40365, 1002), (40366, 64535), (40358, 3), (40365, 1002), (40366, 64535)], + ) + + 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(idle.net_power_w, 0) + self.assertEqual(idle.mode, "idle") + self.assertEqual( + [(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)], + ) + + 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 + 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)], + ) + self.assertEqual( + fake.multi_writes, + [(40365, [64535, 1001]), (40365, [64535, 1001])], + ) + + def test_subminimum_request_is_suppressed_to_idle(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, 0), (40366, 0), (40358, 3), (40365, 0), (40366, 0)], + ) + + def test_material_discharge_is_not_suppressed(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, -750) + + def test_idle_uses_closed_external_window(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, 0), (40366, 0), (40358, 3), (40365, 0), (40366, 0)], + ) + + def test_discharge_at_max_soc_remains_available(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)) + + self.assertTrue(result.ok) + self.assertEqual(result.net_power_w, -250) + + 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, + [(40365, 2002), (40366, 63535), (40358, 3), (40365, 2002), (40366, 63535)], + ) + + 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, + [(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__": + unittest.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