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(`